oh-my-codex 0.17.2 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/Cargo.lock +13 -5
  2. package/Cargo.toml +2 -1
  3. package/README.md +1 -0
  4. package/crates/omx-api/Cargo.toml +19 -0
  5. package/crates/omx-api/src/lib.rs +2940 -0
  6. package/crates/omx-api/src/main.rs +10 -0
  7. package/crates/omx-api/tests/cli.rs +558 -0
  8. package/crates/omx-explore/src/main.rs +4 -0
  9. package/crates/omx-sparkshell/src/codex_bridge.rs +437 -123
  10. package/crates/omx-sparkshell/src/exec.rs +4 -0
  11. package/crates/omx-sparkshell/src/main.rs +738 -29
  12. package/crates/omx-sparkshell/src/prompt.rs +25 -3
  13. package/crates/omx-sparkshell/src/redaction.rs +241 -0
  14. package/crates/omx-sparkshell/tests/execution.rs +479 -238
  15. package/dist/cli/__tests__/api.test.d.ts +2 -0
  16. package/dist/cli/__tests__/api.test.d.ts.map +1 -0
  17. package/dist/cli/__tests__/api.test.js +175 -0
  18. package/dist/cli/__tests__/api.test.js.map +1 -0
  19. package/dist/cli/__tests__/ask.test.js +72 -5
  20. package/dist/cli/__tests__/ask.test.js.map +1 -1
  21. package/dist/cli/__tests__/autoresearch-goal.test.js +14 -1
  22. package/dist/cli/__tests__/autoresearch-goal.test.js.map +1 -1
  23. package/dist/cli/__tests__/doctor-warning-copy.test.js +51 -0
  24. package/dist/cli/__tests__/doctor-warning-copy.test.js.map +1 -1
  25. package/dist/cli/__tests__/explore.test.js +23 -0
  26. package/dist/cli/__tests__/explore.test.js.map +1 -1
  27. package/dist/cli/__tests__/index.test.js +123 -5
  28. package/dist/cli/__tests__/index.test.js.map +1 -1
  29. package/dist/cli/__tests__/launch-fallback.test.js +76 -0
  30. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  31. package/dist/cli/__tests__/package-bin-contract.test.js +4 -3
  32. package/dist/cli/__tests__/package-bin-contract.test.js.map +1 -1
  33. package/dist/cli/__tests__/question.test.js +45 -22
  34. package/dist/cli/__tests__/question.test.js.map +1 -1
  35. package/dist/cli/__tests__/setup-agents-overwrite.test.js +2 -0
  36. package/dist/cli/__tests__/setup-agents-overwrite.test.js.map +1 -1
  37. package/dist/cli/__tests__/setup-install-mode.test.js +138 -0
  38. package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
  39. package/dist/cli/__tests__/setup-scope.test.js +8 -2
  40. package/dist/cli/__tests__/setup-scope.test.js.map +1 -1
  41. package/dist/cli/__tests__/sparkshell-cli.test.js +5 -0
  42. package/dist/cli/__tests__/sparkshell-cli.test.js.map +1 -1
  43. package/dist/cli/__tests__/version-sync-contract.test.js +4 -0
  44. package/dist/cli/__tests__/version-sync-contract.test.js.map +1 -1
  45. package/dist/cli/__tests__/windows-popup-loop-contract.test.js +1 -1
  46. package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
  47. package/dist/cli/api.d.ts +26 -0
  48. package/dist/cli/api.d.ts.map +1 -0
  49. package/dist/cli/api.js +153 -0
  50. package/dist/cli/api.js.map +1 -0
  51. package/dist/cli/doctor.d.ts.map +1 -1
  52. package/dist/cli/doctor.js +39 -4
  53. package/dist/cli/doctor.js.map +1 -1
  54. package/dist/cli/explore.d.ts +2 -0
  55. package/dist/cli/explore.d.ts.map +1 -1
  56. package/dist/cli/explore.js +43 -1
  57. package/dist/cli/explore.js.map +1 -1
  58. package/dist/cli/index.d.ts +10 -4
  59. package/dist/cli/index.d.ts.map +1 -1
  60. package/dist/cli/index.js +128 -10
  61. package/dist/cli/index.js.map +1 -1
  62. package/dist/cli/native-assets.d.ts +2 -1
  63. package/dist/cli/native-assets.d.ts.map +1 -1
  64. package/dist/cli/native-assets.js +1 -0
  65. package/dist/cli/native-assets.js.map +1 -1
  66. package/dist/cli/setup.d.ts.map +1 -1
  67. package/dist/cli/setup.js +6 -1
  68. package/dist/cli/setup.js.map +1 -1
  69. package/dist/cli/sparkshell.d.ts.map +1 -1
  70. package/dist/cli/sparkshell.js +20 -3
  71. package/dist/cli/sparkshell.js.map +1 -1
  72. package/dist/config/generator.d.ts.map +1 -1
  73. package/dist/config/generator.js +90 -0
  74. package/dist/config/generator.js.map +1 -1
  75. package/dist/hooks/__tests__/best-practice-research-skill.test.d.ts +2 -0
  76. package/dist/hooks/__tests__/best-practice-research-skill.test.d.ts.map +1 -0
  77. package/dist/hooks/__tests__/best-practice-research-skill.test.js +27 -0
  78. package/dist/hooks/__tests__/best-practice-research-skill.test.js.map +1 -0
  79. package/dist/hooks/__tests__/keyword-detector.test.js +11 -0
  80. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  81. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +6 -0
  82. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  83. package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js +4 -0
  84. package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js.map +1 -1
  85. package/dist/hooks/keyword-registry.d.ts.map +1 -1
  86. package/dist/hooks/keyword-registry.js +1 -0
  87. package/dist/hooks/keyword-registry.js.map +1 -1
  88. package/dist/hud/__tests__/reconcile.test.js +2 -2
  89. package/dist/hud/__tests__/reconcile.test.js.map +1 -1
  90. package/dist/hud/__tests__/tmux.test.js +23 -18
  91. package/dist/hud/__tests__/tmux.test.js.map +1 -1
  92. package/dist/hud/tmux.d.ts.map +1 -1
  93. package/dist/hud/tmux.js +7 -6
  94. package/dist/hud/tmux.js.map +1 -1
  95. package/dist/mcp/__tests__/bootstrap.test.js +75 -1
  96. package/dist/mcp/__tests__/bootstrap.test.js.map +1 -1
  97. package/dist/mcp/bootstrap.d.ts +3 -1
  98. package/dist/mcp/bootstrap.d.ts.map +1 -1
  99. package/dist/mcp/bootstrap.js +71 -2
  100. package/dist/mcp/bootstrap.js.map +1 -1
  101. package/dist/scripts/__tests__/codex-native-hook.test.js +737 -26
  102. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  103. package/dist/scripts/__tests__/notify-dispatcher.test.js +183 -1
  104. package/dist/scripts/__tests__/notify-dispatcher.test.js.map +1 -1
  105. package/dist/scripts/__tests__/smoke-packed-install.test.js +4 -1
  106. package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
  107. package/dist/scripts/build-api.d.ts +2 -0
  108. package/dist/scripts/build-api.d.ts.map +1 -0
  109. package/dist/scripts/build-api.js +44 -0
  110. package/dist/scripts/build-api.js.map +1 -0
  111. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  112. package/dist/scripts/codex-native-hook.js +208 -8
  113. package/dist/scripts/codex-native-hook.js.map +1 -1
  114. package/dist/scripts/codex-native-pre-post.d.ts.map +1 -1
  115. package/dist/scripts/codex-native-pre-post.js +89 -24
  116. package/dist/scripts/codex-native-pre-post.js.map +1 -1
  117. package/dist/scripts/notify-dispatcher.js +88 -0
  118. package/dist/scripts/notify-dispatcher.js.map +1 -1
  119. package/dist/scripts/notify-hook/team-dispatch.d.ts.map +1 -1
  120. package/dist/scripts/notify-hook/team-dispatch.js +27 -9
  121. package/dist/scripts/notify-hook/team-dispatch.js.map +1 -1
  122. package/dist/scripts/notify-hook/team-leader-nudge.d.ts.map +1 -1
  123. package/dist/scripts/notify-hook/team-leader-nudge.js +26 -11
  124. package/dist/scripts/notify-hook/team-leader-nudge.js.map +1 -1
  125. package/dist/scripts/notify-hook/team-tmux-guard.d.ts +1 -0
  126. package/dist/scripts/notify-hook/team-tmux-guard.d.ts.map +1 -1
  127. package/dist/scripts/notify-hook/team-tmux-guard.js +38 -0
  128. package/dist/scripts/notify-hook/team-tmux-guard.js.map +1 -1
  129. package/dist/scripts/notify-hook/team-worker-stop.d.ts.map +1 -1
  130. package/dist/scripts/notify-hook/team-worker-stop.js +27 -14
  131. package/dist/scripts/notify-hook/team-worker-stop.js.map +1 -1
  132. package/dist/scripts/run-provider-advisor.js +9 -3
  133. package/dist/scripts/run-provider-advisor.js.map +1 -1
  134. package/dist/scripts/smoke-packed-install.d.ts +1 -1
  135. package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
  136. package/dist/scripts/smoke-packed-install.js +2 -0
  137. package/dist/scripts/smoke-packed-install.js.map +1 -1
  138. package/dist/team/__tests__/runtime.test.js +2 -2
  139. package/dist/team/__tests__/runtime.test.js.map +1 -1
  140. package/dist/team/__tests__/tmux-session.test.js +153 -25
  141. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  142. package/dist/team/tmux-session.d.ts +1 -0
  143. package/dist/team/tmux-session.d.ts.map +1 -1
  144. package/dist/team/tmux-session.js +55 -10
  145. package/dist/team/tmux-session.js.map +1 -1
  146. package/dist/utils/__tests__/agents-md.test.js +45 -1
  147. package/dist/utils/__tests__/agents-md.test.js.map +1 -1
  148. package/dist/utils/agents-md.d.ts +2 -0
  149. package/dist/utils/agents-md.d.ts.map +1 -1
  150. package/dist/utils/agents-md.js +19 -0
  151. package/dist/utils/agents-md.js.map +1 -1
  152. package/dist/verification/__tests__/ci-rust-gates.test.js +85 -10
  153. package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
  154. package/dist/verification/__tests__/explore-harness-release-workflow.test.js +1 -0
  155. package/dist/verification/__tests__/explore-harness-release-workflow.test.js.map +1 -1
  156. package/package.json +4 -3
  157. package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
  158. package/plugins/oh-my-codex/skills/best-practice-research/SKILL.md +83 -0
  159. package/plugins/oh-my-codex/skills/deep-interview/SKILL.md +1 -0
  160. package/plugins/oh-my-codex/skills/ralplan/SKILL.md +1 -1
  161. package/prompts/researcher.md +15 -10
  162. package/skills/best-practice-research/SKILL.md +83 -0
  163. package/skills/deep-interview/SKILL.md +1 -0
  164. package/skills/ralplan/SKILL.md +1 -1
  165. package/src/scripts/__tests__/codex-native-hook.test.ts +810 -4
  166. package/src/scripts/__tests__/notify-dispatcher.test.ts +223 -1
  167. package/src/scripts/__tests__/smoke-packed-install.test.ts +8 -2
  168. package/src/scripts/build-api.ts +48 -0
  169. package/src/scripts/codex-native-hook.ts +262 -10
  170. package/src/scripts/codex-native-pre-post.ts +103 -24
  171. package/src/scripts/notify-dispatcher.ts +97 -0
  172. package/src/scripts/notify-hook/team-dispatch.ts +27 -8
  173. package/src/scripts/notify-hook/team-leader-nudge.ts +25 -11
  174. package/src/scripts/notify-hook/team-tmux-guard.ts +42 -0
  175. package/src/scripts/notify-hook/team-worker-stop.ts +24 -13
  176. package/src/scripts/run-provider-advisor.ts +11 -3
  177. package/src/scripts/smoke-packed-install.ts +2 -0
  178. package/templates/catalog-manifest.json +7 -0
@@ -2,12 +2,13 @@ use crate::error::SparkshellError;
2
2
  use crate::exec::CommandOutput;
3
3
  use crate::prompt::build_summary_prompt;
4
4
  use std::env;
5
+ use std::fs;
5
6
  use std::io::{Read, Write};
6
- use std::process::{Command, Stdio};
7
- use std::thread;
8
- use std::time::{Duration, Instant};
7
+ use std::net::{IpAddr, TcpStream, ToSocketAddrs};
8
+ use std::time::Duration;
9
9
 
10
10
  pub const DEFAULT_SUMMARY_TIMEOUT_MS: u64 = 60_000;
11
+ pub const DEFAULT_API_BASE_URL: &str = "http://127.0.0.1:14510";
11
12
  pub const DEFAULT_SPARK_MODEL: &str = "gpt-5.3-codex-spark";
12
13
  pub const DEFAULT_STANDARD_MODEL: &str = "gpt-5.4-mini";
13
14
 
@@ -63,43 +64,32 @@ pub fn summarize_output(
63
64
  let model = resolve_model();
64
65
  let fallback_model = resolve_fallback_model();
65
66
  let timeout_ms = read_summary_timeout_ms();
66
- let (stdout, stderr, status_ok) = run_codex_exec(&prompt, &model, timeout_ms)?;
67
- if !status_ok {
68
- let should_retry = fallback_model != model && should_retry_with_fallback(&stderr);
69
- if should_retry {
70
- let (fallback_stdout, fallback_stderr, fallback_ok) =
71
- run_codex_exec(&prompt, &fallback_model, timeout_ms)?;
72
- if !fallback_ok {
73
- let primary_message = if stderr.trim().is_empty() {
74
- "codex exec exited unsuccessfully".to_string()
75
- } else {
76
- stderr.trim().to_string()
77
- };
78
- let fallback_message = if fallback_stderr.trim().is_empty() {
79
- "codex exec exited unsuccessfully".to_string()
80
- } else {
81
- fallback_stderr.trim().to_string()
82
- };
83
- return Err(SparkshellError::SummaryBridge(format!(
84
- "codex exec failed for primary model `{model}` ({primary_message}) and fallback model `{fallback_model}` ({fallback_message})"
85
- )));
67
+ match request_summary(&prompt, &model, timeout_ms) {
68
+ Ok(stdout) => normalize_summary(&stdout).ok_or_else(|| {
69
+ SparkshellError::SummaryBridge(
70
+ "local API returned no valid summary sections".to_string(),
71
+ )
72
+ }),
73
+ Err(primary_error) => {
74
+ let primary_message = primary_error.to_string();
75
+ if fallback_model != model && should_retry_with_fallback(&primary_message) {
76
+ match request_summary(&prompt, &fallback_model, timeout_ms) {
77
+ Ok(fallback_stdout) => normalize_summary(&fallback_stdout).ok_or_else(|| {
78
+ SparkshellError::SummaryBridge(
79
+ "local API fallback returned no valid summary sections".to_string(),
80
+ )
81
+ }),
82
+ Err(fallback_error) => Err(SparkshellError::SummaryBridge(format!(
83
+ "local API failed for primary model `{model}` ({primary_message}) and fallback model `{fallback_model}` ({fallback_error})"
84
+ ))),
85
+ }
86
+ } else {
87
+ Err(SparkshellError::SummaryBridge(format!(
88
+ "local API summary request failed: {primary_message}"
89
+ )))
86
90
  }
87
- return normalize_summary(&fallback_stdout).ok_or_else(|| {
88
- SparkshellError::SummaryBridge(
89
- "codex exec fallback returned no valid summary sections".to_string(),
90
- )
91
- });
92
- }
93
- let message = if stderr.trim().is_empty() {
94
- "codex exec exited unsuccessfully".to_string()
95
- } else {
96
- format!("codex exec exited unsuccessfully: {}", stderr.trim())
97
- };
98
- return Err(SparkshellError::SummaryBridge(message));
91
+ }
99
92
  }
100
- normalize_summary(&stdout).ok_or_else(|| {
101
- SparkshellError::SummaryBridge("codex exec returned no valid summary sections".to_string())
102
- })
103
93
  }
104
94
 
105
95
  fn should_retry_with_fallback(stderr: &str) -> bool {
@@ -119,93 +109,371 @@ fn should_retry_with_fallback(stderr: &str) -> bool {
119
109
  .any(|needle| normalized.contains(needle))
120
110
  }
121
111
 
122
- fn run_codex_exec(
123
- prompt: &str,
124
- model: &str,
112
+ fn request_summary(prompt: &str, model: &str, timeout_ms: u64) -> Result<String, SparkshellError> {
113
+ let api_base_url = resolve_api_base_url();
114
+ let endpoint = join_api_path(&api_base_url, "/v1/responses");
115
+ let request = build_responses_request(prompt, model)?;
116
+ let response_body = post_json(&endpoint, &request, timeout_ms, resolve_api_bearer())?;
117
+ extract_output_text(&response_body).ok_or_else(|| {
118
+ SparkshellError::SummaryBridge("local API response did not include output_text".to_string())
119
+ })
120
+ }
121
+
122
+ fn resolve_api_base_url() -> String {
123
+ env::var("OMX_API_BASE_URL")
124
+ .ok()
125
+ .map(|value| value.trim().trim_end_matches('/').to_string())
126
+ .filter(|value| !value.is_empty())
127
+ .or_else(|| {
128
+ env::var("OMX_API_PORT")
129
+ .ok()
130
+ .map(|value| value.trim().to_string())
131
+ .filter(|value| !value.is_empty())
132
+ .map(|port| format!("http://127.0.0.1:{port}"))
133
+ })
134
+ .unwrap_or_else(|| DEFAULT_API_BASE_URL.to_string())
135
+ }
136
+
137
+ fn resolve_api_bearer() -> Option<String> {
138
+ env::var("OMX_API_LOCAL_BEARER")
139
+ .ok()
140
+ .map(|value| value.trim().to_string())
141
+ .filter(|value| !value.is_empty())
142
+ .or_else(|| {
143
+ let path = env::var("OMX_API_STATE_FILE")
144
+ .ok()
145
+ .filter(|value| !value.trim().is_empty())
146
+ .unwrap_or_else(|| {
147
+ env::temp_dir()
148
+ .join("omx-api-daemon.json")
149
+ .display()
150
+ .to_string()
151
+ });
152
+ fs::read_to_string(path)
153
+ .ok()
154
+ .and_then(|state| extract_json_string_field(&state, "local_bearer_token_file"))
155
+ .and_then(|token_file| fs::read_to_string(token_file).ok())
156
+ .map(|token| token.trim().to_string())
157
+ .filter(|token| !token.is_empty())
158
+ })
159
+ }
160
+
161
+ fn build_responses_request(prompt: &str, model: &str) -> Result<String, SparkshellError> {
162
+ let mut fields = vec![
163
+ format!("\"model\":{}", json_string(model)),
164
+ format!("\"input\":{}", json_string(prompt)),
165
+ "\"reasoning\":{\"effort\":\"low\"}".to_string(),
166
+ "\"stream\":false".to_string(),
167
+ ];
168
+
169
+ if let Some(path) = resolve_instructions_file() {
170
+ let instructions = fs::read_to_string(&path).map_err(|error| {
171
+ SparkshellError::SummaryBridge(format!(
172
+ "failed to read summary instructions file `{path}`: {error}"
173
+ ))
174
+ })?;
175
+ fields.push(format!("\"instructions\":{}", json_string(&instructions)));
176
+ }
177
+
178
+ Ok(format!("{{{}}}", fields.join(",")))
179
+ }
180
+
181
+ fn join_api_path(base_url: &str, path: &str) -> String {
182
+ format!(
183
+ "{}{}{}",
184
+ base_url.trim_end_matches('/'),
185
+ if path.starts_with('/') { "" } else { "/" },
186
+ path
187
+ )
188
+ }
189
+
190
+ fn post_json(
191
+ url: &str,
192
+ body: &str,
125
193
  timeout_ms: u64,
126
- ) -> Result<(String, String, bool), SparkshellError> {
127
- let mut child = Command::new("codex")
128
- .arg("exec")
129
- .arg("--model")
130
- .arg(model)
131
- .arg("--sandbox")
132
- .arg("read-only")
133
- .arg("-c")
134
- .arg("model_reasoning_effort=\"low\"")
135
- .args(resolve_instructions_file().into_iter().flat_map(|path| {
136
- [
137
- "-c".to_string(),
138
- format!("model_instructions_file=\"{}\"", escape_toml_string(&path)),
139
- ]
140
- }))
141
- .arg("--skip-git-repo-check")
142
- .arg("--color")
143
- .arg("never")
144
- .arg("-")
145
- .stdin(Stdio::piped())
146
- .stdout(Stdio::piped())
147
- .stderr(Stdio::piped())
148
- .spawn()?;
149
-
150
- let mut stdin = child
151
- .stdin
152
- .take()
153
- .ok_or_else(|| SparkshellError::SummaryBridge("failed to open codex stdin".to_string()))?;
154
- let mut stdout = child
155
- .stdout
156
- .take()
157
- .ok_or_else(|| SparkshellError::SummaryBridge("failed to open codex stdout".to_string()))?;
158
- let mut stderr = child
159
- .stderr
160
- .take()
161
- .ok_or_else(|| SparkshellError::SummaryBridge("failed to open codex stderr".to_string()))?;
162
-
163
- let prompt_owned = prompt.to_string();
164
- let stdin_writer = thread::spawn(move || stdin.write_all(prompt_owned.as_bytes()));
165
- let stdout_reader = thread::spawn(move || {
166
- let mut buffer = Vec::new();
167
- let _ = stdout.read_to_end(&mut buffer);
168
- buffer
169
- });
170
- let stderr_reader = thread::spawn(move || {
171
- let mut buffer = Vec::new();
172
- let _ = stderr.read_to_end(&mut buffer);
173
- buffer
174
- });
175
-
176
- let deadline = Instant::now() + Duration::from_millis(timeout_ms);
177
- let status = loop {
178
- if let Some(status) = child.try_wait()? {
179
- break status;
180
- }
181
- if Instant::now() >= deadline {
182
- let _ = child.kill();
183
- let _ = child.wait();
184
- let _ = stdin_writer.join();
185
- let _ = stdout_reader.join();
186
- let _ = stderr_reader.join();
187
- return Err(SparkshellError::SummaryTimeout(timeout_ms));
188
- }
189
- thread::sleep(Duration::from_millis(25));
194
+ bearer: Option<String>,
195
+ ) -> Result<String, SparkshellError> {
196
+ let parsed = parse_http_url(url)?;
197
+ let timeout = Duration::from_millis(timeout_ms);
198
+ let mut addrs = (parsed.host.as_str(), parsed.port)
199
+ .to_socket_addrs()
200
+ .map_err(|error| {
201
+ SparkshellError::SummaryBridge(format!(
202
+ "failed to resolve local API host `{}`: {error}",
203
+ parsed.host
204
+ ))
205
+ })?;
206
+ let addr = addrs.next().ok_or_else(|| {
207
+ SparkshellError::SummaryBridge(format!(
208
+ "failed to resolve local API host `{}`",
209
+ parsed.host
210
+ ))
211
+ })?;
212
+ let mut stream = TcpStream::connect_timeout(&addr, timeout)
213
+ .map_err(|error| map_api_io_error(error, timeout_ms, "local API connection failed"))?;
214
+ stream.set_read_timeout(Some(timeout)).map_err(|error| {
215
+ map_api_io_error(error, timeout_ms, "local API read timeout setup failed")
216
+ })?;
217
+ stream.set_write_timeout(Some(timeout)).map_err(|error| {
218
+ map_api_io_error(error, timeout_ms, "local API write timeout setup failed")
219
+ })?;
220
+
221
+ let host_header = if parsed.explicit_port {
222
+ format!("{}:{}", parsed.host, parsed.port)
223
+ } else {
224
+ parsed.host.clone()
190
225
  };
226
+ let auth_header = bearer
227
+ .as_deref()
228
+ .map(|token| format!("Authorization: Bearer {token}\r\n"))
229
+ .unwrap_or_default();
230
+ let request = format!(
231
+ "POST {} HTTP/1.1\r\nHost: {}\r\nContent-Type: application/json\r\nAccept: application/json\r\n{}Connection: close\r\nContent-Length: {}\r\n\r\n{}",
232
+ parsed.path, host_header, auth_header, body.len(), body
233
+ );
234
+ stream
235
+ .write_all(request.as_bytes())
236
+ .map_err(|error| map_api_io_error(error, timeout_ms, "local API write failed"))?;
237
+ stream
238
+ .flush()
239
+ .map_err(|error| map_api_io_error(error, timeout_ms, "local API flush failed"))?;
240
+
241
+ let mut raw = Vec::new();
242
+ stream
243
+ .read_to_end(&mut raw)
244
+ .map_err(|error| map_api_io_error(error, timeout_ms, "local API read failed"))?;
245
+ let response = String::from_utf8_lossy(&raw);
246
+ let (head, response_body) = response.split_once("\r\n\r\n").ok_or_else(|| {
247
+ SparkshellError::SummaryBridge("local API returned malformed HTTP response".to_string())
248
+ })?;
249
+ let status_line = head.lines().next().unwrap_or_default();
250
+ let status_code = status_line
251
+ .split_whitespace()
252
+ .nth(1)
253
+ .and_then(|value| value.parse::<u16>().ok())
254
+ .ok_or_else(|| {
255
+ SparkshellError::SummaryBridge("local API returned malformed HTTP status".to_string())
256
+ })?;
257
+ if !(200..300).contains(&status_code) {
258
+ return Err(SparkshellError::SummaryBridge(format!(
259
+ "local API returned HTTP {status_code}: {}",
260
+ response_body.trim()
261
+ )));
262
+ }
263
+ Ok(response_body.to_string())
264
+ }
265
+
266
+ fn map_api_io_error(error: std::io::Error, timeout_ms: u64, context: &str) -> SparkshellError {
267
+ if matches!(
268
+ error.kind(),
269
+ std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock
270
+ ) {
271
+ SparkshellError::SummaryTimeout(timeout_ms)
272
+ } else {
273
+ SparkshellError::SummaryBridge(format!("{context}: {error}"))
274
+ }
275
+ }
276
+
277
+ #[derive(Debug)]
278
+ struct HttpUrl {
279
+ host: String,
280
+ port: u16,
281
+ path: String,
282
+ explicit_port: bool,
283
+ }
284
+
285
+ fn parse_http_url(url: &str) -> Result<HttpUrl, SparkshellError> {
286
+ let rest = url.strip_prefix("http://").ok_or_else(|| {
287
+ SparkshellError::SummaryBridge(format!("local API URL must use http://, got `{url}`"))
288
+ })?;
289
+ let (authority, path) = match rest.split_once('/') {
290
+ Some((authority, path)) => (authority, format!("/{path}")),
291
+ None => (rest, "/".to_string()),
292
+ };
293
+ let (host, port, explicit_port) = if let Some((host, port)) = authority.rsplit_once(':') {
294
+ let port = port.parse::<u16>().map_err(|_| {
295
+ SparkshellError::SummaryBridge(format!("local API URL has invalid port in `{url}`"))
296
+ })?;
297
+ (host.to_string(), port, true)
298
+ } else {
299
+ (authority.to_string(), 80, false)
300
+ };
301
+ if host.is_empty() {
302
+ return Err(SparkshellError::SummaryBridge(format!(
303
+ "local API URL has empty host in `{url}`"
304
+ )));
305
+ }
306
+ if !is_loopback_host(&host) && std::env::var_os("OMX_API_ALLOW_UNSAFE_BASE_URL").is_none() {
307
+ return Err(SparkshellError::SummaryBridge(format!(
308
+ "local API URL host `{host}` is not loopback; set OMX_API_ALLOW_UNSAFE_BASE_URL=1 only for trusted development"
309
+ )));
310
+ }
311
+ Ok(HttpUrl {
312
+ host,
313
+ port,
314
+ path,
315
+ explicit_port,
316
+ })
317
+ }
318
+
319
+ fn is_loopback_host(host: &str) -> bool {
320
+ if host == "localhost" {
321
+ return true;
322
+ }
323
+ let trimmed = host.trim_matches(['[', ']']);
324
+ trimmed
325
+ .parse::<IpAddr>()
326
+ .map(|addr| addr.is_loopback())
327
+ .unwrap_or(false)
328
+ }
329
+
330
+ fn json_string(value: &str) -> String {
331
+ let mut rendered = String::from("\"");
332
+ for ch in value.chars() {
333
+ match ch {
334
+ '\\' => rendered.push_str("\\\\"),
335
+ '"' => rendered.push_str("\\\""),
336
+ '\n' => rendered.push_str("\\n"),
337
+ '\r' => rendered.push_str("\\r"),
338
+ '\t' => rendered.push_str("\\t"),
339
+ ch if ch.is_control() => rendered.push_str(&format!("\\u{:04x}", ch as u32)),
340
+ ch => rendered.push(ch),
341
+ }
342
+ }
343
+ rendered.push('"');
344
+ rendered
345
+ }
346
+
347
+ fn extract_output_text(body: &str) -> Option<String> {
348
+ extract_json_string_field(body, "output_text").or_else(|| extract_response_output_text(body))
349
+ }
350
+
351
+ fn extract_response_output_text(body: &str) -> Option<String> {
352
+ let bytes = body.as_bytes();
353
+ let type_pattern = "\"type\"";
354
+ let mut search_start = 0;
355
+ let mut parts = Vec::new();
356
+
357
+ while let Some(relative_index) = body[search_start..].find(type_pattern) {
358
+ let type_index = search_start + relative_index;
359
+ let Some((value, value_end)) = extract_json_string_field_at(body, type_index, "type")
360
+ else {
361
+ search_start = type_index + type_pattern.len();
362
+ continue;
363
+ };
364
+ if value != "output_text" {
365
+ search_start = value_end;
366
+ continue;
367
+ }
191
368
 
192
- let _ = stdin_writer.join();
193
- let stdout_bytes = stdout_reader
194
- .join()
195
- .map_err(|_| SparkshellError::SummaryBridge("failed reading codex stdout".to_string()))?;
196
- let stderr_bytes = stderr_reader
197
- .join()
198
- .map_err(|_| SparkshellError::SummaryBridge("failed reading codex stderr".to_string()))?;
199
-
200
- Ok((
201
- String::from_utf8_lossy(&stdout_bytes).into_owned(),
202
- String::from_utf8_lossy(&stderr_bytes).into_owned(),
203
- status.success(),
204
- ))
369
+ let next_type = body[value_end..]
370
+ .find(type_pattern)
371
+ .map(|index| value_end + index)
372
+ .unwrap_or(bytes.len());
373
+ if let Some((text, text_end)) =
374
+ extract_json_string_field_before(body, value_end, next_type, "text")
375
+ {
376
+ parts.push(text);
377
+ search_start = text_end;
378
+ } else {
379
+ search_start = value_end;
380
+ }
381
+ }
382
+
383
+ if parts.is_empty() {
384
+ None
385
+ } else {
386
+ Some(parts.join(""))
387
+ }
388
+ }
389
+
390
+ fn extract_json_string_field(body: &str, field: &str) -> Option<String> {
391
+ extract_json_string_field_at(body, 0, field).map(|(value, _)| value)
392
+ }
393
+
394
+ fn extract_json_string_field_before(
395
+ body: &str,
396
+ start: usize,
397
+ end: usize,
398
+ field: &str,
399
+ ) -> Option<(String, usize)> {
400
+ extract_json_string_field_in_range(body, start, end.min(body.len()), field)
401
+ }
402
+
403
+ fn extract_json_string_field_at(body: &str, start: usize, field: &str) -> Option<(String, usize)> {
404
+ extract_json_string_field_in_range(body, start, body.len(), field)
405
+ }
406
+
407
+ fn extract_json_string_field_in_range(
408
+ body: &str,
409
+ start: usize,
410
+ end: usize,
411
+ field: &str,
412
+ ) -> Option<(String, usize)> {
413
+ let bytes = body.as_bytes();
414
+ let field_pattern = format!("\"{field}\"");
415
+ let mut search_start = start.min(body.len());
416
+ let search_end = end.min(body.len());
417
+ while search_start < search_end {
418
+ let Some(relative_index) = body[search_start..search_end].find(&field_pattern) else {
419
+ break;
420
+ };
421
+ let mut index = search_start + relative_index + field_pattern.len();
422
+ while matches!(bytes.get(index), Some(b' ' | b'\n' | b'\r' | b'\t')) {
423
+ index += 1;
424
+ }
425
+ if index >= search_end {
426
+ break;
427
+ }
428
+ if bytes.get(index) != Some(&b':') {
429
+ search_start = index;
430
+ continue;
431
+ }
432
+ index += 1;
433
+ while matches!(bytes.get(index), Some(b' ' | b'\n' | b'\r' | b'\t')) {
434
+ index += 1;
435
+ }
436
+ if index >= search_end {
437
+ break;
438
+ }
439
+ if bytes.get(index) != Some(&b'"') {
440
+ search_start = index;
441
+ continue;
442
+ }
443
+ return parse_json_string_at(body, index);
444
+ }
445
+ None
205
446
  }
206
447
 
207
- fn escape_toml_string(value: &str) -> String {
208
- value.replace('\\', "\\\\").replace('"', "\\\"")
448
+ fn parse_json_string_at(body: &str, quote_index: usize) -> Option<(String, usize)> {
449
+ let mut chars = body[quote_index + 1..].char_indices();
450
+ let mut rendered = String::new();
451
+ while let Some((offset, ch)) = chars.next() {
452
+ match ch {
453
+ '"' => return Some((rendered, quote_index + 1 + offset + ch.len_utf8())),
454
+ '\\' => match chars.next()?.1 {
455
+ '"' => rendered.push('"'),
456
+ '\\' => rendered.push('\\'),
457
+ '/' => rendered.push('/'),
458
+ 'b' => rendered.push('\u{0008}'),
459
+ 'f' => rendered.push('\u{000c}'),
460
+ 'n' => rendered.push('\n'),
461
+ 'r' => rendered.push('\r'),
462
+ 't' => rendered.push('\t'),
463
+ 'u' => {
464
+ let mut hex = String::new();
465
+ for _ in 0..4 {
466
+ hex.push(chars.next()?.1);
467
+ }
468
+ let value = u16::from_str_radix(&hex, 16).ok()?;
469
+ rendered.push(char::from_u32(value as u32)?);
470
+ }
471
+ _ => return None,
472
+ },
473
+ ch => rendered.push(ch),
474
+ }
475
+ }
476
+ None
209
477
  }
210
478
 
211
479
  fn normalize_summary(raw: &str) -> Option<String> {
@@ -289,9 +557,9 @@ fn render_section(name: &str, entries: &[String]) -> String {
289
557
  #[allow(unused_unsafe)]
290
558
  mod tests {
291
559
  use super::{
292
- normalize_summary, read_summary_timeout_ms, resolve_fallback_model,
293
- resolve_instructions_file, resolve_model, DEFAULT_SPARK_MODEL, DEFAULT_STANDARD_MODEL,
294
- DEFAULT_SUMMARY_TIMEOUT_MS,
560
+ extract_output_text, normalize_summary, parse_http_url, read_summary_timeout_ms,
561
+ resolve_fallback_model, resolve_instructions_file, resolve_model, DEFAULT_SPARK_MODEL,
562
+ DEFAULT_STANDARD_MODEL, DEFAULT_SUMMARY_TIMEOUT_MS,
295
563
  };
296
564
  use crate::test_support::env_lock;
297
565
  use std::env;
@@ -413,6 +681,33 @@ mod tests {
413
681
  }
414
682
  }
415
683
 
684
+ #[test]
685
+ fn output_text_extraction_prefers_compat_top_level_field() {
686
+ let body = r#"{"output_text":"summary: legacy\nwarnings: none","output":[{"type":"message","content":[{"type":"output_text","text":"summary: nested"}]}]}"#;
687
+ assert_eq!(
688
+ extract_output_text(body),
689
+ Some("summary: legacy\nwarnings: none".to_string())
690
+ );
691
+ }
692
+
693
+ #[test]
694
+ fn output_text_extraction_supports_responses_output_content_shape() {
695
+ let body = r#"{
696
+ "id":"resp_123",
697
+ "output":[
698
+ {"type":"reasoning","summary":[]},
699
+ {"type":"message","content":[
700
+ {"type":"output_text","annotations":[],"text":"summary: ok\n"},
701
+ {"type":"output_text","text":"warnings: none"}
702
+ ]}
703
+ ]
704
+ }"#;
705
+ assert_eq!(
706
+ extract_output_text(body),
707
+ Some("summary: ok\nwarnings: none".to_string())
708
+ );
709
+ }
710
+
416
711
  #[test]
417
712
  fn normalizes_allowed_sections_only() {
418
713
  let summary = normalize_summary(
@@ -451,4 +746,23 @@ notes: nope"
451
746
  )
452
747
  .is_none());
453
748
  }
749
+
750
+ #[test]
751
+ fn api_base_url_parser_rejects_non_loopback_hosts_by_default() {
752
+ let _guard = env_lock();
753
+ unsafe {
754
+ env::remove_var("OMX_API_ALLOW_UNSAFE_BASE_URL");
755
+ }
756
+
757
+ assert!(parse_http_url("http://127.0.0.1:14510/v1/responses").is_ok());
758
+ assert!(parse_http_url("http://localhost:14510/v1/responses").is_ok());
759
+ assert!(parse_http_url("http://example.com:14510/v1/responses")
760
+ .expect_err("non-loopback host should be rejected")
761
+ .to_string()
762
+ .contains("not loopback"));
763
+ assert!(parse_http_url("http://127.0.0.1.evil:14510/v1/responses")
764
+ .expect_err("prefix spoof host should be rejected")
765
+ .to_string()
766
+ .contains("not loopback"));
767
+ }
454
768
  }
@@ -26,6 +26,10 @@ impl CommandOutput {
26
26
  }
27
27
  }
28
28
 
29
+ pub fn execute_shell_command(script: &str) -> Result<CommandOutput, SparkshellError> {
30
+ execute_command(&["bash".to_string(), "-lc".to_string(), script.to_string()])
31
+ }
32
+
29
33
  pub fn execute_command(argv: &[String]) -> Result<CommandOutput, SparkshellError> {
30
34
  if argv.is_empty() {
31
35
  return Err(SparkshellError::InvalidArgs(