Compare commits

..

1 Commits

Author SHA1 Message Date
liuxiaopai-ai
32e4558e4f Config: newline-join sandbox setupCommand arrays 2026-03-02 18:10:31 +00:00
84 changed files with 309 additions and 1698 deletions

View File

@@ -40,34 +40,13 @@ Docs: https://docs.openclaw.ai
### Breaking
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
- **BREAKING:** Onboarding now defaults `tools.profile` to `messaging` for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
### Fixes
- Gateway/Heartbeat model reload: treat `models.*` and `agents.defaults.model` config updates as heartbeat hot-reload triggers so heartbeat picks up model changes without a full gateway restart. (#32046) Thanks @stakeswky.
- Slack/inbound debounce routing: isolate top-level non-DM message debounce keys by message timestamp to avoid cross-thread collisions, preserve DM batching, and flush pending top-level buffers before immediate non-debounce follow-ups to keep ordering stable. (#31951) Thanks @scoootscooob.
- OpenRouter/x-ai compatibility: skip `reasoning.effort` injection for `x-ai/*` models (for example Grok) so OpenRouter requests no longer fail with invalid-arguments errors on unsupported reasoning params. (#32054) Thanks @scoootscooob.
- Auth/provider normalization: map `volcengine-plan` and `byteplus-plan` to base providers only for auth-profile matching so coding-plan defaults resolve stored credentials without changing model/provider routing semantics. (#31821) Thanks @justinhuangcode.
- Memory/LanceDB embeddings: forward configured `embedding.dimensions` into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.
- Mentions/Slack formatting hardening: add null-safe guards for runtime text normalization paths so malformed/undefined text payloads do not crash mention stripping or mrkdwn conversion. (#31865) Thanks @stone-jin.
- Failover/error classification: treat HTTP `529` (provider overloaded, common with Anthropic-compatible APIs) as `rate_limit` so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.
- Voice-call/webhook routing: require exact webhook path matches (instead of prefix matches) so lookalike paths cannot reach provider verification/dispatch logic. (#31930) Thanks @afurm.
- Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (`trim` on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.
- Web UI/config form: support SecretInput string-or-secret-ref unions in map `additionalProperties`, so provider API key fields stay editable instead of being marked unsupported. (#31866) Thanks @ningding97.
- Slack/Bolt startup compatibility: remove invalid `message.channels` and `message.groups` event registrations so Slack providers no longer crash on startup with Bolt 4.6+; channel/group traffic continues through the unified `message` handler (`channel_type`). (#32033) Thanks @mahopan.
- Telegram: guard duplicate-token checks and gateway startup token normalization when account tokens are missing, preventing `token.trim()` crashes during status/start flows. (#31973) Thanks @ningding97.
- Skills/sherpa-onnx-tts: run the `sherpa-onnx-tts` bin under ESM (replace CommonJS `require` imports) and add regression coverage to prevent `require is not defined in ES module scope` startup crashes. (#31965) Thanks @bmendonca3.
- Browser/default profile selection: default `browser.defaultProfile` behavior now prefers `openclaw` (managed standalone CDP) when no explicit default is configured, while still auto-provisioning the `chrome` relay profile for explicit opt-in use. (#32031) Fixes #31907. Thanks @liuxiaopai-ai.
- Doctor/local memory provider checks: stop false-positive local-provider warnings when `provider=local` and no explicit `modelPath` is set by honoring default local model fallback while still warning when gateway probe reports local embeddings not ready. (#32014) Fixes #31998. Thanks @adhishthite.
- Feishu/Run channel fallback: prefer `Provider` over `Surface` when inferring queued run `messageProvider` fallback (when `OriginatingChannel` is missing), preventing Feishu turns from being mislabeled as `webchat` in mixed relay metadata contexts. (#31880) Fixes #31859. Thanks @liuxiaopai-ai.
- Cron/session reaper reliability: move cron session reaper sweeps into `onTimer` `finally` and keep pruning active even when timer ticks fail early (for example cron store parse failures), preventing stale isolated run sessions from accumulating indefinitely. (#31996) Fixes #31946. Thanks @scoootscooob.
- Inbound metadata/direct relay context: restore direct-channel conversation metadata blocks for external channels (for example WhatsApp) while preserving webchat-direct suppression, so relay agents recover sender/message identifiers without reintroducing internal webchat metadata noise. (#31969) Fixes #29972. Thanks @Lucenx9.
- Sandbox/Docker setup command parsing: accept `agents.*.sandbox.docker.setupCommand` as either a string or a string array, and normalize arrays to newline-delimited shell scripts so multi-step setup commands no longer concatenate without separators. (#31953) Thanks @liuxiaopai-ai.
- Gateway/Plugin HTTP route precedence: run explicit plugin HTTP routes before the Control UI SPA catch-all so registered plugin webhook/custom paths remain reachable, while unmatched paths still fall through to Control UI handling. (#31885) Thanks @Sid-Qin.
- macOS/LaunchAgent security defaults: write `Umask=63` (octal `077`) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system `022`. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.
- Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example `env sh -c ...`) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.
- Sandbox/Bootstrap context boundary hardening: reject symlink/hardlink alias bootstrap seed files that resolve outside the source workspace and switch post-compaction `AGENTS.md` context reads to boundary-verified file opens, preventing host file content from being injected via workspace aliasing. Thanks @tdjackey for reporting.
- Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.
@@ -163,7 +142,6 @@ Docs: https://docs.openclaw.ai
- Plugins/Install: clear stale install errors when an npm package is not found so follow-up install attempts report current state correctly. (#25073) Thanks @dalefrieswthat.
- Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3.
- Gateway/macOS supervised restart: actively `launchctl kickstart -k` during intentional supervised restarts to bypass LaunchAgent `ThrottleInterval` delays, and fall back to in-process restart when kickstart fails. Landed from contributor PR #29078 by @cathrynlavery. Thanks @cathrynlavery.
- Gateway/macOS LaunchAgent hardening: write `Umask=077` in generated gateway LaunchAgent plists so npm upgrades preserve owner-only default file permissions for gateway-created state files. (#31919) Fixes #31905. Thanks @liuxiaopai-ai.
- Daemon/macOS TLS certs: default LaunchAgent service env `NODE_EXTRA_CA_CERTS` to `/etc/ssl/cert.pem` (while preserving explicit overrides) so HTTPS clients no longer fail with local-issuer errors under launchd. (#27915) Thanks @Lukavyi.
- Discord/Components wildcard handlers: use distinct internal registration sentinel IDs and parse those sentinels as wildcard keys so select/user/role/channel/mentionable/modal interactions are not dropped by raw customId dedupe paths. Landed from contributor PR #29459 by @Sid-Qin. Thanks @Sid-Qin.
- Feishu/Reaction notifications: add `channels.feishu.reactionNotifications` (`off | own | all`, default `own`) so operators can disable reaction ingress or allow all verified reaction events (not only bot-authored message reactions). (#28529) Thanks @cowboy129.

View File

@@ -149,8 +149,6 @@ OpenClaw's security model is "personal assistant" (one trusted operator, potenti
- The model/agent is **not** a trusted principal. Assume prompt/content injection can manipulate behavior.
- Security boundaries come from host/config trust, auth, tool policy, sandboxing, and exec approvals.
- Prompt injection by itself is not a vulnerability report unless it crosses one of those boundaries.
- Hook/webhook-driven payloads should be treated as untrusted content; keep unsafe bypass flags disabled unless doing tightly scoped debugging (`hooks.gmail.allowUnsafeExternalContent`, `hooks.mappings[].allowUnsafeExternalContent`).
- Weak model tiers are generally easier to prompt-inject. For tool-enabled or hook-driven agents, prefer strong modern model tiers and strict tool policy (for example `tools.profile: "messaging"` or stricter), plus sandboxing where possible.
## Gateway and Node trust concept

View File

@@ -1587,8 +1587,6 @@ Defaults for Talk mode (macOS/iOS/Android).
`tools.profile` sets a base allowlist before `tools.allow`/`tools.deny`:
Local onboarding defaults new local configs to `tools.profile: "messaging"` when unset (existing explicit profiles are preserved).
| Profile | Includes |
| ----------- | ----------------------------------------------------------------------------------------- |
| `minimal` | `session_status` only |

View File

@@ -291,11 +291,6 @@ When validation fails:
}
```
Security note:
- Treat all hook/webhook payload content as untrusted input.
- Keep unsafe-content bypass flags disabled (`hooks.gmail.allowUnsafeExternalContent`, `hooks.mappings[].allowUnsafeExternalContent`) unless doing tightly scoped debugging.
- For hook-driven agents, prefer strong modern model tiers and strict tool policy (for example messaging-only plus sandboxing where possible).
See [full reference](/gateway/configuration-reference#hooks) for all mapping options and Gmail integration.
</Accordion>

View File

@@ -538,11 +538,6 @@ Guidance:
- Only enable temporarily for tightly scoped debugging.
- If enabled, isolate that agent (sandbox + minimal tools + dedicated session namespace).
Hooks risk note:
- Hook payloads are untrusted content, even when delivery comes from systems you control (mail/docs/web content can carry prompt injection).
- Weak model tiers increase this risk. For hook-driven automation, prefer strong modern model tiers and keep tool policy tight (`tools.profile: "messaging"` or stricter), plus sandboxing where possible.
### Prompt injection does not require public DMs
Even if **only you** can message the bot, prompt injection can still happen via

View File

@@ -245,7 +245,6 @@ Typical fields in `~/.openclaw/openclaw.json`:
- `agents.defaults.workspace`
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
- `tools.profile` (local onboarding defaults to `"messaging"` when unset; existing explicit values are preserved)
- `gateway.*` (mode, bind, auth, tailscale)
- `session.dmScope` (behavior details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals))
- `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*`

View File

@@ -34,8 +34,6 @@ Security trust model:
- By default, OpenClaw is a personal agent: one trusted operator boundary.
- Shared/multi-user setups require lock-down (split trust boundaries, keep tool access minimal, and follow [Security](/gateway/security)).
- Local onboarding now defaults new configs to `tools.profile: "messaging"` so broad runtime/filesystem tools are opt-in.
- If hooks/webhooks or other untrusted content feeds are enabled, use a strong modern model tier and keep strict tool policy/sandboxing.
</Step>
<Step title="Local vs Remote">

View File

@@ -236,7 +236,6 @@ Typical fields in `~/.openclaw/openclaw.json`:
- `agents.defaults.workspace`
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
- `tools.profile` (local onboarding defaults to `"messaging"` when unset; existing explicit values are preserved)
- `gateway.*` (mode, bind, auth, tailscale)
- `session.dmScope` (local onboarding defaults this to `per-channel-peer` when unset; existing explicit values are preserved)
- `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*`

View File

@@ -50,7 +50,6 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
- Workspace default (or existing workspace)
- Gateway port **18789**
- Gateway auth **Token** (autogenerated, even on loopback)
- Tool policy default for new local setups: `tools.profile: "messaging"` (existing explicit profile is preserved)
- DM isolation default: local onboarding writes `session.dmScope: "per-channel-peer"` when unset. Details: [CLI Onboarding Reference](/start/wizard-cli-reference#outputs-and-internals)
- Tailscale exposure **Off**
- Telegram + WhatsApp DMs default to **allowlist** (you'll be prompted for your phone number)
@@ -66,7 +65,6 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
1. **Model/Auth** — Anthropic API key (recommended), OpenAI, or Custom Provider
(OpenAI-compatible, Anthropic-compatible, or Unknown auto-detect). Pick a default model.
Security note: if this agent will run tools or process webhook/hooks content, prefer a strong modern model tier and keep tool policy strict. Weaker model tiers are easier to prompt-inject.
For non-interactive runs, `--secret-input-mode ref` stores env-backed refs in auth profiles instead of plaintext API key values.
In non-interactive `ref` mode, the provider env var must be set; passing inline key flags without that env var fails fast.
In interactive runs, choosing secret reference mode lets you point at either an environment variable or a configured provider ref (`file` or `exec`), with a fast preflight validation before saving.

View File

@@ -97,7 +97,7 @@ Notes:
- `browser.ssrfPolicy.allowPrivateNetwork` remains supported as a legacy alias for compatibility.
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
- `color` + per-profile `color` tint the browser UI so you can see which profile is active.
- Default profile is `openclaw` (OpenClaw-managed standalone browser). Use `defaultProfile: "chrome"` to opt into the Chrome extension relay.
- Default profile is `chrome` (extension relay). Use `defaultProfile: "openclaw"` for the managed browser.
- Auto-detect order: system default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
- Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP.

View File

@@ -11,7 +11,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import { describe, test, expect, beforeEach, afterEach } from "vitest";
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "test-key";
const HAS_OPENAI_KEY = Boolean(process.env.OPENAI_API_KEY);
@@ -135,89 +135,6 @@ describe("memory plugin e2e", () => {
expect(config?.autoRecall).toBe(true);
});
test("passes configured dimensions to OpenAI embeddings API", async () => {
const embeddingsCreate = vi.fn(async () => ({
data: [{ embedding: [0.1, 0.2, 0.3] }],
}));
const toArray = vi.fn(async () => []);
const limit = vi.fn(() => ({ toArray }));
const vectorSearch = vi.fn(() => ({ limit }));
vi.resetModules();
vi.doMock("openai", () => ({
default: class MockOpenAI {
embeddings = { create: embeddingsCreate };
},
}));
vi.doMock("@lancedb/lancedb", () => ({
connect: vi.fn(async () => ({
tableNames: vi.fn(async () => ["memories"]),
openTable: vi.fn(async () => ({
vectorSearch,
countRows: vi.fn(async () => 0),
add: vi.fn(async () => undefined),
delete: vi.fn(async () => undefined),
})),
})),
}));
try {
const { default: memoryPlugin } = await import("./index.js");
// oxlint-disable-next-line typescript/no-explicit-any
const registeredTools: any[] = [];
const mockApi = {
id: "memory-lancedb",
name: "Memory (LanceDB)",
source: "test",
config: {},
pluginConfig: {
embedding: {
apiKey: OPENAI_API_KEY,
model: "text-embedding-3-small",
dimensions: 1024,
},
dbPath,
autoCapture: false,
autoRecall: false,
},
runtime: {},
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
// oxlint-disable-next-line typescript/no-explicit-any
registerTool: (tool: any, opts: any) => {
registeredTools.push({ tool, opts });
},
// oxlint-disable-next-line typescript/no-explicit-any
registerCli: vi.fn(),
// oxlint-disable-next-line typescript/no-explicit-any
registerService: vi.fn(),
// oxlint-disable-next-line typescript/no-explicit-any
on: vi.fn(),
resolvePath: (p: string) => p,
};
// oxlint-disable-next-line typescript/no-explicit-any
memoryPlugin.register(mockApi as any);
const recallTool = registeredTools.find((t) => t.opts?.name === "memory_recall")?.tool;
expect(recallTool).toBeDefined();
await recallTool.execute("test-call-dims", { query: "hello dimensions" });
expect(embeddingsCreate).toHaveBeenCalledWith({
model: "text-embedding-3-small",
input: "hello dimensions",
dimensions: 1024,
});
} finally {
vi.doUnmock("openai");
vi.doUnmock("@lancedb/lancedb");
vi.resetModules();
}
});
test("shouldCapture applies real capture rules", async () => {
const { shouldCapture } = await import("./index.js");

View File

@@ -167,20 +167,15 @@ class Embeddings {
apiKey: string,
private model: string,
baseUrl?: string,
private dimensions?: number,
) {
this.client = new OpenAI({ apiKey, baseURL: baseUrl });
}
async embed(text: string): Promise<number[]> {
const params: { model: string; input: string; dimensions?: number } = {
const response = await this.client.embeddings.create({
model: this.model,
input: text,
};
if (this.dimensions) {
params.dimensions = this.dimensions;
}
const response = await this.client.embeddings.create(params);
});
return response.data[0].embedding;
}
}
@@ -303,7 +298,7 @@ const memoryPlugin = {
const vectorDim = dimensions ?? vectorDimsForModel(model);
const db = new MemoryDB(resolvedDbPath, vectorDim);
const embeddings = new Embeddings(apiKey, model, baseUrl, dimensions);
const embeddings = new Embeddings(apiKey, model, baseUrl);
api.logger.info(`memory-lancedb: plugin registered (db: ${resolvedDbPath}, lazy init)`);

View File

@@ -182,47 +182,4 @@ describe("telegramPlugin duplicate token guard", () => {
);
expect(result).toMatchObject({ channel: "telegram", messageId: "tg-1" });
});
it("ignores accounts with missing tokens during duplicate-token checks", async () => {
const cfg = createCfg();
cfg.channels!.telegram!.accounts!.ops = {} as never;
const alertsAccount = telegramPlugin.config.resolveAccount(cfg, "alerts");
expect(await telegramPlugin.config.isConfigured!(alertsAccount, cfg)).toBe(true);
});
it("does not crash startup when a resolved account token is undefined", async () => {
const monitorTelegramProvider = vi.fn(async () => undefined);
const probeTelegram = vi.fn(async () => ({ ok: false }));
const runtime = {
channel: {
telegram: {
monitorTelegramProvider,
probeTelegram,
},
},
logging: {
shouldLogVerbose: () => false,
},
} as unknown as PluginRuntime;
setTelegramRuntime(runtime);
const cfg = createCfg();
const ctx = createStartAccountCtx({
cfg,
accountId: "ops",
runtime: createRuntimeEnv(),
});
ctx.account = {
...ctx.account,
token: undefined as unknown as string,
} as ResolvedTelegramAccount;
await expect(telegramPlugin.gateway!.startAccount!(ctx)).resolves.toBeUndefined();
expect(monitorTelegramProvider).toHaveBeenCalledWith(
expect.objectContaining({
token: "",
}),
);
});
});

View File

@@ -44,7 +44,7 @@ function findTelegramTokenOwnerAccountId(params: {
const tokenOwners = new Map<string, string>();
for (const id of listTelegramAccountIds(params.cfg)) {
const account = resolveTelegramAccount({ cfg: params.cfg, accountId: id });
const token = (account.token ?? "").trim();
const token = account.token.trim();
if (!token) {
continue;
}
@@ -465,7 +465,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
ctx.log?.error?.(`[${account.accountId}] ${reason}`);
throw new Error(reason);
}
const token = (account.token ?? "").trim();
const token = account.token.trim();
let telegramBotLabel = "";
try {
const probe = await getTelegramRuntime().channel.telegram.probeTelegram(

View File

@@ -134,45 +134,6 @@ describe("VoiceCallWebhookServer stale call reaper", () => {
});
});
describe("VoiceCallWebhookServer path matching", () => {
it("rejects lookalike webhook paths that only match by prefix", async () => {
const verifyWebhook = vi.fn(() => ({ ok: true, verifiedRequestKey: "verified:req:prefix" }));
const parseWebhookEvent = vi.fn(() => ({ events: [], statusCode: 200 }));
const strictProvider: VoiceCallProvider = {
...provider,
verifyWebhook,
parseWebhookEvent,
};
const { manager } = createManager([]);
const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } });
const server = new VoiceCallWebhookServer(config, manager, strictProvider);
try {
const baseUrl = await server.start();
const address = (
server as unknown as { server?: { address?: () => unknown } }
).server?.address?.();
const requestUrl = new URL(baseUrl);
if (address && typeof address === "object" && "port" in address && address.port) {
requestUrl.port = String(address.port);
}
requestUrl.pathname = "/voice/webhook-evil";
const response = await fetch(requestUrl.toString(), {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "CallSid=CA123&SpeechResult=hello",
});
expect(response.status).toBe(404);
expect(verifyWebhook).not.toHaveBeenCalled();
expect(parseWebhookEvent).not.toHaveBeenCalled();
} finally {
await server.stop();
}
});
});
describe("VoiceCallWebhookServer replay handling", () => {
it("acknowledges replayed webhook requests and skips event side effects", async () => {
const replayProvider: VoiceCallProvider = {

View File

@@ -255,25 +255,6 @@ export class VoiceCallWebhookServer {
}
}
private normalizeWebhookPathForMatch(pathname: string): string {
const trimmed = pathname.trim();
if (!trimmed) {
return "/";
}
const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
if (prefixed === "/") {
return prefixed;
}
return prefixed.endsWith("/") ? prefixed.slice(0, -1) : prefixed;
}
private isWebhookPathMatch(requestPath: string, configuredPath: string): boolean {
return (
this.normalizeWebhookPathForMatch(requestPath) ===
this.normalizeWebhookPathForMatch(configuredPath)
);
}
/**
* Handle incoming HTTP request.
*/
@@ -285,7 +266,7 @@ export class VoiceCallWebhookServer {
const url = new URL(req.url || "/", `http://${req.headers.host}`);
// Check path
if (!this.isWebhookPathMatch(url.pathname, webhookPath)) {
if (!url.pathname.startsWith(webhookPath)) {
res.statusCode = 404;
res.end("Not Found");
return;

View File

@@ -209,7 +209,6 @@
"qrcode-terminal": "^0.12.0",
"sharp": "^0.34.5",
"sqlite-vec": "0.1.7-alpha.2",
"strip-ansi": "^7.2.0",
"tar": "7.5.9",
"tslog": "^4.10.2",
"undici": "^7.22.0",

3
pnpm-lock.yaml generated
View File

@@ -180,9 +180,6 @@ importers:
sqlite-vec:
specifier: 0.1.7-alpha.2
version: 0.1.7-alpha.2
strip-ansi:
specifier: ^7.2.0
version: 7.2.0
tar:
specifier: 7.5.9
version: 7.5.9

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
function usage(message) {
if (message) {

View File

@@ -1,25 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveAuthProfileOrder } from "./order.js";
import type { AuthProfileStore } from "./types.js";
describe("resolveAuthProfileOrder", () => {
it("accepts base-provider credentials for volcengine-plan auth lookup", () => {
const store: AuthProfileStore = {
version: 1,
profiles: {
"volcengine:default": {
type: "api_key",
provider: "volcengine",
key: "sk-test",
},
},
};
const order = resolveAuthProfileOrder({
store,
provider: "volcengine-plan",
});
expect(order).toEqual(["volcengine:default"]);
});
});

View File

@@ -1,9 +1,5 @@
import type { OpenClawConfig } from "../../config/config.js";
import {
findNormalizedProviderValue,
normalizeProviderId,
normalizeProviderIdForAuth,
} from "../model-selection.js";
import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js";
import { dedupeProfileIds, listProfilesForProvider } from "./profiles.js";
import type { AuthProfileStore } from "./types.js";
import {
@@ -20,7 +16,6 @@ export function resolveAuthProfileOrder(params: {
}): string[] {
const { cfg, store, provider, preferredProfile } = params;
const providerKey = normalizeProviderId(provider);
const providerAuthKey = normalizeProviderIdForAuth(provider);
const now = Date.now();
// Clear any cooldowns that have expired since the last check so profiles
@@ -32,12 +27,12 @@ export function resolveAuthProfileOrder(params: {
const explicitOrder = storedOrder ?? configuredOrder;
const explicitProfiles = cfg?.auth?.profiles
? Object.entries(cfg.auth.profiles)
.filter(([, profile]) => normalizeProviderIdForAuth(profile.provider) === providerAuthKey)
.filter(([, profile]) => normalizeProviderId(profile.provider) === providerKey)
.map(([profileId]) => profileId)
: [];
const baseOrder =
explicitOrder ??
(explicitProfiles.length > 0 ? explicitProfiles : listProfilesForProvider(store, provider));
(explicitProfiles.length > 0 ? explicitProfiles : listProfilesForProvider(store, providerKey));
if (baseOrder.length === 0) {
return [];
}
@@ -47,12 +42,12 @@ export function resolveAuthProfileOrder(params: {
if (!cred) {
return false;
}
if (normalizeProviderIdForAuth(cred.provider) !== providerAuthKey) {
if (normalizeProviderId(cred.provider) !== providerKey) {
return false;
}
const profileConfig = cfg?.auth?.profiles?.[profileId];
if (profileConfig) {
if (normalizeProviderIdForAuth(profileConfig.provider) !== providerAuthKey) {
if (normalizeProviderId(profileConfig.provider) !== providerKey) {
return false;
}
if (profileConfig.mode !== cred.type) {
@@ -91,7 +86,7 @@ export function resolveAuthProfileOrder(params: {
// provider's stored credentials and use any valid entries.
const allBaseProfilesMissing = baseOrder.every((profileId) => !store.profiles[profileId]);
if (filtered.length === 0 && explicitProfiles.length > 0 && allBaseProfilesMissing) {
const storeProfiles = listProfilesForProvider(store, provider);
const storeProfiles = listProfilesForProvider(store, providerKey);
filtered = storeProfiles.filter(isValidProfile);
}

View File

@@ -1,5 +1,5 @@
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
import { normalizeProviderId, normalizeProviderIdForAuth } from "../model-selection.js";
import { normalizeProviderId } from "../model-selection.js";
import {
ensureAuthProfileStore,
saveAuthProfileStore,
@@ -79,9 +79,9 @@ export async function upsertAuthProfileWithLock(params: {
}
export function listProfilesForProvider(store: AuthProfileStore, provider: string): string[] {
const providerKey = normalizeProviderIdForAuth(provider);
const providerKey = normalizeProviderId(provider);
return Object.entries(store.profiles)
.filter(([, cred]) => normalizeProviderIdForAuth(cred.provider) === providerKey)
.filter(([, cred]) => normalizeProviderId(cred.provider) === providerKey)
.map(([id]) => id);
}

View File

@@ -18,8 +18,6 @@ describe("failover-error", () => {
expect(resolveFailoverReasonFromError({ status: 502 })).toBe("timeout");
expect(resolveFailoverReasonFromError({ status: 503 })).toBe("timeout");
expect(resolveFailoverReasonFromError({ status: 504 })).toBe("timeout");
// Anthropic 529 (overloaded) should trigger failover as rate_limit.
expect(resolveFailoverReasonFromError({ status: 529 })).toBe("rate_limit");
});
it("infers format errors from error messages", () => {

View File

@@ -178,9 +178,6 @@ export function resolveFailoverReasonFromError(err: unknown): FailoverReason | n
if (status === 502 || status === 503 || status === 504) {
return "timeout";
}
if (status === 529) {
return "rate_limit";
}
if (status === 400) {
return "format";
}

View File

@@ -8,7 +8,6 @@ import {
buildModelAliasIndex,
normalizeModelSelection,
normalizeProviderId,
normalizeProviderIdForAuth,
modelKey,
resolveAllowedModelRef,
resolveConfiguredModelRef,
@@ -65,14 +64,6 @@ describe("model-selection", () => {
});
});
describe("normalizeProviderIdForAuth", () => {
it("maps coding-plan variants to base provider for auth lookup", () => {
expect(normalizeProviderIdForAuth("volcengine-plan")).toBe("volcengine");
expect(normalizeProviderIdForAuth("byteplus-plan")).toBe("byteplus");
expect(normalizeProviderIdForAuth("openai")).toBe("openai");
});
});
describe("parseModelRef", () => {
it("should parse full model refs", () => {
expect(parseModelRef("anthropic/claude-3-5-sonnet", "openai")).toEqual({

View File

@@ -61,18 +61,6 @@ export function normalizeProviderId(provider: string): string {
return normalized;
}
/** Normalize provider ID for auth lookup. Coding-plan variants share auth with base. */
export function normalizeProviderIdForAuth(provider: string): string {
const normalized = normalizeProviderId(provider);
if (normalized === "volcengine-plan") {
return "volcengine";
}
if (normalized === "byteplus-plan") {
return "byteplus";
}
return normalized;
}
export function findNormalizedProviderValue<T>(
entries: Record<string, T> | undefined,
provider: string,

View File

@@ -317,38 +317,6 @@ describe("applyExtraParamsToAgent", () => {
expect(payloads[0]).toEqual({ reasoning: { max_tokens: 256 } });
});
it("does not inject reasoning.effort for x-ai/grok models on OpenRouter (#32039)", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {
const payload: Record<string, unknown> = {};
options?.onPayload?.(payload);
payloads.push(payload);
return {} as ReturnType<StreamFn>;
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(
agent,
undefined,
"openrouter",
"x-ai/grok-4.1-fast",
undefined,
"medium",
);
const model = {
api: "openai-completions",
provider: "openrouter",
id: "x-ai/grok-4.1-fast",
} as Model<"openai-completions">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, {});
expect(payloads).toHaveLength(1);
expect(payloads[0]).not.toHaveProperty("reasoning");
expect(payloads[0]).not.toHaveProperty("reasoning_effort");
});
it("normalizes thinking=off to null for SiliconFlow Pro models", () => {
const payloads: Record<string, unknown>[] = [];
const baseStreamFn: StreamFn = (_model, _context, options) => {

View File

@@ -620,15 +620,6 @@ function createOpenRouterWrapper(
};
}
/**
* Models on OpenRouter that do not support the `reasoning.effort` parameter.
* Injecting it causes "Invalid arguments passed to the model" errors.
*/
function isOpenRouterReasoningUnsupported(modelId: string): boolean {
const id = modelId.toLowerCase();
return id.startsWith("x-ai/");
}
function isGemini31Model(modelId: string): boolean {
const normalized = modelId.toLowerCase();
return normalized.includes("gemini-3.1-pro") || normalized.includes("gemini-3.1-flash");
@@ -816,13 +807,7 @@ export function applyExtraParamsToAgent(
// which would cause a 400 on models where reasoning is mandatory.
// Users who need reasoning control should target a specific model ID.
// See: openclaw/openclaw#24851
//
// x-ai/grok models do not support OpenRouter's reasoning.effort parameter
// and reject payloads containing it with "Invalid arguments passed to the
// model." Skip reasoning injection for these models.
// See: openclaw/openclaw#32039
const skipReasoningInjection = modelId === "auto" || isOpenRouterReasoningUnsupported(modelId);
const openRouterThinkingLevel = skipReasoningInjection ? undefined : thinkingLevel;
const openRouterThinkingLevel = modelId === "auto" ? undefined : thinkingLevel;
agent.streamFn = createOpenRouterWrapper(agent.streamFn, openRouterThinkingLevel);
agent.streamFn = createOpenRouterSystemCacheWrapper(agent.streamFn);
}

View File

@@ -1,23 +0,0 @@
import { spawnSync } from "node:child_process";
import path from "node:path";
import { describe, expect, it } from "vitest";
describe("skills/sherpa-onnx-tts bin script", () => {
it("loads as ESM and falls through to usage output when env is missing", () => {
const scriptPath = path.resolve(
process.cwd(),
"skills",
"sherpa-onnx-tts",
"bin",
"sherpa-onnx-tts",
);
const result = spawnSync(process.execPath, [scriptPath], {
encoding: "utf8",
});
expect(result.status).toBe(1);
expect(result.stderr).toContain("Missing runtime/model directory.");
expect(result.stderr).toContain("Usage: sherpa-onnx-tts");
expect(result.stderr).not.toContain("require is not defined in ES module scope");
});
});

View File

@@ -281,37 +281,6 @@ describe("runPreparedReply media-only handling", () => {
expect(call?.followupRun.run.messageProvider).toBe("webchat");
});
it("prefers Provider over Surface when origin channel is missing", async () => {
await runPreparedReply(
baseParams({
ctx: {
Body: "",
RawBody: "",
CommandBody: "",
ThreadHistoryBody: "Earlier message in this thread",
OriginatingChannel: undefined,
OriginatingTo: undefined,
Provider: "feishu",
Surface: "webchat",
ChatType: "group",
},
sessionCtx: {
Body: "",
BodyStripped: "",
ThreadHistoryBody: "Earlier message in this thread",
MediaPath: "/tmp/input.png",
Provider: "webchat",
ChatType: "group",
OriginatingChannel: undefined,
OriginatingTo: undefined,
},
}),
);
const call = vi.mocked(runReplyAgent).mock.calls[0]?.[0];
expect(call?.followupRun.run.messageProvider).toBe("feishu");
});
it("passes suppressTyping through typing mode resolution", async () => {
await runPreparedReply(
baseParams({

View File

@@ -477,10 +477,7 @@ export async function runPreparedReply(
sessionKey,
messageProvider: resolveOriginMessageProvider({
originatingChannel: ctx.OriginatingChannel ?? sessionCtx.OriginatingChannel,
// Prefer Provider over Surface for fallback channel identity.
// Surface can carry relayed metadata (for example "webchat") while Provider
// still reflects the active channel that should own tool routing.
provider: ctx.Provider ?? ctx.Surface ?? sessionCtx.Provider,
provider: ctx.Surface ?? ctx.Provider ?? sessionCtx.Provider,
}),
agentAccountId: sessionCtx.AccountId,
groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined,

View File

@@ -111,10 +111,9 @@ describe("buildInboundUserContextPrefix", () => {
expect(text).toBe("");
});
it("hides message identifiers for direct webchat chats", () => {
it("hides message identifiers for direct chats", () => {
const text = buildInboundUserContextPrefix({
ChatType: "direct",
OriginatingChannel: "webchat",
MessageSid: "short-id",
MessageSidFull: "provider-full-id",
} as TemplateContext);
@@ -122,33 +121,6 @@ describe("buildInboundUserContextPrefix", () => {
expect(text).toBe("");
});
it("includes message identifiers for direct external-channel chats", () => {
const text = buildInboundUserContextPrefix({
ChatType: "direct",
OriginatingChannel: "whatsapp",
MessageSid: "short-id",
MessageSidFull: "provider-full-id",
SenderE164: " +15551234567 ",
} as TemplateContext);
const conversationInfo = parseConversationInfoPayload(text);
expect(conversationInfo["message_id"]).toBe("short-id");
expect(conversationInfo["message_id_full"]).toBeUndefined();
expect(conversationInfo["sender"]).toBe("+15551234567");
expect(conversationInfo["conversation_label"]).toBeUndefined();
});
it("includes message identifiers for direct chats when channel is inferred from Provider", () => {
const text = buildInboundUserContextPrefix({
ChatType: "direct",
Provider: "whatsapp",
MessageSid: "provider-only-id",
} as TemplateContext);
const conversationInfo = parseConversationInfoPayload(text);
expect(conversationInfo["message_id"]).toBe("provider-only-id");
});
it("does not treat group chats as direct based on sender id", () => {
const text = buildInboundUserContextPrefix({
ChatType: "group",

View File

@@ -31,17 +31,6 @@ function formatConversationTimestamp(value: unknown): string | undefined {
}
}
function resolveInboundChannel(ctx: TemplateContext): string | undefined {
let channelValue = safeTrim(ctx.OriginatingChannel) ?? safeTrim(ctx.Surface);
if (!channelValue) {
const provider = safeTrim(ctx.Provider);
if (provider !== "webchat" && ctx.Surface !== "webchat") {
channelValue = provider;
}
}
return channelValue;
}
export function buildInboundMetaSystemPrompt(ctx: TemplateContext): string {
const chatType = normalizeChatType(ctx.ChatType);
const isDirect = !chatType || chatType === "direct";
@@ -55,7 +44,18 @@ export function buildInboundMetaSystemPrompt(ctx: TemplateContext): string {
// Resolve channel identity: prefer explicit channel, then surface, then provider.
// For webchat/Hub Chat sessions (when Surface is 'webchat' or undefined with no real channel),
// omit the channel field entirely rather than falling back to an unrelated provider.
const channelValue = resolveInboundChannel(ctx);
let channelValue = safeTrim(ctx.OriginatingChannel) ?? safeTrim(ctx.Surface);
if (!channelValue) {
// Only fall back to Provider if it represents a real messaging channel.
// For webchat/internal sessions, ctx.Provider may be unrelated (e.g., the user's configured
// default channel), so skip it to avoid incorrect runtime labels like "channel=whatsapp".
const provider = safeTrim(ctx.Provider);
// Check if provider is "webchat" or if we're in an internal/webchat context
if (provider !== "webchat" && ctx.Surface !== "webchat") {
channelValue = provider;
}
// Otherwise leave channelValue undefined (no channel label)
}
const payload = {
schema: "openclaw.inbound_meta.v1",
@@ -85,11 +85,6 @@ export function buildInboundUserContextPrefix(ctx: TemplateContext): string {
const blocks: string[] = [];
const chatType = normalizeChatType(ctx.ChatType);
const isDirect = !chatType || chatType === "direct";
const directChannelValue = resolveInboundChannel(ctx);
const includeDirectConversationInfo = Boolean(
directChannelValue && directChannelValue !== "webchat",
);
const shouldIncludeConversationInfo = !isDirect || includeDirectConversationInfo;
const messageId = safeTrim(ctx.MessageSid);
const messageIdFull = safeTrim(ctx.MessageSidFull);
@@ -97,16 +92,16 @@ export function buildInboundUserContextPrefix(ctx: TemplateContext): string {
const timestampStr = formatConversationTimestamp(ctx.Timestamp);
const conversationInfo = {
message_id: shouldIncludeConversationInfo ? resolvedMessageId : undefined,
reply_to_id: shouldIncludeConversationInfo ? safeTrim(ctx.ReplyToId) : undefined,
sender_id: shouldIncludeConversationInfo ? safeTrim(ctx.SenderId) : undefined,
message_id: isDirect ? undefined : resolvedMessageId,
reply_to_id: isDirect ? undefined : safeTrim(ctx.ReplyToId),
sender_id: isDirect ? undefined : safeTrim(ctx.SenderId),
conversation_label: isDirect ? undefined : safeTrim(ctx.ConversationLabel),
sender: shouldIncludeConversationInfo
? (safeTrim(ctx.SenderName) ??
sender: isDirect
? undefined
: (safeTrim(ctx.SenderName) ??
safeTrim(ctx.SenderE164) ??
safeTrim(ctx.SenderId) ??
safeTrim(ctx.SenderUsername))
: undefined,
safeTrim(ctx.SenderUsername)),
timestamp: timestampStr,
group_subject: safeTrim(ctx.GroupSubject),
group_channel: safeTrim(ctx.GroupChannel),

View File

@@ -1,20 +0,0 @@
import { describe, expect, it } from "vitest";
import { stripStructuralPrefixes } from "./mentions.js";
describe("stripStructuralPrefixes", () => {
it("returns empty string for undefined input at runtime", () => {
expect(stripStructuralPrefixes(undefined as unknown as string)).toBe("");
});
it("returns empty string for empty input", () => {
expect(stripStructuralPrefixes("")).toBe("");
});
it("strips sender prefix labels", () => {
expect(stripStructuralPrefixes("John: hello")).toBe("hello");
});
it("passes through plain text", () => {
expect(stripStructuralPrefixes("just a message")).toBe("just a message");
});
});

View File

@@ -111,9 +111,6 @@ export function matchesMentionWithExplicit(params: {
}
export function stripStructuralPrefixes(text: string): string {
if (!text) {
return "";
}
// Ignore wrapper labels, timestamps, and sender prefixes so directive-only
// detection still works in group batches that include history/context.
const afterMarker = text.includes(CURRENT_MESSAGE_MARKER)

View File

@@ -12,19 +12,15 @@ describe("browser config", () => {
expect(resolved.cdpHost).toBe("127.0.0.1");
expect(resolved.cdpProtocol).toBe("http");
const profile = resolveProfile(resolved, resolved.defaultProfile);
expect(profile?.name).toBe("openclaw");
expect(profile?.driver).toBe("openclaw");
expect(profile?.cdpPort).toBe(18800);
expect(profile?.cdpUrl).toBe("http://127.0.0.1:18800");
expect(profile?.name).toBe("chrome");
expect(profile?.driver).toBe("extension");
expect(profile?.cdpPort).toBe(18792);
expect(profile?.cdpUrl).toBe("http://127.0.0.1:18792");
const openclaw = resolveProfile(resolved, "openclaw");
expect(openclaw?.driver).toBe("openclaw");
expect(openclaw?.cdpPort).toBe(18800);
expect(openclaw?.cdpUrl).toBe("http://127.0.0.1:18800");
const chrome = resolveProfile(resolved, "chrome");
expect(chrome?.driver).toBe("extension");
expect(chrome?.cdpPort).toBe(18792);
expect(chrome?.cdpUrl).toBe("http://127.0.0.1:18792");
expect(resolved.remoteCdpTimeoutMs).toBe(1500);
expect(resolved.remoteCdpHandshakeTimeoutMs).toBe(3000);
});
@@ -243,30 +239,31 @@ describe("browser config", () => {
expect(resolved.ssrfPolicy).toEqual({});
});
describe("default profile preference", () => {
it("defaults to openclaw profile when defaultProfile is not configured", () => {
// Tests for headless/noSandbox profile preference (issue #14895)
describe("headless/noSandbox profile preference", () => {
it("defaults to chrome profile when headless=false and noSandbox=false", () => {
const resolved = resolveBrowserConfig({
headless: false,
noSandbox: false,
});
expect(resolved.defaultProfile).toBe("openclaw");
expect(resolved.defaultProfile).toBe("chrome");
});
it("keeps openclaw default when headless=true", () => {
it("prefers openclaw profile when headless=true", () => {
const resolved = resolveBrowserConfig({
headless: true,
});
expect(resolved.defaultProfile).toBe("openclaw");
});
it("keeps openclaw default when noSandbox=true", () => {
it("prefers openclaw profile when noSandbox=true", () => {
const resolved = resolveBrowserConfig({
noSandbox: true,
});
expect(resolved.defaultProfile).toBe("openclaw");
});
it("keeps openclaw default when both headless and noSandbox are true", () => {
it("prefers openclaw profile when both headless and noSandbox are true", () => {
const resolved = resolveBrowserConfig({
headless: true,
noSandbox: true,
@@ -274,7 +271,7 @@ describe("browser config", () => {
expect(resolved.defaultProfile).toBe("openclaw");
});
it("explicit defaultProfile config overrides defaults in headless mode", () => {
it("explicit defaultProfile config overrides headless preference", () => {
const resolved = resolveBrowserConfig({
headless: true,
defaultProfile: "chrome",
@@ -282,7 +279,7 @@ describe("browser config", () => {
expect(resolved.defaultProfile).toBe("chrome");
});
it("explicit defaultProfile config overrides defaults in noSandbox mode", () => {
it("explicit defaultProfile config overrides noSandbox preference", () => {
const resolved = resolveBrowserConfig({
noSandbox: true,
defaultProfile: "chrome",

View File

@@ -264,13 +264,17 @@ export function resolveBrowserConfig(
);
const cdpProtocol = cdpInfo.parsed.protocol === "https:" ? "https" : "http";
// In headless/noSandbox environments (servers), prefer "openclaw" profile over "chrome"
// because Chrome extension relay requires a GUI browser which isn't available headless.
// Issue: https://github.com/openclaw/openclaw/issues/14895
const preferOpenClawProfile = headless || noSandbox;
const defaultProfile =
defaultProfileFromConfig ??
(profiles[DEFAULT_BROWSER_DEFAULT_PROFILE_NAME]
? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME
: profiles[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]
? DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME
: "chrome");
(preferOpenClawProfile && profiles[DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME]
? DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME
: profiles[DEFAULT_BROWSER_DEFAULT_PROFILE_NAME]
? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME
: DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME);
const extraArgs = Array.isArray(cfg?.extraArgs)
? cfg.extraArgs.filter((a): a is string => typeof a === "string" && a.trim().length > 0)

View File

@@ -2,7 +2,7 @@ export const DEFAULT_OPENCLAW_BROWSER_ENABLED = true;
export const DEFAULT_BROWSER_EVALUATE_ENABLED = true;
export const DEFAULT_OPENCLAW_BROWSER_COLOR = "#FF4500";
export const DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME = "openclaw";
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "openclaw";
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "chrome";
export const DEFAULT_AI_SNAPSHOT_MAX_CHARS = 80_000;
export const DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS = 10_000;
export const DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH = 6;

View File

@@ -9,11 +9,15 @@ import {
import { fetchTelegramChatId } from "../channels/telegram/api.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { CONFIG_PATH, migrateLegacyConfig, readConfigFileSnapshot } from "../config/config.js";
import {
OpenClawSchema,
CONFIG_PATH,
migrateLegacyConfig,
readConfigFileSnapshot,
} from "../config/config.js";
import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { parseToolsBySenderTypedKey } from "../config/types.tools.js";
import { OpenClawSchema } from "../config/zod-schema.js";
import { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js";
import {
listInterpreterLikeSafeBins,

View File

@@ -60,61 +60,6 @@ describe("noteMemorySearchHealth", () => {
resolveMemoryBackendConfig.mockReturnValue({ backend: "builtin", citations: "auto" });
});
it("does not warn when local provider is set with no explicit modelPath (default model fallback)", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "local",
local: {},
remote: {},
});
await noteMemorySearchHealth(cfg, {});
expect(note).not.toHaveBeenCalled();
});
it("warns when local provider with default model but gateway probe reports not ready", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "local",
local: {},
remote: {},
});
await noteMemorySearchHealth(cfg, {
gatewayMemoryProbe: { checked: true, ready: false, error: "node-llama-cpp not installed" },
});
expect(note).toHaveBeenCalledTimes(1);
const message = String(note.mock.calls[0]?.[0] ?? "");
expect(message).toContain("gateway reports local embeddings are not ready");
expect(message).toContain("node-llama-cpp not installed");
});
it("does not warn when local provider with default model and gateway probe is ready", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "local",
local: {},
remote: {},
});
await noteMemorySearchHealth(cfg, {
gatewayMemoryProbe: { checked: true, ready: true },
});
expect(note).not.toHaveBeenCalled();
});
it("does not warn when local provider has an explicit hf: modelPath", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "local",
local: { modelPath: "hf:some-org/some-model-GGUF/model.gguf" },
remote: {},
});
await noteMemorySearchHealth(cfg, {});
expect(note).not.toHaveBeenCalled();
});
it("does not warn when QMD backend is active", async () => {
resolveMemoryBackendConfig.mockReturnValue({
backend: "qmd",
@@ -219,7 +164,7 @@ describe("noteMemorySearchHealth", () => {
expect(message).not.toContain("openclaw auth add --provider");
});
it("warns in auto mode when no local modelPath and no API keys are configured", async () => {
it("uses model configure hint in auto mode when no provider credentials are found", async () => {
resolveMemorySearchConfig.mockReturnValue({
provider: "auto",
local: {},
@@ -228,12 +173,10 @@ describe("noteMemorySearchHealth", () => {
await noteMemorySearchHealth(cfg);
// In auto mode, canAutoSelectLocal requires an explicit local file path.
// DEFAULT_LOCAL_MODEL fallback does NOT apply to auto — only to explicit
// provider: "local". So with no local file and no API keys, warn.
expect(note).toHaveBeenCalledTimes(1);
const message = String(note.mock.calls[0]?.[0] ?? "");
expect(message).toContain("openclaw configure --section model");
expect(message).not.toContain("openclaw auth add --provider");
});
});

View File

@@ -5,7 +5,6 @@ import { resolveApiKeyForProvider } from "../agents/model-auth.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveMemoryBackendConfig } from "../memory/backend-config.js";
import { DEFAULT_LOCAL_MODEL } from "../memory/embeddings.js";
import { note } from "../terminal/note.js";
import { resolveUserPath } from "../utils.js";
@@ -43,26 +42,8 @@ export async function noteMemorySearchHealth(
// If a specific provider is configured (not "auto"), check only that one.
if (resolved.provider !== "auto") {
if (resolved.provider === "local") {
if (hasLocalEmbeddings(resolved.local, true)) {
// Model path looks valid (explicit file, hf: URL, or default model).
// If a gateway probe is available and reports not-ready, warn anyway —
// the model download or node-llama-cpp setup may have failed at runtime.
if (opts?.gatewayMemoryProbe?.checked && !opts.gatewayMemoryProbe.ready) {
const detail = opts.gatewayMemoryProbe.error?.trim();
note(
[
'Memory search provider is set to "local" and a model path is configured,',
"but the gateway reports local embeddings are not ready.",
detail ? `Gateway probe: ${detail}` : null,
"",
`Verify: ${formatCliCommand("openclaw memory status --deep")}`,
]
.filter(Boolean)
.join("\n"),
"Memory search",
);
}
return;
if (hasLocalEmbeddings(resolved.local)) {
return; // local model file exists
}
note(
[
@@ -154,20 +135,8 @@ export async function noteMemorySearchHealth(
);
}
/**
* Check whether local embeddings are available.
*
* When `useDefaultFallback` is true (explicit `provider: "local"`), an empty
* modelPath is treated as available because the runtime falls back to
* DEFAULT_LOCAL_MODEL (an auto-downloaded HuggingFace model).
*
* When false (provider: "auto"), we only consider local available if the user
* explicitly configured a local file path — matching `canAutoSelectLocal()`
* in the runtime, which skips local for empty/hf: model paths.
*/
function hasLocalEmbeddings(local: { modelPath?: string }, useDefaultFallback = false): boolean {
const modelPath =
local.modelPath?.trim() || (useDefaultFallback ? DEFAULT_LOCAL_MODEL : undefined);
function hasLocalEmbeddings(local: { modelPath?: string }): boolean {
const modelPath = local.modelPath?.trim();
if (!modelPath) {
return false;
}

View File

@@ -3,7 +3,6 @@ import type { OpenClawConfig } from "../config/config.js";
import {
applyOnboardingLocalWorkspaceConfig,
ONBOARDING_DEFAULT_DM_SCOPE,
ONBOARDING_DEFAULT_TOOLS_PROFILE,
} from "./onboard-config.js";
describe("applyOnboardingLocalWorkspaceConfig", () => {
@@ -14,7 +13,6 @@ describe("applyOnboardingLocalWorkspaceConfig", () => {
expect(result.session?.dmScope).toBe(ONBOARDING_DEFAULT_DM_SCOPE);
expect(result.gateway?.mode).toBe("local");
expect(result.agents?.defaults?.workspace).toBe("/tmp/workspace");
expect(result.tools?.profile).toBe(ONBOARDING_DEFAULT_TOOLS_PROFILE);
});
it("preserves existing dmScope when already configured", () => {
@@ -38,15 +36,4 @@ describe("applyOnboardingLocalWorkspaceConfig", () => {
expect(result.session?.dmScope).toBe("per-account-channel-peer");
});
it("preserves an explicit tools.profile when already configured", () => {
const baseConfig: OpenClawConfig = {
tools: {
profile: "full",
},
};
const result = applyOnboardingLocalWorkspaceConfig(baseConfig, "/tmp/workspace");
expect(result.tools?.profile).toBe("full");
});
});

View File

@@ -1,9 +1,7 @@
import type { OpenClawConfig } from "../config/config.js";
import type { DmScope } from "../config/types.base.js";
import type { ToolProfileId } from "../config/types.tools.js";
export const ONBOARDING_DEFAULT_DM_SCOPE: DmScope = "per-channel-peer";
export const ONBOARDING_DEFAULT_TOOLS_PROFILE: ToolProfileId = "messaging";
export function applyOnboardingLocalWorkspaceConfig(
baseConfig: OpenClawConfig,
@@ -26,9 +24,5 @@ export function applyOnboardingLocalWorkspaceConfig(
...baseConfig.session,
dmScope: baseConfig.session?.dmScope ?? ONBOARDING_DEFAULT_DM_SCOPE,
},
tools: {
...baseConfig.tools,
profile: baseConfig.tools?.profile ?? ONBOARDING_DEFAULT_TOOLS_PROFILE,
},
};
}

View File

@@ -141,11 +141,9 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
const cfg = await readJsonFile<{
gateway?: { auth?: { mode?: string; token?: string } };
agents?: { defaults?: { workspace?: string } };
tools?: { profile?: string };
}>(configPath);
expect(cfg?.agents?.defaults?.workspace).toBe(workspace);
expect(cfg?.tools?.profile).toBe("messaging");
expect(cfg?.gateway?.auth?.mode).toBe("token");
expect(cfg?.gateway?.auth?.token).toBe(token);
});

View File

@@ -41,7 +41,10 @@ describe("config plugin validation", () => {
OPENCLAW_PLUGIN_MANIFEST_CACHE_MS: process.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS,
};
const validateInSuite = (raw: unknown) => validateConfigObjectWithPlugins(raw);
const validateInSuite = (raw: unknown) => {
process.env.OPENCLAW_STATE_DIR = path.join(suiteHome, ".openclaw");
return validateConfigObjectWithPlugins(raw);
};
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-config-plugin-validation-"));
@@ -67,7 +70,6 @@ describe("config plugin validation", () => {
channels: ["bluebubbles"],
schema: { type: "object" },
});
process.env.OPENCLAW_STATE_DIR = path.join(suiteHome, ".openclaw");
process.env.OPENCLAW_PLUGIN_MANIFEST_CACHE_MS = "10000";
clearPluginManifestRegistryCache();
});

View File

@@ -21,3 +21,4 @@ export {
validateConfigObjectRawWithPlugins,
validateConfigObjectWithPlugins,
} from "./validation.js";
export { OpenClawSchema } from "./zod-schema.js";

View File

@@ -67,9 +67,6 @@ export function resolveStateDir(
return resolveUserPath(override, env, effectiveHomedir);
}
const newDir = newStateDir(effectiveHomedir);
if (env.OPENCLAW_TEST_FAST === "1") {
return newDir;
}
const legacyDirs = legacyStateDirs(effectiveHomedir);
const hasNew = fs.existsSync(newDir);
if (hasNew) {
@@ -134,9 +131,6 @@ export function resolveConfigPathCandidate(
env: NodeJS.ProcessEnv = process.env,
homedir: () => string = envHomedir(env),
): string {
if (env.OPENCLAW_TEST_FAST === "1") {
return resolveCanonicalConfigPath(env, resolveStateDir(env, homedir));
}
const candidates = resolveDefaultConfigCandidates(env, homedir);
const existing = candidates.find((candidate) => {
try {
@@ -163,9 +157,6 @@ export function resolveConfigPath(
if (override) {
return resolveUserPath(override, env, homedir);
}
if (env.OPENCLAW_TEST_FAST === "1") {
return path.join(stateDir, CONFIG_FILENAME);
}
const stateOverride = env.OPENCLAW_STATE_DIR?.trim();
const candidates = [
path.join(stateDir, CONFIG_FILENAME),

View File

@@ -38,7 +38,7 @@ function topOfHourOffsetMs(jobId: string) {
let fixtureRoot = "";
let fixtureCount = 0;
function makeStorePath() {
async function makeStorePath() {
const storePath = path.join(fixtureRoot, `case-${fixtureCount++}.jobs.json`);
return {
storePath,
@@ -157,6 +157,7 @@ describe("Cron issue regressions", () => {
});
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-06T10:05:00.000Z"));
});
@@ -167,7 +168,7 @@ describe("Cron issue regressions", () => {
});
it("covers schedule updates and payload patching", async () => {
const store = makeStorePath();
const store = await makeStorePath();
const cron = await startCronForStore({
storePath: store.storePath,
cronEnabled: false,
@@ -213,7 +214,7 @@ describe("Cron issue regressions", () => {
});
it("repairs isolated every jobs missing createdAtMs and sets nextWakeAtMs", async () => {
const store = makeStorePath();
const store = await makeStorePath();
await fs.writeFile(
store.storePath,
JSON.stringify({
@@ -262,7 +263,7 @@ describe("Cron issue regressions", () => {
});
it("repairs missing nextRunAtMs on non-schedule updates without touching other jobs", async () => {
const store = makeStorePath();
const store = await makeStorePath();
const cron = await startCronForStore({ storePath: store.storePath, cronEnabled: false });
const created = await cron.add({
@@ -286,7 +287,7 @@ describe("Cron issue regressions", () => {
});
it("does not advance unrelated due jobs when updating another job", async () => {
const store = makeStorePath();
const store = await makeStorePath();
const now = Date.parse("2026-02-06T10:05:00.000Z");
vi.setSystemTime(now);
const cron = await startCronForStore({ storePath: store.storePath, cronEnabled: false });
@@ -328,7 +329,7 @@ describe("Cron issue regressions", () => {
});
it("treats persisted jobs with missing enabled as enabled during update()", async () => {
const store = makeStorePath();
const store = await makeStorePath();
const now = Date.parse("2026-02-06T10:05:00.000Z");
await fs.writeFile(
store.storePath,
@@ -371,7 +372,7 @@ describe("Cron issue regressions", () => {
});
it("treats persisted due jobs with missing enabled as runnable", async () => {
const store = makeStorePath();
const store = await makeStorePath();
const now = Date.parse("2026-02-06T10:05:00.000Z");
const dueAt = now - 30_000;
await fs.writeFile(
@@ -418,7 +419,7 @@ describe("Cron issue regressions", () => {
it("caps timer delay to 60s for far-future schedules", async () => {
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
const store = makeStorePath();
const store = await makeStorePath();
const cron = await startCronForStore({ storePath: store.storePath });
const callsBeforeAdd = timeoutSpy.mock.calls.length;
@@ -443,7 +444,7 @@ describe("Cron issue regressions", () => {
it("re-arms timer without hot-looping when a run is already in progress", async () => {
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
const store = makeStorePath();
const store = await makeStorePath();
const now = Date.parse("2026-02-06T10:05:00.000Z");
const state = createRunningCronServiceState({
storePath: store.storePath,
@@ -467,7 +468,7 @@ describe("Cron issue regressions", () => {
});
it("skips forced manual runs while a timer-triggered run is in progress", async () => {
const store = makeStorePath();
const store = await makeStorePath();
let resolveRun:
| ((value: { status: "ok" | "error" | "skipped"; summary?: string; error?: string }) => void)
| undefined;
@@ -528,7 +529,7 @@ describe("Cron issue regressions", () => {
});
it("does not double-run a job when cron.run overlaps a due timer tick", async () => {
const store = makeStorePath();
const store = await makeStorePath();
const runStarted = createDeferred<void>();
const runFinished = createDeferred<void>();
const runResolvers: Array<
@@ -585,7 +586,7 @@ describe("Cron issue regressions", () => {
});
it("does not advance unrelated due jobs after manual cron.run", async () => {
const store = makeStorePath();
const store = await makeStorePath();
const nowMs = Date.now();
const dueNextRunAtMs = nowMs - 1_000;
@@ -626,7 +627,7 @@ describe("Cron issue regressions", () => {
});
it("keeps telegram delivery target writeback after manual cron.run", async () => {
const store = makeStorePath();
const store = await makeStorePath();
const originalTarget = "https://t.me/obviyus";
const rewrittenTarget = "-10012345/6789";
const runIsolatedAgentJob = vi.fn(async (params: { job: { id: string } }) => {
@@ -674,7 +675,7 @@ describe("Cron issue regressions", () => {
});
it("#13845: one-shot jobs with terminal statuses do not re-fire on restart", async () => {
const store = makeStorePath();
const store = await makeStorePath();
const pastAt = Date.parse("2026-02-06T09:00:00.000Z");
const baseJob = {
name: "reminder",
@@ -731,7 +732,7 @@ describe("Cron issue regressions", () => {
runIsolatedAgentJob: ReturnType<typeof vi.fn>;
firstRetryAtMs: number;
}> => {
const store = makeStorePath();
const store = await makeStorePath();
const cronJob = createIsolatedRegressionJob({
id: params.id,
name: "reminder",
@@ -793,7 +794,7 @@ describe("Cron issue regressions", () => {
});
it("#24355: one-shot job disabled after max transient retries", async () => {
const store = makeStorePath();
const store = await makeStorePath();
const scheduledAt = Date.parse("2026-02-06T10:00:00.000Z");
const cronJob = createIsolatedRegressionJob({
@@ -836,7 +837,7 @@ describe("Cron issue regressions", () => {
});
it("#24355: one-shot job respects cron.retry config", async () => {
const store = makeStorePath();
const store = await makeStorePath();
const scheduledAt = Date.parse("2026-02-06T10:00:00.000Z");
const cronJob = createIsolatedRegressionJob({
@@ -882,7 +883,7 @@ describe("Cron issue regressions", () => {
});
it("#24355: one-shot job disabled immediately on permanent error", async () => {
const store = makeStorePath();
const store = await makeStorePath();
const scheduledAt = Date.parse("2026-02-06T10:00:00.000Z");
const cronJob = createIsolatedRegressionJob({
@@ -919,7 +920,7 @@ describe("Cron issue regressions", () => {
});
it("prevents spin loop when cron job completes within the scheduled second (#17821)", async () => {
const store = makeStorePath();
const store = await makeStorePath();
// Simulate a cron job "0 13 * * *" (daily 13:00 UTC) that fires exactly
// at 13:00:00.000 and completes 7ms later (still in the same second).
const scheduledAt = Date.parse("2026-02-15T13:00:00.000Z");
@@ -969,7 +970,7 @@ describe("Cron issue regressions", () => {
});
it("enforces a minimum refire gap for second-granularity cron schedules (#17821)", async () => {
const store = makeStorePath();
const store = await makeStorePath();
const scheduledAt = Date.parse("2026-02-15T13:00:00.000Z");
const cronJob = createIsolatedRegressionJob({
@@ -1007,7 +1008,7 @@ describe("Cron issue regressions", () => {
});
it("treats timeoutSeconds=0 as no timeout for isolated agentTurn jobs", async () => {
const store = makeStorePath();
const store = await makeStorePath();
const scheduledAt = Date.parse("2026-02-15T13:00:00.000Z");
const cronJob = createIsolatedRegressionJob({
@@ -1054,7 +1055,7 @@ describe("Cron issue regressions", () => {
});
it("does not time out agentTurn jobs at the default 10-minute safety window", async () => {
const store = makeStorePath();
const store = await makeStorePath();
const scheduledAt = Date.parse("2026-02-15T13:00:00.000Z");
const cronJob = createIsolatedRegressionJob({
@@ -1107,7 +1108,7 @@ describe("Cron issue regressions", () => {
it("aborts isolated runs when cron timeout fires", async () => {
vi.useRealTimers();
const store = makeStorePath();
const store = await makeStorePath();
const scheduledAt = Date.parse("2026-02-15T13:00:00.000Z");
const cronJob = createIsolatedRegressionJob({
id: "abort-on-timeout",
@@ -1146,7 +1147,7 @@ describe("Cron issue regressions", () => {
it("suppresses isolated follow-up side effects after timeout", async () => {
vi.useRealTimers();
const store = makeStorePath();
const store = await makeStorePath();
const scheduledAt = Date.parse("2026-02-15T13:00:00.000Z");
const enqueueSystemEvent = vi.fn();
@@ -1200,7 +1201,7 @@ describe("Cron issue regressions", () => {
it("applies timeoutSeconds to manual cron.run isolated executions", async () => {
vi.useRealTimers();
const store = makeStorePath();
const store = await makeStorePath();
const abortAwareRunner = createAbortAwareIsolatedRunner();
const cron = await startCronForStore({
@@ -1236,7 +1237,7 @@ describe("Cron issue regressions", () => {
it("applies timeoutSeconds to startup catch-up isolated executions", async () => {
vi.useRealTimers();
const store = makeStorePath();
const store = await makeStorePath();
const scheduledAt = Date.parse("2026-02-15T13:00:00.000Z");
const cronJob = createIsolatedRegressionJob({
id: "startup-timeout",
@@ -1353,7 +1354,7 @@ describe("Cron issue regressions", () => {
});
it("records per-job start time and duration for batched due jobs", async () => {
const store = makeStorePath();
const store = await makeStorePath();
const dueAt = Date.parse("2026-02-06T10:05:01.000Z");
const first = createDueIsolatedJob({ id: "batch-first", nowMs: dueAt, nextRunAtMs: dueAt });
const second = createDueIsolatedJob({ id: "batch-second", nowMs: dueAt, nextRunAtMs: dueAt });
@@ -1398,7 +1399,7 @@ describe("Cron issue regressions", () => {
});
it("#17554: run() clears stale runningAtMs and executes the job", async () => {
const store = makeStorePath();
const store = await makeStorePath();
const now = Date.parse("2026-02-06T10:05:00.000Z");
const staleRunningAtMs = now - 2 * 60 * 60 * 1000 - 1;
@@ -1454,7 +1455,7 @@ describe("Cron issue regressions", () => {
it("honors cron maxConcurrentRuns for due jobs", async () => {
vi.useRealTimers();
const store = makeStorePath();
const store = await makeStorePath();
const dueAt = Date.parse("2026-02-06T10:05:01.000Z");
const first = createDueIsolatedJob({ id: "parallel-first", nowMs: dueAt, nextRunAtMs: dueAt });
const second = createDueIsolatedJob({
@@ -1527,7 +1528,7 @@ describe("Cron issue regressions", () => {
// job abort that fires much sooner than the configured outer timeout.
it("outer cron timeout fires at configured timeoutSeconds, not at 1/3 (#29774)", async () => {
vi.useRealTimers();
const store = makeStorePath();
const store = await makeStorePath();
const scheduledAt = Date.parse("2026-02-15T13:00:00.000Z");
// Keep this short for suite speed while still separating expected timeout

View File

@@ -1,165 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createNoopLogger, createCronStoreHarness } from "./service.test-harness.js";
import { createCronServiceState } from "./service/state.js";
import { onTimer } from "./service/timer.js";
import { resetReaperThrottle } from "./session-reaper.js";
import type { CronJob } from "./types.js";
const noopLogger = createNoopLogger();
const { makeStorePath } = createCronStoreHarness({
prefix: "openclaw-cron-reaper-finally-",
});
function createDueIsolatedJob(params: { id: string; nowMs: number }): CronJob {
return {
id: params.id,
name: params.id,
enabled: true,
deleteAfterRun: false,
createdAtMs: params.nowMs,
updatedAtMs: params.nowMs,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "test" },
delivery: { mode: "none" },
state: { nextRunAtMs: params.nowMs },
};
}
describe("CronService - session reaper runs in finally block (#31946)", () => {
beforeEach(() => {
noopLogger.debug.mockClear();
noopLogger.info.mockClear();
noopLogger.warn.mockClear();
noopLogger.error.mockClear();
resetReaperThrottle();
});
afterEach(() => {
vi.clearAllMocks();
});
it("session reaper runs even when job execution throws", async () => {
const store = await makeStorePath();
const now = Date.parse("2026-02-10T10:00:00.000Z");
// Write a store with a due job that will trigger execution.
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(
store.storePath,
JSON.stringify({
version: 1,
jobs: [createDueIsolatedJob({ id: "failing-job", nowMs: now })],
}),
"utf-8",
);
// Create a mock sessionStorePath to track if the reaper is called.
const sessionStorePath = path.join(path.dirname(store.storePath), "sessions", "sessions.json");
const state = createCronServiceState({
storePath: store.storePath,
cronEnabled: true,
log: noopLogger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
// This will throw, simulating a failure during job execution.
runIsolatedAgentJob: vi.fn().mockRejectedValue(new Error("gateway down")),
sessionStorePath,
});
await onTimer(state);
// After onTimer finishes (even with a job error), state.running must be
// false — proving the finally block executed.
expect(state.running).toBe(false);
// The timer must be re-armed.
expect(state.timer).not.toBeNull();
});
it("session reaper runs when resolveSessionStorePath is provided", async () => {
const store = await makeStorePath();
const now = Date.parse("2026-02-10T10:00:00.000Z");
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(
store.storePath,
JSON.stringify({
version: 1,
jobs: [createDueIsolatedJob({ id: "ok-job", nowMs: now })],
}),
"utf-8",
);
const resolvedPaths: string[] = [];
const state = createCronServiceState({
storePath: store.storePath,
cronEnabled: true,
log: noopLogger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok", summary: "done" }),
resolveSessionStorePath: (agentId) => {
const p = path.join(path.dirname(store.storePath), `${agentId}-sessions`, "sessions.json");
resolvedPaths.push(p);
return p;
},
});
await onTimer(state);
// The resolveSessionStorePath callback should have been invoked to build
// the set of store paths for the session reaper.
expect(resolvedPaths.length).toBeGreaterThan(0);
expect(state.running).toBe(false);
});
it("prunes expired cron-run sessions even when cron store load throws", async () => {
const store = await makeStorePath();
const now = Date.parse("2026-02-10T10:00:00.000Z");
const sessionStorePath = path.join(path.dirname(store.storePath), "sessions", "sessions.json");
// Force onTimer's try-block to throw before normal execution flow.
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
await fs.writeFile(store.storePath, "{invalid-json", "utf-8");
// Seed an expired cron-run session entry that should be pruned by the reaper.
await fs.mkdir(path.dirname(sessionStorePath), { recursive: true });
await fs.writeFile(
sessionStorePath,
JSON.stringify({
"agent:agent-default:cron:failing-job:run:stale": {
sessionId: "session-stale",
updatedAt: now - 3 * 24 * 3_600_000,
},
}),
"utf-8",
);
const state = createCronServiceState({
storePath: store.storePath,
cronEnabled: true,
log: noopLogger,
nowMs: () => now,
enqueueSystemEvent: vi.fn(),
requestHeartbeatNow: vi.fn(),
runIsolatedAgentJob: vi.fn(),
sessionStorePath,
});
await expect(onTimer(state)).rejects.toThrow("Failed to parse cron store");
const updatedSessionStore = JSON.parse(await fs.readFile(sessionStorePath, "utf-8")) as Record<
string,
unknown
>;
expect(updatedSessionStore).toEqual({});
expect(state.running).toBe(false);
});
});

View File

@@ -643,11 +643,7 @@ export async function onTimer(state: CronServiceState) {
await persist(state);
});
}
} finally {
// Piggyback session reaper on timer tick (self-throttled to every 5 min).
// Placed in `finally` so the reaper runs even when a long-running job keeps
// `state.running` true across multiple timer ticks — the early return at the
// top of onTimer would otherwise skip the reaper indefinitely.
const storePaths = new Set<string>();
if (state.deps.resolveSessionStorePath) {
const defaultAgentId = state.deps.defaultAgentId ?? DEFAULT_AGENT_ID;
@@ -679,7 +675,7 @@ export async function onTimer(state: CronServiceState) {
}
}
}
} finally {
state.running = false;
armTimer(state);
}

View File

@@ -4,8 +4,6 @@ import fs from "node:fs/promises";
// intentional gateway restarts. Keep it low so CLI restarts and forced
// reinstalls do not stall for a full minute.
export const LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS = 1;
// launchd stores plist integer values in decimal; 0o077 renders as 63 (owner-only files).
export const LAUNCH_AGENT_UMASK_DECIMAL = 0o077;
const plistEscape = (value: string): string =>
value
@@ -113,5 +111,5 @@ export function buildLaunchAgentPlist({
? `\n <key>Comment</key>\n <string>${plistEscape(comment.trim())}</string>`
: "";
const envXml = renderEnvDict(environment);
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n <dict>\n <key>Label</key>\n <string>${plistEscape(label)}</string>\n ${commentXml}\n <key>RunAtLoad</key>\n <true/>\n <key>KeepAlive</key>\n <true/>\n <key>ThrottleInterval</key>\n <integer>${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}</integer>\n <key>Umask</key>\n <integer>${LAUNCH_AGENT_UMASK_DECIMAL}</integer>\n <key>ProgramArguments</key>\n <array>${argsXml}\n </array>\n ${workingDirXml}\n <key>StandardOutPath</key>\n <string>${plistEscape(stdoutPath)}</string>\n <key>StandardErrorPath</key>\n <string>${plistEscape(stderrPath)}</string>${envXml}\n </dict>\n</plist>\n`;
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n <dict>\n <key>Label</key>\n <string>${plistEscape(label)}</string>\n ${commentXml}\n <key>RunAtLoad</key>\n <true/>\n <key>KeepAlive</key>\n <true/>\n <key>ThrottleInterval</key>\n <integer>${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}</integer>\n <key>ProgramArguments</key>\n <array>${argsXml}\n </array>\n ${workingDirXml}\n <key>StandardOutPath</key>\n <string>${plistEscape(stdoutPath)}</string>\n <key>StandardErrorPath</key>\n <string>${plistEscape(stderrPath)}</string>${envXml}\n </dict>\n</plist>\n`;
}

View File

@@ -1,9 +1,6 @@
import { PassThrough } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS,
LAUNCH_AGENT_UMASK_DECIMAL,
} from "./launchd-plist.js";
import { LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS } from "./launchd-plist.js";
import {
installLaunchAgent,
isLaunchAgentListed,
@@ -189,7 +186,7 @@ describe("launchd install", () => {
expect(plist).toContain(`<string>${tmpDir}</string>`);
});
it("writes KeepAlive=true policy with restrictive umask", async () => {
it("writes KeepAlive=true policy", async () => {
const env = createDefaultLaunchdEnv();
await installLaunchAgent({
env,
@@ -202,8 +199,6 @@ describe("launchd install", () => {
expect(plist).toContain("<key>KeepAlive</key>");
expect(plist).toContain("<true/>");
expect(plist).not.toContain("<key>SuccessfulExit</key>");
expect(plist).toContain("<key>Umask</key>");
expect(plist).toContain(`<integer>${LAUNCH_AGENT_UMASK_DECIMAL}</integer>`);
expect(plist).toContain("<key>ThrottleInterval</key>");
expect(plist).toContain(`<integer>${LAUNCH_AGENT_THROTTLE_INTERVAL_SECONDS}</integer>`);
});

View File

@@ -147,18 +147,6 @@ describe("buildGatewayReloadPlan", () => {
expect(plan.restartChannels).toEqual(expected);
});
it("restarts heartbeat when model-related config changes", () => {
const plan = buildGatewayReloadPlan([
"models.providers.openai.models",
"agents.defaults.model",
]);
expect(plan.restartGateway).toBe(false);
expect(plan.restartHeartbeat).toBe(true);
expect(plan.hotReasons).toEqual(
expect.arrayContaining(["models.providers.openai.models", "agents.defaults.model"]),
);
});
it("treats gateway.remote as no-op", () => {
const plan = buildGatewayReloadPlan(["gateway.remote.url"]);
expect(plan.restartGateway).toBe(false);

View File

@@ -59,16 +59,6 @@ const BASE_RELOAD_RULES: ReloadRule[] = [
kind: "hot",
actions: ["restart-heartbeat"],
},
{
prefix: "agents.defaults.model",
kind: "hot",
actions: ["restart-heartbeat"],
},
{
prefix: "models",
kind: "hot",
actions: ["restart-heartbeat"],
},
{ prefix: "agent.heartbeat", kind: "hot", actions: ["restart-heartbeat"] },
{ prefix: "cron", kind: "hot", actions: ["restart-cron"] },
{
@@ -83,6 +73,7 @@ const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [
{ prefix: "identity", kind: "none" },
{ prefix: "wizard", kind: "none" },
{ prefix: "logging", kind: "none" },
{ prefix: "models", kind: "none" },
{ prefix: "agents", kind: "none" },
{ prefix: "tools", kind: "none" },
{ prefix: "bindings", kind: "none" },

View File

@@ -587,24 +587,6 @@ export function createGatewayHttpServer(opts: {
run: () => canvasHost.handleHttpRequest(req, res),
});
}
// Plugin routes run before the Control UI SPA catch-all so explicitly
// registered plugin endpoints stay reachable. Core built-in gateway
// routes above still keep precedence on overlapping paths.
requestStages.push(
...buildPluginRequestStages({
req,
res,
requestPath,
pluginPathContext,
handlePluginRequest,
shouldEnforcePluginGatewayAuth,
resolvedAuth,
trustedProxies,
allowRealIpFallback,
rateLimiter,
}),
);
if (controlUiEnabled) {
requestStages.push({
name: "control-ui-avatar",
@@ -624,6 +606,22 @@ export function createGatewayHttpServer(opts: {
}),
});
}
// Plugins run after built-in gateway routes so core surfaces keep
// precedence on overlapping paths.
requestStages.push(
...buildPluginRequestStages({
req,
res,
requestPath,
pluginPathContext,
handlePluginRequest,
shouldEnforcePluginGatewayAuth,
resolvedAuth,
trustedProxies,
allowRealIpFallback,
rateLimiter,
}),
);
requestStages.push({
name: "gateway-probes",

View File

@@ -1,4 +1,3 @@
import { randomUUID } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { expect } from "vitest";
@@ -289,7 +288,7 @@ async function startRateLimitedTokenServerWithPairedDeviceToken() {
const { server, ws, port, prevToken } = await startServerWithClient();
const deviceIdentityPath = path.join(
os.tmpdir(),
"openclaw-auth-rate-limit-" + randomUUID() + ".json",
`openclaw-auth-rate-limit-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
);
try {
const initial = await connectReq(ws, { token: "secret", deviceIdentityPath });
@@ -323,7 +322,7 @@ async function ensurePairedDeviceTokenForCurrentIdentity(ws: WebSocket): Promise
const deviceIdentityPath = path.join(
os.tmpdir(),
"openclaw-auth-device-" + randomUUID() + ".json",
`openclaw-auth-device-${Date.now()}-${Math.random().toString(36).slice(2)}.json`,
);
const res = await connectReq(ws, { token: "secret", deviceIdentityPath });

View File

@@ -348,13 +348,13 @@ describe("gateway plugin HTTP auth boundary", () => {
});
});
test("plugin routes take priority over control ui catch-all", async () => {
test("does not let plugin handlers shadow control ui routes", async () => {
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
if (pathname === "/my-plugin/inbound") {
if (pathname === "/chat") {
res.statusCode = 200;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("plugin-handled");
res.end("plugin-shadow");
return true;
}
return false;
@@ -369,34 +369,12 @@ describe("gateway plugin HTTP auth boundary", () => {
controlUiRoot: { kind: "missing" },
handlePluginRequest,
},
run: async (server) => {
const response = await sendRequest(server, { path: "/my-plugin/inbound" });
expect(response.res.statusCode).toBe(200);
expect(response.getBody()).toContain("plugin-handled");
expect(handlePluginRequest).toHaveBeenCalledTimes(1);
},
});
});
test("unmatched plugin paths fall through to control ui", async () => {
const handlePluginRequest = vi.fn(async () => false);
await withGatewayServer({
prefix: "openclaw-plugin-http-control-ui-fallthrough-test-",
resolvedAuth: AUTH_NONE,
overrides: {
controlUiEnabled: true,
controlUiBasePath: "",
controlUiRoot: { kind: "missing" },
handlePluginRequest,
},
run: async (server) => {
const response = await sendRequest(server, { path: "/chat" });
expect(handlePluginRequest).toHaveBeenCalledTimes(1);
expect(response.res.statusCode).toBe(503);
expect(response.getBody()).toContain("Control UI assets not found");
expect(handlePluginRequest).not.toHaveBeenCalled();
},
});
});

View File

@@ -26,7 +26,6 @@ type ConsoleSnapshot = {
};
let originalIsTty: boolean | undefined;
let originalOpenClawTestConsole: string | undefined;
let snapshot: ConsoleSnapshot;
let logging: typeof import("../logging.js");
let state: typeof import("./state.js");
@@ -47,8 +46,6 @@ beforeEach(() => {
trace: console.trace,
};
originalIsTty = process.stdout.isTTY;
originalOpenClawTestConsole = process.env.OPENCLAW_TEST_CONSOLE;
process.env.OPENCLAW_TEST_CONSOLE = "1";
Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true });
});
@@ -59,11 +56,6 @@ afterEach(() => {
console.error = snapshot.error;
console.debug = snapshot.debug;
console.trace = snapshot.trace;
if (originalOpenClawTestConsole === undefined) {
delete process.env.OPENCLAW_TEST_CONSOLE;
} else {
process.env.OPENCLAW_TEST_CONSOLE = originalOpenClawTestConsole;
}
Object.defineProperty(process.stdout, "isTTY", { value: originalIsTty, configurable: true });
logging.setConsoleConfigLoaderForTests();
vi.restoreAllMocks();

View File

@@ -58,19 +58,6 @@ function normalizeConsoleStyle(style?: string): ConsoleStyle {
}
function resolveConsoleSettings(): ConsoleSettings {
const envLevel = resolveEnvLogLevelOverride();
// Test runs default to silent console logging unless explicitly overridden.
// Skip config-file and full config fallback reads in this fast path.
if (
process.env.VITEST === "true" &&
process.env.OPENCLAW_TEST_CONSOLE !== "1" &&
!isVerbose() &&
!envLevel &&
!loggingState.overrideSettings
) {
return { level: "silent", style: normalizeConsoleStyle(undefined) };
}
let cfg: OpenClawConfig["logging"] | undefined =
(loggingState.overrideSettings as LoggerSettings | null) ?? readLoggingConfig();
if (!cfg) {
@@ -85,6 +72,7 @@ function resolveConsoleSettings(): ConsoleSettings {
}
}
}
const envLevel = resolveEnvLogLevelOverride();
const level = envLevel ?? normalizeConsoleLevel(cfg?.consoleLevel);
const style = normalizeConsoleStyle(cfg?.consoleStyle);
return { level, style };

View File

@@ -1,66 +0,0 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const { fallbackRequireMock, readLoggingConfigMock } = vi.hoisted(() => ({
readLoggingConfigMock: vi.fn(() => undefined),
fallbackRequireMock: vi.fn(() => {
throw new Error("config fallback should not be used in this test");
}),
}));
vi.mock("./config.js", () => ({
readLoggingConfig: readLoggingConfigMock,
}));
vi.mock("./node-require.js", () => ({
resolveNodeRequireFromMeta: () => fallbackRequireMock,
}));
let originalTestFileLog: string | undefined;
let originalOpenClawLogLevel: string | undefined;
let logging: typeof import("../logging.js");
beforeAll(async () => {
logging = await import("../logging.js");
});
beforeEach(() => {
originalTestFileLog = process.env.OPENCLAW_TEST_FILE_LOG;
originalOpenClawLogLevel = process.env.OPENCLAW_LOG_LEVEL;
delete process.env.OPENCLAW_TEST_FILE_LOG;
delete process.env.OPENCLAW_LOG_LEVEL;
readLoggingConfigMock.mockClear();
fallbackRequireMock.mockClear();
logging.resetLogger();
logging.setLoggerOverride(null);
});
afterEach(() => {
if (originalTestFileLog === undefined) {
delete process.env.OPENCLAW_TEST_FILE_LOG;
} else {
process.env.OPENCLAW_TEST_FILE_LOG = originalTestFileLog;
}
if (originalOpenClawLogLevel === undefined) {
delete process.env.OPENCLAW_LOG_LEVEL;
} else {
process.env.OPENCLAW_LOG_LEVEL = originalOpenClawLogLevel;
}
logging.resetLogger();
logging.setLoggerOverride(null);
vi.restoreAllMocks();
});
describe("getResolvedLoggerSettings", () => {
it("uses a silent fast path in default Vitest mode without config reads", () => {
const settings = logging.getResolvedLoggerSettings();
expect(settings.level).toBe("silent");
expect(readLoggingConfigMock).not.toHaveBeenCalled();
expect(fallbackRequireMock).not.toHaveBeenCalled();
});
it("reads logging config when test file logging is explicitly enabled", () => {
process.env.OPENCLAW_TEST_FILE_LOG = "1";
const settings = logging.getResolvedLoggerSettings();
expect(settings.level).toBe("info");
});
});

View File

@@ -55,27 +55,7 @@ function attachExternalTransport(logger: TsLogger<LogObj>, transport: LogTranspo
});
}
function canUseSilentVitestFileLogFastPath(envLevel: LogLevel | undefined): boolean {
return (
process.env.VITEST === "true" &&
process.env.OPENCLAW_TEST_FILE_LOG !== "1" &&
!envLevel &&
!loggingState.overrideSettings
);
}
function resolveSettings(): ResolvedSettings {
const envLevel = resolveEnvLogLevelOverride();
// Test runs default file logs to silent. Skip config reads and fallback load in the
// common case to avoid pulling heavy config/schema stacks on startup.
if (canUseSilentVitestFileLogFastPath(envLevel)) {
return {
level: "silent",
file: defaultRollingPathForToday(),
maxFileBytes: DEFAULT_MAX_LOG_FILE_BYTES,
};
}
let cfg: OpenClawConfig["logging"] | undefined =
(loggingState.overrideSettings as LoggerSettings | null) ?? readLoggingConfig();
if (!cfg) {
@@ -93,6 +73,7 @@ function resolveSettings(): ResolvedSettings {
const defaultLevel =
process.env.VITEST === "true" && process.env.OPENCLAW_TEST_FILE_LOG !== "1" ? "silent" : "info";
const fromConfig = normalizeLogLevel(cfg?.level, defaultLevel);
const envLevel = resolveEnvLogLevelOverride();
const level = envLevel ?? fromConfig;
const file = cfg?.file ?? defaultRollingPathForToday();
const maxFileBytes = resolveMaxLogFileBytes(cfg?.maxFileBytes);
@@ -118,20 +99,6 @@ export function isFileLogLevelEnabled(level: LogLevel): boolean {
}
function buildLogger(settings: ResolvedSettings): TsLogger<LogObj> {
const logger = new TsLogger<LogObj>({
name: "openclaw",
minLevel: levelToMinLevel(settings.level),
type: "hidden", // no ansi formatting
});
// Silent logging does not write files; skip all filesystem setup in this path.
if (settings.level === "silent") {
for (const transport of externalTransports) {
attachExternalTransport(logger, transport);
}
return logger;
}
fs.mkdirSync(path.dirname(settings.file), { recursive: true });
// Clean up stale rolling logs when using a dated log filename.
if (isRollingPath(settings.file)) {
@@ -139,6 +106,11 @@ function buildLogger(settings: ResolvedSettings): TsLogger<LogObj> {
}
let currentFileBytes = getCurrentLogFileBytes(settings.file);
let warnedAboutSizeCap = false;
const logger = new TsLogger<LogObj>({
name: "openclaw",
minLevel: levelToMinLevel(settings.level),
type: "hidden", // no ansi formatting
});
logger.attachTransport((logObj: LogObj) => {
try {

View File

@@ -1,5 +1,6 @@
import { Chalk } from "chalk";
import type { Logger as TsLogger } from "tslog";
import { CHAT_CHANNEL_ORDER } from "../channels/registry.js";
import { isVerbose } from "../globals.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { clearActiveProgressLine } from "../terminal/progress-line.js";
@@ -93,17 +94,7 @@ const SUBSYSTEM_COLOR_OVERRIDES: Record<string, (typeof SUBSYSTEM_COLORS)[number
};
const SUBSYSTEM_PREFIXES_TO_DROP = ["gateway", "channels", "providers"] as const;
const SUBSYSTEM_MAX_SEGMENTS = 2;
// Keep local to avoid importing channel registry into hot logging paths.
const CHANNEL_SUBSYSTEM_PREFIXES = new Set<string>([
"telegram",
"whatsapp",
"discord",
"irc",
"googlechat",
"slack",
"signal",
"imessage",
]);
const CHANNEL_SUBSYSTEM_PREFIXES = new Set<string>(CHAT_CHANNEL_ORDER);
function pickSubsystemColor(color: ChalkInstance, subsystem: string): ChalkInstance {
const override = SUBSYSTEM_COLOR_OVERRIDES[subsystem];

View File

@@ -133,9 +133,10 @@ describe("QmdMemoryManager", () => {
tmpRoot = path.join(fixtureRoot, `case-${fixtureCount++}`);
workspaceDir = path.join(tmpRoot, "workspace");
stateDir = path.join(tmpRoot, "state");
// Only workspace must exist for configured collection paths; state paths are
// created lazily by manager code when needed.
await fs.mkdir(workspaceDir, { recursive: true });
await Promise.all([
fs.mkdir(workspaceDir, { recursive: true }),
fs.mkdir(stateDir, { recursive: true }),
]);
process.env.OPENCLAW_STATE_DIR = stateDir;
cfg = {
agents: {

View File

@@ -1,61 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import {
clearPluginCommands,
getPluginCommandSpecs,
listPluginCommands,
registerPluginCommand,
} from "./commands.js";
afterEach(() => {
clearPluginCommands();
});
describe("registerPluginCommand", () => {
it("rejects malformed runtime command shapes", () => {
const invalidName = registerPluginCommand(
"demo-plugin",
// Runtime plugin payloads are untyped; guard at boundary.
{
name: undefined as unknown as string,
description: "Demo",
handler: async () => ({ text: "ok" }),
},
);
expect(invalidName).toEqual({
ok: false,
error: "Command name must be a string",
});
const invalidDescription = registerPluginCommand("demo-plugin", {
name: "demo",
description: undefined as unknown as string,
handler: async () => ({ text: "ok" }),
});
expect(invalidDescription).toEqual({
ok: false,
error: "Command description must be a string",
});
});
it("normalizes command metadata for downstream consumers", () => {
const result = registerPluginCommand("demo-plugin", {
name: " demo_cmd ",
description: " Demo command ",
handler: async () => ({ text: "ok" }),
});
expect(result).toEqual({ ok: true });
expect(listPluginCommands()).toEqual([
{
name: "demo_cmd",
description: "Demo command",
pluginId: "demo-plugin",
},
]);
expect(getPluginCommandSpecs()).toEqual([
{
name: "demo_cmd",
description: "Demo command",
},
]);
});
});

View File

@@ -119,36 +119,23 @@ export function registerPluginCommand(
return { ok: false, error: "Command handler must be a function" };
}
if (typeof command.name !== "string") {
return { ok: false, error: "Command name must be a string" };
}
if (typeof command.description !== "string") {
return { ok: false, error: "Command description must be a string" };
}
const name = command.name.trim();
const description = command.description.trim();
if (!description) {
return { ok: false, error: "Command description cannot be empty" };
}
const validationError = validateCommandName(name);
const validationError = validateCommandName(command.name);
if (validationError) {
return { ok: false, error: validationError };
}
const key = `/${name.toLowerCase()}`;
const key = `/${command.name.toLowerCase()}`;
// Check for duplicate registration
if (pluginCommands.has(key)) {
const existing = pluginCommands.get(key)!;
return {
ok: false,
error: `Command "${name}" already registered by plugin "${existing.pluginId}"`,
error: `Command "${command.name}" already registered by plugin "${existing.pluginId}"`,
};
}
pluginCommands.set(key, { ...command, name, description, pluginId });
pluginCommands.set(key, { ...command, pluginId });
logVerbose(`Registered plugin command: ${key} (plugin: ${pluginId})`);
return { ok: true };
}

View File

@@ -1,28 +1,10 @@
import { createRequire } from "node:module";
import type { ErrorObject, ValidateFunction } from "ajv";
import AjvPkg, { type ErrorObject, type ValidateFunction } from "ajv";
const require = createRequire(import.meta.url);
type AjvLike = {
compile: (schema: Record<string, unknown>) => ValidateFunction;
};
let ajvSingleton: AjvLike | null = null;
function getAjv(): AjvLike {
if (ajvSingleton) {
return ajvSingleton;
}
const ajvModule = require("ajv") as { default?: new (opts?: object) => AjvLike };
const AjvCtor =
typeof ajvModule.default === "function"
? ajvModule.default
: (ajvModule as unknown as new (opts?: object) => AjvLike);
ajvSingleton = new AjvCtor({
allErrors: true,
strict: false,
removeAdditional: false,
});
return ajvSingleton;
}
const ajv = new (AjvPkg as unknown as new (opts?: object) => import("ajv").default)({
allErrors: true,
strict: false,
removeAdditional: false,
});
type CachedValidator = {
validate: ValidateFunction;
@@ -49,7 +31,7 @@ export function validateJsonSchemaValue(params: {
}): { ok: true } | { ok: false; errors: string[] } {
let cached = schemaCache.get(params.cacheKey);
if (!cached || cached.schema !== params.schema) {
const validate = getAjv().compile(params.schema);
const validate = ajv.compile(params.schema);
cached = { validate, schema: params.schema };
schemaCache.set(params.cacheKey, cached);
}

View File

@@ -1,73 +0,0 @@
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
const spawnMock = vi.hoisted(() => vi.fn());
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
spawn: spawnMock,
};
});
import { runCommandWithTimeout } from "./exec.js";
function createFakeSpawnedChild() {
const child = new EventEmitter() as EventEmitter & ChildProcess;
const stdout = new EventEmitter();
const stderr = new EventEmitter();
let killed = false;
const kill = vi.fn<(signal?: NodeJS.Signals) => boolean>(() => {
killed = true;
return true;
});
Object.defineProperty(child, "killed", {
get: () => killed,
configurable: true,
});
Object.defineProperty(child, "pid", {
value: 12345,
configurable: true,
});
child.stdout = stdout as ChildProcess["stdout"];
child.stderr = stderr as ChildProcess["stderr"];
child.stdin = null;
child.kill = kill as ChildProcess["kill"];
return { child, stdout, stderr, kill };
}
describe("runCommandWithTimeout no-output timer", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("resets no-output timeout when spawned child keeps emitting stdout", async () => {
vi.useFakeTimers();
const fake = createFakeSpawnedChild();
spawnMock.mockReturnValue(fake.child);
const runPromise = runCommandWithTimeout(["node", "-e", "ignored"], {
timeoutMs: 1_000,
noOutputTimeoutMs: 80,
});
fake.stdout.emit("data", Buffer.from("."));
await vi.advanceTimersByTimeAsync(40);
fake.stdout.emit("data", Buffer.from("."));
await vi.advanceTimersByTimeAsync(40);
fake.stdout.emit("data", Buffer.from("."));
await vi.advanceTimersByTimeAsync(20);
fake.child.emit("close", 0, null);
const result = await runPromise;
expect(result.code ?? 0).toBe(0);
expect(result.termination).toBe("exit");
expect(result.noOutputTimedOut).toBe(false);
expect(result.stdout).toBe("...");
expect(fake.kill).not.toHaveBeenCalled();
});
});

View File

@@ -56,6 +56,36 @@ describe("runCommandWithTimeout", () => {
expect(result.code).not.toBe(0);
});
it("resets no output timer when command keeps emitting output", async () => {
const result = await runCommandWithTimeout(
[
process.execPath,
"-e",
[
'process.stdout.write(".");',
"let count = 0;",
'const ticker = setInterval(() => { process.stdout.write(".");',
"count += 1;",
"if (count === 3) {",
"clearInterval(ticker);",
"process.exit(0);",
"}",
"}, 6);",
].join(" "),
],
{
timeoutMs: 180,
// Keep a healthy margin above the emit interval while avoiding long idle waits.
noOutputTimeoutMs: 120,
},
);
expect(result.code ?? 0).toBe(0);
expect(result.termination).toBe("exit");
expect(result.noOutputTimedOut).toBe(false);
expect(result.stdout.length).toBeGreaterThanOrEqual(3);
});
it("reports global timeout termination when overall timeout elapses", async () => {
const result = await runCommandWithTimeout(
[process.execPath, "-e", "setTimeout(() => {}, 10)"],

View File

@@ -57,10 +57,6 @@ describe("markdownToSlackMrkdwn", () => {
"*Important:* Check the _docs_ at <https://example.com|link>\n\n• first\n• second",
);
});
it("does not throw when input is undefined at runtime", () => {
expect(markdownToSlackMrkdwn(undefined as unknown as string)).toBe("");
});
});
describe("escapeSlackMrkdwn", () => {

View File

@@ -28,9 +28,6 @@ function isAllowedSlackAngleToken(token: string): boolean {
}
function escapeSlackMrkdwnContent(text: string): string {
if (!text) {
return "";
}
if (!text.includes("&") && !text.includes("<") && !text.includes(">")) {
return text;
}
@@ -56,9 +53,6 @@ function escapeSlackMrkdwnContent(text: string): string {
}
function escapeSlackMrkdwnText(text: string): string {
if (!text) {
return "";
}
if (!text.includes("&") && !text.includes("<") && !text.includes(">")) {
return text;
}

View File

@@ -33,6 +33,8 @@ function createMessageHandlers(overrides?: SlackSystemEventTestOverrides) {
});
return {
handler: harness.getHandler("message") as MessageHandler | null,
channelHandler: harness.getHandler("message.channels") as MessageHandler | null,
groupHandler: harness.getHandler("message.groups") as MessageHandler | null,
handleSlackMessage,
};
}
@@ -157,17 +159,17 @@ describe("registerSlackMessageEvents", () => {
expect(messageQueueMock).not.toHaveBeenCalled();
});
it("handles channel and group messages via the unified message handler", async () => {
it("registers and forwards message.channels and message.groups events", async () => {
messageQueueMock.mockClear();
messageAllowMock.mockReset().mockResolvedValue([]);
const { handler, handleSlackMessage } = createMessageHandlers({
const { channelHandler, groupHandler, handleSlackMessage } = createMessageHandlers({
dmPolicy: "open",
channelType: "channel",
});
expect(handler).toBeTruthy();
expect(channelHandler).toBeTruthy();
expect(groupHandler).toBeTruthy();
// channel_type distinguishes the source; all arrive as event type "message"
const channelMessage = {
type: "message",
channel: "C1",
@@ -176,8 +178,8 @@ describe("registerSlackMessageEvents", () => {
text: "hello channel",
ts: "123.100",
};
await handler!({ event: channelMessage, body: {} });
await handler!({
await channelHandler!({ event: channelMessage, body: {} });
await groupHandler!({
event: {
...channelMessage,
channel_type: "group",
@@ -191,19 +193,17 @@ describe("registerSlackMessageEvents", () => {
expect(messageQueueMock).not.toHaveBeenCalled();
});
it("applies subtype system-event handling for channel messages", async () => {
it("applies subtype system-event handling for message.channels events", async () => {
messageQueueMock.mockClear();
messageAllowMock.mockReset().mockResolvedValue([]);
const { handler, handleSlackMessage } = createMessageHandlers({
const { channelHandler, handleSlackMessage } = createMessageHandlers({
dmPolicy: "open",
channelType: "channel",
});
expect(handler).toBeTruthy();
expect(channelHandler).toBeTruthy();
// message_changed events from channels arrive via the generic "message"
// handler with channel_type:"channel" — not a separate event type.
await handler!({
await channelHandler!({
event: {
...makeChangedEvent({ channel: "C1", user: "U1" }),
channel_type: "channel",

View File

@@ -46,15 +46,23 @@ export function registerSlackMessageEvents(params: {
}
};
// NOTE: Slack Event Subscriptions use names like "message.channels" and
// "message.groups" to control *which* message events are delivered, but the
// actual event payload always arrives with `type: "message"`. The
// `channel_type` field ("channel" | "group" | "im" | "mpim") distinguishes
// the source. Bolt rejects `app.event("message.channels")` since v4.6
// because it is a subscription label, not a valid event type.
ctx.app.event("message", async ({ event, body }: SlackEventMiddlewareArgs<"message">) => {
await handleIncomingMessageEvent({ event, body });
});
// Slack may dispatch channel/group message subscriptions under typed event
// names. Register explicit handlers so both delivery styles are supported.
ctx.app.event(
"message.channels",
async ({ event, body }: SlackEventMiddlewareArgs<"message.channels">) => {
await handleIncomingMessageEvent({ event, body });
},
);
ctx.app.event(
"message.groups",
async ({ event, body }: SlackEventMiddlewareArgs<"message.groups">) => {
await handleIncomingMessageEvent({ event, body });
},
);
ctx.app.event("app_mention", async ({ event, body }: SlackEventMiddlewareArgs<"app_mention">) => {
try {

View File

@@ -1,69 +0,0 @@
import { describe, expect, it } from "vitest";
import type { SlackMessageEvent } from "../types.js";
import { buildSlackDebounceKey } from "./message-handler.js";
function makeMessage(overrides: Partial<SlackMessageEvent> = {}): SlackMessageEvent {
return {
type: "message",
channel: "C123",
user: "U456",
ts: "1709000000.000100",
text: "hello",
...overrides,
} as SlackMessageEvent;
}
describe("buildSlackDebounceKey", () => {
const accountId = "default";
it("returns null when message has no sender", () => {
const msg = makeMessage({ user: undefined, bot_id: undefined });
expect(buildSlackDebounceKey(msg, accountId)).toBeNull();
});
it("scopes thread replies by thread_ts", () => {
const msg = makeMessage({ thread_ts: "1709000000.000001" });
expect(buildSlackDebounceKey(msg, accountId)).toBe("slack:default:C123:1709000000.000001:U456");
});
it("isolates unresolved thread replies with maybe-thread prefix", () => {
const msg = makeMessage({
parent_user_id: "U789",
thread_ts: undefined,
ts: "1709000000.000200",
});
expect(buildSlackDebounceKey(msg, accountId)).toBe(
"slack:default:C123:maybe-thread:1709000000.000200:U456",
);
});
it("scopes top-level messages by their own timestamp to prevent cross-thread collisions", () => {
const msgA = makeMessage({ ts: "1709000000.000100" });
const msgB = makeMessage({ ts: "1709000000.000200" });
const keyA = buildSlackDebounceKey(msgA, accountId);
const keyB = buildSlackDebounceKey(msgB, accountId);
// Different timestamps => different debounce keys
expect(keyA).not.toBe(keyB);
expect(keyA).toBe("slack:default:C123:1709000000.000100:U456");
expect(keyB).toBe("slack:default:C123:1709000000.000200:U456");
});
it("keeps top-level DMs channel-scoped to preserve short-message batching", () => {
const dmA = makeMessage({ channel: "D123", ts: "1709000000.000100" });
const dmB = makeMessage({ channel: "D123", ts: "1709000000.000200" });
expect(buildSlackDebounceKey(dmA, accountId)).toBe("slack:default:D123:U456");
expect(buildSlackDebounceKey(dmB, accountId)).toBe("slack:default:D123:U456");
});
it("falls back to bare channel when no timestamp is available", () => {
const msg = makeMessage({ ts: undefined, event_ts: undefined });
expect(buildSlackDebounceKey(msg, accountId)).toBe("slack:default:C123:U456");
});
it("uses bot_id as sender fallback", () => {
const msg = makeMessage({ user: undefined, bot_id: "B999" });
expect(buildSlackDebounceKey(msg, accountId)).toBe("slack:default:C123:1709000000.000100:B999");
});
});

View File

@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { createSlackMessageHandler } from "./message-handler.js";
const enqueueMock = vi.fn(async (_entry: unknown) => {});
const flushKeyMock = vi.fn(async (_key: string) => {});
const resolveThreadTsMock = vi.fn(async ({ message }: { message: Record<string, unknown> }) => ({
...message,
}));
@@ -11,7 +10,6 @@ vi.mock("../../auto-reply/inbound-debounce.js", () => ({
resolveInboundDebounceMs: () => 10,
createInboundDebouncer: () => ({
enqueue: (entry: unknown) => enqueueMock(entry),
flushKey: (key: string) => flushKeyMock(key),
}),
}));
@@ -39,7 +37,6 @@ function createContext(overrides?: {
describe("createSlackMessageHandler", () => {
beforeEach(() => {
enqueueMock.mockClear();
flushKeyMock.mockClear();
resolveThreadTsMock.mockClear();
});
@@ -116,38 +113,4 @@ describe("createSlackMessageHandler", () => {
expect(resolveThreadTsMock).toHaveBeenCalledTimes(1);
expect(enqueueMock).toHaveBeenCalledTimes(1);
});
it("flushes pending top-level buffered keys before immediate non-debounce follow-ups", async () => {
const handler = createSlackMessageHandler({
ctx: createContext(),
account: { accountId: "default" } as Parameters<
typeof createSlackMessageHandler
>[0]["account"],
});
await handler(
{
type: "message",
channel: "C111",
user: "U111",
ts: "1709000000.000100",
text: "first buffered text",
} as never,
{ source: "message" },
);
await handler(
{
type: "message",
subtype: "file_share",
channel: "C111",
user: "U111",
ts: "1709000000.000200",
text: "file follows",
files: [{ id: "F1" }],
} as never,
{ source: "message" },
);
expect(flushKeyMock).toHaveBeenCalledWith("slack:default:C111:1709000000.000100:U111");
});
});

View File

@@ -16,71 +16,6 @@ export type SlackMessageHandler = (
opts: { source: "message" | "app_mention"; wasMentioned?: boolean },
) => Promise<void>;
function resolveSlackSenderId(message: SlackMessageEvent): string | null {
return message.user ?? message.bot_id ?? null;
}
function isSlackDirectMessageChannel(channelId: string): boolean {
return channelId.startsWith("D");
}
function isTopLevelSlackMessage(message: SlackMessageEvent): boolean {
return !message.thread_ts && !message.parent_user_id;
}
function buildTopLevelSlackConversationKey(
message: SlackMessageEvent,
accountId: string,
): string | null {
if (!isTopLevelSlackMessage(message)) {
return null;
}
const senderId = resolveSlackSenderId(message);
if (!senderId) {
return null;
}
return `slack:${accountId}:${message.channel}:${senderId}`;
}
function shouldDebounceSlackMessage(message: SlackMessageEvent, cfg: SlackMonitorContext["cfg"]) {
const text = message.text ?? "";
if (!text.trim()) {
return false;
}
if (message.files && message.files.length > 0) {
return false;
}
const textForCommandDetection = stripSlackMentionsForCommandDetection(text);
return !hasControlCommand(textForCommandDetection, cfg);
}
/**
* Build a debounce key that isolates messages by thread (or by message timestamp
* for top-level non-DM channel messages). Without per-message scoping, concurrent
* top-level messages from the same sender can share a key and get merged
* into a single reply on the wrong thread.
*
* DMs intentionally stay channel-scoped to preserve short-message batching.
*/
export function buildSlackDebounceKey(
message: SlackMessageEvent,
accountId: string,
): string | null {
const senderId = resolveSlackSenderId(message);
if (!senderId) {
return null;
}
const messageTs = message.ts ?? message.event_ts;
const threadKey = message.thread_ts
? `${message.channel}:${message.thread_ts}`
: message.parent_user_id && messageTs
? `${message.channel}:maybe-thread:${messageTs}`
: messageTs && !isSlackDirectMessageChannel(message.channel)
? `${message.channel}:${messageTs}`
: message.channel;
return `slack:${accountId}:${threadKey}:${senderId}`;
}
export function createSlackMessageHandler(params: {
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
@@ -90,34 +25,42 @@ export function createSlackMessageHandler(params: {
const { ctx, account, trackEvent } = params;
const debounceMs = resolveInboundDebounceMs({ cfg: ctx.cfg, channel: "slack" });
const threadTsResolver = createSlackThreadTsResolver({ client: ctx.app.client });
const pendingTopLevelDebounceKeys = new Map<string, Set<string>>();
const debouncer = createInboundDebouncer<{
message: SlackMessageEvent;
opts: { source: "message" | "app_mention"; wasMentioned?: boolean };
}>({
debounceMs,
buildKey: (entry) => buildSlackDebounceKey(entry.message, ctx.accountId),
shouldDebounce: (entry) => shouldDebounceSlackMessage(entry.message, ctx.cfg),
buildKey: (entry) => {
const senderId = entry.message.user ?? entry.message.bot_id;
if (!senderId) {
return null;
}
const messageTs = entry.message.ts ?? entry.message.event_ts;
// If Slack flags a thread reply but omits thread_ts, isolate it from root debouncing.
const threadKey = entry.message.thread_ts
? `${entry.message.channel}:${entry.message.thread_ts}`
: entry.message.parent_user_id && messageTs
? `${entry.message.channel}:maybe-thread:${messageTs}`
: entry.message.channel;
return `slack:${ctx.accountId}:${threadKey}:${senderId}`;
},
shouldDebounce: (entry) => {
const text = entry.message.text ?? "";
if (!text.trim()) {
return false;
}
if (entry.message.files && entry.message.files.length > 0) {
return false;
}
const textForCommandDetection = stripSlackMentionsForCommandDetection(text);
return !hasControlCommand(textForCommandDetection, ctx.cfg);
},
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) {
return;
}
const flushedKey = buildSlackDebounceKey(last.message, ctx.accountId);
const topLevelConversationKey = buildTopLevelSlackConversationKey(
last.message,
ctx.accountId,
);
if (flushedKey && topLevelConversationKey) {
const pendingKeys = pendingTopLevelDebounceKeys.get(topLevelConversationKey);
if (pendingKeys) {
pendingKeys.delete(flushedKey);
if (pendingKeys.size === 0) {
pendingTopLevelDebounceKeys.delete(topLevelConversationKey);
}
}
}
const combinedText =
entries.length === 1
? (last.message.text ?? "")
@@ -174,23 +117,6 @@ export function createSlackMessageHandler(params: {
}
trackEvent?.();
const resolvedMessage = await threadTsResolver.resolve({ message, source: opts.source });
const debounceKey = buildSlackDebounceKey(resolvedMessage, ctx.accountId);
const conversationKey = buildTopLevelSlackConversationKey(resolvedMessage, ctx.accountId);
const canDebounce = debounceMs > 0 && shouldDebounceSlackMessage(resolvedMessage, ctx.cfg);
if (!canDebounce && conversationKey) {
const pendingKeys = pendingTopLevelDebounceKeys.get(conversationKey);
if (pendingKeys && pendingKeys.size > 0) {
const keysToFlush = Array.from(pendingKeys);
for (const pendingKey of keysToFlush) {
await debouncer.flushKey(pendingKey);
}
}
}
if (canDebounce && debounceKey && conversationKey) {
const pendingKeys = pendingTopLevelDebounceKeys.get(conversationKey) ?? new Set<string>();
pendingKeys.add(debounceKey);
pendingTopLevelDebounceKeys.set(conversationKey, pendingKeys);
}
await debouncer.enqueue({ message: resolvedMessage, opts });
};
}

View File

@@ -104,25 +104,6 @@ describe("slack prepareSlackMessage inbound contract", () => {
userTokenSource: "none",
config: {},
};
const defaultMessageTemplate: SlackMessageEvent = {
channel: "D123",
channel_type: "im",
user: "U1",
text: "hi",
ts: "1.000",
} as SlackMessageEvent;
const threadAccount: ResolvedSlackAccount = {
accountId: "default",
enabled: true,
botTokenSource: "config",
appTokenSource: "config",
userTokenSource: "none",
config: {
replyToMode: "all",
thread: { initialHistoryLimit: 20 },
},
replyToMode: "all",
};
async function prepareWithDefaultCtx(message: SlackMessageEvent) {
return prepareSlackMessage({
@@ -148,7 +129,14 @@ describe("slack prepareSlackMessage inbound contract", () => {
}
function createSlackMessage(overrides: Partial<SlackMessageEvent>): SlackMessageEvent {
return { ...defaultMessageTemplate, ...overrides } as SlackMessageEvent;
return {
channel: "D123",
channel_type: "im",
user: "U1",
text: "hi",
ts: "1.000",
...overrides,
} as SlackMessageEvent;
}
async function prepareMessageWith(
@@ -174,7 +162,18 @@ describe("slack prepareSlackMessage inbound contract", () => {
}
function createThreadAccount(): ResolvedSlackAccount {
return threadAccount;
return {
accountId: "default",
enabled: true,
botTokenSource: "config",
appTokenSource: "config",
userTokenSource: "none",
config: {
replyToMode: "all",
thread: { initialHistoryLimit: 20 },
},
replyToMode: "all",
};
}
function createThreadReplyMessage(overrides: Partial<SlackMessageEvent>): SlackMessageEvent {

View File

@@ -60,27 +60,6 @@ describe("bot-native-command-menu", () => {
expect(result.issues).toEqual([]);
});
it("ignores malformed plugin specs without crashing", () => {
const malformedSpecs = [
{ name: "valid", description: " Works " },
{ name: "missing-description", description: undefined },
{ name: undefined, description: "Missing name" },
] as unknown as Parameters<typeof buildPluginTelegramMenuCommands>[0]["specs"];
const result = buildPluginTelegramMenuCommands({
specs: malformedSpecs,
existingCommands: new Set<string>(),
});
expect(result.commands).toEqual([{ command: "valid", description: "Works" }]);
expect(result.issues).toContain(
'Plugin command "/missing_description" is missing a description.',
);
expect(result.issues).toContain(
'Plugin command "/<unknown>" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).',
);
});
it("deletes stale commands before setting new menu", async () => {
const callOrder: string[] = [];
const deleteMyCommands = vi.fn(async () => {

View File

@@ -15,8 +15,8 @@ export type TelegramMenuCommand = {
};
type TelegramPluginCommandSpec = {
name: unknown;
description: unknown;
name: string;
description: string;
};
function isBotCommandsTooMuchError(err: unknown): boolean {
@@ -54,16 +54,14 @@ export function buildPluginTelegramMenuCommands(params: {
const pluginCommandNames = new Set<string>();
for (const spec of specs) {
const rawName = typeof spec.name === "string" ? spec.name : "";
const normalized = normalizeTelegramCommandName(rawName);
const normalized = normalizeTelegramCommandName(spec.name);
if (!normalized || !TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) {
const invalidName = rawName.trim() ? rawName : "<unknown>";
issues.push(
`Plugin command "/${invalidName}" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).`,
`Plugin command "/${spec.name}" is invalid for Telegram (use a-z, 0-9, underscore; max 32 chars).`,
);
continue;
}
const description = typeof spec.description === "string" ? spec.description.trim() : "";
const description = spec.description.trim();
if (!description) {
issues.push(`Plugin command "/${normalized}" is missing a description.`);
continue;

View File

@@ -13,7 +13,6 @@ const DEFAULT_GUARDRAIL_SKIP_PATTERNS = [
/\.test-helpers\.tsx?$/,
/\.test-utils\.tsx?$/,
/\.test-harness\.tsx?$/,
/\.suite\.tsx?$/,
/\.e2e\.tsx?$/,
/\.d\.ts$/,
/[\\/](?:__tests__|tests|test-utils)[\\/]/,

View File

@@ -4,23 +4,18 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
const baseGitEnv = {
GIT_CONFIG_NOSYSTEM: "1",
GIT_TERMINAL_PROMPT: "0",
};
const run = (cwd: string, cmd: string, args: string[] = [], env?: NodeJS.ProcessEnv) => {
return execFileSync(cmd, args, {
cwd,
encoding: "utf8",
env: { ...process.env, ...baseGitEnv, ...env },
env: env ? { ...process.env, ...env } : process.env,
}).trim();
};
describe("git-hooks/pre-commit (integration)", () => {
it("does not treat staged filenames as git-add flags (e.g. --all)", () => {
const dir = mkdtempSync(path.join(os.tmpdir(), "openclaw-pre-commit-"));
run(dir, "git", ["init", "-q", "--initial-branch=main"]);
run(dir, "git", ["init", "-q"]);
// Use the real hook script and lightweight helper stubs.
mkdirSync(path.join(dir, "git-hooks"), { recursive: true });

View File

@@ -12,9 +12,7 @@ const BASE_PATH = process.env.PATH ?? "/usr/bin:/bin";
const BASE_LANG = process.env.LANG ?? "C";
let fixtureRoot = "";
let sharedBinDir = "";
let sharedHomeDir = "";
let sharedHomeBinDir = "";
let sharedFakePythonPath = "";
let caseId = 0;
async function writeExecutable(filePath: string, body: string): Promise<void> {
await writeFile(filePath, body, "utf8");
@@ -59,14 +57,6 @@ describe("scripts/ios-team-id.sh", () => {
fixtureRoot = await mkdtemp(path.join(os.tmpdir(), "openclaw-ios-team-id-"));
sharedBinDir = path.join(fixtureRoot, "shared-bin");
await mkdir(sharedBinDir, { recursive: true });
sharedHomeDir = path.join(fixtureRoot, "home");
sharedHomeBinDir = path.join(sharedHomeDir, "bin");
await mkdir(sharedHomeBinDir, { recursive: true });
await mkdir(path.join(sharedHomeDir, "Library", "Preferences"), { recursive: true });
await writeFile(
path.join(sharedHomeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"),
"",
);
await writeExecutable(
path.join(sharedBinDir, "plutil"),
`#!/usr/bin/env bash
@@ -104,13 +94,6 @@ PLIST
fi
exit 1`,
);
sharedFakePythonPath = path.join(sharedHomeBinDir, "fake-python");
await writeExecutable(
sharedFakePythonPath,
`#!/usr/bin/env bash
printf 'AAAAA11111\\t0\\tAlpha Team\\r\\n'
printf 'BBBBB22222\\t0\\tBeta Team\\r\\n'`,
);
});
afterAll(async () => {
@@ -120,15 +103,33 @@ printf 'BBBBB22222\\t0\\tBeta Team\\r\\n'`,
await rm(fixtureRoot, { recursive: true, force: true });
});
async function createHomeDir(): Promise<{ homeDir: string; binDir: string }> {
const homeDir = path.join(fixtureRoot, `case-${caseId++}`);
await mkdir(homeDir, { recursive: true });
const binDir = path.join(homeDir, "bin");
await mkdir(binDir, { recursive: true });
await mkdir(path.join(homeDir, "Library", "Preferences"), { recursive: true });
await writeFile(path.join(homeDir, "Library", "Preferences", "com.apple.dt.Xcode.plist"), "");
return { homeDir, binDir };
}
it("resolves fallback and preferred team IDs from Xcode team listings", async () => {
const fallbackResult = runScript(sharedHomeDir, {
IOS_PYTHON_BIN: sharedFakePythonPath,
const { homeDir, binDir } = await createHomeDir();
await writeExecutable(
path.join(binDir, "fake-python"),
`#!/usr/bin/env bash
printf 'AAAAA11111\\t0\\tAlpha Team\\r\\n'
printf 'BBBBB22222\\t0\\tBeta Team\\r\\n'`,
);
const fallbackResult = runScript(homeDir, {
IOS_PYTHON_BIN: path.join(binDir, "fake-python"),
});
expect(fallbackResult.ok).toBe(true);
expect(fallbackResult.stdout).toBe("AAAAA11111");
const crlfResult = runScript(sharedHomeDir, {
IOS_PYTHON_BIN: sharedFakePythonPath,
const crlfResult = runScript(homeDir, {
IOS_PYTHON_BIN: path.join(binDir, "fake-python"),
IOS_PREFERRED_TEAM_ID: "BBBBB22222",
});
expect(crlfResult.ok).toBe(true);
@@ -136,7 +137,9 @@ printf 'BBBBB22222\\t0\\tBeta Team\\r\\n'`,
});
it("prints actionable guidance when Xcode account exists but no Team ID is resolvable", async () => {
const result = runScript(sharedHomeDir);
const { homeDir } = await createHomeDir();
const result = runScript(homeDir);
expect(result.ok).toBe(false);
expect(
result.stderr.includes("An Apple account is signed in to Xcode") ||

View File

@@ -1,4 +1,4 @@
import { afterAll, afterEach, beforeAll, vi } from "vitest";
import { afterAll, afterEach, beforeEach, vi } from "vitest";
// Ensure Vitest environment is properly set
process.env.VITEST = "true";
@@ -25,15 +25,12 @@ import { withIsolatedTestHome } from "./test-env.js";
const testEnv = withIsolatedTestHome();
afterAll(() => testEnv.cleanup());
const [
{ installProcessWarningFilter },
{ getActivePluginRegistry, setActivePluginRegistry },
{ createTestRegistry },
] = await Promise.all([
import("../src/infra/warning-filter.js"),
import("../src/plugins/runtime.js"),
import("../src/test-utils/channel-plugins.js"),
]);
const [{ installProcessWarningFilter }, { setActivePluginRegistry }, { createTestRegistry }] =
await Promise.all([
import("../src/infra/warning-filter.js"),
import("../src/plugins/runtime.js"),
import("../src/test-utils/channel-plugins.js"),
]);
installProcessWarningFilter();
@@ -175,18 +172,16 @@ const createDefaultRegistry = () =>
},
]);
// Creating a fresh registry before every test is measurable overhead.
// The registry is immutable by default; tests that override it are restored in afterEach.
// Creating a fresh registry before every single test was measurable overhead.
// The registry is treated as immutable by production code; tests that need a
// custom registry set it explicitly.
const DEFAULT_PLUGIN_REGISTRY = createDefaultRegistry();
beforeAll(() => {
beforeEach(() => {
setActivePluginRegistry(DEFAULT_PLUGIN_REGISTRY);
});
afterEach(() => {
if (getActivePluginRegistry() !== DEFAULT_PLUGIN_REGISTRY) {
setActivePluginRegistry(DEFAULT_PLUGIN_REGISTRY);
}
// Guard against leaked fake timers across test files/workers.
if (vi.isFakeTimers()) {
vi.useRealTimers();

View File

@@ -304,83 +304,6 @@ describe("config form renderer", () => {
expect(noMatchContainer.textContent).toContain('No settings match "mode tag:security"');
});
it("supports SecretInput unions in additionalProperties maps", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const schema = {
type: "object",
properties: {
models: {
type: "object",
properties: {
providers: {
type: "object",
additionalProperties: {
type: "object",
properties: {
apiKey: {
anyOf: [
{ type: "string" },
{
oneOf: [
{
type: "object",
properties: {
source: { type: "string", const: "env" },
provider: { type: "string" },
id: { type: "string" },
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
{
type: "object",
properties: {
source: { type: "string", const: "file" },
provider: { type: "string" },
id: { type: "string" },
},
required: ["source", "provider", "id"],
additionalProperties: false,
},
],
},
],
},
},
},
},
},
},
},
};
const analysis = analyzeConfigSchema(schema);
expect(analysis.unsupportedPaths).not.toContain("models.providers");
expect(analysis.unsupportedPaths).not.toContain("models.providers.*.apiKey");
render(
renderConfigForm({
schema: analysis.schema,
uiHints: {
"models.providers.*.apiKey": { sensitive: true },
},
unsupportedPaths: analysis.unsupportedPaths,
value: { models: { providers: { openai: { apiKey: "old" } } } },
onPatch,
}),
container,
);
const apiKeyInput: HTMLInputElement | null = container.querySelector("input[type='password']");
expect(apiKeyInput).not.toBeNull();
if (!apiKeyInput) {
return;
}
apiKeyInput.value = "new-key";
apiKeyInput.dispatchEvent(new Event("input", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["models", "providers", "openai", "apiKey"], "new-key");
});
it("flags unsupported unions", () => {
const schema = {
type: "object",

View File

@@ -118,58 +118,6 @@ function normalizeSchemaNode(
};
}
function isSecretRefVariant(entry: JsonSchema): boolean {
if (schemaType(entry) !== "object") {
return false;
}
const source = entry.properties?.source;
const provider = entry.properties?.provider;
const id = entry.properties?.id;
if (!source || !provider || !id) {
return false;
}
return (
typeof source.const === "string" &&
schemaType(provider) === "string" &&
schemaType(id) === "string"
);
}
function isSecretRefUnion(entry: JsonSchema): boolean {
const variants = entry.oneOf ?? entry.anyOf;
if (!variants || variants.length === 0) {
return false;
}
return variants.every((variant) => isSecretRefVariant(variant));
}
function normalizeSecretInputUnion(
schema: JsonSchema,
path: Array<string | number>,
remaining: JsonSchema[],
nullable: boolean,
): ConfigSchemaAnalysis | null {
const stringIndex = remaining.findIndex((entry) => schemaType(entry) === "string");
if (stringIndex < 0) {
return null;
}
const nonString = remaining.filter((_, index) => index !== stringIndex);
if (nonString.length !== 1 || !isSecretRefUnion(nonString[0])) {
return null;
}
return normalizeSchemaNode(
{
...schema,
...remaining[stringIndex],
nullable,
anyOf: undefined,
oneOf: undefined,
allOf: undefined,
},
path,
);
}
function normalizeUnion(
schema: JsonSchema,
path: Array<string | number>,
@@ -213,13 +161,6 @@ function normalizeUnion(
remaining.push(entry);
}
// Config secrets accept either a raw key string or a structured secret ref object.
// The form only supports editing the string path for now.
const secretInput = normalizeSecretInputUnion(schema, path, remaining, nullable);
if (secretInput) {
return secretInput;
}
if (literals.length > 0 && remaining.length === 0) {
const unique: unknown[] = [];
for (const value of literals) {