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.
This commit is contained in:
Val Alexander
2026-04-30 21:46:22 -05:00
committed by GitHub
parent 98d87b06e0
commit df0ee092f0
27 changed files with 647 additions and 15 deletions

View File

@@ -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"

View File

@@ -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"

View File

@@ -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");
});
});

View File

@@ -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);

View File

@@ -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;

View File

@@ -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"
`;

View File

@@ -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", () => {

View File

@@ -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({

View File

@@ -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.

View File

@@ -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"})`

View File

@@ -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,
},
});
});
});

View File

@@ -12,6 +12,7 @@ type DaemonStatusSummary = {
loadedText: string;
runtime: Awaited<ReturnType<typeof readServiceStatusSummary>>["runtime"];
runtimeShort: string | null;
layout: Awaited<ReturnType<typeof readServiceStatusSummary>>["layout"];
};
async function buildDaemonStatusSummary(
@@ -29,6 +30,7 @@ async function buildDaemonStatusSummary(
loadedText: summary.loadedText,
runtime: summary.runtime,
runtimeShort: formatDaemonRuntimeShort(summary.runtime),
layout: summary.layout,
};
}

View File

@@ -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>): 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");
});
});
});

View File

@@ -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<ServiceStatusSummary> {
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 {

View File

@@ -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<string | undefined> {
if (!value) {
return undefined;
}
const resolved = path.resolve(value);
try {
return await fs.realpath(resolved);
} catch {
return resolved;
}
}
async function pathExists(candidate: string): Promise<boolean> {
try {
await fs.access(candidate);
return true;
} catch {
return false;
}
}
async function isSourceCheckoutRoot(candidate: string): Promise<boolean> {
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<string | undefined> {
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<GatewayServiceLayoutSummary | undefined> {
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 } : {}),
};
}

View File

@@ -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()),

View File

@@ -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");

View File

@@ -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;

View File

@@ -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: {

View File

@@ -610,6 +610,11 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
const gifPlayback = readBooleanParam(params, "gifPlayback") ?? false;
const forceDocument =
readBooleanParam(params, "forceDocument") ?? readBooleanParam(params, "asDocument") ?? false;
const asVoice =
readBooleanParam(params, "asVoice") ??
readBooleanParam(params, "audioAsVoice") ??
parsed.audioAsVoice ??
false;
const bestEffort = readBooleanParam(params, "bestEffort");
const silent = readBooleanParam(params, "silent");
@@ -696,6 +701,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
message,
mediaUrl: mediaUrl || undefined,
mediaUrls: mergedMediaUrls.length ? mergedMediaUrls : undefined,
asVoice,
gifPlayback,
forceDocument,
bestEffort: bestEffort ?? undefined,

View File

@@ -204,6 +204,29 @@ describe("sendMessage", () => {
);
});
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;

View File

@@ -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<MessageSen
text: params.content,
mediaUrl: params.mediaUrl,
mediaUrls: params.mediaUrls,
audioAsVoice: params.asVoice === true,
},
]);
const normalizedPayloads = projectOutboundPayloadPlanForDelivery(outboundPlan);
@@ -327,6 +329,7 @@ export async function sendMessage(params: MessageSendParams): Promise<MessageSen
message: params.content,
mediaUrl: params.mediaUrl,
mediaUrls: mirrorMediaUrls.length ? mirrorMediaUrls : params.mediaUrls,
asVoice: params.asVoice,
gifPlayback: params.gifPlayback,
accountId: params.accountId,
agentId: params.agentId,

View File

@@ -128,6 +128,7 @@ export async function executeSendAction(params: {
message: string;
mediaUrl?: string;
mediaUrls?: string[];
asVoice?: boolean;
gifPlayback?: boolean;
forceDocument?: boolean;
bestEffort?: boolean;
@@ -179,6 +180,7 @@ export async function executeSendAction(params: {
requesterSenderE164: params.ctx.requesterSenderE164,
mediaUrl: params.mediaUrl || undefined,
mediaUrls: params.mediaUrls,
asVoice: params.asVoice,
channel: params.ctx.channel || undefined,
accountId: params.ctx.accountId ?? undefined,
replyToId: params.replyToId,

View File

@@ -484,6 +484,26 @@ describe("update global helpers", () => {
});
});
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);

View File

@@ -101,6 +101,7 @@ export async function collectInstalledGlobalPackageErrors(params: {
expectedVersion?: string | null;
}): Promise<string[]> {
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<string[]> {
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) {

View File

@@ -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");
});
});

View File

@@ -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;
}