mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-24 16:23:54 +08:00
Compare commits
2 Commits
codex/agen
...
codex/slac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a28dce4bdf | ||
|
|
a96418c65f |
@@ -1,4 +1,4 @@
|
||||
c9c0cef8a5149a32651c6e25a0bdb8c7ae2f18c7d2da820c42c9241f09331e22 config-baseline.json
|
||||
f7420a61f9cef845dbba414d6847baeba9c5e9143de21f3c69ccb15772c86a6e config-baseline.core.json
|
||||
9246475f5771612a5fd12de38b153783c4a4cbb8b2682a5c40115916661c90f2 config-baseline.json
|
||||
6349131baaa1828f2a071f42e4d7b17c8966c59b6588c8a4c1a32ea5ea4dcd5e config-baseline.core.json
|
||||
671979e86e4c4f59415d0a20879e838f9bbd883b3d29eeb02cb5131db8d187fe config-baseline.channel.json
|
||||
94529978588d6e3776a86780b22cf9ff46a6f9957f2f178d3829403fad451ca7 config-baseline.plugin.json
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
95f2562304eebefd432c7694a90b860e4611f989e77bd3214b7c2cbeabba1882 plugin-sdk-api-baseline.json
|
||||
5d2c93807dae6e142616d82b0718964326ce46389bf81288972bbf664af64ae7 plugin-sdk-api-baseline.jsonl
|
||||
f7247b5bbfe3f96bffffd25a8be2f89b37999e36731f34a159ae21ded1cedd05 plugin-sdk-api-baseline.json
|
||||
ce88a53dadc194ceccc63f50146aee03a1a425f551117da826a21519d5bf80db plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -204,55 +204,6 @@ Controls elevated exec access outside the sandbox:
|
||||
}
|
||||
```
|
||||
|
||||
Agent entries can inject an environment only into their own `exec` child
|
||||
processes. Use a SecretRef for credentials and set `inheritHostEnv: false` when the
|
||||
Gateway process environment must not be inherited:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "referrals",
|
||||
tools: {
|
||||
exec: {
|
||||
inheritHostEnv: false,
|
||||
env: {
|
||||
GREENHOUSE_TOKEN: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "REFERRALS_GREENHOUSE_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`agents.list[].tools.exec.env` applies to `exec` only; it does not mutate
|
||||
`process.env` or automatically inject credentials into model-provider or plugin
|
||||
APIs. Trusted in-process plugin code can still inspect the materialized runtime
|
||||
config, so this is not a plugin isolation boundary.
|
||||
Configured values override same-named per-call values from the model. Trusted
|
||||
`resolve_exec_env` hook output and channel context are applied afterward. Host
|
||||
exec still rejects `PATH` and dangerous runtime/startup keys. Sandbox exec
|
||||
already starts from a minimal environment. With `inheritHostEnv: false`,
|
||||
Gateway exec also skips login-shell PATH discovery and cached shell-startup
|
||||
state; configure `pathPrepend` or absolute commands when needed. For
|
||||
`host: "node"`, configure scoped environment and inheritance isolation on the
|
||||
node host. Both this map and `inheritHostEnv: false` are rejected because the
|
||||
Gateway cannot clear the remote service environment or safely hold a scoped
|
||||
credential back during remote approval preparation.
|
||||
|
||||
Treat this map as credential-bearing configuration: every command the agent can
|
||||
run can read and exfiltrate these values, and command output can reveal them.
|
||||
Plaintext values are reported by `openclaw secrets audit`; prefer SecretRefs.
|
||||
Already-running background commands retain the environment captured when they
|
||||
started after a config or secret reload.
|
||||
|
||||
### `tools.loopDetection`
|
||||
|
||||
Tool-loop safety checks are **disabled by default**. Set `enabled: true` to activate detection. Settings can be defined globally in `tools.loopDetection` and overridden per-agent at `agents.list[].tools.loopDetection`.
|
||||
|
||||
@@ -525,47 +525,6 @@ the config fields that accept SecretRefs.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Per-agent exec environment variables
|
||||
|
||||
`agents.list[].tools.exec.env` supports SecretInput values, so a credential can
|
||||
be resolved during Gateway activation and injected only into that agent's
|
||||
`exec` child processes:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "referrals",
|
||||
tools: {
|
||||
exec: {
|
||||
inheritHostEnv: false,
|
||||
env: {
|
||||
GREENHOUSE_TOKEN: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "REFERRALS_GREENHOUSE_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This surface is exec-specific. It does not mutate the Gateway process
|
||||
environment or automatically inject credentials into model-provider or plugin
|
||||
APIs. Trusted in-process plugin code can inspect the materialized runtime
|
||||
config. An unresolved active ref fails Gateway activation. SecretRefs are
|
||||
materialized in the Gateway's protected in-memory config snapshot, so this
|
||||
scopes subprocess injection rather than creating a same-process or same-OS-user
|
||||
security boundary. Every command available to the agent can read these values,
|
||||
command output can reveal them, and plaintext entries are reported by
|
||||
`openclaw secrets audit`. Configure scoped environment on a node host itself;
|
||||
agent exec env is rejected for `host: "node"`.
|
||||
|
||||
## MCP server environment variables
|
||||
|
||||
MCP server env vars configured via `plugins.entries.acpx.config.mcpServers` support SecretInput. This keeps API keys and tokens out of plaintext config:
|
||||
|
||||
@@ -37,7 +37,6 @@ Scope intent:
|
||||
- `agents.defaults.memorySearch.remote.apiKey`
|
||||
- `agents.list[].tts.providers.*.apiKey`
|
||||
- `agents.list[].memorySearch.remote.apiKey`
|
||||
- `agents.list[].tools.exec.env.*`
|
||||
- `talk.providers.*.apiKey`
|
||||
- `talk.realtime.providers.*.apiKey`
|
||||
- `messages.tts.providers.*.apiKey`
|
||||
|
||||
@@ -29,13 +29,6 @@
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "agents.list[].tools.exec.env.*",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "agents.list[].tools.exec.env.*",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "agents.list[].tts.providers.*.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
|
||||
@@ -22,8 +22,7 @@ Working directory for the command.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="env" type="object">
|
||||
Key/value environment overrides. Per-agent configured values are applied after
|
||||
these model-supplied values.
|
||||
Key/value environment overrides merged on top of the inherited environment.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="yieldMs" type="number" default="10000">
|
||||
@@ -90,7 +89,6 @@ Notes:
|
||||
`$OPENCLAW_STATE_DIR/cache/shell-snapshots/`, then sources that snapshot before each exec command.
|
||||
Secret-looking variables are excluded; sandbox and node exec do not use this snapshot. Set
|
||||
`OPENCLAW_EXEC_SHELL_SNAPSHOT=0` in the Gateway process environment to disable this snapshot path.
|
||||
Per-agent `tools.exec.inheritHostEnv: false` also disables it.
|
||||
- Host execution (`gateway`/`node`) rejects `env.PATH` and loader overrides (`LD_*`/`DYLD_*`) to
|
||||
prevent binary hijacking or injected code.
|
||||
- OpenClaw sets `OPENCLAW_SHELL=exec` in the spawned command environment (including PTY and sandbox execution) so shell/profile rules can detect exec-tool context.
|
||||
@@ -115,8 +113,6 @@ Notes:
|
||||
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
|
||||
- `tools.exec.approvalRunningNoticeMs` (default: 10000): emit a single "running" notice when an approval-gated exec runs longer than this (0 disables).
|
||||
- `tools.exec.timeoutSec` (default: 1800): default per-command exec timeout in seconds. Per-call `timeout` overrides it; per-call `timeout: 0` disables the exec process timeout.
|
||||
- `agents.list[].tools.exec.env`: credential-oriented environment values injected only into that agent's gateway/sandbox exec children. Values support SecretRefs; node-host exec rejects this map.
|
||||
- `agents.list[].tools.exec.inheritHostEnv` (default: true): set false to omit the Gateway process environment and shell-startup snapshot from Gateway-hosted exec. This is rejected for `host=node`; sandbox exec is already minimal.
|
||||
- `tools.exec.host` (default: `auto`; resolves to `sandbox` when sandbox runtime is active, `gateway` otherwise)
|
||||
- `tools.exec.security` (default: `deny` for sandbox, `full` for gateway + node when unset)
|
||||
- `tools.exec.ask` (default: `off`)
|
||||
@@ -145,9 +141,7 @@ Example:
|
||||
|
||||
### PATH handling
|
||||
|
||||
- `host=gateway`: normally merges your login-shell `PATH` into the exec environment. With
|
||||
`agents.list[].tools.exec.inheritHostEnv: false`, this merge is skipped; use an absolute command or
|
||||
`tools.exec.pathPrepend`. `env.PATH` overrides are
|
||||
- `host=gateway`: merges your login-shell `PATH` into the exec environment. `env.PATH` overrides are
|
||||
rejected for host execution. The daemon itself still runs with a minimal `PATH`:
|
||||
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
|
||||
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`
|
||||
|
||||
@@ -31,7 +31,20 @@ import {
|
||||
} from "./prepare.test-helpers.js";
|
||||
import { clearSlackSubteamMentionCacheForTest } from "./subteam-mentions.js";
|
||||
|
||||
const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
|
||||
const { enqueueSystemEventMock, logVerboseMock, shouldLogVerboseMock } = vi.hoisted(() => ({
|
||||
enqueueSystemEventMock: vi.fn(),
|
||||
logVerboseMock: vi.fn(),
|
||||
shouldLogVerboseMock: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/runtime-env")>();
|
||||
return {
|
||||
...actual,
|
||||
logVerbose: (...args: unknown[]) => logVerboseMock(...args),
|
||||
shouldLogVerbose: () => shouldLogVerboseMock(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/system-event-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/system-event-runtime")>();
|
||||
@@ -54,6 +67,9 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
||||
clearSlackAllowFromCacheForTest();
|
||||
clearSlackSubteamMentionCacheForTest();
|
||||
enqueueSystemEventMock.mockClear();
|
||||
logVerboseMock.mockClear();
|
||||
shouldLogVerboseMock.mockReset();
|
||||
shouldLogVerboseMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -171,6 +187,28 @@ describe("slack prepareSlackMessage inbound contract", () => {
|
||||
expect(prepared.ctxPayload.BodyForAgent).toContain(body);
|
||||
});
|
||||
|
||||
it("logs inbound metadata without logging message content", async () => {
|
||||
const body = "confidential acquisition target: northstar; do not include this text in logs";
|
||||
shouldLogVerboseMock.mockReturnValue(true);
|
||||
|
||||
const prepared = await prepareWithDefaultCtx(createSlackMessage({ text: body }));
|
||||
|
||||
assertPrepared(prepared);
|
||||
const inboundLog = logVerboseMock.mock.calls
|
||||
.map(([entry]) => entry)
|
||||
.find((entry) => typeof entry === "string" && entry.startsWith("slack inbound:"));
|
||||
const verboseOutput = logVerboseMock.mock.calls
|
||||
.flat()
|
||||
.filter((entry): entry is string => typeof entry === "string")
|
||||
.join("\n");
|
||||
expect(inboundLog).toBe(
|
||||
`slack inbound: account=${prepared.route.accountId} agent=${prepared.route.agentId} channel=D123 message_ts=1.000 thread_ts=none from=slack:U1 chat=direct chars=${body.length}`,
|
||||
);
|
||||
expect(verboseOutput).not.toContain(body);
|
||||
expect(verboseOutput).not.toContain("confidential acquisition target");
|
||||
expect(verboseOutput).not.toContain("preview=");
|
||||
});
|
||||
|
||||
it("prepares wildcard open-policy account DMs", async () => {
|
||||
const ctx = createInboundSlackCtx({
|
||||
cfg: {
|
||||
|
||||
@@ -1386,7 +1386,9 @@ export async function prepareSlackMessage(params: {
|
||||
}
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(`slack inbound: channel=${message.channel} from=${slackFrom} preview="${preview}"`);
|
||||
logVerbose(
|
||||
`slack inbound: account=${route.accountId} agent=${route.agentId} channel=${message.channel} message_ts=${message.ts ?? "unknown"} thread_ts=${effectiveMessageThreadId ?? "none"} from=${slackFrom} chat=${chatType} chars=${rawBody.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const updateLastRouteSessionKey = resolveInboundLastRouteSessionKey({ route, sessionKey });
|
||||
|
||||
@@ -46,11 +46,6 @@ function requireExecTool(tools: ReturnType<typeof createOpenClawCodingTools>) {
|
||||
return execTool;
|
||||
}
|
||||
|
||||
function printEnvCommand(key: string): string {
|
||||
const script = `process.stdout.write(process.env[${JSON.stringify(key)}] ?? "missing")`;
|
||||
return `${JSON.stringify(process.execPath)} -e ${JSON.stringify(script)}`;
|
||||
}
|
||||
|
||||
describe("Agent-specific exec tool defaults", () => {
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(createSessionConversationTestRegistry());
|
||||
@@ -296,191 +291,4 @@ describe("Agent-specific exec tool defaults", () => {
|
||||
const details = result?.details as { status?: string } | undefined;
|
||||
expect(details?.status).toBe("completed");
|
||||
});
|
||||
|
||||
it("injects configured env only into the selected agent and can drop inherited env", async () => {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const key = "OPENCLAW_TEST_AGENT_SCOPED_EXEC_ENV";
|
||||
const previous = process.env[key];
|
||||
process.env[key] = "gateway-value";
|
||||
try {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: { exec: { host: "gateway", security: "full", ask: "off" } },
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "referrals",
|
||||
tools: {
|
||||
exec: {
|
||||
inheritHostEnv: false,
|
||||
env: { [key]: "agent-value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "helper",
|
||||
tools: { exec: { inheritHostEnv: false } },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const referralsExec = requireExecTool(
|
||||
createOpenClawCodingTools({
|
||||
config: cfg,
|
||||
agentId: "referrals",
|
||||
workspaceDir: "/tmp/test-referrals-env",
|
||||
agentDir: "/tmp/agent-referrals-env",
|
||||
}),
|
||||
);
|
||||
const referralsResult = await referralsExec.execute("call-referrals-env", {
|
||||
command: printEnvCommand(key),
|
||||
env: { [key]: "model-value" },
|
||||
});
|
||||
expect((referralsResult.content[0] as { text?: string }).text).toContain("agent-value");
|
||||
|
||||
const helperExec = requireExecTool(
|
||||
createOpenClawCodingTools({
|
||||
config: cfg,
|
||||
agentId: "helper",
|
||||
workspaceDir: "/tmp/test-helper-env",
|
||||
agentDir: "/tmp/agent-helper-env",
|
||||
}),
|
||||
);
|
||||
const helperResult = await helperExec.execute("call-helper-env", {
|
||||
command: printEnvCommand(key),
|
||||
});
|
||||
expect((helperResult.content[0] as { text?: string }).text).toContain("missing");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps dangerous configured host env keys behind the existing security filter", async () => {
|
||||
const execTool = requireExecTool(
|
||||
createOpenClawCodingTools({
|
||||
config: {
|
||||
tools: { exec: { host: "gateway", security: "full", ask: "off" } },
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "ops",
|
||||
tools: { exec: { env: { PATH: "/tmp/untrusted" } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
agentId: "ops",
|
||||
workspaceDir: "/tmp/test-ops-env-filter",
|
||||
agentDir: "/tmp/agent-ops-env-filter",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
execTool.execute("call-ops-env-filter", { command: "echo blocked" }),
|
||||
).rejects.toThrow("PATH is controlled by tools.exec.pathPrepend");
|
||||
});
|
||||
|
||||
it("allows source-config tool inspection but rejects unresolved SecretRefs on execution", async () => {
|
||||
const execTool = requireExecTool(
|
||||
createOpenClawCodingTools({
|
||||
config: {
|
||||
tools: { exec: { host: "gateway", security: "full", ask: "off" } },
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "ops",
|
||||
tools: {
|
||||
exec: {
|
||||
env: {
|
||||
SCOPED_CREDENTIAL: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPS_SCOPED_CREDENTIAL",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
agentId: "ops",
|
||||
workspaceDir: "/tmp/test-ops-unresolved-env",
|
||||
agentDir: "/tmp/agent-ops-unresolved-env",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
execTool.execute("call-ops-unresolved-env", { command: "echo blocked" }),
|
||||
).rejects.toThrow("contains an unresolved SecretRef");
|
||||
});
|
||||
|
||||
it("rejects attempts to spoof trusted channel context through per-call env", async () => {
|
||||
const execTool = requireExecTool(
|
||||
createOpenClawCodingTools({
|
||||
config: { tools: { exec: { host: "gateway", security: "full", ask: "off" } } },
|
||||
agentId: "ops",
|
||||
workspaceDir: "/tmp/test-ops-channel-context-env",
|
||||
agentDir: "/tmp/agent-ops-channel-context-env",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
execTool.execute("call-ops-channel-context-env", {
|
||||
command: "echo blocked",
|
||||
env: { OPENCLAW_CHANNEL_CONTEXT: "spoofed" },
|
||||
}),
|
||||
).rejects.toThrow("reserved for trusted channel context");
|
||||
});
|
||||
|
||||
it("rejects host-env minimization when effective exec host is a remote node", async () => {
|
||||
const execTool = requireExecTool(
|
||||
createOpenClawCodingTools({
|
||||
config: {
|
||||
tools: { exec: { host: "node", security: "full", ask: "off" } },
|
||||
agents: {
|
||||
list: [{ id: "ops", tools: { exec: { inheritHostEnv: false } } }],
|
||||
},
|
||||
},
|
||||
agentId: "ops",
|
||||
workspaceDir: "/tmp/test-ops-node-env",
|
||||
agentDir: "/tmp/agent-ops-node-env",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
execTool.execute("call-ops-node-env", { command: "echo blocked" }),
|
||||
).rejects.toThrow("configure environment isolation on the node host");
|
||||
});
|
||||
|
||||
it("rejects agent-scoped env before remote-node preparation", async () => {
|
||||
const execTool = requireExecTool(
|
||||
createOpenClawCodingTools({
|
||||
config: {
|
||||
tools: { exec: { host: "node", security: "full", ask: "always" } },
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "ops",
|
||||
tools: { exec: { env: { SCOPED_TOKEN: "must-stay-on-gateway" } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
agentId: "ops",
|
||||
workspaceDir: "/tmp/test-ops-node-scoped-env",
|
||||
agentDir: "/tmp/agent-ops-node-scoped-env",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
execTool.execute("call-ops-node-scoped-env", { command: "echo blocked" }),
|
||||
).rejects.toThrow("configure scoped environment on the node host");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -347,8 +347,6 @@ function resolveExecConfig(params: { cfg?: OpenClawConfig; agentId?: string }) {
|
||||
security: layeredPolicy.security,
|
||||
ask: layeredPolicy.ask,
|
||||
node: agentExec?.node ?? globalExec?.node,
|
||||
env: agentExec?.env,
|
||||
inheritHostEnv: agentExec?.inheritHostEnv,
|
||||
pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend,
|
||||
safeBins: agentExec?.safeBins ?? globalExec?.safeBins,
|
||||
strictInlineEval: agentExec?.strictInlineEval ?? globalExec?.strictInlineEval,
|
||||
@@ -817,8 +815,6 @@ export function createOpenClawCodingTools(options?: {
|
||||
reviewer: options?.exec?.reviewer ?? execConfig.reviewer,
|
||||
trigger: options?.trigger,
|
||||
node: options?.exec?.node ?? execConfig.node,
|
||||
env: options?.exec?.env ?? execConfig.env,
|
||||
inheritHostEnv: options?.exec?.inheritHostEnv ?? execConfig.inheritHostEnv,
|
||||
pathPrepend: options?.exec?.pathPrepend ?? execConfig.pathPrepend,
|
||||
safeBins: options?.exec?.safeBins ?? execConfig.safeBins,
|
||||
strictInlineEval: options?.exec?.strictInlineEval ?? execConfig.strictInlineEval,
|
||||
|
||||
@@ -4,26 +4,9 @@
|
||||
* by sandboxed exec calls.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDockerExecArgs, buildSandboxEnv } from "./bash-tools.shared.js";
|
||||
import { buildDockerExecArgs } from "./bash-tools.shared.js";
|
||||
|
||||
describe("buildDockerExecArgs", () => {
|
||||
it("keeps case-distinct sandbox variables separate from PATH and HOME", () => {
|
||||
const env = buildSandboxEnv({
|
||||
defaultPath: "/usr/bin:/bin",
|
||||
containerWorkdir: "/workspace",
|
||||
sandboxEnv: { path: "lower-path", home: "lower-home" },
|
||||
paramsEnv: { Path: "mixed-path" },
|
||||
});
|
||||
|
||||
expect(env).toMatchObject({
|
||||
PATH: "/usr/bin:/bin",
|
||||
HOME: "/workspace",
|
||||
path: "lower-path",
|
||||
home: "lower-home",
|
||||
Path: "mixed-path",
|
||||
});
|
||||
});
|
||||
|
||||
it("prepends custom PATH after login shell sourcing to preserve both custom and system tools", () => {
|
||||
const args = buildDockerExecArgs({
|
||||
containerName: "test-container",
|
||||
|
||||
@@ -60,7 +60,6 @@ function restoreProcessPlatformForTest(): void {
|
||||
type ApprovalRequestPayload = {
|
||||
approvalReviewerDeviceIds?: string[];
|
||||
commandSpans?: Array<{ startIndex: number; endIndex: number }>;
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
function requireApprovalRequestPayload(callIndex: number): ApprovalRequestPayload {
|
||||
@@ -178,24 +177,6 @@ describe("exec approval requests", () => {
|
||||
expect(payload?.approvalReviewerDeviceIds).toEqual(["device-ios-reviewer"]);
|
||||
});
|
||||
|
||||
it("sends only value-free env metadata for gateway approval registration", async () => {
|
||||
vi.mocked(callGatewayTool).mockResolvedValue({ id: "approval-id", expiresAtMs: 1234 });
|
||||
|
||||
await registerExecApprovalRequestForHost({
|
||||
approvalId: "approval-id",
|
||||
command: "echo hi",
|
||||
env: { SCOPED_TOKEN: "do-not-serialize", REGION: "us-east-1" },
|
||||
workdir: "/tmp/project",
|
||||
host: "gateway",
|
||||
security: "allowlist",
|
||||
ask: "always",
|
||||
});
|
||||
|
||||
const payload = requireApprovalRequestPayload(0);
|
||||
expect(payload.env).toEqual({ SCOPED_TOKEN: "", REGION: "" });
|
||||
expect(JSON.stringify(payload)).not.toContain("do-not-serialize");
|
||||
});
|
||||
|
||||
it("does not generate command spans by default", async () => {
|
||||
vi.mocked(callGatewayTool).mockResolvedValue({ id: "approval-id", expiresAtMs: 1234 });
|
||||
|
||||
|
||||
@@ -300,10 +300,7 @@ async function buildHostApprovalDecisionParams(
|
||||
command: params.command,
|
||||
commandArgv: params.commandArgv,
|
||||
systemRunPlan: params.systemRunPlan,
|
||||
env:
|
||||
params.host === "node" || params.env === undefined
|
||||
? params.env
|
||||
: Object.fromEntries(Object.keys(params.env).map((key) => [key, ""])),
|
||||
env: params.env,
|
||||
cwd: params.workdir,
|
||||
nodeId: params.nodeId,
|
||||
host: params.host,
|
||||
|
||||
@@ -75,7 +75,6 @@ type ProcessGatewayAllowlistParams = {
|
||||
workdir: string;
|
||||
env: Record<string, string>;
|
||||
pathPrepend?: string[];
|
||||
useShellSnapshot?: boolean;
|
||||
requestedEnv?: Record<string, string>;
|
||||
pty: boolean;
|
||||
timeoutSec?: number;
|
||||
@@ -959,7 +958,6 @@ export async function processGatewayAllowlist(
|
||||
workdir: params.workdir,
|
||||
env: params.env,
|
||||
pathPrepend: params.pathPrepend,
|
||||
useShellSnapshot: params.useShellSnapshot,
|
||||
sandbox: undefined,
|
||||
containerWorkdir: null,
|
||||
usePty: params.pty,
|
||||
|
||||
@@ -11,7 +11,6 @@ const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
|
||||
const supervisorMock = vi.hoisted(() => ({
|
||||
spawn: vi.fn(),
|
||||
}));
|
||||
const maybeWrapCommandWithShellSnapshotMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../infra/heartbeat-wake.js", () => ({
|
||||
requestHeartbeat: requestHeartbeatMock,
|
||||
@@ -27,10 +26,6 @@ vi.mock("../process/supervisor/index.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./shell-snapshot.js", () => ({
|
||||
maybeWrapCommandWithShellSnapshot: maybeWrapCommandWithShellSnapshotMock,
|
||||
}));
|
||||
|
||||
let markBackgrounded: typeof import("./bash-process-registry.js").markBackgrounded;
|
||||
let buildExecExitOutcome: typeof import("./bash-tools.exec-runtime.js").buildExecExitOutcome;
|
||||
let detectCursorKeyMode: typeof import("./bash-tools.exec-runtime.js").detectCursorKeyMode;
|
||||
@@ -55,10 +50,6 @@ beforeEach(() => {
|
||||
requestHeartbeatMock.mockClear();
|
||||
enqueueSystemEventMock.mockClear();
|
||||
supervisorMock.spawn.mockReset();
|
||||
maybeWrapCommandWithShellSnapshotMock.mockReset();
|
||||
maybeWrapCommandWithShellSnapshotMock.mockImplementation(
|
||||
async ({ command }: { command: string }) => command,
|
||||
);
|
||||
});
|
||||
|
||||
function expectExecTarget(
|
||||
@@ -591,42 +582,6 @@ describe("buildExecExitOutcome", () => {
|
||||
});
|
||||
|
||||
describe("runExecProcess POSIX command wrapper", () => {
|
||||
it("skips shell startup snapshots when host env inheritance is disabled", async () => {
|
||||
supervisorMock.spawn.mockResolvedValueOnce({
|
||||
runId: "mock-run",
|
||||
startedAtMs: Date.now(),
|
||||
wait: async () => ({
|
||||
reason: "exit",
|
||||
exitCode: 0,
|
||||
exitSignal: null,
|
||||
durationMs: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
noOutputTimedOut: false,
|
||||
}),
|
||||
cancel: vi.fn(),
|
||||
});
|
||||
|
||||
await runExecProcess({
|
||||
command: "echo isolated",
|
||||
workdir: process.platform === "win32" ? "C:\\tmp" : "/tmp",
|
||||
env: {},
|
||||
useShellSnapshot: false,
|
||||
usePty: false,
|
||||
warnings: [],
|
||||
maxOutput: 1000,
|
||||
pendingMaxOutput: 1000,
|
||||
notifyOnExit: false,
|
||||
timeoutSec: null,
|
||||
});
|
||||
|
||||
expect(maybeWrapCommandWithShellSnapshotMock).not.toHaveBeenCalled();
|
||||
const spawnCall = supervisorMock.spawn.mock.calls[0]?.[0];
|
||||
const command = spawnCall?.argv?.join(" ") ?? spawnCall?.ptyCommand ?? "";
|
||||
expect(command).toContain("echo isolated");
|
||||
});
|
||||
|
||||
it("normalizes non-finite and oversized exec timeouts before spawning", async () => {
|
||||
supervisorMock.spawn.mockResolvedValue({
|
||||
runId: "mock-run",
|
||||
|
||||
@@ -580,8 +580,6 @@ export async function runExecProcess(opts: {
|
||||
workdir: string;
|
||||
env: Record<string, string>;
|
||||
pathPrepend?: string[];
|
||||
/** Whether to restore the Gateway user's cached shell startup state. */
|
||||
useShellSnapshot?: boolean;
|
||||
sandbox?: BashSandboxConfig;
|
||||
containerWorkdir?: string | null;
|
||||
usePty: boolean;
|
||||
@@ -766,16 +764,13 @@ export async function runExecProcess(opts: {
|
||||
shellRuntimeEnv,
|
||||
opts.pathPrepend,
|
||||
);
|
||||
const commandWithShellSnapshot =
|
||||
opts.useShellSnapshot === false
|
||||
? commandWithPathPrepend
|
||||
: await maybeWrapCommandWithShellSnapshot({
|
||||
command: commandWithPathPrepend,
|
||||
shell,
|
||||
shellArgs,
|
||||
cwd: opts.workdir,
|
||||
env: shellRuntimeEnv,
|
||||
});
|
||||
const commandWithShellSnapshot = await maybeWrapCommandWithShellSnapshot({
|
||||
command: commandWithPathPrepend,
|
||||
shell,
|
||||
shellArgs,
|
||||
cwd: opts.workdir,
|
||||
env: shellRuntimeEnv,
|
||||
});
|
||||
|
||||
const childArgv = [shell, ...shellArgs, commandWithShellSnapshot];
|
||||
if (opts.usePty) {
|
||||
|
||||
@@ -29,10 +29,6 @@ export type ExecToolDefaults = {
|
||||
ask?: ExecAsk;
|
||||
trigger?: string;
|
||||
node?: string;
|
||||
/** Trusted, operator-configured environment scoped to this agent's exec children. */
|
||||
env?: Record<string, unknown>;
|
||||
/** Inherit the Gateway process environment for Gateway-hosted exec (default: true). */
|
||||
inheritHostEnv?: boolean;
|
||||
pathPrepend?: string[];
|
||||
safeBins?: string[];
|
||||
strictInlineEval?: boolean;
|
||||
|
||||
@@ -33,9 +33,7 @@ const mocks = vi.hoisted(() => ({
|
||||
requestedEnv?: Record<string, string>;
|
||||
}>,
|
||||
spawnInputs: [] as Array<{
|
||||
argv?: string[];
|
||||
env?: Record<string, string>;
|
||||
ptyCommand?: string;
|
||||
}>,
|
||||
}));
|
||||
|
||||
@@ -86,17 +84,8 @@ vi.mock("./bash-tools.exec-host-node.js", () => ({
|
||||
|
||||
vi.mock("../process/supervisor/index.js", () => ({
|
||||
getProcessSupervisor: () => ({
|
||||
spawn: async (input: {
|
||||
argv?: string[];
|
||||
env?: Record<string, string>;
|
||||
onStdout?: (chunk: string) => void;
|
||||
ptyCommand?: string;
|
||||
}) => {
|
||||
mocks.spawnInputs.push({
|
||||
argv: input.argv ? [...input.argv] : undefined,
|
||||
env: input.env ? { ...input.env } : undefined,
|
||||
ptyCommand: input.ptyCommand,
|
||||
});
|
||||
spawn: async (input: { env?: Record<string, string>; onStdout?: (chunk: string) => void }) => {
|
||||
mocks.spawnInputs.push({ env: input.env ? { ...input.env } : undefined });
|
||||
input.onStdout?.("ok\n");
|
||||
return {
|
||||
runId: "mock-run",
|
||||
@@ -241,90 +230,6 @@ describe("exec resolve_exec_env hook wiring", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("applies inherited, model, agent, and plugin precedence across key casing", async () => {
|
||||
const inheritedKey = "BREX_CASE_SCOPED_TOKEN";
|
||||
const previous = process.env[inheritedKey];
|
||||
process.env[inheritedKey] = "inherited";
|
||||
installResolveExecEnvHook({ brex_case_scoped_token: "plugin" });
|
||||
|
||||
try {
|
||||
const tool = createExecTool({
|
||||
host: "gateway",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
env: { Brex_Case_Scoped_Token: "agent" },
|
||||
});
|
||||
await tool.execute("call-case-precedence", {
|
||||
command: "echo ok",
|
||||
env: { BREX_CASE_SCOPED_TOKEN: "model" },
|
||||
yieldMs: 120_000,
|
||||
});
|
||||
|
||||
const requestedMatches = Object.entries(mocks.gatewayParams[0]?.requestedEnv ?? {}).filter(
|
||||
([key]) => key.toUpperCase() === inheritedKey,
|
||||
);
|
||||
const effectiveMatches = Object.entries(mocks.gatewayParams[0]?.env ?? {}).filter(
|
||||
([key]) => key.toUpperCase() === inheritedKey,
|
||||
);
|
||||
expect(requestedMatches).toEqual([["brex_case_scoped_token", "plugin"]]);
|
||||
expect(effectiveMatches).toEqual([["brex_case_scoped_token", "plugin"]]);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env[inheritedKey];
|
||||
} else {
|
||||
process.env[inheritedKey] = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["gateway", "node"] as const)(
|
||||
"drops stale inherited channel context for %s exec without turn context",
|
||||
async (host) => {
|
||||
const previous = process.env[CHANNEL_CONTEXT_ENV_KEY];
|
||||
process.env[CHANNEL_CONTEXT_ENV_KEY] = "stale-channel-context";
|
||||
try {
|
||||
const tool = createExecTool({ host, security: "full", ask: "off" });
|
||||
await tool.execute(`call-stale-context-${host}`, {
|
||||
command: "echo ok",
|
||||
yieldMs: 120_000,
|
||||
});
|
||||
|
||||
const effectiveEnv =
|
||||
host === "node" ? mocks.nodeHostParams[0]?.env : mocks.gatewayParams[0]?.env;
|
||||
expect(effectiveEnv).not.toHaveProperty(CHANNEL_CONTEXT_ENV_KEY);
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env[CHANNEL_CONTEXT_ENV_KEY];
|
||||
} else {
|
||||
process.env[CHANNEL_CONTEXT_ENV_KEY] = previous;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("drops stale sandbox channel context when the turn has no channel context", async () => {
|
||||
const tool = createExecTool({
|
||||
host: "sandbox",
|
||||
security: "full",
|
||||
ask: "off",
|
||||
cwd: process.cwd(),
|
||||
sandbox: {
|
||||
containerName: "openclaw-test-sandbox",
|
||||
workspaceDir: process.cwd(),
|
||||
containerWorkdir: "/workspace",
|
||||
env: { [CHANNEL_CONTEXT_ENV_KEY]: "stale-sandbox-context" },
|
||||
},
|
||||
});
|
||||
|
||||
await tool.execute("call-stale-sandbox-context", {
|
||||
command: "echo ok",
|
||||
yieldMs: 120_000,
|
||||
});
|
||||
|
||||
expect(JSON.stringify(mocks.spawnInputs[0])).not.toContain("stale-sandbox-context");
|
||||
expect(JSON.stringify(mocks.spawnInputs[0])).not.toContain(CHANNEL_CONTEXT_ENV_KEY);
|
||||
});
|
||||
|
||||
it("forwards filtered plugin env to node host requests", async () => {
|
||||
installResolveExecEnvHook({
|
||||
NODE_HOST_SAFE: "yes",
|
||||
|
||||
@@ -34,14 +34,8 @@ import {
|
||||
isDangerousHostEnvVarName,
|
||||
normalizeHostOverrideEnvVarKey,
|
||||
sanitizeHostExecEnvWithDiagnostics,
|
||||
setCaseInsensitiveEnvValue,
|
||||
validateConfiguredExecEnvKey,
|
||||
} from "../infra/host-env-security.js";
|
||||
import {
|
||||
OPENCLAW_CHANNEL_CONTEXT_ENV_VAR,
|
||||
OPENCLAW_CLI_ENV_VAR,
|
||||
} from "../infra/openclaw-exec-env.js";
|
||||
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
|
||||
import { OPENCLAW_CLI_ENV_VAR } from "../infra/openclaw-exec-env.js";
|
||||
import {
|
||||
getShellPathFromLoginShell,
|
||||
resolveShellEnvFallbackTimeoutMs,
|
||||
@@ -115,7 +109,7 @@ type ExecToolArgs = Record<string, unknown> & {
|
||||
node?: string;
|
||||
};
|
||||
|
||||
const CHANNEL_CONTEXT_ENV_KEY = OPENCLAW_CHANNEL_CONTEXT_ENV_VAR;
|
||||
const CHANNEL_CONTEXT_ENV_KEY = "OPENCLAW_CHANNEL_CONTEXT";
|
||||
|
||||
function buildSubprocessChannelContext(
|
||||
channelContext: PluginHookChannelContext | undefined,
|
||||
@@ -158,88 +152,23 @@ function filterPluginExecEnv(rawEnv: Record<string, string>): Record<string, str
|
||||
const env: Record<string, string> = {};
|
||||
for (const [rawKey, value] of Object.entries(rawEnv)) {
|
||||
const key = normalizeHostOverrideEnvVarKey(rawKey);
|
||||
if (!key || isBlockedObjectKey(key)) {
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const upperKey = key.toUpperCase();
|
||||
if (
|
||||
upperKey === "PATH" ||
|
||||
upperKey === OPENCLAW_CLI_ENV_VAR ||
|
||||
upperKey === CHANNEL_CONTEXT_ENV_KEY ||
|
||||
isDangerousHostEnvVarName(upperKey) ||
|
||||
isDangerousHostEnvOverrideVarName(upperKey)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
setCaseInsensitiveEnvValue(env, key, value);
|
||||
env[key] = value;
|
||||
}
|
||||
return Object.keys(env).length > 0 ? env : undefined;
|
||||
}
|
||||
|
||||
function resolveMaterializedExecEnv(
|
||||
env: Record<string, unknown> | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
if (!env) {
|
||||
return undefined;
|
||||
}
|
||||
const resolved: Record<string, string> = {};
|
||||
const seen = new Set<string>();
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
const validation = validateConfiguredExecEnvKey(key);
|
||||
if (!validation.ok) {
|
||||
throw new Error(`agents.list[].tools.exec.env.${key} ${validation.reason}`);
|
||||
}
|
||||
if (seen.has(validation.caseFoldedKey)) {
|
||||
throw new Error(
|
||||
`agents.list[].tools.exec.env contains duplicate key ${JSON.stringify(key)} (case-insensitive)`,
|
||||
);
|
||||
}
|
||||
seen.add(validation.caseFoldedKey);
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(
|
||||
`agents.list[].tools.exec.env.${key} contains an unresolved SecretRef; use the active runtime config snapshot`,
|
||||
);
|
||||
}
|
||||
setCaseInsensitiveEnvValue(resolved, validation.key, value);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function mergeExecEnvLayers(
|
||||
...layers: Array<Record<string, string> | undefined>
|
||||
): Record<string, string> | undefined {
|
||||
const merged: Record<string, string> = {};
|
||||
let hasLayer = false;
|
||||
for (const layer of layers) {
|
||||
if (layer === undefined) {
|
||||
continue;
|
||||
}
|
||||
hasLayer = true;
|
||||
for (const [key, value] of Object.entries(layer)) {
|
||||
if (isBlockedObjectKey(key)) {
|
||||
throw new Error(`Security Violation: Environment variable '${key}' is forbidden.`);
|
||||
}
|
||||
setCaseInsensitiveEnvValue(merged, key, value);
|
||||
}
|
||||
}
|
||||
return hasLayer ? merged : undefined;
|
||||
}
|
||||
|
||||
function applyTrustedChannelContextEnv(
|
||||
env: Record<string, string>,
|
||||
channelContextEnv: Record<string, string> | undefined,
|
||||
): void {
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.trim().toUpperCase() === CHANNEL_CONTEXT_ENV_KEY) {
|
||||
delete env[key];
|
||||
}
|
||||
}
|
||||
const trustedValue = channelContextEnv?.[CHANNEL_CONTEXT_ENV_KEY];
|
||||
if (trustedValue !== undefined) {
|
||||
env[CHANNEL_CONTEXT_ENV_KEY] = trustedValue;
|
||||
}
|
||||
}
|
||||
|
||||
function markResolveExecEnvPrepared<T extends ExecToolArgs>(
|
||||
params: T,
|
||||
state: ResolvedExecEnvPreparedState = {},
|
||||
@@ -1668,34 +1597,15 @@ export function createExecTool(
|
||||
}
|
||||
await rejectUnsafeExecControlShellCommand(params.command);
|
||||
|
||||
const hasConfiguredEnv = Object.keys(defaults?.env ?? {}).length > 0;
|
||||
if (host === "node" && (defaults?.inheritHostEnv === false || hasConfiguredEnv)) {
|
||||
throw new Error(
|
||||
hasConfiguredEnv
|
||||
? "agents.list[].tools.exec.env is not supported for host=node; configure scoped environment on the node host"
|
||||
: "tools.exec.inheritHostEnv=false is not supported for host=node; configure environment isolation on the node host",
|
||||
);
|
||||
}
|
||||
const configuredEnv = resolveMaterializedExecEnv(defaults?.env);
|
||||
for (const source of [params.env, configuredEnv]) {
|
||||
if (
|
||||
source &&
|
||||
Object.keys(source).some((key) => key.trim().toUpperCase() === CHANNEL_CONTEXT_ENV_KEY)
|
||||
) {
|
||||
throw new Error(
|
||||
`Security Violation: Environment variable '${CHANNEL_CONTEXT_ENV_KEY}' is reserved for trusted channel context.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const inheritedBaseEnv = defaults?.inheritHostEnv === false ? {} : coerceEnv(process.env);
|
||||
const inheritedBaseEnv = coerceEnv(process.env);
|
||||
const resolvedExecEnvState = getResolvedExecEnvPreparedState(params);
|
||||
const channelContextEnv = buildChannelContextEnv(defaults?.channelContext);
|
||||
const requestedEnv = mergeExecEnvLayers(
|
||||
params.env,
|
||||
configuredEnv,
|
||||
resolvedExecEnvState?.pluginEnv,
|
||||
channelContextEnv,
|
||||
);
|
||||
const requestedEnv: Record<string, string> | undefined =
|
||||
params.env !== undefined ||
|
||||
resolvedExecEnvState?.pluginEnv !== undefined ||
|
||||
channelContextEnv !== undefined
|
||||
? { ...params.env, ...resolvedExecEnvState?.pluginEnv, ...channelContextEnv }
|
||||
: undefined;
|
||||
const hostEnvResult =
|
||||
host === "sandbox"
|
||||
? null
|
||||
@@ -1748,14 +1658,8 @@ export function createExecTool(
|
||||
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
|
||||
})
|
||||
: (hostEnvResult?.env ?? inheritedBaseEnv);
|
||||
applyTrustedChannelContextEnv(env, channelContextEnv);
|
||||
|
||||
if (
|
||||
!sandbox &&
|
||||
host === "gateway" &&
|
||||
defaults?.inheritHostEnv !== false &&
|
||||
!requestedEnv?.PATH
|
||||
) {
|
||||
if (!sandbox && host === "gateway" && !requestedEnv?.PATH) {
|
||||
const shellPath = getShellPathFromLoginShell({
|
||||
env: process.env,
|
||||
timeoutMs: resolveShellEnvFallbackTimeoutMs(process.env),
|
||||
@@ -1818,7 +1722,6 @@ export function createExecTool(
|
||||
workdir,
|
||||
env,
|
||||
pathPrepend: defaultPathPrepend,
|
||||
useShellSnapshot: defaults?.inheritHostEnv !== false,
|
||||
requestedEnv,
|
||||
pty: params.pty === true && !sandbox,
|
||||
timeoutSec: params.timeout,
|
||||
@@ -1882,7 +1785,6 @@ export function createExecTool(
|
||||
workdir,
|
||||
env,
|
||||
pathPrepend: defaultPathPrepend,
|
||||
useShellSnapshot: defaults?.inheritHostEnv !== false,
|
||||
sandbox,
|
||||
containerWorkdir,
|
||||
usePty,
|
||||
|
||||
@@ -8,7 +8,6 @@ import fs from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { parseStrictInteger } from "@openclaw/normalization-core/number-coercion";
|
||||
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
|
||||
import { sliceUtf16Safe } from "../utils.js";
|
||||
import { assertSandboxPath } from "./sandbox-paths.js";
|
||||
import type { SandboxBackendExecSpec } from "./sandbox/backend-handle.types.js";
|
||||
@@ -47,14 +46,10 @@ export function buildSandboxEnv(params: {
|
||||
HOME: params.containerWorkdir,
|
||||
};
|
||||
for (const [key, value] of Object.entries(params.sandboxEnv ?? {})) {
|
||||
if (!isBlockedObjectKey(key)) {
|
||||
env[key] = value;
|
||||
}
|
||||
env[key] = value;
|
||||
}
|
||||
for (const [key, value] of Object.entries(params.paramsEnv ?? {})) {
|
||||
if (!isBlockedObjectKey(key)) {
|
||||
env[key] = value;
|
||||
}
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -23,18 +23,6 @@ describe("realredactConfigSnapshot_real", () => {
|
||||
apiKey: "6789",
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
exec: {
|
||||
env: {
|
||||
REGION: "exec-secret",
|
||||
CREDENTIAL: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "REFERRALS_CREDENTIAL",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -44,24 +32,9 @@ describe("realredactConfigSnapshot_real", () => {
|
||||
const config = result.config as typeof snapshot.config;
|
||||
expect(config.agents.defaults.memorySearch.remote.apiKey).toBe(REDACTED_SENTINEL);
|
||||
expect(config.agents.list[0].memorySearch.remote.apiKey).toBe(REDACTED_SENTINEL);
|
||||
expect(config.agents.list[0].tools.exec.env.REGION).toBe(REDACTED_SENTINEL);
|
||||
expect(config.agents.list[0].tools.exec.env.CREDENTIAL).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: REDACTED_SENTINEL,
|
||||
});
|
||||
expect(result.parsed?.agents.list[0].tools.exec.env.REGION).toBe(REDACTED_SENTINEL);
|
||||
expect(result.raw).not.toContain("exec-secret");
|
||||
expect(result.raw).not.toContain("REFERRALS_CREDENTIAL");
|
||||
const restored = restoreRedactedValues(result.config, snapshot.config, mainSchemaHints);
|
||||
expect(restored.agents.defaults.memorySearch.remote.apiKey).toBe("1234");
|
||||
expect(restored.agents.list[0].memorySearch.remote.apiKey).toBe("6789");
|
||||
expect(restored.agents.list[0].tools.exec.env.REGION).toBe("exec-secret");
|
||||
expect(restored.agents.list[0].tools.exec.env.CREDENTIAL).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "REFERRALS_CREDENTIAL",
|
||||
});
|
||||
});
|
||||
|
||||
it("redacts bundled channel private keys from generated schema hints", () => {
|
||||
|
||||
@@ -773,10 +773,6 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"Per-agent additive allowlist for tools on top of global and profile policy. Keep narrow to avoid accidental privilege expansion on specialized agents.",
|
||||
"agents.list[].tools.codeMode":
|
||||
"Per-agent code mode override. Use this to test or roll out exec/wait tool-surface mode for one agent without enabling it fleet-wide.",
|
||||
"agents.list[].tools.exec.env":
|
||||
"Environment variables injected only into this agent's exec child processes. Values may be plaintext or SecretRefs; prefer SecretRefs for credentials.",
|
||||
"agents.list[].tools.exec.inheritHostEnv":
|
||||
"Whether Gateway-hosted exec inherits the Gateway process environment. Set false for a minimal environment when isolating agent credentials; sandbox exec is already minimal and node-host inheritance is configured on the node.",
|
||||
"agents.list[].tools.byProvider":
|
||||
"Per-agent provider-specific tool policy overrides for channel-scoped capability control. Use this when a single agent needs tighter restrictions on one provider than others.",
|
||||
"agents.list[].tools.message.crossContext.allowWithinProvider":
|
||||
|
||||
@@ -88,7 +88,6 @@ describe("mapSensitivePaths", () => {
|
||||
merged: z
|
||||
.object({ id: z.string() })
|
||||
.and(z.object({ nested: z.string().register(sensitive) })),
|
||||
pipedRecord: z.unknown().pipe(z.record(z.string(), z.string().register(sensitive))),
|
||||
});
|
||||
|
||||
const result = mapSensitivePaths(GrandSchema, "", {});
|
||||
@@ -102,7 +101,6 @@ describe("mapSensitivePaths", () => {
|
||||
expect(result["headersNested.*.nested"]?.sensitive).toBe(true);
|
||||
expect(result["auth.value"]?.sensitive).toBe(true);
|
||||
expect(result["merged.nested"]?.sensitive).toBe(true);
|
||||
expect(result["pipedRecord.*"]?.sensitive).toBe(true);
|
||||
});
|
||||
|
||||
it("should not detect non-sensitive fields nested inside all structural Zod types", () => {
|
||||
@@ -121,7 +119,6 @@ describe("mapSensitivePaths", () => {
|
||||
z.object({ type: z.literal("token"), value: z.string() }),
|
||||
]),
|
||||
merged: z.object({ id: z.string() }).and(z.object({ nested: z.string() })),
|
||||
pipedRecord: z.unknown().pipe(z.record(z.string(), z.string())),
|
||||
});
|
||||
|
||||
const result = mapSensitivePaths(GrandSchema, "", {});
|
||||
@@ -135,7 +132,6 @@ describe("mapSensitivePaths", () => {
|
||||
expect(result["headersNested.*.nested"]?.sensitive).toBe(undefined);
|
||||
expect(result["auth.value"]?.sensitive).toBe(undefined);
|
||||
expect(result["merged.nested"]?.sensitive).toBe(undefined);
|
||||
expect(result["pipedRecord.*"]?.sensitive).toBe(undefined);
|
||||
});
|
||||
|
||||
it("maps sensitive fields nested under object catchall schemas", () => {
|
||||
@@ -192,7 +188,6 @@ describe("mapSensitivePaths", () => {
|
||||
|
||||
expect(hints["agents.defaults.memorySearch.remote.apiKey"]?.sensitive).toBe(true);
|
||||
expect(hints["agents.list[].memorySearch.remote.apiKey"]?.sensitive).toBe(true);
|
||||
expect(hints["agents.list[].tools.exec.env.*"]?.sensitive).toBe(true);
|
||||
expect(hints["gateway.auth.token"]?.sensitive).toBe(true);
|
||||
expect(hints["models.providers.*.headers.*"]?.sensitive).toBe(true);
|
||||
expect(hints["models.providers.*.localService.env.*"]?.sensitive).toBe(true);
|
||||
|
||||
@@ -239,9 +239,6 @@ export function collectMatchingSchemaPaths(
|
||||
} else if (currentSchema instanceof z.ZodIntersection) {
|
||||
collectMatchingSchemaPaths(currentSchema["_def"].left as z.ZodType, path, matchesPath, paths);
|
||||
collectMatchingSchemaPaths(currentSchema["_def"].right as z.ZodType, path, matchesPath, paths);
|
||||
} else if (currentSchema instanceof z.ZodPipe) {
|
||||
collectMatchingSchemaPaths(currentSchema["_def"].in as z.ZodType, path, matchesPath, paths);
|
||||
collectMatchingSchemaPaths(currentSchema["_def"].out as z.ZodType, path, matchesPath, paths);
|
||||
}
|
||||
|
||||
return paths;
|
||||
@@ -320,9 +317,6 @@ function mapSensitivePathsMut(schema: z.ZodType, path: string, hints: ConfigUiHi
|
||||
} else if (currentSchema instanceof z.ZodIntersection) {
|
||||
mapSensitivePathsMut(currentSchema["_def"].left as z.ZodType, path, hints);
|
||||
mapSensitivePathsMut(currentSchema["_def"].right as z.ZodType, path, hints);
|
||||
} else if (currentSchema instanceof z.ZodPipe) {
|
||||
mapSensitivePathsMut(currentSchema["_def"].in as z.ZodType, path, hints);
|
||||
mapSensitivePathsMut(currentSchema["_def"].out as z.ZodType, path, hints);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -214,8 +214,6 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"agents.list[].tools.profile": "Agent Tool Profile",
|
||||
"agents.list[].tools.alsoAllow": "Agent Tool Allowlist Additions",
|
||||
"agents.list[].tools.codeMode": "Agent Code Mode",
|
||||
"agents.list[].tools.exec.env": "Agent Exec Environment",
|
||||
"agents.list[].tools.exec.inheritHostEnv": "Inherit Gateway Environment",
|
||||
"tools.byProvider": "Tool Policy by Provider",
|
||||
"agents.list[].tools.byProvider": "Agent Tool Policy by Provider",
|
||||
"agents.list[].tools.message.crossContext.allowWithinProvider":
|
||||
|
||||
@@ -1040,16 +1040,6 @@ describe("config schema", () => {
|
||||
expect(schema?.properties).toHaveProperty("vars");
|
||||
});
|
||||
|
||||
it("keeps per-agent exec env records discoverable and sensitive", () => {
|
||||
const lookup = lookupConfigSchema(baseSchema, "agents.list.0.tools.exec.env");
|
||||
expect(lookup?.schema?.type).toBe("object");
|
||||
expect(lookup?.schema?.additionalProperties).toBeTypeOf("object");
|
||||
const wildcard = lookup?.children.find((child) => child.key === "*");
|
||||
expect(wildcard?.hasChildren).toBe(true);
|
||||
expect(wildcard?.hintPath).toBe("agents.list[].tools.exec.env.*");
|
||||
expect(wildcard?.hint?.sensitive).toBe(true);
|
||||
});
|
||||
|
||||
it("matches wildcard ui hints for concrete lookup paths", () => {
|
||||
const lookup = lookupConfigSchema(baseSchema, "agents.list.0.identity.avatar");
|
||||
expect(lookup?.path).toBe("agents.list.0.identity.avatar");
|
||||
|
||||
@@ -376,13 +376,6 @@ export type ExecToolConfig = {
|
||||
};
|
||||
};
|
||||
|
||||
export type AgentExecToolConfig = ExecToolConfig & {
|
||||
/** Environment variables injected only into this agent's exec child processes. */
|
||||
env?: Record<string, SecretInput>;
|
||||
/** Inherit the Gateway process environment for Gateway-hosted exec (default: true). */
|
||||
inheritHostEnv?: boolean;
|
||||
};
|
||||
|
||||
export type FsToolsConfig = {
|
||||
/**
|
||||
* Restrict filesystem tools (read/write/edit/apply_patch) to the agent workspace directory.
|
||||
@@ -423,7 +416,7 @@ export type AgentToolsConfig = {
|
||||
allowFrom?: AgentElevatedAllowFromConfig;
|
||||
};
|
||||
/** Exec tool defaults for this agent. */
|
||||
exec?: AgentExecToolConfig;
|
||||
exec?: ExecToolConfig;
|
||||
/** Filesystem tool path guards. */
|
||||
fs?: FsToolsConfig;
|
||||
/** Runtime loop detection for repetitive/ stuck tool-call patterns. */
|
||||
|
||||
@@ -455,62 +455,6 @@ describe("agent defaults schema", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts SecretRef-backed per-agent exec environments", () => {
|
||||
const parsed = AgentEntrySchema.parse({
|
||||
id: "referrals",
|
||||
tools: {
|
||||
exec: {
|
||||
inheritHostEnv: false,
|
||||
env: {
|
||||
GREENHOUSE_TOKEN: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "REFERRALS_GREENHOUSE_TOKEN",
|
||||
},
|
||||
REGION: "us-east-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.tools?.exec?.inheritHostEnv).toBe(false);
|
||||
expect(parsed.tools?.exec?.env?.REGION).toBe("us-east-1");
|
||||
});
|
||||
|
||||
it("rejects unsafe or ambiguous per-agent exec environment keys", () => {
|
||||
const invalidEnvs = [
|
||||
{ PATH: "/tmp/bin" },
|
||||
{ NODE_OPTIONS: "--require ./inject.js" },
|
||||
{ OPENCLAW_CHANNEL_CONTEXT: "spoofed" },
|
||||
{ "not-portable": "value" },
|
||||
{ TOKEN: "first", token: "second" },
|
||||
Object.fromEntries([["__proto__", "polluted"]]),
|
||||
];
|
||||
|
||||
for (const env of invalidEnvs) {
|
||||
expectSchemaFailurePath(
|
||||
AgentEntrySchema.safeParse({ id: "ops", tools: { exec: { env } } }),
|
||||
"tools.exec.env",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps exec environment injection agent-scoped", () => {
|
||||
const result = validateConfigObject({
|
||||
tools: {
|
||||
exec: {
|
||||
env: { SHARED_SECRET: "not-allowed" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("expected global tools.exec.env to be rejected");
|
||||
}
|
||||
expect(result.issues.some((issue) => issue.path === "tools.exec")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-positive contextTokens on agent entries and defaults", () => {
|
||||
expectSchemaFailurePath(
|
||||
AgentEntrySchema.safeParse({ id: "ops", contextTokens: 0 }),
|
||||
|
||||
@@ -10,7 +10,6 @@ import { splitSandboxBindSpec } from "../agents/sandbox/bind-spec.js";
|
||||
import { isSandboxHostPathAbsolute } from "../agents/sandbox/host-paths.js";
|
||||
import { getBlockedNetworkModeReason } from "../agents/sandbox/network-mode.js";
|
||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||
import { validateConfiguredExecEnvKey } from "../infra/host-env-security.js";
|
||||
import { isBlockedObjectKey } from "../infra/prototype-keys.js";
|
||||
import { LEGACY_WEB_SEARCH_PROVIDER_CONFIG_KEYS } from "./web-search-legacy-provider-keys.js";
|
||||
import { AgentModelSchema, AgentToolModelSchema } from "./zod-schema.agent-model.js";
|
||||
@@ -594,55 +593,9 @@ function addExecPolicyModeConflictIssue(
|
||||
});
|
||||
}
|
||||
|
||||
const AgentExecEnvRecordSchema = z.record(z.string(), SecretInputSchema.register(sensitive));
|
||||
|
||||
function buildNestedJsonSchemaMetadata(schema: z.ZodType): Record<string, unknown> {
|
||||
const metadata = {
|
||||
...schema.toJSONSchema({ target: "draft-07", io: "input", unrepresentable: "any" }),
|
||||
} as Record<string, unknown>;
|
||||
delete metadata.$schema;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
const AgentExecEnvSchema = z
|
||||
.unknown()
|
||||
// Keep the raw input available for blocked-key validation while preserving
|
||||
// the record shape for config-schema and form consumers.
|
||||
.meta(buildNestedJsonSchemaMetadata(AgentExecEnvRecordSchema))
|
||||
.superRefine((value, ctx) => {
|
||||
if (!isPlainRecord(value)) {
|
||||
return;
|
||||
}
|
||||
const seen = new Map<string, string>();
|
||||
for (const key of Object.keys(value)) {
|
||||
const validation = validateConfiguredExecEnvKey(key);
|
||||
if (!validation.ok) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [key],
|
||||
message: `Agent exec environment key ${JSON.stringify(key)} ${validation.reason}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const previous = seen.get(validation.caseFoldedKey);
|
||||
if (previous) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: [key],
|
||||
message: `Agent exec environment keys ${JSON.stringify(previous)} and ${JSON.stringify(key)} collide case-insensitively.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
seen.set(validation.caseFoldedKey, key);
|
||||
}
|
||||
})
|
||||
.pipe(AgentExecEnvRecordSchema);
|
||||
|
||||
const AgentToolExecSchema = z
|
||||
.object({
|
||||
...ToolExecBaseShape,
|
||||
env: AgentExecEnvSchema.optional(),
|
||||
inheritHostEnv: z.boolean().optional(),
|
||||
approvalRunningNoticeMs: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
sanitizeHostExecEnvWithDiagnostics,
|
||||
sanitizeSystemRunEnvOverrides,
|
||||
} from "./host-env-security.js";
|
||||
import { OPENCLAW_CHANNEL_CONTEXT_ENV_VAR, OPENCLAW_CLI_ENV_VALUE } from "./openclaw-exec-env.js";
|
||||
import { OPENCLAW_CLI_ENV_VALUE } from "./openclaw-exec-env.js";
|
||||
|
||||
function findSystemCommandPath(command: string) {
|
||||
if (process.platform === "win32") {
|
||||
@@ -1523,46 +1523,6 @@ describe("sanitizeHostExecEnvWithDiagnostics", () => {
|
||||
expect(result.rejectedOverrideInvalidKeys).toEqual(["BAD-KEY"]);
|
||||
expect(result.env["ProgramFiles(x86)"]).toBe("D:\\SDKs");
|
||||
});
|
||||
|
||||
it("drops inherited channel context and applies overrides case-insensitively", () => {
|
||||
const result = sanitizeHostExecEnvWithDiagnostics({
|
||||
baseEnv: {
|
||||
[OPENCLAW_CHANNEL_CONTEXT_ENV_VAR]: "stale",
|
||||
SCOPED_TOKEN: "inherited",
|
||||
},
|
||||
overrides: {
|
||||
scoped_token: "configured",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.env).not.toHaveProperty(OPENCLAW_CHANNEL_CONTEXT_ENV_VAR);
|
||||
expect(
|
||||
Object.entries(result.env).filter(([key]) => key.toUpperCase() === "SCOPED_TOKEN"),
|
||||
).toEqual([["scoped_token", "configured"]]);
|
||||
});
|
||||
|
||||
it("preserves case-distinct inherited variables when no override targets them", () => {
|
||||
const result = sanitizeHostExecEnvWithDiagnostics({
|
||||
baseEnv: {
|
||||
HTTP_PROXY_ALIAS: "upper",
|
||||
http_proxy_alias: "lower",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.env.HTTP_PROXY_ALIAS).toBe("upper");
|
||||
expect(result.env.http_proxy_alias).toBe("lower");
|
||||
});
|
||||
|
||||
it("rejects prototype keys without mutating result objects", () => {
|
||||
const result = sanitizeHostExecEnvWithDiagnostics({
|
||||
baseEnv: {},
|
||||
overrides: Object.fromEntries([["__proto__", "polluted"]]),
|
||||
});
|
||||
|
||||
expect(result.rejectedOverrideBlockedKeys).toEqual(["__proto__"]);
|
||||
expect(Object.hasOwn(result.env, "__proto__")).toBe(false);
|
||||
expect(Object.getPrototypeOf(result.env)).toBe(Object.prototype);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeEnvVarKey", () => {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
// Filters host environment variables before passing them to runtimes.
|
||||
import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import { HOST_ENV_SECURITY_POLICY } from "./host-env-security-policy.js";
|
||||
import {
|
||||
markOpenClawExecEnv,
|
||||
OPENCLAW_CHANNEL_CONTEXT_ENV_VAR,
|
||||
OPENCLAW_CLI_ENV_VAR,
|
||||
} from "./openclaw-exec-env.js";
|
||||
import { isBlockedObjectKey } from "./prototype-keys.js";
|
||||
import { markOpenClawExecEnv } from "./openclaw-exec-env.js";
|
||||
|
||||
const PORTABLE_ENV_VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const WINDOWS_COMPAT_OVERRIDE_ENV_VAR_KEY = /^[A-Za-z_][A-Za-z0-9_()]*$/;
|
||||
@@ -143,52 +138,6 @@ export function isDangerousHostEnvOverrideVarName(rawKey: string): boolean {
|
||||
return HOST_DANGEROUS_OVERRIDE_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix));
|
||||
}
|
||||
|
||||
export type ConfiguredExecEnvKeyValidation =
|
||||
| { ok: true; key: string; caseFoldedKey: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
/** Validates operator-configured agent exec env keys against the host security boundary. */
|
||||
export function validateConfiguredExecEnvKey(rawKey: string): ConfiguredExecEnvKeyValidation {
|
||||
const key = normalizeEnvVarKey(rawKey, { portable: true });
|
||||
if (!key || key !== rawKey) {
|
||||
return { ok: false, reason: "must be a portable environment variable name" };
|
||||
}
|
||||
const upper = key.toUpperCase();
|
||||
if (isBlockedObjectKey(key)) {
|
||||
return { ok: false, reason: "uses a blocked prototype key" };
|
||||
}
|
||||
if (upper === "PATH") {
|
||||
return { ok: false, reason: "PATH is controlled by tools.exec.pathPrepend" };
|
||||
}
|
||||
if (upper === OPENCLAW_CLI_ENV_VAR || upper === OPENCLAW_CHANNEL_CONTEXT_ENV_VAR) {
|
||||
return { ok: false, reason: "is reserved by OpenClaw" };
|
||||
}
|
||||
if (isDangerousHostEnvVarName(upper) || isDangerousHostEnvOverrideVarName(upper)) {
|
||||
return { ok: false, reason: "is blocked by the host exec environment policy" };
|
||||
}
|
||||
return { ok: true, key, caseFoldedKey: upper };
|
||||
}
|
||||
|
||||
/** Sets an env value while making later layers win across case variants on every platform. */
|
||||
export function setCaseInsensitiveEnvValue(
|
||||
env: Record<string, string>,
|
||||
key: string,
|
||||
value: string,
|
||||
): void {
|
||||
const foldedKey = key.toUpperCase();
|
||||
for (const existingKey of Object.keys(env)) {
|
||||
if (existingKey !== key && existingKey.toUpperCase() === foldedKey) {
|
||||
delete env[existingKey];
|
||||
}
|
||||
}
|
||||
Object.defineProperty(env, key, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
function listNormalizedEnvEntries(
|
||||
source: Record<string, string | undefined>,
|
||||
options?: { portable?: boolean },
|
||||
@@ -237,9 +186,6 @@ export function sanitizeHostInheritedEnvEntry(
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
if (isBlockedObjectKey(key) || key.toUpperCase() === OPENCLAW_CHANNEL_CONTEXT_ENV_VAR) {
|
||||
return null;
|
||||
}
|
||||
// Preserve inherited Git allowlists without widening malformed or unsafe entries by deletion.
|
||||
// Protocols outside Git's safe default set are removed instead of being passed through.
|
||||
if (key.toUpperCase() === GIT_ALLOW_PROTOCOL_ENV_KEY) {
|
||||
@@ -292,10 +238,6 @@ function sanitizeHostEnvOverridesWithDiagnostics(params?: {
|
||||
continue;
|
||||
}
|
||||
const upper = normalized.toUpperCase();
|
||||
if (isBlockedObjectKey(normalized)) {
|
||||
rejectedBlocked.push(normalized);
|
||||
continue;
|
||||
}
|
||||
// PATH is part of the security boundary (command resolution + safe-bin checks). Never allow
|
||||
// request-scoped PATH overrides from agents/gateways.
|
||||
if (blockPathOverrides && upper === "PATH") {
|
||||
@@ -306,7 +248,7 @@ function sanitizeHostEnvOverridesWithDiagnostics(params?: {
|
||||
rejectedBlocked.push(upper);
|
||||
continue;
|
||||
}
|
||||
setCaseInsensitiveEnvValue(acceptedOverrides, normalized, value);
|
||||
acceptedOverrides[normalized] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -330,12 +272,7 @@ export function sanitizeHostExecEnvWithDiagnostics(params?: {
|
||||
continue;
|
||||
}
|
||||
const [sanitizedKey, sanitizedValue] = sanitizedEntry;
|
||||
Object.defineProperty(merged, sanitizedKey, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
value: sanitizedValue,
|
||||
});
|
||||
merged[sanitizedKey] = sanitizedValue;
|
||||
}
|
||||
|
||||
const overrideResult = sanitizeHostEnvOverridesWithDiagnostics({
|
||||
@@ -344,7 +281,7 @@ export function sanitizeHostExecEnvWithDiagnostics(params?: {
|
||||
});
|
||||
if (overrideResult.acceptedOverrides) {
|
||||
for (const [key, value] of Object.entries(overrideResult.acceptedOverrides)) {
|
||||
setCaseInsensitiveEnvValue(merged, key, value);
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
/** Process env key that marks child commands as launched by the OpenClaw CLI. */
|
||||
export const OPENCLAW_CLI_ENV_VAR = "OPENCLAW_CLI";
|
||||
|
||||
/** Reserved exec env key carrying trusted sender/chat metadata. */
|
||||
export const OPENCLAW_CHANNEL_CONTEXT_ENV_VAR = "OPENCLAW_CHANNEL_CONTEXT";
|
||||
|
||||
/** Stable marker value used for OpenClaw-launched subprocess detection. */
|
||||
export const OPENCLAW_CLI_ENV_VALUE = "1";
|
||||
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
/** Tests SecretRef materialization for per-agent exec environments. */
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { asConfig, setupSecretsRuntimeSnapshotTestHooks } from "./runtime.test-support.ts";
|
||||
|
||||
const { prepareSecretsRuntimeSnapshot } = setupSecretsRuntimeSnapshotTestHooks();
|
||||
|
||||
describe("secrets runtime per-agent exec env", () => {
|
||||
it("resolves each configured exec env SecretRef into the active snapshot", async () => {
|
||||
const sourceConfig = asConfig({
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "referrals",
|
||||
tools: {
|
||||
exec: {
|
||||
inheritHostEnv: false,
|
||||
env: {
|
||||
GREENHOUSE_TOKEN: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "REFERRALS_GREENHOUSE_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const snapshot = await prepareSecretsRuntimeSnapshot({
|
||||
config: sourceConfig,
|
||||
env: { REFERRALS_GREENHOUSE_TOKEN: "gh-scoped-token" },
|
||||
includeAuthStoreRefs: false,
|
||||
loadablePluginOrigins: new Map(),
|
||||
});
|
||||
|
||||
expect(snapshot.config.agents?.list?.[0]?.tools?.exec?.env?.GREENHOUSE_TOKEN).toBe(
|
||||
"gh-scoped-token",
|
||||
);
|
||||
expect(sourceConfig.agents?.list?.[0]?.tools?.exec?.env?.GREENHOUSE_TOKEN).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "REFERRALS_GREENHOUSE_TOKEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails atomically when an active exec env SecretRef is unresolved", async () => {
|
||||
await expect(
|
||||
prepareSecretsRuntimeSnapshot({
|
||||
config: asConfig({
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "referrals",
|
||||
tools: {
|
||||
exec: {
|
||||
env: {
|
||||
GREENHOUSE_TOKEN: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "MISSING_REFERRALS_GREENHOUSE_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
env: {},
|
||||
includeAuthStoreRefs: false,
|
||||
loadablePluginOrigins: new Map(),
|
||||
}),
|
||||
).rejects.toThrow(/MISSING_REFERRALS_GREENHOUSE_TOKEN/);
|
||||
});
|
||||
|
||||
it("does not resolve agent exec env refs that are inactive on a fixed node host", async () => {
|
||||
const ref = {
|
||||
source: "env" as const,
|
||||
provider: "default",
|
||||
id: "NODE_HOST_ONLY_TOKEN",
|
||||
};
|
||||
const snapshot = await prepareSecretsRuntimeSnapshot({
|
||||
config: asConfig({
|
||||
tools: { exec: { host: "node" } },
|
||||
agents: {
|
||||
list: [{ id: "remote", tools: { exec: { env: { NODE_TOKEN: ref } } } }],
|
||||
},
|
||||
}),
|
||||
env: {},
|
||||
includeAuthStoreRefs: false,
|
||||
loadablePluginOrigins: new Map(),
|
||||
});
|
||||
|
||||
expect(snapshot.config.agents?.list?.[0]?.tools?.exec?.env?.NODE_TOKEN).toEqual(ref);
|
||||
});
|
||||
});
|
||||
@@ -108,35 +108,6 @@ function collectSkillAssignments(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function collectAgentExecEnvAssignments(params: {
|
||||
config: OpenClawConfig;
|
||||
defaults: SecretDefaults | undefined;
|
||||
context: ResolverContext;
|
||||
}): void {
|
||||
for (const [agentIndex, agent] of (params.config.agents?.list ?? []).entries()) {
|
||||
const env = agent.tools?.exec?.env;
|
||||
if (!env) {
|
||||
continue;
|
||||
}
|
||||
const effectiveHost = agent.tools?.exec?.host ?? params.config.tools?.exec?.host ?? "auto";
|
||||
const active = effectiveHost !== "node";
|
||||
for (const [envKey, envValue] of Object.entries(env)) {
|
||||
collectSecretInputAssignment({
|
||||
value: envValue,
|
||||
path: `agents.list.${agentIndex}.tools.exec.env.${envKey}`,
|
||||
expected: "string",
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
active,
|
||||
inactiveReason: "agent exec env is unsupported for host=node.",
|
||||
apply: (value) => {
|
||||
env[envKey] = value as string;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectAgentMemorySearchAssignments(params: {
|
||||
config: OpenClawConfig;
|
||||
defaults: SecretDefaults | undefined;
|
||||
@@ -715,7 +686,6 @@ export function collectCoreConfigAssignments(params: {
|
||||
});
|
||||
}
|
||||
|
||||
collectAgentExecEnvAssignments(params);
|
||||
collectAgentMemorySearchAssignments(params);
|
||||
collectTalkAssignments(params);
|
||||
collectGatewayAssignments(params);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Builds the static and plugin-derived registry of secret migration targets. */
|
||||
import { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js";
|
||||
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
|
||||
import { listBundledPluginMetadata } from "../plugins/bundled-plugin-metadata.js";
|
||||
import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
|
||||
import { loadChannelSecretContractApiForRecord } from "./channel-contract-api.js";
|
||||
import type { SecretTargetRegistryEntry } from "./target-registry-types.js";
|
||||
@@ -173,17 +173,6 @@ const CORE_SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "agents.list[].tools.exec.env.*",
|
||||
targetType: "agents.list[].tools.exec.env.*",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "agents.list[].tools.exec.env.*",
|
||||
secretShape: SECRET_INPUT_SHAPE,
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "cron.webhookToken",
|
||||
targetType: "cron.webhookToken",
|
||||
|
||||
@@ -1191,6 +1191,13 @@ 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()}`;
|
||||
@@ -1208,6 +1215,9 @@ exporters:
|
||||
otlphttp/openclaw:
|
||||
endpoint: ${receiverEndpoint}
|
||||
service:
|
||||
telemetry:
|
||||
metrics:
|
||||
address: 127.0.0.1:${collectorTelemetryPort}
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
|
||||
@@ -719,6 +719,47 @@ 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");
|
||||
@@ -729,6 +770,7 @@ 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(
|
||||
@@ -738,7 +780,7 @@ describe("qa-otel-smoke receiver bounds", () => {
|
||||
return collectorDir;
|
||||
},
|
||||
randomUUID: () => "00000000-0000-4000-8000-000000000000",
|
||||
reserveLocalPort: async () => 4318,
|
||||
reserveLocalPort: async () => ports.shift() ?? 49999,
|
||||
spawn: vi.fn(() => child) as never,
|
||||
stopDockerContainer,
|
||||
waitForLocalPort: async () => {
|
||||
@@ -765,6 +807,7 @@ describe("qa-otel-smoke receiver bounds", () => {
|
||||
};
|
||||
child.stderr = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
const ports = [4318, 45679];
|
||||
|
||||
try {
|
||||
let thrown: unknown;
|
||||
@@ -775,7 +818,7 @@ describe("qa-otel-smoke receiver bounds", () => {
|
||||
return collectorDir;
|
||||
},
|
||||
randomUUID: () => "00000000-0000-4000-8000-000000000000",
|
||||
reserveLocalPort: async () => 4318,
|
||||
reserveLocalPort: async () => ports.shift() ?? 49999,
|
||||
spawn: vi.fn(() => child) as never,
|
||||
stopDockerContainer: vi.fn(async () => {}),
|
||||
waitForLocalPort: async (_port, _timeout, readFailure) => {
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { ConfigUiHints } from "../../../src/config/schema.js";
|
||||
export const redactSnapshotTestHints: ConfigUiHints = {
|
||||
"agents.defaults.memorySearch.remote.apiKey": { sensitive: true },
|
||||
"agents.list[].memorySearch.remote.apiKey": { sensitive: true },
|
||||
"agents.list[].tools.exec.env.*": { sensitive: true },
|
||||
"broadcast.apiToken[]": { sensitive: true },
|
||||
"env.GROQ_API_KEY": { sensitive: true },
|
||||
"gateway.auth.password": { sensitive: true },
|
||||
|
||||
Reference in New Issue
Block a user