diff --git a/client/src/components/ProviderModelSelector.test.jsx b/client/src/components/ProviderModelSelector.test.jsx
index 6ff4c0e1be..f690921c66 100644
--- a/client/src/components/ProviderModelSelector.test.jsx
+++ b/client/src/components/ProviderModelSelector.test.jsx
@@ -7,6 +7,7 @@ const getToolUseModels = vi.fn();
vi.mock('../services/apiLocalLlm', () => ({ getToolUseModels: (...a) => getToolUseModels(...a) }));
import ProviderModelSelector from './ProviderModelSelector';
+import { providerModeSelectionPolicy } from '../utils/providers.js';
import { __resetToolUseModelIdsCache } from '../hooks/useToolUseModelIds.js';
import SHIPPED_PROVIDERS from '../../../data.reference/providers.json';
@@ -171,6 +172,34 @@ describe('ProviderModelSelector', () => {
expect([...effortSelect.options].map((option) => option.value)).toEqual(['', 'low']);
});
+it('keeps a saved TUI pin visible with a reason under a CLI/API-only caller policy', () => {
+ // Acceptance for #6368: an ineligible saved value is never hidden and never
+ // silently replaced — the user must be able to SEE what is pinned and why it
+ // cannot run before choosing something else. The sibling CLI route stays a
+ // separate, selectable option, so collapsing the pair would fail this too.
+ renderSelector({
+ providers: [
+ { id: 'claude-code', name: 'Claude Code', type: 'cli' },
+ { id: 'claude-code-tui', name: 'Claude Code TUI', type: 'tui' },
+ { id: 'ollama', name: 'Ollama', type: 'api' },
+ ],
+ selectedProviderId: 'claude-code-tui',
+ availableModels: [],
+ selectionPolicy: providerModeSelectionPolicy('cli-harness'),
+ });
+ const providerSelect = screen.getAllByRole('combobox')[0];
+ const options = [...providerSelect.options];
+ // The eligible CLI route and the INELIGIBLE SAVED PIN are both offered;
+ // an ineligible route nobody pinned is simply not offered at all.
+ expect(options.map((option) => option.value)).toEqual(['claude-code', 'claude-code-tui']);
+ expect(options.find((option) => option.value === 'claude-code').disabled).toBe(false);
+ const pinned = options.find((option) => option.value === 'claude-code-tui');
+ expect(pinned.disabled).toBe(true);
+ expect(pinned.textContent).toMatch(/not permitted/i);
+ // The select still SHOWS the saved pin rather than snapping to a legal one.
+ expect(providerSelect.value).toBe('claude-code-tui');
+ });
+
it('keeps a disallowed saved model visible only as a disabled stale option', () => {
renderSelector({
providers: [{ id: 'local', name: 'Local' }],
diff --git a/client/src/components/cos/tabs/schedule/AppOverrideRow.jsx b/client/src/components/cos/tabs/schedule/AppOverrideRow.jsx
index 19d21379cd..5165a6239f 100644
--- a/client/src/components/cos/tabs/schedule/AppOverrideRow.jsx
+++ b/client/src/components/cos/tabs/schedule/AppOverrideRow.jsx
@@ -7,6 +7,7 @@ import { isCronExpression, describeCron } from '../../../../utils/cronHelpers';
import ToggleSwitch from '../../../ToggleSwitch';
import useFieldDraft from '../../../../hooks/useFieldDraft';
import { INTERVAL_LABELS, setMetadataOverride } from './scheduleConstants';
+import { providerModeSelectionPolicy } from '../../../../utils/providers.js';
const AppOverrideRow = memo(function AppOverrideRow({ app, taskType, globalIntervalType, globalTaskMetadata, managedAgentOptions, fileIssuesCapable, defaultFileIssues, doWorkRequiresWorktree, inheritedProviderText, providers, providersLoaded = true, override, onUpdate }) {
const [updating, setUpdating] = useState(false);
@@ -215,7 +216,7 @@ const AppOverrideRow = memo(function AppOverrideRow({ app, taskType, globalInter
provider.type === 'api' } : undefined}
+ selectionPolicy={taskType === 'issue-watcher' ? providerModeSelectionPolicy('direct-api') : undefined}
loading={!providersLoaded}
providerId={override?.providerId}
model={override?.model}
diff --git a/client/src/components/cos/tabs/schedule/TaskModelQuickControls.jsx b/client/src/components/cos/tabs/schedule/TaskModelQuickControls.jsx
index 0fcf25025d..7100f4a757 100644
--- a/client/src/components/cos/tabs/schedule/TaskModelQuickControls.jsx
+++ b/client/src/components/cos/tabs/schedule/TaskModelQuickControls.jsx
@@ -1,4 +1,5 @@
import ProviderModelSelector from '../../../ProviderModelSelector';
+import { providerModeSelectionPolicy } from '../../../../utils/providers.js';
// Compact provider/model/effort pins rendered directly on a schedule card, so
// the common "point this task at a different model and run it" loop doesn't
@@ -30,7 +31,7 @@ export default function TaskModelQuickControls({ pins, providers, loading = fals
alwaysShowModel
compact
highlightToolUse={!toolFree}
- selectionPolicy={toolFree ? { provider: (provider) => provider.type === 'api' } : undefined}
+ selectionPolicy={toolFree ? providerModeSelectionPolicy('direct-api') : undefined}
loading={loading}
disabled={disabled || saving}
/>
diff --git a/client/src/utils/README.md b/client/src/utils/README.md
index f90f4909c0..712679479a 100644
--- a/client/src/utils/README.md
+++ b/client/src/utils/README.md
@@ -58,7 +58,7 @@ is where the server parity tests pin each browser mirror.
| `providerGateways` | `PROVIDER_GATEWAYS` / `gatewayForProvider` / `isGatewayBackedProvider` — the hosted OpenAI-compatible gateways (`orcarouter`, `openrouter`) an OpenCode CLI/TUI wrapper can front-end, inheriting its API key at spawn time from the sibling API provider whose id equals the gateway id. Reads the generic `gatewayBacked` marker and, forever, the legacy per-gateway boolean. MIRROR of `server/lib/providerGateways.js`, which the browser cannot import; `server/lib/providerGateways.parity.test.js` reads this file as text and fails when the copies drift. |
| `providerEndpoints` | WHERE a provider's endpoint points. `isLocalEndpoint` (loopback), `isPrivateNetworkEndpoint` (RFC1918 / tailnet address, or a `.local` / `.ts.net` / single-label host — somewhere an unauthenticated OpenAI-compatible server is a normal setup rather than a missing API key), `isLocalInstanceProvider` (talks to a daemon on THIS machine; mirror of `server/lib/localProviderRuntime.js#isLocalInstanceEndpoint` — gate anything that explains a provider by inspecting the host PortOS runs on), `isFleetProvider` / `isFleetHostConfigured` (another machine inside the private network), and `localBackendForProvider` (`'ollama'` / `'lmstudio'` / `null` by id, endpoint or name — it matches by NAME, so pair it with `isLocalInstanceProvider` before claiming this machine's install state). |
| `providerModels` | Which MODEL and EFFORT a run resolves to, and what a picker may list. The configured-default sentinels (`CODEX_CONFIGURED_DEFAULT` …, `isConfiguredDefaultModel`, `configuredDefaultIn` — so a picker can render an option matching a sentinel-valued tier instead of a blank select) and `filterSelectableModels`; the per-CLI effort ladders, `effortLevelsForProvider`, `resolveCliEffort` (what a stored effort actually runs as, so the picker can name a clamped level), `effortSurvivingModel` and `seedModelEffort`; the Antigravity base-model ↔ effort-suffix split (`splitAntigravityModel`, `antigravityBaseModels`, `antigravityModelEffortLevels`, `withStaleAntigravityPin`); the account-aware option list (`providerModelList`, `resolveProviderModelOptions`, `effortAwareModelOptions`, `MODEL_SOURCE` — the one place a Codex-subscription account catalog enters a picker); `effectiveModelFor`; `mergeModelLists` / `mergeProviderUpdate`; and `generationControlsFor` (which temperature / top-p / thinking controls a provider forwards at all). Mirror of `server/lib/providerModels.js` (and `server/lib/opencodeConfig.js` for the controls); `server/lib/providerModels.mirror.test.js` reads this file as text and fails when a mirrored declaration drifts. |
-| `providerSelection` | Which providers and models a picker may OFFER on this install: `isProviderHardwareCompatible` / `isProviderModelHardwareCompatible` / `filterHardwareCompatibleProviderModels` (server-annotated, fail-open), `selectableProviders` (the single rule `ProviderModelSelector` renders from), `filterRunnableProviders` (agent jobs need a CLI/TUI harness), the fail-closed tool-free local policy (`TOOL_FREE_LOCAL_PROVIDER_IDS`, `TOOL_FREE_LOCAL_TEXT_CAPABILITIES`, `isToolFreeLocalProvider`, `isToolFreeLocalModel`, `toolFreeLocalSelectionPolicy`) used by security-sensitive pickers, and the pr-reviewer postures (`PUBLIC_REVIEW_NO_TOOL_POSTURE` / `PUBLIC_REVIEW_ACTIONS_POSTURE`, `supportsPublicReviewPosture`, `enforcesPublicReviewPosture`, `publicReviewSelectionPolicy` — mirror of `server/lib/agentExecutionProfiles.js`: a pipeline stage names a POSTURE, the server publishes each provider's `publicReviewPostures` and `publicReviewEnforcedPostures` on `GET /api/providers`, and the picker offers exactly the providers this install can run the stage on, so no vendor is named on either side). |
+| `providerSelection` | Which providers and models a picker may OFFER on this install: `isProviderHardwareCompatible` / `isProviderModelHardwareCompatible` / `filterHardwareCompatibleProviderModels` (server-annotated, fail-open), `selectableProviders` (the single rule `ProviderModelSelector` renders from), `filterRunnableProviders` (agent jobs need a CLI/TUI harness), the fail-closed tool-free local policy (`TOOL_FREE_LOCAL_PROVIDER_IDS`, `TOOL_FREE_LOCAL_TEXT_CAPABILITIES`, `isToolFreeLocalProvider`, `isToolFreeLocalModel`, `toolFreeLocalSelectionPolicy`) used by security-sensitive pickers, and the pr-reviewer postures (`PUBLIC_REVIEW_NO_TOOL_POSTURE` / `PUBLIC_REVIEW_ACTIONS_POSTURE`, `supportsPublicReviewPosture`, `enforcesPublicReviewPosture`, `publicReviewSelectionPolicy` — mirror of `server/lib/agentExecutionProfiles.js`: a pipeline stage names a POSTURE, the server publishes each provider's `publicReviewPostures` and `publicReviewEnforcedPostures` on `GET /api/providers`, and the picker offers exactly the providers this install can run the stage on, so no vendor is named on either side). Also the caller EXECUTION-MODE mirror (`CALLER_MODE_POLICY_MODES`, `callerModeList`, `providerModeSelectionPolicy`) of `server/lib/callerModePolicy.js` — the server enforces the policy on the pin, on `activeProvider` inheritance and on every fallback candidate; this only lets a picker SHOW the rule, keeping an ineligible saved value visible-but-disabled instead of hidden or silently replaced (parity: `providerModePolicy.parity.test.js`). |
| `localModelHeuristics` | What an untyped LOCAL model can do, judged from its id: `isEmbeddingModel` / `isVisionModel` / `isToolUseModel` (regexes mirrored from `server/lib/localModelHeuristics.js`; `server/lib/localModelHeuristics.mirror.test.js` reads this file as text and fails when a pattern stops matching the same ids), `isVisionCapableCliProvider`, `filterGenerationModels` (sentinels and embedding-only models dropped), `visionLocalModelFilter` and `localToolUseHint` / `withToolUseOptionLabel` (unioned with the server's authoritative per-provider capability maps from `useVisionModelIds` / `useToolUseModelIds`; tool-use is an annotation plus warning, never a filter), and `modelCapabilityInfo` (capability badges for a selected model without over-sharing a local runtime's answer with another provider). |
| `providerContextWindows` | How large a context window a provider/model gets for planning and where the number came from: the vendor constants and the `KNOWN_MODEL_CONTEXT_WINDOWS` ladder (`knownModelContextWindow`, `knownProviderContextWindow`), `catalogModelContextWindow` (the window the provider's own `/models` catalog reported), `resolveModelContextWindow` → `{ tokens, source }` with `CONTEXT_WINDOW_SOURCE` (`override` / `reported` / `assumed` — the state a card has to label rather than print as fact), `effectiveModelContextWindow`, and `modelOptionLabel` ("id (32K ctx)"). Mirror of the ladder in `server/services/stageRunner.js`; `server/services/stageRunner.mirror.test.js` reads this file as text and fails when the two resolve differently. |
| `providerReadiness` | Is a provider READY to run on this install, and why not. `credentialSource` (stored / inherited / env / subscription / none) and `providerCardState(provider, { runtime, status, keySetFor, envVarSet, codexAccount })` + `PROVIDER_CARD_STATE` — ready, benched, blocked on a missing prerequisite (CLI not installed / API key absent / empty credential env var / signed-out ChatGPT account), unknown, or simply switched off. Reads the SERVER's `missingPrerequisites` (published per provider on `GET /api/providers` from `server/lib/providerPrerequisites.js`, the same computation `getFallbackProvider` routes on) and adds only what the browser can see; unknown lookup values mean "not probed", never "missing". Distinct from `ProviderReadiness` / `GET /api/providers/readiness`, which probes the local daemon behind a provider. Also `isRunnerAllowedCommand(command, allowedCommands)` — would the CoS Agent Runner accept this command? Mirrors only the normalization in `server/cos-runner/allowedCommands.js` (the list arrives as `runnerAllowedCommands` on `GET /api/providers`); returns `null` for "list not fetched" so a failed fetch never renders a warning; pinned by `server/cos-runner/allowedCommands.parity.test.js`. `resolvesOutsidePortosPath` / `providerRuntimeKey` (the key a CLI runtime is published under by `GET /api/providers/runtimes`), `supportsModelRefresh` (`canRefreshModels === true`, the server's own answer) and `codexRoutingAdvisory` (the non-blocking "your `~/.codex/config.toml` re-points Codex" notice — server-only and machine-local, rendered in the UI and nowhere else). |
diff --git a/client/src/utils/providerModePolicy.parity.test.js b/client/src/utils/providerModePolicy.parity.test.js
new file mode 100644
index 0000000000..97d8a4ae93
--- /dev/null
+++ b/client/src/utils/providerModePolicy.parity.test.js
@@ -0,0 +1,37 @@
+import { describe, it, expect } from 'vitest';
+import { CALLER_MODE_POLICIES } from '../../../server/lib/callerModePolicy.js';
+import { CALLER_MODE_POLICY_MODES, callerModeList, providerModeSelectionPolicy } from './providerSelection.js';
+
+/**
+ * The browser mirror only decides what a PICKER offers; the server decides what
+ * actually runs. They must agree, or a user saves a route the server refuses at
+ * spawn time (or, worse, the picker hides a route the server would happily run).
+ */
+describe('caller execution-mode policy — client/server parity', () => {
+ it('mirrors every server policy, name for name and mode for mode', () => {
+ expect(Object.keys(CALLER_MODE_POLICY_MODES).sort()).toEqual(Object.keys(CALLER_MODE_POLICIES).sort());
+ for (const [id, { allowedModes }] of Object.entries(CALLER_MODE_POLICIES)) {
+ expect(CALLER_MODE_POLICY_MODES[id]).toEqual([...allowedModes]);
+ }
+ });
+
+ it('permits nothing for an unknown policy name rather than everything', () => {
+ // Fail-closed in the same direction as the server, which throws: a typo has
+ // to be visible, never a silently permissive picker.
+ expect(callerModeList('typo')).toEqual([]);
+ const policy = providerModeSelectionPolicy('typo');
+ expect(policy.provider({ type: 'cli' })).toBe(false);
+ });
+
+ it('offers exactly the caller policy modes', () => {
+ const agent = providerModeSelectionPolicy('agent-harness');
+ expect(agent.provider({ type: 'cli' })).toBe(true);
+ expect(agent.provider({ type: 'tui' })).toBe(true);
+ expect(agent.provider({ type: 'api' })).toBe(false);
+ expect(agent.provider(null)).toBe(false);
+
+ const apiOnly = providerModeSelectionPolicy('direct-api');
+ expect(apiOnly.provider({ type: 'api' })).toBe(true);
+ expect(apiOnly.provider({ type: 'tui' })).toBe(false);
+ });
+});
diff --git a/client/src/utils/providerSelection.js b/client/src/utils/providerSelection.js
index 02989ff6cd..e376281ab3 100644
--- a/client/src/utils/providerSelection.js
+++ b/client/src/utils/providerSelection.js
@@ -183,3 +183,37 @@ export const filterHardwareCompatibleProviderModels = (models, provider) =>
const modelId = typeof model === 'string' ? model : model?.id;
return isProviderModelHardwareCompatible(provider, modelId);
});
+
+/**
+ * Caller EXECUTION-MODE policies, mirrored from
+ * `server/lib/callerModePolicy.js#CALLER_MODE_POLICIES`.
+ *
+ * The server is the authority — it enforces the same policy on the explicit
+ * pin, on `activeProvider` inheritance and on every fallback candidate. This
+ * mirror exists only so a picker can SHOW the rule rather than let a user save
+ * a route the server will refuse at run time. `providerModeSelectionPolicy`
+ * feeds `ProviderModelSelector`'s `selectionPolicy`, which keeps an ineligible
+ * saved value visible-but-disabled with a reason instead of hiding or silently
+ * replacing it. Pinned by `providerModePolicy.parity.test.js`.
+ */
+export const CALLER_MODE_POLICY_MODES = Object.freeze({
+ 'agent-harness': Object.freeze(['cli', 'tui']),
+ 'cli-harness': Object.freeze(['cli']),
+ 'direct-api': Object.freeze(['api']),
+ 'any-text': Object.freeze(['cli', 'tui', 'api']),
+});
+
+/** The allowed-mode list for a policy name, or the array itself when given one. */
+export const callerModeList = (policy) =>
+ (Array.isArray(policy) ? policy : CALLER_MODE_POLICY_MODES[policy]) || [];
+
+/**
+ * A `selectionPolicy` restricting the provider select to one caller's allowed
+ * execution modes. An unknown policy name yields an empty allowed list, which
+ * permits nothing — the same fail-closed direction the server takes, so a typo
+ * surfaces as a visibly blocked picker rather than a silently permissive one.
+ */
+export const providerModeSelectionPolicy = (policy) => {
+ const allowed = callerModeList(policy);
+ return { provider: (provider) => allowed.includes(provider?.type) };
+};
diff --git a/server/lib/README.md b/server/lib/README.md
index 033212fb2e..522c70be0e 100644
--- a/server/lib/README.md
+++ b/server/lib/README.md
@@ -208,6 +208,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `opencodeStream.js` | OpenCode's `--format json` event stream — ONE parser, shared by `services/localModelAgentBenchmark.js` (which wants chars/tokens) and `services/modelCapabilityTests.js` (which wants a readable transcript). `eventPart` (accepts the flat `{part}` and nested `{properties.part}` envelopes OpenCode has both used), `isToolEvent` / `eventText`, `parseAgentLine` / `parseAgentEvents` (blank and unparsable lines yield nothing rather than throwing), `formatAgentEvent` (one frame → a transcript line a person can read, with the path or command the tool acted on), and `summarizeOpenCodeEvents` (assistant chars, tool calls, and output tokens — `null`, never 0, when OpenCode reported no usage; tool ARGUMENTS are deliberately not counted as answer text). Pure. The runner that produces the stream is `services/opencodeTask.js`. |
| `openAiModelsProbe.js` | `probeOpenAiModels(baseUrl, { timeoutMs, apiKey })` → `{ reachable, models, error }` — the one `GET {base}/models` probe for the local OpenAI-compatible daemons, shared by `services/providerReadiness.js` and `services/llamaServerManager.js`. Distinguishes unreachable from reachable-but-unlistable (`models: null`) from up-with-nothing-loaded (`[]`), names the real transport failure via `describeFetchError` (undici reports every one as a bare `fetch failed`), and cancels an unread body on a non-OK response. `apiKey` attaches a Bearer header for a key-gated daemon (vLLM's compose stack), and a 401/403 answers `reachable: true` with `error: 'authentication required'` — a server that refused the request is definitively running, and calling it unreachable would send the user to start it again. Consolidated after the two copies drifted — one passed its timeout as a `timeout` key inside the fetch init object, where it is not an option, silently running a 500ms poll loop on the 15s default. |
| `openAiChatStream.js` | `iterateOpenAiChat(...)` → normalized async content/reasoning chunks and `streamOpenAiChat(...)` → the streamed text — one streaming `POST {base}/chat/completions` against any OpenAI-compatible endpoint; both own timeout/abort composition, retries, parsing, backpressure, and reader cleanup. `streamOllamaChat(...)` is the assessment-only native `/api/chat` transport that preserves Ollama's exact eval counts and nanosecond timings. Also exports `buildMessages`, `parseStreamFrame`, `parseOllamaStreamFrame`, `normalizeUsage` (snake/camel/Ollama token-count keys → `{completionTokens, promptTokens}`, `null` = not reported), and `resolvePartialOutput`. Registering `onStats` on the OpenAI path asks for token counts; a daemon that rejects `stream_options` is retried once without it and remembered per endpoint. Sibling of `openAiModelsProbe.js`. Shared by Ask (`services/askService.js`), `services/localLlmPlayground.js` (provider-backed runs with a `/runs` record), and its `runEndpointLlmTest` (a bare loopback daemon PortOS holds no provider record for, which is how `services/localModelAssessments.js` measures llama.cpp / MTPLX / vLLM). An abort mid-stream throws with `.partialOutput` carrying what already streamed. |
+| `callerModePolicy.js` | The one caller EXECUTION-MODE policy: `CALLER_MODE_POLICIES` (`agent-harness`, `cli-harness`, `direct-api`, `any-text`) plus `resolveCallerModePolicy` / `allowedModesFor` / `callerModeRejection` / `isCallerModeEligible` / `filterCallerModeEligible`. Answers "may THIS caller run on THAT route mode?" for an explicit pin, `activeProvider` inheritance and every fallback candidate alike — `allowedModesFor()` is what rides on `requestCapabilities.allowedModes` into the toolkit's `getFallbackProvider`. Pure and dependency-light so the out-of-process autofixer can import it; `enabled`/benched state, prerequisites and text-transport consent stay with their existing owners. Unknown never becomes true: a record with no executable mode, and a required model capability with no positive evidence, are both refused. |
| `cliChildEnv.js` | The one place the AI-CLI child environment is composed, replacing the hand-rolled copy every spawn site carried — which made each env-level fix an N-file sweep (#3194). `buildCliChildEnv({ baseEnv, before, provider, model, cwd, extra, guard })` returns a COMPLETE env for `spawn`: filters the inherited base to runtime essentials/provider auth, then layers `baseEnv → before → Ollama-Claude defaults → provider.envVars → buildOpencodeEnvVars → extra`, pins `PWD` to `cwd`, strips `CLAUDECODE`, and (with `guard: true`) prepends the pm2 guard shim onto the final `PATH`. The Ollama-Claude layer raises Claude Code's default output ceiling to 65,536 tokens so a thinking-capable local model cannot finish its reasoning past the stock 32K ceiling and die before its final tool call; an explicit provider env value wins. `composeProviderEnv({ before, provider, model, extra })` returns just the ordered provider layers, for sites that build a DELTA someone else bases and spawns (the CoS runner payload, a shell-session overlay). The two slots are not interchangeable: `before` sits UNDER `provider.envVars` (forgeTokenEnv/claudeSettingsEnv, so a provider override still wins), `extra` sits OVER it (TERM/COLORTERM for a PTY). `cliChildEnv.test.js` asserts the composed order per call site and **discovers** any new site that hand-rolls the tuple instead of calling these — so the call-site list stays in the test, not in prose here. |
| `agentExecutionProfiles.js` | Named agent execution postures shared by lifecycle dispatch, CLI/TUI spawners, and child-environment filtering. A stage declares a PROFILE (`PUBLIC_REVIEW_EXECUTION_PROFILE`, `PUBLIC_REVIEW_GATE_EXECUTION_PROFILE`, `PUBLIC_REVIEW_ACTIONS_EXECUTION_PROFILE`); `publicReviewPostureForProfile` maps it to the enforceable POSTURE a provider must carry a maintained recipe for (`no-tool` or `sandboxed-actions`), which is how a pipeline stage stays vendor-agnostic. |
| `localEndpoint.js` | Dependency-light local-instance URL predicates (`isLocalInstanceHost`, `isLocalInstanceEndpoint`, `localEndpointPort`) shared by provider safety policy and local-runtime classification without importing backend configuration or daemon managers. |
diff --git a/server/lib/aiToolkit/providerStatus.js b/server/lib/aiToolkit/providerStatus.js
index 249a94d60a..c4b758ac2c 100644
--- a/server/lib/aiToolkit/providerStatus.js
+++ b/server/lib/aiToolkit/providerStatus.js
@@ -102,6 +102,30 @@ function knownContextWindow(provider, model) {
return planning;
}
+/**
+ * Why this candidate's EXECUTION MODE disqualifies it for the caller, or null.
+ *
+ * `requestCapabilities.allowedModes` is the caller's mode policy — the host
+ * resolves it from `server/lib/callerModePolicy.js` and passes the plain array
+ * down, because this directory stays self-contained. Absent (the default) means
+ * "no mode constraint", so every existing caller routes exactly as before.
+ *
+ * Kept OUT of `capabilityRejection` on purpose. A capability rejection means
+ * "the request itself cannot be satisfied" and throws `NO_ELIGIBLE_FALLBACK` so
+ * the caller learns why; a mode rejection means "this candidate is not for this
+ * caller", which is an ordinary skip — the next candidate may well serve. Making
+ * it throw would turn a routine CLI-only fan-out into a new exception class at
+ * every fallback call site.
+ */
+function modeRejection(provider, requestCapabilities) {
+ const allowed = requestCapabilities?.allowedModes;
+ if (!Array.isArray(allowed) || allowed.length === 0) return null;
+ const mode = provider?.type;
+ if (typeof mode !== 'string' || !mode) return `has no executable mode (type: ${mode ?? 'none'})`;
+ if (!allowed.includes(mode)) return `runs in ${mode} mode; this caller allows ${allowed.join('/')}`;
+ return null;
+}
+
function capabilityRejection(provider, model, requestCapabilities) {
if (!requestCapabilities || typeof requestCapabilities !== 'object') return null;
if (requestCapabilities.hasImages === true && provider?.type !== 'api') {
@@ -482,10 +506,24 @@ export function createProviderStatusService(config = {}) {
// and it hasn't failed lately — neither says its CLI is installed or its
// key is stored. Skipping an un-runnable candidate here is what turns a
// late `spawn ENOENT` into "try the next provider instead".
+ //
+ // `requestCapabilities.allowedModes` is the caller's EXECUTION-MODE policy
+ // (resolved by the host from `lib/callerModePolicy.js`). An ineligible
+ // candidate is skipped, not thrown on — see `modeRejection`.
getFallbackProvider(primaryProviderId, providers, taskFallbackId = null, taskFallbackModelId = null, requestCapabilities = null) {
const capabilityRejections = [];
const acceptCandidate = (provider, source, pinnedModel = null) => {
if (!provider?.enabled || !this.isAvailable(provider.id) || !meetsPrerequisites(provider, providers)) return null;
+ // Caller mode policy, applied to EVERY tier — task-level, configured and
+ // system-priority alike. A configured `fallbackProvider` pointing at a
+ // TUI route is a saved value, not an execution permit: a caller with no
+ // PTY to drive would otherwise inherit one through the very chain that
+ // exists to keep it running.
+ const modeReason = modeRejection(provider, requestCapabilities);
+ if (modeReason) {
+ console.log(`⚠️ Fallback ${provider.id} skipped (${source}): ${modeReason}`);
+ return null;
+ }
// Correct stale pins before checking the selected model's window. A pin
// that no longer exists falls back to the provider default, and THAT
// model must still fit the request before the retry is admitted.
diff --git a/server/lib/aiToolkit/providerStatus.test.js b/server/lib/aiToolkit/providerStatus.test.js
index 68c880fd22..4faee0a9f5 100644
--- a/server/lib/aiToolkit/providerStatus.test.js
+++ b/server/lib/aiToolkit/providerStatus.test.js
@@ -649,6 +649,60 @@ describe('Provider Status Service', () => {
});
});
+ describe('getFallbackProvider — caller execution-mode policy', () => {
+ // A CLI/API-only caller must not inherit a TUI route through ANY tier of
+ // the chain. The configured tier matters most: `fallbackProvider` is a saved
+ // value the user set once, and honoring it regardless of caller context is
+ // how a run with no PTY to drive ends up dispatched at a TUI route.
+ const providers = {
+ primary: { id: 'primary', type: 'cli', enabled: true, fallbackProvider: 'tui-route' },
+ 'tui-route': { id: 'tui-route', type: 'tui', enabled: true },
+ 'task-tui': { id: 'task-tui', type: 'tui', enabled: true },
+ 'fallback-provider-1': { id: 'fallback-provider-1', type: 'tui', enabled: true },
+ 'fallback-provider-2': { id: 'fallback-provider-2', type: 'cli', enabled: true },
+ };
+
+ it('skips a TUI candidate at the task, configured AND system tiers', () => {
+ const result = statusService.getFallbackProvider(
+ 'primary', providers, 'task-tui', null, { allowedModes: ['cli', 'api'] },
+ );
+ expect(result.provider.id).toBe('fallback-provider-2');
+ expect(result.source).toBe('system');
+ });
+
+ it('returns null — never throws — when the policy excludes every candidate', () => {
+ const tuiOnly = {
+ primary: { id: 'primary', type: 'cli', enabled: true, fallbackProvider: 'tui-route' },
+ 'tui-route': { id: 'tui-route', type: 'tui', enabled: true },
+ 'fallback-provider-1': { id: 'fallback-provider-1', type: 'tui', enabled: true },
+ };
+ // A skipped mode is "not for this caller", not "this request is
+ // impossible" — callers already treat a null fallback as transient.
+ expect(statusService.getFallbackProvider(
+ 'primary', tuiOnly, null, null, { allowedModes: ['cli'] },
+ )).toBeNull();
+ });
+
+ it('refuses a candidate whose record names no executable mode', () => {
+ const untyped = {
+ primary: { id: 'primary', type: 'cli', enabled: true, fallbackProvider: 'no-type' },
+ 'no-type': { id: 'no-type', enabled: true },
+ };
+ expect(statusService.getFallbackProvider(
+ 'primary', untyped, null, null, { allowedModes: ['cli', 'tui', 'api'] },
+ )).toBeNull();
+ // …but with NO declared policy it routes exactly as it always has.
+ expect(statusService.getFallbackProvider('primary', untyped).provider.id).toBe('no-type');
+ });
+
+ it('leaves an undeclared caller free to take a TUI route', () => {
+ expect(statusService.getFallbackProvider('primary', providers).provider.id).toBe('tui-route');
+ expect(statusService.getFallbackProvider(
+ 'primary', providers, null, null, { hasImages: false, requiredContextTokens: 10 },
+ ).provider.id).toBe('tui-route');
+ });
+ });
+
describe('getFallbackProvider — stale model pins', () => {
// A `fallbackModel` is set on the PRIMARY but resolved against the
// fallback, so a model bump on the fallback leaves the pin naming an id
diff --git a/server/lib/callerModePolicy.js b/server/lib/callerModePolicy.js
new file mode 100644
index 0000000000..e739153e58
--- /dev/null
+++ b/server/lib/callerModePolicy.js
@@ -0,0 +1,166 @@
+/**
+ * Caller EXECUTION-MODE policy — the one place that answers "may THIS caller
+ * run on THAT provider record's execution mode?" (#6368).
+ *
+ * A provider record's `type` (`cli` / `tui` / `api`) is its executable route
+ * mode, and callers are not interchangeable across them: a CoS agent task needs
+ * a file-writing harness, the standalone autofixer can only drive a headless
+ * CLI, and a tool-free public-review stage must stay on a direct API provider
+ * with no harness authority at all. Before this module each of those rules was
+ * re-derived at its own call site — `provider.type === 'api'` here, a
+ * `filter(p => p.type === 'cli')` there, an inline `selectionPolicy` in three
+ * React components — so a rule applied at the pin was routinely missing from
+ * the fallback chain that could silently replace that pin.
+ *
+ * The policy is deliberately NARROW. It answers mode + required model
+ * capability only. It does NOT re-answer:
+ *
+ * - `enabled` / benched status — owned by `providerStatus.getFallbackProvider`
+ * and the pickers, which already have the runtime state;
+ * - prerequisites (binary on PATH, credential stored) — owned by the host
+ * `prerequisitesMet` hook, which needs I/O this module must not do;
+ * - harness support and text-transport consent — owned by
+ * `routeModeEligibility` in `providerGraphPreview.js`, the declarative half
+ * of the same intersection.
+ *
+ * Keeping those separate is what lets this module stay pure and dependency-light
+ * enough for the out-of-process autofixer to import, and keeps one concern from
+ * quietly overriding another's reason string.
+ *
+ * **Unknown never becomes true.** A record whose `type` names no executable mode
+ * is refused by every policy, and a required model capability with no positive
+ * evidence is refused rather than assumed — an unknown capability is not a
+ * satisfied one. That asymmetry is intentional: an over-permissive answer routes
+ * untrusted content or file-writing authority somewhere the caller said it must
+ * not go, while an over-strict one merely surfaces as "no eligible provider".
+ */
+
+/**
+ * Executable route modes, mirroring a provider record's `type`.
+ *
+ * Declared here rather than imported from `providerHarnesses.js` / the toolkit
+ * constants: this module is reached by the agent resolver, promptRunner and the
+ * out-of-process autofixer, and a widely-reached module must not drag a subtree
+ * it needs one three-string constant from (see "Import scoping" in
+ * server/AGENTS.md). `callerModePolicy.parity.test.js` pins it to both mirrors.
+ */
+export const EXECUTION_MODES = Object.freeze(['cli', 'tui', 'api']);
+
+/** A record's executable route mode, or null when its `type` names none. */
+const routeModeOf = (provider) => (EXECUTION_MODES.includes(provider?.type) ? provider.type : null);
+
+const AGENT_HARNESS_MODES = Object.freeze(['cli', 'tui']);
+
+/**
+ * The named caller contexts PortOS routes for. A caller names one of these
+ * instead of re-deriving a type test, so the rule applied to an explicit pin,
+ * to `activeProvider` inheritance and to every fallback candidate is literally
+ * the same object.
+ *
+ * `requiredModelCapabilities` is an opt-in map of `{ capability: true }`. No
+ * shipped policy declares one — the tool-use signal PortOS publishes today is a
+ * positive allowlist whose non-match means "unrecognized", not "incapable", so
+ * turning it into a hard filter would hide working providers. A caller that has
+ * PROVEN capability evidence can still declare one inline (see
+ * {@link resolveCallerModePolicy}); it is then evaluated under the
+ * unknown-is-not-true rule above.
+ */
+export const CALLER_MODE_POLICIES = Object.freeze({
+ /** CoS agent tasks: needs a harness that can read/write files and run commands. */
+ 'agent-harness': Object.freeze({ allowedModes: AGENT_HARNESS_MODES, requiredModelCapabilities: Object.freeze({}) }),
+ /** Headless one-shot CLI callers (autofixer, calendar MCP sync) — no PTY to drive a TUI. */
+ 'cli-harness': Object.freeze({ allowedModes: Object.freeze(['cli']), requiredModelCapabilities: Object.freeze({}) }),
+ /** Direct HTTP providers only: no harness authority (tool-free review, screened analysis). */
+ 'direct-api': Object.freeze({ allowedModes: Object.freeze(['api']), requiredModelCapabilities: Object.freeze({}) }),
+ /** Ordinary text generation — any executable mode. */
+ 'any-text': Object.freeze({ allowedModes: EXECUTION_MODES, requiredModelCapabilities: Object.freeze({}) }),
+});
+
+/** Policy names, for validation surfaces and tests. */
+export const CALLER_MODE_POLICY_IDS = Object.freeze(Object.keys(CALLER_MODE_POLICIES));
+
+const normalizedModes = (modes) => {
+ const list = Array.isArray(modes) ? modes.filter((mode) => EXECUTION_MODES.includes(mode)) : [];
+ return Object.freeze([...new Set(list)]);
+};
+
+const normalizedCapabilities = (raw) => {
+ if (!raw || typeof raw !== 'object') return Object.freeze({});
+ // Only a `true` requirement is meaningful. `false`/null would read as "this
+ // caller requires the capability to be absent", which nothing asks for and
+ // which an absent-evidence record could satisfy by accident.
+ return Object.freeze(Object.fromEntries(
+ Object.entries(raw).filter(([, required]) => required === true),
+ ));
+};
+
+/**
+ * Normalize any accepted policy spelling to `{ id, allowedModes,
+ * requiredModelCapabilities }`.
+ *
+ * Accepts a registry name, a bare array of modes, or an inline
+ * `{ allowedModes, requiredModelCapabilities }` object. An unregistered NAME
+ * throws: it is a code-level typo, and the alternative — quietly resolving it to
+ * a permissive default — is exactly the silent over-permission this module
+ * exists to prevent. An inline spec that names no valid mode is also a hard
+ * error rather than "allow everything".
+ *
+ * @param {string|readonly string[]|{allowedModes?: readonly string[], requiredModelCapabilities?: object}} policy
+ * @returns {{id: string|null, allowedModes: readonly string[], requiredModelCapabilities: Readonly