mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 16:40:42 +00:00
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:
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
104
src/cli/gateway-run-argv.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user