@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/home.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { isPidAlive } from "../mcp-server.js";
|
|
5
|
+
|
|
6
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
7
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must contain a JSON object`);
|
|
8
|
+
return value as Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function linkDirectory(source: string, target: string): void {
|
|
12
|
+
fs.symlinkSync(source, target, "junction");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function acpHomeStagingDir(): string {
|
|
16
|
+
return path.join(os.homedir(), ".pi", "agent", "antigravity-bridge", "acp-homes");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Isolate native ACP from per-pid bridge entries in the global MCP config. */
|
|
20
|
+
export function stageAcpHome(opts: { geminiHome?: string; parentDir?: string } = {}): string {
|
|
21
|
+
const geminiHome = opts.geminiHome ?? path.join(os.homedir(), ".gemini");
|
|
22
|
+
const globalConfigDir = path.join(geminiHome, "config");
|
|
23
|
+
const globalMcpPath = path.join(globalConfigDir, "mcp_config.json");
|
|
24
|
+
let config: Record<string, unknown> = { mcpServers: {} };
|
|
25
|
+
try {
|
|
26
|
+
config = record(JSON.parse(fs.readFileSync(globalMcpPath, "utf8")), globalMcpPath);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
29
|
+
throw new Error(`Cannot isolate inherited MCP config ${globalMcpPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const servers = record(config.mcpServers ?? {}, `${globalMcpPath} mcpServers`);
|
|
33
|
+
const filtered = Object.fromEntries(Object.entries(servers).filter(([name]) => !/^pi-bridge(?:-\d+)?$/.test(name)));
|
|
34
|
+
|
|
35
|
+
const parentDir = opts.parentDir ?? acpHomeStagingDir();
|
|
36
|
+
fs.mkdirSync(parentDir, { recursive: true, mode: 0o700 });
|
|
37
|
+
const staged = fs.mkdtempSync(path.join(parentDir, `acp-home-${process.pid}-`));
|
|
38
|
+
try {
|
|
39
|
+
fs.mkdirSync(path.join(staged, "config"), { mode: 0o700 });
|
|
40
|
+
fs.writeFileSync(path.join(staged, "config", "mcp_config.json"), JSON.stringify({ ...config, mcpServers: filtered }), { mode: 0o600 });
|
|
41
|
+
|
|
42
|
+
const appData = path.join(geminiHome, "antigravity-cli");
|
|
43
|
+
if (fs.existsSync(appData)) linkDirectory(appData, path.join(staged, "antigravity-cli"));
|
|
44
|
+
const instructions = path.join(geminiHome, "GEMINI.md");
|
|
45
|
+
if (fs.existsSync(instructions)) fs.copyFileSync(instructions, path.join(staged, "GEMINI.md"));
|
|
46
|
+
if (fs.existsSync(globalConfigDir)) {
|
|
47
|
+
for (const entry of fs.readdirSync(globalConfigDir, { withFileTypes: true })) {
|
|
48
|
+
if (entry.name === "mcp_config.json") continue;
|
|
49
|
+
const source = path.join(globalConfigDir, entry.name);
|
|
50
|
+
const target = path.join(staged, "config", entry.name);
|
|
51
|
+
let stat: fs.Stats | fs.Dirent = entry;
|
|
52
|
+
if (entry.isSymbolicLink()) {
|
|
53
|
+
try {
|
|
54
|
+
stat = fs.statSync(source);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (stat.isDirectory()) linkDirectory(source, target);
|
|
61
|
+
else if (stat.isFile()) fs.copyFileSync(source, target);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return staged;
|
|
65
|
+
} catch (err) {
|
|
66
|
+
fs.rmSync(staged, { recursive: true, force: true });
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function sweepStaleAcpHomes(baseDir: string = acpHomeStagingDir(), alive: (pid: number) => boolean = isPidAlive): void {
|
|
72
|
+
let entries: string[];
|
|
73
|
+
try {
|
|
74
|
+
entries = fs.readdirSync(baseDir);
|
|
75
|
+
} catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const name of entries) {
|
|
79
|
+
const match = /^acp-home-(\d+)-/.exec(name);
|
|
80
|
+
if (!match) continue;
|
|
81
|
+
const pid = Number(match[1]);
|
|
82
|
+
if (pid === process.pid || alive(pid)) continue;
|
|
83
|
+
removeStagedAcpHome(path.join(baseDir, name));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function removeStagedAcpHome(staged: string): void {
|
|
88
|
+
if (!path.basename(staged).startsWith("acp-home-")) return;
|
|
89
|
+
try {
|
|
90
|
+
if (!fs.lstatSync(staged).isDirectory()) return;
|
|
91
|
+
fs.rmSync(staged, { recursive: true, force: true });
|
|
92
|
+
} catch {
|
|
93
|
+
// Connection teardown is best-effort; startup sweeps crash leftovers.
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/acp/jsonrpc.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// serialized frame + newline to the child's stdin) and feeds incoming bytes to
|
|
5
5
|
// `feed()`. Everything ACP-specific lives in connection.ts.
|
|
6
6
|
//
|
|
7
|
-
// Semantics verified live against
|
|
7
|
+
// Semantics verified live against agy 1.5.3 ACP:
|
|
8
8
|
// - requests are correlated by numeric id; responses reject the pending
|
|
9
9
|
// promise on JSON-RPC `error` results;
|
|
10
10
|
// - server-to-client REQUESTS (session/request_permission, fs/*, terminal/*)
|
|
@@ -197,6 +197,11 @@ export class JsonRpcSession {
|
|
|
197
197
|
});
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
notify(method: string, params?: unknown): void {
|
|
201
|
+
if (this.#aborted) return;
|
|
202
|
+
this.#send(JSON.stringify({ jsonrpc: "2.0", method, params: params ?? {} }));
|
|
203
|
+
}
|
|
204
|
+
|
|
200
205
|
/** Reject every pending request (Gate D teardown). The transport is going
|
|
201
206
|
* away; nothing will ever be written again. */
|
|
202
207
|
abortAll(reason: string): void {
|
package/src/acp/setup.ts
CHANGED
|
@@ -1,473 +1,107 @@
|
|
|
1
|
-
// ACP engine self-service setup: binary install from the official registry
|
|
2
|
-
// and auth bootstrap. Everything is automatic when possible; the caller
|
|
3
|
-
// surfaces `manual` only when a step fails. Instructions-as-first-resort is
|
|
4
|
-
// gone: /agy engine acp and session_start self-heal silently when ready.
|
|
5
|
-
//
|
|
6
|
-
// Sources: docs/ACP-ADOPTION-PLAN.md §2.1 (registry entry), §12 (pinned
|
|
7
|
-
// install layout: ~/.local/opt/agy-acp/<build>/ + current symlink, zip
|
|
8
|
-
// sha256 recorded at install time), docs/ACP-PROTOCOL-REFERENCE.md
|
|
9
|
-
// (settings.json auth shapes; the server opens the oauth browser itself).
|
|
10
|
-
// Credential rule: this module never reads or writes credential VALUES —
|
|
11
|
-
// acp_token.json is only stat()ed, gemini-api-key is read by the server
|
|
12
|
-
// from the environment.
|
|
13
|
-
|
|
14
|
-
import { spawn } from "node:child_process";
|
|
15
|
-
import { createHash } from "node:crypto";
|
|
16
1
|
import fs from "node:fs";
|
|
17
2
|
import os from "node:os";
|
|
18
3
|
import path from "node:path";
|
|
19
|
-
import { ensureQueuedMessagesInFile, QUEUED_MESSAGES_SEND_IMMEDIATELY } from "../queued-messages.js";
|
|
20
|
-
|
|
21
|
-
export const REGISTRY_URL =
|
|
22
|
-
"https://raw.githubusercontent.com/agentclientprotocol/registry/main/antigravity-acp/agent.json";
|
|
23
|
-
|
|
24
|
-
const INSTALL_TIMEOUT_MS = 30 * 60_000;
|
|
25
|
-
|
|
26
|
-
export interface SetupOptions {
|
|
27
|
-
/** Registry agent.json URL. Overridable for tests. */
|
|
28
|
-
registryUrl?: string;
|
|
29
|
-
/** Install root. Default ~/.local/opt/agy-acp. */
|
|
30
|
-
installRoot?: string;
|
|
31
|
-
/** Server settings dir. Default ~/.gemini/antigravity-acp. */
|
|
32
|
-
geminiDir?: string;
|
|
33
|
-
/** Bridge-side verified-sign-in marker file. Default
|
|
34
|
-
* ~/.pi/agent/antigravity-bridge/acp-auth-verified.json (tests point this
|
|
35
|
-
* at a fixture to stay hermetic). */
|
|
36
|
-
authVerifiedFile?: string;
|
|
37
|
-
/** acp.bin from config (second resolution priority after AGY_ACP_BIN). */
|
|
38
|
-
configBin?: string;
|
|
39
|
-
/** Environment. Default process.env (test injection). */
|
|
40
|
-
env?: NodeJS.ProcessEnv;
|
|
41
|
-
/** Progress messages for the user (throttled by the caller's UI). */
|
|
42
|
-
onProgress?: (msg: string) => void;
|
|
43
|
-
fetchImpl?: typeof fetch;
|
|
44
|
-
/** Archive unpacker. Default: `unzip` subprocess (tests inject a copier). */
|
|
45
|
-
unpack?: (archive: string, dest: string) => Promise<void>;
|
|
46
|
-
/** Download when the binary is missing. Default true. */
|
|
47
|
-
install?: boolean;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export type AcpSetupStatus =
|
|
51
|
-
| {
|
|
52
|
-
ok: true;
|
|
53
|
-
bin: string;
|
|
54
|
-
binarySource: "env" | "config" | "installed" | "existing";
|
|
55
|
-
/** Registry argv for the managed binary; empty for env/config overrides. */
|
|
56
|
-
args: string[];
|
|
57
|
-
auth: string;
|
|
58
|
-
/** True when auth was just set to oauth-personal: /agy auth opens
|
|
59
|
-
* the Google login in the browser. */
|
|
60
|
-
needsLogin: boolean;
|
|
61
|
-
/** Human-readable actions taken (empty when everything was ready). */
|
|
62
|
-
actions: string[];
|
|
63
|
-
}
|
|
64
|
-
| { ok: false; stage: "install" | "auth"; error: string; manual: string };
|
|
65
4
|
|
|
66
|
-
export
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
"suite (Agy Desktop, Agy Editor, Agy CLI), you need to log in to",
|
|
70
|
-
"this one as well. Separately ¯\\_(ツ)_/¯",
|
|
71
|
-
"ACP manual setup:",
|
|
72
|
-
"1. Download the server zip for your platform from the antigravity-acp",
|
|
73
|
-
" registry entry (github.com/agentclientprotocol/registry, folder",
|
|
74
|
-
" antigravity-acp/agent.json) and unzip it, e.g. to",
|
|
75
|
-
" ~/.local/opt/agy-acp/<build>/; chmod +x agy_acp_server.par.",
|
|
76
|
-
"2. Point AGY_ACP_BIN or /agy config acp.bin at the binary",
|
|
77
|
-
" (~/.local/opt/agy-acp/current/agy_acp_server.par works; 'current' is a",
|
|
78
|
-
" symlink to the build dir).",
|
|
79
|
-
'3. Log in: put {"auth":{"type":"oauth-personal"}} in',
|
|
80
|
-
" ~/.gemini/antigravity-acp/settings.json and run /agy auth, then complete",
|
|
81
|
-
" the Google login that opens in your browser. That login IS your",
|
|
82
|
-
" Antigravity subscription (Google AI Plus/Pro/Ultra).",
|
|
83
|
-
"Details: /agy auth-manual",
|
|
84
|
-
].join("\n");
|
|
85
|
-
|
|
86
|
-
export function platformKey(): string {
|
|
87
|
-
const goos = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : "linux";
|
|
88
|
-
const goarch = process.arch === "arm64" ? "aarch64" : "x86_64";
|
|
89
|
-
return `${goos}-${goarch}`;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Build id from a registry archive URL:
|
|
93
|
-
* …/agy-acp-server-<build>-<platform>-<arch>.zip. */
|
|
94
|
-
export function buildIdFromArchive(url: string): string {
|
|
95
|
-
const base = path.posix.basename(url, ".zip");
|
|
96
|
-
const m = base.match(/^agy-acp-server-(.+)-(darwin|linux|windows)-(?:arm64|x86_64)$/);
|
|
97
|
-
if (!m) throw new Error(`unrecognized registry archive name: ${base}`);
|
|
98
|
-
return m[1];
|
|
5
|
+
export interface AcpCleanupResult {
|
|
6
|
+
removedBuilds: string[];
|
|
7
|
+
rootRemoved: boolean;
|
|
99
8
|
}
|
|
100
9
|
|
|
101
|
-
function
|
|
10
|
+
export function defaultManagedAcpRoot(): string {
|
|
102
11
|
return path.join(os.homedir(), ".local", "opt", "agy-acp");
|
|
103
12
|
}
|
|
104
13
|
|
|
105
|
-
function
|
|
106
|
-
return opts.geminiDir ?? path.join(os.homedir(), ".gemini", "antigravity-acp");
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Expand a leading ~ (spawn does not). Mirrors resolveAcpBinary. */
|
|
110
|
-
function expandTilde(p: string): string {
|
|
111
|
-
return p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export function executableFile(p: string): boolean {
|
|
14
|
+
function regularFile(file: string): boolean {
|
|
115
15
|
try {
|
|
116
|
-
fs.
|
|
117
|
-
return fs.statSync(p).isFile();
|
|
16
|
+
return fs.lstatSync(file).isFile();
|
|
118
17
|
} catch {
|
|
119
18
|
return false;
|
|
120
19
|
}
|
|
121
20
|
}
|
|
122
21
|
|
|
123
|
-
|
|
124
|
-
archive: string;
|
|
125
|
-
cmd?: string;
|
|
126
|
-
/** Platform-specific argv the server needs to boot (e.g. linux `--uid=`).
|
|
127
|
-
* Untrusted registry data: validated as a string array before use. */
|
|
128
|
-
args?: unknown;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Registry args are untrusted; accept only a flat string array. */
|
|
132
|
-
function normalizeRegistryArgs(raw: unknown): string[] {
|
|
133
|
-
if (!Array.isArray(raw)) return [];
|
|
134
|
-
return raw.filter((a): a is string => typeof a === "string");
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function fetchRegistryBinary(opts: SetupOptions, key: string): Promise<RegistryBinary> {
|
|
138
|
-
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
139
|
-
const res = await fetchImpl(opts.registryUrl ?? REGISTRY_URL, { signal: AbortSignal.timeout(15_000) });
|
|
140
|
-
if (!res.ok) throw new Error(`registry fetch failed: HTTP ${res.status}`);
|
|
141
|
-
const json = (await res.json()) as { distribution?: { binary?: Record<string, RegistryBinary> } };
|
|
142
|
-
const entry = json.distribution?.binary?.[key];
|
|
143
|
-
if (!entry?.archive) throw new Error(`registry has no ${key} binary`);
|
|
144
|
-
return entry;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function defaultUnpack(archive: string, dest: string): Promise<void> {
|
|
148
|
-
return new Promise((resolve, reject) => {
|
|
149
|
-
const child = spawn("unzip", ["-o", "-q", archive, "-d", dest], { stdio: "ignore" });
|
|
150
|
-
child.on("error", (err) => reject(new Error(`unzip is not available: ${err.message}`)));
|
|
151
|
-
child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`unzip exited with ${code}`))));
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
async function downloadTo(
|
|
156
|
-
url: string,
|
|
157
|
-
dest: string,
|
|
158
|
-
fetchImpl: typeof fetch,
|
|
159
|
-
onProgress?: (msg: string) => void,
|
|
160
|
-
): Promise<string> {
|
|
161
|
-
const res = await fetchImpl(url, { signal: AbortSignal.timeout(INSTALL_TIMEOUT_MS) });
|
|
162
|
-
if (!res.ok || !res.body) throw new Error(`download failed: HTTP ${res.status}`);
|
|
163
|
-
const hash = createHash("sha256");
|
|
164
|
-
const total = Number(res.headers.get("content-length") ?? 0);
|
|
165
|
-
const out = fs.createWriteStream(dest);
|
|
166
|
-
let done = 0;
|
|
167
|
-
let lastPct = -1;
|
|
168
|
-
for await (const chunk of res.body) {
|
|
169
|
-
const buf = Buffer.from(chunk as unknown as Uint8Array);
|
|
170
|
-
hash.update(buf);
|
|
171
|
-
done += buf.length;
|
|
172
|
-
if (total > 0) {
|
|
173
|
-
const pct = Math.floor((done / total) * 100);
|
|
174
|
-
if (pct >= lastPct + 10) {
|
|
175
|
-
lastPct = pct;
|
|
176
|
-
onProgress?.(`downloading ACP server: ${pct}%`);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
if (!out.write(buf)) await new Promise<void>((r) => out.once("drain", r));
|
|
180
|
-
}
|
|
181
|
-
await new Promise<void>((resolve, reject) => {
|
|
182
|
-
out.on("finish", () => resolve());
|
|
183
|
-
out.on("error", reject);
|
|
184
|
-
out.end();
|
|
185
|
-
});
|
|
186
|
-
return hash.digest("hex");
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export interface InstallResult {
|
|
190
|
-
bin: string;
|
|
191
|
-
build: string;
|
|
192
|
-
sha256: string;
|
|
193
|
-
downloaded: boolean;
|
|
194
|
-
/** Registry-declared argv for the managed binary (may be empty). */
|
|
195
|
-
args: string[];
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** Fetch the pinned registry build for this platform and install it under
|
|
199
|
-
* <root>/<build>/ with a `current` symlink (plan §12). No-op when the
|
|
200
|
-
* build is already installed. Registry publishes no checksums; the zip
|
|
201
|
-
* sha256 is recorded at install time for drift detection. */
|
|
202
|
-
export async function installAcpBinary(opts: SetupOptions = {}): Promise<InstallResult> {
|
|
203
|
-
const root = opts.installRoot ?? defaultInstallRoot();
|
|
204
|
-
const entry = await fetchRegistryBinary(opts, platformKey());
|
|
205
|
-
const build = buildIdFromArchive(entry.archive);
|
|
206
|
-
const cmd = (entry.cmd ?? "./agy_acp_server.par").replace(/^\.\//, "");
|
|
207
|
-
const dir = path.join(root, build);
|
|
208
|
-
const bin = path.join(dir, cmd);
|
|
209
|
-
const args = normalizeRegistryArgs(entry.args);
|
|
210
|
-
if (executableFile(bin)) {
|
|
211
|
-
// Already installed: recover any recorded args so the early-return
|
|
212
|
-
// path does not silently drop them.
|
|
213
|
-
return { bin, build, sha256: readRecordedSha(dir), downloaded: false, args: readRecordedArgs(dir) };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
fs.mkdirSync(root, { recursive: true });
|
|
217
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
218
|
-
const tmpZip = path.join(root, `.download-${build}.zip`);
|
|
219
|
-
opts.onProgress?.(`downloading ACP server (build ${build})…`);
|
|
220
|
-
const sha = await downloadTo(entry.archive, tmpZip, opts.fetchImpl ?? fetch, opts.onProgress);
|
|
221
|
-
opts.onProgress?.("unpacking ACP server…");
|
|
222
|
-
const unpack = opts.unpack ?? defaultUnpack;
|
|
223
|
-
await unpack(tmpZip, dir);
|
|
224
|
-
fs.rmSync(tmpZip, { force: true });
|
|
225
|
-
if (!executableFile(bin)) throw new Error(`${cmd} not found in the archive after unpacking`);
|
|
226
|
-
fs.chmodSync(bin, 0o755);
|
|
227
|
-
fs.writeFileSync(path.join(dir, "zip.sha256"), `${sha} ${path.basename(entry.archive)}\n`);
|
|
228
|
-
if (args.length > 0) fs.writeFileSync(path.join(dir, "args.json"), JSON.stringify(args));
|
|
229
|
-
|
|
230
|
-
// Re-point `current` at the new build. The link is ours (managed layout).
|
|
231
|
-
const link = path.join(root, "current");
|
|
232
|
-
fs.rmSync(link, { force: true });
|
|
233
|
-
fs.symlinkSync(build, link);
|
|
234
|
-
opts.onProgress?.("ACP server installed.");
|
|
235
|
-
return { bin, build, sha256: sha, downloaded: true, args };
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function readRecordedSha(dir: string): string {
|
|
22
|
+
function realDirectory(file: string): boolean {
|
|
239
23
|
try {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return "";
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function readRecordedArgs(dir: string): string[] {
|
|
247
|
-
try {
|
|
248
|
-
return normalizeRegistryArgs(JSON.parse(fs.readFileSync(path.join(dir, "args.json"), "utf8")));
|
|
249
|
-
} catch {
|
|
250
|
-
return [];
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
export interface AuthState {
|
|
255
|
-
configured: boolean;
|
|
256
|
-
/** settings.json auth.type, "token" (acp_token.json present), or undefined. */
|
|
257
|
-
type?: string;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/** Token presence WITHOUT reading any credential value: acp_token.json is
|
|
261
|
-
* stat()ed, never opened. The token only exists after the browser login
|
|
262
|
-
* round-trip, so this is the real proof of a completed login. */
|
|
263
|
-
function tokenPresent(target: string): boolean {
|
|
264
|
-
try {
|
|
265
|
-
fs.statSync(path.join(target, "acp_token.json"));
|
|
266
|
-
return true;
|
|
24
|
+
const stat = fs.lstatSync(file);
|
|
25
|
+
return stat.isDirectory() && !stat.isSymbolicLink();
|
|
267
26
|
} catch {
|
|
268
27
|
return false;
|
|
269
28
|
}
|
|
270
29
|
}
|
|
271
30
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
return tokenPresent(dir ?? path.join(os.homedir(), ".gemini", "antigravity-acp"));
|
|
31
|
+
function enoent(err: unknown): boolean {
|
|
32
|
+
return (err as NodeJS.ErrnoException).code === "ENOENT";
|
|
275
33
|
}
|
|
276
34
|
|
|
277
|
-
/**
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
* credential files (e.g. jetski-standalone-oauth-token) and never writes
|
|
281
|
-
* acp_token.json, so the file check alone would nag login-pending forever
|
|
282
|
-
* on a working setup. Cleared the moment the server reports an auth error
|
|
283
|
-
* (-32000), so a stale marker never masks a real logout. Never holds a
|
|
284
|
-
* credential value: a boolean and a timestamp. */
|
|
285
|
-
export function defaultAcpAuthVerifiedPath(): string {
|
|
286
|
-
return path.join(os.homedir(), ".pi", "agent", "antigravity-bridge", "acp-auth-verified.json");
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
export function readAcpAuthVerified(file: string = defaultAcpAuthVerifiedPath()): boolean {
|
|
290
|
-
try {
|
|
291
|
-
const raw = JSON.parse(fs.readFileSync(file, "utf8")) as { verified?: unknown };
|
|
292
|
-
return raw.verified === true;
|
|
293
|
-
} catch {
|
|
294
|
-
return false;
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
export function writeAcpAuthVerified(file: string = defaultAcpAuthVerifiedPath()): void {
|
|
299
|
-
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
300
|
-
fs.writeFileSync(file, `${JSON.stringify({ verified: true, at: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
export function clearAcpAuthVerified(file: string = defaultAcpAuthVerifiedPath()): void {
|
|
35
|
+
/** Remove only artifacts carrying the old installer's layout markers. */
|
|
36
|
+
export function cleanupManagedAcpInstall(root: string = defaultManagedAcpRoot()): AcpCleanupResult {
|
|
37
|
+
let rootStat: fs.Stats;
|
|
304
38
|
try {
|
|
305
|
-
fs.
|
|
306
|
-
} catch {
|
|
307
|
-
|
|
308
|
-
|
|
39
|
+
rootStat = fs.lstatSync(root);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (enoent(err)) return { removedBuilds: [], rootRemoved: false };
|
|
42
|
+
throw err;
|
|
309
43
|
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
/* absent or garbage = unconfigured */
|
|
324
|
-
}
|
|
325
|
-
return tokenPresent(target) ? { configured: true, type: "token" } : { configured: false };
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/** Write a minimal auth block into settings.json. The server reads this file
|
|
329
|
-
* at STARTUP, so this must happen before the first server spawn. */
|
|
330
|
-
export function writeAuthType(type: "gemini-api-key" | "oauth-personal", dir?: string): void {
|
|
331
|
-
const target = dir ?? path.join(os.homedir(), ".gemini", "antigravity-acp");
|
|
332
|
-
fs.mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
333
|
-
const file = path.join(target, "settings.json");
|
|
334
|
-
let settings: Record<string, unknown> = {};
|
|
335
|
-
try {
|
|
336
|
-
settings = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
337
|
-
} catch {
|
|
338
|
-
/* fresh file */
|
|
339
|
-
}
|
|
340
|
-
const prevAuth = (typeof settings.auth === "object" && settings.auth !== null ? settings.auth : {}) as Record<string, unknown>;
|
|
341
|
-
const nextSettings: Record<string, unknown> = { ...settings, auth: { ...prevAuth, type } };
|
|
342
|
-
if (typeof nextSettings.queuedMessages !== "string" || !nextSettings.queuedMessages.trim()) {
|
|
343
|
-
nextSettings.queuedMessages = QUEUED_MESSAGES_SEND_IMMEDIATELY;
|
|
344
|
-
}
|
|
345
|
-
fs.writeFileSync(file, `${JSON.stringify(nextSettings, null, 2)}\n`, { mode: 0o600 });
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
export interface BinaryResolution {
|
|
349
|
-
bin: string | null;
|
|
350
|
-
source: "env" | "config" | "existing" | "installed" | null;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/** Read-only binary resolution: AGY_ACP_BIN > acp.bin > installed `current`
|
|
354
|
-
* layout. Does NOT install and does NOT fall back to the bare PATH name. */
|
|
355
|
-
export function resolveInstalledBinary(opts: SetupOptions = {}): BinaryResolution {
|
|
356
|
-
const env = opts.env ?? process.env;
|
|
357
|
-
const root = opts.installRoot ?? defaultInstallRoot();
|
|
358
|
-
const candidates: Array<[string, BinaryResolution["source"]]> = [
|
|
359
|
-
[env.AGY_ACP_BIN?.trim() ?? "", "env"],
|
|
360
|
-
[opts.configBin?.trim() ?? "", "config"],
|
|
361
|
-
[path.join(root, "current", "agy_acp_server.par"), "existing"],
|
|
362
|
-
];
|
|
363
|
-
for (const [raw, source] of candidates) {
|
|
364
|
-
if (!raw) continue;
|
|
365
|
-
const bin = expandTilde(raw);
|
|
366
|
-
if (executableFile(bin)) return { bin, source };
|
|
367
|
-
}
|
|
368
|
-
return { bin: null, source: null };
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
/** Registry argv for the managed install only. Env/config overrides (a user
|
|
372
|
-
* shim like `agy acp`, or AGY_ACP_BIN) own their argv, so they get none.
|
|
373
|
-
* Identity is by PATH, not by which candidate won: setup persists the managed
|
|
374
|
-
* path into acp.bin, so after install the config candidate resolves to the
|
|
375
|
-
* managed binary too. Compare realpaths against `<root>/current/...` (the
|
|
376
|
-
* symlink target carries the args.json sidecar). Empty when not managed. */
|
|
377
|
-
export function resolveInstalledArgs(opts: SetupOptions = {}): string[] {
|
|
378
|
-
const binary = resolveInstalledBinary(opts);
|
|
379
|
-
if (!binary.bin) return [];
|
|
380
|
-
const root = opts.installRoot ?? defaultInstallRoot();
|
|
381
|
-
const managed = path.join(root, "current", "agy_acp_server.par");
|
|
382
|
-
try {
|
|
383
|
-
if (fs.realpathSync(binary.bin) !== fs.realpathSync(managed)) return [];
|
|
384
|
-
} catch {
|
|
385
|
-
return [];
|
|
44
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return { removedBuilds: [], rootRemoved: false };
|
|
45
|
+
|
|
46
|
+
const entries = fs.readdirSync(root, { withFileTypes: true });
|
|
47
|
+
const interrupted = new Set<string>();
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const match = /^\.download-(.+)\.zip$/.exec(entry.name);
|
|
50
|
+
if (!match || !regularFile(path.join(root, entry.name))) continue;
|
|
51
|
+
interrupted.add(match[1]!);
|
|
52
|
+
try {
|
|
53
|
+
fs.unlinkSync(path.join(root, entry.name));
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (!enoent(err)) throw err;
|
|
56
|
+
}
|
|
386
57
|
}
|
|
387
|
-
return readRecordedArgs(path.dirname(fs.realpathSync(managed)));
|
|
388
|
-
}
|
|
389
58
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
/** Orchestrate everything the ACP engine needs, in order: binary (install
|
|
404
|
-
* from the registry when missing), then auth (oauth-personal: the user's
|
|
405
|
-
* own Antigravity subscription - the same Google account and plan as the
|
|
406
|
-
* agy CLI login; the server opens the browser itself on the first message).
|
|
407
|
-
* Every step is skipped when already satisfied; failures return `manual`
|
|
408
|
-
* text for the caller to surface. */
|
|
409
|
-
export async function ensureAcpReady(opts: SetupOptions = {}): Promise<AcpSetupStatus> {
|
|
410
|
-
const actions: string[] = [];
|
|
411
|
-
|
|
412
|
-
// 1. Binary.
|
|
413
|
-
let binary = resolveInstalledBinary(opts);
|
|
414
|
-
let args = resolveInstalledArgs(opts);
|
|
415
|
-
if (!binary.bin) {
|
|
416
|
-
if (opts.install === false) {
|
|
417
|
-
return { ok: false, stage: "install", error: "no ACP server binary found", manual: MANUAL_SETUP };
|
|
59
|
+
const removedBuilds: string[] = [];
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
62
|
+
const dir = path.join(root, entry.name);
|
|
63
|
+
if (!regularFile(path.join(dir, "agy_acp_server.par"))) {
|
|
64
|
+
if (interrupted.has(entry.name)) {
|
|
65
|
+
try {
|
|
66
|
+
if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (!enoent(err)) throw err;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
418
72
|
}
|
|
73
|
+
const marked =
|
|
74
|
+
regularFile(path.join(dir, "zip.sha256")) ||
|
|
75
|
+
realDirectory(path.join(dir, "localharness_external")) ||
|
|
76
|
+
interrupted.has(entry.name);
|
|
77
|
+
if (!marked) continue;
|
|
419
78
|
try {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
args = installed.args;
|
|
423
|
-
actions.push(installed.downloaded ? `installed ACP server build ${installed.build}` : `found installed ACP server build ${installed.build}`);
|
|
79
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
80
|
+
removedBuilds.push(entry.name);
|
|
424
81
|
} catch (err) {
|
|
425
|
-
|
|
426
|
-
ok: false,
|
|
427
|
-
stage: "install",
|
|
428
|
-
error: err instanceof Error ? err.message : String(err),
|
|
429
|
-
manual: MANUAL_SETUP,
|
|
430
|
-
};
|
|
82
|
+
if (!enoent(err)) throw err;
|
|
431
83
|
}
|
|
432
84
|
}
|
|
433
85
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
ok: false,
|
|
450
|
-
stage: "auth",
|
|
451
|
-
error: err instanceof Error ? err.message : String(err),
|
|
452
|
-
manual: MANUAL_SETUP,
|
|
453
|
-
};
|
|
86
|
+
const current = path.join(root, "current");
|
|
87
|
+
try {
|
|
88
|
+
if (fs.lstatSync(current).isSymbolicLink()) {
|
|
89
|
+
const target = path.resolve(root, fs.readlinkSync(current));
|
|
90
|
+
if (path.dirname(target) === root && removedBuilds.includes(path.basename(target))) fs.unlinkSync(current);
|
|
91
|
+
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (!enoent(err)) throw err;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let rootRemoved = false;
|
|
97
|
+
try {
|
|
98
|
+
if (fs.readdirSync(root).length === 0) {
|
|
99
|
+
fs.rmdirSync(root);
|
|
100
|
+
rootRemoved = true;
|
|
454
101
|
}
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (enoent(err)) rootRemoved = true;
|
|
104
|
+
else throw err;
|
|
455
105
|
}
|
|
456
|
-
|
|
457
|
-
// the token file only appears after the browser round-trip. oauth-personal
|
|
458
|
-
// without a token is login-pending UNLESS a verified sign-in recorded that
|
|
459
|
-
// this server authenticates from artifacts the bridge cannot see (1.1.1
|
|
460
|
-
// legacy ~/.gemini credentials; marker written by runAcpAuth, cleared on
|
|
461
|
-
// any -32000). gemini-api-key has no browser login; a present token file
|
|
462
|
-
// means done.
|
|
463
|
-
const needsLogin = auth.type !== "gemini-api-key" && !tokenPresent(gdir) && !readAcpAuthVerified(opts.authVerifiedFile);
|
|
464
|
-
return {
|
|
465
|
-
ok: true,
|
|
466
|
-
bin: binary.bin!,
|
|
467
|
-
binarySource: binary.source!,
|
|
468
|
-
args,
|
|
469
|
-
auth: auth.type ?? "token",
|
|
470
|
-
needsLogin,
|
|
471
|
-
actions,
|
|
472
|
-
};
|
|
106
|
+
return { removedBuilds, rootRemoved };
|
|
473
107
|
}
|
package/src/agents.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
//
|
|
9
9
|
// A picked name rides the turn request as `--agent` (same flag our web
|
|
10
10
|
// tools already pass for their throwaway agents). ACP has no agent slot in
|
|
11
|
-
//
|
|
11
|
+
// per-turn agent selection, so the bridge refuses it at the command level.
|
|
12
12
|
|
|
13
13
|
import { spawnAgyRaw } from "./models.js";
|
|
14
14
|
|