@estebanforge/pi-antigravity-bridge 1.9.2 → 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 +26 -0
- package/README.md +12 -14
- package/extensions/index.ts +115 -369
- package/package.json +1 -1
- package/src/acp/connection.ts +56 -109
- package/src/acp/driver.ts +38 -62
- 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 -390
- 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 -119
- 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,426 +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
4
|
|
|
50
|
-
export
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
bin: string;
|
|
54
|
-
binarySource: "env" | "config" | "installed" | "existing";
|
|
55
|
-
auth: string;
|
|
56
|
-
/** True when auth was just set to oauth-personal: /agy auth opens
|
|
57
|
-
* the Google login in the browser. */
|
|
58
|
-
needsLogin: boolean;
|
|
59
|
-
/** Human-readable actions taken (empty when everything was ready). */
|
|
60
|
-
actions: string[];
|
|
61
|
-
}
|
|
62
|
-
| { ok: false; stage: "install" | "auth"; error: string; manual: string };
|
|
63
|
-
|
|
64
|
-
export const MANUAL_SETUP = [
|
|
65
|
-
"For some unknown reason, Google ships its ACP server as a separate",
|
|
66
|
-
"binary from the Agy CLI. So, as with any other part of its Antigravity",
|
|
67
|
-
"suite (Agy Desktop, Agy Editor, Agy CLI), you need to log in to",
|
|
68
|
-
"this one as well. Separately ¯\\_(ツ)_/¯",
|
|
69
|
-
"ACP manual setup:",
|
|
70
|
-
"1. Download the server zip for your platform from the antigravity-acp",
|
|
71
|
-
" registry entry (github.com/agentclientprotocol/registry, folder",
|
|
72
|
-
" antigravity-acp/agent.json) and unzip it, e.g. to",
|
|
73
|
-
" ~/.local/opt/agy-acp/<build>/; chmod +x agy_acp_server.par.",
|
|
74
|
-
"2. Point AGY_ACP_BIN or /agy config acp.bin at the binary",
|
|
75
|
-
" (~/.local/opt/agy-acp/current/agy_acp_server.par works; 'current' is a",
|
|
76
|
-
" symlink to the build dir).",
|
|
77
|
-
'3. Log in: put {"auth":{"type":"oauth-personal"}} in',
|
|
78
|
-
" ~/.gemini/antigravity-acp/settings.json and run /agy auth, then complete",
|
|
79
|
-
" the Google login that opens in your browser. That login IS your",
|
|
80
|
-
" Antigravity subscription (Google AI Plus/Pro/Ultra).",
|
|
81
|
-
"Details: /agy auth-manual",
|
|
82
|
-
].join("\n");
|
|
83
|
-
|
|
84
|
-
export function platformKey(): string {
|
|
85
|
-
const goos = process.platform === "darwin" ? "darwin" : process.platform === "win32" ? "windows" : "linux";
|
|
86
|
-
const goarch = process.arch === "arm64" ? "aarch64" : "x86_64";
|
|
87
|
-
return `${goos}-${goarch}`;
|
|
5
|
+
export interface AcpCleanupResult {
|
|
6
|
+
removedBuilds: string[];
|
|
7
|
+
rootRemoved: boolean;
|
|
88
8
|
}
|
|
89
9
|
|
|
90
|
-
|
|
91
|
-
* …/agy-acp-server-<build>-<platform>-<arch>.zip. */
|
|
92
|
-
export function buildIdFromArchive(url: string): string {
|
|
93
|
-
const base = path.posix.basename(url, ".zip");
|
|
94
|
-
const m = base.match(/^agy-acp-server-(.+)-(darwin|linux|windows)-(?:arm64|x86_64)$/);
|
|
95
|
-
if (!m) throw new Error(`unrecognized registry archive name: ${base}`);
|
|
96
|
-
return m[1];
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function defaultInstallRoot(): string {
|
|
10
|
+
export function defaultManagedAcpRoot(): string {
|
|
100
11
|
return path.join(os.homedir(), ".local", "opt", "agy-acp");
|
|
101
12
|
}
|
|
102
13
|
|
|
103
|
-
function
|
|
104
|
-
return opts.geminiDir ?? path.join(os.homedir(), ".gemini", "antigravity-acp");
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Expand a leading ~ (spawn does not). Mirrors resolveAcpBinary. */
|
|
108
|
-
function expandTilde(p: string): string {
|
|
109
|
-
return p === "~" || p.startsWith("~/") ? path.join(os.homedir(), p.slice(1)) : p;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export function executableFile(p: string): boolean {
|
|
14
|
+
function regularFile(file: string): boolean {
|
|
113
15
|
try {
|
|
114
|
-
fs.
|
|
115
|
-
return fs.statSync(p).isFile();
|
|
16
|
+
return fs.lstatSync(file).isFile();
|
|
116
17
|
} catch {
|
|
117
18
|
return false;
|
|
118
19
|
}
|
|
119
20
|
}
|
|
120
21
|
|
|
121
|
-
|
|
122
|
-
archive: string;
|
|
123
|
-
cmd?: string;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function fetchRegistryBinary(opts: SetupOptions, key: string): Promise<RegistryBinary> {
|
|
127
|
-
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
128
|
-
const res = await fetchImpl(opts.registryUrl ?? REGISTRY_URL, { signal: AbortSignal.timeout(15_000) });
|
|
129
|
-
if (!res.ok) throw new Error(`registry fetch failed: HTTP ${res.status}`);
|
|
130
|
-
const json = (await res.json()) as { distribution?: { binary?: Record<string, RegistryBinary> } };
|
|
131
|
-
const entry = json.distribution?.binary?.[key];
|
|
132
|
-
if (!entry?.archive) throw new Error(`registry has no ${key} binary`);
|
|
133
|
-
return entry;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function defaultUnpack(archive: string, dest: string): Promise<void> {
|
|
137
|
-
return new Promise((resolve, reject) => {
|
|
138
|
-
const child = spawn("unzip", ["-o", "-q", archive, "-d", dest], { stdio: "ignore" });
|
|
139
|
-
child.on("error", (err) => reject(new Error(`unzip is not available: ${err.message}`)));
|
|
140
|
-
child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`unzip exited with ${code}`))));
|
|
141
|
-
});
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
async function downloadTo(
|
|
145
|
-
url: string,
|
|
146
|
-
dest: string,
|
|
147
|
-
fetchImpl: typeof fetch,
|
|
148
|
-
onProgress?: (msg: string) => void,
|
|
149
|
-
): Promise<string> {
|
|
150
|
-
const res = await fetchImpl(url, { signal: AbortSignal.timeout(INSTALL_TIMEOUT_MS) });
|
|
151
|
-
if (!res.ok || !res.body) throw new Error(`download failed: HTTP ${res.status}`);
|
|
152
|
-
const hash = createHash("sha256");
|
|
153
|
-
const total = Number(res.headers.get("content-length") ?? 0);
|
|
154
|
-
const out = fs.createWriteStream(dest);
|
|
155
|
-
let done = 0;
|
|
156
|
-
let lastPct = -1;
|
|
157
|
-
for await (const chunk of res.body) {
|
|
158
|
-
const buf = Buffer.from(chunk as unknown as Uint8Array);
|
|
159
|
-
hash.update(buf);
|
|
160
|
-
done += buf.length;
|
|
161
|
-
if (total > 0) {
|
|
162
|
-
const pct = Math.floor((done / total) * 100);
|
|
163
|
-
if (pct >= lastPct + 10) {
|
|
164
|
-
lastPct = pct;
|
|
165
|
-
onProgress?.(`downloading ACP server: ${pct}%`);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
if (!out.write(buf)) await new Promise<void>((r) => out.once("drain", r));
|
|
169
|
-
}
|
|
170
|
-
await new Promise<void>((resolve, reject) => {
|
|
171
|
-
out.on("finish", () => resolve());
|
|
172
|
-
out.on("error", reject);
|
|
173
|
-
out.end();
|
|
174
|
-
});
|
|
175
|
-
return hash.digest("hex");
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export interface InstallResult {
|
|
179
|
-
bin: string;
|
|
180
|
-
build: string;
|
|
181
|
-
sha256: string;
|
|
182
|
-
downloaded: boolean;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** Fetch the pinned registry build for this platform and install it under
|
|
186
|
-
* <root>/<build>/ with a `current` symlink (plan §12). No-op when the
|
|
187
|
-
* build is already installed. Registry publishes no checksums; the zip
|
|
188
|
-
* sha256 is recorded at install time for drift detection. */
|
|
189
|
-
export async function installAcpBinary(opts: SetupOptions = {}): Promise<InstallResult> {
|
|
190
|
-
const root = opts.installRoot ?? defaultInstallRoot();
|
|
191
|
-
const entry = await fetchRegistryBinary(opts, platformKey());
|
|
192
|
-
const build = buildIdFromArchive(entry.archive);
|
|
193
|
-
const cmd = (entry.cmd ?? "./agy_acp_server.par").replace(/^\.\//, "");
|
|
194
|
-
const dir = path.join(root, build);
|
|
195
|
-
const bin = path.join(dir, cmd);
|
|
196
|
-
if (executableFile(bin)) {
|
|
197
|
-
return { bin, build, sha256: readRecordedSha(dir), downloaded: false };
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
fs.mkdirSync(root, { recursive: true });
|
|
201
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
202
|
-
const tmpZip = path.join(root, `.download-${build}.zip`);
|
|
203
|
-
opts.onProgress?.(`downloading ACP server (build ${build})…`);
|
|
204
|
-
const sha = await downloadTo(entry.archive, tmpZip, opts.fetchImpl ?? fetch, opts.onProgress);
|
|
205
|
-
opts.onProgress?.("unpacking ACP server…");
|
|
206
|
-
const unpack = opts.unpack ?? defaultUnpack;
|
|
207
|
-
await unpack(tmpZip, dir);
|
|
208
|
-
fs.rmSync(tmpZip, { force: true });
|
|
209
|
-
if (!executableFile(bin)) throw new Error(`${cmd} not found in the archive after unpacking`);
|
|
210
|
-
fs.chmodSync(bin, 0o755);
|
|
211
|
-
fs.writeFileSync(path.join(dir, "zip.sha256"), `${sha} ${path.basename(entry.archive)}\n`);
|
|
212
|
-
|
|
213
|
-
// Re-point `current` at the new build. The link is ours (managed layout).
|
|
214
|
-
const link = path.join(root, "current");
|
|
215
|
-
fs.rmSync(link, { force: true });
|
|
216
|
-
fs.symlinkSync(build, link);
|
|
217
|
-
opts.onProgress?.("ACP server installed.");
|
|
218
|
-
return { bin, build, sha256: sha, downloaded: true };
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function readRecordedSha(dir: string): string {
|
|
222
|
-
try {
|
|
223
|
-
return fs.readFileSync(path.join(dir, "zip.sha256"), "utf8").split(/\s+/)[0] ?? "";
|
|
224
|
-
} catch {
|
|
225
|
-
return "";
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
export interface AuthState {
|
|
230
|
-
configured: boolean;
|
|
231
|
-
/** settings.json auth.type, "token" (acp_token.json present), or undefined. */
|
|
232
|
-
type?: string;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/** Token presence WITHOUT reading any credential value: acp_token.json is
|
|
236
|
-
* stat()ed, never opened. The token only exists after the browser login
|
|
237
|
-
* round-trip, so this is the real proof of a completed login. */
|
|
238
|
-
function tokenPresent(target: string): boolean {
|
|
22
|
+
function realDirectory(file: string): boolean {
|
|
239
23
|
try {
|
|
240
|
-
fs.
|
|
241
|
-
return
|
|
24
|
+
const stat = fs.lstatSync(file);
|
|
25
|
+
return stat.isDirectory() && !stat.isSymbolicLink();
|
|
242
26
|
} catch {
|
|
243
27
|
return false;
|
|
244
28
|
}
|
|
245
29
|
}
|
|
246
30
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
return tokenPresent(dir ?? path.join(os.homedir(), ".gemini", "antigravity-acp"));
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/** Bridge-side record that the ACP server proved its own credentials in a
|
|
253
|
-
* verified sign-in (runAcpAuth). Needed because servers may authenticate
|
|
254
|
-
* from artifacts this module cannot see: 1.1.1 honors legacy ~/.gemini
|
|
255
|
-
* credential files (e.g. jetski-standalone-oauth-token) and never writes
|
|
256
|
-
* acp_token.json, so the file check alone would nag login-pending forever
|
|
257
|
-
* on a working setup. Cleared the moment the server reports an auth error
|
|
258
|
-
* (-32000), so a stale marker never masks a real logout. Never holds a
|
|
259
|
-
* credential value: a boolean and a timestamp. */
|
|
260
|
-
export function defaultAcpAuthVerifiedPath(): string {
|
|
261
|
-
return path.join(os.homedir(), ".pi", "agent", "antigravity-bridge", "acp-auth-verified.json");
|
|
31
|
+
function enoent(err: unknown): boolean {
|
|
32
|
+
return (err as NodeJS.ErrnoException).code === "ENOENT";
|
|
262
33
|
}
|
|
263
34
|
|
|
264
|
-
|
|
35
|
+
/** Remove only artifacts carrying the old installer's layout markers. */
|
|
36
|
+
export function cleanupManagedAcpInstall(root: string = defaultManagedAcpRoot()): AcpCleanupResult {
|
|
37
|
+
let rootStat: fs.Stats;
|
|
265
38
|
try {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
export function writeAcpAuthVerified(file: string = defaultAcpAuthVerifiedPath()): void {
|
|
274
|
-
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
275
|
-
fs.writeFileSync(file, `${JSON.stringify({ verified: true, at: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
export function clearAcpAuthVerified(file: string = defaultAcpAuthVerifiedPath()): void {
|
|
279
|
-
try {
|
|
280
|
-
fs.rmSync(file, { force: true });
|
|
281
|
-
} catch {
|
|
282
|
-
/* best effort: a leftover marker only over-reports readiness, and the
|
|
283
|
-
next auth error clears it again. */
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/** Auth state WITHOUT reading any credential value: settings.json carries
|
|
288
|
-
* only auth.type (+ non-secret gcp placement), acp_token.json is stat()ed. */
|
|
289
|
-
export function readAuthState(dir?: string): AuthState {
|
|
290
|
-
const target = dir ?? path.join(os.homedir(), ".gemini", "antigravity-acp");
|
|
291
|
-
try {
|
|
292
|
-
const raw = JSON.parse(fs.readFileSync(path.join(target, "settings.json"), "utf8")) as {
|
|
293
|
-
auth?: { type?: unknown };
|
|
294
|
-
};
|
|
295
|
-
const type = typeof raw.auth?.type === "string" ? raw.auth.type : undefined;
|
|
296
|
-
if (type) return { configured: true, type };
|
|
297
|
-
} catch {
|
|
298
|
-
/* absent or garbage = unconfigured */
|
|
299
|
-
}
|
|
300
|
-
return tokenPresent(target) ? { configured: true, type: "token" } : { configured: false };
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/** Write a minimal auth block into settings.json. The server reads this file
|
|
304
|
-
* at STARTUP, so this must happen before the first server spawn. */
|
|
305
|
-
export function writeAuthType(type: "gemini-api-key" | "oauth-personal", dir?: string): void {
|
|
306
|
-
const target = dir ?? path.join(os.homedir(), ".gemini", "antigravity-acp");
|
|
307
|
-
fs.mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
308
|
-
const file = path.join(target, "settings.json");
|
|
309
|
-
let settings: Record<string, unknown> = {};
|
|
310
|
-
try {
|
|
311
|
-
settings = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
312
|
-
} catch {
|
|
313
|
-
/* fresh file */
|
|
314
|
-
}
|
|
315
|
-
const prevAuth = (typeof settings.auth === "object" && settings.auth !== null ? settings.auth : {}) as Record<string, unknown>;
|
|
316
|
-
const nextSettings: Record<string, unknown> = { ...settings, auth: { ...prevAuth, type } };
|
|
317
|
-
if (typeof nextSettings.queuedMessages !== "string" || !nextSettings.queuedMessages.trim()) {
|
|
318
|
-
nextSettings.queuedMessages = QUEUED_MESSAGES_SEND_IMMEDIATELY;
|
|
39
|
+
rootStat = fs.lstatSync(root);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
if (enoent(err)) return { removedBuilds: [], rootRemoved: false };
|
|
42
|
+
throw err;
|
|
319
43
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const candidates: Array<[string, BinaryResolution["source"]]> = [
|
|
334
|
-
[env.AGY_ACP_BIN?.trim() ?? "", "env"],
|
|
335
|
-
[opts.configBin?.trim() ?? "", "config"],
|
|
336
|
-
[path.join(root, "current", "agy_acp_server.par"), "existing"],
|
|
337
|
-
];
|
|
338
|
-
for (const [raw, source] of candidates) {
|
|
339
|
-
if (!raw) continue;
|
|
340
|
-
const bin = expandTilde(raw);
|
|
341
|
-
if (executableFile(bin)) return { bin, source };
|
|
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
|
+
}
|
|
342
57
|
}
|
|
343
|
-
return { bin: null, source: null };
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/** Read-only setup state (for /agy doctor): no installs, no writes. */
|
|
347
|
-
export function inspectAcpSetup(opts: SetupOptions = {}): { bin: string | null; source: string | null; auth: string | null } {
|
|
348
|
-
const binary = resolveInstalledBinary(opts);
|
|
349
|
-
const gdir = defaultGeminiDir(opts);
|
|
350
|
-
const auth = readAuthState(gdir);
|
|
351
|
-
if (!auth.configured) return { bin: binary.bin, source: binary.source, auth: null };
|
|
352
|
-
const type = auth.type ?? "token";
|
|
353
|
-
// Surface the verified-marker state so a setup that authenticates without
|
|
354
|
-
// a token file (1.1.1 legacy artifacts) reads as healthy in doctor.
|
|
355
|
-
const verified = type !== "gemini-api-key" && !tokenPresent(gdir) && readAcpAuthVerified(opts.authVerifiedFile);
|
|
356
|
-
return { bin: binary.bin, source: binary.source, auth: verified ? `${type} (verified, no token file)` : type };
|
|
357
|
-
}
|
|
358
58
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
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;
|
|
373
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;
|
|
374
78
|
try {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
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);
|
|
378
81
|
} catch (err) {
|
|
379
|
-
|
|
380
|
-
ok: false,
|
|
381
|
-
stage: "install",
|
|
382
|
-
error: err instanceof Error ? err.message : String(err),
|
|
383
|
-
manual: MANUAL_SETUP,
|
|
384
|
-
};
|
|
82
|
+
if (!enoent(err)) throw err;
|
|
385
83
|
}
|
|
386
84
|
}
|
|
387
85
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
ok: false,
|
|
404
|
-
stage: "auth",
|
|
405
|
-
error: err instanceof Error ? err.message : String(err),
|
|
406
|
-
manual: MANUAL_SETUP,
|
|
407
|
-
};
|
|
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;
|
|
408
101
|
}
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (enoent(err)) rootRemoved = true;
|
|
104
|
+
else throw err;
|
|
409
105
|
}
|
|
410
|
-
|
|
411
|
-
// the token file only appears after the browser round-trip. oauth-personal
|
|
412
|
-
// without a token is login-pending UNLESS a verified sign-in recorded that
|
|
413
|
-
// this server authenticates from artifacts the bridge cannot see (1.1.1
|
|
414
|
-
// legacy ~/.gemini credentials; marker written by runAcpAuth, cleared on
|
|
415
|
-
// any -32000). gemini-api-key has no browser login; a present token file
|
|
416
|
-
// means done.
|
|
417
|
-
const needsLogin = auth.type !== "gemini-api-key" && !tokenPresent(gdir) && !readAcpAuthVerified(opts.authVerifiedFile);
|
|
418
|
-
return {
|
|
419
|
-
ok: true,
|
|
420
|
-
bin: binary.bin!,
|
|
421
|
-
binarySource: binary.source!,
|
|
422
|
-
auth: auth.type ?? "token",
|
|
423
|
-
needsLogin,
|
|
424
|
-
actions,
|
|
425
|
-
};
|
|
106
|
+
return { removedBuilds, rootRemoved };
|
|
426
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
|
|