@oyasmi/pipiclaw 0.5.7 → 0.5.9
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.
- package/README.md +52 -3
- package/dist/agent/prompt-builder.js +6 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/paths.d.ts +1 -0
- package/dist/paths.js +1 -0
- package/dist/runtime/bootstrap.d.ts +1 -1
- package/dist/runtime/bootstrap.js +25 -13
- package/dist/runtime/dingtalk.js +0 -3
- package/dist/sandbox.js +63 -5
- package/dist/security/config.js +19 -0
- package/dist/security/network.d.ts +28 -0
- package/dist/security/network.js +246 -0
- package/dist/security/types.d.ts +16 -1
- package/dist/shared/shell-escape.d.ts +7 -0
- package/dist/shared/shell-escape.js +11 -0
- package/dist/subagents/discovery.d.ts +1 -1
- package/dist/subagents/discovery.js +1 -1
- package/dist/subagents/tool.d.ts +2 -0
- package/dist/subagents/tool.js +24 -2
- package/dist/tools/config.d.ts +30 -0
- package/dist/tools/config.js +114 -0
- package/dist/tools/edit.js +2 -2
- package/dist/tools/index.js +22 -0
- package/dist/tools/read.js +6 -6
- package/dist/tools/web-fetch.d.ts +17 -0
- package/dist/tools/web-fetch.js +29 -0
- package/dist/tools/web-search.d.ts +16 -0
- package/dist/tools/web-search.js +29 -0
- package/dist/tools/write-content.js +5 -4
- package/dist/web/client.d.ts +40 -0
- package/dist/web/client.js +181 -0
- package/dist/web/config.d.ts +18 -0
- package/dist/web/config.js +34 -0
- package/dist/web/extract.d.ts +7 -0
- package/dist/web/extract.js +122 -0
- package/dist/web/fetch.d.ts +22 -0
- package/dist/web/fetch.js +148 -0
- package/dist/web/format.d.ts +21 -0
- package/dist/web/format.js +38 -0
- package/dist/web/search-providers.d.ts +15 -0
- package/dist/web/search-providers.js +196 -0
- package/dist/web/search.d.ts +19 -0
- package/dist/web/search.js +52 -0
- package/package.json +9 -2
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
export class NetworkGuardError extends Error {
|
|
4
|
+
constructor(options) {
|
|
5
|
+
super(options.message);
|
|
6
|
+
this.name = "NetworkGuardError";
|
|
7
|
+
this.url = options.url;
|
|
8
|
+
this.stage = options.stage;
|
|
9
|
+
this.category = options.category;
|
|
10
|
+
this.resolvedHost = options.resolvedHost;
|
|
11
|
+
this.resolvedAddress = options.resolvedAddress;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const BLOCKED_HOSTS = new Set(["localhost", "metadata.google.internal", "metadata", "169.254.169.254"]);
|
|
15
|
+
const PRIVATE_IPV4_CIDRS = [
|
|
16
|
+
"0.0.0.0/8",
|
|
17
|
+
"10.0.0.0/8",
|
|
18
|
+
"100.64.0.0/10",
|
|
19
|
+
"127.0.0.0/8",
|
|
20
|
+
"169.254.0.0/16",
|
|
21
|
+
"172.16.0.0/12",
|
|
22
|
+
"192.168.0.0/16",
|
|
23
|
+
"198.18.0.0/15",
|
|
24
|
+
];
|
|
25
|
+
const PRIVATE_IPV6_CIDRS = ["::1/128", "::/128", "fc00::/7", "fe80::/10"];
|
|
26
|
+
function normalizeHost(host) {
|
|
27
|
+
return host.trim().replace(/\.$/, "").toLowerCase();
|
|
28
|
+
}
|
|
29
|
+
function parseIpv4(ip) {
|
|
30
|
+
const parts = ip.split(".");
|
|
31
|
+
if (parts.length !== 4) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
let value = 0;
|
|
35
|
+
for (const part of parts) {
|
|
36
|
+
if (!/^\d+$/.test(part)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const octet = Number.parseInt(part, 10);
|
|
40
|
+
if (octet < 0 || octet > 255) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
value = (value << 8) | octet;
|
|
44
|
+
}
|
|
45
|
+
return value >>> 0;
|
|
46
|
+
}
|
|
47
|
+
function expandIpv6(ip) {
|
|
48
|
+
const normalized = ip.toLowerCase();
|
|
49
|
+
const hasEmbeddedIpv4 = normalized.includes(".");
|
|
50
|
+
let working = normalized;
|
|
51
|
+
if (hasEmbeddedIpv4) {
|
|
52
|
+
const lastColon = working.lastIndexOf(":");
|
|
53
|
+
if (lastColon === -1) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const ipv4 = parseIpv4(working.slice(lastColon + 1));
|
|
57
|
+
if (ipv4 === null) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const high = ((ipv4 >>> 16) & 0xffff).toString(16);
|
|
61
|
+
const low = (ipv4 & 0xffff).toString(16);
|
|
62
|
+
working = `${working.slice(0, lastColon)}:${high}:${low}`;
|
|
63
|
+
}
|
|
64
|
+
const pieces = working.split("::");
|
|
65
|
+
if (pieces.length > 2) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const left = pieces[0] ? pieces[0].split(":").filter(Boolean) : [];
|
|
69
|
+
const right = pieces[1] ? pieces[1].split(":").filter(Boolean) : [];
|
|
70
|
+
if (left.length + right.length > 8) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const fill = new Array(8 - left.length - right.length).fill("0");
|
|
74
|
+
const groups = pieces.length === 2 ? [...left, ...fill, ...right] : left;
|
|
75
|
+
return groups.length === 8 ? groups : null;
|
|
76
|
+
}
|
|
77
|
+
function parseIpv6(ip) {
|
|
78
|
+
const groups = expandIpv6(ip);
|
|
79
|
+
if (!groups) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
let value = 0n;
|
|
83
|
+
for (const group of groups) {
|
|
84
|
+
if (!/^[0-9a-f]{1,4}$/i.test(group)) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
value = (value << 16n) | BigInt(Number.parseInt(group, 16));
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
function ipInCidr(ip, cidr) {
|
|
92
|
+
const [network, prefixText] = cidr.split("/");
|
|
93
|
+
const prefix = Number.parseInt(prefixText ?? "", 10);
|
|
94
|
+
if (!Number.isFinite(prefix)) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
const version = isIP(ip);
|
|
98
|
+
if (version === 4) {
|
|
99
|
+
const ipValue = parseIpv4(ip);
|
|
100
|
+
const networkValue = parseIpv4(network);
|
|
101
|
+
if (ipValue === null || networkValue === null || prefix < 0 || prefix > 32) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
|
|
105
|
+
return (ipValue & mask) === (networkValue & mask);
|
|
106
|
+
}
|
|
107
|
+
if (version === 6) {
|
|
108
|
+
const ipValue = parseIpv6(ip);
|
|
109
|
+
const networkValue = parseIpv6(network);
|
|
110
|
+
if (ipValue === null || networkValue === null || prefix < 0 || prefix > 128) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
const shift = 128 - prefix;
|
|
114
|
+
if (shift === 128) {
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
return ipValue >> BigInt(shift) === networkValue >> BigInt(shift);
|
|
118
|
+
}
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
function matchesAllowedHost(hostname, allowedHosts) {
|
|
122
|
+
const normalized = normalizeHost(hostname);
|
|
123
|
+
return allowedHosts.some((candidate) => normalizeHost(candidate) === normalized);
|
|
124
|
+
}
|
|
125
|
+
function matchesAllowedCidr(address, allowedCidrs) {
|
|
126
|
+
return allowedCidrs.some((cidr) => ipInCidr(address, cidr.trim()));
|
|
127
|
+
}
|
|
128
|
+
function isBlockedHost(hostname) {
|
|
129
|
+
const normalized = normalizeHost(hostname);
|
|
130
|
+
return normalized.endsWith(".localhost") || BLOCKED_HOSTS.has(normalized);
|
|
131
|
+
}
|
|
132
|
+
function isPrivateAddress(address) {
|
|
133
|
+
const version = isIP(address);
|
|
134
|
+
if (version === 4) {
|
|
135
|
+
return PRIVATE_IPV4_CIDRS.some((cidr) => ipInCidr(address, cidr));
|
|
136
|
+
}
|
|
137
|
+
if (version === 6) {
|
|
138
|
+
return PRIVATE_IPV6_CIDRS.some((cidr) => ipInCidr(address, cidr));
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
async function validateUrlTarget(rawUrl, context, stage) {
|
|
143
|
+
const url = (() => {
|
|
144
|
+
try {
|
|
145
|
+
return new URL(rawUrl);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
throw new NetworkGuardError({
|
|
149
|
+
url: rawUrl,
|
|
150
|
+
stage,
|
|
151
|
+
category: "invalid-url",
|
|
152
|
+
message: `Invalid URL: ${rawUrl}`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
})();
|
|
156
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
157
|
+
throw new NetworkGuardError({
|
|
158
|
+
url: rawUrl,
|
|
159
|
+
stage,
|
|
160
|
+
category: "unsupported-scheme",
|
|
161
|
+
message: `Only http/https URLs are allowed, got ${url.protocol || "unknown"}`,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const hostname = normalizeHost(url.hostname);
|
|
165
|
+
if (!hostname) {
|
|
166
|
+
throw new NetworkGuardError({
|
|
167
|
+
url: rawUrl,
|
|
168
|
+
stage,
|
|
169
|
+
category: "missing-host",
|
|
170
|
+
message: `URL is missing a hostname: ${rawUrl}`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
const { networkGuard } = context.config;
|
|
174
|
+
if (!networkGuard.enabled) {
|
|
175
|
+
return { url: url.toString(), hostname };
|
|
176
|
+
}
|
|
177
|
+
if (matchesAllowedHost(hostname, networkGuard.allowedHosts)) {
|
|
178
|
+
return { url: url.toString(), hostname };
|
|
179
|
+
}
|
|
180
|
+
if (isBlockedHost(hostname)) {
|
|
181
|
+
throw new NetworkGuardError({
|
|
182
|
+
url: rawUrl,
|
|
183
|
+
stage,
|
|
184
|
+
category: "blocked-host",
|
|
185
|
+
message: `Blocked host: ${hostname}`,
|
|
186
|
+
resolvedHost: hostname,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (isIP(hostname)) {
|
|
190
|
+
if (!matchesAllowedCidr(hostname, networkGuard.allowedCidrs) && isPrivateAddress(hostname)) {
|
|
191
|
+
throw new NetworkGuardError({
|
|
192
|
+
url: rawUrl,
|
|
193
|
+
stage,
|
|
194
|
+
category: "private-address",
|
|
195
|
+
message: `Blocked private network address: ${hostname}`,
|
|
196
|
+
resolvedHost: hostname,
|
|
197
|
+
resolvedAddress: hostname,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return { url: url.toString(), hostname, resolvedAddress: hostname };
|
|
201
|
+
}
|
|
202
|
+
let records;
|
|
203
|
+
try {
|
|
204
|
+
records = (await lookup(hostname, { all: true, verbatim: true }));
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
throw new NetworkGuardError({
|
|
208
|
+
url: rawUrl,
|
|
209
|
+
stage,
|
|
210
|
+
category: "dns-failure",
|
|
211
|
+
message: `Failed to resolve host ${hostname}: ${error instanceof Error ? error.message : String(error)}`,
|
|
212
|
+
resolvedHost: hostname,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (records.length === 0) {
|
|
216
|
+
throw new NetworkGuardError({
|
|
217
|
+
url: rawUrl,
|
|
218
|
+
stage,
|
|
219
|
+
category: "dns-failure",
|
|
220
|
+
message: `Failed to resolve host ${hostname}`,
|
|
221
|
+
resolvedHost: hostname,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
for (const record of records) {
|
|
225
|
+
if (matchesAllowedCidr(record.address, networkGuard.allowedCidrs)) {
|
|
226
|
+
return { url: url.toString(), hostname, resolvedAddress: record.address };
|
|
227
|
+
}
|
|
228
|
+
if (isPrivateAddress(record.address)) {
|
|
229
|
+
throw new NetworkGuardError({
|
|
230
|
+
url: rawUrl,
|
|
231
|
+
stage,
|
|
232
|
+
category: "private-address",
|
|
233
|
+
message: `Blocked private network address resolved from ${hostname}: ${record.address}`,
|
|
234
|
+
resolvedHost: hostname,
|
|
235
|
+
resolvedAddress: record.address,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { url: url.toString(), hostname, resolvedAddress: records[0]?.address };
|
|
240
|
+
}
|
|
241
|
+
export async function validateNetworkTarget(url, context) {
|
|
242
|
+
return validateUrlTarget(url, context, "request");
|
|
243
|
+
}
|
|
244
|
+
export async function validateRedirectTarget(url, context) {
|
|
245
|
+
return validateUrlTarget(url, context, "redirect");
|
|
246
|
+
}
|
package/dist/security/types.d.ts
CHANGED
|
@@ -14,6 +14,12 @@ export interface SecurityConfig {
|
|
|
14
14
|
writeDeny: string[];
|
|
15
15
|
resolveSymlinks: boolean;
|
|
16
16
|
};
|
|
17
|
+
networkGuard: {
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
allowedCidrs: string[];
|
|
20
|
+
allowedHosts: string[];
|
|
21
|
+
maxRedirects: number;
|
|
22
|
+
};
|
|
17
23
|
audit: {
|
|
18
24
|
logBlocked: boolean;
|
|
19
25
|
logFile?: string;
|
|
@@ -63,4 +69,13 @@ export interface BlockedCommandLogEvent extends SecurityLogEventBase {
|
|
|
63
69
|
reason?: string;
|
|
64
70
|
matchedText?: string;
|
|
65
71
|
}
|
|
66
|
-
export
|
|
72
|
+
export interface BlockedNetworkLogEvent extends SecurityLogEventBase {
|
|
73
|
+
type: "network";
|
|
74
|
+
url: string;
|
|
75
|
+
stage: "request" | "redirect";
|
|
76
|
+
resolvedHost?: string;
|
|
77
|
+
resolvedAddress?: string;
|
|
78
|
+
category?: string;
|
|
79
|
+
reason?: string;
|
|
80
|
+
}
|
|
81
|
+
export type SecurityLogEvent = BlockedPathLogEvent | BlockedCommandLogEvent | BlockedNetworkLogEvent;
|
|
@@ -3,3 +3,10 @@
|
|
|
3
3
|
* Wraps in single quotes and escapes internal single quotes.
|
|
4
4
|
*/
|
|
5
5
|
export declare function shellEscape(s: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Normalize filesystem paths for POSIX-style shells.
|
|
8
|
+
* On Windows we convert backslashes to forward slashes so Git Bash/MSYS tools
|
|
9
|
+
* can consume the path consistently.
|
|
10
|
+
*/
|
|
11
|
+
export declare function toShellPath(path: string): string;
|
|
12
|
+
export declare function shellEscapePath(path: string): string;
|
|
@@ -5,3 +5,14 @@
|
|
|
5
5
|
export function shellEscape(s) {
|
|
6
6
|
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Normalize filesystem paths for POSIX-style shells.
|
|
10
|
+
* On Windows we convert backslashes to forward slashes so Git Bash/MSYS tools
|
|
11
|
+
* can consume the path consistently.
|
|
12
|
+
*/
|
|
13
|
+
export function toShellPath(path) {
|
|
14
|
+
return process.platform === "win32" ? path.replace(/\\/g, "/") : path;
|
|
15
|
+
}
|
|
16
|
+
export function shellEscapePath(path) {
|
|
17
|
+
return shellEscape(toShellPath(path));
|
|
18
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Api, Model } from "@mariozechner/pi-ai";
|
|
2
|
-
declare const ALLOWED_SUB_AGENT_TOOLS: readonly ["read", "bash", "edit", "write"];
|
|
2
|
+
declare const ALLOWED_SUB_AGENT_TOOLS: readonly ["read", "bash", "edit", "write", "web_search", "web_fetch"];
|
|
3
3
|
declare const ALLOWED_CONTEXT_MODES: readonly ["isolated", "contextual"];
|
|
4
4
|
declare const ALLOWED_MEMORY_MODES: readonly ["none", "session", "relevant"];
|
|
5
5
|
export type SubAgentToolName = (typeof ALLOWED_SUB_AGENT_TOOLS)[number];
|
|
@@ -3,7 +3,7 @@ import { existsSync, readdirSync, readFileSync } from "fs";
|
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { findExactModelReferenceMatch, formatModelReference } from "../models/utils.js";
|
|
5
5
|
import { SUB_AGENTS_DIR_NAME } from "../paths.js";
|
|
6
|
-
const ALLOWED_SUB_AGENT_TOOLS = ["read", "bash", "edit", "write"];
|
|
6
|
+
const ALLOWED_SUB_AGENT_TOOLS = ["read", "bash", "edit", "write", "web_search", "web_fetch"];
|
|
7
7
|
const DEFAULT_SUB_AGENT_TOOLS = ["read", "bash"];
|
|
8
8
|
const DEFAULT_MAX_TURNS = 24;
|
|
9
9
|
const DEFAULT_MAX_TOOL_CALLS = 48;
|
package/dist/subagents/tool.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { Executor } from "../sandbox.js";
|
|
|
4
4
|
import type { SecurityConfig } from "../security/types.js";
|
|
5
5
|
import type { PipiclawMemoryRecallSettings } from "../settings.js";
|
|
6
6
|
import type { UsageTotals } from "../shared/types.js";
|
|
7
|
+
import type { PipiclawWebToolsConfig } from "../tools/config.js";
|
|
7
8
|
import { type ResolvedSubAgentConfig, type SubAgentDiscoveryResult } from "./discovery.js";
|
|
8
9
|
declare const subagentSchema: import("@sinclair/typebox").TObject<{
|
|
9
10
|
label: import("@sinclair/typebox").TString;
|
|
@@ -44,6 +45,7 @@ export interface SubAgentToolOptions {
|
|
|
44
45
|
getSubAgentDiscovery?: () => SubAgentDiscoveryResult;
|
|
45
46
|
getMemoryRecallSettings?: () => PipiclawMemoryRecallSettings;
|
|
46
47
|
securityConfig?: SecurityConfig;
|
|
48
|
+
webConfig?: PipiclawWebToolsConfig;
|
|
47
49
|
runtimeContext: {
|
|
48
50
|
workspacePath: string;
|
|
49
51
|
channelId: string;
|
package/dist/subagents/tool.js
CHANGED
|
@@ -11,6 +11,8 @@ import { clipText, extractAssistantText, extractLabelFromArgs, HAN_REGEX } from
|
|
|
11
11
|
import { createBashTool } from "../tools/bash.js";
|
|
12
12
|
import { createEditTool } from "../tools/edit.js";
|
|
13
13
|
import { createReadTool } from "../tools/read.js";
|
|
14
|
+
import { createWebFetchTool } from "../tools/web-fetch.js";
|
|
15
|
+
import { createWebSearchTool } from "../tools/web-search.js";
|
|
14
16
|
import { createWriteTool } from "../tools/write.js";
|
|
15
17
|
import { formatSubAgentList, resolveSubAgentConfig, validateSubAgentTask, } from "./discovery.js";
|
|
16
18
|
const subagentSchema = Type.Object({
|
|
@@ -118,6 +120,25 @@ function createToolSet(executor, bashTimeoutSec, options) {
|
|
|
118
120
|
}),
|
|
119
121
|
];
|
|
120
122
|
}
|
|
123
|
+
function createNamedToolSet(executor, bashTimeoutSec, options) {
|
|
124
|
+
const tools = createToolSet(executor, bashTimeoutSec, options);
|
|
125
|
+
const byName = Object.fromEntries(tools.map((tool) => [tool.name, tool]));
|
|
126
|
+
if (options.webConfig && options.webConfig.enable !== false) {
|
|
127
|
+
byName.web_search = createWebSearchTool({
|
|
128
|
+
webConfig: options.webConfig,
|
|
129
|
+
securityConfig: options.securityConfig ?? DEFAULT_SECURITY_CONFIG,
|
|
130
|
+
workspaceDir: options.workspaceDir,
|
|
131
|
+
channelId: options.runtimeContext.channelId,
|
|
132
|
+
});
|
|
133
|
+
byName.web_fetch = createWebFetchTool({
|
|
134
|
+
webConfig: options.webConfig,
|
|
135
|
+
securityConfig: options.securityConfig ?? DEFAULT_SECURITY_CONFIG,
|
|
136
|
+
workspaceDir: options.workspaceDir,
|
|
137
|
+
channelId: options.runtimeContext.channelId,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return byName;
|
|
141
|
+
}
|
|
121
142
|
function buildSubAgentTask(task, config, runtimeContext, contextBlocks) {
|
|
122
143
|
const taskText = task.trim();
|
|
123
144
|
const lines = [
|
|
@@ -288,17 +309,18 @@ export function createSubAgentTool(options) {
|
|
|
288
309
|
details: createDetails(config, usage, assistantTurns, toolCalls, Date.now() - startedAt, Boolean(failureReason), failureReason),
|
|
289
310
|
});
|
|
290
311
|
};
|
|
312
|
+
const availableTools = Object.values(createNamedToolSet(options.executor, config.bashTimeoutSec, options));
|
|
291
313
|
const worker = options.createWorker?.({
|
|
292
314
|
subAgent: config,
|
|
293
315
|
apiKey,
|
|
294
|
-
tools: filterToolsByName(
|
|
316
|
+
tools: filterToolsByName(availableTools, config.tools),
|
|
295
317
|
}) ??
|
|
296
318
|
new Agent({
|
|
297
319
|
initialState: {
|
|
298
320
|
systemPrompt: config.systemPrompt,
|
|
299
321
|
model: config.model,
|
|
300
322
|
thinkingLevel: "off",
|
|
301
|
-
tools: filterToolsByName(
|
|
323
|
+
tools: filterToolsByName(availableTools, config.tools),
|
|
302
324
|
},
|
|
303
325
|
convertToLlm,
|
|
304
326
|
getApiKey: async () => apiKey,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type WebSearchProvider = "brave" | "tavily" | "jina" | "searxng" | "duckduckgo";
|
|
2
|
+
export interface PipiclawWebSearchConfig {
|
|
3
|
+
provider: WebSearchProvider;
|
|
4
|
+
apiKey: string;
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
maxResults: number;
|
|
7
|
+
timeoutMs: number;
|
|
8
|
+
}
|
|
9
|
+
export interface PipiclawWebFetchConfig {
|
|
10
|
+
maxChars: number;
|
|
11
|
+
timeoutMs: number;
|
|
12
|
+
maxImageBytes: number;
|
|
13
|
+
preferJina: boolean;
|
|
14
|
+
enableJinaFallback: boolean;
|
|
15
|
+
defaultExtractMode: "markdown" | "text";
|
|
16
|
+
}
|
|
17
|
+
export interface PipiclawWebToolsConfig {
|
|
18
|
+
enable: boolean;
|
|
19
|
+
proxy: string | null;
|
|
20
|
+
search: PipiclawWebSearchConfig;
|
|
21
|
+
fetch: PipiclawWebFetchConfig;
|
|
22
|
+
}
|
|
23
|
+
export interface PipiclawToolsConfig {
|
|
24
|
+
tools: {
|
|
25
|
+
web: PipiclawWebToolsConfig;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export declare const DEFAULT_TOOLS_CONFIG: PipiclawToolsConfig;
|
|
29
|
+
export declare function getToolsConfigPath(appHomeDir?: string): string;
|
|
30
|
+
export declare function loadToolsConfig(appHomeDir?: string): PipiclawToolsConfig;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { APP_HOME_DIR, TOOLS_CONFIG_PATH } from "../paths.js";
|
|
4
|
+
import { isRecord } from "../shared/type-guards.js";
|
|
5
|
+
const WEB_SEARCH_PROVIDERS = ["brave", "tavily", "jina", "searxng", "duckduckgo"];
|
|
6
|
+
export const DEFAULT_TOOLS_CONFIG = {
|
|
7
|
+
tools: {
|
|
8
|
+
web: {
|
|
9
|
+
enable: false,
|
|
10
|
+
proxy: null,
|
|
11
|
+
search: {
|
|
12
|
+
provider: "brave",
|
|
13
|
+
apiKey: "",
|
|
14
|
+
baseUrl: "",
|
|
15
|
+
maxResults: 5,
|
|
16
|
+
timeoutMs: 30_000,
|
|
17
|
+
},
|
|
18
|
+
fetch: {
|
|
19
|
+
maxChars: 50_000,
|
|
20
|
+
timeoutMs: 30_000,
|
|
21
|
+
maxImageBytes: 10 * 1024 * 1024,
|
|
22
|
+
preferJina: false,
|
|
23
|
+
enableJinaFallback: false,
|
|
24
|
+
defaultExtractMode: "markdown",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
function clampInteger(value, fallback, minimum, maximum) {
|
|
30
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
31
|
+
return fallback;
|
|
32
|
+
}
|
|
33
|
+
const normalized = Math.floor(value);
|
|
34
|
+
if (normalized < minimum) {
|
|
35
|
+
return fallback;
|
|
36
|
+
}
|
|
37
|
+
if (maximum !== undefined && normalized > maximum) {
|
|
38
|
+
return fallback;
|
|
39
|
+
}
|
|
40
|
+
return normalized;
|
|
41
|
+
}
|
|
42
|
+
function asTrimmedString(value, fallback = "") {
|
|
43
|
+
return typeof value === "string" ? value.trim() : fallback;
|
|
44
|
+
}
|
|
45
|
+
function asOptionalProxy(value) {
|
|
46
|
+
if (value === null || value === undefined) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
if (typeof value !== "string") {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const trimmed = value.trim();
|
|
53
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
54
|
+
}
|
|
55
|
+
function mergeToolsConfig(source) {
|
|
56
|
+
if (!isRecord(source)) {
|
|
57
|
+
return DEFAULT_TOOLS_CONFIG;
|
|
58
|
+
}
|
|
59
|
+
const tools = isRecord(source.tools) ? source.tools : {};
|
|
60
|
+
const web = isRecord(tools.web) ? tools.web : {};
|
|
61
|
+
const search = isRecord(web.search) ? web.search : {};
|
|
62
|
+
const fetch = isRecord(web.fetch) ? web.fetch : {};
|
|
63
|
+
const providerValue = asTrimmedString(search.provider, DEFAULT_TOOLS_CONFIG.tools.web.search.provider).toLowerCase();
|
|
64
|
+
const provider = WEB_SEARCH_PROVIDERS.includes(providerValue)
|
|
65
|
+
? providerValue
|
|
66
|
+
: DEFAULT_TOOLS_CONFIG.tools.web.search.provider;
|
|
67
|
+
const defaultExtractMode = asTrimmedString(fetch.defaultExtractMode, DEFAULT_TOOLS_CONFIG.tools.web.fetch.defaultExtractMode);
|
|
68
|
+
return {
|
|
69
|
+
tools: {
|
|
70
|
+
web: {
|
|
71
|
+
enable: typeof web.enable === "boolean" ? web.enable : DEFAULT_TOOLS_CONFIG.tools.web.enable,
|
|
72
|
+
proxy: asOptionalProxy(web.proxy),
|
|
73
|
+
search: {
|
|
74
|
+
provider,
|
|
75
|
+
apiKey: asTrimmedString(search.apiKey),
|
|
76
|
+
baseUrl: asTrimmedString(search.baseUrl),
|
|
77
|
+
maxResults: clampInteger(search.maxResults, DEFAULT_TOOLS_CONFIG.tools.web.search.maxResults, 1, 10),
|
|
78
|
+
timeoutMs: clampInteger(search.timeoutMs, DEFAULT_TOOLS_CONFIG.tools.web.search.timeoutMs, 1),
|
|
79
|
+
},
|
|
80
|
+
fetch: {
|
|
81
|
+
maxChars: clampInteger(fetch.maxChars, DEFAULT_TOOLS_CONFIG.tools.web.fetch.maxChars, 100),
|
|
82
|
+
timeoutMs: clampInteger(fetch.timeoutMs, DEFAULT_TOOLS_CONFIG.tools.web.fetch.timeoutMs, 1),
|
|
83
|
+
maxImageBytes: clampInteger(fetch.maxImageBytes, DEFAULT_TOOLS_CONFIG.tools.web.fetch.maxImageBytes, 1),
|
|
84
|
+
preferJina: typeof fetch.preferJina === "boolean"
|
|
85
|
+
? fetch.preferJina
|
|
86
|
+
: DEFAULT_TOOLS_CONFIG.tools.web.fetch.preferJina,
|
|
87
|
+
enableJinaFallback: typeof fetch.enableJinaFallback === "boolean"
|
|
88
|
+
? fetch.enableJinaFallback
|
|
89
|
+
: DEFAULT_TOOLS_CONFIG.tools.web.fetch.enableJinaFallback,
|
|
90
|
+
defaultExtractMode: defaultExtractMode === "text" || defaultExtractMode === "markdown"
|
|
91
|
+
? defaultExtractMode
|
|
92
|
+
: DEFAULT_TOOLS_CONFIG.tools.web.fetch.defaultExtractMode,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export function getToolsConfigPath(appHomeDir = APP_HOME_DIR) {
|
|
99
|
+
return appHomeDir === APP_HOME_DIR ? TOOLS_CONFIG_PATH : join(appHomeDir, "tools.json");
|
|
100
|
+
}
|
|
101
|
+
export function loadToolsConfig(appHomeDir = APP_HOME_DIR) {
|
|
102
|
+
const configPath = getToolsConfigPath(appHomeDir);
|
|
103
|
+
if (!existsSync(configPath)) {
|
|
104
|
+
return DEFAULT_TOOLS_CONFIG;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
108
|
+
return mergeToolsConfig(raw);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
console.warn(`Failed to load tools config from ${configPath}: ${error}`);
|
|
112
|
+
return DEFAULT_TOOLS_CONFIG;
|
|
113
|
+
}
|
|
114
|
+
}
|
package/dist/tools/edit.js
CHANGED
|
@@ -3,7 +3,7 @@ import * as Diff from "diff";
|
|
|
3
3
|
import { DEFAULT_SECURITY_CONFIG } from "../security/config.js";
|
|
4
4
|
import { logSecurityEvent } from "../security/logger.js";
|
|
5
5
|
import { guardPath } from "../security/path-guard.js";
|
|
6
|
-
import {
|
|
6
|
+
import { shellEscapePath } from "../shared/shell-escape.js";
|
|
7
7
|
import { writeContent } from "./write-content.js";
|
|
8
8
|
/**
|
|
9
9
|
* Generate a unified diff string with line numbers and context
|
|
@@ -123,7 +123,7 @@ export function createEditTool(executor, options = {}) {
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
// Read the file
|
|
126
|
-
const readResult = await executor.exec(`cat ${
|
|
126
|
+
const readResult = await executor.exec(`cat ${shellEscapePath(path)}`, { signal });
|
|
127
127
|
if (readResult.code !== 0) {
|
|
128
128
|
throw new Error(readResult.stderr || `File not found: ${path}`);
|
|
129
129
|
}
|
package/dist/tools/index.js
CHANGED
|
@@ -2,8 +2,11 @@ import { APP_HOME_DIR } from "../paths.js";
|
|
|
2
2
|
import { loadSecurityConfig } from "../security/config.js";
|
|
3
3
|
import { createSubAgentTool } from "../subagents/tool.js";
|
|
4
4
|
import { createBashTool } from "./bash.js";
|
|
5
|
+
import { loadToolsConfig } from "./config.js";
|
|
5
6
|
import { createEditTool } from "./edit.js";
|
|
6
7
|
import { createReadTool } from "./read.js";
|
|
8
|
+
import { createWebFetchTool } from "./web-fetch.js";
|
|
9
|
+
import { createWebSearchTool } from "./web-search.js";
|
|
7
10
|
import { createWriteTool } from "./write.js";
|
|
8
11
|
export function createPipiclawBaseTools(executor, options = {}) {
|
|
9
12
|
const hasSecurityOptions = options.securityConfig || options.securityContext || options.channelId;
|
|
@@ -23,6 +26,7 @@ export function createPipiclawBaseTools(executor, options = {}) {
|
|
|
23
26
|
}
|
|
24
27
|
export function createPipiclawTools(options) {
|
|
25
28
|
const securityConfig = loadSecurityConfig(APP_HOME_DIR);
|
|
29
|
+
const toolsConfig = loadToolsConfig(APP_HOME_DIR);
|
|
26
30
|
const securityContext = {
|
|
27
31
|
workspaceDir: options.workspaceDir,
|
|
28
32
|
workspacePath: options.workspacePath,
|
|
@@ -33,8 +37,25 @@ export function createPipiclawTools(options) {
|
|
|
33
37
|
securityContext,
|
|
34
38
|
channelId: options.channelId,
|
|
35
39
|
});
|
|
40
|
+
const webTools = toolsConfig.tools.web.enable === false
|
|
41
|
+
? []
|
|
42
|
+
: [
|
|
43
|
+
createWebSearchTool({
|
|
44
|
+
webConfig: toolsConfig.tools.web,
|
|
45
|
+
securityConfig,
|
|
46
|
+
workspaceDir: options.workspaceDir,
|
|
47
|
+
channelId: options.channelId,
|
|
48
|
+
}),
|
|
49
|
+
createWebFetchTool({
|
|
50
|
+
webConfig: toolsConfig.tools.web,
|
|
51
|
+
securityConfig,
|
|
52
|
+
workspaceDir: options.workspaceDir,
|
|
53
|
+
channelId: options.channelId,
|
|
54
|
+
}),
|
|
55
|
+
];
|
|
36
56
|
return [
|
|
37
57
|
...baseTools,
|
|
58
|
+
...webTools,
|
|
38
59
|
createSubAgentTool({
|
|
39
60
|
executor: options.executor,
|
|
40
61
|
getCurrentModel: options.getCurrentModel,
|
|
@@ -45,6 +66,7 @@ export function createPipiclawTools(options) {
|
|
|
45
66
|
getSubAgentDiscovery: options.getSubAgentDiscovery,
|
|
46
67
|
getMemoryRecallSettings: options.getMemoryRecallSettings,
|
|
47
68
|
securityConfig,
|
|
69
|
+
webConfig: toolsConfig.tools.web,
|
|
48
70
|
runtimeContext: {
|
|
49
71
|
workspacePath: options.workspacePath,
|
|
50
72
|
channelId: options.channelId,
|
package/dist/tools/read.js
CHANGED
|
@@ -3,7 +3,7 @@ import { extname } from "path";
|
|
|
3
3
|
import { DEFAULT_SECURITY_CONFIG } from "../security/config.js";
|
|
4
4
|
import { logSecurityEvent } from "../security/logger.js";
|
|
5
5
|
import { guardPath } from "../security/path-guard.js";
|
|
6
|
-
import {
|
|
6
|
+
import { shellEscapePath, toShellPath } from "../shared/shell-escape.js";
|
|
7
7
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "./truncate.js";
|
|
8
8
|
/**
|
|
9
9
|
* Map of file extensions to MIME types for common image formats
|
|
@@ -70,7 +70,7 @@ export function createReadTool(executor, options = {}) {
|
|
|
70
70
|
const mimeType = isImageFile(path);
|
|
71
71
|
if (mimeType) {
|
|
72
72
|
// Read as image (binary) - use base64
|
|
73
|
-
const result = await executor.exec(`base64 < ${
|
|
73
|
+
const result = await executor.exec(`base64 < ${shellEscapePath(path)}`, { signal });
|
|
74
74
|
if (result.code !== 0) {
|
|
75
75
|
throw new Error(result.stderr || `Failed to read file: ${path}`);
|
|
76
76
|
}
|
|
@@ -84,7 +84,7 @@ export function createReadTool(executor, options = {}) {
|
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
86
|
// Get total line count first
|
|
87
|
-
const countResult = await executor.exec(`wc -l < ${
|
|
87
|
+
const countResult = await executor.exec(`wc -l < ${shellEscapePath(path)}`, { signal });
|
|
88
88
|
if (countResult.code !== 0) {
|
|
89
89
|
throw new Error(countResult.stderr || `Failed to read file: ${path}`);
|
|
90
90
|
}
|
|
@@ -99,10 +99,10 @@ export function createReadTool(executor, options = {}) {
|
|
|
99
99
|
// Read content with offset
|
|
100
100
|
let cmd;
|
|
101
101
|
if (startLine === 1) {
|
|
102
|
-
cmd = `cat ${
|
|
102
|
+
cmd = `cat ${shellEscapePath(path)}`;
|
|
103
103
|
}
|
|
104
104
|
else {
|
|
105
|
-
cmd = `tail -n +${startLine} ${
|
|
105
|
+
cmd = `tail -n +${startLine} ${shellEscapePath(path)}`;
|
|
106
106
|
}
|
|
107
107
|
const result = await executor.exec(cmd, { signal });
|
|
108
108
|
if (result.code !== 0) {
|
|
@@ -124,7 +124,7 @@ export function createReadTool(executor, options = {}) {
|
|
|
124
124
|
if (truncation.firstLineExceedsLimit) {
|
|
125
125
|
// First line at offset exceeds 50KB - tell model to use bash
|
|
126
126
|
const firstLineSize = formatSize(Buffer.byteLength(selectedContent.split("\n")[0], "utf-8"));
|
|
127
|
-
outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
|
|
127
|
+
outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${toShellPath(path)} | head -c ${DEFAULT_MAX_BYTES}]`;
|
|
128
128
|
details = { truncation };
|
|
129
129
|
}
|
|
130
130
|
else if (truncation.truncated) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AgentTool } from "@mariozechner/pi-agent-core";
|
|
2
|
+
import type { SecurityConfig } from "../security/types.js";
|
|
3
|
+
import type { PipiclawWebToolsConfig } from "./config.js";
|
|
4
|
+
declare const webFetchSchema: import("@sinclair/typebox").TObject<{
|
|
5
|
+
label: import("@sinclair/typebox").TString;
|
|
6
|
+
url: import("@sinclair/typebox").TString;
|
|
7
|
+
extractMode: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TUnion<[import("@sinclair/typebox").TLiteral<"markdown">, import("@sinclair/typebox").TLiteral<"text">]>>;
|
|
8
|
+
maxChars: import("@sinclair/typebox").TOptional<import("@sinclair/typebox").TNumber>;
|
|
9
|
+
}>;
|
|
10
|
+
export interface WebFetchToolOptions {
|
|
11
|
+
webConfig: PipiclawWebToolsConfig;
|
|
12
|
+
securityConfig: SecurityConfig;
|
|
13
|
+
workspaceDir: string;
|
|
14
|
+
channelId?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function createWebFetchTool(options: WebFetchToolOptions): AgentTool<typeof webFetchSchema>;
|
|
17
|
+
export {};
|