oh-my-codex 0.17.3 → 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.
- package/Cargo.lock +13 -5
- package/Cargo.toml +2 -1
- package/README.md +1 -0
- package/crates/omx-api/Cargo.toml +19 -0
- package/crates/omx-api/src/lib.rs +2940 -0
- package/crates/omx-api/src/main.rs +10 -0
- package/crates/omx-api/tests/cli.rs +558 -0
- package/crates/omx-explore/src/main.rs +4 -0
- package/crates/omx-sparkshell/src/codex_bridge.rs +437 -123
- package/crates/omx-sparkshell/src/exec.rs +4 -0
- package/crates/omx-sparkshell/src/main.rs +738 -29
- package/crates/omx-sparkshell/src/prompt.rs +25 -3
- package/crates/omx-sparkshell/src/redaction.rs +241 -0
- package/crates/omx-sparkshell/tests/execution.rs +479 -238
- package/dist/cli/__tests__/api.test.d.ts +2 -0
- package/dist/cli/__tests__/api.test.d.ts.map +1 -0
- package/dist/cli/__tests__/api.test.js +175 -0
- package/dist/cli/__tests__/api.test.js.map +1 -0
- package/dist/cli/__tests__/ask.test.js +72 -5
- package/dist/cli/__tests__/ask.test.js.map +1 -1
- package/dist/cli/__tests__/autoresearch-goal.test.js +14 -1
- package/dist/cli/__tests__/autoresearch-goal.test.js.map +1 -1
- package/dist/cli/__tests__/explore.test.js +23 -0
- package/dist/cli/__tests__/explore.test.js.map +1 -1
- package/dist/cli/__tests__/index.test.js +123 -5
- package/dist/cli/__tests__/index.test.js.map +1 -1
- package/dist/cli/__tests__/launch-fallback.test.js +76 -0
- package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
- package/dist/cli/__tests__/package-bin-contract.test.js +4 -3
- package/dist/cli/__tests__/package-bin-contract.test.js.map +1 -1
- package/dist/cli/__tests__/setup-install-mode.test.js +138 -0
- package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
- package/dist/cli/__tests__/sparkshell-cli.test.js +5 -0
- package/dist/cli/__tests__/sparkshell-cli.test.js.map +1 -1
- package/dist/cli/__tests__/version-sync-contract.test.js +4 -0
- package/dist/cli/__tests__/version-sync-contract.test.js.map +1 -1
- package/dist/cli/__tests__/windows-popup-loop-contract.test.js +1 -1
- package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
- package/dist/cli/api.d.ts +26 -0
- package/dist/cli/api.d.ts.map +1 -0
- package/dist/cli/api.js +153 -0
- package/dist/cli/api.js.map +1 -0
- package/dist/cli/explore.d.ts +2 -0
- package/dist/cli/explore.d.ts.map +1 -1
- package/dist/cli/explore.js +43 -1
- package/dist/cli/explore.js.map +1 -1
- package/dist/cli/index.d.ts +10 -4
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +128 -10
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/native-assets.d.ts +2 -1
- package/dist/cli/native-assets.d.ts.map +1 -1
- package/dist/cli/native-assets.js +1 -0
- package/dist/cli/native-assets.js.map +1 -1
- package/dist/cli/sparkshell.d.ts.map +1 -1
- package/dist/cli/sparkshell.js +20 -3
- package/dist/cli/sparkshell.js.map +1 -1
- package/dist/config/generator.d.ts.map +1 -1
- package/dist/config/generator.js +90 -0
- package/dist/config/generator.js.map +1 -1
- package/dist/hooks/__tests__/best-practice-research-skill.test.d.ts +2 -0
- package/dist/hooks/__tests__/best-practice-research-skill.test.d.ts.map +1 -0
- package/dist/hooks/__tests__/best-practice-research-skill.test.js +27 -0
- package/dist/hooks/__tests__/best-practice-research-skill.test.js.map +1 -0
- package/dist/hooks/__tests__/keyword-detector.test.js +11 -0
- package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
- package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +6 -0
- package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
- package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js +4 -0
- package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js.map +1 -1
- package/dist/hooks/keyword-registry.d.ts.map +1 -1
- package/dist/hooks/keyword-registry.js +1 -0
- package/dist/hooks/keyword-registry.js.map +1 -1
- package/dist/hud/__tests__/reconcile.test.js +2 -2
- package/dist/hud/__tests__/reconcile.test.js.map +1 -1
- package/dist/hud/__tests__/tmux.test.js +23 -18
- package/dist/hud/__tests__/tmux.test.js.map +1 -1
- package/dist/hud/tmux.d.ts.map +1 -1
- package/dist/hud/tmux.js +7 -6
- package/dist/hud/tmux.js.map +1 -1
- package/dist/mcp/__tests__/bootstrap.test.js +75 -1
- package/dist/mcp/__tests__/bootstrap.test.js.map +1 -1
- package/dist/mcp/bootstrap.d.ts +3 -1
- package/dist/mcp/bootstrap.d.ts.map +1 -1
- package/dist/mcp/bootstrap.js +71 -2
- package/dist/mcp/bootstrap.js.map +1 -1
- package/dist/scripts/__tests__/codex-native-hook.test.js +737 -26
- package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
- package/dist/scripts/__tests__/notify-dispatcher.test.js +183 -1
- package/dist/scripts/__tests__/notify-dispatcher.test.js.map +1 -1
- package/dist/scripts/__tests__/smoke-packed-install.test.js +4 -1
- package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
- package/dist/scripts/build-api.d.ts +2 -0
- package/dist/scripts/build-api.d.ts.map +1 -0
- package/dist/scripts/build-api.js +44 -0
- package/dist/scripts/build-api.js.map +1 -0
- package/dist/scripts/codex-native-hook.d.ts.map +1 -1
- package/dist/scripts/codex-native-hook.js +208 -8
- package/dist/scripts/codex-native-hook.js.map +1 -1
- package/dist/scripts/codex-native-pre-post.d.ts.map +1 -1
- package/dist/scripts/codex-native-pre-post.js +89 -24
- package/dist/scripts/codex-native-pre-post.js.map +1 -1
- package/dist/scripts/notify-dispatcher.js +88 -0
- package/dist/scripts/notify-dispatcher.js.map +1 -1
- package/dist/scripts/notify-hook/team-dispatch.d.ts.map +1 -1
- package/dist/scripts/notify-hook/team-dispatch.js +27 -9
- package/dist/scripts/notify-hook/team-dispatch.js.map +1 -1
- package/dist/scripts/notify-hook/team-leader-nudge.d.ts.map +1 -1
- package/dist/scripts/notify-hook/team-leader-nudge.js +26 -11
- package/dist/scripts/notify-hook/team-leader-nudge.js.map +1 -1
- package/dist/scripts/notify-hook/team-tmux-guard.d.ts +1 -0
- package/dist/scripts/notify-hook/team-tmux-guard.d.ts.map +1 -1
- package/dist/scripts/notify-hook/team-tmux-guard.js +38 -0
- package/dist/scripts/notify-hook/team-tmux-guard.js.map +1 -1
- package/dist/scripts/notify-hook/team-worker-stop.d.ts.map +1 -1
- package/dist/scripts/notify-hook/team-worker-stop.js +27 -14
- package/dist/scripts/notify-hook/team-worker-stop.js.map +1 -1
- package/dist/scripts/run-provider-advisor.js +9 -3
- package/dist/scripts/run-provider-advisor.js.map +1 -1
- package/dist/scripts/smoke-packed-install.d.ts +1 -1
- package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
- package/dist/scripts/smoke-packed-install.js +2 -0
- package/dist/scripts/smoke-packed-install.js.map +1 -1
- package/dist/team/__tests__/runtime.test.js +2 -2
- package/dist/team/__tests__/runtime.test.js.map +1 -1
- package/dist/team/__tests__/tmux-session.test.js +96 -19
- package/dist/team/__tests__/tmux-session.test.js.map +1 -1
- package/dist/team/tmux-session.d.ts +1 -0
- package/dist/team/tmux-session.d.ts.map +1 -1
- package/dist/team/tmux-session.js +34 -10
- package/dist/team/tmux-session.js.map +1 -1
- package/dist/verification/__tests__/ci-rust-gates.test.js +85 -10
- package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
- package/dist/verification/__tests__/explore-harness-release-workflow.test.js +1 -0
- package/dist/verification/__tests__/explore-harness-release-workflow.test.js.map +1 -1
- package/package.json +4 -3
- package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/oh-my-codex/skills/best-practice-research/SKILL.md +83 -0
- package/plugins/oh-my-codex/skills/deep-interview/SKILL.md +1 -0
- package/plugins/oh-my-codex/skills/ralplan/SKILL.md +1 -1
- package/prompts/researcher.md +15 -10
- package/skills/best-practice-research/SKILL.md +83 -0
- package/skills/deep-interview/SKILL.md +1 -0
- package/skills/ralplan/SKILL.md +1 -1
- package/src/scripts/__tests__/codex-native-hook.test.ts +810 -4
- package/src/scripts/__tests__/notify-dispatcher.test.ts +223 -1
- package/src/scripts/__tests__/smoke-packed-install.test.ts +8 -2
- package/src/scripts/build-api.ts +48 -0
- package/src/scripts/codex-native-hook.ts +262 -10
- package/src/scripts/codex-native-pre-post.ts +103 -24
- package/src/scripts/notify-dispatcher.ts +97 -0
- package/src/scripts/notify-hook/team-dispatch.ts +27 -8
- package/src/scripts/notify-hook/team-leader-nudge.ts +25 -11
- package/src/scripts/notify-hook/team-tmux-guard.ts +42 -0
- package/src/scripts/notify-hook/team-worker-stop.ts +24 -13
- package/src/scripts/run-provider-advisor.ts +11 -3
- package/src/scripts/smoke-packed-install.ts +2 -0
- 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::
|
|
7
|
-
use std::
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
.
|
|
132
|
-
.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
.
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
208
|
-
|
|
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,
|
|
293
|
-
resolve_instructions_file, resolve_model, DEFAULT_SPARK_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(
|