Compare commits

..

16 Commits

Author SHA1 Message Date
Dallin Romney
f29dbd3ebd test(qa): speed up smoke profile (#96340) 2026-06-24 09:30:59 -07:00
xingzhou
3217165be7 fix(telegram): preserve inline buttons for empty capabilities (#96468)
Merged via squash.

Prepared head SHA: 5e55b5dd30
Co-authored-by: zhangguiping-xydt <275915537+zhangguiping-xydt@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-25 00:09:45 +08:00
Vincent Koc
dbe2802cdc fix(sdk): refresh API baseline hash 2026-06-25 00:04:34 +08:00
ly-wang19
5f25651fd9 fix(ui): roll usage-metrics formatTokens over to "M" at the 999,950 boundary (#96450)
Merged via squash.

Prepared head SHA: fe9881afe7
Co-authored-by: ly-wang19 <94427531+ly-wang19@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-25 00:03:16 +08:00
joshavant
d7c69da6a6 docs(ios): document live activity review flow 2026-06-24 11:00:04 -05:00
joshavant
e77994ed5a fix(ios): clarify camera purpose string 2026-06-24 11:00:04 -05:00
ly-wang19
db3307b02a fix(canvas): stop self-closing embed from starting a greedy block match (#96449)
Merged via squash.

Prepared head SHA: 7253bb298e
Co-authored-by: ly-wang19 <94427531+ly-wang19@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 23:46:20 +08:00
linhongkuan
6b1755aa2b fix(media-core): accept unpadded inline base64 images (#96437)
Merged via squash.

Prepared head SHA: dc4693b7bf
Co-authored-by: lin-hongkuan <234943746+lin-hongkuan@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 23:44:05 +08:00
Yufeng He
fa2379dbc8 fix(telegram): clip progress text on code-point boundaries to avoid lone surrogates (#96456)
Merged via squash.

Prepared head SHA: 765d6c08ac
Co-authored-by: he-yufeng <40085740+he-yufeng@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 23:27:59 +08:00
Galin Iliev
ce6d97d580 fix(plugins): suppress metadata cache hit scan spans (#86796)
Merged via squash.

Prepared head SHA: a4907bf285
Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 23:09:12 +08:00
Alix-007
d1c2934d0d fix(ollama): bound model-discovery JSON response reads (#96027)
* fix(ollama): bound model-discovery JSON response reads

The /api/tags and /api/show discovery reads in extensions/ollama/src/provider-models.ts
parsed their HTTP responses with an unbounded await response.json(). Ollama base URLs
are user-supplied and can point at remote/cloud endpoints, so a hostile or buggy server
(or one reachable via SSRF) could stream an unbounded or never-ending JSON body and drive
model discovery into OOM.

Route both reads through the shared @openclaw/media-core byte-bounded reader
(readResponseWithLimit, re-exported via openclaw/plugin-sdk/response-limit-runtime) under
a single 16 MiB cap before JSON.parse, cancelling the stream on overflow. Overflow throws a
bounded error that the existing fail-soft handlers swallow, so a capped endpoint degrades
gracefully: /api/tags returns { reachable: false, models: [] } and /api/show returns {}.

Symmetric counterpart to the #95103/#95108 response-limit campaign.

AI-assisted.

* fix(ollama): reuse shared bounded JSON reader for model discovery

Replace the local readOllamaDiscoveryJson helper with the shared
readProviderJsonResponse (from openclaw/plugin-sdk/provider-http), which
already enforces the 16 MiB cap, cancels the stream on overflow, and wraps
malformed JSON with the caller label. The /api/tags and /api/show discovery
reads now go through it directly while keeping the existing fail-soft
handlers ({ reachable: false, models: [] } and {}).

Add a focused regression test: when a discovery stream exceeds the JSON byte
cap, fetchOllamaModels returns { reachable: false, models: [] },
queryOllamaModelShowInfo returns {}, and the bounded reader cancels the body
mid-flight so less than the full advertised stream is read.
2026-06-24 10:58:13 -04:00
Alix-007
605aede38c fix(exa): bound untrusted search JSON response reads (#96038)
Exa search success responses were read via an unbounded `await
response.json()`, so a misbehaving or hostile endpoint could stream an
arbitrarily large body into memory before parsing. Read the success
body through the shared bounded reader (16 MiB cap, the same limit other
bundled providers use) and cancel the stream on overflow. This mirrors
the error-body bound already in place and the #95103/#95108 response
-limit campaign on the success-JSON side.

AI-assisted.
2026-06-24 10:57:37 -04:00
Alix-007
6163b1977b fix(parallel): bound successful web-search JSON response reads (#96035)
* fix(parallel): bound successful web-search JSON response reads

The Parallel web_search provider parsed its /v1/search success body with an
unbounded await res.json(). The body comes from an external web-search
upstream, so a hostile or malfunctioning endpoint streaming an unbounded JSON
payload could force the runtime to buffer the whole response before parsing,
creating memory pressure or a hang on the provider path.

Read the success body through the shared readProviderJsonResponse helper with a
16 MiB cap (matching the provider JSON cap from #95218); on overflow the stream
is cancelled and a bounded error is thrown. The error-body path was already
bounded (readResponseTextLimited, 8 KiB). Symmetric follow-up to the
#95103/#95108 response-limit campaign.

* docs(parallel): drop upstream PR ref from response-cap comment

Replace the PR-specific '#95218' annotation with a neutral description of
the shared provider JSON cap so the comment stays accurate independent of
upstream PR numbering.
2026-06-24 10:57:24 -04:00
Vincent Koc
eabc12b7d6 fix(sandbox): install supported node in common image 2026-06-24 22:54:00 +08:00
Josh Lehman
b58e6e0734 refactor: route session status through session accessors (#96460) 2026-06-24 07:44:19 -07:00
Vincent Koc
d83cd282c6 fix(qa): record checked-out ref in evidence (#96434)
Merged via squash.

Prepared head SHA: 86b3df6e59
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-24 22:37:41 +08:00
94 changed files with 1399 additions and 1323 deletions

View File

@@ -57,11 +57,10 @@ jobs:
BASE_IMAGE="openclaw-sandbox-smoke-base:bookworm-slim" \
TARGET_IMAGE="openclaw-sandbox-common-smoke:bookworm-slim" \
PACKAGES="ca-certificates" \
INSTALL_PNPM=0 \
INSTALL_BUN=0 \
INSTALL_BREW=0 \
FINAL_USER=sandbox \
scripts/sandbox-common-setup.sh
u="$(timeout --kill-after=30s 2m docker run --rm openclaw-sandbox-common-smoke:bookworm-slim sh -lc 'id -un')"
test "$u" = "sandbox"
timeout --kill-after=30s 2m docker run --rm openclaw-sandbox-common-smoke:bookworm-slim sh -lc \
'set -e; test "$(id -un)" = sandbox; node --version; pnpm --version'

View File

@@ -105,6 +105,19 @@ Reopen OpenClaw, confirm Talk is still active, then tap `Stop Talk`.
4. Confirm at least one `agent` row is connected.
5. Confirm the iPhone review device appears in the connected instances list.
## Live Activity / Dynamic Island
1. Tap `Settings`.
2. Tap `Reconnect`.
3. Immediately send OpenClaw to the background by returning to the Home Screen
or locking the iPhone.
4. Watch the Lock Screen or Dynamic Island while the Gateway reconnects.
Expected result: while reconnecting, iOS can show an `OpenClaw` Live Activity
with connection status such as `Connecting...` or `Reconnecting...`. On a fast
network this status may be brief because OpenClaw ends the Live Activity after
the Gateway reconnects successfully.
## Push Notification
1. Tap the `Chat` tab.

View File

@@ -57,7 +57,7 @@
<key>NSCalendarsWriteOnlyAccessUsageDescription</key>
<string>OpenClaw uses your calendars to add events when you enable calendar access.</string>
<key>NSCameraUsageDescription</key>
<string>OpenClaw can capture photos or short video clips when requested via the gateway.</string>
<string>OpenClaw uses the camera when you scan a Gateway setup QR code or ask your paired Gateway or assistant to capture a photo or short video from this iPhone, for example to connect to your Gateway or show your assistant a document, device screen, or workspace.</string>
<key>NSContactsUsageDescription</key>
<string>OpenClaw uses your contacts so you can search and reference people while using the assistant.</string>
<key>NSLocalNetworkUsageDescription</key>

View File

@@ -156,7 +156,7 @@ targets:
NSAllowsLocalNetworking: true
NSBonjourServices:
- _openclaw-gw._tcp
NSCameraUsageDescription: OpenClaw can capture photos or short video clips when requested via the gateway.
NSCameraUsageDescription: OpenClaw uses the camera when you scan a Gateway setup QR code or ask your paired Gateway or assistant to capture a photo or short video from this iPhone, for example to connect to your Gateway or show your assistant a document, device screen, or workspace.
NSCalendarsUsageDescription: OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.
NSCalendarsFullAccessUsageDescription: OpenClaw uses your calendars to show events and scheduling context when you enable calendar access.
NSCalendarsWriteOnlyAccessUsageDescription: OpenClaw uses your calendars to add events when you enable calendar access.

View File

@@ -1,4 +1,4 @@
1b953a19c347a27a0f9e856f23769b0c48d051354be4c88778c215231817fe8a config-baseline.json
f3fcfb358d8b8a1f0fa8676090339ff8df1b28ef6c7e80705a979a5c70e2a323 config-baseline.core.json
f5a5855ddd7aa8c23a732f257eceaa20fd163b1d5f342c909f4aef15aa8643cf config-baseline.json
b8dffdb1a328aaf728a0707ab04d21c65f1a225a2360042e10832aa608699716 config-baseline.core.json
671979e86e4c4f59415d0a20879e838f9bbd883b3d29eeb02cb5131db8d187fe config-baseline.channel.json
94529978588d6e3776a86780b22cf9ff46a6f9957f2f178d3829403fad451ca7 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
0418a175983d6e17f535ebb49d07371ceed57c7002f8991113d548f02b1d17d1 plugin-sdk-api-baseline.json
319e947cff12d9c2c5781b6f97f9b6b1c4f8a251dc1e87703c534a37614325cf plugin-sdk-api-baseline.jsonl
8736e8cf5200a1dda3e3257d91de87510097a2b8369ce06c9891dbbf823863c4 plugin-sdk-api-baseline.json
4902be589e5ac9d77014e5b1473e8e4345def7817ae8ea7b205959d4d604c981 plugin-sdk-api-baseline.jsonl

View File

@@ -204,55 +204,6 @@ 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

@@ -415,7 +415,7 @@ If you installed OpenClaw via `npm install -g openclaw`, use the inline `docker
</Step>
<Step title="Optional: build the common image">
For a more functional sandbox image with common tooling (for example `curl`, `jq`, `nodejs`, `python3`, `git`):
For a more functional sandbox image with common tooling (for example `curl`, `jq`, Node 24, pnpm, `python3`, and `git`):
From a source checkout:

View File

@@ -525,47 +525,6 @@ 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,7 +37,6 @@ 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,13 +29,6 @@
"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

@@ -22,8 +22,7 @@ Working directory for the command.
</ParamField>
<ParamField path="env" type="object">
Key/value environment overrides. Per-agent configured values are applied after
these model-supplied values.
Key/value environment overrides merged on top of the inherited environment.
</ParamField>
<ParamField path="yieldMs" type="number" default="10000">
@@ -90,7 +89,6 @@ 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.
@@ -115,8 +113,6 @@ 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`)
@@ -145,9 +141,7 @@ Example:
### PATH handling
- `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
- `host=gateway`: merges your login-shell `PATH` into the exec environment. `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

@@ -20,6 +20,7 @@ import {
wrapWebContent,
writeCachedSearchPayload,
} from "openclaw/plugin-sdk/provider-web-search";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
@@ -30,6 +31,10 @@ const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "i
const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const;
const EXA_MAX_SEARCH_COUNT = 100;
const EXA_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
// Exa search responses are untrusted external bodies. Cap the success JSON the
// same way other bundled providers do (16 MiB) so a misbehaving or hostile
// endpoint cannot stream an unbounded body into memory before we parse it.
const EXA_SEARCH_JSON_MAX_BYTES = 16 * 1024 * 1024;
type ExaConfig = {
apiKey?: string;
@@ -70,9 +75,17 @@ type ExaSearchResponse = {
results?: unknown;
};
async function readExaSearchResults(response: Response): Promise<ExaSearchResult[]> {
async function readExaSearchResults(
response: Response,
opts?: { maxBytes?: number },
): Promise<ExaSearchResult[]> {
const maxBytes = opts?.maxBytes ?? EXA_SEARCH_JSON_MAX_BYTES;
const bytes = await readResponseWithLimit(response, maxBytes, {
onOverflow: ({ maxBytes: maxBytesLocal }) =>
new Error(`Exa API response exceeds ${maxBytesLocal} bytes`),
});
try {
return normalizeExaResults(await response.json());
return normalizeExaResults(JSON.parse(new TextDecoder().decode(bytes)));
} catch (cause) {
throw new Error("Exa API returned malformed JSON", { cause });
}

View File

@@ -26,6 +26,33 @@ function cancelTrackedResponse(
};
}
function streamingJsonResponse(params: { chunkCount: number; chunkSize: number }): {
response: Response;
getReadCount: () => number;
} {
// Streaming fixture proves an oversized success body stops being read before
// the whole payload is buffered into memory.
let reads = 0;
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (reads >= params.chunkCount) {
controller.close();
return;
}
reads += 1;
controller.enqueue(encoder.encode("a".repeat(params.chunkSize)));
},
});
return {
response: new Response(stream, {
status: 200,
headers: { "content-type": "application/json" },
}),
getReadCount: () => reads,
};
}
describe("exa web search provider", () => {
it("exposes the expected metadata and selection wiring", () => {
const provider = createExaWebSearchProvider();
@@ -265,6 +292,27 @@ describe("exa web search provider", () => {
);
});
it("parses well-formed Exa search JSON under the byte cap", async () => {
const response = new Response(
JSON.stringify({ results: [{ url: "https://example.com", title: "Example" }] }),
{ status: 200, headers: { "content-type": "application/json" } },
);
await expect(testing.readExaSearchResults(response)).resolves.toEqual([
{ url: "https://example.com", title: "Example" },
]);
});
it("caps oversized Exa search JSON instead of buffering the whole body", async () => {
const streamed = streamingJsonResponse({ chunkCount: 64, chunkSize: 1024 });
await expect(
testing.readExaSearchResults(streamed.response, { maxBytes: 4096 }),
).rejects.toThrow(/Exa API response exceeds 4096 bytes/);
expect(streamed.getReadCount()).toBeLessThan(64);
});
it("bounds Exa API error bodies without using response.text()", async () => {
const tracked = cancelTrackedResponse(`${"exa upstream unavailable ".repeat(1024)}tail`, {
status: 503,

View File

@@ -5,7 +5,9 @@ import {
buildOllamaProvider,
buildOllamaModelDefinition,
enrichOllamaModelsWithContext,
fetchOllamaModels,
parseOllamaNumCtxParameter,
queryOllamaModelShowInfo,
resetOllamaModelShowInfoCacheForTest,
resolveOllamaApiBase,
type OllamaTagModel,
@@ -380,4 +382,57 @@ describe("ollama provider models", () => {
expect(parseOllamaNumCtxParameter('stop "<|eot_id|>"')).toBeUndefined();
expect(parseOllamaNumCtxParameter({ num_ctx: 8192 })).toBeUndefined();
});
it("fails soft and stops reading when discovery streams exceed the JSON byte cap", async () => {
// Larger than the shared 16 MiB readProviderJsonResponse cap so the bounded reader cancels
// the stream mid-flight; if the cap were removed the reader would buffer the whole payload.
const ONE_MIB = 1024 * 1024;
const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap.
const chunk = new Uint8Array(ONE_MIB);
let bytesPulled = 0;
let canceled = false;
const makeOversizedJsonResponse = (): Response => {
bytesPulled = 0;
canceled = false;
let pulled = 0;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (pulled >= TOTAL_CHUNKS) {
controller.close();
return;
}
pulled += 1;
bytesPulled += chunk.length;
controller.enqueue(chunk);
},
cancel() {
canceled = true;
},
});
return new Response(body, {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
vi.stubGlobal(
"fetch",
vi.fn(async () => makeOversizedJsonResponse()),
);
const tags = await fetchOllamaModels("http://127.0.0.1:11434");
expect(tags).toEqual({ reachable: false, models: [] });
expect(canceled).toBe(true);
// Only the bounded prefix is pulled, never the full advertised 32 MiB stream.
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
vi.stubGlobal(
"fetch",
vi.fn(async () => makeOversizedJsonResponse()),
);
const showInfo = await queryOllamaModelShowInfo("http://127.0.0.1:11434", "evil-model:latest");
expect(showInfo).toEqual({});
expect(canceled).toBe(true);
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
});
});

View File

@@ -2,6 +2,7 @@
import { createHash } from "node:crypto";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-onboard";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import {
OLLAMA_DEFAULT_BASE_URL,
@@ -146,11 +147,11 @@ export async function queryOllamaModelShowInfo(
if (!response.ok) {
return {};
}
const data = (await response.json()) as {
const data = await readProviderJsonResponse<{
model_info?: Record<string, unknown>;
capabilities?: unknown;
parameters?: unknown;
};
}>(response, "ollama-provider-models.show");
let contextWindow: number | undefined;
if (data.model_info) {
@@ -314,7 +315,10 @@ export async function fetchOllamaModels(
if (!response.ok) {
return { reachable: true, models: [] };
}
const data = (await response.json()) as OllamaTagsResponse;
const data = await readProviderJsonResponse<OllamaTagsResponse>(
response,
"ollama-provider-models.tags",
);
const models = (data.models ?? []).filter((m) => m.name);
return { reachable: true, models };
} finally {

View File

@@ -1,6 +1,9 @@
import { createRequire } from "node:module";
import { readPluginPackageVersion } from "openclaw/plugin-sdk/extension-shared";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_SEARCH_COUNT,
mergeScopedSearchConfig,
@@ -36,6 +39,12 @@ import {
const PARALLEL_BASE_URL = "https://api.parallel.ai";
const PARALLEL_SEARCH_PATHNAME = "/v1/search";
const PARALLEL_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
// Parallel's /v1/search returns a bounded result set, but the body is external
// (web-search upstream) and untrusted. Cap the successful JSON read so a
// hostile or malfunctioning endpoint streaming an unbounded body cannot force
// the runtime to buffer the whole payload before parsing. 16 MiB matches the
// shared provider JSON cap (readProviderJsonResponse default).
const PARALLEL_SEARCH_RESPONSE_LIMIT_BYTES = 16 * 1024 * 1024;
const require = createRequire(import.meta.url);
const PLUGIN_VERSION = readPluginPackageVersion({ require });
@@ -151,11 +160,9 @@ async function runParallelSearch(params: {
);
throw new Error(`Parallel API error (${res.status}): ${detail || res.statusText}`);
}
try {
return (await res.json()) as ParallelSearchResponse;
} catch (cause) {
throw new Error("Parallel API returned malformed JSON", { cause });
}
return await readProviderJsonResponse<ParallelSearchResponse>(res, "Parallel API", {
maxBytes: PARALLEL_SEARCH_RESPONSE_LIMIT_BYTES,
});
},
);
}
@@ -282,6 +289,7 @@ export const testing = {
resolveParallelSearchCount,
resolveParallelSearchEndpoint,
PARALLEL_ERROR_BODY_LIMIT_BYTES,
PARALLEL_SEARCH_RESPONSE_LIMIT_BYTES,
USER_AGENT,
} as const;

View File

@@ -59,6 +59,40 @@ function cancelTrackedResponse(
};
}
function streamedJsonResponse(params: { chunkCount: number; chunkSize: number }): {
response: Response;
getReadCount: () => number;
wasCanceled: () => boolean;
} {
// Multi-chunk fixture: proves the bounded read stops pulling chunks before
// the whole (here syntactically broken / unbounded) body is buffered, and
// that the stream is cancelled on overflow.
let reads = 0;
let canceled = false;
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (reads >= params.chunkCount) {
controller.close();
return;
}
reads += 1;
controller.enqueue(encoder.encode("a".repeat(params.chunkSize)));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, {
status: 200,
headers: { "Content-Type": "application/json" },
}),
getReadCount: () => reads,
wasCanceled: () => canceled,
};
}
import { testing } from "../test-api.js";
import { createParallelWebSearchProvider as createContractParallelWebSearchProvider } from "../web-search-contract-api.js";
import { createParallelWebSearchProvider } from "./parallel-web-search-provider.js";
@@ -583,6 +617,65 @@ describe("parallel web search provider", () => {
expect(textSpy).not.toHaveBeenCalled();
});
it("bounds successful Parallel JSON bodies instead of buffering the whole response", async () => {
// 200-chunk x 1 MiB body (~200 MiB) caps at 16 MiB: the bounded reader must
// stop pulling chunks and cancel the stream well before draining it, then
// surface a bounded error rather than buffering the whole payload.
const streamed = streamedJsonResponse({ chunkCount: 200, chunkSize: 1024 * 1024 });
endpointMockState.responses.push(streamed.response);
const provider = createParallelWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: { parallel: { apiKey: "par-secret" } },
});
if (!tool) {
throw new Error("Expected tool definition");
}
const error = await tool
.execute({
objective: `parallel-success-body-${Date.now()}-${Math.random()}`,
search_queries: ["openclaw"],
})
.catch((cause: unknown) => cause);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toMatch(
new RegExp(
`Parallel API: JSON response exceeds ${testing.PARALLEL_SEARCH_RESPONSE_LIMIT_BYTES} bytes`,
),
);
// Stopped well before draining all 200 chunks, and cancelled the stream.
expect(streamed.getReadCount()).toBeLessThan(200);
expect(streamed.wasCanceled()).toBe(true);
});
it("parses a well-formed Parallel JSON body under the byte cap", async () => {
endpointMockState.responses.push(
new Response(
JSON.stringify({
search_id: "ok",
session_id: "ok-session",
results: [{ url: "https://example.com/a", title: "A", excerpts: ["alpha"] }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
const provider = createParallelWebSearchProvider();
const tool = provider.createTool({
config: {},
searchConfig: { parallel: { apiKey: "par-secret" } },
});
if (!tool) {
throw new Error("Expected tool definition");
}
const result = (await tool.execute({
objective: `parallel-success-ok-${Date.now()}-${Math.random()}`,
search_queries: ["openclaw"],
})) as { provider?: string; searchId?: string; count?: number };
expect(result).toMatchObject({ provider: "parallel", searchId: "ok", count: 1 });
});
it("does not surface a Parallel-generated sessionId on a cache hit", async () => {
// Unique objective so this test does not collide with the SDK's
// module-level web-search cache across other cases.

View File

@@ -558,6 +558,8 @@ describe("qa cli runtime", () => {
"qa-channel-reconnect-dedupe",
"reaction-edit-delete",
"thread-follow-up",
"claude-cli-provider-capabilities",
"claude-cli-provider-capabilities-subscription",
"image-generation-roundtrip",
"image-understanding-attachment",
"native-image-generation",

View File

@@ -1,4 +1,5 @@
// Qa Lab tests cover QA evidence summary behavior.
import { execFileSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import {
QA_EVIDENCE_SUMMARY_KIND,
@@ -123,6 +124,29 @@ describe("evidence summary", () => {
});
});
it("prefers the checked-out ref over an inherited GitHub event SHA", () => {
const repoRoot = process.cwd();
const checkedOutRef = execFileSync("git", ["rev-parse", "--verify", "HEAD"], {
cwd: repoRoot,
encoding: "utf8",
}).trim();
const evidence = buildQaSuiteEvidenceSummary({
artifactPaths: [],
channelId: "qa-channel",
env: {
GITHUB_SHA: "bd479958c04a1eadbda8b6105e0722588d71e9ad",
} as NodeJS.ProcessEnv,
generatedAt: "2026-06-24T12:00:00.000Z",
primaryModel: "mock-openai/gpt-5.5",
providerMode: "mock-openai",
repoRoot,
scenarioDefinitions: [{ id: "ref-probe", title: "Ref probe" }],
scenarioResults: [{ name: "Ref probe", status: "pass" }],
});
expect(evidence.entries[0]?.execution?.environment.ref).toBe(checkedOutRef);
});
it("builds Telegram live transport evidence entries", () => {
const evidence = buildLiveTransportEvidenceSummary({
artifactPaths: [

View File

@@ -1,4 +1,5 @@
// Qa Lab plugin module implements QA evidence summary behavior.
import { execFileSync } from "node:child_process";
import { z } from "zod";
import { splitQaModelRef } from "./model-selection.js";
import { getQaProvider, type QaProviderMode } from "./providers/index.js";
@@ -288,6 +289,7 @@ type QaEvidenceBuildBase = {
channelDriver?: string;
packageSource?: QaEvidencePackageSource;
profile?: QaEvidenceProfile;
repoRoot?: string;
runner?: string;
};
@@ -388,9 +390,31 @@ function resolveQaEvidenceChannelDriver(params: { env?: NodeJS.ProcessEnv; fallb
return id ? { id } : undefined;
}
function resolveQaEvidenceEnvironment(env: NodeJS.ProcessEnv | undefined) {
function resolveQaEvidenceCheckoutRef(repoRoot?: string) {
try {
const ref = execFileSync("git", ["rev-parse", "--verify", "HEAD"], {
cwd: repoRoot ?? process.cwd(),
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
return ref || undefined;
} catch {
return undefined;
}
}
export function resolveQaEvidenceEnvironment(params: {
env?: NodeJS.ProcessEnv;
repoRoot?: string;
}) {
return {
ref: env?.OPENCLAW_QA_REF?.trim() || env?.GITHUB_SHA?.trim() || null,
// GitHub's GITHUB_SHA describes the workflow event, not necessarily the
// checked-out ref selected by a manual or remote QA run.
ref:
params.env?.OPENCLAW_QA_REF?.trim() ||
resolveQaEvidenceCheckoutRef(params.repoRoot) ||
params.env?.GITHUB_SHA?.trim() ||
null,
os: process.platform,
nodeVersion: process.version,
};
@@ -550,7 +574,10 @@ export function buildQaSuiteEvidenceSummary(
},
): QaEvidenceSummaryJson {
const provider = buildQaEvidenceProvider(params);
const environment = resolveQaEvidenceEnvironment(params.env);
const environment = resolveQaEvidenceEnvironment({
env: params.env,
repoRoot: params.repoRoot,
});
const packageSource = resolveQaEvidenceBuildPackageSource(params);
const runner = resolveQaEvidenceRunner({ env: params.env, fallback: params.runner });
const profile = resolveQaEvidenceProfile({
@@ -622,7 +649,10 @@ function buildTestRunnerEvidenceSummary(
},
): QaEvidenceSummaryJson {
const provider = buildQaEvidenceProvider(params);
const environment = resolveQaEvidenceEnvironment(params.env);
const environment = resolveQaEvidenceEnvironment({
env: params.env,
repoRoot: params.repoRoot,
});
const packageSource = resolveQaEvidenceBuildPackageSource(params);
const runner = resolveQaEvidenceRunner({
env: params.env,
@@ -726,7 +756,10 @@ export function buildLiveTransportEvidenceSummary(
},
): QaEvidenceSummaryJson {
const provider = buildQaEvidenceProvider(params);
const environment = resolveQaEvidenceEnvironment(params.env);
const environment = resolveQaEvidenceEnvironment({
env: params.env,
repoRoot: params.repoRoot,
});
const packageSource = resolveQaEvidenceBuildPackageSource(params);
const runner = resolveQaEvidenceRunner({ env: params.env, fallback: params.runner });
const profile = resolveQaEvidenceProfile({

View File

@@ -1863,6 +1863,7 @@ export async function runDiscordQaLive(params: {
generatedAt: finishedAt,
primaryModel,
providerMode,
repoRoot,
transportId: "discord",
});
await fs.writeFile(

View File

@@ -2037,6 +2037,7 @@ export async function runSlackQaLive(params: {
generatedAt: finishedAt,
primaryModel,
providerMode,
repoRoot,
transportId: "slack",
});
await fs.writeFile(

View File

@@ -2188,6 +2188,7 @@ export async function runTelegramQaLive(params: {
generatedAt: finishedAt,
primaryModel,
providerMode,
repoRoot,
checks: scenarioResults,
transportId: "telegram",
});

View File

@@ -3282,6 +3282,7 @@ export async function runWhatsAppQaLive(params: {
generatedAt: finishedAt,
primaryModel,
providerMode,
repoRoot,
transportId: "whatsapp",
});
await fs.writeFile(

View File

@@ -1846,6 +1846,52 @@ describe("qa mock openai server", () => {
expect(memorySearch.status).toBe(200);
expect(await memorySearch.text()).toContain('"name":"memory_search"');
const memoryGetFromPathOnlySearchResult = await fetch(`${server.baseUrl}/v1/responses`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
stream: true,
input: [
{
role: "user",
content: [
{
type: "input_text",
text: "Memory tools check: what is the hidden project codename stored only in memory? Use memory tools first.",
},
],
},
{
type: "function_call_output",
output: JSON.stringify({
results: [
{
path: "MEMORY.md",
snippet: "Hidden QA fact: the project codename is ORBIT-9.",
},
],
}),
},
{
role: "user",
content: [
{
type: "input_text",
text: "Protocol note: acknowledged. Continue with the QA scenario plan.",
},
],
},
],
}),
});
expect(memoryGetFromPathOnlySearchResult.status).toBe(200);
const memoryGetText = await memoryGetFromPathOnlySearchResult.text();
expect(memoryGetText).toContain('"name":"memory_get"');
expect(memoryGetText).toContain('\\"path\\":\\"MEMORY.md\\"');
expect(memoryGetText).toContain('\\"from\\":1');
const image = await fetch(`${server.baseUrl}/v1/images/generations`, {
method: "POST",
headers: {

View File

@@ -2612,8 +2612,8 @@ async function buildResponsesPayload(
});
}
}
if (/memory tools check/i.test(prompt)) {
if (!toolOutput) {
if (/memory tools check/i.test(allInputText)) {
if (!scenarioToolOutput) {
return buildToolCallEventsWithArgs("memory_search", {
query: "project codename ORBIT-9",
maxResults: 3,
@@ -2623,10 +2623,7 @@ async function buildResponsesPayload(
? (toolJson.results as Array<Record<string, unknown>>)
: [];
const first = results[0];
if (
typeof first?.path === "string" &&
(typeof first.startLine === "number" || typeof first.endLine === "number")
) {
if (typeof first?.path === "string") {
const from =
typeof first.startLine === "number"
? Math.max(1, first.startLine)

View File

@@ -469,6 +469,94 @@ describe("qa suite runtime launcher", () => {
expect(runQaTestFileScenarios).toHaveBeenCalledTimes(1);
});
it("starts native suite proof before isolated flow work fills the weighted queue", async () => {
const repoRoot = await makeTempRepo("qa-suite-native-before-isolated-");
let releaseShared!: () => void;
let markSharedStarted!: () => void;
const sharedStarted = new Promise<void>((resolve) => {
markSharedStarted = resolve;
});
const sharedBlocked = new Promise<void>((resolve) => {
releaseShared = resolve;
});
let releaseTestFile!: () => void;
let markTestFileStarted!: () => void;
const testFileStarted = new Promise<void>((resolve) => {
markTestFileStarted = resolve;
});
const testFileBlocked = new Promise<void>((resolve) => {
releaseTestFile = resolve;
});
runQaFlowSuite.mockImplementationOnce(
async (params: { outputDir?: string; scenarioIds?: string[] } | undefined) => {
markSharedStarted();
await sharedBlocked;
const outputDir = params?.outputDir ?? "/tmp/qa-flow";
const evidencePath = path.join(outputDir, "qa-evidence.json");
await writeEvidence(evidencePath);
const scenarioIds = params?.scenarioIds ?? ["channel-chat-baseline"];
return {
outputDir,
evidencePath,
reportPath: path.join(outputDir, "qa-suite-report.md"),
summaryPath: path.join(outputDir, "qa-suite-summary.json"),
report: "# QA Suite Report\n",
scenarios: scenarioIds.map((scenarioId) => ({
name: scenarioId,
status: "pass",
steps: [],
})),
watchUrl: "http://127.0.0.1:43124",
};
},
);
runQaTestFileScenarios.mockImplementationOnce(
async (params: {
outputDir: string;
scenarios: Array<{ id: string; execution: { kind: "script" | "vitest" | "playwright" } }>;
}) => {
markTestFileStarted();
await testFileBlocked;
const evidencePath = path.join(params.outputDir, "qa-evidence.json");
await writeEvidence(evidencePath);
return {
outputDir: params.outputDir,
executionKind: params.scenarios[0]?.execution.kind ?? "playwright",
evidencePath,
results: params.scenarios.map((scenarioItem) => ({
durationMs: 1,
logPath: path.join(params.outputDir, `${scenarioItem.id}.log`),
scenario: scenarioItem,
status: "pass",
})),
};
},
);
const runPromise = runQaSuite({
repoRoot,
outputDir: ".artifacts/qa-e2e/native-before-isolated",
concurrency: 2,
scenarioIds: [
"channel-chat-baseline",
"group-visible-reply-tool",
"control-ui-chat-flow-playwright",
],
});
await sharedStarted;
await testFileStarted;
await Promise.resolve();
expect(runQaFlowSuite).toHaveBeenCalledTimes(1);
expect(runQaTestFileScenarios).toHaveBeenCalledTimes(1);
releaseTestFile();
releaseShared();
await runPromise;
expect(runQaFlowSuite).toHaveBeenCalledTimes(2);
});
it("waits for already-started partitions before rejecting a unified suite", async () => {
const repoRoot = await makeTempRepo("qa-suite-reject-settle-");
let releaseTestFile!: () => void;

View File

@@ -448,7 +448,9 @@ async function runUnifiedQaSuite(params: {
);
const evidenceSummaries: QaEvidenceSummaryJson[] = [];
const scenarioResultsById = new Map<string, QaSuiteScenarioResult>();
const partitionTasks: QaUnifiedPartitionTask[] = [];
const sharedFlowPartitionTasks: QaUnifiedPartitionTask[] = [];
const isolatedFlowPartitionTasks: QaUnifiedPartitionTask[] = [];
const testFilePartitionTasks: QaUnifiedPartitionTask[] = [];
if (params.plan.flowScenarios.length > 0) {
const sharedFlowScenarios = params.plan.flowScenarios.filter(
(scenario) => !scenarioRequiresIsolatedQaSuiteWorker(scenario),
@@ -488,7 +490,7 @@ async function runUnifiedQaSuite(params: {
for (const partition of flowPartitions) {
const isolatedPartition =
partition.kind === "isolated" || partition.kind.startsWith("isolated-");
partitionTasks.push({
const task = {
weight: partition.concurrency,
run: async () => {
const result = await runFlowSuite({
@@ -525,11 +527,16 @@ async function runUnifiedQaSuite(params: {
scenarioResults,
};
},
});
} satisfies QaUnifiedPartitionTask;
if (isolatedPartition) {
isolatedFlowPartitionTasks.push(task);
} else {
sharedFlowPartitionTasks.push(task);
}
}
}
if (params.plan.testFileScenariosByKind.size > 0) {
partitionTasks.push({
testFilePartitionTasks.push({
weight: 1,
run: async () => {
const testFileEvidenceSummaries: QaEvidenceSummaryJson[] = [];
@@ -561,6 +568,11 @@ async function runUnifiedQaSuite(params: {
},
});
}
const partitionTasks = [
...sharedFlowPartitionTasks,
...testFilePartitionTasks,
...isolatedFlowPartitionTasks,
];
const partitionResults = await runWeightedUnifiedPartitionTasks(partitionTasks, concurrency);
for (const partitionResult of partitionResults) {
for (const scenarioResult of partitionResult.scenarioResults) {

View File

@@ -848,6 +848,7 @@ async function runQaRuntimeParitySuite(params: {
const finishedAt = new Date();
const { evidence, evidencePath, report, reportPath, summaryPath } = await writeQaSuiteArtifacts(
{
repoRoot: params.repoRoot,
outputDir: params.outputDir,
startedAt: params.startedAt,
finishedAt,
@@ -900,6 +901,7 @@ async function runQaRuntimeParitySuite(params: {
}
async function writeQaSuiteArtifacts(params: {
repoRoot?: string;
outputDir: string;
startedAt: Date;
finishedAt: Date;
@@ -974,6 +976,7 @@ async function writeQaSuiteArtifacts(params: {
generatedAt: params.finishedAt.toISOString(),
primaryModel: params.primaryModel,
providerMode: params.providerMode,
repoRoot: params.repoRoot,
scenarioDefinitions: params.scenarioDefinitions,
scenarioResults: params.scenarios,
})
@@ -1296,6 +1299,7 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
.then(async () => {
const partialFinishedAt = new Date();
const { report, reportPath } = await writeQaSuiteArtifacts({
repoRoot,
outputDir,
startedAt,
finishedAt: partialFinishedAt,
@@ -1448,6 +1452,7 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
});
const { evidence, evidencePath, report, reportPath, summaryPath } =
await writeQaSuiteArtifacts({
repoRoot,
outputDir,
startedAt,
finishedAt,
@@ -1720,6 +1725,7 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
});
const { evidence, evidencePath, report, reportPath, summaryPath } = await writeQaSuiteArtifacts(
{
repoRoot,
outputDir,
startedAt,
finishedAt,

View File

@@ -555,6 +555,7 @@ function buildTestFileEvidence(params: {
kind: QaTestFileExecutionKind;
primaryModel: string;
providerMode: QaProviderMode;
repoRoot: string;
results: readonly QaTestFileScenarioResult[];
evidenceMode?: QaScorecardEvidenceMode;
env?: NodeJS.ProcessEnv;
@@ -581,6 +582,7 @@ function buildTestFileEvidence(params: {
generatedAt: params.generatedAt,
primaryModel: params.primaryModel,
providerMode: params.providerMode,
repoRoot: params.repoRoot,
targets: fallbackResults.map((result) => buildScenarioEvidenceTarget(result.scenario)),
results: fallbackResults.map((result) => ({
id: result.scenario.id,
@@ -616,6 +618,7 @@ function buildTestFileEvidence(params: {
generatedAt: params.generatedAt,
primaryModel: params.primaryModel,
providerMode: params.providerMode,
repoRoot: params.repoRoot,
targets: params.results.map((result) => buildScenarioEvidenceTarget(result.scenario)),
results: params.results.map((result) => ({
id: result.scenario.id,
@@ -802,6 +805,7 @@ export async function runQaTestFileScenarios(
kind,
primaryModel: params.primaryModel,
providerMode: params.providerMode,
repoRoot: params.repoRoot,
results,
});
const paths = await writeTestFileEvidenceFile({

View File

@@ -90,6 +90,10 @@ export function mergeTelegramAccountConfig(
baseAllowFrom: base.allowFrom,
accountAllowFrom: account.allowFrom,
});
const capabilities =
Array.isArray(account.capabilities) && account.capabilities.length === 0
? base.capabilities
: (account.capabilities ?? base.capabilities);
return { ...base, ...account, allowFrom, groups };
return { ...base, ...account, allowFrom, capabilities, groups };
}

View File

@@ -1703,6 +1703,25 @@ describe("handleTelegramAction", () => {
expect(sendMessageTelegram).toHaveBeenCalled();
});
it("allows inline buttons when legacy capabilities are empty", async () => {
await handleTelegramAction(
{
action: "sendMessage",
to: "@testchannel",
content: "Choose",
presentation: {
blocks: [{ type: "buttons", buttons: [{ label: "Ok", value: "cmd:ok" }] }],
},
},
telegramConfig({ capabilities: [] }),
);
const call = mockCall(sendMessageTelegram, 0, "empty legacy capabilities");
expect(call[0]).toBe("@testchannel");
expect(requireRecord(call[2], "empty legacy capabilities options").buttons).toEqual([
[{ text: "Ok", callback_data: "cmd:ok" }],
]);
});
it("uses interactive button labels as fallback text when message text is omitted", async () => {
await handleTelegramAction(
{

View File

@@ -69,6 +69,7 @@ import {
resolveDefaultModelForAgent,
} from "./bot-message-dispatch.agent.runtime.js";
import { deduplicateBlockSentMedia } from "./bot-message-dispatch.media-dedup.js";
import { clipTelegramProgressText } from "./truncate.js";
import {
generateTopicLabel,
getAgentScopedMediaLocalRoots,
@@ -364,22 +365,14 @@ async function mirrorTelegramAssistantReplyToTranscript(params: {
}
}
const MAX_PROGRESS_MARKDOWN_TEXT_CHARS = 300;
const TELEGRAM_GENERAL_TOPIC_ID = 1;
function clipProgressMarkdownText(text: string): string {
if (text.length <= MAX_PROGRESS_MARKDOWN_TEXT_CHARS) {
return text;
}
return `${text.slice(0, MAX_PROGRESS_MARKDOWN_TEXT_CHARS - 1).trimEnd()}`;
}
function sanitizeProgressMarkdownText(text: string): string {
return text.replaceAll("`", "'");
}
function formatProgressAsMarkdownCode(text: string): string {
const clipped = clipProgressMarkdownText(text);
const clipped = clipTelegramProgressText(text);
return `\`${sanitizeProgressMarkdownText(clipped)}\``;
}
@@ -399,7 +392,7 @@ function escapeTelegramProgressHtml(text: string): string {
}
function renderTelegramProgressStringLine(text: string): string {
const clipped = clipProgressMarkdownText(text.trim());
const clipped = clipTelegramProgressText(text.trim());
const italic = clipped.match(/^_(.*)_$/u);
if (italic) {
return `<i>${escapeTelegramProgressHtml(italic[1] ?? "")}</i>`;
@@ -418,7 +411,7 @@ function renderTelegramProgressLine(line: ChannelProgressDraftCompositorLine): s
const parts = [`<b>${escapeTelegramProgressHtml(label)}</b>`];
const detail = line.detail && line.detail !== line.label ? line.detail : undefined;
if (detail) {
parts.push(`<code>${escapeTelegramProgressHtml(clipProgressMarkdownText(detail))}</code>`);
parts.push(`<code>${escapeTelegramProgressHtml(clipTelegramProgressText(detail))}</code>`);
} else {
const text = line.text.trim();
if (text && text !== label) {

View File

@@ -43,6 +43,36 @@ describe("telegram actions contract", () => {
expect(capabilities?.includes("richText")).toBe(expected);
});
it("advertises inline buttons when legacy Telegram capabilities are empty", () => {
const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({
cfg: {
channels: {
telegram: {
botToken: "123:telegram-test-token",
capabilities: [],
},
},
} as OpenClawConfig,
});
expect(capabilities).toContain("inlineButtons");
});
it("does not advertise inline buttons for non-empty legacy Telegram capabilities without inlineButtons", () => {
const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({
cfg: {
channels: {
telegram: {
botToken: "123:telegram-test-token",
capabilities: ["vision"],
},
},
} as OpenClawConfig,
});
expect(capabilities).not.toContain("inlineButtons");
});
it("uses the selected Telegram account's rich text setting", () => {
const capabilities = telegramPlugin.agentPrompt?.messageToolCapabilities?.({
cfg: {

View File

@@ -109,6 +109,39 @@ describe("resolveTelegramInlineButtonsScope (#75433 SecretRef tolerance)", () =>
expect(isTelegramInlineButtonsEnabled({ cfg })).toBe(true);
});
it("preserves the default inline-buttons scope when legacy capabilities are empty", () => {
const cfg = {
channels: {
telegram: {
botToken: { source: "exec", provider: "default", id: "telegram-token" },
capabilities: [],
},
},
} as unknown as OpenClawConfig;
expect(resolveTelegramInlineButtonsScope({ cfg })).toBe("allowlist");
expect(isTelegramInlineButtonsEnabled({ cfg })).toBe(true);
});
it("inherits the channel scope when an account legacy capabilities array is empty", () => {
const cfg = {
channels: {
telegram: {
capabilities: { inlineButtons: "off" },
accounts: {
ops: {
botToken: "123:telegram-ops-token",
capabilities: [],
},
},
},
},
} as unknown as OpenClawConfig;
expect(resolveTelegramInlineButtonsScope({ cfg, accountId: "ops" })).toBe("off");
expect(isTelegramInlineButtonsEnabled({ cfg, accountId: "ops" })).toBe(false);
});
it('preserves configured "off" when botToken is an unresolved SecretRef', () => {
const cfg = {
channels: {

View File

@@ -47,6 +47,9 @@ export function resolveTelegramInlineButtonsScopeFromCapabilities(
return DEFAULT_INLINE_BUTTONS_SCOPE;
}
if (Array.isArray(capabilities)) {
if (capabilities.length === 0) {
return DEFAULT_INLINE_BUTTONS_SCOPE;
}
const enabled = capabilities.some(
(entry) => normalizeLowercaseStringOrEmpty(String(entry)) === "inlinebuttons",
);

View File

@@ -0,0 +1,48 @@
// Telegram tests cover progress text clipping behavior.
import { describe, expect, it } from "vitest";
import { clipTelegramProgressText, TELEGRAM_PROGRESS_MAX_CHARS } from "./truncate.js";
describe("clipTelegramProgressText", () => {
it("drops a surrogate-pair emoji whole when it straddles the limit", () => {
// 😀 is U+1F600, encoded as two UTF-16 code units (high \uD83D + low \uDE00).
// Placing the emoji at positions [MAX-2, MAX-1] (0-indexed) puts its high
// surrogate right on the .slice(0, MAX-1) cut edge. A raw .slice keeps only
// \uD83D — an unpaired high surrogate — which is invalid in a Telegram payload.
const base = "a".repeat(TELEGRAM_PROGRESS_MAX_CHARS - 2); // 298 'a's
const out = clipTelegramProgressText(`${base}😀tail`);
expect(out).toBe(`${base}`);
// No dangling high surrogate (high not followed by a low surrogate).
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(out)).toBe(false);
});
it("keeps an emoji that fits entirely before the cut", () => {
// 296 'a's + '😀' (2 units) + 'xyz' (3 units) = 301 total > 300.
// The emoji sits at [296, 297] — entirely before the cut at 299 — so it stays.
const base = "a".repeat(TELEGRAM_PROGRESS_MAX_CHARS - 4); // 296 'a's
const out = clipTelegramProgressText(`${base}😀xyz`);
expect(out).toBe(`${base}😀x…`);
expect(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/.test(out)).toBe(false);
});
it("returns text unchanged when it is within the limit", () => {
const short = "hello 😀 world";
expect(clipTelegramProgressText(short)).toBe(short);
});
it("trims trailing whitespace before the ellipsis", () => {
// The sliced portion may end in spaces when trailing spaces straddle the cut.
const text = `${"a".repeat(TELEGRAM_PROGRESS_MAX_CHARS - 2)} rest`;
const out = clipTelegramProgressText(text);
expect(out).not.toContain(" …");
expect(out.endsWith("…")).toBe(true);
});
it("handles plain ASCII that fills exactly to the limit", () => {
const exact = "x".repeat(TELEGRAM_PROGRESS_MAX_CHARS);
expect(clipTelegramProgressText(exact)).toBe(exact);
const oneOver = `${"x".repeat(TELEGRAM_PROGRESS_MAX_CHARS)}y`;
const out = clipTelegramProgressText(oneOver);
expect(out.length).toBeLessThanOrEqual(TELEGRAM_PROGRESS_MAX_CHARS);
expect(out.endsWith("…")).toBe(true);
});
});

View File

@@ -0,0 +1,20 @@
// Telegram tests cover progress text clipping behavior.
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
export const TELEGRAM_PROGRESS_MAX_CHARS = 300;
/**
* Clips Telegram progress text to at most {@link TELEGRAM_PROGRESS_MAX_CHARS} UTF-16 code units,
* slicing on a code-point boundary so a surrogate pair straddling the limit is
* dropped whole rather than leaving a lone high surrogate in the payload.
*/
export function clipTelegramProgressText(text: string): string {
if (text.length <= TELEGRAM_PROGRESS_MAX_CHARS) {
return text;
}
// Slice on a code-point boundary so an emoji (or any astral character) that
// straddles the limit is dropped whole instead of leaving a lone \uD83D-style
// high surrogate before the ellipsis, which serializes to an invalid character
// in the Telegram Bot API payload.
return `${sliceUtf16Safe(text, 0, TELEGRAM_PROGRESS_MAX_CHARS - 1).trimEnd()}`;
}

View File

@@ -13,6 +13,16 @@ describe("base64 helpers", () => {
actual: canonicalizeBase64(" SGV s bG8= \n"),
expected: "SGVsbG8=",
},
{
name: "canonicalizeBase64 pads valid unpadded base64",
actual: canonicalizeBase64("SGVsbG8"),
expected: "SGVsbG8=",
},
{
name: "canonicalizeBase64 rejects impossible unpadded length",
actual: canonicalizeBase64("S"),
expected: undefined,
},
{
name: "canonicalizeBase64 rejects invalid base64 characters",
actual: canonicalizeBase64('SGVsbG8=" onerror="alert(1)'),

View File

@@ -74,8 +74,15 @@ export function canonicalizeBase64(base64: string): string | undefined {
}
cleaned += base64[i];
}
if (!cleaned || cleaned.length % 4 !== 0) {
if (!cleaned) {
return undefined;
}
const remainder = cleaned.length % 4;
if (remainder !== 0) {
if (sawPadding || remainder === 1) {
return undefined;
}
cleaned += "=".repeat(4 - remainder);
}
return cleaned;
}

View File

@@ -39,6 +39,13 @@ describe("inline image data URL sanitizer", () => {
);
});
it("canonicalizes valid unpadded image data URLs", () => {
const unpaddedPng = PNG_1X1.replace(/=+$/u, "");
expect(sanitizeInlineImageDataUrl(`data:image/png;base64,${unpaddedPng}`)).toBe(
`data:image/png;base64,${PNG_1X1}`,
);
});
it("rejects image data URLs for formats that require conversion before provider transport", () => {
expect(sanitizeInlineImageDataUrl(`data:image/bmp;base64,${BMP_HEADER}`)).toBeUndefined();
expect(sanitizeInlineImageDataUrl(`data:image/heic;base64,${HEIC_HEADER}`)).toBeUndefined();

View File

@@ -8,10 +8,9 @@ scenario:
- memory.tools
secondary:
- channels.group-messages
objective: Verify the agent uses memory_search and memory_get in a shared channel when the answer lives only in memory files, not the live transcript.
objective: Verify the agent uses memory tools in a shared channel when the answer lives only in memory files, not the live transcript.
successCriteria:
- Agent uses memory_search before answering.
- Agent narrows with memory_get before answering.
- Final reply returns the memory-only fact correctly in-channel.
docsRefs:
- docs/concepts/memory.md
@@ -21,7 +20,7 @@ scenario:
- extensions/qa-lab/src/suite.ts
execution:
kind: flow
summary: Verify the agent uses memory_search and memory_get in a shared channel when the answer lives only in memory files, not the live transcript.
summary: Verify the agent uses memory tools in a shared channel when the answer lives only in memory files, not the live transcript.
config:
channelId: qa-memory-room
channelTitle: QA Memory Room
@@ -33,7 +32,7 @@ scenario:
flow:
steps:
- name: uses memory_search plus memory_get before answering in-channel
- name: uses memory_search before answering in-channel
actions:
- call: reset
- call: fs.writeFile
@@ -80,7 +79,4 @@ flow:
- assert:
expr: "!env.mock || (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).filter((request) => String(request.allInputText ?? '').includes(config.promptSnippet)).some((request) => request.plannedToolName === 'memory_search')"
message: expected memory_search in mock request plan
- assert:
expr: "!env.mock || (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).some((request) => request.plannedToolName === 'memory_get')"
message: expected memory_get in mock request plan
detailsExpr: outbound.text

View File

@@ -31,6 +31,7 @@ scenario:
summary: Run with `pnpm openclaw qa suite --provider-mode live-frontier --cli-auth-mode subscription --model claude-cli/claude-sonnet-4-6 --alt-model claude-cli/claude-sonnet-4-6 --scenario claude-cli-provider-capabilities-subscription`.
config:
authMode: subscription
requiredProviderMode: live-frontier
requiredProvider: claude-cli
chatPrompt: "Claude CLI provider marker check. Reply exactly: CLAUDE-CLI-CHAT-OK"
chatExpected: CLAUDE-CLI-CHAT-OK

View File

@@ -31,6 +31,7 @@ scenario:
summary: Run with `pnpm openclaw qa suite --provider-mode live-frontier --cli-auth-mode api-key --model claude-cli/claude-sonnet-4-6 --alt-model claude-cli/claude-sonnet-4-6 --scenario claude-cli-provider-capabilities`.
config:
authMode: api-key
requiredProviderMode: live-frontier
requiredProvider: claude-cli
chatPrompt: "Claude CLI provider marker check. Reply exactly: CLAUDE-CLI-CHAT-OK"
chatExpected: CLAUDE-CLI-CHAT-OK

View File

@@ -18,47 +18,8 @@ scenario:
- docs/gateway/protocol.md
codeRefs:
- src/mcp/plugin-tools-serve.ts
- extensions/qa-lab/src/suite.ts
- src/mcp/plugin-tools-mcp-client.test.ts
execution:
kind: flow
kind: vitest
path: src/mcp/plugin-tools-mcp-client.test.ts
summary: Verify OpenClaw can expose plugin tools over MCP and a real MCP client can call one successfully.
config:
memoryFact: "MCP fact: the codename is ORBIT-9."
query: "ORBIT-9 codename"
expectedNeedle: "ORBIT-9"
flow:
steps:
- name: serves and calls memory_search over MCP
actions:
- call: fs.writeFile
args:
- expr: "path.join(env.gateway.workspaceDir, 'MEMORY.md')"
- expr: "`${config.memoryFact}\\n`"
- utf8
- call: forceMemoryIndex
args:
- env:
ref: env
query:
expr: config.query
expectedNeedle:
expr: config.expectedNeedle
- call: callPluginToolsMcp
saveAs: result
args:
- env:
ref: env
toolName: memory_search
args:
query:
expr: config.query
maxResults: 3
- set: text
value:
expr: "JSON.stringify(result.content ?? [])"
- assert:
expr: "text.includes(config.expectedNeedle)"
message:
expr: "`MCP memory_search missed expected fact: ${text}`"
detailsExpr: text

View File

@@ -117,6 +117,7 @@ export const migratedSessionAccessorFiles = new Set([
"src/gateway/session-reset-service.ts",
"src/infra/outbound/message-action-tts.ts",
"src/agents/tools/embedded-gateway-stub.ts",
"src/agents/tools/session-status-tool.ts",
"src/agents/tools/sessions-list-tool.ts",
"src/plugins/host-hook-state.ts",
"src/status/status-message.ts",
@@ -142,6 +143,7 @@ export const migratedSessionAccessorWriteFiles = new Set([
"src/auto-reply/reply/abort.ts",
"src/agents/subagent-control.ts",
"src/agents/subagent-registry-helpers.ts",
"src/agents/tools/session-status-tool.ts",
"src/auto-reply/reply/abort-cutoff.runtime.ts",
"src/auto-reply/reply/agent-runner-cli-dispatch.ts",
"src/auto-reply/reply/agent-runner-execution.ts",

View File

@@ -7,7 +7,9 @@ USER root
ENV DEBIAN_FRONTEND=noninteractive
ARG PACKAGES="curl wget jq coreutils grep nodejs npm python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file"
ARG PACKAGES="curl wget jq coreutils grep python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file"
ARG INSTALL_NODE=1
ARG NODE_MAJOR=24
ARG INSTALL_PNPM=1
ARG INSTALL_BUN=1
ARG BUN_INSTALL_DIR=/opt/bun
@@ -26,7 +28,18 @@ RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/ap
apt-get update \
&& apt-get install -y --no-install-recommends ${PACKAGES}
RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi
RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-sandbox-common-apt-lists,target=/var/lib/apt,sharing=locked \
if [ "${INSTALL_NODE}" = "1" ]; then \
apt-get update; \
apt-get install -y --no-install-recommends ca-certificates curl; \
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash -; \
apt-get install -y --no-install-recommends nodejs; \
node --version; \
npm --version; \
fi
RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm && pnpm --version; fi
RUN if [ "${INSTALL_BUN}" = "1" ]; then \
curl -fsSL https://bun.sh/install | bash; \

View File

@@ -10,6 +10,7 @@ import {
QA_EVIDENCE_FILENAME,
QA_EVIDENCE_SUMMARY_KIND,
QA_EVIDENCE_SUMMARY_SCHEMA_VERSION,
resolveQaEvidenceEnvironment,
validateQaEvidenceSummaryJson,
type QaEvidenceStatus,
type QaEvidenceSummaryEntry,
@@ -174,15 +175,15 @@ function sanitizeArtifactText(
function buildExecution(params: {
artifacts: MatrixCell["artifacts"];
repoRoot: string;
source: string;
}): QaEvidenceSummaryEntry["execution"] {
return {
runner: "ux-matrix-script-producer",
environment: {
ref: process.env.OPENCLAW_QA_REF?.trim() || process.env.GITHUB_SHA?.trim() || null,
os: process.platform,
nodeVersion: process.version,
},
environment: resolveQaEvidenceEnvironment({
env: process.env,
repoRoot: params.repoRoot,
}),
provider: {
id: "ux-matrix",
live: false,
@@ -202,7 +203,7 @@ function buildExecution(params: {
};
}
function buildEvidenceEntry(cell: MatrixCell): QaEvidenceSummaryEntry {
function buildEvidenceEntry(cell: MatrixCell, repoRoot: string): QaEvidenceSummaryEntry {
const source = `ux-matrix:${cell.surface}:${cell.stage}`;
return {
test: {
@@ -221,6 +222,7 @@ function buildEvidenceEntry(cell: MatrixCell): QaEvidenceSummaryEntry {
],
execution: buildExecution({
artifacts: cell.artifacts,
repoRoot,
source,
}),
result: {
@@ -243,13 +245,14 @@ function buildEvidenceEntry(cell: MatrixCell): QaEvidenceSummaryEntry {
function buildEvidenceSummary(params: {
cells: readonly MatrixCell[];
generatedAt: string;
repoRoot: string;
}): QaEvidenceSummaryJson {
return validateQaEvidenceSummaryJson({
kind: QA_EVIDENCE_SUMMARY_KIND,
schemaVersion: QA_EVIDENCE_SUMMARY_SCHEMA_VERSION,
generatedAt: params.generatedAt,
evidenceMode: "full",
entries: params.cells.map(buildEvidenceEntry),
entries: params.cells.map((cell) => buildEvidenceEntry(cell, params.repoRoot)),
});
}
@@ -693,6 +696,7 @@ export async function runUxMatrixEvidenceProducer(options: ProducerOptions) {
const previewEvidence = buildEvidenceSummary({
cells: initialCells,
generatedAt: new Date().toISOString(),
repoRoot: options.repoRoot,
});
const screenshotLog = await fs.readFile(path.join(screenshotCellDir, "logs.txt"), "utf8");
await writeProducerArtifactFixtureHtml({
@@ -753,7 +757,11 @@ export async function runUxMatrixEvidenceProducer(options: ProducerOptions) {
...initialCells,
];
const evidence = buildEvidenceSummary({ cells, generatedAt: new Date().toISOString() });
const evidence = buildEvidenceSummary({
cells,
generatedAt: new Date().toISOString(),
repoRoot: options.repoRoot,
});
await writeProducerArtifactFixtureHtml({
artifactBase: options.artifactBase,
evidence,

View File

@@ -6,7 +6,9 @@ source "$ROOT_DIR/scripts/lib/docker-build.sh"
BASE_IMAGE="${BASE_IMAGE:-openclaw-sandbox:bookworm-slim}"
TARGET_IMAGE="${TARGET_IMAGE:-openclaw-sandbox-common:bookworm-slim}"
PACKAGES="${PACKAGES:-curl wget jq coreutils grep nodejs npm python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file}"
PACKAGES="${PACKAGES:-curl wget jq coreutils grep python3 git ca-certificates golang-go rustc cargo unzip pkg-config libasound2-dev build-essential file}"
INSTALL_NODE="${INSTALL_NODE:-1}"
NODE_MAJOR="${NODE_MAJOR:-24}"
INSTALL_PNPM="${INSTALL_PNPM:-1}"
INSTALL_BUN="${INSTALL_BUN:-1}"
BUN_INSTALL_DIR="${BUN_INSTALL_DIR:-/opt/bun}"
@@ -30,6 +32,8 @@ docker_build_exec \
-f "$ROOT_DIR/scripts/docker/sandbox/Dockerfile.common" \
--build-arg BASE_IMAGE="${BASE_IMAGE}" \
--build-arg PACKAGES="${PACKAGES}" \
--build-arg INSTALL_NODE="${INSTALL_NODE}" \
--build-arg NODE_MAJOR="${NODE_MAJOR}" \
--build-arg INSTALL_PNPM="${INSTALL_PNPM}" \
--build-arg INSTALL_BUN="${INSTALL_BUN}" \
--build-arg BUN_INSTALL_DIR="${BUN_INSTALL_DIR}" \

View File

@@ -46,11 +46,6 @@ 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());
@@ -296,191 +291,4 @@ 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,8 +347,6 @@ 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,
@@ -817,8 +815,6 @@ 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,26 +4,9 @@
* by sandboxed exec calls.
*/
import { describe, expect, it } from "vitest";
import { buildDockerExecArgs, buildSandboxEnv } from "./bash-tools.shared.js";
import { buildDockerExecArgs } 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,7 +60,6 @@ function restoreProcessPlatformForTest(): void {
type ApprovalRequestPayload = {
approvalReviewerDeviceIds?: string[];
commandSpans?: Array<{ startIndex: number; endIndex: number }>;
env?: Record<string, string>;
};
function requireApprovalRequestPayload(callIndex: number): ApprovalRequestPayload {
@@ -178,24 +177,6 @@ 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,10 +300,7 @@ async function buildHostApprovalDecisionParams(
command: params.command,
commandArgv: params.commandArgv,
systemRunPlan: params.systemRunPlan,
env:
params.host === "node" || params.env === undefined
? params.env
: Object.fromEntries(Object.keys(params.env).map((key) => [key, ""])),
env: params.env,
cwd: params.workdir,
nodeId: params.nodeId,
host: params.host,

View File

@@ -75,7 +75,6 @@ type ProcessGatewayAllowlistParams = {
workdir: string;
env: Record<string, string>;
pathPrepend?: string[];
useShellSnapshot?: boolean;
requestedEnv?: Record<string, string>;
pty: boolean;
timeoutSec?: number;
@@ -959,7 +958,6 @@ 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,7 +11,6 @@ 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,
@@ -27,10 +26,6 @@ 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;
@@ -55,10 +50,6 @@ beforeEach(() => {
requestHeartbeatMock.mockClear();
enqueueSystemEventMock.mockClear();
supervisorMock.spawn.mockReset();
maybeWrapCommandWithShellSnapshotMock.mockReset();
maybeWrapCommandWithShellSnapshotMock.mockImplementation(
async ({ command }: { command: string }) => command,
);
});
function expectExecTarget(
@@ -591,42 +582,6 @@ 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,8 +580,6 @@ 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;
@@ -766,16 +764,13 @@ export async function runExecProcess(opts: {
shellRuntimeEnv,
opts.pathPrepend,
);
const commandWithShellSnapshot =
opts.useShellSnapshot === false
? commandWithPathPrepend
: await maybeWrapCommandWithShellSnapshot({
command: commandWithPathPrepend,
shell,
shellArgs,
cwd: opts.workdir,
env: shellRuntimeEnv,
});
const commandWithShellSnapshot = await maybeWrapCommandWithShellSnapshot({
command: commandWithPathPrepend,
shell,
shellArgs,
cwd: opts.workdir,
env: shellRuntimeEnv,
});
const childArgv = [shell, ...shellArgs, commandWithShellSnapshot];
if (opts.usePty) {

View File

@@ -29,10 +29,6 @@ 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,9 +33,7 @@ const mocks = vi.hoisted(() => ({
requestedEnv?: Record<string, string>;
}>,
spawnInputs: [] as Array<{
argv?: string[];
env?: Record<string, string>;
ptyCommand?: string;
}>,
}));
@@ -86,17 +84,8 @@ vi.mock("./bash-tools.exec-host-node.js", () => ({
vi.mock("../process/supervisor/index.js", () => ({
getProcessSupervisor: () => ({
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,
});
spawn: async (input: { env?: Record<string, string>; onStdout?: (chunk: string) => void }) => {
mocks.spawnInputs.push({ env: input.env ? { ...input.env } : undefined });
input.onStdout?.("ok\n");
return {
runId: "mock-run",
@@ -241,90 +230,6 @@ 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,14 +34,8 @@ import {
isDangerousHostEnvVarName,
normalizeHostOverrideEnvVarKey,
sanitizeHostExecEnvWithDiagnostics,
setCaseInsensitiveEnvValue,
validateConfiguredExecEnvKey,
} from "../infra/host-env-security.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 { OPENCLAW_CLI_ENV_VAR } from "../infra/openclaw-exec-env.js";
import {
getShellPathFromLoginShell,
resolveShellEnvFallbackTimeoutMs,
@@ -115,7 +109,7 @@ type ExecToolArgs = Record<string, unknown> & {
node?: string;
};
const CHANNEL_CONTEXT_ENV_KEY = OPENCLAW_CHANNEL_CONTEXT_ENV_VAR;
const CHANNEL_CONTEXT_ENV_KEY = "OPENCLAW_CHANNEL_CONTEXT";
function buildSubprocessChannelContext(
channelContext: PluginHookChannelContext | undefined,
@@ -158,88 +152,23 @@ 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 || isBlockedObjectKey(key)) {
if (!key) {
continue;
}
const upperKey = key.toUpperCase();
if (
upperKey === "PATH" ||
upperKey === OPENCLAW_CLI_ENV_VAR ||
upperKey === CHANNEL_CONTEXT_ENV_KEY ||
isDangerousHostEnvVarName(upperKey) ||
isDangerousHostEnvOverrideVarName(upperKey)
) {
continue;
}
setCaseInsensitiveEnvValue(env, key, value);
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 = {},
@@ -1668,34 +1597,15 @@ export function createExecTool(
}
await rejectUnsafeExecControlShellCommand(params.command);
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 inheritedBaseEnv = coerceEnv(process.env);
const resolvedExecEnvState = getResolvedExecEnvPreparedState(params);
const channelContextEnv = buildChannelContextEnv(defaults?.channelContext);
const requestedEnv = mergeExecEnvLayers(
params.env,
configuredEnv,
resolvedExecEnvState?.pluginEnv,
channelContextEnv,
);
const requestedEnv: Record<string, string> | undefined =
params.env !== undefined ||
resolvedExecEnvState?.pluginEnv !== undefined ||
channelContextEnv !== undefined
? { ...params.env, ...resolvedExecEnvState?.pluginEnv, ...channelContextEnv }
: undefined;
const hostEnvResult =
host === "sandbox"
? null
@@ -1748,14 +1658,8 @@ export function createExecTool(
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
})
: (hostEnvResult?.env ?? inheritedBaseEnv);
applyTrustedChannelContextEnv(env, channelContextEnv);
if (
!sandbox &&
host === "gateway" &&
defaults?.inheritHostEnv !== false &&
!requestedEnv?.PATH
) {
if (!sandbox && host === "gateway" && !requestedEnv?.PATH) {
const shellPath = getShellPathFromLoginShell({
env: process.env,
timeoutMs: resolveShellEnvFallbackTimeoutMs(process.env),
@@ -1818,7 +1722,6 @@ export function createExecTool(
workdir,
env,
pathPrepend: defaultPathPrepend,
useShellSnapshot: defaults?.inheritHostEnv !== false,
requestedEnv,
pty: params.pty === true && !sandbox,
timeoutSec: params.timeout,
@@ -1882,7 +1785,6 @@ export function createExecTool(
workdir,
env,
pathPrepend: defaultPathPrepend,
useShellSnapshot: defaults?.inheritHostEnv !== false,
sandbox,
containerWorkdir,
usePty,

View File

@@ -8,7 +8,6 @@ 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";
@@ -47,14 +46,10 @@ export function buildSandboxEnv(params: {
HOME: params.containerWorkdir,
};
for (const [key, value] of Object.entries(params.sandboxEnv ?? {})) {
if (!isBlockedObjectKey(key)) {
env[key] = value;
}
env[key] = value;
}
for (const [key, value] of Object.entries(params.paramsEnv ?? {})) {
if (!isBlockedObjectKey(key)) {
env[key] = value;
}
env[key] = value;
}
return env;
}

View File

@@ -99,9 +99,77 @@ function installScopedSessionStores(syncUpdates = false) {
async function createSessionsModuleMock() {
const actual =
await vi.importActual<typeof import("../config/sessions.js")>("../config/sessions.js");
const resolveMockStorePath = (_store: string | undefined, opts?: { agentId?: string }) =>
opts?.agentId === "support" ? "/tmp/support/sessions.json" : "/tmp/main/sessions.json";
const cloneEntry = (entry: SessionEntry): SessionEntry => structuredClone(entry);
return {
...actual,
loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath),
patchSessionEntryWithKey: async (
scope: { agentId?: string; sessionKey: string; storePath?: string },
update: (
entry: SessionEntry,
context: { existingEntry?: SessionEntry },
) => Promise<Partial<SessionEntry> | null> | Partial<SessionEntry> | null,
options?: { fallbackEntry?: SessionEntry; replaceEntry?: boolean },
) => {
const storePath =
scope.storePath ?? resolveMockStorePath(undefined, { agentId: scope.agentId });
const store = loadSessionStoreMock(storePath) as Record<string, SessionEntry>;
const resolved = actual.resolveSessionStoreEntry({ store, sessionKey: scope.sessionKey });
const existing = resolved.existing ?? options?.fallbackEntry;
if (!existing) {
return null;
}
const patch = await update(cloneEntry(existing), {
existingEntry: resolved.existing ? cloneEntry(resolved.existing) : undefined,
});
if (!patch) {
return { sessionKey: resolved.normalizedKey, entry: cloneEntry(existing) };
}
const next = options?.replaceEntry
? cloneEntry(patch as SessionEntry)
: actual.mergeSessionEntry(existing, patch);
store[resolved.normalizedKey] = next;
updateSessionStoreMock(storePath, store);
return { sessionKey: resolved.normalizedKey, entry: cloneEntry(next) };
},
resolveSessionEntryCandidateTarget: (scope: {
agentId: string;
candidateKeys: readonly string[];
cfg: { session?: { store?: string } };
fallback?: { sessionKey: string; entry: SessionEntry };
}) => {
const storePath = resolveMockStorePath(scope.cfg.session?.store, { agentId: scope.agentId });
const store = loadSessionStoreMock(storePath) as Record<string, SessionEntry>;
const candidates = [...new Set(scope.candidateKeys.map((key) => key.trim()))];
for (const candidateKey of candidates) {
if (!candidateKey) {
continue;
}
const resolved = actual.resolveSessionStoreEntry({ store, sessionKey: candidateKey });
if (!resolved.existing) {
continue;
}
return {
agentId: scope.agentId,
candidateKey,
entry: cloneEntry(resolved.existing),
persisted: true,
sessionKey: resolved.normalizedKey,
};
}
const fallbackKey = scope.fallback?.sessionKey.trim();
return fallbackKey && scope.fallback
? {
agentId: scope.agentId,
candidateKey: fallbackKey,
entry: cloneEntry(scope.fallback.entry),
persisted: false,
sessionKey: fallbackKey,
}
: null;
},
updateSessionStore: async (
storePath: string,
mutator: (store: Record<string, unknown>) => Promise<void> | void,
@@ -111,8 +179,7 @@ async function createSessionsModuleMock() {
updateSessionStoreMock(storePath, store);
return store;
},
resolveStorePath: (_store: string | undefined, opts?: { agentId?: string }) =>
opts?.agentId === "support" ? "/tmp/support/sessions.json" : "/tmp/main/sessions.json",
resolveStorePath: resolveMockStorePath,
};
}
@@ -1191,6 +1258,41 @@ describe("session_status tool", () => {
expect(saved.sessionId).toMatch(UUID_RE);
});
it("preserves an existing legacy main row when implicit fallback mutates model state", async () => {
resetSessionStore({
main: {
sessionId: "legacy-main-session",
updatedAt: 10,
label: "Legacy Main",
lastChannel: "telegram",
},
});
const tool = getSessionStatusTool("agent:main:main");
const result = await tool.execute("call-legacy-main-fallback-model", {
model: "anthropic/claude-sonnet-4-6",
});
const details = result.details as {
ok?: boolean;
sessionKey?: string;
modelOverride?: string | null;
};
expect(details.ok).toBe(true);
expect(details.sessionKey).toBe("main");
expect(details.modelOverride).toBe("anthropic/claude-sonnet-4-6");
expect(updateSessionStoreMock).toHaveBeenCalledTimes(1);
const savedStore = latestMockCallArg(updateSessionStoreMock, 1) as Record<string, SessionEntry>;
expect(savedStore.main).toMatchObject({
sessionId: "legacy-main-session",
label: "Legacy Main",
lastChannel: "telegram",
providerOverride: "anthropic",
modelOverride: "claude-sonnet-4-6",
liveModelSwitchPending: true,
});
});
it("fires session:patch when session_status changes the persisted session model", async () => {
const events: InternalHookEvent[] = [];
registerInternalHook("session:patch", async (event) => {

View File

@@ -0,0 +1,154 @@
// Status-tool session resolution helpers keep storage lookup out of the tool body.
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { resolveSessionEntryCandidateTarget, type SessionEntry } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
buildAgentMainSessionKey,
DEFAULT_AGENT_ID,
parseAgentSessionKey,
} from "../../routing/session-key.js";
import { resolveInternalSessionKey } from "./sessions-helpers.js";
export type ResolvedStatusSessionEntry = {
entry: SessionEntry;
key: string;
persisted: boolean;
};
/** Resolves one status lookup against ordered tool-local session key candidates. */
export function resolveSessionStatusEntry(params: {
agentId: string;
alias: string;
cfg: OpenClawConfig;
includeAliasFallback?: boolean;
keyRaw: string;
mainKey: string;
requesterInternalKey?: string;
}): ResolvedStatusSessionEntry | null {
const keyRaw = params.keyRaw.trim();
if (!keyRaw) {
return null;
}
const includeAliasFallback = params.includeAliasFallback ?? true;
const internal = resolveInternalSessionKey({
key: keyRaw,
alias: params.alias,
mainKey: params.mainKey,
requesterInternalKey: params.requesterInternalKey,
});
const candidates: string[] = [keyRaw];
if (!keyRaw.startsWith("agent:")) {
candidates.push(`agent:${DEFAULT_AGENT_ID}:${keyRaw}`);
}
if (includeAliasFallback && internal !== keyRaw) {
candidates.push(internal);
}
if (includeAliasFallback && !keyRaw.startsWith("agent:")) {
const agentInternal = `agent:${DEFAULT_AGENT_ID}:${internal}`;
const agentRaw = `agent:${DEFAULT_AGENT_ID}:${keyRaw}`;
if (agentInternal !== agentRaw) {
candidates.push(agentInternal);
}
}
if (includeAliasFallback && (keyRaw === "main" || keyRaw === "current")) {
const defaultMainKey = buildAgentMainSessionKey({
agentId: DEFAULT_AGENT_ID,
mainKey: params.mainKey,
});
if (!candidates.includes(defaultMainKey)) {
candidates.push(defaultMainKey);
}
}
const resolved = resolveSessionEntryCandidateTarget({
agentId: params.agentId,
candidateKeys: candidates,
cfg: params.cfg,
});
return resolved
? {
entry: resolved.entry,
key: resolved.sessionKey,
persisted: resolved.persisted,
}
: null;
}
/** Maps requester keys into the currently selected agent store's legacy main key shape. */
export function resolveStoreScopedRequesterKey(params: {
agentId: string;
mainKey: string;
requesterKey: string;
}) {
const parsed = parseAgentSessionKey(params.requesterKey);
if (!parsed || parsed.agentId !== params.agentId) {
return params.requesterKey;
}
return parsed.rest === params.mainKey ? params.mainKey : params.requesterKey;
}
function synthesizeImplicitCurrentSessionEntry(): SessionEntry {
return {
sessionId: "",
updatedAt: Date.now(),
};
}
/** Returns a synthesized current-session entry without writing it to storage. */
export function resolveImplicitCurrentSessionFallback(params: {
agentId: string;
allowFallback: boolean;
cfg: OpenClawConfig;
fallbackKey: string;
}): ResolvedStatusSessionEntry | null {
const fallbackKey = params.fallbackKey.trim();
if (!params.allowFallback || !fallbackKey) {
return null;
}
const resolved = resolveSessionEntryCandidateTarget({
agentId: params.agentId,
candidateKeys: [],
cfg: params.cfg,
fallback: {
sessionKey: fallbackKey,
entry: synthesizeImplicitCurrentSessionEntry(),
},
});
return resolved
? {
entry: resolved.entry,
key: resolved.sessionKey,
persisted: resolved.persisted,
}
: null;
}
/** Lists policy-key fallbacks for implicit default-account direct status lookups. */
export function listImplicitDefaultDirectFallbackKeys(params: {
keyRaw: string;
mainKey: string;
}): string[] {
const parsed = parseAgentSessionKey(params.keyRaw.trim());
if (!parsed) {
return [];
}
const parts = parsed.rest.split(":");
if (parts.length < 4 || parts[1] !== "default" || parts[2] !== "direct") {
return [];
}
const channel = parts[0];
const peerParts = parts.slice(3);
if (!channel || peerParts.length === 0) {
return [];
}
const candidates = [
`agent:${parsed.agentId}:${channel}:direct:${peerParts.join(":")}`,
buildAgentMainSessionKey({
agentId: parsed.agentId,
mainKey: params.mainKey,
}),
params.mainKey,
];
return uniqueStrings(candidates);
}

View File

@@ -3,8 +3,8 @@
*
* Reports and updates session runtime state, model overrides, visibility, task status, and delivery context.
*/
import { randomUUID } from "node:crypto";
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { Type } from "typebox";
import type {
ElevatedLevel,
@@ -14,11 +14,9 @@ import type {
} from "../../auto-reply/thinking.js";
import { getRuntimeConfig } from "../../config/config.js";
import {
loadSessionStore,
mergeSessionEntry,
patchSessionEntryWithKey,
resolveStorePath,
type SessionEntry,
updateSessionStore,
} from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { triggerSessionPatchHook } from "../../gateway/session-patch-hooks.js";
@@ -26,7 +24,6 @@ import { resolveSessionModelIdentityRef } from "../../gateway/session-utils.js";
import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js";
import {
buildAgentMainSessionKey,
DEFAULT_AGENT_ID,
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "../../routing/session-key.js";
@@ -59,12 +56,17 @@ import {
} from "../tool-description-presets.js";
import type { AnyAgentTool } from "./common.js";
import { normalizeToolModelOverride, readStringParam } from "./common.js";
import {
listImplicitDefaultDirectFallbackKeys,
resolveImplicitCurrentSessionFallback,
resolveSessionStatusEntry,
resolveStoreScopedRequesterKey,
} from "./session-status-session-resolve.js";
import {
createAgentToAgentPolicy,
createSessionVisibilityGuard,
resolveCurrentSessionClientAlias,
resolveEffectiveSessionToolsVisibility,
resolveInternalSessionKey,
resolveSandboxedSessionToolContext,
resolveSessionReference,
resolveVisibleSessionReference,
@@ -88,121 +90,6 @@ function loadCommandsStatusRuntime(): Promise<CommandsStatusRuntimeModule> {
return commandsStatusRuntimeLoader.load();
}
function resolveSessionEntry(params: {
store: Record<string, SessionEntry>;
keyRaw: string;
alias: string;
mainKey: string;
requesterInternalKey?: string;
includeAliasFallback?: boolean;
}): { key: string; entry: SessionEntry } | null {
const keyRaw = params.keyRaw.trim();
if (!keyRaw) {
return null;
}
const includeAliasFallback = params.includeAliasFallback ?? true;
const internal = resolveInternalSessionKey({
key: keyRaw,
alias: params.alias,
mainKey: params.mainKey,
requesterInternalKey: params.requesterInternalKey,
});
const candidates: string[] = [keyRaw];
if (!keyRaw.startsWith("agent:")) {
candidates.push(`agent:${DEFAULT_AGENT_ID}:${keyRaw}`);
}
if (includeAliasFallback && internal !== keyRaw) {
candidates.push(internal);
}
if (includeAliasFallback && !keyRaw.startsWith("agent:")) {
const agentInternal = `agent:${DEFAULT_AGENT_ID}:${internal}`;
const agentRaw = `agent:${DEFAULT_AGENT_ID}:${keyRaw}`;
if (agentInternal !== agentRaw) {
candidates.push(agentInternal);
}
}
if (includeAliasFallback && (keyRaw === "main" || keyRaw === "current")) {
const defaultMainKey = buildAgentMainSessionKey({
agentId: DEFAULT_AGENT_ID,
mainKey: params.mainKey,
});
if (!candidates.includes(defaultMainKey)) {
candidates.push(defaultMainKey);
}
}
for (const key of candidates) {
const entry = params.store[key];
if (entry) {
return { key, entry };
}
}
return null;
}
function resolveStoreScopedRequesterKey(params: {
requesterKey: string;
agentId: string;
mainKey: string;
}) {
const parsed = parseAgentSessionKey(params.requesterKey);
if (!parsed || parsed.agentId !== params.agentId) {
return params.requesterKey;
}
return parsed.rest === params.mainKey ? params.mainKey : params.requesterKey;
}
function synthesizeImplicitCurrentSessionEntry(): SessionEntry {
return {
sessionId: "",
updatedAt: Date.now(),
};
}
function resolveImplicitCurrentSessionFallback(params: {
allowFallback: boolean;
fallbackKey: string;
}): { key: string; entry: SessionEntry } | null {
const fallbackKey = params.fallbackKey.trim();
if (!params.allowFallback || !fallbackKey) {
return null;
}
return {
key: fallbackKey,
entry: synthesizeImplicitCurrentSessionEntry(),
};
}
function listImplicitDefaultDirectFallbackKeys(params: {
keyRaw: string;
mainKey: string;
}): string[] {
const parsed = parseAgentSessionKey(params.keyRaw.trim());
if (!parsed) {
return [];
}
const parts = parsed.rest.split(":");
if (parts.length < 4 || parts[1] !== "default" || parts[2] !== "direct") {
return [];
}
const channel = parts[0];
const peerParts = parts.slice(3);
if (!channel || peerParts.length === 0) {
return [];
}
const candidates = [
`agent:${parsed.agentId}:${channel}:direct:${peerParts.join(":")}`,
buildAgentMainSessionKey({
agentId: parsed.agentId,
mainKey: params.mainKey,
}),
params.mainKey,
];
return uniqueStrings(candidates);
}
type ActiveStatusModelIdentity = { provider?: string; model: string };
type SessionStatusOriginDetails = {
@@ -642,7 +529,6 @@ export function createSessionStatusTool(opts?: {
? resolveAgentIdFromSessionKey(requestedKeyInput)
: requesterAgentId;
let storePath = resolveStorePath(cfg.session?.store, { agentId });
let store = loadSessionStore(storePath);
let storeScopedRequesterKey = resolveStoreScopedRequesterKey({
requesterKey: effectiveRequesterKey,
agentId,
@@ -650,8 +536,9 @@ export function createSessionStatusTool(opts?: {
});
// Resolve against the requester-scoped store first to avoid leaking default agent data.
let resolved = resolveSessionEntry({
store,
let resolved = resolveSessionStatusEntry({
cfg,
agentId,
keyRaw: requestedKeyRaw,
alias,
mainKey,
@@ -687,14 +574,14 @@ export function createSessionStatusTool(opts?: {
requestedKeyInput = requestedKeyRaw.trim();
agentId = resolveAgentIdFromSessionKey(visibleSession.key);
storePath = resolveStorePath(cfg.session?.store, { agentId });
store = loadSessionStore(storePath);
storeScopedRequesterKey = resolveStoreScopedRequesterKey({
requesterKey: effectiveRequesterKey,
agentId,
mainKey,
});
resolved = resolveSessionEntry({
store,
resolved = resolveSessionStatusEntry({
cfg,
agentId,
keyRaw: requestedKeyRaw,
alias,
mainKey,
@@ -706,8 +593,9 @@ export function createSessionStatusTool(opts?: {
}
if (!resolved && requestedKeyInput === "current" && effectiveRequesterLookupKey) {
resolved = resolveSessionEntry({
store,
resolved = resolveSessionStatusEntry({
cfg,
agentId,
keyRaw: effectiveRequesterLookupKey,
alias,
mainKey,
@@ -717,8 +605,9 @@ export function createSessionStatusTool(opts?: {
}
if (!resolved && requestedKeyInput === "current") {
resolved = resolveSessionEntry({
store,
resolved = resolveSessionStatusEntry({
cfg,
agentId,
keyRaw: requestedKeyRaw,
alias,
mainKey,
@@ -732,8 +621,9 @@ export function createSessionStatusTool(opts?: {
keyRaw: requestedKeyRaw,
mainKey,
})) {
resolved = resolveSessionEntry({
store,
resolved = resolveSessionStatusEntry({
cfg,
agentId,
keyRaw: fallbackKey,
alias,
mainKey,
@@ -750,7 +640,9 @@ export function createSessionStatusTool(opts?: {
if (!resolved) {
const runSessionFallbackKey = opts?.runSessionKey?.trim();
const fallback = resolveImplicitCurrentSessionFallback({
agentId,
allowFallback: isSemanticCurrentRequest || requestedKeyParam === undefined,
cfg,
fallbackKey:
(isSemanticCurrentRequest || isImplicitRunSessionStatus) && runSessionFallbackKey
? runSessionFallbackKey
@@ -793,46 +685,66 @@ export function createSessionStatusTool(opts?: {
sessionEntry: resolved.entry,
agentId,
});
const modelSelection =
selection.kind === "reset"
? {
provider: configured.provider,
model: configured.model,
isDefault: true,
}
: {
provider: selection.provider,
model: selection.model,
isDefault: selection.isDefault,
};
const nextEntry: SessionEntry = { ...resolved.entry };
const applied = applyModelOverrideToSessionEntry({
entry: nextEntry,
selection:
selection.kind === "reset"
? {
provider: configured.provider,
model: configured.model,
isDefault: true,
}
: {
provider: selection.provider,
model: selection.model,
isDefault: selection.isDefault,
},
selection: modelSelection,
markLiveSwitchPending: true,
});
if (applied.updated) {
const persistedEntry = nextEntry.sessionId.trim()
? nextEntry
: (() => {
const persistedEntryPatch: Partial<SessionEntry> = { ...nextEntry };
delete persistedEntryPatch.sessionId;
const existingEntry = store[resolved.key];
const existingWithValidSessionId = existingEntry?.sessionId?.trim()
? existingEntry
: undefined;
return mergeSessionEntry(existingWithValidSessionId, persistedEntryPatch);
})();
store[resolved.key] = persistedEntry;
await updateSessionStore(storePath, (nextStore) => {
nextStore[resolved.key] = persistedEntry;
});
resolved.entry = persistedEntry;
const patchResult = await patchSessionEntryWithKey(
{
agentId,
sessionKey: resolved.key,
storePath,
},
(entry, context) => {
const persistedEntryPatch: SessionEntry = { ...entry };
applyModelOverrideToSessionEntry({
entry: persistedEntryPatch,
selection: modelSelection,
markLiveSwitchPending: true,
});
if (
!persistedEntryPatch.sessionId.trim() &&
!context.existingEntry?.sessionId?.trim()
) {
persistedEntryPatch.sessionId = randomUUID();
}
return persistedEntryPatch;
},
{
fallbackEntry: resolved.persisted ? undefined : resolved.entry,
replaceEntry: true,
},
);
if (!patchResult) {
throw new Error(`Unknown sessionKey: ${resolved.key}`);
}
const persistedEntry = patchResult.entry;
resolved = {
entry: persistedEntry,
key: patchResult.sessionKey,
persisted: true,
};
triggerSessionPatchHook({
cfg,
sessionEntry: persistedEntry,
sessionKey: resolved.key,
sessionKey: patchResult.sessionKey,
patch: {
key: resolved.key,
key: patchResult.sessionKey,
model: selection.kind === "reset" ? null : `${selection.provider}/${selection.model}`,
},
});

View File

@@ -0,0 +1,38 @@
// Canvas-render tests cover [embed] shortcode extraction and text stripping.
import { describe, expect, it } from "vitest";
import { extractCanvasShortcodes } from "./canvas-render.ts";
describe("extractCanvasShortcodes", () => {
it("does not let a self-closing embed start a greedy block match", () => {
// Regression: the block regex used to greedily swallow the span from a
// self-closing "[embed ... /]" open tag up to a later stray "[/embed]",
// deleting the visible text in between (" keep me ") from channel delivery.
const input = '[embed url="https://a.com" /] keep me [/embed]';
const { text, previews } = extractCanvasShortcodes(input);
expect(previews).toHaveLength(1);
expect(previews[0]?.url).toBe("https://a.com");
// The visible text between the self-closing embed and the stray close
// marker must be preserved, not silently stripped.
expect(text).toContain("keep me");
expect(text).toBe("keep me [/embed]");
});
it("still extracts a normal block embed and strips only the shortcode span", () => {
const input = 'before [embed ref="doc1"] hi [/embed] after';
const { text, previews } = extractCanvasShortcodes(input);
expect(previews).toHaveLength(1);
expect(previews[0]?.viewId).toBe("doc1");
expect(text).toBe("before after");
});
it("still extracts a plain self-closing embed and keeps surrounding text", () => {
const input = 'see [embed url="https://b.com" /] end';
const { text, previews } = extractCanvasShortcodes(input);
expect(previews).toHaveLength(1);
expect(previews[0]?.url).toBe("https://b.com");
expect(text).toBe("see end");
});
});

View File

@@ -203,7 +203,10 @@ export function extractCanvasShortcodes(text: string | undefined): {
attrs: Record<string, string>;
body?: string;
}> = [];
const blockRe = /\[embed\s+([^\]]*?)\]([\s\S]*?)\[\/embed\]/gi;
// Exclude a self-closing open tag ("[embed ... /]") from starting a block
// match by requiring the attrs group not to end with a slash; otherwise the
// block regex greedily swallows visible text up to a later stray [/embed].
const blockRe = /\[embed\s+([^\]]*?[^\]/]|)\]([\s\S]*?)\[\/embed\]/gi;
const selfClosingRe = /\[embed\s+([^\]]*?)\/\]/gi;
for (const re of [blockRe, selfClosingRe]) {
let match: RegExpExecArray | null;

View File

@@ -23,18 +23,6 @@ describe("realredactConfigSnapshot_real", () => {
apiKey: "6789",
},
},
tools: {
exec: {
env: {
REGION: "exec-secret",
CREDENTIAL: {
source: "env",
provider: "default",
id: "REFERRALS_CREDENTIAL",
},
},
},
},
},
],
},
@@ -44,24 +32,9 @@ 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,10 +773,6 @@ 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":

View File

@@ -88,7 +88,6 @@ 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, "", {});
@@ -102,7 +101,6 @@ 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", () => {
@@ -121,7 +119,6 @@ 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, "", {});
@@ -135,7 +132,6 @@ 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", () => {
@@ -192,7 +188,6 @@ 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,9 +239,6 @@ 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;
@@ -320,9 +317,6 @@ 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,8 +214,6 @@ 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":

View File

@@ -1040,16 +1040,6 @@ 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

@@ -13,12 +13,16 @@ export * from "./sessions/reset.js";
export {
canonicalizeSessionEntryAliases,
deleteSessionEntryLifecycle,
patchSessionEntryWithKey,
resetSessionEntryLifecycle,
resolveSessionEntryCandidateTarget,
type CanonicalizeSessionEntryAliasesResult,
type DeleteSessionEntryLifecycleParams,
type DeleteSessionEntryLifecycleResult,
type ResolvedSessionEntryCandidateTarget,
type ResetSessionEntryLifecycleParams,
type ResetSessionEntryLifecycleResult,
type SessionEntryCandidateAccessScope,
type SessionLifecycleArchivedTranscript,
type SessionLifecycleStoreTarget,
} from "./sessions/session-accessor.js";

View File

@@ -28,6 +28,7 @@ import {
publishTranscriptUpdate,
readSessionUpdatedAt,
replaceSessionEntry,
resolveSessionEntryCandidateTarget,
resolveSessionEntryAccessTarget,
restoreSessionFromCompactionCheckpoint,
resolveSessionTranscriptReadTarget,
@@ -226,6 +227,69 @@ describe("session accessor file-backed seam", () => {
expect(persisted.main).toBeUndefined();
});
it("resolves status-style ordered candidate keys without exposing the store", async () => {
fs.writeFileSync(
storePath,
JSON.stringify({
"agent:main:current": {
label: "literal-current",
sessionId: "session-current",
updatedAt: 30,
},
"agent:main:main": {
label: "main",
sessionId: "session-main",
updatedAt: 10,
},
} satisfies Record<string, SessionEntry>),
"utf8",
);
const resolved = resolveSessionEntryCandidateTarget({
agentId: "main",
candidateKeys: ["agent:main:main", "agent:main:current"],
cfg: { session: { store: storePath } },
});
expect(resolved).toEqual({
agentId: "main",
candidateKey: "agent:main:main",
entry: expect.objectContaining({
label: "main",
sessionId: "session-main",
}),
persisted: true,
sessionKey: "agent:main:main",
});
});
it("returns an implicit candidate fallback without persisting it", () => {
const resolved = resolveSessionEntryCandidateTarget({
agentId: "main",
candidateKeys: ["agent:main:missing"],
cfg: { session: { store: storePath } },
fallback: {
sessionKey: "agent:main:current",
entry: {
sessionId: "",
updatedAt: 40,
},
},
});
expect(resolved).toEqual({
agentId: "main",
candidateKey: "agent:main:current",
entry: {
sessionId: "",
updatedAt: 40,
},
persisted: false,
sessionKey: "agent:main:current",
});
expect(fs.existsSync(storePath)).toBe(false);
});
it("purges deleted-agent entries from the current locked store", async () => {
const cfg = {
session: { store: storePath },

View File

@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import {
acquireSessionWriteLock,
resolveSessionWriteLockOptions,
@@ -159,6 +160,35 @@ type ResolvedSessionEntryStoreTarget = ResolvedSessionEntryAccessTarget & {
storePath: string;
};
export type SessionEntryCandidateAccessScope = {
/** Agent owner whose session store is searched. */
agentId: string;
/** Ordered session keys to test inside the resolved store. */
candidateKeys: readonly string[];
/** Runtime config whose session store rule selects the backend target. */
cfg: OpenClawConfig;
/** Environment override used when resolving agent-scoped store paths in tests/tools. */
env?: NodeJS.ProcessEnv;
/** Optional synthesized entry returned only when no candidate exists. */
fallback?: {
entry: SessionEntry;
sessionKey: string;
};
};
export type ResolvedSessionEntryCandidateTarget = {
/** Agent owner whose session store produced this result. */
agentId: string;
/** Candidate key that selected the result, or the fallback key. */
candidateKey: string;
/** Session metadata cloned from storage or from the synthesized fallback. */
entry: SessionEntry;
/** False only for synthesized fallback entries that have not been written. */
persisted: boolean;
/** Persisted key selected by the backend, or the fallback key. */
sessionKey: string;
};
export type ResolvedSessionEntryUpdateContext = Omit<ResolvedSessionEntryAccessTarget, "entry"> & {
/** Mutable entry inside the storage operation. */
entry: SessionEntry;
@@ -747,6 +777,44 @@ export function resolveSessionEntryAccessTarget(
};
}
/** Resolves ordered candidate keys inside one agent-owned session store. */
export function resolveSessionEntryCandidateTarget(
scope: SessionEntryCandidateAccessScope,
): ResolvedSessionEntryCandidateTarget | null {
const storePath = resolveStorePath(scope.cfg.session?.store, {
agentId: scope.agentId,
env: scope.env,
});
const store = loadSessionStore(storePath);
for (const candidateKey of uniqueStrings(scope.candidateKeys.map((key) => key.trim()))) {
if (!candidateKey) {
continue;
}
const resolved = resolveSessionStoreEntry({ store, sessionKey: candidateKey });
if (!resolved.existing) {
continue;
}
return {
agentId: scope.agentId,
candidateKey,
entry: structuredClone(resolved.existing),
persisted: true,
sessionKey: resolved.normalizedKey,
};
}
const fallbackKey = scope.fallback?.sessionKey.trim();
if (!fallbackKey || !scope.fallback) {
return null;
}
return {
agentId: scope.agentId,
candidateKey: fallbackKey,
entry: structuredClone(scope.fallback.entry),
persisted: false,
sessionKey: fallbackKey,
};
}
function resolveSessionEntryStoreTarget(
scope: LogicalSessionAccessScope,
): ResolvedSessionEntryStoreTarget {

View File

@@ -376,13 +376,6 @@ 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.
@@ -423,7 +416,7 @@ export type AgentToolsConfig = {
allowFrom?: AgentElevatedAllowFromConfig;
};
/** Exec tool defaults for this agent. */
exec?: AgentExecToolConfig;
exec?: ExecToolConfig;
/** Filesystem tool path guards. */
fs?: FsToolsConfig;
/** Runtime loop detection for repetitive/ stuck tool-call patterns. */

View File

@@ -455,62 +455,6 @@ 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,7 +10,6 @@ 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";
@@ -594,55 +593,9 @@ 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

@@ -92,8 +92,11 @@ describe("docker build cache layout", () => {
it("does not leave empty shell continuation lines in sandbox-common", async () => {
const dockerfile = await readRepoFile("scripts/docker/sandbox/Dockerfile.common");
expect(dockerfile).not.toContain("apt-get install -y --no-install-recommends ${PACKAGES} \\");
expect(dockerfile).toContain("ARG INSTALL_NODE=1");
expect(dockerfile).toContain("ARG NODE_MAJOR=24");
expect(dockerfile).toContain('curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x"');
expect(dockerfile).toContain(
'RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi',
'RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm && pnpm --version; fi',
);
});

View File

@@ -13,7 +13,7 @@ import {
sanitizeHostExecEnvWithDiagnostics,
sanitizeSystemRunEnvOverrides,
} from "./host-env-security.js";
import { OPENCLAW_CHANNEL_CONTEXT_ENV_VAR, OPENCLAW_CLI_ENV_VALUE } from "./openclaw-exec-env.js";
import { OPENCLAW_CLI_ENV_VALUE } from "./openclaw-exec-env.js";
function findSystemCommandPath(command: string) {
if (process.platform === "win32") {
@@ -1523,46 +1523,6 @@ 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,12 +1,7 @@
// 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,
OPENCLAW_CHANNEL_CONTEXT_ENV_VAR,
OPENCLAW_CLI_ENV_VAR,
} from "./openclaw-exec-env.js";
import { isBlockedObjectKey } from "./prototype-keys.js";
import { markOpenClawExecEnv } from "./openclaw-exec-env.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_()]*$/;
@@ -143,52 +138,6 @@ 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 },
@@ -237,9 +186,6 @@ 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) {
@@ -292,10 +238,6 @@ 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") {
@@ -306,7 +248,7 @@ function sanitizeHostEnvOverridesWithDiagnostics(params?: {
rejectedBlocked.push(upper);
continue;
}
setCaseInsensitiveEnvValue(acceptedOverrides, normalized, value);
acceptedOverrides[normalized] = value;
}
return {
@@ -330,12 +272,7 @@ export function sanitizeHostExecEnvWithDiagnostics(params?: {
continue;
}
const [sanitizedKey, sanitizedValue] = sanitizedEntry;
Object.defineProperty(merged, sanitizedKey, {
configurable: true,
enumerable: true,
writable: true,
value: sanitizedValue,
});
merged[sanitizedKey] = sanitizedValue;
}
const overrideResult = sanitizeHostEnvOverridesWithDiagnostics({
@@ -344,7 +281,7 @@ export function sanitizeHostExecEnvWithDiagnostics(params?: {
});
if (overrideResult.acceptedOverrides) {
for (const [key, value] of Object.entries(overrideResult.acceptedOverrides)) {
setCaseInsensitiveEnvValue(merged, key, value);
merged[key] = value;
}
}

View File

@@ -1,9 +1,6 @@
/** 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

@@ -0,0 +1,60 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { describe, expect, it, vi } from "vitest";
import type { AnyAgentTool } from "../agents/tools/common.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { createPluginToolsMcpServer } from "./plugin-tools-serve.js";
describe("plugin tools MCP client bridge", () => {
it("lists and calls a plugin tool through a real MCP client", async () => {
const execute = vi.fn().mockResolvedValue({
content: [{ type: "text", text: "MCP fact: the codename is ORBIT-9." }],
});
const tool = {
name: "memory_search",
description: "Search memory",
parameters: {
type: "object",
properties: {
query: { type: "string" },
maxResults: { type: "number" },
},
required: ["query"],
},
execute,
} as unknown as AnyAgentTool;
const server = createPluginToolsMcpServer({
config: { plugins: { enabled: true } } as OpenClawConfig,
tools: [tool],
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client(
{ name: "plugin-tools-test-client", version: "0.0.0" },
{ capabilities: {} },
);
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
try {
const listed = await client.listTools();
expect(listed.tools.map((listedTool) => listedTool.name)).toContain("memory_search");
const result = await client.callTool({
name: "memory_search",
arguments: { query: "ORBIT-9 codename", maxResults: 3 },
});
expect(execute).toHaveBeenCalledWith(
expect.stringMatching(/^mcp-\d+$/),
{ query: "ORBIT-9 codename", maxResults: 3 },
expect.any(AbortSignal),
undefined,
);
expect(JSON.stringify(result.content)).toContain("ORBIT-9");
} finally {
await client.close();
await server.close();
}
});
});

View File

@@ -274,6 +274,36 @@ describe("loadPluginMetadataSnapshot process memo", () => {
expect(second.byPluginId.get("demo")).toBe(second.plugins[0]);
});
it("does not emit metadata scan spans for hot memo hits", () => {
const stateDir = tempStateDir();
const timelinePath = path.join(stateDir, "timeline", "metadata.jsonl");
const env = {
OPENCLAW_DIAGNOSTICS: "timeline",
OPENCLAW_DIAGNOSTICS_TIMELINE_PATH: timelinePath,
};
touchPersistedIndex(stateDir);
loadPluginRegistrySnapshotWithMetadata.mockReturnValue({
source: "persisted",
snapshot: makeIndex(),
diagnostics: [],
});
loadPluginMetadataSnapshot({ config: {}, env, stateDir });
loadPluginMetadataSnapshot({ config: {}, env, stateDir });
loadPluginMetadataSnapshot({ config: {}, env, stateDir });
const events = fs
.readFileSync(timelinePath, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { name?: unknown; type?: unknown });
expect(events.map((event) => [event.type, event.name])).toEqual([
["span.start", "plugins.metadata.scan"],
["span.end", "plugins.metadata.scan"],
]);
expect(loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledOnce();
});
it("skips persisted registry filesystem fingerprints after a process memo hit", () => {
const stateDir = tempStateDir();
touchPersistedIndex(stateDir);

View File

@@ -578,16 +578,7 @@ export function loadPluginMetadataSnapshot(
const memoKey = computePluginMetadataSnapshotMemoKey({ params, registryState });
const memo = findPluginMetadataSnapshotMemo(memoKey);
if (memo?.key === memoKey) {
return measureDiagnosticsTimelineSpanSync("plugins.metadata.scan", () => memo.snapshot, {
phase: activeTimelineSpan?.phase ?? "startup",
config: params.config,
env: params.env,
attributes: {
cacheHit: true,
hasWorkspaceDir: params.workspaceDir !== undefined,
hasInstalledIndex: params.index !== undefined,
},
});
return memo.snapshot;
}
const result = measureDiagnosticsTimelineSpanSync(

View File

@@ -1,97 +0,0 @@
/** 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,35 +108,6 @@ 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;
@@ -715,7 +686,6 @@ 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 { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js";
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.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,17 +173,6 @@ 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

@@ -6,7 +6,6 @@ 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

@@ -67,6 +67,7 @@ describe("session accessor boundary guard", () => {
"src/gateway/session-reset-service.ts",
"src/infra/outbound/message-action-tts.ts",
"src/agents/tools/embedded-gateway-stub.ts",
"src/agents/tools/session-status-tool.ts",
"src/agents/tools/sessions-list-tool.ts",
"src/plugins/host-hook-state.ts",
"src/status/status-message.ts",
@@ -100,6 +101,7 @@ describe("session accessor boundary guard", () => {
"src/auto-reply/reply/abort.ts",
"src/agents/subagent-control.ts",
"src/agents/subagent-registry-helpers.ts",
"src/agents/tools/session-status-tool.ts",
"src/auto-reply/reply/abort-cutoff.runtime.ts",
"src/auto-reply/reply/agent-runner-cli-dispatch.ts",
"src/auto-reply/reply/agent-runner-execution.ts",

View File

@@ -14,6 +14,9 @@ describe("sandbox common smoke workflow", () => {
expect(workflow).toContain(
"timeout --kill-after=30s 2m docker run --rm openclaw-sandbox-common-smoke:bookworm-slim",
);
expect(workflow).toContain("node --version");
expect(workflow).toContain("pnpm --version");
expect(workflow).not.toContain("INSTALL_PNPM=0");
expect(workflow).not.toMatch(/(^|\n)\s+docker build -t openclaw-sandbox-smoke-base/u);
expect(workflow).not.toContain(
'u="$(docker run --rm openclaw-sandbox-common-smoke:bookworm-slim',

View File

@@ -3,6 +3,7 @@ import { describe, expect, it, vi, afterEach } from "vitest";
import {
buildPeakErrorHours,
buildUsageMosaicStats,
formatTokens,
getHourAndWeekdayForUtcQuarterBucket,
sessionTouchesSelectedHours,
} from "./usage-metrics.ts";
@@ -411,3 +412,28 @@ describe("usage mosaic token buckets", () => {
expect(sessionTouchesSelectedHours(session, [11], "utc")).toBe(true);
});
});
describe("formatTokens", () => {
it("formats values below 1,000 verbatim", () => {
expect(formatTokens(0)).toBe("0");
expect(formatTokens(999)).toBe("999");
});
it("formats thousands with one decimal and a K suffix", () => {
expect(formatTokens(1_000)).toBe("1.0K");
expect(formatTokens(12_500)).toBe("12.5K");
expect(formatTokens(999_949)).toBe("999.9K");
});
it("rolls 999,950-999,999 over to the M branch instead of '1000.0K'", () => {
// These values round up to "1000.0" at one-decimal thousands precision.
// Without the rollover guard they render the nonsensical "1000.0K".
expect(formatTokens(999_950)).toBe("1.0M");
expect(formatTokens(999_999)).toBe("1.0M");
});
it("formats millions with one decimal and an M suffix", () => {
expect(formatTokens(1_000_000)).toBe("1.0M");
expect(formatTokens(2_500_000)).toBe("2.5M");
});
});

View File

@@ -20,7 +20,16 @@ function formatTokens(n: number): string {
return `${(n / 1_000_000).toFixed(1)}M`;
}
if (n >= 1_000) {
return `${(n / 1_000).toFixed(1)}K`;
// Values from 999,950-999,999 round to "1000.0" at one-decimal
// thousands precision, which would display the nonsensical "1000.0K"
// instead of rolling over to the M branch above. Re-check the
// rounded result before formatting. Mirrors the guard in
// formatCompactTokenCount (../chat/token-format.ts).
const thousands = (n / 1_000).toFixed(1);
if (Number(thousands) >= 1_000) {
return `${(n / 1_000_000).toFixed(1)}M`;
}
return `${thousands}K`;
}
return String(n);
}