@miraland-labs/conduit-bridge 0.16.30 → 0.16.37
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/dist/attempt-worktree.js +10 -8
- package/dist/brief.js +22 -63
- package/dist/checkout.js +1 -13
- package/dist/cli.js +9 -6
- package/dist/config.js +19 -5
- package/dist/driver.js +232 -14
- package/dist/drivers.js +3 -1
- package/dist/ensure-land-commit.js +2 -11
- package/dist/ensure-pull-request.js +4 -14
- package/dist/ensure-test-evidence.js +68 -9
- package/dist/execution-class.js +34 -9
- package/dist/execution-facts.js +1 -16
- package/dist/execution.js +96 -27
- package/dist/git.js +48 -0
- package/dist/investigation.js +5 -2
- package/dist/normative-refs.js +2 -13
- package/dist/transport-fault.js +12 -0
- package/package.json +1 -1
package/dist/attempt-worktree.js
CHANGED
|
@@ -7,6 +7,7 @@ import { constants } from "node:fs";
|
|
|
7
7
|
import { join, resolve } from "node:path";
|
|
8
8
|
import { execFile } from "node:child_process";
|
|
9
9
|
import { promisify } from "node:util";
|
|
10
|
+
import { headCommitOrNull } from "./git.js";
|
|
10
11
|
const execFileAsync = promisify(execFile);
|
|
11
12
|
export function attemptWorktreePath(sourceWorkspace, attemptId) {
|
|
12
13
|
return join(sourceWorkspace, ".conduit", "attempts", attemptId);
|
|
@@ -82,14 +83,18 @@ export async function ensureWorktreeGitIdentity(worktree, sourceWorkspace) {
|
|
|
82
83
|
await git("config", key, inherited || fallback);
|
|
83
84
|
}
|
|
84
85
|
}
|
|
86
|
+
/** Removes the worktree; answers whether one was there to remove. */
|
|
85
87
|
export async function removeAttemptWorktree(sourceWorkspace, worktreePath) {
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
+
// The control plane names every settled repair on every assignment poll, because it holds no
|
|
89
|
+
// record of what this computer still has on disk. That is a statement of fact to reconcile
|
|
90
|
+
// against, not a queue to drain, so the caller asks whether anything actually went -- a release
|
|
91
|
+
// nobody performed is not an event, and logging one made the same eleven paths scroll past every
|
|
92
|
+
// fifteen seconds. It also keeps Git off a path that is already gone.
|
|
88
93
|
try {
|
|
89
94
|
await access(worktreePath, constants.F_OK);
|
|
90
95
|
}
|
|
91
96
|
catch {
|
|
92
|
-
return;
|
|
97
|
+
return false;
|
|
93
98
|
}
|
|
94
99
|
try {
|
|
95
100
|
await execFileAsync("git", ["-C", sourceWorkspace, "worktree", "remove", "--force", worktreePath], { timeout: 60_000, maxBuffer: 2_000_000 });
|
|
@@ -101,6 +106,7 @@ export async function removeAttemptWorktree(sourceWorkspace, worktreePath) {
|
|
|
101
106
|
maxBuffer: 1_000_000,
|
|
102
107
|
}).catch(() => undefined);
|
|
103
108
|
}
|
|
109
|
+
return true;
|
|
104
110
|
}
|
|
105
111
|
/**
|
|
106
112
|
* F-07 safe resume: prove the interrupted attempt still owns this worktree path.
|
|
@@ -114,11 +120,7 @@ export async function proveResumeWorktree(sourceWorkspace, attemptId, worktreePa
|
|
|
114
120
|
return false;
|
|
115
121
|
try {
|
|
116
122
|
await access(worktreePath, constants.F_OK);
|
|
117
|
-
|
|
118
|
-
timeout: 15_000,
|
|
119
|
-
maxBuffer: 1_000_000,
|
|
120
|
-
});
|
|
121
|
-
return /^[0-9a-f]{40,64}$/i.test(stdout.trim());
|
|
123
|
+
return (await headCommitOrNull(worktreePath)) !== null;
|
|
122
124
|
}
|
|
123
125
|
catch {
|
|
124
126
|
return false;
|
package/dist/brief.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { readdir, readFile
|
|
2
|
-
import { join
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
3
|
import { execFile } from "node:child_process";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { isBoundedVerificationCommand } from "./execution-class.js";
|
|
6
|
+
import { gitOriginUrl, headCommitOrNull } from "./git.js";
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
7
8
|
const MANIFESTS = [
|
|
8
9
|
"package.json", "wrangler.jsonc", "wrangler.toml", "tsconfig.json",
|
|
@@ -20,14 +21,22 @@ export async function buildWorkspaceBrief(workspace) {
|
|
|
20
21
|
.map((entry) => entry.name).sort().slice(0, 30);
|
|
21
22
|
const files = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name));
|
|
22
23
|
const manifests = MANIFESTS.filter((name) => files.has(name));
|
|
24
|
+
const declared = await readDeclaredVerification(workspace);
|
|
23
25
|
return {
|
|
24
|
-
repository: await
|
|
25
|
-
base_commit: await
|
|
26
|
+
repository: await gitOriginUrl(workspace),
|
|
27
|
+
base_commit: await headCommitOrNull(workspace),
|
|
26
28
|
modules,
|
|
27
29
|
manifests,
|
|
28
30
|
verification: await discoverVerificationCommands(workspace, files),
|
|
31
|
+
declared_verification: declared,
|
|
29
32
|
};
|
|
30
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Mirror of `src/conductor/repository.ts`. The server fingerprints the initiative URL and the Bridge
|
|
36
|
+
* fingerprints the workspace remote; dispatch requires the two to be equal, so a divergence stops
|
|
37
|
+
* matching silently. The Bridge is a published package and cannot import server code, so
|
|
38
|
+
* `test/matcher.test.ts` ("repository fingerprint agreement") holds the two spellings together.
|
|
39
|
+
*/
|
|
31
40
|
export function normalizeRepositoryUrl(url) {
|
|
32
41
|
let value = url.trim().replace(/\.git$/, "").replace(/\/+$/, "").toLowerCase();
|
|
33
42
|
const ssh = value.match(/^git@([^:]+):(.+)$/);
|
|
@@ -143,61 +152,6 @@ export async function resolveAttemptStartCommit(workspace, requestedBase, claime
|
|
|
143
152
|
return base;
|
|
144
153
|
return null;
|
|
145
154
|
}
|
|
146
|
-
async function gitRemoteUrl(workspace) {
|
|
147
|
-
try {
|
|
148
|
-
const { common } = await gitDirectories(workspace);
|
|
149
|
-
const config = await readFile(join(common, "config"), "utf8");
|
|
150
|
-
const remote = config.match(/\[remote "origin"\][^[]*?url\s*=\s*(\S+)/);
|
|
151
|
-
return remote?.[1] ?? null;
|
|
152
|
-
}
|
|
153
|
-
catch {
|
|
154
|
-
return null;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
async function gitHeadCommit(workspace) {
|
|
158
|
-
try {
|
|
159
|
-
const { worktree, common } = await gitDirectories(workspace);
|
|
160
|
-
let head = String(await readFile(join(worktree, "HEAD"), "utf8")).trim();
|
|
161
|
-
// Follow one level of symbolic refs (HEAD → refs/heads/main, or a nested ref file).
|
|
162
|
-
for (let hop = 0; hop < 3; hop++) {
|
|
163
|
-
if (/^[0-9a-f]{40,64}$/i.test(head))
|
|
164
|
-
return head.toLowerCase();
|
|
165
|
-
const ref = head.match(/^ref:\s*(\S+)$/)?.[1];
|
|
166
|
-
if (!ref)
|
|
167
|
-
return null;
|
|
168
|
-
try {
|
|
169
|
-
head = String(await readFile(join(common, ref), "utf8")).trim();
|
|
170
|
-
}
|
|
171
|
-
catch {
|
|
172
|
-
const packed = String(await readFile(join(common, "packed-refs"), "utf8"));
|
|
173
|
-
const line = packed.split("\n").find((entry) => {
|
|
174
|
-
const trimmed = entry.trim();
|
|
175
|
-
return trimmed.length > 0 && !trimmed.startsWith("#") && !trimmed.startsWith("^") && trimmed.endsWith(` ${ref}`);
|
|
176
|
-
});
|
|
177
|
-
const sha = line?.split(/\s+/)[0]?.trim() ?? null;
|
|
178
|
-
return sha && /^[0-9a-f]{40,64}$/i.test(sha) ? sha.toLowerCase() : null;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
return null;
|
|
182
|
-
}
|
|
183
|
-
catch {
|
|
184
|
-
return null;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
async function gitDirectories(workspace) {
|
|
188
|
-
const dotGit = join(workspace, ".git");
|
|
189
|
-
const info = await stat(dotGit);
|
|
190
|
-
const pointer = info.isDirectory() ? null : (await readFile(dotGit, "utf8")).trim().match(/^gitdir:\s*(.+)$/)?.[1];
|
|
191
|
-
if (!info.isDirectory() && !pointer)
|
|
192
|
-
throw new Error("Invalid Git worktree metadata");
|
|
193
|
-
const worktree = info.isDirectory() ? dotGit : resolve(workspace, pointer);
|
|
194
|
-
try {
|
|
195
|
-
return { worktree, common: resolve(worktree, (await readFile(join(worktree, "commondir"), "utf8")).trim()) };
|
|
196
|
-
}
|
|
197
|
-
catch {
|
|
198
|
-
return { worktree, common: worktree };
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
155
|
/** Exported for tests — discovers bounded verification commands from workspace manifests. */
|
|
202
156
|
/**
|
|
203
157
|
* Commands a project declares for itself, in `.conduit/verification` — one per line, `#` comments
|
|
@@ -227,12 +181,17 @@ export function parseDeclaredVerification(text) {
|
|
|
227
181
|
}
|
|
228
182
|
return declared;
|
|
229
183
|
}
|
|
230
|
-
export async function
|
|
231
|
-
const commands = [];
|
|
184
|
+
export async function readDeclaredVerification(workspace) {
|
|
232
185
|
try {
|
|
233
|
-
|
|
186
|
+
return parseDeclaredVerification(await readFile(join(workspace, ".conduit", "verification"), "utf8"));
|
|
234
187
|
}
|
|
235
|
-
catch {
|
|
188
|
+
catch {
|
|
189
|
+
return []; // no declaration, or unreadable: discovery still applies
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
export async function discoverVerificationCommands(workspace, files) {
|
|
193
|
+
// Declared first: the order is a preference order for every reader that takes one command.
|
|
194
|
+
const commands = await readDeclaredVerification(workspace);
|
|
236
195
|
if (files.has("package.json")) {
|
|
237
196
|
try {
|
|
238
197
|
const manifest = JSON.parse(await readFile(join(workspace, "package.json"), "utf8"));
|
package/dist/checkout.js
CHANGED
|
@@ -11,6 +11,7 @@ import { dirname } from "node:path";
|
|
|
11
11
|
import { execFile } from "node:child_process";
|
|
12
12
|
import { promisify } from "node:util";
|
|
13
13
|
import { normalizeRepositoryUrl } from "./brief.js";
|
|
14
|
+
import { gitOriginUrl } from "./git.js";
|
|
14
15
|
const execFileAsync = promisify(execFile);
|
|
15
16
|
async function pathExists(path, accessFn) {
|
|
16
17
|
try {
|
|
@@ -21,19 +22,6 @@ async function pathExists(path, accessFn) {
|
|
|
21
22
|
return false;
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
|
-
async function gitOriginUrl(workspace, exec) {
|
|
25
|
-
try {
|
|
26
|
-
const { stdout } = await exec("git", ["-C", workspace, "config", "--get", "remote.origin.url"], {
|
|
27
|
-
timeout: 15_000,
|
|
28
|
-
maxBuffer: 1_000_000,
|
|
29
|
-
});
|
|
30
|
-
const value = stdout.trim();
|
|
31
|
-
return value.length > 0 ? value : null;
|
|
32
|
-
}
|
|
33
|
-
catch {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
25
|
/** A control-plane repository fingerprint needs a transport before Git can clone it. */
|
|
38
26
|
function cloneRepositoryUrls(repositoryUrl) {
|
|
39
27
|
const value = repositoryUrl.trim();
|
package/dist/cli.js
CHANGED
|
@@ -612,19 +612,22 @@ async function runner() {
|
|
|
612
612
|
progressed = await executeNextInvestigation(client, config, workspace, brief, timeoutMs) || progressed;
|
|
613
613
|
}
|
|
614
614
|
if (workspace && onlineDriverIds(config).length) {
|
|
615
|
+
// Read the workspace again, then publish it. The last brief stands if the read fails.
|
|
616
|
+
const publishWorkspaceState = async (options = {}) => {
|
|
617
|
+
const currentBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
|
|
618
|
+
const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined }, options);
|
|
619
|
+
const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
|
|
620
|
+
return { preflight, hb };
|
|
621
|
+
};
|
|
615
622
|
progressed = await pumpExecutionSlots(client, config, workspace, brief, timeoutMs, {
|
|
616
623
|
heartbeat: async () => {
|
|
617
|
-
|
|
618
|
-
const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined });
|
|
619
|
-
await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
|
|
624
|
+
await publishWorkspaceState();
|
|
620
625
|
},
|
|
621
626
|
beforeClaim: async (driverId) => {
|
|
622
627
|
// Never let the five-minute heartbeat cache span a CLI upgrade into a certified claim.
|
|
623
628
|
// Publish the fresh probe first; the Control Plane then remains the authority for the
|
|
624
629
|
// exact version frozen onto the claim response and event.
|
|
625
|
-
const
|
|
626
|
-
const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined }, { force: true });
|
|
627
|
-
const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
|
|
630
|
+
const { preflight, hb } = await publishWorkspaceState({ force: true });
|
|
628
631
|
if (hb.staleBridge)
|
|
629
632
|
return false;
|
|
630
633
|
const lane = preflight.drivers.find((candidate) => candidate.id === driverId);
|
package/dist/config.js
CHANGED
|
@@ -262,6 +262,23 @@ export async function saveConfigPrefs(config) {
|
|
|
262
262
|
* nothing without one.
|
|
263
263
|
*/
|
|
264
264
|
export async function saveDriverQuota(driverId, quota) {
|
|
265
|
+
await updateDriverLane(driverId, (lane) => {
|
|
266
|
+
if (quota)
|
|
267
|
+
lane.quota = quota;
|
|
268
|
+
else
|
|
269
|
+
delete lane.quota;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
/** Persist one lane's execution outcome without overwriting operator preferences or quota state. */
|
|
273
|
+
export async function saveDriverOutcome(driverId, outcome) {
|
|
274
|
+
await updateDriverLane(driverId, (lane) => {
|
|
275
|
+
if (outcome)
|
|
276
|
+
lane.outcome = outcome;
|
|
277
|
+
else
|
|
278
|
+
delete lane.outcome;
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
async function updateDriverLane(driverId, update) {
|
|
265
282
|
const run = async () => {
|
|
266
283
|
await withConfigLock(async () => {
|
|
267
284
|
let prefs;
|
|
@@ -269,15 +286,12 @@ export async function saveDriverQuota(driverId, quota) {
|
|
|
269
286
|
prefs = JSON.parse(await readFile(path, "utf8"));
|
|
270
287
|
}
|
|
271
288
|
catch {
|
|
272
|
-
return; // No prefs on disk yet: nothing to attach
|
|
289
|
+
return; // No prefs on disk yet: nothing to attach lane state to.
|
|
273
290
|
}
|
|
274
291
|
const lane = prefs.drivers?.[driverId];
|
|
275
292
|
if (!lane || typeof lane !== "object")
|
|
276
293
|
return;
|
|
277
|
-
|
|
278
|
-
lane.quota = quota;
|
|
279
|
-
else
|
|
280
|
-
delete lane.quota;
|
|
294
|
+
update(lane);
|
|
281
295
|
await writeJsonAtomic(path, { ...prefs, activeAttempts: {}, sessions: undefined });
|
|
282
296
|
});
|
|
283
297
|
};
|
package/dist/driver.js
CHANGED
|
@@ -3,9 +3,9 @@ import { boundedTail } from "./ensure-test-evidence.js";
|
|
|
3
3
|
import { resolveAgentTimeout } from "./execution-budget.js";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { mkdir, readFile, rm, rmdir, writeFile } from "node:fs/promises";
|
|
6
|
-
import { join } from "node:path";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
7
|
import { z } from "zod";
|
|
8
|
-
import { deniedCommands, executionClassPromptRules, parsePiJsonl, projectClaude, projectCoarseMode, projectCodex, projectCursor, projectGrok, projectKiroTools, projectPi, requireStampedExecutionClass, } from "./execution-class.js";
|
|
8
|
+
import { allowsExternalFetch, deniedCommands, executionClassPromptRules, isRunnableVerificationCommand, parsePiJsonl, projectClaude, projectCoarseMode, projectCodex, projectCursor, projectGrok, projectKiroTools, projectPi, requireStampedExecutionClass, } from "./execution-class.js";
|
|
9
9
|
/**
|
|
10
10
|
* The bound for this turn.
|
|
11
11
|
*
|
|
@@ -995,10 +995,16 @@ export const kiroDriver = {
|
|
|
995
995
|
},
|
|
996
996
|
};
|
|
997
997
|
/**
|
|
998
|
-
* Antigravity's `agy` CLI (verified against agy 1.1.
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
998
|
+
* Antigravity's `agy` CLI (verified against agy 1.1.24): non-interactive runs use
|
|
999
|
+
* `--mode plan` (read-only) or `--mode accept-edits`. `-p`/`--print` is a Go-style
|
|
1000
|
+
* flag that consumes its value from the next argument, so the prompt must be the
|
|
1001
|
+
* last argument attached to `-p` — putting other flags after it (as earlier builds
|
|
1002
|
+
* of this driver did) makes agy swallow one of them as the prompt and silently
|
|
1003
|
+
* drop the real prompt. `--print-timeout` is set from the turn budget so agy's
|
|
1004
|
+
* 5-minute default cannot cut a longer turn short. Its enforcement is mode-level,
|
|
1005
|
+
* not per-tool — coarser than Claude, the Cursor tier. Write mode adds `--sandbox`
|
|
1006
|
+
* for terminal restrictions. `--output-format json` returns one envelope, parsed by
|
|
1007
|
+
* parseAntigravityOutput.
|
|
1002
1008
|
*/
|
|
1003
1009
|
export function antigravityModeForGrants(grants, executionClass) {
|
|
1004
1010
|
if (!hasMappedRepoAccess(grants))
|
|
@@ -1011,15 +1017,206 @@ export function antigravityModeForGrants(grants, executionClass) {
|
|
|
1011
1017
|
return "plan";
|
|
1012
1018
|
return null;
|
|
1013
1019
|
}
|
|
1014
|
-
|
|
1015
|
-
|
|
1020
|
+
/** agy's `--print-timeout` takes a Go duration; ceil to whole minutes (agy's own default floor is 5m). */
|
|
1021
|
+
export function antigravityPrintTimeout(timeoutMs) {
|
|
1022
|
+
return `${Math.max(5, Math.ceil(timeoutMs / 60_000))}m0s`;
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Class + grants → agy permission allow-rules (probed against agy 1.1.24).
|
|
1026
|
+
*
|
|
1027
|
+
* Headless `agy -p` soft-denies every tool it was not granted: the run returns an empty response
|
|
1028
|
+
* with a stderr notice and nothing is written. Only wildcard rules ever matched in the probes —
|
|
1029
|
+
* bounded ones (`command(git add)`, `command(git *)`, and in the field `unsandboxed(git push)`)
|
|
1030
|
+
* were auto-denied, because the agent picks its own shell spellings and stops at the first denial.
|
|
1031
|
+
* So the enforceable unit is the coarse rule plus `--sandbox`, the same boundary the Codex driver
|
|
1032
|
+
* leans on with `--sandbox workspace-write`. The grammar agy accepts is `command(<cmd>)`,
|
|
1033
|
+
* `unsandboxed(<cmd>)`, `write_file(<glob>)`, `read_file(<glob>)`, `read_url(<target>)`.
|
|
1034
|
+
*
|
|
1035
|
+
* `deniedCommands` cannot be expressed here: agy has no deny grammar. Antigravity is the coarse
|
|
1036
|
+
* tier the product spec already assigns it — enforcement is the sandbox, the prompt rule that names
|
|
1037
|
+
* the hard-denied commands (executionClassPromptRules), and the delivery gate.
|
|
1038
|
+
*/
|
|
1039
|
+
export function antigravityPermissionProjection(executionClass, grants, options = {}) {
|
|
1040
|
+
const allow = ["read_file(*)"];
|
|
1041
|
+
// Live HTTP is the assignment's capability, read the one way every driver reads it. agy aborts the
|
|
1042
|
+
// whole turn on a denial rather than skipping the tool, so an assignment that may fetch must carry
|
|
1043
|
+
// the rule; one that may not never gets it.
|
|
1044
|
+
if (allowsExternalFetch(executionClass, options.capabilities))
|
|
1045
|
+
allow.push("read_url(*)");
|
|
1046
|
+
const runnable = (options.verificationCommands ?? []).some(isRunnableVerificationCommand);
|
|
1047
|
+
if (executionClass === "observe" || executionClass === "observe_network" || options.diagnosis) {
|
|
1048
|
+
// Diagnosis has to run its bounded checks; a plain observe class gets no shell at all.
|
|
1049
|
+
if (runnable && (options.diagnosis || grants.includes("test_run")))
|
|
1050
|
+
allow.push("command(*)");
|
|
1051
|
+
return allow;
|
|
1052
|
+
}
|
|
1053
|
+
if (executionClass === "verify") {
|
|
1054
|
+
allow.push("command(*)");
|
|
1055
|
+
return allow;
|
|
1056
|
+
}
|
|
1057
|
+
allow.push("write_file(*)", "command(*)");
|
|
1058
|
+
// Land work needs the sandbox escape hatch, and only a wildcard reaches it: a real mutate_repo
|
|
1059
|
+
// assignment carrying `pr_create` was auto-denied on exact `unsandboxed(git push)` with
|
|
1060
|
+
// "a tool required the \"unsandboxed\" permission that headless mode cannot prompt for". So for
|
|
1061
|
+
// the land class the sandbox boundary no longer covers the escape hatch, and enforcement there is
|
|
1062
|
+
// the prompt's hard-deny list, the delivery gate (one PR, a scoped diff, Bridge-witnessed
|
|
1063
|
+
// commands) and the project's grants — the coarse tier. Without `pr_create` no `unsandboxed(...)`
|
|
1064
|
+
// rule is emitted at all, and `--sandbox` stays on either way.
|
|
1065
|
+
if (grants.includes("pr_create"))
|
|
1066
|
+
allow.push("unsandboxed(*)");
|
|
1067
|
+
return allow;
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* The one driver note agy needs beside the class rules. It aborts the whole turn on any denied tool
|
|
1071
|
+
* — Claude Code merely skips one — and a run that had already committed died opening its own PR URL
|
|
1072
|
+
* with `read_url`, which no assignment without external_network may hold.
|
|
1073
|
+
*/
|
|
1074
|
+
export const antigravityPromptRule = "Antigravity: do not use read_url, browser, or web tools unless this assignment lists external_network; do not open the pull request URL after creating it — report the URL from the `gh pr create` output. A denied tool ends the whole run, so use only the tools this assignment allows.";
|
|
1075
|
+
export function antigravityRunArgs(input, mode, timeoutMs) {
|
|
1076
|
+
const args = ["--mode", mode];
|
|
1016
1077
|
if (mode === "accept-edits")
|
|
1017
1078
|
args.push("--sandbox");
|
|
1018
|
-
args.push(
|
|
1079
|
+
args.push("--output-format", "json");
|
|
1080
|
+
args.push("--print-timeout", antigravityPrintTimeout(timeoutMs));
|
|
1081
|
+
// agy's CLI default model is one account lane among several; `--model` selects the lane this
|
|
1082
|
+
// machine's tier mapping names, which is how a spent Gemini window stops the whole driver.
|
|
1083
|
+
if (input.model)
|
|
1084
|
+
args.push("--model", input.model);
|
|
1085
|
+
if (input.resumeSessionId)
|
|
1086
|
+
args.push("--conversation", input.resumeSessionId);
|
|
1087
|
+
args.push("-p", input.prompt);
|
|
1019
1088
|
return args;
|
|
1020
1089
|
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Parse `agy models`: one model per line, the id first and the display name after it
|
|
1092
|
+
* ("claude-sonnet-4-6 Claude Sonnet 4.6 (Thinking)"). Two or more spaces separate the two
|
|
1093
|
+
* columns, which is what tells a model line from a banner line ("Available models:").
|
|
1094
|
+
*/
|
|
1095
|
+
export function parseAntigravityModelList(stdout) {
|
|
1096
|
+
const models = new Set();
|
|
1097
|
+
for (const line of stdout.split("\n")) {
|
|
1098
|
+
const match = /^\s*([A-Za-z0-9][A-Za-z0-9._/-]*)\s{2,}\S/.exec(line);
|
|
1099
|
+
if (match)
|
|
1100
|
+
models.add(match[1]);
|
|
1101
|
+
}
|
|
1102
|
+
return models;
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* `agy --output-format json` prints one envelope:
|
|
1106
|
+
* `{"conversation_id","status","response","duration_seconds","num_turns","usage"}`.
|
|
1107
|
+
* Tolerant like the Cursor parser: a build that answers in plain text still delivers its report.
|
|
1108
|
+
*
|
|
1109
|
+
* A refused run keeps the same envelope with `status: "ERROR"`, an empty `response`, and the reason
|
|
1110
|
+
* in `error` ("Individual quota reached ... Resets in 165h48m43s."), and prints **nothing** on
|
|
1111
|
+
* stderr. Reading only `response` reported that run as "agy exited with code 1", which says nothing
|
|
1112
|
+
* the owner can act on — so `status` and `error` are parsed here and named by the driver.
|
|
1113
|
+
*/
|
|
1114
|
+
export function parseAntigravityOutput(stdout) {
|
|
1115
|
+
try {
|
|
1116
|
+
const parsed = JSON.parse(stdout.trim());
|
|
1117
|
+
return {
|
|
1118
|
+
resultText: typeof parsed.response === "string" ? parsed.response : null,
|
|
1119
|
+
sessionId: typeof parsed.conversation_id === "string" ? parsed.conversation_id : null,
|
|
1120
|
+
status: typeof parsed.status === "string" ? parsed.status : null,
|
|
1121
|
+
error: typeof parsed.error === "string" && parsed.error.trim() ? parsed.error : null,
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
catch {
|
|
1125
|
+
return { resultText: stdout || null, sessionId: null, status: null, error: null };
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* The stderr line agy prints when a permission it was not granted ended the turn. An empty response
|
|
1130
|
+
* beside such a line is a denial, not an agent with nothing to say, and the control plane has to see
|
|
1131
|
+
* the difference.
|
|
1132
|
+
*/
|
|
1133
|
+
export function antigravityDenialNotice(stderr) {
|
|
1134
|
+
for (const line of stderr.split("\n")) {
|
|
1135
|
+
const trimmed = line.trim();
|
|
1136
|
+
if (trimmed && /no output produced|permission/i.test(trimmed))
|
|
1137
|
+
return trimmed;
|
|
1138
|
+
}
|
|
1139
|
+
return null;
|
|
1140
|
+
}
|
|
1141
|
+
/** agy reads one global settings file, so concurrent runs are serialized rather than racing on it. */
|
|
1142
|
+
let antigravitySettingsLock = Promise.resolve();
|
|
1143
|
+
/**
|
|
1144
|
+
* Write the run's permission contract to agy's global settings file, restoring it byte-exactly after.
|
|
1145
|
+
*
|
|
1146
|
+
* Probed against agy 1.1.24: this is the only placement a headless run honours — a workspace-local
|
|
1147
|
+
* `.antigravity/settings.json` and `~/.gemini/config/projects/<uuid>.json` `permissionGrants` were
|
|
1148
|
+
* both ignored, and without `trustedWorkspaces` naming the workspace every tool is soft-denied. The
|
|
1149
|
+
* file belongs to the machine owner and is global, so the original bytes are held in memory and
|
|
1150
|
+
* beside it as `settings.json.conduit-backup` for the length of the run, unparsable JSON fails the
|
|
1151
|
+
* run instead of being overwritten, and every other key is preserved.
|
|
1152
|
+
*/
|
|
1153
|
+
async function withAntigravityPermissions(workspace, allow, run) {
|
|
1154
|
+
const previousRun = antigravitySettingsLock;
|
|
1155
|
+
let release = () => undefined;
|
|
1156
|
+
antigravitySettingsLock = new Promise((settle) => { release = settle; });
|
|
1157
|
+
await previousRun.catch(() => undefined);
|
|
1158
|
+
try {
|
|
1159
|
+
const home = process.env.HOME;
|
|
1160
|
+
if (!home)
|
|
1161
|
+
throw new Error("antigravity needs HOME to write ~/.gemini/antigravity-cli/settings.json");
|
|
1162
|
+
const settingsDir = join(home, ".gemini", "antigravity-cli");
|
|
1163
|
+
const settingsPath = join(settingsDir, "settings.json");
|
|
1164
|
+
const backupPath = `${settingsPath}.conduit-backup`;
|
|
1165
|
+
const settingsDirExisted = existsSync(settingsDir);
|
|
1166
|
+
let previous = null;
|
|
1167
|
+
try {
|
|
1168
|
+
previous = await readFile(settingsPath, "utf8");
|
|
1169
|
+
}
|
|
1170
|
+
catch {
|
|
1171
|
+
previous = null;
|
|
1172
|
+
}
|
|
1173
|
+
let settings = {};
|
|
1174
|
+
if (previous !== null) {
|
|
1175
|
+
try {
|
|
1176
|
+
settings = { ...JSON.parse(previous) };
|
|
1177
|
+
}
|
|
1178
|
+
catch {
|
|
1179
|
+
throw new Error(`antigravity settings at ${settingsPath} are not valid JSON; refusing to overwrite them`);
|
|
1180
|
+
}
|
|
1181
|
+
await writeFile(backupPath, previous, "utf8");
|
|
1182
|
+
}
|
|
1183
|
+
const existingPermissions = (settings.permissions && typeof settings.permissions === "object" && !Array.isArray(settings.permissions))
|
|
1184
|
+
? { ...settings.permissions }
|
|
1185
|
+
: {};
|
|
1186
|
+
const trusted = Array.isArray(settings.trustedWorkspaces)
|
|
1187
|
+
? settings.trustedWorkspaces.filter((entry) => typeof entry === "string")
|
|
1188
|
+
: [];
|
|
1189
|
+
const absolute = resolve(workspace);
|
|
1190
|
+
settings.permissions = { ...existingPermissions, allow };
|
|
1191
|
+
settings.trustedWorkspaces = trusted.includes(absolute) ? trusted : [...trusted, absolute];
|
|
1192
|
+
await mkdir(settingsDir, { recursive: true });
|
|
1193
|
+
await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
|
1194
|
+
try {
|
|
1195
|
+
return await run();
|
|
1196
|
+
}
|
|
1197
|
+
finally {
|
|
1198
|
+
if (previous === null)
|
|
1199
|
+
await rm(settingsPath, { force: true });
|
|
1200
|
+
else
|
|
1201
|
+
await writeFile(settingsPath, previous, "utf8");
|
|
1202
|
+
await rm(backupPath, { force: true });
|
|
1203
|
+
if (!settingsDirExisted)
|
|
1204
|
+
await rmdir(settingsDir).catch(() => undefined);
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
finally {
|
|
1208
|
+
release();
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1021
1211
|
export const antigravityDriver = {
|
|
1022
1212
|
name: "antigravity",
|
|
1213
|
+
async listModels(executable = "agy", workspace = process.cwd()) {
|
|
1214
|
+
const result = await execute(executable, ["models"], workspace, 30_000, undefined, "local");
|
|
1215
|
+
if (result.code !== 0)
|
|
1216
|
+
return null;
|
|
1217
|
+
const models = parseAntigravityModelList(result.stdout);
|
|
1218
|
+
return models.size ? models : null;
|
|
1219
|
+
},
|
|
1023
1220
|
async run(input) {
|
|
1024
1221
|
const fuelSource = input.fuelSource === "local" ? "local" : "conduit";
|
|
1025
1222
|
// Antigravity authenticates with a Google login / GEMINI_API_KEY — not Conduit /v1 shims.
|
|
@@ -1034,12 +1231,33 @@ export const antigravityDriver = {
|
|
|
1034
1231
|
if (!mode) {
|
|
1035
1232
|
return { status: "failed", resultText: null, sessionId: null, error: "No Bridge-mapped Antigravity mode for active grants; refusing to start agent" };
|
|
1036
1233
|
}
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1234
|
+
const allow = antigravityPermissionProjection(executionClass, input.grants, {
|
|
1235
|
+
verificationCommands: input.verificationCommands,
|
|
1236
|
+
capabilities: input.capabilities ?? [],
|
|
1237
|
+
diagnosis: input.workRole === "diagnose",
|
|
1238
|
+
});
|
|
1239
|
+
const timeoutMs = agentTurnTimeoutMs(input);
|
|
1240
|
+
let executed;
|
|
1241
|
+
try {
|
|
1242
|
+
executed = await withAntigravityPermissions(input.workspace, allow, () => execute(input.executable ?? "agy", antigravityRunArgs({ prompt: `${input.prompt}\n\n${antigravityPromptRule}`, grants: input.grants, resumeSessionId: input.resumeSessionId, model: input.model }, mode, timeoutMs), input.workspace, timeoutMs, undefined, "local"));
|
|
1041
1243
|
}
|
|
1042
|
-
|
|
1244
|
+
catch (error) {
|
|
1245
|
+
return { status: "failed", resultText: null, sessionId: null, error: error instanceof Error ? error.message : String(error) };
|
|
1246
|
+
}
|
|
1247
|
+
const { code, stdout, stderr } = executed;
|
|
1248
|
+
const parsed = parseAntigravityOutput(stdout);
|
|
1249
|
+
// A refused run says so in the envelope, not on stderr. Take the envelope's own reason first —
|
|
1250
|
+
// it is the only text that names the cause (a spent allowance and when it refills).
|
|
1251
|
+
const refused = code !== 0 || (parsed.status !== null && parsed.status.toUpperCase() !== "SUCCESS");
|
|
1252
|
+
if (refused) {
|
|
1253
|
+
return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: boundedTail(parsed.error || stderr || parsed.resultText || `agy exited with code ${code}`, 20_000) };
|
|
1254
|
+
}
|
|
1255
|
+
// An empty response with a denial on stderr is a refused run: report it instead of an empty reply.
|
|
1256
|
+
const denial = parsed.resultText?.trim() ? null : antigravityDenialNotice(stderr);
|
|
1257
|
+
if (denial) {
|
|
1258
|
+
return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: boundedTail(`agy returned no response: ${denial}`, 20_000) };
|
|
1259
|
+
}
|
|
1260
|
+
return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
|
|
1043
1261
|
},
|
|
1044
1262
|
};
|
|
1045
1263
|
/**
|
package/dist/drivers.js
CHANGED
|
@@ -67,6 +67,8 @@ function normalizeOutcome(raw) {
|
|
|
67
67
|
const value = raw;
|
|
68
68
|
if (!Number.isFinite(value.consecutive_timeouts) || typeof value.last_timeout_at !== "string")
|
|
69
69
|
return undefined;
|
|
70
|
+
if (!Number.isFinite(Date.parse(value.last_timeout_at)))
|
|
71
|
+
return undefined;
|
|
70
72
|
const count = Math.max(0, Math.floor(value.consecutive_timeouts));
|
|
71
73
|
return { consecutive_timeouts: count, last_timeout_at: value.last_timeout_at };
|
|
72
74
|
}
|
|
@@ -237,7 +239,7 @@ export function laneTimedOutRecently(config, driverId, now = Date.now()) {
|
|
|
237
239
|
if (!outcome || outcome.consecutive_timeouts < TIMEOUT_SUPPRESSION_THRESHOLD)
|
|
238
240
|
return false;
|
|
239
241
|
const seen = Date.parse(outcome.last_timeout_at);
|
|
240
|
-
return
|
|
242
|
+
return now >= seen && now - seen <= TIMEOUT_SUPPRESSION_MS;
|
|
241
243
|
}
|
|
242
244
|
export function onlineDriverIds(config) {
|
|
243
245
|
const drivers = normalizeDrivers(config.drivers);
|
|
@@ -4,18 +4,9 @@
|
|
|
4
4
|
import { execFile } from "node:child_process";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { commitsAheadOfBase } from "./ensure-pull-request.js";
|
|
7
|
+
import { readHeadCommit } from "./git.js";
|
|
7
8
|
import { AgentNoLandCommitError, filterPathsInScope, requiresLandCommit, } from "./land-contract.js";
|
|
8
9
|
const execFileAsync = promisify(execFile);
|
|
9
|
-
async function workspaceHeadCommit(workspace) {
|
|
10
|
-
const { stdout } = await execFileAsync("git", ["-C", workspace, "rev-parse", "HEAD"], {
|
|
11
|
-
timeout: 30_000,
|
|
12
|
-
maxBuffer: 1_000_000,
|
|
13
|
-
});
|
|
14
|
-
const head = stdout.trim();
|
|
15
|
-
if (!head)
|
|
16
|
-
throw new Error("Could not read workspace HEAD");
|
|
17
|
-
return head;
|
|
18
|
-
}
|
|
19
10
|
async function uncommittedPaths(workspace) {
|
|
20
11
|
const { stdout } = await execFileAsync("git", ["-C", workspace, "status", "--porcelain"], {
|
|
21
12
|
timeout: 30_000,
|
|
@@ -64,7 +55,7 @@ export async function ensureLandCommit(input) {
|
|
|
64
55
|
if (!base)
|
|
65
56
|
return { kind: "skipped" };
|
|
66
57
|
const countAhead = input.countCommitsAhead ?? commitsAheadOfBase;
|
|
67
|
-
const readHead = input.readHeadCommit ??
|
|
58
|
+
const readHead = input.readHeadCommit ?? readHeadCommit;
|
|
68
59
|
const listPending = input.listUncommittedPaths ?? uncommittedPaths;
|
|
69
60
|
const commitPaths = input.commitPaths ?? commitScopedPaths;
|
|
70
61
|
const ahead = await countAhead(input.workspace, base);
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
import { execFile } from "node:child_process";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { normalizeRepositoryUrl } from "./brief.js";
|
|
9
|
+
import { readHeadCommit } from "./git.js";
|
|
10
|
+
import { forgeTransportFailure } from "./transport-fault.js";
|
|
9
11
|
import { agentNoLandCommitMessage } from "./land-contract.js";
|
|
10
12
|
const execFileAsync = promisify(execFile);
|
|
11
|
-
/** Mirror execution.ts FORGE_TRANSPORT_PATTERN — keep local to avoid import cycles. */
|
|
12
|
-
const FORGE_TRANSPORT_PATTERN = /unable to access '?https?:\/\/|error in the http2 framing layer|could not resolve host|connection (?:reset|timed out|refused)|\bcurl\b.*\b(?:52|55|56|92)\b|remote end hung up unexpectedly|\brpc failed\b|tls handshake|network is unreachable|operation timed out/i;
|
|
13
13
|
/**
|
|
14
14
|
* Commits the attempt branch has and the base does not.
|
|
15
15
|
* Returns null when this checkout does not know the base. An unverifiable precondition must not
|
|
@@ -103,16 +103,6 @@ export function adoptPullRequestUrlFromEvidence(report, repositoryFingerprint) {
|
|
|
103
103
|
}
|
|
104
104
|
return report;
|
|
105
105
|
}
|
|
106
|
-
async function workspaceHeadCommit(workspace) {
|
|
107
|
-
const { stdout } = await execFileAsync("git", ["-C", workspace, "rev-parse", "HEAD"], {
|
|
108
|
-
timeout: 30_000,
|
|
109
|
-
maxBuffer: 1_000_000,
|
|
110
|
-
});
|
|
111
|
-
const head = stdout.trim().toLowerCase();
|
|
112
|
-
if (!/^[0-9a-f]{7,64}$/.test(head))
|
|
113
|
-
throw new Error("Could not determine workspace HEAD commit");
|
|
114
|
-
return head;
|
|
115
|
-
}
|
|
116
106
|
function execErrorMessage(error) {
|
|
117
107
|
if (!(error instanceof Error))
|
|
118
108
|
return String(error);
|
|
@@ -157,7 +147,7 @@ export async function pushOriginHead(workspace, options = {}) {
|
|
|
157
147
|
catch {
|
|
158
148
|
// fall through to retry / throw
|
|
159
149
|
}
|
|
160
|
-
if (attempt < attempts &&
|
|
150
|
+
if (attempt < attempts && forgeTransportFailure(lastMessage)) {
|
|
161
151
|
await sleep(750 * attempt);
|
|
162
152
|
continue;
|
|
163
153
|
}
|
|
@@ -179,7 +169,7 @@ export async function pushOriginHead(workspace, options = {}) {
|
|
|
179
169
|
* Agent-supplied PR URLs skip creation but still require HEAD identity.
|
|
180
170
|
*/
|
|
181
171
|
export async function ensureDeliveryPullRequest(input) {
|
|
182
|
-
const readHead = input.readHeadCommit ??
|
|
172
|
+
const readHead = input.readHeadCommit ?? readHeadCommit;
|
|
183
173
|
const push = input.pushOriginHead ?? pushOriginHead;
|
|
184
174
|
const countAhead = input.countCommitsAhead ?? commitsAheadOfBase;
|
|
185
175
|
let report = adoptPullRequestUrlFromEvidence(input.report, input.repositoryFingerprint);
|