Skip to content

Commit bd47995

Browse files
feat(plugin-sdk): add extensible channel identity hook context (#91903)
Merged via squash. Prepared head SHA: 90f51ea Co-authored-by: lanzhi-lee <[email protected]> Co-authored-by: vincentkoc <[email protected]> Reviewed-by: @vincentkoc
1 parent 4460fa7 commit bd47995

44 files changed

Lines changed: 597 additions & 33 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
8d38dc64627e6bfcebc25215d499a30841953799133dea16ffce902cf301273f plugin-sdk-api-baseline.json
2-
d7927d51588fd006d743fc56cc22d779141b487f82655d88054d2be12f3093ff plugin-sdk-api-baseline.jsonl
1+
da3373338b7f9c5f5639ad8233a32897d2346a0babe69a77386a7bff154cdcb1 plugin-sdk-api-baseline.json
2+
17404d885e0d64ebc8e3c99443921058a8f1aebf76a5e612eb1f0cd7817d48f0 plugin-sdk-api-baseline.jsonl

docs/plugins/hooks.md

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -319,10 +319,56 @@ Cron-driven runs also expose `ctx.jobId` (the originating cron job id) so
319319
plugin hooks can scope metrics, side effects, or state to a specific scheduled
320320
job.
321321

322-
For channel-originated runs, `ctx.messageProvider` is the provider surface such
323-
as `discord` or `telegram`, while `ctx.channelId` is the conversation target
324-
identifier when OpenClaw can derive one from the session key or delivery
325-
metadata.
322+
For channel-originated runs, `ctx.channel` and `ctx.messageProvider` identify
323+
the provider surface such as `discord` or `telegram`, while `ctx.channelId` is
324+
the conversation target identifier when OpenClaw can derive one from the session
325+
key or delivery metadata.
326+
327+
When sender identity is available, agent hook contexts also include:
328+
329+
- `ctx.senderId` — channel-scoped sender ID (e.g. Feishu `open_id`, Discord
330+
user ID). Populated when the run originates from a user message with known
331+
sender metadata.
332+
- `ctx.chatId` — transport-native conversation identifier (e.g. Feishu
333+
`chat_id`, Telegram `chat_id`). Populated when the originating channel
334+
provides a native conversation ID.
335+
- `ctx.channelContext.sender.id` — the same sender ID as `ctx.senderId`, under a
336+
channel-owned object that plugins can extend with channel-specific fields.
337+
- `ctx.channelContext.chat.id` — the same conversation ID as `ctx.chatId`, under a
338+
channel-owned object that plugins can extend with channel-specific fields.
339+
340+
Core only defines the nested `id` fields. Channel plugins that pass richer
341+
sender or chat metadata through the inbound helper can augment
342+
`PluginHookChannelSenderContext` or `PluginHookChannelChatContext` from
343+
`openclaw/plugin-sdk/channel-inbound`:
344+
345+
```ts
346+
declare module "openclaw/plugin-sdk/channel-inbound" {
347+
interface PluginHookChannelSenderContext {
348+
unionId?: string;
349+
userId?: string;
350+
}
351+
}
352+
```
353+
354+
Channel plugins pass those fields through the inbound SDK helper:
355+
356+
```ts
357+
buildChannelInboundEventContext({
358+
// ...
359+
channelContext: {
360+
sender: { id: senderOpenId, unionId, userId },
361+
chat: { id: chatId },
362+
},
363+
});
364+
```
365+
366+
These fields are optional and absent for system-originated runs (heartbeat,
367+
cron, exec-event).
368+
369+
`ctx.senderExternalId` remains as a deprecated source-compatibility field for
370+
older plugins. Core does not populate it; new channel-specific sender identities
371+
should live under `ctx.channelContext.sender` through module augmentation.
326372

327373
`agent_end` is an observation hook. Gateway and persistent harness paths run it
328374
fire-and-forget after the turn, while short-lived one-shot CLI paths wait for the

docs/plugins/sdk-channel-inbound.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ import {
2929
```
3030

3131
- `buildChannelInboundEventContext(...)`: project normalized channel facts into
32-
the prompt/session context.
32+
the prompt/session context. Use `channelContext` to pass channel-owned
33+
sender/chat metadata through to plugin hook `ctx.channelContext`; augment
34+
`PluginHookChannelSenderContext` or `PluginHookChannelChatContext` from this
35+
subpath for channel-specific fields.
3336
- `runChannelInboundEvent(...)`: run ingest, classify, preflight, resolve,
3437
record, dispatch, and finalize for one inbound platform event.
3538
- `dispatchChannelInboundReply(...)`: record and dispatch an already assembled

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 a narrow sender/chat identity JSON payload in
96+
`OPENCLAW_CHANNEL_CONTEXT` when the channel provided those ids.
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

scripts/lib/plain-gh.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { execFileSync, spawnSync } from "node:child_process";
22
import fs from "node:fs";
33
import path from "node:path";
44

5+
export const PLAIN_GH_MAX_BUFFER_BYTES = 32 * 1024 * 1024;
6+
57
function isExecutable(filePath) {
68
try {
79
fs.accessSync(filePath, fs.constants.X_OK);
@@ -71,6 +73,7 @@ export function execPlainGh(args, options = {}) {
7173
return execFileSync(ghBin, args, {
7274
...options,
7375
env,
76+
maxBuffer: options.maxBuffer ?? PLAIN_GH_MAX_BUFFER_BYTES,
7477
});
7578
}
7679

@@ -80,5 +83,6 @@ export function spawnPlainGh(args, options = {}) {
8083
return spawnSync(ghBin, args, {
8184
...options,
8285
env,
86+
maxBuffer: options.maxBuffer ?? PLAIN_GH_MAX_BUFFER_BYTES,
8387
});
8488
}

scripts/plugin-sdk-surface-report.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ let publicDeprecatedExportsByEntrypointBudget;
163163
try {
164164
budgets = {
165165
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 321),
166-
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10337),
166+
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10349),
167167
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5190),
168168
publicDeprecatedExports: readBudgetEnv(
169169
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",

src/agents/agent-tools.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { resolveEventSessionRoutingPolicy } from "../infra/event-session-routing
1919
import { applyExecPolicyLayer } from "../infra/exec-policy.js";
2020
import { resolveMergedSafeBinProfileFixtures } from "../infra/exec-safe-bin-runtime-policy.js";
2121
import { logWarn } from "../logger.js";
22+
import type { PluginHookChannelContext } from "../plugins/hook-types.js";
2223
import { getPluginToolMeta } from "../plugins/tools.js";
2324
import { createLazyImportLoader } from "../shared/lazy-promise.js";
2425
import type { SkillSnapshot } from "../skills/types.js";
@@ -469,6 +470,8 @@ export function createOpenClawCodingTools(options?: {
469470
currentMessagingTarget?: string;
470471
/** Normalized conversation id exposed to tool hooks. Defaults to currentChannelId. */
471472
hookChannelId?: string;
473+
/** Channel-owned sender/chat metadata exposed to subprocess environments. */
474+
channelContext?: PluginHookChannelContext;
472475
/** Current thread timestamp for auto-threading (Slack). */
473476
currentThreadTs?: string;
474477
/** Current inbound message id for action fallbacks (e.g. Telegram react). */
@@ -836,6 +839,7 @@ export function createOpenClawCodingTools(options?: {
836839
messageProvider: options?.messageProvider,
837840
currentChannelId: options?.currentChannelId,
838841
currentThreadTs: options?.currentThreadTs,
842+
channelContext: options?.channelContext,
839843
accountId: options?.agentAccountId,
840844
approvalReviewerDeviceId: options?.approvalReviewerDeviceId,
841845
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. Exec subprocesses receive only sender/chat IDs. */
76+
channelContext?: PluginHookChannelContext;
7477
accountId?: string;
7578
approvalReviewerDeviceId?: string;
7679
notifyOnExit?: boolean;

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

Lines changed: 63 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
| {
@@ -129,6 +137,31 @@ describe("exec resolve_exec_env hook wiring", () => {
129137
mocks.spawnInputs.length = 0;
130138
});
131139

140+
it("adds only channel identity env to gateway exec subprocesses", async () => {
141+
const tool = createExecTool({
142+
host: "auto",
143+
security: "full",
144+
ask: "off",
145+
channelContext: {
146+
sender: { id: "ou_1", unionId: "on_1" },
147+
chat: { id: "oc_1" },
148+
},
149+
});
150+
151+
await tool.execute("call-channel-env", {
152+
command: "echo ok",
153+
yieldMs: 120_000,
154+
});
155+
156+
expect(JSON.parse(mocks.gatewayParams[0]?.env[CHANNEL_CONTEXT_ENV_KEY] ?? "{}")).toEqual({
157+
chat: { id: "oc_1" },
158+
sender: { id: "ou_1" },
159+
});
160+
expect(mocks.spawnInputs[0]?.env?.[CHANNEL_CONTEXT_ENV_KEY]).toBe(
161+
mocks.gatewayParams[0]?.env[CHANNEL_CONTEXT_ENV_KEY],
162+
);
163+
});
164+
132165
it("merges filtered plugin env into gateway execution and approval-visible requested env", async () => {
133166
installResolveExecEnvHook({
134167
EXISTING: "plugin",
@@ -146,6 +179,10 @@ describe("exec resolve_exec_env hook wiring", () => {
146179
sessionKey: "agent:main:telegram:chat-1",
147180
messageProvider: "telegram",
148181
currentChannelId: "chat-1",
182+
channelContext: {
183+
sender: { id: "ou_1", unionId: "on_1" },
184+
chat: { id: "oc_1" },
185+
},
149186
});
150187
await tool.execute("call-1", {
151188
command: "echo ok",
@@ -164,12 +201,22 @@ describe("exec resolve_exec_env hook wiring", () => {
164201
sessionKey: "agent:main:telegram:chat-1",
165202
messageProvider: "telegram",
166203
channelId: "chat-1",
204+
channelContext: {
205+
sender: { id: "ou_1", unionId: "on_1" },
206+
chat: { id: "oc_1" },
207+
},
167208
},
168209
);
169-
expect(mocks.gatewayParams[0]?.requestedEnv).toEqual({
210+
expect(mocks.gatewayParams[0]?.requestedEnv).toMatchObject({
170211
EXISTING: "plugin",
171212
PLUGIN_SAFE: "yes",
172213
});
214+
expect(
215+
JSON.parse(mocks.gatewayParams[0]?.requestedEnv?.[CHANNEL_CONTEXT_ENV_KEY] ?? "{}"),
216+
).toEqual({
217+
chat: { id: "oc_1" },
218+
sender: { id: "ou_1" },
219+
});
173220
expect(mocks.gatewayParams[0]?.env).toMatchObject({
174221
EXISTING: "plugin",
175222
PLUGIN_SAFE: "yes",
@@ -194,20 +241,34 @@ describe("exec resolve_exec_env hook wiring", () => {
194241
security: "full",
195242
ask: "off",
196243
sessionKey: "agent:main:main",
244+
channelContext: {
245+
sender: { id: "ou_node" },
246+
chat: { id: "oc_node" },
247+
},
197248
});
198249
await tool.execute("call-node", {
199250
command: "echo ok",
200251
env: { REQUEST_SAFE: "request" },
201252
});
202253

203-
expect(mocks.nodeHostParams[0]?.requestedEnv).toEqual({
254+
expect(mocks.nodeHostParams[0]?.requestedEnv).toMatchObject({
204255
NODE_HOST_SAFE: "yes",
205256
REQUEST_SAFE: "request",
206257
});
207258
expect(mocks.nodeHostParams[0]?.env).toMatchObject({
208259
NODE_HOST_SAFE: "yes",
209260
REQUEST_SAFE: "request",
210261
});
262+
expect(
263+
JSON.parse(mocks.nodeHostParams[0]?.requestedEnv?.[CHANNEL_CONTEXT_ENV_KEY] ?? "{}"),
264+
).toEqual({
265+
chat: { id: "oc_node" },
266+
sender: { id: "ou_node" },
267+
});
268+
expect(JSON.parse(mocks.nodeHostParams[0]?.env?.[CHANNEL_CONTEXT_ENV_KEY] ?? "{}")).toEqual({
269+
chat: { id: "oc_node" },
270+
sender: { id: "ou_node" },
271+
});
211272
expect(mocks.nodeHostParams[0]?.env).not.toHaveProperty("LD_PRELOAD");
212273
});
213274

src/agents/bash-tools.exec.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,15 @@ import {
4242
} from "../infra/shell-env.js";
4343
import { logInfo } from "../logger.js";
4444
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
45+
import type { PluginHookChannelContext } from "../plugins/hook-types.js";
4546
import {
4647
normalizeAgentId,
4748
parseAgentSessionKey,
4849
resolveAgentIdFromSessionKey,
4950
} from "../routing/session-key.js";
5051
import { createLazyImportLoader } from "../shared/lazy-promise.js";
5152
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
53+
import { safeJsonStringify } from "../utils/safe-json.js";
5254
import { splitShellArgs } from "../utils/shell-argv.js";
5355
import type { HookContext } from "./agent-tools.before-tool-call.js";
5456
import { stripMalformedXmlArgValueSuffixFromKeys } from "./agent-tools.params.js";
@@ -106,6 +108,31 @@ type ExecToolArgs = Record<string, unknown> & {
106108
ask?: string;
107109
node?: string;
108110
};
111+
112+
const CHANNEL_CONTEXT_ENV_KEY = "OPENCLAW_CHANNEL_CONTEXT";
113+
114+
function buildSubprocessChannelContext(
115+
channelContext: PluginHookChannelContext | undefined,
116+
): PluginHookChannelContext | undefined {
117+
const senderId = normalizeOptionalString(channelContext?.sender?.id);
118+
const chatId = normalizeOptionalString(channelContext?.chat?.id);
119+
const subprocessContext: PluginHookChannelContext = {
120+
...(senderId ? { sender: { id: senderId } } : {}),
121+
...(chatId ? { chat: { id: chatId } } : {}),
122+
};
123+
return subprocessContext.sender || subprocessContext.chat ? subprocessContext : undefined;
124+
}
125+
126+
function buildChannelContextEnv(
127+
channelContext: PluginHookChannelContext | undefined,
128+
): Record<string, string> | undefined {
129+
const subprocessContext = buildSubprocessChannelContext(channelContext);
130+
if (!subprocessContext) {
131+
return undefined;
132+
}
133+
const serialized = safeJsonStringify(subprocessContext);
134+
return serialized ? { [CHANNEL_CONTEXT_ENV_KEY]: serialized } : undefined;
135+
}
109136
type ResolvedExecEnvPreparedState = {
110137
host?: ExecHost;
111138
pluginEnv?: Record<string, string>;
@@ -1336,6 +1363,7 @@ export function createExecTool(
13361363
sessionKey: defaults?.sessionKey ?? context?.hookContext?.sessionKey,
13371364
messageProvider: defaults?.messageProvider,
13381365
channelId: defaults?.currentChannelId ?? context?.hookContext?.channelId,
1366+
...(defaults?.channelContext ? { channelContext: defaults.channelContext } : {}),
13391367
},
13401368
);
13411369
const pluginEnv = filterPluginExecEnv(rawPluginEnv);
@@ -1571,9 +1599,13 @@ export function createExecTool(
15711599

15721600
const inheritedBaseEnv = coerceEnv(process.env);
15731601
const resolvedExecEnvState = getResolvedExecEnvPreparedState(params);
1574-
const requestedEnv = resolvedExecEnvState?.pluginEnv
1575-
? { ...params.env, ...resolvedExecEnvState.pluginEnv }
1576-
: params.env;
1602+
const channelContextEnv = buildChannelContextEnv(defaults?.channelContext);
1603+
const requestedEnv: Record<string, string> | undefined =
1604+
params.env !== undefined ||
1605+
resolvedExecEnvState?.pluginEnv !== undefined ||
1606+
channelContextEnv !== undefined
1607+
? { ...params.env, ...resolvedExecEnvState?.pluginEnv, ...channelContextEnv }
1608+
: undefined;
15771609
const hostEnvResult =
15781610
host === "sandbox"
15791611
? null

0 commit comments

Comments
 (0)