Skip to content

Commit be5b843

Browse files
committed
feat: pass channel context to exec
1 parent 1620d05 commit be5b843

6 files changed

Lines changed: 88 additions & 5 deletions

File tree

docs/tools/exec.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ Notes:
9292
- Host execution (`gateway`/`node`) rejects `env.PATH` and loader overrides (`LD_*`/`DYLD_*`) to
9393
prevent binary hijacking or injected code.
9494
- OpenClaw sets `OPENCLAW_SHELL=exec` in the spawned command environment (including PTY and sandbox execution) so shell/profile rules can detect exec-tool context.
95+
- For channel-origin runs, OpenClaw also exposes channel-owned sender/chat metadata as JSON in
96+
`OPENCLAW_CHANNEL_CONTEXT` when the channel provided that context.
9597
- `openclaw channels login` is blocked from `exec` because it is an interactive channel-auth flow; run it in a terminal on the gateway host, or use the channel-native login tool from chat when one exists.
9698
- Important: sandboxing is **off by default**. If sandboxing is off, implicit `host=auto`
9799
resolves to `gateway`. Explicit `host=sandbox` still fails closed instead of silently

src/agents/agent-tools.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from "../infra/exec-approvals.js";
2525
import { resolveMergedSafeBinProfileFixtures } from "../infra/exec-safe-bin-runtime-policy.js";
2626
import { logWarn } from "../logger.js";
27+
import type { PluginHookChannelContext } from "../plugins/hook-types.js";
2728
import { getPluginToolMeta } from "../plugins/tools.js";
2829
import { createLazyImportLoader } from "../shared/lazy-promise.js";
2930
import type { SkillSnapshot } from "../skills/types.js";
@@ -503,6 +504,8 @@ export function createOpenClawCodingTools(options?: {
503504
currentMessagingTarget?: string;
504505
/** Normalized conversation id exposed to tool hooks. Defaults to currentChannelId. */
505506
hookChannelId?: string;
507+
/** Channel-owned sender/chat metadata exposed to subprocess environments. */
508+
channelContext?: PluginHookChannelContext;
506509
/** Current thread timestamp for auto-threading (Slack). */
507510
currentThreadTs?: string;
508511
/** Current inbound message id for action fallbacks (e.g. Telegram react). */
@@ -870,6 +873,7 @@ export function createOpenClawCodingTools(options?: {
870873
messageProvider: options?.messageProvider,
871874
currentChannelId: options?.currentChannelId,
872875
currentThreadTs: options?.currentThreadTs,
876+
channelContext: options?.channelContext,
873877
accountId: options?.agentAccountId,
874878
approvalReviewerDeviceId: options?.approvalReviewerDeviceId,
875879
backgroundMs: options?.exec?.backgroundMs ?? execConfig.backgroundMs,

src/agents/bash-tools.exec-types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
} from "../infra/exec-approvals.js";
1616
import type { ExecAutoReviewer } from "../infra/exec-auto-review.js";
1717
import type { SafeBinProfileFixture } from "../infra/exec-safe-bin-policy.js";
18+
import type { PluginHookChannelContext } from "../plugins/hook-types.js";
1819
import type { BashSandboxConfig } from "./bash-tools.shared.js";
1920
import type { EmbeddedFullAccessBlockedReason } from "./embedded-agent-runner/types.js";
2021
import type { ExecReviewerConfig } from "./exec-auto-reviewer.js";
@@ -71,6 +72,8 @@ export type ExecToolDefaults = {
7172
messageProvider?: string;
7273
currentChannelId?: string;
7374
currentThreadTs?: string;
75+
/** Channel-owned sender/chat metadata exposed to subprocesses as OPENCLAW_CHANNEL_CONTEXT. */
76+
channelContext?: PluginHookChannelContext;
7477
accountId?: string;
7578
approvalReviewerDeviceId?: string;
7679
notifyOnExit?: boolean;

src/agents/bash-tools.exec.resolve-env-hook.test.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
77
import { OPENCLAW_CLI_ENV_VALUE } from "../infra/openclaw-exec-env.js";
88
import type { ExtensionContext } from "./sessions/index.js";
99

10+
declare module "../plugins/hook-types.js" {
11+
interface PluginHookChannelSenderContext {
12+
unionId?: string;
13+
}
14+
}
15+
16+
const CHANNEL_CONTEXT_ENV_KEY = "OPENCLAW_CHANNEL_CONTEXT";
17+
1018
const mocks = vi.hoisted(() => ({
1119
hookRunner: undefined as
1220
| {
@@ -130,6 +138,31 @@ describe("exec resolve_exec_env hook wiring", () => {
130138
mocks.spawnInputs.length = 0;
131139
});
132140

141+
it("adds channel context env to gateway exec subprocesses", async () => {
142+
const tool = createExecTool({
143+
host: "auto",
144+
security: "full",
145+
ask: "off",
146+
channelContext: {
147+
sender: { id: "ou_1", unionId: "on_1" },
148+
chat: { id: "oc_1" },
149+
},
150+
});
151+
152+
await tool.execute("call-channel-env", {
153+
command: "echo ok",
154+
yieldMs: 120_000,
155+
});
156+
157+
expect(JSON.parse(mocks.gatewayParams[0]?.env[CHANNEL_CONTEXT_ENV_KEY] ?? "{}")).toEqual({
158+
chat: { id: "oc_1" },
159+
sender: { id: "ou_1", unionId: "on_1" },
160+
});
161+
expect(mocks.spawnInputs[0]?.env?.[CHANNEL_CONTEXT_ENV_KEY]).toBe(
162+
mocks.gatewayParams[0]?.env[CHANNEL_CONTEXT_ENV_KEY],
163+
);
164+
});
165+
133166
it("merges filtered plugin env into gateway execution and approval-visible requested env", async () => {
134167
installResolveExecEnvHook({
135168
EXISTING: "plugin",
@@ -147,6 +180,10 @@ describe("exec resolve_exec_env hook wiring", () => {
147180
sessionKey: "agent:main:telegram:chat-1",
148181
messageProvider: "telegram",
149182
currentChannelId: "chat-1",
183+
channelContext: {
184+
sender: { id: "ou_1", unionId: "on_1" },
185+
chat: { id: "oc_1" },
186+
},
150187
});
151188
await tool.execute("call-1", {
152189
command: "echo ok",
@@ -171,10 +208,16 @@ describe("exec resolve_exec_env hook wiring", () => {
171208
},
172209
},
173210
);
174-
expect(mocks.gatewayParams[0]?.requestedEnv).toEqual({
211+
expect(mocks.gatewayParams[0]?.requestedEnv).toMatchObject({
175212
EXISTING: "plugin",
176213
PLUGIN_SAFE: "yes",
177214
});
215+
expect(
216+
JSON.parse(mocks.gatewayParams[0]?.requestedEnv?.[CHANNEL_CONTEXT_ENV_KEY] ?? "{}"),
217+
).toEqual({
218+
chat: { id: "oc_1" },
219+
sender: { id: "ou_1", unionId: "on_1" },
220+
});
178221
expect(mocks.gatewayParams[0]?.env).toMatchObject({
179222
EXISTING: "plugin",
180223
PLUGIN_SAFE: "yes",
@@ -199,20 +242,34 @@ describe("exec resolve_exec_env hook wiring", () => {
199242
security: "full",
200243
ask: "off",
201244
sessionKey: "agent:main:main",
245+
channelContext: {
246+
sender: { id: "ou_node" },
247+
chat: { id: "oc_node" },
248+
},
202249
});
203250
await tool.execute("call-node", {
204251
command: "echo ok",
205252
env: { REQUEST_SAFE: "request" },
206253
});
207254

208-
expect(mocks.nodeHostParams[0]?.requestedEnv).toEqual({
255+
expect(mocks.nodeHostParams[0]?.requestedEnv).toMatchObject({
209256
NODE_HOST_SAFE: "yes",
210257
REQUEST_SAFE: "request",
211258
});
212259
expect(mocks.nodeHostParams[0]?.env).toMatchObject({
213260
NODE_HOST_SAFE: "yes",
214261
REQUEST_SAFE: "request",
215262
});
263+
expect(
264+
JSON.parse(mocks.nodeHostParams[0]?.requestedEnv?.[CHANNEL_CONTEXT_ENV_KEY] ?? "{}"),
265+
).toEqual({
266+
chat: { id: "oc_node" },
267+
sender: { id: "ou_node" },
268+
});
269+
expect(JSON.parse(mocks.nodeHostParams[0]?.env?.[CHANNEL_CONTEXT_ENV_KEY] ?? "{}")).toEqual({
270+
chat: { id: "oc_node" },
271+
sender: { id: "ou_node" },
272+
});
216273
expect(mocks.nodeHostParams[0]?.env).not.toHaveProperty("LD_PRELOAD");
217274
});
218275

src/agents/bash-tools.exec.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
} from "../routing/session-key.js";
5050
import { createLazyImportLoader } from "../shared/lazy-promise.js";
5151
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
52+
import { safeJsonStringify } from "../utils/safe-json.js";
5253
import { splitShellArgs } from "../utils/shell-argv.js";
5354
import type { HookContext } from "./agent-tools.before-tool-call.js";
5455
import { stripMalformedXmlArgValueSuffixFromKeys } from "./agent-tools.params.js";
@@ -106,6 +107,16 @@ type ExecToolArgs = Record<string, unknown> & {
106107
ask?: string;
107108
node?: string;
108109
};
110+
111+
const CHANNEL_CONTEXT_ENV_KEY = "OPENCLAW_CHANNEL_CONTEXT";
112+
113+
function buildChannelContextEnv(channelContext: unknown): Record<string, string> | undefined {
114+
if (!channelContext) {
115+
return undefined;
116+
}
117+
const serialized = safeJsonStringify(channelContext);
118+
return serialized ? { [CHANNEL_CONTEXT_ENV_KEY]: serialized } : undefined;
119+
}
109120
type ResolvedExecEnvPreparedState = {
110121
host?: ExecHost;
111122
pluginEnv?: Record<string, string>;
@@ -1572,9 +1583,13 @@ export function createExecTool(
15721583

15731584
const inheritedBaseEnv = coerceEnv(process.env);
15741585
const resolvedExecEnvState = getResolvedExecEnvPreparedState(params);
1575-
const requestedEnv = resolvedExecEnvState?.pluginEnv
1576-
? { ...params.env, ...resolvedExecEnvState.pluginEnv }
1577-
: params.env;
1586+
const channelContextEnv = buildChannelContextEnv(defaults?.channelContext);
1587+
const requestedEnv: Record<string, string> | undefined =
1588+
params.env !== undefined ||
1589+
resolvedExecEnvState?.pluginEnv !== undefined ||
1590+
channelContextEnv !== undefined
1591+
? { ...params.env, ...resolvedExecEnvState?.pluginEnv, ...channelContextEnv }
1592+
: undefined;
15781593
const hostEnvResult =
15791594
host === "sandbox"
15801595
? null
@@ -1627,6 +1642,7 @@ export function createExecTool(
16271642
containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
16281643
})
16291644
: (hostEnvResult?.env ?? inheritedBaseEnv);
1645+
Object.assign(env, channelContextEnv);
16301646

16311647
if (!sandbox && host === "gateway" && !requestedEnv?.PATH) {
16321648
const shellPath = getShellPathFromLoginShell({

src/agents/embedded-agent-runner/run/attempt.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1268,6 +1268,7 @@ export async function runEmbeddedAttempt(
12681268
memberRoleIds: params.memberRoleIds,
12691269
spawnedBy: params.spawnedBy,
12701270
senderId: params.senderId,
1271+
channelContext: params.channelContext,
12711272
senderName: params.senderName,
12721273
senderUsername: params.senderUsername,
12731274
senderE164: params.senderE164,

0 commit comments

Comments
 (0)