@trim21/personal-pi-extensions 0.1.551 → 0.1.553
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/bwrap/runtime.ts +78 -2
- package/src/claude-code/shell.ts +13 -6
- package/src/gh-readonly.ts +283 -201
- package/src/lib/gh-proxy.ts +54 -64
- package/src/opencode/bash.ts +18 -3
package/package.json
CHANGED
package/src/bwrap/runtime.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { type TObject, Type } from "typebox";
|
|
|
17
17
|
|
|
18
18
|
import { type CommandSpec, parseCommand } from "../lib/cli.js";
|
|
19
19
|
import { fenceCodeBlock } from "../lib/markdown.js";
|
|
20
|
-
import { formatDisplayPath } from "../lib/path.js";
|
|
20
|
+
import { formatDisplayPath, resolveHomePath } from "../lib/path.js";
|
|
21
21
|
import { type SelectAction, selectMultiple, selectWithOptionalInput } from "../lib/ui.js";
|
|
22
22
|
import { type ApprovalRule, evaluateBashApproval, matchRule } from "./approval-rules.js";
|
|
23
23
|
import { commandPatternsFor } from "./approval-suggest.js";
|
|
@@ -91,6 +91,8 @@ export interface BwrapExecutionRequest {
|
|
|
91
91
|
*/
|
|
92
92
|
export interface BwrapExecutionResult {
|
|
93
93
|
exitCode: number | null;
|
|
94
|
+
/** 只描述写边界(工作区外能否写)与网络层级(关 / 白名单 / 放开),不含具体域名;未沙箱执行时为 undefined。 */
|
|
95
|
+
sandboxHint: string | undefined;
|
|
94
96
|
/** 截断后的输出(尾部),未截断时为完整输出;空输出为空字符串。 */
|
|
95
97
|
output: string;
|
|
96
98
|
/** 完整输出的文件路径;无输出时不存在。 */
|
|
@@ -112,16 +114,19 @@ export interface BashExecutionPartial {
|
|
|
112
114
|
export class BashInterruptedError extends Error {
|
|
113
115
|
readonly kind: "timeout" | "aborted";
|
|
114
116
|
readonly partial: BashExecutionPartial;
|
|
117
|
+
readonly sandboxHint: string | undefined;
|
|
115
118
|
|
|
116
119
|
constructor(
|
|
117
120
|
kind: "timeout" | "aborted",
|
|
118
121
|
message: string,
|
|
119
122
|
partial: BashExecutionPartial,
|
|
123
|
+
sandboxHint: string | undefined,
|
|
120
124
|
cause: unknown,
|
|
121
125
|
) {
|
|
122
126
|
super(message, { cause });
|
|
123
127
|
this.kind = kind;
|
|
124
128
|
this.partial = partial;
|
|
129
|
+
this.sandboxHint = sandboxHint;
|
|
125
130
|
// 对齐标准错误分类:中断=AbortError(用户取消),超时=TimeoutError
|
|
126
131
|
this.name = kind === "aborted" ? "AbortError" : "TimeoutError";
|
|
127
132
|
}
|
|
@@ -255,6 +260,67 @@ function notifyMode(
|
|
|
255
260
|
ctx.ui.notify(labels[mode], "info");
|
|
256
261
|
}
|
|
257
262
|
|
|
263
|
+
/** 沙箱内允许写入的根目录(与沙箱层同基准:相对 workspace 解析 "."、`~` 与相对路径)。 */
|
|
264
|
+
function writableRoots(resolved: ResolvedBwrap, workspace: string): string[] {
|
|
265
|
+
const writable = [...resolved.writablePaths, ...resolved.extraWritablePaths].map((path) =>
|
|
266
|
+
resolveHomePath(path, workspace),
|
|
267
|
+
);
|
|
268
|
+
return [...new Set(writable)];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** 网络层级:关 / 只放行白名单 / 完全放开(白名单域名本身不列出,对判断失败无用)。 */
|
|
272
|
+
function describeNetwork(resolved: ResolvedBwrap): string {
|
|
273
|
+
if (!resolved.network) return "network access is off";
|
|
274
|
+
if (resolved.networkAllowlist.length > 0)
|
|
275
|
+
return "network access is limited to allowlisted addresses";
|
|
276
|
+
return "network access is unrestricted";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface SandboxHintInput {
|
|
280
|
+
/** writablePaths 中 "." 的解析基准,也是可写根目录的显示基准。 */
|
|
281
|
+
workspace: string;
|
|
282
|
+
/** 本次命令是否绕过沙箱(Windows、审批通过的全权限、allow-all)。 */
|
|
283
|
+
unsandboxed: boolean;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 命令没经沙箱时沿用 prompt 里的说法,给出在沙盒外重跑的手段。 */
|
|
287
|
+
const SANDBOX_ESCAPE_HATCH =
|
|
288
|
+
"[Sandbox] If the command needs more than that, use the `dangerouslyDisableSandbox` parameter to request unsandboxed execution; the user must approve this request.";
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 命令失败时附在错误文本后的沙箱状态:写边界(可写根目录、.git 只读)+ 网络层级,
|
|
292
|
+
* 以及在沙盒外重跑的手段。只报层级不列白名单域名。
|
|
293
|
+
* 命令没经沙箱(allow-all、审批通过的全权限、Windows)时返回 undefined:
|
|
294
|
+
* 没有沙箱就没什么可提示的。
|
|
295
|
+
*/
|
|
296
|
+
export function describeSandbox(
|
|
297
|
+
resolved: ResolvedBwrap,
|
|
298
|
+
input: SandboxHintInput,
|
|
299
|
+
): string | undefined {
|
|
300
|
+
if (input.unsandboxed) return undefined;
|
|
301
|
+
const roots = writableRoots(resolved, input.workspace);
|
|
302
|
+
const clauses = [
|
|
303
|
+
roots.length === 0
|
|
304
|
+
? "the filesystem is read-only"
|
|
305
|
+
: `writes are limited to ${roots.map((path) => formatDisplayPath(input.workspace, path)).join(", ")}`,
|
|
306
|
+
// .git 只读保护只在工作区本身可写时才有区分度(readonly 下整个文件系统都不可写)
|
|
307
|
+
...(roots.includes(input.workspace) ? [".git is read-only"] : []),
|
|
308
|
+
describeNetwork(resolved),
|
|
309
|
+
];
|
|
310
|
+
return [
|
|
311
|
+
`[Sandbox] This command ran in a sandbox: ${clauses.join("; ")}.`,
|
|
312
|
+
SANDBOX_ESCAPE_HATCH,
|
|
313
|
+
].join("\n");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 把沙箱状态拼到失败文本后(未沙箱执行时保持原文)。失败文本常以换行结尾,
|
|
318
|
+
* 先 trimEnd 保证状态与命令输出之间只有一个空行。
|
|
319
|
+
*/
|
|
320
|
+
export function appendSandboxHint(text: string, hint: string | undefined): string {
|
|
321
|
+
return hint === undefined ? text : `${text.trimEnd()}\n\n${hint}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
258
324
|
export class BwrapRuntime {
|
|
259
325
|
private resolved: ResolvedBwrap | undefined;
|
|
260
326
|
private sandboxDisabled = false;
|
|
@@ -383,6 +449,8 @@ export class BwrapRuntime {
|
|
|
383
449
|
runtime.mihomoPath ??= this.mihomoPath ?? findMihomo();
|
|
384
450
|
this.mihomoPath = runtime.mihomoPath;
|
|
385
451
|
}
|
|
452
|
+
// 命令失败时附带的沙箱状态(写边界 + 网络层级),由上层拼进错误文本
|
|
453
|
+
const sandboxHint = describeSandbox(runtime, { workspace, unsandboxed: local });
|
|
386
454
|
await using output = new BashOutput(request.ctx.sessionManager.getSessionId());
|
|
387
455
|
const { onUpdate } = request;
|
|
388
456
|
|
|
@@ -415,6 +483,7 @@ export class BwrapRuntime {
|
|
|
415
483
|
const partial = await this.finalizeOutput(output);
|
|
416
484
|
return {
|
|
417
485
|
exitCode,
|
|
486
|
+
sandboxHint,
|
|
418
487
|
output: partial.output,
|
|
419
488
|
...(partial.fullOutputPath && { fullOutputPath: partial.fullOutputPath }),
|
|
420
489
|
truncation: partial.truncation,
|
|
@@ -433,6 +502,7 @@ export class BwrapRuntime {
|
|
|
433
502
|
"timeout",
|
|
434
503
|
`Command timed out after ${error.message.slice("timeout:".length)} seconds`,
|
|
435
504
|
partial,
|
|
505
|
+
sandboxHint,
|
|
436
506
|
error,
|
|
437
507
|
);
|
|
438
508
|
}
|
|
@@ -440,7 +510,13 @@ export class BwrapRuntime {
|
|
|
440
510
|
// 兼容 pi local ops 抛的 new Error("aborted")
|
|
441
511
|
if (error instanceof Error && (error.name === "AbortError" || error.message === "aborted")) {
|
|
442
512
|
const partial = await this.finalizeOutput(output);
|
|
443
|
-
throw new BashInterruptedError(
|
|
513
|
+
throw new BashInterruptedError(
|
|
514
|
+
"aborted",
|
|
515
|
+
"Command aborted by user",
|
|
516
|
+
partial,
|
|
517
|
+
sandboxHint,
|
|
518
|
+
error,
|
|
519
|
+
);
|
|
444
520
|
}
|
|
445
521
|
throw error;
|
|
446
522
|
} finally {
|
package/src/claude-code/shell.ts
CHANGED
|
@@ -10,7 +10,12 @@ import {
|
|
|
10
10
|
} from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { Type } from "typebox";
|
|
12
12
|
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
appendSandboxHint,
|
|
15
|
+
BashInterruptedError,
|
|
16
|
+
type BwrapRuntime,
|
|
17
|
+
createBwrapRuntime,
|
|
18
|
+
} from "../bwrap/runtime.js";
|
|
14
19
|
import { resolveWorkdir } from "../lib/path.js";
|
|
15
20
|
|
|
16
21
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
@@ -151,10 +156,10 @@ export function registerShellTools(
|
|
|
151
156
|
const full = text ? `${text}\n\nCommand aborted by user` : "Command aborted by user";
|
|
152
157
|
return { content: [{ type: "text", text: full }], details: undefined };
|
|
153
158
|
}
|
|
154
|
-
const
|
|
159
|
+
const status = text
|
|
155
160
|
? `${text}\n\nCommand timed out after ${timeout} milliseconds`
|
|
156
161
|
: `Command timed out after ${timeout} milliseconds`;
|
|
157
|
-
throw new Error(
|
|
162
|
+
throw new Error(appendSandboxHint(status, error.sandboxHint), { cause: error });
|
|
158
163
|
}
|
|
159
164
|
throw error;
|
|
160
165
|
}
|
|
@@ -165,9 +170,11 @@ export function registerShellTools(
|
|
|
165
170
|
const full = result.fullOutputPath
|
|
166
171
|
? await readFile(result.fullOutputPath, "utf8")
|
|
167
172
|
: result.output;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
173
|
+
// 失败时附带沙箱状态:命令可能是被沙箱的写边界或网络限制挡住的
|
|
174
|
+
throw new Error(
|
|
175
|
+
appendSandboxHint(formatBashError(result.exitCode, full), result.sandboxHint),
|
|
176
|
+
{ cause: result },
|
|
177
|
+
);
|
|
171
178
|
}
|
|
172
179
|
return formatBashSuccess(result);
|
|
173
180
|
},
|
package/src/gh-readonly.ts
CHANGED
|
@@ -95,7 +95,7 @@ export function isGhAvailable(): boolean {
|
|
|
95
95
|
return false;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
export
|
|
98
|
+
export function runGh(
|
|
99
99
|
args: string[],
|
|
100
100
|
ctx: {
|
|
101
101
|
cwd?: string;
|
|
@@ -105,15 +105,13 @@ export async function runGh(
|
|
|
105
105
|
env?: NodeJS.ProcessEnv;
|
|
106
106
|
},
|
|
107
107
|
): Promise<GhResult> {
|
|
108
|
-
const proxyEnv = await ghProxy.env();
|
|
109
|
-
|
|
110
108
|
return new Promise((resolve) => {
|
|
111
109
|
const proc = spawn("gh", args, {
|
|
112
110
|
cwd: ctx.cwd,
|
|
113
111
|
shell: false,
|
|
114
112
|
stdio: ["ignore", "pipe", "pipe"],
|
|
115
113
|
// gh 是 Go 程序,只认环境变量形式的代理配置;ctx.env 最后合并,调用方可覆盖。
|
|
116
|
-
env: { ...process.env, ...
|
|
114
|
+
env: { ...process.env, ...ghProxy.env, ...ctx.env, GH_PAGER: "cat" },
|
|
117
115
|
});
|
|
118
116
|
|
|
119
117
|
let stdout = "";
|
|
@@ -349,16 +347,14 @@ function toToolResult(
|
|
|
349
347
|
* tool's id parameter, e.g. `repo=x/y number=123`. Returns undefined when
|
|
350
348
|
* neither is available, so the pendant is omitted rather than shown empty.
|
|
351
349
|
*/
|
|
352
|
-
function subtitlePendant(
|
|
353
|
-
params: { repo?: string } & Record<
|
|
354
|
-
idKey?:
|
|
350
|
+
function subtitlePendant<IdKey extends string = never>(
|
|
351
|
+
params: { repo?: string } & Partial<Record<IdKey, string | number>>,
|
|
352
|
+
idKey?: IdKey,
|
|
355
353
|
): ToolPendant | undefined {
|
|
356
354
|
const parts: string[] = [];
|
|
357
355
|
if (params.repo) parts.push(`repo=${params.repo}`);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
if (typeof id === "string" || typeof id === "number") parts.push(`${idKey}=${id}`);
|
|
361
|
-
}
|
|
356
|
+
const id = idKey === undefined ? undefined : params[idKey];
|
|
357
|
+
if (typeof id === "string" || typeof id === "number") parts.push(`${idKey}=${id}`);
|
|
362
358
|
if (parts.length === 0) return undefined;
|
|
363
359
|
return { subtitle: parts.join(" ") };
|
|
364
360
|
}
|
|
@@ -756,7 +752,7 @@ export function extractStepFromLog(
|
|
|
756
752
|
|
|
757
753
|
// ── ci-logs rendering (pure, testable) ──────────────────────────────────────
|
|
758
754
|
|
|
759
|
-
export interface
|
|
755
|
+
export interface ToolResult {
|
|
760
756
|
content: { type: "text"; text: string }[];
|
|
761
757
|
details: Record<string, unknown>;
|
|
762
758
|
}
|
|
@@ -978,7 +974,7 @@ export interface PollPrChecksOptions {
|
|
|
978
974
|
/** Test overrides. */
|
|
979
975
|
intervalMs?: number;
|
|
980
976
|
deadlineMs?: number;
|
|
981
|
-
onUpdate?: (msg:
|
|
977
|
+
onUpdate?: (msg: ToolResult) => void;
|
|
982
978
|
}
|
|
983
979
|
|
|
984
980
|
/**
|
|
@@ -1136,45 +1132,243 @@ export function renderChecksVerdict(options: {
|
|
|
1136
1132
|
};
|
|
1137
1133
|
}
|
|
1138
1134
|
|
|
1139
|
-
// ──
|
|
1135
|
+
// ── GitHub REST client ───────────────────────────────────────────────────────
|
|
1140
1136
|
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1137
|
+
/** Toolcall input for the handlers that read through the REST API. */
|
|
1138
|
+
interface PrStatusParams {
|
|
1139
|
+
number: number | string;
|
|
1140
|
+
repo?: string;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
interface RunIdParams {
|
|
1144
|
+
run_id: number | string;
|
|
1145
|
+
repo?: string;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
interface JobIdParams {
|
|
1149
|
+
job_id: number | string;
|
|
1150
|
+
repo?: string;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
interface PrChecksWaitParams {
|
|
1154
|
+
number: number | string;
|
|
1155
|
+
repo?: string;
|
|
1156
|
+
fail_fast?: boolean;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
interface CommitChecksWaitParams {
|
|
1160
|
+
commit: number | string;
|
|
1161
|
+
repo?: string;
|
|
1162
|
+
event?: string;
|
|
1163
|
+
fail_fast?: boolean;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/** What a toolcall handler receives from the framework. */
|
|
1167
|
+
export interface ToolCall<Params> {
|
|
1168
|
+
params: Params;
|
|
1169
|
+
ctx: { cwd?: string };
|
|
1170
|
+
signal?: AbortSignal;
|
|
1171
|
+
/** Streaming progress updates, passed through as-is. */
|
|
1172
|
+
onUpdate?: (update: ToolResult) => void;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* The GitHub reads that go through the REST API, with the HTTP layer injected:
|
|
1177
|
+
* production hands in the proxy-aware fetch, tests hand in a stub and never
|
|
1178
|
+
* touch the network. Handlers that only shell out to `gh` stay plain functions.
|
|
1179
|
+
*
|
|
1180
|
+
* `fetch` is a property (not module state) so a caller that needs different HTTP
|
|
1181
|
+
* behavior — a test, another host — constructs its own instance.
|
|
1182
|
+
*/
|
|
1183
|
+
export class GhClient {
|
|
1184
|
+
readonly fetch: typeof globalThis.fetch;
|
|
1185
|
+
private readonly search: GithubSearch;
|
|
1186
|
+
private readonly checks: GithubChecksClient;
|
|
1187
|
+
|
|
1188
|
+
constructor(fetchImpl: typeof globalThis.fetch = ghProxy.fetch) {
|
|
1189
|
+
this.fetch = fetchImpl;
|
|
1190
|
+
this.search = createGithubSearch({ fetch: fetchImpl });
|
|
1191
|
+
this.checks = createGithubChecks({ fetch: fetchImpl });
|
|
1149
1192
|
}
|
|
1150
1193
|
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
return;
|
|
1194
|
+
/** `list-github-issues` / `list-github-prs`: browse through `gh`, search through the API. */
|
|
1195
|
+
private async list(kind: "issue" | "pr", call: ToolCall<ListFilters>): Promise<ToolResult> {
|
|
1196
|
+
const { params, ctx, signal } = call;
|
|
1197
|
+
const result = toToolResult(
|
|
1198
|
+
params.keywords
|
|
1199
|
+
? await searchList(kind, params, this.search)
|
|
1200
|
+
: await listGithub(kind, params, { cwd: ctx.cwd, signal, input: params }),
|
|
1201
|
+
params,
|
|
1202
|
+
);
|
|
1203
|
+
result.details.pendant = subtitlePendant(params);
|
|
1204
|
+
return result;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
listIssues(call: ToolCall<ListFilters>): Promise<ToolResult> {
|
|
1208
|
+
return this.list("issue", call);
|
|
1162
1209
|
}
|
|
1163
1210
|
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1211
|
+
listPrs(call: ToolCall<ListFilters>): Promise<ToolResult> {
|
|
1212
|
+
return this.list("pr", call);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/**
|
|
1216
|
+
* `read-github-pr-status`: the PR head commit's checks as a snapshot. Same read
|
|
1217
|
+
* path as the wait tools (octokit), but it never polls — pending checks come
|
|
1218
|
+
* back as-is.
|
|
1219
|
+
*/
|
|
1220
|
+
async prStatus(call: ToolCall<PrStatusParams>): Promise<ToolResult> {
|
|
1221
|
+
const { params, ctx, signal } = call;
|
|
1222
|
+
const { number, repo } = params;
|
|
1223
|
+
const pullNumber = toPositiveId(number, "number");
|
|
1224
|
+
const pendant = subtitlePendant(params, "number");
|
|
1225
|
+
const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
|
|
1226
|
+
const { owner, repo: name } = splitRepo(effectiveRepo);
|
|
1227
|
+
|
|
1228
|
+
const pollSignal = signal ?? new AbortController().signal;
|
|
1229
|
+
const headSha = await this.checks.pullHead(owner, name, pullNumber, pollSignal);
|
|
1230
|
+
const [statuses, checkRuns] = await Promise.all([
|
|
1231
|
+
this.checks.statuses(owner, name, headSha, pollSignal),
|
|
1232
|
+
this.checks.checkRuns(owner, name, headSha, pollSignal),
|
|
1233
|
+
]);
|
|
1234
|
+
const checks = mergeChecks(statuses, checkRuns).map((check) => ({
|
|
1235
|
+
name: check.name,
|
|
1236
|
+
bucket: check.bucket,
|
|
1237
|
+
event: check.event,
|
|
1238
|
+
run_id: check.runId,
|
|
1239
|
+
job_id: check.jobId,
|
|
1240
|
+
url: check.link,
|
|
1241
|
+
}));
|
|
1242
|
+
|
|
1243
|
+
const payload = { pr: pullNumber, repo: effectiveRepo, head_sha: headSha, checks };
|
|
1244
|
+
return {
|
|
1245
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
1246
|
+
details: { ...payload, input: params, ...(pendant && { pendant }) },
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/** `get-github-workflow-jobs`: every job of a run, all pages. */
|
|
1251
|
+
async workflowJobs(call: ToolCall<RunIdParams>): Promise<ToolResult> {
|
|
1252
|
+
const { params, ctx, signal } = call;
|
|
1253
|
+
const runId = toPositiveId(params.run_id, "run_id");
|
|
1254
|
+
const effectiveRepo = await resolveRepo(params.repo, signal, ctx.cwd, params);
|
|
1255
|
+
const { owner, repo: name } = splitRepo(effectiveRepo);
|
|
1256
|
+
|
|
1257
|
+
const jobs = await this.checks.runJobs(owner, name, runId, signal);
|
|
1258
|
+
const result = toToolResult(JSON.stringify({ total_count: jobs.length, jobs }), params);
|
|
1259
|
+
result.details.pendant = subtitlePendant(params, "run_id");
|
|
1260
|
+
return result;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/** `read-github-ci-logs`: one job's raw log on disk plus its step line ranges. */
|
|
1264
|
+
async ciLogs(call: ToolCall<JobIdParams>): Promise<ToolResult> {
|
|
1265
|
+
const { params, ctx, signal, onUpdate } = call;
|
|
1266
|
+
const { job_id, repo } = params;
|
|
1267
|
+
const jobId = toPositiveId(job_id, "job_id");
|
|
1268
|
+
|
|
1269
|
+
const pendant = subtitlePendant(params, "job_id");
|
|
1270
|
+
const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
|
|
1271
|
+
const { owner, repo: name } = splitRepo(effectiveRepo);
|
|
1272
|
+
|
|
1273
|
+
const failure = (text: string): ToolResult => ({
|
|
1274
|
+
content: [{ type: "text", text }],
|
|
1275
|
+
details: { input: params, ...(pendant && { pendant }) },
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
let target: RunJob;
|
|
1169
1279
|
try {
|
|
1170
|
-
|
|
1171
|
-
} catch {
|
|
1172
|
-
|
|
1280
|
+
target = await this.checks.job(owner, name, jobId, signal);
|
|
1281
|
+
} catch (error) {
|
|
1282
|
+
const status = (error as { status?: number }).status;
|
|
1283
|
+
if (status !== 404) throw error;
|
|
1284
|
+
return failure(
|
|
1285
|
+
`Job ${jobId} not found in ${effectiveRepo} — job IDs come from \`get-github-workflow-jobs\`.`,
|
|
1286
|
+
);
|
|
1173
1287
|
}
|
|
1174
|
-
});
|
|
1175
1288
|
|
|
1176
|
-
|
|
1177
|
-
|
|
1289
|
+
if (target.status === "queued") {
|
|
1290
|
+
return failure(
|
|
1291
|
+
`Job "${target.name}" is still queued — no logs available yet. Use \`watch-github-run\` to wait for it to start, then retry.`,
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
onUpdate?.({
|
|
1296
|
+
content: [{ type: "text", text: `Fetching log of job "${target.name}"...` }],
|
|
1297
|
+
details: {},
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
const rawLog = await getJobLog(target, signal, ctx.cwd, params);
|
|
1301
|
+
const index = jobLogIndex(target, rawLog);
|
|
1302
|
+
|
|
1303
|
+
return {
|
|
1304
|
+
content: [{ type: "text", text: JSON.stringify(index, null, 2) }],
|
|
1305
|
+
details: { ...index, input: params, ...(pendant && { pendant }) },
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/** `wait-github-pr-checks`: poll the PR's head commit checks until they settle. */
|
|
1310
|
+
async waitPrChecks(call: ToolCall<PrChecksWaitParams>): Promise<ToolResult> {
|
|
1311
|
+
const { params, ctx, signal, onUpdate } = call;
|
|
1312
|
+
const { number, repo, fail_fast } = params;
|
|
1313
|
+
|
|
1314
|
+
const pendant = subtitlePendant(params, "number");
|
|
1315
|
+
onUpdate?.({
|
|
1316
|
+
content: [{ type: "text", text: `Watching CI checks for PR #${number}...` }],
|
|
1317
|
+
details: {},
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
|
|
1321
|
+
const { owner, repo: repoName } = splitRepo(effectiveRepo);
|
|
1322
|
+
|
|
1323
|
+
const prOut = await ghExec(
|
|
1324
|
+
["pr", "view", String(number), "--repo", effectiveRepo, "--json", "headRefOid"],
|
|
1325
|
+
{ cwd: ctx.cwd, signal, input: params },
|
|
1326
|
+
);
|
|
1327
|
+
const { headRefOid } = Value.Parse(prHeadSchema, JSON.parse(prOut));
|
|
1328
|
+
|
|
1329
|
+
return this.waitChecksReport({
|
|
1330
|
+
subject: `PR #${number}`,
|
|
1331
|
+
owner,
|
|
1332
|
+
repo: repoName,
|
|
1333
|
+
headSha: headRefOid,
|
|
1334
|
+
failFast: fail_fast === true,
|
|
1335
|
+
signal,
|
|
1336
|
+
onUpdate,
|
|
1337
|
+
params,
|
|
1338
|
+
pendant,
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/** `wait-github-commit-checks`: same, addressed by commit/branch/tag instead of a PR. */
|
|
1343
|
+
async waitCommitChecks(call: ToolCall<CommitChecksWaitParams>): Promise<ToolResult> {
|
|
1344
|
+
const { params, ctx, signal, onUpdate } = call;
|
|
1345
|
+
const { commit, repo, event, fail_fast } = params;
|
|
1346
|
+
|
|
1347
|
+
const pendant = subtitlePendant(params, "commit");
|
|
1348
|
+
onUpdate?.({
|
|
1349
|
+
content: [{ type: "text", text: `Watching CI checks for commit ${commit}...` }],
|
|
1350
|
+
details: {},
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
|
|
1354
|
+
const { owner, repo: repoName } = splitRepo(effectiveRepo);
|
|
1355
|
+
|
|
1356
|
+
const pollSignal = signal ?? new AbortController().signal;
|
|
1357
|
+
const sha = await this.checks.headSha(owner, repoName, String(commit), pollSignal);
|
|
1358
|
+
|
|
1359
|
+
return this.waitChecksReport({
|
|
1360
|
+
subject: `commit ${sha.slice(0, 7)}`,
|
|
1361
|
+
owner,
|
|
1362
|
+
repo: repoName,
|
|
1363
|
+
headSha: sha,
|
|
1364
|
+
failFast: fail_fast === true,
|
|
1365
|
+
event,
|
|
1366
|
+
signal,
|
|
1367
|
+
onUpdate,
|
|
1368
|
+
params,
|
|
1369
|
+
pendant,
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1178
1372
|
|
|
1179
1373
|
/**
|
|
1180
1374
|
* Shared wait core of `wait-github-pr-checks` and
|
|
@@ -1182,7 +1376,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1182
1376
|
* reports with Actions job details (display-only), render the verdict.
|
|
1183
1377
|
* The caller resolves repo/headSha; `subject` formats the report header.
|
|
1184
1378
|
*/
|
|
1185
|
-
async
|
|
1379
|
+
private async waitChecksReport(options: {
|
|
1186
1380
|
subject: string;
|
|
1187
1381
|
owner: string;
|
|
1188
1382
|
repo: string;
|
|
@@ -1190,10 +1384,10 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1190
1384
|
failFast: boolean;
|
|
1191
1385
|
event?: string;
|
|
1192
1386
|
signal: AbortSignal | undefined;
|
|
1193
|
-
onUpdate: ((msg:
|
|
1387
|
+
onUpdate: ((msg: ToolResult) => void) | undefined;
|
|
1194
1388
|
params: unknown;
|
|
1195
1389
|
pendant?: ToolPendant;
|
|
1196
|
-
}) {
|
|
1390
|
+
}): Promise<ToolResult> {
|
|
1197
1391
|
const { subject, owner, repo, headSha, failFast, event, signal, onUpdate, params, pendant } =
|
|
1198
1392
|
options;
|
|
1199
1393
|
|
|
@@ -1207,7 +1401,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1207
1401
|
headSha,
|
|
1208
1402
|
failFast,
|
|
1209
1403
|
event,
|
|
1210
|
-
checks:
|
|
1404
|
+
checks: this.checks,
|
|
1211
1405
|
signal: pollSignal,
|
|
1212
1406
|
onUpdate,
|
|
1213
1407
|
});
|
|
@@ -1218,7 +1412,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1218
1412
|
let enrichmentError: string | undefined;
|
|
1219
1413
|
if (poll.checks.some((c) => c.bucket === "fail")) {
|
|
1220
1414
|
try {
|
|
1221
|
-
actionJobs = await
|
|
1415
|
+
actionJobs = await this.checks.actionJobs(owner, repo, headSha, pollSignal);
|
|
1222
1416
|
} catch (error) {
|
|
1223
1417
|
enrichmentError =
|
|
1224
1418
|
error instanceof Error ? error.message : "Actions job details unavailable";
|
|
@@ -1227,7 +1421,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1227
1421
|
|
|
1228
1422
|
const verdict = renderChecksVerdict({ subject, poll, actionJobs, enrichmentError });
|
|
1229
1423
|
return {
|
|
1230
|
-
content: [{ type: "text"
|
|
1424
|
+
content: [{ type: "text", text: verdict.text }],
|
|
1231
1425
|
details: {
|
|
1232
1426
|
status: verdict.status,
|
|
1233
1427
|
totalChecks: poll.checks.length,
|
|
@@ -1238,6 +1432,34 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1238
1432
|
},
|
|
1239
1433
|
};
|
|
1240
1434
|
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// ── tools ────────────────────────────────────────────────────────────────────
|
|
1438
|
+
|
|
1439
|
+
export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
1440
|
+
// Windows 上禁用:gh 可执行文件的探测(无扩展名 + POSIX 路径)与进程
|
|
1441
|
+
// 管理(SIGTERM 信号语义)都是 POSIX 假设,不做 Windows 适配。
|
|
1442
|
+
if (process.platform === "win32") {
|
|
1443
|
+
pi.on("session_start", (_event, ctx) => {
|
|
1444
|
+
ctx.ui.notify("gh-readonly tools are disabled on Windows.", "warning");
|
|
1445
|
+
});
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// Fail fast: the `gh` CLI is the only backend for these tools. Without it the
|
|
1450
|
+
// extension registers nothing and reports the problem at session start, so
|
|
1451
|
+
// the user gets one clear error instead of a dozen failing tool calls.
|
|
1452
|
+
if (!isGhAvailable()) {
|
|
1453
|
+
pi.on("session_start", (_event, ctx) => {
|
|
1454
|
+
ctx.ui.notify(
|
|
1455
|
+
"gh CLI not found in PATH: GitHub read-only tools are disabled. Install GitHub CLI (https://cli.github.com/) and reload the session.",
|
|
1456
|
+
"error",
|
|
1457
|
+
);
|
|
1458
|
+
});
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
const client = new GhClient();
|
|
1241
1463
|
|
|
1242
1464
|
// ── read-github-issue ──────────────────────────────────────────────────────
|
|
1243
1465
|
pi.registerTool({
|
|
@@ -1300,15 +1522,8 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1300
1522
|
}),
|
|
1301
1523
|
),
|
|
1302
1524
|
}),
|
|
1303
|
-
async execute(_id, params, signal,
|
|
1304
|
-
|
|
1305
|
-
params.keywords
|
|
1306
|
-
? await searchList("issue", params, githubSearch)
|
|
1307
|
-
: await listGithub("issue", params, { cwd: ctx.cwd, signal, input: params }),
|
|
1308
|
-
params,
|
|
1309
|
-
);
|
|
1310
|
-
result.details.pendant = subtitlePendant(params);
|
|
1311
|
-
return result;
|
|
1525
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1526
|
+
return client.listIssues({ params, ctx, signal, onUpdate });
|
|
1312
1527
|
},
|
|
1313
1528
|
});
|
|
1314
1529
|
|
|
@@ -1373,15 +1588,8 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1373
1588
|
}),
|
|
1374
1589
|
),
|
|
1375
1590
|
}),
|
|
1376
|
-
async execute(_id, params, signal,
|
|
1377
|
-
|
|
1378
|
-
params.keywords
|
|
1379
|
-
? await searchList("pr", params, githubSearch)
|
|
1380
|
-
: await listGithub("pr", params, { cwd: ctx.cwd, signal, input: params }),
|
|
1381
|
-
params,
|
|
1382
|
-
);
|
|
1383
|
-
result.details.pendant = subtitlePendant(params);
|
|
1384
|
-
return result;
|
|
1591
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1592
|
+
return client.listPrs({ params, ctx, signal, onUpdate });
|
|
1385
1593
|
},
|
|
1386
1594
|
});
|
|
1387
1595
|
|
|
@@ -1418,35 +1626,8 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1418
1626
|
number: Type.Union([Type.Number(), Type.String()], { description: "PR number" }),
|
|
1419
1627
|
repo: Type.Optional(Type.String({ description: "OWNER/REPO (defaults to current repo)" })),
|
|
1420
1628
|
}),
|
|
1421
|
-
async execute(_id, params, signal,
|
|
1422
|
-
|
|
1423
|
-
const pullNumber = toPositiveId(number, "number");
|
|
1424
|
-
const pendant = subtitlePendant(params, "number");
|
|
1425
|
-
const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
|
|
1426
|
-
const { owner, repo: name } = splitRepo(effectiveRepo);
|
|
1427
|
-
|
|
1428
|
-
// 与 wait 工具同一条读取路径(octokit),只是这里不轮询:pending 的 check
|
|
1429
|
-
// 原样返回,等结果走 wait-github-pr-checks。
|
|
1430
|
-
const pollSignal = signal ?? new AbortController().signal;
|
|
1431
|
-
const headSha = await githubChecks.pullHead(owner, name, pullNumber, pollSignal);
|
|
1432
|
-
const [statuses, checkRuns] = await Promise.all([
|
|
1433
|
-
githubChecks.statuses(owner, name, headSha, pollSignal),
|
|
1434
|
-
githubChecks.checkRuns(owner, name, headSha, pollSignal),
|
|
1435
|
-
]);
|
|
1436
|
-
const checks = mergeChecks(statuses, checkRuns).map((check) => ({
|
|
1437
|
-
name: check.name,
|
|
1438
|
-
bucket: check.bucket,
|
|
1439
|
-
event: check.event,
|
|
1440
|
-
run_id: check.runId,
|
|
1441
|
-
job_id: check.jobId,
|
|
1442
|
-
url: check.link,
|
|
1443
|
-
}));
|
|
1444
|
-
|
|
1445
|
-
const payload = { pr: pullNumber, repo: effectiveRepo, head_sha: headSha, checks };
|
|
1446
|
-
return {
|
|
1447
|
-
content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
|
|
1448
|
-
details: { ...payload, input: params, ...(pendant && { pendant }) },
|
|
1449
|
-
};
|
|
1629
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1630
|
+
return client.prStatus({ params, ctx, signal, onUpdate });
|
|
1450
1631
|
},
|
|
1451
1632
|
});
|
|
1452
1633
|
|
|
@@ -1582,47 +1763,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1582
1763
|
repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
|
|
1583
1764
|
}),
|
|
1584
1765
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1585
|
-
|
|
1586
|
-
const jobId = toPositiveId(job_id, "job_id");
|
|
1587
|
-
|
|
1588
|
-
const pendant = subtitlePendant(params, "job_id");
|
|
1589
|
-
const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
|
|
1590
|
-
const { owner, repo: name } = splitRepo(effectiveRepo);
|
|
1591
|
-
|
|
1592
|
-
const failure = (text: string): CiLogsResult => ({
|
|
1593
|
-
content: [{ type: "text", text }],
|
|
1594
|
-
details: { input: params, ...(pendant && { pendant }) },
|
|
1595
|
-
});
|
|
1596
|
-
|
|
1597
|
-
let target: RunJob;
|
|
1598
|
-
try {
|
|
1599
|
-
target = await githubChecks.job(owner, name, jobId, signal);
|
|
1600
|
-
} catch (error) {
|
|
1601
|
-
const status = (error as { status?: number }).status;
|
|
1602
|
-
if (status !== 404) throw error;
|
|
1603
|
-
return failure(
|
|
1604
|
-
`Job ${jobId} not found in ${effectiveRepo} — job IDs come from \`get-github-workflow-jobs\`.`,
|
|
1605
|
-
);
|
|
1606
|
-
}
|
|
1607
|
-
|
|
1608
|
-
if (target.status === "queued") {
|
|
1609
|
-
return failure(
|
|
1610
|
-
`Job "${target.name}" is still queued — no logs available yet. Use \`watch-github-run\` to wait for it to start, then retry.`,
|
|
1611
|
-
);
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
onUpdate?.({
|
|
1615
|
-
content: [{ type: "text", text: `Fetching log of job "${target.name}"...` }],
|
|
1616
|
-
details: {},
|
|
1617
|
-
});
|
|
1618
|
-
|
|
1619
|
-
const rawLog = await getJobLog(target, signal, ctx.cwd, params);
|
|
1620
|
-
const index = jobLogIndex(target, rawLog);
|
|
1621
|
-
|
|
1622
|
-
return {
|
|
1623
|
-
content: [{ type: "text", text: JSON.stringify(index, null, 2) }],
|
|
1624
|
-
details: { ...index, input: params, ...(pendant && { pendant }) },
|
|
1625
|
-
};
|
|
1766
|
+
return client.ciLogs({ params, ctx, signal, onUpdate });
|
|
1626
1767
|
},
|
|
1627
1768
|
});
|
|
1628
1769
|
|
|
@@ -1637,15 +1778,8 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1637
1778
|
run_id: Type.Union([Type.Number(), Type.String()], { description: "Workflow run ID" }),
|
|
1638
1779
|
repo: Type.Optional(Type.String({ description: "OWNER/REPO" })),
|
|
1639
1780
|
}),
|
|
1640
|
-
async execute(_id, params, signal,
|
|
1641
|
-
|
|
1642
|
-
const runId = toPositiveId(run_id, "run_id");
|
|
1643
|
-
const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
|
|
1644
|
-
const { owner, repo: name } = splitRepo(effectiveRepo);
|
|
1645
|
-
const jobs = await githubChecks.runJobs(owner, name, runId, signal);
|
|
1646
|
-
const result = toToolResult(JSON.stringify({ total_count: jobs.length, jobs }), params);
|
|
1647
|
-
result.details.pendant = subtitlePendant(params, "run_id");
|
|
1648
|
-
return result;
|
|
1781
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1782
|
+
return client.workflowJobs({ params, ctx, signal, onUpdate });
|
|
1649
1783
|
},
|
|
1650
1784
|
});
|
|
1651
1785
|
|
|
@@ -1738,34 +1872,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1738
1872
|
),
|
|
1739
1873
|
}),
|
|
1740
1874
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
const pendant = subtitlePendant(params, "number");
|
|
1744
|
-
onUpdate?.({
|
|
1745
|
-
content: [{ type: "text", text: `Watching CI checks for PR #${number}...` }],
|
|
1746
|
-
details: {},
|
|
1747
|
-
});
|
|
1748
|
-
|
|
1749
|
-
const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
|
|
1750
|
-
const { owner, repo: repoName } = splitRepo(effectiveRepo);
|
|
1751
|
-
|
|
1752
|
-
const prOut = await ghExec(
|
|
1753
|
-
["pr", "view", String(number), "--repo", effectiveRepo, "--json", "headRefOid"],
|
|
1754
|
-
{ cwd: ctx.cwd, signal, input: params },
|
|
1755
|
-
);
|
|
1756
|
-
const { headRefOid } = Value.Parse(prHeadSchema, JSON.parse(prOut));
|
|
1757
|
-
|
|
1758
|
-
return waitChecksReport({
|
|
1759
|
-
subject: `PR #${number}`,
|
|
1760
|
-
owner,
|
|
1761
|
-
repo: repoName,
|
|
1762
|
-
headSha: headRefOid,
|
|
1763
|
-
failFast: fail_fast === true,
|
|
1764
|
-
signal,
|
|
1765
|
-
onUpdate,
|
|
1766
|
-
params,
|
|
1767
|
-
pendant,
|
|
1768
|
-
});
|
|
1875
|
+
return client.waitPrChecks({ params, ctx, signal, onUpdate });
|
|
1769
1876
|
},
|
|
1770
1877
|
});
|
|
1771
1878
|
|
|
@@ -1798,32 +1905,7 @@ export default function ghReadonlyTools(pi: ExtensionAPI) {
|
|
|
1798
1905
|
),
|
|
1799
1906
|
}),
|
|
1800
1907
|
async execute(_id, params, signal, onUpdate, ctx) {
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
const pendant = subtitlePendant(params, "commit");
|
|
1804
|
-
onUpdate?.({
|
|
1805
|
-
content: [{ type: "text", text: `Watching CI checks for commit ${commit}...` }],
|
|
1806
|
-
details: {},
|
|
1807
|
-
});
|
|
1808
|
-
|
|
1809
|
-
const effectiveRepo = await resolveRepo(repo, signal, ctx.cwd, params);
|
|
1810
|
-
const { owner, repo: repoName } = splitRepo(effectiveRepo);
|
|
1811
|
-
|
|
1812
|
-
const pollSignal = signal ?? new AbortController().signal;
|
|
1813
|
-
const sha = await githubChecks.headSha(owner, repoName, String(commit), pollSignal);
|
|
1814
|
-
|
|
1815
|
-
return waitChecksReport({
|
|
1816
|
-
subject: `commit ${sha.slice(0, 7)}`,
|
|
1817
|
-
owner,
|
|
1818
|
-
repo: repoName,
|
|
1819
|
-
headSha: sha,
|
|
1820
|
-
failFast: fail_fast === true,
|
|
1821
|
-
event,
|
|
1822
|
-
signal,
|
|
1823
|
-
onUpdate,
|
|
1824
|
-
params,
|
|
1825
|
-
pendant,
|
|
1826
|
-
});
|
|
1908
|
+
return client.waitCommitChecks({ params, ctx, signal, onUpdate });
|
|
1827
1909
|
},
|
|
1828
1910
|
});
|
|
1829
1911
|
|
package/src/lib/gh-proxy.ts
CHANGED
|
@@ -5,12 +5,15 @@
|
|
|
5
5
|
* - ~/.pi/agent/gh.json: { "proxy": "http://127.0.0.1:7890", "noProxy": "localhost,.corp" }
|
|
6
6
|
* - HTTPS_PROXY / HTTP_PROXY / ALL_PROXY(小写变体同样接受)、NO_PROXY
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* - gh CLI 子进程:env
|
|
8
|
+
* 两条出口共用同一份配置,且在扩展加载时一次性读完:
|
|
9
|
+
* - gh CLI 子进程:env 给出要注入子进程的 HTTP(S)_PROXY / NO_PROXY 等变量
|
|
10
10
|
* - octokit 请求:fetch 是挂了代理 dispatcher 的 fetch;未配置代理时就是全局 fetch
|
|
11
|
+
*
|
|
12
|
+
* 配置有错(JSON 语法错、字段类型不符、proxy 不是 http(s) URL)直接抛错——扩展
|
|
13
|
+
* 加载即失败,而不是带着一份被忽略的配置静默直连。
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
|
-
import {
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
14
17
|
import { homedir } from "node:os";
|
|
15
18
|
import { join } from "node:path";
|
|
16
19
|
|
|
@@ -63,54 +66,55 @@ function firstEnv(names: readonly string[], env: NodeJS.ProcessEnv): string | un
|
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
/** 代理必须是 http(s) URL:undici 的 ProxyAgent 只支持 HTTP CONNECT 代理。 */
|
|
66
|
-
function normalizeProxy(value: string
|
|
67
|
-
if (!value) return {};
|
|
69
|
+
function normalizeProxy(value: string): string {
|
|
68
70
|
let protocol: string;
|
|
69
71
|
try {
|
|
70
72
|
protocol = new URL(value).protocol;
|
|
71
73
|
} catch {
|
|
72
|
-
|
|
74
|
+
throw new Error(`invalid proxy URL: ${value}`);
|
|
73
75
|
}
|
|
74
76
|
if (protocol !== "http:" && protocol !== "https:") {
|
|
75
|
-
|
|
77
|
+
throw new Error(`unsupported proxy protocol: ${value} (expected http:// or https://)`);
|
|
76
78
|
}
|
|
77
|
-
return
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export interface GhProxyLoad {
|
|
81
|
-
/** 生效的代理设置(配置文件与环境变量合并后的结果)。 */
|
|
82
|
-
settings: GhProxySettings;
|
|
83
|
-
/** 配置读取/解析失败的原因;未失败时为 undefined。 */
|
|
84
|
-
error?: string;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function describeError(error: unknown): string {
|
|
88
|
-
return error instanceof Error ? error.message : String(error);
|
|
79
|
+
return value;
|
|
89
80
|
}
|
|
90
81
|
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
-
|
|
82
|
+
/**
|
|
83
|
+
* 同步读配置:扩展加载时调用一次(node:fs/promises 在同步的初始化路径上用不了,
|
|
84
|
+
* 这里是仓库里允许的同步例外)。文件不存在 = 未配置;文件读不了、JSON 非法或
|
|
85
|
+
* 字段不符都直接抛。
|
|
86
|
+
*/
|
|
87
|
+
export function readGhProxySettings(
|
|
88
|
+
configPath: string = ghProxyConfigPath(),
|
|
89
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
90
|
+
): GhProxySettings {
|
|
91
|
+
let raw: string | undefined;
|
|
94
92
|
try {
|
|
95
|
-
raw =
|
|
93
|
+
raw = readFileSync(configPath, "utf8");
|
|
96
94
|
} catch (error) {
|
|
97
|
-
if ((error as NodeJS.ErrnoException).code
|
|
98
|
-
|
|
95
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
96
|
+
throw new Error(`${configPath}: ${error instanceof Error ? error.message : String(error)}`, {
|
|
97
|
+
cause: error,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
99
100
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
|
|
102
|
+
let file: GhProxySettings = {};
|
|
103
|
+
if (raw !== undefined) {
|
|
104
|
+
try {
|
|
105
|
+
file = parseGhProxyConfig(JSON.parse(raw));
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw new Error(`${configPath}: ${error instanceof Error ? error.message : String(error)}`, {
|
|
108
|
+
cause: error,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
104
111
|
}
|
|
105
|
-
}
|
|
106
112
|
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
const normalized = normalizeProxy(file.settings.proxy ?? firstEnv(PROXY_ENV_NAMES, env));
|
|
110
|
-
const noProxy = file.settings.noProxy ?? firstEnv(NO_PROXY_ENV_NAMES, env);
|
|
113
|
+
const proxy = file.proxy ?? firstEnv(PROXY_ENV_NAMES, env);
|
|
114
|
+
const noProxy = file.noProxy ?? firstEnv(NO_PROXY_ENV_NAMES, env);
|
|
111
115
|
return {
|
|
112
|
-
|
|
113
|
-
|
|
116
|
+
...(proxy && { proxy: normalizeProxy(proxy) }),
|
|
117
|
+
...(noProxy && { noProxy }),
|
|
114
118
|
};
|
|
115
119
|
}
|
|
116
120
|
|
|
@@ -151,46 +155,32 @@ function createProxyDispatcher(
|
|
|
151
155
|
}
|
|
152
156
|
|
|
153
157
|
export interface GhProxy {
|
|
154
|
-
/**
|
|
155
|
-
|
|
158
|
+
/** 生效的代理设置(配置文件与环境变量合并后的结果)。 */
|
|
159
|
+
readonly settings: GhProxySettings;
|
|
156
160
|
/** 要注入 gh 子进程的代理环境变量;未配置代理时为空对象。 */
|
|
157
|
-
env
|
|
161
|
+
readonly env: NodeJS.ProcessEnv;
|
|
158
162
|
/** 走代理的 fetch;未配置代理时就是全局 fetch。 */
|
|
159
163
|
readonly fetch: typeof globalThis.fetch;
|
|
160
164
|
}
|
|
161
165
|
|
|
162
166
|
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* 缓存的是 dispatcher(连接池复用),**不是** `globalThis.fetch` 本身:每次调用都
|
|
167
|
-
* 取当前的全局 fetch,否则首个请求之后替换 `globalThis.fetch`(插桩、测试替身)
|
|
168
|
-
* 就不再生效。
|
|
167
|
+
* 读一次配置并组装代理层。缓存的是 dispatcher(连接池复用),**不是**
|
|
168
|
+
* `globalThis.fetch` 本身:每次调用都取当前的全局 fetch,否则首个请求之后替换
|
|
169
|
+
* `globalThis.fetch`(插桩、测试替身)就不再生效。
|
|
169
170
|
*/
|
|
170
171
|
export function createGhProxy(
|
|
171
172
|
configPath: string = ghProxyConfigPath(),
|
|
172
173
|
env: NodeJS.ProcessEnv = process.env,
|
|
173
174
|
): GhProxy {
|
|
174
|
-
|
|
175
|
+
const settings = readGhProxySettings(configPath, env);
|
|
176
|
+
const { proxy, noProxy } = settings;
|
|
175
177
|
let dispatcher: NonNullable<RequestInit["dispatcher"]> | undefined;
|
|
176
178
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
return {
|
|
183
|
-
load,
|
|
184
|
-
env: async () => {
|
|
185
|
-
const { settings } = await load();
|
|
186
|
-
return proxyEnvVars(settings);
|
|
187
|
-
},
|
|
188
|
-
fetch: async (input, init) => {
|
|
189
|
-
const { settings } = await load();
|
|
190
|
-
const { proxy, noProxy } = settings;
|
|
191
|
-
if (!proxy) return globalThis.fetch(input, init);
|
|
192
|
-
dispatcher ??= createProxyDispatcher(proxy, noProxy);
|
|
193
|
-
return globalThis.fetch(input, { ...init, dispatcher });
|
|
194
|
-
},
|
|
179
|
+
const fetch: typeof globalThis.fetch = (input, init) => {
|
|
180
|
+
if (!proxy) return globalThis.fetch(input, init);
|
|
181
|
+
dispatcher ??= createProxyDispatcher(proxy, noProxy);
|
|
182
|
+
return globalThis.fetch(input, { ...init, dispatcher });
|
|
195
183
|
};
|
|
184
|
+
|
|
185
|
+
return { settings, env: proxyEnvVars(settings), fetch };
|
|
196
186
|
}
|
package/src/opencode/bash.ts
CHANGED
|
@@ -4,7 +4,12 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import type { ExtensionAPI, TruncationResult } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { Type } from "typebox";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
appendSandboxHint,
|
|
9
|
+
BashInterruptedError,
|
|
10
|
+
type BwrapRuntime,
|
|
11
|
+
createBwrapRuntime,
|
|
12
|
+
} from "../bwrap/runtime.js";
|
|
8
13
|
import { resolveWorkdir } from "../lib/path.js";
|
|
9
14
|
|
|
10
15
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
@@ -106,9 +111,13 @@ export default function opencodeBash(
|
|
|
106
111
|
error.partial.truncation,
|
|
107
112
|
error.partial.fullOutputPath,
|
|
108
113
|
);
|
|
114
|
+
// 超时附带沙箱状态(可能是网络限制让命令挂住);中断是用户主动取消,与沙箱无关
|
|
109
115
|
const status =
|
|
110
116
|
error.kind === "timeout"
|
|
111
|
-
?
|
|
117
|
+
? appendSandboxHint(
|
|
118
|
+
`Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
|
119
|
+
error.sandboxHint,
|
|
120
|
+
)
|
|
112
121
|
: "Command aborted by user";
|
|
113
122
|
const full = text ? `${text}\n\n${status}` : status;
|
|
114
123
|
// 对齐上游 opencode:超时与中断都不抛错,输出与状态文本一起返回
|
|
@@ -123,10 +132,16 @@ export default function opencodeBash(
|
|
|
123
132
|
// 命令失败(非 0 退出码)不抛错:输出与状态文本一起返回
|
|
124
133
|
let text = result.output || "(no output)";
|
|
125
134
|
text = appendTruncationNotice(text, result.truncation, result.fullOutputPath);
|
|
135
|
+
const failed = result.exitCode !== 0 && result.exitCode !== null;
|
|
136
|
+
// 失败时附带沙箱状态:命令可能是被沙箱的写边界或网络限制挡住的
|
|
137
|
+
const status = appendSandboxHint(
|
|
138
|
+
`Command exited with code ${result.exitCode}.`,
|
|
139
|
+
failed ? result.sandboxHint : undefined,
|
|
140
|
+
);
|
|
126
141
|
return {
|
|
127
142
|
content: [
|
|
128
143
|
{ type: "text" as const, text },
|
|
129
|
-
{ type: "text" as const, text:
|
|
144
|
+
{ type: "text" as const, text: status },
|
|
130
145
|
],
|
|
131
146
|
details: {
|
|
132
147
|
exitCode: result.exitCode,
|