@estebanforge/pi-antigravity-bridge 1.4.3 → 1.4.4
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 +16 -0
- package/README.md +2 -2
- package/docs/ARCHITECTURE.md +1 -1
- package/extensions/index.ts +61 -59
- package/package.json +1 -1
- package/src/acp/browser-capture.ts +104 -0
- package/src/acp/connection.ts +37 -0
- package/src/acp/driver.ts +4 -0
- package/src/acp/setup.ts +20 -7
- package/src/config.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.4.4] - 2026-09-04
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- The Google sign-in URL now surfaces in pi. The ACP server hands the OAuth URL only to the browser-open call (nothing on stdout/stderr, headless or not), so a `BROWSER` wrapper script records the URL and forwards to the real opener; the connection watches the record file and logs it as an `auth-url` event. The extension toasts it at warning level with the ssh port-forward command for the redirect port, so logins work from SSH sessions on remote machines; local users keep the automatic browser open and get the URL as a fallback. An existing `BROWSER` setting is chained, not replaced.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- ACP login pending was reported only at the moment setup wrote `settings.json`, so after the restart, with `settings.json` in place but the browser login never completed, every check went silent: no toast, no hint, while the first ACP message had no token to use. `needsLogin` now tracks the token file (stat only, never read): `oauth-personal` without `acp_token.json` is login-pending, so the switch-time toast, the picker, and the `session_start` self-heal keep saying so on every start until the login is done.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- Engine switching is command-only now: `/agy engine stream-json|acp`. The bare-`/agy` settings picker no longer carries an Engine row (nor the post-save setup run), so the switch surface cannot be hit accidentally; `/agy engine acp` keeps the self-service setup (binary + auth) and the login warning. The picker's plan+acp (RC01) guard stays in a narrower form: the mode row alone can still produce `plan` while the engine is `acp`.
|
|
18
|
+
- ACP login-pending messages rewritten for end users: what happens (the server opens the Google sign-in page in your browser on the first Antigravity message), when (after the restart or immediately, per moment), and which account (your Antigravity subscription, the same Google account as the `agy` CLI). No URL to open manually. All remaining moments (engine switch, session start) now toast at warning level so the pending action stands out.
|
|
19
|
+
- The "Turn engine" wording became "Engine" in the README env table, the config comment, and the architecture doc heading; the picker row itself is gone (engine switching is command-only, see above).
|
|
20
|
+
|
|
5
21
|
## [1.4.3] - 2026-09-04
|
|
6
22
|
|
|
7
23
|
### Added
|
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ Turns run through one of two engines behind the same provider surface (`config.e
|
|
|
25
25
|
|
|
26
26
|
Engine-dependent features: pi image attachments ride natively only on the ACP engine (the picker offers image attach automatically when `config.engine` is `acp`; the stream-json CLI prompt is text-only). With the optional G1 digest enabled, its delivery also differs: ACP ships it as a native `embeddedContext` resource block, stream-json prepends it to the prompt text.
|
|
27
27
|
|
|
28
|
-
Switch with `/agy engine acp|stream-json` (takes effect on restart). Setup is automatic: switching to `acp` installs Google's official ACP server binary from the [antigravity-acp registry entry](https://github.com/agentclientprotocol/registry) (`~/.local/opt/agy-acp/<build>/` + a `current` symlink, zip sha256 recorded; layout and pinning in [docs/ACP-ADOPTION-PLAN.md](docs/ACP-ADOPTION-PLAN.md)) and prepares the login. The login is your Antigravity subscription: on your first ACP message the server opens the Google login in your browser, and you sign in with the same account and plan you use for the Antigravity CLI (`agy`). It is no different from logging into the CLI; the server just keeps its own token file on your machine, like any Google tool, and this extension never sees your credentials. If you also export `GEMINI_API_KEY`, it is ignored: the server uses the auth type in settings.json, and setup always writes `oauth-personal`. A session start self-heals the same way, silently when everything is ready. Manual instructions (`/agy acp-auth`) surface only when a step fails. Sessions are engine-scoped, so switching engines never crosses conversations.
|
|
28
|
+
Switch with `/agy engine acp|stream-json` (takes effect on restart). Setup is automatic: switching to `acp` installs Google's official ACP server binary from the [antigravity-acp registry entry](https://github.com/agentclientprotocol/registry) (`~/.local/opt/agy-acp/<build>/` + a `current` symlink, zip sha256 recorded; layout and pinning in [docs/ACP-ADOPTION-PLAN.md](docs/ACP-ADOPTION-PLAN.md)) and prepares the login. The login is your Antigravity subscription: on your first ACP message the server opens the Google login in your browser, and you sign in with the same account and plan you use for the Antigravity CLI (`agy`). If no browser is available (an SSH session on a remote machine), pi shows the sign-in URL to copy, plus the ssh port-forward command for the login redirect. It is no different from logging into the CLI; the server just keeps its own token file on your machine, like any Google tool, and this extension never sees your credentials. If you also export `GEMINI_API_KEY`, it is ignored: the server uses the auth type in settings.json, and setup always writes `oauth-personal`. A session start self-heals the same way, silently when everything is ready. Manual instructions (`/agy acp-auth`) surface only when a step fails. Sessions are engine-scoped, so switching engines never crosses conversations.
|
|
29
29
|
|
|
30
30
|
## What it cannot do
|
|
31
31
|
|
|
@@ -154,7 +154,7 @@ For isolation when running any agent that executes commands without a confirmati
|
|
|
154
154
|
| Variable | Purpose |
|
|
155
155
|
| --- | --- |
|
|
156
156
|
| `AGY_BIN` | Path to the agy binary. Defaults to `agy` on PATH. |
|
|
157
|
-
| `AGY_ENGINE` |
|
|
157
|
+
| `AGY_ENGINE` | Engine: `stream-json` (default) or `acp`. Wins over the config file. |
|
|
158
158
|
| `AGY_ACP_BIN` | Path to the ACP server binary (`agy_acp_server.par`). Defaults to `agy_acp_server.par` on PATH. Wins over `config.acp.bin`. When neither points at a binary, auto-setup installs one. |
|
|
159
159
|
| `AGY_EXTRA_ARGS` | Extra args appended to every invocation. Whitespace-split. |
|
|
160
160
|
| `AGY_CONVERSATIONS_DIR` | Override the conversations DB directory. |
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
How the provider works internally. For build/test/debug workflow see [DEVELOPMENT.md](./DEVELOPMENT.md).
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Engine
|
|
6
6
|
|
|
7
7
|
The provider ships two turn engines behind one contract (`TurnDriver`,
|
|
8
8
|
`src/driver-types.ts`): the default **stream-json engine** (below) and the
|
package/extensions/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { SessionStore } from "../src/sessions.js";
|
|
|
40
40
|
import { ToolRoundTrips, WrapperReplay, createStreamSimple } from "../src/provider.js";
|
|
41
41
|
import { AgyDriver } from "../src/driver.js";
|
|
42
42
|
import { AcpDriver } from "../src/acp/driver.js";
|
|
43
|
+
import { setupAuthUrlCapture } from "../src/acp/browser-capture.js";
|
|
43
44
|
import { ensureAcpReady, inspectAcpSetup } from "../src/acp/setup.js";
|
|
44
45
|
import type { TurnDriver } from "../src/driver-types.js";
|
|
45
46
|
import { CONFIG_PATH, loadConfig, saveConfig, type AgyMode, type BridgeTools, type Engine, type ThinkingTier } from "../src/config.js";
|
|
@@ -58,6 +59,12 @@ import { mapAgyToolToNative } from "../src/native-tools.js";
|
|
|
58
59
|
import { Type } from "typebox";
|
|
59
60
|
import { patchStatus, restorePatch } from "../src/patch-cleanup.js";
|
|
60
61
|
|
|
62
|
+
// Last UI seen (session_start / /agy commands). The ACP login URL arrives
|
|
63
|
+
// via the driver log sink, which has no command context; the stash lets that
|
|
64
|
+
// sink toast instead of only logging to stderr. Module scope: both the
|
|
65
|
+
// default export (session_start, log sink) and registerAgyCommand assign it.
|
|
66
|
+
let activeUi: ExtensionUIContext | null = null;
|
|
67
|
+
|
|
61
68
|
function resolveAgyBinary(): string {
|
|
62
69
|
return process.env.AGY_BIN || "agy";
|
|
63
70
|
}
|
|
@@ -102,6 +109,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
102
109
|
// ACP self-heal runs once per process (session_start re-fires on /reload;
|
|
103
110
|
// a ready setup is two file stats, so re-running is harmless anyway).
|
|
104
111
|
let acpSelfHealRan = false;
|
|
112
|
+
// OAuth URL capture: the server hands the login URL only to the
|
|
113
|
+
// browser-open call (nothing on stdio), so a BROWSER wrapper records it
|
|
114
|
+
// and the driver logs it as "auth-url". Local users keep the automatic
|
|
115
|
+
// browser open; over SSH the URL surfaces for copy-paste with the
|
|
116
|
+
// port-forward command.
|
|
117
|
+
const authCapture = setupAuthUrlCapture();
|
|
118
|
+
if (!authCapture && process.platform !== "win32") {
|
|
119
|
+
// Rare (unwritable data dir). Surfacing the diagnostic: without it,
|
|
120
|
+
// login URLs would silently stop appearing on headless boxes.
|
|
121
|
+
console.error("[antigravity-bridge] OAuth URL capture unavailable; login URL surfacing is off (setup failed).");
|
|
122
|
+
}
|
|
105
123
|
// Two turn engines behind one contract (plan §9): stream-json (tested
|
|
106
124
|
// default) and the official ACP server (opt-in via config.engine, off by
|
|
107
125
|
// default). Neither spawns anything until its first turn.
|
|
@@ -121,7 +139,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
121
139
|
// Resolved per connection: the setup flow can install the binary and
|
|
122
140
|
// update acp.bin mid-session; the next turn picks it up (no restart).
|
|
123
141
|
bin: () => loadConfig().acp.bin,
|
|
142
|
+
...(authCapture ? { extraEnv: authCapture.browserEnv, authUrlFile: authCapture.file } : {}),
|
|
124
143
|
log: (msg, data) => {
|
|
144
|
+
if (msg === "auth-url") {
|
|
145
|
+
const { url, port } = (data ?? {}) as { url?: string; port?: number | null };
|
|
146
|
+
if (!url) return;
|
|
147
|
+
const ssh = port ? `\nSSH session? Forward the port on your machine first:\n ssh -N -L ${port}:127.0.0.1:${port} <user@host>` : "";
|
|
148
|
+
const text = `Google sign-in URL for the ACP engine:\n${url}${ssh}`;
|
|
149
|
+
if (activeUi) activeUi.notify(text, "warning");
|
|
150
|
+
else console.error(`[antigravity-bridge acp] ${text}`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
125
153
|
if (!acpFailures.has(msg)) return;
|
|
126
154
|
console.error(`[antigravity-bridge acp] ${msg}${data !== undefined ? " " + JSON.stringify(data) : ""}`);
|
|
127
155
|
},
|
|
@@ -241,6 +269,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
241
269
|
// normal toolUse loop (native cards, permissions, hooks) - no patch, no
|
|
242
270
|
// privileged API. Started on session_start, torn down on session_shutdown.
|
|
243
271
|
pi.on("session_start", async (_event, ctx) => {
|
|
272
|
+
if (ctx.hasUI) activeUi = ctx.ui;
|
|
244
273
|
// Legacy cleanup: users who ran the old consent-gated patcher still
|
|
245
274
|
// carry pi.invokeTool in their installed pi. Inert, but tell them once
|
|
246
275
|
// and offer /agy patch-cleanup. Never auto-edits the install.
|
|
@@ -270,8 +299,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
270
299
|
saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
|
|
271
300
|
}
|
|
272
301
|
if (status.needsLogin) {
|
|
273
|
-
const msg = acpLoginPending("Your next
|
|
274
|
-
if (ctx.hasUI) ctx.ui.notify(msg, "
|
|
302
|
+
const msg = acpLoginPending("Your next Antigravity message opens the Google sign-in page in your browser.");
|
|
303
|
+
if (ctx.hasUI) ctx.ui.notify(msg, "warning");
|
|
275
304
|
else console.error(`[antigravity-bridge] ${msg}`);
|
|
276
305
|
}
|
|
277
306
|
return;
|
|
@@ -372,6 +401,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
372
401
|
}
|
|
373
402
|
});
|
|
374
403
|
pi.on("session_shutdown", async () => {
|
|
404
|
+
// The UI is going away; a later auth-url must fall back to stderr
|
|
405
|
+
// instead of toasting into a dead UI (where it would be lost).
|
|
406
|
+
activeUi = null;
|
|
375
407
|
const h = mcpHandle;
|
|
376
408
|
mcpHandle = null;
|
|
377
409
|
await h?.close();
|
|
@@ -384,11 +416,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
384
416
|
// --- /agy command -----------------------------------------------------------
|
|
385
417
|
|
|
386
418
|
/** Shared body for every ACP-login-pending moment (session_start self-heal,
|
|
387
|
-
* /agy engine acp, picker)
|
|
388
|
-
*
|
|
389
|
-
*
|
|
419
|
+
* /agy engine acp, picker). End-user simple: what happens, when, which
|
|
420
|
+
* account. The server opens the browser itself, so there is no URL to
|
|
421
|
+
* open manually. `browserTrigger` says when that happens. */
|
|
390
422
|
function acpLoginPending(browserTrigger: string): string {
|
|
391
|
-
return `One-time
|
|
423
|
+
return `One-time sign-in needed to finish ACP setup. ${browserTrigger} Use the Google account of your Antigravity subscription (the same account as your agy CLI login). If no browser opens, pi shows the sign-in URL to copy. The token stays on your machine; this extension never sees it.`;
|
|
392
424
|
}
|
|
393
425
|
|
|
394
426
|
interface AgyCommandCtx {
|
|
@@ -403,7 +435,6 @@ interface AgyCommandCtx {
|
|
|
403
435
|
}
|
|
404
436
|
|
|
405
437
|
interface PendingConfig {
|
|
406
|
-
engine?: Engine;
|
|
407
438
|
mode?: AgyMode;
|
|
408
439
|
skipPermissions?: boolean;
|
|
409
440
|
defaultModel?: string;
|
|
@@ -447,6 +478,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
447
478
|
"Antigravity provider: status, doctor, settings picker, clear sessions. Usage: /agy [status|doctor|engine stream-json|acp|mode plan|accept-edits|permissions on|off|ask on|off|model <alias>|thinking low|medium|high|bridge all|mcp|none|digest on|off|system-prompt on|off|acp-bin <path|auto>|acp-auth|patch-cleanup|clear]",
|
|
448
479
|
handler: async (args, cmdCtx: ExtensionCommandContext) => {
|
|
449
480
|
const ui = cmdCtx.ui;
|
|
481
|
+
if (ui) activeUi = ui;
|
|
450
482
|
const mode = cmdCtx.mode;
|
|
451
483
|
const sub = (args ?? "").trim().split(/\s+/)[0]?.toLowerCase();
|
|
452
484
|
const val = (args ?? "").trim().split(/\s+/)[1]?.toLowerCase();
|
|
@@ -501,12 +533,14 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
501
533
|
return;
|
|
502
534
|
}
|
|
503
535
|
saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
536
|
+
if (status.needsLogin) {
|
|
537
|
+
ui?.notify(
|
|
538
|
+
`ACP engine set. ${acpLoginPending("After the restart (pi restart or /reload), your first Antigravity message opens the Google sign-in page in your browser.")}`,
|
|
539
|
+
"warning",
|
|
540
|
+
);
|
|
541
|
+
} else {
|
|
542
|
+
ui?.notify(`ACP engine ready (auth: ${status.auth}). Takes effect on the next pi start (or /reload).`, "info");
|
|
543
|
+
}
|
|
510
544
|
} else {
|
|
511
545
|
ui?.notify(`current engine: ${loadConfig().engine}\nusage: /agy engine stream-json|acp`, "info");
|
|
512
546
|
}
|
|
@@ -547,8 +581,10 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
547
581
|
" the antigravity-acp registry; point acp.bin or AGY_ACP_BIN at it.",
|
|
548
582
|
'2. Default: put {"auth":{"type":"oauth-personal"}} in',
|
|
549
583
|
" ~/.gemini/antigravity-acp/settings.json, run one turn, and complete the",
|
|
550
|
-
" Google login that opens in your browser (
|
|
551
|
-
"
|
|
584
|
+
" Google login that opens in your browser. No browser (SSH session)?",
|
|
585
|
+
" pi shows the sign-in URL to copy; forward the redirect port over ssh",
|
|
586
|
+
" (ssh -N -L <port>:127.0.0.1:<port> <user@host>), then open the URL",
|
|
587
|
+
" on your machine.",
|
|
552
588
|
' Headless alternative: GEMINI_API_KEY + {"auth":{"type":"gemini-api-key"}}',
|
|
553
589
|
" (metered paid API - not your Antigravity plan). The key is used only",
|
|
554
590
|
" when that type is selected; with the default oauth-personal in place,",
|
|
@@ -711,23 +747,15 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
711
747
|
});
|
|
712
748
|
}
|
|
713
749
|
|
|
714
|
-
/** Interactive settings picker (TUI only). Rows: the
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
*
|
|
750
|
+
/** Interactive settings picker (TUI only). Rows: the runtime config surface
|
|
751
|
+
* (mode, permissions, model, thinking, bridge, digest, system prompt).
|
|
752
|
+
* Engine switching is command-only: /agy engine stream-json|acp (the
|
|
753
|
+
* command also runs self-service binary + auth setup for acp). */
|
|
718
754
|
async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promise<void> {
|
|
719
755
|
const config = loadConfig();
|
|
720
756
|
const pending: PendingConfig = {};
|
|
721
757
|
|
|
722
758
|
const items: SettingItem[] = [
|
|
723
|
-
{
|
|
724
|
-
id: "engine",
|
|
725
|
-
label: "Turn engine",
|
|
726
|
-
description:
|
|
727
|
-
"stream-json: the streaming engine (default, supported). acp: Google's official ACP server (experimental, opt-in). Switch takes effect on the next pi start (or /reload); acp also runs binary + auth setup on save.",
|
|
728
|
-
currentValue: config.engine,
|
|
729
|
-
values: ["stream-json", "acp"],
|
|
730
|
-
},
|
|
731
759
|
{
|
|
732
760
|
id: "mode",
|
|
733
761
|
label: "Execution mode",
|
|
@@ -804,9 +832,7 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
|
|
|
804
832
|
Math.min(items.length + 4, 15),
|
|
805
833
|
getSettingsListTheme(),
|
|
806
834
|
(id, newValue) => {
|
|
807
|
-
if (id === "
|
|
808
|
-
pending.engine = newValue as Engine;
|
|
809
|
-
} else if (id === "mode") {
|
|
835
|
+
if (id === "mode") {
|
|
810
836
|
pending.mode = newValue as AgyMode;
|
|
811
837
|
} else if (id === "permissions") {
|
|
812
838
|
pending.skipPermissions = newValue === "auto-approved";
|
|
@@ -840,16 +866,13 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
|
|
|
840
866
|
|
|
841
867
|
if (Object.keys(pending).length === 0) return;
|
|
842
868
|
|
|
843
|
-
// The
|
|
844
|
-
//
|
|
845
|
-
// review-only mode (RC01); refuse the combination.
|
|
846
|
-
// to the on-disk config so a fresh engine pick over a saved plan (and
|
|
847
|
-
// vice versa) is caught too.
|
|
848
|
-
const nextEngine = pending.engine ?? ctx.engine;
|
|
869
|
+
// The picker cannot switch engines (command-only: /agy engine), but the
|
|
870
|
+
// mode row can still produce plan while the latched engine is acp. ACP
|
|
871
|
+
// has no review-only mode (RC01); refuse the combination.
|
|
849
872
|
const nextMode = pending.mode ?? config.mode;
|
|
850
|
-
if (nextMode === "plan" &&
|
|
873
|
+
if (nextMode === "plan" && ctx.engine === "acp") {
|
|
851
874
|
ui.notify(
|
|
852
|
-
"plan + acp is not supported (RC01): the ACP engine has no review-only mode.
|
|
875
|
+
"plan + acp is not supported (RC01): the ACP engine has no review-only mode. /agy engine stream-json first, or /agy mode accept-edits.",
|
|
853
876
|
"warning",
|
|
854
877
|
);
|
|
855
878
|
return;
|
|
@@ -858,7 +881,6 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
|
|
|
858
881
|
try {
|
|
859
882
|
const next = saveConfig(pending);
|
|
860
883
|
const changed = [
|
|
861
|
-
pending.engine !== undefined ? `engine=${next.engine}` : null,
|
|
862
884
|
pending.mode ? `mode=${next.mode}` : null,
|
|
863
885
|
pending.skipPermissions !== undefined
|
|
864
886
|
? `permissions=${next.skipPermissions ? "auto-approved" : "prompt"}`
|
|
@@ -873,26 +895,6 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
|
|
|
873
895
|
.filter(Boolean)
|
|
874
896
|
.join(", ");
|
|
875
897
|
ui.notify(`Saved: ${changed}`, "info");
|
|
876
|
-
if (pending.engine === "acp") {
|
|
877
|
-
// Same flow as /agy engine acp: persist the switch, then prepare
|
|
878
|
-
// the server so the restart just works.
|
|
879
|
-
ui.notify("Preparing the ACP server (binary + auth)…", "info");
|
|
880
|
-
const status = await ensureAcpReady({
|
|
881
|
-
configBin: loadConfig().acp.bin,
|
|
882
|
-
onProgress: (m) => ui.notify(m, "info"),
|
|
883
|
-
});
|
|
884
|
-
if (status.ok) {
|
|
885
|
-
saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
|
|
886
|
-
ui.notify(
|
|
887
|
-
status.needsLogin
|
|
888
|
-
? `ACP server ready. ${acpLoginPending("Your first ACP message (after the restart) opens the browser;")}`
|
|
889
|
-
: `ACP server ready (auth: ${status.auth}).`,
|
|
890
|
-
"info",
|
|
891
|
-
);
|
|
892
|
-
} else {
|
|
893
|
-
ui.notify(`ACP auto-setup failed (${status.error}).\n${status.manual}`, "warning");
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
898
|
} catch (err) {
|
|
897
899
|
ui.notify(
|
|
898
900
|
`Failed to save config: ${err instanceof Error ? err.message : String(err)}`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-antigravity-bridge",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.4",
|
|
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",
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
// The ACP server hands the Google OAuth URL ONLY to the browser-open call:
|
|
6
|
+
// nothing on stdout or stderr, headless or not (verified per
|
|
7
|
+
// docs/ACP-PROTOCOL-REFERENCE.md). The capture makes the URL visible:
|
|
8
|
+
// BROWSER points at a wrapper that appends the URL to a record file and then
|
|
9
|
+
// execs the real opener, and the connection watches the record file to log
|
|
10
|
+
// "auth-url". Local users keep the automatic browser open; over SSH the user
|
|
11
|
+
// copies the URL and forwards the redirect port.
|
|
12
|
+
|
|
13
|
+
export interface AuthUrlCapture {
|
|
14
|
+
/** Spawn env for the ACP server (BROWSER -> wrapper script). */
|
|
15
|
+
browserEnv: Record<string, string>;
|
|
16
|
+
/** Record file the connection watches. */
|
|
17
|
+
file: string;
|
|
18
|
+
/** Most recent URL in the record file, or null. */
|
|
19
|
+
lastUrl(): string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** First existing executable named `name` on `env.PATH`, or null. */
|
|
23
|
+
export function findOnPath(name: string, env: NodeJS.ProcessEnv = process.env): string | null {
|
|
24
|
+
for (const dir of (env.PATH ?? "").split(path.delimiter)) {
|
|
25
|
+
if (!dir) continue;
|
|
26
|
+
const candidate = path.join(dir, name);
|
|
27
|
+
try {
|
|
28
|
+
const st = fs.statSync(candidate);
|
|
29
|
+
if (st.isFile() && (st.mode & 0o111) !== 0) return candidate;
|
|
30
|
+
} catch {
|
|
31
|
+
/* not here */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Last http(s) line of `file`, or null (absent/unreadable counts as empty). */
|
|
38
|
+
export function readLastUrl(file: string): string | null {
|
|
39
|
+
try {
|
|
40
|
+
const lines = fs.readFileSync(file, "utf8").split("\n").filter((l) => /^https?:\/\//.test(l));
|
|
41
|
+
return lines.at(-1) ?? null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Port of the OAuth loopback redirect (redirect_uri param), or null. */
|
|
48
|
+
export function parseAuthPort(url: string): number | null {
|
|
49
|
+
try {
|
|
50
|
+
const redirect = new URL(url).searchParams.get("redirect_uri");
|
|
51
|
+
if (!redirect) return null;
|
|
52
|
+
const port = new URL(redirect).port;
|
|
53
|
+
return port ? Number(port) : null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const shQuote = (s: string): string => `'${s.replace(/'/g, "'\\''")}'`;
|
|
60
|
+
|
|
61
|
+
/** Create the capture (record file + wrapper script) and return the spawn
|
|
62
|
+
* env. Idempotent: the wrapper is rewritten only when content changes. The
|
|
63
|
+
* opener to forward to is resolved once, at write time: an existing BROWSER
|
|
64
|
+
* wins, then xdg-open, then macOS open; with none available the wrapper
|
|
65
|
+
* records the URL and exits (headless: capture without an opener). The
|
|
66
|
+
* forward word-splits the BROWSER value, so flag forms (`open -a Chrome`)
|
|
67
|
+
* work; a BROWSER path containing spaces does not (rare, and BROWSER lists
|
|
68
|
+
* are not honored either - the first entry is what chains). Windows browser
|
|
69
|
+
* resolution differs; no capture there. */
|
|
70
|
+
export function setupAuthUrlCapture(dataDir = path.join(os.homedir(), ".pi", "agent", "antigravity-bridge"), env: NodeJS.ProcessEnv = process.env): AuthUrlCapture | null {
|
|
71
|
+
if (process.platform === "win32") return null;
|
|
72
|
+
const file = path.join(dataDir, "acp-auth-urls.log");
|
|
73
|
+
const wrapper = path.join(dataDir, "acp-browser-wrapper.sh");
|
|
74
|
+
try {
|
|
75
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
76
|
+
// Truncate: the file mirrors the current login attempt only. OAuth
|
|
77
|
+
// URLs carry state + PKCE challenges, so keep the record private.
|
|
78
|
+
fs.writeFileSync(file, "", { mode: 0o600 });
|
|
79
|
+
const opener = env.BROWSER?.trim() || findOnPath("xdg-open", env) || findOnPath("open", env) || "";
|
|
80
|
+
const body = [
|
|
81
|
+
"#!/usr/bin/env sh",
|
|
82
|
+
"# Antigravity bridge: records the Google OAuth URL the ACP server hands",
|
|
83
|
+
"# to the browser, then forwards to the real opener. The bridge watches",
|
|
84
|
+
"# the record file and shows the URL in pi (copy it when logging in over",
|
|
85
|
+
"# SSH; forward the redirect port first).",
|
|
86
|
+
`printf '%s\\n' "$@" >> ${shQuote(file)}`,
|
|
87
|
+
`REAL=${shQuote(opener)}`,
|
|
88
|
+
'if [ -n "$REAL" ]; then set -f; exec $REAL "$@"; fi',
|
|
89
|
+
"",
|
|
90
|
+
].join("\n");
|
|
91
|
+
const write = (p: string, content: string, mode: number): void => {
|
|
92
|
+
try {
|
|
93
|
+
if (fs.readFileSync(p, "utf8") === content) return;
|
|
94
|
+
} catch {
|
|
95
|
+
/* new file */
|
|
96
|
+
}
|
|
97
|
+
fs.writeFileSync(p, content, { mode });
|
|
98
|
+
};
|
|
99
|
+
write(wrapper, body, 0o755);
|
|
100
|
+
return { browserEnv: { BROWSER: wrapper }, file, lastUrl: () => readLastUrl(file) };
|
|
101
|
+
} catch {
|
|
102
|
+
return null; // capture is best effort; login still works without it
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/acp/connection.ts
CHANGED
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
// LIST; a dead URL is accepted at session time (lazy connect)
|
|
21
21
|
|
|
22
22
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
23
|
+
import fs from "node:fs";
|
|
23
24
|
import os from "node:os";
|
|
24
25
|
import path from "node:path";
|
|
26
|
+
import { parseAuthPort, readLastUrl } from "./browser-capture.js";
|
|
25
27
|
import { JsonRpcResponseError, JsonRpcSession } from "./jsonrpc.js";
|
|
26
28
|
|
|
27
29
|
export interface AcpMcpServer {
|
|
@@ -63,6 +65,11 @@ export interface AcpConnectionOptions {
|
|
|
63
65
|
* "auto" selects the first allow option; "deny" fail-closes to the first
|
|
64
66
|
* reject option. Absent = deny (fail closed). */
|
|
65
67
|
permissions?: () => "auto" | "deny";
|
|
68
|
+
/** Record file for BROWSER-captured OAuth URLs (src/acp/browser-capture.ts).
|
|
69
|
+
* The server hands the login URL only to the browser-open call, so the
|
|
70
|
+
* wrapper records it there; the connection watches the file and logs
|
|
71
|
+
* "auth-url" {url, port} for every new URL. */
|
|
72
|
+
authUrlFile?: string;
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
const INIT_TIMEOUT_MS = 30_000;
|
|
@@ -80,6 +87,8 @@ export class AcpConnection {
|
|
|
80
87
|
agentInfo: Record<string, unknown> | undefined;
|
|
81
88
|
/** Last known mode/model config echo from set_config_option results. */
|
|
82
89
|
lastConfigOptions: unknown = undefined;
|
|
90
|
+
#authUrlWatcher: fs.FSWatcher | undefined;
|
|
91
|
+
#lastAuthUrl: string | null = null;
|
|
83
92
|
|
|
84
93
|
constructor(opts: AcpConnectionOptions) {
|
|
85
94
|
this.#opts = opts;
|
|
@@ -117,6 +126,7 @@ export class AcpConnection {
|
|
|
117
126
|
...(this.#opts.extraEnv ? { env: { ...process.env, ...this.#opts.extraEnv } } : {}),
|
|
118
127
|
});
|
|
119
128
|
this.#child = child;
|
|
129
|
+
if (this.#opts.authUrlFile) this.#watchAuthUrl();
|
|
120
130
|
child.stdout?.setEncoding("utf8");
|
|
121
131
|
child.stderr?.setEncoding("utf8");
|
|
122
132
|
// Pipe failures arrive asynchronously as stream 'error' events; the sync
|
|
@@ -373,9 +383,36 @@ export class AcpConnection {
|
|
|
373
383
|
}
|
|
374
384
|
}
|
|
375
385
|
|
|
386
|
+
/** Surface OAuth login URLs captured by the BROWSER wrapper: every new
|
|
387
|
+
* URL in the record file is logged once as "auth-url" {url, port}. Best
|
|
388
|
+
* effort only - a broken watch never affects turns. */
|
|
389
|
+
#watchAuthUrl(): void {
|
|
390
|
+
const file = this.#opts.authUrlFile!;
|
|
391
|
+
try {
|
|
392
|
+
const watcher = fs.watch(file, () => this.#emitAuthUrl());
|
|
393
|
+
// Without a listener, a watch 'error' event escapes as uncaught.
|
|
394
|
+
watcher.on("error", () => {
|
|
395
|
+
/* record file gone: login URL surfacing is off until respawn */
|
|
396
|
+
});
|
|
397
|
+
this.#authUrlWatcher = watcher;
|
|
398
|
+
} catch {
|
|
399
|
+
/* no watch; the one-shot check below still covers earlier writes */
|
|
400
|
+
}
|
|
401
|
+
this.#emitAuthUrl(); // URLs recorded before the watch started
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
#emitAuthUrl(): void {
|
|
405
|
+
const url = readLastUrl(this.#opts.authUrlFile!);
|
|
406
|
+
if (!url || url === this.#lastAuthUrl) return;
|
|
407
|
+
this.#lastAuthUrl = url;
|
|
408
|
+
this.#opts.log("auth-url", { url, port: parseAuthPort(url) });
|
|
409
|
+
}
|
|
410
|
+
|
|
376
411
|
#finish(reason: string): void {
|
|
377
412
|
if (this.#exited) return;
|
|
378
413
|
this.#exited = true;
|
|
414
|
+
this.#authUrlWatcher?.close();
|
|
415
|
+
this.#authUrlWatcher = undefined;
|
|
379
416
|
this.abortAll(`connection exited: ${reason || "process gone"}`);
|
|
380
417
|
this.#opts.onExit({ code: null, signal: null, stderrTail: this.#stderrTail });
|
|
381
418
|
}
|
package/src/acp/driver.ts
CHANGED
|
@@ -41,6 +41,9 @@ export interface AcpDriverOptions {
|
|
|
41
41
|
/** Extra argv for the binary (tests: node + fake-server script). */
|
|
42
42
|
binArgs?: string[];
|
|
43
43
|
extraEnv?: Record<string, string>;
|
|
44
|
+
/** Record file for BROWSER-captured OAuth URLs; passed to the connection,
|
|
45
|
+
* which logs "auth-url" when the server tries to open a login. */
|
|
46
|
+
authUrlFile?: string;
|
|
44
47
|
/** Bridge registration for session/new AND session/load. */
|
|
45
48
|
mcpServers?: () => AcpMcpServer[];
|
|
46
49
|
log?: (msg: string, data?: unknown) => void;
|
|
@@ -422,6 +425,7 @@ export class AcpDriver implements TurnDriver {
|
|
|
422
425
|
bin: resolveAcpBinary(typeof this.#opts.bin === "function" ? this.#opts.bin() : this.#opts.bin),
|
|
423
426
|
binArgs: this.#opts.binArgs,
|
|
424
427
|
extraEnv: this.#opts.extraEnv,
|
|
428
|
+
authUrlFile: this.#opts.authUrlFile,
|
|
425
429
|
cwd: request.cwd,
|
|
426
430
|
mcpServers: this.#opts.mcpServers,
|
|
427
431
|
log: (msg, data) => this.#log(msg, data),
|
package/src/acp/setup.ts
CHANGED
|
@@ -227,6 +227,18 @@ export interface AuthState {
|
|
|
227
227
|
type?: string;
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
/** Token presence WITHOUT reading any credential value: acp_token.json is
|
|
231
|
+
* stat()ed, never opened. The token only exists after the browser login
|
|
232
|
+
* round-trip, so this is the real proof of a completed login. */
|
|
233
|
+
function tokenPresent(target: string): boolean {
|
|
234
|
+
try {
|
|
235
|
+
fs.statSync(path.join(target, "acp_token.json"));
|
|
236
|
+
return true;
|
|
237
|
+
} catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
230
242
|
/** Auth state WITHOUT reading any credential value: settings.json carries
|
|
231
243
|
* only auth.type (+ non-secret gcp placement), acp_token.json is stat()ed. */
|
|
232
244
|
export function readAuthState(dir?: string): AuthState {
|
|
@@ -240,12 +252,7 @@ export function readAuthState(dir?: string): AuthState {
|
|
|
240
252
|
} catch {
|
|
241
253
|
/* absent or garbage = unconfigured */
|
|
242
254
|
}
|
|
243
|
-
|
|
244
|
-
fs.statSync(path.join(target, "acp_token.json"));
|
|
245
|
-
return { configured: true, type: "token" };
|
|
246
|
-
} catch {
|
|
247
|
-
return { configured: false };
|
|
248
|
-
}
|
|
255
|
+
return tokenPresent(target) ? { configured: true, type: "token" } : { configured: false };
|
|
249
256
|
}
|
|
250
257
|
|
|
251
258
|
/** Write a minimal auth block into settings.json. The server reads this file
|
|
@@ -344,12 +351,18 @@ export async function ensureAcpReady(opts: SetupOptions = {}): Promise<AcpSetupS
|
|
|
344
351
|
};
|
|
345
352
|
}
|
|
346
353
|
}
|
|
354
|
+
// settings.json alone proves nothing: setup writes it before the login,
|
|
355
|
+
// the token file only appears after the browser round-trip. oauth-personal
|
|
356
|
+
// without a token = login still pending, and every caller (switch toast,
|
|
357
|
+
// session_start self-heal) must keep saying so until the token exists.
|
|
358
|
+
// gemini-api-key has no browser login; a present token file means done.
|
|
359
|
+
const needsLogin = auth.type !== "gemini-api-key" && !tokenPresent(gdir);
|
|
347
360
|
return {
|
|
348
361
|
ok: true,
|
|
349
362
|
bin: binary.bin!,
|
|
350
363
|
binarySource: binary.source!,
|
|
351
364
|
auth: auth.type ?? "token",
|
|
352
|
-
needsLogin
|
|
365
|
+
needsLogin,
|
|
353
366
|
actions,
|
|
354
367
|
};
|
|
355
368
|
}
|
package/src/config.ts
CHANGED
|
@@ -39,7 +39,7 @@ export interface AcpConfig {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
export interface AgyConfig {
|
|
42
|
-
/**
|
|
42
|
+
/** Engine. Switching requires a pi restart (drivers wire at load). */
|
|
43
43
|
engine: Engine;
|
|
44
44
|
/** Official-server ACP engine options (used when engine = "acp"). */
|
|
45
45
|
acp: AcpConfig;
|