@team-agent/installer 0.5.108 → 0.5.110
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 +1 -1
- package/Cargo.toml +1 -1
- package/README.md +33 -0
- package/README.zh.md +30 -0
- package/crates/team-agent/src/cli/emit.rs +8 -1
- package/crates/team-agent/src/cli/mod.rs +2 -1
- package/crates/team-agent/src/cli/route.rs +207 -0
- package/crates/team-agent/src/cli/spec.rs +3 -0
- package/crates/team-agent/src/cli/status_port/brief.rs +224 -33
- package/crates/team-agent/src/leader/start.rs +5 -1
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +2 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +2 -0
- package/crates/team-agent/src/lifecycle/tests/cli_argv_routing_red.rs +1457 -0
- package/crates/team-agent/src/lifecycle/tests.rs +1 -0
- package/crates/team-agent/src/provider/argv_route.rs +409 -0
- package/crates/team-agent/src/provider/mod.rs +3 -2
- package/crates/team-agent/src/provider/model_catalog.rs +177 -2
- package/crates/team-agent/src/tmux_backend.rs +99 -2
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +32 -0
|
@@ -52,6 +52,7 @@ mod b0_reader_hideone_audit_contract;
|
|
|
52
52
|
mod behavioral_diff_264_red;
|
|
53
53
|
mod claude_compatible_config_red;
|
|
54
54
|
mod claude_profile_launch_red;
|
|
55
|
+
mod cli_argv_routing_red;
|
|
55
56
|
mod clone_agent_preserves_source_tools;
|
|
56
57
|
mod clone_fork_copilot_perms_red;
|
|
57
58
|
mod codex_weak_window_attribution_red;
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
//! Global, opt-in native CLI argv routing. No workspace state or child processes.
|
|
2
|
+
|
|
3
|
+
use super::{wire, Provider};
|
|
4
|
+
use serde::de::{self, MapAccess, Visitor};
|
|
5
|
+
use serde::{Deserialize, Deserializer, Serialize};
|
|
6
|
+
use std::collections::BTreeMap;
|
|
7
|
+
use std::ffi::OsStr;
|
|
8
|
+
use std::fmt;
|
|
9
|
+
use std::fs::{self, File, OpenOptions};
|
|
10
|
+
use std::io::{self, Write};
|
|
11
|
+
use std::path::{Path, PathBuf};
|
|
12
|
+
use std::time::{Duration, Instant};
|
|
13
|
+
|
|
14
|
+
pub const ENV_NAME: &str = "TEAM_AGENT_CLI_ARGV_ROUTING";
|
|
15
|
+
|
|
16
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
17
|
+
#[serde(deny_unknown_fields)]
|
|
18
|
+
pub struct Config {
|
|
19
|
+
pub schema_version: u32,
|
|
20
|
+
pub enabled: bool,
|
|
21
|
+
#[serde(deserialize_with = "deserialize_providers")]
|
|
22
|
+
pub providers: BTreeMap<String, Vec<String>>,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
impl Default for Config {
|
|
26
|
+
fn default() -> Self {
|
|
27
|
+
Self {
|
|
28
|
+
schema_version: 1,
|
|
29
|
+
enabled: false,
|
|
30
|
+
providers: BTreeMap::new(),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// serde's struct decoder rejects duplicate top-level fields. A map needs its
|
|
36
|
+
// own visitor to reject duplicate provider keys rather than silently last-win.
|
|
37
|
+
fn deserialize_providers<'de, D>(decoder: D) -> Result<BTreeMap<String, Vec<String>>, D::Error>
|
|
38
|
+
where
|
|
39
|
+
D: Deserializer<'de>,
|
|
40
|
+
{
|
|
41
|
+
struct ProvidersVisitor;
|
|
42
|
+
impl<'de> Visitor<'de> for ProvidersVisitor {
|
|
43
|
+
type Value = BTreeMap<String, Vec<String>>;
|
|
44
|
+
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
|
45
|
+
formatter.write_str("unique canonical provider keys with string arrays")
|
|
46
|
+
}
|
|
47
|
+
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Self::Value, M::Error> {
|
|
48
|
+
let mut result = BTreeMap::new();
|
|
49
|
+
while let Some((key, value)) = map.next_entry::<String, Vec<String>>()? {
|
|
50
|
+
if result.insert(key, value).is_some() {
|
|
51
|
+
return Err(de::Error::custom("duplicate provider key"));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
Ok(result)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
decoder.deserialize_map(ProvidersVisitor)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
pub fn route_key(provider: Provider) -> Option<&'static str> {
|
|
61
|
+
match provider {
|
|
62
|
+
Provider::Claude | Provider::ClaudeCode => Some("claude"),
|
|
63
|
+
Provider::Fake => None,
|
|
64
|
+
other => Some(wire::provider_wire(other)),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
pub fn parse_route_provider(raw: &str) -> Option<Provider> {
|
|
69
|
+
let provider = if raw == "cursor" {
|
|
70
|
+
Provider::CursorAgent
|
|
71
|
+
} else {
|
|
72
|
+
wire::parse_provider(raw)?
|
|
73
|
+
};
|
|
74
|
+
route_key(provider).map(|_| provider)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
pub fn valid_tokens(tokens: &[String]) -> bool {
|
|
78
|
+
tokens.iter().all(|token| !token.contains('\0'))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
impl Config {
|
|
82
|
+
pub fn parse(bytes: &[u8], path: &Path) -> Result<Self, RouteError> {
|
|
83
|
+
let config: Self = serde_json::from_slice(bytes).map_err(|_| RouteError::invalid(path))?;
|
|
84
|
+
if config.schema_version != 1
|
|
85
|
+
|| config.providers.iter().any(|(key, tokens)| {
|
|
86
|
+
parse_route_provider(key).and_then(route_key) != Some(key.as_str())
|
|
87
|
+
|| !valid_tokens(tokens)
|
|
88
|
+
})
|
|
89
|
+
{
|
|
90
|
+
return Err(RouteError::invalid(path));
|
|
91
|
+
}
|
|
92
|
+
Ok(config)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
97
|
+
pub enum Override {
|
|
98
|
+
Unset,
|
|
99
|
+
On,
|
|
100
|
+
Off,
|
|
101
|
+
Invalid,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
impl Override {
|
|
105
|
+
pub fn parse(raw: Option<&OsStr>) -> Self {
|
|
106
|
+
match raw {
|
|
107
|
+
None => Self::Unset,
|
|
108
|
+
Some(raw) => match raw
|
|
109
|
+
.to_str()
|
|
110
|
+
.map(|s| s.trim().to_ascii_lowercase())
|
|
111
|
+
.as_deref()
|
|
112
|
+
{
|
|
113
|
+
Some("1" | "true" | "on") => Self::On,
|
|
114
|
+
Some("0" | "false" | "off") => Self::Off,
|
|
115
|
+
_ => Self::Invalid,
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
pub fn current() -> Self {
|
|
120
|
+
Self::parse(std::env::var_os(ENV_NAME).as_deref())
|
|
121
|
+
}
|
|
122
|
+
pub fn status(self) -> &'static str {
|
|
123
|
+
match self {
|
|
124
|
+
Self::Unset => "unset",
|
|
125
|
+
Self::On => "on",
|
|
126
|
+
Self::Off => "off",
|
|
127
|
+
Self::Invalid => "invalid",
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
pub fn effective(self, persisted: bool) -> bool {
|
|
131
|
+
match self {
|
|
132
|
+
Self::On => true,
|
|
133
|
+
Self::Unset => persisted,
|
|
134
|
+
Self::Off | Self::Invalid => false,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
pub fn source(self, file_present: bool) -> &'static str {
|
|
138
|
+
match self {
|
|
139
|
+
Self::Unset if file_present => "config",
|
|
140
|
+
Self::Unset => "default",
|
|
141
|
+
_ => "env",
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
#[derive(Debug, Clone, Serialize)]
|
|
147
|
+
pub struct RouteError {
|
|
148
|
+
pub error: String,
|
|
149
|
+
pub reason: &'static str,
|
|
150
|
+
pub action: String,
|
|
151
|
+
pub config_path: Option<PathBuf>,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
impl RouteError {
|
|
155
|
+
fn new(reason: &'static str, message: &str, path: Option<&Path>) -> Self {
|
|
156
|
+
Self {
|
|
157
|
+
error: message.to_string(), reason, config_path: path.map(Path::to_path_buf),
|
|
158
|
+
action: format!("inspect/fix the argv-routing configuration; to force native argv temporarily set {ENV_NAME}=off"),
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
fn invalid(path: &Path) -> Self {
|
|
162
|
+
Self::new(
|
|
163
|
+
"argv_route_config_invalid",
|
|
164
|
+
"invalid argv-routing schema, provider mapping or token",
|
|
165
|
+
Some(path),
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
fn io(path: &Path, error: io::Error) -> Self {
|
|
169
|
+
Self::new(
|
|
170
|
+
"argv_route_config_unreadable",
|
|
171
|
+
&format!("argv-routing I/O failed ({:?})", error.kind()),
|
|
172
|
+
Some(path),
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
impl fmt::Display for RouteError {
|
|
178
|
+
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
179
|
+
write!(formatter, "{}: {}", self.reason, self.error)?;
|
|
180
|
+
if let Some(path) = &self.config_path {
|
|
181
|
+
write!(formatter, " (config: {})", path.display())?;
|
|
182
|
+
}
|
|
183
|
+
write!(formatter, "; action: {}", self.action)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
impl std::error::Error for RouteError {}
|
|
187
|
+
|
|
188
|
+
pub fn config_path() -> Result<PathBuf, RouteError> {
|
|
189
|
+
fn absolute_home(raw: Option<std::ffi::OsString>) -> Option<PathBuf> {
|
|
190
|
+
raw.map(PathBuf::from)
|
|
191
|
+
.filter(|path| path.is_absolute() && !path.as_os_str().is_empty())
|
|
192
|
+
}
|
|
193
|
+
let home = absolute_home(std::env::var_os("HOME"));
|
|
194
|
+
#[cfg(windows)]
|
|
195
|
+
let home = home.or_else(|| absolute_home(std::env::var_os("USERPROFILE")));
|
|
196
|
+
home.map(|path| path.join(".team-agent").join("argv-routing.json"))
|
|
197
|
+
.ok_or_else(|| {
|
|
198
|
+
RouteError::new(
|
|
199
|
+
"argv_route_home_unavailable",
|
|
200
|
+
"an absolute nonempty host home is required",
|
|
201
|
+
None,
|
|
202
|
+
)
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
fn read_bytes(path: &Path) -> Result<Option<Vec<u8>>, RouteError> {
|
|
207
|
+
match fs::read(path) {
|
|
208
|
+
Ok(bytes) => Ok(Some(bytes)),
|
|
209
|
+
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
|
210
|
+
// A dangling symlink is an existing broken configuration, not a
|
|
211
|
+
// missing file that management may silently replace.
|
|
212
|
+
if fs::symlink_metadata(path).is_ok() {
|
|
213
|
+
Err(RouteError::io(path, error))
|
|
214
|
+
} else {
|
|
215
|
+
Ok(None)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
Err(error) => Err(RouteError::io(path, error)),
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
pub fn read_config(path: &Path) -> Result<(Config, bool), RouteError> {
|
|
223
|
+
match read_bytes(path)? {
|
|
224
|
+
Some(bytes) => Config::parse(&bytes, path).map(|config| (config, true)),
|
|
225
|
+
None => Ok((Config::default(), false)),
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/// Insert literal tokens after the executable, preserving the entire old tail.
|
|
230
|
+
pub fn insert(config: &Config, provider: Provider, argv: &mut Vec<String>) {
|
|
231
|
+
if let Some(tokens) = route_key(provider).and_then(|key| config.providers.get(key)) {
|
|
232
|
+
if !argv.is_empty() && !tokens.is_empty() {
|
|
233
|
+
argv.splice(1..1, tokens.iter().cloned());
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/// One read per actual native launch. OFF never reads or writes configuration.
|
|
239
|
+
pub fn apply(provider: Provider, argv: &mut Vec<String>) -> Result<(), RouteError> {
|
|
240
|
+
let override_state = Override::current();
|
|
241
|
+
if route_key(provider).is_none() || matches!(override_state, Override::Off | Override::Invalid)
|
|
242
|
+
{
|
|
243
|
+
return Ok(());
|
|
244
|
+
}
|
|
245
|
+
let path = match config_path() {
|
|
246
|
+
Ok(path) => path,
|
|
247
|
+
Err(_) if override_state == Override::Unset => return Ok(()),
|
|
248
|
+
Err(error) => return Err(error),
|
|
249
|
+
};
|
|
250
|
+
let bytes = match read_bytes(&path) {
|
|
251
|
+
Ok(bytes) => bytes,
|
|
252
|
+
Err(_) if override_state == Override::Unset => return Ok(()),
|
|
253
|
+
Err(error) => return Err(error),
|
|
254
|
+
};
|
|
255
|
+
let Some(bytes) = bytes else {
|
|
256
|
+
return Ok(());
|
|
257
|
+
};
|
|
258
|
+
if override_state == Override::Unset {
|
|
259
|
+
// Tolerant OFF detection precedes strict mapping validation. Never
|
|
260
|
+
// make a disabled or unrecognizable file block historical launches.
|
|
261
|
+
let enabled = serde_json::from_slice::<serde_json::Value>(&bytes)
|
|
262
|
+
.ok()
|
|
263
|
+
.and_then(|value| value.get("enabled").and_then(serde_json::Value::as_bool));
|
|
264
|
+
if enabled != Some(true) {
|
|
265
|
+
return Ok(());
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
insert(&Config::parse(&bytes, &path)?, provider, argv);
|
|
269
|
+
Ok(())
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
pub enum Mutation {
|
|
273
|
+
Enable(bool),
|
|
274
|
+
Set(Provider, Vec<String>),
|
|
275
|
+
Add(Provider, Vec<String>),
|
|
276
|
+
Clear(Provider),
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
struct Lock(File);
|
|
280
|
+
impl Drop for Lock {
|
|
281
|
+
fn drop(&mut self) {
|
|
282
|
+
let _ = crate::platform::file_lock::unlock(&self.0);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
fn private_options() -> OpenOptions {
|
|
287
|
+
let mut options = OpenOptions::new();
|
|
288
|
+
options.read(true).write(true);
|
|
289
|
+
#[cfg(unix)]
|
|
290
|
+
{
|
|
291
|
+
use std::os::unix::fs::OpenOptionsExt;
|
|
292
|
+
options.mode(0o600);
|
|
293
|
+
}
|
|
294
|
+
options
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
fn acquire_lock(path: &Path) -> Result<Lock, RouteError> {
|
|
298
|
+
let parent = path.parent().ok_or_else(|| RouteError::invalid(path))?;
|
|
299
|
+
let mut builder = fs::DirBuilder::new();
|
|
300
|
+
#[cfg(unix)]
|
|
301
|
+
{
|
|
302
|
+
use std::os::unix::fs::DirBuilderExt;
|
|
303
|
+
builder.mode(0o700);
|
|
304
|
+
}
|
|
305
|
+
match builder.create(parent) {
|
|
306
|
+
Ok(()) => {}
|
|
307
|
+
Err(error) if error.kind() == io::ErrorKind::AlreadyExists && parent.is_dir() => {}
|
|
308
|
+
Err(error) => return Err(RouteError::io(path, error)),
|
|
309
|
+
}
|
|
310
|
+
let file = private_options()
|
|
311
|
+
.create(true)
|
|
312
|
+
.truncate(false)
|
|
313
|
+
.open(parent.join("argv-routing.lock"))
|
|
314
|
+
.map_err(|error| RouteError::io(path, error))?;
|
|
315
|
+
let started = Instant::now();
|
|
316
|
+
loop {
|
|
317
|
+
match crate::platform::file_lock::try_lock_once_nonblocking(&file) {
|
|
318
|
+
Ok(true) => return Ok(Lock(file)),
|
|
319
|
+
Ok(false) if started.elapsed() < Duration::from_secs(5) => {
|
|
320
|
+
std::thread::sleep(Duration::from_millis(25))
|
|
321
|
+
}
|
|
322
|
+
Ok(false) => {
|
|
323
|
+
return Err(RouteError::new(
|
|
324
|
+
"argv_route_lock_timeout",
|
|
325
|
+
"argv-routing write lock timed out",
|
|
326
|
+
Some(path),
|
|
327
|
+
))
|
|
328
|
+
}
|
|
329
|
+
Err(error) => return Err(RouteError::io(path, error)),
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
fn write_config(path: &Path, config: &Config) -> Result<(), RouteError> {
|
|
335
|
+
static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
336
|
+
let nanos = std::time::SystemTime::now()
|
|
337
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
338
|
+
.map_or(0, |duration| duration.as_nanos());
|
|
339
|
+
let serial = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
340
|
+
let temp = path.with_file_name(format!(
|
|
341
|
+
".argv-routing-{}-{nanos}-{serial}.tmp",
|
|
342
|
+
std::process::id()
|
|
343
|
+
));
|
|
344
|
+
// Only remove a temp path after successfully creating it ourselves.
|
|
345
|
+
let mut file = private_options()
|
|
346
|
+
.create_new(true)
|
|
347
|
+
.open(&temp)
|
|
348
|
+
.map_err(|error| RouteError::io(path, error))?;
|
|
349
|
+
let result = (|| {
|
|
350
|
+
serde_json::to_writer_pretty(&mut file, config).map_err(|_| RouteError::invalid(path))?;
|
|
351
|
+
file.write_all(b"\n")
|
|
352
|
+
.and_then(|_| file.flush())
|
|
353
|
+
.and_then(|_| file.sync_all())
|
|
354
|
+
.map_err(|error| RouteError::io(path, error))?;
|
|
355
|
+
drop(file);
|
|
356
|
+
fs::rename(&temp, path).map_err(|error| RouteError::io(path, error))
|
|
357
|
+
})();
|
|
358
|
+
if result.is_err() {
|
|
359
|
+
let _ = fs::remove_file(&temp);
|
|
360
|
+
}
|
|
361
|
+
result
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
pub fn mutate(path: &Path, mutation: Mutation) -> Result<(Config, bool), RouteError> {
|
|
365
|
+
match &mutation {
|
|
366
|
+
Mutation::Set(provider, tokens) | Mutation::Add(provider, tokens) => {
|
|
367
|
+
if route_key(*provider).is_none() || tokens.is_empty() || !valid_tokens(tokens) {
|
|
368
|
+
return Err(RouteError::invalid(path));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
Mutation::Clear(provider) if route_key(*provider).is_none() => {
|
|
372
|
+
return Err(RouteError::invalid(path))
|
|
373
|
+
}
|
|
374
|
+
_ => {}
|
|
375
|
+
}
|
|
376
|
+
// Strict preflight prevents creating lock/dir apparatus for a bad file.
|
|
377
|
+
let (config, present) = read_config(path)?;
|
|
378
|
+
if !present && matches!(mutation, Mutation::Clear(_)) {
|
|
379
|
+
return Ok((config, false));
|
|
380
|
+
}
|
|
381
|
+
let _lock = acquire_lock(path)?;
|
|
382
|
+
let (mut config, present) = read_config(path)?;
|
|
383
|
+
match mutation {
|
|
384
|
+
Mutation::Enable(enabled) => config.enabled = enabled,
|
|
385
|
+
Mutation::Set(provider, tokens) => {
|
|
386
|
+
if let Some(key) = route_key(provider) {
|
|
387
|
+
config.providers.insert(key.to_string(), tokens);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
Mutation::Add(provider, tokens) => {
|
|
391
|
+
if let Some(key) = route_key(provider) {
|
|
392
|
+
config
|
|
393
|
+
.providers
|
|
394
|
+
.entry(key.to_string())
|
|
395
|
+
.or_default()
|
|
396
|
+
.extend(tokens);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
Mutation::Clear(provider) => {
|
|
400
|
+
if let Some(key) = route_key(provider) {
|
|
401
|
+
if config.providers.remove(key).is_none() {
|
|
402
|
+
return Ok((config, present));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
write_config(path, &config)?;
|
|
408
|
+
Ok((config, true))
|
|
409
|
+
}
|
|
@@ -42,13 +42,14 @@
|
|
|
42
42
|
// 复用 model::enums 的 Provider/AuthMode,并 re-export(下游惯于 `provider::Provider` 取用)。
|
|
43
43
|
pub use crate::model::enums::{AuthMode, Provider};
|
|
44
44
|
|
|
45
|
-
/// 0.5.66 bypass 单源:provider → bypass argv flag 配置表(唯一权威)。
|
|
46
|
-
pub(crate) mod bypass_flags;
|
|
47
45
|
pub mod adapter;
|
|
48
46
|
/// 0.4.x decoupling step 2: per-provider command builders + permission/auth
|
|
49
47
|
/// helpers split out of `adapter.rs`. Pure extraction — behavior unchanged.
|
|
50
48
|
pub(crate) mod adapters;
|
|
51
49
|
pub mod approvals;
|
|
50
|
+
pub mod argv_route;
|
|
51
|
+
/// 0.5.66 bypass 单源:provider → bypass argv flag 配置表(唯一权威)。
|
|
52
|
+
pub(crate) mod bypass_flags;
|
|
52
53
|
pub mod classify;
|
|
53
54
|
pub mod faults;
|
|
54
55
|
pub mod model_catalog;
|
|
@@ -589,15 +589,18 @@ pub(crate) fn run_command(
|
|
|
589
589
|
Some((writer, rx))
|
|
590
590
|
} else { None };
|
|
591
591
|
let mut output = None;
|
|
592
|
+
let mut pending_output = None;
|
|
593
|
+
let mut output_ready_at: Option<Instant> = None;
|
|
592
594
|
let mut too_large_at = None;
|
|
593
595
|
let status = loop {
|
|
594
|
-
match reader_rx.try_recv() {
|
|
596
|
+
match pending_output.take().map_or_else(|| reader_rx.try_recv(), Ok) {
|
|
595
597
|
Ok(Ok(bytes)) => {
|
|
596
598
|
if bytes.len() as u64 > max_bytes {
|
|
597
599
|
too_large_at.get_or_insert_with(Instant::now);
|
|
598
600
|
} else {
|
|
599
601
|
output = Some(bytes);
|
|
600
602
|
}
|
|
603
|
+
output_ready_at = Some(Instant::now());
|
|
601
604
|
}
|
|
602
605
|
Ok(Err(_)) => { terminate(&mut child, pid); return Err(CatalogError::OutputReadFailed); }
|
|
603
606
|
Err(mpsc::TryRecvError::Disconnected) if output.is_none() && too_large_at.is_none() => { terminate(&mut child, pid); return Err(CatalogError::OutputReadFailed); }
|
|
@@ -614,7 +617,24 @@ pub(crate) fn run_command(
|
|
|
614
617
|
terminate(&mut child, pid);
|
|
615
618
|
return Err(CatalogError::OutputTooLarge);
|
|
616
619
|
}
|
|
617
|
-
Ok(None) if started.elapsed() < timeout =>
|
|
620
|
+
Ok(None) if started.elapsed() < timeout => {
|
|
621
|
+
let mut wait = timeout.saturating_sub(started.elapsed()).min(Duration::from_millis(10));
|
|
622
|
+
if let Some(over_at) = too_large_at {
|
|
623
|
+
wait = wait.min(Duration::from_millis(100).saturating_sub(over_at.elapsed()));
|
|
624
|
+
}
|
|
625
|
+
if output.is_none() && too_large_at.is_none() {
|
|
626
|
+
// Reader completion wakes us instead of trailing a blind 10ms sleep.
|
|
627
|
+
if let Ok(result) = reader_rx.recv_timeout(wait) {
|
|
628
|
+
pending_output = Some(result);
|
|
629
|
+
}
|
|
630
|
+
} else {
|
|
631
|
+
// stdout can close before exit; never busy-poll a disconnected channel.
|
|
632
|
+
let interval = output_ready_at.map_or(Duration::from_millis(10), |at| {
|
|
633
|
+
catalog_exit_poll_interval(at.elapsed())
|
|
634
|
+
});
|
|
635
|
+
std::thread::sleep(wait.min(interval));
|
|
636
|
+
}
|
|
637
|
+
}
|
|
618
638
|
Ok(None) if too_large_at.is_some() => { terminate(&mut child, pid); return Err(CatalogError::OutputTooLarge); }
|
|
619
639
|
Ok(None) => { terminate(&mut child, pid); return Err(CatalogError::Timeout); }
|
|
620
640
|
Err(_) => { terminate(&mut child, pid); return Err(CatalogError::CommandCouldNotBeObserved); }
|
|
@@ -642,6 +662,16 @@ pub(crate) fn run_command(
|
|
|
642
662
|
output.ok_or(CatalogError::OutputReadFailed)
|
|
643
663
|
}
|
|
644
664
|
|
|
665
|
+
fn catalog_exit_poll_interval(elapsed: Duration) -> Duration {
|
|
666
|
+
[1, 3, 6, 10]
|
|
667
|
+
.into_iter()
|
|
668
|
+
.map(Duration::from_millis)
|
|
669
|
+
.find(|checkpoint| *checkpoint > elapsed)
|
|
670
|
+
.map_or(Duration::from_millis(10), |checkpoint| {
|
|
671
|
+
checkpoint.saturating_sub(elapsed)
|
|
672
|
+
})
|
|
673
|
+
}
|
|
674
|
+
|
|
645
675
|
fn terminate(child: &mut Child, pid: u32) {
|
|
646
676
|
#[cfg(unix)]
|
|
647
677
|
unsafe {
|
|
@@ -700,6 +730,151 @@ impl Drop for OwnedTempDir {
|
|
|
700
730
|
fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); }
|
|
701
731
|
}
|
|
702
732
|
|
|
733
|
+
#[cfg(all(test, unix))]
|
|
734
|
+
mod command_wait_regressions {
|
|
735
|
+
use super::*;
|
|
736
|
+
|
|
737
|
+
#[derive(Default)]
|
|
738
|
+
struct Observer {
|
|
739
|
+
pid: Option<u32>,
|
|
740
|
+
exits: Vec<Option<i32>>,
|
|
741
|
+
output_timeouts: usize,
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
impl CatalogObserver for Observer {
|
|
745
|
+
fn spawned(&mut self, pid: u32) { self.pid = Some(pid); }
|
|
746
|
+
fn exited(&mut self, status: &ExitStatus) { self.exits.push(status.code()); }
|
|
747
|
+
fn output_timeout(&mut self) { self.output_timeouts += 1; }
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
fn run(
|
|
751
|
+
script: &str,
|
|
752
|
+
input: Option<&[u8]>,
|
|
753
|
+
timeout: Duration,
|
|
754
|
+
max_bytes: u64,
|
|
755
|
+
observer: &mut Observer,
|
|
756
|
+
) -> Result<Vec<u8>, CatalogError> {
|
|
757
|
+
run_command(
|
|
758
|
+
Path::new("/bin/sh"), &["-c", script], input, None, &[], &[],
|
|
759
|
+
timeout, max_bytes, observer,
|
|
760
|
+
)
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
fn assert_owned_child_gone(observer: &Observer) {
|
|
764
|
+
let pid = observer.pid.unwrap();
|
|
765
|
+
assert!(pid > 1);
|
|
766
|
+
let status = Command::new("/bin/kill")
|
|
767
|
+
.args(["-0", &pid.to_string()])
|
|
768
|
+
.stdout(Stdio::null())
|
|
769
|
+
.stderr(Stdio::null())
|
|
770
|
+
.status()
|
|
771
|
+
.unwrap();
|
|
772
|
+
assert!(!status.success(), "owned child must be reaped");
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
#[test]
|
|
776
|
+
fn eof_and_success_preserve_input_and_raw_output() {
|
|
777
|
+
let mut observer = Observer::default();
|
|
778
|
+
let input = b"catalog\0\xff\n";
|
|
779
|
+
let output = run(
|
|
780
|
+
"exec /bin/cat", Some(input), CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut observer,
|
|
781
|
+
).unwrap();
|
|
782
|
+
assert_eq!(output, input.to_vec());
|
|
783
|
+
assert_eq!(observer.exits, vec![Some(0)]);
|
|
784
|
+
assert_eq!(observer.output_timeouts, 0);
|
|
785
|
+
assert_owned_child_gone(&observer);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
#[test]
|
|
789
|
+
fn stdout_eof_does_not_hide_a_later_nonzero_exit() {
|
|
790
|
+
let mut observer = Observer::default();
|
|
791
|
+
let error = run(
|
|
792
|
+
"printf 'partial'; exec 1>&-; /bin/sleep 0.03; exit 17",
|
|
793
|
+
None, CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut observer,
|
|
794
|
+
).unwrap_err();
|
|
795
|
+
assert_eq!(error, CatalogError::CommandFailed);
|
|
796
|
+
assert_eq!(observer.exits, vec![Some(17)]);
|
|
797
|
+
assert_eq!(observer.output_timeouts, 0);
|
|
798
|
+
assert_owned_child_gone(&observer);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
#[test]
|
|
802
|
+
fn stdout_eof_does_not_disable_the_ten_second_process_timeout() {
|
|
803
|
+
let mut observer = Observer::default();
|
|
804
|
+
let started = Instant::now();
|
|
805
|
+
let error = run(
|
|
806
|
+
"exec 1>&-; exec /bin/sleep 30", None, CATALOG_TIMEOUT,
|
|
807
|
+
MAX_CATALOG_BYTES, &mut observer,
|
|
808
|
+
).unwrap_err();
|
|
809
|
+
let elapsed = started.elapsed();
|
|
810
|
+
assert_eq!(CATALOG_TIMEOUT, Duration::from_secs(10));
|
|
811
|
+
assert_eq!(error, CatalogError::Timeout);
|
|
812
|
+
assert!(elapsed >= CATALOG_TIMEOUT);
|
|
813
|
+
assert!(elapsed < CATALOG_TIMEOUT + Duration::from_secs(2));
|
|
814
|
+
assert!(observer.exits.is_empty());
|
|
815
|
+
assert_eq!(observer.output_timeouts, 0);
|
|
816
|
+
assert_owned_child_gone(&observer);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
#[test]
|
|
820
|
+
fn exact_one_mib_is_allowed_and_one_more_byte_is_rejected() {
|
|
821
|
+
assert_eq!(MAX_CATALOG_BYTES, 1024 * 1024);
|
|
822
|
+
let mut observer = Observer::default();
|
|
823
|
+
let script = format!("exec /usr/bin/head -c {MAX_CATALOG_BYTES} /dev/zero");
|
|
824
|
+
let output = run(
|
|
825
|
+
&script, None, CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut observer,
|
|
826
|
+
).unwrap();
|
|
827
|
+
assert_eq!(output.len() as u64, MAX_CATALOG_BYTES);
|
|
828
|
+
assert_owned_child_gone(&observer);
|
|
829
|
+
let mut observer = Observer::default();
|
|
830
|
+
let script = format!("exec /usr/bin/head -c {} /dev/zero", MAX_CATALOG_BYTES + 1);
|
|
831
|
+
assert_eq!(
|
|
832
|
+
run(&script, None, CATALOG_TIMEOUT, MAX_CATALOG_BYTES, &mut observer),
|
|
833
|
+
Err(CatalogError::OutputTooLarge)
|
|
834
|
+
);
|
|
835
|
+
assert_owned_child_gone(&observer);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
#[test]
|
|
839
|
+
fn oversized_output_keeps_one_hundred_millisecond_exit_grace() {
|
|
840
|
+
let mut observer = Observer::default();
|
|
841
|
+
let started = Instant::now();
|
|
842
|
+
let error = run(
|
|
843
|
+
"printf '%65s' x; exec /bin/sleep 30", None, CATALOG_TIMEOUT, 64, &mut observer,
|
|
844
|
+
).unwrap_err();
|
|
845
|
+
assert_eq!(error, CatalogError::OutputTooLarge);
|
|
846
|
+
assert!(started.elapsed() >= Duration::from_millis(100));
|
|
847
|
+
assert!(started.elapsed() < Duration::from_secs(2));
|
|
848
|
+
assert_owned_child_gone(&observer);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
#[test]
|
|
852
|
+
fn exited_child_with_inherited_stdout_keeps_output_timeout_observation() {
|
|
853
|
+
let mut observer = Observer::default();
|
|
854
|
+
let error = run(
|
|
855
|
+
"/bin/sleep 30 & exit 0", None, Duration::from_millis(200),
|
|
856
|
+
MAX_CATALOG_BYTES, &mut observer,
|
|
857
|
+
).unwrap_err();
|
|
858
|
+
assert_eq!(error, CatalogError::Timeout);
|
|
859
|
+
assert_eq!(observer.exits, vec![Some(0)]);
|
|
860
|
+
assert_eq!(observer.output_timeouts, 1);
|
|
861
|
+
assert_owned_child_gone(&observer);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
#[test]
|
|
865
|
+
fn early_eof_polling_keeps_ten_millisecond_anchor_and_then_old_cadence() {
|
|
866
|
+
let mut elapsed = Duration::ZERO;
|
|
867
|
+
for (sleep_ms, check_ms) in [(1, 1), (2, 3), (3, 6), (4, 10), (10, 20)] {
|
|
868
|
+
let interval = catalog_exit_poll_interval(elapsed);
|
|
869
|
+
assert_eq!(interval, Duration::from_millis(sleep_ms));
|
|
870
|
+
elapsed += interval;
|
|
871
|
+
assert_eq!(elapsed, Duration::from_millis(check_ms));
|
|
872
|
+
}
|
|
873
|
+
assert_eq!(catalog_exit_poll_interval(Duration::from_micros(9500)), Duration::from_micros(500));
|
|
874
|
+
assert_eq!(catalog_exit_poll_interval(Duration::from_millis(50)), Duration::from_millis(10));
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
703
878
|
#[cfg(test)]
|
|
704
879
|
mod tests {
|
|
705
880
|
use super::*;
|