Compare commits

..

6 Commits

Author SHA1 Message Date
Peter Steinberger
ac505335e4 feat: add agent-scoped exec environments 2026-06-24 07:34:51 -07:00
狼哥
374076b5a8 fix(plugins): retain plugin tool registry after replacement (#82562)
Merged via squash.

Prepared head SHA: 1bcbbbfbc1
Co-authored-by: luoyanglang <238804951+luoyanglang@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 22:22:29 +08:00
杨浩宇0668001029
242fbf1a67 test(telegram): pass outbound sanitizer payload 2026-06-24 07:13:32 -07:00
杨浩宇0668001029
434d752dd6 fix(telegram): sanitize outbound tool traces 2026-06-24 07:13:32 -07:00
Ayaan Zaidi
3179692f0e fix(messages): apply response usage to followups 2026-06-24 07:12:33 -07:00
Peter Lindsey
6add1cc969 feat(messages): config-level default for the persistent /usage footer
Adds `messages.responseUsage` (precedence session -> channel -> config default
-> off) so the persistent /usage footer can default-on, with three distinct
states: explicit on (tokens/full), explicit off (persisted), and unset (inherit
the configured default).

Unifies effective-value resolution behind a single channel-aware resolver
`resolveEffectiveResponseUsage` used by reply rendering, the no-arg /usage
toggle, the ACP control, and the gateway session-row builder; the row builder's
`effectiveResponseUsage` is carried through sessions.changed events, chat
snapshots, and the UI row so live consumers never go stale. `/usage reset`
(aliases inherit/clear/default) clears the override to inherit; only explicit
off persists; a full session reset preserves the preference. ACP "Usage detail"
gains an "inherit" option for unset sessions. Docs/help/completions updated; "on"
documented as a legacy alias; config-doc baseline regenerated.
2026-06-24 07:12:33 -07:00
87 changed files with 2335 additions and 1238 deletions

View File

@@ -1,4 +1,4 @@
9246475f5771612a5fd12de38b153783c4a4cbb8b2682a5c40115916661c90f2 config-baseline.json
6349131baaa1828f2a071f42e4d7b17c8966c59b6588c8a4c1a32ea5ea4dcd5e config-baseline.core.json
1b953a19c347a27a0f9e856f23769b0c48d051354be4c88778c215231817fe8a config-baseline.json
f3fcfb358d8b8a1f0fa8676090339ff8df1b28ef6c7e80705a979a5c70e2a323 config-baseline.core.json
671979e86e4c4f59415d0a20879e838f9bbd883b3d29eeb02cb5131db8d187fe config-baseline.channel.json
94529978588d6e3776a86780b22cf9ff46a6f9957f2f178d3829403fad451ca7 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
ebb0ae07e4d6f6ea1faccba7604c9da71a5401b3aa2bc3618963e1e44a8dbcce plugin-sdk-api-baseline.json
9b7aee16d91c6a1b042a7d7e6f92a77b3e234337cc5fcf5a797de05fa9e9a02e plugin-sdk-api-baseline.jsonl
0418a175983d6e17f535ebb49d07371ceed57c7002f8991113d548f02b1d17d1 plugin-sdk-api-baseline.json
319e947cff12d9c2c5781b6f97f9b6b1c4f8a251dc1e87703c534a37614325cf plugin-sdk-api-baseline.jsonl

View File

@@ -30,6 +30,68 @@ title: "Usage tracking"
- CLI: `openclaw channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
- macOS menu bar: "Usage" section under Context (only if available).
## Default usage footer mode
`/usage off|tokens|full` sets the footer for a session and is remembered for that
session. `messages.responseUsage` seeds that mode for sessions that have not
chosen one, so the footer can be on by default without typing `/usage` each time.
Set one mode for every channel, or a per-channel map with a `default` fallback:
```jsonc
{
"messages": {
"responseUsage": "tokens",
// or: { "default": "off", "discord": "full" }
},
}
```
### Three distinct session states
A session's `responseUsage` field has three representable states, each with
different semantics:
| State | Stored value | Effective mode |
| ------------------- | ------------------------------- | --------------------------------------------------------------------- |
| **Unset / inherit** | `undefined` (absent) | Falls through to `messages.responseUsage` config default, then `off`. |
| **Explicit off** | `"off"` (stored) | Always off — a non-off config default cannot re-enable the footer. |
| **Explicit on** | `"tokens"` or `"full"` (stored) | That mode, regardless of config default. |
### Precedence
Effective mode = session override → channel config entry → `default``off`.
An explicit `/usage off` is **persisted** as the literal value `"off"` in the
session, not the same as "unset." This means a non-off `messages.responseUsage`
default cannot turn the footer back on once the user has explicitly disabled it.
### Resetting vs. turning off
- `/usage off` — forces the footer off and persists that choice. A configured
non-off default cannot override this.
- `/usage reset` (aliases: `inherit`, `clear`, `default`) — clears the session
override. The session then **inherits** the effective config default
(`messages.responseUsage`). If no default is configured, the footer is off
(unchanged from before). Use this to "go back to default" without explicitly
turning the footer on.
- A full session reset (`/reset` or `/new`) or a session rollover **preserves**
the explicit usage-mode preference so the user's display choice survives
session rollovers. Only `/usage reset` (and its aliases) actually clears the
override.
### Toggle behavior
`/usage` with no arguments cycles: off → tokens → full → off. The starting point
for the cycle is the **effective** current mode (session override falling through
to the config default when unset), so the cycle is always consistent with what
the user sees in the footer.
### Config
With no config the prior behavior holds (footer off until `/usage`). Use
`/usage reset` to clear a session override and re-inherit the configured default.
## Custom `/usage full` footer
`/usage full` shows a built-in compact footer with model, reasoning, fast/slow,

View File

@@ -204,6 +204,55 @@ Controls elevated exec access outside the sandbox:
}
```
Agent entries can inject an environment only into their own `exec` child
processes. Use a SecretRef for credentials and set `inheritHostEnv: false` when the
Gateway process environment must not be inherited:
```json5
{
agents: {
list: [
{
id: "referrals",
tools: {
exec: {
inheritHostEnv: false,
env: {
GREENHOUSE_TOKEN: {
source: "env",
provider: "default",
id: "REFERRALS_GREENHOUSE_TOKEN",
},
},
},
},
},
],
},
}
```
`agents.list[].tools.exec.env` applies to `exec` only; it does not mutate
`process.env` or automatically inject credentials into model-provider or plugin
APIs. Trusted in-process plugin code can still inspect the materialized runtime
config, so this is not a plugin isolation boundary.
Configured values override same-named per-call values from the model. Trusted
`resolve_exec_env` hook output and channel context are applied afterward. Host
exec still rejects `PATH` and dangerous runtime/startup keys. Sandbox exec
already starts from a minimal environment. With `inheritHostEnv: false`,
Gateway exec also skips login-shell PATH discovery and cached shell-startup
state; configure `pathPrepend` or absolute commands when needed. For
`host: "node"`, configure scoped environment and inheritance isolation on the
node host. Both this map and `inheritHostEnv: false` are rejected because the
Gateway cannot clear the remote service environment or safely hold a scoped
credential back during remote approval preparation.
Treat this map as credential-bearing configuration: every command the agent can
run can read and exfiltrate these values, and command output can reveal them.
Plaintext values are reported by `openclaw secrets audit`; prefer SecretRefs.
Already-running background commands retain the environment captured when they
started after a config or secret reload.
### `tools.loopDetection`
Tool-loop safety checks are **disabled by default**. Set `enabled: true` to activate detection. Settings can be defined globally in `tools.loopDetection` and overridden per-agent at `agents.list[].tools.loopDetection`.

View File

@@ -528,25 +528,13 @@ candidate contains redacted secret placeholders such as `***`.
and re-checked, so a path that lexically lives in a config dir but whose
real target escapes every allowed root is still rejected.
- **Error handling**: clear errors for missing files, parse errors, circular includes, invalid path format, and excessive length
- **Hot reload**: edits to regular include files successfully resolved by the
last valid config are watched, including nested includes. Changing an
authored `$include` target inside a watched file re-resolves the graph.
Paths that were missing or invalid during the last successful resolution,
and filesystem or symlink retargets that do not modify a watched file, are
not discovered automatically; edit `openclaw.json` or restart the Gateway
to resolve the graph again.
</Accordion>
</AccordionGroup>
## Config hot reload
The Gateway watches `~/.openclaw/openclaw.json` plus the canonical include files
successfully resolved by the last valid config, and applies changes
automatically - no manual restart needed for most settings. Invalid candidates
keep the last valid watch set. Missing or invalid paths outside that set, plus
filesystem or symlink retargets that do not modify a watched file, require an
`openclaw.json` edit or a Gateway restart before they can be discovered.
The Gateway watches `~/.openclaw/openclaw.json` and applies changes automatically - no manual restart needed for most settings.
Direct file edits are treated as untrusted until they validate. The watcher waits
for editor temp-write/rename churn to settle, reads the final file, and rejects

View File

@@ -525,6 +525,47 @@ the config fields that accept SecretRefs.
</Accordion>
</AccordionGroup>
## Per-agent exec environment variables
`agents.list[].tools.exec.env` supports SecretInput values, so a credential can
be resolved during Gateway activation and injected only into that agent's
`exec` child processes:
```json5
{
agents: {
list: [
{
id: "referrals",
tools: {
exec: {
inheritHostEnv: false,
env: {
GREENHOUSE_TOKEN: {
source: "env",
provider: "default",
id: "REFERRALS_GREENHOUSE_TOKEN",
},
},
},
},
},
],
},
}
```
This surface is exec-specific. It does not mutate the Gateway process
environment or automatically inject credentials into model-provider or plugin
APIs. Trusted in-process plugin code can inspect the materialized runtime
config. An unresolved active ref fails Gateway activation. SecretRefs are
materialized in the Gateway's protected in-memory config snapshot, so this
scopes subprocess injection rather than creating a same-process or same-OS-user
security boundary. Every command available to the agent can read these values,
command output can reveal them, and plaintext entries are reported by
`openclaw secrets audit`. Configure scoped environment on a node host itself;
agent exec env is rejected for `host: "node"`.
## MCP server environment variables
MCP server env vars configured via `plugins.entries.acpx.config.mcpServers` support SecretInput. This keeps API keys and tokens out of plaintext config:

View File

@@ -37,6 +37,7 @@ Scope intent:
- `agents.defaults.memorySearch.remote.apiKey`
- `agents.list[].tts.providers.*.apiKey`
- `agents.list[].memorySearch.remote.apiKey`
- `agents.list[].tools.exec.env.*`
- `talk.providers.*.apiKey`
- `talk.realtime.providers.*.apiKey`
- `messages.tts.providers.*.apiKey`

View File

@@ -29,6 +29,13 @@
"secretShape": "secret_input",
"optIn": true
},
{
"id": "agents.list[].tools.exec.env.*",
"configFile": "openclaw.json",
"path": "agents.list[].tools.exec.env.*",
"secretShape": "secret_input",
"optIn": true
},
{
"id": "agents.list[].tts.providers.*.apiKey",
"configFile": "openclaw.json",

View File

@@ -76,6 +76,8 @@ Use these in chat:
configured for the active model.
- `/usage off|tokens|full` → appends a **per-response usage footer** to every reply.
- Persists per session (stored as `responseUsage`).
- `/usage reset` (aliases: `inherit`, `clear`, `default`) — clears the session
override so the session re-inherits the configured default.
- `/usage full` shows estimated cost only when OpenClaw has usage metadata and
local pricing for the active model. Otherwise it shows tokens only.
- `/usage cost` → shows a local cost summary from OpenClaw session logs.

View File

@@ -22,7 +22,8 @@ Working directory for the command.
</ParamField>
<ParamField path="env" type="object">
Key/value environment overrides merged on top of the inherited environment.
Key/value environment overrides. Per-agent configured values are applied after
these model-supplied values.
</ParamField>
<ParamField path="yieldMs" type="number" default="10000">
@@ -89,6 +90,7 @@ Notes:
`$OPENCLAW_STATE_DIR/cache/shell-snapshots/`, then sources that snapshot before each exec command.
Secret-looking variables are excluded; sandbox and node exec do not use this snapshot. Set
`OPENCLAW_EXEC_SHELL_SNAPSHOT=0` in the Gateway process environment to disable this snapshot path.
Per-agent `tools.exec.inheritHostEnv: false` also disables it.
- Host execution (`gateway`/`node`) rejects `env.PATH` and loader overrides (`LD_*`/`DYLD_*`) to
prevent binary hijacking or injected code.
- OpenClaw sets `OPENCLAW_SHELL=exec` in the spawned command environment (including PTY and sandbox execution) so shell/profile rules can detect exec-tool context.
@@ -113,6 +115,8 @@ Notes:
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
- `tools.exec.approvalRunningNoticeMs` (default: 10000): emit a single "running" notice when an approval-gated exec runs longer than this (0 disables).
- `tools.exec.timeoutSec` (default: 1800): default per-command exec timeout in seconds. Per-call `timeout` overrides it; per-call `timeout: 0` disables the exec process timeout.
- `agents.list[].tools.exec.env`: credential-oriented environment values injected only into that agent's gateway/sandbox exec children. Values support SecretRefs; node-host exec rejects this map.
- `agents.list[].tools.exec.inheritHostEnv` (default: true): set false to omit the Gateway process environment and shell-startup snapshot from Gateway-hosted exec. This is rejected for `host=node`; sandbox exec is already minimal.
- `tools.exec.host` (default: `auto`; resolves to `sandbox` when sandbox runtime is active, `gateway` otherwise)
- `tools.exec.security` (default: `deny` for sandbox, `full` for gateway + node when unset)
- `tools.exec.ask` (default: `off`)
@@ -141,7 +145,9 @@ Example:
### PATH handling
- `host=gateway`: merges your login-shell `PATH` into the exec environment. `env.PATH` overrides are
- `host=gateway`: normally merges your login-shell `PATH` into the exec environment. With
`agents.list[].tools.exec.inheritHostEnv: false`, this merge is skipped; use an absolute command or
`tools.exec.pathPrepend`. `env.PATH` overrides are
rejected for host execution. The daemon itself still runs with a minimal `PATH`:
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`

View File

@@ -240,7 +240,7 @@ plugins.
| `/tasks` | List active/recent background tasks for the current session |
| `/context [list\|detail\|map\|json]` | Explain how context is assembled |
| `/whoami` | Show your sender id. Alias: `/id` |
| `/usage off\|tokens\|full\|cost` | Control the per-response usage footer or print a local cost summary |
| `/usage off\|tokens\|full\|reset\|cost` | Control the per-response usage footer (`reset`/`inherit`/`clear`/`default` clears the session override to re-inherit the configured default) or print a local cost summary |
</Accordion>
<Accordion title="Skills, allowlists, approvals">

View File

@@ -126,7 +126,7 @@ Session controls:
- `/verbose <on|full|off>`
- `/trace <on|off>`
- `/reasoning <on|off|stream>`
- `/usage <off|tokens|full>`
- `/usage <off|tokens|full|reset>` (`reset`/`inherit`/`clear`/`default` clears the session override)
- `/goal [status] | /goal start <objective> | /goal pause|resume|complete|block|clear`
- `/elevated <on|off|ask|full>` (alias: `/elev`)
- `/activation <mention|always>`

View File

@@ -2,6 +2,7 @@
import type { OutboundDeliveryFormattingOptions } from "openclaw/plugin-sdk/channel-outbound";
import {
resolveOutboundSendDep,
sanitizeForPlainText,
type OutboundSendDeps,
} from "openclaw/plugin-sdk/channel-outbound";
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result";
@@ -19,6 +20,7 @@ import {
sendPayloadMediaSequenceOrFallback,
} from "openclaw/plugin-sdk/reply-payload";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
import type { TelegramInlineButtons } from "./button-types.js";
import { resolveTelegramInlineButtons } from "./button-types.js";
import { splitTelegramHtmlChunks } from "./format.js";
@@ -198,6 +200,7 @@ export function createTelegramOutboundAdapter(
chunkerMode: "markdown",
extractMarkdownImages: true,
textChunkLimit: TELEGRAM_TEXT_CHUNK_LIMIT,
sanitizeText: ({ text }) => sanitizeForPlainText(sanitizeAssistantVisibleText(text)),
shouldSuppressLocalPayloadPrompt: options.shouldSuppressLocalPayloadPrompt,
beforeDeliverPayload: options.beforeDeliverPayload,
shouldTreatDeliveredTextAsVisible: options.shouldTreatDeliveredTextAsVisible,

View File

@@ -29,10 +29,23 @@ describe("telegramPlugin outbound", () => {
expect(telegramOutbound.presentationCapabilities?.limits?.text?.markdownDialect).toBe(
"markdown",
);
expect(telegramOutbound.sanitizeText).toBeUndefined();
expect(telegramOutbound.pollMaxOptions).toBe(10);
});
it("strips assistant-visible tool traces before outbound delivery", () => {
clearTelegramRuntime();
const text = 'Done.\n⚠ 🛠️ `search "Pipeline" in ~/.openclaw/workspace-* (agent)` failed';
expect(telegramOutbound.sanitizeText?.({ text, payload: { text } })).toBe("Done.");
});
it("preserves ordinary outbound text while sanitizing", () => {
clearTelegramRuntime();
const text = "The pipeline has 3 deals.";
expect(telegramOutbound.sanitizeText?.({ text, payload: { text } })).toBe(text);
});
it("preserves explicit HTML parse mode before chunking", () => {
clearTelegramRuntime();
const text = "<b>hi</b>";

View File

@@ -16,7 +16,7 @@ const BASE_AVAILABLE_COMMANDS: AvailableCommand[] = [
{ name: "subagents", description: "List or manage sub-agents." },
{ name: "config", description: "Read or write config (owner-only)." },
{ name: "debug", description: "Set runtime-only overrides (owner-only)." },
{ name: "usage", description: "Toggle usage footer (off|tokens|full)." },
{ name: "usage", description: "Toggle usage footer (off|tokens|full|reset). 'reset'/'inherit'/'clear'/'default' clears the session override to re-inherit the configured default." },
{ name: "stop", description: "Stop the current run." },
{ name: "restart", description: "Restart the gateway (if enabled)." },
{ name: "activation", description: "Set group activation (mention|always)." },

View File

@@ -221,9 +221,9 @@ export function buildSessionPresentation(params: {
id: ACP_RESPONSE_USAGE_CONFIG_ID,
name: "Usage detail",
description:
"Controls how much usage information OpenClaw attaches to responses for the session.",
currentValue: normalizeOptionalString(row.responseUsage) || "off",
values: ["off", "tokens", "full"],
"Controls how much usage information OpenClaw attaches to responses for the session. 'inherit' follows the configured default; 'off' explicitly disables it for this session.",
currentValue: normalizeOptionalString(row.responseUsage) || "inherit",
values: ["inherit", "off", "tokens", "full"],
}),
buildSelectConfigOption({
id: ACP_ELEVATED_LEVEL_CONFIG_ID,

View File

@@ -358,4 +358,106 @@ describe("acp setSessionConfigOption bridge behavior", () => {
sessionStore.clearAllSessionsForTest();
});
it('maps response_usage "inherit" selection to sessions.patch with responseUsage: null', async () => {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();
const request = vi.fn(async (method: string, _params?: unknown) => {
if (method === "sessions.list") {
return {
ts: Date.now(),
path: "/tmp/sessions.json",
count: 1,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: [
{
key: "usage-inherit-session",
kind: "direct",
updatedAt: Date.now(),
thinkingLevel: "minimal",
modelProvider: "openai",
model: "gpt-5.4",
responseUsage: "tokens",
},
],
};
}
if (method === "sessions.patch") {
expect(requireRecord(_params, "sessions.patch params")).toMatchObject({
key: "usage-inherit-session",
responseUsage: null,
});
}
return { ok: true };
}) as GatewayClient["request"];
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
sessionStore,
});
await agent.loadSession(createLoadSessionRequest("usage-inherit-session"));
const result = await agent.setSessionConfigOption(
createSetSessionConfigOptionRequest("usage-inherit-session", "response_usage", "inherit"),
);
// After selecting "inherit", the ACP config option should report "inherit" (unset).
expectConfigOption(result.configOptions, "response_usage", { currentValue: "inherit" });
expect(
(request as unknown as MockCallSource).mock.calls.some(
([method]) => method === "sessions.patch",
),
).toBe(true);
sessionStore.clearAllSessionsForTest();
});
it('maps response_usage "off" selection to sessions.patch with responseUsage: "off"', async () => {
const sessionStore = createInMemorySessionStore();
const connection = createAcpConnection();
const request = vi.fn(async (method: string, _params?: unknown) => {
if (method === "sessions.list") {
return {
ts: Date.now(),
path: "/tmp/sessions.json",
count: 1,
defaults: { modelProvider: null, model: null, contextTokens: null },
sessions: [
{
key: "usage-off-session",
kind: "direct",
updatedAt: Date.now(),
thinkingLevel: "minimal",
modelProvider: "openai",
model: "gpt-5.4",
},
],
};
}
if (method === "sessions.patch") {
expect(requireRecord(_params, "sessions.patch params")).toMatchObject({
key: "usage-off-session",
responseUsage: "off",
});
}
return { ok: true };
}) as GatewayClient["request"];
const agent = new AcpGatewayAgent(connection, createAcpGateway(request), {
sessionStore,
});
await agent.loadSession(createLoadSessionRequest("usage-off-session"));
const result = await agent.setSessionConfigOption(
createSetSessionConfigOptionRequest("usage-off-session", "response_usage", "off"),
);
expectConfigOption(result.configOptions, "response_usage", { currentValue: "off" });
expect(
(request as unknown as MockCallSource).mock.calls.some(
([method]) => method === "sessions.patch",
),
).toBe(true);
sessionStore.clearAllSessionsForTest();
});
});

View File

@@ -98,7 +98,8 @@ describe("acp session UX bridge behavior", () => {
});
expectConfigOption(result.configOptions, "verbose_level", { currentValue: "off" });
expectConfigOption(result.configOptions, "reasoning_level", { currentValue: "off" });
expectConfigOption(result.configOptions, "response_usage", { currentValue: "off" });
// Unset session inherits the configured default → control reads "inherit", not "off".
expectConfigOption(result.configOptions, "response_usage", { currentValue: "inherit" });
expectConfigOption(result.configOptions, "elevated_level", { currentValue: "off" });
sessionStore.clearAllSessionsForTest();

View File

@@ -801,8 +801,7 @@ export class AcpGatewayAgent implements Agent {
const promptKey = this.pendingPromptKey(params.sessionId, runId);
if (
isGatewayCloseError(err) &&
(this.getPendingPrompt(params.sessionId, runId) ||
this.settlingPromptKeys.has(promptKey))
(this.getPendingPrompt(params.sessionId, runId) || this.settlingPromptKeys.has(promptKey))
) {
return;
}
@@ -1592,7 +1591,7 @@ export class AcpGatewayAgent implements Agent {
value: string | boolean,
): {
overrides: Partial<GatewaySessionPresentationRow>;
patch?: Record<string, string | boolean>;
patch?: Record<string, string | boolean | null>;
} {
if (typeof value !== "string") {
throw new Error(
@@ -1630,11 +1629,13 @@ export class AcpGatewayAgent implements Agent {
patch: { reasoningLevel: value },
overrides: { reasoningLevel: value },
};
case ACP_RESPONSE_USAGE_CONFIG_ID:
case ACP_RESPONSE_USAGE_CONFIG_ID: {
const next = value === "inherit" ? null : value;
return {
patch: { responseUsage: value },
overrides: { responseUsage: value as GatewaySessionPresentationRow["responseUsage"] },
patch: { responseUsage: next },
overrides: { responseUsage: next as GatewaySessionPresentationRow["responseUsage"] },
};
}
case ACP_ELEVATED_LEVEL_CONFIG_ID:
return {
patch: { elevatedLevel: value },

View File

@@ -46,6 +46,11 @@ function requireExecTool(tools: ReturnType<typeof createOpenClawCodingTools>) {
return execTool;
}
function printEnvCommand(key: string): string {
const script = `process.stdout.write(process.env[${JSON.stringify(key)}] ?? "missing")`;
return `${JSON.stringify(process.execPath)} -e ${JSON.stringify(script)}`;
}
describe("Agent-specific exec tool defaults", () => {
beforeEach(() => {
setActivePluginRegistry(createSessionConversationTestRegistry());
@@ -291,4 +296,191 @@ describe("Agent-specific exec tool defaults", () => {
const details = result?.details as { status?: string } | undefined;
expect(details?.status).toBe("completed");
});
it("injects configured env only into the selected agent and can drop inherited env", async () => {
if (process.platform === "win32") {
return;
}
const key = "OPENCLAW_TEST_AGENT_SCOPED_EXEC_ENV";
const previous = process.env[key];
process.env[key] = "gateway-value";
try {
const cfg: OpenClawConfig = {
tools: { exec: { host: "gateway", security: "full", ask: "off" } },
agents: {
list: [
{
id: "referrals",
tools: {
exec: {
inheritHostEnv: false,
env: { [key]: "agent-value" },
},
},
},
{
id: "helper",
tools: { exec: { inheritHostEnv: false } },
},
],
},
};
const referralsExec = requireExecTool(
createOpenClawCodingTools({
config: cfg,
agentId: "referrals",
workspaceDir: "/tmp/test-referrals-env",
agentDir: "/tmp/agent-referrals-env",
}),
);
const referralsResult = await referralsExec.execute("call-referrals-env", {
command: printEnvCommand(key),
env: { [key]: "model-value" },
});
expect((referralsResult.content[0] as { text?: string }).text).toContain("agent-value");
const helperExec = requireExecTool(
createOpenClawCodingTools({
config: cfg,
agentId: "helper",
workspaceDir: "/tmp/test-helper-env",
agentDir: "/tmp/agent-helper-env",
}),
);
const helperResult = await helperExec.execute("call-helper-env", {
command: printEnvCommand(key),
});
expect((helperResult.content[0] as { text?: string }).text).toContain("missing");
} finally {
if (previous === undefined) {
delete process.env[key];
} else {
process.env[key] = previous;
}
}
});
it("keeps dangerous configured host env keys behind the existing security filter", async () => {
const execTool = requireExecTool(
createOpenClawCodingTools({
config: {
tools: { exec: { host: "gateway", security: "full", ask: "off" } },
agents: {
list: [
{
id: "ops",
tools: { exec: { env: { PATH: "/tmp/untrusted" } } },
},
],
},
},
agentId: "ops",
workspaceDir: "/tmp/test-ops-env-filter",
agentDir: "/tmp/agent-ops-env-filter",
}),
);
await expect(
execTool.execute("call-ops-env-filter", { command: "echo blocked" }),
).rejects.toThrow("PATH is controlled by tools.exec.pathPrepend");
});
it("allows source-config tool inspection but rejects unresolved SecretRefs on execution", async () => {
const execTool = requireExecTool(
createOpenClawCodingTools({
config: {
tools: { exec: { host: "gateway", security: "full", ask: "off" } },
agents: {
list: [
{
id: "ops",
tools: {
exec: {
env: {
SCOPED_CREDENTIAL: {
source: "env",
provider: "default",
id: "OPS_SCOPED_CREDENTIAL",
},
},
},
},
},
],
},
},
agentId: "ops",
workspaceDir: "/tmp/test-ops-unresolved-env",
agentDir: "/tmp/agent-ops-unresolved-env",
}),
);
await expect(
execTool.execute("call-ops-unresolved-env", { command: "echo blocked" }),
).rejects.toThrow("contains an unresolved SecretRef");
});
it("rejects attempts to spoof trusted channel context through per-call env", async () => {
const execTool = requireExecTool(
createOpenClawCodingTools({
config: { tools: { exec: { host: "gateway", security: "full", ask: "off" } } },
agentId: "ops",
workspaceDir: "/tmp/test-ops-channel-context-env",
agentDir: "/tmp/agent-ops-channel-context-env",
}),
);
await expect(
execTool.execute("call-ops-channel-context-env", {
command: "echo blocked",
env: { OPENCLAW_CHANNEL_CONTEXT: "spoofed" },
}),
).rejects.toThrow("reserved for trusted channel context");
});
it("rejects host-env minimization when effective exec host is a remote node", async () => {
const execTool = requireExecTool(
createOpenClawCodingTools({
config: {
tools: { exec: { host: "node", security: "full", ask: "off" } },
agents: {
list: [{ id: "ops", tools: { exec: { inheritHostEnv: false } } }],
},
},
agentId: "ops",
workspaceDir: "/tmp/test-ops-node-env",
agentDir: "/tmp/agent-ops-node-env",
}),
);
await expect(
execTool.execute("call-ops-node-env", { command: "echo blocked" }),
).rejects.toThrow("configure environment isolation on the node host");
});
it("rejects agent-scoped env before remote-node preparation", async () => {
const execTool = requireExecTool(
createOpenClawCodingTools({
config: {
tools: { exec: { host: "node", security: "full", ask: "always" } },
agents: {
list: [
{
id: "ops",
tools: { exec: { env: { SCOPED_TOKEN: "must-stay-on-gateway" } } },
},
],
},
},
agentId: "ops",
workspaceDir: "/tmp/test-ops-node-scoped-env",
agentDir: "/tmp/agent-ops-node-scoped-env",
}),
);
await expect(
execTool.execute("call-ops-node-scoped-env", { command: "echo blocked" }),
).rejects.toThrow("configure scoped environment on the node host");
});
});

View File

@@ -347,6 +347,8 @@ function resolveExecConfig(params: { cfg?: OpenClawConfig; agentId?: string }) {
security: layeredPolicy.security,
ask: layeredPolicy.ask,
node: agentExec?.node ?? globalExec?.node,
env: agentExec?.env,
inheritHostEnv: agentExec?.inheritHostEnv,
pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend,
safeBins: agentExec?.safeBins ?? globalExec?.safeBins,
strictInlineEval: agentExec?.strictInlineEval ?? globalExec?.strictInlineEval,
@@ -815,6 +817,8 @@ export function createOpenClawCodingTools(options?: {
reviewer: options?.exec?.reviewer ?? execConfig.reviewer,
trigger: options?.trigger,
node: options?.exec?.node ?? execConfig.node,
env: options?.exec?.env ?? execConfig.env,
inheritHostEnv: options?.exec?.inheritHostEnv ?? execConfig.inheritHostEnv,
pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend,
safeBins: options?.exec?.safeBins ?? execConfig.safeBins,
strictInlineEval: options?.exec?.strictInlineEval ?? execConfig.strictInlineEval,

View File

@@ -4,9 +4,26 @@
* by sandboxed exec calls.
*/
import { describe, expect, it } from "vitest";
import { buildDockerExecArgs } from "./bash-tools.shared.js";
import { buildDockerExecArgs, buildSandboxEnv } from "./bash-tools.shared.js";
describe("buildDockerExecArgs", () => {
it("keeps case-distinct sandbox variables separate from PATH and HOME", () => {
const env = buildSandboxEnv({
defaultPath: "/usr/bin:/bin",
containerWorkdir: "/workspace",
sandboxEnv: { path: "lower-path", home: "lower-home" },
paramsEnv: { Path: "mixed-path" },
});
expect(env).toMatchObject({
PATH: "/usr/bin:/bin",
HOME: "/workspace",
path: "lower-path",
home: "lower-home",
Path: "mixed-path",
});
});
it("prepends custom PATH after login shell sourcing to preserve both custom and system tools", () => {
const args = buildDockerExecArgs({
containerName: "test-container",

View File

@@ -60,6 +60,7 @@ function restoreProcessPlatformForTest(): void {
type ApprovalRequestPayload = {
approvalReviewerDeviceIds?: string[];
commandSpans?: Array<{ startIndex: number; endIndex: number }>;
env?: Record<string, string>;
};
function requireApprovalRequestPayload(callIndex: number): ApprovalRequestPayload {
@@ -177,6 +178,24 @@ describe("exec approval requests", () => {
expect(payload?.approvalReviewerDeviceIds).toEqual(["device-ios-reviewer"]);
});
it("sends only value-free env metadata for gateway approval registration", async () => {
vi.mocked(callGatewayTool).mockResolvedValue({ id: "approval-id", expiresAtMs: 1234 });
await registerExecApprovalRequestForHost({
approvalId: "approval-id",
command: "echo hi",
env: { SCOPED_TOKEN: "do-not-serialize", REGION: "us-east-1" },
workdir: "/tmp/project",
host: "gateway",
security: "allowlist",
ask: "always",
});
const payload = requireApprovalRequestPayload(0);
expect(payload.env).toEqual({ SCOPED_TOKEN: "", REGION: "" });
expect(JSON.stringify(payload)).not.toContain("do-not-serialize");
});
it("does not generate command spans by default", async () => {
vi.mocked(callGatewayTool).mockResolvedValue({ id: "approval-id", expiresAtMs: 1234 });

View File

@@ -300,7 +300,10 @@ async function buildHostApprovalDecisionParams(
command: params.command,
commandArgv: params.commandArgv,
systemRunPlan: params.systemRunPlan,
env: params.env,
env:
params.host === "node" || params.env === undefined
? params.env
: Object.fromEntries(Object.keys(params.env).map((key) => [key, ""])),
cwd: params.workdir,
nodeId: params.nodeId,
host: params.host,

View File

@@ -75,6 +75,7 @@ type ProcessGatewayAllowlistParams = {
workdir: string;
env: Record<string, string>;
pathPrepend?: string[];
useShellSnapshot?: boolean;
requestedEnv?: Record<string, string>;
pty: boolean;
timeoutSec?: number;
@@ -958,6 +959,7 @@ export async function processGatewayAllowlist(
workdir: params.workdir,
env: params.env,
pathPrepend: params.pathPrepend,
useShellSnapshot: params.useShellSnapshot,
sandbox: undefined,
containerWorkdir: null,
usePty: params.pty,

View File

@@ -11,6 +11,7 @@ const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
const supervisorMock = vi.hoisted(() => ({
spawn: vi.fn(),
}));
const maybeWrapCommandWithShellSnapshotMock = vi.hoisted(() => vi.fn());
vi.mock("../infra/heartbeat-wake.js", () => ({
requestHeartbeat: requestHeartbeatMock,
@@ -26,6 +27,10 @@ vi.mock("../process/supervisor/index.js", () => ({
}),
}));
vi.mock("./shell-snapshot.js", () => ({
maybeWrapCommandWithShellSnapshot: maybeWrapCommandWithShellSnapshotMock,
}));
let markBackgrounded: typeof import("./bash-process-registry.js").markBackgrounded;
let buildExecExitOutcome: typeof import("./bash-tools.exec-runtime.js").buildExecExitOutcome;
let detectCursorKeyMode: typeof import("./bash-tools.exec-runtime.js").detectCursorKeyMode;
@@ -50,6 +55,10 @@ beforeEach(() => {
requestHeartbeatMock.mockClear();
enqueueSystemEventMock.mockClear();
supervisorMock.spawn.mockReset();
maybeWrapCommandWithShellSnapshotMock.mockReset();
maybeWrapCommandWithShellSnapshotMock.mockImplementation(
async ({ command }: { command: string }) => command,
);
});
function expectExecTarget(
@@ -582,6 +591,42 @@ describe("buildExecExitOutcome", () => {
});
describe("runExecProcess POSIX command wrapper", () => {
it("skips shell startup snapshots when host env inheritance is disabled", async () => {
supervisorMock.spawn.mockResolvedValueOnce({
runId: "mock-run",
startedAtMs: Date.now(),
wait: async () => ({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 0,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
}),
cancel: vi.fn(),
});
await runExecProcess({
command: "echo isolated",
workdir: process.platform === "win32" ? "C:\\tmp" : "/tmp",
env: {},
useShellSnapshot: false,
usePty: false,
warnings: [],
maxOutput: 1000,
pendingMaxOutput: 1000,
notifyOnExit: false,
timeoutSec: null,
});
expect(maybeWrapCommandWithShellSnapshotMock).not.toHaveBeenCalled();
const spawnCall = supervisorMock.spawn.mock.calls[0]?.[0];
const command = spawnCall?.argv?.join(" ") ?? spawnCall?.ptyCommand ?? "";
expect(command).toContain("echo isolated");
});
it("normalizes non-finite and oversized exec timeouts before spawning", async () => {
supervisorMock.spawn.mockResolvedValue({
runId: "mock-run",

View File

@@ -580,6 +580,8 @@ export async function runExecProcess(opts: {
workdir: string;
env: Record<string, string>;
pathPrepend?: string[];
/** Whether to restore the Gateway user's cached shell startup state. */
useShellSnapshot?: boolean;
sandbox?: BashSandboxConfig;
containerWorkdir?: string | null;
usePty: boolean;
@@ -764,13 +766,16 @@ export async function runExecProcess(opts: {
shellRuntimeEnv,
opts.pathPrepend,
);
const commandWithShellSnapshot = await maybeWrapCommandWithShellSnapshot({
command: commandWithPathPrepend,
shell,
shellArgs,
cwd: opts.workdir,
env: shellRuntimeEnv,
});
const commandWithShellSnapshot =
opts.useShellSnapshot === false
? commandWithPathPrepend
: await maybeWrapCommandWithShellSnapshot({
command: commandWithPathPrepend,
shell,
shellArgs,
cwd: opts.workdir,
env: shellRuntimeEnv,
});
const childArgv = [shell, ...shellArgs, commandWithShellSnapshot];
if (opts.usePty) {

View File

@@ -29,6 +29,10 @@ export type ExecToolDefaults = {
ask?: ExecAsk;
trigger?: string;
node?: string;
/** Trusted, operator-configured environment scoped to this agent's exec children. */
env?: Record<string, unknown>;
/** Inherit the Gateway process environment for Gateway-hosted exec (default: true). */
inheritHostEnv?: boolean;
pathPrepend?: string[];
safeBins?: string[];
strictInlineEval?: boolean;

View File

@@ -33,7 +33,9 @@ const mocks = vi.hoisted(() => ({
requestedEnv?: Record<string, string>;
}>,
spawnInputs: [] as Array<{
argv?: string[];
env?: Record<string, string>;
ptyCommand?: string;
}>,
}));
@@ -84,8 +86,17 @@ vi.mock("./bash-tools.exec-host-node.js", () => ({
vi.mock("../process/supervisor/index.js", () => ({
getProcessSupervisor: () => ({
spawn: async (input: { env?: Record<string, string>; onStdout?: (chunk: string) => void }) => {
mocks.spawnInputs.push({ env: input.env ? { ...input.env } : undefined });
spawn: async (input: {
argv?: string[];
env?: Record<string, string>;
onStdout?: (chunk: string) => void;
ptyCommand?: string;
}) => {
mocks.spawnInputs.push({
argv: input.argv ? [...input.argv] : undefined,
env: input.env ? { ...input.env } : undefined,
ptyCommand: input.ptyCommand,
});
input.onStdout?.("ok\n");
return {
runId: "mock-run",
@@ -230,6 +241,90 @@ describe("exec resolve_exec_env hook wiring", () => {
});
});
it("applies inherited, model, agent, and plugin precedence across key casing", async () => {
const inheritedKey = "BREX_CASE_SCOPED_TOKEN";
const previous = process.env[inheritedKey];
process.env[inheritedKey] = "inherited";
installResolveExecEnvHook({ brex_case_scoped_token: "plugin" });
try {
const tool = createExecTool({
host: "gateway",
security: "full",
ask: "off",
env: { Brex_Case_Scoped_Token: "agent" },
});
await tool.execute("call-case-precedence", {
command: "echo ok",
env: { BREX_CASE_SCOPED_TOKEN: "model" },
yieldMs: 120_000,
});
const requestedMatches = Object.entries(mocks.gatewayParams[0]?.requestedEnv ?? {}).filter(
([key]) => key.toUpperCase() === inheritedKey,
);
const effectiveMatches = Object.entries(mocks.gatewayParams[0]?.env ?? {}).filter(
([key]) => key.toUpperCase() === inheritedKey,
);
expect(requestedMatches).toEqual([["brex_case_scoped_token", "plugin"]]);
expect(effectiveMatches).toEqual([["brex_case_scoped_token", "plugin"]]);
} finally {
if (previous === undefined) {
delete process.env[inheritedKey];
} else {
process.env[inheritedKey] = previous;
}
}
});
it.each(["gateway", "node"] as const)(
"drops stale inherited channel context for %s exec without turn context",
async (host) => {
const previous = process.env[CHANNEL_CONTEXT_ENV_KEY];
process.env[CHANNEL_CONTEXT_ENV_KEY] = "stale-channel-context";
try {
const tool = createExecTool({ host, security: "full", ask: "off" });
await tool.execute(`call-stale-context-${host}`, {
command: "echo ok",
yieldMs: 120_000,
});
const effectiveEnv =
host === "node" ? mocks.nodeHostParams[0]?.env : mocks.gatewayParams[0]?.env;
expect(effectiveEnv).not.toHaveProperty(CHANNEL_CONTEXT_ENV_KEY);
} finally {
if (previous === undefined) {
delete process.env[CHANNEL_CONTEXT_ENV_KEY];
} else {
process.env[CHANNEL_CONTEXT_ENV_KEY] = previous;
}
}
},
);
it("drops stale sandbox channel context when the turn has no channel context", async () => {
const tool = createExecTool({
host: "sandbox",
security: "full",
ask: "off",
cwd: process.cwd(),
sandbox: {
containerName: "openclaw-test-sandbox",
workspaceDir: process.cwd(),
containerWorkdir: "/workspace",
env: { [CHANNEL_CONTEXT_ENV_KEY]: "stale-sandbox-context" },
},
});
await tool.execute("call-stale-sandbox-context", {
command: "echo ok",
yieldMs: 120_000,
});
expect(JSON.stringify(mocks.spawnInputs[0])).not.toContain("stale-sandbox-context");
expect(JSON.stringify(mocks.spawnInputs[0])).not.toContain(CHANNEL_CONTEXT_ENV_KEY);
});
it("forwards filtered plugin env to node host requests", async () => {
installResolveExecEnvHook({
NODE_HOST_SAFE: "yes",

View File

@@ -34,8 +34,14 @@ import {
isDangerousHostEnvVarName,
normalizeHostOverrideEnvVarKey,
sanitizeHostExecEnvWithDiagnostics,
setCaseInsensitiveEnvValue,
validateConfiguredExecEnvKey,
} from "../infra/host-env-security.js";
import { OPENCLAW_CLI_ENV_VAR } from "../infra/openclaw-exec-env.js";
import {
OPENCLAW_CHANNEL_CONTEXT_ENV_VAR,
OPENCLAW_CLI_ENV_VAR,
} from "../infra/openclaw-exec-env.js";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import {
getShellPathFromLoginShell,
resolveShellEnvFallbackTimeoutMs,
@@ -109,7 +115,7 @@ type ExecToolArgs = Record<string, unknown> & {
node?: string;
};
const CHANNEL_CONTEXT_ENV_KEY = "OPENCLAW_CHANNEL_CONTEXT";
const CHANNEL_CONTEXT_ENV_KEY = OPENCLAW_CHANNEL_CONTEXT_ENV_VAR;
function buildSubprocessChannelContext(
channelContext: PluginHookChannelContext | undefined,
@@ -152,23 +158,88 @@ function filterPluginExecEnv(rawEnv: Record<string, string>): Record<string, str
const env: Record<string, string> = {};
for (const [rawKey, value] of Object.entries(rawEnv)) {
const key = normalizeHostOverrideEnvVarKey(rawKey);
if (!key) {
if (!key || isBlockedObjectKey(key)) {
continue;
}
const upperKey = key.toUpperCase();
if (
upperKey === "PATH" ||
upperKey === OPENCLAW_CLI_ENV_VAR ||
upperKey === CHANNEL_CONTEXT_ENV_KEY ||
isDangerousHostEnvVarName(upperKey) ||
isDangerousHostEnvOverrideVarName(upperKey)
) {
continue;
}
env[key] = value;
setCaseInsensitiveEnvValue(env, key, value);
}
return Object.keys(env).length > 0 ? env : undefined;
}
function resolveMaterializedExecEnv(
env: Record<string, unknown> | undefined,
): Record<string, string> | undefined {
if (!env) {
return undefined;
}
const resolved: Record<string, string> = {};
const seen = new Set<string>();
for (const [key, value] of Object.entries(env)) {
const validation = validateConfiguredExecEnvKey(key);
if (!validation.ok) {
throw new Error(`agents.list[].tools.exec.env.${key} ${validation.reason}`);
}
if (seen.has(validation.caseFoldedKey)) {
throw new Error(
`agents.list[].tools.exec.env contains duplicate key ${JSON.stringify(key)} (case-insensitive)`,
);
}
seen.add(validation.caseFoldedKey);
if (typeof value !== "string") {
throw new Error(
`agents.list[].tools.exec.env.${key} contains an unresolved SecretRef; use the active runtime config snapshot`,
);
}
setCaseInsensitiveEnvValue(resolved, validation.key, value);
}
return resolved;
}
function mergeExecEnvLayers(
...layers: Array<Record<string, string> | undefined>
): Record<string, string> | undefined {
const merged: Record<string, string> = {};
let hasLayer = false;
for (const layer of layers) {
if (layer === undefined) {
continue;
}
hasLayer = true;
for (const [key, value] of Object.entries(layer)) {
if (isBlockedObjectKey(key)) {
throw new Error(`Security Violation: Environment variable '${key}' is forbidden.`);
}
setCaseInsensitiveEnvValue(merged, key, value);
}
}
return hasLayer ? merged : undefined;
}
function applyTrustedChannelContextEnv(
env: Record<string, string>,
channelContextEnv: Record<string, string> | undefined,
): void {
for (const key of Object.keys(env)) {
if (key.trim().toUpperCase() === CHANNEL_CONTEXT_ENV_KEY) {
delete env[key];
}
}
const trustedValue = channelContextEnv?.[CHANNEL_CONTEXT_ENV_KEY];
if (trustedValue !== undefined) {
env[CHANNEL_CONTEXT_ENV_KEY] = trustedValue;
}
}
function markResolveExecEnvPrepared<T extends ExecToolArgs>(
params: T,
state: ResolvedExecEnvPreparedState = {},
@@ -1597,15 +1668,34 @@ export function createExecTool(
}
await rejectUnsafeExecControlShellCommand(params.command);
const inheritedBaseEnv = coerceEnv(process.env);
const hasConfiguredEnv = Object.keys(defaults?.env ?? {}).length > 0;
if (host === "node" && (defaults?.inheritHostEnv === false || hasConfiguredEnv)) {
throw new Error(
hasConfiguredEnv
? "agents.list[].tools.exec.env is not supported for host=node; configure scoped environment on the node host"
: "tools.exec.inheritHostEnv=false is not supported for host=node; configure environment isolation on the node host",
);
}
const configuredEnv = resolveMaterializedExecEnv(defaults?.env);
for (const source of [params.env, configuredEnv]) {
if (
source &&
Object.keys(source).some((key) => key.trim().toUpperCase() === CHANNEL_CONTEXT_ENV_KEY)
) {
throw new Error(
`Security Violation: Environment variable '${CHANNEL_CONTEXT_ENV_KEY}' is reserved for trusted channel context.`,
);
}
}
const inheritedBaseEnv = defaults?.inheritHostEnv === false ? {} : coerceEnv(process.env);
const resolvedExecEnvState = getResolvedExecEnvPreparedState(params);
const channelContextEnv = buildChannelContextEnv(defaults?.channelContext);
const requestedEnv: Record<string, string> | undefined =
params.env !== undefined ||
resolvedExecEnvState?.pluginEnv !== undefined ||
channelContextEnv !== undefined
? { ...params.env, ...resolvedExecEnvState?.pluginEnv, ...channelContextEnv }
: undefined;
const requestedEnv = mergeExecEnvLayers(
params.env,
configuredEnv,
resolvedExecEnvState?.pluginEnv,
channelContextEnv,
);
const hostEnvResult =
host === "sandbox"
? null
@@ -1658,8 +1748,14 @@ export function createExecTool(
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
})
: (hostEnvResult?.env ?? inheritedBaseEnv);
applyTrustedChannelContextEnv(env, channelContextEnv);
if (!sandbox && host === "gateway" && !requestedEnv?.PATH) {
if (
!sandbox &&
host === "gateway" &&
defaults?.inheritHostEnv !== false &&
!requestedEnv?.PATH
) {
const shellPath = getShellPathFromLoginShell({
env: process.env,
timeoutMs: resolveShellEnvFallbackTimeoutMs(process.env),
@@ -1722,6 +1818,7 @@ export function createExecTool(
workdir,
env,
pathPrepend: defaultPathPrepend,
useShellSnapshot: defaults?.inheritHostEnv !== false,
requestedEnv,
pty: params.pty === true && !sandbox,
timeoutSec: params.timeout,
@@ -1785,6 +1882,7 @@ export function createExecTool(
workdir,
env,
pathPrepend: defaultPathPrepend,
useShellSnapshot: defaults?.inheritHostEnv !== false,
sandbox,
containerWorkdir,
usePty,

View File

@@ -8,6 +8,7 @@ import fs from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import { parseStrictInteger } from "@openclaw/normalization-core/number-coercion";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import { sliceUtf16Safe } from "../utils.js";
import { assertSandboxPath } from "./sandbox-paths.js";
import type { SandboxBackendExecSpec } from "./sandbox/backend-handle.types.js";
@@ -46,10 +47,14 @@ export function buildSandboxEnv(params: {
HOME: params.containerWorkdir,
};
for (const [key, value] of Object.entries(params.sandboxEnv ?? {})) {
env[key] = value;
if (!isBlockedObjectKey(key)) {
env[key] = value;
}
}
for (const [key, value] of Object.entries(params.paramsEnv ?? {})) {
env[key] = value;
if (!isBlockedObjectKey(key)) {
env[key] = value;
}
}
return env;
}

View File

@@ -1,14 +1,20 @@
/** Formats and appends token/cost usage lines to reply payloads. */
import { hasNonzeroUsage, type NormalizedUsage } from "../../agents/usage.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js";
import {
estimateUsageCost,
formatTokenCount,
formatUsd,
type ModelCostConfig,
resolveModelCostConfig,
} from "../../utils/usage-format.js";
import { getReplyPayloadMetadata, setReplyPayloadMetadata } from "../reply-payload.js";
import { resolveEffectiveResponseUsage } from "../thinking.js";
import type { ReplyPayload } from "../types.js";
import { buildUsageContract } from "../usage-bar/contract.js";
import { loadUsageBarTemplate } from "../usage-bar/template.js";
import { renderUsageBar } from "../usage-bar/translator.js";
/** Formats the optional usage/cost summary appended to agent replies. */
export const formatResponseUsageLine = (params: {
usage?: {
input?: number;
@@ -54,7 +60,56 @@ export const formatResponseUsageLine = (params: {
return `Usage: ${inputLabel} in / ${outputLabel} out${cacheSuffix}${suffix}`;
};
/** Appends a usage line to the last text payload while preserving payload metadata. */
export const resolveResponseUsageLine = (params: {
config: OpenClawConfig;
sessionRaw?: string | null;
channel?: string;
usage?: NormalizedUsage;
provider?: string;
model?: string;
preserveUserFacingSessionState?: boolean;
replyUsageState?: PluginHookReplyUsageState;
}): string | undefined => {
const responseUsageMode = resolveEffectiveResponseUsage(
params.sessionRaw,
params.config.messages?.responseUsage,
params.channel,
);
if (
responseUsageMode === "off" ||
!hasNonzeroUsage(params.usage) ||
params.preserveUserFacingSessionState === true
) {
return undefined;
}
const costConfig = resolveModelCostConfig({
provider: params.provider,
model: params.model,
config: params.config,
allowPluginNormalization: false,
});
const showCost = responseUsageMode === "full" && costConfig !== undefined;
const formatted = formatResponseUsageLine({
usage: params.usage,
showCost,
costConfig,
});
const usageTemplate =
responseUsageMode === "full" && params.replyUsageState
? loadUsageBarTemplate(params.config.messages?.usageTemplate)
: undefined;
const rendered =
usageTemplate && params.replyUsageState
? renderUsageBar(usageTemplate, buildUsageContract(params.replyUsageState, params.channel))
: undefined;
if (rendered) {
return rendered;
}
return formatted ?? undefined;
};
export const appendUsageLine = (payloads: ReplyPayload[], line: string): ReplyPayload[] => {
let index = -1;
for (let i = payloads.length - 1; i >= 0; i -= 1) {

View File

@@ -16,7 +16,6 @@ import {
queueEmbeddedAgentMessageWithOutcomeAsync,
} from "../../agents/embedded-agent-runner/runs.js";
import { resolveFastModeState } from "../../agents/fast-mode.js";
import { resolveAgentIdentity } from "../../agents/identity.js";
import { resolveModelAuthMode } from "../../agents/model-auth.js";
import { isCliProvider } from "../../agents/model-selection.js";
import { deriveContextPromptTokens, hasNonzeroUsage, normalizeUsage } from "../../agents/usage.js";
@@ -40,7 +39,6 @@ import {
} from "../../infra/diagnostic-trace-context.js";
import { measureDiagnosticsTimelineSpan } from "../../infra/diagnostics-timeline.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js";
import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js";
import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
@@ -65,12 +63,9 @@ import {
setReplyPayloadMetadata,
} from "../reply-payload.js";
import type { OriginatingChannelType, TemplateContext } from "../templating.js";
import { resolveResponseUsageMode, type VerboseLevel } from "../thinking.js";
import type { VerboseLevel } from "../thinking.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { buildUsageContract } from "../usage-bar/contract.js";
import { loadUsageBarTemplate } from "../usage-bar/template.js";
import { renderUsageBar } from "../usage-bar/translator.js";
import {
buildKnownAgentRunFailureReplyPayload,
runAgentTurnWithFallback,
@@ -89,7 +84,7 @@ import {
hasUnbackedReminderCommitment,
} from "./agent-runner-reminder-guard.js";
import { resetReplyRunSession } from "./agent-runner-session-reset.js";
import { appendUsageLine, formatResponseUsageLine } from "./agent-runner-usage-line.js";
import { appendUsageLine, resolveResponseUsageLine } from "./agent-runner-usage-line.js";
import { resolveQueuedReplyExecutionConfig } from "./agent-runner-utils.js";
import { createAudioAsVoiceBuffer, createBlockReplyPipeline } from "./block-reply-pipeline.js";
import { resolveEffectiveBlockStreamingConfig } from "./block-streaming.js";
@@ -126,7 +121,7 @@ import {
} from "./reply-run-registry.js";
import { createReplyToModeFilterForChannel, resolveReplyToMode } from "./reply-threading.js";
import { admitReplyTurn, resolveReplyTurnKind } from "./reply-turn-admission.js";
import { recordReplyUsageState } from "./reply-usage-state.js";
import { buildReplyUsageState, recordReplyUsageState } from "./reply-usage-state.js";
import { resolveRoutedDeliveryThreadId } from "./routed-delivery-thread.js";
import { incrementRunCompactionCount, persistRunSessionUsage } from "./session-run-accounting.js";
import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js";
@@ -1683,7 +1678,6 @@ export async function runReplyAgent(params: {
toolProgressDetail,
});
let responseUsageLine: string | undefined;
type SessionResetOptions = {
failureLabel: string;
buildLogMessage: (nextSessionId: string) => string;
@@ -1829,80 +1823,52 @@ export async function runReplyAgent(params: {
const providerUsed =
runResult.meta?.agentMeta?.provider ?? fallbackProvider ?? followupRun.run.provider;
let replyUsageState: PluginHookReplyUsageState | undefined;
{
const winnerProvider = fallbackExhausted
? undefined
: (runResult.meta?.executionTrace?.winnerProvider ?? providerUsed);
const winnerModel = fallbackExhausted
? undefined
: (runResult.meta?.executionTrace?.winnerModel ?? modelUsed);
const ctxTokens = runResult.meta?.agentMeta?.contextTokens;
const compactions = runResult.meta?.agentMeta?.compactionCount;
const lastCallUsage = runResult.meta?.agentMeta?.lastCallUsage;
replyUsageState = {
provider: providerUsed,
model: modelUsed,
resolvedRef: winnerProvider && winnerModel ? `${winnerProvider}/${winnerModel}` : undefined,
reasoningEffort:
typeof followupRun.run.thinkLevel === "string" ? followupRun.run.thinkLevel : undefined,
fastMode: resolveFastModeState({
cfg,
provider: providerUsed ?? "",
model: modelUsed ?? "",
agentId: followupRun.run.agentId,
sessionEntry: activeSessionEntry,
}).enabled,
fallbackUsed: runResult.meta?.executionTrace?.fallbackUsed === true,
const winnerProvider = fallbackExhausted
? undefined
: (runResult.meta?.executionTrace?.winnerProvider ?? providerUsed);
const winnerModel = fallbackExhausted
? undefined
: (runResult.meta?.executionTrace?.winnerModel ?? modelUsed);
const ctxTokens = runResult.meta?.agentMeta?.contextTokens;
const compactions = runResult.meta?.agentMeta?.compactionCount;
const lastCallUsage = runResult.meta?.agentMeta?.lastCallUsage;
const replyUsageState = buildReplyUsageState({
config: cfg,
provider: providerUsed,
model: modelUsed,
fallbackExhausted,
winnerProvider,
winnerModel,
reasoningEffort:
typeof followupRun.run.thinkLevel === "string" ? followupRun.run.thinkLevel : undefined,
fastMode: resolveFastModeState({
cfg,
provider: providerUsed ?? "",
model: modelUsed ?? "",
agentId: followupRun.run.agentId,
sessionId: followupRun.run.sessionId,
chatType: typeof sessionCtx.ChatType === "string" ? sessionCtx.ChatType : undefined,
authMode: runResult.meta?.requestShaping?.authMode ?? undefined,
overrideSource: activeSessionEntry?.modelOverrideSource ?? undefined,
requested:
followupRun.run.provider && followupRun.run.model
? `${followupRun.run.provider}/${followupRun.run.model}`
: undefined,
turnUsd: hasBillableUsageBuckets
? estimateUsageCost({
usage,
cost: resolveModelCostConfig({
provider: providerUsed,
model: modelUsed,
config: cfg,
}),
})
sessionEntry: activeSessionEntry,
}).enabled,
fallbackUsed: runResult.meta?.executionTrace?.fallbackUsed === true,
agentId: followupRun.run.agentId,
sessionId: followupRun.run.sessionId,
chatType: typeof sessionCtx.ChatType === "string" ? sessionCtx.ChatType : undefined,
authMode: runResult.meta?.requestShaping?.authMode ?? undefined,
overrideSource: activeSessionEntry?.modelOverrideSource ?? undefined,
requestedProvider: followupRun.run.provider,
requestedModel: followupRun.run.model,
durationMs: Date.now() - runStartedAt,
compactionCount: typeof compactions === "number" ? compactions : undefined,
contextTokenBudget:
typeof ctxTokens === "number" && Number.isFinite(ctxTokens) ? ctxTokens : undefined,
contextUsedTokens:
typeof promptTokens === "number" && Number.isFinite(promptTokens)
? promptTokens
: undefined,
durationMs: Date.now() - runStartedAt,
identity: resolveAgentIdentity(cfg, followupRun.run.agentId),
compactionCount: typeof compactions === "number" ? compactions : undefined,
contextTokenBudget:
typeof ctxTokens === "number" && Number.isFinite(ctxTokens) ? ctxTokens : undefined,
contextUsedTokens:
typeof promptTokens === "number" && Number.isFinite(promptTokens)
? promptTokens
: undefined,
usage: usage
? {
input: usage.input,
output: usage.output,
cacheRead: usage.cacheRead,
cacheWrite: usage.cacheWrite,
total: usage.total,
}
: undefined,
lastUsage: lastCallUsage
? {
input: lastCallUsage.input,
output: lastCallUsage.output,
cacheRead: lastCallUsage.cacheRead,
cacheWrite: lastCallUsage.cacheWrite,
total: lastCallUsage.total,
}
: undefined,
};
recordReplyUsageState(runId, replyUsageState);
}
promptTokens,
usage,
lastCallUsage,
});
recordReplyUsageState(runId, replyUsageState);
const verboseEnabled = resolvedVerboseLevel !== "off";
const preserveUserFacingSessionState = shouldPreserveUserFacingSessionStateForInputProvenance(
followupRun.run.inputProvenance,
@@ -2270,39 +2236,19 @@ export async function runReplyAgent(params: {
});
}
const responseUsageRaw =
const responseUsageSessionRaw =
activeSessionEntry?.responseUsage ??
(sessionKey ? activeSessionStore?.[sessionKey]?.responseUsage : undefined);
const responseUsageMode = resolveResponseUsageMode(responseUsageRaw);
if (responseUsageMode !== "off" && hasNonzeroUsage(usage) && !preserveUserFacingSessionState) {
const costConfig = resolveModelCostConfig({
provider: providerUsed,
model: modelUsed,
config: cfg,
allowPluginNormalization: false,
});
const showCost = responseUsageMode === "full" && costConfig !== undefined;
let formatted = formatResponseUsageLine({
usage,
showCost,
costConfig,
});
const usageTemplate =
responseUsageMode === "full" && replyUsageState
? loadUsageBarTemplate(cfg.messages?.usageTemplate)
: undefined;
const renderedUsageLine = usageTemplate
? renderUsageBar(usageTemplate, buildUsageContract(replyUsageState, replyToChannel))
: undefined;
if (renderedUsageLine) {
formatted = renderedUsageLine;
} else if (formatted && responseUsageMode === "full" && sessionKey) {
formatted = `${formatted} · session \`${sessionKey}\``;
}
if (formatted) {
responseUsageLine = formatted;
}
}
const responseUsageLine = resolveResponseUsageLine({
config: cfg,
sessionRaw: responseUsageSessionRaw,
channel: replyToChannel,
usage,
provider: providerUsed,
model: modelUsed,
preserveUserFacingSessionState,
replyUsageState,
});
if (verboseEnabled) {
activeSessionEntry = refreshSessionEntryFromStore({

View File

@@ -238,20 +238,108 @@ describe("handleUsageCommand", () => {
expect(params.sessionEntry.responseUsage).toBe("tokens");
});
it("clears usage footer mode on off updates", async () => {
it("persists an explicit /usage off so a configured default cannot re-enable it", async () => {
const params = buildUsageParams();
params.command.commandBodyNormalized = "/usage off";
params.sessionEntry = {
sessionId: "target-session",
updatedAt: Date.now(),
responseUsage: "full",
params.sessionStore = {
[params.sessionKey]: {
sessionId: "target-session",
updatedAt: Date.now(),
responseUsage: "tokens",
},
};
params.sessionStore = { [params.sessionKey]: params.sessionEntry };
const result = await handleUsageCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toBe("⚙️ Usage footer: off.");
expect(params.sessionEntry.responseUsage).toBeUndefined();
expect(params.sessionStore[params.sessionKey]?.responseUsage).toBe("off");
});
it("no-arg toggle uses the effective mode (config default) when session is unset", async () => {
// When session has no override, the effective mode is the config default.
// The toggle should cycle from that effective value, not from "off".
const params = buildUsageParams();
params.command.commandBodyNormalized = "/usage";
params.cfg = {
...params.cfg,
messages: { responseUsage: "tokens" },
} as OpenClawConfig;
params.sessionStore = {
[params.sessionKey]: {
sessionId: "target-session",
updatedAt: Date.now(),
// responseUsage is absent — session inherits config default "tokens"
},
};
const result = await handleUsageCommand(params, true);
expect(result?.shouldContinue).toBe(false);
// Effective current = "tokens" (from config), so cycle → "full"
expect(result?.reply?.text).toBe("⚙️ Usage footer: full.");
expect(params.sessionStore[params.sessionKey]?.responseUsage).toBe("full");
});
it("/usage reset clears the session override so the config default takes over", async () => {
const params = buildUsageParams();
params.command.commandBodyNormalized = "/usage reset";
params.sessionStore = {
[params.sessionKey]: {
sessionId: "target-session",
updatedAt: Date.now(),
responseUsage: "off",
},
};
const result = await handleUsageCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toBe("⚙️ Usage footer: reset to default.");
// responseUsage is deleted (undefined) — session now inherits the config default
expect(params.sessionStore[params.sessionKey]?.responseUsage).toBeUndefined();
});
it("/usage inherit (alias) clears the session override", async () => {
const params = buildUsageParams();
params.command.commandBodyNormalized = "/usage inherit";
params.sessionStore = {
[params.sessionKey]: {
sessionId: "target-session",
updatedAt: Date.now(),
responseUsage: "full",
},
};
const result = await handleUsageCommand(params, true);
expect(result?.shouldContinue).toBe(false);
expect(result?.reply?.text).toBe("⚙️ Usage footer: reset to default.");
expect(params.sessionStore[params.sessionKey]?.responseUsage).toBeUndefined();
});
it("explicit off is stored and not treated as unset — config default cannot override it", async () => {
// This verifies the three-state distinction: "off" vs undefined.
// When session has explicit "off", the effective value is "off" regardless of config.
const params = buildUsageParams();
params.command.commandBodyNormalized = "/usage";
params.cfg = {
...params.cfg,
messages: { responseUsage: "tokens" },
} as OpenClawConfig;
params.sessionStore = {
[params.sessionKey]: {
sessionId: "target-session",
updatedAt: Date.now(),
responseUsage: "off", // explicit off — stays off despite config default "tokens"
},
};
const result = await handleUsageCommand(params, true);
expect(result?.shouldContinue).toBe(false);
// Effective current = "off" (explicit, not inherited), so cycle → "tokens"
expect(result?.reply?.text).toBe("⚙️ Usage footer: tokens.");
});
});

View File

@@ -39,7 +39,7 @@ import {
isSessionDefaultDirectiveValue,
normalizeFastMode,
normalizeUsageDisplay,
resolveResponseUsageMode,
resolveEffectiveResponseUsage,
} from "../thinking.js";
import { resolveCommandSurfaceChannel } from "./channel-context.js";
import { rejectNonOwnerCommand, rejectUnauthorizedCommand } from "./command-gates.js";
@@ -352,24 +352,40 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
};
}
if (rawArgs && !requested) {
const isReset = rawArgs ? isSessionDefaultDirectiveValue(rawArgs) : false;
if (rawArgs && !requested && !isReset) {
return {
shouldContinue: false,
reply: { text: "⚙️ Usage: /usage off|tokens|full|cost" },
reply: { text: "⚙️ Usage: /usage off|tokens|full|reset|cost" },
};
}
const targetSessionEntry = params.sessionStore?.[params.sessionKey] ?? params.sessionEntry;
if (isReset) {
if (targetSessionEntry && params.sessionStore && params.sessionKey) {
delete targetSessionEntry.responseUsage;
params.sessionStore[params.sessionKey] = targetSessionEntry;
await persistSessionEntry({ ...params, sessionEntry: targetSessionEntry });
}
return {
shouldContinue: false,
reply: { text: "⚙️ Usage footer: reset to default." },
};
}
const replyChannel = params.command.channel;
const currentRaw = targetSessionEntry?.responseUsage;
const current = resolveResponseUsageMode(currentRaw);
const current = resolveEffectiveResponseUsage(
currentRaw,
params.cfg.messages?.responseUsage,
replyChannel,
);
const next = requested ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off");
if (targetSessionEntry && params.sessionStore && params.sessionKey) {
if (next === "off") {
delete targetSessionEntry.responseUsage;
} else {
targetSessionEntry.responseUsage = next;
}
targetSessionEntry.responseUsage = next;
params.sessionStore[params.sessionKey] = targetSessionEntry;
await persistSessionEntry({ ...params, sessionEntry: targetSessionEntry });
}

View File

@@ -3813,6 +3813,137 @@ describe("createFollowupRunner messaging delivery and dedupe", () => {
persistSpy.mockRestore();
});
it("appends configured responseUsage footers during followup delivery", async () => {
const sessionKey = "main";
const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() };
const cfg = {
messages: {
responseUsage: "tokens",
},
} as OpenClawConfig;
const { onBlockReply } = await runMessagingCase({
agentResult: {
payloads: [{ text: "hello world!" }],
meta: {
agentMeta: {
usage: { input: 1_000, output: 50 },
model: "claude-opus-4-6",
provider: "anthropic",
},
},
},
runnerOverrides: {
sessionEntry,
sessionStore: { [sessionKey]: sessionEntry },
sessionKey,
},
queued: createQueuedRun({
run: {
config: cfg,
messageProvider: "discord",
sessionKey,
},
}),
});
const payload = requireMockCallArg(onBlockReply, 0);
expect(payload.text).toContain("hello world!");
expect(payload.text).toContain("Usage:");
expect(payload.text).toContain("out");
});
it("renders full responseUsage followup footers without exposing the session key", async () => {
const sessionKey = "discord:channel:user";
const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() };
const cfg = {
messages: {
responseUsage: "full",
usageTemplate: {
output: {
default: [
{
text: "model={model.display_name} tokens={usage.input_tokens|num}/{usage.output_tokens|num}",
},
],
},
},
},
} as OpenClawConfig;
const { onBlockReply } = await runMessagingCase({
agentResult: {
payloads: [{ text: "hello world!" }],
meta: {
agentMeta: {
usage: { input: 1_000, output: 50 },
model: "claude-opus-4-6",
provider: "anthropic",
},
},
},
runnerOverrides: {
sessionEntry,
sessionStore: { [sessionKey]: sessionEntry },
sessionKey,
},
queued: createQueuedRun({
run: {
config: cfg,
messageProvider: "discord",
sessionKey,
},
}),
});
const payload = requireMockCallArg(onBlockReply, 0);
expect(payload.text).toContain("hello world!");
expect(payload.text).toContain("model=claude-opus-4-6 tokens=1.0k/50");
expect(payload.text).not.toContain(sessionKey);
});
it("keeps explicit responseUsage off during followup delivery", async () => {
const sessionKey = "main";
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
responseUsage: "off",
};
const cfg = {
messages: {
responseUsage: "tokens",
},
} as OpenClawConfig;
const { onBlockReply } = await runMessagingCase({
agentResult: {
payloads: [{ text: "hello world!" }],
meta: {
agentMeta: {
usage: { input: 1_000, output: 50 },
model: "claude-opus-4-6",
provider: "anthropic",
},
},
},
runnerOverrides: {
sessionEntry,
sessionStore: { [sessionKey]: sessionEntry },
sessionKey,
},
queued: createQueuedRun({
run: {
config: cfg,
messageProvider: "discord",
sessionKey,
},
}),
});
const payload = requireMockCallArg(onBlockReply, 0);
expect(payload.text).toBe("hello world!");
});
it("uses providerUsed for snapshot freshness when agent metadata overrides the run provider", async () => {
const storePath = "/tmp/openclaw-followup-usage-provider.json";
const sessionKey = "main";

View File

@@ -64,6 +64,7 @@ import {
resolveSessionRuntimeOverrideForProvider,
} from "./agent-runner-execution.js";
import { runPreflightCompactionIfNeeded } from "./agent-runner-memory.js";
import { appendUsageLine, resolveResponseUsageLine } from "./agent-runner-usage-line.js";
import {
resolveQueuedReplyExecutionConfig,
resolveQueuedReplyRuntimeConfig,
@@ -90,6 +91,7 @@ import {
import type { ReplyDispatchKind } from "./reply-dispatcher.types.js";
import type { ReplyOperation } from "./reply-run-registry.js";
import { admitReplyTurn } from "./reply-turn-admission.js";
import { buildReplyUsageState } from "./reply-usage-state.js";
import { isRoutableChannel, routeReply } from "./route-reply.js";
import { incrementRunCompactionCount, persistRunSessionUsage } from "./session-run-accounting.js";
import { createTypingSignaler } from "./typing-mode.js";
@@ -1404,6 +1406,60 @@ export function createFollowupRunner(params: {
}
let deliveryPayloads = finalPayloads;
const responseUsageSessionRaw =
activeSessionEntry?.responseUsage ??
(replySessionKey ? sessionStore?.[replySessionKey]?.responseUsage : undefined);
const winnerProvider = fallbackExhausted
? undefined
: (runResult.meta?.executionTrace?.winnerProvider ?? providerUsed);
const winnerModel = fallbackExhausted
? undefined
: (runResult.meta?.executionTrace?.winnerModel ?? modelUsed);
const lastCallUsage = runResult.meta?.agentMeta?.lastCallUsage;
const replyUsageState = buildReplyUsageState({
config: runtimeConfig,
provider: providerUsed,
model: modelUsed,
fallbackExhausted,
winnerProvider,
winnerModel,
reasoningEffort: typeof run.thinkLevel === "string" ? run.thinkLevel : undefined,
fallbackUsed: runResult.meta?.executionTrace?.fallbackUsed === true,
agentId: run.agentId,
sessionId: run.sessionId,
chatType: queued.originatingChatType,
authMode: runResult.meta?.requestShaping?.authMode ?? undefined,
overrideSource: activeSessionEntry?.modelOverrideSource ?? undefined,
requestedProvider: run.provider,
requestedModel: run.model,
compactionCount:
typeof runResult.meta?.agentMeta?.compactionCount === "number"
? runResult.meta.agentMeta.compactionCount
: undefined,
contextTokenBudget:
typeof contextTokensUsed === "number" && Number.isFinite(contextTokensUsed)
? contextTokensUsed
: undefined,
promptTokens,
usage,
lastCallUsage,
});
const responseUsageLine = resolveResponseUsageLine({
config: runtimeConfig,
sessionRaw: responseUsageSessionRaw,
channel: resolveOriginMessageProvider({
originatingChannel: queued.originatingChannel,
provider: run.messageProvider,
}),
usage,
provider: providerUsed,
model: modelUsed,
preserveUserFacingSessionState,
replyUsageState,
});
if (responseUsageLine) {
deliveryPayloads = appendUsageLine(deliveryPayloads, responseUsageLine);
}
if (autoCompactionCount > 0) {
const previousSessionId = run.sessionId;
const count = await incrementRunCompactionCount({
@@ -1438,7 +1494,7 @@ export function createFollowupRunner(params: {
{
text: `🧹 Auto-compaction complete${suffix}.`,
},
...finalPayloads,
...deliveryPayloads,
];
}
}

View File

@@ -1,9 +1,109 @@
import { resolveAgentIdentity } from "../../agents/identity.js";
import { deriveContextPromptTokens, type NormalizedUsage } from "../../agents/usage.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { PluginHookReplyUsageState } from "../../plugins/hook-types.js";
import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js";
const TTL_MS = 5 * 60_000;
const store = new Map<string, { snapshot: PluginHookReplyUsageState; expiresAt: number }>();
export function buildReplyUsageState(params: {
config: OpenClawConfig;
provider?: string;
model?: string;
fallbackExhausted?: boolean;
winnerProvider?: string;
winnerModel?: string;
reasoningEffort?: string;
fastMode?: boolean;
fallbackUsed?: boolean;
agentId: string;
sessionId: string;
chatType?: string;
authMode?: string;
overrideSource?: string;
requestedProvider?: string;
requestedModel?: string;
compactionCount?: number;
contextTokenBudget?: number;
contextUsedTokens?: number;
promptTokens?: number;
usage?: NormalizedUsage;
lastCallUsage?: NormalizedUsage;
durationMs?: number;
}): PluginHookReplyUsageState {
const resolvedProvider = params.fallbackExhausted ? undefined : params.winnerProvider;
const resolvedModel = params.fallbackExhausted ? undefined : params.winnerModel;
const hasBillableUsageBuckets =
params.usage &&
(params.usage.input !== undefined ||
params.usage.output !== undefined ||
params.usage.cacheRead !== undefined ||
params.usage.cacheWrite !== undefined);
return {
provider: params.provider,
model: params.model,
resolvedRef:
resolvedProvider && resolvedModel ? `${resolvedProvider}/${resolvedModel}` : undefined,
reasoningEffort: params.reasoningEffort,
fastMode: params.fastMode,
fallbackUsed: params.fallbackUsed,
agentId: params.agentId,
sessionId: params.sessionId,
chatType: params.chatType,
authMode: params.authMode,
overrideSource: params.overrideSource,
requested:
params.requestedProvider && params.requestedModel
? `${params.requestedProvider}/${params.requestedModel}`
: undefined,
turnUsd: hasBillableUsageBuckets
? estimateUsageCost({
usage: params.usage,
cost: resolveModelCostConfig({
provider: params.provider,
model: params.model,
config: params.config,
}),
})
: undefined,
durationMs: params.durationMs,
identity: resolveAgentIdentity(params.config, params.agentId),
compactionCount: params.compactionCount,
contextTokenBudget:
typeof params.contextTokenBudget === "number" && Number.isFinite(params.contextTokenBudget)
? params.contextTokenBudget
: undefined,
contextUsedTokens:
typeof params.contextUsedTokens === "number" && Number.isFinite(params.contextUsedTokens)
? params.contextUsedTokens
: deriveContextPromptTokens({
lastCallUsage: params.lastCallUsage,
promptTokens: params.promptTokens,
usage: params.usage,
}),
usage: params.usage
? {
input: params.usage.input,
output: params.usage.output,
cacheRead: params.usage.cacheRead,
cacheWrite: params.usage.cacheWrite,
total: params.usage.total,
}
: undefined,
lastUsage: params.lastCallUsage
? {
input: params.lastCallUsage.input,
output: params.lastCallUsage.output,
cacheRead: params.lastCallUsage.cacheRead,
cacheWrite: params.lastCallUsage.cacheWrite,
total: params.lastCallUsage.total,
}
: undefined,
};
}
function prune(now: number): void {
for (const [key, value] of store) {
if (value.expiresAt < now) {

View File

@@ -182,6 +182,37 @@ export function resolveResponseUsageMode(raw?: string | null): UsageDisplayLevel
return normalizeUsageDisplay(raw) ?? "off";
}
export type ResponseUsageInput = "on" | "off" | "tokens" | "full";
export type ResponseUsageDefaultConfig =
| ResponseUsageInput
| { default?: ResponseUsageInput; [channel: string]: ResponseUsageInput | undefined };
export function resolveMessagesResponseUsageDefault(
configured: ResponseUsageDefaultConfig | undefined,
channel?: string,
): ResponseUsageInput | undefined {
if (typeof configured === "string") {
return configured;
}
if (configured && typeof configured === "object") {
return (channel ? configured[channel] : undefined) ?? configured.default;
}
return undefined;
}
export function resolveEffectiveResponseUsage(
sessionRaw: string | undefined | null,
configured: ResponseUsageDefaultConfig | undefined,
channel?: string,
): UsageDisplayLevel {
const sessionNormalized = normalizeUsageDisplay(sessionRaw);
if (sessionNormalized !== undefined) {
return sessionNormalized;
}
const configDefault = resolveMessagesResponseUsageDefault(configured, channel);
return resolveResponseUsageMode(configDefault);
}
/** Normalizes elevated execution policy values. */
export function normalizeElevatedLevel(raw?: string | null): ElevatedLevel | undefined {
if (!raw) {

View File

@@ -25,6 +25,8 @@ const {
formatThinkingLevels,
resolveSupportedThinkingLevel,
resolveThinkingDefaultForModel,
resolveMessagesResponseUsageDefault,
resolveEffectiveResponseUsage,
} = await import("./thinking.js");
beforeEach(() => {
@@ -807,3 +809,71 @@ describe("normalizeReasoningLevel", () => {
expect(normalizeReasoningLevel("streaming")).toBe("stream");
});
});
describe("resolveMessagesResponseUsageDefault", () => {
it("returns undefined when unset (preserves off-by-default behavior)", () => {
expect(resolveMessagesResponseUsageDefault(undefined)).toBeUndefined();
expect(resolveMessagesResponseUsageDefault(undefined, "discord")).toBeUndefined();
});
it("returns a bare string default for any channel", () => {
expect(resolveMessagesResponseUsageDefault("full")).toBe("full");
expect(resolveMessagesResponseUsageDefault("full", "telegram")).toBe("full");
});
it("resolves the channel entry from a map", () => {
const cfg = { default: "off", discord: "full", telegram: "tokens" } as const;
expect(resolveMessagesResponseUsageDefault(cfg, "discord")).toBe("full");
expect(resolveMessagesResponseUsageDefault(cfg, "telegram")).toBe("tokens");
});
it("falls back to default for an unmapped channel", () => {
const cfg = { default: "tokens", discord: "full" } as const;
expect(resolveMessagesResponseUsageDefault(cfg, "whatsapp")).toBe("tokens");
});
it("returns undefined for a map with neither the channel nor a default", () => {
expect(resolveMessagesResponseUsageDefault({ discord: "full" }, "telegram")).toBeUndefined();
});
});
describe("resolveEffectiveResponseUsage", () => {
it("returns off when session is unset and no config is provided", () => {
expect(resolveEffectiveResponseUsage(undefined, undefined)).toBe("off");
expect(resolveEffectiveResponseUsage(null, undefined)).toBe("off");
});
it("applies config default when session is unset", () => {
expect(resolveEffectiveResponseUsage(undefined, "tokens")).toBe("tokens");
expect(resolveEffectiveResponseUsage(undefined, "full")).toBe("full");
});
it("applies per-channel config entry when session is unset", () => {
const cfg = { default: "off", discord: "full", telegram: "tokens" } as const;
expect(resolveEffectiveResponseUsage(undefined, cfg, "discord")).toBe("full");
expect(resolveEffectiveResponseUsage(undefined, cfg, "telegram")).toBe("tokens");
// Unknown channel falls back to config default
expect(resolveEffectiveResponseUsage(undefined, cfg, "whatsapp")).toBe("off");
});
it("session explicit off overrides any config default", () => {
// Explicit "off" is stored and wins — non-off config default cannot re-enable it.
expect(resolveEffectiveResponseUsage("off", "tokens")).toBe("off");
expect(resolveEffectiveResponseUsage("off", "full")).toBe("off");
expect(resolveEffectiveResponseUsage("off", { default: "full", discord: "full" }, "discord")).toBe("off");
});
it("session explicit on value overrides config default", () => {
expect(resolveEffectiveResponseUsage("tokens", "full")).toBe("tokens");
expect(resolveEffectiveResponseUsage("full", "off")).toBe("full");
});
it("unset (undefined/null) falls through to config; explicit off does not", () => {
// These two are distinct states:
// - undefined = unset/inherit → gets config default
// - "off" = explicit off → stays off
const cfg = "tokens" as const;
expect(resolveEffectiveResponseUsage(undefined, cfg)).toBe("tokens"); // inherits
expect(resolveEffectiveResponseUsage("off", cfg)).toBe("off"); // explicit off persists
});
});

View File

@@ -17,6 +17,8 @@ export {
normalizeThinkLevel,
normalizeUsageDisplay,
normalizeVerboseLevel,
resolveEffectiveResponseUsage,
resolveMessagesResponseUsageDefault,
resolveResponseUsageMode,
} from "./thinking.shared.js";
export type {
@@ -24,6 +26,8 @@ export type {
FastMode,
NoticeLevel,
ReasoningLevel,
ResponseUsageDefaultConfig,
ResponseUsageInput,
TraceLevel,
ThinkLevel,
ThinkingCatalogEntry,

View File

@@ -1199,7 +1199,6 @@ function resolveConfigIncludesForRead(
deps: Required<ConfigIoDeps>,
includeFileHashesForWrite?: Record<string, string>,
includeFileTargetsForWrite?: Record<string, string>,
includeFilePaths?: Set<string>,
): unknown {
const allowedRoots = resolveIncludeRoots(deps.env, deps.homedir);
const recordIncludeTarget = (resolvedPath: string, canonicalPath?: string) => {
@@ -1232,10 +1231,7 @@ function resolveConfigIncludesForRead(
resolvedPath,
rootRealDir,
ioFs: deps.fs,
onResolvedPath: (canonicalPath) => {
recordIncludeTarget(resolvedPath, canonicalPath);
includeFilePaths?.add(path.normalize(canonicalPath));
},
onResolvedPath: (canonicalPath) => recordIncludeTarget(resolvedPath, canonicalPath),
});
if (includeFileHashesForWrite) {
includeFileHashesForWrite[path.normalize(resolvedPath)] = hashConfigIncludeRaw(raw);
@@ -1311,13 +1307,11 @@ type ReadConfigFileSnapshotInternalResult = {
envSnapshotForRestore?: Record<string, string | undefined>;
includeFileHashesForWrite?: Record<string, string>;
includeFileTargetsForWrite?: Record<string, string>;
includeFilePaths?: readonly string[];
pluginMetadataSnapshot?: PluginMetadataSnapshot;
};
export type ReadConfigFileSnapshotWithPluginMetadataResult = {
snapshot: ConfigFileSnapshot;
includeFilePaths?: readonly string[];
pluginMetadataSnapshot?: PluginMetadataSnapshot;
};
@@ -1874,7 +1868,6 @@ export function createConfigIO(
let fallbackEnvSnapshotForRestore: Record<string, string | undefined> | undefined;
const includeFileHashesForWrite: Record<string, string> = {};
const includeFileTargetsForWrite: Record<string, string> = {};
const includeFilePaths = new Set<string>();
try {
const raw = await deps.measure("config.snapshot.read.file", () =>
@@ -1923,7 +1916,6 @@ export function createConfigIO(
deps,
includeFileHashesForWrite,
includeFileTargetsForWrite,
includeFilePaths,
),
);
} catch (err) {
@@ -2094,7 +2086,6 @@ export function createConfigIO(
envSnapshotForRestore: readResolution.envSnapshotForRestore,
includeFileHashesForWrite,
includeFileTargetsForWrite,
includeFilePaths: [...includeFilePaths].toSorted(),
pluginMetadataSnapshot: validationPluginMetadata.getSnapshot(),
},
{ observe: !callerRejectedSuspiciousRecovery },
@@ -2160,7 +2151,6 @@ export function createConfigIO(
});
return {
snapshot: result.snapshot,
...(result.snapshot.valid ? { includeFilePaths: result.includeFilePaths ?? [] } : {}),
...(result.pluginMetadataSnapshot
? { pluginMetadataSnapshot: result.pluginMetadataSnapshot }
: {}),

View File

@@ -1865,57 +1865,6 @@ describe("config io write", () => {
});
});
it.runIf(process.platform !== "win32")(
"exposes only canonical valid include paths through the metadata wrapper",
async () => {
await withSuiteHome(async (home) => {
const configDir = path.join(home, ".openclaw");
const configPath = path.join(configDir, "openclaw.json");
const fragmentsDir = path.join(configDir, "fragments");
const aliasDir = path.join(configDir, "alias");
const defaultsPath = path.join(fragmentsDir, "defaults.json5");
const nestedPath = path.join(fragmentsDir, "nested.json5");
await fs.mkdir(fragmentsDir, { recursive: true });
await fs.symlink(fragmentsDir, aliasDir, "dir");
await fs.writeFile(nestedPath, '{ workspace: "~/.openclaw/workspace" }\n', "utf-8");
await fs.writeFile(
defaultsPath,
'{ $include: "./nested.json5", maxConcurrent: 1 }\n',
"utf-8",
);
await fs.writeFile(
configPath,
'{ agents: { defaults: { $include: "./alias/defaults.json5" } } }\n',
"utf-8",
);
const io = createConfigIO({
env: { OPENCLAW_TEST_FAST: "1" } as NodeJS.ProcessEnv,
homedir: () => home,
logger: silentLogger,
});
const valid = await io.readConfigFileSnapshotWithPluginMetadata();
expect(valid.snapshot.valid).toBe(true);
expect(valid.includeFilePaths).toEqual(
[await fs.realpath(defaultsPath), await fs.realpath(nestedPath)].toSorted(),
);
expect(valid.includeFilePaths).not.toContain(path.join(aliasDir, "defaults.json5"));
expect(valid.snapshot).not.toHaveProperty("includeFilePaths");
await fs.writeFile(nestedPath, "{ malformed", "utf-8");
const invalid = await io.readConfigFileSnapshotWithPluginMetadata();
expect(invalid.snapshot.valid).toBe(false);
expect(invalid).not.toHaveProperty("includeFilePaths");
await fs.rm(nestedPath);
const missing = await io.readConfigFileSnapshotWithPluginMetadata();
expect(missing.snapshot.valid).toBe(false);
expect(missing).not.toHaveProperty("includeFilePaths");
});
},
);
it("repairs invalid root-authored siblings without flattening included config", async () => {
await withSuiteHome(async (home) => {
const configPath = path.join(home, ".openclaw", "openclaw.json");

View File

@@ -23,6 +23,18 @@ describe("realredactConfigSnapshot_real", () => {
apiKey: "6789",
},
},
tools: {
exec: {
env: {
REGION: "exec-secret",
CREDENTIAL: {
source: "env",
provider: "default",
id: "REFERRALS_CREDENTIAL",
},
},
},
},
},
],
},
@@ -32,9 +44,24 @@ describe("realredactConfigSnapshot_real", () => {
const config = result.config as typeof snapshot.config;
expect(config.agents.defaults.memorySearch.remote.apiKey).toBe(REDACTED_SENTINEL);
expect(config.agents.list[0].memorySearch.remote.apiKey).toBe(REDACTED_SENTINEL);
expect(config.agents.list[0].tools.exec.env.REGION).toBe(REDACTED_SENTINEL);
expect(config.agents.list[0].tools.exec.env.CREDENTIAL).toEqual({
source: "env",
provider: "default",
id: REDACTED_SENTINEL,
});
expect(result.parsed?.agents.list[0].tools.exec.env.REGION).toBe(REDACTED_SENTINEL);
expect(result.raw).not.toContain("exec-secret");
expect(result.raw).not.toContain("REFERRALS_CREDENTIAL");
const restored = restoreRedactedValues(result.config, snapshot.config, mainSchemaHints);
expect(restored.agents.defaults.memorySearch.remote.apiKey).toBe("1234");
expect(restored.agents.list[0].memorySearch.remote.apiKey).toBe("6789");
expect(restored.agents.list[0].tools.exec.env.REGION).toBe("exec-secret");
expect(restored.agents.list[0].tools.exec.env.CREDENTIAL).toEqual({
source: "env",
provider: "default",
id: "REFERRALS_CREDENTIAL",
});
});
it("redacts bundled channel private keys from generated schema hints", () => {

View File

@@ -773,6 +773,10 @@ export const FIELD_HELP: Record<string, string> = {
"Per-agent additive allowlist for tools on top of global and profile policy. Keep narrow to avoid accidental privilege expansion on specialized agents.",
"agents.list[].tools.codeMode":
"Per-agent code mode override. Use this to test or roll out exec/wait tool-surface mode for one agent without enabling it fleet-wide.",
"agents.list[].tools.exec.env":
"Environment variables injected only into this agent's exec child processes. Values may be plaintext or SecretRefs; prefer SecretRefs for credentials.",
"agents.list[].tools.exec.inheritHostEnv":
"Whether Gateway-hosted exec inherits the Gateway process environment. Set false for a minimal environment when isolating agent credentials; sandbox exec is already minimal and node-host inheritance is configured on the node.",
"agents.list[].tools.byProvider":
"Per-agent provider-specific tool policy overrides for channel-scoped capability control. Use this when a single agent needs tighter restrictions on one provider than others.",
"agents.list[].tools.message.crossContext.allowWithinProvider":
@@ -1875,6 +1879,8 @@ export const FIELD_HELP: Record<string, string> = {
"Prefix text prepended to outbound assistant replies before sending to channels. Use for lightweight branding/context tags and avoid long prefixes that reduce content density.",
"messages.usageTemplate":
"Custom /usage full footer template, either an inline object or a JSON file path. Invalid or unavailable templates fall back to the built-in usage line.",
"messages.responseUsage":
'Default per-reply usage footer mode ("off"|"tokens"|"full") seeded into sessions that have not chosen one via /usage. Also accepts "on" as a legacy alias for "tokens". Accepts a bare mode or a per-channel map with a "default" fallback. Precedence: session value -> channel entry -> default -> off; an explicit /usage choice (including off) is persisted and overrides the default. Use /usage reset (aliases: inherit, clear, default) to clear a session override and re-inherit this configured default.',
"messages.groupChat":
"Group-message handling controls including mention triggers and history window sizing. Keep mention patterns narrow so group channels do not trigger on every message.",
"messages.groupChat.mentionPatterns":

View File

@@ -88,6 +88,7 @@ describe("mapSensitivePaths", () => {
merged: z
.object({ id: z.string() })
.and(z.object({ nested: z.string().register(sensitive) })),
pipedRecord: z.unknown().pipe(z.record(z.string(), z.string().register(sensitive))),
});
const result = mapSensitivePaths(GrandSchema, "", {});
@@ -101,6 +102,7 @@ describe("mapSensitivePaths", () => {
expect(result["headersNested.*.nested"]?.sensitive).toBe(true);
expect(result["auth.value"]?.sensitive).toBe(true);
expect(result["merged.nested"]?.sensitive).toBe(true);
expect(result["pipedRecord.*"]?.sensitive).toBe(true);
});
it("should not detect non-sensitive fields nested inside all structural Zod types", () => {
@@ -119,6 +121,7 @@ describe("mapSensitivePaths", () => {
z.object({ type: z.literal("token"), value: z.string() }),
]),
merged: z.object({ id: z.string() }).and(z.object({ nested: z.string() })),
pipedRecord: z.unknown().pipe(z.record(z.string(), z.string())),
});
const result = mapSensitivePaths(GrandSchema, "", {});
@@ -132,6 +135,7 @@ describe("mapSensitivePaths", () => {
expect(result["headersNested.*.nested"]?.sensitive).toBe(undefined);
expect(result["auth.value"]?.sensitive).toBe(undefined);
expect(result["merged.nested"]?.sensitive).toBe(undefined);
expect(result["pipedRecord.*"]?.sensitive).toBe(undefined);
});
it("maps sensitive fields nested under object catchall schemas", () => {
@@ -188,6 +192,7 @@ describe("mapSensitivePaths", () => {
expect(hints["agents.defaults.memorySearch.remote.apiKey"]?.sensitive).toBe(true);
expect(hints["agents.list[].memorySearch.remote.apiKey"]?.sensitive).toBe(true);
expect(hints["agents.list[].tools.exec.env.*"]?.sensitive).toBe(true);
expect(hints["gateway.auth.token"]?.sensitive).toBe(true);
expect(hints["models.providers.*.headers.*"]?.sensitive).toBe(true);
expect(hints["models.providers.*.localService.env.*"]?.sensitive).toBe(true);

View File

@@ -239,6 +239,9 @@ export function collectMatchingSchemaPaths(
} else if (currentSchema instanceof z.ZodIntersection) {
collectMatchingSchemaPaths(currentSchema["_def"].left as z.ZodType, path, matchesPath, paths);
collectMatchingSchemaPaths(currentSchema["_def"].right as z.ZodType, path, matchesPath, paths);
} else if (currentSchema instanceof z.ZodPipe) {
collectMatchingSchemaPaths(currentSchema["_def"].in as z.ZodType, path, matchesPath, paths);
collectMatchingSchemaPaths(currentSchema["_def"].out as z.ZodType, path, matchesPath, paths);
}
return paths;
@@ -317,6 +320,9 @@ function mapSensitivePathsMut(schema: z.ZodType, path: string, hints: ConfigUiHi
} else if (currentSchema instanceof z.ZodIntersection) {
mapSensitivePathsMut(currentSchema["_def"].left as z.ZodType, path, hints);
mapSensitivePathsMut(currentSchema["_def"].right as z.ZodType, path, hints);
} else if (currentSchema instanceof z.ZodPipe) {
mapSensitivePathsMut(currentSchema["_def"].in as z.ZodType, path, hints);
mapSensitivePathsMut(currentSchema["_def"].out as z.ZodType, path, hints);
}
}

View File

@@ -214,6 +214,8 @@ export const FIELD_LABELS: Record<string, string> = {
"agents.list[].tools.profile": "Agent Tool Profile",
"agents.list[].tools.alsoAllow": "Agent Tool Allowlist Additions",
"agents.list[].tools.codeMode": "Agent Code Mode",
"agents.list[].tools.exec.env": "Agent Exec Environment",
"agents.list[].tools.exec.inheritHostEnv": "Inherit Gateway Environment",
"tools.byProvider": "Tool Policy by Provider",
"agents.list[].tools.byProvider": "Agent Tool Policy by Provider",
"agents.list[].tools.message.crossContext.allowWithinProvider":
@@ -968,6 +970,7 @@ export const FIELD_LABELS: Record<string, string> = {
"messages.visibleReplies": "Visible Replies",
"messages.responsePrefix": "Outbound Response Prefix",
"messages.usageTemplate": "Usage Footer Template",
"messages.responseUsage": "Default Usage Footer Mode",
"messages.groupChat": "Group Chat Rules",
"messages.groupChat.mentionPatterns": "Group Mention Patterns",
"messages.groupChat.historyLimit": "Group History Limit",

View File

@@ -1040,6 +1040,16 @@ describe("config schema", () => {
expect(schema?.properties).toHaveProperty("vars");
});
it("keeps per-agent exec env records discoverable and sensitive", () => {
const lookup = lookupConfigSchema(baseSchema, "agents.list.0.tools.exec.env");
expect(lookup?.schema?.type).toBe("object");
expect(lookup?.schema?.additionalProperties).toBeTypeOf("object");
const wildcard = lookup?.children.find((child) => child.key === "*");
expect(wildcard?.hasChildren).toBe(true);
expect(wildcard?.hintPath).toBe("agents.list[].tools.exec.env.*");
expect(wildcard?.hint?.sensitive).toBe(true);
});
it("matches wildcard ui hints for concrete lookup paths", () => {
const lookup = lookupConfigSchema(baseSchema, "agents.list.0.identity.avatar");
expect(lookup?.path).toBe("agents.list.0.identity.avatar");

View File

@@ -141,6 +141,23 @@ export type MessagesConfig = {
responsePrefix?: string;
/** Custom `/usage full` footer template, inline or JSON file path. */
usageTemplate?: string | Record<string, unknown>;
/**
* Default per-reply usage footer mode (`responseUsage`) seeded into any session
* that has not set its own via `/usage`. Precedence: session value → channel entry
* → `default` → `off`. Absent ⇒ `off` (unchanged behavior).
*
* - string: one default for every channel, e.g. `"full"`.
* - object: per-channel with a fallback, e.g. `{ "default": "off", "discord": "full" }`.
*/
responseUsage?:
| "on"
| "off"
| "tokens"
| "full"
| {
default?: "on" | "off" | "tokens" | "full";
[channel: string]: "on" | "off" | "tokens" | "full" | undefined;
};
groupChat?: GroupChatConfig;
queue?: QueueConfig;
/** Debounce rapid inbound messages per sender (global + per-channel overrides). */

View File

@@ -376,6 +376,13 @@ export type ExecToolConfig = {
};
};
export type AgentExecToolConfig = ExecToolConfig & {
/** Environment variables injected only into this agent's exec child processes. */
env?: Record<string, SecretInput>;
/** Inherit the Gateway process environment for Gateway-hosted exec (default: true). */
inheritHostEnv?: boolean;
};
export type FsToolsConfig = {
/**
* Restrict filesystem tools (read/write/edit/apply_patch) to the agent workspace directory.
@@ -416,7 +423,7 @@ export type AgentToolsConfig = {
allowFrom?: AgentElevatedAllowFromConfig;
};
/** Exec tool defaults for this agent. */
exec?: ExecToolConfig;
exec?: AgentExecToolConfig;
/** Filesystem tool path guards. */
fs?: FsToolsConfig;
/** Runtime loop detection for repetitive/ stuck tool-call patterns. */

View File

@@ -455,6 +455,62 @@ describe("agent defaults schema", () => {
);
});
it("accepts SecretRef-backed per-agent exec environments", () => {
const parsed = AgentEntrySchema.parse({
id: "referrals",
tools: {
exec: {
inheritHostEnv: false,
env: {
GREENHOUSE_TOKEN: {
source: "env",
provider: "default",
id: "REFERRALS_GREENHOUSE_TOKEN",
},
REGION: "us-east-1",
},
},
},
});
expect(parsed.tools?.exec?.inheritHostEnv).toBe(false);
expect(parsed.tools?.exec?.env?.REGION).toBe("us-east-1");
});
it("rejects unsafe or ambiguous per-agent exec environment keys", () => {
const invalidEnvs = [
{ PATH: "/tmp/bin" },
{ NODE_OPTIONS: "--require ./inject.js" },
{ OPENCLAW_CHANNEL_CONTEXT: "spoofed" },
{ "not-portable": "value" },
{ TOKEN: "first", token: "second" },
Object.fromEntries([["__proto__", "polluted"]]),
];
for (const env of invalidEnvs) {
expectSchemaFailurePath(
AgentEntrySchema.safeParse({ id: "ops", tools: { exec: { env } } }),
"tools.exec.env",
);
}
});
it("keeps exec environment injection agent-scoped", () => {
const result = validateConfigObject({
tools: {
exec: {
env: { SHARED_SECRET: "not-allowed" },
},
},
});
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error("expected global tools.exec.env to be rejected");
}
expect(result.issues.some((issue) => issue.path === "tools.exec")).toBe(true);
});
it("rejects non-positive contextTokens on agent entries and defaults", () => {
expectSchemaFailurePath(
AgentEntrySchema.safeParse({ id: "ops", contextTokens: 0 }),

View File

@@ -10,6 +10,7 @@ import { splitSandboxBindSpec } from "../agents/sandbox/bind-spec.js";
import { isSandboxHostPathAbsolute } from "../agents/sandbox/host-paths.js";
import { getBlockedNetworkModeReason } from "../agents/sandbox/network-mode.js";
import { parseDurationMs } from "../cli/parse-duration.js";
import { validateConfiguredExecEnvKey } from "../infra/host-env-security.js";
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
import { LEGACY_WEB_SEARCH_PROVIDER_CONFIG_KEYS } from "./web-search-legacy-provider-keys.js";
import { AgentModelSchema, AgentToolModelSchema } from "./zod-schema.agent-model.js";
@@ -593,9 +594,55 @@ function addExecPolicyModeConflictIssue(
});
}
const AgentExecEnvRecordSchema = z.record(z.string(), SecretInputSchema.register(sensitive));
function buildNestedJsonSchemaMetadata(schema: z.ZodType): Record<string, unknown> {
const metadata = {
...schema.toJSONSchema({ target: "draft-07", io: "input", unrepresentable: "any" }),
} as Record<string, unknown>;
delete metadata.$schema;
return metadata;
}
const AgentExecEnvSchema = z
.unknown()
// Keep the raw input available for blocked-key validation while preserving
// the record shape for config-schema and form consumers.
.meta(buildNestedJsonSchemaMetadata(AgentExecEnvRecordSchema))
.superRefine((value, ctx) => {
if (!isPlainRecord(value)) {
return;
}
const seen = new Map<string, string>();
for (const key of Object.keys(value)) {
const validation = validateConfiguredExecEnvKey(key);
if (!validation.ok) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [key],
message: `Agent exec environment key ${JSON.stringify(key)} ${validation.reason}.`,
});
continue;
}
const previous = seen.get(validation.caseFoldedKey);
if (previous) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [key],
message: `Agent exec environment keys ${JSON.stringify(previous)} and ${JSON.stringify(key)} collide case-insensitively.`,
});
continue;
}
seen.set(validation.caseFoldedKey, key);
}
})
.pipe(AgentExecEnvRecordSchema);
const AgentToolExecSchema = z
.object({
...ToolExecBaseShape,
env: AgentExecEnvSchema.optional(),
inheritHostEnv: z.boolean().optional(),
approvalRunningNoticeMs: z.number().int().nonnegative().optional(),
})
.strict()

View File

@@ -153,12 +153,17 @@ export const SessionSchema = z
.strict()
.optional();
const ResponseUsageModeSchema = z.enum(["on", "off", "tokens", "full"]);
export const MessagesSchema = z
.object({
messagePrefix: z.string().optional(),
visibleReplies: VisibleRepliesSchema.optional(),
responsePrefix: z.string().optional(),
usageTemplate: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(),
responseUsage: z
.union([ResponseUsageModeSchema, z.record(z.string(), ResponseUsageModeSchema)])
.optional(),
groupChat: GroupChatSchema,
queue: QueueSchema,
inbound: InboundDebounceSchema,

View File

@@ -1,6 +1,5 @@
// Gateway config reload tests cover changed-path detection, reload planning,
// plugin registry refresh, skill snapshot invalidation, and watcher behavior.
import nodePath from "node:path";
import chokidar from "chokidar";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { listChannelPlugins } from "../channels/plugins/index.js";
@@ -579,8 +578,8 @@ describe("resolveGatewayReloadSettings", () => {
});
});
type WatcherHandler = (value?: string | Error) => void;
type WatcherEvent = "add" | "change" | "unlink" | "error" | "ready";
type WatcherHandler = () => void;
type WatcherEvent = "add" | "change" | "unlink" | "error";
function createWatcherMock(effectiveUsePolling?: boolean) {
const handlers = new Map<WatcherEvent, WatcherHandler[]>();
@@ -593,9 +592,9 @@ function createWatcherMock(effectiveUsePolling?: boolean) {
handlers.set(event, existing);
return this;
},
emit(event: WatcherEvent, value?: string | Error) {
emit(event: WatcherEvent) {
for (const handler of handlers.get(event) ?? []) {
handler(value);
handler();
}
},
close: vi.fn(async () => {}),
@@ -660,30 +659,17 @@ function makeZeroDebounceHookWrite(persistedHash: string): ConfigWriteNotificati
}
function createReloaderHarness(
readSnapshot: () => Promise<
ConfigFileSnapshot | { snapshot: ConfigFileSnapshot; includeFilePaths?: readonly string[] }
>,
readSnapshot: () => Promise<ConfigFileSnapshot>,
options: {
initialCompareConfig?: OpenClawConfig;
initialInternalWriteHash?: string | null;
initialIncludeFilePaths?: readonly string[];
promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
initialPluginInstallRecords?: Record<string, PluginInstallRecord>;
readPluginInstallRecords?: () => Promise<Record<string, PluginInstallRecord>>;
watchers?: ReturnType<typeof createWatcherMock>[];
} = {},
) {
const watchers = options.watchers ?? [createWatcherMock()];
const watcher = watchers[0] ?? createWatcherMock();
let watcherIndex = 0;
const watchSpy = vi.spyOn(chokidar, "watch").mockImplementation((_path, watchOptions) => {
const next = watchers[watcherIndex++];
if (!next) {
throw new Error("missing watcher mock");
}
next.options.usePolling = next.effectiveUsePolling ?? Boolean(watchOptions?.usePolling);
return next as unknown as never;
});
const watcher = createWatcherMock();
vi.spyOn(chokidar, "watch").mockReturnValue(watcher as unknown as never);
const onHotReload = vi.fn(async (_plan: GatewayReloadPlan, _nextConfig: OpenClawConfig) => {});
const onRestart = vi.fn((_plan: GatewayReloadPlan, _nextConfig: OpenClawConfig) => {});
let writeListener: ((event: ConfigWriteNotification) => void) | null = null;
@@ -704,7 +690,6 @@ function createReloaderHarness(
initialConfig: { gateway: { reload: { debounceMs: 0 } } },
initialCompareConfig: options.initialCompareConfig,
initialInternalWriteHash: options.initialInternalWriteHash,
initialIncludeFilePaths: options.initialIncludeFilePaths,
readSnapshot,
promoteSnapshot: options.promoteSnapshot,
initialPluginInstallRecords: options.initialPluginInstallRecords ?? {},
@@ -717,8 +702,6 @@ function createReloaderHarness(
});
return {
watcher,
watchers,
watchSpy,
onHotReload,
onRestart,
log,
@@ -1016,9 +999,7 @@ describe("startGatewayConfigReloader", () => {
await harness.reloader.stop();
});
it("does not replay a rejected graph and accepts a later content change", async () => {
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
it("does not promote external config edits when hot reload rejects them", async () => {
const acceptedSnapshot = makeSnapshot({
config: {
gateway: { reload: { debounceMs: 0 } },
@@ -1026,50 +1007,22 @@ describe("startGatewayConfigReloader", () => {
},
hash: "external-rejected-1",
});
const revisedSnapshot = makeSnapshot({
config: {
gateway: { reload: { debounceMs: 0 } },
hooks: { enabled: false },
},
hash: "external-revised-2",
});
const readSnapshot = vi
.fn()
.mockResolvedValueOnce({ snapshot: acceptedSnapshot, includeFilePaths: [nextInclude] })
.mockResolvedValueOnce({ snapshot: acceptedSnapshot, includeFilePaths: [nextInclude] })
.mockResolvedValueOnce({ snapshot: revisedSnapshot, includeFilePaths: [nextInclude] });
.fn<() => Promise<ConfigFileSnapshot>>()
.mockResolvedValueOnce(acceptedSnapshot);
const promoteSnapshot = vi.fn(async (_snapshot: ConfigFileSnapshot, _reason: string) => true);
const watchers = [createWatcherMock(), createWatcherMock(), createWatcherMock()];
const { watcher, onHotReload, log, reloader } = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: [oldInclude],
promoteSnapshot,
watchers,
});
onHotReload.mockRejectedValueOnce(new Error("reload refused"));
watcher.emit("change");
await vi.runOnlyPendingTimersAsync();
await vi.runAllTimersAsync();
expect(onHotReload).toHaveBeenCalledTimes(1);
expect(promoteSnapshot).not.toHaveBeenCalled();
expect(log.error).toHaveBeenCalledWith("config reload failed: Error: reload refused");
watcher.emit("change");
await vi.runOnlyPendingTimersAsync();
expect(onHotReload).toHaveBeenCalledTimes(1);
expect(promoteSnapshot).not.toHaveBeenCalled();
expect(log.warn).toHaveBeenCalledWith(
"config reload skipped (previous apply failed; waiting for config change)",
);
watcher.emit("change");
await vi.runOnlyPendingTimersAsync();
expect(onHotReload).toHaveBeenCalledTimes(2);
expect(promoteSnapshot).toHaveBeenCalledTimes(1);
expect(promoteSnapshot).toHaveBeenCalledWith(revisedSnapshot, "valid-config");
await reloader.stop();
});
@@ -1151,461 +1104,6 @@ describe("startGatewayConfigReloader", () => {
await harness.reloader.stop();
});
it("retains a queued include reconciliation when an in-process hot reload throws", async () => {
const includePath = nodePath.normalize("/tmp/includes/active.json5");
const acceptedSnapshot = makeZeroDebounceHookSnapshot("internal-reconcile-1");
const readSnapshot = vi.fn().mockResolvedValueOnce({
snapshot: acceptedSnapshot,
includeFilePaths: [includePath],
});
const watchers = [createWatcherMock(), createWatcherMock()];
const harness = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: [includePath],
watchers,
});
harness.onHotReload.mockRejectedValueOnce(new Error("reload refused"));
harness.emitWrite(makeZeroDebounceHookWrite("internal-reconcile-1"));
watchers[1]?.emit("ready");
await vi.runOnlyPendingTimersAsync();
await vi.runOnlyPendingTimersAsync();
expect(harness.log.error).toHaveBeenCalledWith("config reload failed: Error: reload refused");
expect(readSnapshot).toHaveBeenCalledTimes(1);
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
expect(harness.log.warn).toHaveBeenCalledWith(
"config reload skipped (previous apply failed; waiting for config change)",
);
await harness.reloader.stop();
});
it("watches nested startup includes and does not apply root hash dedupe to include edits", async () => {
const includePaths = [
nodePath.normalize("/tmp/includes/outer.json5"),
nodePath.normalize("/tmp/includes/nested.json5"),
];
const readSnapshot = vi
.fn()
.mockResolvedValueOnce({
snapshot: makeZeroDebounceHookSnapshot("internal-include-1"),
includeFilePaths: includePaths,
})
.mockResolvedValueOnce({
snapshot: makeSnapshot({
sourceConfig: {
gateway: { reload: { debounceMs: 0 } },
hooks: { enabled: false },
},
runtimeConfig: {
gateway: { reload: { debounceMs: 0 } },
hooks: { enabled: false },
},
config: {
gateway: { reload: { debounceMs: 0 } },
hooks: { enabled: false },
},
hash: "internal-include-1",
}),
includeFilePaths: includePaths,
});
const watchers = [createWatcherMock(), createWatcherMock(), createWatcherMock()];
const harness = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: includePaths,
promoteSnapshot: vi.fn(async () => true),
watchers,
});
expect(harness.watchSpy.mock.calls.map((call) => call[0])).toEqual([
"/tmp/openclaw.json",
nodePath.normalize("/tmp/includes/nested.json5"),
nodePath.normalize("/tmp/includes/outer.json5"),
]);
harness.emitWrite(makeZeroDebounceHookWrite("internal-include-1"));
await vi.runOnlyPendingTimersAsync();
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
watchers[2]?.emit("change", nodePath.normalize("/tmp/includes/outer.json5"));
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).toHaveBeenCalledTimes(2);
expect(harness.onHotReload).toHaveBeenCalledTimes(2);
await harness.reloader.stop();
});
it("clears a stale root write hash when an include-triggered read sees different root bytes", async () => {
const includePath = nodePath.normalize("/tmp/includes/active.json5");
const readSnapshot = vi
.fn()
.mockResolvedValueOnce({
snapshot: makeZeroDebounceHookSnapshot("external-root-2"),
includeFilePaths: [includePath],
})
.mockResolvedValueOnce({
snapshot: makeSnapshot({
sourceConfig: { gateway: { reload: { debounceMs: 0 } }, hooks: { enabled: false } },
runtimeConfig: { gateway: { reload: { debounceMs: 0 } }, hooks: { enabled: false } },
config: { gateway: { reload: { debounceMs: 0 } }, hooks: { enabled: false } },
hash: "internal-root-1",
}),
includeFilePaths: [includePath],
});
const watchers = [createWatcherMock(), createWatcherMock()];
const harness = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: [includePath],
initialInternalWriteHash: "internal-root-1",
watchers,
});
watchers[1]?.emit("change", includePath);
await vi.runOnlyPendingTimersAsync();
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
watchers[0]?.emit("change", nodePath.normalize("/tmp/openclaw.json"));
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).toHaveBeenCalledTimes(2);
expect(harness.onHotReload).toHaveBeenCalledTimes(2);
await harness.reloader.stop();
});
it("retries a failed include watcher handoff while the prior set stays active", async () => {
const rootPath = nodePath.normalize("/tmp/openclaw.json");
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
const nextSnapshot = {
snapshot: makeZeroDebounceHookSnapshot("graph-retry-1"),
includeFilePaths: [nextInclude],
};
const readSnapshot = vi
.fn()
.mockResolvedValueOnce(nextSnapshot)
.mockResolvedValueOnce(nextSnapshot);
const watchers = [
createWatcherMock(),
createWatcherMock(),
createWatcherMock(),
createWatcherMock(),
];
const [rootWatcher, oldWatcher, failedCandidate, retryCandidate] = watchers;
if (!rootWatcher || !oldWatcher || !failedCandidate || !retryCandidate) {
throw new Error("expected watcher mocks");
}
const harness = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: [oldInclude],
watchers,
});
rootWatcher.emit("change", rootPath);
await vi.runOnlyPendingTimersAsync();
failedCandidate.emit("error", new Error("ENOSPC"));
failedCandidate.emit("ready");
expect(oldWatcher.close).not.toHaveBeenCalled();
expect(rootWatcher.close).not.toHaveBeenCalled();
expect(harness.watchSpy).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(500);
expect(harness.watchSpy).toHaveBeenCalledTimes(4);
retryCandidate.emit("ready");
await vi.runOnlyPendingTimersAsync();
expect(oldWatcher.close).toHaveBeenCalledTimes(1);
expect(rootWatcher.close).not.toHaveBeenCalled();
expect(readSnapshot).toHaveBeenCalledTimes(2);
expect(harness.log.warn).toHaveBeenCalledWith(
expect.stringContaining("retrying replacement (attempt 1/3 in 500ms)"),
);
await harness.reloader.stop();
});
it("uses the include watcher's effective polling mode when retries are exhausted", async () => {
const originalVitest = process.env.VITEST;
const originalChokidarPolling = process.env.CHOKIDAR_USEPOLLING;
delete process.env.VITEST;
delete process.env.CHOKIDAR_USEPOLLING;
let harness: ReloaderHarness | undefined;
try {
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
const watchers = [
createWatcherMock(false),
createWatcherMock(false),
createWatcherMock(true),
createWatcherMock(true),
createWatcherMock(true),
createWatcherMock(true),
];
harness = createReloaderHarness(
vi.fn().mockResolvedValueOnce({
snapshot: makeZeroDebounceHookSnapshot("effective-polling"),
includeFilePaths: [nextInclude],
}),
{ initialIncludeFilePaths: [oldInclude], watchers },
);
watchers[0]?.emit("change", nodePath.normalize("/tmp/openclaw.json"));
await vi.runOnlyPendingTimersAsync();
for (const [index, delay] of [
[2, 500],
[3, 2000],
[4, 5000],
] as const) {
watchers[index]?.emit("error", new Error("polling failed"));
await vi.advanceTimersByTimeAsync(delay);
}
watchers[5]?.emit("error", new Error("polling failed"));
expect(harness.reloader.hotReloadStatus()).toBe("disabled");
expect(harness.log.error).toHaveBeenCalledWith(expect.stringContaining("in polling mode"));
expect(harness.log.warn).not.toHaveBeenCalledWith(
expect.stringContaining("degrading to polling mode"),
);
} finally {
if (originalVitest === undefined) {
delete process.env.VITEST;
} else {
process.env.VITEST = originalVitest;
}
if (originalChokidarPolling === undefined) {
delete process.env.CHOKIDAR_USEPOLLING;
} else {
process.env.CHOKIDAR_USEPOLLING = originalChokidarPolling;
}
await harness?.reloader.stop();
}
});
it("reconciles once the initial include watcher set is ready", async () => {
const includePath = nodePath.normalize("/tmp/includes/startup.json5");
const readSnapshot = vi.fn().mockResolvedValueOnce({
snapshot: makeZeroDebounceHookSnapshot("startup-include-ready"),
includeFilePaths: [includePath],
});
const watchers = [createWatcherMock(), createWatcherMock()];
const harness = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: [includePath],
watchers,
});
watchers[1]?.emit("ready");
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).toHaveBeenCalledTimes(1);
await harness.reloader.stop();
});
it("reconciles a retained initial watcher after a graph change reverts before ready", async () => {
const rootPath = nodePath.normalize("/tmp/openclaw.json");
const initialInclude = nodePath.normalize("/tmp/includes/initial.json5");
const transientInclude = nodePath.normalize("/tmp/includes/transient.json5");
const initialSnapshot = {
snapshot: makeZeroDebounceHookSnapshot("initial-graph"),
includeFilePaths: [initialInclude],
};
const readSnapshot = vi
.fn()
.mockResolvedValueOnce({
snapshot: makeZeroDebounceHookSnapshot("transient-graph"),
includeFilePaths: [transientInclude],
})
.mockResolvedValueOnce(initialSnapshot)
.mockResolvedValueOnce(initialSnapshot);
const watchers = [createWatcherMock(), createWatcherMock(), createWatcherMock()];
const [rootWatcher, initialWatcher, transientCandidate] = watchers;
if (!rootWatcher || !initialWatcher || !transientCandidate) {
throw new Error("expected watcher mocks");
}
const harness = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: [initialInclude],
watchers,
});
rootWatcher.emit("change", rootPath);
await vi.runOnlyPendingTimersAsync();
rootWatcher.emit("change", rootPath);
await vi.runOnlyPendingTimersAsync();
expect(transientCandidate.close).toHaveBeenCalledTimes(1);
initialWatcher.emit("ready");
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).toHaveBeenCalledTimes(3);
await harness.reloader.stop();
});
it("invalidates an active include watcher that errors during a newer graph handoff", async () => {
const rootPath = nodePath.normalize("/tmp/openclaw.json");
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
const nextSnapshot = {
snapshot: makeZeroDebounceHookSnapshot("graph-active-error"),
includeFilePaths: [nextInclude],
};
const readSnapshot = vi
.fn()
.mockResolvedValueOnce(nextSnapshot)
.mockResolvedValueOnce(nextSnapshot);
const watchers = [createWatcherMock(), createWatcherMock(), createWatcherMock()];
const [rootWatcher, oldWatcher, candidateWatcher] = watchers;
if (!rootWatcher || !oldWatcher || !candidateWatcher) {
throw new Error("expected watcher mocks");
}
const harness = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: [oldInclude],
watchers,
});
rootWatcher.emit("change", rootPath);
await vi.runOnlyPendingTimersAsync();
oldWatcher.emit("error", new Error("active failed"));
expect(oldWatcher.close).toHaveBeenCalledTimes(1);
expect(rootWatcher.close).not.toHaveBeenCalled();
candidateWatcher.emit("ready");
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).toHaveBeenCalledTimes(2);
await harness.reloader.stop();
});
it("atomically swaps changed include graphs after ready and reconciles without watcher leaks", async () => {
const rootPath = nodePath.normalize("/tmp/openclaw.json");
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
const finalInclude = nodePath.normalize("/tmp/includes/final.json5");
const firstConfig: OpenClawConfig = {
gateway: { reload: { debounceMs: 0 } },
hooks: { enabled: true },
};
const finalConfig: OpenClawConfig = {
gateway: { reload: { debounceMs: 0 } },
hooks: { enabled: false },
};
const readSnapshot = vi
.fn()
.mockResolvedValueOnce({
snapshot: makeSnapshot({
sourceConfig: firstConfig,
runtimeConfig: firstConfig,
config: firstConfig,
hash: "graph-1",
}),
includeFilePaths: [nextInclude],
})
.mockResolvedValueOnce({
snapshot: makeSnapshot({
sourceConfig: firstConfig,
runtimeConfig: firstConfig,
config: firstConfig,
hash: "graph-1",
}),
includeFilePaths: [nextInclude],
})
.mockResolvedValueOnce({
snapshot: makeSnapshot({
sourceConfig: finalConfig,
runtimeConfig: finalConfig,
config: finalConfig,
hash: "graph-2",
}),
includeFilePaths: [finalInclude],
});
const watchers = [
createWatcherMock(),
createWatcherMock(),
createWatcherMock(),
createWatcherMock(),
];
const harness = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: [oldInclude],
watchers,
});
const [rootWatcher, initialIncludeWatcher, replacementWatcher, pendingFinalWatcher] = watchers;
if (!rootWatcher || !initialIncludeWatcher || !replacementWatcher || !pendingFinalWatcher) {
throw new Error("expected watcher mocks");
}
rootWatcher.emit("change", rootPath);
await vi.runOnlyPendingTimersAsync();
expect(harness.watchSpy.mock.calls[2]?.[0]).toBe(nextInclude);
expect(rootWatcher.close).not.toHaveBeenCalled();
expect(initialIncludeWatcher.close).not.toHaveBeenCalled();
replacementWatcher.emit("change", nextInclude);
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).toHaveBeenCalledTimes(1);
replacementWatcher.emit("ready");
expect(initialIncludeWatcher.close).toHaveBeenCalledTimes(1);
expect(rootWatcher.close).not.toHaveBeenCalled();
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).toHaveBeenCalledTimes(2);
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
initialIncludeWatcher.emit("change", oldInclude);
await vi.runOnlyPendingTimersAsync();
expect(readSnapshot).toHaveBeenCalledTimes(2);
rootWatcher.emit("change", rootPath);
await vi.runOnlyPendingTimersAsync();
expect(harness.watchSpy.mock.calls[3]?.[0]).toBe(finalInclude);
expect(harness.onHotReload).toHaveBeenCalledTimes(2);
await harness.reloader.stop();
expect(rootWatcher.close).toHaveBeenCalledTimes(1);
expect(initialIncludeWatcher.close).toHaveBeenCalledTimes(1);
expect(replacementWatcher.close).toHaveBeenCalledTimes(1);
expect(pendingFinalWatcher.close).toHaveBeenCalledTimes(1);
});
it("keeps the last valid include watch set when a candidate snapshot is invalid", async () => {
const rootPath = nodePath.normalize("/tmp/openclaw.json");
const acceptedInclude = nodePath.normalize("/tmp/includes/accepted.json5");
const rejectedInclude = nodePath.normalize("/tmp/includes/rejected.json5");
const nextConfig: OpenClawConfig = {
gateway: { reload: { debounceMs: 0 } },
hooks: { enabled: true },
};
const readSnapshot = vi
.fn()
.mockResolvedValueOnce({
snapshot: makeSnapshot({
valid: false,
issues: [{ path: "hooks.enabled", message: "Expected boolean" }],
hash: "invalid-graph",
}),
includeFilePaths: [rejectedInclude],
})
.mockResolvedValueOnce({
snapshot: makeSnapshot({
sourceConfig: nextConfig,
runtimeConfig: nextConfig,
config: nextConfig,
hash: "accepted-graph",
}),
includeFilePaths: [acceptedInclude],
});
const harness = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: [acceptedInclude],
watchers: [createWatcherMock(), createWatcherMock()],
});
harness.watcher.emit("change", rootPath);
await vi.runOnlyPendingTimersAsync();
expect(harness.watchSpy).toHaveBeenCalledTimes(2);
harness.watchers[1]?.emit("change", acceptedInclude);
await vi.runOnlyPendingTimersAsync();
expect(harness.watchSpy).toHaveBeenCalledTimes(2);
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
await harness.reloader.stop();
});
it("honors in-process write intent to skip reload", async () => {
const readSnapshot = vi
.fn<() => Promise<ConfigFileSnapshot>>()
@@ -2035,40 +1533,6 @@ describe("startGatewayConfigReloader", () => {
await harness.reloader.stop();
});
it("skips in-process promotion when includes change under the same root hash", async () => {
const oldInclude = nodePath.normalize("/tmp/includes/old.json5");
const nextInclude = nodePath.normalize("/tmp/includes/next.json5");
const changedByInclude: OpenClawConfig = {
gateway: { reload: { debounceMs: 0 } },
hooks: { enabled: false },
};
const readSnapshot = vi.fn().mockResolvedValueOnce({
snapshot: makeSnapshot({
sourceConfig: changedByInclude,
runtimeConfig: changedByInclude,
config: changedByInclude,
hash: "internal-1",
}),
includeFilePaths: [nextInclude],
});
const promoteSnapshot = vi.fn(async () => true);
const harness = createReloaderHarness(readSnapshot, {
initialIncludeFilePaths: [oldInclude],
promoteSnapshot,
watchers: [createWatcherMock(), createWatcherMock()],
});
harness.emitWrite(makeZeroDebounceHookWrite("internal-1"));
await vi.runOnlyPendingTimersAsync();
expect(harness.onHotReload).toHaveBeenCalledTimes(1);
expect(readSnapshot).toHaveBeenCalledTimes(1);
expect(promoteSnapshot).not.toHaveBeenCalled();
expect(harness.watchSpy).toHaveBeenCalledTimes(2);
await harness.reloader.stop();
});
it("dedupes the first watcher reread for startup internal writes", async () => {
const readSnapshot = vi
.fn<() => Promise<ConfigFileSnapshot>>()

View File

@@ -1,6 +1,5 @@
// Gateway config hot-reload watcher.
// Diffs config/plugin install snapshots and dispatches hot reload or restart plans.
import nodePath from "node:path";
import chokidar from "chokidar";
import type { ConfigWriteNotification } from "../config/io.js";
import { formatConfigIssueLines } from "../config/issue-format.js";
@@ -103,36 +102,6 @@ type GatewayConfigReloader = {
type PluginInstallRecords = Record<string, PluginInstallRecord>;
type ConfigReloadSnapshotReadResult =
| ConfigFileSnapshot
| {
snapshot: ConfigFileSnapshot;
includeFilePaths?: readonly string[];
};
function unpackConfigReloadSnapshot(result: ConfigReloadSnapshotReadResult): {
snapshot: ConfigFileSnapshot;
includeFilePaths?: readonly string[];
} {
return "snapshot" in result ? result : { snapshot: result };
}
function normalizeIncludeWatcherPaths(
rootPath: string,
includeFilePaths: readonly string[] = [],
): string[] {
const normalizedRoot = nodePath.normalize(rootPath);
const includes = new Set(
includeFilePaths.map((includePath) => nodePath.normalize(includePath)).filter(Boolean),
);
includes.delete(normalizedRoot);
return [...includes].toSorted((left, right) => left.localeCompare(right));
}
function watcherPathsEqual(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((entry, index) => entry === right[index]);
}
function asPluginInstallConfig(records: PluginInstallRecords): OpenClawConfig {
return {
plugins: {
@@ -145,8 +114,7 @@ export function startGatewayConfigReloader(opts: {
initialConfig: OpenClawConfig;
initialCompareConfig?: OpenClawConfig;
initialInternalWriteHash?: string | null;
initialIncludeFilePaths?: readonly string[];
readSnapshot: () => Promise<ConfigReloadSnapshotReadResult>;
readSnapshot: () => Promise<ConfigFileSnapshot>;
onHotReload: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => Promise<void>;
onRestart: (plan: GatewayReloadPlan, nextConfig: OpenClawConfig) => void | Promise<void>;
promoteSnapshot?: (snapshot: ConfigFileSnapshot, reason: string) => Promise<boolean>;
@@ -167,7 +135,6 @@ export function startGatewayConfigReloader(opts: {
let pending = false;
let running = false;
let stopped = false;
let pendingIncludeReload = false;
let restartQueued = false;
let missingConfigRetries = 0;
let pendingInProcessConfig: {
@@ -177,7 +144,6 @@ export function startGatewayConfigReloader(opts: {
afterWrite?: ConfigWriteNotification["afterWrite"];
} | null = null;
let lastAppliedWriteHash = opts.initialInternalWriteHash ?? null;
let currentApplyRejected = false;
let currentPluginInstallRecords =
opts.initialPluginInstallRecords ?? loadInstalledPluginIndexInstallRecordsSync();
const readPluginInstallRecords =
@@ -290,11 +256,7 @@ export function startGatewayConfigReloader(opts: {
currentPluginInstallRecords = nextPluginInstallRecords;
settings = resolveGatewayReloadSettings(nextConfig);
if (changedPaths.length === 0) {
if (currentApplyRejected) {
opts.log.warn("config reload skipped (previous apply failed; waiting for config change)");
return false;
}
return true;
return;
}
// Invalidate cached skills snapshots (persisted in sessions.json) whenever
@@ -311,21 +273,18 @@ export function startGatewayConfigReloader(opts: {
opts.log.info(`config change detected; evaluating reload (${changedPaths.join(", ")})`);
if (followUp.mode === "none") {
opts.log.info(`config reload skipped by writer intent (${followUp.reason})`);
currentApplyRejected = false;
return true;
return;
}
const plan = buildGatewayReloadPlan(changedPaths, {
noopPaths: pluginInstallTimestampNoopPaths,
forceChangedPaths: pluginInstallWholeRecordPaths,
});
if (isNoopReloadPlan(plan) && !followUp.requiresRestart) {
currentApplyRejected = false;
return true;
return;
}
if (settings.mode === "off") {
opts.log.info("config reload disabled (gateway.reload.mode=off)");
currentApplyRejected = false;
return true;
return;
}
if (followUp.requiresRestart) {
queueRestart(
@@ -336,13 +295,11 @@ export function startGatewayConfigReloader(opts: {
},
nextConfig,
);
currentApplyRejected = false;
return true;
return;
}
if (settings.mode === "restart") {
queueRestart(plan, nextConfig);
currentApplyRejected = false;
return true;
return;
}
if (plan.restartGateway) {
if (settings.mode === "hot") {
@@ -351,23 +308,13 @@ export function startGatewayConfigReloader(opts: {
", ",
)})`,
);
currentApplyRejected = false;
return true;
return;
}
queueRestart(plan, nextConfig);
currentApplyRejected = false;
return true;
return;
}
try {
await opts.onHotReload(plan, nextConfig);
currentApplyRejected = false;
return true;
} catch (err) {
currentApplyRejected = true;
opts.log.error(`config reload failed: ${String(err)}`);
return false;
}
await opts.onHotReload(plan, nextConfig);
};
const promoteAcceptedSnapshot = async (snapshot: ConfigFileSnapshot, reason: string) => {
@@ -381,26 +328,15 @@ export function startGatewayConfigReloader(opts: {
}
};
const promoteAcceptedInProcessWrite = async (
persistedHash: string,
acceptedCompareConfig: OpenClawConfig,
) => {
const promoteAcceptedInProcessWrite = async (persistedHash: string) => {
if (!opts.promoteSnapshot) {
return;
}
try {
const snapshotRead = unpackConfigReloadSnapshot(await opts.readSnapshot());
const snapshot = snapshotRead.snapshot;
if (
snapshot.hash !== persistedHash ||
!snapshot.valid ||
diffConfigPaths(acceptedCompareConfig, snapshot.sourceConfig).length > 0
) {
const snapshot = await opts.readSnapshot();
if (snapshot.hash !== persistedHash || !snapshot.valid) {
return;
}
if (snapshotRead.includeFilePaths) {
replaceWatchedPaths(snapshotRead.includeFilePaths);
}
await promoteAcceptedSnapshot(snapshot, "in-process-write");
} catch (err) {
opts.log.warn(`config reload in-process last-known-good promotion failed: ${String(err)}`);
@@ -425,31 +361,20 @@ export function startGatewayConfigReloader(opts: {
const pendingWrite = pendingInProcessConfig;
pendingInProcessConfig = null;
missingConfigRetries = 0;
const applied = await applySnapshot(
await applySnapshot(
pendingWrite.config,
pendingWrite.compareConfig,
pendingWrite.afterWrite,
);
if (!applied) {
if (lastAppliedWriteHash === pendingWrite.persistedHash) {
lastAppliedWriteHash = null;
}
return;
}
await promoteAcceptedInProcessWrite(pendingWrite.persistedHash, pendingWrite.compareConfig);
await promoteAcceptedInProcessWrite(pendingWrite.persistedHash);
return;
}
const bypassRootWriteHashDedupe = pendingIncludeReload;
pendingIncludeReload = false;
const snapshotRead = unpackConfigReloadSnapshot(await opts.readSnapshot());
const snapshot = snapshotRead.snapshot;
const snapshot = await opts.readSnapshot();
if (lastAppliedWriteHash && typeof snapshot.hash === "string") {
if (!bypassRootWriteHashDedupe && snapshot.hash === lastAppliedWriteHash) {
if (snapshot.hash === lastAppliedWriteHash) {
return;
}
if (snapshot.hash !== lastAppliedWriteHash) {
lastAppliedWriteHash = null;
}
lastAppliedWriteHash = null;
}
if (handleMissingSnapshot(snapshot)) {
return;
@@ -458,13 +383,7 @@ export function startGatewayConfigReloader(opts: {
handleInvalidSnapshot(snapshot);
return;
}
const applied = await applySnapshot(snapshot.config, snapshot.sourceConfig);
if (!applied) {
return;
}
if (snapshotRead.includeFilePaths) {
replaceWatchedPaths(snapshotRead.includeFilePaths);
}
await applySnapshot(snapshot.config, snapshot.sourceConfig);
await promoteAcceptedSnapshot(snapshot, "valid-config");
} catch (err) {
opts.log.error(`config reload failed: ${String(err)}`);
@@ -473,20 +392,11 @@ export function startGatewayConfigReloader(opts: {
if (pending) {
pending = false;
schedule();
} else if (pendingIncludeReload) {
scheduleAfter(0);
}
}
};
const normalizedRootWatchPath = nodePath.normalize(opts.watchPath);
const scheduleFromWatcher = (changedPath?: string) => {
if (
typeof changedPath === "string" &&
nodePath.normalize(changedPath) !== normalizedRootWatchPath
) {
pendingIncludeReload = true;
}
const scheduleFromWatcher = () => {
schedule();
};
@@ -505,254 +415,35 @@ export function startGatewayConfigReloader(opts: {
scheduleAfter(0);
}) ?? (() => {});
type ConfigWatcher = ReturnType<typeof chokidar.watch>;
type IncludeWatcherGroup = {
paths: string[];
watchers: ConfigWatcher[];
ready: Set<ConfigWatcher>;
usePolling: boolean;
};
const emptyIncludeGroup = (paths: string[] = []): IncludeWatcherGroup => ({
paths,
watchers: [],
ready: new Set(),
usePolling: false,
});
let watcher: ConfigWatcher | null = null;
let watcher: ReturnType<typeof chokidar.watch> | null = null;
let watcherRecreateRetries = 0;
let watcherRecreateTimer: ReturnType<typeof setTimeout> | null = null;
let rootHotReloadDisabled = false;
let hotReloadStatus: GatewayHotReloadStatus = "active";
let degradedToPolling = false;
let watcherUsesPolling = false;
const initialIncludePaths = normalizeIncludeWatcherPaths(
opts.watchPath,
opts.initialIncludeFilePaths,
);
let activeIncludeGroup = emptyIncludeGroup(initialIncludePaths);
let pendingIncludeGroup: IncludeWatcherGroup | null = null;
let desiredIncludePaths = initialIncludePaths;
let includeGeneration = 0;
let includeReplacementRetries = 0;
let includeReplacementTimer: ReturnType<typeof setTimeout> | null = null;
let includeHotReloadDisabled = false;
let includeDegradedToPolling = false;
const closeWatcher = (target: ConfigWatcher | null) => {
void target?.close().catch(() => {});
};
const closeIncludeGroup = (group: IncludeWatcherGroup | null) => {
for (const target of group?.watchers ?? []) {
closeWatcher(target);
}
};
const createWatcherInstance = (watchPath: string, usePolling: boolean): ConfigWatcher =>
chokidar.watch(watchPath, {
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
usePolling,
});
const activateIncludeGroup = (group: IncludeWatcherGroup) => {
if (stopped || group !== pendingIncludeGroup) {
return;
}
const previous = activeIncludeGroup;
activeIncludeGroup = group;
pendingIncludeGroup = null;
includeReplacementRetries = 0;
includeHotReloadDisabled = false;
closeIncludeGroup(previous);
// Re-read once after the handoff so edits during candidate startup are
// reconciled without opening a gap between the old and new exact sets.
pendingIncludeReload = true;
schedule();
};
const scheduleIncludeReplacementRetry = (
generation: number,
failedWithPolling: boolean,
err: unknown,
) => {
if (stopped || generation !== includeGeneration) {
return;
}
if (includeReplacementRetries >= WATCHER_RECREATE_MAX_RETRIES) {
if (!failedWithPolling && resolveChokidarUsePolling(true)) {
includeDegradedToPolling = true;
includeReplacementRetries = 0;
opts.log.warn(
`config include watcher native retries exhausted; degrading to polling mode: ${String(err)}`,
);
includeReplacementTimer = setTimeout(() => {
includeReplacementTimer = null;
stageIncludeReplacement(generation);
}, WATCHER_RECREATE_BACKOFF_MS[0] ?? 500);
return;
}
const mode = failedWithPolling ? "polling mode" : "native mode";
includeHotReloadDisabled = true;
opts.log.error(
`config include hot-reload disabled: watcher failed after ${WATCHER_RECREATE_MAX_RETRIES} re-create attempts in ${mode}; keeping prior paths: ${String(err)}`,
);
return;
}
const backoff =
WATCHER_RECREATE_BACKOFF_MS[includeReplacementRetries] ??
WATCHER_RECREATE_BACKOFF_MS[WATCHER_RECREATE_BACKOFF_MS.length - 1] ??
0;
includeReplacementRetries += 1;
opts.log.warn(
`config include watcher error; retrying replacement (attempt ${includeReplacementRetries}/${WATCHER_RECREATE_MAX_RETRIES} in ${backoff}ms): ${String(err)}`,
);
includeReplacementTimer = setTimeout(() => {
includeReplacementTimer = null;
stageIncludeReplacement(generation);
}, backoff);
};
const createIncludeGroup = (paths: string[], generation: number): IncludeWatcherGroup => {
const usePolling = resolveChokidarUsePolling(includeDegradedToPolling);
const group: IncludeWatcherGroup = {
paths,
watchers: [],
ready: new Set(),
usePolling: false,
};
try {
for (const includePath of paths) {
const next = createWatcherInstance(includePath, usePolling);
group.watchers.push(next);
group.usePolling ||= Boolean(next.options.usePolling);
const scheduleIfActive = (changedPath: string) => {
if (group === activeIncludeGroup) {
scheduleFromWatcher(changedPath);
}
};
next.on("add", scheduleIfActive);
next.on("change", scheduleIfActive);
next.on("unlink", scheduleIfActive);
next.on("ready", () => {
if (stopped) {
return;
}
group.ready.add(next);
if (group.ready.size !== group.watchers.length) {
return;
}
if (group === pendingIncludeGroup) {
if (generation !== includeGeneration) {
return;
}
activateIncludeGroup(group);
} else if (group === activeIncludeGroup) {
pendingIncludeReload = true;
schedule();
}
});
next.on("error", (err) => {
if (stopped) {
return;
}
if (group === pendingIncludeGroup) {
if (generation !== includeGeneration) {
return;
}
pendingIncludeGroup = null;
closeIncludeGroup(group);
scheduleIncludeReplacementRetry(generation, group.usePolling, err);
return;
}
if (group === activeIncludeGroup) {
activeIncludeGroup = emptyIncludeGroup();
closeIncludeGroup(group);
if (!pendingIncludeGroup && !includeReplacementTimer) {
scheduleIncludeReplacementRetry(includeGeneration, group.usePolling, err);
}
}
});
}
return group;
} catch (err) {
closeIncludeGroup(group);
throw err;
}
};
function stageIncludeReplacement(generation: number) {
if (
stopped ||
generation !== includeGeneration ||
pendingIncludeGroup ||
watcherPathsEqual(desiredIncludePaths, activeIncludeGroup.paths)
) {
return;
}
if (desiredIncludePaths.length === 0) {
pendingIncludeGroup = emptyIncludeGroup();
activateIncludeGroup(pendingIncludeGroup);
return;
}
try {
pendingIncludeGroup = createIncludeGroup([...desiredIncludePaths], generation);
} catch (err) {
scheduleIncludeReplacementRetry(
generation,
resolveChokidarUsePolling(includeDegradedToPolling),
err,
);
}
}
const replaceWatchedPaths = (includeFilePaths: readonly string[]) => {
const nextPaths = normalizeIncludeWatcherPaths(opts.watchPath, includeFilePaths);
if (watcherPathsEqual(nextPaths, desiredIncludePaths)) {
return;
}
includeGeneration += 1;
desiredIncludePaths = nextPaths;
includeReplacementRetries = 0;
if (includeReplacementTimer) {
clearTimeout(includeReplacementTimer);
includeReplacementTimer = null;
}
const stagedGroup = pendingIncludeGroup;
pendingIncludeGroup = null;
closeIncludeGroup(stagedGroup);
if (watcherPathsEqual(nextPaths, activeIncludeGroup.paths)) {
includeHotReloadDisabled = false;
return;
}
stageIncludeReplacement(includeGeneration);
};
const createWatcher = () => {
if (stopped) {
return;
}
const next = createWatcherInstance(
opts.watchPath,
resolveChokidarUsePolling(degradedToPolling),
);
const usePolling = resolveChokidarUsePolling(degradedToPolling);
const next = chokidar.watch(opts.watchPath, {
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
usePolling,
});
next.on("add", scheduleFromWatcher);
next.on("change", scheduleFromWatcher);
next.on("unlink", scheduleFromWatcher);
next.on("error", (err) => {
handleWatcherError(next, err);
});
watcher = next;
watcherUsesPolling = Boolean(next.options.usePolling);
rootHotReloadDisabled = false;
const scheduleIfActive = (changedPath: string) => {
if (next === watcher) {
scheduleFromWatcher(changedPath);
}
};
next.on("add", scheduleIfActive);
next.on("change", scheduleIfActive);
next.on("unlink", scheduleIfActive);
next.on("error", (err) => handleWatcherError(next, err));
watcherUsesPolling = next.options.usePolling;
hotReloadStatus = "active";
};
const handleWatcherError = (source: ConfigWatcher, err: unknown) => {
const handleWatcherError = (source: typeof watcher, err: unknown) => {
// Ignore stale errors from a watcher we already replaced or stopped.
if (stopped || source !== watcher) {
return;
@@ -760,7 +451,7 @@ export function startGatewayConfigReloader(opts: {
const failedWatcherUsedPolling = watcherUsesPolling;
watcher = null;
watcherUsesPolling = false;
closeWatcher(source);
void source?.close().catch(() => {});
if (watcherRecreateRetries >= WATCHER_RECREATE_MAX_RETRIES) {
// All native (inotify/kqueue) retries exhausted — fall back to polling
// mode so config hot-reload survives on hosts where inotify resources
@@ -778,7 +469,7 @@ export function startGatewayConfigReloader(opts: {
return;
}
const mode = failedWatcherUsedPolling ? "polling mode" : "native mode";
rootHotReloadDisabled = true;
hotReloadStatus = "disabled";
opts.log.error(
`config hot-reload disabled: watcher failed after ${WATCHER_RECREATE_MAX_RETRIES} re-create attempts in ${mode}: ${String(err)}`,
);
@@ -799,18 +490,6 @@ export function startGatewayConfigReloader(opts: {
};
createWatcher();
if (initialIncludePaths.length > 0) {
try {
activeIncludeGroup = createIncludeGroup(initialIncludePaths, includeGeneration);
} catch (err) {
activeIncludeGroup = emptyIncludeGroup();
scheduleIncludeReplacementRetry(
includeGeneration,
resolveChokidarUsePolling(includeDegradedToPolling),
err,
);
}
}
return {
stop: async () => {
@@ -823,26 +502,11 @@ export function startGatewayConfigReloader(opts: {
clearTimeout(watcherRecreateTimer);
watcherRecreateTimer = null;
}
if (includeReplacementTimer) {
clearTimeout(includeReplacementTimer);
includeReplacementTimer = null;
}
unsubscribeFromWrites();
const rootWatcher = watcher;
const activeIncludes = activeIncludeGroup;
const stagedIncludes = pendingIncludeGroup;
const active = watcher;
watcher = null;
activeIncludeGroup = emptyIncludeGroup();
pendingIncludeGroup = null;
await Promise.all(
[
...(rootWatcher ? [rootWatcher] : []),
...activeIncludes.watchers,
...(stagedIncludes?.watchers ?? []),
].map(async (target) => await target.close().catch(() => {})),
);
await active?.close().catch(() => {});
},
hotReloadStatus: () =>
rootHotReloadDisabled || includeHotReloadDisabled ? "disabled" : "active",
hotReloadStatus: () => hotReloadStatus,
};
}

View File

@@ -475,6 +475,9 @@ export function createAgentEventHandler({
contextTokens: row?.contextTokens,
estimatedCostUsd: row?.estimatedCostUsd,
responseUsage: row?.responseUsage,
// Carry the row-built channel-aware effective mode so the chat snapshot
// matches the session-event/list projections.
effectiveResponseUsage: row?.effectiveResponseUsage,
modelProvider: row?.modelProvider,
model: row?.model,
status: snapshotSource.status,

View File

@@ -193,9 +193,8 @@ type ManagedGatewayConfigReloaderParams = Omit<
initialConfig: OpenClawConfig;
initialCompareConfig?: OpenClawConfig;
initialInternalWriteHash: string | null;
initialIncludeFilePaths?: readonly string[];
watchPath: string;
readSnapshot: typeof import("../config/config.js").readConfigFileSnapshotWithPluginMetadata;
readSnapshot: typeof import("../config/config.js").readConfigFileSnapshot;
promoteSnapshot: typeof import("../config/config.js").promoteConfigSnapshotToLastKnownGood;
subscribeToWrites: typeof import("../config/config.js").registerConfigWriteListener;
logReload: GatewayReloadLog & {
@@ -682,7 +681,6 @@ export function startManagedGatewayConfigReloader(params: ManagedGatewayConfigRe
initialConfig: params.initialConfig,
initialCompareConfig: params.initialCompareConfig,
initialInternalWriteHash: params.initialInternalWriteHash,
initialIncludeFilePaths: params.initialIncludeFilePaths,
readSnapshot: params.readSnapshot,
promoteSnapshot: async (snapshot, _reason) => await params.promoteSnapshot(snapshot),
subscribeToWrites: params.subscribeToWrites,

View File

@@ -99,7 +99,6 @@ function secretsPrepareTimelineAttributes(
export type GatewayStartupConfigSnapshotLoadResult = {
snapshot: ConfigFileSnapshot;
wroteConfig: boolean;
includeFilePaths?: readonly string[];
pluginMetadataSnapshot?: PluginMetadataSnapshot;
};
@@ -144,7 +143,6 @@ export async function loadGatewayStartupConfigSnapshot(params: {
return {
snapshot: configSnapshot,
wroteConfig,
...(snapshotRead.includeFilePaths ? { includeFilePaths: snapshotRead.includeFilePaths } : {}),
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
};
}
@@ -155,7 +153,6 @@ export async function loadGatewayStartupConfigSnapshot(params: {
return {
snapshot: withRuntimeConfig(configSnapshot, autoEnable.config),
wroteConfig,
...(snapshotRead.includeFilePaths ? { includeFilePaths: snapshotRead.includeFilePaths } : {}),
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
};
}

View File

@@ -17,7 +17,7 @@ import { isRestartEnabled } from "../config/commands.flags.js";
import {
getRuntimeConfig,
promoteConfigSnapshotToLastKnownGood,
readConfigFileSnapshotWithPluginMetadata,
readConfigFileSnapshot,
registerConfigWriteListener,
setRuntimeConfigSnapshot,
type ReadConfigFileSnapshotWithPluginMetadataResult,
@@ -638,7 +638,6 @@ export async function startGatewayServer(
let cfgAtStart: OpenClawConfig;
let startupInternalWriteHash: string | null = null;
let startupLastGoodSnapshot = configSnapshot;
let startupIncludeFilePaths = startupConfigLoad.includeFilePaths;
const startupActivationSourceConfig = configSnapshot.sourceConfig;
const startupRuntimeConfig = applyConfigOverrides(configSnapshot.config);
startupTrace.setConfig(startupRuntimeConfig);
@@ -695,15 +694,11 @@ export async function startGatewayServer(
// Keep the old startup-write suppression path intact for compatibility with
// callers that may still report a write, but startup itself no longer mutates config.
if (startupConfigLoad.wroteConfig || authBootstrap.persistedGeneratedToken) {
const startupSnapshotRead = await startupTrace.measure("config.final-snapshot", () =>
readConfigFileSnapshotWithPluginMetadata(),
const startupSnapshot = await startupTrace.measure("config.final-snapshot", () =>
readConfigFileSnapshot(),
);
const startupSnapshot = startupSnapshotRead.snapshot;
startupInternalWriteHash = startupSnapshot.hash ?? null;
startupLastGoodSnapshot = startupSnapshot;
if (startupSnapshotRead.includeFilePaths) {
startupIncludeFilePaths = startupSnapshotRead.includeFilePaths;
}
}
setRuntimeConfigSnapshot(cfgAtStart, startupLastGoodSnapshot.sourceConfig);
const { prepareGatewayPluginBootstrap } = await loadStartupPluginsModule();
@@ -1732,9 +1727,8 @@ export async function startGatewayServer(
initialConfig: cfgAtStart,
initialCompareConfig: startupLastGoodSnapshot.sourceConfig,
initialInternalWriteHash: startupInternalWriteHash,
initialIncludeFilePaths: startupIncludeFilePaths,
watchPath: configSnapshot.path,
readSnapshot: readConfigFileSnapshotWithPluginMetadata,
readSnapshot: readConfigFileSnapshot,
promoteSnapshot: promoteConfigSnapshotToLastKnownGood,
subscribeToWrites: registerConfigWriteListener,
deps,

View File

@@ -209,9 +209,9 @@ async function writeMainSessionStore(options?: SessionStoreEntryOptions) {
function expectMainPatchBroadcast(
result: Awaited<ReturnType<typeof invokeSessionsPatch>>,
expected: Record<string, unknown>,
) {
): Record<string, unknown> {
expectFields(result.responsePayload, { ok: true, key: "agent:main:main" });
expectChangedBroadcast(result.broadcastToConnIds, {
return expectChangedBroadcast(result.broadcastToConnIds, {
sessionKey: "agent:main:main",
reason: "patch",
...expected,
@@ -685,7 +685,31 @@ test("sessions.changed mutation events include live session setting metadata", a
verboseLevel: "on",
});
expectMainPatchBroadcast(result, sessionSettings);
expectMainPatchBroadcast(result, {
...sessionSettings,
// An explicit session override resolves to the same effective mode and the
// sessions.changed builder carries the row-built channel-aware value.
effectiveResponseUsage: "full",
});
});
test("sessions.changed mutation events carry the resolved effectiveResponseUsage when the session has no override", async () => {
// No explicit responseUsage and no configured default → the row builder resolves
// effectiveResponseUsage to "off". The event must carry that resolved value, not
// the absent raw responseUsage, so a UI consumer's effective display stays fresh.
await writeMainSessionStore({ verboseLevel: "on" });
const result = await invokeSessionsPatch({
key: "main",
verboseLevel: "on",
});
const payload = expectMainPatchBroadcast(result, {
effectiveResponseUsage: "off",
});
// Raw responseUsage is genuinely absent (no override), proving the event does not
// merely echo the raw field.
expect(payload.responseUsage).toBeUndefined();
});
test("sessions.changed mutation events include sendPolicy metadata", async () => {

View File

@@ -654,3 +654,24 @@ test("sessions.reset directly unbinds thread bindings when hooks are unavailable
reason: "session-reset",
});
});
test("sessions.reset preserves explicit responseUsage preference across session rollover", async () => {
// Regression: a full session reset must carry the user's display preference forward
// so the usage footer mode survives rollovers. Only /usage reset clears the override.
const { dir } = await createSessionStoreDir();
await writeSingleLineSession(dir, "sess-main", "hello");
await writeSessionStore({
entries: {
main: sessionStoreEntry("sess-main", { responseUsage: "tokens" }),
},
});
const reset = await directSessionReq<{
ok: true;
key: string;
entry: { sessionId: string; responseUsage?: string };
}>("sessions.reset", { key: "main" });
expect(reset.ok).toBe(true);
expect(reset.payload?.entry.responseUsage).toBe("tokens");
});

View File

@@ -52,6 +52,7 @@ export function buildGatewaySessionEventFields(params: {
contextTokens: sessionRow.contextTokens,
estimatedCostUsd: sessionRow.estimatedCostUsd,
responseUsage: sessionRow.responseUsage,
effectiveResponseUsage: sessionRow.effectiveResponseUsage,
modelProvider: sessionRow.modelProvider,
model: sessionRow.model,
status: sessionRow.status,

View File

@@ -809,6 +809,86 @@ describe("gateway session utils", () => {
expect(row.displayName).toBe("Alice");
});
test("buildGatewaySessionRow projects effectiveResponseUsage from a bare config default", () => {
const cfg = {
agents: { list: [{ id: "main", default: true }] },
messages: { responseUsage: "tokens" },
} as OpenClawConfig;
const entry = { sessionId: "s1", updatedAt: 1 } as SessionEntry;
const row = buildGatewaySessionRow({
cfg,
storePath: "",
store: { "agent:main:main": entry },
key: "agent:main:main",
entry,
});
// Session has no explicit override → inherits the configured default.
expect(row.responseUsage).toBeUndefined();
expect(row.effectiveResponseUsage).toBe("tokens");
});
test("buildGatewaySessionRow effectiveResponseUsage respects a per-channel responseUsage map", () => {
const cfg = {
agents: { list: [{ id: "main", default: true }] },
messages: {
responseUsage: { default: "off", discord: "full", telegram: "tokens" },
},
} as OpenClawConfig;
const discordEntry = { sessionId: "d1", updatedAt: 1, channel: "discord" } as SessionEntry;
const discordRow = buildGatewaySessionRow({
cfg,
storePath: "",
store: { "agent:main:discord:direct:1": discordEntry },
key: "agent:main:discord:direct:1",
entry: discordEntry,
});
expect(discordRow.effectiveResponseUsage).toBe("full");
const telegramEntry = { sessionId: "t1", updatedAt: 1, channel: "telegram" } as SessionEntry;
const telegramRow = buildGatewaySessionRow({
cfg,
storePath: "",
store: { "agent:main:telegram:direct:1": telegramEntry },
key: "agent:main:telegram:direct:1",
entry: telegramEntry,
});
expect(telegramRow.effectiveResponseUsage).toBe("tokens");
// A channel with no entry falls back to the config "default" (off).
const slackEntry = { sessionId: "x1", updatedAt: 1, channel: "slack" } as SessionEntry;
const slackRow = buildGatewaySessionRow({
cfg,
storePath: "",
store: { "agent:main:slack:direct:1": slackEntry },
key: "agent:main:slack:direct:1",
entry: slackEntry,
});
expect(slackRow.effectiveResponseUsage).toBe("off");
});
test("buildGatewaySessionRow effectiveResponseUsage keeps an explicit session off over a channel default", () => {
const cfg = {
agents: { list: [{ id: "main", default: true }] },
messages: { responseUsage: { default: "full", discord: "full" } },
} as OpenClawConfig;
const entry = {
sessionId: "d1",
updatedAt: 1,
channel: "discord",
responseUsage: "off",
} as SessionEntry;
const row = buildGatewaySessionRow({
cfg,
storePath: "",
store: { "agent:main:discord:direct:1": entry },
key: "agent:main:discord:direct:1",
entry,
});
// Explicit off persists and wins over the per-channel default.
expect(row.responseUsage).toBe("off");
expect(row.effectiveResponseUsage).toBe("off");
});
test("resolveSessionStoreKey maps main aliases to default agent main", () => {
const cfg = {
session: { mainKey: "work" },

View File

@@ -55,7 +55,7 @@ import {
RECENT_ENDED_SUBAGENT_CHILD_SESSION_MS,
shouldKeepSubagentRunChildLink,
} from "../agents/subagent-run-liveness.js";
import { listThinkingLevelOptions } from "../auto-reply/thinking.js";
import { listThinkingLevelOptions, resolveEffectiveResponseUsage } from "../auto-reply/thinking.js";
import { getRuntimeConfig } from "../config/io.js";
import { resolveAgentModelFallbackValues } from "../config/model-input.js";
import {
@@ -2193,6 +2193,11 @@ export function buildGatewaySessionRow(params: {
parentSessionKey: subagentOwner || entry?.parentSessionKey,
childSessions,
responseUsage: entry?.responseUsage,
effectiveResponseUsage: resolveEffectiveResponseUsage(
entry?.responseUsage,
cfg.messages?.responseUsage,
channel,
),
modelProvider: rowModelProvider,
model: rowModel,
agentRuntime,

View File

@@ -92,6 +92,8 @@ export type GatewaySessionRow = {
parentSessionKey?: string;
childSessions?: string[];
responseUsage?: "on" | "off" | "tokens" | "full";
/** Resolved effective usage mode (session override → channel config → default → off). Populated by surfaces that have config access; absent from the raw session store row. */
effectiveResponseUsage?: "on" | "off" | "tokens" | "full";
modelProvider?: string;
model?: string;
agentRuntime?: GatewayAgentRuntime;

View File

@@ -238,6 +238,30 @@ describe("gateway sessions patch", () => {
expect(entry.thinkingLevel).toBeUndefined();
});
test("persists responseUsage=off (does not clear)", async () => {
const entry = expectPatchOk(
await runPatch({
patch: { key: MAIN_SESSION_KEY, responseUsage: "off" },
}),
);
// Explicit off must persist so a configured messages.responseUsage default
// cannot re-enable the footer the user turned off.
expect(entry.responseUsage).toBe("off");
});
test("clears responseUsage when patch sets null", async () => {
const store: Record<string, SessionEntry> = {
[MAIN_SESSION_KEY]: { responseUsage: "tokens" } as SessionEntry,
};
const entry = expectPatchOk(
await runPatch({
store,
patch: { key: MAIN_SESSION_KEY, responseUsage: null },
}),
);
expect(entry.responseUsage).toBeUndefined();
});
test("persists reasoningLevel=off (does not clear)", async () => {
const entry = expectPatchOk(
await runPatch({

View File

@@ -444,11 +444,7 @@ export async function projectSessionsPatchEntry(params: {
if (!normalized) {
return invalid('invalid responseUsage (use "off"|"tokens"|"full")');
}
if (normalized === "off") {
delete next.responseUsage;
} else {
next.responseUsage = normalized;
}
next.responseUsage = normalized;
}
}

View File

@@ -13,7 +13,7 @@ import {
sanitizeHostExecEnvWithDiagnostics,
sanitizeSystemRunEnvOverrides,
} from "./host-env-security.js";
import { OPENCLAW_CLI_ENV_VALUE } from "./openclaw-exec-env.js";
import { OPENCLAW_CHANNEL_CONTEXT_ENV_VAR, OPENCLAW_CLI_ENV_VALUE } from "./openclaw-exec-env.js";
function findSystemCommandPath(command: string) {
if (process.platform === "win32") {
@@ -1523,6 +1523,46 @@ describe("sanitizeHostExecEnvWithDiagnostics", () => {
expect(result.rejectedOverrideInvalidKeys).toEqual(["BAD-KEY"]);
expect(result.env["ProgramFiles(x86)"]).toBe("D:\\SDKs");
});
it("drops inherited channel context and applies overrides case-insensitively", () => {
const result = sanitizeHostExecEnvWithDiagnostics({
baseEnv: {
[OPENCLAW_CHANNEL_CONTEXT_ENV_VAR]: "stale",
SCOPED_TOKEN: "inherited",
},
overrides: {
scoped_token: "configured",
},
});
expect(result.env).not.toHaveProperty(OPENCLAW_CHANNEL_CONTEXT_ENV_VAR);
expect(
Object.entries(result.env).filter(([key]) => key.toUpperCase() === "SCOPED_TOKEN"),
).toEqual([["scoped_token", "configured"]]);
});
it("preserves case-distinct inherited variables when no override targets them", () => {
const result = sanitizeHostExecEnvWithDiagnostics({
baseEnv: {
HTTP_PROXY_ALIAS: "upper",
http_proxy_alias: "lower",
},
});
expect(result.env.HTTP_PROXY_ALIAS).toBe("upper");
expect(result.env.http_proxy_alias).toBe("lower");
});
it("rejects prototype keys without mutating result objects", () => {
const result = sanitizeHostExecEnvWithDiagnostics({
baseEnv: {},
overrides: Object.fromEntries([["__proto__", "polluted"]]),
});
expect(result.rejectedOverrideBlockedKeys).toEqual(["__proto__"]);
expect(Object.hasOwn(result.env, "__proto__")).toBe(false);
expect(Object.getPrototypeOf(result.env)).toBe(Object.prototype);
});
});
describe("normalizeEnvVarKey", () => {

View File

@@ -1,7 +1,12 @@
// Filters host environment variables before passing them to runtimes.
import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { HOST_ENV_SECURITY_POLICY } from "./host-env-security-policy.js";
import { markOpenClawExecEnv } from "./openclaw-exec-env.js";
import {
markOpenClawExecEnv,
OPENCLAW_CHANNEL_CONTEXT_ENV_VAR,
OPENCLAW_CLI_ENV_VAR,
} from "./openclaw-exec-env.js";
import { isBlockedObjectKey } from "./prototype-keys.js";
const PORTABLE_ENV_VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
const WINDOWS_COMPAT_OVERRIDE_ENV_VAR_KEY = /^[A-Za-z_][A-Za-z0-9_()]*$/;
@@ -138,6 +143,52 @@ export function isDangerousHostEnvOverrideVarName(rawKey: string): boolean {
return HOST_DANGEROUS_OVERRIDE_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix));
}
export type ConfiguredExecEnvKeyValidation =
| { ok: true; key: string; caseFoldedKey: string }
| { ok: false; reason: string };
/** Validates operator-configured agent exec env keys against the host security boundary. */
export function validateConfiguredExecEnvKey(rawKey: string): ConfiguredExecEnvKeyValidation {
const key = normalizeEnvVarKey(rawKey, { portable: true });
if (!key || key !== rawKey) {
return { ok: false, reason: "must be a portable environment variable name" };
}
const upper = key.toUpperCase();
if (isBlockedObjectKey(key)) {
return { ok: false, reason: "uses a blocked prototype key" };
}
if (upper === "PATH") {
return { ok: false, reason: "PATH is controlled by tools.exec.pathPrepend" };
}
if (upper === OPENCLAW_CLI_ENV_VAR || upper === OPENCLAW_CHANNEL_CONTEXT_ENV_VAR) {
return { ok: false, reason: "is reserved by OpenClaw" };
}
if (isDangerousHostEnvVarName(upper) || isDangerousHostEnvOverrideVarName(upper)) {
return { ok: false, reason: "is blocked by the host exec environment policy" };
}
return { ok: true, key, caseFoldedKey: upper };
}
/** Sets an env value while making later layers win across case variants on every platform. */
export function setCaseInsensitiveEnvValue(
env: Record<string, string>,
key: string,
value: string,
): void {
const foldedKey = key.toUpperCase();
for (const existingKey of Object.keys(env)) {
if (existingKey !== key && existingKey.toUpperCase() === foldedKey) {
delete env[existingKey];
}
}
Object.defineProperty(env, key, {
configurable: true,
enumerable: true,
writable: true,
value,
});
}
function listNormalizedEnvEntries(
source: Record<string, string | undefined>,
options?: { portable?: boolean },
@@ -186,6 +237,9 @@ export function sanitizeHostInheritedEnvEntry(
if (!key) {
return null;
}
if (isBlockedObjectKey(key) || key.toUpperCase() === OPENCLAW_CHANNEL_CONTEXT_ENV_VAR) {
return null;
}
// Preserve inherited Git allowlists without widening malformed or unsafe entries by deletion.
// Protocols outside Git's safe default set are removed instead of being passed through.
if (key.toUpperCase() === GIT_ALLOW_PROTOCOL_ENV_KEY) {
@@ -238,6 +292,10 @@ function sanitizeHostEnvOverridesWithDiagnostics(params?: {
continue;
}
const upper = normalized.toUpperCase();
if (isBlockedObjectKey(normalized)) {
rejectedBlocked.push(normalized);
continue;
}
// PATH is part of the security boundary (command resolution + safe-bin checks). Never allow
// request-scoped PATH overrides from agents/gateways.
if (blockPathOverrides && upper === "PATH") {
@@ -248,7 +306,7 @@ function sanitizeHostEnvOverridesWithDiagnostics(params?: {
rejectedBlocked.push(upper);
continue;
}
acceptedOverrides[normalized] = value;
setCaseInsensitiveEnvValue(acceptedOverrides, normalized, value);
}
return {
@@ -272,7 +330,12 @@ export function sanitizeHostExecEnvWithDiagnostics(params?: {
continue;
}
const [sanitizedKey, sanitizedValue] = sanitizedEntry;
merged[sanitizedKey] = sanitizedValue;
Object.defineProperty(merged, sanitizedKey, {
configurable: true,
enumerable: true,
writable: true,
value: sanitizedValue,
});
}
const overrideResult = sanitizeHostEnvOverridesWithDiagnostics({
@@ -281,7 +344,7 @@ export function sanitizeHostExecEnvWithDiagnostics(params?: {
});
if (overrideResult.acceptedOverrides) {
for (const [key, value] of Object.entries(overrideResult.acceptedOverrides)) {
merged[key] = value;
setCaseInsensitiveEnvValue(merged, key, value);
}
}

View File

@@ -1,6 +1,9 @@
/** Process env key that marks child commands as launched by the OpenClaw CLI. */
export const OPENCLAW_CLI_ENV_VAR = "OPENCLAW_CLI";
/** Reserved exec env key carrying trusted sender/chat metadata. */
export const OPENCLAW_CHANNEL_CONTEXT_ENV_VAR = "OPENCLAW_CHANNEL_CONTEXT";
/** Stable marker value used for OpenClaw-launched subprocess detection. */
export const OPENCLAW_CLI_ENV_VALUE = "1";

View File

@@ -21,6 +21,7 @@ const metadataSnapshot = {
workspaceDir: "/resolved-workspace",
};
const loadPluginMetadataSnapshotMock = vi.fn(() => metadataSnapshot);
const isPluginMetadataSnapshotCompatibleMock = vi.fn(() => true);
const getCurrentPluginMetadataSnapshotMock = vi.fn(() => undefined);
const setCurrentPluginMetadataSnapshotMock = vi.fn();
const clearCurrentPluginMetadataSnapshotMock = vi.fn();
@@ -45,6 +46,7 @@ vi.mock("../../agents/agent-scope.js", () => ({
}));
vi.mock("../plugin-metadata-snapshot.js", () => ({
isPluginMetadataSnapshotCompatible: isPluginMetadataSnapshotCompatibleMock,
loadPluginMetadataSnapshot: loadPluginMetadataSnapshotMock,
resolvePluginMetadataSnapshot: loadPluginMetadataSnapshotMock,
}));
@@ -69,6 +71,8 @@ describe("resolvePluginRuntimeLoadContext", () => {
applyPluginAutoEnableMock.mockReset();
getCurrentPluginMetadataSnapshotMock.mockReset();
getCurrentPluginMetadataSnapshotMock.mockReturnValue(undefined);
isPluginMetadataSnapshotCompatibleMock.mockReset();
isPluginMetadataSnapshotCompatibleMock.mockReturnValue(true);
loadPluginMetadataSnapshotMock.mockClear();
getCurrentPluginMetadataSnapshotMock.mockClear();
setCurrentPluginMetadataSnapshotMock.mockClear();

View File

@@ -14,7 +14,10 @@ import {
import { extractPluginInstallRecordsFromInstalledPluginIndex } from "../installed-plugin-index-install-records.js";
import type { PluginLoadOptions } from "../loader.js";
import type { PluginManifestRegistry } from "../manifest-registry.js";
import { resolvePluginMetadataSnapshot } from "../plugin-metadata-snapshot.js";
import {
isPluginMetadataSnapshotCompatible,
resolvePluginMetadataSnapshot,
} from "../plugin-metadata-snapshot.js";
import type { PluginLogger } from "../types.js";
const log = createSubsystemLogger("plugins");
@@ -73,18 +76,16 @@ export function resolvePluginRuntimeLoadContext(
const rawConfig = options?.config ?? getRuntimeConfig();
const rawWorkspaceDir =
options?.workspaceDir ?? resolveAgentWorkspaceDir(rawConfig, resolveDefaultAgentId(rawConfig));
const metadataSnapshot = options?.manifestRegistry
? undefined
: resolvePluginMetadataSnapshot({
config: rawConfig,
env,
workspaceDir: rawWorkspaceDir,
allowWorkspaceScopedCurrent: true,
});
const manifestRegistry = options?.manifestRegistry ?? metadataSnapshot?.manifestRegistry;
const installRecords = metadataSnapshot
? extractPluginInstallRecordsFromInstalledPluginIndex(metadataSnapshot.index)
: undefined;
const initialMetadataSnapshot =
options?.manifestRegistry === undefined
? resolvePluginMetadataSnapshot({
config: rawConfig,
env,
workspaceDir: rawWorkspaceDir,
allowWorkspaceScopedCurrent: true,
})
: undefined;
const manifestRegistry = options?.manifestRegistry ?? initialMetadataSnapshot?.manifestRegistry;
const activationSourceConfig = resolvePluginActivationSourceConfig({
config: rawConfig,
activationSourceConfig: options?.activationSourceConfig,
@@ -93,11 +94,33 @@ export function resolvePluginRuntimeLoadContext(
config: rawConfig,
env,
manifestRegistry,
discovery: metadataSnapshot?.discovery,
discovery: initialMetadataSnapshot?.discovery,
});
const config = autoEnabled.config;
const workspaceDir =
options?.workspaceDir ?? resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
const metadataSnapshot =
options?.manifestRegistry !== undefined
? undefined
: initialMetadataSnapshot &&
isPluginMetadataSnapshotCompatible({
snapshot: initialMetadataSnapshot,
config,
env,
workspaceDir,
})
? initialMetadataSnapshot
: resolvePluginMetadataSnapshot({
config,
env,
workspaceDir,
allowWorkspaceScopedCurrent: true,
...(initialMetadataSnapshot ? { index: initialMetadataSnapshot.index } : {}),
});
const finalManifestRegistry = options?.manifestRegistry ?? metadataSnapshot?.manifestRegistry;
const installRecords = metadataSnapshot
? extractPluginInstallRecordsFromInstalledPluginIndex(metadataSnapshot.index)
: undefined;
if (metadataSnapshot) {
// Reusable snapshots stay available to later manifest-policy lookups for this runtime load.
if (isReusableCurrentPluginMetadataSnapshot(metadataSnapshot)) {
@@ -119,7 +142,7 @@ export function resolvePluginRuntimeLoadContext(
workspaceDir,
env,
logger: options?.logger ?? createPluginRuntimeLoaderLogger(),
manifestRegistry,
...(finalManifestRegistry ? { manifestRegistry: finalManifestRegistry } : {}),
installRecords,
};
}

View File

@@ -27,7 +27,7 @@ function resolveRuntimeSubagentMode(
return "default";
}
function installStandaloneRegistry(
function installStandaloneRuntimePluginRegistry(
registry: PluginRegistry,
params: {
loadOptions: PluginLoadOptions;
@@ -99,7 +99,7 @@ export function ensureStandaloneRuntimePluginRegistryLoaded(params: {
return registry;
}
installStandaloneRegistry(registry, {
installStandaloneRuntimePluginRegistry(registry, {
loadOptions: params.loadOptions,
surface,
});

View File

@@ -2147,6 +2147,62 @@ describe("resolvePluginTools optional tools", () => {
expect(factory).toHaveBeenCalledTimes(2);
});
it("retains cold-loaded plugin tools for cached descriptor execution after active registry replacement", async () => {
const factory = vi.fn(() => makeTool("cached_lifecycle_tool"));
const gatewayRegistry = setRegistry([
{
pluginId: "cache-lifecycle-test",
optional: false,
source: "/tmp/cache-lifecycle-test.js",
names: ["cached_lifecycle_tool"],
factory,
},
]);
const first = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["cached_lifecycle_tool"],
allowGatewaySubagentBinding: true,
}),
);
const [tool] = resolvePluginTools(
createResolveToolsParams({
toolAllowlist: ["cached_lifecycle_tool"],
allowGatewaySubagentBinding: true,
}),
);
expectResolvedToolNames(first, ["cached_lifecycle_tool"]);
expect(tool?.name).toBe("cached_lifecycle_tool");
expect(factory).toHaveBeenCalledTimes(1);
const unrelatedEntry: MockRegistryToolEntry = {
pluginId: "unrelated-live",
optional: false,
source: "/tmp/unrelated-live.js",
names: ["unrelated_live_tool"],
factory: () => makeTool("unrelated_live_tool"),
};
const replacementRegistry = createToolRegistry([unrelatedEntry]);
replacementRegistry.plugins.push({ id: "cache-lifecycle-test", status: "loaded" });
setActivePluginRegistry?.(replacementRegistry as never, "provider-runtime", "default", "/tmp");
resolveRuntimePluginRegistryMock.mockReturnValue(undefined);
loadOpenClawPluginsMock.mockReset();
loadOpenClawPluginsMock
.mockReturnValueOnce(gatewayRegistry)
.mockReturnValue(createToolRegistry([]));
await expect(tool?.execute("call-1", {}, undefined)).resolves.toEqual({
content: [{ type: "text", text: "ok" }],
});
await expect(tool?.execute("call-2", {}, undefined)).resolves.toEqual({
content: [{ type: "text", text: "ok" }],
});
expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1);
expect(getActivePluginRegistry?.()).toBe(replacementRegistry);
expect(getActivePluginRegistry?.()?.tools.map((entry) => entry.pluginId)).toContain(
"unrelated-live",
);
});
it("does not reuse cached plugin tool descriptors across sandbox context changes", () => {
const factory = vi.fn((rawCtx: unknown) => {
const ctx = rawCtx as { sandboxed?: boolean };

View File

@@ -31,16 +31,21 @@ import {
capturePluginToolDescriptor,
createPluginToolDescriptorConfigCacheKeyMemo,
readCachedPluginToolDescriptors,
resetPluginToolDescriptorCache as resetCachedPluginToolDescriptors,
type CachedPluginToolDescriptor,
type PluginToolDescriptorConfigCacheKeyMemo,
writeCachedPluginToolDescriptors,
} from "./tool-descriptor-cache.js";
import type { OpenClawPluginToolContext } from "./types.js";
export {
resetPluginToolDescriptorCache,
resetPluginToolDescriptorCache as resetPluginToolFactoryCache,
} from "./tool-descriptor-cache.js";
let cachedDescriptorRuntimeRegistries = new WeakMap<CachedPluginToolDescriptor, PluginRegistry>();
export function resetPluginToolDescriptorCache(): void {
resetCachedPluginToolDescriptors();
cachedDescriptorRuntimeRegistries = new WeakMap();
}
export { resetPluginToolDescriptorCache as resetPluginToolFactoryCache };
/** MCP bridge metadata attached to plugin tools surfaced through agent tool lists. */
export type PluginToolMcpMeta = {
@@ -692,6 +697,10 @@ function createCachedDescriptorPluginTool(params: {
const registry = resolvePluginToolRegistry({
loadOptions,
onlyPluginIds: [pluginId],
retainedRegistry: cachedDescriptorRuntimeRegistries.get(params.descriptor),
onRetainRegistry: (retainedRegistry) => {
cachedDescriptorRuntimeRegistries.set(params.descriptor, retainedRegistry);
},
});
const candidates = registry?.tools.filter((candidate) => candidate.pluginId === pluginId);
if (!candidates || candidates.length === 0) {
@@ -899,6 +908,8 @@ function resolveCachedPluginTools(params: {
function resolvePluginToolRegistry(params: {
loadOptions: PluginLoadOptions;
onlyPluginIds?: readonly string[];
retainedRegistry?: PluginRegistry;
onRetainRegistry?: (registry: PluginRegistry) => void;
}) {
const lookup = {
env: params.loadOptions.env,
@@ -924,7 +935,16 @@ function resolvePluginToolRegistry(params: {
return activeRegistry;
}
if (registryHasScopedPluginTools(params.retainedRegistry, params.onlyPluginIds)) {
return params.retainedRegistry;
}
const forceStandaloneLoad = Boolean(channelRegistry || activeRegistry);
const shouldRetainColdLoadedToolRegistry =
forceStandaloneLoad &&
params.loadOptions.activate === false &&
params.loadOptions.toolDiscovery === true &&
params.onRetainRegistry !== undefined;
const standaloneRegistry = ensureStandaloneRuntimePluginRegistryLoaded({
surface: "active",
forceLoad: forceStandaloneLoad,
@@ -933,6 +953,9 @@ function resolvePluginToolRegistry(params: {
loadOptions: params.loadOptions,
});
if (registryHasScopedPluginTools(standaloneRegistry, params.onlyPluginIds)) {
if (shouldRetainColdLoadedToolRegistry) {
params.onRetainRegistry?.(standaloneRegistry);
}
return standaloneRegistry;
}
return standaloneRegistry ?? channelRegistry ?? activeRegistry;

View File

@@ -0,0 +1,97 @@
/** Tests SecretRef materialization for per-agent exec environments. */
import { describe, expect, it } from "vitest";
import { asConfig, setupSecretsRuntimeSnapshotTestHooks } from "./runtime.test-support.ts";
const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks();
describe("secrets runtime per-agent exec env", () => {
it("resolves each configured exec env SecretRef into the active snapshot", async () => {
const sourceConfig = asConfig({
agents: {
list: [
{
id: "referrals",
tools: {
exec: {
inheritHostEnv: false,
env: {
GREENHOUSE_TOKEN: {
source: "env",
provider: "default",
id: "REFERRALS_GREENHOUSE_TOKEN",
},
},
},
},
},
],
},
});
const snapshot = await prepareSecretsRuntimeSnapshot({
config: sourceConfig,
env: { REFERRALS_GREENHOUSE_TOKEN: "gh-scoped-token" },
includeAuthStoreRefs: false,
loadablePluginOrigins: new Map(),
});
expect(snapshot.config.agents?.list?.[0]?.tools?.exec?.env?.GREENHOUSE_TOKEN).toBe(
"gh-scoped-token",
);
expect(sourceConfig.agents?.list?.[0]?.tools?.exec?.env?.GREENHOUSE_TOKEN).toEqual({
source: "env",
provider: "default",
id: "REFERRALS_GREENHOUSE_TOKEN",
});
});
it("fails atomically when an active exec env SecretRef is unresolved", async () => {
await expect(
prepareSecretsRuntimeSnapshot({
config: asConfig({
agents: {
list: [
{
id: "referrals",
tools: {
exec: {
env: {
GREENHOUSE_TOKEN: {
source: "env",
provider: "default",
id: "MISSING_REFERRALS_GREENHOUSE_TOKEN",
},
},
},
},
},
],
},
}),
env: {},
includeAuthStoreRefs: false,
loadablePluginOrigins: new Map(),
}),
).rejects.toThrow(/MISSING_REFERRALS_GREENHOUSE_TOKEN/);
});
it("does not resolve agent exec env refs that are inactive on a fixed node host", async () => {
const ref = {
source: "env" as const,
provider: "default",
id: "NODE_HOST_ONLY_TOKEN",
};
const snapshot = await prepareSecretsRuntimeSnapshot({
config: asConfig({
tools: { exec: { host: "node" } },
agents: {
list: [{ id: "remote", tools: { exec: { env: { NODE_TOKEN: ref } } } }],
},
}),
env: {},
includeAuthStoreRefs: false,
loadablePluginOrigins: new Map(),
});
expect(snapshot.config.agents?.list?.[0]?.tools?.exec?.env?.NODE_TOKEN).toEqual(ref);
});
});

View File

@@ -108,6 +108,35 @@ function collectSkillAssignments(params: {
}
}
function collectAgentExecEnvAssignments(params: {
config: OpenClawConfig;
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
for (const [agentIndex, agent] of (params.config.agents?.list ?? []).entries()) {
const env = agent.tools?.exec?.env;
if (!env) {
continue;
}
const effectiveHost = agent.tools?.exec?.host ?? params.config.tools?.exec?.host ?? "auto";
const active = effectiveHost !== "node";
for (const [envKey, envValue] of Object.entries(env)) {
collectSecretInputAssignment({
value: envValue,
path: `agents.list.${agentIndex}.tools.exec.env.${envKey}`,
expected: "string",
defaults: params.defaults,
context: params.context,
active,
inactiveReason: "agent exec env is unsupported for host=node.",
apply: (value) => {
env[envKey] = value as string;
},
});
}
}
}
function collectAgentMemorySearchAssignments(params: {
config: OpenClawConfig;
defaults: SecretDefaults | undefined;
@@ -686,6 +715,7 @@ export function collectCoreConfigAssignments(params: {
});
}
collectAgentExecEnvAssignments(params);
collectAgentMemorySearchAssignments(params);
collectTalkAssignments(params);
collectGatewayAssignments(params);

View File

@@ -1,6 +1,6 @@
/** Builds the static and plugin-derived registry of secret migration targets. */
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js";
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
import { loadChannelSecretContractApiForRecord } from "./channel-contract-api.js";
import type { SecretTargetRegistryEntry } from "./target-registry-types.js";
@@ -173,6 +173,17 @@ const CORE_SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [
includeInConfigure: true,
includeInAudit: true,
},
{
id: "agents.list[].tools.exec.env.*",
targetType: "agents.list[].tools.exec.env.*",
configFile: "openclaw.json",
pathPattern: "agents.list[].tools.exec.env.*",
secretShape: SECRET_INPUT_SHAPE,
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "cron.webhookToken",
targetType: "cron.webhookToken",

View File

@@ -12,7 +12,7 @@ const FAST_LEVELS = ["status", "auto", "on", "off"];
const REASONING_LEVELS = ["on", "off"];
const ELEVATED_LEVELS = ["on", "off", "ask", "full"];
const ACTIVATION_LEVELS = ["mention", "always"];
const USAGE_FOOTER_LEVELS = ["off", "tokens", "full"];
const USAGE_FOOTER_LEVELS = ["off", "tokens", "full", "reset", "inherit", "clear", "default"];
export type ParsedCommand = {
name: string;
@@ -196,7 +196,7 @@ export function helpText(options: SlashCommandOptions = {}): string {
"/verbose <on|off>",
"/trace <on|off>",
"/reasoning <on|off>",
"/usage <off|tokens|full>",
"/usage <off|tokens|full|reset|inherit|clear|default>",
"/elevated <on|off|ask|full>",
"/elev <on|off|ask|full>",
"/activation <mention|always>",

View File

@@ -1442,4 +1442,59 @@ describe("tui command handlers", () => {
expect(openOverlay).toHaveBeenCalledTimes(1);
});
it("/usage reset clears the stale local responseUsage after the gateway patch", async () => {
// Regression: after /usage reset sends responseUsage: null and the gateway deletes
// the field, applySessionInfoFromPatch skips absent fields. The command handler must
// explicitly clear the stale local value so no-arg cycles and subsequent refreshes
// start from the correct effective mode.
const patchSession = vi.fn().mockResolvedValue({
entry: {
// Gateway returns the updated entry without the responseUsage field (it was deleted).
sessionId: "sess-reset",
updatedAt: Date.now(),
},
});
const { handleCommand, addSystem, state } = createHarness({ patchSession });
const sessionInfo = state.sessionInfo as {
responseUsage?: string;
effectiveResponseUsage?: string;
};
sessionInfo.responseUsage = "tokens";
sessionInfo.effectiveResponseUsage = "tokens";
await handleCommand("/usage reset");
expect(patchSession).toHaveBeenCalledWith(
expect.objectContaining({ responseUsage: null }),
);
expect(addSystem).toHaveBeenCalledWith("usage footer: reset to default");
// Both stale local values must be cleared so the toggle/display is not stale
// until refreshSessionInfo() repopulates the inherited default.
expect(sessionInfo.responseUsage).toBeUndefined();
expect(sessionInfo.effectiveResponseUsage).toBeUndefined();
});
it("/usage no-arg toggle cycles from effectiveResponseUsage when the session override is unset", async () => {
// Regression: when the session has no explicit responseUsage but the config default
// is "tokens", the toggle should cycle tokens→full, not off→tokens.
const patchSession = vi.fn().mockResolvedValue({
entry: { sessionId: "sess-toggle", updatedAt: Date.now(), responseUsage: "full" },
});
const { handleCommand, addSystem, state } = createHarness({ patchSession });
// No raw responseUsage on session, but effective (from config default) is "tokens".
const sessionInfo = state.sessionInfo as {
responseUsage?: string;
effectiveResponseUsage?: string;
};
sessionInfo.responseUsage = undefined;
sessionInfo.effectiveResponseUsage = "tokens";
await handleCommand("/usage");
expect(patchSession).toHaveBeenCalledWith(
expect.objectContaining({ responseUsage: "full" }),
);
expect(addSystem).toHaveBeenCalledWith("usage footer: full");
});
});

View File

@@ -11,6 +11,7 @@ import {
} from "../auto-reply/reply/commands-goal.js";
import {
formatThinkingLevels,
isSessionDefaultDirectiveValue,
normalizeUsageDisplay,
resolveResponseUsageMode,
} from "../auto-reply/thinking.js";
@@ -604,19 +605,37 @@ export function createCommandHandlers(context: CommandHandlerContext) {
}
break;
case "usage": {
const normalized = args ? normalizeUsageDisplay(args) : undefined;
if (args && !normalized) {
chatLog.addSystem("usage: /usage <off|tokens|full>");
const isReset = args ? isSessionDefaultDirectiveValue(args) : false;
const normalized = args && !isReset ? normalizeUsageDisplay(args) : undefined;
if (args && !normalized && !isReset) {
chatLog.addSystem("usage: /usage <off|tokens|full|reset>");
break;
}
const currentRaw = state.sessionInfo.responseUsage;
const current = resolveResponseUsageMode(currentRaw);
if (isReset) {
try {
const result = await client.patchSession({
...currentSessionPatchTarget(),
responseUsage: null,
});
chatLog.addSystem("usage footer: reset to default");
applySessionInfoFromPatch(result);
delete state.sessionInfo.responseUsage;
delete state.sessionInfo.effectiveResponseUsage;
await refreshSessionInfo();
} catch (err) {
chatLog.addSystem(`usage failed: ${String(err)}`);
}
break;
}
const current =
state.sessionInfo.effectiveResponseUsage ??
resolveResponseUsageMode(state.sessionInfo.responseUsage);
const next =
normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off");
try {
const result = await client.patchSession({
...currentSessionPatchTarget(),
responseUsage: next === "off" ? null : next,
responseUsage: next,
});
chatLog.addSystem(`usage footer: ${next}`);
applySessionInfoFromPatch(result);

View File

@@ -89,6 +89,7 @@ function sessionInfoUiEquals(left: SessionInfo, right: SessionInfo): boolean {
left.outputTokens === right.outputTokens &&
left.totalTokens === right.totalTokens &&
left.responseUsage === right.responseUsage &&
left.effectiveResponseUsage === right.effectiveResponseUsage &&
left.displayName === right.displayName &&
goalEquals(left.goal, right.goal)
);
@@ -245,6 +246,9 @@ export function createSessionActions(context: SessionActionContext) {
if (entry?.responseUsage !== undefined) {
next.responseUsage = entry.responseUsage;
}
if (entry?.effectiveResponseUsage !== undefined) {
next.effectiveResponseUsage = entry.effectiveResponseUsage;
}
if (entry?.inputTokens !== undefined) {
next.inputTokens = entry.inputTokens;
}

View File

@@ -92,6 +92,8 @@ export type SessionInfo = {
totalTokensFresh?: boolean;
goal?: SessionGoal;
responseUsage?: ResponseUsageMode;
/** Resolved effective usage mode (session override → channel config → default → off). Set by the gateway; the TUI uses this for no-arg toggle cycles so the cycle starts from the effective visible mode rather than the raw session value. */
effectiveResponseUsage?: ResponseUsageMode;
updatedAt?: number | null;
displayName?: string;
};

View File

@@ -6,6 +6,7 @@ import type { ConfigUiHints } from "../../../src/config/schema.js";
export const redactSnapshotTestHints: ConfigUiHints = {
"agents.defaults.memorySearch.remote.apiKey": { sensitive: true },
"agents.list[].memorySearch.remote.apiKey": { sensitive: true },
"agents.list[].tools.exec.env.*": { sensitive: true },
"broadcast.apiToken[]": { sensitive: true },
"env.GROQ_API_KEY": { sensitive: true },
"gateway.auth.password": { sensitive: true },

View File

@@ -297,6 +297,7 @@ const SESSION_EVENT_ROW_FIELDS = [
"compactionCheckpointCount",
"contextTokens",
"displayName",
"effectiveResponseUsage",
"endedAt",
"elevatedLevel",
"effectiveFastMode",

View File

@@ -506,6 +506,8 @@ export type GatewaySessionRow = {
childSessions?: string[];
model?: string;
modelProvider?: string;
/** Resolved effective usage-footer mode (session override → per-channel config → default → off), carried from gateway session rows/events. */
effectiveResponseUsage?: "on" | "off" | "tokens" | "full";
agentRuntime?: GatewayAgentRuntime;
contextTokens?: number;
compactionCheckpointCount?: number;