@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 +1 -1
- package/src/bwrap/core.ts +7 -1
- package/src/bwrap/runtime.ts +31 -5
- package/src/gh-readonly.ts +60 -6
package/package.json
CHANGED
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
|
-
|
|
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[] {
|
package/src/bwrap/runtime.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
114
|
-
?
|
|
115
|
-
: "
|
|
116
|
-
|
|
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;
|
package/src/gh-readonly.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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",
|