Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ resolver = "2"

[workspace.package]
edition = "2021"
version = "0.3.51"
version = "0.3.52"
description = "Tower is the best way to host Python data apps in production"
rust-version = "1.81"
authors = ["Brad Heller <brad@tower.dev>"]
Expand Down
32 changes: 32 additions & 0 deletions crates/tower-cmd/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -980,3 +980,35 @@ impl ResponseEntity for tower_api::apis::default_api::DeleteScheduleSuccess {
}
}
}

pub async fn cancel_run(
config: &Config,
name: &str,
seq: i64,
) -> Result<
tower_api::models::CancelRunResponse,
Error<tower_api::apis::default_api::CancelRunError>,
> {
let api_config = &config.into();

let params = tower_api::apis::default_api::CancelRunParams {
name: name.to_string(),
seq,
};

unwrap_api_response(tower_api::apis::default_api::cancel_run(
api_config, params,
))
.await
}

impl ResponseEntity for tower_api::apis::default_api::CancelRunSuccess {
type Data = tower_api::models::CancelRunResponse;

fn extract_data(self) -> Option<Self::Data> {
match self {
Self::Status200(data) => Some(data),
Self::UnknownValue(_) => None,
}
}
}
64 changes: 64 additions & 0 deletions crates/tower-cmd/src/apps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,24 @@ pub fn apps_cmd() -> Command {
)
.about("Delete an app in Tower"),
)
.subcommand(
Command::new("cancel")
.arg(
Arg::new("app_name")
.value_parser(value_parser!(String))
.index(1)
.required(true)
.help("Name of the app"),
)
.arg(
Arg::new("run_number")
.value_parser(value_parser!(i64))
.index(2)
.required(true)
.help("Run number to cancel"),
)
.about("Cancel a running app run"),
)
}

pub async fn do_logs(config: Config, cmd: &ArgMatches) {
Expand Down Expand Up @@ -223,6 +241,26 @@ pub async fn do_delete(config: Config, cmd: &ArgMatches) {
output::with_spinner("Deleting app", api::delete_app(&config, name)).await;
}

pub async fn do_cancel(config: Config, cmd: &ArgMatches) {
let name = cmd
.get_one::<String>("app_name")
.expect("app_name should be required");
let seq = cmd
.get_one::<i64>("run_number")
.copied()
.expect("run_number should be required");

let response =
output::with_spinner("Cancelling run", api::cancel_run(&config, name, seq)).await;

let run = &response.run;
let status = format!("{:?}", run.status);
output::success_with_data(
&format!("Run #{} for '{}' cancelled (status: {})", seq, name, status),
Some(response),
);
}

async fn latest_run_number(config: &Config, name: &str) -> i64 {
match api::describe_app(config, name).await {
Ok(resp) => resp.runs
Expand Down Expand Up @@ -715,4 +753,30 @@ mod tests {
assert!(should_emit_line(&mut last_line_num, 4));
assert_eq!(last_line_num, Some(4));
}

#[test]
fn test_cancel_args_parsing() {
let matches = apps_cmd()
.try_get_matches_from(["apps", "cancel", "my-app", "42"])
.unwrap();
let (cmd, sub_matches) = matches.subcommand().unwrap();

assert_eq!(cmd, "cancel");
assert_eq!(
sub_matches
.get_one::<String>("app_name")
.map(|s| s.as_str()),
Some("my-app")
);
assert_eq!(sub_matches.get_one::<i64>("run_number"), Some(&42));
}

#[test]
fn test_cancel_requires_both_args() {
let result = apps_cmd().try_get_matches_from(["apps", "cancel", "my-app"]);
assert!(result.is_err());

let result = apps_cmd().try_get_matches_from(["apps", "cancel"]);
assert!(result.is_err());
}
}
1 change: 1 addition & 0 deletions crates/tower-cmd/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ impl App {
Some(("show", args)) => apps::do_show(sessionized_config, args).await,
Some(("logs", args)) => apps::do_logs(sessionized_config, args).await,
Some(("delete", args)) => apps::do_delete(sessionized_config, args).await,
Some(("cancel", args)) => apps::do_cancel(sessionized_config, args).await,
_ => {
apps::apps_cmd().print_help().unwrap();
std::process::exit(2);
Expand Down
28 changes: 28 additions & 0 deletions crates/tower-cmd/src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ struct GenerateTowerfileRequest {
script_path: Option<String>,
}

#[derive(Debug, Deserialize, JsonSchema)]
struct CancelRunRequest {
name: String,
run_number: String,
}

#[derive(Debug, Deserialize, JsonSchema)]
struct ScheduleRequest {
app_name: String,
Expand Down Expand Up @@ -481,6 +487,28 @@ impl TowerService {
}
}

#[tool(description = "Cancel a running Tower app run")]
async fn tower_apps_cancel(
&self,
Parameters(request): Parameters<CancelRunRequest>,
) -> Result<CallToolResult, McpError> {
let seq: i64 = request
.run_number
.parse()
.map_err(|_| McpError::invalid_params("run_number must be a number", None))?;

match api::cancel_run(&self.config, &request.name, seq).await {
Ok(response) => {
let status = format!("{:?}", response.run.status);
Self::text_success(format!(
"Cancelled run #{} for '{}' (status: {})",
seq, request.name, status
))
}
Err(e) => Self::error_result("Failed to cancel run", e),
}
}

#[tool(description = "List secrets in your Tower account (shows only previews for security)")]
async fn tower_secrets_list(
&self,
Expand Down
6 changes: 4 additions & 2 deletions crates/tower-cmd/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,6 @@ fn output_response_content_error<T>(err: ResponseContent<T>) {

match err.status {
StatusCode::CONFLICT => {
error("There was a conflict while trying to do that!");
output_full_error_details(&error_model);
}
StatusCode::UNPROCESSABLE_ENTITY => {
Expand All @@ -316,7 +315,10 @@ fn output_response_content_error<T>(err: ResponseContent<T>) {
);
}
_ => {
error("The Tower API returned an error that the Tower CLI doesn't know what to do with! Maybe try again in a bit.");
if error_model.detail.is_none() && error_model.errors.is_none() {
error("The Tower API returned an error that the Tower CLI doesn't know what to do with! Maybe try again in a bit.");
}
output_full_error_details(&error_model);
}
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/tower-cmd/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ where
let session = config.session.as_ref().ok_or(Error::NoSession)?;

env_vars.insert("TOWER_JWT".to_string(), session.token.jwt.to_string());
env_vars.insert("TOWER__RUNTIME__IS_LOCAL".to_string(), "true".to_string());
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Inspect subprocess environment construction:"
rg -n -C4 'env_clear|\.env\(|\.envs\(|Command::new|env_vars' --type rust

echo
echo "Inspect the local-run path and related runtime flags:"
rg -n -C4 'do_run_local_impl|build_cli_execution_spec|TOWER__RUNTIME__IS_LOCAL|IS_TOWER_MANAGED' --type rust

Repository: tower/tower-cli

Length of output: 50371


🏁 Script executed:

# Check how cache_dir is configured in do_run_local_impl and local execution path
rg -n "cache_dir|StartOptions" crates/tower-runtime/src/local.rs | head -30

Repository: tower/tower-cli

Length of output: 348


🏁 Script executed:

# Check SubprocessBackend execution to see if cache_dir is passed
rg -n -A5 "SubprocessBackend::new|cache_dir.*Some\|cache_dir.*None" crates/tower-runtime/src/subprocess.rs | head -40

Repository: tower/tower-cli

Length of output: 41


🏁 Script executed:

# Look for where do_run_local_impl gets cache_dir info or uses LocalApp
rg -n -B5 -A10 "fn do_run_local_impl" crates/tower-cmd/src/run.rs | head -50

Repository: tower/tower-cli

Length of output: 558


🏁 Script executed:

# Get full context of do_run_local_impl to see how backend/StartOptions is created
sed -n '145,230p' crates/tower-cmd/src/run.rs

Repository: tower/tower-cli

Length of output: 2799


🏁 Script executed:

# Look at how SubprocessBackend.create is called and what options are passed
rg -n -B3 -A8 "backend.create\|SubprocessBackend::create" crates/tower-cmd/src/run.rs

Repository: tower/tower-cli

Length of output: 41


🏁 Script executed:

# Check if cache_dir is provided in build_cli_execution_spec or StartOptions for local runs
rg -n -B5 -A10 "cache_dir:" crates/tower-cmd/src/run.rs

Repository: tower/tower-cli

Length of output: 702


🏁 Script executed:

# Check how Config is initialized and what cache_dir defaults to
rg -n "struct Config|cache_dir:" crates/tower-cmd/src/config.rs | head -20

Repository: tower/tower-cli

Length of output: 129


🏁 Script executed:

# Check the default_cache_dir function that was referenced earlier
rg -n "default_cache_dir" crates/tower-cmd/src/ -A5 | head -30

Repository: tower/tower-cli

Length of output: 41


🏁 Script executed:

# Look for config initialization in the main run flow
rg -n "Config::|cache_dir" crates/tower-cmd/src/run.rs

Repository: tower/tower-cli

Length of output: 269


🏁 Script executed:

# Find the Config struct definition
fd -t f "\.rs$" | xargs grep -l "struct Config" | head -5

Repository: tower/tower-cli

Length of output: 126


🏁 Script executed:

# Look for cache_dir initialization in main or initialization code
rg -n "cache_dir\s*[:=]" crates/tower-cmd/src/*.rs | grep -v "cache_dir:" | head -20

Repository: tower/tower-cli

Length of output: 41


🏁 Script executed:

# Check if there's a config module or initialization
ls -la crates/tower-cmd/src/ | grep -E "config|init"

Repository: tower/tower-cli

Length of output: 41


🏁 Script executed:

# Look at the config crate to understand cache_dir
rg -n "struct Config|cache_dir" crates/config/src/lib.rs | head -30

Repository: tower/tower-cli

Length of output: 496


🏁 Script executed:

# Check if cache_dir has a default value
rg -n "cache_dir" crates/config/src/lib.rs -B2 -A2

Repository: tower/tower-cli

Length of output: 984


Explicitly set TOWER__RUNTIME__IS_TOWER_MANAGED=false in local runs to prevent flag bleed.

Local runs inherit the parent environment because protected_mode is false (cache_dir defaults to Some(default_cache_dir()), not None). This means if the parent tower-cli process has TOWER__RUNTIME__IS_TOWER_MANAGED=true from a prior cloud execution, it will leak into the subprocess. The current code only adds TOWER__RUNTIME__IS_LOCAL=true but does not explicitly clear the cloud flag, making it possible for both tower.info.is_cloud_run() and tower.info.is_local() to return True.

Required hardening change
     env_vars.insert("TOWER_JWT".to_string(), session.token.jwt.to_string());
+    env_vars.insert(
+        "TOWER__RUNTIME__IS_TOWER_MANAGED".to_string(),
+        "false".to_string(),
+    );
     env_vars.insert("TOWER__RUNTIME__IS_LOCAL".to_string(), "true".to_string());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/tower-cmd/src/run.rs` at line 187, When preparing the subprocess env
in run.rs (where env_vars.insert("TOWER__RUNTIME__IS_LOCAL".to_string(),
"true".to_string()) is set), also explicitly clear the cloud-managed flag by
inserting env_vars.insert("TOWER__RUNTIME__IS_TOWER_MANAGED".to_string(),
"false".to_string()); ensure this happens in the local-run branch (the code path
that sets IS_LOCAL when protected_mode is false / cache_dir != None) so the
child process cannot inherit TOWER__RUNTIME__IS_TOWER_MANAGED from the parent.


// Load the Towerfile
let towerfile_path = path.join("Towerfile");
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "maturin"

[project]
name = "tower"
version = "0.3.51"
version = "0.3.52"
description = "Tower CLI and runtime environment for Tower."
authors = [{ name = "Tower Computing Inc.", email = "brad@tower.dev" }]
readme = "README.md"
Expand Down
13 changes: 12 additions & 1 deletion src/tower/info/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,18 @@ def is_cloud_run() -> bool:
Returns:
bool: True if running in Tower cloud, otherwise False.
"""
val = _get_runtime_env_variable("IS_TOWER_MANAGED", "")
val = _get_runtime_env_variable("IS_TOWER_MANAGED", "false")
return _strtobool(val)


def is_local() -> bool:
"""
Check if the current run is executing locally via the Tower CLI.

Returns:
bool: True if running locally, otherwise False.
"""
val = _get_runtime_env_variable("IS_LOCAL", "false")
return _strtobool(val)


Expand Down
7 changes: 7 additions & 0 deletions tests/integration/features/cli_runs.feature
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ Feature: CLI Run Commands
And I run "tower apps logs --follow {app_name}#{run_number}" via CLI using created app name and run number
Then the output should show "Hello, World!"

Scenario: CLI apps cancel should cancel a running run
Given I have a valid Towerfile in the current directory
When I run "tower deploy --create" via CLI
And I run "tower run --detached" via CLI and capture run number
And I run "tower apps cancel {app_name} {run_number}" via CLI using created app name and run number
Then the output should show "cancelled"

Scenario: CLI apps logs follow should display warnings
Given I have a simple hello world application named "app-logs-warning"
When I run "tower deploy --create" via CLI
Expand Down
18 changes: 18 additions & 0 deletions tests/mock-api-server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,24 @@ async def describe_run(name: str, seq: int):
)


@app.post("/v1/apps/{name}/runs/{seq}")
async def cancel_run(name: str, seq: int):
"""Mock endpoint for cancelling a run."""
if name not in mock_apps_db:
raise HTTPException(status_code=404, detail=f"App '{name}' not found")

for run_id, run_data in mock_runs_db.items():
if run_data["app_name"] == name and run_data["number"] == seq:
run_data["status"] = "cancelled"
run_data["status_group"] = "successful"
run_data["cancelled_at"] = now_iso()
return {"run": run_data}

raise HTTPException(
status_code=404, detail=f"Run sequence {seq} not found for app '{name}'"
)


# Placeholder for /secrets endpoints
@app.get("/v1/secrets")
async def list_secrets():
Expand Down
Loading