From 1ccd86cfd7b8b0700ec4640742ccae793e08c10e Mon Sep 17 00:00:00 2001 From: mkorwel Date: Thu, 19 Mar 2026 09:20:52 -0700 Subject: [PATCH] feat(workspaces): implement secure GitHub PAT injection via memory-only mounts --- .../cli/src/commands/workspace/connect.ts | 33 +++++++++++++++-- packages/core/src/services/sshService.test.ts | 32 +++++++++++++++- packages/core/src/services/sshService.ts | 37 +++++++++++++++++++ .../workspace-manager/docker/entrypoint.sh | 5 +++ 4 files changed, 103 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/workspace/connect.ts b/packages/cli/src/commands/workspace/connect.ts index 2d1edff3b1..91d5bb8259 100644 --- a/packages/cli/src/commands/workspace/connect.ts +++ b/packages/cli/src/commands/workspace/connect.ts @@ -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 { @@ -42,6 +44,14 @@ async function waitForReady(client: WorkspaceHubClient, id: string): Promise): Promise { if (!args.config) { // eslint-disable-next-line no-console @@ -57,7 +67,6 @@ export async function connectToWorkspace(args: ArgumentsCamelCase): // 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): 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): } } - // 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 = { 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); diff --git a/packages/core/src/services/sshService.test.ts b/packages/core/src/services/sshService.test.ts index c13fcd1029..caffad5f9e 100644 --- a/packages/core/src/services/sshService.test.ts +++ b/packages/core/src/services/sshService.test.ts @@ -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`, + ] + ); + }); }); diff --git a/packages/core/src/services/sshService.ts b/packages/core/src/services/sshService.ts index bb05ac2ae0..2eec3453a0 100644 --- a/packages/core/src/services/sshService.ts +++ b/packages/core/src/services/sshService.ts @@ -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 { + 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)); + }); + } } diff --git a/packages/workspace-manager/docker/entrypoint.sh b/packages/workspace-manager/docker/entrypoint.sh index 41c3e6c8f1..1c9e5a3258 100644 --- a/packages/workspace-manager/docker/entrypoint.sh +++ b/packages/workspace-manager/docker/entrypoint.sh @@ -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