Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 19 additions & 14 deletions cli/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2638,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 @@ -2845,11 +2845,7 @@
});
}

function assertRequestedWebSearchCliAvailable(
provider,
settings,
exists = commandExists
) {
function assertRequestedWebSearchCliAvailable(provider, settings, exists = commandExists) {
const metadata = getProviderMetadata(provider);
if (!metadata.settingsFields.includes('webSearch')) return;
if (settings.providerSettings?.[provider]?.webSearch !== true) return;
Expand Down Expand Up @@ -2878,10 +2874,7 @@
'-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('-c, --continue', 'Continue the most recent provider session (Claude or OpenCode)')
.option(
'-o, --output-format <format>',
'Output format: stream-json (default), text, json',
Expand Down Expand Up @@ -3161,7 +3154,7 @@
.command('kill-all')
.description('Kill all running tasks and clusters')
.option('-y, --yes', 'Skip confirmation')
.action(async (options) => {

Check warning on line 3157 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 @@ -3385,7 +3378,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 3381 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 3381 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 3381 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 @@ -3681,8 +3674,13 @@
// Garbage-collect orphaned worktrees and database files
function printGcResult(result, dryRun) {
const verb = dryRun ? 'Would remove' : 'Removed';
if (result.orphanedWorktrees.length === 0 && result.orphanedDbs.length === 0) {
console.log(chalk.dim('No orphaned worktrees or database files found.'));
const orphanedProviderState = result.orphanedProviderState || [];
if (
result.orphanedWorktrees.length === 0 &&
result.orphanedDbs.length === 0 &&
orphanedProviderState.length === 0
) {
console.log(chalk.dim('No orphaned worktrees, database files, or provider-state dirs found.'));
return;
}
if (result.orphanedWorktrees.length > 0) {
Expand All @@ -3695,6 +3693,14 @@
console.log(chalk.bold(`\n${verb} ${result.orphanedDbs.length} orphaned database file(s):`));
result.orphanedDbs.forEach((n) => console.log(chalk.dim(` ~/.zeroshot/${n}`)));
}
if (orphanedProviderState.length > 0) {
console.log(
chalk.bold(`\n${verb} ${orphanedProviderState.length} orphaned provider-state dir(s):`)
);
orphanedProviderState.forEach((n) =>
console.log(chalk.dim(` $TMPDIR/zeroshot-provider-state/${n}/`))
);
}
if (result.errors.length > 0) {
console.log(chalk.yellow(`\n${result.errors.length} error(s):`));
result.errors.forEach((e) => console.log(chalk.yellow(` ${e}`)));
Expand Down Expand Up @@ -3858,9 +3864,8 @@
.description('Output task ID for an internal spawn ownership token (machine-readable)')
.action(async (token) => {
try {
const { getTaskIdBySpawnToken } = await import(
'../task-lib/commands/get-task-id-by-spawn-token.js'
);
const { getTaskIdBySpawnToken } =
await import('../task-lib/commands/get-task-id-by-spawn-token.js');
getTaskIdBySpawnToken(token);
} catch (error) {
console.error('Error resolving task spawn ownership:', error.message);
Expand Down Expand Up @@ -4761,7 +4766,7 @@
}
});

function outputAgent(agent, options) {

Check warning on line 4769 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 @@ -4929,7 +4934,7 @@
}

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

Check warning on line 4937 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 @@ -5609,7 +5614,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 5617 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
26 changes: 26 additions & 0 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,32 @@ Mount presets in `dockerMounts` include: `codex`, `gemini`, `gcloud`, `claude`,
Use `--no-mounts` to disable all credential mounts (you will get a warning if
credentials are missing).

### OMP under `--docker`

`--docker --provider omp` mounts `~/.omp` read-only to `$HOME/.omp` in the
container (the `omp` preset), then nests writable bind mounts for the runtime
state subpaths OMP writes on startup — `agent`, `natives`, `logs`, `run`,
`cache` — inside that read-only mount, since the CLI extracts native addons
and writes session/log state under `~/.omp` even in a fresh container. These
writable overlays are backed by a per-cluster host directory under
`$TMPDIR/zeroshot-provider-state/<clusterId>/omp` and are skipped entirely
under `--no-mounts`. IsolationManager removes this directory whenever a
cluster's isolation is torn down (`cleanup()`), and `zeroshot gc` sweeps any
that are orphaned by a crash or force-kill before cleanup runs.

Only the credential env vars OMP's supported model families declare (see
`docker.envPassthrough` on the `omp` provider-registry entry, e.g.
`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`,
`OPENROUTER_API_KEY`, ...) are forwarded into the container — never the full
host environment. AWS/Google Vertex-backed model families still require the
existing `aws`/`gcloud` mount presets; they are not part of OMP's own
passthrough list.

The OMP CLI is installed into its own per-provider image variant (via Bun, the
runtime the published package requires) rather than baked into the shared
base image; the image tag changes automatically whenever the registry install
command changes, so a version bump never silently reuses a stale image.

## Provider CLI Helper

Provider command construction, feature probing, model resolution, output
Expand Down
8 changes: 7 additions & 1 deletion src/agent-cli-provider/adapters/omp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,13 @@ export const ompAdapter: ProviderAdapter = {
displayName: 'OMP',
binary: 'omp',
adapterVersion: '1',
credentialEnvKeys: [],
credentialEnvKeys: [
'ANTHROPIC_API_KEY',
'ANTHROPIC_OAUTH_TOKEN',
'OPENAI_API_KEY',
'GEMINI_API_KEY',
'OPENROUTER_API_KEY',
],
modelCatalog: MODEL_CATALOG,
levelMapping: LEVEL_MAPPING,
defaultLevel: 'level2',
Expand Down
30 changes: 29 additions & 1 deletion src/agent-cli-provider/provider-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@
// a docker-cached build layer for the per-provider image variant. Omit for providers already
// baked into the base image (e.g. Claude) or not installable via a single command.
readonly install?: string;
// Subpaths (relative to `mount.container`) that must stay writable at runtime even though the
// credential mount itself is read-only, e.g. session/state/cache dirs the CLI writes to on
// startup. Each gets its own writable bind mount nested inside the read-only mount.
readonly writableState?: readonly string[];
}

export interface ProviderRegistryEntry {
Expand Down Expand Up @@ -430,7 +434,31 @@
container: '$HOME/.omp',
readonly: true,
},
envPassthrough: [],
envPassthrough: [
'ANTHROPIC_API_KEY',
'ANTHROPIC_OAUTH_TOKEN',
'ANTHROPIC_FOUNDRY_API_KEY',
'OPENAI_API_KEY',
'GEMINI_API_KEY',
'COPILOT_GITHUB_TOKEN',
'AZURE_OPENAI_API_KEY',
'GROQ_API_KEY',
'CEREBRAS_API_KEY',
'XAI_API_KEY',
'OPENROUTER_API_KEY',
'KILO_API_KEY',
'MISTRAL_API_KEY',
'ZAI_API_KEY',
'UMANS_AI_CODING_PLAN_API_KEY',
'MINIMAX_API_KEY',
'OPENCODE_API_KEY',
'CURSOR_ACCESS_TOKEN',
'AI_GATEWAY_API_KEY',
'WAFER_SERVERLESS_API_KEY',
],
install:
'npm install -g bun@1.3.14 && BUN_INSTALL=/usr/local bun install -g --ignore-scripts @oh-my-pi/pi-coding-agent',
writableState: ['agent', 'natives', 'logs', 'run', 'cache'],
},
defaultLevels: {
min: ompAdapter.defaultMinLevel,
Expand Down Expand Up @@ -497,7 +525,7 @@
mcpServers: true,
jsonSchema: false,
reasoningEffort: false,
},

Check warning on line 528 in src/agent-cli-provider/provider-registry.ts

View workflow job for this annotation

GitHub Actions / check

File has too many lines (583). Maximum allowed is 500
docs: {
label: 'Copilot',
setupHeading: 'Copilot Setup',
Expand Down
103 changes: 99 additions & 4 deletions src/isolation-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,24 @@ function providerDockerInstall(providerName) {
}
}

/**
* Subpaths (relative to the provider's mount container path) that must stay writable at runtime
* even though the credential mount itself is read-only. Sourced from the provider registry
* (docker.writableState) so nothing here is provider-specific.
* @param {string} providerName
* @returns {string[]}
*/
function providerWritableState(providerName) {
if (!providerName) return [];
try {
const metadata = getProviderMetadata(providerName);
const writableState = metadata && metadata.docker && metadata.docker.writableState;
return Array.isArray(writableState) ? writableState : [];
} catch {
return [];
}
}

class IsolationManager {
constructor(options = {}) {
this.image = options.image || DEFAULT_IMAGE;
Expand Down Expand Up @@ -257,6 +275,7 @@ class IsolationManager {
containerHome,
providerName
);
this._applyProviderStateMounts(args, config, clusterId, containerHome, providerName);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Failed container creation leaks state

When _spawnContainer rejects after this call creates the provider-state directories, startup exits before the cluster records its isolation manager, so the failure cleanup cannot remove those directories and each failed OMP Docker launch leaves an orphan under the temporary state root.

Context Used: CLAUDE.md (source)

Knowledge Base Used: Orchestrator: Cluster, Agent-Wrapper, and Message Bus Coordination

this._warnMissingProviderCredentials(providerName, mountedHosts, config, containerHome);

args.push('-w', '/workspace', image, 'tail', '-f', '/dev/null');
Expand Down Expand Up @@ -455,6 +474,52 @@ class IsolationManager {
return envToPass;
}

/**
* Mount writable state dirs (sessions, native addon caches, logs, ...) nested inside a
* provider's otherwise read-only credential mount. Docker sorts bind mounts by destination
* depth, so these apply on top of the read-only `mount` from `_applyCredentialMounts`.
* @private
* @param {string[]} args - Docker argv being built (mutated in place)
* @param {object} config - Container config (respects config.noMounts)
* @param {string} clusterId - Cluster ID (host state dir is scoped per cluster+provider)
* @param {string} containerHome - Container home directory for $HOME expansion
* @param {string} providerName - Active provider name
* @returns {string[]} Host directories created for writable state
*/
_applyProviderStateMounts(args, config, clusterId, containerHome, providerName) {
if (config.noMounts) return [];
const writableState = providerWritableState(providerName);
if (writableState.length === 0) return [];

const preset = MOUNT_PRESETS[providerName];
if (!preset) return [];
const containerRoot = resolveMounts([providerName], { containerHome })[0].container;

const hostRoot = path.join(os.tmpdir(), 'zeroshot-provider-state', clusterId, providerName);
fs.rmSync(hostRoot, { recursive: true, force: true });
// Ancestor path components (the shared `zeroshot-provider-state` root, the per-cluster and
// per-provider dirs) get ordinary default permissions — they hold no data themselves, only
// need to stay traversable so a different host user's own clusterId subtree isn't blocked.
fs.mkdirSync(hostRoot, { recursive: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Shared state root blocks users

When a second OS user starts an OMP Docker cluster after another user created the shared provider-state root, the ordinary owner-writable directory permissions make fs.mkdirSync(hostRoot, { recursive: true }) fail with EACCES, preventing the container from starting.

Context Used: CLAUDE.md (source)

Knowledge Base Used: Orchestrator: Cluster, Agent-Wrapper, and Message Bus Coordination


const hostDirs = [];
for (const sub of writableState) {
const hostSub = path.join(hostRoot, sub);
// os.tmpdir() (e.g. /tmp) is shared by every local user on the host, and these leaf dirs are
// bind-mounted writable into the container — so, unlike their ancestors, they must stay
// owner-only (0o700), never widened to group/other access. Widening would let any local
// user plant a file the container's provider process (e.g. a native addon OMP loads on
// startup) then executes, alongside its forwarded credential env vars. Mode is passed
// explicitly (not left to the process umask) so it's deterministically owner-only
// regardless of host umask configuration.
fs.mkdirSync(hostSub, { mode: 0o700 });
hostDirs.push(hostSub);
args.push('-v', `${hostSub}:${path.posix.join(containerRoot, sub)}`);
}

return hostDirs;
}

_warnMissingProviderCredentials(providerName, mountedHosts, config, containerHome) {
if (providerName === 'claude') {
return;
Expand Down Expand Up @@ -856,6 +921,27 @@ class IsolationManager {

// Clean up cluster config dir (always - it's recreated on resume)
this._cleanupClusterConfigDir(clusterId);

// Clean up provider writable-state dirs (always - the container that mounted them is gone,
// and _applyProviderStateMounts recreates them fresh on the next createContainer call).
this._cleanupProviderStateDirs(clusterId);
}

/**
* Remove the writable-state host directories a provider's Docker mounts created for this
* cluster (see `_applyProviderStateMounts`). Without this, every `--docker` run with a
* provider that declares `docker.writableState` (e.g. OMP) leaks a directory under
* `os.tmpdir()` per cluster, since each clusterId is unique and nothing else ever removes it.
* @private
* @param {string} clusterId - Cluster ID
*/
_cleanupProviderStateDirs(clusterId) {
const root = path.join(os.tmpdir(), 'zeroshot-provider-state', clusterId);
try {
fs.rmSync(root, { recursive: true, force: true });
} catch {
// Ignore
}
}

/**
Expand Down Expand Up @@ -1380,10 +1466,14 @@ class IsolationManager {
* @returns {string}
*/
static imageForProvider(providerName, baseImage = DEFAULT_IMAGE) {
if (!providerDockerInstall(providerName)) {
const install = providerDockerInstall(providerName);
if (!install) {
return baseImage;
}
return `${baseImage}-${normalizeProviderName(providerName)}`;
// Hash the install command into the tag so a registry change (e.g. bumping a package version)
// invalidates the cached image instead of `ensureImage` silently reusing a stale one.
const tag = crypto.createHash('sha256').update(install).digest('hex').slice(0, 12);
return `${baseImage}-${normalizeProviderName(providerName)}-${tag}`;
}

/**
Expand Down Expand Up @@ -1590,10 +1680,15 @@ class IsolationManager {
`running auto-GC on ${orphanCount} orphaned worktree(s)...`
);
const gcResult = gcOrphanedWorktrees();
if (gcResult.orphanedWorktrees.length > 0 || gcResult.orphanedDbs.length > 0) {
if (
gcResult.orphanedWorktrees.length > 0 ||
gcResult.orphanedDbs.length > 0 ||
gcResult.orphanedProviderState.length > 0
) {
console.log(
`[IsolationManager] Auto-GC: removed ${gcResult.orphanedWorktrees.length} worktree(s), ` +
`${gcResult.orphanedDbs.length} db file(s)`
`${gcResult.orphanedDbs.length} db file(s), ` +
`${gcResult.orphanedProviderState.length} provider-state dir(s)`
);
}
}
Expand Down
35 changes: 33 additions & 2 deletions src/lib/gc.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const os = require('os');
const { readClustersFileSync } = require('../../lib/clusters-registry');

const DEFAULT_STORAGE_DIR = path.join(os.homedir(), '.zeroshot');
const PROVIDER_STATE_DIR = path.join(os.tmpdir(), 'zeroshot-provider-state');

/** Cluster ID pattern: adjective-noun-number (e.g., "flying-jungle-51") */
const CLUSTER_ID_PATTERN = /^[a-z]+-[a-z]+-\d+$/;
Expand Down Expand Up @@ -148,7 +149,7 @@ function pruneGitWorktrees(worktreeDir) {
* @param {Set<string>} [options.extraKnownIds]
* @param {boolean} [options.dryRun=false]
* @param {boolean} [options.removeDbFiles] - Defaults to false when ZEROSHOT_CLUSTER_ID is set, else true
* @returns {{ orphanedWorktrees: string[], orphanedDbs: string[], errors: string[] }}
* @returns {{ orphanedWorktrees: string[], orphanedDbs: string[], orphanedProviderState: string[], errors: string[] }}
*/
function gcOrphanedWorktrees(options = {}) {
const storageDir = options.storageDir || DEFAULT_STORAGE_DIR;
Expand All @@ -157,7 +158,7 @@ function gcOrphanedWorktrees(options = {}) {
const removeDbFiles =
typeof options.removeDbFiles === 'boolean' ? options.removeDbFiles : activeClusterId === null;
const worktreeDir = path.join(storageDir, 'worktrees');
const result = { orphanedWorktrees: [], orphanedDbs: [], errors: [] };
const result = { orphanedWorktrees: [], orphanedDbs: [], orphanedProviderState: [], errors: [] };

const { knownIds } = resolveStorageAndKnownIds({
storageDir,
Expand All @@ -168,6 +169,7 @@ function gcOrphanedWorktrees(options = {}) {
if (removeDbFiles) {
collectOrphanedDbFiles(storageDir, knownIds, dryRun, result);
}
collectOrphanedProviderStateDirs(knownIds, dryRun, result);

if (!dryRun && result.orphanedWorktrees.length > 0) {
pruneGitWorktrees(worktreeDir);
Expand Down Expand Up @@ -211,6 +213,35 @@ function collectOrphanedDbFiles(storageDir, knownIds, dryRun, result) {
}
}

/** Validator isolation runs under `<clusterId>-validators` (see agent-lifecycle.js). */
function providerStateBaseClusterId(entryName) {
return entryName.endsWith('-validators') ? entryName.slice(0, -'-validators'.length) : entryName;
}

/**
* Sweep `os.tmpdir()/zeroshot-provider-state/<clusterId>` directories left behind by
* IsolationManager._applyProviderStateMounts. These are normally removed by
* IsolationManager.cleanup(), but a crash or force-kill before cleanup runs can orphan them —
* this is the backstop, mirroring the worktree/db sweeps above.
*/
function collectOrphanedProviderStateDirs(knownIds, dryRun, result) {
if (!fs.existsSync(PROVIDER_STATE_DIR)) return;
let entries;
try {
entries = fs.readdirSync(PROVIDER_STATE_DIR, { withFileTypes: true });
} catch (err) {
result.errors.push(`Failed to read provider-state dir: ${err.message}`);
return;
}
for (const entry of entries) {
if (!entry.isDirectory() || knownIds.has(providerStateBaseClusterId(entry.name))) continue;
result.orphanedProviderState.push(entry.name);
if (dryRun) continue;
const err = tryRmdir(path.join(PROVIDER_STATE_DIR, entry.name));
if (err) result.errors.push(`Failed to remove provider-state dir ${entry.name}: ${err}`);
}
}

/**
* Get disk space info for a path.
* @param {string} dirPath
Expand Down
2 changes: 1 addition & 1 deletion src/orchestrator.js
Original file line number Diff line number Diff line change
Expand Up @@ -4528,7 +4528,7 @@ Continue from where you left off. Review your previous output to understand what
*
* @param {object} [options]
* @param {boolean} [options.dryRun=false] - If true, report but don't delete
* @returns {{ orphanedWorktrees: string[], orphanedDbs: string[], errors: string[] }}
* @returns {{ orphanedWorktrees: string[], orphanedDbs: string[], orphanedProviderState: string[], errors: string[] }}
*/
gcWorktrees(options = {}) {
const { gcOrphanedWorktrees } = require('./lib/gc');
Expand Down
Loading
Loading