mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
* fix(windows): add windowsHide to all child process spawns (#8131) On Windows, child processes spawned without windowsHide: true cause transient conhost.exe/cmd console windows to flash open. Audited all spawn/exec/execFile/execSync/execFileSync call sites and added windowsHide: true where missing. Files patched: - src/mitm/manager.ts (MITM server spawn) - src/mitm/systemCommands.ts (sudo/system command spawn) - src/mitm/inspector/systemProxyConfig.ts (execFile wrapper) - src/shared/services/cliRuntime.ts (CLI spawn + npm execFileSync) - src/lib/plugins/loader.ts (plugin host spawn) - src/lib/providerModels/cursorAgent.ts (cursor binary spawn) - src/lib/cloudflaredTunnel.ts (cloudflared spawn) Unix-only call sites (shell: /bin/bash, which) are unaffected. electron/main.js already had windowsHide: true. * fix(windows): cover remaining spawn sites missed by #8131 windowsHide sweep Extends the #8131 windowsHide audit to the three call sites the original sweep missed: ServiceSupervisor.start() and processManager.startProcess() (both spawn() embedded-service child processes), and installers/utils.ts::buildNpmExecOptions() (the execFile() options runNpm() uses to install services). All three now always set windowsHide: true so no transient conhost.exe/cmd console window flashes open on Windows. The two spawn() options objects are factored into small, pure, exported builder functions (buildServiceSpawnOptions, buildCliproxyapiSpawnOptions) so the regression test can assert on the constructed options directly, since both call sites use a bare named `import { spawn } from "node:child_process"` that ESM live-binding semantics make unmockable without --experimental-test-module-mocks (not currently enabled repo-wide). Bumps config/quality/file-size-baseline.json for cloudflaredTunnel.ts 934->935 (the PR's own +1 windowsHide line at the existing spawn options object). Co-Authored-By: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Austin Liu <austinliu@Austins-MacBook-Air-3.local> Co-authored-by: Probe Test <probe@example.com> Co-authored-by: Dingding-leo <Dingding-leo@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"_rebaseline_2026_07_22_8131_windowshide_cloudflared_spawn": "PR #8167 (Dingding-leo, fix/windows-hide-child-process, #8131) own growth: src/lib/cloudflaredTunnel.ts 934->935 (+1, irreducible call-site wiring — the single `windowsHide: true` option added to the existing cloudflared spawn() options object so no transient conhost.exe/cmd console window flashes open on Windows). Covered by the pre-merge-fix regression test tests/unit/windows-hide-child-process-spawns-8131.test.ts (added for the two additional spawn() sites the PR missed: ServiceSupervisor.ts, versionManager/processManager.ts) plus the windowsHide assertion added to tests/unit/services/installers/runNpm-shell-5379.test.ts (installers/utils.ts buildNpmExecOptions).",
|
||||
"_rebaseline_2026_07_22_8006_adobe_firefly_media_provider": "PR #8006 (artickc, feat/adobe-firefly-media) own growth: adds Adobe Firefly as a media-only (image + video) provider — unofficial IMS/cookie-session bridge for firefly.adobe.com covering IMS cookie->access_token exchange, discovery-catalog fallback, credits/balance usage, and submit+poll dispatch for both image (nano-banana/gpt-image families) and video (Sora 2/Veo 3.1/Kling 3.0) generation, with 408-under-load retry handling. New leaf open-sse/services/adobeFireflyClient.ts frozen at 1958 (>>cap 800) — a single self-contained upstream client (mirrors the qoderCli.ts precedent for a new provider client that is legitimately large on day one: IMS auth, cookie/JWT normalization, payload builders for 2 media types x multiple model families, SSE-less submit/poll state machine, error sanitization); not extractable without scattering a single upstream integration across artificial module boundaries mid-PR. open-sse/config/imageRegistry.ts (existing, previously under cap) grows 800->821 (+21, the new adobe-firefly IMAGE_PROVIDERS entry + models list, additive registry data at the existing registry chokepoint). src/lib/usage/providerLimits.ts 1000->1003 (+3, adobe-firefly/firefly added to the existing apikey-usage-fetcher allowlist, irreducible call-site wiring mirroring the sibling #7994 PromptQL/HyperAgent entries in the same PR group). Covered by tests/unit/adobe-firefly.test.ts (35/35). Structural shrink tracked in #3501.",
|
||||
"_rebaseline_2026_07_22_7994_hyperagent_web_provider": "PR #7994 (artickc, feat/hyperagent-web) own growth: adds HyperAgent (hyperagent.com) as a new unofficial web-cookie chat provider, reverse-engineered from live SPA captures (thread/session SSE flow, credits/usage endpoint). New leaf open-sse/executors/hyperagent.ts frozen at 937 (>cap 800) — single self-contained executor covering cookie auth, SSE parsing (text/session_start/session_end/done events), and a sticky thread/session cache for multi-turn continuity; not extractable without splitting the executor mid-request-flow (mirrors the sseParser.ts/muse-spark-web.ts precedent for new provider executors that exceed cap on day one). src/lib/usage/providerLimits.ts 1000->1003 (+3, irreducible call-site wiring adding hyperagent/ha to the existing USAGE_FETCHER_PROVIDERS-style allowlist at the chokepoint other web-cookie providers already extend). Covered by tests/unit/executor-hyperagent.test.ts (16/16). Structural shrink tracked in #3501.",
|
||||
"_rebaseline_2026_07_21_7301_universal_cooldown_retry": "PR #7301 (ViFigueiredo, feat/universal-cooldown-retry) own growth, surfaced during rebase-onto-tip reconciliation (fast-gates PR->release do not run check:file-size): open-sse/services/combo.ts 3388->3479 (+91) generalizes the existing quota-share-only cooldown-aware retry (dispatchWithCooldownRetry) to ALL combo strategies (priority/weighted/round-robin/etc), gates it on the model lockout's REAL reason (not a hardcoded \"rate_limit\") via the existing getModelLockoutInfo/resolveComboCooldownWaitDecision chokepoint, and adds a global comboTimeoutMs guard + aggregated per-target error diagnostics on exhaustion. Companion leaves open-sse/services/combo/comboCooldownRetry.ts (+29), combo/autoStrategy.ts (+9, auto-strategy combo-ref guard so a combo cannot recursively reference itself as a candidate), combo/comboSetup.ts (+3), comboConfig.ts (+6) all stay under cap. Irreducible orchestration wiring at the existing dispatch chokepoint (mirrors the quota-share-only precedent this PR generalizes); not extractable without hiding the retry loop. Covered by tests/unit/combo-auto-candidate-expansion.test.ts (+61, combo-ref guard), tests/unit/combo-routing-engine.test.ts (+68, universal retry across strategies + comboTimeoutMs, no-explicit-any clean), tests/unit/serial/combo-quota-share-cooldown-wait-timing.test.ts (+136, quota_exhausted vs rate_limit reason gating, disabled-flag passthrough). Structural shrink of combo.ts tracked in #3501.",
|
||||
@@ -259,7 +260,7 @@
|
||||
"src/app/api/providers/[id]/test/route.ts": 940,
|
||||
"src/app/api/usage/analytics/route.ts": 948,
|
||||
"src/app/api/v1/models/catalog.ts": 1615,
|
||||
"src/lib/cloudflaredTunnel.ts": 934,
|
||||
"src/lib/cloudflaredTunnel.ts": 935,
|
||||
"src/lib/db/apiKeys.ts": 1662,
|
||||
"src/lib/db/core.ts": 1825,
|
||||
"src/lib/db/migrationRunner.ts": 1125,
|
||||
|
||||
@@ -815,6 +815,7 @@ export async function startCloudflaredTunnel(): Promise<CloudflaredTunnelStatus>
|
||||
});
|
||||
|
||||
const child = spawn(binary.binaryPath as string, getCloudflaredStartArgs(targetUrl), {
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: buildCloudflaredChildEnv(),
|
||||
});
|
||||
|
||||
@@ -137,6 +137,7 @@ export async function loadPlugin(
|
||||
};
|
||||
|
||||
const child = spawn(process.execPath, ["--no-warnings", hostScriptPath, entryPoint], {
|
||||
windowsHide: true,
|
||||
env,
|
||||
stdio: ["ignore", "ignore", "ignore", "ipc"],
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ function runCursorAgent(
|
||||
return new Promise((resolve, reject) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn(binary, args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
child = spawn(binary, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
return;
|
||||
|
||||
@@ -12,6 +12,26 @@ import type { ServiceConfig, ServiceState, ServiceStatus, LogLine, HealthState }
|
||||
|
||||
const CRASH_FAST_THRESHOLD_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Builds the `spawn()` options for a supervised service child process.
|
||||
* `windowsHide: true` suppresses the transient conhost.exe/cmd console
|
||||
* window Windows briefly flashes open for spawned child processes (#8131).
|
||||
* Exported (rather than inlined) so a unit test can assert on it directly
|
||||
* instead of mocking `node:child_process`.
|
||||
*/
|
||||
export function buildServiceSpawnOptions(
|
||||
env: NodeJS.ProcessEnv | undefined,
|
||||
cwd: string | undefined
|
||||
): { env: NodeJS.ProcessEnv | undefined; cwd: string | undefined; detached: boolean; stdio: ["ignore", "pipe", "pipe"]; windowsHide: boolean } {
|
||||
return {
|
||||
env,
|
||||
cwd,
|
||||
detached: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
};
|
||||
}
|
||||
|
||||
export class ServiceSupervisor extends EventEmitter {
|
||||
private state: ServiceState = "stopped";
|
||||
private health: HealthState = "unknown";
|
||||
@@ -90,12 +110,7 @@ export class ServiceSupervisor extends EventEmitter {
|
||||
|
||||
const { command, args, env, cwd } = this.config.spawnArgs();
|
||||
|
||||
const child = spawn(command, args, {
|
||||
env,
|
||||
cwd,
|
||||
detached: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
const child = spawn(command, args, buildServiceSpawnOptions(env, cwd));
|
||||
|
||||
this.childProcess = child;
|
||||
this.pid = child.pid ?? null;
|
||||
|
||||
@@ -92,6 +92,7 @@ export interface NpmExecOptions {
|
||||
env: NodeJS.ProcessEnv;
|
||||
maxBuffer: number;
|
||||
shell?: boolean;
|
||||
windowsHide: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,6 +124,9 @@ export function buildNpmExecOptions(
|
||||
timeout: options.timeoutMs,
|
||||
env,
|
||||
maxBuffer: 10 * 1024 * 1024, // 10 MB for npm output
|
||||
// Suppress the transient conhost.exe/cmd console window Windows briefly
|
||||
// flashes open for spawned child processes (see #8131).
|
||||
windowsHide: true,
|
||||
};
|
||||
if (platform === "win32") {
|
||||
execOptions.shell = true;
|
||||
|
||||
@@ -8,6 +8,27 @@ import { setToolStatus, getVersionManagerTool } from "@/lib/db/versionManager";
|
||||
const DEFAULT_PORT = 8317;
|
||||
const GRACEFUL_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* Builds the `spawn()` options for the cliproxyapi child process.
|
||||
* `windowsHide: true` suppresses the transient conhost.exe/cmd console
|
||||
* window Windows briefly flashes open for spawned child processes (#8131).
|
||||
* Exported (rather than inlined) so a unit test can assert on it directly
|
||||
* instead of mocking `node:child_process`.
|
||||
*/
|
||||
export function buildCliproxyapiSpawnOptions(): {
|
||||
detached: boolean;
|
||||
stdio: ["ignore", "pipe", "pipe"];
|
||||
env: NodeJS.ProcessEnv;
|
||||
windowsHide: boolean;
|
||||
} {
|
||||
return {
|
||||
detached: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env },
|
||||
windowsHide: true,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultConfigDir(): string {
|
||||
return process.env.CLIPROXYAPI_CONFIG_DIR || path.join(os.homedir(), ".cli-proxy-api");
|
||||
}
|
||||
@@ -42,11 +63,11 @@ export async function startProcess(
|
||||
const actualConfigDir = configDir || defaultConfigDir();
|
||||
await writeConfig(actualConfigDir, actualPort);
|
||||
|
||||
const child = spawn(binaryPath, ["-c", path.join(actualConfigDir, "config.yaml")], {
|
||||
detached: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: { ...process.env },
|
||||
});
|
||||
const child = spawn(
|
||||
binaryPath,
|
||||
["-c", path.join(actualConfigDir, "config.yaml")],
|
||||
buildCliproxyapiSpawnOptions()
|
||||
);
|
||||
|
||||
child.stdout?.on("data", () => {});
|
||||
child.stderr?.on("data", () => {});
|
||||
|
||||
@@ -41,10 +41,7 @@ export interface WindowsPreviousState {
|
||||
netshOutput: string;
|
||||
}
|
||||
|
||||
export type PreviousState =
|
||||
| MacOsPreviousState
|
||||
| LinuxPreviousState
|
||||
| WindowsPreviousState;
|
||||
export type PreviousState = MacOsPreviousState | LinuxPreviousState | WindowsPreviousState;
|
||||
|
||||
export interface ApplyResult {
|
||||
platform: Platform;
|
||||
@@ -67,7 +64,7 @@ function defaultExec(
|
||||
options: ExecFileOptions = {}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(file, args, options, (err, stdout, stderr) => {
|
||||
execFile(file, args, { windowsHide: true, ...options }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
@@ -152,12 +149,7 @@ async function macosApply(port: number): Promise<MacOsPreviousState> {
|
||||
async function macosRevert(state: MacOsPreviousState): Promise<void> {
|
||||
const service = state.service;
|
||||
if (state.http.enabled && state.http.host && state.http.port) {
|
||||
await execImpl("networksetup", [
|
||||
"-setwebproxy",
|
||||
service,
|
||||
state.http.host,
|
||||
state.http.port,
|
||||
]);
|
||||
await execImpl("networksetup", ["-setwebproxy", service, state.http.host, state.http.port]);
|
||||
} else {
|
||||
await execImpl("networksetup", ["-setwebproxystate", service, "off"]);
|
||||
}
|
||||
@@ -186,10 +178,7 @@ async function readGsetting(key: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
async function readGsubsetting(
|
||||
scheme: string,
|
||||
key: string
|
||||
): Promise<string> {
|
||||
async function readGsubsetting(scheme: string, key: string): Promise<string> {
|
||||
try {
|
||||
// HR#13: concat (not template) — scheme is a hardcoded "http"|"https" constant.
|
||||
const { stdout } = await execImpl("gsettings", [
|
||||
@@ -232,20 +221,10 @@ async function linuxRevert(state: LinuxPreviousState): Promise<void> {
|
||||
await execImpl("gsettings", ["set", "org.gnome.system.proxy.http", "port", state.httpPort]);
|
||||
}
|
||||
if (state.httpsHost) {
|
||||
await execImpl("gsettings", [
|
||||
"set",
|
||||
"org.gnome.system.proxy.https",
|
||||
"host",
|
||||
state.httpsHost,
|
||||
]);
|
||||
await execImpl("gsettings", ["set", "org.gnome.system.proxy.https", "host", state.httpsHost]);
|
||||
}
|
||||
if (state.httpsPort) {
|
||||
await execImpl("gsettings", [
|
||||
"set",
|
||||
"org.gnome.system.proxy.https",
|
||||
"port",
|
||||
state.httpsPort,
|
||||
]);
|
||||
await execImpl("gsettings", ["set", "org.gnome.system.proxy.https", "port", state.httpsPort]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,8 +287,7 @@ export async function revert(previousState: PreviousState | unknown): Promise<vo
|
||||
try {
|
||||
if (platform === "macos") await macosRevert(state as unknown as MacOsPreviousState);
|
||||
else if (platform === "linux") await linuxRevert(state as unknown as LinuxPreviousState);
|
||||
else if (platform === "windows")
|
||||
await windowsRevert(state as unknown as WindowsPreviousState);
|
||||
else if (platform === "windows") await windowsRevert(state as unknown as WindowsPreviousState);
|
||||
} catch (err) {
|
||||
throw new Error(sanitizeErrorMessage(err) || "system proxy revert failed");
|
||||
}
|
||||
|
||||
@@ -539,7 +539,10 @@ async function startMitmInternal(
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({ err }, "installCertResult threw unexpectedly (continuing without trusted cert)");
|
||||
log.error(
|
||||
{ err },
|
||||
"installCertResult threw unexpectedly (continuing without trusted cert)"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -586,6 +589,7 @@ async function startMitmInternal(
|
||||
}
|
||||
|
||||
serverProcess = spawn(process.execPath, [MITM_SERVER_PATH], {
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
ROUTER_API_KEY: apiKey,
|
||||
|
||||
@@ -140,7 +140,9 @@ export function execFileWithPassword(
|
||||
// `spawn` is used (not `exec`) so each arg is a separate argv entry and
|
||||
// shell metacharacters do not expand. See docs/security/SOCKET_DEV_FINDINGS.md §3.
|
||||
// nosemgrep
|
||||
const child = spawn(finalCommand, finalArgs, { // nosemgrep
|
||||
const child = spawn(finalCommand, finalArgs, {
|
||||
// nosemgrep
|
||||
windowsHide: true,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
|
||||
@@ -332,6 +332,7 @@ const runProcess = (
|
||||
// `command` as a raw argv[0] and the OS loader handles spaces. When useShell
|
||||
// is true (.cmd/.bat on Windows), Node quotes the command for cmd.exe itself.
|
||||
const child = spawn(command, args, {
|
||||
windowsHide: true,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
// On Windows, npm installs CLI wrappers as .cmd/.bat scripts. Those still
|
||||
@@ -466,6 +467,7 @@ const getNpmGlobalPrefix = (): string => {
|
||||
|
||||
try {
|
||||
const result = execFileSync("npm", ["config", "get", "prefix"], {
|
||||
windowsHide: true,
|
||||
timeout: 5000,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
@@ -874,7 +876,10 @@ const locateCommandCandidate = async (
|
||||
// This avoids searching PATH and reduces attack surface
|
||||
let bestKnownPathFailure: KnownPathResult | null = null;
|
||||
if (toolId) {
|
||||
const { match, bestFailure } = await findKnownPathMatch(getKnownToolPaths(toolId), checkKnownPath);
|
||||
const { match, bestFailure } = await findKnownPathMatch(
|
||||
getKnownToolPaths(toolId),
|
||||
checkKnownPath
|
||||
);
|
||||
if (match) {
|
||||
return {
|
||||
command: commands[0],
|
||||
|
||||
@@ -52,6 +52,16 @@ test("buildNpmExecOptions: carries cwd, timeout and maxBuffer through", () => {
|
||||
assert.equal(opts.maxBuffer, 10 * 1024 * 1024);
|
||||
});
|
||||
|
||||
// Regression for #8131 — windowsHide: true must be set on every execFile/spawn
|
||||
// options object so Windows does not flash a transient conhost.exe/cmd window
|
||||
// for the npm child process runNpm() spawns.
|
||||
test("buildNpmExecOptions: windowsHide is always true regardless of platform", () => {
|
||||
for (const platform of ["win32", "linux", "darwin", "freebsd"] as NodeJS.Platform[]) {
|
||||
const opts = buildNpmExecOptions(platform, { timeoutMs: 1000 });
|
||||
assert.equal(opts.windowsHide, true, `${platform} must set windowsHide: true (#8131)`);
|
||||
}
|
||||
});
|
||||
|
||||
test("SERVICE_VERSION_PATTERN: accepts dist-tags and semver", () => {
|
||||
for (const v of ["latest", "next", "1.2.3", "1.2.3-beta.1", "1.2.3+build.5", "0.4.59"]) {
|
||||
assert.ok(SERVICE_VERSION_PATTERN.test(v), `${v} should be valid`);
|
||||
|
||||
40
tests/unit/windows-hide-child-process-spawns-8131.test.ts
Normal file
40
tests/unit/windows-hide-child-process-spawns-8131.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Regression test for #8131 — on Windows, child processes spawned without
|
||||
* `windowsHide: true` cause a transient conhost.exe/cmd console window to
|
||||
* flash open. PR #8167 audited most spawn/exec call sites but missed the
|
||||
* two `child_process.spawn()` call sites below plus the `execFile()` wrapper
|
||||
* used by `runNpm()` (covered separately in
|
||||
* tests/unit/services/installers/runNpm-shell-5379.test.ts).
|
||||
*
|
||||
* Both `ServiceSupervisor.start()` and `processManager.startProcess()` use a
|
||||
* bare named `import { spawn } from "node:child_process"`, which Node's ESM
|
||||
* live-binding semantics make impossible to intercept with
|
||||
* `mock.method()`/`mock.module()` without the (project-wide, not currently
|
||||
* enabled) `--experimental-test-module-mocks` flag. Rather than widen the
|
||||
* test-runner flags, the options object each call site passes to `spawn()`
|
||||
* is factored into a small, pure, exported builder function — asserted on
|
||||
* directly here instead of mocking `node:child_process`.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildServiceSpawnOptions } from "../../src/lib/services/ServiceSupervisor.ts";
|
||||
import { buildCliproxyapiSpawnOptions } from "../../src/lib/versionManager/processManager.ts";
|
||||
|
||||
test("ServiceSupervisor: buildServiceSpawnOptions sets windowsHide: true (#8131)", () => {
|
||||
const opts = buildServiceSpawnOptions({ FOO: "bar" }, "/tmp/cwd");
|
||||
assert.equal(opts.windowsHide, true);
|
||||
// Sanity: the rest of the previously-inline options object still round-trips.
|
||||
assert.equal(opts.env?.FOO, "bar");
|
||||
assert.equal(opts.cwd, "/tmp/cwd");
|
||||
assert.equal(opts.detached, false);
|
||||
assert.deepEqual(opts.stdio, ["ignore", "pipe", "pipe"]);
|
||||
});
|
||||
|
||||
test("processManager: buildCliproxyapiSpawnOptions sets windowsHide: true (#8131)", () => {
|
||||
const opts = buildCliproxyapiSpawnOptions();
|
||||
assert.equal(opts.windowsHide, true);
|
||||
assert.equal(opts.detached, false);
|
||||
assert.deepEqual(opts.stdio, ["ignore", "pipe", "pipe"]);
|
||||
assert.ok(opts.env, "env should be populated from process.env");
|
||||
});
|
||||
Reference in New Issue
Block a user