惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

人人都是产品经理
人人都是产品经理
Apple Machine Learning Research
Apple Machine Learning Research
云风的 BLOG
云风的 BLOG
罗磊的独立博客
博客园 - 三生石上(FineUI控件)
量子位
GbyAI
GbyAI
腾讯CDC
T
Tailwind CSS Blog
博客园 - Franky
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
D
Docker
G
Google Developers Blog
aimingoo的专栏
aimingoo的专栏
The GitHub Blog
The GitHub Blog
Microsoft Security Blog
Microsoft Security Blog
Stack Overflow Blog
Stack Overflow Blog
Hugging Face - Blog
Hugging Face - Blog
小众软件
小众软件
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
N
Netflix TechBlog - Medium
Jina AI
Jina AI
IT之家
IT之家
Y
Y Combinator Blog

Recent Commits to openclaw:main

test: merge chat side-result checks · openclaw/openclaw@ddd2c2a test: merge cron history checks · openclaw/openclaw@f7eb746 test: merge responsive navigation shell checks · openclaw/openclaw@c2e4b47 docs(changelog): add codex oauth fixes · openclaw/openclaw@628e6cd test: merge navigation routing cases · openclaw/openclaw@5d8cecb Tests: mock channel registry bundled fallback · openclaw/openclaw@2b08233 Secrets: avoid broad web search discovery for single plugin config · openclaw/openclaw@a464f59 test: merge config view browser checks · openclaw/openclaw@20cf511 fix(status): align oauth health with runtime · openclaw/openclaw@eed7116 feat: add macOS screen snapshots for monitor preview (#67954) thanks … · openclaw/openclaw@f377db1 fix: report shared auth scopes in hello-ok (#67810) thanks @BunsDev · openclaw/openclaw@0b6c39b Auto-reply: avoid eager bundled route fallback · openclaw/openclaw@3ea1bf4 Tests: narrow session binding contract setup · openclaw/openclaw@54e4e16 fix(macOS): enable undo/redo in webchat composer text input (#34962) · openclaw/openclaw@00951dc Tests: speed up channel setup promotion · openclaw/openclaw@82b529a Docs: refresh agent instructions · openclaw/openclaw@5775fe2 fix(auth): serialize OAuth refresh across agents to fix #26322 (#67876) · openclaw/openclaw@8e79080 test: allow ollama public surface boundary test · openclaw/openclaw@7d4f1a6 Docs: add test performance guardrails · openclaw/openclaw@89706d3 Tests: restore context-engine usage proof · openclaw/openclaw@e4c4f95 Tests: slim context engine runtime coverage · openclaw/openclaw@74c198f ci: retry failed custom checkouts · openclaw/openclaw@0ee5baf test: trim duplicate provider auth onboarding cases · openclaw/openclaw@1ffc02e matrix: fix sessions_spawn --thread subagent session spawning (#67643) · openclaw/openclaw@1ce2596 test: reduce auth choice fixture churn · openclaw/openclaw@857b9cd test: mock health status config boundaries · openclaw/openclaw@9d5ab4a test: mock onboard config io boundary · openclaw/openclaw@299694d test: mock legacy state plugin boundaries · openclaw/openclaw@2713089 test: mock channel install boundaries · openclaw/openclaw@b945248 test: mock doctor preview channel boundaries · openclaw/openclaw@b1a3ad4
fix(hooks): harden cli transcript loading (#70786) · open...
vincentkoc · 2026-04-24 · via Recent Commits to openclaw:main
Original file line numberDiff line numberDiff line change

@@ -18,6 +18,7 @@ import {

1818

} from "./cli-runner.test-support.js";

1919

import { executePreparedCliRun } from "./cli-runner/execute.js";

2020

import { resolveCliNoOutputTimeoutMs } from "./cli-runner/helpers.js";

21+

import * as sessionHistoryModule from "./cli-runner/session-history.js";

2122

import { MAX_CLI_SESSION_HISTORY_MESSAGES } from "./cli-runner/session-history.js";

2223

import type { PreparedCliRunContext } from "./cli-runner/types.js";

2324

@@ -510,6 +511,34 @@ describe("runCliAgent reliability", () => {

510511

}

511512

});

512513
514+

it("does not emit llm_output when the CLI run returns no assistant text", async () => {

515+

const hookRunner = {

516+

hasHooks: vi.fn((hookName: string) => hookName === "llm_output"),

517+

runLlmInput: vi.fn(async () => undefined),

518+

runLlmOutput: vi.fn(async () => undefined),

519+

runAgentEnd: vi.fn(async () => undefined),

520+

};

521+

mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);

522+
523+

supervisorSpawnMock.mockResolvedValueOnce(

524+

createManagedRun({

525+

reason: "exit",

526+

exitCode: 0,

527+

exitSignal: null,

528+

durationMs: 50,

529+

stdout: " ",

530+

stderr: "",

531+

timedOut: false,

532+

noOutputTimedOut: false,

533+

}),

534+

);

535+
536+

const result = await runPreparedCliAgent(buildPreparedContext());

537+
538+

expect(result.payloads).toBeUndefined();

539+

expect(hookRunner.runLlmOutput).not.toHaveBeenCalled();

540+

});

541+
513542

it("emits agent_end with failure details when the CLI run fails", async () => {

514543

const hookRunner = {

515544

hasHooks: vi.fn((hookName: string) => ["llm_input", "agent_end"].includes(hookName)),

@@ -634,6 +663,41 @@ describe("runCliAgent reliability", () => {

634663

fs.rmSync(dir, { recursive: true, force: true });

635664

}

636665

});

666+
667+

it("skips transcript loading when only llm_output hooks are active", async () => {

668+

const hookRunner = {

669+

hasHooks: vi.fn((hookName: string) => hookName === "llm_output"),

670+

runLlmInput: vi.fn(async () => undefined),

671+

runLlmOutput: vi.fn(async () => undefined),

672+

runAgentEnd: vi.fn(async () => undefined),

673+

};

674+

mockGetGlobalHookRunner.mockReturnValue(hookRunner as never);

675+

const historySpy = vi.spyOn(sessionHistoryModule, "loadCliSessionHistoryMessages");

676+
677+

supervisorSpawnMock.mockResolvedValueOnce(

678+

createManagedRun({

679+

reason: "exit",

680+

exitCode: 0,

681+

exitSignal: null,

682+

durationMs: 50,

683+

stdout: "hello from cli",

684+

stderr: "",

685+

timedOut: false,

686+

noOutputTimedOut: false,

687+

}),

688+

);

689+
690+

try {

691+

await runPreparedCliAgent(buildPreparedContext());

692+
693+

expect(historySpy).not.toHaveBeenCalled();

694+

await vi.waitFor(() => {

695+

expect(hookRunner.runLlmOutput).toHaveBeenCalledTimes(1);

696+

});

697+

} finally {

698+

historySpy.mockRestore();

699+

}

700+

});

637701

});

638702
639703

describe("resolveCliNoOutputTimeoutMs", () => {

Original file line numberDiff line numberDiff line change

@@ -1,7 +1,9 @@

11

import { formatErrorMessage } from "../infra/errors.js";

2+

import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";

23

import { loadCliSessionHistoryMessages } from "./cli-runner/session-history.js";

34

import type { PreparedCliRunContext, RunCliAgentParams } from "./cli-runner/types.js";

45

import { FailoverError, isFailoverError, resolveFailoverStatus } from "./failover-error.js";

6+

import { buildAgentHookConversationMessages } from "./harness/hook-history.js";

57

import {

68

runAgentHarnessAgentEndHook,

79

runAgentHarnessLlmInputHook,

@@ -53,13 +55,20 @@ export async function runPreparedCliAgent(

5355

): Promise<EmbeddedPiRunResult> {

5456

const { executePreparedCliRun } = await import("./cli-runner/execute.runtime.js");

5557

const { params } = context;

56-

const historyMessages = loadCliSessionHistoryMessages({

57-

sessionId: params.sessionId,

58-

sessionFile: params.sessionFile,

59-

sessionKey: params.sessionKey,

60-

agentId: params.agentId,

61-

config: params.config,

62-

});

58+

const hookRunner = getGlobalHookRunner();

59+

const hasLlmInputHooks = hookRunner?.hasHooks("llm_input") === true;

60+

const hasLlmOutputHooks = hookRunner?.hasHooks("llm_output") === true;

61+

const hasAgentEndHooks = hookRunner?.hasHooks("agent_end") === true;

62+

const historyMessages =

63+

hasLlmInputHooks || hasAgentEndHooks

64+

? loadCliSessionHistoryMessages({

65+

sessionId: params.sessionId,

66+

sessionFile: params.sessionFile,

67+

sessionKey: params.sessionKey,

68+

agentId: params.agentId,

69+

config: params.config,

70+

})

71+

: [];

6372

const llmInputEvent = {

6473

runId: params.runId,

6574

sessionId: params.sessionId,

@@ -82,9 +91,13 @@ export async function runPreparedCliAgent(

8291

} as const;

8392
8493

const buildAgentEndMessages = (lastAssistant?: unknown): unknown[] => [

85-

...historyMessages,

86-

buildCliHookUserMessage(params.prompt),

87-

...(lastAssistant ? [lastAssistant] : []),

94+

...buildAgentHookConversationMessages({

95+

historyMessages,

96+

currentTurnMessages: [

97+

buildCliHookUserMessage(params.prompt),

98+

...(lastAssistant ? [lastAssistant] : []),

99+

],

100+

}),

88101

];

89102
90103

const buildFailedAgentEndEvent = (error: string) => ({

@@ -125,18 +138,20 @@ export async function runPreparedCliAgent(

125138

usage: output.usage,

126139

})

127140

: undefined;

128-

runAgentHarnessLlmOutputHook({

129-

event: {

130-

runId: params.runId,

131-

sessionId: params.sessionId,

132-

provider: params.provider,

133-

model: context.modelId,

134-

assistantTexts,

135-

...(lastAssistant ? { lastAssistant } : {}),

136-

...(output.usage ? { usage: output.usage } : {}),

137-

},

138-

ctx: hookContext,

139-

});

141+

if (assistantText.length > 0 && hasLlmOutputHooks) {

142+

runAgentHarnessLlmOutputHook({

143+

event: {

144+

runId: params.runId,

145+

sessionId: params.sessionId,

146+

provider: params.provider,

147+

model: context.modelId,

148+

assistantTexts,

149+

...(lastAssistant ? { lastAssistant } : {}),

150+

...(output.usage ? { usage: output.usage } : {}),

151+

},

152+

ctx: hookContext,

153+

});

154+

}

140155

return { output, assistantText, lastAssistant };

141156

};

142157
Original file line numberDiff line numberDiff line change

@@ -123,6 +123,41 @@ describe("loadCliSessionHistoryMessages", () => {

123123

}

124124

});

125125
126+

it("rejects symlinked transcripts instead of following them outside the sessions directory", () => {

127+

const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-"));

128+

const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-outside-"));

129+

vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);

130+

const canonicalSessionFile = path.join(

131+

stateDir,

132+

"agents",

133+

"main",

134+

"sessions",

135+

"session-symlink.jsonl",

136+

);

137+

const outsideFile = createSessionTranscript({

138+

rootDir: outsideDir,

139+

sessionId: "session-symlink",

140+

filePath: path.join(outsideDir, "outside.jsonl"),

141+

messages: ["stolen history"],

142+

});

143+

fs.mkdirSync(path.dirname(canonicalSessionFile), { recursive: true });

144+

fs.symlinkSync(outsideFile, canonicalSessionFile);

145+
146+

try {

147+

expect(

148+

loadCliSessionHistoryMessages({

149+

sessionId: "session-symlink",

150+

sessionFile: canonicalSessionFile,

151+

sessionKey: "agent:main:main",

152+

agentId: "main",

153+

}),

154+

).toEqual([]);

155+

} finally {

156+

fs.rmSync(stateDir, { recursive: true, force: true });

157+

fs.rmSync(outsideDir, { recursive: true, force: true });

158+

}

159+

});

160+
126161

it("drops oversized transcript files instead of loading them into hook payloads", () => {

127162

const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-"));

128163

vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);

@@ -149,4 +184,37 @@ describe("loadCliSessionHistoryMessages", () => {

149184

fs.rmSync(stateDir, { recursive: true, force: true });

150185

}

151186

});

187+
188+

it("honors custom session store roots when resolving hook history transcripts", () => {

189+

const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-"));

190+

const customStoreDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-store-"));

191+

vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);

192+

const storePath = path.join(customStoreDir, "sessions.json");

193+

fs.writeFileSync(storePath, "{}", "utf-8");

194+

const sessionFile = createSessionTranscript({

195+

rootDir: customStoreDir,

196+

sessionId: "session-custom-store",

197+

filePath: path.join(customStoreDir, "session-custom-store.jsonl"),

198+

messages: ["custom store history"],

199+

});

200+
201+

try {

202+

expect(

203+

loadCliSessionHistoryMessages({

204+

sessionId: "session-custom-store",

205+

sessionFile,

206+

sessionKey: "agent:main:main",

207+

agentId: "main",

208+

config: {

209+

session: {

210+

store: storePath,

211+

},

212+

},

213+

}),

214+

).toMatchObject([{ role: "user", content: "custom store history" }]);

215+

} finally {

216+

fs.rmSync(stateDir, { recursive: true, force: true });

217+

fs.rmSync(customStoreDir, { recursive: true, force: true });

218+

}

219+

});

152220

});

Original file line numberDiff line numberDiff line change

@@ -1,34 +1,58 @@

11

import fs from "node:fs";

2+

import path from "node:path";

23

import { SessionManager } from "@mariozechner/pi-coding-agent";

34

import {

45

resolveSessionFilePath,

56

resolveSessionFilePathOptions,

67

} from "../../config/sessions/paths.js";

78

import type { OpenClawConfig } from "../../config/types.openclaw.js";

89

import { resolveSessionAgentIds } from "../agent-scope.js";

10+

import {

11+

limitAgentHookHistoryMessages,

12+

MAX_AGENT_HOOK_HISTORY_MESSAGES,

13+

} from "../harness/hook-history.js";

914
1015

export const MAX_CLI_SESSION_HISTORY_FILE_BYTES = 5 * 1024 * 1024;

11-

export const MAX_CLI_SESSION_HISTORY_MESSAGES = 200;

16+

export const MAX_CLI_SESSION_HISTORY_MESSAGES = MAX_AGENT_HOOK_HISTORY_MESSAGES;

17+
18+

function safeRealpathSync(filePath: string): string | undefined {

19+

try {

20+

return fs.realpathSync(filePath);

21+

} catch {

22+

return undefined;

23+

}

24+

}

25+
26+

function isPathWithinBase(basePath: string, targetPath: string): boolean {

27+

const relative = path.relative(basePath, targetPath);

28+

return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);

29+

}

1230
1331

function resolveSafeCliSessionFile(params: {

1432

sessionId: string;

1533

sessionFile: string;

1634

sessionKey?: string;

1735

agentId?: string;

1836

config?: OpenClawConfig;

19-

}): string {

37+

}): { sessionFile: string; sessionsDir: string } {

2038

const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({

2139

sessionKey: params.sessionKey,

2240

config: params.config,

2341

agentId: params.agentId,

2442

});

25-

return resolveSessionFilePath(

43+

const pathOptions = resolveSessionFilePathOptions({

44+

agentId: sessionAgentId ?? defaultAgentId,

45+

storePath: params.config?.session?.store,

46+

});

47+

const sessionFile = resolveSessionFilePath(

2648

params.sessionId,

2749

{ sessionFile: params.sessionFile },

28-

resolveSessionFilePathOptions({

29-

agentId: sessionAgentId ?? defaultAgentId,

30-

}),

50+

pathOptions,

3151

);

52+

return {

53+

sessionFile,

54+

sessionsDir: pathOptions?.sessionsDir ?? path.dirname(sessionFile),

55+

};

3256

}

3357
3458

export function loadCliSessionHistoryMessages(params: {

@@ -39,28 +63,25 @@ export function loadCliSessionHistoryMessages(params: {

3963

config?: OpenClawConfig;

4064

}): unknown[] {

4165

try {

42-

const sessionFile = resolveSafeCliSessionFile(params);

43-

if (!fs.existsSync(sessionFile)) {

66+

const { sessionFile, sessionsDir } = resolveSafeCliSessionFile(params);

67+

const entryStat = fs.lstatSync(sessionFile);

68+

if (!entryStat.isFile() || entryStat.isSymbolicLink()) {

4469

return [];

4570

}

46-

const stat = fs.statSync(sessionFile);

47-

if (!stat.isFile() || stat.size > MAX_CLI_SESSION_HISTORY_FILE_BYTES) {

71+

const realSessionsDir = safeRealpathSync(sessionsDir) ?? path.resolve(sessionsDir);

72+

const realSessionFile = safeRealpathSync(sessionFile);

73+

if (!realSessionFile || !isPathWithinBase(realSessionsDir, realSessionFile)) {

4874

return [];

4975

}

50-

const entries = SessionManager.open(sessionFile).getEntries();

51-

const history: unknown[] = [];

52-

for (let index = entries.length - 1; index >= 0; index -= 1) {

53-

const entry = entries[index];

54-

if (entry?.type !== "message") {

55-

continue;

56-

}

57-

history.push(entry.message as unknown);

58-

if (history.length >= MAX_CLI_SESSION_HISTORY_MESSAGES) {

59-

break;

60-

}

76+

const stat = fs.statSync(realSessionFile);

77+

if (!stat.isFile() || stat.size > MAX_CLI_SESSION_HISTORY_FILE_BYTES) {

78+

return [];

6179

}

62-

history.reverse();

63-

return history;

80+

const entries = SessionManager.open(realSessionFile).getEntries();

81+

const history = entries.flatMap((entry) =>

82+

entry?.type === "message" ? [entry.message as unknown] : [],

83+

);

84+

return limitAgentHookHistoryMessages(history, MAX_CLI_SESSION_HISTORY_MESSAGES);

6485

} catch {

6586

return [];

6687

}

Original file line numberDiff line numberDiff line change

@@ -3,6 +3,11 @@ export type PluginEntryConfig = {

33

hooks?: {

44

/** Controls prompt mutation via before_prompt_build and prompt fields from legacy before_agent_start. */

55

allowPromptInjection?: boolean;

6+

/**

7+

* Controls access to raw conversation content from llm_input/llm_output/agent_end hooks.

8+

* Non-bundled plugins must opt in explicitly; bundled plugins stay allowed unless disabled.

9+

*/

10+

allowConversationAccess?: boolean;

611

};

712

subagent?: {

813

/** Explicitly allow this plugin to request per-run provider/model overrides for subagent runs. */