@estebanforge/pi-antigravity-bridge 1.4.8 → 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 +30 -0
- package/README.md +42 -7
- 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 +221 -22
- package/package.json +1 -1
- package/src/acp/connection.ts +12 -0
- package/src/acp/driver.ts +17 -10
- package/src/acp/events.ts +17 -0
- 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/daily-log.ts +11 -10
- package/src/driver-types.ts +13 -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 +216 -31
package/src/daily-log.ts
CHANGED
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
// redacted and long strings are truncated before they reach disk.
|
|
16
16
|
// - Retention: files older than `retentionDays` are pruned once per
|
|
17
17
|
// process, so the dir cannot grow unbounded.
|
|
18
|
-
// -
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// - Volume tiers: default installs write ONLY errors — routine logging
|
|
19
|
+
// costs the disk nothing. Warns surface as UI toasts instead (the
|
|
20
|
+
// extension wraps this logger); AGY_DEBUG=1 restores the full trail
|
|
21
|
+
// (debug/info/warn/error).
|
|
22
22
|
|
|
23
23
|
import { appendFile, mkdir, readdir, unlink } from "node:fs/promises";
|
|
24
24
|
import path from "node:path";
|
|
@@ -30,9 +30,9 @@ export interface DailyLoggerOptions {
|
|
|
30
30
|
dir: string;
|
|
31
31
|
/** Files older than this many days are pruned once per process. Default 14. */
|
|
32
32
|
retentionDays?: number;
|
|
33
|
-
/** Verbose gate. When false (default),
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
/** Verbose gate. When false (default), only error-level records land on
|
|
34
|
+
* disk; debug/info/warn are dropped so regular users write nothing
|
|
35
|
+
* routine. AGY_DEBUG=1 (or this option) restores the full trail. */
|
|
36
36
|
debug?: boolean;
|
|
37
37
|
/** Injectable clock for tests. */
|
|
38
38
|
now?: () => Date;
|
|
@@ -166,9 +166,10 @@ export function createDailyLogger(opts: DailyLoggerOptions): DailyLogger {
|
|
|
166
166
|
|
|
167
167
|
return {
|
|
168
168
|
log(event, data, level = "debug") {
|
|
169
|
-
// Volume gate:
|
|
170
|
-
//
|
|
171
|
-
|
|
169
|
+
// Volume gate: default installs write only errors so routine disk
|
|
170
|
+
// traffic is zero for regular users; the extension toasts warns
|
|
171
|
+
// instead. AGY_DEBUG=1 restores the full trail.
|
|
172
|
+
if (!verbose && level !== "error") return;
|
|
172
173
|
write(level, event, data);
|
|
173
174
|
},
|
|
174
175
|
flush() {
|
package/src/driver-types.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Engine-agnostic turn-driver contract.
|
|
2
2
|
//
|
|
3
|
-
// Both turn engines (
|
|
3
|
+
// Both turn engines (stream-json driver in `driver.ts`, ACP driver in
|
|
4
4
|
// `acp/driver.ts`) implement `TurnDriver`, and everything above them — the
|
|
5
5
|
// provider's stream loop, the G9 round-trip store, the extension wiring —
|
|
6
|
-
// depends on this interface only. Types live here so the
|
|
6
|
+
// depends on this interface only. Types live here so the stream module can be
|
|
7
7
|
// deleted (phase 4) without breaking imports.
|
|
8
8
|
//
|
|
9
9
|
// The ACP driver implements the same surface with protocol-native mechanics:
|
|
@@ -21,17 +21,17 @@ export interface DriverProfile {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export interface DriverTurnRequest extends DriverProfile {
|
|
24
|
-
/** Existing conversation/session to resume.
|
|
24
|
+
/** Existing conversation/session to resume. Stream-json: agy conversation id via
|
|
25
25
|
* `--conversation`. ACP: sessionId via `session/load` (falls back to
|
|
26
26
|
* `session/new` when the server no longer knows it). */
|
|
27
27
|
conversationId?: string | null;
|
|
28
28
|
prompt: string;
|
|
29
29
|
/** Image blocks riding with the prompt. ACP forwards them as typed
|
|
30
30
|
* content blocks (probe 2026-09-03: 64x64 two-tone PNG answered
|
|
31
|
-
* correctly); the
|
|
31
|
+
* correctly); the stream-json CLI prompt is text-only and ignores them. */
|
|
32
32
|
images?: Array<{ data: string; mimeType: string }>;
|
|
33
33
|
/** ACP only: pi-side context delivered as a native `embeddedContext`
|
|
34
|
-
* resource block instead of inline prompt text (G1 on ACP).
|
|
34
|
+
* resource block instead of inline prompt text (G1 on ACP). Stream-json
|
|
35
35
|
* embeds the digest in the prompt string and ignores this. */
|
|
36
36
|
contextBlock?: { uri: string; text: string };
|
|
37
37
|
signal?: AbortSignal;
|
|
@@ -52,7 +52,7 @@ export type AgyUsage = {
|
|
|
52
52
|
|
|
53
53
|
export type DriverActivity =
|
|
54
54
|
| { type: "text"; delta: string }
|
|
55
|
-
/**
|
|
55
|
+
/** Stream-json emits a token count only; ACP carries the actual thought text in
|
|
56
56
|
* `delta`. The provider renders whichever is present. */
|
|
57
57
|
| { type: "thought"; tokens?: number; delta?: string }
|
|
58
58
|
| { type: "tool_start"; stepId?: number; name: string; args: Record<string, unknown> }
|
|
@@ -64,7 +64,7 @@ export type DriverActivity =
|
|
|
64
64
|
output?: string;
|
|
65
65
|
durationSeconds?: number;
|
|
66
66
|
/** ACP only: the server's native edit diff from `tool_call`
|
|
67
|
-
* content[] ({type:"diff", path, oldText?, newText}).
|
|
67
|
+
* content[] ({type:"diff", path, oldText?, newText}). Stream-json never
|
|
68
68
|
* sets it; the provider renders it without any git subprocess. */
|
|
69
69
|
diff?: { path: string; oldText?: string; newText: string };
|
|
70
70
|
}
|
|
@@ -108,7 +108,7 @@ export interface DriverSnapshot {
|
|
|
108
108
|
recycleReasons: Record<string, number>;
|
|
109
109
|
};
|
|
110
110
|
lifecycle: string[];
|
|
111
|
-
/** Present on ACP snapshots; absent on
|
|
111
|
+
/** Present on ACP snapshots; absent on stream-json. */
|
|
112
112
|
engine?: "acp";
|
|
113
113
|
acp?: {
|
|
114
114
|
sessionId?: string;
|
|
@@ -125,11 +125,15 @@ export interface DriverSnapshot {
|
|
|
125
125
|
/** From the initialize handshake agentInfo block. */
|
|
126
126
|
agentName?: string;
|
|
127
127
|
agentTitle?: string;
|
|
128
|
+
/** Gate B watch: true once this server process sent usage/token fields
|
|
129
|
+
* in any session/update frame. /agy doctor surfaces it when true and
|
|
130
|
+
* stays silent otherwise. */
|
|
131
|
+
usageSeen: boolean;
|
|
128
132
|
};
|
|
129
133
|
}
|
|
130
134
|
|
|
131
135
|
/** The engine contract. Everything above the driver depends on this interface
|
|
132
|
-
* only; `
|
|
136
|
+
* only; `StreamDriver` and `AcpDriver` both implement it. */
|
|
133
137
|
export interface TurnDriver {
|
|
134
138
|
readonly state: DriverState;
|
|
135
139
|
readonly activeHandle: TurnHandle | null;
|
package/src/driver.ts
CHANGED
|
@@ -122,7 +122,7 @@ export function shouldFlipToCumulative(accumulated: string, next: string): boole
|
|
|
122
122
|
return accumulated.length >= CUMULATIVE_FLIP_MIN_CHARS && isCumulativeResend(accumulated, next);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
export class
|
|
125
|
+
export class StreamDriver implements TurnDriver {
|
|
126
126
|
#state: DriverState = "idle";
|
|
127
127
|
#child: ChildProcess | undefined;
|
|
128
128
|
#generation = 0;
|
|
@@ -130,7 +130,6 @@ export class AgyDriver implements TurnDriver {
|
|
|
130
130
|
#boundConversation: string | undefined;
|
|
131
131
|
#active: ActiveTurn | undefined;
|
|
132
132
|
#queueTail: Promise<void> = Promise.resolve();
|
|
133
|
-
#shutdown = false;
|
|
134
133
|
#stderrTail = "";
|
|
135
134
|
// Frames can split across pipe chunks; the trailing partial line lives here
|
|
136
135
|
// until its newline arrives (same scheme as JsonRpcSession.feed). Dropping
|
|
@@ -218,7 +217,10 @@ export class AgyDriver implements TurnDriver {
|
|
|
218
217
|
}
|
|
219
218
|
|
|
220
219
|
async #runExclusive(request: DriverTurnRequest): Promise<TurnHandle> {
|
|
221
|
-
|
|
220
|
+
// No shutdown latch: pi fires session_shutdown on /new, /resume and
|
|
221
|
+
// /fork (not only process exit), so a closed driver must respawn on the
|
|
222
|
+
// next turn instead of rejecting forever. Parity with the ACP driver
|
|
223
|
+
// fix (regression 2026-09-07).
|
|
222
224
|
if (request.signal?.aborted) throw new Error("aborted before start");
|
|
223
225
|
|
|
224
226
|
const cause = this.#recycleCause(request);
|
|
@@ -588,7 +590,6 @@ export class AgyDriver implements TurnDriver {
|
|
|
588
590
|
}
|
|
589
591
|
|
|
590
592
|
async close(reason: "recycle" | "shutdown", cause?: string): Promise<void> {
|
|
591
|
-
if (reason === "shutdown") this.#shutdown = true;
|
|
592
593
|
const child = this.#child;
|
|
593
594
|
if (!child) {
|
|
594
595
|
this.#state = reason === "shutdown" ? "dead" : "idle";
|
|
@@ -602,7 +603,7 @@ export class AgyDriver implements TurnDriver {
|
|
|
602
603
|
this.#log(`close:${reason}${cause ? `:${cause}` : ""}`);
|
|
603
604
|
const turn = this.#active;
|
|
604
605
|
if (turn && !turn.closed) {
|
|
605
|
-
this.#failTurn(turn, `agy driver ${reason}
|
|
606
|
+
this.#failTurn(turn, `agy driver ${reason === "recycle" ? "recycled" : "shut down"} mid-turn${cause ? ` (${cause})` : ""}`);
|
|
606
607
|
}
|
|
607
608
|
this.#killChild();
|
|
608
609
|
this.#state = reason === "shutdown" ? "dead" : "idle";
|
|
@@ -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 },
|