mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-12 20:42:55 +00:00
refactor: dedupe gateway config and infra flows
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user