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}} + */ +export function resolveCallerModePolicy(policy) { + if (typeof policy === 'string') { + const found = CALLER_MODE_POLICIES[policy]; + if (!found) throw new Error(`Unknown caller mode policy: ${policy}`); + return { id: policy, ...found }; + } + const spec = Array.isArray(policy) ? { allowedModes: policy } : policy; + if (!spec || typeof spec !== 'object') throw new Error('Caller mode policy must be a policy name, a mode array, or a policy object'); + const allowedModes = normalizedModes(spec.allowedModes); + if (allowedModes.length === 0) throw new Error('Caller mode policy must allow at least one of: ' + EXECUTION_MODES.join(', ')); + return { id: null, allowedModes, requiredModelCapabilities: normalizedCapabilities(spec.requiredModelCapabilities) }; +} + +/** The allowed-mode list for a policy — the value handed to fallback selection. */ +export const allowedModesFor = (policy) => resolveCallerModePolicy(policy).allowedModes; + +/** + * Why this provider record may not serve `policy`, or null when it may. + * + * `modelCapabilities` is the caller's tri-state evidence for the model that + * would actually run: `true` proven, `false` proven absent, missing/null + * unknown. Only `true` satisfies a requirement. + * + * @param {object} provider — a provider record (its `type` is the route mode) + * @param {string|readonly string[]|object} policy + * @param {{modelCapabilities?: object}} [options] + * @returns {{code: string, reason: string}|null} + */ +export function callerModeRejection(provider, policy, { modelCapabilities = null } = {}) { + const { allowedModes, requiredModelCapabilities } = resolveCallerModePolicy(policy); + const mode = routeModeOf(provider); + if (!mode) return { code: 'mode-unknown', reason: `has no executable mode (type: ${provider?.type ?? 'none'})` }; + if (!allowedModes.includes(mode)) { + return { code: 'mode-not-allowed', reason: `runs in ${mode} mode; this caller allows ${allowedModes.join('/')}` }; + } + for (const capability of Object.keys(requiredModelCapabilities)) { + if (modelCapabilities?.[capability] !== true) { + return { code: `capability-${capability}`, reason: `model capability "${capability}" is not proven for this route` }; + } + } + return null; +} + +/** Convenience boolean form of {@link callerModeRejection}. */ +export const isCallerModeEligible = (provider, policy, options) => + callerModeRejection(provider, policy, options) === null; + +/** + * The subset of `providers` this caller may run on, in the input's own order. + * + * Order is preserved because every caller's own preference order (the provider + * list order, a fallback priority list) is meaningful and must survive the + * filter. + */ +export const filterCallerModeEligible = (providers, policy, options) => + (Array.isArray(providers) ? providers : []).filter((provider) => isCallerModeEligible(provider, policy, options)); diff --git a/server/lib/callerModePolicy.test.js b/server/lib/callerModePolicy.test.js new file mode 100644 index 0000000000..a7c98b9693 --- /dev/null +++ b/server/lib/callerModePolicy.test.js @@ -0,0 +1,84 @@ +import { describe, it, expect } from 'vitest'; +import { + CALLER_MODE_POLICIES, + CALLER_MODE_POLICY_IDS, + EXECUTION_MODES, + allowedModesFor, + callerModeRejection, + filterCallerModeEligible, + isCallerModeEligible, + resolveCallerModePolicy, +} from './callerModePolicy.js'; +import { PROVIDER_TYPES } from './aiToolkit/constants.js'; +import { ROUTE_MODES } from './providerHarnesses.js'; + +const cli = { id: 'claude-code', type: 'cli' }; +const tui = { id: 'claude-code-tui', type: 'tui' }; +const api = { id: 'ollama', type: 'api' }; + +describe('callerModePolicy', () => { + it('refuses an unregistered policy name instead of resolving it permissively', () => { + // The dangerous failure is a typo silently becoming "allow everything" — + // exactly the over-permission this module exists to prevent. + expect(() => resolveCallerModePolicy('agent-harnes')).toThrow(/Unknown caller mode policy/); + expect(() => resolveCallerModePolicy({ allowedModes: ['ptty'] })).toThrow(/at least one/); + expect(() => resolveCallerModePolicy([])).toThrow(/at least one/); + }); + + it('keeps CLI and TUI as separate routes a policy can allow independently', () => { + expect(allowedModesFor('agent-harness')).toEqual(['cli', 'tui']); + expect(allowedModesFor('cli-harness')).toEqual(['cli']); + expect(allowedModesFor('direct-api')).toEqual(['api']); + expect(allowedModesFor('any-text')).toEqual([...EXECUTION_MODES]); + // Every registered policy names only real modes. + for (const id of CALLER_MODE_POLICY_IDS) { + expect(CALLER_MODE_POLICIES[id].allowedModes.every((mode) => EXECUTION_MODES.includes(mode))).toBe(true); + } + }); + + it('never lets a CLI-only caller reach a TUI route', () => { + expect(isCallerModeEligible(cli, 'cli-harness')).toBe(true); + expect(callerModeRejection(tui, 'cli-harness')).toMatchObject({ code: 'mode-not-allowed' }); + expect(callerModeRejection(tui, 'cli-harness').reason).toContain('this caller allows cli'); + expect(callerModeRejection(api, 'cli-harness')).toMatchObject({ code: 'mode-not-allowed' }); + }); + + it('refuses a record whose type names no executable mode, under every policy', () => { + for (const id of CALLER_MODE_POLICY_IDS) { + expect(callerModeRejection({ id: 'weird' }, id)).toMatchObject({ code: 'mode-unknown' }); + expect(callerModeRejection({ id: 'weird', type: 'pty' }, id)).toMatchObject({ code: 'mode-unknown' }); + } + }); + + it('does not turn an UNKNOWN required capability into a satisfied one', () => { + const policy = { allowedModes: ['api'], requiredModelCapabilities: { tools: true } }; + // Proven present → eligible. + expect(callerModeRejection(api, policy, { modelCapabilities: { tools: true } })).toBeNull(); + // Proven absent, and — the point of this test — NOT PROBED AT ALL both fail. + expect(callerModeRejection(api, policy, { modelCapabilities: { tools: false } })).toMatchObject({ code: 'capability-tools' }); + expect(callerModeRejection(api, policy, { modelCapabilities: { tools: null } })).toMatchObject({ code: 'capability-tools' }); + expect(callerModeRejection(api, policy, { modelCapabilities: {} })).toMatchObject({ code: 'capability-tools' }); + expect(callerModeRejection(api, policy)).toMatchObject({ code: 'capability-tools' }); + }); + + it('ignores a non-true capability REQUIREMENT so an absent-evidence record cannot satisfy it', () => { + const policy = resolveCallerModePolicy({ allowedModes: ['api'], requiredModelCapabilities: { tools: false, vision: null } }); + expect(policy.requiredModelCapabilities).toEqual({}); + expect(callerModeRejection(api, policy)).toBeNull(); + }); + + it('preserves the caller list order when filtering, so preference order survives', () => { + const list = [api, tui, cli, null, { id: 'x' }]; + expect(filterCallerModeEligible(list, 'agent-harness')).toEqual([tui, cli]); + expect(filterCallerModeEligible(list, ['api'])).toEqual([api]); + expect(filterCallerModeEligible(null, 'any-text')).toEqual([]); + }); + + it('declares the same mode vocabulary as the toolkit record type and the route modes', () => { + // EXECUTION_MODES is declared locally to keep this leaf import-free (see its + // doc comment). That is only safe while it cannot drift from the two places + // that already name the same three values. + expect([...EXECUTION_MODES].sort()).toEqual(Object.values(PROVIDER_TYPES).sort()); + expect([...EXECUTION_MODES]).toEqual([...ROUTE_MODES]); + }); +}); diff --git a/server/lib/cliProviderRun.js b/server/lib/cliProviderRun.js index fb1c2f3dee..c5606b6107 100644 --- a/server/lib/cliProviderRun.js +++ b/server/lib/cliProviderRun.js @@ -22,6 +22,7 @@ import { buildCliArgs, prepareCliPrompt } from './cliProviderArgs.js'; import { killProcessTree, resolveWindowsExecutable, prepareWindowsSafeSpawn, guardChildStdin } from './bufferedSpawn.js'; import { buildCliChildEnv } from './cliChildEnv.js'; import { modelPinIsOffered } from './localProviderRuntime.js'; +import { filterCallerModeEligible } from './callerModePolicy.js'; // How much stderr to hand back to callers. Enough to carry a rate-limit banner // or a stack's first frames, short enough to embed in an error message or a @@ -47,7 +48,9 @@ const stderrTailOf = (stderr) => stderr.trim().slice(-STDERR_TAIL_LIMIT); export function pickCliProvider(providers, config = {}) { const { providerId, model, fallbackId = 'claude-code' } = config || {}; const list = Array.isArray(providers) ? providers : Object.values(providers || {}); - const cli = list.filter((p) => p && p.type === 'cli' && p.enabled !== false); + // 'cli-harness' is the shared name for this caller context — see + // callerModePolicy.js. Same rule the fallback chain and the pickers apply. + const cli = filterCallerModeEligible(list.filter((p) => p?.enabled !== false), 'cli-harness'); if (cli.length === 0) { return { error: 'No enabled CLI provider is configured — add one under AI Providers.' }; } diff --git a/server/lib/importScoping.test.js b/server/lib/importScoping.test.js index de2c8338bc..90126a632f 100644 --- a/server/lib/importScoping.test.js +++ b/server/lib/importScoping.test.js @@ -188,6 +188,10 @@ describe('deferred imports stay deferred (#6156)', () => { // suites that cross `lib/quotaBurnConfig.js`, so a leaf costs ~200 apiece with // nothing to defer. Restore the ~1.5k allowance again rather than inching the // number up by a few hundred per PR. +// +// #6368 adds `lib/callerModePolicy.js`, another zero-dependency leaf reached by +// the routing boundary and the lib barrel (~92 instantiations). Same tolerated +// shape; it fits inside the allowance above. const MAX_STATIC_INSTANTIATIONS = 91400; const SKIP_DIRS = new Set(['node_modules', 'coverage', 'dist', 'data']); diff --git a/server/lib/index.js b/server/lib/index.js index b5128644a4..2b7e6a486b 100644 --- a/server/lib/index.js +++ b/server/lib/index.js @@ -128,6 +128,7 @@ export * from './ansiStrip.js'; // barrel's duplicate-identifier collision check. export * as antigravity from './antigravity.js'; export * as childProcess from './childProcess.js'; +export * from './callerModePolicy.js'; export * from './cliChildEnv.js'; export * from './agentExecutionProfiles.js'; export * from './localEndpoint.js'; diff --git a/server/services/agentProviderResolution.js b/server/services/agentProviderResolution.js index e4cf723219..033c9c2bf5 100644 --- a/server/services/agentProviderResolution.js +++ b/server/services/agentProviderResolution.js @@ -19,6 +19,7 @@ import { getActiveProvider, getAllProviders, getProviderById } from './providers import { isProviderAvailable, getFallbackProvider, getProviderStatus } from './providerStatus.js'; import { selectModelForRole, selectModelForTask } from './agentModelSelection.js'; import { modelPinIsOffered } from '../lib/localProviderRuntime.js'; +import { allowedModesFor, callerModeRejection } from '../lib/callerModePolicy.js'; import { PRIMARY_ORCHESTRATION_ROLE, roleAssignment } from '../lib/orchestrationProfile.js'; import { publicReviewPostureForTask, resolvePublicReviewProvider } from './publicReviewProviderSelection.js'; @@ -128,6 +129,14 @@ async function resolvePublicReviewAgentProvider(task, posture) { return { ok: true, provider, selectedModel, modelSelection }; } +/** + * A CoS agent task needs a harness that can read/write files and run commands, + * so `api` records are ineligible however they are reached — by a task pin, by + * `activeProvider` inheritance, or by the fallback chain. Naming the policy once + * is what keeps the pin gate and the fallback gate from drifting apart. + */ +const AGENT_CALLER_POLICY = 'agent-harness'; + async function resolveOrdinaryProviderAndModel(task) { // A task can pin a specific provider via metadata.provider (e.g. a CoS job's // per-job AI override). Resolve it BEFORE the active-provider availability @@ -195,7 +204,12 @@ async function resolveOrdinaryProviderAndModel(task) { const providersMap = Object.fromEntries(providerList.map((p) => [p.id, p])); const taskFallbackId = task.metadata?.fallbackProvider; const taskFallbackModel = task.metadata?.fallbackModel; - const fallbackResult = await getFallbackProvider(provider.id, providersMap, taskFallbackId, taskFallbackModel); + // The caller's mode policy travels WITH the fallback request, so an + // ineligible candidate is skipped during selection rather than picked and + // then rejected below — which used to burn the one retry the cascade had. + const fallbackResult = await getFallbackProvider(provider.id, providersMap, taskFallbackId, taskFallbackModel, { + allowedModes: allowedModesFor(AGENT_CALLER_POLICY), + }); if (fallbackResult) { emitLog('info', `Using fallback provider: ${fallbackResult.provider.id} (source: ${fallbackResult.source})`, { @@ -221,25 +235,27 @@ async function resolveOrdinaryProviderAndModel(task) { } } - // Harness boundary guard. `api`-type providers (Ollama / LM Studio / kimi over - // HTTP) return plain text with NO filesystem tool harness — they can't - // Read/Write/Edit/Bash, so a CoS agent task resolved onto one would spawn a - // child process that writes nothing to disk. Fail clearly instead. This catches - // an api provider arriving via a task pin OR via the fallback chain (the default - // fallback priority includes lmstudio/ollama). The fix for users: add a CLI - // coding provider — e.g. Claude Ollama, or OpenCode MTPLX when a separate - // MTPLX runtime is already running locally. - if (provider.type === 'api') { + // Harness boundary guard — the LAST line of the same policy the fallback + // request above carries, so it still fires for a provider that never went + // through fallback selection: a task pin or the inherited active provider. + // `api`-type providers (Ollama / LM Studio / kimi over HTTP) return plain text + // with NO filesystem tool harness — they can't Read/Write/Edit/Bash, so a CoS + // agent task resolved onto one would spawn a child process that writes nothing + // to disk. A record with no recognizable mode at all is refused here too. The + // fix for users: add a CLI coding provider — e.g. Claude Ollama, or OpenCode + // MTPLX when a separate MTPLX runtime is already running locally. + const harnessRejection = callerModeRejection(provider, AGENT_CALLER_POLICY); + if (harnessRejection) { return { ok: false, // PERMANENT config error when the DIRECTLY-resolved (pinned/active) provider - // was itself api — no CLI/TUI harness is reachable for this task no matter - // how many times it re-dispatches, so the caller must retire it rather than - // leave it silently re-failing forever. An api provider reached by falling - // back from a CLI primary (directProviderType 'cli') is instead TRANSIENT: - // the primary may recover, so the task stays retryable. - permanent: directProviderType === 'api', - error: `Provider "${provider.id}" is an HTTP API provider with no file-writing harness — CoS agent tasks need a CLI/TUI coding provider (claude, codex, "Claude Ollama", or "OpenCode MTPLX").`, + // was itself ineligible — no CLI/TUI harness is reachable for this task no + // matter how many times it re-dispatches, so the caller must retire it + // rather than leave it silently re-failing forever. An ineligible provider + // reached by falling back from a CLI primary (directProviderType 'cli') is + // instead TRANSIENT: the primary may recover, so the task stays retryable. + permanent: Boolean(callerModeRejection({ type: directProviderType }, AGENT_CALLER_POLICY)), + error: `Provider "${provider.id}" ${harnessRejection.reason} — it has no file-writing harness, and CoS agent tasks need a CLI/TUI coding provider (claude, codex, "Claude Ollama", or "OpenCode MTPLX").`, providerId: provider.id }; } diff --git a/server/services/agentProviderResolution.test.js b/server/services/agentProviderResolution.test.js index 4ca6e2b412..5631db115c 100644 --- a/server/services/agentProviderResolution.test.js +++ b/server/services/agentProviderResolution.test.js @@ -531,4 +531,48 @@ describe('orchestration profiles (#5992)', () => { expect(getProviderById).toHaveBeenCalledWith('p-pinned'); expect(result.provider.id).toBe('p-pinned'); }); + + describe('caller execution-mode policy', () => { + // The same policy has to hold at all three doors, or the one it is missing + // from silently re-admits what the others refused. + it('carries the agent caller policy into fallback selection', async () => { + const primary = { id: 'p1', type: 'cli' }; + const fallback = { id: 'p2', type: 'cli', defaultModel: 'm' }; + getActiveProvider.mockResolvedValue(primary); + isProviderAvailable.mockReturnValue(false); + getProviderStatus.mockReturnValue({ message: 'down', reason: 'x' }); + getAllProviders.mockResolvedValue({ providers: [primary, fallback] }); + getFallbackProvider.mockResolvedValue({ provider: fallback, model: null, source: 'system' }); + await resolveAgentProviderAndModel({ id: 't', metadata: { fallbackProvider: 'p2' } }); + + // An `api` route can never run a CoS agent task, so the chain must not be + // allowed to pick one and burn the single retry it has left. + expect(getFallbackProvider).toHaveBeenCalledWith('p1', expect.any(Object), 'p2', undefined, { + allowedModes: ['cli', 'tui'], + }); + }); + + it('refuses a record whose type names no executable mode, permanently', async () => { + // A provider record edited to an unrecognized type used to sail past the + // `type === 'api'` test and reach spawn, where it dies on an unmapped + // dispatch. It is a config error no retry can fix. + const broken = { id: 'mystery', type: 'pty' }; + getActiveProvider.mockResolvedValue(broken); + const r = await resolveAgentProviderAndModel(TASK); + expect(r).toMatchObject({ ok: false, permanent: true, providerId: 'mystery' }); + expect(r.error).toContain('no file-writing harness'); + expect(selectModelForTask).not.toHaveBeenCalled(); + }); + + it('still admits both harness modes — a TUI pin is a legitimate agent route', async () => { + // Guards the generalization against over-reach: the agent policy allows + // cli AND tui, so tightening it to "cli" would break every TUI-pinned task. + const tuiPin = { id: 'claude-code-tui', type: 'tui', models: ['m-default'] }; + getProviderById.mockResolvedValue(tuiPin); + getActiveProvider.mockResolvedValue({ id: 'other', type: 'cli' }); + const r = await resolveAgentProviderAndModel({ id: 't', metadata: { provider: 'claude-code-tui' } }); + expect(r.ok).toBe(true); + expect(r.provider).toBe(tuiPin); + }); + }); }); diff --git a/server/services/cosLocalEndpointSlots.js b/server/services/cosLocalEndpointSlots.js index 6da4a0ee64..eff36c0eff 100644 --- a/server/services/cosLocalEndpointSlots.js +++ b/server/services/cosLocalEndpointSlots.js @@ -27,6 +27,7 @@ import { SWARM_COUNT_MAX, SWARM_COUNT_MIN } from '../lib/validation.js'; import { localRuntimeForProvider, localEndpointPort, normalizeOpenAiBaseUrl } from '../lib/localProviderRuntime.js'; import { listProviders, getActiveProvider } from './providers.js'; import { isProviderAvailable, getFallbackProvider } from './providerStatus.js'; +import { allowedModesFor } from '../lib/callerModePolicy.js'; /** * The raw base URL a provider's inference actually goes to, local or not. @@ -203,7 +204,10 @@ export async function buildLocalEndpointSlotContext() { primaryId, providersMap, task?.metadata?.fallbackProvider ?? null, - task?.metadata?.fallbackModel ?? null + task?.metadata?.fallbackModel ?? null, + // Same caller mode policy `resolveAgentProviderAndModel` sends, or this + // prediction would follow a swap onto a route spawn will refuse. + { allowedModes: allowedModesFor('agent-harness') } )?.provider ?? null, }); } diff --git a/server/services/promptRunner.js b/server/services/promptRunner.js index 0df1b95afc..5a677d692f 100644 --- a/server/services/promptRunner.js +++ b/server/services/promptRunner.js @@ -43,6 +43,7 @@ import { createSingleFlight } from '../lib/singleFlight.js'; import { extractJson } from '../lib/jsonExtract.js'; import { isCreativeRunSource, withCreativeLatitude } from '../lib/creativeLatitude.js'; import { DEFAULT_OUTPUT_RESERVE_TOKENS, estimateTokens } from '../lib/contextBudget.js'; +import { allowedModesFor, callerModeRejection } from '../lib/callerModePolicy.js'; // The fallback-lifecycle notifiers live in services/autoFixer.js, which // transitively pulls in services/cos.js (PM2 + fs + sockets). Importing it @@ -70,13 +71,16 @@ export const DEFAULT_TIMEOUT_MS = 300000; const API_TIMEOUT_BACKSTOP_GRACE_MS = 2000; const APPEND_CHUNK = (acc, chunk) => acc + (typeof chunk === 'string' ? chunk : (chunk?.text || '')); -export function buildRequestCapabilities({ prompt, screenshots, outputReserveTokens } = {}) { +export function buildRequestCapabilities({ prompt, screenshots, outputReserveTokens, callerPolicy = null } = {}) { const reserve = Number.isFinite(Number(outputReserveTokens)) ? Math.max(0, Number(outputReserveTokens)) : DEFAULT_OUTPUT_RESERVE_TOKENS; return { requiredContextTokens: estimateTokens(prompt) + reserve, hasImages: Array.isArray(screenshots) && screenshots.length > 0, + // Only present when the caller declared a mode policy, so an undeclared + // caller keeps routing across every mode exactly as it always has. + ...(callerPolicy ? { allowedModes: allowedModesFor(callerPolicy) } : {}), }; } @@ -551,6 +555,15 @@ export function assertVisionRunUsedImages(result, requestedProvider) { * must pass this — without it, the CLI/TUI spawn lands in PortOS's own * cwd and the analysis runs against the wrong files. No-op for API * providers (no spawn). + * @param {string|string[]|object} [args.callerPolicy] — this caller's + * EXECUTION-MODE policy (a name from `lib/callerModePolicy.js`, a mode array, + * or an inline policy object). Set it when the call site genuinely cannot run + * every mode — a context with no PTY to drive a TUI, or one that must stay on + * a harness-free direct API provider. The policy gates the EXPLICIT provider + * (422 `PROVIDER_MODE_NOT_PERMITTED`, before any run record is written) and + * rides along on `requestCapabilities` so createRun's proactive swap and the + * Tier-3 retry fallback skip ineligible candidates too. Omit (the default) and + * nothing is constrained. * @param {boolean} [args.allowFallback=true] — set false when provider/model * identity is part of the feature contract. Disables proactive provider * substitution and every model/provider retry tier after the first attempt. @@ -1159,8 +1172,19 @@ async function executeProviderRunOnce({ cwd: cwdOverride, screenshots = [], outputReserveTokens, + callerPolicy = null, allowFallback = true, }) { + // Caller EXECUTION-MODE policy, enforced on the EXPLICIT provider before a run + // record exists and carried into fallback selection below, so the pin and the + // route that might replace it are judged by one rule. + const pinRejection = callerPolicy ? callerModeRejection(provider, callerPolicy) : null; + if (pinRejection) { + throw new ServerError( + `Provider "${provider.id}" ${pinRejection.reason} — this caller cannot run it.`, + { status: 422, code: 'PROVIDER_MODE_NOT_PERMITTED' }, + ); + } if (screenshots.length > 0 && provider.type !== PROVIDER_TYPES.API && !isVisionCapableCliProvider(provider)) { @@ -1203,7 +1227,7 @@ async function executeProviderRunOnce({ source, workspacePath: effectiveCwd, effort, - requestCapabilities: buildRequestCapabilities({ prompt, screenshots, outputReserveTokens }), + requestCapabilities: buildRequestCapabilities({ prompt, screenshots, outputReserveTokens, callerPolicy }), allowFallback, }); runId = runResult.runId; diff --git a/server/services/promptRunner.test.js b/server/services/promptRunner.test.js index 674f7f6d31..24a5eb8731 100644 --- a/server/services/promptRunner.test.js +++ b/server/services/promptRunner.test.js @@ -232,6 +232,36 @@ describe('promptRunner — happy paths', () => { })).toEqual({ hasImages: false, requiredContextTokens: 1_002 }); }); + it('refuses an ineligible EXPLICIT provider under a caller mode policy, before any run record', async () => { + // A CLI/API-only caller must not run a TUI route it was handed directly — + // and it must fail before createRun, or a refused call still leaves a run + // record claiming a provider that never executed. + await expect(runPromptThroughProvider({ + provider: tuiProvider(), + prompt: 'p', + source: 'test', + callerPolicy: 'cli-harness', + })).rejects.toMatchObject({ code: 'PROVIDER_MODE_NOT_PERMITTED' }); + expect(runner.createRun).not.toHaveBeenCalled(); + expect(runner.executeCliRun).not.toHaveBeenCalled(); + }); + + it('sends the caller mode policy to fallback selection alongside the request budget', async () => { + runner.executeApiRun.mockImplementation(async ({ onComplete }) => onComplete({ success: true })); + + await runPromptThroughProvider({ + provider: apiProvider(), prompt: 'p', source: 'test', callerPolicy: 'direct-api', + }); + + // Carried on requestCapabilities so createRun's PROACTIVE swap is bound by + // the same rule the explicit pin was — not just the pin. + expect(runner.createRun).toHaveBeenCalledWith(expect.objectContaining({ + requestCapabilities: expect.objectContaining({ allowedModes: ['api'] }), + })); + // An undeclared caller keeps the unconstrained shape it always had. + expect(buildRequestCapabilities({ prompt: 'p', screenshots: [] }).allowedModes).toBeUndefined(); + }); + it('defaults screenshots to [] when omitted', async () => { runner.executeApiRun.mockImplementation(async ({ onComplete }) => onComplete({ success: true })); diff --git a/server/services/providerStatus.js b/server/services/providerStatus.js index b2d72dd7bc..32f2ee9b01 100644 --- a/server/services/providerStatus.js +++ b/server/services/providerStatus.js @@ -110,9 +110,16 @@ export async function markProviderAvailable(providerId) { /** * Get the best available fallback provider. * Returns `{ provider, source, model }` (or null if no fallback is available). + * + * `requestCapabilities` carries the caller's constraints for THIS request — + * `hasImages` / `requiredContextTokens`, and `allowedModes` (the caller's + * execution-mode policy from `lib/callerModePolicy.js`, applied to every + * candidate tier). It was previously dropped by this wrapper, so an agent caller + * could only enforce a policy on the pin it resolved itself and not on the + * fallback that might replace it. */ -export function getFallbackProvider(primaryProviderId, providers, taskFallbackId = null, taskFallbackModelId = null) { - return getProviderStatusService().getFallbackProvider(primaryProviderId, providers, taskFallbackId, taskFallbackModelId); +export function getFallbackProvider(primaryProviderId, providers, taskFallbackId = null, taskFallbackModelId = null, requestCapabilities = null) { + return getProviderStatusService().getFallbackProvider(primaryProviderId, providers, taskFallbackId, taskFallbackModelId, requestCapabilities); } /** diff --git a/server/services/voice/tools/code.js b/server/services/voice/tools/code.js index 7eb9ee2f8e..ec433e3e2b 100644 --- a/server/services/voice/tools/code.js +++ b/server/services/voice/tools/code.js @@ -4,6 +4,7 @@ // imported lazily inside execute() to keep this module's load graph light. import { getVoiceConfig } from '../config.js'; +import { isCallerModeEligible } from '../../../lib/callerModePolicy.js'; // Code-agent delegation — software-engineering requests and explicit // "have …" phrasing. The ambiguous verbs (implement/debug/rewrite/ @@ -91,7 +92,9 @@ export const CODE_TOOLS = [ // copy if none exists. A pin that doesn't resolve to a known provider is // left as-is (the spawner surfaces the unknown-provider error). const { getActiveProvider, getAllProviders, getProviderById } = await import('../../providers.js'); - const isCodeCapable = (p) => p?.type === 'cli' || p?.type === 'tui'; + // Same named policy the CoS agent resolver and the fallback chain apply, + // so "can this route run agent work?" has one answer across all three. + const isCodeCapable = (p) => isCallerModeEligible(p, 'agent-harness'); const candidate = provider ? await getProviderById(provider).catch(() => null) : await getActiveProvider().catch(() => null);