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
4 changes: 3 additions & 1 deletion crates/codra-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ codra run --task review-pr

## GitHub context

Real GitHub Actions mode is enabled only when `GITHUB_ACTIONS=true`. If `GITHUB_EVENT_PATH` is set outside Actions, the CLI parses it as a local fixture and keeps `mode` as `local`.

Reads GitHub Actions environment variables when present:

- `GITHUB_REPOSITORY`, `GITHUB_EVENT_NAME`, `GITHUB_EVENT_PATH`
Expand All @@ -41,7 +43,7 @@ With `--jsonl`, each line is a JSON object:
| `codra.task.summary` | Deterministic task output |
| `codra.task.completed` | Task finished |
| `codra.run.completed` | Success |
| `codra.run.failed` | Unrecoverable error |
| `codra.run.failed` | Unrecoverable error (including invalid `--task` when `--jsonl` is set) |
| `codra.warning` | Non-fatal issue |

Fields: `type`, `runId`, `timestamp`, `task`, `source`, `data`.
Expand Down
18 changes: 16 additions & 2 deletions crates/codra-cli/src/context/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ use super::types::{
};
use super::local_git::load_local_git;

/// True only when running in a real GitHub Actions job (`GITHUB_ACTIONS=true`).
pub fn github_actions_runtime_enabled() -> bool {
env::var("GITHUB_ACTIONS").ok().as_deref() == Some("true")
}

const API_TIMEOUT_SECS: u64 = 15;
const MAX_CHANGED_FILES: usize = 100;
const MAX_PATCH_LEN: usize = 32_000;
Expand Down Expand Up @@ -45,8 +50,7 @@ pub fn load_github_context() -> CodraGitHubContext {
}
}

let in_actions = env::var("GITHUB_ACTIONS").ok().as_deref() == Some("true")
|| ctx.event_path.is_some();
let in_actions = github_actions_runtime_enabled();

if in_actions {
ctx.mode = GitHubContextMode::GitHubActions;
Expand All @@ -55,6 +59,16 @@ pub fn load_github_context() -> CodraGitHubContext {
} else {
ctx.warnings
.push("not running inside GitHub Actions; GitHub event context unavailable".to_string());
if ctx.event_path.is_some() {
ctx.warnings.push(
"GITHUB_EVENT_PATH detected outside GitHub Actions; treating as local fixture context."
.to_string(),
);
parse_event_payload(&mut ctx);
if ctx.pull_request.is_some() || ctx.issue.is_some() {
ctx.available = true;
}
}
}

let token = env::var("GITHUB_TOKEN").ok().filter(|t| !t.is_empty());
Expand Down
2 changes: 1 addition & 1 deletion crates/codra-cli/src/context/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ pub mod github;
pub mod local_git;
pub mod types;

pub use github::load_github_context;
pub use github::{github_actions_runtime_enabled, load_github_context};
pub use types::CodraGitHubContext;
3 changes: 2 additions & 1 deletion crates/codra-cli/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ impl EventEmitter {
"codra.run.failed" => {
let msg = event
.data
.get("message")
.get("error")
.or_else(|| event.data.get("message"))
.and_then(|v| v.as_str())
.unwrap_or("unknown error");
eprintln!("✗ Run failed: {msg}");
Expand Down
5 changes: 4 additions & 1 deletion crates/codra-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ pub mod run;
pub mod tasks;
pub mod utils;

pub use run::{parse_run_args, run_task, RunOptions, VALID_TASKS};
pub use run::{
args_want_jsonl, emit_argument_validation_failed, execute_run, parse_run_args, peek_task_label,
run_task, RunOptions, VALID_TASKS,
};
17 changes: 10 additions & 7 deletions crates/codra-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use codra_cli::run::{parse_run_args, run_task};
use codra_cli::run::{args_want_jsonl, execute_run};
use codra_core::provider::{create_provider, EchoMockProvider, IntelligenceProvider};
use codra_core::provider_config::ProviderConfigService;
use codra_protocol::{McpServerInfo, ProviderConfig, ProviderKind};
Expand Down Expand Up @@ -36,7 +36,15 @@ fn main() {
"mcp-server" => mcp_server(),
"run" => {
args.remove(0);
run_command(&args)
match execute_run(&args) {
Ok(()) => Ok(()),
Err(err) => {
if args_want_jsonl(&args) {
std::process::exit(1);
}
Err(err)
}
}
}
_ => help(),
};
Expand Down Expand Up @@ -521,11 +529,6 @@ fn parse_worker_url(url: &str) -> Result<(String, u16), String> {
Ok((host, port))
}

fn run_command(args: &[String]) -> Result<(), String> {
let opts = parse_run_args(args)?;
run_task(opts)
}

fn help() -> Result<(), String> {
println!("codra <command>");
println!(" run --task <task> [--jsonl] Run a task with optional JSONL event stream");
Expand Down
50 changes: 50 additions & 0 deletions crates/codra-cli/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,56 @@ pub struct RunOptions {
pub jsonl: bool,
}

pub fn args_want_jsonl(args: &[String]) -> bool {
args.iter().any(|a| a == "--jsonl")
}

/// Task name from `--task` if present; otherwise `"unknown"`.
pub fn peek_task_label(args: &[String]) -> String {
let mut i = 0;
while i < args.len() {
if args[i] == "--task" {
return args
.get(i + 1)
.cloned()
.filter(|t| !t.is_empty())
.unwrap_or_else(|| "unknown".to_string());
}
i += 1;
}
"unknown".to_string()
}

pub fn emit_argument_validation_failed(task: &str, error: &str) -> Result<(), String> {
let run_id = new_run_id();
let safe = crate::utils::safe_error::redact_secrets(error);
let emitter = EventEmitter::new(run_id, task.to_string(), true);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Redact invalid task labels before emitting JSONL

When --jsonl is set and the invalid --task value itself contains a secret-like token (for example from a copy/paste mistake), the error string is redacted but the raw task label is still placed in the event's top-level task field via EventEmitter::new, while the payload declares secretsExposed: false. This leaks the value into CI logs/event consumers despite the new validation-failure redaction path; redact the task label or use a neutral value for invalid tasks.

Useful? React with 👍 / 👎.

emitter.emit(
"codra.run.failed",
json!({
"error": safe,
"stage": "argument_validation",
"secretsExposed": false
}),
)
}

/// Parse and run a task, emitting `codra.run.failed` for JSONL argument validation errors.
pub fn execute_run(args: &[String]) -> Result<(), String> {
let jsonl = args_want_jsonl(args);
let task_label = peek_task_label(args);

match parse_run_args(args) {
Ok(opts) => run_task(opts),
Err(err) => {
if jsonl {
emit_argument_validation_failed(&task_label, &err)?;
}
Err(err)
}
}
}

pub fn parse_run_args(args: &[String]) -> Result<RunOptions, String> {
let mut task: Option<String> = None;
let mut jsonl = false;
Expand Down
100 changes: 100 additions & 0 deletions crates/codra-cli/tests/github_actions_detection_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};

use codra_cli::context::github::github_actions_runtime_enabled;
use codra_cli::context::load_github_context;
use codra_cli::context::types::GitHubContextMode;

static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();

fn env_lock() -> std::sync::MutexGuard<'static, ()> {
ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|e| e.into_inner())
}

fn fixture_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/pull_request_event.json")
}

fn clear_github_env() {
for key in [
"GITHUB_ACTIONS",
"GITHUB_EVENT_PATH",
"GITHUB_EVENT_NAME",
"GITHUB_REPOSITORY",
"GITHUB_TOKEN",
] {
std::env::remove_var(key);
}
}

#[test]
fn github_actions_detection_helper() {
let _lock = env_lock();
clear_github_env();
assert!(!github_actions_runtime_enabled());

std::env::set_var("GITHUB_ACTIONS", "true");
assert!(github_actions_runtime_enabled());

std::env::set_var("GITHUB_ACTIONS", "false");
assert!(!github_actions_runtime_enabled());

std::env::set_var("GITHUB_EVENT_PATH", "/tmp/event.json");
assert!(!github_actions_runtime_enabled());
clear_github_env();
}

#[test]
fn event_path_alone_does_not_set_github_actions_mode() {
let _lock = env_lock();
clear_github_env();
std::env::set_var(
"GITHUB_EVENT_PATH",
fixture_path().to_str().expect("fixture path utf-8"),
);
std::env::set_var("GITHUB_EVENT_NAME", "pull_request");

let ctx = load_github_context();
assert_eq!(ctx.mode, GitHubContextMode::Local);
assert!(ctx.pull_request.is_some());
assert!(ctx.warnings.iter().any(|w| {
w.contains("GITHUB_EVENT_PATH detected outside GitHub Actions")
}));

clear_github_env();
}

#[test]
fn github_actions_true_with_event_path_sets_actions_mode() {
let _lock = env_lock();
clear_github_env();
std::env::set_var("GITHUB_ACTIONS", "true");
std::env::set_var(
"GITHUB_EVENT_PATH",
fixture_path().to_str().expect("fixture path utf-8"),
);
std::env::set_var("GITHUB_EVENT_NAME", "pull_request");

let ctx = load_github_context();
assert_eq!(ctx.mode, GitHubContextMode::GitHubActions);
assert!(ctx.pull_request.is_some());
assert!(!ctx.warnings.iter().any(|w| {
w.contains("GITHUB_EVENT_PATH detected outside GitHub Actions")
}));

clear_github_env();
}

#[test]
fn outputs_never_contain_github_token_value() {
let _lock = env_lock();
clear_github_env();
std::env::set_var("GITHUB_TOKEN", "ghp_super_secret_token_value");
let ctx = load_github_context();
let blob = format!("{ctx:?}");
assert!(!blob.contains("ghp_super_secret_token_value"));
clear_github_env();
}
14 changes: 12 additions & 2 deletions crates/codra-cli/tests/run_args_test.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use codra_cli::parse_run_args;
use codra_cli::VALID_TASKS;
use codra_cli::{parse_run_args, peek_task_label, VALID_TASKS};

#[test]
fn parses_task_and_jsonl_flag() {
Expand All @@ -21,4 +20,15 @@ fn rejects_invalid_task() {
for t in VALID_TASKS {
assert!(err.contains(t));
}
}

#[test]
fn peek_task_label_unknown_when_missing() {
assert_eq!(peek_task_label(&[]), "unknown");
}

#[test]
fn peek_task_label_reads_invalid_task_name() {
let args = vec!["--task".to_string(), "not-a-task".to_string(), "--jsonl".to_string()];
assert_eq!(peek_task_label(&args), "not-a-task");
}
65 changes: 65 additions & 0 deletions crates/codra-cli/tests/run_failure_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
use std::process::Command;

use codra_cli::execute_run;

fn codra_bin() -> String {
std::env::var("CARGO_BIN_EXE_codra").expect("CARGO_BIN_EXE_codra must be set for integration tests")
}

#[test]
fn invalid_task_jsonl_emits_run_failed() {
let args = vec![
"--task".to_string(),
"not-a-real-task".to_string(),
"--jsonl".to_string(),
];
let err = execute_run(&args).unwrap_err();
assert!(err.contains("invalid task"));

let output = Command::new(codra_bin())
.args(["run", "--task", "not-a-real-task", "--jsonl"])
.output()
.expect("run codra binary");
assert!(!output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("\"type\":\"codra.run.failed\"") || stdout.contains("codra.run.failed"));
assert!(stdout.contains("argument_validation"));
assert!(stdout.contains("not-a-real-task"));
assert!(stdout.contains("secretsExposed"));
let combined = format!(
"{}{}",
stdout,
String::from_utf8_lossy(&output.stderr)
);
assert!(!combined.contains("ghp_"));
assert!(!combined.to_lowercase().contains("authorization: bearer"));
}

#[test]
fn invalid_task_without_jsonl_is_human_readable() {
let output = Command::new(codra_bin())
.args(["run", "--task", "not-a-real-task"])
.output()
.expect("run codra binary");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("codra:"));
assert!(stderr.contains("invalid task"));
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(!stdout.contains("codra.run.failed"));
}

#[test]
fn invalid_task_jsonl_never_prints_token() {
let output = Command::new(codra_bin())
.env("GITHUB_TOKEN", "ghp_test_secret_value")
.args(["run", "--task", "bad", "--jsonl"])
.output()
.expect("run codra binary");
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(!combined.contains("ghp_test_secret_value"));
}
Loading