@simonepri/refined-antigravity-acp 0.1.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.
@@ -0,0 +1,445 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import zlib from "node:zlib";
6
+ import { execFileSync } from "node:child_process";
7
+
8
+ export const EVENT_PROCESSOR_ENTRY =
9
+ "google3/third_party/py/google/antigravity/connections/local/event_processor.py";
10
+
11
+ export const PYCACHE_PREFIX =
12
+ "google3/third_party/py/google/antigravity/connections/local/__pycache__/event_processor.";
13
+
14
+ export const ZIP_EOCD_MAGIC = 0x06054b50;
15
+ export const ZIP_CD_MAGIC = 0x02014b50;
16
+ export const ZIP_LOCAL_MAGIC = 0x04034b50;
17
+
18
+ export const ORIGINAL_ANCHOR = ` elif (
19
+ tsu.state
20
+ == localharness_pb2.TrajectoryStateUpdate.State.STATE_FULLY_IDLE
21
+ ):`;
22
+
23
+ export const REPLACEMENT_CODE = ` elif tsu.state in (
24
+ localharness_pb2.TrajectoryStateUpdate.State.STATE_FULLY_IDLE,
25
+ localharness_pb2.TrajectoryStateUpdate.State.STATE_WAITING_FOR_TASKS,
26
+ ):`;
27
+
28
+ export const PROMPT_ANCHOR = `elif (
29
+ tsu.state
30
+ == localharness_pb2.TrajectoryStateUpdate.State.STATE_FULLY_IDLE):`;
31
+
32
+ export const PROMPT_REPLACEMENT = ` elif tsu.state in (
33
+ localharness_pb2.TrajectoryStateUpdate.State.STATE_FULLY_IDLE,
34
+ localharness_pb2.TrajectoryStateUpdate.State.STATE_WAITING_FOR_TASKS,
35
+ ):`;
36
+
37
+ export type PatchClassification = "patchable" | "already-fixed" | "unrecognized" | "entry-missing";
38
+
39
+ export interface ZipEntryInfo {
40
+ name: string;
41
+ method: number;
42
+ crc32: number;
43
+ compSize: number;
44
+ uncompSize: number;
45
+ localHeaderOffset: number;
46
+ cdEntryOffset: number;
47
+ nameLength: number;
48
+ extraLength: number;
49
+ commentLength: number;
50
+ }
51
+
52
+ export interface ZipArchiveInfo {
53
+ eocdOffset: number;
54
+ cdOffset: number;
55
+ cdSize: number;
56
+ cdStart: number;
57
+ baseOffset: number;
58
+ entries: Map<string, ZipEntryInfo>;
59
+ }
60
+
61
+ export interface PatchOptions {
62
+ cacheBaseDir?: string;
63
+ force?: boolean;
64
+ skipSign?: boolean;
65
+ }
66
+
67
+ export interface PatchResult {
68
+ path: string;
69
+ patched: boolean;
70
+ classification: PatchClassification;
71
+ cached: boolean;
72
+ }
73
+
74
+ function isValidEocd(buf: Buffer, pos: number): boolean {
75
+ if (pos + 22 > buf.length) return false;
76
+ const cdSize = buf.readUInt32LE(pos + 12);
77
+ const cdStart = pos - cdSize;
78
+ if (cdStart < 0) return false;
79
+ if (cdSize === 0) return true;
80
+ if (cdStart + 4 > buf.length) return false;
81
+ return buf.readUInt32LE(cdStart) === ZIP_CD_MAGIC;
82
+ }
83
+
84
+ export function findEocd(buf: Buffer): number {
85
+ if (buf.length < 22) return -1;
86
+ const signature = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
87
+ let pos = buf.length - 22;
88
+ while (pos >= 0) {
89
+ pos = buf.lastIndexOf(signature, pos);
90
+ if (pos === -1) return -1;
91
+ if (isValidEocd(buf, pos)) return pos;
92
+ pos -= 1;
93
+ }
94
+ return -1;
95
+ }
96
+
97
+ function parseCdEntries(buf: Buffer, cdStart: number, cdSize: number): Map<string, ZipEntryInfo> {
98
+ const entries = new Map<string, ZipEntryInfo>();
99
+ let pos = cdStart;
100
+ const cdEnd = cdStart + cdSize;
101
+
102
+ while (pos + 46 <= cdEnd) {
103
+ if (buf.readUInt32LE(pos) !== ZIP_CD_MAGIC) break;
104
+ const method = buf.readUInt16LE(pos + 10);
105
+ const crc32 = buf.readUInt32LE(pos + 16);
106
+ const compSize = buf.readUInt32LE(pos + 20);
107
+ const uncompSize = buf.readUInt32LE(pos + 24);
108
+ const nameLength = buf.readUInt16LE(pos + 28);
109
+ const extraLength = buf.readUInt16LE(pos + 30);
110
+ const commentLength = buf.readUInt16LE(pos + 32);
111
+ const localHeaderOffset = buf.readUInt32LE(pos + 42);
112
+ const nameStart = pos + 46;
113
+ const nameEnd = nameStart + nameLength;
114
+ if (nameEnd > cdEnd) break;
115
+ const name = buf.toString("utf8", nameStart, nameEnd);
116
+
117
+ entries.set(name, {
118
+ name,
119
+ method,
120
+ crc32,
121
+ compSize,
122
+ uncompSize,
123
+ localHeaderOffset,
124
+ cdEntryOffset: pos,
125
+ nameLength,
126
+ extraLength,
127
+ commentLength,
128
+ });
129
+
130
+ pos += 46 + nameLength + extraLength + commentLength;
131
+ }
132
+
133
+ return entries;
134
+ }
135
+
136
+ export function parseZipArchive(buf: Buffer): ZipArchiveInfo | null {
137
+ const eocdOffset = findEocd(buf);
138
+ if (eocdOffset < 0) return null;
139
+
140
+ const cdSize = buf.readUInt32LE(eocdOffset + 12);
141
+ const cdOffset = buf.readUInt32LE(eocdOffset + 16);
142
+ const cdStart = eocdOffset - cdSize;
143
+ const baseOffset = cdStart - cdOffset;
144
+ const entries = parseCdEntries(buf, cdStart, cdSize);
145
+
146
+ return {
147
+ eocdOffset,
148
+ cdOffset,
149
+ cdSize,
150
+ cdStart,
151
+ baseOffset,
152
+ entries,
153
+ };
154
+ }
155
+
156
+ export function getEntryData(buf: Buffer, zip: ZipArchiveInfo, entry: ZipEntryInfo): Buffer | null {
157
+ const absLocalHdr = zip.baseOffset + entry.localHeaderOffset;
158
+ if (absLocalHdr < 0 || absLocalHdr + 30 > buf.length) return null;
159
+ if (buf.readUInt32LE(absLocalHdr) !== ZIP_LOCAL_MAGIC) return null;
160
+
161
+ const localNameLen = buf.readUInt16LE(absLocalHdr + 26);
162
+ const localExtraLen = buf.readUInt16LE(absLocalHdr + 28);
163
+ const dataOffset = absLocalHdr + 30 + localNameLen + localExtraLen;
164
+ const dataEnd = dataOffset + entry.uncompSize;
165
+
166
+ if (dataEnd > buf.length) return null;
167
+ return buf.subarray(dataOffset, dataEnd);
168
+ }
169
+
170
+ export function findAnchorAndReplacement(
171
+ text: string,
172
+ ): { anchor: string; replacement: string } | null {
173
+ if (text.includes(ORIGINAL_ANCHOR)) {
174
+ return { anchor: ORIGINAL_ANCHOR, replacement: REPLACEMENT_CODE };
175
+ }
176
+ if (text.includes(PROMPT_ANCHOR)) {
177
+ return { anchor: PROMPT_ANCHOR, replacement: PROMPT_REPLACEMENT };
178
+ }
179
+ return null;
180
+ }
181
+
182
+ export function shortenDecorativeComments(text: string, delta: number): string | null {
183
+ if (delta <= 0) return text;
184
+ const matches = [...text.matchAll(/([ \t]*# -{20,})/g)];
185
+ if (matches.length === 0) return null;
186
+
187
+ if (matches.length >= 2) {
188
+ const d1 = Math.ceil(delta / 2);
189
+ const d2 = Math.floor(delta / 2);
190
+ const m1 = matches[0];
191
+ const m2 = matches[1];
192
+ if (m1[0].length <= d1 + 5 || m2[0].length <= d2 + 5) return null;
193
+ const s1 = m1[0].slice(0, m1[0].length - d1);
194
+ const s2 = m2[0].slice(0, m2[0].length - d2);
195
+ return (
196
+ text.slice(0, m1.index) +
197
+ s1 +
198
+ text.slice(m1.index + m1[0].length, m2.index) +
199
+ s2 +
200
+ text.slice(m2.index + m2[0].length)
201
+ );
202
+ }
203
+
204
+ const m = matches[0];
205
+ if (m[0].length <= delta + 5) return null;
206
+ const s = m[0].slice(0, m[0].length - delta);
207
+ return text.slice(0, m.index) + s + text.slice(m.index + m[0].length);
208
+ }
209
+
210
+ export function patchEventProcessorContent(content: string): string | null {
211
+ const pair = findAnchorAndReplacement(content);
212
+ if (!pair) return null;
213
+ const delta =
214
+ Buffer.byteLength(pair.replacement, "utf8") - Buffer.byteLength(pair.anchor, "utf8");
215
+ const shortened = shortenDecorativeComments(content, delta);
216
+ if (!shortened) return null;
217
+ const patched = shortened.replace(pair.anchor, pair.replacement);
218
+ if (Buffer.byteLength(patched, "utf8") !== Buffer.byteLength(content, "utf8")) {
219
+ return null;
220
+ }
221
+ return patched;
222
+ }
223
+
224
+ export function classifyBuffer(buf: Buffer): PatchClassification {
225
+ const zip = parseZipArchive(buf);
226
+ if (!zip) return "unrecognized";
227
+
228
+ const entry = zip.entries.get(EVENT_PROCESSOR_ENTRY);
229
+ if (!entry) return "entry-missing";
230
+ if (entry.method !== 0) return "unrecognized";
231
+
232
+ const data = getEntryData(buf, zip, entry);
233
+ if (!data) return "unrecognized";
234
+
235
+ const text = data.toString("utf8");
236
+ if (text.includes("STATE_WAITING_FOR_TASKS")) {
237
+ return "already-fixed";
238
+ }
239
+
240
+ const pair = findAnchorAndReplacement(text);
241
+ if (!pair) return "unrecognized";
242
+
243
+ const delta =
244
+ Buffer.byteLength(pair.replacement, "utf8") - Buffer.byteLength(pair.anchor, "utf8");
245
+ const shortened = shortenDecorativeComments(text, delta);
246
+ if (!shortened) return "unrecognized";
247
+
248
+ return "patchable";
249
+ }
250
+
251
+ export function classifyBinary(filePath: string): PatchClassification {
252
+ try {
253
+ const buf = fs.readFileSync(filePath);
254
+ return classifyBuffer(buf);
255
+ } catch {
256
+ return "unrecognized";
257
+ }
258
+ }
259
+
260
+ function renameSiblingPyc(buf: Buffer, zip: ZipArchiveInfo): void {
261
+ for (const entry of zip.entries.values()) {
262
+ if (!entry.name.startsWith(PYCACHE_PREFIX) || !entry.name.endsWith(".pyc")) {
263
+ continue;
264
+ }
265
+
266
+ const cdExtPos = entry.cdEntryOffset + 46 + entry.nameLength - 4;
267
+ if (buf.subarray(cdExtPos, cdExtPos + 4).toString("utf8") === ".pyc") {
268
+ buf.write(".bak", cdExtPos, "utf8");
269
+ }
270
+
271
+ const localHdrPos = zip.baseOffset + entry.localHeaderOffset;
272
+ const localNameLen = buf.readUInt16LE(localHdrPos + 26);
273
+ const localExtPos = localHdrPos + 30 + localNameLen - 4;
274
+ if (buf.subarray(localExtPos, localExtPos + 4).toString("utf8") === ".pyc") {
275
+ buf.write(".bak", localExtPos, "utf8");
276
+ }
277
+ }
278
+ }
279
+
280
+ export function patchBuffer(buf: Buffer): {
281
+ patched: boolean;
282
+ classification: PatchClassification;
283
+ } {
284
+ const classification = classifyBuffer(buf);
285
+ if (classification !== "patchable") {
286
+ return { patched: false, classification };
287
+ }
288
+
289
+ const zip = parseZipArchive(buf)!;
290
+ const entry = zip.entries.get(EVENT_PROCESSOR_ENTRY)!;
291
+ const data = getEntryData(buf, zip, entry)!;
292
+ const text = data.toString("utf8");
293
+
294
+ const patchedText = patchEventProcessorContent(text);
295
+ if (!patchedText) {
296
+ return { patched: false, classification: "unrecognized" };
297
+ }
298
+
299
+ const localHdrPos = zip.baseOffset + entry.localHeaderOffset;
300
+ const localNameLen = buf.readUInt16LE(localHdrPos + 26);
301
+ const localExtraLen = buf.readUInt16LE(localHdrPos + 28);
302
+ const dataOffset = localHdrPos + 30 + localNameLen + localExtraLen;
303
+
304
+ buf.write(patchedText, dataOffset, "utf8");
305
+
306
+ const newCrc = zlib.crc32(buf.subarray(dataOffset, dataOffset + entry.uncompSize));
307
+ buf.writeUInt32LE(newCrc, localHdrPos + 14);
308
+ buf.writeUInt32LE(newCrc, entry.cdEntryOffset + 16);
309
+
310
+ renameSiblingPyc(buf, zip);
311
+
312
+ return { patched: true, classification: "already-fixed" };
313
+ }
314
+
315
+ export function getCacheKey(realpath: string, stat?: { size: number; mtimeMs: number }): string {
316
+ const s = stat ?? fs.statSync(realpath);
317
+ return crypto
318
+ .createHash("sha256")
319
+ .update(`${realpath}:${s.size}:${s.mtimeMs}`)
320
+ .digest("hex")
321
+ .slice(0, 32);
322
+ }
323
+
324
+ export function getCacheBaseDir(customDir?: string): string {
325
+ if (customDir) return customDir;
326
+ const pluginData =
327
+ process.env.PASEO_PLUGIN_DATA_DIR ?? path.join(os.homedir(), ".paseo", "plugin-data");
328
+ return path.join(pluginData, "antigravity", "bin");
329
+ }
330
+
331
+ function cloneFile(src: string, dst: string): void {
332
+ try {
333
+ fs.copyFileSync(src, dst, fs.constants.COPYFILE_FICLONE);
334
+ } catch {
335
+ fs.copyFileSync(src, dst);
336
+ }
337
+ }
338
+
339
+ function reSignBinary(dst: string): void {
340
+ if (process.platform !== "darwin") return;
341
+ try {
342
+ execFileSync(
343
+ "codesign",
344
+ ["--force", "-s", "-", "--preserve-metadata=entitlements,flags", dst],
345
+ { stdio: "ignore" },
346
+ );
347
+ } catch {
348
+ // Gracefully ignore codesign errors
349
+ }
350
+ }
351
+
352
+ export function gcCache(baseDir: string, currentKey?: string): void {
353
+ if (!fs.existsSync(baseDir)) return;
354
+ try {
355
+ const entries = fs.readdirSync(baseDir, { withFileTypes: true });
356
+ for (const entry of entries) {
357
+ if (!entry.isDirectory()) continue;
358
+ if (currentKey && entry.name === currentKey) continue;
359
+ fs.rmSync(path.join(baseDir, entry.name), {
360
+ recursive: true,
361
+ force: true,
362
+ });
363
+ }
364
+ } catch {
365
+ // Non-fatal if GC fails
366
+ }
367
+ }
368
+
369
+ interface CacheTarget {
370
+ realPath: string;
371
+ key: string;
372
+ cacheBase: string;
373
+ cacheDir: string;
374
+ dstPath: string;
375
+ }
376
+
377
+ function resolveCacheTarget(srcPath: string, customBase?: string): CacheTarget {
378
+ const realPath = fs.realpathSync(srcPath);
379
+ const stat = fs.statSync(realPath);
380
+ const key = getCacheKey(realPath, stat);
381
+ const cacheBase = getCacheBaseDir(customBase);
382
+ const cacheDir = path.join(cacheBase, key);
383
+ const dstPath = path.join(cacheDir, path.basename(realPath));
384
+ return { realPath, key, cacheBase, cacheDir, dstPath };
385
+ }
386
+
387
+ function cloneAndPatch(
388
+ realPath: string,
389
+ dstPath: string,
390
+ cacheDir: string,
391
+ skipSign?: boolean,
392
+ ): boolean {
393
+ fs.mkdirSync(cacheDir, { recursive: true, mode: 0o755 });
394
+ const tempDst = `${dstPath}.tmp.${Date.now()}`;
395
+ cloneFile(realPath, tempDst);
396
+ fs.chmodSync(tempDst, 0o755);
397
+
398
+ const buf = fs.readFileSync(tempDst);
399
+ const patchRes = patchBuffer(buf);
400
+ if (!patchRes.patched) {
401
+ fs.rmSync(tempDst, { force: true });
402
+ return false;
403
+ }
404
+
405
+ fs.writeFileSync(tempDst, buf);
406
+ if (!skipSign) {
407
+ reSignBinary(tempDst);
408
+ }
409
+ fs.renameSync(tempDst, dstPath);
410
+ return true;
411
+ }
412
+
413
+ export function patchBinary(srcPath: string, options?: PatchOptions): PatchResult {
414
+ if (process.env.PASEO_AGY_NO_PATCH === "1") {
415
+ return { path: srcPath, patched: false, classification: "unrecognized", cached: false };
416
+ }
417
+
418
+ const { realPath, key, cacheBase, cacheDir, dstPath } = resolveCacheTarget(
419
+ srcPath,
420
+ options?.cacheBaseDir,
421
+ );
422
+
423
+ if (!options?.force && fs.existsSync(dstPath)) {
424
+ gcCache(cacheBase, key);
425
+ return { path: dstPath, patched: false, classification: "already-fixed", cached: true };
426
+ }
427
+
428
+ const classification = classifyBinary(realPath);
429
+ if (classification !== "patchable") {
430
+ return { path: srcPath, patched: false, classification, cached: false };
431
+ }
432
+
433
+ const ok = cloneAndPatch(realPath, dstPath, cacheDir, options?.skipSign);
434
+ if (!ok) {
435
+ return { path: srcPath, patched: false, classification: "unrecognized", cached: false };
436
+ }
437
+
438
+ gcCache(cacheBase, key);
439
+ return { path: dstPath, patched: true, classification: "already-fixed", cached: false };
440
+ }
441
+
442
+ export function ensurePatchedBinary(srcPath: string, options?: PatchOptions): string {
443
+ const res = patchBinary(srcPath, options);
444
+ return res.path;
445
+ }
@@ -0,0 +1,51 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { ensureAuthenticated, getAcpTokenPath, syncAgyCredentials } from "./auth.js";
6
+
7
+ describe("auth helpers", () => {
8
+ const originalGeminiHome = process.env.GEMINI_HOME;
9
+ let tempDir: string;
10
+
11
+ beforeEach(() => {
12
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "agy-auth-test-"));
13
+ process.env.GEMINI_HOME = tempDir;
14
+ });
15
+
16
+ afterEach(() => {
17
+ if (originalGeminiHome !== undefined) {
18
+ process.env.GEMINI_HOME = originalGeminiHome;
19
+ } else {
20
+ delete process.env.GEMINI_HOME;
21
+ }
22
+ fs.rmSync(tempDir, { recursive: true, force: true });
23
+ });
24
+
25
+ it("resolves getAcpTokenPath under GEMINI_HOME", () => {
26
+ const tokenPath = getAcpTokenPath();
27
+ expect(tokenPath).toBe(path.join(tempDir, "antigravity-acp", "acp_token.json"));
28
+ });
29
+
30
+ it("detects existing valid token file", () => {
31
+ const tokenPath = getAcpTokenPath();
32
+ fs.mkdirSync(path.dirname(tokenPath), { recursive: true });
33
+ fs.writeFileSync(tokenPath, JSON.stringify({ refresh_token: "test-token" }));
34
+
35
+ expect(syncAgyCredentials()).toBe(true);
36
+ expect(() => ensureAuthenticated()).not.toThrow();
37
+ });
38
+
39
+ it("throws actionable error when no credentials exist", () => {
40
+ // Empty directory, no token file
41
+ // If on macOS, mock or test fallback
42
+ const tokenPath = getAcpTokenPath();
43
+ if (!fs.existsSync(tokenPath)) {
44
+ try {
45
+ ensureAuthenticated();
46
+ } catch (err: unknown) {
47
+ expect((err as Error).message).toContain("Not logged in to Google Antigravity");
48
+ }
49
+ }
50
+ });
51
+ });
package/server/auth.ts ADDED
@@ -0,0 +1,122 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { execFileSync } from "node:child_process";
5
+
6
+ /**
7
+ * Resolves the path to the ACP token JSON file under the Gemini home directory.
8
+ */
9
+ export function getAcpTokenPath(): string {
10
+ const geminiHome = process.env.GEMINI_HOME || path.join(os.homedir(), ".gemini");
11
+ return path.join(geminiHome, "antigravity-acp", "acp_token.json");
12
+ }
13
+
14
+ /**
15
+ * Checks if the local ACP token file contains an active refresh token.
16
+ */
17
+ export function hasValidRefreshToken(tokenPath: string = getAcpTokenPath()): boolean {
18
+ if (!fs.existsSync(tokenPath)) return false;
19
+ try {
20
+ const existing = JSON.parse(fs.readFileSync(tokenPath, "utf-8"));
21
+ return typeof existing.refresh_token === "string" && existing.refresh_token.length > 0;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Extracts the CLI refresh token from macOS Keychain if available.
29
+ */
30
+ function extractClientId(idToken: unknown): string | undefined {
31
+ if (typeof idToken !== "string") return undefined;
32
+ try {
33
+ const parts = idToken.split(".");
34
+ if (parts.length < 2) return undefined;
35
+ const payload = JSON.parse(Buffer.from(parts[1], "base64").toString("utf-8"));
36
+ return typeof payload.aud === "string" ? payload.aud : undefined;
37
+ } catch {
38
+ return undefined;
39
+ }
40
+ }
41
+
42
+ export function readKeychainCredentials(): { refreshToken: string; clientId?: string } | null {
43
+ if (process.platform !== "darwin") return null;
44
+ try {
45
+ const raw = execFileSync(
46
+ "security",
47
+ ["find-generic-password", "-s", "gemini", "-a", "antigravity", "-w"],
48
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000 },
49
+ ).trim();
50
+
51
+ if (!raw) return null;
52
+ const b64 = raw.replace(/^go-keyring-base64:/, "");
53
+ const parsed = JSON.parse(Buffer.from(b64, "base64").toString("utf-8"));
54
+ const refreshToken = parsed?.token?.refresh_token;
55
+ if (!refreshToken) return null;
56
+
57
+ const clientId = extractClientId(parsed.id_token);
58
+ return { refreshToken, clientId };
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Writes an authorized_user credential blob to the ACP token path with mode 0600.
66
+ */
67
+ export function writeAcpTokenFile(
68
+ tokenPath: string,
69
+ refreshToken: string,
70
+ clientId?: string,
71
+ ): void {
72
+ const acpBlob: Record<string, unknown> = {
73
+ refresh_token: refreshToken,
74
+ type: "authorized_user",
75
+ };
76
+ if (clientId) {
77
+ acpBlob.client_id = clientId;
78
+ }
79
+ fs.mkdirSync(path.dirname(tokenPath), { recursive: true, mode: 0o700 });
80
+ fs.writeFileSync(tokenPath, JSON.stringify(acpBlob, null, 2), { mode: 0o600 });
81
+ try {
82
+ fs.chmodSync(tokenPath, 0o600);
83
+ } catch {
84
+ // Ignore chmod failure on filesystems that do not support POSIX modes
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Syncs credentials from the official `agy` CLI's Keychain entry (`antigravity`)
90
+ * into `~/.gemini/antigravity-acp/acp_token.json` if the token file is missing or invalid.
91
+ */
92
+ export function syncAgyCredentials(): boolean {
93
+ const tokenPath = getAcpTokenPath();
94
+ if (hasValidRefreshToken(tokenPath)) {
95
+ return true;
96
+ }
97
+
98
+ const creds = readKeychainCredentials();
99
+ if (!creds) {
100
+ return false;
101
+ }
102
+
103
+ try {
104
+ writeAcpTokenFile(tokenPath, creds.refreshToken, creds.clientId);
105
+ return true;
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Ensures the user is authenticated with Antigravity before starting the ACP server.
113
+ * Throws an actionable error directing the user to run `agy` in their terminal.
114
+ */
115
+ export function ensureAuthenticated(): void {
116
+ const ok = syncAgyCredentials();
117
+ if (!ok && !hasValidRefreshToken(getAcpTokenPath())) {
118
+ throw new Error(
119
+ "Not logged in to Google Antigravity. Please run 'agy' in your terminal to log in.",
120
+ );
121
+ }
122
+ }
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { agyCommand } from "./command.js";
3
+
4
+ describe("agyCommand", () => {
5
+ it("throws on unsupported platforms", () => {
6
+ expect(() => agyCommand("freebsd" as NodeJS.Platform)).toThrow(/does not support freebsd/);
7
+ });
8
+
9
+ it("respects environment override", () => {
10
+ const custom = "/custom/path/to/agy_acp_server.par";
11
+ expect(agyCommand("darwin", custom)).toEqual([custom]);
12
+ });
13
+
14
+ it("appends --uid= on linux", () => {
15
+ const custom = "/custom/path/to/agy_acp_server.par";
16
+ expect(agyCommand("linux", custom)).toEqual([custom, "--uid="]);
17
+ });
18
+ });