Compare commits

..

2 Commits

Author SHA1 Message Date
Peter Steinberger
be889937bc fix: enforce feishu dm policy + pairing flow (#14876) (thanks @coygeek) 2026-02-13 05:44:35 +01:00
Coy Geek
d00d6876f5 fix(aa-01): apply security fix
Generated by staged fix workflow.
2026-02-13 05:44:35 +01:00
132 changed files with 873 additions and 2777 deletions

View File

@@ -6,7 +6,6 @@ Docs: https://docs.openclaw.ai
### Fixes
- Security/Gateway + ACP: block high-risk tools (`sessions_spawn`, `sessions_send`, `gateway`, `whatsapp_login`) from HTTP `/tools/invoke` by default with `gateway.tools.{allow,deny}` overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting `allow_always`/`reject_always`. (#15390) Thanks @aether-ai-agent.
- Security/Audit: distinguish external webhooks (`hooks.enabled`) from internal hooks (`hooks.internal.enabled`) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr.
- Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u.
- Sandbox: pass configured `sandbox.docker.env` variables to sandbox containers at `docker create` time. (#15138) Thanks @stevebot-alive.
@@ -18,9 +17,6 @@ Docs: https://docs.openclaw.ai
- Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.
- Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.
- Sessions/Agents: pass `agentId` when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with `Session file path must be within sessions directory`. (#15141) Thanks @Goldenmonstew.
- Sessions/Agents: pass `agentId` through status and usage transcript-resolution paths (auto-reply, gateway usage APIs, and session cost/log loaders) so non-default agents can resolve absolute session files without path-validation failures. (#15103) Thanks @jalehman.
- Sessions: accept legacy absolute `sessionFile` paths from prior releases while preserving containment checks to block traversal escapes. (#15323) Thanks @mudrii.
- Signal/Install: auto-install `signal-cli` via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary `Exec format error` failures on arm64/arm hosts. (#15443) Thanks @jogvan-k.
## 2026.2.12
@@ -144,6 +140,7 @@ Docs: https://docs.openclaw.ai
- Sessions: prune stale entries, cap session store size, rotate large stores, accept duration/size thresholds, default to warn-only maintenance, and prune cron run sessions after retention windows. (#13083) Thanks @skyfallsin, @Glucksberg, @gumadeiras.
- CI: Implement pipeline and workflow order. Thanks @quotentiroler.
- WhatsApp: preserve original filenames for inbound documents. (#12691) Thanks @akramcodez.
- Feishu: enforce DM `dmPolicy`/pairing gating and sender allow checks for inbound DMs. (#14876) Thanks @coygeek.
- Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov.
- Telegram: recover proactive sends when stale topic thread IDs are used by retrying without `message_thread_id`. (#11620)
- Discord: auto-create forum/media thread posts on send, with chunked follow-up replies and media handling for forum sends. (#12380) Thanks @magendary, @thewilloftheshadow.
@@ -222,7 +219,6 @@ Docs: https://docs.openclaw.ai
### Fixes
- TTS: add missing OpenAI voices (ballad, cedar, juniper, marin, verse) to the allowlist so they are recognized instead of silently falling back to Edge TTS. (#2393)
- Cron: scheduler reliability (timer drift, restart catch-up, lock contention, stale running markers). (#10776) Thanks @tyler6204.
- Cron: store migration hardening (legacy field migration, parse error handling, explicit delivery mode persistence). (#10776) Thanks @tyler6204.
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.

View File

@@ -220,7 +220,6 @@ and still route command execution against the target conversation session (`Comm
- Channel sessions: `agent:<agentId>:slack:channel:<channelId>`.
- Thread replies can create thread session suffixes (`:thread:<threadTs>`) when applicable.
- `channels.slack.thread.historyScope` default is `thread`; `thread.inheritParent` default is `false`.
- `channels.slack.thread.initialHistoryLimit` controls how many existing thread messages are fetched when a new thread session starts (default `20`; set `0` to disable).
Reply threading controls:

View File

@@ -1912,12 +1912,6 @@ See [Plugins](/tools/plugin).
// password: "your-password",
},
trustedProxies: ["10.0.0.1"],
tools: {
// Additional /tools/invoke HTTP denies
deny: ["browser"],
// Remove tools from the default HTTP deny list
allow: ["gateway"],
},
},
}
```
@@ -1933,8 +1927,6 @@ See [Plugins](/tools/plugin).
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`.
- `gateway.remote.token` is for remote CLI calls only; does not enable local gateway auth.
- `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control.
- `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list).
- `gateway.tools.allow`: remove tool names from the default HTTP deny list.
</Accordion>

View File

@@ -58,28 +58,6 @@ Tool availability is filtered through the same policy chain used by Gateway agen
If a tool is not allowed by policy, the endpoint returns **404**.
Gateway HTTP also applies a hard deny list by default (even if session policy allows the tool):
- `sessions_spawn`
- `sessions_send`
- `gateway`
- `whatsapp_login`
You can customize this deny list via `gateway.tools`:
```json5
{
gateway: {
tools: {
// Additional tools to block over HTTP /tools/invoke
deny: ["browser"],
// Remove tools from the default deny list
allow: ["gateway"],
},
},
}
```
To help group policies resolve context, you can optionally set:
- `x-openclaw-message-channel: <channel>` (example: `slack`, `telegram`)

View File

@@ -52,10 +52,6 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- Runs in CI
- No real keys required
- Should be fast and stable
- Pool note:
- OpenClaw uses Vitest `vmForks` on Node 22/23 for faster unit shards.
- On Node 24+, OpenClaw automatically falls back to regular `forks` to avoid Node VM linking errors (`ERR_VM_MODULE_LINK_FAILURE` / `module is already linked`).
- Override manually with `OPENCLAW_TEST_VM_FORKS=0` (force `forks`) or `OPENCLAW_TEST_VM_FORKS=1` (force `vmForks`).
### E2E (gateway smoke)

View File

@@ -11,7 +11,6 @@ title: "Tests"
- `pnpm test:force`: Kills any lingering gateway process holding the default control port, then runs the full Vitest suite with an isolated gateway port so server tests dont collide with a running instance. Use this when a prior gateway run left port 18789 occupied.
- `pnpm test:coverage`: Runs Vitest with V8 coverage. Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
- `pnpm test` on Node 24+: OpenClaw auto-disables Vitest `vmForks` and uses `forks` to avoid `ERR_VM_MODULE_LINK_FAILURE` / `module is already linked`. You can force behavior with `OPENCLAW_TEST_VM_FORKS=0|1`.
- `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing).
- `pnpm test:live`: Runs provider live tests (minimax/zai). Requires API keys and `LIVE=1` (or provider-specific `*_LIVE_TEST=1`) to unskip.

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/bluebubbles",
"version": "2026.2.13",
"version": "2026.2.12",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/copilot-proxy",
"version": "2026.2.13",
"version": "2026.2.12",
"private": true,
"description": "OpenClaw Copilot Proxy provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/diagnostics-otel",
"version": "2026.2.13",
"version": "2026.2.12",
"description": "OpenClaw diagnostics OpenTelemetry exporter",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/discord",
"version": "2026.2.13",
"version": "2026.2.12",
"description": "OpenClaw Discord channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/feishu",
"version": "2026.2.13",
"version": "2026.2.12",
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/google-antigravity-auth",
"version": "2026.2.13",
"version": "2026.2.12",
"private": true,
"description": "OpenClaw Google Antigravity OAuth provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/google-gemini-cli-auth",
"version": "2026.2.13",
"version": "2026.2.12",
"private": true,
"description": "OpenClaw Gemini CLI OAuth provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/googlechat",
"version": "2026.2.13",
"version": "2026.2.12",
"private": true,
"description": "OpenClaw Google Chat channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/imessage",
"version": "2026.2.13",
"version": "2026.2.12",
"private": true,
"description": "OpenClaw iMessage channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/irc",
"version": "2026.2.13",
"version": "2026.2.12",
"description": "OpenClaw IRC channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/line",
"version": "2026.2.13",
"version": "2026.2.12",
"private": true,
"description": "OpenClaw LINE channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/llm-task",
"version": "2026.2.13",
"version": "2026.2.12",
"private": true,
"description": "OpenClaw JSON-only LLM task plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/lobster",
"version": "2026.2.13",
"version": "2026.2.12",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"type": "module",
"devDependencies": {

View File

@@ -1,11 +1,5 @@
# Changelog
## 2026.2.13
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-3
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/matrix",
"version": "2026.2.13",
"version": "2026.2.12",
"description": "OpenClaw Matrix channel plugin",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/mattermost",
"version": "2026.2.13",
"version": "2026.2.12",
"private": true,
"description": "OpenClaw Mattermost channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-core",
"version": "2026.2.13",
"version": "2026.2.12",
"private": true,
"description": "OpenClaw core memory search plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-lancedb",
"version": "2026.2.13",
"version": "2026.2.12",
"private": true,
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/minimax-portal-auth",
"version": "2026.2.13",
"version": "2026.2.12",
"private": true,
"description": "OpenClaw MiniMax Portal OAuth provider plugin",
"type": "module",

View File

@@ -1,11 +1,5 @@
# Changelog
## 2026.2.13
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-3
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/msteams",
"version": "2026.2.13",
"version": "2026.2.12",
"description": "OpenClaw Microsoft Teams channel plugin",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/nextcloud-talk",
"version": "2026.2.13",
"version": "2026.2.12",
"description": "OpenClaw Nextcloud Talk channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,11 +1,5 @@
# Changelog
## 2026.2.13
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-3
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/nostr",
"version": "2026.2.13",
"version": "2026.2.12",
"description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/open-prose",
"version": "2026.2.13",
"version": "2026.2.12",
"private": true,
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/signal",
"version": "2026.2.13",
"version": "2026.2.12",
"private": true,
"description": "OpenClaw Signal channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/slack",
"version": "2026.2.13",
"version": "2026.2.12",
"private": true,
"description": "OpenClaw Slack channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/telegram",
"version": "2026.2.13",
"version": "2026.2.12",
"private": true,
"description": "OpenClaw Telegram channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/tlon",
"version": "2026.2.13",
"version": "2026.2.12",
"private": true,
"description": "OpenClaw Tlon/Urbit channel plugin",
"type": "module",

View File

@@ -1,11 +1,5 @@
# Changelog
## 2026.2.13
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-3
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/twitch",
"version": "2026.2.13",
"version": "2026.2.12",
"private": true,
"description": "OpenClaw Twitch channel plugin",
"type": "module",

View File

@@ -1,11 +1,5 @@
# Changelog
## 2026.2.13
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-3
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/voice-call",
"version": "2026.2.13",
"version": "2026.2.12",
"description": "OpenClaw voice-call plugin",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/whatsapp",
"version": "2026.2.13",
"version": "2026.2.12",
"private": true,
"description": "OpenClaw WhatsApp channel plugin",
"type": "module",

View File

@@ -1,11 +1,5 @@
# Changelog
## 2026.2.13
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-3
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/zalo",
"version": "2026.2.13",
"version": "2026.2.12",
"description": "OpenClaw Zalo channel plugin",
"type": "module",
"dependencies": {

View File

@@ -1,11 +1,5 @@
# Changelog
## 2026.2.13
### Changes
- Version alignment with core OpenClaw release numbers.
## 2026.2.6-3
### Changes

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/zalouser",
"version": "2026.2.13",
"version": "2026.2.12",
"description": "OpenClaw Zalo Personal Account plugin via zca-cli",
"type": "module",
"dependencies": {

View File

@@ -10,10 +10,8 @@ const unitIsolatedFiles = [
"src/plugins/tools.optional.test.ts",
"src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts",
"src/security/fix.test.ts",
"src/security/audit.test.ts",
"src/utils.test.ts",
"src/auto-reply/tool-meta.test.ts",
"src/auto-reply/envelope.test.ts",
"src/commands/auth-choice.test.ts",
"src/media/store.header-ext.test.ts",
"src/browser/server.covers-additional-endpoint-branches.test.ts",
@@ -32,12 +30,9 @@ const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
const isMacOS = process.platform === "darwin" || process.env.RUNNER_OS === "macOS";
const isWindows = process.platform === "win32" || process.env.RUNNER_OS === "Windows";
const isWindowsCi = isCI && isWindows;
const nodeMajor = Number.parseInt(process.versions.node.split(".")[0] ?? "", 10);
const supportsVmForks = Number.isFinite(nodeMajor) ? nodeMajor < 24 : true;
const useVmForks =
process.env.OPENCLAW_TEST_VM_FORKS === "1" ||
(process.env.OPENCLAW_TEST_VM_FORKS !== "0" && !isWindows && supportsVmForks);
const disableIsolation = process.env.OPENCLAW_TEST_NO_ISOLATE === "1";
(process.env.OPENCLAW_TEST_VM_FORKS !== "0" && !isWindows);
const runs = [
...(useVmForks
? [
@@ -49,7 +44,6 @@ const runs = [
"--config",
"vitest.unit.config.ts",
"--pool=vmForks",
...(disableIsolation ? ["--isolate=false"] : []),
...unitIsolatedFiles.flatMap((file) => ["--exclude", file]),
],
},
@@ -148,7 +142,6 @@ const WARNING_SUPPRESSION_FLAGS = [
"--disable-warning=ExperimentalWarning",
"--disable-warning=DEP0040",
"--disable-warning=DEP0060",
"--disable-warning=MaxListenersExceededWarning",
];
function resolveReportDir() {

View File

@@ -1,92 +0,0 @@
import type { RequestPermissionRequest } from "@agentclientprotocol/sdk";
import { describe, expect, it, vi } from "vitest";
import { resolvePermissionRequest } from "./client.js";
function makePermissionRequest(
overrides: Partial<RequestPermissionRequest> = {},
): RequestPermissionRequest {
const { toolCall: toolCallOverride, options: optionsOverride, ...restOverrides } = overrides;
const base: RequestPermissionRequest = {
sessionId: "session-1",
toolCall: {
toolCallId: "tool-1",
title: "read: src/index.ts",
status: "pending",
},
options: [
{ kind: "allow_once", name: "Allow once", optionId: "allow" },
{ kind: "reject_once", name: "Reject once", optionId: "reject" },
],
};
return {
...base,
...restOverrides,
toolCall: toolCallOverride ? { ...base.toolCall, ...toolCallOverride } : base.toolCall,
options: optionsOverride ?? base.options,
};
}
describe("resolvePermissionRequest", () => {
it("auto-approves safe tools without prompting", async () => {
const prompt = vi.fn(async () => true);
const res = await resolvePermissionRequest(makePermissionRequest(), { prompt, log: () => {} });
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
expect(prompt).not.toHaveBeenCalled();
});
it("prompts for dangerous tool names inferred from title", async () => {
const prompt = vi.fn(async () => true);
const res = await resolvePermissionRequest(
makePermissionRequest({
toolCall: { toolCallId: "tool-2", title: "exec: uname -a", status: "pending" },
}),
{ prompt, log: () => {} },
);
expect(prompt).toHaveBeenCalledTimes(1);
expect(prompt).toHaveBeenCalledWith("exec", "exec: uname -a");
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
});
it("uses allow_always and reject_always when once options are absent", async () => {
const options: RequestPermissionRequest["options"] = [
{ kind: "allow_always", name: "Always allow", optionId: "allow-always" },
{ kind: "reject_always", name: "Always reject", optionId: "reject-always" },
];
const prompt = vi.fn(async () => false);
const res = await resolvePermissionRequest(
makePermissionRequest({
toolCall: { toolCallId: "tool-3", title: "gateway: reload", status: "pending" },
options,
}),
{ prompt, log: () => {} },
);
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "reject-always" } });
});
it("prompts when tool identity is unknown and can still approve", async () => {
const prompt = vi.fn(async () => true);
const res = await resolvePermissionRequest(
makePermissionRequest({
toolCall: {
toolCallId: "tool-4",
title: "Modifying critical configuration file",
status: "pending",
},
}),
{ prompt, log: () => {} },
);
expect(prompt).toHaveBeenCalledWith(undefined, "Modifying critical configuration file");
expect(res).toEqual({ outcome: { outcome: "selected", optionId: "allow" } });
});
it("returns cancelled when no permission options are present", async () => {
const prompt = vi.fn(async () => true);
const res = await resolvePermissionRequest(makePermissionRequest({ options: [] }), {
prompt,
log: () => {},
});
expect(prompt).not.toHaveBeenCalled();
expect(res).toEqual({ outcome: { outcome: "cancelled" } });
});
});

View File

@@ -3,7 +3,6 @@ import {
PROTOCOL_VERSION,
ndJsonStream,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
} from "@agentclientprotocol/sdk";
import { spawn, type ChildProcess } from "node:child_process";
@@ -11,189 +10,6 @@ import * as readline from "node:readline";
import { Readable, Writable } from "node:stream";
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
/**
* Tools that require explicit user approval in ACP sessions.
* These tools can execute arbitrary code, modify the filesystem,
* or access sensitive resources.
*/
const DANGEROUS_ACP_TOOLS = new Set([
"exec",
"spawn",
"shell",
"sessions_spawn",
"sessions_send",
"gateway",
"fs_write",
"fs_delete",
"fs_move",
"apply_patch",
]);
type PermissionOption = RequestPermissionRequest["options"][number];
type PermissionResolverDeps = {
prompt?: (toolName: string | undefined, toolTitle?: string) => Promise<boolean>;
log?: (line: string) => void;
};
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function readFirstStringValue(
source: Record<string, unknown> | undefined,
keys: string[],
): string | undefined {
if (!source) {
return undefined;
}
for (const key of keys) {
const value = source[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return undefined;
}
function normalizeToolName(value: string): string | undefined {
const normalized = value.trim().toLowerCase();
if (!normalized) {
return undefined;
}
return normalized;
}
function parseToolNameFromTitle(title: string | undefined | null): string | undefined {
if (!title) {
return undefined;
}
const head = title.split(":", 1)[0]?.trim();
if (!head || !/^[a-zA-Z0-9._-]+$/.test(head)) {
return undefined;
}
return normalizeToolName(head);
}
function resolveToolNameForPermission(params: RequestPermissionRequest): string | undefined {
const toolCall = params.toolCall;
const toolMeta = asRecord(toolCall?._meta);
const rawInput = asRecord(toolCall?.rawInput);
const fromMeta = readFirstStringValue(toolMeta, ["toolName", "tool_name", "name"]);
const fromRawInput = readFirstStringValue(rawInput, ["tool", "toolName", "tool_name", "name"]);
const fromTitle = parseToolNameFromTitle(toolCall?.title);
return normalizeToolName(fromMeta ?? fromRawInput ?? fromTitle ?? "");
}
function pickOption(
options: PermissionOption[],
kinds: PermissionOption["kind"][],
): PermissionOption | undefined {
for (const kind of kinds) {
const match = options.find((option) => option.kind === kind);
if (match) {
return match;
}
}
return undefined;
}
function selectedPermission(optionId: string): RequestPermissionResponse {
return { outcome: { outcome: "selected", optionId } };
}
function cancelledPermission(): RequestPermissionResponse {
return { outcome: { outcome: "cancelled" } };
}
function promptUserPermission(toolName: string | undefined, toolTitle?: string): Promise<boolean> {
if (!process.stdin.isTTY || !process.stderr.isTTY) {
console.error(`[permission denied] ${toolName ?? "unknown"}: non-interactive terminal`);
return Promise.resolve(false);
}
return new Promise((resolve) => {
let settled = false;
const rl = readline.createInterface({
input: process.stdin,
output: process.stderr,
});
const finish = (approved: boolean) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
rl.close();
resolve(approved);
};
const timeout = setTimeout(() => {
console.error(`\n[permission timeout] denied: ${toolName ?? "unknown"}`);
finish(false);
}, 30_000);
const label = toolTitle
? toolName
? `${toolTitle} (${toolName})`
: toolTitle
: (toolName ?? "unknown tool");
rl.question(`\n[permission] Allow "${label}"? (y/N) `, (answer) => {
const approved = answer.trim().toLowerCase() === "y";
console.error(`[permission ${approved ? "approved" : "denied"}] ${toolName ?? "unknown"}`);
finish(approved);
});
});
}
export async function resolvePermissionRequest(
params: RequestPermissionRequest,
deps: PermissionResolverDeps = {},
): Promise<RequestPermissionResponse> {
const log = deps.log ?? ((line: string) => console.error(line));
const prompt = deps.prompt ?? promptUserPermission;
const options = params.options ?? [];
const toolTitle = params.toolCall?.title ?? "tool";
const toolName = resolveToolNameForPermission(params);
if (options.length === 0) {
log(`[permission cancelled] ${toolName ?? "unknown"}: no options available`);
return cancelledPermission();
}
const allowOption = pickOption(options, ["allow_once", "allow_always"]);
const rejectOption = pickOption(options, ["reject_once", "reject_always"]);
const promptRequired = !toolName || DANGEROUS_ACP_TOOLS.has(toolName);
if (!promptRequired) {
const option = allowOption ?? options[0];
if (!option) {
log(`[permission cancelled] ${toolName}: no selectable options`);
return cancelledPermission();
}
log(`[permission auto-approved] ${toolName}`);
return selectedPermission(option.optionId);
}
log(`\n[permission requested] ${toolTitle}${toolName ? ` (${toolName})` : ""}`);
const approved = await prompt(toolName, toolTitle);
if (approved && allowOption) {
return selectedPermission(allowOption.optionId);
}
if (!approved && rejectOption) {
return selectedPermission(rejectOption.optionId);
}
log(
`[permission cancelled] ${toolName ?? "unknown"}: missing ${approved ? "allow" : "reject"} option`,
);
return cancelledPermission();
}
export type AcpClientOptions = {
cwd?: string;
serverCommand?: string;
@@ -288,7 +104,16 @@ export async function createAcpClient(opts: AcpClientOptions = {}): Promise<AcpC
printSessionUpdate(params);
},
requestPermission: async (params: RequestPermissionRequest) => {
return resolvePermissionRequest(params);
console.log("\n[permission requested]", params.toolCall?.title ?? "tool");
const options = params.options ?? [];
const allowOnce = options.find((option) => option.kind === "allow_once");
const fallback = options[0];
return {
outcome: {
outcome: "selected",
optionId: allowOnce?.optionId ?? fallback?.optionId ?? "allow",
},
};
},
}),
stream,

View File

@@ -14,7 +14,6 @@ const CODEX_MODELS = [
"gpt-5.2",
"gpt-5.2-codex",
"gpt-5.3-codex",
"gpt-5.3-codex-spark",
"gpt-5.1-codex",
"gpt-5.1-codex-mini",
"gpt-5.1-codex-max",

View File

@@ -84,43 +84,4 @@ describe("loadModelCatalog", () => {
expect(result).toEqual([{ id: "gpt-4.1", name: "GPT-4.1", provider: "openai" }]);
expect(warnSpy).toHaveBeenCalledTimes(1);
});
it("adds openai-codex/gpt-5.3-codex-spark when base gpt-5.3-codex exists", async () => {
__setModelCatalogImportForTest(
async () =>
({
AuthStorage: class {},
ModelRegistry: class {
getAll() {
return [
{
id: "gpt-5.3-codex",
provider: "openai-codex",
name: "GPT-5.3 Codex",
reasoning: true,
contextWindow: 200000,
input: ["text"],
},
{
id: "gpt-5.2-codex",
provider: "openai-codex",
name: "GPT-5.2 Codex",
},
];
}
},
}) as unknown as PiSdkModule,
);
const result = await loadModelCatalog({ config: {} as OpenClawConfig });
expect(result).toContainEqual(
expect.objectContaining({
provider: "openai-codex",
id: "gpt-5.3-codex-spark",
}),
);
const spark = result.find((entry) => entry.id === "gpt-5.3-codex-spark");
expect(spark?.name).toBe("gpt-5.3-codex-spark");
expect(spark?.reasoning).toBe(true);
});
});

View File

@@ -27,35 +27,6 @@ let hasLoggedModelCatalogError = false;
const defaultImportPiSdk = () => import("./pi-model-discovery.js");
let importPiSdk = defaultImportPiSdk;
const CODEX_PROVIDER = "openai-codex";
const OPENAI_CODEX_GPT53_MODEL_ID = "gpt-5.3-codex";
const OPENAI_CODEX_GPT53_SPARK_MODEL_ID = "gpt-5.3-codex-spark";
function applyOpenAICodexSparkFallback(models: ModelCatalogEntry[]): void {
const hasSpark = models.some(
(entry) =>
entry.provider === CODEX_PROVIDER &&
entry.id.toLowerCase() === OPENAI_CODEX_GPT53_SPARK_MODEL_ID,
);
if (hasSpark) {
return;
}
const baseModel = models.find(
(entry) =>
entry.provider === CODEX_PROVIDER && entry.id.toLowerCase() === OPENAI_CODEX_GPT53_MODEL_ID,
);
if (!baseModel) {
return;
}
models.push({
...baseModel,
id: OPENAI_CODEX_GPT53_SPARK_MODEL_ID,
name: OPENAI_CODEX_GPT53_SPARK_MODEL_ID,
});
}
export function resetModelCatalogCacheForTest() {
modelCatalogPromise = null;
hasLoggedModelCatalogError = false;
@@ -91,9 +62,6 @@ export async function loadModelCatalog(params?: {
try {
const cfg = params?.config ?? loadConfig();
await ensureOpenClawModelsJson(cfg);
await (
await import("./pi-auth-json.js")
).ensurePiAuthJsonFromAuthProfiles(resolveOpenClawAgentDir());
// IMPORTANT: keep the dynamic import *inside* the try/catch.
// If this fails once (e.g. during a pnpm install that temporarily swaps node_modules),
// we must not poison the cache with a rejected promise (otherwise all channel handlers
@@ -126,7 +94,6 @@ export async function loadModelCatalog(params?: {
const input = Array.isArray(entry?.input) ? entry.input : undefined;
models.push({ id, name, provider, contextWindow, reasoning, input });
}
applyOpenAICodexSparkFallback(models);
if (models.length === 0) {
// If we found nothing, don't cache this result so we can try again.

View File

@@ -1,42 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { saveAuthProfileStore } from "./auth-profiles.js";
import { ensurePiAuthJsonFromAuthProfiles } from "./pi-auth-json.js";
describe("ensurePiAuthJsonFromAuthProfiles", () => {
it("writes openai-codex oauth credentials into auth.json for pi-coding-agent discovery", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-"));
saveAuthProfileStore(
{
version: 1,
profiles: {
"openai-codex:default": {
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
},
},
},
agentDir,
);
const first = await ensurePiAuthJsonFromAuthProfiles(agentDir);
expect(first.wrote).toBe(true);
const authPath = path.join(agentDir, "auth.json");
const auth = JSON.parse(await fs.readFile(authPath, "utf8")) as Record<string, unknown>;
expect(auth["openai-codex"]).toMatchObject({
type: "oauth",
access: "access-token",
refresh: "refresh-token",
});
const second = await ensurePiAuthJsonFromAuthProfiles(agentDir);
expect(second.wrote).toBe(false);
});
});

View File

@@ -1,100 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
type AuthJsonCredential =
| {
type: "api_key";
key: string;
}
| {
type: "oauth";
access: string;
refresh: string;
expires: number;
[key: string]: unknown;
};
type AuthJsonShape = Record<string, AuthJsonCredential>;
async function readAuthJson(filePath: string): Promise<AuthJsonShape> {
try {
const raw = await fs.readFile(filePath, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object") {
return {};
}
return parsed as AuthJsonShape;
} catch {
return {};
}
}
/**
* pi-coding-agent's ModelRegistry/AuthStorage expects OAuth credentials in auth.json.
*
* OpenClaw stores OAuth credentials in auth-profiles.json instead. This helper
* bridges a subset of credentials into agentDir/auth.json so pi-coding-agent can
* (a) consider the provider authenticated and (b) include built-in models in its
* registry/catalog output.
*
* Currently used for openai-codex.
*/
export async function ensurePiAuthJsonFromAuthProfiles(agentDir: string): Promise<{
wrote: boolean;
authPath: string;
}> {
const store = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false });
const codexProfiles = listProfilesForProvider(store, "openai-codex");
if (codexProfiles.length === 0) {
return { wrote: false, authPath: path.join(agentDir, "auth.json") };
}
const profileId = codexProfiles[0];
const cred = profileId ? store.profiles[profileId] : undefined;
if (!cred || cred.type !== "oauth") {
return { wrote: false, authPath: path.join(agentDir, "auth.json") };
}
const accessRaw = (cred as { access?: unknown }).access;
const refreshRaw = (cred as { refresh?: unknown }).refresh;
const expiresRaw = (cred as { expires?: unknown }).expires;
const access = typeof accessRaw === "string" ? accessRaw.trim() : "";
const refresh = typeof refreshRaw === "string" ? refreshRaw.trim() : "";
const expires = typeof expiresRaw === "number" ? expiresRaw : Number.NaN;
if (!access || !refresh || !Number.isFinite(expires) || expires <= 0) {
return { wrote: false, authPath: path.join(agentDir, "auth.json") };
}
const authPath = path.join(agentDir, "auth.json");
const next = await readAuthJson(authPath);
const existing = next["openai-codex"];
const desired: AuthJsonCredential = {
type: "oauth",
access,
refresh,
expires,
};
const isSame =
existing &&
typeof existing === "object" &&
(existing as { type?: unknown }).type === "oauth" &&
(existing as { access?: unknown }).access === access &&
(existing as { refresh?: unknown }).refresh === refresh &&
(existing as { expires?: unknown }).expires === expires;
if (isSame) {
return { wrote: false, authPath };
}
next["openai-codex"] = desired;
await fs.mkdir(agentDir, { recursive: true, mode: 0o700 });
await fs.writeFile(authPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
return { wrote: true, authPath };
}

View File

@@ -51,9 +51,10 @@ export async function sanitizeSessionMessagesImages(
const allowNonImageSanitization = sanitizeMode === "full";
// We sanitize historical session messages because Anthropic can reject a request
// if the transcript contains oversized base64 images (see MAX_IMAGE_DIMENSION_PX).
const sanitizedIds = options?.sanitizeToolCallIds
? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode)
: messages;
const sanitizedIds =
allowNonImageSanitization && options?.sanitizeToolCallIds
? sanitizeToolCallIdsForCloudCodeAssist(messages, options.toolCallIdMode)
: messages;
const out: AgentMessage[] = [];
for (const msg of sanitizedIds) {
if (!msg || typeof msg !== "object") {

View File

@@ -94,7 +94,7 @@ describe("sanitizeSessionHistory", () => {
);
});
it("sanitizes tool call ids for openai-responses while keeping images-only mode", async () => {
it("does not sanitize tool call ids for openai-responses", async () => {
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
await sanitizeSessionHistory({
@@ -108,11 +108,7 @@ describe("sanitizeSessionHistory", () => {
expect(helpers.sanitizeSessionMessagesImages).toHaveBeenCalledWith(
mockMessages,
"session:history",
expect.objectContaining({
sanitizeMode: "images-only",
sanitizeToolCallIds: true,
toolCallIdMode: "strict",
}),
expect.objectContaining({ sanitizeMode: "images-only", sanitizeToolCallIds: false }),
);
});

View File

@@ -172,43 +172,6 @@ describe("resolveModel", () => {
});
});
it("builds an openai-codex fallback for gpt-5.3-codex-spark", () => {
const templateModel = {
id: "gpt-5.2-codex",
name: "GPT-5.2 Codex",
provider: "openai-codex",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text", "image"] as const,
cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
contextWindow: 272000,
maxTokens: 128000,
};
vi.mocked(discoverModels).mockReturnValue({
find: vi.fn((provider: string, modelId: string) => {
if (provider === "openai-codex" && modelId === "gpt-5.2-codex") {
return templateModel;
}
return null;
}),
} as unknown as ReturnType<typeof discoverModels>);
const result = resolveModel("openai-codex", "gpt-5.3-codex-spark", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: "openai-codex",
id: "gpt-5.3-codex-spark",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
contextWindow: 272000,
maxTokens: 128000,
});
});
it("builds an anthropic forward-compat fallback for claude-opus-4-6", () => {
const templateModel = {
id: "claude-opus-4-5",
@@ -320,12 +283,6 @@ describe("resolveModel", () => {
expect(result.error).toBe("Unknown model: openai-codex/gpt-4.1-mini");
});
it("errors for unknown gpt-5.3-codex-* variants", () => {
const result = resolveModel("openai-codex", "gpt-5.3-codex-unknown", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe("Unknown model: openai-codex/gpt-5.3-codex-unknown");
});
it("uses codex fallback even when openai-codex provider is configured", () => {
// This test verifies the ordering: codex fallback must fire BEFORE the generic providerCfg fallback.
// If ordering is wrong, the generic fallback would use api: "openai-responses" (the default)

View File

@@ -20,7 +20,6 @@ type InlineProviderConfig = {
};
const OPENAI_CODEX_GPT_53_MODEL_ID = "gpt-5.3-codex";
const OPENAI_CODEX_GPT_53_SPARK_MODEL_ID = "gpt-5.3-codex-spark";
const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const;
@@ -40,11 +39,7 @@ function resolveOpenAICodexGpt53FallbackModel(
if (normalizedProvider !== "openai-codex") {
return undefined;
}
const loweredModelId = trimmedModelId.toLowerCase();
if (
loweredModelId !== OPENAI_CODEX_GPT_53_MODEL_ID &&
loweredModelId !== OPENAI_CODEX_GPT_53_SPARK_MODEL_ID
) {
if (trimmedModelId.toLowerCase() !== OPENAI_CODEX_GPT_53_MODEL_ID) {
return undefined;
}

View File

@@ -436,7 +436,6 @@ export function createSessionStatusTool(opts?: {
...agentDefaults,
model: agentModel,
},
agentId,
sessionEntry: resolved.entry,
sessionKey: resolved.key,
sessionStorePath: storePath,

View File

@@ -30,13 +30,12 @@ describe("resolveTranscriptPolicy", () => {
expect(policy.toolCallIdMode).toBe("strict9");
});
it("enables sanitizeToolCallIds for OpenAI provider", () => {
it("disables sanitizeToolCallIds for OpenAI provider", () => {
const policy = resolveTranscriptPolicy({
provider: "openai",
modelId: "gpt-4o",
modelApi: "openai",
});
expect(policy.sanitizeToolCallIds).toBe(true);
expect(policy.toolCallIdMode).toBe("strict");
expect(policy.sanitizeToolCallIds).toBe(false);
});
});

View File

@@ -95,7 +95,7 @@ export function resolveTranscriptPolicy(params: {
const needsNonImageSanitize = isGoogle || isAnthropic || isMistral || isOpenRouterGemini;
const sanitizeToolCallIds = isGoogle || isMistral || isAnthropic || isOpenAi;
const sanitizeToolCallIds = isGoogle || isMistral || isAnthropic;
const toolCallIdMode: ToolCallIdMode | undefined = isMistral
? "strict9"
: sanitizeToolCallIds
@@ -109,7 +109,7 @@ export function resolveTranscriptPolicy(params: {
return {
sanitizeMode: isOpenAi ? "images-only" : needsNonImageSanitize ? "full" : "images-only",
sanitizeToolCallIds,
sanitizeToolCallIds: !isOpenAi && sanitizeToolCallIds,
toolCallIdMode,
repairToolUseResultPairing: !isOpenAi && repairToolUseResultPairing,
preserveSignatures: isAntigravityClaudeModel,

View File

@@ -47,7 +47,7 @@ describe("normalizeUsage", () => {
expect(hasNonzeroUsage({ total: 1 })).toBe(true);
});
it("does not clamp derived session total tokens to the context window", () => {
it("caps derived session total tokens to the context window", () => {
expect(
deriveSessionTotalTokens({
usage: {
@@ -58,7 +58,7 @@ describe("normalizeUsage", () => {
},
contextTokens: 200_000,
}),
).toBe(2_400_027);
).toBe(200_000);
});
it("uses prompt tokens when within context window", () => {

View File

@@ -134,10 +134,9 @@ export function deriveSessionTotalTokens(params: {
return undefined;
}
// NOTE: Do NOT clamp total to contextTokens here. The stored totalTokens
// should reflect the actual token count (or best estimate). Clamping causes
// /status to display contextTokens/contextTokens (100%) when the accumulated
// input exceeds the context window, hiding the real usage. The display layer
// (formatTokens in status.ts) already caps the percentage at 999%.
const contextTokens = params.contextTokens;
if (typeof contextTokens === "number" && Number.isFinite(contextTokens) && contextTokens > 0) {
total = Math.min(total, contextTokens);
}
return total;
}

View File

@@ -154,7 +154,7 @@ describe("directive behavior", () => {
const texts = (Array.isArray(res) ? res : [res]).map((entry) => entry?.text).filter(Boolean);
expect(texts).toContain(
'Thinking level "xhigh" is only supported for openai/gpt-5.2, openai-codex/gpt-5.3-codex, openai-codex/gpt-5.3-codex-spark, openai-codex/gpt-5.2-codex, openai-codex/gpt-5.1-codex, github-copilot/gpt-5.2-codex or github-copilot/gpt-5.2.',
'Thinking level "xhigh" is only supported for openai/gpt-5.2, openai-codex/gpt-5.3-codex, openai-codex/gpt-5.2-codex, openai-codex/gpt-5.1-codex, github-copilot/gpt-5.2-codex or github-copilot/gpt-5.2.',
);
});
});

View File

@@ -151,7 +151,7 @@ describe("runReplyAgent messaging tool suppression", () => {
expect(result).toMatchObject({ text: "hello world!" });
});
it("persists usage fields even when replies are suppressed", async () => {
it("persists usage even when replies are suppressed", async () => {
const storePath = path.join(
await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-store-")),
"sessions.json",
@@ -177,42 +177,7 @@ describe("runReplyAgent messaging tool suppression", () => {
expect(result).toBeUndefined();
const store = loadSessionStore(storePath, { skipCache: true });
expect(store[sessionKey]?.inputTokens).toBe(10);
expect(store[sessionKey]?.outputTokens).toBe(5);
expect(store[sessionKey]?.totalTokens).toBeUndefined();
expect(store[sessionKey]?.totalTokensFresh).toBe(false);
expect(store[sessionKey]?.model).toBe("claude-opus-4-5");
});
it("persists totalTokens from promptTokens when snapshot is available", async () => {
const storePath = path.join(
await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-store-")),
"sessions.json",
);
const sessionKey = "main";
const entry: SessionEntry = { sessionId: "session", updatedAt: Date.now() };
await saveSessionStore(storePath, { [sessionKey]: entry });
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "hello world!" }],
messagingToolSentTexts: ["different message"],
messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }],
meta: {
agentMeta: {
usage: { input: 10, output: 5 },
promptTokens: 42_000,
model: "claude-opus-4-5",
provider: "anthropic",
},
},
});
const result = await createRun("slack", { storePath, sessionKey });
expect(result).toBeUndefined();
const store = loadSessionStore(storePath, { skipCache: true });
expect(store[sessionKey]?.totalTokens).toBe(42_000);
expect(store[sessionKey]?.totalTokensFresh).toBe(true);
expect(store[sessionKey]?.totalTokens ?? 0).toBeGreaterThan(0);
expect(store[sessionKey]?.model).toBe("claude-opus-4-5");
});
});

View File

@@ -6,11 +6,7 @@ import {
isEmbeddedPiRunActive,
waitForEmbeddedPiRunEnd,
} from "../../agents/pi-embedded.js";
import {
resolveFreshSessionTotalTokens,
resolveSessionFilePath,
resolveSessionFilePathOptions,
} from "../../config/sessions.js";
import { resolveSessionFilePath, resolveSessionFilePathOptions } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { formatContextUsageShort, formatTokenCount } from "../status.js";
@@ -128,9 +124,12 @@ export const handleCompactCommand: CommandHandler = async (params) => {
}
// Use the post-compaction token count for context summary if available
const tokensAfterCompaction = result.result?.tokensAfter;
const totalTokens = tokensAfterCompaction ?? resolveFreshSessionTotalTokens(params.sessionEntry);
const totalTokens =
tokensAfterCompaction ??
params.sessionEntry.totalTokens ??
(params.sessionEntry.inputTokens ?? 0) + (params.sessionEntry.outputTokens ?? 0);
const contextSummary = formatContextUsageShort(
typeof totalTokens === "number" && totalTokens > 0 ? totalTokens : null,
totalTokens > 0 ? totalTokens : null,
params.contextTokens ?? params.sessionEntry.contextTokens ?? null,
);
const reason = result.reason?.trim();

View File

@@ -167,7 +167,6 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
sessionEntry: params.sessionEntry,
sessionFile: params.sessionEntry?.sessionFile,
config: params.cfg,
agentId: params.agentId,
});
const summary = await loadCostUsageSummary({ days: 30, config: params.cfg });

View File

@@ -224,7 +224,6 @@ export async function buildStatusReply(params: {
verboseDefault: agentDefaults.verboseDefault,
elevatedDefault: agentDefaults.elevatedDefault,
},
agentId: statusAgentId,
sessionEntry,
sessionKey,
sessionScope,

View File

@@ -208,14 +208,7 @@ export async function runPreparedReply(
((baseBodyTrimmedRaw.length === 0 && rawBodyTrimmed.length > 0) || isBareNewOrReset);
const baseBodyFinal = isBareSessionReset ? BARE_SESSION_RESET_PROMPT : baseBody;
const inboundUserContext = buildInboundUserContextPrefix(
isNewSession
? {
...sessionCtx,
...(sessionCtx.ThreadHistoryBody?.trim()
? { InboundHistory: undefined, ThreadStarterBody: undefined }
: {}),
}
: { ...sessionCtx, ThreadStarterBody: undefined },
isNewSession ? sessionCtx : { ...sessionCtx, ThreadStarterBody: undefined },
);
const baseBodyForPrompt = isBareSessionReset
? baseBodyFinal
@@ -248,14 +241,6 @@ export async function runPreparedReply(
prefixedBodyBase,
});
prefixedBodyBase = appendUntrustedContext(prefixedBodyBase, sessionCtx.UntrustedContext);
const threadStarterBody = ctx.ThreadStarterBody?.trim();
const threadHistoryBody = ctx.ThreadHistoryBody?.trim();
const threadContextNote =
isNewSession && threadHistoryBody
? `[Thread history - for context]\n${threadHistoryBody}`
: isNewSession && threadStarterBody
? `[Thread starter - for context]\n${threadStarterBody}`
: undefined;
const skillResult = await ensureSkillSnapshot({
sessionEntry,
sessionStore,
@@ -270,7 +255,7 @@ export async function runPreparedReply(
sessionEntry = skillResult.sessionEntry ?? sessionEntry;
currentSystemSent = skillResult.systemSent;
const skillsSnapshot = skillResult.skillsSnapshot;
const prefixedBody = [threadContextNote, prefixedBodyBase].filter(Boolean).join("\n\n");
const prefixedBody = prefixedBodyBase;
const mediaNote = buildInboundMediaNote(ctx);
const mediaReplyHint = mediaNote
? "To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:https://example.com/image.jpg (spaces ok, quote if needed) or a safe relative path like MEDIA:./image.jpg. Avoid absolute paths (MEDIA:/...) and ~ paths — they are blocked for security. Keep caption in the text body."
@@ -337,7 +322,7 @@ export async function runPreparedReply(
sessionEntry,
resolveSessionFilePathOptions({ agentId, storePath }),
);
const queueBodyBase = [threadContextNote, baseBodyForPrompt].filter(Boolean).join("\n\n");
const queueBodyBase = baseBodyForPrompt;
const queuedBody = mediaNote
? [mediaNote, mediaReplyHint, queueBodyBase].filter(Boolean).join("\n").trim()
: queueBodyBase;

View File

@@ -30,7 +30,6 @@ export function finalizeInboundContext<T extends Record<string, unknown>>(
normalized.CommandBody = normalizeTextField(normalized.CommandBody);
normalized.Transcript = normalizeTextField(normalized.Transcript);
normalized.ThreadStarterBody = normalizeTextField(normalized.ThreadStarterBody);
normalized.ThreadHistoryBody = normalizeTextField(normalized.ThreadHistoryBody);
if (Array.isArray(normalized.UntrustedContext)) {
const normalizedUntrusted = normalized.UntrustedContext.map((entry) =>
normalizeInboundTextNewlines(entry),

View File

@@ -113,17 +113,6 @@ describe("shouldRunMemoryFlush", () => {
}),
).toBe(true);
});
it("ignores stale cached totals", () => {
expect(
shouldRunMemoryFlush({
entry: { totalTokens: 96_000, totalTokensFresh: false, compactionCount: 1 },
contextWindowTokens: 100_000,
reserveTokensFloor: 5_000,
softThresholdTokens: 2_000,
}),
).toBe(false);
});
});
describe("resolveMemoryFlushContextWindowTokens", () => {

View File

@@ -1,8 +1,8 @@
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { DEFAULT_PI_COMPACTION_RESERVE_TOKENS_FLOOR } from "../../agents/pi-settings.js";
import { resolveFreshSessionTotalTokens, type SessionEntry } from "../../config/sessions.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
export const DEFAULT_MEMORY_FLUSH_SOFT_TOKENS = 4000;
@@ -76,15 +76,12 @@ export function resolveMemoryFlushContextWindowTokens(params: {
}
export function shouldRunMemoryFlush(params: {
entry?: Pick<
SessionEntry,
"totalTokens" | "totalTokensFresh" | "compactionCount" | "memoryFlushCompactionCount"
>;
entry?: Pick<SessionEntry, "totalTokens" | "compactionCount" | "memoryFlushCompactionCount">;
contextWindowTokens: number;
reserveTokensFloor: number;
softThresholdTokens: number;
}): boolean {
const totalTokens = resolveFreshSessionTotalTokens(params.entry);
const totalTokens = params.entry?.totalTokens;
if (!totalTokens || totalTokens <= 0) {
return false;
}

View File

@@ -4,7 +4,6 @@ import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { buildModelAliasIndex } from "../../agents/model-selection.js";
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.ts";
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
import { applyResetModelOverride } from "./session-reset-model.js";
import { prependSystemEvents } from "./session-updates.js";
@@ -617,26 +616,25 @@ describe("initSessionState preserves behavior overrides across /new and /reset",
describe("prependSystemEvents", () => {
it("adds a local timestamp to queued system events by default", async () => {
vi.useFakeTimers();
try {
const timestamp = new Date("2026-01-12T20:19:17Z");
const expectedTimestamp = formatZonedTimestamp(timestamp, { displaySeconds: true });
vi.setSystemTime(timestamp);
const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
const timestamp = new Date("2026-01-12T20:19:17Z");
vi.setSystemTime(timestamp);
enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" });
enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" });
const result = await prependSystemEvents({
cfg: {} as OpenClawConfig,
sessionKey: "agent:main:main",
isMainSession: false,
isNewSession: false,
prefixedBodyBase: "User: hi",
});
const result = await prependSystemEvents({
cfg: {} as OpenClawConfig,
sessionKey: "agent:main:main",
isMainSession: false,
isNewSession: false,
prefixedBodyBase: "User: hi",
});
expect(expectedTimestamp).toBeDefined();
expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`);
} finally {
resetSystemEventsForTest();
vi.useRealTimers();
}
expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./);
resetSystemEventsForTest();
process.env.TZ = originalTz;
vi.useRealTimers();
});
});

View File

@@ -18,7 +18,6 @@ export async function persistRunSessionUsage(params: PersistRunSessionUsageParam
sessionKey: params.sessionKey,
usage: params.usage,
lastCallUsage: params.lastCallUsage,
promptTokens: params.promptTokens,
modelUsed: params.modelUsed,
providerUsed: params.providerUsed,
contextTokensUsed: params.contextTokensUsed,

View File

@@ -255,7 +255,6 @@ export async function incrementCompactionCount(params: {
// If tokensAfter is provided, update the cached token counts to reflect post-compaction state
if (tokensAfter != null && tokensAfter > 0) {
updates.totalTokens = tokensAfter;
updates.totalTokensFresh = true;
// Clear input/output breakdown since we only have the total estimate after compaction
updates.inputTokens = undefined;
updates.outputTokens = undefined;

View File

@@ -44,13 +44,12 @@ describe("persistSessionUsageUpdate", () => {
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
// totalTokens should reflect lastCallUsage (12_000 input), not accumulated (180_000)
expect(stored[sessionKey].totalTokens).toBe(12_000);
expect(stored[sessionKey].totalTokensFresh).toBe(true);
// inputTokens/outputTokens still reflect accumulated usage for cost tracking
expect(stored[sessionKey].inputTokens).toBe(180_000);
expect(stored[sessionKey].outputTokens).toBe(10_000);
});
it("marks totalTokens as unknown when no fresh context snapshot is available", async () => {
it("falls back to accumulated usage for totalTokens when lastCallUsage not provided", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-usage-"));
const storePath = path.join(tmp, "sessions.json");
const sessionKey = "main";
@@ -68,34 +67,10 @@ describe("persistSessionUsageUpdate", () => {
});
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(stored[sessionKey].totalTokens).toBeUndefined();
expect(stored[sessionKey].totalTokensFresh).toBe(false);
expect(stored[sessionKey].totalTokens).toBe(50_000);
});
it("uses promptTokens when available without lastCallUsage", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-usage-"));
const storePath = path.join(tmp, "sessions.json");
const sessionKey = "main";
await seedSessionStore({
storePath,
sessionKey,
entry: { sessionId: "s1", updatedAt: Date.now() },
});
await persistSessionUsageUpdate({
storePath,
sessionKey,
usage: { input: 50_000, output: 5_000, total: 55_000 },
promptTokens: 42_000,
contextTokensUsed: 200_000,
});
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(stored[sessionKey].totalTokens).toBe(42_000);
expect(stored[sessionKey].totalTokensFresh).toBe(true);
});
it("keeps non-clamped lastCallUsage totalTokens when exceeding context window", async () => {
it("caps totalTokens at context window even with lastCallUsage", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-usage-"));
const storePath = path.join(tmp, "sessions.json");
const sessionKey = "main";
@@ -114,7 +89,7 @@ describe("persistSessionUsageUpdate", () => {
});
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
expect(stored[sessionKey].totalTokens).toBe(250_000);
expect(stored[sessionKey].totalTokensFresh).toBe(true);
// Capped at context window
expect(stored[sessionKey].totalTokens).toBe(200_000);
});
});

View File

@@ -45,29 +45,20 @@ export async function persistSessionUsageUpdate(params: {
const input = params.usage?.input ?? 0;
const output = params.usage?.output ?? 0;
const resolvedContextTokens = params.contextTokensUsed ?? entry.contextTokens;
const hasPromptTokens =
typeof params.promptTokens === "number" &&
Number.isFinite(params.promptTokens) &&
params.promptTokens > 0;
const hasFreshContextSnapshot = Boolean(params.lastCallUsage) || hasPromptTokens;
// Use last-call usage for totalTokens when available. The accumulated
// `usage.input` sums input tokens from every API call in the run
// (tool-use loops, compaction retries), overstating actual context.
// `lastCallUsage` reflects only the final API call — the true context.
const usageForContext = params.lastCallUsage ?? params.usage;
const totalTokens = hasFreshContextSnapshot
? deriveSessionTotalTokens({
usage: usageForContext,
contextTokens: resolvedContextTokens,
promptTokens: params.promptTokens,
})
: undefined;
const patch: Partial<SessionEntry> = {
inputTokens: input,
outputTokens: output,
// Missing a last-call snapshot means context utilization is stale/unknown.
totalTokens,
totalTokensFresh: typeof totalTokens === "number",
totalTokens:
deriveSessionTotalTokens({
usage: usageForContext,
contextTokens: resolvedContextTokens,
promptTokens: params.promptTokens,
}) ?? input,
modelProvider: params.providerUsed ?? entry.modelProvider,
model: params.modelUsed ?? entry.model,
contextTokens: resolvedContextTokens,

View File

@@ -55,13 +55,12 @@ export type SessionInitResult = {
function forkSessionFromParent(params: {
parentEntry: SessionEntry;
agentId: string;
sessionsDir: string;
}): { sessionId: string; sessionFile: string } | null {
const parentSessionFile = resolveSessionFilePath(
params.parentEntry.sessionId,
params.parentEntry,
{ agentId: params.agentId, sessionsDir: params.sessionsDir },
{ sessionsDir: params.sessionsDir },
);
if (!parentSessionFile || !fs.existsSync(parentSessionFile)) {
return null;
@@ -332,7 +331,6 @@ export async function initSessionState(params: {
);
const forked = forkSessionFromParent({
parentEntry: sessionStore[parentSessionKey],
agentId,
sessionsDir: path.dirname(storePath),
});
if (forked) {

View File

@@ -468,69 +468,6 @@ describe("buildStatusMessage", () => {
{ prefix: "openclaw-status-" },
);
});
it("reads transcript usage using explicit agentId when sessionKey is missing", async () => {
await withTempHome(
async (dir) => {
vi.resetModules();
const { buildStatusMessage: buildStatusMessageDynamic } = await import("./status.js");
const sessionId = "sess-worker2";
const logPath = path.join(
dir,
".openclaw",
"agents",
"worker2",
"sessions",
`${sessionId}.jsonl`,
);
fs.mkdirSync(path.dirname(logPath), { recursive: true });
fs.writeFileSync(
logPath,
[
JSON.stringify({
type: "message",
message: {
role: "assistant",
model: "claude-opus-4-5",
usage: {
input: 2,
output: 3,
cacheRead: 1200,
cacheWrite: 0,
totalTokens: 1205,
},
},
}),
].join("\n"),
"utf-8",
);
const text = buildStatusMessageDynamic({
agent: {
model: "anthropic/claude-opus-4-5",
contextTokens: 32_000,
},
agentId: "worker2",
sessionEntry: {
sessionId,
updatedAt: 0,
totalTokens: 5,
contextTokens: 32_000,
},
// Intentionally omitted: sessionKey
sessionScope: "per-sender",
queue: { mode: "collect", depth: 0 },
includeTranscriptUsage: true,
modelAuth: "api-key",
});
expect(normalizeTestText(text)).toContain("Context: 1.2k/32k");
},
{ prefix: "openclaw-status-" },
);
});
});
describe("buildCommandsMessage", () => {

View File

@@ -58,7 +58,6 @@ type QueueStatus = {
type StatusArgs = {
config?: OpenClawConfig;
agent: AgentConfig;
agentId?: string;
sessionEntry?: SessionEntry;
sessionKey?: string;
sessionScope?: SessionScope;
@@ -169,7 +168,6 @@ const formatQueueDetails = (queue?: QueueStatus) => {
const readUsageFromSessionLog = (
sessionId?: string,
sessionEntry?: SessionEntry,
agentId?: string,
sessionKey?: string,
storePath?: string,
):
@@ -187,12 +185,11 @@ const readUsageFromSessionLog = (
}
let logPath: string;
try {
const resolvedAgentId =
agentId ?? (sessionKey ? resolveAgentIdFromSessionKey(sessionKey) : undefined);
const agentId = sessionKey ? resolveAgentIdFromSessionKey(sessionKey) : undefined;
logPath = resolveSessionFilePath(
sessionId,
sessionEntry,
resolveSessionFilePathOptions({ agentId: resolvedAgentId, storePath }),
resolveSessionFilePathOptions({ agentId, storePath }),
);
} catch {
return undefined;
@@ -354,7 +351,6 @@ export function buildStatusMessage(args: StatusArgs): string {
const logUsage = readUsageFromSessionLog(
entry?.sessionId,
entry,
args.agentId,
args.sessionKey,
args.sessionStorePath,
);

View File

@@ -69,9 +69,6 @@ export type MsgContext = {
ForwardedFromMessageId?: number;
ForwardedDate?: number;
ThreadStarterBody?: string;
/** Full thread history when starting a new thread session. */
ThreadHistoryBody?: string;
IsFirstThreadTurn?: boolean;
ThreadLabel?: string;
MediaPath?: string;
MediaUrl?: string;

View File

@@ -44,7 +44,6 @@ describe("listThinkingLevels", () => {
it("includes xhigh for codex models", () => {
expect(listThinkingLevels(undefined, "gpt-5.2-codex")).toContain("xhigh");
expect(listThinkingLevels(undefined, "gpt-5.3-codex")).toContain("xhigh");
expect(listThinkingLevels(undefined, "gpt-5.3-codex-spark")).toContain("xhigh");
});
it("includes xhigh for openai gpt-5.2", () => {

View File

@@ -24,7 +24,6 @@ export function isBinaryThinkingProvider(provider?: string | null): boolean {
export const XHIGH_MODEL_REFS = [
"openai/gpt-5.2",
"openai-codex/gpt-5.3-codex",
"openai-codex/gpt-5.3-codex-spark",
"openai-codex/gpt-5.2-codex",
"openai-codex/gpt-5.1-codex",
"github-copilot/gpt-5.2-codex",

View File

@@ -55,8 +55,13 @@ export function resolveLegacyDaemonCliAccessors(
}
const registerContainer = findRegisterContainerSymbol(bundleSource);
const registerContainerAlias = registerContainer ? aliases.get(registerContainer) : undefined;
const registerDirectAlias = aliases.get("registerDaemonCli");
if (!registerContainer) {
return null;
}
const registerContainerAlias = aliases.get(registerContainer);
if (!registerContainerAlias) {
return null;
}
const runDaemonInstall = aliases.get("runDaemonInstall");
const runDaemonRestart = aliases.get("runDaemonRestart");
@@ -65,7 +70,6 @@ export function resolveLegacyDaemonCliAccessors(
const runDaemonStop = aliases.get("runDaemonStop");
const runDaemonUninstall = aliases.get("runDaemonUninstall");
if (
!(registerContainerAlias || registerDirectAlias) ||
!runDaemonInstall ||
!runDaemonRestart ||
!runDaemonStart ||
@@ -77,9 +81,7 @@ export function resolveLegacyDaemonCliAccessors(
}
return {
registerDaemonCli: registerContainerAlias
? `${registerContainerAlias}.registerDaemonCli`
: registerDirectAlias!,
registerDaemonCli: `${registerContainerAlias}.registerDaemonCli`,
runDaemonInstall,
runDaemonRestart,
runDaemonStart,

View File

@@ -60,35 +60,49 @@ vi.mock("../infra/exec-approvals.js", async () => {
});
describe("exec approvals CLI", () => {
const createProgram = async () => {
const { registerExecApprovalsCli } = await import("./exec-approvals-cli.js");
const program = new Command();
program.exitOverride();
registerExecApprovalsCli(program);
return program;
};
it("routes get command to local, gateway, and node modes", async () => {
it("loads local approvals by default", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGatewayFromCli.mockClear();
const localProgram = await createProgram();
await localProgram.parseAsync(["approvals", "get"], { from: "user" });
const { registerExecApprovalsCli } = await import("./exec-approvals-cli.js");
const program = new Command();
program.exitOverride();
registerExecApprovalsCli(program);
await program.parseAsync(["approvals", "get"], { from: "user" });
expect(callGatewayFromCli).not.toHaveBeenCalled();
expect(runtimeErrors).toHaveLength(0);
});
it("loads gateway approvals when --gateway is set", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGatewayFromCli.mockClear();
const gatewayProgram = await createProgram();
await gatewayProgram.parseAsync(["approvals", "get", "--gateway"], { from: "user" });
const { registerExecApprovalsCli } = await import("./exec-approvals-cli.js");
const program = new Command();
program.exitOverride();
registerExecApprovalsCli(program);
await program.parseAsync(["approvals", "get", "--gateway"], { from: "user" });
expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.get", expect.anything(), {});
expect(runtimeErrors).toHaveLength(0);
});
it("loads node approvals when --node is set", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGatewayFromCli.mockClear();
const nodeProgram = await createProgram();
await nodeProgram.parseAsync(["approvals", "get", "--node", "macbook"], { from: "user" });
const { registerExecApprovalsCli } = await import("./exec-approvals-cli.js");
const program = new Command();
program.exitOverride();
registerExecApprovalsCli(program);
await program.parseAsync(["approvals", "get", "--node", "macbook"], { from: "user" });
expect(callGatewayFromCli).toHaveBeenCalledWith("exec.approvals.node.get", expect.anything(), {
nodeId: "node-1",

View File

@@ -66,16 +66,14 @@ export async function updateSessionStoreAfterAgentRun(params: {
if (hasNonzeroUsage(usage)) {
const input = usage.input ?? 0;
const output = usage.output ?? 0;
const totalTokens =
next.inputTokens = input;
next.outputTokens = output;
next.totalTokens =
deriveSessionTotalTokens({
usage,
contextTokens,
promptTokens,
}) ?? input;
next.inputTokens = input;
next.outputTokens = output;
next.totalTokens = totalTokens;
next.totalTokensFresh = true;
}
if (compactionsThisRun > 0) {
next.compactionCount = (entry.compactionCount ?? 0) + compactionsThisRun;

View File

@@ -10,7 +10,6 @@ import {
resolveEnvApiKey,
} from "../../agents/model-auth.js";
import { ensureOpenClawModelsJson } from "../../agents/models-config.js";
import { ensurePiAuthJsonFromAuthProfiles } from "../../agents/pi-auth-json.js";
import { discoverAuthStorage, discoverModels } from "../../agents/pi-model-discovery.js";
import { modelKey } from "./shared.js";
@@ -49,7 +48,6 @@ const hasAuthForProvider = (provider: string, cfg: OpenClawConfig, authStore: Au
export async function loadModelRegistry(cfg: OpenClawConfig) {
await ensureOpenClawModelsJson(cfg);
const agentDir = resolveOpenClawAgentDir();
await ensurePiAuthJsonFromAuthProfiles(agentDir);
const authStorage = discoverAuthStorage(agentDir);
const registry = discoverModels(authStorage, agentDir);
const models = registry.getAll();

View File

@@ -66,8 +66,6 @@ describe("sessionsCommand", () => {
updatedAt: Date.now() - 45 * 60_000,
inputTokens: 1200,
outputTokens: 800,
totalTokens: 2000,
totalTokensFresh: true,
model: "pi:opus",
},
});
@@ -101,48 +99,8 @@ describe("sessionsCommand", () => {
fs.rmSync(store);
const row = logs.find((line) => line.includes("discord:group:demo")) ?? "";
expect(row).toContain("unknown/32k (?%)");
expect(row).toContain("-".padEnd(20));
expect(row).toContain("think:high");
expect(row).toContain("5m ago");
});
it("exports freshness metadata in JSON output", async () => {
const store = writeStore({
main: {
sessionId: "abc123",
updatedAt: Date.now() - 10 * 60_000,
inputTokens: 1200,
outputTokens: 800,
totalTokens: 2000,
totalTokensFresh: true,
model: "pi:opus",
},
"discord:group:demo": {
sessionId: "xyz",
updatedAt: Date.now() - 5 * 60_000,
inputTokens: 20,
outputTokens: 10,
model: "pi:opus",
},
});
const { runtime, logs } = makeRuntime();
await sessionsCommand({ store, json: true }, runtime);
fs.rmSync(store);
const payload = JSON.parse(logs[0] ?? "{}") as {
sessions?: Array<{
key: string;
totalTokens: number | null;
totalTokensFresh: boolean;
}>;
};
const main = payload.sessions?.find((row) => row.key === "main");
const group = payload.sessions?.find((row) => row.key === "discord:group:demo");
expect(main?.totalTokens).toBe(2000);
expect(main?.totalTokensFresh).toBe(true);
expect(group?.totalTokens).toBeNull();
expect(group?.totalTokensFresh).toBe(false);
});
});

View File

@@ -3,12 +3,7 @@ import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
resolveFreshSessionTotalTokens,
resolveStorePath,
type SessionEntry,
} from "../config/sessions.js";
import { loadSessionStore, resolveStorePath, type SessionEntry } from "../config/sessions.js";
import { info } from "../globals.js";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
import { isRich, theme } from "../terminal/theme.js";
@@ -30,7 +25,6 @@ type SessionRow = {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
totalTokensFresh?: boolean;
model?: string;
contextTokens?: number;
};
@@ -67,15 +61,9 @@ const colorByPct = (label: string, pct: number | null, rich: boolean) => {
return theme.muted(label);
};
const formatTokensCell = (
total: number | undefined,
contextTokens: number | null,
rich: boolean,
) => {
if (total === undefined) {
const ctxLabel = contextTokens ? formatKTokens(contextTokens) : "?";
const label = `unknown/${ctxLabel} (?%)`;
return rich ? theme.muted(label.padEnd(TOKENS_PAD)) : label.padEnd(TOKENS_PAD);
const formatTokensCell = (total: number, contextTokens: number | null, rich: boolean) => {
if (!total) {
return "-".padEnd(TOKENS_PAD);
}
const totalLabel = formatKTokens(total);
const ctxLabel = contextTokens ? formatKTokens(contextTokens) : "?";
@@ -166,7 +154,6 @@ function toRows(store: Record<string, SessionEntry>): SessionRow[] {
inputTokens: entry?.inputTokens,
outputTokens: entry?.outputTokens,
totalTokens: entry?.totalTokens,
totalTokensFresh: entry?.totalTokensFresh,
model: entry?.model,
contextTokens: entry?.contextTokens,
} satisfies SessionRow;
@@ -222,9 +209,6 @@ export async function sessionsCommand(
activeMinutes: activeMinutes ?? null,
sessions: rows.map((r) => ({
...r,
totalTokens: resolveFreshSessionTotalTokens(r) ?? null,
totalTokensFresh:
typeof r.totalTokens === "number" ? r.totalTokensFresh !== false : false,
contextTokens:
r.contextTokens ?? lookupContextTokens(r.model) ?? configContextTokens ?? null,
model: r.model ?? configModel ?? null,
@@ -262,7 +246,9 @@ export async function sessionsCommand(
for (const row of rows) {
const model = row.model ?? configModel;
const contextTokens = row.contextTokens ?? lookupContextTokens(model) ?? configContextTokens;
const total = resolveFreshSessionTotalTokens(row);
const input = row.inputTokens ?? 0;
const output = row.outputTokens ?? 0;
const total = row.totalTokens ?? input + output;
const keyLabel = truncateKey(row.key).padEnd(KEY_PAD);
const keyCell = rich ? theme.accent(keyLabel) : keyLabel;

View File

@@ -1,128 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ReleaseAsset } from "./signal-install.js";
import { looksLikeArchive, pickAsset } from "./signal-install.js";
// Realistic asset list modelled after an actual signal-cli GitHub release.
const SAMPLE_ASSETS: ReleaseAsset[] = [
{
name: "signal-cli-0.13.14-Linux-native.tar.gz",
browser_download_url: "https://example.com/linux-native.tar.gz",
},
{
name: "signal-cli-0.13.14-Linux-native.tar.gz.asc",
browser_download_url: "https://example.com/linux-native.tar.gz.asc",
},
{
name: "signal-cli-0.13.14-macOS-native.tar.gz",
browser_download_url: "https://example.com/macos-native.tar.gz",
},
{
name: "signal-cli-0.13.14-macOS-native.tar.gz.asc",
browser_download_url: "https://example.com/macos-native.tar.gz.asc",
},
{
name: "signal-cli-0.13.14-Windows-native.zip",
browser_download_url: "https://example.com/windows-native.zip",
},
{
name: "signal-cli-0.13.14-Windows-native.zip.asc",
browser_download_url: "https://example.com/windows-native.zip.asc",
},
{ name: "signal-cli-0.13.14.tar.gz", browser_download_url: "https://example.com/jvm.tar.gz" },
{
name: "signal-cli-0.13.14.tar.gz.asc",
browser_download_url: "https://example.com/jvm.tar.gz.asc",
},
];
describe("looksLikeArchive", () => {
it("recognises .tar.gz", () => {
expect(looksLikeArchive("foo.tar.gz")).toBe(true);
});
it("recognises .tgz", () => {
expect(looksLikeArchive("foo.tgz")).toBe(true);
});
it("recognises .zip", () => {
expect(looksLikeArchive("foo.zip")).toBe(true);
});
it("rejects signature files", () => {
expect(looksLikeArchive("foo.tar.gz.asc")).toBe(false);
});
it("rejects unrelated files", () => {
expect(looksLikeArchive("README.md")).toBe(false);
});
});
describe("pickAsset", () => {
describe("linux", () => {
it("selects the Linux-native asset on x64", () => {
const result = pickAsset(SAMPLE_ASSETS, "linux", "x64");
expect(result).toBeDefined();
expect(result!.name).toContain("Linux-native");
expect(result!.name).toMatch(/\.tar\.gz$/);
});
it("returns undefined on arm64 (triggers brew fallback)", () => {
const result = pickAsset(SAMPLE_ASSETS, "linux", "arm64");
expect(result).toBeUndefined();
});
it("returns undefined on arm (32-bit)", () => {
const result = pickAsset(SAMPLE_ASSETS, "linux", "arm");
expect(result).toBeUndefined();
});
});
describe("darwin", () => {
it("selects the macOS-native asset", () => {
const result = pickAsset(SAMPLE_ASSETS, "darwin", "arm64");
expect(result).toBeDefined();
expect(result!.name).toContain("macOS-native");
});
it("selects the macOS-native asset on x64", () => {
const result = pickAsset(SAMPLE_ASSETS, "darwin", "x64");
expect(result).toBeDefined();
expect(result!.name).toContain("macOS-native");
});
});
describe("win32", () => {
it("selects the Windows-native asset", () => {
const result = pickAsset(SAMPLE_ASSETS, "win32", "x64");
expect(result).toBeDefined();
expect(result!.name).toContain("Windows-native");
expect(result!.name).toMatch(/\.zip$/);
});
});
describe("edge cases", () => {
it("returns undefined for an empty asset list", () => {
expect(pickAsset([], "linux", "x64")).toBeUndefined();
});
it("skips assets with missing name or url", () => {
const partial: ReleaseAsset[] = [
{ name: "signal-cli.tar.gz" },
{ browser_download_url: "https://example.com/file.tar.gz" },
];
expect(pickAsset(partial, "linux", "x64")).toBeUndefined();
});
it("falls back to first archive for unknown platform", () => {
const result = pickAsset(SAMPLE_ASSETS, "freebsd" as NodeJS.Platform, "x64");
expect(result).toBeDefined();
expect(result!.name).toMatch(/\.tar\.gz$/);
});
it("never selects .asc signature files", () => {
const result = pickAsset(SAMPLE_ASSETS, "linux", "x64");
expect(result).toBeDefined();
expect(result!.name).not.toMatch(/\.asc$/);
});
});
});

View File

@@ -5,16 +5,15 @@ import os from "node:os";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import type { RuntimeEnv } from "../runtime.js";
import { resolveBrewExecutable } from "../infra/brew.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { CONFIG_DIR } from "../utils.js";
export type ReleaseAsset = {
type ReleaseAsset = {
name?: string;
browser_download_url?: string;
};
export type NamedAsset = {
type NamedAsset = {
name: string;
browser_download_url: string;
};
@@ -31,55 +30,39 @@ export type SignalInstallResult = {
error?: string;
};
/** @internal Exported for testing. */
export function looksLikeArchive(name: string): boolean {
function looksLikeArchive(name: string): boolean {
return name.endsWith(".tar.gz") || name.endsWith(".tgz") || name.endsWith(".zip");
}
/**
* Pick a native release asset from the official GitHub releases.
*
* The official signal-cli releases only publish native (GraalVM) binaries for
* x86-64 Linux. On architectures where no native asset is available this
* returns `undefined` so the caller can fall back to a different install
* strategy (e.g. Homebrew).
*/
/** @internal Exported for testing. */
export function pickAsset(
assets: ReleaseAsset[],
platform: NodeJS.Platform,
arch: string,
): NamedAsset | undefined {
function pickAsset(assets: ReleaseAsset[], platform: NodeJS.Platform) {
const withName = assets.filter((asset): asset is NamedAsset =>
Boolean(asset.name && asset.browser_download_url),
);
// Archives only, excluding signature files (.asc)
const archives = withName.filter((a) => looksLikeArchive(a.name.toLowerCase()));
const byName = (pattern: RegExp) =>
archives.find((asset) => pattern.test(asset.name.toLowerCase()));
withName.find((asset) => pattern.test(asset.name.toLowerCase()));
if (platform === "linux") {
// The official "Linux-native" asset is an x86-64 GraalVM binary.
// On non-x64 architectures it will fail with "Exec format error",
// so only select it when the host architecture matches.
if (arch === "x64") {
return byName(/linux-native/) || byName(/linux/) || archives[0];
}
// No native release for this arch — caller should fall back.
return undefined;
return (
byName(/linux-native/) ||
byName(/linux/) ||
withName.find((asset) => looksLikeArchive(asset.name.toLowerCase()))
);
}
if (platform === "darwin") {
return byName(/macos|osx|darwin/) || archives[0];
return (
byName(/macos|osx|darwin/) ||
withName.find((asset) => looksLikeArchive(asset.name.toLowerCase()))
);
}
if (platform === "win32") {
return byName(/windows|win/) || archives[0];
return (
byName(/windows|win/) || withName.find((asset) => looksLikeArchive(asset.name.toLowerCase()))
);
}
return archives[0];
return withName.find((asset) => looksLikeArchive(asset.name.toLowerCase()));
}
async function downloadToFile(url: string, dest: string, maxRedirects = 5): Promise<void> {
@@ -127,84 +110,14 @@ async function findSignalCliBinary(root: string): Promise<string | null> {
return candidates[0] ?? null;
}
// ---------------------------------------------------------------------------
// Brew-based install (used on architectures without an official native build)
// ---------------------------------------------------------------------------
async function resolveBrewSignalCliPath(brewExe: string): Promise<string | null> {
try {
const result = await runCommandWithTimeout([brewExe, "--prefix", "signal-cli"], {
timeoutMs: 10_000,
});
if (result.code === 0 && result.stdout.trim()) {
const prefix = result.stdout.trim();
// Homebrew installs the wrapper script at <prefix>/bin/signal-cli
const candidate = path.join(prefix, "bin", "signal-cli");
try {
await fs.access(candidate);
return candidate;
} catch {
// Fall back to searching the prefix
return findSignalCliBinary(prefix);
}
}
} catch {
// ignore
}
return null;
}
async function installSignalCliViaBrew(runtime: RuntimeEnv): Promise<SignalInstallResult> {
const brewExe = resolveBrewExecutable();
if (!brewExe) {
export async function installSignalCli(runtime: RuntimeEnv): Promise<SignalInstallResult> {
if (process.platform === "win32") {
return {
ok: false,
error:
`No native signal-cli build is available for ${process.arch}. ` +
"Install Homebrew (https://brew.sh) and try again, or install signal-cli manually.",
error: "Signal CLI auto-install is not supported on Windows yet.",
};
}
runtime.log(`Installing signal-cli via Homebrew (${brewExe})…`);
const result = await runCommandWithTimeout([brewExe, "install", "signal-cli"], {
timeoutMs: 15 * 60_000, // brew builds from source; can take a while
});
if (result.code !== 0) {
return {
ok: false,
error: `brew install signal-cli failed (exit ${result.code}): ${result.stderr.trim().slice(0, 200)}`,
};
}
const cliPath = await resolveBrewSignalCliPath(brewExe);
if (!cliPath) {
return {
ok: false,
error: "brew install succeeded but signal-cli binary was not found.",
};
}
// Extract version from the installed binary.
let version: string | undefined;
try {
const vResult = await runCommandWithTimeout([cliPath, "--version"], {
timeoutMs: 10_000,
});
// Output is typically "signal-cli 0.13.24"
version = vResult.stdout.trim().replace(/^signal-cli\s+/, "") || undefined;
} catch {
// non-critical; leave version undefined
}
return { ok: true, cliPath, version };
}
// ---------------------------------------------------------------------------
// Direct download install (used when an official native asset is available)
// ---------------------------------------------------------------------------
async function installSignalCliFromRelease(runtime: RuntimeEnv): Promise<SignalInstallResult> {
const apiUrl = "https://api.github.com/repos/AsamK/signal-cli/releases/latest";
const response = await fetch(apiUrl, {
headers: {
@@ -223,9 +136,11 @@ async function installSignalCliFromRelease(runtime: RuntimeEnv): Promise<SignalI
const payload = (await response.json()) as ReleaseResponse;
const version = payload.tag_name?.replace(/^v/, "") ?? "unknown";
const assets = payload.assets ?? [];
const asset = pickAsset(assets, process.platform, process.arch);
const asset = pickAsset(assets, process.platform);
const assetName = asset?.name ?? "";
const assetUrl = asset?.browser_download_url ?? "";
if (!asset) {
if (!assetName || !assetUrl) {
return {
ok: false,
error: "No compatible release asset found for this platform.",
@@ -233,31 +148,31 @@ async function installSignalCliFromRelease(runtime: RuntimeEnv): Promise<SignalI
}
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-signal-"));
const archivePath = path.join(tmpDir, asset.name);
const archivePath = path.join(tmpDir, assetName);
runtime.log(`Downloading signal-cli ${version} (${asset.name})…`);
await downloadToFile(asset.browser_download_url, archivePath);
runtime.log(`Downloading signal-cli ${version} (${assetName})…`);
await downloadToFile(assetUrl, archivePath);
const installRoot = path.join(CONFIG_DIR, "tools", "signal-cli", version);
await fs.mkdir(installRoot, { recursive: true });
if (asset.name.endsWith(".zip")) {
if (assetName.endsWith(".zip")) {
await runCommandWithTimeout(["unzip", "-q", archivePath, "-d", installRoot], {
timeoutMs: 60_000,
});
} else if (asset.name.endsWith(".tar.gz") || asset.name.endsWith(".tgz")) {
} else if (assetName.endsWith(".tar.gz") || assetName.endsWith(".tgz")) {
await runCommandWithTimeout(["tar", "-xzf", archivePath, "-C", installRoot], {
timeoutMs: 60_000,
});
} else {
return { ok: false, error: `Unsupported archive type: ${asset.name}` };
return { ok: false, error: `Unsupported archive type: ${assetName}` };
}
const cliPath = await findSignalCliBinary(installRoot);
if (!cliPath) {
return {
ok: false,
error: `signal-cli binary not found after extracting ${asset.name}`,
error: `signal-cli binary not found after extracting ${assetName}`,
};
}
@@ -265,27 +180,3 @@ async function installSignalCliFromRelease(runtime: RuntimeEnv): Promise<SignalI
return { ok: true, cliPath, version };
}
// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------
export async function installSignalCli(runtime: RuntimeEnv): Promise<SignalInstallResult> {
if (process.platform === "win32") {
return {
ok: false,
error: "Signal CLI auto-install is not supported on Windows yet.",
};
}
// The official signal-cli GitHub releases only ship a native binary for
// x86-64 Linux. On other architectures (arm64, armv7, etc.) we delegate
// to Homebrew which builds from source and bundles the JRE automatically.
const hasNativeRelease = process.platform !== "linux" || process.arch === "x64";
if (hasNativeRelease) {
return installSignalCliFromRelease(runtime);
}
return installSignalCliViaBrew(runtime);
}

View File

@@ -22,11 +22,8 @@ export const shortenText = (value: string, maxLen: number) => {
export const formatTokensCompact = (
sess: Pick<SessionStatus, "totalTokens" | "contextTokens" | "percentUsed">,
) => {
const used = sess.totalTokens;
const used = sess.totalTokens ?? 0;
const ctx = sess.contextTokens;
if (used == null) {
return ctx ? `unknown/${formatKTokens(ctx)} (?%)` : "unknown used";
}
if (!ctx) {
return `${formatKTokens(used)} used`;
}

View File

@@ -5,7 +5,6 @@ import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
resolveFreshSessionTotalTokens,
resolveMainSessionKey,
resolveStorePath,
type SessionEntry,
@@ -121,13 +120,12 @@ export async function getStatusSummary(): Promise<StatusSummary> {
const model = entry?.model ?? configModel ?? null;
const contextTokens =
entry?.contextTokens ?? lookupContextTokens(model) ?? configContextTokens ?? null;
const total = resolveFreshSessionTotalTokens(entry);
const totalTokensFresh =
typeof entry?.totalTokens === "number" ? entry?.totalTokensFresh !== false : false;
const remaining =
contextTokens != null && total !== undefined ? Math.max(0, contextTokens - total) : null;
const input = entry?.inputTokens ?? 0;
const output = entry?.outputTokens ?? 0;
const total = entry?.totalTokens ?? input + output;
const remaining = contextTokens != null ? Math.max(0, contextTokens - total) : null;
const pct =
contextTokens && contextTokens > 0 && total !== undefined
contextTokens && contextTokens > 0
? Math.min(999, Math.round((total / contextTokens) * 100))
: null;
const parsedAgentId = parseAgentSessionKey(key)?.agentId;
@@ -149,7 +147,6 @@ export async function getStatusSummary(): Promise<StatusSummary> {
inputTokens: entry?.inputTokens,
outputTokens: entry?.outputTokens,
totalTokens: total ?? null,
totalTokensFresh,
remainingTokens: remaining,
percentUsed: pct,
model,

View File

@@ -23,7 +23,6 @@ const mocks = vi.hoisted(() => ({
thinkingLevel: "low",
inputTokens: 2_000,
outputTokens: 3_000,
totalTokens: 5_000,
contextTokens: 10_000,
model: "pi:opus",
sessionId: "abc123",
@@ -121,12 +120,6 @@ vi.mock("../config/sessions.js", () => ({
loadSessionStore: mocks.loadSessionStore,
resolveMainSessionKey: mocks.resolveMainSessionKey,
resolveStorePath: mocks.resolveStorePath,
resolveFreshSessionTotalTokens: vi.fn(
(entry?: { totalTokens?: number; totalTokensFresh?: boolean }) =>
typeof entry?.totalTokens === "number" && entry?.totalTokensFresh !== false
? entry.totalTokens
: undefined,
),
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
}));
@@ -310,7 +303,6 @@ describe("statusCommand", () => {
expect(payload.sessions.defaults.model).toBeTruthy();
expect(payload.sessions.defaults.contextTokens).toBeGreaterThan(0);
expect(payload.sessions.recent[0].percentUsed).toBe(50);
expect(payload.sessions.recent[0].totalTokensFresh).toBe(true);
expect(payload.sessions.recent[0].remainingTokens).toBe(5000);
expect(payload.sessions.recent[0].flags).toContain("verbose:on");
expect(payload.securityAudit.summary.critical).toBe(1);
@@ -319,55 +311,6 @@ describe("statusCommand", () => {
expect(payload.nodeService.label).toBe("LaunchAgent");
});
it("surfaces unknown usage when totalTokens is missing", async () => {
const originalLoadSessionStore = mocks.loadSessionStore.getMockImplementation();
mocks.loadSessionStore.mockReturnValue({
"+1000": {
updatedAt: Date.now() - 60_000,
inputTokens: 2_000,
outputTokens: 3_000,
contextTokens: 10_000,
model: "pi:opus",
},
});
(runtime.log as vi.Mock).mockClear();
await statusCommand({ json: true }, runtime as never);
const payload = JSON.parse((runtime.log as vi.Mock).mock.calls.at(-1)?.[0]);
expect(payload.sessions.recent[0].totalTokens).toBeNull();
expect(payload.sessions.recent[0].totalTokensFresh).toBe(false);
expect(payload.sessions.recent[0].percentUsed).toBeNull();
expect(payload.sessions.recent[0].remainingTokens).toBeNull();
if (originalLoadSessionStore) {
mocks.loadSessionStore.mockImplementation(originalLoadSessionStore);
}
});
it("prints unknown usage in formatted output when totalTokens is missing", async () => {
const originalLoadSessionStore = mocks.loadSessionStore.getMockImplementation();
mocks.loadSessionStore.mockReturnValue({
"+1000": {
updatedAt: Date.now() - 60_000,
inputTokens: 2_000,
outputTokens: 3_000,
contextTokens: 10_000,
model: "pi:opus",
},
});
try {
(runtime.log as vi.Mock).mockClear();
await statusCommand({}, runtime as never);
const logs = (runtime.log as vi.Mock).mock.calls.map((c) => String(c[0]));
expect(logs.some((line) => line.includes("unknown/") && line.includes("(?%)"))).toBe(true);
} finally {
if (originalLoadSessionStore) {
mocks.loadSessionStore.mockImplementation(originalLoadSessionStore);
}
}
});
it("prints formatted lines otherwise", async () => {
(runtime.log as vi.Mock).mockClear();
await statusCommand({}, runtime as never);
@@ -496,7 +439,6 @@ describe("statusCommand", () => {
updatedAt: Date.now() - 120_000,
inputTokens: 1_000,
outputTokens: 1_000,
totalTokens: 2_000,
contextTokens: 10_000,
model: "pi:opus",
},
@@ -509,7 +451,6 @@ describe("statusCommand", () => {
thinkingLevel: "low",
inputTokens: 2_000,
outputTokens: 3_000,
totalTokens: 5_000,
contextTokens: 10_000,
model: "pi:opus",
sessionId: "abc123",

View File

@@ -16,7 +16,6 @@ export type SessionStatus = {
inputTokens?: number;
outputTokens?: number;
totalTokens: number | null;
totalTokensFresh: boolean;
remainingTokens: number | null;
percentUsed: number | null;
model: string | null;

View File

@@ -1,33 +0,0 @@
import { describe, expect, it, vi } from "vitest";
describe("gateway.tools config", () => {
it("accepts gateway.tools allow and deny lists", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
gateway: {
tools: {
allow: ["gateway"],
deny: ["sessions_spawn", "sessions_send"],
},
},
});
expect(res.ok).toBe(true);
});
it("rejects invalid gateway.tools values", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
gateway: {
tools: {
allow: "gateway",
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("gateway.tools.allow");
}
});
});

View File

@@ -1,8 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_AGENT_MAX_CONCURRENT, DEFAULT_SUBAGENT_MAX_CONCURRENT } from "./agent-limits.js";
import { loadConfig } from "./config.js";
import { withTempHome } from "./test-helpers.js";
describe("config identity defaults", () => {
@@ -16,77 +15,139 @@ describe("config identity defaults", () => {
process.env.HOME = previousHome;
});
const writeAndLoadConfig = async (home: string, config: Record<string, unknown>) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(config, null, 2),
"utf-8",
);
return loadConfig();
};
it("does not derive mention defaults and only sets ackReactionScope when identity is present", async () => {
it("does not derive mentionPatterns when identity is set", async () => {
await withTempHome(async (home) => {
const cfg = await writeAndLoadConfig(home, {
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
},
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(
{
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
},
},
],
},
],
},
messages: {},
});
messages: {},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.responsePrefix).toBeUndefined();
expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined();
});
});
it("defaults ackReactionScope without setting ackReaction", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(
{
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
},
},
],
},
messages: {},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.ackReaction).toBeUndefined();
expect(cfg.messages?.ackReactionScope).toBe("group-mentions");
});
});
it("keeps ackReaction unset and does not synthesize agent/session defaults when identity is missing", async () => {
it("keeps ackReaction unset when identity is missing", async () => {
await withTempHome(async (home) => {
const cfg = await writeAndLoadConfig(home, { messages: {} });
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(
{
messages: {},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.ackReaction).toBeUndefined();
expect(cfg.messages?.ackReactionScope).toBe("group-mentions");
expect(cfg.messages?.responsePrefix).toBeUndefined();
expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined();
expect(cfg.agents?.list).toBeUndefined();
expect(cfg.agents?.defaults?.maxConcurrent).toBe(DEFAULT_AGENT_MAX_CONCURRENT);
expect(cfg.agents?.defaults?.subagents?.maxConcurrent).toBe(DEFAULT_SUBAGENT_MAX_CONCURRENT);
expect(cfg.session).toBeUndefined();
});
});
it("does not override explicit values", async () => {
await withTempHome(async (home) => {
const cfg = await writeAndLoadConfig(home, {
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha Sloth",
theme: "space lobster",
emoji: "🦞",
},
groupChat: { mentionPatterns: ["@openclaw"] },
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(
{
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha Sloth",
theme: "space lobster",
emoji: "🦞",
},
groupChat: { mentionPatterns: ["@openclaw"] },
},
],
},
],
},
messages: {
responsePrefix: "✅",
},
});
messages: {
responsePrefix: "✅",
},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.responsePrefix).toBe("✅");
expect(cfg.agents?.list?.[0]?.groupChat?.mentionPatterns).toEqual(["@openclaw"]);
@@ -95,23 +156,37 @@ describe("config identity defaults", () => {
it("supports provider textChunkLimit config", async () => {
await withTempHome(async (home) => {
const cfg = await writeAndLoadConfig(home, {
messages: {
messagePrefix: "[openclaw]",
responsePrefix: "🦞",
},
channels: {
whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 },
telegram: { enabled: true, textChunkLimit: 3333 },
discord: {
enabled: true,
textChunkLimit: 1999,
maxLinesPerMessage: 17,
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(
{
messages: {
messagePrefix: "[openclaw]",
responsePrefix: "🦞",
},
channels: {
whatsapp: { allowFrom: ["+15555550123"], textChunkLimit: 4444 },
telegram: { enabled: true, textChunkLimit: 3333 },
discord: {
enabled: true,
textChunkLimit: 1999,
maxLinesPerMessage: 17,
},
signal: { enabled: true, textChunkLimit: 2222 },
imessage: { enabled: true, textChunkLimit: 1111 },
},
},
signal: { enabled: true, textChunkLimit: 2222 },
imessage: { enabled: true, textChunkLimit: 1111 },
},
});
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.channels?.whatsapp?.textChunkLimit).toBe(4444);
expect(cfg.channels?.telegram?.textChunkLimit).toBe(3333);
@@ -127,34 +202,48 @@ describe("config identity defaults", () => {
it("accepts blank model provider apiKey values", async () => {
await withTempHome(async (home) => {
const cfg = await writeAndLoadConfig(home, {
models: {
mode: "merge",
providers: {
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
apiKey: "",
api: "anthropic-messages",
models: [
{
id: "MiniMax-M2.1",
name: "MiniMax M2.1",
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 200000,
maxTokens: 8192,
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(
{
models: {
mode: "merge",
providers: {
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
apiKey: "",
api: "anthropic-messages",
models: [
{
id: "MiniMax-M2.1",
name: "MiniMax M2.1",
reasoning: false,
input: ["text"],
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 200000,
maxTokens: 8192,
},
],
},
],
},
},
},
},
});
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.models?.providers?.minimax?.baseUrl).toBe("https://api.minimax.io/anthropic");
});
@@ -162,43 +251,100 @@ describe("config identity defaults", () => {
it("respects empty responsePrefix to disable identity defaults", async () => {
await withTempHome(async (home) => {
const cfg = await writeAndLoadConfig(home, {
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
},
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(
{
agents: {
list: [
{
id: "main",
identity: {
name: "Samantha",
theme: "helpful sloth",
emoji: "🦥",
},
},
],
},
],
},
messages: { responsePrefix: "" },
});
messages: { responsePrefix: "" },
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.responsePrefix).toBe("");
});
});
it("does not synthesize agent list/session when absent", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(
{
messages: {},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.responsePrefix).toBeUndefined();
expect(cfg.messages?.groupChat?.mentionPatterns).toBeUndefined();
expect(cfg.agents?.list).toBeUndefined();
expect(cfg.agents?.defaults?.maxConcurrent).toBe(DEFAULT_AGENT_MAX_CONCURRENT);
expect(cfg.agents?.defaults?.subagents?.maxConcurrent).toBe(DEFAULT_SUBAGENT_MAX_CONCURRENT);
expect(cfg.session).toBeUndefined();
});
});
it("does not derive responsePrefix from identity emoji", async () => {
await withTempHome(async (home) => {
const cfg = await writeAndLoadConfig(home, {
agents: {
list: [
{
id: "main",
identity: {
name: "OpenClaw",
theme: "space lobster",
emoji: "🦞",
},
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(
{
agents: {
list: [
{
id: "main",
identity: {
name: "OpenClaw",
theme: "space lobster",
emoji: "🦞",
},
},
],
},
],
},
messages: {},
});
messages: {},
},
null,
2,
),
"utf-8",
);
vi.resetModules();
const { loadConfig } = await import("./config.js");
const cfg = loadConfig();
expect(cfg.messages?.responsePrefix).toBeUndefined();
});

View File

@@ -1,7 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { validateConfigObjectWithPlugins } from "./config.js";
import { describe, expect, it, vi } from "vitest";
import { withTempHome } from "./test-helpers.js";
async function writePluginFixture(params: {
@@ -31,15 +30,13 @@ async function writePluginFixture(params: {
}
describe("config plugin validation", () => {
const validateInHome = (home: string, raw: unknown) => {
process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw");
return validateConfigObjectWithPlugins(raw);
};
it("rejects missing plugin load paths", async () => {
await withTempHome(async (home) => {
process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw");
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const missingPath = path.join(home, "missing-plugin");
const res = validateInHome(home, {
const res = validateConfigObjectWithPlugins({
agents: { list: [{ id: "pi" }] },
plugins: { enabled: false, load: { paths: [missingPath] } },
});
@@ -56,7 +53,10 @@ describe("config plugin validation", () => {
it("rejects missing plugin ids in entries", async () => {
await withTempHome(async (home) => {
const res = validateInHome(home, {
process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw");
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const res = validateConfigObjectWithPlugins({
agents: { list: [{ id: "pi" }] },
plugins: { enabled: false, entries: { "missing-plugin": { enabled: true } } },
});
@@ -72,7 +72,10 @@ describe("config plugin validation", () => {
it("rejects missing plugin ids in allow/deny/slots", async () => {
await withTempHome(async (home) => {
const res = validateInHome(home, {
process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw");
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const res = validateConfigObjectWithPlugins({
agents: { list: [{ id: "pi" }] },
plugins: {
enabled: false,
@@ -96,6 +99,7 @@ describe("config plugin validation", () => {
it("surfaces plugin config diagnostics", async () => {
await withTempHome(async (home) => {
process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw");
const pluginDir = path.join(home, "bad-plugin");
await writePluginFixture({
dir: pluginDir,
@@ -110,7 +114,9 @@ describe("config plugin validation", () => {
},
});
const res = validateInHome(home, {
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const res = validateConfigObjectWithPlugins({
agents: { list: [{ id: "pi" }] },
plugins: {
enabled: true,
@@ -132,7 +138,10 @@ describe("config plugin validation", () => {
it("accepts known plugin ids", async () => {
await withTempHome(async (home) => {
const res = validateInHome(home, {
process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw");
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const res = validateConfigObjectWithPlugins({
agents: { list: [{ id: "pi" }] },
plugins: { enabled: false, entries: { discord: { enabled: true } } },
});
@@ -142,6 +151,7 @@ describe("config plugin validation", () => {
it("accepts plugin heartbeat targets", async () => {
await withTempHome(async (home) => {
process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw");
const pluginDir = path.join(home, "bluebubbles-plugin");
await writePluginFixture({
dir: pluginDir,
@@ -150,7 +160,9 @@ describe("config plugin validation", () => {
schema: { type: "object" },
});
const res = validateInHome(home, {
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const res = validateConfigObjectWithPlugins({
agents: { defaults: { heartbeat: { target: "bluebubbles" } }, list: [{ id: "pi" }] },
plugins: { enabled: false, load: { paths: [pluginDir] } },
});
@@ -160,7 +172,10 @@ describe("config plugin validation", () => {
it("rejects unknown heartbeat targets", async () => {
await withTempHome(async (home) => {
const res = validateInHome(home, {
process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw");
vi.resetModules();
const { validateConfigObjectWithPlugins } = await import("./config.js");
const res = validateConfigObjectWithPlugins({
agents: { defaults: { heartbeat: { target: "not-a-channel" } }, list: [{ id: "pi" }] },
});
expect(res.ok).toBe(false);

View File

@@ -322,7 +322,6 @@ export const FIELD_LABELS: Record<string, string> = {
"channels.slack.userTokenReadOnly": "Slack User Token Read Only",
"channels.slack.thread.historyScope": "Slack Thread History Scope",
"channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance",
"channels.slack.thread.initialHistoryLimit": "Slack Thread Initial History Limit",
"channels.mattermost.botToken": "Mattermost Bot Token",
"channels.mattermost.baseUrl": "Mattermost Base URL",
"channels.mattermost.chatmode": "Mattermost Chat Mode",
@@ -466,8 +465,6 @@ export const FIELD_HELP: Record<string, string> = {
'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).',
"channels.slack.thread.inheritParent":
"If true, Slack thread sessions inherit the parent channel transcript (default: false).",
"channels.slack.thread.initialHistoryLimit":
"Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable).",
"channels.mattermost.botToken":
"Bot token from Mattermost System Console -> Integrations -> Bot Accounts.",
"channels.mattermost.baseUrl":

View File

@@ -337,7 +337,6 @@ const FIELD_LABELS: Record<string, string> = {
"channels.slack.userTokenReadOnly": "Slack User Token Read Only",
"channels.slack.thread.historyScope": "Slack Thread History Scope",
"channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance",
"channels.slack.thread.initialHistoryLimit": "Slack Thread Initial History Limit",
"channels.mattermost.botToken": "Mattermost Bot Token",
"channels.mattermost.baseUrl": "Mattermost Base URL",
"channels.mattermost.chatmode": "Mattermost Chat Mode",
@@ -481,8 +480,6 @@ const FIELD_HELP: Record<string, string> = {
'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).',
"channels.slack.thread.inheritParent":
"If true, Slack thread sessions inherit the parent channel transcript (default: false).",
"channels.slack.thread.initialHistoryLimit":
"Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable).",
"channels.mattermost.botToken":
"Bot token from Mattermost System Console -> Integrations -> Bot Accounts.",
"channels.mattermost.baseUrl":

View File

@@ -55,14 +55,6 @@ describe("session path safety", () => {
resolveSessionFilePath("sess-1", { sessionFile: "../../etc/passwd" }, { sessionsDir }),
).toThrow(/within sessions directory/);
expect(() =>
resolveSessionFilePath(
"sess-1",
{ sessionFile: "subdir/../../escape.jsonl" },
{ sessionsDir },
),
).toThrow(/within sessions directory/);
expect(() =>
resolveSessionFilePath("sess-1", { sessionFile: "/etc/passwd" }, { sessionsDir }),
).toThrow(/within sessions directory/);
@@ -80,42 +72,6 @@ describe("session path safety", () => {
expect(resolved).toBe(path.resolve(sessionsDir, "subdir/threaded-session.jsonl"));
});
it("accepts absolute sessionFile paths that resolve within the sessions dir", () => {
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
const resolved = resolveSessionFilePath(
"sess-1",
{ sessionFile: "/tmp/openclaw/agents/main/sessions/abc-123.jsonl" },
{ sessionsDir },
);
expect(resolved).toBe(path.resolve(sessionsDir, "abc-123.jsonl"));
});
it("accepts absolute sessionFile with topic suffix within the sessions dir", () => {
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
const resolved = resolveSessionFilePath(
"sess-1",
{ sessionFile: "/tmp/openclaw/agents/main/sessions/abc-123-topic-42.jsonl" },
{ sessionsDir },
);
expect(resolved).toBe(path.resolve(sessionsDir, "abc-123-topic-42.jsonl"));
});
it("rejects absolute sessionFile paths outside the sessions dir", () => {
const sessionsDir = "/tmp/openclaw/agents/main/sessions";
expect(() =>
resolveSessionFilePath(
"sess-1",
{ sessionFile: "/tmp/openclaw/agents/work/sessions/abc-123.jsonl" },
{ sessionsDir },
),
).toThrow(/within sessions directory/);
});
it("uses agent sessions dir fallback for transcript path", () => {
const resolved = resolveSessionTranscriptPath("sess-1", "main");
expect(resolved.endsWith(path.join("agents", "main", "sessions", "sess-1.jsonl"))).toBe(true);

View File

@@ -77,12 +77,9 @@ function resolvePathWithinSessionsDir(sessionsDir: string, candidate: string): s
throw new Error("Session file path must not be empty");
}
const resolvedBase = path.resolve(sessionsDir);
// Older versions stored absolute sessionFile paths in sessions.json.
// Preserve compatibility, but validate containment against the resolved path.
const normalized = path.isAbsolute(trimmed) ? path.relative(resolvedBase, trimmed) : trimmed;
const resolvedCandidate = path.resolve(resolvedBase, normalized);
const resolvedCandidate = path.resolve(resolvedBase, trimmed);
const relative = path.relative(resolvedBase, resolvedCandidate);
if (!normalized || relative.startsWith("..") || path.isAbsolute(relative)) {
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error("Session file path must be within sessions directory");
}
return resolvedCandidate;

Some files were not shown because too many files have changed in this diff Show More