can1357/oh-my-pi · error

imageUrls exposure "${name}" requires the ${name} binary on

Error message

imageUrls exposure "${name}" requires the ${name} binary on PATH

What it means

requireBinary resolves a tunnel/forward helper binary (cloudflared, ngrok, tailscale, ssh, devtunnel, zrok, bore) via $which and throws when it is not on PATH. Each exposure kind that spawns a child process needs its binary, and the broker refuses to start the exposure without it (the caller then falls back to inline base64 images).

Source

Thrown at packages/coding-agent/src/blob-broker/exposure.ts:153

}

/** Public frontend origin printed by `zrok share public`. */
export function parseZrokUrl(line: string): string | null {
	return /https:\/\/[a-z0-9-]+\.share\.zrok\.io/i.exec(line)?.[0] ?? null;
}

/** HTTP origin constructed from the host and port reported by `bore local`. */
export function parseBoreUrl(line: string, fallbackHost?: string): string | null {
	const match = /listening at (?:(?<host>[a-z0-9.-]+):)?(?<port>\d+)/i.exec(line);
	const host = match?.groups?.host ?? fallbackHost;
	const port = match?.groups?.port;
	return host && port ? `http://${host}:${port}` : null;
}

function requireBinary(name: string): string {
	const path = $which(name);
	if (!path) {
		throw new Error(`imageUrls exposure "${name}" requires the ${name} binary on PATH`);
	}
	return path;
}

function normalizeBaseUrl(url: string): string {
	return url.replace(/\/+$/, "");
}

function boundedInteger(value: number | undefined, fallback: number, maximum: number): number {
	if (value === undefined || !Number.isFinite(value)) return fallback;
	return Math.min(maximum, Math.max(0, Math.floor(value)));
}

/**
 * Verify that a public exposure reaches the local blob origin.
 *
 * Each request is cache-busted and time-bounded. Only the broker health
 * endpoint's exact 204 response is accepted; errors expose only the sanitized

View on GitHub (pinned to 9690622007)

Solutions

  1. Install the required binary (e.g. `brew install cloudflared`, `apt install ngrok`) or download it from the vendor.
  2. Ensure the binary's directory is on PATH for the process running omp (check `echo $PATH` in that environment).
  3. Pick an exposure kind whose binary you already have — e.g. "ssh" if only ssh is installed, or "direct" which needs no binary.
  4. If omp runs as a daemon, export the full PATH in the service unit environment.

Example fix

# before: imageUrls destination "ngrok" without ngrok installed
# after
brew install ngrok   # or apt/scoop equivalent
export PATH="$PATH:/usr/local/bin"
# or switch config to a binary-less kind: "direct"
Defensive patterns

Strategy: validation

Validate before calling

import { $which } from "@oh-my-pi/pi-utils";
const REQUIRED = { cloudflared: "cloudflared", ngrok: "ngrok", tailscale: "tailscale", devtunnel: "devtunnel", zrok: "zrok", bore: "bore", ssh: "ssh" };
if (!REQUIRED[kind] || !$which(REQUIRED[kind])) {
  throw new Error(`exposure "${kind}" needs the ${REQUIRED[kind] ?? kind} binary installed and on PATH`);
}

Try / catch

try {
  await startExposure(config, port);
} catch (err) {
  if (err instanceof Error && err.message.includes("binary on PATH")) {
    logger.warn("tunnel binary missing; images will be inlined as base64", { kind: config.kind });
  } else throw err;
}

Prevention

When it happens

Trigger: Configuring imageUrls exposure kind "cloudflared"/"ngrok"/"tailscale"/"devtunnel"/"zrok"/"bore" (or ssh-based kinds) on a machine where that binary is not installed or not on the PATH of the omp process.

Common situations: Fresh machine or CI container missing tunnel CLIs; binary installed via version manager (homebrew, nvm-style paths) not present in the daemon's PATH; launching omp from a systemd unit or IDE with a minimal PATH.

Related errors


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/6bb667105c768ce1. Report an issue: GitHub.