Preserve complete shell snapshot exports through filtering and replay (#43907)

## Why

Line-based export parsing can truncate multiline values or mistake their contents for declarations. Snapshot replay also needs to restore Bash options before parsing functions that use extended glob syntax.

## What changed

- Capture shell state, aliases, and complete export records with NUL-delimited boundaries, separating capture, credential preparation, and rendering.
- Apply credential and environment policy to whole exports, preserving multiline values and making credential aliases follow allowed overrides.
- Restore Bash `shopt` options before functions, including for Bash running as `sh`.
- Support non-evaluating POSIX `ENV` path expansion and preserve native environment metadata and unset exports.
- Move core and exec-server snapshot consumers to the shared capture parser. Allow capture overhead while enforcing the exec-server's 512 KiB state-and-environment limit before filtering.

## Testing

Add regression coverage for complete capture records, multiline export filtering and replay, credential alias overrides, `ENV` expansion, and `PATH` export state. Extend exec-server tests to cover Bash-backed `sh`, `extglob`, `nocasematch`, `set -u`, and large environments.

GitOrigin-RevId: 7589ab4d137529a395dcee10b5162718de24a621
This commit is contained in:
Winston Howes
2026-09-08 20:41:01 +00:00
committed by copyberry
parent f046cf35df
commit 1530f828cb
11 changed files with 1472 additions and 300 deletions
+18 -15
View File
@@ -17,7 +17,10 @@ use anyhow::bail;
use codex_exec_server::Environment;
use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use codex_shell_command::shell_snapshot::snapshot_script;
use codex_shell_command::shell_snapshot::CapturedSnapshot;
use codex_shell_command::shell_snapshot::SnapshotCaptureOptions;
use codex_shell_command::shell_snapshot::SnapshotStartup;
use codex_shell_command::shell_snapshot::snapshot_capture_script;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
use tokio::fs;
@@ -209,8 +212,7 @@ async fn write_shell_snapshot(
let shell =
get_shell(shell_type).with_context(|| format!("No available shell for {shell_type:?}"))?;
let raw_snapshot = capture_snapshot(&shell, cwd).await?;
let snapshot = strip_snapshot_preamble(&raw_snapshot)?;
let snapshot = capture_snapshot(&shell, cwd).await?;
if let Some(parent) = output_path.parent() {
let parent_display = parent.display();
@@ -229,18 +231,19 @@ async fn write_shell_snapshot(
async fn capture_snapshot(shell: &Shell, cwd: &AbsolutePathBuf) -> Result<String> {
let shell_type = shell.shell_type;
let script = snapshot_script(shell_type)
.ok_or_else(|| anyhow!("Shell snapshotting is not yet supported for {shell_type:?}"))?;
run_shell_script(shell, &script, cwd).await
}
fn strip_snapshot_preamble(snapshot: &str) -> Result<String> {
let marker = "# Snapshot file";
let Some(start) = snapshot.find(marker) else {
bail!("Snapshot output missing marker {marker}");
};
Ok(snapshot[start..].to_string())
let script = snapshot_capture_script(
shell_type,
SnapshotCaptureOptions {
startup: SnapshotStartup::Interactive,
declarations: true,
environment: false,
},
)
.ok_or_else(|| anyhow!("Shell snapshotting is not yet supported for {shell_type:?}"))?;
let captured = run_shell_script(shell, &script, cwd).await?;
CapturedSnapshot::parse(shell_type, captured.as_bytes())
.map(|snapshot| snapshot.render_script())
.ok_or_else(|| anyhow!("Invalid shell snapshot capture"))
}
async fn validate_snapshot(
+9 -14
View File
@@ -86,19 +86,6 @@ async fn get_snapshot(shell_type: ShellType) -> Result<String> {
Ok(content)
}
#[test]
fn strip_snapshot_preamble_removes_leading_output() {
let snapshot = "noise\n# Snapshot file\nexport PATH=/bin\n";
let cleaned = strip_snapshot_preamble(snapshot).expect("snapshot marker exists");
assert_eq!(cleaned, "# Snapshot file\nexport PATH=/bin\n");
}
#[test]
fn strip_snapshot_preamble_requires_marker() {
let result = strip_snapshot_preamble("missing header");
assert!(result.is_err());
}
#[test]
fn snapshot_file_name_parser_supports_legacy_and_suffixed_names() {
let session_id = "019cf82b-6a62-7700-bbbd-46909794ef89";
@@ -217,7 +204,15 @@ async fn snapshot_shell_does_not_inherit_stdin() -> Result<()> {
let home_display = home.display();
let script = format!(
"HOME=\"{home_display}\"; export HOME; {}",
snapshot_script(ShellType::Bash).expect("bash supports snapshots")
snapshot_capture_script(
ShellType::Bash,
SnapshotCaptureOptions {
startup: SnapshotStartup::Interactive,
declarations: true,
environment: false,
}
)
.expect("bash supports snapshots")
);
let output = run_script_with_timeout(
&shell,