mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
refactor: dedupe gateway config and infra flows
This commit is contained in:
@@ -64,6 +64,26 @@ async function ensureMinimaxApiKey(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureMinimaxApiKeyWithEnvRefPrompter(params: {
|
||||
config?: Parameters<typeof ensureApiKeyFromEnvOrPrompt>[0]["config"];
|
||||
note: WizardPrompter["note"];
|
||||
select: WizardPrompter["select"];
|
||||
setCredential: Parameters<typeof ensureApiKeyFromEnvOrPrompt>[0]["setCredential"];
|
||||
text: WizardPrompter["text"];
|
||||
}) {
|
||||
return await ensureApiKeyFromEnvOrPrompt({
|
||||
config: params.config ?? {},
|
||||
provider: "minimax",
|
||||
envLabel: "MINIMAX_API_KEY",
|
||||
promptMessage: "Enter key",
|
||||
normalize: (value) => value.trim(),
|
||||
validate: () => undefined,
|
||||
prompter: createPrompter({ select: params.select, text: params.text, note: params.note }),
|
||||
secretInputMode: "ref",
|
||||
setCredential: params.setCredential,
|
||||
});
|
||||
}
|
||||
|
||||
async function runEnsureMinimaxApiKeyFlow(params: { confirmResult: boolean; textResult: string }) {
|
||||
process.env.MINIMAX_API_KEY = "env-key";
|
||||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
||||
@@ -229,7 +249,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
|
||||
const note = vi.fn(async () => undefined);
|
||||
const setCredential = vi.fn(async () => undefined);
|
||||
|
||||
const result = await ensureApiKeyFromEnvOrPrompt({
|
||||
const result = await ensureMinimaxApiKeyWithEnvRefPrompter({
|
||||
config: {
|
||||
secrets: {
|
||||
providers: {
|
||||
@@ -241,13 +261,9 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "minimax",
|
||||
envLabel: "MINIMAX_API_KEY",
|
||||
promptMessage: "Enter key",
|
||||
normalize: (value) => value.trim(),
|
||||
validate: () => undefined,
|
||||
prompter: createPrompter({ select, text, note }),
|
||||
secretInputMode: "ref",
|
||||
select,
|
||||
text,
|
||||
note,
|
||||
setCredential,
|
||||
});
|
||||
|
||||
@@ -271,15 +287,11 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
|
||||
const note = vi.fn(async () => undefined);
|
||||
const setCredential = vi.fn(async () => undefined);
|
||||
|
||||
const result = await ensureApiKeyFromEnvOrPrompt({
|
||||
const result = await ensureMinimaxApiKeyWithEnvRefPrompter({
|
||||
config: {},
|
||||
provider: "minimax",
|
||||
envLabel: "MINIMAX_API_KEY",
|
||||
promptMessage: "Enter key",
|
||||
normalize: (value) => value.trim(),
|
||||
validate: () => undefined,
|
||||
prompter: createPrompter({ select, text, note }),
|
||||
secretInputMode: "ref",
|
||||
select,
|
||||
text,
|
||||
note,
|
||||
setCredential,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { shouldMoveSingleAccountChannelKey } from "../channels/plugins/setup-helpers.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
formatSlackStreamingBooleanMigrationMessage,
|
||||
formatSlackStreamModeMigrationMessage,
|
||||
resolveDiscordPreviewStreamMode,
|
||||
resolveSlackNativeStreaming,
|
||||
resolveSlackStreamingMode,
|
||||
@@ -175,13 +177,11 @@ export function normalizeCompatibilityConfigValues(cfg: OpenClawConfig): {
|
||||
const { streamMode: _ignored, ...rest } = updated;
|
||||
updated = rest;
|
||||
changed = true;
|
||||
changes.push(
|
||||
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolvedStreaming}).`,
|
||||
);
|
||||
changes.push(formatSlackStreamModeMigrationMessage(params.pathPrefix, resolvedStreaming));
|
||||
}
|
||||
if (typeof legacyStreaming === "boolean") {
|
||||
changes.push(
|
||||
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.nativeStreaming (${resolvedNativeStreaming}).`,
|
||||
formatSlackStreamingBooleanMigrationMessage(params.pathPrefix, resolvedNativeStreaming),
|
||||
);
|
||||
} else if (typeof legacyStreaming === "string" && legacyStreaming !== resolvedStreaming) {
|
||||
changes.push(
|
||||
|
||||
@@ -20,6 +20,12 @@ async function makeTempRoot() {
|
||||
return root;
|
||||
}
|
||||
|
||||
async function makeRootWithEmptyCfg() {
|
||||
const root = await makeTempRoot();
|
||||
const cfg: OpenClawConfig = {};
|
||||
return { root, cfg };
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
resetAutoMigrateLegacyStateForTest();
|
||||
resetAutoMigrateLegacyStateDirForTest();
|
||||
@@ -129,6 +135,26 @@ function expectTargetAlreadyExistsWarning(result: StateDirMigrationResult, targe
|
||||
]);
|
||||
}
|
||||
|
||||
function expectUnmigratedWithoutWarnings(result: StateDirMigrationResult) {
|
||||
expect(result.migrated).toBe(false);
|
||||
expect(result.warnings).toEqual([]);
|
||||
}
|
||||
|
||||
function writeLegacyAgentFiles(root: string, files: Record<string, string>) {
|
||||
const legacyAgentDir = path.join(root, "agent");
|
||||
fs.mkdirSync(legacyAgentDir, { recursive: true });
|
||||
for (const [fileName, content] of Object.entries(files)) {
|
||||
fs.writeFileSync(path.join(legacyAgentDir, fileName), content, "utf-8");
|
||||
}
|
||||
return legacyAgentDir;
|
||||
}
|
||||
|
||||
function ensureCredentialsDir(root: string) {
|
||||
const oauthDir = path.join(root, "credentials");
|
||||
fs.mkdirSync(oauthDir, { recursive: true });
|
||||
return oauthDir;
|
||||
}
|
||||
|
||||
describe("doctor legacy state migrations", () => {
|
||||
it("migrates legacy sessions into agents/<id>/sessions", async () => {
|
||||
const root = await makeTempRoot();
|
||||
@@ -177,23 +203,17 @@ describe("doctor legacy state migrations", () => {
|
||||
});
|
||||
|
||||
it("migrates legacy agent dir with conflict fallback", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const cfg: OpenClawConfig = {};
|
||||
|
||||
const legacyAgentDir = path.join(root, "agent");
|
||||
fs.mkdirSync(legacyAgentDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyAgentDir, "foo.txt"), "legacy", "utf-8");
|
||||
fs.writeFileSync(path.join(legacyAgentDir, "baz.txt"), "legacy2", "utf-8");
|
||||
const { root, cfg } = await makeRootWithEmptyCfg();
|
||||
writeLegacyAgentFiles(root, {
|
||||
"foo.txt": "legacy",
|
||||
"baz.txt": "legacy2",
|
||||
});
|
||||
|
||||
const targetAgentDir = path.join(root, "agents", "main", "agent");
|
||||
fs.mkdirSync(targetAgentDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(targetAgentDir, "foo.txt"), "new", "utf-8");
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg,
|
||||
env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||
});
|
||||
await runLegacyStateMigrations({ detected, now: () => 123 });
|
||||
await detectAndRunMigrations({ root, cfg, now: () => 123 });
|
||||
|
||||
expect(fs.readFileSync(path.join(targetAgentDir, "baz.txt"), "utf-8")).toBe("legacy2");
|
||||
const backupDir = path.join(root, "agents", "main", "agent.legacy-123");
|
||||
@@ -201,12 +221,8 @@ describe("doctor legacy state migrations", () => {
|
||||
});
|
||||
|
||||
it("auto-migrates legacy agent dir on startup", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const cfg: OpenClawConfig = {};
|
||||
|
||||
const legacyAgentDir = path.join(root, "agent");
|
||||
fs.mkdirSync(legacyAgentDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyAgentDir, "auth.json"), "{}", "utf-8");
|
||||
const { root, cfg } = await makeRootWithEmptyCfg();
|
||||
writeLegacyAgentFiles(root, { "auth.json": "{}" });
|
||||
|
||||
const { result, log } = await runAutoMigrateLegacyStateWithLog({ root, cfg });
|
||||
|
||||
@@ -217,8 +233,7 @@ describe("doctor legacy state migrations", () => {
|
||||
});
|
||||
|
||||
it("auto-migrates legacy sessions on startup", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { root, cfg } = await makeRootWithEmptyCfg();
|
||||
const legacySessionsDir = writeLegacySessionsFixture({
|
||||
root,
|
||||
sessions: {
|
||||
@@ -245,20 +260,13 @@ describe("doctor legacy state migrations", () => {
|
||||
});
|
||||
|
||||
it("migrates legacy WhatsApp auth files without touching oauth.json", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const cfg: OpenClawConfig = {};
|
||||
|
||||
const oauthDir = path.join(root, "credentials");
|
||||
fs.mkdirSync(oauthDir, { recursive: true });
|
||||
const { root, cfg } = await makeRootWithEmptyCfg();
|
||||
const oauthDir = ensureCredentialsDir(root);
|
||||
fs.writeFileSync(path.join(oauthDir, "oauth.json"), "{}", "utf-8");
|
||||
fs.writeFileSync(path.join(oauthDir, "creds.json"), "{}", "utf-8");
|
||||
fs.writeFileSync(path.join(oauthDir, "session-abc.json"), "{}", "utf-8");
|
||||
|
||||
const detected = await detectLegacyStateMigrations({
|
||||
cfg,
|
||||
env: { OPENCLAW_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||
});
|
||||
await runLegacyStateMigrations({ detected, now: () => 123 });
|
||||
await detectAndRunMigrations({ root, cfg, now: () => 123 });
|
||||
|
||||
const target = path.join(oauthDir, "whatsapp", "default");
|
||||
expect(fs.existsSync(path.join(target, "creds.json"))).toBe(true);
|
||||
@@ -268,11 +276,8 @@ describe("doctor legacy state migrations", () => {
|
||||
});
|
||||
|
||||
it("migrates legacy Telegram pairing allowFrom store to account-scoped default file", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const cfg: OpenClawConfig = {};
|
||||
|
||||
const oauthDir = path.join(root, "credentials");
|
||||
fs.mkdirSync(oauthDir, { recursive: true });
|
||||
const { root, cfg } = await makeRootWithEmptyCfg();
|
||||
const oauthDir = ensureCredentialsDir(root);
|
||||
fs.writeFileSync(
|
||||
path.join(oauthDir, "telegram-allowFrom.json"),
|
||||
JSON.stringify(
|
||||
@@ -359,8 +364,7 @@ describe("doctor legacy state migrations", () => {
|
||||
});
|
||||
|
||||
it("canonicalizes legacy main keys inside the target sessions store", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { root, cfg } = await makeRootWithEmptyCfg();
|
||||
const targetDir = path.join(root, "agents", "main", "sessions");
|
||||
writeJson5(path.join(targetDir, "sessions.json"), {
|
||||
main: { sessionId: "legacy", updatedAt: 10 },
|
||||
@@ -415,8 +419,7 @@ describe("doctor legacy state migrations", () => {
|
||||
});
|
||||
|
||||
it("auto-migrates when only target sessions contain legacy keys", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { root, cfg } = await makeRootWithEmptyCfg();
|
||||
const targetDir = path.join(root, "agents", "main", "sessions");
|
||||
writeJson5(path.join(targetDir, "sessions.json"), {
|
||||
main: { sessionId: "legacy", updatedAt: 10 },
|
||||
@@ -469,9 +472,7 @@ describe("doctor legacy state migrations", () => {
|
||||
fs.symlinkSync(path.join(targetDir, "agent"), path.join(legacyDir, "agent"), DIR_LINK_TYPE);
|
||||
|
||||
const result = await runStateDirMigration(root);
|
||||
|
||||
expect(result.migrated).toBe(false);
|
||||
expect(result.warnings).toEqual([]);
|
||||
expectUnmigratedWithoutWarnings(result);
|
||||
});
|
||||
|
||||
it("warns when legacy state dir is empty and target already exists", async () => {
|
||||
@@ -504,9 +505,7 @@ describe("doctor legacy state migrations", () => {
|
||||
);
|
||||
|
||||
const result = await runStateDirMigration(root);
|
||||
|
||||
expect(result.migrated).toBe(false);
|
||||
expect(result.warnings).toEqual([]);
|
||||
expectUnmigratedWithoutWarnings(result);
|
||||
});
|
||||
|
||||
it("warns when legacy state dir symlink points outside the target tree", async () => {
|
||||
|
||||
@@ -43,6 +43,18 @@ function createRuntime(): RuntimeEnv {
|
||||
};
|
||||
}
|
||||
|
||||
async function runCodexOAuth(params: { isRemote: boolean }) {
|
||||
const { prompter, spin } = createPrompter();
|
||||
const runtime = createRuntime();
|
||||
const result = await loginOpenAICodexOAuth({
|
||||
prompter,
|
||||
runtime,
|
||||
isRemote: params.isRemote,
|
||||
openUrl: async () => {},
|
||||
});
|
||||
return { result, prompter, spin, runtime };
|
||||
}
|
||||
|
||||
describe("loginOpenAICodexOAuth", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -64,14 +76,7 @@ describe("loginOpenAICodexOAuth", () => {
|
||||
});
|
||||
mocks.loginOpenAICodex.mockResolvedValue(creds);
|
||||
|
||||
const { prompter, spin } = createPrompter();
|
||||
const runtime = createRuntime();
|
||||
const result = await loginOpenAICodexOAuth({
|
||||
prompter,
|
||||
runtime,
|
||||
isRemote: false,
|
||||
openUrl: async () => {},
|
||||
});
|
||||
const { result, spin, runtime } = await runCodexOAuth({ isRemote: false });
|
||||
|
||||
expect(result).toEqual(creds);
|
||||
expect(mocks.loginOpenAICodex).toHaveBeenCalledOnce();
|
||||
@@ -124,14 +129,7 @@ describe("loginOpenAICodexOAuth", () => {
|
||||
});
|
||||
mocks.loginOpenAICodex.mockResolvedValue(creds);
|
||||
|
||||
const { prompter } = createPrompter();
|
||||
const runtime = createRuntime();
|
||||
const result = await loginOpenAICodexOAuth({
|
||||
prompter,
|
||||
runtime,
|
||||
isRemote: false,
|
||||
openUrl: async () => {},
|
||||
});
|
||||
const { result, prompter, runtime } = await runCodexOAuth({ isRemote: false });
|
||||
|
||||
expect(result).toEqual(creds);
|
||||
expect(mocks.loginOpenAICodex).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -142,3 +142,17 @@ export function resolveSlackNativeStreaming(
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function formatSlackStreamModeMigrationMessage(
|
||||
pathPrefix: string,
|
||||
resolvedStreaming: string,
|
||||
): string {
|
||||
return `Moved ${pathPrefix}.streamMode → ${pathPrefix}.streaming (${resolvedStreaming}).`;
|
||||
}
|
||||
|
||||
export function formatSlackStreamingBooleanMigrationMessage(
|
||||
pathPrefix: string,
|
||||
resolvedNativeStreaming: boolean,
|
||||
): string {
|
||||
return `Moved ${pathPrefix}.streaming (boolean) → ${pathPrefix}.nativeStreaming (${resolvedNativeStreaming}).`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {
|
||||
formatSlackStreamingBooleanMigrationMessage,
|
||||
formatSlackStreamModeMigrationMessage,
|
||||
resolveDiscordPreviewStreamMode,
|
||||
resolveSlackNativeStreaming,
|
||||
resolveSlackStreamingMode,
|
||||
@@ -357,13 +359,11 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
|
||||
params.entry.nativeStreaming = resolvedNativeStreaming;
|
||||
if (hasLegacyStreamMode) {
|
||||
delete params.entry.streamMode;
|
||||
changes.push(
|
||||
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolvedStreaming}).`,
|
||||
);
|
||||
changes.push(formatSlackStreamModeMigrationMessage(params.pathPrefix, resolvedStreaming));
|
||||
}
|
||||
if (typeof legacyStreaming === "boolean") {
|
||||
changes.push(
|
||||
`Moved ${params.pathPrefix}.streaming (boolean) → ${params.pathPrefix}.nativeStreaming (${resolvedNativeStreaming}).`,
|
||||
formatSlackStreamingBooleanMigrationMessage(params.pathPrefix, resolvedNativeStreaming),
|
||||
);
|
||||
} else if (typeof legacyNativeStreaming !== "boolean" && hasLegacyStreamMode) {
|
||||
changes.push(`Set ${params.pathPrefix}.nativeStreaming → ${resolvedNativeStreaming}.`);
|
||||
|
||||
@@ -20,15 +20,55 @@ function makeRegistry(plugins: Array<{ id: string; channels: string[] }>): Plugi
|
||||
};
|
||||
}
|
||||
|
||||
function makeApnChannelConfig() {
|
||||
return { channels: { apn: { someKey: "value" } } };
|
||||
}
|
||||
|
||||
function makeBluebubblesAndImessageChannels() {
|
||||
return {
|
||||
bluebubbles: { serverUrl: "http://localhost:1234", password: "x" },
|
||||
imessage: { cliPath: "/usr/local/bin/imsg" },
|
||||
};
|
||||
}
|
||||
|
||||
function applyWithSlackConfig(extra?: { plugins?: { allow?: string[] } }) {
|
||||
return applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { slack: { botToken: "x" } },
|
||||
...(extra?.plugins ? { plugins: extra.plugins } : {}),
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
}
|
||||
|
||||
function applyWithApnChannelConfig(extra?: {
|
||||
plugins?: { entries?: Record<string, { enabled: boolean }> };
|
||||
}) {
|
||||
return applyPluginAutoEnable({
|
||||
config: {
|
||||
...makeApnChannelConfig(),
|
||||
...(extra?.plugins ? { plugins: extra.plugins } : {}),
|
||||
},
|
||||
env: {},
|
||||
manifestRegistry: makeRegistry([{ id: "apn-channel", channels: ["apn"] }]),
|
||||
});
|
||||
}
|
||||
|
||||
function applyWithBluebubblesImessageConfig(extra?: {
|
||||
plugins?: { entries?: Record<string, { enabled: boolean }>; deny?: string[] };
|
||||
}) {
|
||||
return applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: makeBluebubblesAndImessageChannels(),
|
||||
...(extra?.plugins ? { plugins: extra.plugins } : {}),
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
}
|
||||
|
||||
describe("applyPluginAutoEnable", () => {
|
||||
it("auto-enables built-in channels and appends to existing allowlist", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { slack: { botToken: "x" } },
|
||||
plugins: { allow: ["telegram"] },
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
const result = applyWithSlackConfig({ plugins: { allow: ["telegram"] } });
|
||||
|
||||
expect(result.config.channels?.slack?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.slack).toBeUndefined();
|
||||
@@ -37,12 +77,7 @@ describe("applyPluginAutoEnable", () => {
|
||||
});
|
||||
|
||||
it("does not create plugins.allow when allowlist is unset", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { slack: { botToken: "x" } },
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
const result = applyWithSlackConfig();
|
||||
|
||||
expect(result.config.channels?.slack?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.allow).toBeUndefined();
|
||||
@@ -187,13 +222,7 @@ describe("applyPluginAutoEnable", () => {
|
||||
// Reproduces: https://github.com/openclaw/openclaw/issues/25261
|
||||
// Plugin "apn-channel" declares channels: ["apn"]. Doctor must write
|
||||
// plugins.entries["apn-channel"], not plugins.entries["apn"].
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { apn: { someKey: "value" } },
|
||||
},
|
||||
env: {},
|
||||
manifestRegistry: makeRegistry([{ id: "apn-channel", channels: ["apn"] }]),
|
||||
});
|
||||
const result = applyWithApnChannelConfig();
|
||||
|
||||
expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.["apn"]).toBeUndefined();
|
||||
@@ -201,26 +230,16 @@ describe("applyPluginAutoEnable", () => {
|
||||
});
|
||||
|
||||
it("does not double-enable when plugin is already enabled under its plugin id", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { apn: { someKey: "value" } },
|
||||
plugins: { entries: { "apn-channel": { enabled: true } } },
|
||||
},
|
||||
env: {},
|
||||
manifestRegistry: makeRegistry([{ id: "apn-channel", channels: ["apn"] }]),
|
||||
const result = applyWithApnChannelConfig({
|
||||
plugins: { entries: { "apn-channel": { enabled: true } } },
|
||||
});
|
||||
|
||||
expect(result.changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("respects explicit disable of the plugin by its plugin id", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: { apn: { someKey: "value" } },
|
||||
plugins: { entries: { "apn-channel": { enabled: false } } },
|
||||
},
|
||||
env: {},
|
||||
manifestRegistry: makeRegistry([{ id: "apn-channel", channels: ["apn"] }]),
|
||||
const result = applyWithApnChannelConfig({
|
||||
plugins: { entries: { "apn-channel": { enabled: false } } },
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.["apn-channel"]?.enabled).toBe(false);
|
||||
@@ -243,15 +262,7 @@ describe("applyPluginAutoEnable", () => {
|
||||
|
||||
describe("preferOver channel prioritization", () => {
|
||||
it("prefers bluebubbles: skips imessage auto-configure when both are configured", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: {
|
||||
bluebubbles: { serverUrl: "http://localhost:1234", password: "x" },
|
||||
imessage: { cliPath: "/usr/local/bin/imsg" },
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
});
|
||||
const result = applyWithBluebubblesImessageConfig();
|
||||
|
||||
expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(true);
|
||||
expect(result.config.plugins?.entries?.imessage?.enabled).toBeUndefined();
|
||||
@@ -262,15 +273,8 @@ describe("applyPluginAutoEnable", () => {
|
||||
});
|
||||
|
||||
it("keeps imessage enabled if already explicitly enabled (non-destructive)", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: {
|
||||
bluebubbles: { serverUrl: "http://localhost:1234", password: "x" },
|
||||
imessage: { cliPath: "/usr/local/bin/imsg" },
|
||||
},
|
||||
plugins: { entries: { imessage: { enabled: true } } },
|
||||
},
|
||||
env: {},
|
||||
const result = applyWithBluebubblesImessageConfig({
|
||||
plugins: { entries: { imessage: { enabled: true } } },
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(true);
|
||||
@@ -278,15 +282,8 @@ describe("applyPluginAutoEnable", () => {
|
||||
});
|
||||
|
||||
it("allows imessage auto-configure when bluebubbles is explicitly disabled", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: {
|
||||
bluebubbles: { serverUrl: "http://localhost:1234", password: "x" },
|
||||
imessage: { cliPath: "/usr/local/bin/imsg" },
|
||||
},
|
||||
plugins: { entries: { bluebubbles: { enabled: false } } },
|
||||
},
|
||||
env: {},
|
||||
const result = applyWithBluebubblesImessageConfig({
|
||||
plugins: { entries: { bluebubbles: { enabled: false } } },
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(false);
|
||||
@@ -295,15 +292,8 @@ describe("applyPluginAutoEnable", () => {
|
||||
});
|
||||
|
||||
it("allows imessage auto-configure when bluebubbles is in deny list", () => {
|
||||
const result = applyPluginAutoEnable({
|
||||
config: {
|
||||
channels: {
|
||||
bluebubbles: { serverUrl: "http://localhost:1234", password: "x" },
|
||||
imessage: { cliPath: "/usr/local/bin/imsg" },
|
||||
},
|
||||
plugins: { deny: ["bluebubbles"] },
|
||||
},
|
||||
env: {},
|
||||
const result = applyWithBluebubblesImessageConfig({
|
||||
plugins: { deny: ["bluebubbles"] },
|
||||
});
|
||||
|
||||
expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBeUndefined();
|
||||
|
||||
@@ -684,12 +684,7 @@ describe("sessions", () => {
|
||||
});
|
||||
|
||||
const createDeferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
const { promise, resolve, reject } = Promise.withResolvers<T>();
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
const firstStarted = createDeferred<void>();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { vi } from "vitest";
|
||||
import { expect, vi } from "vitest";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
||||
@@ -30,6 +30,20 @@ export function mockAgentPayloads(
|
||||
});
|
||||
}
|
||||
|
||||
export function expectDirectTelegramDelivery(
|
||||
deps: CliDeps,
|
||||
params: { chatId: string; text: string; messageThreadId?: number },
|
||||
) {
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
params.chatId,
|
||||
params.text,
|
||||
expect.objectContaining(
|
||||
params.messageThreadId === undefined ? {} : { messageThreadId: params.messageThreadId },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function runTelegramAnnounceTurn(params: {
|
||||
home: string;
|
||||
storePath: string;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
|
||||
import {
|
||||
createCliDeps,
|
||||
expectDirectTelegramDelivery,
|
||||
mockAgentPayloads,
|
||||
runTelegramAnnounceTurn,
|
||||
} from "./isolated-agent.delivery.test-helpers.js";
|
||||
@@ -30,14 +31,11 @@ describe("runCronIsolatedAgentTurn forum topic delivery", () => {
|
||||
expect(res.status).toBe("ok");
|
||||
expect(res.delivered).toBe(true);
|
||||
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"forum message",
|
||||
expect.objectContaining({
|
||||
messageThreadId: 42,
|
||||
}),
|
||||
);
|
||||
expectDirectTelegramDelivery(deps, {
|
||||
chatId: "123",
|
||||
text: "forum message",
|
||||
messageThreadId: 42,
|
||||
});
|
||||
|
||||
vi.clearAllMocks();
|
||||
mockAgentPayloads([{ text: "plain message" }]);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import {
|
||||
createCliDeps,
|
||||
expectDirectTelegramDelivery,
|
||||
mockAgentPayloads,
|
||||
runTelegramAnnounceTurn,
|
||||
} from "./isolated-agent.delivery.test-helpers.js";
|
||||
@@ -262,14 +263,11 @@ describe("runCronIsolatedAgentTurn", () => {
|
||||
expect(res.status).toBe("ok");
|
||||
expect(res.delivered).toBe(true);
|
||||
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledTimes(1);
|
||||
expect(deps.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"123",
|
||||
"Final weather summary",
|
||||
expect.objectContaining({
|
||||
messageThreadId: 42,
|
||||
}),
|
||||
);
|
||||
expectDirectTelegramDelivery(deps, {
|
||||
chatId: "123",
|
||||
text: "Final weather summary",
|
||||
messageThreadId: 42,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import "./isolated-agent.mocks.js";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||
import { withTempHome as withTempHomeHelper } from "../../test/helpers/temp-home.js";
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
@@ -11,7 +11,7 @@ import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
||||
import type { CronJob } from "./types.js";
|
||||
|
||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
return withTempHomeBase(fn, { prefix: "openclaw-cron-submodel-" });
|
||||
return withTempHomeHelper(fn, { prefix: "openclaw-cron-submodel-" });
|
||||
}
|
||||
|
||||
async function writeSessionStore(home: string) {
|
||||
|
||||
@@ -20,32 +20,74 @@ function expectNormalizedAtSchedule(scheduleInput: Record<string, unknown>) {
|
||||
expect(schedule.at).toBe(new Date(Date.parse("2026-01-12T18:00:00Z")).toISOString());
|
||||
}
|
||||
|
||||
function expectAnnounceDeliveryTarget(
|
||||
delivery: Record<string, unknown>,
|
||||
params: { channel: string; to: string },
|
||||
): void {
|
||||
expect(delivery.mode).toBe("announce");
|
||||
expect(delivery.channel).toBe(params.channel);
|
||||
expect(delivery.to).toBe(params.to);
|
||||
}
|
||||
|
||||
function expectPayloadDeliveryHintsCleared(payload: Record<string, unknown>): void {
|
||||
expect(payload.channel).toBeUndefined();
|
||||
expect(payload.deliver).toBeUndefined();
|
||||
}
|
||||
|
||||
function normalizeIsolatedAgentTurnCreateJob(params: {
|
||||
name: string;
|
||||
payload?: Record<string, unknown>;
|
||||
delivery?: Record<string, unknown>;
|
||||
}): Record<string, unknown> {
|
||||
return normalizeCronJobCreate({
|
||||
name: params.name,
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "* * * * *" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
...params.payload,
|
||||
},
|
||||
...(params.delivery ? { delivery: params.delivery } : {}),
|
||||
}) as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function normalizeMainSystemEventCreateJob(params: {
|
||||
name: string;
|
||||
schedule: Record<string, unknown>;
|
||||
}): Record<string, unknown> {
|
||||
return normalizeCronJobCreate({
|
||||
name: params.name,
|
||||
enabled: true,
|
||||
schedule: params.schedule,
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: {
|
||||
kind: "systemEvent",
|
||||
text: "tick",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("normalizeCronJobCreate", () => {
|
||||
it("maps legacy payload.provider to payload.channel and strips provider", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
const normalized = normalizeIsolatedAgentTurnCreateJob({
|
||||
name: "legacy",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "* * * * *" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
deliver: true,
|
||||
provider: " TeLeGrAm ",
|
||||
to: "7200373102",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
});
|
||||
|
||||
const payload = normalized.payload as Record<string, unknown>;
|
||||
expect(payload.channel).toBeUndefined();
|
||||
expect(payload.deliver).toBeUndefined();
|
||||
expectPayloadDeliveryHintsCleared(payload);
|
||||
expect("provider" in payload).toBe(false);
|
||||
|
||||
const delivery = normalized.delivery as Record<string, unknown>;
|
||||
expect(delivery.mode).toBe("announce");
|
||||
expect(delivery.channel).toBe("telegram");
|
||||
expect(delivery.to).toBe("7200373102");
|
||||
expectAnnounceDeliveryTarget(delivery, { channel: "telegram", to: "7200373102" });
|
||||
});
|
||||
|
||||
it("trims agentId and drops null", () => {
|
||||
@@ -105,29 +147,20 @@ describe("normalizeCronJobCreate", () => {
|
||||
});
|
||||
|
||||
it("canonicalizes payload.channel casing", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
const normalized = normalizeIsolatedAgentTurnCreateJob({
|
||||
name: "legacy provider",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "* * * * *" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
deliver: true,
|
||||
channel: "Telegram",
|
||||
to: "7200373102",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
});
|
||||
|
||||
const payload = normalized.payload as Record<string, unknown>;
|
||||
expect(payload.channel).toBeUndefined();
|
||||
expect(payload.deliver).toBeUndefined();
|
||||
expectPayloadDeliveryHintsCleared(payload);
|
||||
|
||||
const delivery = normalized.delivery as Record<string, unknown>;
|
||||
expect(delivery.mode).toBe("announce");
|
||||
expect(delivery.channel).toBe("telegram");
|
||||
expect(delivery.to).toBe("7200373102");
|
||||
expectAnnounceDeliveryTarget(delivery, { channel: "telegram", to: "7200373102" });
|
||||
});
|
||||
|
||||
it("coerces ISO schedule.at to normalized ISO (UTC)", () => {
|
||||
@@ -139,17 +172,10 @@ describe("normalizeCronJobCreate", () => {
|
||||
});
|
||||
|
||||
it("migrates legacy schedule.cron into schedule.expr", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
const normalized = normalizeMainSystemEventCreateJob({
|
||||
name: "legacy-cron-field",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", cron: "*/10 * * * *", tz: "UTC" },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: {
|
||||
kind: "systemEvent",
|
||||
text: "tick",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
});
|
||||
|
||||
const schedule = normalized.schedule as Record<string, unknown>;
|
||||
expect(schedule.kind).toBe("cron");
|
||||
@@ -158,34 +184,20 @@ describe("normalizeCronJobCreate", () => {
|
||||
});
|
||||
|
||||
it("defaults cron stagger for recurring top-of-hour schedules", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
const normalized = normalizeMainSystemEventCreateJob({
|
||||
name: "hourly",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "0 * * * *", tz: "UTC" },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: {
|
||||
kind: "systemEvent",
|
||||
text: "tick",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
});
|
||||
|
||||
const schedule = normalized.schedule as Record<string, unknown>;
|
||||
expect(schedule.staggerMs).toBe(DEFAULT_TOP_OF_HOUR_STAGGER_MS);
|
||||
});
|
||||
|
||||
it("preserves explicit exact cron schedule", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
const normalized = normalizeMainSystemEventCreateJob({
|
||||
name: "exact",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "0 * * * *", tz: "UTC", staggerMs: 0 },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: {
|
||||
kind: "systemEvent",
|
||||
text: "tick",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
});
|
||||
|
||||
const schedule = normalized.schedule as Record<string, unknown>;
|
||||
expect(schedule.staggerMs).toBe(0);
|
||||
@@ -208,69 +220,43 @@ describe("normalizeCronJobCreate", () => {
|
||||
});
|
||||
|
||||
it("normalizes delivery mode and channel", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
const normalized = normalizeIsolatedAgentTurnCreateJob({
|
||||
name: "delivery",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "* * * * *" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
},
|
||||
delivery: {
|
||||
mode: " ANNOUNCE ",
|
||||
channel: " TeLeGrAm ",
|
||||
to: " 7200373102 ",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
});
|
||||
|
||||
const delivery = normalized.delivery as Record<string, unknown>;
|
||||
expect(delivery.mode).toBe("announce");
|
||||
expect(delivery.channel).toBe("telegram");
|
||||
expect(delivery.to).toBe("7200373102");
|
||||
expectAnnounceDeliveryTarget(delivery, { channel: "telegram", to: "7200373102" });
|
||||
});
|
||||
|
||||
it("normalizes delivery accountId and strips blanks", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
const normalized = normalizeIsolatedAgentTurnCreateJob({
|
||||
name: "delivery account",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "* * * * *" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
},
|
||||
delivery: {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
to: "-1003816714067",
|
||||
accountId: " coordinator ",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
});
|
||||
|
||||
const delivery = normalized.delivery as Record<string, unknown>;
|
||||
expect(delivery.accountId).toBe("coordinator");
|
||||
});
|
||||
|
||||
it("strips empty accountId from delivery", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
const normalized = normalizeIsolatedAgentTurnCreateJob({
|
||||
name: "empty account",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "* * * * *" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "now",
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
},
|
||||
delivery: {
|
||||
mode: "announce",
|
||||
channel: "telegram",
|
||||
accountId: " ",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
});
|
||||
|
||||
const delivery = normalized.delivery as Record<string, unknown>;
|
||||
expect("accountId" in delivery).toBe(false);
|
||||
@@ -296,15 +282,9 @@ describe("normalizeCronJobCreate", () => {
|
||||
});
|
||||
|
||||
it("defaults isolated agentTurn delivery to announce", () => {
|
||||
const normalized = normalizeCronJobCreate({
|
||||
const normalized = normalizeIsolatedAgentTurnCreateJob({
|
||||
name: "default-announce",
|
||||
enabled: true,
|
||||
schedule: { kind: "cron", expr: "* * * * *" },
|
||||
payload: {
|
||||
kind: "agentTurn",
|
||||
message: "hi",
|
||||
},
|
||||
}) as unknown as Record<string, unknown>;
|
||||
});
|
||||
|
||||
const delivery = normalized.delivery as Record<string, unknown>;
|
||||
expect(delivery.mode).toBe("announce");
|
||||
@@ -326,9 +306,7 @@ describe("normalizeCronJobCreate", () => {
|
||||
}) as unknown as Record<string, unknown>;
|
||||
|
||||
const delivery = normalized.delivery as Record<string, unknown>;
|
||||
expect(delivery.mode).toBe("announce");
|
||||
expect(delivery.channel).toBe("telegram");
|
||||
expect(delivery.to).toBe("7200373102");
|
||||
expectAnnounceDeliveryTarget(delivery, { channel: "telegram", to: "7200373102" });
|
||||
expect(delivery.bestEffort).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,13 @@ import type { CronServiceState } from "./service/state.js";
|
||||
import { DEFAULT_TOP_OF_HOUR_STAGGER_MS } from "./stagger.js";
|
||||
import type { CronJob, CronJobPatch } from "./types.js";
|
||||
|
||||
function expectCronStaggerMs(job: CronJob, expected: number): void {
|
||||
expect(job.schedule.kind).toBe("cron");
|
||||
if (job.schedule.kind === "cron") {
|
||||
expect(job.schedule.staggerMs).toBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
describe("applyJobPatch", () => {
|
||||
const createIsolatedAgentTurnJob = (
|
||||
id: string,
|
||||
@@ -481,10 +488,7 @@ describe("cron stagger defaults", () => {
|
||||
payload: { kind: "systemEvent", text: "tick" },
|
||||
});
|
||||
|
||||
expect(job.schedule.kind).toBe("cron");
|
||||
if (job.schedule.kind === "cron") {
|
||||
expect(job.schedule.staggerMs).toBe(DEFAULT_TOP_OF_HOUR_STAGGER_MS);
|
||||
}
|
||||
expectCronStaggerMs(job, DEFAULT_TOP_OF_HOUR_STAGGER_MS);
|
||||
});
|
||||
|
||||
it("keeps exact schedules when staggerMs is explicitly 0", () => {
|
||||
@@ -500,10 +504,7 @@ describe("cron stagger defaults", () => {
|
||||
payload: { kind: "systemEvent", text: "tick" },
|
||||
});
|
||||
|
||||
expect(job.schedule.kind).toBe("cron");
|
||||
if (job.schedule.kind === "cron") {
|
||||
expect(job.schedule.staggerMs).toBe(0);
|
||||
}
|
||||
expectCronStaggerMs(job, 0);
|
||||
});
|
||||
|
||||
it("preserves existing stagger when editing cron expression without stagger", () => {
|
||||
|
||||
@@ -333,6 +333,20 @@ async function runIsolatedAnnounceJobAndWait(params: {
|
||||
return job;
|
||||
}
|
||||
|
||||
async function runIsolatedAnnounceScenario(params: {
|
||||
cron: CronService;
|
||||
events: ReturnType<typeof createCronEventHarness>;
|
||||
name: string;
|
||||
status?: "ok" | "error";
|
||||
}) {
|
||||
await runIsolatedAnnounceJobAndWait({
|
||||
cron: params.cron,
|
||||
events: params.events,
|
||||
name: params.name,
|
||||
status: params.status ?? "ok",
|
||||
});
|
||||
}
|
||||
|
||||
async function addWakeModeNowMainSystemEventJob(
|
||||
cron: CronService,
|
||||
options?: { name?: string; agentId?: string; sessionKey?: string },
|
||||
@@ -349,6 +363,82 @@ async function addWakeModeNowMainSystemEventJob(
|
||||
});
|
||||
}
|
||||
|
||||
async function addMainOneShotHelloJob(
|
||||
cron: CronService,
|
||||
params: { atMs: number; name: string; deleteAfterRun?: boolean },
|
||||
) {
|
||||
return cron.add({
|
||||
name: params.name,
|
||||
enabled: true,
|
||||
...(params.deleteAfterRun === undefined ? {} : { deleteAfterRun: params.deleteAfterRun }),
|
||||
schedule: { kind: "at", at: new Date(params.atMs).toISOString() },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
});
|
||||
}
|
||||
|
||||
function expectMainSystemEventPosted(enqueueSystemEvent: unknown, text: string) {
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
text,
|
||||
expect.objectContaining({ agentId: undefined }),
|
||||
);
|
||||
}
|
||||
|
||||
async function stopCronAndCleanup(cron: CronService, store: { cleanup: () => Promise<void> }) {
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
}
|
||||
|
||||
function createStartedCronService(
|
||||
storePath: string,
|
||||
runIsolatedAgentJob?: CronServiceDeps["runIsolatedAgentJob"],
|
||||
) {
|
||||
return new CronService({
|
||||
storePath,
|
||||
cronEnabled: true,
|
||||
log: noopLogger,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeatNow: vi.fn(),
|
||||
runIsolatedAgentJob: runIsolatedAgentJob ?? vi.fn(async () => ({ status: "ok" as const })),
|
||||
});
|
||||
}
|
||||
|
||||
async function createMainOneShotJobHarness(params: { name: string; deleteAfterRun?: boolean }) {
|
||||
const harness = await createMainOneShotHarness();
|
||||
const atMs = Date.parse("2025-12-13T00:00:02.000Z");
|
||||
const job = await addMainOneShotHelloJob(harness.cron, {
|
||||
atMs,
|
||||
name: params.name,
|
||||
deleteAfterRun: params.deleteAfterRun,
|
||||
});
|
||||
return { ...harness, atMs, job };
|
||||
}
|
||||
|
||||
async function loadLegacyDeliveryMigrationByPayload(params: {
|
||||
id: string;
|
||||
payload: { provider?: string; channel?: string };
|
||||
}) {
|
||||
const rawJob = createLegacyDeliveryMigrationJob(params);
|
||||
return loadLegacyDeliveryMigration(rawJob);
|
||||
}
|
||||
|
||||
async function expectNoMainSummaryForIsolatedRun(params: {
|
||||
runIsolatedAgentJob: CronServiceDeps["runIsolatedAgentJob"];
|
||||
name: string;
|
||||
}) {
|
||||
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } =
|
||||
await createIsolatedAnnounceHarness(params.runIsolatedAgentJob);
|
||||
await runIsolatedAnnounceScenario({
|
||||
cron,
|
||||
events,
|
||||
name: params.name,
|
||||
});
|
||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
}
|
||||
|
||||
function createLegacyDeliveryMigrationJob(options: {
|
||||
id: string;
|
||||
payload: { provider?: string; channel?: string };
|
||||
@@ -378,14 +468,7 @@ async function loadLegacyDeliveryMigration(rawJob: Record<string, unknown>) {
|
||||
const store = await makeStorePath();
|
||||
writeStoreFile(store.storePath, { version: 1, jobs: [rawJob] });
|
||||
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
cronEnabled: true,
|
||||
log: noopLogger,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeatNow: vi.fn(),
|
||||
runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })),
|
||||
});
|
||||
const cron = createStartedCronService(store.storePath);
|
||||
await cron.start();
|
||||
const jobs = await cron.list({ includeDisabled: true });
|
||||
const job = jobs.find((j) => j.id === rawJob.id);
|
||||
@@ -394,18 +477,11 @@ async function loadLegacyDeliveryMigration(rawJob: Record<string, unknown>) {
|
||||
|
||||
describe("CronService", () => {
|
||||
it("runs a one-shot main job and disables it after success when requested", async () => {
|
||||
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } =
|
||||
await createMainOneShotHarness();
|
||||
const atMs = Date.parse("2025-12-13T00:00:02.000Z");
|
||||
const job = await cron.add({
|
||||
name: "one-shot hello",
|
||||
enabled: true,
|
||||
deleteAfterRun: false,
|
||||
schedule: { kind: "at", at: new Date(atMs).toISOString() },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
});
|
||||
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events, atMs, job } =
|
||||
await createMainOneShotJobHarness({
|
||||
name: "one-shot hello",
|
||||
deleteAfterRun: false,
|
||||
});
|
||||
|
||||
expect(job.state.nextRunAtMs).toBe(atMs);
|
||||
|
||||
@@ -416,29 +492,18 @@ describe("CronService", () => {
|
||||
const jobs = await cron.list({ includeDisabled: true });
|
||||
const updated = jobs.find((j) => j.id === job.id);
|
||||
expect(updated?.enabled).toBe(false);
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
expect.objectContaining({ agentId: undefined }),
|
||||
);
|
||||
expectMainSystemEventPosted(enqueueSystemEvent, "hello");
|
||||
expect(requestHeartbeatNow).toHaveBeenCalled();
|
||||
|
||||
await cron.list({ includeDisabled: true });
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("runs a one-shot job and deletes it after success by default", async () => {
|
||||
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } =
|
||||
await createMainOneShotHarness();
|
||||
const atMs = Date.parse("2025-12-13T00:00:02.000Z");
|
||||
const job = await cron.add({
|
||||
name: "one-shot delete",
|
||||
enabled: true,
|
||||
schedule: { kind: "at", at: new Date(atMs).toISOString() },
|
||||
sessionTarget: "main",
|
||||
wakeMode: "now",
|
||||
payload: { kind: "systemEvent", text: "hello" },
|
||||
});
|
||||
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events, job } =
|
||||
await createMainOneShotJobHarness({
|
||||
name: "one-shot delete",
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date("2025-12-13T00:00:02.000Z"));
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
@@ -446,14 +511,10 @@ describe("CronService", () => {
|
||||
|
||||
const jobs = await cron.list({ includeDisabled: true });
|
||||
expect(jobs.find((j) => j.id === job.id)).toBeUndefined();
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
expect.objectContaining({ agentId: undefined }),
|
||||
);
|
||||
expectMainSystemEventPosted(enqueueSystemEvent, "hello");
|
||||
expect(requestHeartbeatNow).toHaveBeenCalled();
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("wakeMode now waits for heartbeat completion when available", async () => {
|
||||
@@ -491,10 +552,7 @@ describe("CronService", () => {
|
||||
|
||||
expect(runHeartbeatOnce).toHaveBeenCalledTimes(1);
|
||||
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
expect.objectContaining({ agentId: undefined }),
|
||||
);
|
||||
expectMainSystemEventPosted(enqueueSystemEvent, "hello");
|
||||
expect(job.state.runningAtMs).toBeTypeOf("number");
|
||||
|
||||
if (typeof resolveHeartbeat === "function") {
|
||||
@@ -505,8 +563,7 @@ describe("CronService", () => {
|
||||
expect(job.state.lastStatus).toBe("ok");
|
||||
expect(job.state.lastDurationMs).toBeGreaterThan(0);
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("rejects sessionTarget main for non-default agents at creation time", async () => {
|
||||
@@ -525,8 +582,7 @@ describe("CronService", () => {
|
||||
}),
|
||||
).rejects.toThrow('cron: sessionTarget "main" is only valid for the default agent');
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("wakeMode now falls back to queued heartbeat when main lane stays busy", async () => {
|
||||
@@ -567,23 +623,18 @@ describe("CronService", () => {
|
||||
expect(job.state.lastError).toBeUndefined();
|
||||
|
||||
await cron.list({ includeDisabled: true });
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("runs an isolated job and posts summary to main", async () => {
|
||||
const runIsolatedAgentJob = vi.fn(async () => ({ status: "ok" as const, summary: "done" }));
|
||||
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } =
|
||||
await createIsolatedAnnounceHarness(runIsolatedAgentJob);
|
||||
await runIsolatedAnnounceJobAndWait({ cron, events, name: "weekly", status: "ok" });
|
||||
await runIsolatedAnnounceScenario({ cron, events, name: "weekly" });
|
||||
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
"Cron: done",
|
||||
expect.objectContaining({ agentId: undefined }),
|
||||
);
|
||||
expectMainSystemEventPosted(enqueueSystemEvent, "Cron: done");
|
||||
expect(requestHeartbeatNow).toHaveBeenCalled();
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("does not post isolated summary to main when run already delivered output", async () => {
|
||||
@@ -592,19 +643,11 @@ describe("CronService", () => {
|
||||
summary: "done",
|
||||
delivered: true,
|
||||
}));
|
||||
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } =
|
||||
await createIsolatedAnnounceHarness(runIsolatedAgentJob);
|
||||
await runIsolatedAnnounceJobAndWait({
|
||||
cron,
|
||||
events,
|
||||
await expectNoMainSummaryForIsolatedRun({
|
||||
runIsolatedAgentJob,
|
||||
name: "weekly delivered",
|
||||
status: "ok",
|
||||
});
|
||||
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
|
||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("does not post isolated summary to main when announce delivery was attempted", async () => {
|
||||
@@ -614,27 +657,18 @@ describe("CronService", () => {
|
||||
delivered: false,
|
||||
deliveryAttempted: true,
|
||||
}));
|
||||
const { store, cron, enqueueSystemEvent, requestHeartbeatNow, events } =
|
||||
await createIsolatedAnnounceHarness(runIsolatedAgentJob);
|
||||
await runIsolatedAnnounceJobAndWait({
|
||||
cron,
|
||||
events,
|
||||
await expectNoMainSummaryForIsolatedRun({
|
||||
runIsolatedAgentJob,
|
||||
name: "weekly attempted",
|
||||
status: "ok",
|
||||
});
|
||||
expect(runIsolatedAgentJob).toHaveBeenCalledTimes(1);
|
||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
});
|
||||
|
||||
it("migrates legacy payload.provider to payload.channel on load", async () => {
|
||||
const rawJob = createLegacyDeliveryMigrationJob({
|
||||
const { store, cron, job } = await loadLegacyDeliveryMigrationByPayload({
|
||||
id: "legacy-1",
|
||||
payload: { provider: " TeLeGrAm " },
|
||||
});
|
||||
const { store, cron, job } = await loadLegacyDeliveryMigration(rawJob);
|
||||
// Legacy delivery fields are migrated to the top-level delivery object
|
||||
const delivery = job?.delivery as unknown as Record<string, unknown>;
|
||||
expect(delivery?.channel).toBe("telegram");
|
||||
@@ -642,22 +676,19 @@ describe("CronService", () => {
|
||||
expect("provider" in payload).toBe(false);
|
||||
expect("channel" in payload).toBe(false);
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("canonicalizes payload.channel casing on load", async () => {
|
||||
const rawJob = createLegacyDeliveryMigrationJob({
|
||||
const { store, cron, job } = await loadLegacyDeliveryMigrationByPayload({
|
||||
id: "legacy-2",
|
||||
payload: { channel: "Telegram" },
|
||||
});
|
||||
const { store, cron, job } = await loadLegacyDeliveryMigration(rawJob);
|
||||
// Legacy delivery fields are migrated to the top-level delivery object
|
||||
const delivery = job?.delivery as unknown as Record<string, unknown>;
|
||||
expect(delivery?.channel).toBe("telegram");
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("posts last output to main even when isolated job errors", async () => {
|
||||
@@ -675,13 +706,9 @@ describe("CronService", () => {
|
||||
status: "error",
|
||||
});
|
||||
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
"Cron (error): last output",
|
||||
expect.objectContaining({ agentId: undefined }),
|
||||
);
|
||||
expectMainSystemEventPosted(enqueueSystemEvent, "Cron (error): last output");
|
||||
expect(requestHeartbeatNow).toHaveBeenCalled();
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("does not post fallback main summary for isolated delivery-target errors", async () => {
|
||||
@@ -702,24 +729,19 @@ describe("CronService", () => {
|
||||
|
||||
expect(enqueueSystemEvent).not.toHaveBeenCalled();
|
||||
expect(requestHeartbeatNow).not.toHaveBeenCalled();
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("rejects unsupported session/payload combinations", async () => {
|
||||
ensureDir(fixturesRoot);
|
||||
const store = await makeStorePath();
|
||||
|
||||
const cron = new CronService({
|
||||
storePath: store.storePath,
|
||||
cronEnabled: true,
|
||||
log: noopLogger,
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
requestHeartbeatNow: vi.fn(),
|
||||
runIsolatedAgentJob: vi.fn(async (_params: { job: unknown; message: string }) => ({
|
||||
status: "ok",
|
||||
const cron = createStartedCronService(
|
||||
store.storePath,
|
||||
vi.fn(async (_params: { job: unknown; message: string }) => ({
|
||||
status: "ok" as const,
|
||||
})) as unknown as CronServiceDeps["runIsolatedAgentJob"],
|
||||
});
|
||||
);
|
||||
|
||||
await cron.start();
|
||||
|
||||
|
||||
@@ -32,44 +32,61 @@ async function listJobById(cron: CronService, jobId: string) {
|
||||
return jobs.find((entry) => entry.id === jobId);
|
||||
}
|
||||
|
||||
async function startCronWithStoredJobs(jobs: Array<Record<string, unknown>>) {
|
||||
const store = await makeStorePath();
|
||||
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
store.storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
jobs,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
const cron = await createStartedCron(store.storePath).start();
|
||||
return { store, cron };
|
||||
}
|
||||
|
||||
async function stopCronAndCleanup(cron: CronService, store: { cleanup: () => Promise<void> }) {
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
}
|
||||
|
||||
function createLegacyIsolatedAgentTurnJob(
|
||||
overrides: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
enabled: true,
|
||||
createdAtMs: Date.parse("2026-02-01T12:00:00.000Z"),
|
||||
updatedAtMs: Date.parse("2026-02-05T12:00:00.000Z"),
|
||||
schedule: { kind: "cron", expr: "0 23 * * *", tz: "UTC" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
payload: { kind: "agentTurn", message: "legacy payload fields" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CronService store migrations", () => {
|
||||
it("migrates legacy top-level agentTurn fields and initializes missing state", async () => {
|
||||
const store = await makeStorePath();
|
||||
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
store.storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
jobs: [
|
||||
{
|
||||
id: "legacy-agentturn-job",
|
||||
name: "legacy agentturn",
|
||||
enabled: true,
|
||||
createdAtMs: Date.parse("2026-02-01T12:00:00.000Z"),
|
||||
updatedAtMs: Date.parse("2026-02-05T12:00:00.000Z"),
|
||||
schedule: { kind: "cron", expr: "0 23 * * *", tz: "UTC" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
model: "openrouter/deepseek/deepseek-r1",
|
||||
thinking: "high",
|
||||
timeoutSeconds: 120,
|
||||
allowUnsafeExternalContent: true,
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "12345",
|
||||
bestEffortDeliver: true,
|
||||
payload: { kind: "agentTurn", message: "legacy payload fields" },
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const cron = await createStartedCron(store.storePath).start();
|
||||
const { store, cron } = await startCronWithStoredJobs([
|
||||
createLegacyIsolatedAgentTurnJob({
|
||||
id: "legacy-agentturn-job",
|
||||
name: "legacy agentturn",
|
||||
model: "openrouter/deepseek/deepseek-r1",
|
||||
thinking: "high",
|
||||
timeoutSeconds: 120,
|
||||
allowUnsafeExternalContent: true,
|
||||
deliver: true,
|
||||
channel: "telegram",
|
||||
to: "12345",
|
||||
bestEffortDeliver: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const status = await cron.status();
|
||||
expect(status.enabled).toBe(true);
|
||||
@@ -106,40 +123,17 @@ describe("CronService store migrations", () => {
|
||||
expect(persistedJob?.to).toBeUndefined();
|
||||
expect(persistedJob?.bestEffortDeliver).toBeUndefined();
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("preserves legacy timeoutSeconds=0 during top-level agentTurn field migration", async () => {
|
||||
const store = await makeStorePath();
|
||||
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
store.storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
jobs: [
|
||||
{
|
||||
id: "legacy-agentturn-no-timeout",
|
||||
name: "legacy no-timeout",
|
||||
enabled: true,
|
||||
createdAtMs: Date.parse("2026-02-01T12:00:00.000Z"),
|
||||
updatedAtMs: Date.parse("2026-02-05T12:00:00.000Z"),
|
||||
schedule: { kind: "cron", expr: "0 23 * * *", tz: "UTC" },
|
||||
sessionTarget: "isolated",
|
||||
wakeMode: "next-heartbeat",
|
||||
timeoutSeconds: 0,
|
||||
payload: { kind: "agentTurn", message: "legacy payload fields" },
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const cron = await createStartedCron(store.storePath).start();
|
||||
const { store, cron } = await startCronWithStoredJobs([
|
||||
createLegacyIsolatedAgentTurnJob({
|
||||
id: "legacy-agentturn-no-timeout",
|
||||
name: "legacy no-timeout",
|
||||
timeoutSeconds: 0,
|
||||
}),
|
||||
]);
|
||||
|
||||
const job = await listJobById(cron, "legacy-agentturn-no-timeout");
|
||||
expect(job).toBeDefined();
|
||||
@@ -148,38 +142,22 @@ describe("CronService store migrations", () => {
|
||||
expect(job.payload.timeoutSeconds).toBe(0);
|
||||
}
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
|
||||
it("migrates legacy cron fields (jobId + schedule.cron) and defaults wakeMode", async () => {
|
||||
const store = await makeStorePath();
|
||||
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
store.storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
jobs: [
|
||||
{
|
||||
jobId: "legacy-cron-field-job",
|
||||
name: "legacy cron field",
|
||||
enabled: true,
|
||||
createdAtMs: Date.parse("2026-02-01T12:00:00.000Z"),
|
||||
updatedAtMs: Date.parse("2026-02-05T12:00:00.000Z"),
|
||||
schedule: { kind: "cron", cron: "*/5 * * * *", tz: "UTC" },
|
||||
payload: { kind: "systemEvent", text: "tick" },
|
||||
state: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const cron = await createStartedCron(store.storePath).start();
|
||||
const { store, cron } = await startCronWithStoredJobs([
|
||||
{
|
||||
jobId: "legacy-cron-field-job",
|
||||
name: "legacy cron field",
|
||||
enabled: true,
|
||||
createdAtMs: Date.parse("2026-02-01T12:00:00.000Z"),
|
||||
updatedAtMs: Date.parse("2026-02-05T12:00:00.000Z"),
|
||||
schedule: { kind: "cron", cron: "*/5 * * * *", tz: "UTC" },
|
||||
payload: { kind: "systemEvent", text: "tick" },
|
||||
state: {},
|
||||
},
|
||||
]);
|
||||
const job = await listJobById(cron, "legacy-cron-field-job");
|
||||
expect(job).toBeDefined();
|
||||
expect(job?.wakeMode).toBe("now");
|
||||
@@ -202,7 +180,6 @@ describe("CronService store migrations", () => {
|
||||
expect(persistedSchedule?.cron).toBeUndefined();
|
||||
expect(persistedSchedule?.expr).toBe("*/5 * * * *");
|
||||
|
||||
cron.stop();
|
||||
await store.cleanup();
|
||||
await stopCronAndCleanup(cron, store);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,6 +80,56 @@ function managedStoppedAccount(lastError: string): Partial<ChannelAccountSnapsho
|
||||
};
|
||||
}
|
||||
|
||||
function runningConnectedSlackAccount(
|
||||
overrides: Partial<ChannelAccountSnapshot>,
|
||||
): Partial<ChannelAccountSnapshot> {
|
||||
return {
|
||||
running: true,
|
||||
connected: true,
|
||||
enabled: true,
|
||||
configured: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createSlackSnapshotManager(
|
||||
account: Partial<ChannelAccountSnapshot>,
|
||||
overrides?: Partial<ChannelManager>,
|
||||
): ChannelManager {
|
||||
return createSnapshotManager(
|
||||
{
|
||||
slack: {
|
||||
default: account,
|
||||
},
|
||||
},
|
||||
overrides,
|
||||
);
|
||||
}
|
||||
|
||||
async function expectRestartedChannel(
|
||||
manager: ChannelManager,
|
||||
channel: ChannelId,
|
||||
accountId = "default",
|
||||
) {
|
||||
const monitor = await startAndRunCheck(manager);
|
||||
expect(manager.stopChannel).toHaveBeenCalledWith(channel, accountId);
|
||||
expect(manager.startChannel).toHaveBeenCalledWith(channel, accountId);
|
||||
monitor.stop();
|
||||
}
|
||||
|
||||
async function expectNoRestart(manager: ChannelManager) {
|
||||
const monitor = await startAndRunCheck(manager);
|
||||
expect(manager.stopChannel).not.toHaveBeenCalled();
|
||||
expect(manager.startChannel).not.toHaveBeenCalled();
|
||||
monitor.stop();
|
||||
}
|
||||
|
||||
async function expectNoStart(manager: ChannelManager) {
|
||||
const monitor = await startAndRunCheck(manager);
|
||||
expect(manager.startChannel).not.toHaveBeenCalled();
|
||||
monitor.stop();
|
||||
}
|
||||
|
||||
describe("channel-health-monitor", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
@@ -126,9 +176,7 @@ describe("channel-health-monitor", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const monitor = await startAndRunCheck(manager);
|
||||
expect(manager.startChannel).not.toHaveBeenCalled();
|
||||
monitor.stop();
|
||||
await expectNoStart(manager);
|
||||
});
|
||||
|
||||
it("skips unconfigured channels", async () => {
|
||||
@@ -137,9 +185,7 @@ describe("channel-health-monitor", () => {
|
||||
default: { running: false, enabled: true, configured: false },
|
||||
},
|
||||
});
|
||||
const monitor = await startAndRunCheck(manager);
|
||||
expect(manager.startChannel).not.toHaveBeenCalled();
|
||||
monitor.stop();
|
||||
await expectNoStart(manager);
|
||||
});
|
||||
|
||||
it("skips manually stopped channels", async () => {
|
||||
@@ -151,9 +197,7 @@ describe("channel-health-monitor", () => {
|
||||
},
|
||||
{ isManuallyStopped: vi.fn(() => true) },
|
||||
);
|
||||
const monitor = await startAndRunCheck(manager);
|
||||
expect(manager.startChannel).not.toHaveBeenCalled();
|
||||
monitor.stop();
|
||||
await expectNoStart(manager);
|
||||
});
|
||||
|
||||
it("restarts a stuck channel (running but not connected)", async () => {
|
||||
@@ -312,98 +356,56 @@ describe("channel-health-monitor", () => {
|
||||
|
||||
it("restarts a channel with no events past the stale threshold", async () => {
|
||||
const now = Date.now();
|
||||
const manager = createSnapshotManager({
|
||||
slack: {
|
||||
default: {
|
||||
running: true,
|
||||
connected: true,
|
||||
enabled: true,
|
||||
configured: true,
|
||||
lastStartAt: now - STALE_THRESHOLD - 60_000,
|
||||
lastEventAt: now - STALE_THRESHOLD - 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
const monitor = await startAndRunCheck(manager);
|
||||
expect(manager.stopChannel).toHaveBeenCalledWith("slack", "default");
|
||||
expect(manager.startChannel).toHaveBeenCalledWith("slack", "default");
|
||||
monitor.stop();
|
||||
const manager = createSlackSnapshotManager(
|
||||
runningConnectedSlackAccount({
|
||||
lastStartAt: now - STALE_THRESHOLD - 60_000,
|
||||
lastEventAt: now - STALE_THRESHOLD - 30_000,
|
||||
}),
|
||||
);
|
||||
await expectRestartedChannel(manager, "slack");
|
||||
});
|
||||
|
||||
it("skips channels with recent events", async () => {
|
||||
const now = Date.now();
|
||||
const manager = createSnapshotManager({
|
||||
slack: {
|
||||
default: {
|
||||
running: true,
|
||||
connected: true,
|
||||
enabled: true,
|
||||
configured: true,
|
||||
lastStartAt: now - STALE_THRESHOLD - 60_000,
|
||||
lastEventAt: now - 5_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
const monitor = await startAndRunCheck(manager);
|
||||
expect(manager.stopChannel).not.toHaveBeenCalled();
|
||||
expect(manager.startChannel).not.toHaveBeenCalled();
|
||||
monitor.stop();
|
||||
const manager = createSlackSnapshotManager(
|
||||
runningConnectedSlackAccount({
|
||||
lastStartAt: now - STALE_THRESHOLD - 60_000,
|
||||
lastEventAt: now - 5_000,
|
||||
}),
|
||||
);
|
||||
await expectNoRestart(manager);
|
||||
});
|
||||
|
||||
it("skips channels still within the startup grace window for stale detection", async () => {
|
||||
const now = Date.now();
|
||||
const manager = createSnapshotManager({
|
||||
slack: {
|
||||
default: {
|
||||
running: true,
|
||||
connected: true,
|
||||
enabled: true,
|
||||
configured: true,
|
||||
lastStartAt: now - 5_000,
|
||||
lastEventAt: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
const monitor = await startAndRunCheck(manager);
|
||||
expect(manager.stopChannel).not.toHaveBeenCalled();
|
||||
expect(manager.startChannel).not.toHaveBeenCalled();
|
||||
monitor.stop();
|
||||
const manager = createSlackSnapshotManager(
|
||||
runningConnectedSlackAccount({
|
||||
lastStartAt: now - 5_000,
|
||||
lastEventAt: null,
|
||||
}),
|
||||
);
|
||||
await expectNoRestart(manager);
|
||||
});
|
||||
|
||||
it("restarts a channel that never received any event past the stale threshold", async () => {
|
||||
const now = Date.now();
|
||||
const manager = createSnapshotManager({
|
||||
slack: {
|
||||
default: {
|
||||
running: true,
|
||||
connected: true,
|
||||
enabled: true,
|
||||
configured: true,
|
||||
lastStartAt: now - STALE_THRESHOLD - 60_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
const monitor = await startAndRunCheck(manager);
|
||||
expect(manager.stopChannel).toHaveBeenCalledWith("slack", "default");
|
||||
expect(manager.startChannel).toHaveBeenCalledWith("slack", "default");
|
||||
monitor.stop();
|
||||
const manager = createSlackSnapshotManager(
|
||||
runningConnectedSlackAccount({
|
||||
lastStartAt: now - STALE_THRESHOLD - 60_000,
|
||||
}),
|
||||
);
|
||||
await expectRestartedChannel(manager, "slack");
|
||||
});
|
||||
|
||||
it("respects custom staleEventThresholdMs", async () => {
|
||||
const customThreshold = 10 * 60_000;
|
||||
const now = Date.now();
|
||||
const manager = createSnapshotManager({
|
||||
slack: {
|
||||
default: {
|
||||
running: true,
|
||||
connected: true,
|
||||
enabled: true,
|
||||
configured: true,
|
||||
lastStartAt: now - customThreshold - 60_000,
|
||||
lastEventAt: now - customThreshold - 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
const manager = createSlackSnapshotManager(
|
||||
runningConnectedSlackAccount({
|
||||
lastStartAt: now - customThreshold - 60_000,
|
||||
lastEventAt: now - customThreshold - 30_000,
|
||||
}),
|
||||
);
|
||||
const monitor = await startAndRunCheck(manager, {
|
||||
staleEventThresholdMs: customThreshold,
|
||||
});
|
||||
|
||||
@@ -136,6 +136,15 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
}
|
||||
| undefined;
|
||||
const getFirstAgentMessage = () => getFirstAgentCall()?.message ?? "";
|
||||
const postSyncUserMessage = async (message: string) => {
|
||||
const res = await postChatCompletions(port, {
|
||||
stream: false,
|
||||
model: "openclaw",
|
||||
messages: [{ role: "user", content: message }],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
};
|
||||
|
||||
try {
|
||||
{
|
||||
@@ -320,13 +329,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
|
||||
{
|
||||
mockAgentOnce([{ text: "hello" }]);
|
||||
const res = await postChatCompletions(port, {
|
||||
stream: false,
|
||||
model: "openclaw",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as Record<string, unknown>;
|
||||
const json = await postSyncUserMessage("hi");
|
||||
expect(json.object).toBe("chat.completion");
|
||||
expect(Array.isArray(json.choices)).toBe(true);
|
||||
const choice0 = (json.choices as Array<Record<string, unknown>>)[0] ?? {};
|
||||
@@ -338,13 +341,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||
{
|
||||
agentCommand.mockClear();
|
||||
agentCommand.mockResolvedValueOnce({ payloads: [{ text: "" }] } as never);
|
||||
const res = await postChatCompletions(port, {
|
||||
stream: false,
|
||||
model: "openclaw",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as Record<string, unknown>;
|
||||
const json = await postSyncUserMessage("hi");
|
||||
const choice0 = (json.choices as Array<Record<string, unknown>>)[0] ?? {};
|
||||
const msg = (choice0.message as Record<string, unknown> | undefined) ?? {};
|
||||
expect(msg.content).toBe("No response from OpenClaw.");
|
||||
|
||||
@@ -191,6 +191,19 @@ function extractUsageFromResult(result: unknown): Usage {
|
||||
);
|
||||
}
|
||||
|
||||
type PendingToolCall = { id: string; name: string; arguments: string };
|
||||
|
||||
function resolveStopReasonAndPendingToolCalls(meta: unknown): {
|
||||
stopReason: string | undefined;
|
||||
pendingToolCalls: PendingToolCall[] | undefined;
|
||||
} {
|
||||
if (!meta || typeof meta !== "object") {
|
||||
return { stopReason: undefined, pendingToolCalls: undefined };
|
||||
}
|
||||
const record = meta as { stopReason?: string; pendingToolCalls?: PendingToolCall[] };
|
||||
return { stopReason: record.stopReason, pendingToolCalls: record.pendingToolCalls };
|
||||
}
|
||||
|
||||
function createResponseResource(params: {
|
||||
id: string;
|
||||
model: string;
|
||||
@@ -467,13 +480,7 @@ export async function handleOpenResponsesHttpRequest(
|
||||
const payloads = (result as { payloads?: Array<{ text?: string }> } | null)?.payloads;
|
||||
const usage = extractUsageFromResult(result);
|
||||
const meta = (result as { meta?: unknown } | null)?.meta;
|
||||
const stopReason =
|
||||
meta && typeof meta === "object" ? (meta as { stopReason?: string }).stopReason : undefined;
|
||||
const pendingToolCalls =
|
||||
meta && typeof meta === "object"
|
||||
? (meta as { pendingToolCalls?: Array<{ id: string; name: string; arguments: string }> })
|
||||
.pendingToolCalls
|
||||
: undefined;
|
||||
const { stopReason, pendingToolCalls } = resolveStopReasonAndPendingToolCalls(meta);
|
||||
|
||||
// If agent called a client tool, return function_call instead of text
|
||||
if (stopReason === "tool_calls" && pendingToolCalls && pendingToolCalls.length > 0) {
|
||||
@@ -709,18 +716,7 @@ export async function handleOpenResponsesHttpRequest(
|
||||
const resultAny = result as { payloads?: Array<{ text?: string }>; meta?: unknown };
|
||||
const payloads = resultAny.payloads;
|
||||
const meta = resultAny.meta;
|
||||
const stopReason =
|
||||
meta && typeof meta === "object"
|
||||
? (meta as { stopReason?: string }).stopReason
|
||||
: undefined;
|
||||
const pendingToolCalls =
|
||||
meta && typeof meta === "object"
|
||||
? (
|
||||
meta as {
|
||||
pendingToolCalls?: Array<{ id: string; name: string; arguments: string }>;
|
||||
}
|
||||
).pendingToolCalls
|
||||
: undefined;
|
||||
const { stopReason, pendingToolCalls } = resolveStopReasonAndPendingToolCalls(meta);
|
||||
|
||||
// If agent called a client tool, emit function_call instead of text
|
||||
if (stopReason === "tool_calls" && pendingToolCalls && pendingToolCalls.length > 0) {
|
||||
|
||||
@@ -91,6 +91,38 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
expect(health.ok).toBe(true);
|
||||
};
|
||||
|
||||
const seedApprovedOperatorReadPairing = async (params: {
|
||||
identityPrefix: string;
|
||||
clientId: string;
|
||||
clientMode: string;
|
||||
displayName: string;
|
||||
platform: string;
|
||||
}): Promise<{ identityPath: string; identity: { deviceId: string } }> => {
|
||||
const { mkdtemp } = await import("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } =
|
||||
await import("../infra/device-identity.js");
|
||||
const { approveDevicePairing, requestDevicePairing } =
|
||||
await import("../infra/device-pairing.js");
|
||||
const identityDir = await mkdtemp(join(tmpdir(), params.identityPrefix));
|
||||
const identityPath = join(identityDir, "device.json");
|
||||
const identity = loadOrCreateDeviceIdentity(identityPath);
|
||||
const devicePublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
|
||||
const seeded = await requestDevicePairing({
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: devicePublicKey,
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
clientId: params.clientId,
|
||||
clientMode: params.clientMode,
|
||||
displayName: params.displayName,
|
||||
platform: params.platform,
|
||||
});
|
||||
await approveDevicePairing(seeded.request.requestId);
|
||||
return { identityPath, identity: { deviceId: identity.deviceId } };
|
||||
};
|
||||
|
||||
for (const tc of trustedProxyControlUiCases) {
|
||||
test(tc.name, async () => {
|
||||
await configureTrustedProxyControlUiAuth();
|
||||
@@ -485,29 +517,15 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
});
|
||||
|
||||
test("auto-approves loopback scope upgrades for control ui clients", async () => {
|
||||
const { mkdtemp } = await import("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } =
|
||||
await import("../infra/device-identity.js");
|
||||
const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } =
|
||||
await import("../infra/device-pairing.js");
|
||||
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { server, ws, port, prevToken } = await startServerWithClient("secret");
|
||||
const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-token-scope-"));
|
||||
const identityPath = join(identityDir, "device.json");
|
||||
const identity = loadOrCreateDeviceIdentity(identityPath);
|
||||
const devicePublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
|
||||
const seeded = await requestDevicePairing({
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: devicePublicKey,
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
const { identity, identityPath } = await seedApprovedOperatorReadPairing({
|
||||
identityPrefix: "openclaw-device-token-scope-",
|
||||
clientId: CONTROL_UI_CLIENT.id,
|
||||
clientMode: CONTROL_UI_CLIENT.mode,
|
||||
displayName: "loopback-control-ui-upgrade",
|
||||
platform: CONTROL_UI_CLIENT.platform,
|
||||
});
|
||||
await approveDevicePairing(seeded.request.requestId);
|
||||
|
||||
ws.close();
|
||||
|
||||
@@ -740,30 +758,16 @@ export function registerControlUiAndPairingSuite(): void {
|
||||
});
|
||||
|
||||
test("auto-approves local scope upgrades even when paired metadata is legacy-shaped", async () => {
|
||||
const { mkdtemp } = await import("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { readJsonFile, resolvePairingPaths } = await import("../infra/pairing-files.js");
|
||||
const { writeJsonAtomic } = await import("../infra/json-files.js");
|
||||
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem } =
|
||||
await import("../infra/device-identity.js");
|
||||
const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } =
|
||||
await import("../infra/device-pairing.js");
|
||||
const identityDir = await mkdtemp(join(tmpdir(), "openclaw-device-legacy-"));
|
||||
const identityPath = join(identityDir, "device.json");
|
||||
const identity = loadOrCreateDeviceIdentity(identityPath);
|
||||
const devicePublicKey = publicKeyRawBase64UrlFromPem(identity.publicKeyPem);
|
||||
const seeded = await requestDevicePairing({
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: devicePublicKey,
|
||||
role: "operator",
|
||||
scopes: ["operator.read"],
|
||||
const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js");
|
||||
const { identity, identityPath } = await seedApprovedOperatorReadPairing({
|
||||
identityPrefix: "openclaw-device-legacy-",
|
||||
clientId: TEST_OPERATOR_CLIENT.id,
|
||||
clientMode: TEST_OPERATOR_CLIENT.mode,
|
||||
displayName: "legacy-upgrade-test",
|
||||
platform: "test",
|
||||
});
|
||||
await approveDevicePairing(seeded.request.requestId);
|
||||
|
||||
const { pairedPath } = resolvePairingPaths(undefined, "devices");
|
||||
const paired = (await readJsonFile<Record<string, Record<string, unknown>>>(pairedPath)) ?? {};
|
||||
|
||||
@@ -67,6 +67,14 @@ export function registerDefaultAuthTokenSuite(): void {
|
||||
await new Promise<void>((resolve) => ws.once("close", () => resolve()));
|
||||
}
|
||||
|
||||
async function expectStatusMissingScopeButHealthAvailable(ws: WebSocket): Promise<void> {
|
||||
const status = await rpcReq(ws, "status");
|
||||
expect(status.ok).toBe(false);
|
||||
expect(status.error?.message).toContain("missing scope");
|
||||
const health = await rpcReq(ws, "health");
|
||||
expect(health.ok).toBe(true);
|
||||
}
|
||||
|
||||
test("closes silent handshakes after timeout", async () => {
|
||||
vi.useRealTimers();
|
||||
const prevHandshakeTimeout = process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS;
|
||||
@@ -198,11 +206,7 @@ export function registerDefaultAuthTokenSuite(): void {
|
||||
try {
|
||||
const res = await connectReq(ws, { scopes: [] });
|
||||
expect(res.ok).toBe(true);
|
||||
const status = await rpcReq(ws, "status");
|
||||
expect(status.ok).toBe(false);
|
||||
expect(status.error?.message).toContain("missing scope");
|
||||
const health = await rpcReq(ws, "health");
|
||||
expect(health.ok).toBe(true);
|
||||
await expectStatusMissingScopeButHealthAvailable(ws);
|
||||
} finally {
|
||||
ws.close();
|
||||
}
|
||||
@@ -247,11 +251,7 @@ export function registerDefaultAuthTokenSuite(): void {
|
||||
expect(presenceScopes).toEqual([]);
|
||||
expect(presenceScopes).not.toContain("operator.admin");
|
||||
|
||||
const status = await rpcReq(ws, "status");
|
||||
expect(status.ok).toBe(false);
|
||||
expect(status.error?.message).toContain("missing scope");
|
||||
const health = await rpcReq(ws, "health");
|
||||
expect(health.ok).toBe(true);
|
||||
await expectStatusMissingScopeButHealthAvailable(ws);
|
||||
|
||||
ws.close();
|
||||
});
|
||||
|
||||
@@ -291,10 +291,22 @@ async function sendRawConnectReq(
|
||||
}>(ws, isConnectResMessage(params.id));
|
||||
}
|
||||
|
||||
async function startRateLimitedTokenServerWithPairedDeviceToken() {
|
||||
async function resolvePairedTokenForDeviceIdentityPath(deviceIdentityPath: string): Promise<{
|
||||
identity: { deviceId: string };
|
||||
deviceToken: string;
|
||||
}> {
|
||||
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
|
||||
const { getPairedDevice } = await import("../infra/device-pairing.js");
|
||||
|
||||
const identity = loadOrCreateDeviceIdentity(deviceIdentityPath);
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
const deviceToken = paired?.tokens?.operator?.token;
|
||||
expect(paired?.deviceId).toBe(identity.deviceId);
|
||||
expect(deviceToken).toBeDefined();
|
||||
return { identity: { deviceId: identity.deviceId }, deviceToken: String(deviceToken ?? "") };
|
||||
}
|
||||
|
||||
async function startRateLimitedTokenServerWithPairedDeviceToken() {
|
||||
testState.gatewayAuth = {
|
||||
mode: "token",
|
||||
token: "secret",
|
||||
@@ -309,12 +321,7 @@ async function startRateLimitedTokenServerWithPairedDeviceToken() {
|
||||
if (!initial.ok) {
|
||||
await approvePendingPairingIfNeeded();
|
||||
}
|
||||
|
||||
const identity = loadOrCreateDeviceIdentity(deviceIdentityPath);
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
const deviceToken = paired?.tokens?.operator?.token;
|
||||
expect(paired?.deviceId).toBe(identity.deviceId);
|
||||
expect(deviceToken).toBeDefined();
|
||||
const { deviceToken } = await resolvePairedTokenForDeviceIdentityPath(deviceIdentityPath);
|
||||
|
||||
ws.close();
|
||||
return { server, port, prevToken, deviceToken: String(deviceToken ?? ""), deviceIdentityPath };
|
||||
@@ -331,24 +338,17 @@ async function ensurePairedDeviceTokenForCurrentIdentity(ws: WebSocket): Promise
|
||||
deviceToken: string;
|
||||
deviceIdentityPath: string;
|
||||
}> {
|
||||
const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js");
|
||||
const { getPairedDevice } = await import("../infra/device-pairing.js");
|
||||
|
||||
const deviceIdentityPath = nextAuthIdentityPath("openclaw-auth-device");
|
||||
|
||||
const res = await connectReq(ws, { token: "secret", deviceIdentityPath });
|
||||
if (!res.ok) {
|
||||
await approvePendingPairingIfNeeded();
|
||||
}
|
||||
|
||||
const identity = loadOrCreateDeviceIdentity(deviceIdentityPath);
|
||||
const paired = await getPairedDevice(identity.deviceId);
|
||||
const deviceToken = paired?.tokens?.operator?.token;
|
||||
expect(paired?.deviceId).toBe(identity.deviceId);
|
||||
expect(deviceToken).toBeDefined();
|
||||
const { identity, deviceToken } =
|
||||
await resolvePairedTokenForDeviceIdentityPath(deviceIdentityPath);
|
||||
return {
|
||||
identity: { deviceId: identity.deviceId },
|
||||
deviceToken: String(deviceToken ?? ""),
|
||||
identity,
|
||||
deviceToken,
|
||||
deviceIdentityPath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,12 +42,24 @@ async function postHook(
|
||||
});
|
||||
}
|
||||
|
||||
function setMainAndHooksAgents(): void {
|
||||
testState.agentsConfig = {
|
||||
list: [{ id: "main", default: true }, { id: "hooks" }],
|
||||
};
|
||||
}
|
||||
|
||||
function mockIsolatedRunOkOnce(): void {
|
||||
cronIsolatedRun.mockClear();
|
||||
cronIsolatedRun.mockResolvedValueOnce({
|
||||
status: "ok",
|
||||
summary: "done",
|
||||
});
|
||||
}
|
||||
|
||||
describe("gateway server hooks", () => {
|
||||
test("handles auth, wake, and agent flows", async () => {
|
||||
testState.hooksConfig = { enabled: true, token: HOOK_TOKEN };
|
||||
testState.agentsConfig = {
|
||||
list: [{ id: "main", default: true }, { id: "hooks" }],
|
||||
};
|
||||
setMainAndHooksAgents();
|
||||
await withGatewayServer(async ({ port }) => {
|
||||
const resNoAuth = await postHook(port, "/hooks/wake", { text: "Ping" }, { token: null });
|
||||
expect(resNoAuth.status).toBe(401);
|
||||
@@ -58,22 +70,14 @@ describe("gateway server hooks", () => {
|
||||
expect(wakeEvents.some((e) => e.includes("Ping"))).toBe(true);
|
||||
drainSystemEvents(resolveMainKey());
|
||||
|
||||
cronIsolatedRun.mockClear();
|
||||
cronIsolatedRun.mockResolvedValueOnce({
|
||||
status: "ok",
|
||||
summary: "done",
|
||||
});
|
||||
mockIsolatedRunOkOnce();
|
||||
const resAgent = await postHook(port, "/hooks/agent", { message: "Do it", name: "Email" });
|
||||
expect(resAgent.status).toBe(202);
|
||||
const agentEvents = await waitForSystemEvent();
|
||||
expect(agentEvents.some((e) => e.includes("Hook Email: done"))).toBe(true);
|
||||
drainSystemEvents(resolveMainKey());
|
||||
|
||||
cronIsolatedRun.mockClear();
|
||||
cronIsolatedRun.mockResolvedValueOnce({
|
||||
status: "ok",
|
||||
summary: "done",
|
||||
});
|
||||
mockIsolatedRunOkOnce();
|
||||
const resAgentModel = await postHook(port, "/hooks/agent", {
|
||||
message: "Do it",
|
||||
name: "Email",
|
||||
@@ -87,11 +91,7 @@ describe("gateway server hooks", () => {
|
||||
expect(call?.job?.payload?.model).toBe("openai/gpt-4.1-mini");
|
||||
drainSystemEvents(resolveMainKey());
|
||||
|
||||
cronIsolatedRun.mockClear();
|
||||
cronIsolatedRun.mockResolvedValueOnce({
|
||||
status: "ok",
|
||||
summary: "done",
|
||||
});
|
||||
mockIsolatedRunOkOnce();
|
||||
const resAgentWithId = await postHook(port, "/hooks/agent", {
|
||||
message: "Do it",
|
||||
name: "Email",
|
||||
@@ -105,11 +105,7 @@ describe("gateway server hooks", () => {
|
||||
expect(routedCall?.job?.agentId).toBe("hooks");
|
||||
drainSystemEvents(resolveMainKey());
|
||||
|
||||
cronIsolatedRun.mockClear();
|
||||
cronIsolatedRun.mockResolvedValueOnce({
|
||||
status: "ok",
|
||||
summary: "done",
|
||||
});
|
||||
mockIsolatedRunOkOnce();
|
||||
const resAgentUnknown = await postHook(port, "/hooks/agent", {
|
||||
message: "Do it",
|
||||
name: "Email",
|
||||
@@ -243,15 +239,9 @@ describe("gateway server hooks", () => {
|
||||
allowRequestSessionKey: true,
|
||||
allowedSessionKeyPrefixes: ["hook:", "agent:"],
|
||||
};
|
||||
testState.agentsConfig = {
|
||||
list: [{ id: "main", default: true }, { id: "hooks" }],
|
||||
};
|
||||
setMainAndHooksAgents();
|
||||
await withGatewayServer(async ({ port }) => {
|
||||
cronIsolatedRun.mockClear();
|
||||
cronIsolatedRun.mockResolvedValueOnce({
|
||||
status: "ok",
|
||||
summary: "done",
|
||||
});
|
||||
mockIsolatedRunOkOnce();
|
||||
|
||||
const resAgent = await postHook(port, "/hooks/agent", {
|
||||
message: "Do it",
|
||||
@@ -285,15 +275,9 @@ describe("gateway server hooks", () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
testState.agentsConfig = {
|
||||
list: [{ id: "main", default: true }, { id: "hooks" }],
|
||||
};
|
||||
setMainAndHooksAgents();
|
||||
await withGatewayServer(async ({ port }) => {
|
||||
cronIsolatedRun.mockClear();
|
||||
cronIsolatedRun.mockResolvedValueOnce({
|
||||
status: "ok",
|
||||
summary: "done",
|
||||
});
|
||||
mockIsolatedRunOkOnce();
|
||||
const resNoAgent = await postHook(port, "/hooks/agent", { message: "No explicit agent" });
|
||||
expect(resNoAgent.status).toBe(202);
|
||||
await waitForSystemEvent();
|
||||
@@ -303,11 +287,7 @@ describe("gateway server hooks", () => {
|
||||
expect(noAgentCall?.job?.agentId).toBeUndefined();
|
||||
drainSystemEvents(resolveMainKey());
|
||||
|
||||
cronIsolatedRun.mockClear();
|
||||
cronIsolatedRun.mockResolvedValueOnce({
|
||||
status: "ok",
|
||||
summary: "done",
|
||||
});
|
||||
mockIsolatedRunOkOnce();
|
||||
const resAllowed = await postHook(port, "/hooks/agent", {
|
||||
message: "Allowed",
|
||||
agentId: "hooks",
|
||||
|
||||
@@ -18,10 +18,51 @@ import {
|
||||
withGatewayTempConfig,
|
||||
} from "./server-http.test-harness.js";
|
||||
|
||||
type PluginRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
||||
|
||||
function canonicalizePluginPath(pathname: string): string {
|
||||
return canonicalizePathVariant(pathname);
|
||||
}
|
||||
|
||||
function respondJsonRoute(res: ServerResponse, route: string): true {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify({ ok: true, route }));
|
||||
return true;
|
||||
}
|
||||
|
||||
function createRootMountedControlUiOverrides(handlePluginRequest: PluginRequestHandler) {
|
||||
return {
|
||||
controlUiEnabled: true,
|
||||
controlUiBasePath: "",
|
||||
controlUiRoot: { kind: "missing" as const },
|
||||
handlePluginRequest,
|
||||
};
|
||||
}
|
||||
|
||||
const withRootMountedControlUiServer = (params: {
|
||||
prefix: string;
|
||||
handlePluginRequest: PluginRequestHandler;
|
||||
run: Parameters<typeof withGatewayServer>[0]["run"];
|
||||
}) =>
|
||||
withPluginGatewayServer({
|
||||
prefix: params.prefix,
|
||||
resolvedAuth: AUTH_NONE,
|
||||
overrides: createRootMountedControlUiOverrides(params.handlePluginRequest),
|
||||
run: params.run,
|
||||
});
|
||||
|
||||
const withPluginGatewayServer = (params: Parameters<typeof withGatewayServer>[0]) =>
|
||||
withGatewayServer(params);
|
||||
|
||||
function createProtectedPluginAuthOverrides(handlePluginRequest: PluginRequestHandler) {
|
||||
return {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: (pathContext: { pathname: string }) =>
|
||||
isProtectedPluginRoutePath(pathContext.pathname),
|
||||
};
|
||||
}
|
||||
|
||||
describe("gateway plugin HTTP auth boundary", () => {
|
||||
test("applies default security headers and optional strict transport security", async () => {
|
||||
await withGatewayTempConfig("openclaw-plugin-http-security-headers-test-", async () => {
|
||||
@@ -179,16 +220,10 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
if (pathname === "/plugin/routed") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify({ ok: true, route: "routed" }));
|
||||
return true;
|
||||
return respondJsonRoute(res, "routed");
|
||||
}
|
||||
if (pathname === "/googlechat") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify({ ok: true, route: "wildcard-handler" }));
|
||||
return true;
|
||||
return respondJsonRoute(res, "wildcard-handler");
|
||||
}
|
||||
return false;
|
||||
});
|
||||
@@ -224,16 +259,10 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
|
||||
if (canonicalizePluginPath(pathname) === "/api/channels/nostr/default/profile") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify({ ok: true, route: "channel-default" }));
|
||||
return true;
|
||||
return respondJsonRoute(res, "channel-default");
|
||||
}
|
||||
if (pathname === "/googlechat") {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify({ ok: true, route: "wildcard-default" }));
|
||||
return true;
|
||||
return respondJsonRoute(res, "wildcard-default");
|
||||
}
|
||||
return false;
|
||||
});
|
||||
@@ -293,15 +322,9 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
return false;
|
||||
});
|
||||
|
||||
await withGatewayServer({
|
||||
await withRootMountedControlUiServer({
|
||||
prefix: "openclaw-plugin-http-control-ui-precedence-test-",
|
||||
resolvedAuth: AUTH_NONE,
|
||||
overrides: {
|
||||
controlUiEnabled: true,
|
||||
controlUiBasePath: "",
|
||||
controlUiRoot: { kind: "missing" },
|
||||
handlePluginRequest,
|
||||
},
|
||||
handlePluginRequest,
|
||||
run: async (server) => {
|
||||
const response = await sendRequest(server, {
|
||||
path: "/plugins/diffs/view/demo-id/demo-token",
|
||||
@@ -326,15 +349,9 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
return true;
|
||||
});
|
||||
|
||||
await withGatewayServer({
|
||||
await withRootMountedControlUiServer({
|
||||
prefix: "openclaw-plugin-http-control-ui-webhook-post-test-",
|
||||
resolvedAuth: AUTH_NONE,
|
||||
overrides: {
|
||||
controlUiEnabled: true,
|
||||
controlUiBasePath: "",
|
||||
controlUiRoot: { kind: "missing" },
|
||||
handlePluginRequest,
|
||||
},
|
||||
handlePluginRequest,
|
||||
run: async (server) => {
|
||||
const response = await sendRequest(server, {
|
||||
path: "/bluebubbles-webhook",
|
||||
@@ -360,15 +377,9 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
return false;
|
||||
});
|
||||
|
||||
await withGatewayServer({
|
||||
await withRootMountedControlUiServer({
|
||||
prefix: "openclaw-plugin-http-control-ui-shadow-test-",
|
||||
resolvedAuth: AUTH_NONE,
|
||||
overrides: {
|
||||
controlUiEnabled: true,
|
||||
controlUiBasePath: "",
|
||||
controlUiRoot: { kind: "missing" },
|
||||
handlePluginRequest,
|
||||
},
|
||||
handlePluginRequest,
|
||||
run: async (server) => {
|
||||
const response = await sendRequest(server, { path: "/my-plugin/inbound" });
|
||||
|
||||
@@ -382,15 +393,9 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
test("unmatched plugin paths fall through to control ui", async () => {
|
||||
const handlePluginRequest = vi.fn(async () => false);
|
||||
|
||||
await withGatewayServer({
|
||||
await withRootMountedControlUiServer({
|
||||
prefix: "openclaw-plugin-http-control-ui-fallthrough-test-",
|
||||
resolvedAuth: AUTH_NONE,
|
||||
overrides: {
|
||||
controlUiEnabled: true,
|
||||
controlUiBasePath: "",
|
||||
controlUiRoot: { kind: "missing" },
|
||||
handlePluginRequest,
|
||||
},
|
||||
handlePluginRequest,
|
||||
run: async (server) => {
|
||||
const response = await sendRequest(server, { path: "/chat" });
|
||||
|
||||
@@ -404,14 +409,10 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
test("requires gateway auth for canonicalized /api/channels variants", async () => {
|
||||
const handlePluginRequest = createCanonicalizedChannelPluginHandler();
|
||||
|
||||
await withGatewayServer({
|
||||
await withPluginGatewayServer({
|
||||
prefix: "openclaw-plugin-http-auth-canonicalized-test-",
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: (pathContext) =>
|
||||
isProtectedPluginRoutePath(pathContext.pathname),
|
||||
},
|
||||
overrides: createProtectedPluginAuthOverrides(handlePluginRequest),
|
||||
run: async (server) => {
|
||||
await expectUnauthorizedVariants({ server, variants: CANONICAL_UNAUTH_VARIANTS });
|
||||
expect(handlePluginRequest).not.toHaveBeenCalled();
|
||||
@@ -429,20 +430,15 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
test("rejects unauthenticated plugin-channel fuzz corpus variants", async () => {
|
||||
const handlePluginRequest = createCanonicalizedChannelPluginHandler();
|
||||
|
||||
await withGatewayServer({
|
||||
await withPluginGatewayServer({
|
||||
prefix: "openclaw-plugin-http-auth-fuzz-corpus-test-",
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: {
|
||||
handlePluginRequest,
|
||||
shouldEnforcePluginGatewayAuth: (pathContext) =>
|
||||
isProtectedPluginRoutePath(pathContext.pathname),
|
||||
},
|
||||
overrides: createProtectedPluginAuthOverrides(handlePluginRequest),
|
||||
run: async (server) => {
|
||||
for (const variant of buildChannelPathFuzzCorpus()) {
|
||||
const response = await sendRequest(server, { path: variant.path });
|
||||
expect(response.res.statusCode, variant.label).toBe(401);
|
||||
expect(response.getBody(), variant.label).toContain("Unauthorized");
|
||||
}
|
||||
await expectUnauthorizedVariants({
|
||||
server,
|
||||
variants: buildChannelPathFuzzCorpus(),
|
||||
});
|
||||
expect(handlePluginRequest).not.toHaveBeenCalled();
|
||||
},
|
||||
});
|
||||
@@ -464,11 +460,7 @@ describe("gateway plugin HTTP auth boundary", () => {
|
||||
resolvedAuth: AUTH_TOKEN,
|
||||
overrides: { handlePluginRequest },
|
||||
run: async (server) => {
|
||||
for (const variant of encodedVariants) {
|
||||
const response = await sendRequest(server, { path: variant.path });
|
||||
expect(response.res.statusCode, variant.label).toBe(401);
|
||||
expect(response.getBody(), variant.label).toContain("Unauthorized");
|
||||
}
|
||||
await expectUnauthorizedVariants({ server, variants: encodedVariants });
|
||||
expect(handlePluginRequest).not.toHaveBeenCalled();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,6 +21,34 @@ function writeHookPackageManifest(pkgDir: string, hooks: string[]): void {
|
||||
);
|
||||
}
|
||||
|
||||
function setupHardlinkHookWorkspace(hookName: string): {
|
||||
hooksRoot: string;
|
||||
hookDir: string;
|
||||
outsideDir: string;
|
||||
} {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-workspace-hardlink-"));
|
||||
const hooksRoot = path.join(root, "hooks");
|
||||
fs.mkdirSync(hooksRoot, { recursive: true });
|
||||
|
||||
const hookDir = path.join(hooksRoot, hookName);
|
||||
const outsideDir = path.join(root, "outside");
|
||||
fs.mkdirSync(hookDir, { recursive: true });
|
||||
fs.mkdirSync(outsideDir, { recursive: true });
|
||||
return { hooksRoot, hookDir, outsideDir };
|
||||
}
|
||||
|
||||
function tryCreateHardlinkOrSkip(createLink: () => void): boolean {
|
||||
try {
|
||||
createLink();
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
describe("hooks workspace", () => {
|
||||
it("ignores package.json hook paths that traverse outside package directory", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-workspace-"));
|
||||
@@ -88,27 +116,15 @@ describe("hooks workspace", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-workspace-hardlink-"));
|
||||
const hooksRoot = path.join(root, "hooks");
|
||||
fs.mkdirSync(hooksRoot, { recursive: true });
|
||||
|
||||
const hookDir = path.join(hooksRoot, "hardlink-hook");
|
||||
const outsideDir = path.join(root, "outside");
|
||||
fs.mkdirSync(hookDir, { recursive: true });
|
||||
fs.mkdirSync(outsideDir, { recursive: true });
|
||||
const { hooksRoot, hookDir, outsideDir } = setupHardlinkHookWorkspace("hardlink-hook");
|
||||
fs.writeFileSync(path.join(hookDir, "handler.js"), "export default async () => {};\n");
|
||||
const outsideHookMd = path.join(outsideDir, "HOOK.md");
|
||||
const linkedHookMd = path.join(hookDir, "HOOK.md");
|
||||
fs.writeFileSync(linkedHookMd, "---\nname: hardlink-hook\n---\n");
|
||||
fs.rmSync(linkedHookMd);
|
||||
fs.writeFileSync(outsideHookMd, "---\nname: outside\n---\n");
|
||||
try {
|
||||
fs.linkSync(outsideHookMd, linkedHookMd);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
if (!tryCreateHardlinkOrSkip(() => fs.linkSync(outsideHookMd, linkedHookMd))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = loadHookEntriesFromDir({ dir: hooksRoot, source: "openclaw-workspace" });
|
||||
@@ -121,25 +137,13 @@ describe("hooks workspace", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hooks-workspace-hardlink-"));
|
||||
const hooksRoot = path.join(root, "hooks");
|
||||
fs.mkdirSync(hooksRoot, { recursive: true });
|
||||
|
||||
const hookDir = path.join(hooksRoot, "hardlink-handler-hook");
|
||||
const outsideDir = path.join(root, "outside");
|
||||
fs.mkdirSync(hookDir, { recursive: true });
|
||||
fs.mkdirSync(outsideDir, { recursive: true });
|
||||
const { hooksRoot, hookDir, outsideDir } = setupHardlinkHookWorkspace("hardlink-handler-hook");
|
||||
fs.writeFileSync(path.join(hookDir, "HOOK.md"), "---\nname: hardlink-handler-hook\n---\n");
|
||||
const outsideHandler = path.join(outsideDir, "handler.js");
|
||||
const linkedHandler = path.join(hookDir, "handler.js");
|
||||
fs.writeFileSync(outsideHandler, "export default async () => {};\n");
|
||||
try {
|
||||
fs.linkSync(outsideHandler, linkedHandler);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "EXDEV") {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
if (!tryCreateHardlinkOrSkip(() => fs.linkSync(outsideHandler, linkedHandler))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = loadHookEntriesFromDir({ dir: hooksRoot, source: "openclaw-workspace" });
|
||||
|
||||
@@ -47,6 +47,22 @@ function analyzeEnvWrapperAllowlist(params: { argv: string[]; envPath: string; c
|
||||
return { analysis, allowlistEval };
|
||||
}
|
||||
|
||||
function createPathExecutableFixture(params?: { executable?: string }): {
|
||||
exeName: string;
|
||||
exePath: string;
|
||||
binDir: string;
|
||||
} {
|
||||
const dir = makeTempDir();
|
||||
const binDir = path.join(dir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const baseName = params?.executable ?? "rg";
|
||||
const exeName = process.platform === "win32" ? `${baseName}.exe` : baseName;
|
||||
const exePath = path.join(binDir, exeName);
|
||||
fs.writeFileSync(exePath, "");
|
||||
fs.chmodSync(exePath, 0o755);
|
||||
return { exeName, exePath, binDir };
|
||||
}
|
||||
|
||||
describe("exec approvals allowlist matching", () => {
|
||||
const baseResolution = {
|
||||
rawExecutable: "rg",
|
||||
@@ -221,19 +237,13 @@ describe("exec approvals command resolution", () => {
|
||||
{
|
||||
name: "PATH executable",
|
||||
setup: () => {
|
||||
const dir = makeTempDir();
|
||||
const binDir = path.join(dir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const exeName = process.platform === "win32" ? "rg.exe" : "rg";
|
||||
const exe = path.join(binDir, exeName);
|
||||
fs.writeFileSync(exe, "");
|
||||
fs.chmodSync(exe, 0o755);
|
||||
const fixture = createPathExecutableFixture();
|
||||
return {
|
||||
command: "rg -n foo",
|
||||
cwd: undefined as string | undefined,
|
||||
envPath: makePathEnv(binDir),
|
||||
expectedPath: exe,
|
||||
expectedExecutableName: exeName,
|
||||
envPath: makePathEnv(fixture.binDir),
|
||||
expectedPath: fixture.exePath,
|
||||
expectedExecutableName: fixture.exeName,
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -286,21 +296,15 @@ describe("exec approvals command resolution", () => {
|
||||
});
|
||||
|
||||
it("unwraps transparent env wrapper argv to resolve the effective executable", () => {
|
||||
const dir = makeTempDir();
|
||||
const binDir = path.join(dir, "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const exeName = process.platform === "win32" ? "rg.exe" : "rg";
|
||||
const exe = path.join(binDir, exeName);
|
||||
fs.writeFileSync(exe, "");
|
||||
fs.chmodSync(exe, 0o755);
|
||||
const fixture = createPathExecutableFixture();
|
||||
|
||||
const resolution = resolveCommandResolutionFromArgv(
|
||||
["/usr/bin/env", "rg", "-n", "needle"],
|
||||
undefined,
|
||||
makePathEnv(binDir),
|
||||
makePathEnv(fixture.binDir),
|
||||
);
|
||||
expect(resolution?.resolvedPath).toBe(exe);
|
||||
expect(resolution?.executableName).toBe(exeName);
|
||||
expect(resolution?.resolvedPath).toBe(fixture.exePath);
|
||||
expect(resolution?.executableName).toBe(fixture.exeName);
|
||||
});
|
||||
|
||||
it("blocks semantic env wrappers from allowlist/safeBins auto-resolution", () => {
|
||||
|
||||
@@ -116,6 +116,18 @@ async function runChunkedWhatsAppDelivery(params?: {
|
||||
return { sendWhatsApp, results };
|
||||
}
|
||||
|
||||
async function deliverSingleWhatsAppForHookTest(params?: { sessionKey?: string }) {
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
|
||||
await deliverOutboundPayloads({
|
||||
cfg: whatsappChunkConfig,
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "hello" }],
|
||||
deps: { sendWhatsApp },
|
||||
...(params?.sessionKey ? { session: { key: params.sessionKey } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("deliverOutboundPayloads", () => {
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(defaultRegistry);
|
||||
@@ -653,31 +665,14 @@ describe("deliverOutboundPayloads", () => {
|
||||
});
|
||||
|
||||
it("does not emit internal message:sent hook when neither mirror nor sessionKey is provided", async () => {
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
|
||||
|
||||
await deliverOutboundPayloads({
|
||||
cfg: whatsappChunkConfig,
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "hello" }],
|
||||
deps: { sendWhatsApp },
|
||||
});
|
||||
await deliverSingleWhatsAppForHookTest();
|
||||
|
||||
expect(internalHookMocks.createInternalHookEvent).not.toHaveBeenCalled();
|
||||
expect(internalHookMocks.triggerInternalHook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits internal message:sent hook when sessionKey is provided without mirror", async () => {
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "w1", toJid: "jid" });
|
||||
|
||||
await deliverOutboundPayloads({
|
||||
cfg: whatsappChunkConfig,
|
||||
channel: "whatsapp",
|
||||
to: "+1555",
|
||||
payloads: [{ text: "hello" }],
|
||||
deps: { sendWhatsApp },
|
||||
session: { key: "agent:main:main" },
|
||||
});
|
||||
await deliverSingleWhatsAppForHookTest({ sessionKey: "agent:main:main" });
|
||||
|
||||
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledTimes(1);
|
||||
expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith(
|
||||
|
||||
@@ -258,6 +258,14 @@ async function getDirectoryEntries(params: {
|
||||
preferLiveOnMiss?: boolean;
|
||||
}): Promise<ChannelDirectoryEntry[]> {
|
||||
const signature = buildTargetResolverSignature(params.channel);
|
||||
const listParams = {
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
kind: params.kind,
|
||||
query: params.query,
|
||||
runtime: params.runtime,
|
||||
};
|
||||
const cacheKey = buildDirectoryCacheKey({
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
@@ -270,12 +278,7 @@ async function getDirectoryEntries(params: {
|
||||
return cached;
|
||||
}
|
||||
const entries = await listDirectoryEntries({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
kind: params.kind,
|
||||
query: params.query,
|
||||
runtime: params.runtime,
|
||||
...listParams,
|
||||
source: "cache",
|
||||
});
|
||||
if (entries.length > 0 || !params.preferLiveOnMiss) {
|
||||
@@ -290,12 +293,7 @@ async function getDirectoryEntries(params: {
|
||||
signature,
|
||||
});
|
||||
const liveEntries = await listDirectoryEntries({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
kind: params.kind,
|
||||
query: params.query,
|
||||
runtime: params.runtime,
|
||||
...listParams,
|
||||
source: "live",
|
||||
});
|
||||
directoryCache.set(liveKey, liveEntries, params.cfg);
|
||||
@@ -303,6 +301,24 @@ async function getDirectoryEntries(params: {
|
||||
return liveEntries;
|
||||
}
|
||||
|
||||
function buildNormalizedResolveResult(params: {
|
||||
channel: ChannelId;
|
||||
raw: string;
|
||||
normalized: string;
|
||||
kind: TargetResolveKind;
|
||||
}): ResolveMessagingTargetResult {
|
||||
const directTarget = preserveTargetCase(params.channel, params.raw, params.normalized);
|
||||
return {
|
||||
ok: true,
|
||||
target: {
|
||||
to: directTarget,
|
||||
kind: params.kind,
|
||||
display: stripTargetPrefixes(params.raw),
|
||||
source: "normalized",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pickAmbiguousMatch(
|
||||
entries: ChannelDirectoryEntry[],
|
||||
mode: ResolveAmbiguousMode,
|
||||
@@ -372,16 +388,12 @@ export async function resolveMessagingTarget(params: {
|
||||
return false;
|
||||
};
|
||||
if (looksLikeTargetId()) {
|
||||
const directTarget = preserveTargetCase(params.channel, raw, normalized);
|
||||
return {
|
||||
ok: true,
|
||||
target: {
|
||||
to: directTarget,
|
||||
kind,
|
||||
display: stripTargetPrefixes(raw),
|
||||
source: "normalized",
|
||||
},
|
||||
};
|
||||
return buildNormalizedResolveResult({
|
||||
channel: params.channel,
|
||||
raw,
|
||||
normalized,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
const query = stripTargetPrefixes(raw);
|
||||
const entries = await getDirectoryEntries({
|
||||
@@ -434,16 +446,12 @@ export async function resolveMessagingTarget(params: {
|
||||
(params.channel === "bluebubbles" || params.channel === "imessage") &&
|
||||
/^\+?\d{6,}$/.test(query)
|
||||
) {
|
||||
const directTarget = preserveTargetCase(params.channel, raw, normalized);
|
||||
return {
|
||||
ok: true,
|
||||
target: {
|
||||
to: directTarget,
|
||||
kind,
|
||||
display: stripTargetPrefixes(raw),
|
||||
source: "normalized",
|
||||
},
|
||||
};
|
||||
return buildNormalizedResolveResult({
|
||||
channel: params.channel,
|
||||
raw,
|
||||
normalized,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -11,7 +11,7 @@ function normalizeChannel(value?: string) {
|
||||
return value?.trim().toLowerCase() ?? undefined;
|
||||
}
|
||||
|
||||
function passthroughPluginAutoEnable(config: unknown) {
|
||||
function applyPluginAutoEnableForTests(config: unknown) {
|
||||
return { config, changes: [] as unknown[] };
|
||||
}
|
||||
|
||||
@@ -36,14 +36,16 @@ vi.mock("../../agents/agent-scope.js", () => ({
|
||||
resolveAgentWorkspaceDir: () => TEST_WORKSPACE_ROOT,
|
||||
}));
|
||||
|
||||
vi.mock("../../config/plugin-auto-enable.js", () => ({
|
||||
applyPluginAutoEnable: ({ config }: { config: unknown }) => passthroughPluginAutoEnable(config),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/loader.js", () => ({
|
||||
loadOpenClawPlugins: mocks.loadOpenClawPlugins,
|
||||
}));
|
||||
|
||||
vi.mock("../../config/plugin-auto-enable.js", () => ({
|
||||
applyPluginAutoEnable(args: { config: unknown }) {
|
||||
return applyPluginAutoEnableForTests(args.config);
|
||||
},
|
||||
}));
|
||||
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { resolveOutboundTarget } from "./targets.js";
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
resolveOutboundTarget,
|
||||
resolveSessionDeliveryTarget,
|
||||
} from "./targets.js";
|
||||
import type { SessionDeliveryTarget } from "./targets.js";
|
||||
import {
|
||||
installResolveOutboundTargetPluginRegistryHooks,
|
||||
runResolveOutboundTargetCoreTests,
|
||||
@@ -14,15 +15,15 @@ runResolveOutboundTargetCoreTests();
|
||||
|
||||
describe("resolveOutboundTarget defaultTo config fallback", () => {
|
||||
installResolveOutboundTargetPluginRegistryHooks();
|
||||
const whatsappDefaultCfg: OpenClawConfig = {
|
||||
channels: { whatsapp: { defaultTo: "+15551234567", allowFrom: ["*"] } },
|
||||
};
|
||||
|
||||
it("uses whatsapp defaultTo when no explicit target is provided", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { whatsapp: { defaultTo: "+15551234567", allowFrom: ["*"] } },
|
||||
};
|
||||
const res = resolveOutboundTarget({
|
||||
channel: "whatsapp",
|
||||
to: undefined,
|
||||
cfg,
|
||||
cfg: whatsappDefaultCfg,
|
||||
mode: "implicit",
|
||||
});
|
||||
expect(res).toEqual({ ok: true, to: "+15551234567" });
|
||||
@@ -42,13 +43,10 @@ describe("resolveOutboundTarget defaultTo config fallback", () => {
|
||||
});
|
||||
|
||||
it("explicit --reply-to overrides defaultTo", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { whatsapp: { defaultTo: "+15551234567", allowFrom: ["*"] } },
|
||||
};
|
||||
const res = resolveOutboundTarget({
|
||||
channel: "whatsapp",
|
||||
to: "+15559999999",
|
||||
cfg,
|
||||
cfg: whatsappDefaultCfg,
|
||||
mode: "explicit",
|
||||
});
|
||||
expect(res).toEqual({ ok: true, to: "+15559999999" });
|
||||
@@ -69,6 +67,41 @@ describe("resolveOutboundTarget defaultTo config fallback", () => {
|
||||
});
|
||||
|
||||
describe("resolveSessionDeliveryTarget", () => {
|
||||
const expectImplicitRoute = (
|
||||
resolved: SessionDeliveryTarget,
|
||||
params: {
|
||||
channel?: SessionDeliveryTarget["channel"];
|
||||
to?: string;
|
||||
lastChannel?: SessionDeliveryTarget["lastChannel"];
|
||||
lastTo?: string;
|
||||
},
|
||||
) => {
|
||||
expect(resolved).toEqual({
|
||||
channel: params.channel,
|
||||
to: params.to,
|
||||
accountId: undefined,
|
||||
threadId: undefined,
|
||||
threadIdExplicit: false,
|
||||
mode: "implicit",
|
||||
lastChannel: params.lastChannel,
|
||||
lastTo: params.lastTo,
|
||||
lastAccountId: undefined,
|
||||
lastThreadId: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const expectTopicParsedFromExplicitTo = (
|
||||
entry: Parameters<typeof resolveSessionDeliveryTarget>[0]["entry"],
|
||||
) => {
|
||||
const resolved = resolveSessionDeliveryTarget({
|
||||
entry,
|
||||
requestedChannel: "last",
|
||||
explicitTo: "63448508:topic:1008013",
|
||||
});
|
||||
expect(resolved.to).toBe("63448508");
|
||||
expect(resolved.threadId).toBe(1008013);
|
||||
};
|
||||
|
||||
it("derives implicit delivery from the last route", () => {
|
||||
const resolved = resolveSessionDeliveryTarget({
|
||||
entry: {
|
||||
@@ -106,17 +139,11 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
requestedChannel: "telegram",
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
expectImplicitRoute(resolved, {
|
||||
channel: "telegram",
|
||||
to: undefined,
|
||||
accountId: undefined,
|
||||
threadId: undefined,
|
||||
threadIdExplicit: false,
|
||||
mode: "implicit",
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastAccountId: undefined,
|
||||
lastThreadId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,17 +159,11 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
allowMismatchedLastTo: true,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
expectImplicitRoute(resolved, {
|
||||
channel: "telegram",
|
||||
to: "+1555",
|
||||
accountId: undefined,
|
||||
threadId: undefined,
|
||||
threadIdExplicit: false,
|
||||
mode: "implicit",
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastAccountId: undefined,
|
||||
lastThreadId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -207,49 +228,29 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
fallbackChannel: "slack",
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
expectImplicitRoute(resolved, {
|
||||
channel: "slack",
|
||||
to: undefined,
|
||||
accountId: undefined,
|
||||
threadId: undefined,
|
||||
threadIdExplicit: false,
|
||||
mode: "implicit",
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
lastAccountId: undefined,
|
||||
lastThreadId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses :topic:NNN from explicitTo into threadId", () => {
|
||||
const resolved = resolveSessionDeliveryTarget({
|
||||
entry: {
|
||||
sessionId: "sess-topic",
|
||||
updatedAt: 1,
|
||||
lastChannel: "telegram",
|
||||
lastTo: "63448508",
|
||||
},
|
||||
requestedChannel: "last",
|
||||
explicitTo: "63448508:topic:1008013",
|
||||
expectTopicParsedFromExplicitTo({
|
||||
sessionId: "sess-topic",
|
||||
updatedAt: 1,
|
||||
lastChannel: "telegram",
|
||||
lastTo: "63448508",
|
||||
});
|
||||
|
||||
expect(resolved.to).toBe("63448508");
|
||||
expect(resolved.threadId).toBe(1008013);
|
||||
});
|
||||
|
||||
it("parses :topic:NNN even when lastTo is absent", () => {
|
||||
const resolved = resolveSessionDeliveryTarget({
|
||||
entry: {
|
||||
sessionId: "sess-no-last",
|
||||
updatedAt: 1,
|
||||
lastChannel: "telegram",
|
||||
},
|
||||
requestedChannel: "last",
|
||||
explicitTo: "63448508:topic:1008013",
|
||||
expectTopicParsedFromExplicitTo({
|
||||
sessionId: "sess-no-last",
|
||||
updatedAt: 1,
|
||||
lastChannel: "telegram",
|
||||
});
|
||||
|
||||
expect(resolved.to).toBe("63448508");
|
||||
expect(resolved.threadId).toBe(1008013);
|
||||
});
|
||||
|
||||
it("skips :topic: parsing for non-telegram channels", () => {
|
||||
@@ -365,18 +366,11 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
});
|
||||
|
||||
it("allows heartbeat delivery to Telegram direct chats by default", () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const resolved = resolveHeartbeatDeliveryTarget({
|
||||
cfg,
|
||||
entry: {
|
||||
sessionId: "sess-heartbeat-telegram-direct",
|
||||
updatedAt: 1,
|
||||
lastChannel: "telegram",
|
||||
lastTo: "5232990709",
|
||||
},
|
||||
heartbeat: {
|
||||
target: "last",
|
||||
},
|
||||
const resolved = resolveHeartbeatTarget({
|
||||
sessionId: "sess-heartbeat-telegram-direct",
|
||||
updatedAt: 1,
|
||||
lastChannel: "telegram",
|
||||
lastTo: "5232990709",
|
||||
});
|
||||
|
||||
expect(resolved.channel).toBe("telegram");
|
||||
@@ -384,20 +378,15 @@ describe("resolveSessionDeliveryTarget", () => {
|
||||
});
|
||||
|
||||
it("blocks heartbeat delivery to Telegram direct chats when directPolicy is block", () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const resolved = resolveHeartbeatDeliveryTarget({
|
||||
cfg,
|
||||
entry: {
|
||||
const resolved = resolveHeartbeatTarget(
|
||||
{
|
||||
sessionId: "sess-heartbeat-telegram-direct",
|
||||
updatedAt: 1,
|
||||
lastChannel: "telegram",
|
||||
lastTo: "5232990709",
|
||||
},
|
||||
heartbeat: {
|
||||
target: "last",
|
||||
directPolicy: "block",
|
||||
},
|
||||
});
|
||||
"block",
|
||||
);
|
||||
|
||||
expect(resolved.channel).toBe("none");
|
||||
expect(resolved.reason).toBe("dm-blocked");
|
||||
|
||||
@@ -46,6 +46,19 @@ function clearSupervisorHints() {
|
||||
}
|
||||
}
|
||||
|
||||
function expectLaunchdKickstartSupervised(params?: { launchJobLabel?: string }) {
|
||||
setPlatform("darwin");
|
||||
if (params?.launchJobLabel) {
|
||||
process.env.LAUNCH_JOB_LABEL = params.launchJobLabel;
|
||||
}
|
||||
process.env.OPENCLAW_LAUNCHD_LABEL = "ai.openclaw.gateway";
|
||||
triggerOpenClawRestartMock.mockReturnValue({ ok: true, method: "launchctl" });
|
||||
const result = restartGatewayProcessWithFreshPid();
|
||||
expect(result.mode).toBe("supervised");
|
||||
expect(triggerOpenClawRestartMock).toHaveBeenCalledOnce();
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
describe("restartGatewayProcessWithFreshPid", () => {
|
||||
it("returns disabled when OPENCLAW_NO_RESPAWN is set", () => {
|
||||
process.env.OPENCLAW_NO_RESPAWN = "1";
|
||||
@@ -62,16 +75,7 @@ describe("restartGatewayProcessWithFreshPid", () => {
|
||||
});
|
||||
|
||||
it("runs launchd kickstart helper on macOS when launchd label is set", () => {
|
||||
setPlatform("darwin");
|
||||
process.env.LAUNCH_JOB_LABEL = "ai.openclaw.gateway";
|
||||
process.env.OPENCLAW_LAUNCHD_LABEL = "ai.openclaw.gateway";
|
||||
triggerOpenClawRestartMock.mockReturnValue({ ok: true, method: "launchctl" });
|
||||
|
||||
const result = restartGatewayProcessWithFreshPid();
|
||||
|
||||
expect(result.mode).toBe("supervised");
|
||||
expect(triggerOpenClawRestartMock).toHaveBeenCalledOnce();
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
expectLaunchdKickstartSupervised({ launchJobLabel: "ai.openclaw.gateway" });
|
||||
});
|
||||
|
||||
it("returns failed when launchd kickstart helper fails", () => {
|
||||
@@ -124,13 +128,7 @@ describe("restartGatewayProcessWithFreshPid", () => {
|
||||
|
||||
it("returns supervised when OPENCLAW_LAUNCHD_LABEL is set (stock launchd plist)", () => {
|
||||
clearSupervisorHints();
|
||||
setPlatform("darwin");
|
||||
process.env.OPENCLAW_LAUNCHD_LABEL = "ai.openclaw.gateway";
|
||||
triggerOpenClawRestartMock.mockReturnValue({ ok: true, method: "launchctl" });
|
||||
const result = restartGatewayProcessWithFreshPid();
|
||||
expect(result.mode).toBe("supervised");
|
||||
expect(triggerOpenClawRestartMock).toHaveBeenCalledOnce();
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
expectLaunchdKickstartSupervised();
|
||||
});
|
||||
|
||||
it("returns supervised when OPENCLAW_SYSTEMD_UNIT is set", () => {
|
||||
|
||||
@@ -31,15 +31,29 @@ describe("shell env fallback", () => {
|
||||
resetShellPathCacheForTests();
|
||||
const env: NodeJS.ProcessEnv = { SHELL: shell };
|
||||
const exec = vi.fn(() => Buffer.from("OPENAI_API_KEY=from-shell\0"));
|
||||
const res = loadShellEnvFallback({
|
||||
const res = runShellEnvFallback({
|
||||
enabled: true,
|
||||
env,
|
||||
expectedKeys: ["OPENAI_API_KEY"],
|
||||
exec: exec as unknown as Parameters<typeof loadShellEnvFallback>[0]["exec"],
|
||||
exec,
|
||||
});
|
||||
return { res, exec };
|
||||
}
|
||||
|
||||
function runShellEnvFallback(params: {
|
||||
enabled: boolean;
|
||||
env: NodeJS.ProcessEnv;
|
||||
expectedKeys: string[];
|
||||
exec: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
return loadShellEnvFallback({
|
||||
enabled: params.enabled,
|
||||
env: params.env,
|
||||
expectedKeys: params.expectedKeys,
|
||||
exec: params.exec as unknown as Parameters<typeof loadShellEnvFallback>[0]["exec"],
|
||||
});
|
||||
}
|
||||
|
||||
function makeUnsafeStartupEnv(): NodeJS.ProcessEnv {
|
||||
return {
|
||||
SHELL: "/bin/bash",
|
||||
@@ -76,6 +90,29 @@ describe("shell env fallback", () => {
|
||||
}
|
||||
}
|
||||
|
||||
function getShellPathTwiceWithExec(params: {
|
||||
exec: ReturnType<typeof vi.fn>;
|
||||
platform: NodeJS.Platform;
|
||||
}) {
|
||||
return getShellPathTwice({
|
||||
exec: params.exec as unknown as Parameters<typeof getShellPathFromLoginShell>[0]["exec"],
|
||||
platform: params.platform,
|
||||
});
|
||||
}
|
||||
|
||||
function probeShellPathWithFreshCache(params: {
|
||||
exec: ReturnType<typeof vi.fn>;
|
||||
platform: NodeJS.Platform;
|
||||
}) {
|
||||
resetShellPathCacheForTests();
|
||||
return getShellPathTwiceWithExec(params);
|
||||
}
|
||||
|
||||
function expectBinShFallbackExec(exec: ReturnType<typeof vi.fn>) {
|
||||
expect(exec).toHaveBeenCalledTimes(1);
|
||||
expect(exec).toHaveBeenCalledWith("/bin/sh", ["-l", "-c", "env -0"], expect.any(Object));
|
||||
}
|
||||
|
||||
it("is disabled by default", () => {
|
||||
expect(shouldEnableShellEnvFallback({} as NodeJS.ProcessEnv)).toBe(false);
|
||||
expect(shouldEnableShellEnvFallback({ OPENCLAW_LOAD_SHELL_ENV: "0" })).toBe(false);
|
||||
@@ -96,11 +133,11 @@ describe("shell env fallback", () => {
|
||||
const env: NodeJS.ProcessEnv = { OPENAI_API_KEY: "set" };
|
||||
const exec = vi.fn(() => Buffer.from(""));
|
||||
|
||||
const res = loadShellEnvFallback({
|
||||
const res = runShellEnvFallback({
|
||||
enabled: true,
|
||||
env,
|
||||
expectedKeys: ["OPENAI_API_KEY", "DISCORD_BOT_TOKEN"],
|
||||
exec: exec as unknown as Parameters<typeof loadShellEnvFallback>[0]["exec"],
|
||||
exec,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
@@ -113,11 +150,11 @@ describe("shell env fallback", () => {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
const exec = vi.fn(() => Buffer.from("OPENAI_API_KEY=from-shell\0DISCORD_BOT_TOKEN=discord\0"));
|
||||
|
||||
const res1 = loadShellEnvFallback({
|
||||
const res1 = runShellEnvFallback({
|
||||
enabled: true,
|
||||
env,
|
||||
expectedKeys: ["OPENAI_API_KEY", "DISCORD_BOT_TOKEN"],
|
||||
exec: exec as unknown as Parameters<typeof loadShellEnvFallback>[0]["exec"],
|
||||
exec,
|
||||
});
|
||||
|
||||
expect(res1.ok).toBe(true);
|
||||
@@ -129,11 +166,11 @@ describe("shell env fallback", () => {
|
||||
const exec2 = vi.fn(() =>
|
||||
Buffer.from("OPENAI_API_KEY=from-shell\0DISCORD_BOT_TOKEN=discord2\0"),
|
||||
);
|
||||
const res2 = loadShellEnvFallback({
|
||||
const res2 = runShellEnvFallback({
|
||||
enabled: true,
|
||||
env,
|
||||
expectedKeys: ["OPENAI_API_KEY", "DISCORD_BOT_TOKEN"],
|
||||
exec: exec2 as unknown as Parameters<typeof loadShellEnvFallback>[0]["exec"],
|
||||
exec: exec2,
|
||||
});
|
||||
|
||||
expect(res2.ok).toBe(true);
|
||||
@@ -143,11 +180,10 @@ describe("shell env fallback", () => {
|
||||
});
|
||||
|
||||
it("resolves PATH via login shell and caches it", () => {
|
||||
resetShellPathCacheForTests();
|
||||
const exec = vi.fn(() => Buffer.from("PATH=/usr/local/bin:/usr/bin\0HOME=/tmp\0"));
|
||||
|
||||
const { first, second } = getShellPathTwice({
|
||||
exec: exec as unknown as Parameters<typeof getShellPathFromLoginShell>[0]["exec"],
|
||||
const { first, second } = probeShellPathWithFreshCache({
|
||||
exec,
|
||||
platform: "linux",
|
||||
});
|
||||
|
||||
@@ -157,13 +193,12 @@ describe("shell env fallback", () => {
|
||||
});
|
||||
|
||||
it("returns null on shell env read failure and caches null", () => {
|
||||
resetShellPathCacheForTests();
|
||||
const exec = vi.fn(() => {
|
||||
throw new Error("exec failed");
|
||||
});
|
||||
|
||||
const { first, second } = getShellPathTwice({
|
||||
exec: exec as unknown as Parameters<typeof getShellPathFromLoginShell>[0]["exec"],
|
||||
const { first, second } = probeShellPathWithFreshCache({
|
||||
exec,
|
||||
platform: "linux",
|
||||
});
|
||||
|
||||
@@ -176,16 +211,14 @@ describe("shell env fallback", () => {
|
||||
const { res, exec } = runShellEnvFallbackForShell("zsh");
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(exec).toHaveBeenCalledTimes(1);
|
||||
expect(exec).toHaveBeenCalledWith("/bin/sh", ["-l", "-c", "env -0"], expect.any(Object));
|
||||
expectBinShFallbackExec(exec);
|
||||
});
|
||||
|
||||
it("falls back to /bin/sh when SHELL points to an untrusted path", () => {
|
||||
const { res, exec } = runShellEnvFallbackForShell("/tmp/evil-shell");
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(exec).toHaveBeenCalledTimes(1);
|
||||
expect(exec).toHaveBeenCalledWith("/bin/sh", ["-l", "-c", "env -0"], expect.any(Object));
|
||||
expectBinShFallbackExec(exec);
|
||||
});
|
||||
|
||||
it("falls back to /bin/sh when SHELL is absolute but not registered in /etc/shells", () => {
|
||||
@@ -193,8 +226,7 @@ describe("shell env fallback", () => {
|
||||
const { res, exec } = runShellEnvFallbackForShell("/opt/homebrew/bin/evil-shell");
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(exec).toHaveBeenCalledTimes(1);
|
||||
expect(exec).toHaveBeenCalledWith("/bin/sh", ["-l", "-c", "env -0"], expect.any(Object));
|
||||
expectBinShFallbackExec(exec);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -220,11 +252,11 @@ describe("shell env fallback", () => {
|
||||
return Buffer.from("OPENAI_API_KEY=from-shell\0");
|
||||
});
|
||||
|
||||
const res = loadShellEnvFallback({
|
||||
const res = runShellEnvFallback({
|
||||
enabled: true,
|
||||
env,
|
||||
expectedKeys: ["OPENAI_API_KEY"],
|
||||
exec: exec as unknown as Parameters<typeof loadShellEnvFallback>[0]["exec"],
|
||||
exec,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
@@ -253,11 +285,10 @@ describe("shell env fallback", () => {
|
||||
});
|
||||
|
||||
it("returns null without invoking shell on win32", () => {
|
||||
resetShellPathCacheForTests();
|
||||
const exec = vi.fn(() => Buffer.from("PATH=/usr/local/bin:/usr/bin\0HOME=/tmp\0"));
|
||||
|
||||
const { first, second } = getShellPathTwice({
|
||||
exec: exec as unknown as Parameters<typeof getShellPathFromLoginShell>[0]["exec"],
|
||||
const { first, second } = probeShellPathWithFreshCache({
|
||||
exec,
|
||||
platform: "win32",
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,72 @@ function secureDirStat(uid = 501) {
|
||||
};
|
||||
}
|
||||
|
||||
function makeDirStat(params?: {
|
||||
isDirectory?: boolean;
|
||||
isSymbolicLink?: boolean;
|
||||
uid?: number;
|
||||
mode?: number;
|
||||
}) {
|
||||
return {
|
||||
isDirectory: () => params?.isDirectory ?? true,
|
||||
isSymbolicLink: () => params?.isSymbolicLink ?? false,
|
||||
uid: params?.uid ?? 501,
|
||||
mode: params?.mode ?? 0o40700,
|
||||
};
|
||||
}
|
||||
|
||||
function readOnlyTmpAccessSync() {
|
||||
return vi.fn((target: string) => {
|
||||
if (target === "/tmp") {
|
||||
throw new Error("read-only");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resolveWithReadOnlyTmpFallback(params: {
|
||||
fallbackPath: string;
|
||||
fallbackLstatSync: NonNullable<TmpDirOptions["lstatSync"]>;
|
||||
chmodSync?: NonNullable<TmpDirOptions["chmodSync"]>;
|
||||
warn?: NonNullable<TmpDirOptions["warn"]>;
|
||||
}) {
|
||||
return resolvePreferredOpenClawTmpDir({
|
||||
accessSync: readOnlyTmpAccessSync(),
|
||||
lstatSync: vi.fn((target: string) => {
|
||||
if (target === POSIX_OPENCLAW_TMP_DIR) {
|
||||
throw nodeErrorWithCode("ENOENT");
|
||||
}
|
||||
if (target === params.fallbackPath) {
|
||||
return params.fallbackLstatSync(target);
|
||||
}
|
||||
return secureDirStat(501);
|
||||
}),
|
||||
mkdirSync: vi.fn(),
|
||||
chmodSync: params.chmodSync,
|
||||
getuid: vi.fn(() => 501),
|
||||
tmpdir: vi.fn(() => "/var/fallback"),
|
||||
warn: params.warn,
|
||||
});
|
||||
}
|
||||
|
||||
function symlinkTmpDirLstat() {
|
||||
return vi.fn(() => makeDirStat({ isSymbolicLink: true, mode: 0o120777 }));
|
||||
}
|
||||
|
||||
function expectFallsBackToOsTmpDir(params: { lstatSync: NonNullable<TmpDirOptions["lstatSync"]> }) {
|
||||
const { resolved, tmpdir } = resolveWithMocks({ lstatSync: params.lstatSync });
|
||||
expect(resolved).toBe(fallbackTmp());
|
||||
expect(tmpdir).toHaveBeenCalled();
|
||||
}
|
||||
|
||||
function missingThenSecureLstat(uid = 501) {
|
||||
return vi
|
||||
.fn<NonNullable<TmpDirOptions["lstatSync"]>>()
|
||||
.mockImplementationOnce(() => {
|
||||
throw nodeErrorWithCode("ENOENT");
|
||||
})
|
||||
.mockImplementationOnce(() => secureDirStat(uid));
|
||||
}
|
||||
|
||||
function resolveWithMocks(params: {
|
||||
lstatSync: NonNullable<TmpDirOptions["lstatSync"]>;
|
||||
fallbackLstatSync?: NonNullable<TmpDirOptions["lstatSync"]>;
|
||||
@@ -81,12 +147,7 @@ describe("resolvePreferredOpenClawTmpDir", () => {
|
||||
});
|
||||
|
||||
it("prefers /tmp/openclaw when it does not exist but /tmp is writable", () => {
|
||||
const lstatSyncMock = vi
|
||||
.fn<NonNullable<TmpDirOptions["lstatSync"]>>()
|
||||
.mockImplementationOnce(() => {
|
||||
throw nodeErrorWithCode("ENOENT");
|
||||
})
|
||||
.mockImplementationOnce(() => secureDirStat(501));
|
||||
const lstatSyncMock = missingThenSecureLstat();
|
||||
|
||||
const { resolved, accessSync, mkdirSync, tmpdir } = resolveWithMocks({
|
||||
lstatSync: lstatSyncMock,
|
||||
@@ -99,12 +160,7 @@ describe("resolvePreferredOpenClawTmpDir", () => {
|
||||
});
|
||||
|
||||
it("falls back to os.tmpdir()/openclaw when /tmp/openclaw is not a directory", () => {
|
||||
const lstatSync = vi.fn(() => ({
|
||||
isDirectory: () => false,
|
||||
isSymbolicLink: () => false,
|
||||
uid: 501,
|
||||
mode: 0o100644,
|
||||
})) as unknown as ReturnType<typeof vi.fn> & NonNullable<TmpDirOptions["lstatSync"]>;
|
||||
const lstatSync = vi.fn(() => makeDirStat({ isDirectory: false, mode: 0o100644 }));
|
||||
const { resolved, tmpdir } = resolveWithMocks({ lstatSync });
|
||||
|
||||
expect(resolved).toBe(fallbackTmp());
|
||||
@@ -130,59 +186,20 @@ describe("resolvePreferredOpenClawTmpDir", () => {
|
||||
});
|
||||
|
||||
it("falls back when /tmp/openclaw is a symlink", () => {
|
||||
const lstatSync = vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => true,
|
||||
uid: 501,
|
||||
mode: 0o120777,
|
||||
}));
|
||||
|
||||
const { resolved, tmpdir } = resolveWithMocks({ lstatSync });
|
||||
|
||||
expect(resolved).toBe(fallbackTmp());
|
||||
expect(tmpdir).toHaveBeenCalled();
|
||||
expectFallsBackToOsTmpDir({ lstatSync: symlinkTmpDirLstat() });
|
||||
});
|
||||
|
||||
it("falls back when /tmp/openclaw is not owned by the current user", () => {
|
||||
const lstatSync = vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
uid: 0,
|
||||
mode: 0o40700,
|
||||
}));
|
||||
|
||||
const { resolved, tmpdir } = resolveWithMocks({ lstatSync });
|
||||
|
||||
expect(resolved).toBe(fallbackTmp());
|
||||
expect(tmpdir).toHaveBeenCalled();
|
||||
expectFallsBackToOsTmpDir({ lstatSync: vi.fn(() => makeDirStat({ uid: 0 })) });
|
||||
});
|
||||
|
||||
it("falls back when /tmp/openclaw is group/other writable", () => {
|
||||
const lstatSync = vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
uid: 501,
|
||||
mode: 0o40777,
|
||||
}));
|
||||
const { resolved, tmpdir } = resolveWithMocks({ lstatSync });
|
||||
|
||||
expect(resolved).toBe(fallbackTmp());
|
||||
expect(tmpdir).toHaveBeenCalled();
|
||||
expectFallsBackToOsTmpDir({ lstatSync: vi.fn(() => makeDirStat({ mode: 0o40777 })) });
|
||||
});
|
||||
|
||||
it("throws when fallback path is a symlink", () => {
|
||||
const lstatSync = vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => true,
|
||||
uid: 501,
|
||||
mode: 0o120777,
|
||||
}));
|
||||
const fallbackLstatSync = vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => true,
|
||||
uid: 501,
|
||||
mode: 0o120777,
|
||||
}));
|
||||
const lstatSync = symlinkTmpDirLstat();
|
||||
const fallbackLstatSync = vi.fn(() => makeDirStat({ isSymbolicLink: true, mode: 0o120777 }));
|
||||
|
||||
expect(() =>
|
||||
resolveWithMocks({
|
||||
@@ -193,18 +210,8 @@ describe("resolvePreferredOpenClawTmpDir", () => {
|
||||
});
|
||||
|
||||
it("creates fallback directory when missing, then validates ownership and mode", () => {
|
||||
const lstatSync = vi.fn(() => ({
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => true,
|
||||
uid: 501,
|
||||
mode: 0o120777,
|
||||
}));
|
||||
const fallbackLstatSync = vi
|
||||
.fn<NonNullable<TmpDirOptions["lstatSync"]>>()
|
||||
.mockImplementationOnce(() => {
|
||||
throw nodeErrorWithCode("ENOENT");
|
||||
})
|
||||
.mockImplementationOnce(() => secureDirStat(501));
|
||||
const lstatSync = symlinkTmpDirLstat();
|
||||
const fallbackLstatSync = missingThenSecureLstat();
|
||||
|
||||
const { resolved, mkdirSync } = resolveWithMocks({
|
||||
lstatSync,
|
||||
@@ -238,25 +245,15 @@ describe("resolvePreferredOpenClawTmpDir", () => {
|
||||
}
|
||||
});
|
||||
|
||||
const resolved = resolvePreferredOpenClawTmpDir({
|
||||
accessSync: vi.fn((target: string) => {
|
||||
if (target === "/tmp") {
|
||||
throw new Error("read-only");
|
||||
}
|
||||
}),
|
||||
lstatSync: vi.fn((target: string) => {
|
||||
if (target === POSIX_OPENCLAW_TMP_DIR) {
|
||||
return lstatSync(target);
|
||||
}
|
||||
const resolved = resolveWithReadOnlyTmpFallback({
|
||||
fallbackPath,
|
||||
fallbackLstatSync: vi.fn((target: string) => {
|
||||
if (target === fallbackPath) {
|
||||
return fallbackLstatSync(target);
|
||||
}
|
||||
return secureDirStat(501);
|
||||
return lstatSync(target);
|
||||
}),
|
||||
mkdirSync: vi.fn(),
|
||||
chmodSync,
|
||||
getuid: vi.fn(() => 501),
|
||||
tmpdir: vi.fn(() => "/var/fallback"),
|
||||
warn: vi.fn(),
|
||||
});
|
||||
|
||||
@@ -274,30 +271,15 @@ describe("resolvePreferredOpenClawTmpDir", () => {
|
||||
});
|
||||
const warn = vi.fn();
|
||||
|
||||
const resolved = resolvePreferredOpenClawTmpDir({
|
||||
accessSync: vi.fn((target: string) => {
|
||||
if (target === "/tmp") {
|
||||
throw new Error("read-only");
|
||||
}
|
||||
}),
|
||||
lstatSync: vi.fn((target: string) => {
|
||||
if (target === POSIX_OPENCLAW_TMP_DIR) {
|
||||
throw nodeErrorWithCode("ENOENT");
|
||||
}
|
||||
if (target === fallbackPath) {
|
||||
return {
|
||||
isDirectory: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
uid: 501,
|
||||
mode: fallbackMode,
|
||||
};
|
||||
}
|
||||
return secureDirStat(501);
|
||||
}),
|
||||
mkdirSync: vi.fn(),
|
||||
const resolved = resolveWithReadOnlyTmpFallback({
|
||||
fallbackPath,
|
||||
fallbackLstatSync: vi.fn(() =>
|
||||
makeDirStat({
|
||||
isSymbolicLink: false,
|
||||
mode: fallbackMode,
|
||||
}),
|
||||
),
|
||||
chmodSync,
|
||||
getuid: vi.fn(() => 501),
|
||||
tmpdir: vi.fn(() => "/var/fallback"),
|
||||
warn,
|
||||
});
|
||||
|
||||
|
||||
@@ -400,6 +400,30 @@ function appendCellTextOnly(state: RenderState, cell: TableCell) {
|
||||
// Do not append styles - this is used for code blocks where inner styles would overlap
|
||||
}
|
||||
|
||||
function appendTableBulletValue(
|
||||
state: RenderState,
|
||||
params: {
|
||||
header?: TableCell;
|
||||
value?: TableCell;
|
||||
columnIndex: number;
|
||||
includeColumnFallback: boolean;
|
||||
},
|
||||
) {
|
||||
const { header, value, columnIndex, includeColumnFallback } = params;
|
||||
if (!value?.text) {
|
||||
return;
|
||||
}
|
||||
state.text += "• ";
|
||||
if (header?.text) {
|
||||
appendCell(state, header);
|
||||
state.text += ": ";
|
||||
} else if (includeColumnFallback) {
|
||||
state.text += `Column ${columnIndex}: `;
|
||||
}
|
||||
appendCell(state, value);
|
||||
state.text += "\n";
|
||||
}
|
||||
|
||||
function renderTableAsBullets(state: RenderState) {
|
||||
if (!state.table) {
|
||||
return;
|
||||
@@ -436,20 +460,12 @@ function renderTableAsBullets(state: RenderState) {
|
||||
|
||||
// Add each column as a bullet point
|
||||
for (let i = 1; i < row.length; i++) {
|
||||
const header = headers[i];
|
||||
const value = row[i];
|
||||
if (!value?.text) {
|
||||
continue;
|
||||
}
|
||||
state.text += "• ";
|
||||
if (header?.text) {
|
||||
appendCell(state, header);
|
||||
state.text += ": ";
|
||||
} else {
|
||||
state.text += `Column ${i}: `;
|
||||
}
|
||||
appendCell(state, value);
|
||||
state.text += "\n";
|
||||
appendTableBulletValue(state, {
|
||||
header: headers[i],
|
||||
value: row[i],
|
||||
columnIndex: i,
|
||||
includeColumnFallback: true,
|
||||
});
|
||||
}
|
||||
state.text += "\n";
|
||||
}
|
||||
@@ -457,18 +473,12 @@ function renderTableAsBullets(state: RenderState) {
|
||||
// Simple table: just list headers and values
|
||||
for (const row of rows) {
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const header = headers[i];
|
||||
const value = row[i];
|
||||
if (!value?.text) {
|
||||
continue;
|
||||
}
|
||||
state.text += "• ";
|
||||
if (header?.text) {
|
||||
appendCell(state, header);
|
||||
state.text += ": ";
|
||||
}
|
||||
appendCell(state, value);
|
||||
state.text += "\n";
|
||||
appendTableBulletValue(state, {
|
||||
header: headers[i],
|
||||
value: row[i],
|
||||
columnIndex: i,
|
||||
includeColumnFallback: false,
|
||||
});
|
||||
}
|
||||
state.text += "\n";
|
||||
}
|
||||
@@ -813,6 +823,19 @@ function mergeStyleSpans(spans: MarkdownStyleSpan[]): MarkdownStyleSpan[] {
|
||||
return merged;
|
||||
}
|
||||
|
||||
function resolveSliceBounds(
|
||||
span: { start: number; end: number },
|
||||
start: number,
|
||||
end: number,
|
||||
): { start: number; end: number } | null {
|
||||
const sliceStart = Math.max(span.start, start);
|
||||
const sliceEnd = Math.min(span.end, end);
|
||||
if (sliceEnd <= sliceStart) {
|
||||
return null;
|
||||
}
|
||||
return { start: sliceStart, end: sliceEnd };
|
||||
}
|
||||
|
||||
function sliceStyleSpans(
|
||||
spans: MarkdownStyleSpan[],
|
||||
start: number,
|
||||
@@ -823,15 +846,15 @@ function sliceStyleSpans(
|
||||
}
|
||||
const sliced: MarkdownStyleSpan[] = [];
|
||||
for (const span of spans) {
|
||||
const sliceStart = Math.max(span.start, start);
|
||||
const sliceEnd = Math.min(span.end, end);
|
||||
if (sliceEnd > sliceStart) {
|
||||
sliced.push({
|
||||
start: sliceStart - start,
|
||||
end: sliceEnd - start,
|
||||
style: span.style,
|
||||
});
|
||||
const bounds = resolveSliceBounds(span, start, end);
|
||||
if (!bounds) {
|
||||
continue;
|
||||
}
|
||||
sliced.push({
|
||||
start: bounds.start - start,
|
||||
end: bounds.end - start,
|
||||
style: span.style,
|
||||
});
|
||||
}
|
||||
return mergeStyleSpans(sliced);
|
||||
}
|
||||
@@ -842,15 +865,15 @@ function sliceLinkSpans(spans: MarkdownLinkSpan[], start: number, end: number):
|
||||
}
|
||||
const sliced: MarkdownLinkSpan[] = [];
|
||||
for (const span of spans) {
|
||||
const sliceStart = Math.max(span.start, start);
|
||||
const sliceEnd = Math.min(span.end, end);
|
||||
if (sliceEnd > sliceStart) {
|
||||
sliced.push({
|
||||
start: sliceStart - start,
|
||||
end: sliceEnd - start,
|
||||
href: span.href,
|
||||
});
|
||||
const bounds = resolveSliceBounds(span, start, end);
|
||||
if (!bounds) {
|
||||
continue;
|
||||
}
|
||||
sliced.push({
|
||||
start: bounds.start - start,
|
||||
end: bounds.end - start,
|
||||
href: span.href,
|
||||
});
|
||||
}
|
||||
return sliced;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
normalizeMediaAttachments,
|
||||
runCapability,
|
||||
} from "./runner.js";
|
||||
import type { AudioTranscriptionRequest } from "./types.js";
|
||||
|
||||
async function withAudioFixture(params: {
|
||||
filePrefix: string;
|
||||
@@ -47,6 +48,41 @@ async function withAudioFixture(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const AUDIO_CAPABILITY_CFG = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: "test-key",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
async function runAudioCapabilityWithTranscriber(params: {
|
||||
ctx: MsgContext;
|
||||
media: ReturnType<typeof normalizeMediaAttachments>;
|
||||
cache: ReturnType<typeof createMediaAttachmentCache>;
|
||||
transcribeAudio: (req: AudioTranscriptionRequest) => Promise<{ text: string; model: string }>;
|
||||
}) {
|
||||
const providerRegistry = buildProviderRegistry({
|
||||
openai: {
|
||||
id: "openai",
|
||||
capabilities: ["audio"],
|
||||
transcribeAudio: params.transcribeAudio,
|
||||
},
|
||||
});
|
||||
|
||||
return await runCapability({
|
||||
capability: "audio",
|
||||
cfg: AUDIO_CAPABILITY_CFG,
|
||||
ctx: params.ctx,
|
||||
attachments: params.cache,
|
||||
media: params.media,
|
||||
providerRegistry,
|
||||
});
|
||||
}
|
||||
|
||||
describe("runCapability skips tiny audio files", () => {
|
||||
it("skips audio transcription when file is smaller than MIN_AUDIO_FILE_BYTES", async () => {
|
||||
await withAudioFixture({
|
||||
@@ -56,35 +92,14 @@ describe("runCapability skips tiny audio files", () => {
|
||||
fileContents: Buffer.alloc(100), // 100 bytes, way below 1024
|
||||
run: async ({ ctx, media, cache }) => {
|
||||
let transcribeCalled = false;
|
||||
const providerRegistry = buildProviderRegistry({
|
||||
openai: {
|
||||
id: "openai",
|
||||
capabilities: ["audio"],
|
||||
transcribeAudio: async (req) => {
|
||||
transcribeCalled = true;
|
||||
return { text: "should not happen", model: req.model };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: "test-key",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = await runCapability({
|
||||
capability: "audio",
|
||||
cfg,
|
||||
const result = await runAudioCapabilityWithTranscriber({
|
||||
ctx,
|
||||
attachments: cache,
|
||||
media,
|
||||
providerRegistry,
|
||||
cache,
|
||||
transcribeAudio: async (req) => {
|
||||
transcribeCalled = true;
|
||||
return { text: "should not happen", model: req.model };
|
||||
},
|
||||
});
|
||||
|
||||
// The provider should never be called
|
||||
@@ -109,35 +124,14 @@ describe("runCapability skips tiny audio files", () => {
|
||||
fileContents: Buffer.alloc(0),
|
||||
run: async ({ ctx, media, cache }) => {
|
||||
let transcribeCalled = false;
|
||||
const providerRegistry = buildProviderRegistry({
|
||||
openai: {
|
||||
id: "openai",
|
||||
capabilities: ["audio"],
|
||||
transcribeAudio: async () => {
|
||||
transcribeCalled = true;
|
||||
return { text: "nope", model: "whisper-1" };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: "test-key",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = await runCapability({
|
||||
capability: "audio",
|
||||
cfg,
|
||||
const result = await runAudioCapabilityWithTranscriber({
|
||||
ctx,
|
||||
attachments: cache,
|
||||
media,
|
||||
providerRegistry,
|
||||
cache,
|
||||
transcribeAudio: async () => {
|
||||
transcribeCalled = true;
|
||||
return { text: "nope", model: "whisper-1" };
|
||||
},
|
||||
});
|
||||
|
||||
expect(transcribeCalled).toBe(false);
|
||||
@@ -154,35 +148,14 @@ describe("runCapability skips tiny audio files", () => {
|
||||
fileContents: Buffer.alloc(MIN_AUDIO_FILE_BYTES + 100),
|
||||
run: async ({ ctx, media, cache }) => {
|
||||
let transcribeCalled = false;
|
||||
const providerRegistry = buildProviderRegistry({
|
||||
openai: {
|
||||
id: "openai",
|
||||
capabilities: ["audio"],
|
||||
transcribeAudio: async (req) => {
|
||||
transcribeCalled = true;
|
||||
return { text: "hello world", model: req.model };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: "test-key",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = await runCapability({
|
||||
capability: "audio",
|
||||
cfg,
|
||||
const result = await runAudioCapabilityWithTranscriber({
|
||||
ctx,
|
||||
attachments: cache,
|
||||
media,
|
||||
providerRegistry,
|
||||
cache,
|
||||
transcribeAudio: async (req) => {
|
||||
transcribeCalled = true;
|
||||
return { text: "hello world", model: req.model };
|
||||
},
|
||||
});
|
||||
|
||||
expect(transcribeCalled).toBe(true);
|
||||
|
||||
@@ -36,6 +36,29 @@ export const VOYAGE_BATCH_ENDPOINT = EMBEDDING_BATCH_ENDPOINT;
|
||||
const VOYAGE_BATCH_COMPLETION_WINDOW = "12h";
|
||||
const VOYAGE_BATCH_MAX_REQUESTS = 50000;
|
||||
|
||||
async function assertVoyageResponseOk(res: Response, context: string): Promise<void> {
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`${context}: ${res.status} ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildVoyageBatchRequest<T>(params: {
|
||||
client: VoyageEmbeddingClient;
|
||||
path: string;
|
||||
onResponse: (res: Response) => Promise<T>;
|
||||
}) {
|
||||
const baseUrl = normalizeBatchBaseUrl(params.client);
|
||||
return {
|
||||
url: `${baseUrl}/${params.path}`,
|
||||
ssrfPolicy: params.client.ssrfPolicy,
|
||||
init: {
|
||||
headers: buildBatchHeaders(params.client, { json: true }),
|
||||
},
|
||||
onResponse: params.onResponse,
|
||||
};
|
||||
}
|
||||
|
||||
async function submitVoyageBatch(params: {
|
||||
client: VoyageEmbeddingClient;
|
||||
requests: VoyageBatchRequest[];
|
||||
@@ -74,21 +97,16 @@ async function fetchVoyageBatchStatus(params: {
|
||||
client: VoyageEmbeddingClient;
|
||||
batchId: string;
|
||||
}): Promise<VoyageBatchStatus> {
|
||||
const baseUrl = normalizeBatchBaseUrl(params.client);
|
||||
return await withRemoteHttpResponse({
|
||||
url: `${baseUrl}/batches/${params.batchId}`,
|
||||
ssrfPolicy: params.client.ssrfPolicy,
|
||||
init: {
|
||||
headers: buildBatchHeaders(params.client, { json: true }),
|
||||
},
|
||||
onResponse: async (res) => {
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`voyage batch status failed: ${res.status} ${text}`);
|
||||
}
|
||||
return (await res.json()) as VoyageBatchStatus;
|
||||
},
|
||||
});
|
||||
return await withRemoteHttpResponse(
|
||||
buildVoyageBatchRequest({
|
||||
client: params.client,
|
||||
path: `batches/${params.batchId}`,
|
||||
onResponse: async (res) => {
|
||||
await assertVoyageResponseOk(res, "voyage batch status failed");
|
||||
return (await res.json()) as VoyageBatchStatus;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function readVoyageBatchError(params: {
|
||||
@@ -96,30 +114,25 @@ async function readVoyageBatchError(params: {
|
||||
errorFileId: string;
|
||||
}): Promise<string | undefined> {
|
||||
try {
|
||||
const baseUrl = normalizeBatchBaseUrl(params.client);
|
||||
return await withRemoteHttpResponse({
|
||||
url: `${baseUrl}/files/${params.errorFileId}/content`,
|
||||
ssrfPolicy: params.client.ssrfPolicy,
|
||||
init: {
|
||||
headers: buildBatchHeaders(params.client, { json: true }),
|
||||
},
|
||||
onResponse: async (res) => {
|
||||
if (!res.ok) {
|
||||
return await withRemoteHttpResponse(
|
||||
buildVoyageBatchRequest({
|
||||
client: params.client,
|
||||
path: `files/${params.errorFileId}/content`,
|
||||
onResponse: async (res) => {
|
||||
await assertVoyageResponseOk(res, "voyage batch error file content failed");
|
||||
const text = await res.text();
|
||||
throw new Error(`voyage batch error file content failed: ${res.status} ${text}`);
|
||||
}
|
||||
const text = await res.text();
|
||||
if (!text.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as VoyageBatchOutputLine);
|
||||
return extractBatchErrorMessage(lines);
|
||||
},
|
||||
});
|
||||
if (!text.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const lines = text
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as VoyageBatchOutputLine);
|
||||
return extractBatchErrorMessage(lines);
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
return formatUnavailableBatchError(err);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import os from "node:os";
|
||||
import { resolveGatewayPort } from "../config/paths.js";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
|
||||
import { isCarrierGradeNatIpv4Address, isRfc1918Ipv4Address } from "../shared/net/ip.js";
|
||||
import { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js";
|
||||
|
||||
const DEFAULT_GATEWAY_PORT = 18789;
|
||||
|
||||
export type PairingSetupPayload = {
|
||||
url: string;
|
||||
token?: string;
|
||||
@@ -89,21 +88,6 @@ function normalizeUrl(raw: string, schemeFallback: "ws" | "wss"): string | null
|
||||
return `${schemeFallback}://${withoutPath}`;
|
||||
}
|
||||
|
||||
function resolveGatewayPort(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): number {
|
||||
const envRaw = env.OPENCLAW_GATEWAY_PORT?.trim() || env.CLAWDBOT_GATEWAY_PORT?.trim();
|
||||
if (envRaw) {
|
||||
const parsed = Number.parseInt(envRaw, 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
const configPort = cfg.gateway?.port;
|
||||
if (typeof configPort === "number" && Number.isFinite(configPort) && configPort > 0) {
|
||||
return configPort;
|
||||
}
|
||||
return DEFAULT_GATEWAY_PORT;
|
||||
}
|
||||
|
||||
function resolveScheme(
|
||||
cfg: OpenClawConfig,
|
||||
opts?: {
|
||||
|
||||
@@ -555,6 +555,18 @@ describe("installPluginFromArchive", () => {
|
||||
});
|
||||
|
||||
describe("installPluginFromDir", () => {
|
||||
function expectInstalledAsMemoryCognee(
|
||||
result: Awaited<ReturnType<typeof installPluginFromDir>>,
|
||||
extensionsDir: string,
|
||||
) {
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
expect(result.pluginId).toBe("memory-cognee");
|
||||
expect(result.targetDir).toBe(path.join(extensionsDir, "memory-cognee"));
|
||||
}
|
||||
|
||||
it("uses --ignore-scripts for dependency install", async () => {
|
||||
const { pluginDir, extensionsDir } = setupInstallPluginFromDirFixture();
|
||||
|
||||
@@ -617,12 +629,7 @@ describe("installPluginFromDir", () => {
|
||||
logger: { info: (msg: string) => infoMessages.push(msg), warn: () => {} },
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
expect(res.pluginId).toBe("memory-cognee");
|
||||
expect(res.targetDir).toBe(path.join(extensionsDir, "memory-cognee"));
|
||||
expectInstalledAsMemoryCognee(res, extensionsDir);
|
||||
expect(
|
||||
infoMessages.some((msg) =>
|
||||
msg.includes(
|
||||
@@ -644,12 +651,7 @@ describe("installPluginFromDir", () => {
|
||||
logger: { info: () => {}, warn: () => {} },
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
expect(res.pluginId).toBe("memory-cognee");
|
||||
expect(res.targetDir).toBe(path.join(extensionsDir, "memory-cognee"));
|
||||
expectInstalledAsMemoryCognee(res, extensionsDir);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -147,6 +147,42 @@ function buildFileInstallResult(pluginId: string, targetFile: string): InstallPl
|
||||
};
|
||||
}
|
||||
|
||||
type PackageInstallCommonParams = {
|
||||
extensionsDir?: string;
|
||||
timeoutMs?: number;
|
||||
logger?: PluginInstallLogger;
|
||||
mode?: "install" | "update";
|
||||
dryRun?: boolean;
|
||||
expectedPluginId?: string;
|
||||
};
|
||||
|
||||
type FileInstallCommonParams = Pick<
|
||||
PackageInstallCommonParams,
|
||||
"extensionsDir" | "logger" | "mode" | "dryRun"
|
||||
>;
|
||||
|
||||
function pickPackageInstallCommonParams(
|
||||
params: PackageInstallCommonParams,
|
||||
): PackageInstallCommonParams {
|
||||
return {
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
logger: params.logger,
|
||||
mode: params.mode,
|
||||
dryRun: params.dryRun,
|
||||
expectedPluginId: params.expectedPluginId,
|
||||
};
|
||||
}
|
||||
|
||||
function pickFileInstallCommonParams(params: FileInstallCommonParams): FileInstallCommonParams {
|
||||
return {
|
||||
extensionsDir: params.extensionsDir,
|
||||
logger: params.logger,
|
||||
mode: params.mode,
|
||||
dryRun: params.dryRun,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string): string {
|
||||
const extensionsBase = extensionsDir
|
||||
? resolveUserPath(extensionsDir)
|
||||
@@ -166,15 +202,11 @@ export function resolvePluginInstallDir(pluginId: string, extensionsDir?: string
|
||||
return targetDirResult.path;
|
||||
}
|
||||
|
||||
async function installPluginFromPackageDir(params: {
|
||||
packageDir: string;
|
||||
extensionsDir?: string;
|
||||
timeoutMs?: number;
|
||||
logger?: PluginInstallLogger;
|
||||
mode?: "install" | "update";
|
||||
dryRun?: boolean;
|
||||
expectedPluginId?: string;
|
||||
}): Promise<InstallPluginResult> {
|
||||
async function installPluginFromPackageDir(
|
||||
params: {
|
||||
packageDir: string;
|
||||
} & PackageInstallCommonParams,
|
||||
): Promise<InstallPluginResult> {
|
||||
const { logger, timeoutMs, mode, dryRun } = resolveTimedInstallModeOptions(params, defaultLogger);
|
||||
|
||||
const manifestPath = path.join(params.packageDir, "package.json");
|
||||
@@ -344,15 +376,11 @@ async function installPluginFromPackageDir(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function installPluginFromArchive(params: {
|
||||
archivePath: string;
|
||||
extensionsDir?: string;
|
||||
timeoutMs?: number;
|
||||
logger?: PluginInstallLogger;
|
||||
mode?: "install" | "update";
|
||||
dryRun?: boolean;
|
||||
expectedPluginId?: string;
|
||||
}): Promise<InstallPluginResult> {
|
||||
export async function installPluginFromArchive(
|
||||
params: {
|
||||
archivePath: string;
|
||||
} & PackageInstallCommonParams,
|
||||
): Promise<InstallPluginResult> {
|
||||
const logger = params.logger ?? defaultLogger;
|
||||
const timeoutMs = params.timeoutMs ?? 120_000;
|
||||
const mode = params.mode ?? "install";
|
||||
@@ -370,25 +398,23 @@ export async function installPluginFromArchive(params: {
|
||||
onExtracted: async (packageDir) =>
|
||||
await installPluginFromPackageDir({
|
||||
packageDir,
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs,
|
||||
logger,
|
||||
mode,
|
||||
dryRun: params.dryRun,
|
||||
expectedPluginId: params.expectedPluginId,
|
||||
...pickPackageInstallCommonParams({
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs,
|
||||
logger,
|
||||
mode,
|
||||
dryRun: params.dryRun,
|
||||
expectedPluginId: params.expectedPluginId,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function installPluginFromDir(params: {
|
||||
dirPath: string;
|
||||
extensionsDir?: string;
|
||||
timeoutMs?: number;
|
||||
logger?: PluginInstallLogger;
|
||||
mode?: "install" | "update";
|
||||
dryRun?: boolean;
|
||||
expectedPluginId?: string;
|
||||
}): Promise<InstallPluginResult> {
|
||||
export async function installPluginFromDir(
|
||||
params: {
|
||||
dirPath: string;
|
||||
} & PackageInstallCommonParams,
|
||||
): Promise<InstallPluginResult> {
|
||||
const dirPath = resolveUserPath(params.dirPath);
|
||||
if (!(await fileExists(dirPath))) {
|
||||
return { ok: false, error: `directory not found: ${dirPath}` };
|
||||
@@ -400,12 +426,7 @@ export async function installPluginFromDir(params: {
|
||||
|
||||
return await installPluginFromPackageDir({
|
||||
packageDir: dirPath,
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
logger: params.logger,
|
||||
mode: params.mode,
|
||||
dryRun: params.dryRun,
|
||||
expectedPluginId: params.expectedPluginId,
|
||||
...pickPackageInstallCommonParams(params),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -517,30 +538,22 @@ export async function installPluginFromNpmSpec(params: {
|
||||
return finalized;
|
||||
}
|
||||
|
||||
export async function installPluginFromPath(params: {
|
||||
path: string;
|
||||
extensionsDir?: string;
|
||||
timeoutMs?: number;
|
||||
logger?: PluginInstallLogger;
|
||||
mode?: "install" | "update";
|
||||
dryRun?: boolean;
|
||||
expectedPluginId?: string;
|
||||
}): Promise<InstallPluginResult> {
|
||||
export async function installPluginFromPath(
|
||||
params: {
|
||||
path: string;
|
||||
} & PackageInstallCommonParams,
|
||||
): Promise<InstallPluginResult> {
|
||||
const pathResult = await resolveExistingInstallPath(params.path);
|
||||
if (!pathResult.ok) {
|
||||
return pathResult;
|
||||
}
|
||||
const { resolvedPath: resolved, stat } = pathResult;
|
||||
const packageInstallOptions = pickPackageInstallCommonParams(params);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return await installPluginFromDir({
|
||||
dirPath: resolved,
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
logger: params.logger,
|
||||
mode: params.mode,
|
||||
dryRun: params.dryRun,
|
||||
expectedPluginId: params.expectedPluginId,
|
||||
...packageInstallOptions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -548,20 +561,12 @@ export async function installPluginFromPath(params: {
|
||||
if (archiveKind) {
|
||||
return await installPluginFromArchive({
|
||||
archivePath: resolved,
|
||||
extensionsDir: params.extensionsDir,
|
||||
timeoutMs: params.timeoutMs,
|
||||
logger: params.logger,
|
||||
mode: params.mode,
|
||||
dryRun: params.dryRun,
|
||||
expectedPluginId: params.expectedPluginId,
|
||||
...packageInstallOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return await installPluginFromFile({
|
||||
filePath: resolved,
|
||||
extensionsDir: params.extensionsDir,
|
||||
logger: params.logger,
|
||||
mode: params.mode,
|
||||
dryRun: params.dryRun,
|
||||
...pickFileInstallCommonParams(params),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,6 +17,43 @@ describe("security/dm-policy-shared", () => {
|
||||
hasControlCommand: true,
|
||||
} as const;
|
||||
|
||||
async function expectStoreReadSkipped(params: {
|
||||
provider: string;
|
||||
accountId: string;
|
||||
dmPolicy?: "open" | "allowlist" | "pairing" | "disabled";
|
||||
shouldRead?: boolean;
|
||||
}) {
|
||||
let called = false;
|
||||
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
|
||||
provider: params.provider,
|
||||
accountId: params.accountId,
|
||||
...(params.dmPolicy ? { dmPolicy: params.dmPolicy } : {}),
|
||||
...(params.shouldRead !== undefined ? { shouldRead: params.shouldRead } : {}),
|
||||
readStore: async (_provider, _accountId) => {
|
||||
called = true;
|
||||
return ["should-not-be-read"];
|
||||
},
|
||||
});
|
||||
expect(called).toBe(false);
|
||||
expect(storeAllowFrom).toEqual([]);
|
||||
}
|
||||
|
||||
function resolveCommandGate(overrides: {
|
||||
isGroup: boolean;
|
||||
isSenderAllowed: (allowFrom: string[]) => boolean;
|
||||
groupPolicy?: "open" | "allowlist" | "disabled";
|
||||
}) {
|
||||
return resolveDmGroupAccessWithCommandGate({
|
||||
dmPolicy: "pairing",
|
||||
groupPolicy: overrides.groupPolicy ?? "allowlist",
|
||||
allowFrom: ["owner"],
|
||||
groupAllowFrom: ["group-owner"],
|
||||
storeAllowFrom: ["paired-user"],
|
||||
command: controlCommand,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
it("normalizes config + store allow entries and counts distinct senders", async () => {
|
||||
const state = await resolveDmAllowState({
|
||||
provider: "telegram",
|
||||
@@ -47,33 +84,19 @@ describe("security/dm-policy-shared", () => {
|
||||
});
|
||||
|
||||
it("skips pairing-store reads when dmPolicy is allowlist", async () => {
|
||||
let called = false;
|
||||
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
|
||||
await expectStoreReadSkipped({
|
||||
provider: "telegram",
|
||||
accountId: "default",
|
||||
dmPolicy: "allowlist",
|
||||
readStore: async (_provider, _accountId) => {
|
||||
called = true;
|
||||
return ["should-not-be-read"];
|
||||
},
|
||||
});
|
||||
expect(called).toBe(false);
|
||||
expect(storeAllowFrom).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips pairing-store reads when shouldRead=false", async () => {
|
||||
let called = false;
|
||||
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
|
||||
await expectStoreReadSkipped({
|
||||
provider: "slack",
|
||||
accountId: "default",
|
||||
shouldRead: false,
|
||||
readStore: async (_provider, _accountId) => {
|
||||
called = true;
|
||||
return ["should-not-be-read"];
|
||||
},
|
||||
});
|
||||
expect(called).toBe(false);
|
||||
expect(storeAllowFrom).toEqual([]);
|
||||
});
|
||||
|
||||
it("builds effective DM/group allowlists from config + pairing store", () => {
|
||||
@@ -184,15 +207,9 @@ describe("security/dm-policy-shared", () => {
|
||||
});
|
||||
|
||||
it("resolves command gate with dm/group parity for groups", () => {
|
||||
const resolved = resolveDmGroupAccessWithCommandGate({
|
||||
const resolved = resolveCommandGate({
|
||||
isGroup: true,
|
||||
dmPolicy: "pairing",
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["owner"],
|
||||
groupAllowFrom: ["group-owner"],
|
||||
storeAllowFrom: ["paired-user"],
|
||||
isSenderAllowed: (allowFrom) => allowFrom.includes("paired-user"),
|
||||
command: controlCommand,
|
||||
});
|
||||
expect(resolved.decision).toBe("block");
|
||||
expect(resolved.reason).toBe("groupPolicy=allowlist (not allowlisted)");
|
||||
@@ -216,15 +233,9 @@ describe("security/dm-policy-shared", () => {
|
||||
});
|
||||
|
||||
it("treats dm command authorization as dm access result", () => {
|
||||
const resolved = resolveDmGroupAccessWithCommandGate({
|
||||
const resolved = resolveCommandGate({
|
||||
isGroup: false,
|
||||
dmPolicy: "pairing",
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["owner"],
|
||||
groupAllowFrom: ["group-owner"],
|
||||
storeAllowFrom: ["paired-user"],
|
||||
isSenderAllowed: (allowFrom) => allowFrom.includes("paired-user"),
|
||||
command: controlCommand,
|
||||
});
|
||||
expect(resolved.decision).toBe("allow");
|
||||
expect(resolved.commandAuthorized).toBe(true);
|
||||
@@ -274,80 +285,83 @@ describe("security/dm-policy-shared", () => {
|
||||
"zalo",
|
||||
] as const;
|
||||
|
||||
type ParityCase = {
|
||||
name: string;
|
||||
isGroup: boolean;
|
||||
dmPolicy: "open" | "allowlist" | "pairing" | "disabled";
|
||||
groupPolicy: "open" | "allowlist" | "disabled";
|
||||
allowFrom: string[];
|
||||
groupAllowFrom: string[];
|
||||
storeAllowFrom: string[];
|
||||
isSenderAllowed: (allowFrom: string[]) => boolean;
|
||||
expectedDecision: "allow" | "block" | "pairing";
|
||||
expectedReactionAllowed: boolean;
|
||||
};
|
||||
|
||||
function createParityCase(overrides: Partial<ParityCase> & Pick<ParityCase, "name">): ParityCase {
|
||||
return {
|
||||
name: overrides.name,
|
||||
isGroup: false,
|
||||
dmPolicy: "open",
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
storeAllowFrom: [],
|
||||
isSenderAllowed: () => false,
|
||||
expectedDecision: "allow",
|
||||
expectedReactionAllowed: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("keeps message/reaction policy parity table across channels", () => {
|
||||
const cases = [
|
||||
{
|
||||
createParityCase({
|
||||
name: "dmPolicy=open",
|
||||
isGroup: false,
|
||||
dmPolicy: "open" as const,
|
||||
groupPolicy: "allowlist" as const,
|
||||
allowFrom: [] as string[],
|
||||
groupAllowFrom: [] as string[],
|
||||
storeAllowFrom: [] as string[],
|
||||
isSenderAllowed: () => false,
|
||||
expectedDecision: "allow" as const,
|
||||
dmPolicy: "open",
|
||||
expectedDecision: "allow",
|
||||
expectedReactionAllowed: true,
|
||||
},
|
||||
{
|
||||
}),
|
||||
createParityCase({
|
||||
name: "dmPolicy=disabled",
|
||||
isGroup: false,
|
||||
dmPolicy: "disabled" as const,
|
||||
groupPolicy: "allowlist" as const,
|
||||
allowFrom: [] as string[],
|
||||
groupAllowFrom: [] as string[],
|
||||
storeAllowFrom: [] as string[],
|
||||
isSenderAllowed: () => false,
|
||||
expectedDecision: "block" as const,
|
||||
dmPolicy: "disabled",
|
||||
expectedDecision: "block",
|
||||
expectedReactionAllowed: false,
|
||||
},
|
||||
{
|
||||
}),
|
||||
createParityCase({
|
||||
name: "dmPolicy=allowlist unauthorized",
|
||||
isGroup: false,
|
||||
dmPolicy: "allowlist" as const,
|
||||
groupPolicy: "allowlist" as const,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["owner"],
|
||||
groupAllowFrom: [] as string[],
|
||||
storeAllowFrom: [] as string[],
|
||||
isSenderAllowed: () => false,
|
||||
expectedDecision: "block" as const,
|
||||
expectedDecision: "block",
|
||||
expectedReactionAllowed: false,
|
||||
},
|
||||
{
|
||||
}),
|
||||
createParityCase({
|
||||
name: "dmPolicy=allowlist authorized",
|
||||
isGroup: false,
|
||||
dmPolicy: "allowlist" as const,
|
||||
groupPolicy: "allowlist" as const,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["owner"],
|
||||
groupAllowFrom: [] as string[],
|
||||
storeAllowFrom: [] as string[],
|
||||
isSenderAllowed: () => true,
|
||||
expectedDecision: "allow" as const,
|
||||
expectedDecision: "allow",
|
||||
expectedReactionAllowed: true,
|
||||
},
|
||||
{
|
||||
}),
|
||||
createParityCase({
|
||||
name: "dmPolicy=pairing unauthorized",
|
||||
isGroup: false,
|
||||
dmPolicy: "pairing" as const,
|
||||
groupPolicy: "allowlist" as const,
|
||||
allowFrom: [] as string[],
|
||||
groupAllowFrom: [] as string[],
|
||||
storeAllowFrom: [] as string[],
|
||||
dmPolicy: "pairing",
|
||||
isSenderAllowed: () => false,
|
||||
expectedDecision: "pairing" as const,
|
||||
expectedDecision: "pairing",
|
||||
expectedReactionAllowed: false,
|
||||
},
|
||||
{
|
||||
}),
|
||||
createParityCase({
|
||||
name: "groupPolicy=allowlist rejects DM-paired sender not in explicit group list",
|
||||
isGroup: true,
|
||||
dmPolicy: "pairing" as const,
|
||||
groupPolicy: "allowlist" as const,
|
||||
allowFrom: ["owner"] as string[],
|
||||
groupAllowFrom: ["group-owner"] as string[],
|
||||
storeAllowFrom: ["paired-user"] as string[],
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["owner"],
|
||||
groupAllowFrom: ["group-owner"],
|
||||
storeAllowFrom: ["paired-user"],
|
||||
isSenderAllowed: (allowFrom: string[]) => allowFrom.includes("paired-user"),
|
||||
expectedDecision: "block" as const,
|
||||
expectedDecision: "block",
|
||||
expectedReactionAllowed: false,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
for (const channel of channels) {
|
||||
|
||||
Reference in New Issue
Block a user