mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor(core): extract shared dedup helpers
This commit is contained in:
@@ -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
588
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
15
src/agents/pi-tool-handler-state.test-helpers.ts
Normal file
15
src/agents/pi-tool-handler-state.test-helpers.ts
Normal 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: "",
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
16
src/agents/test-helpers/usage-fixtures.ts
Normal file
16
src/agents/test-helpers/usage-fixtures.ts
Normal 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
21
src/agents/trace-base.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
63
src/auto-reply/reply/get-reply.test-mocks.ts
Normal file
63
src/auto-reply/reply/get-reply.test-mocks.ts
Normal 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,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
110
src/channels/plugins/config-helpers.test.ts
Normal file
110
src/channels/plugins/config-helpers.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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: [] };
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
69
src/gateway/auth-config-utils.ts
Normal file
69
src/gateway/auth-config-utils.ts
Normal 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());
|
||||
}
|
||||
@@ -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<{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1013,6 +1013,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
||||
shouldRetryExecReadProbe({
|
||||
text: execReadText,
|
||||
nonce: nonceC,
|
||||
provider: model.provider,
|
||||
attempt: execReadAttempt,
|
||||
maxAttempts: maxExecReadAttempts,
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
"can’t help",
|
||||
"cannot help",
|
||||
"can't comply",
|
||||
"can’t 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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
19
src/infra/parse-finite-number.test.ts
Normal file
19
src/infra/parse-finite-number.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
12
src/infra/parse-finite-number.ts
Normal file
12
src/infra/parse-finite-number.ts
Normal 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;
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
60
src/memory/batch-status.test.ts
Normal file
60
src/memory/batch-status.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
69
src/memory/batch-status.ts
Normal file
69
src/memory/batch-status.ts
Normal 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;
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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.`);
|
||||
}
|
||||
|
||||
40
src/plugin-sdk/allowlist-resolution.test.ts
Normal file
40
src/plugin-sdk/allowlist-resolution.test.ts
Normal 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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
19
src/plugin-sdk/allowlist-resolution.ts
Normal file
19
src/plugin-sdk/allowlist-resolution.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
14
src/plugin-sdk/channel-send-result.ts
Normal file
14
src/plugin-sdk/channel-send-result.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
33
src/plugin-sdk/discord-send.ts
Normal file
33
src/plugin-sdk/discord-send.ts
Normal 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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
143
src/plugin-sdk/inbound-reply-dispatch.ts
Normal file
143
src/plugin-sdk/inbound-reply-dispatch.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
58
src/plugin-sdk/reply-payload.test.ts
Normal file
58
src/plugin-sdk/reply-payload.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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[],
|
||||
|
||||
17
src/plugin-sdk/request-url.test.ts
Normal file
17
src/plugin-sdk/request-url.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
12
src/plugin-sdk/request-url.ts
Normal file
12
src/plugin-sdk/request-url.ts
Normal 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 "";
|
||||
}
|
||||
39
src/plugin-sdk/runtime.test.ts
Normal file
39
src/plugin-sdk/runtime.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user