fix: quote doctor pairing repair commands (#69210)

This commit is contained in:
Ayaan Zaidi
2026-04-20 11:32:47 +05:30
parent b36d688f78
commit a4130ae8ed
2 changed files with 79 additions and 9 deletions

View File

@@ -216,6 +216,53 @@ describe("noteDevicePairingHealth", () => {
expect(message).not.toContain("control-ui\tclient");
});
it("quotes untrusted device pairing fields in suggested commands", async () => {
callGatewayMock.mockResolvedValue({
pending: [
{
requestId: "req-gateway-1",
deviceId: "device; echo pwn",
publicKey: "pending-pubkey",
role: "operator",
roles: ["operator"],
scopes: ["operator.read"],
clientId: "control-ui",
clientMode: "webchat",
displayName: "Dashboard",
ts: 1,
isRepair: true,
},
],
paired: [
{
deviceId: "device; echo pwn",
publicKey: "paired-pubkey",
displayName: "Dashboard",
clientId: "control-ui",
clientMode: "webchat",
role: "operator; touch /tmp/pwn",
roles: ["operator; touch /tmp/pwn"],
scopes: [],
approvedScopes: [],
tokens: [],
createdAtMs: 1,
approvedAtMs: 1,
},
],
});
await noteDevicePairingHealth({
cfg: { gateway: { mode: "remote" } },
healthOk: true,
});
const message = String(noteMock.mock.calls[0]?.[0] ?? "");
expect(message).toContain("openclaw devices remove 'device; echo pwn'");
expect(message).toContain(
"openclaw devices rotate --device 'device; echo pwn' --role 'operator; touch /tmp/pwn'",
);
});
it("does not duplicate missing-token warnings when local cache exists for an approved role", async () => {
await withTempDir("openclaw-doctor-device-pairing-", async (stateDir) => {
await withEnvAsync(

View File

@@ -184,6 +184,17 @@ function formatRoles(roles: string[]): string {
return roles.length > 0 ? roles.join(", ") : "none";
}
function quoteCliArg(value: string): string {
if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) {
return value;
}
return `'${value.replaceAll("'", "'\\''")}'`;
}
function formatCliArgs(args: string[]): string {
return formatCliCommand(args.map(quoteCliArg).join(" "));
}
function describeDevice(params: {
deviceId: string;
displayName?: string;
@@ -241,8 +252,8 @@ function resolvePendingPairingIssue(
displayName: pending.displayName,
clientId: pending.clientId,
});
const approveCommand = formatCliCommand(`openclaw devices approve ${pending.requestId}`);
const inspectCommand = formatCliCommand("openclaw devices list");
const approveCommand = formatCliArgs(["openclaw", "devices", "approve", pending.requestId]);
const inspectCommand = formatCliArgs(["openclaw", "devices", "list"]);
if (!paired) {
return {
kind: "first-time",
@@ -259,7 +270,7 @@ function resolvePendingPairingIssue(
deviceLabel,
approveCommand,
inspectCommand,
removeCommand: formatCliCommand(`openclaw devices remove ${pending.deviceId}`),
removeCommand: formatCliArgs(["openclaw", "devices", "remove", pending.deviceId]),
};
}
const requestedRoles = uniqueStrings(pending.roles, pending.role);
@@ -346,9 +357,15 @@ function collectPairedRecordIssues(snapshot: DoctorPairingSnapshot): string[] {
}
for (const role of approvedRoles) {
const token = findTokenSummary(device, role);
const rotateCommand = formatCliCommand(
`openclaw devices rotate --device ${device.deviceId} --role ${role}`,
);
const rotateCommand = formatCliArgs([
"openclaw",
"devices",
"rotate",
"--device",
device.deviceId,
"--role",
role,
]);
if (!token) {
lines.push(
`- Paired device ${deviceLabel} has no active ${role} device token even though the role is approved. This commonly ends in pairing-required or device-token-mismatch. Rotate a fresh token with ${rotateCommand}.`,
@@ -456,9 +473,15 @@ function collectLocalDeviceAuthIssues(snapshot: DoctorPairingSnapshot): string[]
if (!role) {
continue;
}
const rotateCommand = formatCliCommand(
`openclaw devices rotate --device ${paired.deviceId} --role ${role}`,
);
const rotateCommand = formatCliArgs([
"openclaw",
"devices",
"rotate",
"--device",
paired.deviceId,
"--role",
role,
]);
const pairedToken = findTokenSummary(paired, role);
if (!pairedToken) {
if (approvedRoles.has(role)) {