mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-27 09:52:10 +08:00
Compare commits
1 Commits
fix/volcen
...
fix/sandbo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32e4558e4f |
22
CHANGELOG.md
22
CHANGELOG.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.*`
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.*`
|
||||
|
||||
@@ -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** (auto‑generated, 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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)`);
|
||||
|
||||
|
||||
@@ -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: "",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -21,3 +21,4 @@ export {
|
||||
validateConfigObjectRawWithPlugins,
|
||||
validateConfigObjectWithPlugins,
|
||||
} from "./validation.js";
|
||||
export { OpenClawSchema } from "./zod-schema.js";
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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>`);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)"],
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)[\\/]/,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user