Skip to content

Commit 3225cc2

Browse files
authored
feat(setup): pairing helper to list/approve devices (#83)
1 parent 35c5525 commit 3225cc2

3 files changed

Lines changed: 112 additions & 2 deletions

File tree

src/server.js

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,13 @@ app.get("/setup", requireSetupAuth, (_req, res) => {
465465
<button id="reset" style="background:#444; margin-left:0.5rem">Reset setup</button>
466466
<pre id="log" style="white-space:pre-wrap"></pre>
467467
<p class="muted">Reset deletes the OpenClaw config file so you can rerun onboarding. Pairing approval lets you grant DM access when dmPolicy=pairing.</p>
468+
469+
<details style="margin-top: 0.75rem">
470+
<summary><strong>Pairing helper</strong> (for “disconnected (1008): pairing required”)</summary>
471+
<p class="muted">This lists pending device requests and lets you approve them without SSH.</p>
472+
<button id="devicesRefresh" style="background:#0f172a">Refresh pending devices</button>
473+
<div id="devicesList" class="muted" style="margin-top:0.5rem"></div>
474+
</details>
468475
</div>
469476
470477
<script src="/setup/app.js"></script>
@@ -824,6 +831,16 @@ function redactSecrets(text) {
824831
.replace(/(AA[A-Za-z0-9_-]{10,}:\S{10,})/g, "[REDACTED]");
825832
}
826833

834+
function extractDeviceRequestIds(text) {
835+
const s = String(text || "");
836+
const out = new Set();
837+
838+
for (const m of s.matchAll(/requestId\s*(?:=|:)\s*([A-Za-z0-9_-]{6,})/g)) out.add(m[1]);
839+
for (const m of s.matchAll(/"requestId"\s*:\s*"([A-Za-z0-9_-]{6,})"/g)) out.add(m[1]);
840+
841+
return Array.from(out);
842+
}
843+
827844
const ALLOWED_CONSOLE_COMMANDS = new Set([
828845
// Wrapper-managed lifecycle
829846
"gateway.restart",
@@ -969,12 +986,31 @@ app.post("/setup/api/pairing/approve", requireSetupAuth, async (req, res) => {
969986
return res.status(r.code === 0 ? 200 : 500).json({ ok: r.code === 0, output: r.output });
970987
});
971988

989+
// Device pairing helper (list + approve) to avoid needing SSH.
990+
app.get("/setup/api/devices/pending", requireSetupAuth, async (_req, res) => {
991+
const r = await runCmd(OPENCLAW_NODE, clawArgs(["devices", "list"]));
992+
const output = redactSecrets(r.output);
993+
const requestIds = extractDeviceRequestIds(output);
994+
return res.status(r.code === 0 ? 200 : 500).json({ ok: r.code === 0, requestIds, output });
995+
});
996+
997+
app.post("/setup/api/devices/approve", requireSetupAuth, async (req, res) => {
998+
const requestId = String((req.body && req.body.requestId) || "").trim();
999+
if (!requestId) return res.status(400).json({ ok: false, error: "Missing device request ID" });
1000+
if (!/^[A-Za-z0-9_-]+$/.test(requestId)) return res.status(400).json({ ok: false, error: "Invalid device request ID" });
1001+
const r = await runCmd(OPENCLAW_NODE, clawArgs(["devices", "approve", requestId]));
1002+
return res.status(r.code === 0 ? 200 : 500).json({ ok: r.code === 0, output: redactSecrets(r.output) });
1003+
});
1004+
9721005
app.post("/setup/api/reset", requireSetupAuth, async (_req, res) => {
9731006
// Minimal reset: delete the config file so /setup can rerun.
9741007
// Keep credentials/sessions/workspace by default.
9751008
try {
976-
fs.rmSync(configPath(), { force: true });
977-
res.type("text/plain").send("OK - deleted config file. You can rerun setup now.");
1009+
const candidates = typeof resolveConfigCandidates === "function" ? resolveConfigCandidates() : [configPath()];
1010+
for (const p of candidates) {
1011+
try { fs.rmSync(p, { force: true }); } catch {}
1012+
}
1013+
res.type("text/plain").send("OK - deleted config file(s). You can rerun setup now.");
9781014
} catch (err) {
9791015
res.status(500).type("text/plain").send(String(err));
9801016
}

src/setup-app.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,62 @@
241241
};
242242
}
243243

244+
// Device pairing helper
245+
var devicesRefreshBtn = document.getElementById('devicesRefresh');
246+
var devicesListEl = document.getElementById('devicesList');
247+
248+
function approveDevice(requestId) {
249+
if (!requestId) return;
250+
if (!confirm('Approve device request ' + requestId + '?')) return;
251+
if (devicesListEl) devicesListEl.textContent = 'Approving ' + requestId + '...';
252+
253+
return httpJson('/setup/api/devices/approve', {
254+
method: 'POST',
255+
headers: { 'content-type': 'application/json' },
256+
body: JSON.stringify({ requestId: requestId })
257+
}).then(function (j) {
258+
if (devicesListEl) devicesListEl.textContent = j.output || 'Approved.';
259+
return refreshStatus();
260+
}).catch(function (e) {
261+
if (devicesListEl) devicesListEl.textContent = 'Error: ' + String(e);
262+
});
263+
}
264+
265+
function refreshDevices() {
266+
if (!devicesListEl) return;
267+
devicesListEl.textContent = 'Loading pending devices...';
268+
return httpJson('/setup/api/devices/pending').then(function (j) {
269+
var ids = j.requestIds || [];
270+
if (!ids.length) {
271+
devicesListEl.textContent = 'No pending device requests found.';
272+
return;
273+
}
274+
devicesListEl.innerHTML = '';
275+
for (var i = 0; i < ids.length; i++) {
276+
(function (id) {
277+
var row = document.createElement('div');
278+
row.style.marginTop = '0.25rem';
279+
var btn = document.createElement('button');
280+
btn.textContent = 'Approve ' + id;
281+
btn.style.background = '#111';
282+
btn.style.marginRight = '0.5rem';
283+
btn.onclick = function () { approveDevice(id); };
284+
var code = document.createElement('code');
285+
code.textContent = id;
286+
row.appendChild(btn);
287+
row.appendChild(code);
288+
devicesListEl.appendChild(row);
289+
})(ids[i]);
290+
}
291+
}).catch(function (e) {
292+
devicesListEl.textContent = 'Error: ' + String(e);
293+
});
294+
}
295+
296+
if (devicesRefreshBtn) {
297+
devicesRefreshBtn.onclick = refreshDevices;
298+
}
299+
244300
document.getElementById('reset').onclick = function () {
245301
if (!confirm('Reset setup? This deletes the config file so onboarding can run again.')) return;
246302
logEl.textContent = 'Resetting...\n';
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
4+
function extractDeviceRequestIds(text) {
5+
const s = String(text || "");
6+
const out = new Set();
7+
8+
// Common patterns: requestId=XYZ, requestId: XYZ, "requestId":"XYZ".
9+
for (const m of s.matchAll(/requestId\s*(?:=|:)\s*([A-Za-z0-9_-]{6,})/g)) out.add(m[1]);
10+
for (const m of s.matchAll(/"requestId"\s*:\s*"([A-Za-z0-9_-]{6,})"/g)) out.add(m[1]);
11+
12+
return Array.from(out);
13+
}
14+
15+
test("extractDeviceRequestIds: finds requestId formats", () => {
16+
const sample = `pending:\n- requestId=abc123_DEF\n{"requestId":"REQ_456-xy"}\nrequestId: ZZZ999`;
17+
assert.deepEqual(extractDeviceRequestIds(sample).sort(), ["REQ_456-xy", "ZZZ999", "abc123_DEF"].sort());
18+
});

0 commit comments

Comments
 (0)