fix(opencode): visible rotation logs + ProxyEgress + callLogs SQL-vars (#5217 Stage 1) (#5351)

Integrated into release/v3.8.41 — OpenCode rotation logs (info) + ProxyEgress applied-proxy sink + callLogs SQL-vars chunking (#5217 Stage 1). Tests green (apply-executor-proxy-info, call-log-trim-sql-vars, opencode-proxy-rotation).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-29 11:21:37 -03:00
committed by GitHub
parent 5189e3011b
commit aa299720ee
9 changed files with 324 additions and 17 deletions

View File

@@ -10,10 +10,10 @@ _In development — bullets added per PR; finalized at release._
### 🔧 Bug Fixes
- **routing:** auto-disable a depleted API key on upstream `402 "Insufficient account balance"` for API Key Round-Robin connections (multiple keys in one connection's `extraApiKeys`). The per-connection path already terminalized 402 (→ `credits_exhausted`), but the per-KEY health tracker (`recordKeyHealthStatus`) only recorded failures for `401`, so a 402-depleted key stayed in rotation and kept getting retried. Now a 402 marks the current key invalid immediately (terminal — balance won't recover mid-session) via a new `recordKeyTerminal`, so the rotator skips it and falls over to the next healthy key; the state persists across restarts. Also added `insufficient balance`/`insufficient_balance`/`insufficient account balance` to the credits-exhausted body signals so non-402 out-of-credit responses terminalize too. Regression guard: `tests/unit/key-health-402-disable-5239.test.ts`. ([#5239](https://github.com/diegosouzapw/OmniRoute/issues/5239) — thanks @muflifadla38)
- **cli:** `omniroute serve` no longer discards a user-set `NODE_OPTIONS=--max-old-space-size=…`. It used to unconditionally overwrite `NODE_OPTIONS` (and pass an explicit `--max-old-space-size` CLI arg) with the calibrated default, so a user who exported `--max-old-space-size=8192` still ran at the old cap and OOM'd (#5238 reporter set 8192, crashed at ~505MB). Now it mirrors the Electron and standalone launchers: if `NODE_OPTIONS` already pins the heap, that value wins (and the duplicate CLI arg is suppressed); otherwise the calibrated `--max-old-space-size` is appended, preserving unrelated flags. Regression guard: `tests/unit/serve-node-options-preserve-5238.test.ts`. (Defect C of [#5238](https://github.com/diegosouzapw/OmniRoute/issues/5238); the `b.mask`/OOM-root parts are tracked separately.)
- **dashboard:** restore the `{active}/{total} active` model-count badge in a provider's **Available Models** toolbar (provider detail page). It was dropped during the v3.8.13 god-file decomposition (#3327) — the `ModelVisibilityToolbar` still received `activeCount`/`totalCount` but they were orphaned as unused `_`-prefixed params and the rendering `<span>` was never carried over (the `modelsActiveCount` i18n key stayed). Re-wired the existing props to the existing key; zero data-layer or i18n change. Regression guard: `modelVisibilityToolbarActiveCount.test.tsx`. ([#5264](https://github.com/diegosouzapw/OmniRoute/issues/5264))
- **rerank:** `/v1/rerank` no longer rejects SiliconFlow and DeepInfra Qwen3-Reranker models with `400 "Invalid rerank model"` even though `/v1/models` lists them. The model-ID parser was never the problem (it already splits on the first slash, so `siliconflow/Qwen/Qwen3-Reranker-8B` parses correctly) — `siliconflow` and `deepinfra` were just missing from the rerank provider registry. Added both: SiliconFlow as Cohere-compatible, DeepInfra via a new `deepinfra` adapter (model in the URL path `POST /v1/inference/<model>`, `{queries,documents}` request, positional `{scores}` response mapped to Cohere `results[]`). Regression guard: `tests/unit/rerank-providers-5332.test.ts`. ([#5332](https://github.com/diegosouzapw/OmniRoute/issues/5332) — thanks @maikokan)
- **opencode/observability:** make OpenCode Free account/proxy rotation visible and fix two real defects surfaced alongside it. **(1)** the per-request rotation selection log (`dispatch via account … through proxy …`) was `debug` (hidden at default `APP_LOG_LEVEL=info`) — promoted to `info` so the shuffle/cooldown lifecycle is auditable (token stays masked). **(2)** `[ProxyEgress]` reported `proxy=direct` even when an account proxy was applied, because the egress logger ran outside the executor's nested proxy context — the effective applied proxy is now captured (via an applied-proxy sink threaded through the proxy AsyncLocalStorage) and reflected in the egress log. **(3)** `[callLogs] too many SQL variables``deleteCallLogRowsByIds` deleted up to 5000 ids in one `IN (…)`, exceeding SQLite's ~999 bound-param cap and aborting log trimming/retention; ids are now chunked (≤500 per statement). Regression guards: `tests/unit/call-log-trim-sql-vars-5217.test.ts`, `apply-executor-proxy-info-5217.test.ts`, extended `opencode-proxy-rotation-4954.test.ts`. The Proxy Pool dropdown (by-id) UI (Gap 1) is a follow-up requiring browser validation. ([#5217](https://github.com/diegosouzapw/OmniRoute/issues/5217) — thanks @daniij)
### 🔧 Bug Fixes
- **authz/dashboard:** stop rejecting every dashboard mutation with `403 INVALID_ORIGIN` when the dashboard is reached over a LAN IP / non-localhost host. The origin-pinning check (#5278) only accepted the configured `*_PUBLIC_BASE_URL` (typically `http://localhost:20128`) plus the internal `request.url` origin — which Next.js standalone reports as the bind host, not the real `Host`. So opening the dashboard at e.g. `http://192.168.0.15:20128` made the browser's same-origin `Origin` match no candidate, and **every** POST/PUT/DELETE (save API key, save provider, test connection) failed while GETs still worked. Two fixes: **(a)** the request `Host` (or a trusted `X-Forwarded-Host`) is now accepted as a valid mutation origin, gated by two independent checks — the token-stamped socket peer must be loopback/private-LAN **and** the Host itself must be a loopback/private-LAN IP literal, so a DNS-rebinding domain (which classifies as `remote`) can never become a trusted origin and the protocol is pinned to the actual connection; **(b)** the `INVALID_ORIGIN` response now carries an actionable message (set `OMNIROUTE_PUBLIC_BASE_URL`) and the dashboard surfaces API error `.message` via a shared `extractApiErrorMessage` helper instead of rendering the raw error object. Regression guards: `tests/unit/authz/public-origin.test.ts` (direct LAN/loopback + DNS-rebinding defense), `tests/unit/api-error-message-5340.test.ts`. ([#5340](https://github.com/diegosouzapw/OmniRoute/issues/5340))
---

View File

@@ -152,7 +152,11 @@ export class OpencodeExecutor extends BaseExecutor {
for (let attempt = 0; attempt < this.accounts.length; attempt++) {
const account = this.pickAccount();
const masked = OpencodeExecutor.maskAccountId(account.fingerprint);
log?.debug?.(
// #5217 (Gap 2): promoted debug→info so the per-request account/proxy
// rotation selection is visible in the Console log view at the default
// APP_LOG_LEVEL=info (users could not see which account/proxy was used).
// Token stays masked — never log the full account id.
log?.info?.(
"OPENCODE",
`dispatch via account ${masked} (idx ${attempt + 1}/${this.accounts.length})` +
(account.proxy ? ` through proxy ${account.proxy.host}:${account.proxy.port}` : " direct")

View File

@@ -28,6 +28,31 @@ function isTlsFingerprintEnabled() {
type TlsFingerprintStore = { used: boolean };
const tlsFingerprintContext = new AsyncLocalStorage<TlsFingerprintStore>();
/**
* #5217 (Gap-secondary): a mutable sink that records the proxy actually applied
* by `runWithProxyContext` for the in-flight request. Executors that pin their
* own per-account proxy *internally* (e.g. OpencodeExecutor wraps its dispatch
* in `runWithProxyContext(account.proxy, …)`) never propagate that choice back
* to the caller's `proxyInfo`, so the post-execution `[ProxyEgress]` line logged
* `proxy=direct` even though `[ProxyFetch] Applied request proxy context: …`
* fired. Wrapping the execution in `runWithAppliedProxyCapture(sink, fn)` lets
* the egress logger read the innermost applied proxy (the last writer wins, which
* is the executor's per-account proxy).
*/
export type AppliedProxySink = { proxy: unknown };
const appliedProxyContext = new AsyncLocalStorage<AppliedProxySink>();
/**
* Run `fn` with an applied-proxy capture sink in context. Any
* `runWithProxyContext` call inside `fn` that ends up applying a proxy records
* that proxy config into `sink.proxy` (innermost wins). The sink is a plain
* mutable object the caller retains, so it can read `sink.proxy` after `fn`
* resolves. Pure plumbing — no behavioral change to the request itself.
*/
export function runWithAppliedProxyCapture<T>(sink: AppliedProxySink, fn: () => T): T {
return appliedProxyContext.run(sink, fn);
}
type FetchWithDispatcherOptions = RequestInit & { dispatcher?: unknown };
type FetchWithDispatcher = (
input: RequestInfo | URL,
@@ -334,6 +359,14 @@ export async function runWithProxyContext(
`[ProxyFetch] Applied request proxy context: ${proxyUrlForLogs(resolvedProxyUrl)}`
);
}
// #5217: record the proxy actually applied so a post-execution egress logger
// reflects the real egress (executors that pin a per-account proxy internally
// otherwise leave proxyInfo reading "direct"). Innermost runWithProxyContext
// wins, which is exactly the per-account proxy the executor selected.
if (effectiveProxyConfig) {
const sink = appliedProxyContext.getStore();
if (sink) sink.proxy = effectiveProxyConfig;
}
return fn();
});
}

View File

@@ -425,28 +425,40 @@ function listReferencedArtifacts() {
);
}
// #5217: SQLite caps a statement at SQLITE_MAX_VARIABLE_NUMBER bound params
// (~999 on many builds). Callers like trimCallLogsToMaxRows() passed up to 5000
// ids in one `IN (...)` → "too many SQL variables" aborted trimming. Chunk well
// under the limit so each DELETE/SELECT stays valid.
const DELETE_ID_CHUNK_SIZE = 500;
function deleteCallLogRowsByIds(ids: string[]): DeleteResult {
if (ids.length === 0) {
return { deletedRows: 0, deletedArtifacts: 0 };
}
const db = getDbInstance();
const placeholders = ids.map(() => "?").join(", ");
const rows = db
.prepare(`SELECT artifact_relpath FROM call_logs WHERE id IN (${placeholders})`)
.all(...ids) as Array<{ artifact_relpath: string | null }>;
const result = db.prepare(`DELETE FROM call_logs WHERE id IN (${placeholders})`).run(...ids);
let deletedRows = 0;
let deletedArtifacts = 0;
for (const row of rows) {
if (deleteCallArtifact(row.artifact_relpath)) {
deletedArtifacts++;
for (let i = 0; i < ids.length; i += DELETE_ID_CHUNK_SIZE) {
const chunk = ids.slice(i, i + DELETE_ID_CHUNK_SIZE);
const placeholders = chunk.map(() => "?").join(", ");
const rows = db
.prepare(`SELECT artifact_relpath FROM call_logs WHERE id IN (${placeholders})`)
.all(...chunk) as Array<{ artifact_relpath: string | null }>;
const result = db.prepare(`DELETE FROM call_logs WHERE id IN (${placeholders})`).run(...chunk);
deletedRows += result.changes;
for (const row of rows) {
if (deleteCallArtifact(row.artifact_relpath)) {
deletedArtifacts++;
}
}
}
cleanupEmptyCallLogDirs();
return {
deletedRows: result.changes,
deletedRows,
deletedArtifacts,
};
}

View File

@@ -70,6 +70,7 @@ import {
handleNoCredentials,
safeResolveProxy,
safeLogEvents,
applyExecutorProxyToInfo,
shouldRetryStreamEarlyEof,
withSessionHeader,
withSelectedConnectionHeader,
@@ -1227,6 +1228,9 @@ async function handleSingleModelChat(
);
}
const proxyInfo = await safeResolveProxy(credentials.connectionId, apiKeyInfo?.id);
// #5217: sink for the proxy the executor pins internally (e.g. OpencodeExecutor
// rotation) so the egress log below reflects the real egress, not "direct".
const appliedProxySink: { proxy: unknown } = { proxy: null };
const proxyStartTime = Date.now();
// 4. Execute chat via core after breaker gate checks (with optional TLS tracking)
@@ -1239,6 +1243,7 @@ async function handleSingleModelChat(
model: effectiveModel,
refreshedCredentials,
proxyInfo,
appliedProxySink,
log,
clientRawRequest,
credentials,
@@ -1266,9 +1271,10 @@ async function handleSingleModelChat(
targetFormat;
// 5. Log proxy + translation events (fire-and-forget; never blocks the response)
// #5217: reflect the proxy the executor actually applied (per-account rotation).
void safeLogEvents({
result,
proxyInfo,
proxyInfo: applyExecutorProxyToInfo(proxyInfo, appliedProxySink.proxy),
proxyLatency,
provider,
model,

View File

@@ -22,8 +22,10 @@ import {
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
import {
runWithProxyContext,
runWithAppliedProxyCapture,
runWithTlsTracking,
isTlsFingerprintActive,
type AppliedProxySink,
} from "@omniroute/open-sse/utils/proxyFetch.ts";
import { resolveProxyForConnection } from "@/lib/localDb";
import {
@@ -363,6 +365,7 @@ export async function executeChatWithBreaker({
model,
refreshedCredentials,
proxyInfo,
appliedProxySink,
log: handlerLog,
clientRawRequest,
credentials,
@@ -388,8 +391,15 @@ export async function executeChatWithBreaker({
: "production";
const isShadowTraffic = normalizedTrafficType === "shadow";
// #5217: capture the proxy actually applied during execution so the caller can
// merge it into proxyInfo before the egress log (executors pinning a per-account
// proxy internally otherwise leave the egress log reading "direct").
const capture = <T>(fn: () => T): T =>
appliedProxySink ? runWithAppliedProxyCapture(appliedProxySink, fn) : fn();
try {
const chatFn = () =>
capture(() =>
runWithProxyContext(proxyInfo?.proxy || null, () =>
(handleChatCore as any)({
body: { ...body, model: `${provider}/${model}` },
@@ -464,6 +474,7 @@ export async function executeChatWithBreaker({
);
},
})
)
);
if (isShadowTraffic) {
@@ -679,6 +690,26 @@ export async function safeResolveProxy(connectionId: string, apiKeyId?: string)
}
}
/**
* #5217: merge a proxy the executor applied internally (captured via
* AppliedProxySink) into the pre-execution proxyInfo so the egress logger reflects
* the real egress. No applied proxy → proxyInfo unchanged. A pre-existing
* non-direct level is preserved; otherwise reported as "account" (per-account
* proxy, e.g. OpenCode rotation). Pure + unit-testable.
*/
export function applyExecutorProxyToInfo(
proxyInfo: { proxy?: unknown; level?: string; levelId?: string | null } | null | undefined,
appliedProxy: unknown
) {
if (!appliedProxy) return proxyInfo;
const priorLevel = proxyInfo?.level;
return {
...(proxyInfo || {}),
proxy: appliedProxy,
level: priorLevel && priorLevel !== "direct" ? priorLevel : "account",
};
}
// Async because the egress-IP lookup lazy-imports proxyEgress; callers treat
// this as fire-and-forget logging (the internal try/catch swallows everything).
export async function safeLogEvents({

View File

@@ -0,0 +1,42 @@
import test from "node:test";
import assert from "node:assert/strict";
import { applyExecutorProxyToInfo } from "../../src/sse/handlers/chatHelpers.ts";
/**
* #5217 (secondary) — the egress logger logged `proxy=direct` even when an
* executor (OpencodeExecutor rotation) pinned a per-account proxy internally,
* because the pre-resolved `proxyInfo` never learned about it.
* `applyExecutorProxyToInfo` merges the executor-applied proxy back into proxyInfo
* so the egress line reflects the real egress.
*/
test("returns proxyInfo unchanged when the executor applied no proxy", () => {
const proxyInfo = { proxy: null, level: "direct", levelId: null };
assert.strictEqual(applyExecutorProxyToInfo(proxyInfo, null), proxyInfo);
assert.strictEqual(applyExecutorProxyToInfo(proxyInfo, undefined), proxyInfo);
});
test("injects the applied proxy and labels a previously-direct level as 'account'", () => {
const applied = { type: "http", host: "127.0.0.1", port: 9999 };
const merged = applyExecutorProxyToInfo({ proxy: null, level: "direct", levelId: null }, applied);
assert.deepEqual(merged?.proxy, applied);
assert.equal(merged?.level, "account");
});
test("injects the applied proxy even when proxyInfo is null/undefined", () => {
const applied = { type: "socks5", host: "10.0.0.1", port: 1080 };
const merged = applyExecutorProxyToInfo(null, applied);
assert.deepEqual(merged?.proxy, applied);
assert.equal(merged?.level, "account");
});
test("preserves an existing non-direct level (connection/key/global proxy)", () => {
const applied = { type: "http", host: "127.0.0.1", port: 8888 };
const merged = applyExecutorProxyToInfo(
{ proxy: { type: "http", host: "1.2.3.4", port: 1 }, level: "connection", levelId: "conn-1" },
applied
);
assert.deepEqual(merged?.proxy, applied, "proxy is overwritten by the actually-applied one");
assert.equal(merged?.level, "connection", "a non-direct level must be preserved");
assert.equal(merged?.levelId, "conn-1");
});

View File

@@ -0,0 +1,102 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
/**
* #5217 — `trimCallLogsToMaxRows()` deleted up to batchSize=5000 ids in a single
* `DELETE … IN (?, ?, …)` via `deleteCallLogRowsByIds`. SQLite caps a statement at
* ~999 bound parameters by default, so any trim that needed to delete >999 rows
* threw "too many SQL variables", aborting the trim and blocking the Request-log
* table from being persisted/pruned. The delete must chunk the ids so a large
* trim succeeds instead of throwing.
*/
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-calllogs-trim-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.CALL_LOG_RETENTION_DAYS = "3650";
const core = await import("../../src/lib/db/core.ts");
const callLogs = await import("../../src/lib/usage/callLogs.ts");
function insertCallLog(id: string, timestamp: string) {
const db = core.getDbInstance();
db.prepare(
`
INSERT INTO call_logs (
id, timestamp, method, path, status, model, provider, detail_state
)
VALUES (@id, @timestamp, 'POST', '/v1/chat/completions', 200, 'openai/gpt-4.1', 'openai', 'none')
`
).run({ id, timestamp });
}
test.beforeEach(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("trimCallLogsToMaxRows deletes >999 rows in one pass without 'too many SQL variables'", () => {
const db = core.getDbInstance();
const total = 1500;
const base = Date.parse("2026-01-01T00:00:00.000Z");
const insertMany = db.transaction(() => {
for (let i = 0; i < total; i++) {
insertCallLog(`trim-${String(i).padStart(5, "0")}`, new Date(base + i * 1000).toISOString());
}
});
insertMany();
assert.equal(
(db.prepare("SELECT COUNT(*) AS cnt FROM call_logs").get() as { cnt: number }).cnt,
total
);
// Trim to 10 rows → 1490 ids must be deleted in a single trim batch (batchSize=5000),
// which without chunking would exceed SQLite's ~999 bound-parameter limit and throw.
let result: { deletedRows: number; deletedArtifacts: number } | undefined;
assert.doesNotThrow(() => {
result = callLogs.trimCallLogsToMaxRows(10);
});
assert.equal(result!.deletedRows, total - 10, "all overflow rows must be deleted (chunked)");
assert.equal(
(db.prepare("SELECT COUNT(*) AS cnt FROM call_logs").get() as { cnt: number }).cnt,
10,
"exactly maxRows rows must remain"
);
});
test("deleteCallLogsBefore deletes a batch larger than SQLite's variable limit without throwing", () => {
const db = core.getDbInstance();
// Exceed SQLITE_MAX_VARIABLE_NUMBER (999 on many builds, 32766 on newer ones).
// deleteCallLogsBefore passes EVERY matching id to one DELETE … IN (...) — the
// un-chunked version threw "too many SQL variables"; chunking must avoid it on
// any build. 35k > the 32766 cap, so this reproduces even on modern SQLite.
const total = 35000;
const base = Date.parse("2026-01-01T00:00:00.000Z");
const insertMany = db.transaction(() => {
for (let i = 0; i < total; i++) {
insertCallLog(`old-${String(i).padStart(5, "0")}`, new Date(base + i * 1000).toISOString());
}
});
insertMany();
let result: { deletedRows: number } | undefined;
assert.doesNotThrow(() => {
result = callLogs.deleteCallLogsBefore("2030-01-01T00:00:00.000Z");
});
assert.equal(result!.deletedRows, total, "every row before the cutoff must be deleted");
assert.equal(
(db.prepare("SELECT COUNT(*) AS cnt FROM call_logs").get() as { cnt: number }).cnt,
0
);
});

View File

@@ -2,7 +2,10 @@ import { describe, it, beforeEach, afterEach, before, after } from "node:test";
import assert from "node:assert";
import net from "node:net";
import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts";
import { resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts";
import {
resolveProxyForRequest,
runWithAppliedProxyCapture,
} from "../../open-sse/utils/proxyFetch.ts";
/**
* #4954 — "OpenCode Free" exposes per-account proxy + multi-account rotation in
@@ -165,4 +168,78 @@ describe("OpencodeExecutor per-account proxy + rotation (#4954)", () => {
assert.strictEqual(p.source, "context", "every dispatch must egress through a proxy context");
}
});
// #5217 (Gap 2): the per-request account/proxy selection log was log.debug, which
// is hidden at the default APP_LOG_LEVEL=info — operators could not see which
// account/proxy a request rotated to. It must be emitted at info level.
it("logs the account/proxy rotation selection at info level (#5217)", async () => {
const exec = new OpencodeExecutor("opencode-zen");
installFetchStub([200]);
const infoCalls: Array<{ tag: unknown; msg: string }> = [];
const debugCalls: Array<{ tag: unknown; msg: string }> = [];
const spyLog = {
debug: (tag: unknown, msg: string) => debugCalls.push({ tag, msg }),
info: (tag: unknown, msg: string) => infoCalls.push({ tag, msg }),
warn() {},
error() {},
};
await exec.execute({
model: "grok-code",
body: { messages: [{ role: "user", content: "hi" }], stream: false },
stream: false,
signal: null,
credentials: credentialsWithProxies(),
log: spyLog as any,
});
const dispatchInfo = infoCalls.find(
(c) => c.tag === "OPENCODE" && /dispatch via account/.test(c.msg)
);
assert.ok(
dispatchInfo,
`expected an info-level "dispatch via account …" log; info calls=${JSON.stringify(infoCalls)}`
);
// The selection line must carry the masked account id + rotation index, and
// must NOT be emitted at debug (where it would be invisible at default level).
assert.match(dispatchInfo!.msg, /account aaaaaaaa…|account bbbbbbbb…/);
assert.match(dispatchInfo!.msg, /idx \d+\/2/);
assert.ok(
!debugCalls.some((c) => /dispatch via account/.test(c.msg)),
"the selection log must not also/only be at debug level"
);
// Masking guard: never log the full 32-char account id.
assert.ok(
!/(a{32}|b{32})/.test(dispatchInfo!.msg),
"rotation log must keep the account id masked"
);
});
// #5217 (secondary): the per-account proxy the executor pins internally must be
// captured into an AppliedProxySink so the post-execution egress logger reflects
// the real egress (was "direct") rather than the pre-resolved connection proxy.
it("records the executor-applied account proxy into the AppliedProxySink (#5217)", async () => {
const exec = new OpencodeExecutor("opencode-zen");
installFetchStub([200]);
const sink: { proxy: any } = { proxy: null };
await runWithAppliedProxyCapture(sink, () =>
exec.execute({
model: "grok-code",
body: { messages: [{ role: "user", content: "hi" }], stream: false },
stream: false,
signal: null,
credentials: credentialsWithProxies(),
log,
})
);
assert.ok(sink.proxy, "sink must capture the proxy the executor actually applied");
assert.equal(sink.proxy.host, "127.0.0.1", "captured proxy host must match the account proxy");
assert.ok(
sink.proxy.port === portA || sink.proxy.port === portB,
`captured proxy port must be one of the configured account proxies, got ${sink.proxy.port}`
);
});
});