feat(apps-script): add +pull, +open, +run, +logs helpers with .clasp.json support

- Add +pull: download project files to local directory with filename sanitization
- Add +open: open script editor in browser
- Add +run: execute functions with cloud-platform scope and contextual error hints
  (extracts GCP project number from OAuth client ID for setup guidance)
- Add +logs: view execution logs via processes.listScriptProcesses
- Add .clasp.json support: auto-resolve scriptId/rootDir for all helpers
- Add clasp_config.rs module with path traversal validation
- Add validate_script_filename() for safe file writes during +pull
- Regenerate skills for new helper commands
This commit is contained in:
jpoehnelt-bot
2026-03-04 14:52:15 -07:00
parent 2bbe1472c1
commit b5c8ef8002
13 changed files with 1102 additions and 68 deletions
Generated
+37
View File
@@ -860,6 +860,7 @@ dependencies = [
"futures-util",
"hostname",
"keyring",
"open",
"percent-encoding",
"rand 0.8.5",
"ratatui",
@@ -1243,6 +1244,25 @@ dependencies = [
"serde",
]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -1510,6 +1530,17 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc"
dependencies = [
"is-wsl",
"libc",
"pathdiff",
]
[[package]]
name = "openssl-probe"
version = "0.2.1"
@@ -1554,6 +1585,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "percent-encoding"
version = "2.3.2"
+1
View File
@@ -55,6 +55,7 @@ chrono = "0.4.44"
keyring = "3.6.3"
async-trait = "0.1.89"
serde_yaml = "0.9.34"
open = "5"
percent-encoding = "2.3.2"
+4
View File
@@ -52,6 +52,10 @@ Shortcut commands for common operations.
| [gws-docs-write](../skills/gws-docs-write/SKILL.md) | Google Docs: Append text to a document. |
| [gws-chat-send](../skills/gws-chat-send/SKILL.md) | Google Chat: Send a message to a space. |
| [gws-apps-script-push](../skills/gws-apps-script-push/SKILL.md) | Google Apps Script: Upload local files to an Apps Script project. |
| [gws-apps-script-pull](../skills/gws-apps-script-pull/SKILL.md) | Google Apps Script: Download project files to local directory. |
| [gws-apps-script-open](../skills/gws-apps-script-open/SKILL.md) | Google Apps Script: Open the script editor in your browser. |
| [gws-apps-script-run](../skills/gws-apps-script-run/SKILL.md) | Google Apps Script: Execute a function in the script. |
| [gws-apps-script-logs](../skills/gws-apps-script-logs/SKILL.md) | Google Apps Script: View execution logs for the script. |
| [gws-events-subscribe](../skills/gws-events-subscribe/SKILL.md) | Google Workspace Events: Subscribe to Workspace events and stream them as NDJSON. |
| [gws-events-renew](../skills/gws-events-renew/SKILL.md) | Google Workspace Events: Renew/reactivate Workspace Events subscriptions. |
| [gws-modelarmor-sanitize-prompt](../skills/gws-modelarmor-sanitize-prompt/SKILL.md) | Google Model Armor: Sanitize a user prompt through a Model Armor template. |
+46
View File
@@ -0,0 +1,46 @@
---
name: gws-apps-script-logs
version: 1.0.0
description: "Google Apps Script: View execution logs for the script."
metadata:
openclaw:
category: "productivity"
requires:
bins: ["gws"]
cliHelp: "gws apps-script +logs --help"
---
# apps-script +logs
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
View execution logs for the script
## Usage
```bash
gws apps-script +logs
```
## Flags
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `--script` | — | — | Script Project ID (reads .clasp.json if omitted) |
## Examples
```bash
gws script +logs --script SCRIPT_ID
gws script +logs # uses .clasp.json
```
## Tips
- Shows recent script executions and their status.
- Use --format table for a readable summary.
## See Also
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
- [gws-apps-script](../gws-apps-script/SKILL.md) — All manage and execute apps script projects commands
+41
View File
@@ -0,0 +1,41 @@
---
name: gws-apps-script-open
version: 1.0.0
description: "Google Apps Script: Open the script editor in your browser."
metadata:
openclaw:
category: "productivity"
requires:
bins: ["gws"]
cliHelp: "gws apps-script +open --help"
---
# apps-script +open
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
Open the script editor in your browser
## Usage
```bash
gws apps-script +open
```
## Flags
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `--script` | — | — | Script Project ID (reads .clasp.json if omitted) |
## Examples
```bash
gws script +open --script SCRIPT_ID
gws script +open # uses .clasp.json
```
## See Also
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
- [gws-apps-script](../gws-apps-script/SKILL.md) — All manage and execute apps script projects commands
+47
View File
@@ -0,0 +1,47 @@
---
name: gws-apps-script-pull
version: 1.0.0
description: "Google Apps Script: Download project files to local directory."
metadata:
openclaw:
category: "productivity"
requires:
bins: ["gws"]
cliHelp: "gws apps-script +pull --help"
---
# apps-script +pull
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
Download project files to local directory
## Usage
```bash
gws apps-script +pull
```
## Flags
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `--script` | — | — | Script Project ID (reads .clasp.json if omitted) |
| `--dir` | — | — | Output directory (reads .clasp.json rootDir, or defaults to current dir) |
## Examples
```bash
gws script +pull --script SCRIPT_ID
gws script +pull --script SCRIPT_ID --dir ./src
gws script +pull # uses .clasp.json
FILES CREATED:
SERVER_JS → {name}.gs
HTML → {name}.html
JSON → appsscript.json
```
## See Also
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
- [gws-apps-script](../gws-apps-script/SKILL.md) — All manage and execute apps script projects commands
+4 -3
View File
@@ -19,21 +19,22 @@ Upload local files to an Apps Script project
## Usage
```bash
gws apps-script +push --script <ID>
gws apps-script +push
```
## Flags
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `--script` | | — | Script Project ID |
| `--dir` | — | — | Directory containing script files (defaults to current dir) |
| `--script` | | — | Script Project ID (reads .clasp.json if omitted) |
| `--dir` | — | — | Directory containing script files (reads .clasp.json rootDir, or defaults to current dir) |
## Examples
```bash
gws script +push --script SCRIPT_ID
gws script +push --script SCRIPT_ID --dir ./src
gws script +push # uses .clasp.json
```
## Tips
+50
View File
@@ -0,0 +1,50 @@
---
name: gws-apps-script-run
version: 1.0.0
description: "Google Apps Script: Execute a function in the script."
metadata:
openclaw:
category: "productivity"
requires:
bins: ["gws"]
cliHelp: "gws apps-script +run --help"
---
# apps-script +run
> **PREREQUISITE:** Read `../gws-shared/SKILL.md` for auth, global flags, and security rules. If missing, run `gws generate-skills` to create it.
Execute a function in the script
## Usage
```bash
gws apps-script +run --function <NAME>
```
## Flags
| Flag | Required | Default | Description |
|------|----------|---------|-------------|
| `--script` | — | — | Script Project ID (reads .clasp.json if omitted) |
| `--function` | ✓ | — | Function name to execute |
| `--dev-mode` | — | — | Run the script in dev mode (HEAD deployment) |
## Examples
```bash
gws script +run --script SCRIPT_ID --function main
gws script +run --function main # uses .clasp.json
gws script +run --function main --dev-mode
SETUP REQUIREMENTS:
1. Auth with cloud-platform scope: gws auth login
2. Link the script to your OAuth client's GCP project:
Open the script editor (gws apps-script +open) → Project Settings →
Change GCP project → enter your project number.
3. Add to appsscript.json: "executionApi": {"access": "MYSELF"}
```
## See Also
- [gws-shared](../gws-shared/SKILL.md) — Global flags and auth
- [gws-apps-script](../gws-apps-script/SKILL.md) — All manage and execute apps script projects commands
+4
View File
@@ -23,6 +23,10 @@ gws apps-script <resource> <method> [flags]
| Command | Description |
|---------|-------------|
| [`+push`](../gws-apps-script-push/SKILL.md) | Upload local files to an Apps Script project |
| [`+pull`](../gws-apps-script-pull/SKILL.md) | Download project files to local directory |
| [`+open`](../gws-apps-script-open/SKILL.md) | Open the script editor in your browser |
| [`+run`](../gws-apps-script-run/SKILL.md) | Execute a function in the script |
| [`+logs`](../gws-apps-script-logs/SKILL.md) | View execution logs for the script |
## API Resources
+220
View File
@@ -0,0 +1,220 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Support for reading `.clasp.json` configuration files.
//!
//! This allows `gws` to be a drop-in replacement for `clasp` by reusing the
//! same project configuration format. When `--script` is omitted, helpers
//! will attempt to read the script ID from `.clasp.json` in the current directory.
use crate::error::GwsError;
use serde::Deserialize;
use std::path::PathBuf;
/// Represents a `.clasp.json` configuration file.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClaspConfig {
pub script_id: String,
pub root_dir: Option<String>,
}
/// Attempts to load `.clasp.json` from the current working directory.
///
/// Returns `Ok(Some(config))` if found and valid, `Ok(None)` if no file exists,
/// or `Err` if the file exists but is malformed or contains unsafe values.
pub fn load_clasp_config() -> Result<Option<ClaspConfig>, GwsError> {
let path = PathBuf::from(".clasp.json");
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(&path)
.map_err(|e| GwsError::Validation(format!("Failed to read .clasp.json: {e}")))?;
let config: ClaspConfig = serde_json::from_str(&contents)
.map_err(|e| GwsError::Validation(format!("Failed to parse .clasp.json: {e}")))?;
// Validate scriptId against injection
if config.script_id.is_empty() {
return Err(GwsError::Validation(
".clasp.json: scriptId must not be empty".to_string(),
));
}
crate::validate::validate_resource_name(&config.script_id)
.map_err(|e| GwsError::Validation(format!(".clasp.json: invalid scriptId: {e}")))?;
// Validate rootDir against path traversal if present
if let Some(ref root_dir) = config.root_dir {
if root_dir != "." {
crate::validate::validate_safe_dir_path(root_dir)
.map_err(|e| GwsError::Validation(format!(".clasp.json: invalid rootDir: {e}")))?;
}
}
Ok(Some(config))
}
/// Resolves the script ID from an explicit `--script` flag or `.clasp.json`.
///
/// Returns the script ID or an error with a helpful message.
pub fn resolve_script_id(explicit: Option<&String>) -> Result<String, GwsError> {
if let Some(id) = explicit {
return Ok(id.clone());
}
match load_clasp_config()? {
Some(config) => Ok(config.script_id),
None => Err(GwsError::Validation(
"No --script flag provided and no .clasp.json found in current directory. \
Either pass --script <ID> or create a .clasp.json with {\"scriptId\": \"...\"}."
.to_string(),
)),
}
}
/// Resolves the working directory from `--dir`, `.clasp.json` `rootDir`, or CWD.
pub fn resolve_dir(explicit_dir: Option<&String>) -> Result<PathBuf, GwsError> {
if let Some(dir) = explicit_dir {
return crate::validate::validate_safe_dir_path(dir);
}
if let Ok(Some(config)) = load_clasp_config() {
if let Some(ref root_dir) = config.root_dir {
return crate::validate::validate_safe_dir_path(root_dir);
}
}
std::env::current_dir()
.map_err(|e| GwsError::Validation(format!("Failed to determine current directory: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::fs;
use tempfile::tempdir;
#[test]
#[serial]
fn test_clasp_config_parse() {
let dir = tempdir().unwrap();
let canonical = dir.path().canonicalize().unwrap();
let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical).unwrap();
fs::write(".clasp.json", r#"{"scriptId": "abc123", "rootDir": "."}"#).unwrap();
let config = load_clasp_config().unwrap().unwrap();
assert_eq!(config.script_id, "abc123");
assert_eq!(config.root_dir.as_deref(), Some("."));
std::env::set_current_dir(&saved_cwd).unwrap();
}
#[test]
#[serial]
fn test_clasp_config_missing() {
let dir = tempdir().unwrap();
let canonical = dir.path().canonicalize().unwrap();
let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical).unwrap();
let config = load_clasp_config().unwrap();
assert!(config.is_none());
std::env::set_current_dir(&saved_cwd).unwrap();
}
#[test]
#[serial]
fn test_clasp_config_no_root_dir() {
let dir = tempdir().unwrap();
let canonical = dir.path().canonicalize().unwrap();
let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical).unwrap();
fs::write(".clasp.json", r#"{"scriptId": "xyz789"}"#).unwrap();
let config = load_clasp_config().unwrap().unwrap();
assert_eq!(config.script_id, "xyz789");
assert!(config.root_dir.is_none());
std::env::set_current_dir(&saved_cwd).unwrap();
}
#[test]
#[serial]
fn test_clasp_config_malicious_root_dir() {
let dir = tempdir().unwrap();
let canonical = dir.path().canonicalize().unwrap();
let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical).unwrap();
fs::write(
".clasp.json",
r#"{"scriptId": "abc", "rootDir": "../../.ssh"}"#,
)
.unwrap();
let result = load_clasp_config();
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("rootDir"), "got: {msg}");
std::env::set_current_dir(&saved_cwd).unwrap();
}
#[test]
#[serial]
fn test_clasp_config_malicious_script_id() {
let dir = tempdir().unwrap();
let canonical = dir.path().canonicalize().unwrap();
let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical).unwrap();
fs::write(".clasp.json", r#"{"scriptId": "../../../etc/passwd"}"#).unwrap();
let result = load_clasp_config();
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("scriptId"), "got: {msg}");
std::env::set_current_dir(&saved_cwd).unwrap();
}
#[test]
#[serial]
fn test_resolve_script_id_explicit() {
let id = resolve_script_id(Some(&"explicit123".to_string())).unwrap();
assert_eq!(id, "explicit123");
}
#[test]
#[serial]
fn test_resolve_script_id_no_config() {
let dir = tempdir().unwrap();
let canonical = dir.path().canonicalize().unwrap();
let saved_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&canonical).unwrap();
let result = resolve_script_id(None);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains(".clasp.json"), "got: {msg}");
std::env::set_current_dir(&saved_cwd).unwrap();
}
}
+1
View File
@@ -18,6 +18,7 @@ use std::future::Future;
use std::pin::Pin;
pub mod calendar;
pub mod chat;
pub mod clasp_config;
pub mod docs;
pub mod drive;
pub mod events;
+538 -65
View File
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use super::clasp_config;
use super::Helper;
use crate::auth;
use crate::error::GwsError;
@@ -32,20 +33,20 @@ impl Helper for ScriptHelper {
mut cmd: Command,
_doc: &crate::discovery::RestDescription,
) -> Command {
// +push
cmd = cmd.subcommand(
Command::new("+push")
.about("[Helper] Upload local files to an Apps Script project")
.arg(
Arg::new("script")
.long("script")
.help("Script Project ID")
.required(true)
.help("Script Project ID (reads .clasp.json if omitted)")
.value_name("ID"),
)
.arg(
Arg::new("dir")
.long("dir")
.help("Directory containing script files (defaults to current dir)")
.help("Directory containing script files (reads .clasp.json rootDir, or defaults to current dir)")
.value_name("DIR"),
)
.after_help(
@@ -53,6 +54,7 @@ impl Helper for ScriptHelper {
EXAMPLES:
gws script +push --script SCRIPT_ID
gws script +push --script SCRIPT_ID --dir ./src
gws script +push # uses .clasp.json
TIPS:
Supports .gs, .js, .html, and appsscript.json files.
@@ -60,6 +62,116 @@ TIPS:
This replaces ALL files in the project.",
),
);
// +pull
cmd = cmd.subcommand(
Command::new("+pull")
.about("[Helper] Download project files to local directory")
.arg(
Arg::new("script")
.long("script")
.help("Script Project ID (reads .clasp.json if omitted)")
.value_name("ID"),
)
.arg(
Arg::new("dir")
.long("dir")
.help("Output directory (reads .clasp.json rootDir, or defaults to current dir)")
.value_name("DIR"),
)
.after_help(
"\
EXAMPLES:
gws script +pull --script SCRIPT_ID
gws script +pull --script SCRIPT_ID --dir ./src
gws script +pull # uses .clasp.json
FILES CREATED:
SERVER_JS {name}.gs
HTML {name}.html
JSON appsscript.json",
),
);
// +open
cmd = cmd.subcommand(
Command::new("+open")
.about("[Helper] Open the script editor in your browser")
.arg(
Arg::new("script")
.long("script")
.help("Script Project ID (reads .clasp.json if omitted)")
.value_name("ID"),
)
.after_help(
"\
EXAMPLES:
gws script +open --script SCRIPT_ID
gws script +open # uses .clasp.json",
),
);
// +run
cmd = cmd.subcommand(
Command::new("+run")
.about("[Helper] Execute a function in the script")
.arg(
Arg::new("script")
.long("script")
.help("Script Project ID (reads .clasp.json if omitted)")
.value_name("ID"),
)
.arg(
Arg::new("function")
.long("function")
.help("Function name to execute")
.required(true)
.value_name("NAME"),
)
.arg(
Arg::new("dev-mode")
.long("dev-mode")
.help("Run the script in dev mode (HEAD deployment)")
.action(clap::ArgAction::SetTrue),
)
.after_help(
"\
EXAMPLES:
gws script +run --script SCRIPT_ID --function main
gws script +run --function main # uses .clasp.json
gws script +run --function main --dev-mode
SETUP REQUIREMENTS:
1. Auth with cloud-platform scope: gws auth login
2. Link the script to your OAuth client's GCP project:
Open the script editor (gws apps-script +open) Project Settings
Change GCP project enter your project number.
3. Add to appsscript.json: \"executionApi\": {\"access\": \"MYSELF\"}",
),
);
// +logs
cmd = cmd.subcommand(
Command::new("+logs")
.about("[Helper] View execution logs for the script")
.arg(
Arg::new("script")
.long("script")
.help("Script Project ID (reads .clasp.json if omitted)")
.value_name("ID"),
)
.after_help(
"\
EXAMPLES:
gws script +logs --script SCRIPT_ID
gws script +logs # uses .clasp.json
TIPS:
Shows recent script executions and their status.
Use --format table for a readable summary.",
),
);
cmd
}
@@ -70,74 +182,426 @@ TIPS:
_sanitize_config: &'a crate::helpers::modelarmor::SanitizeConfig,
) -> Pin<Box<dyn Future<Output = Result<bool, GwsError>> + Send + 'a>> {
Box::pin(async move {
if let Some(matches) = matches.subcommand_matches("+push") {
let script_id = matches.get_one::<String>("script").unwrap();
let dir_path = matches
.get_one::<String>("dir")
.map(|s| s.as_str())
.unwrap_or(".");
let safe_dir = crate::validate::validate_safe_dir_path(dir_path)?;
let mut files = Vec::new();
visit_dirs(&safe_dir, &mut files)?;
if files.is_empty() {
return Err(GwsError::Validation(format!(
"No eligible files found in '{}'",
dir_path
)));
}
// Find method: projects.updateContent
let projects_res = doc.resources.get("projects").ok_or_else(|| {
GwsError::Discovery("Resource 'projects' not found".to_string())
})?;
let update_method = projects_res.methods.get("updateContent").ok_or_else(|| {
GwsError::Discovery("Method 'projects.updateContent' not found".to_string())
})?;
// Build body
let body = json!({
"files": files
});
let body_str = body.to_string();
let scopes: Vec<&str> = update_method.scopes.iter().map(|s| s.as_str()).collect();
let (token, auth_method) = match auth::get_token(&scopes).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) => (None, executor::AuthMethod::None),
};
let params = json!({
"scriptId": script_id
});
let params_str = params.to_string();
executor::execute_method(
doc,
update_method,
Some(&params_str),
Some(&body_str),
token.as_deref(),
auth_method,
None,
None,
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
return Ok(true);
if let Some(sub) = matches.subcommand_matches("+push") {
return handle_push(doc, sub).await.map(|_| true);
}
if let Some(sub) = matches.subcommand_matches("+pull") {
return handle_pull(doc, sub).await.map(|_| true);
}
if let Some(sub) = matches.subcommand_matches("+open") {
return handle_open(sub).await.map(|_| true);
}
if let Some(sub) = matches.subcommand_matches("+run") {
return handle_run(doc, sub).await.map(|_| true);
}
if let Some(sub) = matches.subcommand_matches("+logs") {
return handle_logs(doc, sub).await.map(|_| true);
}
Ok(false)
})
}
}
// ---------------------------------------------------------------------------
// +push
// ---------------------------------------------------------------------------
async fn handle_push(
doc: &crate::discovery::RestDescription,
matches: &ArgMatches,
) -> Result<(), GwsError> {
let script_id = clasp_config::resolve_script_id(matches.get_one::<String>("script"))?;
let dir = clasp_config::resolve_dir(matches.get_one::<String>("dir"))?;
let mut files = Vec::new();
visit_dirs(&dir, &mut files)?;
if files.is_empty() {
return Err(GwsError::Validation(format!(
"No eligible files found in '{}'",
dir.display()
)));
}
let projects_res = doc
.resources
.get("projects")
.ok_or_else(|| GwsError::Discovery("Resource 'projects' not found".to_string()))?;
let update_method = projects_res.methods.get("updateContent").ok_or_else(|| {
GwsError::Discovery("Method 'projects.updateContent' not found".to_string())
})?;
let body = json!({ "files": files });
let body_str = body.to_string();
let scopes: Vec<&str> = update_method.scopes.iter().map(|s| s.as_str()).collect();
let (token, auth_method) = match auth::get_token(&scopes).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) => (None, executor::AuthMethod::None),
};
let params = json!({ "scriptId": script_id });
let params_str = params.to_string();
executor::execute_method(
doc,
update_method,
Some(&params_str),
Some(&body_str),
token.as_deref(),
auth_method,
None,
None,
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
Ok(())
}
// ---------------------------------------------------------------------------
// +pull
// ---------------------------------------------------------------------------
async fn handle_pull(
doc: &crate::discovery::RestDescription,
matches: &ArgMatches,
) -> Result<(), GwsError> {
let script_id = clasp_config::resolve_script_id(matches.get_one::<String>("script"))?;
let output_dir = matches
.get_one::<String>("dir")
.map(|d| crate::validate::validate_safe_output_dir(d))
.transpose()?
.unwrap_or_else(|| {
clasp_config::resolve_dir(None).unwrap_or_else(|_| std::env::current_dir().unwrap())
});
let projects_res = doc
.resources
.get("projects")
.ok_or_else(|| GwsError::Discovery("Resource 'projects' not found".to_string()))?;
let get_content_method = projects_res
.methods
.get("getContent")
.ok_or_else(|| GwsError::Discovery("Method 'projects.getContent' not found".to_string()))?;
let scopes: Vec<&str> = get_content_method
.scopes
.iter()
.map(|s| s.as_str())
.collect();
let (token, auth_method) = match auth::get_token(&scopes).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) => (None, executor::AuthMethod::None),
};
let params = json!({ "scriptId": script_id });
let params_str = params.to_string();
let response = executor::execute_method(
doc,
get_content_method,
Some(&params_str),
None,
token.as_deref(),
auth_method,
None,
None,
false,
&executor::PaginationConfig::default(),
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
true, // capture output
)
.await?;
let response_value =
response.ok_or_else(|| GwsError::Validation("No response from getContent".to_string()))?;
let files = response_value
.get("files")
.and_then(|f| f.as_array())
.ok_or_else(|| GwsError::Validation("Response missing 'files' array".to_string()))?;
// Create output directory if needed
fs::create_dir_all(&output_dir).map_err(|e| {
GwsError::Validation(format!(
"Failed to create output directory '{}': {e}",
output_dir.display()
))
})?;
let canonical_output = output_dir.canonicalize().map_err(|e| {
GwsError::Validation(format!(
"Failed to canonicalize output directory '{}': {e}",
output_dir.display()
))
})?;
let mut written = 0;
for file in files {
let name = file.get("name").and_then(|n| n.as_str()).unwrap_or("");
let file_type = file.get("type").and_then(|t| t.as_str()).unwrap_or("");
let source = file.get("source").and_then(|s| s.as_str()).unwrap_or("");
// Validate the filename from the API
crate::validate::validate_script_filename(name)?;
let extension = file_type_to_extension(file_type);
if extension.is_empty() {
continue; // Skip unknown types
}
let filename = format!("{name}{extension}");
let file_path = canonical_output.join(&filename);
// Final containment check — ensure resolved path is inside output dir
let canonical_file = if file_path.exists() {
file_path
.canonicalize()
.map_err(|e| GwsError::Validation(format!("Failed to resolve path: {e}")))?
} else {
// For new files, the parent exists (we created it), so this is safe
file_path.clone()
};
if !canonical_file.starts_with(&canonical_output) {
return Err(GwsError::Validation(format!(
"File '{}' would be written outside output directory — refusing",
filename
)));
}
fs::write(&file_path, source).map_err(|e| {
GwsError::Validation(format!("Failed to write '{}': {e}", file_path.display()))
})?;
written += 1;
}
eprintln!("Pulled {written} files to {}", canonical_output.display());
Ok(())
}
/// Maps Apps Script file type to local file extension.
fn file_type_to_extension(file_type: &str) -> &str {
match file_type {
"SERVER_JS" => ".gs",
"HTML" => ".html",
"JSON" => ".json",
_ => "",
}
}
// ---------------------------------------------------------------------------
// +open
// ---------------------------------------------------------------------------
async fn handle_open(matches: &ArgMatches) -> Result<(), GwsError> {
let script_id = clasp_config::resolve_script_id(matches.get_one::<String>("script"))?;
let url = format!("https://script.google.com/d/{}/edit", script_id);
eprintln!("Opening {url}");
open::that(&url).map_err(|e| GwsError::Validation(format!("Failed to open browser: {e}")))?;
Ok(())
}
// ---------------------------------------------------------------------------
// +run
// ---------------------------------------------------------------------------
async fn handle_run(
doc: &crate::discovery::RestDescription,
matches: &ArgMatches,
) -> Result<(), GwsError> {
let script_id = clasp_config::resolve_script_id(matches.get_one::<String>("script"))?;
let function_name = matches.get_one::<String>("function").unwrap();
let dev_mode = matches.get_flag("dev-mode");
let scripts_res = doc
.resources
.get("scripts")
.ok_or_else(|| GwsError::Discovery("Resource 'scripts' not found".to_string()))?;
let run_method = scripts_res
.methods
.get("run")
.ok_or_else(|| GwsError::Discovery("Method 'scripts.run' not found".to_string()))?;
let mut body = json!({
"function": function_name,
});
if dev_mode {
body["devMode"] = json!(true);
}
let body_str = body.to_string();
// scripts.run is special: the discovery doc lists scopes of services the
// *script* uses (spreadsheets, drive, mail, etc), not a dedicated "run" scope.
// cloud-platform is a superset that covers all of them.
let (token, auth_method) =
match auth::get_token(&["https://www.googleapis.com/auth/cloud-platform"]).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) => (None, executor::AuthMethod::None),
};
let params = json!({ "scriptId": script_id });
let params_str = params.to_string();
let result = executor::execute_method(
doc,
run_method,
Some(&params_str),
Some(&body_str),
token.as_deref(),
auth_method,
None,
None,
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await;
if let Err(ref e) = result {
let msg = e.to_string();
let gcp_project_hint = extract_gcp_project_number();
if msg.contains("authentication scopes") {
eprintln!(
"\n\x1b[33mHint:\x1b[0m scripts.run requires scopes matching the services your \
script uses.\n\
Re-run \x1b[1mgws auth login\x1b[0m and include the \x1b[1mcloud-platform\x1b[0m scope."
);
} else if msg.contains("does not have permission") || msg.contains("403") {
eprintln!(
"\n\x1b[33mHint:\x1b[0m The script must be linked to your OAuth client's GCP project.{gcp}\n\
1. Open the script editor: \x1b[1mgws apps-script +open\x1b[0m\n\
2. Go to Project Settings (gear icon)\n\
3. Under 'Google Cloud Platform (GCP) Project', click 'Change project'\n\
4. Enter your GCP project number and click 'Set project'\n\n\
Also ensure appsscript.json includes: \x1b[1m\"executionApi\": {{\"access\": \"MYSELF\"}}\x1b[0m",
gcp = gcp_project_hint,
);
} else if msg.contains("not found") || msg.contains("404") {
eprintln!(
"\n\x1b[33mHint:\x1b[0m The script was not found. Possible causes:\n\
The script is not linked to a GCP project{gcp}\n\
(see \x1b[1mgws apps-script +open\x1b[0m Project Settings)\n\
The script has no API-executable deployment (create one with:\n\
\x1b[1mgws apps-script projects versions create\x1b[0m then\n\
\x1b[1mgws apps-script projects deployments create\x1b[0m)\n\
Use \x1b[1m--dev-mode\x1b[0m to run the latest saved code without deploying",
gcp = gcp_project_hint,
);
}
}
result?;
Ok(())
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Extracts the GCP project number from the stored OAuth client ID.
///
/// Client IDs have the format `{project_number}-{hash}.apps.googleusercontent.com`.
/// Returns a formatted string like `\n GCP project number: 388781138000` or empty string.
fn extract_gcp_project_number() -> String {
let enc_path = crate::credential_store::encrypted_credentials_path();
if !enc_path.exists() {
return String::new();
}
let json_str = match crate::credential_store::load_encrypted_from_path(&enc_path) {
Ok(s) => s,
Err(_) => return String::new(),
};
let creds: serde_json::Value = match serde_json::from_str(&json_str) {
Ok(v) => v,
Err(_) => return String::new(),
};
if let Some(client_id) = creds.get("client_id").and_then(|v| v.as_str()) {
if let Some(project_num) = client_id.split('-').next() {
if project_num.chars().all(|c| c.is_ascii_digit()) && !project_num.is_empty() {
return format!(
"\n Your OAuth client's GCP project number: \x1b[1m{project_num}\x1b[0m"
);
}
}
}
String::new()
}
// ---------------------------------------------------------------------------
// +logs
// ---------------------------------------------------------------------------
async fn handle_logs(
doc: &crate::discovery::RestDescription,
matches: &ArgMatches,
) -> Result<(), GwsError> {
let script_id = clasp_config::resolve_script_id(matches.get_one::<String>("script"))?;
let processes_res = doc
.resources
.get("processes")
.ok_or_else(|| GwsError::Discovery("Resource 'processes' not found".to_string()))?;
let list_method = processes_res
.methods
.get("listScriptProcesses")
.ok_or_else(|| {
GwsError::Discovery("Method 'processes.listScriptProcesses' not found".to_string())
})?;
let scopes: Vec<&str> = list_method.scopes.iter().map(|s| s.as_str()).collect();
let (token, auth_method) = match auth::get_token(&scopes).await {
Ok(t) => (Some(t), executor::AuthMethod::OAuth),
Err(_) => (None, executor::AuthMethod::None),
};
let params = json!({ "scriptId": script_id });
let params_str = params.to_string();
executor::execute_method(
doc,
list_method,
Some(&params_str),
None,
token.as_deref(),
auth_method,
None,
None,
matches.get_flag("dry-run"),
&executor::PaginationConfig::default(),
None,
&crate::helpers::modelarmor::SanitizeMode::Warn,
&crate::formatter::OutputFormat::default(),
false,
)
.await?;
Ok(())
}
// ---------------------------------------------------------------------------
// Shared utilities
// ---------------------------------------------------------------------------
fn visit_dirs(dir: &Path, files: &mut Vec<serde_json::Value>) -> Result<(), GwsError> {
if dir.is_dir() {
for entry in fs::read_dir(dir).context("Failed to read dir")? {
@@ -286,4 +750,13 @@ mod tests {
assert!(names.contains(&"root"));
assert!(names.contains(&"utils"));
}
#[test]
fn test_file_type_to_extension() {
assert_eq!(file_type_to_extension("SERVER_JS"), ".gs");
assert_eq!(file_type_to_extension("HTML"), ".html");
assert_eq!(file_type_to_extension("JSON"), ".json");
assert_eq!(file_type_to_extension("UNKNOWN"), "");
assert_eq!(file_type_to_extension(""), "");
}
}
+109
View File
@@ -207,6 +207,67 @@ pub fn validate_resource_name(s: &str) -> Result<&str, GwsError> {
Ok(s)
}
/// Validates an Apps Script file name from the API for safe use as a local filename.
///
/// This is critical for `+pull` — the API returns `name` fields that become
/// filenames on disk. A malicious script project could set names like
/// `../../.ssh/authorized_keys` to escape the target directory.
///
/// Rejects:
/// - Empty names
/// - Names containing `/` or `\` (directory separators)
/// - Names containing `..` (path traversal)
/// - Names starting with `.` (hidden files)
/// - Names with null bytes or control characters
/// - Names with characters outside `[a-zA-Z0-9_-]`
pub fn validate_script_filename(name: &str) -> Result<&str, GwsError> {
if name.is_empty() {
return Err(GwsError::Validation(
"Script file name must not be empty".to_string(),
));
}
// Reject control characters and null bytes
if name.bytes().any(|b| b < 0x20) {
return Err(GwsError::Validation(format!(
"Script file name contains invalid control characters: {name}"
)));
}
// Reject directory separators
if name.contains('/') || name.contains('\\') {
return Err(GwsError::Validation(format!(
"Script file name must not contain directory separators: {name}"
)));
}
// Reject path traversal
if name.contains("..") {
return Err(GwsError::Validation(format!(
"Script file name must not contain '..': {name}"
)));
}
// Reject hidden files
if name.starts_with('.') {
return Err(GwsError::Validation(format!(
"Script file name must not start with '.': {name}"
)));
}
// Allowlist: only alphanumeric, underscore, hyphen
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(GwsError::Validation(format!(
"Script file name contains invalid characters (allowed: a-z, A-Z, 0-9, _, -): {name}"
)));
}
Ok(name)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -471,4 +532,52 @@ mod tests {
// Just % should be rejected too
assert!(validate_resource_name("spaces/100%").is_err());
}
// -- validate_script_filename ---------------------------------------------
#[test]
fn test_validate_script_filename_clean() {
assert!(validate_script_filename("Code").is_ok());
assert!(validate_script_filename("Utils").is_ok());
assert!(validate_script_filename("appsscript").is_ok());
assert!(validate_script_filename("my-lib").is_ok());
assert!(validate_script_filename("helper_2").is_ok());
}
#[test]
fn test_validate_script_filename_traversal() {
assert!(validate_script_filename("../evil").is_err());
assert!(validate_script_filename("..").is_err());
assert!(validate_script_filename("foo..bar").is_err());
}
#[test]
fn test_validate_script_filename_hidden() {
assert!(validate_script_filename(".evil").is_err());
assert!(validate_script_filename(".ssh").is_err());
}
#[test]
fn test_validate_script_filename_slashes() {
assert!(validate_script_filename("foo/bar").is_err());
assert!(validate_script_filename("foo\\bar").is_err());
}
#[test]
fn test_validate_script_filename_control_chars() {
assert!(validate_script_filename("foo\0bar").is_err());
assert!(validate_script_filename("foo\nbar").is_err());
}
#[test]
fn test_validate_script_filename_empty() {
assert!(validate_script_filename("").is_err());
}
#[test]
fn test_validate_script_filename_special_chars() {
assert!(validate_script_filename("foo bar").is_err()); // space
assert!(validate_script_filename("foo@bar").is_err()); // @
assert!(validate_script_filename("foo.bar").is_err()); // dot (not start)
}
}