Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ If you find yourself reaching for `setTimeout` to "check again later", you're po
- `models/pricing.ts` — Anthropic `MODEL_PRICING` and `MODEL_CONTEXT_WINDOW` tables plus `getPricing()` / `getContextWindow()` lookup helpers.
- `types/` — cross-repo wire types (`preview`, `beads`, `streaming`, `file-change`) plus `types/events.ts`: the `USER_EVENTS` constant map of every per-user SSE event name (canonical here; mirrored at `codeagent-mobile/packages/shared/src/types/events.ts`). New event-producing/consuming code references `USER_EVENTS.*`, never a hand-typed string.

⚠️ **The TUI chrome/selector *parsers* do NOT live in shared anymore** (old `protocol/parseChrome.ts` / `selector.ts` / `filterChrome.ts` are gone). Glyphs and conventions vary per agent, so each PTY agent owns its own fixture-driven parsers next to its runtime strategy: `apps/cli/src/agents/<agent>/parsing.ts` (e.g. `cursor/parsing.ts`, `aider/parsing.ts` — `parse<Agent>Chrome` / `filter<Agent>Chrome` / `detect<Agent>Selector`), consumed via the runtime strategy and `streaming-emitter.service.ts`. ACP agents (claude/codex/gemini) get typed streaming and need no chrome parsing. `apps/cli/src/services/parseChrome.ts` is a vestigial one-line re-export of `@codeam/shared` with no remaining importers — don't add logic there.
⚠️ **The TUI chrome/selector *parsers* do NOT live in shared anymore** (old `protocol/parseChrome.ts` / `selector.ts` / `filterChrome.ts` are gone). Glyphs and conventions vary per agent, so each PTY agent owns its own fixture-driven parsers next to its runtime strategy: `apps/cli/src/agents/<agent>/parsing.ts` (e.g. `cursor/parsing.ts`, `aider/parsing.ts` — `parse<Agent>Chrome` / `filter<Agent>Chrome` / `detect<Agent>Selector`), consumed via the runtime strategy and `streaming-emitter.service.ts`. ACP agents (claude/codex/gemini/cursor/kimi/opencode/antigravity) get typed streaming and need no chrome parsing. `apps/cli/src/services/parseChrome.ts` is a vestigial one-line re-export of `@codeam/shared` with no remaining importers — don't add logic there.

Tests live next to the modules in `packages/shared/__tests__/` and run via `(cd packages/shared && npm run test)` or are picked up by the CI job automatically.

Expand Down
42 changes: 40 additions & 2 deletions apps/box/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
#
# ── Pre-installed agents + tooling (deploy-speed optimization) ─────────────
# EVERY supported agent CLI (claude-code, codex, cursor-agent, coderabbit,
# gemini, kimi, aider), the GitHub + GitLab CLIs (gh, glab), and Beads + Dolt
# gemini, kimi, opencode, antigravity/agy, aider), the GitHub + GitLab CLIs (gh, glab), and Beads + Dolt
# are baked into the image so a rescued user's FIRST deploy skips the
# per-session installs entirely (each of those otherwise shells out to a
# curl|sh / npm installer on first use). A box boots agent-ready. The deploy
Expand All @@ -32,7 +32,7 @@
# cache go to NON-home locations that survive the mount:
# • npm-global agents + codeam-cli → /usr/local/{lib,bin}
# • curl-installer agents (claude/cursor/kimi/coderabbit) → /opt/agents
# • dolt / aider / gh / glab / composer → /usr/local/bin
# • dolt / aider / gh / glab / composer / agy → /usr/local/bin
# • php (apt) → /usr/bin
#
# ⚠️ SIZE RULE: NEVER `chown -R` a large tree in its own RUN — Docker's
Expand Down Expand Up @@ -228,6 +228,44 @@ RUN mkdir -p "${AGENT_HOME}" \
if [ -x "$b" ]; then echo " OK $b"; else echo " MISS $b (installs on first deploy)"; fi; \
done

# ── Antigravity CLI (`agy`) → /usr/local/bin (root-owned, pinned, no self-update) ─
# Not via install.sh: that script always fetches the LATEST manifest (no
# version flag), so the image would float. We download the exact release the
# installer would have picked — same bucket, same per-platform tarball, same
# sha512 the manifest publishes
# (https://antigravity-cli-auto-updater-974169037036.us-central1.run.app/manifests/linux_<arch>.json)
# — and verify it. Bump AGY_RELEASE + both AGY_SHA512_* together from those
# manifests (guarded by __tests__/box/agy-version-pin.test.ts).
#
# /usr/local/bin, NOT ~/.local/bin: the runtime `/home/box` volume would shadow
# a home install. The codespace/self-hosted install snippets are
# `command -v agy`-guarded, so the baked binary short-circuits them.
#
# ⚠️ SELF-UPDATE: agy updates itself in the background and RELAUNCHES after an
# update ("Failed to relaunch after update" is in the binary) — inside a live
# `agy acp` session that would swap the binary under us and break the pin.
# AGY_CLI_DISABLE_AUTO_UPDATE=1 (an env knob the binary reads; undocumented)
# turns it off for every box process, and the binary is root-owned 0755 so a
# stray update attempt can't rewrite it either. Upgrades ship with the image.
ARG AGY_RELEASE=1.5.2-4951595792859136
ARG AGY_SHA512_AMD64=aa876c0813572efaf85ed47983cc937414705d40490cbdb54f6e255656a4f8744ae38e457823e924fa8270b348124e0521d3cda3fec6824bb2d14b98a7a05baa
ARG AGY_SHA512_ARM64=9a141da7b218c640a962188b388905f065cc307425bbeff6e74c07f95266f93526f6d0dee9bad876b79431403f5b30ef4e6514f5f44fbf06dca2a077f54f6175
ENV AGY_CLI_DISABLE_AUTO_UPDATE=1
RUN set -eu; \
case "$(dpkg --print-architecture)" in \
amd64) dir=linux-x64; file=cli_linux_x64.tar.gz; sha="${AGY_SHA512_AMD64}" ;; \
arm64) dir=linux-arm; file=cli_linux_arm64.tar.gz; sha="${AGY_SHA512_ARM64}" ;; \
*) echo "agy: unsupported arch $(dpkg --print-architecture)" >&2; exit 1 ;; \
esac; \
curl -fsSL --connect-timeout 15 --max-time 300 \
"https://storage.googleapis.com/antigravity-public/antigravity-cli/${AGY_RELEASE}/${dir}/${file}" \
-o /tmp/agy.tar.gz; \
echo "${sha} /tmp/agy.tar.gz" | sha512sum -c -; \
tar -xzf /tmp/agy.tar.gz -C /tmp antigravity; \
install -m 0755 /tmp/antigravity /usr/local/bin/agy; \
rm -f /tmp/agy.tar.gz /tmp/antigravity; \
agy --version

# ── aider (pip) ────────────────────────────────────────────────────────────
# Debian's system Python is PEP-668 "externally managed" → --break-system-packages
# (the container is single-user; no other Python consumer to protect).
Expand Down
170 changes: 170 additions & 0 deletions apps/cli/__tests__/agents/antigravity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* Antigravity CLI (`agy`) — the four seams a new agent breaks at: ACP dispatch,
* self-hosted credential delivery (both auth kinds), the headless one-shot
* (Preview detection + AI summaries), and `codeam link` token validation.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
getAcpAdapter,
requiresAcp,
resetAcpAdapterCacheForTests,
} from '../../src/agents/acp/adapters';
import { provisionAgentCredentials } from '../../src/commands/host/agent-provisioning';
import { AntigravityRuntimeStrategy } from '../../src/agents/antigravity/runtime';
import { validateLocalAntigravityToken } from '../../src/agents/antigravity/local-token';
import type { OsStrategy } from '../../src/os';

const OAUTH_BLOB = JSON.stringify({
token: {
access_token: 'ya29.test',
token_type: 'Bearer',
refresh_token: '1//refresh',
expiry: '2026-11-06T13:00:00Z',
},
auth_method: 'consumer',
});

let tmp: string;
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codeam-agy-'));
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});

const tokenFile = (): string =>
path.join(tmp, '.gemini', 'antigravity-cli', 'antigravity-oauth-token');
const settingsFile = (): string => path.join(tmp, '.gemini', 'antigravity-cli', 'settings.json');
const readSettings = (): Record<string, unknown> =>
JSON.parse(fs.readFileSync(settingsFile(), 'utf8')) as Record<string, unknown>;

describe('antigravity ACP adapter', () => {
beforeEach(() => resetAcpAdapterCacheForTests());

it('runs over ACP via the native `agy acp` server', () => {
expect(requiresAcp('antigravity')).toBe(true);
const spec = getAcpAdapter('antigravity');
expect(spec?.command).toBe('agy');
expect(spec?.requiresAgentBinary).toBe('agy');
});

it('does NOT skip permissions — our runner answers session/request_permission', () => {
expect(getAcpAdapter('antigravity')?.args).toEqual(['acp']);
});
});

describe('antigravity self-hosted credential provisioning', () => {
it('oauth_token: writes the blob verbatim to agy’s token file, 0600, no env', () => {
const env = provisionAgentCredentials('antigravity', { kind: 'oauth_token', value: OAUTH_BLOB }, tmp);
expect(env).toEqual({});
expect(fs.readFileSync(tokenFile(), 'utf8')).toBe(OAUTH_BLOB);
if (process.platform !== 'win32') {
expect(fs.statSync(tokenFile()).mode & 0o777).toBe(0o600);
}
});

it('oauth_token: rejects a non-JSON or token-less blob instead of writing it', () => {
expect(() =>
provisionAgentCredentials('antigravity', { kind: 'oauth_token', value: 'ya29.bare' }, tmp),
).toThrow(/not valid JSON/);
expect(() =>
provisionAgentCredentials(
'antigravity',
{ kind: 'oauth_token', value: JSON.stringify({ access_token: 'flat' }) },
tmp,
),
).toThrow(/token\.access_token/);
expect(fs.existsSync(tokenFile())).toBe(false);
});

it('api_key: exports GEMINI_API_KEY AND sets modelProvider=gemini (env alone does nothing)', () => {
const env = provisionAgentCredentials('antigravity', { kind: 'api_key', value: 'AIzaTest' }, tmp);
expect(env).toEqual({ GEMINI_API_KEY: 'AIzaTest' });
expect(readSettings()).toEqual({ modelProvider: 'gemini' });
if (process.platform !== 'win32') {
expect(fs.statSync(settingsFile()).mode & 0o777).toBe(0o600);
}
});

it('api_key: merges into an existing settings.json and removes a shadowing OAuth login', () => {
provisionAgentCredentials('antigravity', { kind: 'oauth_token', value: OAUTH_BLOB }, tmp);
fs.writeFileSync(settingsFile(), JSON.stringify({ theme: 'dark' }));
provisionAgentCredentials('antigravity', { kind: 'api_key', value: 'AIzaTest' }, tmp);
expect(fs.existsSync(tokenFile())).toBe(false);
expect(readSettings()).toEqual({ theme: 'dark', modelProvider: 'gemini' });
});

it('switching back to oauth_token drops modelProvider and keeps the other settings', () => {
provisionAgentCredentials('antigravity', { kind: 'api_key', value: 'AIzaTest' }, tmp);
fs.writeFileSync(settingsFile(), JSON.stringify({ theme: 'dark', modelProvider: 'gemini' }));
provisionAgentCredentials('antigravity', { kind: 'oauth_token', value: OAUTH_BLOB }, tmp);
expect(readSettings()).toEqual({ theme: 'dark' });
expect(fs.readFileSync(tokenFile(), 'utf8')).toBe(OAUTH_BLOB);
});
});

describe.skipIf(process.platform === 'win32')('AntigravityRuntimeStrategy.generateOneShot', () => {
/** A fake `agy` on disk: an executable shell script with the given body. */
function fakeAgy(body: string): string {
const bin = path.join(tmp, 'agy');
fs.writeFileSync(bin, `#!/bin/sh\n${body}\n`, { mode: 0o755 });
return bin;
}
const osWith = (bin: string | null): OsStrategy =>
({
id: 'linux',
findInPath: () => bin,
buildLaunch: (cmd: string, args: string[]) => ({ cmd, args }),
}) as unknown as OsStrategy;

it('runs `agy -p <prompt> --print-timeout <n>s` and returns its stdout', async () => {
// Echo argv back, one arg per line, so the test sees exactly what agy got.
const bin = fakeAgy('for a in "$@"; do echo "$a"; done');
const out = await new AntigravityRuntimeStrategy(osWith(bin)).generateOneShot('detect it', {
timeoutMs: 30_000,
});
expect(out?.split('\n')).toEqual(['-p', 'detect it', '--print-timeout', '30s']);
});

it('returns null when agy hangs (signed out, waiting for a pasted code)', async () => {
const bin = fakeAgy('sleep 30');
const out = await new AntigravityRuntimeStrategy(osWith(bin)).generateOneShot('x', {
timeoutMs: 1,
});
expect(out).toBeNull();
}, 15_000);

it('returns null and surfaces stdout when agy exits non-zero', async () => {
const bin = fakeAgy('echo "API_KEY_INVALID"; exit 1');
let failed = '';
const out = await new AntigravityRuntimeStrategy(osWith(bin)).generateOneShot('x', {
onFailedOutput: (s) => {
failed = s;
},
});
expect(out).toBeNull();
expect(failed).toBe('API_KEY_INVALID');
});

it('returns null when agy is not installed', async () => {
expect(await new AntigravityRuntimeStrategy(osWith(null)).generateOneShot('x')).toBeNull();
});
});

describe('validateLocalAntigravityToken (codeam link)', () => {
it('a blob with a refresh token is valid even past its access-token expiry', () => {
expect(validateLocalAntigravityToken({ method: 'oauth', credential: OAUTH_BLOB, source: 'flat-file' }).status).toBe('valid');
});

it('a refresh-less blob past its expiry is expired', () => {
const credential = JSON.stringify({ token: { access_token: 'a', expiry: '2020-01-01T00:00:00Z' } });
expect(validateLocalAntigravityToken({ method: 'oauth', credential, source: 'flat-file' }).status).toBe('expired');
});

it('an API key is valid when present', () => {
expect(validateLocalAntigravityToken({ method: 'api_key', credential: 'AIza', source: 'flat-file' }).status).toBe('valid');
});
});
44 changes: 44 additions & 0 deletions apps/cli/__tests__/box/agy-version-pin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';

/**
* The Box image bakes Antigravity CLI (`agy`) at an EXACT release, sha512-
* verified, into /usr/local/bin (outside the runtime-shadowed /home/box), with
* its background self-update turned off. The official install.sh always pulls
* the LATEST manifest and agy relaunches itself after an update, so without
* these a rebuild floats and a live `agy acp` session can have its binary
* swapped underneath it.
*/
const ROOT = path.resolve(__dirname, '../../../..');
const dockerfile = fs.readFileSync(path.join(ROOT, 'apps/box/Dockerfile'), 'utf8');

function arg(name: string): string {
return new RegExp(`^ARG ${name}=(\\S+)$`, 'm').exec(dockerfile)?.[1] ?? '';
}

describe('Antigravity CLI (agy) pin in the Box image', () => {
it('pins an exact release and a sha512 per architecture', () => {
expect(arg('AGY_RELEASE')).toMatch(/^\d+\.\d+\.\d+-\d+$/);
expect(arg('AGY_SHA512_AMD64')).toMatch(/^[0-9a-f]{128}$/);
expect(arg('AGY_SHA512_ARM64')).toMatch(/^[0-9a-f]{128}$/);
});

it('downloads through the pinned release and verifies the checksum', () => {
expect(dockerfile).toContain(
'https://storage.googleapis.com/antigravity-public/antigravity-cli/${AGY_RELEASE}/',
);
expect(dockerfile).toMatch(/sha512sum -c -/);
// Never the floating installer, which always resolves the latest manifest.
expect(dockerfile).not.toContain('antigravity.google/cli/install.sh');
});

it('installs to /usr/local/bin, not under the shadowed /home/box volume', () => {
expect(dockerfile).toContain('install -m 0755 /tmp/antigravity /usr/local/bin/agy');
expect(dockerfile).toMatch(/agy --version/);
});

it('disables the background self-update for every box process', () => {
expect(dockerfile).toMatch(/^ENV AGY_CLI_DISABLE_AUTO_UPDATE=1$/m);
});
});
4 changes: 4 additions & 0 deletions apps/cli/__tests__/fixtures/agent-install-cases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export const AGENT_INSTALL_CASES: Record<string, AgentCase> = {
note: 'curl installer → ~/.local/bin; adapter spec is cached with the BARE name pre-install',
},
opencode: { kind: 'real', note: 'curl installer → ~/.opencode/bin (off PATH by construction)' },
antigravity: {
kind: 'real',
note: 'no-sudo curl installer → ~/.local/bin/agy (glibc/musl build picked by the script)',
},
aider: {
kind: 'real',
note: 'pip falls back to a --user install → ~/.local/bin; probed via the runtime prepareLaunch',
Expand Down
5 changes: 4 additions & 1 deletion apps/cli/__tests__/host/agent-provisioning.matrix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ function sampleValue(agentId: string, kind: AgentAuthKind): string {
if (kind === 'api_key') return 'sk-test-key';
if (agentId === 'claude') return 'sk-ant-oat01-test';
if (agentId === 'cursor') return JSON.stringify({ accessToken: 'a', refreshToken: 'r', userId: 'u' });
if (agentId === 'antigravity') {
return JSON.stringify({ token: { access_token: 'a', refresh_token: 'r' }, auth_method: 'consumer' });
}
if (agentId === 'codex') {
return JSON.stringify({ auth_mode: 'chatgpt', tokens: { id_token: 'x', access_token: 'a', refresh_token: 'r', account_id: 'acc' } });
}
Expand All @@ -54,7 +57,7 @@ describe('provisionAgentCredentials — every enabled agent × auth kind is prov
);

it('covers the agents users can deploy today', () => {
expect(enabled.map((m) => m.id)).toEqual(expect.arrayContaining(['claude', 'codex', 'aider', 'opencode']));
expect(enabled.map((m) => m.id)).toEqual(expect.arrayContaining(['claude', 'codex', 'aider', 'opencode', 'antigravity']));
});

it.each(rows)('%s / %s does not throw UnsupportedAgentError', (agentId, kind) => {
Expand Down
25 changes: 24 additions & 1 deletion apps/cli/src/agents/acp/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
} from './agent-binary';
import { ensureKimiInstalled, augmentKimiPath } from '../kimi/installer';
import { ensureOpencodeInstalled, augmentOpencodePath } from '../opencode/installer';
import { ensureAntigravityInstalled } from '../antigravity/installer';
import { isLocalSession } from '../../baton/gate';

// CommonJS module — `require` is already in scope. Aliased so we
Expand Down Expand Up @@ -295,6 +296,28 @@ const REGISTRY: Partial<Record<AgentId, () => AdapterSpec | null>> = {
return waitForCommandOnPath('opencode', o);
},
}),
// Antigravity CLI speaks ACP natively via `agy acp` (official since agy 1.5.0,
// 2026-11-04): protocolVersion 1, `loadSession: true`. Same AdapterSpec shape
// as gemini/cursor/kimi. Auth reaches agy through the files the provisioner
// writes under ~/.gemini/antigravity-cli/ (+ GEMINI_API_KEY via the ACP
// client's `env: { ...process.env, ...extraEnv }` spawn).
//
// ⚠️ NO `--dangerously-skip-permissions`: no other adapter skips permissions —
// agy sends normal `session/request_permission` requests and OUR runner
// answers them (auto-approve on codespace/self-hosted, the mobile prompt on a
// local pair; see `permission-gate.ts`), exactly as for every other agent.
antigravity: () => ({
command: 'agy',
args: ['acp'],
requiresAgentBinary: 'agy',
waitForBinary: async (o) => {
if (isLocalSession()) await ensureAntigravityInstalled();
// install.sh drops agy in ~/.local/bin — fold it into a stale PATH (an
// in-session switch installs mid-run). The box bakes /usr/local/bin/agy.
augmentUserLocalBinPaths();
return waitForCommandOnPath('agy', o);
},
}),
};

/**
Expand Down Expand Up @@ -348,7 +371,7 @@ export function getAcpAdapter(agent: AgentId): AdapterSpec | null {
* Pure, STATIC dispatch predicate: does this agent run over ACP?
*
* `true` ⇒ ACP is the agent's ONLY launch path (claude / codex /
* gemini / cursor / kimi today). There is no env flag and no PTY
* gemini / cursor / kimi / opencode / antigravity today). There is no env flag and no PTY
* fallback for these agents — the session must run over the typed
* protocol or fail loudly. `false` ⇒ the agent has no ACP adapter and
* runs over the legacy PTY runtime (aider, coderabbit).
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/agents/acp/agent-binary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,8 @@ export interface AugmentUserLocalBinPathsDeps {
* (mirrors `kimi/installer.ts` `kimiBinDir`).
* - opencode → `$OPENCODE_HOME/bin`, default `~/.opencode/bin`
* (mirrors `opencode/installer.ts` `opencodeBinDir`).
* - antigravity → official install.sh → `~/.local/bin/agy` (box image:
* `/usr/local/bin/agy`, already on PATH).
* - aider → pip `--user` → `~/.local/bin`.
* - coderabbit → vendor script → `~/.local/bin`.
*/
Expand Down
Loading
Loading