Compare commits

...

3 Commits

Author SHA1 Message Date
Kevin Liu
c3a6fede88 [codex] remove worktree plan file 2026-03-23 15:52:41 -07:00
Kevin Liu
5528be02e4 [codex] add workspace snapshot turn metadata 2026-03-23 15:51:13 -07:00
Kevin Liu
ec5f5ee209 [codex] add screen recording plan 2026-03-23 14:04:05 -07:00
10 changed files with 505 additions and 56 deletions

View File

@@ -395,6 +395,9 @@
"fast_mode": {
"type": "boolean"
},
"git_workspace_snapshot": {
"type": "boolean"
},
"guardian_approval": {
"type": "boolean"
},
@@ -2001,6 +2004,9 @@
"fast_mode": {
"type": "boolean"
},
"git_workspace_snapshot": {
"type": "boolean"
},
"guardian_approval": {
"type": "boolean"
},
@@ -2535,4 +2541,4 @@
},
"title": "ConfigToml",
"type": "object"
}
}

View File

@@ -1374,6 +1374,9 @@ impl Session {
conversation_id.to_string(),
sub_id.clone(),
cwd.clone(),
per_turn_config
.features
.enabled(Feature::GitWorkspaceSnapshot),
session_configuration.sandbox_policy.get(),
session_configuration.windows_sandbox_level,
));
@@ -5297,6 +5300,9 @@ async fn spawn_review_thread(
sess.conversation_id.to_string(),
review_turn_id.clone(),
parent_turn_context.cwd.clone(),
parent_turn_context
.features
.enabled(Feature::GitWorkspaceSnapshot),
parent_turn_context.sandbox_policy.get(),
parent_turn_context.windows_sandbox_level,
));

View File

@@ -0,0 +1,136 @@
use std::path::Path;
use std::process::Output;
use tempfile::tempdir;
use tokio::process::Command;
use tokio::time::Duration as TokioDuration;
use tokio::time::timeout;
use crate::git_info::get_git_repo_root;
use crate::git_info::get_head_commit_hash;
const GIT_SNAPSHOT_TIMEOUT: TokioDuration = TokioDuration::from_secs(30);
const SNAPSHOT_AUTHOR_DATE: &str = "1970-01-01T00:00:00Z";
const SNAPSHOT_AUTHOR_EMAIL: &str = "codex@openai.invalid";
const SNAPSHOT_AUTHOR_NAME: &str = "Codex";
const SNAPSHOT_MESSAGE: &str = "codex workspace snapshot";
pub async fn prepare_workspace_snapshot_commit(cwd: &Path) -> Option<String> {
let repo_root = get_git_repo_root(cwd)?;
let head = get_head_commit_hash(&repo_root).await?;
let index_dir = tempdir().ok()?;
let index_path = index_dir.path().join("workspace-snapshot.index");
run_snapshot_command({
let mut command = git_command(repo_root.as_path());
command
.arg("read-tree")
.arg(&head)
.env("GIT_INDEX_FILE", &index_path);
command
})
.await?;
run_snapshot_command({
let mut command = git_command(repo_root.as_path());
command
.arg("add")
.arg("-A")
.env("GIT_INDEX_FILE", &index_path);
command
})
.await?;
let tree = String::from_utf8(
run_snapshot_command({
let mut command = git_command(repo_root.as_path());
command.arg("write-tree").env("GIT_INDEX_FILE", &index_path);
command
})
.await?
.stdout,
)
.ok()?
.trim()
.to_string();
let head_tree = String::from_utf8(
run_snapshot_command({
let mut command = git_command(repo_root.as_path());
command.arg("rev-parse").arg(format!("{head}^{{tree}}"));
command
})
.await?
.stdout,
)
.ok()?
.trim()
.to_string();
if tree == head_tree {
return Some(head);
}
String::from_utf8(
run_snapshot_command({
let mut command = git_command(repo_root.as_path());
command
.arg("commit-tree")
.arg(&tree)
.arg("-p")
.arg(&head)
.arg("-m")
.arg(SNAPSHOT_MESSAGE)
.env("GIT_AUTHOR_DATE", SNAPSHOT_AUTHOR_DATE)
.env("GIT_AUTHOR_EMAIL", SNAPSHOT_AUTHOR_EMAIL)
.env("GIT_AUTHOR_NAME", SNAPSHOT_AUTHOR_NAME)
.env("GIT_COMMITTER_DATE", SNAPSHOT_AUTHOR_DATE)
.env("GIT_COMMITTER_EMAIL", SNAPSHOT_AUTHOR_EMAIL)
.env("GIT_COMMITTER_NAME", SNAPSHOT_AUTHOR_NAME);
command
})
.await?
.stdout,
)
.ok()
.map(|stdout| stdout.trim().to_string())
}
fn git_command(cwd: &Path) -> Command {
let mut command = Command::new("git");
command.current_dir(cwd);
command
}
async fn run_snapshot_command(mut command: Command) -> Option<Output> {
match timeout(GIT_SNAPSHOT_TIMEOUT, command.output()).await {
Ok(Ok(output)) => {
if output.status.success() {
Some(output)
} else {
tracing::warn!(
exit_code = output.status.code(),
stderr = %String::from_utf8_lossy(&output.stderr),
stdout = %String::from_utf8_lossy(&output.stdout),
"git workspace snapshot command failed",
);
None
}
}
Ok(Err(err)) => {
tracing::warn!(error = %err, "git workspace snapshot command errored");
None
}
Err(_) => {
tracing::warn!(
timeout_sec = GIT_SNAPSHOT_TIMEOUT.as_secs(),
"git workspace snapshot command timed out",
);
None
}
}
}
#[cfg(test)]
#[path = "git_snapshot_tests.rs"]
mod tests;

View File

@@ -0,0 +1,97 @@
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::process::Command;
use crate::git_snapshot::prepare_workspace_snapshot_commit;
async fn run_git(repo_path: &std::path::Path, args: &[&str]) -> Vec<u8> {
let git_config_global = repo_path.join("empty-git-config");
std::fs::write(&git_config_global, "").expect("write empty git config");
let output = Command::new("git")
.env("GIT_CONFIG_GLOBAL", &git_config_global)
.env("GIT_CONFIG_NOSYSTEM", "1")
.args(args)
.current_dir(repo_path)
.output()
.await
.expect("git command should run");
assert!(
output.status.success(),
"git {:?} failed: stdout={} stderr={}",
args,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
output.stdout
}
async fn init_repo() -> (TempDir, std::path::PathBuf) {
let temp_dir = TempDir::new().expect("temp dir");
let repo_path = temp_dir.path().join("repo");
std::fs::create_dir_all(&repo_path).expect("create repo");
run_git(&repo_path, &["init"]).await;
std::fs::write(repo_path.join("README.md"), "hello\n").expect("write README");
run_git(&repo_path, &["add", "."]).await;
run_git(
&repo_path,
&[
"-c",
"user.name=Test User",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"initial",
],
)
.await;
(temp_dir, repo_path)
}
#[tokio::test]
async fn workspace_snapshot_commit_returns_head_for_clean_repo() {
let (_temp_dir, repo_path) = init_repo().await;
let head = String::from_utf8(run_git(&repo_path, &["rev-parse", "HEAD"]).await)
.expect("head should be valid utf-8")
.trim()
.to_string();
let snapshot = prepare_workspace_snapshot_commit(&repo_path)
.await
.expect("snapshot hash");
assert_eq!(snapshot, head);
}
#[tokio::test]
async fn workspace_snapshot_commit_includes_untracked_files() {
let (_temp_dir, repo_path) = init_repo().await;
std::fs::write(repo_path.join("notes.txt"), "new file\n").expect("write untracked file");
let snapshot = prepare_workspace_snapshot_commit(&repo_path)
.await
.expect("snapshot hash");
let file_contents =
String::from_utf8(run_git(&repo_path, &["show", &format!("{snapshot}:notes.txt")]).await)
.expect("snapshot file should be valid utf-8");
assert_eq!(file_contents, "new file\n");
}
#[tokio::test]
async fn workspace_snapshot_commit_is_deterministic_for_same_dirty_tree() {
let (_temp_dir, repo_path) = init_repo().await;
std::fs::write(repo_path.join("README.md"), "updated\n").expect("update README");
let first = prepare_workspace_snapshot_commit(&repo_path)
.await
.expect("first snapshot hash");
let second = prepare_workspace_snapshot_commit(&repo_path)
.await
.expect("second snapshot hash");
assert_eq!(first, second);
}

View File

@@ -42,6 +42,7 @@ pub mod external_agent_config;
mod file_watcher;
mod flags;
pub mod git_info;
mod git_snapshot;
mod guardian;
mod hook_runtime;
pub mod instructions;

View File

@@ -12,6 +12,7 @@ use crate::git_info::get_git_remote_urls_assume_git_repo;
use crate::git_info::get_git_repo_root;
use crate::git_info::get_has_changes;
use crate::git_info::get_head_commit_hash;
use crate::git_snapshot::prepare_workspace_snapshot_commit;
use crate::sandbox_tags::sandbox_tag;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::protocol::SandboxPolicy;
@@ -21,6 +22,7 @@ struct WorkspaceGitMetadata {
associated_remote_urls: Option<BTreeMap<String, String>>,
latest_git_commit_hash: Option<String>,
has_changes: Option<bool>,
workspace_snapshot_commit_hash: Option<String>,
}
impl WorkspaceGitMetadata {
@@ -28,6 +30,7 @@ impl WorkspaceGitMetadata {
self.associated_remote_urls.is_none()
&& self.latest_git_commit_hash.is_none()
&& self.has_changes.is_none()
&& self.workspace_snapshot_commit_hash.is_none()
}
}
@@ -39,6 +42,8 @@ struct TurnMetadataWorkspace {
latest_git_commit_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
has_changes: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
workspace_snapshot_commit_hash: Option<String>,
}
impl From<WorkspaceGitMetadata> for TurnMetadataWorkspace {
@@ -47,6 +52,7 @@ impl From<WorkspaceGitMetadata> for TurnMetadataWorkspace {
associated_remote_urls: value.associated_remote_urls,
latest_git_commit_hash: value.latest_git_commit_hash,
has_changes: value.has_changes,
workspace_snapshot_commit_hash: value.workspace_snapshot_commit_hash,
}
}
}
@@ -92,18 +98,21 @@ fn build_turn_metadata_bag(
}
pub async fn build_turn_metadata_header(cwd: &Path, sandbox: Option<&str>) -> Option<String> {
let repo_root = get_git_repo_root(cwd).map(|root| root.to_string_lossy().into_owned());
build_turn_metadata_header_with_options(
cwd, sandbox, /*include_workspace_snapshot_commit_hash*/ false,
)
.await
}
let (latest_git_commit_hash, associated_remote_urls, has_changes) = tokio::join!(
get_head_commit_hash(cwd),
get_git_remote_urls_assume_git_repo(cwd),
get_has_changes(cwd),
);
if latest_git_commit_hash.is_none()
&& associated_remote_urls.is_none()
&& has_changes.is_none()
&& sandbox.is_none()
{
async fn build_turn_metadata_header_with_options(
cwd: &Path,
sandbox: Option<&str>,
include_workspace_snapshot_commit_hash: bool,
) -> Option<String> {
let repo_root = get_git_repo_root(cwd).map(|root| root.to_string_lossy().into_owned());
let workspace_git_metadata =
fetch_workspace_git_metadata(cwd, include_workspace_snapshot_commit_hash).await;
if workspace_git_metadata.is_empty() && sandbox.is_none() {
return None;
}
@@ -112,11 +121,7 @@ pub async fn build_turn_metadata_header(cwd: &Path, sandbox: Option<&str>) -> Op
/*turn_id*/ None,
sandbox.map(ToString::to_string),
repo_root,
Some(WorkspaceGitMetadata {
associated_remote_urls,
latest_git_commit_hash,
has_changes,
}),
Some(workspace_git_metadata),
)
.to_header_value()
}
@@ -124,6 +129,7 @@ pub async fn build_turn_metadata_header(cwd: &Path, sandbox: Option<&str>) -> Op
#[derive(Clone, Debug)]
pub(crate) struct TurnMetadataState {
cwd: PathBuf,
include_workspace_snapshot_commit_hash: bool,
repo_root: Option<String>,
base_metadata: TurnMetadataBag,
base_header: String,
@@ -136,6 +142,7 @@ impl TurnMetadataState {
session_id: String,
turn_id: String,
cwd: PathBuf,
include_workspace_snapshot_commit_hash: bool,
sandbox_policy: &SandboxPolicy,
windows_sandbox_level: WindowsSandboxLevel,
) -> Self {
@@ -154,6 +161,7 @@ impl TurnMetadataState {
Self {
cwd,
include_workspace_snapshot_commit_hash,
repo_root,
base_metadata,
base_header,
@@ -231,17 +239,35 @@ impl TurnMetadataState {
}
async fn fetch_workspace_git_metadata(&self) -> WorkspaceGitMetadata {
let (latest_git_commit_hash, associated_remote_urls, has_changes) = tokio::join!(
get_head_commit_hash(&self.cwd),
get_git_remote_urls_assume_git_repo(&self.cwd),
get_has_changes(&self.cwd),
);
fetch_workspace_git_metadata(&self.cwd, self.include_workspace_snapshot_commit_hash).await
}
}
WorkspaceGitMetadata {
associated_remote_urls,
latest_git_commit_hash,
has_changes,
async fn fetch_workspace_git_metadata(
cwd: &Path,
include_workspace_snapshot_commit_hash: bool,
) -> WorkspaceGitMetadata {
let (latest_git_commit_hash, associated_remote_urls, has_changes) = tokio::join!(
get_head_commit_hash(cwd),
get_git_remote_urls_assume_git_repo(cwd),
get_has_changes(cwd),
);
let workspace_snapshot_commit_hash = if include_workspace_snapshot_commit_hash {
match (latest_git_commit_hash.as_ref(), has_changes) {
(Some(head), Some(false)) => Some(head.clone()),
(Some(_), Some(true)) => prepare_workspace_snapshot_commit(cwd).await,
_ => None,
}
} else {
None
};
WorkspaceGitMetadata {
associated_remote_urls,
latest_git_commit_hash,
has_changes,
workspace_snapshot_commit_hash,
}
}

View File

@@ -4,44 +4,40 @@ use serde_json::Value;
use tempfile::TempDir;
use tokio::process::Command;
async fn run_git(repo_path: &std::path::Path, args: &[&str]) -> Vec<u8> {
let git_config_global = repo_path.join("empty-git-config");
std::fs::write(&git_config_global, "").expect("write empty git config");
let output = Command::new("git")
.env("GIT_CONFIG_GLOBAL", &git_config_global)
.env("GIT_CONFIG_NOSYSTEM", "1")
.args(args)
.current_dir(repo_path)
.output()
.await
.expect("git command should run");
assert!(
output.status.success(),
"git {:?} failed: stdout={} stderr={}",
args,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
output.stdout
}
#[tokio::test]
async fn build_turn_metadata_header_includes_has_changes_for_clean_repo() {
let temp_dir = TempDir::new().expect("temp dir");
let repo_path = temp_dir.path().join("repo");
std::fs::create_dir_all(&repo_path).expect("create repo");
Command::new("git")
.args(["init"])
.current_dir(&repo_path)
.output()
.await
.expect("git init");
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo_path)
.output()
.await
.expect("git config user.name");
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&repo_path)
.output()
.await
.expect("git config user.email");
run_git(&repo_path, &["init"]).await;
run_git(&repo_path, &["config", "user.name", "Test User"]).await;
run_git(&repo_path, &["config", "user.email", "test@example.com"]).await;
std::fs::write(repo_path.join("README.md"), "hello").expect("write file");
Command::new("git")
.args(["add", "."])
.current_dir(&repo_path)
.output()
.await
.expect("git add");
Command::new("git")
.args(["commit", "-m", "initial"])
.current_dir(&repo_path)
.output()
.await
.expect("git commit");
run_git(&repo_path, &["add", "."]).await;
run_git(&repo_path, &["commit", "-m", "initial"]).await;
let header = build_turn_metadata_header(&repo_path, Some("none"))
.await
@@ -60,6 +56,59 @@ async fn build_turn_metadata_header_includes_has_changes_for_clean_repo() {
);
}
#[tokio::test]
async fn build_turn_metadata_header_includes_workspace_snapshot_commit_hash_when_enabled() {
let temp_dir = TempDir::new().expect("temp dir");
let repo_path = temp_dir.path().join("repo");
std::fs::create_dir_all(&repo_path).expect("create repo");
run_git(&repo_path, &["init"]).await;
std::fs::write(repo_path.join("README.md"), "hello").expect("write file");
run_git(&repo_path, &["add", "."]).await;
run_git(
&repo_path,
&[
"-c",
"user.name=Test User",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"initial",
],
)
.await;
std::fs::write(repo_path.join("README.md"), "updated").expect("update file");
let header = build_turn_metadata_header_with_options(
&repo_path,
Some("none"),
/*include_workspace_snapshot_commit_hash*/ true,
)
.await
.expect("header");
let parsed: Value = serde_json::from_str(&header).expect("valid json");
let workspace = parsed
.get("workspaces")
.and_then(Value::as_object)
.and_then(|workspaces| workspaces.values().next())
.cloned()
.expect("workspace");
let snapshot_commit_hash = workspace
.get("workspace_snapshot_commit_hash")
.and_then(Value::as_str)
.expect("workspace snapshot commit hash");
let head_commit_hash = workspace
.get("latest_git_commit_hash")
.and_then(Value::as_str)
.expect("latest git commit hash");
assert_ne!(snapshot_commit_hash, head_commit_hash);
}
#[test]
fn turn_metadata_state_uses_platform_sandbox_tag() {
let temp_dir = TempDir::new().expect("temp dir");
@@ -70,6 +119,7 @@ fn turn_metadata_state_uses_platform_sandbox_tag() {
"session-a".to_string(),
"turn-a".to_string(),
cwd,
/*include_workspace_snapshot_commit_hash*/ false,
&sandbox_policy,
WindowsSandboxLevel::Disabled,
);

View File

@@ -7,6 +7,7 @@ use codex_core::ModelProviderInfo;
use codex_core::Prompt;
use codex_core::ResponseEvent;
use codex_core::WireApi;
use codex_features::Feature;
use codex_otel::SessionTelemetry;
use codex_otel::TelemetryAuthMode;
use codex_protocol::ThreadId;
@@ -546,3 +547,112 @@ async fn responses_stream_includes_turn_metadata_header_for_git_workspace_e2e()
Some(false)
);
}
#[tokio::test]
async fn responses_stream_includes_workspace_snapshot_commit_hash_when_feature_enabled() {
core_test_support::skip_if_no_network!();
let server = responses::start_mock_server().await;
let test = test_codex()
.with_config(|config| {
let _ = config.features.enable(Feature::GitWorkspaceSnapshot);
})
.build(&server)
.await
.expect("build test codex");
let cwd = test.cwd_path();
let git_config_global = cwd.join("empty-git-config");
std::fs::write(&git_config_global, "").expect("write empty git config");
let run_git = |args: &[&str]| {
let output = Command::new("git")
.env("GIT_CONFIG_GLOBAL", &git_config_global)
.env("GIT_CONFIG_NOSYSTEM", "1")
.args(args)
.current_dir(cwd)
.output()
.expect("git command should run");
assert!(
output.status.success(),
"git {:?} failed: stdout={} stderr={}",
args,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
output
};
run_git(&["init"]);
std::fs::write(cwd.join("README.md"), "hello").expect("write README");
run_git(&[
"-c",
"user.name=Test User",
"-c",
"user.email=test@example.com",
"add",
".",
]);
run_git(&[
"-c",
"user.name=Test User",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"initial commit",
]);
std::fs::write(cwd.join("README.md"), "updated").expect("update README");
let first_response = responses::sse(vec![
responses::ev_response_created("resp-2"),
responses::ev_reasoning_item("rsn-1", &["thinking"], &[]),
responses::ev_shell_command_call("call-1", "echo turn-metadata"),
responses::ev_completed("resp-2"),
]);
let follow_up_response = responses::sse(vec![
responses::ev_response_created("resp-3"),
responses::ev_assistant_message("msg-1", "done"),
responses::ev_completed("resp-3"),
]);
let request_log = responses::mount_response_sequence(
&server,
vec![
responses::sse_response(first_response),
responses::sse_response(follow_up_response),
],
)
.await;
test.submit_turn("hello")
.await
.expect("submit post-git turn prompt");
let requests = request_log.requests();
assert_eq!(requests.len(), 2, "expected two requests in one turn");
let second_parsed: serde_json::Value = serde_json::from_str(
&requests[1]
.header("x-codex-turn-metadata")
.expect("second request should include turn metadata"),
)
.expect("second metadata should be valid json");
let second_workspace = second_parsed
.get("workspaces")
.and_then(serde_json::Value::as_object)
.and_then(|workspaces| workspaces.values().next())
.cloned()
.expect("second request should include git workspace metadata");
let snapshot_commit_hash = second_workspace
.get("workspace_snapshot_commit_hash")
.and_then(serde_json::Value::as_str)
.expect("second request should include workspace snapshot commit hash");
let head_commit_hash = second_workspace
.get("latest_git_commit_hash")
.and_then(serde_json::Value::as_str)
.expect("second request should include latest git commit hash");
assert_ne!(snapshot_commit_hash, head_commit_hash);
}

View File

@@ -95,6 +95,8 @@ pub enum Feature {
ExecPermissionApprovals,
/// Enable Claude-style lifecycle hooks loaded from hooks.json files.
CodexHooks,
/// Attach a synthetic commit hash for the current dirty worktree to turn metadata.
GitWorkspaceSnapshot,
/// Expose the built-in request_permissions tool.
RequestPermissionsTool,
/// Allow the model to request web searches that fetch live content.
@@ -645,6 +647,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::GitWorkspaceSnapshot,
key: "git_workspace_snapshot",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::RequestPermissionsTool,
key: "request_permissions_tool",

View File

@@ -114,6 +114,15 @@ fn request_permissions_tool_is_under_development() {
assert_eq!(Feature::RequestPermissionsTool.default_enabled(), false);
}
#[test]
fn git_workspace_snapshot_is_under_development() {
assert_eq!(
Feature::GitWorkspaceSnapshot.stage(),
Stage::UnderDevelopment
);
assert_eq!(Feature::GitWorkspaceSnapshot.default_enabled(), false);
}
#[test]
fn tool_suggest_is_under_development() {
assert_eq!(Feature::ToolSuggest.stage(), Stage::UnderDevelopment);