mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-16 06:43:07 -07:00
feat(workspaces): implement user settings sync (~/.gemini/) for remote workspaces
This commit is contained in:
@@ -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<WorkspaceHubInfo> {
|
||||
@@ -42,6 +49,7 @@ export async function connectToWorkspace(args: ArgumentsCamelCase<ConnectArgs>):
|
||||
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<ConnectArgs>):
|
||||
// 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<ConnectArgs>):
|
||||
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<ConnectArgs>):
|
||||
}
|
||||
}
|
||||
|
||||
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<object, ConnectArgs> = {
|
||||
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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user