diff --git a/packages/cli/src/commands/workspace/connect.ts b/packages/cli/src/commands/workspace/connect.ts index 7ddc01ddc9..2d1edff3b1 100644 --- a/packages/cli/src/commands/workspace/connect.ts +++ b/packages/cli/src/commands/workspace/connect.ts @@ -5,7 +5,13 @@ */ import type { CommandModule, ArgumentsCamelCase } from 'yargs'; -import { WorkspaceHubClient, SSHService, type Config, type WorkspaceHubInfo } from '@google-gemini-cli-core'; +import { + WorkspaceHubClient, + SSHService, + SyncService, + type Config, + type WorkspaceHubInfo +} from '@google-gemini-cli-core'; import { exitCli } from '../utils.js'; import chalk from 'chalk'; @@ -14,6 +20,7 @@ interface ConnectArgs { id: string; forwardAgent?: boolean; wait?: boolean; + sync?: boolean; } async function waitForReady(client: WorkspaceHubClient, id: string): Promise { @@ -42,6 +49,7 @@ export async function connectToWorkspace(args: ArgumentsCamelCase): return; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const hubUrl = process.env['GEMINI_WORKSPACE_HUB_URL'] || 'http://localhost:8080'; const client = new WorkspaceHubClient(hubUrl); @@ -49,7 +57,11 @@ export async function connectToWorkspace(args: ArgumentsCamelCase): // eslint-disable-next-line no-console console.log(chalk.yellow(`Fetching workspace details for "${args.id}"...`)); - let ws = await client.getWorkspace(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 + const ws: WorkspaceHubInfo | undefined = workspaces.find(w => w.id === args.id || w.name === args.id); if (!ws) { // eslint-disable-next-line no-console @@ -57,9 +69,10 @@ export async function connectToWorkspace(args: ArgumentsCamelCase): return; } + let readyWs = ws; if (ws.status !== 'READY') { if (args.wait) { - ws = await waitForReady(client, args.id); + readyWs = await waitForReady(client, args.id); } else { // eslint-disable-next-line no-console console.warn(chalk.yellow(`Warning: Workspace is in status ${ws.status}.`)); @@ -70,19 +83,41 @@ export async function connectToWorkspace(args: ArgumentsCamelCase): } } - const ssh = new SSHService(); - // TODO: Get project from config once GCP settings are integrated into core Config + const { instance_name: instanceName, zone } = readyWs; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const project = process.env['GOOGLE_CLOUD_PROJECT'] || 'dev-project'; + // 1. Sync settings if enabled + if (args.sync !== false) { + // eslint-disable-next-line no-console + console.log(chalk.yellow(`Syncing local settings (~/.gemini) to remote...`)); + const sync = new SyncService(); + try { + await sync.pushSettings({ + instanceName, + zone, + project, + }); + // eslint-disable-next-line no-console + console.log(chalk.green(`✓ Settings synced.`)); + } catch (err) { + // eslint-disable-next-line no-console + console.warn(chalk.red(`Warning: Settings sync failed, continuing connection...`), (err as Error).message); + } + } + + // 2. Connect via SSH + const ssh = new SSHService(); + // eslint-disable-next-line no-console - console.log(chalk.green(`🚀 Teleporting to ${ws.instance_name} (${ws.zone})...`)); + console.log(chalk.green(`🚀 Teleporting to ${instanceName} (${zone})...`)); // Command to run on the remote VM: attach to the shpool session const remoteCommand = 'shpool attach main || shpool attach'; await ssh.connect({ - instanceName: ws.instance_name, - zone: ws.zone, + instanceName, + zone, project, command: remoteCommand, forwardAgent: args.forwardAgent, @@ -115,6 +150,11 @@ export const connectCommand: CommandModule = { type: 'boolean', describe: 'Wait for the workspace to become READY if it is provisioning', default: false, + }) + .option('sync', { + type: 'boolean', + describe: 'Synchronize local ~/.gemini settings to the remote workspace', + default: true, }), handler: async (argv) => { await connectToWorkspace(argv); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 415ec0dec9..d5bb8cc930 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -135,6 +135,7 @@ export * from './services/keychainService.js'; export * from './services/keychainTypes.js'; export * from './services/workspaceHubClient.js'; export * from './services/sshService.js'; +export * from './services/syncService.js'; export * from './skills/skillManager.js'; export * from './skills/skillLoader.js'; diff --git a/packages/core/src/services/syncService.test.ts b/packages/core/src/services/syncService.test.ts new file mode 100644 index 0000000000..00f4f5bbf8 --- /dev/null +++ b/packages/core/src/services/syncService.test.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SyncService } from './syncService.js'; +import { spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), +})); + +vi.mock('../config/storage.js', () => ({ + Storage: { + getGlobalGeminiDir: vi.fn().mockReturnValue('/mock/local/dir'), + }, +})); + +describe('SyncService', () => { + let service: SyncService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new SyncService(); + }); + + it('should construct correct gcloud scp command', async () => { + const mockChild = new EventEmitter() as any; + vi.mocked(spawn).mockReturnValue(mockChild); + + const promise = service.pushSettings({ + instanceName: 'test-inst', + zone: 'us-west1-a', + project: 'test-project', + }); + + setTimeout(() => mockChild.emit('exit', 0), 10); + + await promise; + + expect(spawn).toHaveBeenCalledWith( + 'gcloud', + [ + 'compute', + 'scp', + '--recurse', + '/mock/local/dir', + 'test-inst:.gemini', + '--zone=us-west1-a', + '--project=test-project', + '--tunnel-through-iap', + ], + expect.any(Object) + ); + }); +}); diff --git a/packages/core/src/services/syncService.ts b/packages/core/src/services/syncService.ts new file mode 100644 index 0000000000..e6355db6db --- /dev/null +++ b/packages/core/src/services/syncService.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn } from 'node:child_process'; +import { debugLogger } from '../utils/debugLogger.js'; +import { Storage } from '../config/storage.js'; + +export interface SyncOptions { + instanceName: string; + zone: string; + project: string; +} + +export class SyncService { + /** + * Push local ~/.gemini directory to the remote workspace. + * Currently uses gcloud compute scp. + */ + async pushSettings(options: SyncOptions): Promise { + const { instanceName, zone, project } = options; + const localDir = Storage.getGlobalGeminiDir(); + + // We want to sync the contents of ~/.gemini to ~/.gemini on the remote. + // gcloud compute scp local-dir remote-instance:remote-dir + const remotePath = `${instanceName}:.gemini`; + + // Note: gcloud scp doesn't have a native "exclude" flag like rsync, + // so we might need to be selective or use a tarball approach if it's too slow. + // For v1, we just push the whole thing but excluding the 'tmp' and 'logs' folder if possible + // via a manual scp of subdirectories, or just the whole thing for simplicity now. + + const args = [ + 'compute', + 'scp', + '--recurse', + localDir, + remotePath, + `--zone=${zone}`, + `--project=${project}`, + '--tunnel-through-iap', + ]; + + debugLogger.log(`[SyncService] Syncing settings: gcloud ${args.join(' ')}`); + + return new Promise((resolve, reject) => { + const child = spawn('gcloud', args, { + stdio: 'inherit', + }); + + child.on('exit', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`gcloud scp exited with code ${code}`)); + } + }); + + child.on('error', (err) => { + reject(err); + }); + }); + } +} diff --git a/plans/milestone-4-sync-and-identity.md b/plans/milestone-4-sync-and-identity.md new file mode 100644 index 0000000000..8e64c50d20 --- /dev/null +++ b/plans/milestone-4-sync-and-identity.md @@ -0,0 +1,33 @@ +# Milestone 4 Sub-plan: Secure Sync & Identity + +## 1. Objective +Ensure the remote workspace provides a seamless, personalized experience while maintaining strict security for user credentials. + +## 2. Tasks + +### Task 4.1: User Settings Sync (~/.gemini/) +Implement the logic to push local configuration to the remote workspace. +- [ ] Implement `SyncService` in `packages/core/src/services/syncService.ts`. +- [ ] Logic to use `gcloud compute scp` (recursive) for the `~/.gemini` directory. +- [ ] Implement exclusion patterns (logs, cache, large assets). +- [ ] Integrate sync into the `wsr connect` flow. + +### Task 4.2: GitHub PAT Secure Injection +Safely provide GitHub credentials to the remote container without persisting them on disk. +- [ ] Implement logic in `SSHService` to push secrets to `/dev/shm/.gh_token` via a side-channel (e.g., small temp script or `scp`). +- [ ] Update `entrypoint.sh` to read from `/dev/shm/.gh_token` and perform `gh auth login`. +- [ ] Fetch PAT from local keychain before connection. + +### Task 4.3: Identity-Aware Proxy (IAP) Auth Primitives +Ensure the Hub API correctly identifies the user. +- [ ] Implement `IapMiddleware` in `packages/workspace-manager/src/middleware/iap.ts`. +- [ ] Logic to extract and verify the `x-goog-authenticated-user-email` and `id` headers. +- [ ] Replace `DEFAULT_OWNER` with real identity in Hub routes. + +## 3. Verification & Success Criteria +- **Sync:** After connecting, `gemini help` on the remote side shows the user's local custom commands and aliases. +- **GitHub Auth:** Running `gh auth status` on the remote workspace shows the user as authenticated without having manually logged in. +- **Tenancy:** A user can only see and delete their own workspaces when the Hub is running in multi-user mode. + +## 4. Next Steps +- Implement Task 4.1: Create the `SyncService` for settings synchronization. diff --git a/plans/workspaces-implementation.md b/plans/workspaces-implementation.md index e7bf0e3d0e..5282b738f2 100644 --- a/plans/workspaces-implementation.md +++ b/plans/workspaces-implementation.md @@ -33,10 +33,10 @@ See [Milestone 3 Sub-plan](./milestone-3-connectivity.md) for details. - [ ] Integrate `shpool` into the container entrypoint for session detachment. ### Milestone 4: Secure Sync & Identity (Phase 4) - Make the remote workspace "feel like home" with secure credential forwarding. - +See [Milestone 4 Sub-plan](./milestone-4-sync-and-identity.md) for details. - [ ] Implement `~/.gemini/` configuration synchronization. + - [ ] Implement SSH Agent Forwarding (`-A`) in the connectivity logic. - [ ] Implement secure GitHub PAT injection via `/dev/shm`.