@estebanforge/pi-antigravity-bridge 1.4.9 → 1.4.10

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.
@@ -0,0 +1,127 @@
1
+ // Per-pid bridge registration for the stream-json engine (docs/TODO.md
2
+ // section 1, step 2). The stream-json agy CLI discovers MCP servers from
3
+ // ~/.gemini/config/mcp_config.json (verified live 2026-09-07: a server
4
+ // registered via `agy mcp add --type http` was called by agy through its
5
+ // native call_mcp_tool wrapper, exact entry shape captured from agy's own
6
+ // writes):
7
+ //
8
+ // { "mcpServers": { "<name>": { "disabled": false,
9
+ // "headers": { "x-bridge-token": "..." }, "serverUrl": "http://..." } } }
10
+ //
11
+ // The ACP engine does not use this file (mcpServers ride session/new).
12
+ //
13
+ // Merge rules: foreign servers are preserved; corrupt JSON is refused (the
14
+ // file is shared user config - never clobber); writes are atomic.
15
+ //
16
+ // Run: npm test
17
+
18
+ import fs from "node:fs";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+
22
+ /** Name convention for the bridge's per-pid server entries. */
23
+ export function bridgeServerName(pid: number): string {
24
+ return `pi-bridge-${pid}`;
25
+ }
26
+
27
+ export function mcpConfigPath(home: string = os.homedir()): string {
28
+ return path.join(home, ".gemini", "config", "mcp_config.json");
29
+ }
30
+
31
+ export interface BridgeServerEntry {
32
+ disabled: boolean;
33
+ headers: Record<string, string>;
34
+ serverUrl: string;
35
+ }
36
+
37
+ type McpConfig = { mcpServers: Record<string, unknown> };
38
+
39
+ function readConfig(file: string): { ok: true; config: McpConfig } | { ok: false; reason: string } {
40
+ let parsed: unknown;
41
+ try {
42
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
43
+ } catch (err) {
44
+ const code = (err as NodeJS.ErrnoException).code;
45
+ if (code === "ENOENT") return { ok: true, config: { mcpServers: {} } };
46
+ return { ok: false, reason: `mcp_config.json is not valid JSON; refusing to touch it (${String(err)})` };
47
+ }
48
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
49
+ return { ok: false, reason: "mcp_config.json is not an object; refusing to touch it" };
50
+ }
51
+ const config = parsed as McpConfig;
52
+ if (!config.mcpServers || typeof config.mcpServers !== "object" || Array.isArray(config.mcpServers)) {
53
+ config.mcpServers = {};
54
+ }
55
+ return { ok: true, config };
56
+ }
57
+
58
+ function writeConfig(file: string, config: McpConfig): void {
59
+ // 0700/0600: the file carries the bridge's shared-secret token in its
60
+ // headers, and it lives in the USER'S global agy config (audit 2026-09-07:
61
+ // it previously landed at the umask default, typically world-readable).
62
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
63
+ const tmp = `${file}.${process.pid}.tmp`;
64
+ fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
65
+ fs.renameSync(tmp, file);
66
+ }
67
+
68
+ /** Register (or refresh) the bridge's per-pid server entry. Foreign servers
69
+ * in the file are preserved. */
70
+ export function registerBridgeServer(
71
+ entry: { pid: number; port: number; token: string; tokenHeader: string },
72
+ configPath: string = mcpConfigPath(),
73
+ ): { wrote: boolean; reason?: string } {
74
+ const read = readConfig(configPath);
75
+ if (!read.ok) return { wrote: false, reason: read.reason };
76
+ read.config.mcpServers[bridgeServerName(entry.pid)] = {
77
+ disabled: false,
78
+ headers: { [entry.tokenHeader]: entry.token },
79
+ serverUrl: `http://127.0.0.1:${entry.port}/mcp`,
80
+ } satisfies BridgeServerEntry;
81
+ writeConfig(configPath, read.config);
82
+ return { wrote: true };
83
+ }
84
+
85
+ /** Remove the bridge's per-pid server entry (close path). */
86
+ export function unregisterBridgeServer(pid: number, configPath: string = mcpConfigPath()): { wrote: boolean } {
87
+ const read = readConfig(configPath);
88
+ if (!read.ok) return { wrote: false };
89
+ const name = bridgeServerName(pid);
90
+ if (!(name in read.config.mcpServers)) return { wrote: false };
91
+ delete read.config.mcpServers[name];
92
+ writeConfig(configPath, read.config);
93
+ return { wrote: true };
94
+ }
95
+
96
+ /** Default liveness probe: can the signal be delivered? */
97
+ function pidAlive(pid: number): boolean {
98
+ try {
99
+ process.kill(pid, 0);
100
+ return true;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /** Remove bridge entries whose owning pi process is gone (stale sweep, run
107
+ * at extension start). Foreign servers and live-pid entries are preserved.
108
+ * Entries not matching the per-pid name convention are never touched. */
109
+ export function sweepStaleBridgeServers(
110
+ configPath: string = mcpConfigPath(),
111
+ isAlive: (pid: number) => boolean = pidAlive,
112
+ ): { removed: string[]; reason?: string } {
113
+ const read = readConfig(configPath);
114
+ if (!read.ok) return { removed: [], reason: read.reason };
115
+ const removed: string[] = [];
116
+ for (const name of Object.keys(read.config.mcpServers)) {
117
+ const match = /^pi-bridge-(\d+)$/.exec(name);
118
+ if (!match) continue;
119
+ const pid = Number(match[1]);
120
+ if (Number.isFinite(pid) && !isAlive(pid)) {
121
+ delete read.config.mcpServers[name];
122
+ removed.push(name);
123
+ }
124
+ }
125
+ if (removed.length > 0) writeConfig(configPath, read.config);
126
+ return { removed };
127
+ }
package/src/mcp-server.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  LATEST_PROTOCOL_VERSION,
32
32
  SUPPORTED_PROTOCOL_VERSIONS,
33
33
  } from "@modelcontextprotocol/sdk/types.js";
34
+ import { GATED_AGY_TOOL_SET } from "./approval-hook.js";
34
35
 
35
36
  /** Tools we do NOT expose to agy: it would just error (the provider is already
36
37
  * antigravity, so the tool's own guard refuses; advertising it is noise). */
@@ -38,16 +39,49 @@ const SKIP_CIRCULAR = new Set(["AskAntigravity"]);
38
39
 
39
40
  const BRIDGE_MCP_KEY = "pi-antigravity-bridge";
40
41
  /** Shared-secret header every bridge request must carry. Exported: the ACP
41
- * engine's mcpServers registration needs the same header name (the legacy
42
+ * engine's mcpServers registration needs the same header name (the stream
42
43
  * engine gets it via .agents/mcp_config.json; ACP gets it via headers[]). */
43
44
  export const TOKEN_HEADER = "x-bridge-token";
44
45
  const MAX_BODY_BYTES = 1_000_000;
45
46
 
47
+ // --- approval gate (docs/TODO.md 2.5) ---------------------------------------
48
+
49
+ /** stdin JSON of a PreToolUse hook, forwarded verbatim by the bundled poll
50
+ * script. Only toolCall is load-bearing here. */
51
+ export interface ApprovalPayload {
52
+ toolCall: { name: string; args: Record<string, unknown> };
53
+ stepIdx?: number;
54
+ conversationId?: string;
55
+ [key: string]: unknown;
56
+ }
57
+
58
+ /** Terminal decision for a parked approval (mirrors GateDecision from
59
+ * approval-gate.ts; deny MUST carry a reason - it is the only feedback
60
+ * agy's model gets, see V2). */
61
+ export type ApprovalDecision = { allow: true } | { allow: false; reason: string };
62
+
63
+ /** Provider-facing park controls: ticket verification for the shadow tools'
64
+ * marker calls, and completion when pi's tool result maps to a decision. */
65
+ export interface ApprovalParkApi {
66
+ /** True while the ticket is still parked (unanswered, unexpired). */
67
+ has(ticket: string): boolean;
68
+ /** Settle a ticket. False when the id is unknown or already terminal. */
69
+ resolve(ticket: string, decision: ApprovalDecision): boolean;
70
+ }
71
+
72
+ /** Human-decision latency budget for one parked approval. The staged hook
73
+ * timeout (approval-hook.stagedTimeoutSeconds) exceeds this with margin:
74
+ * a timed-out hook soft-passes (V3), so the park must time out FIRST and
75
+ * print a deny. Mirrors the G9 park budget. */
76
+ export const APPROVAL_PARK_TIMEOUT_MS = 480_000;
77
+
46
78
  export interface McpServerHandle {
47
79
  port: number;
48
80
  /** Shared secret for TOKEN_HEADER. Callers that register the bridge with
49
- * an engine other than the legacy stream-json discovery file need it. */
81
+ * an engine other than the stream-json discovery file need it. */
50
82
  token: string;
83
+ /** Approval-gate park controls (docs/TODO.md 2.5). */
84
+ approvals: ApprovalParkApi;
51
85
  close: () => Promise<void>;
52
86
  }
53
87
 
@@ -71,7 +105,12 @@ export interface McpBridgeDeps {
71
105
  name: string,
72
106
  args: Record<string, unknown>,
73
107
  signal: AbortSignal,
74
- ): Promise<{ content: Array<{ type: string; text?: string }>; isError: boolean }>;
108
+ ): Promise<import("./provider.js").BridgeCallResultShape>;
109
+ /** Approval gate: called once per parked POST /approval, right after the
110
+ * early-ack. The provider interrupts the pi-side view of the agy turn and
111
+ * emits the shadow toolUse; the decision returns via approvals.resolve.
112
+ * Optional: absent = every approval POST is denied directly (fail closed). */
113
+ onApproval?(ticket: string, payload: ApprovalPayload): void;
75
114
  }
76
115
 
77
116
  /** Clamp an unsupported MCP-Protocol-Version header down to the SDK's LATEST.
@@ -244,9 +283,15 @@ export function registerExitCleanup(
244
283
 
245
284
  export async function startMcpServer(
246
285
  deps: McpBridgeDeps,
247
- opts: { preferredPort?: number; log?: (s: string, d?: unknown) => void } = {},
286
+ opts: {
287
+ preferredPort?: number;
288
+ log?: (s: string, d?: unknown) => void;
289
+ /** Test override for the per-park timeout (deny, fail closed). */
290
+ approvalTimeoutMs?: number;
291
+ } = {},
248
292
  ): Promise<McpStartResult> {
249
293
  const log = opts.log ?? (() => {});
294
+ const approvalTimeoutMs = opts.approvalTimeoutMs ?? APPROVAL_PARK_TIMEOUT_MS;
250
295
 
251
296
  const listHandler = async () => {
252
297
  const tools = deps.listTools();
@@ -300,6 +345,136 @@ export async function startMcpServer(
300
345
  const token = crypto.randomUUID();
301
346
  sweepStaleBridgeDirs();
302
347
 
348
+ // --- approval park (docs/TODO.md 2.5) ------------------------------------
349
+ // Ticket -> parked approval. A settled ticket STAYS in the map until its
350
+ // terminal decision is delivered to a poll, so the hook never 404s on the
351
+ // answer; an unknown/expired ticket 404s and the hook fails closed.
352
+ const parks = new Map<
353
+ string,
354
+ { name: string; since: number; timer: NodeJS.Timeout; terminal?: ApprovalDecision }
355
+ >();
356
+ const settlePark = (ticket: string, decision: ApprovalDecision): boolean => {
357
+ const p = parks.get(ticket);
358
+ if (!p || p.terminal) return false;
359
+ p.terminal = decision;
360
+ clearTimeout(p.timer);
361
+ return true;
362
+ };
363
+ const approvalsApi: ApprovalParkApi = {
364
+ has: (ticket) => {
365
+ const p = parks.get(ticket);
366
+ return p !== undefined && p.terminal === undefined;
367
+ },
368
+ resolve: (ticket, decision) => settlePark(ticket, decision),
369
+ };
370
+ const decisionBody = (d: ApprovalDecision): string =>
371
+ d.allow ? JSON.stringify({ decision: "allow" }) : JSON.stringify({ decision: "deny", reason: d.reason });
372
+ const denyDirect = (res: http.ServerResponse, reason: string): void => {
373
+ res.writeHead(200, { "content-type": "application/json" });
374
+ res.end(decisionBody({ allow: false, reason }));
375
+ };
376
+ const tokenOk = (req: http.IncomingMessage): boolean => {
377
+ const received = req.headers[TOKEN_HEADER];
378
+ return (
379
+ typeof received === "string" &&
380
+ received.length === token.length &&
381
+ crypto.timingSafeEqual(Buffer.from(received), Buffer.from(token))
382
+ );
383
+ };
384
+ const readBody = async (req: http.IncomingMessage): Promise<string | null> => {
385
+ let body = "";
386
+ let bytes = 0;
387
+ for await (const chunk of req) {
388
+ body += chunk;
389
+ bytes += chunk.length;
390
+ if (bytes > MAX_BODY_BYTES) return null;
391
+ }
392
+ return body;
393
+ };
394
+ const approvalRoute = async (
395
+ req: http.IncomingMessage,
396
+ res: http.ServerResponse,
397
+ route: string,
398
+ ): Promise<void> => {
399
+ if (!tokenOk(req)) {
400
+ log("unauthorized", { url: req.url });
401
+ res.writeHead(403, { "content-type": "application/json" }).end('{"error":"forbidden"}');
402
+ return;
403
+ }
404
+ if (req.method === "POST" && route === "/approval") {
405
+ const body = await readBody(req);
406
+ if (body === null) {
407
+ res.writeHead(413, { "content-type": "application/json", connection: "close" }).end('{"error":"payload too large"}');
408
+ return;
409
+ }
410
+ let payload: ApprovalPayload;
411
+ try {
412
+ const parsed = JSON.parse(body) as ApprovalPayload;
413
+ const name = parsed?.toolCall?.name;
414
+ if (typeof name !== "string" || name.length === 0) throw new Error("no toolCall.name");
415
+ if (!parsed.toolCall.args || typeof parsed.toolCall.args !== "object") {
416
+ parsed.toolCall.args = {};
417
+ }
418
+ payload = parsed;
419
+ } catch {
420
+ res.writeHead(400, { "content-type": "application/json" }).end('{"error":"invalid payload"}');
421
+ return;
422
+ }
423
+ // Defense in depth: the hooks matcher should never let an ungated
424
+ // tool through; deny directly instead of parking.
425
+ if (!GATED_AGY_TOOL_SET.has(payload.toolCall.name)) {
426
+ log("approval-ungated", { name: payload.toolCall.name });
427
+ denyDirect(res, `tool ${payload.toolCall.name} is not in the approval matcher set`);
428
+ return;
429
+ }
430
+ if (typeof deps.onApproval !== "function") {
431
+ log("approval-unwired", { name: payload.toolCall.name });
432
+ denyDirect(res, "approval gate is not wired; denying");
433
+ return;
434
+ }
435
+ const ticket = crypto.randomUUID();
436
+ const timer = setTimeout(() => {
437
+ // Fail closed FIRST: the staged hook timeout is longer than this
438
+ // park budget (V3: a hook outliving its timeout soft-passes, so the
439
+ // park must answer the deny before the hook is killed).
440
+ settlePark(ticket, { allow: false, reason: `approval gate timed out after ${Math.round(approvalTimeoutMs / 1000)}s` });
441
+ log("approval-timeout", { ticket, name: payload.toolCall.name });
442
+ }, approvalTimeoutMs);
443
+ parks.set(ticket, { name: payload.toolCall.name, since: Date.now(), timer });
444
+ log("approval-parked", { ticket, name: payload.toolCall.name });
445
+ try {
446
+ deps.onApproval(ticket, payload);
447
+ } catch (e) {
448
+ // A throwing provider must never hang the hook: settle deny now.
449
+ log("approval-onapproval-fail", { ticket, msg: e instanceof Error ? e.message : String(e) });
450
+ settlePark(ticket, { allow: false, reason: "approval gate internal error" });
451
+ }
452
+ res.writeHead(200, { "content-type": "application/json" });
453
+ res.end(JSON.stringify({ ticket }));
454
+ return;
455
+ }
456
+ if (req.method === "GET" && route.startsWith("/approval/")) {
457
+ const ticket = decodeURIComponent(route.slice("/approval/".length));
458
+ const p = parks.get(ticket);
459
+ if (!p) {
460
+ // Unknown or already delivered: the hook fails closed on a 404.
461
+ res.writeHead(404, { "content-type": "application/json" }).end('{"error":"unknown ticket"}');
462
+ return;
463
+ }
464
+ if (p.terminal) {
465
+ parks.delete(ticket); // delivered; a repeat poll 404s (fail closed)
466
+ log("approval-delivered", { ticket, name: p.name });
467
+ res.writeHead(200, { "content-type": "application/json" });
468
+ res.end(decisionBody(p.terminal));
469
+ return;
470
+ }
471
+ res.writeHead(200, { "content-type": "application/json" });
472
+ res.end(JSON.stringify({ status: "pending" }));
473
+ return;
474
+ }
475
+ res.writeHead(405).end();
476
+ };
477
+
303
478
  return new Promise<McpStartResult>((resolve) => {
304
479
  const httpServer = http.createServer(async (req, res) => {
305
480
  // #1: a client-side stream error must never crash pi.
@@ -317,18 +492,18 @@ export async function startMcpServer(
317
492
  res.writeHead(404, { "content-type": "application/json" }).end('{"error":"not found"}');
318
493
  return;
319
494
  }
495
+ const route = (req.url ?? "").split("?")[0];
496
+ if (route === "/approval" || route.startsWith("/approval/")) {
497
+ await approvalRoute(req, res, route);
498
+ return;
499
+ }
320
500
  if (req.method !== "POST") {
321
501
  res.writeHead(405).end();
322
502
  return;
323
503
  }
324
504
  // #3: require the shared-secret header. Constant-time compare so a
325
505
  // timing oracle can't recover the token byte-by-byte.
326
- const received = req.headers[TOKEN_HEADER];
327
- if (
328
- typeof received !== "string" ||
329
- received.length !== token.length ||
330
- !crypto.timingSafeEqual(Buffer.from(received), Buffer.from(token))
331
- ) {
506
+ if (!tokenOk(req)) {
332
507
  log("unauthorized", { url: req.url });
333
508
  res.writeHead(403, { "content-type": "application/json" }).end('{"error":"forbidden"}');
334
509
  return;
@@ -424,7 +599,14 @@ export async function startMcpServer(
424
599
  handle: {
425
600
  port,
426
601
  token,
602
+ approvals: approvalsApi,
427
603
  close: async () => {
604
+ // Pending approvals fail closed on shutdown: the hook gets a
605
+ // terminal deny instead of a 404 on its next poll.
606
+ for (const [ticket, p] of [...parks]) {
607
+ if (p.terminal) continue;
608
+ settlePark(ticket, { allow: false, reason: "approval gate bridge shut down" });
609
+ }
428
610
  await new Promise<void>((r) => httpServer.close(() => r()));
429
611
  removeBridgeMcpConfig();
430
612
  disposeExitCleanup();
package/src/models.ts CHANGED
@@ -275,7 +275,7 @@ function thinkingLevelMapFor(efforts: readonly AgyEffort[]): ThinkingLevelMap {
275
275
 
276
276
  /** Project an agy entry to pi's Model shape. `input` advertises accepted
277
277
  * inputs: text-only (default) or text+image. The ACP engine forwards image
278
- * blocks natively (probe 2026-09-03); the legacy CLI prompt is text-only, so
278
+ * blocks natively (probe 2026-09-03); the stream-json CLI prompt is text-only, so
279
279
  * the extension decides by engine at load time. */
280
280
  export function toPiModel(entry: AgyModelEntry, input: Array<"text" | "image"> = ["text"]): Model<Api> {
281
281
  const effortDriven = !!entry.efforts && entry.efforts.length > 0;
@@ -294,7 +294,7 @@ export function toPiModel(entry: AgyModelEntry, input: Array<"text" | "image"> =
294
294
  reasoning: effortDriven,
295
295
  ...(effortDriven ? { thinkingLevelMap: thinkingLevelMapFor(entry.efforts!) } : {}),
296
296
  // Input advertising comes from the caller (engine-dependent): the ACP
297
- // engine forwards image blocks; advertising images on the legacy engine
297
+ // engine forwards image blocks; advertising images on the stream engine
298
298
  // would let pi offer image attach only for them to be dropped.
299
299
  input,
300
300
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },