@estebanforge/pi-antigravity-bridge 1.7.1 → 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 +10 -0
- package/extensions/index.ts +18 -1
- package/package.json +1 -1
- package/src/acp/connection.ts +152 -18
- package/src/acp/driver.ts +55 -5
- package/src/acp/jsonrpc.ts +46 -4
- package/src/config.ts +1 -6
- package/src/driver.ts +26 -1
- package/src/frame-guard.ts +38 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
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
|
+
|
|
5
15
|
## [1.7.1] - 2026-09-23
|
|
6
16
|
|
|
7
17
|
### Fixed
|
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
|
|
@@ -65,9 +66,17 @@ export interface AcpConnectionOptions {
|
|
|
65
66
|
* the same param). Evaluated lazily per call. */
|
|
66
67
|
mcpServers?: () => AcpMcpServer[];
|
|
67
68
|
/** Permission policy for session/request_permission, evaluated per request:
|
|
68
|
-
* "auto" selects the first allow option; "deny"
|
|
69
|
-
* 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). */
|
|
70
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;
|
|
71
80
|
/** Record file for BROWSER-captured OAuth URLs (src/acp/browser-capture.ts).
|
|
72
81
|
* The server hands the login URL only to the browser-open call, so the
|
|
73
82
|
* wrapper records it there; the connection watches the file and logs
|
|
@@ -78,6 +87,30 @@ export interface AcpConnectionOptions {
|
|
|
78
87
|
const INIT_TIMEOUT_MS = 30_000;
|
|
79
88
|
const SESSION_OP_TIMEOUT_MS = 20_000;
|
|
80
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
|
+
|
|
81
114
|
export class AcpConnection {
|
|
82
115
|
#opts: AcpConnectionOptions;
|
|
83
116
|
#child: ChildProcess | undefined;
|
|
@@ -97,6 +130,13 @@ export class AcpConnection {
|
|
|
97
130
|
lastConfigOptions: unknown = undefined;
|
|
98
131
|
#authUrlWatcher: fs.FSWatcher | undefined;
|
|
99
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>();
|
|
100
140
|
|
|
101
141
|
constructor(opts: AcpConnectionOptions) {
|
|
102
142
|
this.#opts = opts;
|
|
@@ -164,6 +204,24 @@ export class AcpConnection {
|
|
|
164
204
|
onRequest: (method, params) => this.#onServerRequest(method, params),
|
|
165
205
|
onNotification: (method, params) => this.#onNotification(method, params),
|
|
166
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
|
+
},
|
|
167
225
|
});
|
|
168
226
|
this.#rpc = rpc;
|
|
169
227
|
|
|
@@ -307,21 +365,12 @@ export class AcpConnection {
|
|
|
307
365
|
await this.guarded("session/close", { sessionId }, SESSION_OP_TIMEOUT_MS);
|
|
308
366
|
}
|
|
309
367
|
|
|
310
|
-
/** Protocol-level permission answering: policy
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
* 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. */
|
|
314
371
|
#onServerRequest(method: string, params: unknown): Promise<unknown> {
|
|
315
372
|
if (method === "session/request_permission") {
|
|
316
|
-
|
|
317
|
-
typeof params === "object" && params !== null ? (params as Record<string, unknown>).options : undefined
|
|
318
|
-
) as Array<{ optionId?: string; kind?: string }> | undefined;
|
|
319
|
-
const allow = options?.find((o) => typeof o.kind === "string" && o.kind.startsWith("allow"));
|
|
320
|
-
const deny = options?.find((o) => typeof o.kind === "string" && o.kind.startsWith("reject"));
|
|
321
|
-
const policy = this.#opts.permissions?.() ?? "deny";
|
|
322
|
-
const chosen = policy === "auto" ? (allow ?? deny ?? options?.[0]) : (deny ?? options?.[0]);
|
|
323
|
-
this.#opts.log("permission", { optionId: chosen?.optionId, policy });
|
|
324
|
-
return Promise.resolve({ outcome: { outcome: "selected", optionId: chosen?.optionId } });
|
|
373
|
+
return this.#answerPermission(params as AcpPermissionRequest);
|
|
325
374
|
}
|
|
326
375
|
// fs/* and terminal/* are declined: our client capabilities are off and
|
|
327
376
|
// agy keeps executing its own tools (plan §8 capability posture).
|
|
@@ -329,6 +378,86 @@ export class AcpConnection {
|
|
|
329
378
|
return Promise.reject(new Error(`client capability not enabled: ${method}`));
|
|
330
379
|
}
|
|
331
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
|
+
|
|
332
461
|
#onNotification(method: string, params: unknown): void {
|
|
333
462
|
if (method === "session/update") {
|
|
334
463
|
// Latched before the suppression gate: history-replay frames are the
|
|
@@ -448,6 +577,11 @@ export class AcpConnection {
|
|
|
448
577
|
this.#exited = true;
|
|
449
578
|
this.#authUrlWatcher?.close();
|
|
450
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();
|
|
451
585
|
this.abortAll(`connection exited: ${reason || "process gone"}`);
|
|
452
586
|
this.#opts.onExit({ code: null, signal: null, stderrTail: redactText(this.#stderrTail) });
|
|
453
587
|
}
|
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/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/driver.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
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";
|
|
22
23
|
import { redactText } from "./redact.js";
|
|
@@ -424,6 +425,18 @@ export class StreamDriver implements TurnDriver {
|
|
|
424
425
|
// Buffer the trailing partial line: a frame split across pipe chunks is
|
|
425
426
|
// reassembled when its newline arrives (mirrors JsonRpcSession.feed).
|
|
426
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
|
+
}
|
|
427
440
|
const lines = this.#stdoutBuf.split("\n");
|
|
428
441
|
this.#stdoutBuf = lines.pop() ?? "";
|
|
429
442
|
const turn = this.#active;
|
|
@@ -431,7 +444,19 @@ export class StreamDriver implements TurnDriver {
|
|
|
431
444
|
if (turn.idleTimer) turn.idleTimer.refresh();
|
|
432
445
|
for (const line of lines) {
|
|
433
446
|
if (!line.trim()) continue;
|
|
434
|
-
|
|
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);
|
|
435
460
|
if (turn.closed) return;
|
|
436
461
|
}
|
|
437
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
|
+
}
|