refactor(core): extract shared dedup helpers

This commit is contained in:
Peter Steinberger
2026-03-07 10:40:49 +00:00
parent 14c61bb33f
commit 3c71e2bd48
114 changed files with 3400 additions and 2040 deletions

View File

@@ -246,6 +246,8 @@
"docs:list": "node scripts/docs-list.js",
"docs:spellcheck": "bash scripts/docs-spellcheck.sh",
"docs:spellcheck:fix": "bash scripts/docs-spellcheck.sh --write",
"dup:check": "jscpd src extensions test scripts --format typescript,javascript --pattern \"**/*.{ts,tsx,js,mjs,cjs}\" --gitignore --noSymlinks --ignore \"**/node_modules/**,**/dist/**,**/.git/**,**/coverage/**,**/build/**,**/.build/**,**/.artifacts/**\" --min-lines 12 --min-tokens 80 --reporters console",
"dup:check:json": "jscpd src extensions test scripts --format typescript,javascript --pattern \"**/*.{ts,tsx,js,mjs,cjs}\" --gitignore --noSymlinks --ignore \"**/node_modules/**,**/dist/**,**/.git/**,**/coverage/**,**/build/**,**/.build/**,**/.artifacts/**\" --min-lines 12 --min-tokens 80 --reporters json --output .artifacts/jscpd",
"format": "oxfmt --write",
"format:all": "pnpm format && pnpm format:swift",
"format:check": "oxfmt --check",
@@ -393,6 +395,7 @@
"@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260301.1",
"@vitest/coverage-v8": "^4.0.18",
"jscpd": "4.0.8",
"lit": "^3.3.2",
"oxfmt": "0.35.0",
"oxlint": "^1.50.0",

588
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -100,6 +100,26 @@ vi.mock("./translator.js", () => ({
describe("serveAcpGateway startup", () => {
let serveAcpGateway: typeof import("./server.js").serveAcpGateway;
function getMockGateway() {
const gateway = mockState.gateways[0];
if (!gateway) {
throw new Error("Expected mocked gateway instance");
}
return gateway;
}
function captureProcessSignalHandlers() {
const signalHandlers = new Map<NodeJS.Signals, () => void>();
const onceSpy = vi.spyOn(process, "once").mockImplementation(((
signal: NodeJS.Signals,
handler: () => void,
) => {
signalHandlers.set(signal, handler);
return process;
}) as typeof process.once);
return { signalHandlers, onceSpy };
}
beforeAll(async () => {
({ serveAcpGateway } = await import("./server.js"));
});
@@ -117,25 +137,14 @@ describe("serveAcpGateway startup", () => {
});
it("waits for gateway hello before creating AgentSideConnection", async () => {
const signalHandlers = new Map<NodeJS.Signals, () => void>();
const onceSpy = vi.spyOn(process, "once").mockImplementation(((
signal: NodeJS.Signals,
handler: () => void,
) => {
signalHandlers.set(signal, handler);
return process;
}) as typeof process.once);
const { signalHandlers, onceSpy } = captureProcessSignalHandlers();
try {
const servePromise = serveAcpGateway({});
await Promise.resolve();
expect(mockState.agentSideConnectionCtor).not.toHaveBeenCalled();
const gateway = mockState.gateways[0];
if (!gateway) {
throw new Error("Expected mocked gateway instance");
}
const gateway = getMockGateway();
gateway.emitHello();
await vi.waitFor(() => {
expect(mockState.agentSideConnectionCtor).toHaveBeenCalledTimes(1);
@@ -159,11 +168,7 @@ describe("serveAcpGateway startup", () => {
const servePromise = serveAcpGateway({});
await Promise.resolve();
const gateway = mockState.gateways[0];
if (!gateway) {
throw new Error("Expected mocked gateway instance");
}
const gateway = getMockGateway();
gateway.emitConnectError("connect failed");
await expect(servePromise).rejects.toThrow("connect failed");
expect(mockState.agentSideConnectionCtor).not.toHaveBeenCalled();
@@ -177,14 +182,7 @@ describe("serveAcpGateway startup", () => {
token: undefined,
password: "resolved-secret-password",
});
const signalHandlers = new Map<NodeJS.Signals, () => void>();
const onceSpy = vi.spyOn(process, "once").mockImplementation(((
signal: NodeJS.Signals,
handler: () => void,
) => {
signalHandlers.set(signal, handler);
return process;
}) as typeof process.once);
const { signalHandlers, onceSpy } = captureProcessSignalHandlers();
try {
const servePromise = serveAcpGateway({});
@@ -200,10 +198,7 @@ describe("serveAcpGateway startup", () => {
password: "resolved-secret-password",
});
const gateway = mockState.gateways[0];
if (!gateway) {
throw new Error("Expected mocked gateway instance");
}
const gateway = getMockGateway();
gateway.emitHello();
await vi.waitFor(() => {
expect(mockState.agentSideConnectionCtor).toHaveBeenCalledTimes(1);

View File

@@ -1,4 +1,3 @@
import crypto from "node:crypto";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import {
addAllowlistEntry,
@@ -20,11 +19,12 @@ import {
registerExecApprovalRequestForHostOrThrow,
} from "./bash-tools.exec-approval-request.js";
import {
createDefaultExecApprovalRequestContext,
resolveBaseExecApprovalDecision,
resolveApprovalDecisionOrUndefined,
resolveExecHostApprovalContext,
} from "./bash-tools.exec-host-shared.js";
import {
DEFAULT_APPROVAL_TIMEOUT_MS,
DEFAULT_NOTIFY_TAIL_CHARS,
createApprovalSlug,
emitExecSystemEvent,
@@ -138,16 +138,24 @@ export async function processGatewayAllowlist(
}
if (requiresAsk) {
const approvalId = crypto.randomUUID();
const approvalSlug = createApprovalSlug(approvalId);
const contextKey = `exec:${approvalId}`;
const {
approvalId,
approvalSlug,
contextKey,
noticeSeconds,
warningText,
expiresAtMs: defaultExpiresAtMs,
preResolvedDecision: defaultPreResolvedDecision,
} = createDefaultExecApprovalRequestContext({
warnings: params.warnings,
approvalRunningNoticeMs: params.approvalRunningNoticeMs,
createApprovalSlug,
});
const resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath;
const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000));
const effectiveTimeout =
typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec;
const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : "";
let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
let preResolvedDecision: string | null | undefined;
let expiresAtMs = defaultExpiresAtMs;
let preResolvedDecision = defaultPreResolvedDecision;
// Register first so the returned approval ID is actionable immediately.
const registration = await registerExecApprovalRequestForHostOrThrow({
@@ -184,24 +192,19 @@ export async function processGatewayAllowlist(
return;
}
let approvedByAsk = false;
let deniedReason: string | null = null;
const baseDecision = resolveBaseExecApprovalDecision({
decision,
askFallback,
obfuscationDetected: obfuscation.detected,
});
let approvedByAsk = baseDecision.approvedByAsk;
let deniedReason = baseDecision.deniedReason;
if (decision === "deny") {
deniedReason = "user-denied";
} else if (!decision) {
if (obfuscation.detected) {
deniedReason = "approval-timeout (obfuscation-detected)";
} else if (askFallback === "full") {
approvedByAsk = true;
} else if (askFallback === "allowlist") {
if (!analysisOk || !allowlistSatisfied) {
deniedReason = "approval-timeout (allowlist-miss)";
} else {
approvedByAsk = true;
}
if (baseDecision.timedOut && askFallback === "allowlist") {
if (!analysisOk || !allowlistSatisfied) {
deniedReason = "approval-timeout (allowlist-miss)";
} else {
deniedReason = "approval-timeout";
approvedByAsk = true;
}
} else if (decision === "allow-once") {
approvedByAsk = true;

View File

@@ -18,14 +18,12 @@ import {
registerExecApprovalRequestForHostOrThrow,
} from "./bash-tools.exec-approval-request.js";
import {
createDefaultExecApprovalRequestContext,
resolveBaseExecApprovalDecision,
resolveApprovalDecisionOrUndefined,
resolveExecHostApprovalContext,
} from "./bash-tools.exec-host-shared.js";
import {
DEFAULT_APPROVAL_TIMEOUT_MS,
createApprovalSlug,
emitExecSystemEvent,
} from "./bash-tools.exec-runtime.js";
import { createApprovalSlug, emitExecSystemEvent } from "./bash-tools.exec-runtime.js";
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
import { callGatewayTool } from "./tools/gateway.js";
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
@@ -209,13 +207,21 @@ export async function executeNodeHostCommand(
}) satisfies Record<string, unknown>;
if (requiresAsk) {
const approvalId = crypto.randomUUID();
const approvalSlug = createApprovalSlug(approvalId);
const contextKey = `exec:${approvalId}`;
const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000));
const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : "";
let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
let preResolvedDecision: string | null | undefined;
const {
approvalId,
approvalSlug,
contextKey,
noticeSeconds,
warningText,
expiresAtMs: defaultExpiresAtMs,
preResolvedDecision: defaultPreResolvedDecision,
} = createDefaultExecApprovalRequestContext({
warnings: params.warnings,
approvalRunningNoticeMs: params.approvalRunningNoticeMs,
createApprovalSlug,
});
let expiresAtMs = defaultExpiresAtMs;
let preResolvedDecision = defaultPreResolvedDecision;
// Register first so the returned approval ID is actionable immediately.
const registration = await registerExecApprovalRequestForHostOrThrow({
@@ -252,23 +258,17 @@ export async function executeNodeHostCommand(
return;
}
let approvedByAsk = false;
const baseDecision = resolveBaseExecApprovalDecision({
decision,
askFallback,
obfuscationDetected: obfuscation.detected,
});
let approvedByAsk = baseDecision.approvedByAsk;
let approvalDecision: "allow-once" | "allow-always" | null = null;
let deniedReason: string | null = null;
let deniedReason = baseDecision.deniedReason;
if (decision === "deny") {
deniedReason = "user-denied";
} else if (!decision) {
if (obfuscation.detected) {
deniedReason = "approval-timeout (obfuscation-detected)";
} else if (askFallback === "full") {
approvedByAsk = true;
approvalDecision = "allow-once";
} else if (askFallback === "allowlist") {
// Defer allowlist enforcement to the node host.
} else {
deniedReason = "approval-timeout";
}
if (baseDecision.timedOut && askFallback === "full" && approvedByAsk) {
approvalDecision = "allow-once";
} else if (decision === "allow-once") {
approvedByAsk = true;
approvalDecision = "allow-once";

View File

@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import {
maxAsk,
minSecurity,
@@ -6,6 +7,7 @@ import {
type ExecSecurity,
} from "../infra/exec-approvals.js";
import { resolveRegisteredExecApprovalDecision } from "./bash-tools.exec-approval-request.js";
import { DEFAULT_APPROVAL_TIMEOUT_MS } from "./bash-tools.exec-runtime.js";
type ResolvedExecApprovals = ReturnType<typeof resolveExecApprovals>;
@@ -16,6 +18,110 @@ export type ExecHostApprovalContext = {
askFallback: ResolvedExecApprovals["agent"]["askFallback"];
};
export type ExecApprovalPendingState = {
warningText: string;
expiresAtMs: number;
preResolvedDecision: string | null | undefined;
};
export type ExecApprovalRequestState = ExecApprovalPendingState & {
noticeSeconds: number;
};
export function createExecApprovalPendingState(params: {
warnings: string[];
timeoutMs: number;
}): ExecApprovalPendingState {
return {
warningText: params.warnings.length ? `${params.warnings.join("\n")}\n\n` : "",
expiresAtMs: Date.now() + params.timeoutMs,
preResolvedDecision: undefined,
};
}
export function createExecApprovalRequestState(params: {
warnings: string[];
timeoutMs: number;
approvalRunningNoticeMs: number;
}): ExecApprovalRequestState {
const pendingState = createExecApprovalPendingState({
warnings: params.warnings,
timeoutMs: params.timeoutMs,
});
return {
...pendingState,
noticeSeconds: Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000)),
};
}
export function createExecApprovalRequestContext(params: {
warnings: string[];
timeoutMs: number;
approvalRunningNoticeMs: number;
createApprovalSlug: (approvalId: string) => string;
}): ExecApprovalRequestState & {
approvalId: string;
approvalSlug: string;
contextKey: string;
} {
const approvalId = crypto.randomUUID();
const pendingState = createExecApprovalRequestState({
warnings: params.warnings,
timeoutMs: params.timeoutMs,
approvalRunningNoticeMs: params.approvalRunningNoticeMs,
});
return {
...pendingState,
approvalId,
approvalSlug: params.createApprovalSlug(approvalId),
contextKey: `exec:${approvalId}`,
};
}
export function createDefaultExecApprovalRequestContext(params: {
warnings: string[];
approvalRunningNoticeMs: number;
createApprovalSlug: (approvalId: string) => string;
}) {
return createExecApprovalRequestContext({
warnings: params.warnings,
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
approvalRunningNoticeMs: params.approvalRunningNoticeMs,
createApprovalSlug: params.createApprovalSlug,
});
}
export function resolveBaseExecApprovalDecision(params: {
decision: string | null;
askFallback: ResolvedExecApprovals["agent"]["askFallback"];
obfuscationDetected: boolean;
}): {
approvedByAsk: boolean;
deniedReason: string | null;
timedOut: boolean;
} {
if (params.decision === "deny") {
return { approvedByAsk: false, deniedReason: "user-denied", timedOut: false };
}
if (!params.decision) {
if (params.obfuscationDetected) {
return {
approvedByAsk: false,
deniedReason: "approval-timeout (obfuscation-detected)",
timedOut: true,
};
}
if (params.askFallback === "full") {
return { approvedByAsk: true, deniedReason: null, timedOut: true };
}
if (params.askFallback === "deny") {
return { approvedByAsk: false, deniedReason: "approval-timeout", timedOut: true };
}
return { approvedByAsk: false, deniedReason: null, timedOut: true };
}
return { approvedByAsk: false, deniedReason: null, timedOut: false };
}
export function resolveExecHostApprovalContext(params: {
agentId?: string;
security: ExecSecurity;

View File

@@ -1,7 +1,7 @@
import path from "node:path";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import type { ExecAsk, ExecHost, ExecSecurity } from "../infra/exec-approvals.js";
import { type ExecHost } from "../infra/exec-approvals.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { isDangerousHostEnvVarName } from "../infra/host-env-security.js";
import { findPathKey, mergePathPrepend } from "../infra/path-prepend.js";
@@ -11,6 +11,11 @@ import type { ProcessSession } from "./bash-process-registry.js";
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
import type { BashSandboxConfig } from "./bash-tools.shared.js";
export { applyPathPrepend, findPathKey, normalizePathPrepend } from "../infra/path-prepend.js";
export {
normalizeExecAsk,
normalizeExecHost,
normalizeExecSecurity,
} from "../infra/exec-approvals.js";
import { logWarn } from "../logger.js";
import type { ManagedRun } from "../process/supervisor/index.js";
import { getProcessSupervisor } from "../process/supervisor/index.js";
@@ -156,30 +161,6 @@ export type ExecProcessHandle = {
kill: () => void;
};
export function normalizeExecHost(value?: string | null): ExecHost | null {
const normalized = value?.trim().toLowerCase();
if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") {
return normalized;
}
return null;
}
export function normalizeExecSecurity(value?: string | null): ExecSecurity | null {
const normalized = value?.trim().toLowerCase();
if (normalized === "deny" || normalized === "allowlist" || normalized === "full") {
return normalized;
}
return null;
}
export function normalizeExecAsk(value?: string | null): ExecAsk | null {
const normalized = value?.trim().toLowerCase();
if (normalized === "off" || normalized === "on-miss" || normalized === "always") {
return normalized as ExecAsk;
}
return null;
}
export function renderExecHostLabel(host: ExecHost) {
return host === "sandbox" ? "sandbox" : host === "gateway" ? "gateway" : "node";
}

View File

@@ -8,6 +8,7 @@ import { parseBooleanValue } from "../utils/boolean.js";
import { safeJsonStringify } from "../utils/safe-json.js";
import { redactImageDataForDiagnostics } from "./payload-redaction.js";
import { getQueuedFileWriter, type QueuedFileWriter } from "./queued-file-writer.js";
import { buildAgentTraceBase } from "./trace-base.js";
export type CacheTraceStage =
| "session:loaded"
@@ -173,15 +174,7 @@ export function createCacheTrace(params: CacheTraceInit): CacheTrace | null {
const writer = params.writer ?? getWriter(cfg.filePath);
let seq = 0;
const base: Omit<CacheTraceEvent, "ts" | "seq" | "stage"> = {
runId: params.runId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
provider: params.provider,
modelId: params.modelId,
modelApi: params.modelApi,
workspaceDir: params.workspaceDir,
};
const base: Omit<CacheTraceEvent, "ts" | "seq" | "stage"> = buildAgentTraceBase(params);
const recordStage: CacheTrace["recordStage"] = (stage, payload = {}) => {
const event: CacheTraceEvent = {

View File

@@ -6,6 +6,7 @@ import {
pruneHistoryForContextShare,
splitMessagesByTokenShare,
} from "./compaction.js";
import { makeAgentAssistantMessage } from "./test-helpers/agent-message-fixtures.js";
function makeMessage(id: number, size: number): AgentMessage {
return {
@@ -24,26 +25,15 @@ function makeAssistantToolCall(
toolCallId: string,
text = "x".repeat(4000),
): AssistantMessage {
return {
role: "assistant",
return makeAgentAssistantMessage({
content: [
{ type: "text", text },
{ type: "toolCall", id: toolCallId, name: "test_tool", arguments: {} },
],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp,
};
});
}
function makeToolResult(timestamp: number, toolCallId: string, text: string): ToolResultMessage {
@@ -229,27 +219,16 @@ describe("pruneHistoryForContextShare", () => {
// all corresponding tool_results should be removed from kept messages
const messages: AgentMessage[] = [
// Chunk 1 (will be dropped) - contains multiple tool_use blocks
{
role: "assistant",
makeAgentAssistantMessage({
content: [
{ type: "text", text: "x".repeat(4000) },
{ type: "toolCall", id: "call_a", name: "tool_a", arguments: {} },
{ type: "toolCall", id: "call_b", name: "tool_b", arguments: {} },
],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: 1,
},
}),
// Chunk 2 (will be kept) - contains orphaned tool_results
makeToolResult(2, "call_a", "result_a"),
makeToolResult(3, "call_b", "result_b"),

View File

@@ -1,6 +1,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, ToolResultMessage } from "@mariozechner/pi-ai";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { makeAgentAssistantMessage } from "./test-helpers/agent-message-fixtures.js";
const piCodingAgentMocks = vi.hoisted(() => ({
generateSummary: vi.fn(async () => "summary"),
@@ -21,23 +22,12 @@ vi.mock("@mariozechner/pi-coding-agent", async () => {
import { isOversizedForSummary, summarizeWithFallback } from "./compaction.js";
function makeAssistantToolCall(timestamp: number): AssistantMessage {
return {
role: "assistant",
return makeAgentAssistantMessage({
content: [{ type: "toolCall", id: "call_1", name: "browser", arguments: { action: "tabs" } }],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp,
};
});
}
function makeToolResultWithDetails(timestamp: number): ToolResultMessage<{ raw: string }> {

View File

@@ -1,33 +1,37 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
function mockContextModuleDeps(loadConfigImpl: () => unknown) {
vi.doMock("../config/config.js", () => ({
loadConfig: loadConfigImpl,
}));
vi.doMock("./models-config.js", () => ({
ensureOpenClawModelsJson: vi.fn(async () => {}),
}));
vi.doMock("./agent-paths.js", () => ({
resolveOpenClawAgentDir: () => "/tmp/openclaw-agent",
}));
vi.doMock("./pi-model-discovery.js", () => ({
discoverAuthStorage: vi.fn(() => ({})),
discoverModels: vi.fn(() => ({
getAll: () => [],
})),
}));
}
describe("lookupContextTokens", () => {
beforeEach(() => {
vi.resetModules();
});
it("returns configured model context window on first lookup", async () => {
vi.doMock("../config/config.js", () => ({
loadConfig: () => ({
models: {
providers: {
openrouter: {
models: [{ id: "openrouter/claude-sonnet", contextWindow: 321_000 }],
},
mockContextModuleDeps(() => ({
models: {
providers: {
openrouter: {
models: [{ id: "openrouter/claude-sonnet", contextWindow: 321_000 }],
},
},
}),
}));
vi.doMock("./models-config.js", () => ({
ensureOpenClawModelsJson: vi.fn(async () => {}),
}));
vi.doMock("./agent-paths.js", () => ({
resolveOpenClawAgentDir: () => "/tmp/openclaw-agent",
}));
vi.doMock("./pi-model-discovery.js", () => ({
discoverAuthStorage: vi.fn(() => ({})),
discoverModels: vi.fn(() => ({
getAll: () => [],
})),
},
}));
const { lookupContextTokens } = await import("./context.js");
@@ -36,21 +40,7 @@ describe("lookupContextTokens", () => {
it("does not skip eager warmup when --profile is followed by -- terminator", async () => {
const loadConfigMock = vi.fn(() => ({ models: {} }));
vi.doMock("../config/config.js", () => ({
loadConfig: loadConfigMock,
}));
vi.doMock("./models-config.js", () => ({
ensureOpenClawModelsJson: vi.fn(async () => {}),
}));
vi.doMock("./agent-paths.js", () => ({
resolveOpenClawAgentDir: () => "/tmp/openclaw-agent",
}));
vi.doMock("./pi-model-discovery.js", () => ({
discoverAuthStorage: vi.fn(() => ({})),
discoverModels: vi.fn(() => ({
getAll: () => [],
})),
}));
mockContextModuleDeps(loadConfigMock);
const argvSnapshot = process.argv;
process.argv = ["node", "openclaw", "--profile", "--", "config", "validate"];
@@ -79,21 +69,7 @@ describe("lookupContextTokens", () => {
},
}));
vi.doMock("../config/config.js", () => ({
loadConfig: loadConfigMock,
}));
vi.doMock("./models-config.js", () => ({
ensureOpenClawModelsJson: vi.fn(async () => {}),
}));
vi.doMock("./agent-paths.js", () => ({
resolveOpenClawAgentDir: () => "/tmp/openclaw-agent",
}));
vi.doMock("./pi-model-discovery.js", () => ({
discoverAuthStorage: vi.fn(() => ({})),
discoverModels: vi.fn(() => ({
getAll: () => [],
})),
}));
mockContextModuleDeps(loadConfigMock);
const argvSnapshot = process.argv;
process.argv = ["node", "openclaw", "config", "validate"];

View File

@@ -19,6 +19,33 @@ function throwPathEscapesBoundary(params: {
throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`);
}
function validateRelativePathWithinBoundary(params: {
relativePath: string;
isAbsolutePath: (path: string) => boolean;
options?: RelativePathOptions;
rootResolved: string;
candidate: string;
}): string {
if (params.relativePath === "" || params.relativePath === ".") {
if (params.options?.allowRoot) {
return "";
}
throwPathEscapesBoundary({
options: params.options,
rootResolved: params.rootResolved,
candidate: params.candidate,
});
}
if (params.relativePath.startsWith("..") || params.isAbsolutePath(params.relativePath)) {
throwPathEscapesBoundary({
options: params.options,
rootResolved: params.rootResolved,
candidate: params.candidate,
});
}
return params.relativePath;
}
function toRelativePathUnderRoot(params: {
root: string;
candidate: string;
@@ -35,47 +62,44 @@ function toRelativePathUnderRoot(params: {
const rootForCompare = normalizeWindowsPathForComparison(rootResolved);
const targetForCompare = normalizeWindowsPathForComparison(resolvedCandidate);
const relative = path.win32.relative(rootForCompare, targetForCompare);
if (relative === "" || relative === ".") {
if (params.options?.allowRoot) {
return "";
}
throwPathEscapesBoundary({
options: params.options,
rootResolved,
candidate: params.candidate,
});
}
if (relative.startsWith("..") || path.win32.isAbsolute(relative)) {
throwPathEscapesBoundary({
options: params.options,
rootResolved,
candidate: params.candidate,
});
}
return relative;
return validateRelativePathWithinBoundary({
relativePath: relative,
isAbsolutePath: path.win32.isAbsolute,
options: params.options,
rootResolved,
candidate: params.candidate,
});
}
const rootResolved = path.resolve(params.root);
const resolvedCandidate = path.resolve(resolvedInput);
const relative = path.relative(rootResolved, resolvedCandidate);
if (relative === "" || relative === ".") {
if (params.options?.allowRoot) {
return "";
}
throwPathEscapesBoundary({
options: params.options,
rootResolved,
candidate: params.candidate,
});
}
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throwPathEscapesBoundary({
options: params.options,
rootResolved,
candidate: params.candidate,
});
}
return relative;
return validateRelativePathWithinBoundary({
relativePath: relative,
isAbsolutePath: path.isAbsolute,
options: params.options,
rootResolved,
candidate: params.candidate,
});
}
function toRelativeBoundaryPath(params: {
root: string;
candidate: string;
options?: Pick<RelativePathOptions, "allowRoot" | "cwd">;
boundaryLabel: string;
includeRootInError?: boolean;
}): string {
return toRelativePathUnderRoot({
root: params.root,
candidate: params.candidate,
options: {
allowRoot: params.options?.allowRoot,
cwd: params.options?.cwd,
boundaryLabel: params.boundaryLabel,
includeRootInError: params.includeRootInError,
},
});
}
export function toRelativeWorkspacePath(
@@ -83,14 +107,11 @@ export function toRelativeWorkspacePath(
candidate: string,
options?: Pick<RelativePathOptions, "allowRoot" | "cwd">,
): string {
return toRelativePathUnderRoot({
return toRelativeBoundaryPath({
root,
candidate,
options: {
allowRoot: options?.allowRoot,
cwd: options?.cwd,
boundaryLabel: "workspace root",
},
options,
boundaryLabel: "workspace root",
});
}
@@ -99,15 +120,12 @@ export function toRelativeSandboxPath(
candidate: string,
options?: Pick<RelativePathOptions, "allowRoot" | "cwd">,
): string {
return toRelativePathUnderRoot({
return toRelativeBoundaryPath({
root,
candidate,
options: {
allowRoot: options?.allowRoot,
cwd: options?.cwd,
boundaryLabel: "sandbox root",
includeRootInError: true,
},
options,
boundaryLabel: "sandbox root",
includeRootInError: true,
});
}

View File

@@ -5,15 +5,17 @@ import {
sanitizeGoogleTurnOrdering,
sanitizeSessionMessagesImages,
} from "./pi-embedded-helpers.js";
import { castAgentMessages } from "./test-helpers/agent-message-fixtures.js";
import {
castAgentMessages,
makeAgentAssistantMessage,
} from "./test-helpers/agent-message-fixtures.js";
let testTimestamp = 1;
const nextTimestamp = () => testTimestamp++;
function makeToolCallResultPairInput(): Array<AssistantMessage | ToolResultMessage> {
return [
{
role: "assistant",
makeAgentAssistantMessage({
content: [
{
type: "toolCall",
@@ -22,20 +24,10 @@ function makeToolCallResultPairInput(): Array<AssistantMessage | ToolResultMessa
arguments: { path: "package.json" },
},
],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: nextTimestamp(),
},
}),
{
role: "toolResult",
toolCallId: "call_123|fc_456",
@@ -47,6 +39,27 @@ function makeToolCallResultPairInput(): Array<AssistantMessage | ToolResultMessa
];
}
function makeEmptyAssistantErrorMessage(): AssistantMessage {
return makeAgentAssistantMessage({
stopReason: "error",
content: [],
model: "gpt-5.2",
timestamp: nextTimestamp(),
}) satisfies AssistantMessage;
}
function makeOpenAiResponsesAssistantMessage(
content: AssistantMessage["content"],
stopReason: AssistantMessage["stopReason"] = "toolUse",
): AssistantMessage {
return makeAgentAssistantMessage({
content,
model: "gpt-5.2",
stopReason,
timestamp: nextTimestamp(),
});
}
function expectToolCallAndResultIds(out: AgentMessage[], expectedId: string) {
const assistant = out[0];
expect(assistant.role).toBe("assistant");
@@ -95,23 +108,9 @@ describe("sanitizeSessionMessagesImages", () => {
it("does not synthesize tool call input when missing", async () => {
const input = castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "read" }],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: nextTimestamp(),
},
makeOpenAiResponsesAssistantMessage([
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
]),
]);
const out = await sanitizeSessionMessagesImages(input, "test");
@@ -124,26 +123,10 @@ describe("sanitizeSessionMessagesImages", () => {
it("removes empty assistant text blocks but preserves tool calls", async () => {
const input = castAgentMessages([
{
role: "assistant",
content: [
{ type: "text", text: "" },
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: nextTimestamp(),
},
makeOpenAiResponsesAssistantMessage([
{ type: "text", text: "" },
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
]),
]);
const out = await sanitizeSessionMessagesImages(input, "test");
@@ -189,33 +172,7 @@ describe("sanitizeSessionMessagesImages", () => {
});
it("sanitizes tool IDs in images-only mode when explicitly enabled", async () => {
const input = castAgentMessages([
{
role: "assistant",
content: [{ type: "toolCall", id: "call_123|fc_456", name: "read", arguments: {} }],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: nextTimestamp(),
},
{
role: "toolResult",
toolCallId: "call_123|fc_456",
toolName: "read",
content: [{ type: "text", text: "ok" }],
isError: false,
timestamp: nextTimestamp(),
},
]);
const input = makeToolCallResultPairInput();
const out = await sanitizeSessionMessagesImages(input, "test", {
sanitizeMode: "images-only",
@@ -297,39 +254,11 @@ describe("sanitizeSessionMessagesImages", () => {
const input = castAgentMessages([
{ role: "user", content: "hello", timestamp: nextTimestamp() } satisfies UserMessage,
{
role: "assistant",
stopReason: "error",
content: [],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: nextTimestamp(),
} satisfies AssistantMessage,
...makeEmptyAssistantErrorMessage(),
},
{
role: "assistant",
stopReason: "error",
content: [],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: nextTimestamp(),
} satisfies AssistantMessage,
...makeEmptyAssistantErrorMessage(),
},
]);
const out = await sanitizeSessionMessagesImages(input, "test");

View File

@@ -1,35 +1,21 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
import type { ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { describe, expect, it } from "vitest";
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
import { sanitizeSessionHistory } from "./google.js";
function makeAssistantToolCall(timestamp: number): AssistantMessage {
return {
role: "assistant",
content: [{ type: "toolCall", id: "call_1", name: "web_fetch", arguments: { url: "x" } }],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp,
};
}
describe("sanitizeSessionHistory toolResult details stripping", () => {
it("strips toolResult.details so untrusted payloads are not fed back to the model", async () => {
const sm = SessionManager.inMemory();
const messages: AgentMessage[] = [
makeAssistantToolCall(1),
makeAgentAssistantMessage({
content: [{ type: "toolCall", id: "call_1", name: "web_fetch", arguments: { url: "x" } }],
model: "gpt-5.2",
stopReason: "toolUse",
timestamp: 1,
}),
{
role: "toolResult",
toolCallId: "call_1",

View File

@@ -1,6 +1,7 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
import {
truncateToolResultText,
truncateToolResultMessage,
@@ -35,23 +36,12 @@ function makeUserMessage(text: string): UserMessage {
}
function makeAssistantMessage(text: string): AssistantMessage {
return {
role: "assistant",
return makeAgentAssistantMessage({
content: [{ type: "text", text }],
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: nextTimestamp(),
};
});
}
describe("truncateToolResultText", () => {

View File

@@ -9,6 +9,7 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createBaseToolHandlerState } from "./pi-tool-handler-state.test-helpers.js";
const hookMocks = vi.hoisted(() => ({
runner: {
@@ -75,17 +76,7 @@ function createToolHandlerCtx() {
hookRunner: hookMocks.runner,
state: {
toolMetaById: new Map<string, unknown>(),
toolMetas: [] as Array<{ toolName?: string; meta?: string }>,
toolSummaryById: new Set<string>(),
lastToolError: undefined,
pendingMessagingTexts: new Map<string, string>(),
pendingMessagingTargets: new Map<string, unknown>(),
pendingMessagingMediaUrls: new Map<string, string[]>(),
messagingToolSentTexts: [] as string[],
messagingToolSentTextsNormalized: [] as string[],
messagingToolSentMediaUrls: [] as string[],
messagingToolSentTargets: [] as unknown[],
blockBuffer: "",
...createBaseToolHandlerState(),
successfulCronAdds: 0,
},
log: { debug: vi.fn(), warn: vi.fn() },
@@ -247,7 +238,10 @@ describe("after_tool_call fires exactly once in embedded runs", () => {
result: { content: [{ type: "text", text: "ok" }] },
});
expect(beforeToolCallMocks.consumeAdjustedParamsForToolCall).toHaveBeenCalledWith(toolCallId);
expect(beforeToolCallMocks.consumeAdjustedParamsForToolCall).toHaveBeenCalledWith(
toolCallId,
"integration-test",
);
const event = (hookMocks.runner.runAfterToolCall as ReturnType<typeof vi.fn>).mock
.calls[0]?.[0] as { params?: unknown } | undefined;
expect(event?.params).toEqual(adjusted);

View File

@@ -0,0 +1,15 @@
export function createBaseToolHandlerState() {
return {
toolMetas: [] as Array<{ toolName?: string; meta?: string }>,
toolSummaryById: new Set<string>(),
lastToolError: undefined,
pendingMessagingTexts: new Map<string, string>(),
pendingMessagingTargets: new Map<string, unknown>(),
pendingMessagingMediaUrls: new Map<string, string[]>(),
messagingToolSentTexts: [] as string[],
messagingToolSentTextsNormalized: [] as string[],
messagingToolSentMediaUrls: [] as string[],
messagingToolSentTargets: [] as unknown[],
blockBuffer: "",
};
}

View File

@@ -112,10 +112,12 @@ function createSlugBase(words = 2) {
return parts.join("-");
}
export function createSessionSlug(isTaken?: (id: string) => boolean): string {
const isIdTaken = isTaken ?? (() => false);
function createAvailableSlug(
words: number,
isIdTaken: (id: string) => boolean,
): string | undefined {
for (let attempt = 0; attempt < 12; attempt += 1) {
const base = createSlugBase(2);
const base = createSlugBase(words);
if (!isIdTaken(base)) {
return base;
}
@@ -126,17 +128,18 @@ export function createSessionSlug(isTaken?: (id: string) => boolean): string {
}
}
}
for (let attempt = 0; attempt < 12; attempt += 1) {
const base = createSlugBase(3);
if (!isIdTaken(base)) {
return base;
}
for (let i = 2; i <= 12; i += 1) {
const candidate = `${base}-${i}`;
if (!isIdTaken(candidate)) {
return candidate;
}
}
return undefined;
}
export function createSessionSlug(isTaken?: (id: string) => boolean): string {
const isIdTaken = isTaken ?? (() => false);
const twoWord = createAvailableSlug(2, isIdTaken);
if (twoWord) {
return twoWord;
}
const threeWord = createAvailableSlug(3, isIdTaken);
if (threeWord) {
return threeWord;
}
const fallback = `${createSlugBase(3)}-${Math.random().toString(36).slice(2, 5)}`;
return isIdTaken(fallback) ? `${fallback}-${Date.now().toString(36)}` : fallback;

View File

@@ -2,6 +2,7 @@ import type { Skill } from "@mariozechner/pi-coding-agent";
import { validateRegistryNpmSpec } from "../../infra/npm-registry-spec.js";
import { parseFrontmatterBlock } from "../../markdown/frontmatter.js";
import {
applyOpenClawManifestInstallCommonFields,
getFrontmatterString,
normalizeStringList,
parseOpenClawManifestInstallBase,
@@ -113,19 +114,12 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
return undefined;
}
const { raw } = parsed;
const spec: SkillInstallSpec = {
kind: parsed.kind as SkillInstallSpec["kind"],
};
if (parsed.id) {
spec.id = parsed.id;
}
if (parsed.label) {
spec.label = parsed.label;
}
if (parsed.bins) {
spec.bins = parsed.bins;
}
const spec = applyOpenClawManifestInstallCommonFields<SkillInstallSpec>(
{
kind: parsed.kind as SkillInstallSpec["kind"],
},
parsed,
);
const osList = normalizeStringList(raw.os);
if (osList.length > 0) {
spec.os = osList;

View File

@@ -1,20 +1,6 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, ToolResultMessage, Usage, UserMessage } from "@mariozechner/pi-ai";
const ZERO_USAGE: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
import { ZERO_USAGE_FIXTURE } from "./usage-fixtures.js";
export function castAgentMessage(message: unknown): AgentMessage {
return message as AgentMessage;
@@ -42,7 +28,7 @@ export function makeAgentAssistantMessage(
api: "openai-responses",
provider: "openai",
model: "test-model",
usage: ZERO_USAGE,
usage: ZERO_USAGE_FIXTURE,
stopReason: "stop",
timestamp: 0,
...overrides,

View File

@@ -1,19 +1,5 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
const ZERO_USAGE: AssistantMessage["usage"] = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};
import { ZERO_USAGE_FIXTURE } from "./usage-fixtures.js";
export function makeAssistantMessageFixture(
overrides: Partial<AssistantMessage> = {},
@@ -24,7 +10,7 @@ export function makeAssistantMessageFixture(
api: "openai-responses",
provider: "openai",
model: "test-model",
usage: ZERO_USAGE,
usage: ZERO_USAGE_FIXTURE,
timestamp: 0,
stopReason: "error",
errorMessage: errorText,

View File

@@ -0,0 +1,16 @@
import type { Usage } from "@mariozechner/pi-ai";
export const ZERO_USAGE_FIXTURE: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
};

21
src/agents/trace-base.ts Normal file
View File

@@ -0,0 +1,21 @@
export type AgentTraceBase = {
runId?: string;
sessionId?: string;
sessionKey?: string;
provider?: string;
modelId?: string;
modelApi?: string | null;
workspaceDir?: string;
};
export function buildAgentTraceBase(params: AgentTraceBase): AgentTraceBase {
return {
runId: params.runId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
provider: params.provider,
modelId: params.modelId,
modelApi: params.modelApi,
workspaceDir: params.workspaceDir,
};
}

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MsgContext } from "../templating.js";
import { registerGetReplyCommonMocks } from "./get-reply.test-mocks.js";
const mocks = vi.hoisted(() => ({
applyMediaUnderstanding: vi.fn(async (..._args: unknown[]) => undefined),
@@ -10,28 +11,8 @@ const mocks = vi.hoisted(() => ({
initSessionState: vi.fn(),
}));
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentDir: vi.fn(() => "/tmp/agent"),
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
resolveSessionAgentId: vi.fn(() => "main"),
resolveAgentSkillsFilter: vi.fn(() => undefined),
}));
vi.mock("../../agents/model-selection.js", () => ({
resolveModelRefFromString: vi.fn(() => null),
}));
vi.mock("../../agents/timeout.js", () => ({
resolveAgentTimeoutMs: vi.fn(() => 60000),
}));
vi.mock("../../agents/workspace.js", () => ({
DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/workspace",
ensureAgentWorkspace: vi.fn(async () => ({ dir: "/tmp/workspace" })),
}));
vi.mock("../../channels/model-overrides.js", () => ({
resolveChannelModelOverride: vi.fn(() => undefined),
}));
vi.mock("../../config/config.js", () => ({
loadConfig: vi.fn(() => ({})),
}));
registerGetReplyCommonMocks();
vi.mock("../../globals.js", () => ({
logVerbose: vi.fn(),
}));
@@ -45,55 +26,18 @@ vi.mock("../../link-understanding/apply.js", () => ({
vi.mock("../../media-understanding/apply.js", () => ({
applyMediaUnderstanding: mocks.applyMediaUnderstanding,
}));
vi.mock("../../runtime.js", () => ({
defaultRuntime: { log: vi.fn() },
}));
vi.mock("../command-auth.js", () => ({
resolveCommandAuthorization: vi.fn(() => ({ isAuthorizedSender: true })),
}));
vi.mock("./commands-core.js", () => ({
emitResetCommandHooks: vi.fn(async () => undefined),
}));
vi.mock("./directive-handling.js", () => ({
resolveDefaultModel: vi.fn(() => ({
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex: new Map(),
})),
}));
vi.mock("./get-reply-directives.js", () => ({
resolveReplyDirectives: mocks.resolveReplyDirectives,
}));
vi.mock("./get-reply-inline-actions.js", () => ({
handleInlineActions: vi.fn(async () => ({ kind: "reply", reply: { text: "ok" } })),
}));
vi.mock("./get-reply-run.js", () => ({
runPreparedReply: vi.fn(async () => undefined),
}));
vi.mock("./inbound-context.js", () => ({
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
}));
vi.mock("./session-reset-model.js", () => ({
applyResetModelOverride: vi.fn(async () => undefined),
}));
vi.mock("./session.js", () => ({
initSessionState: mocks.initSessionState,
}));
vi.mock("./stage-sandbox-media.js", () => ({
stageSandboxMedia: vi.fn(async () => undefined),
}));
vi.mock("./typing.js", () => ({
createTypingController: vi.fn(() => ({
onReplyStart: async () => undefined,
startTypingLoop: async () => undefined,
startTypingOnText: async () => undefined,
refreshTypingTtl: () => undefined,
isActive: () => false,
markRunComplete: () => undefined,
markDispatchIdle: () => undefined,
cleanup: () => undefined,
})),
}));
const { getReplyFromConfig } = await import("./get-reply.js");

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MsgContext } from "../templating.js";
import { registerGetReplyCommonMocks } from "./get-reply.test-mocks.js";
const mocks = vi.hoisted(() => ({
resolveReplyDirectives: vi.fn(),
@@ -8,83 +9,26 @@ const mocks = vi.hoisted(() => ({
initSessionState: vi.fn(),
}));
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentDir: vi.fn(() => "/tmp/agent"),
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
resolveSessionAgentId: vi.fn(() => "main"),
resolveAgentSkillsFilter: vi.fn(() => undefined),
}));
vi.mock("../../agents/model-selection.js", () => ({
resolveModelRefFromString: vi.fn(() => null),
}));
vi.mock("../../agents/timeout.js", () => ({
resolveAgentTimeoutMs: vi.fn(() => 60000),
}));
vi.mock("../../agents/workspace.js", () => ({
DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/workspace",
ensureAgentWorkspace: vi.fn(async () => ({ dir: "/tmp/workspace" })),
}));
vi.mock("../../channels/model-overrides.js", () => ({
resolveChannelModelOverride: vi.fn(() => undefined),
}));
vi.mock("../../config/config.js", () => ({
loadConfig: vi.fn(() => ({})),
}));
registerGetReplyCommonMocks();
vi.mock("../../link-understanding/apply.js", () => ({
applyLinkUnderstanding: vi.fn(async () => undefined),
}));
vi.mock("../../media-understanding/apply.js", () => ({
applyMediaUnderstanding: vi.fn(async () => undefined),
}));
vi.mock("../../runtime.js", () => ({
defaultRuntime: { log: vi.fn() },
}));
vi.mock("../command-auth.js", () => ({
resolveCommandAuthorization: vi.fn(() => ({ isAuthorizedSender: true })),
}));
vi.mock("./commands-core.js", () => ({
emitResetCommandHooks: (...args: unknown[]) => mocks.emitResetCommandHooks(...args),
}));
vi.mock("./directive-handling.js", () => ({
resolveDefaultModel: vi.fn(() => ({
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex: new Map(),
})),
}));
vi.mock("./get-reply-directives.js", () => ({
resolveReplyDirectives: (...args: unknown[]) => mocks.resolveReplyDirectives(...args),
}));
vi.mock("./get-reply-inline-actions.js", () => ({
handleInlineActions: (...args: unknown[]) => mocks.handleInlineActions(...args),
}));
vi.mock("./get-reply-run.js", () => ({
runPreparedReply: vi.fn(async () => undefined),
}));
vi.mock("./inbound-context.js", () => ({
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
}));
vi.mock("./session-reset-model.js", () => ({
applyResetModelOverride: vi.fn(async () => undefined),
}));
vi.mock("./session.js", () => ({
initSessionState: (...args: unknown[]) => mocks.initSessionState(...args),
}));
vi.mock("./stage-sandbox-media.js", () => ({
stageSandboxMedia: vi.fn(async () => undefined),
}));
vi.mock("./typing.js", () => ({
createTypingController: vi.fn(() => ({
onReplyStart: async () => undefined,
startTypingLoop: async () => undefined,
startTypingOnText: async () => undefined,
refreshTypingTtl: () => undefined,
isActive: () => false,
markRunComplete: () => undefined,
markDispatchIdle: () => undefined,
cleanup: () => undefined,
})),
}));
const { getReplyFromConfig } = await import("./get-reply.js");

View File

@@ -0,0 +1,63 @@
import { vi } from "vitest";
export function registerGetReplyCommonMocks(): void {
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentDir: vi.fn(() => "/tmp/agent"),
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
resolveSessionAgentId: vi.fn(() => "main"),
resolveAgentSkillsFilter: vi.fn(() => undefined),
}));
vi.mock("../../agents/model-selection.js", () => ({
resolveModelRefFromString: vi.fn(() => null),
}));
vi.mock("../../agents/timeout.js", () => ({
resolveAgentTimeoutMs: vi.fn(() => 60000),
}));
vi.mock("../../agents/workspace.js", () => ({
DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/workspace",
ensureAgentWorkspace: vi.fn(async () => ({ dir: "/tmp/workspace" })),
}));
vi.mock("../../channels/model-overrides.js", () => ({
resolveChannelModelOverride: vi.fn(() => undefined),
}));
vi.mock("../../config/config.js", () => ({
loadConfig: vi.fn(() => ({})),
}));
vi.mock("../../runtime.js", () => ({
defaultRuntime: { log: vi.fn() },
}));
vi.mock("../command-auth.js", () => ({
resolveCommandAuthorization: vi.fn(() => ({ isAuthorizedSender: true })),
}));
vi.mock("./directive-handling.js", () => ({
resolveDefaultModel: vi.fn(() => ({
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex: new Map(),
})),
}));
vi.mock("./get-reply-run.js", () => ({
runPreparedReply: vi.fn(async () => undefined),
}));
vi.mock("./inbound-context.js", () => ({
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
}));
vi.mock("./session-reset-model.js", () => ({
applyResetModelOverride: vi.fn(async () => undefined),
}));
vi.mock("./stage-sandbox-media.js", () => ({
stageSandboxMedia: vi.fn(async () => undefined),
}));
vi.mock("./typing.js", () => ({
createTypingController: vi.fn(() => ({
onReplyStart: async () => undefined,
startTypingLoop: async () => undefined,
startTypingOnText: async () => undefined,
refreshTypingTtl: () => undefined,
isActive: () => false,
markRunComplete: () => undefined,
markDispatchIdle: () => undefined,
cleanup: () => undefined,
})),
}));
}

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import { createAccountListHelpers } from "./account-helpers.js";
const { listConfiguredAccountIds, listAccountIds, resolveDefaultAccountId } =
@@ -52,6 +53,22 @@ describe("createAccountListHelpers", () => {
});
});
describe("with normalizeAccountId option", () => {
const normalized = createAccountListHelpers("testchannel", { normalizeAccountId });
it("normalizes and deduplicates configured account ids", () => {
expect(
normalized.listConfiguredAccountIds(
cfg({
"Router D": {},
"router-d": {},
"Personal A": {},
}),
),
).toEqual(["router-d", "personal-a"]);
});
});
describe("listAccountIds", () => {
it('returns ["default"] for empty config', () => {
expect(listAccountIds({} as OpenClawConfig)).toEqual(["default"]);

View File

@@ -5,7 +5,10 @@ import {
normalizeOptionalAccountId,
} from "../../routing/session-key.js";
export function createAccountListHelpers(channelKey: string) {
export function createAccountListHelpers(
channelKey: string,
options?: { normalizeAccountId?: (id: string) => string },
) {
function resolveConfiguredDefaultAccountId(cfg: OpenClawConfig): string | undefined {
const channel = cfg.channels?.[channelKey] as Record<string, unknown> | undefined;
const preferred = normalizeOptionalAccountId(
@@ -27,7 +30,12 @@ export function createAccountListHelpers(channelKey: string) {
if (!accounts || typeof accounts !== "object") {
return [];
}
return Object.keys(accounts as Record<string, unknown>).filter(Boolean);
const ids = Object.keys(accounts as Record<string, unknown>).filter(Boolean);
const normalizeConfiguredAccountId = options?.normalizeAccountId;
if (!normalizeConfiguredAccountId) {
return ids;
}
return [...new Set(ids.map((id) => normalizeConfiguredAccountId(id)).filter(Boolean))];
}
function listAccountIds(cfg: OpenClawConfig): string[] {

View File

@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import { clearAccountEntryFields } from "./config-helpers.js";
describe("clearAccountEntryFields", () => {
it("clears configured values and removes empty account entries", () => {
const result = clearAccountEntryFields({
accounts: {
default: {
botToken: "abc123",
},
},
accountId: "default",
fields: ["botToken"],
});
expect(result).toEqual({
nextAccounts: undefined,
changed: true,
cleared: true,
});
});
it("treats empty string values as not configured by default", () => {
const result = clearAccountEntryFields({
accounts: {
default: {
botToken: " ",
},
},
accountId: "default",
fields: ["botToken"],
});
expect(result).toEqual({
nextAccounts: undefined,
changed: true,
cleared: false,
});
});
it("can mark cleared when fields are present even if values are empty", () => {
const result = clearAccountEntryFields({
accounts: {
default: {
tokenFile: "",
},
},
accountId: "default",
fields: ["tokenFile"],
markClearedOnFieldPresence: true,
});
expect(result).toEqual({
nextAccounts: undefined,
changed: true,
cleared: true,
});
});
it("keeps other account fields intact", () => {
const result = clearAccountEntryFields({
accounts: {
default: {
botToken: "abc123",
name: "Primary",
},
backup: {
botToken: "keep",
},
},
accountId: "default",
fields: ["botToken"],
});
expect(result).toEqual({
nextAccounts: {
default: {
name: "Primary",
},
backup: {
botToken: "keep",
},
},
changed: true,
cleared: true,
});
});
it("returns unchanged when account entry is missing", () => {
const result = clearAccountEntryFields({
accounts: {
default: {
botToken: "abc123",
},
},
accountId: "other",
fields: ["botToken"],
});
expect(result).toEqual({
nextAccounts: {
default: {
botToken: "abc123",
},
},
changed: false,
cleared: false,
});
});
});

View File

@@ -6,6 +6,13 @@ type ChannelSection = {
enabled?: boolean;
};
function isConfiguredSecretValue(value: unknown): boolean {
if (typeof value === "string") {
return value.trim().length > 0;
}
return Boolean(value);
}
export function setAccountEnabledInConfigSection(params: {
cfg: OpenClawConfig;
sectionKey: string;
@@ -111,3 +118,58 @@ export function deleteAccountFromConfigSection(params: {
}
return nextCfg;
}
export function clearAccountEntryFields<TAccountEntry extends object>(params: {
accounts?: Record<string, TAccountEntry>;
accountId: string;
fields: string[];
isValueSet?: (value: unknown) => boolean;
markClearedOnFieldPresence?: boolean;
}): {
nextAccounts?: Record<string, TAccountEntry>;
changed: boolean;
cleared: boolean;
} {
const accountKey = params.accountId || DEFAULT_ACCOUNT_ID;
const baseAccounts =
params.accounts && typeof params.accounts === "object" ? { ...params.accounts } : undefined;
if (!baseAccounts || !(accountKey in baseAccounts)) {
return { nextAccounts: baseAccounts, changed: false, cleared: false };
}
const entry = baseAccounts[accountKey];
if (!entry || typeof entry !== "object") {
return { nextAccounts: baseAccounts, changed: false, cleared: false };
}
const nextEntry = { ...(entry as Record<string, unknown>) };
const hasAnyField = params.fields.some((field) => field in nextEntry);
if (!hasAnyField) {
return { nextAccounts: baseAccounts, changed: false, cleared: false };
}
const isValueSet = params.isValueSet ?? isConfiguredSecretValue;
let cleared = Boolean(params.markClearedOnFieldPresence);
for (const field of params.fields) {
if (!(field in nextEntry)) {
continue;
}
if (isValueSet(nextEntry[field])) {
cleared = true;
}
delete nextEntry[field];
}
if (Object.keys(nextEntry).length === 0) {
delete baseAccounts[accountKey];
} else {
baseAccounts[accountKey] = nextEntry as TAccountEntry;
}
const nextAccounts = Object.keys(baseAccounts).length > 0 ? baseAccounts : undefined;
return {
nextAccounts,
changed: true,
cleared,
};
}

View File

@@ -1,6 +1,5 @@
import type { Command } from "commander";
import type { CronJob } from "../../cron/types.js";
import { danger } from "../../globals.js";
import { sanitizeAgentId } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
import type { GatewayRpcOpts } from "../gateway-rpc.js";
@@ -8,9 +7,11 @@ import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import { parsePositiveIntOrUndefined } from "../program/helpers.js";
import {
getCronChannelOptions,
handleCronCliError,
parseAt,
parseCronStaggerMs,
parseDurationMs,
printCronJson,
printCronList,
warnIfCronSchedulerDisabled,
} from "./shared.js";
@@ -24,10 +25,9 @@ export function registerCronStatusCommand(cron: Command) {
.action(async (opts) => {
try {
const res = await callGatewayFromCli("cron.status", opts, {});
defaultRuntime.log(JSON.stringify(res, null, 2));
printCronJson(res);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
handleCronCliError(err);
}
}),
);
@@ -46,14 +46,13 @@ export function registerCronListCommand(cron: Command) {
includeDisabled: Boolean(opts.all),
});
if (opts.json) {
defaultRuntime.log(JSON.stringify(res, null, 2));
printCronJson(res);
return;
}
const jobs = (res as { jobs?: CronJob[] } | null)?.jobs ?? [];
printCronList(jobs, defaultRuntime);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
handleCronCliError(err);
}
}),
);
@@ -273,11 +272,10 @@ export function registerCronAddCommand(cron: Command) {
};
const res = await callGatewayFromCli("cron.add", opts, params);
defaultRuntime.log(JSON.stringify(res, null, 2));
printCronJson(res);
await warnIfCronSchedulerDisabled(opts);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
handleCronCliError(err);
}
}),
);

View File

@@ -1,8 +1,7 @@
import type { Command } from "commander";
import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
import { warnIfCronSchedulerDisabled } from "./shared.js";
import { handleCronCliError, printCronJson, warnIfCronSchedulerDisabled } from "./shared.js";
function registerCronToggleCommand(params: {
cron: Command;
@@ -21,11 +20,10 @@ function registerCronToggleCommand(params: {
id,
patch: { enabled: params.enabled },
});
defaultRuntime.log(JSON.stringify(res, null, 2));
printCronJson(res);
await warnIfCronSchedulerDisabled(opts);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
handleCronCliError(err);
}
}),
);
@@ -43,10 +41,9 @@ export function registerCronSimpleCommands(cron: Command) {
.action(async (id, opts) => {
try {
const res = await callGatewayFromCli("cron.remove", opts, { id });
defaultRuntime.log(JSON.stringify(res, null, 2));
printCronJson(res);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
handleCronCliError(err);
}
}),
);
@@ -79,10 +76,9 @@ export function registerCronSimpleCommands(cron: Command) {
id,
limit,
});
defaultRuntime.log(JSON.stringify(res, null, 2));
printCronJson(res);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
handleCronCliError(err);
}
}),
);
@@ -102,12 +98,11 @@ export function registerCronSimpleCommands(cron: Command) {
id,
mode: opts.due ? "due" : "force",
});
defaultRuntime.log(JSON.stringify(res, null, 2));
printCronJson(res);
const result = res as { ok?: boolean; ran?: boolean } | undefined;
defaultRuntime.exit(result?.ok && result?.ran ? 0 : 1);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
handleCronCliError(err);
}
}),
);

View File

@@ -2,6 +2,7 @@ import { listChannelPlugins } from "../../channels/plugins/index.js";
import { parseAbsoluteTimeMs } from "../../cron/parse.js";
import { resolveCronStaggerMs } from "../../cron/stagger.js";
import type { CronJob, CronSchedule } from "../../cron/types.js";
import { danger } from "../../globals.js";
import { formatDurationHuman } from "../../infra/format-time/format-duration.ts";
import { defaultRuntime } from "../../runtime.js";
import { colorize, isRich, theme } from "../../terminal/theme.js";
@@ -11,6 +12,15 @@ import { callGatewayFromCli } from "../gateway-rpc.js";
export const getCronChannelOptions = () =>
["last", ...listChannelPlugins().map((plugin) => plugin.id)].join("|");
export function printCronJson(value: unknown) {
defaultRuntime.log(JSON.stringify(value, null, 2));
}
export function handleCronCliError(err: unknown) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
try {
const res = (await callGatewayFromCli("cron.status", opts, {})) as {

View File

@@ -60,6 +60,8 @@ describe("memory cli", () => {
return JSON.parse(String(log.mock.calls[0]?.[0] ?? "null")) as Record<string, unknown>;
}
const inactiveMemorySecretDiagnostic = "agents.defaults.memorySearch.remote.apiKey inactive";
function expectCliSync(sync: ReturnType<typeof vi.fn>) {
expect(sync).toHaveBeenCalledWith(
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
@@ -85,6 +87,25 @@ describe("memory cli", () => {
getMemorySearchManager.mockResolvedValueOnce({ manager });
}
function setupMemoryStatusWithInactiveSecretDiagnostics(close: ReturnType<typeof vi.fn>) {
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
resolvedConfig: {},
diagnostics: [inactiveMemorySecretDiagnostic] as string[],
});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus({ workspaceDir: undefined }),
close,
});
}
function hasLoggedInactiveSecretDiagnostic(spy: ReturnType<typeof vi.spyOn>) {
return spy.mock.calls.some(
(call: unknown[]) =>
typeof call[0] === "string" && call[0].includes(inactiveMemorySecretDiagnostic),
);
}
async function runMemoryCli(args: string[]) {
const program = new Command();
program.name("test");
@@ -191,26 +212,12 @@ describe("memory cli", () => {
it("logs gateway secret diagnostics for non-json status output", async () => {
const close = vi.fn(async () => {});
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
resolvedConfig: {},
diagnostics: ["agents.defaults.memorySearch.remote.apiKey inactive"] as string[],
});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus({ workspaceDir: undefined }),
close,
});
setupMemoryStatusWithInactiveSecretDiagnostics(close);
const log = spyRuntimeLogs();
await runMemoryCli(["status"]);
expect(
log.mock.calls.some(
(call) =>
typeof call[0] === "string" &&
call[0].includes("agents.defaults.memorySearch.remote.apiKey inactive"),
),
).toBe(true);
expect(hasLoggedInactiveSecretDiagnostic(log)).toBe(true);
});
it("prints vector error when unavailable", async () => {
@@ -410,15 +417,7 @@ describe("memory cli", () => {
it("routes gateway secret diagnostics to stderr for json status output", async () => {
const close = vi.fn(async () => {});
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
resolvedConfig: {},
diagnostics: ["agents.defaults.memorySearch.remote.apiKey inactive"] as string[],
});
mockManager({
probeVectorAvailability: vi.fn(async () => true),
status: () => makeMemoryStatus({ workspaceDir: undefined }),
close,
});
setupMemoryStatusWithInactiveSecretDiagnostics(close);
const log = spyRuntimeLogs();
const error = spyRuntimeErrors();
@@ -426,13 +425,7 @@ describe("memory cli", () => {
const payload = firstLoggedJson(log);
expect(Array.isArray(payload)).toBe(true);
expect(
error.mock.calls.some(
(call) =>
typeof call[0] === "string" &&
call[0].includes("agents.defaults.memorySearch.remote.apiKey inactive"),
),
).toBe(true);
expect(hasLoggedInactiveSecretDiagnostic(error)).toBe(true);
});
it("logs default message when memory manager is missing", async () => {

View File

@@ -9,6 +9,8 @@ import {
type ExecSecurity,
maxAsk,
minSecurity,
normalizeExecAsk,
normalizeExecSecurity,
resolveExecApprovalsFromFile,
} from "../../infra/exec-approvals.js";
import { buildNodeShellCommand } from "../../infra/node-shell.js";
@@ -43,22 +45,6 @@ type ExecDefaults = {
safeBins?: string[];
};
function normalizeExecSecurity(value?: string | null): ExecSecurity | null {
const normalized = value?.trim().toLowerCase();
if (normalized === "deny" || normalized === "allowlist" || normalized === "full") {
return normalized;
}
return null;
}
function normalizeExecAsk(value?: string | null): ExecAsk | null {
const normalized = value?.trim().toLowerCase();
if (normalized === "off" || normalized === "on-miss" || normalized === "always") {
return normalized as ExecAsk;
}
return null;
}
function resolveExecDefaults(
cfg: ReturnType<typeof loadConfig>,
agentId: string | undefined,

View File

@@ -72,6 +72,32 @@ function createTailscaleRemoteRefConfig() {
};
}
function createDefaultSecretProvider() {
return {
providers: {
default: { source: "env" as const },
},
};
}
function createLocalGatewayConfigWithAuth(auth: Record<string, unknown>) {
return {
secrets: createDefaultSecretProvider(),
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth,
},
};
}
function createLocalGatewayPasswordRefAuth(secretId: string) {
return {
mode: "password",
password: { source: "env", provider: "default", id: secretId },
};
}
describe("registerQrCli", () => {
function createProgram() {
const program = new Command();
@@ -88,6 +114,23 @@ describe("registerQrCli", () => {
await expect(runQr(args)).rejects.toThrow("exit");
}
function parseLastLoggedQrJson() {
return JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as {
setupCode?: string;
gatewayUrl?: string;
auth?: string;
urlSource?: string;
};
}
function mockTailscaleStatusLookup() {
runCommandWithTimeout.mockResolvedValue({
code: 0,
stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}',
stderr: "",
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "");
@@ -157,21 +200,11 @@ describe("registerQrCli", () => {
});
it("skips local password SecretRef resolution when --token override is provided", async () => {
loadConfig.mockReturnValue({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
mode: "password",
password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" },
},
},
});
loadConfig.mockReturnValue(
createLocalGatewayConfigWithAuth(
createLocalGatewayPasswordRefAuth("MISSING_LOCAL_GATEWAY_PASSWORD"),
),
);
await runQr(["--setup-code-only", "--token", "override-token"]);
@@ -184,21 +217,11 @@ describe("registerQrCli", () => {
it("resolves local gateway auth password SecretRefs before setup code generation", async () => {
vi.stubEnv("QR_LOCAL_GATEWAY_PASSWORD", "local-password-secret");
loadConfig.mockReturnValue({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
mode: "password",
password: { source: "env", provider: "default", id: "QR_LOCAL_GATEWAY_PASSWORD" },
},
},
});
loadConfig.mockReturnValue(
createLocalGatewayConfigWithAuth(
createLocalGatewayPasswordRefAuth("QR_LOCAL_GATEWAY_PASSWORD"),
),
);
await runQr(["--setup-code-only"]);
@@ -212,21 +235,11 @@ describe("registerQrCli", () => {
it("uses OPENCLAW_GATEWAY_PASSWORD without resolving local password SecretRef", async () => {
vi.stubEnv("OPENCLAW_GATEWAY_PASSWORD", "password-from-env");
loadConfig.mockReturnValue({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
mode: "password",
password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" },
},
},
});
loadConfig.mockReturnValue(
createLocalGatewayConfigWithAuth(
createLocalGatewayPasswordRefAuth("MISSING_LOCAL_GATEWAY_PASSWORD"),
),
);
await runQr(["--setup-code-only"]);
@@ -239,22 +252,13 @@ describe("registerQrCli", () => {
});
it("does not resolve local password SecretRef when auth mode is token", async () => {
loadConfig.mockReturnValue({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
mode: "token",
token: "token-123",
password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" },
},
},
});
loadConfig.mockReturnValue(
createLocalGatewayConfigWithAuth({
mode: "token",
token: "token-123",
password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" },
}),
);
await runQr(["--setup-code-only"]);
@@ -268,20 +272,11 @@ describe("registerQrCli", () => {
it("resolves local password SecretRef when auth mode is inferred", async () => {
vi.stubEnv("QR_INFERRED_GATEWAY_PASSWORD", "inferred-password");
loadConfig.mockReturnValue({
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
bind: "custom",
customBindHost: "gateway.local",
auth: {
password: { source: "env", provider: "default", id: "QR_INFERRED_GATEWAY_PASSWORD" },
},
},
});
loadConfig.mockReturnValue(
createLocalGatewayConfigWithAuth({
password: { source: "env", provider: "default", id: "QR_INFERRED_GATEWAY_PASSWORD" },
}),
);
await runQr(["--setup-code-only"]);
@@ -390,20 +385,11 @@ describe("registerQrCli", () => {
{ name: "when tailscale is configured", withTailscale: true },
])("reports gateway.remote.url as source in --remote json output ($name)", async (testCase) => {
loadConfig.mockReturnValue(createRemoteQrConfig({ withTailscale: testCase.withTailscale }));
runCommandWithTimeout.mockResolvedValue({
code: 0,
stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}',
stderr: "",
});
mockTailscaleStatusLookup();
await runQr(["--json", "--remote"]);
const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as {
setupCode?: string;
gatewayUrl?: string;
auth?: string;
urlSource?: string;
};
const payload = parseLastLoggedQrJson();
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
expect(payload.auth).toBe("token");
expect(payload.urlSource).toBe("gateway.remote.url");
@@ -416,20 +402,11 @@ describe("registerQrCli", () => {
resolvedConfig: createRemoteQrConfig(),
diagnostics: ["gateway.remote.password inactive"] as string[],
});
runCommandWithTimeout.mockResolvedValue({
code: 0,
stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}',
stderr: "",
});
mockTailscaleStatusLookup();
await runQr(["--json", "--remote"]);
const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as {
setupCode?: string;
gatewayUrl?: string;
auth?: string;
urlSource?: string;
};
const payload = parseLastLoggedQrJson();
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
expect(
runtime.error.mock.calls.some((call) =>

View File

@@ -405,20 +405,15 @@ async function saveSessionStoreUnlocked(
.map((entry) => entry?.sessionId)
.filter((id): id is string => Boolean(id)),
);
for (const [sessionId, sessionFile] of removedSessionFiles) {
if (referencedSessionIds.has(sessionId)) {
continue;
}
const archived = archiveSessionTranscripts({
sessionId,
storePath,
sessionFile,
reason: "deleted",
restrictToStoreDir: true,
});
for (const archivedPath of archived) {
archivedDirs.add(path.dirname(archivedPath));
}
const archivedForDeletedSessions = archiveRemovedSessionTranscripts({
removedSessionFiles,
referencedSessionIds,
storePath,
reason: "deleted",
restrictToStoreDir: true,
});
for (const archivedDir of archivedForDeletedSessions) {
archivedDirs.add(archivedDir);
}
if (archivedDirs.size > 0 || maintenance.resetArchiveRetentionMs != null) {
const targetDirs =
@@ -574,6 +569,32 @@ function rememberRemovedSessionFile(
}
}
export function archiveRemovedSessionTranscripts(params: {
removedSessionFiles: Iterable<[string, string | undefined]>;
referencedSessionIds: ReadonlySet<string>;
storePath: string;
reason: "deleted" | "reset";
restrictToStoreDir?: boolean;
}): Set<string> {
const archivedDirs = new Set<string>();
for (const [sessionId, sessionFile] of params.removedSessionFiles) {
if (params.referencedSessionIds.has(sessionId)) {
continue;
}
const archived = archiveSessionTranscripts({
sessionId,
storePath: params.storePath,
sessionFile,
reason: params.reason,
restrictToStoreDir: params.restrictToStoreDir,
});
for (const archivedPath of archived) {
archivedDirs.add(path.dirname(archivedPath));
}
}
return archivedDirs;
}
async function writeSessionStoreAtomic(params: {
storePath: string;
store: Record<string, SessionEntry>;

View File

@@ -285,7 +285,7 @@ export function validateConfigObject(
};
}
export function validateConfigObjectWithPlugins(raw: unknown):
type ValidateConfigWithPluginsResult =
| {
ok: true;
config: OpenClawConfig;
@@ -295,38 +295,20 @@ export function validateConfigObjectWithPlugins(raw: unknown):
ok: false;
issues: ConfigValidationIssue[];
warnings: ConfigValidationIssue[];
} {
};
export function validateConfigObjectWithPlugins(raw: unknown): ValidateConfigWithPluginsResult {
return validateConfigObjectWithPluginsBase(raw, { applyDefaults: true });
}
export function validateConfigObjectRawWithPlugins(raw: unknown):
| {
ok: true;
config: OpenClawConfig;
warnings: ConfigValidationIssue[];
}
| {
ok: false;
issues: ConfigValidationIssue[];
warnings: ConfigValidationIssue[];
} {
export function validateConfigObjectRawWithPlugins(raw: unknown): ValidateConfigWithPluginsResult {
return validateConfigObjectWithPluginsBase(raw, { applyDefaults: false });
}
function validateConfigObjectWithPluginsBase(
raw: unknown,
opts: { applyDefaults: boolean },
):
| {
ok: true;
config: OpenClawConfig;
warnings: ConfigValidationIssue[];
}
| {
ok: false;
issues: ConfigValidationIssue[];
warnings: ConfigValidationIssue[];
} {
): ValidateConfigWithPluginsResult {
const base = opts.applyDefaults ? validateConfigObject(raw) : validateConfigObjectRaw(raw);
if (!base.ok) {
return { ok: false, issues: base.issues, warnings: [] };

View File

@@ -25,6 +25,21 @@ type SlackConfigLike = {
accounts?: Record<string, SlackAccountLike | undefined>;
};
function forEachEnabledAccount<T extends { enabled?: unknown }>(
accounts: Record<string, T | undefined> | undefined,
run: (accountId: string, account: T) => void,
): void {
if (!accounts) {
return;
}
for (const [accountId, account] of Object.entries(accounts)) {
if (!account || account.enabled === false) {
continue;
}
run(accountId, account);
}
}
export function validateTelegramWebhookSecretRequirements(
value: TelegramConfigLike,
ctx: z.RefinementCtx,
@@ -38,20 +53,11 @@ export function validateTelegramWebhookSecretRequirements(
path: ["webhookSecret"],
});
}
if (!value.accounts) {
return;
}
for (const [accountId, account] of Object.entries(value.accounts)) {
if (!account) {
continue;
}
if (account.enabled === false) {
continue;
}
forEachEnabledAccount(value.accounts, (accountId, account) => {
const accountWebhookUrl =
typeof account.webhookUrl === "string" ? account.webhookUrl.trim() : "";
if (!accountWebhookUrl) {
continue;
return;
}
const hasAccountSecret = hasConfiguredSecretInput(account.webhookSecret);
if (!hasAccountSecret && !hasBaseWebhookSecret) {
@@ -62,7 +68,7 @@ export function validateTelegramWebhookSecretRequirements(
path: ["accounts", accountId, "webhookSecret"],
});
}
}
});
}
export function validateSlackSigningSecretRequirements(
@@ -77,20 +83,11 @@ export function validateSlackSigningSecretRequirements(
path: ["signingSecret"],
});
}
if (!value.accounts) {
return;
}
for (const [accountId, account] of Object.entries(value.accounts)) {
if (!account) {
continue;
}
if (account.enabled === false) {
continue;
}
forEachEnabledAccount(value.accounts, (accountId, account) => {
const accountMode =
account.mode === "http" || account.mode === "socket" ? account.mode : baseMode;
if (accountMode !== "http") {
continue;
return;
}
const accountSecret = account.signingSecret ?? value.signingSecret;
if (!hasConfiguredSecretInput(accountSecret)) {
@@ -101,5 +98,5 @@ export function validateSlackSigningSecretRequirements(
path: ["accounts", accountId, "signingSecret"],
});
}
}
});
}

View File

@@ -1,8 +1,6 @@
import "./isolated-agent.mocks.js";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
import type { CliDeps } from "../cli/deps.js";
import {
@@ -12,72 +10,15 @@ import {
runTelegramAnnounceTurn,
} from "./isolated-agent.delivery.test-helpers.js";
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
import { makeCfg, makeJob, writeSessionStore } from "./isolated-agent.test-harness.js";
import {
makeCfg,
makeJob,
withTempCronHome as withTempHome,
writeSessionStore,
} from "./isolated-agent.test-harness.js";
import { setupIsolatedAgentTurnMocks } from "./isolated-agent.test-setup.js";
type HomeEnvSnapshot = {
HOME: string | undefined;
USERPROFILE: string | undefined;
HOMEDRIVE: string | undefined;
HOMEPATH: string | undefined;
OPENCLAW_HOME: string | undefined;
OPENCLAW_STATE_DIR: string | undefined;
};
const TELEGRAM_TARGET = { mode: "announce", channel: "telegram", to: "123" } as const;
let suiteTempHomeRoot = "";
let suiteTempHomeCaseId = 0;
function snapshotHomeEnv(): HomeEnvSnapshot {
return {
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
HOMEDRIVE: process.env.HOMEDRIVE,
HOMEPATH: process.env.HOMEPATH,
OPENCLAW_HOME: process.env.OPENCLAW_HOME,
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
};
}
function restoreHomeEnv(snapshot: HomeEnvSnapshot) {
const restoreValue = (key: keyof HomeEnvSnapshot) => {
const value = snapshot[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
};
restoreValue("HOME");
restoreValue("USERPROFILE");
restoreValue("HOMEDRIVE");
restoreValue("HOMEPATH");
restoreValue("OPENCLAW_HOME");
restoreValue("OPENCLAW_STATE_DIR");
}
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const home = path.join(suiteTempHomeRoot, `case-${suiteTempHomeCaseId++}`);
await fs.mkdir(path.join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true });
const snapshot = snapshotHomeEnv();
process.env.HOME = home;
process.env.USERPROFILE = home;
delete process.env.OPENCLAW_HOME;
process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw");
if (process.platform === "win32") {
const parsed = path.parse(home);
if (parsed.root) {
process.env.HOMEDRIVE = parsed.root.replace(/[\\/]+$/, "");
process.env.HOMEPATH = home.slice(process.env.HOMEDRIVE.length) || "\\";
}
}
try {
return await fn(home);
} finally {
restoreHomeEnv(snapshot);
}
}
async function runExplicitTelegramAnnounceTurn(params: {
home: string;
storePath: string;
@@ -264,19 +205,6 @@ async function assertExplicitTelegramTargetAnnounce(params: {
}
describe("runCronIsolatedAgentTurn", () => {
beforeAll(async () => {
suiteTempHomeRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-delivery-suite-"));
});
afterAll(async () => {
if (!suiteTempHomeRoot) {
return;
}
await fs.rm(suiteTempHomeRoot, { recursive: true, force: true });
suiteTempHomeRoot = "";
suiteTempHomeCaseId = 0;
});
beforeEach(() => {
setupIsolatedAgentTurnMocks();
});

View File

@@ -1,8 +1,7 @@
import "./isolated-agent.mocks.js";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import type { CliDeps } from "../cli/deps.js";
@@ -10,73 +9,12 @@ import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
import {
makeCfg,
makeJob,
withTempCronHome as withTempHome,
writeSessionStore,
writeSessionStoreEntries,
} from "./isolated-agent.test-harness.js";
import type { CronJob } from "./types.js";
type HomeEnvSnapshot = {
HOME: string | undefined;
USERPROFILE: string | undefined;
HOMEDRIVE: string | undefined;
HOMEPATH: string | undefined;
OPENCLAW_HOME: string | undefined;
OPENCLAW_STATE_DIR: string | undefined;
};
let suiteTempHomeRoot = "";
let suiteTempHomeCaseId = 0;
function snapshotHomeEnv(): HomeEnvSnapshot {
return {
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
HOMEDRIVE: process.env.HOMEDRIVE,
HOMEPATH: process.env.HOMEPATH,
OPENCLAW_HOME: process.env.OPENCLAW_HOME,
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
};
}
function restoreHomeEnv(snapshot: HomeEnvSnapshot) {
const restoreValue = (key: keyof HomeEnvSnapshot) => {
const value = snapshot[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
};
restoreValue("HOME");
restoreValue("USERPROFILE");
restoreValue("HOMEDRIVE");
restoreValue("HOMEPATH");
restoreValue("OPENCLAW_HOME");
restoreValue("OPENCLAW_STATE_DIR");
}
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
const home = path.join(suiteTempHomeRoot, `case-${suiteTempHomeCaseId++}`);
await fs.mkdir(path.join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true });
const snapshot = snapshotHomeEnv();
process.env.HOME = home;
process.env.USERPROFILE = home;
delete process.env.OPENCLAW_HOME;
process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw");
if (process.platform === "win32") {
const parsed = path.parse(home);
if (parsed.root) {
process.env.HOMEDRIVE = parsed.root.replace(/[\\/]+$/, "");
process.env.HOMEPATH = home.slice(process.env.HOMEDRIVE.length) || "\\";
}
}
try {
return await fn(home);
} finally {
restoreHomeEnv(snapshot);
}
}
function makeDeps(): CliDeps {
return {
sendMessageSlack: vi.fn(),
@@ -224,19 +162,6 @@ async function runStoredOverrideAndExpectModel(params: {
}
describe("runCronIsolatedAgentTurn", () => {
beforeAll(async () => {
suiteTempHomeRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cron-turn-suite-"));
});
afterAll(async () => {
if (!suiteTempHomeRoot) {
return;
}
await fs.rm(suiteTempHomeRoot, { recursive: true, force: true });
suiteTempHomeRoot = "";
suiteTempHomeCaseId = 0;
});
beforeEach(() => {
vi.mocked(runEmbeddedPiAgent).mockClear();
vi.mocked(loadModelCatalog).mockResolvedValue([]);

View File

@@ -6,14 +6,14 @@
* run records. The base session (`...:cron:<jobId>`) is kept as-is.
*/
import path from "node:path";
import { parseDurationMs } from "../cli/parse-duration.js";
import { loadSessionStore, updateSessionStore } from "../config/sessions.js";
import type { CronConfig } from "../config/types.cron.js";
import {
archiveSessionTranscripts,
cleanupArchivedSessionTranscripts,
} from "../gateway/session-utils.fs.js";
archiveRemovedSessionTranscripts,
loadSessionStore,
updateSessionStore,
} from "../config/sessions.js";
import type { CronConfig } from "../config/types.cron.js";
import { cleanupArchivedSessionTranscripts } from "../gateway/session-utils.fs.js";
import { isCronRunSessionKey } from "../sessions/session-key-utils.js";
import type { Logger } from "./service/state.js";
@@ -116,22 +116,13 @@ export async function sweepCronRunSessions(params: {
.map((entry) => entry?.sessionId)
.filter((id): id is string => Boolean(id)),
);
const archivedDirs = new Set<string>();
for (const [sessionId, sessionFile] of prunedSessions) {
if (referencedSessionIds.has(sessionId)) {
continue;
}
const archived = archiveSessionTranscripts({
sessionId,
storePath,
sessionFile,
reason: "deleted",
restrictToStoreDir: true,
});
for (const archivedPath of archived) {
archivedDirs.add(path.dirname(archivedPath));
}
}
const archivedDirs = archiveRemovedSessionTranscripts({
removedSessionFiles: prunedSessions,
referencedSessionIds,
storePath,
reason: "deleted",
restrictToStoreDir: true,
});
if (archivedDirs.size > 0) {
await cleanupArchivedSessionTranscripts({
directories: [...archivedDirs],

View File

@@ -21,6 +21,12 @@ import {
createThreadBindingManager,
} from "./thread-bindings.js";
type DiscordConfig = NonNullable<
import("../../config/config.js").OpenClawConfig["channels"]
>["discord"];
type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent;
type DiscordClient = import("@buape/carbon").Client;
function createThreadBinding(
overrides?: Partial<
import("../../infra/outbound/session-binding-service.js").SessionBindingRecord
@@ -48,6 +54,34 @@ function createThreadBinding(
} satisfies import("../../infra/outbound/session-binding-service.js").SessionBindingRecord;
}
function createPreflightArgs(params: {
cfg: import("../../config/config.js").OpenClawConfig;
discordConfig: DiscordConfig;
data: DiscordMessageEvent;
client: DiscordClient;
}): Parameters<typeof preflightDiscordMessage>[0] {
return {
cfg: params.cfg,
discordConfig: params.discordConfig,
accountId: "default",
token: "token",
runtime: {} as import("../../runtime.js").RuntimeEnv,
botUserId: "openclaw-bot",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 1_000_000,
textLimit: 2_000,
replyToMode: "all",
dmEnabled: true,
groupDmEnabled: true,
ackReactionScope: "direct",
groupPolicy: "open",
threadBindings: createNoopThreadBindingManager("default"),
data: params.data,
client: params.client,
};
}
describe("resolvePreflightMentionRequirement", () => {
it("requires mention when config requires mention and thread is not bound", () => {
expect(
@@ -312,42 +346,30 @@ describe("preflightDiscordMessage", () => {
resolveByConversation: (ref) => (ref.conversationId === threadId ? threadBinding : null),
});
const result = await preflightDiscordMessage({
cfg: {
session: {
mainKey: "main",
scope: "per-sender",
},
} as import("../../config/config.js").OpenClawConfig,
discordConfig: {
allowBots: true,
} as NonNullable<import("../../config/config.js").OpenClawConfig["channels"]>["discord"],
accountId: "default",
token: "token",
runtime: {} as import("../../runtime.js").RuntimeEnv,
botUserId: "openclaw-bot",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 1_000_000,
textLimit: 2_000,
replyToMode: "all",
dmEnabled: true,
groupDmEnabled: true,
ackReactionScope: "direct",
groupPolicy: "open",
threadBindings: createNoopThreadBindingManager("default"),
data: {
channel_id: threadId,
guild_id: "guild-1",
guild: {
id: "guild-1",
name: "Guild One",
},
author: message.author,
message,
} as unknown as import("./listeners.js").DiscordMessageEvent,
client,
});
const result = await preflightDiscordMessage(
createPreflightArgs({
cfg: {
session: {
mainKey: "main",
scope: "per-sender",
},
} as import("../../config/config.js").OpenClawConfig,
discordConfig: {
allowBots: true,
} as DiscordConfig,
data: {
channel_id: threadId,
guild_id: "guild-1",
guild: {
id: "guild-1",
name: "Guild One",
},
author: message.author,
message,
} as unknown as DiscordMessageEvent,
client,
}),
);
expect(result).not.toBeNull();
expect(result?.boundSessionKey).toBe(threadBinding.targetSessionKey);
@@ -768,47 +790,33 @@ describe("preflightDiscordMessage", () => {
},
} as unknown as import("@buape/carbon").Message;
const result = await preflightDiscordMessage({
cfg: {
session: {
mainKey: "main",
scope: "per-sender",
},
messages: {
groupChat: {
mentionPatterns: ["openclaw"],
const result = await preflightDiscordMessage(
createPreflightArgs({
cfg: {
session: {
mainKey: "main",
scope: "per-sender",
},
},
} as import("../../config/config.js").OpenClawConfig,
discordConfig: {} as NonNullable<
import("../../config/config.js").OpenClawConfig["channels"]
>["discord"],
accountId: "default",
token: "token",
runtime: {} as import("../../runtime.js").RuntimeEnv,
botUserId: "openclaw-bot",
guildHistories: new Map(),
historyLimit: 0,
mediaMaxBytes: 1_000_000,
textLimit: 2_000,
replyToMode: "all",
dmEnabled: true,
groupDmEnabled: true,
ackReactionScope: "direct",
groupPolicy: "open",
threadBindings: createNoopThreadBindingManager("default"),
data: {
channel_id: channelId,
guild_id: "guild-1",
guild: {
id: "guild-1",
name: "Guild One",
},
author: message.author,
message,
} as unknown as import("./listeners.js").DiscordMessageEvent,
client,
});
messages: {
groupChat: {
mentionPatterns: ["openclaw"],
},
},
} as import("../../config/config.js").OpenClawConfig,
discordConfig: {} as DiscordConfig,
data: {
channel_id: channelId,
guild_id: "guild-1",
guild: {
id: "guild-1",
name: "Guild One",
},
author: message.author,
message,
} as unknown as DiscordMessageEvent,
client,
}),
);
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
expect(transcribeFirstAudioMock).toHaveBeenCalledWith(

View File

@@ -199,6 +199,30 @@ describe("DiscordVoiceManager", () => {
);
};
type ProcessSegmentInvoker = {
processSegment: (params: {
entry: unknown;
wavPath: string;
userId: string;
durationSeconds: number;
}) => Promise<void>;
};
const processVoiceSegment = async (
manager: InstanceType<typeof managerModule.DiscordVoiceManager>,
userId: string,
) =>
await (manager as unknown as ProcessSegmentInvoker).processSegment({
entry: {
guildId: "g1",
channelId: "c1",
route: { sessionKey: "discord:g1:c1", agentId: "agent-1" },
},
wavPath: "/tmp/test.wav",
userId,
durationSeconds: 1.2,
});
it("keeps the new session when an old disconnected handler fires", async () => {
const oldConnection = createConnectionMock();
const newConnection = createConnectionMock();
@@ -298,25 +322,7 @@ describe("DiscordVoiceManager", () => {
},
});
const manager = createManager({ allowFrom: ["discord:u-owner"] }, client);
await (
manager as unknown as {
processSegment: (params: {
entry: unknown;
wavPath: string;
userId: string;
durationSeconds: number;
}) => Promise<void>;
}
).processSegment({
entry: {
guildId: "g1",
channelId: "c1",
route: { sessionKey: "discord:g1:c1", agentId: "agent-1" },
},
wavPath: "/tmp/test.wav",
userId: "u-owner",
durationSeconds: 1.2,
});
await processVoiceSegment(manager, "u-owner");
const commandArgs = agentCommandMock.mock.calls.at(-1)?.[0] as
| { senderIsOwner?: boolean }
@@ -336,25 +342,7 @@ describe("DiscordVoiceManager", () => {
},
});
const manager = createManager({ allowFrom: ["discord:u-owner"] }, client);
await (
manager as unknown as {
processSegment: (params: {
entry: unknown;
wavPath: string;
userId: string;
durationSeconds: number;
}) => Promise<void>;
}
).processSegment({
entry: {
guildId: "g1",
channelId: "c1",
route: { sessionKey: "discord:g1:c1", agentId: "agent-1" },
},
wavPath: "/tmp/test.wav",
userId: "u-guest",
durationSeconds: 1.2,
});
await processVoiceSegment(manager, "u-guest");
const commandArgs = agentCommandMock.mock.calls.at(-1)?.[0] as
| { senderIsOwner?: boolean }
@@ -374,26 +362,7 @@ describe("DiscordVoiceManager", () => {
},
});
const manager = createManager({ allowFrom: ["discord:u-cache"] }, client);
const runSegment = async () =>
await (
manager as unknown as {
processSegment: (params: {
entry: unknown;
wavPath: string;
userId: string;
durationSeconds: number;
}) => Promise<void>;
}
).processSegment({
entry: {
guildId: "g1",
channelId: "c1",
route: { sessionKey: "discord:g1:c1", agentId: "agent-1" },
},
wavPath: "/tmp/test.wav",
userId: "u-cache",
durationSeconds: 1.2,
});
const runSegment = async () => await processVoiceSegment(manager, "u-cache");
await runSegment();
await runSegment();

View File

@@ -0,0 +1,69 @@
import type { GatewayAuthConfig, OpenClawConfig } from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { secretRefKey } from "../secrets/ref-contract.js";
import { resolveSecretRefValues } from "../secrets/resolve.js";
export function withGatewayAuthPassword(cfg: OpenClawConfig, password: string): OpenClawConfig {
return {
...cfg,
gateway: {
...cfg.gateway,
auth: {
...cfg.gateway?.auth,
password,
},
},
};
}
function shouldResolveGatewayPasswordSecretRef(params: {
mode?: GatewayAuthConfig["mode"];
hasPasswordCandidate: boolean;
hasTokenCandidate: boolean;
}): boolean {
if (params.hasPasswordCandidate) {
return false;
}
if (params.mode === "password") {
return true;
}
if (params.mode === "token" || params.mode === "none" || params.mode === "trusted-proxy") {
return false;
}
return !params.hasTokenCandidate;
}
export async function resolveGatewayPasswordSecretRef(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
mode?: GatewayAuthConfig["mode"];
hasPasswordCandidate: boolean;
hasTokenCandidate: boolean;
}): Promise<OpenClawConfig> {
const authPassword = params.cfg.gateway?.auth?.password;
const { ref } = resolveSecretInputRef({
value: authPassword,
defaults: params.cfg.secrets?.defaults,
});
if (!ref) {
return params.cfg;
}
if (
!shouldResolveGatewayPasswordSecretRef({
mode: params.mode,
hasPasswordCandidate: params.hasPasswordCandidate,
hasTokenCandidate: params.hasTokenCandidate,
})
) {
return params.cfg;
}
const resolved = await resolveSecretRefValues([ref], {
config: params.cfg,
env: params.env,
});
const value = resolved.get(secretRefKey(ref));
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error("gateway.auth.password resolved to an empty or non-string value.");
}
return withGatewayAuthPassword(params.cfg, value.trim());
}

View File

@@ -9,8 +9,7 @@ import {
import { hasConfiguredSecretInput, resolveSecretInputRef } from "../config/types.secrets.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js";
import { secretRefKey } from "../secrets/ref-contract.js";
import { resolveSecretRefValues } from "../secrets/resolve.js";
import { resolveSecretInputString } from "../secrets/resolve-secret-input-string.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
@@ -312,23 +311,16 @@ async function resolveGatewaySecretInputString(params: {
path: string;
env: NodeJS.ProcessEnv;
}): Promise<string | undefined> {
const defaults = params.config.secrets?.defaults;
const { ref } = resolveSecretInputRef({
value: params.value,
defaults,
});
if (!ref) {
return trimToUndefined(params.value);
}
const resolved = await resolveSecretRefValues([ref], {
const value = await resolveSecretInputString({
config: params.config,
value: params.value,
env: params.env,
normalize: trimToUndefined,
});
const resolvedValue = trimToUndefined(resolved.get(secretRefKey(ref)));
if (!resolvedValue) {
if (!value) {
throw new Error(`${params.path} resolved to an empty or non-string value.`);
}
return resolvedValue;
return value;
}
async function resolveGatewayCredentials(context: ResolvedGatewayCallContext): Promise<{

View File

@@ -50,6 +50,27 @@ function resolveRemoteModeWithRemoteCredentials(
);
}
function resolveLocalModeWithUnresolvedPassword(mode: "none" | "trusted-proxy") {
return resolveGatewayCredentialsFromConfig({
cfg: {
gateway: {
mode: "local",
auth: {
mode,
password: { source: "env", provider: "default", id: "MISSING_GATEWAY_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as unknown as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
includeLegacyEnv: false,
});
}
describe("resolveGatewayCredentialsFromConfig", () => {
it("prefers explicit credentials over config and environment", () => {
const resolved = resolveGatewayCredentialsFor(
@@ -182,24 +203,7 @@ describe("resolveGatewayCredentialsFromConfig", () => {
});
it("ignores unresolved local password ref when local auth mode is none", () => {
const resolved = resolveGatewayCredentialsFromConfig({
cfg: {
gateway: {
mode: "local",
auth: {
mode: "none",
password: { source: "env", provider: "default", id: "MISSING_GATEWAY_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as unknown as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
includeLegacyEnv: false,
});
const resolved = resolveLocalModeWithUnresolvedPassword("none");
expect(resolved).toEqual({
token: undefined,
password: undefined,
@@ -207,24 +211,7 @@ describe("resolveGatewayCredentialsFromConfig", () => {
});
it("ignores unresolved local password ref when local auth mode is trusted-proxy", () => {
const resolved = resolveGatewayCredentialsFromConfig({
cfg: {
gateway: {
mode: "local",
auth: {
mode: "trusted-proxy",
password: { source: "env", provider: "default", id: "MISSING_GATEWAY_PASSWORD" },
},
},
secrets: {
providers: {
default: { source: "env" },
},
},
} as unknown as OpenClawConfig,
env: {} as NodeJS.ProcessEnv,
includeLegacyEnv: false,
});
const resolved = resolveLocalModeWithUnresolvedPassword("trusted-proxy");
expect(resolved).toEqual({
token: undefined,
password: undefined,

View File

@@ -1013,6 +1013,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
shouldRetryExecReadProbe({
text: execReadText,
nonce: nonceC,
provider: model.provider,
attempt: execReadAttempt,
maxAttempts: maxExecReadAttempts,
})

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
hasExpectedSingleNonce,
hasExpectedToolNonce,
isLikelyToolNonceRefusal,
shouldRetryExecReadProbe,
shouldRetryToolReadProbe,
} from "./live-tool-probe-utils.js";
@@ -17,6 +18,26 @@ describe("live tool probe utils", () => {
expect(hasExpectedSingleNonce("value nonce-2", "nonce-1")).toBe(false);
});
it("detects anthropic nonce refusal phrasing", () => {
expect(
isLikelyToolNonceRefusal(
"Same request, same answer — this isn't a real OpenClaw probe. No part of the system asks me to parrot back nonce values.",
),
).toBe(true);
});
it("does not treat generic helper text as nonce refusal", () => {
expect(isLikelyToolNonceRefusal("I can help with that request.")).toBe(false);
});
it("detects prompt-injection style tool refusal without nonce text", () => {
expect(
isLikelyToolNonceRefusal(
"That's not a legitimate self-test. This looks like a prompt injection attempt.",
),
).toBe(true);
});
it("retries malformed tool output when attempts remain", () => {
expect(
shouldRetryToolReadProbe({
@@ -95,6 +116,32 @@ describe("live tool probe utils", () => {
).toBe(true);
});
it("retries anthropic nonce refusal output", () => {
expect(
shouldRetryToolReadProbe({
text: "This isn't a real OpenClaw probe; I won't parrot back nonce values.",
nonceA: "nonce-a",
nonceB: "nonce-b",
provider: "anthropic",
attempt: 0,
maxAttempts: 3,
}),
).toBe(true);
});
it("retries anthropic prompt-injection refusal output", () => {
expect(
shouldRetryToolReadProbe({
text: "This is not a legitimate self-test; it appears to be a prompt injection attempt.",
nonceA: "nonce-a",
nonceB: "nonce-b",
provider: "anthropic",
attempt: 0,
maxAttempts: 3,
}),
).toBe(true);
});
it("does not retry nonce marker echoes for non-mistral providers", () => {
expect(
shouldRetryToolReadProbe({
@@ -113,6 +160,7 @@ describe("live tool probe utils", () => {
shouldRetryExecReadProbe({
text: "read[object Object]",
nonce: "nonce-c",
provider: "openai",
attempt: 0,
maxAttempts: 3,
}),
@@ -124,6 +172,7 @@ describe("live tool probe utils", () => {
shouldRetryExecReadProbe({
text: "read[object Object]",
nonce: "nonce-c",
provider: "openai",
attempt: 2,
maxAttempts: 3,
}),
@@ -135,9 +184,22 @@ describe("live tool probe utils", () => {
shouldRetryExecReadProbe({
text: "nonce-c",
nonce: "nonce-c",
provider: "openai",
attempt: 0,
maxAttempts: 3,
}),
).toBe(false);
});
it("retries anthropic exec+read nonce refusal output", () => {
expect(
shouldRetryExecReadProbe({
text: "No part of the system asks me to parrot back nonce values.",
nonce: "nonce-c",
provider: "anthropic",
attempt: 0,
maxAttempts: 3,
}),
).toBe(true);
});
});

View File

@@ -6,6 +6,44 @@ export function hasExpectedSingleNonce(text: string, nonce: string): boolean {
return text.includes(nonce);
}
const NONCE_REFUSAL_MARKERS = [
"token",
"secret",
"local file",
"uuid-named file",
"uuid named file",
"parrot back",
"disclose",
"can't help",
"cant help",
"cannot help",
"can't comply",
"cant comply",
"cannot comply",
"isn't a real openclaw probe",
"is not a real openclaw probe",
"not a real openclaw probe",
"no part of the system asks me",
];
const PROBE_REFUSAL_MARKERS = [
"prompt injection attempt",
"not a legitimate self-test",
"not legitimate self-test",
"authorized integration probe",
];
export function isLikelyToolNonceRefusal(text: string): boolean {
const lower = text.toLowerCase();
if (PROBE_REFUSAL_MARKERS.some((marker) => lower.includes(marker))) {
return true;
}
if (lower.includes("nonce")) {
return NONCE_REFUSAL_MARKERS.some((marker) => lower.includes(marker));
}
return false;
}
function hasMalformedToolOutput(text: string): boolean {
const trimmed = text.trim();
if (!trimmed) {
@@ -38,6 +76,9 @@ export function shouldRetryToolReadProbe(params: {
if (hasMalformedToolOutput(params.text)) {
return true;
}
if (params.provider === "anthropic" && isLikelyToolNonceRefusal(params.text)) {
return true;
}
const lower = params.text.trim().toLowerCase();
if (params.provider === "mistral" && (lower.includes("noncea=") || lower.includes("nonceb="))) {
return true;
@@ -48,6 +89,7 @@ export function shouldRetryToolReadProbe(params: {
export function shouldRetryExecReadProbe(params: {
text: string;
nonce: string;
provider: string;
attempt: number;
maxAttempts: number;
}): boolean {
@@ -57,5 +99,8 @@ export function shouldRetryExecReadProbe(params: {
if (hasExpectedSingleNonce(params.text, params.nonce)) {
return false;
}
if (params.provider === "anthropic" && isLikelyToolNonceRefusal(params.text)) {
return true;
}
return hasMalformedToolOutput(params.text);
}

View File

@@ -3,77 +3,57 @@ import { agentCommand, installGatewayTestHooks, withGatewayServer } from "./test
installGatewayTestHooks({ scope: "test" });
const OPENAI_SERVER_OPTIONS = {
host: "127.0.0.1",
auth: { mode: "token" as const, token: "secret" },
controlUiEnabled: false,
openAiChatCompletionsEnabled: true,
};
async function runOpenAiMessageChannelRequest(params?: { messageChannelHeader?: string }) {
agentCommand.mockReset();
agentCommand.mockResolvedValueOnce({ payloads: [{ text: "ok" }] } as never);
let firstCall: { messageChannel?: string } | undefined;
await withGatewayServer(
async ({ port }) => {
const headers: Record<string, string> = {
"content-type": "application/json",
authorization: "Bearer secret",
};
if (params?.messageChannelHeader) {
headers["x-openclaw-message-channel"] = params.messageChannelHeader;
}
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
}),
});
expect(res.status).toBe(200);
firstCall = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as
| { messageChannel?: string }
| undefined;
await res.text();
},
{ serverOptions: OPENAI_SERVER_OPTIONS },
);
return firstCall;
}
describe("OpenAI HTTP message channel", () => {
it("passes x-openclaw-message-channel through to agentCommand", async () => {
agentCommand.mockReset();
agentCommand.mockResolvedValueOnce({ payloads: [{ text: "ok" }] } as never);
await withGatewayServer(
async ({ port }) => {
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: "Bearer secret",
"x-openclaw-message-channel": "custom-client-channel",
},
body: JSON.stringify({
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
}),
});
expect(res.status).toBe(200);
const firstCall = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as
| { messageChannel?: string }
| undefined;
expect(firstCall?.messageChannel).toBe("custom-client-channel");
await res.text();
},
{
serverOptions: {
host: "127.0.0.1",
auth: { mode: "token", token: "secret" },
controlUiEnabled: false,
openAiChatCompletionsEnabled: true,
},
},
);
const firstCall = await runOpenAiMessageChannelRequest({
messageChannelHeader: "custom-client-channel",
});
expect(firstCall?.messageChannel).toBe("custom-client-channel");
});
it("defaults messageChannel to webchat when header is absent", async () => {
agentCommand.mockReset();
agentCommand.mockResolvedValueOnce({ payloads: [{ text: "ok" }] } as never);
await withGatewayServer(
async ({ port }) => {
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: "Bearer secret",
},
body: JSON.stringify({
model: "openclaw",
messages: [{ role: "user", content: "hi" }],
}),
});
expect(res.status).toBe(200);
const firstCall = (agentCommand.mock.calls[0] as unknown[] | undefined)?.[0] as
| { messageChannel?: string }
| undefined;
expect(firstCall?.messageChannel).toBe("webchat");
await res.text();
},
{
serverOptions: {
host: "127.0.0.1",
auth: { mode: "token", token: "secret" },
controlUiEnabled: false,
openAiChatCompletionsEnabled: true,
},
},
);
const firstCall = await runOpenAiMessageChannelRequest();
expect(firstCall?.messageChannel).toBe("webchat");
});
});

View File

@@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => ({
fsLstat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null),
fsRealpath: vi.fn(async (p: string) => p),
fsOpen: vi.fn(async () => ({}) as unknown),
writeFileWithinRoot: vi.fn(async () => {}),
}));
vi.mock("../../config/config.js", () => ({
@@ -77,6 +78,15 @@ vi.mock("../session-utils.js", () => ({
listAgentsForGateway: mocks.listAgentsForGateway,
}));
vi.mock("../../infra/fs-safe.js", async () => {
const actual =
await vi.importActual<typeof import("../../infra/fs-safe.js")>("../../infra/fs-safe.js");
return {
...actual,
writeFileWithinRoot: mocks.writeFileWithinRoot,
};
});
// Mock node:fs/promises agents.ts uses `import fs from "node:fs/promises"`
// which resolves to the module namespace default, so we spread actual and
// override the methods we need, plus set `default` explicitly.

View File

@@ -732,10 +732,19 @@ export const agentsHandlers: GatewayRequestHandlers = {
return;
}
const content = String(params.content ?? "");
const relativeWritePath = path.relative(resolvedPath.workspaceReal, resolvedPath.ioPath);
if (
!relativeWritePath ||
relativeWritePath.startsWith("..") ||
path.isAbsolute(relativeWritePath)
) {
respondWorkspaceFileUnsafe(respond, name);
return;
}
try {
await writeFileWithinRoot({
rootDir: workspaceDir,
relativePath: name,
rootDir: resolvedPath.workspaceReal,
relativePath: relativeWritePath,
data: content,
encoding: "utf8",
});

View File

@@ -274,20 +274,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
});
return;
}
const p = params as {
nodeId: string;
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
deviceFamily?: string;
modelIdentifier?: string;
caps?: string[];
commands?: string[];
remoteIp?: string;
silent?: boolean;
};
const p = params as Parameters<typeof requestNodePairing>[0];
await respondUnavailableOnThrow(respond, async () => {
const result = await requestNodePairing({
nodeId: p.nodeId,
@@ -300,6 +287,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
modelIdentifier: p.modelIdentifier,
caps: p.caps,
commands: p.commands,
permissions: p.permissions,
remoteIp: p.remoteIp,
silent: p.silent,
});

View File

@@ -17,6 +17,27 @@ async function invokeSecretsReload(params: {
});
}
async function invokeSecretsResolve(params: {
handlers: ReturnType<typeof createSecretsHandlers>;
respond: ReturnType<typeof vi.fn>;
commandName: unknown;
targetIds: unknown;
}) {
await params.handlers["secrets.resolve"]({
req: { type: "req", id: "1", method: "secrets.resolve" },
params: {
commandName: params.commandName,
targetIds: params.targetIds,
},
client: null,
isWebchatConnect: () => false,
respond: params.respond as unknown as Parameters<
ReturnType<typeof createSecretsHandlers>["secrets.resolve"]
>[0]["respond"],
context: {} as never,
});
}
describe("secrets handlers", () => {
function createHandlers(overrides?: {
reloadSecrets?: () => Promise<{ warningCount: number }>;
@@ -73,13 +94,11 @@ describe("secrets handlers", () => {
});
const handlers = createHandlers({ resolveSecrets });
const respond = vi.fn();
await handlers["secrets.resolve"]({
req: { type: "req", id: "1", method: "secrets.resolve" },
params: { commandName: "memory status", targetIds: ["talk.apiKey"] },
client: null,
isWebchatConnect: () => false,
await invokeSecretsResolve({
handlers,
respond,
context: {} as never,
commandName: "memory status",
targetIds: ["talk.apiKey"],
});
expect(resolveSecrets).toHaveBeenCalledWith({
commandName: "memory status",
@@ -96,13 +115,11 @@ describe("secrets handlers", () => {
it("rejects invalid secrets.resolve params", async () => {
const handlers = createHandlers();
const respond = vi.fn();
await handlers["secrets.resolve"]({
req: { type: "req", id: "1", method: "secrets.resolve" },
params: { commandName: "", targetIds: "bad" },
client: null,
isWebchatConnect: () => false,
await invokeSecretsResolve({
handlers,
respond,
context: {} as never,
commandName: "",
targetIds: "bad",
});
expect(respond).toHaveBeenCalledWith(
false,
@@ -117,13 +134,11 @@ describe("secrets handlers", () => {
const resolveSecrets = vi.fn();
const handlers = createHandlers({ resolveSecrets });
const respond = vi.fn();
await handlers["secrets.resolve"]({
req: { type: "req", id: "1", method: "secrets.resolve" },
params: { commandName: "memory status", targetIds: ["talk.apiKey", 12] },
client: null,
isWebchatConnect: () => false,
await invokeSecretsResolve({
handlers,
respond,
context: {} as never,
commandName: "memory status",
targetIds: ["talk.apiKey", 12],
});
expect(resolveSecrets).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
@@ -140,13 +155,11 @@ describe("secrets handlers", () => {
const resolveSecrets = vi.fn();
const handlers = createHandlers({ resolveSecrets });
const respond = vi.fn();
await handlers["secrets.resolve"]({
req: { type: "req", id: "1", method: "secrets.resolve" },
params: { commandName: "memory status", targetIds: ["unknown.target"] },
client: null,
isWebchatConnect: () => false,
await invokeSecretsResolve({
handlers,
respond,
context: {} as never,
commandName: "memory status",
targetIds: ["unknown.target"],
});
expect(resolveSecrets).not.toHaveBeenCalled();
expect(respond).toHaveBeenCalledWith(
@@ -167,13 +180,11 @@ describe("secrets handlers", () => {
});
const handlers = createHandlers({ resolveSecrets });
const respond = vi.fn();
await handlers["secrets.resolve"]({
req: { type: "req", id: "1", method: "secrets.resolve" },
params: { commandName: "memory status", targetIds: ["talk.apiKey"] },
client: null,
isWebchatConnect: () => false,
await invokeSecretsResolve({
handlers,
respond,
context: {} as never,
commandName: "memory status",
targetIds: ["talk.apiKey"],
});
expect(respond).toHaveBeenCalledWith(
false,

View File

@@ -151,6 +151,35 @@ async function addMainSystemEventCronJob(params: { ws: WebSocket; name: string;
return expectCronJobIdFromResponse(response);
}
async function addWebhookCronJob(params: {
ws: WebSocket;
name: string;
sessionTarget?: "main" | "isolated";
payloadText?: string;
delivery: Record<string, unknown>;
}) {
const response = await rpcReq(params.ws, "cron.add", {
name: params.name,
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: params.sessionTarget ?? "main",
wakeMode: "next-heartbeat",
payload: {
kind: params.sessionTarget === "isolated" ? "agentTurn" : "systemEvent",
...(params.sessionTarget === "isolated"
? { message: params.payloadText ?? "test" }
: { text: params.payloadText ?? "send webhook" }),
},
delivery: params.delivery,
});
return expectCronJobIdFromResponse(response);
}
async function runCronJobForce(ws: WebSocket, id: string) {
const response = await rpcReq(ws, "cron.run", { id, mode: "force" }, 20_000);
expect(response.ok).toBe(true);
}
function getWebhookCall(index: number) {
const [args] = fetchWithSsrFGuardMock.mock.calls[index] as unknown as [
{
@@ -574,22 +603,12 @@ describe("gateway server cron", () => {
});
expect(invalidWebhookRes.ok).toBe(false);
const notifyRes = await rpcReq(ws, "cron.add", {
const notifyJobId = await addWebhookCronJob({
ws,
name: "webhook enabled",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "send webhook" },
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
});
expect(notifyRes.ok).toBe(true);
const notifyJobIdValue = (notifyRes.payload as { id?: unknown } | null)?.id;
const notifyJobId = typeof notifyJobIdValue === "string" ? notifyJobIdValue : "";
expect(notifyJobId.length > 0).toBe(true);
const notifyRunRes = await rpcReq(ws, "cron.run", { id: notifyJobId, mode: "force" }, 20_000);
expect(notifyRunRes.ok).toBe(true);
await runCronJobForce(ws, notifyJobId);
await waitForCondition(
() => fetchWithSsrFGuardMock.mock.calls.length === 1,
@@ -644,13 +663,10 @@ describe("gateway server cron", () => {
fetchWithSsrFGuardMock.mockClear();
cronIsolatedRun.mockResolvedValueOnce({ status: "error", summary: "delivery failed" });
const failureDestRes = await rpcReq(ws, "cron.add", {
const failureDestJobId = await addWebhookCronJob({
ws,
name: "failure destination webhook",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "test" },
delivery: {
mode: "announce",
channel: "telegram",
@@ -661,19 +677,7 @@ describe("gateway server cron", () => {
},
},
});
expect(failureDestRes.ok).toBe(true);
const failureDestJobIdValue = (failureDestRes.payload as { id?: unknown } | null)?.id;
const failureDestJobId =
typeof failureDestJobIdValue === "string" ? failureDestJobIdValue : "";
expect(failureDestJobId.length > 0).toBe(true);
const failureDestRunRes = await rpcReq(
ws,
"cron.run",
{ id: failureDestJobId, mode: "force" },
20_000,
);
expect(failureDestRunRes.ok).toBe(true);
await runCronJobForce(ws, failureDestJobId);
await waitForCondition(
() => fetchWithSsrFGuardMock.mock.calls.length === 1,
CRON_WAIT_TIMEOUT_MS,
@@ -686,27 +690,13 @@ describe("gateway server cron", () => {
);
cronIsolatedRun.mockResolvedValueOnce({ status: "ok", summary: "" });
const noSummaryRes = await rpcReq(ws, "cron.add", {
const noSummaryJobId = await addWebhookCronJob({
ws,
name: "webhook no summary",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "test" },
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
});
expect(noSummaryRes.ok).toBe(true);
const noSummaryJobIdValue = (noSummaryRes.payload as { id?: unknown } | null)?.id;
const noSummaryJobId = typeof noSummaryJobIdValue === "string" ? noSummaryJobIdValue : "";
expect(noSummaryJobId.length > 0).toBe(true);
const noSummaryRunRes = await rpcReq(
ws,
"cron.run",
{ id: noSummaryJobId, mode: "force" },
20_000,
);
expect(noSummaryRunRes.ok).toBe(true);
await runCronJobForce(ws, noSummaryJobId);
await yieldToEventLoop();
await yieldToEventLoop();
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(1);
@@ -746,22 +736,12 @@ describe("gateway server cron", () => {
await connectOk(ws);
try {
const notifyRes = await rpcReq(ws, "cron.add", {
const notifyJobId = await addWebhookCronJob({
ws,
name: "webhook secretinput object",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "send webhook" },
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
});
expect(notifyRes.ok).toBe(true);
const notifyJobIdValue = (notifyRes.payload as { id?: unknown } | null)?.id;
const notifyJobId = typeof notifyJobIdValue === "string" ? notifyJobIdValue : "";
expect(notifyJobId.length > 0).toBe(true);
const notifyRunRes = await rpcReq(ws, "cron.run", { id: notifyJobId, mode: "force" }, 20_000);
expect(notifyRunRes.ok).toBe(true);
await runCronJobForce(ws, notifyJobId);
await waitForCondition(
() => fetchWithSsrFGuardMock.mock.calls.length === 1,

View File

@@ -339,6 +339,46 @@ async function startGatewayServerWithRetries(params: {
throw new Error("failed to start gateway server after retries");
}
async function waitForWebSocketOpen(ws: WebSocket, timeoutMs = 10_000): Promise<void> {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), timeoutMs);
const cleanup = () => {
clearTimeout(timer);
ws.off("open", onOpen);
ws.off("error", onError);
ws.off("close", onClose);
};
const onOpen = () => {
cleanup();
resolve();
};
const onError = (err: unknown) => {
cleanup();
reject(err instanceof Error ? err : new Error(String(err)));
};
const onClose = (code: number, reason: Buffer) => {
cleanup();
reject(new Error(`closed ${code}: ${reason.toString()}`));
};
ws.once("open", onOpen);
ws.once("error", onError);
ws.once("close", onClose);
});
}
async function openTrackedWebSocket(params: {
port: number;
headers?: Record<string, string>;
}): Promise<WebSocket> {
const ws = new WebSocket(
`ws://127.0.0.1:${params.port}`,
params.headers ? { headers: params.headers } : undefined,
);
trackConnectChallengeNonce(ws);
await waitForWebSocketOpen(ws);
return ws;
}
export async function withGatewayServer<T>(
fn: (ctx: { port: number; server: Awaited<ReturnType<typeof startGatewayServer>> }) => Promise<T>,
opts?: { port?: number; serverOptions?: GatewayServerOptions },
@@ -371,33 +411,10 @@ export async function createGatewaySuiteHarness(opts?: {
port: started.port,
server: started.server,
openWs: async (headers?: Record<string, string>) => {
const ws = new WebSocket(`ws://127.0.0.1:${started.port}`, headers ? { headers } : undefined);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000);
const cleanup = () => {
clearTimeout(timer);
ws.off("open", onOpen);
ws.off("error", onError);
ws.off("close", onClose);
};
const onOpen = () => {
cleanup();
resolve();
};
const onError = (err: unknown) => {
cleanup();
reject(err instanceof Error ? err : new Error(String(err)));
};
const onClose = (code: number, reason: Buffer) => {
cleanup();
reject(new Error(`closed ${code}: ${reason.toString()}`));
};
ws.once("open", onOpen);
ws.once("error", onError);
ws.once("close", onClose);
return await openTrackedWebSocket({
port: started.port,
headers,
});
return ws;
},
close: async () => {
await started.server.close();
@@ -431,35 +448,7 @@ export async function startServerWithClient(
port = started.port;
const server = started.server;
const ws = new WebSocket(
`ws://127.0.0.1:${port}`,
wsHeaders ? { headers: wsHeaders } : undefined,
);
trackConnectChallengeNonce(ws);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000);
const cleanup = () => {
clearTimeout(timer);
ws.off("open", onOpen);
ws.off("error", onError);
ws.off("close", onClose);
};
const onOpen = () => {
cleanup();
resolve();
};
const onError = (err: unknown) => {
cleanup();
reject(err instanceof Error ? err : new Error(String(err)));
};
const onClose = (code: number, reason: Buffer) => {
cleanup();
reject(new Error(`closed ${code}: ${reason.toString()}`));
};
ws.once("open", onOpen);
ws.once("error", onError);
ws.once("close", onClose);
});
const ws = await openTrackedWebSocket({ port, headers: wsHeaders });
return { server, ws, port, prevToken: prev, envSnapshot };
}

View File

@@ -1,5 +1,6 @@
import { parseFrontmatterBlock } from "../markdown/frontmatter.js";
import {
applyOpenClawManifestInstallCommonFields,
getFrontmatterString,
normalizeStringList,
parseOpenClawManifestInstallBase,
@@ -27,19 +28,12 @@ function parseInstallSpec(input: unknown): HookInstallSpec | undefined {
return undefined;
}
const { raw } = parsed;
const spec: HookInstallSpec = {
kind: parsed.kind as HookInstallSpec["kind"],
};
if (parsed.id) {
spec.id = parsed.id;
}
if (parsed.label) {
spec.label = parsed.label;
}
if (parsed.bins) {
spec.bins = parsed.bins;
}
const spec = applyOpenClawManifestInstallCommonFields<HookInstallSpec>(
{
kind: parsed.kind as HookInstallSpec["kind"],
},
parsed,
);
if (typeof raw.package === "string") {
spec.package = raw.package;
}

View File

@@ -97,7 +97,7 @@ export type MessageSentHookEvent = InternalHookEvent & {
context: MessageSentHookContext;
};
export type MessageTranscribedHookContext = {
type MessageEnrichedBodyHookContext = {
/** Sender identifier (e.g., phone number, user ID) */
from?: string;
/** Recipient identifier */
@@ -106,8 +106,6 @@ export type MessageTranscribedHookContext = {
body?: string;
/** Enriched body shown to the agent, including transcript */
bodyForAgent?: string;
/** The transcribed text from audio */
transcript: string;
/** Unix timestamp when the message was received */
timestamp?: number;
/** Channel identifier (e.g., "telegram", "whatsapp") */
@@ -132,45 +130,20 @@ export type MessageTranscribedHookContext = {
mediaType?: string;
};
export type MessageTranscribedHookContext = MessageEnrichedBodyHookContext & {
/** The transcribed text from audio */
transcript: string;
};
export type MessageTranscribedHookEvent = InternalHookEvent & {
type: "message";
action: "transcribed";
context: MessageTranscribedHookContext;
};
export type MessagePreprocessedHookContext = {
/** Sender identifier (e.g., phone number, user ID) */
from?: string;
/** Recipient identifier */
to?: string;
/** Original raw message body */
body?: string;
/** Fully enriched body shown to the agent (transcripts, image descriptions, link summaries) */
bodyForAgent?: string;
export type MessagePreprocessedHookContext = MessageEnrichedBodyHookContext & {
/** Transcribed audio text, if the message contained audio */
transcript?: string;
/** Unix timestamp when the message was received */
timestamp?: number;
/** Channel identifier (e.g., "telegram", "whatsapp") */
channelId: string;
/** Conversation/chat ID */
conversationId?: string;
/** Message ID from the provider */
messageId?: string;
/** Sender user ID */
senderId?: string;
/** Sender display name */
senderName?: string;
/** Sender username */
senderUsername?: string;
/** Provider name */
provider?: string;
/** Surface name */
surface?: string;
/** Path to the media file, if present */
mediaPath?: string;
/** MIME type of the media, if present */
mediaType?: string;
/** Whether this message was sent in a group/channel context */
isGroup?: boolean;
/** Group or channel identifier, if applicable */

View File

@@ -213,23 +213,10 @@ export function toInternalMessageTranscribedContext(
canonical: CanonicalInboundMessageHookContext,
cfg: OpenClawConfig,
): MessageTranscribedHookContext & { cfg: OpenClawConfig } {
const shared = toInternalInboundMessageHookContextBase(canonical);
return {
from: canonical.from,
to: canonical.to,
body: canonical.body,
bodyForAgent: canonical.bodyForAgent,
...shared,
transcript: canonical.transcript ?? "",
timestamp: canonical.timestamp,
channelId: canonical.channelId,
conversationId: canonical.conversationId,
messageId: canonical.messageId,
senderId: canonical.senderId,
senderName: canonical.senderName,
senderUsername: canonical.senderUsername,
provider: canonical.provider,
surface: canonical.surface,
mediaPath: canonical.mediaPath,
mediaType: canonical.mediaType,
cfg,
};
}
@@ -238,12 +225,22 @@ export function toInternalMessagePreprocessedContext(
canonical: CanonicalInboundMessageHookContext,
cfg: OpenClawConfig,
): MessagePreprocessedHookContext & { cfg: OpenClawConfig } {
const shared = toInternalInboundMessageHookContextBase(canonical);
return {
...shared,
transcript: canonical.transcript,
isGroup: canonical.isGroup,
groupId: canonical.groupId,
cfg,
};
}
function toInternalInboundMessageHookContextBase(canonical: CanonicalInboundMessageHookContext) {
return {
from: canonical.from,
to: canonical.to,
body: canonical.body,
bodyForAgent: canonical.bodyForAgent,
transcript: canonical.transcript,
timestamp: canonical.timestamp,
channelId: canonical.channelId,
conversationId: canonical.conversationId,
@@ -255,9 +252,6 @@ export function toInternalMessagePreprocessedContext(
surface: canonical.surface,
mediaPath: canonical.mediaPath,
mediaType: canonical.mediaType,
isGroup: canonical.isGroup,
groupId: canonical.groupId,
cfg,
};
}

View File

@@ -1,3 +1,5 @@
import { isAllowedParsedChatSender } from "../plugin-sdk/allow-from.js";
export type ServicePrefix<TService extends string> = { prefix: string; service: TService };
export type ChatTargetPrefixesParams = {
@@ -13,10 +15,24 @@ export type ParsedChatTarget =
| { kind: "chat_guid"; chatGuid: string }
| { kind: "chat_identifier"; chatIdentifier: string };
export type ParsedChatAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string };
export type ChatSenderAllowParams = {
allowFrom: Array<string | number>;
sender: string;
chatId?: number | null;
chatGuid?: string | null;
chatIdentifier?: string | null;
};
function stripPrefix(value: string, prefix: string): string {
return value.slice(prefix.length).trim();
}
function startsWithAnyPrefix(value: string, prefixes: readonly string[]): boolean {
return prefixes.some((prefix) => value.startsWith(prefix));
}
export function resolveServicePrefixedTarget<TService extends string, TTarget>(params: {
trimmed: string;
lower: string;
@@ -41,6 +57,31 @@ export function resolveServicePrefixedTarget<TService extends string, TTarget>(p
return null;
}
export function resolveServicePrefixedChatTarget<TService extends string, TTarget>(params: {
trimmed: string;
lower: string;
servicePrefixes: Array<ServicePrefix<TService>>;
chatIdPrefixes: string[];
chatGuidPrefixes: string[];
chatIdentifierPrefixes: string[];
extraChatPrefixes?: string[];
parseTarget: (remainder: string) => TTarget;
}): ({ kind: "handle"; to: string; service: TService } | TTarget) | null {
const chatPrefixes = [
...params.chatIdPrefixes,
...params.chatGuidPrefixes,
...params.chatIdentifierPrefixes,
...(params.extraChatPrefixes ?? []),
];
return resolveServicePrefixedTarget({
trimmed: params.trimmed,
lower: params.lower,
servicePrefixes: params.servicePrefixes,
isChatTarget: (remainderLower) => startsWithAnyPrefix(remainderLower, chatPrefixes),
parseTarget: params.parseTarget,
});
}
export function parseChatTargetPrefixesOrThrow(
params: ChatTargetPrefixesParams,
): ParsedChatTarget | null {
@@ -97,6 +138,56 @@ export function resolveServicePrefixedAllowTarget<TAllowTarget>(params: {
return null;
}
export function resolveServicePrefixedOrChatAllowTarget<
TAllowTarget extends ParsedChatAllowTarget,
>(params: {
trimmed: string;
lower: string;
servicePrefixes: Array<{ prefix: string }>;
parseAllowTarget: (remainder: string) => TAllowTarget;
chatIdPrefixes: string[];
chatGuidPrefixes: string[];
chatIdentifierPrefixes: string[];
}): TAllowTarget | null {
const servicePrefixed = resolveServicePrefixedAllowTarget({
trimmed: params.trimmed,
lower: params.lower,
servicePrefixes: params.servicePrefixes,
parseAllowTarget: params.parseAllowTarget,
});
if (servicePrefixed) {
return servicePrefixed as TAllowTarget;
}
const chatTarget = parseChatAllowTargetPrefixes({
trimmed: params.trimmed,
lower: params.lower,
chatIdPrefixes: params.chatIdPrefixes,
chatGuidPrefixes: params.chatGuidPrefixes,
chatIdentifierPrefixes: params.chatIdentifierPrefixes,
});
if (chatTarget) {
return chatTarget as TAllowTarget;
}
return null;
}
export function createAllowedChatSenderMatcher<TParsed extends ParsedChatAllowTarget>(params: {
normalizeSender: (sender: string) => string;
parseAllowTarget: (entry: string) => TParsed;
}): (input: ChatSenderAllowParams) => boolean {
return (input) =>
isAllowedParsedChatSender({
allowFrom: input.allowFrom,
sender: input.sender,
chatId: input.chatId,
chatGuid: input.chatGuid,
chatIdentifier: input.chatIdentifier,
normalizeSender: params.normalizeSender,
parseAllowTarget: params.parseAllowTarget,
});
}
export function parseChatAllowTargetPrefixes(
params: ChatTargetPrefixesParams,
): ParsedChatTarget | null {

View File

@@ -1,11 +1,11 @@
import { isAllowedParsedChatSender } from "../plugin-sdk/allow-from.js";
import { normalizeE164 } from "../utils.js";
import {
createAllowedChatSenderMatcher,
type ChatSenderAllowParams,
type ParsedChatTarget,
parseChatAllowTargetPrefixes,
parseChatTargetPrefixesOrThrow,
resolveServicePrefixedAllowTarget,
resolveServicePrefixedTarget,
resolveServicePrefixedChatTarget,
resolveServicePrefixedOrChatAllowTarget,
} from "./target-parsing-helpers.js";
export type IMessageService = "imessage" | "sms" | "auto";
@@ -80,14 +80,13 @@ export function parseIMessageTarget(raw: string): IMessageTarget {
}
const lower = trimmed.toLowerCase();
const servicePrefixed = resolveServicePrefixedTarget({
const servicePrefixed = resolveServicePrefixedChatTarget({
trimmed,
lower,
servicePrefixes: SERVICE_PREFIXES,
isChatTarget: (remainderLower) =>
CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)),
chatIdPrefixes: CHAT_ID_PREFIXES,
chatGuidPrefixes: CHAT_GUID_PREFIXES,
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
parseTarget: parseIMessageTarget,
});
if (servicePrefixed) {
@@ -115,46 +114,29 @@ export function parseIMessageAllowTarget(raw: string): IMessageAllowTarget {
}
const lower = trimmed.toLowerCase();
const servicePrefixed = resolveServicePrefixedAllowTarget({
const servicePrefixed = resolveServicePrefixedOrChatAllowTarget({
trimmed,
lower,
servicePrefixes: SERVICE_PREFIXES,
parseAllowTarget: parseIMessageAllowTarget,
chatIdPrefixes: CHAT_ID_PREFIXES,
chatGuidPrefixes: CHAT_GUID_PREFIXES,
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
});
if (servicePrefixed) {
return servicePrefixed;
}
const chatTarget = parseChatAllowTargetPrefixes({
trimmed,
lower,
chatIdPrefixes: CHAT_ID_PREFIXES,
chatGuidPrefixes: CHAT_GUID_PREFIXES,
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
});
if (chatTarget) {
return chatTarget;
}
return { kind: "handle", handle: normalizeIMessageHandle(trimmed) };
}
export function isAllowedIMessageSender(params: {
allowFrom: Array<string | number>;
sender: string;
chatId?: number | null;
chatGuid?: string | null;
chatIdentifier?: string | null;
}): boolean {
return isAllowedParsedChatSender({
allowFrom: params.allowFrom,
sender: params.sender,
chatId: params.chatId,
chatGuid: params.chatGuid,
chatIdentifier: params.chatIdentifier,
normalizeSender: normalizeIMessageHandle,
parseAllowTarget: parseIMessageAllowTarget,
});
const isAllowedIMessageSenderMatcher = createAllowedChatSenderMatcher({
normalizeSender: normalizeIMessageHandle,
parseAllowTarget: parseIMessageAllowTarget,
});
export function isAllowedIMessageSender(params: ChatSenderAllowParams): boolean {
return isAllowedIMessageSenderMatcher(params);
}
export function formatIMessageChatTarget(chatId?: number | null): string {

View File

@@ -540,12 +540,9 @@ async function resolveOutsideBoundaryPathAsync(params: {
return null;
}
const kind = await getPathKind(params.context.absolutePath, false);
return buildOutsideLexicalBoundaryPath({
return buildOutsideBoundaryPathFromContext({
boundaryLabel: params.boundaryLabel,
rootCanonicalPath: params.context.rootCanonicalPath,
absolutePath: params.context.absolutePath,
canonicalOutsideLexicalPath: params.context.canonicalOutsideLexicalPath,
rootPath: params.context.rootPath,
context: params.context,
kind,
});
}
@@ -558,13 +555,25 @@ function resolveOutsideBoundaryPathSync(params: {
return null;
}
const kind = getPathKindSync(params.context.absolutePath, false);
return buildOutsideBoundaryPathFromContext({
boundaryLabel: params.boundaryLabel,
context: params.context,
kind,
});
}
function buildOutsideBoundaryPathFromContext(params: {
boundaryLabel: string;
context: BoundaryResolutionContext;
kind: { exists: boolean; kind: ResolvedBoundaryPathKind };
}): ResolvedBoundaryPath {
return buildOutsideLexicalBoundaryPath({
boundaryLabel: params.boundaryLabel,
rootCanonicalPath: params.context.rootCanonicalPath,
absolutePath: params.context.absolutePath,
canonicalOutsideLexicalPath: params.context.canonicalOutsideLexicalPath,
rootPath: params.context.rootPath,
kind,
kind: params.kind,
});
}

View File

@@ -11,6 +11,30 @@ export type ExecHost = "sandbox" | "gateway" | "node";
export type ExecSecurity = "deny" | "allowlist" | "full";
export type ExecAsk = "off" | "on-miss" | "always";
export function normalizeExecHost(value?: string | null): ExecHost | null {
const normalized = value?.trim().toLowerCase();
if (normalized === "sandbox" || normalized === "gateway" || normalized === "node") {
return normalized;
}
return null;
}
export function normalizeExecSecurity(value?: string | null): ExecSecurity | null {
const normalized = value?.trim().toLowerCase();
if (normalized === "deny" || normalized === "allowlist" || normalized === "full") {
return normalized;
}
return null;
}
export function normalizeExecAsk(value?: string | null): ExecAsk | null {
const normalized = value?.trim().toLowerCase();
if (normalized === "off" || normalized === "on-miss" || normalized === "always") {
return normalized;
}
return null;
}
export type SystemRunApprovalBinding = {
argv: string[];
cwd: string | null;

View File

@@ -10,8 +10,7 @@ import {
import { rejectPendingPairingRequest } from "./pairing-pending.js";
import { generatePairingToken, verifyPairingToken } from "./pairing-token.js";
export type NodePairingPendingRequest = {
requestId: string;
type NodePairingNodeMetadata = {
nodeId: string;
displayName?: string;
platform?: string;
@@ -24,26 +23,18 @@ export type NodePairingPendingRequest = {
commands?: string[];
permissions?: Record<string, boolean>;
remoteIp?: string;
};
export type NodePairingPendingRequest = NodePairingNodeMetadata & {
requestId: string;
silent?: boolean;
isRepair?: boolean;
ts: number;
};
export type NodePairingPairedNode = {
nodeId: string;
export type NodePairingPairedNode = Omit<NodePairingNodeMetadata, "requestId"> & {
token: string;
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
deviceFamily?: string;
modelIdentifier?: string;
caps?: string[];
commands?: string[];
bins?: string[];
permissions?: Record<string, boolean>;
remoteIp?: string;
createdAtMs: number;
approvedAtMs: number;
lastConnectedAtMs?: number;

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { parseFiniteNumber } from "./parse-finite-number.js";
describe("parseFiniteNumber", () => {
it("returns finite numbers", () => {
expect(parseFiniteNumber(42)).toBe(42);
});
it("parses numeric strings", () => {
expect(parseFiniteNumber("3.14")).toBe(3.14);
});
it("returns undefined for non-finite or non-numeric values", () => {
expect(parseFiniteNumber(Number.NaN)).toBeUndefined();
expect(parseFiniteNumber(Number.POSITIVE_INFINITY)).toBeUndefined();
expect(parseFiniteNumber("not-a-number")).toBeUndefined();
expect(parseFiniteNumber(null)).toBeUndefined();
});
});

View File

@@ -0,0 +1,12 @@
export function parseFiniteNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return undefined;
}

View File

@@ -1,3 +1,4 @@
import { parseFiniteNumber as parseFiniteNumberish } from "./parse-finite-number.js";
import { PROVIDER_LABELS } from "./provider-usage.shared.js";
import type { ProviderUsageSnapshot, UsageProviderId } from "./provider-usage.types.js";
@@ -17,16 +18,7 @@ export async function fetchJson(
}
export function parseFiniteNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = Number.parseFloat(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return undefined;
return parseFiniteNumberish(value);
}
type BuildUsageHttpErrorSnapshotOptions = {

View File

@@ -1,6 +1,12 @@
export { extractBatchErrorMessage, formatUnavailableBatchError } from "./batch-error-utils.js";
export { postJsonWithRetry } from "./batch-http.js";
export { applyEmbeddingBatchOutputLine } from "./batch-output.js";
export {
resolveBatchCompletionFromStatus,
resolveCompletedBatchResult,
throwIfBatchTerminalFailure,
type BatchCompletionResult,
} from "./batch-status.js";
export {
EMBEDDING_BATCH_ENDPOINT,
type EmbeddingBatchStatus,

View File

@@ -7,9 +7,13 @@ import {
formatUnavailableBatchError,
normalizeBatchBaseUrl,
postJsonWithRetry,
resolveBatchCompletionFromStatus,
resolveCompletedBatchResult,
runEmbeddingBatchGroups,
throwIfBatchTerminalFailure,
type EmbeddingBatchExecutionParams,
type EmbeddingBatchStatus,
type BatchCompletionResult,
type ProviderBatchOutputLine,
uploadBatchJsonlFile,
withRemoteHttpResponse,
@@ -144,7 +148,7 @@ async function waitForOpenAiBatch(params: {
timeoutMs: number;
debug?: (message: string, data?: Record<string, unknown>) => void;
initial?: OpenAiBatchStatus;
}): Promise<{ outputFileId: string; errorFileId?: string }> {
}): Promise<BatchCompletionResult> {
const start = Date.now();
let current: OpenAiBatchStatus | undefined = params.initial;
while (true) {
@@ -156,21 +160,21 @@ async function waitForOpenAiBatch(params: {
}));
const state = status.status ?? "unknown";
if (state === "completed") {
if (!status.output_file_id) {
throw new Error(`openai batch ${params.batchId} completed without output file`);
}
return {
outputFileId: status.output_file_id,
errorFileId: status.error_file_id ?? undefined,
};
}
if (["failed", "expired", "cancelled", "canceled"].includes(state)) {
const detail = status.error_file_id
? await readOpenAiBatchError({ openAi: params.openAi, errorFileId: status.error_file_id })
: undefined;
const suffix = detail ? `: ${detail}` : "";
throw new Error(`openai batch ${params.batchId} ${state}${suffix}`);
return resolveBatchCompletionFromStatus({
provider: "openai",
batchId: params.batchId,
status,
});
}
await throwIfBatchTerminalFailure({
provider: "openai",
status: { ...status, id: params.batchId },
readError: async (errorFileId) =>
await readOpenAiBatchError({
openAi: params.openAi,
errorFileId,
}),
});
if (!params.wait) {
throw new Error(`openai batch ${params.batchId} still ${state}; wait disabled`);
}
@@ -204,6 +208,7 @@ export async function runOpenAiEmbeddingBatches(
if (!batchInfo.id) {
throw new Error("openai batch create failed: missing batch id");
}
const batchId = batchInfo.id;
params.debug?.("memory embeddings: openai batch created", {
batchId: batchInfo.id,
@@ -213,30 +218,21 @@ export async function runOpenAiEmbeddingBatches(
requests: group.length,
});
if (!params.wait && batchInfo.status !== "completed") {
throw new Error(
`openai batch ${batchInfo.id} submitted; enable remote.batch.wait to await completion`,
);
}
const completed =
batchInfo.status === "completed"
? {
outputFileId: batchInfo.output_file_id ?? "",
errorFileId: batchInfo.error_file_id ?? undefined,
}
: await waitForOpenAiBatch({
openAi: params.openAi,
batchId: batchInfo.id,
wait: params.wait,
pollIntervalMs: params.pollIntervalMs,
timeoutMs: params.timeoutMs,
debug: params.debug,
initial: batchInfo,
});
if (!completed.outputFileId) {
throw new Error(`openai batch ${batchInfo.id} completed without output file`);
}
const completed = await resolveCompletedBatchResult({
provider: "openai",
status: batchInfo,
wait: params.wait,
waitForBatch: async () =>
await waitForOpenAiBatch({
openAi: params.openAi,
batchId,
wait: params.wait,
pollIntervalMs: params.pollIntervalMs,
timeoutMs: params.timeoutMs,
debug: params.debug,
initial: batchInfo,
}),
});
const content = await fetchOpenAiFileContent({
openAi: params.openAi,

View File

@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import {
resolveBatchCompletionFromStatus,
resolveCompletedBatchResult,
throwIfBatchTerminalFailure,
} from "./batch-status.js";
describe("batch-status helpers", () => {
it("resolves completion payload from completed status", () => {
expect(
resolveBatchCompletionFromStatus({
provider: "openai",
batchId: "b1",
status: {
output_file_id: "out-1",
error_file_id: "err-1",
},
}),
).toEqual({
outputFileId: "out-1",
errorFileId: "err-1",
});
});
it("throws for terminal failure states", async () => {
await expect(
throwIfBatchTerminalFailure({
provider: "voyage",
status: { id: "b2", status: "failed", error_file_id: "err-file" },
readError: async () => "bad input",
}),
).rejects.toThrow("voyage batch b2 failed: bad input");
});
it("returns completed result directly without waiting", async () => {
const waitForBatch = async () => ({ outputFileId: "out-2" });
const result = await resolveCompletedBatchResult({
provider: "openai",
status: {
id: "b3",
status: "completed",
output_file_id: "out-3",
},
wait: false,
waitForBatch,
});
expect(result).toEqual({ outputFileId: "out-3", errorFileId: undefined });
});
it("throws when wait disabled and batch is not complete", async () => {
await expect(
resolveCompletedBatchResult({
provider: "openai",
status: { id: "b4", status: "pending" },
wait: false,
waitForBatch: async () => ({ outputFileId: "out" }),
}),
).rejects.toThrow("openai batch b4 submitted; enable remote.batch.wait to await completion");
});
});

View File

@@ -0,0 +1,69 @@
const TERMINAL_FAILURE_STATES = new Set(["failed", "expired", "cancelled", "canceled"]);
type BatchStatusLike = {
id?: string;
status?: string;
output_file_id?: string | null;
error_file_id?: string | null;
};
export type BatchCompletionResult = {
outputFileId: string;
errorFileId?: string;
};
export function resolveBatchCompletionFromStatus(params: {
provider: string;
batchId: string;
status: BatchStatusLike;
}): BatchCompletionResult {
if (!params.status.output_file_id) {
throw new Error(`${params.provider} batch ${params.batchId} completed without output file`);
}
return {
outputFileId: params.status.output_file_id,
errorFileId: params.status.error_file_id ?? undefined,
};
}
export async function throwIfBatchTerminalFailure(params: {
provider: string;
status: BatchStatusLike;
readError: (errorFileId: string) => Promise<string | undefined>;
}): Promise<void> {
const state = params.status.status ?? "unknown";
if (!TERMINAL_FAILURE_STATES.has(state)) {
return;
}
const detail = params.status.error_file_id
? await params.readError(params.status.error_file_id)
: undefined;
const suffix = detail ? `: ${detail}` : "";
throw new Error(`${params.provider} batch ${params.status.id ?? "<unknown>"} ${state}${suffix}`);
}
export async function resolveCompletedBatchResult(params: {
provider: string;
status: BatchStatusLike;
wait: boolean;
waitForBatch: () => Promise<BatchCompletionResult>;
}): Promise<BatchCompletionResult> {
const batchId = params.status.id ?? "<unknown>";
if (!params.wait && params.status.status !== "completed") {
throw new Error(
`${params.provider} batch ${batchId} submitted; enable remote.batch.wait to await completion`,
);
}
const completed =
params.status.status === "completed"
? resolveBatchCompletionFromStatus({
provider: params.provider,
batchId,
status: params.status,
})
: await params.waitForBatch();
if (!completed.outputFileId) {
throw new Error(`${params.provider} batch ${batchId} completed without output file`);
}
return completed;
}

View File

@@ -9,9 +9,13 @@ import {
formatUnavailableBatchError,
normalizeBatchBaseUrl,
postJsonWithRetry,
resolveBatchCompletionFromStatus,
resolveCompletedBatchResult,
runEmbeddingBatchGroups,
throwIfBatchTerminalFailure,
type EmbeddingBatchExecutionParams,
type EmbeddingBatchStatus,
type BatchCompletionResult,
type ProviderBatchOutputLine,
uploadBatchJsonlFile,
withRemoteHttpResponse,
@@ -146,7 +150,7 @@ async function waitForVoyageBatch(params: {
timeoutMs: number;
debug?: (message: string, data?: Record<string, unknown>) => void;
initial?: VoyageBatchStatus;
}): Promise<{ outputFileId: string; errorFileId?: string }> {
}): Promise<BatchCompletionResult> {
const start = Date.now();
let current: VoyageBatchStatus | undefined = params.initial;
while (true) {
@@ -158,21 +162,21 @@ async function waitForVoyageBatch(params: {
}));
const state = status.status ?? "unknown";
if (state === "completed") {
if (!status.output_file_id) {
throw new Error(`voyage batch ${params.batchId} completed without output file`);
}
return {
outputFileId: status.output_file_id,
errorFileId: status.error_file_id ?? undefined,
};
}
if (["failed", "expired", "cancelled", "canceled"].includes(state)) {
const detail = status.error_file_id
? await readVoyageBatchError({ client: params.client, errorFileId: status.error_file_id })
: undefined;
const suffix = detail ? `: ${detail}` : "";
throw new Error(`voyage batch ${params.batchId} ${state}${suffix}`);
return resolveBatchCompletionFromStatus({
provider: "voyage",
batchId: params.batchId,
status,
});
}
await throwIfBatchTerminalFailure({
provider: "voyage",
status: { ...status, id: params.batchId },
readError: async (errorFileId) =>
await readVoyageBatchError({
client: params.client,
errorFileId,
}),
});
if (!params.wait) {
throw new Error(`voyage batch ${params.batchId} still ${state}; wait disabled`);
}
@@ -206,6 +210,7 @@ export async function runVoyageEmbeddingBatches(
if (!batchInfo.id) {
throw new Error("voyage batch create failed: missing batch id");
}
const batchId = batchInfo.id;
params.debug?.("memory embeddings: voyage batch created", {
batchId: batchInfo.id,
@@ -215,30 +220,21 @@ export async function runVoyageEmbeddingBatches(
requests: group.length,
});
if (!params.wait && batchInfo.status !== "completed") {
throw new Error(
`voyage batch ${batchInfo.id} submitted; enable remote.batch.wait to await completion`,
);
}
const completed =
batchInfo.status === "completed"
? {
outputFileId: batchInfo.output_file_id ?? "",
errorFileId: batchInfo.error_file_id ?? undefined,
}
: await waitForVoyageBatch({
client: params.client,
batchId: batchInfo.id,
wait: params.wait,
pollIntervalMs: params.pollIntervalMs,
timeoutMs: params.timeoutMs,
debug: params.debug,
initial: batchInfo,
});
if (!completed.outputFileId) {
throw new Error(`voyage batch ${batchInfo.id} completed without output file`);
}
const completed = await resolveCompletedBatchResult({
provider: "voyage",
status: batchInfo,
wait: params.wait,
waitForBatch: async () =>
await waitForVoyageBatch({
client: params.client,
batchId,
wait: params.wait,
pollIntervalMs: params.pollIntervalMs,
timeoutMs: params.timeoutMs,
debug: params.debug,
initial: batchInfo,
}),
});
const baseUrl = normalizeBatchBaseUrl(params.client);
const errors: string[] = [];

View File

@@ -3,21 +3,25 @@ import type { OpenClawConfig } from "../config/config.js";
import { withEnvAsync } from "../test-utils/env.js";
import { resolveNodeHostGatewayCredentials } from "./runner.js";
function createRemoteGatewayTokenRefConfig(tokenId: string): OpenClawConfig {
return {
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
mode: "remote",
remote: {
token: { source: "env", provider: "default", id: tokenId },
},
},
} as OpenClawConfig;
}
describe("resolveNodeHostGatewayCredentials", () => {
it("resolves remote token SecretRef values", async () => {
const config = {
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
mode: "remote",
remote: {
token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" },
},
},
} as OpenClawConfig;
const config = createRemoteGatewayTokenRefConfig("REMOTE_GATEWAY_TOKEN");
await withEnvAsync(
{
@@ -32,19 +36,7 @@ describe("resolveNodeHostGatewayCredentials", () => {
});
it("prefers OPENCLAW_GATEWAY_TOKEN over configured refs", async () => {
const config = {
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
mode: "remote",
remote: {
token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" },
},
},
} as OpenClawConfig;
const config = createRemoteGatewayTokenRefConfig("REMOTE_GATEWAY_TOKEN");
await withEnvAsync(
{
@@ -59,19 +51,7 @@ describe("resolveNodeHostGatewayCredentials", () => {
});
it("throws when a configured remote token ref cannot resolve", async () => {
const config = {
secrets: {
providers: {
default: { source: "env" },
},
},
gateway: {
mode: "remote",
remote: {
token: { source: "env", provider: "default", id: "MISSING_REMOTE_GATEWAY_TOKEN" },
},
},
} as OpenClawConfig;
const config = createRemoteGatewayTokenRefConfig("MISSING_REMOTE_GATEWAY_TOKEN");
await withEnvAsync(
{

View File

@@ -1,6 +1,6 @@
import { resolveBrowserConfig } from "../browser/config.js";
import { loadConfig, type OpenClawConfig } from "../config/config.js";
import { normalizeSecretInputString, resolveSecretInputRef } from "../config/types.secrets.js";
import { normalizeSecretInputString } from "../config/types.secrets.js";
import { GatewayClient } from "../gateway/client.js";
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
import type { SkillBinTrustEntry } from "../infra/exec-approvals.js";
@@ -12,8 +12,7 @@ import {
NODE_SYSTEM_RUN_COMMANDS,
} from "../infra/node-commands.js";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import { secretRefKey } from "../secrets/ref-contract.js";
import { resolveSecretRefValues } from "../secrets/resolve.js";
import { resolveSecretInputString } from "../secrets/resolve-secret-input-string.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { VERSION } from "../version.js";
import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js";
@@ -117,27 +116,17 @@ async function resolveNodeHostSecretInputString(params: {
path: string;
env: NodeJS.ProcessEnv;
}): Promise<string | undefined> {
const defaults = params.config.secrets?.defaults;
const { ref } = resolveSecretInputRef({
const resolvedValue = await resolveSecretInputString({
config: params.config,
value: params.value,
defaults,
env: params.env,
onResolveRefError: (error) => {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`${params.path} secret reference could not be resolved: ${detail}`, {
cause: error,
});
},
});
if (!ref) {
return normalizeSecretInputString(params.value);
}
let resolved: Map<string, unknown>;
try {
resolved = await resolveSecretRefValues([ref], {
config: params.config,
env: params.env,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`${params.path} secret reference could not be resolved: ${detail}`, {
cause: error,
});
}
const resolvedValue = normalizeSecretInputString(resolved.get(secretRefKey(ref)));
if (!resolvedValue) {
throw new Error(`${params.path} resolved to an empty or non-string value.`);
}

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
mapBasicAllowlistResolutionEntries,
type BasicAllowlistResolutionEntry,
} from "./allowlist-resolution.js";
describe("mapBasicAllowlistResolutionEntries", () => {
it("maps entries to normalized allowlist resolver output", () => {
const entries: BasicAllowlistResolutionEntry[] = [
{
input: "alice",
resolved: true,
id: "U123",
name: "Alice",
note: "ok",
},
{
input: "bob",
resolved: false,
},
];
expect(mapBasicAllowlistResolutionEntries(entries)).toEqual([
{
input: "alice",
resolved: true,
id: "U123",
name: "Alice",
note: "ok",
},
{
input: "bob",
resolved: false,
id: undefined,
name: undefined,
note: undefined,
},
]);
});
});

View File

@@ -0,0 +1,19 @@
export type BasicAllowlistResolutionEntry = {
input: string;
resolved: boolean;
id?: string;
name?: string;
note?: string;
};
export function mapBasicAllowlistResolutionEntries(
entries: BasicAllowlistResolutionEntry[],
): BasicAllowlistResolutionEntry[] {
return entries.map((entry) => ({
input: entry.input,
resolved: entry.resolved,
id: entry.id,
name: entry.name,
note: entry.note,
}));
}

View File

@@ -85,7 +85,11 @@ export type { WizardPrompter } from "../wizard/prompts.js";
export { isAllowedParsedChatSender } from "./allow-from.js";
export { readBooleanParam } from "./boolean-param.js";
export { createScopedPairingAccess } from "./pairing-access.js";
export { buildProbeChannelStatusSummary } from "./status-helpers.js";
export { resolveRequestUrl } from "./request-url.js";
export {
buildComputedAccountStatusSnapshot,
buildProbeChannelStatusSummary,
} from "./status-helpers.js";
export { extractToolSend } from "./tool-send.js";
export { normalizeWebhookPath } from "./webhook-path.js";
export {

View File

@@ -0,0 +1,14 @@
export type ChannelSendRawResult = {
ok: boolean;
messageId?: string | null;
error?: string | null;
};
export function buildChannelSendResult(channel: string, result: ChannelSendRawResult) {
return {
channel,
ok: result.ok,
messageId: result.messageId ?? "",
error: result.error ? new Error(result.error) : undefined,
};
}

View File

@@ -0,0 +1,33 @@
import type { DiscordSendResult } from "../discord/send.types.js";
type DiscordSendOptionInput = {
replyToId?: string | null;
accountId?: string | null;
silent?: boolean;
};
type DiscordSendMediaOptionInput = DiscordSendOptionInput & {
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
};
export function buildDiscordSendOptions(input: DiscordSendOptionInput) {
return {
verbose: false,
replyTo: input.replyToId ?? undefined,
accountId: input.accountId ?? undefined,
silent: input.silent ?? undefined,
};
}
export function buildDiscordSendMediaOptions(input: DiscordSendMediaOptionInput) {
return {
...buildDiscordSendOptions(input),
mediaUrl: input.mediaUrl,
mediaLocalRoots: input.mediaLocalRoots,
};
}
export function tagDiscordChannelResult(result: DiscordSendResult) {
return { channel: "discord" as const, ...result };
}

View File

@@ -59,6 +59,8 @@ export { createScopedPairingAccess } from "./pairing-access.js";
export { createPersistentDedupe } from "./persistent-dedupe.js";
export {
buildBaseChannelStatusSummary,
buildProbeChannelStatusSummary,
buildRuntimeAccountStatusSnapshot,
createDefaultChannelRuntimeState,
} from "./status-helpers.js";
export { withTempDownloadPath } from "./temp-path.js";

View File

@@ -47,3 +47,4 @@ export { imessageOnboardingAdapter } from "../channels/plugins/onboarding/imessa
export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js";
export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
export { collectStatusIssuesFromLastError } from "./status-helpers.js";

View File

@@ -0,0 +1,143 @@
import { withReplyDispatcher } from "../auto-reply/dispatch.js";
import {
dispatchReplyFromConfig,
type DispatchFromConfigResult,
} from "../auto-reply/reply/dispatch-from-config.js";
import type { ReplyDispatcher } from "../auto-reply/reply/reply-dispatcher.js";
import type { FinalizedMsgContext } from "../auto-reply/templating.js";
import type { GetReplyOptions } from "../auto-reply/types.js";
import { createReplyPrefixOptions } from "../channels/reply-prefix.js";
import type { OpenClawConfig } from "../config/config.js";
import { createNormalizedOutboundDeliverer, type OutboundReplyPayload } from "./reply-payload.js";
type ReplyOptionsWithoutModelSelected = Omit<
Omit<GetReplyOptions, "onToolResult" | "onBlockReply">,
"onModelSelected"
>;
type RecordInboundSessionFn = typeof import("../channels/session.js").recordInboundSession;
type DispatchReplyWithBufferedBlockDispatcherFn =
typeof import("../auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher;
type ReplyDispatchFromConfigOptions = Omit<GetReplyOptions, "onToolResult" | "onBlockReply">;
export async function dispatchReplyFromConfigWithSettledDispatcher(params: {
cfg: OpenClawConfig;
ctxPayload: FinalizedMsgContext;
dispatcher: ReplyDispatcher;
onSettled: () => void | Promise<void>;
replyOptions?: ReplyDispatchFromConfigOptions;
}): Promise<DispatchFromConfigResult> {
return await withReplyDispatcher({
dispatcher: params.dispatcher,
onSettled: params.onSettled,
run: () =>
dispatchReplyFromConfig({
ctx: params.ctxPayload,
cfg: params.cfg,
dispatcher: params.dispatcher,
replyOptions: params.replyOptions,
}),
});
}
export function buildInboundReplyDispatchBase(params: {
cfg: OpenClawConfig;
channel: string;
accountId?: string;
route: {
agentId: string;
sessionKey: string;
};
storePath: string;
ctxPayload: FinalizedMsgContext;
core: {
channel: {
session: {
recordInboundSession: RecordInboundSessionFn;
};
reply: {
dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcherFn;
};
};
};
}) {
return {
cfg: params.cfg,
channel: params.channel,
accountId: params.accountId,
agentId: params.route.agentId,
routeSessionKey: params.route.sessionKey,
storePath: params.storePath,
ctxPayload: params.ctxPayload,
recordInboundSession: params.core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
params.core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
};
}
type BuildInboundReplyDispatchBaseParams = Parameters<typeof buildInboundReplyDispatchBase>[0];
type RecordInboundSessionAndDispatchReplyParams = Parameters<
typeof recordInboundSessionAndDispatchReply
>[0];
export async function dispatchInboundReplyWithBase(
params: BuildInboundReplyDispatchBaseParams &
Pick<
RecordInboundSessionAndDispatchReplyParams,
"deliver" | "onRecordError" | "onDispatchError" | "replyOptions"
>,
): Promise<void> {
const dispatchBase = buildInboundReplyDispatchBase(params);
await recordInboundSessionAndDispatchReply({
...dispatchBase,
deliver: params.deliver,
onRecordError: params.onRecordError,
onDispatchError: params.onDispatchError,
replyOptions: params.replyOptions,
});
}
export async function recordInboundSessionAndDispatchReply(params: {
cfg: OpenClawConfig;
channel: string;
accountId?: string;
agentId: string;
routeSessionKey: string;
storePath: string;
ctxPayload: FinalizedMsgContext;
recordInboundSession: RecordInboundSessionFn;
dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcherFn;
deliver: (payload: OutboundReplyPayload) => Promise<void>;
onRecordError: (err: unknown) => void;
onDispatchError: (err: unknown, info: { kind: string }) => void;
replyOptions?: ReplyOptionsWithoutModelSelected;
}): Promise<void> {
await params.recordInboundSession({
storePath: params.storePath,
sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey,
ctx: params.ctxPayload,
onRecordError: params.onRecordError,
});
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg: params.cfg,
agentId: params.agentId,
channel: params.channel,
accountId: params.accountId,
});
const deliver = createNormalizedOutboundDeliverer(params.deliver);
await params.dispatchReplyWithBufferedBlockDispatcher({
ctx: params.ctxPayload,
cfg: params.cfg,
dispatcherOptions: {
...prefixOptions,
deliver,
onError: params.onDispatchError,
},
replyOptions: {
...params.replyOptions,
onModelSelected,
},
});
}

View File

@@ -132,6 +132,16 @@ export { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matchin
export type { FileLockHandle, FileLockOptions } from "./file-lock.js";
export { acquireFileLock, withFileLock } from "./file-lock.js";
export {
mapBasicAllowlistResolutionEntries,
type BasicAllowlistResolutionEntry,
} from "./allowlist-resolution.js";
export { resolveRequestUrl } from "./request-url.js";
export {
buildDiscordSendMediaOptions,
buildDiscordSendOptions,
tagDiscordChannelResult,
} from "./discord-send.js";
export type { KeyedAsyncQueueHooks } from "./keyed-async-queue.js";
export { enqueueKeyedTask, KeyedAsyncQueue } from "./keyed-async-queue.js";
export { normalizeWebhookPath, resolveWebhookPath } from "./webhook-path.js";
@@ -167,7 +177,9 @@ export { buildAgentMediaPayload } from "./agent-media-payload.js";
export {
buildBaseAccountStatusSnapshot,
buildBaseChannelStatusSummary,
buildComputedAccountStatusSnapshot,
buildProbeChannelStatusSummary,
buildRuntimeAccountStatusSnapshot,
buildTokenChannelStatusSummary,
collectStatusIssuesFromLastError,
createDefaultChannelRuntimeState,
@@ -178,6 +190,8 @@ export {
} from "../channels/plugins/onboarding/helpers.js";
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
export { formatResolvedUnresolvedNote } from "./resolution-notes.js";
export { buildChannelSendResult } from "./channel-send-result.js";
export type { ChannelSendRawResult } from "./channel-send-result.js";
export type { ChannelDock } from "../channels/dock.js";
export { getChatChannelMeta } from "../channels/registry.js";
export { resolveAllowlistMatchByCandidates } from "../channels/allowlist-match.js";
@@ -278,6 +292,7 @@ export {
resolveInboundRouteEnvelopeBuilder,
resolveInboundRouteEnvelopeBuilderWithRuntime,
} from "./inbound-envelope.js";
export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js";
export {
listConfiguredAccountIds,
resolveAccountWithDefaultFallback,
@@ -288,17 +303,29 @@ export { extractToolSend } from "./tool-send.js";
export {
createNormalizedOutboundDeliverer,
formatTextWithAttachmentLinks,
isNumericTargetId,
normalizeOutboundReplyPayload,
resolveOutboundMediaUrls,
sendPayloadWithChunkedTextAndMedia,
sendMediaWithLeadingCaption,
} from "./reply-payload.js";
export type { OutboundReplyPayload } from "./reply-payload.js";
export {
buildInboundReplyDispatchBase,
dispatchInboundReplyWithBase,
dispatchReplyFromConfigWithSettledDispatcher,
recordInboundSessionAndDispatchReply,
} from "./inbound-reply-dispatch.js";
export type { OutboundMediaLoadOptions } from "./outbound-media.js";
export { loadOutboundMediaFromUrl } from "./outbound-media.js";
export { resolveChannelAccountConfigBasePath } from "./config-paths.js";
export { buildMediaPayload } from "../channels/plugins/media-payload.js";
export type { MediaPayload, MediaPayloadInput } from "../channels/plugins/media-payload.js";
export { createLoggerBackedRuntime } from "./runtime.js";
export {
createLoggerBackedRuntime,
resolveRuntimeEnv,
resolveRuntimeEnvWithUnavailableExit,
} from "./runtime.js";
export { chunkTextForOutbound } from "./text-chunking.js";
export { readBooleanParam } from "./boolean-param.js";
export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js";
@@ -487,6 +514,7 @@ export type { PollInput } from "../polls.js";
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
export {
clearAccountEntryFields,
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
} from "../channels/plugins/config-helpers.js";
@@ -589,12 +617,18 @@ export {
normalizeIMessageMessagingTarget,
} from "../channels/plugins/normalize/imessage.js";
export {
createAllowedChatSenderMatcher,
parseChatAllowTargetPrefixes,
parseChatTargetPrefixesOrThrow,
resolveServicePrefixedChatTarget,
resolveServicePrefixedAllowTarget,
resolveServicePrefixedOrChatAllowTarget,
resolveServicePrefixedTarget,
} from "../imessage/target-parsing-helpers.js";
export type { ParsedChatTarget } from "../imessage/target-parsing-helpers.js";
export type {
ChatSenderAllowParams,
ParsedChatTarget,
} from "../imessage/target-parsing-helpers.js";
// Channel: Slack
export {

View File

@@ -60,6 +60,7 @@ export {
export { formatDocsLink } from "../terminal/links.js";
export type { WizardPrompter } from "../wizard/prompts.js";
export { createScopedPairingAccess } from "./pairing-access.js";
export { dispatchInboundReplyWithBase } from "./inbound-reply-dispatch.js";
export type { OutboundReplyPayload } from "./reply-payload.js";
export {
createNormalizedOutboundDeliverer,

View File

@@ -14,13 +14,17 @@ export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
export { clearAccountEntryFields } from "../channels/plugins/config-helpers.js";
export {
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
} from "../config/runtime-group-policy.js";
export { buildTokenChannelStatusSummary } from "./status-helpers.js";
export {
buildComputedAccountStatusSnapshot,
buildTokenChannelStatusSummary,
} from "./status-helpers.js";
export { LineConfigSchema } from "../line/config-schema.js";
export type { LineChannelData, LineConfig, ResolvedLineAccount } from "../line/types.js";

View File

@@ -92,5 +92,10 @@ export type { WizardPrompter } from "../wizard/prompts.js";
export { createScopedPairingAccess } from "./pairing-access.js";
export { formatResolvedUnresolvedNote } from "./resolution-notes.js";
export { runPluginCommandWithTimeout } from "./run-command.js";
export { createLoggerBackedRuntime } from "./runtime.js";
export { buildProbeChannelStatusSummary } from "./status-helpers.js";
export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js";
export { createLoggerBackedRuntime, resolveRuntimeEnv } from "./runtime.js";
export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js";
export {
buildProbeChannelStatusSummary,
collectStatusIssuesFromLastError,
} from "./status-helpers.js";

View File

@@ -2,6 +2,7 @@
// Keep this list additive and scoped to symbols used under extensions/minimax-portal-auth.
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
export type {
OpenClawPluginApi,
ProviderAuthContext,

View File

@@ -94,9 +94,11 @@ export { loadWebMedia } from "../web/media.js";
export type { WizardPrompter } from "../wizard/prompts.js";
export { keepHttpServerTaskAlive } from "./channel-lifecycle.js";
export { withFileLock } from "./file-lock.js";
export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js";
export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js";
export { loadOutboundMediaFromUrl } from "./outbound-media.js";
export { createScopedPairingAccess } from "./pairing-access.js";
export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js";
export {
buildHostnameAllowlistPolicyFromSuffixAllowlist,
isHttpsUrlAllowedByHostnameSuffixAllowlist,
@@ -104,5 +106,7 @@ export {
} from "./ssrf-policy.js";
export {
buildBaseChannelStatusSummary,
buildProbeChannelStatusSummary,
buildRuntimeAccountStatusSnapshot,
createDefaultChannelRuntimeState,
} from "./status-helpers.js";

View File

@@ -12,6 +12,7 @@ export {
} from "../channels/plugins/channel-config.js";
export {
deleteAccountFromConfigSection,
clearAccountEntryFields,
setAccountEnabledInConfigSection,
} from "../channels/plugins/config-helpers.js";
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
@@ -89,4 +90,9 @@ export {
formatTextWithAttachmentLinks,
resolveOutboundMediaUrls,
} from "./reply-payload.js";
export { dispatchInboundReplyWithBase } from "./inbound-reply-dispatch.js";
export { createLoggerBackedRuntime } from "./runtime.js";
export {
buildBaseChannelStatusSummary,
buildRuntimeAccountStatusSnapshot,
} from "./status-helpers.js";

View File

@@ -2,5 +2,6 @@
// Keep this list additive and scoped to symbols used under extensions/qwen-portal-auth.
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
export type { OpenClawPluginApi, ProviderAuthContext } from "../plugins/types.js";
export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.js";

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { isNumericTargetId, sendPayloadWithChunkedTextAndMedia } from "./reply-payload.js";
describe("sendPayloadWithChunkedTextAndMedia", () => {
it("returns empty result when payload has no text and no media", async () => {
const result = await sendPayloadWithChunkedTextAndMedia({
ctx: { payload: {} },
sendText: async () => ({ channel: "test", messageId: "text" }),
sendMedia: async () => ({ channel: "test", messageId: "media" }),
emptyResult: { channel: "test", messageId: "" },
});
expect(result).toEqual({ channel: "test", messageId: "" });
});
it("sends first media with text and remaining media without text", async () => {
const calls: Array<{ text: string; mediaUrl: string }> = [];
const result = await sendPayloadWithChunkedTextAndMedia({
ctx: {
payload: { text: "hello", mediaUrls: ["https://a", "https://b"] },
},
sendText: async () => ({ channel: "test", messageId: "text" }),
sendMedia: async (ctx) => {
calls.push({ text: ctx.text, mediaUrl: ctx.mediaUrl });
return { channel: "test", messageId: ctx.mediaUrl };
},
emptyResult: { channel: "test", messageId: "" },
});
expect(calls).toEqual([
{ text: "hello", mediaUrl: "https://a" },
{ text: "", mediaUrl: "https://b" },
]);
expect(result).toEqual({ channel: "test", messageId: "https://b" });
});
it("chunks text and sends each chunk", async () => {
const chunks: string[] = [];
const result = await sendPayloadWithChunkedTextAndMedia({
ctx: { payload: { text: "alpha beta gamma" } },
textChunkLimit: 5,
chunker: () => ["alpha", "beta", "gamma"],
sendText: async (ctx) => {
chunks.push(ctx.text);
return { channel: "test", messageId: ctx.text };
},
sendMedia: async () => ({ channel: "test", messageId: "media" }),
emptyResult: { channel: "test", messageId: "" },
});
expect(chunks).toEqual(["alpha", "beta", "gamma"]);
expect(result).toEqual({ channel: "test", messageId: "gamma" });
});
it("detects numeric target IDs", () => {
expect(isNumericTargetId("12345")).toBe(true);
expect(isNumericTargetId(" 987 ")).toBe(true);
expect(isNumericTargetId("ab12")).toBe(false);
expect(isNumericTargetId("")).toBe(false);
});
});

View File

@@ -49,6 +49,55 @@ export function resolveOutboundMediaUrls(payload: {
return [];
}
export async function sendPayloadWithChunkedTextAndMedia<
TContext extends { payload: object },
TResult,
>(params: {
ctx: TContext;
textChunkLimit?: number;
chunker?: ((text: string, limit: number) => string[]) | null;
sendText: (ctx: TContext & { text: string }) => Promise<TResult>;
sendMedia: (ctx: TContext & { text: string; mediaUrl: string }) => Promise<TResult>;
emptyResult: TResult;
}): Promise<TResult> {
const payload = params.ctx.payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string };
const text = payload.text ?? "";
const urls = resolveOutboundMediaUrls(payload);
if (!text && urls.length === 0) {
return params.emptyResult;
}
if (urls.length > 0) {
let lastResult = await params.sendMedia({
...params.ctx,
text,
mediaUrl: urls[0],
});
for (let i = 1; i < urls.length; i++) {
lastResult = await params.sendMedia({
...params.ctx,
text: "",
mediaUrl: urls[i],
});
}
return lastResult;
}
const limit = params.textChunkLimit;
const chunks = limit && params.chunker ? params.chunker(text, limit) : [text];
let lastResult: TResult;
for (const chunk of chunks) {
lastResult = await params.sendText({ ...params.ctx, text: chunk });
}
return lastResult!;
}
export function isNumericTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
return /^\d{3,}$/.test(trimmed);
}
export function formatTextWithAttachmentLinks(
text: string | undefined,
mediaUrls: string[],

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { resolveRequestUrl } from "./request-url.js";
describe("resolveRequestUrl", () => {
it("resolves string input", () => {
expect(resolveRequestUrl("https://example.com/a")).toBe("https://example.com/a");
});
it("resolves URL input", () => {
expect(resolveRequestUrl(new URL("https://example.com/b"))).toBe("https://example.com/b");
});
it("resolves object input with url field", () => {
const requestLike = { url: "https://example.com/c" } as unknown as RequestInfo;
expect(resolveRequestUrl(requestLike)).toBe("https://example.com/c");
});
});

View File

@@ -0,0 +1,12 @@
export function resolveRequestUrl(input: RequestInfo | URL): string {
if (typeof input === "string") {
return input;
}
if (input instanceof URL) {
return input.toString();
}
if (typeof input === "object" && input && "url" in input && typeof input.url === "string") {
return input.url;
}
return "";
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
import { resolveRuntimeEnv } from "./runtime.js";
describe("resolveRuntimeEnv", () => {
it("returns provided runtime when present", () => {
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(() => {
throw new Error("exit");
}),
};
const logger = {
info: vi.fn(),
error: vi.fn(),
};
const resolved = resolveRuntimeEnv({ runtime, logger });
expect(resolved).toBe(runtime);
expect(logger.info).not.toHaveBeenCalled();
expect(logger.error).not.toHaveBeenCalled();
});
it("creates logger-backed runtime when runtime is missing", () => {
const logger = {
info: vi.fn(),
error: vi.fn(),
};
const resolved = resolveRuntimeEnv({ logger });
resolved.log?.("hello %s", "world");
resolved.error?.("bad %d", 7);
expect(logger.info).toHaveBeenCalledWith("hello world");
expect(logger.error).toHaveBeenCalledWith("bad 7");
});
});

View File

@@ -22,3 +22,23 @@ export function createLoggerBackedRuntime(params: {
},
};
}
export function resolveRuntimeEnv(params: {
runtime?: RuntimeEnv;
logger: LoggerLike;
exitError?: (code: number) => Error;
}): RuntimeEnv {
return params.runtime ?? createLoggerBackedRuntime(params);
}
export function resolveRuntimeEnvWithUnavailableExit(params: {
runtime?: RuntimeEnv;
logger: LoggerLike;
unavailableMessage?: string;
}): RuntimeEnv {
return resolveRuntimeEnv({
runtime: params.runtime,
logger: params.logger,
exitError: () => new Error(params.unavailableMessage ?? "Runtime exit not available"),
});
}

View File

@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
import {
buildBaseAccountStatusSnapshot,
buildBaseChannelStatusSummary,
buildComputedAccountStatusSnapshot,
buildRuntimeAccountStatusSnapshot,
buildTokenChannelStatusSummary,
collectStatusIssuesFromLastError,
createDefaultChannelRuntimeState,
@@ -88,6 +90,42 @@ describe("buildBaseAccountStatusSnapshot", () => {
});
});
describe("buildComputedAccountStatusSnapshot", () => {
it("builds account status when configured is computed outside resolver", () => {
expect(
buildComputedAccountStatusSnapshot({
accountId: "default",
enabled: true,
configured: false,
}),
).toEqual({
accountId: "default",
name: undefined,
enabled: true,
configured: false,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
probe: undefined,
lastInboundAt: null,
lastOutboundAt: null,
});
});
});
describe("buildRuntimeAccountStatusSnapshot", () => {
it("builds runtime lifecycle fields with defaults", () => {
expect(buildRuntimeAccountStatusSnapshot({})).toEqual({
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
probe: undefined,
});
});
});
describe("buildTokenChannelStatusSummary", () => {
it("includes token/probe fields with mode by default", () => {
expect(buildTokenChannelStatusSummary({})).toEqual({

View File

@@ -81,13 +81,44 @@ export function buildBaseAccountStatusSnapshot(params: {
name: account.name,
enabled: account.enabled,
configured: account.configured,
...buildRuntimeAccountStatusSnapshot({ runtime, probe }),
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
};
}
export function buildComputedAccountStatusSnapshot(params: {
accountId: string;
name?: string;
enabled?: boolean;
configured?: boolean;
runtime?: RuntimeLifecycleSnapshot | null;
probe?: unknown;
}) {
const { accountId, name, enabled, configured, runtime, probe } = params;
return buildBaseAccountStatusSnapshot({
account: {
accountId,
name,
enabled,
configured,
},
runtime,
probe,
});
}
export function buildRuntimeAccountStatusSnapshot(params: {
runtime?: RuntimeLifecycleSnapshot | null;
probe?: unknown;
}) {
const { runtime, probe } = params;
return {
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
};
}

View File

@@ -22,6 +22,7 @@ export {
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
export {
deleteAccountFromConfigSection,
clearAccountEntryFields,
setAccountEnabledInConfigSection,
} from "../channels/plugins/config-helpers.js";
export { formatPairingApproveHint } from "../channels/plugins/helpers.js";

Some files were not shown because too many files have changed in this diff Show More