@trim21/personal-pi-extensions 0.1.552 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.552",
3
+ "version": "0.1.553",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -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("aborted", "Command aborted by user", partial, error);
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 {
@@ -10,7 +10,12 @@ import {
10
10
  } from "@earendil-works/pi-coding-agent";
11
11
  import { Type } from "typebox";
12
12
 
13
- import { BashInterruptedError, type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
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 full = text
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(full, { cause: 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
- throw new Error(formatBashError(result.exitCode, full), {
169
- cause: result,
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
  },
@@ -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 { BashInterruptedError, type BwrapRuntime, createBwrapRuntime } from "../bwrap/runtime.js";
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
- ? `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
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: `Command exited with code ${result.exitCode}.` },
144
+ { type: "text" as const, text: status },
130
145
  ],
131
146
  details: {
132
147
  exitCode: result.exitCode,