Skip to content
53 changes: 25 additions & 28 deletions apps/daemon/src/runtimes/defs/antigravity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import {
mkdirSync,
readFileSync,
writeFileSync,
renameSync,
statSync,
} from 'node:fs';
import { randomBytes } from 'node:crypto';
import { readFile as fsReadFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
Expand Down Expand Up @@ -49,9 +52,11 @@ export function writeAntigravityModelSelection(
label: string,
settingsPath: string = ANTIGRAVITY_SETTINGS_PATH,
): void {
let fileMode = 0o600;
let existing: Record<string, unknown> = {};
if (existsSync(settingsPath)) {
try {
fileMode = statSync(settingsPath).mode;
const parsed = JSON.parse(readFileSync(settingsPath, 'utf8')) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
existing = parsed as Record<string, unknown>;
Expand All @@ -63,7 +68,11 @@ export function writeAntigravityModelSelection(
}
existing.model = label;
mkdirSync(dirname(settingsPath), { recursive: true });
writeFileSync(settingsPath, `${JSON.stringify(existing, null, 2)}\n`);

// Use atomic write to prevent JSON corruption during concurrent agent spawns
const tempPath = `${settingsPath}.${randomBytes(4).toString('hex')}.tmp`;
writeFileSync(tempPath, `${JSON.stringify(existing, null, 2)}\n`, { mode: fileMode });
renameSync(tempPath, settingsPath);
}

// Per-process serialization for write-settings → spawn → agy-reads
Expand Down Expand Up @@ -215,39 +224,27 @@ export const antigravityAgentDef = {
runtimeContext.antigravitySettingsPath,
);
}
// We invoke agy via `-p -` (print mode + stdin sentinel), NOT
// `chat -`. Verified against `agy --help` on v1.0.3 — the
// `Available subcommands` list is `changelog / help / install /
// plugin / update`, and `chat` is NOT among them. `-p` is the
// documented print-mode flag (`Short alias for --print`) and
// `agy -p -` reads the prompt from stdin. The looper reviewer
// bot's environment runs a different agy build that may have
// renamed the entry point; until upstream confirms a stable
// headless subcommand (see google-antigravity/antigravity-cli#119)
// and the change actually ships in the auto-update channel that
// packaged OD users get, `-p -` is the contract that actually
// produces a print-mode reply on the installed CLI.
// We no longer use `-p -` because recent `agy` versions treat `-` as a literal
// prompt string instead of reading from stdin (see issue #5495).
// Instead, we use `promptViaFile: true` so the daemon securely prepares a
// managed temp file per-run and cleans it up after the agent exits.
if (!runtimeContext.promptFilePath) {
throw new Error('antigravity requires runtimeContext.promptFilePath when promptViaFile is true');
}

const args: string[] = [];
// Always opt into `--log-file` when the daemon supplied a path so
// it can post-exit grep for the actual upstream failure shape
// (auth missing vs quota reached vs upstream error) — without it
// the chat surfaces a generic "empty response" because print mode
// never echoes those errors on stdout. See server.ts empty-output
// guard for the consumer.
//
// Flag order is load-bearing on agy v1.0.3: `agy -p --log-file
// /tmp/x -` runs successfully but leaves /tmp/x empty, while `agy
// --log-file /tmp/x -p -` captures the diagnostic log, including
// `Propagating selected model override to backend: label="<model>"`
// and auth/quota failures.
if (runtimeContext.agentLogFilePath) {
args.push('--log-file', runtimeContext.agentLogFilePath);
args.push(`--log-file=${runtimeContext.agentLogFilePath}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainer attention — keep the silent-failure log contract covered. Changing this option to the single-token --log-file=… form leaves the existing fake Antigravity CLI in apps/daemon/tests/connection-test.test.ts unable to find the requested log path: that fixture looks up the exact --log-file token and reads the following argument. On this head, pnpm exec vitest run -c vitest.config.ts tests/connection-test.test.ts fails 1 of 156 tests; the quota-exhaustion case receives agent_auth_required instead of the expected rate_limited because the diagnostic log is never written. This matters because that test pins the user-visible distinction between OAuth recovery and quota recovery, and the repository approval bar requires the matching package tests to pass. Please either retain the previously validated two-token form here, or update the fake CLI to accept the equals form after validating that the supported agy versions do too, then rerun the connection-test file.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

}

args.push(`--add-dir=${dirname(runtimeContext.promptFilePath)}`);

args.push('-p');
args.push('-');
args.push(`Read the system instructions, conversation history, and user request from the file ${runtimeContext.promptFilePath}. Follow the instructions strictly and provide the final response to the user's latest request.`);
return args;
},
promptViaStdin: true,
promptViaStdin: false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — update the focused regression test with the transport contract. This line changes promptViaStdin to false, but apps/daemon/tests/runtimes/agent-args.test.ts still asserts that it is true and that every argument list ends in ['-p', '-']. On this head, vitest run -c vitest.config.ts tests/runtimes/agent-args.test.ts fails at line 540 (false !== true), so the daemon test lane is red and none of the new file behavior is pinned. Please update that Antigravity case to assert the intended file transport and argument shape, including the log-file and follow-up variants; if the existing managed prompt-file path is used, also assert promptViaFile: true and that a missing promptFilePath fails clearly.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

promptViaFile: true,
streamFormat: 'plain',
installUrl: 'https://antigravity.google/cli',
docsUrl: 'https://antigravity.google/docs/cli-overview',
Expand Down
42 changes: 33 additions & 9 deletions apps/daemon/tests/runtimes/agent-args.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, readFileSync } from 'node:fs';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { test } from 'vitest';
import {
AGENT_DEFS, aider, antigravity, assert, claude, codex, copilot, cursorAgent, deepseek, devin, detectAgents, grokBuild, join, kilo, kimi, kiro, mkdtempSync, opencode, pi, qoder, qwen, rmSync, spawnEnvForAgent, tmpdir, vibe, writeFileSync, chmodSync,
Expand Down Expand Up @@ -534,18 +534,28 @@ test('qwen args check promptViaStdin, base args, model args and exclude `-` sent
// the daemon would render the resulting empty reply as a "successful"
// agent response — exactly the failure mode the auth/quota guard at
// server.ts ~12090 is meant to catch but for the wrong reason.
test('antigravity pipes prompt via stdin via -p flag (print mode)', () => {
test('antigravity delivers prompt via managed temp file instead of stdin', () => {
assert.equal(antigravity.bin, 'agy');
assert.equal(antigravity.streamFormat, 'plain');
assert.equal(antigravity.promptViaStdin, true);
assert.equal(antigravity.promptViaStdin, false);
assert.equal(antigravity.promptViaFile, true);

const args = antigravity.buildArgs('write hello world', [], [], {}, {});
assert.deepEqual(args, ['-p', '-']);
assert.throws(() => {
antigravity.buildArgs('hello', [], [], {}, {});
}, /requires runtimeContext\.promptFilePath/);

const expectedFileText = 'Read the system instructions, conversation history, and user request from the file /tmp/managed-prompt.md. Follow the instructions strictly and provide the final response to the user\'s latest request.';

const args = antigravity.buildArgs('write hello world', [], [], {}, {
promptFilePath: '/tmp/managed-prompt.md'
});
assert.deepEqual(args, ['--add-dir=/tmp', '-p', expectedFileText]);

const argsWithLog = antigravity.buildArgs('write hello world', [], [], {}, {
agentLogFilePath: '/tmp/od-agy-test.log',
promptFilePath: '/tmp/managed-prompt.md'
});
assert.deepEqual(argsWithLog, ['--log-file', '/tmp/od-agy-test.log', '-p', '-']);
assert.deepEqual(argsWithLog, ['--log-file=/tmp/od-agy-test.log', '--add-dir=/tmp', '-p', expectedFileText]);

// No `--model` flag exists upstream, so buildArgs argv must stay the
// same regardless of which label the user picks.
Expand All @@ -558,9 +568,10 @@ test('antigravity pipes prompt via stdin via -p flag (print mode)', () => {
}, {
agentLogFilePath: '/tmp/od-agy-test.log',
antigravitySettingsPath: join(settingsDir, 'settings.json'),
promptFilePath: '/tmp/managed-prompt.md'
});
assert.equal(withModel.includes('--model'), false);
assert.deepEqual(withModel, ['--log-file', '/tmp/od-agy-test.log', '-p', '-']);
assert.deepEqual(withModel, ['--log-file=/tmp/od-agy-test.log', '--add-dir=/tmp', '-p', expectedFileText]);
} finally {
rmSync(settingsDir, { recursive: true, force: true });
}
Expand All @@ -575,14 +586,16 @@ test('antigravity pipes prompt via stdin via -p flag (print mode)', () => {
// same regression.
const followUp = antigravity.buildArgs('next message', [], [], {}, {
hasPriorAssistantTurn: true,
promptFilePath: '/tmp/managed-prompt.md'
});
assert.deepEqual(followUp, ['-p', '-']);
assert.deepEqual(followUp, ['--add-dir=/tmp', '-p', expectedFileText]);
assert.equal(followUp.includes('-c'), false);

const firstTurn = antigravity.buildArgs('first', [], [], {}, {
hasPriorAssistantTurn: false,
promptFilePath: '/tmp/managed-prompt.md'
});
assert.deepEqual(firstTurn, ['-p', '-']);
assert.deepEqual(firstTurn, ['--add-dir=/tmp', '-p', expectedFileText]);
assert.equal(antigravity.resumesSessionViaCli, undefined);

assert.equal(antigravity.maxPromptArgBytes, undefined);
Expand Down Expand Up @@ -669,6 +682,17 @@ test('antigravity persists model selection to agy settings.json', () => {
writeAntigravityModelSelection('Gemini 3.5 Flash (Low)', corruptPath);
const recovered = JSON.parse(readFileSync(corruptPath, 'utf8'));
assert.equal(recovered.model, 'Gemini 3.5 Flash (Low)');
// 5. Atomic replacement must preserve the destination's existing mode
// so sensitive policies in settings.json do not become world-readable.
const modePath = join(dir, 'mode-settings.json');
writeFileSync(modePath, JSON.stringify({ model: 'old' }));
chmodSync(modePath, 0o600);
const expectedMode = statSync(modePath).mode & 0o777;

writeAntigravityModelSelection('Gemini 3.5 Pro', modePath);

const postStat = statSync(modePath);
assert.equal(postStat.mode & 0o777, expectedMode);
} finally {
rmSync(dir, { recursive: true, force: true });
}
Expand Down
Loading