gaslamp 2.2.1 → 2.2.2
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/README.md +13 -9
- package/bin/gaslamp.mjs +2 -0
- package/package.json +1 -1
- package/src/consult.mjs +68 -15
- package/src/fleet.mjs +3 -2
- package/src/setup.mjs +1 -1
- package/src/stdin.mjs +28 -0
package/README.md
CHANGED
|
@@ -288,15 +288,18 @@ threads, `--schema`, fleets — with these differences:
|
|
|
288
288
|
kernel sandbox (`--sandbox read-only`: Seatbelt on macOS, Landlock on Linux;
|
|
289
289
|
writes only to `~/.grok` and temp dirs). Grok's permission layer is not a
|
|
290
290
|
substitute: headless `--permission-mode default` denied its write tool but
|
|
291
|
-
ran an arbitrary shell `touch`.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
291
|
+
ran an arbitrary shell `touch`. On macOS, a read-only gemini consult runs
|
|
292
|
+
agy under Seatbelt (`sandbox-exec`): it can write its own state directory
|
|
293
|
+
(`~/.gemini/antigravity-cli`, minus the files that configure it) and temp
|
|
294
|
+
dirs, nothing else, whatever Antigravity's permission settings say — so a
|
|
295
|
+
user can keep `always-proceed` / `agentMode: auto` for interactive work, and
|
|
296
|
+
search and read-only commands still run. Elsewhere (Linux, or inside a
|
|
297
|
+
Codex sandbox, where Seatbelt does not nest) the guarantee is Antigravity's
|
|
298
|
+
`request-review` preset: the consult refuses to start when its permission
|
|
299
|
+
files grant more than reads, read-only git and the end_session off-ramp, or
|
|
300
|
+
set a lax `toolPermission` or `agentMode: auto` (which approves writes and
|
|
301
|
+
commands under any preset, unreported), and fails if init doesn't confirm
|
|
302
|
+
the preset. That path is read-capable rather than airtight: an allowed
|
|
300
303
|
`git diff` still accepts git's own `--output=<file>`.
|
|
301
304
|
Antigravity has no headless `workspace-write` (`--mode accept-edits` did
|
|
302
305
|
not enable writes in print mode), so that is a usage error there.
|
|
@@ -337,6 +340,7 @@ it. This is loop prevention, not a security boundary. Set
|
|
|
337
340
|
| `GASLAMP_DEBUG` | unset | verbose stderr (spawn argv) |
|
|
338
341
|
| `CLAUDE_BIN` / `CODEX_BIN` / `AGY_BIN` / `GROK_BIN` | autodetected | backend binaries |
|
|
339
342
|
| `GROK_HOME` | `~/.grok` | where grok keeps sessions (the system prompt hash is read from there) |
|
|
343
|
+
| `GASLAMP_GEMINI_SEATBELT` | on (macOS) | `0` runs read-only gemini consults on Antigravity's preset check instead of Seatbelt |
|
|
340
344
|
| `CODEX_HOME` | `~/.codex` | where `setup --skill` puts Codex's copy |
|
|
341
345
|
|
|
342
346
|
Exit codes: `0` ok · `1` consult failed/killed · `2` usage · `3` nested
|
package/bin/gaslamp.mjs
CHANGED
|
@@ -120,6 +120,8 @@ Env knobs:
|
|
|
120
120
|
GASLAMP_DEBUG verbose stderr (spawn argv)
|
|
121
121
|
CLAUDE_BIN / CODEX_BIN / AGY_BIN / GROK_BIN
|
|
122
122
|
backend binaries (autodetected otherwise)
|
|
123
|
+
GASLAMP_GEMINI_SEATBELT 0 = read-only gemini consults rely on Antigravity's
|
|
124
|
+
preset check instead of Seatbelt (macOS default)
|
|
123
125
|
|
|
124
126
|
Every job record (meta.json) carries provenance: provider, harness version,
|
|
125
127
|
the model the backend reported (or unknown), the vendor system prompt's hash
|
package/package.json
CHANGED
package/src/consult.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
newJobId, readMeta, renderTrailer, writeMeta,
|
|
48
48
|
} from "./jobs.mjs";
|
|
49
49
|
import { readThread, validThreadName, writeThread } from "./threads.mjs";
|
|
50
|
+
import { readStdin } from "./stdin.mjs";
|
|
50
51
|
|
|
51
52
|
// Tools permitted under the claude `read-only` override (advisory reviewer).
|
|
52
53
|
// The git reads matter: a reviewer that can't run `git diff`/`git log` is
|
|
@@ -81,9 +82,14 @@ const RETRIEVAL_TOOLS = {
|
|
|
81
82
|
claude: /^(WebSearch|WebFetch)$/,
|
|
82
83
|
codex: /^web_search/,
|
|
83
84
|
gemini: /^(search_web|read_url_content|open_browser_url|read_browser_page|browser_)/,
|
|
84
|
-
grok: /^(web_search|web_fetch|
|
|
85
|
+
grok: /^(web_search|web_fetch|x_)/,
|
|
85
86
|
};
|
|
86
87
|
|
|
88
|
+
// Grok's X search is a backend tool: init never lists it, and a call arrives
|
|
89
|
+
// as a tool_use named "X search:" ({"variant":"XSearch","backend":true}) whose
|
|
90
|
+
// tool_result names the tool that ran (x_keyword_search, x_semantic_search).
|
|
91
|
+
const toolKey = (name) => String(name).toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
|
|
92
|
+
|
|
87
93
|
// Variables a third-party harness has no business inheriting from the calling
|
|
88
94
|
// agent: Claude Code's session id, messaging socket token, account and OAuth
|
|
89
95
|
// details; Codex's and the API keys' equivalents. They reach the consultee's
|
|
@@ -113,9 +119,36 @@ function backendEnv(backend) {
|
|
|
113
119
|
if (backend === "gemini" || backend === "grok")
|
|
114
120
|
for (const k of Object.keys(env)) if (FOREIGN_ENV.test(k)) delete env[k];
|
|
115
121
|
if (backend === "grok") Object.assign(env, GROK_COMPAT_OFF, { GROK_DISABLE_AUTOUPDATER: "1" });
|
|
122
|
+
if (backend === "gemini") env.AGY_CLI_DISABLE_AUTO_UPDATE = "true";
|
|
116
123
|
return env;
|
|
117
124
|
}
|
|
118
125
|
|
|
126
|
+
// Read-only for a gemini consult, on macOS: agy runs under Seatbelt, writing
|
|
127
|
+
// nothing but its own state directory and temp — the guarantee no longer
|
|
128
|
+
// depends on Antigravity's permission settings, which a user may keep lax
|
|
129
|
+
// for interactive work (toolPermission always-proceed, agentMode auto) and
|
|
130
|
+
// which no flag overrides per run. Inside that state directory the files
|
|
131
|
+
// that configure agy stay write-denied (later deny rules win in SBPL).
|
|
132
|
+
// (bin/ stays writable: agy extracts a helper, bin/agentapi, before running
|
|
133
|
+
// any shell command, and denying it breaks read-only commands like git diff.)
|
|
134
|
+
// Elsewhere — Linux, or inside a sandbox of Codex's own, where Seatbelt does
|
|
135
|
+
// not nest — the preset check below is the guarantee.
|
|
136
|
+
const SANDBOX_EXEC = "/usr/bin/sandbox-exec";
|
|
137
|
+
const useSeatbelt = () => process.platform === "darwin" && existsSync(SANDBOX_EXEC)
|
|
138
|
+
&& !process.env.CODEX_SANDBOX && process.env.GASLAMP_GEMINI_SEATBELT !== "0";
|
|
139
|
+
function agySeatbeltProfile() {
|
|
140
|
+
const q = (p) => '"' + p.replace(/[\\"]/g, (c) => "\\" + c) + '"';
|
|
141
|
+
const state = join(homedir(), ".gemini", "antigravity-cli");
|
|
142
|
+
return [
|
|
143
|
+
"(version 1)",
|
|
144
|
+
"(allow default)",
|
|
145
|
+
"(deny file-write*)",
|
|
146
|
+
`(allow file-write* (subpath ${q(state)}) (subpath "/private/tmp") (subpath "/private/var/folders") (subpath "/dev"))`,
|
|
147
|
+
`(deny file-write* ${["settings.json", "keybindings.json"].map((f) => `(literal ${q(join(state, f))})`).join(" ")} ` +
|
|
148
|
+
`${["rules", "plugins", "skills", "updater"].map((d) => `(subpath ${q(join(state, d))})`).join(" ")})`,
|
|
149
|
+
].join("\n");
|
|
150
|
+
}
|
|
151
|
+
|
|
119
152
|
// What a read-only gemini consult may find pre-allowed in Antigravity's
|
|
120
153
|
// permission files: reads, read-only git (read-capable like the claude read
|
|
121
154
|
// set — git's own --output flags aside), and the end_session off-ramp. Any
|
|
@@ -491,12 +524,12 @@ export function runConsult(backend, argv) {
|
|
|
491
524
|
let prompt = o.prompt;
|
|
492
525
|
if (prompt == null || prompt === "-") {
|
|
493
526
|
if (prompt == null && process.stdin.isTTY) die(2, "no prompt — pass one as an argument or pipe it on stdin");
|
|
494
|
-
prompt =
|
|
527
|
+
prompt = readStdin();
|
|
495
528
|
} else if (!process.stdin.isTTY) {
|
|
496
529
|
// Prompt on argv AND piped stdin: stdin is evidence — the diff, the log,
|
|
497
530
|
// the failing output — appended as a <stdin> block. This is codex exec's
|
|
498
531
|
// own convention for exactly this case, applied uniformly to both backends.
|
|
499
|
-
const evidence =
|
|
532
|
+
const evidence = readStdin();
|
|
500
533
|
if (evidence.trim())
|
|
501
534
|
prompt += "\n\n<stdin>\n" + (evidence.endsWith("\n") ? evidence : evidence + "\n") + "</stdin>";
|
|
502
535
|
}
|
|
@@ -522,19 +555,28 @@ export function runConsult(backend, argv) {
|
|
|
522
555
|
// must not strand either.
|
|
523
556
|
const bin = resolveBin(backend);
|
|
524
557
|
const env = backendEnv(backend);
|
|
525
|
-
let grants = null;
|
|
558
|
+
let grants = null, agySettings = null, wrap = null;
|
|
559
|
+
let enforcement = sandbox === "read-only" ? {
|
|
560
|
+
claude: "permission-mode default + read allowlist", codex: "codex read-only sandbox",
|
|
561
|
+
grok: "grok --sandbox read-only (kernel)", gemini: null,
|
|
562
|
+
}[backend] : null;
|
|
526
563
|
if (backend === "gemini" && sandbox === "read-only") {
|
|
527
564
|
const state = agyPermissionState();
|
|
565
|
+
grants = state.grants.map((g) => g.rule);
|
|
566
|
+
agySettings = [...state.presets.map((p) => `toolPermission ${p.preset}`), ...state.modes.map((m) => `agentMode ${m.mode}`)];
|
|
528
567
|
const lax = state.presets.filter((p) => AGY_LAX_PRESETS.has(p.preset));
|
|
529
568
|
const modes = state.modes.filter((m) => AGY_LAX_MODES.has(m.mode));
|
|
530
569
|
const open = state.grants.filter((g) => !AGY_READ_GRANT.test(g.rule));
|
|
531
|
-
if (
|
|
570
|
+
if (useSeatbelt()) {
|
|
571
|
+
wrap = [SANDBOX_EXEC, "-p", agySeatbeltProfile()];
|
|
572
|
+
enforcement = "seatbelt (sandbox-exec)";
|
|
573
|
+
} else if (lax.length || modes.length || open.length)
|
|
532
574
|
die(1, "Antigravity's settings would let a read-only consult act unprompted — " +
|
|
533
575
|
[...lax.map((p) => `toolPermission "${p.preset}" (${p.file})`),
|
|
534
576
|
...modes.map((m) => `agentMode "${m.mode}" (${m.file})`),
|
|
535
577
|
...open.map((g) => `${g.rule} (${g.file})`)].join(", ") +
|
|
536
578
|
". Remove them, or pass --sandbox danger-full-access to consult without the guarantee.");
|
|
537
|
-
|
|
579
|
+
else enforcement = "agy request-review preset";
|
|
538
580
|
}
|
|
539
581
|
const model = resolveModel(backend, o.model, bin, env);
|
|
540
582
|
const version = harnessVersion(bin, env);
|
|
@@ -558,8 +600,8 @@ export function runConsult(backend, argv) {
|
|
|
558
600
|
// `model` is what the backend reported answering with; unknown until it
|
|
559
601
|
// does (codex never reports one in its event stream).
|
|
560
602
|
model: "unknown", modelRequested: o.model ?? null, modelPassed: model,
|
|
561
|
-
permissionMode: null, sandbox,
|
|
562
|
-
...(grants ? { grants } : {}),
|
|
603
|
+
permissionMode: null, sandbox, enforcement,
|
|
604
|
+
...(grants ? { grants, agySettings } : {}),
|
|
563
605
|
systemPrompt: { known: false },
|
|
564
606
|
retrieval: { available: null, used: [] },
|
|
565
607
|
gaslamp: { version: PKG_VERSION, revision: gaslampRevision() },
|
|
@@ -584,10 +626,13 @@ export function runConsult(backend, argv) {
|
|
|
584
626
|
: backend === "gemini" ? geminiArgs(o, resumeSid, schemaText, model, sandbox)
|
|
585
627
|
: grokArgs(o, resumeSid, schemaText, sandbox);
|
|
586
628
|
|
|
587
|
-
|
|
629
|
+
// sandbox-exec applies the profile and execs the harness in place (same
|
|
630
|
+
// pid), so the group, the signals and the process name are agy's own.
|
|
631
|
+
const [cmd, argv0] = wrap ? [wrap[0], [...wrap.slice(1), bin, ...args]] : [bin, args];
|
|
632
|
+
log("spawn", cmd, JSON.stringify(argv0));
|
|
588
633
|
// detached → the child leads its own process group, so signal forwarding can
|
|
589
634
|
// kill the whole tree (backend + whatever it spawned), never just the top.
|
|
590
|
-
const child = spawn(
|
|
635
|
+
const child = spawn(cmd, argv0, { cwd: meta.cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
|
|
591
636
|
meta.childPid = child.pid ?? null;
|
|
592
637
|
writeMeta(meta);
|
|
593
638
|
const killGroup = (sig) => {
|
|
@@ -680,12 +725,20 @@ export function runConsult(backend, argv) {
|
|
|
680
725
|
if (j.model) prov.model = j.model;
|
|
681
726
|
prov.permissionMode = j.permissionMode ?? null;
|
|
682
727
|
if (Array.isArray(j.tools)) prov.retrieval.available = j.tools.filter((t) => RETRIEVAL_TOOLS[backend].test(t));
|
|
728
|
+
// Grok's backend X search is never listed but is there (see toolKey).
|
|
729
|
+
if (backend === "grok" && prov.retrieval.available) prov.retrieval.available.push("x_search");
|
|
683
730
|
writeMeta(meta);
|
|
684
731
|
} else if (claudeShaped && j.type === "assistant") {
|
|
685
732
|
for (const c of j.message?.content ?? []) {
|
|
686
|
-
if (c.type === "tool_use" || c.type === "server_tool_use") noteTool(c.name);
|
|
733
|
+
if (c.type === "tool_use" || c.type === "server_tool_use") noteTool(toolKey(c.name));
|
|
687
734
|
if (c.type === "tool_use" && c.name === "use_tool") noteTool(c.input?.tool_name);
|
|
688
735
|
}
|
|
736
|
+
} else if (backend === "grok" && j.type === "user") {
|
|
737
|
+
for (const c of j.message?.content ?? []) {
|
|
738
|
+
if (c.type !== "tool_result" || typeof c.content !== "string") continue;
|
|
739
|
+
let r; try { r = JSON.parse(c.content); } catch { continue; }
|
|
740
|
+
if (typeof r?.name === "string") noteTool(r.name);
|
|
741
|
+
}
|
|
689
742
|
} else if (backend === "gemini" && j.event === "init") {
|
|
690
743
|
initSeen = true;
|
|
691
744
|
const init = j.init ?? {};
|
|
@@ -695,9 +748,9 @@ export function runConsult(backend, argv) {
|
|
|
695
748
|
if (Array.isArray(init.tools)) prov.retrieval.available = [...new Set(init.tools
|
|
696
749
|
.filter((t) => RETRIEVAL_TOOLS.gemini.test(t)).map((t) => t.startsWith("browser_") ? "browser_*" : t))];
|
|
697
750
|
writeMeta(meta);
|
|
698
|
-
//
|
|
699
|
-
// a laxer one would let the consult act unprompted.
|
|
700
|
-
if (sandbox === "read-only" && !["request-review", "strict"].includes(prov.permissionMode))
|
|
751
|
+
// Without Seatbelt, read-only rests on Antigravity's request-review (or
|
|
752
|
+
// strict) preset; a laxer one would let the consult act unprompted.
|
|
753
|
+
if (sandbox === "read-only" && !wrap && !["request-review", "strict"].includes(prov.permissionMode))
|
|
701
754
|
refuse(`agy reports permission mode "${prov.permissionMode}", not request-review — refusing a read-only consult it would not enforce`);
|
|
702
755
|
else initValidated = true;
|
|
703
756
|
} else if (backend === "gemini" && j.event === "step_update") {
|
|
@@ -816,7 +869,7 @@ export function runConsult(backend, argv) {
|
|
|
816
869
|
: structuredOut !== undefined ? JSON.stringify(structuredOut) : resultText;
|
|
817
870
|
|
|
818
871
|
let status = killedBy ? "killed" : (refusal || code !== 0 || resultErr) ? "failed" : "done";
|
|
819
|
-
if (status === "done" && backend === "gemini" && sandbox === "read-only" && !initValidated) {
|
|
872
|
+
if (status === "done" && backend === "gemini" && sandbox === "read-only" && !wrap && !initValidated) {
|
|
820
873
|
status = "failed";
|
|
821
874
|
refusal = "agy reported no init event, so its permission mode — the read-only guarantee — went unconfirmed";
|
|
822
875
|
writeSync(2, `[gaslamp] ${refusal}\n`);
|
package/src/fleet.mjs
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
} from "./jobs.mjs";
|
|
44
44
|
import { BACKEND_NAMES, isBackend } from "./backends.mjs";
|
|
45
45
|
import { readThread } from "./threads.mjs";
|
|
46
|
+
import { readStdin } from "./stdin.mjs";
|
|
46
47
|
|
|
47
48
|
const GASLAMP_BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "gaslamp.mjs");
|
|
48
49
|
// Quota-shaped, not CPU-shaped: the bottleneck is API rate limits, not cores
|
|
@@ -116,7 +117,7 @@ function buildTasks(backend, o) {
|
|
|
116
117
|
let raw;
|
|
117
118
|
const stdinRequested = o.stdin || (o.prompt == null && !process.stdin.isTTY);
|
|
118
119
|
if (stdinRequested) {
|
|
119
|
-
const lines =
|
|
120
|
+
const lines = readStdin().split("\n").map((l) => l.trim()).filter(Boolean);
|
|
120
121
|
if (!lines.length) die(2, "no tasks on stdin");
|
|
121
122
|
raw = lines.map((line, i) => norm(line.startsWith("{") ? parseJsonLine(line, i) : line, i));
|
|
122
123
|
} else {
|
|
@@ -125,7 +126,7 @@ function buildTasks(backend, o) {
|
|
|
125
126
|
// <stdin> convention as a single consult (git diff | gaslamp fleet …).
|
|
126
127
|
let prompt = o.prompt;
|
|
127
128
|
if (!process.stdin.isTTY) {
|
|
128
|
-
const evidence =
|
|
129
|
+
const evidence = readStdin();
|
|
129
130
|
if (evidence.trim())
|
|
130
131
|
prompt += "\n\n<stdin>\n" + (evidence.endsWith("\n") ? evidence : evidence + "\n") + "</stdin>";
|
|
131
132
|
}
|
package/src/setup.mjs
CHANGED
|
@@ -121,7 +121,7 @@ Omit \`--model\` to use ${Other}'s own configured default. Match the tier to the
|
|
|
121
121
|
|
|
122
122
|
Same contract as above — threads, \`--schema\`, fleets, records — with two differences:
|
|
123
123
|
|
|
124
|
-
- **Read-only unless you say otherwise.** An omitted \`--sandbox\` means read-only, enforced
|
|
124
|
+
- **Read-only unless you say otherwise.** An omitted \`--sandbox\` means read-only, kernel-enforced where possible: Grok runs in its own sandbox, and on macOS gaslamp runs Antigravity under Seatbelt, so neither writes outside its own state and temp dirs (Antigravity elsewhere relies on its request-review preset, and gaslamp refuses lax settings). Inside that they read local files and search — Google search on Gemini, web and X search on Grok — so for a search consult send the question and its constraints and let them fetch; for a review of material you already hold, pipe it in. What fails is writing: a write or a writing shell command errors and the turn goes on, except on Antigravity's preset path, where writes and shell commands are refused outright and can end the turn with nothing to say (gaslamp marks that failed), and its URL fetch is not offered at all. Gemini has no \`workspace-write\`; \`danger-full-access\` exists on both.
|
|
125
125
|
- **Provenance on every job.** \`meta.json\` records the model the harness reported (Grok also shows the model it billed), the harness version, a hash of the vendor system prompt where the harness exposes it (Grok) or \`known: false\`, and which web/X retrieval tools the consultee had and actually used.
|
|
126
126
|
|
|
127
127
|
\`gaslamp gemini --model\` takes \`argon\` (the default) or \`flash\`, resolved to the newest \`gemini-<version>-<tier>\` in Antigravity's own catalog; Antigravity also serves Claude and GPT models under the same account, so the target always pins a Gemini unless you pass another id. \`gaslamp grok\` uses the account's default model unless \`--model\` names one (\`grok models\` lists them).
|
package/src/stdin.mjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// stdin.mjs — read all of stdin, cat-like, even when it is non-blocking.
|
|
2
|
+
//
|
|
3
|
+
// readFileSync(0) throws EAGAIN instead of waiting when the caller hands
|
|
4
|
+
// gaslamp a stdin with O_NONBLOCK set (seen from an agent harness's shell),
|
|
5
|
+
// and the consult died before reading its evidence. Reading in a loop that
|
|
6
|
+
// naps on EAGAIN waits for the data and the EOF the way a blocking read would;
|
|
7
|
+
// on macOS reopening /dev/stdin is no escape, since it dups the same
|
|
8
|
+
// non-blocking file description.
|
|
9
|
+
|
|
10
|
+
import { readSync } from "node:fs";
|
|
11
|
+
|
|
12
|
+
export function readStdin() {
|
|
13
|
+
const chunks = [];
|
|
14
|
+
const buf = Buffer.alloc(1 << 16);
|
|
15
|
+
const nap = new Int32Array(new SharedArrayBuffer(4));
|
|
16
|
+
for (;;) {
|
|
17
|
+
let n;
|
|
18
|
+
try { n = readSync(0, buf, 0, buf.length, null); }
|
|
19
|
+
catch (e) {
|
|
20
|
+
if (e.code === "EAGAIN") { Atomics.wait(nap, 0, 0, 10); continue; }
|
|
21
|
+
if (e.code === "EOF") break;
|
|
22
|
+
throw e;
|
|
23
|
+
}
|
|
24
|
+
if (n === 0) break;
|
|
25
|
+
chunks.push(Buffer.from(buf.subarray(0, n)));
|
|
26
|
+
}
|
|
27
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
28
|
+
}
|