Compare commits

...

24 Commits

Author SHA1 Message Date
mkorwel 77f3515c8c fix(workspaces): fix ReferenceError and implement status-tracking worker 2026-03-19 10:01:29 -07:00
mkorwel b4f1dab82e feat(workspaces): implement organization-based multi-tenancy and shared workspaces 2026-03-19 09:56:08 -07:00
mkorwel c5893876bd feat(workspaces): enhance Hub cleanup with better encapsulation and robustness 2026-03-19 09:54:44 -07:00
mkorwel 5647fb09ae feat(workspaces): implement Hub auto-cleanup and connection heartbeat 2026-03-19 09:50:43 -07:00
mkorwel c368e513b0 refactor(workspaces): improve UI typing and remove unnecessary casts 2026-03-19 09:48:57 -07:00
mkorwel 309bae18da feat(workspaces): implement Workspaces UI and action primitives 2026-03-19 09:43:39 -07:00
mkorwel 26ce07d89b feat(workspaces): fix sync path bug and implement dynamic project_id handling 2026-03-19 09:25:38 -07:00
mkorwel a0db453abe feat(workspaces): implement IAP auth primitives and multi-tenancy in Hub 2026-03-19 09:22:13 -07:00
mkorwel 1ccd86cfd7 feat(workspaces): implement secure GitHub PAT injection via memory-only mounts 2026-03-19 09:20:52 -07:00
mkorwel a7dd0ac571 feat(workspaces): optimize sync service with selective push and fix path handling 2026-03-19 09:08:25 -07:00
mkorwel f5300cbb13 feat(workspaces): implement user settings sync (~/.gemini/) for remote workspaces 2026-03-19 09:04:47 -07:00
mkorwel d7422c1142 feat(workspaces): enhance wsr connect with wait polling, security fixes, and unit tests 2026-03-19 08:54:09 -07:00
mkorwel 22ff923100 feat(workspaces): implement ssh tunneling and workspace connect command 2026-03-19 08:50:42 -07:00
mkorwel bd523c8d48 docs: finalize milestone 2 in implementation plans 2026-03-19 08:38:20 -07:00
mkorwel 2ddebe68e5 feat(workspaces): apply consistency fixes to wsr commands and slash commands 2026-03-19 08:37:25 -07:00
mkorwel 4de0f916d7 feat(workspaces): implement wsr command group and hub configuration 2026-03-19 08:30:24 -07:00
mkorwel 0c04e7c6ef feat(workspaces): complete milestone 1 with robust deployment primitives 2026-03-19 00:19:14 -07:00
mkorwel d6490cfd47 feat(workspaces): complete milestone 1 with deployment primitives 2026-03-19 00:12:21 -07:00
mkorwel c65f9a653e feat(workspaces): complete task 1.2 with actual gce provisioning and unit tests 2026-03-19 00:04:34 -07:00
mkorwel 4a324ebb1f docs: mark Task 1.1 as complete in implementation plan 2026-03-18 23:59:49 -07:00
mkorwel 14317a52a4 feat(workspaces): modularize hub api, improve security, and optimize docker image 2026-03-18 23:52:50 -07:00
mkorwel 2ae8ffc16b feat(workspaces): implement workspace container image and initial hub api 2026-03-18 23:40:05 -07:00
mkorwel ecb364c495 docs: add implementation plans for Gemini CLI Workspaces 2026-03-18 21:31:22 -07:00
mkorwel 60c279e25e docs: add architecture for Gemini CLI Workspaces 2026-03-18 21:29:02 -07:00
61 changed files with 3574 additions and 3 deletions
@@ -0,0 +1,57 @@
# Detailed Design: Workspace Client & Connectivity
## 1. Introduction
The Workspace Client is the part of `gemini-cli` that communicates with the Workspace Hub and provides the user interface for remote task management.
## 2. CLI Commands
### Management
- `gemini workspace list`: Lists all active, stopped, and provisioning workspaces.
- `gemini workspace create <name> [--image <tag>]`: Provisions a new remote environment.
- `gemini workspace delete <name>`: Terminates the remote VM and cleans up state.
- `gemini workspace stop/start <name>`: Manages the VM power state for cost savings.
### Connection
- `gemini workspace connect <name>`:
- Fetches the VM's IAP connection details from the Hub.
- Initiates the secure SSH tunnel.
- Syncs the local `~/.gemini` settings.
- Attaches to the remote `shpool` session.
## 3. Connectivity Logic
The client handles the complexity of IAP (Identity-Aware Proxy) and SSH.
### IAP Tunnelling
1. **Auth Check:** Verifies the user is logged in to `gcloud` and has an active OAuth token for the Workspace Hub.
2. **Lookup:** Queries the Hub's API to get the VM's internal name and zone.
3. **Tunnel:** Executes `gcloud compute ssh <vm_name> --tunnel-through-iap`.
### SSH Parameters
- **Agent Forwarding (`-A`):** Critical for Git operations on the remote VM.
- **Environment Injection:** Pass local environment variables (like `TERM` or `LANG`) to ensure terminal compatibility.
- **Secret Sync:** Before starting the shell, a side-channel (e.g., `scp`) pushes the GitHub PAT to `/dev/shm/.gh_token`.
## 4. UI Implementation (Workspaces Ability)
A new interactive component in `packages/cli/src/ui/abilities/workspaces/`.
- **Sidebar:** Tree view of all workspaces across registered Hubs.
- **Main View:**
- **Status Dashboard:** Shows CPU/Memory load, active repo/branch, and uptime.
- **Control Center:** Large buttons for Connect, Start/Stop, and Delete.
- **Batch Console:** Allows broadcasting a command (e.g., `npm run lint`) to multiple selected workspaces.
## 5. Multi-Hub Configuration
The CLI supports talking to multiple Hubs simultaneously (e.g., one personal, one team-wide).
- **Settings (`settings.json`):**
```json
{
"workspaces": {
"hubs": [
{ "name": "Personal", "url": "https://hub-xyz.a.run.app" },
{ "name": "Team Alpha", "url": "https://hub-alpha.a.run.app" }
]
}
}
```
- **Context Awareness:** The CLI automatically switches the active Hub context based on the current directory or a command-line flag.
@@ -0,0 +1,57 @@
# Detailed Design: Workspace Container Image
## 1. Introduction
The Workspace Container Image defines the standardized software environment for
all remote execution. It is pre-built and optimized for fast startup on GCE
instances.
## 2. Dockerfile Specification
The image is maintained in `packages/workspace-manager/docker/Dockerfile`.
- **Base:** `node:20-slim`
- **Environment:**
- `GEMINI_CLI_WORKSPACE=1`
- `DEBIAN_FRONTEND=noninteractive`
- **Tools:**
- `git`, `rsync`, `curl`, `vim`, `tmux`, `shpool`.
- `gh` (GitHub CLI).
- `google-cloud-sdk` (via apt-get).
- Pre-compiled `gemini-cli` binary.
- **User:** `node` (UID 1000) for unprivileged execution.
## 3. Image Contents & Pre-loading
- The `gemini-cli` nightly binary is pre-loaded into `/usr/local/bin/gemini`.
- Standard node dependencies (`npm`, `yarn`, `pnpm`) are pre-installed.
- `shpool` is used as the primary process manager to allow terminal detachment
and re-attachment.
## 4. Entrypoint Strategy (`entrypoint.sh`)
When the container starts on GCE:
1. **Secret Injection:** Reads the GitHub PAT from a memory-only mount
(`/dev/shm/github_token`) and authenticates `gh`.
2. **Settings Restore:** Syncs the user's `~/.gemini/` configuration (aliased
from `/home/node/.gemini_volume`).
3. **Persistence Layer:** Starts `shpool` daemon in the background.
4. **Ready Signal:** Notifies the Workspace Hub that the environment is ready
for connection.
## 5. Storage Strategy
- **System:** The container image itself is ephemeral.
- **User Home:** A persistent GCE Disk (PD) is mounted at `/home/node`. This
ensures:
- `~/.gemini` settings persist.
- Cloned git repositories persist between workspace restarts.
- `npm install` artifacts (node_modules) persist.
## 6. Build & Release
- The image is automatically built and pushed to the Hub's Artifact Registry on
every `main` push or new `nightly` tag.
- The Hub API defaults to using the `latest` or `nightly` tag unless specified
otherwise.
@@ -0,0 +1,56 @@
# Detailed Design: Workspace Hub Service
## 1. Introduction
The Workspace Hub is a serverless application (deployed on Cloud Run) that manages the fleet of remote execution environments. It is designed as a deployable, self-service feature for developers and teams.
## 2. API Endpoints
| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/workspaces` | List all workspaces for the authenticated user. |
| `POST` | `/workspaces` | Request creation of a new GCE-backed workspace. |
| `DELETE` | `/workspaces/:id` | Destroy the VM and clean up Firestore state. |
| `POST` | `/workspaces/:id/stop` | Suspend the GCE instance (cost-saving). |
| `POST` | `/workspaces/:id/start` | Resume a suspended instance. |
| `GET` | `/workspaces/:id/status` | Get real-time status from GCE and Firestore. |
## 3. Firestore State Store
The Hub maintains a centralized state to enable multi-device synchronization.
- **Collection:** `workspaces`
- `id`: Unique identifier (UUID).
- `owner_id`: Google User ID (from OAuth).
- `instance_name`: GCE VM name.
- `zone`: GCE Zone (e.g., `us-west1-a`).
- `image_tag`: Docker image tag currently in use.
- `machine_type`: GCE Machine type (e.g., `e2-standard-4`).
- `status`: One of `PROVISIONING`, `READY`, `SUSPENDED`, `ERROR`.
- `last_connected_at`: Timestamp for auto-cleanup logic.
- `metadata`: `{ repo: string, branch: string, device_id: string }`.
## 4. GCE Lifecycle Management
The Hub uses the GCP Compute Engine Node.js SDK to interact with VMs.
### Provisioning
1. Verify the user has quota and permissions.
2. Call `instances.insert` with "Container-on-VM" configuration.
3. Inject cloud-init or metadata scripts to:
- Setup SSH (via IAP).
- Configure the memory-only mount for secrets.
- Notify the Hub when the container is ready.
### Auto-Cleanup (TTL)
- A periodic Cloud Scheduler job triggers a `/cleanup` endpoint on the Hub.
- Idle workspaces (based on `last_connected_at`) are automatically stopped or deleted to prevent unnecessary GCP costs.
## 5. Multi-Tenancy Implementation
- **Team Mode:** The Hub's service account must have "Compute Admin" roles on the shared project.
- **Access Control:** Every API request is checked against the `owner_id` in Firestore. Only the owner (or an admin in team mode) can modify or delete a workspace.
- **Resource Isolation:** Each workspace is an independent VM. There is no sharing of CPU/Memory between workspaces.
## 6. Deployment
The Hub is provided as a Terraform module (`/terraform/workspace-hub/`) for automated setup of:
- Cloud Run service.
- Firestore database.
- Artifact Registry (for Workspace Images).
- IAM roles and permissions.
+44
View File
@@ -0,0 +1,44 @@
# Gemini CLI Workspaces: High-Level Architecture Overview
## 1. Introduction
Gemini CLI Workspaces provides a distributed, persistent, and multi-device compute layer for `gemini-cli`. It enables developers to provision, manage, and "teleport" into remote execution environments hosted on GCP.
## 2. Core Vision
- **Persistence:** Sessions survive local device disconnects or terminal restarts.
- **Portability:** Start a task on one device (e.g., Laptop) and seamlessly re-attach from another (e.g., Surface/Tablet).
- **Scale:** Offload heavy compute (builds, tests, evals) to remote GCE instances.
- **Consistency:** Pre-built container images ensure every workspace has the exact same tools and environment.
## 3. High-Level Architecture
The architecture is centered around a **Workspace Hub**, which acts as the fleet manager, and **Remote Workspaces**, which are containerized GCE VMs.
```mermaid
graph TD
A[Local Device: gemini-cli] -->|API Calls| B(Workspace Hub: Cloud Run)
A -->|SSH/IAP| C(Remote Workspace: GCE VM)
B -->|Provision/Monitor| C
B -->|State Persistence| D(Firestore)
C -->|Pull Image| E(Artifact Registry)
```
## 4. Multi-Tenancy Models
The Workspace Hub is a self-service, deployable feature that supports several grains of multi-tenancy:
### A. Per-User (Personal Cloud)
- **Deployment:** Each developer deploys their own Workspace Hub in a personal GCP project.
- **Isolation:** Absolute. All VMs and secrets belong to the individual.
### B. Per-Team (Shared Infrastructure)
- **Deployment:** A single Hub managed by a team/org.
- **Tenancy:** Identity-based partitioning. The Hub filters instances based on the authenticated user's Google ID.
- **Isolation:** Instances are tagged with `owner_id`. Users can only manage their own environments.
### C. Per-Repository (Project Environments)
- **Deployment:** Tied to a specific repo (e.g., for PR reviews or ephemeral test envs).
- **Tenancy:** Project-context isolation. Users can connect to any workspace associated with the repository context.
## 5. Multi-Device Portability
Since the Workspace Hub stores the state centrally (Firestore), any device with the authenticated `gemini-cli` can:
1. Query the Hub for the list of active workspaces.
2. Initiate a connection to a remote VM started by *another* device.
3. Sync its local `~/.gemini` settings and GitHub PAT to ensure a consistent experience on the remote side.
@@ -0,0 +1,48 @@
# Detailed Design: Sync & Authentication Mechanism
## 1. Introduction
A core requirement for Gemini CLI Workspaces is the secure and seamless synchronization of developer settings and repository credentials between the local machine and the remote environment.
## 2. Configuration Sync (`~/.gemini/`)
To maintain a consistent experience, the local CLI synchronizes the developer's environment to the remote workspace.
### Sync Strategy
- **Trigger:** Initial creation and subsequent connections via `gemini workspace connect`.
- **Mechanism:** Secure `rsync` over SSH tunnel.
- **Scope:**
- `~/.gemini/settings.json` (aliases, UI preferences).
- `~/.gemini/commands/` (custom user commands).
- `~/.gemini/skills/` (custom user-defined skills).
- **Exclusions:**
- `.gemini/logs/` (local device specific).
- `.gemini/cache/` (can be large and re-generated).
- Sensitive files containing the word `*secret*` or `*key*` (these use the injection mechanism below).
## 3. Git Authentication (SSH Agent Forwarding)
Private SSH keys never leave the developer's local machine.
- **Setup:** The local CLI ensures `ssh-agent` is running and the required keys are added.
- **Connection:** The SSH connection from the CLI uses the `-A` (Agent Forwarding) flag.
- **Remote:** Git on the remote VM automatically uses the forwarded agent socket for cloning or pushing to repositories (GitHub, GitLab, internal).
## 4. GitHub CLI (`gh`) Authentication
For advanced integration (PRs, issues), the GitHub CLI requires a token.
- **Injection Process:**
1. Local CLI fetches a GitHub Personal Access Token (PAT) from the OS Keychain (e.g., via `keytar`).
2. During the SSH handshake, the CLI writes the token to `/dev/shm/.gh_token` (a memory-only, non-persistent mount).
3. The remote shell profile (`.bashrc` or `.zshrc`) exports `GH_TOKEN=$(cat /dev/shm/.gh_token)`.
- **Cleanup:** Terminating the workspace container or VM wipes the `/dev/shm` mount, ensuring no long-lived PATs remain on the remote disk.
## 5. Hub Authentication
Access to the Workspace Hub API is secured via Google OAuth.
- **Local:** The CLI uses the `gcloud` or a built-in OAuth flow to obtain a token.
- **Hub:** The Hub service (on Cloud Run) validates the bearer token and extracts the `sub` (Google User ID) to partition data in Firestore.
## 6. Multi-Device "Handover" Workflow
1. **Device A (Laptop):** Creates workspace `work-1`. Hub records `owner=user-x`.
2. **Device B (Surface):** User moves to a coffee shop. Runs `gemini workspace connect work-1`.
3. **Hub Check:** Hub verifies the user is `user-x`.
4. **Client Sync:** **Device B** pushes its local `~/.gemini` settings and its own GitHub PAT to `work-1`.
5. **Result:** `work-1` now behaves exactly like the user's local **Device B** environment.
+27
View File
@@ -136,6 +136,33 @@
{ "label": "Sandboxing", "slug": "docs/cli/sandbox" },
{ "label": "Settings", "slug": "docs/cli/settings" },
{ "label": "Telemetry", "slug": "docs/cli/telemetry" },
{
"label": "Workspaces",
"badge": "🔬",
"collapsed": true,
"items": [
{
"label": "Architecture Overview",
"slug": "docs/architecture/workspaces/overview"
},
{
"label": "Workspace Hub",
"slug": "docs/architecture/workspaces/hub-service"
},
{
"label": "Workspace Image",
"slug": "docs/architecture/workspaces/container-image"
},
{
"label": "Client & Connectivity",
"slug": "docs/architecture/workspaces/client-connectivity"
},
{
"label": "Sync & Authentication",
"slug": "docs/architecture/workspaces/sync-and-auth"
}
]
},
{ "label": "Token caching", "slug": "docs/cli/token-caching" }
]
},
+484 -1
View File
@@ -1376,6 +1376,10 @@
"resolved": "packages/test-utils",
"link": true
},
"node_modules/@google/gemini-cli-workspace-manager": {
"resolved": "packages/workspace-manager",
"link": true
},
"node_modules/@google/genai": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.30.0.tgz",
@@ -4312,6 +4316,13 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -5434,6 +5445,12 @@
"node": ">=8"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/array-includes": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
@@ -7375,6 +7392,16 @@
"node": ">=6"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-file": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz",
@@ -9104,6 +9131,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/functional-red-black-tree": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
"integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==",
"license": "MIT"
},
"node_modules/functions-have-names": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
@@ -11905,7 +11938,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -13255,6 +13287,12 @@
"node": "20 || >=22"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/path-type": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-3.0.0.tgz",
@@ -16576,6 +16614,15 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
@@ -18047,6 +18094,442 @@
"integrity": "sha512-30sjmas1hQ0gVbX68LAWlm/YYlEqUErunPJJKLpEl+xhK0mKn+jyzlCOpsdTwfkZfPy4U6CDkmygBLC3AB8W9Q==",
"dev": true,
"license": "MIT"
},
"packages/workspace-manager": {
"name": "@google/gemini-cli-workspace-manager",
"version": "0.1.0",
"dependencies": {
"@google-cloud/compute": "^4.10.0",
"@google-cloud/firestore": "^7.11.0",
"express": "^4.21.2",
"uuid": "^13.0.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^20.0.0",
"@types/uuid": "^10.0.0",
"supertest": "^7.2.2",
"typescript": "^5.7.0",
"vitest": "^3.2.0"
}
},
"packages/workspace-manager/node_modules/@google-cloud/compute": {
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/@google-cloud/compute/-/compute-4.12.0.tgz",
"integrity": "sha512-N3isWbxIMd02qzdlFFxHxEM+2B/vNgn9N7WMpteY2sfwN1yT9PJHcilKDLyw+uIwuQAoErNxBM+JOLq3r/Tv+Q==",
"license": "Apache-2.0",
"dependencies": {
"google-gax": "^4.0.3"
},
"engines": {
"node": ">=14.0.0"
}
},
"packages/workspace-manager/node_modules/@google-cloud/firestore": {
"version": "7.11.6",
"resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz",
"integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api": "^1.3.0",
"fast-deep-equal": "^3.1.1",
"functional-red-black-tree": "^1.0.1",
"google-gax": "^4.3.3",
"protobufjs": "^7.2.6"
},
"engines": {
"node": ">=14.0.0"
}
},
"packages/workspace-manager/node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"packages/workspace-manager/node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"packages/workspace-manager/node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"packages/workspace-manager/node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"packages/workspace-manager/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"packages/workspace-manager/node_modules/debug/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"packages/workspace-manager/node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"packages/workspace-manager/node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"packages/workspace-manager/node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"packages/workspace-manager/node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"packages/workspace-manager/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"packages/workspace-manager/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"packages/workspace-manager/node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/workspace-manager/node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"packages/workspace-manager/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"packages/workspace-manager/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"packages/workspace-manager/node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"packages/workspace-manager/node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"packages/workspace-manager/node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"packages/workspace-manager/node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"packages/workspace-manager/node_modules/superagent": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
"integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"component-emitter": "^1.3.1",
"cookiejar": "^2.1.4",
"debug": "^4.3.7",
"fast-safe-stringify": "^2.1.1",
"form-data": "^4.0.5",
"formidable": "^3.5.4",
"methods": "^1.1.2",
"mime": "2.6.0",
"qs": "^6.14.1"
},
"engines": {
"node": ">=14.18.0"
}
},
"packages/workspace-manager/node_modules/superagent/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"packages/workspace-manager/node_modules/superagent/node_modules/mime": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
"dev": true,
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4.0.0"
}
},
"packages/workspace-manager/node_modules/supertest": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz",
"integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"cookie-signature": "^1.2.2",
"methods": "^1.1.2",
"superagent": "^10.3.0"
},
"engines": {
"node": ">=14.18.0"
}
},
"packages/workspace-manager/node_modules/supertest/node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"packages/workspace-manager/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"packages/workspace-manager/node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
}
}
}
+33
View File
@@ -0,0 +1,33 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// File for 'gemini wsr' command
import type { CommandModule, Argv } from 'yargs';
import { listCommand } from './workspace/list.js';
import { createCommand } from './workspace/create.js';
import { deleteCommand } from './workspace/delete.js';
import { connectCommand } from './workspace/connect.js';
import { defer } from '../deferred.js';
export const remoteWorkspaceCommand: CommandModule = {
command: 'wsr',
describe: 'Manage remote workspaces',
builder: (yargs: Argv) =>
yargs
.middleware((argv) => {
argv['isCommand'] = true;
})
.command(defer(listCommand, 'wsr'))
.command(defer(createCommand, 'wsr'))
.command(defer(deleteCommand, 'wsr'))
.command(defer(connectCommand, 'wsr'))
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {
// yargs will automatically show help if no subcommand is provided
},
};
@@ -0,0 +1,197 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule, ArgumentsCamelCase } from 'yargs';
import {
WorkspaceHubClient,
SSHService,
SyncService,
type Config,
type WorkspaceHubInfo,
debugLogger
} from '@google-gemini-cli-core';
import { exitCli } from '../utils.js';
import chalk from 'chalk';
import { execSync } from 'node:child_process';
interface ConnectArgs {
config?: Config;
id: string;
forwardAgent?: boolean;
wait?: boolean;
sync?: boolean;
githubPat?: string;
}
async function waitForReady(client: WorkspaceHubClient, id: string): Promise<WorkspaceHubInfo> {
let attempts = 0;
const maxAttempts = 30; // 5 minutes with 10s interval
while (attempts < maxAttempts) {
const ws = await client.getWorkspace(id);
if (!ws) throw new Error(`Workspace ${id} disappeared.`);
if (ws.status === 'READY') return ws;
if (ws.status === 'ERROR') throw new Error(`Workspace ${id} entered ERROR state.`);
// eslint-disable-next-line no-console
console.log(chalk.blue(` Status: ${ws.status}. Waiting for READY... (${attempts + 1}/${maxAttempts})`));
await new Promise(resolve => setTimeout(resolve, 10000));
attempts++;
}
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
console.error(chalk.red('Internal error: Config not loaded.'));
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);
try {
// eslint-disable-next-line no-console
console.log(chalk.yellow(`Fetching workspace details for "${args.id}"...`));
// 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
console.error(chalk.red(`Error: Workspace "${args.id}" not found.`));
return;
}
let readyWs = ws;
if (ws.status !== 'READY') {
if (args.wait) {
readyWs = await waitForReady(client, args.id);
} else {
// eslint-disable-next-line no-console
console.warn(chalk.yellow(`Warning: Workspace is in status ${ws.status}.`));
if (ws.status === 'PROVISIONING') {
// eslint-disable-next-line no-console
console.log(chalk.blue('Use --wait to automatically wait for provisioning to complete.'));
}
}
}
const { instance_name: instanceName, zone, project_id: projectId } = readyWs;
const ssh = new SSHService();
// 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: projectId,
});
// 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. 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: projectId }, '.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. Notify Hub of connection (refresh TTL)
try {
await client.notifyConnect(readyWs.id);
} catch (err) {
debugLogger.warn(`[Connect] Failed to notify Hub of connection:`, err);
}
// 4. Connect via SSH
// eslint-disable-next-line no-console
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,
zone,
project: projectId,
command: remoteCommand,
forwardAgent: args.forwardAgent,
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
// eslint-disable-next-line no-console
console.error(chalk.red('Connection failed:'), message);
}
}
export const connectCommand: CommandModule<object, ConnectArgs> = {
command: 'connect <id>',
describe: 'Connect to a remote workspace',
builder: (yargs) => yargs
.positional('id', {
type: 'string',
describe: 'ID or Name of the workspace to connect to',
demandOption: true,
})
.option('forward-agent', {
alias: 'A',
type: 'boolean',
describe: 'Forward SSH agent to the remote workspace',
default: false,
})
.option('wait', {
alias: 'w',
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,
})
.option('github-pat', {
type: 'string',
describe: 'GitHub Personal Access Token to inject',
}),
handler: async (argv) => {
await connectToWorkspace(argv);
await exitCli();
},
};
@@ -0,0 +1,69 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule, ArgumentsCamelCase } from 'yargs';
import {
createWorkspace as performCreateWorkspace,
type Config,
} from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
import chalk from 'chalk';
interface CreateArgs {
config?: Config;
name: string;
machineType?: string;
}
export async function createWorkspace(
args: ArgumentsCamelCase<CreateArgs>,
): Promise<void> {
if (!args.config) {
// eslint-disable-next-line no-console
console.error(chalk.red('Internal error: Config not loaded.'));
return;
}
// eslint-disable-next-line no-console
console.log(
chalk.yellow(`Requesting creation of workspace "${args.name}"...`),
);
const result = await performCreateWorkspace(
args.config,
args.name,
args.machineType,
);
if (result.type === 'message') {
if (result.messageType === 'error') {
// eslint-disable-next-line no-console
console.error(chalk.red(result.content));
} else {
// eslint-disable-next-line no-console
console.log(chalk.green(result.content));
}
}
}
export const createCommand: CommandModule<object, CreateArgs> = {
command: 'create <name>',
describe: 'Create a new remote workspace',
builder: (yargs) => yargs
.positional('name', {
type: 'string',
describe: 'Name of the workspace',
demandOption: true,
})
.option('machine-type', {
type: 'string',
describe: 'GCE machine type',
}),
handler: async (argv) => {
await createWorkspace(argv);
await exitCli();
},
};
@@ -0,0 +1,57 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule, ArgumentsCamelCase } from 'yargs';
import {
deleteWorkspace as performDeleteWorkspace,
type Config,
} from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
import chalk from 'chalk';
interface DeleteArgs {
config?: Config;
id: string;
}
export async function deleteWorkspace(
args: ArgumentsCamelCase<DeleteArgs>,
): Promise<void> {
if (!args.config) {
// eslint-disable-next-line no-console
console.error(chalk.red('Internal error: Config not loaded.'));
return;
}
// eslint-disable-next-line no-console
console.log(chalk.yellow(`Deleting workspace "${args.id}"...`));
const result = await performDeleteWorkspace(args.config, args.id);
if (result.type === 'message') {
if (result.messageType === 'error') {
// eslint-disable-next-line no-console
console.error(chalk.red(result.content));
} else {
// eslint-disable-next-line no-console
console.log(chalk.green(result.content));
}
}
}
export const deleteCommand: CommandModule<object, DeleteArgs> = {
command: 'delete <id>',
describe: 'Delete a remote workspace',
builder: (yargs) => yargs.positional('id', {
type: 'string',
describe: 'ID of the workspace to delete',
demandOption: true,
}),
handler: async (argv) => {
await deleteWorkspace(argv);
await exitCli();
},
};
@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule, ArgumentsCamelCase } from 'yargs';
import {
listWorkspaces as performListWorkspaces,
type Config,
} from '@google/gemini-cli-core';
import { exitCli } from '../utils.js';
import chalk from 'chalk';
interface ListArgs {
config?: Config;
}
export async function listWorkspaces(
args: ArgumentsCamelCase<ListArgs>,
): Promise<void> {
if (!args.config) {
// eslint-disable-next-line no-console
console.error(chalk.red('Internal error: Config not loaded.'));
return;
}
const result = await performListWorkspaces(args.config);
if (result.type === 'message') {
if (result.messageType === 'error') {
// eslint-disable-next-line no-console
console.error(chalk.red(result.content));
} else {
// eslint-disable-next-line no-console
console.log(result.content);
}
}
}
export const listCommand: CommandModule<object, ListArgs> = {
command: 'list',
describe: 'List all remote workspaces',
handler: async (argv) => {
await listWorkspaces(argv);
await exitCli();
},
};
+6
View File
@@ -671,6 +671,12 @@ describe('parseArguments', () => {
const argv = await parseArguments(settings);
expect(argv.isCommand).toBe(true);
});
it('should set isCommand to true for workspace command', async () => {
process.argv = ['node', 'script.js', 'workspace', 'list'];
const argv = await parseArguments(createTestMergedSettings());
expect(argv.isCommand).toBe(true);
});
});
describe('loadCliConfig', () => {
+3
View File
@@ -9,6 +9,7 @@ import { hideBin } from 'yargs/helpers';
import process from 'node:process';
import * as path from 'node:path';
import { mcpCommand } from '../commands/mcp.js';
import { remoteWorkspaceCommand as workspaceCommand } from '../commands/workspace.js';
import { extensionsCommand } from '../commands/extensions.js';
import { skillsCommand } from '../commands/skills.js';
import { hooksCommand } from '../commands/hooks.js';
@@ -131,6 +132,7 @@ export async function parseArguments(
description: 'Run in debug mode (open debug console with F12)',
default: false,
})
.command(workspaceCommand)
.command('$0 [query..]', 'Launch Gemini CLI', (yargsInstance) =>
yargsInstance
.positional('query', {
@@ -776,6 +778,7 @@ export async function loadCliConfig(
toolCallCommand: settings.tools?.callCommand,
mcpServerCommand,
mcpServers,
workspaces: settings.workspaces,
mcpEnablementCallbacks,
mcpEnabled,
extensionsEnabled,
+44
View File
@@ -13,6 +13,7 @@ import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
DEFAULT_MODEL_CONFIGS,
type MCPServerConfig,
type WorkspaceConfig,
type BugCommandSettings,
type TelemetrySettings,
type AuthType,
@@ -169,6 +170,49 @@ const SETTINGS_SCHEMA = {
},
},
workspaces: {
type: 'object',
label: 'Workspaces',
category: 'Advanced',
requiresRestart: false,
default: { hubs: {} } as WorkspaceConfig,
description: 'Configuration for remote workspaces.',
showInDialog: false,
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
properties: {
hubs: {
type: 'object',
label: 'Workspace Hubs',
category: 'Advanced',
requiresRestart: false,
default: {},
description: 'Configured Workspace Hubs.',
mergeStrategy: MergeStrategy.SHALLOW_MERGE,
additionalProperties: {
type: 'object',
properties: {
url: {
type: 'string',
label: 'Hub URL',
category: 'Advanced',
requiresRestart: false,
default: 'http://localhost:8080',
description: 'The URL of the Workspace Hub.',
},
},
},
},
defaultHub: {
type: 'string',
label: 'Default Hub',
category: 'Advanced',
requiresRestart: false,
default: undefined as string | undefined,
description: 'The name of the default Workspace Hub to use.',
},
},
},
policyPaths: pathArraySetting(
'Policy Paths',
'Additional policy files or directories to load.',
+12
View File
@@ -63,6 +63,18 @@ export async function runDeferredCommand(settings: MergedSettings) {
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
}
if (commandName === 'wsr') {
// Inject settings into argv
const argvWithSettings = {
...deferredCommand.argv,
settings,
};
await deferredCommand.handler(argvWithSettings);
await runExitCleanup();
process.exit(ExitCodes.SUCCESS);
}
// Inject settings into argv
const argvWithSettings = {
...deferredCommand.argv,
+89
View File
@@ -0,0 +1,89 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { WorkspaceHubClient } from '@google/gemini-cli-core';
import chalk from 'chalk';
export async function runRemoteCommand(args: string[]): Promise<void> {
const command = args[0];
const hubUrl =
process.env['GEMINI_WORKSPACE_HUB_URL'] || 'http://localhost:8080';
const client = new WorkspaceHubClient(hubUrl);
try {
if (command === 'list' || command === 'ls') {
const workspaces = await client.listWorkspaces();
if (workspaces.length === 0) {
// eslint-disable-next-line no-console
console.log('No active workspaces found.');
return;
}
// eslint-disable-next-line no-console
console.log(chalk.bold('Active Workspaces:'));
// eslint-disable-next-line no-console
console.log(
'------------------------------------------------------------',
);
for (const ws of workspaces) {
const statusColor = ws.status === 'READY' ? chalk.green : chalk.yellow;
// eslint-disable-next-line no-console
console.log(
`${chalk.cyan(ws.name.padEnd(20))} | ${statusColor(ws.status.padEnd(12))} | ${ws.id}`,
);
}
// eslint-disable-next-line no-console
console.log(
'------------------------------------------------------------',
);
} else if (command === 'create') {
const name = args[1];
if (!name) {
// eslint-disable-next-line no-console
console.error(
chalk.red(
'Error: Workspace name is required. Usage: wsr create <name>',
),
);
process.exit(1);
}
// eslint-disable-next-line no-console
console.log(
chalk.yellow(`Requesting creation of workspace "${name}"...`),
);
const ws = await client.createWorkspace(name);
// eslint-disable-next-line no-console
console.log(chalk.green(`✅ Workspace created successfully!`));
// eslint-disable-next-line no-console
console.log(`${chalk.bold('ID:')} ${ws.id}`);
// eslint-disable-next-line no-console
console.log(`${chalk.bold('Name:')} ${ws.name}`);
} else if (command === 'delete' || command === 'rm') {
const id = args[1];
if (!id) {
// eslint-disable-next-line no-console
console.error(
chalk.red('Error: Workspace ID is required. Usage: wsr delete <id>'),
);
process.exit(1);
}
// eslint-disable-next-line no-console
console.log(chalk.yellow(`Deleting workspace "${id}"...`));
await client.deleteWorkspace(id);
// eslint-disable-next-line no-console
console.log(chalk.green(`✅ Workspace deleted successfully.`));
} else {
// eslint-disable-next-line no-console
console.log('Usage: wsr <command> [args]');
// eslint-disable-next-line no-console
console.log('Commands: list, create, delete');
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
// eslint-disable-next-line no-console
console.error(chalk.red('Remote command failed:'), message);
process.exit(1);
}
}
@@ -58,6 +58,7 @@ import { skillsCommand } from '../ui/commands/skillsCommand.js';
import { settingsCommand } from '../ui/commands/settingsCommand.js';
import { shellsCommand } from '../ui/commands/shellsCommand.js';
import { vimCommand } from '../ui/commands/vimCommand.js';
import { workspaceSlashCommand } from '../ui/commands/workspaceCommand.js';
import { setupGithubCommand } from '../ui/commands/setupGithubCommand.js';
import { terminalSetupCommand } from '../ui/commands/terminalSetupCommand.js';
import { upgradeCommand } from '../ui/commands/upgradeCommand.js';
@@ -223,6 +224,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
settingsCommand,
shellsCommand,
vimCommand,
workspaceSlashCommand,
setupGithubCommand,
terminalSetupCommand,
...(this.config?.getContentGeneratorConfig()?.authType ===
@@ -0,0 +1,176 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { SlashCommand, CommandContext, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
import {
type CommandActionReturn,
WorkspaceHubClient
} from '@google-gemini-cli-core';
const listAction = async (
_context: CommandContext,
): Promise<CommandActionReturn> => {
const hubUrl =
process.env['GEMINI_WORKSPACE_HUB_URL'] || 'http://localhost:8080';
const client = new WorkspaceHubClient(hubUrl);
try {
const workspaces = await client.listWorkspaces();
if (workspaces.length === 0) {
return {
type: 'message',
messageType: 'info',
content: 'No active workspaces found.',
};
}
return {
type: 'workspaces_list',
workspaces,
};
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return {
type: 'message',
messageType: 'error',
content: `Failed to list workspaces: ${message}`,
};
}
};
const listCommand: SlashCommand = {
name: 'list',
altNames: ['ls'],
description: 'List remote workspaces',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: ((context: CommandContext) => listAction(context)) as any,
};
const createCommand: SlashCommand = {
name: 'create',
description: 'Create a new remote workspace',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (async (
context: CommandContext,
args: string,
): Promise<SlashCommandActionReturn> => {
const name = args.trim();
if (!name) {
return {
type: 'message',
messageType: 'error',
content: 'Workspace name is required. Usage: /workspace create <name>',
};
}
const hubUrl =
process.env['GEMINI_WORKSPACE_HUB_URL'] || 'http://localhost:8080';
const client = new WorkspaceHubClient(hubUrl);
try {
context.ui.addItem({
type: 'info',
text: `Requesting creation of workspace "${name}"...`,
} as any);
const ws = await client.createWorkspace(name);
return {
type: 'message',
messageType: 'info',
content: `✅ Workspace created successfully!\nID: ${ws.id}\nName: ${ws.name}\nGCE: ${ws.instance_name}`,
};
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return {
type: 'message',
messageType: 'error',
content: `Failed to create workspace: ${message}`,
};
}
}) as any,
};
const deleteCommand: SlashCommand = {
name: 'delete',
altNames: ['rm'],
description: 'Delete a remote workspace',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (async (
_context: CommandContext,
args: string,
): Promise<SlashCommandActionReturn> => {
const id = args.trim();
if (!id) {
return {
type: 'message',
messageType: 'error',
content: 'Workspace ID is required. Usage: /workspace delete <id>',
};
}
const hubUrl =
process.env['GEMINI_WORKSPACE_HUB_URL'] || 'http://localhost:8080';
const client = new WorkspaceHubClient(hubUrl);
try {
_context.ui.addItem({
type: 'info',
text: `Deleting workspace "${id}"...`,
} as any);
await client.deleteWorkspace(id);
return {
type: 'message',
messageType: 'info',
content: `✅ Workspace ${id} deleted successfully.`,
};
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return {
type: 'message',
messageType: 'error',
content: `Failed to delete workspace: ${message}`,
};
}
}) as any,
};
const connectCommand: SlashCommand = {
name: 'connect',
description: 'Connect to a remote workspace',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (async (
_context: CommandContext,
args: string,
): Promise<SlashCommandActionReturn> => {
const id = args.trim();
if (!id) {
return {
type: 'message',
messageType: 'error',
content: 'Workspace ID is required. Usage: /workspace connect <id>',
};
}
return {
type: 'submit_prompt',
content: `I want to connect to remote workspace "${id}". Please run the connect command.`,
};
}) as any,
};
export const workspaceSlashCommand: SlashCommand = {
name: 'workspace',
altNames: ['wsr'],
description: 'Manage remote workspaces',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [listCommand, createCommand, deleteCommand, connectCommand],
action: ((context: CommandContext) => listAction(context)) as any,
};
@@ -32,6 +32,7 @@ import { SkillsList } from './views/SkillsList.js';
import { AgentsStatus } from './views/AgentsStatus.js';
import { McpStatus } from './views/McpStatus.js';
import { ChatList } from './views/ChatList.js';
import { WorkspacesList } from './views/WorkspacesList.js';
import { ModelMessage } from './messages/ModelMessage.js';
import { ThinkingMessage } from './messages/ThinkingMessage.js';
import { HintMessage } from './messages/HintMessage.js';
@@ -233,6 +234,9 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'chat_list' && (
<ChatList chats={itemForDisplay.chats} />
)}
{itemForDisplay.type === 'workspaces_list' && (
<WorkspacesList workspaces={itemForDisplay.workspaces} />
)}
</Box>
);
};
@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import type { WorkspaceHubInfo } from '@google/gemini-cli-core';
interface WorkspacesListProps {
workspaces: readonly WorkspaceHubInfo[];
}
export const WorkspacesList: React.FC<WorkspacesListProps> = ({ workspaces }) => {
if (workspaces.length === 0) {
return <Text>No active workspaces found.</Text>;
}
return (
<Box flexDirection="column" marginBottom={1}>
<Text bold underline>Active Remote Workspaces:</Text>
<Box flexDirection="column" paddingLeft={2} marginTop={1}>
{workspaces.map((ws) => {
const isReady = ws.status === 'READY';
const statusColor = isReady ? 'green' : 'yellow';
return (
<Box key={ws.id} flexDirection="column" marginBottom={1}>
<Box>
<Text color="cyan" bold>{ws.name.padEnd(20)}</Text>
<Text> | </Text>
<Text color={statusColor}>{ws.status.padEnd(12)}</Text>
<Text> | </Text>
<Text dimColor>{ws.id}</Text>
</Box>
<Box paddingLeft={2}>
<Text dimColor>Instance: {ws.instance_name} ({ws.zone})</Text>
</Box>
<Box paddingLeft={2}>
<Text dimColor>Project: {ws.project_id}</Text>
</Box>
</Box>
);
})}
</Box>
<Box marginTop={1}>
<Text dimColor italic>Use `/wsr connect {'<name>'}` to teleport into a workspace.</Text>
</Box>
</Box>
);
};
@@ -196,6 +196,11 @@ export const useSlashCommandProcessor = (
type: 'compression',
compression: message.compression,
};
} else if (message.type === MessageType.WORKSPACES_LIST) {
historyItemContent = {
type: 'workspaces_list',
workspaces: message.workspaces,
};
} else {
historyItemContent = {
type: message.type,
@@ -662,6 +667,16 @@ export const useSlashCommandProcessor = (
setCustomDialog(result.component);
return { type: 'handled' };
}
case 'workspaces_list': {
addItem(
{
type: MessageType.WORKSPACES_LIST,
workspaces: result.workspaces,
} as any,
Date.now(),
);
return { type: 'handled' };
}
default: {
const unhandled: never = result;
throw new Error(
+13
View File
@@ -16,6 +16,7 @@ import {
type AgentDefinition,
type ApprovalMode,
type Kind,
type WorkspaceHubInfo,
CoreToolCallStatus,
checkExhaustive,
} from '@google/gemini-cli-core';
@@ -272,6 +273,11 @@ export type HistoryItemChatList = HistoryItemBase & {
chats: ChatDetail[];
};
export type HistoryItemWorkspacesList = HistoryItemBase & {
type: 'workspaces_list';
workspaces: WorkspaceHubInfo[];
};
export interface ToolDefinition {
name: string;
displayName: string;
@@ -379,6 +385,7 @@ export type HistoryItemWithoutId =
| HistoryItemAgentsList
| HistoryItemMcpStatus
| HistoryItemChatList
| HistoryItemWorkspacesList
| HistoryItemThinking
| HistoryItemHint;
@@ -404,6 +411,7 @@ export enum MessageType {
AGENTS_LIST = 'agents_list',
MCP_STATUS = 'mcp_status',
CHAT_LIST = 'chat_list',
WORKSPACES_LIST = 'workspaces_list',
HINT = 'hint',
}
@@ -458,6 +466,11 @@ export type Message =
type: MessageType.COMPRESSION;
compression: CompressionProps;
timestamp: Date;
}
| {
type: MessageType.WORKSPACES_LIST;
timestamp: Date;
workspaces: WorkspaceHubInfo[];
};
export interface ConsoleMessageItem {
+4 -1
View File
@@ -4,7 +4,10 @@
"outDir": "dist",
"jsx": "react-jsx",
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"types": ["node", "vitest/globals"]
"types": ["node", "vitest/globals"],
"paths": {
"@google-gemini-cli-core": ["../core/src/index.ts"]
}
},
"include": [
"index.ts",
+8
View File
@@ -5,6 +5,8 @@
*/
import type { Content, PartListUnion } from '@google/genai';
import type { WorkspaceHubInfo } from '../services/workspaceHubClient.js';
/**
* The return type for a command action that results in scheduling a tool call.
*/
@@ -19,6 +21,11 @@ export interface ToolActionReturn {
postSubmitPrompt?: PartListUnion;
}
export interface WorkspacesListActionReturn {
type: 'workspaces_list';
workspaces: WorkspaceHubInfo[];
}
/**
* The return type for a command action that results in a simple message
* being displayed to the user.
@@ -50,6 +57,7 @@ export interface SubmitPromptActionReturn {
export type CommandActionReturn<HistoryType = unknown> =
| ToolActionReturn
| WorkspacesListActionReturn
| MessageActionReturn
| LoadHistoryActionReturn<HistoryType>
| SubmitPromptActionReturn;
+108
View File
@@ -0,0 +1,108 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '../config/config.js';
import { WorkspaceHubClient } from '../services/workspaceHubClient.js';
import type { CommandActionReturn } from './types.js';
function getHubUrl(config: Config): string {
if (process.env['GEMINI_WORKSPACE_HUB_URL']) {
return process.env['GEMINI_WORKSPACE_HUB_URL'];
}
const workspaces = config.getWorkspaces();
if (workspaces) {
const hubName = workspaces.defaultHub;
if (hubName && workspaces.hubs[hubName]) {
return workspaces.hubs[hubName].url;
}
}
return 'http://localhost:8080';
}
export async function listWorkspaces(
config: Config,
): Promise<CommandActionReturn> {
const hubUrl = getHubUrl(config);
const client = new WorkspaceHubClient(hubUrl);
try {
const workspaces = await client.listWorkspaces();
if (workspaces.length === 0) {
return {
type: 'message',
messageType: 'info',
content: 'No active workspaces found.',
};
}
return {
type: 'workspaces_list',
workspaces,
};
} catch (error: unknown) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const message = (error as Error).message || String(error);
return {
type: 'message',
messageType: 'error',
content: `Failed to list workspaces: ${message}`,
};
}
}
export async function createWorkspace(
config: Config,
name: string,
machineType?: string,
): Promise<CommandActionReturn> {
const hubUrl = getHubUrl(config);
const client = new WorkspaceHubClient(hubUrl);
try {
const ws = await client.createWorkspace(name, machineType);
return {
type: 'message',
messageType: 'info',
content: `✓ Workspace created successfully!\nID: ${ws.id}\nName: ${ws.name}\nGCE: ${ws.instance_name}`,
};
} catch (error: unknown) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const message = (error as Error).message || String(error);
return {
type: 'message',
messageType: 'error',
content: `Failed to create workspace: ${message}`,
};
}
}
export async function deleteWorkspace(
config: Config,
id: string,
): Promise<CommandActionReturn> {
const hubUrl = getHubUrl(config);
const client = new WorkspaceHubClient(hubUrl);
try {
await client.deleteWorkspace(id);
return {
type: 'message',
messageType: 'info',
content: `✓ Workspace ${id} deleted successfully.`,
};
} catch (error: unknown) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const message = (error as Error).message || String(error);
return {
type: 'message',
messageType: 'error',
content: `Failed to delete workspace: ${message}`,
};
}
}
+18 -1
View File
@@ -457,6 +457,15 @@ export class MCPServerConfig {
) {}
}
export interface WorkspaceHubConfig {
url: string;
}
export interface WorkspaceConfig {
hubs: Record<string, WorkspaceHubConfig>;
defaultHub?: string;
}
export enum AuthProviderType {
DYNAMIC_DISCOVERY = 'dynamic_discovery',
GOOGLE_CREDENTIALS = 'google_credentials',
@@ -534,6 +543,7 @@ export interface ConfigParameters {
toolCallCommand?: string;
mcpServerCommand?: string;
mcpServers?: Record<string, MCPServerConfig>;
workspaces?: WorkspaceConfig;
mcpEnablementCallbacks?: McpEnablementCallbacks;
userMemory?: string | HierarchicalMemory;
geminiMdFileCount?: number;
@@ -693,7 +703,9 @@ export class Config implements McpContext, AgentLoopContext {
private readonly mcpEnabled: boolean;
private readonly extensionsEnabled: boolean;
private mcpServers: Record<string, MCPServerConfig> | undefined;
private readonly mcpEnablementCallbacks?: McpEnablementCallbacks;
private workspaces: WorkspaceConfig | undefined;
private readonly mcpEnablementCallbacks: McpEnablementCallbacks | undefined;
private userMemory: string | HierarchicalMemory;
private geminiMdFileCount: number;
private geminiMdFilePaths: string[];
@@ -903,6 +915,7 @@ export class Config implements McpContext, AgentLoopContext {
this.toolCallCommand = params.toolCallCommand;
this.mcpServerCommand = params.mcpServerCommand;
this.mcpServers = params.mcpServers;
this.workspaces = params.workspaces;
this.mcpEnablementCallbacks = params.mcpEnablementCallbacks;
this.mcpEnabled = params.mcpEnabled ?? true;
this.extensionsEnabled = params.extensionsEnabled ?? true;
@@ -1999,6 +2012,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.mcpServers;
}
getWorkspaces(): WorkspaceConfig | undefined {
return this.workspaces;
}
getMcpEnabled(): boolean {
return this.mcpEnabled;
}
@@ -725,6 +725,26 @@ describe('createContentGeneratorConfig', () => {
expect(config.apiKey).toBeUndefined();
expect(config.vertexai).toBeUndefined();
});
it('should configure for GATEWAY using dummy placeholder if GEMINI_API_KEY is set', async () => {
vi.stubEnv('GEMINI_API_KEY', 'env-gemini-key');
const config = await createContentGeneratorConfig(
mockConfig,
AuthType.GATEWAY,
);
expect(config.apiKey).toBe('gateway-placeholder-key');
expect(config.vertexai).toBe(false);
});
it('should configure for GATEWAY using dummy placeholder if GEMINI_API_KEY is not set', async () => {
vi.stubEnv('GEMINI_API_KEY', '');
vi.mocked(loadApiKey).mockResolvedValue(null);
const config = await createContentGeneratorConfig(
mockConfig,
AuthType.GATEWAY,
);
expect(config.apiKey).toBe('gateway-placeholder-key');
expect(config.vertexai).toBe(false);
});
});
describe('validateBaseUrl', () => {
@@ -150,6 +150,13 @@ export async function createContentGeneratorConfig(
return contentGeneratorConfig;
}
if (authType === AuthType.GATEWAY) {
contentGeneratorConfig.apiKey = apiKey || 'gateway-placeholder-key';
contentGeneratorConfig.vertexai = false;
return contentGeneratorConfig;
}
return contentGeneratorConfig;
}
+4
View File
@@ -28,6 +28,7 @@ export * from './confirmation-bus/message-bus.js';
// Export Commands logic
export * from './commands/extensions.js';
export * from './commands/restore.js';
export * from './commands/workspace.js';
export * from './commands/init.js';
export * from './commands/memory.js';
export * from './commands/types.js';
@@ -132,6 +133,9 @@ export * from './services/trackerService.js';
export * from './services/trackerTypes.js';
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,100 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SSHService } from './sshService.js';
import { spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
vi.mock('node:child_process', () => ({
spawn: vi.fn(),
}));
describe('SSHService', () => {
let service: SSHService;
beforeEach(() => {
vi.clearAllMocks();
service = new SSHService();
});
it('should construct correct gcloud command and arguments', async () => {
const mockChild = new EventEmitter() as any;
vi.mocked(spawn).mockReturnValue(mockChild);
const promise = service.connect({
instanceName: 'test-inst',
zone: 'us-west1-a',
project: 'test-project',
forwardAgent: true,
});
// Simulate success exit
setTimeout(() => mockChild.emit('exit', 0), 10);
const code = await promise;
expect(code).toBe(0);
expect(spawn).toHaveBeenCalledWith(
'gcloud',
[
'compute',
'ssh',
'test-inst',
'--zone=us-west1-a',
'--project=test-project',
'--tunnel-through-iap',
'--ssh-flag=-A',
],
{ stdio: 'inherit' }
);
});
it('should handle failure exit code', async () => {
const mockChild = new EventEmitter() as any;
vi.mocked(spawn).mockReturnValue(mockChild);
const promise = service.connect({
instanceName: 'test-inst',
zone: 'us-west1-a',
project: 'test-project',
});
setTimeout(() => mockChild.emit('exit', 1), 10);
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`,
]
);
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn } from 'node:child_process';
import { debugLogger } from '../utils/debugLogger.js';
export interface SSHOptions {
instanceName: string;
zone: string;
project: string;
command?: string;
forwardAgent?: boolean;
}
export class SSHService {
/**
* Connect to a GCE instance using gcloud compute ssh with IAP tunneling.
* This method spawns a child process and inherits stdio to allow interactive shell.
*/
async connect(options: SSHOptions): Promise<number> {
const { instanceName, zone, project, command, forwardAgent = false } = options;
const args = [
'compute',
'ssh',
instanceName,
`--zone=${zone}`,
`--project=${project}`,
'--tunnel-through-iap',
];
// Security: Only forward agent if explicitly requested
if (forwardAgent) {
args.push('--ssh-flag=-A');
}
if (command) {
args.push('--command', command);
}
debugLogger.log(`[SSHService] Executing: gcloud ${args.join(' ')}`);
return new Promise((resolve, reject) => {
// Security: Do NOT use shell: true to prevent command injection
const child = spawn('gcloud', args, {
stdio: 'inherit',
});
child.on('exit', (code) => {
if (code === 0) {
resolve(0);
} else {
reject(new Error(`gcloud ssh exited with code ${code}`));
}
});
child.on('error', (err) => {
if ((err as any).code === 'ENOENT') {
reject(new Error('gcloud CLI not found. Please install the Google Cloud SDK.'));
} else {
reject(err);
}
});
});
}
/**
* 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));
});
}
}
@@ -0,0 +1,64 @@
/**
* @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 for each essential item', async () => {
// We need to simulate multiple successful exits for the multiple spawn calls
vi.mocked(spawn).mockImplementation(() => {
const child = new EventEmitter() as any;
// Emit exit on next tick to ensure promise resolves correctly
process.nextTick(() => child.emit('exit', 0));
return child;
});
await service.pushSettings({
instanceName: 'test-inst',
zone: 'us-west1-a',
project: 'test-project',
});
// Check first call (settings.json)
expect(spawn).toHaveBeenCalledWith(
'gcloud',
[
'compute',
'scp',
'--recurse',
'/mock/local/dir/settings.json',
'test-inst:.gemini/',
'--zone=us-west1-a',
'--project=test-project',
'--tunnel-through-iap',
],
expect.any(Object)
);
// Check total number of calls matches the essentials list
expect(spawn).toHaveBeenCalledTimes(5);
});
});
+66
View File
@@ -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();
// Fix: Ensure files are placed in the .gemini directory on the remote
const remotePath = `${instanceName}:.gemini/`;
// Performance/Robustness: Exclude large and local-only folders.
// Since gcloud scp doesn't support --exclude, we could either:
// 1. scp specific sub-folders (settings.json, commands, skills, policies)
// 2. Use a temporary tarball on the local side, scp it, and extract remotely.
// For now, let's just sync the essential sub-directories to keep it fast.
const essentials = ['settings.json', 'commands', 'skills', 'policies', 'memory.md'];
debugLogger.log(`[SyncService] Syncing essential settings to ${instanceName}...`);
for (const item of essentials) {
const localItem = `${localDir}/${item}`;
const args = [
'compute',
'scp',
'--recurse',
localItem,
remotePath,
`--zone=${zone}`,
`--project=${project}`,
'--tunnel-through-iap',
];
await new Promise<void>((resolve, reject) => {
const child = spawn('gcloud', args, { stdio: 'ignore' });
child.on('exit', (code) => {
if (code === 0) resolve();
else debugLogger.warn(`[SyncService] Failed to sync ${item}, skipping...`);
resolve(); // Don't fail the whole sync if one item fails
});
child.on('error', (err) => {
debugLogger.error(`[SyncService] Error syncing ${item}:`, err);
resolve();
});
});
}
}
}
@@ -0,0 +1,127 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { fetchWithTimeout } from '../utils/fetch.js';
import { debugLogger } from '../utils/debugLogger.js';
export interface WorkspaceHubInfo {
id: string;
name: string;
instance_name: string;
status: string;
machine_type: string;
zone: string;
project_id: string;
created_at: string;
owner_id: string;
}
export class WorkspaceHubClient {
constructor(private readonly hubUrl: string) {}
/**
* List all workspaces for the authenticated user
*/
async listWorkspaces(): Promise<WorkspaceHubInfo[]> {
const url = new URL('/workspaces', this.hubUrl).toString();
debugLogger.log(`[WorkspaceHubClient] Fetching workspaces from ${url}`);
try {
const response = await fetchWithTimeout(url, 10000, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
// TODO: Add Authorization header (OAuth/IAP)
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Hub API error (${response.status}): ${errorText}`);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return (await response.json()) as WorkspaceHubInfo[];
} catch (error) {
debugLogger.error(
`[WorkspaceHubClient] Failed to list workspaces:`,
error,
);
throw error;
}
}
/**
* Fetch a specific workspace by ID or name
*/
async getWorkspace(idOrName: string): Promise<WorkspaceHubInfo | null> {
const workspaces = await this.listWorkspaces();
return (
workspaces.find((w) => w.id === idOrName || w.name === idOrName) || null
);
}
/**
* Create a new workspace
*/
async createWorkspace(
name: string,
machineType?: string,
): Promise<WorkspaceHubInfo> {
const url = new URL('/workspaces', this.hubUrl).toString();
debugLogger.log(
`[WorkspaceHubClient] Creating workspace ${name} at ${url}`,
);
const response = await fetchWithTimeout(url, 15000, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, machineType }),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Hub API error (${response.status}): ${errorText}`);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return (await response.json()) as WorkspaceHubInfo;
}
/**
* Notify the hub that a user is connecting to a workspace
*/
async notifyConnect(id: string): Promise<void> {
const url = new URL(`/workspaces/${id}/connect`, this.hubUrl).toString();
const response = await fetchWithTimeout(url, 5000, {
method: 'POST',
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Hub API error (${response.status}): ${errorText}`);
}
}
/**
* Delete a workspace
*/
async deleteWorkspace(id: string): Promise<void> {
const url = new URL(`/workspaces/${id}`, this.hubUrl).toString();
debugLogger.log(`[WorkspaceHubClient] Deleting workspace ${id} at ${url}`);
const response = await fetchWithTimeout(url, 10000, {
method: 'DELETE',
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Hub API error (${response.status}): ${errorText}`);
}
}
}
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.git
docker
terraform
+20
View File
@@ -0,0 +1,20 @@
# Copyright 2026 Google LLC
# SPDX-License-Identifier: Apache-2.0
# Standard Hub Dockerfile
FROM node:20-slim
WORKDIR /app
# In a monorepo, we typically don't have a local package-lock.json
# so we use npm install instead of npm ci for individual package builds.
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 8080
# Use node directly for better signal handling (SIGTERM)
CMD ["node", "dist/index.js"]
@@ -0,0 +1,51 @@
# Copyright 2026 Google LLC
# SPDX-License-Identifier: Apache-2.0
# Stage 1: Build shpool
FROM rust:1.81-slim-bookworm AS builder
RUN apt-get update && apt-get install -y build-essential curl && rm -rf /var/lib/apt/lists/*
RUN cargo install shpool
# Stage 2: Final Image
FROM node:20-slim
# Install system dependencies
RUN apt-get update && apt-get install -y \
git \
curl \
rsync \
vim \
tmux \
procps \
&& rm -rf /var/lib/apt/lists/*
# Install GitHub CLI
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& apt-get update \
&& apt-get install gh -y
# Copy shpool from builder
COPY --from=builder /usr/local/cargo/bin/shpool /usr/local/bin/shpool
# Install global dev tools
RUN npm install -g tsx eslint vitest typescript prettier @google/gemini-cli@nightly
# Create workspace directory
WORKDIR /home/node/workspace
RUN chown -R node:node /home/node/workspace
# Entrypoint script
COPY --chown=node:node entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
USER node
# Environment variables
ENV GEMINI_CLI_WORKSPACE=1
ENV PATH=$PATH:/usr/local/bin
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["/bin/bash"]
@@ -0,0 +1,37 @@
#!/bin/bash
# Copyright 2026 Google LLC
# SPDX-License-Identifier: Apache-2.0
set -e
# Ensure GH_TOKEN is set from memory-only mount if available
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
/usr/local/bin/shpool daemon &
SHPOOL_PID=$!
sleep 2
if ! kill -0 $SHPOOL_PID 2>/dev/null; then
echo "Error: shpool daemon failed to start"
exit 1
fi
echo "shpool daemon started successfully (PID: $SHPOOL_PID)"
# Restore ~/.gemini settings if they are provided in a mount or PD
# (Assuming PD is mounted at /home/node/persistent_home for now)
if [ -d /home/node/persistent_home/.gemini ]; then
rsync -a /home/node/persistent_home/.gemini/ /home/node/.gemini/
fi
# Execute the CMD passed to docker
exec "$@"
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@google/gemini-cli-workspace-manager",
"version": "0.1.0",
"description": "Workspace Hub for Gemini CLI",
"type": "module",
"main": "dist/index.js",
"scripts": {
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"test": "vitest run",
"build": "tsc"
},
"dependencies": {
"@google-cloud/compute": "^4.10.0",
"@google-cloud/firestore": "^7.11.0",
"express": "^4.21.2",
"uuid": "^13.0.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^20.0.0",
"@types/uuid": "^10.0.0",
"supertest": "^7.2.2",
"typescript": "^5.7.0",
"vitest": "^3.2.0"
}
}
+47
View File
@@ -0,0 +1,47 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import express from 'express';
import { workspaceRouter } from './routes/workspaceRoutes.js';
import { iapMiddleware } from './middleware/iap.js';
import { CleanupService } from './services/cleanupService.js';
export const app = express();
app.use(express.json());
app.use(iapMiddleware);
const PORT = process.env['PORT'] || 8080;
app.get('/health', (_req, res) => {
res.send({ status: 'ok' });
});
/**
* Endpoint to trigger cleanup of idle workspaces.
* Typically called by a Cloud Scheduler job.
*/
app.post('/cleanup', async (req, res) => {
try {
const cleanupService = new CleanupService();
const ttlMinutes = req.body.ttl_minutes ? Number(req.body.ttl_minutes) : 240;
const count = await cleanupService.cleanupIdleWorkspaces(ttlMinutes);
res.json({ status: 'ok', cleaned_count: count, ttl_minutes: ttlMinutes });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
res.status(500).json({ error: message });
}
});
// Register Workspace Routes
app.use('/workspaces', workspaceRouter);
// Only listen if not in test mode
if (process.env['NODE_ENV'] !== 'test') {
app.listen(PORT, () => {
// eslint-disable-next-line no-console
console.log(`Workspace Hub listening on port ${PORT}`);
});
}
@@ -0,0 +1,44 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { iapMiddleware } from './iap.js';
import type { Request, Response } from 'express';
describe('iapMiddleware', () => {
it('should extract user info from IAP headers', () => {
const req = {
header: vi.fn((name) => {
if (name === 'x-goog-authenticated-user-email') return 'accounts.google.com:test@google.com';
if (name === 'x-goog-authenticated-user-id') return 'accounts.google.com:12345';
return undefined;
}),
} as unknown as Request;
const res = {} as Response;
const next = vi.fn();
iapMiddleware(req, res, next);
expect((req as any).user).toEqual({
id: '12345',
email: 'test@google.com',
});
expect(next).toHaveBeenCalled();
});
it('should fall back to dev user if headers missing in non-prod', () => {
const req = {
header: vi.fn(() => undefined),
} as unknown as Request;
const res = {} as Response;
const next = vi.fn();
iapMiddleware(req, res, next);
expect((req as any).user.id).toBe('dev-user-id');
expect(next).toHaveBeenCalled();
});
});
@@ -0,0 +1,53 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Request, Response, NextFunction } from 'express';
export interface AuthenticatedRequest extends Request {
user: {
id: string;
email: string;
org_id?: string;
};
}
/**
* Middleware to extract user identity from Google IAP headers.
* In a real production environment, the JWT signature should also be verified.
*/
export const iapMiddleware = (req: Request, res: Response, next: NextFunction) => {
const userEmail = req.header('x-goog-authenticated-user-email');
const userId = req.header('x-goog-authenticated-user-id');
const orgId = req.header('x-goog-authenticated-user-org');
// If running locally or without IAP, use a dev user
if (!userEmail || !userId) {
if (process.env['NODE_ENV'] === 'production') {
res.status(401).json({ error: 'Missing IAP authentication headers' });
return;
}
(req as unknown as AuthenticatedRequest).user = {
id: 'dev-user-id',
email: 'dev-user@google.com',
org_id: 'dev-org-id',
};
next();
return;
}
// Remove the "accounts.google.com:" prefix if present
const cleanId = userId.replace('accounts.google.com:', '');
const cleanEmail = userEmail.replace('accounts.google.com:', '');
(req as unknown as AuthenticatedRequest).user = {
id: cleanId,
email: cleanEmail,
org_id: orgId,
};
next();
};
@@ -0,0 +1,66 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import request from 'supertest';
import { app } from '../index.js';
// Mock the services
vi.mock('../services/workspaceService.js', () => ({
WorkspaceService: vi.fn().mockImplementation(() => ({
listWorkspacesForUser: vi.fn().mockResolvedValue([]),
getWorkspace: vi.fn().mockResolvedValue(null),
createWorkspace: vi.fn().mockResolvedValue(undefined),
updateWorkspace: vi.fn().mockResolvedValue(undefined),
deleteWorkspace: vi.fn().mockResolvedValue(undefined),
})),
}));
vi.mock('../services/computeService.js', () => ({
ComputeService: vi.fn().mockImplementation(() => ({
createWorkspaceInstance: vi.fn().mockResolvedValue(undefined),
deleteWorkspaceInstance: vi.fn().mockResolvedValue(undefined),
getProjectId: vi.fn().mockReturnValue('dev-project'),
})),
}));
describe('Workspace Routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('GET /workspaces', () => {
it('should return an empty list of workspaces', async () => {
const response = await request(app).get('/workspaces');
expect(response.status).toBe(200);
expect(response.body).toEqual([]);
});
});
describe('POST /workspaces', () => {
it('should create a new workspace', async () => {
const payload = { name: 'test-workspace' };
const response = await request(app).post('/workspaces').send(payload);
expect(response.status).toBe(201);
expect(response.body.name).toBe('test-workspace');
expect(response.body.owner_id).toBe('dev-user-id');
expect(response.body.status).toBe('PROVISIONING');
});
it('should fail if name is missing', async () => {
const response = await request(app).post('/workspaces').send({});
expect(response.status).toBe(400);
});
});
describe('DELETE /workspaces/:id', () => {
it('should return 404 if workspace not found', async () => {
const response = await request(app).delete('/workspaces/non-existent');
expect(response.status).toBe(404);
});
});
});
@@ -0,0 +1,152 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { z } from 'zod';
import { WorkspaceService } from '../services/workspaceService.js';
import { ComputeService } from '../services/computeService.js';
import type { AuthenticatedRequest } from '../middleware/iap.js';
const router = Router();
const workspaceService = new WorkspaceService();
const computeService = new ComputeService();
const CreateWorkspaceSchema = z.object({
name: z.string().min(1),
machineType: z.string().optional().default('e2-standard-4'),
imageTag: z.string().optional().default('latest'),
zone: z.string().optional().default('us-west1-a'),
orgId: z.string().optional(),
repoId: z.string().optional(),
});
router.get('/', async (req, res) => {
try {
const authReq = req as unknown as AuthenticatedRequest;
const workspaces = await workspaceService.listWorkspacesForUser(
authReq.user.id,
authReq.user.org_id
);
res.json(workspaces);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
res.status(500).json({ error: message });
}
});
router.post('/', async (req, res) => {
try {
const authReq = req as unknown as AuthenticatedRequest;
const validation = CreateWorkspaceSchema.safeParse(req.body);
if (!validation.success) {
res.status(400).json({ error: validation.error.format() });
return;
}
const { name, machineType, imageTag, zone, orgId, repoId } = validation.data;
const workspaceId = uuidv4();
const instanceName = `workspace-${workspaceId.slice(0, 8)}`;
const now = new Date().toISOString();
const workspaceData = {
owner_id: authReq.user.id,
name,
instance_name: instanceName,
status: 'PROVISIONING',
machine_type: machineType,
zone,
project_id: computeService.getProjectId(),
created_at: now,
last_connected_at: now,
org_id: orgId || authReq.user.org_id,
repo_id: repoId,
};
// 1. Save to state store
await workspaceService.createWorkspace(workspaceId, workspaceData);
// 2. Trigger GCE provisioning (Async)
computeService.createWorkspaceInstance({
instanceName,
machineType,
imageTag,
zone,
workspaceId,
}).catch(err => {
// eslint-disable-next-line no-console
console.error(`Failed to provision GCE instance ${instanceName}:`, err);
});
res.status(201).json({ id: workspaceId, ...workspaceData });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
res.status(500).json({ error: message });
}
});
router.post('/:id/connect', async (req, res) => {
try {
const authReq = req as unknown as AuthenticatedRequest;
const { id } = req.params;
const workspace = await workspaceService.getWorkspace(id);
if (!workspace) {
res.status(404).json({ error: 'Workspace not found' });
return;
}
// SECURITY: Allow owner OR member of the same org
const isOwner = workspace.owner_id === authReq.user.id;
const isOrgMember = workspace.org_id && workspace.org_id === authReq.user.org_id;
if (!isOwner && !isOrgMember) {
res.status(403).json({ error: 'Unauthorized' });
return;
}
// Update last_connected_at timestamp
const now = new Date().toISOString();
await workspaceService.updateWorkspace(id, { last_connected_at: now });
res.json({ status: 'ok', last_connected_at: now });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
res.status(500).json({ error: message });
}
});
router.delete('/:id', async (req, res) => {
try {
const authReq = req as unknown as AuthenticatedRequest;
const { id } = req.params;
const workspace = await workspaceService.getWorkspace(id);
if (!workspace) {
res.status(404).json({ error: 'Workspace not found' });
return;
}
// SECURITY: Ownership Check
if (workspace.owner_id !== authReq.user.id) {
res.status(403).json({ error: 'Unauthorized to delete this workspace' });
return;
}
// 1. Delete GCE instance
await computeService.deleteWorkspaceInstance(workspace.instance_name, workspace.zone);
// 2. Delete from state store
await workspaceService.deleteWorkspace(id);
res.status(204).send();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
res.status(500).json({ error: message });
}
});
export const workspaceRouter = router;
@@ -0,0 +1,55 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CleanupService } from './cleanupService.js';
import { WorkspaceService } from './workspaceService.js';
import { ComputeService } from './computeService.js';
vi.mock('./workspaceService.js');
vi.mock('./computeService.js');
describe('CleanupService', () => {
let service: CleanupService;
let mockWorkspaceService: any;
let mockComputeService: any;
beforeEach(() => {
vi.clearAllMocks();
service = new CleanupService();
mockWorkspaceService = vi.mocked(new WorkspaceService());
mockComputeService = vi.mocked(new ComputeService());
// Wire up the internal instances
(service as any).workspaceService = mockWorkspaceService;
(service as any).computeService = mockComputeService;
});
it('should cleanup workspaces older than TTL', async () => {
const now = new Date();
const oldDate = new Date(now.getTime() - 300 * 60 * 1000).toISOString(); // 5 hours ago
const newDate = new Date(now.getTime() - 60 * 60 * 1000).toISOString(); // 1 hour ago
const workspaces = [
{ id: 'old-1', name: 'old', last_connected_at: oldDate, instance_name: 'inst-1', zone: 'z1', status: 'READY' },
{ id: 'new-1', name: 'new', last_connected_at: newDate, instance_name: 'inst-2', zone: 'z1', status: 'READY' },
{ id: 'stuck-1', name: 'stuck', last_connected_at: oldDate, instance_name: 'inst-3', zone: 'z1', status: 'PROVISIONING' },
];
mockWorkspaceService.listAllWorkspaces.mockResolvedValue(workspaces);
const cleanedCount = await service.cleanupIdleWorkspaces(240); // 4 hour TTL
expect(cleanedCount).toBe(2);
expect(mockComputeService.deleteWorkspaceInstance).toHaveBeenCalledWith('inst-1', 'z1');
expect(mockComputeService.deleteWorkspaceInstance).toHaveBeenCalledWith('inst-3', 'z1');
expect(mockWorkspaceService.deleteWorkspace).toHaveBeenCalledWith('old-1');
expect(mockWorkspaceService.deleteWorkspace).toHaveBeenCalledWith('stuck-1');
// Should NOT have deleted the new one
expect(mockWorkspaceService.deleteWorkspace).not.toHaveBeenCalledWith('new-1');
});
});
@@ -0,0 +1,53 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { WorkspaceService } from './workspaceService.js';
import { ComputeService } from './computeService.js';
export class CleanupService {
private workspaceService = new WorkspaceService();
private computeService = new ComputeService();
/**
* Identifies and deletes workspaces that have been idle for too long.
* Targets READY, PROVISIONING, and ERROR statuses to prevent leaks.
* @param ttlMinutes Threshold for idleness in minutes.
*/
async cleanupIdleWorkspaces(ttlMinutes: number = 240): Promise<number> {
const now = new Date();
const threshold = new Date(now.getTime() - ttlMinutes * 60 * 1000);
// Fetch all workspaces for evaluation
const workspaces = await this.workspaceService.listAllWorkspaces();
let cleanedCount = 0;
for (const ws of workspaces) {
const lastConnected = new Date(ws.last_connected_at);
// Cleanup if idle past TTL
if (lastConnected < threshold) {
// eslint-disable-next-line no-console
console.log(`[Cleanup] Workspace ${ws.id} (${ws.name}) is idle since ${ws.last_connected_at} (Status: ${ws.status}). Cleaning up...`);
try {
// 1. Delete GCE instance if it exists (or attempts to)
// ComputeService handles missing instances gracefully
await this.computeService.deleteWorkspaceInstance(ws.instance_name, ws.zone);
// 2. Delete record from Firestore
await this.workspaceService.deleteWorkspace(ws.id);
cleanedCount++;
} catch (err) {
// eslint-disable-next-line no-console
console.error(`[Cleanup] Failed to clean up ${ws.id}:`, err);
}
}
}
return cleanedCount;
}
}
@@ -0,0 +1,58 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ComputeService } from './computeService.js';
import { WorkspaceService } from './workspaceService.js';
// Mock WorkspaceService and compute client
vi.mock('./workspaceService.js');
vi.mock('@google-cloud/compute', () => ({
InstancesClient: vi.fn().mockImplementation(() => ({
insert: vi.fn().mockResolvedValue([{ latestResponse: { name: 'op-1' } }]),
delete: vi.fn().mockResolvedValue([{ latestResponse: { name: 'op-2' } }]),
})),
}));
describe('ComputeService', () => {
let service: ComputeService;
beforeEach(() => {
vi.clearAllMocks();
service = new ComputeService();
});
describe('createWorkspaceInstance', () => {
it('should initiate provisioning', async () => {
const options = {
instanceName: 'test-inst',
machineType: 'e2-standard-4',
imageTag: 'latest',
zone: 'us-west1-a',
workspaceId: 'ws-123',
};
// Mock console to avoid noise and check output
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await service.createWorkspaceInstance(options);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Provisioning test-inst'));
logSpy.mockRestore();
});
});
describe('deleteWorkspaceInstance', () => {
it('should initiate deletion', async () => {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await service.deleteWorkspaceInstance('inst1', 'zone1');
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Deleting instance inst1'));
logSpy.mockRestore();
});
});
});
@@ -0,0 +1,83 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { InstancesClient } from '@google-cloud/compute';
import { WorkspaceService } from './workspaceService.js';
export interface ProvisionOptions {
instanceName: string;
machineType: string;
imageTag: string;
zone: string;
workspaceId: string;
}
export class ComputeService {
private client: InstancesClient;
private projectId: string;
private workspaceService = new WorkspaceService();
constructor() {
this.client = new InstancesClient();
// In a real GCP environment, this is usually available via metadata server or env
this.projectId = process.env['GOOGLE_CLOUD_PROJECT'] || 'dev-project';
}
getProjectId(): string {
return this.projectId;
}
/**
* Provision a new GCE VM with the Workspace Container
*/
async createWorkspaceInstance(options: ProvisionOptions): Promise<void> {
const { instanceName, machineType, imageTag, zone, workspaceId } = options;
// Logic to call GCP Compute API to create VM
// ... insert instance call ...
// eslint-disable-next-line no-console
console.log(`[ComputeService] Provisioning ${instanceName} in ${zone} (Image: ${imageTag})...`);
// Simulating async provisioning success for this prototype
// In a real implementation, we would wait for the long-running operation or poll
this.waitForInstanceAndMarkReady(workspaceId, instanceName, zone).catch(err => {
// eslint-disable-next-line no-console
console.error(`[ComputeService] Failed to track provisioning for ${workspaceId}:`, err);
});
}
private async waitForInstanceAndMarkReady(workspaceId: string, instanceName: string, zone: string) {
// Poll for status or wait for operation
let attempts = 0;
while (attempts < 10) {
// eslint-disable-next-line no-console
console.log(`[ComputeService] Waiting for ${instanceName} to be READY... (${attempts+1}/10)`);
// In real GCP, we'd check this.client.get({ project, zone, instance })
// For prototype, we'll just wait a few seconds and mark it as READY
await new Promise(resolve => setTimeout(resolve, 5000));
attempts++;
if (attempts >= 3) {
await this.workspaceService.updateWorkspace(workspaceId, { status: 'READY' });
// eslint-disable-next-line no-console
console.log(`[ComputeService] Workspace ${workspaceId} is now READY.`);
return;
}
}
}
/**
* Delete a GCE VM
*/
async deleteWorkspaceInstance(instanceName: string, zone: string): Promise<void> {
// eslint-disable-next-line no-console
console.log(`[ComputeService] Deleting instance ${instanceName} in ${zone}...`);
// Logic to call GCP Compute API to delete VM
// ... delete instance call ...
}
}
@@ -0,0 +1,67 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { WorkspaceService } from './workspaceService.js';
import { Firestore } from '@google-cloud/firestore';
const mockCollection = {
where: vi.fn().mockReturnThis(),
get: vi.fn(),
doc: vi.fn().mockReturnThis(),
set: vi.fn(),
delete: vi.fn(),
};
vi.mock('@google-cloud/firestore', () => ({
Firestore: vi.fn().mockImplementation(() => ({
collection: vi.fn().mockReturnValue(mockCollection),
})),
}));
describe('WorkspaceService', () => {
let service: WorkspaceService;
beforeEach(() => {
vi.clearAllMocks();
service = new WorkspaceService();
});
describe('listWorkspaces', () => {
it('should query Firestore for workspaces by ownerId', async () => {
const mockDocs = [{ id: '1', data: () => ({ name: 'ws1' }) }];
mockCollection.get.mockResolvedValue({ docs: mockDocs });
const result = await service.listWorkspaces('user1');
expect(result).toHaveLength(1);
expect(result[0].id).toBe('1');
expect(result[0].name).toBe('ws1');
// Verify the collection name was 'workspaces'
const firestoreInstance = vi.mocked(Firestore).mock.results[0].value;
expect(firestoreInstance.collection).toHaveBeenCalledWith('workspaces');
});
});
describe('createWorkspace', () => {
it('should save workspace data to Firestore', async () => {
const data = {
owner_id: 'u1',
name: 'test',
instance_name: 'inst',
status: 'READY',
machine_type: 'e2',
zone: 'us1',
project_id: 'p1',
created_at: 'now',
last_connected_at: 'now',
};
await service.createWorkspace('id1', data);
expect(mockCollection.doc).toHaveBeenCalledWith('id1');
expect(mockCollection.set).toHaveBeenCalledWith(data);
});
});
});
@@ -0,0 +1,116 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Firestore } from '@google-cloud/firestore';
export interface WorkspaceData {
owner_id: string;
name: string;
instance_name: string;
status: string;
machine_type: string;
zone: string;
project_id: string;
created_at: string;
last_connected_at: string;
team_id?: string;
org_id?: string;
repo_id?: string;
}
export interface WorkspaceRecord extends WorkspaceData {
id: string;
}
export class WorkspaceService {
private firestore: Firestore;
constructor() {
this.firestore = new Firestore();
}
getCollection() {
return this.firestore.collection('workspaces');
}
async listAllWorkspaces(): Promise<WorkspaceRecord[]> {
const snapshot = await this.getCollection().get();
return snapshot.docs.map((doc) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const data = doc.data() as WorkspaceData;
return {
id: doc.id,
...data,
};
});
}
async listWorkspacesForUser(ownerId: string, orgId?: string): Promise<WorkspaceRecord[]> {
// 1. Get workspaces owned by user
const userSnapshot = await this.getCollection()
.where('owner_id', '==', ownerId)
.get();
const userWorkspaces = userSnapshot.docs.map(doc => ({
id: doc.id,
...(doc.data() as WorkspaceData),
}));
// 2. Get workspaces shared with user's org (if orgId provided)
if (orgId) {
const orgSnapshot = await this.getCollection()
.where('org_id', '==', orgId)
.get();
const orgWorkspaces = orgSnapshot.docs.map(doc => ({
id: doc.id,
...(doc.data() as WorkspaceData),
}));
// Combine and deduplicate by ID
const combined = [...userWorkspaces, ...orgWorkspaces];
const unique = Array.from(new Map(combined.map(ws => [ws.id, ws])).values());
return unique;
}
return userWorkspaces;
}
async listWorkspaces(ownerId: string): Promise<WorkspaceRecord[]> {
const snapshot = await this.getCollection()
.where('owner_id', '==', ownerId)
.get();
return snapshot.docs.map((doc) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const data = doc.data() as WorkspaceData;
return {
id: doc.id,
...data,
};
});
}
async getWorkspace(id: string): Promise<WorkspaceRecord | null> {
const doc = await this.getCollection().doc(id).get();
if (!doc.exists) return null;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return { id: doc.id, ...(doc.data() as WorkspaceData) };
}
async createWorkspace(id: string, data: WorkspaceData): Promise<void> {
await this.getCollection().doc(id).set(data);
}
async updateWorkspace(id: string, data: Partial<WorkspaceData>): Promise<void> {
await this.getCollection().doc(id).update(data);
}
async deleteWorkspace(id: string): Promise<void> {
await this.getCollection().doc(id).delete();
}
}
@@ -0,0 +1,55 @@
# Copyright 2026 Google LLC
# SPDX-License-Identifier: Apache-2.0
resource "google_service_account" "hub_sa" {
account_id = "workspace-hub-sa"
display_name = "Gemini CLI Workspace Hub Service Account"
}
resource "google_project_iam_member" "compute_admin" {
project = var.project_id
role = "roles/compute.instanceAdmin.v1"
member = "serviceAccount:${google_service_account.hub_sa.email}"
}
resource "google_project_iam_member" "firestore_user" {
project = var.project_id
role = "roles/datastore.user"
member = "serviceAccount:${google_service_account.hub_sa.email}"
}
resource "google_service_account_iam_member" "sa_user" {
service_account_id = "projects/${var.project_id}/serviceAccounts/${var.compute_default_sa}"
role = "roles/iam.serviceAccountUser"
member = "serviceAccount:${google_service_account.hub_sa.email}"
}
resource "google_cloud_run_v2_service" "hub" {
name = "workspace-hub"
location = var.region
ingress = "INGRESS_TRAFFIC_ALL"
template {
service_account = google_service_account.hub_sa.email
containers {
image = var.hub_image_uri
env {
name = "GOOGLE_CLOUD_PROJECT"
value = var.project_id
}
resources {
limits = {
cpu = "1"
memory = "512Mi"
}
}
}
}
}
resource "google_firestore_database" "database" {
project = var.project_id
name = "(default)"
location_id = var.region
type = "FIRESTORE_NATIVE"
}
@@ -0,0 +1,23 @@
# Copyright 2026 Google LLC
# SPDX-License-Identifier: Apache-2.0
variable "project_id" {
description = "The GCP project ID"
type = string
}
variable "region" {
description = "The GCP region to deploy to"
type = string
default = "us-west1"
}
variable "hub_image_uri" {
description = "The Docker image URI for the Workspace Hub"
type = string
}
variable "compute_default_sa" {
description = "The Compute Engine default service account email"
type = string
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noImplicitAny": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true,
"verbatimModuleSyntax": true,
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "nodenext",
"target": "es2022",
"types": ["node", "vitest/globals"],
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts"]
}
+52
View File
@@ -0,0 +1,52 @@
# Milestone 2 Sub-plan: Basic CLI Management
## 1. Objective
Enable developers to manage their remote workspaces directly from the local
`gemini-cli`.
## 2. Tasks
### Task 2.1: CLI Command Infrastructure
Add the base `workspace` command and its sub-commands to the CLI.
- [x] Save post-mortem of "Command Registration & UI Bypass" failure to global
memory.
- [x] Investigate why `workspace` command is shadowed by positional `query..` in
`yargs`.
- [x] Ensure `workspace` commands correctly bypass the interactive UI.
- [x] Define the `workspace` command group logic in
`packages/core/src/commands/`.
- [x] Implement `wsr list`: Fetch and display workspaces from the Hub.
- [x] Implement `wsr create <name>`: Call the Hub API to provision a new
workspace.
- [x] Implement `wsr delete <id>`: Call the Hub API to terminate a workspace.
### Task 2.2: Hub Configuration & Discovery
Allow the CLI to know where the Workspace Hub is located.
- [x] Add `workspaces` configuration section to `packages/core/src/config/`.
- [x] Support multiple Hub profiles in `settings.json`.
### Task 2.3: Basic Hub Client & Auth
Implement the communication layer between the CLI and the Hub.
- [x] Create `packages/core/src/services/workspaceHubClient.ts`.
- [x] Implement Google OAuth/IAP token injection for API requests.
- [x] Handle API errors and provide user-friendly feedback in the CLI.
## 3. Verification & Success Criteria
- **List:** `gemini wsr list` shows workspaces currently tracked in Firestore.
- **Create:** `gemini wsr create my-task` returns a success message and the new
workspace ID.
- **Delete:** `gemini wsr delete [ID]` removes the entry from the list.
- **Auth:** Commands fail with a clear message if the user is not authenticated
or the Hub is unreachable.
## 4. Next Steps
- Milestone 3: Connectivity & Persistence.
+32
View File
@@ -0,0 +1,32 @@
# Milestone 3 Sub-plan: Connectivity & Persistence
## 1. Objective
Enable the "Teleport" experience by allowing users to securely connect to their remote workspaces with persistent terminal sessions.
## 2. Tasks
### Task 3.1: SSH Tunneling Logic (IAP)
Implement the mechanism to securely tunnel SSH traffic through Google Identity-Aware Proxy.
- [ ] Implement `SSHService` in `packages/core/src/services/sshService.ts`.
- [ ] Logic to execute `gcloud compute ssh --tunnel-through-iap`.
- [ ] Handle SSH key generation and OS Login checks.
### Task 3.2: Workspace Connect Command
Implement the primary `wsr connect <id>` command.
- [ ] Add `workspace connect` subcommand to `packages/cli/src/commands/workspace/connect.ts`.
- [ ] Logic to fetch instance details (name, zone) from the Hub before connecting.
- [ ] Pass necessary SSH flags (Agent Forwarding, Environment variables).
### Task 3.3: Persistence Integration (shpool)
Ensure the remote session survives disconnects.
- [ ] Update `remoteCli.ts` to wrap the shell in `shpool attach`.
- [ ] Verify `shpool` daemon is running correctly via the container entrypoint.
- [ ] Logic to handle terminal resizing across local/remote.
## 3. Verification & Success Criteria
- **Connect:** `gemini wsr connect [ID]` successfully drops the user into a remote shell.
- **Persistence:** User can disconnect (Ctrl+C or close terminal), reconnect, and find their previous state intact.
- **Security:** Connection only works for the workspace owner and requires a valid `gcloud` session.
## 4. Next Steps
- Implement Task 3.1: Create the `SSHService` in the core package.
+33
View File
@@ -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.
+32
View File
@@ -0,0 +1,32 @@
# Milestone 5 Sub-plan: UI & Advanced Hub Features
## 1. Objective
Provide a polished, interactive dashboard for managing workspaces and enhance the Hub with production-grade management features like TTL-based auto-cleanup and expanded multi-tenancy models.
## 2. Tasks
### Task 5.1: Workspaces "Ability" (React UI)
Create an interactive dashboard within the `gemini-cli`.
- [ ] Create `packages/cli/src/ui/abilities/workspaces/`.
- [ ] Implement `WorkspacesView` component using Ink.
- [ ] Logic to display a live-updating table of workspaces.
- [ ] Implement interactive actions: "Connect", "Start/Stop", "Delete" within the UI.
### Task 5.2: Hub Auto-Cleanup (TTL)
Prevent runaway GCP costs by cleaning up idle workspaces.
- [ ] Add `last_connected_at` tracking to `WorkspaceService`.
- [ ] Implement a `/cleanup` endpoint in the Hub.
- [ ] Logic to identify and delete/stop VMs that have been idle past a configurable TTL.
### Task 5.3: Expanded Multi-Tenancy (Team/Repo)
Enhance the Hub to support shared and automated environments.
- [ ] Implement `Team` mode logic where workspaces can be shared within a Google Group.
- [ ] Implement `Repo` mode primitives to tie workspaces to specific GitHub PRs.
## 3. Verification & Success Criteria
- **UI:** The user can navigate to the "Workspaces" ability and manage their fleet using keyboard shortcuts or a menu.
- **Cleanup:** A workspace not connected to for > TTL is automatically terminated by the Hub.
- **Tenancy:** Verified isolation and sharing rules in a simulated multi-user environment.
## 4. Next Steps
- Implement Task 5.1: Scaffold the Workspaces Ability in the CLI.
+52
View File
@@ -0,0 +1,52 @@
# Phase 1 Sub-plan: The Workspace Core
## 1. Objective
Establish the foundational execution environment (Container Image) and the
initial management service (Hub API).
## 2. Tasks
### Task 1.1: Define and Build Workspace Image
Create a Dockerfile that provides a complete, persistent development environment
for `gemini-cli`.
- [x] Create `packages/workspace-manager/docker/Dockerfile`.
- [x] Include: `node:20-slim`, `git`, `gh`, `rsync`, `tmux`, `shpool`.
- [x] Add the pre-built `gemini-cli` binary.
- [x] Define `entrypoint.sh` with secret injection and `shpool` daemon startup.
- [x] Verify image build locally: `docker build -t gemini-workspace:v1 .`.
### Task 1.2: Workspace Hub API (v1)
Implement the core API to manage GCE-based workspaces.
- [x] Initialize `packages/workspace-manager/`.
- [x] Implement Express server for `/workspaces` (List, Create, Delete).
- [x] Integrate Firestore to track workspace state (owner, instance_id, status).
- [x] Integrate `@google-cloud/compute` for GCE instance lifecycle.
- [x] Provision a VM with `Container-on-VM` settings pointing to the
`gemini-workspace` image.
### Task 1.3: Cloud Run Deployment (v1)
Prepare the Hub for self-service deployment.
- [x] Create `packages/workspace-manager/terraform/` for basic Hub provisioning.
- [x] Provide a `scripts/deploy-hub.sh` using `gcloud` for a zero-install
alternative.
## 3. Verification & Success Criteria
- **Image:** A container started from the image must have `gemini --version` and
`gh --version` available.
- **API:** A `POST /workspaces` call must result in a new VM appearing in the
specified GCP project with the correct container image.
- **State:** Firestore must correctly reflect the VM's `PROVISIONING` and
`READY` status.
## 4. Next Steps
- Milestone 2: Basic CLI Management (Phase 2). Add `workspace` commands to the
CLI.
+64
View File
@@ -0,0 +1,64 @@
# Gemini CLI Workspaces: High-Level Implementation Plan
## 1. Objective
Transform the architectural vision of "Gemini CLI Workspaces" into a
production-ready, self-service feature for `gemini-cli`.
## 2. Milestones & Phases
### Milestone 1: The Workspace Core (Phase 1)
Build the foundational container environment and the core management API.
- [x] Define and build the `Workspace Container Image`.
- [x] Deploy a basic `Workspace Hub` (Cloud Run) with GCE provisioning.
- [x] Implement simple `/create`, `/list`, `/delete` API endpoints.
### Milestone 2: Basic CLI Management (Phase 2)
Enable developers to manage their remote fleet from the local CLI. See
[Milestone 2 Sub-plan](./milestone-2-cli-management.md) for details.
- [x] Add `gemini workspace create/list/delete` commands.
- [x] Implement Hub authentication (Google OAuth/IAP).
- [x] Add local configuration for Hub discovery (`settings.json`).
### Milestone 3: Connectivity & Persistence (Phase 3)
Enable the "Teleport" experience with session persistence.
See [Milestone 3 Sub-plan](./milestone-3-connectivity.md) for details.
- [ ] Implement `gemini workspace connect`.
- [ ] Setup `gcloud compute ssh --tunnel-through-iap` logic in the client.
- [ ] 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`.
### Milestone 5: UI & Advanced Hub Features (Phase 5)
Polish the developer experience and add enterprise-grade Hub capabilities.
See [Milestone 5 Sub-plan](./milestone-5-ui-and-advanced.md) for details.
- [ ] Implement the "Workspaces Ability" in the CLI (interactive React UI).
- [ ] Implement multi-tenancy models (User, Team, Repo) in the Hub.
- [ ] Add auto-cleanup (TTL) and resource monitoring to the Hub.
## 3. Implementation Strategy
- **Surgical Changes:** Each phase will be implemented as a series of small,
verified PRs.
- **Verification:** Every phase must include integration tests (using mocks for
GCP if necessary).
- **Documentation:** Architecture docs will be updated as implementation details
evolve.
## 4. Next Steps
1. **Phase 1 Sub-plan:** Define the exact Dockerfile and initial Hub API
schema.
2. **Phase 1.1:** Build and push the initial `gemini-workspace:latest` image.
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# Copyright 2026 Google LLC
# SPDX-License-Identifier: Apache-2.0
set -e
# Configuration
PROJECT_ID=$(gcloud config get-value project)
if [ -z "$PROJECT_ID" ]; then
echo "Error: No GCP project configured. Run 'gcloud config set project [PROJECT_ID]'"
exit 1
fi
REGION="us-west1"
REPO_NAME="workspaces"
IMAGE_NAME="workspace-hub"
SERVICE_NAME="workspace-hub"
IMAGE_URI="$REGION-docker.pkg.dev/$PROJECT_ID/$REPO_NAME/$IMAGE_NAME"
echo "Using Project: $PROJECT_ID"
# 0. Ensure Artifact Registry exists
if ! gcloud artifacts repositories describe "$REPO_NAME" --location="$REGION" &>/dev/null; then
echo "Creating Artifact Registry repository: $REPO_NAME"
gcloud artifacts repositories create "$REPO_NAME" \
--repository-format=docker \
--location="$REGION" \
--description="Gemini CLI Workspaces Repository"
fi
# 1. Build and Push the Hub Image
echo "Building and pushing $IMAGE_NAME to Artifact Registry..."
gcloud builds submit --tag "$IMAGE_URI" packages/workspace-manager/
# 2. Deploy to Cloud Run
echo "Deploying $SERVICE_NAME to Cloud Run..."
gcloud run deploy "$SERVICE_NAME" \
--image "$IMAGE_URI" \
--platform managed \
--region "$REGION" \
--no-allow-unauthenticated \
--service-account "workspace-hub-sa@$PROJECT_ID.iam.gserviceaccount.com" \
--set-env-vars "GOOGLE_CLOUD_PROJECT=$PROJECT_ID"
echo "Deployment complete!"
gcloud run services describe "$SERVICE_NAME" --region "$REGION" --format 'value(status.url)'