mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 11:40:43 +00:00
Replace legacy qrcode-terminal usage with shared qrcode-tui media helpers, bound QR PNG rendering options, and raise bundled plugin host floors for the new SDK runtime surface.
512 lines
16 KiB
TypeScript
512 lines
16 KiB
TypeScript
import { Command } from "commander";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { encodePairingSetupCode } from "../pairing/setup-code.js";
|
|
import { createCliRuntimeCapture, mockRuntimeModule } from "./test-runtime-capture.js";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
loadConfig: vi.fn(),
|
|
runCommandWithTimeout: vi.fn(),
|
|
resolveCommandSecretRefsViaGateway: vi.fn(async ({ config }: { config: unknown }) => ({
|
|
resolvedConfig: config,
|
|
diagnostics: [] as string[],
|
|
})),
|
|
renderTerminal: vi.fn(async () => "ASCII-QR"),
|
|
}));
|
|
const { defaultRuntime: runtime, resetRuntimeCapture } = createCliRuntimeCapture();
|
|
const runtimeLog = runtime.log;
|
|
const runtimeError = runtime.error;
|
|
const runtimeExit = runtime.exit;
|
|
|
|
vi.mock("../runtime.js", async () => {
|
|
return mockRuntimeModule(
|
|
() => vi.importActual<typeof import("../runtime.js")>("../runtime.js"),
|
|
runtime,
|
|
);
|
|
});
|
|
vi.mock("../config/config.js", () => ({ loadConfig: mocks.loadConfig }));
|
|
vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: mocks.runCommandWithTimeout }));
|
|
vi.mock("../media/qr-terminal.ts", () => ({
|
|
renderQrTerminal: mocks.renderTerminal,
|
|
}));
|
|
vi.mock("./command-secret-gateway.js", () => ({
|
|
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway,
|
|
}));
|
|
vi.mock("../infra/device-bootstrap.js", () => ({
|
|
issueDeviceBootstrapToken: vi.fn(async () => ({
|
|
token: "bootstrap-123",
|
|
expiresAtMs: 123,
|
|
})),
|
|
}));
|
|
const loadConfig = mocks.loadConfig;
|
|
const runCommandWithTimeout = mocks.runCommandWithTimeout;
|
|
const resolveCommandSecretRefsViaGateway = mocks.resolveCommandSecretRefsViaGateway;
|
|
const renderTerminal = mocks.renderTerminal;
|
|
|
|
const { registerQrCli } = await import("./qr-cli.js");
|
|
|
|
function createRemoteQrConfig(params?: { withTailscale?: boolean }) {
|
|
return {
|
|
gateway: {
|
|
...(params?.withTailscale ? { tailscale: { mode: "serve" } } : {}),
|
|
remote: { url: "wss://remote.example.com:444", token: "remote-tok" },
|
|
auth: { mode: "token", token: "local-tok" },
|
|
},
|
|
plugins: {
|
|
entries: {
|
|
"device-pair": {
|
|
config: {
|
|
publicUrl: "wss://wrong.example.com:443",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createTailscaleRemoteRefConfig() {
|
|
return {
|
|
gateway: {
|
|
tailscale: { mode: "serve" },
|
|
remote: {
|
|
token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" },
|
|
},
|
|
auth: {},
|
|
},
|
|
};
|
|
}
|
|
|
|
function createDefaultSecretProvider() {
|
|
return {
|
|
providers: {
|
|
default: { source: "env" as const },
|
|
},
|
|
};
|
|
}
|
|
|
|
function createLocalGatewayConfigWithAuth(auth: Record<string, unknown>) {
|
|
return {
|
|
secrets: createDefaultSecretProvider(),
|
|
gateway: {
|
|
bind: "custom",
|
|
customBindHost: "127.0.0.1",
|
|
auth,
|
|
},
|
|
};
|
|
}
|
|
|
|
function createLocalGatewayPasswordRefAuth(secretId: string) {
|
|
return {
|
|
mode: "password",
|
|
password: { source: "env", provider: "default", id: secretId },
|
|
};
|
|
}
|
|
|
|
function createLocalGatewayEnvPasswordRefAuth(secretId: string) {
|
|
return {
|
|
password: { source: "env", provider: "default", id: secretId },
|
|
};
|
|
}
|
|
|
|
describe("registerQrCli", () => {
|
|
function createProgram() {
|
|
const program = new Command();
|
|
registerQrCli(program);
|
|
return program;
|
|
}
|
|
|
|
async function runQr(args: string[]) {
|
|
const program = createProgram();
|
|
await program.parseAsync(["qr", ...args], { from: "user" });
|
|
}
|
|
|
|
async function expectQrExit(args: string[]) {
|
|
await expect(runQr(args)).rejects.toThrow("exit");
|
|
}
|
|
|
|
function readRuntimeCallText(call: unknown[] | undefined): string {
|
|
const value = call?.[0];
|
|
if (typeof value === "string") {
|
|
return value;
|
|
}
|
|
return value === undefined ? "" : JSON.stringify(value);
|
|
}
|
|
|
|
function parseLastLoggedQrJson() {
|
|
const raw = runtimeLog.mock.calls.at(-1)?.[0];
|
|
return JSON.parse(typeof raw === "string" ? raw : "{}") as {
|
|
setupCode?: string;
|
|
gatewayUrl?: string;
|
|
auth?: string;
|
|
urlSource?: string;
|
|
};
|
|
}
|
|
|
|
function expectLoggedSetupCode(url: string) {
|
|
const expected = encodePairingSetupCode({
|
|
url,
|
|
bootstrapToken: "bootstrap-123",
|
|
});
|
|
expect(runtime.log).toHaveBeenCalledWith(expected);
|
|
}
|
|
|
|
function expectLoggedLocalSetupCode() {
|
|
expectLoggedSetupCode("ws://127.0.0.1:18789");
|
|
}
|
|
|
|
function mockTailscaleStatusLookup() {
|
|
runCommandWithTimeout.mockResolvedValue({
|
|
code: 0,
|
|
stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}',
|
|
stderr: "",
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
resetRuntimeCapture();
|
|
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "");
|
|
vi.stubEnv("OPENCLAW_GATEWAY_PASSWORD", "");
|
|
runtimeExit.mockImplementation(() => {
|
|
throw new Error("exit");
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it("prints setup code only when requested", async () => {
|
|
loadConfig.mockReturnValue({
|
|
gateway: {
|
|
bind: "custom",
|
|
customBindHost: "127.0.0.1",
|
|
auth: { mode: "token", token: "tok" },
|
|
},
|
|
});
|
|
|
|
await runQr(["--setup-code-only"]);
|
|
|
|
const expected = encodePairingSetupCode({
|
|
url: "ws://127.0.0.1:18789",
|
|
bootstrapToken: "bootstrap-123",
|
|
});
|
|
expect(runtime.log).toHaveBeenCalledWith(expected);
|
|
expect(renderTerminal).not.toHaveBeenCalled();
|
|
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renders ASCII QR by default", async () => {
|
|
loadConfig.mockReturnValue({
|
|
gateway: {
|
|
bind: "custom",
|
|
customBindHost: "127.0.0.1",
|
|
auth: { mode: "token", token: "tok" },
|
|
},
|
|
});
|
|
|
|
await runQr([]);
|
|
|
|
expect(renderTerminal).toHaveBeenCalledTimes(1);
|
|
const output = runtimeLog.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
|
|
expect(output).toContain("Pairing QR");
|
|
expect(output).toContain("ASCII-QR");
|
|
expect(output).toContain("Gateway:");
|
|
expect(output).toContain("openclaw devices approve <requestId>");
|
|
});
|
|
|
|
it("fails fast for insecure remote mobile pairing setup urls", async () => {
|
|
loadConfig.mockReturnValue({
|
|
gateway: {
|
|
bind: "custom",
|
|
customBindHost: "gateway.example",
|
|
auth: { mode: "token", token: "tok" },
|
|
},
|
|
});
|
|
|
|
await expectQrExit(["--setup-code-only"]);
|
|
|
|
const output = runtimeError.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
|
|
expect(output).toContain("Tailscale and public mobile pairing require a secure gateway URL");
|
|
expect(output).toContain("gateway.tailscale.mode=serve");
|
|
});
|
|
|
|
it("allows private LAN IP cleartext setup urls", async () => {
|
|
loadConfig.mockReturnValue({
|
|
gateway: {
|
|
bind: "custom",
|
|
customBindHost: "192.168.1.8",
|
|
auth: { mode: "token", token: "tok" },
|
|
},
|
|
});
|
|
|
|
await runQr(["--setup-code-only"]);
|
|
|
|
expectLoggedSetupCode("ws://192.168.1.8:18789");
|
|
});
|
|
|
|
it("allows android emulator cleartext override urls", async () => {
|
|
loadConfig.mockReturnValue({
|
|
gateway: {
|
|
bind: "loopback",
|
|
auth: { mode: "token", token: "tok" },
|
|
},
|
|
});
|
|
|
|
await runQr(["--setup-code-only", "--url", "ws://10.0.2.2:18789"]);
|
|
|
|
expectLoggedSetupCode("ws://10.0.2.2:18789");
|
|
});
|
|
|
|
it("accepts --token override when config has no auth", async () => {
|
|
loadConfig.mockReturnValue({
|
|
gateway: {
|
|
bind: "custom",
|
|
customBindHost: "127.0.0.1",
|
|
},
|
|
});
|
|
|
|
await runQr(["--setup-code-only", "--token", "override-token"]);
|
|
|
|
expectLoggedLocalSetupCode();
|
|
});
|
|
|
|
it("skips local password SecretRef resolution when --token override is provided", async () => {
|
|
loadConfig.mockReturnValue(
|
|
createLocalGatewayConfigWithAuth(
|
|
createLocalGatewayPasswordRefAuth("MISSING_LOCAL_GATEWAY_PASSWORD"),
|
|
),
|
|
);
|
|
|
|
await runQr(["--setup-code-only", "--token", "override-token"]);
|
|
|
|
expectLoggedLocalSetupCode();
|
|
});
|
|
|
|
it("resolves local gateway auth password SecretRefs before setup code generation", async () => {
|
|
vi.stubEnv("QR_LOCAL_GATEWAY_PASSWORD", "local-password-secret");
|
|
loadConfig.mockReturnValue(
|
|
createLocalGatewayConfigWithAuth(
|
|
createLocalGatewayPasswordRefAuth("QR_LOCAL_GATEWAY_PASSWORD"),
|
|
),
|
|
);
|
|
|
|
await runQr(["--setup-code-only"]);
|
|
|
|
expectLoggedLocalSetupCode();
|
|
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses OPENCLAW_GATEWAY_PASSWORD without resolving local password SecretRef", async () => {
|
|
vi.stubEnv("OPENCLAW_GATEWAY_PASSWORD", "password-from-env");
|
|
loadConfig.mockReturnValue(
|
|
createLocalGatewayConfigWithAuth(
|
|
createLocalGatewayPasswordRefAuth("MISSING_LOCAL_GATEWAY_PASSWORD"),
|
|
),
|
|
);
|
|
|
|
await runQr(["--setup-code-only"]);
|
|
|
|
expectLoggedLocalSetupCode();
|
|
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not resolve local password SecretRef when auth mode is token", async () => {
|
|
loadConfig.mockReturnValue(
|
|
createLocalGatewayConfigWithAuth({
|
|
mode: "token",
|
|
token: "token-123",
|
|
...createLocalGatewayEnvPasswordRefAuth("MISSING_LOCAL_GATEWAY_PASSWORD"),
|
|
}),
|
|
);
|
|
|
|
await runQr(["--setup-code-only"]);
|
|
|
|
expectLoggedLocalSetupCode();
|
|
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("resolves local password SecretRef when auth mode is inferred", async () => {
|
|
vi.stubEnv("QR_INFERRED_GATEWAY_PASSWORD", "inferred-password");
|
|
loadConfig.mockReturnValue(
|
|
createLocalGatewayConfigWithAuth({
|
|
...createLocalGatewayEnvPasswordRefAuth("QR_INFERRED_GATEWAY_PASSWORD"),
|
|
}),
|
|
);
|
|
|
|
await runQr(["--setup-code-only"]);
|
|
|
|
expectLoggedLocalSetupCode();
|
|
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fails when token and password SecretRefs are both configured with inferred mode", async () => {
|
|
vi.stubEnv("QR_INFERRED_GATEWAY_TOKEN", "inferred-token");
|
|
loadConfig.mockReturnValue({
|
|
secrets: {
|
|
providers: {
|
|
default: { source: "env" },
|
|
},
|
|
},
|
|
gateway: {
|
|
bind: "custom",
|
|
customBindHost: "gateway.local",
|
|
auth: {
|
|
token: { source: "env", provider: "default", id: "QR_INFERRED_GATEWAY_TOKEN" },
|
|
password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" },
|
|
},
|
|
},
|
|
});
|
|
|
|
await expectQrExit(["--setup-code-only"]);
|
|
const output = runtimeError.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
|
|
expect(output).toContain("gateway.auth.mode is unset");
|
|
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("exits with error when gateway config is not pairable", async () => {
|
|
loadConfig.mockReturnValue({
|
|
gateway: {
|
|
bind: "loopback",
|
|
auth: { mode: "token", token: "tok" },
|
|
},
|
|
});
|
|
|
|
await expectQrExit([]);
|
|
|
|
const output = runtime.error.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
|
|
expect(output).toContain("only bound to loopback");
|
|
});
|
|
|
|
it("uses gateway.remote.url when --remote is set (ignores device-pair publicUrl)", async () => {
|
|
loadConfig.mockReturnValue(createRemoteQrConfig());
|
|
await runQr(["--setup-code-only", "--remote"]);
|
|
|
|
const expected = encodePairingSetupCode({
|
|
url: "wss://remote.example.com:444",
|
|
bootstrapToken: "bootstrap-123",
|
|
});
|
|
expect(runtime.log).toHaveBeenCalledWith(expected);
|
|
expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
commandName: "qr --remote",
|
|
targetIds: new Set(["gateway.remote.token", "gateway.remote.password"]),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("logs remote secret diagnostics in non-json output mode", async () => {
|
|
loadConfig.mockReturnValue(createRemoteQrConfig());
|
|
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
|
|
resolvedConfig: createRemoteQrConfig(),
|
|
diagnostics: ["gateway.remote.token inactive"] as string[],
|
|
});
|
|
|
|
await runQr(["--remote"]);
|
|
|
|
expect(
|
|
runtimeLog.mock.calls.some((call) =>
|
|
readRuntimeCallText(call).includes("gateway.remote.token inactive"),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("routes remote secret diagnostics to stderr for setup-code-only output", async () => {
|
|
loadConfig.mockReturnValue(createRemoteQrConfig());
|
|
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
|
|
resolvedConfig: createRemoteQrConfig(),
|
|
diagnostics: ["gateway.remote.token inactive"] as string[],
|
|
});
|
|
|
|
await runQr(["--setup-code-only", "--remote"]);
|
|
|
|
expect(
|
|
runtimeError.mock.calls.some((call) =>
|
|
readRuntimeCallText(call).includes("gateway.remote.token inactive"),
|
|
),
|
|
).toBe(true);
|
|
const expected = encodePairingSetupCode({
|
|
url: "wss://remote.example.com:444",
|
|
bootstrapToken: "bootstrap-123",
|
|
});
|
|
expect(runtime.log).toHaveBeenCalledWith(expected);
|
|
});
|
|
|
|
it.each([
|
|
{ name: "without tailscale configured", withTailscale: false },
|
|
{ name: "when tailscale is configured", withTailscale: true },
|
|
])("reports gateway.remote.url as source in --remote json output ($name)", async (testCase) => {
|
|
loadConfig.mockReturnValue(createRemoteQrConfig({ withTailscale: testCase.withTailscale }));
|
|
mockTailscaleStatusLookup();
|
|
|
|
await runQr(["--json", "--remote"]);
|
|
|
|
const payload = parseLastLoggedQrJson();
|
|
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
|
|
expect(payload.auth).toBe("token");
|
|
expect(payload.urlSource).toBe("gateway.remote.url");
|
|
expect(runCommandWithTimeout).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("routes remote secret diagnostics to stderr for json output", async () => {
|
|
loadConfig.mockReturnValue(createRemoteQrConfig());
|
|
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
|
|
resolvedConfig: createRemoteQrConfig(),
|
|
diagnostics: ["gateway.remote.password inactive"] as string[],
|
|
});
|
|
mockTailscaleStatusLookup();
|
|
|
|
await runQr(["--json", "--remote"]);
|
|
|
|
const payload = parseLastLoggedQrJson();
|
|
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
|
|
expect(
|
|
runtimeError.mock.calls.some((call) =>
|
|
readRuntimeCallText(call).includes("gateway.remote.password inactive"),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("errors when --remote is set but no remote URL is configured", async () => {
|
|
loadConfig.mockReturnValue({
|
|
gateway: {
|
|
bind: "custom",
|
|
customBindHost: "gateway.local",
|
|
auth: { mode: "token", token: "tok" },
|
|
},
|
|
});
|
|
|
|
await expectQrExit(["--remote"]);
|
|
const output = runtimeError.mock.calls.map((call) => readRuntimeCallText(call)).join("\n");
|
|
expect(output).toContain("qr --remote requires");
|
|
expect(resolveCommandSecretRefsViaGateway).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("supports --remote with tailscale serve when remote token ref resolves", async () => {
|
|
loadConfig.mockReturnValue(createTailscaleRemoteRefConfig());
|
|
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
|
|
resolvedConfig: {
|
|
gateway: {
|
|
tailscale: { mode: "serve" },
|
|
remote: {
|
|
token: "tailscale-remote-token",
|
|
},
|
|
auth: {},
|
|
},
|
|
},
|
|
diagnostics: [],
|
|
});
|
|
runCommandWithTimeout.mockResolvedValue({
|
|
code: 0,
|
|
stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}',
|
|
stderr: "",
|
|
});
|
|
|
|
await runQr(["--json", "--remote"]);
|
|
|
|
const payload = parseLastLoggedQrJson();
|
|
expect(payload.gatewayUrl).toBe("wss://ts-host.tailnet.ts.net");
|
|
expect(payload.auth).toBe("token");
|
|
expect(payload.urlSource).toBe("gateway.tailscale.mode=serve");
|
|
});
|
|
});
|