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.
- 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__/doctor-warning-copy.test.js +51 -0
- package/dist/cli/__tests__/doctor-warning-copy.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__/question.test.js +45 -22
- package/dist/cli/__tests__/question.test.js.map +1 -1
- package/dist/cli/__tests__/setup-agents-overwrite.test.js +2 -0
- package/dist/cli/__tests__/setup-agents-overwrite.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__/setup-scope.test.js +8 -2
- package/dist/cli/__tests__/setup-scope.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/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +39 -4
- package/dist/cli/doctor.js.map +1 -1
- 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/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +6 -1
- package/dist/cli/setup.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 +153 -25
- 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 +55 -10
- package/dist/team/tmux-session.js.map +1 -1
- package/dist/utils/__tests__/agents-md.test.js +45 -1
- package/dist/utils/__tests__/agents-md.test.js.map +1 -1
- package/dist/utils/agents-md.d.ts +2 -0
- package/dist/utils/agents-md.d.ts.map +1 -1
- package/dist/utils/agents-md.js +19 -0
- package/dist/utils/agents-md.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(
|