@estebanforge/pi-antigravity-bridge 1.7.1 → 1.7.3

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 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.3] - 2026-09-24
6
+
7
+ ### Fixed
8
+
9
+ - **Collapsed ACP tool cards no longer repeat the tool name.** The `agy-native-event` renderer filled the muted detail slot with the tool name itself whenever an event carried neither a path nor a recognized command argument, so `ls` and `exec_command` collapsed to "✓ ls ls" / "✓ exec_command exec_command". The detail now prefers the path basename, then the command's first line, then agy's own display text (the first line of the completed call's output, e.g. "Run make test in wicket-cli-atlas"), and stays empty when none exists; the name always stands alone. ACP tool_call frames carry no per-tool reasoning, so agy's display text is the closest honest signal.
10
+
11
+ ## [1.7.2] - 2026-09-24
12
+
13
+ ### Added
14
+
15
+ - **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. tsc clean.
16
+
17
+ ### Changed
18
+
19
+ - **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.
20
+
5
21
  ## [1.7.1] - 2026-09-23
6
22
 
7
23
  ### Fixed
package/README.md CHANGED
@@ -73,7 +73,7 @@ cards for read-only steps.
73
73
 
74
74
  ## Approval gate (agy native tools)
75
75
 
76
- agy runs its own agent loop with native tools (`run_command`, `create_file`, `edit_file`, ...), which pi's permission extensions never saw. The optional approval gate routes those calls through a pi-side approval: a staged PreToolUse hook parks the call, the provider surfaces it as a shadow `bash`/`write`/`edit` toolUse, and your permission extension (or the built-in ask/allow/deny fallback) decides before agy executes it. Off by default (`approvals.gateMode: auto` enables it only when a pi permission extension is detected); denials fail closed; read-only agy tools stay ungated.
76
+ agy runs its own agent loop with native tools (`run_command`, `create_file`, `edit_file`, ...), which pi's permission extensions never saw. The optional approval gate routes those calls through a pi-side approval: a staged PreToolUse hook parks the call, the provider surfaces it as a shadow `bash`/`write`/`edit` toolUse, and your permission extension (or the built-in ask/allow/deny fallback) decides before agy executes it. Off by default (`approvals.gateMode: auto` enables it only when a pi permission extension is detected); denials fail closed; read-only agy tools stay ungated. On ACP turns the server has its own per-tool permission ask: when one fires (`skipPermissions` off), the request parks on a real pi dialog instead of a silent deny, and allow-always choices are remembered per connection.
77
77
 
78
78
  Full mechanics, configuration, and a sample gate extension: [docs/APPROVAL-GATE.md](docs/APPROVAL-GATE.md).
79
79
 
@@ -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: () => {
@@ -340,9 +357,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
340
357
  if (engine === "acp") {
341
358
  pi.registerEntryRenderer<NativeDisplayEvent>("agy-native-event", (entry, { expanded }, theme) => {
342
359
  const event = entry.data;
343
- const title = (event?.path ? path.basename(event.path) : event?.command?.split(/\r?\n/, 1)[0] ?? event?.name ?? "tool").slice(0, 160);
360
+ // Collapsed detail: most-specific signal wins (path, command, agy's own
361
+ // display text). The name is NOT a fallback here - it rendered "ls ls"
362
+ // duplication whenever the event carried neither path nor command.
363
+ const firstLine = (value: string | undefined): string | undefined => value?.split(/\r?\n/, 1)[0].trim() || undefined;
364
+ const detail = event?.path ? path.basename(event.path) : firstLine(event?.command) ?? firstLine(event?.output);
344
365
  const status = event?.status === "failed" ? theme.fg("error", "✗") : theme.fg("success", "✓");
345
- const lines = [`${status} ${theme.fg("toolTitle", event?.name ?? "Antigravity")} ${theme.fg("muted", title)}`];
366
+ const lines = [`${status} ${theme.fg("toolTitle", event?.name ?? "Antigravity")}${detail ? ` ${theme.fg("muted", detail.slice(0, 160))}` : ""}`];
346
367
  if (expanded) {
347
368
  if (event?.path) lines.push(theme.fg("dim", event.path));
348
369
  if (event?.diff) {
@@ -1218,7 +1239,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
1218
1239
  if (rest.length > 0) {
1219
1240
  // Only the keyword compares case-insensitively; the path keeps its case.
1220
1241
  const bin = rest.toLowerCase() === "auto" ? "" : rest.replace(/^~(?=\/|$)/, os.homedir());
1221
- // Spread, not a bare acp patch: a bare {bin, permissions} object
1242
+ // Spread, not a bare acp patch: a bare {bin} object
1222
1243
  // would drop sibling keys (usageEstimate) from the file.
1223
1244
  saveConfig({ acp: { ...loadConfig().acp, bin } });
1224
1245
  ui?.notify(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.7.1",
3
+ "version": "1.7.3",
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",
@@ -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 is answered in-connection: policy per turn
15
- // (skipPermissions on -> first allow option; off -> first reject option,
16
- // fail-closed)
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" fail-closes to the first
69
- * reject option. Absent = deny (fail closed). */
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 from the driver ("auto"
311
- * selects the first allow option; "deny" fail-closes to the first reject
312
- * option). Never hangs: options are data from the server and the answer
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
- const options = (
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
- // - single `auto` permission policy: request_permission answered
19
- // in-connection (plan §9.3); no provider involvement
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: only turns with skipPermissions answer allow.
480
- permissions: () => (this.#active?.request.skipPermissions ? "auto" : "deny"),
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
  });
@@ -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
- this.parseErrors += 1;
84
- this.#opts.onParseError?.(line.slice(0, 200));
85
- continue;
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: "", permissions: "auto", usageEstimate: "estimate" },
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
- this.#applyParsed(turn, parseAgyLine(line));
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
+ }