mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
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:
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
Generated
+1
@@ -1138,6 +1138,7 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.1.14",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"vt100",
|
||||
"which",
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user