@oyasmi/pipiclaw 0.5.9 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/agent/channel-runner.d.ts +5 -0
  2. package/dist/agent/channel-runner.js +59 -15
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/memory/consolidation.js +11 -2
  6. package/dist/memory/session.js +2 -2
  7. package/dist/memory/sidecar-worker.d.ts +1 -0
  8. package/dist/memory/sidecar-worker.js +56 -1
  9. package/dist/paths.d.ts +1 -0
  10. package/dist/paths.js +1 -0
  11. package/dist/runtime/bootstrap.d.ts +1 -0
  12. package/dist/runtime/bootstrap.js +50 -11
  13. package/dist/runtime/delivery.js +56 -5
  14. package/dist/runtime/dingtalk.d.ts +2 -0
  15. package/dist/runtime/dingtalk.js +14 -4
  16. package/dist/runtime/events.d.ts +3 -0
  17. package/dist/runtime/events.js +30 -5
  18. package/dist/security/command-guard.js +4 -0
  19. package/dist/security/config.d.ts +6 -0
  20. package/dist/security/config.js +38 -6
  21. package/dist/security/path-guard.js +4 -0
  22. package/dist/security/platform.d.ts +1 -0
  23. package/dist/security/platform.js +3 -0
  24. package/dist/settings.d.ts +4 -1
  25. package/dist/settings.js +31 -6
  26. package/dist/shared/config-diagnostics.d.ts +7 -0
  27. package/dist/shared/config-diagnostics.js +3 -0
  28. package/dist/tools/config.d.ts +7 -0
  29. package/dist/tools/config.js +63 -7
  30. package/dist/tools/index.d.ts +3 -0
  31. package/dist/tools/index.js +2 -2
  32. package/dist/web/client.d.ts +1 -0
  33. package/dist/web/client.js +30 -18
  34. package/dist/web/config.d.ts +1 -0
  35. package/dist/web/config.js +1 -0
  36. package/dist/web/fetch.d.ts +1 -0
  37. package/dist/web/fetch.js +7 -5
  38. package/dist/web/search-providers.js +6 -3
  39. package/package.json +1 -1
@@ -78,6 +78,7 @@ export class DingTalkBot {
78
78
  this.isReconnecting = false;
79
79
  this.isStopped = false;
80
80
  this.reconnectAttempts = 0;
81
+ this.hasReportedReady = false;
81
82
  // Deduplication cache (Set for O(1) lookup, order array for FIFO eviction)
82
83
  this.processedIds = new Set();
83
84
  this.processedIdsOrder = [];
@@ -180,8 +181,10 @@ export class DingTalkBot {
180
181
  this.client.registerCallbackListener(TOPIC_ROBOT, (msg) => {
181
182
  return this.handleRawMessage(msg);
182
183
  });
183
- log.logConnected();
184
- await this.doReconnect(true); // Initial connection
184
+ const connected = await this.doReconnect(true); // Initial connection
185
+ if (!connected) {
186
+ log.logWarning("DingTalk: initial stream connection not ready yet; retrying in background");
187
+ }
185
188
  }
186
189
  handleRawMessage(msg) {
187
190
  // 1. Immediate ACK
@@ -213,16 +216,17 @@ export class DingTalkBot {
213
216
  }
214
217
  async doReconnect(immediate = false) {
215
218
  if (this.isReconnecting || this.isStopped || !this.client)
216
- return;
219
+ return false;
217
220
  this.isReconnecting = true;
218
221
  let connectionFailed = false;
222
+ let connected = false;
219
223
  if (!immediate && this.reconnectAttempts > 0) {
220
224
  const delay = Math.min(1000 * 2 ** this.reconnectAttempts + Math.random() * 1000, 30000);
221
225
  log.logInfo(`DingTalk: waiting ${Math.round(delay / 1000)}s before reconnecting...`);
222
226
  await this.waitForDelay(delay);
223
227
  if (this.isStopped || !this.client) {
224
228
  this.isReconnecting = false;
225
- return;
229
+ return false;
226
230
  }
227
231
  }
228
232
  try {
@@ -234,6 +238,11 @@ export class DingTalkBot {
234
238
  this.lastSocketAvailableTime = Date.now();
235
239
  this.reconnectAttempts = 0; // Success, reset backoff
236
240
  log.logInfo("DingTalk: connected to stream.");
241
+ if (!this.hasReportedReady) {
242
+ log.logConnected();
243
+ this.hasReportedReady = true;
244
+ }
245
+ connected = true;
237
246
  // Setup keep alive
238
247
  this.clearKeepAliveTimer();
239
248
  this.keepAliveTimer = this.setTrackedInterval(() => {
@@ -291,6 +300,7 @@ export class DingTalkBot {
291
300
  if (connectionFailed && !this.isStopped) {
292
301
  this.scheduleReconnect(0, false);
293
302
  }
303
+ return connected;
294
304
  }
295
305
  async stop() {
296
306
  log.logInfo("DingTalk: stopping bot");
@@ -42,6 +42,9 @@ export declare class EventsWatcher {
42
42
  private handlePeriodic;
43
43
  private execute;
44
44
  private deleteFile;
45
+ private getInvalidMarkerPath;
46
+ private markInvalid;
47
+ private clearInvalidMarker;
45
48
  private sleep;
46
49
  }
47
50
  /**
@@ -1,5 +1,5 @@
1
1
  import { Cron } from "croner";
2
- import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, watch } from "fs";
2
+ import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, watch, writeFileSync } from "fs";
3
3
  import { readFile } from "fs/promises";
4
4
  import { join } from "path";
5
5
  import * as log from "../log.js";
@@ -128,10 +128,11 @@ export class EventsWatcher {
128
128
  }
129
129
  if (!event) {
130
130
  log.logWarning(`Failed to parse event file after ${MAX_RETRIES} retries: ${filename}`, lastError?.message);
131
- this.deleteFile(filename);
131
+ this.markInvalid(filename, lastError?.message ?? "Unknown event parse error");
132
132
  return;
133
133
  }
134
134
  this.knownFiles.add(filename);
135
+ this.clearInvalidMarker(filename);
135
136
  switch (event.type) {
136
137
  case "immediate":
137
138
  this.handleImmediate(filename, event);
@@ -196,7 +197,7 @@ export class EventsWatcher {
196
197
  const now = Date.now();
197
198
  if (!Number.isFinite(atTime)) {
198
199
  log.logWarning(`Invalid one-shot time for ${filename}: ${event.at}`);
199
- this.deleteFile(filename);
200
+ this.markInvalid(filename, `Invalid one-shot time: ${event.at}`);
200
201
  return;
201
202
  }
202
203
  if (atTime <= now) {
@@ -207,7 +208,7 @@ export class EventsWatcher {
207
208
  const delay = atTime - now;
208
209
  if (delay > MAX_TIMEOUT_MS) {
209
210
  log.logWarning(`One-shot event exceeds maximum supported delay for ${filename}: ${event.at}. Use a periodic cron event instead.`);
210
- this.deleteFile(filename);
211
+ this.markInvalid(filename, `One-shot event exceeds maximum supported delay: ${event.at}`);
211
212
  return;
212
213
  }
213
214
  log.logInfo(`Scheduling one-shot event: ${filename} in ${Math.round(delay / 1000)}s`);
@@ -230,7 +231,7 @@ export class EventsWatcher {
230
231
  }
231
232
  catch (err) {
232
233
  log.logWarning(`Invalid cron schedule for ${filename}: ${event.schedule}`, String(err));
233
- this.deleteFile(filename);
234
+ this.markInvalid(filename, `Invalid cron schedule: ${event.schedule}\n${String(err)}`);
234
235
  }
235
236
  }
236
237
  execute(filename, event, deleteAfter = true) {
@@ -279,8 +280,32 @@ export class EventsWatcher {
279
280
  log.logWarning(`Failed to delete event file: ${filename}`, String(err));
280
281
  }
281
282
  }
283
+ this.clearInvalidMarker(filename);
282
284
  this.knownFiles.delete(filename);
283
285
  }
286
+ getInvalidMarkerPath(filename) {
287
+ return join(this.eventsDir, `${filename}.error.txt`);
288
+ }
289
+ markInvalid(filename, message) {
290
+ try {
291
+ writeFileSync(this.getInvalidMarkerPath(filename), [`timestamp: ${new Date().toISOString()}`, `file: ${filename}`, "", message.trim()].join("\n"), "utf-8");
292
+ }
293
+ catch (err) {
294
+ log.logWarning(`Failed to write event error marker: ${filename}`, String(err));
295
+ }
296
+ this.knownFiles.add(filename);
297
+ }
298
+ clearInvalidMarker(filename) {
299
+ const markerPath = this.getInvalidMarkerPath(filename);
300
+ try {
301
+ unlinkSync(markerPath);
302
+ }
303
+ catch (err) {
304
+ if (err instanceof Error && "code" in err && err.code !== "ENOENT") {
305
+ log.logWarning(`Failed to delete event error marker: ${filename}`, String(err));
306
+ }
307
+ }
308
+ }
284
309
  sleep(ms) {
285
310
  return new Promise((resolve) => setTimeout(resolve, ms));
286
311
  }
@@ -1,4 +1,5 @@
1
1
  import { basename } from "node:path";
2
+ import { isWindowsPlatform } from "./platform.js";
2
3
  const WHITESPACE = /\s+/;
3
4
  function stripNullAndNormalize(text) {
4
5
  return text.replace(/\0/g, "").normalize("NFKC");
@@ -402,6 +403,9 @@ export function guardCommand(command, config) {
402
403
  if (!config.enabled) {
403
404
  return { allowed: true };
404
405
  }
406
+ if (isWindowsPlatform()) {
407
+ return { allowed: true };
408
+ }
405
409
  const atoms = splitCommandChain(command);
406
410
  const normalizedWhole = stripNullAndNormalize(command);
407
411
  for (const allowPattern of config.allowPatterns) {
@@ -1,4 +1,10 @@
1
+ import type { ConfigDiagnostic } from "../shared/config-diagnostics.js";
1
2
  import type { SecurityConfig } from "./types.js";
3
+ export interface LoadedSecurityConfig {
4
+ config: SecurityConfig;
5
+ diagnostics: ConfigDiagnostic[];
6
+ }
2
7
  export declare const DEFAULT_SECURITY_CONFIG: SecurityConfig;
3
8
  export declare function getSecurityConfigPath(appHomeDir?: string): string;
9
+ export declare function loadSecurityConfigWithDiagnostics(appHomeDir?: string): LoadedSecurityConfig;
4
10
  export declare function loadSecurityConfig(appHomeDir?: string): SecurityConfig;
@@ -34,14 +34,30 @@ function asStringArray(value) {
34
34
  function asOptionalString(value) {
35
35
  return typeof value === "string" && value.trim() ? value : undefined;
36
36
  }
37
- function mergeSecurityConfig(source) {
37
+ function pushInvalidSecurityDiagnostic(diagnostics, configPath, field, message) {
38
+ diagnostics.push({
39
+ source: "security",
40
+ path: configPath,
41
+ severity: "warning",
42
+ message: `${field}: ${message}`,
43
+ });
44
+ }
45
+ function mergeSecurityConfig(source, configPath, diagnostics) {
38
46
  if (!isRecord(source)) {
47
+ pushInvalidSecurityDiagnostic(diagnostics, configPath, "root", "expected a JSON object; using defaults");
39
48
  return DEFAULT_SECURITY_CONFIG;
40
49
  }
41
50
  const commandGuard = isRecord(source.commandGuard) ? source.commandGuard : {};
42
51
  const pathGuard = isRecord(source.pathGuard) ? source.pathGuard : {};
43
52
  const networkGuard = isRecord(source.networkGuard) ? source.networkGuard : {};
44
53
  const audit = isRecord(source.audit) ? source.audit : {};
54
+ if (networkGuard.maxRedirects !== undefined) {
55
+ const maxRedirects = networkGuard.maxRedirects;
56
+ const isValidMaxRedirects = typeof maxRedirects === "number" && Number.isFinite(maxRedirects) && maxRedirects > 0;
57
+ if (!isValidMaxRedirects) {
58
+ pushInvalidSecurityDiagnostic(diagnostics, configPath, "networkGuard.maxRedirects", "expected a positive integer; using default");
59
+ }
60
+ }
45
61
  return {
46
62
  enabled: typeof source.enabled === "boolean" ? source.enabled : DEFAULT_SECURITY_CONFIG.enabled,
47
63
  commandGuard: {
@@ -85,17 +101,33 @@ function mergeSecurityConfig(source) {
85
101
  export function getSecurityConfigPath(appHomeDir = APP_HOME_DIR) {
86
102
  return join(appHomeDir, "security.json");
87
103
  }
88
- export function loadSecurityConfig(appHomeDir = APP_HOME_DIR) {
104
+ export function loadSecurityConfigWithDiagnostics(appHomeDir = APP_HOME_DIR) {
89
105
  const configPath = getSecurityConfigPath(appHomeDir);
90
106
  if (!existsSync(configPath)) {
91
- return DEFAULT_SECURITY_CONFIG;
107
+ return { config: DEFAULT_SECURITY_CONFIG, diagnostics: [] };
92
108
  }
93
109
  try {
94
110
  const raw = JSON.parse(readFileSync(configPath, "utf-8"));
95
- return mergeSecurityConfig(raw);
111
+ const diagnostics = [];
112
+ return {
113
+ config: mergeSecurityConfig(raw, configPath, diagnostics),
114
+ diagnostics,
115
+ };
96
116
  }
97
117
  catch (error) {
98
- console.warn(`Failed to load security config from ${configPath}: ${error}`);
99
- return DEFAULT_SECURITY_CONFIG;
118
+ return {
119
+ config: DEFAULT_SECURITY_CONFIG,
120
+ diagnostics: [
121
+ {
122
+ source: "security",
123
+ path: configPath,
124
+ severity: "error",
125
+ message: error instanceof Error ? error.message : String(error),
126
+ },
127
+ ],
128
+ };
100
129
  }
101
130
  }
131
+ export function loadSecurityConfig(appHomeDir = APP_HOME_DIR) {
132
+ return loadSecurityConfigWithDiagnostics(appHomeDir).config;
133
+ }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, lstatSync, realpathSync } from "node:fs";
2
2
  import { homedir, tmpdir } from "node:os";
3
3
  import { basename, dirname, isAbsolute, normalize, resolve } from "node:path";
4
+ import { isWindowsPlatform } from "./platform.js";
4
5
  const PRIVATE_KEY_EXTENSIONS = new Set([".pem", ".key", ".p12", ".pfx"]);
5
6
  const PRIVATE_KEY_NAME_HINTS = /(id_rsa|id_ed25519|private|secret|credentials)/i;
6
7
  const PROC_MEM_PATH = /^\/proc\/\d+\/mem(?:\/|$)/;
@@ -197,6 +198,9 @@ export function guardPath(rawPath, operation, ctx) {
197
198
  if (!ctx.config.enabled) {
198
199
  return { allowed: true, operation, rawPath };
199
200
  }
201
+ if (isWindowsPlatform()) {
202
+ return { allowed: true, operation, rawPath };
203
+ }
200
204
  const homeDir = ctx.homeDir ?? homedir();
201
205
  const effectiveCtx = {
202
206
  ...ctx,
@@ -0,0 +1 @@
1
+ export declare function isWindowsPlatform(): boolean;
@@ -0,0 +1,3 @@
1
+ export function isWindowsPlatform() {
2
+ return process.platform === "win32";
3
+ }
@@ -7,6 +7,7 @@
7
7
  * This module currently provides only PipiclawSettingsManager.
8
8
  */
9
9
  import type { Transport } from "@mariozechner/pi-ai";
10
+ import type { ConfigDiagnostic } from "./shared/config-diagnostics.js";
10
11
  type PackageSource = string | {
11
12
  source: string;
12
13
  extensions?: string[];
@@ -83,10 +84,13 @@ export interface PipiclawSettings {
83
84
  export declare class PipiclawSettingsManager {
84
85
  private settingsPath;
85
86
  private settings;
87
+ private loadErrors;
86
88
  constructor(baseDir: string);
87
89
  private load;
88
90
  private save;
89
91
  reload(): void;
92
+ drainErrors(): SettingsError[];
93
+ getDiagnostics(): ConfigDiagnostic[];
90
94
  getCompactionSettings(): PipiclawCompactionSettings;
91
95
  getCompactionEnabled(): boolean;
92
96
  setCompactionEnabled(enabled: boolean): void;
@@ -176,6 +180,5 @@ export declare class PipiclawSettingsManager {
176
180
  getProjectSettings(): Settings;
177
181
  applyOverrides(overrides: Partial<Settings>): void;
178
182
  flush(): Promise<void>;
179
- drainErrors(): SettingsError[];
180
183
  }
181
184
  export {};
package/dist/settings.js CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
10
10
  import { dirname, join } from "path";
11
+ import * as log from "./log.js";
11
12
  const DEFAULT_COMPACTION = {
12
13
  enabled: true,
13
14
  reserveTokens: 16384,
@@ -40,18 +41,32 @@ const DEFAULT_SESSION_MEMORY = {
40
41
  */
41
42
  export class PipiclawSettingsManager {
42
43
  constructor(baseDir) {
44
+ this.loadErrors = [];
43
45
  this.settingsPath = join(baseDir, "settings.json");
44
46
  this.settings = this.load();
45
47
  }
46
48
  load() {
49
+ this.loadErrors = [];
47
50
  if (!existsSync(this.settingsPath)) {
48
51
  return {};
49
52
  }
50
53
  try {
51
54
  const content = readFileSync(this.settingsPath, "utf-8");
52
- return JSON.parse(content);
55
+ const parsed = JSON.parse(content);
56
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
57
+ this.loadErrors.push({
58
+ scope: "global",
59
+ error: new Error(`Expected a JSON object in ${this.settingsPath}`),
60
+ });
61
+ return {};
62
+ }
63
+ return parsed;
53
64
  }
54
- catch {
65
+ catch (error) {
66
+ this.loadErrors.push({
67
+ scope: "global",
68
+ error: error instanceof Error ? error : new Error(String(error)),
69
+ });
55
70
  return {};
56
71
  }
57
72
  }
@@ -64,12 +79,25 @@ export class PipiclawSettingsManager {
64
79
  writeFileSync(this.settingsPath, JSON.stringify(this.settings, null, 2), "utf-8");
65
80
  }
66
81
  catch (error) {
67
- console.error(`Warning: Could not save settings file: ${error}`);
82
+ log.logWarning(`Could not save settings file`, `${this.settingsPath}\n${String(error)}`);
68
83
  }
69
84
  }
70
85
  reload() {
71
86
  this.settings = this.load();
72
87
  }
88
+ drainErrors() {
89
+ const errors = this.loadErrors;
90
+ this.loadErrors = [];
91
+ return errors;
92
+ }
93
+ getDiagnostics() {
94
+ return this.loadErrors.map(({ error }) => ({
95
+ source: "settings",
96
+ path: this.settingsPath,
97
+ severity: "error",
98
+ message: error.message,
99
+ }));
100
+ }
73
101
  getCompactionSettings() {
74
102
  return {
75
103
  ...DEFAULT_COMPACTION,
@@ -377,7 +405,4 @@ export class PipiclawSettingsManager {
377
405
  flush() {
378
406
  return Promise.resolve();
379
407
  }
380
- drainErrors() {
381
- return [];
382
- }
383
408
  }
@@ -0,0 +1,7 @@
1
+ export interface ConfigDiagnostic {
2
+ source: "settings" | "tools" | "security";
3
+ path: string;
4
+ severity: "warning" | "error";
5
+ message: string;
6
+ }
7
+ export declare function formatConfigDiagnostic(diagnostic: ConfigDiagnostic): string;
@@ -0,0 +1,3 @@
1
+ export function formatConfigDiagnostic(diagnostic) {
2
+ return `${diagnostic.source}.json: ${diagnostic.message}`;
3
+ }
@@ -1,3 +1,4 @@
1
+ import type { ConfigDiagnostic } from "../shared/config-diagnostics.js";
1
2
  export type WebSearchProvider = "brave" | "tavily" | "jina" | "searxng" | "duckduckgo";
2
3
  export interface PipiclawWebSearchConfig {
3
4
  provider: WebSearchProvider;
@@ -10,6 +11,7 @@ export interface PipiclawWebFetchConfig {
10
11
  maxChars: number;
11
12
  timeoutMs: number;
12
13
  maxImageBytes: number;
14
+ maxResponseBytes: number;
13
15
  preferJina: boolean;
14
16
  enableJinaFallback: boolean;
15
17
  defaultExtractMode: "markdown" | "text";
@@ -25,6 +27,11 @@ export interface PipiclawToolsConfig {
25
27
  web: PipiclawWebToolsConfig;
26
28
  };
27
29
  }
30
+ export interface LoadedToolsConfig {
31
+ config: PipiclawToolsConfig;
32
+ diagnostics: ConfigDiagnostic[];
33
+ }
28
34
  export declare const DEFAULT_TOOLS_CONFIG: PipiclawToolsConfig;
29
35
  export declare function getToolsConfigPath(appHomeDir?: string): string;
36
+ export declare function loadToolsConfigWithDiagnostics(appHomeDir?: string): LoadedToolsConfig;
30
37
  export declare function loadToolsConfig(appHomeDir?: string): PipiclawToolsConfig;
@@ -19,6 +19,7 @@ export const DEFAULT_TOOLS_CONFIG = {
19
19
  maxChars: 50_000,
20
20
  timeoutMs: 30_000,
21
21
  maxImageBytes: 10 * 1024 * 1024,
22
+ maxResponseBytes: 5 * 1024 * 1024,
22
23
  preferJina: false,
23
24
  enableJinaFallback: false,
24
25
  defaultExtractMode: "markdown",
@@ -52,8 +53,17 @@ function asOptionalProxy(value) {
52
53
  const trimmed = value.trim();
53
54
  return trimmed.length > 0 ? trimmed : null;
54
55
  }
55
- function mergeToolsConfig(source) {
56
+ function pushInvalidValueDiagnostic(diagnostics, configPath, field, message) {
57
+ diagnostics.push({
58
+ source: "tools",
59
+ path: configPath,
60
+ severity: "warning",
61
+ message: `${field}: ${message}`,
62
+ });
63
+ }
64
+ function mergeToolsConfig(source, configPath, diagnostics) {
56
65
  if (!isRecord(source)) {
66
+ pushInvalidValueDiagnostic(diagnostics, configPath, "root", "expected a JSON object; using defaults");
57
67
  return DEFAULT_TOOLS_CONFIG;
58
68
  }
59
69
  const tools = isRecord(source.tools) ? source.tools : {};
@@ -63,8 +73,37 @@ function mergeToolsConfig(source) {
63
73
  const providerValue = asTrimmedString(search.provider, DEFAULT_TOOLS_CONFIG.tools.web.search.provider).toLowerCase();
64
74
  const provider = WEB_SEARCH_PROVIDERS.includes(providerValue)
65
75
  ? providerValue
66
- : DEFAULT_TOOLS_CONFIG.tools.web.search.provider;
76
+ : (() => {
77
+ if (search.provider !== undefined) {
78
+ pushInvalidValueDiagnostic(diagnostics, configPath, "tools.web.search.provider", `unknown provider "${String(search.provider)}"; using ${DEFAULT_TOOLS_CONFIG.tools.web.search.provider}`);
79
+ }
80
+ return DEFAULT_TOOLS_CONFIG.tools.web.search.provider;
81
+ })();
67
82
  const defaultExtractMode = asTrimmedString(fetch.defaultExtractMode, DEFAULT_TOOLS_CONFIG.tools.web.fetch.defaultExtractMode);
83
+ if (web.proxy !== undefined && web.proxy !== null && typeof web.proxy !== "string") {
84
+ pushInvalidValueDiagnostic(diagnostics, configPath, "tools.web.proxy", "expected a string or null; using null");
85
+ }
86
+ if (search.maxResults !== undefined && clampInteger(search.maxResults, -1, 1, 10) === -1) {
87
+ pushInvalidValueDiagnostic(diagnostics, configPath, "tools.web.search.maxResults", "expected an integer between 1 and 10; using default");
88
+ }
89
+ if (search.timeoutMs !== undefined && clampInteger(search.timeoutMs, -1, 1) === -1) {
90
+ pushInvalidValueDiagnostic(diagnostics, configPath, "tools.web.search.timeoutMs", "expected a positive integer; using default");
91
+ }
92
+ if (fetch.maxChars !== undefined && clampInteger(fetch.maxChars, -1, 100) === -1) {
93
+ pushInvalidValueDiagnostic(diagnostics, configPath, "tools.web.fetch.maxChars", "expected an integer >= 100; using default");
94
+ }
95
+ if (fetch.timeoutMs !== undefined && clampInteger(fetch.timeoutMs, -1, 1) === -1) {
96
+ pushInvalidValueDiagnostic(diagnostics, configPath, "tools.web.fetch.timeoutMs", "expected a positive integer; using default");
97
+ }
98
+ if (fetch.maxImageBytes !== undefined && clampInteger(fetch.maxImageBytes, -1, 1) === -1) {
99
+ pushInvalidValueDiagnostic(diagnostics, configPath, "tools.web.fetch.maxImageBytes", "expected a positive integer; using default");
100
+ }
101
+ if (fetch.maxResponseBytes !== undefined && clampInteger(fetch.maxResponseBytes, -1, 1) === -1) {
102
+ pushInvalidValueDiagnostic(diagnostics, configPath, "tools.web.fetch.maxResponseBytes", "expected a positive integer; using default");
103
+ }
104
+ if (fetch.defaultExtractMode !== undefined && defaultExtractMode !== "text" && defaultExtractMode !== "markdown") {
105
+ pushInvalidValueDiagnostic(diagnostics, configPath, "tools.web.fetch.defaultExtractMode", `expected "markdown" or "text"; using ${DEFAULT_TOOLS_CONFIG.tools.web.fetch.defaultExtractMode}`);
106
+ }
68
107
  return {
69
108
  tools: {
70
109
  web: {
@@ -81,6 +120,7 @@ function mergeToolsConfig(source) {
81
120
  maxChars: clampInteger(fetch.maxChars, DEFAULT_TOOLS_CONFIG.tools.web.fetch.maxChars, 100),
82
121
  timeoutMs: clampInteger(fetch.timeoutMs, DEFAULT_TOOLS_CONFIG.tools.web.fetch.timeoutMs, 1),
83
122
  maxImageBytes: clampInteger(fetch.maxImageBytes, DEFAULT_TOOLS_CONFIG.tools.web.fetch.maxImageBytes, 1),
123
+ maxResponseBytes: clampInteger(fetch.maxResponseBytes, DEFAULT_TOOLS_CONFIG.tools.web.fetch.maxResponseBytes, 1),
84
124
  preferJina: typeof fetch.preferJina === "boolean"
85
125
  ? fetch.preferJina
86
126
  : DEFAULT_TOOLS_CONFIG.tools.web.fetch.preferJina,
@@ -98,17 +138,33 @@ function mergeToolsConfig(source) {
98
138
  export function getToolsConfigPath(appHomeDir = APP_HOME_DIR) {
99
139
  return appHomeDir === APP_HOME_DIR ? TOOLS_CONFIG_PATH : join(appHomeDir, "tools.json");
100
140
  }
101
- export function loadToolsConfig(appHomeDir = APP_HOME_DIR) {
141
+ export function loadToolsConfigWithDiagnostics(appHomeDir = APP_HOME_DIR) {
102
142
  const configPath = getToolsConfigPath(appHomeDir);
103
143
  if (!existsSync(configPath)) {
104
- return DEFAULT_TOOLS_CONFIG;
144
+ return { config: DEFAULT_TOOLS_CONFIG, diagnostics: [] };
105
145
  }
106
146
  try {
107
147
  const raw = JSON.parse(readFileSync(configPath, "utf-8"));
108
- return mergeToolsConfig(raw);
148
+ const diagnostics = [];
149
+ return {
150
+ config: mergeToolsConfig(raw, configPath, diagnostics),
151
+ diagnostics,
152
+ };
109
153
  }
110
154
  catch (error) {
111
- console.warn(`Failed to load tools config from ${configPath}: ${error}`);
112
- return DEFAULT_TOOLS_CONFIG;
155
+ return {
156
+ config: DEFAULT_TOOLS_CONFIG,
157
+ diagnostics: [
158
+ {
159
+ source: "tools",
160
+ path: configPath,
161
+ severity: "error",
162
+ message: error instanceof Error ? error.message : String(error),
163
+ },
164
+ ],
165
+ };
113
166
  }
114
167
  }
168
+ export function loadToolsConfig(appHomeDir = APP_HOME_DIR) {
169
+ return loadToolsConfigWithDiagnostics(appHomeDir).config;
170
+ }
@@ -4,6 +4,7 @@ import type { Executor, SandboxConfig } from "../sandbox.js";
4
4
  import type { SecurityConfig, SecurityRuntimeContext } from "../security/types.js";
5
5
  import type { PipiclawMemoryRecallSettings } from "../settings.js";
6
6
  import type { SubAgentDiscoveryResult } from "../subagents/discovery.js";
7
+ import type { PipiclawToolsConfig } from "./config.js";
7
8
  export interface CreatePipiclawToolsOptions {
8
9
  executor: Executor;
9
10
  getCurrentModel: () => Model<Api>;
@@ -16,6 +17,8 @@ export interface CreatePipiclawToolsOptions {
16
17
  sandboxConfig: SandboxConfig;
17
18
  getSubAgentDiscovery: () => SubAgentDiscoveryResult;
18
19
  getMemoryRecallSettings: () => PipiclawMemoryRecallSettings;
20
+ securityConfig?: SecurityConfig;
21
+ toolsConfig?: PipiclawToolsConfig;
19
22
  }
20
23
  export interface CreatePipiclawBaseToolsOptions {
21
24
  securityConfig?: SecurityConfig;
@@ -25,8 +25,8 @@ export function createPipiclawBaseTools(executor, options = {}) {
25
25
  ];
26
26
  }
27
27
  export function createPipiclawTools(options) {
28
- const securityConfig = loadSecurityConfig(APP_HOME_DIR);
29
- const toolsConfig = loadToolsConfig(APP_HOME_DIR);
28
+ const securityConfig = options.securityConfig ?? loadSecurityConfig(APP_HOME_DIR);
29
+ const toolsConfig = options.toolsConfig ?? loadToolsConfig(APP_HOME_DIR);
30
30
  const securityContext = {
31
31
  workspaceDir: options.workspaceDir,
32
32
  workspacePath: options.workspacePath,
@@ -23,6 +23,7 @@ export interface WebHttpRequestOptions {
23
23
  timeoutMs: number;
24
24
  signal?: AbortSignal;
25
25
  maxRedirects?: number;
26
+ maxResponseBytes?: number;
26
27
  }
27
28
  export declare class WebHttpClient {
28
29
  private readonly context;
@@ -108,24 +108,36 @@ export class WebHttpClient {
108
108
  throw error;
109
109
  }
110
110
  const agent = getProxyAgent(currentUrl, this.context.webConfig.proxy);
111
- const response = await axios.request({
112
- method,
113
- url: currentUrl,
114
- data,
115
- headers: {
116
- "User-Agent": WEB_USER_AGENT,
117
- Accept: "*/*",
118
- ...options.headers,
119
- },
120
- responseType: "arraybuffer",
121
- validateStatus: () => true,
122
- timeout: options.timeoutMs,
123
- signal: options.signal,
124
- maxRedirects: 0,
125
- proxy: false,
126
- httpAgent: agent,
127
- httpsAgent: agent,
128
- });
111
+ let response;
112
+ try {
113
+ response = await axios.request({
114
+ method,
115
+ url: currentUrl,
116
+ data,
117
+ headers: {
118
+ "User-Agent": WEB_USER_AGENT,
119
+ Accept: "*/*",
120
+ ...options.headers,
121
+ },
122
+ responseType: "arraybuffer",
123
+ validateStatus: () => true,
124
+ timeout: options.timeoutMs,
125
+ signal: options.signal,
126
+ maxRedirects: 0,
127
+ maxContentLength: options.maxResponseBytes ?? Number.POSITIVE_INFINITY,
128
+ proxy: false,
129
+ httpAgent: agent,
130
+ httpsAgent: agent,
131
+ });
132
+ }
133
+ catch (error) {
134
+ if (options.maxResponseBytes &&
135
+ typeof error?.message === "string" &&
136
+ error.message.includes("maxContentLength")) {
137
+ throw new Error(`Response exceeds maxResponseBytes (${options.maxResponseBytes} bytes)`);
138
+ }
139
+ throw error;
140
+ }
129
141
  const headers = normalizeHeaders(response.headers);
130
142
  const body = Buffer.isBuffer(response.data) ? response.data : Buffer.from(response.data);
131
143
  if (isRedirectStatus(response.status) && headers.location) {
@@ -10,6 +10,7 @@ export interface ResolvedWebFetchRequest {
10
10
  maxChars: number;
11
11
  timeoutMs: number;
12
12
  maxImageBytes: number;
13
+ maxResponseBytes: number;
13
14
  preferJina: boolean;
14
15
  enableJinaFallback: boolean;
15
16
  }
@@ -12,6 +12,7 @@ export function resolveWebFetchRequest(config, url, extractMode, maxChars) {
12
12
  maxChars: clamp(maxChars, config.maxChars, 100),
13
13
  timeoutMs: config.timeoutMs,
14
14
  maxImageBytes: config.maxImageBytes,
15
+ maxResponseBytes: config.maxResponseBytes,
15
16
  preferJina: config.preferJina,
16
17
  enableJinaFallback: config.enableJinaFallback,
17
18
  };
@@ -17,6 +17,7 @@ export declare function runWebFetch(context: WebFetchExecutionContext, request:
17
17
  extractMode: "markdown" | "text";
18
18
  maxChars: number;
19
19
  maxImageBytes: number;
20
+ maxResponseBytes: number;
20
21
  preferJina: boolean;
21
22
  enableJinaFallback: boolean;
22
23
  }, signal?: AbortSignal): Promise<WebFetchOutput>;