Skip to content

Commit cb38535

Browse files
[plugin sdk] Project session extension slots (#75609)
Merged via squash. Prepared head SHA: d9b670a Co-authored-by: 100yenadmin <239388517+100yenadmin@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman
1 parent e3364ae commit cb38535

27 files changed

Lines changed: 1973 additions & 43 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai
4242
- Providers/OpenRouter: add opt-in response caching params that send OpenRouter's `X-OpenRouter-Cache`, `X-OpenRouter-Cache-TTL`, and cache-clear headers only on verified OpenRouter routes. Thanks @vincentkoc.
4343
- Providers/OpenRouter: expand app-attribution categories so OpenClaw advertises coding, programming, writing, chat, and personal-agent usage on verified OpenRouter routes. Thanks @vincentkoc.
4444
- Plugins/runtime state: add `registerIfAbsent` for atomic keyed-store dedupe claims that return whether a plugin successfully claimed a key without overwriting an existing live value. Thanks @amknight.
45+
- Plugin SDK: add plugin-owned `SessionEntry` slot projection and scoped trusted-policy session extension reads. (#75609; replaces part of #73384/#74483) Thanks @100yenadmin.
4546

4647
### Fixes
4748

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
3c0423e26e758e7a5f5febcbaacd6a7ceb8584a8eecd0224f7ce98e6bcb9e9c0 plugin-sdk-api-baseline.json
2-
952ba44c63a9f2107fc10aead1d0cc77ef06ac9a9befcac3ca9e4b0f4427cdfc plugin-sdk-api-baseline.jsonl
1+
f8495c07213012748f099b12ddb02847ffd4eaa1b46f2ae9dfa574fa0ef3299a plugin-sdk-api-baseline.json
2+
815ac868dda35d0af88b9c522233d6065c3eeb70775e19c111162b80390733fa plugin-sdk-api-baseline.jsonl

extensions/codex/src/app-server/dynamic-tools.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,92 @@ describe("createCodexDynamicToolBridge", () => {
414414
expect(result).toEqual(expectInputText("legacy compacted"));
415415
});
416416

417+
it("keeps config out of Codex tool-result contexts", async () => {
418+
const config = { session: { store: "/tmp/openclaw-session-store.json" } };
419+
const registry = createEmptyPluginRegistry();
420+
const middlewareContexts: Record<string, unknown>[] = [];
421+
const legacyContexts: Record<string, unknown>[] = [];
422+
const middleware = vi.fn(async (_event: unknown, ctx: Record<string, unknown>) => {
423+
middlewareContexts.push(ctx);
424+
return undefined;
425+
});
426+
const factory = async (codex: {
427+
on: (
428+
event: "tool_result",
429+
handler: (
430+
event: unknown,
431+
ctx: Record<string, unknown>,
432+
) => Promise<{ result: AgentToolResult<unknown> } | void>,
433+
) => void;
434+
}) => {
435+
codex.on("tool_result", async (_event, ctx) => {
436+
legacyContexts.push(ctx);
437+
});
438+
};
439+
registry.agentToolResultMiddlewares.push({
440+
pluginId: "tokenjuice",
441+
pluginName: "Tokenjuice",
442+
rawHandler: middleware,
443+
handler: middleware,
444+
runtimes: ["codex"],
445+
source: "test",
446+
});
447+
registry.codexAppServerExtensionFactories.push({
448+
pluginId: "legacy",
449+
pluginName: "Legacy",
450+
rawFactory: factory,
451+
factory,
452+
source: "test",
453+
});
454+
setActivePluginRegistry(registry);
455+
456+
const execute = vi.fn(async () => textToolResult("done"));
457+
const bridge = createCodexDynamicToolBridge({
458+
tools: [createTool({ name: "exec", execute })],
459+
signal: new AbortController().signal,
460+
hookContext: {
461+
agentId: "agent-1",
462+
config: config as never,
463+
sessionId: "session-1",
464+
sessionKey: "agent:agent-1:session-1",
465+
runId: "run-1",
466+
},
467+
});
468+
469+
await bridge.handleToolCall({
470+
threadId: "thread-1",
471+
turnId: "turn-1",
472+
callId: "call-1",
473+
namespace: null,
474+
tool: "exec",
475+
arguments: { command: "pwd" },
476+
});
477+
478+
expect(execute).toHaveBeenCalledWith(
479+
"call-1",
480+
{ command: "pwd" },
481+
expect.any(AbortSignal),
482+
undefined,
483+
);
484+
expect(middlewareContexts).toHaveLength(1);
485+
expect(middlewareContexts[0]).toMatchObject({
486+
runtime: "codex",
487+
agentId: "agent-1",
488+
sessionId: "session-1",
489+
sessionKey: "agent:agent-1:session-1",
490+
runId: "run-1",
491+
});
492+
expect(middlewareContexts[0]).not.toHaveProperty("config");
493+
expect(legacyContexts).toHaveLength(1);
494+
expect(legacyContexts[0]).toMatchObject({
495+
agentId: "agent-1",
496+
sessionId: "session-1",
497+
sessionKey: "agent:agent-1:session-1",
498+
runId: "run-1",
499+
});
500+
expect(legacyContexts[0]).not.toHaveProperty("config");
501+
});
502+
417503
it("fires after_tool_call for successful codex tool executions", async () => {
418504
const afterToolCall = vi.fn();
419505
initializeGlobalHookRunner(

extensions/codex/src/app-server/dynamic-tools.ts

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
extractToolResultMediaArtifact,
77
filterToolResultMediaUrls,
88
HEARTBEAT_RESPONSE_TOOL_NAME,
9+
type EmbeddedRunAttemptParams,
910
isToolWrappedWithBeforeToolCallHook,
1011
isMessagingTool,
1112
isMessagingToolSendAction,
@@ -24,6 +25,16 @@ import {
2425
type JsonValue,
2526
} from "./protocol.js";
2627

28+
type CodexDynamicToolHookContext = {
29+
agentId?: string;
30+
config?: EmbeddedRunAttemptParams["config"];
31+
sessionId?: string;
32+
sessionKey?: string;
33+
runId?: string;
34+
};
35+
36+
type CodexToolResultHookContext = Omit<CodexDynamicToolHookContext, "config">;
37+
2738
export type CodexDynamicToolBridge = {
2839
specs: CodexDynamicToolSpec[];
2940
handleToolCall: (
@@ -45,13 +56,9 @@ export type CodexDynamicToolBridge = {
4556
export function createCodexDynamicToolBridge(params: {
4657
tools: AnyAgentTool[];
4758
signal: AbortSignal;
48-
hookContext?: {
49-
agentId?: string;
50-
sessionId?: string;
51-
sessionKey?: string;
52-
runId?: string;
53-
};
59+
hookContext?: CodexDynamicToolHookContext;
5460
}): CodexDynamicToolBridge {
61+
const toolResultHookContext = toToolResultHookContext(params.hookContext);
5562
const tools = params.tools.map((tool) =>
5663
isToolWrappedWithBeforeToolCallHook(tool)
5764
? tool
@@ -68,11 +75,10 @@ export function createCodexDynamicToolBridge(params: {
6875
};
6976
const middlewareRunner = createAgentToolResultMiddlewareRunner({
7077
runtime: "codex",
71-
...params.hookContext,
78+
...toolResultHookContext,
7279
});
73-
const legacyExtensionRunner = createCodexAppServerToolResultExtensionRunner(
74-
params.hookContext ?? {},
75-
);
80+
const legacyExtensionRunner =
81+
createCodexAppServerToolResultExtensionRunner(toolResultHookContext);
7682

7783
return {
7884
specs: tools.map((tool) => ({
@@ -124,10 +130,10 @@ export function createCodexDynamicToolBridge(params: {
124130
void runAgentHarnessAfterToolCallHook({
125131
toolName: tool.name,
126132
toolCallId: call.callId,
127-
runId: params.hookContext?.runId,
128-
agentId: params.hookContext?.agentId,
129-
sessionId: params.hookContext?.sessionId,
130-
sessionKey: params.hookContext?.sessionKey,
133+
runId: toolResultHookContext.runId,
134+
agentId: toolResultHookContext.agentId,
135+
sessionId: toolResultHookContext.sessionId,
136+
sessionKey: toolResultHookContext.sessionKey,
131137
startArgs: args,
132138
result,
133139
startedAt,
@@ -147,10 +153,10 @@ export function createCodexDynamicToolBridge(params: {
147153
void runAgentHarnessAfterToolCallHook({
148154
toolName: tool.name,
149155
toolCallId: call.callId,
150-
runId: params.hookContext?.runId,
151-
agentId: params.hookContext?.agentId,
152-
sessionId: params.hookContext?.sessionId,
153-
sessionKey: params.hookContext?.sessionKey,
156+
runId: toolResultHookContext.runId,
157+
agentId: toolResultHookContext.agentId,
158+
sessionId: toolResultHookContext.sessionId,
159+
sessionKey: toolResultHookContext.sessionKey,
154160
startArgs: args,
155161
error: error instanceof Error ? error.message : String(error),
156162
startedAt,
@@ -169,6 +175,18 @@ export function createCodexDynamicToolBridge(params: {
169175
};
170176
}
171177

178+
function toToolResultHookContext(
179+
ctx: CodexDynamicToolHookContext | undefined,
180+
): CodexToolResultHookContext {
181+
const { agentId, sessionId, sessionKey, runId } = ctx ?? {};
182+
return {
183+
...(agentId && { agentId }),
184+
...(sessionId && { sessionId }),
185+
...(sessionKey && { sessionKey }),
186+
...(runId && { runId }),
187+
};
188+
}
189+
172190
function composeAbortSignals(...signals: Array<AbortSignal | undefined>): AbortSignal {
173191
const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal));
174192
if (activeSignals.length === 0) {

extensions/codex/src/app-server/run-attempt.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ export async function runCodexAppServerAttempt(
425425
signal: runAbortController.signal,
426426
hookContext: {
427427
agentId: sessionAgentId,
428+
config: params.config,
428429
sessionId: params.sessionId,
429430
sessionKey: sandboxSessionKey,
430431
runId: params.runId,
@@ -535,6 +536,7 @@ export async function runCodexAppServerAttempt(
535536
agentId: sessionAgentId,
536537
sessionId: params.sessionId,
537538
sessionKey: sandboxSessionKey,
539+
config: params.config,
538540
runId: params.runId,
539541
signal: runAbortController.signal,
540542
});
@@ -1376,6 +1378,7 @@ function createCodexNativeHookRelay(params: {
13761378
agentId: string | undefined;
13771379
sessionId: string;
13781380
sessionKey: string | undefined;
1381+
config: EmbeddedRunAttemptParams["config"];
13791382
runId: string;
13801383
signal: AbortSignal;
13811384
}): NativeHookRelayRegistrationHandle | undefined {
@@ -1392,6 +1395,7 @@ function createCodexNativeHookRelay(params: {
13921395
...(params.agentId ? { agentId: params.agentId } : {}),
13931396
sessionId: params.sessionId,
13941397
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
1398+
...(params.config ? { config: params.config } : {}),
13951399
runId: params.runId,
13961400
allowedEvents: params.options?.events ?? CODEX_NATIVE_HOOK_RELAY_EVENTS,
13971401
ttlMs: params.options?.ttlMs,

src/agents/harness/native-hook-relay.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
import { statSync, writeFileSync } from "node:fs";
2+
import fs from "node:fs/promises";
23
import { createServer } from "node:http";
4+
import { tmpdir } from "node:os";
5+
import path from "node:path";
36
import { afterEach, describe, expect, it, vi } from "vitest";
7+
import { updateSessionStore, type SessionEntry } from "../../config/sessions.js";
48
import {
59
initializeGlobalHookRunner,
610
resetGlobalHookRunner,
711
} from "../../plugins/hook-runner-global.js";
812
import { createMockPluginRegistry } from "../../plugins/hooks.test-helpers.js";
13+
import { patchPluginSessionExtension } from "../../plugins/host-hook-state.js";
14+
import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
15+
import { setActivePluginRegistry } from "../../plugins/runtime.js";
916
import {
1017
__testing,
1118
buildNativeHookRelayCommand,
@@ -17,6 +24,7 @@ import {
1724
afterEach(() => {
1825
vi.useRealTimers();
1926
resetGlobalHookRunner();
27+
setActivePluginRegistry(createEmptyPluginRegistry());
2028
__testing.clearNativeHookRelaysForTests();
2129
});
2230

@@ -629,6 +637,95 @@ describe("native hook relay registry", () => {
629637
);
630638
});
631639

640+
it("passes config to trusted policies for native pre-tool session extension reads", async () => {
641+
const stateDir = await fs.mkdtemp(path.join(tmpdir(), "openclaw-native-relay-policy-"));
642+
const storePath = path.join(stateDir, "sessions.json");
643+
const config = { session: { store: storePath } };
644+
const seen: unknown[] = [];
645+
const registry = createEmptyPluginRegistry();
646+
registry.sessionExtensions = [
647+
{
648+
pluginId: "policy-plugin",
649+
pluginName: "Policy Plugin",
650+
source: "test",
651+
extension: {
652+
namespace: "policy",
653+
description: "policy state",
654+
},
655+
},
656+
];
657+
registry.trustedToolPolicies = [
658+
{
659+
pluginId: "policy-plugin",
660+
pluginName: "Policy Plugin",
661+
source: "test",
662+
policy: {
663+
id: "session-extension-policy",
664+
description: "session extension policy",
665+
evaluate(_event, ctx) {
666+
const policyState = ctx.getSessionExtension?.("policy");
667+
seen.push(policyState);
668+
if ((policyState as { block?: boolean } | undefined)?.block) {
669+
return { block: true, blockReason: "blocked by session extension" };
670+
}
671+
return undefined;
672+
},
673+
},
674+
},
675+
];
676+
setActivePluginRegistry(registry);
677+
try {
678+
await updateSessionStore(storePath, (store) => {
679+
store["agent:main:session-1"] = {
680+
sessionId: "session-1",
681+
updatedAt: Date.now(),
682+
} as SessionEntry;
683+
});
684+
await expect(
685+
patchPluginSessionExtension({
686+
cfg: config as never,
687+
sessionKey: "agent:main:session-1",
688+
pluginId: "policy-plugin",
689+
namespace: "policy",
690+
value: { block: true },
691+
}),
692+
).resolves.toMatchObject({ ok: true });
693+
694+
const relay = registerNativeHookRelay({
695+
provider: "codex",
696+
agentId: "agent-1",
697+
sessionId: "session-1",
698+
sessionKey: "agent:main:session-1",
699+
config: config as never,
700+
runId: "run-1",
701+
allowedEvents: ["pre_tool_use"],
702+
});
703+
704+
const response = await invokeNativeHookRelay({
705+
provider: "codex",
706+
relayId: relay.relayId,
707+
event: "pre_tool_use",
708+
rawPayload: {
709+
hook_event_name: "PreToolUse",
710+
tool_name: "Bash",
711+
tool_use_id: "native-policy-call-1",
712+
tool_input: { command: "rm -rf dist" },
713+
},
714+
});
715+
716+
expect(JSON.parse(response.stdout)).toEqual({
717+
hookSpecificOutput: {
718+
hookEventName: "PreToolUse",
719+
permissionDecision: "deny",
720+
permissionDecisionReason: "blocked by session extension",
721+
},
722+
});
723+
expect(seen).toEqual([{ block: true }]);
724+
} finally {
725+
await fs.rm(stateDir, { recursive: true, force: true });
726+
}
727+
});
728+
632729
it("does not rewrite Codex native tool input when before_tool_call adjusts params", async () => {
633730
const beforeToolCall = vi.fn(async () => ({
634731
params: { command: "echo replaced" },

0 commit comments

Comments
 (0)