Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions src/app/api/runtimes/probe/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => ({
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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");
});
});
41 changes: 38 additions & 3 deletions src/app/api/runtimes/probe/route.ts
Original file line number Diff line number Diff line change
@@ -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<ProbeResult, "deviceAuth"> & {
runtimeAuthMetadata?: Record<string, unknown>;
};

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<string, unknown> = {
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
Expand Down Expand Up @@ -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,
});
}

Expand All @@ -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) ──
Expand Down
Loading