diff --git a/src/app/api/runtimes/probe/route.test.ts b/src/app/api/runtimes/probe/route.test.ts index 99b901d5..66f72af0 100644 --- a/src/app/api/runtimes/probe/route.test.ts +++ b/src/app/api/runtimes/probe/route.test.ts @@ -11,6 +11,12 @@ vi.mock("@/lib/require-auth", () => ({ vi.mock("@/lib/gateway-client", () => ({ probeGateway: (...args: unknown[]) => mockProbeGateway(...args), + resolveDeviceIdentity: vi.fn(() => ({ + deviceId: "device-1", + publicKeyRawBase64Url: "public-key", + privateKeyPem: "private-key", + source: "configured", + })), })); vi.mock("@/lib/openclaw-config-parser", () => ({ @@ -30,6 +36,7 @@ describe("POST /api/runtimes/probe", () => { beforeEach(() => { vi.clearAllMocks(); vi.unstubAllGlobals(); + vi.stubEnv("AUTH_SECRET", "runtime-probe-device-auth-test-secret"); }); it("probes Hermes runtimes through the HTTP API", async () => { @@ -103,4 +110,68 @@ describe("POST /api/runtimes/probe", () => { expect(response.status).toBe(200); expect(mockProbeGateway).toHaveBeenCalledWith("ws://localhost:18789", "openclaw-token", undefined); }); + + it("seals issued device credentials before returning probe data to the browser", async () => { + mockProbeGateway.mockResolvedValue({ + ok: true, + version: "2026.7.1", + agents: [], + models: [], + devicePrivateKeyPem: "plaintext-private-key", + deviceAuth: { + token: "plaintext-device-token", + role: "operator", + scopes: ["operator.read", "operator.write"], + }, + }); + + const response = await POST(makeRequest({ + mode: "gateway", + url: "localhost:18789", + token: "openclaw-token", + })); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload).not.toHaveProperty("deviceAuth"); + expect(payload.devicePrivateKeyPem).toMatch(/^crewcmd:runtime-token:v1:/); + expect(payload.runtimeAuthMetadata).toMatchObject({ + devicePrivateKeyPem: payload.devicePrivateKeyPem, + openclawDeviceAuth: { + version: 1, + deviceId: "device-1", + role: "operator", + scopes: ["operator.read", "operator.write"], + }, + }); + expect(JSON.stringify(payload)).not.toContain("plaintext-private-key"); + expect(JSON.stringify(payload)).not.toContain("plaintext-device-token"); + }); + + it("seals a pending pairing identity so retries use the same device safely", async () => { + mockProbeGateway.mockResolvedValue({ + ok: false, + error: "pairing_required", + pairingInstructions: "Approve this device", + agents: [], + models: [], + devicePrivateKeyPem: "plaintext-private-key", + }); + + const response = await POST(makeRequest({ + mode: "gateway", + url: "localhost:18789", + token: "openclaw-token", + })); + const payload = await response.json(); + + expect(response.status).toBe(200); + expect(payload).toMatchObject({ + ok: false, + pairingRequired: true, + pairingInstructions: "Approve this device", + }); + expect(payload.devicePrivateKeyPem).toMatch(/^crewcmd:runtime-token:v1:/); + expect(JSON.stringify(payload)).not.toContain("plaintext-private-key"); + }); }); diff --git a/src/app/api/runtimes/probe/route.ts b/src/app/api/runtimes/probe/route.ts index 75a45d97..e1398157 100644 --- a/src/app/api/runtimes/probe/route.ts +++ b/src/app/api/runtimes/probe/route.ts @@ -1,8 +1,42 @@ import { NextRequest, NextResponse } from "next/server"; -import { probeGateway } from "@/lib/gateway-client"; +import { probeGateway, resolveDeviceIdentity, type ProbeResult } from "@/lib/gateway-client"; import { parseOpenClawConfig } from "@/lib/openclaw-config-parser"; import { requireAuth } from "@/lib/require-auth"; import { getRuntimeProvider } from "@/lib/runtimes/providers"; +import { + sealRuntimeDevicePrivateKey, + storeRuntimeDeviceAuth, +} from "@/lib/runtime-device-auth"; + +type SealedGatewayProbeResult = Omit & { + runtimeAuthMetadata?: Record; +}; + +function sealGatewayProbeResult(result: ProbeResult): SealedGatewayProbeResult { + const { deviceAuth, devicePrivateKeyPem, ...safeResult } = result; + if (!devicePrivateKeyPem) return safeResult; + + const device = resolveDeviceIdentity(devicePrivateKeyPem); + const sealedDevicePrivateKey = sealRuntimeDevicePrivateKey(devicePrivateKeyPem); + let runtimeAuthMetadata: Record = { + devicePrivateKeyPem: sealedDevicePrivateKey, + }; + if (deviceAuth) { + runtimeAuthMetadata = storeRuntimeDeviceAuth( + runtimeAuthMetadata, + device.deviceId, + deviceAuth, + ); + } + + return { + ...safeResult, + // This opaque ciphertext is returned only so pairing retries can retain + // the same identity. The browser never receives the private key itself. + devicePrivateKeyPem: sealedDevicePrivateKey, + runtimeAuthMetadata, + }; +} /** * POST /api/runtimes/probe @@ -95,11 +129,12 @@ export async function POST(request: NextRequest) { if (!result.ok) { // Special case: pairing required — return 200 with status so UI can show approval instructions if (result.error === "pairing_required") { + const sealedResult = sealGatewayProbeResult(result); return NextResponse.json({ ok: false, pairingRequired: true, pairingInstructions: result.pairingInstructions, - devicePrivateKeyPem: result.devicePrivateKeyPem, + devicePrivateKeyPem: sealedResult.devicePrivateKeyPem, }); } @@ -109,7 +144,7 @@ export async function POST(request: NextRequest) { ); } - return NextResponse.json(result); + return NextResponse.json(sealGatewayProbeResult(result)); } // ── Local config file mode (same-machine fallback) ──