@estebanforge/pi-antigravity-bridge 1.4.3 → 1.4.5
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 +26 -0
- package/README.md +4 -3
- package/docs/ACP-ADOPTION-PLAN.md +2 -2
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/extensions/index.ts +118 -69
- package/package.json +1 -1
- package/src/acp/auth.ts +71 -0
- package/src/acp/browser-capture.ts +104 -0
- package/src/acp/connection.ts +39 -2
- package/src/acp/driver.ts +4 -0
- package/src/acp/setup.ts +31 -13
- package/src/config.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.4.5] - 2026-09-04
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `/agy auth` subcommand: runs the antigravity-acp sign-in on demand (spawns the server, sends `authenticate`, waits for the browser round-trip to complete; minutes-scale). Signing in no longer rides the first Antigravity message, and both login-pending moments now point at `/agy auth`. The sign-in URL toast (and its ssh port-forward hint) fires during `/agy auth` exactly as it does during turns. After the authenticate reply the run waits up to 5 s for `acp_token.json` before killing the sign-in server, and a second concurrent `/agy auth` fails fast instead of racing the token write.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- `/agy acp-auth` renamed to `/agy auth-manual` (no alias): it prints the manual credential setup for those who want it. Auth-error remediation, MANUAL_SETUP, README, and docs now point at `/agy auth` / `/agy auth-manual`, and the stale first-message login wording is gone from the manual steps.
|
|
14
|
+
|
|
15
|
+
## [1.4.4] - 2026-09-04
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- 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.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- 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.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- 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`.
|
|
28
|
+
- 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.
|
|
29
|
+
- 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).
|
|
30
|
+
|
|
5
31
|
## [1.4.3] - 2026-09-04
|
|
6
32
|
|
|
7
33
|
### 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:
|
|
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: the same account and plan you use for the Antigravity CLI (`agy`). Sign in explicitly with `/agy auth` (engine `acp` selected): it opens the Google login in your browser and completes when you finish it. 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 auth-manual`) 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
|
|
|
@@ -121,6 +121,7 @@ The `activate_skill` catalog mirrors pi's directory-based skill discovery: the t
|
|
|
121
121
|
/agy status, or open the full settings picker (TUI)
|
|
122
122
|
/agy status print current settings + session counts
|
|
123
123
|
/agy doctor bridge state, driver counters, bridge port, last lifecycle events
|
|
124
|
+
/agy auth run the antigravity-acp sign-in now (engine acp): opens the Google login in your browser, shows the URL when no browser opens
|
|
124
125
|
/agy mode plan review-only: agy plans but writes nothing
|
|
125
126
|
/agy mode accept-edits agy applies edits directly (default)
|
|
126
127
|
/agy permissions on|off auto-approve / prompt for tool calls (see warning)
|
|
@@ -132,7 +133,7 @@ The `activate_skill` catalog mirrors pi's directory-based skill discovery: the t
|
|
|
132
133
|
/agy bridge all|mcp|none which pi tools the MCP bridge exposes to agy (default all; none = bridge off)
|
|
133
134
|
/agy acp-bin <path|auto> point the ACP engine at a specific server binary (auto = setup installs, or AGY_ACP_BIN; applies on the next ACP turn)
|
|
134
135
|
/agy engine acp|stream-json switch the turn engine (restart to apply; default stream-json; acp runs self-service setup: binary install + auth bootstrap)
|
|
135
|
-
/agy
|
|
136
|
+
/agy auth-manual manual ACP credential setup (fallback; auto-setup normally covers this; default login = your Antigravity subscription, same account as the agy CLI)
|
|
136
137
|
/agy patch-cleanup restore the original pi files if an older version patched them
|
|
137
138
|
/agy clear drop all session bindings (force fresh conversations)
|
|
138
139
|
```
|
|
@@ -154,7 +155,7 @@ For isolation when running any agent that executes commands without a confirmati
|
|
|
154
155
|
| Variable | Purpose |
|
|
155
156
|
| --- | --- |
|
|
156
157
|
| `AGY_BIN` | Path to the agy binary. Defaults to `agy` on PATH. |
|
|
157
|
-
| `AGY_ENGINE` |
|
|
158
|
+
| `AGY_ENGINE` | Engine: `stream-json` (default) or `acp`. Wins over the config file. |
|
|
158
159
|
| `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
160
|
| `AGY_EXTRA_ARGS` | Extra args appended to every invocation. Whitespace-split. |
|
|
160
161
|
| `AGY_CONVERSATIONS_DIR` | Override the conversations DB directory. |
|
|
@@ -325,7 +325,7 @@ Tested with the parity suite (section 11) and `scripts/parity-live.mjs`
|
|
|
325
325
|
| Human decision latency on parked G9 tool round-trips exceeds the 10m overall-turn cap | Turn killed mid-wait | `AcpDriver` pauses the overall timer while parked, re-armed with REMAINING budget; every park carries its own timeout (9.3) |
|
|
326
326
|
| Binary size (1.5 GB + 117 MB) | Disk + update churn | Pinned install layout (section 12), registry pin by build id |
|
|
327
327
|
| aarch64 TCMalloc startup failure (forum report, Chromebook) | arch-specific | x86_64 unaffected; gate install per-arch, link the forum issue in README |
|
|
328
|
-
| Separate auth state from `agy` CLI | First-run auth friction | `/agy
|
|
328
|
+
| Separate auth state from `agy` CLI | First-run auth friction | `/agy auth-manual` prints exact instructions; `gemini-api-key` path works headless via `GEMINI_API_KEY`. The agent never writes credentials |
|
|
329
329
|
| Thought text changes rendering volume | Noisy transcript | Route through the existing thinking block pipeline, close-on-switch unchanged; no new block types |
|
|
330
330
|
| Two engines drift on behavior | Confusing support surface | Shared provider layer + shared parity suite run against both engines in CI |
|
|
331
331
|
|
|
@@ -827,7 +827,7 @@ Install layout:
|
|
|
827
827
|
request (issue) once we depend on this.
|
|
828
828
|
- `--uid=` from the linux registry entry: the server ran without it here.
|
|
829
829
|
Treat as a launcher artifact; revisit if a future build refuses to start.
|
|
830
|
-
- Auth onboarding: `/agy
|
|
830
|
+
- Auth onboarding: `/agy auth-manual` prints the four methods with exact
|
|
831
831
|
`settings.json` shapes (source: official Zed docs, section 2.3). Headless
|
|
832
832
|
path: `GEMINI_API_KEY` + `auth.type: "gemini-api-key"`. The agent NEVER
|
|
833
833
|
writes or reads credentials; it prints instructions and verifies by
|
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/docs/DEVELOPMENT.md
CHANGED
|
@@ -37,7 +37,7 @@ AGY_LIVE=1 node --experimental-strip-types scripts/smoke-stream-json.mjs
|
|
|
37
37
|
|
|
38
38
|
# Live smoke for the ACP engine through OUR driver stack. OPT-IN: spends a
|
|
39
39
|
# little quota. Needs AGY_ACP_BIN (or acp on PATH) and a one-time
|
|
40
|
-
# /agy
|
|
40
|
+
# /agy auth-manual credential setup.
|
|
41
41
|
AGY_ACP_LIVE=1 AGY_ACP_BIN=~/.local/opt/agy-acp/current/agy_acp_server.par \
|
|
42
42
|
npx tsx scripts/smoke-acp.mjs
|
|
43
43
|
|
package/extensions/index.ts
CHANGED
|
@@ -40,6 +40,8 @@ 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 { runAcpAuth } from "../src/acp/auth.js";
|
|
44
|
+
import { setupAuthUrlCapture } from "../src/acp/browser-capture.js";
|
|
43
45
|
import { ensureAcpReady, inspectAcpSetup } from "../src/acp/setup.js";
|
|
44
46
|
import type { TurnDriver } from "../src/driver-types.js";
|
|
45
47
|
import { CONFIG_PATH, loadConfig, saveConfig, type AgyMode, type BridgeTools, type Engine, type ThinkingTier } from "../src/config.js";
|
|
@@ -58,6 +60,12 @@ import { mapAgyToolToNative } from "../src/native-tools.js";
|
|
|
58
60
|
import { Type } from "typebox";
|
|
59
61
|
import { patchStatus, restorePatch } from "../src/patch-cleanup.js";
|
|
60
62
|
|
|
63
|
+
// Last UI seen (session_start / /agy commands). The ACP login URL arrives
|
|
64
|
+
// via the driver log sink, which has no command context; the stash lets that
|
|
65
|
+
// sink toast instead of only logging to stderr. Module scope: both the
|
|
66
|
+
// default export (session_start, log sink) and registerAgyCommand assign it.
|
|
67
|
+
let activeUi: ExtensionUIContext | null = null;
|
|
68
|
+
|
|
61
69
|
function resolveAgyBinary(): string {
|
|
62
70
|
return process.env.AGY_BIN || "agy";
|
|
63
71
|
}
|
|
@@ -102,6 +110,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
102
110
|
// ACP self-heal runs once per process (session_start re-fires on /reload;
|
|
103
111
|
// a ready setup is two file stats, so re-running is harmless anyway).
|
|
104
112
|
let acpSelfHealRan = false;
|
|
113
|
+
// OAuth URL capture: the server hands the login URL only to the
|
|
114
|
+
// browser-open call (nothing on stdio), so a BROWSER wrapper records it
|
|
115
|
+
// and the driver logs it as "auth-url". Local users keep the automatic
|
|
116
|
+
// browser open; over SSH the URL surfaces for copy-paste with the
|
|
117
|
+
// port-forward command.
|
|
118
|
+
const authCapture = setupAuthUrlCapture();
|
|
119
|
+
if (!authCapture && process.platform !== "win32") {
|
|
120
|
+
// Rare (unwritable data dir). Surfacing the diagnostic: without it,
|
|
121
|
+
// login URLs would silently stop appearing on headless boxes.
|
|
122
|
+
console.error("[antigravity-bridge] OAuth URL capture unavailable; login URL surfacing is off (setup failed).");
|
|
123
|
+
}
|
|
105
124
|
// Two turn engines behind one contract (plan §9): stream-json (tested
|
|
106
125
|
// default) and the official ACP server (opt-in via config.engine, off by
|
|
107
126
|
// default). Neither spawns anything until its first turn.
|
|
@@ -117,14 +136,27 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
117
136
|
"unsupported-server-request",
|
|
118
137
|
]);
|
|
119
138
|
const legacyDriver = new AgyDriver();
|
|
139
|
+
// Shared ACP log sink (driver turns AND /agy auth): the login URL event
|
|
140
|
+
// toasts so SSH users can copy it; genuine failures reach stderr.
|
|
141
|
+
const acpLog = (msg: string, data?: unknown): void => {
|
|
142
|
+
if (msg === "auth-url") {
|
|
143
|
+
const { url, port } = (data ?? {}) as { url?: string; port?: number | null };
|
|
144
|
+
if (!url) return;
|
|
145
|
+
const ssh = port ? `\nSSH session? Forward the port on your machine first:\n ssh -N -L ${port}:127.0.0.1:${port} <user@host>` : "";
|
|
146
|
+
const text = `Google sign-in URL for the ACP engine:\n${url}${ssh}`;
|
|
147
|
+
if (activeUi) activeUi.notify(text, "warning");
|
|
148
|
+
else console.error(`[antigravity-bridge acp] ${text}`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!acpFailures.has(msg)) return;
|
|
152
|
+
console.error(`[antigravity-bridge acp] ${msg}${data !== undefined ? " " + JSON.stringify(data) : ""}`);
|
|
153
|
+
};
|
|
120
154
|
const acpDriver = new AcpDriver({
|
|
121
155
|
// Resolved per connection: the setup flow can install the binary and
|
|
122
156
|
// update acp.bin mid-session; the next turn picks it up (no restart).
|
|
123
157
|
bin: () => loadConfig().acp.bin,
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
console.error(`[antigravity-bridge acp] ${msg}${data !== undefined ? " " + JSON.stringify(data) : ""}`);
|
|
127
|
-
},
|
|
158
|
+
...(authCapture ? { extraEnv: authCapture.browserEnv, authUrlFile: authCapture.file } : {}),
|
|
159
|
+
log: acpLog,
|
|
128
160
|
mcpServers: () => {
|
|
129
161
|
const handle = mcpHandle;
|
|
130
162
|
if (!handle) return [];
|
|
@@ -204,6 +236,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
204
236
|
acpDriver,
|
|
205
237
|
engine,
|
|
206
238
|
getMcpPort: () => mcpHandle?.port ?? null,
|
|
239
|
+
acpLog,
|
|
240
|
+
authCapture: authCapture ?? null,
|
|
207
241
|
});
|
|
208
242
|
|
|
209
243
|
// AskAntigravity tool: one-shot delegation to agy (ported from
|
|
@@ -241,6 +275,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
241
275
|
// normal toolUse loop (native cards, permissions, hooks) - no patch, no
|
|
242
276
|
// privileged API. Started on session_start, torn down on session_shutdown.
|
|
243
277
|
pi.on("session_start", async (_event, ctx) => {
|
|
278
|
+
if (ctx.hasUI) activeUi = ctx.ui;
|
|
244
279
|
// Legacy cleanup: users who ran the old consent-gated patcher still
|
|
245
280
|
// carry pi.invokeTool in their installed pi. Inert, but tell them once
|
|
246
281
|
// and offer /agy patch-cleanup. Never auto-edits the install.
|
|
@@ -270,8 +305,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
270
305
|
saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
|
|
271
306
|
}
|
|
272
307
|
if (status.needsLogin) {
|
|
273
|
-
const msg = acpLoginPending(
|
|
274
|
-
if (ctx.hasUI) ctx.ui.notify(msg, "
|
|
308
|
+
const msg = acpLoginPending();
|
|
309
|
+
if (ctx.hasUI) ctx.ui.notify(msg, "warning");
|
|
275
310
|
else console.error(`[antigravity-bridge] ${msg}`);
|
|
276
311
|
}
|
|
277
312
|
return;
|
|
@@ -372,6 +407,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
372
407
|
}
|
|
373
408
|
});
|
|
374
409
|
pi.on("session_shutdown", async () => {
|
|
410
|
+
// The UI is going away; a later auth-url must fall back to stderr
|
|
411
|
+
// instead of toasting into a dead UI (where it would be lost).
|
|
412
|
+
activeUi = null;
|
|
375
413
|
const h = mcpHandle;
|
|
376
414
|
mcpHandle = null;
|
|
377
415
|
await h?.close();
|
|
@@ -384,11 +422,11 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
384
422
|
// --- /agy command -----------------------------------------------------------
|
|
385
423
|
|
|
386
424
|
/** Shared body for every ACP-login-pending moment (session_start self-heal,
|
|
387
|
-
* /agy engine acp
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
function acpLoginPending(
|
|
391
|
-
return `One-time
|
|
425
|
+
* /agy engine acp). End-user simple: what to run, what happens, which
|
|
426
|
+
* account. Sign-in is explicit (/agy auth); it never rides the first
|
|
427
|
+
* message. */
|
|
428
|
+
function acpLoginPending(): string {
|
|
429
|
+
return `One-time sign-in needed to finish ACP setup. Run /agy auth: the Google sign-in opens in your browser. 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
430
|
}
|
|
393
431
|
|
|
394
432
|
interface AgyCommandCtx {
|
|
@@ -400,10 +438,14 @@ interface AgyCommandCtx {
|
|
|
400
438
|
/** Engine latched at extension load (see the provider wiring note). */
|
|
401
439
|
engine: Engine;
|
|
402
440
|
getMcpPort: () => number | null;
|
|
441
|
+
/** Shared ACP log sink (login URL surfacing + failure events). */
|
|
442
|
+
acpLog: (msg: string, data?: unknown) => void;
|
|
443
|
+
/** BROWSER-capture handles; null when unavailable (Windows, unwritable
|
|
444
|
+
* data dir). /agy auth passes them to the sign-in process. */
|
|
445
|
+
authCapture: { browserEnv: Record<string, string>; file: string } | null;
|
|
403
446
|
}
|
|
404
447
|
|
|
405
448
|
interface PendingConfig {
|
|
406
|
-
engine?: Engine;
|
|
407
449
|
mode?: AgyMode;
|
|
408
450
|
skipPermissions?: boolean;
|
|
409
451
|
defaultModel?: string;
|
|
@@ -436,7 +478,7 @@ function statusText(ctx: AgyCommandCtx): string {
|
|
|
436
478
|
row("digest:", config.digest ? "on" : "off"),
|
|
437
479
|
row("system prompt:", config.systemPrompt ? "on" : "off"),
|
|
438
480
|
"",
|
|
439
|
-
"Subcommands: /agy engine stream-json|acp, /agy mode plan|accept-edits, /agy permissions on|off, /agy ask on|off, /agy model <alias>, /agy thinking low|medium|high, /agy bridge all|mcp|none, /agy digest on|off, /agy system-prompt on|off, /agy acp-bin <path|auto>, /agy
|
|
481
|
+
"Subcommands: /agy auth, /agy auth-manual, /agy engine stream-json|acp, /agy mode plan|accept-edits, /agy permissions on|off, /agy ask on|off, /agy model <alias>, /agy thinking low|medium|high, /agy bridge all|mcp|none, /agy digest on|off, /agy system-prompt on|off, /agy acp-bin <path|auto>, /agy patch-cleanup, /agy clear",
|
|
440
482
|
].join("\n");
|
|
441
483
|
}
|
|
442
484
|
|
|
@@ -444,9 +486,10 @@ function statusText(ctx: AgyCommandCtx): string {
|
|
|
444
486
|
function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
445
487
|
pi.registerCommand("agy", {
|
|
446
488
|
description:
|
|
447
|
-
"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>|
|
|
489
|
+
"Antigravity provider: status, doctor, settings picker, clear sessions. Usage: /agy [status|doctor|auth|auth-manual|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>|patch-cleanup|clear]",
|
|
448
490
|
handler: async (args, cmdCtx: ExtensionCommandContext) => {
|
|
449
491
|
const ui = cmdCtx.ui;
|
|
492
|
+
if (ui) activeUi = ui;
|
|
450
493
|
const mode = cmdCtx.mode;
|
|
451
494
|
const sub = (args ?? "").trim().split(/\s+/)[0]?.toLowerCase();
|
|
452
495
|
const val = (args ?? "").trim().split(/\s+/)[1]?.toLowerCase();
|
|
@@ -501,17 +544,55 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
501
544
|
return;
|
|
502
545
|
}
|
|
503
546
|
saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
547
|
+
if (status.needsLogin) {
|
|
548
|
+
ui?.notify(
|
|
549
|
+
`ACP engine set. ${acpLoginPending()}`,
|
|
550
|
+
"warning",
|
|
551
|
+
);
|
|
552
|
+
} else {
|
|
553
|
+
ui?.notify(`ACP engine ready (auth: ${status.auth}). Takes effect on the next pi start (or /reload).`, "info");
|
|
554
|
+
}
|
|
510
555
|
} else {
|
|
511
556
|
ui?.notify(`current engine: ${loadConfig().engine}\nusage: /agy engine stream-json|acp`, "info");
|
|
512
557
|
}
|
|
513
558
|
return;
|
|
514
559
|
}
|
|
560
|
+
if (sub === "auth") {
|
|
561
|
+
// Gate on the CONFIGURED engine (disk + env), not the latched one:
|
|
562
|
+
// /agy engine acp followed by /agy auth in the same session works,
|
|
563
|
+
// no restart needed before signing in.
|
|
564
|
+
if (loadConfig().engine !== "acp") {
|
|
565
|
+
ui?.notify(`the selected engine is ${loadConfig().engine}. /agy engine acp first, then /agy auth.`, "warning");
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
ui?.notify("Preparing the ACP server (binary + auth settings)…", "info");
|
|
569
|
+
const status = await ensureAcpReady({ configBin: loadConfig().acp.bin, onProgress: (m) => ui?.notify(m, "info") });
|
|
570
|
+
if (!status.ok) {
|
|
571
|
+
ui?.notify(`ACP auto-setup failed (${status.error}).\n${status.manual}`, "warning");
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
|
|
575
|
+
if (!status.needsLogin) {
|
|
576
|
+
ui?.notify(`Already signed in (auth: ${status.auth}). Nothing to do.`, "info");
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
ui?.notify(
|
|
580
|
+
"Signing in: the Google sign-in opens in your browser and completes when you finish it (minutes-scale). If no browser opens, pi shows the sign-in URL to copy.",
|
|
581
|
+
"info",
|
|
582
|
+
);
|
|
583
|
+
const r = await runAcpAuth({
|
|
584
|
+
bin: status.bin,
|
|
585
|
+
extraEnv: ctx.authCapture?.browserEnv,
|
|
586
|
+
authUrlFile: ctx.authCapture?.file,
|
|
587
|
+
log: ctx.acpLog,
|
|
588
|
+
});
|
|
589
|
+
if (r.ok) {
|
|
590
|
+
ui?.notify("Signed in. The ACP engine is ready; takes effect on the next pi start (or /reload).", "info");
|
|
591
|
+
} else {
|
|
592
|
+
ui?.notify(`ACP sign-in failed (${r.error}).\nRun /agy auth to retry; /agy auth-manual has manual steps.`, "warning");
|
|
593
|
+
}
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
515
596
|
if (sub === "acp-bin") {
|
|
516
597
|
const rest = (args ?? "").trim().split(/\s+/).slice(1).join(" ");
|
|
517
598
|
if (rest.length > 0) {
|
|
@@ -530,7 +611,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
530
611
|
}
|
|
531
612
|
return;
|
|
532
613
|
}
|
|
533
|
-
if (sub === "
|
|
614
|
+
if (sub === "auth-manual") {
|
|
534
615
|
ui?.notify(
|
|
535
616
|
[
|
|
536
617
|
"ACP engine authentication (one-time; usually automatic -",
|
|
@@ -546,14 +627,16 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
546
627
|
"1. Server binary: auto-setup installs it. Manual: agy_acp_server.par from",
|
|
547
628
|
" the antigravity-acp registry; point acp.bin or AGY_ACP_BIN at it.",
|
|
548
629
|
'2. Default: put {"auth":{"type":"oauth-personal"}} in',
|
|
549
|
-
" ~/.gemini/antigravity-acp/settings.json, run
|
|
550
|
-
" Google login that opens in your browser
|
|
551
|
-
"
|
|
630
|
+
" ~/.gemini/antigravity-acp/settings.json, run /agy auth, and complete",
|
|
631
|
+
" the Google login that opens in your browser. No browser (SSH",
|
|
632
|
+
" session)? pi shows the sign-in URL to copy; forward the redirect",
|
|
633
|
+
" port over ssh (ssh -N -L <port>:127.0.0.1:<port> <user@host>), then",
|
|
634
|
+
" open the URL on your machine.",
|
|
552
635
|
' Headless alternative: GEMINI_API_KEY + {"auth":{"type":"gemini-api-key"}}',
|
|
553
636
|
" (metered paid API - not your Antigravity plan). The key is used only",
|
|
554
637
|
" when that type is selected; with the default oauth-personal in place,",
|
|
555
638
|
" an exported key is ignored.",
|
|
556
|
-
"3.
|
|
639
|
+
"3. /agy doctor shows the server version when auth is OK.",
|
|
557
640
|
].join("\n"),
|
|
558
641
|
"info",
|
|
559
642
|
);
|
|
@@ -711,23 +794,15 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
711
794
|
});
|
|
712
795
|
}
|
|
713
796
|
|
|
714
|
-
/** Interactive settings picker (TUI only). Rows: the
|
|
715
|
-
*
|
|
716
|
-
*
|
|
717
|
-
*
|
|
797
|
+
/** Interactive settings picker (TUI only). Rows: the runtime config surface
|
|
798
|
+
* (mode, permissions, model, thinking, bridge, digest, system prompt).
|
|
799
|
+
* Engine switching is command-only: /agy engine stream-json|acp (the
|
|
800
|
+
* command also runs self-service binary + auth setup for acp). */
|
|
718
801
|
async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promise<void> {
|
|
719
802
|
const config = loadConfig();
|
|
720
803
|
const pending: PendingConfig = {};
|
|
721
804
|
|
|
722
805
|
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
806
|
{
|
|
732
807
|
id: "mode",
|
|
733
808
|
label: "Execution mode",
|
|
@@ -804,9 +879,7 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
|
|
|
804
879
|
Math.min(items.length + 4, 15),
|
|
805
880
|
getSettingsListTheme(),
|
|
806
881
|
(id, newValue) => {
|
|
807
|
-
if (id === "
|
|
808
|
-
pending.engine = newValue as Engine;
|
|
809
|
-
} else if (id === "mode") {
|
|
882
|
+
if (id === "mode") {
|
|
810
883
|
pending.mode = newValue as AgyMode;
|
|
811
884
|
} else if (id === "permissions") {
|
|
812
885
|
pending.skipPermissions = newValue === "auto-approved";
|
|
@@ -840,16 +913,13 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
|
|
|
840
913
|
|
|
841
914
|
if (Object.keys(pending).length === 0) return;
|
|
842
915
|
|
|
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;
|
|
916
|
+
// The picker cannot switch engines (command-only: /agy engine), but the
|
|
917
|
+
// mode row can still produce plan while the latched engine is acp. ACP
|
|
918
|
+
// has no review-only mode (RC01); refuse the combination.
|
|
849
919
|
const nextMode = pending.mode ?? config.mode;
|
|
850
|
-
if (nextMode === "plan" &&
|
|
920
|
+
if (nextMode === "plan" && ctx.engine === "acp") {
|
|
851
921
|
ui.notify(
|
|
852
|
-
"plan + acp is not supported (RC01): the ACP engine has no review-only mode.
|
|
922
|
+
"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
923
|
"warning",
|
|
854
924
|
);
|
|
855
925
|
return;
|
|
@@ -858,7 +928,6 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
|
|
|
858
928
|
try {
|
|
859
929
|
const next = saveConfig(pending);
|
|
860
930
|
const changed = [
|
|
861
|
-
pending.engine !== undefined ? `engine=${next.engine}` : null,
|
|
862
931
|
pending.mode ? `mode=${next.mode}` : null,
|
|
863
932
|
pending.skipPermissions !== undefined
|
|
864
933
|
? `permissions=${next.skipPermissions ? "auto-approved" : "prompt"}`
|
|
@@ -873,26 +942,6 @@ async function openAgyPicker(ui: ExtensionUIContext, ctx: AgyCommandCtx): Promis
|
|
|
873
942
|
.filter(Boolean)
|
|
874
943
|
.join(", ");
|
|
875
944
|
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
945
|
} catch (err) {
|
|
897
946
|
ui.notify(
|
|
898
947
|
`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.5",
|
|
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/acp/auth.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { AcpConnection } from "./connection.js";
|
|
2
|
+
import { hasAcpToken } from "./setup.js";
|
|
3
|
+
|
|
4
|
+
// Explicit sign-in (/agy auth): spawn a short-lived server, send
|
|
5
|
+
// `authenticate` (the RPC that fires the loopback listener + browser OAuth
|
|
6
|
+
// flow), and wait for the token to land in the shared acp_token.json. A
|
|
7
|
+
// dedicated process keeps the minutes-scale login ritual away from live
|
|
8
|
+
// turns; the next driver connection picks the token up. The real server
|
|
9
|
+
// blocks until the browser round-trip completes; the observed window is
|
|
10
|
+
// minutes-scale (one flow timed out at ~8.5 minutes), so the timeout is
|
|
11
|
+
// generous.
|
|
12
|
+
|
|
13
|
+
export const AUTH_TIMEOUT_MS = 10 * 60_000;
|
|
14
|
+
|
|
15
|
+
export interface AcpAuthOptions {
|
|
16
|
+
bin: string;
|
|
17
|
+
binArgs?: string[];
|
|
18
|
+
cwd?: string;
|
|
19
|
+
extraEnv?: Record<string, string>;
|
|
20
|
+
authUrlFile?: string;
|
|
21
|
+
/** The server is expected to have written acp_token.json by the time
|
|
22
|
+
* authenticate replies, but write-after-reply is unverified; after a
|
|
23
|
+
* successful reply the run polls for the token up to this long before
|
|
24
|
+
* SIGTERM, so a laggard write is never killed mid-flight. */
|
|
25
|
+
tokenGraceMs?: number;
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
log?: (msg: string, data?: unknown) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AcpAuthResult {
|
|
31
|
+
ok: boolean;
|
|
32
|
+
/** Failure reason (spawn, timeout, -32000 family, concurrent run). Absent when ok. */
|
|
33
|
+
error?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let authInFlight: Promise<AcpAuthResult> | null = null;
|
|
37
|
+
|
|
38
|
+
export function runAcpAuth(opts: AcpAuthOptions): Promise<AcpAuthResult> {
|
|
39
|
+
const conn = new AcpConnection({
|
|
40
|
+
bin: opts.bin,
|
|
41
|
+
binArgs: opts.binArgs,
|
|
42
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
43
|
+
extraEnv: opts.extraEnv,
|
|
44
|
+
authUrlFile: opts.authUrlFile,
|
|
45
|
+
log: opts.log ?? (() => {}),
|
|
46
|
+
onUpdate: () => {},
|
|
47
|
+
onExit: () => {},
|
|
48
|
+
});
|
|
49
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
50
|
+
const grace = opts.tokenGraceMs ?? 5_000;
|
|
51
|
+
const run = async (): Promise<AcpAuthResult> => {
|
|
52
|
+
try {
|
|
53
|
+
await conn.start();
|
|
54
|
+
await conn.request("authenticate", { methodId: "oauth-personal" }, opts.timeoutMs ?? AUTH_TIMEOUT_MS);
|
|
55
|
+
const deadline = Date.now() + grace;
|
|
56
|
+
while (!hasAcpToken() && Date.now() < deadline) await sleep(200);
|
|
57
|
+
return { ok: true };
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
60
|
+
} finally {
|
|
61
|
+
conn.kill();
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
// One sign-in at a time: two concurrent runs would race the OAuth loopback
|
|
65
|
+
// and the token write. A second invocation fails fast instead.
|
|
66
|
+
if (authInFlight) return Promise.resolve({ ok: false, error: "a sign-in is already running; wait for it to finish" });
|
|
67
|
+
authInFlight = run();
|
|
68
|
+
return authInFlight.finally(() => {
|
|
69
|
+
authInFlight = null;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -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 {
|
|
@@ -36,7 +38,7 @@ export interface AcpSessionInfo {
|
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
/** Auth failed (-32000 family). Message carries the remediation the server
|
|
39
|
-
* provided plus the /agy
|
|
41
|
+
* provided plus the /agy auth-manual pointer. */
|
|
40
42
|
export class AcpAuthError extends Error {
|
|
41
43
|
constructor(message: string) {
|
|
42
44
|
super(message);
|
|
@@ -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
|
}
|
|
@@ -387,7 +424,7 @@ export function translateError(method: string, err: unknown): Error {
|
|
|
387
424
|
if (err.code === -32000) {
|
|
388
425
|
const detail = typeof err.data === "object" && err.data !== null ? (err.data as { message?: unknown }).message : undefined;
|
|
389
426
|
const detailText = typeof detail === "string" ? `: ${detail}` : `: ${err.message}`;
|
|
390
|
-
return new AcpAuthError(`ACP authentication required for ${method}${detailText}. Run /agy
|
|
427
|
+
return new AcpAuthError(`ACP authentication required for ${method}${detailText}. Run /agy auth to sign in; /agy auth-manual has manual steps.`);
|
|
391
428
|
}
|
|
392
429
|
// Typed protocol errors survive: callers probe .code (-32601 cancel-
|
|
393
430
|
// unsupported, -32602 param shape). The method prefix is lost — describe()
|
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
|
@@ -48,8 +48,8 @@ export type AcpSetupStatus =
|
|
|
48
48
|
bin: string;
|
|
49
49
|
binarySource: "env" | "config" | "installed" | "existing";
|
|
50
50
|
auth: string;
|
|
51
|
-
/** True when auth was just set to oauth-personal:
|
|
52
|
-
* Google login in the browser
|
|
51
|
+
/** True when auth was just set to oauth-personal: /agy auth opens
|
|
52
|
+
* the Google login in the browser. */
|
|
53
53
|
needsLogin: boolean;
|
|
54
54
|
/** Human-readable actions taken (empty when everything was ready). */
|
|
55
55
|
actions: string[];
|
|
@@ -70,10 +70,10 @@ export const MANUAL_SETUP = [
|
|
|
70
70
|
" (~/.local/opt/agy-acp/current/agy_acp_server.par works; 'current' is a",
|
|
71
71
|
" symlink to the build dir).",
|
|
72
72
|
'3. Log in: put {"auth":{"type":"oauth-personal"}} in',
|
|
73
|
-
" ~/.gemini/antigravity-acp/settings.json and
|
|
74
|
-
" opens in your browser
|
|
73
|
+
" ~/.gemini/antigravity-acp/settings.json and run /agy auth, then complete",
|
|
74
|
+
" the Google login that opens in your browser. That login IS your",
|
|
75
75
|
" Antigravity subscription (Google AI Plus/Pro/Ultra).",
|
|
76
|
-
"Details: /agy
|
|
76
|
+
"Details: /agy auth-manual",
|
|
77
77
|
].join("\n");
|
|
78
78
|
|
|
79
79
|
export function platformKey(): string {
|
|
@@ -227,6 +227,23 @@ 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
|
+
|
|
242
|
+
/** Whether a login token exists (default dir, or an explicit one). */
|
|
243
|
+
export function hasAcpToken(dir?: string): boolean {
|
|
244
|
+
return tokenPresent(dir ?? path.join(os.homedir(), ".gemini", "antigravity-acp"));
|
|
245
|
+
}
|
|
246
|
+
|
|
230
247
|
/** Auth state WITHOUT reading any credential value: settings.json carries
|
|
231
248
|
* only auth.type (+ non-secret gcp placement), acp_token.json is stat()ed. */
|
|
232
249
|
export function readAuthState(dir?: string): AuthState {
|
|
@@ -240,12 +257,7 @@ export function readAuthState(dir?: string): AuthState {
|
|
|
240
257
|
} catch {
|
|
241
258
|
/* absent or garbage = unconfigured */
|
|
242
259
|
}
|
|
243
|
-
|
|
244
|
-
fs.statSync(path.join(target, "acp_token.json"));
|
|
245
|
-
return { configured: true, type: "token" };
|
|
246
|
-
} catch {
|
|
247
|
-
return { configured: false };
|
|
248
|
-
}
|
|
260
|
+
return tokenPresent(target) ? { configured: true, type: "token" } : { configured: false };
|
|
249
261
|
}
|
|
250
262
|
|
|
251
263
|
/** Write a minimal auth block into settings.json. The server reads this file
|
|
@@ -327,7 +339,7 @@ export async function ensureAcpReady(opts: SetupOptions = {}): Promise<AcpSetupS
|
|
|
327
339
|
// before the first spawn, so a fresh write takes effect. oauth-personal is
|
|
328
340
|
// THE default: it is the Antigravity subscription (same Google account and
|
|
329
341
|
// plan as the agy CLI login). gemini-api-key (metered paid API) stays a
|
|
330
|
-
// manual option for headless boxes (/agy
|
|
342
|
+
// manual option for headless boxes (/agy auth-manual); never the default.
|
|
331
343
|
const gdir = defaultGeminiDir(opts);
|
|
332
344
|
const auth = readAuthState(gdir);
|
|
333
345
|
if (!auth.configured) {
|
|
@@ -344,12 +356,18 @@ export async function ensureAcpReady(opts: SetupOptions = {}): Promise<AcpSetupS
|
|
|
344
356
|
};
|
|
345
357
|
}
|
|
346
358
|
}
|
|
359
|
+
// settings.json alone proves nothing: setup writes it before the login,
|
|
360
|
+
// the token file only appears after the browser round-trip. oauth-personal
|
|
361
|
+
// without a token = login still pending, and every caller (switch toast,
|
|
362
|
+
// session_start self-heal) must keep saying so until the token exists.
|
|
363
|
+
// gemini-api-key has no browser login; a present token file means done.
|
|
364
|
+
const needsLogin = auth.type !== "gemini-api-key" && !tokenPresent(gdir);
|
|
347
365
|
return {
|
|
348
366
|
ok: true,
|
|
349
367
|
bin: binary.bin!,
|
|
350
368
|
binarySource: binary.source!,
|
|
351
369
|
auth: auth.type ?? "token",
|
|
352
|
-
needsLogin
|
|
370
|
+
needsLogin,
|
|
353
371
|
actions,
|
|
354
372
|
};
|
|
355
373
|
}
|
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;
|