// docs-guard-exempt: docs/cli/... substrings are external antigravity.google URL citations in comments, not repo paths. 'use strict'; /** * antigravity capability UPGRADES — ADR-1239 Phase B / #2096 (EoS/antigravity). * * Drives the user-reachable surface (spawned `bin/install.js` via * `runMinimalInstall`) plus targeted unit coverage to prove the two real * upgrades Antigravity contributes as part of the imperative-adapter * migration: * * UPGRADE 1 — permission-writer: `configureAntigravityPermissions` writes * Antigravity's native `{"permissions":{"allow":[...]}}` schema * (antigravity.google/docs/cli/permissions) into the SAME settings.json * MSD's own hook registration writes, non-destructively appending MSD's own * read_file/command allow rules while preserving any existing user * permissions (allow/deny/ask). Uninstall removes only the MSD-owned rules. * * UPGRADE 2 — MCP companion config: `configureAntigravityMcpConfig` writes * a standalone `mcp_config.json` (antigravity.google/docs/cli/gcli-migration) * registering the `msd` MCP server (`bin/msd-mcp-server.js`, the SAME * companion OpenCode documents/wires), non-destructively preserving any * other user-configured `mcpServers` entries. Uninstall removes only the * `msd` entry. * * Both writers are NOT MSD_TEST_MODE-gated (unlike * OpenCode's) — runMinimalInstall strips MSD_TEST_MODE from the spawned * installer's env, so both fire during a "minimal" install too. */ const { test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { spawnSync } = require('node:child_process'); const { runNode } = require('./helpers/process-seam.cjs'); const { runMinimalInstall, installerEnv, INSTALL_SCRIPT } = require('./helpers/install-shared.cjs'); const { cleanup } = require('./helpers.cjs'); const { PROBE_TIMEOUT_MS, INSTALL_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const { toTildePosixPath, buildAntigravityAllowRules, configureAntigravityPermissions, configureAntigravityMcpConfig, } = require('../bin/install.js'); const { PROTOCOL_VERSION } = require('../msd-core/bin/lib/mcp-server.cjs'); const { PACKAGE_NAME } = require('../msd-core/bin/lib/package-identity.cjs'); const MCP_SERVER_BIN = path.join(__dirname, '..', 'bin', 'msd-mcp-server.js'); const ANTIGRAVITY_CAP = JSON.parse( fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'antigravity', 'capability.json'), 'utf8'), ); // --------------------------------------------------------------------------- // Descriptor boundary — permissionWriter wiring // --------------------------------------------------------------------------- test('capabilities/antigravity/capability.json declares runtime.permissionWriter: "antigravity"', () => { assert.equal(ANTIGRAVITY_CAP.runtime.permissionWriter, 'antigravity'); }); // --------------------------------------------------------------------------- // UPGRADE 1: permission-writer unit coverage (toTildePosixPath / rule builder) // --------------------------------------------------------------------------- test('toTildePosixPath collapses a homedir-rooted path to "~/..." form', () => { const home = require('node:os').homedir(); assert.equal(toTildePosixPath(path.join(home, '.gemini', 'antigravity')), '~/.gemini/antigravity'); }); test('toTildePosixPath leaves a non-homedir path as an absolute posix path', () => { assert.equal(toTildePosixPath('/var/tmp/some-config-dir'), '/var/tmp/some-config-dir'); }); test('buildAntigravityAllowRules emits the 4 documented "action(target)" rule strings', () => { const rules = buildAntigravityAllowRules('/tmp/ag-config'); assert.deepEqual(rules, [ 'read_file(/tmp/ag-config/msd-core/*)', 'read_file(/tmp/ag-config/agents/msd-*)', 'read_file(/tmp/ag-config/skills/msd-*)', 'command(node /tmp/ag-config/hooks/*)', ]); }); // --------------------------------------------------------------------------- // UPGRADE 1: live install — settings.json permissions.allow (both scopes) // --------------------------------------------------------------------------- for (const scope of ['global', 'local']) { test(`antigravity --${scope}: settings.json permissions.allow contains MSD's rules (UPGRADE 1)`, (t) => { const { configDir, root } = runMinimalInstall({ runtime: 'antigravity', scope }); t.after(() => cleanup(root)); const settingsPath = path.join(configDir, 'settings.json'); assert.ok(fs.existsSync(settingsPath), `${settingsPath} must exist`); const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); assert.ok(settings.permissions && Array.isArray(settings.permissions.allow), 'permissions.allow must be an array'); // Asserting on the exact rule strings here would require reproducing // toTildePosixPath's os.homedir()-relative substitution from OUTSIDE the // spawned installer subprocess: runMinimalInstall overrides that // subprocess's HOME to `root`, so for --global (configDir === root === // that subprocess's HOME) every rule collapses to the bare `~/...` form, // while THIS test process's own (unrelated, real) os.homedir() would // reconstruct full absolute paths instead — a guaranteed mismatch that // has nothing to do with correctness. Assert on the documented rule // SHAPE (action + path suffix) instead, which holds regardless of // whether the prefix collapsed to `~` or stayed a full absolute path. const expectedShapes = [ { action: 'read_file(', suffix: '/msd-core/*)' }, { action: 'read_file(', suffix: '/agents/msd-*)' }, { action: 'read_file(', suffix: '/skills/msd-*)' }, { action: 'command(node ', suffix: '/hooks/*)' }, ]; for (const { action, suffix } of expectedShapes) { const found = settings.permissions.allow.some((rule) => rule.startsWith(action) && rule.endsWith(suffix)); assert.ok(found, `permissions.allow must include a rule shaped like "${action}...${suffix}" — got ${JSON.stringify(settings.permissions.allow)}`); } // Priority-scoping (deny/ask) is a user-owned decision — MSD never writes it. assert.equal(settings.permissions.deny, undefined, 'MSD must never write permissions.deny'); assert.equal(settings.permissions.ask, undefined, 'MSD must never write permissions.ask'); }); } test('configureAntigravityPermissions is idempotent — a second call adds no duplicate rules', (t) => { const root = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'msd-ag-perm-idem-')); t.after(() => cleanup(root)); configureAntigravityPermissions(true, root); configureAntigravityPermissions(true, root); const settings = JSON.parse(fs.readFileSync(path.join(root, 'settings.json'), 'utf8')); assert.equal(settings.permissions.allow.length, new Set(settings.permissions.allow).size, 'no duplicate allow entries'); assert.equal(settings.permissions.allow.length, buildAntigravityAllowRules(root).length); }); test('configureAntigravityPermissions preserves a pre-existing user permissions block (allow/deny/ask)', (t) => { const root = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'msd-ag-perm-preserve-')); t.after(() => cleanup(root)); fs.writeFileSync(path.join(root, 'settings.json'), JSON.stringify({ permissions: { allow: ['command(git)'], deny: ['command(rm -rf)'], ask: ['command(*)'], }, userCustomField: 'preserve-me', }, null, 2)); configureAntigravityPermissions(true, root); const settings = JSON.parse(fs.readFileSync(path.join(root, 'settings.json'), 'utf8')); assert.ok(settings.permissions.allow.includes('command(git)'), 'pre-existing allow entry preserved'); assert.deepEqual(settings.permissions.deny, ['command(rm -rf)'], 'deny block untouched'); assert.deepEqual(settings.permissions.ask, ['command(*)'], 'ask block untouched'); assert.equal(settings.userCustomField, 'preserve-me', 'unrelated top-level user fields preserved'); for (const rule of buildAntigravityAllowRules(root)) { assert.ok(settings.permissions.allow.includes(rule)); } }); // --------------------------------------------------------------------------- // UPGRADE 2: MCP companion config — live install (both scopes) // --------------------------------------------------------------------------- for (const scope of ['global', 'local']) { test(`antigravity --${scope}: mcp_config.json registers the msd MCP companion (UPGRADE 2)`, (t) => { const { configDir, root } = runMinimalInstall({ runtime: 'antigravity', scope }); t.after(() => cleanup(root)); const mcpConfigPath = path.join(configDir, 'mcp_config.json'); assert.ok(fs.existsSync(mcpConfigPath), `${mcpConfigPath} must exist`); const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8')); assert.ok(mcpConfig.mcpServers && mcpConfig.mcpServers.msd, 'mcpServers.msd must be present'); assert.equal(mcpConfig.mcpServers.msd.command, 'npx'); assert.deepEqual(mcpConfig.mcpServers.msd.args, ['-y', '-p', PACKAGE_NAME, 'msd-mcp-server']); }); } test('configureAntigravityMcpConfig is idempotent — a second call does not clobber an existing msd entry', (t) => { const root = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'msd-ag-mcp-idem-')); t.after(() => cleanup(root)); configureAntigravityMcpConfig(true, root); // Simulate a user hand-edit of the msd entry after install. const configPath = path.join(root, 'mcp_config.json'); const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); config.mcpServers.msd.args.push('--custom-flag'); fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); configureAntigravityMcpConfig(true, root); const after = JSON.parse(fs.readFileSync(configPath, 'utf8')); assert.ok(after.mcpServers.msd.args.includes('--custom-flag'), 'a user-owned msd override is never clobbered (Hyrum\'s Law)'); }); test('configureAntigravityMcpConfig preserves a pre-existing unrelated mcpServers entry', (t) => { const root = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'msd-ag-mcp-preserve-')); t.after(() => cleanup(root)); fs.writeFileSync(path.join(root, 'mcp_config.json'), JSON.stringify({ mcpServers: { 'my-own-server': { command: 'my-tool', args: ['--flag'] }, }, }, null, 2)); configureAntigravityMcpConfig(true, root); const config = JSON.parse(fs.readFileSync(path.join(root, 'mcp_config.json'), 'utf8')); assert.deepEqual(config.mcpServers['my-own-server'], { command: 'my-tool', args: ['--flag'] }); assert.ok(config.mcpServers.msd); }); // AC-style proof: the companion mcp_config.json points at (bin/msd-mcp-server.js) // is actually reachable, not just documented. Mirrors tests/msd-mcp-server-bin.test.cjs // exactly (same shim, same line-delimited JSON-RPC over stdio). test('UPGRADE 2: msd-mcp-server companion is reachable — spawn, initialize, tools/list over stdio', () => { const stdin = [ JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }), JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }), ].join('\n') + '\n'; const res = spawnSync(process.execPath, [MCP_SERVER_BIN], { input: stdin, encoding: 'utf-8', timeout: PROBE_TIMEOUT_MS, env: { ...process.env, MSD_TEST_MODE: '1' }, }); assert.strictEqual(res.status, 0, `msd-mcp-server must exit cleanly on stdin EOF; stderr: ${res.stderr}`); const lines = res.stdout.trim().split('\n').map((l) => JSON.parse(l)); assert.strictEqual(lines.length, 2, 'one response per request'); assert.strictEqual(lines[0].result.protocolVersion, PROTOCOL_VERSION, 'initialize handshake succeeds'); const toolNames = lines[1].result.tools.map((t) => t.name).sort(); assert.deepStrictEqual( toolNames, ['msd_invoke_command', 'msd_read_state', 'msd_write_state'], 'the companion the mcp_config.json entry connects to advertises the real MSD tool surface', ); }); // --------------------------------------------------------------------------- // Uninstall — symmetric cleanup for both upgrades // --------------------------------------------------------------------------- test('antigravity --global uninstall removes only MSD-owned permissions.allow rules + mcpServers.msd, preserving user data', (t) => { const root = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'msd-ag-uninstall-')); t.after(() => cleanup(root)); const args = [INSTALL_SCRIPT, '--antigravity', '--global', '--config-dir', root]; const installResult = runNode(args, { env: installerEnv({ HOME: root, USERPROFILE: root }), timeoutMs: INSTALL_TIMEOUT_MS, }); assert.strictEqual(installResult.exitCode, 0, `install failed: ${installResult.stderr}`); // Seed user-owned data alongside MSD's contributions, post-install. const settingsPath = path.join(root, 'settings.json'); const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); settings.permissions.allow.push('command(git)'); settings.permissions.deny = ['command(rm -rf)']; fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); const mcpConfigPath = path.join(root, 'mcp_config.json'); const mcpConfig = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8')); mcpConfig.mcpServers['my-own-server'] = { command: 'my-tool', args: [] }; fs.writeFileSync(mcpConfigPath, JSON.stringify(mcpConfig, null, 2) + '\n'); const uninstallArgs = [INSTALL_SCRIPT, '--antigravity', '--global', '--config-dir', root, '--uninstall']; const uninstallResult = runNode(uninstallArgs, { env: installerEnv({ HOME: root, USERPROFILE: root }), timeoutMs: INSTALL_TIMEOUT_MS, }); assert.strictEqual(uninstallResult.exitCode, 0, `uninstall failed: ${uninstallResult.stderr}`); const settingsAfter = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); assert.deepEqual(settingsAfter.permissions.allow, ['command(git)'], 'MSD allow rules removed, user rule preserved'); assert.deepEqual(settingsAfter.permissions.deny, ['command(rm -rf)'], 'user deny rule preserved'); const mcpConfigAfter = JSON.parse(fs.readFileSync(mcpConfigPath, 'utf8')); assert.equal(mcpConfigAfter.mcpServers.msd, undefined, 'msd MCP entry removed'); assert.deepEqual(mcpConfigAfter.mcpServers['my-own-server'], { command: 'my-tool', args: [] }, 'user MCP server preserved'); });