mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-21 06:22:28 +08:00
Compare commits
2 Commits
fix/sessio
...
codex/aa-0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be889937bc | ||
|
|
d00d6876f5 |
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 don’t 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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bluebubbles",
|
||||
"version": "2026.2.13",
|
||||
"version": "2026.2.12",
|
||||
"description": "OpenClaw BlueBubbles channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-otel",
|
||||
"version": "2026.2.13",
|
||||
"version": "2026.2.12",
|
||||
"description": "OpenClaw diagnostics OpenTelemetry exporter",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/discord",
|
||||
"version": "2026.2.13",
|
||||
"version": "2026.2.12",
|
||||
"description": "OpenClaw Discord channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/imessage",
|
||||
"version": "2026.2.13",
|
||||
"version": "2026.2.12",
|
||||
"private": true,
|
||||
"description": "OpenClaw iMessage channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/irc",
|
||||
"version": "2026.2.13",
|
||||
"version": "2026.2.12",
|
||||
"description": "OpenClaw IRC channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/line",
|
||||
"version": "2026.2.13",
|
||||
"version": "2026.2.12",
|
||||
"private": true,
|
||||
"description": "OpenClaw LINE channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.13
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6-3
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/matrix",
|
||||
"version": "2026.2.13",
|
||||
"version": "2026.2.12",
|
||||
"description": "OpenClaw Matrix channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/mattermost",
|
||||
"version": "2026.2.13",
|
||||
"version": "2026.2.12",
|
||||
"private": true,
|
||||
"description": "OpenClaw Mattermost channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.13
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6-3
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/msteams",
|
||||
"version": "2026.2.13",
|
||||
"version": "2026.2.12",
|
||||
"description": "OpenClaw Microsoft Teams channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.13
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6-3
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/signal",
|
||||
"version": "2026.2.13",
|
||||
"version": "2026.2.12",
|
||||
"private": true,
|
||||
"description": "OpenClaw Signal channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/slack",
|
||||
"version": "2026.2.13",
|
||||
"version": "2026.2.12",
|
||||
"private": true,
|
||||
"description": "OpenClaw Slack channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/telegram",
|
||||
"version": "2026.2.13",
|
||||
"version": "2026.2.12",
|
||||
"private": true,
|
||||
"description": "OpenClaw Telegram channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.13
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6-3
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/twitch",
|
||||
"version": "2026.2.13",
|
||||
"version": "2026.2.12",
|
||||
"private": true,
|
||||
"description": "OpenClaw Twitch channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.13
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6-3
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/voice-call",
|
||||
"version": "2026.2.13",
|
||||
"version": "2026.2.12",
|
||||
"description": "OpenClaw voice-call plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/whatsapp",
|
||||
"version": "2026.2.13",
|
||||
"version": "2026.2.12",
|
||||
"private": true,
|
||||
"description": "OpenClaw WhatsApp channel plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.13
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6-3
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/zalo",
|
||||
"version": "2026.2.13",
|
||||
"version": "2026.2.12",
|
||||
"description": "OpenClaw Zalo channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.13
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6-3
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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" } });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -436,7 +436,6 @@ export function createSessionStatusTool(opts?: {
|
||||
...agentDefaults,
|
||||
model: agentModel,
|
||||
},
|
||||
agentId,
|
||||
sessionEntry: resolved.entry,
|
||||
sessionKey: resolved.key,
|
||||
sessionStorePath: storePath,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -224,7 +224,6 @@ export async function buildStatusReply(params: {
|
||||
verboseDefault: agentDefaults.verboseDefault,
|
||||
elevatedDefault: agentDefaults.elevatedDefault,
|
||||
},
|
||||
agentId: statusAgentId,
|
||||
sessionEntry,
|
||||
sessionKey,
|
||||
sessionScope,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user