Skip to content
Merged
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ Destructive commands (need permission): `zeroshot kill`, `zeroshot clear`, `zero
| Generated graph fixtures | `protocol/openengine-cluster/v1/fixtures/graph/` |
| Generated watch fixtures | `protocol/openengine-cluster/v1/fixtures/watch/` |

Provider-specific settings, defaults, validation, and static capabilities derive from the provider
registry; do not add parallel provider lists. Opt-in native CLI capabilities must keep requested
and effective state distinct and fail closed unless local help/version evidence proves the control.

Cluster Protocol Rust types are the source of truth. Files under
`protocol/openengine-cluster/v1/` are generated projections; update them with
`cargo run -p openengine-cluster-testkit --bin generate-cluster-protocol -- --write` and
Expand Down
1 change: 1 addition & 0 deletions cli/commands/providers.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ async function setupCommand(args) {
mutateSettings((settings) => {
settings.providerSettings = settings.providerSettings || {};
settings.providerSettings[provider] = {
...(settings.providerSettings[provider] || {}),
maxLevel: levelKeys[maxIdx - 1],
minLevel: levelKeys[minIdx - 1],
defaultLevel: levelKeys[defaultIdxNum - 1],
Expand Down
49 changes: 45 additions & 4 deletions cli/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,13 @@
DEFAULT_SETTINGS,
settingsFileExists,
} = require('../lib/settings');
const { VALID_PROVIDERS, normalizeProviderName } = require('../lib/provider-names');
const {
VALID_PROVIDERS,
getProviderMetadata,
normalizeProviderName,
resolveProviderCommand,
} = require('../lib/provider-names');
const { commandExists } = require('../lib/provider-detection');
const { getProvider, parseProviderChunk } = require('../src/providers');
const { readClustersFileSync } = require('../lib/clusters-registry');
const { MOUNT_PRESETS, resolveEnvs } = require('../lib/docker-config');
Expand All @@ -69,6 +75,10 @@
startClusterFromText,
} = require('../lib/start-cluster');
const { requirePreflight } = require('../src/preflight');
const {
createUnsupportedProviderCapabilityError,
serializeTaskStartupError,
} = require('../src/task-startup-error');
const { providersCommand, setDefaultCommand, setupCommand } = require('./commands/providers');
const { runInspectCommand } = require('./commands/inspect');
const { runCmdproof } = require('./commands/cmdproof');
Expand Down Expand Up @@ -2628,7 +2638,7 @@
Force provider flags: -G (GitHub), -L (GitLab), -J (Jira), -D (DevOps), -N (Linear)
`
)
.action(async (inputArg, options) => {

Check warning on line 2641 in cli/index.js

View workflow job for this annotation

GitHub Actions / check

Async arrow function has a complexity of 31. Maximum allowed is 20

Check warning on line 2641 in cli/index.js

View workflow job for this annotation

GitHub Actions / check

Refactor this function to reduce its Cognitive Complexity from 53 to the 15 allowed

Check warning on line 2641 in cli/index.js

View workflow job for this annotation

GitHub Actions / check

Async arrow function has too many lines (158). Maximum allowed is 150
try {
// Normalize options (--ship → --pr → --worktree flags)
normalizeRunOptions(options);
Expand Down Expand Up @@ -2835,16 +2845,43 @@
});
}

function assertRequestedWebSearchCliAvailable(
provider,
settings,
exists = commandExists
) {
const metadata = getProviderMetadata(provider);
if (!metadata.settingsFields.includes('webSearch')) return;
if (settings.providerSettings?.[provider]?.webSearch !== true) return;
const { command } = resolveProviderCommand(provider);
if (exists(command)) return;
throw createUnsupportedProviderCapabilityError(
provider,
'webSearch',
`${metadata.displayName} web search was requested, but the ${command} CLI is not installed. ` +
`${metadata.installInstructions}. Install it or set providerSettings.${provider}.webSearch to false.`
);
}

taskCmd
.command('run <prompt>')
.description('Run a single-agent background task')
.option('-C, --cwd <path>', 'Working directory for task')
.option('--provider <provider>', `Provider to use (${PROVIDER_CHOICES})`)
.option('--model <model>', 'Model id override for the provider')
.option('--model-level <level>', 'Model level override (level1, level2, level3)')
.option('--reasoning-effort <effort>', 'Reasoning effort (low, medium, high, xhigh, max)')
.option('-r, --resume <sessionId>', 'Resume a specific provider session (Claude or Codex)')
.option('-c, --continue', 'Continue the most recent Claude session (claude only)')
.option(
'--reasoning-effort <effort>',
'Reasoning effort override (low, medium, high, xhigh, max)'
)
.option(
'-r, --resume <sessionId>',
'Resume a specific provider session (Claude, Codex, or OpenCode)'
)
.option(
'-c, --continue',
'Continue the most recent provider session (Claude or OpenCode)'
)
.option(
'-o, --output-format <format>',
'Output format: stream-json (default), text, json',
Expand All @@ -2866,6 +2903,7 @@
const providerOverride = normalizeProviderName(
options.provider || process.env.ZEROSHOT_PROVIDER || settings.defaultProvider
);
assertRequestedWebSearchCliAvailable(providerOverride, settings);
await requirePreflight({
requireGh: false, // gh not needed for plain tasks
requireDocker: false, // Docker not needed for plain tasks
Expand All @@ -2878,6 +2916,8 @@
await runTask(prompt, options);
} catch (error) {
console.error('Error:', error.message);
const startupEnvelope = serializeTaskStartupError(error);
if (startupEnvelope) fs.writeSync(process.stderr.fd, `${startupEnvelope}\n`);
process.exit(1);
}
});
Expand Down Expand Up @@ -3121,7 +3161,7 @@
.command('kill-all')
.description('Kill all running tasks and clusters')
.option('-y, --yes', 'Skip confirmation')
.action(async (options) => {

Check warning on line 3164 in cli/index.js

View workflow job for this annotation

GitHub Actions / check

Refactor this function to reduce its Cognitive Complexity from 26 to the 15 allowed
try {
// Get counts first
const orchestrator = await getOrchestrator();
Expand Down Expand Up @@ -3345,7 +3385,7 @@
.command('resume <id> [prompt]')
.description('Resume a failed task or cluster')
.option('-d, --detach', 'Resume in background (daemon mode)')
.action(async (id, prompt, options) => {

Check warning on line 3388 in cli/index.js

View workflow job for this annotation

GitHub Actions / check

Async arrow function has a complexity of 30. Maximum allowed is 20

Check warning on line 3388 in cli/index.js

View workflow job for this annotation

GitHub Actions / check

Refactor this function to reduce its Cognitive Complexity from 38 to the 15 allowed

Check warning on line 3388 in cli/index.js

View workflow job for this annotation

GitHub Actions / check

Async arrow function has too many lines (181). Maximum allowed is 150
let orchestrator = null;
let keepOrchestratorOpen = false;
try {
Expand Down Expand Up @@ -4721,7 +4761,7 @@
}
});

function outputAgent(agent, options) {

Check warning on line 4764 in cli/index.js

View workflow job for this annotation

GitHub Actions / check

Refactor this function to reduce its Cognitive Complexity from 19 to the 15 allowed
if (options.json) {
console.log(JSON.stringify(agent, null, 2));
return;
Expand Down Expand Up @@ -4889,7 +4929,7 @@
}

// Format tool result for display
function formatToolResult(content, isError, toolName, toolInput) {

Check warning on line 4932 in cli/index.js

View workflow job for this annotation

GitHub Actions / check

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed
if (!content) return isError ? 'error' : 'done';

// For errors, show full message
Expand Down Expand Up @@ -5569,7 +5609,7 @@

// Accumulate text and print complete lines only
// Word wrap long lines, aligning continuation with message column
function accumulateText(prefix, sender, text) {

Check warning on line 5612 in cli/index.js

View workflow job for this annotation

GitHub Actions / check

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed
if (!text) return;
const buf = getLineBuffer(sender);

Expand Down Expand Up @@ -6010,6 +6050,7 @@
}

module.exports = {
assertRequestedWebSearchCliAvailable,
applyModelOverrideToConfig,
inspectAgentAttachment,
printAttachableAgentList,
Expand Down
57 changes: 54 additions & 3 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,57 @@ Zeroshot supports two provider shapes:
- Override per run: `zeroshot run ... --provider <provider>`
- Env override: `ZEROSHOT_PROVIDER=codex`

## Opt-in native web search

Native or bundled search is off by default. It can be enabled only for Codex or
OpenCode with strict boolean provider settings:

```json
{
"providerSettings": {
"codex": { "webSearch": true },
"opencode": { "webSearch": true }
}
}
```

| Provider | Setting | Canonical child control | Minimum CLI |
| -------- | ---------------------------------------- | ------------------------------------------------ | ----------- |
| Codex | `providerSettings.codex.webSearch` | `codex exec --config 'web_search="live"' ...` | `0.146.0` |
| OpenCode | `providerSettings.opencode.webSearch` | command environment `OPENCODE_ENABLE_EXA=1` | `1.0.137` |

Both settings default to `false`; absent and explicit `false` settings leave
the child command and environment unchanged. Codex applies the config override
before the prompt and, for a resumed session, before `resume`. OpenCode applies
the environment control to fresh `run` commands and to `run --session` or
`run --continue`.

Enabled mode fails closed before starting the provider when local support
cannot be proved. Codex requires nonempty `codex exec --help` output advertising
`--config` and a parseable version at or above `0.146.0`. OpenCode requires a
parseable version at or above `1.0.137`. Missing, malformed, or older versions
are unsupported. These checks attest only the installed CLI control: they do
not claim provider-account access, backend availability, or network reachability.
Executable probe and build-command output report `supportsWebSearch` separately
from `configuration.webSearch.requested` and `.effective`; effective is true
only after local support proof.

Codex does **not** use `codex exec --search`: current `ExecCli` rejects that
argument, while the top-level TUI flag does not configure noninteractive exec.
The config override above matches the
[Codex TypeScript SDK](https://github.com/openai/codex/blob/main/sdk/typescript/src/exec.ts)
for fresh and resumed commands. OpenCode documents the environment control in
its [web-search tool guide](https://opencode.ai/docs/tools/#websearch); the
[versioned runtime flag](https://github.com/anomalyco/opencode/blob/v1.18.10/packages/opencode/src/effect/runtime-flags.ts)
and [tool registration](https://github.com/anomalyco/opencode/blob/v1.18.10/packages/opencode/src/tool/registry.ts)
show the corresponding bundled control.

Claude, Gemini, Kiro, Copilot, Pi, and Gateway do not declare `webSearch`;
setting it for those providers is rejected. No equally safe, explicit,
support-detectable additive native-search control is established for them.
Permission or tool allowlists authorize already-present tools; they must not be
presented as controls that enable search.

## Gateway Provider

Use `gateway` for OpenAI-compatible or Anthropic-compatible model endpoints.
Expand Down Expand Up @@ -144,9 +195,9 @@ Configured IDs must use Opencode's `provider/model` shape. Nested model paths
such as `openrouter/anthropic/claude-sonnet-4` are accepted; whitespace and
empty path segments are rejected before Opencode is started. Direct agent
`model` fields remain limited to the built-in catalog. Nested Docker tasks
receive only a temporary settings-file projection for the requested level and
model; arbitrary provider settings and environment overlays are not trusted or
forwarded to the provider process.
receive only a temporary settings-file projection for the requested level/model
and an explicitly enabled declared `webSearch`; arbitrary provider settings and
environment overlays are not trusted or forwarded to the provider process.

### Current explicit model IDs

Expand Down
25 changes: 23 additions & 2 deletions src/agent-cli-provider/adapters/codex.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { UnsupportedProviderCapabilityError } from '../errors';
import { getString, isRecord, tryParseJson } from '../json';
import { appendJsonSchemaPrompt, writeStrictOutputSchemaFile } from '../schema';
import {
Expand All @@ -17,6 +18,7 @@ import {
classifyBaseProviderError,
commandSpec,
createParserState,
isCliVersionAtLeast,
optionFeatures,
resolveModelSpecWithConfig,
validateModelIdFromCatalog,
Expand Down Expand Up @@ -46,19 +48,25 @@ function supports(help: string, pattern: RegExp): boolean {
return help ? pattern.test(help) : true;
}

function detectCliFeatures(helpText?: string | null): CodexCliFeatures {
function detectCliFeatures(
helpText?: string | null,
versionText?: string | null
): CodexCliFeatures {
const help = helpText ?? '';
const unknown = !help;
const supportsConfigOverride = supports(help, /--config\b/);
return {
provider: 'codex',
supportsJson: supports(help, /--json\b/),
supportsOutputSchema: supports(help, /--output-schema\b/),
supportsAutoApprove: supports(help, /--dangerously-bypass-approvals-and-sandbox\b/),
supportsCwd: supports(help, /\s-C\b/) || supports(help, /--cwd\b/),
supportsConfigOverride: supports(help, /--config\b/),
supportsConfigOverride,
supportsModel: supports(help, /\s-m\b/) || supports(help, /--model\b/),
supportsSkipGitRepoCheck: supports(help, /--skip-git-repo-check\b/),
supportsResume: supports(help, /\bresume\b/),
supportsWebSearch:
!unknown && supportsConfigOverride && isCliVersionAtLeast(versionText, '0.146.0'),
unknown,
};
}
Expand Down Expand Up @@ -178,6 +186,18 @@ function collectWarnings(options: BuildProviderCommandOptions): WarningMetadata[
return warnings;
}

function addWebSearchArgs(args: string[], options: BuildProviderCommandOptions): void {
if (options.webSearch !== true) return;
if (optionFeatures(options).supportsWebSearch !== true) {
throw new UnsupportedProviderCapabilityError(
'codex',
'webSearch',
'Codex web search requires nonempty `codex exec --help` support for `--config` and a parseable Codex CLI version >= 0.146.0. Update @openai/codex or set providerSettings.codex.webSearch to false.'
);
}
args.push('--config', 'web_search="live"');
}

function buildCommand(context: string, options: BuildProviderCommandOptions = {}): CommandSpec {
if (options.resumeSessionId && optionFeatures(options).supportsResume === false) {
throw new Error(
Expand All @@ -190,6 +210,7 @@ function buildCommand(context: string, options: BuildProviderCommandOptions = {}
options.resumeSessionId && optionFeatures(options).supportsResume !== false
? options.resumeSessionId
: null;
addWebSearchArgs(args, options);
if (resumeSessionId) {
args.push('resume');
}
Expand Down
14 changes: 14 additions & 0 deletions src/agent-cli-provider/adapters/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,17 @@ export function optionFeatures(
): CliFeatureOverrides {
return options?.cliFeatures ?? {};
}

export function isCliVersionAtLeast(versionText: string | null | undefined, floor: string): boolean {
const actualMatch = /(?:^|[^\w.])v?(\d+)\.(\d+)\.(\d+)(?![\w.-])/.exec(
versionText ?? ''
);
const floorMatch = /^(\d+)\.(\d+)\.(\d+)$/.exec(floor);
if (!actualMatch || !floorMatch) return false;
for (let index = 1; index <= 3; index += 1) {
const actual = Number(actualMatch[index]);
const minimum = Number(floorMatch[index]);
if (actual !== minimum) return actual > minimum;
}
return true;
}
18 changes: 16 additions & 2 deletions src/agent-cli-provider/adapters/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { stripTimestampPrefix } from '../log-prefix';
import { getProviderRegistryEntry, normalizeProviderName, providerIds } from '../provider-registry';
import { UnsupportedProviderCapabilityError } from '../errors';
import {
getProviderRegistryEntry,
normalizeProviderName,
providerIds,
supportsProviderCapability,
} from '../provider-registry';
import type {
BuildProviderCommandOptions,
CommandSpec,
Expand Down Expand Up @@ -52,7 +58,15 @@ export function buildProviderCommand(
context: string,
options?: BuildProviderCommandOptions
): CommandSpec {
return getProviderAdapter(providerName).buildCommand(context, options);
const adapter = getProviderAdapter(providerName);
if (options?.webSearch !== undefined && !supportsProviderCapability(adapter.id, 'webSearch')) {
throw new UnsupportedProviderCapabilityError(
adapter.id,
'webSearch',
`Provider ${adapter.id} does not expose provider-controlled native web search; remove options.webSearch.`
);
}
return adapter.buildCommand(context, options);
}

export function parseProviderChunk(
Expand Down
Loading
Loading