@trim21/personal-pi-extensions 0.0.239 → 0.0.241

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.0.239",
3
+ "version": "0.0.241",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
package/src/bwrap/core.ts CHANGED
@@ -167,7 +167,13 @@ function findDefaultBwrap(): string {
167
167
  }
168
168
 
169
169
  export function findBwrap(override?: string): string {
170
- return override ?? findDefaultBwrap();
170
+ if (override) {
171
+ if (!existsSync(override)) {
172
+ throw new Error(`bwrap not found at configured path: ${override}`);
173
+ }
174
+ return override;
175
+ }
176
+ return findDefaultBwrap();
171
177
  }
172
178
 
173
179
  export function buildBwrapArgs(resolved: ResolvedBwrap, cwd: string): string[] {
@@ -73,6 +73,7 @@ function notifyMode(
73
73
  export class BwrapRuntime {
74
74
  private resolved: ResolvedBwrap | undefined;
75
75
  private sandboxDisabled = false;
76
+ private bwrapUnavailable = false;
76
77
 
77
78
  setup(pi: ExtensionAPI): void {
78
79
  pi.registerFlag("no-bwrap", {
@@ -84,13 +85,18 @@ export class BwrapRuntime {
84
85
  pi.on("session_start", (_event, ctx) => {
85
86
  this.sandboxDisabled = pi.getFlag("no-bwrap") === true && ctx.hasUI;
86
87
  this.resolved = undefined;
88
+ this.bwrapUnavailable = false;
87
89
  const runtime = this.resolve(ctx);
88
90
  if (runtime.bwrapEnabled) {
89
91
  try {
90
92
  findBwrap(runtime.bwrapPath);
91
93
  } catch (error) {
92
- this.sandboxDisabled = true;
94
+ // Fail closed: a missing bwrap binary must not silently degrade to an
95
+ // unsandboxed allow-all session. Commands are refused until the user
96
+ // explicitly opts out via --no-bwrap or the bwrap-allow-all command.
97
+ this.bwrapUnavailable = true;
93
98
  this.resolved = undefined;
99
+ ctx.ui.setStatus("bwrap", ctx.ui.theme.fg("error", "bwrap: unavailable"));
94
100
  ctx.ui.notify(error instanceof Error ? error.message : "bwrap not found", "error");
95
101
  return;
96
102
  }
@@ -110,10 +116,16 @@ export class BwrapRuntime {
110
116
 
111
117
  pi.on("before_agent_start", (event, ctx) => {
112
118
  const runtime = this.resolve(ctx);
113
- const prompt = ctx.hasUI
114
- ? `\n\n## Command Execution\nCurrent bwrap mode: **${runtime.mode}**. The bwrap runtime selects sandboxing and, when requested, user approval for unsandboxed execution.\n`
115
- : "\n\n## Command Execution\nThis headless session is forced into bwrap readonly mode. Unsandboxed execution cannot be approved.\n";
116
- return { systemPrompt: event.systemPrompt + prompt };
119
+ const modeText = ctx.hasUI
120
+ ? `Current bwrap mode: **${runtime.mode}**. The bwrap runtime selects sandboxing and, when requested, user approval for unsandboxed execution.`
121
+ : "This headless session is forced into bwrap readonly mode. Unsandboxed execution cannot be approved.";
122
+ const unavailableText = this.bwrapUnavailable
123
+ ? " bwrap is unavailable (binary not found): bash commands are refused unless the user explicitly approves unsandboxed execution."
124
+ : "";
125
+ return {
126
+ systemPrompt:
127
+ event.systemPrompt + `\n\n## Command Execution\n${modeText}${unavailableText}\n`,
128
+ };
117
129
  });
118
130
 
119
131
  this.registerCommands(pi);
@@ -128,10 +140,17 @@ export class BwrapRuntime {
128
140
  reset(): void {
129
141
  this.resolved = undefined;
130
142
  this.sandboxDisabled = false;
143
+ this.bwrapUnavailable = false;
131
144
  }
132
145
 
133
146
  async execute(request: BwrapExecutionRequest) {
134
147
  const runtime = this.resolve(request.ctx);
148
+ if (this.bwrapUnavailable && runtime.bwrapEnabled && request.requestFullAccess !== true) {
149
+ throw new Error(
150
+ "bwrap (bubblewrap) not found; refusing to execute commands without sandboxing. " +
151
+ "Install bubblewrap and restart the session, or pass --no-bwrap to disable the sandbox explicitly.",
152
+ );
153
+ }
135
154
  if (request.requestFullAccess === true && runtime.bwrapEnabled) {
136
155
  await this.approveFullAccess(request.ctx, request.command, request.requestFullAccessReason);
137
156
  }
@@ -229,6 +248,13 @@ export class BwrapRuntime {
229
248
  handler: (args, ctx) =>
230
249
  this.runCommand(pi, specs.bwrap, args, ctx, (commandCtx) => {
231
250
  const runtime = this.resolve(commandCtx);
251
+ if (this.bwrapUnavailable) {
252
+ commandCtx.ui.notify(
253
+ "bwrap is unavailable: binary not found. Commands are refused unless sandboxing is explicitly disabled.",
254
+ "error",
255
+ );
256
+ return;
257
+ }
232
258
  if (!runtime.bwrapEnabled) {
233
259
  commandCtx.ui.notify(`bwrap disabled (mode: ${runtime.mode})`, "info");
234
260
  return;
@@ -29,9 +29,10 @@
29
29
  */
30
30
 
31
31
  import { spawn } from "node:child_process";
32
+ import { existsSync } from "node:fs";
32
33
  import { mkdir, readFile, writeFile } from "node:fs/promises";
33
34
  import { homedir } from "node:os";
34
- import { dirname, join, resolve } from "node:path";
35
+ import { delimiter, dirname, join, resolve } from "node:path";
35
36
 
36
37
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
37
38
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
@@ -46,10 +47,28 @@ interface GhResult {
46
47
  combined: string;
47
48
  /** Why the process was killed, when `killed` is true. */
48
49
  reason?: "timeout" | "abort";
50
+ /** When the process could not be started at all (e.g. `gh` not found in PATH). */
51
+ spawnError?: string;
49
52
  }
50
53
 
51
54
  // ── helpers ──────────────────────────────────────────────────────────────────
52
55
 
56
+ /**
57
+ * Check whether the `gh` CLI is on the system, scanning PATH like
58
+ * `findDefaultBwrap`. The extension registers no tools when `gh` is missing, so
59
+ * the model never sees GitHub tools that would fail on every call.
60
+ */
61
+ export function isGhAvailable(): boolean {
62
+ const pathEnv = process.env.PATH ?? "";
63
+ for (const directory of pathEnv.split(delimiter)) {
64
+ if (existsSync(join(directory, "gh"))) return true;
65
+ }
66
+ for (const candidate of ["/usr/bin/gh", "/usr/local/bin/gh", "/run/current-system/sw/bin/gh"]) {
67
+ if (existsSync(candidate)) return true;
68
+ }
69
+ return false;
70
+ }
71
+
53
72
  export function runGh(
54
73
  args: string[],
55
74
  ctx: { cwd?: string; signal?: AbortSignal; timeout?: number },
@@ -131,12 +150,22 @@ export function runGh(
131
150
  });
132
151
  });
133
152
 
134
- proc.on("error", () => {
153
+ proc.on("error", (err: Error) => {
135
154
  if (timeoutId) clearTimeout(timeoutId);
136
155
  if (onAbort && ctx.signal) {
137
156
  ctx.signal.removeEventListener("abort", onAbort);
138
157
  }
139
- resolve({ stdout, stderr, code: 1, killed, combined: combined.join(""), reason: killReason });
158
+ // spawn 失败(如 gh 不在 PATH ENOENT、cwd 不存在)时进程从未启动,
159
+ // 没有任何 stdout/stderr;把底层错误带上,否则会退化成无信息的 "exit code 1"。
160
+ resolve({
161
+ stdout,
162
+ stderr,
163
+ code: 1,
164
+ killed,
165
+ combined: combined.join(""),
166
+ reason: killReason,
167
+ spawnError: err.message,
168
+ });
140
169
  });
141
170
  });
142
171
  }
@@ -162,9 +191,21 @@ export class GhError extends Error {
162
191
  ? " (command aborted)"
163
192
  : ""
164
193
  : "";
165
- super(
166
- `${inputText}<output>${result.combined.trim() || `exit code ${result.code}`}${killedText}<output>`,
167
- );
194
+
195
+ // The process never started (e.g. `gh` not found): surface the spawn error.
196
+ // Otherwise show the command's own output; an empty output with a non-zero
197
+ // exit is explicitly marked, so a bare "exit code 1" can't be mistaken for
198
+ // a specific failure.
199
+ let outputText: string;
200
+ if (result.spawnError) {
201
+ outputText = `spawn failed: ${result.spawnError}`;
202
+ } else if (result.combined.trim()) {
203
+ outputText = result.combined.trim();
204
+ } else {
205
+ outputText = `exit code ${result.code} (no output)`;
206
+ }
207
+
208
+ super(`${inputText}<output>${outputText}${killedText}<output>`);
168
209
  this.name = "GhError";
169
210
  this.args = args;
170
211
  this.code = result.code;
@@ -1058,6 +1099,19 @@ export async function writeLogFile(
1058
1099
  // ── tools ────────────────────────────────────────────────────────────────────
1059
1100
 
1060
1101
  export default function ghReadonlyTools(pi: ExtensionAPI) {
1102
+ // Fail fast: the `gh` CLI is the only backend for these tools. Without it the
1103
+ // extension registers nothing and reports the problem at session start, so
1104
+ // the user gets one clear error instead of a dozen failing tool calls.
1105
+ if (!isGhAvailable()) {
1106
+ pi.on("session_start", (_event, ctx) => {
1107
+ ctx.ui.notify(
1108
+ "gh CLI not found in PATH: GitHub read-only tools are disabled. Install GitHub CLI (https://cli.github.com/) and reload the session.",
1109
+ "error",
1110
+ );
1111
+ });
1112
+ return;
1113
+ }
1114
+
1061
1115
  // ── read-github-issue ──────────────────────────────────────────────────────
1062
1116
  pi.registerTool({
1063
1117
  name: "read-github-issue",