Compare commits

..

1 Commits

Author SHA1 Message Date
Peter Steinberger
8720b36510 feat: add agent-scoped exec environments 2026-06-23 23:15:16 -07:00
71 changed files with 1223 additions and 1319 deletions

1
.github/labeler.yml vendored
View File

@@ -118,7 +118,6 @@
- any-glob-to-any-file:
- "extensions/qa-lab/**"
- "qa/scenarios/**"
- "docs/maturity/**"
- "docs/concepts/qa-e2e-automation.md"
- "docs/concepts/personal-agent-benchmark-pack.md"
- "docs/channels/qa-channel.md"

View File

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

View File

@@ -1,2 +1,2 @@
f7247b5bbfe3f96bffffd25a8be2f89b37999e36731f34a159ae21ded1cedd05 plugin-sdk-api-baseline.json
ce88a53dadc194ceccc63f50146aee03a1a425f551117da826a21519d5bf80db plugin-sdk-api-baseline.jsonl
95f2562304eebefd432c7694a90b860e4611f989e77bd3214b7c2cbeabba1882 plugin-sdk-api-baseline.json
5d2c93807dae6e142616d82b0718964326ce46389bf81288972bbf664af64ae7 plugin-sdk-api-baseline.jsonl

View File

@@ -24,14 +24,6 @@ This directory owns docs authoring, Mintlify link rules, and docs i18n policy.
- `scripts/docs-sync-publish.mjs` excludes and prunes `docs/internal/**` from the public `openclaw/docs` publish repo if a page is force-added later.
- Internal docs may mention repo paths, private app names, 1Password item names, and runbooks, but never include secret values.
## Maturity Scorecard Editing
`taxonomy.yaml` and `qa/maturity-scores.yaml` are the source inputs; generated maturity docs under `docs/maturity/` are projections and should not be hand-edited for score, LTS, taxonomy, QA profile, or evidence tables.
`scripts/qa/render-maturity-docs.ts` owns generation; use `pnpm maturity:render` to refresh committed docs and `pnpm maturity:check` to verify them.
`.github/workflows/maturity-scorecard.yml` renders artifact previews and can open generated-doc PRs; `.github/workflows/openclaw-release-checks.yml` dispatches it for release QA.
Keep deterministic `qa-evidence.json.scorecard` data in GitHub Actions artifacts unless a maintainer explicitly asks for a sanitized committed projection.
Human overrides must change source state in a PR and explain the reason plus public or redacted evidence.
## Docs i18n
- Foreign-language docs are not maintained in this repo. The generated publish output lives in the separate `openclaw/docs` repo (often cloned locally as `../openclaw-docs`).

View File

@@ -68,7 +68,7 @@ Slim evidence omits per-entry `execution` and sets `evidenceMode: "slim"`;
```bash
pnpm openclaw qa run \
--qa-profile smoke-ci \
--category channel-framework.conversation-routing-and-delivery \
--category agent-runtime-and-provider-execution.agent-turn-execution \
--provider-mode mock-openai \
--output-dir .artifacts/qa-e2e/smoke-ci-profile-dispatch
```
@@ -966,7 +966,6 @@ output and whose artifact paths are resolved relative to that producer
`qa run --qa-profile`, the same `qa-evidence.json` also includes the profile
scorecard summary for the selected taxonomy categories.
Treat it as a discovery aid, not a gate replacement; the selected scenario still needs the right provider mode, live transport, Multipass, Testbox, or release lane for the behavior under test.
For scorecard context, see [Maturity scorecard](/maturity/scorecard).
For character and style checks, run the same scenario across multiple live model
refs and write a judged Markdown report:
@@ -1024,7 +1023,6 @@ When no `--judge-model` is passed, the judges default to
## Related docs
- [Matrix QA](/concepts/qa-matrix)
- [Maturity scorecard](/maturity/scorecard)
- [Personal agent benchmark pack](/concepts/personal-agent-benchmark-pack)
- [QA Channel](/channels/qa-channel)
- [Testing](/help/testing)

View File

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

View File

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

View File

@@ -20,7 +20,6 @@ of Docker runners. This doc is a "how we test" guide:
- [QA overview](/concepts/qa-e2e-automation) - architecture, command surface, scenario authoring.
- [Matrix QA](/concepts/qa-matrix) - reference for `pnpm openclaw qa matrix`.
- [Maturity scorecard](/maturity/scorecard) - how release QA evidence supports stability and LTS decisions.
- [QA channel](/channels/qa-channel) - the synthetic transport plugin used by repo-backed scenarios.
This page covers running the regular test suites and Docker/Parallels runners. The QA-specific runners section below ([QA-specific runners](#qa-specific-runners)) lists the concrete `qa` invocations and points back at the references above.

View File

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

View File

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

View File

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

View File

@@ -121,46 +121,6 @@ describe("compileMemoryWikiVault", () => {
).resolves.toContain('"text":"Alpha is the canonical source page."');
});
it("discovers pages in nested subdirectories during compile", async () => {
const { rootDir, config } = await createVault({
rootDir: nextCaseRoot(),
initialize: true,
});
await fs.mkdir(path.join(rootDir, "sources", "sub"), { recursive: true });
await fs.writeFile(
path.join(rootDir, "sources", "top.md"),
renderWikiMarkdown({
frontmatter: { pageType: "source", id: "source.top", title: "Top Source" },
body: "# Top Source\n",
}),
"utf8",
);
await fs.writeFile(
path.join(rootDir, "sources", "sub", "nested.md"),
renderWikiMarkdown({
frontmatter: { pageType: "source", id: "source.nested", title: "Nested Source" },
body: "# Nested Source\n",
}),
"utf8",
);
const result = await compileMemoryWikiVault(config);
expect(result.pageCounts.source).toBe(2);
// Root index should link to both
await expect(fs.readFile(path.join(rootDir, "index.md"), "utf8")).resolves.toContain(
"[Top Source](sources/top.md)",
);
await expect(fs.readFile(path.join(rootDir, "index.md"), "utf8")).resolves.toContain(
"[Nested Source](sources/sub/nested.md)",
);
// Sources index should link to nested file
await expect(fs.readFile(path.join(rootDir, "sources", "index.md"), "utf8")).resolves.toContain(
"[Nested Source](sub/nested.md)",
);
});
it("renders native directory index links relative to each generated index", async () => {
const { rootDir, config } = await createVault({
rootDir: nextCaseRoot(),

View File

@@ -364,15 +364,10 @@ export type RefreshMemoryWikiIndexesResult = {
async function collectMarkdownFiles(rootDir: string, relativeDir: string): Promise<string[]> {
const dirPath = path.join(rootDir, relativeDir);
const entries = await fs
.readdir(dirPath, { withFileTypes: true, recursive: true })
.catch(() => []);
const entries = await fs.readdir(dirPath, { withFileTypes: true }).catch(() => []);
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
.map((entry) => {
const absPath = path.join(entry.parentPath ?? dirPath, entry.name);
return path.relative(rootDir, absPath).split(path.sep).join("/");
})
.map((entry) => path.join(relativeDir, entry.name))
.filter((relativePath) => path.basename(relativePath) !== "index.md")
.toSorted((left, right) => left.localeCompare(right));
}

View File

@@ -1067,35 +1067,6 @@ describe("searchMemoryWiki", () => {
]);
});
it("discovers pages in nested subdirectories", async () => {
const { rootDir, config } = await createQueryVault({
initialize: true,
});
await fs.mkdir(path.join(rootDir, "sources", "sub"), { recursive: true });
await fs.writeFile(
path.join(rootDir, "sources", "top.md"),
renderWikiMarkdown({
frontmatter: { pageType: "source", id: "source.top", title: "Top Source" },
body: "# Top Source\n",
}),
"utf8",
);
await fs.writeFile(
path.join(rootDir, "sources", "sub", "nested.md"),
renderWikiMarkdown({
frontmatter: { pageType: "source", id: "source.nested", title: "Nested Source" },
body: "# Nested Source\n",
}),
"utf8",
);
const results = await searchMemoryWiki({ config, query: "Source" });
expect(results).toHaveLength(2);
const paths = results.map((r) => r.path).toSorted();
expect(paths).toEqual(["sources/sub/nested.md", "sources/top.md"]);
});
it("drops gateway-style owner-qualified session hits that collide with the scoped store", async () => {
const { config } = await createQueryVault({
initialize: true,

View File

@@ -245,17 +245,12 @@ async function listWikiMarkdownFiles(rootDir: string): Promise<string[]> {
await Promise.all(
QUERY_DIRS.map(async (relativeDir) => {
const dirPath = path.join(rootDir, relativeDir);
const entries = await fs
.readdir(dirPath, { withFileTypes: true, recursive: true })
.catch(() => []);
const entries = await fs.readdir(dirPath, { withFileTypes: true }).catch(() => []);
return entries
.filter(
(entry) => entry.isFile() && entry.name.endsWith(".md") && entry.name !== "index.md",
)
.map((entry) => {
const absPath = path.join(entry.parentPath ?? dirPath, entry.name);
return path.relative(rootDir, absPath).split(path.sep).join("/");
});
.map((entry) => path.join(relativeDir, entry.name));
}),
)
).flat();

View File

@@ -92,39 +92,6 @@ describe("resolveMemoryWikiStatus", () => {
expect(status.warnings.map((warning) => warning.code)).toContain("bridge-artifacts-missing");
});
it("discovers pages in nested subdirectories", async () => {
const { rootDir, config } = await createVault({
prefix: "memory-wiki-nested-",
initialize: true,
});
await fs.mkdir(path.join(rootDir, "sources", "sub"), { recursive: true });
await fs.writeFile(
path.join(rootDir, "sources", "top.md"),
renderWikiMarkdown({
frontmatter: { pageType: "source", id: "source.top", title: "Top Source" },
body: "# Top Source\n",
}),
"utf8",
);
await fs.writeFile(
path.join(rootDir, "sources", "sub", "nested.md"),
renderWikiMarkdown({
frontmatter: { pageType: "source", id: "source.nested", title: "Nested Source" },
body: "# Nested Source\n",
}),
"utf8",
);
const status = await resolveMemoryWikiStatus(config, {
pathExists: async () => true,
resolveCommand: async () => null,
});
expect(status.pageCounts.source).toBe(2);
expect(status.sourceCounts.native).toBe(2);
});
it("counts source provenance from the vault", async () => {
const { rootDir, config } = await createVault({
prefix: "memory-wiki-status-",

View File

@@ -87,28 +87,26 @@ async function collectVaultCounts(vaultPath: string): Promise<{
};
const dirs = ["entities", "concepts", "sources", "syntheses", "reports"] as const;
for (const dir of dirs) {
const dirPath = path.join(vaultPath, dir);
const entries = await fs
.readdir(dirPath, { withFileTypes: true, recursive: true })
.readdir(path.join(vaultPath, dir), { withFileTypes: true })
.catch(() => []);
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".md") || entry.name === "index.md") {
continue;
}
const absolutePath = path.join(entry.parentPath ?? dirPath, entry.name);
const relativeToVault = path.relative(vaultPath, absolutePath).split(path.sep).join("/");
const kind = inferWikiPageKind(relativeToVault);
const kind = inferWikiPageKind(path.join(dir, entry.name));
if (kind) {
pageCounts[kind] += 1;
}
if (dir === "sources") {
const absolutePath = path.join(vaultPath, dir, entry.name);
const raw = await fs.readFile(absolutePath, "utf8").catch(() => null);
if (!raw) {
continue;
}
const page = toWikiPageSummary({
absolutePath,
relativePath: relativeToVault,
relativePath: path.join(dir, entry.name),
raw,
});
if (!page) {

View File

@@ -85,15 +85,6 @@ describe("buildWelcomeCard", () => {
expect(actions[0]?.title).toBe("What can you do?");
});
it("styles the heading with valid PascalCase Adaptive Card enum values", () => {
// Lowercase weight/size fall back to Default in the Teams renderer, so the heading must use the
// schema's PascalCase enums to render bold/medium.
const card = buildWelcomeCard();
const heading = (card.body as Array<{ weight?: string; size?: string }>)[0];
expect(heading?.weight).toBe("Bolder");
expect(heading?.size).toBe("Medium");
});
it("uses custom bot name", () => {
const card = buildWelcomeCard({ botName: "TestBot" });
const body = card.body as Array<{ text: string }>;

View File

@@ -31,10 +31,8 @@ export function buildWelcomeCard(options?: WelcomeCardOptions): Record<string, u
{
type: "TextBlock",
text: `Hi! I'm ${botName}.`,
// Adaptive Card TextWeight/TextSize enums are PascalCase ("Bolder"/"Medium"); lowercase
// values fall back to Default, so the greeting rendered unstyled (matches polls/presentation).
weight: "Bolder",
size: "Medium",
weight: "bolder",
size: "medium",
},
{
type: "TextBlock",

View File

@@ -382,7 +382,7 @@ function buildOpenShellPolicyYaml(params: {
filesystem_policy:
include_workdir: true
read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log, /opt]
read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log]
read_write: [/sandbox, /tmp, /dev/null]
landlock:

View File

@@ -45,21 +45,6 @@ type PendingExec = {
};
const MATERIALIZED_SKILLS_REMOTE_PARTS = [".openclaw", "sandbox-skills"] as const;
export function buildOpenShellDirectoryUploadArgs(params: {
sandboxName: string;
localPath: string;
remotePath: string;
}): string[] {
return [
"sandbox",
"upload",
"--no-git-ignore",
params.sandboxName,
params.localPath,
normalizeRemotePath(params.remotePath),
];
}
export const PINNED_REMOTE_PATH_MUTATION_SCRIPT = [
"set -eu",
'die() { echo "$1" >&2; exit 1; }',
@@ -753,26 +738,26 @@ class OpenShellSandboxBackendImpl {
async ({ dir: tmpDir }) => {
// Stage a symlink-free snapshot so upload never dereferences host paths
// outside the mirrored workspace tree.
const remoteRootName = path.posix.basename(normalizeRemotePath(remotePath));
const remoteRootName = path.posix.basename(remotePath);
const stagedRoot = path.join(tmpDir, remoteRootName);
await stageDirectoryContents({
sourceDir: localPath,
targetDir: stagedRoot,
});
const stagedEntries = (await fs.readdir(stagedRoot)).toSorted();
for (const entry of stagedEntries) {
const result = await runOpenShellCli({
context: this.params.execContext,
args: buildOpenShellDirectoryUploadArgs({
sandboxName: this.params.execContext.sandboxName,
localPath: path.join(stagedRoot, entry),
remotePath,
}),
cwd: this.params.createParams.workspaceDir,
});
if (result.code !== 0) {
throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
}
const result = await runOpenShellCli({
context: this.params.execContext,
args: [
"sandbox",
"upload",
"--no-git-ignore",
this.params.execContext.sandboxName,
stagedRoot,
path.posix.dirname(remotePath),
],
cwd: this.params.createParams.workspaceDir,
});
if (result.code !== 0) {
throw new Error(result.stderr.trim() || "openshell sandbox upload failed");
}
},
);

View File

@@ -29,7 +29,6 @@ const cliMocks = vi.hoisted(() => ({
let createOpenShellSandboxBackendManager: typeof import("./backend.js").createOpenShellSandboxBackendManager;
let createOpenShellSandboxBackendFactory: typeof import("./backend.js").createOpenShellSandboxBackendFactory;
let buildOpenShellDirectoryUploadArgs: typeof import("./backend.js").buildOpenShellDirectoryUploadArgs;
let ensureOpenShellRemoteRealDirectoryScript: typeof import("./backend.js").ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT;
describe("openshell cli helpers", () => {
@@ -173,7 +172,6 @@ describe("openshell backend manager", () => {
};
});
({
buildOpenShellDirectoryUploadArgs,
ENSURE_OPEN_SHELL_REMOTE_REAL_DIRECTORY_SCRIPT: ensureOpenShellRemoteRealDirectoryScript,
createOpenShellSandboxBackendFactory,
createOpenShellSandboxBackendManager,
@@ -189,30 +187,6 @@ describe("openshell backend manager", () => {
vi.clearAllMocks();
});
it("uploads staged directory snapshots to the managed remote directory itself", () => {
expect(
buildOpenShellDirectoryUploadArgs({
sandboxName: "openclaw-session",
localPath: "/tmp/openclaw-upload/sandbox/seed.txt",
remotePath: "/sandbox",
}),
).toEqual([
"sandbox",
"upload",
"--no-git-ignore",
"openclaw-session",
"/tmp/openclaw-upload/sandbox/seed.txt",
"/sandbox",
]);
expect(
buildOpenShellDirectoryUploadArgs({
sandboxName: "openclaw-session",
localPath: "/tmp/openclaw-upload/project",
remotePath: "/sandbox/./project",
}).at(-1),
).toBe("/sandbox/project");
});
it.runIf(process.platform !== "win32")(
"preserves caller positional args after OpenShell remote directory validation",
async () => {

View File

@@ -431,8 +431,8 @@ describe("qa cli runtime", () => {
repoRoot: "/tmp/openclaw-repo",
outputDir: ".artifacts/qa-e2e/smoke-ci",
profile: "smoke-ci",
surface: "channel-framework",
category: "channel-framework.conversation-routing-and-delivery",
surface: "agent-runtime-and-provider-execution",
category: "agent-runtime-and-provider-execution.agent-turn-execution",
scenarioIds: ["dm-chat-baseline"],
transportId: "qa-channel",
fastMode: true,
@@ -482,7 +482,7 @@ describe("qa cli runtime", () => {
expect(evidence.scorecard).not.toHaveProperty("profile");
expect(evidence.scorecard?.features?.fulfilled).toBe(0);
expect(evidence.scorecard?.categoryReports?.[0]).toMatchObject({
id: "channel-framework.conversation-routing-and-delivery",
id: "agent-runtime-and-provider-execution.agent-turn-execution",
features: {
fulfilled: 0,
},
@@ -595,11 +595,11 @@ describe("qa cli runtime", () => {
runQaProfileCommand({
repoRoot: "/tmp/openclaw-repo",
profile: "smoke-ci",
category: "channel-framework.conversation-routing-and-delivery",
category: "agent-runtime-and-provider-execution.agent-turn-execution",
scenarioIds: ["not-a-real-scenario"],
}),
).rejects.toThrow(
"qa run did not find taxonomy scenarios for --qa-profile smoke-ci --category channel-framework.conversation-routing-and-delivery --scenario not-a-real-scenario.",
"qa run did not find taxonomy scenarios for --qa-profile smoke-ci --category agent-runtime-and-provider-execution.agent-turn-execution --scenario not-a-real-scenario.",
);
expect(runQaSuite).not.toHaveBeenCalled();
});

View File

@@ -214,9 +214,9 @@ describe("qa cli registration", () => {
"--qa-profile",
"smoke-ci",
"--surface",
"channel-framework",
"agent-runtime-and-provider-execution",
"--category",
"channel-framework.conversation-routing-and-delivery",
"agent-runtime-and-provider-execution.agent-turn-execution",
"--scenario",
"dm-chat-baseline",
"--evidence-mode",
@@ -239,8 +239,8 @@ describe("qa cli registration", () => {
repoRoot: "/tmp/openclaw-repo",
outputDir: ".artifacts/qa-e2e/smoke-ci",
profile: "smoke-ci",
surface: "channel-framework",
category: "channel-framework.conversation-routing-and-delivery",
surface: "agent-runtime-and-provider-execution",
category: "agent-runtime-and-provider-execution.agent-turn-execution",
scenarioIds: ["dm-chat-baseline"],
evidenceMode: "slim",
transportId: "qa-channel",
@@ -257,7 +257,7 @@ describe("qa cli registration", () => {
it.each([
["--output-dir", [".artifacts/qa-e2e/smoke-ci"]],
["--surface", ["agent-runtime-and-provider-execution"]],
["--category", ["channel-framework.conversation-routing-and-delivery"]],
["--category", ["agent-runtime-and-provider-execution.agent-turn-execution"]],
["--scenario", ["dm-chat-baseline"]],
["--evidence-mode", ["slim"]],
["--exclude-test-execution-evidence", []],

View File

@@ -167,32 +167,6 @@ describe("qa suite", () => {
expect(qaSuiteProgressTesting.sanitizeQaSuiteProgressValue("\u0000\u0001")).toBe("<empty>");
});
it("includes effective channel driver in run start progress logs", () => {
expect(
qaSuiteProgressTesting.formatQaSuiteRunStartProgress({
selectedScenarioCount: 80,
concurrency: 8,
transportId: "qa-channel",
}),
).toBe("run start: scenarios=80 concurrency=8 transport=qa-channel");
expect(
qaSuiteProgressTesting.formatQaSuiteRunStartProgress({
selectedScenarioCount: 80,
concurrency: 1,
transportId: "qa-channel",
channelDriverSelection: {
capabilityMatrixPath: "crabline-fake-provider-capabilities.json",
channel: "telegram",
channelDriver: "crabline",
smokeArtifactPath: "crabline-fake-provider-smoke.json",
},
}),
).toBe(
"run start: scenarios=80 concurrency=1 transport=qa-channel channelDriver=crabline channel=telegram",
);
});
it("records gateway RSS peak and trace samples", () => {
expect(
qaSuiteProgressTesting.buildQaSuiteRuntimeMetrics({

View File

@@ -200,29 +200,6 @@ function writeQaSuiteProgress(enabled: boolean, message: string) {
process.stderr.write(`[qa-suite] ${message}\n`);
}
function formatQaSuiteRunStartProgress(params: {
selectedScenarioCount: number;
concurrency: number;
transportId: QaTransportId;
channelDriver?: QaScorecardChannelDriver | null;
channelDriverSelection?: OpenClawCrablineChannelDriverSelection | null;
}) {
const channelDriver = params.channelDriver ?? params.channelDriverSelection?.channelDriver;
const channel = params.channelDriverSelection?.channel;
const parts = [
`run start: scenarios=${params.selectedScenarioCount}`,
`concurrency=${params.concurrency}`,
`transport=${sanitizeQaSuiteProgressValue(params.transportId)}`,
];
if (channelDriver) {
parts.push(`channelDriver=${sanitizeQaSuiteProgressValue(channelDriver)}`);
}
if (channel) {
parts.push(`channel=${sanitizeQaSuiteProgressValue(channel)}`);
}
return parts.join(" ");
}
async function waitForQaLabReady(baseUrl: string, timeoutMs = 10_000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
@@ -1208,13 +1185,7 @@ export async function runQaFlowSuite(params?: QaSuiteRunParams): Promise<QaSuite
const gatewayHeapCheckpointsEnabled = shouldCaptureGatewayHeapCheckpoints();
writeQaSuiteProgress(
progressEnabled,
formatQaSuiteRunStartProgress({
selectedScenarioCount: selectedScenarios.length,
concurrency,
transportId,
channelDriver: params?.channelDriver,
channelDriverSelection: params?.channelDriverSelection,
}),
`run start: scenarios=${selectedScenarios.length} concurrency=${concurrency} transport=${transportId}`,
);
const useIsolatedScenarioWorkers = shouldRunQaSuiteWithIsolatedScenarioWorkers({
scenarios: selectedScenarios,
@@ -1796,7 +1767,6 @@ export const qaSuiteProgressTesting = {
buildQaGatewayHeapCheckpointRuntimeEnvPatch,
buildQaIsolatedScenarioWorkerParams,
buildQaSuiteRuntimeMetrics,
formatQaSuiteRunStartProgress,
buildQaRuntimeEnvPatch,
mergeQaRuntimeEnvPatches,
parseQaSuiteBooleanEnv,

View File

@@ -1,19 +0,0 @@
// Error-format helper tests cover the non-Error cause stringifier contract.
import { describe, expect, it } from "vitest";
import { stringifyNonErrorCause } from "./error-format.js";
describe("stringifyNonErrorCause", () => {
it("returns a string for values JSON.stringify serializes to undefined", () => {
// JSON.stringify(fn|symbol|undefined) is undefined; the `string`-typed helper must not leak it.
expect(stringifyNonErrorCause(() => {})).toBe("[object Function]");
expect(stringifyNonErrorCause(Symbol("x"))).toBe("[object Symbol]");
expect(stringifyNonErrorCause(undefined)).toBe("[object Undefined]");
});
it("stringifies ordinary scalar and object causes", () => {
expect(stringifyNonErrorCause({ a: 1 })).toBe('{"a":1}');
expect(stringifyNonErrorCause("hi")).toBe("hi");
expect(stringifyNonErrorCause(42)).toBe("42");
expect(stringifyNonErrorCause(null)).toBe("null");
});
});

View File

@@ -75,9 +75,7 @@ export function stringifyNonErrorCause(value: unknown): string {
return String(value);
}
try {
// JSON.stringify returns undefined (not a string) for functions/symbols/undefined; fall back to
// a tag string so this `string`-typed helper never leaks undefined (matches src/infra/errors.ts).
return JSON.stringify(value) ?? Object.prototype.toString.call(value);
return JSON.stringify(value);
} catch {
return Object.prototype.toString.call(value);
}

View File

@@ -9,7 +9,7 @@ scenario:
- telemetry.prometheus-authenticated-gateway-export
secondary:
- harness.qa-lab
- docker.runtime-validation
- docker.e2e
objective: Verify a QA-lab gateway run emits protected, bounded Prometheus diagnostics metrics through the diagnostics-prometheus plugin.
successCriteria:
- The diagnostics-prometheus plugin exposes the protected scrape route.

View File

@@ -6,7 +6,7 @@ scenario:
runtimeParityTier: standard
coverage:
primary:
- runtime.multi-turn-continuity
- runtime.first-hour-20
secondary:
- runtime.long-context
objective: Verify both runtimes preserve a same-session conversation across the required 20-turn maintainer gate.

View File

@@ -6,7 +6,7 @@ scenario:
runtimeParityTier: soak
coverage:
primary:
- runtime.long-run-stability
- runtime.soak-100
secondary:
- runtime.long-context
objective: Provide an optional long-run soak that can be scheduled or run in Testbox without entering the maintainer default gate.

View File

@@ -117,18 +117,6 @@ export function resolvePrepackCommandTimeoutMs(env: NodeJS.ProcessEnv = process.
);
}
export function resolvePrepackCommandStdio(
options: SpawnSyncOptions,
env: NodeJS.ProcessEnv = process.env,
): SpawnSyncOptions["stdio"] {
const requestedStdio = options.stdio ?? "inherit";
const npmJsonOutput = env.npm_config_json === "true" || env.npm_config_json === "1";
if (npmJsonOutput && requestedStdio === "inherit") {
return ["inherit", 2, "inherit"];
}
return requestedStdio;
}
export function runPrepackCommand(
command: string,
args: string[],
@@ -136,10 +124,10 @@ export function runPrepackCommand(
): ReturnType<typeof spawnSync> {
const env = options.env ?? process.env;
return spawnSync(command, args, {
stdio: "inherit",
...options,
env,
killSignal: options.killSignal ?? "SIGKILL",
stdio: resolvePrepackCommandStdio(options, env),
timeout: options.timeout ?? resolvePrepackCommandTimeoutMs(env),
});
}

View File

@@ -4,7 +4,6 @@ import http from "node:http";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js";
import { createBundleMcpJsonSchemaValidator } from "./agent-bundle-mcp-runtime.js";
import { cleanupBundleMcpHarness } from "./agent-bundle-mcp-test-harness.js";
import {
@@ -27,8 +26,6 @@ vi.mock("./embedded-agent-mcp.js", () => ({
}),
}));
const tempDirs: string[] = [];
type RuntimeFactoryOptions = NonNullable<
Parameters<typeof testing.createSessionMcpRuntimeManager>[0]
>;
@@ -40,12 +37,10 @@ async function writeListToolsMcpServer(params: {
filePath: string;
logPath: string;
delayMs?: number;
initializeDelayMs?: number;
hang?: boolean;
inputSchema?: unknown;
tools?: Array<{ name: string; description?: string; inputSchema?: unknown }>;
capabilities?: Record<string, unknown>;
notifyListChangedOnInitialized?: boolean;
listToolsMethodNotFound?: boolean;
callToolIsError?: boolean;
callToolJsonRpcError?: boolean;
@@ -58,10 +53,8 @@ import fs from "node:fs/promises";
const logPath = ${JSON.stringify(params.logPath)};
const delayMs = ${params.delayMs ?? 0};
const initializeDelayMs = ${params.initializeDelayMs ?? 0};
const hang = ${params.hang === true};
const capabilities = ${JSON.stringify(params.capabilities ?? { tools: {} })};
const notifyListChangedOnInitialized = ${params.notifyListChangedOnInitialized === true};
const listToolsMethodNotFound = ${params.listToolsMethodNotFound === true};
const tools = ${JSON.stringify(
params.tools ?? [
@@ -91,7 +84,7 @@ function handle(message) {
}
log("recv " + String(message.method ?? "unknown"));
if (message.method === "initialize") {
const response = {
send({
jsonrpc: "2.0",
id: message.id,
result: {
@@ -99,19 +92,10 @@ function handle(message) {
capabilities,
serverInfo: { name: "test-list-tools", version: "1.0.0" },
},
};
if (initializeDelayMs > 0) {
setTimeout(() => send(response), initializeDelayMs);
} else {
send(response);
}
});
return;
}
if (message.method === "notifications/initialized") {
if (notifyListChangedOnInitialized) {
log("notify tools/list_changed");
send({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
}
return;
}
if (message.method === "tools/list") {
@@ -297,7 +281,6 @@ function makeRuntime(
}
afterEach(async () => {
cleanupTempDirs(tempDirs);
await cleanupBundleMcpHarness();
});
@@ -2052,529 +2035,4 @@ process.stdin.on("end", () => {
}
},
);
it(
"parallelizes MCP server catalog loading across multiple slow servers",
{ timeout: LIST_TOOLS_TEST_DEADLINE_MS },
async () => {
const tempDir = makeTempDir(tempDirs, "bundle-mcp-parallel-");
const delays = [200, 400, 600];
const serverPaths = delays.map((delay, i) => {
const serverPath = path.join(tempDir, `slow-server-${i}.mjs`);
const logPath = path.join(tempDir, `server-${i}.log`);
return { serverPath, logPath, delay, serverName: `slowServer${i}` };
});
await Promise.all(
serverPaths.map(({ serverPath, logPath, delay }) =>
writeListToolsMcpServer({ filePath: serverPath, logPath, delayMs: delay }),
),
);
testing.setBundleMcpCatalogListTimeoutMsForTest(4_000);
const runtime = await getOrCreateSessionMcpRuntime({
sessionId: "session-parallel-catalog-test",
sessionKey: "agent:test:session-parallel-catalog-test",
workspaceDir: "/workspace",
cfg: {
mcp: {
servers: Object.fromEntries(
serverPaths.map(({ serverName, serverPath }) => [
serverName,
{
command: process.execPath,
args: [serverPath],
connectionTimeoutMs: 2_000,
},
]),
),
},
},
});
try {
const sumDelays = delays.reduce((a, b) => a + b, 0);
const maxDelay = Math.max(...delays);
const parallelBudgetMs = maxDelay + 500;
const t0 = performance.now();
const catalog = await runtime.getCatalog();
const wallTime = performance.now() - t0;
// Must have successfully connected to all servers
expect(Object.keys(catalog.servers)).toHaveLength(delays.length);
expect(catalog.tools.map((t) => t.toolName)).toEqual([
"slow_tool",
"slow_tool",
"slow_tool",
]);
// Sequential listing would have to wait roughly sumDelays before overhead;
// parallel listing should stay near the slowest server plus launch overhead.
expect(wallTime).toBeLessThan(parallelBudgetMs);
expect(parallelBudgetMs).toBeLessThan(sumDelays);
expect(wallTime).toBeGreaterThanOrEqual(maxDelay * 0.7);
} finally {
await runtime.dispose();
}
},
);
it(
"awaits in-progress MCP session connections after catalog invalidation",
{ timeout: LIST_TOOLS_TEST_DEADLINE_MS },
async () => {
const tempDir = makeTempDir(tempDirs, "bundle-mcp-inflight-connect-");
const invalidatingServer = {
serverName: "invalidatingServer",
serverPath: path.join(tempDir, "invalidating-server.mjs"),
logPath: path.join(tempDir, "invalidating-server.log"),
};
const slowConnectServer = {
serverName: "slowConnectServer",
serverPath: path.join(tempDir, "slow-connect-server.mjs"),
logPath: path.join(tempDir, "slow-connect-server.log"),
};
await writeListToolsMcpServer({
filePath: invalidatingServer.serverPath,
logPath: invalidatingServer.logPath,
capabilities: { tools: { listChanged: true } },
notifyListChangedOnInitialized: true,
});
await writeListToolsMcpServer({
filePath: slowConnectServer.serverPath,
logPath: slowConnectServer.logPath,
initializeDelayMs: 500,
});
testing.setBundleMcpCatalogListTimeoutMsForTest(4_000);
const runtime = await getOrCreateSessionMcpRuntime({
sessionId: "session-inflight-connect-test",
sessionKey: "agent:test:session-inflight-connect-test",
workspaceDir: "/workspace",
cfg: {
mcp: {
servers: Object.fromEntries(
[invalidatingServer, slowConnectServer].map(({ serverName, serverPath }) => [
serverName,
{
command: process.execPath,
args: [serverPath],
connectionTimeoutMs: 2_000,
},
]),
),
},
},
});
try {
const firstCatalog = runtime.getCatalog();
await waitForFileText(
invalidatingServer.logPath,
"notify tools/list_changed",
LIST_TOOLS_SERVER_LOG_TIMEOUT_MS,
);
const secondCatalog = await runtime.getCatalog();
await firstCatalog;
expect(Object.keys(secondCatalog.servers).toSorted()).toEqual([
invalidatingServer.serverName,
slowConnectServer.serverName,
]);
expect(secondCatalog.diagnostics ?? []).toEqual([]);
} finally {
await runtime.dispose();
}
},
);
it(
"retires timed-out shared MCP sessions before later catalog retries",
{ timeout: 8_000 },
async () => {
const tempDir = makeTempDir(tempDirs, "bundle-mcp-timeout-retire-");
const triggerServerPath = path.join(tempDir, "trigger-server.mjs");
const triggerLogPath = path.join(tempDir, "trigger.log");
const slowServerPath = path.join(tempDir, "slow-server.mjs");
const slowLogPath = path.join(tempDir, "slow.log");
const firstConnectMarkerPath = path.join(tempDir, "first-connect.marker");
await writeExecutable(
triggerServerPath,
`#!/usr/bin/env node
import fs from "node:fs/promises";
const logPath = ${JSON.stringify(triggerLogPath)};
let buffer = "";
function log(line) {
void fs.appendFile(logPath, line + "\\n", "utf8").catch(() => {});
}
function send(message) {
process.stdout.write(JSON.stringify(message) + "\\n");
}
function handle(message) {
if (!message || typeof message !== "object") {
return;
}
log("recv " + String(message.method ?? "unknown"));
if (message.method === "initialize") {
send({
jsonrpc: "2.0",
id: message.id,
result: {
protocolVersion: message.params?.protocolVersion ?? "2025-03-26",
capabilities: { tools: { listChanged: true } },
serverInfo: { name: "timeout-trigger", version: "1.0.0" },
},
});
return;
}
if (message.method === "notifications/initialized") {
send({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
log("sent initial tools/list_changed");
return;
}
if (message.method === "tools/list") {
send({
jsonrpc: "2.0",
id: message.id,
result: {
tools: [{ name: "poke", inputSchema: { type: "object", properties: {} } }],
},
});
return;
}
if (message.method === "tools/call") {
send({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
log("sent call tools/list_changed");
send({
jsonrpc: "2.0",
id: message.id,
result: { isError: false, content: [{ type: "text", text: "poked" }] },
});
}
}
process.stdin.setEncoding("utf8");
function shutdown() {
process.exit(0);
}
process.stdin.on("data", (chunk) => {
buffer += chunk;
while (true) {
const newline = buffer.indexOf("\\n");
if (newline < 0) {
return;
}
const line = buffer.slice(0, newline).replace(/\\r$/, "");
buffer = buffer.slice(newline + 1);
if (line.trim()) {
handle(JSON.parse(line));
}
}
});
process.stdin.on("end", shutdown);
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);`,
);
await writeExecutable(
slowServerPath,
`#!/usr/bin/env node
import fs from "node:fs/promises";
const logPath = ${JSON.stringify(slowLogPath)};
const markerPath = ${JSON.stringify(firstConnectMarkerPath)};
let buffer = "";
function log(line) {
void fs.appendFile(logPath, line + "\\n", "utf8").catch(() => {});
}
function send(message) {
process.stdout.write(JSON.stringify(message) + "\\n");
}
async function isFirstConnect() {
try {
const handle = await fs.open(markerPath, "wx");
await handle.close();
return true;
} catch {
return false;
}
}
async function handle(message) {
if (!message || typeof message !== "object") {
return;
}
log("recv " + String(message.method ?? "unknown"));
if (message.method === "initialize") {
const response = {
jsonrpc: "2.0",
id: message.id,
result: {
protocolVersion: message.params?.protocolVersion ?? "2025-03-26",
capabilities: { tools: {} },
serverInfo: { name: "timeout-slow", version: "1.0.0" },
},
};
if (await isFirstConnect()) {
log("slow first initialize");
setTimeout(() => send(response), 600);
} else {
log("fast retry initialize");
send(response);
}
return;
}
if (message.method === "tools/list") {
send({
jsonrpc: "2.0",
id: message.id,
result: {
tools: [{ name: "slow_tool", inputSchema: { type: "object", properties: {} } }],
},
});
}
}
process.stdin.setEncoding("utf8");
function shutdown() {
process.exit(0);
}
process.stdin.on("data", (chunk) => {
buffer += chunk;
while (true) {
const newline = buffer.indexOf("\\n");
if (newline < 0) {
return;
}
const line = buffer.slice(0, newline).replace(/\\r$/, "");
buffer = buffer.slice(newline + 1);
if (line.trim()) {
void handle(JSON.parse(line));
}
}
});
process.stdin.on("end", shutdown);
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);`,
);
const runtime = await getOrCreateSessionMcpRuntime({
sessionId: "session-timeout-retire-test",
sessionKey: "agent:test:session-timeout-retire-test",
workspaceDir: "/workspace",
cfg: {
mcp: {
servers: {
trigger: {
command: process.execPath,
args: [triggerServerPath],
connectionTimeoutMs: 2_000,
},
slow: {
command: process.execPath,
args: [slowServerPath],
connectionTimeoutMs: 150,
},
},
},
},
});
try {
const firstCatalog = runtime.getCatalog();
await waitForFileText(
triggerLogPath,
"sent initial tools/list_changed",
LIST_TOOLS_SERVER_LOG_TIMEOUT_MS,
);
const secondCatalog = await runtime.getCatalog();
await firstCatalog;
expect(secondCatalog.servers.trigger).toBeDefined();
expect(secondCatalog.diagnostics?.some((diag) => diag.serverName === "slow")).toBe(true);
await waitForFileText(
slowLogPath,
"slow first initialize",
LIST_TOOLS_SERVER_LOG_TIMEOUT_MS,
);
await expect(runtime.callTool("trigger", "poke", {})).resolves.toMatchObject({
content: [{ type: "text", text: "poked" }],
isError: false,
});
await waitForFileText(
triggerLogPath,
"sent call tools/list_changed",
LIST_TOOLS_SERVER_LOG_TIMEOUT_MS,
);
await waitForPredicate(
() => runtime.peekCatalog() === null,
"manual list_changed to retry timed-out server",
LIST_TOOLS_SERVER_LOG_TIMEOUT_MS,
);
const retriedCatalog = await runtime.getCatalog();
expect(retriedCatalog.diagnostics?.some((diag) => diag.serverName === "slow")).not.toBe(
true,
);
expect(retriedCatalog.servers.slow).toBeDefined();
expect(retriedCatalog.tools.map((tool) => tool.toolName).toSorted()).toEqual([
"poke",
"slow_tool",
]);
await waitForFileText(
slowLogPath,
"fast retry initialize",
LIST_TOOLS_SERVER_LOG_TIMEOUT_MS,
);
} finally {
await runtime.dispose();
}
},
);
it(
"does not dispose sessions shared with a newer catalog generation",
{ timeout: LIST_TOOLS_TEST_DEADLINE_MS },
async () => {
const tempDir = makeTempDir(tempDirs, "bundle-mcp-overlap-generation-");
const serverPath = path.join(tempDir, "overlap-server.mjs");
const logPath = path.join(tempDir, "server.log");
await writeExecutable(
serverPath,
`#!/usr/bin/env node
import fs from "node:fs/promises";
const logPath = ${JSON.stringify(logPath)};
let buffer = "";
let listCount = 0;
function log(line) {
void fs.appendFile(logPath, line + "\\n", "utf8").catch(() => {});
}
function send(message) {
process.stdout.write(JSON.stringify(message) + "\\n");
}
function handle(message) {
if (!message || typeof message !== "object") {
return;
}
log("recv " + String(message.method ?? "unknown"));
if (message.method === "initialize") {
send({
jsonrpc: "2.0",
id: message.id,
result: {
protocolVersion: message.params?.protocolVersion ?? "2025-03-26",
capabilities: { tools: { listChanged: true } },
serverInfo: { name: "overlap-generation", version: "1.0.0" },
},
});
return;
}
if (message.method === "notifications/initialized") {
send({ jsonrpc: "2.0", method: "notifications/tools/list_changed" });
log("sent tools/list_changed");
return;
}
if (message.method === "tools/list") {
listCount += 1;
const currentList = listCount;
log("tools/list " + currentList);
if (currentList === 1) {
setTimeout(() => {
send({
jsonrpc: "2.0",
id: message.id,
result: {
tools: [{ name: "ok_tool", inputSchema: [] }],
},
});
}, 100);
return;
}
send({
jsonrpc: "2.0",
id: message.id,
result: {
tools: [{ name: "ok_tool", inputSchema: { type: "object", properties: {} } }],
},
});
return;
}
if (message.method === "tools/call") {
send({
jsonrpc: "2.0",
id: message.id,
result: { isError: false, content: [{ type: "text", text: "still connected" }] },
});
}
}
process.stdin.setEncoding("utf8");
function shutdown() {
process.exit(0);
}
process.stdin.on("data", (chunk) => {
buffer += chunk;
while (true) {
const newline = buffer.indexOf("\\n");
if (newline < 0) {
return;
}
const line = buffer.slice(0, newline).replace(/\\r$/, "");
buffer = buffer.slice(newline + 1);
if (line.trim()) {
handle(JSON.parse(line));
}
}
});
process.stdin.on("end", shutdown);
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);`,
);
const runtime = await getOrCreateSessionMcpRuntime({
sessionId: "session-overlap-generation-test",
sessionKey: "agent:test:session-overlap-generation-test",
workspaceDir: "/workspace",
cfg: {
mcp: {
servers: {
overlap: {
command: process.execPath,
args: [serverPath],
},
},
},
},
});
try {
const firstCatalog = runtime.getCatalog();
await waitForFileText(logPath, "sent tools/list_changed", LIST_TOOLS_SERVER_LOG_TIMEOUT_MS);
await waitForFileText(logPath, "tools/list 1", LIST_TOOLS_SERVER_LOG_TIMEOUT_MS);
const secondCatalog = await runtime.getCatalog();
const firstCatalogResult = await firstCatalog;
expect(firstCatalogResult.diagnostics?.[0]?.serverName).toBe("overlap");
expect(secondCatalog.diagnostics ?? []).toEqual([]);
expect(secondCatalog.tools.map((tool) => tool.toolName)).toEqual(["ok_tool"]);
await expect(runtime.callTool("overlap", "ok_tool", {})).resolves.toMatchObject({
content: [{ type: "text", text: "still connected" }],
isError: false,
});
} finally {
await runtime.dispose();
}
},
);
});

View File

@@ -23,7 +23,6 @@ import {
findJsonSchemaShapeError,
normalizeJsonSchemaForTypeBox,
} from "../shared/json-schema-defaults.js";
import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
import { sanitizeServerName } from "./agent-bundle-mcp-names.js";
import type {
McpCatalogTool,
@@ -44,11 +43,6 @@ type BundleMcpSession = {
transportType: "stdio" | "sse" | "streamable-http";
requestTimeoutMs: number;
supportsParallelToolCalls: boolean;
connected: boolean;
retiring: boolean;
catalogUseCount: number;
sharedAcrossCatalogGenerations: boolean;
connectPromise?: Promise<void>;
detachStderr?: () => void;
};
@@ -65,7 +59,6 @@ const SESSION_MCP_RUNTIME_SWEEP_INTERVAL_MS = 60 * 1000;
const BUNDLE_MCP_FAILURE_THRESHOLD = 3;
const BUNDLE_MCP_FAILURE_COOLDOWN_MS = 60_000;
const BUNDLE_MCP_CATALOG_LIST_TIMEOUT_MS = 1_500;
const BUNDLE_MCP_CATALOG_CONNECT_CONCURRENCY = 6;
const BUNDLE_MCP_METADATA_TEXT_LIMIT = 1_200;
let bundleMcpCatalogListTimeoutMs: number | undefined;
@@ -540,41 +533,6 @@ export function createSessionMcpRuntime(params: {
throw createDisposedError(params.sessionId);
}
};
const ensureSessionConnected = async (
session: BundleMcpSession,
connectionTimeoutMs: number,
): Promise<void> => {
if (session.retiring) {
throw new Error(`bundle-mcp server "${session.serverName}" is retiring`);
}
if (session.connected) {
return;
}
session.connectPromise ??= connectWithTimeout(
session.client,
session.transport,
connectionTimeoutMs,
)
.then(() => {
session.connected = true;
})
.finally(() => {
session.connectPromise = undefined;
});
await session.connectPromise;
};
const retireSessionIfCurrent = async (
serverName: string,
session: BundleMcpSession,
): Promise<boolean> => {
if (sessions.get(serverName) !== session) {
return false;
}
session.retiring = true;
sessions.delete(serverName);
await disposeSession(session);
return true;
};
const getCatalog = async (): Promise<McpToolCatalog> => {
failIfDisposed();
@@ -601,13 +559,6 @@ export function createSessionMcpRuntime(params: {
const usedServerNames = new Set<string>();
try {
// Pre-compute safe server names sequentially (synchronous, fast — no I/O)
const preparedEntries: Array<{
serverName: string;
rawServer: (typeof loaded.mcpServers)[string];
resolved: NonNullable<ReturnType<typeof resolveMcpTransport>>;
safeServerName: string;
}> = [];
for (const [serverName, rawServer] of Object.entries(loaded.mcpServers)) {
failIfDisposed();
const resolved = resolveMcpTransport(serverName, rawServer);
@@ -620,209 +571,137 @@ export function createSessionMcpRuntime(params: {
`bundle-mcp: server key "${serverName}" registered as "${safeServerName}" for provider-safe tool names.`,
);
}
preparedEntries.push({ serverName, rawServer, resolved, safeServerName });
}
// Bounded fan-out keeps common 4-5 server setups parallel without letting
// large configs spawn/connect every MCP transport at once.
type ServerResult = {
serverName: string;
serverEntry: McpServerCatalog | null;
toolEntries: McpCatalogTool[];
diagnostics: McpToolCatalogDiagnostic[];
};
const tasks = preparedEntries.map(
({ serverName, rawServer, resolved, safeServerName }) =>
async (): Promise<ServerResult> => {
failIfDisposed();
let session = sessions.get(serverName);
if (session?.retiring) {
session = undefined;
}
const reusedSession = Boolean(session);
if (!session) {
const client = new Client(
{
name: "openclaw-bundle-mcp",
version: "0.0.0",
},
{
jsonSchemaValidator: createBundleMcpJsonSchemaValidator(),
listChanged: {
tools: {
autoRefresh: false,
debounceMs: 0,
onChanged: (error) => {
if (error) {
logWarn(
`bundle-mcp: failed to refresh changed tool list for server "${serverName}": ${redactErrorUrls(error)}`,
);
}
catalogInvalidationGeneration += 1;
catalog = null;
catalogInFlight = undefined;
},
},
let session = sessions.get(serverName);
const reusedSession = Boolean(session);
let connected = Boolean(session);
if (!session) {
const client = new Client(
{
name: "openclaw-bundle-mcp",
version: "0.0.0",
},
{
jsonSchemaValidator: createBundleMcpJsonSchemaValidator(),
listChanged: {
tools: {
autoRefresh: false,
debounceMs: 0,
onChanged: (error) => {
if (error) {
logWarn(
`bundle-mcp: failed to refresh changed tool list for server "${serverName}": ${redactErrorUrls(error)}`,
);
}
catalogInvalidationGeneration += 1;
catalog = null;
catalogInFlight = undefined;
},
},
);
session = {
serverName,
client,
transport: resolved.transport,
transportType: resolved.transportType,
requestTimeoutMs: resolved.requestTimeoutMs,
supportsParallelToolCalls: resolved.supportsParallelToolCalls,
connected: false,
retiring: false,
catalogUseCount: 0,
sharedAcrossCatalogGenerations: false,
detachStderr: resolved.detachStderr,
};
sessions.set(serverName, session);
}
},
},
);
session = {
serverName,
client,
transport: resolved.transport,
transportType: resolved.transportType,
requestTimeoutMs: resolved.requestTimeoutMs,
supportsParallelToolCalls: resolved.supportsParallelToolCalls,
detachStderr: resolved.detachStderr,
};
sessions.set(serverName, session);
}
if (session.catalogUseCount === 0) {
session.sharedAcrossCatalogGenerations = false;
}
if (reusedSession && session.catalogUseCount > 0) {
session.sharedAcrossCatalogGenerations = true;
}
session.catalogUseCount += 1;
let connectedForCatalog = false;
try {
failIfDisposed();
await ensureSessionConnected(session, resolved.connectionTimeoutMs);
connectedForCatalog = true;
failIfDisposed();
const capabilities = summarizeServerCapabilities(
session.client.getServerCapabilities(),
);
const listedTools = await listAllToolsBestEffort({
client: session.client,
timeoutMs: getCatalogListTimeoutMs(rawServer, resolved.requestTimeoutMs),
suppressUnsupported: Boolean(
!capabilities.tools && (capabilities.resources || capabilities.prompts),
),
});
failIfDisposed();
const selection = getMcpToolSelection(rawServer);
const exposedTools = listedTools.filter((tool) =>
shouldExposeMcpTool(selection, tool.name.trim()),
);
const serverEntry: McpServerCatalog = {
serverName,
safeServerName,
launchSummary: resolved.description,
toolCount: exposedTools.length,
requestTimeoutMs: resolved.requestTimeoutMs,
supportsParallelToolCalls: resolved.supportsParallelToolCalls,
...(capabilities.resources ? { resources: capabilities.resources } : {}),
...(capabilities.prompts ? { prompts: capabilities.prompts } : {}),
...(capabilities.tools
? {
tools: {
...capabilities.tools,
...(exposedTools.length !== listedTools.length
? { filteredCount: listedTools.length - exposedTools.length }
: {}),
},
}
: {}),
...(selection.include || selection.exclude
? {
toolFilter: {
...(selection.include ? { include: [...selection.include] } : {}),
...(selection.exclude ? { exclude: [...selection.exclude] } : {}),
},
}
: {}),
};
const toolEntries: McpCatalogTool[] = [];
for (const tool of exposedTools) {
const toolName = tool.name.trim();
if (!toolName) {
continue;
try {
failIfDisposed();
if (!connected) {
await connectWithTimeout(
session.client,
session.transport,
resolved.connectionTimeoutMs,
);
connected = true;
}
failIfDisposed();
const capabilities = summarizeServerCapabilities(
session.client.getServerCapabilities(),
);
const listedTools = await listAllToolsBestEffort({
client: session.client,
timeoutMs: getCatalogListTimeoutMs(rawServer, resolved.requestTimeoutMs),
suppressUnsupported: Boolean(
!capabilities.tools && (capabilities.resources || capabilities.prompts),
),
});
failIfDisposed();
const selection = getMcpToolSelection(rawServer);
const exposedTools = listedTools.filter((tool) =>
shouldExposeMcpTool(selection, tool.name.trim()),
);
servers[serverName] = {
serverName,
safeServerName,
launchSummary: resolved.description,
toolCount: exposedTools.length,
requestTimeoutMs: resolved.requestTimeoutMs,
supportsParallelToolCalls: resolved.supportsParallelToolCalls,
...(capabilities.resources ? { resources: capabilities.resources } : {}),
...(capabilities.prompts ? { prompts: capabilities.prompts } : {}),
...(capabilities.tools
? {
tools: {
...capabilities.tools,
...(exposedTools.length !== listedTools.length
? { filteredCount: listedTools.length - exposedTools.length }
: {}),
},
}
toolEntries.push({
serverName,
safeServerName,
toolName,
title: tool.title,
description: sanitizeMcpMetadataText(tool.description),
inputSchema: tool.inputSchema,
fallbackDescription: `Provided by bundle MCP server "${serverName}" (${resolved.description}).`,
});
}
return {
serverName,
serverEntry,
toolEntries,
diagnostics: [] as McpToolCatalogDiagnostic[],
};
} catch (error) {
const message = redactErrorUrls(error);
if (!disposed) {
const action = reusedSession ? "refresh" : "start";
logWarn(
`bundle-mcp: failed to ${action} server "${serverName}" (${resolved.description}): ${message}`,
);
}
const diags: McpToolCatalogDiagnostic[] = [
{
serverName,
safeServerName,
launchSummary: resolved.description,
message,
},
];
const sharedWithNewerGeneration =
session.sharedAcrossCatalogGenerations || session.catalogUseCount > 1;
if (!connectedForCatalog && !session.connected) {
// Timed-out connects can still leave the SDK client bound to a
// transport. Delete before async close so future catalogs start fresh.
await retireSessionIfCurrent(serverName, session);
} else if (!reusedSession && !sharedWithNewerGeneration) {
// Catalog invalidation can overlap generations; an older failed
// generation must not dispose a session a newer one already reused.
await retireSessionIfCurrent(serverName, session);
}
failIfDisposed();
return {
serverName,
serverEntry: null,
toolEntries: [],
diagnostics: diags,
} as ServerResult;
} finally {
session.catalogUseCount -= 1;
if (session.catalogUseCount === 0) {
session.sharedAcrossCatalogGenerations = false;
}
: {}),
...(selection.include || selection.exclude
? {
toolFilter: {
...(selection.include ? { include: [...selection.include] } : {}),
...(selection.exclude ? { exclude: [...selection.exclude] } : {}),
},
}
: {}),
};
for (const tool of exposedTools) {
const toolName = tool.name.trim();
if (!toolName) {
continue;
}
},
);
const { results, firstError, hasError } = await runTasksWithConcurrency({
tasks,
limit: BUNDLE_MCP_CATALOG_CONNECT_CONCURRENCY,
errorMode: "continue",
});
if (hasError) {
throw firstError;
}
for (const result of results) {
if (!result) {
continue;
tools.push({
serverName,
safeServerName,
toolName,
title: tool.title,
description: sanitizeMcpMetadataText(tool.description),
inputSchema: tool.inputSchema,
fallbackDescription: `Provided by bundle MCP server "${serverName}" (${resolved.description}).`,
});
}
} catch (error) {
const message = redactErrorUrls(error);
if (!disposed) {
const action = reusedSession ? "refresh" : "start";
logWarn(
`bundle-mcp: failed to ${action} server "${serverName}" (${resolved.description}): ${message}`,
);
}
diagnostics.push({
serverName,
safeServerName,
launchSummary: resolved.description,
message,
});
if (!reusedSession) {
await disposeSession(session);
sessions.delete(serverName);
}
failIfDisposed();
}
const { serverEntry, toolEntries, diagnostics: serverDiags } = result;
if (serverEntry) {
servers[result.serverName] = serverEntry;
}
tools.push(...toolEntries);
diagnostics.push(...serverDiags);
}
failIfDisposed();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -109,101 +109,6 @@ describe("commitments command", () => {
);
});
it("keeps fixed-width columns aligned when an id or scope is truncated", async () => {
// An id longer than the 16-char ID column and a scope longer than the
// 28-char Scope column, so truncate() fires for both cells.
mocks.listCommitments.mockResolvedValue([
commitment({
id: "cm_abcdefghijklmnopqrstuvwxyz", // 29 chars > 16
agentId: "averylongagentidentifier",
channel: "telegram",
to: "+15551234567890", // agentId/channel/to joined > 28 chars
}),
]);
const { runtime, logs } = createRuntime();
await commitmentsListCommand({}, runtime);
const lines = logs.map(stripAnsi);
const header = lines.find((line) => line.startsWith("ID"));
const row = lines.find((line) => line.startsWith("cm_"));
expect(header).toBeDefined();
expect(row).toBeDefined();
// The truncated ID cell must stay within its 16-char column: 15 chars of
// content plus a single-character ellipsis, not a 3-char "..." that overflows.
expect(row?.slice(0, 16)).toBe("cm_abcdefghijkl…");
// With each truncated cell at its intended width, the following columns line
// up with the header. A 3-char "..." pushes every column after a truncated
// cell 2 chars right of its header label.
expect(row?.indexOf("pending")).toBe(header?.indexOf("Status"));
expect(row?.indexOf("event_check_in")).toBe(header?.indexOf("Kind"));
});
it("keeps the Scope column aligned when only the scope is truncated", async () => {
// Short id (untouched) but a scope longer than its 28-char column, so only
// the scope cell is truncated. Isolates the second truncation site.
mocks.listCommitments.mockResolvedValue([
commitment({
id: "cm_short", // 8 chars, fits the ID column untouched
agentId: "averylongagentidentifier",
channel: "telegram",
to: "+15551234567890", // joined scope exceeds 28 chars
}),
]);
const { runtime, logs } = createRuntime();
await commitmentsListCommand({}, runtime);
const lines = logs.map(stripAnsi);
const header = lines.find((line) => line.startsWith("ID"));
const row = lines.find((line) => line.startsWith("cm_"));
expect(header).toBeDefined();
expect(row).toBeDefined();
// The short id is rendered in full (no ellipsis).
expect(row?.slice(0, 16)).toBe("cm_short ");
// The 28-char Scope cell ends in a single-char ellipsis and holds its width,
// so the trailing Suggested text column still begins under its header label.
const scopeCell = row?.slice(70, 98);
expect(scopeCell?.length).toBe(28);
expect(scopeCell?.endsWith("…")).toBe(true);
expect(row?.indexOf("How did it go?")).toBe(header?.indexOf("Suggested text"));
});
it("does not truncate an id that exactly fills the ID column", async () => {
// 16 chars == maxChars, so value.length <= maxChars and the id passes through
// whole with no ellipsis. Guards the boundary so we never over-truncate.
mocks.listCommitments.mockResolvedValue([commitment({ id: "cm_exactly16char" })]);
const { runtime, logs } = createRuntime();
await commitmentsListCommand({}, runtime);
const lines = logs.map(stripAnsi);
const header = lines.find((line) => line.startsWith("ID"));
const row = lines.find((line) => line.startsWith("cm_"));
expect(row?.slice(0, 16)).toBe("cm_exactly16char");
expect(row).not.toContain("…");
expect(row?.indexOf("pending")).toBe(header?.indexOf("Status"));
});
it("truncates an id one character past the column width to a single ellipsis", async () => {
// 17 chars == maxChars + 1, so truncate fires: 15 chars of content plus one
// ellipsis == 16, holding the column (the old "..." produced 18 and overflowed).
mocks.listCommitments.mockResolvedValue([commitment({ id: "cm_0123456789abcd" })]);
const { runtime, logs } = createRuntime();
await commitmentsListCommand({}, runtime);
const lines = logs.map(stripAnsi);
const header = lines.find((line) => line.startsWith("ID"));
const row = lines.find((line) => line.startsWith("cm_"));
expect(row?.slice(0, 16)).toBe("cm_0123456789ab…");
expect(row?.indexOf("pending")).toBe(header?.indexOf("Status"));
});
it("writes list JSON to runtime stdout instead of log output", async () => {
const { runtime, logs, stdout } = createRuntime();

View File

@@ -24,7 +24,7 @@ const STATUS_VALUES = new Set<CommitmentStatus>([
]);
function truncate(value: string, maxChars: number): string {
return value.length <= maxChars ? value : `${value.slice(0, maxChars - 1)}`;
return value.length <= maxChars ? value : `${value.slice(0, maxChars - 1)}...`;
}
function safe(value: string): string {

View File

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

View File

@@ -773,6 +773,10 @@ export const FIELD_HELP: Record<string, string> = {
"Per-agent additive allowlist for tools on top of global and profile policy. Keep narrow to avoid accidental privilege expansion on specialized agents.",
"agents.list[].tools.codeMode":
"Per-agent code mode override. Use this to test or roll out exec/wait tool-surface mode for one agent without enabling it fleet-wide.",
"agents.list[].tools.exec.env":
"Environment variables injected only into this agent's exec child processes. Values may be plaintext or SecretRefs; prefer SecretRefs for credentials.",
"agents.list[].tools.exec.inheritHostEnv":
"Whether Gateway-hosted exec inherits the Gateway process environment. Set false for a minimal environment when isolating agent credentials; sandbox exec is already minimal and node-host inheritance is configured on the node.",
"agents.list[].tools.byProvider":
"Per-agent provider-specific tool policy overrides for channel-scoped capability control. Use this when a single agent needs tighter restrictions on one provider than others.",
"agents.list[].tools.message.crossContext.allowWithinProvider":

View File

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

View File

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

View File

@@ -214,6 +214,8 @@ export const FIELD_LABELS: Record<string, string> = {
"agents.list[].tools.profile": "Agent Tool Profile",
"agents.list[].tools.alsoAllow": "Agent Tool Allowlist Additions",
"agents.list[].tools.codeMode": "Agent Code Mode",
"agents.list[].tools.exec.env": "Agent Exec Environment",
"agents.list[].tools.exec.inheritHostEnv": "Inherit Gateway Environment",
"tools.byProvider": "Tool Policy by Provider",
"agents.list[].tools.byProvider": "Agent Tool Policy by Provider",
"agents.list[].tools.message.crossContext.allowWithinProvider":

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -926,7 +926,6 @@ describe("test-projects args", () => {
config: "test/vitest/vitest.agents.config.ts",
forwardedArgs: [],
includePatterns: [
"src/agents/agent-bundle-mcp-runtime.test.ts",
"src/agents/models-config.file-mode.test.ts",
"src/agents/sandbox/ssh.test.ts",
],

View File

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

View File

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

View File

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

View File

@@ -10,12 +10,6 @@ describe("shared/model-param-b", () => {
expect(inferParamBFromIdOrName("(70b) + m1.5b + qwen-14b")).toBe(70);
});
it("matches both tokens when two are separated by a single delimiter", () => {
expect(inferParamBFromIdOrName("8b 70b")).toBe(70);
expect(inferParamBFromIdOrName("8b-70b")).toBe(70);
expect(inferParamBFromIdOrName("7b-13b")).toBe(13);
});
it("ignores malformed, zero, and non-delimited matches", () => {
expect(inferParamBFromIdOrName("abc70beta 0b x70b2")).toBeNull();
expect(inferParamBFromIdOrName("model 0b")).toBeNull();

View File

@@ -4,9 +4,7 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st
/** Infers the largest `<number>b` parameter-size token from a model id or display name. */
export function inferParamBFromIdOrName(text: string): number | null {
const raw = normalizeLowercaseStringOrEmpty(text);
// Trailing boundary is a lookahead so two adjacent `<num>b` tokens sharing one delimiter (e.g.
// "8b 70b" / "8b-70b") both match; a consuming boundary ate the delimiter and skipped the second.
const matches = raw.matchAll(/(?:^|[^a-z0-9])[a-z]?(\d+(?:\.\d+)?)b(?=[^a-z0-9]|$)/g);
const matches = raw.matchAll(/(?:^|[^a-z0-9])[a-z]?(\d+(?:\.\d+)?)b(?:[^a-z0-9]|$)/g);
let best: number | null = null;
for (const match of matches) {
const numRaw = match[1];

View File

@@ -12,11 +12,13 @@ profiles:
evidenceMode: slim
channelDriver: crabline
categoryIds:
- agent-runtime-and-provider-execution.agent-turn-execution
- agent-runtime-and-provider-execution.model-and-runtime-selection
- agent-runtime-and-provider-execution.provider-auth
- agent-runtime-and-provider-execution.streaming-and-progress
- agent-runtime-and-provider-execution.tool-calls-and-response-handling
- agent-runtime-and-provider-execution.tool-execution-controls
- session-memory-and-context-engine.token-management
- session-memory-and-context-engine.context-engine
- session-memory-and-context-engine.cross-client-history-and-session-parity
- session-memory-and-context-engine.core-prompts-and-context
@@ -34,6 +36,7 @@ profiles:
- channel-framework.outbound-delivery-and-reply-pipeline
- channel-framework.group-thread-and-ambient-room-behavior
- channel-framework.status-health-and-operator-controls
- session-memory-and-context-engine.memory
- session-memory-and-context-engine.diagnostics-maintenance-and-recovery
- automation-cron-hooks-tasks-polling.cron-jobs
- plugin-sdk-and-bundled-plugin-architecture.installing-and-running-plugins
@@ -42,6 +45,7 @@ profiles:
- media-understanding-and-media-generation.media-understanding
- media-understanding-and-media-generation.media-generation
- browser-control-ui-and-webchat.browser-ui
- security-auth-pairing-and-secrets.credential-and-secret-hygiene
- id: release
description: Stable/LTS proof selector for live providers, live channels, package artifacts,
upgrade paths, and platform proof where the claim depends on real upstreams or release
@@ -1301,13 +1305,13 @@ surfaces:
id: agent-turn-execution
features:
- name: Turn startup and runtime choice
coverageIds: [agents.create, agents.instructions, channels.discord-config, config.crestodian-setup, runtime.first-action, runtime.multi-turn-continuity, runtime.long-context]
coverageIds: [agents.create, agents.instructions, channels.discord-config, config.crestodian-setup, runtime.first-action, runtime.first-hour-20, runtime.long-context]
description: Starting an agent turn and choosing gateway versus embedded runtime execution.
- name: Session and run coordination
coverageIds: [agents.subagents, channels.dedup, channels.dm, channels.qa-channel, channels.reconnect, channels.streaming, channels.threads, commitments.heartbeat-target-none, commitments.scope, personal.channel-replies, runtime.codex-plugin.lifecycle, runtime.delivery, runtime.fallback-delivery, runtime.gateway-restart, runtime.restart-recovery, runtime.turn-ordering]
description: Establishing session and run ids, queue locks, and related execution coordination.
- name: Abort and terminal outcomes
coverageIds: [channels.streaming, runtime.delivery, runtime.fallback-delivery, runtime.long-context, runtime.long-run-stability]
coverageIds: [channels.streaming, runtime.delivery, runtime.fallback-delivery, runtime.long-context, runtime.soak-100]
description: Honoring aborts, timing provider/model work, and emitting terminal outcomes.
docs:
- docs/concepts/agent-loop.md
@@ -1589,7 +1593,7 @@ surfaces:
coverageIds: [session.pruning]
description: Covers Pruning across manual and automatic compaction, preemptive overflow checks, context-window estimation, session pruning, tool-result trimming, compaction providers, retry/timeout behavior, and compacted transcript checkpoints.
- name: Token Pressure
coverageIds: [runtime.codex-app-server, runtime.multi-turn-continuity, runtime.gateway-log-sentinel.codex-progress, runtime.long-context, runtime.long-run-stability]
coverageIds: [runtime.codex-app-server, runtime.first-hour-20, runtime.gateway-log-sentinel.codex-progress, runtime.long-context, runtime.soak-100]
description: Covers Token Pressure across manual and automatic compaction, preemptive overflow checks, context-window estimation, session pruning, tool-result trimming, compaction providers, retry/timeout behavior, and compacted transcript checkpoints.
docs:
- docs/concepts/compaction.md
@@ -2414,7 +2418,7 @@ surfaces:
coverageIds: [telemetry.trusted-trace-context]
description: Trusted trace context, W3C traceparent propagation to model calls, file-log correlation, content-capture controls, and redacted/bounded attributes
- name: Model and runtime telemetry
coverageIds: [docker.runtime-validation, harness.qa-lab, harness.tool-trace-visibility, personal.failure-recovery, personal.no-fake-progress, personal.task-followthrough, runtime.qa-bus, telemetry.otel, telemetry.prometheus, tools.evidence, tools.trace]
coverageIds: [docker.e2e, harness.qa-lab, harness.tool-trace-visibility, personal.failure-recovery, personal.no-fake-progress, personal.task-followthrough, runtime.qa-bus, telemetry.otel, telemetry.prometheus, tools.evidence, tools.trace]
description: Model, tool, message, session, queue, Talk, exec, webhook, context assembly, harness, and exporter-health signals
- name: diagnostics-prometheus plugin install
coverageIds: [telemetry.diagnostics-prometheus-plugin-install]
@@ -2423,7 +2427,7 @@ surfaces:
coverageIds: [telemetry.prometheus-authenticated-gateway-export]
description: Gateway-authenticated GET /api/diagnostics/prometheus behavior, status, and operator-visible verification.
- name: Prometheus text exposition
coverageIds: [docker.runtime-validation, harness.qa-lab, telemetry.prometheus]
coverageIds: [docker.e2e, harness.qa-lab, telemetry.prometheus]
description: Prometheus text exposition, counters, gauges, histograms, label policy, series cap, and overflow metric
- name: Trusted diagnostic event subscription
coverageIds: [telemetry.trusted-diagnostic-event-subscription]
@@ -6441,7 +6445,7 @@ surfaces:
coverageIds: [docker.package-artifact-generation]
description: Docker E2E package artifact generation and shared build helpers
- name: Docker E2E plan/scheduler scripts
coverageIds: [docker.runtime-validation, harness.qa-lab, telemetry.prometheus]
coverageIds: [docker.e2e, harness.qa-lab, telemetry.prometheus]
description: Docker E2E plan/scheduler scripts, lane metadata, targeted grouping, package artifact generation, and GitHub hydration action
- name: Release-path install
coverageIds: [docker.release-path-install]

View File

@@ -1191,13 +1191,6 @@ async function startDockerOtelCollector(
const osTmpdir = deps.tmpdir ?? tmpdir;
const collectorPort = await reservePort();
let collectorTelemetryPort = await reservePort();
for (let attempt = 0; collectorTelemetryPort === collectorPort && attempt < 5; attempt += 1) {
collectorTelemetryPort = await reservePort();
}
if (collectorTelemetryPort === collectorPort) {
throw new Error("OpenTelemetry collector telemetry port matched receiver port after retries.");
}
const tempDir = await makeTempDir(path.join(osTmpdir(), "openclaw-otel-collector-"));
const configPath = path.join(tempDir, "collector.yaml");
const containerName = `openclaw-otel-smoke-${makeUuid()}`;
@@ -1215,9 +1208,6 @@ exporters:
otlphttp/openclaw:
endpoint: ${receiverEndpoint}
service:
telemetry:
metrics:
address: 127.0.0.1:${collectorTelemetryPort}
pipelines:
traces:
receivers: [otlp]

View File

@@ -719,47 +719,6 @@ describe("qa-otel-smoke receiver bounds", () => {
expect(kill).not.toHaveBeenCalled();
});
it("moves Docker collector telemetry off the default host port", async () => {
const child = new EventEmitter() as EventEmitter & {
stderr: EventEmitter;
stdout: EventEmitter;
};
child.stderr = new EventEmitter();
child.stdout = new EventEmitter();
let writtenConfig = "";
const stopDockerContainer = vi.fn(async () => {});
const removePath = vi.fn(async () => {});
const ports = [4318, 4318, 45679];
const collector = await testing.startDockerOtelCollector(4317, {
mkdtemp: async () => "/tmp/openclaw-otel-collector-test",
platform: "linux",
randomUUID: () => "00000000-0000-4000-8000-000000000000",
reserveLocalPort: async () => ports.shift() ?? 49999,
rm: removePath as never,
spawn: vi.fn(() => child) as never,
stopDockerContainer,
waitForLocalPort: async () => {},
writeFile: async (_path, config) => {
writtenConfig = String(config);
},
});
expect(writtenConfig).toContain("endpoint: 127.0.0.1:4318");
expect(writtenConfig).toContain("telemetry:");
expect(writtenConfig).toContain("address: 127.0.0.1:45679");
expect(writtenConfig).not.toContain("address: :8888");
await collector.close();
expect(stopDockerContainer).toHaveBeenCalledWith(
"openclaw-otel-smoke-00000000-0000-4000-8000-000000000000",
);
expect(removePath).toHaveBeenCalledWith("/tmp/openclaw-otel-collector-test", {
force: true,
recursive: true,
});
});
it("cleans Docker collector containers and temp config after readiness failures", async () => {
const tempRoot = mkdtempSync(path.join(os.tmpdir(), "openclaw-qa-otel-collector-"));
const collectorDir = path.join(tempRoot, "collector");
@@ -770,7 +729,6 @@ describe("qa-otel-smoke receiver bounds", () => {
child.stderr = new EventEmitter();
child.stdout = new EventEmitter();
const stopDockerContainer = vi.fn(async () => {});
const ports = [4318, 45679];
try {
await expect(
@@ -780,7 +738,7 @@ describe("qa-otel-smoke receiver bounds", () => {
return collectorDir;
},
randomUUID: () => "00000000-0000-4000-8000-000000000000",
reserveLocalPort: async () => ports.shift() ?? 49999,
reserveLocalPort: async () => 4318,
spawn: vi.fn(() => child) as never,
stopDockerContainer,
waitForLocalPort: async () => {
@@ -807,7 +765,6 @@ describe("qa-otel-smoke receiver bounds", () => {
};
child.stderr = new EventEmitter();
child.stdout = new EventEmitter();
const ports = [4318, 45679];
try {
let thrown: unknown;
@@ -818,7 +775,7 @@ describe("qa-otel-smoke receiver bounds", () => {
return collectorDir;
},
randomUUID: () => "00000000-0000-4000-8000-000000000000",
reserveLocalPort: async () => ports.shift() ?? 49999,
reserveLocalPort: async () => 4318,
spawn: vi.fn(() => child) as never,
stopDockerContainer: vi.fn(async () => {}),
waitForLocalPort: async (_port, _timeout, readFailure) => {

View File

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

View File

@@ -2,7 +2,6 @@
import { describe, expect, it } from "vitest";
import {
collectPreparedPrepackErrors,
resolvePrepackCommandStdio,
resolvePrepackCommandTimeoutMs,
runPrepackCommand,
} from "../scripts/openclaw-prepack.ts";
@@ -27,20 +26,6 @@ describe("collectPreparedPrepackErrors", () => {
});
describe("runPrepackCommand", () => {
it("keeps prepack child stdout off npm pack JSON stdout", () => {
expect(resolvePrepackCommandStdio({ stdio: "inherit" }, { npm_config_json: "true" })).toEqual([
"inherit",
2,
"inherit",
]);
expect(
resolvePrepackCommandStdio(
{ stdio: ["ignore", "pipe", "pipe"] },
{ npm_config_json: "true" },
),
).toEqual(["ignore", "pipe", "pipe"]);
});
it("returns captured output for successful commands", () => {
const result = runPrepackCommand(process.execPath, ["--eval", "process.stdout.write('ok')"], {
encoding: "utf8",