Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -1521,6 +1521,11 @@ APP_LOG_TO_FILE=true
# PROXY_AUTO_REMOVE=false
# Consecutive failures before an auto-remove fires. Default: 3.
# PROXY_AUTO_REMOVE_AFTER=3
# Let automated reachability probes (the scheduler + the "Test All" button) WRITE
# a proxy's status. Default "false": probes are read-only and never deactivate a
# proxy — only the operator sets active/inactive (a flaky probe must not strand an
# assigned proxy; #6246). Set "true" to restore the legacy test-and-set behaviour.
# PROXY_HEALTH_AUTO_DEACTIVATE=false

# Allow OAuth and provider validation flows to bypass a pinned proxy and connect
# directly when proxy reachability pre-checks fail. Default: false.
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

### 🐛 Bug Fixes

- **fix(proxy):** make "Test All" read-only and add bulk enable/disable ([#6246](https://github.com/diegosouzapw/OmniRoute/issues/6246)). Complements the core fail-closed / scheduler fix (#6296) with the two remaining reporter asks. (1) The **"Test All" button** (`POST /api/settings/proxies/auto-test`) used to flip a proxy to `inactive` on a failed reachability probe; since the egress selector excludes `inactive` proxies, a flaky probe (an unreachable `httpbin.org`, a proxy that blocks `HEAD`, or a slow paid proxy) silently disabled every proxy that failed — "Test All" is now **read-only by default** (only the operator sets a proxy active/inactive; opt back into the legacy test-and-set with `PROXY_HEALTH_AUTO_DEACTIVATE=true`). (2) Adds a **bulk enable/disable** proxies endpoint + toolbar action (`POST /api/settings/proxies/batch-activate`) so an operator can re-activate proxies in one click. Regression guard: `tests/unit/proxy-health-6246.test.ts`. (thanks @tenshiak)

- **chatcore (tools): stop the default 128-tool cap from silently dropping opencode's `task`/MCP tools.** opencode (used as an MCP/agent host) sends a large tool list; when it exceeds the speculative `MAX_TOOLS_LIMIT` (128) default, `truncateToolList` did a blind `tools.slice(0, 128)`, dropping every tool past index 128 — including opencode's built-in `task` tool (subagent launch) and many MCP tools, so models routed through OmniRoute could no longer spawn subagents or reach part of their tools. The cap exists to avoid upstream `400`s for providers with real hard limits (e.g. grok-cli 200), so it is kept for those: detection of the opencode client (`isOpencodeClient` — any `x-opencode-*` header, or `opencode` in the user-agent) now only bypasses the **speculative 128 default**, never a known provider ceiling. Precedence is explicit — a proactive/detected provider limit always truncates (even for opencode); otherwise opencode forwards its full tool list; otherwise the unchanged 128 default applies to every other client. Refactors `getEffectiveToolLimit` into `getKnownToolLimit(provider) ?? DEFAULT_LIMIT` (byte-identical for existing callers) and fixes a cosmetic debug-log that reported the truncated count instead of the original. Regression guard: `tests/unit/tool-limit-detector.test.ts`.

- **fix(mitm):** the macOS MITM-cert install check now matches the system keychain again. `security find-certificate -a -Z` prints the SHA-1 as a colon-less hex string, but the installed-check compared it against `getCertFingerprint()`'s colon-separated form, so the substring match never hit — the cert was reported as not-installed and re-prompted for the sudo install on every run. Fingerprints are now normalized (colons stripped, upper-cased) on both sides via the extracted `macCertOutputHasFingerprint` helper. Regression guard: `tests/unit/mitm-cert-mac-fingerprint.test.ts`. ([#6204](https://github.com/diegosouzapw/OmniRoute/pull/6204), closes [#6134](https://github.com/diegosouzapw/OmniRoute/issues/6134) — thanks @rianonehub)
Expand Down
1 change: 1 addition & 0 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,7 @@ Anthropic-compatible provider instead.
| `PROXY_HEALTH_ENABLED` | `true` | `src/lib/proxyHealth/scheduler.ts` | Set `false` to disable the background proxy health scheduler that periodically probes registered proxies. |
| `PROXY_HEALTH_INTERVAL_MS` | `600000` | `src/lib/proxyHealth/scheduler.ts` | Background health-scheduler sweep interval in ms (minimum `60000`). |
| `PROXY_HEALTH_TEST_URL` | `https://httpbin.org/ip` | `src/lib/proxyHealth/scheduler.ts` | Reachability probe target used by the scheduler and the `/api/settings/proxies/auto-test` endpoint. Point it at an internal/self-hosted URL to avoid the public default. |
| `PROXY_HEALTH_AUTO_DEACTIVATE` | `false` | `src/lib/proxyHealth/statusPolicy.ts` | When `false` (default), automated reachability probes (the scheduler + the `/api/settings/proxies/auto-test` "Test All" button) are **read-only** and never write a proxy's status — only the operator sets active/inactive, so a flaky probe can't strand an assigned proxy (#6246). Set `true` to restore the legacy test-and-set behaviour. |
| `PROXY_AUTO_REMOVE` | `false` | `src/lib/proxyHealth/scheduler.ts` | Set `true` to let the scheduler auto-remove proxies after repeated consecutive failures. |
| `PROXY_AUTO_REMOVE_AFTER` | `3` | `src/lib/proxyHealth/scheduler.ts` | Consecutive failures before the scheduler auto-removes a proxy (when `PROXY_AUTO_REMOVE=true`). |
| `OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK` | `false` | `src/shared/constants/featureFlagDefinitions.ts` | Allow OAuth and provider validation flows to bypass a pinned proxy and connect directly when proxy reachability pre-checks fail. Effective precedence is Feature Flags DB override > env var > default. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@ interface ProxyBatchActionsProps {
selectedCount: number;
batchDeleting: boolean;
autoTesting: boolean;
batchActivating: boolean;
onBatchDelete: () => void;
onBatchActivate: () => void;
onAutoTestAll: () => void;
}

export function ProxyBatchActions({
selectedCount,
batchDeleting,
autoTesting,
batchActivating,
onBatchDelete,
onBatchActivate,
onAutoTestAll,
}: ProxyBatchActionsProps) {
const t = useTranslations("proxyRegistry");
Expand All @@ -27,6 +31,16 @@ export function ProxyBatchActions({
<span className="text-xs text-text-muted">
{t("batchSelectedCount", { count: selectedCount })}
</span>
<Button
size="sm"
variant="secondary"
icon="check_circle"
onClick={onBatchActivate}
loading={batchActivating}
data-testid="proxy-registry-batch-activate"
>
{t("batchActivateSelected", { count: selectedCount })}
</Button>
<Button
size="sm"
variant="secondary"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,9 +267,11 @@ export default function ProxyRegistryManager() {
setSelectedIds,
batchDeleting,
autoTesting,
batchActivating,
toggleSelectAll: hookToggleSelectAll,
toggleSelect,
handleBatchDelete: hookHandleBatchDelete,
handleBatchActivate: hookHandleBatchActivate,
handleAutoTestAll: hookHandleAutoTestAll,
} = useProxyBatchOperations(load);

Expand All @@ -279,6 +281,10 @@ export default function ProxyRegistryManager() {
hookHandleBatchDelete(setError);
}, [hookHandleBatchDelete, setError]);

const handleBatchActivate = useCallback(() => {
hookHandleBatchActivate(setError, "active");
}, [hookHandleBatchActivate, setError]);

const handleAutoTestAll = useCallback(() => {
hookHandleAutoTestAll(setError, setTestById);
}, [hookHandleAutoTestAll, setError, setTestById]);
Expand Down Expand Up @@ -636,7 +642,9 @@ export default function ProxyRegistryManager() {
selectedCount={selectedIds.size}
batchDeleting={batchDeleting}
autoTesting={autoTesting}
batchActivating={batchActivating}
onBatchDelete={handleBatchDelete}
onBatchActivate={handleBatchActivate}
onAutoTestAll={handleAutoTestAll}
/>
<Button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export function useProxyBatchOperations(load: () => Promise<void>) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [batchDeleting, setBatchDeleting] = useState(false);
const [autoTesting, setAutoTesting] = useState(false);
const [batchActivating, setBatchActivating] = useState(false);

const toggleSelectAll = useCallback(
(allSelected: boolean, items: Array<{ id: string }>) => {
Expand Down Expand Up @@ -63,6 +64,34 @@ export function useProxyBatchOperations(load: () => Promise<void>) {
[selectedIds, load]
);

// #6246: bulk enable/disable — the only automated path that writes proxy
// status (an explicit operator action). Health probes are read-only by default.
const handleBatchActivate = useCallback(
async (setError: (msg: string | null) => void, status: "active" | "inactive" = "active") => {
if (selectedIds.size === 0) return;
setBatchActivating(true);
try {
const res = await fetch("/api/settings/proxies/batch-activate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: Array.from(selectedIds), status }),
});
const data: { error?: { message?: string } } = await res.json().catch(() => ({}));
if (res.ok) {
setSelectedIds(new Set());
await load();
} else {
setError(data?.error?.message || "Batch update failed");
}
} catch (e: unknown) {
setError(e instanceof Error ? e.message : "Batch update failed");
} finally {
setBatchActivating(false);
}
},
[selectedIds, load]
);

const handleAutoTestAll = useCallback(
async (
setError: (msg: string | null) => void,
Expand Down Expand Up @@ -106,9 +135,11 @@ export function useProxyBatchOperations(load: () => Promise<void>) {
setSelectedIds,
batchDeleting,
autoTesting,
batchActivating,
toggleSelectAll,
toggleSelect,
handleBatchDelete,
handleBatchActivate,
handleAutoTestAll,
};
}
10 changes: 8 additions & 2 deletions src/app/api/settings/proxies/auto-test/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { createProxyDispatcher } from "@omniroute/open-sse/utils/proxyDispatcher";
import { fetch as undiciFetch } from "undici";
import { resolveHealthCheckStatusWrite } from "@/lib/proxyHealth/statusPolicy";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { createErrorResponse } from "@/lib/api/errorResponse";

Expand Down Expand Up @@ -43,11 +44,16 @@ async function testSingleProxy(proxy: { id: string; type: string; host: string;
});
const latencyMs = Date.now() - start;
const alive = resp.status < 500;
await updateProxy(proxy.id, { status: alive ? "active" : "inactive" }).catch(() => {});
// #6246: "Test All" is a test, not test-and-set. By default an automated probe
// never mutates a proxy's status (only the operator does). Opt back into the
// legacy write with PROXY_HEALTH_AUTO_DEACTIVATE=true.
const statusWrite = resolveHealthCheckStatusWrite(alive);
if (statusWrite) await updateProxy(proxy.id, { status: statusWrite }).catch(() => {});
return { proxyId: proxy.id, host: proxy.host, port: proxy.port, alive, latencyMs };
} catch (err) {
const latencyMs = Date.now() - start;
await updateProxy(proxy.id, { status: "inactive" }).catch(() => {});
const statusWrite = resolveHealthCheckStatusWrite(false);
if (statusWrite) await updateProxy(proxy.id, { status: statusWrite }).catch(() => {});
return {
proxyId: proxy.id,
host: proxy.host,
Expand Down
72 changes: 72 additions & 0 deletions src/app/api/settings/proxies/batch-activate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { z } from "zod";
import { updateProxy } from "@/lib/localDb";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";

const batchActivateSchema = z.object({
ids: z.array(z.string()).min(1).max(500),
// "active" (bulk enable) or "inactive" (bulk disable). Defaults to enable —
// the #6246 ask was a way to re-enable proxies an operator had disabled.
status: z.enum(["active", "inactive"]).optional().default("active"),
});
Comment on lines +1 to +13

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

According to Rule 8 of the Repository Style Guide ('Always validate inputs with Zod schemas from src/shared/validation/schemas.ts'), all Zod schemas used for input validation must be defined in src/shared/validation/schemas.ts rather than locally within the route file. Please move batchActivateSchema to src/shared/validation/schemas.ts and import it here.

Suggested change
import { z } from "zod";
import { updateProxy } from "@/lib/localDb";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
const batchActivateSchema = z.object({
ids: z.array(z.string()).min(1).max(500),
// "active" (bulk enable) or "inactive" (bulk disable). Defaults to enable —
// the #6246 ask was a way to re-enable proxies an operator had disabled.
status: z.enum(["active", "inactive"]).optional().default("active"),
});
import { updateProxy } from "@/lib/localDb";
import { createErrorResponse, createErrorResponseFromUnknown } from "@/lib/api/errorResponse";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { clearDispatcherCache } from "@omniroute/open-sse/utils/proxyDispatcher";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { batchActivateSchema } from "@/shared/validation/schemas";
References
  1. Always validate inputs with Zod schemas from src/shared/validation/schemas.ts.


/**
* POST /api/settings/proxies/batch-activate
*
* Bulk-set the status of multiple proxies in one request (#6246). This is the
* ONLY automated path allowed to change proxy status — it is an explicit
* operator action, unlike the reachability probes which are read-only by
* default (see src/lib/proxyHealth/statusPolicy.ts).
*/
export async function POST(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;

let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return createErrorResponse({ status: 400, message: "Invalid JSON body", type: "invalid_request" });
}

const validation = validateBody(batchActivateSchema, rawBody);
if (isValidationFailure(validation)) {
return createErrorResponse({ status: 400, message: validation.error.message, type: "invalid_request" });
}

const { ids, status } = validation.data;

try {
const results: Array<{ id: string; success: boolean; error?: string }> = [];
let updatedCount = 0;

for (const id of ids) {
try {
if (await updateProxy(id, { status })) {
results.push({ id, success: true });
updatedCount++;
} else {
results.push({ id, success: false, error: "Proxy not found" });
}
} catch (err) {
results.push({ id, success: false, error: err instanceof Error ? err.message : "Unknown error" });
}
}

if (updatedCount > 0) {
try { clearDispatcherCache(); } catch { /* non-critical */ }
}

return Response.json({
success: updatedCount > 0,
status,
updated: updatedCount,
failed: ids.length - updatedCount,
results,
});
} catch (error) {
return createErrorResponseFromUnknown(error, "Failed to batch update proxy status");
}
}
1 change: 1 addition & 0 deletions src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -7940,6 +7940,7 @@
"errorTestFailed": "Failed to test proxies",
"batchSelectedCount": "{count} selected",
"batchDeleteSelected": "Delete {count} selected",
"batchActivateSelected": "Enable {count} selected",
"testPassed": "✓ OK"
},
"playground": {
Expand Down
41 changes: 41 additions & 0 deletions src/lib/proxyHealth/statusPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Proxy health-check status-write policy (#6246).
*
* The manual "Test All" reachability probe (`/api/settings/proxies/auto-test`)
* used to flip a proxy's `status` to `inactive` on a single failed probe. Because
* the egress selector excludes `inactive` proxies (PROXY_ALIVE_PREDICATE), a flaky
* external probe (an unreachable httpbin.org, a proxy that blocks HEAD, or a slow
* paid proxy) silently disabled every proxy that failed — "Test All" is a test,
* not test-and-set.
*
* Policy: automated reachability probes are **read-only by default** — they never
* write a proxy's status. Only the operator sets a proxy active/inactive (same
* contract as provider accounts, which are auto-disabled only when the operator
* opts in). Set `PROXY_HEALTH_AUTO_DEACTIVATE=true` to restore the legacy
* test-and-set behavior (probe result writes `active`/`inactive`).
*/

/**
* Resolve the status an automated health probe should WRITE for a proxy, or
* `null` to leave the status untouched (the default). Pure + unit-testable.
*
* @param alive whether the reachability probe succeeded
* @param env environment source (defaults to process.env)
*/
export function resolveHealthCheckStatusWrite(
alive: boolean,
env: { PROXY_HEALTH_AUTO_DEACTIVATE?: string } = process.env
): "active" | "inactive" | null {
if ((env.PROXY_HEALTH_AUTO_DEACTIVATE ?? "").trim().toLowerCase() !== "true") {
// Default: never let an automated probe change a proxy's status.
return null;
}
return alive ? "active" : "inactive";
}

/** True when automated probes are allowed to write proxy status (opt-in). */
export function isProxyHealthAutoDeactivateEnabled(
env: { PROXY_HEALTH_AUTO_DEACTIVATE?: string } = process.env
): boolean {
return (env.PROXY_HEALTH_AUTO_DEACTIVATE ?? "").trim().toLowerCase() === "true";
}
Loading
Loading