From d2e8a493a3af00e49047df683501419d188b93c9 Mon Sep 17 00:00:00 2001 From: OpenClaw Assistant Date: Sun, 15 Mar 2026 08:36:55 +0000 Subject: [PATCH] Handle probe identity fallback and stale mocks --- src/gateway/probe.test.ts | 42 ++++++++++++++++++++++++++++++++++++--- src/gateway/probe.ts | 16 ++++++++++++++- src/tts/tts.test.ts | 22 +++++++++++++------- 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index 4f3628149ba..b5ece53c0d4 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -1,10 +1,15 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const gatewayClientState = vi.hoisted(() => ({ options: null as Record | null, requests: [] as string[], })); +const deviceIdentityState = vi.hoisted(() => ({ + value: { id: "test-device-identity" } as Record, + throwOnLoad: false, +})); + class MockGatewayClient { private readonly opts: Record; @@ -40,9 +45,21 @@ vi.mock("./client.js", () => ({ GatewayClient: MockGatewayClient, })); +vi.mock("../infra/device-identity.js", () => ({ + loadOrCreateDeviceIdentity: () => { + if (deviceIdentityState.throwOnLoad) { + throw new Error("read-only identity dir"); + } + return deviceIdentityState.value; + }, +})); + const { probeGateway } = await import("./probe.js"); describe("probeGateway", () => { + beforeEach(() => { + deviceIdentityState.throwOnLoad = false; + }); it("connects with operator.read scope", async () => { const result = await probeGateway({ url: "ws://127.0.0.1:18789", @@ -51,7 +68,7 @@ describe("probeGateway", () => { }); expect(gatewayClientState.options?.scopes).toEqual(["operator.read"]); - expect(gatewayClientState.options?.deviceIdentity).toBeUndefined(); + expect(gatewayClientState.options?.deviceIdentity).toEqual(deviceIdentityState.value); expect(gatewayClientState.requests).toEqual([ "health", "status", @@ -68,7 +85,7 @@ describe("probeGateway", () => { timeoutMs: 1_000, }); - expect(gatewayClientState.options?.deviceIdentity).toBeUndefined(); + expect(gatewayClientState.options?.deviceIdentity).toEqual(deviceIdentityState.value); }); it("skips detail RPCs for lightweight reachability probes", async () => { @@ -82,4 +99,23 @@ describe("probeGateway", () => { expect(gatewayClientState.options?.deviceIdentity).toBeNull(); expect(gatewayClientState.requests).toEqual([]); }); + + it("falls back to token/password auth when device identity cannot be persisted", async () => { + deviceIdentityState.throwOnLoad = true; + + const result = await probeGateway({ + url: "ws://127.0.0.1:18789", + auth: { token: "secret" }, + timeoutMs: 1_000, + }); + + expect(result.ok).toBe(true); + expect(gatewayClientState.options?.deviceIdentity).toBeNull(); + expect(gatewayClientState.requests).toEqual([ + "health", + "status", + "system-presence", + "config.get", + ]); + }); }); diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index 72d232ddaae..fa4a589ac2d 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { formatErrorMessage } from "../infra/errors.js"; import type { SystemPresence } from "../infra/system-presence.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; @@ -40,6 +41,19 @@ export async function probeGateway(opts: { let connectError: string | null = null; let close: GatewayProbeClose | null = null; + const deviceIdentity = (() => { + if (opts.includeDetails === false) { + return null; + } + try { + return loadOrCreateDeviceIdentity(); + } catch { + // Read-only or restricted environments should still be able to run + // token/password-auth detail probes without crashing on identity persistence. + return null; + } + })(); + return await new Promise((resolve) => { let settled = false; const settle = (result: Omit) => { @@ -61,7 +75,7 @@ export async function probeGateway(opts: { clientVersion: "dev", mode: GATEWAY_CLIENT_MODES.PROBE, instanceId, - deviceIdentity: opts.includeDetails === false ? null : undefined, + deviceIdentity, onConnectError: (err) => { connectError = formatErrorMessage(err); }, diff --git a/src/tts/tts.test.ts b/src/tts/tts.test.ts index b326b4835e5..2b380025390 100644 --- a/src/tts/tts.test.ts +++ b/src/tts/tts.test.ts @@ -2,7 +2,7 @@ import { completeSimple, type AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { ensureCustomApiRegistered } from "../agents/custom-api-registry.js"; import { getApiKeyForModel } from "../agents/model-auth.js"; -import { resolveModel } from "../agents/pi-embedded-runner/model.js"; +import { resolveModelAsync } from "../agents/pi-embedded-runner/model.js"; import type { OpenClawConfig } from "../config/config.js"; import { withEnv } from "../test-utils/env.js"; import * as tts from "./tts.js"; @@ -20,8 +20,8 @@ vi.mock("@mariozechner/pi-ai/oauth", () => ({ getOAuthApiKey: vi.fn(async () => null), })); -vi.mock("../agents/pi-embedded-runner/model.js", () => ({ - resolveModel: vi.fn((provider: string, modelId: string) => ({ +vi.mock("../agents/pi-embedded-runner/model.js", () => { + const buildResolvedModel = (provider: string, modelId: string) => ({ model: { provider, id: modelId, @@ -35,8 +35,16 @@ vi.mock("../agents/pi-embedded-runner/model.js", () => ({ }, authStorage: { profiles: {} }, modelRegistry: { find: vi.fn() }, - })), -})); + }); + return { + resolveModel: vi.fn((provider: string, modelId: string) => + buildResolvedModel(provider, modelId), + ), + resolveModelAsync: vi.fn(async (provider: string, modelId: string) => + buildResolvedModel(provider, modelId), + ), + }; +}); vi.mock("../agents/model-auth.js", () => ({ getApiKeyForModel: vi.fn(async () => ({ @@ -411,11 +419,11 @@ describe("tts", () => { timeoutMs: 30_000, }); - expect(resolveModel).toHaveBeenCalledWith("openai", "gpt-4.1-mini", undefined, cfg); + expect(resolveModelAsync).toHaveBeenCalledWith("openai", "gpt-4.1-mini", undefined, cfg); }); it("registers the Ollama api before direct summarization", async () => { - vi.mocked(resolveModel).mockReturnValue({ + vi.mocked(resolveModelAsync).mockResolvedValue({ model: { provider: "ollama", id: "qwen3:8b",