feat(tui/github): add settings view and Actions tools; wire into bottom pane and chatwidget

- core(config): add GithubConfig and setter for check_on_push
- tui: integrate gh_actions panel; exec tools plumbing
- workflows: use RUNNER_TEMP cargo-home; enforce policy removals (images + cargo caches)
- repo: ignore .cargo2; docs(config): clarify allow_git_writes default
- protocol: minor adjustments across core/protocol crates
This commit is contained in:
James Peter
2025-09-07 17:43:58 +10:00
parent ef4e5559fa
commit 4f59548cb1
20 changed files with 520 additions and 20 deletions
+1 -1
View File
@@ -513,7 +513,7 @@ jobs:
OPENAI_API_KEY="x" \
OPENAI_BASE_URL="http://127.0.0.1:5055/v1" \
OPENAI_API_BASE="http://127.0.0.1:5055/v1" \
CARGO_HOME="$GITHUB_WORKSPACE/.cargo-home" \
CARGO_HOME="$RUNNER_TEMP/cargo-home" \
CARGO_TARGET_DIR="$GITHUB_WORKSPACE/codex-rs/target" \
npm_config_cache="$SAFE_HOME/.npm" \
npx -y @just-every/code@latest \
+9 -3
View File
@@ -214,7 +214,7 @@ jobs:
-s workspace-write \
-c sandbox_workspace_write.allow_git_writes=true \
-c sandbox_workspace_write.network_access=true \
-c shell_environment_policy.r#set.CARGO_HOME="${GITHUB_WORKSPACE}/.cargo-home" \
-c shell_environment_policy.r#set.CARGO_HOME="${RUNNER_TEMP}/cargo-home" \
-c shell_environment_policy.r#set.CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/codex-rs/target" \
--cd "$GITHUB_WORKSPACE" \
--skip-git-repo-check \
@@ -243,7 +243,7 @@ jobs:
body = `This PR merges ${process.env.UPSTREAM_REPO}@${process.env.UPSTREAM_BRANCH} into ${base}.`;
}
- name: Enforce policy removals on merge branch (images)
- name: Enforce policy removals on merge branch (images + caches)
if: steps.check_upstream.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.CODE_GH_PAT || github.token }}
@@ -260,13 +260,19 @@ jobs:
rm -rf "$wt" && git worktree add -f "$wt" origin/upstream-merge
pushd "$wt" >/dev/null
removed=false
# Remove any tracked upstream images disallowed by local policy
for p in .github/codex-cli-*.png .github/codex-cli-*.jpg .github/codex-cli-*.jpeg .github/codex-cli-*.webp; do
files=$(git ls-files -- "$p" || true)
if [ -n "$files" ]; then echo "$files" | xargs -r git rm -f --; removed=true; fi
done
# Belt-and-suspenders: drop any accidentally committed cargo cache dirs (.cargo-home/ or .cargo2/)
for d in .cargo-home .cargo2; do
files=$(git ls-files -- "$d/**" || true)
if [ -n "$files" ]; then echo "$files" | xargs -r git rm -f --; removed=true; fi
done
if [ "$removed" = true ]; then
git -c user.email="github-actions[bot]@users.noreply.github.com" -c user.name="github-actions[bot]" \
commit -m "chore(merge): remove upstream .github codex-cli images per local policy"
commit -m "chore(merge): enforce policy removals (images + cargo caches)"
git push origin HEAD:upstream-merge
fi
popd >/dev/null
+1
View File
@@ -46,6 +46,7 @@ codex-cli/bin/*-windows-*
# Local Cargo cache (kept in-repo for sandboxed CI; never commit)
/.cargo-home/
/.cargo-cache/
/.cargo2/
# Release and CI scratch
/.code-bin/
+1
View File
@@ -1138,6 +1138,7 @@ dependencies = [
"unicode-segmentation",
"unicode-width 0.1.14",
"url",
"urlencoding",
"uuid",
"vt100",
"which",
+2 -2
View File
@@ -92,13 +92,13 @@ codex --sandbox danger-full-access
The same setting can be persisted in `~/.codex/config.toml` via the top-level `sandbox_mode = "MODE"` key, e.g. `sandbox_mode = "workspace-write"`.
If you need to allow the agent to update Git metadata (e.g., in CI when it must commit/push), you can opt‑in by setting a workspace‑write tweak:
If you want to prevent the agent from updating Git metadata (e.g., local safety), you can opt‑out with a workspace‑write tweak:
```toml
sandbox_mode = "workspace-write"
[sandbox_workspace_write]
allow_git_writes = true # default is false; enables writes under .git
allow_git_writes = false # default is true; set false to protect .git
```
### Debugging Virtual Cursor
+35
View File
@@ -2,6 +2,7 @@ use crate::config_profile::ConfigProfile;
use crate::config_types::AgentConfig;
use crate::config_types::BrowserConfig;
use crate::config_types::History;
use crate::config_types::GithubConfig;
use crate::config_types::ThemeName;
use crate::config_types::McpServerConfig;
use crate::config_types::SandboxWorkspaceWrite;
@@ -189,6 +190,9 @@ pub struct Config {
/// Whether we're using ChatGPT authentication (affects feature availability)
pub using_chatgpt_auth: bool,
/// GitHub integration configuration.
pub github: GithubConfig,
}
impl Config {
@@ -394,6 +398,33 @@ pub fn set_tui_theme_name(codex_home: &Path, theme: ThemeName) -> anyhow::Result
Ok(())
}
/// Persist the GitHub workflow check preference under `[github].check_workflows_on_push`.
pub fn set_github_check_on_push(codex_home: &Path, enabled: bool) -> anyhow::Result<()> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
// Parse existing config if present; otherwise start a new document.
let mut doc = match std::fs::read_to_string(config_path.clone()) {
Ok(s) => s.parse::<DocumentMut>()?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
Err(e) => return Err(e.into()),
};
// Write `[github].check_workflows_on_push = <enabled>`
doc["github"]["check_workflows_on_push"] = toml_edit::value(enabled);
// ensure codex_home exists
std::fs::create_dir_all(codex_home)?;
// create a tmp_file
let tmp_file = NamedTempFile::new_in(codex_home)?;
std::fs::write(tmp_file.path(), doc.to_string())?;
// atomically move the tmp file into config.toml
tmp_file.persist(config_path)?;
Ok(())
}
/// Apply a single dotted-path override onto a TOML value.
fn apply_toml_override(root: &mut TomlValue, path: &str, value: TomlValue) {
use toml::value::Table;
@@ -555,6 +586,9 @@ pub struct ConfigToml {
/// All characters are inserted as they are received, and no buffering
/// or placeholder replacement will occur for fast keypress bursts.
pub disable_paste_burst: Option<bool>,
/// GitHub integration configuration.
pub github: Option<GithubConfig>,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
@@ -903,6 +937,7 @@ impl Config {
debug: debug.unwrap_or(false),
// Already computed before moving codex_home
using_chatgpt_auth,
github: cfg.github.unwrap_or_default(),
};
Ok(config)
}
+16 -2
View File
@@ -46,6 +46,17 @@ fn default_true() -> bool {
true
}
/// GitHub integration settings.
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
pub struct GithubConfig {
/// When true, Codex watches for GitHub Actions workflow runs after a
/// successful `git push` and reports failures as background messages.
/// Enabled by default; can be disabled via `~/.code/config.toml` under
/// `[github]` with `check_workflows_on_push = false`.
#[serde(default = "default_true")]
pub check_workflows_on_push: bool,
}
#[derive(Deserialize, Debug, Clone, PartialEq)]
pub struct McpServerConfig {
pub command: String,
@@ -342,11 +353,14 @@ pub struct SandboxWorkspaceWrite {
#[serde(default)]
pub exclude_slash_tmp: bool,
/// When true, do not protect the top-level `.git` folder under a writable
/// root. Defaults to false for safety.
#[serde(default)]
/// root. Defaults to true (historical behavior allows Git writes).
#[serde(default = "crate::config_types::default_true_bool")]
pub allow_git_writes: bool,
}
// Serde helper: default to true for `allow_git_writes` when omitted.
pub(crate) const fn default_true_bool() -> bool { true }
#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ShellEnvironmentPolicyInherit {
+6 -3
View File
@@ -200,12 +200,15 @@ pub enum SandboxPolicy {
exclude_slash_tmp: bool,
/// When true, do not protect the top-level `.git` folder under a
/// writable root. Defaults to false for safety.
#[serde(default)]
/// writable root. Defaults to true (historical behavior allows Git writes).
#[serde(default = "crate::protocol::default_true_bool")]
allow_git_writes: bool,
},
}
// Serde helper: default to true for flags where we want historical permissive behavior.
pub(crate) const fn default_true_bool() -> bool { true }
/// A writable root path accompanied by a list of subpaths that should remain
/// read‑only even when the root is writable. This is primarily used to ensure
/// top‑level VCS metadata directories (e.g. `.git`) under a writable root are
@@ -253,7 +256,7 @@ impl SandboxPolicy {
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
allow_git_writes: false,
allow_git_writes: true,
}
}
+6 -3
View File
@@ -225,12 +225,15 @@ pub enum SandboxPolicy {
exclude_slash_tmp: bool,
/// When true, do not protect the top-level `.git` folder under a
/// writable root. Defaults to false for safety.
#[serde(default)]
/// writable root. Defaults to true (historical behavior allows Git writes).
#[serde(default = "crate::protocol::default_true_bool")]
allow_git_writes: bool,
},
}
// Serde helper: default to true for flags where we want historical permissive behavior.
pub(crate) const fn default_true_bool() -> bool { true }
/// A writable root path accompanied by a list of subpaths that should remain
/// read‑only even when the root is writable. This is primarily used to ensure
/// top‑level VCS metadata directories (e.g. `.git`) under a writable root are
@@ -285,7 +288,7 @@ impl SandboxPolicy {
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: false,
allow_git_writes: false,
allow_git_writes: true,
}
}
+1
View File
@@ -87,6 +87,7 @@ url = "2"
uuid = "1"
syntect = { version = "5", features = ["yaml-load", "plist-load", "parsing", "default-syntaxes"] }
which = "6"
urlencoding = "2.1"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
+10
View File
@@ -731,6 +731,11 @@ impl App<'_> {
widget.add_agents_output();
}
}
SlashCommand::Github => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_github_command(command_args);
}
}
SlashCommand::Reasoning => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_reasoning_command(command_args);
@@ -858,6 +863,11 @@ impl App<'_> {
widget.set_text_verbosity(new_verbosity);
}
}
AppEvent::UpdateGithubWatcher(enabled) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.set_github_watcher(enabled);
}
}
AppEvent::DiffResult(text) => {
if let AppState::Chat { widget } = &mut self.app_state {
widget.add_diff_output(text);
+3
View File
@@ -72,6 +72,9 @@ pub(crate) enum AppEvent {
/// Update the text verbosity level
UpdateTextVerbosity(TextVerbosity),
/// Update GitHub workflow monitoring toggle
UpdateGithubWatcher(bool),
/// Update the theme (with history event)
UpdateTheme(ThemeName),
@@ -0,0 +1,145 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::buffer::Buffer;
use ratatui::layout::{Alignment, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget};
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use super::bottom_pane_view::BottomPaneView;
use super::BottomPane;
/// Interactive UI for GitHub workflow monitoring settings.
/// Shows token status and allows toggling the watcher on/off.
pub(crate) struct GithubSettingsView {
watcher_enabled: bool,
token_status: String,
token_ready: bool,
app_event_tx: AppEventSender,
is_complete: bool,
/// Selection index: 0 = toggle, 1 = close
selected_row: usize,
}
impl GithubSettingsView {
pub fn new(watcher_enabled: bool, token_status: String, ready: bool, app_event_tx: AppEventSender) -> Self {
Self {
watcher_enabled,
token_status,
token_ready: ready,
app_event_tx,
is_complete: false,
selected_row: 0,
}
}
fn toggle(&mut self) {
self.watcher_enabled = !self.watcher_enabled;
self.app_event_tx
.send(AppEvent::UpdateGithubWatcher(self.watcher_enabled));
}
}
impl<'a> BottomPaneView<'a> for GithubSettingsView {
fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) {
match key_event {
KeyEvent { code: KeyCode::Up, modifiers: KeyModifiers::NONE, .. } => {
if self.selected_row > 0 { self.selected_row -= 1; }
}
KeyEvent { code: KeyCode::Down, modifiers: KeyModifiers::NONE, .. } => {
if self.selected_row < 1 { self.selected_row += 1; }
}
KeyEvent { code: KeyCode::Left | KeyCode::Right, modifiers: KeyModifiers::NONE, .. } => {
if self.selected_row == 0 { self.toggle(); }
}
KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::NONE, .. } => {
if self.selected_row == 0 {
// Toggle and stay; Enter behaves like toggle
self.toggle();
} else {
// Close
self.is_complete = true;
}
}
KeyEvent { code: KeyCode::Char(' '), modifiers: KeyModifiers::NONE, .. } => {
if self.selected_row == 0 { self.toggle(); }
}
KeyEvent { code: KeyCode::Esc, .. } => {
self.is_complete = true;
}
_ => {}
}
}
fn is_complete(&self) -> bool { self.is_complete }
fn desired_height(&self, _width: u16) -> u16 { 9 }
fn render(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(crate::colors::border()))
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()))
.title(" GitHub Settings ")
.title_alignment(Alignment::Center);
let inner = block.inner(area);
block.render(area, buf);
let status_line = if self.token_ready {
Line::from(vec![
Span::styled("Status: ", Style::default().fg(crate::colors::text_dim())),
Span::styled("Ready", Style::default().fg(crate::colors::success()).add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(&self.token_status, Style::default().fg(crate::colors::dim())),
])
} else {
Line::from(vec![
Span::styled("Status: ", Style::default().fg(crate::colors::text_dim())),
Span::styled("No token", Style::default().fg(crate::colors::warning()).add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(
"Set GH_TOKEN/GITHUB_TOKEN or run: 'gh auth login'",
Style::default().fg(crate::colors::dim()),
),
])
};
let toggle_label = if self.watcher_enabled { "Enabled" } else { "Disabled" };
let mut toggle_style = Style::default().fg(crate::colors::text());
if self.selected_row == 0 { toggle_style = toggle_style.bg(crate::colors::selection()).add_modifier(Modifier::BOLD); }
let lines = vec![
status_line,
Line::from(""),
Line::from(vec![
Span::styled("Workflow Monitoring: ", Style::default().fg(crate::colors::text_dim())),
Span::styled(toggle_label, toggle_style),
]),
Line::from(""),
Line::from(vec![
Span::styled(if self.selected_row == 1 { "› " } else { " " }, Style::default()),
Span::styled("Close", if self.selected_row == 1 { Style::default().bg(crate::colors::selection()).add_modifier(Modifier::BOLD) } else { Style::default() }),
]),
Line::from(""),
Line::from(vec![
Span::styled("↑↓", Style::default().fg(crate::colors::light_blue())),
Span::raw(" Navigate "),
Span::styled("←→/Space", Style::default().fg(crate::colors::success())),
Span::raw(" Toggle "),
Span::styled("Enter", Style::default().fg(crate::colors::success())),
Span::raw(" Toggle/Close "),
Span::styled("Esc", Style::default().fg(crate::colors::error())),
Span::raw(" Cancel"),
]),
];
let paragraph = Paragraph::new(lines)
.alignment(Alignment::Left)
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text()));
paragraph.render(Rect { x: inner.x.saturating_add(1), y: inner.y, width: inner.width.saturating_sub(2), height: inner.height }, buf);
}
}
+10
View File
@@ -28,6 +28,7 @@ mod scroll_state;
mod selection_popup_common;
pub mod list_selection_view;
pub mod resume_selection_view;
mod github_settings_view;
// no direct use of list_selection_view or its items here
mod textarea;
mod theme_selection_view;
@@ -457,6 +458,15 @@ impl BottomPane<'_> {
self.request_redraw()
}
/// Show GitHub settings (token status + watcher toggle)
pub fn show_github_settings(&mut self, watcher_enabled: bool, token_status: String, ready: bool) {
use github_settings_view::GithubSettingsView;
let view = GithubSettingsView::new(watcher_enabled, token_status, ready, self.app_event_tx.clone());
self.active_view = Some(Box::new(view));
self.status_view_active = false;
self.request_redraw();
}
/// Height (terminal rows) required by the current bottom pane.
pub(crate) fn request_redraw(&self) {
self.app_event_tx.send(AppEvent::RequestRedraw)
+88 -1
View File
@@ -16,6 +16,7 @@ use codex_core::config_types::TextVerbosity;
mod interrupts;
mod streaming;
mod exec_tools;
mod gh_actions;
mod tools;
mod layout_scroll;
mod diff_handlers;
@@ -93,6 +94,7 @@ use ratatui::widgets::ScrollbarOrientation;
use ratatui::symbols::scrollbar as scrollbar_symbols;
use serde::{Deserialize, Serialize};
use codex_core::config::find_codex_home;
use codex_core::config::set_github_check_on_push;
#[derive(Debug, Serialize, Deserialize)]
struct CachedConnection {
@@ -5256,7 +5258,61 @@ impl ChatWidget<'_> {
"Browser commands:\n• /browser <url> - Open URL in internal browser\n• /browser off - Disable browser mode\n• /browser status - Show current status\n• /browser fullpage [on|off] - Toggle full-page mode\n• /browser config <key> <value> - Update configuration\n\nUse /chrome [port] to connect to external Chrome browser".to_string()
};
// Add the response to the UI as a background event
// Add the response to the UI as a background event using the helper
// so the first content line is not hidden by the renderer.
self.history_push(history_cell::new_background_event(response));
}
pub(crate) fn handle_github_command(&mut self, command_text: String) {
let trimmed = command_text.trim();
let enabled = self.config.github.check_workflows_on_push;
// If no args or 'status', show interactive settings in the footer
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("status") {
let token_info = gh_actions::get_github_token().map(|(_, src)| src);
let (ready, token_status) = match token_info {
Some(gh_actions::TokenSource::Env) => (true, "Token: detected (env: GITHUB_TOKEN/GH_TOKEN)".to_string()),
Some(gh_actions::TokenSource::GhCli) => (true, "Token: detected via gh auth".to_string()),
None => (false, "Token: not set (set GH_TOKEN/GITHUB_TOKEN or run 'gh auth login')".to_string()),
};
self.bottom_pane.show_github_settings(enabled, token_status, ready);
return;
}
let response = if trimmed.eq_ignore_ascii_case("on") {
self.config.github.check_workflows_on_push = true;
match find_codex_home() {
Ok(home) => {
if let Err(e) = set_github_check_on_push(&home, true) {
tracing::warn!("Failed to persist /github on: {}", e);
"✅ Enabled GitHub watcher (persist failed; see logs)".to_string()
} else {
"✅ Enabled GitHub watcher (persisted)".to_string()
}
}
Err(_) => {
"✅ Enabled GitHub watcher (not persisted: CODE_HOME/CODEX_HOME not found)".to_string()
}
}
} else if trimmed.eq_ignore_ascii_case("off") {
self.config.github.check_workflows_on_push = false;
match find_codex_home() {
Ok(home) => {
if let Err(e) = set_github_check_on_push(&home, false) {
tracing::warn!("Failed to persist /github off: {}", e);
"✅ Disabled GitHub watcher (persist failed; see logs)".to_string()
} else {
"✅ Disabled GitHub watcher (persisted)".to_string()
}
}
Err(_) => {
"✅ Disabled GitHub watcher (not persisted: CODE_HOME/CODEX_HOME not found)".to_string()
}
}
} else {
"Usage: /github [status|on|off]".to_string()
};
let lines = response
.lines()
.map(|line| Line::from(line.to_string()))
@@ -7768,3 +7824,34 @@ struct PerfState {
enabled: bool,
stats: std::cell::RefCell<PerfStats>,
}
impl ChatWidget<'_> {
pub(crate) fn set_github_watcher(&mut self, enabled: bool) {
self.config.github.check_workflows_on_push = enabled;
match find_codex_home() {
Ok(home) => {
if let Err(e) = set_github_check_on_push(&home, enabled) {
tracing::warn!("Failed to persist GitHub watcher setting: {}", e);
let msg = format!(
"✅ {} GitHub watcher (persist failed; see logs)",
if enabled { "Enabled" } else { "Disabled" }
);
self.history_push(history_cell::new_background_event(msg));
} else {
let msg = format!(
"✅ {} GitHub watcher (persisted)",
if enabled { "Enabled" } else { "Disabled" }
);
self.history_push(history_cell::new_background_event(msg));
}
}
Err(_) => {
let msg = format!(
"✅ {} GitHub watcher (not persisted: CODE_HOME/CODEX_HOME not found)",
if enabled { "Enabled" } else { "Disabled" }
);
self.history_push(history_cell::new_background_event(msg));
}
}
}
}
@@ -459,6 +459,7 @@ pub(super) fn handle_exec_end_now(chat: &mut ChatWidget<'_>, ev: ExecCommandEndE
return;
}
let command_for_watch = command.clone();
let mut completed_opt = Some(history_cell::new_completed_exec_command(
command,
parsed,
@@ -508,6 +509,12 @@ pub(super) fn handle_exec_end_now(chat: &mut ChatWidget<'_>, ev: ExecCommandEndE
if exit_code == 0 {
chat.bottom_pane.update_status_text("command completed".to_string());
// If this was a successful `git push`, start background GH Actions watch if enabled.
crate::chatwidget::gh_actions::maybe_watch_after_push(
chat.app_event_tx.clone(),
chat.config.clone(),
&command_for_watch,
);
} else {
chat.bottom_pane.update_status_text(format!("command failed (exit {})", exit_code));
}
+168
View File
@@ -0,0 +1,168 @@
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use codex_core::config::Config;
use codex_core::git_info::collect_git_info;
use codex_core::protocol::{BackgroundEventEvent, Event, EventMsg};
use std::process::Command;
/// Source of a GitHub API token used by the watcher.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum TokenSource {
/// `GITHUB_TOKEN` or `GH_TOKEN` environment variable.
Env,
/// Fetched via `gh auth token` from the GitHub CLI.
GhCli,
}
/// Obtain a GitHub token, preferring environment variables and falling back to `gh`.
/// Returns the token string and its source if available.
pub(super) fn get_github_token() -> Option<(String, TokenSource)> {
if let Ok(t) = std::env::var("GITHUB_TOKEN") { if !t.is_empty() { return Some((t, TokenSource::Env)); } }
if let Ok(t) = std::env::var("GH_TOKEN") { if !t.is_empty() { return Some((t, TokenSource::Env)); } }
// Fallback: use GitHub CLI if installed and logged in.
if let Ok(out) = Command::new("gh").args(["auth", "token"]).output() {
if out.status.success() {
let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !token.is_empty() { return Some((token, TokenSource::GhCli)); }
}
}
None
}
/// Start a background task to watch GitHub Actions for the latest push and
/// surface a failure message if any run for the pushed commit completes with a
/// non-success conclusion.
pub(super) fn maybe_watch_after_push(app_event_tx: AppEventSender, config: Config, command: &[String]) {
// Only proceed when enabled in config and the command appears to be a git push.
if !config.github.check_workflows_on_push {
return;
}
if command.is_empty() { return; }
if !command[0].eq_ignore_ascii_case("git") { return; }
let is_push = command.iter().skip(1).any(|c| c.eq_ignore_ascii_case("push"));
if !is_push { return; }
// Spawn a detached task so we don't block UI; clone what we need.
let tx = app_event_tx.clone();
tokio::spawn(async move {
// Gather repo info (branch, sha, origin URL) from the current cwd.
let Some(git) = collect_git_info(&config.cwd).await else { return; };
let (Some(head_sha), branch_opt, Some(repo_url)) = (git.commit_hash, git.branch, git.repository_url) else { return; };
let Some((owner, repo)) = parse_owner_repo(&repo_url) else { return; };
// Build API endpoint and client (optionally with token if present).
let api_base = format!("https://api.github.com/repos/{owner}/{repo}/actions/runs");
let token = get_github_token().map(|(t, _)| t);
let client = reqwest::Client::builder()
.user_agent("codex-cli-rs/github-monitor")
.build()
.unwrap_or_else(|_| reqwest::Client::new());
// Two-phase polling:
// 1) Appearance window: up to 2 minutes to see a workflow run start
// 2) Completion window: once found, poll that single run for up to 60 minutes
let mut found_run_id: Option<u64> = None;
let mut attempt: u32 = 0;
let branch = branch_opt.unwrap_or_default();
const APPEAR_POLL_INTERVAL_MS: u64 = 5_000; // 5s
const APPEAR_TIMEOUT_MS: u64 = 120_000; // 2m
const RUN_POLL_INTERVAL_MS: u64 = 10_000; // 10s
const RUN_TIMEOUT_MS: u64 = 60 * 60 * 1_000; // 60m
let appear_attempts_max = (APPEAR_TIMEOUT_MS / APPEAR_POLL_INTERVAL_MS) as u32; // 24
while attempt < appear_attempts_max {
attempt += 1;
// Query latest runs for this branch and event=push; filter head_sha client-side.
let url = format!("{api_base}?per_page=20&event=push&branch={}", urlencoding::encode(&branch));
let mut req = client.get(&url);
if let Some(ref t) = token { req = req.bearer_auth(t); }
let resp = match req.send().await { Ok(r) => r, Err(_) => { sleep_ms(APPEAR_POLL_INTERVAL_MS).await; continue; } };
if !resp.status().is_success() { sleep_ms(APPEAR_POLL_INTERVAL_MS).await; continue; }
let body = match resp.json::<serde_json::Value>().await { Ok(v) => v, Err(_) => { sleep_ms(APPEAR_POLL_INTERVAL_MS).await; continue; } };
let runs = body.get("workflow_runs").and_then(|v| v.as_array()).cloned().unwrap_or_default();
// Look for the run corresponding to this commit.
for run in runs {
let run_sha = run.get("head_sha").and_then(|v| v.as_str()).unwrap_or("");
if run_sha.eq_ignore_ascii_case(&head_sha) {
found_run_id = run.get("id").and_then(|v| v.as_u64());
// If it is already completed, check outcome now; otherwise continue polling below.
if let Some(status) = run.get("status").and_then(|v| v.as_str()) {
if status == "completed" {
let conclusion = run.get("conclusion").and_then(|v| v.as_str()).unwrap_or("unknown");
if conclusion != "success" {
let html = run.get("html_url").and_then(|v| v.as_str()).unwrap_or("");
surface_failure(&tx, &owner, &repo, &branch, &head_sha, html, conclusion);
}
return;
}
}
}
}
// If we found a run but it's not completed yet, poll the single run until it completes
if let Some(run_id) = found_run_id {
let run_url = format!("{api_base}/{run_id}");
let mut elapsed_ms: u64 = 0;
loop {
let mut req = client.get(&run_url);
if let Some(ref t) = token { req = req.bearer_auth(t); }
let resp = match req.send().await { Ok(r) => r, Err(_) => { sleep_ms(RUN_POLL_INTERVAL_MS).await; elapsed_ms = elapsed_ms.saturating_add(RUN_POLL_INTERVAL_MS); if elapsed_ms >= RUN_TIMEOUT_MS { return; } continue; } };
if !resp.status().is_success() { sleep_ms(RUN_POLL_INTERVAL_MS).await; elapsed_ms = elapsed_ms.saturating_add(RUN_POLL_INTERVAL_MS); if elapsed_ms >= RUN_TIMEOUT_MS { return; } continue; }
let run = match resp.json::<serde_json::Value>().await { Ok(v) => v, Err(_) => { sleep_ms(RUN_POLL_INTERVAL_MS).await; elapsed_ms = elapsed_ms.saturating_add(RUN_POLL_INTERVAL_MS); if elapsed_ms >= RUN_TIMEOUT_MS { return; } continue; } };
let status = run.get("status").and_then(|v| v.as_str()).unwrap_or("");
if status == "completed" {
let conclusion = run.get("conclusion").and_then(|v| v.as_str()).unwrap_or("unknown");
let html = run.get("html_url").and_then(|v| v.as_str()).unwrap_or("");
if conclusion != "success" {
surface_failure(&tx, &owner, &repo, &branch, &head_sha, html, conclusion);
}
return;
}
sleep_ms(RUN_POLL_INTERVAL_MS).await;
elapsed_ms = elapsed_ms.saturating_add(RUN_POLL_INTERVAL_MS);
if elapsed_ms >= RUN_TIMEOUT_MS { return; }
}
}
// Not found yet; wait and retry.
sleep_ms(APPEAR_POLL_INTERVAL_MS).await;
}
});
}
fn parse_owner_repo(url: &str) -> Option<(String, String)> {
// [email protected]:owner/repo.git or https://github.com/owner/repo(.git)
if let Some(rest) = url.strip_prefix("[email protected]:") {
let s = rest.trim_end_matches(".git");
let mut parts = s.splitn(2, '/');
let owner = parts.next()?.to_string();
let repo = parts.next()?.to_string();
return Some((owner, repo));
}
if let Some(pos) = url.find("github.com/") {
let s = &url[pos + "github.com/".len()..];
let s = s.trim_end_matches(".git");
let mut parts = s.splitn(2, '/');
let owner = parts.next()?.to_string();
let repo = parts.next()?.to_string();
return Some((owner, repo));
}
None
}
fn surface_failure(tx: &AppEventSender, owner: &str, repo: &str, branch: &str, sha: &str, url: &str, conclusion: &str) {
let short = &sha[..std::cmp::min(7, sha.len())];
let msg = if url.is_empty() {
format!("❌ GitHub Actions failed for {owner}/{repo}@{short} on {branch}: {conclusion}")
} else {
format!("❌ GitHub Actions failed for {owner}/{repo}@{short} on {branch}: {conclusion} — {url}")
};
let _ = tx.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
event_seq: 0,
msg: EventMsg::BackgroundEvent(BackgroundEventEvent { message: msg }),
order: None,
}));
}
async fn sleep_ms(ms: u64) { tokio::time::sleep(std::time::Duration::from_millis(ms)).await; }
+5 -4
View File
@@ -436,10 +436,11 @@ fn determine_repo_trust_state(
// skip the trust flow
Ok(false)
} else if config_toml.is_cwd_trusted(&config.cwd) {
// if the current cwd project is trusted and no config has been set
// skip the trust flow and set the approval policy and sandbox mode
config.approval_policy = AskForApproval::OnRequest;
config.sandbox_policy = SandboxPolicy::new_workspace_write_policy();
// If the current cwd project is trusted and no explicit config has been set,
// default to fully trusted, non‑interactive execution to match expected behavior.
// This restores the previous semantics before the recent trust‑flow refactor.
config.approval_policy = AskForApproval::Never;
config.sandbox_policy = SandboxPolicy::DangerFullAccess;
Ok(false)
} else {
// if none of the above conditions are met, show the trust screen
+2
View File
@@ -26,6 +26,7 @@ pub enum SlashCommand {
Prompts,
Perf,
Agents,
Github,
Resume,
// Prompt-expanding commands
Plan,
@@ -59,6 +60,7 @@ impl SlashCommand {
SlashCommand::Theme => "switch between color themes",
SlashCommand::Prompts => "show example prompts",
SlashCommand::Agents => "list agents (running and availability)",
SlashCommand::Github => "GitHub Actions watcher (status/on/off)",
SlashCommand::Perf => "performance tracing (on/off/show/reset)",
SlashCommand::Logout => "log out of Codex",
#[cfg(debug_assertions)]
+4 -1
View File
@@ -287,7 +287,7 @@ disk, but attempts to write a file or access the network will be blocked.
A more relaxed policy is `workspace-write`. When specified, the current working directory for the Codex task will be writable (as well as `$TMPDIR` on macOS). Note that the CLI defaults to using the directory where it was spawned as `cwd`, though this can be overridden using `--cwd/-C`.
On macOS (and soon Linux), all writable roots (including `cwd`) that contain a `.git/` folder _as an immediate child_ will configure the `.git/` folder to be read-only while the rest of the Git repository will be writable. This means that commands like `git commit` will fail, by default (as it entails writing to `.git/`), and will require Codex to ask for permission.
Historically, Codex allowed writes inside the top‑level `.git/` folder when using `workspace-write`. That permissive behavior is the default again. If you want to protect `.git` under `workspace-write`, you can opt out via `[sandbox_workspace_write].allow_git_writes = false`.
```toml
# same as `--sandbox workspace-write`
@@ -302,6 +302,9 @@ exclude_tmpdir_env_var = false
exclude_slash_tmp = false
# Optional list of _additional_ writable roots beyond $TMPDIR and /tmp.
# Protect top-level .git under writable roots (default is true = allow writes)
allow_git_writes = true
writable_roots = ["/Users/YOU/.pyenv/shims"]
# Allow the command being run inside the sandbox to make outbound network