@mrclrchtr/supi-antigravity 6.4.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/CLAUDE.md +21 -0
- package/CONTEXT.md +53 -0
- package/README.md +75 -0
- package/docs/adr/0001-use-an-isolated-antigravity-home.md +5 -0
- package/node_modules/@mrclrchtr/supi-core/README.md +118 -0
- package/node_modules/@mrclrchtr/supi-core/package.json +76 -0
- package/node_modules/@mrclrchtr/supi-core/src/api.ts +40 -0
- package/node_modules/@mrclrchtr/supi-core/src/config/config.ts +232 -0
- package/node_modules/@mrclrchtr/supi-core/src/config/prompt-surface.ts +363 -0
- package/node_modules/@mrclrchtr/supi-core/src/config.ts +12 -0
- package/node_modules/@mrclrchtr/supi-core/src/context/context-provider-registry.ts +36 -0
- package/node_modules/@mrclrchtr/supi-core/src/context/context-tag.ts +31 -0
- package/node_modules/@mrclrchtr/supi-core/src/context.ts +8 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-identity.ts +11 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +308 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug-timing.ts +120 -0
- package/node_modules/@mrclrchtr/supi-core/src/debug.ts +14 -0
- package/node_modules/@mrclrchtr/supi-core/src/evidence-badge.ts +41 -0
- package/node_modules/@mrclrchtr/supi-core/src/footer-registry.ts +57 -0
- package/node_modules/@mrclrchtr/supi-core/src/index.ts +34 -0
- package/node_modules/@mrclrchtr/supi-core/src/llm.ts +201 -0
- package/node_modules/@mrclrchtr/supi-core/src/model-selection.ts +134 -0
- package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +44 -0
- package/node_modules/@mrclrchtr/supi-core/src/path.ts +2 -0
- package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
- package/node_modules/@mrclrchtr/supi-core/src/project.ts +15 -0
- package/node_modules/@mrclrchtr/supi-core/src/prompt-surface.ts +4 -0
- package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +93 -0
- package/node_modules/@mrclrchtr/supi-core/src/report.ts +121 -0
- package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +71 -0
- package/node_modules/@mrclrchtr/supi-core/src/session.ts +8 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-registry.ts +105 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings/settings-schema.ts +453 -0
- package/node_modules/@mrclrchtr/supi-core/src/settings.ts +36 -0
- package/node_modules/@mrclrchtr/supi-core/src/spinner-frames.ts +11 -0
- package/node_modules/@mrclrchtr/supi-core/src/status-spinner.ts +68 -0
- package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
- package/package.json +79 -0
- package/scripts/live-probe.ts +161 -0
- package/src/activity.ts +22 -0
- package/src/availability.ts +231 -0
- package/src/catalogue.ts +21 -0
- package/src/config.ts +26 -0
- package/src/conversation/handles.ts +183 -0
- package/src/extension.ts +22 -0
- package/src/isolated-home.ts +346 -0
- package/src/process/environment.ts +33 -0
- package/src/process/event-values.ts +279 -0
- package/src/process/events.ts +365 -0
- package/src/process/hooks.ts +219 -0
- package/src/process/ndjson.ts +120 -0
- package/src/process/protocol.ts +69 -0
- package/src/process/runner.ts +147 -0
- package/src/process/subprocess.ts +278 -0
- package/src/process/usage.ts +50 -0
- package/src/runtime.ts +118 -0
- package/src/settings.ts +38 -0
- package/src/structured-output.ts +58 -0
- package/src/tool/antigravity_run/evidence.ts +169 -0
- package/src/tool/antigravity_run/execute.ts +225 -0
- package/src/tool/antigravity_run/guidance.ts +3 -0
- package/src/tool/antigravity_run/input.ts +106 -0
- package/src/tool/antigravity_run/register.ts +36 -0
- package/src/tool/antigravity_run/render.ts +236 -0
- package/src/tool/antigravity_run/result.ts +186 -0
- package/src/tool/antigravity_run/spec.ts +20 -0
- package/src/types.ts +107 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { type ConversationHandleRecord, isCuratedModel } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
/** Private custom-entry type used to rebuild handles on the active PI branch. */
|
|
6
|
+
export const ANTIGRAVITY_HANDLE_ENTRY_TYPE = "supi-antigravity-handle";
|
|
7
|
+
|
|
8
|
+
interface PersistedHandleEntry {
|
|
9
|
+
version: 1;
|
|
10
|
+
action: "created" | "retired";
|
|
11
|
+
record?: ConversationHandleRecord;
|
|
12
|
+
handle?: string;
|
|
13
|
+
reason?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Preflight failure for a Conversation Handle request. */
|
|
17
|
+
export class ConversationHandleError extends Error {
|
|
18
|
+
readonly code: "unknown" | "retired" | "busy";
|
|
19
|
+
|
|
20
|
+
constructor(code: ConversationHandleError["code"]) {
|
|
21
|
+
const message =
|
|
22
|
+
code === "unknown"
|
|
23
|
+
? "Unknown Antigravity Conversation Handle."
|
|
24
|
+
: code === "retired"
|
|
25
|
+
? "That Antigravity Conversation Handle is retired."
|
|
26
|
+
: "That Antigravity Conversation Handle is already in use.";
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "ConversationHandleError";
|
|
29
|
+
this.code = code;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Session-local Conversation Handle state with branch-aware reconstruction. */
|
|
34
|
+
export class ConversationHandleStore {
|
|
35
|
+
#records = new Map<string, ConversationHandleRecord>();
|
|
36
|
+
#busy = new Set<string>();
|
|
37
|
+
|
|
38
|
+
/** Rebuild active and retired handles from one current PI branch. */
|
|
39
|
+
rebuild(branch: readonly unknown[]): void {
|
|
40
|
+
this.#records.clear();
|
|
41
|
+
this.#busy.clear();
|
|
42
|
+
for (const entry of branch) {
|
|
43
|
+
this.#consumeEntry(entry);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Create a new opaque handle for a successful Antigravity Run. */
|
|
48
|
+
create(record: Omit<ConversationHandleRecord, "handle" | "status">): ConversationHandleRecord {
|
|
49
|
+
const created: ConversationHandleRecord = Object.freeze({
|
|
50
|
+
...record,
|
|
51
|
+
handle: createOpaqueHandle(),
|
|
52
|
+
status: "active",
|
|
53
|
+
});
|
|
54
|
+
this.#records.set(created.handle, created);
|
|
55
|
+
return created;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Return a handle record without changing its state. */
|
|
59
|
+
get(handle: string): ConversationHandleRecord | undefined {
|
|
60
|
+
return this.#records.get(handle);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Acquire a handle for one follow-up, rejecting overlap before process startup. */
|
|
64
|
+
acquire(handle: string): ConversationHandleRecord {
|
|
65
|
+
const record = this.#records.get(handle);
|
|
66
|
+
if (!record) throw new ConversationHandleError("unknown");
|
|
67
|
+
if (record.status === "retired") throw new ConversationHandleError("retired");
|
|
68
|
+
if (this.#busy.has(handle)) throw new ConversationHandleError("busy");
|
|
69
|
+
this.#busy.add(handle);
|
|
70
|
+
return record;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Release a successfully completed or pre-process follow-up. */
|
|
74
|
+
release(handle: string): void {
|
|
75
|
+
this.#busy.delete(handle);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Retire a handle after a started follow-up fails or is canceled. */
|
|
79
|
+
retire(handle: string): ConversationHandleRecord | undefined {
|
|
80
|
+
const record = this.#records.get(handle);
|
|
81
|
+
if (!record) return undefined;
|
|
82
|
+
const retired = Object.freeze({ ...record, status: "retired" as const });
|
|
83
|
+
this.#records.set(handle, retired);
|
|
84
|
+
this.#busy.delete(handle);
|
|
85
|
+
return retired;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Clear all state when the extension instance shuts down. */
|
|
89
|
+
clear(): void {
|
|
90
|
+
this.#records.clear();
|
|
91
|
+
this.#busy.clear();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#consumeEntry(entry: unknown): void {
|
|
95
|
+
if (!isRecord(entry)) return;
|
|
96
|
+
if (entry.type === "custom" && entry.customType === ANTIGRAVITY_HANDLE_ENTRY_TYPE) {
|
|
97
|
+
this.#consumePersistedData(entry.data);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (entry.type === "message" && isRecord(entry.message)) {
|
|
101
|
+
const message = entry.message;
|
|
102
|
+
if (message.role === "toolResult" && message.toolName === "antigravity_run") {
|
|
103
|
+
this.#consumeResultDetails(message.details);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#consumePersistedData(value: unknown): void {
|
|
109
|
+
if (!isRecord(value) || value.version !== 1) return;
|
|
110
|
+
const action = value.action;
|
|
111
|
+
if (action === "created" && isHandleRecord(value.record)) {
|
|
112
|
+
this.#records.set(value.record.handle, Object.freeze({ ...value.record }));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (action === "retired" && typeof value.handle === "string") {
|
|
116
|
+
this.retire(value.handle);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
#consumeResultDetails(value: unknown): void {
|
|
121
|
+
if (!isRecord(value)) return;
|
|
122
|
+
const record = {
|
|
123
|
+
handle: value.handle,
|
|
124
|
+
rawAntigravityId: value.rawAntigravityId,
|
|
125
|
+
model: value.model,
|
|
126
|
+
canonicalWorkingDirectory: value.canonicalWorkingDirectory,
|
|
127
|
+
workspaceAccess: value.workspaceAccess,
|
|
128
|
+
cliVersion: value.cliVersion,
|
|
129
|
+
status: value.handleState,
|
|
130
|
+
};
|
|
131
|
+
if (isHandleRecord(record) && !this.#records.has(record.handle)) {
|
|
132
|
+
this.#records.set(record.handle, Object.freeze({ ...record }));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Persist a successful handle without adding model-visible content. */
|
|
138
|
+
export function appendHandleCreated(pi: ExtensionAPI, record: ConversationHandleRecord): void {
|
|
139
|
+
const data: PersistedHandleEntry = { version: 1, action: "created", record };
|
|
140
|
+
pi.appendEntry(ANTIGRAVITY_HANDLE_ENTRY_TYPE, data);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Persist a branch-aware retirement without exposing the raw ID to the model. */
|
|
144
|
+
export function appendHandleRetired(
|
|
145
|
+
pi: ExtensionAPI,
|
|
146
|
+
record: ConversationHandleRecord,
|
|
147
|
+
reason: string,
|
|
148
|
+
): void {
|
|
149
|
+
const data: PersistedHandleEntry = {
|
|
150
|
+
version: 1,
|
|
151
|
+
action: "retired",
|
|
152
|
+
handle: record.handle,
|
|
153
|
+
reason: boundedReason(reason),
|
|
154
|
+
};
|
|
155
|
+
pi.appendEntry(ANTIGRAVITY_HANDLE_ENTRY_TYPE, data);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function createOpaqueHandle(): string {
|
|
159
|
+
return `agy_${randomUUID().replaceAll("-", "")}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isHandleRecord(value: unknown): value is ConversationHandleRecord {
|
|
163
|
+
if (!isRecord(value)) return false;
|
|
164
|
+
return (
|
|
165
|
+
typeof value.handle === "string" &&
|
|
166
|
+
value.handle.length > 0 &&
|
|
167
|
+
typeof value.rawAntigravityId === "string" &&
|
|
168
|
+
value.rawAntigravityId.length > 0 &&
|
|
169
|
+
isCuratedModel(value.model) &&
|
|
170
|
+
typeof value.canonicalWorkingDirectory === "string" &&
|
|
171
|
+
typeof value.workspaceAccess === "boolean" &&
|
|
172
|
+
typeof value.cliVersion === "string" &&
|
|
173
|
+
(value.status === "active" || value.status === "retired")
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function boundedReason(value: string): string {
|
|
178
|
+
return value.replace(/\s+/g, " ").trim().slice(0, 200) || "follow-up failed";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
182
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
183
|
+
}
|
package/src/extension.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { AntigravityRuntime } from "./runtime.ts";
|
|
3
|
+
import { registerAntigravitySettings } from "./settings.ts";
|
|
4
|
+
|
|
5
|
+
/** Register the opt-in Antigravity Run extension. */
|
|
6
|
+
export default function antigravityExtension(pi: ExtensionAPI): void {
|
|
7
|
+
const runtime = new AntigravityRuntime({ pi });
|
|
8
|
+
registerAntigravitySettings(pi, runtime);
|
|
9
|
+
|
|
10
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
11
|
+
runtime.rebuildHandles(ctx.sessionManager.getBranch());
|
|
12
|
+
await runtime.refresh(ctx.cwd, ctx);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
16
|
+
runtime.rebuildHandles(ctx.sessionManager.getBranch());
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
pi.on("session_shutdown", async () => {
|
|
20
|
+
await runtime.shutdown();
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
chmod,
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
open,
|
|
8
|
+
readFile,
|
|
9
|
+
readlink,
|
|
10
|
+
rename,
|
|
11
|
+
rm,
|
|
12
|
+
stat,
|
|
13
|
+
symlink,
|
|
14
|
+
} from "node:fs/promises";
|
|
15
|
+
import { basename, dirname, join } from "node:path";
|
|
16
|
+
import { promisify } from "node:util";
|
|
17
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
|
|
19
|
+
const execFileAsync = promisify(execFile);
|
|
20
|
+
const ANTIGRAVITY_BASE_DIR = join("supi", "antigravity");
|
|
21
|
+
const SETTINGS_RELATIVE_PATH = join(".gemini", "antigravity-cli", "settings.json");
|
|
22
|
+
// macOS uses this conventional filename as the default keychain for an alternate HOME.
|
|
23
|
+
const KEYCHAIN_RELATIVE_PATH = join("Library", "Keychains", "login.keychain-db");
|
|
24
|
+
// A keychain with the special login name gets a user-password-managed password. Use a
|
|
25
|
+
// package-owned keychain with an empty password, then expose it through the conventional name.
|
|
26
|
+
const PRIVATE_KEYCHAIN_FILE_NAME = "antigravity.keychain-db";
|
|
27
|
+
const EMPTY_TEMPLATE_NAME = "empty-git-template";
|
|
28
|
+
const SECURITY_COMMAND = "/usr/bin/security";
|
|
29
|
+
|
|
30
|
+
/** The package-owned paths used by Antigravity. */
|
|
31
|
+
export interface IsolatedAntigravityPaths {
|
|
32
|
+
agentDir: string;
|
|
33
|
+
baseDir: string;
|
|
34
|
+
homeDir: string;
|
|
35
|
+
consultationWorkspace: string;
|
|
36
|
+
settingsPath: string;
|
|
37
|
+
gitTemplateDir: string;
|
|
38
|
+
/** The conventional macOS keychain path backed by the private isolated keychain. */
|
|
39
|
+
keychainPath: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Return package-owned Antigravity paths for one Pi agent directory. */
|
|
43
|
+
export function getIsolatedAntigravityPaths(
|
|
44
|
+
agentDir = resolveAgentDirectory(),
|
|
45
|
+
): IsolatedAntigravityPaths {
|
|
46
|
+
const baseDir = join(agentDir, ANTIGRAVITY_BASE_DIR);
|
|
47
|
+
const homeDir = join(baseDir, "home");
|
|
48
|
+
const consultationWorkspace = join(baseDir, "consultation-workspace");
|
|
49
|
+
return {
|
|
50
|
+
agentDir,
|
|
51
|
+
baseDir,
|
|
52
|
+
homeDir,
|
|
53
|
+
consultationWorkspace,
|
|
54
|
+
settingsPath: join(homeDir, SETTINGS_RELATIVE_PATH),
|
|
55
|
+
gitTemplateDir: join(baseDir, EMPTY_TEMPLATE_NAME),
|
|
56
|
+
keychainPath: join(homeDir, KEYCHAIN_RELATIVE_PATH),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Return the agent state directory used by the current Pi installation. */
|
|
61
|
+
export function resolveAgentDirectory(): string {
|
|
62
|
+
return process.env.PI_CODING_AGENT_DIR || getAgentDir();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The exact Inspection Permission Set enforced in the Isolated Antigravity Home. */
|
|
66
|
+
export const INSPECTION_PERMISSION_SET = Object.freeze({
|
|
67
|
+
allowNonWorkspaceAccess: false,
|
|
68
|
+
permissions: Object.freeze({
|
|
69
|
+
allow: Object.freeze(["read_url(*)"]),
|
|
70
|
+
deny: Object.freeze([
|
|
71
|
+
"write_file(*)",
|
|
72
|
+
"command(*)",
|
|
73
|
+
"unsandboxed(*)",
|
|
74
|
+
"mcp(*)",
|
|
75
|
+
"execute_url(*)",
|
|
76
|
+
]),
|
|
77
|
+
}),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const consultationInitializations = new Map<string, Promise<void>>();
|
|
81
|
+
const keychainInitializations = new Map<string, Promise<void>>();
|
|
82
|
+
|
|
83
|
+
/** Options for preparing an isolated profile. */
|
|
84
|
+
export interface IsolatedAntigravityPreparationOptions {
|
|
85
|
+
/** Override the host platform for tests. */
|
|
86
|
+
platform?: NodeJS.Platform;
|
|
87
|
+
/** Run one macOS Security command. */
|
|
88
|
+
runSecurityCommand?: (args: readonly string[], homeDir: string) => Promise<void>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Create the stable empty Consultation Workspace without user Git templates. */
|
|
92
|
+
export async function initializeConsultationWorkspace(
|
|
93
|
+
paths: IsolatedAntigravityPaths,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const existing = consultationInitializations.get(paths.consultationWorkspace);
|
|
96
|
+
if (existing) {
|
|
97
|
+
await existing;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const initialization = initializeConsultationWorkspaceOnce(paths).finally(() => {
|
|
101
|
+
consultationInitializations.delete(paths.consultationWorkspace);
|
|
102
|
+
});
|
|
103
|
+
consultationInitializations.set(paths.consultationWorkspace, initialization);
|
|
104
|
+
await initialization;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function initializeConsultationWorkspaceOnce(paths: IsolatedAntigravityPaths): Promise<void> {
|
|
108
|
+
await mkdir(paths.baseDir, { recursive: true, mode: 0o700 });
|
|
109
|
+
await mkdir(paths.gitTemplateDir, { recursive: true, mode: 0o700 });
|
|
110
|
+
await mkdir(paths.consultationWorkspace, { recursive: true, mode: 0o700 });
|
|
111
|
+
await chmod(paths.baseDir, 0o700);
|
|
112
|
+
await chmod(paths.gitTemplateDir, 0o700);
|
|
113
|
+
await chmod(paths.consultationWorkspace, 0o700);
|
|
114
|
+
|
|
115
|
+
if (await isGitRepository(paths.consultationWorkspace)) return;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
await execFileAsync(
|
|
119
|
+
"git",
|
|
120
|
+
["init", "--quiet", `--template=${paths.gitTemplateDir}`, paths.consultationWorkspace],
|
|
121
|
+
{
|
|
122
|
+
env: {
|
|
123
|
+
...process.env,
|
|
124
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
125
|
+
},
|
|
126
|
+
maxBuffer: 64 * 1024,
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
throw new Error("Could not initialize the Antigravity Consultation Workspace.", {
|
|
131
|
+
cause: error,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function isGitRepository(directory: string): Promise<boolean> {
|
|
137
|
+
try {
|
|
138
|
+
await stat(join(directory, ".git"));
|
|
139
|
+
return true;
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Prepare both package-owned directories and atomically enforce permissions. */
|
|
146
|
+
export async function prepareIsolatedAntigravityHome(
|
|
147
|
+
paths = getIsolatedAntigravityPaths(),
|
|
148
|
+
options: IsolatedAntigravityPreparationOptions = {},
|
|
149
|
+
): Promise<IsolatedAntigravityPaths> {
|
|
150
|
+
const keychainDirectory = dirname(paths.keychainPath);
|
|
151
|
+
const libraryDirectory = dirname(keychainDirectory);
|
|
152
|
+
const isMacOS = (options.platform ?? process.platform) === "darwin";
|
|
153
|
+
await mkdir(paths.homeDir, { recursive: true, mode: 0o700 });
|
|
154
|
+
await mkdir(dirname(paths.settingsPath), { recursive: true, mode: 0o700 });
|
|
155
|
+
if (isMacOS) await mkdir(keychainDirectory, { recursive: true, mode: 0o700 });
|
|
156
|
+
await chmod(paths.homeDir, 0o700);
|
|
157
|
+
await chmod(dirname(paths.settingsPath), 0o700);
|
|
158
|
+
if (isMacOS) {
|
|
159
|
+
await chmod(libraryDirectory, 0o700);
|
|
160
|
+
await chmod(keychainDirectory, 0o700);
|
|
161
|
+
}
|
|
162
|
+
await Promise.all([
|
|
163
|
+
initializeConsultationWorkspace(paths),
|
|
164
|
+
mergeInspectionSettings(paths.settingsPath),
|
|
165
|
+
prepareIsolatedMacOSKeychain(paths, options),
|
|
166
|
+
]);
|
|
167
|
+
return paths;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function prepareIsolatedMacOSKeychain(
|
|
171
|
+
paths: IsolatedAntigravityPaths,
|
|
172
|
+
options: IsolatedAntigravityPreparationOptions,
|
|
173
|
+
): Promise<void> {
|
|
174
|
+
if ((options.platform ?? process.platform) !== "darwin") return;
|
|
175
|
+
const existing = keychainInitializations.get(paths.keychainPath);
|
|
176
|
+
if (existing) {
|
|
177
|
+
await existing;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const runSecurityCommand = options.runSecurityCommand ?? runMacOSSecurityCommand;
|
|
181
|
+
const initialization = prepareIsolatedMacOSKeychainOnce(paths, runSecurityCommand).finally(() => {
|
|
182
|
+
keychainInitializations.delete(paths.keychainPath);
|
|
183
|
+
});
|
|
184
|
+
keychainInitializations.set(paths.keychainPath, initialization);
|
|
185
|
+
await initialization;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function prepareIsolatedMacOSKeychainOnce(
|
|
189
|
+
paths: IsolatedAntigravityPaths,
|
|
190
|
+
runSecurityCommand: (args: readonly string[], homeDir: string) => Promise<void>,
|
|
191
|
+
): Promise<void> {
|
|
192
|
+
const keychainDirectory = dirname(paths.keychainPath);
|
|
193
|
+
const privateKeychainPath = join(keychainDirectory, PRIVATE_KEYCHAIN_FILE_NAME);
|
|
194
|
+
await replaceLegacyKeychainEntry(paths.keychainPath, privateKeychainPath);
|
|
195
|
+
if (!(await pathExists(privateKeychainPath))) {
|
|
196
|
+
await runSecurityCommand(["create-keychain", "-p", "", privateKeychainPath], paths.homeDir);
|
|
197
|
+
}
|
|
198
|
+
if (!(await isSymlinkTo(paths.keychainPath, PRIVATE_KEYCHAIN_FILE_NAME))) {
|
|
199
|
+
await rm(paths.keychainPath, { force: true });
|
|
200
|
+
await symlink(PRIVATE_KEYCHAIN_FILE_NAME, paths.keychainPath);
|
|
201
|
+
}
|
|
202
|
+
// The private keychain has an empty password. Unlocking it with that known password is
|
|
203
|
+
// non-interactive and prevents a locked keychain from starting SecurityAgent.
|
|
204
|
+
await runSecurityCommand(["unlock-keychain", "-p", "", privateKeychainPath], paths.homeDir);
|
|
205
|
+
await chmod(privateKeychainPath, 0o600);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function replaceLegacyKeychainEntry(
|
|
209
|
+
keychainPath: string,
|
|
210
|
+
privateKeychainPath: string,
|
|
211
|
+
): Promise<void> {
|
|
212
|
+
const entry = await readFileEntry(keychainPath);
|
|
213
|
+
if (!entry) return;
|
|
214
|
+
if (entry.isSymbolicLink()) {
|
|
215
|
+
if (await isSymlinkTo(keychainPath, PRIVATE_KEYCHAIN_FILE_NAME)) return;
|
|
216
|
+
await rm(keychainPath, { force: true });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (keychainPath === privateKeychainPath) return;
|
|
220
|
+
await rm(keychainPath, { force: true });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function isSymlinkTo(filePath: string, target: string): Promise<boolean> {
|
|
224
|
+
const entry = await readFileEntry(filePath);
|
|
225
|
+
if (!entry?.isSymbolicLink()) return false;
|
|
226
|
+
try {
|
|
227
|
+
return (await readlink(filePath)) === target;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (isMissingFile(error)) return false;
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function readFileEntry(
|
|
235
|
+
filePath: string,
|
|
236
|
+
): Promise<Awaited<ReturnType<typeof lstat>> | undefined> {
|
|
237
|
+
try {
|
|
238
|
+
return await lstat(filePath);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (isMissingFile(error)) return undefined;
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function runMacOSSecurityCommand(args: readonly string[], homeDir: string): Promise<void> {
|
|
246
|
+
try {
|
|
247
|
+
await execFileAsync(SECURITY_COMMAND, [...args], {
|
|
248
|
+
env: { HOME: homeDir },
|
|
249
|
+
maxBuffer: 64 * 1024,
|
|
250
|
+
});
|
|
251
|
+
} catch (error) {
|
|
252
|
+
throw new Error("Could not prepare the isolated Antigravity macOS keychain.", {
|
|
253
|
+
cause: error,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function pathExists(filePath: string): Promise<boolean> {
|
|
259
|
+
try {
|
|
260
|
+
await stat(filePath);
|
|
261
|
+
return true;
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (isMissingFile(error)) return false;
|
|
264
|
+
throw error;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Merge and atomically write the Inspection Permission Set. */
|
|
269
|
+
export async function mergeInspectionSettings(settingsPath: string): Promise<void> {
|
|
270
|
+
const existing = await readSettingsObject(settingsPath);
|
|
271
|
+
const existingPermissions = isRecord(existing.permissions) ? existing.permissions : {};
|
|
272
|
+
const merged = {
|
|
273
|
+
...existing,
|
|
274
|
+
allowNonWorkspaceAccess: INSPECTION_PERMISSION_SET.allowNonWorkspaceAccess,
|
|
275
|
+
permissions: {
|
|
276
|
+
...existingPermissions,
|
|
277
|
+
allow: [...INSPECTION_PERMISSION_SET.permissions.allow],
|
|
278
|
+
deny: [...INSPECTION_PERMISSION_SET.permissions.deny],
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
await writeJsonAtomically(settingsPath, merged);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function readSettingsObject(settingsPath: string): Promise<Record<string, unknown>> {
|
|
285
|
+
let content: string;
|
|
286
|
+
try {
|
|
287
|
+
content = await readFile(settingsPath, "utf8");
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (isMissingFile(error)) return {};
|
|
290
|
+
throw new Error("Could not read the isolated Antigravity settings.", { cause: error });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
const parsed: unknown = JSON.parse(content);
|
|
295
|
+
if (!isRecord(parsed)) throw new Error("settings root is not an object");
|
|
296
|
+
return parsed;
|
|
297
|
+
} catch (error) {
|
|
298
|
+
throw new Error("The isolated Antigravity settings file is not valid JSON.", { cause: error });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Write JSON by replacing a same-directory temporary file. */
|
|
303
|
+
export async function writeJsonAtomically(
|
|
304
|
+
filePath: string,
|
|
305
|
+
value: Record<string, unknown>,
|
|
306
|
+
): Promise<void> {
|
|
307
|
+
const directory = dirname(filePath);
|
|
308
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
309
|
+
const temporaryPath = join(directory, `.${basename(filePath)}.${randomUUID()}.tmp`);
|
|
310
|
+
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
|
311
|
+
try {
|
|
312
|
+
handle = await open(temporaryPath, "wx", 0o600);
|
|
313
|
+
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
314
|
+
await handle.sync();
|
|
315
|
+
await handle.close();
|
|
316
|
+
handle = undefined;
|
|
317
|
+
await chmod(temporaryPath, 0o600);
|
|
318
|
+
await rename(temporaryPath, filePath);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (handle) await handle.close().catch(() => undefined);
|
|
321
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
322
|
+
throw new Error("Could not atomically write the isolated Antigravity settings.", {
|
|
323
|
+
cause: error,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Build the resolved command a user can run to authenticate Antigravity. */
|
|
329
|
+
export function formatAntigravityLoginCommand(paths: IsolatedAntigravityPaths): string {
|
|
330
|
+
return [
|
|
331
|
+
`cd ${shellQuote(paths.consultationWorkspace)} &&`,
|
|
332
|
+
`HOME=${shellQuote(paths.homeDir)} AGY_CLI_DISABLE_AUTO_UPDATE=true agy`,
|
|
333
|
+
].join("\n");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function shellQuote(value: string): string {
|
|
337
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("$", "\\$").replaceAll("`", "\\`")}"`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
341
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function isMissingFile(error: unknown): boolean {
|
|
345
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
346
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Environment variables that are safe and useful for a non-interactive agy process. */
|
|
2
|
+
export const APPROVED_ENVIRONMENT_KEYS = Object.freeze([
|
|
3
|
+
"PATH",
|
|
4
|
+
"TMPDIR",
|
|
5
|
+
"TMP",
|
|
6
|
+
"TEMP",
|
|
7
|
+
"LANG",
|
|
8
|
+
"LC_ALL",
|
|
9
|
+
"LC_CTYPE",
|
|
10
|
+
"LC_COLLATE",
|
|
11
|
+
"TERM",
|
|
12
|
+
"TERM_PROGRAM",
|
|
13
|
+
"COLORTERM",
|
|
14
|
+
"USER",
|
|
15
|
+
"LOGNAME",
|
|
16
|
+
"SHELL",
|
|
17
|
+
"NO_COLOR",
|
|
18
|
+
] as const);
|
|
19
|
+
|
|
20
|
+
/** Build the restricted environment used by every Antigravity process. */
|
|
21
|
+
export function buildAntigravityEnvironment(
|
|
22
|
+
homeDir: string,
|
|
23
|
+
source: NodeJS.ProcessEnv = process.env,
|
|
24
|
+
): NodeJS.ProcessEnv {
|
|
25
|
+
const environment: NodeJS.ProcessEnv = {};
|
|
26
|
+
for (const key of APPROVED_ENVIRONMENT_KEYS) {
|
|
27
|
+
const value = source[key];
|
|
28
|
+
if (value !== undefined) environment[key] = value;
|
|
29
|
+
}
|
|
30
|
+
environment.HOME = homeDir;
|
|
31
|
+
environment.AGY_CLI_DISABLE_AUTO_UPDATE = "true";
|
|
32
|
+
return environment;
|
|
33
|
+
}
|