@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.
- package/CHANGELOG.md +13 -0
- package/README.md +36 -1
- package/docs/ACP-ADOPTION-PLAN.md +83 -18
- package/docs/ACP-PROTOCOL-REFERENCE.md +4 -3
- package/docs/ARCHITECTURE.md +12 -5
- package/docs/DEVELOPMENT.md +16 -0
- package/docs/PI-BRIDGE-GAPS.md +26 -5
- package/docs/TODO.md +21 -0
- package/extensions/index.ts +169 -14
- package/package.json +1 -1
- package/src/acp/driver.ts +9 -8
- package/src/approval-detect.ts +146 -0
- package/src/approval-gate.ts +208 -0
- package/src/approval-hook.ts +252 -0
- package/src/config.ts +43 -0
- package/src/driver-types.ts +9 -9
- package/src/driver.ts +6 -5
- package/src/mcp-registration.ts +127 -0
- package/src/mcp-server.ts +192 -10
- package/src/models.ts +2 -2
- package/src/provider.ts +209 -26
|
@@ -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
|
|
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
|
|
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<
|
|
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: {
|
|
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
|
-
|
|
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
|
|
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
|
|
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 },
|