feat(workspaces): implement secure GitHub PAT injection via memory-only mounts

This commit is contained in:
mkorwel
2026-03-19 09:20:52 -07:00
parent a7dd0ac571
commit 1ccd86cfd7
4 changed files with 103 additions and 4 deletions
+30 -3
View File
@@ -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);
+31 -1
View File
@@ -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`,
]
);
});
});
+37
View File
@@ -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