From df0ee092f01766ef254de6f536899f105322d00f Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Thu, 30 Apr 2026 21:46:22 -0500 Subject: [PATCH] fix: harden gateway recovery diagnostics and media delivery Harden gateway recovery diagnostics and media delivery.\n\n- Accept gateway send asVoice and map it to outbound audioAsVoice.\n- Preserve generated Swift protocol models for the gateway send schema.\n- Keep the broader recovery hardening for install/update/status/vector/TTS paths in one reviewed PR.\n\nProof:\n- Focused local gateway/outbound/update/status/doctor/sqlite-vec tests passed.\n- oxfmt --check and git diff --check passed.\n- Testbox OPENCLAW_TESTBOX=1 pnpm check:changed passed at 2f5ef650e97763a61ff43c28e61707db84c50060.\n- GitHub required checks are green at the merge SHA; the qa-lab parity gate is optional/surface-only and was still pending. --- .../OpenClawProtocol/GatewayModels.swift | 4 + .../OpenClawProtocol/GatewayModels.swift | 4 + .../src/host/sqlite-vec.test.ts | 25 ++++ .../memory-host-sdk/src/host/sqlite-vec.ts | 11 +- src/cli/update-cli/restart-helper.test.ts | 39 +++++ src/cli/update-cli/restart-helper.ts | 26 +++- src/commands/doctor-gateway-services.test.ts | 35 +++++ src/commands/doctor-gateway-services.ts | 11 ++ .../onboard-non-interactive.gateway.test.ts | 29 ++++ .../onboard-non-interactive/local/output.ts | 82 +++++++++- src/commands/status.daemon.test.ts | 48 ++++++ src/commands/status.daemon.ts | 2 + src/commands/status.service-summary.test.ts | 46 ++++++ src/commands/status.service-summary.ts | 7 + src/daemon/service-layout.ts | 141 ++++++++++++++++++ src/gateway/protocol/schema/agent.ts | 1 + src/gateway/server-methods/send.test.ts | 31 ++++ src/gateway/server-methods/send.ts | 10 +- .../message-action-runner.media.test.ts | 30 ++++ src/infra/outbound/message-action-runner.ts | 6 + src/infra/outbound/message.test.ts | 23 +++ src/infra/outbound/message.ts | 3 + src/infra/outbound/outbound-send-service.ts | 2 + src/infra/update-global.test.ts | 20 +++ src/infra/update-global.ts | 13 ++ src/tasks/task-boundaries.test.ts | 9 ++ src/tasks/task-registry.maintenance.ts | 4 +- 27 files changed, 647 insertions(+), 15 deletions(-) create mode 100644 packages/memory-host-sdk/src/host/sqlite-vec.test.ts create mode 100644 src/commands/status.daemon.test.ts create mode 100644 src/daemon/service-layout.ts diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index fdfc4d4d707..9ac02c024ad 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -473,6 +473,7 @@ public struct SendParams: Codable, Sendable { public let message: String? public let mediaurl: String? public let mediaurls: [String]? + public let asvoice: Bool? public let gifplayback: Bool? public let channel: String? public let accountid: String? @@ -487,6 +488,7 @@ public struct SendParams: Codable, Sendable { message: String?, mediaurl: String?, mediaurls: [String]?, + asvoice: Bool?, gifplayback: Bool?, channel: String?, accountid: String?, @@ -500,6 +502,7 @@ public struct SendParams: Codable, Sendable { self.message = message self.mediaurl = mediaurl self.mediaurls = mediaurls + self.asvoice = asvoice self.gifplayback = gifplayback self.channel = channel self.accountid = accountid @@ -515,6 +518,7 @@ public struct SendParams: Codable, Sendable { case message case mediaurl = "mediaUrl" case mediaurls = "mediaUrls" + case asvoice = "asVoice" case gifplayback = "gifPlayback" case channel case accountid = "accountId" diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index fdfc4d4d707..9ac02c024ad 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -473,6 +473,7 @@ public struct SendParams: Codable, Sendable { public let message: String? public let mediaurl: String? public let mediaurls: [String]? + public let asvoice: Bool? public let gifplayback: Bool? public let channel: String? public let accountid: String? @@ -487,6 +488,7 @@ public struct SendParams: Codable, Sendable { message: String?, mediaurl: String?, mediaurls: [String]?, + asvoice: Bool?, gifplayback: Bool?, channel: String?, accountid: String?, @@ -500,6 +502,7 @@ public struct SendParams: Codable, Sendable { self.message = message self.mediaurl = mediaurl self.mediaurls = mediaurls + self.asvoice = asvoice self.gifplayback = gifplayback self.channel = channel self.accountid = accountid @@ -515,6 +518,7 @@ public struct SendParams: Codable, Sendable { case message case mediaurl = "mediaUrl" case mediaurls = "mediaUrls" + case asvoice = "asVoice" case gifplayback = "gifPlayback" case channel case accountid = "accountId" diff --git a/packages/memory-host-sdk/src/host/sqlite-vec.test.ts b/packages/memory-host-sdk/src/host/sqlite-vec.test.ts new file mode 100644 index 00000000000..07e3942b3c4 --- /dev/null +++ b/packages/memory-host-sdk/src/host/sqlite-vec.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("sqlite-vec", () => { + throw new Error("bundled sqlite-vec should not load when extensionPath is explicit"); +}); + +import { loadSqliteVecExtension } from "./sqlite-vec.js"; + +describe("loadSqliteVecExtension", () => { + it("loads explicit extensionPath without importing bundled sqlite-vec", async () => { + const db = { + enableLoadExtension: vi.fn(), + loadExtension: vi.fn(), + }; + + await expect( + loadSqliteVecExtension({ + db: db as never, + extensionPath: "/opt/openclaw/sqlite-vec.so", + }), + ).resolves.toEqual({ ok: true, extensionPath: "/opt/openclaw/sqlite-vec.so" }); + expect(db.enableLoadExtension).toHaveBeenCalledWith(true); + expect(db.loadExtension).toHaveBeenCalledWith("/opt/openclaw/sqlite-vec.so"); + }); +}); diff --git a/packages/memory-host-sdk/src/host/sqlite-vec.ts b/packages/memory-host-sdk/src/host/sqlite-vec.ts index 6a835a01814..61268647b51 100644 --- a/packages/memory-host-sdk/src/host/sqlite-vec.ts +++ b/packages/memory-host-sdk/src/host/sqlite-vec.ts @@ -18,17 +18,16 @@ export async function loadSqliteVecExtension(params: { extensionPath?: string; }): Promise<{ ok: boolean; extensionPath?: string; error?: string }> { try { - const sqliteVec = await loadSqliteVecModule(); const resolvedPath = normalizeOptionalString(params.extensionPath); - const extensionPath = resolvedPath ?? sqliteVec.getLoadablePath(); - params.db.enableLoadExtension(true); if (resolvedPath) { - params.db.loadExtension(extensionPath); - } else { - sqliteVec.load(params.db); + params.db.loadExtension(resolvedPath); + return { ok: true, extensionPath: resolvedPath }; } + const sqliteVec = await loadSqliteVecModule(); + const extensionPath = sqliteVec.getLoadablePath(); + sqliteVec.load(params.db); return { ok: true, extensionPath }; } catch (err) { const message = formatErrorMessage(err); diff --git a/src/cli/update-cli/restart-helper.test.ts b/src/cli/update-cli/restart-helper.test.ts index a35d78995cf..77f6b6b605d 100644 --- a/src/cli/update-cli/restart-helper.test.ts +++ b/src/cli/update-cli/restart-helper.test.ts @@ -142,6 +142,45 @@ exit 0 await cleanupScript(scriptPath); }); + it("fails with sudo systemd guidance when the gateway unit is system-scoped", async () => { + Object.defineProperty(process, "platform", { value: "linux" }); + const tmpDir = await makeTempDir("openclaw-restart-helper-"); + const fakeBinDir = path.join(tmpDir, "bin"); + const callsPath = path.join(tmpDir, "systemctl-calls.log"); + await fs.mkdir(fakeBinDir, { recursive: true }); + await fs.writeFile( + path.join(fakeBinDir, "systemctl"), + `#!/bin/sh +printf '%s\\n' "$*" >> "$OPENCLAW_SYSTEMCTL_CALLS" +if [ "$1" = "--user" ] && [ "$2" = "is-active" ]; then exit 3; fi +if [ "$1" = "--user" ] && [ "$2" = "is-enabled" ]; then exit 1; fi +if [ "$1" = "is-active" ] && [ "$2" = "--quiet" ]; then exit 0; fi +if [ "$1" = "is-enabled" ] && [ "$2" = "--quiet" ]; then exit 0; fi +if [ "$1" = "--user" ] && [ "$2" = "restart" ]; then exit 99; fi +exit 1 +`, + { mode: 0o755 }, + ); + + const { scriptPath } = await prepareAndReadScript({ + OPENCLAW_PROFILE: "default", + HOME: path.join(tmpDir, "home"), + OPENCLAW_STATE_DIR: path.join(tmpDir, "state"), + }); + const result = await executeScript(scriptPath, { + PATH: `${fakeBinDir}:${process.env.PATH ?? ""}`, + OPENCLAW_SYSTEMCTL_CALLS: callsPath, + }); + const calls = await fs.readFile(callsPath, "utf-8"); + + expect(result.code).toBe(78); + expect(result.stderr).toContain("system-scoped openclaw gateway unit detected"); + expect(result.stderr).toContain("sudo systemctl restart openclaw-gateway.service"); + expect(calls).toContain("--user is-active --quiet openclaw-gateway.service"); + expect(calls).toContain("is-active --quiet openclaw-gateway.service"); + expect(calls).not.toContain("--user restart openclaw-gateway.service"); + }); + it("creates a launchd restart script on macOS", async () => { Object.defineProperty(process, "platform", { value: "darwin" }); process.getuid = () => 501; diff --git a/src/cli/update-cli/restart-helper.ts b/src/cli/update-cli/restart-helper.ts index 82958898d3e..c6370122214 100644 --- a/src/cli/update-cli/restart-helper.ts +++ b/src/cli/update-cli/restart-helper.ts @@ -85,16 +85,32 @@ export async function prepareRestartScript( # Standalone restart script — survives parent process termination. # Wait briefly to ensure file locks are released after update. sleep 1 +exec 3>&2 ${logSetup} printf '[%s] openclaw restart attempt source=update target=%s\\n' "$(date -u +%FT%TZ)" '${escaped}' >&2 -if systemctl --user restart '${escaped}'; then - status=0 - printf '[%s] openclaw restart done source=update\\n' "$(date -u +%FT%TZ)" >&2 +if systemctl --user is-active --quiet '${escaped}' || systemctl --user is-enabled --quiet '${escaped}'; then + if systemctl --user restart '${escaped}'; then + status=0 + printf '[%s] openclaw restart done source=update\\n' "$(date -u +%FT%TZ)" >&2 + else + status=$? + printf '[%s] openclaw restart failed source=update status=%s\\n' "$(date -u +%FT%TZ)" "$status" >&2 + fi +elif systemctl is-active --quiet '${escaped}' || systemctl is-enabled --quiet '${escaped}'; then + status=78 + printf '[%s] system-scoped openclaw gateway unit detected; update cannot restart it without sudo. Run: sudo systemctl restart %s\\n' "$(date -u +%FT%TZ)" '${escaped}' >&2 + printf '[%s] system-scoped openclaw gateway unit detected; update cannot restart it without sudo. Run: sudo systemctl restart %s\\n' "$(date -u +%FT%TZ)" '${escaped}' >&3 2>/dev/null || true else - status=$? - printf '[%s] openclaw restart failed source=update status=%s\\n' "$(date -u +%FT%TZ)" "$status" >&2 + if systemctl --user restart '${escaped}'; then + status=0 + printf '[%s] openclaw restart done source=update\\n' "$(date -u +%FT%TZ)" >&2 + else + status=$? + printf '[%s] openclaw restart failed source=update status=%s\\n' "$(date -u +%FT%TZ)" "$status" >&2 + fi fi # Self-cleanup +exec 3>&- rm -f "$0" exit "$status" `; diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index 73f83392f7b..556a6f8aabc 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -1,3 +1,6 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { withEnvAsync } from "../test-utils/env.js"; @@ -901,6 +904,38 @@ describe("maybeRepairGatewayServiceConfig", () => { expect(mocks.install).not.toHaveBeenCalled(); }); }); + + it("warns when the gateway service entrypoint resolves to a source checkout", async () => { + await withEnvAsync({}, async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-doctor-service-layout-")); + try { + await fs.mkdir(path.join(root, ".git"), { recursive: true }); + await fs.mkdir(path.join(root, "src"), { recursive: true }); + await fs.mkdir(path.join(root, "extensions"), { recursive: true }); + await fs.mkdir(path.join(root, "dist"), { recursive: true }); + await fs.writeFile( + path.join(root, "package.json"), + JSON.stringify({ name: "openclaw", version: "0.0.0-test" }), + "utf8", + ); + const entrypoint = path.join(root, "dist", "index.js"); + await fs.writeFile(entrypoint, "export {};\n", "utf8"); + mocks.readCommand.mockResolvedValue(createGatewayCommand(entrypoint)); + mocks.auditGatewayServiceConfig.mockResolvedValue({ ok: true, issues: [] }); + mocks.buildGatewayInstallPlan.mockResolvedValue(createGatewayCommand(entrypoint)); + + await runRepair({ gateway: {} }); + + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining("resolves to a source checkout"), + "Gateway service config", + ); + expect(mocks.install).not.toHaveBeenCalled(); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + }); }); describe("maybeScanExtraGatewayServices", () => { diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index ba0223c9371..7ff29877cce 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -19,6 +19,7 @@ import { readEmbeddedGatewayToken, SERVICE_AUDIT_CODES, } from "../daemon/service-audit.js"; +import { summarizeGatewayServiceLayout } from "../daemon/service-layout.js"; import { readManagedServiceEnvKeysFromEnvironment } from "../daemon/service-managed-env.js"; import { resolveGatewayService, type GatewayServiceCommandConfig } from "../daemon/service.js"; import { @@ -367,6 +368,16 @@ export async function maybeRepairGatewayServiceConfig( if (serviceWrapperPath) { note(`Gateway service invokes ${OPENCLAW_WRAPPER_ENV_KEY}: ${serviceWrapperPath}`, "Gateway"); } + const serviceLayout = await summarizeGatewayServiceLayout(command); + if (serviceLayout?.entrypointSourceCheckout) { + note( + [ + `Gateway service entrypoint resolves to a source checkout: ${serviceLayout.packageRootReal ?? serviceLayout.packageRoot ?? serviceLayout.entrypointReal ?? serviceLayout.entrypoint}.`, + "Run `openclaw doctor --fix` from the intended package install, or reinstall the gateway service with `openclaw gateway install --force`.", + ].join("\n"), + "Gateway service config", + ); + } const tokenRefConfigured = Boolean( resolveSecretInputRef({ diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index 68f39dca53a..75e05aa9d91 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -590,6 +590,35 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }); }, 60_000); + it("classifies daemon health ECONNREFUSED failures with a recovery command", async () => { + await withStateDir("state-local-daemon-health-refused-", async (stateDir) => { + waitForGatewayReachableMock = vi.fn(async () => ({ + ok: false, + detail: "connect ECONNREFUSED 127.0.0.1:18789", + })); + gatewayServiceMock.readRuntime.mockResolvedValueOnce({ + status: "stopped", + state: "failed", + pid: 0, + }); + readLastGatewayErrorLineMock.mockResolvedValueOnce(""); + + const { runtimeWithCapture, readCapturedJson } = createJsonCaptureRuntime(); + await expectLocalJsonSetupFailure(stateDir, runtimeWithCapture); + + const parsed = JSON.parse(readCapturedJson()) as { + ok: boolean; + phase: string; + classification?: string; + hints?: string[]; + }; + expect(parsed.ok).toBe(false); + expect(parsed.phase).toBe("gateway-health"); + expect(parsed.classification).toBe("service-stopped"); + expect(parsed.hints).toContain("Fix: run `openclaw gateway restart`."); + }); + }, 60_000); + it("auto-generates token auth when binding LAN and persists the token", async () => { if (process.platform === "win32") { // Windows runner occasionally drops the temp config write in this flow; skip to keep CI green. diff --git a/src/commands/onboard-non-interactive/local/output.ts b/src/commands/onboard-non-interactive/local/output.ts index ef8af8e01ad..bd79110abe6 100644 --- a/src/commands/onboard-non-interactive/local/output.ts +++ b/src/commands/onboard-non-interactive/local/output.ts @@ -16,6 +16,14 @@ export type GatewayHealthFailureDiagnostics = { inspectError?: string; }; +export type GatewayHealthFailureClassification = + | "not-listening" + | "auth-mismatch" + | "service-missing" + | "service-stopped" + | "startup-blocked" + | "runtime-deps-broken"; + export function logNonInteractiveOnboardingJson(params: { opts: OnboardOptions; runtime: RuntimeEnv; @@ -78,6 +86,71 @@ function formatGatewayRuntimeSummary( return parts.join(", "); } +function hasConnectionRefusedDetail(detail: string): boolean { + return /\b(?:econnrefused|connection refused|connect refused)\b/i.test(detail); +} + +function classifyGatewayHealthFailure(params: { + detail?: string; + diagnostics?: GatewayHealthFailureDiagnostics; +}): GatewayHealthFailureClassification | undefined { + const detail = params.detail ?? ""; + const lastGatewayError = params.diagnostics?.lastGatewayError ?? ""; + const combined = `${detail}\n${lastGatewayError}`; + if ( + /\b(?:unauthorized|forbidden|invalid token|invalid password|auth mismatch)\b/i.test(combined) + ) { + return "auth-mismatch"; + } + if ( + /\b(?:runtime[- ]deps?|runtime dependencies|cannot find module|sqlite-vec|loadextension)\b/i.test( + combined, + ) + ) { + return "runtime-deps-broken"; + } + if (params.diagnostics?.service?.loaded === false && hasConnectionRefusedDetail(detail)) { + return "service-missing"; + } + const runtimeStatus = params.diagnostics?.service?.runtimeStatus; + if ( + runtimeStatus && + runtimeStatus !== "running" && + runtimeStatus !== "active" && + hasConnectionRefusedDetail(detail) + ) { + return "service-stopped"; + } + if (lastGatewayError.trim()) { + return "startup-blocked"; + } + if (hasConnectionRefusedDetail(detail)) { + return "not-listening"; + } + return undefined; +} + +function recoveryHintForGatewayHealthFailure( + classification: GatewayHealthFailureClassification | undefined, +): string | undefined { + switch (classification) { + case "auth-mismatch": + return "Fix: run `openclaw doctor --fix`."; + case "runtime-deps-broken": + return "Fix: run `openclaw doctor --fix`."; + case "service-missing": + return "Fix: run `openclaw gateway install --force`."; + case "service-stopped": + return "Fix: run `openclaw gateway restart`."; + case "startup-blocked": + return "Fix: run `openclaw gateway status --deep`."; + case "not-listening": + return "Fix: start `openclaw gateway run`, or run `openclaw gateway restart` for a managed gateway."; + default: + return undefined; + } +} + export function logNonInteractiveOnboardingFailure(params: { opts: OnboardOptions; runtime: RuntimeEnv; @@ -99,7 +172,12 @@ export function logNonInteractiveOnboardingFailure(params: { daemonRuntime?: string; diagnostics?: GatewayHealthFailureDiagnostics; }) { - const hints = params.hints?.filter(Boolean) ?? []; + const classification = classifyGatewayHealthFailure({ + detail: params.detail, + diagnostics: params.diagnostics, + }); + const recoveryHint = recoveryHintForGatewayHealthFailure(classification); + const hints = [...(recoveryHint ? [recoveryHint] : []), ...(params.hints?.filter(Boolean) ?? [])]; const gatewayRuntime = formatGatewayRuntimeSummary(params.diagnostics); if (params.opts.json) { @@ -108,6 +186,7 @@ export function logNonInteractiveOnboardingFailure(params: { mode: params.mode, phase: params.phase, message: params.message, + classification, detail: params.detail, gateway: params.gateway, installDaemon: Boolean(params.installDaemon), @@ -121,6 +200,7 @@ export function logNonInteractiveOnboardingFailure(params: { const lines = [ params.message, + classification ? `Classification: ${classification}` : undefined, params.detail ? `Last probe: ${params.detail}` : undefined, params.diagnostics?.service ? `Service: ${params.diagnostics.service.label} (${params.diagnostics.service.loaded ? params.diagnostics.service.loadedText : "not loaded"})` diff --git a/src/commands/status.daemon.test.ts b/src/commands/status.daemon.test.ts new file mode 100644 index 00000000000..4e2daf1ac9c --- /dev/null +++ b/src/commands/status.daemon.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; +import { getDaemonStatusSummary } from "./status.daemon.js"; + +const mocks = vi.hoisted(() => ({ + readServiceStatusSummary: vi.fn(), + resolveGatewayService: vi.fn(() => ({ kind: "gateway" })), + resolveNodeService: vi.fn(() => ({ kind: "node" })), +})); + +vi.mock("./status.service-summary.js", () => ({ + readServiceStatusSummary: mocks.readServiceStatusSummary, +})); + +vi.mock("../daemon/service.js", () => ({ + resolveGatewayService: mocks.resolveGatewayService, +})); + +vi.mock("../daemon/node-service.js", () => ({ + resolveNodeService: mocks.resolveNodeService, +})); + +describe("status daemon summary", () => { + it("preserves service layout diagnostics for status output", async () => { + mocks.readServiceStatusSummary.mockResolvedValueOnce({ + label: "systemd", + installed: true, + loaded: true, + managedByOpenClaw: true, + externallyManaged: false, + loadedText: "enabled", + runtime: { status: "running", pid: 1234 }, + layout: { + execStart: "/usr/bin/node /opt/openclaw/dist/entry.js gateway", + sourceScope: "system", + entrypointSourceCheckout: false, + }, + }); + + await expect(getDaemonStatusSummary()).resolves.toMatchObject({ + runtimeShort: expect.stringContaining("running"), + layout: { + execStart: "/usr/bin/node /opt/openclaw/dist/entry.js gateway", + sourceScope: "system", + entrypointSourceCheckout: false, + }, + }); + }); +}); diff --git a/src/commands/status.daemon.ts b/src/commands/status.daemon.ts index 54c72914933..7a55cb12158 100644 --- a/src/commands/status.daemon.ts +++ b/src/commands/status.daemon.ts @@ -12,6 +12,7 @@ type DaemonStatusSummary = { loadedText: string; runtime: Awaited>["runtime"]; runtimeShort: string | null; + layout: Awaited>["layout"]; }; async function buildDaemonStatusSummary( @@ -29,6 +30,7 @@ async function buildDaemonStatusSummary( loadedText: summary.loadedText, runtime: summary.runtime, runtimeShort: formatDaemonRuntimeShort(summary.runtime), + layout: summary.layout, }; } diff --git a/src/commands/status.service-summary.test.ts b/src/commands/status.service-summary.test.ts index 0b65ccd4c00..7dfd8fa7056 100644 --- a/src/commands/status.service-summary.test.ts +++ b/src/commands/status.service-summary.test.ts @@ -1,7 +1,10 @@ +import fs from "node:fs/promises"; +import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import type { GatewayService } from "../daemon/service.js"; import type { GatewayServiceEnvArgs } from "../daemon/service.js"; import { createMockGatewayService } from "../daemon/service.test-helpers.js"; +import { withTempDir } from "../test-helpers/temp-dir.js"; import { readServiceStatusSummary } from "./status.service-summary.js"; function createService(overrides: Partial): GatewayService { @@ -89,4 +92,47 @@ describe("readServiceStatusSummary", () => { expect(summary.loaded).toBe(true); expect(summary.runtime).toMatchObject({ status: "running" }); }); + + it("includes service layout diagnostics and flags source checkout entrypoints", async () => { + await withTempDir({ prefix: "openclaw-status-service-layout-" }, async (root) => { + await fs.mkdir(path.join(root, ".git"), { recursive: true }); + await fs.mkdir(path.join(root, "src"), { recursive: true }); + await fs.mkdir(path.join(root, "extensions"), { recursive: true }); + await fs.mkdir(path.join(root, "dist"), { recursive: true }); + await fs.writeFile( + path.join(root, "package.json"), + JSON.stringify({ name: "openclaw", version: "0.0.0-test" }), + "utf8", + ); + const entrypoint = path.join(root, "dist", "index.js"); + const serviceFile = path.join(root, "openclaw-gateway.service"); + await fs.writeFile(entrypoint, "export {};\n", "utf8"); + await fs.writeFile(serviceFile, "[Service]\n", "utf8"); + const realRoot = await fs.realpath(root); + + const summary = await readServiceStatusSummary( + createService({ + isLoaded: vi.fn(async () => true), + readCommand: vi.fn(async () => ({ + programArguments: ["/usr/bin/node", entrypoint, "gateway", "run"], + sourcePath: serviceFile, + })), + readRuntime: vi.fn(async () => ({ status: "running" })), + }), + "Daemon", + ); + + expect(summary.layout).toMatchObject({ + sourcePath: serviceFile, + sourcePathReal: path.join(realRoot, "openclaw-gateway.service"), + entrypoint, + entrypointReal: path.join(realRoot, "dist", "index.js"), + packageRoot: realRoot, + packageRootReal: realRoot, + packageVersion: "0.0.0-test", + entrypointSourceCheckout: true, + }); + expect(summary.layout?.execStart).toContain("gateway run"); + }); + }); }); diff --git a/src/commands/status.service-summary.ts b/src/commands/status.service-summary.ts index d202c40fa41..0d11b854241 100644 --- a/src/commands/status.service-summary.ts +++ b/src/commands/status.service-summary.ts @@ -1,3 +1,7 @@ +import { + summarizeGatewayServiceLayout, + type GatewayServiceLayoutSummary, +} from "../daemon/service-layout.js"; import type { GatewayServiceRuntime } from "../daemon/service-runtime.js"; import { readGatewayServiceState, type GatewayService } from "../daemon/service.js"; @@ -9,6 +13,7 @@ export type ServiceStatusSummary = { externallyManaged: boolean; loadedText: string; runtime: GatewayServiceRuntime | undefined; + layout?: GatewayServiceLayoutSummary; }; export async function readServiceStatusSummary( @@ -17,6 +22,7 @@ export async function readServiceStatusSummary( ): Promise { try { const state = await readGatewayServiceState(service, { env: process.env }); + const layout = await summarizeGatewayServiceLayout(state.command); const managedByOpenClaw = state.installed; const externallyManaged = !managedByOpenClaw && state.running; const installed = managedByOpenClaw || externallyManaged; @@ -33,6 +39,7 @@ export async function readServiceStatusSummary( externallyManaged, loadedText, runtime: state.runtime, + ...(layout ? { layout } : {}), }; } catch { return { diff --git a/src/daemon/service-layout.ts b/src/daemon/service-layout.ts new file mode 100644 index 00000000000..14d054b074e --- /dev/null +++ b/src/daemon/service-layout.ts @@ -0,0 +1,141 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { readPackageName, readPackageVersion } from "../infra/package-json.js"; +import type { GatewayServiceCommandConfig } from "./service-types.js"; + +export type GatewayServiceLayoutSummary = { + execStart: string; + sourcePath?: string; + sourcePathReal?: string; + sourceScope?: "user" | "system"; + entrypoint?: string; + entrypointReal?: string; + packageRoot?: string; + packageRootReal?: string; + packageVersion?: string; + entrypointSourceCheckout?: boolean; +}; + +function shellQuoteArg(value: string): string { + if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(value)) { + return value; + } + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function formatExecStart(programArguments: readonly string[]): string { + return programArguments.map(shellQuoteArg).join(" "); +} + +function resolveSystemdScopeFromServicePath( + sourcePath: string | undefined, +): "user" | "system" | undefined { + const normalized = sourcePath?.replaceAll("\\", "/") ?? ""; + if (!normalized.endsWith(".service")) { + return undefined; + } + if ( + normalized.startsWith("/etc/systemd/") || + normalized.startsWith("/usr/lib/systemd/") || + normalized.startsWith("/lib/systemd/") + ) { + return "system"; + } + return "user"; +} + +function findGatewayEntrypoint(programArguments: readonly string[]): string | undefined { + const gatewayIndex = programArguments.indexOf("gateway"); + if (gatewayIndex <= 0) { + return undefined; + } + return programArguments[gatewayIndex - 1]; +} + +async function tryRealpath(value: string | undefined): Promise { + if (!value) { + return undefined; + } + const resolved = path.resolve(value); + try { + return await fs.realpath(resolved); + } catch { + return resolved; + } +} + +async function pathExists(candidate: string): Promise { + try { + await fs.access(candidate); + return true; + } catch { + return false; + } +} + +async function isSourceCheckoutRoot(candidate: string): Promise { + const hasRepoMarker = + (await pathExists(path.join(candidate, ".git"))) || + (await pathExists(path.join(candidate, "pnpm-workspace.yaml"))); + if (!hasRepoMarker) { + return false; + } + return ( + (await pathExists(path.join(candidate, "src"))) && + (await pathExists(path.join(candidate, "extensions"))) + ); +} + +async function resolveOpenClawPackageRoot(entrypoint: string): Promise { + let current = path.dirname(path.resolve(entrypoint)); + for (let depth = 0; depth < 8; depth += 1) { + const packageJson = path.join(current, "package.json"); + if (await pathExists(packageJson)) { + const name = await readPackageName(current); + if (name === "openclaw") { + return current; + } + } + const next = path.dirname(current); + if (next === current) { + return undefined; + } + current = next; + } + return undefined; +} + +export async function summarizeGatewayServiceLayout( + command: GatewayServiceCommandConfig | null, +): Promise { + if (!command) { + return undefined; + } + const sourcePath = command.sourcePath?.trim() || undefined; + const entrypoint = findGatewayEntrypoint(command.programArguments); + const [sourcePathReal, entrypointReal] = await Promise.all([ + tryRealpath(sourcePath), + tryRealpath(entrypoint), + ]); + const packageRoot = entrypointReal ? await resolveOpenClawPackageRoot(entrypointReal) : undefined; + const packageRootReal = await tryRealpath(packageRoot); + const packageVersion = packageRoot + ? ((await readPackageVersion(packageRoot)) ?? undefined) + : undefined; + const entrypointSourceCheckout = packageRootReal + ? await isSourceCheckoutRoot(packageRootReal) + : undefined; + + return { + execStart: formatExecStart(command.programArguments), + ...(sourcePath ? { sourcePath } : {}), + ...(sourcePathReal ? { sourcePathReal } : {}), + ...(sourcePath ? { sourceScope: resolveSystemdScopeFromServicePath(sourcePath) } : {}), + ...(entrypoint ? { entrypoint } : {}), + ...(entrypointReal ? { entrypointReal } : {}), + ...(packageRoot ? { packageRoot } : {}), + ...(packageRootReal ? { packageRootReal } : {}), + ...(packageVersion ? { packageVersion } : {}), + ...(entrypointSourceCheckout !== undefined ? { entrypointSourceCheckout } : {}), + }; +} diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index f76babb0bf2..6d451301873 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -91,6 +91,7 @@ export const SendParamsSchema = Type.Object( message: Type.Optional(Type.String()), mediaUrl: Type.Optional(Type.String()), mediaUrls: Type.Optional(Type.Array(Type.String())), + asVoice: Type.Optional(Type.Boolean()), gifPlayback: Type.Optional(Type.Boolean()), channel: Type.Optional(Type.String()), accountId: Type.Optional(Type.String()), diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 30a79c7a3a7..3073f6a4e21 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -283,6 +283,37 @@ describe("gateway send mirroring", () => { ); }); + it("maps gateway asVoice sends onto outbound audioAsVoice payloads", async () => { + mockDeliverySuccess("m-voice"); + + const { respond } = await runSend({ + to: "channel:C1", + message: "voice note", + mediaUrl: "file:///tmp/openclaw-voice.ogg", + asVoice: true, + channel: "slack", + idempotencyKey: "idem-voice", + }); + + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [ + expect.objectContaining({ + text: "voice note", + mediaUrl: "file:///tmp/openclaw-voice.ogg", + audioAsVoice: true, + }), + ], + }), + ); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ messageId: "m-voice" }), + undefined, + expect.objectContaining({ channel: "slack" }), + ); + }); + it("forwards gateway client scopes into outbound delivery", async () => { mockDeliverySuccess("m-scope"); diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 2700cfb4005..1cd4e1f75c8 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -390,6 +390,7 @@ export const sendHandlers: GatewayRequestHandlers = { message?: string; mediaUrl?: string; mediaUrls?: string[]; + asVoice?: boolean; gifPlayback?: boolean; channel?: string; accountId?: string; @@ -469,7 +470,14 @@ export const sendHandlers: GatewayRequestHandlers = { }); const deliveryTarget = idLikeTarget?.to ?? resolvedTarget.to; const outboundDeps = context.deps ? createOutboundSendDeps(context.deps) : undefined; - const outboundPayloads = [{ text: message, mediaUrl, mediaUrls }]; + const outboundPayloads = [ + { + text: message, + mediaUrl, + mediaUrls, + ...(request.asVoice === true ? { audioAsVoice: true } : {}), + }, + ]; const outboundPayloadPlan = createOutboundPayloadPlan(outboundPayloads); const mirrorProjection = projectOutboundPayloadPlanForMirror(outboundPayloadPlan); const mirrorText = mirrorProjection.text; diff --git a/src/infra/outbound/message-action-runner.media.test.ts b/src/infra/outbound/message-action-runner.media.test.ts index 2fdd2c3411d..030c157a19d 100644 --- a/src/infra/outbound/message-action-runner.media.test.ts +++ b/src/infra/outbound/message-action-runner.media.test.ts @@ -233,6 +233,36 @@ describe("runMessageAction media behavior", () => { vi.mocked(loadWebMedia).mockImplementation(actualLoadWebMedia); }); + it("forwards asVoice from send actions into core delivery", async () => { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "workspace", + source: "test", + plugin: workspacePlugin, + }, + ]), + ); + + const result = await runDrySend({ + cfg: workspaceConfig, + actionParams: { + channel: "workspace", + target: "12345678", + message: "voice note", + media: "https://example.com/voice.ogg", + asVoice: true, + }, + }); + + expect(result.kind).toBe("send"); + expect(channelResolutionMocks.executeSendAction).toHaveBeenCalledWith( + expect.objectContaining({ + asVoice: true, + }), + ); + }); + describe("sendAttachment hydration", () => { const cfg = { channels: { diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 440cd55c037..5383827aa4c 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -610,6 +610,11 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise { ); }); + it("maps voice media sends onto outbound audioAsVoice payloads", async () => { + await sendMessage({ + cfg: {}, + channel: "forum", + to: "123456", + content: "voice note", + mediaUrl: "file:///tmp/openclaw-voice.ogg", + asVoice: true, + }); + + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [ + expect.objectContaining({ + text: "voice note", + mediaUrl: "file:///tmp/openclaw-voice.ogg", + audioAsVoice: true, + }), + ], + }), + ); + }); + it("applies mirror matrix semantics for MEDIA and silent token variants", async () => { const matrix: Array<{ name: string; diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index 107066f2c17..becfcd9c4c1 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -67,6 +67,7 @@ type MessageSendParams = { channel?: string; mediaUrl?: string; mediaUrls?: string[]; + asVoice?: boolean; gifPlayback?: boolean; forceDocument?: boolean; accountId?: string; @@ -242,6 +243,7 @@ export async function sendMessage(params: MessageSendParams): Promise { }); }); + it("flags global package roots that resolve into source checkouts", async () => { + await withTempDir({ prefix: "openclaw-update-global-source-checkout-" }, async (base) => { + const checkoutRoot = path.join(base, "checkout"); + const globalRoot = path.join(base, "prefix", "lib", "node_modules"); + const packageRoot = path.join(globalRoot, "openclaw"); + await fs.mkdir(path.join(checkoutRoot, ".git"), { recursive: true }); + await fs.mkdir(path.join(checkoutRoot, "src"), { recursive: true }); + await fs.mkdir(path.join(checkoutRoot, "extensions"), { recursive: true }); + await fs.writeFile(path.join(checkoutRoot, "pnpm-workspace.yaml"), "packages: []\n", "utf8"); + await writeGlobalPackageJson(checkoutRoot, "2026.4.27"); + await fs.mkdir(globalRoot, { recursive: true }); + await fs.symlink(checkoutRoot, packageRoot, "dir"); + const realCheckoutRoot = await fs.realpath(checkoutRoot); + + await expect(collectInstalledGlobalPackageErrors({ packageRoot })).resolves.toContain( + `global package root resolves to source checkout: ${realCheckoutRoot}`, + ); + }); + }); + it("does not require private QA sidecars when the inventory is missing", async () => { await withTempDir({ prefix: "openclaw-update-global-legacy-" }, async (packageRoot) => { await writeGlobalPackageJson(packageRoot); diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index efdaaa41207..ca3054a5f75 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -101,6 +101,7 @@ export async function collectInstalledGlobalPackageErrors(params: { expectedVersion?: string | null; }): Promise { const errors: string[] = []; + errors.push(...(await collectSourceCheckoutInstallErrors(params.packageRoot))); const installedVersion = await readPackageVersion(params.packageRoot); if (params.expectedVersion && installedVersion !== params.expectedVersion) { errors.push( @@ -117,6 +118,18 @@ export async function collectInstalledGlobalPackageErrors(params: { return errors; } +async function collectSourceCheckoutInstallErrors(packageRoot: string): Promise { + const realPackageRoot = await tryRealpath(packageRoot); + const hasSourceCheckoutShape = + ((await pathExists(path.join(realPackageRoot, ".git"))) || + (await pathExists(path.join(realPackageRoot, "pnpm-workspace.yaml")))) && + (await pathExists(path.join(realPackageRoot, "src"))) && + (await pathExists(path.join(realPackageRoot, "extensions"))); + return hasSourceCheckoutShape + ? [`global package root resolves to source checkout: ${realPackageRoot}`] + : []; +} + function shouldRequirePackagedDistInventory(version: string | null | undefined): boolean { const parsed = parseSemver(version ?? null); if (!parsed) { diff --git a/src/tasks/task-boundaries.test.ts b/src/tasks/task-boundaries.test.ts index 5b1beec2fa8..77561c7bdff 100644 --- a/src/tasks/task-boundaries.test.ts +++ b/src/tasks/task-boundaries.test.ts @@ -79,4 +79,13 @@ describe("task boundaries", () => { expect(importers.toSorted()).toEqual([...TASK_REGISTRY_ALLOWED_IMPORTERS].toSorted()); }); + + it("keeps task registry maintenance chat-type checks on the lightweight parser", () => { + const maintenance = sources.find( + ({ relative }) => relative === "tasks/task-registry.maintenance.ts", + ); + + expect(maintenance?.source).toContain("session-chat-type-shared.js"); + expect(maintenance?.source).not.toContain("session-chat-type.js"); + }); }); diff --git a/src/tasks/task-registry.maintenance.ts b/src/tasks/task-registry.maintenance.ts index 632b9ca66dd..a5b43b0240b 100644 --- a/src/tasks/task-registry.maintenance.ts +++ b/src/tasks/task-registry.maintenance.ts @@ -24,7 +24,7 @@ import { sweepExpiredPluginStateEntries, } from "../plugin-state/plugin-state-store.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; -import { deriveSessionChatType } from "../sessions/session-chat-type.js"; +import { deriveSessionChatTypeFromKey } from "../sessions/session-chat-type-shared.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, @@ -386,7 +386,7 @@ function hasBackingSession(task: TaskRecord): boolean { } if (task.runtime === "subagent" || task.runtime === "cli") { if (task.runtime === "cli") { - const chatType = deriveSessionChatType(childSessionKey); + const chatType = deriveSessionChatTypeFromKey(childSessionKey); if (chatType === "channel" || chatType === "group" || chatType === "direct") { return false; }