Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- codex-rs/analytics/src/accepted_lines.rs +187 -0
- codex-rs/analytics/src/analytics_capture.rs +34 -0
- codex-rs/analytics/src/analytics_client_tests.rs +0 -0
- codex-rs/analytics/src/client.rs +961 -0
- codex-rs/analytics/src/client_tests.rs +1020 -0
- codex-rs/analytics/src/events.rs +1542 -0
- codex-rs/analytics/src/facts.rs +742 -0
- codex-rs/analytics/src/guardian_v2.rs +49 -0
- codex-rs/analytics/src/lib.rs +116 -0
- codex-rs/analytics/src/reducer.rs +0 -0
- codex-rs/analytics/src/thread_hint.rs +38 -0
- codex-rs/app-server-daemon/src/backend/mod.rs +52 -0
- codex-rs/app-server-daemon/src/backend/pid.rs +774 -0
- codex-rs/app-server-daemon/src/backend/pid_identity.rs +242 -0
- codex-rs/app-server-daemon/src/backend/pid_identity_tests.rs +89 -0
- codex-rs/app-server-daemon/src/backend/pid_start.rs +321 -0
- codex-rs/app-server-daemon/src/backend/pid_tests.rs +924 -0
- codex-rs/app-server-daemon/src/backend/pid_windows.rs +100 -0
- codex-rs/app-server-daemon/src/backend/windows.rs +286 -0
- codex-rs/app-server-daemon/src/backend/windows_tests.rs +87 -0
- codex-rs/app-server-daemon/src/client.rs +207 -0
- codex-rs/app-server-daemon/src/install_lock.rs +143 -0
- codex-rs/app-server-daemon/src/lib.rs +1400 -0
- codex-rs/app-server-daemon/src/managed_install.rs +221 -0
- codex-rs/app-server-daemon/src/managed_install_path_tests.rs +127 -0
- codex-rs/app-server-daemon/src/managed_install_tests.rs +27 -0
- codex-rs/app-server-daemon/src/manual_update.rs +281 -0
- codex-rs/app-server-daemon/src/migration.rs +193 -0
- codex-rs/app-server-daemon/src/prepare_install.rs +493 -0
- codex-rs/app-server-daemon/src/prepare_install_tests.rs +385 -0
- codex-rs/app-server-daemon/src/prepare_install_windows.rs +97 -0
- codex-rs/app-server-daemon/src/prepare_install_windows_tests.rs +19 -0
- codex-rs/app-server-daemon/src/remote_control_client.rs +770 -0
- codex-rs/app-server-daemon/src/settings.rs +195 -0
- codex-rs/app-server-daemon/src/settings_tests.rs +130 -0
- codex-rs/app-server-daemon/src/thread_recovery.rs +16 -0
- codex-rs/app-server-daemon/src/update_loop.rs +710 -0
- codex-rs/app-server-daemon/src/update_loop_tests.rs +891 -0
- codex-rs/build-info/src/build_info_tests.rs +197 -0
- codex-rs/build-info/src/lib.rs +155 -0
- codex-rs/cloud-tasks/src/app.rs +534 -0
- codex-rs/cloud-tasks/src/cli.rs +120 -0
- codex-rs/cloud-tasks/src/env_detect.rs +428 -0
- codex-rs/cloud-tasks/src/env_detect_tests.rs +359 -0
- codex-rs/cloud-tasks/src/lib.rs +0 -0
- codex-rs/cloud-tasks/src/new_task.rs +35 -0
- codex-rs/cloud-tasks/src/scrollable_diff.rs +208 -0
- codex-rs/cloud-tasks/src/scrollable_diff_tests.rs +48 -0
- codex-rs/cloud-tasks/src/ui.rs +1046 -0
- codex-rs/cloud-tasks/src/util.rs +173 -0
codex-rs/analytics/src/accepted_lines.rs
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use crate::events::CodexAcceptedLineFingerprintsEventParams;
|
| 2 |
+
use crate::events::CodexAcceptedLineFingerprintsEventRequest;
|
| 3 |
+
use crate::events::TrackEventRequest;
|
| 4 |
+
use codex_git_utils::canonicalize_git_remote_url;
|
| 5 |
+
use codex_git_utils::get_git_remote_urls_assume_git_repo;
|
| 6 |
+
use sha1::Digest;
|
| 7 |
+
use std::path::Path;
|
| 8 |
+
|
| 9 |
+
#[derive(Clone, Debug, PartialEq, Eq)]
|
| 10 |
+
pub(crate) struct AcceptedLineCounts {
|
| 11 |
+
pub(crate) accepted_added_lines: u64,
|
| 12 |
+
pub(crate) accepted_deleted_lines: u64,
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
pub(crate) struct AcceptedLineFingerprintEventInput {
|
| 16 |
+
pub(crate) event_type: &'static str,
|
| 17 |
+
pub(crate) turn_id: String,
|
| 18 |
+
pub(crate) thread_id: String,
|
| 19 |
+
pub(crate) product_surface: Option<String>,
|
| 20 |
+
pub(crate) model_slug: Option<String>,
|
| 21 |
+
pub(crate) completed_at: u64,
|
| 22 |
+
pub(crate) repo_hash: Option<String>,
|
| 23 |
+
pub(crate) accepted_added_lines: u64,
|
| 24 |
+
pub(crate) accepted_deleted_lines: u64,
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
pub(crate) fn accepted_line_counts_from_unified_diff(unified_diff: &str) -> AcceptedLineCounts {
|
| 28 |
+
let mut in_hunk = false;
|
| 29 |
+
let mut accepted_added_lines = 0;
|
| 30 |
+
let mut accepted_deleted_lines = 0;
|
| 31 |
+
|
| 32 |
+
for line in unified_diff.lines() {
|
| 33 |
+
if line.starts_with("diff --git ") {
|
| 34 |
+
in_hunk = false;
|
| 35 |
+
continue;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
if line.starts_with("@@ ") {
|
| 39 |
+
in_hunk = true;
|
| 40 |
+
continue;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
if !in_hunk && (line.starts_with("+++ ") || line.starts_with("--- ")) {
|
| 44 |
+
continue;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
if line.starts_with('+') {
|
| 48 |
+
accepted_added_lines += 1;
|
| 49 |
+
continue;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
if line.starts_with('-') {
|
| 53 |
+
accepted_deleted_lines += 1;
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
AcceptedLineCounts {
|
| 58 |
+
accepted_added_lines,
|
| 59 |
+
accepted_deleted_lines,
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
pub fn fingerprint_hash(domain: &str, value: &str) -> String {
|
| 64 |
+
let mut hasher = sha1::Sha1::new();
|
| 65 |
+
hasher.update(b"file-line-v1\0");
|
| 66 |
+
hasher.update(domain.as_bytes());
|
| 67 |
+
hasher.update(b"\0");
|
| 68 |
+
hasher.update(value.as_bytes());
|
| 69 |
+
format!("{:x}", hasher.finalize())
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
pub(crate) fn accepted_line_fingerprint_event_requests(
|
| 73 |
+
input: AcceptedLineFingerprintEventInput,
|
| 74 |
+
) -> Vec<TrackEventRequest> {
|
| 75 |
+
let AcceptedLineFingerprintEventInput {
|
| 76 |
+
event_type,
|
| 77 |
+
turn_id,
|
| 78 |
+
thread_id,
|
| 79 |
+
product_surface,
|
| 80 |
+
model_slug,
|
| 81 |
+
completed_at,
|
| 82 |
+
repo_hash,
|
| 83 |
+
accepted_added_lines,
|
| 84 |
+
accepted_deleted_lines,
|
| 85 |
+
} = input;
|
| 86 |
+
|
| 87 |
+
vec![TrackEventRequest::AcceptedLineFingerprints(Box::new(
|
| 88 |
+
CodexAcceptedLineFingerprintsEventRequest {
|
| 89 |
+
event_type: "codex_accepted_line_fingerprints",
|
| 90 |
+
event_params: CodexAcceptedLineFingerprintsEventParams {
|
| 91 |
+
event_type,
|
| 92 |
+
turn_id,
|
| 93 |
+
thread_id,
|
| 94 |
+
product_surface,
|
| 95 |
+
model_slug,
|
| 96 |
+
completed_at,
|
| 97 |
+
repo_hash,
|
| 98 |
+
accepted_added_lines,
|
| 99 |
+
accepted_deleted_lines,
|
| 100 |
+
line_fingerprints: [],
|
| 101 |
+
},
|
| 102 |
+
},
|
| 103 |
+
))]
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
pub async fn accepted_line_repo_hash_for_cwd(cwd: &Path) -> Option<String> {
|
| 107 |
+
let remotes = get_git_remote_urls_assume_git_repo(cwd).await?;
|
| 108 |
+
remotes
|
| 109 |
+
.get("origin")
|
| 110 |
+
.or_else(|| remotes.values().next())
|
| 111 |
+
.map(|remote_url| {
|
| 112 |
+
let canonical_remote_url = canonicalize_git_remote_url(remote_url.as_str())
|
| 113 |
+
.unwrap_or_else(|| remote_url.to_string());
|
| 114 |
+
fingerprint_hash("repo", &canonical_remote_url)
|
| 115 |
+
})
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
#[cfg(test)]
|
| 119 |
+
mod tests {
|
| 120 |
+
use super::*;
|
| 121 |
+
|
| 122 |
+
#[test]
|
| 123 |
+
fn parses_accepted_line_counts() {
|
| 124 |
+
let diff = "\
|
| 125 |
+
diff --git a/src/lib.rs b/src/lib.rs
|
| 126 |
+
index 1111111..2222222
|
| 127 |
+
--- a/src/lib.rs
|
| 128 |
+
+++ b/src/lib.rs
|
| 129 |
+
@@ -1,3 +1,5 @@
|
| 130 |
+
-old line
|
| 131 |
+
+fn useful() {
|
| 132 |
+
+}
|
| 133 |
+
+ return user.id;
|
| 134 |
+
context
|
| 135 |
+
";
|
| 136 |
+
|
| 137 |
+
assert_eq!(
|
| 138 |
+
accepted_line_counts_from_unified_diff(diff),
|
| 139 |
+
AcceptedLineCounts {
|
| 140 |
+
accepted_added_lines: 3,
|
| 141 |
+
accepted_deleted_lines: 1,
|
| 142 |
+
},
|
| 143 |
+
);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
#[test]
|
| 147 |
+
fn skips_added_file_metadata_headers() {
|
| 148 |
+
let diff = "\
|
| 149 |
+
diff --git a/new.py b/new.py
|
| 150 |
+
new file mode 100644
|
| 151 |
+
index 0000000..1111111
|
| 152 |
+
--- /dev/null
|
| 153 |
+
+++ b/new.py
|
| 154 |
+
@@ -0,0 +1 @@
|
| 155 |
+
+print('hello')
|
| 156 |
+
";
|
| 157 |
+
|
| 158 |
+
assert_eq!(
|
| 159 |
+
accepted_line_counts_from_unified_diff(diff),
|
| 160 |
+
AcceptedLineCounts {
|
| 161 |
+
accepted_added_lines: 1,
|
| 162 |
+
accepted_deleted_lines: 0,
|
| 163 |
+
},
|
| 164 |
+
);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
#[test]
|
| 168 |
+
fn parses_hunk_lines_that_look_like_file_headers() {
|
| 169 |
+
let diff = "\
|
| 170 |
+
diff --git a/src/lib.rs b/src/lib.rs
|
| 171 |
+
index 1111111..2222222
|
| 172 |
+
--- a/src/lib.rs
|
| 173 |
+
+++ b/src/lib.rs
|
| 174 |
+
@@ -1,2 +1,2 @@
|
| 175 |
+
--- old value
|
| 176 |
+
+++ new value
|
| 177 |
+
";
|
| 178 |
+
|
| 179 |
+
assert_eq!(
|
| 180 |
+
accepted_line_counts_from_unified_diff(diff),
|
| 181 |
+
AcceptedLineCounts {
|
| 182 |
+
accepted_added_lines: 1,
|
| 183 |
+
accepted_deleted_lines: 1,
|
| 184 |
+
},
|
| 185 |
+
);
|
| 186 |
+
}
|
| 187 |
+
}
|
codex-rs/analytics/src/analytics_capture.rs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use crate::events::TrackEventsRequest;
|
| 2 |
+
use std::fs::File;
|
| 3 |
+
use std::fs::OpenOptions;
|
| 4 |
+
use std::io;
|
| 5 |
+
use std::io::Write;
|
| 6 |
+
use std::path::Path;
|
| 7 |
+
|
| 8 |
+
pub(crate) const ANALYTICS_EVENTS_CAPTURE_FILE_ENV_VAR: &str =
|
| 9 |
+
"CODEX_ANALYTICS_EVENTS_CAPTURE_FILE";
|
| 10 |
+
|
| 11 |
+
pub(crate) fn initialize(path: &Path) -> io::Result<()> {
|
| 12 |
+
open_capture_file(path).map(drop)
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
pub(crate) fn append_payload(path: &Path, payload: &TrackEventsRequest) -> io::Result<()> {
|
| 16 |
+
let mut line = serde_json::to_vec(payload)
|
| 17 |
+
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
| 18 |
+
line.push(b'\n');
|
| 19 |
+
|
| 20 |
+
let mut file = open_capture_file(path)?;
|
| 21 |
+
file.write_all(&line)?;
|
| 22 |
+
file.flush()
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
fn open_capture_file(path: &Path) -> io::Result<File> {
|
| 26 |
+
let mut options = OpenOptions::new();
|
| 27 |
+
options.create(true).append(true);
|
| 28 |
+
#[cfg(unix)]
|
| 29 |
+
{
|
| 30 |
+
use std::os::unix::fs::OpenOptionsExt;
|
| 31 |
+
options.mode(0o600);
|
| 32 |
+
}
|
| 33 |
+
options.open(path)
|
| 34 |
+
}
|
codex-rs/analytics/src/analytics_client_tests.rs
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
codex-rs/analytics/src/client.rs
ADDED
|
@@ -0,0 +1,961 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use crate::events::AppServerRpcTransport;
|
| 2 |
+
use crate::events::GuardianReviewAnalyticsResult;
|
| 3 |
+
use crate::events::GuardianReviewTrackContext;
|
| 4 |
+
use crate::events::TrackEventRequest;
|
| 5 |
+
use crate::events::TrackEventsRequest;
|
| 6 |
+
use crate::events::current_runtime_metadata;
|
| 7 |
+
use crate::facts::AnalyticsFact;
|
| 8 |
+
use crate::facts::AnalyticsJsonRpcError;
|
| 9 |
+
use crate::facts::AppInvocation;
|
| 10 |
+
use crate::facts::AppMentionedInput;
|
| 11 |
+
use crate::facts::AppUsedInput;
|
| 12 |
+
use crate::facts::ArtifactOperation;
|
| 13 |
+
use crate::facts::ArtifactOperationInput;
|
| 14 |
+
use crate::facts::CodexGoalEvent;
|
| 15 |
+
use crate::facts::CustomAnalyticsFact;
|
| 16 |
+
use crate::facts::ElicitationType;
|
| 17 |
+
use crate::facts::ExternalAgentConfigImportCompletedInput;
|
| 18 |
+
use crate::facts::ExternalAgentConfigImportFailureInput;
|
| 19 |
+
use crate::facts::HookRunFact;
|
| 20 |
+
use crate::facts::HookRunInput;
|
| 21 |
+
use crate::facts::ImagePreparationFact;
|
| 22 |
+
use crate::facts::McpToolCallElicitation;
|
| 23 |
+
use crate::facts::PluginInstallFailedInput;
|
| 24 |
+
use crate::facts::PluginInstallRequested;
|
| 25 |
+
use crate::facts::PluginInstallRequestedInput;
|
| 26 |
+
use crate::facts::PluginInstallSource;
|
| 27 |
+
use crate::facts::PluginMeasurementsInput;
|
| 28 |
+
use crate::facts::PluginState;
|
| 29 |
+
use crate::facts::PluginStateChangedInput;
|
| 30 |
+
use crate::facts::SkillInvocation;
|
| 31 |
+
use crate::facts::SkillInvokedInput;
|
| 32 |
+
use crate::facts::SubAgentThreadStartedInput;
|
| 33 |
+
use crate::facts::TrackEventsContext;
|
| 34 |
+
use crate::facts::TurnCodexErrorFact;
|
| 35 |
+
use crate::facts::TurnProfileFact;
|
| 36 |
+
use crate::facts::TurnResolvedConfigFact;
|
| 37 |
+
use crate::facts::TurnTokenUsageFact;
|
| 38 |
+
use crate::guardian_v2::GuardianV2Event;
|
| 39 |
+
use crate::now_unix_millis;
|
| 40 |
+
use crate::reducer::AnalyticsReducer;
|
| 41 |
+
use crate::reducer::MAX_PLUGIN_MEASUREMENTS_PER_BATCH;
|
| 42 |
+
use crate::reducer::tracked_tool_item_id;
|
| 43 |
+
use crate::reducer::valid_plugin_measurement_identifier;
|
| 44 |
+
use crate::reducer::valid_plugin_measurement_row;
|
| 45 |
+
use codex_app_server_protocol::ClientRequest;
|
| 46 |
+
use codex_app_server_protocol::ClientResponsePayload;
|
| 47 |
+
use codex_app_server_protocol::InitializeParams;
|
| 48 |
+
use codex_app_server_protocol::ItemCompletedNotification;
|
| 49 |
+
use codex_app_server_protocol::ItemStartedNotification;
|
| 50 |
+
use codex_app_server_protocol::JSONRPCErrorError;
|
| 51 |
+
use codex_app_server_protocol::RequestId;
|
| 52 |
+
use codex_app_server_protocol::ServerNotification;
|
| 53 |
+
use codex_app_server_protocol::ServerRequest;
|
| 54 |
+
use codex_app_server_protocol::ServerResponse;
|
| 55 |
+
use codex_app_server_protocol::Turn;
|
| 56 |
+
use codex_app_server_protocol::TurnCompletedNotification;
|
| 57 |
+
use codex_app_server_protocol::TurnError;
|
| 58 |
+
use codex_app_server_protocol::TurnItemsView;
|
| 59 |
+
use codex_app_server_protocol::TurnStartedNotification;
|
| 60 |
+
use codex_app_server_protocol::TurnStatus;
|
| 61 |
+
use codex_app_server_protocol::item_event_to_server_notification;
|
| 62 |
+
use codex_login::AuthManager;
|
| 63 |
+
use codex_login::CodexAuth;
|
| 64 |
+
use codex_login::default_client::create_client;
|
| 65 |
+
use codex_plugin::PluginId;
|
| 66 |
+
use codex_plugin::PluginTelemetryMetadata;
|
| 67 |
+
use codex_protocol::ThreadId;
|
| 68 |
+
use codex_protocol::items::CollabAgentToolCallItem;
|
| 69 |
+
use codex_protocol::items::CollabAgentToolCallStatus;
|
| 70 |
+
use codex_protocol::items::TurnItem;
|
| 71 |
+
use codex_protocol::protocol::Event;
|
| 72 |
+
use codex_protocol::protocol::EventMsg;
|
| 73 |
+
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
| 74 |
+
use std::collections::HashSet;
|
| 75 |
+
use std::path::PathBuf;
|
| 76 |
+
use std::sync::Arc;
|
| 77 |
+
use std::sync::Mutex;
|
| 78 |
+
use std::time::Duration;
|
| 79 |
+
use tokio::sync::mpsc;
|
| 80 |
+
use tokio::sync::oneshot;
|
| 81 |
+
|
| 82 |
+
const ANALYTICS_EVENTS_QUEUE_SIZE: usize = 256;
|
| 83 |
+
const ANALYTICS_EVENTS_TIMEOUT: Duration = Duration::from_secs(10);
|
| 84 |
+
// Covers two sequential POSTs plus queue/barrier scheduling; additional queued sends remain best-effort.
|
| 85 |
+
const ANALYTICS_EVENTS_FLUSH_TIMEOUT: Duration = Duration::from_secs(25);
|
| 86 |
+
const ANALYTICS_EVENT_DEDUPE_MAX_KEYS: usize = 4096;
|
| 87 |
+
|
| 88 |
+
pub(crate) enum AnalyticsEventsQueueMessage {
|
| 89 |
+
Fact(Box<AnalyticsFact>),
|
| 90 |
+
Flush(oneshot::Sender<()>),
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
#[derive(Clone)]
|
| 94 |
+
pub(crate) struct AnalyticsEventsQueue {
|
| 95 |
+
pub(crate) sender: mpsc::Sender<AnalyticsEventsQueueMessage>,
|
| 96 |
+
pub(crate) app_used_emitted_keys: Arc<Mutex<HashSet<(String, String)>>>,
|
| 97 |
+
pub(crate) plugin_used_emitted_keys: Arc<Mutex<HashSet<(String, String)>>>,
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
#[derive(Clone)]
|
| 101 |
+
pub struct AnalyticsEventsClient {
|
| 102 |
+
queue: Option<AnalyticsEventsQueue>,
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
| 106 |
+
enum AnalyticsEventsDestination {
|
| 107 |
+
Http {
|
| 108 |
+
url: String,
|
| 109 |
+
},
|
| 110 |
+
#[cfg(debug_assertions)]
|
| 111 |
+
CaptureFile {
|
| 112 |
+
path: PathBuf,
|
| 113 |
+
},
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
impl AnalyticsEventsDestination {
|
| 117 |
+
fn from_base_url(base_url: String) -> Self {
|
| 118 |
+
let capture_file = analytics_capture_file_from_env();
|
| 119 |
+
Self::from_base_url_and_capture_file(base_url, capture_file)
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
fn from_base_url_and_capture_file(base_url: String, capture_file: Option<PathBuf>) -> Self {
|
| 123 |
+
#[cfg(debug_assertions)]
|
| 124 |
+
if let Some(path) = capture_file {
|
| 125 |
+
if let Err(err) = crate::analytics_capture::initialize(&path) {
|
| 126 |
+
tracing::error!(
|
| 127 |
+
path = %path.display(),
|
| 128 |
+
"failed to initialize analytics event capture; network delivery remains disabled: {err}"
|
| 129 |
+
);
|
| 130 |
+
}
|
| 131 |
+
tracing::warn!(
|
| 132 |
+
path = %path.display(),
|
| 133 |
+
"analytics event capture enabled; network delivery is disabled"
|
| 134 |
+
);
|
| 135 |
+
return Self::CaptureFile { path };
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
#[cfg(not(debug_assertions))]
|
| 139 |
+
let _ = capture_file;
|
| 140 |
+
|
| 141 |
+
let base_url = base_url.trim_end_matches('/');
|
| 142 |
+
Self::Http {
|
| 143 |
+
url: format!("{base_url}/codex/analytics-events/events"),
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
fn analytics_capture_file_from_env() -> Option<PathBuf> {
|
| 149 |
+
#[cfg(debug_assertions)]
|
| 150 |
+
{
|
| 151 |
+
std::env::var_os(crate::analytics_capture::ANALYTICS_EVENTS_CAPTURE_FILE_ENV_VAR)
|
| 152 |
+
.filter(|value| !value.is_empty())
|
| 153 |
+
.map(PathBuf::from)
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
#[cfg(not(debug_assertions))]
|
| 157 |
+
None
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
impl AnalyticsEventsQueue {
|
| 161 |
+
fn new(auth_manager: Arc<AuthManager>, destination: AnalyticsEventsDestination) -> Self {
|
| 162 |
+
let (sender, mut receiver) = mpsc::channel(ANALYTICS_EVENTS_QUEUE_SIZE);
|
| 163 |
+
tokio::spawn(async move {
|
| 164 |
+
let mut reducer = AnalyticsReducer::default();
|
| 165 |
+
while let Some(input) = receiver.recv().await {
|
| 166 |
+
let input = match input {
|
| 167 |
+
AnalyticsEventsQueueMessage::Fact(input) => *input,
|
| 168 |
+
AnalyticsEventsQueueMessage::Flush(done_tx) => {
|
| 169 |
+
let mut events = Vec::new();
|
| 170 |
+
reducer.flush(&mut events);
|
| 171 |
+
send_track_events(&auth_manager, &destination, events).await;
|
| 172 |
+
let _ = done_tx.send(());
|
| 173 |
+
continue;
|
| 174 |
+
}
|
| 175 |
+
};
|
| 176 |
+
let mut events = Vec::new();
|
| 177 |
+
reducer.ingest(input, &mut events).await;
|
| 178 |
+
send_track_events(&auth_manager, &destination, events).await;
|
| 179 |
+
}
|
| 180 |
+
});
|
| 181 |
+
Self {
|
| 182 |
+
sender,
|
| 183 |
+
app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
|
| 184 |
+
plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
fn try_send(&self, input: AnalyticsFact) {
|
| 189 |
+
if self
|
| 190 |
+
.sender
|
| 191 |
+
.try_send(AnalyticsEventsQueueMessage::Fact(Box::new(input)))
|
| 192 |
+
.is_err()
|
| 193 |
+
{
|
| 194 |
+
//TODO: add a metric for this
|
| 195 |
+
tracing::warn!("dropping analytics events: queue is full");
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
pub(crate) fn should_enqueue_app_used(
|
| 200 |
+
&self,
|
| 201 |
+
tracking: &TrackEventsContext,
|
| 202 |
+
app: &AppInvocation,
|
| 203 |
+
) -> bool {
|
| 204 |
+
let Some(connector_id) = app.connector_id.as_ref() else {
|
| 205 |
+
return true;
|
| 206 |
+
};
|
| 207 |
+
let mut emitted = self
|
| 208 |
+
.app_used_emitted_keys
|
| 209 |
+
.lock()
|
| 210 |
+
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
| 211 |
+
if emitted.len() >= ANALYTICS_EVENT_DEDUPE_MAX_KEYS {
|
| 212 |
+
emitted.clear();
|
| 213 |
+
}
|
| 214 |
+
emitted.insert((tracking.turn_id.clone(), connector_id.clone()))
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
pub(crate) fn should_enqueue_plugin_used(
|
| 218 |
+
&self,
|
| 219 |
+
tracking: &TrackEventsContext,
|
| 220 |
+
plugin: &PluginTelemetryMetadata,
|
| 221 |
+
) -> bool {
|
| 222 |
+
let mut emitted = self
|
| 223 |
+
.plugin_used_emitted_keys
|
| 224 |
+
.lock()
|
| 225 |
+
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
| 226 |
+
if emitted.len() >= ANALYTICS_EVENT_DEDUPE_MAX_KEYS {
|
| 227 |
+
emitted.clear();
|
| 228 |
+
}
|
| 229 |
+
let Some(plugin_id) = plugin
|
| 230 |
+
.plugin_id
|
| 231 |
+
.as_ref()
|
| 232 |
+
.map(PluginId::as_key)
|
| 233 |
+
.or_else(|| plugin.remote_plugin_id.clone())
|
| 234 |
+
else {
|
| 235 |
+
return true;
|
| 236 |
+
};
|
| 237 |
+
emitted.insert((tracking.turn_id.clone(), plugin_id))
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
impl AnalyticsEventsClient {
|
| 242 |
+
pub fn new(
|
| 243 |
+
auth_manager: Arc<AuthManager>,
|
| 244 |
+
base_url: String,
|
| 245 |
+
analytics_enabled: Option<bool>,
|
| 246 |
+
) -> Self {
|
| 247 |
+
let destination = AnalyticsEventsDestination::from_base_url(base_url);
|
| 248 |
+
Self {
|
| 249 |
+
queue: (analytics_enabled != Some(false))
|
| 250 |
+
.then(|| AnalyticsEventsQueue::new(Arc::clone(&auth_manager), destination)),
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
pub fn disabled() -> Self {
|
| 255 |
+
Self { queue: None }
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
pub async fn flush(&self) {
|
| 259 |
+
let Some(queue) = self.queue.as_ref() else {
|
| 260 |
+
return;
|
| 261 |
+
};
|
| 262 |
+
let (done_tx, done_rx) = oneshot::channel();
|
| 263 |
+
let flushed = tokio::time::timeout(ANALYTICS_EVENTS_FLUSH_TIMEOUT, async {
|
| 264 |
+
if queue
|
| 265 |
+
.sender
|
| 266 |
+
.send(AnalyticsEventsQueueMessage::Flush(done_tx))
|
| 267 |
+
.await
|
| 268 |
+
.is_err()
|
| 269 |
+
{
|
| 270 |
+
return false;
|
| 271 |
+
}
|
| 272 |
+
done_rx.await.is_ok()
|
| 273 |
+
})
|
| 274 |
+
.await;
|
| 275 |
+
|
| 276 |
+
if !matches!(flushed, Ok(true)) {
|
| 277 |
+
tracing::warn!("timed out or failed while flushing analytics events");
|
| 278 |
+
}
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
pub fn is_enabled(&self) -> bool {
|
| 282 |
+
self.queue.is_some()
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
pub fn track_plugin_measurements(&self, mut input: PluginMeasurementsInput) {
|
| 286 |
+
if input.rows.is_empty()
|
| 287 |
+
|| input.rows.len() > MAX_PLUGIN_MEASUREMENTS_PER_BATCH
|
| 288 |
+
|| !valid_plugin_measurement_identifier(&input.operation)
|
| 289 |
+
{
|
| 290 |
+
return;
|
| 291 |
+
}
|
| 292 |
+
input.rows.retain(valid_plugin_measurement_row);
|
| 293 |
+
if input.rows.is_empty() {
|
| 294 |
+
return;
|
| 295 |
+
}
|
| 296 |
+
self.record_fact(AnalyticsFact::Custom(
|
| 297 |
+
CustomAnalyticsFact::PluginMeasurements(input),
|
| 298 |
+
));
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
pub fn track_skill_invocations(
|
| 302 |
+
&self,
|
| 303 |
+
tracking: TrackEventsContext,
|
| 304 |
+
invocations: Vec<SkillInvocation>,
|
| 305 |
+
) {
|
| 306 |
+
if invocations.is_empty() {
|
| 307 |
+
return;
|
| 308 |
+
}
|
| 309 |
+
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::SkillInvoked(
|
| 310 |
+
SkillInvokedInput {
|
| 311 |
+
tracking,
|
| 312 |
+
invocations,
|
| 313 |
+
},
|
| 314 |
+
)));
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
pub fn track_artifact_operation(
|
| 318 |
+
&self,
|
| 319 |
+
tracking: TrackEventsContext,
|
| 320 |
+
operation: ArtifactOperation,
|
| 321 |
+
) {
|
| 322 |
+
self.record_fact(AnalyticsFact::Custom(
|
| 323 |
+
CustomAnalyticsFact::ArtifactOperation(ArtifactOperationInput {
|
| 324 |
+
tracking,
|
| 325 |
+
operation,
|
| 326 |
+
}),
|
| 327 |
+
));
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
pub fn track_initialize(
|
| 331 |
+
&self,
|
| 332 |
+
connection_id: u64,
|
| 333 |
+
params: InitializeParams,
|
| 334 |
+
product_client_id: String,
|
| 335 |
+
rpc_transport: AppServerRpcTransport,
|
| 336 |
+
) {
|
| 337 |
+
self.record_fact(AnalyticsFact::Initialize {
|
| 338 |
+
connection_id,
|
| 339 |
+
params,
|
| 340 |
+
product_client_id,
|
| 341 |
+
runtime: current_runtime_metadata(),
|
| 342 |
+
rpc_transport,
|
| 343 |
+
});
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
pub fn track_subagent_thread_started(&self, input: SubAgentThreadStartedInput) {
|
| 347 |
+
self.record_fact(AnalyticsFact::Custom(
|
| 348 |
+
CustomAnalyticsFact::SubAgentThreadStarted(input),
|
| 349 |
+
));
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
/// Logs Guardian turn and tool events that bypass the app-server listener.
|
| 353 |
+
pub fn track_guardian_session_event(&self, thread_id: ThreadId, event: &Event) {
|
| 354 |
+
if let Some(notification) = session_event_to_analytics_notification(thread_id, event) {
|
| 355 |
+
self.track_notification(¬ification);
|
| 356 |
+
}
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
pub fn track_collab_tool_call(
|
| 360 |
+
&self,
|
| 361 |
+
turn_id: String,
|
| 362 |
+
mut item: CollabAgentToolCallItem,
|
| 363 |
+
started_at_ms: i64,
|
| 364 |
+
completed_at_ms: i64,
|
| 365 |
+
) {
|
| 366 |
+
let thread_id = item.sender_thread_id.to_string();
|
| 367 |
+
let completed_item = TurnItem::CollabAgentToolCall(item.clone()).into();
|
| 368 |
+
item.status = CollabAgentToolCallStatus::InProgress;
|
| 369 |
+
|
| 370 |
+
self.track_notification(&ServerNotification::ItemStarted(ItemStartedNotification {
|
| 371 |
+
item: TurnItem::CollabAgentToolCall(item).into(),
|
| 372 |
+
thread_id: thread_id.clone(),
|
| 373 |
+
turn_id: turn_id.clone(),
|
| 374 |
+
started_at_ms,
|
| 375 |
+
}));
|
| 376 |
+
self.track_notification(&ServerNotification::ItemCompleted(
|
| 377 |
+
ItemCompletedNotification {
|
| 378 |
+
item: completed_item,
|
| 379 |
+
thread_id,
|
| 380 |
+
turn_id,
|
| 381 |
+
completed_at_ms,
|
| 382 |
+
},
|
| 383 |
+
));
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
pub fn track_code_mode_tool_call(&self, input: crate::facts::CodeModeToolCallFact) {
|
| 387 |
+
self.record_fact(AnalyticsFact::Custom(
|
| 388 |
+
CustomAnalyticsFact::CodeModeToolCall(input),
|
| 389 |
+
));
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
pub fn track_control_tool_call(&self, input: crate::facts::ControlToolCallFact) {
|
| 393 |
+
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::ControlToolCall(
|
| 394 |
+
input,
|
| 395 |
+
)));
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
pub fn track_guardian_review(
|
| 399 |
+
&self,
|
| 400 |
+
tracking: &GuardianReviewTrackContext,
|
| 401 |
+
result: GuardianReviewAnalyticsResult,
|
| 402 |
+
completed_at_ms: u64,
|
| 403 |
+
) {
|
| 404 |
+
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::GuardianReview(
|
| 405 |
+
Box::new(tracking.event_params(result, completed_at_ms)),
|
| 406 |
+
)));
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
pub fn track_app_mentioned(&self, tracking: TrackEventsContext, mentions: Vec<AppInvocation>) {
|
| 410 |
+
if mentions.is_empty() {
|
| 411 |
+
return;
|
| 412 |
+
}
|
| 413 |
+
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::AppMentioned(
|
| 414 |
+
AppMentionedInput { tracking, mentions },
|
| 415 |
+
)));
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
pub fn track_request(
|
| 419 |
+
&self,
|
| 420 |
+
connection_id: u64,
|
| 421 |
+
request_id: RequestId,
|
| 422 |
+
request: &ClientRequest,
|
| 423 |
+
) {
|
| 424 |
+
if let ClientRequest::TurnInterrupt { params, .. } = request {
|
| 425 |
+
if params.turn_id.is_empty() {
|
| 426 |
+
return;
|
| 427 |
+
}
|
| 428 |
+
self.record_fact(AnalyticsFact::ExplicitClientInterruptRequest {
|
| 429 |
+
connection_id,
|
| 430 |
+
request_id,
|
| 431 |
+
turn_id: params.turn_id.clone(),
|
| 432 |
+
requested_at_ms: now_unix_millis(),
|
| 433 |
+
});
|
| 434 |
+
return;
|
| 435 |
+
}
|
| 436 |
+
if !matches!(
|
| 437 |
+
request,
|
| 438 |
+
ClientRequest::TurnStart { .. } | ClientRequest::TurnSteer { .. }
|
| 439 |
+
) {
|
| 440 |
+
return;
|
| 441 |
+
}
|
| 442 |
+
self.record_fact(AnalyticsFact::ClientRequest {
|
| 443 |
+
connection_id,
|
| 444 |
+
request_id,
|
| 445 |
+
request: Box::new(request.clone()),
|
| 446 |
+
});
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
pub fn track_app_used(
|
| 450 |
+
&self,
|
| 451 |
+
tracking: TrackEventsContext,
|
| 452 |
+
app: AppInvocation,
|
| 453 |
+
elicitation_type: Option<ElicitationType>,
|
| 454 |
+
) {
|
| 455 |
+
let Some(queue) = self.queue.as_ref() else {
|
| 456 |
+
return;
|
| 457 |
+
};
|
| 458 |
+
if !queue.should_enqueue_app_used(&tracking, &app) {
|
| 459 |
+
return;
|
| 460 |
+
}
|
| 461 |
+
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::AppUsed(
|
| 462 |
+
AppUsedInput {
|
| 463 |
+
tracking,
|
| 464 |
+
app,
|
| 465 |
+
elicitation_type,
|
| 466 |
+
},
|
| 467 |
+
)));
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
pub fn track_mcp_tool_call_elicitation(&self, input: McpToolCallElicitation) {
|
| 471 |
+
self.record_fact(AnalyticsFact::Custom(
|
| 472 |
+
CustomAnalyticsFact::McpToolCallElicitation(input),
|
| 473 |
+
));
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
pub fn track_hook_run(&self, tracking: TrackEventsContext, hook: HookRunFact) {
|
| 477 |
+
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::HookRun(
|
| 478 |
+
HookRunInput { tracking, hook },
|
| 479 |
+
)));
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
pub fn track_plugin_used(&self, tracking: TrackEventsContext, plugin: PluginTelemetryMetadata) {
|
| 483 |
+
let Some(queue) = self.queue.as_ref() else {
|
| 484 |
+
return;
|
| 485 |
+
};
|
| 486 |
+
if !queue.should_enqueue_plugin_used(&tracking, &plugin) {
|
| 487 |
+
return;
|
| 488 |
+
}
|
| 489 |
+
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::PluginUsed(
|
| 490 |
+
crate::facts::PluginUsedInput { tracking, plugin },
|
| 491 |
+
)));
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
pub fn track_plugin_install_requested(
|
| 495 |
+
&self,
|
| 496 |
+
tracking: TrackEventsContext,
|
| 497 |
+
request: PluginInstallRequested,
|
| 498 |
+
) {
|
| 499 |
+
self.record_fact(AnalyticsFact::Custom(
|
| 500 |
+
CustomAnalyticsFact::PluginInstallRequested(PluginInstallRequestedInput {
|
| 501 |
+
tracking,
|
| 502 |
+
request,
|
| 503 |
+
}),
|
| 504 |
+
));
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
pub fn track_compaction(&self, event: crate::facts::CodexCompactionEvent) {
|
| 508 |
+
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::Compaction(
|
| 509 |
+
Box::new(event),
|
| 510 |
+
)));
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
pub fn track_guardian_v2_event(&self, event: GuardianV2Event) {
|
| 514 |
+
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::GuardianV2(
|
| 515 |
+
Box::new(event),
|
| 516 |
+
)));
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
pub fn track_goal_event(&self, event: CodexGoalEvent) {
|
| 520 |
+
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::Goal(Box::new(
|
| 521 |
+
event,
|
| 522 |
+
))));
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
pub fn track_thread_hint_status(&self, event: crate::thread_hint::ThreadHintStatusEvent) {
|
| 526 |
+
self.record_fact(AnalyticsFact::Custom(
|
| 527 |
+
CustomAnalyticsFact::ThreadHintStatus(Box::new(event)),
|
| 528 |
+
));
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
pub fn track_image_preparation(&self, fact: ImagePreparationFact) {
|
| 532 |
+
self.record_fact(AnalyticsFact::Custom(
|
| 533 |
+
CustomAnalyticsFact::ImagePreparation(Box::new(fact)),
|
| 534 |
+
));
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
pub fn track_turn_resolved_config(&self, fact: TurnResolvedConfigFact) {
|
| 538 |
+
self.record_fact(AnalyticsFact::Custom(
|
| 539 |
+
CustomAnalyticsFact::TurnResolvedConfig(Box::new(fact)),
|
| 540 |
+
));
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
pub fn track_turn_token_usage(&self, fact: TurnTokenUsageFact) {
|
| 544 |
+
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(
|
| 545 |
+
Box::new(fact),
|
| 546 |
+
)));
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
pub fn track_turn_profile(&self, fact: TurnProfileFact) {
|
| 550 |
+
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnProfile(
|
| 551 |
+
Box::new(fact),
|
| 552 |
+
)));
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
pub fn track_turn_codex_error(&self, fact: TurnCodexErrorFact) {
|
| 556 |
+
self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::TurnCodexError(
|
| 557 |
+
Box::new(fact),
|
| 558 |
+
)));
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) {
|
| 562 |
+
self.record_fact(AnalyticsFact::Custom(
|
| 563 |
+
CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput {
|
| 564 |
+
plugin,
|
| 565 |
+
state: PluginState::Installed,
|
| 566 |
+
}),
|
| 567 |
+
));
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
pub fn track_plugin_install_failed(
|
| 571 |
+
&self,
|
| 572 |
+
plugin: PluginTelemetryMetadata,
|
| 573 |
+
source: PluginInstallSource,
|
| 574 |
+
error_type: String,
|
| 575 |
+
sub_error_type: Option<String>,
|
| 576 |
+
) {
|
| 577 |
+
self.record_fact(AnalyticsFact::Custom(
|
| 578 |
+
CustomAnalyticsFact::PluginInstallFailed(PluginInstallFailedInput {
|
| 579 |
+
plugin,
|
| 580 |
+
source,
|
| 581 |
+
error_type,
|
| 582 |
+
sub_error_type,
|
| 583 |
+
}),
|
| 584 |
+
));
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
pub fn track_external_agent_config_import_completed(
|
| 588 |
+
&self,
|
| 589 |
+
input: ExternalAgentConfigImportCompletedInput,
|
| 590 |
+
) {
|
| 591 |
+
self.record_fact(AnalyticsFact::Custom(
|
| 592 |
+
CustomAnalyticsFact::ExternalAgentConfigImportCompleted(input),
|
| 593 |
+
));
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
pub fn track_external_agent_config_import_failure(
|
| 597 |
+
&self,
|
| 598 |
+
input: ExternalAgentConfigImportFailureInput,
|
| 599 |
+
) {
|
| 600 |
+
self.record_fact(AnalyticsFact::Custom(
|
| 601 |
+
CustomAnalyticsFact::ExternalAgentConfigImportFailure(input),
|
| 602 |
+
));
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
pub fn track_plugin_uninstalled(&self, plugin: PluginTelemetryMetadata) {
|
| 606 |
+
self.record_fact(AnalyticsFact::Custom(
|
| 607 |
+
CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput {
|
| 608 |
+
plugin,
|
| 609 |
+
state: PluginState::Uninstalled,
|
| 610 |
+
}),
|
| 611 |
+
));
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
pub fn track_plugin_enabled(&self, plugin: PluginTelemetryMetadata) {
|
| 615 |
+
self.record_fact(AnalyticsFact::Custom(
|
| 616 |
+
CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput {
|
| 617 |
+
plugin,
|
| 618 |
+
state: PluginState::Enabled,
|
| 619 |
+
}),
|
| 620 |
+
));
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
pub fn track_plugin_disabled(&self, plugin: PluginTelemetryMetadata) {
|
| 624 |
+
self.record_fact(AnalyticsFact::Custom(
|
| 625 |
+
CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput {
|
| 626 |
+
plugin,
|
| 627 |
+
state: PluginState::Disabled,
|
| 628 |
+
}),
|
| 629 |
+
));
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
pub(crate) fn record_fact(&self, input: AnalyticsFact) {
|
| 633 |
+
if let Some(queue) = self.queue.as_ref() {
|
| 634 |
+
queue.try_send(input);
|
| 635 |
+
}
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
pub fn track_response(
|
| 639 |
+
&self,
|
| 640 |
+
connection_id: u64,
|
| 641 |
+
request_id: RequestId,
|
| 642 |
+
response: &ClientResponsePayload,
|
| 643 |
+
) {
|
| 644 |
+
self.track_response_inner(
|
| 645 |
+
connection_id,
|
| 646 |
+
request_id,
|
| 647 |
+
response,
|
| 648 |
+
/*thread_originator*/ None,
|
| 649 |
+
);
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
pub fn track_response_with_thread_originator(
|
| 653 |
+
&self,
|
| 654 |
+
connection_id: u64,
|
| 655 |
+
request_id: RequestId,
|
| 656 |
+
response: &ClientResponsePayload,
|
| 657 |
+
thread_originator: String,
|
| 658 |
+
) {
|
| 659 |
+
self.track_response_inner(connection_id, request_id, response, Some(thread_originator));
|
| 660 |
+
}
|
| 661 |
+
|
| 662 |
+
fn track_response_inner(
|
| 663 |
+
&self,
|
| 664 |
+
connection_id: u64,
|
| 665 |
+
request_id: RequestId,
|
| 666 |
+
response: &ClientResponsePayload,
|
| 667 |
+
thread_originator: Option<String>,
|
| 668 |
+
) {
|
| 669 |
+
if !matches!(
|
| 670 |
+
response,
|
| 671 |
+
ClientResponsePayload::ThreadStart(_)
|
| 672 |
+
| ClientResponsePayload::ThreadResume(_)
|
| 673 |
+
| ClientResponsePayload::ThreadFork(_)
|
| 674 |
+
| ClientResponsePayload::TurnStart(_)
|
| 675 |
+
| ClientResponsePayload::TurnSteer(_)
|
| 676 |
+
| ClientResponsePayload::TurnInterrupt(_)
|
| 677 |
+
) {
|
| 678 |
+
return;
|
| 679 |
+
}
|
| 680 |
+
if serde_json::to_writer(std::io::sink(), response).is_err() {
|
| 681 |
+
return;
|
| 682 |
+
}
|
| 683 |
+
self.record_fact(AnalyticsFact::ClientResponse {
|
| 684 |
+
connection_id,
|
| 685 |
+
request_id,
|
| 686 |
+
response: Box::new(response.clone()),
|
| 687 |
+
thread_originator,
|
| 688 |
+
});
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
pub fn track_error_response(
|
| 692 |
+
&self,
|
| 693 |
+
connection_id: u64,
|
| 694 |
+
request_id: RequestId,
|
| 695 |
+
error: JSONRPCErrorError,
|
| 696 |
+
error_type: Option<AnalyticsJsonRpcError>,
|
| 697 |
+
) {
|
| 698 |
+
self.record_fact(AnalyticsFact::ErrorResponse {
|
| 699 |
+
connection_id,
|
| 700 |
+
request_id,
|
| 701 |
+
error,
|
| 702 |
+
error_type,
|
| 703 |
+
});
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
pub fn track_server_request(&self, connection_id: u64, request: ServerRequest) {
|
| 707 |
+
self.record_fact(AnalyticsFact::ServerRequest {
|
| 708 |
+
connection_id,
|
| 709 |
+
request: Box::new(request),
|
| 710 |
+
});
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
pub fn track_server_response(&self, completed_at_ms: u64, response: ServerResponse) {
|
| 714 |
+
self.record_fact(AnalyticsFact::ServerResponse {
|
| 715 |
+
completed_at_ms,
|
| 716 |
+
response: Box::new(response),
|
| 717 |
+
});
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
pub fn track_effective_permissions_approval_response(
|
| 721 |
+
&self,
|
| 722 |
+
completed_at_ms: u64,
|
| 723 |
+
request_id: RequestId,
|
| 724 |
+
response: RequestPermissionsResponse,
|
| 725 |
+
) {
|
| 726 |
+
self.record_fact(AnalyticsFact::EffectivePermissionsApprovalResponse {
|
| 727 |
+
completed_at_ms,
|
| 728 |
+
request_id,
|
| 729 |
+
response: Box::new(response),
|
| 730 |
+
});
|
| 731 |
+
}
|
| 732 |
+
|
| 733 |
+
pub fn track_server_request_aborted(&self, completed_at_ms: u64, request_id: RequestId) {
|
| 734 |
+
self.record_fact(AnalyticsFact::ServerRequestAborted {
|
| 735 |
+
completed_at_ms,
|
| 736 |
+
request_id,
|
| 737 |
+
});
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
/// Records analytics-relevant notifications without cloning ignored variants.
|
| 741 |
+
pub fn track_notification(&self, notification: &ServerNotification) {
|
| 742 |
+
if !matches!(
|
| 743 |
+
notification,
|
| 744 |
+
ServerNotification::ThreadArchived(_)
|
| 745 |
+
| ServerNotification::ThreadClosed(_)
|
| 746 |
+
| ServerNotification::ThreadUnarchived(_)
|
| 747 |
+
| ServerNotification::TurnStarted(_)
|
| 748 |
+
| ServerNotification::TurnCompleted(_)
|
| 749 |
+
| ServerNotification::TurnDiffUpdated(_)
|
| 750 |
+
| ServerNotification::ItemStarted(_)
|
| 751 |
+
| ServerNotification::ItemCompleted(_)
|
| 752 |
+
| ServerNotification::ItemGuardianApprovalReviewStarted(_)
|
| 753 |
+
| ServerNotification::ItemGuardianApprovalReviewCompleted(_)
|
| 754 |
+
) {
|
| 755 |
+
return;
|
| 756 |
+
}
|
| 757 |
+
self.record_fact(AnalyticsFact::Notification(Box::new(notification.clone())));
|
| 758 |
+
}
|
| 759 |
+
}
|
| 760 |
+
|
| 761 |
+
fn session_event_to_analytics_notification(
|
| 762 |
+
thread_id: ThreadId,
|
| 763 |
+
event: &Event,
|
| 764 |
+
) -> Option<ServerNotification> {
|
| 765 |
+
let notification = match &event.msg {
|
| 766 |
+
EventMsg::ItemStarted(started) => item_event_to_server_notification(
|
| 767 |
+
event.msg.clone(),
|
| 768 |
+
&started.thread_id.to_string(),
|
| 769 |
+
&started.turn_id,
|
| 770 |
+
),
|
| 771 |
+
EventMsg::ItemCompleted(completed) => item_event_to_server_notification(
|
| 772 |
+
event.msg.clone(),
|
| 773 |
+
&completed.thread_id.to_string(),
|
| 774 |
+
&completed.turn_id,
|
| 775 |
+
),
|
| 776 |
+
EventMsg::TurnStarted(started) => {
|
| 777 |
+
ServerNotification::TurnStarted(TurnStartedNotification {
|
| 778 |
+
thread_id: thread_id.to_string(),
|
| 779 |
+
turn: Turn {
|
| 780 |
+
started_at: started.started_at,
|
| 781 |
+
..analytics_turn(&started.turn_id, TurnStatus::InProgress)
|
| 782 |
+
},
|
| 783 |
+
})
|
| 784 |
+
}
|
| 785 |
+
EventMsg::TurnComplete(completed) => {
|
| 786 |
+
let error = completed.error.as_ref().map(|error| TurnError {
|
| 787 |
+
message: String::new(),
|
| 788 |
+
codex_error_info: error.codex_error_info.clone().map(Into::into),
|
| 789 |
+
additional_details: None,
|
| 790 |
+
misalignment: None,
|
| 791 |
+
});
|
| 792 |
+
let status = if error.is_some() {
|
| 793 |
+
TurnStatus::Failed
|
| 794 |
+
} else {
|
| 795 |
+
TurnStatus::Completed
|
| 796 |
+
};
|
| 797 |
+
ServerNotification::TurnCompleted(TurnCompletedNotification {
|
| 798 |
+
thread_id: thread_id.to_string(),
|
| 799 |
+
turn: Turn {
|
| 800 |
+
error,
|
| 801 |
+
started_at: completed.started_at,
|
| 802 |
+
completed_at: completed.completed_at,
|
| 803 |
+
duration_ms: completed.duration_ms,
|
| 804 |
+
..analytics_turn(&completed.turn_id, status)
|
| 805 |
+
},
|
| 806 |
+
})
|
| 807 |
+
}
|
| 808 |
+
EventMsg::TurnAborted(aborted) => {
|
| 809 |
+
ServerNotification::TurnCompleted(TurnCompletedNotification {
|
| 810 |
+
thread_id: thread_id.to_string(),
|
| 811 |
+
turn: Turn {
|
| 812 |
+
started_at: aborted.started_at,
|
| 813 |
+
completed_at: aborted.completed_at,
|
| 814 |
+
duration_ms: aborted.duration_ms,
|
| 815 |
+
..analytics_turn(
|
| 816 |
+
aborted.turn_id.as_deref().unwrap_or(&event.id),
|
| 817 |
+
TurnStatus::Interrupted,
|
| 818 |
+
)
|
| 819 |
+
},
|
| 820 |
+
})
|
| 821 |
+
}
|
| 822 |
+
// Legacy tool events accompany canonical items. Messages, reasoning, and review
|
| 823 |
+
// content must not enter the analytics queue.
|
| 824 |
+
_ => return None,
|
| 825 |
+
};
|
| 826 |
+
match ¬ification {
|
| 827 |
+
ServerNotification::ItemStarted(ItemStartedNotification { item, .. })
|
| 828 |
+
| ServerNotification::ItemCompleted(ItemCompletedNotification { item, .. }) => {
|
| 829 |
+
tracked_tool_item_id(item)?;
|
| 830 |
+
}
|
| 831 |
+
_ => {}
|
| 832 |
+
}
|
| 833 |
+
Some(notification)
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
+
fn analytics_turn(turn_id: &str, status: TurnStatus) -> Turn {
|
| 837 |
+
Turn {
|
| 838 |
+
id: turn_id.to_string(),
|
| 839 |
+
items: Vec::new(),
|
| 840 |
+
items_view: TurnItemsView::NotLoaded,
|
| 841 |
+
status,
|
| 842 |
+
error: None,
|
| 843 |
+
started_at: None,
|
| 844 |
+
completed_at: None,
|
| 845 |
+
duration_ms: None,
|
| 846 |
+
}
|
| 847 |
+
}
|
| 848 |
+
|
| 849 |
+
async fn send_track_events(
|
| 850 |
+
auth_manager: &AuthManager,
|
| 851 |
+
destination: &AnalyticsEventsDestination,
|
| 852 |
+
mut events: Vec<TrackEventRequest>,
|
| 853 |
+
) {
|
| 854 |
+
if events.is_empty() {
|
| 855 |
+
return;
|
| 856 |
+
}
|
| 857 |
+
|
| 858 |
+
let Some(auth) = auth_manager.auth().await else {
|
| 859 |
+
return;
|
| 860 |
+
};
|
| 861 |
+
if auth.is_api_key_auth() {
|
| 862 |
+
events.retain(TrackEventRequest::can_send_with_api_key_auth);
|
| 863 |
+
} else if !auth.uses_codex_backend() {
|
| 864 |
+
return;
|
| 865 |
+
}
|
| 866 |
+
if events.is_empty() {
|
| 867 |
+
return;
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
for events in track_event_request_batches(events) {
|
| 871 |
+
send_track_events_request(&auth, destination, events).await;
|
| 872 |
+
}
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
fn track_event_request_batches(events: Vec<TrackEventRequest>) -> Vec<Vec<TrackEventRequest>> {
|
| 876 |
+
let mut batches = Vec::new();
|
| 877 |
+
let mut current_batch = Vec::new();
|
| 878 |
+
|
| 879 |
+
for event in events {
|
| 880 |
+
if event.should_send_in_isolated_request() {
|
| 881 |
+
if !current_batch.is_empty() {
|
| 882 |
+
batches.push(current_batch);
|
| 883 |
+
current_batch = Vec::new();
|
| 884 |
+
}
|
| 885 |
+
batches.push(vec![event]);
|
| 886 |
+
} else {
|
| 887 |
+
current_batch.push(event);
|
| 888 |
+
}
|
| 889 |
+
}
|
| 890 |
+
|
| 891 |
+
if !current_batch.is_empty() {
|
| 892 |
+
batches.push(current_batch);
|
| 893 |
+
}
|
| 894 |
+
|
| 895 |
+
batches
|
| 896 |
+
}
|
| 897 |
+
|
| 898 |
+
async fn send_track_events_request(
|
| 899 |
+
auth: &CodexAuth,
|
| 900 |
+
destination: &AnalyticsEventsDestination,
|
| 901 |
+
events: Vec<TrackEventRequest>,
|
| 902 |
+
) {
|
| 903 |
+
if events.is_empty() {
|
| 904 |
+
return;
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
let payload = TrackEventsRequest { events };
|
| 908 |
+
|
| 909 |
+
#[cfg(debug_assertions)]
|
| 910 |
+
if capture_track_events_request(destination, &payload) {
|
| 911 |
+
return;
|
| 912 |
+
}
|
| 913 |
+
|
| 914 |
+
let url = match destination {
|
| 915 |
+
AnalyticsEventsDestination::Http { url } => url,
|
| 916 |
+
#[cfg(debug_assertions)]
|
| 917 |
+
AnalyticsEventsDestination::CaptureFile { .. } => return,
|
| 918 |
+
};
|
| 919 |
+
let response = create_client()
|
| 920 |
+
.post(url)
|
| 921 |
+
.timeout(ANALYTICS_EVENTS_TIMEOUT)
|
| 922 |
+
.headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers())
|
| 923 |
+
.header("Content-Type", "application/json")
|
| 924 |
+
.json(&payload)
|
| 925 |
+
.send()
|
| 926 |
+
.await;
|
| 927 |
+
|
| 928 |
+
match response {
|
| 929 |
+
Ok(response) if response.status().is_success() => {}
|
| 930 |
+
Ok(response) => {
|
| 931 |
+
let status = response.status();
|
| 932 |
+
let body = response.text().await.unwrap_or_default();
|
| 933 |
+
tracing::warn!("events failed with status {status}: {body}");
|
| 934 |
+
}
|
| 935 |
+
Err(err) => {
|
| 936 |
+
tracing::warn!("failed to send events request: {err}");
|
| 937 |
+
}
|
| 938 |
+
}
|
| 939 |
+
}
|
| 940 |
+
|
| 941 |
+
#[cfg(debug_assertions)]
|
| 942 |
+
fn capture_track_events_request(
|
| 943 |
+
destination: &AnalyticsEventsDestination,
|
| 944 |
+
payload: &TrackEventsRequest,
|
| 945 |
+
) -> bool {
|
| 946 |
+
let AnalyticsEventsDestination::CaptureFile { path } = destination else {
|
| 947 |
+
return false;
|
| 948 |
+
};
|
| 949 |
+
|
| 950 |
+
if let Err(err) = crate::analytics_capture::append_payload(path, payload) {
|
| 951 |
+
tracing::error!(
|
| 952 |
+
path = %path.display(),
|
| 953 |
+
"failed to capture analytics events; network delivery remains disabled: {err}"
|
| 954 |
+
);
|
| 955 |
+
}
|
| 956 |
+
true
|
| 957 |
+
}
|
| 958 |
+
|
| 959 |
+
#[cfg(test)]
|
| 960 |
+
#[path = "client_tests.rs"]
|
| 961 |
+
mod tests;
|
codex-rs/analytics/src/client_tests.rs
ADDED
|
@@ -0,0 +1,1020 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use super::AnalyticsEventsClient;
|
| 2 |
+
use super::AnalyticsEventsDestination;
|
| 3 |
+
use super::AnalyticsEventsQueue;
|
| 4 |
+
use super::AnalyticsEventsQueueMessage;
|
| 5 |
+
#[cfg(debug_assertions)]
|
| 6 |
+
use super::capture_track_events_request;
|
| 7 |
+
#[cfg(debug_assertions)]
|
| 8 |
+
use super::send_track_events;
|
| 9 |
+
#[cfg(debug_assertions)]
|
| 10 |
+
use super::send_track_events_request;
|
| 11 |
+
use super::track_event_request_batches;
|
| 12 |
+
#[cfg(debug_assertions)]
|
| 13 |
+
use crate::events::AppServerRpcTransport;
|
| 14 |
+
use crate::events::CodexAcceptedLineFingerprintsEventParams;
|
| 15 |
+
use crate::events::CodexAcceptedLineFingerprintsEventRequest;
|
| 16 |
+
#[cfg(debug_assertions)]
|
| 17 |
+
use crate::events::CodexAppServerClientMetadata;
|
| 18 |
+
#[cfg(debug_assertions)]
|
| 19 |
+
use crate::events::CodexMcpToolCallEventParams;
|
| 20 |
+
#[cfg(debug_assertions)]
|
| 21 |
+
use crate::events::CodexMcpToolCallEventRequest;
|
| 22 |
+
#[cfg(debug_assertions)]
|
| 23 |
+
use crate::events::CodexPluginMeasurementEventParams;
|
| 24 |
+
#[cfg(debug_assertions)]
|
| 25 |
+
use crate::events::CodexPluginMeasurementEventRequest;
|
| 26 |
+
#[cfg(debug_assertions)]
|
| 27 |
+
use crate::events::CodexPluginMetadata;
|
| 28 |
+
#[cfg(debug_assertions)]
|
| 29 |
+
use crate::events::CodexPluginUsedEventRequest;
|
| 30 |
+
#[cfg(debug_assertions)]
|
| 31 |
+
use crate::events::CodexPluginUsedMetadata;
|
| 32 |
+
#[cfg(debug_assertions)]
|
| 33 |
+
use crate::events::CodexRuntimeMetadata;
|
| 34 |
+
#[cfg(debug_assertions)]
|
| 35 |
+
use crate::events::CodexToolItemEventBase;
|
| 36 |
+
#[cfg(debug_assertions)]
|
| 37 |
+
use crate::events::FinalApprovalOutcome;
|
| 38 |
+
use crate::events::SkillInvocationEventParams;
|
| 39 |
+
use crate::events::SkillInvocationEventRequest;
|
| 40 |
+
#[cfg(debug_assertions)]
|
| 41 |
+
use crate::events::ThreadArchiveAction;
|
| 42 |
+
#[cfg(debug_assertions)]
|
| 43 |
+
use crate::events::ThreadArchiveEvent;
|
| 44 |
+
#[cfg(debug_assertions)]
|
| 45 |
+
use crate::events::ThreadArchiveEventParams;
|
| 46 |
+
#[cfg(debug_assertions)]
|
| 47 |
+
use crate::events::ToolItemTerminalStatus;
|
| 48 |
+
use crate::events::TrackEventRequest;
|
| 49 |
+
#[cfg(debug_assertions)]
|
| 50 |
+
use crate::events::codex_artifact_operation_event_request;
|
| 51 |
+
use crate::facts::AnalyticsFact;
|
| 52 |
+
use crate::facts::AppInvocation;
|
| 53 |
+
#[cfg(debug_assertions)]
|
| 54 |
+
use crate::facts::ArtifactOperation;
|
| 55 |
+
#[cfg(debug_assertions)]
|
| 56 |
+
use crate::facts::ArtifactOperationLifecycle;
|
| 57 |
+
use crate::facts::CustomAnalyticsFact;
|
| 58 |
+
use crate::facts::ElicitationType;
|
| 59 |
+
use crate::facts::InvocationType;
|
| 60 |
+
use crate::facts::PluginMeasurementRow;
|
| 61 |
+
use crate::facts::PluginMeasurementsInput;
|
| 62 |
+
use crate::facts::TrackEventsContext;
|
| 63 |
+
use crate::reducer::MAX_PLUGIN_MEASUREMENTS_PER_BATCH;
|
| 64 |
+
use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer;
|
| 65 |
+
use codex_app_server_protocol::AskForApproval as AppServerAskForApproval;
|
| 66 |
+
use codex_app_server_protocol::ClientRequest;
|
| 67 |
+
use codex_app_server_protocol::ClientResponsePayload;
|
| 68 |
+
use codex_app_server_protocol::CommandExecutionOutputDeltaNotification;
|
| 69 |
+
use codex_app_server_protocol::RequestId;
|
| 70 |
+
use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy;
|
| 71 |
+
use codex_app_server_protocol::ServerNotification;
|
| 72 |
+
use codex_app_server_protocol::SessionSource as AppServerSessionSource;
|
| 73 |
+
use codex_app_server_protocol::Thread;
|
| 74 |
+
use codex_app_server_protocol::ThreadArchiveParams;
|
| 75 |
+
use codex_app_server_protocol::ThreadArchiveResponse;
|
| 76 |
+
use codex_app_server_protocol::ThreadArchivedNotification;
|
| 77 |
+
use codex_app_server_protocol::ThreadForkResponse;
|
| 78 |
+
use codex_app_server_protocol::ThreadResumeResponse;
|
| 79 |
+
use codex_app_server_protocol::ThreadStartResponse;
|
| 80 |
+
use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus;
|
| 81 |
+
use codex_app_server_protocol::Turn;
|
| 82 |
+
use codex_app_server_protocol::TurnDiffUpdatedNotification;
|
| 83 |
+
use codex_app_server_protocol::TurnInterruptParams;
|
| 84 |
+
use codex_app_server_protocol::TurnInterruptResponse;
|
| 85 |
+
use codex_app_server_protocol::TurnStartParams;
|
| 86 |
+
use codex_app_server_protocol::TurnStartResponse;
|
| 87 |
+
use codex_app_server_protocol::TurnStatus as AppServerTurnStatus;
|
| 88 |
+
use codex_app_server_protocol::TurnSteerParams;
|
| 89 |
+
use codex_app_server_protocol::TurnSteerResponse;
|
| 90 |
+
#[cfg(debug_assertions)]
|
| 91 |
+
use codex_login::AuthManager;
|
| 92 |
+
use codex_utils_absolute_path::test_support::PathBufExt;
|
| 93 |
+
use codex_utils_absolute_path::test_support::test_path_buf;
|
| 94 |
+
use pretty_assertions::assert_eq;
|
| 95 |
+
use std::collections::BTreeMap;
|
| 96 |
+
use std::collections::HashSet;
|
| 97 |
+
#[cfg(debug_assertions)]
|
| 98 |
+
use std::fs;
|
| 99 |
+
#[cfg(debug_assertions)]
|
| 100 |
+
use std::path::PathBuf;
|
| 101 |
+
use std::sync::Arc;
|
| 102 |
+
use std::sync::Mutex;
|
| 103 |
+
#[cfg(debug_assertions)]
|
| 104 |
+
use std::time::SystemTime;
|
| 105 |
+
use tokio::sync::mpsc;
|
| 106 |
+
use tokio::sync::mpsc::error::TryRecvError;
|
| 107 |
+
|
| 108 |
+
#[cfg(debug_assertions)]
|
| 109 |
+
impl AnalyticsEventsClient {
|
| 110 |
+
pub(crate) fn new_for_capture_file(auth_manager: Arc<AuthManager>, path: PathBuf) -> Self {
|
| 111 |
+
Self {
|
| 112 |
+
queue: Some(AnalyticsEventsQueue::new(
|
| 113 |
+
auth_manager,
|
| 114 |
+
AnalyticsEventsDestination::CaptureFile { path },
|
| 115 |
+
)),
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
fn sample_accepted_line_fingerprint_event(thread_id: &str) -> TrackEventRequest {
|
| 121 |
+
TrackEventRequest::AcceptedLineFingerprints(Box::new(
|
| 122 |
+
CodexAcceptedLineFingerprintsEventRequest {
|
| 123 |
+
event_type: "codex_accepted_line_fingerprints",
|
| 124 |
+
event_params: CodexAcceptedLineFingerprintsEventParams {
|
| 125 |
+
event_type: "codex.accepted_line_fingerprints",
|
| 126 |
+
turn_id: "turn-1".to_string(),
|
| 127 |
+
thread_id: thread_id.to_string(),
|
| 128 |
+
product_surface: Some("codex".to_string()),
|
| 129 |
+
model_slug: Some("gpt-5.1-codex".to_string()),
|
| 130 |
+
completed_at: 1,
|
| 131 |
+
repo_hash: None,
|
| 132 |
+
accepted_added_lines: 1,
|
| 133 |
+
accepted_deleted_lines: 0,
|
| 134 |
+
line_fingerprints: [],
|
| 135 |
+
},
|
| 136 |
+
},
|
| 137 |
+
))
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
fn sample_skill_track_event(thread_id: &str, plugin_id: Option<&str>) -> TrackEventRequest {
|
| 141 |
+
TrackEventRequest::SkillInvocation(SkillInvocationEventRequest {
|
| 142 |
+
event_type: "skill_invocation",
|
| 143 |
+
skill_id: format!("skill-{thread_id}"),
|
| 144 |
+
skill_name: "doc".to_string(),
|
| 145 |
+
event_params: SkillInvocationEventParams {
|
| 146 |
+
product_client_id: None,
|
| 147 |
+
skill_scope: None,
|
| 148 |
+
plugin_id: plugin_id.map(str::to_string),
|
| 149 |
+
remote_plugin_id: None,
|
| 150 |
+
thread_id: Some(thread_id.to_string()),
|
| 151 |
+
turn_id: Some("turn-1".to_string()),
|
| 152 |
+
invoke_type: Some(InvocationType::Explicit),
|
| 153 |
+
model_slug: Some("gpt-5.1-codex".to_string()),
|
| 154 |
+
},
|
| 155 |
+
})
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
#[cfg(debug_assertions)]
|
| 159 |
+
fn sample_artifact_operation_event(thread_id: &str) -> TrackEventRequest {
|
| 160 |
+
TrackEventRequest::ArtifactOperation(codex_artifact_operation_event_request(
|
| 161 |
+
TrackEventsContext {
|
| 162 |
+
model_slug: "gpt-5.1-codex".to_string(),
|
| 163 |
+
thread_id: thread_id.to_string(),
|
| 164 |
+
turn_id: "turn-1".to_string(),
|
| 165 |
+
product_client_id: "codex_desktop".to_string(),
|
| 166 |
+
},
|
| 167 |
+
ArtifactOperation {
|
| 168 |
+
item_id: format!("item-{thread_id}"),
|
| 169 |
+
lifecycle: ArtifactOperationLifecycle::Started,
|
| 170 |
+
occurred_at_ms: 1,
|
| 171 |
+
plugin_id: "presentations@openai-primary-runtime".to_string(),
|
| 172 |
+
script_path: "skills/presentations/container_tools/mark_artifact_operation_started.mjs"
|
| 173 |
+
.to_string(),
|
| 174 |
+
skill: "presentations".to_string(),
|
| 175 |
+
artifact_type: "presentation".to_string(),
|
| 176 |
+
operation_kind: "create".to_string(),
|
| 177 |
+
expected_output_count: 1,
|
| 178 |
+
output_format: "pptx".to_string(),
|
| 179 |
+
execution_backend: "unified_exec".to_string(),
|
| 180 |
+
},
|
| 181 |
+
))
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
fn sample_regular_track_event(thread_id: &str) -> TrackEventRequest {
|
| 185 |
+
sample_skill_track_event(thread_id, /*plugin_id*/ None)
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
#[cfg(debug_assertions)]
|
| 189 |
+
fn sample_mcp_tool_call_event(thread_id: &str, plugin_id: Option<&str>) -> TrackEventRequest {
|
| 190 |
+
TrackEventRequest::McpToolCall(CodexMcpToolCallEventRequest {
|
| 191 |
+
event_type: "codex_mcp_tool_call_event",
|
| 192 |
+
event_params: CodexMcpToolCallEventParams {
|
| 193 |
+
base: CodexToolItemEventBase {
|
| 194 |
+
thread_id: thread_id.to_string(),
|
| 195 |
+
session_id: format!("session-{thread_id}"),
|
| 196 |
+
turn_id: "turn-1".to_string(),
|
| 197 |
+
root_turn_id: None,
|
| 198 |
+
item_id: format!("item-{thread_id}"),
|
| 199 |
+
cell_id: None,
|
| 200 |
+
parent_call_id: None,
|
| 201 |
+
originating_response_id: None,
|
| 202 |
+
subsequent_response_id: None,
|
| 203 |
+
app_server_client: CodexAppServerClientMetadata {
|
| 204 |
+
product_client_id: "codex_desktop".to_string(),
|
| 205 |
+
client_name: None,
|
| 206 |
+
client_version: None,
|
| 207 |
+
rpc_transport: AppServerRpcTransport::InProcess,
|
| 208 |
+
experimental_api_enabled: None,
|
| 209 |
+
},
|
| 210 |
+
runtime: CodexRuntimeMetadata {
|
| 211 |
+
codex_rs_version: "0.0.0".to_string(),
|
| 212 |
+
runtime_os: "test".to_string(),
|
| 213 |
+
runtime_os_version: "test".to_string(),
|
| 214 |
+
runtime_arch: "test".to_string(),
|
| 215 |
+
},
|
| 216 |
+
thread_source: None,
|
| 217 |
+
subagent_source: None,
|
| 218 |
+
parent_thread_id: None,
|
| 219 |
+
tool_name: "search".to_string(),
|
| 220 |
+
tool_event_type: None,
|
| 221 |
+
started_at_ms: 1,
|
| 222 |
+
completed_at_ms: 2,
|
| 223 |
+
duration_ms: Some(1),
|
| 224 |
+
execution_duration_ms: Some(1),
|
| 225 |
+
review_count: 0,
|
| 226 |
+
guardian_review_count: 0,
|
| 227 |
+
user_review_count: 0,
|
| 228 |
+
final_approval_outcome: FinalApprovalOutcome::NotNeeded,
|
| 229 |
+
terminal_status: ToolItemTerminalStatus::Completed,
|
| 230 |
+
failure_kind: None,
|
| 231 |
+
requested_additional_permissions: false,
|
| 232 |
+
requested_network_access: false,
|
| 233 |
+
},
|
| 234 |
+
mcp_server_name: "sample".to_string(),
|
| 235 |
+
mcp_tool_name: "search".to_string(),
|
| 236 |
+
mcp_error_present: false,
|
| 237 |
+
plugin_id: plugin_id.map(str::to_string),
|
| 238 |
+
connector_id: None,
|
| 239 |
+
elicitation_type: None,
|
| 240 |
+
},
|
| 241 |
+
})
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
#[cfg(debug_assertions)]
|
| 245 |
+
fn sample_plugin_used_track_event(thread_id: &str, plugin_id: Option<&str>) -> TrackEventRequest {
|
| 246 |
+
TrackEventRequest::PluginUsed(CodexPluginUsedEventRequest {
|
| 247 |
+
event_type: "codex_plugin_used",
|
| 248 |
+
event_params: CodexPluginUsedMetadata {
|
| 249 |
+
plugin: CodexPluginMetadata {
|
| 250 |
+
plugin_id: plugin_id.map(str::to_string),
|
| 251 |
+
remote_plugin_id: None,
|
| 252 |
+
plugin_name: Some("sample".to_string()),
|
| 253 |
+
marketplace_name: Some("test".to_string()),
|
| 254 |
+
has_skills: Some(true),
|
| 255 |
+
mcp_server_count: Some(1),
|
| 256 |
+
connector_ids: Some(vec!["calendar".to_string()]),
|
| 257 |
+
product_client_id: Some("codex_desktop".to_string()),
|
| 258 |
+
},
|
| 259 |
+
mcp_server_names: Some(vec!["mcp-1".to_string()]),
|
| 260 |
+
thread_id: Some(thread_id.to_string()),
|
| 261 |
+
turn_id: Some("turn-1".to_string()),
|
| 262 |
+
model_slug: Some("gpt-5.1-codex".to_string()),
|
| 263 |
+
},
|
| 264 |
+
})
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
#[cfg(debug_assertions)]
|
| 268 |
+
fn unique_capture_path(name: &str) -> PathBuf {
|
| 269 |
+
let nonce = SystemTime::now()
|
| 270 |
+
.duration_since(SystemTime::UNIX_EPOCH)
|
| 271 |
+
.expect("system clock should be after Unix epoch")
|
| 272 |
+
.as_nanos();
|
| 273 |
+
std::env::temp_dir().join(format!(
|
| 274 |
+
"codex-analytics-{name}-{}-{nonce}.jsonl",
|
| 275 |
+
std::process::id()
|
| 276 |
+
))
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
fn client_with_receiver() -> (
|
| 280 |
+
AnalyticsEventsClient,
|
| 281 |
+
mpsc::Receiver<AnalyticsEventsQueueMessage>,
|
| 282 |
+
) {
|
| 283 |
+
let (sender, receiver) = mpsc::channel(8);
|
| 284 |
+
let queue = AnalyticsEventsQueue {
|
| 285 |
+
sender,
|
| 286 |
+
app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
|
| 287 |
+
plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
|
| 288 |
+
};
|
| 289 |
+
(AnalyticsEventsClient { queue: Some(queue) }, receiver)
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
#[test]
|
| 293 |
+
#[cfg(debug_assertions)]
|
| 294 |
+
fn analytics_destination_uses_explicit_capture_file() {
|
| 295 |
+
let capture_path = unique_capture_path("destination");
|
| 296 |
+
let destination = AnalyticsEventsDestination::from_base_url_and_capture_file(
|
| 297 |
+
"https://chatgpt.com/backend-api/".to_string(),
|
| 298 |
+
Some(capture_path.clone()),
|
| 299 |
+
);
|
| 300 |
+
|
| 301 |
+
assert_eq!(
|
| 302 |
+
destination,
|
| 303 |
+
AnalyticsEventsDestination::CaptureFile {
|
| 304 |
+
path: capture_path.clone()
|
| 305 |
+
}
|
| 306 |
+
);
|
| 307 |
+
assert_eq!(
|
| 308 |
+
fs::read_to_string(&capture_path).expect("read capture file"),
|
| 309 |
+
""
|
| 310 |
+
);
|
| 311 |
+
#[cfg(unix)]
|
| 312 |
+
{
|
| 313 |
+
use std::os::unix::fs::PermissionsExt;
|
| 314 |
+
|
| 315 |
+
let mode = fs::metadata(&capture_path)
|
| 316 |
+
.expect("read capture file metadata")
|
| 317 |
+
.permissions()
|
| 318 |
+
.mode();
|
| 319 |
+
assert_eq!(mode & 0o777, 0o600);
|
| 320 |
+
}
|
| 321 |
+
fs::remove_file(capture_path).expect("remove capture file");
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
#[test]
|
| 325 |
+
fn analytics_destination_uses_http_without_capture_file() {
|
| 326 |
+
let destination = AnalyticsEventsDestination::from_base_url_and_capture_file(
|
| 327 |
+
"https://chatgpt.com/backend-api/".to_string(),
|
| 328 |
+
/*capture_file*/ None,
|
| 329 |
+
);
|
| 330 |
+
|
| 331 |
+
assert_eq!(
|
| 332 |
+
destination,
|
| 333 |
+
AnalyticsEventsDestination::Http {
|
| 334 |
+
url: "https://chatgpt.com/backend-api/codex/analytics-events/events".to_string()
|
| 335 |
+
}
|
| 336 |
+
);
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
#[test]
|
| 340 |
+
#[cfg(not(debug_assertions))]
|
| 341 |
+
fn analytics_destination_ignores_capture_file_in_release() {
|
| 342 |
+
let destination = AnalyticsEventsDestination::from_base_url_and_capture_file(
|
| 343 |
+
"https://chatgpt.com/backend-api/".to_string(),
|
| 344 |
+
Some(std::path::PathBuf::from("ignored.jsonl")),
|
| 345 |
+
);
|
| 346 |
+
|
| 347 |
+
assert_eq!(
|
| 348 |
+
destination,
|
| 349 |
+
AnalyticsEventsDestination::Http {
|
| 350 |
+
url: "https://chatgpt.com/backend-api/codex/analytics-events/events".to_string()
|
| 351 |
+
}
|
| 352 |
+
);
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
#[tokio::test]
|
| 356 |
+
#[cfg(debug_assertions)]
|
| 357 |
+
async fn capture_file_writes_exact_serialized_request() {
|
| 358 |
+
let capture_path = unique_capture_path("single");
|
| 359 |
+
let destination = AnalyticsEventsDestination::CaptureFile {
|
| 360 |
+
path: capture_path.clone(),
|
| 361 |
+
};
|
| 362 |
+
let event = sample_regular_track_event("thread-1");
|
| 363 |
+
let expected_event = serde_json::to_value(&event).expect("serialize expected event");
|
| 364 |
+
let auth = codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
| 365 |
+
|
| 366 |
+
send_track_events_request(&auth, &destination, vec![event]).await;
|
| 367 |
+
|
| 368 |
+
let contents = fs::read_to_string(&capture_path).expect("read capture file");
|
| 369 |
+
let lines = contents.lines().collect::<Vec<_>>();
|
| 370 |
+
assert_eq!(lines.len(), 1);
|
| 371 |
+
let payload: serde_json::Value =
|
| 372 |
+
serde_json::from_str(lines[0]).expect("parse captured payload");
|
| 373 |
+
assert_eq!(payload, serde_json::json!({"events": [expected_event]}));
|
| 374 |
+
|
| 375 |
+
fs::remove_file(capture_path).expect("remove capture file");
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
#[tokio::test]
|
| 379 |
+
#[cfg(debug_assertions)]
|
| 380 |
+
async fn capture_file_writes_final_batches_as_separate_lines() {
|
| 381 |
+
let capture_path = unique_capture_path("batches");
|
| 382 |
+
let destination = AnalyticsEventsDestination::CaptureFile {
|
| 383 |
+
path: capture_path.clone(),
|
| 384 |
+
};
|
| 385 |
+
let auth = codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
| 386 |
+
let events = vec![
|
| 387 |
+
sample_regular_track_event("thread-1"),
|
| 388 |
+
sample_accepted_line_fingerprint_event("thread-2"),
|
| 389 |
+
sample_regular_track_event("thread-3"),
|
| 390 |
+
];
|
| 391 |
+
|
| 392 |
+
for batch in track_event_request_batches(events) {
|
| 393 |
+
send_track_events_request(&auth, &destination, batch).await;
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
let contents = fs::read_to_string(&capture_path).expect("read capture file");
|
| 397 |
+
let payloads = contents
|
| 398 |
+
.lines()
|
| 399 |
+
.map(|line| serde_json::from_str::<serde_json::Value>(line).expect("parse capture line"))
|
| 400 |
+
.collect::<Vec<_>>();
|
| 401 |
+
assert_eq!(payloads.len(), 3);
|
| 402 |
+
assert_eq!(payloads[0]["events"][0]["skill_id"], "skill-thread-1");
|
| 403 |
+
assert_eq!(
|
| 404 |
+
payloads[1]["events"][0]["event_type"],
|
| 405 |
+
"codex_accepted_line_fingerprints"
|
| 406 |
+
);
|
| 407 |
+
assert_eq!(payloads[2]["events"][0]["skill_id"], "skill-thread-3");
|
| 408 |
+
|
| 409 |
+
fs::remove_file(capture_path).expect("remove capture file");
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
#[tokio::test]
|
| 413 |
+
#[cfg(debug_assertions)]
|
| 414 |
+
async fn api_key_auth_sends_only_plugin_events_to_codex_backend() {
|
| 415 |
+
let capture_path = unique_capture_path("api-key-plugin-events");
|
| 416 |
+
let destination = AnalyticsEventsDestination::CaptureFile {
|
| 417 |
+
path: capture_path.clone(),
|
| 418 |
+
};
|
| 419 |
+
let auth_manager = codex_login::AuthManager::from_auth_for_testing(
|
| 420 |
+
codex_login::CodexAuth::from_api_key("sk-test"),
|
| 421 |
+
);
|
| 422 |
+
let plugin_measurement = |thread_id: &str, plugin_id: &str| {
|
| 423 |
+
TrackEventRequest::PluginMeasurement(CodexPluginMeasurementEventRequest {
|
| 424 |
+
event_type: "codex_plugin_measurement_event",
|
| 425 |
+
event_params: CodexPluginMeasurementEventParams {
|
| 426 |
+
model_slug: None,
|
| 427 |
+
reasoning_effort: None,
|
| 428 |
+
thread_id: thread_id.to_string(),
|
| 429 |
+
turn_id: "turn-1".to_string(),
|
| 430 |
+
item_id: "item-1".to_string(),
|
| 431 |
+
originator: "codex_cli_rs".to_string(),
|
| 432 |
+
plugin_id: plugin_id.to_string(),
|
| 433 |
+
execution_id: "execution-1".to_string(),
|
| 434 |
+
operation: "security_scan".to_string(),
|
| 435 |
+
measurement_name: "findings".to_string(),
|
| 436 |
+
number_value: 1.0,
|
| 437 |
+
dimensions: None,
|
| 438 |
+
},
|
| 439 |
+
})
|
| 440 |
+
};
|
| 441 |
+
|
| 442 |
+
send_track_events(
|
| 443 |
+
&auth_manager,
|
| 444 |
+
&destination,
|
| 445 |
+
vec![
|
| 446 |
+
sample_regular_track_event("non-plugin-skill"),
|
| 447 |
+
sample_mcp_tool_call_event("non-plugin-mcp", /*plugin_id*/ None),
|
| 448 |
+
sample_plugin_used_track_event("non-plugin-used", /*plugin_id*/ None),
|
| 449 |
+
plugin_measurement("non-plugin-measurement", /*plugin_id*/ ""),
|
| 450 |
+
sample_accepted_line_fingerprint_event("other-event"),
|
| 451 |
+
TrackEventRequest::ThreadArchive(ThreadArchiveEvent {
|
| 452 |
+
event_type: "codex_thread_archive_event",
|
| 453 |
+
event_params: ThreadArchiveEventParams {
|
| 454 |
+
thread_id: "non-plugin-thread-archive".to_string(),
|
| 455 |
+
action: ThreadArchiveAction::Archived,
|
| 456 |
+
occurred_at_ms: 1,
|
| 457 |
+
app_server_client: None,
|
| 458 |
+
runtime: None,
|
| 459 |
+
thread_source: None,
|
| 460 |
+
parent_thread_id: None,
|
| 461 |
+
},
|
| 462 |
+
}),
|
| 463 |
+
sample_plugin_used_track_event("plugin-used", Some("sample@test")),
|
| 464 |
+
sample_skill_track_event("plugin-skill", Some("sample@test")),
|
| 465 |
+
sample_mcp_tool_call_event("plugin-mcp", Some("sample@test")),
|
| 466 |
+
sample_artifact_operation_event("plugin-artifact"),
|
| 467 |
+
plugin_measurement("plugin-measurement", "sample@test"),
|
| 468 |
+
],
|
| 469 |
+
)
|
| 470 |
+
.await;
|
| 471 |
+
|
| 472 |
+
let contents = fs::read_to_string(&capture_path).expect("read capture file");
|
| 473 |
+
let lines = contents.lines().collect::<Vec<_>>();
|
| 474 |
+
assert_eq!(lines.len(), 1);
|
| 475 |
+
let payload: serde_json::Value =
|
| 476 |
+
serde_json::from_str(lines[0]).expect("parse captured payload");
|
| 477 |
+
let events = payload["events"].as_array().expect("events array");
|
| 478 |
+
for event in events {
|
| 479 |
+
let event_params = event["event_params"].as_object().expect("event params");
|
| 480 |
+
for server_owned_field in [
|
| 481 |
+
"auth_mode",
|
| 482 |
+
"api_organization_id",
|
| 483 |
+
"api_project_id",
|
| 484 |
+
"api_key_tracking_id",
|
| 485 |
+
] {
|
| 486 |
+
assert!(!event_params.contains_key(server_owned_field));
|
| 487 |
+
}
|
| 488 |
+
}
|
| 489 |
+
let delivered_events = events
|
| 490 |
+
.iter()
|
| 491 |
+
.map(|event| {
|
| 492 |
+
serde_json::json!({
|
| 493 |
+
"event_type": event["event_type"],
|
| 494 |
+
"plugin_id": event["event_params"]["plugin_id"],
|
| 495 |
+
"thread_id": event["event_params"]["thread_id"],
|
| 496 |
+
})
|
| 497 |
+
})
|
| 498 |
+
.collect::<Vec<_>>();
|
| 499 |
+
assert_eq!(
|
| 500 |
+
delivered_events,
|
| 501 |
+
vec![
|
| 502 |
+
serde_json::json!({
|
| 503 |
+
"event_type": "codex_plugin_used",
|
| 504 |
+
"plugin_id": "sample@test",
|
| 505 |
+
"thread_id": "plugin-used",
|
| 506 |
+
}),
|
| 507 |
+
serde_json::json!({
|
| 508 |
+
"event_type": "skill_invocation",
|
| 509 |
+
"plugin_id": "sample@test",
|
| 510 |
+
"thread_id": "plugin-skill",
|
| 511 |
+
}),
|
| 512 |
+
serde_json::json!({
|
| 513 |
+
"event_type": "codex_mcp_tool_call_event",
|
| 514 |
+
"plugin_id": "sample@test",
|
| 515 |
+
"thread_id": "plugin-mcp",
|
| 516 |
+
}),
|
| 517 |
+
serde_json::json!({
|
| 518 |
+
"event_type": "codex_artifact_operation",
|
| 519 |
+
"plugin_id": "presentations@openai-primary-runtime",
|
| 520 |
+
"thread_id": "plugin-artifact",
|
| 521 |
+
}),
|
| 522 |
+
serde_json::json!({
|
| 523 |
+
"event_type": "codex_plugin_measurement_event",
|
| 524 |
+
"plugin_id": "sample@test",
|
| 525 |
+
"thread_id": "plugin-measurement",
|
| 526 |
+
}),
|
| 527 |
+
]
|
| 528 |
+
);
|
| 529 |
+
|
| 530 |
+
fs::remove_file(capture_path).expect("remove capture file");
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
#[test]
|
| 534 |
+
#[cfg(debug_assertions)]
|
| 535 |
+
fn capture_write_failure_still_consumes_delivery() {
|
| 536 |
+
let capture_path = unique_capture_path("missing-parent").join("events.jsonl");
|
| 537 |
+
let destination = AnalyticsEventsDestination::CaptureFile { path: capture_path };
|
| 538 |
+
let payload = crate::events::TrackEventsRequest {
|
| 539 |
+
events: vec![sample_regular_track_event("thread-1")],
|
| 540 |
+
};
|
| 541 |
+
|
| 542 |
+
assert!(capture_track_events_request(&destination, &payload));
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
fn sample_turn_start_request() -> ClientRequest {
|
| 546 |
+
ClientRequest::TurnStart {
|
| 547 |
+
request_id: RequestId::Integer(1),
|
| 548 |
+
params: TurnStartParams {
|
| 549 |
+
thread_id: "thread-1".to_string(),
|
| 550 |
+
client_user_message_id: None,
|
| 551 |
+
input: Vec::new(),
|
| 552 |
+
..Default::default()
|
| 553 |
+
},
|
| 554 |
+
}
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
fn sample_turn_steer_request() -> ClientRequest {
|
| 558 |
+
ClientRequest::TurnSteer {
|
| 559 |
+
request_id: RequestId::Integer(2),
|
| 560 |
+
params: TurnSteerParams {
|
| 561 |
+
thread_id: "thread-1".to_string(),
|
| 562 |
+
expected_turn_id: "turn-1".to_string(),
|
| 563 |
+
client_user_message_id: None,
|
| 564 |
+
input: Vec::new(),
|
| 565 |
+
responsesapi_client_metadata: None,
|
| 566 |
+
additional_context: None,
|
| 567 |
+
},
|
| 568 |
+
}
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
fn sample_turn_interrupt_request(turn_id: &str) -> ClientRequest {
|
| 572 |
+
ClientRequest::TurnInterrupt {
|
| 573 |
+
request_id: RequestId::Integer(3),
|
| 574 |
+
params: TurnInterruptParams {
|
| 575 |
+
thread_id: "thread-1".to_string(),
|
| 576 |
+
turn_id: turn_id.to_string(),
|
| 577 |
+
},
|
| 578 |
+
}
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
fn sample_turn_interrupt_response() -> ClientResponsePayload {
|
| 582 |
+
ClientResponsePayload::TurnInterrupt(TurnInterruptResponse {})
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
fn sample_thread_archive_request() -> ClientRequest {
|
| 586 |
+
ClientRequest::ThreadArchive {
|
| 587 |
+
request_id: RequestId::Integer(3),
|
| 588 |
+
params: ThreadArchiveParams {
|
| 589 |
+
thread_id: "thread-1".to_string(),
|
| 590 |
+
},
|
| 591 |
+
}
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
fn sample_thread(thread_id: &str) -> Thread {
|
| 595 |
+
Thread {
|
| 596 |
+
originator: None,
|
| 597 |
+
environments: None,
|
| 598 |
+
id: thread_id.to_string(),
|
| 599 |
+
extra: None,
|
| 600 |
+
session_id: format!("session-{thread_id}"),
|
| 601 |
+
forked_from_id: None,
|
| 602 |
+
parent_thread_id: None,
|
| 603 |
+
preview: "first prompt".to_string(),
|
| 604 |
+
ephemeral: false,
|
| 605 |
+
section: None,
|
| 606 |
+
section_entered_at: None,
|
| 607 |
+
project_id: None,
|
| 608 |
+
daybreak_enabled: None,
|
| 609 |
+
history_mode: Default::default(),
|
| 610 |
+
model_provider: "openai".to_string(),
|
| 611 |
+
model: None,
|
| 612 |
+
reasoning_effort: None,
|
| 613 |
+
created_at: 1,
|
| 614 |
+
updated_at: 2,
|
| 615 |
+
recency_at: Some(2),
|
| 616 |
+
status: AppServerThreadStatus::Idle,
|
| 617 |
+
path: None,
|
| 618 |
+
cwd: test_path_buf("/tmp").abs(),
|
| 619 |
+
cli_version: "0.0.0".to_string(),
|
| 620 |
+
source: AppServerSessionSource::Exec,
|
| 621 |
+
can_accept_direct_input: None,
|
| 622 |
+
thread_source: None,
|
| 623 |
+
agent_nickname: None,
|
| 624 |
+
agent_role: None,
|
| 625 |
+
git_info: None,
|
| 626 |
+
name: None,
|
| 627 |
+
turns: Vec::new(),
|
| 628 |
+
}
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
fn sample_thread_start_response() -> ClientResponsePayload {
|
| 632 |
+
ClientResponsePayload::ThreadStart(ThreadStartResponse {
|
| 633 |
+
disabled_plugin_ids: Vec::new(),
|
| 634 |
+
thread: sample_thread("thread-1"),
|
| 635 |
+
model: "gpt-5".to_string(),
|
| 636 |
+
model_provider: "openai".to_string(),
|
| 637 |
+
service_tier: None,
|
| 638 |
+
cwd: test_path_buf("/tmp").abs(),
|
| 639 |
+
runtime_workspace_roots: Vec::new(),
|
| 640 |
+
instruction_sources: Vec::new(),
|
| 641 |
+
approval_policy: AppServerAskForApproval::OnRequest,
|
| 642 |
+
approvals_reviewer: AppServerApprovalsReviewer::User,
|
| 643 |
+
sandbox: AppServerSandboxPolicy::DangerFullAccess,
|
| 644 |
+
active_permission_profile: None,
|
| 645 |
+
reasoning_effort: None,
|
| 646 |
+
multi_agent_mode: Default::default(),
|
| 647 |
+
})
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
fn sample_thread_resume_response() -> ClientResponsePayload {
|
| 651 |
+
ClientResponsePayload::ThreadResume(ThreadResumeResponse {
|
| 652 |
+
disabled_plugin_ids: Vec::new(),
|
| 653 |
+
thread: sample_thread("thread-2"),
|
| 654 |
+
model: "gpt-5".to_string(),
|
| 655 |
+
model_provider: "openai".to_string(),
|
| 656 |
+
service_tier: None,
|
| 657 |
+
cwd: test_path_buf("/tmp").abs(),
|
| 658 |
+
runtime_workspace_roots: Vec::new(),
|
| 659 |
+
instruction_sources: Vec::new(),
|
| 660 |
+
approval_policy: AppServerAskForApproval::OnRequest,
|
| 661 |
+
approvals_reviewer: AppServerApprovalsReviewer::User,
|
| 662 |
+
sandbox: AppServerSandboxPolicy::DangerFullAccess,
|
| 663 |
+
active_permission_profile: None,
|
| 664 |
+
reasoning_effort: None,
|
| 665 |
+
collaboration_mode: None,
|
| 666 |
+
multi_agent_mode: Default::default(),
|
| 667 |
+
initial_turns_page: None,
|
| 668 |
+
turns_backwards_cursor: None,
|
| 669 |
+
items_backwards_cursor: None,
|
| 670 |
+
})
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
fn sample_thread_fork_response() -> ClientResponsePayload {
|
| 674 |
+
ClientResponsePayload::ThreadFork(ThreadForkResponse {
|
| 675 |
+
disabled_plugin_ids: Vec::new(),
|
| 676 |
+
thread: sample_thread("thread-3"),
|
| 677 |
+
model: "gpt-5".to_string(),
|
| 678 |
+
model_provider: "openai".to_string(),
|
| 679 |
+
service_tier: None,
|
| 680 |
+
cwd: test_path_buf("/tmp").abs(),
|
| 681 |
+
runtime_workspace_roots: Vec::new(),
|
| 682 |
+
instruction_sources: Vec::new(),
|
| 683 |
+
approval_policy: AppServerAskForApproval::OnRequest,
|
| 684 |
+
approvals_reviewer: AppServerApprovalsReviewer::User,
|
| 685 |
+
sandbox: AppServerSandboxPolicy::DangerFullAccess,
|
| 686 |
+
active_permission_profile: None,
|
| 687 |
+
reasoning_effort: None,
|
| 688 |
+
multi_agent_mode: Default::default(),
|
| 689 |
+
})
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
fn sample_turn_start_response() -> ClientResponsePayload {
|
| 693 |
+
ClientResponsePayload::TurnStart(TurnStartResponse {
|
| 694 |
+
turn: Turn {
|
| 695 |
+
id: "turn-1".to_string(),
|
| 696 |
+
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
| 697 |
+
items: Vec::new(),
|
| 698 |
+
status: AppServerTurnStatus::InProgress,
|
| 699 |
+
error: None,
|
| 700 |
+
started_at: None,
|
| 701 |
+
completed_at: None,
|
| 702 |
+
duration_ms: None,
|
| 703 |
+
},
|
| 704 |
+
})
|
| 705 |
+
}
|
| 706 |
+
|
| 707 |
+
fn sample_turn_steer_response() -> ClientResponsePayload {
|
| 708 |
+
ClientResponsePayload::TurnSteer(TurnSteerResponse {
|
| 709 |
+
turn_id: "turn-2".to_string(),
|
| 710 |
+
})
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
#[test]
|
| 714 |
+
fn track_plugin_measurements_rejects_unbounded_inputs_before_queueing() {
|
| 715 |
+
let (client, mut receiver) = client_with_receiver();
|
| 716 |
+
let measurements = |row_count| PluginMeasurementsInput {
|
| 717 |
+
thread_id: "thread-1".to_string(),
|
| 718 |
+
turn_id: "turn-1".to_string(),
|
| 719 |
+
item_id: "item-1".to_string(),
|
| 720 |
+
originator: "codex_cli_rs".to_string(),
|
| 721 |
+
model_slug: None,
|
| 722 |
+
reasoning_effort: None,
|
| 723 |
+
plugin_id: "sample@openai-curated".to_string(),
|
| 724 |
+
execution_id: "execution-1".to_string(),
|
| 725 |
+
operation: "security_scan".to_string(),
|
| 726 |
+
rows: vec![
|
| 727 |
+
PluginMeasurementRow {
|
| 728 |
+
measurement_name: "finding_count".to_string(),
|
| 729 |
+
number_value: 1.0,
|
| 730 |
+
dimensions: BTreeMap::new(),
|
| 731 |
+
};
|
| 732 |
+
row_count
|
| 733 |
+
],
|
| 734 |
+
};
|
| 735 |
+
|
| 736 |
+
client.track_plugin_measurements(measurements(MAX_PLUGIN_MEASUREMENTS_PER_BATCH + 1));
|
| 737 |
+
assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty)));
|
| 738 |
+
|
| 739 |
+
let mut oversized_operation = measurements(1);
|
| 740 |
+
oversized_operation.operation = "o".repeat(65);
|
| 741 |
+
client.track_plugin_measurements(oversized_operation);
|
| 742 |
+
assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty)));
|
| 743 |
+
|
| 744 |
+
let mut mixed_rows = measurements(4);
|
| 745 |
+
mixed_rows.rows[0].measurement_name = "m".repeat(65);
|
| 746 |
+
mixed_rows.rows[1]
|
| 747 |
+
.dimensions
|
| 748 |
+
.insert("d".repeat(65), "valid".to_string());
|
| 749 |
+
mixed_rows.rows[2]
|
| 750 |
+
.dimensions
|
| 751 |
+
.insert("valid".to_string(), "v".repeat(65));
|
| 752 |
+
client.track_plugin_measurements(mixed_rows);
|
| 753 |
+
assert!(matches!(
|
| 754 |
+
receiver.try_recv(),
|
| 755 |
+
Ok(AnalyticsEventsQueueMessage::Fact(fact))
|
| 756 |
+
if matches!(
|
| 757 |
+
fact.as_ref(),
|
| 758 |
+
AnalyticsFact::Custom(CustomAnalyticsFact::PluginMeasurements(input))
|
| 759 |
+
if input.rows.len() == 1
|
| 760 |
+
&& input.rows[0].measurement_name == "finding_count"
|
| 761 |
+
)
|
| 762 |
+
));
|
| 763 |
+
|
| 764 |
+
client.track_plugin_measurements(measurements(MAX_PLUGIN_MEASUREMENTS_PER_BATCH));
|
| 765 |
+
assert!(matches!(
|
| 766 |
+
receiver.try_recv(),
|
| 767 |
+
Ok(AnalyticsEventsQueueMessage::Fact(fact))
|
| 768 |
+
if matches!(
|
| 769 |
+
fact.as_ref(),
|
| 770 |
+
AnalyticsFact::Custom(CustomAnalyticsFact::PluginMeasurements(input))
|
| 771 |
+
if input.rows.len() == MAX_PLUGIN_MEASUREMENTS_PER_BATCH
|
| 772 |
+
)
|
| 773 |
+
));
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
#[test]
|
| 777 |
+
fn track_request_only_enqueues_analytics_relevant_requests() {
|
| 778 |
+
let (client, mut receiver) = client_with_receiver();
|
| 779 |
+
|
| 780 |
+
for (request_id, request) in [
|
| 781 |
+
(RequestId::Integer(1), sample_turn_start_request()),
|
| 782 |
+
(RequestId::Integer(2), sample_turn_steer_request()),
|
| 783 |
+
] {
|
| 784 |
+
client.track_request(/*connection_id*/ 7, request_id, &request);
|
| 785 |
+
assert!(matches!(
|
| 786 |
+
receiver.try_recv(),
|
| 787 |
+
Ok(AnalyticsEventsQueueMessage::Fact(input))
|
| 788 |
+
if matches!(*input, AnalyticsFact::ClientRequest { .. })
|
| 789 |
+
));
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
client.track_request(
|
| 793 |
+
/*connection_id*/ 7,
|
| 794 |
+
RequestId::Integer(3),
|
| 795 |
+
&sample_turn_interrupt_request("turn-1"),
|
| 796 |
+
);
|
| 797 |
+
assert!(matches!(
|
| 798 |
+
receiver.try_recv(),
|
| 799 |
+
Ok(AnalyticsEventsQueueMessage::Fact(input))
|
| 800 |
+
if matches!(
|
| 801 |
+
*input,
|
| 802 |
+
AnalyticsFact::ExplicitClientInterruptRequest {
|
| 803 |
+
ref turn_id,
|
| 804 |
+
requested_at_ms,
|
| 805 |
+
..
|
| 806 |
+
} if turn_id == "turn-1" && requested_at_ms > 0
|
| 807 |
+
)
|
| 808 |
+
));
|
| 809 |
+
|
| 810 |
+
let ignored_request = sample_thread_archive_request();
|
| 811 |
+
client.track_request(
|
| 812 |
+
/*connection_id*/ 7,
|
| 813 |
+
RequestId::Integer(3),
|
| 814 |
+
&ignored_request,
|
| 815 |
+
);
|
| 816 |
+
assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty)));
|
| 817 |
+
|
| 818 |
+
client.track_request(
|
| 819 |
+
/*connection_id*/ 7,
|
| 820 |
+
RequestId::Integer(4),
|
| 821 |
+
&sample_turn_interrupt_request(""),
|
| 822 |
+
);
|
| 823 |
+
assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty)));
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
#[test]
|
| 827 |
+
fn track_response_only_enqueues_analytics_relevant_responses() {
|
| 828 |
+
let (client, mut receiver) = client_with_receiver();
|
| 829 |
+
|
| 830 |
+
for (request_id, response) in [
|
| 831 |
+
(RequestId::Integer(1), sample_thread_start_response()),
|
| 832 |
+
(RequestId::Integer(2), sample_thread_resume_response()),
|
| 833 |
+
(RequestId::Integer(3), sample_thread_fork_response()),
|
| 834 |
+
(RequestId::Integer(4), sample_turn_start_response()),
|
| 835 |
+
(RequestId::Integer(5), sample_turn_steer_response()),
|
| 836 |
+
(RequestId::Integer(6), sample_turn_interrupt_response()),
|
| 837 |
+
] {
|
| 838 |
+
client.track_response(/*connection_id*/ 7, request_id, &response);
|
| 839 |
+
assert!(matches!(
|
| 840 |
+
receiver.try_recv(),
|
| 841 |
+
Ok(AnalyticsEventsQueueMessage::Fact(input))
|
| 842 |
+
if matches!(*input, AnalyticsFact::ClientResponse { .. })
|
| 843 |
+
));
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
client.track_response(
|
| 847 |
+
/*connection_id*/ 7,
|
| 848 |
+
RequestId::Integer(7),
|
| 849 |
+
&ClientResponsePayload::ThreadArchive(ThreadArchiveResponse {}),
|
| 850 |
+
);
|
| 851 |
+
assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty)));
|
| 852 |
+
}
|
| 853 |
+
|
| 854 |
+
#[cfg(unix)]
|
| 855 |
+
#[test]
|
| 856 |
+
fn track_response_ignores_unserializable_thread_responses() {
|
| 857 |
+
use std::ffi::OsString;
|
| 858 |
+
use std::os::unix::ffi::OsStringExt;
|
| 859 |
+
|
| 860 |
+
let (client, mut receiver) = client_with_receiver();
|
| 861 |
+
let mut response = sample_thread_start_response();
|
| 862 |
+
let ClientResponsePayload::ThreadStart(thread_start) = &mut response else {
|
| 863 |
+
panic!("expected thread/start response");
|
| 864 |
+
};
|
| 865 |
+
thread_start.cwd = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(
|
| 866 |
+
std::path::PathBuf::from(OsString::from_vec(vec![b'/', b'b', b'a', b'd', 0xff])),
|
| 867 |
+
)
|
| 868 |
+
.expect("non-UTF-8 Unix paths are valid absolute paths");
|
| 869 |
+
|
| 870 |
+
client.track_response(/*connection_id*/ 7, RequestId::Integer(1), &response);
|
| 871 |
+
|
| 872 |
+
assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty)));
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
#[tokio::test]
|
| 876 |
+
async fn flush_waits_for_preceding_fact_delivery() {
|
| 877 |
+
let (client, mut receiver) = client_with_receiver();
|
| 878 |
+
client.track_request(
|
| 879 |
+
/*connection_id*/ 7,
|
| 880 |
+
RequestId::Integer(1),
|
| 881 |
+
&sample_turn_start_request(),
|
| 882 |
+
);
|
| 883 |
+
|
| 884 |
+
let flush = tokio::spawn(async move { client.flush().await });
|
| 885 |
+
assert!(matches!(
|
| 886 |
+
receiver.recv().await,
|
| 887 |
+
Some(AnalyticsEventsQueueMessage::Fact(input))
|
| 888 |
+
if matches!(*input, AnalyticsFact::ClientRequest { .. })
|
| 889 |
+
));
|
| 890 |
+
let done_tx = match receiver.recv().await {
|
| 891 |
+
Some(AnalyticsEventsQueueMessage::Flush(done_tx)) => done_tx,
|
| 892 |
+
_ => panic!("expected analytics flush barrier"),
|
| 893 |
+
};
|
| 894 |
+
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
| 895 |
+
assert!(!flush.is_finished());
|
| 896 |
+
done_tx.send(()).expect("flush receiver should remain open");
|
| 897 |
+
flush.await.expect("flush task should complete");
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
#[tokio::test]
|
| 901 |
+
async fn flush_is_noop_when_analytics_is_disabled() {
|
| 902 |
+
let client = AnalyticsEventsClient::new(
|
| 903 |
+
codex_login::AuthManager::from_auth_for_testing(
|
| 904 |
+
codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
| 905 |
+
),
|
| 906 |
+
"https://chatgpt.com/backend-api".to_string(),
|
| 907 |
+
/*analytics_enabled*/ Some(false),
|
| 908 |
+
);
|
| 909 |
+
client.track_notification(&ServerNotification::ThreadArchived(
|
| 910 |
+
ThreadArchivedNotification {
|
| 911 |
+
thread_id: "thread-1".to_string(),
|
| 912 |
+
},
|
| 913 |
+
));
|
| 914 |
+
assert!(client.queue.is_none());
|
| 915 |
+
client.flush().await;
|
| 916 |
+
}
|
| 917 |
+
|
| 918 |
+
#[test]
|
| 919 |
+
fn app_used_preserves_first_classification_and_emits_again_next_turn() {
|
| 920 |
+
let (client, mut receiver) = client_with_receiver();
|
| 921 |
+
let tracking = TrackEventsContext {
|
| 922 |
+
model_slug: "gpt-5".to_string(),
|
| 923 |
+
thread_id: "thread-1".to_string(),
|
| 924 |
+
turn_id: "turn-1".to_string(),
|
| 925 |
+
product_client_id: "codex_desktop".to_string(),
|
| 926 |
+
};
|
| 927 |
+
for (turn_id, elicitation_type) in [
|
| 928 |
+
("turn-1", Some(ElicitationType::AuthOrLink)),
|
| 929 |
+
("turn-1", None),
|
| 930 |
+
("turn-2", None),
|
| 931 |
+
] {
|
| 932 |
+
client.track_app_used(
|
| 933 |
+
TrackEventsContext {
|
| 934 |
+
turn_id: turn_id.to_string(),
|
| 935 |
+
..tracking.clone()
|
| 936 |
+
},
|
| 937 |
+
AppInvocation {
|
| 938 |
+
connector_id: Some("calendar".to_string()),
|
| 939 |
+
app_name: Some("Calendar".to_string()),
|
| 940 |
+
invocation_type: Some(InvocationType::Implicit),
|
| 941 |
+
},
|
| 942 |
+
elicitation_type,
|
| 943 |
+
);
|
| 944 |
+
}
|
| 945 |
+
for (turn_id, elicitation_type) in [
|
| 946 |
+
("turn-1", Some(ElicitationType::AuthOrLink)),
|
| 947 |
+
("turn-2", None),
|
| 948 |
+
] {
|
| 949 |
+
let Ok(AnalyticsEventsQueueMessage::Fact(input)) = receiver.try_recv() else {
|
| 950 |
+
panic!("expected app-used analytics fact");
|
| 951 |
+
};
|
| 952 |
+
let AnalyticsFact::Custom(CustomAnalyticsFact::AppUsed(input)) = *input else {
|
| 953 |
+
panic!("expected app-used analytics fact");
|
| 954 |
+
};
|
| 955 |
+
assert_eq!(
|
| 956 |
+
(
|
| 957 |
+
input.tracking.turn_id.as_str(),
|
| 958 |
+
input.app.connector_id.as_deref(),
|
| 959 |
+
input.elicitation_type,
|
| 960 |
+
),
|
| 961 |
+
(turn_id, Some("calendar"), elicitation_type)
|
| 962 |
+
);
|
| 963 |
+
}
|
| 964 |
+
assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty)));
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
+
#[test]
|
| 968 |
+
fn track_notification_only_enqueues_analytics_relevant_notifications() {
|
| 969 |
+
let (client, mut receiver) = client_with_receiver();
|
| 970 |
+
let tracked_payload = TurnDiffUpdatedNotification {
|
| 971 |
+
thread_id: "thread-1".to_string(),
|
| 972 |
+
turn_id: "turn-1".to_string(),
|
| 973 |
+
diff: "diff".to_string(),
|
| 974 |
+
};
|
| 975 |
+
let tracked_notification = ServerNotification::TurnDiffUpdated(tracked_payload.clone());
|
| 976 |
+
|
| 977 |
+
client.track_notification(&tracked_notification);
|
| 978 |
+
|
| 979 |
+
let Ok(AnalyticsEventsQueueMessage::Fact(input)) = receiver.try_recv() else {
|
| 980 |
+
panic!("expected analytics notification");
|
| 981 |
+
};
|
| 982 |
+
let AnalyticsFact::Notification(notification) = *input else {
|
| 983 |
+
panic!("expected analytics notification fact");
|
| 984 |
+
};
|
| 985 |
+
let ServerNotification::TurnDiffUpdated(notification) = *notification else {
|
| 986 |
+
panic!("expected turn diff notification");
|
| 987 |
+
};
|
| 988 |
+
assert_eq!(notification, tracked_payload);
|
| 989 |
+
|
| 990 |
+
let ignored_notification =
|
| 991 |
+
ServerNotification::CommandExecutionOutputDelta(CommandExecutionOutputDeltaNotification {
|
| 992 |
+
thread_id: "thread-1".to_string(),
|
| 993 |
+
turn_id: "turn-1".to_string(),
|
| 994 |
+
item_id: "item-1".to_string(),
|
| 995 |
+
delta: "output".to_string(),
|
| 996 |
+
});
|
| 997 |
+
|
| 998 |
+
client.track_notification(&ignored_notification);
|
| 999 |
+
assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty)));
|
| 1000 |
+
}
|
| 1001 |
+
|
| 1002 |
+
#[test]
|
| 1003 |
+
fn track_event_request_batches_only_isolates_accepted_line_fingerprint_events() {
|
| 1004 |
+
let batches = track_event_request_batches(vec![
|
| 1005 |
+
sample_regular_track_event("thread-1"),
|
| 1006 |
+
sample_regular_track_event("thread-2"),
|
| 1007 |
+
sample_accepted_line_fingerprint_event("thread-3"),
|
| 1008 |
+
sample_accepted_line_fingerprint_event("thread-4"),
|
| 1009 |
+
sample_regular_track_event("thread-5"),
|
| 1010 |
+
sample_regular_track_event("thread-6"),
|
| 1011 |
+
]);
|
| 1012 |
+
|
| 1013 |
+
assert_eq!(batches.len(), 4);
|
| 1014 |
+
assert_eq!(batches[0].len(), 2);
|
| 1015 |
+
assert_eq!(batches[1].len(), 1);
|
| 1016 |
+
assert_eq!(batches[2].len(), 1);
|
| 1017 |
+
assert_eq!(batches[3].len(), 2);
|
| 1018 |
+
assert!(batches[1][0].should_send_in_isolated_request());
|
| 1019 |
+
assert!(batches[2][0].should_send_in_isolated_request());
|
| 1020 |
+
}
|
codex-rs/analytics/src/events.rs
ADDED
|
@@ -0,0 +1,1542 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::time::Instant;
|
| 2 |
+
|
| 3 |
+
use crate::facts::AppInvocation;
|
| 4 |
+
use crate::facts::ArtifactOperation;
|
| 5 |
+
use crate::facts::ArtifactOperationLifecycle;
|
| 6 |
+
use crate::facts::CodexCompactionEvent;
|
| 7 |
+
use crate::facts::CodexErrKind;
|
| 8 |
+
use crate::facts::CodexGoalEvent;
|
| 9 |
+
use crate::facts::CompactionImplementation;
|
| 10 |
+
use crate::facts::CompactionPhase;
|
| 11 |
+
use crate::facts::CompactionReason;
|
| 12 |
+
use crate::facts::CompactionStatus;
|
| 13 |
+
use crate::facts::CompactionStrategy;
|
| 14 |
+
use crate::facts::CompactionTrigger;
|
| 15 |
+
use crate::facts::ElicitationType;
|
| 16 |
+
use crate::facts::GoalEventKind;
|
| 17 |
+
use crate::facts::HookRunFact;
|
| 18 |
+
use crate::facts::ImagePreparationMetadata;
|
| 19 |
+
use crate::facts::InvocationType;
|
| 20 |
+
use crate::facts::PluginInstallRequested;
|
| 21 |
+
use crate::facts::PluginState;
|
| 22 |
+
use crate::facts::SubAgentThreadStartedInput;
|
| 23 |
+
use crate::facts::ThreadInitializationMode;
|
| 24 |
+
use crate::facts::TrackEventsContext;
|
| 25 |
+
use crate::facts::TurnStatus;
|
| 26 |
+
use crate::facts::TurnSteerRejectionReason;
|
| 27 |
+
use crate::facts::TurnSteerResult;
|
| 28 |
+
use crate::facts::TurnSubmissionType;
|
| 29 |
+
use crate::guardian_v2::GuardianV2EventRequest;
|
| 30 |
+
use crate::now_unix_millis;
|
| 31 |
+
use codex_app_server_protocol::CodexErrorInfo;
|
| 32 |
+
use codex_app_server_protocol::CommandExecutionSource;
|
| 33 |
+
use codex_login::default_client::originator;
|
| 34 |
+
use codex_plugin::PluginId;
|
| 35 |
+
use codex_plugin::PluginTelemetryMetadata;
|
| 36 |
+
use codex_protocol::approvals::NetworkApprovalProtocol;
|
| 37 |
+
use codex_protocol::models::AdditionalPermissionProfile;
|
| 38 |
+
use codex_protocol::models::SandboxPermissions;
|
| 39 |
+
use codex_protocol::protocol::GuardianAssessmentOutcome;
|
| 40 |
+
use codex_protocol::protocol::GuardianCommandSource;
|
| 41 |
+
use codex_protocol::protocol::GuardianRiskLevel;
|
| 42 |
+
use codex_protocol::protocol::GuardianUserAuthorization;
|
| 43 |
+
use codex_protocol::protocol::HookEventName;
|
| 44 |
+
use codex_protocol::protocol::HookExecutionMode;
|
| 45 |
+
use codex_protocol::protocol::HookHandlerType;
|
| 46 |
+
use codex_protocol::protocol::HookRunStatus;
|
| 47 |
+
use codex_protocol::protocol::HookSource;
|
| 48 |
+
use codex_protocol::protocol::SubAgentSource;
|
| 49 |
+
use codex_protocol::protocol::ThreadSource;
|
| 50 |
+
use codex_protocol::protocol::TokenUsage;
|
| 51 |
+
use serde::Serialize;
|
| 52 |
+
use std::collections::BTreeMap;
|
| 53 |
+
|
| 54 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 55 |
+
#[serde(rename_all = "snake_case")]
|
| 56 |
+
pub enum AppServerRpcTransport {
|
| 57 |
+
Stdio,
|
| 58 |
+
Websocket,
|
| 59 |
+
InProcess,
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
#[derive(Serialize)]
|
| 63 |
+
pub(crate) struct TrackEventsRequest {
|
| 64 |
+
pub(crate) events: Vec<TrackEventRequest>,
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
#[derive(Serialize)]
|
| 68 |
+
#[serde(untagged)]
|
| 69 |
+
pub(crate) enum TrackEventRequest {
|
| 70 |
+
SkillInvocation(SkillInvocationEventRequest),
|
| 71 |
+
ThreadInitialized(ThreadInitializedEvent),
|
| 72 |
+
ThreadArchive(ThreadArchiveEvent),
|
| 73 |
+
GuardianReview(Box<GuardianReviewEventRequest>),
|
| 74 |
+
GuardianV2(Box<GuardianV2EventRequest>),
|
| 75 |
+
AppMentioned(CodexAppMentionedEventRequest),
|
| 76 |
+
AppUsed(CodexAppUsedEventRequest),
|
| 77 |
+
HookRun(CodexHookRunEventRequest),
|
| 78 |
+
Compaction(Box<CodexCompactionEventRequest>),
|
| 79 |
+
Goal(Box<CodexGoalEventRequest>),
|
| 80 |
+
ThreadHintStatus(Box<crate::thread_hint::ThreadHintStatusEventRequest>),
|
| 81 |
+
TurnEvent(Box<CodexTurnEventRequest>),
|
| 82 |
+
TurnSteer(CodexTurnSteerEventRequest),
|
| 83 |
+
ArtifactOperation(CodexArtifactOperationEventRequest),
|
| 84 |
+
CommandExecution(CodexCommandExecutionEventRequest),
|
| 85 |
+
PluginMeasurement(CodexPluginMeasurementEventRequest),
|
| 86 |
+
FileChange(CodexFileChangeEventRequest),
|
| 87 |
+
McpToolCall(CodexMcpToolCallEventRequest),
|
| 88 |
+
DynamicToolCall(CodexDynamicToolCallEventRequest),
|
| 89 |
+
ControlToolCall(CodexControlToolCallEventRequest),
|
| 90 |
+
CollabAgentToolCall(CodexCollabAgentToolCallEventRequest),
|
| 91 |
+
WebSearch(CodexWebSearchEventRequest),
|
| 92 |
+
ImageGeneration(CodexImageGenerationEventRequest),
|
| 93 |
+
AcceptedLineFingerprints(Box<CodexAcceptedLineFingerprintsEventRequest>),
|
| 94 |
+
#[allow(dead_code)]
|
| 95 |
+
ReviewEvent(CodexReviewEventRequest),
|
| 96 |
+
PluginUsed(CodexPluginUsedEventRequest),
|
| 97 |
+
PluginInstallRequested(CodexPluginInstallRequestedEventRequest),
|
| 98 |
+
PluginInstalled(CodexPluginEventRequest),
|
| 99 |
+
PluginUninstalled(CodexPluginEventRequest),
|
| 100 |
+
PluginEnabled(CodexPluginEventRequest),
|
| 101 |
+
PluginDisabled(CodexPluginEventRequest),
|
| 102 |
+
PluginInstallFailed(CodexPluginInstallFailedEventRequest),
|
| 103 |
+
ExternalAgentConfigImportCompleted(CodexOnboardingExternalAgentImportCompleteEventRequest),
|
| 104 |
+
ExternalAgentConfigImportFailure(CodexOnboardingExternalAgentImportFailureEventRequest),
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
#[derive(Serialize)]
|
| 108 |
+
pub(crate) struct CodexArtifactOperationEventParams {
|
| 109 |
+
pub(crate) thread_id: String,
|
| 110 |
+
pub(crate) turn_id: String,
|
| 111 |
+
pub(crate) item_id: String,
|
| 112 |
+
pub(crate) lifecycle: ArtifactOperationLifecycle,
|
| 113 |
+
pub(crate) occurred_at_ms: u64,
|
| 114 |
+
pub(crate) product_client_id: String,
|
| 115 |
+
pub(crate) runtime: CodexRuntimeMetadata,
|
| 116 |
+
pub(crate) model_slug: String,
|
| 117 |
+
pub(crate) plugin_id: String,
|
| 118 |
+
pub(crate) script_path: String,
|
| 119 |
+
pub(crate) skill: String,
|
| 120 |
+
pub(crate) artifact_type: String,
|
| 121 |
+
pub(crate) operation_kind: String,
|
| 122 |
+
pub(crate) expected_output_count: u32,
|
| 123 |
+
pub(crate) output_format: String,
|
| 124 |
+
pub(crate) execution_backend: String,
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
#[derive(Serialize)]
|
| 128 |
+
pub(crate) struct CodexArtifactOperationEventRequest {
|
| 129 |
+
pub(crate) event_type: &'static str,
|
| 130 |
+
pub(crate) event_params: CodexArtifactOperationEventParams,
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
pub(crate) fn codex_artifact_operation_event_request(
|
| 134 |
+
tracking: TrackEventsContext,
|
| 135 |
+
operation: ArtifactOperation,
|
| 136 |
+
) -> CodexArtifactOperationEventRequest {
|
| 137 |
+
CodexArtifactOperationEventRequest {
|
| 138 |
+
event_type: "codex_artifact_operation",
|
| 139 |
+
event_params: CodexArtifactOperationEventParams {
|
| 140 |
+
thread_id: tracking.thread_id,
|
| 141 |
+
turn_id: tracking.turn_id,
|
| 142 |
+
item_id: operation.item_id,
|
| 143 |
+
lifecycle: operation.lifecycle,
|
| 144 |
+
occurred_at_ms: operation.occurred_at_ms,
|
| 145 |
+
product_client_id: tracking.product_client_id,
|
| 146 |
+
runtime: current_runtime_metadata(),
|
| 147 |
+
model_slug: tracking.model_slug,
|
| 148 |
+
plugin_id: operation.plugin_id,
|
| 149 |
+
script_path: operation.script_path,
|
| 150 |
+
skill: operation.skill,
|
| 151 |
+
artifact_type: operation.artifact_type,
|
| 152 |
+
operation_kind: operation.operation_kind,
|
| 153 |
+
expected_output_count: operation.expected_output_count,
|
| 154 |
+
output_format: operation.output_format,
|
| 155 |
+
execution_backend: operation.execution_backend,
|
| 156 |
+
},
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
impl TrackEventRequest {
|
| 161 |
+
pub(crate) fn should_send_in_isolated_request(&self) -> bool {
|
| 162 |
+
matches!(self, Self::AcceptedLineFingerprints(_))
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
pub(crate) fn can_send_with_api_key_auth(&self) -> bool {
|
| 166 |
+
match self {
|
| 167 |
+
Self::PluginUsed(event) => event.event_params.plugin.plugin_id.is_some(),
|
| 168 |
+
Self::SkillInvocation(event) => event.event_params.plugin_id.is_some(),
|
| 169 |
+
Self::McpToolCall(event) => event.event_params.plugin_id.is_some(),
|
| 170 |
+
Self::ArtifactOperation(event) => !event.event_params.plugin_id.is_empty(),
|
| 171 |
+
Self::PluginMeasurement(event) => !event.event_params.plugin_id.is_empty(),
|
| 172 |
+
_ => false,
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
#[derive(Serialize)]
|
| 178 |
+
pub(crate) struct CodexAcceptedLineFingerprintsEventParams {
|
| 179 |
+
pub(crate) event_type: &'static str,
|
| 180 |
+
pub(crate) turn_id: String,
|
| 181 |
+
pub(crate) thread_id: String,
|
| 182 |
+
pub(crate) product_surface: Option<String>,
|
| 183 |
+
pub(crate) model_slug: Option<String>,
|
| 184 |
+
pub(crate) completed_at: u64,
|
| 185 |
+
pub(crate) repo_hash: Option<String>,
|
| 186 |
+
pub(crate) accepted_added_lines: u64,
|
| 187 |
+
pub(crate) accepted_deleted_lines: u64,
|
| 188 |
+
// Analytics ingestion and warehouse schemas require this field on the wire.
|
| 189 |
+
// Keep it statically empty; line fingerprints are no longer generated.
|
| 190 |
+
pub(crate) line_fingerprints: [(); 0],
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
#[derive(Serialize)]
|
| 194 |
+
pub(crate) struct CodexAcceptedLineFingerprintsEventRequest {
|
| 195 |
+
pub(crate) event_type: &'static str,
|
| 196 |
+
pub(crate) event_params: CodexAcceptedLineFingerprintsEventParams,
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
#[derive(Serialize)]
|
| 200 |
+
pub(crate) struct SkillInvocationEventRequest {
|
| 201 |
+
pub(crate) event_type: &'static str,
|
| 202 |
+
pub(crate) skill_id: String,
|
| 203 |
+
pub(crate) skill_name: String,
|
| 204 |
+
pub(crate) event_params: SkillInvocationEventParams,
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
#[derive(Serialize)]
|
| 208 |
+
pub(crate) struct SkillInvocationEventParams {
|
| 209 |
+
pub(crate) product_client_id: Option<String>,
|
| 210 |
+
pub(crate) skill_scope: Option<String>,
|
| 211 |
+
pub(crate) plugin_id: Option<String>,
|
| 212 |
+
pub(crate) remote_plugin_id: Option<String>,
|
| 213 |
+
pub(crate) thread_id: Option<String>,
|
| 214 |
+
pub(crate) turn_id: Option<String>,
|
| 215 |
+
pub(crate) invoke_type: Option<InvocationType>,
|
| 216 |
+
pub(crate) model_slug: Option<String>,
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
#[derive(Clone, Serialize)]
|
| 220 |
+
pub(crate) struct CodexAppServerClientMetadata {
|
| 221 |
+
pub(crate) product_client_id: String,
|
| 222 |
+
pub(crate) client_name: Option<String>,
|
| 223 |
+
pub(crate) client_version: Option<String>,
|
| 224 |
+
pub(crate) rpc_transport: AppServerRpcTransport,
|
| 225 |
+
pub(crate) experimental_api_enabled: Option<bool>,
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
#[derive(Clone, Serialize)]
|
| 229 |
+
pub(crate) struct CodexRuntimeMetadata {
|
| 230 |
+
pub(crate) codex_rs_version: String,
|
| 231 |
+
pub(crate) runtime_os: String,
|
| 232 |
+
pub(crate) runtime_os_version: String,
|
| 233 |
+
pub(crate) runtime_arch: String,
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
#[derive(Serialize)]
|
| 237 |
+
pub(crate) struct ThreadInitializedEventParams {
|
| 238 |
+
pub(crate) thread_id: String,
|
| 239 |
+
pub(crate) session_id: String,
|
| 240 |
+
pub(crate) app_server_client: CodexAppServerClientMetadata,
|
| 241 |
+
pub(crate) runtime: CodexRuntimeMetadata,
|
| 242 |
+
pub(crate) model: String,
|
| 243 |
+
pub(crate) ephemeral: bool,
|
| 244 |
+
/// Whether the thread's checkout is a validated linked Git worktree, if known.
|
| 245 |
+
pub(crate) is_worktree: Option<bool>,
|
| 246 |
+
pub(crate) thread_source: Option<ThreadSource>,
|
| 247 |
+
pub(crate) initialization_mode: ThreadInitializationMode,
|
| 248 |
+
pub(crate) subagent_source: Option<String>,
|
| 249 |
+
pub(crate) parent_thread_id: Option<String>,
|
| 250 |
+
pub(crate) forked_from_thread_id: Option<String>,
|
| 251 |
+
pub(crate) created_at: u64,
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
#[derive(Serialize)]
|
| 255 |
+
pub(crate) struct ThreadInitializedEvent {
|
| 256 |
+
pub(crate) event_type: &'static str,
|
| 257 |
+
pub(crate) event_params: ThreadInitializedEventParams,
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
#[derive(Clone, Copy, Serialize)]
|
| 261 |
+
#[serde(rename_all = "snake_case")]
|
| 262 |
+
pub(crate) enum ThreadArchiveAction {
|
| 263 |
+
Archived,
|
| 264 |
+
Unarchived,
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
#[derive(Serialize)]
|
| 268 |
+
pub(crate) struct ThreadArchiveEventParams {
|
| 269 |
+
pub(crate) thread_id: String,
|
| 270 |
+
pub(crate) action: ThreadArchiveAction,
|
| 271 |
+
pub(crate) occurred_at_ms: u64,
|
| 272 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 273 |
+
pub(crate) app_server_client: Option<CodexAppServerClientMetadata>,
|
| 274 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 275 |
+
pub(crate) runtime: Option<CodexRuntimeMetadata>,
|
| 276 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 277 |
+
pub(crate) thread_source: Option<ThreadSource>,
|
| 278 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 279 |
+
pub(crate) parent_thread_id: Option<String>,
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
#[derive(Serialize)]
|
| 283 |
+
pub(crate) struct ThreadArchiveEvent {
|
| 284 |
+
pub(crate) event_type: &'static str,
|
| 285 |
+
pub(crate) event_params: ThreadArchiveEventParams,
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
#[derive(Serialize)]
|
| 289 |
+
pub(crate) struct GuardianReviewEventRequest {
|
| 290 |
+
pub(crate) event_type: &'static str,
|
| 291 |
+
pub(crate) event_params: GuardianReviewEventPayload,
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 295 |
+
#[serde(rename_all = "snake_case")]
|
| 296 |
+
pub enum GuardianReviewDecision {
|
| 297 |
+
Approved,
|
| 298 |
+
Denied,
|
| 299 |
+
Aborted,
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 303 |
+
#[serde(rename_all = "snake_case")]
|
| 304 |
+
pub enum GuardianReviewTerminalStatus {
|
| 305 |
+
Approved,
|
| 306 |
+
Denied,
|
| 307 |
+
Aborted,
|
| 308 |
+
TimedOut,
|
| 309 |
+
FailedClosed,
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 313 |
+
#[serde(rename_all = "snake_case")]
|
| 314 |
+
pub enum GuardianReviewFailureReason {
|
| 315 |
+
Timeout,
|
| 316 |
+
Cancelled,
|
| 317 |
+
PromptBuildError,
|
| 318 |
+
SessionError,
|
| 319 |
+
ParseError,
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 323 |
+
#[serde(rename_all = "snake_case")]
|
| 324 |
+
pub enum GuardianReviewSessionKind {
|
| 325 |
+
TrunkNew,
|
| 326 |
+
TrunkReused,
|
| 327 |
+
EphemeralForked,
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 331 |
+
#[serde(rename_all = "snake_case")]
|
| 332 |
+
pub enum GuardianApprovalRequestSource {
|
| 333 |
+
/// Approval requested directly by the main Codex turn.
|
| 334 |
+
MainTurn,
|
| 335 |
+
/// Approval requested by a delegated subagent and routed through the parent
|
| 336 |
+
/// session for guardian review.
|
| 337 |
+
DelegatedSubagent,
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
/// Path-free permission metadata for a Guardian reviewed action.
|
| 341 |
+
#[derive(Clone, Debug, Serialize)]
|
| 342 |
+
pub struct GuardianAdditionalPermissions {
|
| 343 |
+
network: Option<GuardianNetworkPermissions>,
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
#[derive(Clone, Debug, Serialize)]
|
| 347 |
+
struct GuardianNetworkPermissions {
|
| 348 |
+
enabled: Option<bool>,
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
impl From<&AdditionalPermissionProfile> for GuardianAdditionalPermissions {
|
| 352 |
+
fn from(permissions: &AdditionalPermissionProfile) -> Self {
|
| 353 |
+
Self {
|
| 354 |
+
network: permissions
|
| 355 |
+
.network
|
| 356 |
+
.as_ref()
|
| 357 |
+
.map(|network| GuardianNetworkPermissions {
|
| 358 |
+
enabled: network.enabled,
|
| 359 |
+
}),
|
| 360 |
+
}
|
| 361 |
+
}
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
#[derive(Clone, Debug, Serialize)]
|
| 365 |
+
#[serde(tag = "type", rename_all = "snake_case")]
|
| 366 |
+
pub enum GuardianReviewedAction {
|
| 367 |
+
Shell {
|
| 368 |
+
sandbox_permissions: SandboxPermissions,
|
| 369 |
+
additional_permissions: Option<GuardianAdditionalPermissions>,
|
| 370 |
+
},
|
| 371 |
+
UnifiedExec {
|
| 372 |
+
sandbox_permissions: SandboxPermissions,
|
| 373 |
+
additional_permissions: Option<GuardianAdditionalPermissions>,
|
| 374 |
+
tty: bool,
|
| 375 |
+
},
|
| 376 |
+
WriteStdin {
|
| 377 |
+
tty: bool,
|
| 378 |
+
},
|
| 379 |
+
Execve {
|
| 380 |
+
source: GuardianCommandSource,
|
| 381 |
+
additional_permissions: Option<GuardianAdditionalPermissions>,
|
| 382 |
+
},
|
| 383 |
+
ApplyPatch {},
|
| 384 |
+
NetworkAccess {
|
| 385 |
+
protocol: NetworkApprovalProtocol,
|
| 386 |
+
port: u16,
|
| 387 |
+
},
|
| 388 |
+
McpToolCall {
|
| 389 |
+
server: String,
|
| 390 |
+
tool_name: String,
|
| 391 |
+
connector_id: Option<String>,
|
| 392 |
+
connector_name: Option<String>,
|
| 393 |
+
tool_title: Option<String>,
|
| 394 |
+
},
|
| 395 |
+
RequestPermissions {},
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
#[derive(Clone, Serialize)]
|
| 399 |
+
pub struct GuardianReviewEventParams {
|
| 400 |
+
pub thread_id: String,
|
| 401 |
+
pub turn_id: String,
|
| 402 |
+
pub review_id: String,
|
| 403 |
+
pub target_item_id: Option<String>,
|
| 404 |
+
pub approval_request_source: GuardianApprovalRequestSource,
|
| 405 |
+
pub reviewed_action: GuardianReviewedAction,
|
| 406 |
+
pub reviewed_action_truncated: bool,
|
| 407 |
+
pub decision: GuardianReviewDecision,
|
| 408 |
+
pub terminal_status: GuardianReviewTerminalStatus,
|
| 409 |
+
pub failure_reason: Option<GuardianReviewFailureReason>,
|
| 410 |
+
pub attempt_count: i64,
|
| 411 |
+
pub risk_level: Option<GuardianRiskLevel>,
|
| 412 |
+
pub user_authorization: Option<GuardianUserAuthorization>,
|
| 413 |
+
pub outcome: Option<GuardianAssessmentOutcome>,
|
| 414 |
+
pub guardian_thread_id: Option<String>,
|
| 415 |
+
pub guardian_session_kind: Option<GuardianReviewSessionKind>,
|
| 416 |
+
pub guardian_model: Option<String>,
|
| 417 |
+
pub guardian_reasoning_effort: Option<String>,
|
| 418 |
+
pub guardian_default_review_model_id: Option<String>,
|
| 419 |
+
pub guardian_catalog_contains_auto_review: Option<bool>,
|
| 420 |
+
pub guardian_review_model_overridden: Option<bool>,
|
| 421 |
+
pub guardian_review_model_override: Option<String>,
|
| 422 |
+
pub guardian_model_provider_id: Option<String>,
|
| 423 |
+
pub had_prior_review_context: Option<bool>,
|
| 424 |
+
pub review_timeout_ms: u64,
|
| 425 |
+
pub tool_call_count: Option<u64>,
|
| 426 |
+
pub time_to_first_token_ms: Option<u64>,
|
| 427 |
+
pub completion_latency_ms: Option<u64>,
|
| 428 |
+
pub started_at: u64,
|
| 429 |
+
pub completed_at: Option<u64>,
|
| 430 |
+
pub input_tokens: Option<i64>,
|
| 431 |
+
pub cached_input_tokens: Option<i64>,
|
| 432 |
+
pub cache_write_input_tokens: Option<i64>,
|
| 433 |
+
pub output_tokens: Option<i64>,
|
| 434 |
+
pub reasoning_output_tokens: Option<i64>,
|
| 435 |
+
pub total_tokens: Option<i64>,
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
pub struct GuardianReviewTrackContext {
|
| 439 |
+
thread_id: String,
|
| 440 |
+
turn_id: String,
|
| 441 |
+
review_id: String,
|
| 442 |
+
target_item_id: Option<String>,
|
| 443 |
+
approval_request_source: GuardianApprovalRequestSource,
|
| 444 |
+
reviewed_action: GuardianReviewedAction,
|
| 445 |
+
review_timeout_ms: u64,
|
| 446 |
+
pub started_at_ms: u64,
|
| 447 |
+
started_instant: Instant,
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
impl GuardianReviewTrackContext {
|
| 451 |
+
pub fn new(
|
| 452 |
+
thread_id: String,
|
| 453 |
+
turn_id: String,
|
| 454 |
+
review_id: String,
|
| 455 |
+
target_item_id: Option<String>,
|
| 456 |
+
approval_request_source: GuardianApprovalRequestSource,
|
| 457 |
+
reviewed_action: GuardianReviewedAction,
|
| 458 |
+
review_timeout_ms: u64,
|
| 459 |
+
) -> Self {
|
| 460 |
+
Self {
|
| 461 |
+
thread_id,
|
| 462 |
+
turn_id,
|
| 463 |
+
review_id,
|
| 464 |
+
target_item_id,
|
| 465 |
+
approval_request_source,
|
| 466 |
+
reviewed_action,
|
| 467 |
+
review_timeout_ms,
|
| 468 |
+
started_at_ms: now_unix_millis(),
|
| 469 |
+
started_instant: Instant::now(),
|
| 470 |
+
}
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
pub(crate) fn event_params(
|
| 474 |
+
&self,
|
| 475 |
+
result: GuardianReviewAnalyticsResult,
|
| 476 |
+
completed_at_ms: u64,
|
| 477 |
+
) -> GuardianReviewEventParams {
|
| 478 |
+
GuardianReviewEventParams {
|
| 479 |
+
thread_id: self.thread_id.clone(),
|
| 480 |
+
turn_id: self.turn_id.clone(),
|
| 481 |
+
review_id: self.review_id.clone(),
|
| 482 |
+
target_item_id: self.target_item_id.clone(),
|
| 483 |
+
approval_request_source: self.approval_request_source,
|
| 484 |
+
reviewed_action: self.reviewed_action.clone(),
|
| 485 |
+
reviewed_action_truncated: result.reviewed_action_truncated,
|
| 486 |
+
decision: result.decision,
|
| 487 |
+
terminal_status: result.terminal_status,
|
| 488 |
+
failure_reason: result.failure_reason,
|
| 489 |
+
attempt_count: result.attempt_count,
|
| 490 |
+
risk_level: result.risk_level,
|
| 491 |
+
user_authorization: result.user_authorization,
|
| 492 |
+
outcome: result.outcome,
|
| 493 |
+
guardian_thread_id: result.guardian_thread_id,
|
| 494 |
+
guardian_session_kind: result.guardian_session_kind,
|
| 495 |
+
guardian_model: result.guardian_model,
|
| 496 |
+
guardian_reasoning_effort: result.guardian_reasoning_effort,
|
| 497 |
+
guardian_default_review_model_id: result.guardian_default_review_model_id,
|
| 498 |
+
guardian_catalog_contains_auto_review: result.guardian_catalog_contains_auto_review,
|
| 499 |
+
guardian_review_model_overridden: result.guardian_review_model_overridden,
|
| 500 |
+
guardian_review_model_override: result.guardian_review_model_override,
|
| 501 |
+
guardian_model_provider_id: result.guardian_model_provider_id,
|
| 502 |
+
had_prior_review_context: result.had_prior_review_context,
|
| 503 |
+
review_timeout_ms: self.review_timeout_ms,
|
| 504 |
+
// TODO(rhan-oai): plumb nested Guardian review session tool-call counts.
|
| 505 |
+
tool_call_count: None,
|
| 506 |
+
time_to_first_token_ms: result.time_to_first_token_ms,
|
| 507 |
+
completion_latency_ms: Some(self.started_instant.elapsed().as_millis() as u64),
|
| 508 |
+
started_at: self.started_at_ms / 1_000,
|
| 509 |
+
completed_at: Some(completed_at_ms / 1_000),
|
| 510 |
+
input_tokens: result.token_usage.as_ref().map(|usage| usage.input_tokens),
|
| 511 |
+
cached_input_tokens: result
|
| 512 |
+
.token_usage
|
| 513 |
+
.as_ref()
|
| 514 |
+
.map(|usage| usage.cached_input_tokens),
|
| 515 |
+
cache_write_input_tokens: result
|
| 516 |
+
.token_usage
|
| 517 |
+
.as_ref()
|
| 518 |
+
.map(|usage| usage.cache_write_input_tokens),
|
| 519 |
+
output_tokens: result.token_usage.as_ref().map(|usage| usage.output_tokens),
|
| 520 |
+
reasoning_output_tokens: result
|
| 521 |
+
.token_usage
|
| 522 |
+
.as_ref()
|
| 523 |
+
.map(|usage| usage.reasoning_output_tokens),
|
| 524 |
+
total_tokens: result.token_usage.as_ref().map(|usage| usage.total_tokens),
|
| 525 |
+
}
|
| 526 |
+
}
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
#[derive(Debug)]
|
| 530 |
+
pub struct GuardianReviewAnalyticsResult {
|
| 531 |
+
pub decision: GuardianReviewDecision,
|
| 532 |
+
pub terminal_status: GuardianReviewTerminalStatus,
|
| 533 |
+
pub failure_reason: Option<GuardianReviewFailureReason>,
|
| 534 |
+
pub attempt_count: i64,
|
| 535 |
+
pub risk_level: Option<GuardianRiskLevel>,
|
| 536 |
+
pub user_authorization: Option<GuardianUserAuthorization>,
|
| 537 |
+
pub outcome: Option<GuardianAssessmentOutcome>,
|
| 538 |
+
pub guardian_thread_id: Option<String>,
|
| 539 |
+
pub guardian_session_kind: Option<GuardianReviewSessionKind>,
|
| 540 |
+
pub guardian_model: Option<String>,
|
| 541 |
+
pub guardian_reasoning_effort: Option<String>,
|
| 542 |
+
pub guardian_default_review_model_id: Option<String>,
|
| 543 |
+
pub guardian_catalog_contains_auto_review: Option<bool>,
|
| 544 |
+
pub guardian_review_model_overridden: Option<bool>,
|
| 545 |
+
pub guardian_review_model_override: Option<String>,
|
| 546 |
+
pub guardian_model_provider_id: Option<String>,
|
| 547 |
+
pub had_prior_review_context: Option<bool>,
|
| 548 |
+
pub reviewed_action_truncated: bool,
|
| 549 |
+
pub token_usage: Option<TokenUsage>,
|
| 550 |
+
pub time_to_first_token_ms: Option<u64>,
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
impl GuardianReviewAnalyticsResult {
|
| 554 |
+
pub fn without_session() -> Self {
|
| 555 |
+
Self {
|
| 556 |
+
decision: GuardianReviewDecision::Denied,
|
| 557 |
+
terminal_status: GuardianReviewTerminalStatus::FailedClosed,
|
| 558 |
+
failure_reason: None,
|
| 559 |
+
attempt_count: 1,
|
| 560 |
+
risk_level: None,
|
| 561 |
+
user_authorization: None,
|
| 562 |
+
outcome: None,
|
| 563 |
+
guardian_thread_id: None,
|
| 564 |
+
guardian_session_kind: None,
|
| 565 |
+
guardian_model: None,
|
| 566 |
+
guardian_reasoning_effort: None,
|
| 567 |
+
guardian_default_review_model_id: None,
|
| 568 |
+
guardian_catalog_contains_auto_review: None,
|
| 569 |
+
guardian_review_model_overridden: None,
|
| 570 |
+
guardian_review_model_override: None,
|
| 571 |
+
guardian_model_provider_id: None,
|
| 572 |
+
had_prior_review_context: None,
|
| 573 |
+
reviewed_action_truncated: false,
|
| 574 |
+
token_usage: None,
|
| 575 |
+
time_to_first_token_ms: None,
|
| 576 |
+
}
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
pub fn from_session(params: GuardianReviewSessionAnalyticsParams) -> Self {
|
| 580 |
+
Self {
|
| 581 |
+
guardian_thread_id: Some(params.guardian_thread_id),
|
| 582 |
+
guardian_session_kind: Some(params.guardian_session_kind),
|
| 583 |
+
guardian_model: Some(params.guardian_model),
|
| 584 |
+
guardian_reasoning_effort: params.guardian_reasoning_effort,
|
| 585 |
+
guardian_default_review_model_id: Some(params.guardian_default_review_model_id),
|
| 586 |
+
guardian_catalog_contains_auto_review: Some(
|
| 587 |
+
params.guardian_catalog_contains_auto_review,
|
| 588 |
+
),
|
| 589 |
+
guardian_review_model_overridden: Some(params.guardian_review_model_overridden),
|
| 590 |
+
guardian_review_model_override: params.guardian_review_model_override,
|
| 591 |
+
guardian_model_provider_id: Some(params.guardian_model_provider_id),
|
| 592 |
+
had_prior_review_context: Some(params.had_prior_review_context),
|
| 593 |
+
..Self::without_session()
|
| 594 |
+
}
|
| 595 |
+
}
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
pub struct GuardianReviewSessionAnalyticsParams {
|
| 599 |
+
pub guardian_thread_id: String,
|
| 600 |
+
pub guardian_session_kind: GuardianReviewSessionKind,
|
| 601 |
+
pub guardian_model: String,
|
| 602 |
+
pub guardian_reasoning_effort: Option<String>,
|
| 603 |
+
pub guardian_default_review_model_id: String,
|
| 604 |
+
pub guardian_catalog_contains_auto_review: bool,
|
| 605 |
+
pub guardian_review_model_overridden: bool,
|
| 606 |
+
pub guardian_review_model_override: Option<String>,
|
| 607 |
+
pub guardian_model_provider_id: String,
|
| 608 |
+
pub had_prior_review_context: bool,
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
#[derive(Serialize)]
|
| 612 |
+
pub(crate) struct GuardianReviewEventPayload {
|
| 613 |
+
pub(crate) session_id: String,
|
| 614 |
+
pub(crate) app_server_client: CodexAppServerClientMetadata,
|
| 615 |
+
pub(crate) runtime: CodexRuntimeMetadata,
|
| 616 |
+
#[serde(flatten)]
|
| 617 |
+
pub(crate) guardian_review: GuardianReviewEventParams,
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
#[allow(dead_code)]
|
| 621 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 622 |
+
#[serde(rename_all = "snake_case")]
|
| 623 |
+
pub(crate) enum FinalApprovalOutcome {
|
| 624 |
+
Unknown,
|
| 625 |
+
NotNeeded,
|
| 626 |
+
ConfigAllowed,
|
| 627 |
+
PolicyForbidden,
|
| 628 |
+
GuardianApproved,
|
| 629 |
+
GuardianDenied,
|
| 630 |
+
GuardianAborted,
|
| 631 |
+
UserApproved,
|
| 632 |
+
UserApprovedForSession,
|
| 633 |
+
UserDenied,
|
| 634 |
+
UserAborted,
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
#[allow(dead_code)]
|
| 638 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 639 |
+
#[serde(rename_all = "snake_case")]
|
| 640 |
+
pub(crate) enum ToolItemTerminalStatus {
|
| 641 |
+
Completed,
|
| 642 |
+
Failed,
|
| 643 |
+
Rejected,
|
| 644 |
+
Interrupted,
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
#[allow(dead_code)]
|
| 648 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 649 |
+
#[serde(rename_all = "snake_case")]
|
| 650 |
+
pub(crate) enum ToolItemFailureKind {
|
| 651 |
+
ToolError,
|
| 652 |
+
ApprovalDenied,
|
| 653 |
+
ApprovalAborted,
|
| 654 |
+
SandboxDenied,
|
| 655 |
+
PolicyForbidden,
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
/// The immediate initiator of the tool request associated with an analytics event.
|
| 659 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 660 |
+
#[serde(rename_all = "snake_case")]
|
| 661 |
+
pub(crate) enum ToolEventType {
|
| 662 |
+
/// The event item ID matches a tool call emitted in a sampled model response.
|
| 663 |
+
ModelToolCall,
|
| 664 |
+
/// The event item ID matches a child call dispatched by an executing tool.
|
| 665 |
+
InnerToolCall,
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
#[derive(Serialize)]
|
| 669 |
+
pub(crate) struct CodexToolItemEventBase {
|
| 670 |
+
pub(crate) thread_id: String,
|
| 671 |
+
pub(crate) session_id: String,
|
| 672 |
+
pub(crate) turn_id: String,
|
| 673 |
+
pub(crate) root_turn_id: Option<String>,
|
| 674 |
+
/// App-server ThreadItem.id. For tool-originated items this generally
|
| 675 |
+
/// corresponds to the originating core call_id.
|
| 676 |
+
pub(crate) item_id: String,
|
| 677 |
+
pub(crate) cell_id: Option<String>,
|
| 678 |
+
pub(crate) parent_call_id: Option<String>,
|
| 679 |
+
pub(crate) originating_response_id: Option<String>,
|
| 680 |
+
pub(crate) subsequent_response_id: Option<String>,
|
| 681 |
+
pub(crate) app_server_client: CodexAppServerClientMetadata,
|
| 682 |
+
pub(crate) runtime: CodexRuntimeMetadata,
|
| 683 |
+
pub(crate) thread_source: Option<ThreadSource>,
|
| 684 |
+
pub(crate) subagent_source: Option<String>,
|
| 685 |
+
pub(crate) parent_thread_id: Option<String>,
|
| 686 |
+
pub(crate) tool_name: String,
|
| 687 |
+
/// Absent when origin evidence is unavailable or conflicting at emission time.
|
| 688 |
+
pub(crate) tool_event_type: Option<ToolEventType>,
|
| 689 |
+
pub(crate) started_at_ms: u64,
|
| 690 |
+
pub(crate) completed_at_ms: u64,
|
| 691 |
+
// Observed item lifecycle duration. This may undercount end-to-end execution
|
| 692 |
+
// for tools where app-server only sees part of the upstream flow.
|
| 693 |
+
pub(crate) duration_ms: Option<u64>,
|
| 694 |
+
pub(crate) execution_duration_ms: Option<u64>,
|
| 695 |
+
pub(crate) review_count: u64,
|
| 696 |
+
pub(crate) guardian_review_count: u64,
|
| 697 |
+
pub(crate) user_review_count: u64,
|
| 698 |
+
pub(crate) final_approval_outcome: FinalApprovalOutcome,
|
| 699 |
+
pub(crate) terminal_status: ToolItemTerminalStatus,
|
| 700 |
+
pub(crate) failure_kind: Option<ToolItemFailureKind>,
|
| 701 |
+
pub(crate) requested_additional_permissions: bool,
|
| 702 |
+
pub(crate) requested_network_access: bool,
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
#[allow(dead_code)]
|
| 706 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 707 |
+
#[serde(rename_all = "snake_case")]
|
| 708 |
+
pub(crate) enum ReviewSubjectKind {
|
| 709 |
+
CommandExecution,
|
| 710 |
+
WriteStdin,
|
| 711 |
+
FileChange,
|
| 712 |
+
McpToolCall,
|
| 713 |
+
Permissions,
|
| 714 |
+
NetworkAccess,
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
#[allow(dead_code)]
|
| 718 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 719 |
+
#[serde(rename_all = "snake_case")]
|
| 720 |
+
pub(crate) enum Reviewer {
|
| 721 |
+
Guardian,
|
| 722 |
+
User,
|
| 723 |
+
}
|
| 724 |
+
|
| 725 |
+
#[allow(dead_code)]
|
| 726 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 727 |
+
#[serde(rename_all = "snake_case")]
|
| 728 |
+
pub(crate) enum ReviewTrigger {
|
| 729 |
+
Initial,
|
| 730 |
+
SandboxDenial,
|
| 731 |
+
NetworkPolicyDenial,
|
| 732 |
+
ExecveIntercept,
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
#[allow(dead_code)]
|
| 736 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 737 |
+
#[serde(rename_all = "snake_case")]
|
| 738 |
+
pub(crate) enum ReviewStatus {
|
| 739 |
+
Approved,
|
| 740 |
+
Denied,
|
| 741 |
+
Aborted,
|
| 742 |
+
TimedOut,
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
#[allow(dead_code)]
|
| 746 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 747 |
+
#[serde(rename_all = "snake_case")]
|
| 748 |
+
pub(crate) enum ReviewResolution {
|
| 749 |
+
None,
|
| 750 |
+
SessionApproval,
|
| 751 |
+
ExecPolicyAmendment,
|
| 752 |
+
NetworkPolicyAmendment,
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
#[derive(Serialize)]
|
| 756 |
+
pub(crate) struct CodexReviewEventParams {
|
| 757 |
+
pub(crate) thread_id: String,
|
| 758 |
+
pub(crate) turn_id: String,
|
| 759 |
+
pub(crate) item_id: Option<String>,
|
| 760 |
+
pub(crate) review_id: String,
|
| 761 |
+
pub(crate) app_server_client: CodexAppServerClientMetadata,
|
| 762 |
+
pub(crate) runtime: CodexRuntimeMetadata,
|
| 763 |
+
pub(crate) thread_source: Option<ThreadSource>,
|
| 764 |
+
pub(crate) subagent_source: Option<String>,
|
| 765 |
+
pub(crate) parent_thread_id: Option<String>,
|
| 766 |
+
pub(crate) subject_kind: ReviewSubjectKind,
|
| 767 |
+
pub(crate) subject_name: String,
|
| 768 |
+
pub(crate) reviewer: Reviewer,
|
| 769 |
+
pub(crate) trigger: ReviewTrigger,
|
| 770 |
+
pub(crate) status: ReviewStatus,
|
| 771 |
+
pub(crate) resolution: ReviewResolution,
|
| 772 |
+
pub(crate) started_at_ms: u64,
|
| 773 |
+
pub(crate) completed_at_ms: u64,
|
| 774 |
+
pub(crate) duration_ms: Option<u64>,
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
#[derive(Serialize)]
|
| 778 |
+
pub(crate) struct CodexReviewEventRequest {
|
| 779 |
+
pub(crate) event_type: &'static str,
|
| 780 |
+
pub(crate) event_params: CodexReviewEventParams,
|
| 781 |
+
}
|
| 782 |
+
#[allow(dead_code)]
|
| 783 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 784 |
+
#[serde(rename_all = "snake_case")]
|
| 785 |
+
pub(crate) enum WebSearchActionKind {
|
| 786 |
+
Search,
|
| 787 |
+
OpenPage,
|
| 788 |
+
FindInPage,
|
| 789 |
+
Other,
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
#[derive(Serialize)]
|
| 793 |
+
pub(crate) struct CodexCommandExecutionEventParams {
|
| 794 |
+
pub(crate) model_slug: Option<String>,
|
| 795 |
+
pub(crate) reasoning_effort: Option<String>,
|
| 796 |
+
#[serde(flatten)]
|
| 797 |
+
pub(crate) base: CodexToolItemEventBase,
|
| 798 |
+
pub(crate) plugin_id: Option<String>,
|
| 799 |
+
pub(crate) script_path: Option<String>,
|
| 800 |
+
pub(crate) command_execution_source: CommandExecutionSource,
|
| 801 |
+
pub(crate) exit_code: Option<i32>,
|
| 802 |
+
pub(crate) command_total_action_count: u64,
|
| 803 |
+
pub(crate) command_read_action_count: u64,
|
| 804 |
+
pub(crate) command_list_files_action_count: u64,
|
| 805 |
+
pub(crate) command_search_action_count: u64,
|
| 806 |
+
pub(crate) command_unknown_action_count: u64,
|
| 807 |
+
}
|
| 808 |
+
|
| 809 |
+
#[derive(Serialize)]
|
| 810 |
+
pub(crate) struct CodexCommandExecutionEventRequest {
|
| 811 |
+
pub(crate) event_type: &'static str,
|
| 812 |
+
pub(crate) event_params: CodexCommandExecutionEventParams,
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
#[derive(Serialize)]
|
| 816 |
+
pub(crate) struct CodexPluginMeasurementEventParams {
|
| 817 |
+
pub(crate) thread_id: String,
|
| 818 |
+
pub(crate) turn_id: String,
|
| 819 |
+
pub(crate) item_id: String,
|
| 820 |
+
pub(crate) originator: String,
|
| 821 |
+
pub(crate) model_slug: Option<String>,
|
| 822 |
+
pub(crate) reasoning_effort: Option<String>,
|
| 823 |
+
pub(crate) plugin_id: String,
|
| 824 |
+
pub(crate) execution_id: String,
|
| 825 |
+
pub(crate) operation: String,
|
| 826 |
+
pub(crate) measurement_name: String,
|
| 827 |
+
pub(crate) number_value: f64,
|
| 828 |
+
pub(crate) dimensions: Option<BTreeMap<String, String>>,
|
| 829 |
+
}
|
| 830 |
+
|
| 831 |
+
#[derive(Serialize)]
|
| 832 |
+
pub(crate) struct CodexPluginMeasurementEventRequest {
|
| 833 |
+
pub(crate) event_type: &'static str,
|
| 834 |
+
pub(crate) event_params: CodexPluginMeasurementEventParams,
|
| 835 |
+
}
|
| 836 |
+
|
| 837 |
+
#[derive(Serialize)]
|
| 838 |
+
pub(crate) struct CodexFileChangeEventParams {
|
| 839 |
+
#[serde(flatten)]
|
| 840 |
+
pub(crate) base: CodexToolItemEventBase,
|
| 841 |
+
pub(crate) file_change_count: u64,
|
| 842 |
+
pub(crate) file_add_count: u64,
|
| 843 |
+
pub(crate) file_update_count: u64,
|
| 844 |
+
pub(crate) file_delete_count: u64,
|
| 845 |
+
pub(crate) file_move_count: u64,
|
| 846 |
+
}
|
| 847 |
+
|
| 848 |
+
#[derive(Serialize)]
|
| 849 |
+
pub(crate) struct CodexFileChangeEventRequest {
|
| 850 |
+
pub(crate) event_type: &'static str,
|
| 851 |
+
pub(crate) event_params: CodexFileChangeEventParams,
|
| 852 |
+
}
|
| 853 |
+
|
| 854 |
+
#[derive(Serialize)]
|
| 855 |
+
pub(crate) struct CodexMcpToolCallEventParams {
|
| 856 |
+
#[serde(flatten)]
|
| 857 |
+
pub(crate) base: CodexToolItemEventBase,
|
| 858 |
+
pub(crate) mcp_server_name: String,
|
| 859 |
+
pub(crate) mcp_tool_name: String,
|
| 860 |
+
pub(crate) mcp_error_present: bool,
|
| 861 |
+
pub(crate) plugin_id: Option<String>,
|
| 862 |
+
pub(crate) connector_id: Option<String>,
|
| 863 |
+
pub(crate) elicitation_type: Option<ElicitationType>,
|
| 864 |
+
}
|
| 865 |
+
|
| 866 |
+
#[derive(Serialize)]
|
| 867 |
+
pub(crate) struct CodexMcpToolCallEventRequest {
|
| 868 |
+
pub(crate) event_type: &'static str,
|
| 869 |
+
pub(crate) event_params: CodexMcpToolCallEventParams,
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
#[derive(Serialize)]
|
| 873 |
+
pub(crate) struct CodexDynamicToolCallEventParams {
|
| 874 |
+
#[serde(flatten)]
|
| 875 |
+
pub(crate) base: CodexToolItemEventBase,
|
| 876 |
+
pub(crate) dynamic_tool_name: String,
|
| 877 |
+
pub(crate) success: Option<bool>,
|
| 878 |
+
pub(crate) output_content_item_count: Option<u64>,
|
| 879 |
+
pub(crate) output_text_item_count: Option<u64>,
|
| 880 |
+
pub(crate) output_image_item_count: Option<u64>,
|
| 881 |
+
pub(crate) output_audio_item_count: Option<u64>,
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
#[derive(Serialize)]
|
| 885 |
+
pub(crate) struct CodexDynamicToolCallEventRequest {
|
| 886 |
+
pub(crate) event_type: &'static str,
|
| 887 |
+
pub(crate) event_params: CodexDynamicToolCallEventParams,
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
#[derive(Serialize)]
|
| 891 |
+
pub(crate) struct CodexControlToolCallEventParams {
|
| 892 |
+
#[serde(flatten)]
|
| 893 |
+
pub(crate) base: CodexToolItemEventBase,
|
| 894 |
+
pub(crate) success: bool,
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
#[derive(Serialize)]
|
| 898 |
+
pub(crate) struct CodexControlToolCallEventRequest {
|
| 899 |
+
pub(crate) event_type: &'static str,
|
| 900 |
+
pub(crate) event_params: CodexControlToolCallEventParams,
|
| 901 |
+
}
|
| 902 |
+
|
| 903 |
+
#[derive(Serialize)]
|
| 904 |
+
pub(crate) struct CodexCollabAgentToolCallEventParams {
|
| 905 |
+
#[serde(flatten)]
|
| 906 |
+
pub(crate) base: CodexToolItemEventBase,
|
| 907 |
+
pub(crate) sender_thread_id: String,
|
| 908 |
+
pub(crate) receiver_thread_count: u64,
|
| 909 |
+
pub(crate) receiver_thread_ids: Option<Vec<String>>,
|
| 910 |
+
pub(crate) requested_model: Option<String>,
|
| 911 |
+
pub(crate) requested_reasoning_effort: Option<String>,
|
| 912 |
+
pub(crate) agent_state_count: Option<u64>,
|
| 913 |
+
pub(crate) completed_agent_count: Option<u64>,
|
| 914 |
+
pub(crate) failed_agent_count: Option<u64>,
|
| 915 |
+
}
|
| 916 |
+
|
| 917 |
+
#[derive(Serialize)]
|
| 918 |
+
pub(crate) struct CodexCollabAgentToolCallEventRequest {
|
| 919 |
+
pub(crate) event_type: &'static str,
|
| 920 |
+
pub(crate) event_params: CodexCollabAgentToolCallEventParams,
|
| 921 |
+
}
|
| 922 |
+
|
| 923 |
+
#[derive(Serialize)]
|
| 924 |
+
pub(crate) struct CodexWebSearchEventParams {
|
| 925 |
+
#[serde(flatten)]
|
| 926 |
+
pub(crate) base: CodexToolItemEventBase,
|
| 927 |
+
pub(crate) web_search_action: Option<WebSearchActionKind>,
|
| 928 |
+
pub(crate) query_present: bool,
|
| 929 |
+
pub(crate) query_count: Option<u64>,
|
| 930 |
+
}
|
| 931 |
+
|
| 932 |
+
#[derive(Serialize)]
|
| 933 |
+
pub(crate) struct CodexWebSearchEventRequest {
|
| 934 |
+
pub(crate) event_type: &'static str,
|
| 935 |
+
pub(crate) event_params: CodexWebSearchEventParams,
|
| 936 |
+
}
|
| 937 |
+
|
| 938 |
+
#[derive(Serialize)]
|
| 939 |
+
pub(crate) struct CodexImageGenerationEventParams {
|
| 940 |
+
#[serde(flatten)]
|
| 941 |
+
pub(crate) base: CodexToolItemEventBase,
|
| 942 |
+
pub(crate) revised_prompt_present: bool,
|
| 943 |
+
pub(crate) saved_path_present: bool,
|
| 944 |
+
pub(crate) transparent_background: Option<bool>,
|
| 945 |
+
pub(crate) imagegen_request_id: Option<String>,
|
| 946 |
+
pub(crate) generation_id: Option<String>,
|
| 947 |
+
}
|
| 948 |
+
|
| 949 |
+
#[derive(Serialize)]
|
| 950 |
+
pub(crate) struct CodexImageGenerationEventRequest {
|
| 951 |
+
pub(crate) event_type: &'static str,
|
| 952 |
+
pub(crate) event_params: CodexImageGenerationEventParams,
|
| 953 |
+
}
|
| 954 |
+
|
| 955 |
+
#[derive(Serialize)]
|
| 956 |
+
pub(crate) struct CodexAppMetadata {
|
| 957 |
+
pub(crate) connector_id: Option<String>,
|
| 958 |
+
pub(crate) thread_id: Option<String>,
|
| 959 |
+
pub(crate) turn_id: Option<String>,
|
| 960 |
+
pub(crate) app_name: Option<String>,
|
| 961 |
+
pub(crate) product_client_id: Option<String>,
|
| 962 |
+
pub(crate) invoke_type: Option<InvocationType>,
|
| 963 |
+
pub(crate) model_slug: Option<String>,
|
| 964 |
+
}
|
| 965 |
+
|
| 966 |
+
#[derive(Serialize)]
|
| 967 |
+
pub(crate) struct CodexAppMentionedEventRequest {
|
| 968 |
+
pub(crate) event_type: &'static str,
|
| 969 |
+
pub(crate) event_params: CodexAppMetadata,
|
| 970 |
+
}
|
| 971 |
+
|
| 972 |
+
#[derive(Serialize)]
|
| 973 |
+
pub(crate) struct CodexAppUsedMetadata {
|
| 974 |
+
#[serde(flatten)]
|
| 975 |
+
pub(crate) app: CodexAppMetadata,
|
| 976 |
+
pub(crate) elicitation_type: Option<ElicitationType>,
|
| 977 |
+
}
|
| 978 |
+
|
| 979 |
+
#[derive(Serialize)]
|
| 980 |
+
pub(crate) struct CodexAppUsedEventRequest {
|
| 981 |
+
pub(crate) event_type: &'static str,
|
| 982 |
+
pub(crate) event_params: CodexAppUsedMetadata,
|
| 983 |
+
}
|
| 984 |
+
|
| 985 |
+
#[derive(Serialize)]
|
| 986 |
+
pub(crate) struct CodexHookRunMetadata {
|
| 987 |
+
pub(crate) thread_id: Option<String>,
|
| 988 |
+
pub(crate) turn_id: Option<String>,
|
| 989 |
+
pub(crate) product_client_id: Option<String>,
|
| 990 |
+
pub(crate) model_slug: Option<String>,
|
| 991 |
+
pub(crate) hook_name: Option<String>,
|
| 992 |
+
pub(crate) hook_source: Option<&'static str>,
|
| 993 |
+
pub(crate) handler_type: Option<HookHandlerType>,
|
| 994 |
+
pub(crate) execution_mode: Option<HookExecutionMode>,
|
| 995 |
+
pub(crate) status: Option<HookRunStatus>,
|
| 996 |
+
}
|
| 997 |
+
|
| 998 |
+
#[derive(Serialize)]
|
| 999 |
+
pub(crate) struct CodexHookRunEventRequest {
|
| 1000 |
+
pub(crate) event_type: &'static str,
|
| 1001 |
+
pub(crate) event_params: CodexHookRunMetadata,
|
| 1002 |
+
}
|
| 1003 |
+
|
| 1004 |
+
#[derive(Serialize)]
|
| 1005 |
+
pub(crate) struct CodexCompactionEventParams {
|
| 1006 |
+
pub(crate) thread_id: String,
|
| 1007 |
+
pub(crate) session_id: String,
|
| 1008 |
+
pub(crate) turn_id: String,
|
| 1009 |
+
pub(crate) app_server_client: CodexAppServerClientMetadata,
|
| 1010 |
+
pub(crate) runtime: CodexRuntimeMetadata,
|
| 1011 |
+
pub(crate) thread_source: Option<ThreadSource>,
|
| 1012 |
+
pub(crate) subagent_source: Option<String>,
|
| 1013 |
+
pub(crate) parent_thread_id: Option<String>,
|
| 1014 |
+
pub(crate) trigger: CompactionTrigger,
|
| 1015 |
+
pub(crate) reason: CompactionReason,
|
| 1016 |
+
pub(crate) implementation: CompactionImplementation,
|
| 1017 |
+
pub(crate) phase: CompactionPhase,
|
| 1018 |
+
pub(crate) strategy: CompactionStrategy,
|
| 1019 |
+
pub(crate) status: CompactionStatus,
|
| 1020 |
+
pub(crate) codex_error_kind: Option<CodexErrKind>,
|
| 1021 |
+
pub(crate) codex_error_http_status_code: Option<u16>,
|
| 1022 |
+
pub(crate) active_context_tokens_before: i64,
|
| 1023 |
+
pub(crate) active_context_tokens_after: i64,
|
| 1024 |
+
pub(crate) retained_image_count: Option<usize>,
|
| 1025 |
+
pub(crate) compaction_summary_tokens: Option<i64>,
|
| 1026 |
+
pub(crate) cached_input_tokens: Option<i64>,
|
| 1027 |
+
pub(crate) cache_write_input_tokens: Option<i64>,
|
| 1028 |
+
pub(crate) started_at: u64,
|
| 1029 |
+
pub(crate) completed_at: u64,
|
| 1030 |
+
pub(crate) duration_ms: Option<u64>,
|
| 1031 |
+
}
|
| 1032 |
+
|
| 1033 |
+
#[derive(Serialize)]
|
| 1034 |
+
pub(crate) struct CodexCompactionEventRequest {
|
| 1035 |
+
pub(crate) event_type: &'static str,
|
| 1036 |
+
pub(crate) event_params: CodexCompactionEventParams,
|
| 1037 |
+
}
|
| 1038 |
+
|
| 1039 |
+
#[derive(Serialize)]
|
| 1040 |
+
pub(crate) struct CodexGoalEventParams {
|
| 1041 |
+
pub(crate) thread_id: String,
|
| 1042 |
+
pub(crate) session_id: String,
|
| 1043 |
+
pub(crate) turn_id: Option<String>,
|
| 1044 |
+
pub(crate) app_server_client: CodexAppServerClientMetadata,
|
| 1045 |
+
pub(crate) runtime: CodexRuntimeMetadata,
|
| 1046 |
+
pub(crate) thread_source: Option<ThreadSource>,
|
| 1047 |
+
pub(crate) subagent_source: Option<String>,
|
| 1048 |
+
pub(crate) parent_thread_id: Option<String>,
|
| 1049 |
+
pub(crate) goal_id: String,
|
| 1050 |
+
pub(crate) event_kind: GoalEventKind,
|
| 1051 |
+
pub(crate) goal_status: codex_state::ThreadGoalStatus,
|
| 1052 |
+
pub(crate) has_token_budget: bool,
|
| 1053 |
+
pub(crate) cumulative_tokens_accounted: Option<i64>,
|
| 1054 |
+
pub(crate) cumulative_time_accounted_seconds: Option<i64>,
|
| 1055 |
+
}
|
| 1056 |
+
|
| 1057 |
+
#[derive(Serialize)]
|
| 1058 |
+
pub(crate) struct CodexGoalEventRequest {
|
| 1059 |
+
pub(crate) event_type: &'static str,
|
| 1060 |
+
pub(crate) event_params: CodexGoalEventParams,
|
| 1061 |
+
}
|
| 1062 |
+
|
| 1063 |
+
#[derive(Serialize)]
|
| 1064 |
+
pub(crate) struct CodexTurnEventParams {
|
| 1065 |
+
pub(crate) thread_id: String,
|
| 1066 |
+
pub(crate) session_id: String,
|
| 1067 |
+
pub(crate) turn_id: String,
|
| 1068 |
+
pub(crate) root_turn_id: Option<String>,
|
| 1069 |
+
pub(crate) turn_trigger: Option<String>,
|
| 1070 |
+
pub(crate) codex_turn_source: Option<String>,
|
| 1071 |
+
// TODO(rhan-oai): Populate once queued/default submission type is plumbed from
|
| 1072 |
+
// the turn/start callsites instead of always being reported as None.
|
| 1073 |
+
pub(crate) submission_type: Option<TurnSubmissionType>,
|
| 1074 |
+
pub(crate) app_server_client: CodexAppServerClientMetadata,
|
| 1075 |
+
pub(crate) runtime: CodexRuntimeMetadata,
|
| 1076 |
+
pub(crate) ephemeral: bool,
|
| 1077 |
+
pub(crate) thread_source: Option<ThreadSource>,
|
| 1078 |
+
pub(crate) initialization_mode: ThreadInitializationMode,
|
| 1079 |
+
pub(crate) subagent_source: Option<String>,
|
| 1080 |
+
pub(crate) parent_thread_id: Option<String>,
|
| 1081 |
+
pub(crate) model: Option<String>,
|
| 1082 |
+
pub(crate) model_provider: String,
|
| 1083 |
+
pub(crate) sandbox_policy: Option<&'static str>,
|
| 1084 |
+
pub(crate) reasoning_effort: Option<String>,
|
| 1085 |
+
pub(crate) reasoning_summary: Option<String>,
|
| 1086 |
+
pub(crate) service_tier: String,
|
| 1087 |
+
pub(crate) approval_policy: String,
|
| 1088 |
+
pub(crate) approvals_reviewer: String,
|
| 1089 |
+
pub(crate) guardian_v2_enabled: bool,
|
| 1090 |
+
pub(crate) sandbox_network_access: bool,
|
| 1091 |
+
pub(crate) collaboration_mode: Option<&'static str>,
|
| 1092 |
+
pub(crate) personality: Option<String>,
|
| 1093 |
+
pub(crate) workspace_kind: Option<String>,
|
| 1094 |
+
pub(crate) num_input_images: usize,
|
| 1095 |
+
pub(crate) image_preparations: Vec<ImagePreparationMetadata>,
|
| 1096 |
+
pub(crate) is_first_turn: bool,
|
| 1097 |
+
pub(crate) status: Option<TurnStatus>,
|
| 1098 |
+
/// Client wall-clock time for the first non-startup turn/interrupt request
|
| 1099 |
+
/// that later received a successful response.
|
| 1100 |
+
pub(crate) explicit_client_interrupt_requested_at_ms: Option<u64>,
|
| 1101 |
+
pub(crate) turn_error: Option<CodexErrorInfo>,
|
| 1102 |
+
pub(crate) codex_error_kind: Option<CodexErrKind>,
|
| 1103 |
+
pub(crate) codex_error_http_status_code: Option<u16>,
|
| 1104 |
+
pub(crate) steer_count: Option<usize>,
|
| 1105 |
+
pub(crate) total_tool_call_count: Option<usize>,
|
| 1106 |
+
pub(crate) shell_command_count: Option<usize>,
|
| 1107 |
+
pub(crate) file_change_count: Option<usize>,
|
| 1108 |
+
pub(crate) mcp_tool_call_count: Option<usize>,
|
| 1109 |
+
pub(crate) dynamic_tool_call_count: Option<usize>,
|
| 1110 |
+
pub(crate) subagent_tool_call_count: Option<usize>,
|
| 1111 |
+
pub(crate) web_search_count: Option<usize>,
|
| 1112 |
+
pub(crate) image_generation_count: Option<usize>,
|
| 1113 |
+
pub(crate) input_tokens: Option<i64>,
|
| 1114 |
+
pub(crate) cached_input_tokens: Option<i64>,
|
| 1115 |
+
pub(crate) cache_write_input_tokens: Option<i64>,
|
| 1116 |
+
pub(crate) output_tokens: Option<i64>,
|
| 1117 |
+
pub(crate) reasoning_output_tokens: Option<i64>,
|
| 1118 |
+
pub(crate) total_tokens: Option<i64>,
|
| 1119 |
+
pub(crate) before_first_sampling_ms: u64,
|
| 1120 |
+
pub(crate) sampling_ms: u64,
|
| 1121 |
+
pub(crate) compaction_ms: u64,
|
| 1122 |
+
pub(crate) between_sampling_overhead_ms: u64,
|
| 1123 |
+
pub(crate) tool_blocking_ms: u64,
|
| 1124 |
+
pub(crate) after_last_sampling_ms: u64,
|
| 1125 |
+
pub(crate) sampling_request_count: u32,
|
| 1126 |
+
pub(crate) sampling_retry_count: u32,
|
| 1127 |
+
pub(crate) duration_ms: Option<u64>,
|
| 1128 |
+
pub(crate) started_at: Option<u64>,
|
| 1129 |
+
pub(crate) completed_at: Option<u64>,
|
| 1130 |
+
}
|
| 1131 |
+
|
| 1132 |
+
#[derive(Serialize)]
|
| 1133 |
+
pub(crate) struct CodexTurnEventRequest {
|
| 1134 |
+
pub(crate) event_type: &'static str,
|
| 1135 |
+
pub(crate) event_params: CodexTurnEventParams,
|
| 1136 |
+
}
|
| 1137 |
+
|
| 1138 |
+
#[derive(Serialize)]
|
| 1139 |
+
pub(crate) struct CodexTurnSteerEventParams {
|
| 1140 |
+
pub(crate) thread_id: String,
|
| 1141 |
+
pub(crate) session_id: String,
|
| 1142 |
+
pub(crate) expected_turn_id: Option<String>,
|
| 1143 |
+
pub(crate) accepted_turn_id: Option<String>,
|
| 1144 |
+
pub(crate) app_server_client: CodexAppServerClientMetadata,
|
| 1145 |
+
pub(crate) runtime: CodexRuntimeMetadata,
|
| 1146 |
+
pub(crate) thread_source: Option<ThreadSource>,
|
| 1147 |
+
pub(crate) subagent_source: Option<String>,
|
| 1148 |
+
pub(crate) parent_thread_id: Option<String>,
|
| 1149 |
+
pub(crate) num_input_images: usize,
|
| 1150 |
+
pub(crate) result: TurnSteerResult,
|
| 1151 |
+
pub(crate) rejection_reason: Option<TurnSteerRejectionReason>,
|
| 1152 |
+
pub(crate) created_at: u64,
|
| 1153 |
+
}
|
| 1154 |
+
|
| 1155 |
+
#[derive(Serialize)]
|
| 1156 |
+
pub(crate) struct CodexTurnSteerEventRequest {
|
| 1157 |
+
pub(crate) event_type: &'static str,
|
| 1158 |
+
pub(crate) event_params: CodexTurnSteerEventParams,
|
| 1159 |
+
}
|
| 1160 |
+
|
| 1161 |
+
#[derive(Serialize)]
|
| 1162 |
+
pub(crate) struct CodexPluginMetadata {
|
| 1163 |
+
pub(crate) plugin_id: Option<String>,
|
| 1164 |
+
pub(crate) remote_plugin_id: Option<String>,
|
| 1165 |
+
pub(crate) plugin_name: Option<String>,
|
| 1166 |
+
pub(crate) marketplace_name: Option<String>,
|
| 1167 |
+
pub(crate) has_skills: Option<bool>,
|
| 1168 |
+
pub(crate) mcp_server_count: Option<usize>,
|
| 1169 |
+
pub(crate) connector_ids: Option<Vec<String>>,
|
| 1170 |
+
pub(crate) product_client_id: Option<String>,
|
| 1171 |
+
}
|
| 1172 |
+
|
| 1173 |
+
#[derive(Serialize)]
|
| 1174 |
+
pub(crate) struct CodexPluginUsedMetadata {
|
| 1175 |
+
#[serde(flatten)]
|
| 1176 |
+
pub(crate) plugin: CodexPluginMetadata,
|
| 1177 |
+
pub(crate) mcp_server_names: Option<Vec<String>>,
|
| 1178 |
+
pub(crate) thread_id: Option<String>,
|
| 1179 |
+
pub(crate) turn_id: Option<String>,
|
| 1180 |
+
pub(crate) model_slug: Option<String>,
|
| 1181 |
+
}
|
| 1182 |
+
|
| 1183 |
+
#[derive(Serialize)]
|
| 1184 |
+
pub(crate) struct CodexPluginInstallRequestedPluginMetadata {
|
| 1185 |
+
pub(crate) plugin_id: String,
|
| 1186 |
+
pub(crate) remote_plugin_id: Option<String>,
|
| 1187 |
+
pub(crate) plugin_name: String,
|
| 1188 |
+
pub(crate) connector_ids: Vec<String>,
|
| 1189 |
+
}
|
| 1190 |
+
|
| 1191 |
+
#[derive(Serialize)]
|
| 1192 |
+
pub(crate) struct CodexPluginInstallRequestedMetadata {
|
| 1193 |
+
pub(crate) suggestion_id: String,
|
| 1194 |
+
pub(crate) plugins: Vec<CodexPluginInstallRequestedPluginMetadata>,
|
| 1195 |
+
pub(crate) source: crate::facts::PluginInstallRequestSource,
|
| 1196 |
+
pub(crate) thread_id: String,
|
| 1197 |
+
pub(crate) turn_id: String,
|
| 1198 |
+
pub(crate) model_slug: String,
|
| 1199 |
+
pub(crate) product_client_id: Option<String>,
|
| 1200 |
+
}
|
| 1201 |
+
|
| 1202 |
+
#[derive(Serialize)]
|
| 1203 |
+
pub(crate) struct CodexPluginInstallRequestedEventRequest {
|
| 1204 |
+
pub(crate) event_type: &'static str,
|
| 1205 |
+
pub(crate) event_params: CodexPluginInstallRequestedMetadata,
|
| 1206 |
+
}
|
| 1207 |
+
|
| 1208 |
+
#[derive(Serialize)]
|
| 1209 |
+
pub(crate) struct CodexPluginEventRequest {
|
| 1210 |
+
pub(crate) event_type: &'static str,
|
| 1211 |
+
pub(crate) event_params: CodexPluginMetadata,
|
| 1212 |
+
}
|
| 1213 |
+
|
| 1214 |
+
#[derive(Serialize)]
|
| 1215 |
+
pub(crate) struct CodexPluginInstallFailedMetadata {
|
| 1216 |
+
#[serde(flatten)]
|
| 1217 |
+
pub(crate) plugin: CodexPluginMetadata,
|
| 1218 |
+
pub(crate) source: crate::facts::PluginInstallSource,
|
| 1219 |
+
pub(crate) error_type: String,
|
| 1220 |
+
pub(crate) sub_error_type: Option<String>,
|
| 1221 |
+
}
|
| 1222 |
+
|
| 1223 |
+
#[derive(Serialize)]
|
| 1224 |
+
pub(crate) struct CodexPluginInstallFailedEventRequest {
|
| 1225 |
+
pub(crate) event_type: &'static str,
|
| 1226 |
+
pub(crate) event_params: CodexPluginInstallFailedMetadata,
|
| 1227 |
+
}
|
| 1228 |
+
|
| 1229 |
+
#[derive(Serialize)]
|
| 1230 |
+
pub(crate) struct CodexOnboardingExternalAgentImportCompleteMetadata {
|
| 1231 |
+
pub(crate) import_id: String,
|
| 1232 |
+
pub(crate) source: String,
|
| 1233 |
+
pub(crate) provider_id: String,
|
| 1234 |
+
#[serde(rename = "type")]
|
| 1235 |
+
pub(crate) item_type: String,
|
| 1236 |
+
pub(crate) success_count: usize,
|
| 1237 |
+
pub(crate) failed_count: usize,
|
| 1238 |
+
pub(crate) product_client_id: Option<String>,
|
| 1239 |
+
}
|
| 1240 |
+
|
| 1241 |
+
#[derive(Serialize)]
|
| 1242 |
+
pub(crate) struct CodexOnboardingExternalAgentImportCompleteEventRequest {
|
| 1243 |
+
pub(crate) event_type: &'static str,
|
| 1244 |
+
pub(crate) event_params: CodexOnboardingExternalAgentImportCompleteMetadata,
|
| 1245 |
+
}
|
| 1246 |
+
|
| 1247 |
+
#[derive(Serialize)]
|
| 1248 |
+
pub(crate) struct CodexOnboardingExternalAgentImportFailureMetadata {
|
| 1249 |
+
pub(crate) import_id: String,
|
| 1250 |
+
pub(crate) source: String,
|
| 1251 |
+
pub(crate) provider_id: String,
|
| 1252 |
+
#[serde(rename = "type")]
|
| 1253 |
+
pub(crate) item_type: String,
|
| 1254 |
+
pub(crate) failure_stage: String,
|
| 1255 |
+
pub(crate) error_type: String,
|
| 1256 |
+
pub(crate) sub_error_type: Option<String>,
|
| 1257 |
+
pub(crate) product_client_id: Option<String>,
|
| 1258 |
+
}
|
| 1259 |
+
|
| 1260 |
+
#[derive(Serialize)]
|
| 1261 |
+
pub(crate) struct CodexOnboardingExternalAgentImportFailureEventRequest {
|
| 1262 |
+
pub(crate) event_type: &'static str,
|
| 1263 |
+
pub(crate) event_params: CodexOnboardingExternalAgentImportFailureMetadata,
|
| 1264 |
+
}
|
| 1265 |
+
|
| 1266 |
+
#[derive(Serialize)]
|
| 1267 |
+
pub(crate) struct CodexPluginUsedEventRequest {
|
| 1268 |
+
pub(crate) event_type: &'static str,
|
| 1269 |
+
pub(crate) event_params: CodexPluginUsedMetadata,
|
| 1270 |
+
}
|
| 1271 |
+
|
| 1272 |
+
pub(crate) fn plugin_state_event_type(state: PluginState) -> &'static str {
|
| 1273 |
+
match state {
|
| 1274 |
+
PluginState::Installed => "codex_plugin_installed",
|
| 1275 |
+
PluginState::Uninstalled => "codex_plugin_uninstalled",
|
| 1276 |
+
PluginState::Enabled => "codex_plugin_enabled",
|
| 1277 |
+
PluginState::Disabled => "codex_plugin_disabled",
|
| 1278 |
+
}
|
| 1279 |
+
}
|
| 1280 |
+
|
| 1281 |
+
pub(crate) fn codex_app_metadata(
|
| 1282 |
+
tracking: &TrackEventsContext,
|
| 1283 |
+
app: AppInvocation,
|
| 1284 |
+
) -> CodexAppMetadata {
|
| 1285 |
+
CodexAppMetadata {
|
| 1286 |
+
connector_id: app.connector_id,
|
| 1287 |
+
thread_id: Some(tracking.thread_id.clone()),
|
| 1288 |
+
turn_id: Some(tracking.turn_id.clone()),
|
| 1289 |
+
app_name: app.app_name,
|
| 1290 |
+
product_client_id: Some(tracking.product_client_id.clone()),
|
| 1291 |
+
invoke_type: app.invocation_type,
|
| 1292 |
+
model_slug: Some(tracking.model_slug.clone()),
|
| 1293 |
+
}
|
| 1294 |
+
}
|
| 1295 |
+
|
| 1296 |
+
pub(crate) fn codex_plugin_metadata(plugin: PluginTelemetryMetadata) -> CodexPluginMetadata {
|
| 1297 |
+
codex_plugin_metadata_with_product_client_id(plugin, originator().value)
|
| 1298 |
+
}
|
| 1299 |
+
|
| 1300 |
+
fn codex_plugin_metadata_with_product_client_id(
|
| 1301 |
+
plugin: PluginTelemetryMetadata,
|
| 1302 |
+
product_client_id: String,
|
| 1303 |
+
) -> CodexPluginMetadata {
|
| 1304 |
+
let PluginTelemetryMetadata {
|
| 1305 |
+
plugin_id,
|
| 1306 |
+
remote_plugin_id,
|
| 1307 |
+
capability_summary,
|
| 1308 |
+
} = plugin;
|
| 1309 |
+
CodexPluginMetadata {
|
| 1310 |
+
plugin_id: plugin_id.as_ref().map(PluginId::as_key),
|
| 1311 |
+
remote_plugin_id,
|
| 1312 |
+
plugin_name: plugin_id
|
| 1313 |
+
.as_ref()
|
| 1314 |
+
.map(|plugin_id| plugin_id.plugin_name.clone()),
|
| 1315 |
+
marketplace_name: plugin_id.map(|plugin_id| plugin_id.marketplace_name),
|
| 1316 |
+
has_skills: capability_summary
|
| 1317 |
+
.as_ref()
|
| 1318 |
+
.map(|summary| summary.has_skills),
|
| 1319 |
+
mcp_server_count: capability_summary
|
| 1320 |
+
.as_ref()
|
| 1321 |
+
.map(|summary| summary.mcp_server_names.len()),
|
| 1322 |
+
connector_ids: capability_summary.map(|summary| {
|
| 1323 |
+
summary
|
| 1324 |
+
.app_connector_ids
|
| 1325 |
+
.into_iter()
|
| 1326 |
+
.map(|connector_id| connector_id.0)
|
| 1327 |
+
.collect()
|
| 1328 |
+
}),
|
| 1329 |
+
product_client_id: Some(product_client_id),
|
| 1330 |
+
}
|
| 1331 |
+
}
|
| 1332 |
+
|
| 1333 |
+
pub(crate) fn codex_plugin_install_requested_metadata(
|
| 1334 |
+
tracking: &TrackEventsContext,
|
| 1335 |
+
request: PluginInstallRequested,
|
| 1336 |
+
) -> CodexPluginInstallRequestedMetadata {
|
| 1337 |
+
CodexPluginInstallRequestedMetadata {
|
| 1338 |
+
suggestion_id: request.suggestion_id,
|
| 1339 |
+
plugins: request
|
| 1340 |
+
.plugins
|
| 1341 |
+
.into_iter()
|
| 1342 |
+
.map(|plugin| CodexPluginInstallRequestedPluginMetadata {
|
| 1343 |
+
plugin_id: plugin.plugin_id,
|
| 1344 |
+
remote_plugin_id: plugin.remote_plugin_id,
|
| 1345 |
+
plugin_name: plugin.plugin_name,
|
| 1346 |
+
connector_ids: plugin.connector_ids,
|
| 1347 |
+
})
|
| 1348 |
+
.collect(),
|
| 1349 |
+
source: request.source,
|
| 1350 |
+
thread_id: tracking.thread_id.clone(),
|
| 1351 |
+
turn_id: tracking.turn_id.clone(),
|
| 1352 |
+
model_slug: tracking.model_slug.clone(),
|
| 1353 |
+
product_client_id: Some(originator().value),
|
| 1354 |
+
}
|
| 1355 |
+
}
|
| 1356 |
+
|
| 1357 |
+
pub(crate) fn codex_compaction_event_params(
|
| 1358 |
+
input: CodexCompactionEvent,
|
| 1359 |
+
session_id: String,
|
| 1360 |
+
app_server_client: CodexAppServerClientMetadata,
|
| 1361 |
+
runtime: CodexRuntimeMetadata,
|
| 1362 |
+
thread_source: Option<ThreadSource>,
|
| 1363 |
+
subagent_source: Option<String>,
|
| 1364 |
+
parent_thread_id: Option<String>,
|
| 1365 |
+
) -> CodexCompactionEventParams {
|
| 1366 |
+
CodexCompactionEventParams {
|
| 1367 |
+
thread_id: input.thread_id,
|
| 1368 |
+
session_id,
|
| 1369 |
+
turn_id: input.turn_id,
|
| 1370 |
+
app_server_client,
|
| 1371 |
+
runtime,
|
| 1372 |
+
thread_source,
|
| 1373 |
+
subagent_source,
|
| 1374 |
+
parent_thread_id,
|
| 1375 |
+
trigger: input.trigger,
|
| 1376 |
+
reason: input.reason,
|
| 1377 |
+
implementation: input.implementation,
|
| 1378 |
+
phase: input.phase,
|
| 1379 |
+
strategy: input.strategy,
|
| 1380 |
+
status: input.status,
|
| 1381 |
+
codex_error_kind: input.codex_error_kind,
|
| 1382 |
+
codex_error_http_status_code: input.codex_error_http_status_code,
|
| 1383 |
+
active_context_tokens_before: input.active_context_tokens_before,
|
| 1384 |
+
active_context_tokens_after: input.active_context_tokens_after,
|
| 1385 |
+
retained_image_count: input.retained_image_count,
|
| 1386 |
+
compaction_summary_tokens: input.compaction_summary_tokens,
|
| 1387 |
+
cached_input_tokens: input.cached_input_tokens,
|
| 1388 |
+
cache_write_input_tokens: input.cache_write_input_tokens,
|
| 1389 |
+
started_at: input.started_at,
|
| 1390 |
+
completed_at: input.completed_at,
|
| 1391 |
+
duration_ms: input.duration_ms,
|
| 1392 |
+
}
|
| 1393 |
+
}
|
| 1394 |
+
|
| 1395 |
+
pub(crate) fn codex_goal_event_params(
|
| 1396 |
+
input: CodexGoalEvent,
|
| 1397 |
+
session_id: String,
|
| 1398 |
+
app_server_client: CodexAppServerClientMetadata,
|
| 1399 |
+
runtime: CodexRuntimeMetadata,
|
| 1400 |
+
thread_source: Option<ThreadSource>,
|
| 1401 |
+
subagent_source: Option<String>,
|
| 1402 |
+
parent_thread_id: Option<String>,
|
| 1403 |
+
) -> CodexGoalEventParams {
|
| 1404 |
+
CodexGoalEventParams {
|
| 1405 |
+
thread_id: input.thread_id,
|
| 1406 |
+
session_id,
|
| 1407 |
+
turn_id: input.turn_id,
|
| 1408 |
+
app_server_client,
|
| 1409 |
+
runtime,
|
| 1410 |
+
thread_source,
|
| 1411 |
+
subagent_source,
|
| 1412 |
+
parent_thread_id,
|
| 1413 |
+
goal_id: input.goal_id,
|
| 1414 |
+
event_kind: input.event_kind,
|
| 1415 |
+
goal_status: input.goal_status,
|
| 1416 |
+
has_token_budget: input.has_token_budget,
|
| 1417 |
+
cumulative_tokens_accounted: input.cumulative_tokens_accounted,
|
| 1418 |
+
cumulative_time_accounted_seconds: input.cumulative_time_accounted_seconds,
|
| 1419 |
+
}
|
| 1420 |
+
}
|
| 1421 |
+
|
| 1422 |
+
pub(crate) fn codex_plugin_used_metadata(
|
| 1423 |
+
tracking: &TrackEventsContext,
|
| 1424 |
+
plugin: PluginTelemetryMetadata,
|
| 1425 |
+
) -> CodexPluginUsedMetadata {
|
| 1426 |
+
let mcp_server_names = plugin
|
| 1427 |
+
.capability_summary
|
| 1428 |
+
.as_ref()
|
| 1429 |
+
.map(|summary| summary.mcp_server_names.clone());
|
| 1430 |
+
CodexPluginUsedMetadata {
|
| 1431 |
+
plugin: codex_plugin_metadata_with_product_client_id(
|
| 1432 |
+
plugin,
|
| 1433 |
+
tracking.product_client_id.clone(),
|
| 1434 |
+
),
|
| 1435 |
+
mcp_server_names,
|
| 1436 |
+
thread_id: Some(tracking.thread_id.clone()),
|
| 1437 |
+
turn_id: Some(tracking.turn_id.clone()),
|
| 1438 |
+
model_slug: Some(tracking.model_slug.clone()),
|
| 1439 |
+
}
|
| 1440 |
+
}
|
| 1441 |
+
|
| 1442 |
+
pub(crate) fn codex_hook_run_metadata(
|
| 1443 |
+
tracking: &TrackEventsContext,
|
| 1444 |
+
hook: HookRunFact,
|
| 1445 |
+
) -> CodexHookRunMetadata {
|
| 1446 |
+
CodexHookRunMetadata {
|
| 1447 |
+
thread_id: Some(tracking.thread_id.clone()),
|
| 1448 |
+
turn_id: Some(tracking.turn_id.clone()),
|
| 1449 |
+
product_client_id: Some(tracking.product_client_id.clone()),
|
| 1450 |
+
model_slug: Some(tracking.model_slug.clone()),
|
| 1451 |
+
hook_name: Some(analytics_hook_event_name(hook.event_name).to_owned()),
|
| 1452 |
+
hook_source: Some(analytics_hook_source(hook.hook_source)),
|
| 1453 |
+
handler_type: Some(hook.handler_type),
|
| 1454 |
+
execution_mode: Some(hook.execution_mode),
|
| 1455 |
+
status: Some(analytics_hook_status(hook.status)),
|
| 1456 |
+
}
|
| 1457 |
+
}
|
| 1458 |
+
|
| 1459 |
+
fn analytics_hook_event_name(event_name: HookEventName) -> &'static str {
|
| 1460 |
+
match event_name {
|
| 1461 |
+
HookEventName::PreToolUse => "PreToolUse",
|
| 1462 |
+
HookEventName::PermissionRequest => "PermissionRequest",
|
| 1463 |
+
HookEventName::PostToolUse => "PostToolUse",
|
| 1464 |
+
HookEventName::PreCompact => "PreCompact",
|
| 1465 |
+
HookEventName::PostCompact => "PostCompact",
|
| 1466 |
+
HookEventName::SessionStart => "SessionStart",
|
| 1467 |
+
HookEventName::SessionEnd => "SessionEnd",
|
| 1468 |
+
HookEventName::UserPromptSubmit => "UserPromptSubmit",
|
| 1469 |
+
HookEventName::SubagentStart => "SubagentStart",
|
| 1470 |
+
HookEventName::SubagentStop => "SubagentStop",
|
| 1471 |
+
HookEventName::Stop => "Stop",
|
| 1472 |
+
HookEventName::Interrupt => "Interrupt",
|
| 1473 |
+
}
|
| 1474 |
+
}
|
| 1475 |
+
|
| 1476 |
+
fn analytics_hook_source(source: HookSource) -> &'static str {
|
| 1477 |
+
match source {
|
| 1478 |
+
HookSource::System => "system",
|
| 1479 |
+
HookSource::User => "user",
|
| 1480 |
+
HookSource::Project => "project",
|
| 1481 |
+
HookSource::Mdm => "mdm",
|
| 1482 |
+
HookSource::SessionFlags => "session_flags",
|
| 1483 |
+
HookSource::Plugin => "plugin",
|
| 1484 |
+
HookSource::CloudRequirements => "cloud_requirements",
|
| 1485 |
+
HookSource::CloudManagedConfig => "cloud_managed_config",
|
| 1486 |
+
HookSource::LegacyManagedConfigFile => "legacy_managed_config_file",
|
| 1487 |
+
HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm",
|
| 1488 |
+
HookSource::Unknown => "unknown",
|
| 1489 |
+
}
|
| 1490 |
+
}
|
| 1491 |
+
|
| 1492 |
+
pub(crate) fn current_runtime_metadata() -> CodexRuntimeMetadata {
|
| 1493 |
+
let os_info = os_info::get();
|
| 1494 |
+
CodexRuntimeMetadata {
|
| 1495 |
+
codex_rs_version: env!("CARGO_PKG_VERSION").to_string(),
|
| 1496 |
+
runtime_os: std::env::consts::OS.to_string(),
|
| 1497 |
+
runtime_os_version: os_info.version().to_string(),
|
| 1498 |
+
runtime_arch: std::env::consts::ARCH.to_string(),
|
| 1499 |
+
}
|
| 1500 |
+
}
|
| 1501 |
+
|
| 1502 |
+
pub(crate) fn subagent_thread_started_event_request(
|
| 1503 |
+
input: SubAgentThreadStartedInput,
|
| 1504 |
+
) -> ThreadInitializedEvent {
|
| 1505 |
+
let event_params = ThreadInitializedEventParams {
|
| 1506 |
+
thread_id: input.thread_id,
|
| 1507 |
+
session_id: input.session_id,
|
| 1508 |
+
app_server_client: CodexAppServerClientMetadata {
|
| 1509 |
+
product_client_id: input.product_client_id,
|
| 1510 |
+
client_name: input.client_name,
|
| 1511 |
+
client_version: input.client_version,
|
| 1512 |
+
rpc_transport: AppServerRpcTransport::InProcess,
|
| 1513 |
+
experimental_api_enabled: None,
|
| 1514 |
+
},
|
| 1515 |
+
runtime: current_runtime_metadata(),
|
| 1516 |
+
model: input.model,
|
| 1517 |
+
ephemeral: input.ephemeral,
|
| 1518 |
+
is_worktree: None,
|
| 1519 |
+
thread_source: input.thread_source,
|
| 1520 |
+
initialization_mode: ThreadInitializationMode::New,
|
| 1521 |
+
subagent_source: Some(subagent_source_name(&input.subagent_source)),
|
| 1522 |
+
parent_thread_id: input.parent_thread_id,
|
| 1523 |
+
forked_from_thread_id: input.forked_from_thread_id,
|
| 1524 |
+
created_at: input.created_at,
|
| 1525 |
+
};
|
| 1526 |
+
ThreadInitializedEvent {
|
| 1527 |
+
event_type: "codex_thread_initialized",
|
| 1528 |
+
event_params,
|
| 1529 |
+
}
|
| 1530 |
+
}
|
| 1531 |
+
|
| 1532 |
+
pub(crate) fn subagent_source_name(subagent_source: &SubAgentSource) -> String {
|
| 1533 |
+
subagent_source.kind().to_string()
|
| 1534 |
+
}
|
| 1535 |
+
|
| 1536 |
+
fn analytics_hook_status(status: HookRunStatus) -> HookRunStatus {
|
| 1537 |
+
match status {
|
| 1538 |
+
// Running is unexpected here and normalized defensively.
|
| 1539 |
+
HookRunStatus::Running => HookRunStatus::Failed,
|
| 1540 |
+
other => other,
|
| 1541 |
+
}
|
| 1542 |
+
}
|
codex-rs/analytics/src/facts.rs
ADDED
|
@@ -0,0 +1,742 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use crate::events::AppServerRpcTransport;
|
| 2 |
+
use crate::events::CodexRuntimeMetadata;
|
| 3 |
+
use crate::events::GuardianReviewEventParams;
|
| 4 |
+
use crate::guardian_v2::GuardianV2Event;
|
| 5 |
+
use codex_app_server_protocol::ClientRequest;
|
| 6 |
+
use codex_app_server_protocol::ClientResponsePayload;
|
| 7 |
+
use codex_app_server_protocol::InitializeParams;
|
| 8 |
+
use codex_app_server_protocol::JSONRPCErrorError;
|
| 9 |
+
use codex_app_server_protocol::RequestId;
|
| 10 |
+
use codex_app_server_protocol::ServerNotification;
|
| 11 |
+
use codex_app_server_protocol::ServerRequest;
|
| 12 |
+
use codex_app_server_protocol::ServerResponse;
|
| 13 |
+
use codex_plugin::PluginTelemetryMetadata;
|
| 14 |
+
use codex_protocol::config_types::ApprovalsReviewer;
|
| 15 |
+
use codex_protocol::config_types::ModeKind;
|
| 16 |
+
use codex_protocol::config_types::Personality;
|
| 17 |
+
use codex_protocol::config_types::ReasoningSummary;
|
| 18 |
+
use codex_protocol::config_types::ServiceTier;
|
| 19 |
+
use codex_protocol::error::CodexErr;
|
| 20 |
+
pub use codex_protocol::error::CodexErrKind;
|
| 21 |
+
use codex_protocol::models::PermissionProfile;
|
| 22 |
+
use codex_protocol::openai_models::ReasoningEffort;
|
| 23 |
+
use codex_protocol::protocol::AskForApproval;
|
| 24 |
+
use codex_protocol::protocol::HookEventName;
|
| 25 |
+
use codex_protocol::protocol::HookExecutionMode;
|
| 26 |
+
use codex_protocol::protocol::HookHandlerType;
|
| 27 |
+
use codex_protocol::protocol::HookRunStatus;
|
| 28 |
+
use codex_protocol::protocol::HookSource;
|
| 29 |
+
use codex_protocol::protocol::SessionSource;
|
| 30 |
+
use codex_protocol::protocol::SkillScope;
|
| 31 |
+
use codex_protocol::protocol::SubAgentSource;
|
| 32 |
+
use codex_protocol::protocol::ThreadSource;
|
| 33 |
+
use codex_protocol::protocol::TokenUsage;
|
| 34 |
+
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
| 35 |
+
use serde::Serialize;
|
| 36 |
+
use std::collections::BTreeMap;
|
| 37 |
+
use std::path::PathBuf;
|
| 38 |
+
use std::sync::Arc;
|
| 39 |
+
|
| 40 |
+
#[derive(Clone)]
|
| 41 |
+
pub struct TrackEventsContext {
|
| 42 |
+
pub model_slug: String,
|
| 43 |
+
pub thread_id: String,
|
| 44 |
+
pub turn_id: String,
|
| 45 |
+
pub product_client_id: String,
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
| 49 |
+
#[serde(rename_all = "snake_case")]
|
| 50 |
+
pub enum ArtifactOperationLifecycle {
|
| 51 |
+
Started,
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
#[derive(Clone, Debug, PartialEq, Eq)]
|
| 55 |
+
pub struct ArtifactOperation {
|
| 56 |
+
pub item_id: String,
|
| 57 |
+
pub lifecycle: ArtifactOperationLifecycle,
|
| 58 |
+
pub occurred_at_ms: u64,
|
| 59 |
+
pub plugin_id: String,
|
| 60 |
+
pub script_path: String,
|
| 61 |
+
pub skill: String,
|
| 62 |
+
pub artifact_type: String,
|
| 63 |
+
pub operation_kind: String,
|
| 64 |
+
pub expected_output_count: u32,
|
| 65 |
+
pub output_format: String,
|
| 66 |
+
pub execution_backend: String,
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
#[derive(Clone)]
|
| 70 |
+
pub enum CodeModeToolCallFact {
|
| 71 |
+
CellStarted {
|
| 72 |
+
thread_id: String,
|
| 73 |
+
turn_id: String,
|
| 74 |
+
call_id: String,
|
| 75 |
+
cell_id: String,
|
| 76 |
+
},
|
| 77 |
+
ChildStarted {
|
| 78 |
+
thread_id: String,
|
| 79 |
+
turn_id: String,
|
| 80 |
+
call_id: String,
|
| 81 |
+
cell_id: String,
|
| 82 |
+
},
|
| 83 |
+
CellClosed {
|
| 84 |
+
thread_id: String,
|
| 85 |
+
turn_id: String,
|
| 86 |
+
cell_id: String,
|
| 87 |
+
},
|
| 88 |
+
SamplingResponseCompleted {
|
| 89 |
+
thread_id: String,
|
| 90 |
+
turn_id: String,
|
| 91 |
+
response_id: String,
|
| 92 |
+
tool_call_ids: Vec<String>,
|
| 93 |
+
},
|
| 94 |
+
Completed {
|
| 95 |
+
thread_id: String,
|
| 96 |
+
turn_id: String,
|
| 97 |
+
turn_metadata: Arc<dyn TurnAnalyticsMetadata>,
|
| 98 |
+
call_id: String,
|
| 99 |
+
cell_id: Option<String>,
|
| 100 |
+
tool_name: String,
|
| 101 |
+
started_at_ms: u64,
|
| 102 |
+
completed_at_ms: u64,
|
| 103 |
+
status: CodeModeToolCallStatus,
|
| 104 |
+
},
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
| 108 |
+
pub enum CodeModeToolCallStatus {
|
| 109 |
+
Completed,
|
| 110 |
+
Failed,
|
| 111 |
+
Interrupted,
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
#[derive(Clone)]
|
| 115 |
+
pub struct ControlToolCallFact {
|
| 116 |
+
pub thread_id: String,
|
| 117 |
+
pub turn_id: String,
|
| 118 |
+
pub turn_metadata: Arc<dyn TurnAnalyticsMetadata>,
|
| 119 |
+
pub call_id: String,
|
| 120 |
+
pub cell_id: Option<String>,
|
| 121 |
+
pub tool_name: String,
|
| 122 |
+
pub started_at_ms: u64,
|
| 123 |
+
pub completed_at_ms: u64,
|
| 124 |
+
pub status: ControlToolCallStatus,
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
| 128 |
+
pub enum ControlToolCallStatus {
|
| 129 |
+
Completed,
|
| 130 |
+
Failed,
|
| 131 |
+
Rejected,
|
| 132 |
+
Interrupted,
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
pub fn build_track_events_context(
|
| 136 |
+
model_slug: String,
|
| 137 |
+
thread_id: String,
|
| 138 |
+
turn_id: String,
|
| 139 |
+
product_client_id: String,
|
| 140 |
+
) -> TrackEventsContext {
|
| 141 |
+
TrackEventsContext {
|
| 142 |
+
model_slug,
|
| 143 |
+
thread_id,
|
| 144 |
+
turn_id,
|
| 145 |
+
product_client_id,
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
| 150 |
+
#[serde(rename_all = "snake_case")]
|
| 151 |
+
pub enum ImageDetailSetting {
|
| 152 |
+
High,
|
| 153 |
+
Original,
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
/// Measurements for one successfully decoded image at the point where Codex prepares it for
|
| 157 |
+
/// durable conversation history.
|
| 158 |
+
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
| 159 |
+
pub struct ImagePreparationMetadata {
|
| 160 |
+
/// Set for images embedded in message content.
|
| 161 |
+
pub message_role: Option<String>,
|
| 162 |
+
/// Set to the originating call ID for tool-output images. This joins to the `item_id` on
|
| 163 |
+
/// existing tool events for tool type and provenance.
|
| 164 |
+
pub item_id: Option<String>,
|
| 165 |
+
pub effective_detail: ImageDetailSetting,
|
| 166 |
+
pub source_width: u32,
|
| 167 |
+
pub source_height: u32,
|
| 168 |
+
pub prepared_width: u32,
|
| 169 |
+
pub prepared_height: u32,
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
#[derive(Clone)]
|
| 173 |
+
pub struct ImagePreparationFact {
|
| 174 |
+
pub turn_id: String,
|
| 175 |
+
pub metadata: ImagePreparationMetadata,
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 179 |
+
#[serde(rename_all = "snake_case")]
|
| 180 |
+
pub enum TurnSubmissionType {
|
| 181 |
+
Default,
|
| 182 |
+
Queued,
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
#[derive(Clone)]
|
| 186 |
+
pub struct TurnResolvedConfigFact {
|
| 187 |
+
pub turn_id: String,
|
| 188 |
+
pub thread_id: String,
|
| 189 |
+
pub turn_metadata: Arc<dyn TurnAnalyticsMetadata>,
|
| 190 |
+
pub num_input_images: usize,
|
| 191 |
+
pub submission_type: Option<TurnSubmissionType>,
|
| 192 |
+
pub ephemeral: bool,
|
| 193 |
+
pub session_source: SessionSource,
|
| 194 |
+
pub model: String,
|
| 195 |
+
pub model_provider: String,
|
| 196 |
+
pub permission_profile: PermissionProfile,
|
| 197 |
+
pub permission_profile_cwd: PathBuf,
|
| 198 |
+
pub reasoning_effort: Option<ReasoningEffort>,
|
| 199 |
+
pub reasoning_summary: Option<ReasoningSummary>,
|
| 200 |
+
pub service_tier: Option<ServiceTier>,
|
| 201 |
+
pub approval_policy: AskForApproval,
|
| 202 |
+
pub approvals_reviewer: ApprovalsReviewer,
|
| 203 |
+
pub guardian_v2_enabled: bool,
|
| 204 |
+
pub sandbox_network_access: bool,
|
| 205 |
+
pub collaboration_mode: ModeKind,
|
| 206 |
+
pub personality: Option<Personality>,
|
| 207 |
+
pub workspace_kind: Option<String>,
|
| 208 |
+
pub is_first_turn: bool,
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
/// A live, read-only view of a turn's analytics metadata.
|
| 212 |
+
///
|
| 213 |
+
/// Implementations must return `None` for unknown or ambiguous roots. The reducer
|
| 214 |
+
/// reads this when constructing each event because steering can invalidate a root
|
| 215 |
+
/// after the turn's configuration has been resolved.
|
| 216 |
+
pub trait TurnAnalyticsMetadata: Send + Sync {
|
| 217 |
+
fn root_turn_id(&self) -> Option<String>;
|
| 218 |
+
/// The caller-provided trigger recorded when this turn started.
|
| 219 |
+
fn turn_trigger(&self) -> Option<String>;
|
| 220 |
+
/// The effective Responses source at event emission, including accepted steers.
|
| 221 |
+
fn codex_turn_source(&self) -> Option<String>;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 225 |
+
#[serde(rename_all = "snake_case")]
|
| 226 |
+
pub enum ThreadInitializationMode {
|
| 227 |
+
New,
|
| 228 |
+
Forked,
|
| 229 |
+
Resumed,
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
#[derive(Clone)]
|
| 233 |
+
pub struct TurnTokenUsageFact {
|
| 234 |
+
pub turn_id: String,
|
| 235 |
+
pub thread_id: String,
|
| 236 |
+
pub token_usage: TokenUsage,
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
| 240 |
+
pub struct TurnProfile {
|
| 241 |
+
pub before_first_sampling_ms: u64,
|
| 242 |
+
pub sampling_ms: u64,
|
| 243 |
+
pub compaction_ms: u64,
|
| 244 |
+
pub between_sampling_overhead_ms: u64,
|
| 245 |
+
pub tool_blocking_ms: u64,
|
| 246 |
+
pub after_last_sampling_ms: u64,
|
| 247 |
+
pub sampling_request_count: u32,
|
| 248 |
+
pub sampling_retry_count: u32,
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
#[derive(Clone)]
|
| 252 |
+
pub struct TurnProfileFact {
|
| 253 |
+
pub turn_id: String,
|
| 254 |
+
pub profile: TurnProfile,
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
#[derive(Clone)]
|
| 258 |
+
pub struct TurnCodexErrorFact {
|
| 259 |
+
pub(crate) turn_id: String,
|
| 260 |
+
pub(crate) thread_id: String,
|
| 261 |
+
pub(crate) error: TurnCodexError,
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
impl TurnCodexErrorFact {
|
| 265 |
+
pub fn from_codex_err(thread_id: String, turn_id: String, error: &CodexErr) -> Self {
|
| 266 |
+
Self {
|
| 267 |
+
turn_id,
|
| 268 |
+
thread_id,
|
| 269 |
+
error: TurnCodexError::from_codex_err(error),
|
| 270 |
+
}
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
#[derive(Clone)]
|
| 275 |
+
pub(crate) struct TurnCodexError {
|
| 276 |
+
pub(crate) kind: CodexErrKind,
|
| 277 |
+
pub(crate) http_status_code: Option<u16>,
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
impl TurnCodexError {
|
| 281 |
+
fn from_codex_err(error: &CodexErr) -> Self {
|
| 282 |
+
Self {
|
| 283 |
+
kind: error.into(),
|
| 284 |
+
http_status_code: error.http_status_code_value(),
|
| 285 |
+
}
|
| 286 |
+
}
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 290 |
+
#[serde(rename_all = "snake_case")]
|
| 291 |
+
pub enum TurnStatus {
|
| 292 |
+
Completed,
|
| 293 |
+
Failed,
|
| 294 |
+
Interrupted,
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 298 |
+
#[serde(rename_all = "snake_case")]
|
| 299 |
+
pub enum TurnSteerResult {
|
| 300 |
+
Accepted,
|
| 301 |
+
Rejected,
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 305 |
+
#[serde(rename_all = "snake_case")]
|
| 306 |
+
pub enum TurnSteerRejectionReason {
|
| 307 |
+
NoActiveTurn,
|
| 308 |
+
ExpectedTurnMismatch,
|
| 309 |
+
NonSteerableReview,
|
| 310 |
+
NonSteerableCompact,
|
| 311 |
+
EmptyInput,
|
| 312 |
+
InputTooLarge,
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
#[derive(Clone)]
|
| 316 |
+
pub struct CodexTurnSteerEvent {
|
| 317 |
+
pub expected_turn_id: Option<String>,
|
| 318 |
+
pub accepted_turn_id: Option<String>,
|
| 319 |
+
pub num_input_images: usize,
|
| 320 |
+
pub result: TurnSteerResult,
|
| 321 |
+
pub rejection_reason: Option<TurnSteerRejectionReason>,
|
| 322 |
+
pub created_at: u64,
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
#[derive(Clone, Copy, Debug)]
|
| 326 |
+
pub enum AnalyticsJsonRpcError {
|
| 327 |
+
TurnSteer(TurnSteerRequestError),
|
| 328 |
+
Input(InputError),
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
#[derive(Clone, Copy, Debug)]
|
| 332 |
+
pub enum TurnSteerRequestError {
|
| 333 |
+
NoActiveTurn,
|
| 334 |
+
ExpectedTurnMismatch,
|
| 335 |
+
NonSteerableReview,
|
| 336 |
+
NonSteerableCompact,
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
#[derive(Clone, Copy, Debug)]
|
| 340 |
+
pub enum InputError {
|
| 341 |
+
Empty,
|
| 342 |
+
TooLarge,
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
impl From<TurnSteerRequestError> for TurnSteerRejectionReason {
|
| 346 |
+
fn from(error: TurnSteerRequestError) -> Self {
|
| 347 |
+
match error {
|
| 348 |
+
TurnSteerRequestError::NoActiveTurn => Self::NoActiveTurn,
|
| 349 |
+
TurnSteerRequestError::ExpectedTurnMismatch => Self::ExpectedTurnMismatch,
|
| 350 |
+
TurnSteerRequestError::NonSteerableReview => Self::NonSteerableReview,
|
| 351 |
+
TurnSteerRequestError::NonSteerableCompact => Self::NonSteerableCompact,
|
| 352 |
+
}
|
| 353 |
+
}
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
impl From<InputError> for TurnSteerRejectionReason {
|
| 357 |
+
fn from(error: InputError) -> Self {
|
| 358 |
+
match error {
|
| 359 |
+
InputError::Empty => Self::EmptyInput,
|
| 360 |
+
InputError::TooLarge => Self::InputTooLarge,
|
| 361 |
+
}
|
| 362 |
+
}
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
#[derive(Clone, Debug)]
|
| 366 |
+
pub struct SkillInvocation {
|
| 367 |
+
pub skill_name: String,
|
| 368 |
+
pub location: SkillInvocationLocation,
|
| 369 |
+
pub plugin_id: Option<String>,
|
| 370 |
+
pub remote_plugin_id: Option<String>,
|
| 371 |
+
pub invocation_type: InvocationType,
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
#[derive(Clone, Debug)]
|
| 375 |
+
pub enum SkillInvocationLocation {
|
| 376 |
+
Host {
|
| 377 |
+
path: PathBuf,
|
| 378 |
+
scope: SkillScope,
|
| 379 |
+
},
|
| 380 |
+
Resource {
|
| 381 |
+
id: String,
|
| 382 |
+
skill_id: Option<String>,
|
| 383 |
+
scope: Option<SkillScope>,
|
| 384 |
+
},
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 388 |
+
#[serde(rename_all = "lowercase")]
|
| 389 |
+
pub enum InvocationType {
|
| 390 |
+
Explicit,
|
| 391 |
+
Implicit,
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
| 395 |
+
#[serde(rename_all = "snake_case")]
|
| 396 |
+
pub enum ElicitationType {
|
| 397 |
+
/// Authentication or account linking blocked the original attempt, as reported
|
| 398 |
+
/// by trusted connector auth-failure metadata. Both are treated the same when
|
| 399 |
+
/// identifying elicitation-only usage. This does not imply that an
|
| 400 |
+
/// authentication prompt was shown or completed.
|
| 401 |
+
AuthOrLink,
|
| 402 |
+
Approval,
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
pub struct AppInvocation {
|
| 406 |
+
pub connector_id: Option<String>,
|
| 407 |
+
pub app_name: Option<String>,
|
| 408 |
+
pub invocation_type: Option<InvocationType>,
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
/// A known classification, queued before the corresponding item completion.
|
| 412 |
+
/// Ordinary calls do not send this fact; their emitted classification stays null.
|
| 413 |
+
#[derive(Clone, Debug, Eq, PartialEq)]
|
| 414 |
+
pub struct McpToolCallElicitation {
|
| 415 |
+
pub thread_id: String,
|
| 416 |
+
pub turn_id: String,
|
| 417 |
+
pub item_id: String,
|
| 418 |
+
pub elicitation_type: ElicitationType,
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
#[derive(Clone)]
|
| 422 |
+
pub struct SubAgentThreadStartedInput {
|
| 423 |
+
pub session_id: String,
|
| 424 |
+
pub thread_id: String,
|
| 425 |
+
pub parent_thread_id: Option<String>,
|
| 426 |
+
pub forked_from_thread_id: Option<String>,
|
| 427 |
+
pub product_client_id: String,
|
| 428 |
+
pub client_name: Option<String>,
|
| 429 |
+
pub client_version: Option<String>,
|
| 430 |
+
pub model: String,
|
| 431 |
+
pub ephemeral: bool,
|
| 432 |
+
pub thread_source: Option<ThreadSource>,
|
| 433 |
+
pub subagent_source: SubAgentSource,
|
| 434 |
+
pub created_at: u64,
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 438 |
+
#[serde(rename_all = "snake_case")]
|
| 439 |
+
pub enum CompactionTrigger {
|
| 440 |
+
Manual,
|
| 441 |
+
Auto,
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 445 |
+
#[serde(rename_all = "snake_case")]
|
| 446 |
+
pub enum CompactionReason {
|
| 447 |
+
UserRequested,
|
| 448 |
+
ContextLimit,
|
| 449 |
+
ModelDownshift,
|
| 450 |
+
CompHashChanged,
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 454 |
+
#[serde(rename_all = "snake_case")]
|
| 455 |
+
pub enum CompactionImplementation {
|
| 456 |
+
Responses,
|
| 457 |
+
ResponsesCompactionV2,
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 461 |
+
#[serde(rename_all = "snake_case")]
|
| 462 |
+
pub enum CompactionPhase {
|
| 463 |
+
StandaloneTurn,
|
| 464 |
+
PreTurn,
|
| 465 |
+
MidTurn,
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 469 |
+
#[serde(rename_all = "snake_case")]
|
| 470 |
+
pub enum CompactionStrategy {
|
| 471 |
+
Memento,
|
| 472 |
+
PrefixCompaction,
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 476 |
+
#[serde(rename_all = "snake_case")]
|
| 477 |
+
pub enum CompactionStatus {
|
| 478 |
+
Completed,
|
| 479 |
+
Failed,
|
| 480 |
+
Interrupted,
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
#[derive(Clone)]
|
| 484 |
+
pub struct CodexCompactionEvent {
|
| 485 |
+
pub thread_id: String,
|
| 486 |
+
pub turn_id: String,
|
| 487 |
+
pub trigger: CompactionTrigger,
|
| 488 |
+
pub reason: CompactionReason,
|
| 489 |
+
pub implementation: CompactionImplementation,
|
| 490 |
+
pub phase: CompactionPhase,
|
| 491 |
+
pub strategy: CompactionStrategy,
|
| 492 |
+
pub status: CompactionStatus,
|
| 493 |
+
pub codex_error_kind: Option<CodexErrKind>,
|
| 494 |
+
pub codex_error_http_status_code: Option<u16>,
|
| 495 |
+
pub active_context_tokens_before: i64,
|
| 496 |
+
pub active_context_tokens_after: i64,
|
| 497 |
+
pub retained_image_count: Option<usize>,
|
| 498 |
+
pub compaction_summary_tokens: Option<i64>,
|
| 499 |
+
pub cached_input_tokens: Option<i64>,
|
| 500 |
+
pub cache_write_input_tokens: Option<i64>,
|
| 501 |
+
pub started_at: u64,
|
| 502 |
+
pub completed_at: u64,
|
| 503 |
+
pub duration_ms: Option<u64>,
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 507 |
+
#[serde(rename_all = "snake_case")]
|
| 508 |
+
pub enum GoalEventKind {
|
| 509 |
+
Created,
|
| 510 |
+
UsageAccounted,
|
| 511 |
+
StatusChanged,
|
| 512 |
+
Cleared,
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
#[derive(Clone)]
|
| 516 |
+
pub struct CodexGoalEvent {
|
| 517 |
+
pub thread_id: String,
|
| 518 |
+
pub turn_id: Option<String>,
|
| 519 |
+
pub goal_id: String,
|
| 520 |
+
pub event_kind: GoalEventKind,
|
| 521 |
+
pub goal_status: codex_state::ThreadGoalStatus,
|
| 522 |
+
pub has_token_budget: bool,
|
| 523 |
+
pub cumulative_tokens_accounted: Option<i64>,
|
| 524 |
+
pub cumulative_time_accounted_seconds: Option<i64>,
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
#[allow(dead_code)]
|
| 528 |
+
pub(crate) enum AnalyticsFact {
|
| 529 |
+
Initialize {
|
| 530 |
+
connection_id: u64,
|
| 531 |
+
params: InitializeParams,
|
| 532 |
+
product_client_id: String,
|
| 533 |
+
runtime: CodexRuntimeMetadata,
|
| 534 |
+
rpc_transport: AppServerRpcTransport,
|
| 535 |
+
},
|
| 536 |
+
ClientRequest {
|
| 537 |
+
connection_id: u64,
|
| 538 |
+
request_id: RequestId,
|
| 539 |
+
request: Box<ClientRequest>,
|
| 540 |
+
},
|
| 541 |
+
ExplicitClientInterruptRequest {
|
| 542 |
+
connection_id: u64,
|
| 543 |
+
request_id: RequestId,
|
| 544 |
+
turn_id: String,
|
| 545 |
+
requested_at_ms: u64,
|
| 546 |
+
},
|
| 547 |
+
ClientResponse {
|
| 548 |
+
connection_id: u64,
|
| 549 |
+
request_id: RequestId,
|
| 550 |
+
response: Box<ClientResponsePayload>,
|
| 551 |
+
thread_originator: Option<String>,
|
| 552 |
+
},
|
| 553 |
+
ErrorResponse {
|
| 554 |
+
connection_id: u64,
|
| 555 |
+
request_id: RequestId,
|
| 556 |
+
error: JSONRPCErrorError,
|
| 557 |
+
error_type: Option<AnalyticsJsonRpcError>,
|
| 558 |
+
},
|
| 559 |
+
ServerRequest {
|
| 560 |
+
connection_id: u64,
|
| 561 |
+
request: Box<ServerRequest>,
|
| 562 |
+
},
|
| 563 |
+
ServerResponse {
|
| 564 |
+
completed_at_ms: u64,
|
| 565 |
+
response: Box<ServerResponse>,
|
| 566 |
+
},
|
| 567 |
+
EffectivePermissionsApprovalResponse {
|
| 568 |
+
completed_at_ms: u64,
|
| 569 |
+
request_id: RequestId,
|
| 570 |
+
response: Box<RequestPermissionsResponse>,
|
| 571 |
+
},
|
| 572 |
+
ServerRequestAborted {
|
| 573 |
+
completed_at_ms: u64,
|
| 574 |
+
request_id: RequestId,
|
| 575 |
+
},
|
| 576 |
+
Notification(Box<ServerNotification>),
|
| 577 |
+
// Facts that do not naturally exist on the app-server protocol surface, or
|
| 578 |
+
// would require non-trivial protocol reshaping on this branch.
|
| 579 |
+
Custom(CustomAnalyticsFact),
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
pub(crate) enum CustomAnalyticsFact {
|
| 583 |
+
ArtifactOperation(ArtifactOperationInput),
|
| 584 |
+
CodeModeToolCall(CodeModeToolCallFact),
|
| 585 |
+
ControlToolCall(ControlToolCallFact),
|
| 586 |
+
SubAgentThreadStarted(SubAgentThreadStartedInput),
|
| 587 |
+
Compaction(Box<CodexCompactionEvent>),
|
| 588 |
+
Goal(Box<CodexGoalEvent>),
|
| 589 |
+
ThreadHintStatus(Box<crate::thread_hint::ThreadHintStatusEvent>),
|
| 590 |
+
GuardianReview(Box<GuardianReviewEventParams>),
|
| 591 |
+
GuardianV2(Box<GuardianV2Event>),
|
| 592 |
+
TurnResolvedConfig(Box<TurnResolvedConfigFact>),
|
| 593 |
+
TurnTokenUsage(Box<TurnTokenUsageFact>),
|
| 594 |
+
TurnProfile(Box<TurnProfileFact>),
|
| 595 |
+
TurnCodexError(Box<TurnCodexErrorFact>),
|
| 596 |
+
ImagePreparation(Box<ImagePreparationFact>),
|
| 597 |
+
SkillInvoked(SkillInvokedInput),
|
| 598 |
+
AppMentioned(AppMentionedInput),
|
| 599 |
+
AppUsed(AppUsedInput),
|
| 600 |
+
McpToolCallElicitation(McpToolCallElicitation),
|
| 601 |
+
HookRun(HookRunInput),
|
| 602 |
+
PluginUsed(PluginUsedInput),
|
| 603 |
+
PluginInstallRequested(PluginInstallRequestedInput),
|
| 604 |
+
PluginStateChanged(PluginStateChangedInput),
|
| 605 |
+
PluginInstallFailed(PluginInstallFailedInput),
|
| 606 |
+
PluginMeasurements(PluginMeasurementsInput),
|
| 607 |
+
ExternalAgentConfigImportCompleted(ExternalAgentConfigImportCompletedInput),
|
| 608 |
+
ExternalAgentConfigImportFailure(ExternalAgentConfigImportFailureInput),
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
pub(crate) struct ArtifactOperationInput {
|
| 612 |
+
pub tracking: TrackEventsContext,
|
| 613 |
+
pub operation: ArtifactOperation,
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
#[derive(Clone, Debug, PartialEq)]
|
| 617 |
+
pub struct PluginMeasurementRow {
|
| 618 |
+
pub measurement_name: String,
|
| 619 |
+
pub number_value: f64,
|
| 620 |
+
pub dimensions: BTreeMap<String, String>,
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
#[derive(Clone, Debug, PartialEq)]
|
| 624 |
+
pub struct PluginMeasurementsInput {
|
| 625 |
+
pub thread_id: String,
|
| 626 |
+
pub turn_id: String,
|
| 627 |
+
pub item_id: String,
|
| 628 |
+
pub originator: String,
|
| 629 |
+
pub model_slug: Option<String>,
|
| 630 |
+
pub reasoning_effort: Option<String>,
|
| 631 |
+
pub plugin_id: String,
|
| 632 |
+
pub execution_id: String,
|
| 633 |
+
pub operation: String,
|
| 634 |
+
pub rows: Vec<PluginMeasurementRow>,
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
pub(crate) struct SkillInvokedInput {
|
| 638 |
+
pub tracking: TrackEventsContext,
|
| 639 |
+
pub invocations: Vec<SkillInvocation>,
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
pub(crate) struct AppMentionedInput {
|
| 643 |
+
pub tracking: TrackEventsContext,
|
| 644 |
+
pub mentions: Vec<AppInvocation>,
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
pub(crate) struct AppUsedInput {
|
| 648 |
+
pub tracking: TrackEventsContext,
|
| 649 |
+
pub app: AppInvocation,
|
| 650 |
+
pub elicitation_type: Option<ElicitationType>,
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
pub(crate) struct HookRunInput {
|
| 654 |
+
pub tracking: TrackEventsContext,
|
| 655 |
+
pub hook: HookRunFact,
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
pub struct HookRunFact {
|
| 659 |
+
pub event_name: HookEventName,
|
| 660 |
+
pub hook_source: HookSource,
|
| 661 |
+
pub handler_type: HookHandlerType,
|
| 662 |
+
pub execution_mode: HookExecutionMode,
|
| 663 |
+
pub status: HookRunStatus,
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
pub(crate) struct PluginUsedInput {
|
| 667 |
+
pub tracking: TrackEventsContext,
|
| 668 |
+
pub plugin: PluginTelemetryMetadata,
|
| 669 |
+
}
|
| 670 |
+
|
| 671 |
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
| 672 |
+
#[serde(rename_all = "snake_case")]
|
| 673 |
+
pub enum PluginInstallRequestSource {
|
| 674 |
+
EndpointRecommendation,
|
| 675 |
+
LegacyDiscovery,
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
#[derive(Clone, Debug, PartialEq, Eq)]
|
| 679 |
+
pub struct PluginInstallRequested {
|
| 680 |
+
pub suggestion_id: String,
|
| 681 |
+
pub plugins: Vec<PluginInstallRequestedPlugin>,
|
| 682 |
+
pub source: PluginInstallRequestSource,
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
#[derive(Clone, Debug, PartialEq, Eq)]
|
| 686 |
+
pub struct PluginInstallRequestedPlugin {
|
| 687 |
+
pub plugin_id: String,
|
| 688 |
+
pub remote_plugin_id: Option<String>,
|
| 689 |
+
pub plugin_name: String,
|
| 690 |
+
pub connector_ids: Vec<String>,
|
| 691 |
+
}
|
| 692 |
+
|
| 693 |
+
pub(crate) struct PluginInstallRequestedInput {
|
| 694 |
+
pub tracking: TrackEventsContext,
|
| 695 |
+
pub request: PluginInstallRequested,
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
pub(crate) struct PluginStateChangedInput {
|
| 699 |
+
pub plugin: PluginTelemetryMetadata,
|
| 700 |
+
pub state: PluginState,
|
| 701 |
+
}
|
| 702 |
+
|
| 703 |
+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
| 704 |
+
#[serde(rename_all = "snake_case")]
|
| 705 |
+
pub enum PluginInstallSource {
|
| 706 |
+
Manual,
|
| 707 |
+
ExternalAgentMigration,
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
pub(crate) struct PluginInstallFailedInput {
|
| 711 |
+
pub plugin: PluginTelemetryMetadata,
|
| 712 |
+
pub source: PluginInstallSource,
|
| 713 |
+
pub error_type: String,
|
| 714 |
+
pub sub_error_type: Option<String>,
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
pub struct ExternalAgentConfigImportCompletedInput {
|
| 718 |
+
pub import_id: String,
|
| 719 |
+
pub source: String,
|
| 720 |
+
pub provider_id: String,
|
| 721 |
+
pub item_type: String,
|
| 722 |
+
pub success_count: usize,
|
| 723 |
+
pub failed_count: usize,
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
pub struct ExternalAgentConfigImportFailureInput {
|
| 727 |
+
pub import_id: String,
|
| 728 |
+
pub source: String,
|
| 729 |
+
pub provider_id: String,
|
| 730 |
+
pub item_type: String,
|
| 731 |
+
pub failure_stage: String,
|
| 732 |
+
pub error_type: String,
|
| 733 |
+
pub sub_error_type: Option<String>,
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
#[derive(Clone, Copy)]
|
| 737 |
+
pub(crate) enum PluginState {
|
| 738 |
+
Installed,
|
| 739 |
+
Uninstalled,
|
| 740 |
+
Enabled,
|
| 741 |
+
Disabled,
|
| 742 |
+
}
|
codex-rs/analytics/src/guardian_v2.rs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Guardian V2 classifier and fast-decision facts, enriched by the existing reducer.
|
| 2 |
+
//! Payloads contain attribution and bounded outcomes, never prompts or tool arguments.
|
| 3 |
+
|
| 4 |
+
use crate::events::CodexAppServerClientMetadata;
|
| 5 |
+
use crate::events::CodexRuntimeMetadata;
|
| 6 |
+
use codex_protocol::protocol::ThreadSource;
|
| 7 |
+
use serde::Serialize;
|
| 8 |
+
|
| 9 |
+
#[derive(Serialize)]
|
| 10 |
+
pub struct GuardianV2Event {
|
| 11 |
+
pub thread_id: String,
|
| 12 |
+
pub turn_id: String,
|
| 13 |
+
pub item_id: Option<String>,
|
| 14 |
+
pub model: Option<String>,
|
| 15 |
+
pub occurred_at_ms: u64,
|
| 16 |
+
#[serde(flatten)]
|
| 17 |
+
pub kind: GuardianV2EventKind,
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
#[derive(Serialize)]
|
| 21 |
+
#[serde(untagged)]
|
| 22 |
+
pub enum GuardianV2EventKind {
|
| 23 |
+
Classification {
|
| 24 |
+
outcome: &'static str,
|
| 25 |
+
risk_level: Option<&'static str>,
|
| 26 |
+
duration_ms: u64,
|
| 27 |
+
},
|
| 28 |
+
FastDecision {
|
| 29 |
+
decision: &'static str,
|
| 30 |
+
},
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
#[derive(Serialize)]
|
| 34 |
+
pub(crate) struct GuardianV2EventRequest {
|
| 35 |
+
pub(crate) event_type: &'static str,
|
| 36 |
+
pub(crate) event_params: GuardianV2EventParams,
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
#[derive(Serialize)]
|
| 40 |
+
pub(crate) struct GuardianV2EventParams {
|
| 41 |
+
pub(crate) session_id: String,
|
| 42 |
+
pub(crate) app_server_client: CodexAppServerClientMetadata,
|
| 43 |
+
pub(crate) runtime: CodexRuntimeMetadata,
|
| 44 |
+
pub(crate) thread_source: Option<ThreadSource>,
|
| 45 |
+
pub(crate) subagent_source: Option<String>,
|
| 46 |
+
pub(crate) parent_thread_id: Option<String>,
|
| 47 |
+
#[serde(flatten)]
|
| 48 |
+
pub(crate) guardian_v2: GuardianV2Event,
|
| 49 |
+
}
|
codex-rs/analytics/src/lib.rs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
mod accepted_lines;
|
| 2 |
+
#[cfg(debug_assertions)]
|
| 3 |
+
mod analytics_capture;
|
| 4 |
+
mod client;
|
| 5 |
+
mod events;
|
| 6 |
+
mod facts;
|
| 7 |
+
mod guardian_v2;
|
| 8 |
+
mod reducer;
|
| 9 |
+
mod thread_hint;
|
| 10 |
+
|
| 11 |
+
use std::time::SystemTime;
|
| 12 |
+
use std::time::UNIX_EPOCH;
|
| 13 |
+
|
| 14 |
+
pub use accepted_lines::fingerprint_hash;
|
| 15 |
+
pub use client::AnalyticsEventsClient;
|
| 16 |
+
pub use events::AppServerRpcTransport;
|
| 17 |
+
pub use events::GuardianAdditionalPermissions;
|
| 18 |
+
pub use events::GuardianApprovalRequestSource;
|
| 19 |
+
pub use events::GuardianReviewAnalyticsResult;
|
| 20 |
+
pub use events::GuardianReviewDecision;
|
| 21 |
+
pub use events::GuardianReviewEventParams;
|
| 22 |
+
pub use events::GuardianReviewFailureReason;
|
| 23 |
+
pub use events::GuardianReviewSessionAnalyticsParams;
|
| 24 |
+
pub use events::GuardianReviewSessionKind;
|
| 25 |
+
pub use events::GuardianReviewTerminalStatus;
|
| 26 |
+
pub use events::GuardianReviewTrackContext;
|
| 27 |
+
pub use events::GuardianReviewedAction;
|
| 28 |
+
pub use facts::AnalyticsJsonRpcError;
|
| 29 |
+
pub use facts::AppInvocation;
|
| 30 |
+
pub use facts::ArtifactOperation;
|
| 31 |
+
pub use facts::ArtifactOperationLifecycle;
|
| 32 |
+
pub use facts::CodeModeToolCallFact;
|
| 33 |
+
pub use facts::CodeModeToolCallStatus;
|
| 34 |
+
pub use facts::CodexCompactionEvent;
|
| 35 |
+
pub use facts::CodexErrKind;
|
| 36 |
+
pub use facts::CodexGoalEvent;
|
| 37 |
+
pub use facts::CodexTurnSteerEvent;
|
| 38 |
+
pub use facts::CompactionImplementation;
|
| 39 |
+
pub use facts::CompactionPhase;
|
| 40 |
+
pub use facts::CompactionReason;
|
| 41 |
+
pub use facts::CompactionStatus;
|
| 42 |
+
pub use facts::CompactionStrategy;
|
| 43 |
+
pub use facts::CompactionTrigger;
|
| 44 |
+
pub use facts::ControlToolCallFact;
|
| 45 |
+
pub use facts::ControlToolCallStatus;
|
| 46 |
+
pub use facts::ElicitationType;
|
| 47 |
+
pub use facts::ExternalAgentConfigImportCompletedInput;
|
| 48 |
+
pub use facts::ExternalAgentConfigImportFailureInput;
|
| 49 |
+
pub use facts::GoalEventKind;
|
| 50 |
+
pub use facts::HookRunFact;
|
| 51 |
+
pub use facts::ImageDetailSetting;
|
| 52 |
+
pub use facts::ImagePreparationFact;
|
| 53 |
+
pub use facts::ImagePreparationMetadata;
|
| 54 |
+
pub use facts::InputError;
|
| 55 |
+
pub use facts::InvocationType;
|
| 56 |
+
pub use facts::McpToolCallElicitation;
|
| 57 |
+
pub use facts::PluginInstallRequestSource;
|
| 58 |
+
pub use facts::PluginInstallRequested;
|
| 59 |
+
pub use facts::PluginInstallRequestedPlugin;
|
| 60 |
+
pub use facts::PluginInstallSource;
|
| 61 |
+
pub use facts::PluginMeasurementRow;
|
| 62 |
+
pub use facts::PluginMeasurementsInput;
|
| 63 |
+
pub use facts::SkillInvocation;
|
| 64 |
+
pub use facts::SkillInvocationLocation;
|
| 65 |
+
pub use facts::SubAgentThreadStartedInput;
|
| 66 |
+
pub use facts::ThreadInitializationMode;
|
| 67 |
+
pub use facts::TrackEventsContext;
|
| 68 |
+
pub use facts::TurnAnalyticsMetadata;
|
| 69 |
+
pub use facts::TurnCodexErrorFact;
|
| 70 |
+
pub use facts::TurnProfile;
|
| 71 |
+
pub use facts::TurnProfileFact;
|
| 72 |
+
pub use facts::TurnResolvedConfigFact;
|
| 73 |
+
pub use facts::TurnStatus;
|
| 74 |
+
pub use facts::TurnSteerRejectionReason;
|
| 75 |
+
pub use facts::TurnSteerRequestError;
|
| 76 |
+
pub use facts::TurnSteerResult;
|
| 77 |
+
pub use facts::TurnTokenUsageFact;
|
| 78 |
+
pub use facts::build_track_events_context;
|
| 79 |
+
pub use guardian_v2::GuardianV2Event;
|
| 80 |
+
pub use guardian_v2::GuardianV2EventKind;
|
| 81 |
+
pub use thread_hint::ThreadHintStatus;
|
| 82 |
+
pub use thread_hint::ThreadHintStatusEvent;
|
| 83 |
+
|
| 84 |
+
#[cfg(test)]
|
| 85 |
+
mod analytics_client_tests;
|
| 86 |
+
|
| 87 |
+
pub fn now_unix_seconds() -> u64 {
|
| 88 |
+
SystemTime::now()
|
| 89 |
+
.duration_since(UNIX_EPOCH)
|
| 90 |
+
.unwrap_or_default()
|
| 91 |
+
.as_secs()
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
pub fn now_unix_millis() -> u64 {
|
| 95 |
+
u64::try_from(
|
| 96 |
+
SystemTime::now()
|
| 97 |
+
.duration_since(UNIX_EPOCH)
|
| 98 |
+
.unwrap_or_default()
|
| 99 |
+
.as_millis(),
|
| 100 |
+
)
|
| 101 |
+
.unwrap_or(u64::MAX)
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
pub(crate) fn serialize_enum_as_string<T: serde::Serialize>(value: &T) -> Option<String> {
|
| 105 |
+
serde_json::to_value(value)
|
| 106 |
+
.ok()
|
| 107 |
+
.and_then(|value| value.as_str().map(str::to_string))
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
pub(crate) fn usize_to_u64(value: usize) -> u64 {
|
| 111 |
+
u64::try_from(value).unwrap_or(u64::MAX)
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
pub(crate) fn option_i64_to_u64(value: Option<i64>) -> Option<u64> {
|
| 115 |
+
value.and_then(|value| u64::try_from(value).ok())
|
| 116 |
+
}
|
codex-rs/analytics/src/reducer.rs
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
codex-rs/analytics/src/thread_hint.rs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Per-attempt thread hint status analytics without hint contents.
|
| 2 |
+
|
| 3 |
+
use crate::events::CodexAppServerClientMetadata;
|
| 4 |
+
use crate::events::CodexRuntimeMetadata;
|
| 5 |
+
use codex_protocol::protocol::ThreadSource;
|
| 6 |
+
use serde::Serialize;
|
| 7 |
+
|
| 8 |
+
#[derive(Clone, Copy, Debug, Serialize)]
|
| 9 |
+
#[serde(rename_all = "snake_case")]
|
| 10 |
+
pub enum ThreadHintStatus {
|
| 11 |
+
Succeeded,
|
| 12 |
+
Failed,
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
pub struct ThreadHintStatusEvent {
|
| 16 |
+
pub thread_id: String,
|
| 17 |
+
pub status: ThreadHintStatus,
|
| 18 |
+
pub occurred_at_ms: u64,
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
#[derive(Serialize)]
|
| 22 |
+
pub(crate) struct ThreadHintStatusEventRequest {
|
| 23 |
+
pub(crate) event_type: &'static str,
|
| 24 |
+
pub(crate) event_params: ThreadHintStatusEventParams,
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
#[derive(Serialize)]
|
| 28 |
+
pub(crate) struct ThreadHintStatusEventParams {
|
| 29 |
+
pub(crate) thread_id: String,
|
| 30 |
+
pub(crate) session_id: String,
|
| 31 |
+
pub(crate) app_server_client: CodexAppServerClientMetadata,
|
| 32 |
+
pub(crate) runtime: CodexRuntimeMetadata,
|
| 33 |
+
pub(crate) thread_source: Option<ThreadSource>,
|
| 34 |
+
pub(crate) subagent_source: Option<String>,
|
| 35 |
+
pub(crate) parent_thread_id: Option<String>,
|
| 36 |
+
pub(crate) status: ThreadHintStatus,
|
| 37 |
+
pub(crate) occurred_at_ms: u64,
|
| 38 |
+
}
|
codex-rs/app-server-daemon/src/backend/mod.rs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
mod pid;
|
| 2 |
+
#[cfg(windows)]
|
| 3 |
+
pub(crate) mod windows;
|
| 4 |
+
|
| 5 |
+
use std::path::Path;
|
| 6 |
+
use std::path::PathBuf;
|
| 7 |
+
|
| 8 |
+
use serde::Serialize;
|
| 9 |
+
|
| 10 |
+
pub(crate) use pid::PidBackend;
|
| 11 |
+
|
| 12 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
| 13 |
+
#[serde(rename_all = "camelCase")]
|
| 14 |
+
pub enum BackendKind {
|
| 15 |
+
Pid,
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
#[derive(Debug, Clone)]
|
| 19 |
+
pub(crate) struct BackendPaths {
|
| 20 |
+
pub(crate) codex_bin: PathBuf,
|
| 21 |
+
pub(crate) pid_file: PathBuf,
|
| 22 |
+
pub(crate) update_pid_file: PathBuf,
|
| 23 |
+
pub(crate) remote_control_enabled: bool,
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
pub(crate) fn pid_backend(paths: BackendPaths) -> PidBackend {
|
| 27 |
+
PidBackend::new(
|
| 28 |
+
paths.codex_bin,
|
| 29 |
+
paths.pid_file,
|
| 30 |
+
paths.remote_control_enabled,
|
| 31 |
+
)
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
pub(crate) fn pid_update_loop_backend(paths: BackendPaths) -> PidBackend {
|
| 35 |
+
PidBackend::new_update_loop(
|
| 36 |
+
paths.codex_bin,
|
| 37 |
+
paths.update_pid_file,
|
| 38 |
+
/*restore_release*/ None,
|
| 39 |
+
)
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
pub(crate) async fn append_stderr_log_tail_context(pid_file: &Path, context: &mut String) {
|
| 43 |
+
match pid::read_stderr_log_tail(pid_file).await {
|
| 44 |
+
Ok(Some(tail)) => tail.append_to_context(context),
|
| 45 |
+
Ok(None) => {}
|
| 46 |
+
Err(err) => {
|
| 47 |
+
context.push_str(&format!(
|
| 48 |
+
"\n\nFailed to read managed app-server stderr log: {err:#}"
|
| 49 |
+
));
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
}
|
codex-rs/app-server-daemon/src/backend/pid.rs
ADDED
|
@@ -0,0 +1,774 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! PID reservations serialize detached launches; process identities protect stale-record cleanup.
|
| 2 |
+
|
| 3 |
+
#[path = "pid_identity.rs"]
|
| 4 |
+
mod identity;
|
| 5 |
+
|
| 6 |
+
use std::io::SeekFrom;
|
| 7 |
+
use std::path::Path;
|
| 8 |
+
use std::path::PathBuf;
|
| 9 |
+
use std::time::Duration;
|
| 10 |
+
|
| 11 |
+
use crate::managed_install::ExecutableIdentity;
|
| 12 |
+
use anyhow::Context;
|
| 13 |
+
use anyhow::Result;
|
| 14 |
+
use anyhow::bail;
|
| 15 |
+
#[cfg(any(unix, windows))]
|
| 16 |
+
use codex_app_server_transport::REMOTE_CONTROL_DISABLED_ENV_VAR;
|
| 17 |
+
use serde::Deserialize;
|
| 18 |
+
use serde::Serialize;
|
| 19 |
+
use tokio::fs;
|
| 20 |
+
use tokio::io::AsyncReadExt;
|
| 21 |
+
use tokio::io::AsyncSeekExt;
|
| 22 |
+
#[cfg(unix)]
|
| 23 |
+
use tokio::process::Command;
|
| 24 |
+
use tokio::time::sleep;
|
| 25 |
+
|
| 26 |
+
use crate::settings::DEFAULT_SHUTDOWN_GRACE_SECONDS;
|
| 27 |
+
|
| 28 |
+
const STOP_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
| 29 |
+
const STOP_FORCE_TIMEOUT: Duration = Duration::from_secs(10);
|
| 30 |
+
const START_TIMEOUT: Duration = Duration::from_secs(10);
|
| 31 |
+
const STDERR_LOG_TAIL_BYTES: u64 = 4096;
|
| 32 |
+
|
| 33 |
+
#[derive(Debug)]
|
| 34 |
+
#[cfg_attr(not(any(unix, windows)), allow(dead_code))]
|
| 35 |
+
pub(crate) struct PidBackend {
|
| 36 |
+
codex_bin: PathBuf,
|
| 37 |
+
pid_file: PathBuf,
|
| 38 |
+
lock_file: PathBuf,
|
| 39 |
+
command_kind: PidCommandKind,
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
| 43 |
+
#[serde(rename_all = "camelCase")]
|
| 44 |
+
struct PidRecord {
|
| 45 |
+
pid: u32,
|
| 46 |
+
// Keep the legacy timestamp for older CLI/updater versions reading this record.
|
| 47 |
+
process_start_time: String,
|
| 48 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 49 |
+
#[serde(alias = "linuxProcessIdentity")]
|
| 50 |
+
process_identity: Option<identity::ProcessIdentity>,
|
| 51 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 52 |
+
executable_identity: Option<ExecutableIdentity>,
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
| 56 |
+
pub(crate) struct PidLogTail {
|
| 57 |
+
pub(crate) path: PathBuf,
|
| 58 |
+
pub(crate) contents: String,
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
impl PidLogTail {
|
| 62 |
+
pub(crate) fn append_to_context(&self, context: &mut String) {
|
| 63 |
+
context.push_str(&format!(
|
| 64 |
+
"\n\nManaged app-server stderr ({}):",
|
| 65 |
+
self.path.display()
|
| 66 |
+
));
|
| 67 |
+
for line in self.contents.lines() {
|
| 68 |
+
context.push_str("\n ");
|
| 69 |
+
context.push_str(line);
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
| 75 |
+
enum PidFileState {
|
| 76 |
+
Missing,
|
| 77 |
+
Starting,
|
| 78 |
+
Running(PidRecord),
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
#[derive(Debug, Clone)]
|
| 82 |
+
#[cfg_attr(not(any(unix, windows)), allow(dead_code))]
|
| 83 |
+
enum PidCommandKind {
|
| 84 |
+
AppServer { remote_control_enabled: bool },
|
| 85 |
+
UpdateLoop { restore_release: Option<String> },
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
impl PidBackend {
|
| 89 |
+
pub(crate) async fn running_executable_identity(&self) -> Result<Option<ExecutableIdentity>> {
|
| 90 |
+
match self.read_pid_file_state().await? {
|
| 91 |
+
PidFileState::Running(record) if self.record_is_active(&record).await? => {
|
| 92 |
+
Ok(record.executable_identity)
|
| 93 |
+
}
|
| 94 |
+
_ => Ok(None),
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
pub(crate) fn new(codex_bin: PathBuf, pid_file: PathBuf, remote_control_enabled: bool) -> Self {
|
| 99 |
+
let lock_file = pid_file.with_extension("pid.lock");
|
| 100 |
+
Self {
|
| 101 |
+
codex_bin,
|
| 102 |
+
pid_file,
|
| 103 |
+
lock_file,
|
| 104 |
+
command_kind: PidCommandKind::AppServer {
|
| 105 |
+
remote_control_enabled,
|
| 106 |
+
},
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
pub(crate) fn new_update_loop(
|
| 111 |
+
codex_bin: PathBuf,
|
| 112 |
+
pid_file: PathBuf,
|
| 113 |
+
restore_release: Option<String>,
|
| 114 |
+
) -> Self {
|
| 115 |
+
let lock_file = pid_file.with_extension("pid.lock");
|
| 116 |
+
Self {
|
| 117 |
+
codex_bin,
|
| 118 |
+
pid_file,
|
| 119 |
+
lock_file,
|
| 120 |
+
command_kind: PidCommandKind::UpdateLoop { restore_release },
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
pub(crate) async fn is_starting_or_running(&self) -> Result<bool> {
|
| 125 |
+
loop {
|
| 126 |
+
match self.read_pid_file_state().await? {
|
| 127 |
+
PidFileState::Missing => return Ok(false),
|
| 128 |
+
PidFileState::Starting => return Ok(true),
|
| 129 |
+
PidFileState::Running(record) => {
|
| 130 |
+
if self.record_is_active(&record).await? {
|
| 131 |
+
return Ok(true);
|
| 132 |
+
}
|
| 133 |
+
match self.refresh_after_stale_record(&record).await? {
|
| 134 |
+
PidFileState::Missing => return Ok(false),
|
| 135 |
+
PidFileState::Starting | PidFileState::Running(_) => continue,
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
#[cfg(any(unix, windows))]
|
| 143 |
+
pub(crate) async fn start(&self) -> Result<Option<u32>> {
|
| 144 |
+
self.start_inner(/*replacement*/ None).await
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
#[cfg(not(any(unix, windows)))]
|
| 148 |
+
pub(crate) async fn start(&self) -> Result<Option<u32>> {
|
| 149 |
+
bail!("pid-managed app-server startup is unsupported on this platform")
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
pub(crate) async fn stop(&self) -> Result<()> {
|
| 153 |
+
self.stop_with_grace(DEFAULT_SHUTDOWN_GRACE_SECONDS).await
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
pub(crate) async fn stop_with_grace(&self, grace_seconds: u32) -> Result<()> {
|
| 157 |
+
loop {
|
| 158 |
+
let Some(record) = self.wait_for_pid_start().await? else {
|
| 159 |
+
return Ok(());
|
| 160 |
+
};
|
| 161 |
+
if !self.record_is_active(&record).await? {
|
| 162 |
+
match self.refresh_after_stale_record(&record).await? {
|
| 163 |
+
PidFileState::Missing => return Ok(()),
|
| 164 |
+
PidFileState::Starting | PidFileState::Running(_) => continue,
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
let pid = record.pid;
|
| 169 |
+
let started_at = tokio::time::Instant::now();
|
| 170 |
+
let force_after = Duration::from_secs(grace_seconds.into());
|
| 171 |
+
let deadline = started_at + force_after + STOP_FORCE_TIMEOUT;
|
| 172 |
+
#[cfg(unix)]
|
| 173 |
+
self.terminate_process(pid)?;
|
| 174 |
+
#[cfg(windows)]
|
| 175 |
+
let process = {
|
| 176 |
+
let Some(process) = super::windows::Process::open(pid)? else {
|
| 177 |
+
continue;
|
| 178 |
+
};
|
| 179 |
+
if process.start_time()? != record.process_start_time {
|
| 180 |
+
continue;
|
| 181 |
+
}
|
| 182 |
+
match self.command_kind {
|
| 183 |
+
PidCommandKind::AppServer { .. } => {
|
| 184 |
+
let codex_home = self
|
| 185 |
+
.pid_file
|
| 186 |
+
.parent()
|
| 187 |
+
.and_then(Path::parent)
|
| 188 |
+
.context("daemon pid path has no Codex home")?;
|
| 189 |
+
let socket_path =
|
| 190 |
+
codex_app_server_transport::app_server_control_socket_path(codex_home)?;
|
| 191 |
+
if let Err(err) =
|
| 192 |
+
crate::client::request_shutdown(socket_path.as_path(), pid).await
|
| 193 |
+
{
|
| 194 |
+
tracing::warn!(%pid, %err, "managed app-server shutdown request failed; waiting for force deadline");
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
PidCommandKind::UpdateLoop { .. } => {
|
| 198 |
+
fs::write(self.pid_file.with_extension("shutdown"), pid.to_string())
|
| 199 |
+
.await
|
| 200 |
+
.context("failed to request updater shutdown")?;
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
process
|
| 204 |
+
};
|
| 205 |
+
let mut forced = false;
|
| 206 |
+
loop {
|
| 207 |
+
#[cfg(unix)]
|
| 208 |
+
if let Ok(raw_pid) = libc::pid_t::try_from(pid)
|
| 209 |
+
&& raw_pid > 0
|
| 210 |
+
{
|
| 211 |
+
// A previous updater may have started this child; reap it if it has exited.
|
| 212 |
+
unsafe { libc::waitpid(raw_pid, std::ptr::null_mut(), libc::WNOHANG) };
|
| 213 |
+
}
|
| 214 |
+
if !self.record_is_active(&record).await? {
|
| 215 |
+
match self.refresh_after_stale_record(&record).await? {
|
| 216 |
+
PidFileState::Missing => return Ok(()),
|
| 217 |
+
PidFileState::Starting | PidFileState::Running(_) => break,
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
if tokio::time::Instant::now() >= deadline {
|
| 221 |
+
break;
|
| 222 |
+
}
|
| 223 |
+
if !forced && started_at.elapsed() >= force_after {
|
| 224 |
+
#[cfg(unix)]
|
| 225 |
+
self.force_terminate_process(pid)?;
|
| 226 |
+
#[cfg(windows)]
|
| 227 |
+
process.terminate()?;
|
| 228 |
+
forced = true;
|
| 229 |
+
}
|
| 230 |
+
sleep(STOP_POLL_INTERVAL).await;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
if self.record_is_active(&record).await? {
|
| 234 |
+
bail!("timed out waiting for pid-managed app server {pid} to stop");
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
async fn wait_for_pid_start(&self) -> Result<Option<PidRecord>> {
|
| 240 |
+
let deadline = tokio::time::Instant::now() + START_TIMEOUT;
|
| 241 |
+
loop {
|
| 242 |
+
match self.read_pid_file_state().await? {
|
| 243 |
+
PidFileState::Missing => return Ok(None),
|
| 244 |
+
PidFileState::Running(record) => return Ok(Some(record)),
|
| 245 |
+
PidFileState::Starting if tokio::time::Instant::now() < deadline => {
|
| 246 |
+
sleep(STOP_POLL_INTERVAL).await;
|
| 247 |
+
}
|
| 248 |
+
PidFileState::Starting => {
|
| 249 |
+
bail!(
|
| 250 |
+
"timed out waiting for pid reservation in {} to finish initializing",
|
| 251 |
+
self.pid_file.display()
|
| 252 |
+
);
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
}
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
async fn read_pid_file_state(&self) -> Result<PidFileState> {
|
| 259 |
+
let contents = match fs::read_to_string(&self.pid_file).await {
|
| 260 |
+
Ok(contents) => contents,
|
| 261 |
+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
| 262 |
+
return if reservation_lock_is_active(&self.lock_file).await? {
|
| 263 |
+
Ok(PidFileState::Starting)
|
| 264 |
+
} else {
|
| 265 |
+
Ok(PidFileState::Missing)
|
| 266 |
+
};
|
| 267 |
+
}
|
| 268 |
+
Err(err) => {
|
| 269 |
+
return Err(err).with_context(|| {
|
| 270 |
+
format!("failed to read pid file {}", self.pid_file.display())
|
| 271 |
+
});
|
| 272 |
+
}
|
| 273 |
+
};
|
| 274 |
+
if contents.trim().is_empty() {
|
| 275 |
+
match inspect_empty_pid_reservation(&self.pid_file, &self.lock_file).await? {
|
| 276 |
+
EmptyPidReservation::Active => {
|
| 277 |
+
return Ok(PidFileState::Starting);
|
| 278 |
+
}
|
| 279 |
+
EmptyPidReservation::Stale => {
|
| 280 |
+
return Ok(PidFileState::Missing);
|
| 281 |
+
}
|
| 282 |
+
EmptyPidReservation::Record(record) => return Ok(PidFileState::Running(record)),
|
| 283 |
+
}
|
| 284 |
+
}
|
| 285 |
+
let record = serde_json::from_str(&contents)
|
| 286 |
+
.with_context(|| format!("invalid pid file contents in {}", self.pid_file.display()))?;
|
| 287 |
+
Ok(PidFileState::Running(record))
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
async fn read_pid_file_state_with_lock_held(&self) -> Result<PidFileState> {
|
| 291 |
+
let contents = match fs::read_to_string(&self.pid_file).await {
|
| 292 |
+
Ok(contents) => contents,
|
| 293 |
+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
| 294 |
+
return Ok(PidFileState::Missing);
|
| 295 |
+
}
|
| 296 |
+
Err(err) => {
|
| 297 |
+
return Err(err).with_context(|| {
|
| 298 |
+
format!("failed to read pid file {}", self.pid_file.display())
|
| 299 |
+
});
|
| 300 |
+
}
|
| 301 |
+
};
|
| 302 |
+
if contents.trim().is_empty() {
|
| 303 |
+
let _ = fs::remove_file(&self.pid_file).await;
|
| 304 |
+
return Ok(PidFileState::Missing);
|
| 305 |
+
}
|
| 306 |
+
let record = serde_json::from_str(&contents)
|
| 307 |
+
.with_context(|| format!("invalid pid file contents in {}", self.pid_file.display()))?;
|
| 308 |
+
Ok(PidFileState::Running(record))
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
async fn refresh_after_stale_record(&self, expected: &PidRecord) -> Result<PidFileState> {
|
| 312 |
+
let reservation_lock = self.acquire_reservation_lock().await?;
|
| 313 |
+
let state = match self.read_pid_file_state_with_lock_held().await? {
|
| 314 |
+
PidFileState::Running(record) if record == *expected => {
|
| 315 |
+
let _ = fs::remove_file(&self.pid_file).await;
|
| 316 |
+
PidFileState::Missing
|
| 317 |
+
}
|
| 318 |
+
state => state,
|
| 319 |
+
};
|
| 320 |
+
drop(reservation_lock);
|
| 321 |
+
Ok(state)
|
| 322 |
+
}
|
| 323 |
+
|
| 324 |
+
async fn acquire_reservation_lock(&self) -> Result<fs::File> {
|
| 325 |
+
let reservation_lock = fs::OpenOptions::new()
|
| 326 |
+
.create(true)
|
| 327 |
+
.truncate(false)
|
| 328 |
+
.write(true)
|
| 329 |
+
.open(&self.lock_file)
|
| 330 |
+
.await
|
| 331 |
+
.with_context(|| {
|
| 332 |
+
format!("failed to open pid lock file {}", self.lock_file.display())
|
| 333 |
+
})?;
|
| 334 |
+
let lock_deadline = tokio::time::Instant::now() + START_TIMEOUT;
|
| 335 |
+
while !try_lock_file(&reservation_lock)? {
|
| 336 |
+
if tokio::time::Instant::now() >= lock_deadline {
|
| 337 |
+
bail!(
|
| 338 |
+
"timed out waiting for pid lock {}",
|
| 339 |
+
self.lock_file.display()
|
| 340 |
+
);
|
| 341 |
+
}
|
| 342 |
+
sleep(STOP_POLL_INTERVAL).await;
|
| 343 |
+
}
|
| 344 |
+
Ok(reservation_lock)
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
#[cfg(any(unix, windows))]
|
| 348 |
+
async fn open_stderr_log(&self) -> Result<fs::File> {
|
| 349 |
+
let stderr_log_file = stderr_log_file_for_pid_file(&self.pid_file);
|
| 350 |
+
fs::OpenOptions::new()
|
| 351 |
+
.create(true)
|
| 352 |
+
.truncate(true)
|
| 353 |
+
.write(true)
|
| 354 |
+
.open(&stderr_log_file)
|
| 355 |
+
.await
|
| 356 |
+
.with_context(|| {
|
| 357 |
+
format!(
|
| 358 |
+
"failed to open stderr log for pid-managed app server {}",
|
| 359 |
+
stderr_log_file.display()
|
| 360 |
+
)
|
| 361 |
+
})
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
#[cfg(any(unix, windows))]
|
| 365 |
+
fn command_args(&self) -> Vec<&str> {
|
| 366 |
+
match &self.command_kind {
|
| 367 |
+
PidCommandKind::AppServer {
|
| 368 |
+
remote_control_enabled: true,
|
| 369 |
+
} => vec!["app-server", "--remote-control", "--listen", "unix://"],
|
| 370 |
+
PidCommandKind::AppServer {
|
| 371 |
+
remote_control_enabled: false,
|
| 372 |
+
} => vec!["app-server", "--listen", "unix://"],
|
| 373 |
+
PidCommandKind::UpdateLoop { restore_release } => {
|
| 374 |
+
let mut args = vec!["app-server", "daemon", "pid-update-loop"];
|
| 375 |
+
if let Some(release) = restore_release {
|
| 376 |
+
args.extend(["--restore-release", release.as_str()]);
|
| 377 |
+
}
|
| 378 |
+
args
|
| 379 |
+
}
|
| 380 |
+
}
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
#[cfg(any(unix, windows))]
|
| 384 |
+
fn command_env(&self) -> Option<(&'static str, &'static str)> {
|
| 385 |
+
match self.command_kind {
|
| 386 |
+
PidCommandKind::AppServer {
|
| 387 |
+
remote_control_enabled: false,
|
| 388 |
+
} => Some((REMOTE_CONTROL_DISABLED_ENV_VAR, "1")),
|
| 389 |
+
PidCommandKind::AppServer {
|
| 390 |
+
remote_control_enabled: true,
|
| 391 |
+
}
|
| 392 |
+
| PidCommandKind::UpdateLoop { .. } => None,
|
| 393 |
+
}
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
fn terminate_process(&self, pid: u32) -> Result<()> {
|
| 397 |
+
match self.command_kind {
|
| 398 |
+
PidCommandKind::AppServer { .. } => terminate_process(pid),
|
| 399 |
+
#[cfg(unix)]
|
| 400 |
+
PidCommandKind::UpdateLoop { .. } => terminate_process_group(pid),
|
| 401 |
+
#[cfg(not(unix))]
|
| 402 |
+
PidCommandKind::UpdateLoop { .. } => terminate_process(pid),
|
| 403 |
+
}
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
#[cfg(not(windows))]
|
| 407 |
+
fn force_terminate_process(&self, pid: u32) -> Result<()> {
|
| 408 |
+
match self.command_kind {
|
| 409 |
+
PidCommandKind::AppServer { .. } => force_terminate_process(pid),
|
| 410 |
+
PidCommandKind::UpdateLoop { .. } => force_terminate_process_group(pid),
|
| 411 |
+
}
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
async fn record_is_active(&self, record: &PidRecord) -> Result<bool> {
|
| 415 |
+
process_matches_record(record).await
|
| 416 |
+
}
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
pub(crate) async fn read_stderr_log_tail(pid_file: &Path) -> Result<Option<PidLogTail>> {
|
| 420 |
+
let path = stderr_log_file_for_pid_file(pid_file);
|
| 421 |
+
let Some(contents) = read_log_tail(&path, STDERR_LOG_TAIL_BYTES).await? else {
|
| 422 |
+
return Ok(None);
|
| 423 |
+
};
|
| 424 |
+
Ok(Some(PidLogTail { path, contents }))
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
fn stderr_log_file_for_pid_file(pid_file: &Path) -> PathBuf {
|
| 428 |
+
pid_file.with_extension("stderr.log")
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
async fn read_log_tail(path: &Path, byte_limit: u64) -> Result<Option<String>> {
|
| 432 |
+
let mut file = match fs::File::open(path).await {
|
| 433 |
+
Ok(file) => file,
|
| 434 |
+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
| 435 |
+
Err(err) => {
|
| 436 |
+
return Err(err)
|
| 437 |
+
.with_context(|| format!("failed to open stderr log {}", path.display()));
|
| 438 |
+
}
|
| 439 |
+
};
|
| 440 |
+
let len = file
|
| 441 |
+
.metadata()
|
| 442 |
+
.await
|
| 443 |
+
.with_context(|| format!("failed to inspect stderr log {}", path.display()))?
|
| 444 |
+
.len();
|
| 445 |
+
if len == 0 {
|
| 446 |
+
return Ok(None);
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
let start = len.saturating_sub(byte_limit);
|
| 450 |
+
file.seek(SeekFrom::Start(start))
|
| 451 |
+
.await
|
| 452 |
+
.with_context(|| format!("failed to seek stderr log {}", path.display()))?;
|
| 453 |
+
let mut bytes = Vec::new();
|
| 454 |
+
file.read_to_end(&mut bytes)
|
| 455 |
+
.await
|
| 456 |
+
.with_context(|| format!("failed to read stderr log {}", path.display()))?;
|
| 457 |
+
if start > 0
|
| 458 |
+
&& let Some(newline_index) = bytes.iter().position(|byte| *byte == b'\n')
|
| 459 |
+
{
|
| 460 |
+
bytes.drain(..=newline_index);
|
| 461 |
+
}
|
| 462 |
+
let contents = String::from_utf8_lossy(&bytes).trim_end().to_string();
|
| 463 |
+
if contents.is_empty() {
|
| 464 |
+
return Ok(None);
|
| 465 |
+
}
|
| 466 |
+
Ok(Some(contents))
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
#[cfg(unix)]
|
| 470 |
+
fn process_exists(pid: u32) -> bool {
|
| 471 |
+
let Ok(pid) = libc::pid_t::try_from(pid) else {
|
| 472 |
+
return false;
|
| 473 |
+
};
|
| 474 |
+
let result = unsafe { libc::kill(pid, 0) };
|
| 475 |
+
result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
#[cfg(unix)]
|
| 479 |
+
fn terminate_process(pid: u32) -> Result<()> {
|
| 480 |
+
let raw_pid = libc::pid_t::try_from(pid)
|
| 481 |
+
.with_context(|| format!("pid-managed app server pid {pid} is out of range"))?;
|
| 482 |
+
let result = unsafe { libc::kill(raw_pid, libc::SIGTERM) };
|
| 483 |
+
if result == 0 {
|
| 484 |
+
return Ok(());
|
| 485 |
+
}
|
| 486 |
+
let err = std::io::Error::last_os_error();
|
| 487 |
+
if err.raw_os_error() == Some(libc::ESRCH) {
|
| 488 |
+
return Ok(());
|
| 489 |
+
}
|
| 490 |
+
Err(err).with_context(|| format!("failed to terminate pid-managed app server {pid}"))
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
#[cfg(unix)]
|
| 494 |
+
fn force_terminate_process(pid: u32) -> Result<()> {
|
| 495 |
+
let raw_pid = libc::pid_t::try_from(pid)
|
| 496 |
+
.with_context(|| format!("pid-managed app server pid {pid} is out of range"))?;
|
| 497 |
+
let result = unsafe { libc::kill(raw_pid, libc::SIGKILL) };
|
| 498 |
+
if result == 0 {
|
| 499 |
+
return Ok(());
|
| 500 |
+
}
|
| 501 |
+
let err = std::io::Error::last_os_error();
|
| 502 |
+
if err.raw_os_error() == Some(libc::ESRCH) {
|
| 503 |
+
return Ok(());
|
| 504 |
+
}
|
| 505 |
+
Err(err).with_context(|| format!("failed to force terminate pid-managed app server {pid}"))
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
#[cfg(unix)]
|
| 509 |
+
fn terminate_process_group(pid: u32) -> Result<()> {
|
| 510 |
+
let raw_pid = libc::pid_t::try_from(pid)
|
| 511 |
+
.with_context(|| format!("pid-managed updater pid {pid} is out of range"))?;
|
| 512 |
+
let result = unsafe { libc::kill(-raw_pid, libc::SIGTERM) };
|
| 513 |
+
if result == 0 {
|
| 514 |
+
return Ok(());
|
| 515 |
+
}
|
| 516 |
+
let err = std::io::Error::last_os_error();
|
| 517 |
+
if err.raw_os_error() == Some(libc::ESRCH) {
|
| 518 |
+
return Ok(());
|
| 519 |
+
}
|
| 520 |
+
Err(err).with_context(|| format!("failed to terminate pid-managed updater group {pid}"))
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
#[cfg(unix)]
|
| 524 |
+
fn force_terminate_process_group(pid: u32) -> Result<()> {
|
| 525 |
+
let raw_pid = libc::pid_t::try_from(pid)
|
| 526 |
+
.with_context(|| format!("pid-managed updater pid {pid} is out of range"))?;
|
| 527 |
+
let result = unsafe { libc::kill(-raw_pid, libc::SIGKILL) };
|
| 528 |
+
if result == 0 {
|
| 529 |
+
return Ok(());
|
| 530 |
+
}
|
| 531 |
+
let err = std::io::Error::last_os_error();
|
| 532 |
+
if err.raw_os_error() == Some(libc::ESRCH) {
|
| 533 |
+
return Ok(());
|
| 534 |
+
}
|
| 535 |
+
Err(err).with_context(|| format!("failed to force terminate pid-managed updater group {pid}"))
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
#[cfg(not(any(unix, windows)))]
|
| 539 |
+
fn terminate_process(_pid: u32) -> Result<()> {
|
| 540 |
+
bail!("pid-managed app-server shutdown is unsupported on this platform")
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
#[cfg(not(any(unix, windows)))]
|
| 544 |
+
fn force_terminate_process(_pid: u32) -> Result<()> {
|
| 545 |
+
bail!("pid-managed app-server shutdown is unsupported on this platform")
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
#[cfg(not(any(unix, windows)))]
|
| 549 |
+
fn force_terminate_process_group(_pid: u32) -> Result<()> {
|
| 550 |
+
bail!("pid-managed updater shutdown is unsupported on this platform")
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
#[cfg(unix)]
|
| 554 |
+
async fn process_matches_record(record: &PidRecord) -> Result<bool> {
|
| 555 |
+
if !process_exists(record.pid) {
|
| 556 |
+
return Ok(false);
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
let details = async {
|
| 560 |
+
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
| 561 |
+
if let Some(expected) = &record.process_identity {
|
| 562 |
+
return expected.matches_process(record.pid).await;
|
| 563 |
+
}
|
| 564 |
+
let (state, start_time) = read_process_details(record.pid).await?;
|
| 565 |
+
let matches = start_time == record.process_start_time;
|
| 566 |
+
let is_zombie = state.starts_with('Z');
|
| 567 |
+
if !matches && !is_zombie {
|
| 568 |
+
bail!(
|
| 569 |
+
"cannot verify pid-managed process {}: legacy start time changed; PID record retained. \
|
| 570 |
+
Retry with the locale and timezone used to start the daemon. If the system clock \
|
| 571 |
+
changed, stop the original process before restarting the daemon",
|
| 572 |
+
record.pid
|
| 573 |
+
);
|
| 574 |
+
}
|
| 575 |
+
Ok((is_zombie, matches))
|
| 576 |
+
}
|
| 577 |
+
.await;
|
| 578 |
+
match details {
|
| 579 |
+
Ok((is_zombie, matches)) => {
|
| 580 |
+
// An unreaped zombie still passes kill(pid, 0) and retains its start
|
| 581 |
+
// time, but it can no longer run the app-server or updater.
|
| 582 |
+
if is_zombie {
|
| 583 |
+
if matches && let Ok(raw_pid) = libc::pid_t::try_from(record.pid) {
|
| 584 |
+
// Re-exec can lose the Child handle without changing parenthood.
|
| 585 |
+
unsafe { libc::waitpid(raw_pid, std::ptr::null_mut(), libc::WNOHANG) };
|
| 586 |
+
}
|
| 587 |
+
return Ok(false);
|
| 588 |
+
}
|
| 589 |
+
Ok(matches)
|
| 590 |
+
}
|
| 591 |
+
Err(_err) if !process_exists(record.pid) => Ok(false),
|
| 592 |
+
Err(err) => Err(err),
|
| 593 |
+
}
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
#[cfg(not(any(unix, windows)))]
|
| 597 |
+
async fn process_matches_record(_record: &PidRecord) -> Result<bool> {
|
| 598 |
+
Ok(false)
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
| 602 |
+
#[cfg_attr(not(any(unix, windows)), allow(dead_code))]
|
| 603 |
+
enum EmptyPidReservation {
|
| 604 |
+
Active,
|
| 605 |
+
Stale,
|
| 606 |
+
Record(PidRecord),
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
#[cfg(unix)]
|
| 610 |
+
fn try_lock_file(file: &fs::File) -> Result<bool> {
|
| 611 |
+
use std::os::fd::AsRawFd;
|
| 612 |
+
|
| 613 |
+
let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
| 614 |
+
if result == 0 {
|
| 615 |
+
return Ok(true);
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
let err = std::io::Error::last_os_error();
|
| 619 |
+
if err.raw_os_error() == Some(libc::EWOULDBLOCK) {
|
| 620 |
+
return Ok(false);
|
| 621 |
+
}
|
| 622 |
+
Err(err).context("failed to lock pid reservation")
|
| 623 |
+
}
|
| 624 |
+
|
| 625 |
+
#[cfg(not(any(unix, windows)))]
|
| 626 |
+
fn try_lock_file(_file: &fs::File) -> Result<bool> {
|
| 627 |
+
bail!("pid-managed app-server startup is unsupported on this platform")
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
#[cfg(any(unix, windows))]
|
| 631 |
+
async fn reservation_lock_is_active(path: &Path) -> Result<bool> {
|
| 632 |
+
let file = match fs::OpenOptions::new()
|
| 633 |
+
.write(true)
|
| 634 |
+
.create(true)
|
| 635 |
+
.truncate(false)
|
| 636 |
+
.open(path)
|
| 637 |
+
.await
|
| 638 |
+
{
|
| 639 |
+
Ok(file) => file,
|
| 640 |
+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
| 641 |
+
return Ok(false);
|
| 642 |
+
}
|
| 643 |
+
Err(err) => {
|
| 644 |
+
return Err(err)
|
| 645 |
+
.with_context(|| format!("failed to inspect pid lock file {}", path.display()));
|
| 646 |
+
}
|
| 647 |
+
};
|
| 648 |
+
Ok(!try_lock_file(&file)?)
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
#[cfg(not(any(unix, windows)))]
|
| 652 |
+
async fn reservation_lock_is_active(_path: &Path) -> Result<bool> {
|
| 653 |
+
Ok(false)
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
#[cfg(any(unix, windows))]
|
| 657 |
+
async fn inspect_empty_pid_reservation(
|
| 658 |
+
pid_path: &Path,
|
| 659 |
+
lock_path: &Path,
|
| 660 |
+
) -> Result<EmptyPidReservation> {
|
| 661 |
+
let file = match fs::OpenOptions::new()
|
| 662 |
+
.write(true)
|
| 663 |
+
.create(true)
|
| 664 |
+
.truncate(false)
|
| 665 |
+
.open(lock_path)
|
| 666 |
+
.await
|
| 667 |
+
{
|
| 668 |
+
Ok(file) => file,
|
| 669 |
+
Err(err) => {
|
| 670 |
+
return Err(err).with_context(|| {
|
| 671 |
+
format!("failed to inspect pid lock file {}", lock_path.display())
|
| 672 |
+
});
|
| 673 |
+
}
|
| 674 |
+
};
|
| 675 |
+
if !try_lock_file(&file)? {
|
| 676 |
+
return Ok(EmptyPidReservation::Active);
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
let contents = match fs::read_to_string(pid_path).await {
|
| 680 |
+
Ok(contents) => contents,
|
| 681 |
+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
| 682 |
+
return Ok(EmptyPidReservation::Stale);
|
| 683 |
+
}
|
| 684 |
+
Err(err) => {
|
| 685 |
+
return Err(err)
|
| 686 |
+
.with_context(|| format!("failed to reread pid file {}", pid_path.display()));
|
| 687 |
+
}
|
| 688 |
+
};
|
| 689 |
+
if contents.trim().is_empty() {
|
| 690 |
+
let _ = fs::remove_file(pid_path).await;
|
| 691 |
+
return Ok(EmptyPidReservation::Stale);
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
let record = serde_json::from_str(&contents)
|
| 695 |
+
.with_context(|| format!("invalid pid file contents in {}", pid_path.display()))?;
|
| 696 |
+
Ok(EmptyPidReservation::Record(record))
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
#[cfg(not(any(unix, windows)))]
|
| 700 |
+
async fn inspect_empty_pid_reservation(
|
| 701 |
+
_pid_path: &Path,
|
| 702 |
+
_lock_path: &Path,
|
| 703 |
+
) -> Result<EmptyPidReservation> {
|
| 704 |
+
Ok(EmptyPidReservation::Stale)
|
| 705 |
+
}
|
| 706 |
+
|
| 707 |
+
#[cfg(unix)]
|
| 708 |
+
async fn read_process_start_time(pid: u32) -> Result<String> {
|
| 709 |
+
Ok(read_process_details(pid).await?.1)
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
#[cfg(unix)]
|
| 713 |
+
async fn read_process_details(pid: u32) -> Result<(String, String)> {
|
| 714 |
+
let output = Command::new("ps")
|
| 715 |
+
.args(["-p", &pid.to_string(), "-o", "stat=", "-o", "lstart="])
|
| 716 |
+
.output()
|
| 717 |
+
.await
|
| 718 |
+
.context("failed to invoke ps for pid-managed app server")?;
|
| 719 |
+
if !output.status.success() {
|
| 720 |
+
bail!("failed to read start time for pid-managed app server {pid}");
|
| 721 |
+
}
|
| 722 |
+
|
| 723 |
+
let details = String::from_utf8(output.stdout)
|
| 724 |
+
.context("pid-managed app server process details were not utf-8")?;
|
| 725 |
+
let Some((state, start_time)) = details.trim().split_once(char::is_whitespace) else {
|
| 726 |
+
bail!("pid-managed app server {pid} has no recorded start time");
|
| 727 |
+
};
|
| 728 |
+
let start_time = start_time.trim();
|
| 729 |
+
if start_time.is_empty() {
|
| 730 |
+
bail!("pid-managed app server {pid} has no recorded start time");
|
| 731 |
+
}
|
| 732 |
+
Ok((state.to_string(), start_time.to_string()))
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
#[cfg(all(test, any(unix, windows)))]
|
| 736 |
+
#[path = "pid_tests.rs"]
|
| 737 |
+
mod tests;
|
| 738 |
+
|
| 739 |
+
#[cfg(windows)]
|
| 740 |
+
use super::windows::try_lock_file;
|
| 741 |
+
|
| 742 |
+
#[cfg(windows)]
|
| 743 |
+
async fn read_process_start_time(pid: u32) -> Result<String> {
|
| 744 |
+
super::windows::Process::open(pid)?
|
| 745 |
+
.context("daemon process exited during startup")?
|
| 746 |
+
.start_time()
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
#[cfg(windows)]
|
| 750 |
+
async fn process_matches_record(record: &PidRecord) -> Result<bool> {
|
| 751 |
+
let Some(process) = super::windows::Process::open(record.pid)? else {
|
| 752 |
+
return Ok(false);
|
| 753 |
+
};
|
| 754 |
+
Ok(process.is_running()? && process.start_time()? == record.process_start_time)
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
#[cfg(windows)]
|
| 758 |
+
fn force_terminate_process(pid: u32) -> Result<()> {
|
| 759 |
+
if let Some(process) = super::windows::Process::open(pid)? {
|
| 760 |
+
process.terminate()?;
|
| 761 |
+
}
|
| 762 |
+
Ok(())
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
#[cfg(windows)]
|
| 766 |
+
use force_terminate_process as terminate_process;
|
| 767 |
+
|
| 768 |
+
#[cfg(windows)]
|
| 769 |
+
#[path = "pid_windows.rs"]
|
| 770 |
+
mod windows;
|
| 771 |
+
|
| 772 |
+
#[cfg(any(unix, windows))]
|
| 773 |
+
#[path = "pid_start.rs"]
|
| 774 |
+
mod start;
|
codex-rs/app-server-daemon/src/backend/pid_identity.rs
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Native process identities and zombie state, independent of locale and timezone.
|
| 2 |
+
//! Boot IDs prevent records surviving reboot from matching reused process identifiers.
|
| 3 |
+
|
| 4 |
+
#[cfg(any(target_os = "linux", target_os = "macos", test))]
|
| 5 |
+
use anyhow::Context;
|
| 6 |
+
#[cfg(any(target_os = "linux", target_os = "macos", test))]
|
| 7 |
+
use anyhow::Result;
|
| 8 |
+
use serde::Deserialize;
|
| 9 |
+
use serde::Serialize;
|
| 10 |
+
|
| 11 |
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
| 12 |
+
#[serde(untagged, rename_all_fields = "camelCase")]
|
| 13 |
+
pub(super) enum ProcessIdentity {
|
| 14 |
+
Linux {
|
| 15 |
+
boot_id: String,
|
| 16 |
+
start_ticks: u64,
|
| 17 |
+
},
|
| 18 |
+
// Untagged decoding must try this before the timestamp-only MacOs variant.
|
| 19 |
+
MacOsUnique {
|
| 20 |
+
boot_id: String,
|
| 21 |
+
unique_id: u64,
|
| 22 |
+
start_seconds: u64,
|
| 23 |
+
start_microseconds: u64,
|
| 24 |
+
},
|
| 25 |
+
MacOs {
|
| 26 |
+
start_seconds: u64,
|
| 27 |
+
start_microseconds: u64,
|
| 28 |
+
},
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
| 32 |
+
impl ProcessIdentity {
|
| 33 |
+
pub(super) async fn matches_process(&self, pid: u32) -> Result<(bool, bool)> {
|
| 34 |
+
// A different boot proves staleness even when the reused PID is inaccessible.
|
| 35 |
+
if let Self::Linux { boot_id, .. } | Self::MacOsUnique { boot_id, .. } = self
|
| 36 |
+
&& *boot_id != read_boot_id().await?
|
| 37 |
+
{
|
| 38 |
+
return Ok((false, false));
|
| 39 |
+
}
|
| 40 |
+
#[cfg(target_os = "macos")]
|
| 41 |
+
if let Self::MacOsUnique { unique_id, .. } = self
|
| 42 |
+
&& *unique_id != read_unique_id(pid)?
|
| 43 |
+
{
|
| 44 |
+
return Ok((false, false));
|
| 45 |
+
}
|
| 46 |
+
let (is_zombie, actual) = read_process_details(pid).await?;
|
| 47 |
+
let matches = if let (
|
| 48 |
+
// Timestamp-only native records predate the cross-user identity check.
|
| 49 |
+
Self::MacOs {
|
| 50 |
+
start_seconds,
|
| 51 |
+
start_microseconds,
|
| 52 |
+
},
|
| 53 |
+
Self::MacOsUnique {
|
| 54 |
+
start_seconds: actual_seconds,
|
| 55 |
+
start_microseconds: actual_microseconds,
|
| 56 |
+
..
|
| 57 |
+
},
|
| 58 |
+
) = (self, &actual)
|
| 59 |
+
{
|
| 60 |
+
start_seconds == actual_seconds && start_microseconds == actual_microseconds
|
| 61 |
+
} else {
|
| 62 |
+
*self == actual
|
| 63 |
+
};
|
| 64 |
+
Ok((is_zombie, matches))
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
| 69 |
+
impl super::PidBackend {
|
| 70 |
+
pub(crate) async fn promote_legacy_identity(&self) -> Result<()> {
|
| 71 |
+
let _reservation = self.acquire_reservation_lock().await?;
|
| 72 |
+
let super::PidFileState::Running(mut record) =
|
| 73 |
+
self.read_pid_file_state_with_lock_held().await?
|
| 74 |
+
else {
|
| 75 |
+
return Ok(());
|
| 76 |
+
};
|
| 77 |
+
if matches!(
|
| 78 |
+
record.process_identity,
|
| 79 |
+
Some(ProcessIdentity::Linux { .. } | ProcessIdentity::MacOsUnique { .. })
|
| 80 |
+
) {
|
| 81 |
+
return Ok(());
|
| 82 |
+
}
|
| 83 |
+
// An updater keeps its PID across exec during upgrades. Only promote a
|
| 84 |
+
// legacy record while its original check still identifies the process,
|
| 85 |
+
// with the native identity unchanged on both sides of that check.
|
| 86 |
+
let before = read_process_details(record.pid).await?;
|
| 87 |
+
if before.0 || !super::process_matches_record(&record).await? {
|
| 88 |
+
return Ok(());
|
| 89 |
+
}
|
| 90 |
+
if read_process_details(record.pid).await? != before {
|
| 91 |
+
return Ok(());
|
| 92 |
+
}
|
| 93 |
+
record.process_identity = Some(before.1);
|
| 94 |
+
let temporary = self.pid_file.with_extension("pid.tmp");
|
| 95 |
+
tokio::fs::write(&temporary, serde_json::to_vec(&record)?).await?;
|
| 96 |
+
tokio::fs::rename(&temporary, &self.pid_file).await?;
|
| 97 |
+
Ok(())
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
#[cfg(target_os = "macos")]
|
| 102 |
+
pub(super) async fn read_process_details(pid: u32) -> Result<(bool, ProcessIdentity)> {
|
| 103 |
+
let boot_id = read_boot_id().await?;
|
| 104 |
+
let unique_id = read_unique_id(pid)?;
|
| 105 |
+
let pid = libc::pid_t::try_from(pid).context("daemon pid is out of range")?;
|
| 106 |
+
let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::uninit();
|
| 107 |
+
let size = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
|
| 108 |
+
// SAFETY: the buffer has the exact size and alignment required by this flavor.
|
| 109 |
+
// arg=1 includes zombies so callers can reap children that have exited.
|
| 110 |
+
let read = unsafe {
|
| 111 |
+
libc::proc_pidinfo(
|
| 112 |
+
pid,
|
| 113 |
+
libc::PROC_PIDTBSDINFO,
|
| 114 |
+
/*arg*/ 1,
|
| 115 |
+
info.as_mut_ptr().cast(),
|
| 116 |
+
size,
|
| 117 |
+
)
|
| 118 |
+
};
|
| 119 |
+
if read <= 0 {
|
| 120 |
+
return Err(std::io::Error::last_os_error())
|
| 121 |
+
.context("failed to read daemon process identity");
|
| 122 |
+
}
|
| 123 |
+
anyhow::ensure!(read == size, "incomplete daemon process identity");
|
| 124 |
+
// SAFETY: proc_pidinfo initialized the entire structure on success.
|
| 125 |
+
let info = unsafe { info.assume_init() };
|
| 126 |
+
anyhow::ensure!(
|
| 127 |
+
unique_id == read_unique_id(info.pbi_pid)?,
|
| 128 |
+
"daemon process changed while reading its identity"
|
| 129 |
+
);
|
| 130 |
+
Ok((
|
| 131 |
+
info.pbi_status == libc::SZOMB,
|
| 132 |
+
ProcessIdentity::MacOsUnique {
|
| 133 |
+
boot_id,
|
| 134 |
+
unique_id,
|
| 135 |
+
start_seconds: info.pbi_start_tvsec,
|
| 136 |
+
start_microseconds: info.pbi_start_tvusec,
|
| 137 |
+
},
|
| 138 |
+
))
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
#[cfg(target_os = "macos")]
|
| 142 |
+
fn read_unique_id(pid: u32) -> Result<u64> {
|
| 143 |
+
// XNU's proc_uniqidentifierinfo ABI (flavor 17), unchanged in size since 2013.
|
| 144 |
+
// Unlike PROC_PIDTBSDINFO, this query does not require matching process ownership.
|
| 145 |
+
#[repr(C)]
|
| 146 |
+
struct UniqueInfo {
|
| 147 |
+
executable_uuid: [u8; 16],
|
| 148 |
+
unique_id: u64,
|
| 149 |
+
parent_unique_id: u64,
|
| 150 |
+
reserved: [u64; 3],
|
| 151 |
+
}
|
| 152 |
+
let pid = libc::pid_t::try_from(pid).context("daemon pid is out of range")?;
|
| 153 |
+
let mut info = std::mem::MaybeUninit::<UniqueInfo>::uninit();
|
| 154 |
+
let size = std::mem::size_of::<UniqueInfo>() as libc::c_int;
|
| 155 |
+
// SAFETY: the buffer matches proc_uniqidentifierinfo's size and alignment.
|
| 156 |
+
let read = unsafe {
|
| 157 |
+
libc::proc_pidinfo(
|
| 158 |
+
pid,
|
| 159 |
+
/*flavor*/ 17,
|
| 160 |
+
/*arg*/ 1,
|
| 161 |
+
info.as_mut_ptr().cast(),
|
| 162 |
+
size,
|
| 163 |
+
)
|
| 164 |
+
};
|
| 165 |
+
if read <= 0 {
|
| 166 |
+
return Err(std::io::Error::last_os_error()).context("failed to read daemon unique ID");
|
| 167 |
+
}
|
| 168 |
+
anyhow::ensure!(read == size, "incomplete daemon unique ID");
|
| 169 |
+
// SAFETY: proc_pidinfo initialized the entire structure on success.
|
| 170 |
+
Ok(unsafe { info.assume_init() }.unique_id)
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
#[cfg(target_os = "macos")]
|
| 174 |
+
async fn read_boot_id() -> Result<String> {
|
| 175 |
+
let mut buffer = [0u8; 37];
|
| 176 |
+
let mut size = buffer.len();
|
| 177 |
+
// SAFETY: the writable buffer has size bytes; the new-value pointer is null.
|
| 178 |
+
let result = unsafe {
|
| 179 |
+
libc::sysctlbyname(
|
| 180 |
+
c"kern.bootsessionuuid".as_ptr(),
|
| 181 |
+
buffer.as_mut_ptr().cast(),
|
| 182 |
+
&mut size,
|
| 183 |
+
std::ptr::null_mut(),
|
| 184 |
+
/*newlen*/ 0,
|
| 185 |
+
)
|
| 186 |
+
};
|
| 187 |
+
if result != 0 {
|
| 188 |
+
return Err(std::io::Error::last_os_error()).context("failed to read macOS boot ID");
|
| 189 |
+
}
|
| 190 |
+
anyhow::ensure!(size == buffer.len(), "incomplete macOS boot ID");
|
| 191 |
+
Ok(std::ffi::CStr::from_bytes_with_nul(&buffer)?
|
| 192 |
+
.to_str()?
|
| 193 |
+
.to_owned())
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
#[cfg(target_os = "linux")]
|
| 197 |
+
pub(super) async fn read_process_details(pid: u32) -> Result<(bool, ProcessIdentity)> {
|
| 198 |
+
let stat = tokio::fs::read(format!("/proc/{pid}/stat"))
|
| 199 |
+
.await
|
| 200 |
+
.with_context(|| format!("failed to read process stat for pid-managed process {pid}"))?;
|
| 201 |
+
let (state, start_ticks) = parse_stat(&stat)?;
|
| 202 |
+
Ok((
|
| 203 |
+
state == "Z",
|
| 204 |
+
ProcessIdentity::Linux {
|
| 205 |
+
boot_id: read_boot_id().await?,
|
| 206 |
+
start_ticks,
|
| 207 |
+
},
|
| 208 |
+
))
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
#[cfg(target_os = "linux")]
|
| 212 |
+
async fn read_boot_id() -> Result<String> {
|
| 213 |
+
let boot_id = tokio::fs::read_to_string("/proc/sys/kernel/random/boot_id")
|
| 214 |
+
.await
|
| 215 |
+
.context("failed to read Linux boot ID")?;
|
| 216 |
+
let boot_id = boot_id.trim();
|
| 217 |
+
anyhow::ensure!(!boot_id.is_empty(), "Linux boot ID is empty");
|
| 218 |
+
Ok(boot_id.to_owned())
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
#[cfg(any(target_os = "linux", test))]
|
| 222 |
+
fn parse_stat(stat: &[u8]) -> Result<(String, u64)> {
|
| 223 |
+
// comm (field 2) can contain spaces and closing parentheses. The final ')'
|
| 224 |
+
// terminates it; splitting the whole line on whitespace miscounts fields.
|
| 225 |
+
let end = stat
|
| 226 |
+
.iter()
|
| 227 |
+
.rposition(|byte| *byte == b')')
|
| 228 |
+
.context("process stat has no comm")?;
|
| 229 |
+
let fields = std::str::from_utf8(&stat[end + 1..]).context("invalid process stat fields")?;
|
| 230 |
+
let mut fields = fields.split_whitespace();
|
| 231 |
+
let state = fields.next().context("process stat has no state")?;
|
| 232 |
+
let start_ticks = fields
|
| 233 |
+
.nth(/*n*/ 18)
|
| 234 |
+
.context("process stat has no start time")?
|
| 235 |
+
.parse()
|
| 236 |
+
.context("process stat start time is invalid")?;
|
| 237 |
+
Ok((state.to_string(), start_ticks))
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
#[cfg(test)]
|
| 241 |
+
#[path = "pid_identity_tests.rs"]
|
| 242 |
+
mod tests;
|
codex-rs/app-server-daemon/src/backend/pid_identity_tests.rs
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use super::parse_stat;
|
| 2 |
+
use pretty_assertions::assert_eq;
|
| 3 |
+
|
| 4 |
+
#[test]
|
| 5 |
+
fn parses_start_ticks_after_comm_with_spaces_and_parentheses() {
|
| 6 |
+
let stat =
|
| 7 |
+
b"123 (codex \xff) worker) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 987654321 20";
|
| 8 |
+
assert_eq!(parse_stat(stat).unwrap(), ("S".to_string(), 987654321));
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
#[test]
|
| 12 |
+
fn reads_earlier_linux_identity_field() {
|
| 13 |
+
use crate::backend::pid::PidRecord;
|
| 14 |
+
let mut expected = serde_json::json!({
|
| 15 |
+
"pid": 42,
|
| 16 |
+
"processStartTime": "legacy timestamp",
|
| 17 |
+
"linuxProcessIdentity": {"bootId": "boot", "startTicks": 123},
|
| 18 |
+
});
|
| 19 |
+
let record: PidRecord = serde_json::from_value(expected.clone()).unwrap();
|
| 20 |
+
expected["processIdentity"] = expected
|
| 21 |
+
.as_object_mut()
|
| 22 |
+
.unwrap()
|
| 23 |
+
.remove("linuxProcessIdentity")
|
| 24 |
+
.unwrap();
|
| 25 |
+
assert_eq!(serde_json::to_value(record).unwrap(), expected);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
| 29 |
+
#[tokio::test]
|
| 30 |
+
async fn previous_boot_does_not_require_process_inspection() {
|
| 31 |
+
let (_, identity) = super::read_process_details(std::process::id())
|
| 32 |
+
.await
|
| 33 |
+
.unwrap();
|
| 34 |
+
let mut record = serde_json::to_value(identity).unwrap();
|
| 35 |
+
record["bootId"] = "previous boot".into();
|
| 36 |
+
let identity: super::ProcessIdentity = serde_json::from_value(record).unwrap();
|
| 37 |
+
// An invalid PID would make process inspection fail if it preceded the boot check.
|
| 38 |
+
assert_eq!(
|
| 39 |
+
identity.matches_process(u32::MAX).await.unwrap(),
|
| 40 |
+
(false, false)
|
| 41 |
+
);
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
#[cfg(target_os = "macos")]
|
| 45 |
+
#[tokio::test]
|
| 46 |
+
async fn reused_pid_can_belong_to_root() {
|
| 47 |
+
let (_, identity) = super::read_process_details(std::process::id())
|
| 48 |
+
.await
|
| 49 |
+
.unwrap();
|
| 50 |
+
// launchd is root-owned: PROC_PIDTBSDINFO returns EPERM for an ordinary caller.
|
| 51 |
+
assert_eq!(
|
| 52 |
+
identity.matches_process(/*pid*/ 1).await.unwrap(),
|
| 53 |
+
(false, false)
|
| 54 |
+
);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
#[cfg(target_os = "macos")]
|
| 58 |
+
#[tokio::test]
|
| 59 |
+
async fn promotes_timestamp_only_macos_record() {
|
| 60 |
+
use crate::backend::pid::PidBackend;
|
| 61 |
+
use crate::backend::pid::PidRecord;
|
| 62 |
+
|
| 63 |
+
let temp = tempfile::TempDir::new().unwrap();
|
| 64 |
+
let path = temp.path().join("server.pid");
|
| 65 |
+
let (_, identity) = super::read_process_details(std::process::id())
|
| 66 |
+
.await
|
| 67 |
+
.unwrap();
|
| 68 |
+
let mut legacy = serde_json::to_value(&identity).unwrap();
|
| 69 |
+
legacy.as_object_mut().unwrap().remove("bootId");
|
| 70 |
+
legacy.as_object_mut().unwrap().remove("uniqueId");
|
| 71 |
+
let mut record = PidRecord {
|
| 72 |
+
pid: std::process::id(),
|
| 73 |
+
process_start_time: "unused legacy text".into(),
|
| 74 |
+
process_identity: Some(serde_json::from_value(legacy).unwrap()),
|
| 75 |
+
executable_identity: None,
|
| 76 |
+
};
|
| 77 |
+
std::fs::write(&path, serde_json::to_vec(&record).unwrap()).unwrap();
|
| 78 |
+
let backend = PidBackend::new(
|
| 79 |
+
temp.path().join("codex"),
|
| 80 |
+
path.clone(),
|
| 81 |
+
/*remote_control_enabled*/ false,
|
| 82 |
+
);
|
| 83 |
+
backend.promote_legacy_identity().await.unwrap();
|
| 84 |
+
record.process_identity = Some(identity);
|
| 85 |
+
assert_eq!(
|
| 86 |
+
serde_json::from_slice::<PidRecord>(&std::fs::read(path).unwrap()).unwrap(),
|
| 87 |
+
record,
|
| 88 |
+
);
|
| 89 |
+
}
|
codex-rs/app-server-daemon/src/backend/pid_start.rs
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Detached process launch and PID publication. Hold the reservation lock until
|
| 2 |
+
//! the record is published, and on Windows until an updater acknowledges startup.
|
| 3 |
+
|
| 4 |
+
use super::PidBackend;
|
| 5 |
+
#[cfg(windows)]
|
| 6 |
+
use super::PidCommandKind;
|
| 7 |
+
use super::PidFileState;
|
| 8 |
+
use super::PidRecord;
|
| 9 |
+
use super::read_process_start_time;
|
| 10 |
+
use crate::managed_install::executable_identity;
|
| 11 |
+
use anyhow::Context;
|
| 12 |
+
use anyhow::Result;
|
| 13 |
+
use anyhow::bail;
|
| 14 |
+
use std::process::Stdio;
|
| 15 |
+
use tokio::fs;
|
| 16 |
+
use tokio::process::Command;
|
| 17 |
+
|
| 18 |
+
impl PidBackend {
|
| 19 |
+
pub(super) async fn start_inner(&self, replacement: Option<PidRecord>) -> Result<Option<u32>> {
|
| 20 |
+
#[cfg(windows)]
|
| 21 |
+
crate::backend::windows::ensure_not_elevated()?;
|
| 22 |
+
if let Some(parent) = self.pid_file.parent() {
|
| 23 |
+
codex_uds::prepare_private_socket_directory(parent)
|
| 24 |
+
.await
|
| 25 |
+
.with_context(|| format!("failed to create pid directory {}", parent.display()))?;
|
| 26 |
+
}
|
| 27 |
+
let reservation_lock = self.acquire_reservation_lock().await?;
|
| 28 |
+
loop {
|
| 29 |
+
match fs::OpenOptions::new()
|
| 30 |
+
.create_new(true)
|
| 31 |
+
.write(true)
|
| 32 |
+
.open(&self.pid_file)
|
| 33 |
+
.await
|
| 34 |
+
{
|
| 35 |
+
Ok(pid_file) => {
|
| 36 |
+
drop(pid_file);
|
| 37 |
+
break;
|
| 38 |
+
}
|
| 39 |
+
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
| 40 |
+
match self.read_pid_file_state_with_lock_held().await? {
|
| 41 |
+
PidFileState::Missing => continue,
|
| 42 |
+
PidFileState::Running(record) => {
|
| 43 |
+
if replacement.as_ref() == Some(&record) {
|
| 44 |
+
break;
|
| 45 |
+
}
|
| 46 |
+
if replacement.is_some() {
|
| 47 |
+
if self.record_is_active(&record).await? {
|
| 48 |
+
bail!("updater ownership changed before handoff");
|
| 49 |
+
}
|
| 50 |
+
// A failed rollback may leave the dead successor's record.
|
| 51 |
+
break;
|
| 52 |
+
}
|
| 53 |
+
if self.record_is_active(&record).await? {
|
| 54 |
+
return Ok(None);
|
| 55 |
+
}
|
| 56 |
+
let _ = fs::remove_file(&self.pid_file).await;
|
| 57 |
+
continue;
|
| 58 |
+
}
|
| 59 |
+
PidFileState::Starting => {
|
| 60 |
+
unreachable!("lock holder cannot observe starting")
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
Err(err) => {
|
| 65 |
+
return Err(err).with_context(|| {
|
| 66 |
+
format!("failed to reserve pid file {}", self.pid_file.display())
|
| 67 |
+
});
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
// Pin the selected release across installer symlink/junction retargeting.
|
| 72 |
+
let codex_bin = fs::canonicalize(&self.codex_bin)
|
| 73 |
+
.await
|
| 74 |
+
.unwrap_or_else(|_| self.codex_bin.clone());
|
| 75 |
+
let launched_identity =
|
| 76 |
+
if matches!(self.command_kind, super::PidCommandKind::AppServer { .. }) {
|
| 77 |
+
executable_identity(&codex_bin).await.ok()
|
| 78 |
+
} else {
|
| 79 |
+
None
|
| 80 |
+
};
|
| 81 |
+
let mut command = Command::new(&codex_bin);
|
| 82 |
+
let stderr_log = match self.open_stderr_log().await {
|
| 83 |
+
Ok(stderr_log) => stderr_log,
|
| 84 |
+
Err(err) => {
|
| 85 |
+
if replacement.is_none() {
|
| 86 |
+
let _ = fs::remove_file(&self.pid_file).await;
|
| 87 |
+
}
|
| 88 |
+
return Err(err);
|
| 89 |
+
}
|
| 90 |
+
};
|
| 91 |
+
command
|
| 92 |
+
.args(self.command_args())
|
| 93 |
+
.stdin(Stdio::null())
|
| 94 |
+
.stdout(Stdio::null())
|
| 95 |
+
.stderr(Stdio::from(stderr_log.into_std().await));
|
| 96 |
+
// Older or pinned managed binaries may predate this optional startup flag.
|
| 97 |
+
let managed_app_server =
|
| 98 |
+
matches!(self.command_kind, super::PidCommandKind::AppServer { .. });
|
| 99 |
+
if managed_app_server
|
| 100 |
+
&& matches!(
|
| 101 |
+
tokio::time::timeout(
|
| 102 |
+
std::time::Duration::from_secs(5),
|
| 103 |
+
Command::new(&codex_bin)
|
| 104 |
+
.args(["app-server", "--managed-daemon", "--help"])
|
| 105 |
+
.stdin(Stdio::null())
|
| 106 |
+
.stdout(Stdio::null())
|
| 107 |
+
.stderr(Stdio::null())
|
| 108 |
+
.kill_on_drop(true)
|
| 109 |
+
.status(),
|
| 110 |
+
).await,
|
| 111 |
+
Ok(Ok(status)) if status.success()
|
| 112 |
+
)
|
| 113 |
+
{
|
| 114 |
+
command.arg("--managed-daemon");
|
| 115 |
+
} else if managed_app_server {
|
| 116 |
+
let codex_home = self
|
| 117 |
+
.pid_file
|
| 118 |
+
.parent()
|
| 119 |
+
.and_then(std::path::Path::parent)
|
| 120 |
+
.context("daemon pid path has no Codex home")?;
|
| 121 |
+
let recovery_file = codex_app_server_transport::daemon_recovery_file_path(codex_home);
|
| 122 |
+
match fs::remove_file(&recovery_file).await {
|
| 123 |
+
Ok(()) => {}
|
| 124 |
+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
| 125 |
+
Err(err) => {
|
| 126 |
+
tracing::warn!(path = %recovery_file.display(), %err, "failed to clear daemon recovery state before legacy launch");
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
if let Some((key, value)) = self.command_env() {
|
| 131 |
+
command.env(key, value);
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
#[cfg(unix)]
|
| 135 |
+
{
|
| 136 |
+
unsafe {
|
| 137 |
+
command.pre_exec(|| {
|
| 138 |
+
if libc::setsid() == -1 {
|
| 139 |
+
return Err(std::io::Error::last_os_error());
|
| 140 |
+
}
|
| 141 |
+
Ok(())
|
| 142 |
+
});
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
#[cfg(windows)]
|
| 147 |
+
{
|
| 148 |
+
use windows_sys::Win32::System::Threading::CREATE_BREAKAWAY_FROM_JOB;
|
| 149 |
+
use windows_sys::Win32::System::Threading::DETACHED_PROCESS;
|
| 150 |
+
// Preserve process-scoped paths before changing cwd; CA names match CUSTOM_CA_ENV_KEYS.
|
| 151 |
+
for name in [
|
| 152 |
+
"CODEX_HOME",
|
| 153 |
+
"CODEX_SQLITE_HOME",
|
| 154 |
+
"CODEX_CA_CERTIFICATE",
|
| 155 |
+
"SSL_CERT_FILE",
|
| 156 |
+
"REQUESTS_CA_BUNDLE",
|
| 157 |
+
"CURL_CA_BUNDLE",
|
| 158 |
+
"NODE_EXTRA_CA_CERTS",
|
| 159 |
+
"GIT_SSL_CAINFO",
|
| 160 |
+
"CARGO_HTTP_CAINFO",
|
| 161 |
+
"PIP_CERT",
|
| 162 |
+
"BUNDLE_SSL_CA_CERT",
|
| 163 |
+
"npm_config_cafile",
|
| 164 |
+
"AWS_CONFIG_FILE",
|
| 165 |
+
"AWS_SHARED_CREDENTIALS_FILE",
|
| 166 |
+
"AWS_WEB_IDENTITY_TOKEN_FILE",
|
| 167 |
+
"AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE",
|
| 168 |
+
] {
|
| 169 |
+
let Some(mut value) = std::env::var_os(name) else {
|
| 170 |
+
continue;
|
| 171 |
+
};
|
| 172 |
+
if matches!(name, "CODEX_SQLITE_HOME" | "npm_config_cafile") {
|
| 173 |
+
value = value.to_str().unwrap_or_default().trim().into();
|
| 174 |
+
}
|
| 175 |
+
// These consumers expand `~` independently of the working directory.
|
| 176 |
+
let expands_home = if name == "npm_config_cafile" {
|
| 177 |
+
value
|
| 178 |
+
.to_str()
|
| 179 |
+
.is_some_and(|path| path.starts_with("~/") || path.starts_with("~\\"))
|
| 180 |
+
} else {
|
| 181 |
+
matches!(
|
| 182 |
+
name,
|
| 183 |
+
"CODEX_SQLITE_HOME" | "AWS_CONFIG_FILE" | "AWS_SHARED_CREDENTIALS_FILE"
|
| 184 |
+
) && std::path::Path::new(&value).starts_with("~")
|
| 185 |
+
};
|
| 186 |
+
if value.is_empty() || expands_home {
|
| 187 |
+
continue;
|
| 188 |
+
}
|
| 189 |
+
command.env(name, std::path::absolute(value)?);
|
| 190 |
+
}
|
| 191 |
+
if let Some(value) = std::env::var_os("SSL_CERT_DIR") {
|
| 192 |
+
let paths = std::env::split_paths(&value)
|
| 193 |
+
.filter(|path| !path.as_os_str().is_empty())
|
| 194 |
+
.map(std::path::absolute)
|
| 195 |
+
.collect::<std::io::Result<Vec<_>>>()?;
|
| 196 |
+
command.env("SSL_CERT_DIR", std::env::join_paths(paths)?);
|
| 197 |
+
}
|
| 198 |
+
// A Windows process pins its working directory for its lifetime.
|
| 199 |
+
// Keep both managed children out of the launching project's directory.
|
| 200 |
+
command.current_dir(
|
| 201 |
+
self.pid_file
|
| 202 |
+
.parent()
|
| 203 |
+
.context("daemon pid path has no parent")?,
|
| 204 |
+
);
|
| 205 |
+
// Never retry inside the parent's Job Object: that would report a
|
| 206 |
+
// successful launch that dies when the terminal/SSH session closes.
|
| 207 |
+
command.creation_flags(DETACHED_PROCESS | CREATE_BREAKAWAY_FROM_JOB);
|
| 208 |
+
if matches!(self.command_kind, PidCommandKind::UpdateLoop { .. }) {
|
| 209 |
+
match fs::remove_file(self.pid_file.with_extension("ready")).await {
|
| 210 |
+
Ok(()) => {}
|
| 211 |
+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
| 212 |
+
Err(err) => return Err(err).context("failed to clear updater readiness"),
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
match self.command_kind {
|
| 216 |
+
PidCommandKind::AppServer { .. } => {
|
| 217 |
+
command.env(codex_app_server_transport::DAEMON_SHUTDOWN_SOCKET_ENV, "1");
|
| 218 |
+
}
|
| 219 |
+
PidCommandKind::UpdateLoop { .. } => {
|
| 220 |
+
let shutdown_file = self.pid_file.with_extension("shutdown");
|
| 221 |
+
match fs::remove_file(&shutdown_file).await {
|
| 222 |
+
Ok(()) => {}
|
| 223 |
+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
| 224 |
+
Err(err) => {
|
| 225 |
+
return Err(err).context("failed to clear updater shutdown request");
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
command.env(
|
| 229 |
+
codex_app_server_transport::DAEMON_SHUTDOWN_FILE_ENV,
|
| 230 |
+
shutdown_file,
|
| 231 |
+
);
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
let child = match command.spawn() {
|
| 237 |
+
Ok(child) => child,
|
| 238 |
+
Err(err) => {
|
| 239 |
+
if replacement.is_none() {
|
| 240 |
+
let _ = fs::remove_file(&self.pid_file).await;
|
| 241 |
+
}
|
| 242 |
+
return Err(err).with_context(|| {
|
| 243 |
+
let job_hint = if cfg!(windows) {
|
| 244 |
+
" (the Windows host job must allow breakaway)"
|
| 245 |
+
} else {
|
| 246 |
+
""
|
| 247 |
+
};
|
| 248 |
+
format!(
|
| 249 |
+
"failed to spawn detached app-server process using {}{job_hint}",
|
| 250 |
+
self.codex_bin.display()
|
| 251 |
+
)
|
| 252 |
+
});
|
| 253 |
+
}
|
| 254 |
+
};
|
| 255 |
+
let pid = child
|
| 256 |
+
.id()
|
| 257 |
+
.context("spawned app-server process has no pid")?;
|
| 258 |
+
let record = match async {
|
| 259 |
+
#[cfg(windows)]
|
| 260 |
+
super::super::windows::Process::open(pid)?
|
| 261 |
+
.context("daemon exited during launch")?
|
| 262 |
+
.ensure_detached()?;
|
| 263 |
+
let process_start_time = read_process_start_time(pid).await?;
|
| 264 |
+
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
| 265 |
+
let process_identity = super::identity::read_process_details(pid)
|
| 266 |
+
.await
|
| 267 |
+
.ok()
|
| 268 |
+
.map(|(_, identity)| identity);
|
| 269 |
+
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
| 270 |
+
let process_identity = None;
|
| 271 |
+
anyhow::Ok(PidRecord {
|
| 272 |
+
pid,
|
| 273 |
+
process_start_time,
|
| 274 |
+
process_identity,
|
| 275 |
+
executable_identity: launched_identity,
|
| 276 |
+
})
|
| 277 |
+
}
|
| 278 |
+
.await
|
| 279 |
+
{
|
| 280 |
+
Ok(record) => record,
|
| 281 |
+
Err(err) => {
|
| 282 |
+
let _ = self.terminate_process(pid);
|
| 283 |
+
let mut context =
|
| 284 |
+
format!("failed to record pid-managed app-server process {pid} startup");
|
| 285 |
+
super::super::append_stderr_log_tail_context(&self.pid_file, &mut context).await;
|
| 286 |
+
if replacement.is_none() {
|
| 287 |
+
let _ = fs::remove_file(&self.pid_file).await;
|
| 288 |
+
}
|
| 289 |
+
return Err(err).context(context);
|
| 290 |
+
}
|
| 291 |
+
};
|
| 292 |
+
let contents = serde_json::to_vec(&record).context("failed to serialize pid record")?;
|
| 293 |
+
let temp_pid_file = self.pid_file.with_extension("pid.tmp");
|
| 294 |
+
if let Err(err) = fs::write(&temp_pid_file, &contents).await {
|
| 295 |
+
let _ = self.terminate_process(pid);
|
| 296 |
+
if replacement.is_none() {
|
| 297 |
+
let _ = fs::remove_file(&self.pid_file).await;
|
| 298 |
+
}
|
| 299 |
+
return Err(err).with_context(|| {
|
| 300 |
+
format!("failed to write pid temp file {}", temp_pid_file.display())
|
| 301 |
+
});
|
| 302 |
+
}
|
| 303 |
+
if let Err(err) = fs::rename(&temp_pid_file, &self.pid_file).await {
|
| 304 |
+
let _ = self.terminate_process(pid);
|
| 305 |
+
let _ = fs::remove_file(&temp_pid_file).await;
|
| 306 |
+
if replacement.is_none() {
|
| 307 |
+
let _ = fs::remove_file(&self.pid_file).await;
|
| 308 |
+
}
|
| 309 |
+
return Err(err).with_context(|| {
|
| 310 |
+
format!("failed to publish pid file {}", self.pid_file.display())
|
| 311 |
+
});
|
| 312 |
+
}
|
| 313 |
+
#[cfg(windows)]
|
| 314 |
+
if matches!(self.command_kind, PidCommandKind::UpdateLoop { .. }) {
|
| 315 |
+
self.finish_updater_start(&record, replacement.as_ref())
|
| 316 |
+
.await?;
|
| 317 |
+
}
|
| 318 |
+
drop(reservation_lock);
|
| 319 |
+
Ok(Some(pid))
|
| 320 |
+
}
|
| 321 |
+
}
|
codex-rs/app-server-daemon/src/backend/pid_tests.rs
ADDED
|
@@ -0,0 +1,924 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#[cfg(any(unix, windows))]
|
| 2 |
+
use std::process::Stdio;
|
| 3 |
+
use std::time::Duration;
|
| 4 |
+
|
| 5 |
+
use pretty_assertions::assert_eq;
|
| 6 |
+
use tempfile::TempDir;
|
| 7 |
+
#[cfg(any(unix, windows))]
|
| 8 |
+
use tokio::time::sleep;
|
| 9 |
+
|
| 10 |
+
use codex_app_server_transport::REMOTE_CONTROL_DISABLED_ENV_VAR;
|
| 11 |
+
|
| 12 |
+
use super::PidBackend;
|
| 13 |
+
use super::PidCommandKind;
|
| 14 |
+
use super::PidFileState;
|
| 15 |
+
use super::PidLogTail;
|
| 16 |
+
use super::PidRecord;
|
| 17 |
+
#[cfg(unix)]
|
| 18 |
+
use super::read_process_start_time;
|
| 19 |
+
use super::read_stderr_log_tail;
|
| 20 |
+
use super::stderr_log_file_for_pid_file;
|
| 21 |
+
use super::try_lock_file;
|
| 22 |
+
|
| 23 |
+
#[cfg(windows)]
|
| 24 |
+
fn is_elevated_test_process() -> anyhow::Result<bool> {
|
| 25 |
+
let output = std::process::Command::new("powershell.exe")
|
| 26 |
+
.args([
|
| 27 |
+
"-NoProfile",
|
| 28 |
+
"-NonInteractive",
|
| 29 |
+
"-Command",
|
| 30 |
+
"[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)",
|
| 31 |
+
])
|
| 32 |
+
.output()?;
|
| 33 |
+
anyhow::ensure!(
|
| 34 |
+
output.status.success(),
|
| 35 |
+
"administrator membership query failed"
|
| 36 |
+
);
|
| 37 |
+
match String::from_utf8(output.stdout)?.trim() {
|
| 38 |
+
"True" => Ok(true),
|
| 39 |
+
"False" => Ok(false),
|
| 40 |
+
other => anyhow::bail!("unexpected administrator membership: {other}"),
|
| 41 |
+
}
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
#[tokio::test]
|
| 45 |
+
async fn locked_empty_pid_file_is_treated_as_active_reservation() {
|
| 46 |
+
let temp_dir = TempDir::new().expect("temp dir");
|
| 47 |
+
let pid_file = temp_dir.path().join("app-server.pid");
|
| 48 |
+
tokio::fs::write(&pid_file, "")
|
| 49 |
+
.await
|
| 50 |
+
.expect("write pid file");
|
| 51 |
+
let backend = PidBackend::new(
|
| 52 |
+
temp_dir.path().join("codex"),
|
| 53 |
+
pid_file.clone(),
|
| 54 |
+
/*remote_control_enabled*/ false,
|
| 55 |
+
);
|
| 56 |
+
let reservation = tokio::fs::OpenOptions::new()
|
| 57 |
+
.create(true)
|
| 58 |
+
.truncate(false)
|
| 59 |
+
.write(true)
|
| 60 |
+
.open(&backend.lock_file)
|
| 61 |
+
.await
|
| 62 |
+
.expect("open pid lock file");
|
| 63 |
+
assert!(try_lock_file(&reservation).expect("lock reservation"));
|
| 64 |
+
|
| 65 |
+
assert_eq!(
|
| 66 |
+
backend.read_pid_file_state().await.expect("read pid"),
|
| 67 |
+
PidFileState::Starting
|
| 68 |
+
);
|
| 69 |
+
assert!(pid_file.exists());
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
#[tokio::test]
|
| 73 |
+
async fn unlocked_empty_pid_file_is_treated_as_stale_reservation() {
|
| 74 |
+
let temp_dir = TempDir::new().expect("temp dir");
|
| 75 |
+
let pid_file = temp_dir.path().join("app-server.pid");
|
| 76 |
+
tokio::fs::write(&pid_file, "")
|
| 77 |
+
.await
|
| 78 |
+
.expect("write pid file");
|
| 79 |
+
let backend = PidBackend::new(
|
| 80 |
+
temp_dir.path().join("codex"),
|
| 81 |
+
pid_file.clone(),
|
| 82 |
+
/*remote_control_enabled*/ false,
|
| 83 |
+
);
|
| 84 |
+
|
| 85 |
+
assert_eq!(
|
| 86 |
+
backend.read_pid_file_state().await.expect("read pid"),
|
| 87 |
+
PidFileState::Missing
|
| 88 |
+
);
|
| 89 |
+
assert!(!pid_file.exists());
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
#[tokio::test]
|
| 93 |
+
async fn stop_waits_for_live_reservation_to_resolve() {
|
| 94 |
+
let temp_dir = TempDir::new().expect("temp dir");
|
| 95 |
+
let pid_file = temp_dir.path().join("app-server.pid");
|
| 96 |
+
tokio::fs::write(&pid_file, "")
|
| 97 |
+
.await
|
| 98 |
+
.expect("write pid file");
|
| 99 |
+
let backend = PidBackend::new(
|
| 100 |
+
temp_dir.path().join("codex"),
|
| 101 |
+
pid_file.clone(),
|
| 102 |
+
/*remote_control_enabled*/ false,
|
| 103 |
+
);
|
| 104 |
+
let reservation = tokio::fs::OpenOptions::new()
|
| 105 |
+
.create(true)
|
| 106 |
+
.truncate(false)
|
| 107 |
+
.write(true)
|
| 108 |
+
.open(&backend.lock_file)
|
| 109 |
+
.await
|
| 110 |
+
.expect("open pid lock file");
|
| 111 |
+
assert!(try_lock_file(&reservation).expect("lock reservation"));
|
| 112 |
+
let cleanup = tokio::spawn(async move {
|
| 113 |
+
tokio::time::sleep(Duration::from_millis(50)).await;
|
| 114 |
+
drop(reservation);
|
| 115 |
+
tokio::fs::remove_file(pid_file)
|
| 116 |
+
.await
|
| 117 |
+
.expect("remove pid file");
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
backend.stop().await.expect("stop");
|
| 121 |
+
cleanup.await.expect("cleanup task");
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
#[tokio::test]
|
| 125 |
+
async fn start_retries_stale_empty_pid_file_under_its_own_lock() {
|
| 126 |
+
let temp_dir = TempDir::new().expect("temp dir");
|
| 127 |
+
let state_dir = temp_dir.path().join("state");
|
| 128 |
+
codex_uds::prepare_private_socket_directory(&state_dir)
|
| 129 |
+
.await
|
| 130 |
+
.expect("private state directory");
|
| 131 |
+
let pid_file = state_dir.join("app-server.pid");
|
| 132 |
+
tokio::fs::write(&pid_file, "")
|
| 133 |
+
.await
|
| 134 |
+
.expect("write pid file");
|
| 135 |
+
let backend = PidBackend::new(
|
| 136 |
+
temp_dir.path().join("missing-codex"),
|
| 137 |
+
pid_file,
|
| 138 |
+
/*remote_control_enabled*/ false,
|
| 139 |
+
);
|
| 140 |
+
|
| 141 |
+
let err = backend.start().await.expect_err("start");
|
| 142 |
+
#[cfg(windows)]
|
| 143 |
+
if is_elevated_test_process().expect("query administrator membership") {
|
| 144 |
+
assert!(err.to_string().contains("non-elevated terminal"));
|
| 145 |
+
assert_eq!(
|
| 146 |
+
tokio::fs::read(&backend.pid_file).await.unwrap(),
|
| 147 |
+
Vec::<u8>::new()
|
| 148 |
+
);
|
| 149 |
+
assert!(!backend.lock_file.exists());
|
| 150 |
+
return;
|
| 151 |
+
}
|
| 152 |
+
assert!(
|
| 153 |
+
err.to_string()
|
| 154 |
+
.starts_with("failed to spawn detached app-server process using ")
|
| 155 |
+
);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
#[cfg(unix)]
|
| 159 |
+
#[tokio::test]
|
| 160 |
+
async fn legacy_launch_clears_recovery_best_effort() {
|
| 161 |
+
for snapshot_is_directory in [false, true] {
|
| 162 |
+
let home = TempDir::new().expect("temp dir");
|
| 163 |
+
let state_dir = home.path().join("app-server-daemon");
|
| 164 |
+
std::fs::create_dir_all(&state_dir).expect("state dir");
|
| 165 |
+
let recovery_file = codex_app_server_transport::daemon_recovery_file_path(home.path());
|
| 166 |
+
if snapshot_is_directory {
|
| 167 |
+
std::fs::create_dir(&recovery_file).expect("invalid snapshot directory");
|
| 168 |
+
} else {
|
| 169 |
+
std::fs::write(&recovery_file, "{}").expect("pending snapshot");
|
| 170 |
+
}
|
| 171 |
+
let backend = PidBackend::new(
|
| 172 |
+
home.path().join("missing-codex"),
|
| 173 |
+
state_dir.join("app-server.pid"),
|
| 174 |
+
/*remote_control_enabled*/ false,
|
| 175 |
+
);
|
| 176 |
+
|
| 177 |
+
let error = backend.start().await.expect_err("missing binary");
|
| 178 |
+
assert!(
|
| 179 |
+
error
|
| 180 |
+
.to_string()
|
| 181 |
+
.starts_with("failed to spawn detached app-server process using "),
|
| 182 |
+
"{error:#}"
|
| 183 |
+
);
|
| 184 |
+
assert_eq!(recovery_file.exists(), snapshot_is_directory);
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
#[tokio::test]
|
| 189 |
+
async fn stale_record_cleanup_preserves_replacement_record() {
|
| 190 |
+
let temp_dir = TempDir::new().expect("temp dir");
|
| 191 |
+
let pid_file = temp_dir.path().join("app-server.pid");
|
| 192 |
+
let backend = PidBackend::new(
|
| 193 |
+
temp_dir.path().join("codex"),
|
| 194 |
+
pid_file.clone(),
|
| 195 |
+
/*remote_control_enabled*/ false,
|
| 196 |
+
);
|
| 197 |
+
let stale = PidRecord {
|
| 198 |
+
pid: 1,
|
| 199 |
+
process_start_time: "old".to_string(),
|
| 200 |
+
process_identity: None,
|
| 201 |
+
executable_identity: None,
|
| 202 |
+
};
|
| 203 |
+
let replacement = PidRecord {
|
| 204 |
+
pid: 2,
|
| 205 |
+
process_start_time: "new".to_string(),
|
| 206 |
+
process_identity: None,
|
| 207 |
+
executable_identity: None,
|
| 208 |
+
};
|
| 209 |
+
tokio::fs::write(
|
| 210 |
+
&pid_file,
|
| 211 |
+
serde_json::to_vec(&replacement).expect("serialize replacement"),
|
| 212 |
+
)
|
| 213 |
+
.await
|
| 214 |
+
.expect("write replacement pid file");
|
| 215 |
+
|
| 216 |
+
assert_eq!(
|
| 217 |
+
backend
|
| 218 |
+
.refresh_after_stale_record(&stale)
|
| 219 |
+
.await
|
| 220 |
+
.expect("cleanup"),
|
| 221 |
+
PidFileState::Running(replacement)
|
| 222 |
+
);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
#[cfg(unix)]
|
| 226 |
+
#[tokio::test]
|
| 227 |
+
async fn pid_record_captures_the_resolved_launch_binary() {
|
| 228 |
+
use std::os::unix::fs::PermissionsExt;
|
| 229 |
+
|
| 230 |
+
let temp = TempDir::new().expect("temp dir");
|
| 231 |
+
let original = temp.path().join("original-codex");
|
| 232 |
+
let replacement = temp.path().join("replacement-codex");
|
| 233 |
+
for (path, bytes) in [
|
| 234 |
+
(&original, b"#!/bin/sh\nexec sleep 30\n".as_slice()),
|
| 235 |
+
(
|
| 236 |
+
&replacement,
|
| 237 |
+
b"#!/bin/sh\n# replacement\nexec sleep 30\n".as_slice(),
|
| 238 |
+
),
|
| 239 |
+
] {
|
| 240 |
+
std::fs::write(path, bytes).expect("binary");
|
| 241 |
+
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).expect("executable");
|
| 242 |
+
}
|
| 243 |
+
let selected = temp.path().join("current-codex");
|
| 244 |
+
std::os::unix::fs::symlink(&original, &selected).expect("selected binary");
|
| 245 |
+
let backend = PidBackend::new(
|
| 246 |
+
selected.clone(),
|
| 247 |
+
temp.path().join("app-server.pid"),
|
| 248 |
+
/*remote_control_enabled*/ false,
|
| 249 |
+
);
|
| 250 |
+
backend.start().await.expect("start daemon");
|
| 251 |
+
let record: PidRecord =
|
| 252 |
+
serde_json::from_slice(&std::fs::read(&backend.pid_file).expect("PID record"))
|
| 253 |
+
.expect("parse PID record");
|
| 254 |
+
assert_eq!(
|
| 255 |
+
record.executable_identity,
|
| 256 |
+
Some(
|
| 257 |
+
crate::managed_install::executable_identity(&original)
|
| 258 |
+
.await
|
| 259 |
+
.expect("original digest")
|
| 260 |
+
)
|
| 261 |
+
);
|
| 262 |
+
std::fs::remove_file(&selected).expect("remove link");
|
| 263 |
+
std::os::unix::fs::symlink(&replacement, &selected).expect("retarget link");
|
| 264 |
+
assert_ne!(
|
| 265 |
+
record.executable_identity,
|
| 266 |
+
Some(
|
| 267 |
+
crate::managed_install::executable_identity(&selected)
|
| 268 |
+
.await
|
| 269 |
+
.expect("new digest")
|
| 270 |
+
)
|
| 271 |
+
);
|
| 272 |
+
assert_eq!(
|
| 273 |
+
serde_json::from_str::<PidRecord>(r#"{"pid":1,"processStartTime":"old"}"#)
|
| 274 |
+
.expect("legacy PID record")
|
| 275 |
+
.executable_identity,
|
| 276 |
+
None
|
| 277 |
+
);
|
| 278 |
+
backend.stop().await.expect("stop daemon");
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
#[cfg(unix)]
|
| 282 |
+
#[tokio::test]
|
| 283 |
+
async fn legacy_start_time_mismatch_preserves_record_and_process() {
|
| 284 |
+
let temp = TempDir::new().unwrap();
|
| 285 |
+
let backend = PidBackend::new(
|
| 286 |
+
temp.path().join("codex"),
|
| 287 |
+
temp.path().join("app-server.pid"),
|
| 288 |
+
/*remote_control_enabled*/ false,
|
| 289 |
+
);
|
| 290 |
+
let contents = serde_json::to_vec(&serde_json::json!({
|
| 291 |
+
"pid": std::process::id(),
|
| 292 |
+
"processStartTime": "historical wall-clock start time",
|
| 293 |
+
}))
|
| 294 |
+
.unwrap();
|
| 295 |
+
std::fs::write(&backend.pid_file, &contents).unwrap();
|
| 296 |
+
for result in [
|
| 297 |
+
backend.is_starting_or_running().await.map(|_| ()),
|
| 298 |
+
backend.start().await.map(|_| ()),
|
| 299 |
+
backend.stop().await,
|
| 300 |
+
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
| 301 |
+
backend.promote_legacy_identity().await,
|
| 302 |
+
] {
|
| 303 |
+
assert!(
|
| 304 |
+
result
|
| 305 |
+
.unwrap_err()
|
| 306 |
+
.to_string()
|
| 307 |
+
.contains("PID record retained")
|
| 308 |
+
);
|
| 309 |
+
}
|
| 310 |
+
assert_eq!(std::fs::read(&backend.pid_file).unwrap(), contents);
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
#[cfg(unix)]
|
| 314 |
+
#[tokio::test]
|
| 315 |
+
async fn stop_reaps_untracked_app_server_child() {
|
| 316 |
+
let temp_dir = TempDir::new().expect("temp dir");
|
| 317 |
+
let pid_file = temp_dir.path().join("app-server.pid");
|
| 318 |
+
let mut child = std::process::Command::new("sleep")
|
| 319 |
+
.arg("5")
|
| 320 |
+
.stdin(Stdio::null())
|
| 321 |
+
.stdout(Stdio::null())
|
| 322 |
+
.stderr(Stdio::null())
|
| 323 |
+
.spawn()
|
| 324 |
+
.expect("spawn app-server shim");
|
| 325 |
+
let pid = child.id();
|
| 326 |
+
let record = PidRecord {
|
| 327 |
+
pid,
|
| 328 |
+
process_start_time: read_process_start_time(pid).await.expect("start time"),
|
| 329 |
+
process_identity: None,
|
| 330 |
+
executable_identity: None,
|
| 331 |
+
};
|
| 332 |
+
tokio::fs::write(
|
| 333 |
+
&pid_file,
|
| 334 |
+
serde_json::to_vec(&record).expect("serialize pid"),
|
| 335 |
+
)
|
| 336 |
+
.await
|
| 337 |
+
.expect("write pid file");
|
| 338 |
+
let backend = PidBackend::new(
|
| 339 |
+
temp_dir.path().join("codex"),
|
| 340 |
+
pid_file.clone(),
|
| 341 |
+
/*remote_control_enabled*/ false,
|
| 342 |
+
);
|
| 343 |
+
|
| 344 |
+
let result = tokio::time::timeout(Duration::from_secs(2), backend.stop()).await;
|
| 345 |
+
if matches!(child.try_wait(), Ok(None)) {
|
| 346 |
+
let _ = child.kill();
|
| 347 |
+
let _ = child.wait();
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
// `sleep` is not tracked by Tokio, so stop must reap it instead of leaving a zombie.
|
| 351 |
+
result.expect("stop timed out").expect("stop");
|
| 352 |
+
assert!(!pid_file.exists());
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
#[cfg(any(unix, windows))]
|
| 356 |
+
#[tokio::test]
|
| 357 |
+
async fn shutdown_grace_child() {
|
| 358 |
+
let Some(ready) = std::env::var_os("CODEX_TEST_SHUTDOWN_GRACE_READY") else {
|
| 359 |
+
return;
|
| 360 |
+
};
|
| 361 |
+
let ready = std::path::PathBuf::from(ready);
|
| 362 |
+
#[cfg(unix)]
|
| 363 |
+
let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
| 364 |
+
.expect("install shutdown handler");
|
| 365 |
+
tokio::fs::write(&ready, "").await.expect("ready marker");
|
| 366 |
+
let _ = tokio::time::timeout(Duration::from_secs(8), async {
|
| 367 |
+
#[cfg(unix)]
|
| 368 |
+
terminate.recv().await;
|
| 369 |
+
#[cfg(windows)]
|
| 370 |
+
loop {
|
| 371 |
+
if ready.with_extension("shutdown").exists() {
|
| 372 |
+
break;
|
| 373 |
+
}
|
| 374 |
+
sleep(Duration::from_millis(10)).await;
|
| 375 |
+
}
|
| 376 |
+
if std::env::var_os("CODEX_TEST_SHUTDOWN_GRACE_EXIT").is_some() {
|
| 377 |
+
sleep(Duration::from_millis(150)).await;
|
| 378 |
+
tokio::fs::write(ready.with_extension("exited"), "")
|
| 379 |
+
.await
|
| 380 |
+
.expect("graceful exit marker");
|
| 381 |
+
return;
|
| 382 |
+
}
|
| 383 |
+
std::future::pending::<()>().await;
|
| 384 |
+
})
|
| 385 |
+
.await;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
#[cfg(any(unix, windows))]
|
| 389 |
+
#[tokio::test]
|
| 390 |
+
async fn shutdown_grace_handles_process_exit() {
|
| 391 |
+
let temp = TempDir::new().expect("temp dir");
|
| 392 |
+
for (name, grace_seconds, exits) in [
|
| 393 |
+
("zero", 0, false),
|
| 394 |
+
("finite_exit", 1, true),
|
| 395 |
+
("finite_force", 1, false),
|
| 396 |
+
] {
|
| 397 |
+
let ready = temp.path().join(format!("{name}.ready"));
|
| 398 |
+
let mut child = std::process::Command::new(std::env::current_exe().expect("test binary"))
|
| 399 |
+
.args(["--exact", "backend::pid::tests::shutdown_grace_child"])
|
| 400 |
+
.env("CODEX_TEST_SHUTDOWN_GRACE_READY", &ready)
|
| 401 |
+
.envs(exits.then_some(("CODEX_TEST_SHUTDOWN_GRACE_EXIT", "1")))
|
| 402 |
+
.stdout(Stdio::null())
|
| 403 |
+
.stderr(Stdio::null())
|
| 404 |
+
.spawn()
|
| 405 |
+
.expect("spawn daemon shim");
|
| 406 |
+
let pid = child.id();
|
| 407 |
+
let wait_until = tokio::time::Instant::now() + Duration::from_secs(3);
|
| 408 |
+
while !ready.exists() {
|
| 409 |
+
assert!(
|
| 410 |
+
tokio::time::Instant::now() < wait_until,
|
| 411 |
+
"shim did not start"
|
| 412 |
+
);
|
| 413 |
+
sleep(Duration::from_millis(10)).await;
|
| 414 |
+
}
|
| 415 |
+
let pid_file = temp.path().join(format!("{name}.pid"));
|
| 416 |
+
let record = PidRecord {
|
| 417 |
+
pid,
|
| 418 |
+
process_start_time: super::read_process_start_time(pid)
|
| 419 |
+
.await
|
| 420 |
+
.expect("start time"),
|
| 421 |
+
process_identity: None,
|
| 422 |
+
executable_identity: None,
|
| 423 |
+
};
|
| 424 |
+
tokio::fs::write(
|
| 425 |
+
&pid_file,
|
| 426 |
+
serde_json::to_vec(&record).expect("serialize pid"),
|
| 427 |
+
)
|
| 428 |
+
.await
|
| 429 |
+
.expect("write pid file");
|
| 430 |
+
#[cfg(unix)]
|
| 431 |
+
let backend = PidBackend::new(
|
| 432 |
+
temp.path().join("codex"),
|
| 433 |
+
pid_file,
|
| 434 |
+
/*remote_control_enabled*/ false,
|
| 435 |
+
);
|
| 436 |
+
#[cfg(windows)]
|
| 437 |
+
let backend = PidBackend::new_update_loop(
|
| 438 |
+
temp.path().join("codex"),
|
| 439 |
+
pid_file,
|
| 440 |
+
/*restore_release*/ None,
|
| 441 |
+
);
|
| 442 |
+
let result = tokio::time::timeout(
|
| 443 |
+
Duration::from_secs(3),
|
| 444 |
+
backend.stop_with_grace(grace_seconds),
|
| 445 |
+
)
|
| 446 |
+
.await;
|
| 447 |
+
let still_running = backend
|
| 448 |
+
.record_is_active(&record)
|
| 449 |
+
.await
|
| 450 |
+
.expect("child status");
|
| 451 |
+
if still_running {
|
| 452 |
+
child.kill().expect("clean up daemon shim");
|
| 453 |
+
}
|
| 454 |
+
let _ = child.wait(); // The backend may already have reaped a Unix child.
|
| 455 |
+
result.expect("stop deadline").expect("stop outcome");
|
| 456 |
+
assert_eq!(ready.with_extension("exited").exists(), exits);
|
| 457 |
+
assert!(!still_running, "{name}");
|
| 458 |
+
}
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
#[cfg(unix)]
|
| 462 |
+
#[tokio::test]
|
| 463 |
+
async fn stopping_updater_signals_its_installer_process_group() {
|
| 464 |
+
use std::os::unix::process::CommandExt;
|
| 465 |
+
|
| 466 |
+
let temp = TempDir::new().expect("temp dir");
|
| 467 |
+
let ready = temp.path().join("installer.ready");
|
| 468 |
+
let stopped = temp.path().join("installer.stopped");
|
| 469 |
+
let mut command = std::process::Command::new("/bin/sh");
|
| 470 |
+
command
|
| 471 |
+
.args([
|
| 472 |
+
"-c",
|
| 473 |
+
"sh -c 'trap \"touch $STOP_MARKER; exit 0\" TERM; touch $READY_MARKER; while :; do sleep 1; done' & wait",
|
| 474 |
+
])
|
| 475 |
+
.env("READY_MARKER", &ready)
|
| 476 |
+
.env("STOP_MARKER", &stopped)
|
| 477 |
+
.stdout(Stdio::null())
|
| 478 |
+
.stderr(Stdio::null());
|
| 479 |
+
unsafe {
|
| 480 |
+
command.pre_exec(|| {
|
| 481 |
+
if libc::setsid() == -1 {
|
| 482 |
+
return Err(std::io::Error::last_os_error());
|
| 483 |
+
}
|
| 484 |
+
Ok(())
|
| 485 |
+
});
|
| 486 |
+
}
|
| 487 |
+
let mut child = command.spawn().expect("spawn updater shim");
|
| 488 |
+
let pid = child.id();
|
| 489 |
+
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
|
| 490 |
+
while !ready.exists() {
|
| 491 |
+
assert!(
|
| 492 |
+
tokio::time::Instant::now() < deadline,
|
| 493 |
+
"installer did not start"
|
| 494 |
+
);
|
| 495 |
+
sleep(Duration::from_millis(10)).await;
|
| 496 |
+
}
|
| 497 |
+
let pid_file = temp.path().join("updater.pid");
|
| 498 |
+
tokio::fs::write(
|
| 499 |
+
&pid_file,
|
| 500 |
+
serde_json::to_vec(&PidRecord {
|
| 501 |
+
pid,
|
| 502 |
+
process_start_time: read_process_start_time(pid).await.expect("start time"),
|
| 503 |
+
process_identity: None,
|
| 504 |
+
executable_identity: None,
|
| 505 |
+
})
|
| 506 |
+
.expect("serialize pid"),
|
| 507 |
+
)
|
| 508 |
+
.await
|
| 509 |
+
.expect("write pid file");
|
| 510 |
+
let backend = PidBackend::new_update_loop(
|
| 511 |
+
temp.path().join("codex"),
|
| 512 |
+
pid_file,
|
| 513 |
+
/*restore_release*/ None,
|
| 514 |
+
);
|
| 515 |
+
backend.stop().await.expect("stop updater");
|
| 516 |
+
// The backend normally reaps the shim, so a second wait may return ECHILD.
|
| 517 |
+
let _ = child.wait();
|
| 518 |
+
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
|
| 519 |
+
while !stopped.exists() {
|
| 520 |
+
assert!(
|
| 521 |
+
tokio::time::Instant::now() < deadline,
|
| 522 |
+
"installer was not stopped"
|
| 523 |
+
);
|
| 524 |
+
sleep(Duration::from_millis(10)).await;
|
| 525 |
+
}
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
#[cfg(unix)]
|
| 529 |
+
#[tokio::test]
|
| 530 |
+
async fn exited_unreaped_updater_is_reaped() {
|
| 531 |
+
let temp = TempDir::new().expect("temp dir");
|
| 532 |
+
let mut child = std::process::Command::new("sleep")
|
| 533 |
+
.arg("60")
|
| 534 |
+
.spawn()
|
| 535 |
+
.expect("spawn updater shim");
|
| 536 |
+
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
| 537 |
+
let process_identity = Some(
|
| 538 |
+
super::identity::read_process_details(child.id())
|
| 539 |
+
.await
|
| 540 |
+
.unwrap()
|
| 541 |
+
.1,
|
| 542 |
+
);
|
| 543 |
+
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
| 544 |
+
let process_identity = None;
|
| 545 |
+
let record = PidRecord {
|
| 546 |
+
pid: child.id(),
|
| 547 |
+
process_start_time: read_process_start_time(child.id())
|
| 548 |
+
.await
|
| 549 |
+
.expect("start time"),
|
| 550 |
+
process_identity,
|
| 551 |
+
executable_identity: None,
|
| 552 |
+
};
|
| 553 |
+
let backend = PidBackend::new_update_loop(
|
| 554 |
+
temp.path().join("codex"),
|
| 555 |
+
temp.path().join("updater.pid"),
|
| 556 |
+
/*restore_release*/ None,
|
| 557 |
+
);
|
| 558 |
+
child.kill().expect("terminate updater shim");
|
| 559 |
+
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
| 560 |
+
let result = loop {
|
| 561 |
+
let result = backend.record_is_active(&record).await;
|
| 562 |
+
if matches!(&result, Ok(false)) || tokio::time::Instant::now() >= deadline {
|
| 563 |
+
break result;
|
| 564 |
+
}
|
| 565 |
+
tokio::time::sleep(Duration::from_millis(50)).await;
|
| 566 |
+
};
|
| 567 |
+
assert!(!result.expect("check zombie updater"));
|
| 568 |
+
assert_eq!(
|
| 569 |
+
child
|
| 570 |
+
.wait()
|
| 571 |
+
.expect_err("updater shim was already reaped")
|
| 572 |
+
.raw_os_error(),
|
| 573 |
+
Some(libc::ECHILD)
|
| 574 |
+
);
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
#[test]
|
| 578 |
+
fn update_loop_uses_hidden_app_server_subcommand() {
|
| 579 |
+
let backend = PidBackend {
|
| 580 |
+
codex_bin: "codex".into(),
|
| 581 |
+
pid_file: "updater.pid".into(),
|
| 582 |
+
lock_file: "updater.pid.lock".into(),
|
| 583 |
+
command_kind: PidCommandKind::UpdateLoop {
|
| 584 |
+
restore_release: None,
|
| 585 |
+
},
|
| 586 |
+
};
|
| 587 |
+
|
| 588 |
+
assert_eq!(
|
| 589 |
+
backend.command_args(),
|
| 590 |
+
vec!["app-server", "daemon", "pid-update-loop"]
|
| 591 |
+
);
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
#[test]
|
| 595 |
+
fn app_server_remote_control_uses_runtime_flag() {
|
| 596 |
+
let backend = PidBackend::new(
|
| 597 |
+
"codex".into(),
|
| 598 |
+
"app-server.pid".into(),
|
| 599 |
+
/*remote_control_enabled*/ true,
|
| 600 |
+
);
|
| 601 |
+
|
| 602 |
+
assert_eq!(
|
| 603 |
+
backend.command_args(),
|
| 604 |
+
vec!["app-server", "--remote-control", "--listen", "unix://"]
|
| 605 |
+
);
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
#[test]
|
| 609 |
+
fn app_server_disabled_remote_control_uses_compatible_args_and_runtime_env() {
|
| 610 |
+
let backend = PidBackend::new(
|
| 611 |
+
"codex".into(),
|
| 612 |
+
"app-server.pid".into(),
|
| 613 |
+
/*remote_control_enabled*/ false,
|
| 614 |
+
);
|
| 615 |
+
|
| 616 |
+
assert_eq!(
|
| 617 |
+
backend.command_args(),
|
| 618 |
+
vec!["app-server", "--listen", "unix://"]
|
| 619 |
+
);
|
| 620 |
+
assert_eq!(
|
| 621 |
+
backend.command_env(),
|
| 622 |
+
Some((REMOTE_CONTROL_DISABLED_ENV_VAR, "1"))
|
| 623 |
+
);
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
#[tokio::test]
|
| 627 |
+
async fn read_stderr_log_tail_returns_recent_complete_lines() {
|
| 628 |
+
let temp_dir = TempDir::new().expect("temp dir");
|
| 629 |
+
let pid_file = temp_dir.path().join("app-server.pid");
|
| 630 |
+
let log_file = stderr_log_file_for_pid_file(&pid_file);
|
| 631 |
+
let contents = format!("{}\nrecent error\nusage", "x".repeat(4100));
|
| 632 |
+
tokio::fs::write(&log_file, contents)
|
| 633 |
+
.await
|
| 634 |
+
.expect("write stderr log");
|
| 635 |
+
|
| 636 |
+
assert_eq!(
|
| 637 |
+
read_stderr_log_tail(&pid_file)
|
| 638 |
+
.await
|
| 639 |
+
.expect("read stderr log"),
|
| 640 |
+
Some(PidLogTail {
|
| 641 |
+
path: log_file,
|
| 642 |
+
contents: "recent error\nusage".to_string(),
|
| 643 |
+
})
|
| 644 |
+
);
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
#[tokio::test]
|
| 648 |
+
async fn stale_creation_time_never_stops_reused_pid() {
|
| 649 |
+
let temp = TempDir::new().expect("temp");
|
| 650 |
+
let backend = PidBackend::new(
|
| 651 |
+
temp.path().join("codex.exe"),
|
| 652 |
+
temp.path().join("server.pid"),
|
| 653 |
+
/*remote_control_enabled*/ false,
|
| 654 |
+
);
|
| 655 |
+
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
| 656 |
+
let identities = {
|
| 657 |
+
use super::identity::ProcessIdentity;
|
| 658 |
+
let (_, identity) = super::identity::read_process_details(std::process::id())
|
| 659 |
+
.await
|
| 660 |
+
.unwrap();
|
| 661 |
+
let mut stale_time = identity.clone();
|
| 662 |
+
let mut stale_epoch = identity;
|
| 663 |
+
match &mut stale_time {
|
| 664 |
+
ProcessIdentity::Linux { start_ticks, .. } => *start_ticks += 1,
|
| 665 |
+
ProcessIdentity::MacOs {
|
| 666 |
+
start_microseconds, ..
|
| 667 |
+
}
|
| 668 |
+
| ProcessIdentity::MacOsUnique {
|
| 669 |
+
start_microseconds, ..
|
| 670 |
+
} => *start_microseconds += 1,
|
| 671 |
+
}
|
| 672 |
+
match &mut stale_epoch {
|
| 673 |
+
ProcessIdentity::Linux { boot_id, .. } => boot_id.push_str("-previous"),
|
| 674 |
+
ProcessIdentity::MacOs { start_seconds, .. } => *start_seconds += 1,
|
| 675 |
+
ProcessIdentity::MacOsUnique { unique_id, .. } => *unique_id += 1,
|
| 676 |
+
}
|
| 677 |
+
[Some(stale_time), Some(stale_epoch)]
|
| 678 |
+
};
|
| 679 |
+
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
| 680 |
+
let identities = [None];
|
| 681 |
+
for process_identity in identities {
|
| 682 |
+
let record = PidRecord {
|
| 683 |
+
pid: std::process::id(),
|
| 684 |
+
process_start_time: "stale".into(),
|
| 685 |
+
process_identity,
|
| 686 |
+
executable_identity: None,
|
| 687 |
+
};
|
| 688 |
+
let contents = serde_json::to_vec(&record).unwrap();
|
| 689 |
+
std::fs::write(&backend.pid_file, &contents).unwrap();
|
| 690 |
+
backend.stop().await.expect("stale record cleanup");
|
| 691 |
+
assert!(!backend.pid_file.exists());
|
| 692 |
+
assert!(!backend.pid_file.with_extension("shutdown").exists());
|
| 693 |
+
}
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
#[cfg(windows)]
|
| 697 |
+
#[tokio::test]
|
| 698 |
+
async fn failed_updater_handoff_preserves_predecessor_record() {
|
| 699 |
+
let elevated = is_elevated_test_process().expect("query administrator membership");
|
| 700 |
+
let temp = TempDir::new().expect("temp");
|
| 701 |
+
let state_dir = temp.path().join("state");
|
| 702 |
+
codex_uds::prepare_private_socket_directory(&state_dir)
|
| 703 |
+
.await
|
| 704 |
+
.expect("private state directory");
|
| 705 |
+
let backend = PidBackend::new_update_loop(
|
| 706 |
+
temp.path().join("missing-codex.exe"),
|
| 707 |
+
state_dir.join("updater.pid"),
|
| 708 |
+
/*restore_release*/ None,
|
| 709 |
+
);
|
| 710 |
+
let record = PidRecord {
|
| 711 |
+
pid: std::process::id(),
|
| 712 |
+
process_start_time: super::read_process_start_time(std::process::id())
|
| 713 |
+
.await
|
| 714 |
+
.unwrap(),
|
| 715 |
+
process_identity: None,
|
| 716 |
+
executable_identity: None,
|
| 717 |
+
};
|
| 718 |
+
for record in [
|
| 719 |
+
record.clone(),
|
| 720 |
+
PidRecord {
|
| 721 |
+
process_start_time: "stale".into(),
|
| 722 |
+
..record
|
| 723 |
+
},
|
| 724 |
+
] {
|
| 725 |
+
tokio::fs::write(&backend.pid_file, serde_json::to_vec(&record).unwrap())
|
| 726 |
+
.await
|
| 727 |
+
.unwrap();
|
| 728 |
+
let error = backend.replace_current_updater().await.unwrap_err();
|
| 729 |
+
if elevated {
|
| 730 |
+
assert!(error.to_string().contains("non-elevated terminal"));
|
| 731 |
+
} else {
|
| 732 |
+
// Windows may reject breakaway before reporting the missing executable.
|
| 733 |
+
// Both must reach process I/O rather than rejecting stale ownership.
|
| 734 |
+
assert!(error.root_cause().is::<std::io::Error>(), "{error:#}");
|
| 735 |
+
}
|
| 736 |
+
assert_eq!(
|
| 737 |
+
backend.read_pid_file_state().await.unwrap(),
|
| 738 |
+
PidFileState::Running(record)
|
| 739 |
+
);
|
| 740 |
+
}
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
#[cfg(windows)]
|
| 744 |
+
#[tokio::test]
|
| 745 |
+
async fn updater_readiness_and_post_publication_failure_preserve_ownership() {
|
| 746 |
+
use futures::FutureExt;
|
| 747 |
+
use windows_sys::Win32::Security::ImpersonateAnonymousToken;
|
| 748 |
+
use windows_sys::Win32::Security::RevertToSelf;
|
| 749 |
+
use windows_sys::Win32::System::Threading::GetCurrentThread;
|
| 750 |
+
|
| 751 |
+
let temp = TempDir::new().expect("temp");
|
| 752 |
+
let backend = PidBackend::new_update_loop(
|
| 753 |
+
temp.path().join("codex.exe"),
|
| 754 |
+
temp.path().join("updater.pid"),
|
| 755 |
+
/*restore_release*/ None,
|
| 756 |
+
);
|
| 757 |
+
let _lock = backend
|
| 758 |
+
.acquire_reservation_lock()
|
| 759 |
+
.await
|
| 760 |
+
.expect("reservation lock");
|
| 761 |
+
let mut child = tokio::process::Command::new("powershell.exe")
|
| 762 |
+
.args([
|
| 763 |
+
"-NoProfile",
|
| 764 |
+
"-NonInteractive",
|
| 765 |
+
"-Command",
|
| 766 |
+
"Start-Sleep 60",
|
| 767 |
+
])
|
| 768 |
+
.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW)
|
| 769 |
+
.kill_on_drop(true)
|
| 770 |
+
.spawn()
|
| 771 |
+
.expect("successor fixture");
|
| 772 |
+
let pid = child.id().expect("pid");
|
| 773 |
+
let successor = PidRecord {
|
| 774 |
+
pid,
|
| 775 |
+
process_start_time: super::read_process_start_time(pid)
|
| 776 |
+
.await
|
| 777 |
+
.expect("creation time"),
|
| 778 |
+
process_identity: None,
|
| 779 |
+
executable_identity: None,
|
| 780 |
+
};
|
| 781 |
+
let predecessor = PidRecord {
|
| 782 |
+
pid: std::process::id(),
|
| 783 |
+
process_start_time: super::read_process_start_time(std::process::id())
|
| 784 |
+
.await
|
| 785 |
+
.expect("creation time"),
|
| 786 |
+
process_identity: None,
|
| 787 |
+
executable_identity: None,
|
| 788 |
+
};
|
| 789 |
+
tokio::fs::write(&backend.pid_file, serde_json::to_vec(&successor).unwrap())
|
| 790 |
+
.await
|
| 791 |
+
.unwrap();
|
| 792 |
+
let ready = backend.pid_file.with_extension("ready");
|
| 793 |
+
tokio::fs::write(&ready, b"").await.unwrap();
|
| 794 |
+
backend
|
| 795 |
+
.finish_updater_start(&successor, Some(&predecessor))
|
| 796 |
+
.await
|
| 797 |
+
.expect("ready successor");
|
| 798 |
+
assert!(!ready.exists());
|
| 799 |
+
assert_eq!(
|
| 800 |
+
backend.read_pid_file_state().await.unwrap(),
|
| 801 |
+
PidFileState::Running(successor.clone())
|
| 802 |
+
);
|
| 803 |
+
|
| 804 |
+
// If cleanup cannot even query the successor, leave its ownership intact.
|
| 805 |
+
std::thread::scope(|scope| {
|
| 806 |
+
scope
|
| 807 |
+
.spawn(|| {
|
| 808 |
+
assert_ne!(unsafe { ImpersonateAnonymousToken(GetCurrentThread()) }, 0);
|
| 809 |
+
let result = backend
|
| 810 |
+
.finish_updater_start(&successor, Some(&predecessor))
|
| 811 |
+
.now_or_never();
|
| 812 |
+
let reverted = unsafe { RevertToSelf() };
|
| 813 |
+
assert_ne!(reverted, 0);
|
| 814 |
+
let error = result
|
| 815 |
+
.expect("denied cleanup must not suspend")
|
| 816 |
+
.unwrap_err();
|
| 817 |
+
assert_eq!(
|
| 818 |
+
error
|
| 819 |
+
.downcast_ref::<std::io::Error>()
|
| 820 |
+
.unwrap()
|
| 821 |
+
.raw_os_error(),
|
| 822 |
+
Some(windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED as i32)
|
| 823 |
+
);
|
| 824 |
+
})
|
| 825 |
+
.join()
|
| 826 |
+
.expect("anonymous cleanup check");
|
| 827 |
+
});
|
| 828 |
+
assert_eq!(
|
| 829 |
+
backend.read_pid_file_state().await.unwrap(),
|
| 830 |
+
PidFileState::Running(successor.clone())
|
| 831 |
+
);
|
| 832 |
+
|
| 833 |
+
let reused_pid = PidRecord {
|
| 834 |
+
process_start_time: "stale".into(),
|
| 835 |
+
..successor.clone()
|
| 836 |
+
};
|
| 837 |
+
assert!(
|
| 838 |
+
backend
|
| 839 |
+
.finish_updater_start(&reused_pid, Some(&predecessor))
|
| 840 |
+
.await
|
| 841 |
+
.is_err()
|
| 842 |
+
);
|
| 843 |
+
assert_eq!(
|
| 844 |
+
backend.read_pid_file_state().await.unwrap(),
|
| 845 |
+
PidFileState::Running(predecessor.clone())
|
| 846 |
+
);
|
| 847 |
+
assert!(
|
| 848 |
+
child.try_wait().unwrap().is_none(),
|
| 849 |
+
"PID reuse must not terminate another process"
|
| 850 |
+
);
|
| 851 |
+
tokio::fs::write(&backend.pid_file, serde_json::to_vec(&successor).unwrap())
|
| 852 |
+
.await
|
| 853 |
+
.unwrap();
|
| 854 |
+
// An invalid readiness path triggers cleanup while the successor is alive.
|
| 855 |
+
tokio::fs::create_dir(&ready).await.unwrap();
|
| 856 |
+
assert!(
|
| 857 |
+
backend
|
| 858 |
+
.finish_updater_start(&successor, Some(&predecessor))
|
| 859 |
+
.await
|
| 860 |
+
.is_err()
|
| 861 |
+
);
|
| 862 |
+
assert!(
|
| 863 |
+
child.try_wait().unwrap().is_some(),
|
| 864 |
+
"successor must exit before rollback"
|
| 865 |
+
);
|
| 866 |
+
assert_eq!(
|
| 867 |
+
backend.read_pid_file_state().await.unwrap(),
|
| 868 |
+
PidFileState::Running(predecessor)
|
| 869 |
+
);
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
#[cfg(windows)]
|
| 873 |
+
#[test]
|
| 874 |
+
fn inaccessible_pid_preserves_identity_check_error() {
|
| 875 |
+
use futures::FutureExt;
|
| 876 |
+
use windows_sys::Win32::Security::ImpersonateAnonymousToken;
|
| 877 |
+
use windows_sys::Win32::Security::RevertToSelf;
|
| 878 |
+
use windows_sys::Win32::System::Threading::GetCurrentThread;
|
| 879 |
+
|
| 880 |
+
// Isolate impersonation from Tokio workers and other tests. Query our own
|
| 881 |
+
// process anonymously instead of assuming a system PID is inaccessible.
|
| 882 |
+
std::thread::spawn(|| {
|
| 883 |
+
let record = PidRecord {
|
| 884 |
+
pid: std::process::id(),
|
| 885 |
+
process_start_time: "stale".into(),
|
| 886 |
+
process_identity: None,
|
| 887 |
+
executable_identity: None,
|
| 888 |
+
};
|
| 889 |
+
assert!(
|
| 890 |
+
crate::backend::windows::Process::open(record.pid)
|
| 891 |
+
.unwrap()
|
| 892 |
+
.is_some()
|
| 893 |
+
);
|
| 894 |
+
assert_ne!(unsafe { ImpersonateAnonymousToken(GetCurrentThread()) }, 0);
|
| 895 |
+
let opened = crate::backend::windows::Process::open(record.pid);
|
| 896 |
+
// Windows identity checks are synchronous; never suspend while impersonating.
|
| 897 |
+
let matches = super::process_matches_record(&record).now_or_never();
|
| 898 |
+
let reverted = unsafe { RevertToSelf() };
|
| 899 |
+
assert_ne!(reverted, 0);
|
| 900 |
+
|
| 901 |
+
let error = match opened {
|
| 902 |
+
Err(error) => error,
|
| 903 |
+
Ok(_) => panic!("anonymous caller unexpectedly queried the test process"),
|
| 904 |
+
};
|
| 905 |
+
assert_eq!(
|
| 906 |
+
error
|
| 907 |
+
.downcast_ref::<std::io::Error>()
|
| 908 |
+
.unwrap()
|
| 909 |
+
.raw_os_error(),
|
| 910 |
+
Some(windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED as i32),
|
| 911 |
+
);
|
| 912 |
+
assert_eq!(
|
| 913 |
+
matches
|
| 914 |
+
.expect("identity check must not suspend")
|
| 915 |
+
.unwrap_err()
|
| 916 |
+
.downcast_ref::<std::io::Error>()
|
| 917 |
+
.unwrap()
|
| 918 |
+
.raw_os_error(),
|
| 919 |
+
Some(windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED as i32),
|
| 920 |
+
);
|
| 921 |
+
})
|
| 922 |
+
.join()
|
| 923 |
+
.expect("anonymous identity check");
|
| 924 |
+
}
|
codex-rs/app-server-daemon/src/backend/pid_windows.rs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Windows updater publication handshake. The predecessor holds the operation
|
| 2 |
+
//! and reservation locks until its successor initializes and acknowledges readiness.
|
| 3 |
+
|
| 4 |
+
use super::super::windows::Process;
|
| 5 |
+
use super::PidBackend;
|
| 6 |
+
use super::PidFileState;
|
| 7 |
+
use super::PidRecord;
|
| 8 |
+
use super::START_TIMEOUT;
|
| 9 |
+
use super::STOP_POLL_INTERVAL;
|
| 10 |
+
use super::process_matches_record;
|
| 11 |
+
use super::read_process_start_time;
|
| 12 |
+
use anyhow::Context;
|
| 13 |
+
use anyhow::Result;
|
| 14 |
+
use anyhow::bail;
|
| 15 |
+
use tokio::fs;
|
| 16 |
+
use tokio::time::sleep;
|
| 17 |
+
|
| 18 |
+
impl PidBackend {
|
| 19 |
+
pub(crate) async fn wait_for_ownership(&self) -> Result<()> {
|
| 20 |
+
let deadline = tokio::time::Instant::now() + START_TIMEOUT;
|
| 21 |
+
loop {
|
| 22 |
+
if let PidFileState::Running(record) = self.read_pid_file_state().await?
|
| 23 |
+
&& record.pid == std::process::id()
|
| 24 |
+
&& self.record_is_active(&record).await?
|
| 25 |
+
{
|
| 26 |
+
return Ok(());
|
| 27 |
+
}
|
| 28 |
+
if tokio::time::Instant::now() >= deadline {
|
| 29 |
+
bail!("updater was not published by its launcher");
|
| 30 |
+
}
|
| 31 |
+
sleep(STOP_POLL_INTERVAL).await;
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
pub(crate) async fn mark_ready(&self) -> Result<()> {
|
| 36 |
+
fs::write(self.pid_file.with_extension("ready"), b"")
|
| 37 |
+
.await
|
| 38 |
+
.context("failed to acknowledge updater startup")
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
pub(crate) async fn replace_current_updater(&self) -> Result<()> {
|
| 42 |
+
let record = PidRecord {
|
| 43 |
+
pid: std::process::id(),
|
| 44 |
+
process_start_time: read_process_start_time(std::process::id()).await?,
|
| 45 |
+
process_identity: None,
|
| 46 |
+
executable_identity: None,
|
| 47 |
+
};
|
| 48 |
+
self.start_inner(Some(record)).await?;
|
| 49 |
+
Ok(())
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
pub(super) async fn finish_updater_start(
|
| 53 |
+
&self,
|
| 54 |
+
record: &PidRecord,
|
| 55 |
+
replacement: Option<&PidRecord>,
|
| 56 |
+
) -> Result<()> {
|
| 57 |
+
let temp_pid_file = self.pid_file.with_extension("pid.tmp");
|
| 58 |
+
let ready = self.pid_file.with_extension("ready");
|
| 59 |
+
let deadline = tokio::time::Instant::now() + START_TIMEOUT;
|
| 60 |
+
let startup = async {
|
| 61 |
+
loop {
|
| 62 |
+
if !process_matches_record(record).await? {
|
| 63 |
+
bail!("updater exited before becoming ready");
|
| 64 |
+
}
|
| 65 |
+
match fs::remove_file(&ready).await {
|
| 66 |
+
Ok(()) => return Ok(()),
|
| 67 |
+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
| 68 |
+
Err(err) => return Err(err.into()),
|
| 69 |
+
}
|
| 70 |
+
if tokio::time::Instant::now() >= deadline {
|
| 71 |
+
bail!("updater did not become ready");
|
| 72 |
+
}
|
| 73 |
+
sleep(STOP_POLL_INTERVAL).await;
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
.await;
|
| 77 |
+
if let Err(err) = startup {
|
| 78 |
+
if let Some(process) = Process::open(record.pid)?
|
| 79 |
+
&& process.start_time()? == record.process_start_time
|
| 80 |
+
{
|
| 81 |
+
process.terminate()?;
|
| 82 |
+
let deadline = tokio::time::Instant::now() + START_TIMEOUT;
|
| 83 |
+
while process.is_running()? {
|
| 84 |
+
if tokio::time::Instant::now() >= deadline {
|
| 85 |
+
bail!("failed updater did not exit; ownership was not restored");
|
| 86 |
+
}
|
| 87 |
+
sleep(STOP_POLL_INTERVAL).await;
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
if let Some(previous) = replacement {
|
| 91 |
+
fs::write(&temp_pid_file, serde_json::to_vec(previous)?).await?;
|
| 92 |
+
fs::rename(&temp_pid_file, &self.pid_file).await?;
|
| 93 |
+
} else {
|
| 94 |
+
let _ = fs::remove_file(&self.pid_file).await;
|
| 95 |
+
}
|
| 96 |
+
return Err(err);
|
| 97 |
+
}
|
| 98 |
+
Ok(())
|
| 99 |
+
}
|
| 100 |
+
}
|
codex-rs/app-server-daemon/src/backend/windows.rs
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Windows process identity and file locks. Keep a process handle across shutdown
|
| 2 |
+
//! so PID reuse can never redirect forced termination to a different process.
|
| 3 |
+
//! Managed servers must not elevate ordinary clients sharing the account's socket.
|
| 4 |
+
//! Installer jobs contain extraction processes when an update is cancelled.
|
| 5 |
+
|
| 6 |
+
use std::io;
|
| 7 |
+
use std::os::windows::io::AsRawHandle;
|
| 8 |
+
use std::os::windows::io::FromRawHandle;
|
| 9 |
+
use std::os::windows::io::OwnedHandle;
|
| 10 |
+
use std::os::windows::process::CommandExt;
|
| 11 |
+
use std::path::Path;
|
| 12 |
+
use std::process::Command;
|
| 13 |
+
use std::process::Stdio;
|
| 14 |
+
|
| 15 |
+
use anyhow::Context;
|
| 16 |
+
use anyhow::Result;
|
| 17 |
+
use windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER;
|
| 18 |
+
use windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION;
|
| 19 |
+
use windows_sys::Win32::Foundation::FILETIME;
|
| 20 |
+
use windows_sys::Win32::Foundation::WAIT_OBJECT_0;
|
| 21 |
+
use windows_sys::Win32::Foundation::WAIT_TIMEOUT;
|
| 22 |
+
use windows_sys::Win32::Security::GetTokenInformation;
|
| 23 |
+
use windows_sys::Win32::Security::TOKEN_ELEVATION;
|
| 24 |
+
use windows_sys::Win32::Security::TOKEN_QUERY;
|
| 25 |
+
use windows_sys::Win32::Security::TokenElevation;
|
| 26 |
+
use windows_sys::Win32::Storage::FileSystem::LOCKFILE_EXCLUSIVE_LOCK;
|
| 27 |
+
use windows_sys::Win32::Storage::FileSystem::LOCKFILE_FAIL_IMMEDIATELY;
|
| 28 |
+
use windows_sys::Win32::Storage::FileSystem::LockFileEx;
|
| 29 |
+
use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
|
| 30 |
+
use windows_sys::Win32::System::JobObjects::CreateJobObjectW;
|
| 31 |
+
use windows_sys::Win32::System::JobObjects::IsProcessInJob;
|
| 32 |
+
use windows_sys::Win32::System::JobObjects::JOB_OBJECT_LIMIT_BREAKAWAY_OK;
|
| 33 |
+
use windows_sys::Win32::System::JobObjects::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
| 34 |
+
use windows_sys::Win32::System::JobObjects::JOBOBJECT_EXTENDED_LIMIT_INFORMATION;
|
| 35 |
+
use windows_sys::Win32::System::JobObjects::JobObjectExtendedLimitInformation;
|
| 36 |
+
use windows_sys::Win32::System::JobObjects::SetInformationJobObject;
|
| 37 |
+
use windows_sys::Win32::System::Threading::CREATE_BREAKAWAY_FROM_JOB;
|
| 38 |
+
use windows_sys::Win32::System::Threading::CREATE_SUSPENDED;
|
| 39 |
+
use windows_sys::Win32::System::Threading::DETACHED_PROCESS;
|
| 40 |
+
use windows_sys::Win32::System::Threading::GetCurrentProcess;
|
| 41 |
+
use windows_sys::Win32::System::Threading::GetProcessId;
|
| 42 |
+
use windows_sys::Win32::System::Threading::GetProcessTimes;
|
| 43 |
+
use windows_sys::Win32::System::Threading::OpenProcess;
|
| 44 |
+
use windows_sys::Win32::System::Threading::OpenProcessToken;
|
| 45 |
+
use windows_sys::Win32::System::Threading::PROCESS_ACCESS_RIGHTS;
|
| 46 |
+
use windows_sys::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION;
|
| 47 |
+
use windows_sys::Win32::System::Threading::PROCESS_SYNCHRONIZE;
|
| 48 |
+
use windows_sys::Win32::System::Threading::PROCESS_TERMINATE;
|
| 49 |
+
use windows_sys::Win32::System::Threading::TerminateProcess;
|
| 50 |
+
use windows_sys::Win32::System::Threading::WaitForSingleObject;
|
| 51 |
+
|
| 52 |
+
pub(crate) fn ensure_not_elevated() -> Result<()> {
|
| 53 |
+
let mut token = 0;
|
| 54 |
+
if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
|
| 55 |
+
return Err(io::Error::last_os_error()).context("failed to query daemon launcher token");
|
| 56 |
+
}
|
| 57 |
+
let token = unsafe { OwnedHandle::from_raw_handle(token as _) };
|
| 58 |
+
let mut elevation: TOKEN_ELEVATION = unsafe { std::mem::zeroed() };
|
| 59 |
+
let mut returned = 0;
|
| 60 |
+
if unsafe {
|
| 61 |
+
GetTokenInformation(
|
| 62 |
+
token.as_raw_handle() as _,
|
| 63 |
+
TokenElevation,
|
| 64 |
+
(&mut elevation as *mut TOKEN_ELEVATION).cast(),
|
| 65 |
+
std::mem::size_of::<TOKEN_ELEVATION>() as u32,
|
| 66 |
+
&mut returned,
|
| 67 |
+
)
|
| 68 |
+
} == 0
|
| 69 |
+
{
|
| 70 |
+
return Err(io::Error::last_os_error())
|
| 71 |
+
.context("failed to query daemon launcher elevation");
|
| 72 |
+
}
|
| 73 |
+
anyhow::ensure!(
|
| 74 |
+
elevation.TokenIsElevated == 0,
|
| 75 |
+
"start the Windows daemon from a non-elevated terminal; shared clients must not inherit administrator privileges"
|
| 76 |
+
);
|
| 77 |
+
Ok(())
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// Probe the actual child association: escaping an inner job can leave an outer
|
| 81 |
+
// job attached. Suspend the image so no application code runs before cleanup.
|
| 82 |
+
pub(crate) fn ensure_detached_launch(executable: &Path) -> Result<()> {
|
| 83 |
+
let mut child = Command::new(executable)
|
| 84 |
+
.creation_flags(CREATE_SUSPENDED | DETACHED_PROCESS | CREATE_BREAKAWAY_FROM_JOB)
|
| 85 |
+
.stdin(Stdio::null())
|
| 86 |
+
.stdout(Stdio::null())
|
| 87 |
+
.stderr(Stdio::null())
|
| 88 |
+
.spawn()
|
| 89 |
+
.context("cannot launch detached daemon; existing daemon was not stopped")?;
|
| 90 |
+
let mut in_job = 0;
|
| 91 |
+
let result = if unsafe {
|
| 92 |
+
IsProcessInJob(
|
| 93 |
+
child.as_raw_handle() as _,
|
| 94 |
+
/*jobhandle*/ 0,
|
| 95 |
+
&mut in_job,
|
| 96 |
+
)
|
| 97 |
+
} == 0
|
| 98 |
+
{
|
| 99 |
+
Err(io::Error::last_os_error()).context("failed to verify daemon launch capability")
|
| 100 |
+
} else if in_job != 0 {
|
| 101 |
+
Err(anyhow::anyhow!(
|
| 102 |
+
"host Job Object prevents daemon detachment; start from a host that allows breakaway"
|
| 103 |
+
))
|
| 104 |
+
} else {
|
| 105 |
+
Ok(())
|
| 106 |
+
};
|
| 107 |
+
child
|
| 108 |
+
.kill()
|
| 109 |
+
.context("failed to terminate suspended launch probe")?;
|
| 110 |
+
child
|
| 111 |
+
.wait()
|
| 112 |
+
.context("failed to reap suspended launch probe")?;
|
| 113 |
+
result
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
pub(super) struct Process(OwnedHandle);
|
| 117 |
+
|
| 118 |
+
impl Process {
|
| 119 |
+
pub(super) fn open(pid: u32) -> Result<Option<Self>> {
|
| 120 |
+
Self::open_with_access(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE)
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
fn open_with_access(pid: u32, access: PROCESS_ACCESS_RIGHTS) -> Result<Option<Self>> {
|
| 124 |
+
let handle = unsafe {
|
| 125 |
+
OpenProcess(access, /*binherithandle*/ 0, pid)
|
| 126 |
+
};
|
| 127 |
+
if handle == 0 {
|
| 128 |
+
let err = io::Error::last_os_error();
|
| 129 |
+
return if err.raw_os_error() == Some(ERROR_INVALID_PARAMETER as i32) {
|
| 130 |
+
Ok(None)
|
| 131 |
+
} else {
|
| 132 |
+
Err(err).context("failed to open daemon process")
|
| 133 |
+
};
|
| 134 |
+
}
|
| 135 |
+
Ok(Some(Self(unsafe {
|
| 136 |
+
OwnedHandle::from_raw_handle(handle as _)
|
| 137 |
+
})))
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
pub(super) fn start_time(&self) -> Result<String> {
|
| 141 |
+
let mut created: FILETIME = unsafe { std::mem::zeroed() };
|
| 142 |
+
let mut exited = created;
|
| 143 |
+
let mut kernel = created;
|
| 144 |
+
let mut user = created;
|
| 145 |
+
if unsafe {
|
| 146 |
+
GetProcessTimes(
|
| 147 |
+
self.0.as_raw_handle() as _,
|
| 148 |
+
&mut created,
|
| 149 |
+
&mut exited,
|
| 150 |
+
&mut kernel,
|
| 151 |
+
&mut user,
|
| 152 |
+
)
|
| 153 |
+
} == 0
|
| 154 |
+
{
|
| 155 |
+
return Err(io::Error::last_os_error()).context("failed to query daemon creation time");
|
| 156 |
+
}
|
| 157 |
+
Ok(
|
| 158 |
+
((u64::from(created.dwHighDateTime) << 32) | u64::from(created.dwLowDateTime))
|
| 159 |
+
.to_string(),
|
| 160 |
+
)
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
pub(super) fn is_running(&self) -> Result<bool> {
|
| 164 |
+
match unsafe {
|
| 165 |
+
WaitForSingleObject(self.0.as_raw_handle() as _, /*dwmilliseconds*/ 0)
|
| 166 |
+
} {
|
| 167 |
+
WAIT_TIMEOUT => Ok(true),
|
| 168 |
+
WAIT_OBJECT_0 => Ok(false),
|
| 169 |
+
_ => Err(io::Error::last_os_error()).context("failed to wait for daemon process"),
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
pub(super) fn ensure_detached(&self) -> Result<()> {
|
| 174 |
+
let mut in_job = 0;
|
| 175 |
+
if unsafe {
|
| 176 |
+
IsProcessInJob(
|
| 177 |
+
self.0.as_raw_handle() as _,
|
| 178 |
+
/*jobhandle*/ 0,
|
| 179 |
+
&mut in_job,
|
| 180 |
+
)
|
| 181 |
+
} == 0
|
| 182 |
+
{
|
| 183 |
+
let error = io::Error::last_os_error();
|
| 184 |
+
self.terminate()?;
|
| 185 |
+
return Err(error).context("failed to verify daemon detachment");
|
| 186 |
+
}
|
| 187 |
+
if in_job != 0 {
|
| 188 |
+
self.terminate()?;
|
| 189 |
+
anyhow::bail!(
|
| 190 |
+
"host Job Object prevents daemon detachment; start from a host that allows breakaway"
|
| 191 |
+
);
|
| 192 |
+
}
|
| 193 |
+
Ok(())
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
pub(super) fn terminate(&self) -> Result<()> {
|
| 197 |
+
if !self.is_running()? {
|
| 198 |
+
return Ok(());
|
| 199 |
+
}
|
| 200 |
+
let pid = unsafe { GetProcessId(self.0.as_raw_handle() as _) };
|
| 201 |
+
let Some(target) = Self::open_with_access(
|
| 202 |
+
pid,
|
| 203 |
+
PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE | PROCESS_TERMINATE,
|
| 204 |
+
)?
|
| 205 |
+
else {
|
| 206 |
+
return Ok(());
|
| 207 |
+
};
|
| 208 |
+
// Keep the original identity handle alive and validate the handle that
|
| 209 |
+
// will actually be terminated, rather than trusting a second PID lookup.
|
| 210 |
+
if target.start_time()? != self.start_time()? || !target.is_running()? {
|
| 211 |
+
return Ok(());
|
| 212 |
+
}
|
| 213 |
+
if unsafe {
|
| 214 |
+
TerminateProcess(target.0.as_raw_handle() as _, /*uexitcode*/ 1)
|
| 215 |
+
} == 0
|
| 216 |
+
{
|
| 217 |
+
return Err(io::Error::last_os_error()).context("failed to terminate daemon process");
|
| 218 |
+
}
|
| 219 |
+
Ok(())
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
pub(crate) fn try_lock_file(file: &tokio::fs::File) -> Result<bool> {
|
| 224 |
+
let mut overlapped = unsafe { std::mem::zeroed() };
|
| 225 |
+
if unsafe {
|
| 226 |
+
LockFileEx(
|
| 227 |
+
file.as_raw_handle() as _,
|
| 228 |
+
LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY,
|
| 229 |
+
/*dwreserved*/ 0,
|
| 230 |
+
/*nnumberofbytestolocklow*/ 1,
|
| 231 |
+
/*nnumberofbytestolockhigh*/ 0,
|
| 232 |
+
&mut overlapped,
|
| 233 |
+
)
|
| 234 |
+
} != 0
|
| 235 |
+
{
|
| 236 |
+
return Ok(true);
|
| 237 |
+
}
|
| 238 |
+
let err = io::Error::last_os_error();
|
| 239 |
+
if err.raw_os_error() == Some(ERROR_LOCK_VIOLATION as i32) {
|
| 240 |
+
return Ok(false);
|
| 241 |
+
}
|
| 242 |
+
Err(err).context("failed to lock daemon state")
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
// Keep installer descendants bounded by the updater's lifetime, while allowing
|
| 246 |
+
// app-server launches and successor updaters to break away from this job.
|
| 247 |
+
pub(crate) fn updater_job() -> Result<OwnedHandle> {
|
| 248 |
+
process_job(unsafe { GetCurrentProcess() })
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
/// Called before writing the installer script, while PowerShell is waiting on stdin.
|
| 252 |
+
pub(crate) fn installer_job(child: &tokio::process::Child) -> Result<OwnedHandle> {
|
| 253 |
+
let process = child
|
| 254 |
+
.raw_handle()
|
| 255 |
+
.context("installer process handle is unavailable")?;
|
| 256 |
+
process_job(process as isize)
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
fn process_job(process: isize) -> Result<OwnedHandle> {
|
| 260 |
+
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
|
| 261 |
+
if job == 0 {
|
| 262 |
+
return Err(io::Error::last_os_error()).context("failed to create updater job");
|
| 263 |
+
}
|
| 264 |
+
let owned = unsafe { OwnedHandle::from_raw_handle(job as _) };
|
| 265 |
+
let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
|
| 266 |
+
limits.BasicLimitInformation.LimitFlags =
|
| 267 |
+
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK;
|
| 268 |
+
if unsafe {
|
| 269 |
+
SetInformationJobObject(
|
| 270 |
+
job,
|
| 271 |
+
JobObjectExtendedLimitInformation,
|
| 272 |
+
(&limits as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(),
|
| 273 |
+
std::mem::size_of_val(&limits) as u32,
|
| 274 |
+
)
|
| 275 |
+
} == 0
|
| 276 |
+
|| unsafe { AssignProcessToJobObject(job, process) } == 0
|
| 277 |
+
{
|
| 278 |
+
return Err(io::Error::last_os_error())
|
| 279 |
+
.context("failed to contain updater installer processes");
|
| 280 |
+
}
|
| 281 |
+
Ok(owned)
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
#[cfg(test)]
|
| 285 |
+
#[path = "windows_tests.rs"]
|
| 286 |
+
mod tests;
|
codex-rs/app-server-daemon/src/backend/windows_tests.rs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use super::Process;
|
| 2 |
+
use pretty_assertions::assert_eq;
|
| 3 |
+
use std::os::windows::io::AsRawHandle;
|
| 4 |
+
use windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED;
|
| 5 |
+
use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW;
|
| 6 |
+
use windows_sys::Win32::System::Threading::TerminateProcess;
|
| 7 |
+
|
| 8 |
+
#[test]
|
| 9 |
+
fn detached_launch_preflight_rejects_restrictive_job() {
|
| 10 |
+
const CHILD: &str = "CODEX_TEST_RESTRICTIVE_LAUNCH_JOB";
|
| 11 |
+
let executable = std::env::current_exe().expect("test executable");
|
| 12 |
+
if std::env::var_os(CHILD).is_some() {
|
| 13 |
+
let job = unsafe { super::CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
|
| 14 |
+
assert_ne!(job, 0);
|
| 15 |
+
let job = unsafe {
|
| 16 |
+
<std::os::windows::io::OwnedHandle as std::os::windows::io::FromRawHandle>::from_raw_handle(job as _)
|
| 17 |
+
};
|
| 18 |
+
assert_ne!(
|
| 19 |
+
unsafe {
|
| 20 |
+
super::AssignProcessToJobObject(
|
| 21 |
+
job.as_raw_handle() as _,
|
| 22 |
+
super::GetCurrentProcess(),
|
| 23 |
+
)
|
| 24 |
+
},
|
| 25 |
+
0
|
| 26 |
+
);
|
| 27 |
+
// A new job does not permit breakaway. Reject before any lifecycle mutation.
|
| 28 |
+
assert!(super::ensure_detached_launch(&executable).is_err());
|
| 29 |
+
return;
|
| 30 |
+
}
|
| 31 |
+
let output = std::process::Command::new(executable)
|
| 32 |
+
.args([
|
| 33 |
+
"--exact",
|
| 34 |
+
"backend::windows::tests::detached_launch_preflight_rejects_restrictive_job",
|
| 35 |
+
"--nocapture",
|
| 36 |
+
])
|
| 37 |
+
.env(CHILD, "1")
|
| 38 |
+
.output()
|
| 39 |
+
.expect("isolated job test");
|
| 40 |
+
assert!(
|
| 41 |
+
output.status.success(),
|
| 42 |
+
"{}",
|
| 43 |
+
String::from_utf8_lossy(&output.stderr)
|
| 44 |
+
);
|
| 45 |
+
assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed"));
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
#[tokio::test]
|
| 49 |
+
async fn identity_queries_do_not_require_termination_access() {
|
| 50 |
+
let mut child = tokio::process::Command::new("powershell.exe")
|
| 51 |
+
.args([
|
| 52 |
+
"-NoProfile",
|
| 53 |
+
"-NonInteractive",
|
| 54 |
+
"-Command",
|
| 55 |
+
"Start-Sleep 60",
|
| 56 |
+
])
|
| 57 |
+
.creation_flags(CREATE_NO_WINDOW)
|
| 58 |
+
.kill_on_drop(true)
|
| 59 |
+
.spawn()
|
| 60 |
+
.expect("child");
|
| 61 |
+
let process = Process::open(child.id().expect("pid"))
|
| 62 |
+
.expect("query handle")
|
| 63 |
+
.expect("live process");
|
| 64 |
+
assert!(!process.start_time().expect("creation time").is_empty());
|
| 65 |
+
assert!(process.is_running().expect("liveness"));
|
| 66 |
+
// Check the rights on the actual query handle, independent of privileges
|
| 67 |
+
// that could let the caller reopen the process with termination access.
|
| 68 |
+
assert_eq!(
|
| 69 |
+
unsafe {
|
| 70 |
+
TerminateProcess(process.0.as_raw_handle() as _, /*uexitcode*/ 1)
|
| 71 |
+
},
|
| 72 |
+
0
|
| 73 |
+
);
|
| 74 |
+
assert_eq!(
|
| 75 |
+
std::io::Error::last_os_error().raw_os_error(),
|
| 76 |
+
Some(ERROR_ACCESS_DENIED as i32)
|
| 77 |
+
);
|
| 78 |
+
assert!(
|
| 79 |
+
process
|
| 80 |
+
.is_running()
|
| 81 |
+
.expect("query must not terminate child")
|
| 82 |
+
);
|
| 83 |
+
child
|
| 84 |
+
.kill()
|
| 85 |
+
.await
|
| 86 |
+
.expect("cleanup through original spawn handle");
|
| 87 |
+
}
|
codex-rs/app-server-daemon/src/client.rs
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::path::Path;
|
| 2 |
+
use std::time::Duration;
|
| 3 |
+
|
| 4 |
+
use anyhow::Context;
|
| 5 |
+
use anyhow::Result;
|
| 6 |
+
use anyhow::anyhow;
|
| 7 |
+
use codex_app_server_protocol::ClientInfo;
|
| 8 |
+
use codex_app_server_protocol::InitializeCapabilities;
|
| 9 |
+
use codex_app_server_protocol::InitializeParams;
|
| 10 |
+
use codex_app_server_protocol::InitializeResponse;
|
| 11 |
+
use codex_app_server_protocol::JSONRPCMessage;
|
| 12 |
+
use codex_app_server_protocol::JSONRPCNotification;
|
| 13 |
+
use codex_app_server_protocol::JSONRPCRequest;
|
| 14 |
+
use codex_app_server_protocol::RequestId;
|
| 15 |
+
use codex_uds::UnixStream;
|
| 16 |
+
use futures::SinkExt;
|
| 17 |
+
use futures::StreamExt;
|
| 18 |
+
use tokio::io::AsyncRead;
|
| 19 |
+
use tokio::io::AsyncWrite;
|
| 20 |
+
use tokio::time::timeout;
|
| 21 |
+
use tokio_tungstenite::WebSocketStream;
|
| 22 |
+
use tokio_tungstenite::client_async;
|
| 23 |
+
use tokio_tungstenite::tungstenite::Message;
|
| 24 |
+
|
| 25 |
+
pub(crate) const CONTROL_SOCKET_RESPONSE_TIMEOUT: Duration = Duration::from_secs(2);
|
| 26 |
+
const CLIENT_NAME: &str = "codex_app_server_daemon";
|
| 27 |
+
const INITIALIZE_REQUEST_ID: RequestId = RequestId::Integer(1);
|
| 28 |
+
|
| 29 |
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
| 30 |
+
pub(crate) struct ProbeInfo {
|
| 31 |
+
pub(crate) app_server_version: String,
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
pub(crate) async fn probe(socket_path: &Path) -> Result<ProbeInfo> {
|
| 35 |
+
timeout(CONTROL_SOCKET_RESPONSE_TIMEOUT, probe_inner(socket_path))
|
| 36 |
+
.await
|
| 37 |
+
.with_context(|| {
|
| 38 |
+
format!(
|
| 39 |
+
"timed out probing app-server control socket {}",
|
| 40 |
+
socket_path.display()
|
| 41 |
+
)
|
| 42 |
+
})?
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
async fn probe_inner(socket_path: &Path) -> Result<ProbeInfo> {
|
| 46 |
+
let mut websocket = connect(socket_path).await?;
|
| 47 |
+
|
| 48 |
+
let initialize_response = initialize(&mut websocket, /*experimental_api*/ false).await?;
|
| 49 |
+
let initialized = JSONRPCMessage::Notification(JSONRPCNotification {
|
| 50 |
+
method: "initialized".to_string(),
|
| 51 |
+
params: None,
|
| 52 |
+
});
|
| 53 |
+
send_message(&mut websocket, &initialized)
|
| 54 |
+
.await
|
| 55 |
+
.context("failed to send initialized notification")?;
|
| 56 |
+
websocket.close(None).await.ok();
|
| 57 |
+
|
| 58 |
+
Ok(ProbeInfo {
|
| 59 |
+
app_server_version: parse_version_from_user_agent(&initialize_response.user_agent)?,
|
| 60 |
+
})
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
pub(crate) async fn connect(socket_path: &Path) -> Result<WebSocketStream<UnixStream>> {
|
| 64 |
+
connect_at(socket_path, "ws://localhost/").await
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
async fn connect_at(socket_path: &Path, url: &str) -> Result<WebSocketStream<UnixStream>> {
|
| 68 |
+
let stream = UnixStream::connect(socket_path)
|
| 69 |
+
.await
|
| 70 |
+
.with_context(|| format!("failed to connect to {}", socket_path.display()))?;
|
| 71 |
+
let (websocket, _response) = client_async(url, stream)
|
| 72 |
+
.await
|
| 73 |
+
.with_context(|| format!("failed to upgrade {}", socket_path.display()))?;
|
| 74 |
+
Ok(websocket)
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
#[cfg(windows)]
|
| 78 |
+
pub(crate) async fn request_shutdown(socket_path: &Path, pid: u32) -> Result<()> {
|
| 79 |
+
timeout(CONTROL_SOCKET_RESPONSE_TIMEOUT, async {
|
| 80 |
+
let mut websocket = connect_at(socket_path, "ws://localhost/daemon/shutdown").await?;
|
| 81 |
+
websocket
|
| 82 |
+
.send(Message::Text(pid.to_string().into()))
|
| 83 |
+
.await?;
|
| 84 |
+
let reply = websocket
|
| 85 |
+
.next()
|
| 86 |
+
.await
|
| 87 |
+
.context("shutdown socket closed without acknowledgment")??;
|
| 88 |
+
anyhow::ensure!(
|
| 89 |
+
matches!(reply, Message::Text(ack) if ack == pid.to_string()),
|
| 90 |
+
"shutdown acknowledgment did not match the managed process {pid}"
|
| 91 |
+
);
|
| 92 |
+
websocket.close(None).await?;
|
| 93 |
+
Ok(())
|
| 94 |
+
})
|
| 95 |
+
.await
|
| 96 |
+
.context("timed out waiting for managed app-server shutdown acknowledgment")?
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
pub(crate) async fn initialize<S>(
|
| 100 |
+
websocket: &mut WebSocketStream<S>,
|
| 101 |
+
experimental_api: bool,
|
| 102 |
+
) -> Result<InitializeResponse>
|
| 103 |
+
where
|
| 104 |
+
S: AsyncRead + AsyncWrite + Unpin,
|
| 105 |
+
{
|
| 106 |
+
let initialize = JSONRPCMessage::Request(JSONRPCRequest {
|
| 107 |
+
id: INITIALIZE_REQUEST_ID,
|
| 108 |
+
method: "initialize".to_string(),
|
| 109 |
+
params: Some(serde_json::to_value(InitializeParams {
|
| 110 |
+
client_info: ClientInfo {
|
| 111 |
+
name: CLIENT_NAME.to_string(),
|
| 112 |
+
title: Some("Codex App Server Daemon".to_string()),
|
| 113 |
+
version: env!("CARGO_PKG_VERSION").to_string(),
|
| 114 |
+
},
|
| 115 |
+
capabilities: if experimental_api {
|
| 116 |
+
Some(InitializeCapabilities {
|
| 117 |
+
experimental_api: true,
|
| 118 |
+
..Default::default()
|
| 119 |
+
})
|
| 120 |
+
} else {
|
| 121 |
+
None
|
| 122 |
+
},
|
| 123 |
+
})?),
|
| 124 |
+
trace: None,
|
| 125 |
+
});
|
| 126 |
+
send_message(websocket, &initialize)
|
| 127 |
+
.await
|
| 128 |
+
.context("failed to send initialize request")?;
|
| 129 |
+
|
| 130 |
+
let response = loop {
|
| 131 |
+
let message = timeout(CONTROL_SOCKET_RESPONSE_TIMEOUT, read_message(websocket))
|
| 132 |
+
.await
|
| 133 |
+
.context("timed out waiting for initialize response")??;
|
| 134 |
+
if let JSONRPCMessage::Response(response) = message
|
| 135 |
+
&& response.id == INITIALIZE_REQUEST_ID
|
| 136 |
+
{
|
| 137 |
+
break response;
|
| 138 |
+
}
|
| 139 |
+
};
|
| 140 |
+
serde_json::from_value::<InitializeResponse>(response.result)
|
| 141 |
+
.context("failed to parse initialize response")
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
pub(crate) async fn send_message<S>(
|
| 145 |
+
websocket: &mut WebSocketStream<S>,
|
| 146 |
+
message: &JSONRPCMessage,
|
| 147 |
+
) -> Result<()>
|
| 148 |
+
where
|
| 149 |
+
S: AsyncRead + AsyncWrite + Unpin,
|
| 150 |
+
{
|
| 151 |
+
websocket
|
| 152 |
+
.send(Message::Text(serde_json::to_string(message)?.into()))
|
| 153 |
+
.await?;
|
| 154 |
+
Ok(())
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
pub(crate) async fn read_message<S>(websocket: &mut WebSocketStream<S>) -> Result<JSONRPCMessage>
|
| 158 |
+
where
|
| 159 |
+
S: AsyncRead + AsyncWrite + Unpin,
|
| 160 |
+
{
|
| 161 |
+
loop {
|
| 162 |
+
let frame = websocket
|
| 163 |
+
.next()
|
| 164 |
+
.await
|
| 165 |
+
.ok_or_else(|| anyhow!("app-server closed the control socket"))??;
|
| 166 |
+
let Message::Text(payload) = frame else {
|
| 167 |
+
continue;
|
| 168 |
+
};
|
| 169 |
+
return serde_json::from_str::<JSONRPCMessage>(&payload)
|
| 170 |
+
.context("failed to parse app-server JSON-RPC message");
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
fn parse_version_from_user_agent(user_agent: &str) -> Result<String> {
|
| 175 |
+
let (_originator, rest) = user_agent
|
| 176 |
+
.split_once('/')
|
| 177 |
+
.ok_or_else(|| anyhow!("app-server user-agent omitted version separator"))?;
|
| 178 |
+
let version = rest
|
| 179 |
+
.split_whitespace()
|
| 180 |
+
.next()
|
| 181 |
+
.filter(|version| !version.is_empty())
|
| 182 |
+
.ok_or_else(|| anyhow!("app-server user-agent omitted version"))?;
|
| 183 |
+
Ok(version.to_string())
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
#[cfg(all(test, unix))]
|
| 187 |
+
mod tests {
|
| 188 |
+
use pretty_assertions::assert_eq;
|
| 189 |
+
|
| 190 |
+
use super::parse_version_from_user_agent;
|
| 191 |
+
|
| 192 |
+
#[test]
|
| 193 |
+
fn parses_version_from_codex_user_agent() {
|
| 194 |
+
assert_eq!(
|
| 195 |
+
parse_version_from_user_agent(
|
| 196 |
+
"codex_app_server_daemon/1.2.3 (Linux 6.8.0; x86_64) codex_cli_rs/1.2.3",
|
| 197 |
+
)
|
| 198 |
+
.expect("version"),
|
| 199 |
+
"1.2.3"
|
| 200 |
+
);
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
#[test]
|
| 204 |
+
fn rejects_user_agent_without_version() {
|
| 205 |
+
assert!(parse_version_from_user_agent("codex_app_server_daemon").is_err());
|
| 206 |
+
}
|
| 207 |
+
}
|
codex-rs/app-server-daemon/src/install_lock.rs
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Coordinates package selection with the shell and PowerShell installers.
|
| 2 |
+
|
| 3 |
+
use anyhow::Context;
|
| 4 |
+
use anyhow::Result;
|
| 5 |
+
use anyhow::anyhow;
|
| 6 |
+
use std::fs::OpenOptions;
|
| 7 |
+
use std::path::Path;
|
| 8 |
+
use std::time::Duration;
|
| 9 |
+
use tokio::time::sleep;
|
| 10 |
+
|
| 11 |
+
pub(crate) struct InstallLock {
|
| 12 |
+
_file: Option<tokio::fs::File>,
|
| 13 |
+
directory: Option<std::path::PathBuf>,
|
| 14 |
+
_directory_owner: Option<tempfile::TempDir>,
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
impl Drop for InstallLock {
|
| 18 |
+
fn drop(&mut self) {
|
| 19 |
+
if let Some(directory) = &self.directory {
|
| 20 |
+
let _ = std::fs::remove_file(directory);
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
pub(crate) async fn acquire_install_lock(root: &Path) -> Result<InstallLock> {
|
| 26 |
+
let lock = root.join("install.lock");
|
| 27 |
+
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
| 28 |
+
#[cfg(unix)]
|
| 29 |
+
let use_lockf = {
|
| 30 |
+
// Match the installer so both processes coordinate on the same lock.
|
| 31 |
+
let choice = tokio::process::Command::new("/bin/sh")
|
| 32 |
+
.args(["-c", if cfg!(target_os = "macos") {
|
| 33 |
+
"if command -v lockf >/dev/null 2>&1; then exit 0; elif command -v flock >/dev/null 2>&1; then exit 1; else exit 2; fi"
|
| 34 |
+
} else {
|
| 35 |
+
"if command -v flock >/dev/null 2>&1; then exit 1; else exit 2; fi"
|
| 36 |
+
}])
|
| 37 |
+
.status().await?;
|
| 38 |
+
match choice.code() {
|
| 39 |
+
Some(0) => true,
|
| 40 |
+
Some(1) => false,
|
| 41 |
+
Some(2) => {
|
| 42 |
+
let directory = root.join("install.lock.d");
|
| 43 |
+
// Publish complete metadata atomically. Older shell installers
|
| 44 |
+
// immediately remove an empty lock directory as stale.
|
| 45 |
+
let owner = tempfile::Builder::new()
|
| 46 |
+
.prefix("install-lock-")
|
| 47 |
+
.tempdir_in(root)?;
|
| 48 |
+
std::fs::write(owner.path().join("pid"), std::process::id().to_string())?;
|
| 49 |
+
let now = std::time::SystemTime::now()
|
| 50 |
+
.duration_since(std::time::UNIX_EPOCH)?
|
| 51 |
+
.as_secs();
|
| 52 |
+
std::fs::write(owner.path().join("started_at"), now.to_string())?;
|
| 53 |
+
loop {
|
| 54 |
+
match std::os::unix::fs::symlink(owner.path(), &directory) {
|
| 55 |
+
Ok(()) => {
|
| 56 |
+
return Ok(InstallLock {
|
| 57 |
+
_file: None,
|
| 58 |
+
directory: Some(directory),
|
| 59 |
+
_directory_owner: Some(owner),
|
| 60 |
+
});
|
| 61 |
+
}
|
| 62 |
+
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
|
| 63 |
+
Err(error) => {
|
| 64 |
+
return Err(error)
|
| 65 |
+
.context("failed to acquire daemon installer directory lock");
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
let pid = std::fs::read_to_string(directory.join("pid"))
|
| 69 |
+
.ok()
|
| 70 |
+
.and_then(|pid| pid.trim().parse::<i32>().ok())
|
| 71 |
+
.filter(|pid| *pid > 0);
|
| 72 |
+
let dead = pid.is_none_or(|pid| unsafe { libc::kill(pid, 0) } != 0
|
| 73 |
+
&& std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH));
|
| 74 |
+
let age = std::fs::metadata(&directory)
|
| 75 |
+
.and_then(|metadata| metadata.modified())
|
| 76 |
+
.ok()
|
| 77 |
+
.and_then(|modified| modified.elapsed().ok())
|
| 78 |
+
.unwrap_or_default();
|
| 79 |
+
// Allow the creator time to publish its PID; match the installer's
|
| 80 |
+
// ten-minute stale-lock grace instead of stealing an active lock.
|
| 81 |
+
if dead && age >= Duration::from_secs(600) {
|
| 82 |
+
std::fs::remove_dir_all(&directory)?;
|
| 83 |
+
continue;
|
| 84 |
+
}
|
| 85 |
+
anyhow::ensure!(
|
| 86 |
+
tokio::time::Instant::now() < deadline,
|
| 87 |
+
"timed out waiting for daemon installer lock {}",
|
| 88 |
+
directory.display()
|
| 89 |
+
);
|
| 90 |
+
sleep(Duration::from_millis(50)).await;
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
_ => return Err(anyhow!("failed to detect daemon installer lock tools")),
|
| 94 |
+
}
|
| 95 |
+
};
|
| 96 |
+
let mut options = OpenOptions::new();
|
| 97 |
+
options.create(true).truncate(false).write(true);
|
| 98 |
+
#[cfg(windows)]
|
| 99 |
+
{
|
| 100 |
+
use std::os::windows::fs::OpenOptionsExt;
|
| 101 |
+
options.share_mode(0);
|
| 102 |
+
}
|
| 103 |
+
loop {
|
| 104 |
+
let acquired = options.open(&lock);
|
| 105 |
+
#[cfg(unix)]
|
| 106 |
+
let acquired = acquired.and_then(|file| {
|
| 107 |
+
{
|
| 108 |
+
use std::os::fd::AsRawFd;
|
| 109 |
+
let result = if use_lockf {
|
| 110 |
+
unsafe { libc::lockf(file.as_raw_fd(), libc::F_TLOCK, 0) }
|
| 111 |
+
} else {
|
| 112 |
+
unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }
|
| 113 |
+
};
|
| 114 |
+
if result != 0 {
|
| 115 |
+
return Err(std::io::Error::last_os_error());
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
Ok(file)
|
| 119 |
+
});
|
| 120 |
+
match acquired {
|
| 121 |
+
Ok(file) => {
|
| 122 |
+
return Ok(InstallLock {
|
| 123 |
+
_file: Some(tokio::fs::File::from_std(file)),
|
| 124 |
+
directory: None,
|
| 125 |
+
_directory_owner: None,
|
| 126 |
+
});
|
| 127 |
+
}
|
| 128 |
+
Err(error)
|
| 129 |
+
if error.kind() == std::io::ErrorKind::WouldBlock
|
| 130 |
+
|| cfg!(windows)
|
| 131 |
+
&& (error.raw_os_error() == Some(32)
|
| 132 |
+
|| error.kind() == std::io::ErrorKind::PermissionDenied) => {}
|
| 133 |
+
Err(error) => return Err(error).context("failed to acquire daemon installer lock"),
|
| 134 |
+
}
|
| 135 |
+
if tokio::time::Instant::now() >= deadline {
|
| 136 |
+
return Err(anyhow!(
|
| 137 |
+
"timed out waiting for daemon installer lock {}",
|
| 138 |
+
lock.display()
|
| 139 |
+
));
|
| 140 |
+
}
|
| 141 |
+
sleep(Duration::from_millis(50)).await;
|
| 142 |
+
}
|
| 143 |
+
}
|
codex-rs/app-server-daemon/src/lib.rs
ADDED
|
@@ -0,0 +1,1400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Managed app-server lifecycle, serialized across CLI invocations and the updater.
|
| 2 |
+
|
| 3 |
+
mod backend;
|
| 4 |
+
#[cfg(windows)]
|
| 5 |
+
use backend::windows::try_lock_file;
|
| 6 |
+
mod client;
|
| 7 |
+
mod install_lock;
|
| 8 |
+
mod managed_install;
|
| 9 |
+
mod prepare_install;
|
| 10 |
+
pub use prepare_install::InstallRequest;
|
| 11 |
+
pub use prepare_install::update_from_cli;
|
| 12 |
+
mod remote_control_client;
|
| 13 |
+
mod settings;
|
| 14 |
+
mod thread_recovery;
|
| 15 |
+
mod update_loop;
|
| 16 |
+
|
| 17 |
+
use std::path::Path;
|
| 18 |
+
use std::path::PathBuf;
|
| 19 |
+
use std::time::Duration;
|
| 20 |
+
|
| 21 |
+
use anyhow::Context;
|
| 22 |
+
use anyhow::Result;
|
| 23 |
+
use anyhow::anyhow;
|
| 24 |
+
pub use backend::BackendKind;
|
| 25 |
+
use backend::BackendPaths;
|
| 26 |
+
use codex_app_server_protocol::RemoteControlConnectionStatus;
|
| 27 |
+
use codex_app_server_protocol::RemoteControlPairingStartResponse;
|
| 28 |
+
use codex_app_server_transport::app_server_control_socket_path;
|
| 29 |
+
use codex_utils_home_dir::find_codex_home;
|
| 30 |
+
use managed_install::managed_codex_bin;
|
| 31 |
+
#[cfg(any(unix, windows))]
|
| 32 |
+
use managed_install::managed_codex_version;
|
| 33 |
+
use serde::Serialize;
|
| 34 |
+
use settings::DaemonSettings;
|
| 35 |
+
use settings::MAX_SHUTDOWN_GRACE_SECONDS;
|
| 36 |
+
use tokio::time::sleep;
|
| 37 |
+
|
| 38 |
+
const START_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
| 39 |
+
const START_TIMEOUT: Duration = Duration::from_secs(10);
|
| 40 |
+
// Leave room for the longest graceful stop, forced-exit check, and restart.
|
| 41 |
+
const OPERATION_LOCK_TIMEOUT: Duration =
|
| 42 |
+
Duration::from_secs(MAX_SHUTDOWN_GRACE_SECONDS as u64 + 75);
|
| 43 |
+
const LEGACY_PID_FILE_NAME: &str = "app-server.pid";
|
| 44 |
+
const LEGACY_UPDATE_PID_FILE_NAME: &str = "app-server-updater.pid";
|
| 45 |
+
const DAEMON_PID_FILE_NAME: &str = "daemon.pid";
|
| 46 |
+
const DAEMON_UPDATE_PID_FILE_NAME: &str = "daemon-updater.pid";
|
| 47 |
+
const OPERATION_LOCK_FILE_NAME: &str = "daemon.lock";
|
| 48 |
+
const SETTINGS_FILE_NAME: &str = "settings.json";
|
| 49 |
+
const STATE_DIR_NAME: &str = "app-server-daemon";
|
| 50 |
+
|
| 51 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
| 52 |
+
pub enum LifecycleCommand {
|
| 53 |
+
Start,
|
| 54 |
+
Restart,
|
| 55 |
+
Stop,
|
| 56 |
+
Version,
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
| 60 |
+
#[serde(rename_all = "camelCase")]
|
| 61 |
+
pub enum LifecycleStatus {
|
| 62 |
+
AlreadyRunning,
|
| 63 |
+
Started,
|
| 64 |
+
Restarted,
|
| 65 |
+
Stopped,
|
| 66 |
+
NotRunning,
|
| 67 |
+
Running,
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
| 71 |
+
#[serde(rename_all = "camelCase")]
|
| 72 |
+
pub struct LifecycleOutput {
|
| 73 |
+
pub status: LifecycleStatus,
|
| 74 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 75 |
+
pub backend: Option<BackendKind>,
|
| 76 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 77 |
+
pub pid: Option<u32>,
|
| 78 |
+
pub managed_codex_path: PathBuf,
|
| 79 |
+
pub managed_codex_version: Option<String>,
|
| 80 |
+
pub socket_path: PathBuf,
|
| 81 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 82 |
+
pub cli_version: Option<String>,
|
| 83 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 84 |
+
pub app_server_version: Option<String>,
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
| 88 |
+
pub struct BootstrapOptions {
|
| 89 |
+
pub remote_control_enabled: bool,
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/// Passively probes an existing app-server socket and returns its reported
|
| 93 |
+
/// app-server version.
|
| 94 |
+
pub async fn probe_app_server_version(socket_path: &Path) -> Result<String> {
|
| 95 |
+
Ok(client::probe(socket_path).await?.app_server_version)
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
| 99 |
+
#[serde(rename_all = "camelCase")]
|
| 100 |
+
pub enum BootstrapStatus {
|
| 101 |
+
Bootstrapped,
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
| 105 |
+
#[serde(rename_all = "camelCase")]
|
| 106 |
+
pub struct BootstrapOutput {
|
| 107 |
+
pub status: BootstrapStatus,
|
| 108 |
+
pub backend: BackendKind,
|
| 109 |
+
pub auto_update_enabled: bool,
|
| 110 |
+
pub remote_control_enabled: bool,
|
| 111 |
+
pub managed_codex_path: PathBuf,
|
| 112 |
+
pub managed_codex_version: Option<String>,
|
| 113 |
+
pub socket_path: PathBuf,
|
| 114 |
+
pub cli_version: String,
|
| 115 |
+
pub app_server_version: String,
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)]
|
| 119 |
+
#[serde(rename_all = "camelCase")]
|
| 120 |
+
pub enum UpdateStatus {
|
| 121 |
+
Updated,
|
| 122 |
+
NoUpdate,
|
| 123 |
+
Unsupported,
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
|
| 127 |
+
#[serde(rename_all = "camelCase")]
|
| 128 |
+
pub struct UpdateOutput {
|
| 129 |
+
pub status: UpdateStatus,
|
| 130 |
+
pub managed_codex_path: PathBuf,
|
| 131 |
+
pub installed_version: Option<String>,
|
| 132 |
+
pub running_version: Option<String>,
|
| 133 |
+
pub message: String,
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
| 137 |
+
#[serde(untagged)]
|
| 138 |
+
pub enum RemoteControlStartOutput {
|
| 139 |
+
Bootstrap(BootstrapOutput),
|
| 140 |
+
Start(LifecycleOutput),
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
| 144 |
+
pub struct RemoteControlReadyStatus {
|
| 145 |
+
pub status: RemoteControlConnectionStatus,
|
| 146 |
+
pub server_name: String,
|
| 147 |
+
pub environment_id: Option<String>,
|
| 148 |
+
pub timed_out: bool,
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
| 152 |
+
pub struct RemoteControlReadyOutput {
|
| 153 |
+
pub daemon: RemoteControlStartOutput,
|
| 154 |
+
pub remote_control: RemoteControlReadyStatus,
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
| 158 |
+
pub enum RemoteControlMode {
|
| 159 |
+
Enabled,
|
| 160 |
+
Disabled,
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
impl RemoteControlMode {
|
| 164 |
+
fn is_enabled(self) -> bool {
|
| 165 |
+
matches!(self, Self::Enabled)
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
| 170 |
+
#[serde(rename_all = "camelCase")]
|
| 171 |
+
pub enum RemoteControlStatus {
|
| 172 |
+
Enabled,
|
| 173 |
+
Disabled,
|
| 174 |
+
AlreadyEnabled,
|
| 175 |
+
AlreadyDisabled,
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
| 179 |
+
#[serde(rename_all = "camelCase")]
|
| 180 |
+
pub struct RemoteControlOutput {
|
| 181 |
+
pub status: RemoteControlStatus,
|
| 182 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 183 |
+
pub backend: Option<BackendKind>,
|
| 184 |
+
pub remote_control_enabled: bool,
|
| 185 |
+
pub socket_path: PathBuf,
|
| 186 |
+
pub cli_version: String,
|
| 187 |
+
#[serde(skip_serializing_if = "Option::is_none")]
|
| 188 |
+
pub app_server_version: Option<String>,
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
#[cfg(any(unix, windows))]
|
| 192 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
| 193 |
+
pub(crate) enum RestartIfRunningOutcome {
|
| 194 |
+
Busy,
|
| 195 |
+
NotRunning,
|
| 196 |
+
NotReady,
|
| 197 |
+
AlreadyCurrent,
|
| 198 |
+
Restarted,
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
#[cfg(any(unix, windows))]
|
| 202 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
| 203 |
+
pub(crate) enum RestartMode {
|
| 204 |
+
IfVersionChanged,
|
| 205 |
+
IfBinaryOrVersionChanged,
|
| 206 |
+
Always,
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
#[cfg(any(unix, windows))]
|
| 210 |
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
| 211 |
+
enum RestartDecision {
|
| 212 |
+
NotReady,
|
| 213 |
+
AlreadyCurrent,
|
| 214 |
+
Restart,
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
pub async fn run(command: LifecycleCommand) -> Result<LifecycleOutput> {
|
| 218 |
+
ensure_supported_platform()?;
|
| 219 |
+
#[cfg(windows)]
|
| 220 |
+
if matches!(command, LifecycleCommand::Start | LifecycleCommand::Restart) {
|
| 221 |
+
backend::windows::ensure_not_elevated()?;
|
| 222 |
+
}
|
| 223 |
+
// Keep daemon package preparation off callers' async stack frames.
|
| 224 |
+
Box::pin(Daemon::from_environment()?.run(command)).await
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
pub async fn bootstrap(options: BootstrapOptions) -> Result<BootstrapOutput> {
|
| 228 |
+
ensure_supported_platform()?;
|
| 229 |
+
#[cfg(windows)]
|
| 230 |
+
backend::windows::ensure_not_elevated()?;
|
| 231 |
+
Box::pin(Daemon::from_environment()?.bootstrap(options)).await
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
pub async fn ensure_remote_control_ready() -> Result<RemoteControlReadyOutput> {
|
| 235 |
+
ensure_supported_platform()?;
|
| 236 |
+
#[cfg(windows)]
|
| 237 |
+
backend::windows::ensure_not_elevated()?;
|
| 238 |
+
Box::pin(Daemon::from_environment()?.ensure_remote_control_ready()).await
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
pub async fn enable_remote_control_on_socket(
|
| 242 |
+
socket_path: &Path,
|
| 243 |
+
connect_timeout: Duration,
|
| 244 |
+
connect_retry_delay: Duration,
|
| 245 |
+
) -> Result<RemoteControlReadyStatus> {
|
| 246 |
+
ensure_supported_platform()?;
|
| 247 |
+
remote_control_client::enable_remote_control_with_connect_retry(
|
| 248 |
+
socket_path,
|
| 249 |
+
connect_timeout,
|
| 250 |
+
connect_retry_delay,
|
| 251 |
+
)
|
| 252 |
+
.await
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
/// Starts a manual pairing session through an already-running daemon app-server.
|
| 256 |
+
pub async fn start_remote_control_pairing() -> Result<RemoteControlPairingStartResponse> {
|
| 257 |
+
ensure_supported_platform()?;
|
| 258 |
+
let daemon = Daemon::from_environment()?;
|
| 259 |
+
remote_control_client::start_pairing(&daemon.socket_path).await
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
pub async fn set_remote_control(mode: RemoteControlMode) -> Result<RemoteControlOutput> {
|
| 263 |
+
ensure_supported_platform()?;
|
| 264 |
+
#[cfg(windows)]
|
| 265 |
+
backend::windows::ensure_not_elevated()?;
|
| 266 |
+
Box::pin(Daemon::from_environment()?.set_remote_control(mode)).await
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
pub async fn run_pid_update_loop(
|
| 270 |
+
http_client_factory: codex_http_client::HttpClientFactory,
|
| 271 |
+
restore_release: Option<String>,
|
| 272 |
+
) -> Result<()> {
|
| 273 |
+
ensure_supported_platform()?;
|
| 274 |
+
#[cfg(windows)]
|
| 275 |
+
backend::windows::ensure_not_elevated()?;
|
| 276 |
+
update_loop::run(http_client_factory, restore_release).await
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
pub async fn update(
|
| 280 |
+
http_client_factory: codex_http_client::HttpClientFactory,
|
| 281 |
+
) -> Result<UpdateOutput> {
|
| 282 |
+
ensure_supported_platform()?;
|
| 283 |
+
#[cfg(windows)]
|
| 284 |
+
backend::windows::ensure_not_elevated()?;
|
| 285 |
+
update_loop::request_manual_update(&Daemon::from_environment()?, http_client_factory).await
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
#[cfg(any(unix, windows))]
|
| 289 |
+
fn ensure_supported_platform() -> Result<()> {
|
| 290 |
+
Ok(())
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
#[cfg(not(any(unix, windows)))]
|
| 294 |
+
fn ensure_supported_platform() -> Result<()> {
|
| 295 |
+
Err(anyhow!(
|
| 296 |
+
"codex app-server daemon lifecycle is only supported on Unix and Windows platforms"
|
| 297 |
+
))
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
#[derive(Clone)]
|
| 301 |
+
struct Daemon {
|
| 302 |
+
socket_path: PathBuf,
|
| 303 |
+
pid_file: PathBuf,
|
| 304 |
+
update_pid_file: PathBuf,
|
| 305 |
+
operation_lock_file: PathBuf,
|
| 306 |
+
settings_file: PathBuf,
|
| 307 |
+
managed_codex_bin: PathBuf,
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
impl Daemon {
|
| 311 |
+
fn from_environment() -> Result<Self> {
|
| 312 |
+
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
|
| 313 |
+
let socket_path = app_server_control_socket_path(codex_home.as_path())?
|
| 314 |
+
.as_path()
|
| 315 |
+
.to_path_buf();
|
| 316 |
+
let state_dir = codex_home.as_path().join(STATE_DIR_NAME);
|
| 317 |
+
let managed_codex_bin = managed_codex_bin(codex_home.as_path());
|
| 318 |
+
// Old CLIs must not mistake a daemon-owned installation for their backend.
|
| 319 |
+
let (pid_file, update_pid_file) =
|
| 320 |
+
if managed_codex_bin.starts_with(codex_home.as_path().join("packages/standalone")) {
|
| 321 |
+
(LEGACY_PID_FILE_NAME, LEGACY_UPDATE_PID_FILE_NAME)
|
| 322 |
+
} else {
|
| 323 |
+
(DAEMON_PID_FILE_NAME, DAEMON_UPDATE_PID_FILE_NAME)
|
| 324 |
+
};
|
| 325 |
+
Ok(Self {
|
| 326 |
+
socket_path,
|
| 327 |
+
pid_file: state_dir.join(pid_file),
|
| 328 |
+
update_pid_file: state_dir.join(update_pid_file),
|
| 329 |
+
operation_lock_file: state_dir.join(OPERATION_LOCK_FILE_NAME),
|
| 330 |
+
settings_file: state_dir.join(SETTINGS_FILE_NAME),
|
| 331 |
+
managed_codex_bin,
|
| 332 |
+
})
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
fn recovery_file(&self) -> Result<PathBuf> {
|
| 336 |
+
Ok(codex_app_server_transport::daemon_recovery_file_path(
|
| 337 |
+
self.settings_file
|
| 338 |
+
.parent()
|
| 339 |
+
.and_then(Path::parent)
|
| 340 |
+
.context("daemon settings path has no Codex home")?,
|
| 341 |
+
))
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
// Call only after taking the operation lock: an explicit update may have
|
| 345 |
+
// migrated the package and PID namespace while this command was waiting.
|
| 346 |
+
fn current_installation(&self) -> Result<Self> {
|
| 347 |
+
let managed_codex_bin = self.current_managed_codex_bin()?;
|
| 348 |
+
let home = self
|
| 349 |
+
.settings_file
|
| 350 |
+
.parent()
|
| 351 |
+
.and_then(Path::parent)
|
| 352 |
+
.context("daemon settings path has no Codex home")?;
|
| 353 |
+
let (pid, updater) = if managed_codex_bin.starts_with(home.join("packages/standalone")) {
|
| 354 |
+
(LEGACY_PID_FILE_NAME, LEGACY_UPDATE_PID_FILE_NAME)
|
| 355 |
+
} else {
|
| 356 |
+
(DAEMON_PID_FILE_NAME, DAEMON_UPDATE_PID_FILE_NAME)
|
| 357 |
+
};
|
| 358 |
+
Ok(Self {
|
| 359 |
+
managed_codex_bin,
|
| 360 |
+
pid_file: self.pid_file.with_file_name(pid),
|
| 361 |
+
update_pid_file: self.update_pid_file.with_file_name(updater),
|
| 362 |
+
..self.clone()
|
| 363 |
+
})
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
async fn run(&self, command: LifecycleCommand) -> Result<LifecycleOutput> {
|
| 367 |
+
if command == LifecycleCommand::Version {
|
| 368 |
+
return self.version().await;
|
| 369 |
+
}
|
| 370 |
+
let _operation_lock = self.acquire_operation_lock().await?;
|
| 371 |
+
let selected = self.current_installation()?;
|
| 372 |
+
match command {
|
| 373 |
+
LifecycleCommand::Start => selected.start().await,
|
| 374 |
+
LifecycleCommand::Restart => selected.restart().await,
|
| 375 |
+
LifecycleCommand::Stop => {
|
| 376 |
+
let output = selected.stop().await?;
|
| 377 |
+
if let Err(err) = thread_recovery::discard_pending(&selected) {
|
| 378 |
+
eprintln!("warning: failed to clear saved threads after daemon stop: {err}");
|
| 379 |
+
}
|
| 380 |
+
Ok(output)
|
| 381 |
+
}
|
| 382 |
+
LifecycleCommand::Version => unreachable!(),
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
async fn start(&self) -> Result<LifecycleOutput> {
|
| 387 |
+
let mut managed = self.clone();
|
| 388 |
+
let settings = self.load_settings().await?;
|
| 389 |
+
let (status, backend, pid, info) = if let Ok(info) = client::probe(&self.socket_path).await
|
| 390 |
+
{
|
| 391 |
+
(
|
| 392 |
+
LifecycleStatus::AlreadyRunning,
|
| 393 |
+
self.running_backend(&settings).await?,
|
| 394 |
+
None,
|
| 395 |
+
info,
|
| 396 |
+
)
|
| 397 |
+
} else if self.running_backend_instance(&settings).await?.is_some() {
|
| 398 |
+
(
|
| 399 |
+
LifecycleStatus::AlreadyRunning,
|
| 400 |
+
Some(BackendKind::Pid),
|
| 401 |
+
None,
|
| 402 |
+
self.wait_until_ready().await?,
|
| 403 |
+
)
|
| 404 |
+
} else {
|
| 405 |
+
// A fresh start must ignore snapshots left by older stop clients.
|
| 406 |
+
if let Err(err) = thread_recovery::discard_pending(self) {
|
| 407 |
+
eprintln!("warning: failed to clear stale daemon recovery before start: {err}");
|
| 408 |
+
}
|
| 409 |
+
prepare_install::prepare(self, &settings).await?;
|
| 410 |
+
managed.managed_codex_bin = self.current_managed_codex_bin()?;
|
| 411 |
+
managed.ensure_managed_codex_bin()?;
|
| 412 |
+
let pid = managed.start_managed_backend(&settings).await?;
|
| 413 |
+
(
|
| 414 |
+
LifecycleStatus::Started,
|
| 415 |
+
Some(BackendKind::Pid),
|
| 416 |
+
pid,
|
| 417 |
+
self.wait_until_ready().await?,
|
| 418 |
+
)
|
| 419 |
+
};
|
| 420 |
+
if backend.is_some()
|
| 421 |
+
&& let Err(err) = managed.ensure_managed_updater(&settings).await
|
| 422 |
+
{
|
| 423 |
+
eprintln!("warning: failed to ensure managed updater after app-server start: {err:#}");
|
| 424 |
+
}
|
| 425 |
+
Ok(managed
|
| 426 |
+
.output(status, backend, pid, Some(info.app_server_version))
|
| 427 |
+
.await)
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
async fn restart(&self) -> Result<LifecycleOutput> {
|
| 431 |
+
let settings = self.load_settings().await?;
|
| 432 |
+
if client::probe(&self.socket_path).await.is_ok()
|
| 433 |
+
&& self.running_backend(&settings).await?.is_none()
|
| 434 |
+
{
|
| 435 |
+
return Err(anyhow!(
|
| 436 |
+
"app server is running but is not managed by codex app-server daemon"
|
| 437 |
+
));
|
| 438 |
+
}
|
| 439 |
+
prepare_install::prepare(self, &settings).await?;
|
| 440 |
+
let mut managed = self.clone();
|
| 441 |
+
managed.managed_codex_bin = self.current_managed_codex_bin()?;
|
| 442 |
+
if !settings.auto_update_enabled {
|
| 443 |
+
backend::pid_update_loop_backend(self.backend_paths(&settings))
|
| 444 |
+
.stop()
|
| 445 |
+
.await?;
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
managed.ensure_managed_codex_bin()?;
|
| 449 |
+
if let Some(backend) = self.running_backend_instance(&settings).await? {
|
| 450 |
+
if let Err(err) = thread_recovery::discard_pending(self) {
|
| 451 |
+
eprintln!("warning: failed to clear stale daemon recovery before restart: {err}");
|
| 452 |
+
}
|
| 453 |
+
backend
|
| 454 |
+
.stop_with_grace(settings.shutdown_grace_seconds)
|
| 455 |
+
.await?;
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
let pid = managed.start_managed_backend(&settings).await?;
|
| 459 |
+
let info = self.wait_until_ready().await?;
|
| 460 |
+
if let Err(err) = managed.ensure_managed_updater(&settings).await {
|
| 461 |
+
eprintln!(
|
| 462 |
+
"warning: failed to ensure managed updater after app-server restart: {err:#}"
|
| 463 |
+
);
|
| 464 |
+
}
|
| 465 |
+
Ok(managed
|
| 466 |
+
.output(
|
| 467 |
+
LifecycleStatus::Restarted,
|
| 468 |
+
Some(BackendKind::Pid),
|
| 469 |
+
pid,
|
| 470 |
+
Some(info.app_server_version),
|
| 471 |
+
)
|
| 472 |
+
.await)
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
#[cfg(any(unix, windows))]
|
| 476 |
+
pub(crate) async fn try_restart_if_running(
|
| 477 |
+
&self,
|
| 478 |
+
mode: RestartMode,
|
| 479 |
+
managed_codex_bin: &Path,
|
| 480 |
+
) -> Result<RestartIfRunningOutcome> {
|
| 481 |
+
let operation_lock = self.open_operation_lock_file().await?;
|
| 482 |
+
if !try_lock_file(&operation_lock)? {
|
| 483 |
+
return Ok(RestartIfRunningOutcome::Busy);
|
| 484 |
+
}
|
| 485 |
+
let settings = self.load_settings().await?;
|
| 486 |
+
let outcome = if let Some(backend) = self.running_backend_instance(&settings).await? {
|
| 487 |
+
let info = client::probe(&self.socket_path).await.ok();
|
| 488 |
+
let managed_version = if info.is_some() {
|
| 489 |
+
Some(managed_codex_version(managed_codex_bin).await?)
|
| 490 |
+
} else {
|
| 491 |
+
None
|
| 492 |
+
};
|
| 493 |
+
// The installer can retarget `current` while the updater waits for
|
| 494 |
+
// this lock or probes the running server. Never restart from a
|
| 495 |
+
// release that is no longer the selected latest-channel binary.
|
| 496 |
+
if !self.is_stable_standalone_release()?
|
| 497 |
+
|| managed_install::resolved_managed_codex_bin(&self.current_managed_codex_bin()?)
|
| 498 |
+
.await
|
| 499 |
+
.ok()
|
| 500 |
+
.as_deref()
|
| 501 |
+
!= Some(managed_codex_bin)
|
| 502 |
+
{
|
| 503 |
+
return Ok(RestartIfRunningOutcome::AlreadyCurrent);
|
| 504 |
+
}
|
| 505 |
+
let mode = if mode == RestartMode::IfBinaryOrVersionChanged {
|
| 506 |
+
let managed_identity =
|
| 507 |
+
managed_install::executable_identity(managed_codex_bin).await?;
|
| 508 |
+
if backend.running_executable_identity().await?.as_ref() == Some(&managed_identity)
|
| 509 |
+
{
|
| 510 |
+
RestartMode::IfVersionChanged
|
| 511 |
+
} else {
|
| 512 |
+
RestartMode::Always
|
| 513 |
+
}
|
| 514 |
+
} else {
|
| 515 |
+
mode
|
| 516 |
+
};
|
| 517 |
+
match restart_decision(mode, info.as_ref(), managed_version.as_deref()) {
|
| 518 |
+
RestartDecision::NotReady => return Ok(RestartIfRunningOutcome::NotReady),
|
| 519 |
+
RestartDecision::AlreadyCurrent => RestartIfRunningOutcome::AlreadyCurrent,
|
| 520 |
+
RestartDecision::Restart => {
|
| 521 |
+
#[cfg(windows)]
|
| 522 |
+
backend::windows::ensure_detached_launch(managed_codex_bin)?;
|
| 523 |
+
if let Err(err) = thread_recovery::discard_pending(self) {
|
| 524 |
+
eprintln!(
|
| 525 |
+
"warning: failed to clear stale daemon recovery before update: {err}"
|
| 526 |
+
);
|
| 527 |
+
}
|
| 528 |
+
backend
|
| 529 |
+
.stop_with_grace(settings.shutdown_grace_seconds)
|
| 530 |
+
.await?;
|
| 531 |
+
let _ = self
|
| 532 |
+
.start_managed_backend_with_bin(&settings, managed_codex_bin)
|
| 533 |
+
.await?;
|
| 534 |
+
self.wait_until_ready().await?;
|
| 535 |
+
RestartIfRunningOutcome::Restarted
|
| 536 |
+
}
|
| 537 |
+
}
|
| 538 |
+
} else if client::probe(&self.socket_path).await.is_ok() {
|
| 539 |
+
return Err(anyhow!(
|
| 540 |
+
"app server is running but is not managed by codex app-server daemon"
|
| 541 |
+
));
|
| 542 |
+
} else {
|
| 543 |
+
RestartIfRunningOutcome::NotRunning
|
| 544 |
+
};
|
| 545 |
+
|
| 546 |
+
if !self.is_stable_standalone_release()?
|
| 547 |
+
|| managed_install::resolved_managed_codex_bin(&self.current_managed_codex_bin()?)
|
| 548 |
+
.await
|
| 549 |
+
.ok()
|
| 550 |
+
.as_deref()
|
| 551 |
+
!= Some(managed_codex_bin)
|
| 552 |
+
{
|
| 553 |
+
return Ok(RestartIfRunningOutcome::AlreadyCurrent);
|
| 554 |
+
}
|
| 555 |
+
Ok(outcome)
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
async fn stop(&self) -> Result<LifecycleOutput> {
|
| 559 |
+
let settings = DaemonSettings::load_for_stop(&self.settings_file).await;
|
| 560 |
+
if let Some(backend) = self.running_backend_instance(&settings).await? {
|
| 561 |
+
backend
|
| 562 |
+
.stop_with_grace(settings.shutdown_grace_seconds)
|
| 563 |
+
.await?;
|
| 564 |
+
return Ok(self
|
| 565 |
+
.output(
|
| 566 |
+
LifecycleStatus::Stopped,
|
| 567 |
+
Some(BackendKind::Pid),
|
| 568 |
+
/*pid*/ None,
|
| 569 |
+
/*app_server_version*/ None,
|
| 570 |
+
)
|
| 571 |
+
.await);
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
if client::probe(&self.socket_path).await.is_ok() {
|
| 575 |
+
return Err(anyhow!(
|
| 576 |
+
"app server is running but is not managed by codex app-server daemon"
|
| 577 |
+
));
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
Ok(self
|
| 581 |
+
.output(
|
| 582 |
+
LifecycleStatus::NotRunning,
|
| 583 |
+
/*backend*/ None,
|
| 584 |
+
/*pid*/ None,
|
| 585 |
+
/*app_server_version*/ None,
|
| 586 |
+
)
|
| 587 |
+
.await)
|
| 588 |
+
}
|
| 589 |
+
|
| 590 |
+
async fn version(&self) -> Result<LifecycleOutput> {
|
| 591 |
+
let settings = self.load_settings().await?;
|
| 592 |
+
let info = client::probe(&self.socket_path).await?;
|
| 593 |
+
Ok(self
|
| 594 |
+
.output(
|
| 595 |
+
LifecycleStatus::Running,
|
| 596 |
+
self.running_backend(&settings).await?,
|
| 597 |
+
/*pid*/ None,
|
| 598 |
+
Some(info.app_server_version),
|
| 599 |
+
)
|
| 600 |
+
.await)
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
async fn wait_until_ready(&self) -> Result<client::ProbeInfo> {
|
| 604 |
+
let deadline = tokio::time::Instant::now() + START_TIMEOUT;
|
| 605 |
+
loop {
|
| 606 |
+
match client::probe(&self.socket_path).await {
|
| 607 |
+
Ok(info) => return Ok(info),
|
| 608 |
+
Err(err) if tokio::time::Instant::now() < deadline => {
|
| 609 |
+
let _ = err;
|
| 610 |
+
sleep(START_POLL_INTERVAL).await;
|
| 611 |
+
}
|
| 612 |
+
Err(err) => {
|
| 613 |
+
let context = self.app_server_not_ready_context().await;
|
| 614 |
+
return Err(err).context(context);
|
| 615 |
+
}
|
| 616 |
+
}
|
| 617 |
+
}
|
| 618 |
+
}
|
| 619 |
+
|
| 620 |
+
async fn app_server_not_ready_context(&self) -> String {
|
| 621 |
+
let mut context = format!(
|
| 622 |
+
"app server did not become ready on {}",
|
| 623 |
+
self.socket_path.display()
|
| 624 |
+
);
|
| 625 |
+
self.append_daemon_app_server_context(&mut context).await;
|
| 626 |
+
backend::append_stderr_log_tail_context(&self.pid_file, &mut context).await;
|
| 627 |
+
context
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
async fn append_daemon_app_server_context(&self, context: &mut String) {
|
| 631 |
+
let managed_codex_version = self
|
| 632 |
+
.managed_codex_version_best_effort()
|
| 633 |
+
.await
|
| 634 |
+
.unwrap_or_else(|| "unknown".to_string());
|
| 635 |
+
context.push_str(&format!(
|
| 636 |
+
"\n\nDaemon used app-server:\n path: {}\n version: {managed_codex_version}",
|
| 637 |
+
self.managed_codex_bin.display()
|
| 638 |
+
));
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
async fn bootstrap(&self, options: BootstrapOptions) -> Result<BootstrapOutput> {
|
| 642 |
+
let _operation_lock = self.acquire_operation_lock().await?;
|
| 643 |
+
self.current_installation()?.bootstrap_locked(options).await
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
async fn ensure_remote_control_started(&self) -> Result<RemoteControlStartOutput> {
|
| 647 |
+
let _operation_lock = self.acquire_operation_lock().await?;
|
| 648 |
+
let selected = self.current_installation()?;
|
| 649 |
+
let settings = selected.load_settings().await?;
|
| 650 |
+
if selected.is_bootstrapped(&settings).await? {
|
| 651 |
+
let _ = selected
|
| 652 |
+
.set_remote_control_locked(RemoteControlMode::Enabled)
|
| 653 |
+
.await?;
|
| 654 |
+
let output = selected.start().await?;
|
| 655 |
+
return Ok(RemoteControlStartOutput::Start(output));
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
let output = selected
|
| 659 |
+
.bootstrap_locked(BootstrapOptions {
|
| 660 |
+
remote_control_enabled: true,
|
| 661 |
+
})
|
| 662 |
+
.await?;
|
| 663 |
+
Ok(RemoteControlStartOutput::Bootstrap(output))
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
async fn ensure_remote_control_ready(&self) -> Result<RemoteControlReadyOutput> {
|
| 667 |
+
let daemon = self.ensure_remote_control_started().await?;
|
| 668 |
+
let remote_control =
|
| 669 |
+
remote_control_client::enable_remote_control(&self.socket_path).await?;
|
| 670 |
+
Ok(RemoteControlReadyOutput {
|
| 671 |
+
daemon,
|
| 672 |
+
remote_control,
|
| 673 |
+
})
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
async fn set_remote_control(&self, mode: RemoteControlMode) -> Result<RemoteControlOutput> {
|
| 677 |
+
let _operation_lock = self.acquire_operation_lock().await?;
|
| 678 |
+
self.current_installation()?
|
| 679 |
+
.set_remote_control_locked(mode)
|
| 680 |
+
.await
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
async fn set_remote_control_locked(
|
| 684 |
+
&self,
|
| 685 |
+
mode: RemoteControlMode,
|
| 686 |
+
) -> Result<RemoteControlOutput> {
|
| 687 |
+
let previous_settings = self.load_settings().await?;
|
| 688 |
+
let mut settings = previous_settings.clone();
|
| 689 |
+
let remote_control_enabled = mode.is_enabled();
|
| 690 |
+
let backend = self.running_backend_instance(&previous_settings).await?;
|
| 691 |
+
|
| 692 |
+
if backend.is_none() && client::probe(&self.socket_path).await.is_ok() {
|
| 693 |
+
return Err(anyhow!(
|
| 694 |
+
"app server is running but is not managed by codex app-server daemon"
|
| 695 |
+
));
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
if settings.remote_control_enabled == remote_control_enabled {
|
| 699 |
+
let info = if backend.is_some() {
|
| 700 |
+
Some(self.wait_until_ready().await?)
|
| 701 |
+
} else {
|
| 702 |
+
None
|
| 703 |
+
};
|
| 704 |
+
if info.is_some() {
|
| 705 |
+
match mode {
|
| 706 |
+
RemoteControlMode::Enabled => {
|
| 707 |
+
remote_control_client::enable_remote_control(&self.socket_path).await?;
|
| 708 |
+
}
|
| 709 |
+
RemoteControlMode::Disabled => {
|
| 710 |
+
remote_control_client::disable_remote_control(&self.socket_path).await?;
|
| 711 |
+
}
|
| 712 |
+
}
|
| 713 |
+
}
|
| 714 |
+
return Ok(self.remote_control_output(
|
| 715 |
+
already_remote_control_status(mode),
|
| 716 |
+
backend.map(|_| BackendKind::Pid),
|
| 717 |
+
remote_control_enabled,
|
| 718 |
+
info.map(|info| info.app_server_version),
|
| 719 |
+
));
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
if backend.is_some() {
|
| 723 |
+
self.ensure_managed_codex_bin()?;
|
| 724 |
+
}
|
| 725 |
+
settings.remote_control_enabled = remote_control_enabled;
|
| 726 |
+
settings.save(&self.settings_file).await?;
|
| 727 |
+
|
| 728 |
+
let app_server_version = if let Some(backend) = backend {
|
| 729 |
+
if let Err(err) = thread_recovery::discard_pending(self) {
|
| 730 |
+
eprintln!(
|
| 731 |
+
"warning: failed to clear stale recovery before remote-control restart: {err}"
|
| 732 |
+
);
|
| 733 |
+
}
|
| 734 |
+
backend
|
| 735 |
+
.stop_with_grace(settings.shutdown_grace_seconds)
|
| 736 |
+
.await?;
|
| 737 |
+
let _ = self.start_managed_backend(&settings).await?;
|
| 738 |
+
let info = self.wait_until_ready().await?;
|
| 739 |
+
if let Err(err) = self.ensure_managed_updater(&settings).await {
|
| 740 |
+
eprintln!(
|
| 741 |
+
"warning: failed to ensure managed updater after remote-control change: {err:#}"
|
| 742 |
+
);
|
| 743 |
+
}
|
| 744 |
+
Some(info.app_server_version)
|
| 745 |
+
} else {
|
| 746 |
+
None
|
| 747 |
+
};
|
| 748 |
+
|
| 749 |
+
Ok(self.remote_control_output(
|
| 750 |
+
remote_control_status(mode),
|
| 751 |
+
app_server_version.as_ref().map(|_| BackendKind::Pid),
|
| 752 |
+
remote_control_enabled,
|
| 753 |
+
app_server_version,
|
| 754 |
+
))
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
async fn bootstrap_locked(&self, options: BootstrapOptions) -> Result<BootstrapOutput> {
|
| 758 |
+
let mut settings = self.load_settings().await?;
|
| 759 |
+
settings.remote_control_enabled = options.remote_control_enabled;
|
| 760 |
+
if client::probe(&self.socket_path).await.is_ok()
|
| 761 |
+
&& self.running_backend(&settings).await?.is_none()
|
| 762 |
+
{
|
| 763 |
+
return Err(anyhow!(
|
| 764 |
+
"app server is running but is not managed by codex app-server daemon"
|
| 765 |
+
));
|
| 766 |
+
}
|
| 767 |
+
prepare_install::prepare(self, &settings).await?;
|
| 768 |
+
let mut managed = self.clone();
|
| 769 |
+
managed.managed_codex_bin = self.current_managed_codex_bin()?;
|
| 770 |
+
managed.ensure_managed_codex_bin()?;
|
| 771 |
+
settings.save(&self.settings_file).await?;
|
| 772 |
+
|
| 773 |
+
backend::pid_update_loop_backend(self.backend_paths(&settings))
|
| 774 |
+
.stop()
|
| 775 |
+
.await?;
|
| 776 |
+
if let Some(backend) = self.running_backend_instance(&settings).await? {
|
| 777 |
+
if let Err(err) = thread_recovery::discard_pending(self) {
|
| 778 |
+
eprintln!("warning: failed to clear stale daemon recovery before bootstrap: {err}");
|
| 779 |
+
}
|
| 780 |
+
backend
|
| 781 |
+
.stop_with_grace(settings.shutdown_grace_seconds)
|
| 782 |
+
.await?;
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
let backend = backend::pid_backend(managed.backend_paths(&settings));
|
| 786 |
+
backend.start().await?;
|
| 787 |
+
let info = self.wait_until_ready().await?;
|
| 788 |
+
let auto_update_enabled = managed.ensure_managed_updater(&settings).await?;
|
| 789 |
+
let managed_codex_version = managed.managed_codex_version_best_effort().await;
|
| 790 |
+
Ok(BootstrapOutput {
|
| 791 |
+
status: BootstrapStatus::Bootstrapped,
|
| 792 |
+
backend: BackendKind::Pid,
|
| 793 |
+
auto_update_enabled,
|
| 794 |
+
remote_control_enabled: settings.remote_control_enabled,
|
| 795 |
+
managed_codex_path: managed.managed_codex_bin,
|
| 796 |
+
managed_codex_version,
|
| 797 |
+
socket_path: self.socket_path.clone(),
|
| 798 |
+
cli_version: env!("CARGO_PKG_VERSION").to_string(),
|
| 799 |
+
app_server_version: info.app_server_version,
|
| 800 |
+
})
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
async fn running_backend(&self, settings: &DaemonSettings) -> Result<Option<BackendKind>> {
|
| 804 |
+
Ok(self
|
| 805 |
+
.running_backend_instance(settings)
|
| 806 |
+
.await?
|
| 807 |
+
.map(|_| BackendKind::Pid))
|
| 808 |
+
}
|
| 809 |
+
|
| 810 |
+
async fn running_backend_instance(
|
| 811 |
+
&self,
|
| 812 |
+
settings: &DaemonSettings,
|
| 813 |
+
) -> Result<Option<backend::PidBackend>> {
|
| 814 |
+
let backend = backend::pid_backend(self.backend_paths(settings));
|
| 815 |
+
if backend.is_starting_or_running().await? {
|
| 816 |
+
return Ok(Some(backend));
|
| 817 |
+
}
|
| 818 |
+
Ok(None)
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
async fn start_managed_backend(&self, settings: &DaemonSettings) -> Result<Option<u32>> {
|
| 822 |
+
self.start_managed_backend_with_bin(settings, &self.managed_codex_bin)
|
| 823 |
+
.await
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
async fn start_managed_backend_with_bin(
|
| 827 |
+
&self,
|
| 828 |
+
settings: &DaemonSettings,
|
| 829 |
+
managed_codex_bin: &Path,
|
| 830 |
+
) -> Result<Option<u32>> {
|
| 831 |
+
let backend =
|
| 832 |
+
backend::pid_backend(self.backend_paths_with_bin(settings, managed_codex_bin));
|
| 833 |
+
backend.start().await
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
+
async fn ensure_managed_updater(&self, settings: &DaemonSettings) -> Result<bool> {
|
| 837 |
+
let updater = backend::pid_update_loop_backend(self.backend_paths(settings));
|
| 838 |
+
if !settings.auto_update_enabled {
|
| 839 |
+
updater.stop().await?;
|
| 840 |
+
return Ok(false);
|
| 841 |
+
}
|
| 842 |
+
if !self.is_stable_standalone_release()? {
|
| 843 |
+
// An installer publishes current and the latest marker separately.
|
| 844 |
+
// Keep its updater alive while that publication may be in progress.
|
| 845 |
+
if !self.has_latest_selection_marker() {
|
| 846 |
+
updater.stop().await?;
|
| 847 |
+
}
|
| 848 |
+
return Ok(false);
|
| 849 |
+
}
|
| 850 |
+
let Ok(codex_bin) =
|
| 851 |
+
managed_install::resolved_managed_codex_bin(&self.managed_codex_bin).await
|
| 852 |
+
else {
|
| 853 |
+
if !self.has_latest_selection_marker() {
|
| 854 |
+
updater.stop().await?;
|
| 855 |
+
}
|
| 856 |
+
return Ok(false);
|
| 857 |
+
};
|
| 858 |
+
if !managed_install::supports_daemon_update_loop(&codex_bin).await
|
| 859 |
+
|| !self.is_stable_standalone_release()?
|
| 860 |
+
|| !managed_install::resolved_managed_codex_bin(&self.managed_codex_bin)
|
| 861 |
+
.await
|
| 862 |
+
.is_ok_and(|selected| selected == codex_bin)
|
| 863 |
+
{
|
| 864 |
+
if !self.has_latest_selection_marker() {
|
| 865 |
+
updater.stop().await?;
|
| 866 |
+
}
|
| 867 |
+
return Ok(false);
|
| 868 |
+
}
|
| 869 |
+
backend::pid_update_loop_backend(self.backend_paths_with_bin(settings, &codex_bin))
|
| 870 |
+
.start()
|
| 871 |
+
.await?;
|
| 872 |
+
Ok(true)
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
fn is_stable_standalone_release(&self) -> Result<bool> {
|
| 876 |
+
let codex_home = self
|
| 877 |
+
.settings_file
|
| 878 |
+
.parent()
|
| 879 |
+
.and_then(Path::parent)
|
| 880 |
+
.context("daemon settings path has no Codex home")?;
|
| 881 |
+
Ok(managed_install::is_stable_standalone_release(
|
| 882 |
+
codex_home,
|
| 883 |
+
&self.current_managed_codex_bin()?,
|
| 884 |
+
))
|
| 885 |
+
}
|
| 886 |
+
|
| 887 |
+
fn current_managed_codex_bin(&self) -> Result<PathBuf> {
|
| 888 |
+
// An installer can move a legacy binary into bin/ while this updater runs.
|
| 889 |
+
let home = self
|
| 890 |
+
.settings_file
|
| 891 |
+
.parent()
|
| 892 |
+
.and_then(Path::parent)
|
| 893 |
+
.context("daemon settings path has no Codex home")?;
|
| 894 |
+
Ok(managed_install::managed_codex_bin(home))
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
fn has_latest_selection_marker(&self) -> bool {
|
| 898 |
+
self.settings_file
|
| 899 |
+
.parent()
|
| 900 |
+
.and_then(Path::parent)
|
| 901 |
+
.is_some_and(|home| {
|
| 902 |
+
managed_install::package_root(home)
|
| 903 |
+
.join("auto-update-version")
|
| 904 |
+
.is_file()
|
| 905 |
+
})
|
| 906 |
+
}
|
| 907 |
+
|
| 908 |
+
async fn is_bootstrapped(&self, settings: &DaemonSettings) -> Result<bool> {
|
| 909 |
+
if !settings.auto_update_enabled
|
| 910 |
+
|| !self.is_stable_standalone_release()?
|
| 911 |
+
|| !managed_install::supports_daemon_update_loop(&self.managed_codex_bin).await
|
| 912 |
+
{
|
| 913 |
+
return Ok(self.running_backend_instance(settings).await?.is_some());
|
| 914 |
+
}
|
| 915 |
+
let updater = backend::pid_update_loop_backend(self.backend_paths(settings));
|
| 916 |
+
updater.is_starting_or_running().await
|
| 917 |
+
}
|
| 918 |
+
|
| 919 |
+
fn ensure_managed_codex_bin(&self) -> Result<()> {
|
| 920 |
+
if self.managed_codex_bin.is_file() {
|
| 921 |
+
#[cfg(windows)]
|
| 922 |
+
backend::windows::ensure_detached_launch(&self.managed_codex_bin)?;
|
| 923 |
+
return Ok(());
|
| 924 |
+
}
|
| 925 |
+
|
| 926 |
+
let managed_codex_path = self.managed_codex_bin.display();
|
| 927 |
+
Err(anyhow!(
|
| 928 |
+
"daemon executable not found at {managed_codex_path}; repair the existing installation, or run `codex app-server daemon start` to install a missing daemon"
|
| 929 |
+
))
|
| 930 |
+
}
|
| 931 |
+
|
| 932 |
+
#[cfg(any(unix, windows))]
|
| 933 |
+
async fn managed_codex_version_best_effort(&self) -> Option<String> {
|
| 934 |
+
managed_codex_version(&self.managed_codex_bin).await.ok()
|
| 935 |
+
}
|
| 936 |
+
|
| 937 |
+
#[cfg(not(any(unix, windows)))]
|
| 938 |
+
async fn managed_codex_version_best_effort(&self) -> Option<String> {
|
| 939 |
+
None
|
| 940 |
+
}
|
| 941 |
+
|
| 942 |
+
fn backend_paths(&self, settings: &DaemonSettings) -> BackendPaths {
|
| 943 |
+
self.backend_paths_with_bin(settings, &self.managed_codex_bin)
|
| 944 |
+
}
|
| 945 |
+
|
| 946 |
+
fn backend_paths_with_bin(
|
| 947 |
+
&self,
|
| 948 |
+
settings: &DaemonSettings,
|
| 949 |
+
managed_codex_bin: &Path,
|
| 950 |
+
) -> BackendPaths {
|
| 951 |
+
BackendPaths {
|
| 952 |
+
codex_bin: managed_codex_bin.to_path_buf(),
|
| 953 |
+
pid_file: self.pid_file.clone(),
|
| 954 |
+
update_pid_file: self.update_pid_file.clone(),
|
| 955 |
+
remote_control_enabled: settings.remote_control_enabled,
|
| 956 |
+
}
|
| 957 |
+
}
|
| 958 |
+
|
| 959 |
+
fn manual_update_socket_path(&self) -> PathBuf {
|
| 960 |
+
self.update_pid_file.with_extension("sock")
|
| 961 |
+
}
|
| 962 |
+
|
| 963 |
+
async fn load_settings(&self) -> Result<DaemonSettings> {
|
| 964 |
+
DaemonSettings::load(&self.settings_file).await
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
+
async fn acquire_operation_lock(&self) -> Result<tokio::fs::File> {
|
| 968 |
+
let operation_lock = self.open_operation_lock_file().await?;
|
| 969 |
+
let deadline = tokio::time::Instant::now() + OPERATION_LOCK_TIMEOUT;
|
| 970 |
+
while !try_lock_file(&operation_lock)? {
|
| 971 |
+
if tokio::time::Instant::now() >= deadline {
|
| 972 |
+
return Err(anyhow!(
|
| 973 |
+
"timed out waiting for daemon operation lock {}",
|
| 974 |
+
self.operation_lock_file.display()
|
| 975 |
+
));
|
| 976 |
+
}
|
| 977 |
+
sleep(START_POLL_INTERVAL).await;
|
| 978 |
+
}
|
| 979 |
+
Ok(operation_lock)
|
| 980 |
+
}
|
| 981 |
+
|
| 982 |
+
async fn open_operation_lock_file(&self) -> Result<tokio::fs::File> {
|
| 983 |
+
if let Some(parent) = self.operation_lock_file.parent() {
|
| 984 |
+
#[cfg(unix)]
|
| 985 |
+
if let Some(home) = parent.parent() {
|
| 986 |
+
tokio::fs::create_dir_all(home).await?;
|
| 987 |
+
}
|
| 988 |
+
codex_uds::prepare_private_socket_directory(parent)
|
| 989 |
+
.await
|
| 990 |
+
.with_context(|| {
|
| 991 |
+
format!(
|
| 992 |
+
"failed to create daemon state directory {}",
|
| 993 |
+
parent.display()
|
| 994 |
+
)
|
| 995 |
+
})?;
|
| 996 |
+
}
|
| 997 |
+
tokio::fs::OpenOptions::new()
|
| 998 |
+
.create(true)
|
| 999 |
+
.truncate(false)
|
| 1000 |
+
.write(true)
|
| 1001 |
+
.open(&self.operation_lock_file)
|
| 1002 |
+
.await
|
| 1003 |
+
.with_context(|| {
|
| 1004 |
+
format!(
|
| 1005 |
+
"failed to open daemon operation lock {}",
|
| 1006 |
+
self.operation_lock_file.display()
|
| 1007 |
+
)
|
| 1008 |
+
})
|
| 1009 |
+
}
|
| 1010 |
+
|
| 1011 |
+
async fn output(
|
| 1012 |
+
&self,
|
| 1013 |
+
status: LifecycleStatus,
|
| 1014 |
+
backend: Option<BackendKind>,
|
| 1015 |
+
pid: Option<u32>,
|
| 1016 |
+
app_server_version: Option<String>,
|
| 1017 |
+
) -> LifecycleOutput {
|
| 1018 |
+
let managed_codex_version = self.managed_codex_version_best_effort().await;
|
| 1019 |
+
LifecycleOutput {
|
| 1020 |
+
status,
|
| 1021 |
+
backend,
|
| 1022 |
+
pid,
|
| 1023 |
+
managed_codex_path: self.managed_codex_bin.clone(),
|
| 1024 |
+
managed_codex_version,
|
| 1025 |
+
socket_path: self.socket_path.clone(),
|
| 1026 |
+
cli_version: Some(env!("CARGO_PKG_VERSION").to_string()),
|
| 1027 |
+
app_server_version,
|
| 1028 |
+
}
|
| 1029 |
+
}
|
| 1030 |
+
|
| 1031 |
+
fn remote_control_output(
|
| 1032 |
+
&self,
|
| 1033 |
+
status: RemoteControlStatus,
|
| 1034 |
+
backend: Option<BackendKind>,
|
| 1035 |
+
remote_control_enabled: bool,
|
| 1036 |
+
app_server_version: Option<String>,
|
| 1037 |
+
) -> RemoteControlOutput {
|
| 1038 |
+
RemoteControlOutput {
|
| 1039 |
+
status,
|
| 1040 |
+
backend,
|
| 1041 |
+
remote_control_enabled,
|
| 1042 |
+
socket_path: self.socket_path.clone(),
|
| 1043 |
+
cli_version: env!("CARGO_PKG_VERSION").to_string(),
|
| 1044 |
+
app_server_version,
|
| 1045 |
+
}
|
| 1046 |
+
}
|
| 1047 |
+
}
|
| 1048 |
+
|
| 1049 |
+
fn remote_control_status(mode: RemoteControlMode) -> RemoteControlStatus {
|
| 1050 |
+
match mode {
|
| 1051 |
+
RemoteControlMode::Enabled => RemoteControlStatus::Enabled,
|
| 1052 |
+
RemoteControlMode::Disabled => RemoteControlStatus::Disabled,
|
| 1053 |
+
}
|
| 1054 |
+
}
|
| 1055 |
+
|
| 1056 |
+
fn already_remote_control_status(mode: RemoteControlMode) -> RemoteControlStatus {
|
| 1057 |
+
match mode {
|
| 1058 |
+
RemoteControlMode::Enabled => RemoteControlStatus::AlreadyEnabled,
|
| 1059 |
+
RemoteControlMode::Disabled => RemoteControlStatus::AlreadyDisabled,
|
| 1060 |
+
}
|
| 1061 |
+
}
|
| 1062 |
+
|
| 1063 |
+
#[cfg(any(unix, windows))]
|
| 1064 |
+
fn restart_decision(
|
| 1065 |
+
mode: RestartMode,
|
| 1066 |
+
info: Option<&client::ProbeInfo>,
|
| 1067 |
+
managed_version: Option<&str>,
|
| 1068 |
+
) -> RestartDecision {
|
| 1069 |
+
match (mode, info, managed_version) {
|
| 1070 |
+
(RestartMode::IfBinaryOrVersionChanged, _, _) => {
|
| 1071 |
+
unreachable!("binary comparison is resolved before restart decision")
|
| 1072 |
+
}
|
| 1073 |
+
(RestartMode::IfVersionChanged, None, _) => RestartDecision::NotReady,
|
| 1074 |
+
(RestartMode::IfVersionChanged, Some(info), Some(managed_version))
|
| 1075 |
+
if info.app_server_version == managed_version =>
|
| 1076 |
+
{
|
| 1077 |
+
RestartDecision::AlreadyCurrent
|
| 1078 |
+
}
|
| 1079 |
+
_ => RestartDecision::Restart,
|
| 1080 |
+
}
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
+
#[cfg(unix)]
|
| 1084 |
+
fn try_lock_file(file: &tokio::fs::File) -> Result<bool> {
|
| 1085 |
+
use std::os::fd::AsRawFd;
|
| 1086 |
+
|
| 1087 |
+
let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
| 1088 |
+
if result == 0 {
|
| 1089 |
+
return Ok(true);
|
| 1090 |
+
}
|
| 1091 |
+
|
| 1092 |
+
let err = std::io::Error::last_os_error();
|
| 1093 |
+
if err.raw_os_error() == Some(libc::EWOULDBLOCK) {
|
| 1094 |
+
return Ok(false);
|
| 1095 |
+
}
|
| 1096 |
+
Err(err).context("failed to lock daemon operation")
|
| 1097 |
+
}
|
| 1098 |
+
|
| 1099 |
+
#[cfg(not(any(unix, windows)))]
|
| 1100 |
+
fn try_lock_file(_file: &tokio::fs::File) -> Result<bool> {
|
| 1101 |
+
Ok(true)
|
| 1102 |
+
}
|
| 1103 |
+
|
| 1104 |
+
#[cfg(all(test, any(unix, windows)))]
|
| 1105 |
+
mod tests {
|
| 1106 |
+
use pretty_assertions::assert_eq;
|
| 1107 |
+
use tempfile::TempDir;
|
| 1108 |
+
|
| 1109 |
+
use super::BackendKind;
|
| 1110 |
+
use super::BootstrapOutput;
|
| 1111 |
+
use super::BootstrapStatus;
|
| 1112 |
+
use super::Daemon;
|
| 1113 |
+
use super::LifecycleOutput;
|
| 1114 |
+
use super::LifecycleStatus;
|
| 1115 |
+
use super::RemoteControlStartOutput;
|
| 1116 |
+
use super::RemoteControlStatus;
|
| 1117 |
+
use super::RestartDecision;
|
| 1118 |
+
use super::RestartMode;
|
| 1119 |
+
use super::restart_decision;
|
| 1120 |
+
use crate::client::ProbeInfo;
|
| 1121 |
+
#[cfg(unix)]
|
| 1122 |
+
use crate::settings::DaemonSettings;
|
| 1123 |
+
|
| 1124 |
+
#[test]
|
| 1125 |
+
fn remote_control_status_uses_camel_case_json() {
|
| 1126 |
+
assert_eq!(
|
| 1127 |
+
serde_json::to_string(&RemoteControlStatus::AlreadyEnabled).expect("serialize"),
|
| 1128 |
+
"\"alreadyEnabled\""
|
| 1129 |
+
);
|
| 1130 |
+
}
|
| 1131 |
+
|
| 1132 |
+
#[test]
|
| 1133 |
+
fn restart_decision_preserves_forced_refreshes() {
|
| 1134 |
+
let current_info = ProbeInfo {
|
| 1135 |
+
app_server_version: "0.1.0".to_string(),
|
| 1136 |
+
};
|
| 1137 |
+
|
| 1138 |
+
assert_eq!(
|
| 1139 |
+
[
|
| 1140 |
+
restart_decision(
|
| 1141 |
+
RestartMode::IfVersionChanged,
|
| 1142 |
+
Some(¤t_info),
|
| 1143 |
+
Some("0.1.0"),
|
| 1144 |
+
),
|
| 1145 |
+
restart_decision(
|
| 1146 |
+
RestartMode::IfVersionChanged,
|
| 1147 |
+
/*info*/ None,
|
| 1148 |
+
/*managed_version*/ None,
|
| 1149 |
+
),
|
| 1150 |
+
restart_decision(RestartMode::Always, Some(¤t_info), Some("0.1.0")),
|
| 1151 |
+
restart_decision(
|
| 1152 |
+
RestartMode::Always,
|
| 1153 |
+
/*info*/ None,
|
| 1154 |
+
/*managed_version*/ None,
|
| 1155 |
+
),
|
| 1156 |
+
],
|
| 1157 |
+
[
|
| 1158 |
+
RestartDecision::AlreadyCurrent,
|
| 1159 |
+
RestartDecision::NotReady,
|
| 1160 |
+
RestartDecision::Restart,
|
| 1161 |
+
RestartDecision::Restart,
|
| 1162 |
+
]
|
| 1163 |
+
);
|
| 1164 |
+
}
|
| 1165 |
+
|
| 1166 |
+
#[test]
|
| 1167 |
+
fn remote_control_start_output_serializes_inner_output_without_tag() {
|
| 1168 |
+
let lifecycle_output = LifecycleOutput {
|
| 1169 |
+
status: LifecycleStatus::AlreadyRunning,
|
| 1170 |
+
backend: Some(BackendKind::Pid),
|
| 1171 |
+
pid: None,
|
| 1172 |
+
managed_codex_path: "codex".into(),
|
| 1173 |
+
managed_codex_version: Some("1.2.3".to_string()),
|
| 1174 |
+
socket_path: "codex.sock".into(),
|
| 1175 |
+
cli_version: Some("1.2.3".to_string()),
|
| 1176 |
+
app_server_version: Some("1.2.4".to_string()),
|
| 1177 |
+
};
|
| 1178 |
+
let output = RemoteControlStartOutput::Start(lifecycle_output.clone());
|
| 1179 |
+
|
| 1180 |
+
assert_eq!(
|
| 1181 |
+
serde_json::to_value(&lifecycle_output).expect("serialize"),
|
| 1182 |
+
serde_json::json!({
|
| 1183 |
+
"status": "alreadyRunning",
|
| 1184 |
+
"backend": "pid",
|
| 1185 |
+
"managedCodexPath": "codex",
|
| 1186 |
+
"managedCodexVersion": "1.2.3",
|
| 1187 |
+
"socketPath": "codex.sock",
|
| 1188 |
+
"cliVersion": "1.2.3",
|
| 1189 |
+
"appServerVersion": "1.2.4",
|
| 1190 |
+
})
|
| 1191 |
+
);
|
| 1192 |
+
assert_eq!(
|
| 1193 |
+
serde_json::to_value(output).expect("serialize"),
|
| 1194 |
+
serde_json::to_value(lifecycle_output).expect("serialize")
|
| 1195 |
+
);
|
| 1196 |
+
|
| 1197 |
+
let bootstrap_output = BootstrapOutput {
|
| 1198 |
+
status: BootstrapStatus::Bootstrapped,
|
| 1199 |
+
backend: BackendKind::Pid,
|
| 1200 |
+
auto_update_enabled: true,
|
| 1201 |
+
remote_control_enabled: true,
|
| 1202 |
+
managed_codex_path: "codex".into(),
|
| 1203 |
+
managed_codex_version: Some("1.2.3".to_string()),
|
| 1204 |
+
socket_path: "codex.sock".into(),
|
| 1205 |
+
cli_version: "1.2.3".to_string(),
|
| 1206 |
+
app_server_version: "1.2.4".to_string(),
|
| 1207 |
+
};
|
| 1208 |
+
let output = RemoteControlStartOutput::Bootstrap(bootstrap_output.clone());
|
| 1209 |
+
|
| 1210 |
+
assert_eq!(
|
| 1211 |
+
serde_json::to_value(&bootstrap_output).expect("serialize"),
|
| 1212 |
+
serde_json::json!({
|
| 1213 |
+
"status": "bootstrapped",
|
| 1214 |
+
"backend": "pid",
|
| 1215 |
+
"autoUpdateEnabled": true,
|
| 1216 |
+
"remoteControlEnabled": true,
|
| 1217 |
+
"managedCodexPath": "codex",
|
| 1218 |
+
"managedCodexVersion": "1.2.3",
|
| 1219 |
+
"socketPath": "codex.sock",
|
| 1220 |
+
"cliVersion": "1.2.3",
|
| 1221 |
+
"appServerVersion": "1.2.4",
|
| 1222 |
+
})
|
| 1223 |
+
);
|
| 1224 |
+
assert_eq!(
|
| 1225 |
+
serde_json::to_value(output).expect("serialize"),
|
| 1226 |
+
serde_json::to_value(bootstrap_output).expect("serialize")
|
| 1227 |
+
);
|
| 1228 |
+
}
|
| 1229 |
+
|
| 1230 |
+
#[tokio::test]
|
| 1231 |
+
async fn waiting_lifecycle_command_uses_migrated_installation() {
|
| 1232 |
+
let home = TempDir::new().expect("home");
|
| 1233 |
+
let state = home.path().join("app-server-daemon");
|
| 1234 |
+
let legacy = home.path().join("packages/standalone/current");
|
| 1235 |
+
std::fs::create_dir_all(&legacy).expect("legacy selection");
|
| 1236 |
+
let daemon = Daemon {
|
| 1237 |
+
socket_path: home.path().join("server.sock"),
|
| 1238 |
+
pid_file: state.join(super::LEGACY_PID_FILE_NAME),
|
| 1239 |
+
update_pid_file: state.join(super::LEGACY_UPDATE_PID_FILE_NAME),
|
| 1240 |
+
operation_lock_file: state.join("daemon.lock"),
|
| 1241 |
+
settings_file: state.join("settings.json"),
|
| 1242 |
+
managed_codex_bin: super::managed_codex_bin(home.path()),
|
| 1243 |
+
};
|
| 1244 |
+
let lock = daemon.acquire_operation_lock().await.expect("lock");
|
| 1245 |
+
let stop = daemon.run(super::LifecycleCommand::Stop);
|
| 1246 |
+
tokio::pin!(stop);
|
| 1247 |
+
assert!(
|
| 1248 |
+
tokio::time::timeout(std::time::Duration::from_millis(20), &mut stop)
|
| 1249 |
+
.await
|
| 1250 |
+
.is_err()
|
| 1251 |
+
);
|
| 1252 |
+
std::fs::create_dir_all(home.path().join("packages/app-server-daemon/current"))
|
| 1253 |
+
.expect("migrate selection");
|
| 1254 |
+
drop(lock);
|
| 1255 |
+
let output = stop.await.expect("stop");
|
| 1256 |
+
assert_eq!(
|
| 1257 |
+
(output.status, output.managed_codex_path),
|
| 1258 |
+
(
|
| 1259 |
+
LifecycleStatus::NotRunning,
|
| 1260 |
+
super::managed_codex_bin(home.path())
|
| 1261 |
+
)
|
| 1262 |
+
);
|
| 1263 |
+
}
|
| 1264 |
+
|
| 1265 |
+
#[tokio::test]
|
| 1266 |
+
async fn stop_creates_missing_home_parent() {
|
| 1267 |
+
let temp = TempDir::new().expect("temp dir");
|
| 1268 |
+
let state = temp.path().join("missing-home").join("daemon-state");
|
| 1269 |
+
let daemon = Daemon {
|
| 1270 |
+
socket_path: state.join("server.sock"),
|
| 1271 |
+
pid_file: state.join("server.pid"),
|
| 1272 |
+
update_pid_file: state.join("updater.pid"),
|
| 1273 |
+
operation_lock_file: state.join("daemon.lock"),
|
| 1274 |
+
settings_file: state.join("settings.json"),
|
| 1275 |
+
managed_codex_bin: state.join("missing-codex"),
|
| 1276 |
+
};
|
| 1277 |
+
assert_eq!(
|
| 1278 |
+
daemon
|
| 1279 |
+
.run(super::LifecycleCommand::Stop)
|
| 1280 |
+
.await
|
| 1281 |
+
.expect("stop on fresh home")
|
| 1282 |
+
.status,
|
| 1283 |
+
LifecycleStatus::NotRunning,
|
| 1284 |
+
);
|
| 1285 |
+
}
|
| 1286 |
+
|
| 1287 |
+
#[tokio::test]
|
| 1288 |
+
async fn stop_and_fresh_start_discard_pending_thread_restore() {
|
| 1289 |
+
let home = TempDir::new().expect("home");
|
| 1290 |
+
let state = home.path().join("app-server-daemon");
|
| 1291 |
+
codex_uds::prepare_private_socket_directory(&state)
|
| 1292 |
+
.await
|
| 1293 |
+
.expect("private state directory");
|
| 1294 |
+
let daemon = Daemon {
|
| 1295 |
+
socket_path: home.path().join("server.sock"),
|
| 1296 |
+
pid_file: state.join("server.pid"),
|
| 1297 |
+
update_pid_file: state.join("updater.pid"),
|
| 1298 |
+
operation_lock_file: state.join("daemon.lock"),
|
| 1299 |
+
settings_file: state.join("settings.json"),
|
| 1300 |
+
managed_codex_bin: home.path().join("codex"),
|
| 1301 |
+
};
|
| 1302 |
+
codex_app_server_transport::daemon_recovery::write_candidates(
|
| 1303 |
+
&daemon.recovery_file().expect("recovery path"),
|
| 1304 |
+
&["thread".to_string()].into_iter().collect(),
|
| 1305 |
+
)
|
| 1306 |
+
.expect("saved threads");
|
| 1307 |
+
assert_eq!(
|
| 1308 |
+
daemon
|
| 1309 |
+
.run(super::LifecycleCommand::Stop)
|
| 1310 |
+
.await
|
| 1311 |
+
.expect("stop")
|
| 1312 |
+
.status,
|
| 1313 |
+
LifecycleStatus::NotRunning
|
| 1314 |
+
);
|
| 1315 |
+
assert!(!daemon.recovery_file().expect("recovery path").exists());
|
| 1316 |
+
// Simulate an older stop client leaving a snapshot behind.
|
| 1317 |
+
std::fs::write(
|
| 1318 |
+
daemon.recovery_file().expect("recovery path"),
|
| 1319 |
+
r#"["thread"]"#,
|
| 1320 |
+
)
|
| 1321 |
+
.expect("legacy saved threads");
|
| 1322 |
+
daemon
|
| 1323 |
+
.run(super::LifecycleCommand::Start)
|
| 1324 |
+
.await
|
| 1325 |
+
.expect_err("missing backend binary");
|
| 1326 |
+
assert!(!daemon.recovery_file().expect("recovery path").exists());
|
| 1327 |
+
}
|
| 1328 |
+
|
| 1329 |
+
#[cfg(unix)]
|
| 1330 |
+
#[tokio::test]
|
| 1331 |
+
async fn managed_local_backend_counts_as_bootstrapped_without_updater() {
|
| 1332 |
+
use std::os::unix::fs::PermissionsExt;
|
| 1333 |
+
|
| 1334 |
+
let home = TempDir::new().expect("home");
|
| 1335 |
+
let standalone = home.path().join("packages/standalone");
|
| 1336 |
+
let local_bin = standalone.join("local-main/bin/codex");
|
| 1337 |
+
tokio::fs::create_dir_all(local_bin.parent().expect("bin parent"))
|
| 1338 |
+
.await
|
| 1339 |
+
.expect("local bin directory");
|
| 1340 |
+
tokio::fs::write(&local_bin, b"#!/bin/sh\nexec sleep 30\n")
|
| 1341 |
+
.await
|
| 1342 |
+
.expect("local bin");
|
| 1343 |
+
std::fs::set_permissions(&local_bin, std::fs::Permissions::from_mode(0o755))
|
| 1344 |
+
.expect("executable local bin");
|
| 1345 |
+
std::os::unix::fs::symlink("local-main", standalone.join("current"))
|
| 1346 |
+
.expect("current local build");
|
| 1347 |
+
let state = home.path().join("app-server-daemon");
|
| 1348 |
+
let daemon = Daemon {
|
| 1349 |
+
socket_path: home
|
| 1350 |
+
.path()
|
| 1351 |
+
.join("app-server-control/app-server-control.sock"),
|
| 1352 |
+
pid_file: state.join("app-server.pid"),
|
| 1353 |
+
update_pid_file: state.join("app-server-updater.pid"),
|
| 1354 |
+
operation_lock_file: state.join("daemon.lock"),
|
| 1355 |
+
settings_file: state.join("settings.json"),
|
| 1356 |
+
managed_codex_bin: standalone.join("current/bin/codex"),
|
| 1357 |
+
};
|
| 1358 |
+
let settings = DaemonSettings::default();
|
| 1359 |
+
assert!(
|
| 1360 |
+
!daemon
|
| 1361 |
+
.is_bootstrapped(&settings)
|
| 1362 |
+
.await
|
| 1363 |
+
.expect("not running")
|
| 1364 |
+
);
|
| 1365 |
+
let backend = crate::backend::pid_backend(daemon.backend_paths(&settings));
|
| 1366 |
+
backend.start().await.expect("start local backend");
|
| 1367 |
+
let bootstrapped = daemon.is_bootstrapped(&settings).await;
|
| 1368 |
+
backend.stop().await.expect("stop local backend");
|
| 1369 |
+
assert!(bootstrapped.expect("managed local backend"));
|
| 1370 |
+
}
|
| 1371 |
+
|
| 1372 |
+
#[tokio::test]
|
| 1373 |
+
async fn not_ready_context_reports_daemon_app_server_before_stderr() {
|
| 1374 |
+
let temp_dir = TempDir::new().expect("temp dir");
|
| 1375 |
+
let daemon = Daemon {
|
| 1376 |
+
socket_path: temp_dir.path().join("app-server-control.sock"),
|
| 1377 |
+
pid_file: temp_dir.path().join("app-server.pid"),
|
| 1378 |
+
update_pid_file: temp_dir.path().join("app-server-updater.pid"),
|
| 1379 |
+
operation_lock_file: temp_dir.path().join("daemon.lock"),
|
| 1380 |
+
settings_file: temp_dir.path().join("settings.json"),
|
| 1381 |
+
managed_codex_bin: temp_dir.path().join("missing-codex"),
|
| 1382 |
+
};
|
| 1383 |
+
let stderr_log = daemon.pid_file.with_extension("stderr.log");
|
| 1384 |
+
tokio::fs::write(&stderr_log, "unexpected argument")
|
| 1385 |
+
.await
|
| 1386 |
+
.expect("write stderr log");
|
| 1387 |
+
|
| 1388 |
+
assert_eq!(
|
| 1389 |
+
daemon.app_server_not_ready_context().await,
|
| 1390 |
+
format!(
|
| 1391 |
+
"app server did not become ready on {}\n\n\
|
| 1392 |
+
Daemon used app-server:\n path: {}\n version: unknown\n\n\
|
| 1393 |
+
Managed app-server stderr ({}):\n unexpected argument",
|
| 1394 |
+
daemon.socket_path.display(),
|
| 1395 |
+
daemon.managed_codex_bin.display(),
|
| 1396 |
+
stderr_log.display()
|
| 1397 |
+
)
|
| 1398 |
+
);
|
| 1399 |
+
}
|
| 1400 |
+
}
|
codex-rs/app-server-daemon/src/managed_install.rs
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Resolves both package and legacy standalone layouts and compares installed executables.
|
| 2 |
+
|
| 3 |
+
use std::path::Path;
|
| 4 |
+
use std::path::PathBuf;
|
| 5 |
+
use std::process::Stdio;
|
| 6 |
+
use std::time::Duration;
|
| 7 |
+
|
| 8 |
+
use anyhow::Context;
|
| 9 |
+
use anyhow::Result;
|
| 10 |
+
use anyhow::anyhow;
|
| 11 |
+
use serde::Deserialize;
|
| 12 |
+
use serde::Serialize;
|
| 13 |
+
use tokio::fs;
|
| 14 |
+
use tokio::process::Command;
|
| 15 |
+
use tokio::time::timeout;
|
| 16 |
+
|
| 17 |
+
/// New daemons own their packages, regardless of how the calling CLI was installed.
|
| 18 |
+
/// Preserve legacy launch state, including logs left after a daemon is stopped;
|
| 19 |
+
/// settings, installer selections, and lock files alone do not prove a prior launch.
|
| 20 |
+
pub(crate) fn package_root(codex_home: &Path) -> PathBuf {
|
| 21 |
+
let dedicated = codex_home.join("packages/app-server-daemon");
|
| 22 |
+
if !matches!(dedicated.join("current").symlink_metadata(),
|
| 23 |
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound)
|
| 24 |
+
{
|
| 25 |
+
return dedicated;
|
| 26 |
+
}
|
| 27 |
+
let state = codex_home.join("app-server-daemon");
|
| 28 |
+
for (package, artifacts) in [
|
| 29 |
+
(
|
| 30 |
+
"app-server-daemon",
|
| 31 |
+
[
|
| 32 |
+
crate::DAEMON_PID_FILE_NAME,
|
| 33 |
+
"daemon.stderr.log",
|
| 34 |
+
crate::DAEMON_UPDATE_PID_FILE_NAME,
|
| 35 |
+
"daemon-updater.stderr.log",
|
| 36 |
+
],
|
| 37 |
+
),
|
| 38 |
+
(
|
| 39 |
+
"standalone",
|
| 40 |
+
[
|
| 41 |
+
crate::LEGACY_PID_FILE_NAME,
|
| 42 |
+
"app-server.stderr.log",
|
| 43 |
+
crate::LEGACY_UPDATE_PID_FILE_NAME,
|
| 44 |
+
"app-server-updater.stderr.log",
|
| 45 |
+
],
|
| 46 |
+
),
|
| 47 |
+
] {
|
| 48 |
+
if artifacts.iter().any(|name| {
|
| 49 |
+
!matches!(state.join(name).symlink_metadata(),
|
| 50 |
+
Err(error) if error.kind() == std::io::ErrorKind::NotFound)
|
| 51 |
+
}) {
|
| 52 |
+
return codex_home.join("packages").join(package);
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
dedicated
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/// Resolve both packaged and legacy binaries without requiring a valid install.
|
| 59 |
+
pub(crate) fn managed_codex_bin(codex_home: &Path) -> PathBuf {
|
| 60 |
+
let root = package_root(codex_home);
|
| 61 |
+
let current = root.join("current");
|
| 62 |
+
let packaged = current.join("bin").join(managed_codex_file_name());
|
| 63 |
+
let legacy = current.join(managed_codex_file_name());
|
| 64 |
+
if packaged.is_file()
|
| 65 |
+
|| !legacy.is_file() && (cfg!(windows) || root.ends_with("app-server-daemon"))
|
| 66 |
+
{
|
| 67 |
+
packaged
|
| 68 |
+
} else {
|
| 69 |
+
legacy
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
/// Only latest-channel stable releases may run the public latest-version updater.
|
| 74 |
+
pub(crate) fn is_stable_standalone_release(codex_home: &Path, codex_bin: &Path) -> bool {
|
| 75 |
+
let standalone = package_root(codex_home);
|
| 76 |
+
let Ok(releases) = std::fs::canonicalize(standalone.join("releases")) else {
|
| 77 |
+
return false;
|
| 78 |
+
};
|
| 79 |
+
let Ok(release) = std::fs::canonicalize(standalone.join("current")) else {
|
| 80 |
+
return false;
|
| 81 |
+
};
|
| 82 |
+
if release.parent() != Some(releases.as_path()) {
|
| 83 |
+
return false;
|
| 84 |
+
}
|
| 85 |
+
let Some(release_name) = release.file_name().and_then(|name| name.to_str()) else {
|
| 86 |
+
return false;
|
| 87 |
+
};
|
| 88 |
+
// GNU packages can seed the new directory; retain legacy updater eligibility.
|
| 89 |
+
if standalone.ends_with("standalone") && release_name.ends_with("-gnu") {
|
| 90 |
+
return false;
|
| 91 |
+
}
|
| 92 |
+
let targets = [
|
| 93 |
+
"aarch64-apple-darwin",
|
| 94 |
+
"x86_64-apple-darwin",
|
| 95 |
+
"aarch64-unknown-linux-gnu",
|
| 96 |
+
"x86_64-unknown-linux-gnu",
|
| 97 |
+
"aarch64-unknown-linux-musl",
|
| 98 |
+
"x86_64-unknown-linux-musl",
|
| 99 |
+
"aarch64-pc-windows-msvc",
|
| 100 |
+
"x86_64-pc-windows-msvc",
|
| 101 |
+
];
|
| 102 |
+
let Some(version) = targets
|
| 103 |
+
.iter()
|
| 104 |
+
.find_map(|target| release_name.strip_suffix(&format!("-{target}")))
|
| 105 |
+
else {
|
| 106 |
+
return false;
|
| 107 |
+
};
|
| 108 |
+
let components: Vec<_> = version.split('.').collect();
|
| 109 |
+
components.len() == 3
|
| 110 |
+
&& components.iter().all(|component| {
|
| 111 |
+
!component.is_empty() && component.bytes().all(|byte| byte.is_ascii_digit())
|
| 112 |
+
})
|
| 113 |
+
&& std::fs::read_to_string(standalone.join("auto-update-version"))
|
| 114 |
+
.is_ok_and(|selected| selected == release_name)
|
| 115 |
+
&& std::fs::canonicalize(codex_bin).is_ok_and(|bin| bin.starts_with(&release))
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/// Older managed binaries can serve app-server requests without owning an updater.
|
| 119 |
+
pub(crate) async fn supports_daemon_update_loop(codex_bin: &Path) -> bool {
|
| 120 |
+
supports_daemon_command(codex_bin, &["pid-update-loop", "--help"]).await
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/// Probe an internal daemon command without running a long-lived process.
|
| 124 |
+
pub(crate) async fn supports_daemon_command(codex_bin: &Path, args: &[&str]) -> bool {
|
| 125 |
+
let mut command = Command::new(codex_bin);
|
| 126 |
+
#[cfg(windows)]
|
| 127 |
+
command.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
|
| 128 |
+
timeout(
|
| 129 |
+
Duration::from_secs(5),
|
| 130 |
+
command
|
| 131 |
+
.args(["app-server", "daemon"])
|
| 132 |
+
.args(args)
|
| 133 |
+
.stdin(Stdio::null())
|
| 134 |
+
.stdout(Stdio::null())
|
| 135 |
+
.stderr(Stdio::null())
|
| 136 |
+
.kill_on_drop(true)
|
| 137 |
+
.status(),
|
| 138 |
+
)
|
| 139 |
+
.await
|
| 140 |
+
.is_ok_and(|result| result.is_ok_and(|status| status.success()))
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
pub(crate) async fn resolved_managed_codex_bin(codex_bin: &Path) -> Result<PathBuf> {
|
| 144 |
+
fs::canonicalize(codex_bin).await.with_context(|| {
|
| 145 |
+
format!(
|
| 146 |
+
"failed to resolve managed Codex binary {}",
|
| 147 |
+
codex_bin.display()
|
| 148 |
+
)
|
| 149 |
+
})
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
pub(crate) async fn managed_codex_version(codex_bin: &Path) -> Result<String> {
|
| 153 |
+
let mut command = Command::new(codex_bin);
|
| 154 |
+
#[cfg(windows)]
|
| 155 |
+
command.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
|
| 156 |
+
let output = command
|
| 157 |
+
.arg("--version")
|
| 158 |
+
.kill_on_drop(true)
|
| 159 |
+
.output()
|
| 160 |
+
.await
|
| 161 |
+
.with_context(|| {
|
| 162 |
+
format!(
|
| 163 |
+
"failed to invoke managed Codex binary {}",
|
| 164 |
+
codex_bin.display()
|
| 165 |
+
)
|
| 166 |
+
})?;
|
| 167 |
+
if !output.status.success() {
|
| 168 |
+
return Err(anyhow!(
|
| 169 |
+
"managed Codex binary {} exited with status {}",
|
| 170 |
+
codex_bin.display(),
|
| 171 |
+
output.status
|
| 172 |
+
));
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
let stdout = String::from_utf8(output.stdout).with_context(|| {
|
| 176 |
+
format!(
|
| 177 |
+
"managed Codex version was not utf-8: {}",
|
| 178 |
+
codex_bin.display()
|
| 179 |
+
)
|
| 180 |
+
})?;
|
| 181 |
+
parse_codex_version(&stdout)
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
| 185 |
+
pub(crate) struct ExecutableIdentity {
|
| 186 |
+
digest: [u8; 32],
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
pub(crate) async fn executable_identity(executable: &Path) -> Result<ExecutableIdentity> {
|
| 190 |
+
let bytes = fs::read(executable)
|
| 191 |
+
.await
|
| 192 |
+
.with_context(|| format!("failed to read executable {}", executable.display()))?;
|
| 193 |
+
Ok(executable_identity_from_bytes(&bytes))
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
pub(crate) fn executable_identity_from_bytes(bytes: &[u8]) -> ExecutableIdentity {
|
| 197 |
+
ExecutableIdentity {
|
| 198 |
+
digest: *blake3::hash(bytes).as_bytes(),
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
fn managed_codex_file_name() -> &'static str {
|
| 203 |
+
if cfg!(windows) { "codex.exe" } else { "codex" }
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
fn parse_codex_version(output: &str) -> Result<String> {
|
| 207 |
+
let version = output
|
| 208 |
+
.split_whitespace()
|
| 209 |
+
.nth(1)
|
| 210 |
+
.filter(|version| !version.is_empty())
|
| 211 |
+
.ok_or_else(|| anyhow!("managed Codex version output was malformed"))?;
|
| 212 |
+
Ok(version.to_string())
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
#[cfg(test)]
|
| 216 |
+
#[path = "managed_install_tests.rs"]
|
| 217 |
+
mod tests;
|
| 218 |
+
|
| 219 |
+
#[cfg(test)]
|
| 220 |
+
#[path = "managed_install_path_tests.rs"]
|
| 221 |
+
mod path_tests;
|
codex-rs/app-server-daemon/src/managed_install_path_tests.rs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use pretty_assertions::assert_eq;
|
| 2 |
+
|
| 3 |
+
#[test]
|
| 4 |
+
fn discovers_package_and_legacy_installs() {
|
| 5 |
+
let home = tempfile::TempDir::new().expect("home");
|
| 6 |
+
let current = home.path().join("packages/standalone/current");
|
| 7 |
+
let legacy = current.join(super::managed_codex_file_name());
|
| 8 |
+
assert_eq!(
|
| 9 |
+
super::managed_codex_bin(home.path()),
|
| 10 |
+
home.path()
|
| 11 |
+
.join("packages/app-server-daemon/current/bin")
|
| 12 |
+
.join(super::managed_codex_file_name())
|
| 13 |
+
);
|
| 14 |
+
std::fs::create_dir_all(¤t).expect("current directory");
|
| 15 |
+
std::fs::write(&legacy, b"legacy").expect("legacy executable");
|
| 16 |
+
let state = home.path().join("app-server-daemon");
|
| 17 |
+
std::fs::create_dir(&state).unwrap();
|
| 18 |
+
for name in ["settings.json", "daemon.lock", "app-server.pid.lock"] {
|
| 19 |
+
std::fs::write(state.join(name), b"").unwrap();
|
| 20 |
+
}
|
| 21 |
+
// A CLI install and a previous stop/status operation do not establish ownership.
|
| 22 |
+
assert_eq!(
|
| 23 |
+
super::package_root(home.path()),
|
| 24 |
+
home.path().join("packages/app-server-daemon")
|
| 25 |
+
);
|
| 26 |
+
std::fs::write(state.join("app-server.stderr.log"), b"").unwrap();
|
| 27 |
+
assert_eq!(super::managed_codex_bin(home.path()), legacy);
|
| 28 |
+
let packaged = current.join("bin").join(super::managed_codex_file_name());
|
| 29 |
+
std::fs::create_dir(current.join("bin")).expect("bin directory");
|
| 30 |
+
std::fs::write(&packaged, b"packaged").expect("packaged executable");
|
| 31 |
+
assert_eq!(super::managed_codex_bin(home.path()), packaged);
|
| 32 |
+
|
| 33 |
+
std::fs::remove_dir_all(home.path().join("packages/standalone")).unwrap();
|
| 34 |
+
assert_eq!(
|
| 35 |
+
super::package_root(home.path()),
|
| 36 |
+
home.path().join("packages/standalone")
|
| 37 |
+
);
|
| 38 |
+
std::fs::remove_file(state.join("app-server.stderr.log")).unwrap();
|
| 39 |
+
std::fs::write(state.join("app-server.pid"), b"running daemon").unwrap();
|
| 40 |
+
assert_eq!(
|
| 41 |
+
super::package_root(home.path()),
|
| 42 |
+
home.path().join("packages/standalone")
|
| 43 |
+
);
|
| 44 |
+
|
| 45 |
+
std::fs::write(state.join("daemon.pid"), b"running dedicated daemon").unwrap();
|
| 46 |
+
assert_eq!(
|
| 47 |
+
super::package_root(home.path()),
|
| 48 |
+
home.path().join("packages/app-server-daemon")
|
| 49 |
+
);
|
| 50 |
+
std::fs::remove_file(state.join("daemon.pid")).unwrap();
|
| 51 |
+
std::fs::write(state.join("daemon.stderr.log"), b"").unwrap();
|
| 52 |
+
assert_eq!(
|
| 53 |
+
super::package_root(home.path()),
|
| 54 |
+
home.path().join("packages/app-server-daemon")
|
| 55 |
+
);
|
| 56 |
+
|
| 57 |
+
#[cfg(unix)]
|
| 58 |
+
{
|
| 59 |
+
let dedicated = home.path().join("packages/app-server-daemon");
|
| 60 |
+
std::fs::write(&dedicated, b"not a directory").expect("unreadable selection");
|
| 61 |
+
assert_eq!(super::package_root(home.path()), dedicated);
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
#[cfg(unix)]
|
| 66 |
+
#[test]
|
| 67 |
+
fn updater_only_runs_for_stable_installer_owned_releases() {
|
| 68 |
+
let home = tempfile::TempDir::new().expect("home");
|
| 69 |
+
let state = home.path().join("app-server-daemon");
|
| 70 |
+
std::fs::create_dir(&state).unwrap();
|
| 71 |
+
std::fs::write(state.join("app-server.stderr.log"), b"").unwrap();
|
| 72 |
+
let standalone = home.path().join("packages/standalone");
|
| 73 |
+
let current = standalone.join("current");
|
| 74 |
+
let release = standalone.join("releases/0.150.0-aarch64-apple-darwin");
|
| 75 |
+
let managed = release.join("bin/codex");
|
| 76 |
+
std::fs::create_dir_all(managed.parent().expect("bin parent")).expect("release");
|
| 77 |
+
std::fs::write(&managed, b"stable").expect("managed bin");
|
| 78 |
+
std::os::unix::fs::symlink(&release, ¤t).expect("current release");
|
| 79 |
+
assert!(!super::is_stable_standalone_release(home.path(), &managed));
|
| 80 |
+
let marker = standalone.join("auto-update-version");
|
| 81 |
+
let release_name = release.file_name().expect("release name");
|
| 82 |
+
std::fs::write(&marker, release_name.as_encoded_bytes()).expect("latest selection");
|
| 83 |
+
assert!(super::is_stable_standalone_release(home.path(), &managed));
|
| 84 |
+
std::fs::write(&marker, b"0.149.0-aarch64-apple-darwin").expect("stale selection");
|
| 85 |
+
assert!(!super::is_stable_standalone_release(home.path(), &managed));
|
| 86 |
+
std::fs::remove_file(&marker).expect("pinned selection");
|
| 87 |
+
assert!(!super::is_stable_standalone_release(home.path(), &managed));
|
| 88 |
+
|
| 89 |
+
let alpha = standalone.join("releases/0.151.0-alpha.1-aarch64-apple-darwin");
|
| 90 |
+
let alpha_managed = alpha.join("bin/codex");
|
| 91 |
+
std::fs::create_dir_all(alpha_managed.parent().expect("alpha bin parent"))
|
| 92 |
+
.expect("alpha release");
|
| 93 |
+
std::fs::write(&alpha_managed, b"alpha").expect("alpha bin");
|
| 94 |
+
std::fs::remove_file(¤t).expect("remove current");
|
| 95 |
+
std::os::unix::fs::symlink(alpha, ¤t).expect("current alpha");
|
| 96 |
+
assert!(!super::is_stable_standalone_release(
|
| 97 |
+
home.path(),
|
| 98 |
+
&alpha_managed
|
| 99 |
+
));
|
| 100 |
+
|
| 101 |
+
let local = standalone.join("local-main");
|
| 102 |
+
let local_managed = local.join("bin/codex");
|
| 103 |
+
std::fs::create_dir_all(local_managed.parent().expect("local bin parent"))
|
| 104 |
+
.expect("local build");
|
| 105 |
+
std::fs::write(&local_managed, b"local").expect("local bin");
|
| 106 |
+
std::fs::remove_file(¤t).expect("remove current");
|
| 107 |
+
std::os::unix::fs::symlink(local, ¤t).expect("current local build");
|
| 108 |
+
assert!(!super::is_stable_standalone_release(
|
| 109 |
+
home.path(),
|
| 110 |
+
&local_managed
|
| 111 |
+
));
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
#[cfg(unix)]
|
| 115 |
+
#[tokio::test]
|
| 116 |
+
async fn older_managed_binary_does_not_claim_updater_support() {
|
| 117 |
+
use std::os::unix::fs::PermissionsExt;
|
| 118 |
+
|
| 119 |
+
let temp = tempfile::TempDir::new().expect("home");
|
| 120 |
+
let binary = temp.path().join("codex");
|
| 121 |
+
std::fs::write(&binary, b"#!/bin/sh\nexit 2\n").expect("older binary");
|
| 122 |
+
std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755))
|
| 123 |
+
.expect("executable binary");
|
| 124 |
+
assert!(!super::supports_daemon_update_loop(&binary).await);
|
| 125 |
+
std::fs::write(&binary, b"#!/bin/sh\nexit 0\n").expect("newer binary");
|
| 126 |
+
assert!(super::supports_daemon_update_loop(&binary).await);
|
| 127 |
+
}
|
codex-rs/app-server-daemon/src/managed_install_tests.rs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use pretty_assertions::assert_eq;
|
| 2 |
+
|
| 3 |
+
use super::executable_identity_from_bytes;
|
| 4 |
+
use super::parse_codex_version;
|
| 5 |
+
|
| 6 |
+
#[test]
|
| 7 |
+
fn parses_codex_cli_version_output() {
|
| 8 |
+
assert_eq!(
|
| 9 |
+
parse_codex_version("codex 1.2.3\n").expect("version"),
|
| 10 |
+
"1.2.3"
|
| 11 |
+
);
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
#[test]
|
| 15 |
+
fn rejects_malformed_codex_cli_version_output() {
|
| 16 |
+
assert!(parse_codex_version("codex\n").is_err());
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
#[test]
|
| 20 |
+
fn executable_identity_uses_binary_contents() {
|
| 21 |
+
let old = executable_identity_from_bytes(b"old");
|
| 22 |
+
let same = executable_identity_from_bytes(b"old");
|
| 23 |
+
let new = executable_identity_from_bytes(b"new");
|
| 24 |
+
|
| 25 |
+
assert_eq!(old, same);
|
| 26 |
+
assert_ne!(old, new);
|
| 27 |
+
}
|
codex-rs/app-server-daemon/src/manual_update.rs
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Forwards manual requests to the updater and reports its install and restart result.
|
| 2 |
+
|
| 3 |
+
use anyhow::Context;
|
| 4 |
+
use anyhow::Result;
|
| 5 |
+
use codex_uds::UnixStream;
|
| 6 |
+
use tokio::io::AsyncReadExt;
|
| 7 |
+
use tokio::io::AsyncWriteExt;
|
| 8 |
+
use tokio::time::Duration;
|
| 9 |
+
use tokio::time::sleep;
|
| 10 |
+
use tokio::time::timeout;
|
| 11 |
+
|
| 12 |
+
use super::InstallerHttp;
|
| 13 |
+
use super::Signal;
|
| 14 |
+
use super::UpdateLoopControl;
|
| 15 |
+
use super::UpdateTrigger;
|
| 16 |
+
use super::selected_release;
|
| 17 |
+
use super::update_once;
|
| 18 |
+
use crate::Daemon;
|
| 19 |
+
use crate::RestartIfRunningOutcome;
|
| 20 |
+
use crate::UpdateOutput;
|
| 21 |
+
use crate::UpdateStatus;
|
| 22 |
+
use crate::client;
|
| 23 |
+
use crate::managed_install::executable_identity;
|
| 24 |
+
use crate::managed_install::managed_codex_version;
|
| 25 |
+
|
| 26 |
+
pub(crate) async fn request(daemon: &Daemon) -> Result<UpdateOutput> {
|
| 27 |
+
let socket_path = daemon.manual_update_socket_path();
|
| 28 |
+
let mut replacement_deadline = None;
|
| 29 |
+
'request: loop {
|
| 30 |
+
let mut connection = if let Some(deadline) = replacement_deadline {
|
| 31 |
+
loop {
|
| 32 |
+
if let Ok(connection) = UnixStream::connect(&socket_path).await {
|
| 33 |
+
break connection;
|
| 34 |
+
}
|
| 35 |
+
// A one-shot updater may exit after accepting the connection but
|
| 36 |
+
// before reading our request. Give a successor time to appear,
|
| 37 |
+
// then return to the normal startup path if none owns the PID.
|
| 38 |
+
if tokio::time::Instant::now() + Duration::from_secs(29) >= deadline {
|
| 39 |
+
let settings = daemon.load_settings().await?;
|
| 40 |
+
if !crate::backend::pid_update_loop_backend(daemon.backend_paths(&settings))
|
| 41 |
+
.is_starting_or_running()
|
| 42 |
+
.await?
|
| 43 |
+
{
|
| 44 |
+
replacement_deadline = None;
|
| 45 |
+
continue 'request;
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
anyhow::ensure!(
|
| 49 |
+
tokio::time::Instant::now() < deadline,
|
| 50 |
+
"timed out waiting for the replacement daemon updater"
|
| 51 |
+
);
|
| 52 |
+
sleep(Duration::from_millis(50)).await;
|
| 53 |
+
}
|
| 54 |
+
} else {
|
| 55 |
+
match UnixStream::connect(&socket_path).await {
|
| 56 |
+
Ok(connection) => connection,
|
| 57 |
+
Err(_) => {
|
| 58 |
+
let _operation_lock = daemon.acquire_operation_lock().await?;
|
| 59 |
+
if let Ok(connection) = UnixStream::connect(&socket_path).await {
|
| 60 |
+
connection
|
| 61 |
+
} else {
|
| 62 |
+
let settings = daemon.load_settings().await?;
|
| 63 |
+
if !supported(daemon)?
|
| 64 |
+
|| (daemon.running_backend_instance(&settings).await?.is_none()
|
| 65 |
+
&& client::probe(&daemon.socket_path).await.is_ok())
|
| 66 |
+
{
|
| 67 |
+
return unsupported(daemon).await;
|
| 68 |
+
}
|
| 69 |
+
crate::backend::pid_update_loop_backend(daemon.backend_paths(&settings))
|
| 70 |
+
.stop()
|
| 71 |
+
.await?;
|
| 72 |
+
let current_exe = std::env::current_exe()?;
|
| 73 |
+
let paths = daemon.backend_paths_with_bin(&settings, ¤t_exe);
|
| 74 |
+
let restore_release = if daemon.is_stable_standalone_release()? {
|
| 75 |
+
None
|
| 76 |
+
} else {
|
| 77 |
+
Some(selected_release(daemon)?.2)
|
| 78 |
+
};
|
| 79 |
+
let worker = crate::backend::PidBackend::new_update_loop(
|
| 80 |
+
paths.codex_bin,
|
| 81 |
+
paths.update_pid_file,
|
| 82 |
+
restore_release,
|
| 83 |
+
);
|
| 84 |
+
worker.start().await?;
|
| 85 |
+
let deadline = tokio::time::Instant::now() + Duration::from_secs(30);
|
| 86 |
+
loop {
|
| 87 |
+
if let Ok(connection) = UnixStream::connect(&socket_path).await {
|
| 88 |
+
break connection;
|
| 89 |
+
}
|
| 90 |
+
anyhow::ensure!(
|
| 91 |
+
tokio::time::Instant::now() < deadline,
|
| 92 |
+
"timed out waiting for the daemon updater to accept a manual request"
|
| 93 |
+
);
|
| 94 |
+
sleep(Duration::from_millis(50)).await;
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
};
|
| 100 |
+
let mut response = Vec::new();
|
| 101 |
+
let transport = async {
|
| 102 |
+
#[cfg(windows)]
|
| 103 |
+
connection.ensure_non_elevated_peer()?;
|
| 104 |
+
connection.write_all(b"update\n").await?;
|
| 105 |
+
connection
|
| 106 |
+
.take(16 * 1024)
|
| 107 |
+
.read_to_end(&mut response)
|
| 108 |
+
.await?;
|
| 109 |
+
Ok::<_, std::io::Error>(())
|
| 110 |
+
}
|
| 111 |
+
.await;
|
| 112 |
+
if response.is_empty() {
|
| 113 |
+
if transport
|
| 114 |
+
.as_ref()
|
| 115 |
+
.is_err_and(|err| err.kind() == std::io::ErrorKind::PermissionDenied)
|
| 116 |
+
{
|
| 117 |
+
transport?;
|
| 118 |
+
}
|
| 119 |
+
let deadline = replacement_deadline
|
| 120 |
+
.get_or_insert_with(|| tokio::time::Instant::now() + Duration::from_secs(30));
|
| 121 |
+
anyhow::ensure!(
|
| 122 |
+
tokio::time::Instant::now() < *deadline,
|
| 123 |
+
"daemon updater disconnected before responding"
|
| 124 |
+
);
|
| 125 |
+
sleep(Duration::from_millis(50)).await;
|
| 126 |
+
continue;
|
| 127 |
+
}
|
| 128 |
+
transport?;
|
| 129 |
+
let outcome: std::result::Result<UpdateOutput, String> =
|
| 130 |
+
serde_json::from_slice(&response).context("invalid response from daemon updater")?;
|
| 131 |
+
let outcome = outcome.map_err(anyhow::Error::msg)?;
|
| 132 |
+
if outcome.status == UpdateStatus::Unsupported && supported(daemon)? {
|
| 133 |
+
// A scheduled or older updater cannot authorize restoring a pin.
|
| 134 |
+
// Replace it under the writable operation lock, even with identical bytes.
|
| 135 |
+
let _operation_lock = daemon.acquire_operation_lock().await?;
|
| 136 |
+
let settings = daemon.load_settings().await?;
|
| 137 |
+
let worker = crate::backend::pid_update_loop_backend(daemon.backend_paths(&settings));
|
| 138 |
+
if !daemon.is_stable_standalone_release()? {
|
| 139 |
+
worker.stop().await?;
|
| 140 |
+
continue 'request;
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
return Ok(outcome);
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
pub(super) fn supported(daemon: &Daemon) -> Result<bool> {
|
| 148 |
+
if daemon.is_stable_standalone_release()? {
|
| 149 |
+
return Ok(true);
|
| 150 |
+
}
|
| 151 |
+
let Ok((root, release, _)) = selected_release(daemon) else {
|
| 152 |
+
return Ok(false);
|
| 153 |
+
};
|
| 154 |
+
let bin = daemon.current_managed_codex_bin()?;
|
| 155 |
+
Ok(std::fs::canonicalize(root.join("releases"))
|
| 156 |
+
.is_ok_and(|releases| release.parent() == Some(releases.as_path()))
|
| 157 |
+
&& bin.is_file()
|
| 158 |
+
&& std::fs::canonicalize(bin).is_ok_and(|bin| bin.starts_with(&release)))
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
const UNSUPPORTED_MESSAGE: &str =
|
| 162 |
+
"This command requires a daemon package selected from its managed releases directory.";
|
| 163 |
+
|
| 164 |
+
pub(super) async fn unsupported(daemon: &Daemon) -> Result<UpdateOutput> {
|
| 165 |
+
let managed_codex_path = daemon.current_managed_codex_bin()?;
|
| 166 |
+
Ok(UpdateOutput {
|
| 167 |
+
status: UpdateStatus::Unsupported,
|
| 168 |
+
installed_version: managed_codex_version(&managed_codex_path).await.ok(),
|
| 169 |
+
running_version: client::probe(&daemon.socket_path)
|
| 170 |
+
.await
|
| 171 |
+
.ok()
|
| 172 |
+
.map(|info| info.app_server_version),
|
| 173 |
+
managed_codex_path,
|
| 174 |
+
message: UNSUPPORTED_MESSAGE.to_string(),
|
| 175 |
+
})
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
pub(super) async fn handle_request(
|
| 179 |
+
mut connection: UnixStream,
|
| 180 |
+
http: &impl InstallerHttp,
|
| 181 |
+
daemon: &Daemon,
|
| 182 |
+
running_updater_identity: &crate::managed_install::ExecutableIdentity,
|
| 183 |
+
terminate: &mut Signal,
|
| 184 |
+
trigger: UpdateTrigger<'_>,
|
| 185 |
+
) -> Result<RequestDisposition> {
|
| 186 |
+
#[cfg(windows)]
|
| 187 |
+
if connection.ensure_non_elevated_peer().is_err() {
|
| 188 |
+
return Ok(RequestDisposition::Unchanged);
|
| 189 |
+
}
|
| 190 |
+
let mut request = [0; 7];
|
| 191 |
+
if !matches!(
|
| 192 |
+
timeout(Duration::from_secs(5), connection.read_exact(&mut request)).await,
|
| 193 |
+
Ok(Ok(_))
|
| 194 |
+
) || &request != b"update\n"
|
| 195 |
+
{
|
| 196 |
+
return Ok(RequestDisposition::Unchanged);
|
| 197 |
+
}
|
| 198 |
+
let result = run(http, daemon, running_updater_identity, terminate, trigger).await;
|
| 199 |
+
let interrupted = result.as_ref().err().is_some_and(|err| {
|
| 200 |
+
err.downcast_ref::<std::io::Error>()
|
| 201 |
+
.is_some_and(|err| err.kind() == std::io::ErrorKind::Interrupted)
|
| 202 |
+
});
|
| 203 |
+
let unsupported = result
|
| 204 |
+
.as_ref()
|
| 205 |
+
.is_ok_and(|output| output.status == UpdateStatus::Unsupported);
|
| 206 |
+
let outcome: std::result::Result<UpdateOutput, String> =
|
| 207 |
+
result.map_err(|err| format!("{err:#}"));
|
| 208 |
+
let sent = connection.write_all(&serde_json::to_vec(&outcome)?).await;
|
| 209 |
+
if interrupted {
|
| 210 |
+
return Ok(RequestDisposition::Stop);
|
| 211 |
+
}
|
| 212 |
+
sent?;
|
| 213 |
+
Ok(if unsupported {
|
| 214 |
+
RequestDisposition::Unchanged
|
| 215 |
+
} else {
|
| 216 |
+
RequestDisposition::Continue
|
| 217 |
+
})
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
pub(super) enum RequestDisposition {
|
| 221 |
+
Continue,
|
| 222 |
+
Unchanged,
|
| 223 |
+
Stop,
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
pub(super) async fn run(
|
| 227 |
+
http: &impl InstallerHttp,
|
| 228 |
+
daemon: &Daemon,
|
| 229 |
+
running_updater_identity: &crate::managed_install::ExecutableIdentity,
|
| 230 |
+
terminate: &mut Signal,
|
| 231 |
+
trigger: UpdateTrigger<'_>,
|
| 232 |
+
) -> Result<UpdateOutput> {
|
| 233 |
+
let settings = daemon.load_settings().await?;
|
| 234 |
+
let managed_running = daemon.running_backend_instance(&settings).await?.is_some();
|
| 235 |
+
if !(daemon.is_stable_standalone_release()?
|
| 236 |
+
|| matches!(trigger, UpdateTrigger::RestoreProduction(_)) && supported(daemon)?)
|
| 237 |
+
|| (!managed_running && client::probe(&daemon.socket_path).await.is_ok())
|
| 238 |
+
{
|
| 239 |
+
return unsupported(daemon).await;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
let managed_codex_path = daemon.current_managed_codex_bin()?;
|
| 243 |
+
let (_, previous_release, _) = selected_release(daemon)?;
|
| 244 |
+
let previous_identity = executable_identity(&managed_codex_path).await?;
|
| 245 |
+
let (control, restart) =
|
| 246 |
+
update_once(http, daemon, running_updater_identity, terminate, trigger).await?;
|
| 247 |
+
if matches!(control, UpdateLoopControl::Stop) {
|
| 248 |
+
return Err(std::io::Error::from(std::io::ErrorKind::Interrupted).into());
|
| 249 |
+
}
|
| 250 |
+
let current_managed_codex_path = daemon.current_managed_codex_bin()?;
|
| 251 |
+
let installed_version = Some(managed_codex_version(¤t_managed_codex_path).await?);
|
| 252 |
+
let updated = previous_release != selected_release(daemon)?.1
|
| 253 |
+
|| previous_identity != executable_identity(¤t_managed_codex_path).await?;
|
| 254 |
+
let running_version = client::probe(&daemon.socket_path)
|
| 255 |
+
.await
|
| 256 |
+
.ok()
|
| 257 |
+
.map(|info| info.app_server_version);
|
| 258 |
+
let message = match restart {
|
| 259 |
+
Some(RestartIfRunningOutcome::Restarted) => {
|
| 260 |
+
"The managed installation is ready and the running daemon was restarted. Active or queued work may have been interrupted."
|
| 261 |
+
}
|
| 262 |
+
Some(RestartIfRunningOutcome::AlreadyCurrent) => {
|
| 263 |
+
"The managed installation and running daemon are already current; the daemon was left running."
|
| 264 |
+
}
|
| 265 |
+
Some(RestartIfRunningOutcome::NotRunning) => {
|
| 266 |
+
"The managed installation is ready; no daemon was running when the updater checked."
|
| 267 |
+
}
|
| 268 |
+
_ => unreachable!("successful manual update has a restart outcome"),
|
| 269 |
+
};
|
| 270 |
+
Ok(UpdateOutput {
|
| 271 |
+
status: if updated {
|
| 272 |
+
UpdateStatus::Updated
|
| 273 |
+
} else {
|
| 274 |
+
UpdateStatus::NoUpdate
|
| 275 |
+
},
|
| 276 |
+
managed_codex_path: current_managed_codex_path,
|
| 277 |
+
installed_version,
|
| 278 |
+
running_version,
|
| 279 |
+
message: message.to_string(),
|
| 280 |
+
})
|
| 281 |
+
}
|
codex-rs/app-server-daemon/src/migration.rs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Moves a legacy daemon to its dedicated package only during an explicit update.
|
| 2 |
+
//! Preparation leaves the legacy selection intact; publishing current is the cutover.
|
| 3 |
+
|
| 4 |
+
use std::path::Path;
|
| 5 |
+
|
| 6 |
+
use anyhow::Context;
|
| 7 |
+
use anyhow::Result;
|
| 8 |
+
use futures::FutureExt;
|
| 9 |
+
|
| 10 |
+
use super::InstallerHttp;
|
| 11 |
+
use super::InstallerMode;
|
| 12 |
+
use super::fetch_installer_script;
|
| 13 |
+
use super::run_installer_script;
|
| 14 |
+
use super::selected_release;
|
| 15 |
+
use crate::Daemon;
|
| 16 |
+
use crate::UpdateOutput;
|
| 17 |
+
use crate::UpdateStatus;
|
| 18 |
+
use crate::backend;
|
| 19 |
+
use crate::client;
|
| 20 |
+
use crate::install_lock::acquire_install_lock;
|
| 21 |
+
use crate::managed_install;
|
| 22 |
+
|
| 23 |
+
pub(super) async fn run(http: &impl InstallerHttp, legacy: &Daemon) -> Result<UpdateOutput> {
|
| 24 |
+
let _operation_lock = legacy.acquire_operation_lock().await?;
|
| 25 |
+
let previous = selected_release(legacy)?;
|
| 26 |
+
anyhow::ensure!(
|
| 27 |
+
previous.0.ends_with("standalone") && super::manual_update::supported(legacy)?,
|
| 28 |
+
"daemon selection changed; retry the update"
|
| 29 |
+
);
|
| 30 |
+
let home = previous
|
| 31 |
+
.0
|
| 32 |
+
.parent()
|
| 33 |
+
.and_then(Path::parent)
|
| 34 |
+
.context("package root has no Codex home")?;
|
| 35 |
+
let root = home.join("packages/app-server-daemon");
|
| 36 |
+
let settings = legacy.load_settings().await?;
|
| 37 |
+
let running = legacy.running_backend_instance(&settings).await?;
|
| 38 |
+
if running.is_none() && client::probe(&legacy.socket_path).await.is_ok() {
|
| 39 |
+
return super::manual_update::unsupported(legacy).await;
|
| 40 |
+
}
|
| 41 |
+
#[cfg(unix)]
|
| 42 |
+
let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
| 43 |
+
let cancellation = async {
|
| 44 |
+
#[cfg(unix)]
|
| 45 |
+
tokio::select! {
|
| 46 |
+
_ = terminate.recv() => {},
|
| 47 |
+
_ = tokio::signal::ctrl_c() => {},
|
| 48 |
+
}
|
| 49 |
+
#[cfg(windows)]
|
| 50 |
+
let _ = tokio::signal::ctrl_c().await;
|
| 51 |
+
Some(())
|
| 52 |
+
};
|
| 53 |
+
let mut cancellation = std::pin::pin!(cancellation);
|
| 54 |
+
|
| 55 |
+
let script = fetch_installer_script(http).await?;
|
| 56 |
+
anyhow::ensure!(
|
| 57 |
+
script
|
| 58 |
+
.windows(b"CODEX_INSTALL_DEFER_SELECTION".len())
|
| 59 |
+
.any(|window| window == b"CODEX_INSTALL_DEFER_SELECTION"),
|
| 60 |
+
"the published installer does not support daemon migration yet; the legacy installation was left unchanged"
|
| 61 |
+
);
|
| 62 |
+
eprintln!("Preparing the daemon update in {}...", root.display());
|
| 63 |
+
#[cfg(unix)]
|
| 64 |
+
{
|
| 65 |
+
if matches!(
|
| 66 |
+
run_installer_script(
|
| 67 |
+
&script,
|
| 68 |
+
InstallerMode::Migration,
|
| 69 |
+
&root,
|
| 70 |
+
cancellation.as_mut()
|
| 71 |
+
)
|
| 72 |
+
.await?,
|
| 73 |
+
super::UpdateLoopControl::Stop
|
| 74 |
+
) {
|
| 75 |
+
return Err(std::io::Error::from(std::io::ErrorKind::Interrupted).into());
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
#[cfg(windows)]
|
| 79 |
+
tokio::select! {
|
| 80 |
+
result = run_installer_script(&script, InstallerMode::Migration, &root) => { result?; },
|
| 81 |
+
_ = &mut cancellation => return Err(std::io::Error::from(std::io::ErrorKind::Interrupted).into()),
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
let _install_lock = acquire_install_lock(&root).await?;
|
| 85 |
+
let pending = root.join(".migration-current");
|
| 86 |
+
let release = pending
|
| 87 |
+
.canonicalize()
|
| 88 |
+
.context("installer did not prepare a daemon package")?;
|
| 89 |
+
let name = release
|
| 90 |
+
.file_name()
|
| 91 |
+
.context("prepared release has no name")?;
|
| 92 |
+
anyhow::ensure!(
|
| 93 |
+
release.parent() == Some(root.join("releases").canonicalize()?.as_path())
|
| 94 |
+
&& std::fs::read_to_string(root.join("auto-update-version"))? == name.to_string_lossy(),
|
| 95 |
+
"installer did not prepare a latest-channel daemon package"
|
| 96 |
+
);
|
| 97 |
+
let entrypoint = if cfg!(windows) {
|
| 98 |
+
"bin/codex.exe"
|
| 99 |
+
} else {
|
| 100 |
+
"bin/codex"
|
| 101 |
+
};
|
| 102 |
+
let binary = release.join(entrypoint);
|
| 103 |
+
let version = managed_install::managed_codex_version(&binary).await?;
|
| 104 |
+
anyhow::ensure!(
|
| 105 |
+
name.to_string_lossy().starts_with(&format!("{version}-")),
|
| 106 |
+
"prepared daemon version does not match its release"
|
| 107 |
+
);
|
| 108 |
+
// An older production updater understands only the legacy package/PID paths.
|
| 109 |
+
anyhow::ensure!(
|
| 110 |
+
managed_install::supports_daemon_command(
|
| 111 |
+
&binary,
|
| 112 |
+
&["pid-update-loop", "--check-package-ownership"]
|
| 113 |
+
)
|
| 114 |
+
.await,
|
| 115 |
+
"the latest production release does not support daemon migration yet; the legacy daemon was left running"
|
| 116 |
+
);
|
| 117 |
+
#[cfg(windows)]
|
| 118 |
+
backend::windows::ensure_detached_launch(&binary)?;
|
| 119 |
+
anyhow::ensure!(
|
| 120 |
+
selected_release(legacy)? == previous,
|
| 121 |
+
"daemon selection changed; retry the update"
|
| 122 |
+
);
|
| 123 |
+
if cancellation.as_mut().now_or_never().is_some() {
|
| 124 |
+
return Err(std::io::Error::from(std::io::ErrorKind::Interrupted).into());
|
| 125 |
+
}
|
| 126 |
+
let cutover = async {
|
| 127 |
+
backend::pid_update_loop_backend(legacy.backend_paths(&settings))
|
| 128 |
+
.stop()
|
| 129 |
+
.await?;
|
| 130 |
+
anyhow::ensure!(
|
| 131 |
+
selected_release(legacy)? == previous,
|
| 132 |
+
"daemon selection changed; retry the update"
|
| 133 |
+
);
|
| 134 |
+
if let Some(running) = &running {
|
| 135 |
+
crate::thread_recovery::discard_pending(legacy)?;
|
| 136 |
+
running
|
| 137 |
+
.stop_with_grace(settings.shutdown_grace_seconds)
|
| 138 |
+
.await?;
|
| 139 |
+
}
|
| 140 |
+
std::fs::rename(&pending, root.join("current"))
|
| 141 |
+
.context("failed to select the new daemon package; retry the update")
|
| 142 |
+
}
|
| 143 |
+
.await;
|
| 144 |
+
if let Err(error) = cutover {
|
| 145 |
+
if let Err(restore_error) = async {
|
| 146 |
+
legacy
|
| 147 |
+
.current_installation()?
|
| 148 |
+
.ensure_managed_updater(&settings)
|
| 149 |
+
.await
|
| 150 |
+
}
|
| 151 |
+
.await
|
| 152 |
+
{
|
| 153 |
+
eprintln!(
|
| 154 |
+
"warning: failed to restore the legacy updater after migration failed: {restore_error:#}"
|
| 155 |
+
);
|
| 156 |
+
}
|
| 157 |
+
return Err(error);
|
| 158 |
+
}
|
| 159 |
+
let selected = Daemon {
|
| 160 |
+
pid_file: legacy.pid_file.with_file_name(crate::DAEMON_PID_FILE_NAME),
|
| 161 |
+
update_pid_file: legacy
|
| 162 |
+
.update_pid_file
|
| 163 |
+
.with_file_name(crate::DAEMON_UPDATE_PID_FILE_NAME),
|
| 164 |
+
managed_codex_bin: root.join("current").join(entrypoint),
|
| 165 |
+
..legacy.clone()
|
| 166 |
+
};
|
| 167 |
+
let running_version = if running.is_some() {
|
| 168 |
+
selected.start_managed_backend(&settings).await.context(
|
| 169 |
+
"daemon migrated but could not start; retry with `codex app-server daemon start`",
|
| 170 |
+
)?;
|
| 171 |
+
Some(
|
| 172 |
+
selected
|
| 173 |
+
.wait_until_ready()
|
| 174 |
+
.await
|
| 175 |
+
.context(
|
| 176 |
+
"daemon migrated but is not ready; retry with `codex app-server daemon start`",
|
| 177 |
+
)?
|
| 178 |
+
.app_server_version,
|
| 179 |
+
)
|
| 180 |
+
} else {
|
| 181 |
+
None
|
| 182 |
+
};
|
| 183 |
+
if let Err(error) = selected.ensure_managed_updater(&settings).await {
|
| 184 |
+
eprintln!("warning: daemon migrated but its updater could not start: {error:#}");
|
| 185 |
+
}
|
| 186 |
+
Ok(UpdateOutput {
|
| 187 |
+
status: UpdateStatus::Updated,
|
| 188 |
+
installed_version: Some(version),
|
| 189 |
+
running_version,
|
| 190 |
+
managed_codex_path: selected.managed_codex_bin,
|
| 191 |
+
message: "The daemon was updated and moved to its dedicated package. The legacy CLI package was left unchanged.".to_string(),
|
| 192 |
+
})
|
| 193 |
+
}
|
codex-rs/app-server-daemon/src/prepare_install.rs
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Installs complete CLI packages after confirmation. Initial starts preserve
|
| 2 |
+
//! existing selections; explicit replacements migrate to dedicated packages and stay pinned.
|
| 3 |
+
|
| 4 |
+
use std::path::Path;
|
| 5 |
+
use std::path::PathBuf;
|
| 6 |
+
|
| 7 |
+
use anyhow::Context;
|
| 8 |
+
use anyhow::Result;
|
| 9 |
+
use anyhow::anyhow;
|
| 10 |
+
use codex_install_context::CodexPackageManifest;
|
| 11 |
+
use codex_install_context::InstallContext;
|
| 12 |
+
|
| 13 |
+
use crate::Daemon;
|
| 14 |
+
use crate::install_lock::acquire_install_lock;
|
| 15 |
+
use crate::managed_install;
|
| 16 |
+
use crate::settings::DaemonSettings;
|
| 17 |
+
|
| 18 |
+
/// The complete CLI package offered for a daemon installation.
|
| 19 |
+
pub struct InstallRequest {
|
| 20 |
+
pub source: PathBuf,
|
| 21 |
+
pub version: String,
|
| 22 |
+
pub destination: PathBuf,
|
| 23 |
+
pub installed_version: Option<String>,
|
| 24 |
+
pub restart_required: bool,
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/// Prepare a missing package while the caller holds the daemon operation lock.
|
| 28 |
+
pub(super) async fn prepare(daemon: &Daemon, settings: &DaemonSettings) -> Result<()> {
|
| 29 |
+
let source = InstallContext::current().package_layout.as_ref();
|
| 30 |
+
// Keep package replacement state out of the CLI dispatcher's async stack frame.
|
| 31 |
+
Box::pin(prepare_from_package(
|
| 32 |
+
daemon,
|
| 33 |
+
settings,
|
| 34 |
+
InstallMode::Missing,
|
| 35 |
+
source.map(|layout| layout.package_dir.as_path()),
|
| 36 |
+
&std::env::current_exe()?,
|
| 37 |
+
|_| Ok(true),
|
| 38 |
+
))
|
| 39 |
+
.await
|
| 40 |
+
.map(|_| ())
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/// Select this CLI's complete package and pin it, restarting only a running daemon.
|
| 44 |
+
/// Returns None when the user cancels without changing the installation.
|
| 45 |
+
pub async fn update_from_cli(
|
| 46 |
+
confirm: impl FnOnce(&InstallRequest) -> Result<bool>,
|
| 47 |
+
) -> Result<Option<crate::UpdateOutput>> {
|
| 48 |
+
crate::ensure_supported_platform()?;
|
| 49 |
+
#[cfg(windows)]
|
| 50 |
+
crate::backend::windows::ensure_not_elevated()?;
|
| 51 |
+
let daemon = Daemon::from_environment()?;
|
| 52 |
+
let settings = daemon.load_settings().await?;
|
| 53 |
+
let source = InstallContext::current().package_layout.as_ref();
|
| 54 |
+
if !Box::pin(prepare_from_package(
|
| 55 |
+
&daemon,
|
| 56 |
+
&settings,
|
| 57 |
+
InstallMode::Replace,
|
| 58 |
+
source.map(|layout| layout.package_dir.as_path()),
|
| 59 |
+
&std::env::current_exe()?,
|
| 60 |
+
confirm,
|
| 61 |
+
))
|
| 62 |
+
.await?
|
| 63 |
+
{
|
| 64 |
+
return Ok(None);
|
| 65 |
+
}
|
| 66 |
+
let managed_codex_path = daemon.current_managed_codex_bin()?;
|
| 67 |
+
Ok(Some(crate::UpdateOutput {
|
| 68 |
+
status: crate::UpdateStatus::Updated,
|
| 69 |
+
installed_version: Some(managed_install::managed_codex_version(&managed_codex_path).await?),
|
| 70 |
+
running_version: crate::client::probe(&daemon.socket_path)
|
| 71 |
+
.await
|
| 72 |
+
.ok()
|
| 73 |
+
.map(|info| info.app_server_version),
|
| 74 |
+
managed_codex_path,
|
| 75 |
+
message: "The CLI package is selected and pinned. Run `codex app-server daemon update` to return to production updates.".to_string(),
|
| 76 |
+
}))
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
#[derive(Clone, Copy, PartialEq, Eq)]
|
| 80 |
+
enum InstallMode {
|
| 81 |
+
Missing,
|
| 82 |
+
Replace,
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
async fn prepare_from_package(
|
| 86 |
+
daemon: &Daemon,
|
| 87 |
+
settings: &DaemonSettings,
|
| 88 |
+
mode: InstallMode,
|
| 89 |
+
source: Option<&Path>,
|
| 90 |
+
running_exe: &Path,
|
| 91 |
+
confirm: impl FnOnce(&InstallRequest) -> Result<bool>,
|
| 92 |
+
) -> Result<bool> {
|
| 93 |
+
let home = daemon
|
| 94 |
+
.settings_file
|
| 95 |
+
.parent()
|
| 96 |
+
.and_then(Path::parent)
|
| 97 |
+
.context("daemon settings path has no Codex home")?;
|
| 98 |
+
let previous_root = managed_install::package_root(home);
|
| 99 |
+
let root = home.join("packages/app-server-daemon");
|
| 100 |
+
anyhow::ensure!(
|
| 101 |
+
daemon.managed_codex_bin.starts_with(&previous_root),
|
| 102 |
+
"daemon package location changed; retry the command"
|
| 103 |
+
);
|
| 104 |
+
if mode == InstallMode::Missing {
|
| 105 |
+
if previous_root != root
|
| 106 |
+
|| daemon.running_backend_instance(settings).await?.is_some()
|
| 107 |
+
|| crate::client::probe(&daemon.socket_path).await.is_ok()
|
| 108 |
+
{
|
| 109 |
+
return Ok(true);
|
| 110 |
+
}
|
| 111 |
+
if !matches!(root.join("current").symlink_metadata(), Err(error) if error.kind() == std::io::ErrorKind::NotFound)
|
| 112 |
+
|| ["daemon.pid", "daemon.stderr.log", "daemon-updater.pid", "daemon-updater.stderr.log"]
|
| 113 |
+
.iter().any(|name| !matches!(home.join("app-server-daemon").join(name).symlink_metadata(), Err(error) if error.kind() == std::io::ErrorKind::NotFound))
|
| 114 |
+
{
|
| 115 |
+
daemon.ensure_managed_codex_bin()?;
|
| 116 |
+
return Ok(true);
|
| 117 |
+
}
|
| 118 |
+
} else {
|
| 119 |
+
anyhow::ensure!(
|
| 120 |
+
previous_root.join("current").symlink_metadata().is_ok(),
|
| 121 |
+
"no daemon package is selected; run `codex app-server daemon start` first"
|
| 122 |
+
);
|
| 123 |
+
}
|
| 124 |
+
std::fs::create_dir_all(&root)?;
|
| 125 |
+
anyhow::ensure!(
|
| 126 |
+
managed_install::package_root(home) == previous_root,
|
| 127 |
+
"daemon package location changed; retry the command"
|
| 128 |
+
);
|
| 129 |
+
let backend = daemon.running_backend_instance(settings).await?;
|
| 130 |
+
anyhow::ensure!(
|
| 131 |
+
backend.is_some() || crate::client::probe(&daemon.socket_path).await.is_err(),
|
| 132 |
+
"app server is running but is not managed by codex app-server daemon"
|
| 133 |
+
);
|
| 134 |
+
let selected = managed_install::managed_codex_bin(home);
|
| 135 |
+
let previous_release = previous_root.join("current").canonicalize().ok();
|
| 136 |
+
if mode == InstallMode::Missing {
|
| 137 |
+
if selected.is_file() {
|
| 138 |
+
return Ok(true);
|
| 139 |
+
}
|
| 140 |
+
anyhow::ensure!(
|
| 141 |
+
backend.is_none()
|
| 142 |
+
&& matches!(root.join("current").symlink_metadata(), Err(error) if error.kind() == std::io::ErrorKind::NotFound),
|
| 143 |
+
"the selected daemon package is incomplete; repair its installation before starting"
|
| 144 |
+
);
|
| 145 |
+
}
|
| 146 |
+
let source = source.context(
|
| 147 |
+
"this CLI has no complete local package; install a packaged Codex CLI or use the standalone installer",
|
| 148 |
+
)?;
|
| 149 |
+
anyhow::ensure!(
|
| 150 |
+
!root.canonicalize()?.starts_with(source.canonicalize()?),
|
| 151 |
+
"CODEX_HOME must be outside the source CLI package"
|
| 152 |
+
);
|
| 153 |
+
let manifest_bytes = std::fs::read(source.join("codex-package.json"))?;
|
| 154 |
+
let manifest: CodexPackageManifest = serde_json::from_slice(&manifest_bytes)?;
|
| 155 |
+
let version = manifest.version.to_string();
|
| 156 |
+
let target = platform_target()?;
|
| 157 |
+
let metadata: serde_json::Value = serde_json::from_slice(&manifest_bytes)?;
|
| 158 |
+
let entrypoint = if cfg!(windows) {
|
| 159 |
+
"bin/codex.exe"
|
| 160 |
+
} else {
|
| 161 |
+
"bin/codex"
|
| 162 |
+
};
|
| 163 |
+
anyhow::ensure!(
|
| 164 |
+
metadata["target"] == target && metadata["entrypoint"] == entrypoint,
|
| 165 |
+
"the CLI package does not match this platform or executable"
|
| 166 |
+
);
|
| 167 |
+
validate_package(source)?;
|
| 168 |
+
// A local build may replace the executable while confirmation is pending.
|
| 169 |
+
let running_identity = managed_install::executable_identity(running_exe).await?;
|
| 170 |
+
if mode == InstallMode::Replace {
|
| 171 |
+
if !confirm(&InstallRequest {
|
| 172 |
+
source: source.to_path_buf(),
|
| 173 |
+
version: version.clone(),
|
| 174 |
+
destination: root.clone(),
|
| 175 |
+
installed_version: tokio::time::timeout(
|
| 176 |
+
std::time::Duration::from_secs(5),
|
| 177 |
+
managed_install::managed_codex_version(&selected),
|
| 178 |
+
)
|
| 179 |
+
.await
|
| 180 |
+
.ok()
|
| 181 |
+
.and_then(Result::ok),
|
| 182 |
+
restart_required: backend.is_some(),
|
| 183 |
+
})? {
|
| 184 |
+
return Ok(false);
|
| 185 |
+
}
|
| 186 |
+
} else {
|
| 187 |
+
eprintln!(
|
| 188 |
+
"Installing daemon from CLI version {version} into {}...",
|
| 189 |
+
root.display()
|
| 190 |
+
);
|
| 191 |
+
}
|
| 192 |
+
// Confirmation must not block lifecycle commands. Recheck the approved
|
| 193 |
+
// selection and running state once this operation owns both locks.
|
| 194 |
+
let _operation_lock = if mode == InstallMode::Replace {
|
| 195 |
+
Some(daemon.acquire_operation_lock().await?)
|
| 196 |
+
} else {
|
| 197 |
+
None // Initial startup already holds the operation lock.
|
| 198 |
+
};
|
| 199 |
+
let current_settings = if mode == InstallMode::Replace {
|
| 200 |
+
Some(daemon.load_settings().await?)
|
| 201 |
+
} else {
|
| 202 |
+
None
|
| 203 |
+
};
|
| 204 |
+
let settings = current_settings.as_ref().unwrap_or(settings);
|
| 205 |
+
let _install_lock = acquire_install_lock(&root).await?;
|
| 206 |
+
anyhow::ensure!(
|
| 207 |
+
managed_install::package_root(home) == previous_root,
|
| 208 |
+
"daemon package location changed; retry the command"
|
| 209 |
+
);
|
| 210 |
+
if mode == InstallMode::Missing && managed_install::managed_codex_bin(home).is_file() {
|
| 211 |
+
return Ok(true);
|
| 212 |
+
}
|
| 213 |
+
anyhow::ensure!(
|
| 214 |
+
previous_root.join("current").canonicalize().ok() == previous_release,
|
| 215 |
+
"daemon selection changed while awaiting confirmation; retry the command"
|
| 216 |
+
);
|
| 217 |
+
let current_backend = daemon.running_backend_instance(settings).await?;
|
| 218 |
+
anyhow::ensure!(
|
| 219 |
+
current_backend.is_some() == backend.is_some(),
|
| 220 |
+
"daemon running state changed while awaiting confirmation; retry the command"
|
| 221 |
+
);
|
| 222 |
+
let backend = current_backend;
|
| 223 |
+
let stable = stable_version(&version).is_some();
|
| 224 |
+
let releases = root.join("releases");
|
| 225 |
+
std::fs::create_dir_all(&releases)?;
|
| 226 |
+
let stage = tempfile::Builder::new()
|
| 227 |
+
.prefix(".staging.")
|
| 228 |
+
.tempdir_in(&releases)?;
|
| 229 |
+
let digest = package_tree(source, Some(stage.path()))?;
|
| 230 |
+
validate_package(stage.path())?;
|
| 231 |
+
let staged_exe = if cfg!(target_os = "macos") && stage.path().join("CodexCLI.app").is_dir() {
|
| 232 |
+
stage.path().join("CodexCLI.app/Contents/MacOS/codex")
|
| 233 |
+
} else {
|
| 234 |
+
stage.path().join(entrypoint)
|
| 235 |
+
};
|
| 236 |
+
anyhow::ensure!(
|
| 237 |
+
package_tree(source, /*destination*/ None)? == digest
|
| 238 |
+
&& std::fs::read(stage.path().join("codex-package.json"))? == manifest_bytes
|
| 239 |
+
&& managed_install::executable_identity(&staged_exe).await? == running_identity,
|
| 240 |
+
"the CLI package changed while preparing the daemon or differs from the running executable"
|
| 241 |
+
);
|
| 242 |
+
let binary_version =
|
| 243 |
+
managed_install::managed_codex_version(&stage.path().join(entrypoint)).await?;
|
| 244 |
+
anyhow::ensure!(
|
| 245 |
+
!stable || version == binary_version,
|
| 246 |
+
"the CLI package version does not match its executable"
|
| 247 |
+
);
|
| 248 |
+
let name = if stable && mode == InstallMode::Missing {
|
| 249 |
+
format!("{version}-{target}")
|
| 250 |
+
} else {
|
| 251 |
+
format!("local-{digest}-{target}")
|
| 252 |
+
};
|
| 253 |
+
let release = releases.join(&name);
|
| 254 |
+
if release.try_exists()? {
|
| 255 |
+
anyhow::ensure!(
|
| 256 |
+
!release.symlink_metadata()?.file_type().is_symlink()
|
| 257 |
+
&& package_tree(&release, /*destination*/ None)? == digest,
|
| 258 |
+
"an existing daemon release has different contents; refusing to overwrite it"
|
| 259 |
+
);
|
| 260 |
+
} else {
|
| 261 |
+
#[cfg(unix)]
|
| 262 |
+
if !stage.path().join("codex").exists() {
|
| 263 |
+
std::os::unix::fs::symlink("bin/codex", stage.path().join("codex"))?;
|
| 264 |
+
}
|
| 265 |
+
std::fs::rename(stage.path(), &release)?;
|
| 266 |
+
}
|
| 267 |
+
let standalone = home.join("packages/standalone");
|
| 268 |
+
let canonical_source = source.canonicalize()?;
|
| 269 |
+
let follows_latest = mode == InstallMode::Missing
|
| 270 |
+
&& stable
|
| 271 |
+
&& (standalone.join("current").canonicalize().ok().as_deref()
|
| 272 |
+
!= Some(canonical_source.as_path())
|
| 273 |
+
|| std::fs::read_to_string(standalone.join("auto-update-version"))
|
| 274 |
+
.ok()
|
| 275 |
+
.as_deref()
|
| 276 |
+
== canonical_source.file_name().and_then(|name| name.to_str()));
|
| 277 |
+
anyhow::ensure!(
|
| 278 |
+
managed_install::package_root(home) == previous_root
|
| 279 |
+
&& (backend.is_some() || crate::client::probe(&daemon.socket_path).await.is_err()),
|
| 280 |
+
"daemon package location or socket ownership changed while preparing its package; retry the command"
|
| 281 |
+
);
|
| 282 |
+
#[cfg(windows)]
|
| 283 |
+
if backend.is_some() {
|
| 284 |
+
crate::backend::windows::ensure_detached_launch(&release.join(entrypoint))?;
|
| 285 |
+
}
|
| 286 |
+
#[cfg(windows)]
|
| 287 |
+
windows::validate_selection(&root)?;
|
| 288 |
+
if mode == InstallMode::Replace {
|
| 289 |
+
let stopped = async {
|
| 290 |
+
crate::backend::pid_update_loop_backend(daemon.backend_paths(settings))
|
| 291 |
+
.stop()
|
| 292 |
+
.await?;
|
| 293 |
+
anyhow::ensure!(
|
| 294 |
+
managed_install::package_root(home) == previous_root
|
| 295 |
+
&& previous_root.join("current").canonicalize().ok() == previous_release,
|
| 296 |
+
"daemon selection changed while preparing its package; retry the command"
|
| 297 |
+
);
|
| 298 |
+
if let Some(backend) = &backend {
|
| 299 |
+
if let Err(error) = crate::thread_recovery::discard_pending(daemon) {
|
| 300 |
+
eprintln!(
|
| 301 |
+
"warning: failed to clear stale daemon recovery before replacement: {error}"
|
| 302 |
+
);
|
| 303 |
+
}
|
| 304 |
+
backend
|
| 305 |
+
.stop_with_grace(settings.shutdown_grace_seconds)
|
| 306 |
+
.await?;
|
| 307 |
+
}
|
| 308 |
+
Ok::<(), anyhow::Error>(())
|
| 309 |
+
}
|
| 310 |
+
.await;
|
| 311 |
+
if let Err(error) = stopped {
|
| 312 |
+
if let Err(restore_error) = async {
|
| 313 |
+
daemon
|
| 314 |
+
.current_installation()?
|
| 315 |
+
.ensure_managed_updater(settings)
|
| 316 |
+
.await
|
| 317 |
+
}
|
| 318 |
+
.await
|
| 319 |
+
{
|
| 320 |
+
eprintln!(
|
| 321 |
+
"warning: failed to restore the daemon updater after replacement failed: {restore_error:#}"
|
| 322 |
+
);
|
| 323 |
+
}
|
| 324 |
+
return Err(error);
|
| 325 |
+
}
|
| 326 |
+
}
|
| 327 |
+
let marker = root.join("auto-update-version");
|
| 328 |
+
if follows_latest {
|
| 329 |
+
let temporary = tempfile::NamedTempFile::new_in(&root)?;
|
| 330 |
+
std::fs::write(temporary.path(), &name)?;
|
| 331 |
+
temporary.persist(marker)?;
|
| 332 |
+
} else if marker.exists() {
|
| 333 |
+
std::fs::remove_file(marker)?;
|
| 334 |
+
}
|
| 335 |
+
#[cfg(unix)]
|
| 336 |
+
{
|
| 337 |
+
let temporary = tempfile::TempDir::new_in(&root)?;
|
| 338 |
+
let link = temporary.path().join("current");
|
| 339 |
+
std::os::unix::fs::symlink(&release, &link)?;
|
| 340 |
+
std::fs::rename(link, root.join("current"))?;
|
| 341 |
+
}
|
| 342 |
+
#[cfg(windows)]
|
| 343 |
+
windows::select_release(&root, &release)?;
|
| 344 |
+
if backend.is_some() {
|
| 345 |
+
let selected = Daemon {
|
| 346 |
+
pid_file: daemon.pid_file.with_file_name(crate::DAEMON_PID_FILE_NAME),
|
| 347 |
+
update_pid_file: daemon
|
| 348 |
+
.update_pid_file
|
| 349 |
+
.with_file_name(crate::DAEMON_UPDATE_PID_FILE_NAME),
|
| 350 |
+
managed_codex_bin: root.join("current").join(entrypoint),
|
| 351 |
+
..daemon.clone()
|
| 352 |
+
};
|
| 353 |
+
selected.start_managed_backend(settings).await.context(
|
| 354 |
+
"daemon package selected but could not start; retry with `codex app-server daemon start`",
|
| 355 |
+
)?;
|
| 356 |
+
selected.wait_until_ready().await?;
|
| 357 |
+
}
|
| 358 |
+
Ok(true)
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
/// Hash the complete tree and optionally copy those same bytes. Relative file
|
| 362 |
+
/// links are materialized; escaping links and directory links are rejected.
|
| 363 |
+
fn package_tree(root: &Path, destination: Option<&Path>) -> Result<String> {
|
| 364 |
+
let canonical_root = root.canonicalize()?;
|
| 365 |
+
let mut hasher = blake3::Hasher::new();
|
| 366 |
+
let mut paths = vec![root.to_path_buf()];
|
| 367 |
+
while let Some(path) = paths.pop() {
|
| 368 |
+
let relative = path.strip_prefix(root)?;
|
| 369 |
+
// The Unix installer adds this alias outside the package layout.
|
| 370 |
+
if cfg!(unix)
|
| 371 |
+
&& relative == Path::new("codex")
|
| 372 |
+
&& std::fs::read_link(&path).ok().as_deref() == Some(Path::new("bin/codex"))
|
| 373 |
+
{
|
| 374 |
+
continue;
|
| 375 |
+
}
|
| 376 |
+
anyhow::ensure!(
|
| 377 |
+
path.canonicalize()?.starts_with(&canonical_root),
|
| 378 |
+
"package link escapes its root"
|
| 379 |
+
);
|
| 380 |
+
let metadata = path.metadata()?;
|
| 381 |
+
hasher.update(relative.as_os_str().as_encoded_bytes());
|
| 382 |
+
hasher.update(&[0]);
|
| 383 |
+
if metadata.is_dir() {
|
| 384 |
+
anyhow::ensure!(
|
| 385 |
+
!path.symlink_metadata()?.file_type().is_symlink(),
|
| 386 |
+
"package contains a directory link"
|
| 387 |
+
);
|
| 388 |
+
hasher.update(b"directory");
|
| 389 |
+
if let Some(destination) = destination
|
| 390 |
+
&& !relative.as_os_str().is_empty()
|
| 391 |
+
{
|
| 392 |
+
std::fs::create_dir(destination.join(relative))?;
|
| 393 |
+
}
|
| 394 |
+
let mut entries = std::fs::read_dir(&path)?
|
| 395 |
+
.map(|entry| entry.map(|entry| entry.path()))
|
| 396 |
+
.collect::<std::io::Result<Vec<_>>>()?;
|
| 397 |
+
entries.sort();
|
| 398 |
+
paths.extend(entries);
|
| 399 |
+
} else {
|
| 400 |
+
anyhow::ensure!(metadata.is_file(), "package contains an unsupported file");
|
| 401 |
+
let bytes = std::fs::read(&path)?;
|
| 402 |
+
hasher.update(b"file");
|
| 403 |
+
hasher.update(blake3::hash(&bytes).as_bytes());
|
| 404 |
+
#[cfg(unix)]
|
| 405 |
+
{
|
| 406 |
+
use std::os::unix::fs::PermissionsExt;
|
| 407 |
+
hasher.update(&(metadata.permissions().mode() & 0o777).to_le_bytes());
|
| 408 |
+
}
|
| 409 |
+
if let Some(destination) = destination {
|
| 410 |
+
let target = destination.join(relative);
|
| 411 |
+
std::fs::write(&target, bytes)?;
|
| 412 |
+
std::fs::set_permissions(target, metadata.permissions())?;
|
| 413 |
+
}
|
| 414 |
+
}
|
| 415 |
+
}
|
| 416 |
+
Ok(hasher.finalize().to_hex().to_string())
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
fn stable_version(value: &str) -> Option<semver::Version> {
|
| 420 |
+
let version = semver::Version::parse(value).ok()?;
|
| 421 |
+
(version.pre.is_empty()
|
| 422 |
+
&& version.build.is_empty()
|
| 423 |
+
&& (version.major, version.minor, version.patch) != (0, 0, 0))
|
| 424 |
+
.then_some(version)
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
fn validate_package(root: &Path) -> Result<()> {
|
| 428 |
+
let mut names = vec![
|
| 429 |
+
"codex-package.json",
|
| 430 |
+
if cfg!(windows) {
|
| 431 |
+
"bin/codex.exe"
|
| 432 |
+
} else {
|
| 433 |
+
"bin/codex"
|
| 434 |
+
},
|
| 435 |
+
if cfg!(windows) {
|
| 436 |
+
"bin/codex-code-mode-host.exe"
|
| 437 |
+
} else {
|
| 438 |
+
"bin/codex-code-mode-host"
|
| 439 |
+
},
|
| 440 |
+
if cfg!(windows) {
|
| 441 |
+
"codex-path/rg.exe"
|
| 442 |
+
} else {
|
| 443 |
+
"codex-path/rg"
|
| 444 |
+
},
|
| 445 |
+
];
|
| 446 |
+
if cfg!(windows) {
|
| 447 |
+
names.extend([
|
| 448 |
+
"codex-resources/codex-command-runner.exe",
|
| 449 |
+
"codex-resources/codex-windows-sandbox-setup.exe",
|
| 450 |
+
]);
|
| 451 |
+
} else if cfg!(target_os = "linux") {
|
| 452 |
+
names.push("codex-resources/bwrap");
|
| 453 |
+
}
|
| 454 |
+
for name in names {
|
| 455 |
+
if !root.join(name).is_file() {
|
| 456 |
+
return Err(anyhow!(
|
| 457 |
+
"local Codex package is missing {name}; reinstall the CLI or use the standalone installer"
|
| 458 |
+
));
|
| 459 |
+
}
|
| 460 |
+
#[cfg(unix)]
|
| 461 |
+
if name != "codex-package.json" {
|
| 462 |
+
use std::os::unix::fs::PermissionsExt;
|
| 463 |
+
if std::fs::metadata(root.join(name))?.permissions().mode() & 0o111 == 0 {
|
| 464 |
+
return Err(anyhow!(
|
| 465 |
+
"local Codex package file {name} is not executable; reinstall the CLI or use the standalone installer"
|
| 466 |
+
));
|
| 467 |
+
}
|
| 468 |
+
}
|
| 469 |
+
}
|
| 470 |
+
Ok(())
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
fn platform_target() -> Result<&'static str> {
|
| 474 |
+
match (std::env::consts::OS, std::env::consts::ARCH) {
|
| 475 |
+
("macos", "aarch64") => Ok("aarch64-apple-darwin"),
|
| 476 |
+
("macos", "x86_64") => Ok("x86_64-apple-darwin"),
|
| 477 |
+
("linux", "aarch64") if cfg!(target_env = "gnu") => Ok("aarch64-unknown-linux-gnu"),
|
| 478 |
+
("linux", "aarch64") => Ok("aarch64-unknown-linux-musl"),
|
| 479 |
+
("linux", "x86_64") if cfg!(target_env = "gnu") => Ok("x86_64-unknown-linux-gnu"),
|
| 480 |
+
("linux", "x86_64") => Ok("x86_64-unknown-linux-musl"),
|
| 481 |
+
("windows", "aarch64") => Ok("aarch64-pc-windows-msvc"),
|
| 482 |
+
("windows", "x86_64") => Ok("x86_64-pc-windows-msvc"),
|
| 483 |
+
(os, arch) => Err(anyhow!("unsupported packaged daemon platform {os}/{arch}")),
|
| 484 |
+
}
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
#[cfg(windows)]
|
| 488 |
+
#[path = "prepare_install_windows.rs"]
|
| 489 |
+
mod windows;
|
| 490 |
+
|
| 491 |
+
#[cfg(test)]
|
| 492 |
+
#[path = "prepare_install_tests.rs"]
|
| 493 |
+
mod tests;
|
codex-rs/app-server-daemon/src/prepare_install_tests.rs
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Covers complete-package staging, conservative selection, and legacy preservation.
|
| 2 |
+
#![cfg(unix)]
|
| 3 |
+
use super::InstallMode;
|
| 4 |
+
use super::prepare_from_package;
|
| 5 |
+
use super::validate_package;
|
| 6 |
+
use crate::settings::DaemonSettings;
|
| 7 |
+
use pretty_assertions::assert_eq;
|
| 8 |
+
use std::os::unix::fs::PermissionsExt;
|
| 9 |
+
use std::path::Path;
|
| 10 |
+
use std::path::PathBuf;
|
| 11 |
+
|
| 12 |
+
fn daemon(home: &std::path::Path) -> crate::Daemon {
|
| 13 |
+
let state = home.join("app-server-daemon");
|
| 14 |
+
crate::Daemon {
|
| 15 |
+
socket_path: state.join("app-server.sock"),
|
| 16 |
+
pid_file: state.join("app-server.pid"),
|
| 17 |
+
update_pid_file: state.join("app-server-updater.pid"),
|
| 18 |
+
operation_lock_file: state.join("daemon.lock"),
|
| 19 |
+
settings_file: state.join("settings.json"),
|
| 20 |
+
managed_codex_bin: crate::managed_install::managed_codex_bin(home),
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
fn package(root: &Path, version: &str) -> PathBuf {
|
| 25 |
+
let target = super::platform_target().expect("target");
|
| 26 |
+
for dir in ["bin", "codex-path", "codex-resources/nested"] {
|
| 27 |
+
std::fs::create_dir_all(root.join(dir)).expect("package directory");
|
| 28 |
+
}
|
| 29 |
+
let bin = root.join("bin/codex");
|
| 30 |
+
std::fs::write(&bin, format!("#!/bin/sh\necho 'codex {version}'\n")).expect("codex executable");
|
| 31 |
+
for file in [
|
| 32 |
+
"bin/codex-code-mode-host",
|
| 33 |
+
"codex-path/rg",
|
| 34 |
+
"codex-resources/nested/runtime",
|
| 35 |
+
] {
|
| 36 |
+
std::fs::write(root.join(file), b"runtime").expect("package file");
|
| 37 |
+
if file != "codex-resources/nested/runtime" {
|
| 38 |
+
std::fs::set_permissions(root.join(file), std::fs::Permissions::from_mode(0o755))
|
| 39 |
+
.expect("executable helper");
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
if cfg!(target_os = "linux") {
|
| 43 |
+
std::fs::write(root.join("codex-resources/bwrap"), b"runtime").expect("bwrap");
|
| 44 |
+
std::fs::set_permissions(
|
| 45 |
+
root.join("codex-resources/bwrap"),
|
| 46 |
+
std::fs::Permissions::from_mode(0o755),
|
| 47 |
+
)
|
| 48 |
+
.expect("executable bwrap");
|
| 49 |
+
}
|
| 50 |
+
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))
|
| 51 |
+
.expect("executable permission");
|
| 52 |
+
std::fs::write(
|
| 53 |
+
root.join("codex-package.json"),
|
| 54 |
+
serde_json::json!({
|
| 55 |
+
"version": version, "target": target, "entrypoint": "bin/codex"
|
| 56 |
+
})
|
| 57 |
+
.to_string(),
|
| 58 |
+
)
|
| 59 |
+
.expect("manifest");
|
| 60 |
+
bin
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
#[tokio::test]
|
| 64 |
+
async fn seeds_full_package() {
|
| 65 |
+
let temp = tempfile::TempDir::new().expect("temp");
|
| 66 |
+
let home = temp.path().join("home");
|
| 67 |
+
let daemon = daemon(&home);
|
| 68 |
+
let settings = DaemonSettings::default();
|
| 69 |
+
let old = temp.path().join("old");
|
| 70 |
+
let old_bin = package(&old, "0.152.0");
|
| 71 |
+
prepare_from_package(
|
| 72 |
+
&daemon,
|
| 73 |
+
&settings,
|
| 74 |
+
InstallMode::Missing,
|
| 75 |
+
Some(&old),
|
| 76 |
+
&old_bin,
|
| 77 |
+
|_| Ok(true),
|
| 78 |
+
)
|
| 79 |
+
.await
|
| 80 |
+
.expect("seed");
|
| 81 |
+
|
| 82 |
+
let standalone = home.join("packages/app-server-daemon");
|
| 83 |
+
let selected = std::fs::canonicalize(standalone.join("current")).expect("selected");
|
| 84 |
+
assert_eq!(
|
| 85 |
+
std::fs::read(selected.join("codex-resources/nested/runtime")).expect("runtime"),
|
| 86 |
+
b"runtime"
|
| 87 |
+
);
|
| 88 |
+
assert_eq!(
|
| 89 |
+
std::fs::read_to_string(standalone.join("auto-update-version")).expect("marker"),
|
| 90 |
+
selected.file_name().expect("name").to_string_lossy()
|
| 91 |
+
);
|
| 92 |
+
assert!(validate_package(&selected).is_ok());
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
#[tokio::test]
|
| 96 |
+
async fn incomplete_source_fails_without_selecting_it() {
|
| 97 |
+
let temp = tempfile::TempDir::new().expect("temp");
|
| 98 |
+
let source = temp.path().join("package");
|
| 99 |
+
let bin = package(&source, "0.152.0");
|
| 100 |
+
std::fs::remove_file(source.join("bin/codex-code-mode-host")).expect("remove helper");
|
| 101 |
+
let home = temp.path().join("home");
|
| 102 |
+
let error = prepare_from_package(
|
| 103 |
+
&daemon(&home),
|
| 104 |
+
&DaemonSettings::default(),
|
| 105 |
+
InstallMode::Missing,
|
| 106 |
+
Some(&source),
|
| 107 |
+
&bin,
|
| 108 |
+
|_| Ok(true),
|
| 109 |
+
)
|
| 110 |
+
.await
|
| 111 |
+
.expect_err("incomplete package");
|
| 112 |
+
assert!(error.to_string().contains("bin/codex-code-mode-host"));
|
| 113 |
+
assert!(!home.join("packages/app-server-daemon/current").exists());
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
#[cfg(target_os = "macos")]
|
| 117 |
+
#[tokio::test]
|
| 118 |
+
async fn provisioned_macos_bundle_seeds_from_its_running_executable() {
|
| 119 |
+
let temp = tempfile::TempDir::new().expect("temp");
|
| 120 |
+
let source = temp.path().join("package");
|
| 121 |
+
let launcher = package(&source, "0.1.0-internal-test.202609091200.1");
|
| 122 |
+
std::fs::write(&launcher, b"#!/bin/sh\necho codex 0.0.0\n").expect("launcher");
|
| 123 |
+
let bundle = source.join("CodexCLI.app/Contents/MacOS/codex");
|
| 124 |
+
std::fs::create_dir_all(bundle.parent().expect("bundle parent")).expect("bundle dir");
|
| 125 |
+
std::fs::write(&bundle, b"provisioned executable").expect("bundle executable");
|
| 126 |
+
let home = temp.path().join("home");
|
| 127 |
+
prepare_from_package(
|
| 128 |
+
&daemon(&home),
|
| 129 |
+
&DaemonSettings::default(),
|
| 130 |
+
InstallMode::Missing,
|
| 131 |
+
Some(&source),
|
| 132 |
+
&bundle,
|
| 133 |
+
|_| Ok(true),
|
| 134 |
+
)
|
| 135 |
+
.await
|
| 136 |
+
.expect("seed provisioned bundle");
|
| 137 |
+
let selected = std::fs::canonicalize(home.join("packages/app-server-daemon/current"))
|
| 138 |
+
.expect("selected release");
|
| 139 |
+
assert_eq!(
|
| 140 |
+
std::fs::read(selected.join("CodexCLI.app/Contents/MacOS/codex"))
|
| 141 |
+
.expect("bundled executable"),
|
| 142 |
+
b"provisioned executable"
|
| 143 |
+
);
|
| 144 |
+
assert!(
|
| 145 |
+
!home
|
| 146 |
+
.join("packages/app-server-daemon/auto-update-version")
|
| 147 |
+
.exists()
|
| 148 |
+
);
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
#[tokio::test]
|
| 152 |
+
async fn legacy_selection_is_not_migrated_or_refreshed() {
|
| 153 |
+
let temp = tempfile::TempDir::new().unwrap();
|
| 154 |
+
let home = temp.path().join("home");
|
| 155 |
+
let legacy = home.join("packages/standalone");
|
| 156 |
+
let bin = package(&legacy.join("releases/old"), "0.150.0");
|
| 157 |
+
std::os::unix::fs::symlink("releases/old", legacy.join("current")).unwrap();
|
| 158 |
+
let state = home.join("app-server-daemon");
|
| 159 |
+
std::fs::create_dir(&state).unwrap();
|
| 160 |
+
std::fs::write(state.join("app-server.stderr.log"), b"").unwrap();
|
| 161 |
+
let source = temp.path().join("new");
|
| 162 |
+
let new_bin = package(&source, "0.160.0");
|
| 163 |
+
prepare_from_package(
|
| 164 |
+
&daemon(&home),
|
| 165 |
+
&DaemonSettings::default(),
|
| 166 |
+
InstallMode::Missing,
|
| 167 |
+
Some(&source),
|
| 168 |
+
&new_bin,
|
| 169 |
+
|_| Ok(true),
|
| 170 |
+
)
|
| 171 |
+
.await
|
| 172 |
+
.unwrap();
|
| 173 |
+
assert_eq!(
|
| 174 |
+
crate::managed_install::managed_codex_bin(&home)
|
| 175 |
+
.canonicalize()
|
| 176 |
+
.unwrap(),
|
| 177 |
+
bin.canonicalize().unwrap()
|
| 178 |
+
);
|
| 179 |
+
assert!(!home.join("packages/app-server-daemon").exists());
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
#[tokio::test]
|
| 183 |
+
async fn standalone_seed_preserves_explicit_pin_or_latest_channel() {
|
| 184 |
+
for follows_latest in [false, true] {
|
| 185 |
+
let temp = tempfile::TempDir::new().unwrap();
|
| 186 |
+
let home = temp.path().join("home");
|
| 187 |
+
let standalone = home.join("packages/standalone");
|
| 188 |
+
let source = standalone.join("releases/0.152.0-local-target");
|
| 189 |
+
let bin = package(&source, "0.152.0");
|
| 190 |
+
std::os::unix::fs::symlink(&source, standalone.join("current")).unwrap();
|
| 191 |
+
if follows_latest {
|
| 192 |
+
std::fs::write(
|
| 193 |
+
standalone.join("auto-update-version"),
|
| 194 |
+
"0.152.0-local-target",
|
| 195 |
+
)
|
| 196 |
+
.unwrap();
|
| 197 |
+
}
|
| 198 |
+
prepare_from_package(
|
| 199 |
+
&daemon(&home),
|
| 200 |
+
&DaemonSettings::default(),
|
| 201 |
+
InstallMode::Missing,
|
| 202 |
+
Some(&source),
|
| 203 |
+
&bin,
|
| 204 |
+
|_| Ok(true),
|
| 205 |
+
)
|
| 206 |
+
.await
|
| 207 |
+
.unwrap();
|
| 208 |
+
let root = home.join("packages/app-server-daemon");
|
| 209 |
+
let selected = root.join("current").canonicalize().unwrap();
|
| 210 |
+
assert_eq!(
|
| 211 |
+
std::fs::read_to_string(root.join("auto-update-version")).ok(),
|
| 212 |
+
follows_latest.then(|| selected.file_name().unwrap().to_string_lossy().into_owned())
|
| 213 |
+
);
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
#[tokio::test]
|
| 218 |
+
async fn explicit_selection_requires_unchanged_cli_and_pins_all_versions() {
|
| 219 |
+
for legacy in [false, true] {
|
| 220 |
+
let temp = tempfile::TempDir::new().unwrap();
|
| 221 |
+
let home = temp.path().join("home");
|
| 222 |
+
let source = temp.path().join("source");
|
| 223 |
+
let bin = package(&source, "0.152.0");
|
| 224 |
+
let settings = DaemonSettings::default();
|
| 225 |
+
let initial = daemon(&home);
|
| 226 |
+
prepare_from_package(
|
| 227 |
+
&initial,
|
| 228 |
+
&settings,
|
| 229 |
+
InstallMode::Missing,
|
| 230 |
+
Some(&source),
|
| 231 |
+
&bin,
|
| 232 |
+
|_| Ok(true),
|
| 233 |
+
)
|
| 234 |
+
.await
|
| 235 |
+
.unwrap();
|
| 236 |
+
if legacy {
|
| 237 |
+
std::fs::rename(
|
| 238 |
+
home.join("packages/app-server-daemon"),
|
| 239 |
+
home.join("packages/standalone"),
|
| 240 |
+
)
|
| 241 |
+
.unwrap();
|
| 242 |
+
let root = home.join("packages/standalone");
|
| 243 |
+
let name = std::fs::read_to_string(root.join("auto-update-version")).unwrap();
|
| 244 |
+
std::fs::remove_file(root.join("current")).unwrap();
|
| 245 |
+
std::os::unix::fs::symlink(root.join("releases").join(name), root.join("current"))
|
| 246 |
+
.unwrap();
|
| 247 |
+
let state = home.join("app-server-daemon");
|
| 248 |
+
std::fs::create_dir_all(&state).unwrap();
|
| 249 |
+
std::fs::write(state.join("app-server.stderr.log"), b"").unwrap();
|
| 250 |
+
}
|
| 251 |
+
let initial_root = crate::managed_install::package_root(&home);
|
| 252 |
+
let legacy_release = initial_root.join("current").canonicalize().unwrap();
|
| 253 |
+
let legacy_marker = std::fs::read(initial_root.join("auto-update-version")).unwrap();
|
| 254 |
+
let error = prepare_from_package(
|
| 255 |
+
&daemon(&home),
|
| 256 |
+
&settings,
|
| 257 |
+
InstallMode::Replace,
|
| 258 |
+
Some(&source),
|
| 259 |
+
&bin,
|
| 260 |
+
|_| {
|
| 261 |
+
let mut rebuilt = std::fs::read(&bin)?;
|
| 262 |
+
rebuilt.extend_from_slice(b"# rebuilt during confirmation\n");
|
| 263 |
+
std::fs::write(&bin, rebuilt)?;
|
| 264 |
+
Ok(true)
|
| 265 |
+
},
|
| 266 |
+
)
|
| 267 |
+
.await
|
| 268 |
+
.expect_err("reject a CLI rebuilt during confirmation");
|
| 269 |
+
assert!(
|
| 270 |
+
error
|
| 271 |
+
.to_string()
|
| 272 |
+
.contains("differs from the running executable")
|
| 273 |
+
);
|
| 274 |
+
assert_eq!(
|
| 275 |
+
initial_root.join("current").canonicalize().unwrap(),
|
| 276 |
+
legacy_release
|
| 277 |
+
);
|
| 278 |
+
assert_eq!(
|
| 279 |
+
std::fs::read(initial_root.join("auto-update-version")).unwrap(),
|
| 280 |
+
legacy_marker
|
| 281 |
+
);
|
| 282 |
+
let root = home.join("packages/app-server-daemon");
|
| 283 |
+
let mut previous = legacy_release.clone();
|
| 284 |
+
for version in [
|
| 285 |
+
"0.153.0",
|
| 286 |
+
"0.151.0",
|
| 287 |
+
"0.151.0",
|
| 288 |
+
"0.154.0-alpha.1",
|
| 289 |
+
"0.0.0",
|
| 290 |
+
"0.0.0",
|
| 291 |
+
] {
|
| 292 |
+
let daemon = daemon(&home);
|
| 293 |
+
let previous_root = crate::managed_install::package_root(&home);
|
| 294 |
+
package(&source, version);
|
| 295 |
+
std::fs::write(
|
| 296 |
+
source.join("codex-resources/nested/runtime"),
|
| 297 |
+
previous.to_string_lossy().as_bytes(),
|
| 298 |
+
)
|
| 299 |
+
.unwrap();
|
| 300 |
+
let before = std::fs::read(previous.join("bin/codex")).unwrap();
|
| 301 |
+
assert!(
|
| 302 |
+
!prepare_from_package(
|
| 303 |
+
&daemon,
|
| 304 |
+
&settings,
|
| 305 |
+
InstallMode::Replace,
|
| 306 |
+
Some(&source),
|
| 307 |
+
&bin,
|
| 308 |
+
|request| {
|
| 309 |
+
assert_eq!(request.destination, root);
|
| 310 |
+
Ok(false)
|
| 311 |
+
}
|
| 312 |
+
)
|
| 313 |
+
.await
|
| 314 |
+
.unwrap()
|
| 315 |
+
);
|
| 316 |
+
assert_eq!(crate::managed_install::package_root(&home), previous_root);
|
| 317 |
+
assert_eq!(
|
| 318 |
+
previous_root.join("current").canonicalize().unwrap(),
|
| 319 |
+
previous
|
| 320 |
+
);
|
| 321 |
+
prepare_from_package(
|
| 322 |
+
&daemon,
|
| 323 |
+
&settings,
|
| 324 |
+
InstallMode::Replace,
|
| 325 |
+
Some(&source),
|
| 326 |
+
&bin,
|
| 327 |
+
|_| Ok(true),
|
| 328 |
+
)
|
| 329 |
+
.await
|
| 330 |
+
.unwrap();
|
| 331 |
+
let selected = root.join("current").canonicalize().unwrap();
|
| 332 |
+
assert_eq!(crate::managed_install::package_root(&home), root);
|
| 333 |
+
if legacy {
|
| 334 |
+
assert_eq!(
|
| 335 |
+
initial_root.join("current").canonicalize().unwrap(),
|
| 336 |
+
legacy_release
|
| 337 |
+
);
|
| 338 |
+
assert_eq!(
|
| 339 |
+
std::fs::read(initial_root.join("auto-update-version")).unwrap(),
|
| 340 |
+
legacy_marker
|
| 341 |
+
);
|
| 342 |
+
}
|
| 343 |
+
assert_ne!(selected, previous);
|
| 344 |
+
assert_eq!(
|
| 345 |
+
std::fs::read(selected.join("bin/codex")).unwrap(),
|
| 346 |
+
std::fs::read(&bin).unwrap()
|
| 347 |
+
);
|
| 348 |
+
assert_eq!(std::fs::read(previous.join("bin/codex")).unwrap(), before);
|
| 349 |
+
assert!(!root.join("auto-update-version").exists());
|
| 350 |
+
assert!(daemon.running_backend(&settings).await.unwrap().is_none());
|
| 351 |
+
previous = selected;
|
| 352 |
+
}
|
| 353 |
+
}
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
#[tokio::test]
|
| 357 |
+
async fn broken_selection_is_not_a_missing_installation() {
|
| 358 |
+
let temp = tempfile::TempDir::new().unwrap();
|
| 359 |
+
let home = temp.path().join("home");
|
| 360 |
+
let source = temp.path().join("source");
|
| 361 |
+
let bin = package(&source, "0.152.0");
|
| 362 |
+
let daemon = daemon(&home);
|
| 363 |
+
let current = home.join("packages/app-server-daemon/current");
|
| 364 |
+
std::fs::create_dir_all(current.parent().unwrap()).unwrap();
|
| 365 |
+
std::os::unix::fs::symlink("missing-release", ¤t).unwrap();
|
| 366 |
+
let error = prepare_from_package(
|
| 367 |
+
&daemon,
|
| 368 |
+
&DaemonSettings::default(),
|
| 369 |
+
InstallMode::Missing,
|
| 370 |
+
Some(&source),
|
| 371 |
+
&bin,
|
| 372 |
+
|_| panic!("broken installation must not request replacement"),
|
| 373 |
+
)
|
| 374 |
+
.await
|
| 375 |
+
.unwrap_err();
|
| 376 |
+
assert!(
|
| 377 |
+
error
|
| 378 |
+
.to_string()
|
| 379 |
+
.contains("repair the existing installation")
|
| 380 |
+
);
|
| 381 |
+
assert_eq!(
|
| 382 |
+
std::fs::read_link(current).unwrap(),
|
| 383 |
+
PathBuf::from("missing-release")
|
| 384 |
+
);
|
| 385 |
+
}
|
codex-rs/app-server-daemon/src/prepare_install_windows.rs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Selects a Windows managed release by creating or retargeting the installer junction.
|
| 2 |
+
|
| 3 |
+
use std::os::windows::ffi::OsStrExt;
|
| 4 |
+
use std::os::windows::fs::OpenOptionsExt;
|
| 5 |
+
use std::os::windows::io::AsRawHandle;
|
| 6 |
+
use std::path::Path;
|
| 7 |
+
|
| 8 |
+
use anyhow::Context;
|
| 9 |
+
use anyhow::Result;
|
| 10 |
+
use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS;
|
| 11 |
+
use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT;
|
| 12 |
+
use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE;
|
| 13 |
+
use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ;
|
| 14 |
+
use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE;
|
| 15 |
+
use windows_sys::Win32::System::IO::DeviceIoControl;
|
| 16 |
+
|
| 17 |
+
pub(super) fn select_release(root: &Path, release: &Path) -> Result<()> {
|
| 18 |
+
let release = release.canonicalize()?;
|
| 19 |
+
let current = root.join("current");
|
| 20 |
+
if current.symlink_metadata().is_err() {
|
| 21 |
+
let temporary = tempfile::TempDir::new_in(root)?;
|
| 22 |
+
let junction = temporary.path().join("current");
|
| 23 |
+
std::fs::create_dir(&junction)?;
|
| 24 |
+
retarget_junction(&junction, &release)?;
|
| 25 |
+
std::fs::rename(junction, ¤t)?;
|
| 26 |
+
return Ok(());
|
| 27 |
+
}
|
| 28 |
+
validate_selection(root)?;
|
| 29 |
+
retarget_junction(¤t, &release)
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
pub(super) fn validate_selection(root: &Path) -> Result<()> {
|
| 33 |
+
let current = root.join("current");
|
| 34 |
+
if matches!(current.symlink_metadata(), Err(error) if error.kind() == std::io::ErrorKind::NotFound)
|
| 35 |
+
{
|
| 36 |
+
return Ok(());
|
| 37 |
+
}
|
| 38 |
+
anyhow::ensure!(
|
| 39 |
+
current.canonicalize()?.parent() == Some(root.join("releases").canonicalize()?.as_path()),
|
| 40 |
+
"refusing to replace a daemon selection outside its releases directory"
|
| 41 |
+
);
|
| 42 |
+
Ok(())
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
fn retarget_junction(current: &Path, release: &Path) -> Result<()> {
|
| 46 |
+
// Use the same mount-point reparse operation as the standalone installer.
|
| 47 |
+
// Retargeting in place keeps current available to concurrent readers.
|
| 48 |
+
const FSCTL_SET_REPARSE_POINT: u32 = 0x0009_00a4;
|
| 49 |
+
const IO_REPARSE_TAG_MOUNT_POINT: u32 = 0xa000_0003;
|
| 50 |
+
const GENERIC_WRITE: u32 = 0x4000_0000;
|
| 51 |
+
let release: Vec<u16> = release.as_os_str().encode_wide().collect();
|
| 52 |
+
let prefix: Vec<u16> = r"\\?\".encode_utf16().collect();
|
| 53 |
+
let release = release.strip_prefix(prefix.as_slice()).unwrap_or(&release);
|
| 54 |
+
let substitute: Vec<u8> = r"\??\"
|
| 55 |
+
.encode_utf16()
|
| 56 |
+
.chain(release.iter().copied())
|
| 57 |
+
.flat_map(u16::to_le_bytes)
|
| 58 |
+
.collect();
|
| 59 |
+
u16::try_from(substitute.len() + 20).context("managed release path is too long")?;
|
| 60 |
+
let length = substitute.len() as u16;
|
| 61 |
+
// REPARSE_DATA_BUFFER: an 8-byte header, then four u16 byte offsets/lengths
|
| 62 |
+
// for substitute and print names. The UTF-16 path buffer starts at byte 16;
|
| 63 |
+
// both names have a trailing NUL, and the print name is empty.
|
| 64 |
+
let mut data = vec![0; substitute.len() + 20];
|
| 65 |
+
data[0..4].copy_from_slice(&IO_REPARSE_TAG_MOUNT_POINT.to_le_bytes());
|
| 66 |
+
data[4..6].copy_from_slice(&(length + 12).to_le_bytes());
|
| 67 |
+
data[10..12].copy_from_slice(&length.to_le_bytes());
|
| 68 |
+
data[12..14].copy_from_slice(&(length + 2).to_le_bytes());
|
| 69 |
+
data[16..16 + substitute.len()].copy_from_slice(&substitute);
|
| 70 |
+
let handle = std::fs::OpenOptions::new()
|
| 71 |
+
.access_mode(GENERIC_WRITE)
|
| 72 |
+
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
|
| 73 |
+
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
|
| 74 |
+
.open(current)?;
|
| 75 |
+
let mut returned = 0;
|
| 76 |
+
if unsafe {
|
| 77 |
+
DeviceIoControl(
|
| 78 |
+
handle.as_raw_handle() as isize,
|
| 79 |
+
FSCTL_SET_REPARSE_POINT,
|
| 80 |
+
data.as_ptr().cast(),
|
| 81 |
+
data.len() as u32,
|
| 82 |
+
std::ptr::null_mut(),
|
| 83 |
+
0,
|
| 84 |
+
&mut returned,
|
| 85 |
+
std::ptr::null_mut(),
|
| 86 |
+
)
|
| 87 |
+
} == 0
|
| 88 |
+
{
|
| 89 |
+
return Err(std::io::Error::last_os_error())
|
| 90 |
+
.context("failed to retarget managed daemon junction");
|
| 91 |
+
}
|
| 92 |
+
Ok(())
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
#[cfg(test)]
|
| 96 |
+
#[path = "prepare_install_windows_tests.rs"]
|
| 97 |
+
mod tests;
|
codex-rs/app-server-daemon/src/prepare_install_windows_tests.rs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Verifies Windows selection without requiring an elevated daemon.
|
| 2 |
+
use pretty_assertions::assert_eq;
|
| 3 |
+
|
| 4 |
+
#[cfg(windows)]
|
| 5 |
+
#[test]
|
| 6 |
+
fn daemon_junction_can_be_created_and_retargeted_without_cli_links() {
|
| 7 |
+
let home = tempfile::TempDir::new().unwrap();
|
| 8 |
+
let root = home.path().join("packages/app-server-daemon");
|
| 9 |
+
for version in ["first", "second"] {
|
| 10 |
+
let release = root.join("releases").join(version);
|
| 11 |
+
std::fs::create_dir_all(&release).unwrap();
|
| 12 |
+
super::select_release(&root, &release).unwrap();
|
| 13 |
+
assert_eq!(
|
| 14 |
+
root.join("current").canonicalize().unwrap(),
|
| 15 |
+
release.canonicalize().unwrap()
|
| 16 |
+
);
|
| 17 |
+
}
|
| 18 |
+
assert!(!home.path().join("packages/standalone").exists());
|
| 19 |
+
}
|
codex-rs/app-server-daemon/src/remote_control_client.rs
ADDED
|
@@ -0,0 +1,770 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::path::Path;
|
| 2 |
+
use std::time::Duration;
|
| 3 |
+
|
| 4 |
+
use anyhow::Context;
|
| 5 |
+
use anyhow::Result;
|
| 6 |
+
use anyhow::anyhow;
|
| 7 |
+
use codex_app_server_protocol::JSONRPCMessage;
|
| 8 |
+
use codex_app_server_protocol::JSONRPCNotification;
|
| 9 |
+
use codex_app_server_protocol::JSONRPCRequest;
|
| 10 |
+
use codex_app_server_protocol::RemoteControlConnectionStatus;
|
| 11 |
+
use codex_app_server_protocol::RemoteControlDisableParams;
|
| 12 |
+
use codex_app_server_protocol::RemoteControlDisableResponse;
|
| 13 |
+
use codex_app_server_protocol::RemoteControlEnableParams;
|
| 14 |
+
use codex_app_server_protocol::RemoteControlEnableResponse;
|
| 15 |
+
use codex_app_server_protocol::RemoteControlPairingStartParams;
|
| 16 |
+
use codex_app_server_protocol::RemoteControlPairingStartResponse;
|
| 17 |
+
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
|
| 18 |
+
use codex_app_server_protocol::RequestId;
|
| 19 |
+
use serde::de::DeserializeOwned;
|
| 20 |
+
use tokio::io::AsyncRead;
|
| 21 |
+
use tokio::io::AsyncWrite;
|
| 22 |
+
use tokio::time::Instant;
|
| 23 |
+
use tokio::time::sleep;
|
| 24 |
+
use tokio::time::timeout;
|
| 25 |
+
use tokio_tungstenite::WebSocketStream;
|
| 26 |
+
|
| 27 |
+
use crate::RemoteControlReadyStatus;
|
| 28 |
+
use crate::client;
|
| 29 |
+
|
| 30 |
+
const REMOTE_CONTROL_READY_TIMEOUT: Duration = Duration::from_secs(10);
|
| 31 |
+
const REMOTE_CONTROL_REQUEST_ID: RequestId = RequestId::Integer(2);
|
| 32 |
+
const INVALID_PARAMS_ERROR_CODE: i64 = -32602;
|
| 33 |
+
|
| 34 |
+
enum RemoteControlRpcResponse<T> {
|
| 35 |
+
Success(T),
|
| 36 |
+
InvalidParams,
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
pub(crate) async fn enable_remote_control(socket_path: &Path) -> Result<RemoteControlReadyStatus> {
|
| 40 |
+
let mut websocket = client::connect(socket_path).await?;
|
| 41 |
+
enable_remote_control_with_timeout(&mut websocket, REMOTE_CONTROL_READY_TIMEOUT).await
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
pub(crate) async fn disable_remote_control(socket_path: &Path) -> Result<RemoteControlReadyStatus> {
|
| 45 |
+
let mut websocket = client::connect(socket_path).await?;
|
| 46 |
+
initialize_client(&mut websocket).await?;
|
| 47 |
+
let params = serde_json::to_value(RemoteControlDisableParams { ephemeral: true })?;
|
| 48 |
+
let response: RemoteControlDisableResponse = request_remote_control_with_legacy_fallback(
|
| 49 |
+
&mut websocket,
|
| 50 |
+
"remoteControl/disable",
|
| 51 |
+
params,
|
| 52 |
+
)
|
| 53 |
+
.await?;
|
| 54 |
+
websocket.close(None).await.ok();
|
| 55 |
+
Ok(RemoteControlReadyStatus::from(response))
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
pub(crate) async fn start_pairing(socket_path: &Path) -> Result<RemoteControlPairingStartResponse> {
|
| 59 |
+
let mut websocket = client::connect(socket_path).await?;
|
| 60 |
+
initialize_client(&mut websocket).await?;
|
| 61 |
+
let params = serde_json::to_value(RemoteControlPairingStartParams { manual_code: true })?;
|
| 62 |
+
send_remote_control_request(
|
| 63 |
+
&mut websocket,
|
| 64 |
+
REMOTE_CONTROL_REQUEST_ID.clone(),
|
| 65 |
+
"remoteControl/pairing/start",
|
| 66 |
+
Some(params),
|
| 67 |
+
)
|
| 68 |
+
.await?;
|
| 69 |
+
let response = match read_remote_control_response(
|
| 70 |
+
&mut websocket,
|
| 71 |
+
&REMOTE_CONTROL_REQUEST_ID,
|
| 72 |
+
"remoteControl/pairing/start",
|
| 73 |
+
)
|
| 74 |
+
.await?
|
| 75 |
+
{
|
| 76 |
+
RemoteControlRpcResponse::Success(response) => response,
|
| 77 |
+
RemoteControlRpcResponse::InvalidParams => {
|
| 78 |
+
return Err(anyhow!(
|
| 79 |
+
"remoteControl/pairing/start rejected manual pairing parameters"
|
| 80 |
+
));
|
| 81 |
+
}
|
| 82 |
+
};
|
| 83 |
+
websocket.close(None).await.ok();
|
| 84 |
+
Ok(response)
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
pub(crate) async fn enable_remote_control_with_connect_retry(
|
| 88 |
+
socket_path: &Path,
|
| 89 |
+
connect_timeout: Duration,
|
| 90 |
+
connect_retry_delay: Duration,
|
| 91 |
+
) -> Result<RemoteControlReadyStatus> {
|
| 92 |
+
let mut websocket =
|
| 93 |
+
connect_with_retry(socket_path, connect_timeout, connect_retry_delay).await?;
|
| 94 |
+
enable_remote_control_with_timeout(&mut websocket, REMOTE_CONTROL_READY_TIMEOUT).await
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
async fn enable_remote_control_with_timeout<S>(
|
| 98 |
+
websocket: &mut WebSocketStream<S>,
|
| 99 |
+
ready_timeout: Duration,
|
| 100 |
+
) -> Result<RemoteControlReadyStatus>
|
| 101 |
+
where
|
| 102 |
+
S: AsyncRead + AsyncWrite + Unpin,
|
| 103 |
+
{
|
| 104 |
+
initialize_client(websocket).await?;
|
| 105 |
+
|
| 106 |
+
let response: RemoteControlEnableResponse = request_remote_control_with_legacy_fallback(
|
| 107 |
+
websocket,
|
| 108 |
+
"remoteControl/enable",
|
| 109 |
+
serde_json::to_value(RemoteControlEnableParams { ephemeral: true })?,
|
| 110 |
+
)
|
| 111 |
+
.await?;
|
| 112 |
+
let mut latest = RemoteControlReadyStatus::from(response);
|
| 113 |
+
if latest.status == RemoteControlConnectionStatus::Connecting {
|
| 114 |
+
latest = wait_for_remote_control_status(websocket, latest, ready_timeout).await?;
|
| 115 |
+
}
|
| 116 |
+
websocket.close(None).await.ok();
|
| 117 |
+
Ok(latest)
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
async fn initialize_client<S>(websocket: &mut WebSocketStream<S>) -> Result<()>
|
| 121 |
+
where
|
| 122 |
+
S: AsyncRead + AsyncWrite + Unpin,
|
| 123 |
+
{
|
| 124 |
+
client::initialize(websocket, /*experimental_api*/ true).await?;
|
| 125 |
+
let initialized = JSONRPCMessage::Notification(JSONRPCNotification {
|
| 126 |
+
method: "initialized".to_string(),
|
| 127 |
+
params: None,
|
| 128 |
+
});
|
| 129 |
+
client::send_message(websocket, &initialized)
|
| 130 |
+
.await
|
| 131 |
+
.context("failed to send initialized notification")
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
async fn send_remote_control_request<S>(
|
| 135 |
+
websocket: &mut WebSocketStream<S>,
|
| 136 |
+
request_id: RequestId,
|
| 137 |
+
method: &str,
|
| 138 |
+
params: Option<serde_json::Value>,
|
| 139 |
+
) -> Result<()>
|
| 140 |
+
where
|
| 141 |
+
S: AsyncRead + AsyncWrite + Unpin,
|
| 142 |
+
{
|
| 143 |
+
let request = JSONRPCMessage::Request(JSONRPCRequest {
|
| 144 |
+
id: request_id,
|
| 145 |
+
method: method.to_string(),
|
| 146 |
+
params,
|
| 147 |
+
trace: None,
|
| 148 |
+
});
|
| 149 |
+
client::send_message(websocket, &request)
|
| 150 |
+
.await
|
| 151 |
+
.with_context(|| format!("failed to send {method} request"))
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
async fn request_remote_control_with_legacy_fallback<S, T>(
|
| 155 |
+
websocket: &mut WebSocketStream<S>,
|
| 156 |
+
method: &str,
|
| 157 |
+
params: serde_json::Value,
|
| 158 |
+
) -> Result<T>
|
| 159 |
+
where
|
| 160 |
+
S: AsyncRead + AsyncWrite + Unpin,
|
| 161 |
+
T: DeserializeOwned,
|
| 162 |
+
{
|
| 163 |
+
send_remote_control_request(
|
| 164 |
+
websocket,
|
| 165 |
+
REMOTE_CONTROL_REQUEST_ID.clone(),
|
| 166 |
+
method,
|
| 167 |
+
Some(params),
|
| 168 |
+
)
|
| 169 |
+
.await?;
|
| 170 |
+
match read_remote_control_response(websocket, &REMOTE_CONTROL_REQUEST_ID, method).await? {
|
| 171 |
+
RemoteControlRpcResponse::Success(response) => Ok(response),
|
| 172 |
+
RemoteControlRpcResponse::InvalidParams => {
|
| 173 |
+
send_remote_control_request(
|
| 174 |
+
websocket,
|
| 175 |
+
REMOTE_CONTROL_REQUEST_ID.clone(),
|
| 176 |
+
method,
|
| 177 |
+
/*params*/ None,
|
| 178 |
+
)
|
| 179 |
+
.await?;
|
| 180 |
+
match read_remote_control_response(websocket, &REMOTE_CONTROL_REQUEST_ID, method)
|
| 181 |
+
.await?
|
| 182 |
+
{
|
| 183 |
+
RemoteControlRpcResponse::Success(response) => Ok(response),
|
| 184 |
+
RemoteControlRpcResponse::InvalidParams => {
|
| 185 |
+
Err(anyhow!("{method} rejected legacy params"))
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
async fn connect_with_retry(
|
| 193 |
+
socket_path: &Path,
|
| 194 |
+
connect_timeout: Duration,
|
| 195 |
+
connect_retry_delay: Duration,
|
| 196 |
+
) -> Result<WebSocketStream<codex_uds::UnixStream>> {
|
| 197 |
+
let deadline = Instant::now() + connect_timeout;
|
| 198 |
+
loop {
|
| 199 |
+
match client::connect(socket_path).await {
|
| 200 |
+
Ok(websocket) => return Ok(websocket),
|
| 201 |
+
Err(_) if Instant::now() < deadline => {
|
| 202 |
+
sleep(connect_retry_delay).await;
|
| 203 |
+
}
|
| 204 |
+
Err(error) => {
|
| 205 |
+
return Err(error).with_context(|| {
|
| 206 |
+
format!(
|
| 207 |
+
"app server did not become ready on {}",
|
| 208 |
+
socket_path.display()
|
| 209 |
+
)
|
| 210 |
+
});
|
| 211 |
+
}
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
async fn read_remote_control_response<S, T>(
|
| 217 |
+
websocket: &mut WebSocketStream<S>,
|
| 218 |
+
request_id: &RequestId,
|
| 219 |
+
method: &str,
|
| 220 |
+
) -> Result<RemoteControlRpcResponse<T>>
|
| 221 |
+
where
|
| 222 |
+
S: AsyncRead + AsyncWrite + Unpin,
|
| 223 |
+
T: DeserializeOwned,
|
| 224 |
+
{
|
| 225 |
+
loop {
|
| 226 |
+
let message = timeout(
|
| 227 |
+
client::CONTROL_SOCKET_RESPONSE_TIMEOUT,
|
| 228 |
+
client::read_message(websocket),
|
| 229 |
+
)
|
| 230 |
+
.await
|
| 231 |
+
.with_context(|| format!("timed out waiting for {method} response"))??;
|
| 232 |
+
match message {
|
| 233 |
+
JSONRPCMessage::Response(response) if response.id == *request_id => {
|
| 234 |
+
let response = serde_json::from_value::<T>(response.result)
|
| 235 |
+
.with_context(|| format!("failed to parse {method} response"))?;
|
| 236 |
+
return Ok(RemoteControlRpcResponse::Success(response));
|
| 237 |
+
}
|
| 238 |
+
JSONRPCMessage::Error(err)
|
| 239 |
+
if err.id == *request_id && err.error.code == INVALID_PARAMS_ERROR_CODE =>
|
| 240 |
+
{
|
| 241 |
+
return Ok(RemoteControlRpcResponse::InvalidParams);
|
| 242 |
+
}
|
| 243 |
+
JSONRPCMessage::Error(err) if err.id == *request_id => {
|
| 244 |
+
return Err(anyhow!("{method} failed: {}", err.error.message));
|
| 245 |
+
}
|
| 246 |
+
JSONRPCMessage::Notification(notification)
|
| 247 |
+
if remote_control_status_notification(¬ification).is_some() =>
|
| 248 |
+
{
|
| 249 |
+
continue;
|
| 250 |
+
}
|
| 251 |
+
_ => {}
|
| 252 |
+
}
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
async fn wait_for_remote_control_status<S>(
|
| 257 |
+
websocket: &mut WebSocketStream<S>,
|
| 258 |
+
mut latest: RemoteControlReadyStatus,
|
| 259 |
+
ready_timeout: Duration,
|
| 260 |
+
) -> Result<RemoteControlReadyStatus>
|
| 261 |
+
where
|
| 262 |
+
S: AsyncRead + AsyncWrite + Unpin,
|
| 263 |
+
{
|
| 264 |
+
let deadline = tokio::time::Instant::now() + ready_timeout;
|
| 265 |
+
while tokio::time::Instant::now() < deadline {
|
| 266 |
+
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
| 267 |
+
let message = match timeout(remaining, client::read_message(websocket)).await {
|
| 268 |
+
Ok(Ok(message)) => message,
|
| 269 |
+
Ok(Err(err)) => return Err(err),
|
| 270 |
+
Err(_) => {
|
| 271 |
+
latest.timed_out = true;
|
| 272 |
+
return Ok(latest);
|
| 273 |
+
}
|
| 274 |
+
};
|
| 275 |
+
let JSONRPCMessage::Notification(notification) = message else {
|
| 276 |
+
continue;
|
| 277 |
+
};
|
| 278 |
+
let Some(status) = remote_control_status_notification(¬ification) else {
|
| 279 |
+
continue;
|
| 280 |
+
};
|
| 281 |
+
latest = RemoteControlReadyStatus::from(status);
|
| 282 |
+
if latest.status != RemoteControlConnectionStatus::Connecting {
|
| 283 |
+
return Ok(latest);
|
| 284 |
+
}
|
| 285 |
+
}
|
| 286 |
+
latest.timed_out = true;
|
| 287 |
+
Ok(latest)
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
fn remote_control_status_notification(
|
| 291 |
+
notification: &JSONRPCNotification,
|
| 292 |
+
) -> Option<RemoteControlStatusChangedNotification> {
|
| 293 |
+
if notification.method != "remoteControl/status/changed" {
|
| 294 |
+
return None;
|
| 295 |
+
}
|
| 296 |
+
let params = notification.params.clone()?;
|
| 297 |
+
serde_json::from_value(params).ok()
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
impl From<RemoteControlEnableResponse> for RemoteControlReadyStatus {
|
| 301 |
+
fn from(response: RemoteControlEnableResponse) -> Self {
|
| 302 |
+
let RemoteControlEnableResponse {
|
| 303 |
+
status,
|
| 304 |
+
server_name,
|
| 305 |
+
installation_id: _,
|
| 306 |
+
environment_id,
|
| 307 |
+
} = response;
|
| 308 |
+
Self {
|
| 309 |
+
status,
|
| 310 |
+
server_name,
|
| 311 |
+
environment_id,
|
| 312 |
+
timed_out: false,
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
impl From<RemoteControlDisableResponse> for RemoteControlReadyStatus {
|
| 318 |
+
fn from(response: RemoteControlDisableResponse) -> Self {
|
| 319 |
+
let RemoteControlDisableResponse {
|
| 320 |
+
status,
|
| 321 |
+
server_name,
|
| 322 |
+
installation_id: _,
|
| 323 |
+
environment_id,
|
| 324 |
+
} = response;
|
| 325 |
+
Self {
|
| 326 |
+
status,
|
| 327 |
+
server_name,
|
| 328 |
+
environment_id,
|
| 329 |
+
timed_out: false,
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
impl From<RemoteControlStatusChangedNotification> for RemoteControlReadyStatus {
|
| 335 |
+
fn from(notification: RemoteControlStatusChangedNotification) -> Self {
|
| 336 |
+
let RemoteControlStatusChangedNotification {
|
| 337 |
+
status,
|
| 338 |
+
server_name,
|
| 339 |
+
installation_id: _,
|
| 340 |
+
environment_id,
|
| 341 |
+
} = notification;
|
| 342 |
+
Self {
|
| 343 |
+
status,
|
| 344 |
+
server_name,
|
| 345 |
+
environment_id,
|
| 346 |
+
timed_out: false,
|
| 347 |
+
}
|
| 348 |
+
}
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
#[cfg(all(test, unix))]
|
| 352 |
+
mod tests {
|
| 353 |
+
use anyhow::Result;
|
| 354 |
+
use codex_app_server_protocol::JSONRPCError;
|
| 355 |
+
use codex_app_server_protocol::JSONRPCErrorError;
|
| 356 |
+
use codex_app_server_protocol::JSONRPCResponse;
|
| 357 |
+
use codex_uds::UnixListener;
|
| 358 |
+
use pretty_assertions::assert_eq;
|
| 359 |
+
use tempfile::TempDir;
|
| 360 |
+
use tokio_tungstenite::accept_async;
|
| 361 |
+
|
| 362 |
+
use super::*;
|
| 363 |
+
|
| 364 |
+
const INITIALIZE_REQUEST_ID: RequestId = RequestId::Integer(1);
|
| 365 |
+
const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111";
|
| 366 |
+
const TEST_SERVER_NAME: &str = "owen-mbp";
|
| 367 |
+
const TEST_CODEX_HOME: &str = "/tmp/codex-home";
|
| 368 |
+
|
| 369 |
+
#[tokio::test]
|
| 370 |
+
async fn enable_remote_control_uses_connected_enable_response_without_later_notification()
|
| 371 |
+
-> Result<()> {
|
| 372 |
+
let status = run_enable_remote_control_scenario(EnableScenario {
|
| 373 |
+
initial_notification: Some(remote_control_status(
|
| 374 |
+
RemoteControlConnectionStatus::Connected,
|
| 375 |
+
Some("env_test"),
|
| 376 |
+
)),
|
| 377 |
+
enable_response: remote_control_status(
|
| 378 |
+
RemoteControlConnectionStatus::Connected,
|
| 379 |
+
Some("env_test"),
|
| 380 |
+
),
|
| 381 |
+
after_enable_notification: None,
|
| 382 |
+
ready_timeout: Duration::from_millis(20),
|
| 383 |
+
reject_ephemeral_params: false,
|
| 384 |
+
})
|
| 385 |
+
.await?;
|
| 386 |
+
|
| 387 |
+
assert_eq!(
|
| 388 |
+
status,
|
| 389 |
+
RemoteControlReadyStatus {
|
| 390 |
+
status: RemoteControlConnectionStatus::Connected,
|
| 391 |
+
server_name: TEST_SERVER_NAME.to_string(),
|
| 392 |
+
environment_id: Some("env_test".to_string()),
|
| 393 |
+
timed_out: false,
|
| 394 |
+
}
|
| 395 |
+
);
|
| 396 |
+
Ok(())
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
#[tokio::test]
|
| 400 |
+
async fn enable_remote_control_waits_for_connected_notification() -> Result<()> {
|
| 401 |
+
let status = run_enable_remote_control_scenario(EnableScenario {
|
| 402 |
+
initial_notification: None,
|
| 403 |
+
enable_response: remote_control_status(
|
| 404 |
+
RemoteControlConnectionStatus::Connecting,
|
| 405 |
+
/*environment_id*/ None,
|
| 406 |
+
),
|
| 407 |
+
after_enable_notification: Some(remote_control_status(
|
| 408 |
+
RemoteControlConnectionStatus::Connected,
|
| 409 |
+
Some("env_test"),
|
| 410 |
+
)),
|
| 411 |
+
ready_timeout: Duration::from_secs(1),
|
| 412 |
+
reject_ephemeral_params: false,
|
| 413 |
+
})
|
| 414 |
+
.await?;
|
| 415 |
+
|
| 416 |
+
assert_eq!(
|
| 417 |
+
status,
|
| 418 |
+
RemoteControlReadyStatus {
|
| 419 |
+
status: RemoteControlConnectionStatus::Connected,
|
| 420 |
+
server_name: TEST_SERVER_NAME.to_string(),
|
| 421 |
+
environment_id: Some("env_test".to_string()),
|
| 422 |
+
timed_out: false,
|
| 423 |
+
}
|
| 424 |
+
);
|
| 425 |
+
Ok(())
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
#[tokio::test]
|
| 429 |
+
async fn enable_remote_control_reports_connecting_after_timeout() -> Result<()> {
|
| 430 |
+
let status = run_enable_remote_control_scenario(EnableScenario {
|
| 431 |
+
initial_notification: None,
|
| 432 |
+
enable_response: remote_control_status(
|
| 433 |
+
RemoteControlConnectionStatus::Connecting,
|
| 434 |
+
/*environment_id*/ None,
|
| 435 |
+
),
|
| 436 |
+
after_enable_notification: None,
|
| 437 |
+
ready_timeout: Duration::from_millis(20),
|
| 438 |
+
reject_ephemeral_params: false,
|
| 439 |
+
})
|
| 440 |
+
.await?;
|
| 441 |
+
|
| 442 |
+
assert_eq!(
|
| 443 |
+
status,
|
| 444 |
+
RemoteControlReadyStatus {
|
| 445 |
+
status: RemoteControlConnectionStatus::Connecting,
|
| 446 |
+
server_name: TEST_SERVER_NAME.to_string(),
|
| 447 |
+
environment_id: None,
|
| 448 |
+
timed_out: true,
|
| 449 |
+
}
|
| 450 |
+
);
|
| 451 |
+
Ok(())
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
#[tokio::test]
|
| 455 |
+
async fn enable_remote_control_returns_errored_enable_response() -> Result<()> {
|
| 456 |
+
let status = run_enable_remote_control_scenario(EnableScenario {
|
| 457 |
+
initial_notification: None,
|
| 458 |
+
enable_response: remote_control_status(
|
| 459 |
+
RemoteControlConnectionStatus::Errored,
|
| 460 |
+
/*environment_id*/ None,
|
| 461 |
+
),
|
| 462 |
+
after_enable_notification: None,
|
| 463 |
+
ready_timeout: Duration::from_millis(20),
|
| 464 |
+
reject_ephemeral_params: false,
|
| 465 |
+
})
|
| 466 |
+
.await?;
|
| 467 |
+
|
| 468 |
+
assert_eq!(
|
| 469 |
+
status,
|
| 470 |
+
RemoteControlReadyStatus {
|
| 471 |
+
status: RemoteControlConnectionStatus::Errored,
|
| 472 |
+
server_name: TEST_SERVER_NAME.to_string(),
|
| 473 |
+
environment_id: None,
|
| 474 |
+
timed_out: false,
|
| 475 |
+
}
|
| 476 |
+
);
|
| 477 |
+
Ok(())
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
#[tokio::test]
|
| 481 |
+
async fn enable_remote_control_retries_without_params_for_older_servers() -> Result<()> {
|
| 482 |
+
let status = run_enable_remote_control_scenario(EnableScenario {
|
| 483 |
+
initial_notification: None,
|
| 484 |
+
enable_response: remote_control_status(
|
| 485 |
+
RemoteControlConnectionStatus::Connected,
|
| 486 |
+
Some("env_test"),
|
| 487 |
+
),
|
| 488 |
+
after_enable_notification: None,
|
| 489 |
+
ready_timeout: Duration::from_millis(20),
|
| 490 |
+
reject_ephemeral_params: true,
|
| 491 |
+
})
|
| 492 |
+
.await?;
|
| 493 |
+
|
| 494 |
+
assert_eq!(
|
| 495 |
+
status,
|
| 496 |
+
RemoteControlReadyStatus {
|
| 497 |
+
status: RemoteControlConnectionStatus::Connected,
|
| 498 |
+
server_name: TEST_SERVER_NAME.to_string(),
|
| 499 |
+
environment_id: Some("env_test".to_string()),
|
| 500 |
+
timed_out: false,
|
| 501 |
+
}
|
| 502 |
+
);
|
| 503 |
+
Ok(())
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
#[tokio::test]
|
| 507 |
+
async fn disable_remote_control_retries_without_params_for_older_servers() -> Result<()> {
|
| 508 |
+
let dir = TempDir::new()?;
|
| 509 |
+
let socket_path = dir.path().join("app-server.sock");
|
| 510 |
+
let listener = UnixListener::bind(&socket_path).await?;
|
| 511 |
+
let server_task = tokio::spawn(async move {
|
| 512 |
+
let mut websocket = accept_initialized_client(listener).await?;
|
| 513 |
+
let disable = client::read_message(&mut websocket).await?;
|
| 514 |
+
let JSONRPCMessage::Request(disable) = disable else {
|
| 515 |
+
panic!("expected remoteControl/disable request");
|
| 516 |
+
};
|
| 517 |
+
assert_eq!(disable.id, REMOTE_CONTROL_REQUEST_ID);
|
| 518 |
+
assert_eq!(disable.method, "remoteControl/disable");
|
| 519 |
+
assert_eq!(
|
| 520 |
+
disable.params,
|
| 521 |
+
Some(serde_json::json!({ "ephemeral": true }))
|
| 522 |
+
);
|
| 523 |
+
client::send_message(
|
| 524 |
+
&mut websocket,
|
| 525 |
+
&JSONRPCMessage::Error(JSONRPCError {
|
| 526 |
+
id: REMOTE_CONTROL_REQUEST_ID,
|
| 527 |
+
error: JSONRPCErrorError {
|
| 528 |
+
code: INVALID_PARAMS_ERROR_CODE,
|
| 529 |
+
message: "Invalid params".to_string(),
|
| 530 |
+
data: None,
|
| 531 |
+
},
|
| 532 |
+
}),
|
| 533 |
+
)
|
| 534 |
+
.await?;
|
| 535 |
+
let fallback = client::read_message(&mut websocket).await?;
|
| 536 |
+
let JSONRPCMessage::Request(fallback) = fallback else {
|
| 537 |
+
panic!("expected fallback remoteControl/disable request");
|
| 538 |
+
};
|
| 539 |
+
assert_eq!(fallback.id, REMOTE_CONTROL_REQUEST_ID);
|
| 540 |
+
assert_eq!(fallback.method, "remoteControl/disable");
|
| 541 |
+
assert_eq!(fallback.params, None);
|
| 542 |
+
client::send_message(
|
| 543 |
+
&mut websocket,
|
| 544 |
+
&JSONRPCMessage::Response(JSONRPCResponse {
|
| 545 |
+
id: REMOTE_CONTROL_REQUEST_ID,
|
| 546 |
+
result: serde_json::to_value(RemoteControlDisableResponse::from(
|
| 547 |
+
remote_control_status(
|
| 548 |
+
RemoteControlConnectionStatus::Disabled,
|
| 549 |
+
/*environment_id*/ None,
|
| 550 |
+
),
|
| 551 |
+
))?,
|
| 552 |
+
}),
|
| 553 |
+
)
|
| 554 |
+
.await?;
|
| 555 |
+
Ok::<_, anyhow::Error>(())
|
| 556 |
+
});
|
| 557 |
+
|
| 558 |
+
let status = disable_remote_control(&socket_path).await?;
|
| 559 |
+
server_task.await??;
|
| 560 |
+
assert_eq!(
|
| 561 |
+
status,
|
| 562 |
+
RemoteControlReadyStatus {
|
| 563 |
+
status: RemoteControlConnectionStatus::Disabled,
|
| 564 |
+
server_name: TEST_SERVER_NAME.to_string(),
|
| 565 |
+
environment_id: None,
|
| 566 |
+
timed_out: false,
|
| 567 |
+
}
|
| 568 |
+
);
|
| 569 |
+
Ok(())
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
#[tokio::test]
|
| 573 |
+
async fn start_pairing_requests_manual_code() -> Result<()> {
|
| 574 |
+
let dir = TempDir::new()?;
|
| 575 |
+
let socket_path = dir.path().join("app-server.sock");
|
| 576 |
+
let listener = UnixListener::bind(&socket_path).await?;
|
| 577 |
+
let server_task = tokio::spawn(async move {
|
| 578 |
+
let mut websocket = accept_initialized_client(listener).await?;
|
| 579 |
+
let pairing = client::read_message(&mut websocket).await?;
|
| 580 |
+
let JSONRPCMessage::Request(pairing) = pairing else {
|
| 581 |
+
panic!("expected remoteControl/pairing/start request");
|
| 582 |
+
};
|
| 583 |
+
assert_eq!(pairing.id, REMOTE_CONTROL_REQUEST_ID);
|
| 584 |
+
assert_eq!(pairing.method, "remoteControl/pairing/start");
|
| 585 |
+
assert_eq!(
|
| 586 |
+
pairing.params,
|
| 587 |
+
Some(serde_json::json!({ "manualCode": true }))
|
| 588 |
+
);
|
| 589 |
+
client::send_message(
|
| 590 |
+
&mut websocket,
|
| 591 |
+
&JSONRPCMessage::Response(JSONRPCResponse {
|
| 592 |
+
id: REMOTE_CONTROL_REQUEST_ID,
|
| 593 |
+
result: serde_json::to_value(RemoteControlPairingStartResponse {
|
| 594 |
+
pairing_code: "pairing-code".to_string(),
|
| 595 |
+
manual_pairing_code: Some("ABCD-EFGH".to_string()),
|
| 596 |
+
environment_id: "env_test".to_string(),
|
| 597 |
+
expires_at: 1_700_000_000,
|
| 598 |
+
})?,
|
| 599 |
+
}),
|
| 600 |
+
)
|
| 601 |
+
.await?;
|
| 602 |
+
Ok::<_, anyhow::Error>(())
|
| 603 |
+
});
|
| 604 |
+
|
| 605 |
+
let response = start_pairing(&socket_path).await?;
|
| 606 |
+
server_task.await??;
|
| 607 |
+
assert_eq!(
|
| 608 |
+
response,
|
| 609 |
+
RemoteControlPairingStartResponse {
|
| 610 |
+
pairing_code: "pairing-code".to_string(),
|
| 611 |
+
manual_pairing_code: Some("ABCD-EFGH".to_string()),
|
| 612 |
+
environment_id: "env_test".to_string(),
|
| 613 |
+
expires_at: 1_700_000_000,
|
| 614 |
+
}
|
| 615 |
+
);
|
| 616 |
+
Ok(())
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
struct EnableScenario {
|
| 620 |
+
initial_notification: Option<RemoteControlStatusChangedNotification>,
|
| 621 |
+
enable_response: RemoteControlStatusChangedNotification,
|
| 622 |
+
after_enable_notification: Option<RemoteControlStatusChangedNotification>,
|
| 623 |
+
ready_timeout: Duration,
|
| 624 |
+
reject_ephemeral_params: bool,
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
async fn run_enable_remote_control_scenario(
|
| 628 |
+
scenario: EnableScenario,
|
| 629 |
+
) -> Result<RemoteControlReadyStatus> {
|
| 630 |
+
let dir = TempDir::new()?;
|
| 631 |
+
let socket_path = dir.path().join("app-server.sock");
|
| 632 |
+
let listener = UnixListener::bind(&socket_path).await?;
|
| 633 |
+
let ready_timeout = scenario.ready_timeout;
|
| 634 |
+
let server_task = tokio::spawn(serve_enable_remote_control_scenario(listener, scenario));
|
| 635 |
+
|
| 636 |
+
let mut websocket = client::connect(&socket_path).await?;
|
| 637 |
+
let status = enable_remote_control_with_timeout(&mut websocket, ready_timeout).await?;
|
| 638 |
+
server_task.await??;
|
| 639 |
+
Ok(status)
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
async fn serve_enable_remote_control_scenario(
|
| 643 |
+
listener: UnixListener,
|
| 644 |
+
scenario: EnableScenario,
|
| 645 |
+
) -> Result<()> {
|
| 646 |
+
let mut websocket = accept_initialized_client(listener).await?;
|
| 647 |
+
if let Some(status) = scenario.initial_notification {
|
| 648 |
+
send_remote_control_status(&mut websocket, status).await?;
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
let enable = client::read_message(&mut websocket).await?;
|
| 652 |
+
let JSONRPCMessage::Request(enable) = enable else {
|
| 653 |
+
panic!("expected remoteControl/enable request");
|
| 654 |
+
};
|
| 655 |
+
assert_eq!(enable.id, REMOTE_CONTROL_REQUEST_ID);
|
| 656 |
+
assert_eq!(enable.method, "remoteControl/enable");
|
| 657 |
+
assert_eq!(
|
| 658 |
+
enable.params,
|
| 659 |
+
Some(serde_json::json!({ "ephemeral": true }))
|
| 660 |
+
);
|
| 661 |
+
if scenario.reject_ephemeral_params {
|
| 662 |
+
client::send_message(
|
| 663 |
+
&mut websocket,
|
| 664 |
+
&JSONRPCMessage::Error(JSONRPCError {
|
| 665 |
+
id: REMOTE_CONTROL_REQUEST_ID,
|
| 666 |
+
error: JSONRPCErrorError {
|
| 667 |
+
code: INVALID_PARAMS_ERROR_CODE,
|
| 668 |
+
message: "Invalid params".to_string(),
|
| 669 |
+
data: None,
|
| 670 |
+
},
|
| 671 |
+
}),
|
| 672 |
+
)
|
| 673 |
+
.await?;
|
| 674 |
+
let fallback = client::read_message(&mut websocket).await?;
|
| 675 |
+
let JSONRPCMessage::Request(fallback) = fallback else {
|
| 676 |
+
panic!("expected fallback remoteControl/enable request");
|
| 677 |
+
};
|
| 678 |
+
assert_eq!(fallback.id, REMOTE_CONTROL_REQUEST_ID);
|
| 679 |
+
assert_eq!(fallback.method, "remoteControl/enable");
|
| 680 |
+
assert_eq!(fallback.params, None);
|
| 681 |
+
}
|
| 682 |
+
client::send_message(
|
| 683 |
+
&mut websocket,
|
| 684 |
+
&JSONRPCMessage::Response(JSONRPCResponse {
|
| 685 |
+
id: REMOTE_CONTROL_REQUEST_ID,
|
| 686 |
+
result: serde_json::to_value(RemoteControlEnableResponse::from(
|
| 687 |
+
scenario.enable_response,
|
| 688 |
+
))?,
|
| 689 |
+
}),
|
| 690 |
+
)
|
| 691 |
+
.await?;
|
| 692 |
+
|
| 693 |
+
if let Some(status) = scenario.after_enable_notification {
|
| 694 |
+
send_remote_control_status(&mut websocket, status).await?;
|
| 695 |
+
} else {
|
| 696 |
+
tokio::time::sleep(Duration::from_millis(50)).await;
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
Ok(())
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
async fn accept_initialized_client(
|
| 703 |
+
mut listener: UnixListener,
|
| 704 |
+
) -> Result<WebSocketStream<codex_uds::UnixStream>> {
|
| 705 |
+
let stream = listener.accept().await?;
|
| 706 |
+
let mut websocket = accept_async(stream).await?;
|
| 707 |
+
let initialize = client::read_message(&mut websocket).await?;
|
| 708 |
+
let JSONRPCMessage::Request(initialize) = initialize else {
|
| 709 |
+
panic!("expected initialize request");
|
| 710 |
+
};
|
| 711 |
+
assert_eq!(initialize.id, INITIALIZE_REQUEST_ID);
|
| 712 |
+
assert_eq!(initialize.method, "initialize");
|
| 713 |
+
let Some(initialize_params) = initialize.params else {
|
| 714 |
+
panic!("expected initialize params");
|
| 715 |
+
};
|
| 716 |
+
assert_eq!(
|
| 717 |
+
initialize_params["capabilities"]["experimentalApi"],
|
| 718 |
+
serde_json::Value::Bool(true)
|
| 719 |
+
);
|
| 720 |
+
client::send_message(
|
| 721 |
+
&mut websocket,
|
| 722 |
+
&JSONRPCMessage::Response(JSONRPCResponse {
|
| 723 |
+
id: INITIALIZE_REQUEST_ID,
|
| 724 |
+
result: serde_json::json!({
|
| 725 |
+
"userAgent": "codex_app_server/1.2.3",
|
| 726 |
+
"codexHome": TEST_CODEX_HOME,
|
| 727 |
+
"platformFamily": "unix",
|
| 728 |
+
"platformOs": "macos",
|
| 729 |
+
}),
|
| 730 |
+
}),
|
| 731 |
+
)
|
| 732 |
+
.await?;
|
| 733 |
+
|
| 734 |
+
let initialized = client::read_message(&mut websocket).await?;
|
| 735 |
+
let JSONRPCMessage::Notification(initialized) = initialized else {
|
| 736 |
+
panic!("expected initialized notification");
|
| 737 |
+
};
|
| 738 |
+
assert_eq!(initialized.method, "initialized");
|
| 739 |
+
Ok(websocket)
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
async fn send_remote_control_status<S>(
|
| 743 |
+
websocket: &mut WebSocketStream<S>,
|
| 744 |
+
status: RemoteControlStatusChangedNotification,
|
| 745 |
+
) -> Result<()>
|
| 746 |
+
where
|
| 747 |
+
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
| 748 |
+
{
|
| 749 |
+
client::send_message(
|
| 750 |
+
websocket,
|
| 751 |
+
&JSONRPCMessage::Notification(JSONRPCNotification {
|
| 752 |
+
method: "remoteControl/status/changed".to_string(),
|
| 753 |
+
params: Some(serde_json::to_value(status)?),
|
| 754 |
+
}),
|
| 755 |
+
)
|
| 756 |
+
.await
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
fn remote_control_status(
|
| 760 |
+
status: RemoteControlConnectionStatus,
|
| 761 |
+
environment_id: Option<&str>,
|
| 762 |
+
) -> RemoteControlStatusChangedNotification {
|
| 763 |
+
RemoteControlStatusChangedNotification {
|
| 764 |
+
status,
|
| 765 |
+
server_name: TEST_SERVER_NAME.to_string(),
|
| 766 |
+
installation_id: TEST_INSTALLATION_ID.to_string(),
|
| 767 |
+
environment_id: environment_id.map(str::to_string),
|
| 768 |
+
}
|
| 769 |
+
}
|
| 770 |
+
}
|
codex-rs/app-server-daemon/src/settings.rs
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::path::Path;
|
| 2 |
+
use std::time::Duration;
|
| 3 |
+
|
| 4 |
+
use anyhow::Context;
|
| 5 |
+
use anyhow::Result;
|
| 6 |
+
use anyhow::ensure;
|
| 7 |
+
use serde::Deserialize;
|
| 8 |
+
use serde::de::DeserializeOwned;
|
| 9 |
+
use serde_json::Map;
|
| 10 |
+
use serde_json::Value;
|
| 11 |
+
use tokio::fs;
|
| 12 |
+
|
| 13 |
+
pub(crate) const DEFAULT_UPDATE_INTERVAL_MINUTES: u32 = 60;
|
| 14 |
+
pub(crate) const DEFAULT_SHUTDOWN_GRACE_SECONDS: u32 = 60;
|
| 15 |
+
pub(crate) const MAX_SHUTDOWN_GRACE_SECONDS: u32 = 5 * 60;
|
| 16 |
+
|
| 17 |
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
| 18 |
+
pub(crate) struct DaemonSettings {
|
| 19 |
+
pub(crate) remote_control_enabled: bool,
|
| 20 |
+
pub(crate) auto_update_enabled: bool,
|
| 21 |
+
pub(crate) update_interval_minutes: u32,
|
| 22 |
+
pub(crate) shutdown_grace_seconds: u32,
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
impl Default for DaemonSettings {
|
| 26 |
+
fn default() -> Self {
|
| 27 |
+
Self {
|
| 28 |
+
remote_control_enabled: false,
|
| 29 |
+
auto_update_enabled: true,
|
| 30 |
+
update_interval_minutes: DEFAULT_UPDATE_INTERVAL_MINUTES,
|
| 31 |
+
shutdown_grace_seconds: DEFAULT_SHUTDOWN_GRACE_SECONDS,
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
#[derive(Default, Deserialize)]
|
| 37 |
+
#[serde(rename_all = "camelCase")]
|
| 38 |
+
struct StopSettings {
|
| 39 |
+
shutdown_grace_seconds: Option<u32>,
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
#[derive(Deserialize)]
|
| 43 |
+
#[serde(rename_all = "camelCase")]
|
| 44 |
+
struct StoredSettings {
|
| 45 |
+
#[serde(default)]
|
| 46 |
+
remote_control_enabled: bool,
|
| 47 |
+
#[serde(default = "default_shutdown_grace_seconds")]
|
| 48 |
+
shutdown_grace_seconds: u32,
|
| 49 |
+
#[serde(default)]
|
| 50 |
+
updater: UpdaterSettings,
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
impl Default for StoredSettings {
|
| 54 |
+
fn default() -> Self {
|
| 55 |
+
Self {
|
| 56 |
+
remote_control_enabled: false,
|
| 57 |
+
shutdown_grace_seconds: DEFAULT_SHUTDOWN_GRACE_SECONDS,
|
| 58 |
+
updater: UpdaterSettings::default(),
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
#[derive(Debug, Deserialize)]
|
| 64 |
+
#[serde(rename_all = "camelCase")]
|
| 65 |
+
pub(crate) struct UpdaterSettings {
|
| 66 |
+
#[serde(default = "default_auto_update_enabled")]
|
| 67 |
+
pub(crate) auto_update_enabled: bool,
|
| 68 |
+
#[serde(default = "default_update_interval_minutes")]
|
| 69 |
+
pub(crate) update_interval_minutes: u32,
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
impl Default for UpdaterSettings {
|
| 73 |
+
fn default() -> Self {
|
| 74 |
+
Self {
|
| 75 |
+
auto_update_enabled: default_auto_update_enabled(),
|
| 76 |
+
update_interval_minutes: default_update_interval_minutes(),
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
fn default_auto_update_enabled() -> bool {
|
| 82 |
+
true
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
fn default_update_interval_minutes() -> u32 {
|
| 86 |
+
DEFAULT_UPDATE_INTERVAL_MINUTES
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
fn default_shutdown_grace_seconds() -> u32 {
|
| 90 |
+
DEFAULT_SHUTDOWN_GRACE_SECONDS
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
fn validate_shutdown_grace(seconds: u32) -> Result<()> {
|
| 94 |
+
ensure!(
|
| 95 |
+
seconds <= MAX_SHUTDOWN_GRACE_SECONDS,
|
| 96 |
+
"shutdown grace must be between 0 and {MAX_SHUTDOWN_GRACE_SECONDS} seconds"
|
| 97 |
+
);
|
| 98 |
+
Ok(())
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
impl UpdaterSettings {
|
| 102 |
+
pub(crate) async fn load(settings_file: &Path) -> Result<Self> {
|
| 103 |
+
let settings: StoredSettings = read_settings(settings_file).await?;
|
| 104 |
+
validate_shutdown_grace(settings.shutdown_grace_seconds)?;
|
| 105 |
+
let settings = settings.updater;
|
| 106 |
+
settings.validate()?;
|
| 107 |
+
Ok(settings)
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
pub(crate) fn validate(&self) -> Result<()> {
|
| 111 |
+
ensure!(
|
| 112 |
+
self.update_interval_minutes > 0,
|
| 113 |
+
"update interval must be positive"
|
| 114 |
+
);
|
| 115 |
+
Ok(())
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
pub(crate) fn update_interval(&self, minute: Duration) -> Duration {
|
| 119 |
+
minute * self.update_interval_minutes
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
impl DaemonSettings {
|
| 124 |
+
pub(crate) async fn load(path: &Path) -> Result<Self> {
|
| 125 |
+
let settings: StoredSettings = read_settings(path).await?;
|
| 126 |
+
settings.updater.validate()?;
|
| 127 |
+
validate_shutdown_grace(settings.shutdown_grace_seconds)?;
|
| 128 |
+
Ok(Self {
|
| 129 |
+
remote_control_enabled: settings.remote_control_enabled,
|
| 130 |
+
auto_update_enabled: settings.updater.auto_update_enabled,
|
| 131 |
+
update_interval_minutes: settings.updater.update_interval_minutes,
|
| 132 |
+
shutdown_grace_seconds: settings.shutdown_grace_seconds,
|
| 133 |
+
})
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
pub(crate) async fn load_for_stop(path: &Path) -> Self {
|
| 137 |
+
// Stop must work even when settings are unreadable or partially edited.
|
| 138 |
+
let shutdown_grace_seconds = read_settings::<StopSettings>(path)
|
| 139 |
+
.await
|
| 140 |
+
.ok()
|
| 141 |
+
.and_then(|settings| settings.shutdown_grace_seconds)
|
| 142 |
+
.filter(|&seconds| seconds <= MAX_SHUTDOWN_GRACE_SECONDS)
|
| 143 |
+
.unwrap_or(DEFAULT_SHUTDOWN_GRACE_SECONDS);
|
| 144 |
+
Self {
|
| 145 |
+
shutdown_grace_seconds,
|
| 146 |
+
..Self::default()
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
pub(crate) async fn save(&self, path: &Path) -> Result<()> {
|
| 151 |
+
if let Some(parent) = path.parent() {
|
| 152 |
+
fs::create_dir_all(parent).await.with_context(|| {
|
| 153 |
+
format!(
|
| 154 |
+
"failed to create daemon settings directory {}",
|
| 155 |
+
parent.display()
|
| 156 |
+
)
|
| 157 |
+
})?;
|
| 158 |
+
}
|
| 159 |
+
let mut settings: Map<String, Value> = read_settings(path).await?;
|
| 160 |
+
settings.insert(
|
| 161 |
+
"remoteControlEnabled".to_string(),
|
| 162 |
+
Value::Bool(self.remote_control_enabled),
|
| 163 |
+
);
|
| 164 |
+
let contents =
|
| 165 |
+
serde_json::to_vec_pretty(&settings).context("failed to serialize settings")?;
|
| 166 |
+
let temporary_path = path.with_extension("tmp");
|
| 167 |
+
fs::write(&temporary_path, contents)
|
| 168 |
+
.await
|
| 169 |
+
.with_context(|| {
|
| 170 |
+
format!(
|
| 171 |
+
"failed to write daemon settings {}",
|
| 172 |
+
temporary_path.display()
|
| 173 |
+
)
|
| 174 |
+
})?;
|
| 175 |
+
fs::rename(&temporary_path, path)
|
| 176 |
+
.await
|
| 177 |
+
.with_context(|| format!("failed to replace daemon settings {}", path.display()))
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
async fn read_settings<T: DeserializeOwned + Default>(path: &Path) -> Result<T> {
|
| 182 |
+
let contents = match fs::read_to_string(path).await {
|
| 183 |
+
Ok(contents) => contents,
|
| 184 |
+
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(T::default()),
|
| 185 |
+
Err(err) => {
|
| 186 |
+
return Err(err).with_context(|| format!("failed to read settings {}", path.display()));
|
| 187 |
+
}
|
| 188 |
+
};
|
| 189 |
+
serde_json::from_str(&contents)
|
| 190 |
+
.with_context(|| format!("failed to parse settings {}", path.display()))
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
#[cfg(test)]
|
| 194 |
+
#[path = "settings_tests.rs"]
|
| 195 |
+
mod tests;
|
codex-rs/app-server-daemon/src/settings_tests.rs
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use pretty_assertions::assert_eq;
|
| 2 |
+
use tempfile::TempDir;
|
| 3 |
+
|
| 4 |
+
use super::DaemonSettings;
|
| 5 |
+
use super::MAX_SHUTDOWN_GRACE_SECONDS;
|
| 6 |
+
|
| 7 |
+
#[tokio::test]
|
| 8 |
+
async fn remote_control_save_preserves_updater_settings() {
|
| 9 |
+
let temp = TempDir::new().expect("temp dir");
|
| 10 |
+
let path = temp.path().join("settings.json");
|
| 11 |
+
tokio::fs::write(&path, r#"{"remoteControlEnabled":true}"#)
|
| 12 |
+
.await
|
| 13 |
+
.expect("write legacy settings");
|
| 14 |
+
|
| 15 |
+
let settings = DaemonSettings::load(&path).await.expect("load settings");
|
| 16 |
+
assert_eq!(
|
| 17 |
+
settings,
|
| 18 |
+
DaemonSettings {
|
| 19 |
+
remote_control_enabled: true,
|
| 20 |
+
..DaemonSettings::default()
|
| 21 |
+
}
|
| 22 |
+
);
|
| 23 |
+
|
| 24 |
+
tokio::fs::write(
|
| 25 |
+
&path,
|
| 26 |
+
r#"{"remoteControlEnabled":true,"shutdownGraceSeconds":25,"updater":{"autoUpdateEnabled":false,"updateIntervalMinutes":17},"futureSetting":42}"#,
|
| 27 |
+
)
|
| 28 |
+
.await
|
| 29 |
+
.expect("write settings");
|
| 30 |
+
let updated = DaemonSettings {
|
| 31 |
+
remote_control_enabled: false,
|
| 32 |
+
..DaemonSettings::load(&path)
|
| 33 |
+
.await
|
| 34 |
+
.expect("load updater settings")
|
| 35 |
+
};
|
| 36 |
+
updated.save(&path).await.expect("save remote control");
|
| 37 |
+
assert_eq!(
|
| 38 |
+
serde_json::from_slice::<serde_json::Value>(
|
| 39 |
+
&tokio::fs::read(&path).await.expect("read settings")
|
| 40 |
+
)
|
| 41 |
+
.expect("parse settings"),
|
| 42 |
+
serde_json::json!({
|
| 43 |
+
"remoteControlEnabled": false,
|
| 44 |
+
"shutdownGraceSeconds": 25,
|
| 45 |
+
"updater": {"autoUpdateEnabled": false, "updateIntervalMinutes": 17},
|
| 46 |
+
"futureSetting": 42,
|
| 47 |
+
})
|
| 48 |
+
);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
#[tokio::test]
|
| 52 |
+
async fn shutdown_grace_accepts_zero_through_five_minutes() {
|
| 53 |
+
let temp = TempDir::new().expect("temp dir");
|
| 54 |
+
let path = temp.path().join("settings.json");
|
| 55 |
+
assert_eq!(
|
| 56 |
+
DaemonSettings::load(&path).await.expect("missing settings"),
|
| 57 |
+
DaemonSettings::default()
|
| 58 |
+
);
|
| 59 |
+
assert_eq!(
|
| 60 |
+
DaemonSettings::load_for_stop(&path)
|
| 61 |
+
.await
|
| 62 |
+
.shutdown_grace_seconds,
|
| 63 |
+
60
|
| 64 |
+
);
|
| 65 |
+
|
| 66 |
+
for seconds in [
|
| 67 |
+
0,
|
| 68 |
+
MAX_SHUTDOWN_GRACE_SECONDS,
|
| 69 |
+
MAX_SHUTDOWN_GRACE_SECONDS + 1,
|
| 70 |
+
] {
|
| 71 |
+
tokio::fs::write(
|
| 72 |
+
&path,
|
| 73 |
+
serde_json::to_vec(&serde_json::json!({"shutdownGraceSeconds": seconds}))
|
| 74 |
+
.expect("serialize settings"),
|
| 75 |
+
)
|
| 76 |
+
.await
|
| 77 |
+
.expect("write settings");
|
| 78 |
+
let expected = (seconds <= MAX_SHUTDOWN_GRACE_SECONDS).then_some(seconds);
|
| 79 |
+
assert_eq!(
|
| 80 |
+
DaemonSettings::load(&path)
|
| 81 |
+
.await
|
| 82 |
+
.ok()
|
| 83 |
+
.map(|settings| settings.shutdown_grace_seconds),
|
| 84 |
+
expected
|
| 85 |
+
);
|
| 86 |
+
assert_eq!(
|
| 87 |
+
DaemonSettings::load_for_stop(&path)
|
| 88 |
+
.await
|
| 89 |
+
.shutdown_grace_seconds,
|
| 90 |
+
expected.unwrap_or(60)
|
| 91 |
+
);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
tokio::fs::write(&path, r#"{"shutdownGraceSeconds":"unlimited"}"#)
|
| 95 |
+
.await
|
| 96 |
+
.expect("write invalid settings");
|
| 97 |
+
assert!(DaemonSettings::load(&path).await.is_err());
|
| 98 |
+
assert_eq!(
|
| 99 |
+
DaemonSettings::load_for_stop(&path)
|
| 100 |
+
.await
|
| 101 |
+
.shutdown_grace_seconds,
|
| 102 |
+
60
|
| 103 |
+
);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
#[tokio::test]
|
| 107 |
+
async fn update_interval_accepts_long_values_and_rejects_zero() {
|
| 108 |
+
let temp = TempDir::new().expect("temp dir");
|
| 109 |
+
let path = temp.path().join("settings.json");
|
| 110 |
+
for minutes in [u32::MAX, 0] {
|
| 111 |
+
tokio::fs::write(
|
| 112 |
+
&path,
|
| 113 |
+
serde_json::to_vec(&serde_json::json!({
|
| 114 |
+
"updater": {"updateIntervalMinutes": minutes},
|
| 115 |
+
}))
|
| 116 |
+
.expect("serialize invalid settings"),
|
| 117 |
+
)
|
| 118 |
+
.await
|
| 119 |
+
.expect("write invalid settings");
|
| 120 |
+
let loaded = DaemonSettings::load(&path).await;
|
| 121 |
+
if minutes == 0 {
|
| 122 |
+
assert!(loaded.is_err());
|
| 123 |
+
} else {
|
| 124 |
+
assert_eq!(
|
| 125 |
+
loaded.expect("load long interval").update_interval_minutes,
|
| 126 |
+
minutes
|
| 127 |
+
);
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
}
|
codex-rs/app-server-daemon/src/thread_recovery.rs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Removes stale snapshots before planned daemon replacements.
|
| 2 |
+
|
| 3 |
+
use std::io::ErrorKind;
|
| 4 |
+
|
| 5 |
+
use anyhow::Context;
|
| 6 |
+
use anyhow::Result;
|
| 7 |
+
|
| 8 |
+
use crate::Daemon;
|
| 9 |
+
|
| 10 |
+
pub(crate) fn discard_pending(daemon: &Daemon) -> Result<()> {
|
| 11 |
+
match std::fs::remove_file(daemon.recovery_file()?) {
|
| 12 |
+
Ok(()) => Ok(()),
|
| 13 |
+
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
|
| 14 |
+
Err(err) => Err(err).context("failed to clear daemon recovery file"),
|
| 15 |
+
}
|
| 16 |
+
}
|
codex-rs/app-server-daemon/src/update_loop.rs
ADDED
|
@@ -0,0 +1,710 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Owns scheduled and manual installs, daemon restarts, and updater replacement.
|
| 2 |
+
|
| 3 |
+
use std::path::Path;
|
| 4 |
+
#[cfg(unix)]
|
| 5 |
+
use std::process::Command as StdCommand;
|
| 6 |
+
use std::process::Stdio;
|
| 7 |
+
use std::time::Duration;
|
| 8 |
+
|
| 9 |
+
use anyhow::Context;
|
| 10 |
+
use anyhow::Result;
|
| 11 |
+
use codex_http_client::ClientRouteClass;
|
| 12 |
+
use codex_http_client::HttpClientFactory;
|
| 13 |
+
use codex_http_client::RouteAwareClientPool;
|
| 14 |
+
use futures::FutureExt;
|
| 15 |
+
#[cfg(unix)]
|
| 16 |
+
use std::os::unix::process::CommandExt;
|
| 17 |
+
use tokio::io::AsyncWriteExt;
|
| 18 |
+
use tokio::process::Command;
|
| 19 |
+
#[cfg(unix)]
|
| 20 |
+
use tokio::signal::unix::Signal;
|
| 21 |
+
#[cfg(unix)]
|
| 22 |
+
use tokio::signal::unix::SignalKind;
|
| 23 |
+
#[cfg(unix)]
|
| 24 |
+
use tokio::signal::unix::signal;
|
| 25 |
+
use tokio::time::Instant;
|
| 26 |
+
use tokio::time::sleep;
|
| 27 |
+
use tokio::time::sleep_until;
|
| 28 |
+
|
| 29 |
+
use crate::Daemon;
|
| 30 |
+
use crate::RestartIfRunningOutcome;
|
| 31 |
+
use crate::RestartMode;
|
| 32 |
+
use crate::managed_install::ExecutableIdentity;
|
| 33 |
+
use crate::managed_install::executable_identity;
|
| 34 |
+
use crate::managed_install::resolved_managed_codex_bin;
|
| 35 |
+
#[cfg(any(target_os = "linux", target_os = "macos", windows))]
|
| 36 |
+
use crate::settings::DaemonSettings;
|
| 37 |
+
use crate::settings::UpdaterSettings;
|
| 38 |
+
|
| 39 |
+
#[path = "manual_update.rs"]
|
| 40 |
+
mod manual_update;
|
| 41 |
+
#[path = "migration.rs"]
|
| 42 |
+
mod migration;
|
| 43 |
+
|
| 44 |
+
pub(crate) async fn request_manual_update(
|
| 45 |
+
daemon: &Daemon,
|
| 46 |
+
http_client_factory: HttpClientFactory,
|
| 47 |
+
) -> Result<crate::UpdateOutput> {
|
| 48 |
+
if daemon
|
| 49 |
+
.pid_file
|
| 50 |
+
.file_name()
|
| 51 |
+
.is_some_and(|name| name == crate::LEGACY_PID_FILE_NAME)
|
| 52 |
+
&& manual_update::supported(daemon)?
|
| 53 |
+
{
|
| 54 |
+
let http = RouteAwareClientPool::new_without_request_logging(
|
| 55 |
+
http_client_factory,
|
| 56 |
+
ClientRouteClass::Other,
|
| 57 |
+
);
|
| 58 |
+
return migration::run(&http, daemon).await;
|
| 59 |
+
}
|
| 60 |
+
manual_update::request(daemon).await
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
const INITIAL_UPDATE_DELAY: Duration = Duration::from_secs(5 * 60);
|
| 64 |
+
const RESTART_RETRY_INTERVAL: Duration = Duration::from_millis(50);
|
| 65 |
+
#[cfg(unix)]
|
| 66 |
+
const INSTALL_URL: &str = "https://chatgpt.com/codex/install.sh";
|
| 67 |
+
#[cfg(windows)]
|
| 68 |
+
const INSTALL_URL: &str = "https://chatgpt.com/codex/install.ps1";
|
| 69 |
+
|
| 70 |
+
pub(crate) async fn run(
|
| 71 |
+
http_client_factory: HttpClientFactory,
|
| 72 |
+
restore_release: Option<String>,
|
| 73 |
+
) -> Result<()> {
|
| 74 |
+
let http = RouteAwareClientPool::new_without_request_logging(
|
| 75 |
+
http_client_factory,
|
| 76 |
+
ClientRouteClass::Other,
|
| 77 |
+
);
|
| 78 |
+
run_with_http(
|
| 79 |
+
&http,
|
| 80 |
+
&Daemon::from_environment()?,
|
| 81 |
+
¤t_updater_identity().await?,
|
| 82 |
+
restore_release,
|
| 83 |
+
)
|
| 84 |
+
.await
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
async fn run_with_http(
|
| 88 |
+
http: &impl InstallerHttp,
|
| 89 |
+
daemon: &Daemon,
|
| 90 |
+
running_updater_identity: &ExecutableIdentity,
|
| 91 |
+
mut restore_release: Option<String>,
|
| 92 |
+
) -> Result<()> {
|
| 93 |
+
#[cfg(unix)]
|
| 94 |
+
let mut terminate =
|
| 95 |
+
signal(SignalKind::terminate()).context("failed to install updater shutdown handler")?;
|
| 96 |
+
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
| 97 |
+
{
|
| 98 |
+
let paths = daemon.backend_paths(&DaemonSettings::default());
|
| 99 |
+
for backend in [
|
| 100 |
+
crate::backend::pid_backend(paths.clone()),
|
| 101 |
+
crate::backend::pid_update_loop_backend(paths),
|
| 102 |
+
] {
|
| 103 |
+
// Inspection failures must preserve legacy records without blocking updates.
|
| 104 |
+
let _ = backend.promote_legacy_identity().await;
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
#[cfg(windows)]
|
| 108 |
+
let updater = {
|
| 109 |
+
// Updater ownership needs only paths, not settings that may be mid-edit.
|
| 110 |
+
crate::backend::pid_update_loop_backend(daemon.backend_paths(&DaemonSettings::default()))
|
| 111 |
+
};
|
| 112 |
+
#[cfg(windows)]
|
| 113 |
+
updater.wait_for_ownership().await?;
|
| 114 |
+
#[cfg(windows)]
|
| 115 |
+
let mut terminate = Signal;
|
| 116 |
+
#[cfg(windows)]
|
| 117 |
+
let _installer_job = crate::backend::windows::updater_job()?;
|
| 118 |
+
let socket_path = daemon.manual_update_socket_path();
|
| 119 |
+
codex_uds::prepare_private_socket_directory(
|
| 120 |
+
socket_path
|
| 121 |
+
.parent()
|
| 122 |
+
.context("updater socket has no parent")?,
|
| 123 |
+
)
|
| 124 |
+
.await?;
|
| 125 |
+
if socket_path.exists() {
|
| 126 |
+
tokio::fs::remove_file(&socket_path).await?;
|
| 127 |
+
}
|
| 128 |
+
let mut listener = Some(codex_uds::UnixListener::bind(&socket_path).await?);
|
| 129 |
+
#[cfg(windows)]
|
| 130 |
+
updater.mark_ready().await?;
|
| 131 |
+
let needs_managed_handoff =
|
| 132 |
+
match resolved_managed_codex_bin(&daemon.current_managed_codex_bin()?).await {
|
| 133 |
+
Ok(managed_bin) => {
|
| 134 |
+
executable_identity(&managed_bin).await.ok().as_ref()
|
| 135 |
+
!= Some(running_updater_identity)
|
| 136 |
+
}
|
| 137 |
+
Err(_) => true,
|
| 138 |
+
};
|
| 139 |
+
let auto_update_enabled = UpdaterSettings::load(&daemon.settings_file)
|
| 140 |
+
.await
|
| 141 |
+
.map(|settings| settings.auto_update_enabled)
|
| 142 |
+
.unwrap_or(true);
|
| 143 |
+
let mut next_check = Instant::now()
|
| 144 |
+
+ if restore_release.is_some() || needs_managed_handoff || !auto_update_enabled {
|
| 145 |
+
Duration::from_secs(15)
|
| 146 |
+
} else {
|
| 147 |
+
INITIAL_UPDATE_DELAY
|
| 148 |
+
};
|
| 149 |
+
let mut manual_handoff_pending = needs_managed_handoff;
|
| 150 |
+
loop {
|
| 151 |
+
tokio::select! {
|
| 152 |
+
biased;
|
| 153 |
+
_ = terminate.recv() => return Ok(()),
|
| 154 |
+
connection = listener.as_mut().context("updater listener closed")?.accept() => {
|
| 155 |
+
let connection = connection.context("failed to accept updater request")?;
|
| 156 |
+
// Only a manual CLI launch under the operation lock supplies this
|
| 157 |
+
// single-use authority. Ordinary socket requests cannot undo a pin.
|
| 158 |
+
let restoration = restore_release.take();
|
| 159 |
+
let trigger = restoration.as_deref().map_or(UpdateTrigger::Manual, UpdateTrigger::RestoreProduction);
|
| 160 |
+
let disposition = match manual_update::handle_request(connection, http, daemon, running_updater_identity, &mut terminate, trigger).await {
|
| 161 |
+
Ok(manual_update::RequestDisposition::Stop) => return Ok(()),
|
| 162 |
+
Ok(disposition) => disposition,
|
| 163 |
+
Err(_) => manual_update::RequestDisposition::Continue,
|
| 164 |
+
};
|
| 165 |
+
if UpdaterSettings::load(&daemon.settings_file)
|
| 166 |
+
.await
|
| 167 |
+
.is_ok_and(|settings| !settings.auto_update_enabled)
|
| 168 |
+
{
|
| 169 |
+
// Drain requests queued during this one-shot update before exiting.
|
| 170 |
+
next_check = Instant::now() + Duration::from_millis(100);
|
| 171 |
+
continue;
|
| 172 |
+
}
|
| 173 |
+
if matches!(disposition, manual_update::RequestDisposition::Unchanged) {
|
| 174 |
+
continue;
|
| 175 |
+
}
|
| 176 |
+
manual_handoff_pending = true;
|
| 177 |
+
next_check = Instant::now();
|
| 178 |
+
}
|
| 179 |
+
_ = sleep_until(next_check) => {
|
| 180 |
+
// The authorizing CLI may have exited before sending its request.
|
| 181 |
+
if restore_release.is_some() {
|
| 182 |
+
return Ok(());
|
| 183 |
+
}
|
| 184 |
+
match UpdaterSettings::load(&daemon.settings_file).await {
|
| 185 |
+
Ok(settings) if !settings.auto_update_enabled => return Ok(()),
|
| 186 |
+
Err(_) => {
|
| 187 |
+
next_check = Instant::now() + Duration::from_secs(60);
|
| 188 |
+
continue;
|
| 189 |
+
}
|
| 190 |
+
Ok(_) => {}
|
| 191 |
+
}
|
| 192 |
+
// Failed successor cleanup leaves its PID published. The predecessor
|
| 193 |
+
// must stop instead of installing again without ownership.
|
| 194 |
+
#[cfg(windows)]
|
| 195 |
+
updater.wait_for_ownership().await?;
|
| 196 |
+
if manual_handoff_pending {
|
| 197 |
+
if !daemon.is_stable_standalone_release()? {
|
| 198 |
+
if !daemon.has_latest_selection_marker() {
|
| 199 |
+
return Ok(());
|
| 200 |
+
}
|
| 201 |
+
next_check = Instant::now() + Duration::from_secs(30);
|
| 202 |
+
continue;
|
| 203 |
+
}
|
| 204 |
+
match adopt_managed_updater(daemon, running_updater_identity, &mut listener).await {
|
| 205 |
+
Ok(UpdateLoopControl::Stop) => return Ok(()),
|
| 206 |
+
Ok(UpdateLoopControl::Continue) => {
|
| 207 |
+
manual_handoff_pending = false;
|
| 208 |
+
let Some(delay) = next_update_delay(daemon).await else {
|
| 209 |
+
return Ok(());
|
| 210 |
+
};
|
| 211 |
+
next_check = Instant::now() + delay;
|
| 212 |
+
}
|
| 213 |
+
Err(err) => {
|
| 214 |
+
if listener.is_none() {
|
| 215 |
+
return Err(err);
|
| 216 |
+
}
|
| 217 |
+
eprintln!("warning: failed to refresh managed updater: {err:#}");
|
| 218 |
+
next_check = Instant::now() + Duration::from_secs(30);
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
continue;
|
| 222 |
+
}
|
| 223 |
+
match update_once(http, daemon, running_updater_identity, &mut terminate, UpdateTrigger::Scheduled).await {
|
| 224 |
+
Ok((UpdateLoopControl::Continue, Some(_))) => {
|
| 225 |
+
manual_handoff_pending = true;
|
| 226 |
+
next_check = Instant::now();
|
| 227 |
+
continue;
|
| 228 |
+
}
|
| 229 |
+
Ok((UpdateLoopControl::Continue, None)) | Err(_) => {}
|
| 230 |
+
Ok((UpdateLoopControl::Stop, _)) => return Ok(()),
|
| 231 |
+
}
|
| 232 |
+
let Some(delay) = next_update_delay(daemon).await else {
|
| 233 |
+
return Ok(());
|
| 234 |
+
};
|
| 235 |
+
next_check = Instant::now() + delay;
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
async fn next_update_delay(daemon: &Daemon) -> Option<Duration> {
|
| 242 |
+
match UpdaterSettings::load(&daemon.settings_file).await {
|
| 243 |
+
Ok(settings) if !settings.auto_update_enabled => None,
|
| 244 |
+
Ok(settings) => Some(settings.update_interval(Duration::from_secs(60))),
|
| 245 |
+
Err(_) => Some(Duration::from_secs(60)),
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
async fn adopt_managed_updater(
|
| 250 |
+
daemon: &Daemon,
|
| 251 |
+
running_identity: &ExecutableIdentity,
|
| 252 |
+
listener: &mut Option<codex_uds::UnixListener>,
|
| 253 |
+
) -> Result<UpdateLoopControl> {
|
| 254 |
+
let managed_bin = resolved_managed_codex_bin(&daemon.current_managed_codex_bin()?).await?;
|
| 255 |
+
if executable_identity(&managed_bin).await? == *running_identity {
|
| 256 |
+
return Ok(UpdateLoopControl::Continue);
|
| 257 |
+
}
|
| 258 |
+
if !crate::managed_install::supports_daemon_update_loop(&managed_bin).await {
|
| 259 |
+
return Ok(UpdateLoopControl::Stop);
|
| 260 |
+
}
|
| 261 |
+
#[cfg(unix)]
|
| 262 |
+
{
|
| 263 |
+
let _ = listener;
|
| 264 |
+
reexec_managed_updater(&managed_bin).map(|_| UpdateLoopControl::Stop)
|
| 265 |
+
}
|
| 266 |
+
#[cfg(windows)]
|
| 267 |
+
{
|
| 268 |
+
let replacement = crate::backend::pid_update_loop_backend(
|
| 269 |
+
daemon.backend_paths_with_bin(&daemon.load_settings().await?, &managed_bin),
|
| 270 |
+
);
|
| 271 |
+
listener.take();
|
| 272 |
+
if let Err(err) = replacement.replace_current_updater().await {
|
| 273 |
+
// A failed replacement may still own the PID if its cleanup could
|
| 274 |
+
// not terminate the successor. Never reopen our request socket then.
|
| 275 |
+
replacement.wait_for_ownership().await?;
|
| 276 |
+
let socket_path = daemon.manual_update_socket_path();
|
| 277 |
+
if socket_path.exists() {
|
| 278 |
+
tokio::fs::remove_file(&socket_path).await?;
|
| 279 |
+
}
|
| 280 |
+
*listener = Some(codex_uds::UnixListener::bind(&socket_path).await?);
|
| 281 |
+
return Err(err);
|
| 282 |
+
}
|
| 283 |
+
Ok(UpdateLoopControl::Stop)
|
| 284 |
+
}
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
async fn sleep_or_terminate(duration: Duration, terminate: &mut Signal) -> bool {
|
| 288 |
+
tokio::select! {
|
| 289 |
+
_ = sleep(duration) => false,
|
| 290 |
+
_ = terminate.recv() => true,
|
| 291 |
+
}
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
enum UpdateLoopControl {
|
| 295 |
+
Continue,
|
| 296 |
+
Stop,
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
#[derive(Clone, Copy, PartialEq, Eq)]
|
| 300 |
+
enum UpdateTrigger<'a> {
|
| 301 |
+
Scheduled,
|
| 302 |
+
Manual,
|
| 303 |
+
RestoreProduction(&'a str),
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
async fn update_once(
|
| 307 |
+
http: &impl InstallerHttp,
|
| 308 |
+
daemon: &Daemon,
|
| 309 |
+
running_updater_identity: &ExecutableIdentity,
|
| 310 |
+
terminate: &mut Signal,
|
| 311 |
+
trigger: UpdateTrigger<'_>,
|
| 312 |
+
) -> Result<(UpdateLoopControl, Option<RestartIfRunningOutcome>)> {
|
| 313 |
+
if trigger == UpdateTrigger::Scheduled
|
| 314 |
+
&& !UpdaterSettings::load(&daemon.settings_file)
|
| 315 |
+
.await?
|
| 316 |
+
.auto_update_enabled
|
| 317 |
+
{
|
| 318 |
+
return Ok((UpdateLoopControl::Stop, None));
|
| 319 |
+
}
|
| 320 |
+
if release_selection_unstable(daemon, trigger)? {
|
| 321 |
+
// An installer can be between changing current and publishing its
|
| 322 |
+
// latest-channel marker. Retry after the interval instead of exiting.
|
| 323 |
+
return Ok((UpdateLoopControl::Continue, None));
|
| 324 |
+
}
|
| 325 |
+
let (package_root, previous_selection, previous_release) = selected_release(daemon)?;
|
| 326 |
+
let codex_home = package_root
|
| 327 |
+
.parent()
|
| 328 |
+
.and_then(Path::parent)
|
| 329 |
+
.context("daemon package root has no Codex home")?;
|
| 330 |
+
let (installer_mode, installer_guard) =
|
| 331 |
+
if let UpdateTrigger::RestoreProduction(expected) = trigger {
|
| 332 |
+
anyhow::ensure!(
|
| 333 |
+
previous_release == expected,
|
| 334 |
+
"daemon selection changed; retry the update"
|
| 335 |
+
);
|
| 336 |
+
(
|
| 337 |
+
InstallerMode::RestoreProduction(&previous_release),
|
| 338 |
+
"CODEX_INSTALL_IF_CURRENT",
|
| 339 |
+
)
|
| 340 |
+
} else {
|
| 341 |
+
(
|
| 342 |
+
InstallerMode::Update(&previous_release),
|
| 343 |
+
"CODEX_INSTALL_IF_LATEST",
|
| 344 |
+
)
|
| 345 |
+
};
|
| 346 |
+
let script = tokio::select! {
|
| 347 |
+
result = fetch_installer_script(http) => result?,
|
| 348 |
+
_ = terminate.recv() => return Ok((UpdateLoopControl::Stop, None)),
|
| 349 |
+
};
|
| 350 |
+
anyhow::ensure!(
|
| 351 |
+
script
|
| 352 |
+
.windows(installer_guard.len())
|
| 353 |
+
.any(|window| window == installer_guard.as_bytes()),
|
| 354 |
+
"standalone installer does not support {installer_guard} guarded updates"
|
| 355 |
+
);
|
| 356 |
+
if package_root.ends_with("app-server-daemon") {
|
| 357 |
+
anyhow::ensure!(
|
| 358 |
+
script
|
| 359 |
+
.windows(b"CODEX_INSTALL_DAEMON_ONLY".len())
|
| 360 |
+
.any(|window| window == b"CODEX_INSTALL_DAEMON_ONLY"),
|
| 361 |
+
"installer does not support daemon-owned packages"
|
| 362 |
+
);
|
| 363 |
+
}
|
| 364 |
+
if trigger == UpdateTrigger::Scheduled
|
| 365 |
+
&& !UpdaterSettings::load(&daemon.settings_file)
|
| 366 |
+
.await?
|
| 367 |
+
.auto_update_enabled
|
| 368 |
+
{
|
| 369 |
+
return Ok((UpdateLoopControl::Stop, None));
|
| 370 |
+
}
|
| 371 |
+
if release_selection_unstable(daemon, trigger)? {
|
| 372 |
+
return Ok((UpdateLoopControl::Continue, None));
|
| 373 |
+
}
|
| 374 |
+
anyhow::ensure!(
|
| 375 |
+
crate::managed_install::package_root(codex_home) == package_root,
|
| 376 |
+
"daemon package root changed during the update; retry the command"
|
| 377 |
+
);
|
| 378 |
+
#[cfg(unix)]
|
| 379 |
+
if matches!(
|
| 380 |
+
run_installer_script(&script, installer_mode, &package_root, terminate.recv()).await?,
|
| 381 |
+
UpdateLoopControl::Stop
|
| 382 |
+
) {
|
| 383 |
+
return Ok((UpdateLoopControl::Stop, None));
|
| 384 |
+
}
|
| 385 |
+
#[cfg(windows)]
|
| 386 |
+
tokio::select! {
|
| 387 |
+
result = run_installer_script(&script, installer_mode, &package_root) => { result?; },
|
| 388 |
+
_ = terminate.recv() => return Ok((UpdateLoopControl::Stop, None)),
|
| 389 |
+
}
|
| 390 |
+
anyhow::ensure!(
|
| 391 |
+
trigger == UpdateTrigger::Scheduled || daemon.is_stable_standalone_release()?,
|
| 392 |
+
"installer did not select a stable latest release; retry the update"
|
| 393 |
+
);
|
| 394 |
+
if release_selection_unstable(daemon, trigger)? {
|
| 395 |
+
return Ok((UpdateLoopControl::Continue, None));
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
let managed_codex_bin =
|
| 399 |
+
resolved_managed_codex_bin(&daemon.current_managed_codex_bin()?).await?;
|
| 400 |
+
let restart_mode = match trigger {
|
| 401 |
+
// The package can contain different resources even when its CLI binary
|
| 402 |
+
// is identical. A release change must also replace the running process.
|
| 403 |
+
UpdateTrigger::Manual | UpdateTrigger::RestoreProduction(_)
|
| 404 |
+
if selected_release(daemon)?.1 != previous_selection =>
|
| 405 |
+
{
|
| 406 |
+
RestartMode::Always
|
| 407 |
+
}
|
| 408 |
+
UpdateTrigger::Manual | UpdateTrigger::RestoreProduction(_) => {
|
| 409 |
+
RestartMode::IfBinaryOrVersionChanged
|
| 410 |
+
}
|
| 411 |
+
UpdateTrigger::Scheduled
|
| 412 |
+
if executable_identity(&managed_codex_bin).await? != *running_updater_identity =>
|
| 413 |
+
{
|
| 414 |
+
RestartMode::Always
|
| 415 |
+
}
|
| 416 |
+
UpdateTrigger::Scheduled => RestartMode::IfVersionChanged,
|
| 417 |
+
};
|
| 418 |
+
|
| 419 |
+
loop {
|
| 420 |
+
if terminate.recv().now_or_never().flatten().is_some() {
|
| 421 |
+
return Ok((UpdateLoopControl::Stop, None));
|
| 422 |
+
}
|
| 423 |
+
match daemon
|
| 424 |
+
.try_restart_if_running(restart_mode, &managed_codex_bin)
|
| 425 |
+
.await?
|
| 426 |
+
{
|
| 427 |
+
RestartIfRunningOutcome::Busy => {
|
| 428 |
+
if sleep_or_terminate(RESTART_RETRY_INTERVAL, terminate).await {
|
| 429 |
+
return Ok((UpdateLoopControl::Stop, None));
|
| 430 |
+
}
|
| 431 |
+
}
|
| 432 |
+
RestartIfRunningOutcome::Restarted => {
|
| 433 |
+
return Ok((
|
| 434 |
+
UpdateLoopControl::Continue,
|
| 435 |
+
Some(RestartIfRunningOutcome::Restarted),
|
| 436 |
+
));
|
| 437 |
+
}
|
| 438 |
+
RestartIfRunningOutcome::NotRunning => {
|
| 439 |
+
return Ok((
|
| 440 |
+
UpdateLoopControl::Continue,
|
| 441 |
+
Some(RestartIfRunningOutcome::NotRunning),
|
| 442 |
+
));
|
| 443 |
+
}
|
| 444 |
+
RestartIfRunningOutcome::AlreadyCurrent
|
| 445 |
+
if trigger != UpdateTrigger::Scheduled
|
| 446 |
+
&& restart_mode == RestartMode::IfBinaryOrVersionChanged =>
|
| 447 |
+
{
|
| 448 |
+
anyhow::ensure!(
|
| 449 |
+
daemon.is_stable_standalone_release()?
|
| 450 |
+
&& resolved_managed_codex_bin(&daemon.current_managed_codex_bin()?).await?
|
| 451 |
+
== managed_codex_bin,
|
| 452 |
+
"managed daemon changed during the update; retry"
|
| 453 |
+
);
|
| 454 |
+
return Ok((
|
| 455 |
+
UpdateLoopControl::Continue,
|
| 456 |
+
Some(RestartIfRunningOutcome::AlreadyCurrent),
|
| 457 |
+
));
|
| 458 |
+
}
|
| 459 |
+
RestartIfRunningOutcome::NotReady | RestartIfRunningOutcome::AlreadyCurrent => {
|
| 460 |
+
anyhow::ensure!(
|
| 461 |
+
trigger == UpdateTrigger::Scheduled,
|
| 462 |
+
"managed daemon could not restart; retry when it is ready"
|
| 463 |
+
);
|
| 464 |
+
return Ok((
|
| 465 |
+
if daemon.is_stable_standalone_release()? {
|
| 466 |
+
UpdateLoopControl::Continue
|
| 467 |
+
} else {
|
| 468 |
+
UpdateLoopControl::Stop
|
| 469 |
+
},
|
| 470 |
+
None,
|
| 471 |
+
));
|
| 472 |
+
}
|
| 473 |
+
}
|
| 474 |
+
}
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
fn release_selection_unstable(daemon: &Daemon, trigger: UpdateTrigger<'_>) -> Result<bool> {
|
| 478 |
+
if daemon.is_stable_standalone_release()?
|
| 479 |
+
|| matches!(trigger, UpdateTrigger::RestoreProduction(_))
|
| 480 |
+
&& manual_update::supported(daemon)?
|
| 481 |
+
{
|
| 482 |
+
return Ok(false);
|
| 483 |
+
}
|
| 484 |
+
anyhow::ensure!(
|
| 485 |
+
trigger == UpdateTrigger::Scheduled,
|
| 486 |
+
"standalone install selection changed during the update"
|
| 487 |
+
);
|
| 488 |
+
Ok(true)
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
fn selected_release(daemon: &Daemon) -> Result<(std::path::PathBuf, std::path::PathBuf, String)> {
|
| 492 |
+
let home = daemon
|
| 493 |
+
.settings_file
|
| 494 |
+
.parent()
|
| 495 |
+
.and_then(Path::parent)
|
| 496 |
+
.context("daemon settings path has no Codex home")?;
|
| 497 |
+
let root = crate::managed_install::package_root(home);
|
| 498 |
+
let release = std::fs::canonicalize(root.join("current"))?;
|
| 499 |
+
let name = release
|
| 500 |
+
.file_name()
|
| 501 |
+
.context("managed release has no name")?
|
| 502 |
+
.to_string_lossy()
|
| 503 |
+
.into_owned();
|
| 504 |
+
Ok((root, release, name))
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
async fn current_updater_identity() -> Result<ExecutableIdentity> {
|
| 508 |
+
let current_exe =
|
| 509 |
+
std::env::current_exe().context("failed to resolve current updater executable")?;
|
| 510 |
+
executable_identity(¤t_exe).await
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
#[cfg(unix)]
|
| 514 |
+
pub(crate) fn reexec_managed_updater(managed_codex_bin: &std::path::Path) -> Result<()> {
|
| 515 |
+
let err = StdCommand::new(managed_codex_bin)
|
| 516 |
+
.args(["app-server", "daemon", "pid-update-loop"])
|
| 517 |
+
.exec();
|
| 518 |
+
Err(err).with_context(|| {
|
| 519 |
+
format!(
|
| 520 |
+
"failed to replace updater with managed Codex binary {}",
|
| 521 |
+
managed_codex_bin.display()
|
| 522 |
+
)
|
| 523 |
+
})
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
enum InstallerMode<'a> {
|
| 527 |
+
Update(&'a str),
|
| 528 |
+
RestoreProduction(&'a str),
|
| 529 |
+
Migration,
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
async fn run_installer_script(
|
| 533 |
+
script: &[u8],
|
| 534 |
+
mode: InstallerMode<'_>,
|
| 535 |
+
package_root: &Path,
|
| 536 |
+
#[cfg(unix)] terminate: impl std::future::Future<Output = Option<()>>,
|
| 537 |
+
) -> Result<UpdateLoopControl> {
|
| 538 |
+
#[cfg(unix)]
|
| 539 |
+
let mut command = {
|
| 540 |
+
let mut command = Command::new("/bin/sh");
|
| 541 |
+
command.arg("-s");
|
| 542 |
+
command.process_group(0);
|
| 543 |
+
command
|
| 544 |
+
};
|
| 545 |
+
#[cfg(windows)]
|
| 546 |
+
let mut command = {
|
| 547 |
+
let mut command = Command::new("powershell.exe");
|
| 548 |
+
command.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
|
| 549 |
+
command.args(["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "try { Invoke-Expression ([Console]::In.ReadToEnd()) } catch { Write-Error $_; exit 1 }"])
|
| 550 |
+
.kill_on_drop(true);
|
| 551 |
+
command
|
| 552 |
+
};
|
| 553 |
+
let (latest_guard, current_guard, previous_release, defer_selection) = match mode {
|
| 554 |
+
InstallerMode::Update(release) => ("1", "0", release, "0"),
|
| 555 |
+
InstallerMode::RestoreProduction(release) => ("0", "1", release, "0"),
|
| 556 |
+
InstallerMode::Migration => ("0", "0", "", "1"),
|
| 557 |
+
};
|
| 558 |
+
let mut child = command
|
| 559 |
+
.env(
|
| 560 |
+
"CODEX_HOME",
|
| 561 |
+
package_root
|
| 562 |
+
.parent()
|
| 563 |
+
.and_then(Path::parent)
|
| 564 |
+
.context("package root has no Codex home")?,
|
| 565 |
+
)
|
| 566 |
+
.env("CODEX_INSTALL_DEFER_SELECTION", defer_selection)
|
| 567 |
+
.env(
|
| 568 |
+
"CODEX_INSTALL_DAEMON_ONLY",
|
| 569 |
+
if package_root.ends_with("app-server-daemon") {
|
| 570 |
+
"1"
|
| 571 |
+
} else {
|
| 572 |
+
"0"
|
| 573 |
+
},
|
| 574 |
+
)
|
| 575 |
+
.env("CODEX_RELEASE", "latest")
|
| 576 |
+
.env("CODEX_NON_INTERACTIVE", "1")
|
| 577 |
+
.env("CODEX_INSTALL_IF_LATEST", latest_guard)
|
| 578 |
+
.env("CODEX_INSTALL_IF_CURRENT", current_guard)
|
| 579 |
+
.env("CODEX_UPDATE_FROM_RELEASE", previous_release)
|
| 580 |
+
.kill_on_drop(true)
|
| 581 |
+
.stdin(Stdio::piped())
|
| 582 |
+
.stdout(Stdio::null())
|
| 583 |
+
.stderr(Stdio::null())
|
| 584 |
+
.spawn()
|
| 585 |
+
.context("failed to invoke standalone Codex updater")?;
|
| 586 |
+
#[cfg(windows)]
|
| 587 |
+
let _installer_job = crate::backend::windows::installer_job(&child)?;
|
| 588 |
+
let mut stdin = child
|
| 589 |
+
.stdin
|
| 590 |
+
.take()
|
| 591 |
+
.context("standalone Codex updater stdin was unavailable")?;
|
| 592 |
+
#[cfg(unix)]
|
| 593 |
+
let mut terminate = std::pin::pin!(terminate);
|
| 594 |
+
#[cfg(unix)]
|
| 595 |
+
let write_result = tokio::select! {
|
| 596 |
+
result = stdin.write_all(script) => Some(result),
|
| 597 |
+
_ = &mut terminate => None,
|
| 598 |
+
};
|
| 599 |
+
#[cfg(windows)]
|
| 600 |
+
let write_result = Some(stdin.write_all(script).await);
|
| 601 |
+
drop(stdin);
|
| 602 |
+
#[cfg(unix)]
|
| 603 |
+
if write_result.is_none() {
|
| 604 |
+
cancel_installer(&mut child, package_root).await;
|
| 605 |
+
return Ok(UpdateLoopControl::Stop);
|
| 606 |
+
}
|
| 607 |
+
write_result
|
| 608 |
+
.context("installer write was cancelled")?
|
| 609 |
+
.context("failed to pass standalone Codex updater to shell")?;
|
| 610 |
+
#[cfg(unix)]
|
| 611 |
+
let status = tokio::select! {
|
| 612 |
+
result = child.wait() => result,
|
| 613 |
+
_ = &mut terminate => {
|
| 614 |
+
cancel_installer(&mut child, package_root).await;
|
| 615 |
+
return Ok(UpdateLoopControl::Stop);
|
| 616 |
+
}
|
| 617 |
+
};
|
| 618 |
+
#[cfg(windows)]
|
| 619 |
+
let status = child.wait().await;
|
| 620 |
+
let status = status.context("failed to wait for standalone Codex updater")?;
|
| 621 |
+
|
| 622 |
+
if status.success() {
|
| 623 |
+
Ok(UpdateLoopControl::Continue)
|
| 624 |
+
} else {
|
| 625 |
+
anyhow::bail!("standalone Codex updater exited with status {status}")
|
| 626 |
+
}
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
#[cfg(unix)]
|
| 630 |
+
async fn cancel_installer(child: &mut tokio::process::Child, package_root: &Path) {
|
| 631 |
+
let Some(pid) = child.id().and_then(|pid| libc::pid_t::try_from(pid).ok()) else {
|
| 632 |
+
return;
|
| 633 |
+
};
|
| 634 |
+
// Let the shell's EXIT/TERM trap release the installer lock first.
|
| 635 |
+
unsafe { libc::kill(-pid, libc::SIGTERM) };
|
| 636 |
+
sleep(Duration::from_secs(2)).await;
|
| 637 |
+
// Keep the shell unreaped until after the group kill, so its PID cannot
|
| 638 |
+
// be reused while descendants that ignored TERM are still running.
|
| 639 |
+
unsafe { libc::kill(-pid, libc::SIGKILL) };
|
| 640 |
+
// A forced kill can bypass the shell trap on hosts using the mkdir lock.
|
| 641 |
+
// The lock is ours only if its recorded owner is this still-unreaped shell.
|
| 642 |
+
let lock = package_root.join("install.lock.d");
|
| 643 |
+
if std::fs::read_to_string(lock.join("pid")).is_ok_and(|owner| owner.trim() == pid.to_string())
|
| 644 |
+
{
|
| 645 |
+
let _ = std::fs::remove_dir_all(lock);
|
| 646 |
+
}
|
| 647 |
+
let _ = child.wait().await;
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
async fn fetch_installer_script(http: &impl InstallerHttp) -> Result<Vec<u8>> {
|
| 651 |
+
match http.get(INSTALL_URL).await? {
|
| 652 |
+
InstallerResponse::Success(body) => Ok(body),
|
| 653 |
+
InstallerResponse::Unsuccessful { status } => {
|
| 654 |
+
anyhow::bail!("standalone Codex updater request failed with status {status}")
|
| 655 |
+
}
|
| 656 |
+
}
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
#[derive(Clone, Debug, PartialEq, Eq)]
|
| 660 |
+
enum InstallerResponse {
|
| 661 |
+
Success(Vec<u8>),
|
| 662 |
+
Unsuccessful { status: u16 },
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
/// HTTP boundary used to download the standalone installer.
|
| 666 |
+
///
|
| 667 |
+
/// Implementations must issue a GET for the supplied URL, return exact response bytes for a
|
| 668 |
+
/// successful status, and report a non-success status without buffering its response body.
|
| 669 |
+
trait InstallerHttp: Send + Sync {
|
| 670 |
+
fn get<'a>(
|
| 671 |
+
&'a self,
|
| 672 |
+
url: &'a str,
|
| 673 |
+
) -> impl std::future::Future<Output = Result<InstallerResponse>> + Send + 'a;
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
impl InstallerHttp for RouteAwareClientPool {
|
| 677 |
+
async fn get(&self, url: &str) -> Result<InstallerResponse> {
|
| 678 |
+
let response = RouteAwareClientPool::get(self, url)
|
| 679 |
+
.send()
|
| 680 |
+
.await
|
| 681 |
+
.context("failed to fetch standalone Codex updater")?;
|
| 682 |
+
if !response.status().is_success() {
|
| 683 |
+
return Ok(InstallerResponse::Unsuccessful {
|
| 684 |
+
status: response.status().as_u16(),
|
| 685 |
+
});
|
| 686 |
+
}
|
| 687 |
+
let body = response
|
| 688 |
+
.bytes()
|
| 689 |
+
.await
|
| 690 |
+
.context("failed to read standalone Codex updater")?
|
| 691 |
+
.to_vec();
|
| 692 |
+
Ok(InstallerResponse::Success(body))
|
| 693 |
+
}
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
#[cfg(test)]
|
| 697 |
+
#[path = "update_loop_tests.rs"]
|
| 698 |
+
mod tests;
|
| 699 |
+
|
| 700 |
+
#[cfg(windows)]
|
| 701 |
+
struct Signal;
|
| 702 |
+
|
| 703 |
+
#[cfg(windows)]
|
| 704 |
+
impl Signal {
|
| 705 |
+
async fn recv(&mut self) -> Option<()> {
|
| 706 |
+
// An unreadable control path must stop the updater rather than disable shutdown.
|
| 707 |
+
let _ = codex_app_server_transport::daemon_shutdown_signal().await;
|
| 708 |
+
Some(())
|
| 709 |
+
}
|
| 710 |
+
}
|
codex-rs/app-server-daemon/src/update_loop_tests.rs
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::sync::Mutex;
|
| 2 |
+
#[cfg(unix)]
|
| 3 |
+
use std::time::Duration;
|
| 4 |
+
|
| 5 |
+
use pretty_assertions::assert_eq;
|
| 6 |
+
#[cfg(unix)]
|
| 7 |
+
use tempfile::TempDir;
|
| 8 |
+
|
| 9 |
+
use super::INSTALL_URL;
|
| 10 |
+
use super::InstallerHttp;
|
| 11 |
+
use super::InstallerResponse;
|
| 12 |
+
use super::fetch_installer_script;
|
| 13 |
+
#[cfg(unix)]
|
| 14 |
+
use super::manual_update::run as manual_update_once;
|
| 15 |
+
#[cfg(unix)]
|
| 16 |
+
use crate::Daemon;
|
| 17 |
+
#[cfg(unix)]
|
| 18 |
+
use crate::UpdateOutput;
|
| 19 |
+
#[cfg(unix)]
|
| 20 |
+
use crate::UpdateStatus;
|
| 21 |
+
#[cfg(unix)]
|
| 22 |
+
use crate::managed_install::executable_identity;
|
| 23 |
+
#[cfg(unix)]
|
| 24 |
+
use crate::managed_install::executable_identity_from_bytes;
|
| 25 |
+
|
| 26 |
+
#[tokio::test]
|
| 27 |
+
async fn installer_fetch_uses_exact_url_and_preserves_bytes() {
|
| 28 |
+
let script = b"#!/bin/sh\nprintf 'update bytes'\n".to_vec();
|
| 29 |
+
let http = FakeInstallerHttp::new(InstallerResponse::Success(script.clone()));
|
| 30 |
+
|
| 31 |
+
assert_eq!(
|
| 32 |
+
fetch_installer_script(&http)
|
| 33 |
+
.await
|
| 34 |
+
.expect("installer fetch should succeed"),
|
| 35 |
+
script
|
| 36 |
+
);
|
| 37 |
+
assert_eq!(http.requested_urls(), vec![INSTALL_URL.to_string()]);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
#[tokio::test]
|
| 41 |
+
async fn installer_fetch_rejects_non_success_status() {
|
| 42 |
+
let http = FakeInstallerHttp::new(InstallerResponse::Unsuccessful { status: 503 });
|
| 43 |
+
|
| 44 |
+
let error = fetch_installer_script(&http)
|
| 45 |
+
.await
|
| 46 |
+
.expect_err("non-success response should fail");
|
| 47 |
+
|
| 48 |
+
assert!(error.to_string().contains("503"));
|
| 49 |
+
assert_eq!(http.requested_urls(), vec![INSTALL_URL.to_string()]);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
struct FakeInstallerHttp {
|
| 53 |
+
response: InstallerResponse,
|
| 54 |
+
requested_urls: Mutex<Vec<String>>,
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
#[cfg(unix)]
|
| 58 |
+
#[tokio::test]
|
| 59 |
+
async fn explicit_update_migrates_running_and_stopped_installations() {
|
| 60 |
+
for (running, local) in [(false, false), (true, false), (false, true), (true, true)] {
|
| 61 |
+
let home = TempDir::new().unwrap();
|
| 62 |
+
let (legacy, release) = manual_update_daemon(&home);
|
| 63 |
+
let root = home.path().join("packages/standalone");
|
| 64 |
+
if local {
|
| 65 |
+
let package = root.join("releases/local-development");
|
| 66 |
+
std::fs::create_dir(&package).unwrap();
|
| 67 |
+
std::fs::copy(&legacy.managed_codex_bin, package.join("codex")).unwrap();
|
| 68 |
+
std::fs::remove_file(root.join("current")).unwrap();
|
| 69 |
+
std::os::unix::fs::symlink(&package, root.join("current")).unwrap();
|
| 70 |
+
std::fs::remove_file(root.join("auto-update-version")).unwrap();
|
| 71 |
+
}
|
| 72 |
+
let previous = root.join("current").canonicalize().unwrap();
|
| 73 |
+
let scheduled = FakeInstallerHttp::new(InstallerResponse::Success(
|
| 74 |
+
b"# CODEX_INSTALL_IF_LATEST\ntest \"$CODEX_INSTALL_DEFER_SELECTION\" = 0 && test \"$CODEX_INSTALL_DAEMON_ONLY\" = 0\n".to_vec(),
|
| 75 |
+
));
|
| 76 |
+
super::update_once(
|
| 77 |
+
&scheduled,
|
| 78 |
+
&legacy,
|
| 79 |
+
&executable_identity(&legacy.managed_codex_bin)
|
| 80 |
+
.await
|
| 81 |
+
.unwrap(),
|
| 82 |
+
&mut test_terminate(),
|
| 83 |
+
super::UpdateTrigger::Scheduled,
|
| 84 |
+
)
|
| 85 |
+
.await
|
| 86 |
+
.unwrap();
|
| 87 |
+
assert_eq!(
|
| 88 |
+
scheduled.requested_urls(),
|
| 89 |
+
if local {
|
| 90 |
+
vec![]
|
| 91 |
+
} else {
|
| 92 |
+
vec![INSTALL_URL.to_string()]
|
| 93 |
+
}
|
| 94 |
+
);
|
| 95 |
+
assert_eq!(
|
| 96 |
+
crate::managed_install::package_root(home.path()),
|
| 97 |
+
home.path().join("packages/standalone")
|
| 98 |
+
);
|
| 99 |
+
let settings = format!(
|
| 100 |
+
r#"{{"updater":{{"autoUpdateEnabled":{running}}},"remoteControlEnabled":true}}"#
|
| 101 |
+
);
|
| 102 |
+
std::fs::write(&legacy.settings_file, &settings).unwrap();
|
| 103 |
+
let daemon_settings = legacy.load_settings().await.unwrap();
|
| 104 |
+
let old_backend = crate::backend::pid_backend(legacy.backend_paths(&daemon_settings));
|
| 105 |
+
let old_updater =
|
| 106 |
+
crate::backend::pid_update_loop_backend(legacy.backend_paths(&daemon_settings));
|
| 107 |
+
let server = if running {
|
| 108 |
+
old_backend.start().await.unwrap();
|
| 109 |
+
old_updater.start().await.unwrap();
|
| 110 |
+
Some(test_control_server(&legacy, home.path()).await)
|
| 111 |
+
} else {
|
| 112 |
+
None
|
| 113 |
+
};
|
| 114 |
+
let legacy_bin = std::fs::read(&legacy.managed_codex_bin).unwrap();
|
| 115 |
+
let dedicated = home.path().join("packages/app-server-daemon");
|
| 116 |
+
let old_installer = FakeInstallerHttp::new(InstallerResponse::Success(b"exit 99".to_vec()));
|
| 117 |
+
assert!(
|
| 118 |
+
super::migration::run(&old_installer, &legacy)
|
| 119 |
+
.await
|
| 120 |
+
.unwrap_err()
|
| 121 |
+
.to_string()
|
| 122 |
+
.contains("does not support daemon migration")
|
| 123 |
+
);
|
| 124 |
+
|
| 125 |
+
let script = format!(
|
| 126 |
+
r#"# CODEX_INSTALL_DEFER_SELECTION
|
| 127 |
+
set -eu
|
| 128 |
+
test "$CODEX_INSTALL_DEFER_SELECTION" = 1
|
| 129 |
+
test "$CODEX_INSTALL_IF_CURRENT" = 0
|
| 130 |
+
test "$CODEX_INSTALL_DAEMON_ONLY" = 1
|
| 131 |
+
test "$CODEX_INSTALL_IF_LATEST" = 0
|
| 132 |
+
root="$CODEX_HOME/packages/app-server-daemon"
|
| 133 |
+
mkdir -p "$root/releases/{release}/bin"
|
| 134 |
+
printf '#!/bin/sh\nif [ "$1" = --version ]; then echo codex 1.0.0; else exit 2; fi\n' > "$root/releases/{release}/bin/codex"
|
| 135 |
+
chmod +x "$root/releases/{release}/bin/codex"
|
| 136 |
+
ln -sfn 'releases/{release}' "$root/.migration-current"
|
| 137 |
+
printf '{release}' > "$root/auto-update-version"
|
| 138 |
+
"#
|
| 139 |
+
);
|
| 140 |
+
let http = FakeInstallerHttp::new(InstallerResponse::Success(script.as_bytes().to_vec()));
|
| 141 |
+
let error = super::migration::run(&http, &legacy).await.unwrap_err();
|
| 142 |
+
assert!(
|
| 143 |
+
error
|
| 144 |
+
.to_string()
|
| 145 |
+
.contains("production release does not support daemon migration")
|
| 146 |
+
);
|
| 147 |
+
assert!(!dedicated.join("current").exists());
|
| 148 |
+
assert_eq!(
|
| 149 |
+
crate::managed_install::package_root(home.path()),
|
| 150 |
+
home.path().join("packages/standalone")
|
| 151 |
+
);
|
| 152 |
+
assert_eq!(
|
| 153 |
+
(
|
| 154 |
+
old_backend.is_starting_or_running().await.unwrap(),
|
| 155 |
+
old_updater.is_starting_or_running().await.unwrap()
|
| 156 |
+
),
|
| 157 |
+
(running, running)
|
| 158 |
+
);
|
| 159 |
+
let compatible = FakeInstallerHttp::new(InstallerResponse::Success(script.replace("else exit 2", "elif [ \"$4\" = --check-package-ownership ] || [ \"$4\" = --help ]; then exit 0; else exec sleep 30").into_bytes()));
|
| 160 |
+
let output = super::migration::run(&compatible, &legacy).await.unwrap();
|
| 161 |
+
assert_eq!(output, UpdateOutput {
|
| 162 |
+
status: UpdateStatus::Updated,
|
| 163 |
+
installed_version: Some("1.0.0".to_string()),
|
| 164 |
+
running_version: (running).then(|| "1.0.0".to_string()),
|
| 165 |
+
managed_codex_path: dedicated.join("current/bin/codex"),
|
| 166 |
+
message: "The daemon was updated and moved to its dedicated package. The legacy CLI package was left unchanged.".to_string(),
|
| 167 |
+
});
|
| 168 |
+
assert_eq!(
|
| 169 |
+
crate::managed_install::package_root(home.path()),
|
| 170 |
+
dedicated.clone()
|
| 171 |
+
);
|
| 172 |
+
assert!(!dedicated.join(".migration-current").exists());
|
| 173 |
+
assert_eq!(root.join("current").canonicalize().unwrap(), previous);
|
| 174 |
+
assert_eq!(
|
| 175 |
+
std::fs::read(&legacy.managed_codex_bin).unwrap(),
|
| 176 |
+
legacy_bin
|
| 177 |
+
);
|
| 178 |
+
assert_eq!(
|
| 179 |
+
std::fs::read_to_string(&legacy.settings_file).unwrap(),
|
| 180 |
+
settings
|
| 181 |
+
);
|
| 182 |
+
let selected = Daemon {
|
| 183 |
+
pid_file: legacy.pid_file.with_file_name(crate::DAEMON_PID_FILE_NAME),
|
| 184 |
+
update_pid_file: legacy
|
| 185 |
+
.update_pid_file
|
| 186 |
+
.with_file_name(crate::DAEMON_UPDATE_PID_FILE_NAME),
|
| 187 |
+
managed_codex_bin: dedicated.join("current/bin/codex"),
|
| 188 |
+
..legacy.clone()
|
| 189 |
+
};
|
| 190 |
+
let new_backend = crate::backend::pid_backend(selected.backend_paths(&daemon_settings));
|
| 191 |
+
assert_eq!(new_backend.is_starting_or_running().await.unwrap(), running);
|
| 192 |
+
assert!(!old_backend.is_starting_or_running().await.unwrap());
|
| 193 |
+
assert!(!old_updater.is_starting_or_running().await.unwrap());
|
| 194 |
+
let new_updater =
|
| 195 |
+
crate::backend::pid_update_loop_backend(selected.backend_paths(&daemon_settings));
|
| 196 |
+
assert_eq!(new_updater.is_starting_or_running().await.unwrap(), running);
|
| 197 |
+
new_updater.stop().await.unwrap();
|
| 198 |
+
new_backend.stop().await.unwrap();
|
| 199 |
+
if let Some(server) = server {
|
| 200 |
+
server.abort();
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
impl FakeInstallerHttp {
|
| 206 |
+
fn new(response: InstallerResponse) -> Self {
|
| 207 |
+
Self {
|
| 208 |
+
response,
|
| 209 |
+
requested_urls: Mutex::new(Vec::new()),
|
| 210 |
+
}
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
fn requested_urls(&self) -> Vec<String> {
|
| 214 |
+
self.requested_urls
|
| 215 |
+
.lock()
|
| 216 |
+
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
| 217 |
+
.clone()
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
impl InstallerHttp for FakeInstallerHttp {
|
| 222 |
+
async fn get(&self, url: &str) -> anyhow::Result<InstallerResponse> {
|
| 223 |
+
self.requested_urls
|
| 224 |
+
.lock()
|
| 225 |
+
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
| 226 |
+
.push(url.to_string());
|
| 227 |
+
Ok(self.response.clone())
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
#[cfg(unix)]
|
| 232 |
+
#[tokio::test]
|
| 233 |
+
async fn cancelling_installer_stops_children_and_releases_fallback_lock() {
|
| 234 |
+
let home = tempfile::TempDir::new().expect("home");
|
| 235 |
+
let ready = home.path().join("ready");
|
| 236 |
+
let delayed = home.path().join("delayed");
|
| 237 |
+
let lock = home.path().join("packages/standalone/install.lock.d");
|
| 238 |
+
let script = format!(
|
| 239 |
+
"mkdir -p '{lock}'\necho $$ > '{lock}/pid'\n(trap '' TERM; echo ready > '{ready}'; sleep 4; echo late > '{delayed}') &\nwait\n",
|
| 240 |
+
lock = lock.display(),
|
| 241 |
+
ready = ready.display(),
|
| 242 |
+
delayed = delayed.display(),
|
| 243 |
+
);
|
| 244 |
+
let (cancel, cancelled) = tokio::sync::oneshot::channel();
|
| 245 |
+
let ready_for_signal = ready.clone();
|
| 246 |
+
let signal_sender = tokio::spawn(async move {
|
| 247 |
+
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
| 248 |
+
while !ready_for_signal.exists() {
|
| 249 |
+
assert!(
|
| 250 |
+
tokio::time::Instant::now() < deadline,
|
| 251 |
+
"installer did not start"
|
| 252 |
+
);
|
| 253 |
+
tokio::time::sleep(Duration::from_millis(20)).await;
|
| 254 |
+
}
|
| 255 |
+
cancel.send(()).expect("cancel installer");
|
| 256 |
+
});
|
| 257 |
+
let result = tokio::time::timeout(
|
| 258 |
+
Duration::from_secs(10),
|
| 259 |
+
super::run_installer_script(
|
| 260 |
+
script.as_bytes(),
|
| 261 |
+
super::InstallerMode::Update("0.150.0-test"),
|
| 262 |
+
&home.path().join("packages/standalone"),
|
| 263 |
+
async { cancelled.await.ok() },
|
| 264 |
+
),
|
| 265 |
+
)
|
| 266 |
+
.await
|
| 267 |
+
.expect("installer cancellation timed out")
|
| 268 |
+
.expect("installer cancellation failed");
|
| 269 |
+
signal_sender.await.expect("signal sender");
|
| 270 |
+
assert!(matches!(result, super::UpdateLoopControl::Stop));
|
| 271 |
+
assert!(!lock.exists());
|
| 272 |
+
tokio::time::sleep(Duration::from_secs(3)).await;
|
| 273 |
+
assert!(!delayed.exists());
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
#[cfg(unix)]
|
| 277 |
+
fn manual_update_daemon(home: &TempDir) -> (Daemon, String) {
|
| 278 |
+
use std::os::unix::fs::PermissionsExt;
|
| 279 |
+
|
| 280 |
+
let target = if cfg!(target_os = "macos") {
|
| 281 |
+
format!("{}-apple-darwin", std::env::consts::ARCH)
|
| 282 |
+
} else {
|
| 283 |
+
format!("{}-unknown-linux-musl", std::env::consts::ARCH)
|
| 284 |
+
};
|
| 285 |
+
let release = format!("1.0.0-{target}");
|
| 286 |
+
let standalone = home.path().join("packages/standalone");
|
| 287 |
+
let bin = standalone.join("releases").join(&release).join("codex");
|
| 288 |
+
std::fs::create_dir_all(bin.parent().expect("binary parent")).expect("release directory");
|
| 289 |
+
std::fs::write(
|
| 290 |
+
&bin,
|
| 291 |
+
b"#!/bin/sh\nif [ \"$1\" = '--version' ]; then echo codex 1.0.0; else exec sleep 30; fi\n",
|
| 292 |
+
)
|
| 293 |
+
.expect("managed binary");
|
| 294 |
+
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))
|
| 295 |
+
.expect("executable binary");
|
| 296 |
+
std::os::unix::fs::symlink(format!("releases/{release}"), standalone.join("current"))
|
| 297 |
+
.expect("current release");
|
| 298 |
+
std::fs::write(standalone.join("auto-update-version"), &release).expect("latest marker");
|
| 299 |
+
let state = home.path().join("app-server-daemon");
|
| 300 |
+
std::fs::create_dir(&state).unwrap();
|
| 301 |
+
std::fs::write(state.join("app-server.stderr.log"), b"").unwrap();
|
| 302 |
+
(
|
| 303 |
+
Daemon {
|
| 304 |
+
socket_path: home.path().join("app-server-control/server.sock"),
|
| 305 |
+
pid_file: state.join("app-server.pid"),
|
| 306 |
+
update_pid_file: state.join("app-server-updater.pid"),
|
| 307 |
+
operation_lock_file: state.join("daemon.lock"),
|
| 308 |
+
settings_file: state.join("settings.json"),
|
| 309 |
+
managed_codex_bin: standalone.join("current/codex"),
|
| 310 |
+
},
|
| 311 |
+
release,
|
| 312 |
+
)
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
#[cfg(unix)]
|
| 316 |
+
fn test_terminate() -> tokio::signal::unix::Signal {
|
| 317 |
+
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
| 318 |
+
.expect("install test signal handler")
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
#[cfg(unix)]
|
| 322 |
+
#[tokio::test]
|
| 323 |
+
async fn manual_request_retries_after_updater_replacement() {
|
| 324 |
+
use tokio::io::AsyncReadExt;
|
| 325 |
+
use tokio::io::AsyncWriteExt;
|
| 326 |
+
|
| 327 |
+
let home = TempDir::new().expect("home");
|
| 328 |
+
let (daemon, _) = manual_update_daemon(&home);
|
| 329 |
+
let socket_path = daemon.manual_update_socket_path();
|
| 330 |
+
codex_uds::prepare_private_socket_directory(socket_path.parent().expect("socket parent"))
|
| 331 |
+
.await
|
| 332 |
+
.expect("socket directory");
|
| 333 |
+
let mut listener = codex_uds::UnixListener::bind(&socket_path)
|
| 334 |
+
.await
|
| 335 |
+
.expect("old updater socket");
|
| 336 |
+
let expected = UpdateOutput {
|
| 337 |
+
status: UpdateStatus::NoUpdate,
|
| 338 |
+
managed_codex_path: daemon.managed_codex_bin.clone(),
|
| 339 |
+
installed_version: None,
|
| 340 |
+
running_version: None,
|
| 341 |
+
message: "already current".to_string(),
|
| 342 |
+
};
|
| 343 |
+
let reply = expected.clone();
|
| 344 |
+
let server = tokio::spawn(async move {
|
| 345 |
+
let mut old = listener.accept().await.expect("first connection");
|
| 346 |
+
let mut request = [0; 7];
|
| 347 |
+
old.read_exact(&mut request).await.expect("first request");
|
| 348 |
+
drop(old);
|
| 349 |
+
drop(listener);
|
| 350 |
+
tokio::fs::remove_file(&socket_path)
|
| 351 |
+
.await
|
| 352 |
+
.expect("remove old socket");
|
| 353 |
+
let mut successor = codex_uds::UnixListener::bind(&socket_path)
|
| 354 |
+
.await
|
| 355 |
+
.expect("successor socket");
|
| 356 |
+
let mut connection = successor.accept().await.expect("retried connection");
|
| 357 |
+
connection
|
| 358 |
+
.read_exact(&mut request)
|
| 359 |
+
.await
|
| 360 |
+
.expect("retried request");
|
| 361 |
+
connection
|
| 362 |
+
.write_all(&serde_json::to_vec(&Ok::<_, String>(reply)).expect("serialize response"))
|
| 363 |
+
.await
|
| 364 |
+
.expect("send response");
|
| 365 |
+
});
|
| 366 |
+
assert_eq!(
|
| 367 |
+
super::manual_update::request(&daemon)
|
| 368 |
+
.await
|
| 369 |
+
.expect("request survives handoff"),
|
| 370 |
+
expected
|
| 371 |
+
);
|
| 372 |
+
server.await.expect("replacement task");
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
#[cfg(unix)]
|
| 376 |
+
#[tokio::test]
|
| 377 |
+
async fn manual_request_recovers_when_one_shot_updater_exits() {
|
| 378 |
+
use tokio::io::AsyncReadExt;
|
| 379 |
+
|
| 380 |
+
let home = TempDir::new().expect("home");
|
| 381 |
+
let (daemon, _) = manual_update_daemon(&home);
|
| 382 |
+
let socket_path = daemon.manual_update_socket_path();
|
| 383 |
+
codex_uds::prepare_private_socket_directory(socket_path.parent().expect("socket parent"))
|
| 384 |
+
.await
|
| 385 |
+
.expect("socket directory");
|
| 386 |
+
let mut listener = codex_uds::UnixListener::bind(&socket_path)
|
| 387 |
+
.await
|
| 388 |
+
.expect("one-shot updater socket");
|
| 389 |
+
let server = tokio::spawn(async move {
|
| 390 |
+
let mut connection = listener.accept().await.expect("request connection");
|
| 391 |
+
let mut request = [0; 7];
|
| 392 |
+
connection.read_exact(&mut request).await.expect("request");
|
| 393 |
+
drop(connection);
|
| 394 |
+
drop(listener);
|
| 395 |
+
tokio::fs::remove_file(socket_path)
|
| 396 |
+
.await
|
| 397 |
+
.expect("remove exited updater socket");
|
| 398 |
+
});
|
| 399 |
+
// Without the selected executable, the startup path reports unsupported. A
|
| 400 |
+
// retry that only waits for a successor would time out instead.
|
| 401 |
+
std::fs::remove_file(&daemon.managed_codex_bin).expect("remove selected binary");
|
| 402 |
+
let result = tokio::time::timeout(
|
| 403 |
+
Duration::from_secs(5),
|
| 404 |
+
super::manual_update::request(&daemon),
|
| 405 |
+
)
|
| 406 |
+
.await
|
| 407 |
+
.expect("retry should return to normal startup")
|
| 408 |
+
.expect("unsupported response");
|
| 409 |
+
assert_eq!(result.status, UpdateStatus::Unsupported);
|
| 410 |
+
server.await.expect("updater task");
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
#[cfg(unix)]
|
| 414 |
+
#[tokio::test]
|
| 415 |
+
async fn unsupported_request_preserves_updater_schedule() {
|
| 416 |
+
use tokio::io::AsyncReadExt;
|
| 417 |
+
use tokio::io::AsyncWriteExt;
|
| 418 |
+
|
| 419 |
+
let home = TempDir::new().expect("home");
|
| 420 |
+
let (daemon, _) = manual_update_daemon(&home);
|
| 421 |
+
let daemon = std::sync::Arc::new(daemon);
|
| 422 |
+
let identity = executable_identity(&daemon.managed_codex_bin)
|
| 423 |
+
.await
|
| 424 |
+
.expect("updater identity");
|
| 425 |
+
let socket_path = daemon.manual_update_socket_path();
|
| 426 |
+
let http = FakeInstallerHttp::new(InstallerResponse::Success(Vec::new()));
|
| 427 |
+
let updater_daemon = std::sync::Arc::clone(&daemon);
|
| 428 |
+
let worker = tokio::spawn(async move {
|
| 429 |
+
super::run_with_http(
|
| 430 |
+
&http,
|
| 431 |
+
&updater_daemon,
|
| 432 |
+
&identity,
|
| 433 |
+
/*restore_release*/ None,
|
| 434 |
+
)
|
| 435 |
+
.await
|
| 436 |
+
});
|
| 437 |
+
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
| 438 |
+
while !socket_path.exists() {
|
| 439 |
+
assert!(
|
| 440 |
+
tokio::time::Instant::now() < deadline,
|
| 441 |
+
"updater did not listen"
|
| 442 |
+
);
|
| 443 |
+
tokio::time::sleep(Duration::from_millis(20)).await;
|
| 444 |
+
}
|
| 445 |
+
// An external installer can pin while this ordinary worker still listens.
|
| 446 |
+
// Raw IPC must not gain the manual CLI's authority to restore production.
|
| 447 |
+
let marker = super::selected_release(&daemon)
|
| 448 |
+
.unwrap()
|
| 449 |
+
.0
|
| 450 |
+
.join("auto-update-version");
|
| 451 |
+
let previous_marker = std::fs::read(&marker).unwrap();
|
| 452 |
+
std::fs::remove_file(&marker).unwrap();
|
| 453 |
+
let mut pinned = codex_uds::UnixStream::connect(&socket_path).await.unwrap();
|
| 454 |
+
pinned.write_all(b"update\n").await.unwrap();
|
| 455 |
+
let mut response = Vec::new();
|
| 456 |
+
pinned.read_to_end(&mut response).await.unwrap();
|
| 457 |
+
let response: Result<UpdateOutput, String> = serde_json::from_slice(&response).unwrap();
|
| 458 |
+
assert_eq!(response.unwrap().status, UpdateStatus::Unsupported);
|
| 459 |
+
assert!(!marker.exists());
|
| 460 |
+
std::fs::write(&marker, previous_marker).unwrap();
|
| 461 |
+
std::fs::remove_file(&daemon.managed_codex_bin).expect("remove selected binary");
|
| 462 |
+
let mut malformed = codex_uds::UnixStream::connect(&socket_path)
|
| 463 |
+
.await
|
| 464 |
+
.expect("connect malformed request");
|
| 465 |
+
malformed
|
| 466 |
+
.write_all(b"upd")
|
| 467 |
+
.await
|
| 468 |
+
.expect("send partial request");
|
| 469 |
+
malformed.shutdown().await.expect("disconnect request");
|
| 470 |
+
let mut discarded = Vec::new();
|
| 471 |
+
malformed
|
| 472 |
+
.read_to_end(&mut discarded)
|
| 473 |
+
.await
|
| 474 |
+
.expect("rejected request");
|
| 475 |
+
tokio::time::sleep(Duration::from_millis(100)).await;
|
| 476 |
+
assert!(!worker.is_finished(), "malformed request stopped updater");
|
| 477 |
+
assert_eq!(
|
| 478 |
+
super::manual_update::request(&daemon)
|
| 479 |
+
.await
|
| 480 |
+
.expect("unsupported response")
|
| 481 |
+
.status,
|
| 482 |
+
UpdateStatus::Unsupported
|
| 483 |
+
);
|
| 484 |
+
tokio::time::sleep(Duration::from_millis(100)).await;
|
| 485 |
+
assert!(!worker.is_finished(), "unsupported request stopped updater");
|
| 486 |
+
worker.abort();
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
#[cfg(unix)]
|
| 490 |
+
async fn test_control_server(
|
| 491 |
+
daemon: &Daemon,
|
| 492 |
+
home: &std::path::Path,
|
| 493 |
+
) -> tokio::task::JoinHandle<()> {
|
| 494 |
+
use futures::SinkExt;
|
| 495 |
+
use futures::StreamExt;
|
| 496 |
+
std::fs::create_dir_all(daemon.socket_path.parent().expect("socket parent"))
|
| 497 |
+
.expect("socket directory");
|
| 498 |
+
let mut listener = codex_uds::UnixListener::bind(&daemon.socket_path)
|
| 499 |
+
.await
|
| 500 |
+
.expect("control listener");
|
| 501 |
+
let codex_home = home.to_path_buf();
|
| 502 |
+
tokio::spawn(async move {
|
| 503 |
+
loop {
|
| 504 |
+
let connection = listener.accept().await.expect("control connection");
|
| 505 |
+
let mut websocket = tokio_tungstenite::accept_async(connection)
|
| 506 |
+
.await
|
| 507 |
+
.expect("websocket handshake");
|
| 508 |
+
websocket
|
| 509 |
+
.next()
|
| 510 |
+
.await
|
| 511 |
+
.expect("initialize request")
|
| 512 |
+
.expect("frame");
|
| 513 |
+
let version = if std::fs::read_to_string(
|
| 514 |
+
crate::managed_install::package_root(&codex_home).join("auto-update-version"),
|
| 515 |
+
)
|
| 516 |
+
.unwrap_or_default()
|
| 517 |
+
.starts_with("1.1.0")
|
| 518 |
+
{
|
| 519 |
+
"1.1.0"
|
| 520 |
+
} else {
|
| 521 |
+
"1.0.0"
|
| 522 |
+
};
|
| 523 |
+
websocket.send(tokio_tungstenite::tungstenite::Message::Text(
|
| 524 |
+
serde_json::json!({"id": 1, "result": {
|
| 525 |
+
"userAgent": format!("codex_app_server_daemon/{version}"),
|
| 526 |
+
"codexHome": codex_home, "platformFamily": "unix", "platformOs": std::env::consts::OS,
|
| 527 |
+
}}).to_string().into(),
|
| 528 |
+
)).await.expect("initialize response");
|
| 529 |
+
websocket
|
| 530 |
+
.next()
|
| 531 |
+
.await
|
| 532 |
+
.expect("initialized notification")
|
| 533 |
+
.expect("frame");
|
| 534 |
+
}
|
| 535 |
+
})
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
#[cfg(unix)]
|
| 539 |
+
#[tokio::test]
|
| 540 |
+
async fn manual_update_restarts_managed_daemon_with_automatic_updates_disabled() {
|
| 541 |
+
check_manual_update_restart("standalone").await;
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
#[cfg(unix)]
|
| 545 |
+
#[tokio::test]
|
| 546 |
+
async fn manual_update_restarts_local_daemon_with_automatic_updates_disabled() {
|
| 547 |
+
check_manual_update_restart("app-server-daemon").await;
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
#[cfg(unix)]
|
| 551 |
+
async fn check_manual_update_restart(package_directory: &str) {
|
| 552 |
+
use tokio::io::AsyncReadExt;
|
| 553 |
+
use tokio::io::AsyncWriteExt;
|
| 554 |
+
|
| 555 |
+
let local_package = package_directory == "app-server-daemon";
|
| 556 |
+
let home = TempDir::new().expect("home");
|
| 557 |
+
let (mut daemon, mut release) = manual_update_daemon(&home);
|
| 558 |
+
let standalone = home.path().join("packages").join(package_directory);
|
| 559 |
+
if local_package {
|
| 560 |
+
std::fs::rename(home.path().join("packages/standalone"), &standalone).unwrap();
|
| 561 |
+
let local = format!("local-development-{release}");
|
| 562 |
+
std::fs::rename(
|
| 563 |
+
standalone.join("releases").join(&release),
|
| 564 |
+
standalone.join("releases").join(&local),
|
| 565 |
+
)
|
| 566 |
+
.unwrap();
|
| 567 |
+
std::fs::remove_file(standalone.join("current")).unwrap();
|
| 568 |
+
std::os::unix::fs::symlink(format!("releases/{local}"), standalone.join("current"))
|
| 569 |
+
.unwrap();
|
| 570 |
+
std::fs::remove_file(standalone.join("auto-update-version")).unwrap();
|
| 571 |
+
daemon.managed_codex_bin = standalone.join("current/codex");
|
| 572 |
+
release = local;
|
| 573 |
+
}
|
| 574 |
+
let daemon = std::sync::Arc::new(daemon);
|
| 575 |
+
std::fs::create_dir_all(daemon.settings_file.parent().expect("state directory"))
|
| 576 |
+
.expect("state directory");
|
| 577 |
+
std::fs::write(
|
| 578 |
+
&daemon.settings_file,
|
| 579 |
+
r#"{"updater":{"autoUpdateEnabled":false}}"#,
|
| 580 |
+
)
|
| 581 |
+
.expect("disabled updater");
|
| 582 |
+
let server = test_control_server(&daemon, home.path()).await;
|
| 583 |
+
let settings = crate::settings::DaemonSettings::default();
|
| 584 |
+
let backend = crate::backend::pid_backend(daemon.backend_paths(&settings));
|
| 585 |
+
backend.start().await.expect("start daemon");
|
| 586 |
+
let current_pid = || {
|
| 587 |
+
serde_json::from_slice::<serde_json::Value>(
|
| 588 |
+
&std::fs::read(&daemon.pid_file).expect("daemon PID record"),
|
| 589 |
+
)
|
| 590 |
+
.expect("PID JSON")["pid"]
|
| 591 |
+
.as_u64()
|
| 592 |
+
.expect("PID")
|
| 593 |
+
};
|
| 594 |
+
let before = current_pid();
|
| 595 |
+
let version = if local_package { "1.0.0" } else { "1.1.0" };
|
| 596 |
+
let next = release
|
| 597 |
+
.trim_start_matches("local-development-")
|
| 598 |
+
.replacen("1.0.0", version, 1);
|
| 599 |
+
let guard = if local_package {
|
| 600 |
+
"CODEX_INSTALL_IF_CURRENT"
|
| 601 |
+
} else {
|
| 602 |
+
"CODEX_INSTALL_IF_LATEST"
|
| 603 |
+
};
|
| 604 |
+
let install_binary = if local_package {
|
| 605 |
+
// Same binary and version, but a different package: it must still restart.
|
| 606 |
+
format!(
|
| 607 |
+
"cp '{root}/releases/{release}/codex' '{root}/releases/{next}/bin/codex'",
|
| 608 |
+
root = standalone.display()
|
| 609 |
+
)
|
| 610 |
+
} else {
|
| 611 |
+
format!(
|
| 612 |
+
r#"printf '#!/bin/sh\nif [ "$1" = --version ]; then echo codex 1.1.0; else exec sleep 30; fi\n' > '{root}/releases/{next}/bin/codex'"#,
|
| 613 |
+
root = standalone.display()
|
| 614 |
+
)
|
| 615 |
+
};
|
| 616 |
+
let ready = home.path().join("installer-ready");
|
| 617 |
+
let proceed = home.path().join("installer-proceed");
|
| 618 |
+
let script = format!(
|
| 619 |
+
"#!/bin/sh\n# CODEX_INSTALL_IF_LATEST CODEX_INSTALL_IF_CURRENT CODEX_INSTALL_DAEMON_ONLY\nif [ \"$CODEX_UPDATE_FROM_RELEASE\" = '{next}' ]; then exit 0; fi\ntest \"${guard}\" = 1 || exit 4\ntest \"$CODEX_UPDATE_FROM_RELEASE\" = '{release}' || exit 5\ntouch '{ready}'\nwhile [ ! -e '{proceed}' ]; do sleep .05; done\nmkdir -p '{root}/releases/{next}/bin'\n{install_binary}\nchmod +x '{root}/releases/{next}/bin/codex'\nln -sfn 'releases/{next}' '{root}/current'\nprintf '{next}' > '{root}/auto-update-version'\n",
|
| 620 |
+
root = standalone.display(),
|
| 621 |
+
ready = ready.display(),
|
| 622 |
+
proceed = proceed.display(),
|
| 623 |
+
);
|
| 624 |
+
let http = FakeInstallerHttp::new(InstallerResponse::Success(script.into_bytes()));
|
| 625 |
+
let updater_daemon = std::sync::Arc::clone(&daemon);
|
| 626 |
+
let restore_release = local_package.then(|| release.clone());
|
| 627 |
+
let worker = tokio::spawn(async move {
|
| 628 |
+
super::run_with_http(
|
| 629 |
+
&http,
|
| 630 |
+
&updater_daemon,
|
| 631 |
+
&executable_identity_from_bytes(b"updater"),
|
| 632 |
+
restore_release,
|
| 633 |
+
)
|
| 634 |
+
.await
|
| 635 |
+
});
|
| 636 |
+
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
| 637 |
+
while !daemon.manual_update_socket_path().exists() {
|
| 638 |
+
assert!(
|
| 639 |
+
tokio::time::Instant::now() < deadline,
|
| 640 |
+
"updater did not listen"
|
| 641 |
+
);
|
| 642 |
+
tokio::time::sleep(Duration::from_millis(20)).await;
|
| 643 |
+
}
|
| 644 |
+
let request_daemon = std::sync::Arc::clone(&daemon);
|
| 645 |
+
let request = tokio::spawn(async move { super::manual_update::request(&request_daemon).await });
|
| 646 |
+
while !ready.exists() {
|
| 647 |
+
assert!(
|
| 648 |
+
tokio::time::Instant::now() < deadline,
|
| 649 |
+
"installer did not start"
|
| 650 |
+
);
|
| 651 |
+
tokio::time::sleep(Duration::from_millis(20)).await;
|
| 652 |
+
}
|
| 653 |
+
let mut queued = codex_uds::UnixStream::connect(&daemon.manual_update_socket_path())
|
| 654 |
+
.await
|
| 655 |
+
.expect("queue a second update");
|
| 656 |
+
queued
|
| 657 |
+
.write_all(b"update\n")
|
| 658 |
+
.await
|
| 659 |
+
.expect("send second update");
|
| 660 |
+
std::fs::write(proceed, b"go").expect("release installer");
|
| 661 |
+
let output = request
|
| 662 |
+
.await
|
| 663 |
+
.expect("first request task")
|
| 664 |
+
.expect("manual update");
|
| 665 |
+
assert_eq!(output.status, UpdateStatus::Updated);
|
| 666 |
+
assert_eq!(output.installed_version.as_deref(), Some(version));
|
| 667 |
+
assert_eq!(output.running_version.as_deref(), Some(version));
|
| 668 |
+
assert_eq!(
|
| 669 |
+
output.managed_codex_path,
|
| 670 |
+
standalone.join("current/bin/codex")
|
| 671 |
+
);
|
| 672 |
+
let restarted = current_pid();
|
| 673 |
+
assert_ne!(restarted, before);
|
| 674 |
+
let mut response = Vec::new();
|
| 675 |
+
queued
|
| 676 |
+
.read_to_end(&mut response)
|
| 677 |
+
.await
|
| 678 |
+
.expect("second response");
|
| 679 |
+
let second: Result<crate::UpdateOutput, String> =
|
| 680 |
+
serde_json::from_slice(&response).expect("valid second response");
|
| 681 |
+
assert_eq!(
|
| 682 |
+
second.expect("second update").status,
|
| 683 |
+
UpdateStatus::NoUpdate
|
| 684 |
+
);
|
| 685 |
+
assert_eq!(current_pid(), restarted);
|
| 686 |
+
tokio::time::timeout(Duration::from_secs(5), worker)
|
| 687 |
+
.await
|
| 688 |
+
.expect("one-shot updater did not exit")
|
| 689 |
+
.expect("updater task")
|
| 690 |
+
.expect("updater loop");
|
| 691 |
+
let no_op = FakeInstallerHttp::new(InstallerResponse::Success(
|
| 692 |
+
b"#!/bin/sh\n# CODEX_INSTALL_IF_LATEST CODEX_INSTALL_DAEMON_ONLY\nexit 0\n".to_vec(),
|
| 693 |
+
));
|
| 694 |
+
use std::io::Write;
|
| 695 |
+
std::fs::OpenOptions::new()
|
| 696 |
+
.append(true)
|
| 697 |
+
.open(
|
| 698 |
+
daemon
|
| 699 |
+
.current_managed_codex_bin()
|
| 700 |
+
.expect("current executable"),
|
| 701 |
+
)
|
| 702 |
+
.expect("managed executable")
|
| 703 |
+
.write_all(b"\n# same-version replacement\n")
|
| 704 |
+
.expect("replace binary bytes");
|
| 705 |
+
let output = manual_update_once(
|
| 706 |
+
&no_op,
|
| 707 |
+
&daemon,
|
| 708 |
+
&executable_identity_from_bytes(b"updater"),
|
| 709 |
+
&mut test_terminate(),
|
| 710 |
+
super::UpdateTrigger::Manual,
|
| 711 |
+
)
|
| 712 |
+
.await
|
| 713 |
+
.expect("retry with stale running binary");
|
| 714 |
+
assert_eq!(output.status, UpdateStatus::NoUpdate);
|
| 715 |
+
assert_ne!(current_pid(), restarted);
|
| 716 |
+
backend.stop().await.expect("stop daemon");
|
| 717 |
+
server.abort();
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
#[cfg(windows)]
|
| 721 |
+
#[tokio::test]
|
| 722 |
+
async fn powershell_installer_is_noninteractive_and_reports_script_failure() {
|
| 723 |
+
let valid = FakeInstallerHttp::new(InstallerResponse::Success(
|
| 724 |
+
br#"
|
| 725 |
+
function Test-Installer {
|
| 726 |
+
if ($env:CODEX_NON_INTERACTIVE -ne '1') { throw 'interactive installer' }
|
| 727 |
+
if ($env:CODEX_INSTALL_DAEMON_ONLY -ne '1') { throw 'wrong package destination' }
|
| 728 |
+
if ($env:CODEX_INSTALL_IF_CURRENT -ne '1' -or $env:CODEX_INSTALL_IF_LATEST -ne '0') { throw 'wrong update guard' }
|
| 729 |
+
}
|
| 730 |
+
Test-Installer
|
| 731 |
+
"#
|
| 732 |
+
.to_vec(),
|
| 733 |
+
));
|
| 734 |
+
let script = super::fetch_installer_script(&valid)
|
| 735 |
+
.await
|
| 736 |
+
.expect("fetch installer");
|
| 737 |
+
super::run_installer_script(
|
| 738 |
+
&script,
|
| 739 |
+
super::InstallerMode::RestoreProduction("0.150.0-x86_64-pc-windows-msvc"),
|
| 740 |
+
std::path::Path::new("packages/app-server-daemon"),
|
| 741 |
+
)
|
| 742 |
+
.await
|
| 743 |
+
.expect("installer succeeds");
|
| 744 |
+
let failing = FakeInstallerHttp::new(InstallerResponse::Success(
|
| 745 |
+
b"throw 'installer failed'".to_vec(),
|
| 746 |
+
));
|
| 747 |
+
let script = super::fetch_installer_script(&failing)
|
| 748 |
+
.await
|
| 749 |
+
.expect("fetch failing installer");
|
| 750 |
+
assert!(
|
| 751 |
+
super::run_installer_script(
|
| 752 |
+
&script,
|
| 753 |
+
super::InstallerMode::RestoreProduction("0.150.0-x86_64-pc-windows-msvc"),
|
| 754 |
+
std::path::Path::new("packages/app-server-daemon")
|
| 755 |
+
)
|
| 756 |
+
.await
|
| 757 |
+
.is_err()
|
| 758 |
+
);
|
| 759 |
+
}
|
| 760 |
+
|
| 761 |
+
#[cfg(unix)]
|
| 762 |
+
#[tokio::test]
|
| 763 |
+
async fn update_rejects_a_package_root_change_during_download() {
|
| 764 |
+
struct ChangingRoot<'a>(&'a std::path::Path);
|
| 765 |
+
impl InstallerHttp for ChangingRoot<'_> {
|
| 766 |
+
async fn get(&self, _url: &str) -> anyhow::Result<InstallerResponse> {
|
| 767 |
+
std::os::unix::fs::symlink(
|
| 768 |
+
self.0.join("packages/standalone"),
|
| 769 |
+
self.0.join("packages/app-server-daemon"),
|
| 770 |
+
)?;
|
| 771 |
+
Ok(InstallerResponse::Success(
|
| 772 |
+
b"# CODEX_INSTALL_IF_LATEST\nexit 99\n".to_vec(),
|
| 773 |
+
))
|
| 774 |
+
}
|
| 775 |
+
}
|
| 776 |
+
let home = TempDir::new().unwrap();
|
| 777 |
+
let (daemon, _) = manual_update_daemon(&home);
|
| 778 |
+
let identity = executable_identity(&daemon.managed_codex_bin)
|
| 779 |
+
.await
|
| 780 |
+
.unwrap();
|
| 781 |
+
let error = manual_update_once(
|
| 782 |
+
&ChangingRoot(home.path()),
|
| 783 |
+
&daemon,
|
| 784 |
+
&identity,
|
| 785 |
+
&mut test_terminate(),
|
| 786 |
+
super::UpdateTrigger::Manual,
|
| 787 |
+
)
|
| 788 |
+
.await
|
| 789 |
+
.unwrap_err();
|
| 790 |
+
assert!(error.to_string().contains("package root changed"));
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
#[cfg(unix)]
|
| 794 |
+
#[tokio::test]
|
| 795 |
+
async fn daemon_owned_updates_require_and_request_an_isolated_installer() {
|
| 796 |
+
let home = TempDir::new().unwrap();
|
| 797 |
+
let (mut daemon, release) = manual_update_daemon(&home);
|
| 798 |
+
let root = home.path().join("packages/app-server-daemon");
|
| 799 |
+
std::fs::rename(home.path().join("packages/standalone"), &root).unwrap();
|
| 800 |
+
daemon.managed_codex_bin = root.join("current/codex");
|
| 801 |
+
let identity = executable_identity(&daemon.managed_codex_bin)
|
| 802 |
+
.await
|
| 803 |
+
.unwrap();
|
| 804 |
+
let old = FakeInstallerHttp::new(InstallerResponse::Success(
|
| 805 |
+
b"# CODEX_INSTALL_IF_LATEST\nexit 0\n".to_vec(),
|
| 806 |
+
));
|
| 807 |
+
let error = manual_update_once(
|
| 808 |
+
&old,
|
| 809 |
+
&daemon,
|
| 810 |
+
&identity,
|
| 811 |
+
&mut test_terminate(),
|
| 812 |
+
super::UpdateTrigger::Manual,
|
| 813 |
+
)
|
| 814 |
+
.await
|
| 815 |
+
.unwrap_err();
|
| 816 |
+
assert!(
|
| 817 |
+
error
|
| 818 |
+
.to_string()
|
| 819 |
+
.contains("does not support daemon-owned packages")
|
| 820 |
+
);
|
| 821 |
+
let current = FakeInstallerHttp::new(InstallerResponse::Success(
|
| 822 |
+
b"# CODEX_INSTALL_IF_LATEST\ntest \"$CODEX_INSTALL_IF_LATEST\" = 1 && test \"$CODEX_INSTALL_DAEMON_ONLY\" = 1\n".to_vec(),
|
| 823 |
+
));
|
| 824 |
+
let output = manual_update_once(
|
| 825 |
+
¤t,
|
| 826 |
+
&daemon,
|
| 827 |
+
&identity,
|
| 828 |
+
&mut test_terminate(),
|
| 829 |
+
super::UpdateTrigger::Manual,
|
| 830 |
+
)
|
| 831 |
+
.await
|
| 832 |
+
.unwrap();
|
| 833 |
+
assert_eq!(output.status, UpdateStatus::NoUpdate);
|
| 834 |
+
assert!(!home.path().join("packages/standalone").exists());
|
| 835 |
+
|
| 836 |
+
// Reuse the stopped daemon to verify explicit unpinning with either preference.
|
| 837 |
+
let marker = root.join("auto-update-version");
|
| 838 |
+
let no_op = FakeInstallerHttp::new(InstallerResponse::Success(
|
| 839 |
+
b"# CODEX_INSTALL_IF_CURRENT CODEX_INSTALL_DAEMON_ONLY\nexit 0\n".to_vec(),
|
| 840 |
+
));
|
| 841 |
+
let script = format!(
|
| 842 |
+
r#"# CODEX_INSTALL_IF_CURRENT CODEX_INSTALL_DAEMON_ONLY
|
| 843 |
+
set -eu
|
| 844 |
+
test "$CODEX_INSTALL_IF_CURRENT" = 1
|
| 845 |
+
test "$CODEX_INSTALL_IF_LATEST" = 0
|
| 846 |
+
test "$CODEX_INSTALL_DAEMON_ONLY" = 1
|
| 847 |
+
test "$CODEX_RELEASE" = latest
|
| 848 |
+
test "$CODEX_UPDATE_FROM_RELEASE" = '{release}'
|
| 849 |
+
printf '%s' '{release}' > '{marker}'
|
| 850 |
+
"#,
|
| 851 |
+
marker = marker.display()
|
| 852 |
+
);
|
| 853 |
+
let restore = FakeInstallerHttp::new(InstallerResponse::Success(script.into_bytes()));
|
| 854 |
+
for auto_update_enabled in [false, true] {
|
| 855 |
+
let settings = format!(r#"{{"updater":{{"autoUpdateEnabled":{auto_update_enabled}}}}}"#);
|
| 856 |
+
std::fs::write(&daemon.settings_file, &settings).unwrap();
|
| 857 |
+
std::fs::remove_file(&marker).unwrap();
|
| 858 |
+
let error = manual_update_once(
|
| 859 |
+
&no_op,
|
| 860 |
+
&daemon,
|
| 861 |
+
&identity,
|
| 862 |
+
&mut test_terminate(),
|
| 863 |
+
super::UpdateTrigger::RestoreProduction(&release),
|
| 864 |
+
)
|
| 865 |
+
.await
|
| 866 |
+
.unwrap_err();
|
| 867 |
+
assert!(
|
| 868 |
+
error
|
| 869 |
+
.to_string()
|
| 870 |
+
.contains("did not select a stable latest release")
|
| 871 |
+
);
|
| 872 |
+
assert!(!marker.exists());
|
| 873 |
+
let restored = manual_update_once(
|
| 874 |
+
&restore,
|
| 875 |
+
&daemon,
|
| 876 |
+
&identity,
|
| 877 |
+
&mut test_terminate(),
|
| 878 |
+
super::UpdateTrigger::RestoreProduction(&release),
|
| 879 |
+
)
|
| 880 |
+
.await
|
| 881 |
+
.unwrap();
|
| 882 |
+
// Restoring eligibility for the same package still reports noUpdate.
|
| 883 |
+
assert_eq!(restored, output);
|
| 884 |
+
assert!(!daemon.pid_file.exists());
|
| 885 |
+
assert!(daemon.is_stable_standalone_release().unwrap());
|
| 886 |
+
assert_eq!(
|
| 887 |
+
std::fs::read_to_string(&daemon.settings_file).unwrap(),
|
| 888 |
+
settings
|
| 889 |
+
);
|
| 890 |
+
}
|
| 891 |
+
}
|
codex-rs/build-info/src/build_info_tests.rs
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::fs;
|
| 2 |
+
|
| 3 |
+
use codex_install_context::InstallContext;
|
| 4 |
+
use pretty_assertions::assert_eq;
|
| 5 |
+
use semver::Version;
|
| 6 |
+
use tempfile::tempdir;
|
| 7 |
+
|
| 8 |
+
use crate::BuildInfo;
|
| 9 |
+
use crate::build_id;
|
| 10 |
+
|
| 11 |
+
const BUILD_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567";
|
| 12 |
+
|
| 13 |
+
/// A packaged runtime takes its release identity from its package manifest.
|
| 14 |
+
#[test]
|
| 15 |
+
fn packaged_runtime_uses_manifest_version() {
|
| 16 |
+
let package = tempdir().expect("create runtime package");
|
| 17 |
+
let bin_dir = package.path().join("bin");
|
| 18 |
+
fs::create_dir(&bin_dir).expect("create runtime binary directory");
|
| 19 |
+
let executable = bin_dir.join("codex");
|
| 20 |
+
fs::write(&executable, b"").expect("create runtime binary");
|
| 21 |
+
fs::write(
|
| 22 |
+
package.path().join("codex-package.json"),
|
| 23 |
+
r#"{"version":"1.2.3-alpha.4"}"#,
|
| 24 |
+
)
|
| 25 |
+
.expect("create runtime package manifest");
|
| 26 |
+
|
| 27 |
+
let context = InstallContext::from_exe(
|
| 28 |
+
cfg!(target_os = "macos"),
|
| 29 |
+
Some(&executable),
|
| 30 |
+
/*method_override*/ None,
|
| 31 |
+
);
|
| 32 |
+
|
| 33 |
+
assert_eq!(
|
| 34 |
+
BuildInfo::resolve(&context, BUILD_COMMIT),
|
| 35 |
+
BuildInfo {
|
| 36 |
+
version: Version::parse("1.2.3-alpha.4").expect("valid release version"),
|
| 37 |
+
build_commit: BUILD_COMMIT.to_string(),
|
| 38 |
+
target: Some(env!("CODEX_BUILD_TARGET").to_string()),
|
| 39 |
+
},
|
| 40 |
+
);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/// Unpackaged builds expose their stamped commit and structured source version.
|
| 44 |
+
#[test]
|
| 45 |
+
fn unpackaged_runtime_uses_build_commit() {
|
| 46 |
+
let context = InstallContext::from_exe(
|
| 47 |
+
cfg!(target_os = "macos"),
|
| 48 |
+
/*current_exe*/ None,
|
| 49 |
+
/*method_override*/ None,
|
| 50 |
+
);
|
| 51 |
+
|
| 52 |
+
assert_eq!(
|
| 53 |
+
BuildInfo::resolve(&context, BUILD_COMMIT),
|
| 54 |
+
BuildInfo {
|
| 55 |
+
version: Version::new(0, 0, 0),
|
| 56 |
+
build_commit: BUILD_COMMIT.to_string(),
|
| 57 |
+
target: Some(env!("CODEX_BUILD_TARGET").to_string()),
|
| 58 |
+
},
|
| 59 |
+
);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/// Older package layouts without release metadata retain their build identity.
|
| 63 |
+
#[test]
|
| 64 |
+
fn legacy_package_without_version_uses_build_commit() {
|
| 65 |
+
let package = tempdir().expect("create runtime package");
|
| 66 |
+
let bin_dir = package.path().join("bin");
|
| 67 |
+
fs::create_dir(&bin_dir).expect("create runtime binary directory");
|
| 68 |
+
let executable = bin_dir.join("codex");
|
| 69 |
+
fs::write(&executable, b"").expect("create runtime binary");
|
| 70 |
+
fs::write(package.path().join("codex-package.json"), "{}")
|
| 71 |
+
.expect("create legacy runtime package manifest");
|
| 72 |
+
|
| 73 |
+
let context = InstallContext::from_exe(
|
| 74 |
+
cfg!(target_os = "macos"),
|
| 75 |
+
Some(&executable),
|
| 76 |
+
/*method_override*/ None,
|
| 77 |
+
);
|
| 78 |
+
|
| 79 |
+
assert_eq!(
|
| 80 |
+
BuildInfo::resolve(&context, BUILD_COMMIT),
|
| 81 |
+
BuildInfo {
|
| 82 |
+
version: Version::new(0, 0, 0),
|
| 83 |
+
build_commit: BUILD_COMMIT.to_string(),
|
| 84 |
+
target: Some(env!("CODEX_BUILD_TARGET").to_string()),
|
| 85 |
+
},
|
| 86 |
+
);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/// Invalid package versions cannot override the executable's stamped commit.
|
| 90 |
+
#[test]
|
| 91 |
+
fn invalid_package_version_uses_build_commit() {
|
| 92 |
+
let package = tempdir().expect("create runtime package");
|
| 93 |
+
let bin_dir = package.path().join("bin");
|
| 94 |
+
fs::create_dir(&bin_dir).expect("create runtime binary directory");
|
| 95 |
+
let executable = bin_dir.join("codex");
|
| 96 |
+
fs::write(&executable, b"").expect("create runtime binary");
|
| 97 |
+
fs::write(
|
| 98 |
+
package.path().join("codex-package.json"),
|
| 99 |
+
r#"{"version":"not-a-release-version"}"#,
|
| 100 |
+
)
|
| 101 |
+
.expect("create runtime package manifest");
|
| 102 |
+
|
| 103 |
+
let context = InstallContext::from_exe(
|
| 104 |
+
cfg!(target_os = "macos"),
|
| 105 |
+
Some(&executable),
|
| 106 |
+
/*method_override*/ None,
|
| 107 |
+
);
|
| 108 |
+
|
| 109 |
+
assert_eq!(
|
| 110 |
+
BuildInfo::resolve(&context, BUILD_COMMIT),
|
| 111 |
+
BuildInfo {
|
| 112 |
+
version: Version::new(0, 0, 0),
|
| 113 |
+
build_commit: BUILD_COMMIT.to_string(),
|
| 114 |
+
target: Some(env!("CODEX_BUILD_TARGET").to_string()),
|
| 115 |
+
},
|
| 116 |
+
);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/// Serializing build information preserves release version, commit, and target.
|
| 120 |
+
#[test]
|
| 121 |
+
fn build_info_serialization_preserves_build_provenance() {
|
| 122 |
+
let build_info = BuildInfo {
|
| 123 |
+
version: Version::parse("1.2.3-alpha.4").expect("valid release version"),
|
| 124 |
+
build_commit: BUILD_COMMIT.to_string(),
|
| 125 |
+
target: Some("x86_64-pc-windows-msvc".to_string()),
|
| 126 |
+
};
|
| 127 |
+
let serialized = serde_json::json!({
|
| 128 |
+
"version": "1.2.3-alpha.4",
|
| 129 |
+
"build_commit": BUILD_COMMIT,
|
| 130 |
+
"target": "x86_64-pc-windows-msvc",
|
| 131 |
+
});
|
| 132 |
+
|
| 133 |
+
assert_eq!(
|
| 134 |
+
serde_json::to_value(&build_info).expect("serialize build information"),
|
| 135 |
+
serialized,
|
| 136 |
+
);
|
| 137 |
+
assert_eq!(
|
| 138 |
+
serde_json::from_value::<BuildInfo>(serialized).expect("deserialize build information"),
|
| 139 |
+
build_info,
|
| 140 |
+
);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
#[test]
|
| 144 |
+
fn historical_build_info_does_not_infer_the_current_target() {
|
| 145 |
+
let legacy = serde_json::json!({ "version": "1.2.3", "build_commit": BUILD_COMMIT });
|
| 146 |
+
for info in [
|
| 147 |
+
serde_json::from_value::<BuildInfo>(legacy).expect("deserialize legacy build information"),
|
| 148 |
+
BuildInfo::from_version("1.2.3"),
|
| 149 |
+
BuildInfo::from_version(BUILD_COMMIT),
|
| 150 |
+
] {
|
| 151 |
+
assert_eq!(info.target(), None);
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
#[test]
|
| 156 |
+
fn build_id_is_stable_and_uses_the_supplied_commit_and_target() {
|
| 157 |
+
// These vectors also cover targets different from the machine running CI.
|
| 158 |
+
for (target, digest) in [
|
| 159 |
+
(
|
| 160 |
+
"aarch64-apple-darwin",
|
| 161 |
+
"90138d7ee35f3f61cd61eb55e8d64105f856055641af175388b102dffa772594",
|
| 162 |
+
),
|
| 163 |
+
(
|
| 164 |
+
"x86_64-unknown-linux-gnu",
|
| 165 |
+
"89f3a373036537bde64861b3ad8b1c9494924b0174622e47acda695619630d09",
|
| 166 |
+
),
|
| 167 |
+
(
|
| 168 |
+
"x86_64-unknown-linux-musl",
|
| 169 |
+
"fb4f62da3e84f6864dcec8ede7bc66f1c96ecaeaf55f8a786b85df994057c8ac",
|
| 170 |
+
),
|
| 171 |
+
] {
|
| 172 |
+
let expected = Some(format!("sha256:{digest}"));
|
| 173 |
+
assert_eq!(build_id(BUILD_COMMIT, target), expected);
|
| 174 |
+
assert_eq!(
|
| 175 |
+
build_id(&BUILD_COMMIT.to_ascii_uppercase(), target),
|
| 176 |
+
expected
|
| 177 |
+
);
|
| 178 |
+
assert_ne!(
|
| 179 |
+
build_id("1123456789abcdef0123456789abcdef01234567", target),
|
| 180 |
+
expected
|
| 181 |
+
);
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
#[test]
|
| 186 |
+
fn build_id_requires_a_valid_stamp_and_target() {
|
| 187 |
+
for commit in [
|
| 188 |
+
"",
|
| 189 |
+
"dev",
|
| 190 |
+
"unknown",
|
| 191 |
+
"0123456",
|
| 192 |
+
"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
|
| 193 |
+
] {
|
| 194 |
+
assert_eq!(build_id(commit, "x86_64-unknown-linux-musl"), None);
|
| 195 |
+
}
|
| 196 |
+
assert_eq!(build_id(BUILD_COMMIT, ""), None);
|
| 197 |
+
}
|
codex-rs/build-info/src/lib.rs
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Resolve the packaged release version, compiled commit, and target of the current runtime.
|
| 2 |
+
|
| 3 |
+
use std::fmt;
|
| 4 |
+
use std::sync::OnceLock;
|
| 5 |
+
|
| 6 |
+
use codex_install_context::InstallContext;
|
| 7 |
+
use semver::Version;
|
| 8 |
+
use serde::Deserialize;
|
| 9 |
+
use serde::Serialize;
|
| 10 |
+
use sha2::Digest;
|
| 11 |
+
use sha2::Sha256;
|
| 12 |
+
|
| 13 |
+
static BUILD_INFO: OnceLock<BuildInfo> = OnceLock::new();
|
| 14 |
+
|
| 15 |
+
/// Initialize build information from the commit stamped into the calling executable.
|
| 16 |
+
///
|
| 17 |
+
/// The environment lookup intentionally expands at the macro call site so Git
|
| 18 |
+
/// changes invalidate only final binary actions, not this shared library.
|
| 19 |
+
/// Cargo builds must supply STABLE_GIT_COMMIT in the build environment.
|
| 20 |
+
#[macro_export]
|
| 21 |
+
macro_rules! initialize {
|
| 22 |
+
() => {
|
| 23 |
+
$crate::BuildInfo::initialize(option_env!("STABLE_GIT_COMMIT").unwrap_or("dev"));
|
| 24 |
+
};
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/// The packaged release version and build provenance for the current runtime.
|
| 28 |
+
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
| 29 |
+
pub struct BuildInfo {
|
| 30 |
+
version: Version,
|
| 31 |
+
build_commit: String,
|
| 32 |
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
| 33 |
+
target: Option<String>,
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
impl BuildInfo {
|
| 37 |
+
/// Return build information for the current Codex runtime.
|
| 38 |
+
pub fn get() -> Self {
|
| 39 |
+
BUILD_INFO
|
| 40 |
+
.get_or_init(|| Self::resolve(InstallContext::current(), "dev"))
|
| 41 |
+
.clone()
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/// Initialize build information using the final executable's stamped commit.
|
| 45 |
+
///
|
| 46 |
+
/// Keeping the stamp in the executable prevents Git operations from
|
| 47 |
+
/// invalidating the shared Rust library graph.
|
| 48 |
+
#[doc(hidden)]
|
| 49 |
+
pub fn initialize(build_commit: &'static str) {
|
| 50 |
+
let _ = BUILD_INFO.get_or_init(|| Self::resolve(InstallContext::current(), build_commit));
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/// Recover structured release information from a persisted version string.
|
| 54 |
+
pub fn from_version(version: impl Into<String>) -> Self {
|
| 55 |
+
let version = version.into();
|
| 56 |
+
match Version::parse(&version) {
|
| 57 |
+
Ok(parsed_version) => Self {
|
| 58 |
+
build_commit: if parsed_version.major == 0
|
| 59 |
+
&& parsed_version.minor == 0
|
| 60 |
+
&& parsed_version.patch == 0
|
| 61 |
+
{
|
| 62 |
+
version
|
| 63 |
+
} else {
|
| 64 |
+
"unknown".to_string()
|
| 65 |
+
},
|
| 66 |
+
version: parsed_version,
|
| 67 |
+
target: None,
|
| 68 |
+
},
|
| 69 |
+
Err(_) => Self {
|
| 70 |
+
version: Version::new(0, 0, 0),
|
| 71 |
+
build_commit: version,
|
| 72 |
+
target: None,
|
| 73 |
+
},
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/// Return the parsed package version, or `0.0.0` for a source build.
|
| 78 |
+
pub fn version(&self) -> &Version {
|
| 79 |
+
&self.version
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/// Format the version for a user-facing Codex header.
|
| 83 |
+
pub fn display_version(&self) -> String {
|
| 84 |
+
if self.build_commit == "dev" {
|
| 85 |
+
"dev".to_string()
|
| 86 |
+
} else if self.is_source_build() {
|
| 87 |
+
format!("v{}", self.build_commit)
|
| 88 |
+
} else {
|
| 89 |
+
format!("v{}", self.version)
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
/// Identify source builds without parsing their displayed Git commit.
|
| 94 |
+
pub fn is_source_build(&self) -> bool {
|
| 95 |
+
self.version.major == 0 && self.version.minor == 0 && self.version.patch == 0
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/// Return the Git commit stamped into the final executable.
|
| 99 |
+
pub fn build_commit(&self) -> &str {
|
| 100 |
+
&self.build_commit
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/// Return the compiler target triple, or `None` for historical metadata without a target.
|
| 104 |
+
pub fn target(&self) -> Option<&str> {
|
| 105 |
+
self.target.as_deref()
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
fn resolve(install_context: &InstallContext, build_commit: &'static str) -> Self {
|
| 109 |
+
if let Some(manifest) = install_context.package_manifest() {
|
| 110 |
+
return Self {
|
| 111 |
+
version: manifest.version,
|
| 112 |
+
build_commit: build_commit.to_owned(),
|
| 113 |
+
target: Some(env!("CODEX_BUILD_TARGET").to_owned()),
|
| 114 |
+
};
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
Self {
|
| 118 |
+
version: Version::new(0, 0, 0),
|
| 119 |
+
build_commit: build_commit.to_owned(),
|
| 120 |
+
target: Some(env!("CODEX_BUILD_TARGET").to_owned()),
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/// Derive a standard build's opaque ID from its stamped commit and compiler target.
|
| 126 |
+
///
|
| 127 |
+
/// Explicit inputs let CI identify cross-compiled builds without running them.
|
| 128 |
+
/// Runtime callers must use the running executable's stamp and target. The stable
|
| 129 |
+
/// encoding is SHA-256 of `git:<full lowercase commit>:<target>`, excluding version.
|
| 130 |
+
/// This identifies a standard build configuration, not exact executable bytes.
|
| 131 |
+
pub fn build_id(build_commit: &str, target: &str) -> Option<String> {
|
| 132 |
+
if build_commit.len() != 40
|
| 133 |
+
|| !build_commit.bytes().all(|byte| byte.is_ascii_hexdigit())
|
| 134 |
+
|| target.is_empty()
|
| 135 |
+
{
|
| 136 |
+
return None;
|
| 137 |
+
}
|
| 138 |
+
let identity = format!("git:{}:{target}", build_commit.to_ascii_lowercase());
|
| 139 |
+
let digest = Sha256::digest(identity.as_bytes());
|
| 140 |
+
Some(format!("sha256:{digest:x}"))
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
impl fmt::Display for BuildInfo {
|
| 144 |
+
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
| 145 |
+
if self.is_source_build() {
|
| 146 |
+
formatter.write_str(&self.build_commit)
|
| 147 |
+
} else {
|
| 148 |
+
self.version.fmt(formatter)
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
#[cfg(test)]
|
| 154 |
+
#[path = "build_info_tests.rs"]
|
| 155 |
+
mod tests;
|
codex-rs/cloud-tasks/src/app.rs
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::time::Duration;
|
| 2 |
+
use std::time::Instant;
|
| 3 |
+
|
| 4 |
+
// Environment filter data models for the TUI
|
| 5 |
+
#[derive(Clone, Debug, Default)]
|
| 6 |
+
pub struct EnvironmentRow {
|
| 7 |
+
pub id: String,
|
| 8 |
+
pub label: Option<String>,
|
| 9 |
+
pub is_pinned: bool,
|
| 10 |
+
pub repo_hints: Option<String>, // e.g., "openai/codex"
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
#[derive(Clone, Debug, Default)]
|
| 14 |
+
pub struct EnvModalState {
|
| 15 |
+
pub query: String,
|
| 16 |
+
pub selected: usize,
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
#[derive(Clone, Debug, Default)]
|
| 20 |
+
pub struct BestOfModalState {
|
| 21 |
+
pub selected: usize,
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
#[derive(Clone, Debug, Copy, PartialEq, Eq)]
|
| 25 |
+
pub enum ApplyResultLevel {
|
| 26 |
+
Success,
|
| 27 |
+
Partial,
|
| 28 |
+
Error,
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
#[derive(Clone, Debug)]
|
| 32 |
+
pub struct ApplyModalState {
|
| 33 |
+
pub task_id: TaskId,
|
| 34 |
+
pub title: String,
|
| 35 |
+
pub result_message: Option<String>,
|
| 36 |
+
pub result_level: Option<ApplyResultLevel>,
|
| 37 |
+
pub skipped_paths: Vec<String>,
|
| 38 |
+
pub conflict_paths: Vec<String>,
|
| 39 |
+
pub diff_override: Option<String>,
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
use crate::scrollable_diff::ScrollableDiff;
|
| 43 |
+
use codex_cloud_tasks_client::CloudBackend;
|
| 44 |
+
use codex_cloud_tasks_client::TaskId;
|
| 45 |
+
use codex_cloud_tasks_client::TaskSummary;
|
| 46 |
+
#[derive(Default)]
|
| 47 |
+
pub struct App {
|
| 48 |
+
pub tasks: Vec<TaskSummary>,
|
| 49 |
+
pub selected: usize,
|
| 50 |
+
pub status: String,
|
| 51 |
+
pub diff_overlay: Option<DiffOverlay>,
|
| 52 |
+
pub spinner_start: Option<Instant>,
|
| 53 |
+
pub refresh_inflight: bool,
|
| 54 |
+
pub details_inflight: bool,
|
| 55 |
+
// Environment filter state
|
| 56 |
+
pub env_filter: Option<String>,
|
| 57 |
+
pub env_modal: Option<EnvModalState>,
|
| 58 |
+
pub apply_modal: Option<ApplyModalState>,
|
| 59 |
+
pub best_of_modal: Option<BestOfModalState>,
|
| 60 |
+
pub environments: Vec<EnvironmentRow>,
|
| 61 |
+
pub env_last_loaded: Option<std::time::Instant>,
|
| 62 |
+
pub env_loading: bool,
|
| 63 |
+
pub env_error: Option<String>,
|
| 64 |
+
// New Task page
|
| 65 |
+
pub new_task: Option<crate::new_task::NewTaskPage>,
|
| 66 |
+
pub best_of_n: usize,
|
| 67 |
+
// Apply preflight spinner state
|
| 68 |
+
pub apply_preflight_inflight: bool,
|
| 69 |
+
// Apply action spinner state
|
| 70 |
+
pub apply_inflight: bool,
|
| 71 |
+
// Background enrichment coordination
|
| 72 |
+
pub list_generation: u64,
|
| 73 |
+
pub in_flight: std::collections::HashSet<String>,
|
| 74 |
+
// Background enrichment caches were planned; currently unused.
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
impl App {
|
| 78 |
+
pub fn new() -> Self {
|
| 79 |
+
Self {
|
| 80 |
+
tasks: Vec::new(),
|
| 81 |
+
selected: 0,
|
| 82 |
+
status: "Press r to refresh".to_string(),
|
| 83 |
+
diff_overlay: None,
|
| 84 |
+
spinner_start: None,
|
| 85 |
+
refresh_inflight: false,
|
| 86 |
+
details_inflight: false,
|
| 87 |
+
env_filter: None,
|
| 88 |
+
env_modal: None,
|
| 89 |
+
apply_modal: None,
|
| 90 |
+
best_of_modal: None,
|
| 91 |
+
environments: Vec::new(),
|
| 92 |
+
env_last_loaded: None,
|
| 93 |
+
env_loading: false,
|
| 94 |
+
env_error: None,
|
| 95 |
+
new_task: None,
|
| 96 |
+
best_of_n: 1,
|
| 97 |
+
apply_preflight_inflight: false,
|
| 98 |
+
apply_inflight: false,
|
| 99 |
+
list_generation: 0,
|
| 100 |
+
in_flight: std::collections::HashSet::new(),
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
pub fn next(&mut self) {
|
| 105 |
+
if self.tasks.is_empty() {
|
| 106 |
+
return;
|
| 107 |
+
}
|
| 108 |
+
self.selected = (self.selected + 1).min(self.tasks.len().saturating_sub(1));
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
pub fn prev(&mut self) {
|
| 112 |
+
if self.tasks.is_empty() {
|
| 113 |
+
return;
|
| 114 |
+
}
|
| 115 |
+
if self.selected > 0 {
|
| 116 |
+
self.selected -= 1;
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
pub async fn load_tasks(
|
| 122 |
+
backend: &dyn CloudBackend,
|
| 123 |
+
env: Option<&str>,
|
| 124 |
+
) -> anyhow::Result<Vec<TaskSummary>> {
|
| 125 |
+
// In later milestones, add a small debounce, spinner, and error display.
|
| 126 |
+
let tasks = tokio::time::timeout(
|
| 127 |
+
Duration::from_secs(5),
|
| 128 |
+
backend.list_tasks(env, Some(20), /*cursor*/ None),
|
| 129 |
+
)
|
| 130 |
+
.await??;
|
| 131 |
+
// Hide review-only tasks from the main list.
|
| 132 |
+
let filtered: Vec<TaskSummary> = tasks.tasks.into_iter().filter(|t| !t.is_review).collect();
|
| 133 |
+
Ok(filtered)
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
pub struct DiffOverlay {
|
| 137 |
+
pub title: String,
|
| 138 |
+
pub task_id: TaskId,
|
| 139 |
+
pub sd: ScrollableDiff,
|
| 140 |
+
pub base_can_apply: bool,
|
| 141 |
+
pub diff_lines: Vec<String>,
|
| 142 |
+
pub text_lines: Vec<String>,
|
| 143 |
+
pub prompt: Option<String>,
|
| 144 |
+
pub attempts: Vec<AttemptView>,
|
| 145 |
+
pub selected_attempt: usize,
|
| 146 |
+
pub current_view: DetailView,
|
| 147 |
+
pub base_turn_id: Option<String>,
|
| 148 |
+
pub sibling_turn_ids: Vec<String>,
|
| 149 |
+
pub attempt_total_hint: Option<usize>,
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
#[derive(Clone, Debug, Default)]
|
| 153 |
+
pub struct AttemptView {
|
| 154 |
+
pub turn_id: Option<String>,
|
| 155 |
+
pub status: codex_cloud_tasks_client::AttemptStatus,
|
| 156 |
+
pub attempt_placement: Option<i64>,
|
| 157 |
+
pub diff_lines: Vec<String>,
|
| 158 |
+
pub text_lines: Vec<String>,
|
| 159 |
+
pub prompt: Option<String>,
|
| 160 |
+
pub diff_raw: Option<String>,
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
impl AttemptView {
|
| 164 |
+
pub fn has_diff(&self) -> bool {
|
| 165 |
+
!self.diff_lines.is_empty()
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
pub fn has_text(&self) -> bool {
|
| 169 |
+
!self.text_lines.is_empty() || self.prompt.is_some()
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
impl DiffOverlay {
|
| 174 |
+
pub fn new(task_id: TaskId, title: String, attempt_total_hint: Option<usize>) -> Self {
|
| 175 |
+
let mut sd = ScrollableDiff::new();
|
| 176 |
+
sd.set_content(Vec::new());
|
| 177 |
+
Self {
|
| 178 |
+
title,
|
| 179 |
+
task_id,
|
| 180 |
+
sd,
|
| 181 |
+
base_can_apply: false,
|
| 182 |
+
diff_lines: Vec::new(),
|
| 183 |
+
text_lines: Vec::new(),
|
| 184 |
+
prompt: None,
|
| 185 |
+
attempts: vec![AttemptView::default()],
|
| 186 |
+
selected_attempt: 0,
|
| 187 |
+
current_view: DetailView::Prompt,
|
| 188 |
+
base_turn_id: None,
|
| 189 |
+
sibling_turn_ids: Vec::new(),
|
| 190 |
+
attempt_total_hint,
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
pub fn current_attempt(&self) -> Option<&AttemptView> {
|
| 195 |
+
self.attempts.get(self.selected_attempt)
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
pub fn base_attempt_mut(&mut self) -> &mut AttemptView {
|
| 199 |
+
if self.attempts.is_empty() {
|
| 200 |
+
self.attempts.push(AttemptView::default());
|
| 201 |
+
}
|
| 202 |
+
&mut self.attempts[0]
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
pub fn set_view(&mut self, view: DetailView) {
|
| 206 |
+
self.current_view = view;
|
| 207 |
+
self.apply_selection_to_fields();
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
pub fn expected_attempts(&self) -> Option<usize> {
|
| 211 |
+
self.attempt_total_hint.or({
|
| 212 |
+
if self.attempts.is_empty() {
|
| 213 |
+
None
|
| 214 |
+
} else {
|
| 215 |
+
Some(self.attempts.len())
|
| 216 |
+
}
|
| 217 |
+
})
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
pub fn attempt_count(&self) -> usize {
|
| 221 |
+
self.attempts.len()
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
pub fn attempt_display_total(&self) -> usize {
|
| 225 |
+
self.expected_attempts()
|
| 226 |
+
.unwrap_or_else(|| self.attempts.len().max(1))
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
pub fn step_attempt(&mut self, delta: isize) -> bool {
|
| 230 |
+
let total = self.attempts.len();
|
| 231 |
+
if total <= 1 {
|
| 232 |
+
return false;
|
| 233 |
+
}
|
| 234 |
+
let total_isize = total as isize;
|
| 235 |
+
let current = self.selected_attempt as isize;
|
| 236 |
+
let mut next = current + delta;
|
| 237 |
+
next = ((next % total_isize) + total_isize) % total_isize;
|
| 238 |
+
let next = next as usize;
|
| 239 |
+
self.selected_attempt = next;
|
| 240 |
+
self.apply_selection_to_fields();
|
| 241 |
+
true
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
pub fn current_can_apply(&self) -> bool {
|
| 245 |
+
matches!(self.current_view, DetailView::Diff)
|
| 246 |
+
&& self
|
| 247 |
+
.current_attempt()
|
| 248 |
+
.and_then(|attempt| attempt.diff_raw.as_ref())
|
| 249 |
+
.map(|diff| !diff.is_empty())
|
| 250 |
+
.unwrap_or(false)
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
pub fn apply_selection_to_fields(&mut self) {
|
| 254 |
+
let (diff_lines, text_lines, prompt) = if let Some(attempt) = self.current_attempt() {
|
| 255 |
+
(
|
| 256 |
+
attempt.diff_lines.clone(),
|
| 257 |
+
attempt.text_lines.clone(),
|
| 258 |
+
attempt.prompt.clone(),
|
| 259 |
+
)
|
| 260 |
+
} else {
|
| 261 |
+
self.diff_lines.clear();
|
| 262 |
+
self.text_lines.clear();
|
| 263 |
+
self.prompt = None;
|
| 264 |
+
self.sd.set_content(vec!["<loading attempt>".to_string()]);
|
| 265 |
+
return;
|
| 266 |
+
};
|
| 267 |
+
|
| 268 |
+
self.diff_lines = diff_lines.clone();
|
| 269 |
+
self.text_lines = text_lines.clone();
|
| 270 |
+
self.prompt = prompt;
|
| 271 |
+
|
| 272 |
+
match self.current_view {
|
| 273 |
+
DetailView::Diff => {
|
| 274 |
+
if diff_lines.is_empty() {
|
| 275 |
+
self.sd.set_content(vec!["<no diff available>".to_string()]);
|
| 276 |
+
} else {
|
| 277 |
+
self.sd.set_content(diff_lines);
|
| 278 |
+
}
|
| 279 |
+
}
|
| 280 |
+
DetailView::Prompt => {
|
| 281 |
+
if text_lines.is_empty() {
|
| 282 |
+
self.sd.set_content(vec!["<no output>".to_string()]);
|
| 283 |
+
} else {
|
| 284 |
+
self.sd.set_content(text_lines);
|
| 285 |
+
}
|
| 286 |
+
}
|
| 287 |
+
}
|
| 288 |
+
}
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
| 292 |
+
pub enum DetailView {
|
| 293 |
+
Diff,
|
| 294 |
+
Prompt,
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
/// Internal app events delivered from background tasks.
|
| 298 |
+
/// These let the UI event loop remain responsive and keep the spinner animating.
|
| 299 |
+
#[derive(Debug)]
|
| 300 |
+
pub enum AppEvent {
|
| 301 |
+
TasksLoaded {
|
| 302 |
+
env: Option<String>,
|
| 303 |
+
result: anyhow::Result<Vec<TaskSummary>>,
|
| 304 |
+
},
|
| 305 |
+
// Background diff summary events were planned; removed for now to keep code minimal.
|
| 306 |
+
/// Autodetection of a likely environment id finished
|
| 307 |
+
EnvironmentAutodetected(anyhow::Result<crate::env_detect::AutodetectSelection>),
|
| 308 |
+
/// Background completion of environment list fetch
|
| 309 |
+
EnvironmentsLoaded(anyhow::Result<Vec<EnvironmentRow>>),
|
| 310 |
+
DetailsDiffLoaded {
|
| 311 |
+
id: TaskId,
|
| 312 |
+
title: String,
|
| 313 |
+
diff: String,
|
| 314 |
+
},
|
| 315 |
+
DetailsMessagesLoaded {
|
| 316 |
+
id: TaskId,
|
| 317 |
+
title: String,
|
| 318 |
+
messages: Vec<String>,
|
| 319 |
+
prompt: Option<String>,
|
| 320 |
+
turn_id: Option<String>,
|
| 321 |
+
sibling_turn_ids: Vec<String>,
|
| 322 |
+
attempt_placement: Option<i64>,
|
| 323 |
+
attempt_status: codex_cloud_tasks_client::AttemptStatus,
|
| 324 |
+
},
|
| 325 |
+
DetailsFailed {
|
| 326 |
+
id: TaskId,
|
| 327 |
+
title: String,
|
| 328 |
+
error: String,
|
| 329 |
+
},
|
| 330 |
+
AttemptsLoaded {
|
| 331 |
+
id: TaskId,
|
| 332 |
+
attempts: Vec<codex_cloud_tasks_client::TurnAttempt>,
|
| 333 |
+
},
|
| 334 |
+
/// Background completion of new task submission
|
| 335 |
+
NewTaskSubmitted(Result<codex_cloud_tasks_client::CreatedTask, String>),
|
| 336 |
+
/// Background completion of apply preflight when opening modal or on demand
|
| 337 |
+
ApplyPreflightFinished {
|
| 338 |
+
id: TaskId,
|
| 339 |
+
title: String,
|
| 340 |
+
message: String,
|
| 341 |
+
level: ApplyResultLevel,
|
| 342 |
+
skipped: Vec<String>,
|
| 343 |
+
conflicts: Vec<String>,
|
| 344 |
+
},
|
| 345 |
+
/// Background completion of apply action (actual patch application)
|
| 346 |
+
ApplyFinished {
|
| 347 |
+
id: TaskId,
|
| 348 |
+
result: std::result::Result<codex_cloud_tasks_client::ApplyOutcome, String>,
|
| 349 |
+
},
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
// Convenience aliases; currently unused.
|
| 353 |
+
#[cfg(test)]
|
| 354 |
+
mod tests {
|
| 355 |
+
use super::*;
|
| 356 |
+
use chrono::Utc;
|
| 357 |
+
use codex_cloud_tasks_client::CloudBackendFuture;
|
| 358 |
+
use codex_cloud_tasks_client::CloudTaskError;
|
| 359 |
+
|
| 360 |
+
struct FakeBackend {
|
| 361 |
+
// maps env key to titles
|
| 362 |
+
by_env: std::collections::HashMap<Option<String>, Vec<&'static str>>,
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
impl FakeBackend {
|
| 366 |
+
async fn list_tasks(
|
| 367 |
+
&self,
|
| 368 |
+
env: Option<&str>,
|
| 369 |
+
limit: Option<i64>,
|
| 370 |
+
cursor: Option<&str>,
|
| 371 |
+
) -> Result<codex_cloud_tasks_client::TaskListPage, CloudTaskError> {
|
| 372 |
+
let key = env.map(str::to_string);
|
| 373 |
+
let titles = self
|
| 374 |
+
.by_env
|
| 375 |
+
.get(&key)
|
| 376 |
+
.cloned()
|
| 377 |
+
.unwrap_or_else(|| vec!["default-a", "default-b"]);
|
| 378 |
+
let mut out = Vec::new();
|
| 379 |
+
for (i, t) in titles.into_iter().enumerate() {
|
| 380 |
+
out.push(TaskSummary {
|
| 381 |
+
id: TaskId(format!("T-{i}")),
|
| 382 |
+
title: t.to_string(),
|
| 383 |
+
status: codex_cloud_tasks_client::TaskStatus::Ready,
|
| 384 |
+
updated_at: Utc::now(),
|
| 385 |
+
environment_id: env.map(str::to_string),
|
| 386 |
+
environment_label: None,
|
| 387 |
+
summary: codex_cloud_tasks_client::DiffSummary::default(),
|
| 388 |
+
is_review: false,
|
| 389 |
+
attempt_total: Some(1),
|
| 390 |
+
});
|
| 391 |
+
}
|
| 392 |
+
let max = limit.unwrap_or(i64::MAX);
|
| 393 |
+
let max = max.min(20);
|
| 394 |
+
let mut limited = Vec::new();
|
| 395 |
+
for task in out {
|
| 396 |
+
if (limited.len() as i64) >= max {
|
| 397 |
+
break;
|
| 398 |
+
}
|
| 399 |
+
limited.push(task);
|
| 400 |
+
}
|
| 401 |
+
Ok(codex_cloud_tasks_client::TaskListPage {
|
| 402 |
+
tasks: limited,
|
| 403 |
+
cursor: cursor.map(str::to_string),
|
| 404 |
+
})
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
async fn get_task_summary(&self, id: TaskId) -> Result<TaskSummary, CloudTaskError> {
|
| 408 |
+
self.list_tasks(/*env*/ None, /*limit*/ None, /*cursor*/ None)
|
| 409 |
+
.await?
|
| 410 |
+
.tasks
|
| 411 |
+
.into_iter()
|
| 412 |
+
.find(|t| t.id == id)
|
| 413 |
+
.ok_or_else(|| CloudTaskError::Msg(format!("Task {} not found", id.0)))
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
async fn get_task_text(
|
| 417 |
+
&self,
|
| 418 |
+
_id: TaskId,
|
| 419 |
+
) -> Result<codex_cloud_tasks_client::TaskText, CloudTaskError> {
|
| 420 |
+
Ok(codex_cloud_tasks_client::TaskText {
|
| 421 |
+
prompt: Some("Example prompt".to_string()),
|
| 422 |
+
messages: Vec::new(),
|
| 423 |
+
turn_id: Some("fake-turn".to_string()),
|
| 424 |
+
sibling_turn_ids: Vec::new(),
|
| 425 |
+
attempt_placement: Some(0),
|
| 426 |
+
attempt_status: codex_cloud_tasks_client::AttemptStatus::Completed,
|
| 427 |
+
})
|
| 428 |
+
}
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
impl codex_cloud_tasks_client::CloudBackend for FakeBackend {
|
| 432 |
+
fn list_tasks<'a>(
|
| 433 |
+
&'a self,
|
| 434 |
+
env: Option<&'a str>,
|
| 435 |
+
limit: Option<i64>,
|
| 436 |
+
cursor: Option<&'a str>,
|
| 437 |
+
) -> CloudBackendFuture<'a, codex_cloud_tasks_client::TaskListPage> {
|
| 438 |
+
Box::pin(FakeBackend::list_tasks(self, env, limit, cursor))
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
fn get_task_summary(&self, id: TaskId) -> CloudBackendFuture<'_, TaskSummary> {
|
| 442 |
+
Box::pin(FakeBackend::get_task_summary(self, id))
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
fn get_task_diff(&self, _id: TaskId) -> CloudBackendFuture<'_, Option<String>> {
|
| 446 |
+
Box::pin(async {
|
| 447 |
+
Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented(
|
| 448 |
+
"not used in test",
|
| 449 |
+
))
|
| 450 |
+
})
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
fn get_task_messages(&self, _id: TaskId) -> CloudBackendFuture<'_, Vec<String>> {
|
| 454 |
+
Box::pin(async { Ok(vec![]) })
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
fn get_task_text(
|
| 458 |
+
&self,
|
| 459 |
+
id: TaskId,
|
| 460 |
+
) -> CloudBackendFuture<'_, codex_cloud_tasks_client::TaskText> {
|
| 461 |
+
Box::pin(FakeBackend::get_task_text(self, id))
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
fn list_sibling_attempts(
|
| 465 |
+
&self,
|
| 466 |
+
_task: TaskId,
|
| 467 |
+
_turn_id: String,
|
| 468 |
+
) -> CloudBackendFuture<'_, Vec<codex_cloud_tasks_client::TurnAttempt>> {
|
| 469 |
+
Box::pin(async { Ok(Vec::new()) })
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
fn apply_task(
|
| 473 |
+
&self,
|
| 474 |
+
_id: TaskId,
|
| 475 |
+
_diff_override: Option<String>,
|
| 476 |
+
) -> CloudBackendFuture<'_, codex_cloud_tasks_client::ApplyOutcome> {
|
| 477 |
+
Box::pin(async {
|
| 478 |
+
Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented(
|
| 479 |
+
"not used in test",
|
| 480 |
+
))
|
| 481 |
+
})
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
fn apply_task_preflight(
|
| 485 |
+
&self,
|
| 486 |
+
_id: TaskId,
|
| 487 |
+
_diff_override: Option<String>,
|
| 488 |
+
) -> CloudBackendFuture<'_, codex_cloud_tasks_client::ApplyOutcome> {
|
| 489 |
+
Box::pin(async {
|
| 490 |
+
Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented(
|
| 491 |
+
"not used in test",
|
| 492 |
+
))
|
| 493 |
+
})
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
fn create_task<'a>(
|
| 497 |
+
&'a self,
|
| 498 |
+
_env_id: &'a str,
|
| 499 |
+
_prompt: &'a str,
|
| 500 |
+
_git_ref: &'a str,
|
| 501 |
+
_qa_mode: bool,
|
| 502 |
+
_best_of_n: usize,
|
| 503 |
+
) -> CloudBackendFuture<'a, codex_cloud_tasks_client::CreatedTask> {
|
| 504 |
+
Box::pin(async {
|
| 505 |
+
Err(codex_cloud_tasks_client::CloudTaskError::Unimplemented(
|
| 506 |
+
"not used in test",
|
| 507 |
+
))
|
| 508 |
+
})
|
| 509 |
+
}
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
#[tokio::test]
|
| 513 |
+
async fn load_tasks_uses_env_parameter() {
|
| 514 |
+
// Arrange: env-specific task titles
|
| 515 |
+
let mut by_env = std::collections::HashMap::new();
|
| 516 |
+
by_env.insert(None, vec!["root-1", "root-2"]);
|
| 517 |
+
by_env.insert(Some("env-A".to_string()), vec!["A-1"]);
|
| 518 |
+
by_env.insert(Some("env-B".to_string()), vec!["B-1", "B-2", "B-3"]);
|
| 519 |
+
let backend = FakeBackend { by_env };
|
| 520 |
+
|
| 521 |
+
// Act + Assert
|
| 522 |
+
let root = load_tasks(&backend, /*env*/ None).await.unwrap();
|
| 523 |
+
assert_eq!(root.len(), 2);
|
| 524 |
+
assert_eq!(root[0].title, "root-1");
|
| 525 |
+
|
| 526 |
+
let a = load_tasks(&backend, Some("env-A")).await.unwrap();
|
| 527 |
+
assert_eq!(a.len(), 1);
|
| 528 |
+
assert_eq!(a[0].title, "A-1");
|
| 529 |
+
|
| 530 |
+
let b = load_tasks(&backend, Some("env-B")).await.unwrap();
|
| 531 |
+
assert_eq!(b.len(), 3);
|
| 532 |
+
assert_eq!(b[2].title, "B-3");
|
| 533 |
+
}
|
| 534 |
+
}
|
codex-rs/cloud-tasks/src/cli.rs
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use clap::Args;
|
| 2 |
+
use clap::Parser;
|
| 3 |
+
use codex_utils_cli::CliConfigOverrides;
|
| 4 |
+
|
| 5 |
+
#[derive(Parser, Debug, Default)]
|
| 6 |
+
#[command(version)]
|
| 7 |
+
pub struct Cli {
|
| 8 |
+
#[clap(skip)]
|
| 9 |
+
pub config_overrides: CliConfigOverrides,
|
| 10 |
+
|
| 11 |
+
#[command(subcommand)]
|
| 12 |
+
pub command: Option<Command>,
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
#[derive(Debug, clap::Subcommand)]
|
| 16 |
+
pub enum Command {
|
| 17 |
+
/// Submit a new Codex Cloud task without launching the TUI.
|
| 18 |
+
Exec(ExecCommand),
|
| 19 |
+
/// Show the status of a Codex Cloud task.
|
| 20 |
+
Status(StatusCommand),
|
| 21 |
+
/// List Codex Cloud tasks.
|
| 22 |
+
List(ListCommand),
|
| 23 |
+
/// Apply the diff for a Codex Cloud task locally.
|
| 24 |
+
Apply(ApplyCommand),
|
| 25 |
+
/// Show the unified diff for a Codex Cloud task.
|
| 26 |
+
Diff(DiffCommand),
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
#[derive(Debug, Args)]
|
| 30 |
+
pub struct ExecCommand {
|
| 31 |
+
/// Task prompt to run in Codex Cloud.
|
| 32 |
+
#[arg(value_name = "QUERY")]
|
| 33 |
+
pub query: Option<String>,
|
| 34 |
+
|
| 35 |
+
/// Target environment identifier (see `codex cloud` to browse).
|
| 36 |
+
#[arg(long = "env", value_name = "ENV_ID")]
|
| 37 |
+
pub environment: String,
|
| 38 |
+
|
| 39 |
+
/// Number of assistant attempts (best-of-N).
|
| 40 |
+
#[arg(
|
| 41 |
+
long = "attempts",
|
| 42 |
+
default_value_t = 1usize,
|
| 43 |
+
value_parser = parse_attempts
|
| 44 |
+
)]
|
| 45 |
+
pub attempts: usize,
|
| 46 |
+
|
| 47 |
+
/// Git branch to run in Codex Cloud (defaults to current branch).
|
| 48 |
+
#[arg(long = "branch", value_name = "BRANCH")]
|
| 49 |
+
pub branch: Option<String>,
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
fn parse_attempts(input: &str) -> Result<usize, String> {
|
| 53 |
+
let value: usize = input
|
| 54 |
+
.parse()
|
| 55 |
+
.map_err(|_| "attempts must be an integer between 1 and 4".to_string())?;
|
| 56 |
+
if (1..=4).contains(&value) {
|
| 57 |
+
Ok(value)
|
| 58 |
+
} else {
|
| 59 |
+
Err("attempts must be between 1 and 4".to_string())
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
fn parse_limit(input: &str) -> Result<i64, String> {
|
| 64 |
+
let value: i64 = input
|
| 65 |
+
.parse()
|
| 66 |
+
.map_err(|_| "limit must be an integer between 1 and 20".to_string())?;
|
| 67 |
+
if (1..=20).contains(&value) {
|
| 68 |
+
Ok(value)
|
| 69 |
+
} else {
|
| 70 |
+
Err("limit must be between 1 and 20".to_string())
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
#[derive(Debug, Args)]
|
| 75 |
+
pub struct StatusCommand {
|
| 76 |
+
/// Codex Cloud task identifier to inspect.
|
| 77 |
+
#[arg(value_name = "TASK_ID")]
|
| 78 |
+
pub task_id: String,
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
#[derive(Debug, Args)]
|
| 82 |
+
pub struct ListCommand {
|
| 83 |
+
/// Filter tasks by environment identifier.
|
| 84 |
+
#[arg(long = "env", value_name = "ENV_ID")]
|
| 85 |
+
pub environment: Option<String>,
|
| 86 |
+
|
| 87 |
+
/// Maximum number of tasks to return (1-20).
|
| 88 |
+
#[arg(long = "limit", default_value_t = 20, value_parser = parse_limit, value_name = "N")]
|
| 89 |
+
pub limit: i64,
|
| 90 |
+
|
| 91 |
+
/// Pagination cursor returned by a previous call.
|
| 92 |
+
#[arg(long = "cursor", value_name = "CURSOR")]
|
| 93 |
+
pub cursor: Option<String>,
|
| 94 |
+
|
| 95 |
+
/// Emit JSON instead of plain text.
|
| 96 |
+
#[arg(long = "json", default_value_t = false)]
|
| 97 |
+
pub json: bool,
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
#[derive(Debug, Args)]
|
| 101 |
+
pub struct ApplyCommand {
|
| 102 |
+
/// Codex Cloud task identifier to apply.
|
| 103 |
+
#[arg(value_name = "TASK_ID")]
|
| 104 |
+
pub task_id: String,
|
| 105 |
+
|
| 106 |
+
/// Attempt number to apply (1-based).
|
| 107 |
+
#[arg(long = "attempt", value_parser = parse_attempts, value_name = "N")]
|
| 108 |
+
pub attempt: Option<usize>,
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
#[derive(Debug, Args)]
|
| 112 |
+
pub struct DiffCommand {
|
| 113 |
+
/// Codex Cloud task identifier to display.
|
| 114 |
+
#[arg(value_name = "TASK_ID")]
|
| 115 |
+
pub task_id: String,
|
| 116 |
+
|
| 117 |
+
/// Attempt number to display (1-based).
|
| 118 |
+
#[arg(long = "attempt", value_parser = parse_attempts, value_name = "N")]
|
| 119 |
+
pub attempt: Option<usize>,
|
| 120 |
+
}
|
codex-rs/cloud-tasks/src/env_detect.rs
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use codex_git_utils::SanitizedGitUrl;
|
| 2 |
+
use codex_http_client::RouteAwareClientPool;
|
| 3 |
+
use http::StatusCode;
|
| 4 |
+
use http::header::CONTENT_TYPE;
|
| 5 |
+
use http::header::HeaderMap;
|
| 6 |
+
use std::collections::HashMap;
|
| 7 |
+
use tracing::info;
|
| 8 |
+
use tracing::warn;
|
| 9 |
+
|
| 10 |
+
#[derive(Debug, Clone, serde::Deserialize)]
|
| 11 |
+
struct CodeEnvironment {
|
| 12 |
+
id: String,
|
| 13 |
+
#[serde(default)]
|
| 14 |
+
label: Option<String>,
|
| 15 |
+
#[serde(default)]
|
| 16 |
+
is_pinned: Option<bool>,
|
| 17 |
+
#[serde(default)]
|
| 18 |
+
task_count: Option<i64>,
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
| 22 |
+
pub struct AutodetectSelection {
|
| 23 |
+
pub id: String,
|
| 24 |
+
pub label: Option<String>,
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
pub async fn autodetect_environment_id(
|
| 28 |
+
http: &RouteAwareClientPool,
|
| 29 |
+
base_url: &str,
|
| 30 |
+
headers: &HeaderMap,
|
| 31 |
+
desired_label: Option<String>,
|
| 32 |
+
) -> anyhow::Result<AutodetectSelection> {
|
| 33 |
+
autodetect_environment_id_with_origins(
|
| 34 |
+
http,
|
| 35 |
+
base_url,
|
| 36 |
+
headers,
|
| 37 |
+
desired_label,
|
| 38 |
+
&get_git_origins(),
|
| 39 |
+
)
|
| 40 |
+
.await
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
async fn autodetect_environment_id_with_origins(
|
| 44 |
+
http: &impl EnvironmentHttp,
|
| 45 |
+
base_url: &str,
|
| 46 |
+
headers: &HeaderMap,
|
| 47 |
+
desired_label: Option<String>,
|
| 48 |
+
origins: &[SanitizedGitUrl],
|
| 49 |
+
) -> anyhow::Result<AutodetectSelection> {
|
| 50 |
+
// 1) Try repo-specific environments based on local git origins (GitHub only, like VSCode)
|
| 51 |
+
crate::append_error_log(format!("env: git origins: {origins:?}"));
|
| 52 |
+
let mut by_repo_envs: Vec<CodeEnvironment> = Vec::new();
|
| 53 |
+
for origin in origins {
|
| 54 |
+
if let Some((owner, repo)) = parse_owner_repo(origin.as_str()) {
|
| 55 |
+
let url = if base_url.contains("/backend-api") {
|
| 56 |
+
format!(
|
| 57 |
+
"{}/wham/environments/by-repo/{}/{}/{}",
|
| 58 |
+
base_url, "github", owner, repo
|
| 59 |
+
)
|
| 60 |
+
} else {
|
| 61 |
+
format!(
|
| 62 |
+
"{}/api/codex/environments/by-repo/{}/{}/{}",
|
| 63 |
+
base_url, "github", owner, repo
|
| 64 |
+
)
|
| 65 |
+
};
|
| 66 |
+
crate::append_error_log(format!("env: GET {url}"));
|
| 67 |
+
match get_json::<Vec<CodeEnvironment>>(http, &url, headers).await {
|
| 68 |
+
Ok(mut list) => {
|
| 69 |
+
crate::append_error_log(format!(
|
| 70 |
+
"env: by-repo returned {} env(s) for {owner}/{repo}",
|
| 71 |
+
list.len(),
|
| 72 |
+
));
|
| 73 |
+
by_repo_envs.append(&mut list);
|
| 74 |
+
}
|
| 75 |
+
Err(e) => crate::append_error_log(format!(
|
| 76 |
+
"env: by-repo fetch failed for {owner}/{repo}: {e}"
|
| 77 |
+
)),
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
if let Some(env) = pick_environment_row(&by_repo_envs, desired_label.as_deref()) {
|
| 82 |
+
return Ok(AutodetectSelection {
|
| 83 |
+
id: env.id.clone(),
|
| 84 |
+
label: env.label.as_deref().map(str::to_owned),
|
| 85 |
+
});
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// 2) Fallback to the full list
|
| 89 |
+
let list_url = if base_url.contains("/backend-api") {
|
| 90 |
+
format!("{base_url}/wham/environments")
|
| 91 |
+
} else {
|
| 92 |
+
format!("{base_url}/api/codex/environments")
|
| 93 |
+
};
|
| 94 |
+
crate::append_error_log(format!("env: GET {list_url}"));
|
| 95 |
+
// Fetch and log the full environments JSON for debugging
|
| 96 |
+
let response = http.get(&list_url, headers).await?;
|
| 97 |
+
let status = response.status;
|
| 98 |
+
let ct = response.content_type;
|
| 99 |
+
let body = response.body;
|
| 100 |
+
crate::append_error_log(format!("env: status={status} content-type={ct}"));
|
| 101 |
+
match serde_json::from_str::<serde_json::Value>(&body) {
|
| 102 |
+
Ok(v) => {
|
| 103 |
+
let pretty = serde_json::to_string_pretty(&v).unwrap_or(body.clone());
|
| 104 |
+
crate::append_error_log(format!("env: /environments JSON (pretty):\n{pretty}"));
|
| 105 |
+
}
|
| 106 |
+
Err(_) => crate::append_error_log(format!("env: /environments (raw):\n{body}")),
|
| 107 |
+
}
|
| 108 |
+
if !status.is_success() {
|
| 109 |
+
anyhow::bail!("GET {list_url} failed: {status}; content-type={ct}; body={body}");
|
| 110 |
+
}
|
| 111 |
+
let all_envs: Vec<CodeEnvironment> = serde_json::from_str(&body).map_err(|e| {
|
| 112 |
+
anyhow::anyhow!("Decode error for {list_url}: {e}; content-type={ct}; body={body}")
|
| 113 |
+
})?;
|
| 114 |
+
if let Some(env) = pick_environment_row(&all_envs, desired_label.as_deref()) {
|
| 115 |
+
return Ok(AutodetectSelection {
|
| 116 |
+
id: env.id.clone(),
|
| 117 |
+
label: env.label.as_deref().map(str::to_owned),
|
| 118 |
+
});
|
| 119 |
+
}
|
| 120 |
+
anyhow::bail!("no environments available")
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
fn pick_environment_row(
|
| 124 |
+
envs: &[CodeEnvironment],
|
| 125 |
+
desired_label: Option<&str>,
|
| 126 |
+
) -> Option<CodeEnvironment> {
|
| 127 |
+
if envs.is_empty() {
|
| 128 |
+
return None;
|
| 129 |
+
}
|
| 130 |
+
if let Some(label) = desired_label {
|
| 131 |
+
let lc = label.to_lowercase();
|
| 132 |
+
if let Some(e) = envs
|
| 133 |
+
.iter()
|
| 134 |
+
.find(|e| e.label.as_deref().unwrap_or("").to_lowercase() == lc)
|
| 135 |
+
{
|
| 136 |
+
crate::append_error_log(format!("env: matched by label: {label} -> {}", e.id));
|
| 137 |
+
return Some(e.clone());
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
if envs.len() == 1 {
|
| 141 |
+
crate::append_error_log("env: single environment available; selecting it");
|
| 142 |
+
return Some(envs[0].clone());
|
| 143 |
+
}
|
| 144 |
+
if let Some(e) = envs.iter().find(|e| e.is_pinned.unwrap_or(false)) {
|
| 145 |
+
crate::append_error_log(format!("env: selecting pinned environment: {}", e.id));
|
| 146 |
+
return Some(e.clone());
|
| 147 |
+
}
|
| 148 |
+
// Highest task_count as heuristic
|
| 149 |
+
if let Some(e) = envs
|
| 150 |
+
.iter()
|
| 151 |
+
.max_by_key(|e| e.task_count.unwrap_or(0))
|
| 152 |
+
.or_else(|| envs.first())
|
| 153 |
+
{
|
| 154 |
+
crate::append_error_log(format!("env: selecting by task_count/first: {}", e.id));
|
| 155 |
+
return Some(e.clone());
|
| 156 |
+
}
|
| 157 |
+
None
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
async fn get_json<T: serde::de::DeserializeOwned>(
|
| 161 |
+
http: &impl EnvironmentHttp,
|
| 162 |
+
url: &str,
|
| 163 |
+
headers: &HeaderMap,
|
| 164 |
+
) -> anyhow::Result<T> {
|
| 165 |
+
let response = http.get(url, headers).await?;
|
| 166 |
+
let status = response.status;
|
| 167 |
+
let ct = response.content_type;
|
| 168 |
+
let body = response.body;
|
| 169 |
+
crate::append_error_log(format!("env: status={status} content-type={ct}"));
|
| 170 |
+
if !status.is_success() {
|
| 171 |
+
anyhow::bail!("GET {url} failed: {status}; content-type={ct}; body={body}");
|
| 172 |
+
}
|
| 173 |
+
let parsed = serde_json::from_str::<T>(&body).map_err(|e| {
|
| 174 |
+
anyhow::anyhow!("Decode error for {url}: {e}; content-type={ct}; body={body}")
|
| 175 |
+
})?;
|
| 176 |
+
Ok(parsed)
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
#[derive(Clone, Debug, PartialEq, Eq)]
|
| 180 |
+
struct EnvironmentResponse {
|
| 181 |
+
status: StatusCode,
|
| 182 |
+
content_type: String,
|
| 183 |
+
body: String,
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
/// HTTP boundary used by environment discovery.
|
| 187 |
+
///
|
| 188 |
+
/// Implementations must issue a GET for the complete `url`, forward all supplied headers, and
|
| 189 |
+
/// return the response status, content type, and body for the caller to validate and decode.
|
| 190 |
+
trait EnvironmentHttp: Send + Sync {
|
| 191 |
+
fn get<'a>(
|
| 192 |
+
&'a self,
|
| 193 |
+
url: &'a str,
|
| 194 |
+
headers: &'a HeaderMap,
|
| 195 |
+
) -> impl std::future::Future<Output = anyhow::Result<EnvironmentResponse>> + Send + 'a;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
impl EnvironmentHttp for RouteAwareClientPool {
|
| 199 |
+
async fn get(&self, url: &str, headers: &HeaderMap) -> anyhow::Result<EnvironmentResponse> {
|
| 200 |
+
let response = RouteAwareClientPool::get(self, url)
|
| 201 |
+
.headers(headers.clone())
|
| 202 |
+
.send()
|
| 203 |
+
.await?;
|
| 204 |
+
Ok(EnvironmentResponse {
|
| 205 |
+
status: response.status(),
|
| 206 |
+
content_type: response
|
| 207 |
+
.headers()
|
| 208 |
+
.get(CONTENT_TYPE)
|
| 209 |
+
.and_then(|value| value.to_str().ok())
|
| 210 |
+
.unwrap_or("")
|
| 211 |
+
.to_string(),
|
| 212 |
+
body: response.text().await.unwrap_or_default(),
|
| 213 |
+
})
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
fn get_git_origins() -> Vec<SanitizedGitUrl> {
|
| 218 |
+
// Prefer: git config --get-regexp remote\..*\.url
|
| 219 |
+
let out = std::process::Command::new("git")
|
| 220 |
+
.args(["-c", codex_git_utils::SAFE_BARE_REPOSITORY_CONFIG])
|
| 221 |
+
.args(["config", "--get-regexp", "remote\\..*\\.url"])
|
| 222 |
+
.output();
|
| 223 |
+
if let Ok(ok) = out
|
| 224 |
+
&& ok.status.success()
|
| 225 |
+
{
|
| 226 |
+
let s = String::from_utf8_lossy(&ok.stdout);
|
| 227 |
+
let mut urls = Vec::new();
|
| 228 |
+
for line in s.lines() {
|
| 229 |
+
if let Some((_, url)) = line.split_once(' ')
|
| 230 |
+
&& let Ok(url) = SanitizedGitUrl::try_from(url.trim())
|
| 231 |
+
{
|
| 232 |
+
urls.push(url);
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
if !urls.is_empty() {
|
| 236 |
+
return uniq(urls);
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
// Fallback: git remote -v
|
| 240 |
+
let out = std::process::Command::new("git")
|
| 241 |
+
.args(["-c", codex_git_utils::SAFE_BARE_REPOSITORY_CONFIG])
|
| 242 |
+
.args(["remote", "-v"])
|
| 243 |
+
.output();
|
| 244 |
+
if let Ok(ok) = out
|
| 245 |
+
&& ok.status.success()
|
| 246 |
+
{
|
| 247 |
+
let s = String::from_utf8_lossy(&ok.stdout);
|
| 248 |
+
let mut urls = Vec::new();
|
| 249 |
+
for line in s.lines() {
|
| 250 |
+
let parts: Vec<&str> = line.split_whitespace().collect();
|
| 251 |
+
if parts.len() >= 2
|
| 252 |
+
&& let Ok(url) = SanitizedGitUrl::try_from(parts[1])
|
| 253 |
+
{
|
| 254 |
+
urls.push(url);
|
| 255 |
+
}
|
| 256 |
+
}
|
| 257 |
+
if !urls.is_empty() {
|
| 258 |
+
return uniq(urls);
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
Vec::new()
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
fn uniq(mut v: Vec<SanitizedGitUrl>) -> Vec<SanitizedGitUrl> {
|
| 265 |
+
v.sort();
|
| 266 |
+
v.dedup();
|
| 267 |
+
v
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
fn parse_owner_repo(url: &str) -> Option<(String, String)> {
|
| 271 |
+
// Normalize common prefixes and handle multiple SSH/HTTPS variants.
|
| 272 |
+
let mut s = url.trim().to_string();
|
| 273 |
+
// Drop protocol scheme for ssh URLs
|
| 274 |
+
if let Some(rest) = s.strip_prefix("ssh://") {
|
| 275 |
+
s = rest.to_string();
|
| 276 |
+
}
|
| 277 |
+
// Accept any user before @github.com (e.g., git@, org-123@)
|
| 278 |
+
if let Some(idx) = s.find("@github.com:") {
|
| 279 |
+
let rest = &s[idx + "@github.com:".len()..];
|
| 280 |
+
let rest = rest.trim_start_matches('/').trim_end_matches(".git");
|
| 281 |
+
let mut parts = rest.splitn(2, '/');
|
| 282 |
+
let owner = parts.next()?.to_string();
|
| 283 |
+
let repo = parts.next()?.to_string();
|
| 284 |
+
crate::append_error_log(format!("env: parsed SSH GitHub origin => {owner}/{repo}"));
|
| 285 |
+
return Some((owner, repo));
|
| 286 |
+
}
|
| 287 |
+
// HTTPS or git protocol
|
| 288 |
+
for prefix in [
|
| 289 |
+
"https://github.com/",
|
| 290 |
+
"http://github.com/",
|
| 291 |
+
"git://github.com/",
|
| 292 |
+
"github.com/",
|
| 293 |
+
"github.com:",
|
| 294 |
+
] {
|
| 295 |
+
if let Some(rest) = s.strip_prefix(prefix) {
|
| 296 |
+
let rest = rest.trim_start_matches('/').trim_end_matches(".git");
|
| 297 |
+
let mut parts = rest.splitn(2, '/');
|
| 298 |
+
let owner = parts.next()?.to_string();
|
| 299 |
+
let repo = parts.next()?.to_string();
|
| 300 |
+
crate::append_error_log(format!("env: parsed HTTP GitHub origin => {owner}/{repo}"));
|
| 301 |
+
return Some((owner, repo));
|
| 302 |
+
}
|
| 303 |
+
}
|
| 304 |
+
None
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
/// List environments for the current repo(s) with a fallback to the global list.
|
| 308 |
+
/// Returns a de-duplicated, sorted set suitable for the TUI modal.
|
| 309 |
+
pub async fn list_environments(
|
| 310 |
+
http: &RouteAwareClientPool,
|
| 311 |
+
base_url: &str,
|
| 312 |
+
headers: &HeaderMap,
|
| 313 |
+
) -> anyhow::Result<Vec<crate::app::EnvironmentRow>> {
|
| 314 |
+
list_environments_with_origins(http, base_url, headers, &get_git_origins()).await
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
async fn list_environments_with_origins(
|
| 318 |
+
http: &impl EnvironmentHttp,
|
| 319 |
+
base_url: &str,
|
| 320 |
+
headers: &HeaderMap,
|
| 321 |
+
origins: &[SanitizedGitUrl],
|
| 322 |
+
) -> anyhow::Result<Vec<crate::app::EnvironmentRow>> {
|
| 323 |
+
let mut map: HashMap<String, crate::app::EnvironmentRow> = HashMap::new();
|
| 324 |
+
|
| 325 |
+
// 1) By-repo lookup for each parsed GitHub origin
|
| 326 |
+
for origin in origins {
|
| 327 |
+
if let Some((owner, repo)) = parse_owner_repo(origin.as_str()) {
|
| 328 |
+
let url = if base_url.contains("/backend-api") {
|
| 329 |
+
format!(
|
| 330 |
+
"{}/wham/environments/by-repo/{}/{}/{}",
|
| 331 |
+
base_url, "github", owner, repo
|
| 332 |
+
)
|
| 333 |
+
} else {
|
| 334 |
+
format!(
|
| 335 |
+
"{}/api/codex/environments/by-repo/{}/{}/{}",
|
| 336 |
+
base_url, "github", owner, repo
|
| 337 |
+
)
|
| 338 |
+
};
|
| 339 |
+
match get_json::<Vec<CodeEnvironment>>(http, &url, headers).await {
|
| 340 |
+
Ok(list) => {
|
| 341 |
+
info!("env_tui: by-repo {}:{} -> {} envs", owner, repo, list.len());
|
| 342 |
+
for e in list {
|
| 343 |
+
let entry =
|
| 344 |
+
map.entry(e.id.clone())
|
| 345 |
+
.or_insert_with(|| crate::app::EnvironmentRow {
|
| 346 |
+
id: e.id.clone(),
|
| 347 |
+
label: e.label.clone(),
|
| 348 |
+
is_pinned: e.is_pinned.unwrap_or(false),
|
| 349 |
+
repo_hints: Some(format!("{owner}/{repo}")),
|
| 350 |
+
});
|
| 351 |
+
// Merge: keep label if present, or use new; accumulate pinned flag
|
| 352 |
+
if entry.label.is_none() {
|
| 353 |
+
entry.label = e.label.clone();
|
| 354 |
+
}
|
| 355 |
+
entry.is_pinned = entry.is_pinned || e.is_pinned.unwrap_or(false);
|
| 356 |
+
if entry.repo_hints.is_none() {
|
| 357 |
+
entry.repo_hints = Some(format!("{owner}/{repo}"));
|
| 358 |
+
}
|
| 359 |
+
}
|
| 360 |
+
}
|
| 361 |
+
Err(e) => {
|
| 362 |
+
warn!(
|
| 363 |
+
"env_tui: by-repo fetch failed for {}/{}: {}",
|
| 364 |
+
owner, repo, e
|
| 365 |
+
);
|
| 366 |
+
}
|
| 367 |
+
}
|
| 368 |
+
}
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
// 2) Fallback to the full list; on error return what we have if any.
|
| 372 |
+
let list_url = if base_url.contains("/backend-api") {
|
| 373 |
+
format!("{base_url}/wham/environments")
|
| 374 |
+
} else {
|
| 375 |
+
format!("{base_url}/api/codex/environments")
|
| 376 |
+
};
|
| 377 |
+
match get_json::<Vec<CodeEnvironment>>(http, &list_url, headers).await {
|
| 378 |
+
Ok(list) => {
|
| 379 |
+
info!("env_tui: global list -> {} envs", list.len());
|
| 380 |
+
for e in list {
|
| 381 |
+
let entry = map
|
| 382 |
+
.entry(e.id.clone())
|
| 383 |
+
.or_insert_with(|| crate::app::EnvironmentRow {
|
| 384 |
+
id: e.id.clone(),
|
| 385 |
+
label: e.label.clone(),
|
| 386 |
+
is_pinned: e.is_pinned.unwrap_or(false),
|
| 387 |
+
repo_hints: None,
|
| 388 |
+
});
|
| 389 |
+
if entry.label.is_none() {
|
| 390 |
+
entry.label = e.label.clone();
|
| 391 |
+
}
|
| 392 |
+
entry.is_pinned = entry.is_pinned || e.is_pinned.unwrap_or(false);
|
| 393 |
+
}
|
| 394 |
+
}
|
| 395 |
+
Err(e) => {
|
| 396 |
+
if map.is_empty() {
|
| 397 |
+
return Err(e);
|
| 398 |
+
} else {
|
| 399 |
+
warn!(
|
| 400 |
+
"env_tui: global list failed; using by-repo results only: {}",
|
| 401 |
+
e
|
| 402 |
+
);
|
| 403 |
+
}
|
| 404 |
+
}
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
let mut rows: Vec<crate::app::EnvironmentRow> = map.into_values().collect();
|
| 408 |
+
rows.sort_by(|a, b| {
|
| 409 |
+
// pinned first
|
| 410 |
+
let p = b.is_pinned.cmp(&a.is_pinned);
|
| 411 |
+
if p != std::cmp::Ordering::Equal {
|
| 412 |
+
return p;
|
| 413 |
+
}
|
| 414 |
+
// then label (ci), then id
|
| 415 |
+
let al = a.label.as_deref().unwrap_or("").to_lowercase();
|
| 416 |
+
let bl = b.label.as_deref().unwrap_or("").to_lowercase();
|
| 417 |
+
let l = al.cmp(&bl);
|
| 418 |
+
if l != std::cmp::Ordering::Equal {
|
| 419 |
+
return l;
|
| 420 |
+
}
|
| 421 |
+
a.id.cmp(&b.id)
|
| 422 |
+
});
|
| 423 |
+
Ok(rows)
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
#[cfg(test)]
|
| 427 |
+
#[path = "env_detect_tests.rs"]
|
| 428 |
+
mod tests;
|
codex-rs/cloud-tasks/src/env_detect_tests.rs
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::collections::HashMap;
|
| 2 |
+
use std::io;
|
| 3 |
+
use std::io::Read;
|
| 4 |
+
use std::io::Write;
|
| 5 |
+
use std::sync::Mutex;
|
| 6 |
+
use std::time::Duration;
|
| 7 |
+
use std::time::Instant;
|
| 8 |
+
|
| 9 |
+
use codex_http_client::ClientRouteClass;
|
| 10 |
+
use codex_http_client::HttpClientFactory;
|
| 11 |
+
use codex_http_client::OutboundProxyPolicy;
|
| 12 |
+
use http::HeaderMap;
|
| 13 |
+
use http::HeaderValue;
|
| 14 |
+
use http::StatusCode;
|
| 15 |
+
use http::header::AUTHORIZATION;
|
| 16 |
+
use pretty_assertions::assert_eq;
|
| 17 |
+
|
| 18 |
+
use super::*;
|
| 19 |
+
|
| 20 |
+
const BASE_URL: &str = "https://chatgpt.com/backend-api";
|
| 21 |
+
const BY_REPO_URL: &str =
|
| 22 |
+
"https://chatgpt.com/backend-api/wham/environments/by-repo/github/openai/codex";
|
| 23 |
+
const GLOBAL_URL: &str = "https://chatgpt.com/backend-api/wham/environments";
|
| 24 |
+
|
| 25 |
+
#[tokio::test]
|
| 26 |
+
async fn production_http_forwards_headers_and_decodes_response() {
|
| 27 |
+
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
|
| 28 |
+
.expect("environment HTTP listener should bind");
|
| 29 |
+
let address = listener
|
| 30 |
+
.local_addr()
|
| 31 |
+
.expect("environment HTTP listener should have an address");
|
| 32 |
+
listener
|
| 33 |
+
.set_nonblocking(true)
|
| 34 |
+
.expect("environment HTTP listener should become nonblocking");
|
| 35 |
+
let server = std::thread::spawn(move || {
|
| 36 |
+
let deadline = Instant::now() + Duration::from_secs(10);
|
| 37 |
+
let (mut stream, _) = loop {
|
| 38 |
+
match listener.accept() {
|
| 39 |
+
Ok(connection) => break connection,
|
| 40 |
+
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
|
| 41 |
+
assert!(
|
| 42 |
+
Instant::now() < deadline,
|
| 43 |
+
"environment HTTP listener should receive a request"
|
| 44 |
+
);
|
| 45 |
+
std::thread::sleep(Duration::from_millis(10));
|
| 46 |
+
}
|
| 47 |
+
Err(error) => panic!("environment HTTP listener should accept: {error}"),
|
| 48 |
+
}
|
| 49 |
+
};
|
| 50 |
+
stream
|
| 51 |
+
.set_nonblocking(false)
|
| 52 |
+
.expect("environment HTTP stream should become blocking");
|
| 53 |
+
stream
|
| 54 |
+
.set_read_timeout(Some(Duration::from_secs(10)))
|
| 55 |
+
.expect("environment HTTP stream should get a read timeout");
|
| 56 |
+
let mut request = Vec::new();
|
| 57 |
+
let mut buffer = [0_u8; 1024];
|
| 58 |
+
loop {
|
| 59 |
+
let bytes_read = stream
|
| 60 |
+
.read(&mut buffer)
|
| 61 |
+
.expect("environment HTTP request should read");
|
| 62 |
+
if bytes_read == 0 {
|
| 63 |
+
break;
|
| 64 |
+
}
|
| 65 |
+
request.extend_from_slice(&buffer[..bytes_read]);
|
| 66 |
+
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
| 67 |
+
break;
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
let body = r#"[{"id":"env-real","label":"Real"}]"#;
|
| 72 |
+
write!(
|
| 73 |
+
stream,
|
| 74 |
+
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
| 75 |
+
body.len()
|
| 76 |
+
)
|
| 77 |
+
.expect("environment HTTP response should write");
|
| 78 |
+
String::from_utf8(request).expect("environment HTTP request should be UTF-8")
|
| 79 |
+
});
|
| 80 |
+
let http = RouteAwareClientPool::new_without_request_logging(
|
| 81 |
+
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
| 82 |
+
ClientRouteClass::Api,
|
| 83 |
+
);
|
| 84 |
+
let base_url = format!("http://{address}");
|
| 85 |
+
let headers =
|
| 86 |
+
HeaderMap::from_iter([(AUTHORIZATION, HeaderValue::from_static("Bearer real-token"))]);
|
| 87 |
+
|
| 88 |
+
let selection = tokio::time::timeout(
|
| 89 |
+
Duration::from_secs(10),
|
| 90 |
+
autodetect_environment_id_with_origins(
|
| 91 |
+
&http,
|
| 92 |
+
&base_url,
|
| 93 |
+
&headers,
|
| 94 |
+
/*desired_label*/ None,
|
| 95 |
+
&[],
|
| 96 |
+
),
|
| 97 |
+
)
|
| 98 |
+
.await
|
| 99 |
+
.expect("environment request should finish")
|
| 100 |
+
.expect("environment response should decode");
|
| 101 |
+
let request = server
|
| 102 |
+
.join()
|
| 103 |
+
.expect("environment HTTP server should finish");
|
| 104 |
+
|
| 105 |
+
assert_eq!(
|
| 106 |
+
selection,
|
| 107 |
+
AutodetectSelection {
|
| 108 |
+
id: "env-real".to_string(),
|
| 109 |
+
label: Some("Real".to_string()),
|
| 110 |
+
}
|
| 111 |
+
);
|
| 112 |
+
assert!(request.starts_with("GET /api/codex/environments HTTP/1.1\r\n"));
|
| 113 |
+
assert!(
|
| 114 |
+
request
|
| 115 |
+
.to_ascii_lowercase()
|
| 116 |
+
.contains("authorization: bearer real-token\r\n")
|
| 117 |
+
);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
#[tokio::test]
|
| 121 |
+
async fn autodetect_requests_exact_repository_endpoint_and_decodes_selection() {
|
| 122 |
+
let http = FakeHttp::new(HashMap::from([(
|
| 123 |
+
BY_REPO_URL.to_string(),
|
| 124 |
+
json_response(r#"[{"id":"env-repo","label":"Repository","is_pinned":true}]"#),
|
| 125 |
+
)]));
|
| 126 |
+
|
| 127 |
+
let headers = HeaderMap::from_iter([(
|
| 128 |
+
AUTHORIZATION,
|
| 129 |
+
HeaderValue::from_static("Bearer forwarded-token"),
|
| 130 |
+
)]);
|
| 131 |
+
let selection = autodetect_environment_id_with_origins(
|
| 132 |
+
&http,
|
| 133 |
+
BASE_URL,
|
| 134 |
+
&headers,
|
| 135 |
+
Some("Repository".to_string()),
|
| 136 |
+
&[SanitizedGitUrl::try_from("git@github.com:openai/codex.git").expect("valid Git remote")],
|
| 137 |
+
)
|
| 138 |
+
.await
|
| 139 |
+
.expect("repository environment should be selected");
|
| 140 |
+
|
| 141 |
+
assert_eq!(
|
| 142 |
+
selection,
|
| 143 |
+
AutodetectSelection {
|
| 144 |
+
id: "env-repo".to_string(),
|
| 145 |
+
label: Some("Repository".to_string()),
|
| 146 |
+
}
|
| 147 |
+
);
|
| 148 |
+
assert_eq!(
|
| 149 |
+
http.requests(),
|
| 150 |
+
vec![RecordedRequest {
|
| 151 |
+
url: BY_REPO_URL.to_string(),
|
| 152 |
+
headers,
|
| 153 |
+
}]
|
| 154 |
+
);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/// Repository discovery must accept credential-bearing origins without exposing credentials.
|
| 158 |
+
#[tokio::test]
|
| 159 |
+
async fn autodetect_sanitizes_credential_bearing_git_origins() {
|
| 160 |
+
let http = FakeHttp::new(HashMap::from([(
|
| 161 |
+
BY_REPO_URL.to_string(),
|
| 162 |
+
json_response(r#"[{"id":"env-repo","label":"Repository"}]"#),
|
| 163 |
+
)]));
|
| 164 |
+
|
| 165 |
+
let selection = autodetect_environment_id_with_origins(
|
| 166 |
+
&http,
|
| 167 |
+
BASE_URL,
|
| 168 |
+
&HeaderMap::new(),
|
| 169 |
+
/*desired_label*/ None,
|
| 170 |
+
&[SanitizedGitUrl::try_from(
|
| 171 |
+
"https://alice:synthetic-git-secret@github.com/openai/codex.git",
|
| 172 |
+
)
|
| 173 |
+
.expect("valid Git remote")],
|
| 174 |
+
)
|
| 175 |
+
.await
|
| 176 |
+
.expect("repository environment should be selected");
|
| 177 |
+
|
| 178 |
+
assert_eq!(
|
| 179 |
+
selection,
|
| 180 |
+
AutodetectSelection {
|
| 181 |
+
id: "env-repo".to_string(),
|
| 182 |
+
label: Some("Repository".to_string()),
|
| 183 |
+
}
|
| 184 |
+
);
|
| 185 |
+
assert_eq!(http.requested_urls(), vec![BY_REPO_URL.to_string()]);
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
/// Removing an SCP remote username must not prevent repository-specific discovery.
|
| 189 |
+
#[tokio::test]
|
| 190 |
+
async fn autodetect_recognizes_sanitized_scp_git_origins() {
|
| 191 |
+
let http = FakeHttp::new(HashMap::from([(
|
| 192 |
+
BY_REPO_URL.to_string(),
|
| 193 |
+
json_response(r#"[{"id":"env-repo","label":"Repository"}]"#),
|
| 194 |
+
)]));
|
| 195 |
+
|
| 196 |
+
let selection = autodetect_environment_id_with_origins(
|
| 197 |
+
&http,
|
| 198 |
+
BASE_URL,
|
| 199 |
+
&HeaderMap::new(),
|
| 200 |
+
/*desired_label*/ None,
|
| 201 |
+
&[
|
| 202 |
+
SanitizedGitUrl::try_from("org-123@github.com:openai/codex.git")
|
| 203 |
+
.expect("valid Git remote"),
|
| 204 |
+
],
|
| 205 |
+
)
|
| 206 |
+
.await
|
| 207 |
+
.expect("repository environment should be selected");
|
| 208 |
+
|
| 209 |
+
assert_eq!(
|
| 210 |
+
selection,
|
| 211 |
+
AutodetectSelection {
|
| 212 |
+
id: "env-repo".to_string(),
|
| 213 |
+
label: Some("Repository".to_string()),
|
| 214 |
+
}
|
| 215 |
+
);
|
| 216 |
+
assert_eq!(http.requested_urls(), vec![BY_REPO_URL.to_string()]);
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
#[tokio::test]
|
| 220 |
+
async fn autodetect_falls_back_to_exact_global_endpoint_and_decodes_selection() {
|
| 221 |
+
let http = FakeHttp::new(HashMap::from([
|
| 222 |
+
(BY_REPO_URL.to_string(), json_response("[]")),
|
| 223 |
+
(
|
| 224 |
+
GLOBAL_URL.to_string(),
|
| 225 |
+
json_response(r#"[{"id":"env-global","label":"Global"}]"#),
|
| 226 |
+
),
|
| 227 |
+
]));
|
| 228 |
+
|
| 229 |
+
let selection = autodetect_environment_id_with_origins(
|
| 230 |
+
&http,
|
| 231 |
+
BASE_URL,
|
| 232 |
+
&HeaderMap::new(),
|
| 233 |
+
/*desired_label*/ None,
|
| 234 |
+
&[SanitizedGitUrl::try_from("git@github.com:openai/codex.git").expect("valid Git remote")],
|
| 235 |
+
)
|
| 236 |
+
.await
|
| 237 |
+
.expect("global environment should be selected");
|
| 238 |
+
|
| 239 |
+
assert_eq!(
|
| 240 |
+
selection,
|
| 241 |
+
AutodetectSelection {
|
| 242 |
+
id: "env-global".to_string(),
|
| 243 |
+
label: Some("Global".to_string()),
|
| 244 |
+
}
|
| 245 |
+
);
|
| 246 |
+
assert_eq!(
|
| 247 |
+
http.requested_urls(),
|
| 248 |
+
vec![BY_REPO_URL.to_string(), GLOBAL_URL.to_string()]
|
| 249 |
+
);
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
#[tokio::test]
|
| 253 |
+
async fn list_requests_exact_repository_and_global_endpoints_and_merges_results() {
|
| 254 |
+
let http = FakeHttp::new(HashMap::from([
|
| 255 |
+
(
|
| 256 |
+
BY_REPO_URL.to_string(),
|
| 257 |
+
json_response(r#"[{"id":"env-repo","label":"Repository"}]"#),
|
| 258 |
+
),
|
| 259 |
+
(
|
| 260 |
+
GLOBAL_URL.to_string(),
|
| 261 |
+
json_response(
|
| 262 |
+
r#"[{"id":"env-repo","is_pinned":true},{"id":"env-global","label":"Global"}]"#,
|
| 263 |
+
),
|
| 264 |
+
),
|
| 265 |
+
]));
|
| 266 |
+
|
| 267 |
+
let rows = list_environments_with_origins(
|
| 268 |
+
&http,
|
| 269 |
+
BASE_URL,
|
| 270 |
+
&HeaderMap::new(),
|
| 271 |
+
&[
|
| 272 |
+
SanitizedGitUrl::try_from("https://github.com/openai/codex.git")
|
| 273 |
+
.expect("valid Git remote"),
|
| 274 |
+
],
|
| 275 |
+
)
|
| 276 |
+
.await
|
| 277 |
+
.expect("environment list should decode");
|
| 278 |
+
|
| 279 |
+
assert_eq!(
|
| 280 |
+
rows.into_iter()
|
| 281 |
+
.map(|row| (row.id, row.label, row.is_pinned, row.repo_hints))
|
| 282 |
+
.collect::<Vec<_>>(),
|
| 283 |
+
vec![
|
| 284 |
+
(
|
| 285 |
+
"env-repo".to_string(),
|
| 286 |
+
Some("Repository".to_string()),
|
| 287 |
+
true,
|
| 288 |
+
Some("openai/codex".to_string()),
|
| 289 |
+
),
|
| 290 |
+
(
|
| 291 |
+
"env-global".to_string(),
|
| 292 |
+
Some("Global".to_string()),
|
| 293 |
+
false,
|
| 294 |
+
None,
|
| 295 |
+
),
|
| 296 |
+
]
|
| 297 |
+
);
|
| 298 |
+
assert_eq!(
|
| 299 |
+
http.requested_urls(),
|
| 300 |
+
vec![BY_REPO_URL.to_string(), GLOBAL_URL.to_string()]
|
| 301 |
+
);
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
struct FakeHttp {
|
| 305 |
+
responses: HashMap<String, EnvironmentResponse>,
|
| 306 |
+
requests: Mutex<Vec<RecordedRequest>>,
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
#[derive(Clone, Debug, PartialEq, Eq)]
|
| 310 |
+
struct RecordedRequest {
|
| 311 |
+
url: String,
|
| 312 |
+
headers: HeaderMap,
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
impl FakeHttp {
|
| 316 |
+
fn new(responses: HashMap<String, EnvironmentResponse>) -> Self {
|
| 317 |
+
Self {
|
| 318 |
+
responses,
|
| 319 |
+
requests: Mutex::new(Vec::new()),
|
| 320 |
+
}
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
fn requests(&self) -> Vec<RecordedRequest> {
|
| 324 |
+
self.requests.lock().expect("request lock").clone()
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
fn requested_urls(&self) -> Vec<String> {
|
| 328 |
+
self.requests
|
| 329 |
+
.lock()
|
| 330 |
+
.expect("request lock")
|
| 331 |
+
.iter()
|
| 332 |
+
.map(|request| request.url.clone())
|
| 333 |
+
.collect()
|
| 334 |
+
}
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
impl EnvironmentHttp for FakeHttp {
|
| 338 |
+
async fn get(&self, url: &str, headers: &HeaderMap) -> anyhow::Result<EnvironmentResponse> {
|
| 339 |
+
self.requests
|
| 340 |
+
.lock()
|
| 341 |
+
.expect("request lock")
|
| 342 |
+
.push(RecordedRequest {
|
| 343 |
+
url: url.to_string(),
|
| 344 |
+
headers: headers.clone(),
|
| 345 |
+
});
|
| 346 |
+
self.responses
|
| 347 |
+
.get(url)
|
| 348 |
+
.cloned()
|
| 349 |
+
.ok_or_else(|| anyhow::anyhow!("unexpected URL: {url}"))
|
| 350 |
+
}
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
fn json_response(body: &str) -> EnvironmentResponse {
|
| 354 |
+
EnvironmentResponse {
|
| 355 |
+
status: StatusCode::OK,
|
| 356 |
+
content_type: "application/json".to_string(),
|
| 357 |
+
body: body.to_string(),
|
| 358 |
+
}
|
| 359 |
+
}
|
codex-rs/cloud-tasks/src/lib.rs
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
codex-rs/cloud-tasks/src/new_task.rs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use codex_tui::ComposerInput;
|
| 2 |
+
|
| 3 |
+
pub struct NewTaskPage {
|
| 4 |
+
pub composer: ComposerInput,
|
| 5 |
+
pub submitting: bool,
|
| 6 |
+
pub env_id: Option<String>,
|
| 7 |
+
pub best_of_n: usize,
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
impl NewTaskPage {
|
| 11 |
+
pub fn new(env_id: Option<String>, best_of_n: usize) -> Self {
|
| 12 |
+
let mut composer = ComposerInput::new();
|
| 13 |
+
composer.set_hint_items(vec![
|
| 14 |
+
("⏎", "send"),
|
| 15 |
+
("Shift+⏎", "newline"),
|
| 16 |
+
("Ctrl+O", "env"),
|
| 17 |
+
("Ctrl+N", "attempts"),
|
| 18 |
+
("Ctrl+C", "quit"),
|
| 19 |
+
]);
|
| 20 |
+
Self {
|
| 21 |
+
composer,
|
| 22 |
+
submitting: false,
|
| 23 |
+
env_id,
|
| 24 |
+
best_of_n,
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
// Additional helpers can be added as usage evolves.
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
impl Default for NewTaskPage {
|
| 32 |
+
fn default() -> Self {
|
| 33 |
+
Self::new(/*env_id*/ None, /*best_of_n*/ 1)
|
| 34 |
+
}
|
| 35 |
+
}
|
codex-rs/cloud-tasks/src/scrollable_diff.rs
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use unicode_segmentation::UnicodeSegmentation;
|
| 2 |
+
use unicode_width::UnicodeWidthStr;
|
| 3 |
+
|
| 4 |
+
/// Scroll position and geometry for a vertical scroll view.
|
| 5 |
+
#[derive(Clone, Copy, Debug, Default)]
|
| 6 |
+
pub struct ScrollViewState {
|
| 7 |
+
pub scroll: u16,
|
| 8 |
+
pub viewport_h: u16,
|
| 9 |
+
pub content_h: u16,
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
impl ScrollViewState {
|
| 13 |
+
pub fn clamp(&mut self) {
|
| 14 |
+
let max_scroll = self.content_h.saturating_sub(self.viewport_h);
|
| 15 |
+
if self.scroll > max_scroll {
|
| 16 |
+
self.scroll = max_scroll;
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/// A simple, local scrollable view for diffs or message text.
|
| 22 |
+
///
|
| 23 |
+
/// Owns raw lines, caches wrapped lines for a given width, and maintains
|
| 24 |
+
/// a small scroll state that is clamped whenever geometry shrinks.
|
| 25 |
+
#[derive(Clone, Debug, Default)]
|
| 26 |
+
pub struct ScrollableDiff {
|
| 27 |
+
raw: Vec<String>,
|
| 28 |
+
wrapped: Vec<String>,
|
| 29 |
+
wrapped_src_idx: Vec<usize>,
|
| 30 |
+
wrap_cols: Option<u16>,
|
| 31 |
+
pub state: ScrollViewState,
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
impl ScrollableDiff {
|
| 35 |
+
pub fn new() -> Self {
|
| 36 |
+
Self::default()
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/// Replace the raw content lines. Does not rewrap immediately; call `set_width` next.
|
| 40 |
+
pub fn set_content(&mut self, lines: Vec<String>) {
|
| 41 |
+
self.raw = lines;
|
| 42 |
+
self.wrapped.clear();
|
| 43 |
+
self.wrapped_src_idx.clear();
|
| 44 |
+
self.state.content_h = 0;
|
| 45 |
+
// Force rewrap on next set_width even if width is unchanged
|
| 46 |
+
self.wrap_cols = None;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/// Set the wrap width. If changed, rebuild wrapped lines and clamp scroll.
|
| 50 |
+
pub fn set_width(&mut self, width: u16) {
|
| 51 |
+
if self.wrap_cols == Some(width) {
|
| 52 |
+
return;
|
| 53 |
+
}
|
| 54 |
+
self.wrap_cols = Some(width);
|
| 55 |
+
self.rewrap(width);
|
| 56 |
+
self.state.clamp();
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/// Update viewport height and clamp scroll if needed.
|
| 60 |
+
pub fn set_viewport(&mut self, height: u16) {
|
| 61 |
+
self.state.viewport_h = height;
|
| 62 |
+
self.state.clamp();
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/// Return the cached wrapped lines. Call `set_width` first when area changes.
|
| 66 |
+
pub fn wrapped_lines(&self) -> &[String] {
|
| 67 |
+
&self.wrapped
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
pub fn wrapped_src_indices(&self) -> &[usize] {
|
| 71 |
+
&self.wrapped_src_idx
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
pub fn raw_line_at(&self, idx: usize) -> &str {
|
| 75 |
+
self.raw.get(idx).map(String::as_str).unwrap_or("")
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/// Scroll by a signed delta; clamps to content.
|
| 79 |
+
pub fn scroll_by(&mut self, delta: i16) {
|
| 80 |
+
let s = self.state.scroll as i32 + delta as i32;
|
| 81 |
+
self.state.scroll = s.clamp(0, self.max_scroll() as i32) as u16;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/// Page by a signed delta; typically viewport_h - 1.
|
| 85 |
+
pub fn page_by(&mut self, delta: i16) {
|
| 86 |
+
self.scroll_by(delta);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
pub fn scroll_to_top(&mut self) {
|
| 90 |
+
self.state.scroll = 0;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
pub fn scroll_to_bottom(&mut self) {
|
| 94 |
+
self.state.scroll = self.max_scroll();
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
/// Optional percent scrolled; None when not enough geometry is known.
|
| 98 |
+
pub fn percent_scrolled(&self) -> Option<u8> {
|
| 99 |
+
if self.state.content_h == 0 || self.state.viewport_h == 0 {
|
| 100 |
+
return None;
|
| 101 |
+
}
|
| 102 |
+
if self.state.content_h <= self.state.viewport_h {
|
| 103 |
+
return None;
|
| 104 |
+
}
|
| 105 |
+
let visible_bottom = self.state.scroll.saturating_add(self.state.viewport_h) as f32;
|
| 106 |
+
let pct = (visible_bottom / self.state.content_h as f32 * 100.0).round();
|
| 107 |
+
Some(pct.clamp(0.0, 100.0) as u8)
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
fn max_scroll(&self) -> u16 {
|
| 111 |
+
self.state.content_h.saturating_sub(self.state.viewport_h)
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
fn rewrap(&mut self, width: u16) {
|
| 115 |
+
if width == 0 {
|
| 116 |
+
self.wrapped = self.raw.clone();
|
| 117 |
+
self.state.content_h = self.wrapped.len() as u16;
|
| 118 |
+
return;
|
| 119 |
+
}
|
| 120 |
+
let max_cols = width as usize;
|
| 121 |
+
let mut out: Vec<String> = Vec::new();
|
| 122 |
+
let mut out_idx: Vec<usize> = Vec::new();
|
| 123 |
+
for (raw_idx, raw) in self.raw.iter().enumerate() {
|
| 124 |
+
// Normalize tabs for width accounting (MVP: 4 spaces).
|
| 125 |
+
let raw = raw.replace('\t', " ");
|
| 126 |
+
if raw.is_empty() {
|
| 127 |
+
out.push(String::new());
|
| 128 |
+
out_idx.push(raw_idx);
|
| 129 |
+
continue;
|
| 130 |
+
}
|
| 131 |
+
let mut line = String::new();
|
| 132 |
+
let mut line_cols = 0usize;
|
| 133 |
+
let mut last_soft_idx: Option<usize> = None; // last whitespace or punctuation break
|
| 134 |
+
for grapheme in raw
|
| 135 |
+
.graphemes(/*is_extended*/ true)
|
| 136 |
+
.flat_map(|grapheme| grapheme.split_inclusive(char::is_whitespace))
|
| 137 |
+
{
|
| 138 |
+
if grapheme == "\n" {
|
| 139 |
+
out.push(std::mem::take(&mut line));
|
| 140 |
+
out_idx.push(raw_idx);
|
| 141 |
+
line_cols = 0;
|
| 142 |
+
last_soft_idx = None;
|
| 143 |
+
continue;
|
| 144 |
+
}
|
| 145 |
+
let grapheme_width = display_width(grapheme);
|
| 146 |
+
if line_cols.saturating_add(grapheme_width) > max_cols {
|
| 147 |
+
if let Some(split) = last_soft_idx {
|
| 148 |
+
let (prefix, rest) = line.split_at(split);
|
| 149 |
+
let prefix = prefix.trim_end();
|
| 150 |
+
if !prefix.is_empty() {
|
| 151 |
+
out.push(prefix.to_string());
|
| 152 |
+
out_idx.push(raw_idx);
|
| 153 |
+
}
|
| 154 |
+
line = rest.trim_start().to_string();
|
| 155 |
+
last_soft_idx = None;
|
| 156 |
+
// Retry adding the current grapheme now that the line may be shorter.
|
| 157 |
+
} else if !line.is_empty() {
|
| 158 |
+
out.push(std::mem::take(&mut line));
|
| 159 |
+
out_idx.push(raw_idx);
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
if grapheme.chars().all(char::is_whitespace)
|
| 163 |
+
|| grapheme.chars().any(|ch| {
|
| 164 |
+
matches!(
|
| 165 |
+
ch,
|
| 166 |
+
',' | ';'
|
| 167 |
+
| '.'
|
| 168 |
+
| ':'
|
| 169 |
+
| ')'
|
| 170 |
+
| ']'
|
| 171 |
+
| '}'
|
| 172 |
+
| '|'
|
| 173 |
+
| '/'
|
| 174 |
+
| '?'
|
| 175 |
+
| '!'
|
| 176 |
+
| '-'
|
| 177 |
+
| '_'
|
| 178 |
+
)
|
| 179 |
+
})
|
| 180 |
+
{
|
| 181 |
+
last_soft_idx = Some(line.len());
|
| 182 |
+
}
|
| 183 |
+
line.push_str(grapheme);
|
| 184 |
+
line_cols = display_width(&line);
|
| 185 |
+
}
|
| 186 |
+
if !line.is_empty() {
|
| 187 |
+
out.push(line);
|
| 188 |
+
out_idx.push(raw_idx);
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
self.wrapped = out;
|
| 192 |
+
self.wrapped_src_idx = out_idx;
|
| 193 |
+
self.state.content_h = self.wrapped.len() as u16;
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/// Counts terminal cells, including the halfwidth sound marks omitted by `unicode-width`.
|
| 198 |
+
fn display_width(text: &str) -> usize {
|
| 199 |
+
UnicodeWidthStr::width(text)
|
| 200 |
+
+ text
|
| 201 |
+
.chars()
|
| 202 |
+
.filter(|ch| matches!(ch, '\u{FF9E}' | '\u{FF9F}'))
|
| 203 |
+
.count()
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
#[cfg(test)]
|
| 207 |
+
#[path = "scrollable_diff_tests.rs"]
|
| 208 |
+
mod tests;
|
codex-rs/cloud-tasks/src/scrollable_diff_tests.rs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use super::ScrollableDiff;
|
| 2 |
+
use pretty_assertions::assert_eq;
|
| 3 |
+
|
| 4 |
+
#[test]
|
| 5 |
+
fn wraps_halfwidth_sound_marks_using_ratatui_width() {
|
| 6 |
+
let mut diff = ScrollableDiff::new();
|
| 7 |
+
diff.set_content(vec![
|
| 8 |
+
"aガbパc".to_string(),
|
| 9 |
+
"a ゙b".to_string(),
|
| 10 |
+
"a ゚b".to_string(),
|
| 11 |
+
]);
|
| 12 |
+
diff.set_width(/*width*/ 2);
|
| 13 |
+
|
| 14 |
+
insta::assert_snapshot!(
|
| 15 |
+
diff.wrapped_lines().join("\n"),
|
| 16 |
+
@r"
|
| 17 |
+
a
|
| 18 |
+
ガ
|
| 19 |
+
b
|
| 20 |
+
パ
|
| 21 |
+
c
|
| 22 |
+
a
|
| 23 |
+
゙b
|
| 24 |
+
a
|
| 25 |
+
゚b
|
| 26 |
+
"
|
| 27 |
+
);
|
| 28 |
+
assert_eq!(diff.wrapped_src_indices(), [0, 0, 0, 0, 0, 1, 1, 2, 2]);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
#[test]
|
| 32 |
+
fn does_not_emit_empty_rows_after_full_width_sound_mark_graphemes() {
|
| 33 |
+
for grapheme in ["ガ", "パ"] {
|
| 34 |
+
let mut diff = ScrollableDiff::new();
|
| 35 |
+
diff.set_content(vec![format!("a {grapheme} tail")]);
|
| 36 |
+
diff.set_width(/*width*/ 2);
|
| 37 |
+
|
| 38 |
+
let expected = ["a", grapheme, "ta", "il"];
|
| 39 |
+
let actual = diff
|
| 40 |
+
.wrapped_lines()
|
| 41 |
+
.iter()
|
| 42 |
+
.map(String::as_str)
|
| 43 |
+
.collect::<Vec<_>>();
|
| 44 |
+
assert_eq!(actual, expected);
|
| 45 |
+
assert_eq!(diff.wrapped_src_indices(), [0, 0, 0, 0]);
|
| 46 |
+
assert_eq!(diff.state.content_h, 4);
|
| 47 |
+
}
|
| 48 |
+
}
|
codex-rs/cloud-tasks/src/ui.rs
ADDED
|
@@ -0,0 +1,1046 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use ratatui::layout::Constraint;
|
| 2 |
+
use ratatui::layout::Direction;
|
| 3 |
+
use ratatui::layout::Layout;
|
| 4 |
+
use ratatui::prelude::*;
|
| 5 |
+
use ratatui::style::Color;
|
| 6 |
+
use ratatui::style::Modifier;
|
| 7 |
+
use ratatui::style::Style;
|
| 8 |
+
use ratatui::style::Stylize;
|
| 9 |
+
use ratatui::widgets::Block;
|
| 10 |
+
use ratatui::widgets::BorderType;
|
| 11 |
+
use ratatui::widgets::Borders;
|
| 12 |
+
use ratatui::widgets::Clear;
|
| 13 |
+
use ratatui::widgets::List;
|
| 14 |
+
use ratatui::widgets::ListItem;
|
| 15 |
+
use ratatui::widgets::ListState;
|
| 16 |
+
use ratatui::widgets::Padding;
|
| 17 |
+
use ratatui::widgets::Paragraph;
|
| 18 |
+
use std::sync::OnceLock;
|
| 19 |
+
use std::time::Instant;
|
| 20 |
+
|
| 21 |
+
use crate::app::App;
|
| 22 |
+
use crate::app::AttemptView;
|
| 23 |
+
use crate::util::format_relative_time_now;
|
| 24 |
+
use codex_cloud_tasks_client::AttemptStatus;
|
| 25 |
+
use codex_cloud_tasks_client::TaskStatus;
|
| 26 |
+
use codex_tui::render_markdown_text;
|
| 27 |
+
|
| 28 |
+
pub fn draw(frame: &mut Frame, app: &mut App) {
|
| 29 |
+
let area = frame.area();
|
| 30 |
+
let chunks = Layout::default()
|
| 31 |
+
.direction(Direction::Vertical)
|
| 32 |
+
.constraints([
|
| 33 |
+
Constraint::Min(1), // list
|
| 34 |
+
Constraint::Length(2), // two-line footer (help + status)
|
| 35 |
+
])
|
| 36 |
+
.split(area);
|
| 37 |
+
if app.new_task.is_some() {
|
| 38 |
+
draw_new_task_page(frame, chunks[0], app);
|
| 39 |
+
draw_footer(frame, chunks[1], app);
|
| 40 |
+
} else {
|
| 41 |
+
draw_list(frame, chunks[0], app);
|
| 42 |
+
draw_footer(frame, chunks[1], app);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
if app.diff_overlay.is_some() {
|
| 46 |
+
draw_diff_overlay(frame, area, app);
|
| 47 |
+
}
|
| 48 |
+
if app.env_modal.is_some() {
|
| 49 |
+
draw_env_modal(frame, area, app);
|
| 50 |
+
}
|
| 51 |
+
if app.best_of_modal.is_some() {
|
| 52 |
+
draw_best_of_modal(frame, area, app);
|
| 53 |
+
}
|
| 54 |
+
if app.apply_modal.is_some() {
|
| 55 |
+
draw_apply_modal(frame, area, app);
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// ===== Overlay helpers (geometry + styling) =====
|
| 60 |
+
static ROUNDED: OnceLock<bool> = OnceLock::new();
|
| 61 |
+
|
| 62 |
+
fn rounded_enabled() -> bool {
|
| 63 |
+
*ROUNDED.get_or_init(|| {
|
| 64 |
+
std::env::var("CODEX_TUI_ROUNDED")
|
| 65 |
+
.ok()
|
| 66 |
+
.map(|v| v == "1")
|
| 67 |
+
.unwrap_or(true)
|
| 68 |
+
})
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
fn overlay_outer(area: Rect) -> Rect {
|
| 72 |
+
let outer_v = Layout::default()
|
| 73 |
+
.direction(Direction::Vertical)
|
| 74 |
+
.constraints([
|
| 75 |
+
Constraint::Percentage(10),
|
| 76 |
+
Constraint::Percentage(80),
|
| 77 |
+
Constraint::Percentage(10),
|
| 78 |
+
])
|
| 79 |
+
.split(area)[1];
|
| 80 |
+
Layout::default()
|
| 81 |
+
.direction(Direction::Horizontal)
|
| 82 |
+
.constraints([
|
| 83 |
+
Constraint::Percentage(10),
|
| 84 |
+
Constraint::Percentage(80),
|
| 85 |
+
Constraint::Percentage(10),
|
| 86 |
+
])
|
| 87 |
+
.split(outer_v)[1]
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
fn overlay_block() -> Block<'static> {
|
| 91 |
+
let base = Block::default().borders(Borders::ALL);
|
| 92 |
+
let base = if rounded_enabled() {
|
| 93 |
+
base.border_type(BorderType::Rounded)
|
| 94 |
+
} else {
|
| 95 |
+
base
|
| 96 |
+
};
|
| 97 |
+
base.padding(Padding::new(2, 2, 1, 1))
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
fn overlay_content(area: Rect) -> Rect {
|
| 101 |
+
overlay_block().inner(area)
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
pub fn draw_new_task_page(frame: &mut Frame, area: Rect, app: &mut App) {
|
| 105 |
+
let title_spans = {
|
| 106 |
+
let mut spans: Vec<ratatui::text::Span> = vec!["New Task".magenta().bold()];
|
| 107 |
+
if let Some(id) = app
|
| 108 |
+
.new_task
|
| 109 |
+
.as_ref()
|
| 110 |
+
.and_then(|p| p.env_id.as_ref())
|
| 111 |
+
.cloned()
|
| 112 |
+
{
|
| 113 |
+
spans.push(" • ".into());
|
| 114 |
+
// Try to map id to label
|
| 115 |
+
let label = app
|
| 116 |
+
.environments
|
| 117 |
+
.iter()
|
| 118 |
+
.find(|r| r.id == id)
|
| 119 |
+
.and_then(|r| r.label.clone())
|
| 120 |
+
.unwrap_or(id);
|
| 121 |
+
spans.push(label.dim());
|
| 122 |
+
} else {
|
| 123 |
+
spans.push(" • ".into());
|
| 124 |
+
spans.push("Env: none (press ctrl-o to choose)".red());
|
| 125 |
+
}
|
| 126 |
+
if let Some(page) = app.new_task.as_ref() {
|
| 127 |
+
spans.push(" • ".into());
|
| 128 |
+
let attempts = page.best_of_n;
|
| 129 |
+
let label = format!(
|
| 130 |
+
"{} attempt{}",
|
| 131 |
+
attempts,
|
| 132 |
+
if attempts == 1 { "" } else { "s" }
|
| 133 |
+
);
|
| 134 |
+
spans.push(label.cyan());
|
| 135 |
+
}
|
| 136 |
+
spans
|
| 137 |
+
};
|
| 138 |
+
let block = Block::default()
|
| 139 |
+
.borders(Borders::ALL)
|
| 140 |
+
.title(Line::from(title_spans));
|
| 141 |
+
|
| 142 |
+
frame.render_widget(Clear, area);
|
| 143 |
+
frame.render_widget(block.clone(), area);
|
| 144 |
+
let content = block.inner(area);
|
| 145 |
+
|
| 146 |
+
// Expand composer height up to (terminal height - 6), with a 3-line minimum.
|
| 147 |
+
let max_allowed = frame.area().height.saturating_sub(6).max(3);
|
| 148 |
+
let desired = app
|
| 149 |
+
.new_task
|
| 150 |
+
.as_ref()
|
| 151 |
+
.map(|p| p.composer.desired_height(content.width))
|
| 152 |
+
.unwrap_or(3)
|
| 153 |
+
.clamp(3, max_allowed);
|
| 154 |
+
|
| 155 |
+
// Anchor the composer to the bottom-left by allocating a flexible spacer
|
| 156 |
+
// above it and a fixed `desired`-height area for the composer.
|
| 157 |
+
let rows = Layout::default()
|
| 158 |
+
.direction(Direction::Vertical)
|
| 159 |
+
.constraints([Constraint::Min(1), Constraint::Length(desired)])
|
| 160 |
+
.split(content);
|
| 161 |
+
let composer_area = rows[1];
|
| 162 |
+
|
| 163 |
+
if let Some(page) = app.new_task.as_ref() {
|
| 164 |
+
page.composer.render_ref(composer_area, frame.buffer_mut());
|
| 165 |
+
// Composer renders its own footer hints; no extra row here.
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
// Place cursor where composer wants it
|
| 169 |
+
if let Some(page) = app.new_task.as_ref()
|
| 170 |
+
&& let Some((x, y)) = page.composer.cursor_pos(composer_area)
|
| 171 |
+
{
|
| 172 |
+
frame.set_cursor_position((x, y));
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
fn draw_list(frame: &mut Frame, area: Rect, app: &mut App) {
|
| 177 |
+
let items: Vec<ListItem> = app.tasks.iter().map(|t| render_task_item(app, t)).collect();
|
| 178 |
+
|
| 179 |
+
// Selection reflects the actual task index (no artificial spacer item).
|
| 180 |
+
let mut state = ListState::default().with_selected(Some(app.selected));
|
| 181 |
+
// Dim task list when a modal/overlay is active to emphasize focus.
|
| 182 |
+
let dim_bg = app.env_modal.is_some()
|
| 183 |
+
|| app.apply_modal.is_some()
|
| 184 |
+
|| app.best_of_modal.is_some()
|
| 185 |
+
|| app.diff_overlay.is_some();
|
| 186 |
+
// Dynamic title includes current environment filter
|
| 187 |
+
let suffix_span = if let Some(ref id) = app.env_filter {
|
| 188 |
+
let label = app
|
| 189 |
+
.environments
|
| 190 |
+
.iter()
|
| 191 |
+
.find(|r| &r.id == id)
|
| 192 |
+
.and_then(|r| r.label.clone())
|
| 193 |
+
.unwrap_or_else(|| "Selected".to_string());
|
| 194 |
+
format!(" • {label}").dim()
|
| 195 |
+
} else {
|
| 196 |
+
" • All".dim()
|
| 197 |
+
};
|
| 198 |
+
// Percent scrolled based on selection position in the list (0% at top, 100% at bottom).
|
| 199 |
+
let percent_span = if app.tasks.len() <= 1 {
|
| 200 |
+
" • 0%".dim()
|
| 201 |
+
} else {
|
| 202 |
+
let p = ((app.selected as f32) / ((app.tasks.len() - 1) as f32) * 100.0).round() as i32;
|
| 203 |
+
format!(" • {}%", p.clamp(0, 100)).dim()
|
| 204 |
+
};
|
| 205 |
+
let title_line = {
|
| 206 |
+
let base = Line::from(vec!["Cloud Tasks".into(), suffix_span, percent_span]);
|
| 207 |
+
if dim_bg {
|
| 208 |
+
base.style(Style::default().add_modifier(Modifier::DIM))
|
| 209 |
+
} else {
|
| 210 |
+
base
|
| 211 |
+
}
|
| 212 |
+
};
|
| 213 |
+
let block = Block::default().borders(Borders::ALL).title(title_line);
|
| 214 |
+
// Render the outer block first
|
| 215 |
+
frame.render_widget(block.clone(), area);
|
| 216 |
+
// Draw list inside with a persistent top spacer row
|
| 217 |
+
let inner = block.inner(area);
|
| 218 |
+
let rows = Layout::default()
|
| 219 |
+
.direction(Direction::Vertical)
|
| 220 |
+
.constraints([Constraint::Length(1), Constraint::Min(1)])
|
| 221 |
+
.split(inner);
|
| 222 |
+
let mut list = List::new(items)
|
| 223 |
+
.highlight_symbol("› ")
|
| 224 |
+
.highlight_style(Style::default().bold());
|
| 225 |
+
if dim_bg {
|
| 226 |
+
list = list.style(Style::default().add_modifier(Modifier::DIM));
|
| 227 |
+
}
|
| 228 |
+
frame.render_stateful_widget(list, rows[1], &mut state);
|
| 229 |
+
|
| 230 |
+
// In-box spinner during initial/refresh loads
|
| 231 |
+
if app.refresh_inflight {
|
| 232 |
+
draw_centered_spinner(frame, inner, &mut app.spinner_start, "Loading tasks…");
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
fn draw_footer(frame: &mut Frame, area: Rect, app: &mut App) {
|
| 237 |
+
let mut help = vec![
|
| 238 |
+
"↑/↓".dim(),
|
| 239 |
+
": Move ".dim(),
|
| 240 |
+
"r".dim(),
|
| 241 |
+
": Refresh ".dim(),
|
| 242 |
+
"Enter".dim(),
|
| 243 |
+
": Open ".dim(),
|
| 244 |
+
];
|
| 245 |
+
// Apply hint; show disabled note when overlay is open without a diff.
|
| 246 |
+
if let Some(ov) = app.diff_overlay.as_ref() {
|
| 247 |
+
if !ov.current_can_apply() {
|
| 248 |
+
help.push("a".dim());
|
| 249 |
+
help.push(": Apply (disabled) ".dim());
|
| 250 |
+
} else {
|
| 251 |
+
help.push("a".dim());
|
| 252 |
+
help.push(": Apply ".dim());
|
| 253 |
+
}
|
| 254 |
+
if ov.attempt_count() > 1 {
|
| 255 |
+
help.push("Tab".dim());
|
| 256 |
+
help.push(": Next attempt ".dim());
|
| 257 |
+
help.push("[ ]".dim());
|
| 258 |
+
help.push(": Cycle attempts ".dim());
|
| 259 |
+
}
|
| 260 |
+
} else {
|
| 261 |
+
help.push("a".dim());
|
| 262 |
+
help.push(": Apply ".dim());
|
| 263 |
+
}
|
| 264 |
+
help.push("o : Set Env ".dim());
|
| 265 |
+
if app.new_task.is_some() {
|
| 266 |
+
help.push("Ctrl+N".dim());
|
| 267 |
+
help.push(format!(": Attempts {}x ", app.best_of_n).dim());
|
| 268 |
+
help.push("(editing new task) ".dim());
|
| 269 |
+
} else {
|
| 270 |
+
help.push("n : New Task ".dim());
|
| 271 |
+
}
|
| 272 |
+
help.extend(vec!["q".dim(), ": Quit ".dim()]);
|
| 273 |
+
// Split footer area into two rows: help+spinner (top) and status (bottom)
|
| 274 |
+
let rows = Layout::default()
|
| 275 |
+
.direction(Direction::Vertical)
|
| 276 |
+
.constraints([Constraint::Length(1), Constraint::Length(1)])
|
| 277 |
+
.split(area);
|
| 278 |
+
|
| 279 |
+
// Top row: help text + spinner at right
|
| 280 |
+
let top = Layout::default()
|
| 281 |
+
.direction(Direction::Horizontal)
|
| 282 |
+
.constraints([Constraint::Fill(1), Constraint::Length(18)])
|
| 283 |
+
.split(rows[0]);
|
| 284 |
+
let para = Paragraph::new(Line::from(help));
|
| 285 |
+
// Draw help text; avoid clearing the whole footer area every frame.
|
| 286 |
+
frame.render_widget(para, top[0]);
|
| 287 |
+
// Right side: spinner or clear the spinner area if idle to prevent stale glyphs.
|
| 288 |
+
if app.refresh_inflight
|
| 289 |
+
|| app.details_inflight
|
| 290 |
+
|| app.env_loading
|
| 291 |
+
|| app.apply_preflight_inflight
|
| 292 |
+
|| app.apply_inflight
|
| 293 |
+
{
|
| 294 |
+
draw_inline_spinner(frame, top[1], &mut app.spinner_start, "Loading…");
|
| 295 |
+
} else {
|
| 296 |
+
frame.render_widget(Clear, top[1]);
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
// Bottom row: status/log text across full width (single-line; sanitize newlines)
|
| 300 |
+
let mut status_line = app.status.replace('\n', " ");
|
| 301 |
+
if status_line.len() > 2000 {
|
| 302 |
+
// hard cap to avoid TUI noise
|
| 303 |
+
status_line.truncate(2000);
|
| 304 |
+
status_line.push('…');
|
| 305 |
+
}
|
| 306 |
+
// Clear the status row to avoid trailing characters when the message shrinks.
|
| 307 |
+
frame.render_widget(Clear, rows[1]);
|
| 308 |
+
let status = Paragraph::new(status_line);
|
| 309 |
+
frame.render_widget(status, rows[1]);
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
fn draw_diff_overlay(frame: &mut Frame, area: Rect, app: &mut App) {
|
| 313 |
+
let inner = overlay_outer(area);
|
| 314 |
+
if app.diff_overlay.is_none() {
|
| 315 |
+
return;
|
| 316 |
+
}
|
| 317 |
+
let ov_can_apply = app
|
| 318 |
+
.diff_overlay
|
| 319 |
+
.as_ref()
|
| 320 |
+
.map(super::app::DiffOverlay::current_can_apply)
|
| 321 |
+
.unwrap_or(false);
|
| 322 |
+
let is_error = app
|
| 323 |
+
.diff_overlay
|
| 324 |
+
.as_ref()
|
| 325 |
+
.and_then(|o| o.sd.wrapped_lines().first().cloned())
|
| 326 |
+
.map(|s| s.trim_start().starts_with("Task failed:"))
|
| 327 |
+
.unwrap_or(false)
|
| 328 |
+
&& !ov_can_apply;
|
| 329 |
+
let title = app
|
| 330 |
+
.diff_overlay
|
| 331 |
+
.as_ref()
|
| 332 |
+
.map(|o| o.title.clone())
|
| 333 |
+
.unwrap_or_default();
|
| 334 |
+
|
| 335 |
+
// Title block
|
| 336 |
+
let title_ref = title.as_str();
|
| 337 |
+
let mut title_spans: Vec<ratatui::text::Span> = if is_error {
|
| 338 |
+
vec![
|
| 339 |
+
"Details ".magenta(),
|
| 340 |
+
"[FAILED]".red().bold(),
|
| 341 |
+
" ".into(),
|
| 342 |
+
title_ref.magenta(),
|
| 343 |
+
]
|
| 344 |
+
} else if ov_can_apply {
|
| 345 |
+
vec!["Diff: ".magenta(), title_ref.magenta()]
|
| 346 |
+
} else {
|
| 347 |
+
vec!["Details: ".magenta(), title_ref.magenta()]
|
| 348 |
+
};
|
| 349 |
+
if let Some(p) = app
|
| 350 |
+
.diff_overlay
|
| 351 |
+
.as_ref()
|
| 352 |
+
.and_then(|o| o.sd.percent_scrolled())
|
| 353 |
+
{
|
| 354 |
+
title_spans.push(" • ".dim());
|
| 355 |
+
title_spans.push(format!("{p}%").dim());
|
| 356 |
+
}
|
| 357 |
+
frame.render_widget(Clear, inner);
|
| 358 |
+
frame.render_widget(
|
| 359 |
+
overlay_block().title(Line::from(title_spans)).clone(),
|
| 360 |
+
inner,
|
| 361 |
+
);
|
| 362 |
+
|
| 363 |
+
// Content area and optional status bar
|
| 364 |
+
let content_full = overlay_content(inner);
|
| 365 |
+
let mut content_area = content_full;
|
| 366 |
+
if let Some(ov) = app.diff_overlay.as_mut() {
|
| 367 |
+
let has_text = ov.current_attempt().is_some_and(AttemptView::has_text);
|
| 368 |
+
let has_diff = ov.current_attempt().is_some_and(AttemptView::has_diff) || ov.base_can_apply;
|
| 369 |
+
if has_diff || has_text {
|
| 370 |
+
let rows = Layout::default()
|
| 371 |
+
.direction(Direction::Vertical)
|
| 372 |
+
.constraints([Constraint::Length(1), Constraint::Min(1)])
|
| 373 |
+
.split(content_full);
|
| 374 |
+
// Status bar label
|
| 375 |
+
let mut spans: Vec<ratatui::text::Span> = Vec::new();
|
| 376 |
+
if has_diff && has_text {
|
| 377 |
+
let prompt_lbl = if matches!(ov.current_view, crate::app::DetailView::Prompt) {
|
| 378 |
+
"[Prompt]".magenta().bold()
|
| 379 |
+
} else {
|
| 380 |
+
"Prompt".dim()
|
| 381 |
+
};
|
| 382 |
+
let diff_lbl = if matches!(ov.current_view, crate::app::DetailView::Diff) {
|
| 383 |
+
"[Diff]".magenta().bold()
|
| 384 |
+
} else {
|
| 385 |
+
"Diff".dim()
|
| 386 |
+
};
|
| 387 |
+
spans.extend(vec![
|
| 388 |
+
prompt_lbl,
|
| 389 |
+
" ".into(),
|
| 390 |
+
diff_lbl,
|
| 391 |
+
" ".into(),
|
| 392 |
+
"(← → to switch view)".dim(),
|
| 393 |
+
]);
|
| 394 |
+
} else if has_text {
|
| 395 |
+
spans.push("Conversation".magenta().bold());
|
| 396 |
+
} else {
|
| 397 |
+
spans.push("Diff".magenta().bold());
|
| 398 |
+
}
|
| 399 |
+
if let Some(total) = ov.expected_attempts().or({
|
| 400 |
+
if ov.attempts.is_empty() {
|
| 401 |
+
None
|
| 402 |
+
} else {
|
| 403 |
+
Some(ov.attempts.len())
|
| 404 |
+
}
|
| 405 |
+
}) && total > 1
|
| 406 |
+
{
|
| 407 |
+
spans.extend(vec![
|
| 408 |
+
" ".into(),
|
| 409 |
+
format!("Attempt {}/{}", ov.selected_attempt + 1, total)
|
| 410 |
+
.bold()
|
| 411 |
+
.dim(),
|
| 412 |
+
" ".into(),
|
| 413 |
+
"(Tab/Shift-Tab or [ ] to cycle attempts)".dim(),
|
| 414 |
+
]);
|
| 415 |
+
}
|
| 416 |
+
frame.render_widget(Paragraph::new(Line::from(spans)), rows[0]);
|
| 417 |
+
ov.sd.set_width(rows[1].width);
|
| 418 |
+
ov.sd.set_viewport(rows[1].height);
|
| 419 |
+
content_area = rows[1];
|
| 420 |
+
} else {
|
| 421 |
+
ov.sd.set_width(content_full.width);
|
| 422 |
+
ov.sd.set_viewport(content_full.height);
|
| 423 |
+
content_area = content_full;
|
| 424 |
+
}
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
// Styled content render
|
| 428 |
+
// Choose styling by the active view, not just presence of a diff
|
| 429 |
+
let is_diff_view = app
|
| 430 |
+
.diff_overlay
|
| 431 |
+
.as_ref()
|
| 432 |
+
.map(|o| matches!(o.current_view, crate::app::DetailView::Diff))
|
| 433 |
+
.unwrap_or(false);
|
| 434 |
+
let styled_lines: Vec<Line<'static>> = if is_diff_view {
|
| 435 |
+
let raw = app.diff_overlay.as_ref().map(|o| o.sd.wrapped_lines());
|
| 436 |
+
raw.unwrap_or(&[])
|
| 437 |
+
.iter()
|
| 438 |
+
.map(|l| style_diff_line(l))
|
| 439 |
+
.collect()
|
| 440 |
+
} else {
|
| 441 |
+
app.diff_overlay
|
| 442 |
+
.as_ref()
|
| 443 |
+
.map(|o| style_conversation_lines(&o.sd, o.current_attempt()))
|
| 444 |
+
.unwrap_or_default()
|
| 445 |
+
};
|
| 446 |
+
let raw_empty = app
|
| 447 |
+
.diff_overlay
|
| 448 |
+
.as_ref()
|
| 449 |
+
.map(|o| o.sd.wrapped_lines().is_empty())
|
| 450 |
+
.unwrap_or(true);
|
| 451 |
+
if app.details_inflight && raw_empty {
|
| 452 |
+
draw_centered_spinner(
|
| 453 |
+
frame,
|
| 454 |
+
content_area,
|
| 455 |
+
&mut app.spinner_start,
|
| 456 |
+
"Loading details…",
|
| 457 |
+
);
|
| 458 |
+
} else {
|
| 459 |
+
let scroll = app
|
| 460 |
+
.diff_overlay
|
| 461 |
+
.as_ref()
|
| 462 |
+
.map(|o| o.sd.state.scroll)
|
| 463 |
+
.unwrap_or(0);
|
| 464 |
+
let content = Paragraph::new(Text::from(styled_lines)).scroll((scroll, 0));
|
| 465 |
+
frame.render_widget(content, content_area);
|
| 466 |
+
}
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
pub fn draw_apply_modal(frame: &mut Frame, area: Rect, app: &mut App) {
|
| 470 |
+
use ratatui::widgets::Wrap;
|
| 471 |
+
let inner = overlay_outer(area);
|
| 472 |
+
let title = Line::from("Apply Changes?".magenta().bold());
|
| 473 |
+
let block = overlay_block().title(title);
|
| 474 |
+
frame.render_widget(Clear, inner);
|
| 475 |
+
frame.render_widget(block.clone(), inner);
|
| 476 |
+
let content = overlay_content(inner);
|
| 477 |
+
|
| 478 |
+
if let Some(m) = &app.apply_modal {
|
| 479 |
+
// Header
|
| 480 |
+
let header = Paragraph::new(Line::from(
|
| 481 |
+
format!("Apply '{}' ?", m.title).magenta().bold(),
|
| 482 |
+
))
|
| 483 |
+
.wrap(Wrap { trim: true });
|
| 484 |
+
// Footer instructions
|
| 485 |
+
let footer =
|
| 486 |
+
Paragraph::new(Line::from("Press Y to apply, P to preflight, N to cancel.").dim())
|
| 487 |
+
.wrap(Wrap { trim: true });
|
| 488 |
+
|
| 489 |
+
// Split into header/body/footer
|
| 490 |
+
let rows = Layout::default()
|
| 491 |
+
.direction(Direction::Vertical)
|
| 492 |
+
.constraints([
|
| 493 |
+
Constraint::Length(1),
|
| 494 |
+
Constraint::Min(1),
|
| 495 |
+
Constraint::Length(1),
|
| 496 |
+
])
|
| 497 |
+
.split(content);
|
| 498 |
+
|
| 499 |
+
frame.render_widget(header, rows[0]);
|
| 500 |
+
// Body: spinner while preflight/apply runs; otherwise show result message and path lists
|
| 501 |
+
if app.apply_preflight_inflight {
|
| 502 |
+
draw_centered_spinner(frame, rows[1], &mut app.spinner_start, "Checking…");
|
| 503 |
+
} else if app.apply_inflight {
|
| 504 |
+
draw_centered_spinner(frame, rows[1], &mut app.spinner_start, "Applying…");
|
| 505 |
+
} else if m.result_message.is_none() {
|
| 506 |
+
draw_centered_spinner(frame, rows[1], &mut app.spinner_start, "Loading…");
|
| 507 |
+
} else if let Some(msg) = &m.result_message {
|
| 508 |
+
let mut body_lines: Vec<Line> = Vec::new();
|
| 509 |
+
let first = match m.result_level {
|
| 510 |
+
Some(crate::app::ApplyResultLevel::Success) => msg.clone().green(),
|
| 511 |
+
Some(crate::app::ApplyResultLevel::Partial) => msg.clone().magenta(),
|
| 512 |
+
Some(crate::app::ApplyResultLevel::Error) => msg.clone().red(),
|
| 513 |
+
None => msg.clone().into(),
|
| 514 |
+
};
|
| 515 |
+
body_lines.push(Line::from(first));
|
| 516 |
+
|
| 517 |
+
// On partial or error, show conflicts/skips if present
|
| 518 |
+
if !matches!(m.result_level, Some(crate::app::ApplyResultLevel::Success)) {
|
| 519 |
+
use ratatui::text::Span;
|
| 520 |
+
if !m.conflict_paths.is_empty() {
|
| 521 |
+
body_lines.push(Line::from(""));
|
| 522 |
+
body_lines.push(
|
| 523 |
+
Line::from(format!("Conflicts ({}):", m.conflict_paths.len()))
|
| 524 |
+
.red()
|
| 525 |
+
.bold(),
|
| 526 |
+
);
|
| 527 |
+
for p in &m.conflict_paths {
|
| 528 |
+
body_lines
|
| 529 |
+
.push(Line::from(vec![" • ".into(), Span::raw(p.clone()).dim()]));
|
| 530 |
+
}
|
| 531 |
+
}
|
| 532 |
+
if !m.skipped_paths.is_empty() {
|
| 533 |
+
body_lines.push(Line::from(""));
|
| 534 |
+
body_lines.push(
|
| 535 |
+
Line::from(format!("Skipped ({}):", m.skipped_paths.len()))
|
| 536 |
+
.magenta()
|
| 537 |
+
.bold(),
|
| 538 |
+
);
|
| 539 |
+
for p in &m.skipped_paths {
|
| 540 |
+
body_lines
|
| 541 |
+
.push(Line::from(vec![" • ".into(), Span::raw(p.clone()).dim()]));
|
| 542 |
+
}
|
| 543 |
+
}
|
| 544 |
+
}
|
| 545 |
+
let body = Paragraph::new(body_lines).wrap(Wrap { trim: true });
|
| 546 |
+
frame.render_widget(body, rows[1]);
|
| 547 |
+
}
|
| 548 |
+
frame.render_widget(footer, rows[2]);
|
| 549 |
+
}
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
| 553 |
+
enum ConversationSpeaker {
|
| 554 |
+
User,
|
| 555 |
+
Assistant,
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
fn style_conversation_lines(
|
| 559 |
+
sd: &crate::scrollable_diff::ScrollableDiff,
|
| 560 |
+
attempt: Option<&AttemptView>,
|
| 561 |
+
) -> Vec<Line<'static>> {
|
| 562 |
+
use ratatui::text::Span;
|
| 563 |
+
|
| 564 |
+
let wrapped = sd.wrapped_lines();
|
| 565 |
+
if wrapped.is_empty() {
|
| 566 |
+
return Vec::new();
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
let indices = sd.wrapped_src_indices();
|
| 570 |
+
let mut styled: Vec<Line<'static>> = Vec::new();
|
| 571 |
+
let mut speaker: Option<ConversationSpeaker> = None;
|
| 572 |
+
let mut in_code = false;
|
| 573 |
+
let mut last_src: Option<usize> = None;
|
| 574 |
+
let mut bullet_indent: Option<usize> = None;
|
| 575 |
+
|
| 576 |
+
for (display, &src_idx) in wrapped.iter().zip(indices.iter()) {
|
| 577 |
+
let raw = sd.raw_line_at(src_idx);
|
| 578 |
+
let trimmed = raw.trim();
|
| 579 |
+
let is_new_raw = last_src.map(|prev| prev != src_idx).unwrap_or(true);
|
| 580 |
+
|
| 581 |
+
if trimmed.eq_ignore_ascii_case("user:") {
|
| 582 |
+
speaker = Some(ConversationSpeaker::User);
|
| 583 |
+
in_code = false;
|
| 584 |
+
bullet_indent = None;
|
| 585 |
+
styled.push(conversation_header_line(
|
| 586 |
+
ConversationSpeaker::User,
|
| 587 |
+
/*attempt*/ None,
|
| 588 |
+
));
|
| 589 |
+
last_src = Some(src_idx);
|
| 590 |
+
continue;
|
| 591 |
+
}
|
| 592 |
+
if trimmed.eq_ignore_ascii_case("assistant:") {
|
| 593 |
+
speaker = Some(ConversationSpeaker::Assistant);
|
| 594 |
+
in_code = false;
|
| 595 |
+
bullet_indent = None;
|
| 596 |
+
styled.push(conversation_header_line(
|
| 597 |
+
ConversationSpeaker::Assistant,
|
| 598 |
+
attempt,
|
| 599 |
+
));
|
| 600 |
+
last_src = Some(src_idx);
|
| 601 |
+
continue;
|
| 602 |
+
}
|
| 603 |
+
if raw.is_empty() {
|
| 604 |
+
let mut spans: Vec<Span> = Vec::new();
|
| 605 |
+
if let Some(role) = speaker {
|
| 606 |
+
spans.push(conversation_gutter_span(role));
|
| 607 |
+
} else {
|
| 608 |
+
spans.push(Span::raw(String::new()));
|
| 609 |
+
}
|
| 610 |
+
styled.push(Line::from(spans));
|
| 611 |
+
last_src = Some(src_idx);
|
| 612 |
+
bullet_indent = None;
|
| 613 |
+
continue;
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
if is_new_raw {
|
| 617 |
+
let trimmed_start = raw.trim_start();
|
| 618 |
+
if trimmed_start.starts_with("```") {
|
| 619 |
+
in_code = !in_code;
|
| 620 |
+
bullet_indent = None;
|
| 621 |
+
} else if !in_code
|
| 622 |
+
&& (trimmed_start.starts_with("- ") || trimmed_start.starts_with("* "))
|
| 623 |
+
{
|
| 624 |
+
let indent = raw.chars().take_while(|c| c.is_whitespace()).count();
|
| 625 |
+
bullet_indent = Some(indent);
|
| 626 |
+
} else if !in_code {
|
| 627 |
+
bullet_indent = None;
|
| 628 |
+
}
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
let mut spans: Vec<Span> = Vec::new();
|
| 632 |
+
if let Some(role) = speaker {
|
| 633 |
+
spans.push(conversation_gutter_span(role));
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
spans.extend(conversation_text_spans(
|
| 637 |
+
display,
|
| 638 |
+
in_code,
|
| 639 |
+
is_new_raw,
|
| 640 |
+
bullet_indent,
|
| 641 |
+
));
|
| 642 |
+
|
| 643 |
+
styled.push(Line::from(spans));
|
| 644 |
+
last_src = Some(src_idx);
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
if styled.is_empty() {
|
| 648 |
+
wrapped.iter().map(|l| Line::from(l.to_string())).collect()
|
| 649 |
+
} else {
|
| 650 |
+
styled
|
| 651 |
+
}
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
fn conversation_header_line(
|
| 655 |
+
speaker: ConversationSpeaker,
|
| 656 |
+
attempt: Option<&AttemptView>,
|
| 657 |
+
) -> Line<'static> {
|
| 658 |
+
use ratatui::text::Span;
|
| 659 |
+
|
| 660 |
+
let mut spans: Vec<Span> = vec!["╭ ".dim()];
|
| 661 |
+
match speaker {
|
| 662 |
+
ConversationSpeaker::User => {
|
| 663 |
+
spans.push("User".cyan().bold());
|
| 664 |
+
spans.push(" prompt".dim());
|
| 665 |
+
}
|
| 666 |
+
ConversationSpeaker::Assistant => {
|
| 667 |
+
spans.push("Assistant".magenta().bold());
|
| 668 |
+
spans.push(" response".dim());
|
| 669 |
+
if let Some(attempt) = attempt
|
| 670 |
+
&& let Some(status_span) = attempt_status_span(attempt.status)
|
| 671 |
+
{
|
| 672 |
+
spans.push(" • ".dim());
|
| 673 |
+
spans.push(status_span);
|
| 674 |
+
}
|
| 675 |
+
}
|
| 676 |
+
}
|
| 677 |
+
Line::from(spans)
|
| 678 |
+
}
|
| 679 |
+
|
| 680 |
+
fn conversation_gutter_span(speaker: ConversationSpeaker) -> ratatui::text::Span<'static> {
|
| 681 |
+
match speaker {
|
| 682 |
+
ConversationSpeaker::User => "│ ".cyan().dim(),
|
| 683 |
+
ConversationSpeaker::Assistant => "│ ".magenta().dim(),
|
| 684 |
+
}
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
fn conversation_text_spans(
|
| 688 |
+
display: &str,
|
| 689 |
+
in_code: bool,
|
| 690 |
+
is_new_raw: bool,
|
| 691 |
+
bullet_indent: Option<usize>,
|
| 692 |
+
) -> Vec<ratatui::text::Span<'static>> {
|
| 693 |
+
use ratatui::text::Span;
|
| 694 |
+
|
| 695 |
+
if in_code {
|
| 696 |
+
return vec![Span::styled(
|
| 697 |
+
display.to_string(),
|
| 698 |
+
Style::default().fg(Color::Cyan),
|
| 699 |
+
)];
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
let trimmed = display.trim_start();
|
| 703 |
+
|
| 704 |
+
if let Some(indent) = bullet_indent {
|
| 705 |
+
if is_new_raw {
|
| 706 |
+
let rest = trimmed.get(2..).unwrap_or("").trim_start();
|
| 707 |
+
let mut spans: Vec<Span> = Vec::new();
|
| 708 |
+
if indent > 0 {
|
| 709 |
+
spans.push(Span::raw(" ".repeat(indent)));
|
| 710 |
+
}
|
| 711 |
+
spans.push("• ".into());
|
| 712 |
+
spans.push(Span::raw(rest.to_string()));
|
| 713 |
+
return spans;
|
| 714 |
+
}
|
| 715 |
+
let mut continuation = String::new();
|
| 716 |
+
continuation.push_str(&" ".repeat(indent + 2));
|
| 717 |
+
continuation.push_str(trimmed);
|
| 718 |
+
return vec![Span::raw(continuation)];
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
if is_new_raw
|
| 722 |
+
&& (trimmed.starts_with("### ") || trimmed.starts_with("## ") || trimmed.starts_with("# "))
|
| 723 |
+
{
|
| 724 |
+
return vec![Span::styled(
|
| 725 |
+
display.to_string(),
|
| 726 |
+
Style::default()
|
| 727 |
+
.fg(Color::Magenta)
|
| 728 |
+
.add_modifier(Modifier::BOLD),
|
| 729 |
+
)];
|
| 730 |
+
}
|
| 731 |
+
|
| 732 |
+
let mut rendered = render_markdown_text(display);
|
| 733 |
+
if rendered.lines.is_empty() {
|
| 734 |
+
return vec![Span::raw(display.to_string())];
|
| 735 |
+
}
|
| 736 |
+
// `render_markdown_text` can yield multiple lines when the input contains
|
| 737 |
+
// explicit breaks. We only expect a single line here; join the spans of the
|
| 738 |
+
// first rendered line for styling.
|
| 739 |
+
rendered.lines.remove(0).spans.into_iter().collect()
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
fn attempt_status_span(status: AttemptStatus) -> Option<ratatui::text::Span<'static>> {
|
| 743 |
+
match status {
|
| 744 |
+
AttemptStatus::Completed => Some("Completed".green()),
|
| 745 |
+
AttemptStatus::Failed => Some("Failed".red().bold()),
|
| 746 |
+
AttemptStatus::InProgress => Some("In progress".magenta()),
|
| 747 |
+
AttemptStatus::Pending => Some("Pending".cyan()),
|
| 748 |
+
AttemptStatus::Cancelled => Some("Cancelled".dim()),
|
| 749 |
+
AttemptStatus::Unknown => None,
|
| 750 |
+
}
|
| 751 |
+
}
|
| 752 |
+
|
| 753 |
+
fn style_diff_line(raw: &str) -> Line<'static> {
|
| 754 |
+
use ratatui::style::Color;
|
| 755 |
+
use ratatui::style::Modifier;
|
| 756 |
+
use ratatui::style::Style;
|
| 757 |
+
use ratatui::text::Span;
|
| 758 |
+
|
| 759 |
+
if raw.starts_with("@@") {
|
| 760 |
+
return Line::from(vec![Span::styled(
|
| 761 |
+
raw.to_string(),
|
| 762 |
+
Style::default()
|
| 763 |
+
.fg(Color::Magenta)
|
| 764 |
+
.add_modifier(Modifier::BOLD),
|
| 765 |
+
)]);
|
| 766 |
+
}
|
| 767 |
+
if raw.starts_with("+++") || raw.starts_with("---") {
|
| 768 |
+
return Line::from(vec![Span::styled(
|
| 769 |
+
raw.to_string(),
|
| 770 |
+
Style::default().add_modifier(Modifier::DIM),
|
| 771 |
+
)]);
|
| 772 |
+
}
|
| 773 |
+
if raw.starts_with('+') {
|
| 774 |
+
return Line::from(vec![Span::styled(
|
| 775 |
+
raw.to_string(),
|
| 776 |
+
Style::default().fg(Color::Green),
|
| 777 |
+
)]);
|
| 778 |
+
}
|
| 779 |
+
if raw.starts_with('-') {
|
| 780 |
+
return Line::from(vec![Span::styled(
|
| 781 |
+
raw.to_string(),
|
| 782 |
+
Style::default().fg(Color::Red),
|
| 783 |
+
)]);
|
| 784 |
+
}
|
| 785 |
+
Line::from(vec![Span::raw(raw.to_string())])
|
| 786 |
+
}
|
| 787 |
+
|
| 788 |
+
fn render_task_item(_app: &App, t: &codex_cloud_tasks_client::TaskSummary) -> ListItem<'static> {
|
| 789 |
+
let status = match t.status {
|
| 790 |
+
TaskStatus::Ready => "READY".green(),
|
| 791 |
+
TaskStatus::Pending => "PENDING".magenta(),
|
| 792 |
+
TaskStatus::Applied => "APPLIED".blue(),
|
| 793 |
+
TaskStatus::Error => "ERROR".red(),
|
| 794 |
+
};
|
| 795 |
+
|
| 796 |
+
// Title line: [STATUS] Title
|
| 797 |
+
let title = Line::from(vec![
|
| 798 |
+
"[".into(),
|
| 799 |
+
status,
|
| 800 |
+
"] ".into(),
|
| 801 |
+
t.title.clone().into(),
|
| 802 |
+
]);
|
| 803 |
+
|
| 804 |
+
// Meta line: environment label and relative time (dim)
|
| 805 |
+
let mut meta: Vec<ratatui::text::Span> = Vec::new();
|
| 806 |
+
if let Some(lbl) = t.environment_label.as_ref().filter(|s| !s.is_empty()) {
|
| 807 |
+
meta.push(lbl.clone().dim());
|
| 808 |
+
}
|
| 809 |
+
let when = format_relative_time_now(t.updated_at).dim();
|
| 810 |
+
if !meta.is_empty() {
|
| 811 |
+
meta.push(" ".into());
|
| 812 |
+
meta.push("•".dim());
|
| 813 |
+
meta.push(" ".into());
|
| 814 |
+
}
|
| 815 |
+
meta.push(when);
|
| 816 |
+
let meta_line = Line::from(meta);
|
| 817 |
+
|
| 818 |
+
// Subline: summary when present; otherwise show "no diff"
|
| 819 |
+
let sub = if t.summary.files_changed > 0
|
| 820 |
+
|| t.summary.lines_added > 0
|
| 821 |
+
|| t.summary.lines_removed > 0
|
| 822 |
+
{
|
| 823 |
+
let adds = t.summary.lines_added;
|
| 824 |
+
let dels = t.summary.lines_removed;
|
| 825 |
+
let files = t.summary.files_changed;
|
| 826 |
+
Line::from(vec![
|
| 827 |
+
format!("+{adds}").green(),
|
| 828 |
+
"/".into(),
|
| 829 |
+
format!("−{dels}").red(),
|
| 830 |
+
" ".into(),
|
| 831 |
+
"•".dim(),
|
| 832 |
+
" ".into(),
|
| 833 |
+
format!("{files}").into(),
|
| 834 |
+
" ".into(),
|
| 835 |
+
"files".dim(),
|
| 836 |
+
])
|
| 837 |
+
} else {
|
| 838 |
+
Line::from("no diff".to_string().dim())
|
| 839 |
+
};
|
| 840 |
+
|
| 841 |
+
// Insert a blank spacer line after the summary to separate tasks
|
| 842 |
+
let spacer = Line::from("");
|
| 843 |
+
ListItem::new(vec![title, meta_line, sub, spacer])
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
fn draw_inline_spinner(
|
| 847 |
+
frame: &mut Frame,
|
| 848 |
+
area: Rect,
|
| 849 |
+
spinner_start: &mut Option<Instant>,
|
| 850 |
+
label: &str,
|
| 851 |
+
) {
|
| 852 |
+
use ratatui::widgets::Paragraph;
|
| 853 |
+
let start = spinner_start.get_or_insert_with(Instant::now);
|
| 854 |
+
let blink_on = (start.elapsed().as_millis() / 600).is_multiple_of(2);
|
| 855 |
+
let dot = if blink_on {
|
| 856 |
+
"• ".into()
|
| 857 |
+
} else {
|
| 858 |
+
"◦ ".dim()
|
| 859 |
+
};
|
| 860 |
+
let label = label.cyan();
|
| 861 |
+
let line = Line::from(vec![dot, label]);
|
| 862 |
+
frame.render_widget(Paragraph::new(line), area);
|
| 863 |
+
}
|
| 864 |
+
|
| 865 |
+
fn draw_centered_spinner(
|
| 866 |
+
frame: &mut Frame,
|
| 867 |
+
area: Rect,
|
| 868 |
+
spinner_start: &mut Option<Instant>,
|
| 869 |
+
label: &str,
|
| 870 |
+
) {
|
| 871 |
+
// Center a 1xN spinner within the given rect
|
| 872 |
+
let rows = Layout::default()
|
| 873 |
+
.direction(Direction::Vertical)
|
| 874 |
+
.constraints([
|
| 875 |
+
Constraint::Percentage(50),
|
| 876 |
+
Constraint::Length(1),
|
| 877 |
+
Constraint::Percentage(49),
|
| 878 |
+
])
|
| 879 |
+
.split(area);
|
| 880 |
+
let cols = Layout::default()
|
| 881 |
+
.direction(Direction::Horizontal)
|
| 882 |
+
.constraints([
|
| 883 |
+
Constraint::Percentage(50),
|
| 884 |
+
Constraint::Length(18),
|
| 885 |
+
Constraint::Percentage(50),
|
| 886 |
+
])
|
| 887 |
+
.split(rows[1]);
|
| 888 |
+
draw_inline_spinner(frame, cols[1], spinner_start, label);
|
| 889 |
+
}
|
| 890 |
+
|
| 891 |
+
// Styling helpers for diff rendering live inline where used.
|
| 892 |
+
|
| 893 |
+
pub fn draw_env_modal(frame: &mut Frame, area: Rect, app: &mut App) {
|
| 894 |
+
use ratatui::widgets::Wrap;
|
| 895 |
+
|
| 896 |
+
// Use shared overlay geometry and padding.
|
| 897 |
+
let inner = overlay_outer(area);
|
| 898 |
+
|
| 899 |
+
// Title: primary only; move long hints to a subheader inside content.
|
| 900 |
+
let title = Line::from(vec!["Select Environment".magenta().bold()]);
|
| 901 |
+
let block = overlay_block().title(title);
|
| 902 |
+
|
| 903 |
+
frame.render_widget(Clear, inner);
|
| 904 |
+
frame.render_widget(block.clone(), inner);
|
| 905 |
+
let content = overlay_content(inner);
|
| 906 |
+
|
| 907 |
+
if app.env_loading {
|
| 908 |
+
draw_centered_spinner(
|
| 909 |
+
frame,
|
| 910 |
+
content,
|
| 911 |
+
&mut app.spinner_start,
|
| 912 |
+
"Loading environments…",
|
| 913 |
+
);
|
| 914 |
+
return;
|
| 915 |
+
}
|
| 916 |
+
|
| 917 |
+
// Layout: subheader + search + results list
|
| 918 |
+
let rows = Layout::default()
|
| 919 |
+
.direction(Direction::Vertical)
|
| 920 |
+
.constraints([
|
| 921 |
+
Constraint::Length(1), // subheader
|
| 922 |
+
Constraint::Length(1), // search
|
| 923 |
+
Constraint::Min(1), // list
|
| 924 |
+
])
|
| 925 |
+
.split(content);
|
| 926 |
+
|
| 927 |
+
// Subheader with usage hints (dim cyan)
|
| 928 |
+
let subheader = Paragraph::new(Line::from(
|
| 929 |
+
"Type to search, Enter select, Esc cancel".cyan().dim(),
|
| 930 |
+
))
|
| 931 |
+
.wrap(Wrap { trim: true });
|
| 932 |
+
frame.render_widget(subheader, rows[0]);
|
| 933 |
+
|
| 934 |
+
let query = app
|
| 935 |
+
.env_modal
|
| 936 |
+
.as_ref()
|
| 937 |
+
.map(|m| m.query.clone())
|
| 938 |
+
.unwrap_or_default();
|
| 939 |
+
let ql = query.to_lowercase();
|
| 940 |
+
let search = Paragraph::new(format!("Search: {query}")).wrap(Wrap { trim: true });
|
| 941 |
+
frame.render_widget(search, rows[1]);
|
| 942 |
+
|
| 943 |
+
// Filter environments by query (case-insensitive substring over label/id/hints)
|
| 944 |
+
let envs: Vec<&crate::app::EnvironmentRow> = app
|
| 945 |
+
.environments
|
| 946 |
+
.iter()
|
| 947 |
+
.filter(|e| {
|
| 948 |
+
if ql.is_empty() {
|
| 949 |
+
return true;
|
| 950 |
+
}
|
| 951 |
+
let mut hay = String::new();
|
| 952 |
+
if let Some(l) = &e.label {
|
| 953 |
+
hay.push_str(&l.to_lowercase());
|
| 954 |
+
hay.push(' ');
|
| 955 |
+
}
|
| 956 |
+
hay.push_str(&e.id.to_lowercase());
|
| 957 |
+
if let Some(h) = &e.repo_hints {
|
| 958 |
+
hay.push(' ');
|
| 959 |
+
hay.push_str(&h.to_lowercase());
|
| 960 |
+
}
|
| 961 |
+
hay.contains(&ql)
|
| 962 |
+
})
|
| 963 |
+
.collect();
|
| 964 |
+
|
| 965 |
+
let mut items: Vec<ListItem> = Vec::new();
|
| 966 |
+
items.push(ListItem::new(Line::from("All Environments (Global)")));
|
| 967 |
+
for env in envs.iter() {
|
| 968 |
+
let primary = env.label.clone().unwrap_or_else(|| "<unnamed>".to_string());
|
| 969 |
+
let mut spans: Vec<ratatui::text::Span> = vec![primary.into()];
|
| 970 |
+
if env.is_pinned {
|
| 971 |
+
spans.push(" ".into());
|
| 972 |
+
spans.push("PINNED".magenta().bold());
|
| 973 |
+
}
|
| 974 |
+
spans.push(" ".into());
|
| 975 |
+
spans.push(env.id.clone().dim());
|
| 976 |
+
if let Some(hint) = &env.repo_hints {
|
| 977 |
+
spans.push(" ".into());
|
| 978 |
+
spans.push(hint.clone().dim());
|
| 979 |
+
}
|
| 980 |
+
items.push(ListItem::new(Line::from(spans)));
|
| 981 |
+
}
|
| 982 |
+
|
| 983 |
+
let sel_desired = app.env_modal.as_ref().map(|m| m.selected).unwrap_or(0);
|
| 984 |
+
let sel = sel_desired.min(envs.len());
|
| 985 |
+
let mut list_state = ListState::default().with_selected(Some(sel));
|
| 986 |
+
let list = List::new(items)
|
| 987 |
+
.highlight_symbol("› ")
|
| 988 |
+
.highlight_style(Style::default().bold())
|
| 989 |
+
.block(Block::default().borders(Borders::NONE));
|
| 990 |
+
frame.render_stateful_widget(list, rows[2], &mut list_state);
|
| 991 |
+
}
|
| 992 |
+
|
| 993 |
+
pub fn draw_best_of_modal(frame: &mut Frame, area: Rect, app: &mut App) {
|
| 994 |
+
use ratatui::widgets::Wrap;
|
| 995 |
+
|
| 996 |
+
let inner = overlay_outer(area);
|
| 997 |
+
const MAX_WIDTH: u16 = 40;
|
| 998 |
+
const MIN_WIDTH: u16 = 20;
|
| 999 |
+
const MAX_HEIGHT: u16 = 12;
|
| 1000 |
+
const MIN_HEIGHT: u16 = 6;
|
| 1001 |
+
let modal_width = inner.width.min(MAX_WIDTH).max(inner.width.min(MIN_WIDTH));
|
| 1002 |
+
let modal_height = inner
|
| 1003 |
+
.height
|
| 1004 |
+
.min(MAX_HEIGHT)
|
| 1005 |
+
.max(inner.height.min(MIN_HEIGHT));
|
| 1006 |
+
let modal_x = inner.x + (inner.width.saturating_sub(modal_width)) / 2;
|
| 1007 |
+
let modal_y = inner.y + (inner.height.saturating_sub(modal_height)) / 2;
|
| 1008 |
+
let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
|
| 1009 |
+
let title = Line::from(vec!["Parallel Attempts".magenta().bold()]);
|
| 1010 |
+
let block = overlay_block().title(title);
|
| 1011 |
+
|
| 1012 |
+
frame.render_widget(Clear, modal_area);
|
| 1013 |
+
frame.render_widget(block.clone(), modal_area);
|
| 1014 |
+
let content = overlay_content(modal_area);
|
| 1015 |
+
|
| 1016 |
+
let rows = Layout::default()
|
| 1017 |
+
.direction(Direction::Vertical)
|
| 1018 |
+
.constraints([Constraint::Length(2), Constraint::Min(1)])
|
| 1019 |
+
.split(content);
|
| 1020 |
+
|
| 1021 |
+
let hint = Paragraph::new(Line::from("Use ↑/↓ to choose, 1-4 jump".cyan().dim()))
|
| 1022 |
+
.wrap(Wrap { trim: true });
|
| 1023 |
+
frame.render_widget(hint, rows[0]);
|
| 1024 |
+
|
| 1025 |
+
let selected = app.best_of_modal.as_ref().map(|m| m.selected).unwrap_or(0);
|
| 1026 |
+
let options = [1usize, 2, 3, 4];
|
| 1027 |
+
let mut items: Vec<ListItem> = Vec::new();
|
| 1028 |
+
for &attempts in &options {
|
| 1029 |
+
let noun = if attempts == 1 { "attempt" } else { "attempts" };
|
| 1030 |
+
let mut spans: Vec<ratatui::text::Span> = vec![format!("{attempts} {noun:<8}").into()];
|
| 1031 |
+
spans.push(" ".into());
|
| 1032 |
+
spans.push(format!("{attempts}x parallel").dim());
|
| 1033 |
+
if attempts == app.best_of_n {
|
| 1034 |
+
spans.push(" ".into());
|
| 1035 |
+
spans.push("Current".magenta().bold());
|
| 1036 |
+
}
|
| 1037 |
+
items.push(ListItem::new(Line::from(spans)));
|
| 1038 |
+
}
|
| 1039 |
+
let sel = selected.min(options.len().saturating_sub(1));
|
| 1040 |
+
let mut list_state = ListState::default().with_selected(Some(sel));
|
| 1041 |
+
let list = List::new(items)
|
| 1042 |
+
.highlight_symbol("› ")
|
| 1043 |
+
.highlight_style(Style::default().bold())
|
| 1044 |
+
.block(Block::default().borders(Borders::NONE));
|
| 1045 |
+
frame.render_stateful_widget(list, rows[1], &mut list_state);
|
| 1046 |
+
}
|
codex-rs/cloud-tasks/src/util.rs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use chrono::DateTime;
|
| 2 |
+
use chrono::Local;
|
| 3 |
+
use chrono::Utc;
|
| 4 |
+
use http::header::HeaderMap;
|
| 5 |
+
|
| 6 |
+
use codex_core::config::Config;
|
| 7 |
+
use codex_http_client::HttpClientFactory;
|
| 8 |
+
use codex_http_client::OutboundProxyPolicy;
|
| 9 |
+
use codex_login::AuthManager;
|
| 10 |
+
use std::sync::Arc;
|
| 11 |
+
|
| 12 |
+
pub fn set_user_agent_suffix(suffix: &str) {
|
| 13 |
+
if let Ok(mut guard) = codex_login::default_client::USER_AGENT_SUFFIX.lock() {
|
| 14 |
+
guard.replace(suffix.to_string());
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
pub fn append_error_log(message: impl AsRef<str>) {
|
| 19 |
+
let ts = Utc::now().to_rfc3339();
|
| 20 |
+
if let Ok(mut f) = std::fs::OpenOptions::new()
|
| 21 |
+
.create(true)
|
| 22 |
+
.append(true)
|
| 23 |
+
.open("error.log")
|
| 24 |
+
{
|
| 25 |
+
use std::io::Write as _;
|
| 26 |
+
let _ = writeln!(f, "[{ts}] {}", message.as_ref());
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/// Normalize the configured base URL to a canonical form used by the backend client.
|
| 31 |
+
/// - trims trailing '/'
|
| 32 |
+
/// - appends '/backend-api' for ChatGPT hosts when missing
|
| 33 |
+
pub fn normalize_base_url(input: &str) -> String {
|
| 34 |
+
let mut base_url = input.to_string();
|
| 35 |
+
while base_url.ends_with('/') {
|
| 36 |
+
base_url.pop();
|
| 37 |
+
}
|
| 38 |
+
if (base_url.starts_with("https://chatgpt.com")
|
| 39 |
+
|| base_url.starts_with("https://chat.openai.com"))
|
| 40 |
+
&& !base_url.contains("/backend-api")
|
| 41 |
+
{
|
| 42 |
+
base_url = format!("{base_url}/backend-api");
|
| 43 |
+
}
|
| 44 |
+
base_url
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/// Validate the destination before loading saved ChatGPT credentials, including in mock mode:
|
| 48 |
+
/// environment discovery still makes authenticated HTTP requests when the task backend is mocked.
|
| 49 |
+
pub(crate) fn validate_chatgpt_base_url(input: &str) -> anyhow::Result<String> {
|
| 50 |
+
let invalid_url = || {
|
| 51 |
+
anyhow::anyhow!(
|
| 52 |
+
"CODEX_CLOUD_TASKS_BASE_URL must use a trusted HTTPS origin on port 443, without user information, a query, or a fragment; custom backends cannot use saved ChatGPT credentials"
|
| 53 |
+
)
|
| 54 |
+
};
|
| 55 |
+
let uri = input.parse::<http::Uri>().map_err(|_| invalid_url())?;
|
| 56 |
+
let authority = uri
|
| 57 |
+
.authority()
|
| 58 |
+
.ok_or_else(invalid_url)?
|
| 59 |
+
.as_str()
|
| 60 |
+
.to_ascii_lowercase();
|
| 61 |
+
if uri.scheme_str() != Some("https")
|
| 62 |
+
|| !matches!(
|
| 63 |
+
authority.as_str(),
|
| 64 |
+
"chatgpt.com"
|
| 65 |
+
| "chatgpt.com:443"
|
| 66 |
+
| "chat.openai.com"
|
| 67 |
+
| "chat.openai.com:443"
|
| 68 |
+
| "chatgpt-staging.com"
|
| 69 |
+
| "chatgpt-staging.com:443"
|
| 70 |
+
)
|
| 71 |
+
|| uri.query().is_some()
|
| 72 |
+
|| input.contains('#')
|
| 73 |
+
{
|
| 74 |
+
return Err(invalid_url());
|
| 75 |
+
}
|
| 76 |
+
Ok(normalize_base_url(&format!(
|
| 77 |
+
"https://{authority}{}",
|
| 78 |
+
uri.path()
|
| 79 |
+
)))
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
pub async fn load_auth_manager(
|
| 83 |
+
chatgpt_base_url: Option<String>,
|
| 84 |
+
) -> (Option<Arc<AuthManager>>, HttpClientFactory) {
|
| 85 |
+
// TODO: pass in cli overrides once cloud tasks properly support them.
|
| 86 |
+
let config = match Config::load_with_cli_overrides(Vec::new()).await {
|
| 87 |
+
Ok(config) => config,
|
| 88 |
+
Err(error) => {
|
| 89 |
+
append_error_log(format!(
|
| 90 |
+
"failed to load auth config; using transport-default proxy handling: {error}"
|
| 91 |
+
));
|
| 92 |
+
let http_client_factory = HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault);
|
| 93 |
+
return (None, http_client_factory);
|
| 94 |
+
}
|
| 95 |
+
};
|
| 96 |
+
let http_client_factory = config.http_client_factory();
|
| 97 |
+
let mut auth_config = config.auth_config();
|
| 98 |
+
auth_config.chatgpt_base_url = chatgpt_base_url.or(Some(config.chatgpt_base_url.clone()));
|
| 99 |
+
let auth_manager = match AuthManager::shared_from_auth_config(
|
| 100 |
+
auth_config,
|
| 101 |
+
/*enable_codex_api_key_env*/ false,
|
| 102 |
+
)
|
| 103 |
+
.await
|
| 104 |
+
{
|
| 105 |
+
Ok(auth_manager) => auth_manager,
|
| 106 |
+
Err(error) => {
|
| 107 |
+
append_error_log(format!("failed to load auth: {error}"));
|
| 108 |
+
return (None, http_client_factory);
|
| 109 |
+
}
|
| 110 |
+
};
|
| 111 |
+
(Some(auth_manager), http_client_factory)
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
/// Build headers for ChatGPT-backed requests: `User-Agent`, optional `Authorization`,
|
| 115 |
+
/// and optional `ChatGPT-Account-Id`.
|
| 116 |
+
pub async fn build_chatgpt_headers() -> HeaderMap {
|
| 117 |
+
use http::header::HeaderValue;
|
| 118 |
+
use http::header::USER_AGENT;
|
| 119 |
+
|
| 120 |
+
set_user_agent_suffix("codex_cloud_tasks_tui");
|
| 121 |
+
let ua = codex_login::default_client::get_codex_user_agent();
|
| 122 |
+
let mut headers = HeaderMap::new();
|
| 123 |
+
headers.insert(
|
| 124 |
+
USER_AGENT,
|
| 125 |
+
HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")),
|
| 126 |
+
);
|
| 127 |
+
if let Some(am) = load_auth_manager(/*chatgpt_base_url*/ None).await.0
|
| 128 |
+
&& let Some(auth) = am.auth().await
|
| 129 |
+
&& auth.uses_codex_backend()
|
| 130 |
+
{
|
| 131 |
+
headers.extend(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers());
|
| 132 |
+
}
|
| 133 |
+
headers
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/// Construct a browser-friendly task URL for the given backend base URL.
|
| 137 |
+
pub fn task_url(base_url: &str, task_id: &str) -> String {
|
| 138 |
+
let normalized = normalize_base_url(base_url);
|
| 139 |
+
if let Some(root) = normalized.strip_suffix("/backend-api") {
|
| 140 |
+
return format!("{root}/codex/tasks/{task_id}");
|
| 141 |
+
}
|
| 142 |
+
if let Some(root) = normalized.strip_suffix("/api/codex") {
|
| 143 |
+
return format!("{root}/codex/tasks/{task_id}");
|
| 144 |
+
}
|
| 145 |
+
if normalized.ends_with("/codex") {
|
| 146 |
+
return format!("{normalized}/tasks/{task_id}");
|
| 147 |
+
}
|
| 148 |
+
format!("{normalized}/codex/tasks/{task_id}")
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
pub fn format_relative_time(reference: DateTime<Utc>, ts: DateTime<Utc>) -> String {
|
| 152 |
+
let mut secs = (reference - ts).num_seconds();
|
| 153 |
+
if secs < 0 {
|
| 154 |
+
secs = 0;
|
| 155 |
+
}
|
| 156 |
+
if secs < 60 {
|
| 157 |
+
return format!("{secs}s ago");
|
| 158 |
+
}
|
| 159 |
+
let mins = secs / 60;
|
| 160 |
+
if mins < 60 {
|
| 161 |
+
return format!("{mins}m ago");
|
| 162 |
+
}
|
| 163 |
+
let hours = mins / 60;
|
| 164 |
+
if hours < 24 {
|
| 165 |
+
return format!("{hours}h ago");
|
| 166 |
+
}
|
| 167 |
+
let local = ts.with_timezone(&Local);
|
| 168 |
+
local.format("%b %e %H:%M").to_string()
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
pub fn format_relative_time_now(ts: DateTime<Utc>) -> String {
|
| 172 |
+
format_relative_time(Utc::now(), ts)
|
| 173 |
+
}
|