@estebanforge/pi-antigravity-bridge 1.9.3 → 1.10.0

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/src/acp/auth.ts DELETED
@@ -1,174 +0,0 @@
1
- import { AcpConnection } from "./connection.js";
2
- import { mapUpdate } from "./events.js";
3
- import { hasAcpToken, writeAcpAuthVerified } from "./setup.js";
4
-
5
- // Explicit sign-in (/agy auth): spawn a short-lived server, send
6
- // `authenticate` (the RPC that fires the loopback listener + browser OAuth
7
- // flow), and wait for the token to land in the shared acp_token.json. A
8
- // dedicated process keeps the minutes-scale login ritual away from live
9
- // turns; the next driver connection picks the token up. The real server
10
- // blocks until the browser round-trip completes; the observed window is
11
- // minutes-scale (one flow timed out at ~8.5 minutes), so the timeout is
12
- // generous.
13
- //
14
- // Token-absent after an OK reply is NOT always a failure: server 1.1.1 can
15
- // authenticate from legacy ~/.gemini credential artifacts and never writes
16
- // acp_token.json (observed on darwin/arm64 2026-10-13). A live session/new
17
- // probe separates that from a hollow accept: probe success = signed in
18
- // (recorded in the bridge-side verified marker), probe failure = the server
19
- // holds no usable credentials.
20
-
21
- export const AUTH_TIMEOUT_MS = 10 * 60_000;
22
-
23
- export interface AcpAuthOptions {
24
- bin: string;
25
- binArgs?: string[];
26
- cwd?: string;
27
- extraEnv?: Record<string, string>;
28
- authUrlFile?: string;
29
- /** Directory holding acp_token.json. Default: ~/.gemini/antigravity-acp.
30
- * Tests point this at a fixture dir; production callers use the default. */
31
- tokenDir?: string;
32
- /** The server is expected to have written acp_token.json by the time
33
- * authenticate replies, but write-after-reply is unverified; after a
34
- * successful reply the run polls for the token up to this long before
35
- * SIGTERM, so a laggard write is never killed mid-flight. A token that
36
- * never appears is verified through a live session probe instead of
37
- * failing outright. */
38
- tokenGraceMs?: number;
39
- /** Bridge-side verified-sign-in marker. Written on every verified
40
- * success (token file or live session probe). Default is the real
41
- * bridge path; tests point this at a fixture. */
42
- authVerifiedFile?: string;
43
- timeoutMs?: number;
44
- log?: (msg: string, data?: unknown) => void;
45
- }
46
-
47
- export interface AcpAuthResult {
48
- ok: boolean;
49
- /** Present when verified through the session probe: the server
50
- * authenticated from existing credentials without writing a local
51
- * token file. Absent on the normal token-file path and on failure. */
52
- note?: string;
53
- /** Failure reason (spawn, timeout, -32000 family, concurrent run). Absent when ok. */
54
- error?: string;
55
- }
56
-
57
- /** Google returns account-disablement as a normal session/prompt reply
58
- * (stopReason end_turn) carrying this text, not a -32000 auth error, so a
59
- * session/new probe alone cannot tell "authenticated" from "entitled". */
60
- const ACCOUNT_DISABLED_RE = /disabled in this account|violation of\s+Terms of Service|submit an appeal/i;
61
-
62
- /** Bounds the probe's session/prompt. A real first token normally lands well
63
- * under this; a hollow or hung server hits the cap and fails auth. */
64
- const PROBE_PROMPT_TIMEOUT_MS = 30_000;
65
-
66
- /** Live credential probe for the token-absent branch. session/new alone is
67
- * too shallow: a ToS-disabled account passes it. So the probe sends one
68
- * trivial session/prompt and inspects the streamed reply (collected by the
69
- * caller's onUpdate into `sink`): the disablement notice means the account
70
- * is blocked (distinct from a hollow accept). Returns "usable" only when
71
- * the prompt completes without that notice. Closes the probe session; the
72
- * caller kills the connection either way. */
73
- async function probeUsableCredentials(
74
- conn: AcpConnection,
75
- sink: { text: string },
76
- log: (event: string, data?: unknown) => void,
77
- cwd?: string,
78
- ): Promise<"usable" | "disabled" | "unusable"> {
79
- let timer: NodeJS.Timeout | undefined;
80
- try {
81
- const s = await conn.newSession(cwd ?? process.cwd());
82
- try {
83
- sink.text = "";
84
- // conn.prompt has no timeout by design (driver timers govern real
85
- // turns). The probe runs during interactive auth, so bound it: a
86
- // hollow server that accepts session/new but never answers the
87
- // prompt must fail, not hang /agy auth and the authInFlight lock.
88
- await Promise.race([
89
- conn.prompt(s.sessionId, "ping"),
90
- new Promise<never>((_, reject) => {
91
- timer = setTimeout(() => reject(new Error("probe prompt timed out")), PROBE_PROMPT_TIMEOUT_MS);
92
- }),
93
- ]);
94
- } finally {
95
- clearTimeout(timer);
96
- await conn.closeSession(s.sessionId).catch(() => {});
97
- }
98
- // Log the reply head so a change in Google's disablement wording (which
99
- // would silently regress the regex to "usable") is diagnosable.
100
- log("auth-probe-reply", { head: sink.text.slice(0, 200) });
101
- return ACCOUNT_DISABLED_RE.test(sink.text) ? "disabled" : "usable";
102
- } catch {
103
- return "unusable";
104
- }
105
- }
106
-
107
- let authInFlight: Promise<AcpAuthResult> | null = null;
108
-
109
- export function runAcpAuth(opts: AcpAuthOptions): Promise<AcpAuthResult> {
110
- // Collects streamed reply text so the credential probe can detect an
111
- // account-disablement notice (which arrives as normal prompt content).
112
- const probeSink = { text: "" };
113
- const conn = new AcpConnection({
114
- bin: opts.bin,
115
- binArgs: opts.binArgs,
116
- cwd: opts.cwd ?? process.cwd(),
117
- extraEnv: opts.extraEnv,
118
- authUrlFile: opts.authUrlFile,
119
- log: opts.log ?? (() => {}),
120
- onUpdate: (_sessionId, update) => {
121
- const mapped = mapUpdate(update);
122
- if (mapped?.kind === "text") probeSink.text += mapped.delta;
123
- },
124
- onExit: () => {},
125
- });
126
- const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
127
- const grace = opts.tokenGraceMs ?? 5_000;
128
- const run = async (): Promise<AcpAuthResult> => {
129
- try {
130
- await conn.start();
131
- await conn.request("authenticate", { methodId: "oauth-personal" }, opts.timeoutMs ?? AUTH_TIMEOUT_MS);
132
- const deadline = Date.now() + grace;
133
- while (!hasAcpToken(opts.tokenDir) && Date.now() < deadline) await sleep(200);
134
- if (!hasAcpToken(opts.tokenDir)) {
135
- // authenticate replied but no token file: either the server
136
- // authenticated from artifacts outside antigravity-acp, or the
137
- // reply is hollow. A live session prompt separates the two.
138
- const probe = await probeUsableCredentials(conn, probeSink, opts.log ?? (() => {}), opts.cwd);
139
- if (probe === "disabled") {
140
- return {
141
- ok: false,
142
- error: "this Google account is disabled for the Antigravity service (Terms of Service). Signing in again will not help; submit an appeal with Google to restore access.",
143
- };
144
- }
145
- if (probe !== "usable") {
146
- return {
147
- ok: false,
148
- error: `the server accepted the sign-in but holds no usable credentials (session probe failed; token file absent in ${opts.tokenDir ?? "~/.gemini/antigravity-acp"}). Finish the Google sign-in if a browser opened, then retry /agy auth.`,
149
- };
150
- }
151
- writeAcpAuthVerified(opts.authVerifiedFile);
152
- return { ok: true, note: "the server authenticated from existing credentials; no browser sign-in was needed" };
153
- }
154
- // Token present (normal browser sign-in): no entitlement probe. A
155
- // ToS-disabled account that completes a real sign-in still verifies
156
- // here and surfaces the disablement on its first real prompt. The
157
- // prompt probe is scoped to the token-absent branch (issue #9's
158
- // `agy acp` shim path) to avoid a model call on every sign-in.
159
- writeAcpAuthVerified(opts.authVerifiedFile);
160
- return { ok: true };
161
- } catch (err) {
162
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
163
- } finally {
164
- conn.kill();
165
- }
166
- };
167
- // One sign-in at a time: two concurrent runs would race the OAuth loopback
168
- // and the token write. A second invocation fails fast instead.
169
- if (authInFlight) return Promise.resolve({ ok: false, error: "a sign-in is already running; wait for it to finish" });
170
- authInFlight = run();
171
- return authInFlight.finally(() => {
172
- authInFlight = null;
173
- });
174
- }
@@ -1,104 +0,0 @@
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
- }