@estebanforge/pi-antigravity-bridge 1.7.0 → 1.7.2
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/CHANGELOG.md +16 -0
- package/extensions/index.ts +18 -1
- package/package.json +1 -1
- package/src/acp/connection.ts +164 -25
- package/src/acp/driver.ts +55 -5
- package/src/acp/jsonrpc.ts +46 -4
- package/src/ask-tool.ts +5 -2
- package/src/config.ts +1 -6
- package/src/daily-log.ts +17 -4
- package/src/driver.ts +30 -2
- package/src/frame-guard.ts +38 -0
- package/src/redact.ts +27 -0
- package/src/web-tools.ts +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.7.2] - 2026-09-24
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **ACP permission requests park on a human decision.** `session/request_permission` was answered silently in-process (skip turns auto-allowed, everything else auto-denied), making ACP turns all-or-nothing with no per-tool human say. Interactive turns now hold the JSON-RPC request open and surface the server's options as a pi select dialog under the dialog lock, with the turn budget paused while the human decides: 480 s park, deny on timeout/esc/throw/connection death, and a cancel instead of an answer when the server offers no reject option (a deny must never degrade into picking an allow). `skipPermissions` keeps the synchronous auto-allow; `approvals.mode allow` maps to it, deny and headless stay fail-closed. `allow_always`/`reject_always` answers are remembered per connection for identical later requests, and first settle wins: a late dialog answer after a timeout is forgotten. Nine new tests pin the behavior, including a scenario proving a late answer writes nothing. 1217 tests, tsc clean.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **Removed the dead `acp.permissions` config key.** It documented the in-connection auto policy but never had a consumer. Configs carrying it keep loading (unknown `acp` keys are ignored) and it drops out of the file on the next save.
|
|
14
|
+
|
|
15
|
+
## [1.7.1] - 2026-09-23
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **Subprocess stderr can no longer leak secrets into logs or the chat.** The `agy` CLI can print auth material (API keys, OAuth tokens, credential headers) to stderr, and six surfaces carried that text verbatim: the daily support log users attach to bug reports, the stream-json turn-failure error, the ACP connection-exit rejection reasons and `/agy doctor` tail, the web tools' failure note, and the AskAntigravity delegation exit note. A key in an auth error therefore landed on disk and in the transcript unchanged. Redaction now runs on the reassembled stderr tail at exit and getter boundaries - never per chunk - so a token split across two pipe events still matches, and the raw tail stays internal. New `redactText()` scrubs four secret-shaped pattern classes (API-key prefixes, OAuth tokens, credential headers, token-ish JSON fields), anchored with a word boundary so ordinary text like `desk-123456789012` survives, and carries no length cap because callers already bound their own output sizes. The daily log scrubber additionally scans string values, not just secret-shaped keys, and routes `Error.message` through the same pass; values are scanned before truncation so a cut token cannot lose the characters a pattern needs. Best-effort by design: a last line against leak paths, not a sandbox. Ten new tests pin the behavior, two of them spawning real processes (a secret-bearing crash and a token split across stderr chunks). 1200 tests, tsc clean.
|
|
20
|
+
|
|
5
21
|
## [1.7.0] - 2026-09-23
|
|
6
22
|
|
|
7
23
|
### Added
|
package/extensions/index.ts
CHANGED
|
@@ -298,6 +298,23 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
298
298
|
// a restart. Without this the driver defaults to "estimate" and the
|
|
299
299
|
// config knob (incl. "off") is dead.
|
|
300
300
|
usageEstimate: () => loadConfig().acp.usageEstimate,
|
|
301
|
+
// ACP request_permission parity with the approval gate: skipPermissions
|
|
302
|
+
// turns auto-allow in the driver; approvals.mode allow short-circuits
|
|
303
|
+
// to the same synchronous allow; everything else opens a dialog (esc
|
|
304
|
+
// and headless deny fail-closed) under the shared dialog lock.
|
|
305
|
+
permissionPolicy: () => (loadConfig().approvals.mode === "allow" ? "auto" : "deny"),
|
|
306
|
+
onPermissionRequest: async (req) => {
|
|
307
|
+
const ui = activeUi;
|
|
308
|
+
if (loadConfig().approvals.mode === "deny" || !ui) return undefined;
|
|
309
|
+
return await withDialogLock(async () => {
|
|
310
|
+
const labels = req.options.map((o) => o.name ?? o.kind ?? o.optionId ?? "?");
|
|
311
|
+
const title = req.toolCall?.title ? `agy requests: ${req.toolCall.title}` : "agy requests permission";
|
|
312
|
+
const picked = await ui.select(title, labels);
|
|
313
|
+
if (picked === undefined) return undefined;
|
|
314
|
+
const idx = labels.indexOf(picked);
|
|
315
|
+
return idx >= 0 ? (req.options[idx]?.optionId ?? undefined) : undefined;
|
|
316
|
+
});
|
|
317
|
+
},
|
|
301
318
|
...(authCapture ? { extraEnv: authCapture.browserEnv, authUrlFile: authCapture.file } : {}),
|
|
302
319
|
log: acpLog,
|
|
303
320
|
mcpServers: () => {
|
|
@@ -1218,7 +1235,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
1218
1235
|
if (rest.length > 0) {
|
|
1219
1236
|
// Only the keyword compares case-insensitively; the path keeps its case.
|
|
1220
1237
|
const bin = rest.toLowerCase() === "auto" ? "" : rest.replace(/^~(?=\/|$)/, os.homedir());
|
|
1221
|
-
// Spread, not a bare acp patch: a bare {bin
|
|
1238
|
+
// Spread, not a bare acp patch: a bare {bin} object
|
|
1222
1239
|
// would drop sibling keys (usageEstimate) from the file.
|
|
1223
1240
|
saveConfig({ acp: { ...loadConfig().acp, bin } });
|
|
1224
1241
|
ui?.notify(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-antigravity-bridge",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.2",
|
|
4
4
|
"description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. antigravity/* models in Pi's /model picker, no-patch MCP bridge: agy runs Pi's tools. ToS safe to use.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/acp/connection.ts
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
// the effort tier baked in, e.g. gemini-3.8-flash-low)
|
|
12
12
|
// - session/cancel returns -32601 on RC01 (not implemented) — the driver
|
|
13
13
|
// treats that as "cancel unsupported" and falls back to teardown+kill
|
|
14
|
-
// - session/request_permission
|
|
15
|
-
//
|
|
16
|
-
//
|
|
14
|
+
// - session/request_permission: skipPermissions turns answer the first
|
|
15
|
+
// allow option synchronously; otherwise a wired human handler PARKS the
|
|
16
|
+
// request (the JSON-RPC reply waits for the decision), and everything
|
|
17
|
+
// else fail-closes to the first reject option
|
|
17
18
|
// - session/load replays history as notifications BEFORE its response; the
|
|
18
19
|
// driver suppresses updates while the load is in flight
|
|
19
20
|
// - mcpServers entries: {name, type:"http", url, headers:[]} — headers is a
|
|
@@ -26,6 +27,7 @@ import path from "node:path";
|
|
|
26
27
|
import { parseAuthPort, readLastUrl } from "./browser-capture.js";
|
|
27
28
|
import { frameCarriesUsage } from "./events.js";
|
|
28
29
|
import type { AgyUsage } from "../driver-types.js";
|
|
30
|
+
import { redactText } from "../redact.js";
|
|
29
31
|
import { JsonRpcResponseError, JsonRpcSession } from "./jsonrpc.js";
|
|
30
32
|
|
|
31
33
|
export interface AcpMcpServer {
|
|
@@ -64,9 +66,17 @@ export interface AcpConnectionOptions {
|
|
|
64
66
|
* the same param). Evaluated lazily per call. */
|
|
65
67
|
mcpServers?: () => AcpMcpServer[];
|
|
66
68
|
/** Permission policy for session/request_permission, evaluated per request:
|
|
67
|
-
* "auto" selects the first allow option; "deny"
|
|
68
|
-
* reject option. Absent =
|
|
69
|
+
* "auto" selects the first allow option; "deny" consults the human
|
|
70
|
+
* handler (parking) or fail-closes to the first reject option. Absent =
|
|
71
|
+
* deny (fail closed). */
|
|
69
72
|
permissions?: () => "auto" | "deny";
|
|
73
|
+
/** Human gate for "deny"-policy turns. The request is PARKED: the JSON-RPC
|
|
74
|
+
* reply waits until the handler settles. The returned optionId is
|
|
75
|
+
* validated against the server's options; undefined/null deny. Absent =
|
|
76
|
+
* synchronous deny (fail closed). */
|
|
77
|
+
onPermissionRequest?: AcpPermissionHandler;
|
|
78
|
+
/** Park budget for one permission dialog. Tests shrink it. */
|
|
79
|
+
permissionParkMs?: number;
|
|
70
80
|
/** Record file for BROWSER-captured OAuth URLs (src/acp/browser-capture.ts).
|
|
71
81
|
* The server hands the login URL only to the browser-open call, so the
|
|
72
82
|
* wrapper records it there; the connection watches the file and logs
|
|
@@ -77,6 +87,30 @@ export interface AcpConnectionOptions {
|
|
|
77
87
|
const INIT_TIMEOUT_MS = 30_000;
|
|
78
88
|
const SESSION_OP_TIMEOUT_MS = 20_000;
|
|
79
89
|
|
|
90
|
+
/** Human-decision budget for one parked permission request. Mirrors the
|
|
91
|
+
* approval-gate park budget (APPROVAL_PARK_TIMEOUT_MS): the dialog must
|
|
92
|
+
* deny by itself, never hold the agy prompt open forever. */
|
|
93
|
+
const PERMISSION_PARK_MS = 480_000;
|
|
94
|
+
|
|
95
|
+
/** One session/request_permission option block (the subset we consume). */
|
|
96
|
+
export interface AcpPermissionOption {
|
|
97
|
+
optionId?: string;
|
|
98
|
+
kind?: string;
|
|
99
|
+
name?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Payload handed to the permission handler when the server asks for a human
|
|
103
|
+
* decision. Options are server-defined data; the handler returns the chosen
|
|
104
|
+
* optionId, or undefined/null to deny. */
|
|
105
|
+
export interface AcpPermissionRequest {
|
|
106
|
+
sessionId?: string;
|
|
107
|
+
options: AcpPermissionOption[];
|
|
108
|
+
toolCall?: { toolCallId?: string; kind?: string; title?: string };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type AcpPermissionHandler = (req: AcpPermissionRequest) => Promise<string | null | undefined>;
|
|
112
|
+
|
|
113
|
+
|
|
80
114
|
export class AcpConnection {
|
|
81
115
|
#opts: AcpConnectionOptions;
|
|
82
116
|
#child: ChildProcess | undefined;
|
|
@@ -96,6 +130,13 @@ export class AcpConnection {
|
|
|
96
130
|
lastConfigOptions: unknown = undefined;
|
|
97
131
|
#authUrlWatcher: fs.FSWatcher | undefined;
|
|
98
132
|
#lastAuthUrl: string | null = null;
|
|
133
|
+
/** Parked permission dialogs: each entry settles its own request with a
|
|
134
|
+
* deny. #finish drains the set so a dying connection never leaves the
|
|
135
|
+
* server's request hanging. */
|
|
136
|
+
#permissionPending = new Set<() => void>();
|
|
137
|
+
/** allow_always / reject_always answers, keyed by toolCall kind|title for
|
|
138
|
+
* the life of this connection: identical requests skip the dialog. */
|
|
139
|
+
#permissionMemory = new Map<string, string>();
|
|
99
140
|
|
|
100
141
|
constructor(opts: AcpConnectionOptions) {
|
|
101
142
|
this.#opts = opts;
|
|
@@ -110,7 +151,9 @@ export class AcpConnection {
|
|
|
110
151
|
}
|
|
111
152
|
|
|
112
153
|
get stderrTail(): string {
|
|
113
|
-
|
|
154
|
+
// Redacted at the boundary: the raw tail stays internal, every consumer
|
|
155
|
+
// (doctor display, driver exit logs, failure messages) sees redacted text.
|
|
156
|
+
return redactText(this.#stderrTail);
|
|
114
157
|
}
|
|
115
158
|
|
|
116
159
|
/** Gate B watch: latched true once any session/update frame carried
|
|
@@ -152,8 +195,8 @@ export class AcpConnection {
|
|
|
152
195
|
// try/catch in #write cannot see them. Without this listener an EPIPE
|
|
153
196
|
// (server died mid-handshake) is uncaught and kills pi.
|
|
154
197
|
child.stdin?.on("error", (err) => {
|
|
155
|
-
this.#opts.log("stdin-error", { message: err.message });
|
|
156
|
-
if (!this.#exited && !this.#killed) this.#finish(err.message);
|
|
198
|
+
this.#opts.log("stdin-error", { message: redactText(err.message) });
|
|
199
|
+
if (!this.#exited && !this.#killed) this.#finish(redactText(err.message));
|
|
157
200
|
});
|
|
158
201
|
|
|
159
202
|
const rpc = new JsonRpcSession({
|
|
@@ -161,6 +204,24 @@ export class AcpConnection {
|
|
|
161
204
|
onRequest: (method, params) => this.#onServerRequest(method, params),
|
|
162
205
|
onNotification: (method, params) => this.#onNotification(method, params),
|
|
163
206
|
onParseError: (line) => this.#opts.log("parse-error", { line }),
|
|
207
|
+
onOverflow: (detail) => {
|
|
208
|
+
// The session already aborted its pending requests with the typed
|
|
209
|
+
// reason; finish so later events are ignored, then take the whole
|
|
210
|
+
// process GROUP down: a flooder is broken or hostile, and the
|
|
211
|
+
// binary may be a wrapper script with live descendants.
|
|
212
|
+
this.#opts.log("frame-overflow", { detail });
|
|
213
|
+
this.#finish(`stdout frame overflow: ${detail}`);
|
|
214
|
+
const child = this.#child;
|
|
215
|
+
try {
|
|
216
|
+
if (child?.pid && process.platform !== "win32") {
|
|
217
|
+
process.kill(-child.pid, "SIGKILL");
|
|
218
|
+
} else {
|
|
219
|
+
child?.kill("SIGKILL");
|
|
220
|
+
}
|
|
221
|
+
} catch {
|
|
222
|
+
/* already gone */
|
|
223
|
+
}
|
|
224
|
+
},
|
|
164
225
|
});
|
|
165
226
|
this.#rpc = rpc;
|
|
166
227
|
|
|
@@ -169,12 +230,14 @@ export class AcpConnection {
|
|
|
169
230
|
this.#stderrTail = (this.#stderrTail + chunk).slice(-8192);
|
|
170
231
|
});
|
|
171
232
|
child.on("error", (err) => {
|
|
172
|
-
this.#opts.log("spawn-error", { message: err.message });
|
|
173
|
-
this.#finish(err.message);
|
|
233
|
+
this.#opts.log("spawn-error", { message: redactText(err.message) });
|
|
234
|
+
this.#finish(redactText(err.message));
|
|
174
235
|
});
|
|
175
236
|
child.on("exit", (code, signal) => {
|
|
176
237
|
this.#opts.log("exit", { code: code ?? signal ?? "?" });
|
|
177
|
-
|
|
238
|
+
// The reason rides into pending-request rejections, so it must be
|
|
239
|
+
// redacted here (server stderr can carry auth material).
|
|
240
|
+
this.#finish(redactText(this.#stderrTail.trim()));
|
|
178
241
|
});
|
|
179
242
|
|
|
180
243
|
const result = (await this.request(
|
|
@@ -302,21 +365,12 @@ export class AcpConnection {
|
|
|
302
365
|
await this.guarded("session/close", { sessionId }, SESSION_OP_TIMEOUT_MS);
|
|
303
366
|
}
|
|
304
367
|
|
|
305
|
-
/** Protocol-level permission answering: policy
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
* is computed synchronously. */
|
|
368
|
+
/** Protocol-level permission answering: policy "auto" selects the first
|
|
369
|
+
* allow option synchronously; "deny" consults the parked human handler
|
|
370
|
+
* (#answerPermission) or fail-closes. See there for the full matrix. */
|
|
309
371
|
#onServerRequest(method: string, params: unknown): Promise<unknown> {
|
|
310
372
|
if (method === "session/request_permission") {
|
|
311
|
-
|
|
312
|
-
typeof params === "object" && params !== null ? (params as Record<string, unknown>).options : undefined
|
|
313
|
-
) as Array<{ optionId?: string; kind?: string }> | undefined;
|
|
314
|
-
const allow = options?.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow"));
|
|
315
|
-
const deny = options?.find((o) => typeof o.kind === "string" && o.kind.startsWith("reject"));
|
|
316
|
-
const policy = this.#opts.permissions?.() ?? "deny";
|
|
317
|
-
const chosen = policy === "auto" ? (allow ?? deny ?? options?.[0]) : (deny ?? options?.[0]);
|
|
318
|
-
this.#opts.log("permission", { optionId: chosen?.optionId, policy });
|
|
319
|
-
return Promise.resolve({ outcome: { outcome: "selected", optionId: chosen?.optionId } });
|
|
373
|
+
return this.#answerPermission(params as AcpPermissionRequest);
|
|
320
374
|
}
|
|
321
375
|
// fs/* and terminal/* are declined: our client capabilities are off and
|
|
322
376
|
// agy keeps executing its own tools (plan §8 capability posture).
|
|
@@ -324,6 +378,86 @@ export class AcpConnection {
|
|
|
324
378
|
return Promise.reject(new Error(`client capability not enabled: ${method}`));
|
|
325
379
|
}
|
|
326
380
|
|
|
381
|
+
/** Answer session/request_permission. "auto" (skipPermissions turns)
|
|
382
|
+
* selects the first allow option synchronously. Otherwise a wired handler
|
|
383
|
+
* parks the request — the reply waits for the human — while every other
|
|
384
|
+
* path fail-closes synchronously: no handler, esc, throw, timeout,
|
|
385
|
+
* connection death. A chosen *_always option is remembered per connection
|
|
386
|
+
* for identical later requests (same toolCall kind+title). */
|
|
387
|
+
#answerPermission(params: AcpPermissionRequest): Promise<unknown> {
|
|
388
|
+
const options = Array.isArray(params?.options) ? params.options : [];
|
|
389
|
+
const allow = options.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow"));
|
|
390
|
+
const denyId = options.find((o) => typeof o.kind === "string" && o.kind.startsWith("reject"))?.optionId;
|
|
391
|
+
const policy = this.#opts.permissions?.() ?? "deny";
|
|
392
|
+
if (policy === "auto") {
|
|
393
|
+
const chosen = (allow ?? options[0])?.optionId;
|
|
394
|
+
this.#opts.log("permission", { optionId: chosen, policy });
|
|
395
|
+
return Promise.resolve({ outcome: { outcome: "selected", optionId: chosen } });
|
|
396
|
+
}
|
|
397
|
+
const handler = this.#opts.onPermissionRequest;
|
|
398
|
+
if (!handler) {
|
|
399
|
+
this.#opts.log("permission", { optionId: denyId, policy });
|
|
400
|
+
return Promise.resolve(
|
|
401
|
+
denyId !== undefined
|
|
402
|
+
? { outcome: { outcome: "selected", optionId: denyId } }
|
|
403
|
+
: { outcome: { outcome: "cancelled" } },
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
// Always-memory needs a stable identity: the title. Without one, every
|
|
407
|
+
// untitled call would collapse onto one key and inherit its answer.
|
|
408
|
+
const memKey = params?.toolCall?.title ? `${params.toolCall.kind ?? ""}|${params.toolCall.title}` : undefined;
|
|
409
|
+
const remembered = memKey !== undefined ? this.#permissionMemory.get(memKey) : undefined;
|
|
410
|
+
if (remembered !== undefined && options.some((o) => o.optionId === remembered)) {
|
|
411
|
+
this.#opts.log("permission", { optionId: remembered, policy: "memory" });
|
|
412
|
+
return Promise.resolve({ outcome: { outcome: "selected", optionId: remembered } });
|
|
413
|
+
}
|
|
414
|
+
const parkMs = this.#opts.permissionParkMs ?? PERMISSION_PARK_MS;
|
|
415
|
+
return new Promise((resolve) => {
|
|
416
|
+
let timer: NodeJS.Timeout | undefined;
|
|
417
|
+
// First settle wins: a late dialog answer after a timeout/deny must
|
|
418
|
+
// not re-log or record an always-memory from a dead request.
|
|
419
|
+
let settled = false;
|
|
420
|
+
const settle = (optionId: string | undefined, why: string): void => {
|
|
421
|
+
if (settled) return;
|
|
422
|
+
settled = true;
|
|
423
|
+
if (timer) clearTimeout(timer);
|
|
424
|
+
this.#permissionPending.delete(settleDeny);
|
|
425
|
+
this.#opts.log("permission", { optionId, policy: why });
|
|
426
|
+
// undefined optionId (no reject option on the table) cancels
|
|
427
|
+
// instead of selecting nothing.
|
|
428
|
+
resolve(
|
|
429
|
+
optionId !== undefined
|
|
430
|
+
? { outcome: { outcome: "selected", optionId } }
|
|
431
|
+
: { outcome: { outcome: "cancelled" } },
|
|
432
|
+
);
|
|
433
|
+
};
|
|
434
|
+
const settleDeny = (): void => settle(denyId, "dialog-deny");
|
|
435
|
+
timer = setTimeout(() => {
|
|
436
|
+
this.#opts.log("permission-timeout", { parkMs });
|
|
437
|
+
settleDeny();
|
|
438
|
+
}, parkMs);
|
|
439
|
+
this.#permissionPending.add(settleDeny);
|
|
440
|
+
Promise.resolve()
|
|
441
|
+
.then(() => handler(params))
|
|
442
|
+
.then((picked) => {
|
|
443
|
+
if (settled) return;
|
|
444
|
+
const chosen = picked != null ? options.find((o) => o.optionId === picked) : undefined;
|
|
445
|
+
if (!chosen?.optionId) {
|
|
446
|
+
settle(denyId, "dialog-deny");
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (chosen.kind === "allow_always" || chosen.kind === "reject_always") {
|
|
450
|
+
if (memKey !== undefined) this.#permissionMemory.set(memKey, chosen.optionId);
|
|
451
|
+
}
|
|
452
|
+
settle(chosen.optionId, "dialog");
|
|
453
|
+
})
|
|
454
|
+
.catch((err: unknown) => {
|
|
455
|
+
this.#opts.log("permission-error", { message: err instanceof Error ? err.message : String(err) });
|
|
456
|
+
settle(denyId, "dialog-error");
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
327
461
|
#onNotification(method: string, params: unknown): void {
|
|
328
462
|
if (method === "session/update") {
|
|
329
463
|
// Latched before the suppression gate: history-replay frames are the
|
|
@@ -443,8 +577,13 @@ export class AcpConnection {
|
|
|
443
577
|
this.#exited = true;
|
|
444
578
|
this.#authUrlWatcher?.close();
|
|
445
579
|
this.#authUrlWatcher = undefined;
|
|
580
|
+
// Pending permission dialogs must not outlive the connection: deny them
|
|
581
|
+
// so their replies race the teardown instead of hanging the server.
|
|
582
|
+
const pendings = [...this.#permissionPending];
|
|
583
|
+
this.#permissionPending.clear();
|
|
584
|
+
for (const deny of pendings) deny();
|
|
446
585
|
this.abortAll(`connection exited: ${reason || "process gone"}`);
|
|
447
|
-
this.#opts.onExit({ code: null, signal: null, stderrTail: this.#stderrTail });
|
|
586
|
+
this.#opts.onExit({ code: null, signal: null, stderrTail: redactText(this.#stderrTail) });
|
|
448
587
|
}
|
|
449
588
|
}
|
|
450
589
|
|
package/src/acp/driver.ts
CHANGED
|
@@ -15,12 +15,16 @@
|
|
|
15
15
|
// probed once per connection; when upstream ships it, abort goes graceful
|
|
16
16
|
// - overall-timer pause uses remaining-budget semantics on G9 parks (never a
|
|
17
17
|
// fresh cap); every park carries its own timeout (BRIDGE_TIMEOUT_MS)
|
|
18
|
-
// -
|
|
19
|
-
//
|
|
18
|
+
// - request_permission: skipPermissions turns answer the first allow option
|
|
19
|
+
// synchronously; otherwise an optional human handler (extension dialog)
|
|
20
|
+
// PARKS the request with the turn budget paused; no handler = deny
|
|
21
|
+
// fail-closed. Approvals-mode parity: a permissionPolicy override maps
|
|
22
|
+
// approvals.mode allow to the same synchronous auto path
|
|
20
23
|
|
|
21
24
|
import { randomUUID } from "node:crypto";
|
|
22
25
|
import type { UsageEstimate } from "../config.js";
|
|
23
|
-
import { AcpConnection, resolveAcpBinary, type AcpMcpServer } from "./connection.js";
|
|
26
|
+
import { AcpConnection, resolveAcpBinary, type AcpMcpServer, type AcpPermissionRequest } from "./connection.js";
|
|
27
|
+
import type { AcpPermissionHandler } from "./connection.js";
|
|
24
28
|
import { mapStopReason, mapUpdate, TextAccumulator, type AcpEditDiff } from "./events.js";
|
|
25
29
|
import { estimateTokens, synthesizeUsage } from "./usage-estimate.js";
|
|
26
30
|
import type {
|
|
@@ -53,6 +57,16 @@ export interface AcpDriverOptions {
|
|
|
53
57
|
* none. A function is resolved per turn (follows live config, like bin).
|
|
54
58
|
* Default "estimate". */
|
|
55
59
|
usageEstimate?: UsageEstimate | (() => UsageEstimate);
|
|
60
|
+
/** Human gate for ACP request_permission on non-skip turns. Forwarded to
|
|
61
|
+
* the connection; the driver parks the turn budget around each call
|
|
62
|
+
* (dialog time does not eat the deadline). */
|
|
63
|
+
onPermissionRequest?: AcpPermissionHandler;
|
|
64
|
+
/** Park budget for one permission dialog (tests shrink it). */
|
|
65
|
+
permissionParkMs?: number;
|
|
66
|
+
/** Standing policy consulted when the turn is not skipPermissions:
|
|
67
|
+
* "auto" answers the first allow option synchronously, "deny" consults
|
|
68
|
+
* the human handler or fail-closes. Default deny. */
|
|
69
|
+
permissionPolicy?: () => "auto" | "deny";
|
|
56
70
|
log?: (msg: string, data?: unknown) => void;
|
|
57
71
|
}
|
|
58
72
|
|
|
@@ -166,6 +180,33 @@ export class AcpDriver implements TurnDriver {
|
|
|
166
180
|
this.#emit(t, activity);
|
|
167
181
|
}
|
|
168
182
|
|
|
183
|
+
/** Run the human permission dialog with the turn parked: same budget
|
|
184
|
+
* semantics as bridge parks (dialog time does not consume the deadline).
|
|
185
|
+
* The finally unparks the CAPTURED turn, never whatever is active at
|
|
186
|
+
* exit: the turn can close mid-dialog (abort, server death) and a new
|
|
187
|
+
* turn must not inherit this park's accounting. */
|
|
188
|
+
async #parkForPermission(req: AcpPermissionRequest): Promise<string | null | undefined> {
|
|
189
|
+
const t = this.#active;
|
|
190
|
+
const parked = t !== undefined && !t.closed;
|
|
191
|
+
if (parked) {
|
|
192
|
+
t.parks += 1;
|
|
193
|
+
this.#clearIdle(t);
|
|
194
|
+
this.#pauseOverall(t);
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
return await this.#opts.onPermissionRequest!(req);
|
|
198
|
+
} finally {
|
|
199
|
+
if (parked && !t.closed && t.parks > 0) {
|
|
200
|
+
t.parks -= 1;
|
|
201
|
+
if (t.parks === 0) {
|
|
202
|
+
this.#armIdle(t);
|
|
203
|
+
this.#resumeOverall(t);
|
|
204
|
+
this.#log("unparked");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
169
210
|
kickIdle(): void {
|
|
170
211
|
const t = this.#active;
|
|
171
212
|
if (!t || t.closed) return;
|
|
@@ -476,8 +517,17 @@ export class AcpDriver implements TurnDriver {
|
|
|
476
517
|
cwd: request.cwd,
|
|
477
518
|
mcpServers: this.#opts.mcpServers,
|
|
478
519
|
log: (msg, data) => this.#log(msg, data),
|
|
479
|
-
// Fail-closed permissions:
|
|
480
|
-
|
|
520
|
+
// Fail-closed permissions: skipPermissions answers allow; a standing
|
|
521
|
+
// policy override (approvals.mode allow) may widen that; everything
|
|
522
|
+
// else goes to the human handler or denies.
|
|
523
|
+
permissions: () =>
|
|
524
|
+
this.#active?.request.skipPermissions
|
|
525
|
+
? "auto"
|
|
526
|
+
: (this.#opts.permissionPolicy?.() ?? "deny"),
|
|
527
|
+
onPermissionRequest: this.#opts.onPermissionRequest
|
|
528
|
+
? (req) => this.#parkForPermission(req)
|
|
529
|
+
: undefined,
|
|
530
|
+
permissionParkMs: this.#opts.permissionParkMs,
|
|
481
531
|
onUpdate: (sessionId, update) => this.#onConnectionUpdate(sessionId, update),
|
|
482
532
|
onExit: (info) => this.#onConnectionExit(conn, info),
|
|
483
533
|
});
|
package/src/acp/jsonrpc.ts
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
// - `abortAll()` rejects every pending request (Gate D teardown: no promise
|
|
17
17
|
// survives a killed connection).
|
|
18
18
|
|
|
19
|
+
import { isKnownNoiseLine, MAX_FRAME_BYTES, stripGluedNoise } from "../frame-guard.js";
|
|
20
|
+
|
|
19
21
|
export interface JsonRpcErrorShape {
|
|
20
22
|
code: number;
|
|
21
23
|
message: string;
|
|
@@ -41,6 +43,9 @@ export interface JsonRpcSessionOptions {
|
|
|
41
43
|
onNotification?: (method: string, params: unknown) => void;
|
|
42
44
|
/** Unparseable line (logged, never fatal). */
|
|
43
45
|
onParseError?: (line: string) => void;
|
|
46
|
+
/** Frame-cap breach: the peer flooded stdout with no newline. The session
|
|
47
|
+
* aborts itself; the transport must kill the process. */
|
|
48
|
+
onOverflow?: (detail: string) => void;
|
|
44
49
|
/** Default timeout for requests without an explicit one (ms). 0 = none. */
|
|
45
50
|
defaultTimeoutMs?: number;
|
|
46
51
|
}
|
|
@@ -58,6 +63,7 @@ export class JsonRpcSession {
|
|
|
58
63
|
#pending = new Map<number, Pending>();
|
|
59
64
|
#nextId = 1;
|
|
60
65
|
#aborted = false;
|
|
66
|
+
#overflowed = false;
|
|
61
67
|
#lineBuf = "";
|
|
62
68
|
parseErrors = 0;
|
|
63
69
|
|
|
@@ -68,26 +74,62 @@ export class JsonRpcSession {
|
|
|
68
74
|
|
|
69
75
|
/** Feed raw transport bytes. Stdio chunks are NOT newline-aligned: bytes
|
|
70
76
|
* are buffered and only complete lines are parsed (a frame split across a
|
|
71
|
-
* 64KB pipe boundary must never be dropped).
|
|
77
|
+
* 64KB pipe boundary must never be dropped). The buffer is capped: a peer
|
|
78
|
+
* flooding stdout with no newline breaches the cap and the session dies
|
|
79
|
+
* (onOverflow + abortAll), it never grows without bound. */
|
|
72
80
|
feed(chunk: string): void {
|
|
81
|
+
if (this.#overflowed) return;
|
|
73
82
|
this.#lineBuf += chunk;
|
|
83
|
+
if (this.#lineBuf.length > MAX_FRAME_BYTES) {
|
|
84
|
+
const detail = `${this.#lineBuf.length} bytes buffered with no newline (cap ${MAX_FRAME_BYTES})`;
|
|
85
|
+
this.#overflow(detail);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
74
88
|
const lines = this.#lineBuf.split("\n");
|
|
75
89
|
this.#lineBuf = lines.pop() ?? "";
|
|
76
90
|
for (const raw of lines) {
|
|
77
91
|
const line = raw.trim();
|
|
78
92
|
if (!line) continue;
|
|
93
|
+
// Known foreign noise (e.g. the Chromium launcher behind a browser
|
|
94
|
+
// login) is dropped before parsing, not counted as a parse error.
|
|
95
|
+
if (isKnownNoiseLine(line)) continue;
|
|
79
96
|
let msg: JsonRpcIncoming;
|
|
80
97
|
try {
|
|
81
98
|
msg = JSON.parse(line) as JsonRpcIncoming;
|
|
82
99
|
} catch {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
100
|
+
// A noise FRAGMENT without its newline glued itself onto the next
|
|
101
|
+
// real frame; strip everything up to the marker and re-parse.
|
|
102
|
+
const repaired = stripGluedNoise(line);
|
|
103
|
+
if (repaired) {
|
|
104
|
+
try {
|
|
105
|
+
msg = JSON.parse(repaired) as JsonRpcIncoming;
|
|
106
|
+
} catch {
|
|
107
|
+
this.parseErrors += 1;
|
|
108
|
+
this.#opts.onParseError?.(line.slice(0, 200));
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
} else {
|
|
112
|
+
// Malformed lines are counted and dropped, never fatal (the
|
|
113
|
+
// parseAgyLine lesson: a chatty banner must not kill the reader
|
|
114
|
+
// loop).
|
|
115
|
+
this.parseErrors += 1;
|
|
116
|
+
this.#opts.onParseError?.(line.slice(0, 200));
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
86
119
|
}
|
|
87
120
|
this.#handleMessage(msg);
|
|
88
121
|
}
|
|
89
122
|
}
|
|
90
123
|
|
|
124
|
+
#overflow(detail: string): void {
|
|
125
|
+
this.#overflowed = true;
|
|
126
|
+
this.#lineBuf = "";
|
|
127
|
+
// Aborting first gives pending requests the typed overflow reason; the
|
|
128
|
+
// transport's own teardown afterwards is a no-op on the empty map.
|
|
129
|
+
this.abortAll(`stdout frame overflow: ${detail}`);
|
|
130
|
+
this.#opts.onOverflow?.(detail);
|
|
131
|
+
}
|
|
132
|
+
|
|
91
133
|
#handleMessage(msg: JsonRpcIncoming): void {
|
|
92
134
|
// Response to one of our requests.
|
|
93
135
|
if (msg.method === undefined && msg.id !== undefined && msg.id !== null) {
|
package/src/ask-tool.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
snapshotConversations,
|
|
26
26
|
} from "./discovery.js";
|
|
27
27
|
import { loadConfig, type AgyMode, type ThinkingTier } from "./config.js";
|
|
28
|
+
import { redactText } from "./redact.js";
|
|
28
29
|
import { acquireBridgeSuppression } from "./mcp-registration.js";
|
|
29
30
|
import { AGY_EFFORT_ORDER, spawnAgyModelsRaw, toAgyEffort } from "./models.js";
|
|
30
31
|
|
|
@@ -742,8 +743,10 @@ export async function registerAskAntigravityTool(
|
|
|
742
743
|
}
|
|
743
744
|
|
|
744
745
|
if (outcome.exitCode !== 0) {
|
|
745
|
-
|
|
746
|
-
|
|
746
|
+
// stderr can carry auth material; the note is chat-visible.
|
|
747
|
+
const stderr = redactText(details.stderr.trim());
|
|
748
|
+
const note = stderr
|
|
749
|
+
? `agy exited with status ${outcome.exitCode}: ${stderr}`
|
|
747
750
|
: `agy exited with status ${outcome.exitCode}`;
|
|
748
751
|
return {
|
|
749
752
|
content: [{ type: "text", text: text ? `${text}\n\n[${note}]` : note }],
|
package/src/config.ts
CHANGED
|
@@ -76,10 +76,6 @@ export interface GateConfig {
|
|
|
76
76
|
export interface AcpConfig {
|
|
77
77
|
/** Path to agy_acp_server.par. Empty = env AGY_ACP_BIN > PATH. */
|
|
78
78
|
bin: string;
|
|
79
|
-
/** Single policy today: auto-approve request_permission in-connection
|
|
80
|
-
* (parity with skipPermissions). Kept as a key so future policies do not
|
|
81
|
-
* change the config shape. */
|
|
82
|
-
permissions: "auto";
|
|
83
79
|
/** Gate B stopgap: client-side token estimates for ACP turns so pi's
|
|
84
80
|
* usage surfaces show nonzero numbers. Estimates are labeled as such in
|
|
85
81
|
* /agy doctor and auto-disable when the server sends real usage. */
|
|
@@ -187,7 +183,7 @@ const DEFAULTS: AgyConfig = {
|
|
|
187
183
|
turnTimeoutMin: 0, // placeholder: the real default is resolved per loadConfig call (defaultTurnTimeoutMin)
|
|
188
184
|
inactivityTimeoutMin: 5,
|
|
189
185
|
approvals: { gateMode: "auto", mode: "ask" },
|
|
190
|
-
acp: { bin: "",
|
|
186
|
+
acp: { bin: "", usageEstimate: "estimate" },
|
|
191
187
|
};
|
|
192
188
|
|
|
193
189
|
/** Hard ceiling for a free-typed turn cap: one day, in minutes. */
|
|
@@ -317,7 +313,6 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
|
|
|
317
313
|
bin:
|
|
318
314
|
process.env.AGY_ACP_BIN ??
|
|
319
315
|
(typeof fileAcp.bin === "string" ? fileAcp.bin : DEFAULTS.acp.bin),
|
|
320
|
-
permissions: "auto",
|
|
321
316
|
usageEstimate,
|
|
322
317
|
};
|
|
323
318
|
|
package/src/daily-log.ts
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
// - One appendFile per record: O_APPEND keeps single-line writes atomic,
|
|
13
13
|
// so two pi tabs sharing the dir stay line-consistent.
|
|
14
14
|
// - No secrets, no prompt content: values of secret-shaped keys are
|
|
15
|
-
// redacted
|
|
15
|
+
// redacted wholesale, secret-shaped material inside string values
|
|
16
|
+
// (stderr tails, error messages) is scrubbed, and long strings are
|
|
17
|
+
// truncated before they reach disk.
|
|
16
18
|
// - Retention: files older than `retentionDays` are pruned once per
|
|
17
19
|
// process, so the dir cannot grow unbounded.
|
|
18
20
|
// - Volume tiers: default installs write ONLY errors — routine logging
|
|
@@ -22,6 +24,7 @@
|
|
|
22
24
|
|
|
23
25
|
import { appendFile, mkdir, readdir, unlink } from "node:fs/promises";
|
|
24
26
|
import path from "node:path";
|
|
27
|
+
import { redactText } from "./redact.js";
|
|
25
28
|
|
|
26
29
|
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
27
30
|
|
|
@@ -63,14 +66,24 @@ const MAX_RECORD = 4000;
|
|
|
63
66
|
// lives in `value`, which a name-only regex would miss.
|
|
64
67
|
const SECRET_KEY = /token|secret|password|passphrase|authorization|api[-_]?key|cookie|headers/i;
|
|
65
68
|
|
|
66
|
-
/** Redact secret-shaped
|
|
69
|
+
/** Redact secret-shaped keys wholesale, scrub secrets embedded in string
|
|
70
|
+
* values, and cap runaway strings before disk. */
|
|
67
71
|
function scrub(value: unknown, depth: number): unknown {
|
|
68
72
|
if (value === null || value === undefined) return value;
|
|
69
73
|
if (value instanceof Error) {
|
|
70
|
-
|
|
74
|
+
// message goes through the string branch: errors are the main carrier of
|
|
75
|
+
// subprocess stderr text (the exact leak class this scrubber exists for).
|
|
76
|
+
return {
|
|
77
|
+
name: value.name,
|
|
78
|
+
message: scrub(value.message, depth),
|
|
79
|
+
stack: scrub(value.stack, depth),
|
|
80
|
+
};
|
|
71
81
|
}
|
|
72
82
|
if (typeof value === "string") {
|
|
73
|
-
|
|
83
|
+
// Value scan first, truncate second: a cut token could lose the tail
|
|
84
|
+
// characters the pattern needs to match.
|
|
85
|
+
const clean = redactText(value);
|
|
86
|
+
return clean.length > MAX_STRING ? clean.slice(0, MAX_STRING) + "…(truncated)" : clean;
|
|
74
87
|
}
|
|
75
88
|
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
76
89
|
if (depth >= MAX_DEPTH) return "(depth limit)";
|
package/src/driver.ts
CHANGED
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
18
18
|
import { randomUUID } from "node:crypto";
|
|
19
19
|
import { parseAgyLine } from "./stream-events.js";
|
|
20
|
+
import { isKnownNoiseLine, MAX_FRAME_BYTES, stripGluedNoise } from "./frame-guard.js";
|
|
20
21
|
import { bridgeMcpConfigDir, bridgeMcpConfigExists } from "./mcp-server.js";
|
|
21
22
|
import { gateHooksStaged } from "./approval-hook.js";
|
|
23
|
+
import { redactText } from "./redact.js";
|
|
22
24
|
import type {
|
|
23
25
|
AgyUsage,
|
|
24
26
|
DriverActivity,
|
|
@@ -403,7 +405,9 @@ export class StreamDriver implements TurnDriver {
|
|
|
403
405
|
} else {
|
|
404
406
|
this.#failTurn(
|
|
405
407
|
turn,
|
|
406
|
-
|
|
408
|
+
// agy stderr can carry auth material; the tail becomes the
|
|
409
|
+
// user-facing error and a daily-log value.
|
|
410
|
+
redactText(this.#stderrTail.trim()) || `agy exited with status ${code ?? "signal"}`,
|
|
407
411
|
);
|
|
408
412
|
}
|
|
409
413
|
}
|
|
@@ -421,6 +425,18 @@ export class StreamDriver implements TurnDriver {
|
|
|
421
425
|
// Buffer the trailing partial line: a frame split across pipe chunks is
|
|
422
426
|
// reassembled when its newline arrives (mirrors JsonRpcSession.feed).
|
|
423
427
|
this.#stdoutBuf += chunk;
|
|
428
|
+
if (this.#stdoutBuf.length > MAX_FRAME_BYTES) {
|
|
429
|
+
// A no-newline flood over the cap means the peer is broken or
|
|
430
|
+
// hostile: fail the turn and tear the process tree down instead of
|
|
431
|
+
// growing memory without bound.
|
|
432
|
+
const detail = `${this.#stdoutBuf.length} bytes buffered with no newline (cap ${MAX_FRAME_BYTES})`;
|
|
433
|
+
this.#stdoutBuf = "";
|
|
434
|
+
const turn = this.#active;
|
|
435
|
+
this.#log("frame-overflow", { detail });
|
|
436
|
+
if (turn && !turn.closed) this.#failTurn(turn, `stdout frame overflow: ${detail}`);
|
|
437
|
+
this.#killChild();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
424
440
|
const lines = this.#stdoutBuf.split("\n");
|
|
425
441
|
this.#stdoutBuf = lines.pop() ?? "";
|
|
426
442
|
const turn = this.#active;
|
|
@@ -428,7 +444,19 @@ export class StreamDriver implements TurnDriver {
|
|
|
428
444
|
if (turn.idleTimer) turn.idleTimer.refresh();
|
|
429
445
|
for (const line of lines) {
|
|
430
446
|
if (!line.trim()) continue;
|
|
431
|
-
|
|
447
|
+
// Known foreign noise (e.g. the Chromium launcher behind a browser
|
|
448
|
+
// login) is dropped; a noise FRAGMENT glued onto a real frame is
|
|
449
|
+
// stripped so the frame still parses.
|
|
450
|
+
if (isKnownNoiseLine(line)) continue;
|
|
451
|
+
let parsed = parseAgyLine(line);
|
|
452
|
+
// Repair applies only to true parse failures (raw is the string
|
|
453
|
+
// fragment): a valid JSON object agy sent with an unrecognized shape
|
|
454
|
+
// also lands as "unknown" and must reach the turn untouched.
|
|
455
|
+
if (parsed.kind === "unknown" && typeof parsed.raw === "string") {
|
|
456
|
+
const repaired = stripGluedNoise(line);
|
|
457
|
+
if (repaired) parsed = parseAgyLine(repaired);
|
|
458
|
+
}
|
|
459
|
+
this.#applyParsed(turn, parsed);
|
|
432
460
|
if (turn.closed) return;
|
|
433
461
|
}
|
|
434
462
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Frame-guard: shared stdout framing defense for both engines.
|
|
2
|
+
//
|
|
3
|
+
// agy's stdout is a protocol stream, but foreign processes occasionally write
|
|
4
|
+
// into the same inherited pipe (the Chromium launcher behind a browser login
|
|
5
|
+
// prints "Opening in existing browser session." straight into it). Two
|
|
6
|
+
// defenses:
|
|
7
|
+
//
|
|
8
|
+
// 1. A hard per-frame ceiling: a line with no newline must never grow the
|
|
9
|
+
// reassembly buffer without bound. Breach = the peer is broken or
|
|
10
|
+
// hostile, the transport is dead.
|
|
11
|
+
// 2. Known-noise handling: a complete noise line is dropped before parsing;
|
|
12
|
+
// a noise FRAGMENT without a trailing newline glues itself onto the next
|
|
13
|
+
// real frame, so repair strips everything up to and including the marker.
|
|
14
|
+
|
|
15
|
+
/** Hard ceiling on one buffered (newline-terminated) frame. 32MB matches the
|
|
16
|
+
* reference ACP transport; real frames are KBs, machine-generated output
|
|
17
|
+
* with no newline for 32MB is a flood, not a frame. */
|
|
18
|
+
export const MAX_FRAME_BYTES = 32 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
const NOISE_MARKERS = ["Opening in existing browser session."];
|
|
21
|
+
|
|
22
|
+
/** True when a complete line is pure known noise: drop before parsing. */
|
|
23
|
+
export function isKnownNoiseLine(line: string): boolean {
|
|
24
|
+
const trimmed = line.trim();
|
|
25
|
+
if (!trimmed) return false;
|
|
26
|
+
return NOISE_MARKERS.some((m) => trimmed === m);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Repair for the glue case: a noise fragment arrived without its newline and
|
|
30
|
+
* the next real frame is now "noise + json". Returns the remainder after the
|
|
31
|
+
* first known marker, or null when the line carries no marker. */
|
|
32
|
+
export function stripGluedNoise(line: string): string | null {
|
|
33
|
+
for (const marker of NOISE_MARKERS) {
|
|
34
|
+
const idx = line.indexOf(marker);
|
|
35
|
+
if (idx >= 0) return line.slice(idx + marker.length).trim();
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
package/src/redact.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Secret redaction for free text: stderr tails, error messages, log string
|
|
2
|
+
// values. Patterns ported from pi-antigravity-acp-provider's errors.redact
|
|
3
|
+
// (MIT). No length cap here on purpose: callers already bound their own
|
|
4
|
+
// output sizes (8192 stderr tails, MAX_STRING truncation), and a cap inside
|
|
5
|
+
// this function would silently shorten their output.
|
|
6
|
+
|
|
7
|
+
const SECRET_PATTERNS: Array<[RegExp, string]> = [
|
|
8
|
+
// API-key prefixes: Google (AIza), OpenAI-style (sk-), npm, GitHub. The
|
|
9
|
+
// left word-boundary stops mid-word matches ("desk-123456789012").
|
|
10
|
+
[/\b(?:AIza|sk-|npm_|gh[opurs]_)[A-Za-z0-9_-]{12,}/gu, "<redacted>"],
|
|
11
|
+
// Google OAuth tokens (access ya29., refresh 1//).
|
|
12
|
+
[/\b(?:ya29\.|1\/\/)[A-Za-z0-9._~+/-]{12,}/gu, "<redacted>"],
|
|
13
|
+
// Credential header values; the key text stays (useful context).
|
|
14
|
+
[/((?:authorization|cookie|set-cookie)\s*[:=]\s*(?:bearer\s+)?)[^\s,;]+/giu, "$1<redacted>"],
|
|
15
|
+
// Token-ish JSON fields; the key stays.
|
|
16
|
+
[
|
|
17
|
+
/((?:["']?(?:access_token|refresh_token|id_token|client_secret|api[-_]?key)["']?)\s*[:=]\s*["']?)[^"'\s,;&}]+/giu,
|
|
18
|
+
"$1<redacted>",
|
|
19
|
+
],
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/** Replace secret-shaped material with `<redacted>`, preserving structure. */
|
|
23
|
+
export function redactText(text: string): string {
|
|
24
|
+
let out = text;
|
|
25
|
+
for (const [pattern, replacement] of SECRET_PATTERNS) out = out.replace(pattern, replacement);
|
|
26
|
+
return out;
|
|
27
|
+
}
|
package/src/web-tools.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync }
|
|
|
21
21
|
import os from "node:os";
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
import { Type } from "typebox";
|
|
24
|
+
import { redactText } from "./redact.js";
|
|
24
25
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
25
26
|
|
|
26
27
|
export const WEB_AGENT_PREFIX = "pi-bridge-web-";
|
|
@@ -119,7 +120,7 @@ export async function runWebAgent(opts: WebRunOptions): Promise<WebRunResult> {
|
|
|
119
120
|
const agentName = `${WEB_AGENT_PREFIX}${process.pid}-${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
|
|
120
121
|
const agentDir = path.join(root, agentName);
|
|
121
122
|
mkdirSync(agentDir, { recursive: true, mode: 0o700 });
|
|
122
|
-
// commandExecutionPolicy stays at the
|
|
123
|
+
// commandExecutionPolicy stays at the live-validated "auto": the template's
|
|
123
124
|
// tools list has no command tool and plan mode blocks edits; an unvalidated
|
|
124
125
|
// stricter enum risks agent discovery failing outright.
|
|
125
126
|
writeFileSync(
|
|
@@ -178,7 +179,7 @@ export async function runWebAgent(opts: WebRunOptions): Promise<WebRunResult> {
|
|
|
178
179
|
child.once("close", resolve);
|
|
179
180
|
});
|
|
180
181
|
if (!failed && opts.signal?.aborted) failed = "web run aborted";
|
|
181
|
-
if (!failed && code !== 0) failed = `agy exited with code ${code}${stderrTail ? `: ${stderrTail.trim().slice(-300)}` : ""}`;
|
|
182
|
+
if (!failed && code !== 0) failed = `agy exited with code ${code}${stderrTail ? `: ${redactText(stderrTail.trim()).slice(-300)}` : ""}`;
|
|
182
183
|
if (!failed && !allowedToolUsed) failed = `Antigravity returned an answer without an observed ${opts.gatedTool} step; refusing it as unverified`;
|
|
183
184
|
if (!failed && response === "") failed = "Antigravity web run produced no answer";
|
|
184
185
|
if (failed) {
|