Merge dff6f2976285f8e745d7c36941c77e7a3d964d41 into 5bb5d7dab4b29e68b15bb7665d0736f46499a35c
This commit is contained in:
commit
1522863d5a
@ -101,15 +101,19 @@ vi.mock("../../runtime.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../accounts.js", () => ({
|
||||
resolveConfiguredMatrixBotUserIds: vi.fn(() => new Set<string>()),
|
||||
resolveMatrixAccount: () => ({
|
||||
accountId: "default",
|
||||
config: {
|
||||
dm: {},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../accounts.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../accounts.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveConfiguredMatrixBotUserIds: vi.fn(() => new Set<string>()),
|
||||
resolveMatrixAccount: () => ({
|
||||
accountId: "default",
|
||||
config: {
|
||||
dm: {},
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../active-client.js", () => ({
|
||||
setActiveMatrixClient: hoisted.setActiveMatrixClient,
|
||||
|
||||
@ -103,16 +103,32 @@ function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
const FILE_EXTENSIONS_PATTERN = Array.from(FILE_REF_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|");
|
||||
type FileReferencePatterns = {
|
||||
fileReferencePattern: RegExp;
|
||||
orphanedTldPattern: RegExp;
|
||||
};
|
||||
|
||||
let cachedFileReferencePatterns: FileReferencePatterns | null = null;
|
||||
|
||||
function getFileReferencePatterns(): FileReferencePatterns {
|
||||
if (cachedFileReferencePatterns) {
|
||||
return cachedFileReferencePatterns;
|
||||
}
|
||||
const fileExtensionsPattern = Array.from(FILE_REF_EXTENSIONS_WITH_TLD).map(escapeRegex).join("|");
|
||||
cachedFileReferencePatterns = {
|
||||
fileReferencePattern: new RegExp(
|
||||
`(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${fileExtensionsPattern}))(?=$|[^a-zA-Z0-9_\\-/])`,
|
||||
"gi",
|
||||
),
|
||||
orphanedTldPattern: new RegExp(
|
||||
`([^a-zA-Z0-9]|^)([A-Za-z]\\.(?:${fileExtensionsPattern}))(?=[^a-zA-Z0-9/]|$)`,
|
||||
"g",
|
||||
),
|
||||
};
|
||||
return cachedFileReferencePatterns;
|
||||
}
|
||||
|
||||
const AUTO_LINKED_ANCHOR_PATTERN = /<a\s+href="https?:\/\/([^"]+)"[^>]*>\1<\/a>/gi;
|
||||
const FILE_REFERENCE_PATTERN = new RegExp(
|
||||
`(^|[^a-zA-Z0-9_\\-/])([a-zA-Z0-9_.\\-./]+\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=$|[^a-zA-Z0-9_\\-/])`,
|
||||
"gi",
|
||||
);
|
||||
const ORPHANED_TLD_PATTERN = new RegExp(
|
||||
`([^a-zA-Z0-9]|^)([A-Za-z]\\.(?:${FILE_EXTENSIONS_PATTERN}))(?=[^a-zA-Z0-9/]|$)`,
|
||||
"g",
|
||||
);
|
||||
const HTML_TAG_PATTERN = /(<\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?>/gi;
|
||||
|
||||
function wrapStandaloneFileRef(match: string, prefix: string, filename: string): string {
|
||||
@ -134,8 +150,9 @@ function wrapSegmentFileRefs(
|
||||
if (!text || codeDepth > 0 || preDepth > 0 || anchorDepth > 0) {
|
||||
return text;
|
||||
}
|
||||
const wrappedStandalone = text.replace(FILE_REFERENCE_PATTERN, wrapStandaloneFileRef);
|
||||
return wrappedStandalone.replace(ORPHANED_TLD_PATTERN, (match, prefix: string, tld: string) =>
|
||||
const { fileReferencePattern, orphanedTldPattern } = getFileReferencePatterns();
|
||||
const wrappedStandalone = text.replace(fileReferencePattern, wrapStandaloneFileRef);
|
||||
return wrappedStandalone.replace(orphanedTldPattern, (match, prefix: string, tld: string) =>
|
||||
prefix === ">" ? match : `${prefix}<code>${escapeHtml(tld)}</code>`,
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ const defaultGatewayMock = async (
|
||||
method: string,
|
||||
_opts: unknown,
|
||||
params?: unknown,
|
||||
_timeoutMs?: number,
|
||||
_extra?: { expectFinal?: boolean; progress?: boolean; quiet?: boolean },
|
||||
) => {
|
||||
if (method === "cron.status") {
|
||||
return { enabled: true };
|
||||
@ -20,8 +20,12 @@ vi.mock("./gateway-rpc.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./gateway-rpc.js")>("./gateway-rpc.js");
|
||||
return {
|
||||
...actual,
|
||||
callGatewayFromCli: (method: string, opts: unknown, params?: unknown, extra?: unknown) =>
|
||||
callGatewayFromCli(method, opts, params, extra as number | undefined),
|
||||
callGatewayFromCli: (
|
||||
method: string,
|
||||
opts: unknown,
|
||||
params?: unknown,
|
||||
extra?: { expectFinal?: boolean; progress?: boolean; quiet?: boolean },
|
||||
) => callGatewayFromCli(method, opts, params, extra),
|
||||
};
|
||||
});
|
||||
|
||||
@ -266,6 +270,48 @@ describe("cron cli", () => {
|
||||
expect(params?.delivery?.mode).toBe("announce");
|
||||
});
|
||||
|
||||
it("skips cron.status helper in json mode", async () => {
|
||||
await runCronCommand([
|
||||
"cron",
|
||||
"add",
|
||||
"--name",
|
||||
"Json add",
|
||||
"--cron",
|
||||
"* * * * *",
|
||||
"--session",
|
||||
"isolated",
|
||||
"--message",
|
||||
"hello",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
const statusCalls = callGatewayFromCli.mock.calls.filter((call) => call[0] === "cron.status");
|
||||
expect(statusCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("runs cron.status helper quietly outside json mode", async () => {
|
||||
await runCronCommand([
|
||||
"cron",
|
||||
"add",
|
||||
"--name",
|
||||
"Quiet helper",
|
||||
"--cron",
|
||||
"* * * * *",
|
||||
"--session",
|
||||
"isolated",
|
||||
"--message",
|
||||
"hello",
|
||||
"--expect-final",
|
||||
]);
|
||||
|
||||
const statusCall = callGatewayFromCli.mock.calls.find((call) => call[0] === "cron.status");
|
||||
expect(statusCall?.[3]).toEqual({
|
||||
progress: false,
|
||||
quiet: true,
|
||||
expectFinal: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("infers sessionTarget from payload when --session is omitted", async () => {
|
||||
await runCronCommand([
|
||||
"cron",
|
||||
|
||||
@ -22,8 +22,20 @@ export function handleCronCliError(err: unknown) {
|
||||
}
|
||||
|
||||
export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) {
|
||||
if (opts?.json === true) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = (await callGatewayFromCli("cron.status", opts, {})) as {
|
||||
const res = (await callGatewayFromCli(
|
||||
"cron.status",
|
||||
opts,
|
||||
{},
|
||||
{
|
||||
progress: false,
|
||||
quiet: true,
|
||||
expectFinal: false,
|
||||
},
|
||||
)) as {
|
||||
enabled?: boolean;
|
||||
storePath?: string;
|
||||
};
|
||||
|
||||
75
src/cli/gateway-rpc.test.ts
Normal file
75
src/cli/gateway-rpc.test.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const callGateway = vi.fn();
|
||||
const withProgress = vi.fn(async (_opts: unknown, fn: () => Promise<unknown>) => await fn());
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway,
|
||||
}));
|
||||
|
||||
vi.mock("./progress.js", () => ({
|
||||
withProgress,
|
||||
}));
|
||||
|
||||
const { callGatewayFromCli } = await import("./gateway-rpc.js");
|
||||
|
||||
describe("callGatewayFromCli", () => {
|
||||
beforeEach(() => {
|
||||
callGateway.mockReset();
|
||||
withProgress.mockClear();
|
||||
});
|
||||
|
||||
it("uses probe mode for quiet calls", async () => {
|
||||
callGateway.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
await callGatewayFromCli("cron.status", { timeout: "30000" }, {}, { quiet: true });
|
||||
|
||||
expect(callGateway).toHaveBeenCalledTimes(1);
|
||||
expect(callGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "cron.status",
|
||||
mode: "probe",
|
||||
clientName: "cli",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("retries transient transport errors with probe mode after the first CLI attempt", async () => {
|
||||
callGateway
|
||||
.mockRejectedValueOnce(new Error("gateway closed (1000 normal closure): no close reason"))
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
await callGatewayFromCli("cron.add", { timeout: "30000" }, { name: "job" });
|
||||
|
||||
expect(callGateway).toHaveBeenCalledTimes(2);
|
||||
expect(callGateway.mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({ method: "cron.add", mode: "cli" }),
|
||||
);
|
||||
expect(callGateway.mock.calls[1]?.[0]).toEqual(
|
||||
expect.objectContaining({ method: "cron.add", mode: "probe" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not retry non-transport errors", async () => {
|
||||
callGateway.mockRejectedValueOnce(new Error("active gateway does not support required method"));
|
||||
|
||||
await expect(
|
||||
callGatewayFromCli("cron.add", { timeout: "30000" }, { name: "job" }),
|
||||
).rejects.toThrow("active gateway does not support required method");
|
||||
|
||||
expect(callGateway).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("stops after three transient failures", async () => {
|
||||
callGateway.mockRejectedValue(
|
||||
new Error("gateway closed (1006 abnormal closure (no close frame)): no close reason"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
callGatewayFromCli("cron.add", { timeout: "30000" }, { name: "job" }),
|
||||
).rejects.toThrow("gateway closed (1006 abnormal closure (no close frame)): no close reason");
|
||||
|
||||
expect(callGateway).toHaveBeenCalledTimes(3);
|
||||
expect(callGateway.mock.calls.map((call) => call[0]?.mode)).toEqual(["cli", "probe", "probe"]);
|
||||
});
|
||||
});
|
||||
@ -19,29 +19,51 @@ export function addGatewayClientOptions(cmd: Command) {
|
||||
.option("--expect-final", "Wait for final response (agent)", false);
|
||||
}
|
||||
|
||||
function isRetryableCliTransportError(err: unknown): boolean {
|
||||
const message = (err instanceof Error ? err.message : String(err)).toLowerCase();
|
||||
return (
|
||||
message.includes("gateway closed (1000") ||
|
||||
message.includes("gateway closed (1006") ||
|
||||
message.includes("gateway timeout") ||
|
||||
message.includes("connect challenge timeout")
|
||||
);
|
||||
}
|
||||
|
||||
export async function callGatewayFromCli(
|
||||
method: string,
|
||||
opts: GatewayRpcOpts,
|
||||
params?: unknown,
|
||||
extra?: { expectFinal?: boolean; progress?: boolean },
|
||||
extra?: { expectFinal?: boolean; progress?: boolean; quiet?: boolean },
|
||||
) {
|
||||
const showProgress = extra?.progress ?? opts.json !== true;
|
||||
const quiet = extra?.quiet === true;
|
||||
const baseMode = quiet ? GATEWAY_CLIENT_MODES.PROBE : GATEWAY_CLIENT_MODES.CLI;
|
||||
return await withProgress(
|
||||
{
|
||||
label: `Gateway ${method}`,
|
||||
indeterminate: true,
|
||||
enabled: showProgress,
|
||||
},
|
||||
async () =>
|
||||
await callGateway({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
method,
|
||||
params,
|
||||
expectFinal: extra?.expectFinal ?? Boolean(opts.expectFinal),
|
||||
timeoutMs: Number(opts.timeout ?? 10_000),
|
||||
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||
mode: GATEWAY_CLIENT_MODES.CLI,
|
||||
}),
|
||||
async () => {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
return await callGateway({
|
||||
url: opts.url,
|
||||
token: opts.token,
|
||||
method,
|
||||
params,
|
||||
expectFinal: extra?.expectFinal ?? Boolean(opts.expectFinal),
|
||||
timeoutMs: Number(opts.timeout ?? 10_000),
|
||||
clientName: GATEWAY_CLIENT_NAMES.CLI,
|
||||
mode: attempt === 0 ? baseMode : GATEWAY_CLIENT_MODES.PROBE,
|
||||
});
|
||||
} catch (err) {
|
||||
if (attempt === 2 || !isRetryableCliTransportError(err)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`gateway retries exhausted for ${method}`);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -49,6 +49,7 @@ describe("hooks install (e2e)", () => {
|
||||
{
|
||||
name: "@acme/hello-hooks",
|
||||
version: "0.0.0",
|
||||
type: "module",
|
||||
openclaw: { hooks: ["./hooks/hello-hook"] },
|
||||
},
|
||||
null,
|
||||
|
||||
@ -127,15 +127,29 @@ function loadHookFromDir(params: {
|
||||
// keep the discovered path when realpath is unavailable
|
||||
}
|
||||
|
||||
let hookFilePath = hookMdPath;
|
||||
try {
|
||||
hookFilePath = fs.realpathSync.native(hookMdPath);
|
||||
} catch {
|
||||
hookFilePath = hookMdPath;
|
||||
}
|
||||
|
||||
let resolvedHandlerPath = handlerPath;
|
||||
try {
|
||||
resolvedHandlerPath = fs.realpathSync.native(handlerPath);
|
||||
} catch {
|
||||
resolvedHandlerPath = handlerPath;
|
||||
}
|
||||
|
||||
return {
|
||||
hook: {
|
||||
name,
|
||||
description,
|
||||
source: params.source,
|
||||
pluginId: params.pluginId,
|
||||
filePath: hookMdPath,
|
||||
filePath: hookFilePath,
|
||||
baseDir,
|
||||
handlerPath,
|
||||
handlerPath: resolvedHandlerPath,
|
||||
},
|
||||
frontmatter,
|
||||
};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { lookup as dnsLookupCb, type LookupAddress } from "node:dns";
|
||||
import { lookup as dnsLookup } from "node:dns/promises";
|
||||
import { Agent, EnvHttpProxyAgent, ProxyAgent, type Dispatcher } from "undici";
|
||||
import * as undici from "undici";
|
||||
import type { Dispatcher } from "undici";
|
||||
import {
|
||||
extractEmbeddedIpv4FromIpv6,
|
||||
isBlockedSpecialUseIpv4Address,
|
||||
@ -403,13 +404,19 @@ export function createPinnedDispatcher(
|
||||
const lookup = resolvePinnedDispatcherLookup(pinned, policy?.pinnedHostname, ssrfPolicy);
|
||||
|
||||
if (!policy || policy.mode === "direct") {
|
||||
return new Agent({
|
||||
if (typeof undici.Agent !== "function") {
|
||||
return {
|
||||
close: async () => undefined,
|
||||
destroy: () => undefined,
|
||||
} as unknown as Dispatcher;
|
||||
}
|
||||
return new undici.Agent({
|
||||
connect: withPinnedLookup(lookup, policy?.connect),
|
||||
});
|
||||
}
|
||||
|
||||
if (policy.mode === "env-proxy") {
|
||||
return new EnvHttpProxyAgent({
|
||||
return new undici.EnvHttpProxyAgent({
|
||||
connect: withPinnedLookup(lookup, policy.connect),
|
||||
...(policy.proxyTls ? { proxyTls: { ...policy.proxyTls } } : {}),
|
||||
});
|
||||
@ -417,9 +424,9 @@ export function createPinnedDispatcher(
|
||||
|
||||
const proxyUrl = policy.proxyUrl.trim();
|
||||
if (!policy.proxyTls) {
|
||||
return new ProxyAgent(proxyUrl);
|
||||
return new undici.ProxyAgent(proxyUrl);
|
||||
}
|
||||
return new ProxyAgent({
|
||||
return new undici.ProxyAgent({
|
||||
uri: proxyUrl,
|
||||
proxyTls: { ...policy.proxyTls },
|
||||
});
|
||||
|
||||
@ -206,17 +206,17 @@ describe("runMessageAction context isolation", () => {
|
||||
cfg: slackConfig,
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
target: "channel:C12345678",
|
||||
message: "hi",
|
||||
},
|
||||
toolContext: { currentChannelId: "C12345678" },
|
||||
toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" },
|
||||
},
|
||||
{
|
||||
name: "accepts legacy to parameter for send",
|
||||
cfg: slackConfig,
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
to: "#C12345678",
|
||||
to: "channel:C12345678",
|
||||
message: "hi",
|
||||
},
|
||||
},
|
||||
@ -234,7 +234,7 @@ describe("runMessageAction context isolation", () => {
|
||||
cfg: slackConfig,
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
target: "channel:C12345678",
|
||||
media: "https://example.com/note.ogg",
|
||||
},
|
||||
toolContext: { currentChannelId: "C12345678" },
|
||||
@ -244,7 +244,7 @@ describe("runMessageAction context isolation", () => {
|
||||
cfg: slackConfig,
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
target: "channel:C12345678",
|
||||
message: "hi",
|
||||
pollMulti: false,
|
||||
pollAnonymous: false,
|
||||
@ -268,7 +268,7 @@ describe("runMessageAction context isolation", () => {
|
||||
cfg: slackConfig,
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
target: "channel:C12345678",
|
||||
},
|
||||
toolContext: { currentChannelId: "C12345678" },
|
||||
}),
|
||||
@ -306,7 +306,7 @@ describe("runMessageAction context isolation", () => {
|
||||
cfg: slackConfig,
|
||||
actionParams: {
|
||||
channel: "slack",
|
||||
target: "#C12345678",
|
||||
target: "channel:C12345678",
|
||||
blocks: [{ type: "divider" }],
|
||||
},
|
||||
toolContext: { currentChannelId: "C12345678" },
|
||||
|
||||
@ -477,7 +477,6 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
|
||||
message,
|
||||
preferComponents: true,
|
||||
});
|
||||
|
||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||
if (
|
||||
!hasReplyPayloadContent(
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { emitDiagnosticEvent } from "../infra/diagnostic-events.js";
|
||||
import {
|
||||
@ -10,9 +9,35 @@ import {
|
||||
type SessionRef,
|
||||
type SessionStateValue,
|
||||
} from "./diagnostic-session-state.js";
|
||||
import { createSubsystemLogger } from "./subsystem.js";
|
||||
import { createSubsystemLogger, type SubsystemLogger } from "./subsystem.js";
|
||||
|
||||
const diag = createSubsystemLogger("diagnostic");
|
||||
let diagnosticLoggerInstance: SubsystemLogger | null = null;
|
||||
let cachedLoadedDiagnosticConfig: OpenClawConfig | undefined;
|
||||
let diagnosticConfigRefreshPromise: Promise<void> | null = null;
|
||||
|
||||
function getDiagnosticLogger(): SubsystemLogger {
|
||||
diagnosticLoggerInstance ??= createSubsystemLogger("diagnostic");
|
||||
return diagnosticLoggerInstance;
|
||||
}
|
||||
|
||||
const diag = new Proxy({} as SubsystemLogger, {
|
||||
get(_target, prop, receiver) {
|
||||
return Reflect.get(getDiagnosticLogger() as object, prop, receiver);
|
||||
},
|
||||
});
|
||||
|
||||
function refreshDiagnosticConfigSnapshot(): void {
|
||||
diagnosticConfigRefreshPromise ??= import("../config/config.js")
|
||||
.then(({ loadConfig }) => {
|
||||
cachedLoadedDiagnosticConfig = loadConfig();
|
||||
})
|
||||
.catch(() => {
|
||||
cachedLoadedDiagnosticConfig = undefined;
|
||||
})
|
||||
.finally(() => {
|
||||
diagnosticConfigRefreshPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
const webhookStats = {
|
||||
received: 0,
|
||||
@ -335,13 +360,9 @@ export function startDiagnosticHeartbeat(config?: OpenClawConfig) {
|
||||
return;
|
||||
}
|
||||
heartbeatInterval = setInterval(() => {
|
||||
let heartbeatConfig = config;
|
||||
if (!heartbeatConfig) {
|
||||
try {
|
||||
heartbeatConfig = loadConfig();
|
||||
} catch {
|
||||
heartbeatConfig = undefined;
|
||||
}
|
||||
let heartbeatConfig = config ?? cachedLoadedDiagnosticConfig;
|
||||
if (!heartbeatConfig && !diagnosticConfigRefreshPromise) {
|
||||
refreshDiagnosticConfigSnapshot();
|
||||
}
|
||||
const stuckSessionWarnMs = resolveStuckSessionWarnMs(heartbeatConfig);
|
||||
const now = Date.now();
|
||||
@ -427,6 +448,9 @@ export function resetDiagnosticStateForTest(): void {
|
||||
webhookStats.errors = 0;
|
||||
webhookStats.lastReceived = 0;
|
||||
lastActivityAt = 0;
|
||||
cachedLoadedDiagnosticConfig = undefined;
|
||||
diagnosticConfigRefreshPromise = null;
|
||||
diagnosticLoggerInstance = null;
|
||||
stopDiagnosticHeartbeat();
|
||||
}
|
||||
|
||||
|
||||
@ -73,17 +73,16 @@ export type {
|
||||
TelegramTopicConfig,
|
||||
TtsConfig,
|
||||
} from "../config/types.js";
|
||||
export { resolveStorePath } from "../config/sessions/paths.js";
|
||||
export { resolveSessionKey } from "../config/sessions/session-key.js";
|
||||
export {
|
||||
loadSessionStore,
|
||||
readSessionUpdatedAt,
|
||||
recordSessionMetaFromInbound,
|
||||
resolveSessionKey,
|
||||
resolveStorePath,
|
||||
resolveSessionStoreEntry,
|
||||
updateLastRoute,
|
||||
updateSessionStore,
|
||||
type SessionResetMode,
|
||||
type SessionScope,
|
||||
} from "../config/sessions.js";
|
||||
} from "../config/sessions/store.js";
|
||||
export { resolveGroupSessionKey } from "../config/sessions/group.js";
|
||||
export {
|
||||
evaluateSessionFreshness,
|
||||
@ -91,6 +90,7 @@ export {
|
||||
resolveSessionResetPolicy,
|
||||
resolveSessionResetType,
|
||||
resolveThreadFlag,
|
||||
type SessionResetMode,
|
||||
} from "../config/sessions/reset.js";
|
||||
export { resolveSessionStoreEntry } from "../config/sessions/store.js";
|
||||
export type { SessionScope } from "../config/sessions/types.js";
|
||||
export { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js";
|
||||
|
||||
@ -44,6 +44,14 @@ const MANIFEST_PATH_BY_FORMAT: Record<PluginBundleFormat, string> = {
|
||||
};
|
||||
const CLAUDE_PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}";
|
||||
|
||||
function canonicalizeExistingDir(dir: string): string {
|
||||
try {
|
||||
return fs.realpathSync.native(dir);
|
||||
} catch {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
function readPluginJsonObject(params: {
|
||||
rootDir: string;
|
||||
relativePath: string;
|
||||
@ -122,11 +130,22 @@ function expandBundleRootPlaceholders(value: string, rootDir: string): string {
|
||||
}
|
||||
|
||||
function normalizeBundlePath(targetPath: string): string {
|
||||
return path.normalize(path.resolve(targetPath));
|
||||
return canonicalizeExistingDir(path.normalize(path.resolve(targetPath)));
|
||||
}
|
||||
|
||||
function normalizeExpandedAbsolutePath(value: string): string {
|
||||
return path.isAbsolute(value) ? path.normalize(value) : value;
|
||||
return path.isAbsolute(value) ? canonicalizeExistingDir(path.normalize(value)) : value;
|
||||
}
|
||||
|
||||
function resolveBundlePath(value: string, rootDir: string, baseDir: string): string {
|
||||
const expanded = expandBundleRootPlaceholders(value, rootDir);
|
||||
if (path.isAbsolute(expanded)) {
|
||||
return canonicalizeExistingDir(path.normalize(expanded));
|
||||
}
|
||||
if (isExplicitRelativePath(expanded)) {
|
||||
return canonicalizeExistingDir(path.resolve(baseDir, expanded));
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
function absolutizeBundleMcpServer(params: {
|
||||
@ -134,32 +153,30 @@ function absolutizeBundleMcpServer(params: {
|
||||
baseDir: string;
|
||||
server: BundleMcpServerConfig;
|
||||
}): BundleMcpServerConfig {
|
||||
const rootDir = canonicalizeExistingDir(params.rootDir);
|
||||
const baseDir = canonicalizeExistingDir(params.baseDir);
|
||||
const next: BundleMcpServerConfig = { ...params.server };
|
||||
|
||||
if (typeof next.cwd !== "string" && typeof next.workingDirectory !== "string") {
|
||||
next.cwd = params.baseDir;
|
||||
next.cwd = baseDir;
|
||||
}
|
||||
|
||||
const command = next.command;
|
||||
if (typeof command === "string") {
|
||||
const expanded = expandBundleRootPlaceholders(command, params.rootDir);
|
||||
next.command = isExplicitRelativePath(expanded)
|
||||
? path.resolve(params.baseDir, expanded)
|
||||
: normalizeExpandedAbsolutePath(expanded);
|
||||
next.command = resolveBundlePath(command, rootDir, baseDir);
|
||||
}
|
||||
|
||||
const cwd = next.cwd;
|
||||
if (typeof cwd === "string") {
|
||||
const expanded = expandBundleRootPlaceholders(cwd, params.rootDir);
|
||||
next.cwd = path.isAbsolute(expanded) ? expanded : path.resolve(params.baseDir, expanded);
|
||||
next.cwd = resolveBundlePath(cwd, rootDir, baseDir);
|
||||
}
|
||||
|
||||
const workingDirectory = next.workingDirectory;
|
||||
if (typeof workingDirectory === "string") {
|
||||
const expanded = expandBundleRootPlaceholders(workingDirectory, params.rootDir);
|
||||
const expanded = expandBundleRootPlaceholders(workingDirectory, rootDir);
|
||||
next.workingDirectory = path.isAbsolute(expanded)
|
||||
? path.normalize(expanded)
|
||||
: path.resolve(params.baseDir, expanded);
|
||||
? canonicalizeExistingDir(path.normalize(expanded))
|
||||
: canonicalizeExistingDir(path.resolve(baseDir, expanded));
|
||||
}
|
||||
|
||||
if (Array.isArray(next.args)) {
|
||||
@ -167,11 +184,11 @@ function absolutizeBundleMcpServer(params: {
|
||||
if (typeof entry !== "string") {
|
||||
return entry;
|
||||
}
|
||||
const expanded = expandBundleRootPlaceholders(entry, params.rootDir);
|
||||
const expanded = expandBundleRootPlaceholders(entry, rootDir);
|
||||
if (!isExplicitRelativePath(expanded)) {
|
||||
return normalizeExpandedAbsolutePath(expanded);
|
||||
}
|
||||
return path.resolve(params.baseDir, expanded);
|
||||
return canonicalizeExistingDir(path.resolve(baseDir, expanded));
|
||||
});
|
||||
}
|
||||
|
||||
@ -180,7 +197,7 @@ function absolutizeBundleMcpServer(params: {
|
||||
Object.entries(next.env).map(([key, value]) => [
|
||||
key,
|
||||
typeof value === "string"
|
||||
? normalizeExpandedAbsolutePath(expandBundleRootPlaceholders(value, params.rootDir))
|
||||
? normalizeExpandedAbsolutePath(expandBundleRootPlaceholders(value, rootDir))
|
||||
: value,
|
||||
]),
|
||||
);
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import { loadConfig, writeConfigFile } from "../../config/config.js";
|
||||
import * as configRuntime from "../../config/config.js";
|
||||
import type { PluginRuntime } from "./types.js";
|
||||
|
||||
export function createRuntimeConfig(): PluginRuntime["config"] {
|
||||
return {
|
||||
loadConfig,
|
||||
writeConfigFile,
|
||||
loadConfig: configRuntime.loadConfig,
|
||||
writeConfigFile:
|
||||
typeof configRuntime.writeConfigFile === "function"
|
||||
? configRuntime.writeConfigFile
|
||||
: async () => {
|
||||
throw new Error("writeConfigFile is unavailable in the current runtime");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -200,6 +200,7 @@ function buildConfigForOpenClawTarget(entry: SecretRegistryEntry, envId: string)
|
||||
}
|
||||
if (entry.id === "plugins.entries.tavily.config.webSearch.apiKey") {
|
||||
setPathCreateStrict(config, ["tools", "web", "search", "provider"], "tavily");
|
||||
setPathCreateStrict(config, ["plugins", "entries", "tavily", "enabled"], true);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ensureAuthProfileStore, type AuthProfileStore } from "../agents/auth-profiles.js";
|
||||
import {
|
||||
clearConfigCache,
|
||||
@ -10,6 +10,7 @@ import {
|
||||
writeConfigFile,
|
||||
} from "../config/config.js";
|
||||
import { withTempHome } from "../config/home-env.test-harness.js";
|
||||
import type { PluginWebSearchProviderEntry } from "../plugins/types.js";
|
||||
import {
|
||||
activateSecretsRuntimeSnapshot,
|
||||
clearSecretsRuntimeSnapshot,
|
||||
@ -18,6 +19,18 @@ import {
|
||||
prepareSecretsRuntimeSnapshot,
|
||||
} from "./runtime.js";
|
||||
|
||||
const { resolvePluginWebSearchProvidersMock } = vi.hoisted(() => ({
|
||||
resolvePluginWebSearchProvidersMock: vi.fn(() => [createGeminiTestProvider()]),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/web-search-providers.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../plugins/web-search-providers.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolvePluginWebSearchProviders: resolvePluginWebSearchProvidersMock,
|
||||
};
|
||||
});
|
||||
|
||||
const OPENAI_ENV_KEY_REF = { source: "env", provider: "default", id: "OPENAI_API_KEY" } as const;
|
||||
const allowInsecureTempSecretFile = process.platform === "win32";
|
||||
|
||||
@ -25,6 +38,44 @@ function asConfig(value: unknown): OpenClawConfig {
|
||||
return value as OpenClawConfig;
|
||||
}
|
||||
|
||||
function createGeminiTestProvider(): PluginWebSearchProviderEntry {
|
||||
return {
|
||||
pluginId: "google",
|
||||
id: "gemini",
|
||||
label: "gemini",
|
||||
hint: "gemini test provider",
|
||||
envVars: ["GEMINI_API_KEY"],
|
||||
placeholder: "gemini-...",
|
||||
signupUrl: "https://example.com/gemini",
|
||||
autoDetectOrder: 20,
|
||||
credentialPath: "plugins.entries.google.config.webSearch.apiKey",
|
||||
inactiveSecretPaths: ["plugins.entries.google.config.webSearch.apiKey"],
|
||||
getCredentialValue: (searchConfig) => {
|
||||
const providerConfig =
|
||||
searchConfig?.gemini && typeof searchConfig.gemini === "object"
|
||||
? (searchConfig.gemini as { apiKey?: unknown })
|
||||
: undefined;
|
||||
return providerConfig?.apiKey ?? searchConfig?.apiKey;
|
||||
},
|
||||
setCredentialValue: (searchConfigTarget, value) => {
|
||||
const providerConfig = (searchConfigTarget.gemini ??= {}) as { apiKey?: unknown };
|
||||
providerConfig.apiKey = value;
|
||||
},
|
||||
getConfiguredCredentialValue: (config) =>
|
||||
(config?.plugins?.entries?.google?.config as { webSearch?: { apiKey?: unknown } })?.webSearch
|
||||
?.apiKey,
|
||||
setConfiguredCredentialValue: (configTarget, value) => {
|
||||
const plugins = (configTarget.plugins ??= {}) as { entries?: Record<string, unknown> };
|
||||
const entries = (plugins.entries ??= {});
|
||||
const google = (entries.google ??= {}) as { config?: Record<string, unknown> };
|
||||
const pluginConfig = (google.config ??= {});
|
||||
const webSearch = (pluginConfig.webSearch ??= {}) as { apiKey?: unknown };
|
||||
webSearch.apiKey = value;
|
||||
},
|
||||
createTool: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
function loadAuthStoreWithProfiles(profiles: AuthProfileStore["profiles"]): AuthProfileStore {
|
||||
return {
|
||||
version: 1,
|
||||
|
||||
@ -31,14 +31,6 @@
|
||||
"resolvedPath": "extensions/imessage/runtime-api.js",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-matrix.ts",
|
||||
"line": 4,
|
||||
"kind": "import",
|
||||
"specifier": "../../../extensions/matrix/runtime-api.js",
|
||||
"resolvedPath": "extensions/matrix/runtime-api.js",
|
||||
"reason": "imports extension-owned file from src/plugins"
|
||||
},
|
||||
{
|
||||
"file": "src/plugins/runtime/runtime-slack-ops.runtime.ts",
|
||||
"line": 10,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user