feat: GCE forever agent with Cloud Run bridge support

- Add gce-startup.sh: self-contained VM startup (install, build, configure, run)
- Add start-forever.js: dual-process launcher for local development
- Add npm scripts: start:forever, start:bridge
- Bind A2A listener to 0.0.0.0 (was 127.0.0.1) for remote bridge access
- Pre-configure all headless settings to bypass interactive dialogs
- Use .env file + script pseudo-TTY for systemd service
This commit is contained in:
Adam Weidman
2026-03-03 23:45:17 -05:00
parent 8caf7d5690
commit 7bc0515c7c
4 changed files with 275 additions and 1 deletions
+2
View File
@@ -19,6 +19,8 @@
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
"start:a2a-server": "CODER_AGENT_PORT=41242 npm run start --workspace @google/gemini-cli-a2a-server",
"start:forever": "node scripts/start-forever.js",
"start:bridge": "node packages/a2a-server/dist/src/chat-bridge/bridge.js",
"debug": "cross-env DEBUG=1 node --inspect-brk scripts/start.js",
"deflake": "node scripts/deflake.js",
"deflake:test:integration:sandbox:none": "npm run deflake -- --command=\"npm run test:integration:sandbox:none -- --retry=0\"",
+1 -1
View File
@@ -393,7 +393,7 @@ export function startExternalListener(options?: {
},
);
server.listen(port, '127.0.0.1', () => {
server.listen(port, '0.0.0.0', () => {
const address = server.address();
const actualPort =
typeof address === 'object' && address ? address.port : port;
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env bash
# GCE startup script — runs on VM boot.
# Installs Node.js, clones, builds, starts forever agent + bridge.
# Pass gemini-api-key via instance metadata.
set -euo pipefail
LOG="/var/log/forever-agent-startup.log"
exec > >(tee -a "$LOG") 2>&1
echo "=== Forever Agent startup $(date) ==="
# Read API key from instance metadata
GEMINI_API_KEY=$(curl -sf -H "Metadata-Flavor: Google" \
"http://metadata.google.internal/computeMetadata/v1/instance/attributes/gemini-api-key" || echo "")
CHAT_PROJECT_NUMBER=$(curl -sf -H "Metadata-Flavor: Google" \
"http://metadata.google.internal/computeMetadata/v1/instance/attributes/chat-project-number" || echo "")
if [ -z "$GEMINI_API_KEY" ]; then
echo "ERROR: gemini-api-key not set in instance metadata"
exit 1
fi
echo "API key loaded (${#GEMINI_API_KEY} chars)"
# Install Node.js + screen (first boot only)
if ! command -v node &>/dev/null; then
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs git screen
fi
echo "Node $(node --version)"
# Clone/update repo
REPO_DIR="/opt/forever-agent"
if [ ! -d "$REPO_DIR" ]; then
GIT_TERMINAL_PROMPT=0 git clone -b afw/forever-gchat \
https://github.com/google-gemini/gemini-cli.git "$REPO_DIR"
else
cd "$REPO_DIR" && GIT_TERMINAL_PROMPT=0 git pull --ff-only || true
fi
# npm install && npm run build
cd "$REPO_DIR"
npm install 2>&1 | tail -5
npm run build 2>&1 | tail -10
# Pre-create workspace and config to skip interactive prompts
WORK_DIR="/opt/forever-workspace"
mkdir -p "$WORK_DIR/.gemini"
mkdir -p /root/.gemini
# Pre-trust the workspace folder (skips "Do you trust this folder?" dialog)
# --yolo doesn't bypass folder trust, so we must pre-configure it
cat > /root/.gemini/trustedFolders.json << 'TRUSTEOF'
{
"/opt/forever-workspace": "TRUST_FOLDER"
}
TRUSTEOF
# Disable folder trust + skip session retention dialog
# Write to both user-level AND workspace-level to be safe
for SETTINGS_DIR in /root/.gemini "$WORK_DIR/.gemini"; do
mkdir -p "$SETTINGS_DIR"
cat > "$SETTINGS_DIR/settings.json" << 'SETTINGSEOF'
{
"security": {
"folderTrust": {
"enabled": false
},
"auth": {
"selectedType": "gemini-api-key",
"useExternal": true
}
},
"general": {
"sessionRetention": {
"enabled": true,
"maxAge": "30d",
"warningAcknowledged": true
}
}
}
SETTINGSEOF
done
cat > "$WORK_DIR/.gemini/GEMINI.md" << 'GEMINIEOF'
---
sisyphus:
enabled: true
idleTimeout: 30
prompt: "continue with the next task"
---
# Mission
You are a forever-running autonomous agent accessible via Google Chat.
Process incoming tasks, answer questions, and proactively work on improvements.
GEMINIEOF
# Create systemd service for the chat bridge
cat > /etc/systemd/system/chat-bridge.service << EOF
[Unit]
Description=Google Chat Bridge
After=network.target
[Service]
Type=simple
Environment=GOOGLE_API_KEY=${GEMINI_API_KEY}
Environment=CHAT_PROJECT_NUMBER=${CHAT_PROJECT_NUMBER}
Environment=A2A_PORT=3100
Environment=BRIDGE_PORT=8081
Environment=A2A_URL=http://127.0.0.1:3100
Environment=GIT_TERMINAL_PROMPT=0
WorkingDirectory=${REPO_DIR}
ExecStart=/usr/bin/node ${REPO_DIR}/packages/a2a-server/dist/src/chat-bridge/bridge.js
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
# Write env vars to a file — sourced by the wrapper script
cat > /etc/forever-agent.env << ENVEOF
export HOME=/root
export GEMINI_CLI_HOME=/root
export GOOGLE_API_KEY='${GEMINI_API_KEY}'
export GEMINI_API_KEY='${GEMINI_API_KEY}'
export A2A_PORT=3100
export GIT_TERMINAL_PROMPT=0
ENVEOF
chmod 600 /etc/forever-agent.env
# Create a wrapper script that sources env and uses 'script' for pseudo-TTY
cat > /usr/local/bin/start-forever-agent.sh << 'WRAPPEREOF'
#!/usr/bin/env bash
set -a
source /etc/forever-agent.env
set +a
# script provides a pseudo-TTY for Ink; output goes to /dev/null (TUI noise)
exec /usr/bin/script -qfc "node /opt/forever-agent/packages/cli/dist/index.js --forever --a2a-port 3100 --yolo" /dev/null
WRAPPEREOF
chmod +x /usr/local/bin/start-forever-agent.sh
# Create systemd service for the forever agent
cat > /etc/systemd/system/forever-agent.service << EOF
[Unit]
Description=Gemini CLI Forever Agent
After=network.target chat-bridge.service
[Service]
Type=simple
Environment=GOOGLE_API_KEY=${GEMINI_API_KEY}
Environment=A2A_PORT=3100
Environment=GIT_TERMINAL_PROMPT=0
Environment=HOME=/root
Environment=GEMINI_CLI_HOME=/root
WorkingDirectory=${WORK_DIR}
ExecStart=/usr/local/bin/start-forever-agent.sh
Restart=on-failure
RestartSec=10
StandardOutput=null
StandardError=journal
[Install]
WantedBy=multi-user.target
EOF
# Start both services
systemctl daemon-reload
systemctl enable chat-bridge forever-agent
systemctl start chat-bridge
sleep 2
systemctl start forever-agent
echo "=== Chat bridge started on port 8081 ==="
echo "=== Forever agent started in screen session ==="
echo "=== Startup complete $(date) ==="
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn } from 'node:child_process';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, '..');
const A2A_PORT = process.env.A2A_PORT || '3100';
const BRIDGE_PORT = process.env.BRIDGE_PORT || '8081';
// Ensure .gemini/GEMINI.md exists to skip onboarding dialog
const geminiDir = join(process.cwd(), '.gemini');
const geminiMd = join(geminiDir, 'GEMINI.md');
if (!existsSync(geminiMd)) {
mkdirSync(geminiDir, { recursive: true });
writeFileSync(
geminiMd,
`---
sisyphus:
enabled: true
idleTimeout: 30
prompt: "continue with the next task"
---
# Mission
You are a forever-running autonomous agent.
Process incoming tasks and answer questions.
`,
);
console.log(`Created ${geminiMd} (skip onboarding)`);
}
console.log('=== Gemini CLI Forever Agent ===');
console.log(`Agent listener: localhost:${A2A_PORT}`);
console.log(`Chat bridge: 0.0.0.0:${BRIDGE_PORT}`);
console.log('');
// Start the chat bridge
const bridgePath = join(
repoRoot,
'packages/a2a-server/dist/src/chat-bridge/bridge.js',
);
const bridge = spawn('node', [bridgePath], {
env: {
...process.env,
A2A_PORT,
BRIDGE_PORT,
A2A_URL: `http://127.0.0.1:${A2A_PORT}`,
},
stdio: 'inherit',
});
bridge.on('error', (err) => {
console.error(`Bridge failed to start: ${err.message}`);
});
// Start the forever agent
const cliPath = join(repoRoot, 'packages/cli/dist/index.js');
const agent = spawn(
'node',
[cliPath, '--forever', '--a2a-port', A2A_PORT, '--yolo'],
{
env: { ...process.env, A2A_PORT },
stdio: 'inherit',
},
);
agent.on('error', (err) => {
console.error(`Agent failed to start: ${err.message}`);
});
// Cleanup on exit
function cleanup() {
console.log('\nShutting down...');
bridge.kill();
agent.kill();
}
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
agent.on('exit', (code) => {
console.log(`Agent exited with code ${code}`);
bridge.kill();
process.exit(code || 0);
});
bridge.on('exit', (code) => {
console.log(`Bridge exited with code ${code}`);
});