@estebanforge/pi-antigravity-bridge 1.10.0 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.10.1] - 2026-11-09
6
+
7
+ ### Fixed
8
+
9
+ - **Windows: the bridge now finds the installed `agy` CLI.** `isAgyInstalled` probed bare names with a plain `stat`, which never applies `PATHEXT`, so `agy.exe` was invisible on Windows and every start reported the CLI as missing (also looping the self-service installer, whose verify uses the same probe). Bare names on `PATH` now probe `agy.exe` only, the one Windows artifact Google ships: spawns run with `shell: false` and Node throws `EINVAL` on `.bat`/`.cmd` shims (CVE-2024-27980), so a `.cmd` wrapper does not count as installed, and neither does an extensionless file. A bare `AGY_BIN=agy.exe` finds the real file instead of probing `agy.exe.EXE`, and an `AGY_BIN` set with backslashes (`C:\...\agy.exe`) is treated as a file path instead of being mangled into a `PATH` segment (issue #11).
10
+ - **Windows and fresh CLI installs: ACP turns no longer abort on the CLI's empty `mcp_config.json`.** The agy CLI's initial state is a 0-byte `%USERPROFILE%\.gemini\config\mcp_config.json`; staging the isolated ACP home tolerated only a missing file and turned the empty file into `Cannot isolate inherited MCP config: Unexpected end of JSON input`, killing every turn. Empty and whitespace-only files now stage as `{ "mcpServers": {} }`; a leading UTF-8 BOM (a common Windows-editor artifact) is stripped instead of breaking the parse; a file with contents that are not a JSON object still fails closed (issue #11). The same tolerant read now covers stream-json bridge registration, which previously refused to touch an empty or BOM-prefixed file and silently skipped registering the bridge.
11
+
12
+ ### Changed
13
+
14
+ - **The legacy ACP binary override cleanup is now automatic, and the warning speaks plainly.** Startup strips the retired `acp.bin` key from `~/.pi/agent/antigravity-bridge/config.json` in place, keeping the rest of the file untouched (a symlinked config is written through, not replaced), so the usual case never shows a warning. Only a set `AGY_ACP_BIN` environment variable still warns (the bridge cannot edit shell config), with exact removal instructions. A failed config rewrite warns with the file path and the manual fix instead of failing silently.
15
+
5
16
  ## [1.10.0] - 2026-11-08
6
17
 
7
18
  ### Changed
@@ -65,7 +65,7 @@ import { agyAuthenticationMessage } from "../src/agy-install.js";
65
65
  import { createAgySetupOffer } from "../src/agy-setup.js";
66
66
  import { ensureQueuedMessagesDefaults } from "../src/queued-messages.js";
67
67
  import type { TurnDriver, TurnOutcome } from "../src/driver-types.js";
68
- import { CONFIG_PATH, legacyAcpBinConfigured, loadConfig, logsDir, MAX_TURN_CAP_MIN, parseCapMinutes, saveConfig, type AgyMode, type BridgeTools, type Engine, type ThinkingTier } from "../src/config.js";
68
+ import { CONFIG_PATH, loadConfig, logsDir, MAX_TURN_CAP_MIN, parseCapMinutes, removeLegacyAcpBin, saveConfig, type AgyMode, type BridgeTools, type Engine, type ThinkingTier } from "../src/config.js";
69
69
  import { agyMissingMessage, ENGINE_SETTINGS_ROW_ID, engineSettingsRow, isAgyInstalled, savedEngineMessage, showEnginePicker, shouldOfferEnginePicker } from "../src/engine-picker.js";
70
70
  import { agyVersionVerdict, checkAgyCliVersion, describeAgyVersionCheck, MIN_AGY_ACP_VERSION, MIN_AGY_VERSION } from "../src/agy-version.js";
71
71
  import { isValidAgyAgentName, listAgyAgents } from "../src/agents.js";
@@ -205,7 +205,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
205
205
  // start when the gate is active; removed at session_shutdown.
206
206
  let gateScriptPath: string | null = null;
207
207
  let managedAcpCleanupRan = false;
208
- let legacyAcpWarningShown = false;
208
+ let legacyConfigCleanupRan = false;
209
+ let legacyConfigCleanupFailed = false;
210
+ let legacyEnvAcpWarningShown = false;
211
+ let legacyFileAcpWarningShown = false;
209
212
  let agySetupOffer: (() => Promise<void>) | null = null;
210
213
  let agyMissingWarned = false;
211
214
  let agyVersionWarned = false;
@@ -616,9 +619,28 @@ export default async function (pi: ExtensionAPI): Promise<void> {
616
619
  console.error(`[antigravity-bridge] engine picker failed: ${String(err)}`);
617
620
  }
618
621
  }
619
- if (!legacyAcpWarningShown && legacyAcpBinConfigured()) {
620
- legacyAcpWarningShown = true;
621
- const msg = "Legacy AGY_ACP_BIN/acp.bin settings are ignored. Turns use `agy acp`; remove the stale override.";
622
+ // The retired acp.bin key heals itself in the config file; the env var
623
+ // lives outside our reach, so it is the only case that still warns.
624
+ // Independent once-flags: a failed cleanup must never suppress the
625
+ // env-var warning (or vice versa) for the rest of the process.
626
+ if (!legacyConfigCleanupRan) {
627
+ legacyConfigCleanupRan = true;
628
+ try {
629
+ removeLegacyAcpBin();
630
+ } catch (err) {
631
+ legacyConfigCleanupFailed = true;
632
+ fileLog.log("legacy-acp-config-cleanup", { error: String(err) }, "error");
633
+ }
634
+ }
635
+ if (!legacyEnvAcpWarningShown && process.env.AGY_ACP_BIN?.trim()) {
636
+ legacyEnvAcpWarningShown = true;
637
+ const msg = "AGY_ACP_BIN is set but no longer used; turns run \"agy acp\" from the CLI. Remove it from your shell config, then start a new terminal.";
638
+ if (ctx.hasUI) ctx.ui.notify(msg, "warning");
639
+ else console.error(`[antigravity-bridge] ${msg}`);
640
+ }
641
+ if (!legacyFileAcpWarningShown && legacyConfigCleanupFailed) {
642
+ legacyFileAcpWarningShown = true;
643
+ const msg = `Couldn't remove the old "bin" setting from ${CONFIG_PATH} (details in the bridge log). Delete the "bin" line under "acp" by hand; turns run "agy acp" from the CLI.`;
622
644
  if (ctx.hasUI) ctx.ui.notify(msg, "warning");
623
645
  else console.error(`[antigravity-bridge] ${msg}`);
624
646
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.10.0",
3
+ "version": "1.10.1",
4
4
  "description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. Use antigravity/* models in Pi. Full /model picker support. Models runs Pi's tools. ToS safe to use.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/acp/home.ts CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { isPidAlive } from "../mcp-server.js";
5
+ import { readGlobalMcpConfig } from "../mcp-registration.js";
5
6
 
6
7
  function record(value: unknown, label: string): Record<string, unknown> {
7
8
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must contain a JSON object`);
@@ -23,7 +24,9 @@ export function stageAcpHome(opts: { geminiHome?: string; parentDir?: string } =
23
24
  const globalMcpPath = path.join(globalConfigDir, "mcp_config.json");
24
25
  let config: Record<string, unknown> = { mcpServers: {} };
25
26
  try {
26
- config = record(JSON.parse(fs.readFileSync(globalMcpPath, "utf8")), globalMcpPath);
27
+ // Tolerant of the CLI's 0-byte fresh-install file and a UTF-8 BOM;
28
+ // only a missing file means "no config at all" (issue #11).
29
+ config = record(readGlobalMcpConfig(globalMcpPath), globalMcpPath);
27
30
  } catch (err) {
28
31
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
29
32
  throw new Error(`Cannot isolate inherited MCP config ${globalMcpPath}: ${err instanceof Error ? err.message : String(err)}`);
package/src/config.ts CHANGED
@@ -251,12 +251,19 @@ function readConfigFile(configPath: string): Partial<AgyConfig> {
251
251
  return {};
252
252
  }
253
253
 
254
- export function legacyAcpBinConfigured(configPath: string = CONFIG_PATH): boolean {
255
- if (process.env.AGY_ACP_BIN?.trim()) return true;
254
+ /** Strip the retired acp.bin key from the raw config file; returns true when
255
+ * removed. Raw passthrough on purpose (no defaults merge): users keep their
256
+ * minimal hand-edited shape instead of gaining the full default set. */
257
+ export function removeLegacyAcpBin(configPath: string = CONFIG_PATH): boolean {
256
258
  const file = readConfigFile(configPath) as Record<string, unknown>;
257
259
  if (!file.acp || typeof file.acp !== "object" || Array.isArray(file.acp)) return false;
258
- const bin = (file.acp as Record<string, unknown>).bin;
259
- return typeof bin === "string" && bin.trim().length > 0;
260
+ const acp = file.acp as Record<string, unknown>;
261
+ if (!("bin" in acp)) return false;
262
+ delete acp.bin;
263
+ // Rename would replace a dotfiles-managed symlink with a regular file;
264
+ // write through the resolved target so the link survives.
265
+ writeConfigJson(fs.realpathSync(configPath), file);
266
+ return true;
260
267
  }
261
268
 
262
269
  /** Load config merged over defaults. Env vars override the file when set. */
@@ -419,6 +426,24 @@ function narrowEngine(raw: string): Engine {
419
426
  return raw === "acp" || raw === "stream-json" ? raw : DEFAULT_ENGINE;
420
427
  }
421
428
 
429
+ /** Atomic JSON write (temp + rename, 0600) shared by every config writer. */
430
+ function writeConfigJson(configPath: string, value: unknown): void {
431
+ const dir = path.dirname(configPath);
432
+ fs.mkdirSync(dir, { recursive: true });
433
+ const tmp = `${configPath}.${process.pid}.tmp`;
434
+ try {
435
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 0o600 });
436
+ fs.renameSync(tmp, configPath);
437
+ } catch (err) {
438
+ try {
439
+ fs.unlinkSync(tmp);
440
+ } catch {
441
+ /* nothing to clean */
442
+ }
443
+ throw err;
444
+ }
445
+ }
446
+
422
447
  /** Atomically persist a config patch (temp + rename). Merges onto the RAW
423
448
  * FILE values, never loadConfig(): env overrides must not be baked into the
424
449
  * file (AGY_READONLY=1 in the env would otherwise persist readOnly: true to
@@ -451,20 +476,7 @@ export function saveConfig(patch: Partial<AgyConfig>, configPath: string = CONFI
451
476
  ...patch,
452
477
  engine: narrowEngine(String(persistedEngine).toLowerCase()),
453
478
  };
454
- const dir = path.dirname(configPath);
455
- fs.mkdirSync(dir, { recursive: true });
456
- const tmp = `${configPath}.${process.pid}.tmp`;
457
- try {
458
- fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
459
- fs.renameSync(tmp, configPath);
460
- } catch (err) {
461
- try {
462
- fs.unlinkSync(tmp);
463
- } catch {
464
- /* nothing to clean */
465
- }
466
- throw err;
467
- }
479
+ writeConfigJson(configPath, next);
468
480
  return next;
469
481
  }
470
482
 
@@ -85,28 +85,51 @@ export function engineSettingsRow(engine: Engine): DialogRow {
85
85
  };
86
86
  }
87
87
 
88
- /** True when the `agy` CLI binary can be found. A binRef with a path
89
- * separator (AGY_BIN=/opt/agy/agy) must exist as a file; a bare name is
90
- * searched on PATH. statSync cannot throw through the guards, but a race
91
- * (file removed between listing and stat) fails closed to false. */
92
- export function isAgyInstalled(binRef: string, env: NodeJS.ProcessEnv = process.env): boolean {
93
- if (binRef.includes("/")) {
88
+ /** Bare-name suffix candidates per platform. Windows pins the probe to
89
+ * agy.exe: that is the only artifact Google ships there, and .bat/.cmd
90
+ * cannot spawn anyway (shell:false + EINVAL since CVE-2024-27980). */
91
+ function executableSuffixes(platform: NodeJS.Platform): string[] {
92
+ return platform === "win32" ? [".EXE"] : [""];
93
+ }
94
+
95
+ /** True when the `agy` CLI binary can be found. A binRef carrying a path
96
+ * separator must exist as a file - backslashes count as separators on
97
+ * win32, where path.join would mangle a bare "C:\..." AGY_BIN into a PATH
98
+ * segment. Otherwise the bare name is searched on PATH, appending the
99
+ * Windows candidate (agy.exe only: Google ships nothing else there, and
100
+ * an extensionless or .cmd file cannot be spawned anyway). A bare
101
+ * AGY_BIN that already ends in .exe probes the raw name too. Only a
102
+ * regular file counts; races fail closed. */
103
+ export function isAgyInstalled(
104
+ binRef: string,
105
+ env: NodeJS.ProcessEnv = process.env,
106
+ platform: NodeJS.Platform = process.platform,
107
+ ): boolean {
108
+ if (binRef.includes("/") || (platform === "win32" && binRef.includes("\\"))) {
94
109
  try {
95
110
  return fs.statSync(binRef).isFile();
96
111
  } catch {
97
112
  return false;
98
113
  }
99
114
  }
115
+ let suffixes = executableSuffixes(platform);
116
+ // A bare AGY_BIN=agy.exe already carries the extension; name+suffix
117
+ // would stat agy.exe.EXE and miss the real file (issue #11 class).
118
+ if (platform === "win32" && path.extname(binRef).toLowerCase() === ".exe") {
119
+ suffixes = ["", ...suffixes];
120
+ }
100
121
  return (env.PATH ?? "")
101
122
  .split(path.delimiter)
102
123
  .filter(Boolean)
103
- .some((dir) => {
104
- try {
105
- return fs.statSync(path.join(dir, binRef)).isFile();
106
- } catch {
107
- return false;
108
- }
109
- });
124
+ .some((dir) =>
125
+ suffixes.some((suffix) => {
126
+ try {
127
+ return fs.statSync(path.join(dir, binRef) + suffix).isFile();
128
+ } catch {
129
+ return false;
130
+ }
131
+ }),
132
+ );
110
133
  }
111
134
 
112
135
  /** Toast copy for the missing-CLI warning. Fires every pi start while the
@@ -36,19 +36,29 @@ export interface BridgeServerEntry {
36
36
 
37
37
  type McpConfig = { mcpServers: Record<string, unknown> };
38
38
 
39
+ /** Tolerant read of the global mcp_config.json: strips a UTF-8 BOM and
40
+ * treats the CLI's fresh-install 0-byte file as no config (issue #11).
41
+ * Content that is not a JSON object still throws; ENOENT propagates.
42
+ * A malformed mcpServers value is NOT normalized here - staging wants
43
+ * strictness; registration normalizes at its own call site. */
44
+ export function readGlobalMcpConfig(file: string): McpConfig {
45
+ const raw = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "");
46
+ const parsed = raw.trim() === "" ? { mcpServers: {} } : JSON.parse(raw);
47
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
48
+ throw new Error("mcp_config.json is not an object");
49
+ }
50
+ return parsed as McpConfig;
51
+ }
52
+
39
53
  function readConfig(file: string): { ok: true; config: McpConfig } | { ok: false; reason: string } {
40
- let parsed: unknown;
54
+ let config: McpConfig;
41
55
  try {
42
- parsed = JSON.parse(fs.readFileSync(file, "utf8"));
56
+ config = readGlobalMcpConfig(file);
43
57
  } catch (err) {
44
58
  const code = (err as NodeJS.ErrnoException).code;
45
59
  if (code === "ENOENT") return { ok: true, config: { mcpServers: {} } };
46
60
  return { ok: false, reason: `mcp_config.json is not valid JSON; refusing to touch it (${String(err)})` };
47
61
  }
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
62
  if (!config.mcpServers || typeof config.mcpServers !== "object" || Array.isArray(config.mcpServers)) {
53
63
  config.mcpServers = {};
54
64
  }