feat(security): support operator-managed network proxy routing (#70044)

* feat: support operator-managed proxy routing

* docs: add network proxy changelog entry

* fix(proxy): restrict gateway bypass to loopback IPs

* fix(cli): harden container proxy URL checks

* docs(proxy): clarify gateway bypass scope

* docs: remove proxy changelog entry

* fix(proxy): clear startup CI guard failures

* fix(proxy): harden gateway proxy policy parsing

* fix(proxy): honor update shorthand proxy policy

* fix(cli): redact proxy URL suffixes

* test(proxy): keep gateway help off proxy startup

* fix(proxy): keep overlapping lifecycle active

* docs: add proxy changelog entry

---------

Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
This commit is contained in:
Jesse Merhi
2026-04-28 15:20:47 +10:00
committed by GitHub
parent 025081dbc5
commit 2633b14914
36 changed files with 2737 additions and 96 deletions

View File

@@ -1,5 +1,11 @@
import { hasFlag } from "./argv.js";
export type CliCommandPluginLoadPolicy = "never" | "always" | "text-only";
export type CliRouteConfigGuardPolicy = "never" | "always" | "when-suppressed";
export type CliNetworkProxyPolicy = "default" | "bypass";
export type CliNetworkProxyPolicyResolver =
| CliNetworkProxyPolicy
| ((ctx: { argv: string[]; commandPath: string[] }) => CliNetworkProxyPolicy);
export type CliRoutedCommandId =
| "health"
| "status"
@@ -21,6 +27,7 @@ export type CliCommandPathPolicy = {
loadPlugins: CliCommandPluginLoadPolicy;
hideBanner: boolean;
ensureCliPath: boolean;
networkProxy: CliNetworkProxyPolicyResolver;
};
export type CliCommandCatalogEntry = {
@@ -38,11 +45,17 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
commandPath: ["crestodian"],
policy: { bypassConfigGuard: true, loadPlugins: "never", ensureCliPath: false },
},
{ commandPath: ["agent"], policy: { loadPlugins: "always" } },
{
commandPath: ["agent"],
policy: {
loadPlugins: "always",
networkProxy: ({ argv }) => (hasFlag(argv, "--local") ? "default" : "bypass"),
},
},
{ commandPath: ["message"], policy: { loadPlugins: "never" } },
{ commandPath: ["channels"], policy: { loadPlugins: "always" } },
{ commandPath: ["directory"], policy: { loadPlugins: "always" } },
{ commandPath: ["agents"], policy: { loadPlugins: "always" } },
{ commandPath: ["agents"], policy: { loadPlugins: "always", networkProxy: "bypass" } },
{
commandPath: ["agents", "bind"],
exact: true,
@@ -69,34 +82,59 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
policy: { loadPlugins: "never" },
},
{ commandPath: ["configure"], policy: { bypassConfigGuard: true, loadPlugins: "never" } },
{ commandPath: ["migrate"], policy: { bypassConfigGuard: true, loadPlugins: "never" } },
{
commandPath: ["migrate"],
policy: { bypassConfigGuard: true, loadPlugins: "never", networkProxy: "bypass" },
},
{
commandPath: ["status"],
policy: {
loadPlugins: "never",
routeConfigGuard: "when-suppressed",
ensureCliPath: false,
networkProxy: "bypass",
},
route: { id: "status" },
},
{
commandPath: ["health"],
policy: { loadPlugins: "never", ensureCliPath: false },
policy: { loadPlugins: "never", ensureCliPath: false, networkProxy: "bypass" },
route: { id: "health" },
},
{
commandPath: ["gateway"],
policy: {
networkProxy: ({ commandPath }) =>
commandPath.length === 1 || commandPath[1] === "run" ? "default" : "bypass",
},
},
{
commandPath: ["gateway", "status"],
exact: true,
policy: {
routeConfigGuard: "always",
loadPlugins: "never",
networkProxy: "bypass",
},
route: { id: "gateway-status" },
},
{ commandPath: ["gateway", "call"], exact: true, policy: { networkProxy: "bypass" } },
{ commandPath: ["gateway", "diagnostics"], exact: true, policy: { networkProxy: "bypass" } },
{ commandPath: ["gateway", "discover"], exact: true, policy: { networkProxy: "bypass" } },
{ commandPath: ["gateway", "export"], exact: true, policy: { networkProxy: "bypass" } },
{ commandPath: ["gateway", "health"], exact: true, policy: { networkProxy: "bypass" } },
{ commandPath: ["gateway", "install"], exact: true, policy: { networkProxy: "bypass" } },
{ commandPath: ["gateway", "probe"], exact: true, policy: { networkProxy: "bypass" } },
{ commandPath: ["gateway", "restart"], exact: true, policy: { networkProxy: "bypass" } },
{ commandPath: ["gateway", "stability"], exact: true, policy: { networkProxy: "bypass" } },
{ commandPath: ["gateway", "start"], exact: true, policy: { networkProxy: "bypass" } },
{ commandPath: ["gateway", "stop"], exact: true, policy: { networkProxy: "bypass" } },
{ commandPath: ["gateway", "uninstall"], exact: true, policy: { networkProxy: "bypass" } },
{ commandPath: ["gateway", "usage-cost"], exact: true, policy: { networkProxy: "bypass" } },
{
commandPath: ["sessions"],
exact: true,
policy: { ensureCliPath: false },
policy: { ensureCliPath: false, networkProxy: "bypass" },
route: { id: "sessions" },
},
{
@@ -106,70 +144,121 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
// is only used in human text output. text-only skips the bundled-plugin
// import waterfall in `--json` mode, mirroring what `channels list`
// already does. Human (non-JSON) invocations still load plugins. (#71739)
policy: { loadPlugins: "text-only" },
policy: { loadPlugins: "text-only", networkProxy: "bypass" },
route: { id: "agents-list" },
},
{
commandPath: ["config", "get"],
exact: true,
policy: { ensureCliPath: false },
policy: { ensureCliPath: false, networkProxy: "bypass" },
route: { id: "config-get" },
},
{
commandPath: ["config", "unset"],
exact: true,
policy: { ensureCliPath: false },
policy: { ensureCliPath: false, networkProxy: "bypass" },
route: { id: "config-unset" },
},
{
commandPath: ["models", "list"],
exact: true,
policy: { ensureCliPath: false, routeConfigGuard: "always" },
policy: { ensureCliPath: false, routeConfigGuard: "always", networkProxy: "bypass" },
route: { id: "models-list" },
},
{
commandPath: ["models", "status"],
exact: true,
policy: { ensureCliPath: false, routeConfigGuard: "always" },
policy: {
ensureCliPath: false,
routeConfigGuard: "always",
networkProxy: ({ argv }) => (hasFlag(argv, "--probe") ? "default" : "bypass"),
},
route: { id: "models-status" },
},
{
commandPath: ["tasks", "list"],
exact: true,
policy: { ensureCliPath: false, routeConfigGuard: "when-suppressed", loadPlugins: "never" },
policy: {
ensureCliPath: false,
routeConfigGuard: "when-suppressed",
loadPlugins: "never",
networkProxy: "bypass",
},
route: { id: "tasks-list" },
},
{
commandPath: ["tasks", "audit"],
exact: true,
policy: { ensureCliPath: false, routeConfigGuard: "when-suppressed", loadPlugins: "never" },
policy: {
ensureCliPath: false,
routeConfigGuard: "when-suppressed",
loadPlugins: "never",
networkProxy: "bypass",
},
route: { id: "tasks-audit" },
},
{
commandPath: ["tasks"],
policy: { ensureCliPath: false, routeConfigGuard: "when-suppressed", loadPlugins: "never" },
policy: {
ensureCliPath: false,
routeConfigGuard: "when-suppressed",
loadPlugins: "never",
networkProxy: "bypass",
},
route: { id: "tasks-list" },
},
{ commandPath: ["backup"], policy: { bypassConfigGuard: true } },
{ commandPath: ["acp"], policy: { networkProxy: "bypass" } },
{ commandPath: ["approvals"], policy: { networkProxy: "bypass" } },
{ commandPath: ["backup"], policy: { bypassConfigGuard: true, networkProxy: "bypass" } },
{ commandPath: ["chat"], policy: { networkProxy: "bypass" } },
{ commandPath: ["config"], policy: { networkProxy: "bypass" } },
{ commandPath: ["cron"], policy: { networkProxy: "bypass" } },
{ commandPath: ["dashboard"], policy: { networkProxy: "bypass" } },
{ commandPath: ["daemon"], policy: { networkProxy: "bypass" } },
{ commandPath: ["devices"], policy: { networkProxy: "bypass" } },
{ commandPath: ["doctor"], policy: { bypassConfigGuard: true } },
{ commandPath: ["exec-policy"], policy: { networkProxy: "bypass" } },
{ commandPath: ["hooks"], policy: { networkProxy: "bypass" } },
{ commandPath: ["logs"], policy: { networkProxy: "bypass" } },
{ commandPath: ["mcp"], policy: { networkProxy: "bypass" } },
{
commandPath: ["node"],
policy: { networkProxy: "bypass" },
},
{
commandPath: ["node", "run"],
exact: true,
policy: { networkProxy: "default" },
},
{ commandPath: ["nodes"], policy: { networkProxy: "bypass" } },
{ commandPath: ["pairing"], policy: { networkProxy: "bypass" } },
{ commandPath: ["proxy"], policy: { networkProxy: "bypass" } },
{ commandPath: ["qr"], policy: { networkProxy: "bypass" } },
{ commandPath: ["reset"], policy: { networkProxy: "bypass" } },
{
commandPath: ["completion"],
policy: {
bypassConfigGuard: true,
hideBanner: true,
networkProxy: "bypass",
},
},
{ commandPath: ["secrets"], policy: { bypassConfigGuard: true } },
{ commandPath: ["secrets"], policy: { bypassConfigGuard: true, networkProxy: "bypass" } },
{ commandPath: ["security"], policy: { networkProxy: "bypass" } },
{ commandPath: ["system"], policy: { networkProxy: "bypass" } },
{ commandPath: ["terminal"], policy: { networkProxy: "bypass" } },
{ commandPath: ["tui"], policy: { networkProxy: "bypass" } },
{ commandPath: ["uninstall"], policy: { networkProxy: "bypass" } },
{ commandPath: ["update"], policy: { hideBanner: true } },
{
commandPath: ["config", "validate"],
exact: true,
policy: { bypassConfigGuard: true },
policy: { bypassConfigGuard: true, networkProxy: "bypass" },
},
{
commandPath: ["config", "schema"],
exact: true,
policy: { bypassConfigGuard: true },
policy: { bypassConfigGuard: true, networkProxy: "bypass" },
},
{
commandPath: ["plugins", "update"],
@@ -184,23 +273,43 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
{
commandPath: ["channels", "add"],
exact: true,
policy: { loadPlugins: "never" },
policy: { loadPlugins: "never", networkProxy: "bypass" },
},
{
commandPath: ["channels", "logs"],
exact: true,
policy: { loadPlugins: "never", networkProxy: "bypass" },
},
{
commandPath: ["channels", "remove"],
exact: true,
policy: { networkProxy: "bypass" },
},
{
commandPath: ["channels", "resolve"],
exact: true,
policy: { networkProxy: "bypass" },
},
{
commandPath: ["channels", "status"],
exact: true,
policy: { loadPlugins: "never" },
policy: {
loadPlugins: "never",
networkProxy: ({ argv }) => (hasFlag(argv, "--probe") ? "default" : "bypass"),
},
route: { id: "channels-status" },
},
{
commandPath: ["channels", "list"],
exact: true,
policy: { loadPlugins: "never" },
policy: { loadPlugins: "never", networkProxy: "bypass" },
route: { id: "channels-list" },
},
{
commandPath: ["channels", "logs"],
exact: true,
policy: { loadPlugins: "never" },
},
{ commandPath: ["skills"], exact: true, policy: { networkProxy: "bypass" } },
{ commandPath: ["skills", "check"], exact: true, policy: { networkProxy: "bypass" } },
{ commandPath: ["skills", "info"], exact: true, policy: { networkProxy: "bypass" } },
{ commandPath: ["skills", "install"], exact: true },
{ commandPath: ["skills", "list"], exact: true, policy: { networkProxy: "bypass" } },
{ commandPath: ["skills", "search"], exact: true },
{ commandPath: ["skills", "update"], exact: true },
];

View File

@@ -1,7 +1,17 @@
import { describe, expect, it } from "vitest";
import { resolveCliCommandPathPolicy } from "./command-path-policy.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CliCommandCatalogEntry } from "./command-catalog.js";
import {
resolveCliCatalogCommandPath,
resolveCliCommandPathPolicy,
resolveCliNetworkProxyPolicy,
} from "./command-path-policy.js";
describe("command-path-policy", () => {
afterEach(() => {
vi.doUnmock("./command-catalog.js");
vi.resetModules();
});
it("resolves status policy with shared startup semantics", () => {
expect(resolveCliCommandPathPolicy(["status"])).toEqual({
bypassConfigGuard: false,
@@ -9,6 +19,7 @@ describe("command-path-policy", () => {
loadPlugins: "never",
hideBanner: false,
ensureCliPath: false,
networkProxy: "bypass",
});
});
@@ -19,6 +30,7 @@ describe("command-path-policy", () => {
loadPlugins: "always",
hideBanner: false,
ensureCliPath: true,
networkProxy: "default",
});
expect(resolveCliCommandPathPolicy(["channels", "add"])).toEqual({
bypassConfigGuard: false,
@@ -26,6 +38,7 @@ describe("command-path-policy", () => {
loadPlugins: "never",
hideBanner: false,
ensureCliPath: true,
networkProxy: "bypass",
});
expect(resolveCliCommandPathPolicy(["channels", "status"])).toEqual({
bypassConfigGuard: false,
@@ -33,6 +46,7 @@ describe("command-path-policy", () => {
loadPlugins: "never",
hideBanner: false,
ensureCliPath: true,
networkProxy: expect.any(Function),
});
expect(resolveCliCommandPathPolicy(["channels", "list"])).toEqual({
bypassConfigGuard: false,
@@ -40,6 +54,7 @@ describe("command-path-policy", () => {
loadPlugins: "never",
hideBanner: false,
ensureCliPath: true,
networkProxy: "bypass",
});
expect(resolveCliCommandPathPolicy(["channels", "logs"])).toEqual({
bypassConfigGuard: false,
@@ -47,6 +62,7 @@ describe("command-path-policy", () => {
loadPlugins: "never",
hideBanner: false,
ensureCliPath: true,
networkProxy: "bypass",
});
});
@@ -64,6 +80,7 @@ describe("command-path-policy", () => {
loadPlugins: "never",
hideBanner: false,
ensureCliPath: true,
networkProxy: "bypass",
});
}
});
@@ -75,6 +92,7 @@ describe("command-path-policy", () => {
loadPlugins: "never",
hideBanner: false,
ensureCliPath: true,
networkProxy: "default",
});
expect(resolveCliCommandPathPolicy(["config", "validate"])).toEqual({
bypassConfigGuard: true,
@@ -82,6 +100,7 @@ describe("command-path-policy", () => {
loadPlugins: "never",
hideBanner: false,
ensureCliPath: true,
networkProxy: "bypass",
});
expect(resolveCliCommandPathPolicy(["gateway", "status"])).toEqual({
bypassConfigGuard: false,
@@ -89,6 +108,7 @@ describe("command-path-policy", () => {
loadPlugins: "never",
hideBanner: false,
ensureCliPath: true,
networkProxy: "bypass",
});
expect(resolveCliCommandPathPolicy(["plugins", "update"])).toEqual({
bypassConfigGuard: false,
@@ -96,6 +116,7 @@ describe("command-path-policy", () => {
loadPlugins: "never",
hideBanner: true,
ensureCliPath: true,
networkProxy: "default",
});
for (const commandPath of [
["plugins", "install"],
@@ -110,6 +131,7 @@ describe("command-path-policy", () => {
loadPlugins: "never",
hideBanner: false,
ensureCliPath: true,
networkProxy: "default",
});
}
expect(resolveCliCommandPathPolicy(["cron", "list"])).toEqual({
@@ -118,6 +140,116 @@ describe("command-path-policy", () => {
loadPlugins: "never",
hideBanner: false,
ensureCliPath: true,
networkProxy: "bypass",
});
});
it("defaults unknown command paths to network proxy routing", () => {
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "googlemeet", "login"])).toBe(
"default",
);
});
it("resolves static network proxy bypass policies from the catalog", () => {
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "status"])).toBe("bypass");
expect(
resolveCliNetworkProxyPolicy(["node", "openclaw", "config", "get", "proxy.enabled"]),
).toBe("bypass");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "proxy", "start"])).toBe("bypass");
});
it("resolves mixed network proxy policies from argv-sensitive catalog entries", () => {
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "gateway"])).toBe("default");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "gateway", "run"])).toBe("default");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "gateway", "health"])).toBe("bypass");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "node", "run"])).toBe("default");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "node", "status"])).toBe("bypass");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "agent", "--local"])).toBe("default");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "agent", "run"])).toBe("bypass");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "channels", "status"])).toBe("bypass");
expect(
resolveCliNetworkProxyPolicy(["node", "openclaw", "channels", "status", "--probe"]),
).toBe("default");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "models", "status"])).toBe("bypass");
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "models", "status", "--probe"])).toBe(
"default",
);
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "skills", "info", "browser"])).toBe(
"bypass",
);
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "skills", "search", "browser"])).toBe(
"default",
);
});
it("uses the longest catalog command path for deep network proxy overrides", async () => {
const catalog: readonly CliCommandCatalogEntry[] = [
{ commandPath: ["nodes"], policy: { networkProxy: "bypass" } },
{
commandPath: ["nodes", "camera", "snap"],
exact: true,
policy: { networkProxy: "default" },
},
];
vi.resetModules();
vi.doMock("./command-catalog.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./command-catalog.js")>();
return { ...actual, cliCommandCatalog: catalog };
});
const { resolveCliCatalogCommandPath, resolveCliNetworkProxyPolicy } =
await import("./command-path-policy.js");
expect(resolveCliCatalogCommandPath(["node", "openclaw", "nodes", "camera", "snap"])).toEqual([
"nodes",
"camera",
"snap",
]);
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "nodes", "camera", "snap"])).toBe(
"default",
);
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "nodes", "camera", "list"])).toBe(
"bypass",
);
});
it("stops catalog command path resolution before positional arguments", () => {
expect(
resolveCliCatalogCommandPath(["node", "openclaw", "config", "get", "proxy.enabled"]),
).toEqual(["config", "get"]);
expect(
resolveCliCatalogCommandPath(["node", "openclaw", "message", "send", "--to", "demo"]),
).toEqual(["message"]);
});
it("treats bare gateway invocations with options as the gateway runtime", () => {
const argv = ["node", "openclaw", "gateway", "--port", "1234"];
expect(resolveCliCatalogCommandPath(argv)).toEqual(["gateway"]);
expect(resolveCliNetworkProxyPolicy(argv)).toBe("default");
});
it("does not let gateway run option values spoof bypass subcommands", () => {
for (const argv of [
["node", "openclaw", "gateway", "--token", "status"],
["node", "openclaw", "gateway", "--token=status"],
["node", "openclaw", "gateway", "--password", "health"],
["node", "openclaw", "gateway", "--password-file", "status"],
["node", "openclaw", "gateway", "--ws-log", "compact"],
]) {
expect(resolveCliCatalogCommandPath(argv), argv.join(" ")).toEqual(["gateway"]);
expect(resolveCliNetworkProxyPolicy(argv), argv.join(" ")).toBe("default");
}
});
it("still resolves real gateway bypass subcommands after their command token", () => {
expect(resolveCliCatalogCommandPath(["node", "openclaw", "gateway", "status"])).toEqual([
"gateway",
"status",
]);
expect(
resolveCliCatalogCommandPath(["node", "openclaw", "gateway", "status", "--token", "secret"]),
).toEqual(["gateway", "status"]);
expect(resolveCliNetworkProxyPolicy(["node", "openclaw", "gateway", "status"])).toBe("bypass");
});
});

View File

@@ -1,6 +1,12 @@
import { isGatewayConfigBypassCommandPath } from "../gateway/explicit-connection-policy.js";
import { cliCommandCatalog, type CliCommandPathPolicy } from "./command-catalog.js";
import { getCommandPathWithRootOptions } from "./argv.js";
import {
cliCommandCatalog,
type CliCommandPathPolicy,
type CliNetworkProxyPolicy,
} from "./command-catalog.js";
import { matchesCommandPath } from "./command-path-matches.js";
import { resolveGatewayCatalogCommandPath } from "./gateway-run-argv.js";
const DEFAULT_CLI_COMMAND_PATH_POLICY: CliCommandPathPolicy = {
bypassConfigGuard: false,
@@ -8,6 +14,7 @@ const DEFAULT_CLI_COMMAND_PATH_POLICY: CliCommandPathPolicy = {
loadPlugins: "never",
hideBanner: false,
ensureCliPath: true,
networkProxy: "default",
};
export function resolveCliCommandPathPolicy(commandPath: string[]): CliCommandPathPolicy {
@@ -26,3 +33,31 @@ export function resolveCliCommandPathPolicy(commandPath: string[]): CliCommandPa
}
return resolvedPolicy;
}
function isCommandPathPrefix(commandPath: string[], pattern: readonly string[]): boolean {
return pattern.every((segment, index) => commandPath[index] === segment);
}
export function resolveCliCatalogCommandPath(argv: string[]): string[] {
const tokens =
resolveGatewayCatalogCommandPath(argv) ?? getCommandPathWithRootOptions(argv, argv.length);
if (tokens.length === 0) {
return [];
}
let bestMatch: readonly string[] | null = null;
for (const entry of cliCommandCatalog) {
if (!isCommandPathPrefix(tokens, entry.commandPath)) {
continue;
}
if (!bestMatch || entry.commandPath.length > bestMatch.length) {
bestMatch = entry.commandPath;
}
}
return bestMatch ? [...bestMatch] : [tokens[0]];
}
export function resolveCliNetworkProxyPolicy(argv: string[]): CliNetworkProxyPolicy {
const commandPath = resolveCliCatalogCommandPath(argv);
const networkProxy = resolveCliCommandPathPolicy(commandPath).networkProxy;
return typeof networkProxy === "function" ? networkProxy({ argv, commandPath }) : networkProxy;
}

View File

@@ -213,6 +213,157 @@ describe("maybeRunCliInContainer", () => {
);
});
it("passes the proxy URL env fallback into the child container CLI", () => {
const spawnSync = vi
.fn()
.mockReturnValueOnce({
status: 0,
stdout: "true\n",
})
.mockReturnValueOnce({
status: 1,
stdout: "",
})
.mockReturnValueOnce({
status: 0,
stdout: "",
});
maybeRunCliInContainer(["node", "openclaw", "status"], {
env: {
OPENCLAW_CONTAINER: "demo",
OPENCLAW_PROXY_URL: " http://proxy.internal:3128 ",
} as NodeJS.ProcessEnv,
spawnSync,
});
expect(spawnSync).toHaveBeenNthCalledWith(
3,
"podman",
[
"exec",
"-i",
"--env",
"OPENCLAW_CONTAINER_HINT=demo",
"--env",
"OPENCLAW_CLI_CONTAINER_BYPASS=1",
"--env",
"OPENCLAW_PROXY_URL=http://proxy.internal:3128",
"demo",
"openclaw",
"status",
],
{
stdio: "inherit",
env: {
OPENCLAW_CONTAINER: "",
OPENCLAW_PROXY_URL: " http://proxy.internal:3128 ",
},
},
);
});
it.each([
"http://127.0.0.1:3128",
"http://127.1:3128",
"http://127.0.0.01:3128",
"http://localhost.:3128",
"http://[::1]:3128",
"http://[::ffff:127.0.0.1]:3128",
])("fails before forwarding loopback proxy URL %s into a child container CLI", (proxyUrl) => {
const spawnSync = vi
.fn()
.mockReturnValueOnce({
status: 0,
stdout: "true\n",
})
.mockReturnValueOnce({
status: 1,
stdout: "",
});
expect(() =>
maybeRunCliInContainer(["node", "openclaw", "status"], {
env: {
OPENCLAW_CONTAINER: "demo",
OPENCLAW_PROXY_URL: ` ${proxyUrl} `,
} as NodeJS.ProcessEnv,
spawnSync,
}),
).toThrow("127.0.0.1 inside a container points at the container");
expect(spawnSync).toHaveBeenCalledTimes(2);
});
it("redacts proxy URL credentials and URL suffixes before rejecting loopback container proxy forwarding", () => {
const spawnSync = vi
.fn()
.mockReturnValueOnce({
status: 0,
stdout: "true\n",
})
.mockReturnValueOnce({
status: 1,
stdout: "",
});
let message = "";
try {
maybeRunCliInContainer(["node", "openclaw", "status"], {
env: {
OPENCLAW_CONTAINER: "demo",
OPENCLAW_PROXY_URL:
"http://proxy-user:proxy-secret@127.1:3128?token=proxy-query-secret#proxy-fragment-secret",
} as NodeJS.ProcessEnv,
spawnSync,
});
} catch (err) {
message = err instanceof Error ? err.message : String(err);
}
expect(message).toContain("OPENCLAW_PROXY_URL=http://redacted:redacted@127.0.0.1:3128/");
expect(message).not.toContain("proxy-user");
expect(message).not.toContain("proxy-secret");
expect(message).not.toContain("proxy-query-secret");
expect(message).not.toContain("proxy-fragment-secret");
expect(message).not.toContain("?token=");
expect(message).not.toContain("#");
expect(spawnSync).toHaveBeenCalledTimes(2);
});
it("allows explicitly overridden loopback proxy URL forwarding into a child container CLI", () => {
const spawnSync = vi
.fn()
.mockReturnValueOnce({
status: 0,
stdout: "true\n",
})
.mockReturnValueOnce({
status: 1,
stdout: "",
})
.mockReturnValueOnce({
status: 0,
stdout: "",
});
maybeRunCliInContainer(["node", "openclaw", "status"], {
env: {
OPENCLAW_CONTAINER: "demo",
OPENCLAW_PROXY_URL: " http://127.0.0.1:3128 ",
OPENCLAW_CONTAINER_ALLOW_LOOPBACK_PROXY_URL: "1",
} as NodeJS.ProcessEnv,
spawnSync,
});
expect(spawnSync).toHaveBeenNthCalledWith(
3,
"podman",
expect.arrayContaining(["OPENCLAW_PROXY_URL=http://127.0.0.1:3128"]),
expect.anything(),
);
});
it("executes through podman when the named container is running", () => {
const spawnSync = vi
.fn()

View File

@@ -1,4 +1,5 @@
import { spawnSync } from "node:child_process";
import { isIP } from "node:net";
import { consumeRootOptionToken, FLAG_TERMINATOR } from "../infra/cli-root-options.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { resolveCliArgvInvocation } from "./argv-invocation.js";
@@ -26,6 +27,8 @@ type ContainerRuntimeExec = {
argsPrefix: string[];
};
const CONTAINER_ALLOW_LOOPBACK_PROXY_URL_ENV = "OPENCLAW_CONTAINER_ALLOW_LOOPBACK_PROXY_URL";
export function parseCliContainerArgs(argv: string[]): CliContainerParseResult {
let container: string | null = null;
@@ -127,10 +130,16 @@ function buildContainerExecArgs(params: {
exec: ContainerRuntimeExec;
containerName: string;
argv: string[];
env: NodeJS.ProcessEnv;
stdinIsTTY: boolean;
stdoutIsTTY: boolean;
}): string[] {
const envFlag = params.exec.runtime === "docker" ? "-e" : "--env";
const proxyUrl = normalizeOptionalString(params.env.OPENCLAW_PROXY_URL);
if (proxyUrl) {
assertContainerProxyUrlIsReachable(proxyUrl, params.env);
}
const proxyEnvArgs = proxyUrl ? [envFlag, `OPENCLAW_PROXY_URL=${proxyUrl}`] : [];
const interactiveFlags = ["-i", ...(params.stdinIsTTY && params.stdoutIsTTY ? ["-t"] : [])];
return [
...params.exec.argsPrefix,
@@ -140,12 +149,70 @@ function buildContainerExecArgs(params: {
`OPENCLAW_CONTAINER_HINT=${params.containerName}`,
envFlag,
"OPENCLAW_CLI_CONTAINER_BYPASS=1",
...proxyEnvArgs,
params.containerName,
"openclaw",
...params.argv,
];
}
function assertContainerProxyUrlIsReachable(proxyUrl: string, env: NodeJS.ProcessEnv): void {
if (env[CONTAINER_ALLOW_LOOPBACK_PROXY_URL_ENV] === "1") {
return;
}
let parsed: URL;
try {
parsed = new URL(proxyUrl);
} catch {
return;
}
if (!isLoopbackProxyHostname(parsed.hostname)) {
return;
}
throw new Error(
`OPENCLAW_PROXY_URL=${redactProxyUrlForMessage(proxyUrl)} is loopback; 127.0.0.1 inside a container points at the container, not the host. ` +
`Use a container-reachable proxy address, or set ${CONTAINER_ALLOW_LOOPBACK_PROXY_URL_ENV}=1 if this is intentional.`,
);
}
function isLoopbackProxyHostname(hostname: string): boolean {
const normalizedHostname = hostname.toLowerCase().replace(/\.+$/, "");
if (normalizedHostname === "localhost") {
return true;
}
if (isIP(normalizedHostname) === 4) {
return normalizedHostname.split(".", 1)[0] === "127";
}
const ipv6Hostname = normalizedHostname.replace(/^\[|\]$/g, "");
if (isIP(ipv6Hostname) !== 6) {
return false;
}
if (ipv6Hostname === "::1" || ipv6Hostname === "0:0:0:0:0:0:0:1") {
return true;
}
const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(ipv6Hostname);
if (!mapped) {
return false;
}
const high = Number.parseInt(mapped[1], 16);
return Number.isInteger(high) && high >= 0x7f00 && high <= 0x7fff;
}
function redactProxyUrlForMessage(raw: string): string {
try {
const url = new URL(raw);
if (url.username || url.password) {
url.username = "redacted";
url.password = url.password ? "redacted" : "";
}
url.search = "";
url.hash = "";
return url.toString();
} catch {
return "<invalid URL>";
}
}
function buildContainerExecEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const next = { ...env };
// Container-targeted CLI invocations should use the container's own profile
@@ -230,6 +297,7 @@ export function maybeRunCliInContainer(
exec: runningContainer,
containerName: runningContainer.containerName,
argv: parsed.argv.slice(2),
env: resolvedDeps.env,
stdinIsTTY: resolvedDeps.stdinIsTTY,
stdoutIsTTY: resolvedDeps.stdoutIsTTY,
}),

104
src/cli/gateway-run-argv.ts Normal file
View File

@@ -0,0 +1,104 @@
import { isValueToken } from "../infra/cli-root-options.js";
const GATEWAY_RUN_VALUE_FLAGS = new Set([
"--port",
"--bind",
"--token",
"--auth",
"--password",
"--password-file",
"--tailscale",
"--ws-log",
"--raw-stream-path",
]);
const GATEWAY_RUN_BOOLEAN_FLAGS = new Set([
"--tailscale-reset-on-exit",
"--allow-unconfigured",
"--dev",
"--reset",
"--force",
"--verbose",
"--cli-backend-logs",
"--claude-cli-logs",
"--compact",
"--raw-stream",
]);
export function consumeGatewayRunOptionToken(args: ReadonlyArray<string>, index: number): number {
const arg = args[index];
if (!arg || arg === "--" || !arg.startsWith("-")) {
return 0;
}
const equalsIndex = arg.indexOf("=");
const flag = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex);
if (GATEWAY_RUN_BOOLEAN_FLAGS.has(flag)) {
return equalsIndex === -1 ? 1 : 0;
}
if (!GATEWAY_RUN_VALUE_FLAGS.has(flag)) {
return 0;
}
if (equalsIndex !== -1) {
return arg.slice(equalsIndex + 1).trim() ? 1 : 0;
}
return isValueToken(args[index + 1]) ? 2 : 0;
}
export function consumeGatewayFastPathRootOptionToken(
args: ReadonlyArray<string>,
index: number,
): number {
const arg = args[index];
if (!arg || arg === "--") {
return 0;
}
if (arg === "--no-color") {
return 1;
}
if (arg.startsWith("--profile=")) {
return arg.slice("--profile=".length).trim() ? 1 : 0;
}
if (arg === "--profile") {
return isValueToken(args[index + 1]) ? 2 : 0;
}
return 0;
}
export function resolveGatewayCatalogCommandPath(argv: string[]): string[] | null {
const args = argv.slice(2);
let sawGateway = false;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg || arg === "--") {
break;
}
if (!sawGateway) {
const consumed = consumeGatewayFastPathRootOptionToken(args, index);
if (consumed > 0) {
index += consumed - 1;
continue;
}
if (arg.startsWith("-")) {
continue;
}
if (arg !== "gateway") {
return null;
}
sawGateway = true;
continue;
}
const consumed = consumeGatewayRunOptionToken(args, index);
if (consumed > 0) {
index += consumed - 1;
continue;
}
if (arg.startsWith("-")) {
continue;
}
return ["gateway", arg];
}
return sawGateway ? ["gateway"] : null;
}

View File

@@ -9,7 +9,10 @@ import {
normalizeOptionalLowercaseString,
} from "../shared/string-coerce.js";
import { resolveCliArgvInvocation } from "./argv-invocation.js";
import { resolveCliCommandPathPolicy } from "./command-path-policy.js";
import {
resolveCliCommandPathPolicy,
resolveCliNetworkProxyPolicy,
} from "./command-path-policy.js";
export function rewriteUpdateFlagArgv(argv: string[]): string[] {
const index = argv.indexOf("--update");
@@ -73,6 +76,16 @@ export function shouldStartCrestodianForModernOnboard(argv: string[]): boolean {
);
}
export function shouldStartProxyForCli(argv: string[]): boolean {
const policyArgv = rewriteUpdateFlagArgv(argv);
const invocation = resolveCliArgvInvocation(policyArgv);
const [primary] = invocation.commandPath;
if (invocation.hasHelpOrVersion || !primary) {
return false;
}
return resolveCliNetworkProxyPolicy(policyArgv) === "default";
}
export function resolveMissingPluginCommandMessage(
pluginId: string,
config?: OpenClawConfig,

View File

@@ -1,7 +1,7 @@
import process from "node:process";
import { CommanderError } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { runCli } from "./run-main.js";
import { runCli, shouldStartProxyForCli } from "./run-main.js";
const tryRouteCliMock = vi.hoisted(() => vi.fn());
const loadDotEnvMock = vi.hoisted(() => vi.fn());
@@ -32,6 +32,11 @@ const createCliProgressMock = vi.hoisted(() =>
done: progressDoneMock,
})),
);
const loadConfigMock = vi.hoisted(() => vi.fn(() => ({})));
const startProxyMock = vi.hoisted(() =>
vi.fn<(config: unknown) => Promise<unknown>>(async () => null),
);
const stopProxyMock = vi.hoisted(() => vi.fn<(handle: unknown) => Promise<void>>(async () => {}));
const maybeRunCliInContainerMock = vi.hoisted(() =>
vi.fn<
(argv: string[]) => { handled: true; exitCode: number } | { handled: false; argv: string[] }
@@ -166,6 +171,37 @@ vi.mock("./progress.js", () => ({
createCliProgress: createCliProgressMock,
}));
vi.mock("../config/io.js", () => ({
getRuntimeConfig: loadConfigMock,
}));
vi.mock("../infra/net/proxy/proxy-lifecycle.js", () => ({
startProxy: startProxyMock,
stopProxy: stopProxyMock,
}));
function makeProxyHandle() {
return {
proxyUrl: "http://127.0.0.1:19876",
injectedProxyUrl: "http://127.0.0.1:19876",
envSnapshot: {
http_proxy: undefined,
https_proxy: undefined,
HTTP_PROXY: undefined,
HTTPS_PROXY: undefined,
GLOBAL_AGENT_HTTP_PROXY: undefined,
GLOBAL_AGENT_HTTPS_PROXY: undefined,
GLOBAL_AGENT_FORCE_GLOBAL_AGENT: undefined,
no_proxy: undefined,
NO_PROXY: undefined,
GLOBAL_AGENT_NO_PROXY: undefined,
OPENCLAW_PROXY_ACTIVE: undefined,
},
stop: vi.fn(async () => {}),
kill: vi.fn(),
};
}
describe("runCli exit behavior", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -173,6 +209,9 @@ describe("runCli exit behavior", () => {
outputPrecomputedBrowserHelpTextMock.mockReturnValue(false);
outputPrecomputedRootHelpTextMock.mockReturnValue(false);
hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(false);
loadConfigMock.mockReturnValue({});
startProxyMock.mockResolvedValue(null);
stopProxyMock.mockResolvedValue(undefined);
getProgramContextMock.mockReturnValue(null);
delete process.env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH;
delete process.env.OPENCLAW_HIDE_BANNER;
@@ -272,6 +311,185 @@ describe("runCli exit behavior", () => {
exitSpy.mockRestore();
});
it("does not start the managed proxy for local gateway client commands", async () => {
tryRouteCliMock.mockResolvedValueOnce(true);
await runCli(["node", "openclaw", "status"]);
expect(startProxyMock).not.toHaveBeenCalled();
expect(stopProxyMock).not.toHaveBeenCalled();
});
it.each([
["gateway runtime", ["node", "openclaw", "gateway", "run"]],
["bare gateway runtime", ["node", "openclaw", "gateway"]],
["node runtime", ["node", "openclaw", "node", "run"]],
["local agent runtime", ["node", "openclaw", "agent", "--local"]],
["provider inference", ["node", "openclaw", "infer", "web", "fetch", "https://example.com"]],
["model command", ["node", "openclaw", "models", "auth", "login", "openai"]],
["plugin command", ["node", "openclaw", "plugins", "marketplace", "list"]],
["skill command", ["node", "openclaw", "skills", "search", "browser"]],
["update command", ["node", "openclaw", "update", "check"]],
["channel probe", ["node", "openclaw", "channels", "status", "--probe"]],
["channel capabilities probe", ["node", "openclaw", "channels", "capabilities"]],
["directory plugin command", ["node", "openclaw", "directory", "peers", "list"]],
["message plugin command", ["node", "openclaw", "message", "send", "--to", "demo"]],
["unknown plugin command", ["node", "openclaw", "googlemeet", "login"]],
])("starts managed proxy routing for %s", (_name, argv) => {
expect(shouldStartProxyForCli(argv)).toBe(true);
});
it.each([
["root help", ["node", "openclaw", "--help"]],
["root version", ["node", "openclaw", "--version"]],
["gateway help", ["node", "openclaw", "gateway", "--help"]],
["gateway run help", ["node", "openclaw", "gateway", "run", "--help"]],
["status", ["node", "openclaw", "status"]],
["health", ["node", "openclaw", "health"]],
["gateway status", ["node", "openclaw", "gateway", "status"]],
["gateway health", ["node", "openclaw", "gateway", "health"]],
["remote agent control-plane", ["node", "openclaw", "agent", "run"]],
["chat control-plane", ["node", "openclaw", "chat"]],
["terminal control-plane", ["node", "openclaw", "terminal"]],
["config", ["node", "openclaw", "config", "get", "proxy.enabled"]],
["completion", ["node", "openclaw", "completion", "zsh"]],
["debug proxy cli", ["node", "openclaw", "proxy", "start"]],
["agents list", ["node", "openclaw", "agents", "list"]],
["models list", ["node", "openclaw", "models", "list"]],
["models status without live probe", ["node", "openclaw", "models", "status"]],
["tasks list", ["node", "openclaw", "tasks", "list"]],
["migrate", ["node", "openclaw", "migrate"]],
])("skips managed proxy routing for %s", (_name, argv) => {
expect(shouldStartProxyForCli(argv)).toBe(false);
});
it("starts the managed proxy for network-capable commands by default", async () => {
tryRouteCliMock.mockResolvedValueOnce(true);
await runCli(["node", "openclaw", "plugins", "marketplace", "list"]);
expect(startProxyMock).toHaveBeenCalledWith(undefined);
});
it("starts the managed proxy for unknown plugin commands by default", async () => {
tryRouteCliMock.mockResolvedValueOnce(true);
await runCli(["node", "openclaw", "googlemeet", "login"]);
expect(startProxyMock).toHaveBeenCalledWith(undefined);
});
it("fails protected commands when managed proxy activation fails", async () => {
startProxyMock.mockRejectedValueOnce(new Error("proxy: enabled but no HTTP proxy URL"));
await expect(runCli(["node", "openclaw", "gateway", "run"])).rejects.toThrow(
"proxy: enabled but no HTTP proxy URL",
);
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(stopProxyMock).not.toHaveBeenCalled();
});
it("fails protected commands when config cannot be loaded for managed proxy startup", async () => {
loadConfigMock.mockImplementationOnce(() => {
throw new Error("config parse failed");
});
await expect(runCli(["node", "openclaw", "gateway", "run"])).rejects.toThrow(
"config parse failed",
);
expect(startProxyMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
});
it("stops the managed proxy after normal gateway runtime completion", async () => {
const handle = makeProxyHandle();
startProxyMock.mockResolvedValueOnce(handle);
await runCli(["node", "openclaw", "gateway", "run"]);
expect(startProxyMock).toHaveBeenCalledWith(undefined);
expect(stopProxyMock).toHaveBeenCalledOnce();
expect(stopProxyMock).toHaveBeenCalledWith(handle);
});
it("stops the managed proxy and exits after SIGINT", async () => {
const handle = makeProxyHandle();
startProxyMock.mockResolvedValueOnce(handle);
let resolveRoute: (value: boolean) => void = () => {};
tryRouteCliMock.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
resolveRoute = resolve;
}),
);
const processOnceSpy = vi.spyOn(process, "once");
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number | string) => {
void code;
return undefined as never;
}) as typeof process.exit);
try {
const runPromise = runCli(["node", "openclaw", "plugins", "marketplace", "list"]);
await vi.waitFor(() => {
expect(processOnceSpy).toHaveBeenCalledWith("SIGINT", expect.any(Function));
});
const sigintHandler = processOnceSpy.mock.calls.find(([event]) => event === "SIGINT")?.[1];
if (typeof sigintHandler !== "function") {
throw new Error("SIGINT handler was not registered");
}
sigintHandler();
await vi.waitFor(() => {
expect(stopProxyMock).toHaveBeenCalledWith(handle);
});
await vi.waitFor(() => {
expect(exitSpy).toHaveBeenCalledWith(130);
});
resolveRoute(true);
await runPromise;
expect(stopProxyMock).toHaveBeenCalledTimes(1);
} finally {
exitSpy.mockRestore();
processOnceSpy.mockRestore();
}
});
it("synchronously kills the managed proxy during hard process exit", async () => {
const handle = makeProxyHandle();
startProxyMock.mockResolvedValueOnce(handle);
let resolveRoute: (value: boolean) => void = () => {};
tryRouteCliMock.mockReturnValueOnce(
new Promise<boolean>((resolve) => {
resolveRoute = resolve;
}),
);
const processOnceSpy = vi.spyOn(process, "once");
try {
const runPromise = runCli(["node", "openclaw", "plugins", "marketplace", "list"]);
await vi.waitFor(() => {
expect(processOnceSpy.mock.calls.filter(([event]) => event === "exit")).toHaveLength(2);
});
const exitHandler = processOnceSpy.mock.calls.find(([event]) => event === "exit")?.[1];
if (typeof exitHandler !== "function") {
throw new Error("exit handler was not registered");
}
exitHandler(0 as never);
expect(handle.kill).toHaveBeenCalledWith("SIGTERM");
resolveRoute(true);
await runPromise;
expect(stopProxyMock).not.toHaveBeenCalledWith(handle);
} finally {
processOnceSpy.mockRestore();
}
});
it("bootstraps env proxy before bare Crestodian startup", async () => {
hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(true);
const stdinTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");

View File

@@ -6,6 +6,7 @@ import {
shouldEnsureCliPath,
shouldStartCrestodianForBareRoot,
shouldStartCrestodianForModernOnboard,
shouldStartProxyForCli,
shouldUseBrowserHelpFastPath,
shouldUseRootHelpFastPath,
} from "./run-main-policy.js";
@@ -143,6 +144,13 @@ describe("shouldStartCrestodianForModernOnboard", () => {
});
});
describe("shouldStartProxyForCli", () => {
it("starts managed proxy routing for the --update shorthand", () => {
expect(shouldStartProxyForCli(["node", "openclaw", "--update"])).toBe(true);
expect(shouldStartProxyForCli(["node", "openclaw", "--profile", "p", "--update"])).toBe(true);
});
});
describe("shouldUseRootHelpFastPath", () => {
it("uses the fast path for root help only", () => {
expect(shouldUseRootHelpFastPath(["node", "openclaw", "--help"])).toBe(true);

View File

@@ -4,9 +4,9 @@ import process from "node:process";
import { fileURLToPath } from "node:url";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isValueToken } from "../infra/cli-root-options.js";
import { isTruthyEnvValue, normalizeEnv } from "../infra/env.js";
import { isMainModule } from "../infra/is-main.js";
import type { ProxyHandle } from "../infra/net/proxy/proxy-lifecycle.js";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import type { PluginManifestCommandAliasRegistry } from "../plugins/manifest-command-aliases.js";
@@ -17,6 +17,10 @@ import {
shouldSkipPluginCommandRegistration,
} from "./command-registration-policy.js";
import { maybeRunCliInContainer, parseCliContainerArgs } from "./container-target.js";
import {
consumeGatewayFastPathRootOptionToken,
consumeGatewayRunOptionToken,
} from "./gateway-run-argv.js";
import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
import {
resolveMissingPluginCommandMessage as resolveMissingPluginCommandMessageFromPolicy,
@@ -24,6 +28,7 @@ import {
shouldEnsureCliPath,
shouldStartCrestodianForBareRoot,
shouldStartCrestodianForModernOnboard,
shouldStartProxyForCli,
shouldUseBrowserHelpFastPath,
shouldUseRootHelpFastPath,
} from "./run-main-policy.js";
@@ -34,37 +39,13 @@ export {
shouldEnsureCliPath,
shouldStartCrestodianForBareRoot,
shouldStartCrestodianForModernOnboard,
shouldStartProxyForCli,
shouldUseBrowserHelpFastPath,
shouldUseRootHelpFastPath,
} from "./run-main-policy.js";
type Awaitable<T> = T | Promise<T>;
const GATEWAY_RUN_VALUE_FLAGS = new Set([
"--port",
"--bind",
"--token",
"--auth",
"--password",
"--password-file",
"--tailscale",
"--ws-log",
"--raw-stream-path",
]);
const GATEWAY_RUN_BOOLEAN_FLAGS = new Set([
"--tailscale-reset-on-exit",
"--allow-unconfigured",
"--dev",
"--reset",
"--force",
"--verbose",
"--cli-backend-logs",
"--claude-cli-logs",
"--compact",
"--raw-stream",
]);
const CLI_PROXY_ENV_KEYS = [
"HTTP_PROXY",
"HTTPS_PROXY",
@@ -268,42 +249,6 @@ async function ensureCliEnvProxyDispatcher(): Promise<void> {
}
}
function consumeGatewayRunOptionToken(args: ReadonlyArray<string>, index: number): number {
const arg = args[index];
if (!arg || arg === "--" || !arg.startsWith("-")) {
return 0;
}
const equalsIndex = arg.indexOf("=");
const flag = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex);
if (GATEWAY_RUN_BOOLEAN_FLAGS.has(flag)) {
return equalsIndex === -1 ? 1 : 0;
}
if (!GATEWAY_RUN_VALUE_FLAGS.has(flag)) {
return 0;
}
if (equalsIndex !== -1) {
return arg.slice(equalsIndex + 1).trim() ? 1 : 0;
}
return isValueToken(args[index + 1]) ? 2 : 0;
}
function consumeGatewayFastPathRootOptionToken(args: ReadonlyArray<string>, index: number): number {
const arg = args[index];
if (!arg || arg === "--") {
return 0;
}
if (arg === "--no-color") {
return 1;
}
if (arg.startsWith("--profile=")) {
return arg.slice("--profile=".length).trim() ? 1 : 0;
}
if (arg === "--profile") {
return isValueToken(args[index + 1]) ? 2 : 0;
}
return 0;
}
function shouldBootstrapCliProxyBeforeFastPath(env: NodeJS.ProcessEnv = process.env): boolean {
if (
isTruthyEnvValue(env.OPENCLAW_DEBUG_PROXY_ENABLED) ||
@@ -378,6 +323,55 @@ export async function runCli(argv: string[] = process.argv) {
// Enforce the minimum supported runtime before doing any work.
assertSupportedRuntime();
// Activate operator-managed proxy routing for network-capable commands.
// Local Gateway/control-plane commands keep direct loopback access while
// runtime, provider, plugin, update, and unknown plugin commands route egress.
let proxyHandle: ProxyHandle | null = null;
const stopStartedProxy = async () => {
const handle = proxyHandle;
proxyHandle = null;
if (handle) {
const { stopProxy } = await import("../infra/net/proxy/proxy-lifecycle.js");
await stopProxy(handle);
}
};
const killStartedProxy = () => {
const handle = proxyHandle;
proxyHandle = null;
handle?.kill("SIGTERM");
};
if (shouldStartProxyForCli(normalizedArgv)) {
const [{ getRuntimeConfig }, { startProxy }] = await Promise.all([
import("../config/io.js"),
import("../infra/net/proxy/proxy-lifecycle.js"),
]);
const config = getRuntimeConfig();
proxyHandle = await startProxy(config?.proxy ?? undefined);
}
let onSigterm: (() => void) | null = null;
let onSigint: (() => void) | null = null;
let onExit: (() => void) | null = null;
if (proxyHandle) {
const shutdown = (exitCode: number) => {
if (onSigterm) {
process.off("SIGTERM", onSigterm);
}
if (onSigint) {
process.off("SIGINT", onSigint);
}
void stopStartedProxy().finally(() => {
process.exit(exitCode);
});
};
onSigterm = () => shutdown(143);
onSigint = () => shutdown(130);
onExit = () => killStartedProxy();
process.once("SIGTERM", onSigterm);
process.once("SIGINT", onSigint);
process.once("exit", onExit);
}
try {
if (shouldUseRootHelpFastPath(normalizedArgv)) {
const { outputPrecomputedRootHelpText } = await import("./root-help-metadata.js");
@@ -606,6 +600,16 @@ export async function runCli(argv: string[] = process.argv) {
stopStartupProgress();
}
} finally {
if (onSigterm) {
process.off("SIGTERM", onSigterm);
}
if (onSigint) {
process.off("SIGINT", onSigint);
}
if (onExit) {
process.off("exit", onExit);
}
await stopStartedProxy();
await closeCliMemoryManagers();
}
}