feat(runtime): add Google Workspace CLI support

This commit is contained in:
Goon committed 2026-05-24 12:12:16 +07:00
1 parent 68684e4859
commit 12fb1baf79
13 files changed
+336 -12

No files matched your search

+1 -1
View File
@@ -92,7 +92,7 @@ RUN set -eux; \
apk add --no-cache python3 py3-pip nodejs npm pandoc github-cli poppler-utils bash; \
pip3 install --no-cache-dir --break-system-packages \
-r /tmp/requirements-base.txt -r /tmp/requirements-skills.txt; \
npm install -g --cache /tmp/npm-cache docx@^9.6.1 pptxgenjs@^4.0.1; \
npm install -g --cache /tmp/npm-cache docx@^9.6.1 pptxgenjs@^4.0.1 @googleworkspace/cli@0.22.5; \
rm -rf /tmp/npm-cache /root/.cache /var/cache/apk/*; \
else \
if [ "$ENABLE_PYTHON" = "true" ]; then \
+11 -7
View File
@@ -39,10 +39,11 @@ Pre-installed runtimes depend on the Docker image variant you deploy. The Packag
| Variant | Published tag | Build args | Pre-installed runtimes |
|---------|---------------|------------|------------------------|
| Minimal | `latest` | `ENABLE_PYTHON=false`, `ENABLE_NODE=false`, `ENABLE_FULL_SKILLS=false` | No Python or Node.js runtimes |
| Python | `python` | `ENABLE_PYTHON=true` | `python3`, `py3-pip`, `edge-tts` |
| Node | `node` | `ENABLE_NODE=true` | `nodejs`, `npm` |
| Full | `full` | `ENABLE_FULL_SKILLS=true` | `python3`, `py3-pip`, `nodejs`, `npm`, `pandoc`, `github-cli`, bundled skill deps |
| Latest | `latest` | `ENABLE_PYTHON=true`, `ENABLE_NODE=false`, `ENABLE_FULL_SKILLS=false` | `python3`, `py3-pip`, shared Python deps |
| Base | `base` | `ENABLE_PYTHON=false`, `ENABLE_NODE=false`, `ENABLE_FULL_SKILLS=false` | No Python or Node.js runtimes |
| Full | `full` | `ENABLE_FULL_SKILLS=true` | `python3`, `py3-pip`, `nodejs`, `npm`, `pandoc`, `github-cli`, bundled skill deps, Workspace CLI |
| Custom Python | not published | `ENABLE_PYTHON=true` | `python3`, `py3-pip`, shared Python deps |
| Custom Node | not published | `ENABLE_NODE=true` | `nodejs`, `npm` |
### Full Variant Extras
@@ -62,6 +63,7 @@ Pre-installed runtimes depend on the Docker image variant you deploy. The Packag
|---------|---------|
| `docx` | docx skill (document creation) |
| `pptxgenjs` | pptx skill (presentation creation) |
| `@googleworkspace/cli` (`gws`) | Google Workspace CLI for Drive, Gmail, Calendar, and Workspace APIs |
---
@@ -113,8 +115,10 @@ Default `{runtimeDir}` resolution:
The system prompt and UI should treat runtime availability as variant-dependent:
```
Minimal `latest`: Python/Node may be missing in the container.
`python`, `node`, and `full` variants pre-install different runtimes.
Published `latest`: Python is present; Node may be missing in the container.
Published `full`: Python, Node, and full skill extras are present.
Published `base`: Python and Node are absent.
Custom builds can set ENABLE_PYTHON=true or ENABLE_NODE=true.
To install additional packages: pip3 install <pkg> or npm install -g <pkg>
```
@@ -200,7 +204,7 @@ When a user uploads a skill with the same name via the UI, the managed version t
To add a new package to the Docker image:
1. **Python**: Add to the `pip3 install` line in `Dockerfile` (usually `full`, sometimes `python`)
2. **Node.js**: Add to the `npm install -g` line in `Dockerfile` (usually `full`, sometimes `node`)
2. **Node.js**: Add to the `npm install -g` line in `Dockerfile` (usually `full`, sometimes a custom `ENABLE_NODE=true` build)
3. **System tool**: Add to the `apk add` line in `Dockerfile`
4. **Docs/UI guidance**: Update runtime variant docs and any UI copy that describes pre-installed tools
5. **Rebuild**: `docker compose ... up -d --build`
+14
View File
@@ -301,6 +301,8 @@ SecureCLI is a feature that allows GoClaw to automatically inject credentials in
When an agent needs to run `gh auth`, `gcloud auth`, or other authenticated CLI commands, the admin can configure a SecureCLI binary with encrypted environment variables. The agent never sees the raw credentials — they are injected directly into the child process environment via Direct Exec Mode.
Built-in presets include `gh`, `gcloud`, `gws`, `aws`, `kubectl`, and `terraform`. The `gws` preset targets Google Workspace CLI (`@googleworkspace/cli`) and supports `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE`, `GOOGLE_WORKSPACE_CLI_TOKEN`, `GOOGLE_WORKSPACE_CLI_CLIENT_ID`, and `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET`.
### Database Schema
```sql
@@ -334,6 +336,18 @@ CREATE TABLE secure_cli_binaries (
| `POST` | `/v1/cli-credentials/{id}/test` | Dry-run test (requires admin) |
| `GET` | `/v1/cli-credentials/presets` | List preset templates for common CLIs |
### Google Workspace CLI preset
The `gws` preset is intended for server-side Google Workspace reads and reviewed admin workflows. It blocks interactive credential commands (`gws auth setup`, `gws auth login`, `gws auth export`, `gws auth logout`) because those flows can create, expose, or clear credentials outside GoClaw's encrypted store.
Credential options:
- `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE`: path to exported `gws` credentials or an OAuth credentials JSON file.
- `GOOGLE_WORKSPACE_CLI_TOKEN`: pre-obtained OAuth access token.
- `GOOGLE_WORKSPACE_CLI_CLIENT_ID` / `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET`: optional OAuth client values for deployments that manage auth outside the agent turn.
Use `docs/google-workspace-cli.md` for command examples and smoke-test guidance.
### Features
- **Agent-specific or global:** Configs can be scoped to a single agent or shared across all agents (agent_id = null)
+88
View File
@@ -0,0 +1,88 @@
# Google Workspace CLI Integration
GoClaw supports Google Workspace CLI through the `gws` binary from `@googleworkspace/cli`.
## Runtime availability
- Published `full` image: `gws` is preinstalled.
- Published `latest` image: Python is available, but Node/npm and `gws` are not preinstalled.
- Published `base` image: Python and Node/npm are not preinstalled.
- Custom Node-enabled builds: set `ENABLE_NODE=true`, then install `npm:@googleworkspace/cli` from the Packages page or `/v1/packages/install`.
`gws` requires Node.js 18+ when installed through npm.
## SecureCLI setup
Create a SecureCLI credential from the `gws` preset. Provide at least one usable auth source:
- `GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE`: exported `gws` credentials or an OAuth credentials JSON file path.
- `GOOGLE_WORKSPACE_CLI_TOKEN`: pre-obtained OAuth access token.
- `GOOGLE_WORKSPACE_CLI_CLIENT_ID` and `GOOGLE_WORKSPACE_CLI_CLIENT_SECRET`: optional OAuth client values for external auth flows.
The preset blocks these interactive or credential-exporting commands:
- `gws auth setup`
- `gws auth login`
- `gws auth export`
- `gws auth logout`
Run those flows outside agent execution, then store the resulting token or credentials file path in SecureCLI.
## Agent command patterns
Use `--params` for query parameters and `--json` for request bodies. Prefer read-only commands unless an admin has explicitly approved writes.
Drive:
```sh
gws drive files list --params '{"pageSize": 10}'
```
Gmail:
```sh
gws gmail users messages list --params '{"userId": "me", "maxResults": 10}'
```
Calendar:
```sh
gws calendar events list --params '{"calendarId": "primary", "maxResults": 10}'
```
Pagination:
```sh
gws drive files list --params '{"pageSize": 100}' --page-all
```
Schema inspection:
```sh
gws schema drive.files.list
```
## Validation
Without credentials, local validation can only prove packaging and credential injection:
```sh
gws --help
gws drive files list --help
```
With credentials available, run these smoke tests from a SecureCLI-enabled agent or equivalent runtime:
```sh
gws drive files list --params '{"pageSize": 1}'
gws gmail users messages list --params '{"userId": "me", "maxResults": 1}'
gws calendar events list --params '{"calendarId": "primary", "maxResults": 1}'
```
Do not mark live Google Workspace validation complete unless all three authenticated commands return successful JSON.
## Limitations
- Google Workspace auth and scopes are controlled by the configured Google account, OAuth app, token, or credentials file.
- Domain-wide delegation and account impersonation are not represented by a GoClaw preset env var. Configure those in Google Workspace and the credential file if needed.
- Write commands can modify Workspace data. Keep the default preset read-oriented, and create a separate reviewed SecureCLI config for approved write workflows.
+12
View File
@@ -6,6 +6,18 @@ Significant changes, features, and fixes in reverse chronological order.
## 2026-05-24
### Google Workspace CLI runtime integration
**Features**
- Added `gws` as a preinstalled Google Workspace CLI in the full runtime image.
- Added a SecureCLI `gws` preset with encrypted credential injection fields and guardrails for interactive auth/export commands.
- Documented Drive, Gmail, and Calendar command patterns plus live credential smoke-test requirements.
**Tests**
- Added runtime binary discovery, SecureCLI preset, deny-pattern, and Dockerfile contract coverage for Google Workspace CLI.
### Browser cookie sync and config UI
**Features**
@@ -49,6 +49,67 @@ func TestSecureCLICheckBinaryFindsRuntimeNpmBinary(t *testing.T) {
}
}
func TestSecureCLICheckBinaryFindsGoogleWorkspaceRuntimeBinary(t *testing.T) {
runtimeDir := t.TempDir()
t.Setenv("RUNTIME_DIR", runtimeDir)
t.Setenv("NPM_CONFIG_PREFIX", "")
t.Setenv("PATH", "/usr/bin")
binDir := filepath.Join(runtimeDir, "npm-global", "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
wantPath := filepath.Join(binDir, "gws")
if err := os.WriteFile(wantPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/v1/cli-credentials/check-binary", strings.NewReader(`{"binary_name":"gws"}`))
rec := httptest.NewRecorder()
NewSecureCLIHandler(nil, nil).handleCheckBinary(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var got struct {
Found bool `json:"found"`
Path string `json:"path"`
Error string `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if !got.Found {
t.Fatalf("found = false, error = %q", got.Error)
}
if got.Path != wantPath {
t.Fatalf("path = %q, want %q", got.Path, wantPath)
}
}
func TestSecureCLIPresetsIncludesGoogleWorkspace(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/cli-credentials/presets", nil)
rec := httptest.NewRecorder()
NewSecureCLIHandler(nil, nil).handlePresets(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var got struct {
Presets map[string]struct {
BinaryName string `json:"binary_name"`
} `json:"presets"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Presets["gws"].BinaryName != "gws" {
t.Fatalf("gws preset = %#v, want binary_name gws", got.Presets["gws"])
}
}
func TestSecureCLICheckBinaryFindsNpmPackageCliAlias(t *testing.T) {
runtimeDir := t.TempDir()
t.Setenv("RUNTIME_DIR", runtimeDir)
@@ -67,6 +67,29 @@ func TestFindRuntimeExecutableFindsNpmGlobalBinary(t *testing.T) {
}
}
func TestFindRuntimeExecutableFindsGoogleWorkspaceBinary(t *testing.T) {
runtimeDir := t.TempDir()
t.Setenv("RUNTIME_DIR", runtimeDir)
t.Setenv("NPM_CONFIG_PREFIX", "")
binDir := npmGlobalBinDir()
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
wantPath := filepath.Join(binDir, "gws")
if err := os.WriteFile(wantPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
got, ok := FindRuntimeExecutable("gws")
if !ok {
t.Fatalf("FindRuntimeExecutable did not find gws runtime binary")
}
if got != wantPath {
t.Fatalf("FindRuntimeExecutable path = %q, want %q", got, wantPath)
}
}
func TestFindRuntimeExecutableFindsNpmPackageCliAlias(t *testing.T) {
runtimeDir := t.TempDir()
t.Setenv("RUNTIME_DIR", runtimeDir)
@@ -99,6 +122,20 @@ func TestFindRuntimeExecutableFindsNpmPackageCliAlias(t *testing.T) {
}
}
func TestFullImagePreinstallsGoogleWorkspaceCLI(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "Dockerfile"))
if err != nil {
t.Fatal(err)
}
dockerfile := string(raw)
if !strings.Contains(dockerfile, "ENABLE_FULL_SKILLS") {
t.Fatal("Dockerfile contract test cannot locate ENABLE_FULL_SKILLS block")
}
if !strings.Contains(dockerfile, "@googleworkspace/cli@0.22.5") {
t.Fatal("full runtime image must preinstall @googleworkspace/cli@0.22.5")
}
}
func TestFindRuntimeExecutableRejectsPathLikeNames(t *testing.T) {
if got, ok := FindRuntimeExecutable("../openrouter"); ok || got != "" {
t.Fatalf("FindRuntimeExecutable path-like name = %q, %v; want empty false", got, ok)
+15 -1
View File
@@ -19,7 +19,7 @@ type CLIPreset struct {
type EnvVarDef struct {
Name string `json:"name"`
Desc string `json:"desc"`
IsFile bool `json:"is_file,omitempty"` // credential is a file path (e.g. GOOGLE_APPLICATION_CREDENTIALS)
IsFile bool `json:"is_file,omitempty"` // credential is a file path (e.g. GOOGLE_APPLICATION_CREDENTIALS)
Optional bool `json:"optional,omitempty"`
}
@@ -45,6 +45,20 @@ var CLIPresets = map[string]CLIPreset{
Timeout: 120,
Tips: "Use --format=json for structured output",
},
"gws": {
BinaryName: "gws",
Description: "Google Workspace CLI",
EnvVars: []EnvVarDef{
{Name: "GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE", Desc: "Path to exported gws credentials or OAuth credentials JSON", IsFile: true},
{Name: "GOOGLE_WORKSPACE_CLI_TOKEN", Desc: "Pre-obtained Google OAuth access token", Optional: true},
{Name: "GOOGLE_WORKSPACE_CLI_CLIENT_ID", Desc: "OAuth client ID for manual auth flows", Optional: true},
{Name: "GOOGLE_WORKSPACE_CLI_CLIENT_SECRET", Desc: "OAuth client secret for manual auth flows", Optional: true},
},
DenyArgs: []string{`auth\s+(setup|login|export|logout)`},
DenyVerbose: nil,
Timeout: 120,
Tips: "Use --params JSON for query parameters, --json for request bodies, and --page-all for paginated reads. Prefer read/list/get commands unless an admin has approved write commands.",
},
"aws": {
BinaryName: "aws",
Description: "AWS CLI",
@@ -0,0 +1,70 @@
package tools
import (
"encoding/json"
"slices"
"testing"
)
func TestGoogleWorkspacePresetContract(t *testing.T) {
preset := GetPreset("gws")
if preset == nil {
t.Fatal("gws preset is missing")
}
if preset.BinaryName != "gws" {
t.Fatalf("BinaryName = %q, want gws", preset.BinaryName)
}
envNames := make([]string, 0, len(preset.EnvVars))
for _, envVar := range preset.EnvVars {
envNames = append(envNames, envVar.Name)
}
for _, want := range []string{
"GOOGLE_WORKSPACE_CLI_CREDENTIALS_FILE",
"GOOGLE_WORKSPACE_CLI_TOKEN",
"GOOGLE_WORKSPACE_CLI_CLIENT_ID",
"GOOGLE_WORKSPACE_CLI_CLIENT_SECRET",
} {
if !slices.Contains(envNames, want) {
t.Fatalf("gws preset env vars = %v, missing %s", envNames, want)
}
}
if slices.Contains(envNames, "GOOGLE_WORKSPACE_CLI_IMPERSONATED_USER") {
t.Fatalf("gws preset must not expose removed upstream impersonation env var")
}
}
func TestGoogleWorkspacePresetDenyPatterns(t *testing.T) {
preset := GetPreset("gws")
if preset == nil {
t.Fatal("gws preset is missing")
}
denyArgs, err := json.Marshal(preset.DenyArgs)
if err != nil {
t.Fatal(err)
}
blocked := [][]string{
{"auth", "setup"},
{"auth", "login"},
{"auth", "export", "--unmasked"},
{"auth", "logout"},
}
for _, args := range blocked {
if got := matchesBinaryDeny(args, denyArgs); got == "" {
t.Fatalf("matchesBinaryDeny(%v) did not block auth credential command", args)
}
}
allowed := [][]string{
{"drive", "files", "list", "--params", `{"pageSize":1}`},
{"gmail", "users", "messages", "list", "--params", `{"userId":"me","maxResults":1}`},
{"calendar", "events", "list", "--params", `{"calendarId":"primary","maxResults":1}`},
{"schema", "drive.files.list"},
}
for _, args := range allowed {
if got := matchesBinaryDeny(args, denyArgs); got != "" {
t.Fatalf("matchesBinaryDeny(%v) = %q, want allowed", args, got)
}
}
}
+24
View File
@@ -294,3 +294,27 @@ func TestResolveAndMatchBinaryFallsBackToRuntimeExecutableDirs(t *testing.T) {
t.Fatalf("path = %q, want %q", got, binaryPath)
}
}
func TestResolveAndMatchBinaryFindsGoogleWorkspaceRuntimeBinary(t *testing.T) {
runtimeDir := t.TempDir()
t.Setenv("RUNTIME_DIR", runtimeDir)
t.Setenv("NPM_CONFIG_PREFIX", "")
t.Setenv("PATH", "/usr/bin")
binDir := filepath.Join(runtimeDir, "npm-global", "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
binaryPath := filepath.Join(binDir, "gws")
if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
got, err := resolveAndMatchBinary("gws", nil)
if err != nil {
t.Fatalf("resolveAndMatchBinary returned error: %v", err)
}
if got != binaryPath {
t.Fatalf("path = %q, want %q", got, binaryPath)
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
"title": "Runtimes",
"scopeTitle": "Container-scoped runtime status",
"scopeDesc": "These checks run inside the active GoClaw runtime container. Host-installed runtimes, shell profile changes, and nvm-managed binaries are not used here.",
"minimalImageHint": "The published latest image is minimal, so missing Python or Node runtimes can be expected there. Use the python, node, or full image variant, rebuild with ENABLE_PYTHON=true / ENABLE_NODE=true / ENABLE_FULL_SKILLS=true, or install runtimes manually in this container.",
"minimalImageHint": "Published latest includes Python but not Node. Use the full image for Node/npm and bundled extras, use base for the smallest image, rebuild with ENABLE_NODE=true / ENABLE_FULL_SKILLS=true, or install runtimes manually in this container.",
"available": "Available",
"missing": "Missing",
"missingInContainer": "Missing in container"
+1 -1
View File
@@ -5,7 +5,7 @@
"title": "Runtimes",
"scopeTitle": "Trạng thái runtime theo container",
"scopeDesc": "Các kiểm tra này chạy bên trong container runtime GoClaw hiện tại. Runtime cài trên máy host, thay đổi shell profile và binary do nvm quản lý sẽ không được dùng ở đây.",
"minimalImageHint": "Image latest được phát hành là bản tối giản, nên việc thiếu Python hoặc Node ở đó có thể là bình thường. Hãy dùng biến thể image python, node hoặc full, build lại với ENABLE_PYTHON=true / ENABLE_NODE=true / ENABLE_FULL_SKILLS=true, hoặc cài runtime trực tiếp trong container này.",
"minimalImageHint": "Image latest được phát hành có Python nhưng chưa có Node. Dùng image full để có Node/npm và extras, dùng base cho image nhỏ nhất, build lại với ENABLE_NODE=true / ENABLE_FULL_SKILLS=true, hoặc cài runtime trực tiếp trong container này.",
"available": "Sẵn sàng",
"missing": "Chưa cài",
"missingInContainer": "Thiếu trong container"
+1 -1
View File
@@ -5,7 +5,7 @@
"title": "运行时",
"scopeTitle": "容器范围内的运行时状态",
"scopeDesc": "这些检查在当前活动的 GoClaw 运行时容器内执行。宿主机已安装的运行时、shell profile 变更以及由 nvm 管理的二进制文件都不会在这里被使用。",
"minimalImageHint": "已发布的 latest 镜像是最小化变体,因此缺少 Python 或 Node 运行时在该镜像中可能是正常现象。请改用 python、node 或 full 镜像变体,或使用 ENABLE_PYTHON=true / ENABLE_NODE=true / ENABLE_FULL_SKILLS=true 重新构建,或者直接在此容器中手动安装运行时。",
"minimalImageHint": "已发布的 latest 镜像包含 Python,但不包含 Node。需要 Node/npm 和内置扩展时请使用 full 镜像,需要最小镜像时使用 base,或使用 ENABLE_NODE=true / ENABLE_FULL_SKILLS=true 重新构建,也可以直接在此容器中手动安装运行时。",
"available": "可用",
"missing": "缺失",
"missingInContainer": "容器内缺失"