refactor(cli): consolidate security-sensitive regression fixtures (#114411)

* refactor(cli): consolidate security-sensitive regression fixtures

* refactor(cli): refresh fixture cleanup against main
This commit is contained in:
Peter Steinberger
2026-08-02 01:19:59 -07:00
committed by GitHub
parent 76ec39556e
commit 6b625357fc
4 changed files with 504 additions and 1218 deletions
File diff suppressed because it is too large Load Diff
+212 -407
View File
@@ -407,11 +407,11 @@ function makeProxyHandle() {
};
}
async function withInteractiveTty(fn: () => Promise<void>): Promise<void> {
async function withCliTty(value: boolean, fn: () => Promise<void>): Promise<void> {
const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value });
try {
await fn();
} finally {
@@ -428,6 +428,36 @@ async function withInteractiveTty(fn: () => Promise<void>): Promise<void> {
}
}
function withInteractiveTty(fn: () => Promise<void>): Promise<void> {
return withCliTty(true, fn);
}
function runBareCli(): Promise<void> {
return withInteractiveTty(() => runCli(["node", "openclaw"]));
}
function primeBareRootConfig(sourceConfig: ConfigSnapshotStub["sourceConfig"]): void {
readConfigFileSnapshotMock.mockResolvedValueOnce({ exists: true, valid: true, sourceConfig });
}
async function expectNonInteractiveBareCliError(
message: string,
assert?: () => void,
): Promise<void> {
const previousExitCode = process.exitCode;
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
process.exitCode = undefined;
try {
await withCliTty(false, () => runCli(["node", "openclaw"]));
expect(process.exitCode).toBe(1);
expect(errorSpy).toHaveBeenCalledWith(message);
assert?.();
} finally {
errorSpy.mockRestore();
process.exitCode = previousExitCode;
}
}
describe("runCli exit behavior", () => {
afterAll(() => {
serviceEnvSnapshot.restore();
@@ -2970,7 +3000,7 @@ describe("runCli exit behavior", () => {
},
])("$name", async ({ snapshot }) => {
readConfigFileSnapshotMock.mockResolvedValueOnce(snapshot);
await withInteractiveTty(async () => runCli(["node", "openclaw"]));
await runBareCli();
expect(readConfigFileSnapshotMock).toHaveBeenCalledOnce();
expect(setupWizardCommandMock).toHaveBeenCalledWith({});
@@ -2979,43 +3009,20 @@ describe("runCli exit behavior", () => {
});
it("points noninteractive fresh bare root invocations to onboarding automation", async () => {
const previousExitCode = process.exitCode;
const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
process.exitCode = undefined;
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: false,
valid: true,
sourceConfig: {},
});
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false });
try {
await runCli(["node", "openclaw"]);
expect(process.exitCode).toBe(1);
expect(errorSpy).toHaveBeenCalledWith(
"Onboarding needs an interactive TTY. Use `openclaw onboard --non-interactive --accept-risk ...` for automation.",
);
expect(setupWizardCommandMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(buildProgramMock).not.toHaveBeenCalled();
} finally {
errorSpy.mockRestore();
process.exitCode = previousExitCode;
if (stdinDescriptor) {
Object.defineProperty(process.stdin, "isTTY", stdinDescriptor);
} else {
Reflect.deleteProperty(process.stdin, "isTTY");
}
if (stdoutDescriptor) {
Object.defineProperty(process.stdout, "isTTY", stdoutDescriptor);
} else {
Reflect.deleteProperty(process.stdout, "isTTY");
}
}
await expectNonInteractiveBareCliError(
"Onboarding needs an interactive TTY. Use `openclaw onboard --non-interactive --accept-risk ...` for automation.",
() => {
expect(setupWizardCommandMock).not.toHaveBeenCalled();
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(buildProgramMock).not.toHaveBeenCalled();
},
);
});
it("starts the gateway-backed TUI for bare root invocations when config already exists", async () => {
@@ -3038,9 +3045,7 @@ describe("runCli exit behavior", () => {
});
await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "gateway-ref-password" }, async () => {
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
});
expect(readConfigFileSnapshotMock).toHaveBeenCalledTimes(1);
@@ -3078,9 +3083,7 @@ describe("runCli exit behavior", () => {
detail: "Gateway default agent has no configured model",
});
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
expect(setupWizardCommandMock).not.toHaveBeenCalled();
expect(runRemoteGatewayInferenceOnboardingMock).toHaveBeenCalledWith({
@@ -3093,21 +3096,15 @@ describe("runCli exit behavior", () => {
});
it("keeps missing inference setup local for a local Gateway", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: { mode: "local" },
},
primeBareRootConfig({
gateway: { mode: "local" },
});
probeGatewayConfiguredModelMock.mockResolvedValueOnce({
kind: "missing-configured-model",
detail: "Gateway default agent has no configured model",
});
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
expect(setupWizardCommandMock).toHaveBeenCalledWith({});
expect(runRemoteGatewayInferenceOnboardingMock).not.toHaveBeenCalled();
@@ -3115,69 +3112,36 @@ describe("runCli exit behavior", () => {
});
it("does not direct non-interactive remote setup into local onboarding", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: {
mode: "remote",
remote: { url: "wss://gateway.example/ws", token: "remote-token" },
},
primeBareRootConfig({
gateway: {
mode: "remote",
remote: { url: "wss://gateway.example/ws", token: "remote-token" },
},
});
probeGatewayConfiguredModelMock.mockResolvedValueOnce({
kind: "missing-configured-model",
detail: "Gateway default agent has no configured model",
});
const previousExitCode = process.exitCode;
const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false });
try {
await runCli(["node", "openclaw"]);
expect(process.exitCode).toBe(1);
expect(errorSpy).toHaveBeenCalledWith(
"Remote Gateway inference setup needs an interactive TTY. Re-run `openclaw` in a terminal connected to this Gateway.",
);
expect(setupWizardCommandMock).not.toHaveBeenCalled();
expect(runRemoteGatewayInferenceOnboardingMock).not.toHaveBeenCalled();
} finally {
errorSpy.mockRestore();
process.exitCode = previousExitCode;
if (stdinDescriptor) {
Object.defineProperty(process.stdin, "isTTY", stdinDescriptor);
} else {
Reflect.deleteProperty(process.stdin, "isTTY");
}
if (stdoutDescriptor) {
Object.defineProperty(process.stdout, "isTTY", stdoutDescriptor);
} else {
Reflect.deleteProperty(process.stdout, "isTTY");
}
}
await expectNonInteractiveBareCliError(
"Remote Gateway inference setup needs an interactive TTY. Re-run `openclaw` in a terminal connected to this Gateway.",
() => {
expect(setupWizardCommandMock).not.toHaveBeenCalled();
expect(runRemoteGatewayInferenceOnboardingMock).not.toHaveBeenCalled();
},
);
});
it("uses the active local gateway lock port for bare root preflight and TUI handoff", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: {
mode: "local",
port: 18789,
auth: { mode: "token", token: "configured-token" },
},
primeBareRootConfig({
gateway: {
mode: "local",
port: 18789,
auth: { mode: "token", token: "configured-token" },
},
});
readActiveGatewayLockPortMock.mockResolvedValueOnce(48789);
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
expect(probeGatewayConfiguredModelMock).toHaveBeenCalledWith({
url: "ws://127.0.0.1:48789",
@@ -3190,23 +3154,17 @@ describe("runCli exit behavior", () => {
});
it("keeps an explicit gateway port ahead of active local lock metadata", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: {
mode: "local",
port: 18789,
auth: { mode: "token", token: "configured-token" },
},
primeBareRootConfig({
gateway: {
mode: "local",
port: 18789,
auth: { mode: "token", token: "configured-token" },
},
});
readActiveGatewayLockPortMock.mockResolvedValueOnce(48789);
await withEnvAsync({ OPENCLAW_GATEWAY_PORT: "19001" }, async () => {
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
});
expect(readActiveGatewayLockPortMock).not.toHaveBeenCalled();
@@ -3217,15 +3175,11 @@ describe("runCli exit behavior", () => {
});
it("carries the canonical local TLS fingerprint through bare root", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: {
mode: "local",
tls: { enabled: true },
auth: { mode: "token", token: "configured-token" },
},
primeBareRootConfig({
gateway: {
mode: "local",
tls: { enabled: true },
auth: { mode: "token", token: "configured-token" },
},
});
loadGatewayTlsRuntimeMock.mockResolvedValueOnce({
@@ -3234,9 +3188,7 @@ describe("runCli exit behavior", () => {
fingerprintSha256: "sha256:local-self-signed-fingerprint",
});
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
expect(probeGatewayConfiguredModelMock).toHaveBeenCalledWith({
url: "wss://127.0.0.1:18789",
@@ -3250,21 +3202,15 @@ describe("runCli exit behavior", () => {
});
it("uses gateway env credentials for bare root gateway preflight", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: {
mode: "local",
auth: { mode: "token" },
},
primeBareRootConfig({
gateway: {
mode: "local",
auth: { mode: "token" },
},
});
await withEnvAsync({ OPENCLAW_GATEWAY_TOKEN: "env-token" }, async () => {
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
});
expect(probeGatewayConfiguredModelMock).toHaveBeenCalledWith({
@@ -3292,45 +3238,39 @@ describe("runCli exit behavior", () => {
"process.stdout.write(JSON.stringify({ protocolVersion: 1, values: { PASSWORD_SECRET: 'password-from-exec' } }));", // pragma: allowlist secret
].join("");
await withSecureTestNodeExecPath(async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
secrets: {
providers: {
tokenprovider: {
source: "exec",
command: process.execPath,
args: ["-e", tokenProgram],
allowInsecurePath: true,
},
passwordprovider: {
source: "exec",
command: process.execPath,
args: ["-e", passwordProgram],
allowInsecurePath: true,
},
primeBareRootConfig({
secrets: {
providers: {
tokenprovider: {
source: "exec",
command: process.execPath,
args: ["-e", tokenProgram],
allowInsecurePath: true,
},
passwordprovider: {
source: "exec",
command: process.execPath,
args: ["-e", passwordProgram],
allowInsecurePath: true,
},
},
gateway: {
mode: "local",
auth: {
mode: "password",
token: { source: "exec", provider: "tokenprovider", id: "TOKEN_SECRET" },
password: {
source: "exec",
provider: "passwordprovider",
id: "PASSWORD_SECRET",
},
},
gateway: {
mode: "local",
auth: {
mode: "password",
token: { source: "exec", provider: "tokenprovider", id: "TOKEN_SECRET" },
password: {
source: "exec",
provider: "passwordprovider",
id: "PASSWORD_SECRET",
},
},
},
});
try {
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
expect(probeGatewayConfiguredModelMock).toHaveBeenCalledWith({
url: "ws://127.0.0.1:18789",
@@ -3349,24 +3289,18 @@ describe("runCli exit behavior", () => {
});
it("probes local gateways over loopback even when the gateway advertises a LAN bind", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: {
mode: "local",
bind: "lan",
auth: {
mode: "token",
token: "local-token",
},
primeBareRootConfig({
gateway: {
mode: "local",
bind: "lan",
auth: {
mode: "token",
token: "local-token",
},
},
});
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
expect(readActiveGatewayLockPortMock).toHaveBeenCalledTimes(1);
expect(probeGatewayConfiguredModelMock).toHaveBeenCalledWith({
@@ -3380,17 +3314,13 @@ describe("runCli exit behavior", () => {
});
it("falls back to the configured local tailnet gateway URL when loopback is unavailable", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: {
mode: "local",
bind: "tailnet",
auth: {
mode: "token",
token: "local-token",
},
primeBareRootConfig({
gateway: {
mode: "local",
bind: "tailnet",
auth: {
mode: "token",
token: "local-token",
},
},
});
@@ -3409,9 +3339,7 @@ describe("runCli exit behavior", () => {
.mockResolvedValueOnce({ kind: "unreachable", detail: "loopback offline" })
.mockResolvedValueOnce({ kind: "configured" });
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
expect(probeGatewayConfiguredModelMock).toHaveBeenNthCalledWith(1, {
url: "ws://127.0.0.1:18789",
@@ -3428,15 +3356,11 @@ describe("runCli exit behavior", () => {
});
it("prefers a configured secondary Gateway over a missing-model primary probe", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: {
mode: "local",
bind: "tailnet",
auth: { mode: "token", token: "local-token" },
},
primeBareRootConfig({
gateway: {
mode: "local",
bind: "tailnet",
auth: { mode: "token", token: "local-token" },
},
});
resolveControlUiLinksMock.mockImplementation(({ bind }: { bind?: string } = {}) =>
@@ -3451,9 +3375,7 @@ describe("runCli exit behavior", () => {
})
.mockResolvedValueOnce({ kind: "configured" });
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
expect(setupWizardCommandMock).not.toHaveBeenCalled();
expect(launchTuiCliMock).toHaveBeenCalledWith(
@@ -3463,16 +3385,12 @@ describe("runCli exit behavior", () => {
});
it("keeps confirmed missing inference ahead of an unverified secondary Gateway", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
agents: { defaults: { model: { primary: "openai/local-only-model" } } },
gateway: {
mode: "local",
bind: "tailnet",
auth: { mode: "token", token: "local-token" },
},
primeBareRootConfig({
agents: { defaults: { model: { primary: "openai/local-only-model" } } },
gateway: {
mode: "local",
bind: "tailnet",
auth: { mode: "token", token: "local-token" },
},
});
resolveControlUiLinksMock.mockImplementation(({ bind }: { bind?: string } = {}) =>
@@ -3490,9 +3408,7 @@ describe("runCli exit behavior", () => {
detail: "Gateway default agent has no configured model",
});
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
expect(setupWizardCommandMock).toHaveBeenCalledWith({});
expect(launchTuiCliMock).not.toHaveBeenCalled();
@@ -3500,22 +3416,16 @@ describe("runCli exit behavior", () => {
it("keeps a reachable unverified Gateway ahead of local inference fallback", async () => {
const url = "ws://127.0.0.1:18789";
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
agents: { defaults: { model: { primary: "openai/local-only-model" } } },
gateway: { mode: "remote", remote: { url, token: "remote-token" } },
},
primeBareRootConfig({
agents: { defaults: { model: { primary: "openai/local-only-model" } } },
gateway: { mode: "remote", remote: { url, token: "remote-token" } },
});
probeGatewayConfiguredModelMock.mockResolvedValueOnce({
kind: "reachable-unverified",
detail: "config.get: unauthorized",
});
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
expect(setupWizardCommandMock).not.toHaveBeenCalled();
expect(launchTuiCliMock).toHaveBeenCalledWith(
@@ -3526,21 +3436,15 @@ describe("runCli exit behavior", () => {
it("keeps a configured remote Gateway authoritative across a transient cold-restart probe", async () => {
const url = "wss://gateway.example/ws";
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: { mode: "remote", remote: { url, token: "remote-token" } },
},
primeBareRootConfig({
gateway: { mode: "remote", remote: { url, token: "remote-token" } },
});
probeGatewayConfiguredModelMock.mockResolvedValueOnce({
kind: "unreachable",
detail: "gateway restarting",
});
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
expect(setupWizardCommandMock).not.toHaveBeenCalled();
expect(runRemoteGatewayInferenceOnboardingMock).not.toHaveBeenCalled();
@@ -3551,21 +3455,15 @@ describe("runCli exit behavior", () => {
});
it("keeps a configured local Gateway authoritative across a transient cold-restart probe", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: { mode: "local", auth: { mode: "token", token: "local-token" } },
},
primeBareRootConfig({
gateway: { mode: "local", auth: { mode: "token", token: "local-token" } },
});
probeGatewayConfiguredModelMock.mockResolvedValueOnce({
kind: "unreachable",
detail: "offline",
});
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
expect(setupWizardCommandMock).not.toHaveBeenCalled();
expect(launchTuiCliMock).toHaveBeenCalledWith(
@@ -3575,21 +3473,15 @@ describe("runCli exit behavior", () => {
});
it("starts the local TUI when no Gateway is configured and the default probe is unavailable", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
agents: { defaults: { model: { primary: "openai/gpt-5.5" } } },
},
primeBareRootConfig({
agents: { defaults: { model: { primary: "openai/gpt-5.5" } } },
});
probeGatewayConfiguredModelMock.mockResolvedValueOnce({
kind: "unreachable",
detail: "offline",
});
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
expect(launchTuiCliMock).toHaveBeenCalledWith({ deliver: false, local: true }, {});
});
@@ -3599,24 +3491,18 @@ describe("runCli exit behavior", () => {
{ label: "mDNS", url: "ws://gateway.local:18789" },
{ label: "Tailnet DNS", url: "ws://machine.tail123.ts.net:18789" },
])("does not probe a plaintext remote gateway over $label without opt-in", async ({ url }) => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: {
mode: "remote",
remote: {
url,
token: "remote-token",
},
primeBareRootConfig({
gateway: {
mode: "remote",
remote: {
url,
token: "remote-token",
},
},
});
await withEnvAsync({ OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: undefined }, async () => {
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
});
expect(probeGatewayConfiguredModelMock).not.toHaveBeenCalled();
@@ -3626,24 +3512,18 @@ describe("runCli exit behavior", () => {
it("probes a plaintext remote loopback gateway", async () => {
const url = "ws://127.0.0.1:18789";
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: {
mode: "remote",
remote: {
url,
token: "remote-token",
},
primeBareRootConfig({
gateway: {
mode: "remote",
remote: {
url,
token: "remote-token",
},
},
});
await withEnvAsync({ OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: undefined }, async () => {
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
});
expect(probeGatewayConfiguredModelMock).toHaveBeenCalledWith({
@@ -3659,24 +3539,18 @@ describe("runCli exit behavior", () => {
it("keeps configured remote password authoritative from preflight through TUI launch", async () => {
const url = "ws://127.0.0.1:18789";
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: {
mode: "remote",
remote: {
url,
password: "configured-remote-password", // pragma: allowlist secret
},
primeBareRootConfig({
gateway: {
mode: "remote",
remote: {
url,
password: "configured-remote-password", // pragma: allowlist secret
},
},
});
await withEnvAsync({ OPENCLAW_GATEWAY_PASSWORD: "stale-env-password" }, async () => {
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
});
expect(probeGatewayConfiguredModelMock).toHaveBeenCalledWith({
@@ -3691,24 +3565,20 @@ describe("runCli exit behavior", () => {
it("falls back to gateway env auth when configured remote SecretRefs are unresolved", async () => {
const url = "ws://127.0.0.1:18789";
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: {
mode: "remote",
remote: {
url,
token: {
source: "env",
provider: "default",
id: "MISSING_REMOTE_GATEWAY_TOKEN",
},
password: {
source: "env",
provider: "default",
id: "MISSING_REMOTE_GATEWAY_PASSWORD",
},
primeBareRootConfig({
gateway: {
mode: "remote",
remote: {
url,
token: {
source: "env",
provider: "default",
id: "MISSING_REMOTE_GATEWAY_TOKEN",
},
password: {
source: "env",
provider: "default",
id: "MISSING_REMOTE_GATEWAY_PASSWORD",
},
},
},
@@ -3722,9 +3592,7 @@ describe("runCli exit behavior", () => {
OPENCLAW_GATEWAY_PASSWORD: "env-remote-password",
},
async () => {
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
},
);
@@ -3739,24 +3607,18 @@ describe("runCli exit behavior", () => {
it("probes an explicitly allowed plaintext private remote gateway", async () => {
const url = "ws://192.168.1.10:18789";
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: {
mode: "remote",
remote: {
url,
token: "remote-token",
},
primeBareRootConfig({
gateway: {
mode: "remote",
remote: {
url,
token: "remote-token",
},
},
});
await withEnvAsync({ OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: "1" }, async () => {
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
});
expect(probeGatewayConfiguredModelMock).toHaveBeenCalledWith({
@@ -3771,24 +3633,18 @@ describe("runCli exit behavior", () => {
});
it("forwards the configured TLS pin when probing a remote gateway", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: {
mode: "remote",
remote: {
url: "wss://gateway.example.com:18789",
token: "remote-token",
tlsFingerprint: "sha256:11:22:33:44",
},
primeBareRootConfig({
gateway: {
mode: "remote",
remote: {
url: "wss://gateway.example.com:18789",
token: "remote-token",
tlsFingerprint: "sha256:11:22:33:44",
},
},
});
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
expect(probeGatewayConfiguredModelMock).toHaveBeenCalledWith({
url: "wss://gateway.example.com:18789",
@@ -3802,24 +3658,18 @@ describe("runCli exit behavior", () => {
});
it("routes to inference onboarding without probing a public plaintext remote gateway", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: true,
sourceConfig: {
gateway: {
mode: "remote",
remote: {
url: "ws://gateway.example.com:18789",
token: "remote-token",
},
primeBareRootConfig({
gateway: {
mode: "remote",
remote: {
url: "ws://gateway.example.com:18789",
token: "remote-token",
},
},
});
await withEnvAsync({ OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: undefined }, async () => {
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
});
expect(probeGatewayConfiguredModelMock).not.toHaveBeenCalled();
@@ -3867,73 +3717,28 @@ describe("runCli exit behavior", () => {
sourceConfig: { gateway: { mode: "local" } },
});
await withInteractiveTty(async () => {
await runCli(["node", "openclaw"]);
});
await runBareCli();
expect(setupWizardCommandMock).toHaveBeenCalledWith({ classic: true });
expect(launchTuiCliMock).not.toHaveBeenCalled();
});
it("points noninteractive invalid config to doctor before onboarding", async () => {
const previousExitCode = process.exitCode;
const stdinDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
process.exitCode = undefined;
readConfigFileSnapshotMock.mockResolvedValueOnce({
exists: true,
valid: false,
sourceConfig: { gateway: { mode: "local" } },
});
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: false });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: false });
try {
await runCli(["node", "openclaw"]);
expect(process.exitCode).toBe(1);
expect(errorSpy).toHaveBeenCalledWith(
"OpenClaw config is invalid. Run `openclaw doctor --fix` before onboarding.",
);
expect(setupWizardCommandMock).not.toHaveBeenCalled();
} finally {
errorSpy.mockRestore();
process.exitCode = previousExitCode;
if (stdinDescriptor) {
Object.defineProperty(process.stdin, "isTTY", stdinDescriptor);
} else {
Reflect.deleteProperty(process.stdin, "isTTY");
}
if (stdoutDescriptor) {
Object.defineProperty(process.stdout, "isTTY", stdoutDescriptor);
} else {
Reflect.deleteProperty(process.stdout, "isTTY");
}
}
await expectNonInteractiveBareCliError(
"OpenClaw config is invalid. Run `openclaw doctor --fix` before onboarding.",
() => expect(setupWizardCommandMock).not.toHaveBeenCalled(),
);
});
it("bootstraps env proxy before bare TUI startup", async () => {
hasEnvHttpProxyAgentConfiguredMock.mockReturnValue(true);
const stdinTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
const stdoutTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });
Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true });
try {
await runCli(["node", "openclaw"]);
} finally {
if (stdinTty) {
Object.defineProperty(process.stdin, "isTTY", stdinTty);
} else {
delete (process.stdin as { isTTY?: boolean }).isTTY;
}
if (stdoutTty) {
Object.defineProperty(process.stdout, "isTTY", stdoutTty);
} else {
delete (process.stdout as { isTTY?: boolean }).isTTY;
}
}
await runBareCli();
expect(ensureGlobalUndiciEnvProxyDispatcherMock).toHaveBeenCalledTimes(1);
expect(launchTuiCliMock).toHaveBeenCalledOnce();
+61 -129
View File
@@ -592,6 +592,20 @@ describe("update-cli", () => {
});
};
const primeNpmChannelTag = (tag: string, version: string | null): void => {
vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag, version });
};
const primeServiceCommand = (
programArguments: Array<string | undefined>,
environment?: NodeJS.ProcessEnv,
): void => {
serviceReadCommand.mockResolvedValue({
programArguments,
...(environment === undefined ? {} : { environment }),
});
};
const expectUpdateCallChannel = (channel: string) => {
const call = vi.mocked(runGatewayUpdate).mock.calls[0]?.[0];
expect(call?.channel).toBe(channel);
@@ -845,9 +859,7 @@ describe("update-cli", () => {
const mockOwnedGitService = () => {
const serviceEntrypoint = path.join(process.cwd(), "dist", "index.js");
serviceReadCommand.mockResolvedValue({
programArguments: ["node", serviceEntrypoint, "gateway", "run"],
});
primeServiceCommand(["node", serviceEntrypoint, "gateway", "run"]);
pathExists.mockImplementation(
async (candidate: string) => candidate === path.join(process.cwd(), "package.json"),
);
@@ -886,10 +898,7 @@ describe("update-cli", () => {
readPackageVersion.mockResolvedValue("2.0.0");
mockPackageInstallStatus(tempDir);
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "0.0.1",
});
primeNpmChannelTag("latest", "0.0.1");
vi.mocked(runGatewayUpdate).mockResolvedValue({
status: "ok",
mode: "npm",
@@ -940,9 +949,7 @@ describe("update-cli", () => {
});
}
serviceLoaded.mockResolvedValue(true);
serviceReadCommand.mockResolvedValue({
programArguments: ["node", entrypoints[0], "gateway", "run"],
});
primeServiceCommand(["node", entrypoints[0], "gateway", "run"]);
return { root, entrypoints };
};
@@ -1337,10 +1344,7 @@ describe("update-cli", () => {
version: "2026.6.33",
packageSpec: "openclaw@2026.6.33",
});
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "9999.0.0",
});
primeNpmChannelTag("latest", "9999.0.0");
nodeVersionSatisfiesEngine.mockReturnValue(true);
vi.mocked(checkUpdateStatus).mockResolvedValue({
root: "/test/path",
@@ -1879,10 +1883,7 @@ describe("update-cli", () => {
}),
});
readPackageVersion.mockResolvedValue("2026.4.14");
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "2026.4.10",
});
primeNpmChannelTag("latest", "2026.4.10");
mockCurrentProcessFreshDoctor();
mockGatewayProbe("2026.4.10", "downgraded-gateway");
@@ -1908,7 +1909,7 @@ describe("update-cli", () => {
}),
});
readPackageVersion.mockResolvedValue("2026.4.14");
vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag: "latest", version: "2026.4.10" });
primeNpmChannelTag("latest", "2026.4.10");
mockCurrentProcessFreshDoctor();
await updateCommand({ yes: true, tag: "2026.4.10", restart: false });
@@ -1936,7 +1937,7 @@ describe("update-cli", () => {
readPackageVersion.mockImplementation(async (pkgRoot: string) =>
pkgRoot === downgradedRoot ? "2026.4.10" : "2026.4.14",
);
vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag: "latest", version: "2026.4.10" });
primeNpmChannelTag("latest", "2026.4.10");
delete process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION;
let hostVersionDuringPluginUpdate: string | undefined = "unset";
@@ -3105,9 +3106,8 @@ describe("update-cli", () => {
it("refuses a package target that changes after the service stops", async () => {
mockPackageInstallStatus(process.cwd());
mockOwnedGitService();
serviceReadCommand.mockResolvedValue({
programArguments: ["node", path.join(process.cwd(), "dist", "index.js"), "gateway", "run"],
environment: { OPENCLAW_STATE_DIR: "/managed/state" },
primeServiceCommand(["node", path.join(process.cwd(), "dist", "index.js"), "gateway", "run"], {
OPENCLAW_STATE_DIR: "/managed/state",
});
serviceLoaded.mockResolvedValue(true);
vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({
@@ -3345,10 +3345,7 @@ describe("update-cli", () => {
...baseSnapshot,
config: { update: { channel: "beta" } } as OpenClawConfig,
});
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "1.2.3-1",
});
primeNpmChannelTag("latest", "1.2.3-1");
await updateCommand({});
expectPackageInstallSpec("openclaw@1.2.3-1");
@@ -3528,10 +3525,7 @@ describe("update-cli", () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
readPackageVersion.mockResolvedValue("2026.4.22");
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "2026.4.22",
});
primeNpmChannelTag("latest", "2026.4.22");
await updateCommand({ yes: true });
@@ -3551,10 +3545,7 @@ describe("update-cli", () => {
setTty(false);
mockPackageInstallStatus(tempDir);
readPackageVersion.mockResolvedValue("2026.4.22");
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: null,
});
primeNpmChannelTag("latest", null);
mockCurrentProcessFreshDoctor();
await updateCommand({});
@@ -3633,12 +3624,9 @@ describe("update-cli", () => {
it("refuses package updates from inherited gateway service env when --no-restart leaves the gateway running", async () => {
mockPackageInstallStatus(createCaseDir("openclaw-update"));
serviceReadCommand.mockResolvedValue({
programArguments: ["openclaw", "gateway", "run"],
environment: {
OPENCLAW_SERVICE_MARKER: "openclaw",
OPENCLAW_SERVICE_KIND: "gateway",
},
primeServiceCommand(["openclaw", "gateway", "run"], {
OPENCLAW_SERVICE_MARKER: "openclaw",
OPENCLAW_SERVICE_KIND: "gateway",
});
serviceLoaded.mockResolvedValue(true);
@@ -3670,12 +3658,9 @@ describe("update-cli", () => {
"refuses package updates from inherited gateway service env when $name",
async ({ setupRuntime }) => {
mockPackageInstallStatus(createCaseDir("openclaw-update"));
serviceReadCommand.mockResolvedValue({
programArguments: ["openclaw", "gateway", "run"],
environment: {
OPENCLAW_SERVICE_MARKER: "openclaw",
OPENCLAW_SERVICE_KIND: "gateway",
},
primeServiceCommand(["openclaw", "gateway", "run"], {
OPENCLAW_SERVICE_MARKER: "openclaw",
OPENCLAW_SERVICE_KIND: "gateway",
});
setupRuntime();
@@ -3758,10 +3743,7 @@ describe("update-cli", () => {
it("blocks package updates when the target requires a newer Node runtime", async () => {
mockPackageInstallStatus(createCaseDir("openclaw-update"));
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "2026.3.23-2",
});
primeNpmChannelTag("latest", "2026.3.23-2");
vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({
target: "latest",
version: "2026.3.23-2",
@@ -3894,10 +3876,7 @@ describe("update-cli", () => {
const nodeModules = path.join(prefix, "lib", "node_modules");
const { pkgRoot } = await setupInstalledPackageAtNodeModules(nodeModules, "2026.4.20");
readPackageVersion.mockResolvedValue("2026.4.20");
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "2026.4.25",
});
primeNpmChannelTag("latest", "2026.4.25");
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
return commandResult({ stdout: `${nodeModules}\n` });
@@ -3942,18 +3921,13 @@ describe("update-cli", () => {
it("runs old package doctors without fix mode when service ownership is unknown", async () => {
const tempDir = await createTrackedTempDir("openclaw-update-package-");
const { nodeModules, pkgRoot, entryPath } = await setupInstalledPackageRoot(tempDir);
serviceReadCommand.mockResolvedValue({
programArguments: ["openclaw-wrapper", "gateway", "run"],
});
primeServiceCommand(["openclaw-wrapper", "gateway", "run"]);
serviceLoaded.mockResolvedValue(true);
serviceReadRuntime.mockResolvedValue({ status: "stopped", state: "stopped" });
readPackageVersion.mockImplementation(async (packageRoot: string) =>
packageRoot === pkgRoot ? "2026.4.21" : "1.0.0",
);
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "2026.4.21",
});
primeNpmChannelTag("latest", "2026.4.21");
mockFileBackedPathExists();
mockNpmGlobalRoot(nodeModules);
@@ -3991,10 +3965,7 @@ describe("update-cli", () => {
it("continues package post-core work for explicit post-update doctor advisories", async () => {
const tempDir = await createTrackedTempDir("openclaw-update-package-doctor-warning-");
const { nodeModules, entryPath } = await setupInstalledPackageRoot(tempDir);
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "2026.4.21",
});
primeNpmChannelTag("latest", "2026.4.21");
mockFileBackedPathExists();
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv, options) => {
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
@@ -4060,10 +4031,7 @@ describe("update-cli", () => {
it("fails package updates when the post-update doctor is killed after verification", async () => {
const tempDir = await createTrackedTempDir("openclaw-update-package-doctor-timeout-");
const { nodeModules, entryPath } = await setupInstalledPackageRoot(tempDir);
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "2026.4.21",
});
primeNpmChannelTag("latest", "2026.4.21");
mockFileBackedPathExists();
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") {
@@ -4103,10 +4071,7 @@ describe("update-cli", () => {
const { nodeModules, pkgRoot, entryPath } = await setupInstalledPackageAtNodeModules(
path.join(tempDir, "lib", "node_modules"),
);
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "2026.5.14",
});
primeNpmChannelTag("latest", "2026.5.14");
mockFileBackedPathExists();
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (!Array.isArray(argv)) {
@@ -4351,12 +4316,9 @@ describe("update-cli", () => {
it("preserves both the update and Scheduled Task recovery failures", async () => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
mockPackageInstallStatus(createCaseDir("openclaw-update-recovery-failure"));
serviceReadCommand.mockResolvedValue({
programArguments: ["openclaw", "gateway", "run"],
environment: {
OPENCLAW_SERVICE_MARKER: "openclaw",
OPENCLAW_SERVICE_KIND: "gateway",
},
primeServiceCommand(["openclaw", "gateway", "run"], {
OPENCLAW_SERVICE_MARKER: "openclaw",
OPENCLAW_SERVICE_KIND: "gateway",
});
serviceReadRuntime.mockResolvedValue({ status: "stopped", state: "stopped" });
suspendScheduledTaskAutoStartForUpdate.mockResolvedValue(true);
@@ -4399,12 +4361,9 @@ describe("update-cli", () => {
);
resumeScheduledTaskAutoStartAfterUpdate.mockResolvedValue(true);
mockPackageInstallStatus(createCaseDir("openclaw-update-suspension-signal"));
serviceReadCommand.mockResolvedValue({
programArguments: ["openclaw", "gateway", "run"],
environment: {
OPENCLAW_SERVICE_MARKER: "openclaw",
OPENCLAW_SERVICE_KIND: "gateway",
},
primeServiceCommand(["openclaw", "gateway", "run"], {
OPENCLAW_SERVICE_MARKER: "openclaw",
OPENCLAW_SERVICE_KIND: "gateway",
});
serviceReadRuntime.mockResolvedValue({ status: "stopped", state: "stopped" });
@@ -4439,12 +4398,9 @@ describe("update-cli", () => {
async (runtimeStatus) => {
const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32");
mockPackageInstallStatus(createCaseDir("openclaw-update-stopped-task"));
serviceReadCommand.mockResolvedValue({
programArguments: ["openclaw", "gateway", "run"],
environment: {
OPENCLAW_SERVICE_MARKER: "openclaw",
OPENCLAW_SERVICE_KIND: "gateway",
},
primeServiceCommand(["openclaw", "gateway", "run"], {
OPENCLAW_SERVICE_MARKER: "openclaw",
OPENCLAW_SERVICE_KIND: "gateway",
});
serviceReadRuntime.mockResolvedValue(
runtimeStatus === "running"
@@ -5025,9 +4981,7 @@ describe("update-cli", () => {
"utf-8",
);
mockPackageInstallStatus(shellRoot);
serviceReadCommand.mockResolvedValue({
programArguments: [serviceNode, path.join(serviceRoot, "dist", "index.js"), "gateway"],
});
primeServiceCommand([serviceNode, path.join(serviceRoot, "dist", "index.js"), "gateway"]);
await updateCommand({ dryRun: true });
@@ -5053,10 +5007,8 @@ describe("update-cli", () => {
"utf-8",
);
mockPackageInstallStatus(shellRoot);
serviceReadCommand.mockResolvedValue({
programArguments: [serviceNode, path.join(serviceRoot, "dist", "index.js"), "gateway"],
});
vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag: "latest", version: "2026.5.20" });
primeServiceCommand([serviceNode, path.join(serviceRoot, "dist", "index.js"), "gateway"]);
primeNpmChannelTag("latest", "2026.5.20");
vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({
target: "latest",
version: "2026.5.20",
@@ -5101,14 +5053,9 @@ describe("update-cli", () => {
await fs.writeFile(entrypoint, "", "utf-8");
await writePackageDistInventory(serviceRoot);
mockPackageInstallStatus(shellRoot);
serviceReadCommand.mockResolvedValue({
programArguments: [serviceNode, entrypoint, "gateway"],
});
primeServiceCommand([serviceNode, entrypoint, "gateway"]);
serviceLoaded.mockResolvedValue(true);
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "2026.5.20",
});
primeNpmChannelTag("latest", "2026.5.20");
mockFileBackedPathExists();
vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => {
if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") {
@@ -5157,9 +5104,7 @@ describe("update-cli", () => {
const serviceNode = "/opt/other-node/bin/node";
const entrypoint = path.join(root, "dist", "index.js");
mockPackageInstallStatus(root);
serviceReadCommand.mockResolvedValue({
programArguments: [serviceNode, entrypoint, "gateway"],
});
primeServiceCommand([serviceNode, entrypoint, "gateway"]);
await updateCommand({ dryRun: true });
@@ -5195,11 +5140,9 @@ describe("update-cli", () => {
await writePackageDistInventory(root);
// Same package root for both shell and service.
mockPackageInstallStatus(root);
serviceReadCommand.mockResolvedValue({
programArguments: [serviceNode, entrypoint, "gateway"],
});
primeServiceCommand([serviceNode, entrypoint, "gateway"]);
serviceLoaded.mockResolvedValue(true);
vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag: "latest", version: "2026.7.1" });
primeNpmChannelTag("latest", "2026.7.1");
vi.mocked(fetchNpmPackageTargetStatus).mockResolvedValue({
target: "latest",
version: "2026.7.1",
@@ -5270,14 +5213,9 @@ describe("update-cli", () => {
await fs.writeFile(entrypoint, "", "utf-8");
await writePackageDistInventory(root);
mockPackageInstallStatus(root);
serviceReadCommand.mockResolvedValue({
programArguments: [serviceNode, entrypoint, "gateway"],
});
primeServiceCommand([serviceNode, entrypoint, "gateway"]);
serviceLoaded.mockResolvedValue(true);
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
tag: "latest",
version: "2026.5.20",
});
primeNpmChannelTag("latest", "2026.5.20");
mockFileBackedPathExists();
// The PATH npm returns a DIFFERENT global root (simulates Node-B's npm).
const nodeBGlobalRoot = path.join(
@@ -6247,9 +6185,7 @@ describe("update-cli", () => {
it("tries the updated install restart when package service refresh fails", async () => {
const { updatedRoot, updatedEntrypoint } = setupNpmUpdatedRootRefresh();
serviceLoaded.mockResolvedValue(true);
serviceReadCommand.mockResolvedValue({
programArguments: ["node", updatedEntrypoint, "gateway", "run"],
});
primeServiceCommand(["node", updatedEntrypoint, "gateway", "run"]);
mockGatewayInstallFailure(updatedEntrypoint);
mockGatewayProbe("2026.4.24", "updated-gateway");
@@ -6289,9 +6225,7 @@ describe("update-cli", () => {
candidate === updatedEntrypoint || candidate === updatedPackageJson,
);
serviceLoaded.mockResolvedValue(true);
serviceReadCommand.mockResolvedValue({
programArguments: ["node", updatedEntrypoint, "gateway", "run"],
});
primeServiceCommand(["node", updatedEntrypoint, "gateway", "run"]);
mockGatewayInstallFailure(updatedEntrypoint);
mockGatewayProbe("2026.4.24", "matching-old-gateway");
@@ -6340,9 +6274,7 @@ describe("update-cli", () => {
[oldEntrypoint, updatedEntrypoint, oldPackageJson, updatedPackageJson].includes(candidate),
);
serviceLoaded.mockResolvedValue(true);
serviceReadCommand.mockResolvedValue({
programArguments: ["node", oldEntrypoint, "gateway", "run"],
});
primeServiceCommand(["node", oldEntrypoint, "gateway", "run"]);
mockGatewayInstallFailure(updatedEntrypoint);
mockGatewayProbe("2026.4.24", "matching-old-service");
+99 -393
View File
@@ -233,16 +233,8 @@ vi.mock("../commands/model-picker.runtime.js", () => ({
}));
const OPENROUTER_CATALOG = [
{
provider: "openrouter",
id: "auto",
name: "OpenRouter Auto",
},
{
provider: "openrouter",
id: "meta-llama/llama-3.3-70b:free",
name: "Llama 3.3 70B",
},
catalogModel("openrouter", "auto", "OpenRouter Auto"),
catalogModel("openrouter", "meta-llama/llama-3.3-70b:free", "Llama 3.3 70B"),
] as const;
function expectRouterModelFiltering(options: Array<{ value: string }>) {
@@ -256,6 +248,19 @@ function createSelectAllMultiselect() {
return vi.fn(async (params) => params.options.map((option: { value: string }) => option.value));
}
function promptDefaultPicker(params: Parameters<typeof promptDefaultModel>[0]) {
return promptDefaultModel({
allowKeep: false,
includeManual: false,
ignoreAllowlist: true,
...params,
});
}
function catalogModel(provider: string, id: string, name: string): ModelCatalogEntry {
return { provider, id, name };
}
function configuredTextModel(id: string, name: string) {
return {
id,
@@ -428,12 +433,9 @@ describe("promptDefaultModel", () => {
const select = vi.fn(async (params) => params.initialValue as never);
const prompter = makePrompter({ select });
await promptDefaultModel({
await promptDefaultPicker({
config: { agents: { defaults: {} } } as OpenClawConfig,
prompter,
allowKeep: false,
includeManual: false,
ignoreAllowlist: true,
});
const options = pickerOptions(select as MockCallSource);
@@ -462,12 +464,9 @@ describe("promptDefaultModel", () => {
]);
const select = vi.fn(async (params) => params.initialValue as never);
await promptDefaultModel({
await promptDefaultPicker({
config: { agents: { defaults } } as OpenClawConfig,
prompter: makePrompter({ select }),
allowKeep: false,
includeManual: false,
ignoreAllowlist: true,
});
const option = requireOption(pickerOptions(select as MockCallSource), "openai/gpt-5.5");
@@ -493,12 +492,9 @@ describe("promptDefaultModel", () => {
} as OpenClawConfig;
const select = vi.fn(async (params) => params.initialValue as never);
await promptDefaultModel({
await promptDefaultPicker({
config,
prompter: makePrompter({ select }),
allowKeep: false,
includeManual: false,
ignoreAllowlist: true,
});
const option = requireOption(pickerOptions(select as MockCallSource), "openai/gpt-5.5");
@@ -537,12 +533,9 @@ describe("promptDefaultModel", () => {
modelCatalogRouteVariants.value = [platform, chatGPT];
const select = vi.fn(async (params) => params.initialValue as never);
await promptDefaultModel({
await promptDefaultPicker({
config: { agents: { defaults: {} } } as OpenClawConfig,
prompter: makePrompter({ select }),
allowKeep: false,
includeManual: false,
ignoreAllowlist: true,
});
const option = requireOption(pickerOptions(select as MockCallSource), "openai/gpt-5.5");
@@ -562,12 +555,9 @@ describe("promptDefaultModel", () => {
const select = vi.fn(async (params) => params.initialValue as never);
const prompter = makePrompter({ select });
await promptDefaultModel({
await promptDefaultPicker({
config: { agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } } },
prompter,
allowKeep: false,
includeManual: false,
ignoreAllowlist: true,
});
const values = optionValues(pickerOptions(select as MockCallSource));
@@ -587,14 +577,11 @@ describe("promptDefaultModel", () => {
]);
const select = vi.fn(async (params) => params.initialValue as never);
await promptDefaultModel({
await promptDefaultPicker({
config: {
agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } },
},
prompter: makePrompter({ select }),
allowKeep: false,
includeManual: false,
ignoreAllowlist: true,
});
expect(optionValues(pickerOptions(select as MockCallSource))).toEqual([
@@ -618,12 +605,9 @@ describe("promptDefaultModel", () => {
const select = vi.fn(async (params) => params.initialValue as never);
const prompter = makePrompter({ select });
await promptDefaultModel({
await promptDefaultPicker({
config: { agents: { defaults: {} } } as OpenClawConfig,
prompter,
allowKeep: false,
includeManual: false,
ignoreAllowlist: true,
});
const values = optionValues(pickerOptions(select as MockCallSource));
@@ -659,12 +643,9 @@ describe("promptDefaultModel", () => {
]);
const select = vi.fn(async (params) => params.initialValue as never);
await promptDefaultModel({
await promptDefaultPicker({
config: { agents: { defaults: {} } } as OpenClawConfig,
prompter: makePrompter({ select }),
allowKeep: false,
includeManual: false,
ignoreAllowlist: true,
});
expect(optionValues(pickerOptions(select as MockCallSource))).toEqual([
@@ -699,12 +680,9 @@ describe("promptDefaultModel", () => {
const select = vi.fn(async (params) => params.initialValue as never);
const prompter = makePrompter({ select });
await promptDefaultModel({
await promptDefaultPicker({
config: { agents: { defaults: {} } } as OpenClawConfig,
prompter,
allowKeep: false,
includeManual: false,
ignoreAllowlist: true,
});
const values = optionValues(pickerOptions(select as MockCallSource));
@@ -723,12 +701,9 @@ describe("promptDefaultModel", () => {
const select = vi.fn(async (params) => params.options[0]?.value as never);
const prompter = makePrompter({ select });
const result = await promptDefaultModel({
const result = await promptDefaultPicker({
config: { agents: { defaults: {} } } as OpenClawConfig,
prompter,
allowKeep: false,
includeManual: false,
ignoreAllowlist: true,
});
expect(result.model).toBe("google/gemini-3.1-pro-preview");
@@ -764,12 +739,9 @@ describe("promptDefaultModel", () => {
agents: { defaults: {} },
} as OpenClawConfig;
const result = await promptDefaultModel({
const result = await promptDefaultPicker({
config,
prompter,
allowKeep: false,
includeManual: false,
ignoreAllowlist: true,
});
expect(loadModelCatalog).not.toHaveBeenCalled();
@@ -783,16 +755,8 @@ describe("promptDefaultModel", () => {
it("treats byteplus plan models as preferred-provider matches", async () => {
loadModelCatalog.mockResolvedValue([
{
provider: "openai",
id: "gpt-5.5",
name: "GPT-5.5",
},
{
provider: "byteplus-plan",
id: "ark-code-latest",
name: "Ark Coding Plan",
},
catalogModel("openai", "gpt-5.5", "GPT-5.5"),
catalogModel("byteplus-plan", "ark-code-latest", "Ark Coding Plan"),
]);
const select = vi.fn(async (params) => params.initialValue as never);
@@ -805,12 +769,10 @@ describe("promptDefaultModel", () => {
},
} as OpenClawConfig;
const result = await promptDefaultModel({
const result = await promptDefaultPicker({
config,
prompter,
allowKeep: true,
includeManual: false,
ignoreAllowlist: true,
preferredProvider: "byteplus",
});
@@ -828,11 +790,7 @@ describe("promptDefaultModel", () => {
it("shows literal double-prefix labels for providers that preserve literal prefixes", async () => {
loadModelCatalog.mockResolvedValue([
{
provider: "nvidia",
id: "nvidia/nemotron-3-super-120b-a12b",
name: "Nemotron",
},
catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Nemotron"),
]);
resolvePluginProviders.mockReturnValue([
{
@@ -851,12 +809,10 @@ describe("promptDefaultModel", () => {
},
} as OpenClawConfig;
await promptDefaultModel({
await promptDefaultPicker({
config,
prompter,
allowKeep: true,
includeManual: false,
ignoreAllowlist: true,
});
const options = pickerOptions(select as MockCallSource);
@@ -870,21 +826,9 @@ describe("promptDefaultModel", () => {
it("does not double-prefix non-literal NVIDIA vendor model labels", async () => {
loadModelCatalog.mockResolvedValue([
{
provider: "nvidia",
id: "nvidia/nemotron-3-super-120b-a12b",
name: "Nemotron",
},
{
provider: "nvidia",
id: "minimaxai/minimax-m2.7",
name: "MiniMax M2.7",
},
{
provider: "nvidia",
id: "z-ai/glm-5.1",
name: "GLM 5.1",
},
catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Nemotron"),
catalogModel("nvidia", "minimaxai/minimax-m2.7", "MiniMax M2.7"),
catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"),
]);
resolvePluginProviders.mockReturnValue([
{
@@ -896,12 +840,9 @@ describe("promptDefaultModel", () => {
const select = vi.fn(async (params) => params.initialValue as never);
const prompter = makePrompter({ select });
await promptDefaultModel({
await promptDefaultPicker({
config: { agents: { defaults: {} } } as OpenClawConfig,
prompter,
allowKeep: false,
includeManual: false,
ignoreAllowlist: true,
preferredProvider: "nvidia",
});
@@ -933,12 +874,11 @@ describe("promptDefaultModel", () => {
},
} as OpenClawConfig;
const result = await promptDefaultModel({
const result = await promptDefaultPicker({
config,
prompter,
allowKeep: true,
includeManual: true,
ignoreAllowlist: true,
preferredProvider: "nvidia",
browseCatalogOnDemand: true,
});
@@ -966,12 +906,11 @@ describe("promptDefaultModel", () => {
},
} as OpenClawConfig;
const result = await promptDefaultModel({
const result = await promptDefaultPicker({
config,
prompter,
allowKeep: true,
includeManual: true,
ignoreAllowlist: true,
preferredProvider: "openai",
browseCatalogOnDemand: true,
});
@@ -999,12 +938,11 @@ describe("promptDefaultModel", () => {
},
} as OpenClawConfig;
const result = await promptDefaultModel({
const result = await promptDefaultPicker({
config,
prompter,
allowKeep: true,
includeManual: true,
ignoreAllowlist: true,
browseCatalogOnDemand: true,
loadCatalog: true,
});
@@ -1023,16 +961,8 @@ describe("promptDefaultModel", () => {
it("loads the full model catalog when browsing without a preferred provider", async () => {
loadModelCatalog.mockResolvedValue([
{
provider: "openai",
id: "gpt-5.5",
name: "GPT-5.5",
},
{
provider: "openai",
id: "gpt-5.5-pro",
name: "GPT-5.5 Pro",
},
catalogModel("openai", "gpt-5.5", "GPT-5.5"),
catalogModel("openai", "gpt-5.5-pro", "GPT-5.5 Pro"),
]);
const select = vi
.fn()
@@ -1052,12 +982,11 @@ describe("promptDefaultModel", () => {
},
} as OpenClawConfig;
const result = await promptDefaultModel({
const result = await promptDefaultPicker({
config,
prompter,
allowKeep: true,
includeManual: true,
ignoreAllowlist: true,
browseCatalogOnDemand: true,
});
@@ -1070,16 +999,8 @@ describe("promptDefaultModel", () => {
it("loads the preferred provider catalog when the user chooses to browse", async () => {
loadPreferredProviderPickerCatalog.mockResolvedValue([
{
provider: "openai",
id: "gpt-5.5",
name: "GPT-5.5",
},
{
provider: "openai",
id: "gpt-5.5-pro",
name: "GPT-5.5 Pro",
},
catalogModel("openai", "gpt-5.5", "GPT-5.5"),
catalogModel("openai", "gpt-5.5-pro", "GPT-5.5 Pro"),
]);
const select = vi
.fn()
@@ -1099,12 +1020,11 @@ describe("promptDefaultModel", () => {
},
} as OpenClawConfig;
const result = await promptDefaultModel({
const result = await promptDefaultPicker({
config,
prompter,
allowKeep: true,
includeManual: true,
ignoreAllowlist: true,
preferredProvider: "openai",
browseCatalogOnDemand: true,
});
@@ -1122,16 +1042,8 @@ describe("promptDefaultModel", () => {
it("scopes on-demand preferred-provider loads before the first model prompt", async () => {
loadPreferredProviderPickerCatalog.mockResolvedValue([
{
provider: "nvidia",
id: "nvidia/nemotron-3-super-120b-a12b",
name: "NVIDIA Nemotron 3 Super 120B",
},
{
provider: "nvidia",
id: "moonshotai/kimi-k2.5",
name: "Kimi K2.5",
},
catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "NVIDIA Nemotron 3 Super 120B"),
catalogModel("nvidia", "moonshotai/kimi-k2.5", "Kimi K2.5"),
]);
const select = vi.fn(async (params) => params.options[0]?.value as never);
const prompter = makePrompter({ select });
@@ -1143,12 +1055,9 @@ describe("promptDefaultModel", () => {
},
} as OpenClawConfig;
const result = await promptDefaultModel({
const result = await promptDefaultPicker({
config,
prompter,
allowKeep: false,
includeManual: false,
ignoreAllowlist: true,
preferredProvider: "nvidia",
browseCatalogOnDemand: true,
});
@@ -1168,16 +1077,8 @@ describe("promptDefaultModel", () => {
it("preselects the first live provider row when keep-current is disabled", async () => {
loadPreferredProviderPickerCatalog.mockResolvedValue([
{
provider: "nvidia",
id: "z-ai/glm-5.1",
name: "GLM 5.1",
},
{
provider: "nvidia",
id: "nvidia/nemotron-3-super-120b-a12b",
name: "NVIDIA Nemotron 3 Super 120B",
},
catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"),
catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "NVIDIA Nemotron 3 Super 120B"),
]);
const select = vi.fn(async (params) => params.initialValue as never);
const prompter = makePrompter({ select });
@@ -1189,12 +1090,9 @@ describe("promptDefaultModel", () => {
},
} as OpenClawConfig;
const result = await promptDefaultModel({
const result = await promptDefaultPicker({
config,
prompter,
allowKeep: false,
includeManual: false,
ignoreAllowlist: true,
preferredProvider: "nvidia",
browseCatalogOnDemand: true,
});
@@ -1214,21 +1112,9 @@ describe("promptDefaultModel", () => {
it("keeps on-demand NVIDIA vendor labels single-prefixed after browsing", async () => {
loadPreferredProviderPickerCatalog.mockResolvedValue([
{
provider: "nvidia",
id: "nvidia/nemotron-3-super-120b-a12b",
name: "NVIDIA Nemotron 3 Super 120B",
},
{
provider: "nvidia",
id: "minimaxai/minimax-m2.7",
name: "MiniMax M2.7",
},
{
provider: "nvidia",
id: "z-ai/glm-5.1",
name: "GLM 5.1",
},
catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "NVIDIA Nemotron 3 Super 120B"),
catalogModel("nvidia", "minimaxai/minimax-m2.7", "MiniMax M2.7"),
catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"),
]);
resolvePluginProviders.mockReturnValue([
{
@@ -1242,7 +1128,7 @@ describe("promptDefaultModel", () => {
.mockResolvedValueOnce("nvidia/nemotron-3-super-120b-a12b");
const prompter = makePrompter({ select });
await promptDefaultModel({
await promptDefaultPicker({
config: {
agents: {
defaults: {
@@ -1253,7 +1139,6 @@ describe("promptDefaultModel", () => {
prompter,
allowKeep: true,
includeManual: true,
ignoreAllowlist: true,
preferredProvider: "nvidia",
browseCatalogOnDemand: true,
});
@@ -1270,21 +1155,9 @@ describe("promptDefaultModel", () => {
it("omits local NVIDIA static fallback rows when browsing live provider rows", async () => {
loadPreferredProviderPickerCatalog.mockResolvedValue([
{
provider: "nvidia",
id: "nvidia/nemotron-3-super-120b-a12b",
name: "NVIDIA Nemotron 3 Super 120B",
},
{
provider: "nvidia",
id: "minimaxai/minimax-m2.7",
name: "MiniMax M2.7",
},
{
provider: "nvidia",
id: "z-ai/glm-5.1",
name: "GLM 5.1",
},
catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "NVIDIA Nemotron 3 Super 120B"),
catalogModel("nvidia", "minimaxai/minimax-m2.7", "MiniMax M2.7"),
catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"),
]);
loadStaticManifestCatalogRowsForList.mockReturnValue([
manifestTextRow("nvidia", "minimaxai/minimax-m2.5", "MiniMax M2.5", "deprecated"),
@@ -1302,7 +1175,7 @@ describe("promptDefaultModel", () => {
.mockResolvedValueOnce("nvidia/nemotron-3-super-120b-a12b");
const prompter = makePrompter({ select });
await promptDefaultModel({
await promptDefaultPicker({
config: {
agents: {
defaults: {
@@ -1313,7 +1186,6 @@ describe("promptDefaultModel", () => {
prompter,
allowKeep: true,
includeManual: true,
ignoreAllowlist: true,
preferredProvider: "nvidia",
browseCatalogOnDemand: true,
});
@@ -1329,11 +1201,7 @@ describe("promptDefaultModel", () => {
it("uses the configured default agent dir for provider-scoped catalog auth", async () => {
loadPreferredProviderPickerCatalog.mockResolvedValue([
{
provider: "nvidia",
id: "z-ai/glm-5.1",
name: "GLM 5.1",
},
catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"),
]);
const select = vi.fn(async (params) => params.options[0]?.value as never);
const prompter = makePrompter({ select });
@@ -1350,12 +1218,9 @@ describe("promptDefaultModel", () => {
},
} as OpenClawConfig;
await promptDefaultModel({
await promptDefaultPicker({
config,
prompter,
allowKeep: false,
includeManual: false,
ignoreAllowlist: true,
preferredProvider: "nvidia",
browseCatalogOnDemand: true,
env,
@@ -1371,11 +1236,7 @@ describe("promptDefaultModel", () => {
it("supports configuring vLLM during setup", async () => {
loadModelCatalog.mockResolvedValue([
{
provider: "anthropic",
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.5",
},
catalogModel("anthropic", "claude-sonnet-4-6", "Claude Sonnet 4.5"),
]);
resolveProviderModelPickerEntries.mockReturnValue([
{ value: "vllm", label: "vLLM (custom)", hint: "Enter vLLM URL + API key + model" },
@@ -1413,13 +1274,10 @@ describe("promptDefaultModel", () => {
const prompter = makePrompter({ select });
const config = { agents: { defaults: {} } } as OpenClawConfig;
const result = await promptDefaultModel({
const result = await promptDefaultPicker({
config,
prompter,
allowKeep: false,
includeManual: false,
includeProviderPluginSetups: true,
ignoreAllowlist: true,
agentDir: "/tmp/openclaw-agent",
runtime: {} as never,
});
@@ -1443,13 +1301,7 @@ describe("promptDefaultModel", () => {
});
it("prefers provider model-picker contributions when the runtime exposes them", async () => {
loadModelCatalog.mockResolvedValue([
{
provider: "openai",
id: "gpt-5.5",
name: "GPT-5.5",
},
]);
loadModelCatalog.mockResolvedValue([catalogModel("openai", "gpt-5.5", "GPT-5.5")]);
providerModelPickerContributionRuntime.enabled = true;
providerModelPickerContributionRuntime.resolve.mockReturnValue([
{
@@ -1477,13 +1329,10 @@ describe("promptDefaultModel", () => {
});
const prompter = makePrompter({ select });
await promptDefaultModel({
await promptDefaultPicker({
config: { agents: { defaults: {} } } as OpenClawConfig,
prompter,
allowKeep: false,
includeManual: false,
includeProviderPluginSetups: true,
ignoreAllowlist: true,
agentDir: "/tmp/openclaw-agent",
runtime: {} as never,
});
@@ -1505,12 +1354,11 @@ describe("promptDefaultModel", () => {
},
} as OpenClawConfig;
const result = await promptDefaultModel({
const result = await promptDefaultPicker({
config,
prompter,
allowKeep: true,
includeManual: true,
ignoreAllowlist: true,
includeProviderPluginSetups: true,
loadCatalog: false,
agentDir: "/tmp/openclaw-agent",
@@ -1529,13 +1377,7 @@ describe("promptDefaultModel", () => {
});
it("surfaces NVIDIA provider model-picker contributions", async () => {
loadModelCatalog.mockResolvedValue([
{
provider: "openai",
id: "gpt-5.4",
name: "GPT-5.4",
},
]);
loadModelCatalog.mockResolvedValue([catalogModel("openai", "gpt-5.4", "GPT-5.4")]);
providerModelPickerContributionRuntime.enabled = true;
providerModelPickerContributionRuntime.resolve.mockReturnValue([
{
@@ -1558,13 +1400,10 @@ describe("promptDefaultModel", () => {
});
const prompter = makePrompter({ select });
await promptDefaultModel({
await promptDefaultPicker({
config: { agents: { defaults: {} } } as OpenClawConfig,
prompter,
allowKeep: false,
includeManual: false,
includeProviderPluginSetups: true,
ignoreAllowlist: true,
agentDir: "/tmp/openclaw-agent",
runtime: {} as never,
});
@@ -1579,21 +1418,9 @@ describe("promptDefaultModel", () => {
describe("promptModelAllowlist", () => {
it("filters to allowed keys when provided", async () => {
loadModelCatalog.mockResolvedValue([
{
provider: "anthropic",
id: "claude-opus-4-6",
name: "Claude Opus 4.5",
},
{
provider: "anthropic",
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.5",
},
{
provider: "openai",
id: "gpt-5.5",
name: "GPT-5.5",
},
catalogModel("anthropic", "claude-opus-4-6", "Claude Opus 4.5"),
catalogModel("anthropic", "claude-sonnet-4-6", "Claude Sonnet 4.5"),
catalogModel("openai", "gpt-5.5", "GPT-5.5"),
]);
const multiselect = createSelectAllMultiselect();
@@ -1613,13 +1440,7 @@ describe("promptModelAllowlist", () => {
it("localizes the model allowlist picker", async () => {
process.env.OPENCLAW_LOCALE = "zh-CN";
loadModelCatalog.mockResolvedValue([
{
provider: "openai",
id: "gpt-5.5",
name: "GPT-5.5",
},
]);
loadModelCatalog.mockResolvedValue([catalogModel("openai", "gpt-5.5", "GPT-5.5")]);
const multiselect = createSelectAllMultiselect();
const prompter = makePrompter({ multiselect });
@@ -1743,13 +1564,7 @@ describe("promptModelAllowlist", () => {
});
it("uses configured provider models for allowlist picker without loading the full catalog in replace mode", async () => {
loadModelCatalog.mockResolvedValue([
{
provider: "openai",
id: "gpt-5.5",
name: "GPT-5.5",
},
]);
loadModelCatalog.mockResolvedValue([catalogModel("openai", "gpt-5.5", "GPT-5.5")]);
const multiselect = createSelectAllMultiselect();
const prompter = makePrompter({ multiselect });
@@ -1782,21 +1597,9 @@ describe("promptModelAllowlist", () => {
it("scopes the initial allowlist picker to the preferred provider", async () => {
loadModelCatalog.mockResolvedValue([
{
provider: "anthropic",
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.5",
},
{
provider: "openai",
id: "gpt-5.5",
name: "GPT-5.5",
},
{
provider: "openai",
id: "gpt-5.4-mini",
name: "GPT-5.4 Mini",
},
catalogModel("anthropic", "claude-sonnet-4-6", "Claude Sonnet 4.5"),
catalogModel("openai", "gpt-5.5", "GPT-5.5"),
catalogModel("openai", "gpt-5.4-mini", "GPT-5.4 Mini"),
]);
const multiselect = createSelectAllMultiselect();
@@ -1815,16 +1618,8 @@ describe("promptModelAllowlist", () => {
it("includes stale configured preferred provider models in the scoped cleanup", async () => {
loadModelCatalog.mockResolvedValue([
{
provider: "openrouter",
id: "meta-llama/llama-3.3-70b:free",
name: "Llama 3.3 70B",
},
{
provider: "openai",
id: "gpt-5.5",
name: "GPT-5.5",
},
catalogModel("openrouter", "meta-llama/llama-3.3-70b:free", "Llama 3.3 70B"),
catalogModel("openai", "gpt-5.5", "GPT-5.5"),
]);
const activeModel = "openrouter/meta-llama/llama-3.3-70b:free";
@@ -1918,16 +1713,8 @@ describe("promptModelAllowlist", () => {
it("keeps live preferred-provider rows before configured fallback supplements", async () => {
loadPreferredProviderPickerCatalog.mockResolvedValue([
{
provider: "nvidia",
id: "minimaxai/minimax-m2.7",
name: "MiniMax M2.7",
},
{
provider: "nvidia",
id: "nvidia/nemotron-3-super-120b-a12b",
name: "Nemotron 3 Super",
},
catalogModel("nvidia", "minimaxai/minimax-m2.7", "MiniMax M2.7"),
catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Nemotron 3 Super"),
]);
const multiselect = createSelectAllMultiselect();
@@ -1969,36 +1756,12 @@ describe("promptModelAllowlist", () => {
it("keeps provider-scoped live rows authoritative over configured provider supplements", async () => {
loadPreferredProviderPickerCatalog.mockResolvedValue([
{
provider: "nvidia",
id: "nvidia/nemotron-3-super-120b-a12b",
name: "Nemotron 3 Super",
},
{
provider: "nvidia",
id: "z-ai/glm-5.1",
name: "GLM 5.1",
},
{
provider: "nvidia",
id: "minimaxai/minimax-m2.7",
name: "MiniMax M2.7",
},
{
provider: "nvidia",
id: "moonshotai/kimi-k2.5",
name: "Kimi K2.5",
},
{
provider: "nvidia",
id: "minimaxai/minimax-m2.5",
name: "MiniMax M2.5",
},
{
provider: "nvidia",
id: "z-ai/glm5",
name: "GLM5",
},
catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Nemotron 3 Super"),
catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"),
catalogModel("nvidia", "minimaxai/minimax-m2.7", "MiniMax M2.7"),
catalogModel("nvidia", "moonshotai/kimi-k2.5", "Kimi K2.5"),
catalogModel("nvidia", "minimaxai/minimax-m2.5", "MiniMax M2.5"),
catalogModel("nvidia", "z-ai/glm5", "GLM5"),
]);
loadStaticManifestCatalogRowsForList.mockReturnValue([
manifestTextRow("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Bundled Nemotron 3 Super"),
@@ -2052,16 +1815,8 @@ describe("promptModelAllowlist", () => {
it("keeps custom configured rows after provider-scoped live rows", async () => {
loadPreferredProviderPickerCatalog.mockResolvedValue([
{
provider: "nvidia",
id: "nvidia/nemotron-3-super-120b-a12b",
name: "Nemotron 3 Super",
},
{
provider: "nvidia",
id: "z-ai/glm-5.1",
name: "GLM 5.1",
},
catalogModel("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Nemotron 3 Super"),
catalogModel("nvidia", "z-ai/glm-5.1", "GLM 5.1"),
]);
loadStaticManifestCatalogRowsForList.mockReturnValue([
manifestTextRow("nvidia", "nvidia/nemotron-3-super-120b-a12b", "Bundled Nemotron 3 Super"),
@@ -2107,16 +1862,8 @@ describe("promptModelAllowlist", () => {
it("does not re-add configured static rows after filtering deprecated live rows", async () => {
loadPreferredProviderPickerCatalog.mockResolvedValue([
{
provider: "nvidia",
id: "minimaxai/minimax-m2.5",
name: "MiniMax M2.5",
},
{
provider: "nvidia",
id: "z-ai/glm5",
name: "GLM5",
},
catalogModel("nvidia", "minimaxai/minimax-m2.5", "MiniMax M2.5"),
catalogModel("nvidia", "z-ai/glm5", "GLM5"),
]);
loadStaticManifestCatalogRowsForList.mockReturnValue([
manifestTextRow("nvidia", "minimaxai/minimax-m2.5", "Bundled MiniMax M2.5", "deprecated"),
@@ -2196,16 +1943,8 @@ describe("promptModelAllowlist", () => {
it("keeps local no-key provider models visible in allowlist choices", async () => {
resolveEnvApiKey.mockReturnValue(null);
loadModelCatalog.mockResolvedValue([
{
provider: "vllm",
id: "meta-llama/Meta-Llama-3-8B-Instruct",
name: "Meta Llama",
},
{
provider: "openai",
id: "gpt-5.5",
name: "GPT-5.5",
},
catalogModel("vllm", "meta-llama/Meta-Llama-3-8B-Instruct", "Meta Llama"),
catalogModel("openai", "gpt-5.5", "GPT-5.5"),
]);
const multiselect = createSelectAllMultiselect();
@@ -2232,13 +1971,7 @@ describe("promptModelAllowlist", () => {
});
it("seeds existing model fallbacks into unscoped allowlist selections", async () => {
loadModelCatalog.mockResolvedValue([
{
provider: "openai",
id: "gpt-5.5",
name: "GPT-5.5",
},
]);
loadModelCatalog.mockResolvedValue([catalogModel("openai", "gpt-5.5", "GPT-5.5")]);
const multiselect = vi.fn(async (params) => params.initialValues ?? []);
const prompter = makePrompter({ multiselect });
@@ -2268,21 +2001,9 @@ describe("promptModelAllowlist", () => {
it("resolves bare fallback seeds against the primary model provider", async () => {
loadModelCatalog.mockResolvedValue([
{
provider: "anthropic",
id: "claude-opus-4-6",
name: "Claude Opus 4.5",
},
{
provider: "anthropic",
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.5",
},
{
provider: "openai",
id: "claude-sonnet-4-6",
name: "Wrong provider",
},
catalogModel("anthropic", "claude-opus-4-6", "Claude Opus 4.5"),
catalogModel("anthropic", "claude-sonnet-4-6", "Claude Sonnet 4.5"),
catalogModel("openai", "claude-sonnet-4-6", "Wrong provider"),
]);
const multiselect = vi.fn(async (params) => params.initialValues ?? []);
@@ -2356,21 +2077,9 @@ describe("promptModelAllowlist", () => {
it("keeps provider-scoped fallback supplements within scope", async () => {
loadModelCatalog.mockResolvedValue([
{
provider: "openai",
id: "gpt-5.5",
name: "GPT-5.5",
},
{
provider: "openai",
id: "gpt-5.4",
name: "GPT-5.4",
},
{
provider: "anthropic",
id: "claude-sonnet-4-6",
name: "Claude Sonnet 4.5",
},
catalogModel("openai", "gpt-5.5", "GPT-5.5"),
catalogModel("openai", "gpt-5.4", "GPT-5.4"),
catalogModel("anthropic", "claude-sonnet-4-6", "Claude Sonnet 4.5"),
]);
const multiselect = vi.fn(async (params) => params.initialValues ?? []);
@@ -2515,12 +2224,9 @@ describe("router model filtering", () => {
const allowlistPrompter = makePrompter({ multiselect });
const config = { agents: { defaults: {} } } as OpenClawConfig;
await promptDefaultModel({
await promptDefaultPicker({
config,
prompter: defaultPrompter,
allowKeep: false,
includeManual: false,
ignoreAllowlist: true,
});
await promptModelAllowlist({ config, prompter: allowlistPrompter });