@estebanforge/pi-antigravity-bridge 1.5.1 → 1.5.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 +17 -0
- package/README.md +1 -1
- package/docs/DEVELOPMENT.md +2 -1
- package/extensions/index.ts +14 -5
- package/package.json +1 -1
- package/src/ask-tool.ts +15 -32
- package/src/dialog-lock.ts +48 -0
- package/src/mcp-registration.ts +180 -14
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.5.3] - 2026-09-10
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Parallel agy tool approvals no longer clobber each other.** The shadow-tool `GatePolicy` asked through pi's `ui.confirm`, but pi's TUI shows ONE extension dialog at a time and an overlapping call replaces the live dialog without settling it: with several agy bash/write/edit calls in flight, approvals were silently lost (the park timeout eventually resolved them as declines). The confirm now holds the same shared cross-extension dialog lock as the pi-*-me gates (`withDialogLock`, `Symbol.for("pi-me.dialog-lock")`), so approvals queue and render in turn; the park timeout still bounds each dialog and is held inside the lock. New `src/dialog-lock.ts` module plus contract tests (FIFO order, throw-release, shared key).
|
|
10
|
+
|
|
11
|
+
## [1.5.2] - 2026-09-09
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Delegation suppression now spans the whole delegated run. The 1.5.1 suppression window ended after 5 seconds, but agy watches `mcp_config.json` and hot-reloads MCP servers on every file change (`ReloadMcpConfig` in the binary): the window's rewrite poked the live delegation into reconnecting, and a delegated peer review hit the fail-closed "no active antigravity turn" deny ~50 seconds in (observed live, reproduced on demand through `AskAntigravity`). The bridge entries are now hidden until the delegated process actually closes; the release fires on process close or error only, and a regression test pins "exactly one release, at close, never on a timer" with a fake `AGY_BIN` that outlives the old grace (plus the spawn-error route).
|
|
16
|
+
- Suppression is coordinated across pi processes through a shared marker file (`suppression.json` in the bridge's extensions-data dir, `{pid: since}` per live delegation, 0600/0700, atomic rename). Release and the session-start heal re-enable the entries only when no live delegator remains, so two sessions delegating concurrently - or a second pi window opening mid-delegation, which the old blind heal did unconditionally - no longer un-hide each other's entries, and a session starting mid-delegation registers its own entry disabled. Dead delegators are pruned by pid liveness with a 24h age bound against pid reuse; re-enable decisions read the marker fresh at the flip, so a racing acquire keeps its suppression. The marker is coordination only (the disabled flags in `mcp_config.json` stay the gate), lives outside agy's watched config dir, and its writes are best-effort: a lost entry in the syscall-scale read-modify-write window degrades to the status-quo fail-closed deny, and any residual interleave is bounded and self-heals at the next release or heal.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- README's recursion-safety paragraph, the DEVELOPMENT regression list, and the AGENTS.md module lines describe the shipped mechanism: whole-run suppression, cross-process marker, marker-aware heal and registration guard.
|
|
21
|
+
|
|
5
22
|
## [1.5.1] - 2026-09-15
|
|
6
23
|
|
|
7
24
|
### Fixed
|
package/README.md
CHANGED
|
@@ -54,7 +54,7 @@ The bridge starts a localhost MCP server inside pi's process. `tools/list` retur
|
|
|
54
54
|
|
|
55
55
|
**Long calls don't die.** agy's MCP client abandons a `tools/call` request at a flat ~180s, which used to kill any pi tool that ran longer (a long peer review, a build, a commit preview waiting for you). A call still running after ~20 seconds now settles its HTTP request with a `STILL RUNNING` answer carrying a `callId` while pi keeps executing; agy fetches the result through the bridge-local `bridge_poll_result` tool and polls until it lands. Escalated calls get their own 30-minute budget, so human-gated tools can take as long as the human takes. Fast calls stay fully synchronous and never see any of this. If a park does fail (abort, timeout, recycle), the late result is re-routed to agy as a follow-up prompt in the same conversation instead of being lost.
|
|
56
56
|
|
|
57
|
-
**Recursion safety.** Only the provider's agy receives the extra `--add-dir`. `AskAntigravity` is also filtered from the exposed tool list. Its delegated `agy -p` spawns with just the workspace, and
|
|
57
|
+
**Recursion safety.** Only the provider's agy receives the extra `--add-dir`. `AskAntigravity` is also filtered from the exposed tool list. Its delegated `agy -p` spawns with just the workspace, and for the whole delegated run the bridge hides its global per-pid entries: disabled before the spawn, released only on process close or error - agy watches the config and hot-reloads MCP servers on file changes, so a mid-run re-enable would poke the live delegation back into pi. A cross-process marker (`suppression.json` in the bridge's extensions-data dir) coordinates concurrent delegations: entries come back only when no live delegation remains, a session starting mid-delegation registers its own entry disabled, and the session-start heal re-enables only once the last delegator is gone (dead entries pruned by pid liveness with a 24h age bound). The inner agy therefore sees no bridge and cannot re-enter pi. Foreign servers in the global config are never touched.
|
|
58
58
|
|
|
59
59
|
**Cost / fan-out.** Every registered pi tool except builtins (and `AskAntigravity`) is exposed, including other delegation tools like `AskClaude`/`AskCodex`. agy can therefore chain into other models via the bridge, which is a new cost/time fan-out vector that did not exist before this feature.
|
|
60
60
|
|
package/docs/DEVELOPMENT.md
CHANGED
|
@@ -98,7 +98,8 @@ Most "stuck" reports trace to one of:
|
|
|
98
98
|
- `tests/acp-driver.test.ts` - the ACP driver over the fake server (`tests/helpers/fake-acp-server.mjs`, scenario-selected): happy flow, load-replay, permission auto-answer, Gate D abort (cancel probe, teardown, `cancelSupported` memory), the stale-exit race (a killed connection's late exit must not fail its replacement - `ACP_FAKE_SLOW_DEATH_MS`), auth errors, park/kickIdle timer pause with remaining budget.
|
|
99
99
|
- `tests/acp-config.test.ts` - engine selection narrowing (`AGY_ENGINE`/`config.engine`), acp block parsing.
|
|
100
100
|
- `tests/daily-log.test.ts` - the support log: day rotation, retention cutoff boundary, secret redaction (incl. header blocks), the 4 KB record cap, never-throw on a broken dir, and the two-tier gate (debug records dropped unless `AGY_DEBUG`).
|
|
101
|
-
- `tests/mcp-registration.test.ts` - the `~/.gemini/config/mcp_config.json` registration for the stream-json CLI: exact agy entry shape, foreign servers preserved, corrupt config refused, atomic writes, stale-entry sweep, delegation suppression (`setBridgeEntriesDisabled` flip/restore/idempotence, refcounted `acquireBridgeSuppression`).
|
|
101
|
+
- `tests/mcp-registration.test.ts` - the `~/.gemini/config/mcp_config.json` registration for the stream-json CLI: exact agy entry shape, foreign servers preserved, corrupt config refused, atomic writes, stale-entry sweep, delegation suppression (`setBridgeEntriesDisabled` flip/restore/idempotence, refcounted `acquireBridgeSuppression`) and the cross-process marker (concurrent two-session simulation, dead and stale delegator pruning, marker-aware `healBridgeSuppression`, the registration guard).
|
|
102
|
+
- `tests/ask-tool-suppression.test.ts` - the AskAntigravity delegation path end-to-end over a fake `AGY_BIN` that outlives the old grace: exactly one suppression release, at process close, never on a timer.
|
|
102
103
|
- `tests/engine-picker.test.ts` - first-run onboarding: picker gate (no config file + no AGY_ENGINE, fail-closed fs), option order (stream-json first), the ACP download/sign-in disclosure pins (intro + saved toast), agy binary detection (PATH scan, explicit path, fail-closed), missing-CLI toast copy.
|
|
103
104
|
- `tests/provider-escalation.test.ts` - the early-ack + poll pipeline (escalation registry, poll views, late-delivery tombstones) and tool-result image forwarding on both engines.
|
|
104
105
|
- `tests/approval-gate.test.ts` - the shadow tool factory: marker calls never execute, ticket verification denies forged/stale markers before the policy, denials throw, native-to-shadow mapping.
|
package/extensions/index.ts
CHANGED
|
@@ -72,7 +72,7 @@ import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.j
|
|
|
72
72
|
import { startMcpServer, TOKEN_HEADER, type McpServerHandle } from "../src/mcp-server.js";
|
|
73
73
|
import {
|
|
74
74
|
registerBridgeServer,
|
|
75
|
-
|
|
75
|
+
healBridgeSuppression,
|
|
76
76
|
sweepStaleBridgeServers,
|
|
77
77
|
unregisterBridgeServer,
|
|
78
78
|
} from "../src/mcp-registration.js";
|
|
@@ -88,6 +88,7 @@ import {
|
|
|
88
88
|
import { mapAgyToolToNative } from "../src/native-tools.js";
|
|
89
89
|
import { Type } from "typebox";
|
|
90
90
|
import { patchStatus, restorePatch } from "../src/patch-cleanup.js";
|
|
91
|
+
import { withDialogLock } from "../src/dialog-lock.js";
|
|
91
92
|
|
|
92
93
|
// Last UI seen (session_start / /agy commands). The ACP login URL arrives
|
|
93
94
|
// via the driver log sink, which has no command context; the stash lets that
|
|
@@ -726,10 +727,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
726
727
|
if (r.ok && r.handle) {
|
|
727
728
|
mcpHandle = r.handle;
|
|
728
729
|
// Stale entries swept at start; entries a crashed delegation left
|
|
729
|
-
// suppressed are healed here
|
|
730
|
-
//
|
|
730
|
+
// suppressed are healed here - but only when no live delegation is in
|
|
731
|
+
// flight anywhere (marker-aware heal). A blind re-enable used to
|
|
732
|
+
// un-hide the bridge during another session's active delegation.
|
|
731
733
|
sweepStaleBridgeServers();
|
|
732
|
-
|
|
734
|
+
healBridgeSuppression();
|
|
733
735
|
registerBridgeServer({
|
|
734
736
|
pid: process.pid,
|
|
735
737
|
port: r.handle.port,
|
|
@@ -807,7 +809,14 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
807
809
|
: typeof params.path === "string"
|
|
808
810
|
? params.path
|
|
809
811
|
: JSON.stringify(stripMarkerFields(params)).slice(0, 200);
|
|
810
|
-
|
|
812
|
+
// Dialog lock: parallel agy tool approvals queue up instead of
|
|
813
|
+
// clobbering the live dialog (which silently loses the approval).
|
|
814
|
+
// Capture the narrowed method: TS drops the guard's narrowing
|
|
815
|
+
// inside the deferred closure.
|
|
816
|
+
const uiConfirm = extCtx.ui.confirm.bind(extCtx.ui);
|
|
817
|
+
const ok = await withDialogLock(() =>
|
|
818
|
+
uiConfirm(`agy ${tool}?`, what, { timeout: APPROVAL_PARK_MS }),
|
|
819
|
+
);
|
|
811
820
|
return ok ? { allow: true } : { allow: false, reason: `declined in pi (agy ${tool})` };
|
|
812
821
|
};
|
|
813
822
|
const handle = r.handle;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-antigravity-bridge",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.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",
|
package/src/ask-tool.ts
CHANGED
|
@@ -48,14 +48,6 @@ const FAMILY_DEFAULT_TIER: Record<Family, ThinkingTier> = {
|
|
|
48
48
|
other: "medium",
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
-
/** Bridge-suppression grace for a delegated `agy -p`. The shared config stays
|
|
52
|
-
* disabled for min(process close, this): agy reads it once at startup, so
|
|
53
|
-
* this bound covers that read without staying disabled for the whole run.
|
|
54
|
-
* Residual race: a provider agy respawn in ANOTHER session landing inside
|
|
55
|
-
* the window reads the entries disabled and that process lacks bridge tools
|
|
56
|
-
* until its next recycle (session start heals the file). Fail-open past the
|
|
57
|
-
* window = status-quo behavior. */
|
|
58
|
-
const BRIDGE_SUPPRESS_MS = 5000;
|
|
59
51
|
const TIER_RANK: Record<ThinkingTier, number> = { low: 0, medium: 1, high: 2 };
|
|
60
52
|
|
|
61
53
|
// Static alias overlay for non-Gemini models agy may or may not surface.
|
|
@@ -520,30 +512,21 @@ export async function registerAskAntigravityTool(
|
|
|
520
512
|
const binary = process.env.AGY_BIN || "agy";
|
|
521
513
|
let out = "";
|
|
522
514
|
|
|
523
|
-
// Delegation isolation: any agy on this machine
|
|
524
|
-
// mcp_config.json, so this spawned `agy -p` would
|
|
525
|
-
// pi-bridge-* entries and call tools the host bridge cannot serve
|
|
526
|
-
// outside a live provider turn ("no active antigravity turn").
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
BRIDGE_SUPPRESS_MS,
|
|
539
|
-
);
|
|
540
|
-
const restoreBridge = (): void => {
|
|
541
|
-
if (suppressTimer) {
|
|
542
|
-
clearTimeout(suppressTimer);
|
|
543
|
-
suppressTimer = undefined;
|
|
544
|
-
}
|
|
545
|
-
releaseBridge();
|
|
546
|
-
};
|
|
515
|
+
// Delegation isolation: any agy on this machine discovers MCP servers
|
|
516
|
+
// from the global mcp_config.json, so this spawned `agy -p` would find
|
|
517
|
+
// live pi-bridge-* entries and call tools the host bridge cannot serve
|
|
518
|
+
// outside a live provider turn ("no active antigravity turn"). agy also
|
|
519
|
+
// WATCHES that file (ReloadMcpConfig): re-enabling mid-run pokes the live
|
|
520
|
+
// delegation to reconnect. The old 5s grace timer did exactly that -
|
|
521
|
+
// observed live 2026-09-09 as a call-tool-fail ~50s into a delegation -
|
|
522
|
+
// so the entries now stay hidden for the WHOLE delegated run. The release
|
|
523
|
+
// fires only on process close/error (cleanup + finally below); if pi
|
|
524
|
+
// itself dies first, session start heals the file
|
|
525
|
+
// (healBridgeSuppression in extensions/index.ts, marker-aware). Cross-
|
|
526
|
+
// process coordination rides the suppression marker in mcp-registration.
|
|
527
|
+
// Refcounted, so overlapping delegations in this process cannot release
|
|
528
|
+
// each other's window early. A refused config fail-opens to the status quo.
|
|
529
|
+
const restoreBridge = acquireBridgeSuppression();
|
|
547
530
|
|
|
548
531
|
const statusInterval = onUpdate
|
|
549
532
|
? setInterval(() => {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Process-wide FIFO lock for human-facing dialogs (confirm/editor/select).
|
|
2
|
+
//
|
|
3
|
+
// pi's interactive UI shows ONE extension dialog at a time; an overlapping
|
|
4
|
+
// ctx.ui.* dialog call REPLACES the live dialog and the replaced promise
|
|
5
|
+
// never settles, so parallel gated tool calls hang forever (or silently lose
|
|
6
|
+
// their prompt). Holding this lock around each dialog serializes them: the
|
|
7
|
+
// first renders, the rest appear in turn as each is answered.
|
|
8
|
+
//
|
|
9
|
+
// The lock is keyed via Symbol.for because ALL pi-* extensions run in the
|
|
10
|
+
// SAME pi process and must share one queue: a per-module lock would still let
|
|
11
|
+
// a dialog from one extension clobber a dialog from another in the same
|
|
12
|
+
// parallel tool-call batch.
|
|
13
|
+
const DIALOG_LOCK: unique symbol = Symbol.for("pi-me.dialog-lock");
|
|
14
|
+
|
|
15
|
+
interface DialogQueue {
|
|
16
|
+
tail: Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function dialogQueue(): DialogQueue {
|
|
20
|
+
const host = globalThis as typeof globalThis & Record<symbol, unknown>;
|
|
21
|
+
const existing = host[DIALOG_LOCK] as DialogQueue | undefined;
|
|
22
|
+
if (existing) return existing;
|
|
23
|
+
const created: DialogQueue = { tail: Promise.resolve() };
|
|
24
|
+
host[DIALOG_LOCK] = created;
|
|
25
|
+
return created;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Run `run` while holding the cross-extension dialog lock. FIFO: each caller
|
|
30
|
+
* chains onto the queue tail synchronously (before its first await), so call
|
|
31
|
+
* order is the order the dialogs appear. Released in a finally block, so one
|
|
32
|
+
* throwing dialog can never wedge the queue for the callers behind it.
|
|
33
|
+
* NOT reentrant: calling it inside a held `run` self-deadlocks.
|
|
34
|
+
*/
|
|
35
|
+
export async function withDialogLock<T>(run: () => Promise<T>): Promise<T> {
|
|
36
|
+
const queue = dialogQueue();
|
|
37
|
+
const prev = queue.tail;
|
|
38
|
+
let release!: () => void;
|
|
39
|
+
queue.tail = new Promise<void>((resolve) => {
|
|
40
|
+
release = resolve;
|
|
41
|
+
});
|
|
42
|
+
await prev;
|
|
43
|
+
try {
|
|
44
|
+
return await run();
|
|
45
|
+
} finally {
|
|
46
|
+
release();
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/mcp-registration.ts
CHANGED
|
@@ -66,20 +66,32 @@ function writeConfig(file: string, config: McpConfig): void {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/** Register (or refresh) the bridge's per-pid server entry. Foreign servers
|
|
69
|
-
* in the file are preserved.
|
|
69
|
+
* in the file are preserved.
|
|
70
|
+
*
|
|
71
|
+
* One shared guard for delegation isolation: while ANY live delegation is
|
|
72
|
+
* in flight anywhere on the machine (per the suppression marker), a fresh
|
|
73
|
+
* registration lands disabled - the delegated agy must not discover a new
|
|
74
|
+
* bridge mid-run. The last release re-enables every entry again. */
|
|
70
75
|
export function registerBridgeServer(
|
|
71
76
|
entry: { pid: number; port: number; token: string; tokenHeader: string },
|
|
72
77
|
configPath: string = mcpConfigPath(),
|
|
73
|
-
|
|
78
|
+
opts: {
|
|
79
|
+
markerPath?: string;
|
|
80
|
+
isAlive?: (pid: number) => boolean;
|
|
81
|
+
now?: () => number;
|
|
82
|
+
} = {},
|
|
83
|
+
): { wrote: boolean; disabled: boolean; reason?: string } {
|
|
84
|
+
const marker = readSuppressionMarker(opts.markerPath ?? suppressionMarkerPath());
|
|
85
|
+
const disabled = hasLiveDelegator(marker, opts.isAlive ?? pidAlive, (opts.now ?? Date.now)());
|
|
74
86
|
const read = readConfig(configPath);
|
|
75
|
-
if (!read.ok) return { wrote: false, reason: read.reason };
|
|
87
|
+
if (!read.ok) return { wrote: false, disabled, reason: read.reason };
|
|
76
88
|
read.config.mcpServers[bridgeServerName(entry.pid)] = {
|
|
77
|
-
disabled
|
|
89
|
+
disabled,
|
|
78
90
|
headers: { [entry.tokenHeader]: entry.token },
|
|
79
91
|
serverUrl: `http://127.0.0.1:${entry.port}/mcp`,
|
|
80
92
|
} satisfies BridgeServerEntry;
|
|
81
93
|
writeConfig(configPath, read.config);
|
|
82
|
-
return { wrote: true };
|
|
94
|
+
return { wrote: true, disabled };
|
|
83
95
|
}
|
|
84
96
|
|
|
85
97
|
/** Remove the bridge's per-pid server entry (close path). */
|
|
@@ -124,19 +136,123 @@ export function setBridgeEntriesDisabled(
|
|
|
124
136
|
return { wrote: true, changed };
|
|
125
137
|
}
|
|
126
138
|
|
|
139
|
+
// --- Cross-process suppression marker --------------------------------------
|
|
140
|
+
|
|
141
|
+
/** Shape of the shared delegator marker (suppression.json). Keyed by pi pid;
|
|
142
|
+
* `since` is the acquire timestamp in ms since epoch. Coordination ONLY:
|
|
143
|
+
* the disabled flags in mcp_config.json stay the actual gate. */
|
|
144
|
+
export interface SuppressionMarker {
|
|
145
|
+
delegators: Record<string, { since: number }>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Marker path, mirroring the extensions-data convention (src/config.ts
|
|
149
|
+
* logsDir). Lives OUTSIDE ~/.gemini so agy's watched config dir stays
|
|
150
|
+
* untouched. */
|
|
151
|
+
export function suppressionMarkerPath(home: string = os.homedir()): string {
|
|
152
|
+
return path.join(
|
|
153
|
+
home,
|
|
154
|
+
".pi",
|
|
155
|
+
"extensions-data",
|
|
156
|
+
"estebanforge",
|
|
157
|
+
"pi-antigravity-bridge",
|
|
158
|
+
"suppression.json",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** A SIGKILLed pi can leave its marker entry behind, and pid reuse could
|
|
163
|
+
* keep kill(pid,0) answering forever. The age bound caps that wedge: a
|
|
164
|
+
* delegation never legitimately runs this long, so an entry this old is
|
|
165
|
+
* dead regardless of what the pid probe says. */
|
|
166
|
+
const DELEGATOR_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
167
|
+
|
|
168
|
+
function readSuppressionMarker(file: string): SuppressionMarker {
|
|
169
|
+
try {
|
|
170
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
171
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { delegators: {} };
|
|
172
|
+
const delegators = (parsed as SuppressionMarker).delegators;
|
|
173
|
+
if (!delegators || typeof delegators !== "object" || Array.isArray(delegators)) {
|
|
174
|
+
return { delegators: {} };
|
|
175
|
+
}
|
|
176
|
+
return { delegators };
|
|
177
|
+
} catch {
|
|
178
|
+
// Missing or corrupt marker: fail-open to empty. Never worse than the
|
|
179
|
+
// pre-marker blind re-enable; the next write replaces the file.
|
|
180
|
+
return { delegators: {} };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function writeSuppressionMarker(file: string, marker: SuppressionMarker): void {
|
|
185
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
186
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
187
|
+
fs.writeFileSync(tmp, JSON.stringify(marker, null, 2) + "\n", { mode: 0o600 });
|
|
188
|
+
fs.renameSync(tmp, file);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function delegatorLive(pid: string, entry: { since?: number } | undefined, isAlive: (pid: number) => boolean, now: number): boolean {
|
|
192
|
+
const n = Number(pid);
|
|
193
|
+
const since = typeof entry?.since === "number" ? entry.since : 0;
|
|
194
|
+
return Number.isFinite(n) && n > 0 && now - since < DELEGATOR_MAX_AGE_MS && isAlive(n);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function pruneDelegators(
|
|
198
|
+
marker: SuppressionMarker,
|
|
199
|
+
isAlive: (pid: number) => boolean,
|
|
200
|
+
now: number,
|
|
201
|
+
): { kept: SuppressionMarker; pruned: string[] } {
|
|
202
|
+
const kept: SuppressionMarker = { delegators: {} };
|
|
203
|
+
const pruned: string[] = [];
|
|
204
|
+
for (const [pid, entry] of Object.entries(marker.delegators)) {
|
|
205
|
+
if (delegatorLive(pid, entry, isAlive, now)) kept.delegators[pid] = { since: entry.since };
|
|
206
|
+
else pruned.push(pid);
|
|
207
|
+
}
|
|
208
|
+
return { kept, pruned };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function hasLiveDelegator(marker: SuppressionMarker, isAlive: (pid: number) => boolean, now: number): boolean {
|
|
212
|
+
return Object.entries(marker.delegators).some(([pid, entry]) => delegatorLive(pid, entry, isAlive, now));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function bestEffortWriteMarker(file: string, marker: SuppressionMarker): void {
|
|
216
|
+
try {
|
|
217
|
+
writeSuppressionMarker(file, marker);
|
|
218
|
+
} catch {
|
|
219
|
+
// Coordination hint only; a failed write must never break a release or
|
|
220
|
+
// a heal. The disabled flags in mcp_config.json remain the gate.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
127
224
|
const suppressionRefs = new Map<string, number>();
|
|
128
225
|
|
|
129
226
|
/** Reference-counted suppression for a self-spawned agy process (AskAntigravity
|
|
130
|
-
* delegation). First acquire disables every pi-bridge-* entry
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
|
|
136
|
-
|
|
227
|
+
* delegation). First acquire disables every pi-bridge-* entry AND records
|
|
228
|
+
* this process's pid in the shared suppression marker; last release removes
|
|
229
|
+
* it and re-enables only when no LIVE delegator remains - so two pi sessions
|
|
230
|
+
* delegating concurrently no longer re-enable each other's entries
|
|
231
|
+
* (previously a same-process-only refcount raced on the shared file).
|
|
232
|
+
* Nested acquires in one process are free. Residual race: concurrent
|
|
233
|
+
* read-modify-write of the marker across processes is last-writer-wins
|
|
234
|
+
* (atomic rename), and a syscall-scale interleave can still briefly re-open
|
|
235
|
+
* the bridge during a live delegation; both are bounded, self-heal at the
|
|
236
|
+
* next release/heal, and degrade to the status-quo fail-closed deny. */
|
|
237
|
+
export function acquireBridgeSuppression(opts: SuppressionOptions = {}): () => void {
|
|
238
|
+
const configPath = opts.configPath ?? mcpConfigPath();
|
|
239
|
+
const markerPath = opts.markerPath ?? suppressionMarkerPath();
|
|
240
|
+
const pid = opts.pid ?? process.pid;
|
|
241
|
+
const isAlive = opts.isAlive ?? pidAlive;
|
|
242
|
+
const now = opts.now ?? Date.now;
|
|
243
|
+
const key = `${path.resolve(markerPath)}\u0000${pid}`;
|
|
137
244
|
const refs = (suppressionRefs.get(key) ?? 0) + 1;
|
|
138
245
|
suppressionRefs.set(key, refs);
|
|
139
|
-
if (refs === 1)
|
|
246
|
+
if (refs === 1) {
|
|
247
|
+
// Marker BEFORE the config flip: a session-start heal racing between
|
|
248
|
+
// the two writes reads an empty marker and would re-enable entries for
|
|
249
|
+
// a delegation that is about to go live. Recording first shrinks that
|
|
250
|
+
// window to the config flip itself.
|
|
251
|
+
const marker = readSuppressionMarker(markerPath);
|
|
252
|
+
marker.delegators[String(pid)] = { since: now() };
|
|
253
|
+
bestEffortWriteMarker(markerPath, marker);
|
|
254
|
+
setBridgeEntriesDisabled(true, configPath);
|
|
255
|
+
}
|
|
140
256
|
let released = false;
|
|
141
257
|
return () => {
|
|
142
258
|
if (released) return;
|
|
@@ -144,10 +260,60 @@ export function acquireBridgeSuppression(configPath: string = mcpConfigPath()):
|
|
|
144
260
|
const left = Math.max(0, (suppressionRefs.get(key) ?? 1) - 1);
|
|
145
261
|
if (left === 0) suppressionRefs.delete(key);
|
|
146
262
|
else suppressionRefs.set(key, left);
|
|
147
|
-
if (left === 0)
|
|
263
|
+
if (left === 0) {
|
|
264
|
+
const marker = readSuppressionMarker(markerPath);
|
|
265
|
+
delete marker.delegators[String(pid)];
|
|
266
|
+
const { kept } = pruneDelegators(marker, isAlive, now());
|
|
267
|
+
bestEffortWriteMarker(markerPath, kept);
|
|
268
|
+
// Re-read right before the flip: an acquire that raced us between
|
|
269
|
+
// the marker write and this read is already live and must keep its
|
|
270
|
+
// suppression (a stale snapshot here would re-enable over it).
|
|
271
|
+
const fresh = readSuppressionMarker(markerPath);
|
|
272
|
+
if (Object.keys(fresh.delegators).length === 0) {
|
|
273
|
+
setBridgeEntriesDisabled(false, configPath);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
148
276
|
};
|
|
149
277
|
}
|
|
150
278
|
|
|
279
|
+
export interface SuppressionOptions {
|
|
280
|
+
/** Global mcp_config.json path (default: the real user config). */
|
|
281
|
+
configPath?: string;
|
|
282
|
+
/** Cross-process delegator marker path (default: suppression.json in the
|
|
283
|
+
* bridge's extensions-data dir). */
|
|
284
|
+
markerPath?: string;
|
|
285
|
+
/** Delegator identity; defaults to this process's pid. Injectable so tests
|
|
286
|
+
* can simulate two sessions in one process. */
|
|
287
|
+
pid?: number;
|
|
288
|
+
isAlive?: (pid: number) => boolean;
|
|
289
|
+
now?: () => number;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Session-start heal, marker-aware. Prunes dead or stale delegators, then
|
|
293
|
+
* re-enables the entries ONLY when no live delegator remains. A blind
|
|
294
|
+
* re-enable here used to un-hide the bridge during another session's active
|
|
295
|
+
* delegation - a plain session start in a second window could reproduce the
|
|
296
|
+
* fail-closed deny this file exists to prevent. */
|
|
297
|
+
export function healBridgeSuppression(opts: SuppressionOptions = {}): {
|
|
298
|
+
pruned: string[];
|
|
299
|
+
reEnabled: boolean;
|
|
300
|
+
reason?: string;
|
|
301
|
+
} {
|
|
302
|
+
const configPath = opts.configPath ?? mcpConfigPath();
|
|
303
|
+
const markerPath = opts.markerPath ?? suppressionMarkerPath();
|
|
304
|
+
const isAlive = opts.isAlive ?? pidAlive;
|
|
305
|
+
const now = opts.now ?? Date.now;
|
|
306
|
+
const marker = readSuppressionMarker(markerPath);
|
|
307
|
+
const { kept, pruned } = pruneDelegators(marker, isAlive, now());
|
|
308
|
+
if (pruned.length > 0) bestEffortWriteMarker(markerPath, kept);
|
|
309
|
+
// Decide on a FRESH read, not the pruned snapshot: an acquire that raced
|
|
310
|
+
// us after our write is already live and must keep its suppression.
|
|
311
|
+
const fresh = readSuppressionMarker(markerPath);
|
|
312
|
+
if (Object.keys(fresh.delegators).length > 0) return { pruned, reEnabled: false };
|
|
313
|
+
const flip = setBridgeEntriesDisabled(false, configPath);
|
|
314
|
+
return { pruned, reEnabled: flip.changed > 0, reason: flip.reason };
|
|
315
|
+
}
|
|
316
|
+
|
|
151
317
|
/** Default liveness probe: can the signal be delivered? */
|
|
152
318
|
function pidAlive(pid: number): boolean {
|
|
153
319
|
try {
|