@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/CHANGELOG.md +16 -0
- package/README.md +12 -14
- package/extensions/index.ts +115 -375
- package/package.json +1 -1
- package/src/acp/connection.ts +47 -105
- package/src/acp/driver.ts +25 -59
- package/src/acp/events.ts +17 -11
- package/src/acp/home.ts +95 -0
- package/src/acp/jsonrpc.ts +6 -1
- package/src/acp/setup.ts +71 -437
- package/src/agents.ts +1 -1
- package/src/agy-install.ts +85 -0
- package/src/agy-paths.ts +3 -6
- package/src/agy-setup.ts +72 -0
- package/src/agy-version.ts +3 -4
- package/src/ask-tool.ts +3 -3
- package/src/config.ts +17 -19
- package/src/driver-types.ts +3 -5
- package/src/engine-picker.ts +8 -12
- package/src/mcp-registration.ts +1 -1
- package/src/mcp-server.ts +5 -5
- package/src/provider.ts +4 -4
- package/src/queued-messages.ts +7 -26
- package/src/acp/auth.ts +0 -174
- package/src/acp/browser-capture.ts +0 -104
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
|
-
}
|