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:list": "node scripts/docs-list.js",
|
||||||
"docs:spellcheck": "bash scripts/docs-spellcheck.sh",
|
"docs:spellcheck": "bash scripts/docs-spellcheck.sh",
|
||||||
"docs:spellcheck:fix": "bash scripts/docs-spellcheck.sh --write",
|
"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": "oxfmt --write",
|
||||||
"format:all": "pnpm format && pnpm format:swift",
|
"format:all": "pnpm format && pnpm format:swift",
|
||||||
"format:check": "oxfmt --check",
|
"format:check": "oxfmt --check",
|
||||||
@@ -393,6 +395,7 @@
|
|||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20260301.1",
|
"@typescript/native-preview": "7.0.0-dev.20260301.1",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
|
"jscpd": "4.0.8",
|
||||||
"lit": "^3.3.2",
|
"lit": "^3.3.2",
|
||||||
"oxfmt": "0.35.0",
|
"oxfmt": "0.35.0",
|
||||||
"oxlint": "^1.50.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", () => {
|
describe("serveAcpGateway startup", () => {
|
||||||
let serveAcpGateway: typeof import("./server.js").serveAcpGateway;
|
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 () => {
|
beforeAll(async () => {
|
||||||
({ serveAcpGateway } = await import("./server.js"));
|
({ serveAcpGateway } = await import("./server.js"));
|
||||||
});
|
});
|
||||||
@@ -117,25 +137,14 @@ describe("serveAcpGateway startup", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("waits for gateway hello before creating AgentSideConnection", async () => {
|
it("waits for gateway hello before creating AgentSideConnection", async () => {
|
||||||
const signalHandlers = new Map<NodeJS.Signals, () => void>();
|
const { signalHandlers, onceSpy } = captureProcessSignalHandlers();
|
||||||
const onceSpy = vi.spyOn(process, "once").mockImplementation(((
|
|
||||||
signal: NodeJS.Signals,
|
|
||||||
handler: () => void,
|
|
||||||
) => {
|
|
||||||
signalHandlers.set(signal, handler);
|
|
||||||
return process;
|
|
||||||
}) as typeof process.once);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const servePromise = serveAcpGateway({});
|
const servePromise = serveAcpGateway({});
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(mockState.agentSideConnectionCtor).not.toHaveBeenCalled();
|
expect(mockState.agentSideConnectionCtor).not.toHaveBeenCalled();
|
||||||
const gateway = mockState.gateways[0];
|
const gateway = getMockGateway();
|
||||||
if (!gateway) {
|
|
||||||
throw new Error("Expected mocked gateway instance");
|
|
||||||
}
|
|
||||||
|
|
||||||
gateway.emitHello();
|
gateway.emitHello();
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(mockState.agentSideConnectionCtor).toHaveBeenCalledTimes(1);
|
expect(mockState.agentSideConnectionCtor).toHaveBeenCalledTimes(1);
|
||||||
@@ -159,11 +168,7 @@ describe("serveAcpGateway startup", () => {
|
|||||||
const servePromise = serveAcpGateway({});
|
const servePromise = serveAcpGateway({});
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
const gateway = mockState.gateways[0];
|
const gateway = getMockGateway();
|
||||||
if (!gateway) {
|
|
||||||
throw new Error("Expected mocked gateway instance");
|
|
||||||
}
|
|
||||||
|
|
||||||
gateway.emitConnectError("connect failed");
|
gateway.emitConnectError("connect failed");
|
||||||
await expect(servePromise).rejects.toThrow("connect failed");
|
await expect(servePromise).rejects.toThrow("connect failed");
|
||||||
expect(mockState.agentSideConnectionCtor).not.toHaveBeenCalled();
|
expect(mockState.agentSideConnectionCtor).not.toHaveBeenCalled();
|
||||||
@@ -177,14 +182,7 @@ describe("serveAcpGateway startup", () => {
|
|||||||
token: undefined,
|
token: undefined,
|
||||||
password: "resolved-secret-password",
|
password: "resolved-secret-password",
|
||||||
});
|
});
|
||||||
const signalHandlers = new Map<NodeJS.Signals, () => void>();
|
const { signalHandlers, onceSpy } = captureProcessSignalHandlers();
|
||||||
const onceSpy = vi.spyOn(process, "once").mockImplementation(((
|
|
||||||
signal: NodeJS.Signals,
|
|
||||||
handler: () => void,
|
|
||||||
) => {
|
|
||||||
signalHandlers.set(signal, handler);
|
|
||||||
return process;
|
|
||||||
}) as typeof process.once);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const servePromise = serveAcpGateway({});
|
const servePromise = serveAcpGateway({});
|
||||||
@@ -200,10 +198,7 @@ describe("serveAcpGateway startup", () => {
|
|||||||
password: "resolved-secret-password",
|
password: "resolved-secret-password",
|
||||||
});
|
});
|
||||||
|
|
||||||
const gateway = mockState.gateways[0];
|
const gateway = getMockGateway();
|
||||||
if (!gateway) {
|
|
||||||
throw new Error("Expected mocked gateway instance");
|
|
||||||
}
|
|
||||||
gateway.emitHello();
|
gateway.emitHello();
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
expect(mockState.agentSideConnectionCtor).toHaveBeenCalledTimes(1);
|
expect(mockState.agentSideConnectionCtor).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import {
|
import {
|
||||||
addAllowlistEntry,
|
addAllowlistEntry,
|
||||||
@@ -20,11 +19,12 @@ import {
|
|||||||
registerExecApprovalRequestForHostOrThrow,
|
registerExecApprovalRequestForHostOrThrow,
|
||||||
} from "./bash-tools.exec-approval-request.js";
|
} from "./bash-tools.exec-approval-request.js";
|
||||||
import {
|
import {
|
||||||
|
createDefaultExecApprovalRequestContext,
|
||||||
|
resolveBaseExecApprovalDecision,
|
||||||
resolveApprovalDecisionOrUndefined,
|
resolveApprovalDecisionOrUndefined,
|
||||||
resolveExecHostApprovalContext,
|
resolveExecHostApprovalContext,
|
||||||
} from "./bash-tools.exec-host-shared.js";
|
} from "./bash-tools.exec-host-shared.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_APPROVAL_TIMEOUT_MS,
|
|
||||||
DEFAULT_NOTIFY_TAIL_CHARS,
|
DEFAULT_NOTIFY_TAIL_CHARS,
|
||||||
createApprovalSlug,
|
createApprovalSlug,
|
||||||
emitExecSystemEvent,
|
emitExecSystemEvent,
|
||||||
@@ -138,16 +138,24 @@ export async function processGatewayAllowlist(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (requiresAsk) {
|
if (requiresAsk) {
|
||||||
const approvalId = crypto.randomUUID();
|
const {
|
||||||
const approvalSlug = createApprovalSlug(approvalId);
|
approvalId,
|
||||||
const contextKey = `exec:${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 resolvedPath = allowlistEval.segments[0]?.resolution?.resolvedPath;
|
||||||
const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000));
|
|
||||||
const effectiveTimeout =
|
const effectiveTimeout =
|
||||||
typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec;
|
typeof params.timeoutSec === "number" ? params.timeoutSec : params.defaultTimeoutSec;
|
||||||
const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : "";
|
let expiresAtMs = defaultExpiresAtMs;
|
||||||
let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
|
let preResolvedDecision = defaultPreResolvedDecision;
|
||||||
let preResolvedDecision: string | null | undefined;
|
|
||||||
|
|
||||||
// Register first so the returned approval ID is actionable immediately.
|
// Register first so the returned approval ID is actionable immediately.
|
||||||
const registration = await registerExecApprovalRequestForHostOrThrow({
|
const registration = await registerExecApprovalRequestForHostOrThrow({
|
||||||
@@ -184,24 +192,19 @@ export async function processGatewayAllowlist(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let approvedByAsk = false;
|
const baseDecision = resolveBaseExecApprovalDecision({
|
||||||
let deniedReason: string | null = null;
|
decision,
|
||||||
|
askFallback,
|
||||||
|
obfuscationDetected: obfuscation.detected,
|
||||||
|
});
|
||||||
|
let approvedByAsk = baseDecision.approvedByAsk;
|
||||||
|
let deniedReason = baseDecision.deniedReason;
|
||||||
|
|
||||||
if (decision === "deny") {
|
if (baseDecision.timedOut && askFallback === "allowlist") {
|
||||||
deniedReason = "user-denied";
|
if (!analysisOk || !allowlistSatisfied) {
|
||||||
} else if (!decision) {
|
deniedReason = "approval-timeout (allowlist-miss)";
|
||||||
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;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
deniedReason = "approval-timeout";
|
approvedByAsk = true;
|
||||||
}
|
}
|
||||||
} else if (decision === "allow-once") {
|
} else if (decision === "allow-once") {
|
||||||
approvedByAsk = true;
|
approvedByAsk = true;
|
||||||
|
|||||||
@@ -18,14 +18,12 @@ import {
|
|||||||
registerExecApprovalRequestForHostOrThrow,
|
registerExecApprovalRequestForHostOrThrow,
|
||||||
} from "./bash-tools.exec-approval-request.js";
|
} from "./bash-tools.exec-approval-request.js";
|
||||||
import {
|
import {
|
||||||
|
createDefaultExecApprovalRequestContext,
|
||||||
|
resolveBaseExecApprovalDecision,
|
||||||
resolveApprovalDecisionOrUndefined,
|
resolveApprovalDecisionOrUndefined,
|
||||||
resolveExecHostApprovalContext,
|
resolveExecHostApprovalContext,
|
||||||
} from "./bash-tools.exec-host-shared.js";
|
} from "./bash-tools.exec-host-shared.js";
|
||||||
import {
|
import { createApprovalSlug, emitExecSystemEvent } from "./bash-tools.exec-runtime.js";
|
||||||
DEFAULT_APPROVAL_TIMEOUT_MS,
|
|
||||||
createApprovalSlug,
|
|
||||||
emitExecSystemEvent,
|
|
||||||
} from "./bash-tools.exec-runtime.js";
|
|
||||||
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
|
import type { ExecToolDetails } from "./bash-tools.exec-types.js";
|
||||||
import { callGatewayTool } from "./tools/gateway.js";
|
import { callGatewayTool } from "./tools/gateway.js";
|
||||||
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
|
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
|
||||||
@@ -209,13 +207,21 @@ export async function executeNodeHostCommand(
|
|||||||
}) satisfies Record<string, unknown>;
|
}) satisfies Record<string, unknown>;
|
||||||
|
|
||||||
if (requiresAsk) {
|
if (requiresAsk) {
|
||||||
const approvalId = crypto.randomUUID();
|
const {
|
||||||
const approvalSlug = createApprovalSlug(approvalId);
|
approvalId,
|
||||||
const contextKey = `exec:${approvalId}`;
|
approvalSlug,
|
||||||
const noticeSeconds = Math.max(1, Math.round(params.approvalRunningNoticeMs / 1000));
|
contextKey,
|
||||||
const warningText = params.warnings.length ? `${params.warnings.join("\n")}\n\n` : "";
|
noticeSeconds,
|
||||||
let expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
|
warningText,
|
||||||
let preResolvedDecision: string | null | undefined;
|
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.
|
// Register first so the returned approval ID is actionable immediately.
|
||||||
const registration = await registerExecApprovalRequestForHostOrThrow({
|
const registration = await registerExecApprovalRequestForHostOrThrow({
|
||||||
@@ -252,23 +258,17 @@ export async function executeNodeHostCommand(
|
|||||||
return;
|
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 approvalDecision: "allow-once" | "allow-always" | null = null;
|
||||||
let deniedReason: string | null = null;
|
let deniedReason = baseDecision.deniedReason;
|
||||||
|
|
||||||
if (decision === "deny") {
|
if (baseDecision.timedOut && askFallback === "full" && approvedByAsk) {
|
||||||
deniedReason = "user-denied";
|
approvalDecision = "allow-once";
|
||||||
} 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";
|
|
||||||
}
|
|
||||||
} else if (decision === "allow-once") {
|
} else if (decision === "allow-once") {
|
||||||
approvedByAsk = true;
|
approvedByAsk = true;
|
||||||
approvalDecision = "allow-once";
|
approvalDecision = "allow-once";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
import {
|
import {
|
||||||
maxAsk,
|
maxAsk,
|
||||||
minSecurity,
|
minSecurity,
|
||||||
@@ -6,6 +7,7 @@ import {
|
|||||||
type ExecSecurity,
|
type ExecSecurity,
|
||||||
} from "../infra/exec-approvals.js";
|
} from "../infra/exec-approvals.js";
|
||||||
import { resolveRegisteredExecApprovalDecision } from "./bash-tools.exec-approval-request.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>;
|
type ResolvedExecApprovals = ReturnType<typeof resolveExecApprovals>;
|
||||||
|
|
||||||
@@ -16,6 +18,110 @@ export type ExecHostApprovalContext = {
|
|||||||
askFallback: ResolvedExecApprovals["agent"]["askFallback"];
|
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: {
|
export function resolveExecHostApprovalContext(params: {
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
security: ExecSecurity;
|
security: ExecSecurity;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import { Type } from "@sinclair/typebox";
|
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 { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||||
import { isDangerousHostEnvVarName } from "../infra/host-env-security.js";
|
import { isDangerousHostEnvVarName } from "../infra/host-env-security.js";
|
||||||
import { findPathKey, mergePathPrepend } from "../infra/path-prepend.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 { ExecToolDetails } from "./bash-tools.exec-types.js";
|
||||||
import type { BashSandboxConfig } from "./bash-tools.shared.js";
|
import type { BashSandboxConfig } from "./bash-tools.shared.js";
|
||||||
export { applyPathPrepend, findPathKey, normalizePathPrepend } from "../infra/path-prepend.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 { logWarn } from "../logger.js";
|
||||||
import type { ManagedRun } from "../process/supervisor/index.js";
|
import type { ManagedRun } from "../process/supervisor/index.js";
|
||||||
import { getProcessSupervisor } from "../process/supervisor/index.js";
|
import { getProcessSupervisor } from "../process/supervisor/index.js";
|
||||||
@@ -156,30 +161,6 @@ export type ExecProcessHandle = {
|
|||||||
kill: () => void;
|
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) {
|
export function renderExecHostLabel(host: ExecHost) {
|
||||||
return host === "sandbox" ? "sandbox" : host === "gateway" ? "gateway" : "node";
|
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 { safeJsonStringify } from "../utils/safe-json.js";
|
||||||
import { redactImageDataForDiagnostics } from "./payload-redaction.js";
|
import { redactImageDataForDiagnostics } from "./payload-redaction.js";
|
||||||
import { getQueuedFileWriter, type QueuedFileWriter } from "./queued-file-writer.js";
|
import { getQueuedFileWriter, type QueuedFileWriter } from "./queued-file-writer.js";
|
||||||
|
import { buildAgentTraceBase } from "./trace-base.js";
|
||||||
|
|
||||||
export type CacheTraceStage =
|
export type CacheTraceStage =
|
||||||
| "session:loaded"
|
| "session:loaded"
|
||||||
@@ -173,15 +174,7 @@ export function createCacheTrace(params: CacheTraceInit): CacheTrace | null {
|
|||||||
const writer = params.writer ?? getWriter(cfg.filePath);
|
const writer = params.writer ?? getWriter(cfg.filePath);
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
|
|
||||||
const base: Omit<CacheTraceEvent, "ts" | "seq" | "stage"> = {
|
const base: Omit<CacheTraceEvent, "ts" | "seq" | "stage"> = buildAgentTraceBase(params);
|
||||||
runId: params.runId,
|
|
||||||
sessionId: params.sessionId,
|
|
||||||
sessionKey: params.sessionKey,
|
|
||||||
provider: params.provider,
|
|
||||||
modelId: params.modelId,
|
|
||||||
modelApi: params.modelApi,
|
|
||||||
workspaceDir: params.workspaceDir,
|
|
||||||
};
|
|
||||||
|
|
||||||
const recordStage: CacheTrace["recordStage"] = (stage, payload = {}) => {
|
const recordStage: CacheTrace["recordStage"] = (stage, payload = {}) => {
|
||||||
const event: CacheTraceEvent = {
|
const event: CacheTraceEvent = {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
pruneHistoryForContextShare,
|
pruneHistoryForContextShare,
|
||||||
splitMessagesByTokenShare,
|
splitMessagesByTokenShare,
|
||||||
} from "./compaction.js";
|
} from "./compaction.js";
|
||||||
|
import { makeAgentAssistantMessage } from "./test-helpers/agent-message-fixtures.js";
|
||||||
|
|
||||||
function makeMessage(id: number, size: number): AgentMessage {
|
function makeMessage(id: number, size: number): AgentMessage {
|
||||||
return {
|
return {
|
||||||
@@ -24,26 +25,15 @@ function makeAssistantToolCall(
|
|||||||
toolCallId: string,
|
toolCallId: string,
|
||||||
text = "x".repeat(4000),
|
text = "x".repeat(4000),
|
||||||
): AssistantMessage {
|
): AssistantMessage {
|
||||||
return {
|
return makeAgentAssistantMessage({
|
||||||
role: "assistant",
|
|
||||||
content: [
|
content: [
|
||||||
{ type: "text", text },
|
{ type: "text", text },
|
||||||
{ type: "toolCall", id: toolCallId, name: "test_tool", arguments: {} },
|
{ type: "toolCall", id: toolCallId, name: "test_tool", arguments: {} },
|
||||||
],
|
],
|
||||||
api: "openai-responses",
|
|
||||||
provider: "openai",
|
|
||||||
model: "gpt-5.2",
|
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",
|
stopReason: "stop",
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeToolResult(timestamp: number, toolCallId: string, text: string): ToolResultMessage {
|
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
|
// all corresponding tool_results should be removed from kept messages
|
||||||
const messages: AgentMessage[] = [
|
const messages: AgentMessage[] = [
|
||||||
// Chunk 1 (will be dropped) - contains multiple tool_use blocks
|
// Chunk 1 (will be dropped) - contains multiple tool_use blocks
|
||||||
{
|
makeAgentAssistantMessage({
|
||||||
role: "assistant",
|
|
||||||
content: [
|
content: [
|
||||||
{ type: "text", text: "x".repeat(4000) },
|
{ type: "text", text: "x".repeat(4000) },
|
||||||
{ type: "toolCall", id: "call_a", name: "tool_a", arguments: {} },
|
{ type: "toolCall", id: "call_a", name: "tool_a", arguments: {} },
|
||||||
{ type: "toolCall", id: "call_b", name: "tool_b", arguments: {} },
|
{ type: "toolCall", id: "call_b", name: "tool_b", arguments: {} },
|
||||||
],
|
],
|
||||||
api: "openai-responses",
|
|
||||||
provider: "openai",
|
|
||||||
model: "gpt-5.2",
|
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",
|
stopReason: "stop",
|
||||||
timestamp: 1,
|
timestamp: 1,
|
||||||
},
|
}),
|
||||||
// Chunk 2 (will be kept) - contains orphaned tool_results
|
// Chunk 2 (will be kept) - contains orphaned tool_results
|
||||||
makeToolResult(2, "call_a", "result_a"),
|
makeToolResult(2, "call_a", "result_a"),
|
||||||
makeToolResult(3, "call_b", "result_b"),
|
makeToolResult(3, "call_b", "result_b"),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
import type { AssistantMessage, ToolResultMessage } from "@mariozechner/pi-ai";
|
import type { AssistantMessage, ToolResultMessage } from "@mariozechner/pi-ai";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { makeAgentAssistantMessage } from "./test-helpers/agent-message-fixtures.js";
|
||||||
|
|
||||||
const piCodingAgentMocks = vi.hoisted(() => ({
|
const piCodingAgentMocks = vi.hoisted(() => ({
|
||||||
generateSummary: vi.fn(async () => "summary"),
|
generateSummary: vi.fn(async () => "summary"),
|
||||||
@@ -21,23 +22,12 @@ vi.mock("@mariozechner/pi-coding-agent", async () => {
|
|||||||
import { isOversizedForSummary, summarizeWithFallback } from "./compaction.js";
|
import { isOversizedForSummary, summarizeWithFallback } from "./compaction.js";
|
||||||
|
|
||||||
function makeAssistantToolCall(timestamp: number): AssistantMessage {
|
function makeAssistantToolCall(timestamp: number): AssistantMessage {
|
||||||
return {
|
return makeAgentAssistantMessage({
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "toolCall", id: "call_1", name: "browser", arguments: { action: "tabs" } }],
|
content: [{ type: "toolCall", id: "call_1", name: "browser", arguments: { action: "tabs" } }],
|
||||||
api: "openai-responses",
|
|
||||||
provider: "openai",
|
|
||||||
model: "gpt-5.2",
|
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",
|
stopReason: "toolUse",
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeToolResultWithDetails(timestamp: number): ToolResultMessage<{ raw: string }> {
|
function makeToolResultWithDetails(timestamp: number): ToolResultMessage<{ raw: string }> {
|
||||||
|
|||||||
@@ -1,33 +1,37 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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", () => {
|
describe("lookupContextTokens", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns configured model context window on first lookup", async () => {
|
it("returns configured model context window on first lookup", async () => {
|
||||||
vi.doMock("../config/config.js", () => ({
|
mockContextModuleDeps(() => ({
|
||||||
loadConfig: () => ({
|
models: {
|
||||||
models: {
|
providers: {
|
||||||
providers: {
|
openrouter: {
|
||||||
openrouter: {
|
models: [{ id: "openrouter/claude-sonnet", contextWindow: 321_000 }],
|
||||||
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");
|
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 () => {
|
it("does not skip eager warmup when --profile is followed by -- terminator", async () => {
|
||||||
const loadConfigMock = vi.fn(() => ({ models: {} }));
|
const loadConfigMock = vi.fn(() => ({ models: {} }));
|
||||||
vi.doMock("../config/config.js", () => ({
|
mockContextModuleDeps(loadConfigMock);
|
||||||
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: () => [],
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const argvSnapshot = process.argv;
|
const argvSnapshot = process.argv;
|
||||||
process.argv = ["node", "openclaw", "--profile", "--", "config", "validate"];
|
process.argv = ["node", "openclaw", "--profile", "--", "config", "validate"];
|
||||||
@@ -79,21 +69,7 @@ describe("lookupContextTokens", () => {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.doMock("../config/config.js", () => ({
|
mockContextModuleDeps(loadConfigMock);
|
||||||
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: () => [],
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const argvSnapshot = process.argv;
|
const argvSnapshot = process.argv;
|
||||||
process.argv = ["node", "openclaw", "config", "validate"];
|
process.argv = ["node", "openclaw", "config", "validate"];
|
||||||
|
|||||||
@@ -19,6 +19,33 @@ function throwPathEscapesBoundary(params: {
|
|||||||
throw new Error(`Path escapes ${boundary}${suffix}: ${params.candidate}`);
|
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: {
|
function toRelativePathUnderRoot(params: {
|
||||||
root: string;
|
root: string;
|
||||||
candidate: string;
|
candidate: string;
|
||||||
@@ -35,47 +62,44 @@ function toRelativePathUnderRoot(params: {
|
|||||||
const rootForCompare = normalizeWindowsPathForComparison(rootResolved);
|
const rootForCompare = normalizeWindowsPathForComparison(rootResolved);
|
||||||
const targetForCompare = normalizeWindowsPathForComparison(resolvedCandidate);
|
const targetForCompare = normalizeWindowsPathForComparison(resolvedCandidate);
|
||||||
const relative = path.win32.relative(rootForCompare, targetForCompare);
|
const relative = path.win32.relative(rootForCompare, targetForCompare);
|
||||||
if (relative === "" || relative === ".") {
|
return validateRelativePathWithinBoundary({
|
||||||
if (params.options?.allowRoot) {
|
relativePath: relative,
|
||||||
return "";
|
isAbsolutePath: path.win32.isAbsolute,
|
||||||
}
|
options: params.options,
|
||||||
throwPathEscapesBoundary({
|
rootResolved,
|
||||||
options: params.options,
|
candidate: params.candidate,
|
||||||
rootResolved,
|
});
|
||||||
candidate: params.candidate,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (relative.startsWith("..") || path.win32.isAbsolute(relative)) {
|
|
||||||
throwPathEscapesBoundary({
|
|
||||||
options: params.options,
|
|
||||||
rootResolved,
|
|
||||||
candidate: params.candidate,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return relative;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootResolved = path.resolve(params.root);
|
const rootResolved = path.resolve(params.root);
|
||||||
const resolvedCandidate = path.resolve(resolvedInput);
|
const resolvedCandidate = path.resolve(resolvedInput);
|
||||||
const relative = path.relative(rootResolved, resolvedCandidate);
|
const relative = path.relative(rootResolved, resolvedCandidate);
|
||||||
if (relative === "" || relative === ".") {
|
return validateRelativePathWithinBoundary({
|
||||||
if (params.options?.allowRoot) {
|
relativePath: relative,
|
||||||
return "";
|
isAbsolutePath: path.isAbsolute,
|
||||||
}
|
options: params.options,
|
||||||
throwPathEscapesBoundary({
|
rootResolved,
|
||||||
options: params.options,
|
candidate: params.candidate,
|
||||||
rootResolved,
|
});
|
||||||
candidate: params.candidate,
|
}
|
||||||
});
|
|
||||||
}
|
function toRelativeBoundaryPath(params: {
|
||||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
root: string;
|
||||||
throwPathEscapesBoundary({
|
candidate: string;
|
||||||
options: params.options,
|
options?: Pick<RelativePathOptions, "allowRoot" | "cwd">;
|
||||||
rootResolved,
|
boundaryLabel: string;
|
||||||
candidate: params.candidate,
|
includeRootInError?: boolean;
|
||||||
});
|
}): string {
|
||||||
}
|
return toRelativePathUnderRoot({
|
||||||
return relative;
|
root: params.root,
|
||||||
|
candidate: params.candidate,
|
||||||
|
options: {
|
||||||
|
allowRoot: params.options?.allowRoot,
|
||||||
|
cwd: params.options?.cwd,
|
||||||
|
boundaryLabel: params.boundaryLabel,
|
||||||
|
includeRootInError: params.includeRootInError,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toRelativeWorkspacePath(
|
export function toRelativeWorkspacePath(
|
||||||
@@ -83,14 +107,11 @@ export function toRelativeWorkspacePath(
|
|||||||
candidate: string,
|
candidate: string,
|
||||||
options?: Pick<RelativePathOptions, "allowRoot" | "cwd">,
|
options?: Pick<RelativePathOptions, "allowRoot" | "cwd">,
|
||||||
): string {
|
): string {
|
||||||
return toRelativePathUnderRoot({
|
return toRelativeBoundaryPath({
|
||||||
root,
|
root,
|
||||||
candidate,
|
candidate,
|
||||||
options: {
|
options,
|
||||||
allowRoot: options?.allowRoot,
|
boundaryLabel: "workspace root",
|
||||||
cwd: options?.cwd,
|
|
||||||
boundaryLabel: "workspace root",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,15 +120,12 @@ export function toRelativeSandboxPath(
|
|||||||
candidate: string,
|
candidate: string,
|
||||||
options?: Pick<RelativePathOptions, "allowRoot" | "cwd">,
|
options?: Pick<RelativePathOptions, "allowRoot" | "cwd">,
|
||||||
): string {
|
): string {
|
||||||
return toRelativePathUnderRoot({
|
return toRelativeBoundaryPath({
|
||||||
root,
|
root,
|
||||||
candidate,
|
candidate,
|
||||||
options: {
|
options,
|
||||||
allowRoot: options?.allowRoot,
|
boundaryLabel: "sandbox root",
|
||||||
cwd: options?.cwd,
|
includeRootInError: true,
|
||||||
boundaryLabel: "sandbox root",
|
|
||||||
includeRootInError: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,15 +5,17 @@ import {
|
|||||||
sanitizeGoogleTurnOrdering,
|
sanitizeGoogleTurnOrdering,
|
||||||
sanitizeSessionMessagesImages,
|
sanitizeSessionMessagesImages,
|
||||||
} from "./pi-embedded-helpers.js";
|
} 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;
|
let testTimestamp = 1;
|
||||||
const nextTimestamp = () => testTimestamp++;
|
const nextTimestamp = () => testTimestamp++;
|
||||||
|
|
||||||
function makeToolCallResultPairInput(): Array<AssistantMessage | ToolResultMessage> {
|
function makeToolCallResultPairInput(): Array<AssistantMessage | ToolResultMessage> {
|
||||||
return [
|
return [
|
||||||
{
|
makeAgentAssistantMessage({
|
||||||
role: "assistant",
|
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: "toolCall",
|
type: "toolCall",
|
||||||
@@ -22,20 +24,10 @@ function makeToolCallResultPairInput(): Array<AssistantMessage | ToolResultMessa
|
|||||||
arguments: { path: "package.json" },
|
arguments: { path: "package.json" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
api: "openai-responses",
|
|
||||||
provider: "openai",
|
|
||||||
model: "gpt-5.2",
|
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",
|
stopReason: "toolUse",
|
||||||
timestamp: nextTimestamp(),
|
timestamp: nextTimestamp(),
|
||||||
},
|
}),
|
||||||
{
|
{
|
||||||
role: "toolResult",
|
role: "toolResult",
|
||||||
toolCallId: "call_123|fc_456",
|
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) {
|
function expectToolCallAndResultIds(out: AgentMessage[], expectedId: string) {
|
||||||
const assistant = out[0];
|
const assistant = out[0];
|
||||||
expect(assistant.role).toBe("assistant");
|
expect(assistant.role).toBe("assistant");
|
||||||
@@ -95,23 +108,9 @@ describe("sanitizeSessionMessagesImages", () => {
|
|||||||
|
|
||||||
it("does not synthesize tool call input when missing", async () => {
|
it("does not synthesize tool call input when missing", async () => {
|
||||||
const input = castAgentMessages([
|
const input = castAgentMessages([
|
||||||
{
|
makeOpenAiResponsesAssistantMessage([
|
||||||
role: "assistant",
|
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
|
||||||
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(),
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||||
@@ -124,26 +123,10 @@ describe("sanitizeSessionMessagesImages", () => {
|
|||||||
|
|
||||||
it("removes empty assistant text blocks but preserves tool calls", async () => {
|
it("removes empty assistant text blocks but preserves tool calls", async () => {
|
||||||
const input = castAgentMessages([
|
const input = castAgentMessages([
|
||||||
{
|
makeOpenAiResponsesAssistantMessage([
|
||||||
role: "assistant",
|
{ type: "text", text: "" },
|
||||||
content: [
|
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
|
||||||
{ 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(),
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||||
@@ -189,33 +172,7 @@ describe("sanitizeSessionMessagesImages", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sanitizes tool IDs in images-only mode when explicitly enabled", async () => {
|
it("sanitizes tool IDs in images-only mode when explicitly enabled", async () => {
|
||||||
const input = castAgentMessages([
|
const input = makeToolCallResultPairInput();
|
||||||
{
|
|
||||||
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 out = await sanitizeSessionMessagesImages(input, "test", {
|
const out = await sanitizeSessionMessagesImages(input, "test", {
|
||||||
sanitizeMode: "images-only",
|
sanitizeMode: "images-only",
|
||||||
@@ -297,39 +254,11 @@ describe("sanitizeSessionMessagesImages", () => {
|
|||||||
const input = castAgentMessages([
|
const input = castAgentMessages([
|
||||||
{ role: "user", content: "hello", timestamp: nextTimestamp() } satisfies UserMessage,
|
{ role: "user", content: "hello", timestamp: nextTimestamp() } satisfies UserMessage,
|
||||||
{
|
{
|
||||||
role: "assistant",
|
...makeEmptyAssistantErrorMessage(),
|
||||||
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,
|
|
||||||
{
|
{
|
||||||
role: "assistant",
|
...makeEmptyAssistantErrorMessage(),
|
||||||
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,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const out = await sanitizeSessionMessagesImages(input, "test");
|
const out = await sanitizeSessionMessagesImages(input, "test");
|
||||||
|
|||||||
@@ -1,35 +1,21 @@
|
|||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
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 { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
|
||||||
import { sanitizeSessionHistory } from "./google.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", () => {
|
describe("sanitizeSessionHistory toolResult details stripping", () => {
|
||||||
it("strips toolResult.details so untrusted payloads are not fed back to the model", async () => {
|
it("strips toolResult.details so untrusted payloads are not fed back to the model", async () => {
|
||||||
const sm = SessionManager.inMemory();
|
const sm = SessionManager.inMemory();
|
||||||
|
|
||||||
const messages: AgentMessage[] = [
|
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",
|
role: "toolResult",
|
||||||
toolCallId: "call_1",
|
toolCallId: "call_1",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
|
import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
|
||||||
import {
|
import {
|
||||||
truncateToolResultText,
|
truncateToolResultText,
|
||||||
truncateToolResultMessage,
|
truncateToolResultMessage,
|
||||||
@@ -35,23 +36,12 @@ function makeUserMessage(text: string): UserMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeAssistantMessage(text: string): AssistantMessage {
|
function makeAssistantMessage(text: string): AssistantMessage {
|
||||||
return {
|
return makeAgentAssistantMessage({
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "text", text }],
|
content: [{ type: "text", text }],
|
||||||
api: "openai-responses",
|
|
||||||
provider: "openai",
|
|
||||||
model: "gpt-5.2",
|
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",
|
stopReason: "stop",
|
||||||
timestamp: nextTimestamp(),
|
timestamp: nextTimestamp(),
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("truncateToolResultText", () => {
|
describe("truncateToolResultText", () => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createBaseToolHandlerState } from "./pi-tool-handler-state.test-helpers.js";
|
||||||
|
|
||||||
const hookMocks = vi.hoisted(() => ({
|
const hookMocks = vi.hoisted(() => ({
|
||||||
runner: {
|
runner: {
|
||||||
@@ -75,17 +76,7 @@ function createToolHandlerCtx() {
|
|||||||
hookRunner: hookMocks.runner,
|
hookRunner: hookMocks.runner,
|
||||||
state: {
|
state: {
|
||||||
toolMetaById: new Map<string, unknown>(),
|
toolMetaById: new Map<string, unknown>(),
|
||||||
toolMetas: [] as Array<{ toolName?: string; meta?: string }>,
|
...createBaseToolHandlerState(),
|
||||||
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: "",
|
|
||||||
successfulCronAdds: 0,
|
successfulCronAdds: 0,
|
||||||
},
|
},
|
||||||
log: { debug: vi.fn(), warn: vi.fn() },
|
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" }] },
|
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
|
const event = (hookMocks.runner.runAfterToolCall as ReturnType<typeof vi.fn>).mock
|
||||||
.calls[0]?.[0] as { params?: unknown } | undefined;
|
.calls[0]?.[0] as { params?: unknown } | undefined;
|
||||||
expect(event?.params).toEqual(adjusted);
|
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("-");
|
return parts.join("-");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSessionSlug(isTaken?: (id: string) => boolean): string {
|
function createAvailableSlug(
|
||||||
const isIdTaken = isTaken ?? (() => false);
|
words: number,
|
||||||
|
isIdTaken: (id: string) => boolean,
|
||||||
|
): string | undefined {
|
||||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||||
const base = createSlugBase(2);
|
const base = createSlugBase(words);
|
||||||
if (!isIdTaken(base)) {
|
if (!isIdTaken(base)) {
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
@@ -126,17 +128,18 @@ export function createSessionSlug(isTaken?: (id: string) => boolean): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
return undefined;
|
||||||
const base = createSlugBase(3);
|
}
|
||||||
if (!isIdTaken(base)) {
|
|
||||||
return base;
|
export function createSessionSlug(isTaken?: (id: string) => boolean): string {
|
||||||
}
|
const isIdTaken = isTaken ?? (() => false);
|
||||||
for (let i = 2; i <= 12; i += 1) {
|
const twoWord = createAvailableSlug(2, isIdTaken);
|
||||||
const candidate = `${base}-${i}`;
|
if (twoWord) {
|
||||||
if (!isIdTaken(candidate)) {
|
return twoWord;
|
||||||
return candidate;
|
}
|
||||||
}
|
const threeWord = createAvailableSlug(3, isIdTaken);
|
||||||
}
|
if (threeWord) {
|
||||||
|
return threeWord;
|
||||||
}
|
}
|
||||||
const fallback = `${createSlugBase(3)}-${Math.random().toString(36).slice(2, 5)}`;
|
const fallback = `${createSlugBase(3)}-${Math.random().toString(36).slice(2, 5)}`;
|
||||||
return isIdTaken(fallback) ? `${fallback}-${Date.now().toString(36)}` : fallback;
|
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 { validateRegistryNpmSpec } from "../../infra/npm-registry-spec.js";
|
||||||
import { parseFrontmatterBlock } from "../../markdown/frontmatter.js";
|
import { parseFrontmatterBlock } from "../../markdown/frontmatter.js";
|
||||||
import {
|
import {
|
||||||
|
applyOpenClawManifestInstallCommonFields,
|
||||||
getFrontmatterString,
|
getFrontmatterString,
|
||||||
normalizeStringList,
|
normalizeStringList,
|
||||||
parseOpenClawManifestInstallBase,
|
parseOpenClawManifestInstallBase,
|
||||||
@@ -113,19 +114,12 @@ function parseInstallSpec(input: unknown): SkillInstallSpec | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const { raw } = parsed;
|
const { raw } = parsed;
|
||||||
const spec: SkillInstallSpec = {
|
const spec = applyOpenClawManifestInstallCommonFields<SkillInstallSpec>(
|
||||||
kind: parsed.kind as SkillInstallSpec["kind"],
|
{
|
||||||
};
|
kind: parsed.kind as SkillInstallSpec["kind"],
|
||||||
|
},
|
||||||
if (parsed.id) {
|
parsed,
|
||||||
spec.id = parsed.id;
|
);
|
||||||
}
|
|
||||||
if (parsed.label) {
|
|
||||||
spec.label = parsed.label;
|
|
||||||
}
|
|
||||||
if (parsed.bins) {
|
|
||||||
spec.bins = parsed.bins;
|
|
||||||
}
|
|
||||||
const osList = normalizeStringList(raw.os);
|
const osList = normalizeStringList(raw.os);
|
||||||
if (osList.length > 0) {
|
if (osList.length > 0) {
|
||||||
spec.os = osList;
|
spec.os = osList;
|
||||||
|
|||||||
@@ -1,20 +1,6 @@
|
|||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||||
import type { AssistantMessage, ToolResultMessage, Usage, UserMessage } from "@mariozechner/pi-ai";
|
import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
|
||||||
|
import { ZERO_USAGE_FIXTURE } from "./usage-fixtures.js";
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function castAgentMessage(message: unknown): AgentMessage {
|
export function castAgentMessage(message: unknown): AgentMessage {
|
||||||
return message as AgentMessage;
|
return message as AgentMessage;
|
||||||
@@ -42,7 +28,7 @@ export function makeAgentAssistantMessage(
|
|||||||
api: "openai-responses",
|
api: "openai-responses",
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
model: "test-model",
|
model: "test-model",
|
||||||
usage: ZERO_USAGE,
|
usage: ZERO_USAGE_FIXTURE,
|
||||||
stopReason: "stop",
|
stopReason: "stop",
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
...overrides,
|
...overrides,
|
||||||
|
|||||||
@@ -1,19 +1,5 @@
|
|||||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||||
|
import { ZERO_USAGE_FIXTURE } from "./usage-fixtures.js";
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function makeAssistantMessageFixture(
|
export function makeAssistantMessageFixture(
|
||||||
overrides: Partial<AssistantMessage> = {},
|
overrides: Partial<AssistantMessage> = {},
|
||||||
@@ -24,7 +10,7 @@ export function makeAssistantMessageFixture(
|
|||||||
api: "openai-responses",
|
api: "openai-responses",
|
||||||
provider: "openai",
|
provider: "openai",
|
||||||
model: "test-model",
|
model: "test-model",
|
||||||
usage: ZERO_USAGE,
|
usage: ZERO_USAGE_FIXTURE,
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
stopReason: "error",
|
stopReason: "error",
|
||||||
errorMessage: errorText,
|
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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { MsgContext } from "../templating.js";
|
import type { MsgContext } from "../templating.js";
|
||||||
|
import { registerGetReplyCommonMocks } from "./get-reply.test-mocks.js";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
applyMediaUnderstanding: vi.fn(async (..._args: unknown[]) => undefined),
|
applyMediaUnderstanding: vi.fn(async (..._args: unknown[]) => undefined),
|
||||||
@@ -10,28 +11,8 @@ const mocks = vi.hoisted(() => ({
|
|||||||
initSessionState: vi.fn(),
|
initSessionState: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../agents/agent-scope.js", () => ({
|
registerGetReplyCommonMocks();
|
||||||
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("../../globals.js", () => ({
|
vi.mock("../../globals.js", () => ({
|
||||||
logVerbose: vi.fn(),
|
logVerbose: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -45,55 +26,18 @@ vi.mock("../../link-understanding/apply.js", () => ({
|
|||||||
vi.mock("../../media-understanding/apply.js", () => ({
|
vi.mock("../../media-understanding/apply.js", () => ({
|
||||||
applyMediaUnderstanding: mocks.applyMediaUnderstanding,
|
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", () => ({
|
vi.mock("./commands-core.js", () => ({
|
||||||
emitResetCommandHooks: vi.fn(async () => undefined),
|
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", () => ({
|
vi.mock("./get-reply-directives.js", () => ({
|
||||||
resolveReplyDirectives: mocks.resolveReplyDirectives,
|
resolveReplyDirectives: mocks.resolveReplyDirectives,
|
||||||
}));
|
}));
|
||||||
vi.mock("./get-reply-inline-actions.js", () => ({
|
vi.mock("./get-reply-inline-actions.js", () => ({
|
||||||
handleInlineActions: vi.fn(async () => ({ kind: "reply", reply: { text: "ok" } })),
|
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", () => ({
|
vi.mock("./session.js", () => ({
|
||||||
initSessionState: mocks.initSessionState,
|
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");
|
const { getReplyFromConfig } = await import("./get-reply.js");
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { MsgContext } from "../templating.js";
|
import type { MsgContext } from "../templating.js";
|
||||||
|
import { registerGetReplyCommonMocks } from "./get-reply.test-mocks.js";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
resolveReplyDirectives: vi.fn(),
|
resolveReplyDirectives: vi.fn(),
|
||||||
@@ -8,83 +9,26 @@ const mocks = vi.hoisted(() => ({
|
|||||||
initSessionState: vi.fn(),
|
initSessionState: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../agents/agent-scope.js", () => ({
|
registerGetReplyCommonMocks();
|
||||||
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("../../link-understanding/apply.js", () => ({
|
vi.mock("../../link-understanding/apply.js", () => ({
|
||||||
applyLinkUnderstanding: vi.fn(async () => undefined),
|
applyLinkUnderstanding: vi.fn(async () => undefined),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../media-understanding/apply.js", () => ({
|
vi.mock("../../media-understanding/apply.js", () => ({
|
||||||
applyMediaUnderstanding: vi.fn(async () => undefined),
|
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", () => ({
|
vi.mock("./commands-core.js", () => ({
|
||||||
emitResetCommandHooks: (...args: unknown[]) => mocks.emitResetCommandHooks(...args),
|
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", () => ({
|
vi.mock("./get-reply-directives.js", () => ({
|
||||||
resolveReplyDirectives: (...args: unknown[]) => mocks.resolveReplyDirectives(...args),
|
resolveReplyDirectives: (...args: unknown[]) => mocks.resolveReplyDirectives(...args),
|
||||||
}));
|
}));
|
||||||
vi.mock("./get-reply-inline-actions.js", () => ({
|
vi.mock("./get-reply-inline-actions.js", () => ({
|
||||||
handleInlineActions: (...args: unknown[]) => mocks.handleInlineActions(...args),
|
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", () => ({
|
vi.mock("./session.js", () => ({
|
||||||
initSessionState: (...args: unknown[]) => mocks.initSessionState(...args),
|
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");
|
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 { describe, expect, it } from "vitest";
|
||||||
import type { OpenClawConfig } from "../../config/config.js";
|
import type { OpenClawConfig } from "../../config/config.js";
|
||||||
|
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||||
import { createAccountListHelpers } from "./account-helpers.js";
|
import { createAccountListHelpers } from "./account-helpers.js";
|
||||||
|
|
||||||
const { listConfiguredAccountIds, listAccountIds, resolveDefaultAccountId } =
|
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", () => {
|
describe("listAccountIds", () => {
|
||||||
it('returns ["default"] for empty config', () => {
|
it('returns ["default"] for empty config', () => {
|
||||||
expect(listAccountIds({} as OpenClawConfig)).toEqual(["default"]);
|
expect(listAccountIds({} as OpenClawConfig)).toEqual(["default"]);
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import {
|
|||||||
normalizeOptionalAccountId,
|
normalizeOptionalAccountId,
|
||||||
} from "../../routing/session-key.js";
|
} 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 {
|
function resolveConfiguredDefaultAccountId(cfg: OpenClawConfig): string | undefined {
|
||||||
const channel = cfg.channels?.[channelKey] as Record<string, unknown> | undefined;
|
const channel = cfg.channels?.[channelKey] as Record<string, unknown> | undefined;
|
||||||
const preferred = normalizeOptionalAccountId(
|
const preferred = normalizeOptionalAccountId(
|
||||||
@@ -27,7 +30,12 @@ export function createAccountListHelpers(channelKey: string) {
|
|||||||
if (!accounts || typeof accounts !== "object") {
|
if (!accounts || typeof accounts !== "object") {
|
||||||
return [];
|
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[] {
|
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;
|
enabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isConfiguredSecretValue(value: unknown): boolean {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value.trim().length > 0;
|
||||||
|
}
|
||||||
|
return Boolean(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function setAccountEnabledInConfigSection(params: {
|
export function setAccountEnabledInConfigSection(params: {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
sectionKey: string;
|
sectionKey: string;
|
||||||
@@ -111,3 +118,58 @@ export function deleteAccountFromConfigSection(params: {
|
|||||||
}
|
}
|
||||||
return nextCfg;
|
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 { Command } from "commander";
|
||||||
import type { CronJob } from "../../cron/types.js";
|
import type { CronJob } from "../../cron/types.js";
|
||||||
import { danger } from "../../globals.js";
|
|
||||||
import { sanitizeAgentId } from "../../routing/session-key.js";
|
import { sanitizeAgentId } from "../../routing/session-key.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import type { GatewayRpcOpts } from "../gateway-rpc.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 { parsePositiveIntOrUndefined } from "../program/helpers.js";
|
||||||
import {
|
import {
|
||||||
getCronChannelOptions,
|
getCronChannelOptions,
|
||||||
|
handleCronCliError,
|
||||||
parseAt,
|
parseAt,
|
||||||
parseCronStaggerMs,
|
parseCronStaggerMs,
|
||||||
parseDurationMs,
|
parseDurationMs,
|
||||||
|
printCronJson,
|
||||||
printCronList,
|
printCronList,
|
||||||
warnIfCronSchedulerDisabled,
|
warnIfCronSchedulerDisabled,
|
||||||
} from "./shared.js";
|
} from "./shared.js";
|
||||||
@@ -24,10 +25,9 @@ export function registerCronStatusCommand(cron: Command) {
|
|||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
try {
|
try {
|
||||||
const res = await callGatewayFromCli("cron.status", opts, {});
|
const res = await callGatewayFromCli("cron.status", opts, {});
|
||||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
printCronJson(res);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(danger(String(err)));
|
handleCronCliError(err);
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -46,14 +46,13 @@ export function registerCronListCommand(cron: Command) {
|
|||||||
includeDisabled: Boolean(opts.all),
|
includeDisabled: Boolean(opts.all),
|
||||||
});
|
});
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
printCronJson(res);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const jobs = (res as { jobs?: CronJob[] } | null)?.jobs ?? [];
|
const jobs = (res as { jobs?: CronJob[] } | null)?.jobs ?? [];
|
||||||
printCronList(jobs, defaultRuntime);
|
printCronList(jobs, defaultRuntime);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(danger(String(err)));
|
handleCronCliError(err);
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -273,11 +272,10 @@ export function registerCronAddCommand(cron: Command) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const res = await callGatewayFromCli("cron.add", opts, params);
|
const res = await callGatewayFromCli("cron.add", opts, params);
|
||||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
printCronJson(res);
|
||||||
await warnIfCronSchedulerDisabled(opts);
|
await warnIfCronSchedulerDisabled(opts);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(danger(String(err)));
|
handleCronCliError(err);
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import { danger } from "../../globals.js";
|
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||||
import { warnIfCronSchedulerDisabled } from "./shared.js";
|
import { handleCronCliError, printCronJson, warnIfCronSchedulerDisabled } from "./shared.js";
|
||||||
|
|
||||||
function registerCronToggleCommand(params: {
|
function registerCronToggleCommand(params: {
|
||||||
cron: Command;
|
cron: Command;
|
||||||
@@ -21,11 +20,10 @@ function registerCronToggleCommand(params: {
|
|||||||
id,
|
id,
|
||||||
patch: { enabled: params.enabled },
|
patch: { enabled: params.enabled },
|
||||||
});
|
});
|
||||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
printCronJson(res);
|
||||||
await warnIfCronSchedulerDisabled(opts);
|
await warnIfCronSchedulerDisabled(opts);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(danger(String(err)));
|
handleCronCliError(err);
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -43,10 +41,9 @@ export function registerCronSimpleCommands(cron: Command) {
|
|||||||
.action(async (id, opts) => {
|
.action(async (id, opts) => {
|
||||||
try {
|
try {
|
||||||
const res = await callGatewayFromCli("cron.remove", opts, { id });
|
const res = await callGatewayFromCli("cron.remove", opts, { id });
|
||||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
printCronJson(res);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(danger(String(err)));
|
handleCronCliError(err);
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -79,10 +76,9 @@ export function registerCronSimpleCommands(cron: Command) {
|
|||||||
id,
|
id,
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
printCronJson(res);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(danger(String(err)));
|
handleCronCliError(err);
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -102,12 +98,11 @@ export function registerCronSimpleCommands(cron: Command) {
|
|||||||
id,
|
id,
|
||||||
mode: opts.due ? "due" : "force",
|
mode: opts.due ? "due" : "force",
|
||||||
});
|
});
|
||||||
defaultRuntime.log(JSON.stringify(res, null, 2));
|
printCronJson(res);
|
||||||
const result = res as { ok?: boolean; ran?: boolean } | undefined;
|
const result = res as { ok?: boolean; ran?: boolean } | undefined;
|
||||||
defaultRuntime.exit(result?.ok && result?.ran ? 0 : 1);
|
defaultRuntime.exit(result?.ok && result?.ran ? 0 : 1);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(danger(String(err)));
|
handleCronCliError(err);
|
||||||
defaultRuntime.exit(1);
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { listChannelPlugins } from "../../channels/plugins/index.js";
|
|||||||
import { parseAbsoluteTimeMs } from "../../cron/parse.js";
|
import { parseAbsoluteTimeMs } from "../../cron/parse.js";
|
||||||
import { resolveCronStaggerMs } from "../../cron/stagger.js";
|
import { resolveCronStaggerMs } from "../../cron/stagger.js";
|
||||||
import type { CronJob, CronSchedule } from "../../cron/types.js";
|
import type { CronJob, CronSchedule } from "../../cron/types.js";
|
||||||
|
import { danger } from "../../globals.js";
|
||||||
import { formatDurationHuman } from "../../infra/format-time/format-duration.ts";
|
import { formatDurationHuman } from "../../infra/format-time/format-duration.ts";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||||
@@ -11,6 +12,15 @@ import { callGatewayFromCli } from "../gateway-rpc.js";
|
|||||||
export const getCronChannelOptions = () =>
|
export const getCronChannelOptions = () =>
|
||||||
["last", ...listChannelPlugins().map((plugin) => plugin.id)].join("|");
|
["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) {
|
export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
|
||||||
try {
|
try {
|
||||||
const res = (await callGatewayFromCli("cron.status", opts, {})) as {
|
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>;
|
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>) {
|
function expectCliSync(sync: ReturnType<typeof vi.fn>) {
|
||||||
expect(sync).toHaveBeenCalledWith(
|
expect(sync).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
|
expect.objectContaining({ reason: "cli", force: false, progress: expect.any(Function) }),
|
||||||
@@ -85,6 +87,25 @@ describe("memory cli", () => {
|
|||||||
getMemorySearchManager.mockResolvedValueOnce({ manager });
|
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[]) {
|
async function runMemoryCli(args: string[]) {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
program.name("test");
|
program.name("test");
|
||||||
@@ -191,26 +212,12 @@ describe("memory cli", () => {
|
|||||||
|
|
||||||
it("logs gateway secret diagnostics for non-json status output", async () => {
|
it("logs gateway secret diagnostics for non-json status output", async () => {
|
||||||
const close = vi.fn(async () => {});
|
const close = vi.fn(async () => {});
|
||||||
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
|
setupMemoryStatusWithInactiveSecretDiagnostics(close);
|
||||||
resolvedConfig: {},
|
|
||||||
diagnostics: ["agents.defaults.memorySearch.remote.apiKey inactive"] as string[],
|
|
||||||
});
|
|
||||||
mockManager({
|
|
||||||
probeVectorAvailability: vi.fn(async () => true),
|
|
||||||
status: () => makeMemoryStatus({ workspaceDir: undefined }),
|
|
||||||
close,
|
|
||||||
});
|
|
||||||
|
|
||||||
const log = spyRuntimeLogs();
|
const log = spyRuntimeLogs();
|
||||||
await runMemoryCli(["status"]);
|
await runMemoryCli(["status"]);
|
||||||
|
|
||||||
expect(
|
expect(hasLoggedInactiveSecretDiagnostic(log)).toBe(true);
|
||||||
log.mock.calls.some(
|
|
||||||
(call) =>
|
|
||||||
typeof call[0] === "string" &&
|
|
||||||
call[0].includes("agents.defaults.memorySearch.remote.apiKey inactive"),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prints vector error when unavailable", async () => {
|
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 () => {
|
it("routes gateway secret diagnostics to stderr for json status output", async () => {
|
||||||
const close = vi.fn(async () => {});
|
const close = vi.fn(async () => {});
|
||||||
resolveCommandSecretRefsViaGateway.mockResolvedValueOnce({
|
setupMemoryStatusWithInactiveSecretDiagnostics(close);
|
||||||
resolvedConfig: {},
|
|
||||||
diagnostics: ["agents.defaults.memorySearch.remote.apiKey inactive"] as string[],
|
|
||||||
});
|
|
||||||
mockManager({
|
|
||||||
probeVectorAvailability: vi.fn(async () => true),
|
|
||||||
status: () => makeMemoryStatus({ workspaceDir: undefined }),
|
|
||||||
close,
|
|
||||||
});
|
|
||||||
|
|
||||||
const log = spyRuntimeLogs();
|
const log = spyRuntimeLogs();
|
||||||
const error = spyRuntimeErrors();
|
const error = spyRuntimeErrors();
|
||||||
@@ -426,13 +425,7 @@ describe("memory cli", () => {
|
|||||||
|
|
||||||
const payload = firstLoggedJson(log);
|
const payload = firstLoggedJson(log);
|
||||||
expect(Array.isArray(payload)).toBe(true);
|
expect(Array.isArray(payload)).toBe(true);
|
||||||
expect(
|
expect(hasLoggedInactiveSecretDiagnostic(error)).toBe(true);
|
||||||
error.mock.calls.some(
|
|
||||||
(call) =>
|
|
||||||
typeof call[0] === "string" &&
|
|
||||||
call[0].includes("agents.defaults.memorySearch.remote.apiKey inactive"),
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("logs default message when memory manager is missing", async () => {
|
it("logs default message when memory manager is missing", async () => {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
type ExecSecurity,
|
type ExecSecurity,
|
||||||
maxAsk,
|
maxAsk,
|
||||||
minSecurity,
|
minSecurity,
|
||||||
|
normalizeExecAsk,
|
||||||
|
normalizeExecSecurity,
|
||||||
resolveExecApprovalsFromFile,
|
resolveExecApprovalsFromFile,
|
||||||
} from "../../infra/exec-approvals.js";
|
} from "../../infra/exec-approvals.js";
|
||||||
import { buildNodeShellCommand } from "../../infra/node-shell.js";
|
import { buildNodeShellCommand } from "../../infra/node-shell.js";
|
||||||
@@ -43,22 +45,6 @@ type ExecDefaults = {
|
|||||||
safeBins?: string[];
|
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(
|
function resolveExecDefaults(
|
||||||
cfg: ReturnType<typeof loadConfig>,
|
cfg: ReturnType<typeof loadConfig>,
|
||||||
agentId: string | undefined,
|
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", () => {
|
describe("registerQrCli", () => {
|
||||||
function createProgram() {
|
function createProgram() {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -88,6 +114,23 @@ describe("registerQrCli", () => {
|
|||||||
await expect(runQr(args)).rejects.toThrow("exit");
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "");
|
vi.stubEnv("OPENCLAW_GATEWAY_TOKEN", "");
|
||||||
@@ -157,21 +200,11 @@ describe("registerQrCli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("skips local password SecretRef resolution when --token override is provided", async () => {
|
it("skips local password SecretRef resolution when --token override is provided", async () => {
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue(
|
||||||
secrets: {
|
createLocalGatewayConfigWithAuth(
|
||||||
providers: {
|
createLocalGatewayPasswordRefAuth("MISSING_LOCAL_GATEWAY_PASSWORD"),
|
||||||
default: { source: "env" },
|
),
|
||||||
},
|
);
|
||||||
},
|
|
||||||
gateway: {
|
|
||||||
bind: "custom",
|
|
||||||
customBindHost: "gateway.local",
|
|
||||||
auth: {
|
|
||||||
mode: "password",
|
|
||||||
password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await runQr(["--setup-code-only", "--token", "override-token"]);
|
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 () => {
|
it("resolves local gateway auth password SecretRefs before setup code generation", async () => {
|
||||||
vi.stubEnv("QR_LOCAL_GATEWAY_PASSWORD", "local-password-secret");
|
vi.stubEnv("QR_LOCAL_GATEWAY_PASSWORD", "local-password-secret");
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue(
|
||||||
secrets: {
|
createLocalGatewayConfigWithAuth(
|
||||||
providers: {
|
createLocalGatewayPasswordRefAuth("QR_LOCAL_GATEWAY_PASSWORD"),
|
||||||
default: { source: "env" },
|
),
|
||||||
},
|
);
|
||||||
},
|
|
||||||
gateway: {
|
|
||||||
bind: "custom",
|
|
||||||
customBindHost: "gateway.local",
|
|
||||||
auth: {
|
|
||||||
mode: "password",
|
|
||||||
password: { source: "env", provider: "default", id: "QR_LOCAL_GATEWAY_PASSWORD" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await runQr(["--setup-code-only"]);
|
await runQr(["--setup-code-only"]);
|
||||||
|
|
||||||
@@ -212,21 +235,11 @@ describe("registerQrCli", () => {
|
|||||||
|
|
||||||
it("uses OPENCLAW_GATEWAY_PASSWORD without resolving local password SecretRef", async () => {
|
it("uses OPENCLAW_GATEWAY_PASSWORD without resolving local password SecretRef", async () => {
|
||||||
vi.stubEnv("OPENCLAW_GATEWAY_PASSWORD", "password-from-env");
|
vi.stubEnv("OPENCLAW_GATEWAY_PASSWORD", "password-from-env");
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue(
|
||||||
secrets: {
|
createLocalGatewayConfigWithAuth(
|
||||||
providers: {
|
createLocalGatewayPasswordRefAuth("MISSING_LOCAL_GATEWAY_PASSWORD"),
|
||||||
default: { source: "env" },
|
),
|
||||||
},
|
);
|
||||||
},
|
|
||||||
gateway: {
|
|
||||||
bind: "custom",
|
|
||||||
customBindHost: "gateway.local",
|
|
||||||
auth: {
|
|
||||||
mode: "password",
|
|
||||||
password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await runQr(["--setup-code-only"]);
|
await runQr(["--setup-code-only"]);
|
||||||
|
|
||||||
@@ -239,22 +252,13 @@ describe("registerQrCli", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("does not resolve local password SecretRef when auth mode is token", async () => {
|
it("does not resolve local password SecretRef when auth mode is token", async () => {
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue(
|
||||||
secrets: {
|
createLocalGatewayConfigWithAuth({
|
||||||
providers: {
|
mode: "token",
|
||||||
default: { source: "env" },
|
token: "token-123",
|
||||||
},
|
password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" },
|
||||||
},
|
}),
|
||||||
gateway: {
|
);
|
||||||
bind: "custom",
|
|
||||||
customBindHost: "gateway.local",
|
|
||||||
auth: {
|
|
||||||
mode: "token",
|
|
||||||
token: "token-123",
|
|
||||||
password: { source: "env", provider: "default", id: "MISSING_LOCAL_GATEWAY_PASSWORD" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await runQr(["--setup-code-only"]);
|
await runQr(["--setup-code-only"]);
|
||||||
|
|
||||||
@@ -268,20 +272,11 @@ describe("registerQrCli", () => {
|
|||||||
|
|
||||||
it("resolves local password SecretRef when auth mode is inferred", async () => {
|
it("resolves local password SecretRef when auth mode is inferred", async () => {
|
||||||
vi.stubEnv("QR_INFERRED_GATEWAY_PASSWORD", "inferred-password");
|
vi.stubEnv("QR_INFERRED_GATEWAY_PASSWORD", "inferred-password");
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue(
|
||||||
secrets: {
|
createLocalGatewayConfigWithAuth({
|
||||||
providers: {
|
password: { source: "env", provider: "default", id: "QR_INFERRED_GATEWAY_PASSWORD" },
|
||||||
default: { source: "env" },
|
}),
|
||||||
},
|
);
|
||||||
},
|
|
||||||
gateway: {
|
|
||||||
bind: "custom",
|
|
||||||
customBindHost: "gateway.local",
|
|
||||||
auth: {
|
|
||||||
password: { source: "env", provider: "default", id: "QR_INFERRED_GATEWAY_PASSWORD" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await runQr(["--setup-code-only"]);
|
await runQr(["--setup-code-only"]);
|
||||||
|
|
||||||
@@ -390,20 +385,11 @@ describe("registerQrCli", () => {
|
|||||||
{ name: "when tailscale is configured", withTailscale: true },
|
{ name: "when tailscale is configured", withTailscale: true },
|
||||||
])("reports gateway.remote.url as source in --remote json output ($name)", async (testCase) => {
|
])("reports gateway.remote.url as source in --remote json output ($name)", async (testCase) => {
|
||||||
loadConfig.mockReturnValue(createRemoteQrConfig({ withTailscale: testCase.withTailscale }));
|
loadConfig.mockReturnValue(createRemoteQrConfig({ withTailscale: testCase.withTailscale }));
|
||||||
runCommandWithTimeout.mockResolvedValue({
|
mockTailscaleStatusLookup();
|
||||||
code: 0,
|
|
||||||
stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}',
|
|
||||||
stderr: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
await runQr(["--json", "--remote"]);
|
await runQr(["--json", "--remote"]);
|
||||||
|
|
||||||
const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as {
|
const payload = parseLastLoggedQrJson();
|
||||||
setupCode?: string;
|
|
||||||
gatewayUrl?: string;
|
|
||||||
auth?: string;
|
|
||||||
urlSource?: string;
|
|
||||||
};
|
|
||||||
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
|
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
|
||||||
expect(payload.auth).toBe("token");
|
expect(payload.auth).toBe("token");
|
||||||
expect(payload.urlSource).toBe("gateway.remote.url");
|
expect(payload.urlSource).toBe("gateway.remote.url");
|
||||||
@@ -416,20 +402,11 @@ describe("registerQrCli", () => {
|
|||||||
resolvedConfig: createRemoteQrConfig(),
|
resolvedConfig: createRemoteQrConfig(),
|
||||||
diagnostics: ["gateway.remote.password inactive"] as string[],
|
diagnostics: ["gateway.remote.password inactive"] as string[],
|
||||||
});
|
});
|
||||||
runCommandWithTimeout.mockResolvedValue({
|
mockTailscaleStatusLookup();
|
||||||
code: 0,
|
|
||||||
stdout: '{"Self":{"DNSName":"ts-host.tailnet.ts.net."}}',
|
|
||||||
stderr: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
await runQr(["--json", "--remote"]);
|
await runQr(["--json", "--remote"]);
|
||||||
|
|
||||||
const payload = JSON.parse(String(runtime.log.mock.calls.at(-1)?.[0] ?? "{}")) as {
|
const payload = parseLastLoggedQrJson();
|
||||||
setupCode?: string;
|
|
||||||
gatewayUrl?: string;
|
|
||||||
auth?: string;
|
|
||||||
urlSource?: string;
|
|
||||||
};
|
|
||||||
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
|
expect(payload.gatewayUrl).toBe("wss://remote.example.com:444");
|
||||||
expect(
|
expect(
|
||||||
runtime.error.mock.calls.some((call) =>
|
runtime.error.mock.calls.some((call) =>
|
||||||
|
|||||||
@@ -405,20 +405,15 @@ async function saveSessionStoreUnlocked(
|
|||||||
.map((entry) => entry?.sessionId)
|
.map((entry) => entry?.sessionId)
|
||||||
.filter((id): id is string => Boolean(id)),
|
.filter((id): id is string => Boolean(id)),
|
||||||
);
|
);
|
||||||
for (const [sessionId, sessionFile] of removedSessionFiles) {
|
const archivedForDeletedSessions = archiveRemovedSessionTranscripts({
|
||||||
if (referencedSessionIds.has(sessionId)) {
|
removedSessionFiles,
|
||||||
continue;
|
referencedSessionIds,
|
||||||
}
|
storePath,
|
||||||
const archived = archiveSessionTranscripts({
|
reason: "deleted",
|
||||||
sessionId,
|
restrictToStoreDir: true,
|
||||||
storePath,
|
});
|
||||||
sessionFile,
|
for (const archivedDir of archivedForDeletedSessions) {
|
||||||
reason: "deleted",
|
archivedDirs.add(archivedDir);
|
||||||
restrictToStoreDir: true,
|
|
||||||
});
|
|
||||||
for (const archivedPath of archived) {
|
|
||||||
archivedDirs.add(path.dirname(archivedPath));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (archivedDirs.size > 0 || maintenance.resetArchiveRetentionMs != null) {
|
if (archivedDirs.size > 0 || maintenance.resetArchiveRetentionMs != null) {
|
||||||
const targetDirs =
|
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: {
|
async function writeSessionStoreAtomic(params: {
|
||||||
storePath: string;
|
storePath: string;
|
||||||
store: Record<string, SessionEntry>;
|
store: Record<string, SessionEntry>;
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ export function validateConfigObject(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateConfigObjectWithPlugins(raw: unknown):
|
type ValidateConfigWithPluginsResult =
|
||||||
| {
|
| {
|
||||||
ok: true;
|
ok: true;
|
||||||
config: OpenClawConfig;
|
config: OpenClawConfig;
|
||||||
@@ -295,38 +295,20 @@ export function validateConfigObjectWithPlugins(raw: unknown):
|
|||||||
ok: false;
|
ok: false;
|
||||||
issues: ConfigValidationIssue[];
|
issues: ConfigValidationIssue[];
|
||||||
warnings: ConfigValidationIssue[];
|
warnings: ConfigValidationIssue[];
|
||||||
} {
|
};
|
||||||
|
|
||||||
|
export function validateConfigObjectWithPlugins(raw: unknown): ValidateConfigWithPluginsResult {
|
||||||
return validateConfigObjectWithPluginsBase(raw, { applyDefaults: true });
|
return validateConfigObjectWithPluginsBase(raw, { applyDefaults: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateConfigObjectRawWithPlugins(raw: unknown):
|
export function validateConfigObjectRawWithPlugins(raw: unknown): ValidateConfigWithPluginsResult {
|
||||||
| {
|
|
||||||
ok: true;
|
|
||||||
config: OpenClawConfig;
|
|
||||||
warnings: ConfigValidationIssue[];
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
ok: false;
|
|
||||||
issues: ConfigValidationIssue[];
|
|
||||||
warnings: ConfigValidationIssue[];
|
|
||||||
} {
|
|
||||||
return validateConfigObjectWithPluginsBase(raw, { applyDefaults: false });
|
return validateConfigObjectWithPluginsBase(raw, { applyDefaults: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateConfigObjectWithPluginsBase(
|
function validateConfigObjectWithPluginsBase(
|
||||||
raw: unknown,
|
raw: unknown,
|
||||||
opts: { applyDefaults: boolean },
|
opts: { applyDefaults: boolean },
|
||||||
):
|
): ValidateConfigWithPluginsResult {
|
||||||
| {
|
|
||||||
ok: true;
|
|
||||||
config: OpenClawConfig;
|
|
||||||
warnings: ConfigValidationIssue[];
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
ok: false;
|
|
||||||
issues: ConfigValidationIssue[];
|
|
||||||
warnings: ConfigValidationIssue[];
|
|
||||||
} {
|
|
||||||
const base = opts.applyDefaults ? validateConfigObject(raw) : validateConfigObjectRaw(raw);
|
const base = opts.applyDefaults ? validateConfigObject(raw) : validateConfigObjectRaw(raw);
|
||||||
if (!base.ok) {
|
if (!base.ok) {
|
||||||
return { ok: false, issues: base.issues, warnings: [] };
|
return { ok: false, issues: base.issues, warnings: [] };
|
||||||
|
|||||||
@@ -25,6 +25,21 @@ type SlackConfigLike = {
|
|||||||
accounts?: Record<string, SlackAccountLike | undefined>;
|
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(
|
export function validateTelegramWebhookSecretRequirements(
|
||||||
value: TelegramConfigLike,
|
value: TelegramConfigLike,
|
||||||
ctx: z.RefinementCtx,
|
ctx: z.RefinementCtx,
|
||||||
@@ -38,20 +53,11 @@ export function validateTelegramWebhookSecretRequirements(
|
|||||||
path: ["webhookSecret"],
|
path: ["webhookSecret"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!value.accounts) {
|
forEachEnabledAccount(value.accounts, (accountId, account) => {
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const [accountId, account] of Object.entries(value.accounts)) {
|
|
||||||
if (!account) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (account.enabled === false) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const accountWebhookUrl =
|
const accountWebhookUrl =
|
||||||
typeof account.webhookUrl === "string" ? account.webhookUrl.trim() : "";
|
typeof account.webhookUrl === "string" ? account.webhookUrl.trim() : "";
|
||||||
if (!accountWebhookUrl) {
|
if (!accountWebhookUrl) {
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
const hasAccountSecret = hasConfiguredSecretInput(account.webhookSecret);
|
const hasAccountSecret = hasConfiguredSecretInput(account.webhookSecret);
|
||||||
if (!hasAccountSecret && !hasBaseWebhookSecret) {
|
if (!hasAccountSecret && !hasBaseWebhookSecret) {
|
||||||
@@ -62,7 +68,7 @@ export function validateTelegramWebhookSecretRequirements(
|
|||||||
path: ["accounts", accountId, "webhookSecret"],
|
path: ["accounts", accountId, "webhookSecret"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateSlackSigningSecretRequirements(
|
export function validateSlackSigningSecretRequirements(
|
||||||
@@ -77,20 +83,11 @@ export function validateSlackSigningSecretRequirements(
|
|||||||
path: ["signingSecret"],
|
path: ["signingSecret"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!value.accounts) {
|
forEachEnabledAccount(value.accounts, (accountId, account) => {
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const [accountId, account] of Object.entries(value.accounts)) {
|
|
||||||
if (!account) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (account.enabled === false) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const accountMode =
|
const accountMode =
|
||||||
account.mode === "http" || account.mode === "socket" ? account.mode : baseMode;
|
account.mode === "http" || account.mode === "socket" ? account.mode : baseMode;
|
||||||
if (accountMode !== "http") {
|
if (accountMode !== "http") {
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
const accountSecret = account.signingSecret ?? value.signingSecret;
|
const accountSecret = account.signingSecret ?? value.signingSecret;
|
||||||
if (!hasConfiguredSecretInput(accountSecret)) {
|
if (!hasConfiguredSecretInput(accountSecret)) {
|
||||||
@@ -101,5 +98,5 @@ export function validateSlackSigningSecretRequirements(
|
|||||||
path: ["accounts", accountId, "signingSecret"],
|
path: ["accounts", accountId, "signingSecret"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import "./isolated-agent.mocks.js";
|
import "./isolated-agent.mocks.js";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import path from "node:path";
|
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
|
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
|
||||||
import type { CliDeps } from "../cli/deps.js";
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
import {
|
import {
|
||||||
@@ -12,72 +10,15 @@ import {
|
|||||||
runTelegramAnnounceTurn,
|
runTelegramAnnounceTurn,
|
||||||
} from "./isolated-agent.delivery.test-helpers.js";
|
} from "./isolated-agent.delivery.test-helpers.js";
|
||||||
import { runCronIsolatedAgentTurn } from "./isolated-agent.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";
|
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;
|
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: {
|
async function runExplicitTelegramAnnounceTurn(params: {
|
||||||
home: string;
|
home: string;
|
||||||
storePath: string;
|
storePath: string;
|
||||||
@@ -264,19 +205,6 @@ async function assertExplicitTelegramTargetAnnounce(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("runCronIsolatedAgentTurn", () => {
|
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(() => {
|
beforeEach(() => {
|
||||||
setupIsolatedAgentTurnMocks();
|
setupIsolatedAgentTurnMocks();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import "./isolated-agent.mocks.js";
|
import "./isolated-agent.mocks.js";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
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 { loadModelCatalog } from "../agents/model-catalog.js";
|
||||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||||
import type { CliDeps } from "../cli/deps.js";
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
@@ -10,73 +9,12 @@ import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
|||||||
import {
|
import {
|
||||||
makeCfg,
|
makeCfg,
|
||||||
makeJob,
|
makeJob,
|
||||||
|
withTempCronHome as withTempHome,
|
||||||
writeSessionStore,
|
writeSessionStore,
|
||||||
writeSessionStoreEntries,
|
writeSessionStoreEntries,
|
||||||
} from "./isolated-agent.test-harness.js";
|
} from "./isolated-agent.test-harness.js";
|
||||||
import type { CronJob } from "./types.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 {
|
function makeDeps(): CliDeps {
|
||||||
return {
|
return {
|
||||||
sendMessageSlack: vi.fn(),
|
sendMessageSlack: vi.fn(),
|
||||||
@@ -224,19 +162,6 @@ async function runStoredOverrideAndExpectModel(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("runCronIsolatedAgentTurn", () => {
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(runEmbeddedPiAgent).mockClear();
|
vi.mocked(runEmbeddedPiAgent).mockClear();
|
||||||
vi.mocked(loadModelCatalog).mockResolvedValue([]);
|
vi.mocked(loadModelCatalog).mockResolvedValue([]);
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
* run records. The base session (`...:cron:<jobId>`) is kept as-is.
|
* run records. The base session (`...:cron:<jobId>`) is kept as-is.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import path from "node:path";
|
|
||||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||||
import { loadSessionStore, updateSessionStore } from "../config/sessions.js";
|
|
||||||
import type { CronConfig } from "../config/types.cron.js";
|
|
||||||
import {
|
import {
|
||||||
archiveSessionTranscripts,
|
archiveRemovedSessionTranscripts,
|
||||||
cleanupArchivedSessionTranscripts,
|
loadSessionStore,
|
||||||
} from "../gateway/session-utils.fs.js";
|
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 { isCronRunSessionKey } from "../sessions/session-key-utils.js";
|
||||||
import type { Logger } from "./service/state.js";
|
import type { Logger } from "./service/state.js";
|
||||||
|
|
||||||
@@ -116,22 +116,13 @@ export async function sweepCronRunSessions(params: {
|
|||||||
.map((entry) => entry?.sessionId)
|
.map((entry) => entry?.sessionId)
|
||||||
.filter((id): id is string => Boolean(id)),
|
.filter((id): id is string => Boolean(id)),
|
||||||
);
|
);
|
||||||
const archivedDirs = new Set<string>();
|
const archivedDirs = archiveRemovedSessionTranscripts({
|
||||||
for (const [sessionId, sessionFile] of prunedSessions) {
|
removedSessionFiles: prunedSessions,
|
||||||
if (referencedSessionIds.has(sessionId)) {
|
referencedSessionIds,
|
||||||
continue;
|
storePath,
|
||||||
}
|
reason: "deleted",
|
||||||
const archived = archiveSessionTranscripts({
|
restrictToStoreDir: true,
|
||||||
sessionId,
|
});
|
||||||
storePath,
|
|
||||||
sessionFile,
|
|
||||||
reason: "deleted",
|
|
||||||
restrictToStoreDir: true,
|
|
||||||
});
|
|
||||||
for (const archivedPath of archived) {
|
|
||||||
archivedDirs.add(path.dirname(archivedPath));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (archivedDirs.size > 0) {
|
if (archivedDirs.size > 0) {
|
||||||
await cleanupArchivedSessionTranscripts({
|
await cleanupArchivedSessionTranscripts({
|
||||||
directories: [...archivedDirs],
|
directories: [...archivedDirs],
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ import {
|
|||||||
createThreadBindingManager,
|
createThreadBindingManager,
|
||||||
} from "./thread-bindings.js";
|
} 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(
|
function createThreadBinding(
|
||||||
overrides?: Partial<
|
overrides?: Partial<
|
||||||
import("../../infra/outbound/session-binding-service.js").SessionBindingRecord
|
import("../../infra/outbound/session-binding-service.js").SessionBindingRecord
|
||||||
@@ -48,6 +54,34 @@ function createThreadBinding(
|
|||||||
} satisfies import("../../infra/outbound/session-binding-service.js").SessionBindingRecord;
|
} 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", () => {
|
describe("resolvePreflightMentionRequirement", () => {
|
||||||
it("requires mention when config requires mention and thread is not bound", () => {
|
it("requires mention when config requires mention and thread is not bound", () => {
|
||||||
expect(
|
expect(
|
||||||
@@ -312,42 +346,30 @@ describe("preflightDiscordMessage", () => {
|
|||||||
resolveByConversation: (ref) => (ref.conversationId === threadId ? threadBinding : null),
|
resolveByConversation: (ref) => (ref.conversationId === threadId ? threadBinding : null),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await preflightDiscordMessage({
|
const result = await preflightDiscordMessage(
|
||||||
cfg: {
|
createPreflightArgs({
|
||||||
session: {
|
cfg: {
|
||||||
mainKey: "main",
|
session: {
|
||||||
scope: "per-sender",
|
mainKey: "main",
|
||||||
},
|
scope: "per-sender",
|
||||||
} as import("../../config/config.js").OpenClawConfig,
|
},
|
||||||
discordConfig: {
|
} as import("../../config/config.js").OpenClawConfig,
|
||||||
allowBots: true,
|
discordConfig: {
|
||||||
} as NonNullable<import("../../config/config.js").OpenClawConfig["channels"]>["discord"],
|
allowBots: true,
|
||||||
accountId: "default",
|
} as DiscordConfig,
|
||||||
token: "token",
|
data: {
|
||||||
runtime: {} as import("../../runtime.js").RuntimeEnv,
|
channel_id: threadId,
|
||||||
botUserId: "openclaw-bot",
|
guild_id: "guild-1",
|
||||||
guildHistories: new Map(),
|
guild: {
|
||||||
historyLimit: 0,
|
id: "guild-1",
|
||||||
mediaMaxBytes: 1_000_000,
|
name: "Guild One",
|
||||||
textLimit: 2_000,
|
},
|
||||||
replyToMode: "all",
|
author: message.author,
|
||||||
dmEnabled: true,
|
message,
|
||||||
groupDmEnabled: true,
|
} as unknown as DiscordMessageEvent,
|
||||||
ackReactionScope: "direct",
|
client,
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result?.boundSessionKey).toBe(threadBinding.targetSessionKey);
|
expect(result?.boundSessionKey).toBe(threadBinding.targetSessionKey);
|
||||||
@@ -768,47 +790,33 @@ describe("preflightDiscordMessage", () => {
|
|||||||
},
|
},
|
||||||
} as unknown as import("@buape/carbon").Message;
|
} as unknown as import("@buape/carbon").Message;
|
||||||
|
|
||||||
const result = await preflightDiscordMessage({
|
const result = await preflightDiscordMessage(
|
||||||
cfg: {
|
createPreflightArgs({
|
||||||
session: {
|
cfg: {
|
||||||
mainKey: "main",
|
session: {
|
||||||
scope: "per-sender",
|
mainKey: "main",
|
||||||
},
|
scope: "per-sender",
|
||||||
messages: {
|
|
||||||
groupChat: {
|
|
||||||
mentionPatterns: ["openclaw"],
|
|
||||||
},
|
},
|
||||||
},
|
messages: {
|
||||||
} as import("../../config/config.js").OpenClawConfig,
|
groupChat: {
|
||||||
discordConfig: {} as NonNullable<
|
mentionPatterns: ["openclaw"],
|
||||||
import("../../config/config.js").OpenClawConfig["channels"]
|
},
|
||||||
>["discord"],
|
},
|
||||||
accountId: "default",
|
} as import("../../config/config.js").OpenClawConfig,
|
||||||
token: "token",
|
discordConfig: {} as DiscordConfig,
|
||||||
runtime: {} as import("../../runtime.js").RuntimeEnv,
|
data: {
|
||||||
botUserId: "openclaw-bot",
|
channel_id: channelId,
|
||||||
guildHistories: new Map(),
|
guild_id: "guild-1",
|
||||||
historyLimit: 0,
|
guild: {
|
||||||
mediaMaxBytes: 1_000_000,
|
id: "guild-1",
|
||||||
textLimit: 2_000,
|
name: "Guild One",
|
||||||
replyToMode: "all",
|
},
|
||||||
dmEnabled: true,
|
author: message.author,
|
||||||
groupDmEnabled: true,
|
message,
|
||||||
ackReactionScope: "direct",
|
} as unknown as DiscordMessageEvent,
|
||||||
groupPolicy: "open",
|
client,
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
|
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
|
||||||
expect(transcribeFirstAudioMock).toHaveBeenCalledWith(
|
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 () => {
|
it("keeps the new session when an old disconnected handler fires", async () => {
|
||||||
const oldConnection = createConnectionMock();
|
const oldConnection = createConnectionMock();
|
||||||
const newConnection = createConnectionMock();
|
const newConnection = createConnectionMock();
|
||||||
@@ -298,25 +322,7 @@ describe("DiscordVoiceManager", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const manager = createManager({ allowFrom: ["discord:u-owner"] }, client);
|
const manager = createManager({ allowFrom: ["discord:u-owner"] }, client);
|
||||||
await (
|
await processVoiceSegment(manager, "u-owner");
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
const commandArgs = agentCommandMock.mock.calls.at(-1)?.[0] as
|
const commandArgs = agentCommandMock.mock.calls.at(-1)?.[0] as
|
||||||
| { senderIsOwner?: boolean }
|
| { senderIsOwner?: boolean }
|
||||||
@@ -336,25 +342,7 @@ describe("DiscordVoiceManager", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const manager = createManager({ allowFrom: ["discord:u-owner"] }, client);
|
const manager = createManager({ allowFrom: ["discord:u-owner"] }, client);
|
||||||
await (
|
await processVoiceSegment(manager, "u-guest");
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
const commandArgs = agentCommandMock.mock.calls.at(-1)?.[0] as
|
const commandArgs = agentCommandMock.mock.calls.at(-1)?.[0] as
|
||||||
| { senderIsOwner?: boolean }
|
| { senderIsOwner?: boolean }
|
||||||
@@ -374,26 +362,7 @@ describe("DiscordVoiceManager", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const manager = createManager({ allowFrom: ["discord:u-cache"] }, client);
|
const manager = createManager({ allowFrom: ["discord:u-cache"] }, client);
|
||||||
const runSegment = async () =>
|
const runSegment = async () => await processVoiceSegment(manager, "u-cache");
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
await runSegment();
|
await runSegment();
|
||||||
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 { hasConfiguredSecretInput, resolveSecretInputRef } from "../config/types.secrets.js";
|
||||||
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
||||||
import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js";
|
import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js";
|
||||||
import { secretRefKey } from "../secrets/ref-contract.js";
|
import { resolveSecretInputString } from "../secrets/resolve-secret-input-string.js";
|
||||||
import { resolveSecretRefValues } from "../secrets/resolve.js";
|
|
||||||
import {
|
import {
|
||||||
GATEWAY_CLIENT_MODES,
|
GATEWAY_CLIENT_MODES,
|
||||||
GATEWAY_CLIENT_NAMES,
|
GATEWAY_CLIENT_NAMES,
|
||||||
@@ -312,23 +311,16 @@ async function resolveGatewaySecretInputString(params: {
|
|||||||
path: string;
|
path: string;
|
||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
}): Promise<string | undefined> {
|
}): Promise<string | undefined> {
|
||||||
const defaults = params.config.secrets?.defaults;
|
const value = await resolveSecretInputString({
|
||||||
const { ref } = resolveSecretInputRef({
|
|
||||||
value: params.value,
|
|
||||||
defaults,
|
|
||||||
});
|
|
||||||
if (!ref) {
|
|
||||||
return trimToUndefined(params.value);
|
|
||||||
}
|
|
||||||
const resolved = await resolveSecretRefValues([ref], {
|
|
||||||
config: params.config,
|
config: params.config,
|
||||||
|
value: params.value,
|
||||||
env: params.env,
|
env: params.env,
|
||||||
|
normalize: trimToUndefined,
|
||||||
});
|
});
|
||||||
const resolvedValue = trimToUndefined(resolved.get(secretRefKey(ref)));
|
if (!value) {
|
||||||
if (!resolvedValue) {
|
|
||||||
throw new Error(`${params.path} resolved to an empty or non-string value.`);
|
throw new Error(`${params.path} resolved to an empty or non-string value.`);
|
||||||
}
|
}
|
||||||
return resolvedValue;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveGatewayCredentials(context: ResolvedGatewayCallContext): Promise<{
|
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", () => {
|
describe("resolveGatewayCredentialsFromConfig", () => {
|
||||||
it("prefers explicit credentials over config and environment", () => {
|
it("prefers explicit credentials over config and environment", () => {
|
||||||
const resolved = resolveGatewayCredentialsFor(
|
const resolved = resolveGatewayCredentialsFor(
|
||||||
@@ -182,24 +203,7 @@ describe("resolveGatewayCredentialsFromConfig", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("ignores unresolved local password ref when local auth mode is none", () => {
|
it("ignores unresolved local password ref when local auth mode is none", () => {
|
||||||
const resolved = resolveGatewayCredentialsFromConfig({
|
const resolved = resolveLocalModeWithUnresolvedPassword("none");
|
||||||
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,
|
|
||||||
});
|
|
||||||
expect(resolved).toEqual({
|
expect(resolved).toEqual({
|
||||||
token: undefined,
|
token: undefined,
|
||||||
password: undefined,
|
password: undefined,
|
||||||
@@ -207,24 +211,7 @@ describe("resolveGatewayCredentialsFromConfig", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("ignores unresolved local password ref when local auth mode is trusted-proxy", () => {
|
it("ignores unresolved local password ref when local auth mode is trusted-proxy", () => {
|
||||||
const resolved = resolveGatewayCredentialsFromConfig({
|
const resolved = resolveLocalModeWithUnresolvedPassword("trusted-proxy");
|
||||||
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,
|
|
||||||
});
|
|
||||||
expect(resolved).toEqual({
|
expect(resolved).toEqual({
|
||||||
token: undefined,
|
token: undefined,
|
||||||
password: undefined,
|
password: undefined,
|
||||||
|
|||||||
@@ -1013,6 +1013,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
|||||||
shouldRetryExecReadProbe({
|
shouldRetryExecReadProbe({
|
||||||
text: execReadText,
|
text: execReadText,
|
||||||
nonce: nonceC,
|
nonce: nonceC,
|
||||||
|
provider: model.provider,
|
||||||
attempt: execReadAttempt,
|
attempt: execReadAttempt,
|
||||||
maxAttempts: maxExecReadAttempts,
|
maxAttempts: maxExecReadAttempts,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
hasExpectedSingleNonce,
|
hasExpectedSingleNonce,
|
||||||
hasExpectedToolNonce,
|
hasExpectedToolNonce,
|
||||||
|
isLikelyToolNonceRefusal,
|
||||||
shouldRetryExecReadProbe,
|
shouldRetryExecReadProbe,
|
||||||
shouldRetryToolReadProbe,
|
shouldRetryToolReadProbe,
|
||||||
} from "./live-tool-probe-utils.js";
|
} from "./live-tool-probe-utils.js";
|
||||||
@@ -17,6 +18,26 @@ describe("live tool probe utils", () => {
|
|||||||
expect(hasExpectedSingleNonce("value nonce-2", "nonce-1")).toBe(false);
|
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", () => {
|
it("retries malformed tool output when attempts remain", () => {
|
||||||
expect(
|
expect(
|
||||||
shouldRetryToolReadProbe({
|
shouldRetryToolReadProbe({
|
||||||
@@ -95,6 +116,32 @@ describe("live tool probe utils", () => {
|
|||||||
).toBe(true);
|
).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", () => {
|
it("does not retry nonce marker echoes for non-mistral providers", () => {
|
||||||
expect(
|
expect(
|
||||||
shouldRetryToolReadProbe({
|
shouldRetryToolReadProbe({
|
||||||
@@ -113,6 +160,7 @@ describe("live tool probe utils", () => {
|
|||||||
shouldRetryExecReadProbe({
|
shouldRetryExecReadProbe({
|
||||||
text: "read[object Object]",
|
text: "read[object Object]",
|
||||||
nonce: "nonce-c",
|
nonce: "nonce-c",
|
||||||
|
provider: "openai",
|
||||||
attempt: 0,
|
attempt: 0,
|
||||||
maxAttempts: 3,
|
maxAttempts: 3,
|
||||||
}),
|
}),
|
||||||
@@ -124,6 +172,7 @@ describe("live tool probe utils", () => {
|
|||||||
shouldRetryExecReadProbe({
|
shouldRetryExecReadProbe({
|
||||||
text: "read[object Object]",
|
text: "read[object Object]",
|
||||||
nonce: "nonce-c",
|
nonce: "nonce-c",
|
||||||
|
provider: "openai",
|
||||||
attempt: 2,
|
attempt: 2,
|
||||||
maxAttempts: 3,
|
maxAttempts: 3,
|
||||||
}),
|
}),
|
||||||
@@ -135,9 +184,22 @@ describe("live tool probe utils", () => {
|
|||||||
shouldRetryExecReadProbe({
|
shouldRetryExecReadProbe({
|
||||||
text: "nonce-c",
|
text: "nonce-c",
|
||||||
nonce: "nonce-c",
|
nonce: "nonce-c",
|
||||||
|
provider: "openai",
|
||||||
attempt: 0,
|
attempt: 0,
|
||||||
maxAttempts: 3,
|
maxAttempts: 3,
|
||||||
}),
|
}),
|
||||||
).toBe(false);
|
).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);
|
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 {
|
function hasMalformedToolOutput(text: string): boolean {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -38,6 +76,9 @@ export function shouldRetryToolReadProbe(params: {
|
|||||||
if (hasMalformedToolOutput(params.text)) {
|
if (hasMalformedToolOutput(params.text)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (params.provider === "anthropic" && isLikelyToolNonceRefusal(params.text)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const lower = params.text.trim().toLowerCase();
|
const lower = params.text.trim().toLowerCase();
|
||||||
if (params.provider === "mistral" && (lower.includes("noncea=") || lower.includes("nonceb="))) {
|
if (params.provider === "mistral" && (lower.includes("noncea=") || lower.includes("nonceb="))) {
|
||||||
return true;
|
return true;
|
||||||
@@ -48,6 +89,7 @@ export function shouldRetryToolReadProbe(params: {
|
|||||||
export function shouldRetryExecReadProbe(params: {
|
export function shouldRetryExecReadProbe(params: {
|
||||||
text: string;
|
text: string;
|
||||||
nonce: string;
|
nonce: string;
|
||||||
|
provider: string;
|
||||||
attempt: number;
|
attempt: number;
|
||||||
maxAttempts: number;
|
maxAttempts: number;
|
||||||
}): boolean {
|
}): boolean {
|
||||||
@@ -57,5 +99,8 @@ export function shouldRetryExecReadProbe(params: {
|
|||||||
if (hasExpectedSingleNonce(params.text, params.nonce)) {
|
if (hasExpectedSingleNonce(params.text, params.nonce)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (params.provider === "anthropic" && isLikelyToolNonceRefusal(params.text)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return hasMalformedToolOutput(params.text);
|
return hasMalformedToolOutput(params.text);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,77 +3,57 @@ import { agentCommand, installGatewayTestHooks, withGatewayServer } from "./test
|
|||||||
|
|
||||||
installGatewayTestHooks({ scope: "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", () => {
|
describe("OpenAI HTTP message channel", () => {
|
||||||
it("passes x-openclaw-message-channel through to agentCommand", async () => {
|
it("passes x-openclaw-message-channel through to agentCommand", async () => {
|
||||||
agentCommand.mockReset();
|
const firstCall = await runOpenAiMessageChannelRequest({
|
||||||
agentCommand.mockResolvedValueOnce({ payloads: [{ text: "ok" }] } as never);
|
messageChannelHeader: "custom-client-channel",
|
||||||
|
});
|
||||||
await withGatewayServer(
|
expect(firstCall?.messageChannel).toBe("custom-client-channel");
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults messageChannel to webchat when header is absent", async () => {
|
it("defaults messageChannel to webchat when header is absent", async () => {
|
||||||
agentCommand.mockReset();
|
const firstCall = await runOpenAiMessageChannelRequest();
|
||||||
agentCommand.mockResolvedValueOnce({ payloads: [{ text: "ok" }] } as never);
|
expect(firstCall?.messageChannel).toBe("webchat");
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
fsLstat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null),
|
fsLstat: vi.fn(async (..._args: unknown[]) => null as import("node:fs").Stats | null),
|
||||||
fsRealpath: vi.fn(async (p: string) => p),
|
fsRealpath: vi.fn(async (p: string) => p),
|
||||||
fsOpen: vi.fn(async () => ({}) as unknown),
|
fsOpen: vi.fn(async () => ({}) as unknown),
|
||||||
|
writeFileWithinRoot: vi.fn(async () => {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../config/config.js", () => ({
|
vi.mock("../../config/config.js", () => ({
|
||||||
@@ -77,6 +78,15 @@ vi.mock("../session-utils.js", () => ({
|
|||||||
listAgentsForGateway: mocks.listAgentsForGateway,
|
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"`
|
// 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
|
// which resolves to the module namespace default, so we spread actual and
|
||||||
// override the methods we need, plus set `default` explicitly.
|
// override the methods we need, plus set `default` explicitly.
|
||||||
|
|||||||
@@ -732,10 +732,19 @@ export const agentsHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = String(params.content ?? "");
|
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 {
|
try {
|
||||||
await writeFileWithinRoot({
|
await writeFileWithinRoot({
|
||||||
rootDir: workspaceDir,
|
rootDir: resolvedPath.workspaceReal,
|
||||||
relativePath: name,
|
relativePath: relativeWritePath,
|
||||||
data: content,
|
data: content,
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -274,20 +274,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const p = params as {
|
const p = params as Parameters<typeof requestNodePairing>[0];
|
||||||
nodeId: string;
|
|
||||||
displayName?: string;
|
|
||||||
platform?: string;
|
|
||||||
version?: string;
|
|
||||||
coreVersion?: string;
|
|
||||||
uiVersion?: string;
|
|
||||||
deviceFamily?: string;
|
|
||||||
modelIdentifier?: string;
|
|
||||||
caps?: string[];
|
|
||||||
commands?: string[];
|
|
||||||
remoteIp?: string;
|
|
||||||
silent?: boolean;
|
|
||||||
};
|
|
||||||
await respondUnavailableOnThrow(respond, async () => {
|
await respondUnavailableOnThrow(respond, async () => {
|
||||||
const result = await requestNodePairing({
|
const result = await requestNodePairing({
|
||||||
nodeId: p.nodeId,
|
nodeId: p.nodeId,
|
||||||
@@ -300,6 +287,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
|||||||
modelIdentifier: p.modelIdentifier,
|
modelIdentifier: p.modelIdentifier,
|
||||||
caps: p.caps,
|
caps: p.caps,
|
||||||
commands: p.commands,
|
commands: p.commands,
|
||||||
|
permissions: p.permissions,
|
||||||
remoteIp: p.remoteIp,
|
remoteIp: p.remoteIp,
|
||||||
silent: p.silent,
|
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", () => {
|
describe("secrets handlers", () => {
|
||||||
function createHandlers(overrides?: {
|
function createHandlers(overrides?: {
|
||||||
reloadSecrets?: () => Promise<{ warningCount: number }>;
|
reloadSecrets?: () => Promise<{ warningCount: number }>;
|
||||||
@@ -73,13 +94,11 @@ describe("secrets handlers", () => {
|
|||||||
});
|
});
|
||||||
const handlers = createHandlers({ resolveSecrets });
|
const handlers = createHandlers({ resolveSecrets });
|
||||||
const respond = vi.fn();
|
const respond = vi.fn();
|
||||||
await handlers["secrets.resolve"]({
|
await invokeSecretsResolve({
|
||||||
req: { type: "req", id: "1", method: "secrets.resolve" },
|
handlers,
|
||||||
params: { commandName: "memory status", targetIds: ["talk.apiKey"] },
|
|
||||||
client: null,
|
|
||||||
isWebchatConnect: () => false,
|
|
||||||
respond,
|
respond,
|
||||||
context: {} as never,
|
commandName: "memory status",
|
||||||
|
targetIds: ["talk.apiKey"],
|
||||||
});
|
});
|
||||||
expect(resolveSecrets).toHaveBeenCalledWith({
|
expect(resolveSecrets).toHaveBeenCalledWith({
|
||||||
commandName: "memory status",
|
commandName: "memory status",
|
||||||
@@ -96,13 +115,11 @@ describe("secrets handlers", () => {
|
|||||||
it("rejects invalid secrets.resolve params", async () => {
|
it("rejects invalid secrets.resolve params", async () => {
|
||||||
const handlers = createHandlers();
|
const handlers = createHandlers();
|
||||||
const respond = vi.fn();
|
const respond = vi.fn();
|
||||||
await handlers["secrets.resolve"]({
|
await invokeSecretsResolve({
|
||||||
req: { type: "req", id: "1", method: "secrets.resolve" },
|
handlers,
|
||||||
params: { commandName: "", targetIds: "bad" },
|
|
||||||
client: null,
|
|
||||||
isWebchatConnect: () => false,
|
|
||||||
respond,
|
respond,
|
||||||
context: {} as never,
|
commandName: "",
|
||||||
|
targetIds: "bad",
|
||||||
});
|
});
|
||||||
expect(respond).toHaveBeenCalledWith(
|
expect(respond).toHaveBeenCalledWith(
|
||||||
false,
|
false,
|
||||||
@@ -117,13 +134,11 @@ describe("secrets handlers", () => {
|
|||||||
const resolveSecrets = vi.fn();
|
const resolveSecrets = vi.fn();
|
||||||
const handlers = createHandlers({ resolveSecrets });
|
const handlers = createHandlers({ resolveSecrets });
|
||||||
const respond = vi.fn();
|
const respond = vi.fn();
|
||||||
await handlers["secrets.resolve"]({
|
await invokeSecretsResolve({
|
||||||
req: { type: "req", id: "1", method: "secrets.resolve" },
|
handlers,
|
||||||
params: { commandName: "memory status", targetIds: ["talk.apiKey", 12] },
|
|
||||||
client: null,
|
|
||||||
isWebchatConnect: () => false,
|
|
||||||
respond,
|
respond,
|
||||||
context: {} as never,
|
commandName: "memory status",
|
||||||
|
targetIds: ["talk.apiKey", 12],
|
||||||
});
|
});
|
||||||
expect(resolveSecrets).not.toHaveBeenCalled();
|
expect(resolveSecrets).not.toHaveBeenCalled();
|
||||||
expect(respond).toHaveBeenCalledWith(
|
expect(respond).toHaveBeenCalledWith(
|
||||||
@@ -140,13 +155,11 @@ describe("secrets handlers", () => {
|
|||||||
const resolveSecrets = vi.fn();
|
const resolveSecrets = vi.fn();
|
||||||
const handlers = createHandlers({ resolveSecrets });
|
const handlers = createHandlers({ resolveSecrets });
|
||||||
const respond = vi.fn();
|
const respond = vi.fn();
|
||||||
await handlers["secrets.resolve"]({
|
await invokeSecretsResolve({
|
||||||
req: { type: "req", id: "1", method: "secrets.resolve" },
|
handlers,
|
||||||
params: { commandName: "memory status", targetIds: ["unknown.target"] },
|
|
||||||
client: null,
|
|
||||||
isWebchatConnect: () => false,
|
|
||||||
respond,
|
respond,
|
||||||
context: {} as never,
|
commandName: "memory status",
|
||||||
|
targetIds: ["unknown.target"],
|
||||||
});
|
});
|
||||||
expect(resolveSecrets).not.toHaveBeenCalled();
|
expect(resolveSecrets).not.toHaveBeenCalled();
|
||||||
expect(respond).toHaveBeenCalledWith(
|
expect(respond).toHaveBeenCalledWith(
|
||||||
@@ -167,13 +180,11 @@ describe("secrets handlers", () => {
|
|||||||
});
|
});
|
||||||
const handlers = createHandlers({ resolveSecrets });
|
const handlers = createHandlers({ resolveSecrets });
|
||||||
const respond = vi.fn();
|
const respond = vi.fn();
|
||||||
await handlers["secrets.resolve"]({
|
await invokeSecretsResolve({
|
||||||
req: { type: "req", id: "1", method: "secrets.resolve" },
|
handlers,
|
||||||
params: { commandName: "memory status", targetIds: ["talk.apiKey"] },
|
|
||||||
client: null,
|
|
||||||
isWebchatConnect: () => false,
|
|
||||||
respond,
|
respond,
|
||||||
context: {} as never,
|
commandName: "memory status",
|
||||||
|
targetIds: ["talk.apiKey"],
|
||||||
});
|
});
|
||||||
expect(respond).toHaveBeenCalledWith(
|
expect(respond).toHaveBeenCalledWith(
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -151,6 +151,35 @@ async function addMainSystemEventCronJob(params: { ws: WebSocket; name: string;
|
|||||||
return expectCronJobIdFromResponse(response);
|
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) {
|
function getWebhookCall(index: number) {
|
||||||
const [args] = fetchWithSsrFGuardMock.mock.calls[index] as unknown as [
|
const [args] = fetchWithSsrFGuardMock.mock.calls[index] as unknown as [
|
||||||
{
|
{
|
||||||
@@ -574,22 +603,12 @@ describe("gateway server cron", () => {
|
|||||||
});
|
});
|
||||||
expect(invalidWebhookRes.ok).toBe(false);
|
expect(invalidWebhookRes.ok).toBe(false);
|
||||||
|
|
||||||
const notifyRes = await rpcReq(ws, "cron.add", {
|
const notifyJobId = await addWebhookCronJob({
|
||||||
|
ws,
|
||||||
name: "webhook enabled",
|
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" },
|
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
|
||||||
});
|
});
|
||||||
expect(notifyRes.ok).toBe(true);
|
await runCronJobForce(ws, notifyJobId);
|
||||||
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 waitForCondition(
|
await waitForCondition(
|
||||||
() => fetchWithSsrFGuardMock.mock.calls.length === 1,
|
() => fetchWithSsrFGuardMock.mock.calls.length === 1,
|
||||||
@@ -644,13 +663,10 @@ describe("gateway server cron", () => {
|
|||||||
|
|
||||||
fetchWithSsrFGuardMock.mockClear();
|
fetchWithSsrFGuardMock.mockClear();
|
||||||
cronIsolatedRun.mockResolvedValueOnce({ status: "error", summary: "delivery failed" });
|
cronIsolatedRun.mockResolvedValueOnce({ status: "error", summary: "delivery failed" });
|
||||||
const failureDestRes = await rpcReq(ws, "cron.add", {
|
const failureDestJobId = await addWebhookCronJob({
|
||||||
|
ws,
|
||||||
name: "failure destination webhook",
|
name: "failure destination webhook",
|
||||||
enabled: true,
|
|
||||||
schedule: { kind: "every", everyMs: 60_000 },
|
|
||||||
sessionTarget: "isolated",
|
sessionTarget: "isolated",
|
||||||
wakeMode: "next-heartbeat",
|
|
||||||
payload: { kind: "agentTurn", message: "test" },
|
|
||||||
delivery: {
|
delivery: {
|
||||||
mode: "announce",
|
mode: "announce",
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
@@ -661,19 +677,7 @@ describe("gateway server cron", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(failureDestRes.ok).toBe(true);
|
await runCronJobForce(ws, failureDestJobId);
|
||||||
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 waitForCondition(
|
await waitForCondition(
|
||||||
() => fetchWithSsrFGuardMock.mock.calls.length === 1,
|
() => fetchWithSsrFGuardMock.mock.calls.length === 1,
|
||||||
CRON_WAIT_TIMEOUT_MS,
|
CRON_WAIT_TIMEOUT_MS,
|
||||||
@@ -686,27 +690,13 @@ describe("gateway server cron", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
cronIsolatedRun.mockResolvedValueOnce({ status: "ok", summary: "" });
|
cronIsolatedRun.mockResolvedValueOnce({ status: "ok", summary: "" });
|
||||||
const noSummaryRes = await rpcReq(ws, "cron.add", {
|
const noSummaryJobId = await addWebhookCronJob({
|
||||||
|
ws,
|
||||||
name: "webhook no summary",
|
name: "webhook no summary",
|
||||||
enabled: true,
|
|
||||||
schedule: { kind: "every", everyMs: 60_000 },
|
|
||||||
sessionTarget: "isolated",
|
sessionTarget: "isolated",
|
||||||
wakeMode: "next-heartbeat",
|
|
||||||
payload: { kind: "agentTurn", message: "test" },
|
|
||||||
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
|
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
|
||||||
});
|
});
|
||||||
expect(noSummaryRes.ok).toBe(true);
|
await runCronJobForce(ws, noSummaryJobId);
|
||||||
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 yieldToEventLoop();
|
await yieldToEventLoop();
|
||||||
await yieldToEventLoop();
|
await yieldToEventLoop();
|
||||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(1);
|
expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(1);
|
||||||
@@ -746,22 +736,12 @@ describe("gateway server cron", () => {
|
|||||||
await connectOk(ws);
|
await connectOk(ws);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const notifyRes = await rpcReq(ws, "cron.add", {
|
const notifyJobId = await addWebhookCronJob({
|
||||||
|
ws,
|
||||||
name: "webhook secretinput object",
|
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" },
|
delivery: { mode: "webhook", to: "https://example.invalid/cron-finished" },
|
||||||
});
|
});
|
||||||
expect(notifyRes.ok).toBe(true);
|
await runCronJobForce(ws, notifyJobId);
|
||||||
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 waitForCondition(
|
await waitForCondition(
|
||||||
() => fetchWithSsrFGuardMock.mock.calls.length === 1,
|
() => fetchWithSsrFGuardMock.mock.calls.length === 1,
|
||||||
|
|||||||
@@ -339,6 +339,46 @@ async function startGatewayServerWithRetries(params: {
|
|||||||
throw new Error("failed to start gateway server after retries");
|
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>(
|
export async function withGatewayServer<T>(
|
||||||
fn: (ctx: { port: number; server: Awaited<ReturnType<typeof startGatewayServer>> }) => Promise<T>,
|
fn: (ctx: { port: number; server: Awaited<ReturnType<typeof startGatewayServer>> }) => Promise<T>,
|
||||||
opts?: { port?: number; serverOptions?: GatewayServerOptions },
|
opts?: { port?: number; serverOptions?: GatewayServerOptions },
|
||||||
@@ -371,33 +411,10 @@ export async function createGatewaySuiteHarness(opts?: {
|
|||||||
port: started.port,
|
port: started.port,
|
||||||
server: started.server,
|
server: started.server,
|
||||||
openWs: async (headers?: Record<string, string>) => {
|
openWs: async (headers?: Record<string, string>) => {
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:${started.port}`, headers ? { headers } : undefined);
|
return await openTrackedWebSocket({
|
||||||
trackConnectChallengeNonce(ws);
|
port: started.port,
|
||||||
await new Promise<void>((resolve, reject) => {
|
headers,
|
||||||
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 ws;
|
|
||||||
},
|
},
|
||||||
close: async () => {
|
close: async () => {
|
||||||
await started.server.close();
|
await started.server.close();
|
||||||
@@ -431,35 +448,7 @@ export async function startServerWithClient(
|
|||||||
port = started.port;
|
port = started.port;
|
||||||
const server = started.server;
|
const server = started.server;
|
||||||
|
|
||||||
const ws = new WebSocket(
|
const ws = await openTrackedWebSocket({ port, headers: wsHeaders });
|
||||||
`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);
|
|
||||||
});
|
|
||||||
return { server, ws, port, prevToken: prev, envSnapshot };
|
return { server, ws, port, prevToken: prev, envSnapshot };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { parseFrontmatterBlock } from "../markdown/frontmatter.js";
|
import { parseFrontmatterBlock } from "../markdown/frontmatter.js";
|
||||||
import {
|
import {
|
||||||
|
applyOpenClawManifestInstallCommonFields,
|
||||||
getFrontmatterString,
|
getFrontmatterString,
|
||||||
normalizeStringList,
|
normalizeStringList,
|
||||||
parseOpenClawManifestInstallBase,
|
parseOpenClawManifestInstallBase,
|
||||||
@@ -27,19 +28,12 @@ function parseInstallSpec(input: unknown): HookInstallSpec | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const { raw } = parsed;
|
const { raw } = parsed;
|
||||||
const spec: HookInstallSpec = {
|
const spec = applyOpenClawManifestInstallCommonFields<HookInstallSpec>(
|
||||||
kind: parsed.kind as HookInstallSpec["kind"],
|
{
|
||||||
};
|
kind: parsed.kind as HookInstallSpec["kind"],
|
||||||
|
},
|
||||||
if (parsed.id) {
|
parsed,
|
||||||
spec.id = parsed.id;
|
);
|
||||||
}
|
|
||||||
if (parsed.label) {
|
|
||||||
spec.label = parsed.label;
|
|
||||||
}
|
|
||||||
if (parsed.bins) {
|
|
||||||
spec.bins = parsed.bins;
|
|
||||||
}
|
|
||||||
if (typeof raw.package === "string") {
|
if (typeof raw.package === "string") {
|
||||||
spec.package = raw.package;
|
spec.package = raw.package;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export type MessageSentHookEvent = InternalHookEvent & {
|
|||||||
context: MessageSentHookContext;
|
context: MessageSentHookContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessageTranscribedHookContext = {
|
type MessageEnrichedBodyHookContext = {
|
||||||
/** Sender identifier (e.g., phone number, user ID) */
|
/** Sender identifier (e.g., phone number, user ID) */
|
||||||
from?: string;
|
from?: string;
|
||||||
/** Recipient identifier */
|
/** Recipient identifier */
|
||||||
@@ -106,8 +106,6 @@ export type MessageTranscribedHookContext = {
|
|||||||
body?: string;
|
body?: string;
|
||||||
/** Enriched body shown to the agent, including transcript */
|
/** Enriched body shown to the agent, including transcript */
|
||||||
bodyForAgent?: string;
|
bodyForAgent?: string;
|
||||||
/** The transcribed text from audio */
|
|
||||||
transcript: string;
|
|
||||||
/** Unix timestamp when the message was received */
|
/** Unix timestamp when the message was received */
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
/** Channel identifier (e.g., "telegram", "whatsapp") */
|
/** Channel identifier (e.g., "telegram", "whatsapp") */
|
||||||
@@ -132,45 +130,20 @@ export type MessageTranscribedHookContext = {
|
|||||||
mediaType?: string;
|
mediaType?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MessageTranscribedHookContext = MessageEnrichedBodyHookContext & {
|
||||||
|
/** The transcribed text from audio */
|
||||||
|
transcript: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type MessageTranscribedHookEvent = InternalHookEvent & {
|
export type MessageTranscribedHookEvent = InternalHookEvent & {
|
||||||
type: "message";
|
type: "message";
|
||||||
action: "transcribed";
|
action: "transcribed";
|
||||||
context: MessageTranscribedHookContext;
|
context: MessageTranscribedHookContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessagePreprocessedHookContext = {
|
export type MessagePreprocessedHookContext = MessageEnrichedBodyHookContext & {
|
||||||
/** 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;
|
|
||||||
/** Transcribed audio text, if the message contained audio */
|
/** Transcribed audio text, if the message contained audio */
|
||||||
transcript?: string;
|
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 */
|
/** Whether this message was sent in a group/channel context */
|
||||||
isGroup?: boolean;
|
isGroup?: boolean;
|
||||||
/** Group or channel identifier, if applicable */
|
/** Group or channel identifier, if applicable */
|
||||||
|
|||||||
@@ -213,23 +213,10 @@ export function toInternalMessageTranscribedContext(
|
|||||||
canonical: CanonicalInboundMessageHookContext,
|
canonical: CanonicalInboundMessageHookContext,
|
||||||
cfg: OpenClawConfig,
|
cfg: OpenClawConfig,
|
||||||
): MessageTranscribedHookContext & { cfg: OpenClawConfig } {
|
): MessageTranscribedHookContext & { cfg: OpenClawConfig } {
|
||||||
|
const shared = toInternalInboundMessageHookContextBase(canonical);
|
||||||
return {
|
return {
|
||||||
from: canonical.from,
|
...shared,
|
||||||
to: canonical.to,
|
|
||||||
body: canonical.body,
|
|
||||||
bodyForAgent: canonical.bodyForAgent,
|
|
||||||
transcript: canonical.transcript ?? "",
|
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,
|
cfg,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -238,12 +225,22 @@ export function toInternalMessagePreprocessedContext(
|
|||||||
canonical: CanonicalInboundMessageHookContext,
|
canonical: CanonicalInboundMessageHookContext,
|
||||||
cfg: OpenClawConfig,
|
cfg: OpenClawConfig,
|
||||||
): MessagePreprocessedHookContext & { 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 {
|
return {
|
||||||
from: canonical.from,
|
from: canonical.from,
|
||||||
to: canonical.to,
|
to: canonical.to,
|
||||||
body: canonical.body,
|
body: canonical.body,
|
||||||
bodyForAgent: canonical.bodyForAgent,
|
bodyForAgent: canonical.bodyForAgent,
|
||||||
transcript: canonical.transcript,
|
|
||||||
timestamp: canonical.timestamp,
|
timestamp: canonical.timestamp,
|
||||||
channelId: canonical.channelId,
|
channelId: canonical.channelId,
|
||||||
conversationId: canonical.conversationId,
|
conversationId: canonical.conversationId,
|
||||||
@@ -255,9 +252,6 @@ export function toInternalMessagePreprocessedContext(
|
|||||||
surface: canonical.surface,
|
surface: canonical.surface,
|
||||||
mediaPath: canonical.mediaPath,
|
mediaPath: canonical.mediaPath,
|
||||||
mediaType: canonical.mediaType,
|
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 ServicePrefix<TService extends string> = { prefix: string; service: TService };
|
||||||
|
|
||||||
export type ChatTargetPrefixesParams = {
|
export type ChatTargetPrefixesParams = {
|
||||||
@@ -13,10 +15,24 @@ export type ParsedChatTarget =
|
|||||||
| { kind: "chat_guid"; chatGuid: string }
|
| { kind: "chat_guid"; chatGuid: string }
|
||||||
| { kind: "chat_identifier"; chatIdentifier: 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 {
|
function stripPrefix(value: string, prefix: string): string {
|
||||||
return value.slice(prefix.length).trim();
|
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: {
|
export function resolveServicePrefixedTarget<TService extends string, TTarget>(params: {
|
||||||
trimmed: string;
|
trimmed: string;
|
||||||
lower: string;
|
lower: string;
|
||||||
@@ -41,6 +57,31 @@ export function resolveServicePrefixedTarget<TService extends string, TTarget>(p
|
|||||||
return null;
|
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(
|
export function parseChatTargetPrefixesOrThrow(
|
||||||
params: ChatTargetPrefixesParams,
|
params: ChatTargetPrefixesParams,
|
||||||
): ParsedChatTarget | null {
|
): ParsedChatTarget | null {
|
||||||
@@ -97,6 +138,56 @@ export function resolveServicePrefixedAllowTarget<TAllowTarget>(params: {
|
|||||||
return null;
|
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(
|
export function parseChatAllowTargetPrefixes(
|
||||||
params: ChatTargetPrefixesParams,
|
params: ChatTargetPrefixesParams,
|
||||||
): ParsedChatTarget | null {
|
): ParsedChatTarget | null {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { isAllowedParsedChatSender } from "../plugin-sdk/allow-from.js";
|
|
||||||
import { normalizeE164 } from "../utils.js";
|
import { normalizeE164 } from "../utils.js";
|
||||||
import {
|
import {
|
||||||
|
createAllowedChatSenderMatcher,
|
||||||
|
type ChatSenderAllowParams,
|
||||||
type ParsedChatTarget,
|
type ParsedChatTarget,
|
||||||
parseChatAllowTargetPrefixes,
|
|
||||||
parseChatTargetPrefixesOrThrow,
|
parseChatTargetPrefixesOrThrow,
|
||||||
resolveServicePrefixedAllowTarget,
|
resolveServicePrefixedChatTarget,
|
||||||
resolveServicePrefixedTarget,
|
resolveServicePrefixedOrChatAllowTarget,
|
||||||
} from "./target-parsing-helpers.js";
|
} from "./target-parsing-helpers.js";
|
||||||
|
|
||||||
export type IMessageService = "imessage" | "sms" | "auto";
|
export type IMessageService = "imessage" | "sms" | "auto";
|
||||||
@@ -80,14 +80,13 @@ export function parseIMessageTarget(raw: string): IMessageTarget {
|
|||||||
}
|
}
|
||||||
const lower = trimmed.toLowerCase();
|
const lower = trimmed.toLowerCase();
|
||||||
|
|
||||||
const servicePrefixed = resolveServicePrefixedTarget({
|
const servicePrefixed = resolveServicePrefixedChatTarget({
|
||||||
trimmed,
|
trimmed,
|
||||||
lower,
|
lower,
|
||||||
servicePrefixes: SERVICE_PREFIXES,
|
servicePrefixes: SERVICE_PREFIXES,
|
||||||
isChatTarget: (remainderLower) =>
|
chatIdPrefixes: CHAT_ID_PREFIXES,
|
||||||
CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
chatGuidPrefixes: CHAT_GUID_PREFIXES,
|
||||||
CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
|
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
|
||||||
CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)),
|
|
||||||
parseTarget: parseIMessageTarget,
|
parseTarget: parseIMessageTarget,
|
||||||
});
|
});
|
||||||
if (servicePrefixed) {
|
if (servicePrefixed) {
|
||||||
@@ -115,46 +114,29 @@ export function parseIMessageAllowTarget(raw: string): IMessageAllowTarget {
|
|||||||
}
|
}
|
||||||
const lower = trimmed.toLowerCase();
|
const lower = trimmed.toLowerCase();
|
||||||
|
|
||||||
const servicePrefixed = resolveServicePrefixedAllowTarget({
|
const servicePrefixed = resolveServicePrefixedOrChatAllowTarget({
|
||||||
trimmed,
|
trimmed,
|
||||||
lower,
|
lower,
|
||||||
servicePrefixes: SERVICE_PREFIXES,
|
servicePrefixes: SERVICE_PREFIXES,
|
||||||
parseAllowTarget: parseIMessageAllowTarget,
|
parseAllowTarget: parseIMessageAllowTarget,
|
||||||
|
chatIdPrefixes: CHAT_ID_PREFIXES,
|
||||||
|
chatGuidPrefixes: CHAT_GUID_PREFIXES,
|
||||||
|
chatIdentifierPrefixes: CHAT_IDENTIFIER_PREFIXES,
|
||||||
});
|
});
|
||||||
if (servicePrefixed) {
|
if (servicePrefixed) {
|
||||||
return 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) };
|
return { kind: "handle", handle: normalizeIMessageHandle(trimmed) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAllowedIMessageSender(params: {
|
const isAllowedIMessageSenderMatcher = createAllowedChatSenderMatcher({
|
||||||
allowFrom: Array<string | number>;
|
normalizeSender: normalizeIMessageHandle,
|
||||||
sender: string;
|
parseAllowTarget: parseIMessageAllowTarget,
|
||||||
chatId?: number | null;
|
});
|
||||||
chatGuid?: string | null;
|
|
||||||
chatIdentifier?: string | null;
|
export function isAllowedIMessageSender(params: ChatSenderAllowParams): boolean {
|
||||||
}): boolean {
|
return isAllowedIMessageSenderMatcher(params);
|
||||||
return isAllowedParsedChatSender({
|
|
||||||
allowFrom: params.allowFrom,
|
|
||||||
sender: params.sender,
|
|
||||||
chatId: params.chatId,
|
|
||||||
chatGuid: params.chatGuid,
|
|
||||||
chatIdentifier: params.chatIdentifier,
|
|
||||||
normalizeSender: normalizeIMessageHandle,
|
|
||||||
parseAllowTarget: parseIMessageAllowTarget,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatIMessageChatTarget(chatId?: number | null): string {
|
export function formatIMessageChatTarget(chatId?: number | null): string {
|
||||||
|
|||||||
@@ -540,12 +540,9 @@ async function resolveOutsideBoundaryPathAsync(params: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const kind = await getPathKind(params.context.absolutePath, false);
|
const kind = await getPathKind(params.context.absolutePath, false);
|
||||||
return buildOutsideLexicalBoundaryPath({
|
return buildOutsideBoundaryPathFromContext({
|
||||||
boundaryLabel: params.boundaryLabel,
|
boundaryLabel: params.boundaryLabel,
|
||||||
rootCanonicalPath: params.context.rootCanonicalPath,
|
context: params.context,
|
||||||
absolutePath: params.context.absolutePath,
|
|
||||||
canonicalOutsideLexicalPath: params.context.canonicalOutsideLexicalPath,
|
|
||||||
rootPath: params.context.rootPath,
|
|
||||||
kind,
|
kind,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -558,13 +555,25 @@ function resolveOutsideBoundaryPathSync(params: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const kind = getPathKindSync(params.context.absolutePath, false);
|
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({
|
return buildOutsideLexicalBoundaryPath({
|
||||||
boundaryLabel: params.boundaryLabel,
|
boundaryLabel: params.boundaryLabel,
|
||||||
rootCanonicalPath: params.context.rootCanonicalPath,
|
rootCanonicalPath: params.context.rootCanonicalPath,
|
||||||
absolutePath: params.context.absolutePath,
|
absolutePath: params.context.absolutePath,
|
||||||
canonicalOutsideLexicalPath: params.context.canonicalOutsideLexicalPath,
|
canonicalOutsideLexicalPath: params.context.canonicalOutsideLexicalPath,
|
||||||
rootPath: params.context.rootPath,
|
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 ExecSecurity = "deny" | "allowlist" | "full";
|
||||||
export type ExecAsk = "off" | "on-miss" | "always";
|
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 = {
|
export type SystemRunApprovalBinding = {
|
||||||
argv: string[];
|
argv: string[];
|
||||||
cwd: string | null;
|
cwd: string | null;
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
import { rejectPendingPairingRequest } from "./pairing-pending.js";
|
import { rejectPendingPairingRequest } from "./pairing-pending.js";
|
||||||
import { generatePairingToken, verifyPairingToken } from "./pairing-token.js";
|
import { generatePairingToken, verifyPairingToken } from "./pairing-token.js";
|
||||||
|
|
||||||
export type NodePairingPendingRequest = {
|
type NodePairingNodeMetadata = {
|
||||||
requestId: string;
|
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
@@ -24,26 +23,18 @@ export type NodePairingPendingRequest = {
|
|||||||
commands?: string[];
|
commands?: string[];
|
||||||
permissions?: Record<string, boolean>;
|
permissions?: Record<string, boolean>;
|
||||||
remoteIp?: string;
|
remoteIp?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NodePairingPendingRequest = NodePairingNodeMetadata & {
|
||||||
|
requestId: string;
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
isRepair?: boolean;
|
isRepair?: boolean;
|
||||||
ts: number;
|
ts: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NodePairingPairedNode = {
|
export type NodePairingPairedNode = Omit<NodePairingNodeMetadata, "requestId"> & {
|
||||||
nodeId: string;
|
|
||||||
token: string;
|
token: string;
|
||||||
displayName?: string;
|
|
||||||
platform?: string;
|
|
||||||
version?: string;
|
|
||||||
coreVersion?: string;
|
|
||||||
uiVersion?: string;
|
|
||||||
deviceFamily?: string;
|
|
||||||
modelIdentifier?: string;
|
|
||||||
caps?: string[];
|
|
||||||
commands?: string[];
|
|
||||||
bins?: string[];
|
bins?: string[];
|
||||||
permissions?: Record<string, boolean>;
|
|
||||||
remoteIp?: string;
|
|
||||||
createdAtMs: number;
|
createdAtMs: number;
|
||||||
approvedAtMs: number;
|
approvedAtMs: number;
|
||||||
lastConnectedAtMs?: 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 { PROVIDER_LABELS } from "./provider-usage.shared.js";
|
||||||
import type { ProviderUsageSnapshot, UsageProviderId } from "./provider-usage.types.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 {
|
export function parseFiniteNumber(value: unknown): number | undefined {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
return parseFiniteNumberish(value);
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
const parsed = Number.parseFloat(value);
|
|
||||||
if (Number.isFinite(parsed)) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type BuildUsageHttpErrorSnapshotOptions = {
|
type BuildUsageHttpErrorSnapshotOptions = {
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
export { extractBatchErrorMessage, formatUnavailableBatchError } from "./batch-error-utils.js";
|
export { extractBatchErrorMessage, formatUnavailableBatchError } from "./batch-error-utils.js";
|
||||||
export { postJsonWithRetry } from "./batch-http.js";
|
export { postJsonWithRetry } from "./batch-http.js";
|
||||||
export { applyEmbeddingBatchOutputLine } from "./batch-output.js";
|
export { applyEmbeddingBatchOutputLine } from "./batch-output.js";
|
||||||
|
export {
|
||||||
|
resolveBatchCompletionFromStatus,
|
||||||
|
resolveCompletedBatchResult,
|
||||||
|
throwIfBatchTerminalFailure,
|
||||||
|
type BatchCompletionResult,
|
||||||
|
} from "./batch-status.js";
|
||||||
export {
|
export {
|
||||||
EMBEDDING_BATCH_ENDPOINT,
|
EMBEDDING_BATCH_ENDPOINT,
|
||||||
type EmbeddingBatchStatus,
|
type EmbeddingBatchStatus,
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ import {
|
|||||||
formatUnavailableBatchError,
|
formatUnavailableBatchError,
|
||||||
normalizeBatchBaseUrl,
|
normalizeBatchBaseUrl,
|
||||||
postJsonWithRetry,
|
postJsonWithRetry,
|
||||||
|
resolveBatchCompletionFromStatus,
|
||||||
|
resolveCompletedBatchResult,
|
||||||
runEmbeddingBatchGroups,
|
runEmbeddingBatchGroups,
|
||||||
|
throwIfBatchTerminalFailure,
|
||||||
type EmbeddingBatchExecutionParams,
|
type EmbeddingBatchExecutionParams,
|
||||||
type EmbeddingBatchStatus,
|
type EmbeddingBatchStatus,
|
||||||
|
type BatchCompletionResult,
|
||||||
type ProviderBatchOutputLine,
|
type ProviderBatchOutputLine,
|
||||||
uploadBatchJsonlFile,
|
uploadBatchJsonlFile,
|
||||||
withRemoteHttpResponse,
|
withRemoteHttpResponse,
|
||||||
@@ -144,7 +148,7 @@ async function waitForOpenAiBatch(params: {
|
|||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
debug?: (message: string, data?: Record<string, unknown>) => void;
|
debug?: (message: string, data?: Record<string, unknown>) => void;
|
||||||
initial?: OpenAiBatchStatus;
|
initial?: OpenAiBatchStatus;
|
||||||
}): Promise<{ outputFileId: string; errorFileId?: string }> {
|
}): Promise<BatchCompletionResult> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
let current: OpenAiBatchStatus | undefined = params.initial;
|
let current: OpenAiBatchStatus | undefined = params.initial;
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -156,21 +160,21 @@ async function waitForOpenAiBatch(params: {
|
|||||||
}));
|
}));
|
||||||
const state = status.status ?? "unknown";
|
const state = status.status ?? "unknown";
|
||||||
if (state === "completed") {
|
if (state === "completed") {
|
||||||
if (!status.output_file_id) {
|
return resolveBatchCompletionFromStatus({
|
||||||
throw new Error(`openai batch ${params.batchId} completed without output file`);
|
provider: "openai",
|
||||||
}
|
batchId: params.batchId,
|
||||||
return {
|
status,
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
await throwIfBatchTerminalFailure({
|
||||||
|
provider: "openai",
|
||||||
|
status: { ...status, id: params.batchId },
|
||||||
|
readError: async (errorFileId) =>
|
||||||
|
await readOpenAiBatchError({
|
||||||
|
openAi: params.openAi,
|
||||||
|
errorFileId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
if (!params.wait) {
|
if (!params.wait) {
|
||||||
throw new Error(`openai batch ${params.batchId} still ${state}; wait disabled`);
|
throw new Error(`openai batch ${params.batchId} still ${state}; wait disabled`);
|
||||||
}
|
}
|
||||||
@@ -204,6 +208,7 @@ export async function runOpenAiEmbeddingBatches(
|
|||||||
if (!batchInfo.id) {
|
if (!batchInfo.id) {
|
||||||
throw new Error("openai batch create failed: missing batch id");
|
throw new Error("openai batch create failed: missing batch id");
|
||||||
}
|
}
|
||||||
|
const batchId = batchInfo.id;
|
||||||
|
|
||||||
params.debug?.("memory embeddings: openai batch created", {
|
params.debug?.("memory embeddings: openai batch created", {
|
||||||
batchId: batchInfo.id,
|
batchId: batchInfo.id,
|
||||||
@@ -213,30 +218,21 @@ export async function runOpenAiEmbeddingBatches(
|
|||||||
requests: group.length,
|
requests: group.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!params.wait && batchInfo.status !== "completed") {
|
const completed = await resolveCompletedBatchResult({
|
||||||
throw new Error(
|
provider: "openai",
|
||||||
`openai batch ${batchInfo.id} submitted; enable remote.batch.wait to await completion`,
|
status: batchInfo,
|
||||||
);
|
wait: params.wait,
|
||||||
}
|
waitForBatch: async () =>
|
||||||
|
await waitForOpenAiBatch({
|
||||||
const completed =
|
openAi: params.openAi,
|
||||||
batchInfo.status === "completed"
|
batchId,
|
||||||
? {
|
wait: params.wait,
|
||||||
outputFileId: batchInfo.output_file_id ?? "",
|
pollIntervalMs: params.pollIntervalMs,
|
||||||
errorFileId: batchInfo.error_file_id ?? undefined,
|
timeoutMs: params.timeoutMs,
|
||||||
}
|
debug: params.debug,
|
||||||
: await waitForOpenAiBatch({
|
initial: batchInfo,
|
||||||
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 content = await fetchOpenAiFileContent({
|
const content = await fetchOpenAiFileContent({
|
||||||
openAi: params.openAi,
|
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,
|
formatUnavailableBatchError,
|
||||||
normalizeBatchBaseUrl,
|
normalizeBatchBaseUrl,
|
||||||
postJsonWithRetry,
|
postJsonWithRetry,
|
||||||
|
resolveBatchCompletionFromStatus,
|
||||||
|
resolveCompletedBatchResult,
|
||||||
runEmbeddingBatchGroups,
|
runEmbeddingBatchGroups,
|
||||||
|
throwIfBatchTerminalFailure,
|
||||||
type EmbeddingBatchExecutionParams,
|
type EmbeddingBatchExecutionParams,
|
||||||
type EmbeddingBatchStatus,
|
type EmbeddingBatchStatus,
|
||||||
|
type BatchCompletionResult,
|
||||||
type ProviderBatchOutputLine,
|
type ProviderBatchOutputLine,
|
||||||
uploadBatchJsonlFile,
|
uploadBatchJsonlFile,
|
||||||
withRemoteHttpResponse,
|
withRemoteHttpResponse,
|
||||||
@@ -146,7 +150,7 @@ async function waitForVoyageBatch(params: {
|
|||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
debug?: (message: string, data?: Record<string, unknown>) => void;
|
debug?: (message: string, data?: Record<string, unknown>) => void;
|
||||||
initial?: VoyageBatchStatus;
|
initial?: VoyageBatchStatus;
|
||||||
}): Promise<{ outputFileId: string; errorFileId?: string }> {
|
}): Promise<BatchCompletionResult> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
let current: VoyageBatchStatus | undefined = params.initial;
|
let current: VoyageBatchStatus | undefined = params.initial;
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -158,21 +162,21 @@ async function waitForVoyageBatch(params: {
|
|||||||
}));
|
}));
|
||||||
const state = status.status ?? "unknown";
|
const state = status.status ?? "unknown";
|
||||||
if (state === "completed") {
|
if (state === "completed") {
|
||||||
if (!status.output_file_id) {
|
return resolveBatchCompletionFromStatus({
|
||||||
throw new Error(`voyage batch ${params.batchId} completed without output file`);
|
provider: "voyage",
|
||||||
}
|
batchId: params.batchId,
|
||||||
return {
|
status,
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
await throwIfBatchTerminalFailure({
|
||||||
|
provider: "voyage",
|
||||||
|
status: { ...status, id: params.batchId },
|
||||||
|
readError: async (errorFileId) =>
|
||||||
|
await readVoyageBatchError({
|
||||||
|
client: params.client,
|
||||||
|
errorFileId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
if (!params.wait) {
|
if (!params.wait) {
|
||||||
throw new Error(`voyage batch ${params.batchId} still ${state}; wait disabled`);
|
throw new Error(`voyage batch ${params.batchId} still ${state}; wait disabled`);
|
||||||
}
|
}
|
||||||
@@ -206,6 +210,7 @@ export async function runVoyageEmbeddingBatches(
|
|||||||
if (!batchInfo.id) {
|
if (!batchInfo.id) {
|
||||||
throw new Error("voyage batch create failed: missing batch id");
|
throw new Error("voyage batch create failed: missing batch id");
|
||||||
}
|
}
|
||||||
|
const batchId = batchInfo.id;
|
||||||
|
|
||||||
params.debug?.("memory embeddings: voyage batch created", {
|
params.debug?.("memory embeddings: voyage batch created", {
|
||||||
batchId: batchInfo.id,
|
batchId: batchInfo.id,
|
||||||
@@ -215,30 +220,21 @@ export async function runVoyageEmbeddingBatches(
|
|||||||
requests: group.length,
|
requests: group.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!params.wait && batchInfo.status !== "completed") {
|
const completed = await resolveCompletedBatchResult({
|
||||||
throw new Error(
|
provider: "voyage",
|
||||||
`voyage batch ${batchInfo.id} submitted; enable remote.batch.wait to await completion`,
|
status: batchInfo,
|
||||||
);
|
wait: params.wait,
|
||||||
}
|
waitForBatch: async () =>
|
||||||
|
await waitForVoyageBatch({
|
||||||
const completed =
|
client: params.client,
|
||||||
batchInfo.status === "completed"
|
batchId,
|
||||||
? {
|
wait: params.wait,
|
||||||
outputFileId: batchInfo.output_file_id ?? "",
|
pollIntervalMs: params.pollIntervalMs,
|
||||||
errorFileId: batchInfo.error_file_id ?? undefined,
|
timeoutMs: params.timeoutMs,
|
||||||
}
|
debug: params.debug,
|
||||||
: await waitForVoyageBatch({
|
initial: batchInfo,
|
||||||
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 baseUrl = normalizeBatchBaseUrl(params.client);
|
const baseUrl = normalizeBatchBaseUrl(params.client);
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|||||||
@@ -3,21 +3,25 @@ import type { OpenClawConfig } from "../config/config.js";
|
|||||||
import { withEnvAsync } from "../test-utils/env.js";
|
import { withEnvAsync } from "../test-utils/env.js";
|
||||||
import { resolveNodeHostGatewayCredentials } from "./runner.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", () => {
|
describe("resolveNodeHostGatewayCredentials", () => {
|
||||||
it("resolves remote token SecretRef values", async () => {
|
it("resolves remote token SecretRef values", async () => {
|
||||||
const config = {
|
const config = createRemoteGatewayTokenRefConfig("REMOTE_GATEWAY_TOKEN");
|
||||||
secrets: {
|
|
||||||
providers: {
|
|
||||||
default: { source: "env" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
gateway: {
|
|
||||||
mode: "remote",
|
|
||||||
remote: {
|
|
||||||
token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
|
|
||||||
await withEnvAsync(
|
await withEnvAsync(
|
||||||
{
|
{
|
||||||
@@ -32,19 +36,7 @@ describe("resolveNodeHostGatewayCredentials", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("prefers OPENCLAW_GATEWAY_TOKEN over configured refs", async () => {
|
it("prefers OPENCLAW_GATEWAY_TOKEN over configured refs", async () => {
|
||||||
const config = {
|
const config = createRemoteGatewayTokenRefConfig("REMOTE_GATEWAY_TOKEN");
|
||||||
secrets: {
|
|
||||||
providers: {
|
|
||||||
default: { source: "env" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
gateway: {
|
|
||||||
mode: "remote",
|
|
||||||
remote: {
|
|
||||||
token: { source: "env", provider: "default", id: "REMOTE_GATEWAY_TOKEN" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
|
|
||||||
await withEnvAsync(
|
await withEnvAsync(
|
||||||
{
|
{
|
||||||
@@ -59,19 +51,7 @@ describe("resolveNodeHostGatewayCredentials", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("throws when a configured remote token ref cannot resolve", async () => {
|
it("throws when a configured remote token ref cannot resolve", async () => {
|
||||||
const config = {
|
const config = createRemoteGatewayTokenRefConfig("MISSING_REMOTE_GATEWAY_TOKEN");
|
||||||
secrets: {
|
|
||||||
providers: {
|
|
||||||
default: { source: "env" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
gateway: {
|
|
||||||
mode: "remote",
|
|
||||||
remote: {
|
|
||||||
token: { source: "env", provider: "default", id: "MISSING_REMOTE_GATEWAY_TOKEN" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as OpenClawConfig;
|
|
||||||
|
|
||||||
await withEnvAsync(
|
await withEnvAsync(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { resolveBrowserConfig } from "../browser/config.js";
|
import { resolveBrowserConfig } from "../browser/config.js";
|
||||||
import { loadConfig, type OpenClawConfig } from "../config/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 { GatewayClient } from "../gateway/client.js";
|
||||||
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
||||||
import type { SkillBinTrustEntry } from "../infra/exec-approvals.js";
|
import type { SkillBinTrustEntry } from "../infra/exec-approvals.js";
|
||||||
@@ -12,8 +12,7 @@ import {
|
|||||||
NODE_SYSTEM_RUN_COMMANDS,
|
NODE_SYSTEM_RUN_COMMANDS,
|
||||||
} from "../infra/node-commands.js";
|
} from "../infra/node-commands.js";
|
||||||
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
|
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
|
||||||
import { secretRefKey } from "../secrets/ref-contract.js";
|
import { resolveSecretInputString } from "../secrets/resolve-secret-input-string.js";
|
||||||
import { resolveSecretRefValues } from "../secrets/resolve.js";
|
|
||||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js";
|
import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js";
|
||||||
@@ -117,27 +116,17 @@ async function resolveNodeHostSecretInputString(params: {
|
|||||||
path: string;
|
path: string;
|
||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
}): Promise<string | undefined> {
|
}): Promise<string | undefined> {
|
||||||
const defaults = params.config.secrets?.defaults;
|
const resolvedValue = await resolveSecretInputString({
|
||||||
const { ref } = resolveSecretInputRef({
|
config: params.config,
|
||||||
value: params.value,
|
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) {
|
if (!resolvedValue) {
|
||||||
throw new Error(`${params.path} resolved to an empty or non-string value.`);
|
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 { isAllowedParsedChatSender } from "./allow-from.js";
|
||||||
export { readBooleanParam } from "./boolean-param.js";
|
export { readBooleanParam } from "./boolean-param.js";
|
||||||
export { createScopedPairingAccess } from "./pairing-access.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 { extractToolSend } from "./tool-send.js";
|
||||||
export { normalizeWebhookPath } from "./webhook-path.js";
|
export { normalizeWebhookPath } from "./webhook-path.js";
|
||||||
export {
|
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 { createPersistentDedupe } from "./persistent-dedupe.js";
|
||||||
export {
|
export {
|
||||||
buildBaseChannelStatusSummary,
|
buildBaseChannelStatusSummary,
|
||||||
|
buildProbeChannelStatusSummary,
|
||||||
|
buildRuntimeAccountStatusSnapshot,
|
||||||
createDefaultChannelRuntimeState,
|
createDefaultChannelRuntimeState,
|
||||||
} from "./status-helpers.js";
|
} from "./status-helpers.js";
|
||||||
export { withTempDownloadPath } from "./temp-path.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 { IMessageConfigSchema } from "../config/zod-schema.providers-core.js";
|
||||||
|
|
||||||
export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.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 type { FileLockHandle, FileLockOptions } from "./file-lock.js";
|
||||||
export { acquireFileLock, withFileLock } 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 type { KeyedAsyncQueueHooks } from "./keyed-async-queue.js";
|
||||||
export { enqueueKeyedTask, KeyedAsyncQueue } from "./keyed-async-queue.js";
|
export { enqueueKeyedTask, KeyedAsyncQueue } from "./keyed-async-queue.js";
|
||||||
export { normalizeWebhookPath, resolveWebhookPath } from "./webhook-path.js";
|
export { normalizeWebhookPath, resolveWebhookPath } from "./webhook-path.js";
|
||||||
@@ -167,7 +177,9 @@ export { buildAgentMediaPayload } from "./agent-media-payload.js";
|
|||||||
export {
|
export {
|
||||||
buildBaseAccountStatusSnapshot,
|
buildBaseAccountStatusSnapshot,
|
||||||
buildBaseChannelStatusSummary,
|
buildBaseChannelStatusSummary,
|
||||||
|
buildComputedAccountStatusSnapshot,
|
||||||
buildProbeChannelStatusSummary,
|
buildProbeChannelStatusSummary,
|
||||||
|
buildRuntimeAccountStatusSnapshot,
|
||||||
buildTokenChannelStatusSummary,
|
buildTokenChannelStatusSummary,
|
||||||
collectStatusIssuesFromLastError,
|
collectStatusIssuesFromLastError,
|
||||||
createDefaultChannelRuntimeState,
|
createDefaultChannelRuntimeState,
|
||||||
@@ -178,6 +190,8 @@ export {
|
|||||||
} from "../channels/plugins/onboarding/helpers.js";
|
} from "../channels/plugins/onboarding/helpers.js";
|
||||||
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
|
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
|
||||||
export { formatResolvedUnresolvedNote } from "./resolution-notes.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 type { ChannelDock } from "../channels/dock.js";
|
||||||
export { getChatChannelMeta } from "../channels/registry.js";
|
export { getChatChannelMeta } from "../channels/registry.js";
|
||||||
export { resolveAllowlistMatchByCandidates } from "../channels/allowlist-match.js";
|
export { resolveAllowlistMatchByCandidates } from "../channels/allowlist-match.js";
|
||||||
@@ -278,6 +292,7 @@ export {
|
|||||||
resolveInboundRouteEnvelopeBuilder,
|
resolveInboundRouteEnvelopeBuilder,
|
||||||
resolveInboundRouteEnvelopeBuilderWithRuntime,
|
resolveInboundRouteEnvelopeBuilderWithRuntime,
|
||||||
} from "./inbound-envelope.js";
|
} from "./inbound-envelope.js";
|
||||||
|
export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js";
|
||||||
export {
|
export {
|
||||||
listConfiguredAccountIds,
|
listConfiguredAccountIds,
|
||||||
resolveAccountWithDefaultFallback,
|
resolveAccountWithDefaultFallback,
|
||||||
@@ -288,17 +303,29 @@ export { extractToolSend } from "./tool-send.js";
|
|||||||
export {
|
export {
|
||||||
createNormalizedOutboundDeliverer,
|
createNormalizedOutboundDeliverer,
|
||||||
formatTextWithAttachmentLinks,
|
formatTextWithAttachmentLinks,
|
||||||
|
isNumericTargetId,
|
||||||
normalizeOutboundReplyPayload,
|
normalizeOutboundReplyPayload,
|
||||||
resolveOutboundMediaUrls,
|
resolveOutboundMediaUrls,
|
||||||
|
sendPayloadWithChunkedTextAndMedia,
|
||||||
sendMediaWithLeadingCaption,
|
sendMediaWithLeadingCaption,
|
||||||
} from "./reply-payload.js";
|
} from "./reply-payload.js";
|
||||||
export type { OutboundReplyPayload } 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 type { OutboundMediaLoadOptions } from "./outbound-media.js";
|
||||||
export { loadOutboundMediaFromUrl } from "./outbound-media.js";
|
export { loadOutboundMediaFromUrl } from "./outbound-media.js";
|
||||||
export { resolveChannelAccountConfigBasePath } from "./config-paths.js";
|
export { resolveChannelAccountConfigBasePath } from "./config-paths.js";
|
||||||
export { buildMediaPayload } from "../channels/plugins/media-payload.js";
|
export { buildMediaPayload } from "../channels/plugins/media-payload.js";
|
||||||
export type { MediaPayload, MediaPayloadInput } 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 { chunkTextForOutbound } from "./text-chunking.js";
|
||||||
export { readBooleanParam } from "./boolean-param.js";
|
export { readBooleanParam } from "./boolean-param.js";
|
||||||
export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.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 { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
|
||||||
export {
|
export {
|
||||||
|
clearAccountEntryFields,
|
||||||
deleteAccountFromConfigSection,
|
deleteAccountFromConfigSection,
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
} from "../channels/plugins/config-helpers.js";
|
} from "../channels/plugins/config-helpers.js";
|
||||||
@@ -589,12 +617,18 @@ export {
|
|||||||
normalizeIMessageMessagingTarget,
|
normalizeIMessageMessagingTarget,
|
||||||
} from "../channels/plugins/normalize/imessage.js";
|
} from "../channels/plugins/normalize/imessage.js";
|
||||||
export {
|
export {
|
||||||
|
createAllowedChatSenderMatcher,
|
||||||
parseChatAllowTargetPrefixes,
|
parseChatAllowTargetPrefixes,
|
||||||
parseChatTargetPrefixesOrThrow,
|
parseChatTargetPrefixesOrThrow,
|
||||||
|
resolveServicePrefixedChatTarget,
|
||||||
resolveServicePrefixedAllowTarget,
|
resolveServicePrefixedAllowTarget,
|
||||||
|
resolveServicePrefixedOrChatAllowTarget,
|
||||||
resolveServicePrefixedTarget,
|
resolveServicePrefixedTarget,
|
||||||
} from "../imessage/target-parsing-helpers.js";
|
} 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
|
// Channel: Slack
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export {
|
|||||||
export { formatDocsLink } from "../terminal/links.js";
|
export { formatDocsLink } from "../terminal/links.js";
|
||||||
export type { WizardPrompter } from "../wizard/prompts.js";
|
export type { WizardPrompter } from "../wizard/prompts.js";
|
||||||
export { createScopedPairingAccess } from "./pairing-access.js";
|
export { createScopedPairingAccess } from "./pairing-access.js";
|
||||||
|
export { dispatchInboundReplyWithBase } from "./inbound-reply-dispatch.js";
|
||||||
export type { OutboundReplyPayload } from "./reply-payload.js";
|
export type { OutboundReplyPayload } from "./reply-payload.js";
|
||||||
export {
|
export {
|
||||||
createNormalizedOutboundDeliverer,
|
createNormalizedOutboundDeliverer,
|
||||||
|
|||||||
@@ -14,13 +14,17 @@ export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
|||||||
export { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
export { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
||||||
|
|
||||||
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
|
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
|
||||||
|
export { clearAccountEntryFields } from "../channels/plugins/config-helpers.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||||
resolveDefaultGroupPolicy,
|
resolveDefaultGroupPolicy,
|
||||||
} from "../config/runtime-group-policy.js";
|
} 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 { LineConfigSchema } from "../line/config-schema.js";
|
||||||
export type { LineChannelData, LineConfig, ResolvedLineAccount } from "../line/types.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 { createScopedPairingAccess } from "./pairing-access.js";
|
||||||
export { formatResolvedUnresolvedNote } from "./resolution-notes.js";
|
export { formatResolvedUnresolvedNote } from "./resolution-notes.js";
|
||||||
export { runPluginCommandWithTimeout } from "./run-command.js";
|
export { runPluginCommandWithTimeout } from "./run-command.js";
|
||||||
export { createLoggerBackedRuntime } from "./runtime.js";
|
export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js";
|
||||||
export { buildProbeChannelStatusSummary } from "./status-helpers.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.
|
// Keep this list additive and scoped to symbols used under extensions/minimax-portal-auth.
|
||||||
|
|
||||||
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
||||||
|
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
|
||||||
export type {
|
export type {
|
||||||
OpenClawPluginApi,
|
OpenClawPluginApi,
|
||||||
ProviderAuthContext,
|
ProviderAuthContext,
|
||||||
|
|||||||
@@ -94,9 +94,11 @@ export { loadWebMedia } from "../web/media.js";
|
|||||||
export type { WizardPrompter } from "../wizard/prompts.js";
|
export type { WizardPrompter } from "../wizard/prompts.js";
|
||||||
export { keepHttpServerTaskAlive } from "./channel-lifecycle.js";
|
export { keepHttpServerTaskAlive } from "./channel-lifecycle.js";
|
||||||
export { withFileLock } from "./file-lock.js";
|
export { withFileLock } from "./file-lock.js";
|
||||||
|
export { dispatchReplyFromConfigWithSettledDispatcher } from "./inbound-reply-dispatch.js";
|
||||||
export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js";
|
export { readJsonFileWithFallback, writeJsonFileAtomically } from "./json-store.js";
|
||||||
export { loadOutboundMediaFromUrl } from "./outbound-media.js";
|
export { loadOutboundMediaFromUrl } from "./outbound-media.js";
|
||||||
export { createScopedPairingAccess } from "./pairing-access.js";
|
export { createScopedPairingAccess } from "./pairing-access.js";
|
||||||
|
export { resolveInboundSessionEnvelopeContext } from "../channels/session-envelope.js";
|
||||||
export {
|
export {
|
||||||
buildHostnameAllowlistPolicyFromSuffixAllowlist,
|
buildHostnameAllowlistPolicyFromSuffixAllowlist,
|
||||||
isHttpsUrlAllowedByHostnameSuffixAllowlist,
|
isHttpsUrlAllowedByHostnameSuffixAllowlist,
|
||||||
@@ -104,5 +106,7 @@ export {
|
|||||||
} from "./ssrf-policy.js";
|
} from "./ssrf-policy.js";
|
||||||
export {
|
export {
|
||||||
buildBaseChannelStatusSummary,
|
buildBaseChannelStatusSummary,
|
||||||
|
buildProbeChannelStatusSummary,
|
||||||
|
buildRuntimeAccountStatusSnapshot,
|
||||||
createDefaultChannelRuntimeState,
|
createDefaultChannelRuntimeState,
|
||||||
} from "./status-helpers.js";
|
} from "./status-helpers.js";
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export {
|
|||||||
} from "../channels/plugins/channel-config.js";
|
} from "../channels/plugins/channel-config.js";
|
||||||
export {
|
export {
|
||||||
deleteAccountFromConfigSection,
|
deleteAccountFromConfigSection,
|
||||||
|
clearAccountEntryFields,
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
} from "../channels/plugins/config-helpers.js";
|
} from "../channels/plugins/config-helpers.js";
|
||||||
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
|
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
|
||||||
@@ -89,4 +90,9 @@ export {
|
|||||||
formatTextWithAttachmentLinks,
|
formatTextWithAttachmentLinks,
|
||||||
resolveOutboundMediaUrls,
|
resolveOutboundMediaUrls,
|
||||||
} from "./reply-payload.js";
|
} from "./reply-payload.js";
|
||||||
|
export { dispatchInboundReplyWithBase } from "./inbound-reply-dispatch.js";
|
||||||
export { createLoggerBackedRuntime } from "./runtime.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.
|
// Keep this list additive and scoped to symbols used under extensions/qwen-portal-auth.
|
||||||
|
|
||||||
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
export { emptyPluginConfigSchema } from "../plugins/config-schema.js";
|
||||||
|
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
|
||||||
export type { OpenClawPluginApi, ProviderAuthContext } from "../plugins/types.js";
|
export type { OpenClawPluginApi, ProviderAuthContext } from "../plugins/types.js";
|
||||||
export { generatePkceVerifierChallenge, toFormUrlEncoded } from "./oauth-utils.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 [];
|
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(
|
export function formatTextWithAttachmentLinks(
|
||||||
text: string | undefined,
|
text: string | undefined,
|
||||||
mediaUrls: string[],
|
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 {
|
import {
|
||||||
buildBaseAccountStatusSnapshot,
|
buildBaseAccountStatusSnapshot,
|
||||||
buildBaseChannelStatusSummary,
|
buildBaseChannelStatusSummary,
|
||||||
|
buildComputedAccountStatusSnapshot,
|
||||||
|
buildRuntimeAccountStatusSnapshot,
|
||||||
buildTokenChannelStatusSummary,
|
buildTokenChannelStatusSummary,
|
||||||
collectStatusIssuesFromLastError,
|
collectStatusIssuesFromLastError,
|
||||||
createDefaultChannelRuntimeState,
|
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", () => {
|
describe("buildTokenChannelStatusSummary", () => {
|
||||||
it("includes token/probe fields with mode by default", () => {
|
it("includes token/probe fields with mode by default", () => {
|
||||||
expect(buildTokenChannelStatusSummary({})).toEqual({
|
expect(buildTokenChannelStatusSummary({})).toEqual({
|
||||||
|
|||||||
@@ -81,13 +81,44 @@ export function buildBaseAccountStatusSnapshot(params: {
|
|||||||
name: account.name,
|
name: account.name,
|
||||||
enabled: account.enabled,
|
enabled: account.enabled,
|
||||||
configured: account.configured,
|
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,
|
running: runtime?.running ?? false,
|
||||||
lastStartAt: runtime?.lastStartAt ?? null,
|
lastStartAt: runtime?.lastStartAt ?? null,
|
||||||
lastStopAt: runtime?.lastStopAt ?? null,
|
lastStopAt: runtime?.lastStopAt ?? null,
|
||||||
lastError: runtime?.lastError ?? null,
|
lastError: runtime?.lastError ?? null,
|
||||||
probe,
|
probe,
|
||||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
|
||||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export {
|
|||||||
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
|
export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js";
|
||||||
export {
|
export {
|
||||||
deleteAccountFromConfigSection,
|
deleteAccountFromConfigSection,
|
||||||
|
clearAccountEntryFields,
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
} from "../channels/plugins/config-helpers.js";
|
} from "../channels/plugins/config-helpers.js";
|
||||||
export { formatPairingApproveHint } from "../channels/plugins/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