SaylorTwift HF Staff commited on
Commit
854994d
·
verified ·
1 Parent(s): dabfdaa

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. codex-rs/analytics/src/accepted_lines.rs +187 -0
  2. codex-rs/analytics/src/analytics_capture.rs +34 -0
  3. codex-rs/analytics/src/analytics_client_tests.rs +0 -0
  4. codex-rs/analytics/src/client.rs +961 -0
  5. codex-rs/analytics/src/client_tests.rs +1020 -0
  6. codex-rs/analytics/src/events.rs +1542 -0
  7. codex-rs/analytics/src/facts.rs +742 -0
  8. codex-rs/analytics/src/guardian_v2.rs +49 -0
  9. codex-rs/analytics/src/lib.rs +116 -0
  10. codex-rs/analytics/src/reducer.rs +0 -0
  11. codex-rs/analytics/src/thread_hint.rs +38 -0
  12. codex-rs/app-server-daemon/src/backend/mod.rs +52 -0
  13. codex-rs/app-server-daemon/src/backend/pid.rs +774 -0
  14. codex-rs/app-server-daemon/src/backend/pid_identity.rs +242 -0
  15. codex-rs/app-server-daemon/src/backend/pid_identity_tests.rs +89 -0
  16. codex-rs/app-server-daemon/src/backend/pid_start.rs +321 -0
  17. codex-rs/app-server-daemon/src/backend/pid_tests.rs +924 -0
  18. codex-rs/app-server-daemon/src/backend/pid_windows.rs +100 -0
  19. codex-rs/app-server-daemon/src/backend/windows.rs +286 -0
  20. codex-rs/app-server-daemon/src/backend/windows_tests.rs +87 -0
  21. codex-rs/app-server-daemon/src/client.rs +207 -0
  22. codex-rs/app-server-daemon/src/install_lock.rs +143 -0
  23. codex-rs/app-server-daemon/src/lib.rs +1400 -0
  24. codex-rs/app-server-daemon/src/managed_install.rs +221 -0
  25. codex-rs/app-server-daemon/src/managed_install_path_tests.rs +127 -0
  26. codex-rs/app-server-daemon/src/managed_install_tests.rs +27 -0
  27. codex-rs/app-server-daemon/src/manual_update.rs +281 -0
  28. codex-rs/app-server-daemon/src/migration.rs +193 -0
  29. codex-rs/app-server-daemon/src/prepare_install.rs +493 -0
  30. codex-rs/app-server-daemon/src/prepare_install_tests.rs +385 -0
  31. codex-rs/app-server-daemon/src/prepare_install_windows.rs +97 -0
  32. codex-rs/app-server-daemon/src/prepare_install_windows_tests.rs +19 -0
  33. codex-rs/app-server-daemon/src/remote_control_client.rs +770 -0
  34. codex-rs/app-server-daemon/src/settings.rs +195 -0
  35. codex-rs/app-server-daemon/src/settings_tests.rs +130 -0
  36. codex-rs/app-server-daemon/src/thread_recovery.rs +16 -0
  37. codex-rs/app-server-daemon/src/update_loop.rs +710 -0
  38. codex-rs/app-server-daemon/src/update_loop_tests.rs +891 -0
  39. codex-rs/build-info/src/build_info_tests.rs +197 -0
  40. codex-rs/build-info/src/lib.rs +155 -0
  41. codex-rs/cloud-tasks/src/app.rs +534 -0
  42. codex-rs/cloud-tasks/src/cli.rs +120 -0
  43. codex-rs/cloud-tasks/src/env_detect.rs +428 -0
  44. codex-rs/cloud-tasks/src/env_detect_tests.rs +359 -0
  45. codex-rs/cloud-tasks/src/lib.rs +0 -0
  46. codex-rs/cloud-tasks/src/new_task.rs +35 -0
  47. codex-rs/cloud-tasks/src/scrollable_diff.rs +208 -0
  48. codex-rs/cloud-tasks/src/scrollable_diff_tests.rs +48 -0
  49. codex-rs/cloud-tasks/src/ui.rs +1046 -0
  50. 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(&notification);
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 &notification {
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(&current_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(&current_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(&current).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, &current).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(&current).expect("remove current");
95
+ std::os::unix::fs::symlink(alpha, &current).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(&current).expect("remove current");
107
+ std::os::unix::fs::symlink(local, &current).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, &current_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(&current_managed_codex_path).await?);
252
+ let updated = previous_release != selected_release(daemon)?.1
253
+ || previous_identity != executable_identity(&current_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", &current).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, &current)?;
26
+ return Ok(());
27
+ }
28
+ validate_selection(root)?;
29
+ retarget_junction(&current, &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(&notification).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(&notification) 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
+ &current_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(&current_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
+ &current,
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
+ }