@trim21/personal-pi-extensions 0.1.555 → 0.1.557
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/package.json +1 -1
- package/src/aft/guidelines.md +3 -0
- package/src/aft/tools.ts +9 -1
- package/src/gh-readonly.ts +217 -7
- package/src/lib/{gh-proxy.ts → proxy.ts} +28 -25
- package/src/system-prompt/prompt.md +0 -1
- package/src/web/fetch.ts +130 -13
- package/vendor/mihomo-linux-x64 +0 -0
package/package.json
CHANGED
package/src/aft/tools.ts
CHANGED
|
@@ -41,6 +41,14 @@ const SEARCH_PROMPT = readFileSync(
|
|
|
41
41
|
fileURLToPath(new URL("search.md", import.meta.url)),
|
|
42
42
|
"utf8",
|
|
43
43
|
).trim();
|
|
44
|
+
/**
|
|
45
|
+
* 四个工具共用的使用准则,挂在结构入口 aft_outline 上注入一次:pi 的
|
|
46
|
+
* promptGuidelines 是 per-tool 的,而这条讲的是整个 AFT 工具集的分工。
|
|
47
|
+
*/
|
|
48
|
+
const AFT_GUIDELINES = readFileSync(
|
|
49
|
+
fileURLToPath(new URL("guidelines.md", import.meta.url)),
|
|
50
|
+
"utf8",
|
|
51
|
+
).trim();
|
|
44
52
|
|
|
45
53
|
export interface AftToolContext {
|
|
46
54
|
cwd: string;
|
|
@@ -124,7 +132,7 @@ export function registerOutlineTool(pi: ExtensionAPI, ctx: AftToolContext): void
|
|
|
124
132
|
"target 为目录时默认返回扁平文件树(语言、顶层符号数、字节大小);传 files: false 可改回符号大纲。",
|
|
125
133
|
].join("\n"),
|
|
126
134
|
promptSnippet: "Output structural outline of a file/directory",
|
|
127
|
-
promptGuidelines: [OUTLINE_PROMPT],
|
|
135
|
+
promptGuidelines: [OUTLINE_PROMPT, AFT_GUIDELINES],
|
|
128
136
|
parameters: OutlineParams,
|
|
129
137
|
async execute(_id, params, signal, _onUpdate, extCtx) {
|
|
130
138
|
const target = coerceTargetParam(params.target);
|
package/src/gh-readonly.ts
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* - read-github-repo: Get repo info
|
|
19
19
|
* - list-github-releases: List releases
|
|
20
20
|
* - read-github-release: Get release details
|
|
21
|
+
* - download-github-release-assets: Download a release's assets with gh credentials
|
|
21
22
|
* - wait-github-pr-checks: Watch PR CI checks
|
|
22
23
|
* - wait-github-commit-checks: Watch CI checks of a commit (no PR required)
|
|
23
24
|
* - watch-github-run: Watch a workflow run
|
|
@@ -29,14 +30,14 @@
|
|
|
29
30
|
* cp gh-readonly.ts .pi/extensions/
|
|
30
31
|
*
|
|
31
32
|
* Proxy (for the gh CLI and for the octokit-backed search/checks requests):
|
|
32
|
-
* ~/.pi/agent/
|
|
33
|
+
* ~/.pi/agent/proxy.json: { "proxy": "http://127.0.0.1:7890", "noProxy": "localhost" }
|
|
33
34
|
* HTTPS_PROXY / HTTP_PROXY / ALL_PROXY and NO_PROXY are used instead for the
|
|
34
35
|
* fields the config file leaves out. The config is read once per process.
|
|
35
36
|
*/
|
|
36
37
|
|
|
37
38
|
import { spawn } from "node:child_process";
|
|
38
39
|
import { existsSync } from "node:fs";
|
|
39
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
40
|
+
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
40
41
|
import { homedir } from "node:os";
|
|
41
42
|
import { delimiter, dirname, join } from "node:path";
|
|
42
43
|
|
|
@@ -44,7 +45,6 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
44
45
|
import { Type } from "typebox";
|
|
45
46
|
import { Value } from "typebox/value";
|
|
46
47
|
|
|
47
|
-
import { createGhProxy } from "./lib/gh-proxy.js";
|
|
48
48
|
import {
|
|
49
49
|
type ActionJob,
|
|
50
50
|
type CheckRun,
|
|
@@ -57,13 +57,14 @@ import {
|
|
|
57
57
|
type RunJob,
|
|
58
58
|
} from "./lib/github.js";
|
|
59
59
|
import { type ToolPendant } from "./lib/pendant.js";
|
|
60
|
+
import { createHttpProxy } from "./lib/proxy.js";
|
|
60
61
|
import { createSeqState } from "./lib/seq-state.js";
|
|
61
62
|
|
|
62
63
|
/**
|
|
63
|
-
* 代理配置(~/.pi/agent/
|
|
64
|
+
* 代理配置(~/.pi/agent/proxy.json,回退到 HTTP(S)_PROXY 环境变量)在本模块内共享:
|
|
64
65
|
* `gh` 子进程与 octokit 请求都从这里取,配置只在首次使用时读一次。
|
|
65
66
|
*/
|
|
66
|
-
const
|
|
67
|
+
const httpProxy = createHttpProxy();
|
|
67
68
|
|
|
68
69
|
interface GhResult {
|
|
69
70
|
stdout: string;
|
|
@@ -111,7 +112,7 @@ export function runGh(
|
|
|
111
112
|
shell: false,
|
|
112
113
|
stdio: ["ignore", "pipe", "pipe"],
|
|
113
114
|
// gh 是 Go 程序,只认环境变量形式的代理配置;ctx.env 最后合并,调用方可覆盖。
|
|
114
|
-
env: { ...process.env, ...
|
|
115
|
+
env: { ...process.env, ...httpProxy.env, ...ctx.env, GH_PAGER: "cat" },
|
|
115
116
|
});
|
|
116
117
|
|
|
117
118
|
let stdout = "";
|
|
@@ -302,6 +303,12 @@ const repoViewSchema = Type.Object({ nameWithOwner: Type.String() });
|
|
|
302
303
|
|
|
303
304
|
const prHeadSchema = Type.Object({ headRefOid: Type.String() });
|
|
304
305
|
|
|
306
|
+
/** `gh release view --json tagName,assets` 里本工具真正读取的字段。 */
|
|
307
|
+
const releaseViewSchema = Type.Object({
|
|
308
|
+
tagName: Type.String(),
|
|
309
|
+
assets: Type.Array(Type.Object({ name: Type.String(), size: Type.Number() })),
|
|
310
|
+
});
|
|
311
|
+
|
|
305
312
|
function truncate(
|
|
306
313
|
text: string,
|
|
307
314
|
maxLines = 2000,
|
|
@@ -444,6 +451,19 @@ export function jobLogPath(repo: string, runId: string, jobId: number): string {
|
|
|
444
451
|
return join(homedir(), ".cache", "pi", "github", "ci-logs", owner, name, runId, `${jobId}.log`);
|
|
445
452
|
}
|
|
446
453
|
|
|
454
|
+
/**
|
|
455
|
+
* Directory the release assets of one release are downloaded into:
|
|
456
|
+
* `~/.cache/pi/github/releases/<owner>/<repo>/<tag>/`.
|
|
457
|
+
*
|
|
458
|
+
* A tag is a git ref name and may contain `/`; only the characters that are safe
|
|
459
|
+
* in one path segment survive, so a tag can never escape its own directory.
|
|
460
|
+
*/
|
|
461
|
+
export function releaseAssetDir(repo: string, tag: string): string {
|
|
462
|
+
const { owner, repo: name } = splitRepo(repo);
|
|
463
|
+
const safeTag = tag.replaceAll(/[^A-Za-z0-9._+-]/g, "_").replace(/^\.+$/, "_");
|
|
464
|
+
return join(homedir(), ".cache", "pi", "github", "releases", owner, name, safeTag);
|
|
465
|
+
}
|
|
466
|
+
|
|
447
467
|
async function getJobLog(
|
|
448
468
|
job: RunJob,
|
|
449
469
|
signal: AbortSignal | undefined,
|
|
@@ -1163,6 +1183,13 @@ interface CommitChecksWaitParams {
|
|
|
1163
1183
|
fail_fast?: boolean;
|
|
1164
1184
|
}
|
|
1165
1185
|
|
|
1186
|
+
interface ReleaseDownloadParams {
|
|
1187
|
+
repo?: string;
|
|
1188
|
+
tag?: string;
|
|
1189
|
+
pattern?: string;
|
|
1190
|
+
archive?: "zip" | "tar.gz";
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1166
1193
|
/** What a toolcall handler receives from the framework. */
|
|
1167
1194
|
export interface ToolCall<Params> {
|
|
1168
1195
|
params: Params;
|
|
@@ -1185,7 +1212,7 @@ export class GhClient {
|
|
|
1185
1212
|
private readonly search: GithubSearch;
|
|
1186
1213
|
private readonly checks: GithubChecksClient;
|
|
1187
1214
|
|
|
1188
|
-
constructor(fetchImpl: typeof globalThis.fetch =
|
|
1215
|
+
constructor(fetchImpl: typeof globalThis.fetch = httpProxy.fetch) {
|
|
1189
1216
|
this.fetch = fetchImpl;
|
|
1190
1217
|
this.search = createGithubSearch({ fetch: fetchImpl });
|
|
1191
1218
|
this.checks = createGithubChecks({ fetch: fetchImpl });
|
|
@@ -1434,6 +1461,155 @@ export class GhClient {
|
|
|
1434
1461
|
}
|
|
1435
1462
|
}
|
|
1436
1463
|
|
|
1464
|
+
// ── release asset download ───────────────────────────────────────────────────
|
|
1465
|
+
|
|
1466
|
+
/** One regular file in a release's download directory. */
|
|
1467
|
+
interface ReleaseFile {
|
|
1468
|
+
name: string;
|
|
1469
|
+
path: string;
|
|
1470
|
+
bytes: number;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** Split the comma-separated `pattern` toolcall parameter into gh pattern values. */
|
|
1474
|
+
export function releasePatterns(pattern: string | undefined): string[] {
|
|
1475
|
+
if (pattern === undefined) return [];
|
|
1476
|
+
return pattern
|
|
1477
|
+
.split(",")
|
|
1478
|
+
.map((value) => value.trim())
|
|
1479
|
+
.filter((value) => value !== "");
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* The `gh release download` argv. `--skip-existing` is always on: the download
|
|
1484
|
+
* directory is a cache, and rewriting a file that is already there would pull
|
|
1485
|
+
* the ground out from under anything reading it.
|
|
1486
|
+
*/
|
|
1487
|
+
export function releaseDownloadArgs(options: {
|
|
1488
|
+
tag: string;
|
|
1489
|
+
repo: string;
|
|
1490
|
+
dir: string;
|
|
1491
|
+
patterns: readonly string[];
|
|
1492
|
+
archive?: "zip" | "tar.gz";
|
|
1493
|
+
}): string[] {
|
|
1494
|
+
const { tag, repo, dir, patterns, archive } = options;
|
|
1495
|
+
const args = ["release", "download", tag, ...repoArgs(repo)];
|
|
1496
|
+
if (archive !== undefined) args.push("--archive", archive);
|
|
1497
|
+
for (const pattern of patterns) args.push("--pattern", pattern);
|
|
1498
|
+
args.push("--dir", dir, "--skip-existing");
|
|
1499
|
+
return args;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
/** Regular files directly inside `dir`, with their sizes, sorted by name. */
|
|
1503
|
+
async function listReleaseFiles(dir: string): Promise<ReleaseFile[]> {
|
|
1504
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
1505
|
+
const files: ReleaseFile[] = [];
|
|
1506
|
+
for (const entry of entries) {
|
|
1507
|
+
if (!entry.isFile()) continue;
|
|
1508
|
+
const path = join(dir, entry.name);
|
|
1509
|
+
const info = await stat(path);
|
|
1510
|
+
files.push({ name: entry.name, path, bytes: info.size });
|
|
1511
|
+
}
|
|
1512
|
+
return files.toSorted((a, b) => a.name.localeCompare(b.name));
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
/**
|
|
1516
|
+
* `gh release download` answers this exact message when a `--pattern` matched no
|
|
1517
|
+
* asset. It is the only signal the CLI offers, so the enrichment below degrades
|
|
1518
|
+
* to gh's own error (still thrown) if the wording ever changes.
|
|
1519
|
+
*/
|
|
1520
|
+
const GH_NO_ASSET_MATCH = "no assets match the file pattern";
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* `download-github-release-assets`: fetch a release's assets (or source archive)
|
|
1524
|
+
* into `releaseAssetDir` with the gh credentials, so private repositories work
|
|
1525
|
+
* and the shell sandbox's network limits do not apply.
|
|
1526
|
+
*
|
|
1527
|
+
* The tag is resolved through `gh release view` before downloading: a tag that
|
|
1528
|
+
* does not exist and a pattern that matched nothing are different answers, and
|
|
1529
|
+
* the release's own asset names are what the model needs to fix the second one.
|
|
1530
|
+
*/
|
|
1531
|
+
export async function downloadReleaseAssets(
|
|
1532
|
+
call: ToolCall<ReleaseDownloadParams>,
|
|
1533
|
+
): Promise<ToolResult> {
|
|
1534
|
+
const { params, ctx, signal } = call;
|
|
1535
|
+
const patterns = releasePatterns(params.pattern);
|
|
1536
|
+
if (params.archive !== undefined && patterns.length > 0) {
|
|
1537
|
+
throw new Error(
|
|
1538
|
+
"pattern and archive are mutually exclusive (pick asset globs or the source archive)",
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
const effectiveRepo = await resolveRepo(params.repo, signal, ctx.cwd, params);
|
|
1543
|
+
const view = Value.Parse(
|
|
1544
|
+
releaseViewSchema,
|
|
1545
|
+
JSON.parse(
|
|
1546
|
+
await ghExec(
|
|
1547
|
+
[
|
|
1548
|
+
"release",
|
|
1549
|
+
"view",
|
|
1550
|
+
...(params.tag === undefined ? [] : [params.tag]),
|
|
1551
|
+
...repoArgs(effectiveRepo),
|
|
1552
|
+
"--json",
|
|
1553
|
+
"tagName,assets",
|
|
1554
|
+
],
|
|
1555
|
+
{ cwd: ctx.cwd, signal, input: params },
|
|
1556
|
+
),
|
|
1557
|
+
),
|
|
1558
|
+
);
|
|
1559
|
+
|
|
1560
|
+
const assetNames = view.assets.map((asset) => asset.name);
|
|
1561
|
+
const dir = releaseAssetDir(effectiveRepo, view.tagName);
|
|
1562
|
+
await mkdir(dir, { recursive: true });
|
|
1563
|
+
try {
|
|
1564
|
+
await ghExec(
|
|
1565
|
+
releaseDownloadArgs({
|
|
1566
|
+
tag: view.tagName,
|
|
1567
|
+
repo: effectiveRepo,
|
|
1568
|
+
dir,
|
|
1569
|
+
patterns,
|
|
1570
|
+
...(params.archive !== undefined && { archive: params.archive }),
|
|
1571
|
+
}),
|
|
1572
|
+
{ cwd: ctx.cwd, signal, input: params },
|
|
1573
|
+
);
|
|
1574
|
+
} catch (error) {
|
|
1575
|
+
// gh names the fault but not the choices; the release's asset list turns a
|
|
1576
|
+
// dead end into the next toolcall.
|
|
1577
|
+
if (error instanceof GhError && error.stderr.includes(GH_NO_ASSET_MATCH)) {
|
|
1578
|
+
throw new Error(
|
|
1579
|
+
`no asset of ${effectiveRepo}@${view.tagName} matched ${JSON.stringify(patterns)}; the release has: ${assetNames.join(", ") || "(no assets)"}`,
|
|
1580
|
+
{ cause: error },
|
|
1581
|
+
);
|
|
1582
|
+
}
|
|
1583
|
+
throw error;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
const files = await listReleaseFiles(dir);
|
|
1587
|
+
const payload = { repo: effectiveRepo, tag: view.tagName, dir, files };
|
|
1588
|
+
const pendant = subtitlePendant({ repo: effectiveRepo, tag: view.tagName }, "tag");
|
|
1589
|
+
|
|
1590
|
+
if (files.length === 0 && params.archive === undefined) {
|
|
1591
|
+
return {
|
|
1592
|
+
content: [
|
|
1593
|
+
{
|
|
1594
|
+
type: "text",
|
|
1595
|
+
text: `Nothing to download from ${effectiveRepo}@${view.tagName}: the release has no assets (try archive for the source tarball)`,
|
|
1596
|
+
},
|
|
1597
|
+
],
|
|
1598
|
+
details: {
|
|
1599
|
+
...payload,
|
|
1600
|
+
available_assets: assetNames,
|
|
1601
|
+
input: params,
|
|
1602
|
+
...(pendant && { pendant }),
|
|
1603
|
+
},
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
return {
|
|
1608
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
1609
|
+
details: { ...payload, input: params, ...(pendant && { pendant }) },
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1437
1613
|
// ── tools ────────────────────────────────────────────────────────────────────
|
|
1438
1614
|
|
|
1439
1615
|
export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
@@ -1853,6 +2029,40 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1853
2029
|
},
|
|
1854
2030
|
});
|
|
1855
2031
|
|
|
2032
|
+
// ── download-github-release-assets ────────────────────────────────────────
|
|
2033
|
+
pi.registerTool({
|
|
2034
|
+
name: "download-github-release-assets",
|
|
2035
|
+
label: "GitHub Release Download",
|
|
2036
|
+
description:
|
|
2037
|
+
"Download a GitHub release's assets (or its source archive) into " +
|
|
2038
|
+
"~/.cache/pi/github/releases/<owner>/<repo>/<tag>/ using the gh CLI's credentials, " +
|
|
2039
|
+
"so private repositories and large binaries work where a plain HTTP fetch cannot. " +
|
|
2040
|
+
"Files already in that directory are kept, never re-fetched. The result is the JSON " +
|
|
2041
|
+
"summary {repo, tag, dir, files:[{name, path, bytes}]} listing everything now in the " +
|
|
2042
|
+
"directory; file contents are not echoed. Read the entries you need from `path`.",
|
|
2043
|
+
promptSnippet: "Download GitHub release assets",
|
|
2044
|
+
parameters: Type.Object({
|
|
2045
|
+
repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
|
|
2046
|
+
tag: Type.Optional(
|
|
2047
|
+
Type.String({ description: "Release tag (defaults to the latest release)" }),
|
|
2048
|
+
),
|
|
2049
|
+
pattern: Type.Optional(
|
|
2050
|
+
Type.String({
|
|
2051
|
+
description:
|
|
2052
|
+
'Comma-separated glob patterns for asset names, e.g. "*.tar.gz,*.deb" (default: every asset)',
|
|
2053
|
+
}),
|
|
2054
|
+
),
|
|
2055
|
+
archive: Type.Optional(
|
|
2056
|
+
Type.Union([Type.Literal("zip"), Type.Literal("tar.gz")], {
|
|
2057
|
+
description: "Download the release's source archive instead of its assets",
|
|
2058
|
+
}),
|
|
2059
|
+
),
|
|
2060
|
+
}),
|
|
2061
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
2062
|
+
return downloadReleaseAssets({ params, ctx, signal });
|
|
2063
|
+
},
|
|
2064
|
+
});
|
|
2065
|
+
|
|
1856
2066
|
// ── wait-github-pr-checks ─────────────────────────────────────────────────
|
|
1857
2067
|
pi.registerTool({
|
|
1858
2068
|
name: "wait-github-pr-checks",
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* gh-readonly
|
|
2
|
+
* HTTP 代理层:gh-readonly 与 web_fetch 共用的出网配置。
|
|
3
3
|
*
|
|
4
4
|
* 配置来源(配置文件优先,未写的字段回退到环境变量):
|
|
5
|
-
* - ~/.pi/agent/
|
|
5
|
+
* - ~/.pi/agent/proxy.json: { "proxy": "http://127.0.0.1:7890", "noProxy": "localhost,.corp" }
|
|
6
6
|
* - HTTPS_PROXY / HTTP_PROXY / ALL_PROXY(小写变体同样接受)、NO_PROXY
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* 这份配置是全局出网代理,不专属 GitHub:gh 子进程、octokit 请求、web_fetch 都走它。
|
|
9
|
+
*
|
|
10
|
+
* 三条出口共用同一份配置,且在扩展加载时一次性读完:
|
|
9
11
|
* - gh CLI 子进程:env 给出要注入子进程的 HTTP(S)_PROXY / NO_PROXY 等变量
|
|
10
|
-
* - octokit 请求:fetch 是挂了代理 dispatcher 的 fetch
|
|
12
|
+
* - octokit 请求:fetch 是挂了代理 dispatcher 的 fetch(octokit 只认自定义 fetch)
|
|
13
|
+
* - web_fetch:同样用 fetch(Node 的全局 fetch 不认 HTTPS_PROXY 环境变量)
|
|
11
14
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
15
|
+
* 未配置代理时 fetch 就是全局 fetch。配置有错(JSON 语法错、字段类型不符、proxy 不是
|
|
16
|
+
* http(s) URL)直接抛错——扩展加载即失败,而不是带着一份被忽略的配置静默直连。
|
|
14
17
|
*/
|
|
15
18
|
|
|
16
19
|
import { readFileSync } from "node:fs";
|
|
@@ -22,25 +25,25 @@ import { EnvHttpProxyAgent } from "undici";
|
|
|
22
25
|
|
|
23
26
|
import { parseWithSchema } from "./parse-with-schema.js";
|
|
24
27
|
|
|
25
|
-
const
|
|
28
|
+
const proxyConfigSchema = Type.Object({
|
|
26
29
|
proxy: Type.Optional(Type.String()),
|
|
27
30
|
noProxy: Type.Optional(Type.String()),
|
|
28
31
|
});
|
|
29
32
|
|
|
30
|
-
export interface
|
|
33
|
+
export interface HttpProxySettings {
|
|
31
34
|
/** 代理 URL(http/https);undefined 表示不使用代理。 */
|
|
32
35
|
proxy?: string;
|
|
33
36
|
/** 不走代理的 host 列表(逗号分隔),语义同 NO_PROXY。 */
|
|
34
37
|
noProxy?: string;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
export function
|
|
38
|
-
return join(homedir(), ".pi", "agent", "
|
|
40
|
+
export function proxyConfigPath(): string {
|
|
41
|
+
return join(homedir(), ".pi", "agent", "proxy.json");
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
/** 解析
|
|
42
|
-
export function
|
|
43
|
-
const parsed = parseWithSchema(
|
|
44
|
+
/** 解析 proxy.json 的内容;字段类型不符时抛出带字段路径的错误。 */
|
|
45
|
+
export function parseProxyConfig(value: unknown): HttpProxySettings {
|
|
46
|
+
const parsed = parseWithSchema(proxyConfigSchema, value);
|
|
44
47
|
const proxy = parsed.proxy?.trim();
|
|
45
48
|
const noProxy = parsed.noProxy?.trim();
|
|
46
49
|
return { ...(proxy && { proxy }), ...(noProxy && { noProxy }) };
|
|
@@ -84,10 +87,10 @@ function normalizeProxy(value: string): string {
|
|
|
84
87
|
* 这里是仓库里允许的同步例外)。文件不存在 = 未配置;文件读不了、JSON 非法或
|
|
85
88
|
* 字段不符都直接抛。
|
|
86
89
|
*/
|
|
87
|
-
export function
|
|
88
|
-
configPath: string =
|
|
90
|
+
export function readProxySettings(
|
|
91
|
+
configPath: string = proxyConfigPath(),
|
|
89
92
|
env: NodeJS.ProcessEnv = process.env,
|
|
90
|
-
):
|
|
93
|
+
): HttpProxySettings {
|
|
91
94
|
let raw: string | undefined;
|
|
92
95
|
try {
|
|
93
96
|
raw = readFileSync(configPath, "utf8");
|
|
@@ -99,10 +102,10 @@ export function readGhProxySettings(
|
|
|
99
102
|
}
|
|
100
103
|
}
|
|
101
104
|
|
|
102
|
-
let file:
|
|
105
|
+
let file: HttpProxySettings = {};
|
|
103
106
|
if (raw !== undefined) {
|
|
104
107
|
try {
|
|
105
|
-
file =
|
|
108
|
+
file = parseProxyConfig(JSON.parse(raw));
|
|
106
109
|
} catch (error) {
|
|
107
110
|
throw new Error(`${configPath}: ${error instanceof Error ? error.message : String(error)}`, {
|
|
108
111
|
cause: error,
|
|
@@ -119,7 +122,7 @@ export function readGhProxySettings(
|
|
|
119
122
|
}
|
|
120
123
|
|
|
121
124
|
/** 要注入 gh 子进程的代理环境变量;未配置代理时为空对象(子进程继承父进程环境)。 */
|
|
122
|
-
export function proxyEnvVars(settings:
|
|
125
|
+
export function proxyEnvVars(settings: HttpProxySettings): NodeJS.ProcessEnv {
|
|
123
126
|
const { proxy, noProxy } = settings;
|
|
124
127
|
if (!proxy) return {};
|
|
125
128
|
return {
|
|
@@ -154,9 +157,9 @@ function createProxyDispatcher(
|
|
|
154
157
|
}) as unknown as NonNullable<RequestInit["dispatcher"]>;
|
|
155
158
|
}
|
|
156
159
|
|
|
157
|
-
export interface
|
|
160
|
+
export interface HttpProxy {
|
|
158
161
|
/** 生效的代理设置(配置文件与环境变量合并后的结果)。 */
|
|
159
|
-
readonly settings:
|
|
162
|
+
readonly settings: HttpProxySettings;
|
|
160
163
|
/** 要注入 gh 子进程的代理环境变量;未配置代理时为空对象。 */
|
|
161
164
|
readonly env: NodeJS.ProcessEnv;
|
|
162
165
|
/** 走代理的 fetch;未配置代理时就是全局 fetch。 */
|
|
@@ -168,11 +171,11 @@ export interface GhProxy {
|
|
|
168
171
|
* `globalThis.fetch` 本身:每次调用都取当前的全局 fetch,否则首个请求之后替换
|
|
169
172
|
* `globalThis.fetch`(插桩、测试替身)就不再生效。
|
|
170
173
|
*/
|
|
171
|
-
export function
|
|
172
|
-
configPath: string =
|
|
174
|
+
export function createHttpProxy(
|
|
175
|
+
configPath: string = proxyConfigPath(),
|
|
173
176
|
env: NodeJS.ProcessEnv = process.env,
|
|
174
|
-
):
|
|
175
|
-
const settings =
|
|
177
|
+
): HttpProxy {
|
|
178
|
+
const settings = readProxySettings(configPath, env);
|
|
176
179
|
const { proxy, noProxy } = settings;
|
|
177
180
|
let dispatcher: NonNullable<RequestInit["dispatcher"]> | undefined;
|
|
178
181
|
|
|
@@ -17,7 +17,6 @@ In addition to the tools above, you may have access to other custom tools depend
|
|
|
17
17
|
|
|
18
18
|
## Doing tasks
|
|
19
19
|
|
|
20
|
-
- When investigating a codebase, first use the AFT tools (aft_outline / aft_zoom / aft_callgraph / aft_search) to map the structure, inspect named symbols, and trace call relationships before falling back to grep or full-file reads — they are cheaper and purpose-built for this.
|
|
21
20
|
- The user will primarily request you to perform software engineering tasks. These may include solving bugs, adding new functionality, refactoring code, explaining code, and more. When given an unclear or generic instruction, consider it in the context of these software engineering tasks and the current working directory. For example, if the user asks you to change "methodName" to snake case, do not reply with just "method_name", instead find the method in the code and modify the code.
|
|
22
21
|
- You are highly capable and often allow users to complete ambitious tasks that would otherwise be too complex or take too long. You should defer to user judgement about whether a task is too large to attempt.
|
|
23
22
|
- For exploratory questions ("what could we do about X?", "how should we approach this?", "what do you think?"), respond in 2-3 sentences with a recommendation and the main tradeoff. Present it as something the user can redirect, not a decided plan. Don't implement until the user agrees.
|
package/src/web/fetch.ts
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `web_fetch` 工具:抓取 URL 并提取正文为 markdown
|
|
2
|
+
* `web_fetch` 工具:抓取 URL 并提取正文为 markdown,或按 `output_path` 原样落盘。
|
|
3
3
|
*
|
|
4
4
|
* SSRF 防护:DNS 预解析 + 拒绝私有/保留地址 + 每跳重定向重新校验,
|
|
5
5
|
* 防止把 agent 变成内网探测口。正文提取用 readability 主内容算法。
|
|
6
6
|
*
|
|
7
|
+
* 出网走 `src/lib/proxy.ts` 的代理层(~/.pi/agent/proxy.json,回退 HTTPS_PROXY 等环境
|
|
8
|
+
* 变量):Node 的全局 fetch 不认代理环境变量,GitHub 的用户附件、release 资产这类只在
|
|
9
|
+
* 代理可达的 host 上,必须从这里挂出去,否则沙箱内一律 fetch failed。
|
|
10
|
+
*
|
|
7
11
|
* 本文件是独立扩展入口(见 package.json 的 pi.extensions),可在配置里单独禁用。
|
|
8
12
|
*/
|
|
9
13
|
import { lookup } from "node:dns/promises";
|
|
14
|
+
import { mkdir, open, rm } from "node:fs/promises";
|
|
10
15
|
import { isIP } from "node:net";
|
|
16
|
+
import { dirname } from "node:path";
|
|
11
17
|
|
|
12
18
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
19
|
import { Readability } from "@mozilla/readability";
|
|
@@ -15,9 +21,17 @@ import { parseHTML } from "linkedom";
|
|
|
15
21
|
import TurndownService from "turndown";
|
|
16
22
|
import { Type } from "typebox";
|
|
17
23
|
|
|
24
|
+
import { resolvePathArg } from "../lib/path.js";
|
|
25
|
+
import { createHttpProxy } from "../lib/proxy.js";
|
|
26
|
+
import { guardWriteAccess } from "../lib/write-guard.js";
|
|
27
|
+
|
|
28
|
+
const httpProxy = createHttpProxy();
|
|
29
|
+
|
|
18
30
|
const MAX_REDIRECTS = 5;
|
|
19
31
|
const TIMEOUT_MS = 30_000;
|
|
20
32
|
const MAX_BYTES = 5 * 1024 * 1024;
|
|
33
|
+
/** 落盘模式的上限:附件、镜像、release 资产都比网页大得多,文本模式仍用 MAX_BYTES。 */
|
|
34
|
+
const MAX_FILE_BYTES = 200 * 1024 * 1024;
|
|
21
35
|
const MIN_USEFUL_CONTENT = 200;
|
|
22
36
|
const MAX_MARKDOWN_BYTES = 100 * 1024;
|
|
23
37
|
|
|
@@ -84,12 +98,20 @@ interface FetchedPage {
|
|
|
84
98
|
markdown: string;
|
|
85
99
|
}
|
|
86
100
|
|
|
87
|
-
/**
|
|
101
|
+
/** 落盘结果:最终 URL、本地路径、响应的 content-type 与写出的字节数。 */
|
|
102
|
+
interface SavedFile {
|
|
103
|
+
url: string;
|
|
104
|
+
filePath: string;
|
|
105
|
+
contentType: string;
|
|
106
|
+
bytes: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 手动跟随重定向,每跳重新做 SSRF 校验(防 DNS rebinding 简化处理)。 */
|
|
88
110
|
async function fetchWithRedirects(
|
|
89
111
|
url: URL,
|
|
90
112
|
signal: AbortSignal | undefined,
|
|
91
|
-
fetchFn: typeof fetch = fetch,
|
|
92
|
-
): Promise<Response> {
|
|
113
|
+
fetchFn: typeof fetch = httpProxy.fetch,
|
|
114
|
+
): Promise<{ response: Response; url: URL }> {
|
|
93
115
|
let current = url;
|
|
94
116
|
for (let redirects = 0; ; redirects++) {
|
|
95
117
|
await assertPublicHostname(current.hostname);
|
|
@@ -109,11 +131,19 @@ async function fetchWithRedirects(
|
|
|
109
131
|
}
|
|
110
132
|
continue;
|
|
111
133
|
}
|
|
112
|
-
return response;
|
|
134
|
+
return { response, url: current };
|
|
113
135
|
}
|
|
114
136
|
}
|
|
115
137
|
|
|
116
|
-
|
|
138
|
+
/** 已确认 2xx 的响应,连同手动重定向后跟踪到的最终地址。 */
|
|
139
|
+
interface OpenedResponse {
|
|
140
|
+
response: Response;
|
|
141
|
+
/** 手动重定向下 `response.url` 可能是空的,最终地址由重定向循环给出。 */
|
|
142
|
+
url: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 校验 URL、逐跳跟随重定向、要求 2xx;响应体怎么处理由调用方决定。 */
|
|
146
|
+
async function openResponse(url: string, signal?: AbortSignal): Promise<OpenedResponse> {
|
|
117
147
|
let target: URL;
|
|
118
148
|
try {
|
|
119
149
|
target = new URL(url);
|
|
@@ -124,10 +154,15 @@ export async function fetchPage(url: string, signal?: AbortSignal): Promise<Fetc
|
|
|
124
154
|
throw new Error(`只支持 http/https,收到: ${target.protocol}`);
|
|
125
155
|
}
|
|
126
156
|
|
|
127
|
-
const response = await fetchWithRedirects(target, signal);
|
|
157
|
+
const { response, url: finalUrl } = await fetchWithRedirects(target, signal);
|
|
128
158
|
if (!response.ok) {
|
|
129
159
|
throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
130
160
|
}
|
|
161
|
+
return { response, url: finalUrl.href };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function fetchPage(url: string, signal?: AbortSignal): Promise<FetchedPage> {
|
|
165
|
+
const { response, url: finalUrl } = await openResponse(url, signal);
|
|
131
166
|
const contentType = response.headers.get("content-type") ?? "";
|
|
132
167
|
const category = classifyContentType(contentType);
|
|
133
168
|
if (category === null) {
|
|
@@ -155,10 +190,58 @@ export async function fetchPage(url: string, signal?: AbortSignal): Promise<Fetc
|
|
|
155
190
|
}
|
|
156
191
|
|
|
157
192
|
if (category === "html") {
|
|
158
|
-
return extractMarkdown(body,
|
|
193
|
+
return extractMarkdown(body, finalUrl);
|
|
159
194
|
}
|
|
160
195
|
// JSON / XML / text/*:原样返回
|
|
161
|
-
return { url:
|
|
196
|
+
return { url: finalUrl, title: finalUrl, markdown: body.trim() };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 把响应体原样写进 `filePath`(二进制安全:不做 content-type 白名单、不解码、不转换),
|
|
201
|
+
* 用于 GitHub 用户附件、release 资产这类不能当正文读的下载。
|
|
202
|
+
* 任何一步失败都会删掉半成品,不留下看着完整其实截断的文件。
|
|
203
|
+
*/
|
|
204
|
+
export async function saveUrlToFile(
|
|
205
|
+
url: string,
|
|
206
|
+
filePath: string,
|
|
207
|
+
signal?: AbortSignal,
|
|
208
|
+
): Promise<SavedFile> {
|
|
209
|
+
const { response, url: finalUrl } = await openResponse(url, signal);
|
|
210
|
+
|
|
211
|
+
const declaredLength = Number(response.headers.get("content-length") ?? "0");
|
|
212
|
+
if (declaredLength > MAX_FILE_BYTES) {
|
|
213
|
+
throw new Error(`文件过大 (${declaredLength} bytes),上限 ${MAX_FILE_BYTES}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
217
|
+
const handle = await open(filePath, "w");
|
|
218
|
+
let bytes = 0;
|
|
219
|
+
try {
|
|
220
|
+
if (response.body) {
|
|
221
|
+
const reader = response.body.getReader();
|
|
222
|
+
for (;;) {
|
|
223
|
+
const chunk = (await reader.read()) as { done: boolean; value: Uint8Array };
|
|
224
|
+
if (chunk.done) break;
|
|
225
|
+
bytes += chunk.value.byteLength;
|
|
226
|
+
if (bytes > MAX_FILE_BYTES) {
|
|
227
|
+
throw new Error(`文件过大,上限 ${MAX_FILE_BYTES} bytes`);
|
|
228
|
+
}
|
|
229
|
+
await handle.write(chunk.value);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} catch (error) {
|
|
233
|
+
await handle.close();
|
|
234
|
+
await rm(filePath, { force: true });
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
await handle.close();
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
url: finalUrl,
|
|
241
|
+
filePath,
|
|
242
|
+
contentType: response.headers.get("content-type") ?? "",
|
|
243
|
+
bytes,
|
|
244
|
+
};
|
|
162
245
|
}
|
|
163
246
|
|
|
164
247
|
/** 按 mime 主体分类响应;html 走 readability,其余文本类原样返回 */
|
|
@@ -232,14 +315,48 @@ export default function webFetchTool(pi: ExtensionAPI): void {
|
|
|
232
315
|
label: "Web Fetch",
|
|
233
316
|
description:
|
|
234
317
|
"Fetch a URL and return its content as markdown (HTML pages) or raw text " +
|
|
235
|
-
"(JSON/XML/plain-text API responses).
|
|
236
|
-
"
|
|
237
|
-
|
|
318
|
+
"(JSON/XML/plain-text API responses). With output_path the body is saved to that " +
|
|
319
|
+
"file verbatim instead — any content type, no extraction, no truncation — and the " +
|
|
320
|
+
"result is the JSON summary {url, file_path, content_type, bytes} rather than the " +
|
|
321
|
+
"content; use it for images, logs and other attachments (GitHub user-attachments " +
|
|
322
|
+
"links from issue bodies, release assets, raw files). Give the file the extension " +
|
|
323
|
+
"matching the response's content_type: the Read tool decides image support by " +
|
|
324
|
+
"extension. Requests honour the proxy in ~/.pi/agent/proxy.json, so they reach hosts " +
|
|
325
|
+
"the shell sandbox blocks. SSRF-protected: refuses private/internal addresses.",
|
|
326
|
+
promptSnippet: "Fetch a web page, API response, or download a file",
|
|
238
327
|
parameters: Type.Object({
|
|
239
328
|
url: Type.String({ description: "The URL to fetch" }),
|
|
329
|
+
output_path: Type.Optional(
|
|
330
|
+
Type.String({
|
|
331
|
+
description:
|
|
332
|
+
"Save the response body to this path verbatim instead of returning it (absolute, or relative to the session cwd; ~ is expanded). Parent directories are created and an existing file is overwritten.",
|
|
333
|
+
}),
|
|
334
|
+
),
|
|
240
335
|
}),
|
|
241
|
-
async execute(_id, params, signal) {
|
|
336
|
+
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
337
|
+
const destination =
|
|
338
|
+
params.output_path === undefined ? undefined : resolvePathArg(ctx.cwd, params.output_path);
|
|
339
|
+
// 落盘位置的审批与写文件工具同一套:工作区与 /tmp 自动放行,其余问用户。
|
|
340
|
+
// 放在 try 外面,拒绝的原因(user deny)不该被改写成「抓取失败」。
|
|
341
|
+
if (destination !== undefined) {
|
|
342
|
+
await guardWriteAccess(ctx, { toolName: "web_fetch", absolutePath: destination });
|
|
343
|
+
}
|
|
344
|
+
|
|
242
345
|
try {
|
|
346
|
+
if (destination !== undefined) {
|
|
347
|
+
const file = await saveUrlToFile(params.url, destination, signal);
|
|
348
|
+
const payload = {
|
|
349
|
+
url: file.url,
|
|
350
|
+
file_path: file.filePath,
|
|
351
|
+
content_type: file.contentType,
|
|
352
|
+
bytes: file.bytes,
|
|
353
|
+
};
|
|
354
|
+
return {
|
|
355
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
356
|
+
details: payload,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
243
360
|
const page = await fetchPage(params.url, signal);
|
|
244
361
|
const { text, truncated } = truncateMarkdown(page.markdown);
|
|
245
362
|
const details: Record<string, unknown> = {
|
package/vendor/mihomo-linux-x64
CHANGED
|
Binary file
|