mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-16 14:53:19 -07:00
feat(workspaces): implement secure GitHub PAT injection via memory-only mounts
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
} from '@google-gemini-cli-core';
|
||||
import { exitCli } from '../utils.js';
|
||||
import chalk from 'chalk';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
interface ConnectArgs {
|
||||
config?: Config;
|
||||
@@ -21,6 +22,7 @@ interface ConnectArgs {
|
||||
forwardAgent?: boolean;
|
||||
wait?: boolean;
|
||||
sync?: boolean;
|
||||
githubPat?: string;
|
||||
}
|
||||
|
||||
async function waitForReady(client: WorkspaceHubClient, id: string): Promise<WorkspaceHubInfo> {
|
||||
@@ -42,6 +44,14 @@ async function waitForReady(client: WorkspaceHubClient, id: string): Promise<Wor
|
||||
throw new Error(`Timeout waiting for workspace ${id} to become READY.`);
|
||||
}
|
||||
|
||||
function getGitHubToken(): string | null {
|
||||
try {
|
||||
return execSync('gh auth token', { encoding: 'utf8' }).trim();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function connectToWorkspace(args: ArgumentsCamelCase<ConnectArgs>): Promise<void> {
|
||||
if (!args.config) {
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -57,7 +67,6 @@ export async function connectToWorkspace(args: ArgumentsCamelCase<ConnectArgs>):
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(chalk.yellow(`Fetching workspace details for "${args.id}"...`));
|
||||
|
||||
// We need to fetch the workspace info to get the instance name and zone
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const workspaces: WorkspaceHubInfo[] = await client.listWorkspaces();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
@@ -86,6 +95,7 @@ export async function connectToWorkspace(args: ArgumentsCamelCase<ConnectArgs>):
|
||||
const { instance_name: instanceName, zone } = readyWs;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const project = process.env['GOOGLE_CLOUD_PROJECT'] || 'dev-project';
|
||||
const ssh = new SSHService();
|
||||
|
||||
// 1. Sync settings if enabled
|
||||
if (args.sync !== false) {
|
||||
@@ -106,9 +116,22 @@ export async function connectToWorkspace(args: ArgumentsCamelCase<ConnectArgs>):
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Connect via SSH
|
||||
const ssh = new SSHService();
|
||||
// 2. Inject GitHub PAT if available
|
||||
const pat = args.githubPat || getGitHubToken();
|
||||
if (pat) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(chalk.yellow('Injecting GitHub credentials...'));
|
||||
try {
|
||||
await ssh.pushSecret({ instanceName, zone, project }, '.gh_token', pat);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(chalk.green('✓ Credentials injected.'));
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(chalk.red('Warning: Failed to inject GitHub credentials.'), (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Connect via SSH
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(chalk.green(`🚀 Teleporting to ${instanceName} (${zone})...`));
|
||||
|
||||
@@ -155,6 +178,10 @@ export const connectCommand: CommandModule<object, ConnectArgs> = {
|
||||
type: 'boolean',
|
||||
describe: 'Synchronize local ~/.gemini settings to the remote workspace',
|
||||
default: true,
|
||||
})
|
||||
.option('github-pat', {
|
||||
type: 'string',
|
||||
describe: 'GitHub Personal Access Token to inject',
|
||||
}),
|
||||
handler: async (argv) => {
|
||||
await connectToWorkspace(argv);
|
||||
|
||||
@@ -49,7 +49,7 @@ describe('SSHService', () => {
|
||||
'--tunnel-through-iap',
|
||||
'--ssh-flag=-A',
|
||||
],
|
||||
expect.objectContaining({ stdio: 'inherit' })
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
});
|
||||
|
||||
@@ -67,4 +67,34 @@ describe('SSHService', () => {
|
||||
|
||||
await expect(promise).rejects.toThrow('gcloud ssh exited with code 1');
|
||||
});
|
||||
|
||||
it('should construct correct gcloud command for pushSecret', async () => {
|
||||
const mockChild = new EventEmitter() as any;
|
||||
vi.mocked(spawn).mockReturnValue(mockChild);
|
||||
|
||||
const secretValue = 'secret-val';
|
||||
const promise = service.pushSecret(
|
||||
{ instanceName: 'test-inst', zone: 'z1', project: 'p1' },
|
||||
'.gh_token',
|
||||
secretValue
|
||||
);
|
||||
|
||||
setTimeout(() => mockChild.emit('exit', 0), 10);
|
||||
|
||||
await promise;
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'gcloud',
|
||||
[
|
||||
'compute',
|
||||
'ssh',
|
||||
'test-inst',
|
||||
'--zone=z1',
|
||||
'--project=p1',
|
||||
'--tunnel-through-iap',
|
||||
'--command',
|
||||
`cat << 'EOF' > /dev/shm/.gh_token\n${secretValue}\nEOF\nchmod 600 /dev/shm/.gh_token`,
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,4 +66,41 @@ export class SSHService {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes a secret string to a temporary, memory-only file on the remote VM.
|
||||
* Uses gcloud compute ssh with a heredoc to write to /dev/shm.
|
||||
*/
|
||||
async pushSecret(options: SSHOptions, secretName: string, secretValue: string): Promise<void> {
|
||||
const { instanceName, zone, project } = options;
|
||||
const remotePath = `/dev/shm/${secretName}`;
|
||||
|
||||
// Command to write secret securely without it appearing in process list
|
||||
const remoteCommand = `cat << 'EOF' > ${remotePath}
|
||||
${secretValue}
|
||||
EOF
|
||||
chmod 600 ${remotePath}`;
|
||||
|
||||
const args = [
|
||||
'compute',
|
||||
'ssh',
|
||||
instanceName,
|
||||
`--zone=${zone}`,
|
||||
`--project=${project}`,
|
||||
'--tunnel-through-iap',
|
||||
'--command',
|
||||
remoteCommand,
|
||||
];
|
||||
|
||||
debugLogger.log(`[SSHService] Pushing secret ${secretName} to ${instanceName}...`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('gcloud', args);
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`Failed to push secret ${secretName}, exit code ${code}`));
|
||||
});
|
||||
child.on('error', (err) => reject(err));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ set -e
|
||||
if [ -f /dev/shm/.gh_token ]; then
|
||||
export GH_TOKEN=$(cat /dev/shm/.gh_token)
|
||||
echo "GitHub token injected from memory."
|
||||
# Authenticate gh CLI if possible
|
||||
if command -v gh >/dev/null 2>&1; then
|
||||
echo "$GH_TOKEN" | gh auth login --with-token
|
||||
echo "GitHub CLI authenticated."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start shpool daemon in the background and verify it stays up
|
||||
|
||||
Reference in New Issue
Block a user