mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-25 00:34:16 +08:00
Compare commits
16 Commits
codex/agen
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f29dbd3ebd | ||
|
|
3217165be7 | ||
|
|
dbe2802cdc | ||
|
|
5f25651fd9 | ||
|
|
d7c69da6a6 | ||
|
|
e77994ed5a | ||
|
|
db3307b02a | ||
|
|
6b1755aa2b | ||
|
|
fa2379dbc8 | ||
|
|
ce6d97d580 | ||
|
|
d1c2934d0d | ||
|
|
605aede38c | ||
|
|
6163b1977b | ||
|
|
eabc12b7d6 | ||
|
|
b58e6e0734 | ||
|
|
d83cd282c6 |
5
.github/workflows/sandbox-common-smoke.yml
vendored
5
.github/workflows/sandbox-common-smoke.yml
vendored
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1863,6 +1863,7 @@ export async function runDiscordQaLive(params: {
|
||||
generatedAt: finishedAt,
|
||||
primaryModel,
|
||||
providerMode,
|
||||
repoRoot,
|
||||
transportId: "discord",
|
||||
});
|
||||
await fs.writeFile(
|
||||
|
||||
@@ -2037,6 +2037,7 @@ export async function runSlackQaLive(params: {
|
||||
generatedAt: finishedAt,
|
||||
primaryModel,
|
||||
providerMode,
|
||||
repoRoot,
|
||||
transportId: "slack",
|
||||
});
|
||||
await fs.writeFile(
|
||||
|
||||
@@ -2188,6 +2188,7 @@ export async function runTelegramQaLive(params: {
|
||||
generatedAt: finishedAt,
|
||||
primaryModel,
|
||||
providerMode,
|
||||
repoRoot,
|
||||
checks: scenarioResults,
|
||||
transportId: "telegram",
|
||||
});
|
||||
|
||||
@@ -3282,6 +3282,7 @@ export async function runWhatsAppQaLive(params: {
|
||||
generatedAt: finishedAt,
|
||||
primaryModel,
|
||||
providerMode,
|
||||
repoRoot,
|
||||
transportId: "whatsapp",
|
||||
});
|
||||
await fs.writeFile(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
48
extensions/telegram/src/truncate.test.ts
Normal file
48
extensions/telegram/src/truncate.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
20
extensions/telegram/src/truncate.ts
Normal file
20
extensions/telegram/src/truncate.ts
Normal 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()}…`;
|
||||
}
|
||||
@@ -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)'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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; \
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}" \
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
154
src/agents/tools/session-status-session-resolve.ts
Normal file
154
src/agents/tools/session-status-session-resolve.ts
Normal 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);
|
||||
}
|
||||
@@ -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}`,
|
||||
},
|
||||
});
|
||||
|
||||
38
src/chat/canvas-render.test.ts
Normal file
38
src/chat/canvas-render.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
60
src/mcp/plugin-tools-mcp-client.test.ts
Normal file
60
src/mcp/plugin-tools-mcp-client.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user