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
68 changed files with 3625 additions and 499 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" }
]
},
+555 -152
View File
@@ -80,7 +80,7 @@
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0",
"keytar": "^7.9.0",
"node-pty": "^1.1.0"
"node-pty": "^1.0.0"
}
},
"node_modules/@agentclientprotocol/sdk": {
@@ -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",
@@ -4906,6 +4917,53 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vscode/vsce": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.6.0.tgz",
"integrity": "sha512-u2ZoMfymRNJb14aHNawnXJtXHLXDVKc1oKZaH4VELKT/9iWKRVgtQOdwxCgtwSxJoqYvuK4hGlBWQJ05wxADhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@azure/identity": "^4.1.0",
"@secretlint/node": "^10.1.1",
"@secretlint/secretlint-formatter-sarif": "^10.1.1",
"@secretlint/secretlint-rule-no-dotenv": "^10.1.1",
"@secretlint/secretlint-rule-preset-recommend": "^10.1.1",
"@vscode/vsce-sign": "^2.0.0",
"azure-devops-node-api": "^12.5.0",
"chalk": "^4.1.2",
"cheerio": "^1.0.0-rc.9",
"cockatiel": "^3.1.2",
"commander": "^12.1.0",
"form-data": "^4.0.0",
"glob": "^11.0.0",
"hosted-git-info": "^4.0.2",
"jsonc-parser": "^3.2.0",
"leven": "^3.1.0",
"markdown-it": "^14.1.0",
"mime": "^1.3.4",
"minimatch": "^3.0.3",
"parse-semver": "^1.1.1",
"read": "^1.0.7",
"secretlint": "^10.1.1",
"semver": "^7.5.2",
"tmp": "^0.2.3",
"typed-rest-client": "^1.8.4",
"url-join": "^4.0.1",
"xml2js": "^0.5.0",
"yauzl": "^2.3.1",
"yazl": "^2.2.2"
},
"bin": {
"vsce": "vsce"
},
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"keytar": "^7.7.0"
}
},
"node_modules/@vscode/vsce-sign": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.6.tgz",
@@ -5051,6 +5109,52 @@
"win32"
]
},
"node_modules/@vscode/vsce/node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
"integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==",
"dev": true,
"license": "ISC",
"dependencies": {
"lru-cache": "^6.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@vscode/vsce/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@vscode/vsce/node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"dev": true,
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@vscode/vsce/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true,
"license": "ISC"
},
"node_modules/@vue/compiler-core": {
"version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz",
@@ -5341,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",
@@ -6562,13 +6672,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/config-chain": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
@@ -7289,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",
@@ -9018,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",
@@ -11819,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"
@@ -12107,6 +12225,13 @@
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/nan": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
"integrity": "sha512-1UxuyYGdoQHcGg87Lkqm3FzefucTa0NAiOcuRsDmysep3c1LVCRK2krrUDafMWtjSG04htvAmvg96+SDknOmgQ==",
"license": "MIT",
"optional": true
},
"node_modules/nano-spawn": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-1.0.3.tgz",
@@ -12250,23 +12375,16 @@
}
},
"node_modules/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "^7.1.0"
"nan": "^2.17.0"
}
},
"node_modules/node-pty/node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT",
"optional": true
},
"node_modules/node-sarif-builder": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-3.2.0.tgz",
@@ -13169,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",
@@ -16490,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",
@@ -17910,23 +18043,6 @@
"node": ">=20"
}
},
"packages/snake-game": {
"name": "@google/gemini-cli-snake-game",
"version": "0.1.0",
"extraneous": true,
"license": "Apache-2.0",
"dependencies": {
"lucide-react": "^0.471.0"
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"esbuild": "^0.25.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"typescript": "^5.7.3"
}
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.36.0-nightly.20260317.2f90b4653",
@@ -17961,7 +18077,7 @@
"@types/vscode": "^1.99.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vscode/vsce": "^3.7.1",
"@vscode/vsce": "^3.6.0",
"esbuild": "^0.25.3",
"eslint": "^9.25.1",
"npm-run-all2": "^8.0.2",
@@ -17979,140 +18095,247 @@
"dev": true,
"license": "MIT"
},
"packages/vscode-ide-companion/node_modules/@vscode/vsce": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/@vscode/vsce/-/vsce-3.7.1.tgz",
"integrity": "sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g==",
"dev": true,
"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": {
"@azure/identity": "^4.1.0",
"@secretlint/node": "^10.1.2",
"@secretlint/secretlint-formatter-sarif": "^10.1.2",
"@secretlint/secretlint-rule-no-dotenv": "^10.1.2",
"@secretlint/secretlint-rule-preset-recommend": "^10.1.2",
"@vscode/vsce-sign": "^2.0.0",
"azure-devops-node-api": "^12.5.0",
"chalk": "^4.1.2",
"cheerio": "^1.0.0-rc.9",
"cockatiel": "^3.1.2",
"commander": "^12.1.0",
"form-data": "^4.0.0",
"glob": "^11.0.0",
"hosted-git-info": "^4.0.2",
"jsonc-parser": "^3.2.0",
"leven": "^3.1.0",
"markdown-it": "^14.1.0",
"mime": "^1.3.4",
"minimatch": "^3.0.3",
"parse-semver": "^1.1.1",
"read": "^1.0.7",
"secretlint": "^10.1.2",
"semver": "^7.5.2",
"tmp": "^0.2.3",
"typed-rest-client": "^1.8.4",
"url-join": "^4.0.1",
"xml2js": "^0.5.0",
"yauzl": "^2.3.1",
"yazl": "^2.2.2"
},
"bin": {
"vsce": "vsce"
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"keytar": "^7.7.0"
"node": ">= 0.6"
}
},
"packages/vscode-ide-companion/node_modules/@vscode/vsce/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"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": {
"brace-expansion": "^1.1.7"
"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": "*"
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"packages/vscode-ide-companion/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"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/vscode-ide-companion/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"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": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"packages/vscode-ide-companion/node_modules/glob": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
"integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"foreground-child": "^3.3.1",
"jackspeak": "^4.1.1",
"minimatch": "^10.1.1",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^2.0.0"
},
"bin": {
"glob": "dist/esm/bin.mjs"
"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": "20 || >=22"
"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/isaacs"
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/vscode-ide-companion/node_modules/hosted-git-info": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
"integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==",
"dev": true,
"license": "ISC",
"dependencies": {
"lru-cache": "^6.0.0"
},
"engines": {
"node": ">=10"
}
},
"packages/vscode-ide-companion/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"packages/vscode-ide-companion/node_modules/mime": {
"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==",
"dev": true,
"license": "MIT",
"bin": {
"mime": "cli.js"
@@ -18121,12 +18344,192 @@
"node": ">=4"
}
},
"packages/vscode-ide-companion/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"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": "ISC"
"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"
}
}
}
}
+1 -1
View File
@@ -150,7 +150,7 @@
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0",
"keytar": "^7.9.0",
"node-pty": "^1.1.0"
"node-pty": "^1.0.0"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
+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 -7
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';
@@ -97,7 +98,6 @@ export interface CliArgs {
rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined;
isCommand: boolean | undefined;
simulateUser: boolean | undefined;
}
/**
@@ -132,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', {
@@ -299,11 +300,6 @@ export async function parseArguments(
.option('accept-raw-output-risk', {
type: 'boolean',
description: 'Suppress the security warning when using --raw-output.',
})
.option('simulate-user', {
type: 'boolean',
description:
'Run the user simulation agent in the background for evaluation purposes.',
}),
)
// Register MCP subcommands
@@ -782,6 +778,7 @@ export async function loadCliConfig(
toolCallCommand: settings.tools?.callCommand,
mcpServerCommand,
mcpServers,
workspaces: settings.workspaces,
mcpEnablementCallbacks,
mcpEnabled,
extensionsEnabled,
@@ -805,7 +802,6 @@ export async function loadCliConfig(
approvalMode,
disableYoloMode:
settings.security?.disableYoloMode || settings.admin?.secureModeEnabled,
simulateUser: !!argv.simulateUser,
disableAlwaysAllow:
settings.security?.disableAlwaysAllow ||
settings.admin?.secureModeEnabled,
+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,
-1
View File
@@ -513,7 +513,6 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
simulateUser: undefined,
});
await act(async () => {
+4 -40
View File
@@ -9,15 +9,7 @@ import { render } from 'ink';
import { basename } from 'node:path';
import { AppContainer } from './ui/AppContainer.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { UserSimulator } from './services/UserSimulator.js';
import { registerCleanup, setupTtyCheck } from './utils/cleanup.js';
import { PassThrough } from 'node:stream';
interface RenderMetrics {
renderTime: number;
output: string;
staticOutput?: string;
}
import {
type StartupWarning,
type Config,
@@ -140,11 +132,6 @@ export async function startInteractiveUI(
await new Promise((resolve) => setTimeout(resolve, 100));
}
const simulateUser = config.getSimulateUser();
const simulatedStdin = new PassThrough({ encoding: 'utf8' });
let lastFrame: string | undefined;
const staticHistory: string[] = [];
const instance = render(
process.env['DEBUG'] ? (
<React.StrictMode>
@@ -156,20 +143,12 @@ export async function startInteractiveUI(
{
stdout: inkStdout,
stderr: inkStderr,
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
stdin: (simulateUser ? simulatedStdin : process.stdin) as any,
stdin: process.stdin,
exitOnCtrlC: false,
isScreenReaderEnabled: config.getScreenReader(),
onRender: (metrics: RenderMetrics) => {
lastFrame = metrics.output;
if (metrics.staticOutput) {
staticHistory.push(metrics.staticOutput);
if (staticHistory.length > 50) {
staticHistory.shift();
}
}
if (metrics.renderTime > SLOW_RENDER_MS) {
recordSlowRender(config, metrics.renderTime);
onRender: ({ renderTime }: { renderTime: number }) => {
if (renderTime > SLOW_RENDER_MS) {
recordSlowRender(config, renderTime);
}
profiler.reportFrameRendered();
},
@@ -200,21 +179,6 @@ export async function startInteractiveUI(
}
});
if (simulateUser) {
const simulator = new UserSimulator(
config,
() => {
if (lastFrame === undefined) return undefined;
// Combine history with latest frame for the simulator
const historyText = staticHistory.join('\n');
return historyText ? `${historyText}\n${lastFrame}` : lastFrame;
},
simulatedStdin,
);
simulator.start();
registerCleanup(() => simulator.stop());
}
registerCleanup(() => instance.unmount());
registerCleanup(setupTtyCheck());
+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 ===
-213
View File
@@ -1,213 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '@google/gemini-cli-core';
import {
debugLogger,
LlmRole,
PREVIEW_GEMINI_FLASH_MODEL,
resolveModel,
} from '@google/gemini-cli-core';
import type { Writable } from 'node:stream';
import * as fs from 'node:fs';
export class UserSimulator {
private isRunning = false;
private timer: NodeJS.Timeout | null = null;
private lastScreenContent = '';
private isProcessing = false;
private interactionsFile: string | null = null;
private actionHistory: string[] = [];
constructor(
private readonly config: Config,
private readonly getScreen: () => string | undefined,
private readonly stdinBuffer: Writable,
) {}
start() {
if (!this.config.getSimulateUser()) {
return;
}
this.interactionsFile = `interactions_${Date.now()}.txt`;
this.isRunning = true;
this.timer = setInterval(() => this.tick(), 3000);
}
stop() {
this.isRunning = false;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
debugLogger.log('User simulator stopped');
}
private async tick() {
if (!this.isRunning || this.isProcessing) return;
try {
this.isProcessing = true;
const screen = this.getScreen();
if (!screen) return;
const strippedScreen = screen
.replace(
// eslint-disable-next-line no-control-regex
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
'',
)
.replace(/\n([ \t]*\n)+/g, '\n\n');
const normalizedScreen = strippedScreen
.replace(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/g, '')
.replace(/\[?\s*\b\d+(\.\d+)?s\b\s*\]?/g, '')
.trim();
if (normalizedScreen === this.lastScreenContent) return;
debugLogger.log(
`[SIMULATOR] Screen Content Seen:\n---\n${strippedScreen}\n---`,
);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Screen Content Seen:\n---\n${strippedScreen}\n---\n\n`,
);
}
const contentGenerator = this.config.getContentGenerator();
if (!contentGenerator) return;
const originalGoal = this.config.getQuestion();
const goalInstruction = originalGoal
? `\nThe original goal was: "${originalGoal}"\n`
: '';
const historyInstruction =
this.actionHistory.length > 0
? `\nYou have previously taken the following actions (in order):\n${this.actionHistory.map((a, i) => `${i + 1}. ${JSON.stringify(a)}`).join('\n')}\nPay close attention to whether you have already asked for the original goal. If you have already submitted the original goal, DO NOT repeat it verbatim. If the UI shows your typed text but hasn't submitted it yet, just output \\r to press Enter.\n`
: '';
const prompt = `You are evaluating a CLI agent by simulating a user sitting at the terminal.
Look carefully at the screen and determine the CLI's current state:
STATE 1: The agent is busy (e.g., streaming a response, showing a spinner, running a tool, or displaying a timer like "7s"). It is actively working and NOT waiting for text input.
- In this case, you MUST output exactly: <WAIT>
STATE 2: The agent is waiting for you to authorize a tool, confirm an action, or answer a specific multi-choice question (e.g., "Action Required", "Allow execution", numbered options).
- In this case, you MUST output the exact raw characters to select the option and submit it (e.g., 1\\r, 2\\r, y\\r, n\\r, or just \\r if the default option is acceptable). Do NOT output <DONE> or "Thank you". You must unblock the agent and allow it to run the tool.
STATE 3: The agent has finished its current thought process AND is idle, waiting for a NEW general text prompt (usually indicated by a "> Type your message" prompt).
- First, verify that the ACTUAL task is fully complete based on your original goal. Do not stop at intermediate steps like planning or syntax checking.
- If the task is indeed fully complete, output "Thank you\\r" to graciously finish the simulation.
- If you have already said thank you, output exactly: <DONE>
- If the agent is waiting at a general text prompt but the original task is NOT complete, provide text instructions to continue what is missing. DO NOT repeat the original goal if it has already been provided once. Ask it to continue or provide feedback based on the current state or send <DONE> if you think the task is completed.
STATE 4: Any other situation where the agent is waiting for text input or needs to press Enter.
- Output the raw characters you would type, followed by \\r. For just an Enter key press, output \\r.
CRITICAL RULES:
- RULE 1: If there is ANY active spinner (e.g., , , , , , , ) or an elapsed time indicator (e.g., "0s", "7s") anywhere on the screen, the agent is STILL WORKING. You MUST output <WAIT>. Do NOT issue commands, even if a text prompt is visible below it.
- RULE 2: If there is an "Action Required" or confirmation prompt on the screen, YOU MUST HANDLE IT (State 2). This takes precedence over everything else.
- RULE 3: Output ONLY the raw characters to send, <WAIT>, or <DONE>.
- RULE 4: Do NOT output markdown, explanations of your thought process, or quotes.
${goalInstruction}${historyInstruction}
Here is the current terminal screen output:
<screen>
${strippedScreen}
</screen>`;
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Prompt Used:\n---\n${prompt}\n---\n\n`,
);
}
const model = resolveModel(
PREVIEW_GEMINI_FLASH_MODEL,
false, // useGemini3_1
false, // useCustomToolModel
this.config.getHasAccessToPreviewModel?.() ?? true,
this.config,
);
const response = await contentGenerator.generateContent(
{
model,
contents: [
{
role: 'user',
parts: [{ text: prompt }],
},
],
},
'simulator-prompt',
LlmRole.UTILITY_SIMULATOR,
);
const responseText = (response.text || '').replace(
/^[`"']+|[`"']+$/g,
'',
);
const trimmedResponse = responseText.trim();
debugLogger.log(
`[SIMULATOR] Raw model response: ${JSON.stringify(response.text)}`,
);
if (this.interactionsFile) {
fs.appendFileSync(
this.interactionsFile,
`[LOG] [SIMULATOR] Raw model response: ${JSON.stringify(response.text)}\n\n`,
);
}
debugLogger.log(
`[SIMULATOR] Processed response: ${JSON.stringify(responseText)}`,
);
if (trimmedResponse === '<DONE>') {
const msg = '[SIMULATOR] Terminating simulation: Task is completed.';
debugLogger.log(msg);
if (this.interactionsFile) {
fs.appendFileSync(this.interactionsFile, `[LOG] ${msg}\n\n`);
}
// eslint-disable-next-line no-console
console.log(`\n${msg}`);
this.stop();
process.exit(0);
}
if (trimmedResponse === '<WAIT>') {
debugLogger.log(
'[SIMULATOR] Skipping action (model decided to <WAIT>)',
);
this.lastScreenContent = normalizedScreen;
return;
}
if (responseText) {
const keys = responseText.replace(/\\n/g, '\r').replace(/\\r/g, '\r');
const readableAction = trimmedResponse.replace(/\\r/g, '[ENTER]');
this.actionHistory.push(readableAction);
debugLogger.log(
`[SIMULATOR] Sending to stdin: ${JSON.stringify(keys)}`,
);
this.stdinBuffer.write(keys);
this.lastScreenContent = normalizedScreen;
} else {
debugLogger.log('[SIMULATOR] Skipping (empty response)');
this.lastScreenContent = normalizedScreen;
}
} catch (e: unknown) {
debugLogger.error('UserSimulator tick failed', e);
} finally {
this.isProcessing = false;
}
}
}
@@ -62,7 +62,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getGeminiMdFileCount: vi.fn(() => 0),
getDeferredCommand: vi.fn(() => undefined),
getFileSystemService: vi.fn(() => ({})),
getSimulateUser: vi.fn(() => false),
clientVersion: '1.0.0',
getModel: vi.fn().mockReturnValue('gemini-pro'),
getWorkingDir: vi.fn().mockReturnValue('/mock/cwd'),
@@ -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 -8
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;
@@ -649,7 +659,6 @@ export interface ConfigParameters {
billing?: {
overageStrategy?: OverageStrategy;
};
simulateUser?: boolean;
}
export class Config implements McpContext, AgentLoopContext {
@@ -694,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[];
@@ -867,7 +878,6 @@ export class Config implements McpContext, AgentLoopContext {
private lastModeSwitchTime: number = performance.now();
readonly injectionService: InjectionService;
private approvedPlanPath: string | undefined;
private readonly simulateUser: boolean;
constructor(params: ConfigParameters) {
this._sessionId = params.sessionId;
@@ -905,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;
@@ -1097,7 +1108,6 @@ export class Config implements McpContext, AgentLoopContext {
this.fileExclusions = new FileExclusions(this);
this.eventEmitter = params.eventEmitter;
this.enableConseca = params.enableConseca ?? false;
this.simulateUser = params.simulateUser ?? false;
// Initialize Safety Infrastructure
const contextBuilder = new ContextBuilder(this);
@@ -2002,6 +2012,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.mcpServers;
}
getWorkspaces(): WorkspaceConfig | undefined {
return this.workspaces;
}
getMcpEnabled(): boolean {
return this.mcpEnabled;
}
@@ -2512,10 +2526,6 @@ export class Config implements McpContext, AgentLoopContext {
return this.usageStatisticsEnabled;
}
getSimulateUser(): boolean {
return this.simulateUser;
}
getAcpMode(): boolean {
return this.acpMode;
}
@@ -277,7 +277,7 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = {
family: 'gemini-3',
isPreview: true,
isVisible: true,
features: { thinking: true, multimodalToolUse: true },
features: { thinking: false, multimodalToolUse: true },
},
'gemini-2.5-pro': {
tier: 'pro',
+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}`);
}
}
}
-1
View File
@@ -16,5 +16,4 @@ export enum LlmRole {
UTILITY_EDIT_CORRECTOR = 'utility_edit_corrector',
UTILITY_AUTOCOMPLETE = 'utility_autocomplete',
UTILITY_FAST_ACK_HELPER = 'utility_fast_ack_helper',
UTILITY_SIMULATOR = 'utility_simulator',
}
-72
View File
@@ -1,72 +0,0 @@
# User Simulation Feature - Design Document
*Branch: `user-simulation`*
## 1. Overview
The User Simulation feature enables an autonomous LLM-driven agent to act as a realistic user in interactive terminal sessions. It aims to support integration testing, evaluation, and background evaluation of system capabilities by effectively observing the terminal interface and supplying contextual standard input (stdin).
This is accomplished by adding a `--simulate-user` option to the CLI, intercepting terminal updates via a simulated stdout reader, computing appropriate responses via an LLM, and piping these directly into a `PassThrough` stream masquerading as `process.stdin`.
## 2. Core Architecture
### 2.1 Configurations and Triggers
**Affected Files:**
- `packages/cli/src/config/config.ts`
- `packages/core/src/config/config.ts`
- `packages/cli/src/interactiveCli.tsx`
**Implementation:**
The feature is controlled via the explicit CLI argument `--simulate-user`. When supplied, the argument flows through the `Config` interface (`config.getSimulateUser()`), modifying how the interactive CLI bootstraps.
- Instead of attaching the raw `process.stdin` directly to the `ink` render environment, the UI engine binds to a Node.js `PassThrough` stream if simulation is enabled.
- The Interactive CLI session begins by initiating the `UserSimulator`, providing it access to the `Config`, the `simulatedStdin` buffer, and an observable snapshot of the `lastFrame` (the current state of the UI render).
### 2.2 The `UserSimulator` Service
**Affected Files:**
- `packages/cli/src/services/UserSimulator.ts`
**Implementation:**
The `UserSimulator` class governs the core observation and input loops. It handles interactions asynchronously outside of the primary command execution flow.
#### Loop Execution
The simulator runs via a repeating timer (3-second intervals `setInterval`), checking `tick()`. It ensures only one evaluation happens sequentially using a mutex lock (`this.isProcessing`).
#### Screen Processing and Environment Context
The engine receives the rendered terminal snapshot (`lastFrame`) and applies sanitizations:
- Removes ANSI terminal colors and cursor properties using regex filtering.
- Strips variable UI animation symbols (Spinners like ⠋, ⠙) and time indicators (e.g., `[ 7s ]`).
These visual stability checks allow the simulator to detect meaningful frame changes instead of being repeatedly triggered by minor clock updates.
#### Simulation Brain & LLM Integration
The simulator utilizes `PREVIEW_GEMINI_FLASH_MODEL`, leveraging the newly configured `thinking: true` capacity (`packages/core/src/config/defaultModelConfigs.ts`) for better reasoning, operating under the distinct telemetry role `LlmRole.UTILITY_SIMULATOR`.
The LLM is provided an extensive context schema encapsulating:
- **Terminal output:** Current stripped terminal representation.
- **Original task objective:** The intention of the CLI session.
- **Action Memory:** A tracked sequence (`actionHistory`) of previously executed actions to avoid getting stuck in inquiry loops.
#### State Machine Interpretation
The model translates the screen context into specific behavioral states:
- **STATE 1 (WAIT):** Triggered if any indication of internal "thinking" or processing exists (active spinners/timers). Returns `<WAIT>`.
- **STATE 2 (ACTION/AUTHORIZATION):** Prompt requests an approval (e.g., "Allow execution"). Supplies strict raw choice input.
- **STATE 3 (DONE):** Fully idle string detection paired with task completion fulfillment. Gracefully types `Thank you\r` and eventually exits indicating `<DONE>`.
- **STATE 4 (INPUT):** Idle state where continuation text instruction is needed, supplying direct commands injected to stdin.
#### Safeguards and Debugging
- **Anti-Stall Mechanism:** The simulator tracks repetitive actions. If the LLM repeats the exact same non-empty interaction `MAX_REPEATS` (3) times, it terminates the process abruptly to prevent infinite loops.
- **Interaction Transcripts:** Binds an audit log, dumping each parsed `[SIMULATOR] Screen Content Seen` block, the constructed `Prompt Used`, and `Raw model response` to discrete contextual `interactions_<timestamp>.txt` files, making it traceable.
### 2.3 Required Ecosystem Changes
- `packages/core/src/telemetry/llmRole.ts`: A new internal trace role `UTILITY_SIMULATOR` has been appended to separate evaluation events from main-thread generations.
- `packages/core/src/config/defaultModelConfigs.ts`: Added dynamic thought logic parameter (`thinking: true`) for `gemini-3-flash-preview` to enhance the simulation depth reasoning.
- Unit Testing Mocks: Updated dummy interface `mockConfig.ts` to ensure compatibility by safely mocking `getSimulateUser`.
## 3. Workflow Summary
1. The user launches `gemini --simulate-user`.
2. Interactive UI bindings swap `process.stdin` for a readable `PassThrough` stream.
3. Every 3 seconds, `UserSimulator.tick()` accesses the newest render frame.
4. The frame is stripped of unstable metrics/spinners. If the clean frame is unchanged since the last tick, it waits.
5. If changed, the simulator invokes Gemini Flash Preview (Thinking).
6. Gemini observes the state—emitting `<WAIT>` if busy, action keys for selections, `<DONE>` if completed, or commands to be resolved.
7. Return text is piped cleanly back to `simulatedStdin`—advancing the ink render state.
*This design effectively encapsulates the user interface flow without imposing side-effects on standard operations.*
+1 -1
View File
@@ -129,7 +129,7 @@
"@types/vscode": "^1.99.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vscode/vsce": "^3.7.1",
"@vscode/vsce": "^3.6.0",
"esbuild": "^0.25.3",
"eslint": "^9.25.1",
"npm-run-all2": "^8.0.2",
+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)'