diff --git a/.github/actions/setup-project-bun/action.yml b/.github/actions/setup-project-bun/action.yml index f758ad64ff8..1ab22dd1dc9 100644 --- a/.github/actions/setup-project-bun/action.yml +++ b/.github/actions/setup-project-bun/action.yml @@ -4,8 +4,10 @@ description: >- runtime (default), or testRunnerBun for test-runner. # Runtime 1.4.2 fixes #6684's Windows Bun.fetch stream truncation. The test runner -# stays on 1.4.0: #4821 reverted #4064 after 1.4.2's multi-file --isolate preload -# segfault; #6713 run 37631558263 reproduces it on Linux 1/4, macOS 2/2, Windows 9/9. +# skipped 1.4.2: #4821 reverted #4064 after its multi-file --isolate preload segfault +# (#6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9). It moved from 1.4.0 to +# 1.4.3 for oven-sh/bun#44581: 1.4.0's Bun.spawnSync could lose a child's exit after +# finalizers ran inside an earlier call and spin past its own timeout (#4956). # Rejoin only after the upstream runner crash is fixed and a green lane=all # dispatch proves the unified version; then delete testRunnerBun and this role input. inputs: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34ebb9411c4..34de6c49240 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -550,8 +550,9 @@ jobs: printf 'scheduled run tests dev at %s (workflow %s)\n' "$sha" "$GITHUB_SHA" # Bun roles (#6713): runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming; - # test-runner 1.4.0 avoids 1.4.2's multi-file --isolate preload segfault (#4821 - # reverted #4064; run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9). + # the test runner skipped 1.4.2's multi-file --isolate preload segfault (#4821 + # reverted #4064; run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and runs + # 1.4.3, whose oven-sh/bun#44581 stops Bun.spawnSync losing a child's exit (#4956). # Rejoin after the upstream runner crash is fixed and a green lane=all dispatch # proves the unified version, then delete testRunnerBun and the action's role input. # The suite, split by file across four Linux runners. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4c74e70a2d1..f6b200e609b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,8 +61,8 @@ A ready-for-review PR is the author's claim that the change is complete, underst ## Local validation and hooks -The suite uses Bun 1.4.0 (`testRunnerBun`) while the shipped runtime is 1.4.2. -`scripts/test.ts` finds a 1.4.0 binary on PATH or in `~/.bun/bin`, or uses +The suite uses Bun 1.4.3 (`testRunnerBun`) while the shipped runtime is 1.4.2. +`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`; install that version locally if it is missing. Run `bun run test` before review readiness. If the full local suite is too costly diff --git a/Dockerfile b/Dockerfile index c8e587fe934..7e36f2063a0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,10 @@ # syntax=docker/dockerfile:1 # Keep the runtime aligned with package.json and pin the multi-platform image index. -# Runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming; CI tests stay on 1.4.0 +# Runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming; CI tests run on 1.4.3 # because 1.4.2 segfaults reloading preloads in multi-file --isolate (#4821; -# #6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9). Rejoin after the +# #6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and 1.4.0's spawnSync +# can lose a child's exit (#4956, oven-sh/bun#44581). Rejoin after the # upstream crash fix passes a unified-version lane=all dispatch, then delete # package.json's testRunnerBun pin and setup-project-bun's role input. ARG BUN_IMAGE=oven/bun:1.4.2@sha256:9114c058aeae42162ee16dd5084b95fe9473970bb6bcb5b232ab1630f0546895 diff --git a/desktop/src-tauri/src/first_run.rs b/desktop/src-tauri/src/first_run.rs index cf0c5b9e87c..9f1532e79c3 100644 --- a/desktop/src-tauri/src/first_run.rs +++ b/desktop/src-tauri/src/first_run.rs @@ -85,6 +85,11 @@ pub fn apply_start_at_login_default(app: &AppHandle) -> StartAtLogin { /// It runs once, behind its own marker, and only where autostart is already on. It never turns the /// setting on and never turns it off; the worst case is an entry that keeps its old arguments and a /// login launch that shows its window, which is the visible failure rather than the silent one. +/// +/// On macOS the marker lives in a config directory every copy of the app shares, while the plugin +/// rewrites whatever plist exists. The rewrite is therefore limited to this copy's own entry in the +/// exact shape the plugin writes; another or a missing copy, or any key the rewrite would drop, is +/// left untouched and unmarked (#6972). pub fn adopt_launch_origin_argument(app: &AppHandle) { let Ok(dir) = app.path().app_config_dir() else { return; @@ -102,6 +107,23 @@ pub fn adopt_launch_origin_argument(app: &AppHandle) { // that is already enabled, so retrying after a transient registry, LaunchAgent or desktop-file // error is free — and claiming the marker first would suppress the migration permanently and // leave a login launch showing its window forever. + #[cfg(target_os = "macos")] + { + use crate::login_registration::Migration; + let Ok(home) = app.path().home_dir() else { + return; + }; + match crate::login_registration::judge_migration(&home) { + Migration::Rewrite => { + if crate::login_autostart::enable(app).is_err() { + return; + } + } + Migration::Absent => {} + Migration::Leave => return, + } + } + #[cfg(not(target_os = "macos"))] match app.autolaunch().is_enabled() { Ok(true) => { if crate::login_autostart::enable(app).is_err() { diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 06c1fa29540..d4e3dec7e32 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -15,6 +15,9 @@ mod formatting; mod identity; mod logging; mod login_autostart; +// The judgment is portable so Linux CI runs its fixture parity tests; only macOS reads launchd. +#[cfg(any(target_os = "macos", test))] +mod login_registration; #[cfg(any(target_os = "windows", test))] mod windows_autostart_command; // macOS only: it exists to replace one item in a menu no other platform installs. Compiling it @@ -35,6 +38,8 @@ mod popup; mod popup; #[cfg(target_os = "macos")] mod provider_icons; +#[cfg(target_os = "macos")] +mod tray_login; // The macOS build selects native_tray.rs as the popup module; compile the portable popup // module's tests on macOS too so its navigation rules run on the maintainers' platform. #[cfg(all(test, target_os = "macos"))] diff --git a/desktop/src-tauri/src/login_registration.rs b/desktop/src-tauri/src/login_registration.rs new file mode 100644 index 00000000000..2461254c5eb --- /dev/null +++ b/desktop/src-tauri/src/login_registration.rs @@ -0,0 +1,1044 @@ +//! Start at Login on macOS, judged the way Startup safety judges it. +//! +//! The autostart plugin answers "is Start at Login on?" with whether +//! `~/Library/LaunchAgents/OpenCodex.plist` exists. Startup safety +//! (`src/service/desktop-startup.ts`, `macLoginRegistration`) asks more: the plist must have the +//! shape this app writes, launchd must not have the job disabled, the registered app must be this +//! copy, and a loaded job must have been loaded from this copy and this plist. When the two +//! disagreed the tray showed a checked item while the Startup page reported Start at Login as +//! unverified (#6972). +//! +//! [`classify`] is a step-for-step port of that judgment over an injected [`Probe`], so the tray +//! can be tested without launchd. The cases in `tests/fixtures/desktop-login-registration-cases.json` +//! are run against both implementations; a change to either side that is not mirrored in the other +//! fails one of the two suites. Only [`LoginState::On`] is shown checked: every other state is a +//! reason the registration could not be credited, and none of them may read as on. + +use serde_json::Value; +use std::io; +use std::path::{Path, PathBuf}; + +const SERVICE: &str = "OpenCodex"; +const APP_SUFFIX: &str = "/Contents/MacOS/opencodex-desktop"; +const AUTOSTART_ARGUMENT: &str = "--autostart"; +/// System Settings → General → Login Items & Extensions, where a disabled job is turned back on. +#[cfg(target_os = "macos")] +pub const LOGIN_ITEMS_SETTINGS: &str = + "x-apple.systempreferences:com.apple.LoginItems-Settings.extension"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LoginState { + /// This copy's registration is enabled and either loaded from this copy or valid for the next + /// login. The only state Startup safety credits, and the only one shown checked. + On, + /// No LaunchAgent plist. + Off, + /// This copy is registered but launchd has the job disabled (System Settings or + /// `launchctl disable`). Rewriting the plist does not clear that, so the item opens Login + /// Items settings instead. + DisabledBySystem, + /// The registration, or the job launchd loaded, belongs to another OpenCodex copy. The tray + /// never rewrites it. + OtherCopy, + /// The registration names an app path that does not exist. It is still not this copy's. + MissingCopy, + /// The evidence could not be read, was malformed, timed out or changed while it was read. + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClickAction { + Disable, + Enable, + OpenLoginItems, + Nothing, +} + +impl LoginState { + pub fn checked(self) -> bool { + self == Self::On + } + + pub fn label(self) -> &'static str { + match self { + Self::On | Self::Off => "Start at Login", + Self::DisabledBySystem => "Start at Login (disabled by macOS)", + Self::OtherCopy => "Start at Login (another OpenCodex copy)", + Self::MissingCopy => "Start at Login (registered app is missing)", + Self::Unknown => "Start at Login (status unknown)", + } + } + + pub fn click_action(self) -> ClickAction { + match self { + Self::On => ClickAction::Disable, + Self::Off => ClickAction::Enable, + Self::DisabledBySystem => ClickAction::OpenLoginItems, + // A missing path may be a copy being replaced or on a volume that is not mounted, so it + // is no more this copy's to take over than an existing one. + Self::OtherCopy | Self::MissingCopy | Self::Unknown => ClickAction::Nothing, + } + } + + pub fn clickable(self) -> bool { + self.click_action() != ClickAction::Nothing + } +} + +/// A failed command. `Exit` is an ordinary non-zero exit; everything else (spawn failure, +/// timeout, signal, oversized or non-UTF-8 output) is `Failed`, which never counts as absence. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RunError { + Exit { status: i32, stderr: String }, + Failed, +} + +/// The filesystem and process reads the judgment needs, injectable for tests. +pub trait Probe { + /// An identity of the path itself (not its target) that changes when it is replaced or edited. + fn identity(&self, path: &Path) -> io::Result; + fn realpath(&self, path: &Path) -> io::Result; + fn executable(&self, path: &Path) -> io::Result<()>; + /// Trimmed stdout of a successful run. + fn run(&self, program: &str, args: &[&str]) -> Result; +} + +/// JavaScript's `\s`, so trimming and the regex ports below match the TypeScript judgment. +fn js_space(c: char) -> bool { + matches!( + c, + '\t' | '\n' | '\u{0b}' | '\u{0c}' | '\r' | ' ' | '\u{a0}' | '\u{1680}' | '\u{2000}' + ..='\u{200a}' + | '\u{2028}' + | '\u{2029}' + | '\u{202f}' + | '\u{205f}' + | '\u{3000}' + | '\u{feff}' + ) +} + +pub fn js_trim(text: &str) -> &str { + text.trim_matches(js_space) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Enabled { + Yes, + Disabled, + Unknown, +} + +/// `/"OpenCodex"\s*=>\s*(enabled|false|disabled|true)(?=\s*(?:\r?\n|[,;}]|$))/`, first match. +fn override_value(text: &str) -> Option<&'static str> { + let needle = "\"OpenCodex\""; + let mut from = 0; + while let Some(found) = text[from..].find(needle) { + let start = from + found; + from = start + needle.len(); + let rest = text[from..].trim_start_matches(js_space); + let Some(rest) = rest.strip_prefix("=>") else { + continue; + }; + let rest = rest.trim_start_matches(js_space); + for word in ["enabled", "false", "disabled", "true"] { + let Some(after) = rest.strip_prefix(word) else { + continue; + }; + let spaces = after.len() - after.trim_start_matches(js_space).len(); + let (gap, next) = after.split_at(spaces); + if gap.contains('\n') || next.is_empty() || next.starts_with([',', ';', '}']) { + return Some(word); + } + } + } + None +} + +/// The print-disabled half of `loginEnabled` in `macLoginRegistration`. +fn enabled(disabled: &str, plist_disabled: Option) -> Enabled { + let body = js_trim(disabled); + if !(body.starts_with("disabled services = {") && body.ends_with('}')) { + return Enabled::Unknown; + } + match disabled.matches("\"OpenCodex\"").count() { + // launchd's override is absent, so the plist's own Disabled key decides. auto-launch never + // writes one; a plist that disables itself was edited by something else. + 0 if plist_disabled == Some(true) => Enabled::Unknown, + 0 => Enabled::Yes, + 1 => match override_value(disabled) { + Some("enabled" | "false") => Enabled::Yes, + Some(_) => Enabled::Disabled, + None => Enabled::Unknown, + }, + _ => Enabled::Unknown, + } +} + +/// ECMAScript line terminators: what `^`, `$` and `.` honour under the `m` flag. +fn js_line_end(c: char) -> bool { + matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}') +} + +/// `/^\s*key(.+)$/m`, first match. +fn line_value<'a>(text: &'a str, key: &str) -> Option<&'a str> { + text.split(js_line_end).find_map(|line| { + line.trim_start_matches(js_space) + .strip_prefix(key) + .filter(|value| !value.is_empty()) + }) +} + +/// The registered program when the plist has the shape auto-launch writes for this app. +fn registered_app(plist: &Value) -> Option<(&str, Option)> { + let args = plist.get("ProgramArguments")?.as_array()?; + let app = args.first()?.as_str()?; + let disabled = match plist.get("Disabled") { + None => None, + Some(Value::Bool(value)) => Some(*value), + Some(_) => return None, + }; + let shaped = plist.get("Label").and_then(Value::as_str) == Some(SERVICE) + && plist.get("RunAtLoad") == Some(&Value::Bool(true)) + && args.len() == 2 + && args[1].as_str() == Some(AUTOSTART_ARGUMENT) + && app.ends_with(APP_SUFFIX) + && plist + .get("Program") + .is_none_or(|program| program.as_str() == Some(app)); + shaped.then_some((app, disabled)) +} + +#[derive(Debug, PartialEq, Eq)] +struct Capture { + app: PathBuf, + proxy: PathBuf, + registration: PathBuf, + plist_identity: String, + target_identity: String, + definition: String, + app_identity: String, + proxy_identity: String, + disabled: String, + enabled: Enabled, +} + +fn unknown(_: T) -> LoginState { + LoginState::Unknown +} + +fn capture( + probe: &dyn Probe, + plist: &Path, + domain: &str, + current_app: &Path, +) -> Result { + let plist_identity = probe.identity(plist).map_err(|error| { + if error.kind() == io::ErrorKind::NotFound { + LoginState::Off + } else { + LoginState::Unknown + } + })?; + let registration = probe.realpath(plist).map_err(unknown)?; + let target_identity = probe.identity(®istration).map_err(unknown)?; + let plist_arg = plist.to_str().ok_or(LoginState::Unknown)?; + let definition = probe + .run( + "/usr/bin/plutil", + &["-convert", "json", "-o", "-", plist_arg], + ) + .map_err(unknown)?; + let parsed: Value = serde_json::from_str(&definition).map_err(unknown)?; + let (registered, plist_disabled) = registered_app(&parsed).ok_or(LoginState::Unknown)?; + let registered = Path::new(registered); + let app = probe.realpath(registered).map_err(|error| { + if error.kind() == io::ErrorKind::NotFound { + LoginState::MissingCopy + } else { + LoginState::Unknown + } + })?; + if app != current_app { + return Err(LoginState::OtherCopy); + } + let sibling = app.parent().ok_or(LoginState::Unknown)?.join("ocx"); + let proxy = probe.realpath(&sibling).map_err(unknown)?; + probe.executable(&app).map_err(unknown)?; + probe.executable(&proxy).map_err(unknown)?; + let app_identity = probe.identity(&app).map_err(unknown)?; + let proxy_identity = probe.identity(&proxy).map_err(unknown)?; + let disabled = probe + .run("/bin/launchctl", &["print-disabled", domain]) + .map_err(unknown)?; + let enabled = enabled(&disabled, plist_disabled); + // Every path and identity is read again: a registration replaced during the probes is not the + // registration that was judged. + let stable = probe.realpath(plist).ok().as_ref() == Some(®istration) + && probe.identity(plist).ok().as_ref() == Some(&plist_identity) + && probe.identity(®istration).ok().as_ref() == Some(&target_identity) + && probe.realpath(registered).ok().as_ref() == Some(&app) + && probe.realpath(&sibling).ok().as_ref() == Some(&proxy) + && probe.identity(&app).ok().as_ref() == Some(&app_identity) + && probe.identity(&proxy).ok().as_ref() == Some(&proxy_identity); + if !stable { + return Err(LoginState::Unknown); + } + Ok(Capture { + app, + proxy, + registration, + plist_identity, + target_identity, + definition, + app_identity, + proxy_identity, + disabled, + enabled, + }) +} + +/// Judge this copy's Start at Login registration. `current_app` must already be resolved. +pub fn classify(probe: &dyn Probe, home: &Path, uid: u32, current_app: &Path) -> LoginState { + let plist = home + .join("Library") + .join("LaunchAgents") + .join(format!("{SERVICE}.plist")); + let domain = format!("gui/{uid}"); + let first = match capture(probe, &plist, &domain, current_app) { + Ok(first) => first, + Err(state) => return state, + }; + match first.enabled { + Enabled::Yes => {} + Enabled::Disabled => return LoginState::DisabledBySystem, + Enabled::Unknown => return LoginState::Unknown, + } + let target = format!("{domain}/{SERVICE}"); + match probe.run("/bin/launchctl", &["print", &target]) { + Ok(loaded) => { + let Some(program) = line_value(&loaded, "program = ") else { + return LoginState::Unknown; + }; + match probe.realpath(Path::new(program)) { + Ok(program) if program == first.app => {} + Ok(_) => return LoginState::OtherCopy, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return LoginState::OtherCopy + } + Err(_) => return LoginState::Unknown, + } + let Some(path) = line_value(&loaded, "path = ") else { + return LoginState::Unknown; + }; + match probe.realpath(Path::new(path)) { + Ok(path) if path == first.registration => LoginState::On, + _ => LoginState::Unknown, + } + } + // launchctl's own service-not-found answer is absence; any other failure is unverified. + Err(RunError::Exit { + status: 113, + stderr, + }) => { + let missing = + format!("Could not find service \"{SERVICE}\" in domain for user gui: {uid}"); + let stderr = js_trim(&stderr); + if stderr != missing && stderr != format!("Bad request.\n{missing}") { + return LoginState::Unknown; + } + // An unloaded job leaves no durable evidence, so the whole registration is read again + // and must not have changed. + match capture(probe, &plist, &domain, current_app) { + Ok(last) if last == first => LoginState::On, + _ => LoginState::Unknown, + } + } + Err(_) => LoginState::Unknown, + } +} + +/// What the once-per-installation launch-origin migration in `first_run.rs` may do. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Migration { + /// No LaunchAgent: nothing to migrate. + Absent, + /// This copy's own enabled entry, in the shape auto-launch writes with or without + /// `--autostart`: rewriting it changes only the arguments. + Rewrite, + /// Anything else: another or a missing copy, a `Program` override, a definition that disables + /// itself, an edited shape or unreadable evidence. Rewriting would change more than arguments. + Leave, +} + +pub fn migration(probe: &dyn Probe, home: &Path, current_app: &Path) -> Migration { + let plist = home + .join("Library") + .join("LaunchAgents") + .join(format!("{SERVICE}.plist")); + let plist_identity = match probe.identity(&plist) { + Ok(identity) => identity, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Migration::Absent, + Err(_) => return Migration::Leave, + }; + let Ok(registration) = probe.realpath(&plist) else { + return Migration::Leave; + }; + let Ok(target_identity) = probe.identity(®istration) else { + return Migration::Leave; + }; + let Some(plist_arg) = plist.to_str() else { + return Migration::Leave; + }; + let Ok(definition) = probe.run( + "/usr/bin/plutil", + &["-convert", "json", "-o", "-", plist_arg], + ) else { + return Migration::Leave; + }; + let Ok(parsed) = serde_json::from_str::(&definition) else { + return Migration::Leave; + }; + let Some(args) = parsed.get("ProgramArguments").and_then(Value::as_array) else { + return Migration::Leave; + }; + let Some(app) = args.first().and_then(Value::as_str) else { + return Migration::Leave; + }; + let arguments = match args.len() { + 1 => true, + 2 => args[1].as_str() == Some(AUTOSTART_ARGUMENT), + _ => false, + }; + // The plugin rewrites the whole file with exactly these three keys, so any other key — + // `Program`, `Disabled`, `KeepAlive`, environment — would be dropped by the rewrite. + let keys = parsed.as_object().is_some_and(|plist| { + plist.len() == 3 + && ["Label", "ProgramArguments", "RunAtLoad"] + .iter() + .all(|key| plist.contains_key(*key)) + }); + let own_shape = arguments + && keys + && parsed.get("Label").and_then(Value::as_str) == Some(SERVICE) + && parsed.get("RunAtLoad") == Some(&Value::Bool(true)) + && app.ends_with(APP_SUFFIX); + if !own_shape { + return Migration::Leave; + } + let Ok(resolved) = probe.realpath(Path::new(app)) else { + return Migration::Leave; + }; + // As in [`classify`]: a plist replaced while it was read is not the plist that was judged. + let stable = probe.identity(&plist).ok().as_ref() == Some(&plist_identity) + && probe.realpath(&plist).ok().as_ref() == Some(®istration) + && probe.identity(®istration).ok().as_ref() == Some(&target_identity) + && probe.realpath(Path::new(app)).ok().as_ref() == Some(&resolved); + if stable && resolved == current_app { + Migration::Rewrite + } else { + Migration::Leave + } +} + +/// Running launchctl and plutil with a deadline, without ever killing a process. +/// +/// The shell never kills a process (`tests/clients/desktop-exit-ownership.test.ts`), so a +/// command that outlives its deadline is abandoned: the caller gets [`RunError::Failed`] at the +/// deadline and a detached thread reaps the child and its output readers later. One process-wide +/// slot is held until all three have finished, and no command starts while it is held, so a +/// launchd that never answers costs at most one abandoned command however often the tray asks. +#[cfg(any(target_os = "macos", all(test, unix)))] +mod command { + use super::{js_trim, RunError}; + use std::io::Read; + use std::process::{Child, Command, Stdio}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::mpsc::{channel, Receiver}; + use std::thread::JoinHandle; + use std::time::{Duration, Instant}; + + const MAX_OUTPUT: u64 = 128 * 1024; + static BUSY: AtomicBool = AtomicBool::new(false); + + /// Occupancy of the one command slot; released when dropped. + struct Slot; + + impl Slot { + fn take() -> Option { + BUSY.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .ok() + .map(|_| Self) + } + } + + impl Drop for Slot { + fn drop(&mut self) { + BUSY.store(false, Ordering::Release); + } + } + + type Reader = (Receiver>, JoinHandle<()>); + + fn drain(stream: Option) -> Reader { + let (sender, receiver) = channel(); + let handle = std::thread::spawn(move || { + let mut bytes = Vec::new(); + if let Some(stream) = stream { + let _ = stream.take(MAX_OUTPUT + 1).read_to_end(&mut bytes); + } + let _ = sender.send(bytes); + }); + (receiver, handle) + } + + /// Hand the slot to a thread that waits for the child and both readers, then frees it. + fn abandon(slot: Slot, mut child: Child, readers: [Reader; 2]) -> RunError { + std::thread::spawn(move || { + let _ = child.wait(); + for (_, handle) in readers { + let _ = handle.join(); + } + drop(slot); + }); + RunError::Failed + } + + pub(super) fn run(program: &str, args: &[&str], timeout: Duration) -> Result { + let slot = Slot::take().ok_or(RunError::Failed)?; + let deadline = Instant::now() + timeout; + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|_| RunError::Failed)?; + let readers = [drain(child.stdout.take()), drain(child.stderr.take())]; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(5)) + } + _ => return Err(abandon(slot, child, readers)), + } + }; + let mut output = Vec::new(); + for (receiver, _) in &readers { + let left = deadline.saturating_duration_since(Instant::now()); + match receiver.recv_timeout(left) { + Ok(bytes) => output.push(bytes), + Err(_) => return Err(abandon(slot, child, readers)), + } + } + for (_, handle) in readers { + let _ = handle.join(); + } + drop(slot); + let [stdout, stderr] = [&output[0], &output[1]]; + if stdout.len() as u64 > MAX_OUTPUT || stderr.len() as u64 > MAX_OUTPUT { + return Err(RunError::Failed); + } + // Node decodes `encoding: "utf8"` output lossily; so does this. + if status.success() { + return Ok(js_trim(&String::from_utf8_lossy(stdout)).to_string()); + } + match status.code() { + Some(status) => Err(RunError::Exit { + status, + stderr: String::from_utf8_lossy(stderr).into_owned(), + }), + None => Err(RunError::Failed), + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn a_command_past_its_deadline_holds_the_only_slot_until_it_ends() { + let started = Instant::now(); + assert_eq!( + run("/bin/sleep", &["1"], Duration::from_millis(50)), + Err(RunError::Failed) + ); + assert!(started.elapsed() < Duration::from_millis(500)); + // Repeated timeouts cannot add work: nothing runs while the abandoned command lives. + for _ in 0..20 { + assert_eq!( + run("/bin/echo", &["ready"], Duration::from_secs(5)), + Err(RunError::Failed) + ); + } + let freed = Instant::now() + Duration::from_secs(10); + while BUSY.load(Ordering::Acquire) { + assert!( + Instant::now() < freed, + "the abandoned command was never reaped" + ); + std::thread::sleep(Duration::from_millis(20)); + } + assert_eq!( + run("/bin/echo", &["ready"], Duration::from_secs(5)), + Ok("ready".into()) + ); + assert_eq!( + run( + "/bin/sh", + &["-c", "echo nope >&2; exit 113"], + Duration::from_secs(5) + ), + Err(RunError::Exit { + status: 113, + stderr: "nope\n".into() + }) + ); + } + } +} + +#[cfg(target_os = "macos")] +pub use system::{judge, judge_migration}; + +#[cfg(target_os = "macos")] +mod system { + use super::*; + use std::ffi::CString; + use std::os::raw::c_char; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::fs::MetadataExt; + use std::time::Duration; + + /// TypeScript's `run` deadline in `desktop-startup.ts`. + const TIMEOUT: Duration = Duration::from_millis(750); + const X_OK: i32 = 1; + + unsafe extern "C" { + fn getuid() -> u32; + fn access(path: *const c_char, mode: i32) -> i32; + } + + pub struct SystemProbe; + + impl Probe for SystemProbe { + fn identity(&self, path: &Path) -> io::Result { + let meta = std::fs::symlink_metadata(path)?; + Ok(format!( + "{}:{}:{}:{}:{}.{}:{}.{}", + meta.dev(), + meta.ino(), + meta.size(), + meta.mode(), + meta.mtime(), + meta.mtime_nsec(), + meta.ctime(), + meta.ctime_nsec() + )) + } + + fn realpath(&self, path: &Path) -> io::Result { + std::fs::canonicalize(path) + } + + fn executable(&self, path: &Path) -> io::Result<()> { + let path = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| io::Error::from(io::ErrorKind::InvalidInput))?; + // SAFETY: `path` is a NUL-terminated string that outlives the call. + if unsafe { access(path.as_ptr(), X_OK) } == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } + } + + fn run(&self, program: &str, args: &[&str]) -> Result { + command::run(program, args, TIMEOUT) + } + } + + fn current_app() -> Option { + std::fs::canonicalize(std::env::current_exe().ok()?).ok() + } + + /// Decide the launch-origin migration against the live LaunchAgent. Blocking. + pub fn judge_migration(home: &Path) -> Migration { + match current_app() { + Some(app) => migration(&SystemProbe, home, &app), + None => Migration::Leave, + } + } + + /// Read the live registration. Blocking: run off the main thread. + pub fn judge(home: &Path) -> LoginState { + let Some(app) = current_app() else { + return LoginState::Unknown; + }; + // SAFETY: getuid has no preconditions and cannot fail. + let uid = unsafe { getuid() }; + classify(&SystemProbe, home, uid, &app) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + use std::collections::{BTreeMap, BTreeSet}; + + const CASES: &str = + include_str!("../../../tests/fixtures/desktop-login-registration-cases.json"); + const HOME: &str = "/fixture/home"; + const APP: &str = "/fixture/OpenCodex.app/Contents/MacOS/opencodex-desktop"; + const OTHER_APP: &str = "/fixture/Other/OpenCodex.app/Contents/MacOS/opencodex-desktop"; + const MISSING_APP: &str = "/fixture/Gone/OpenCodex.app/Contents/MacOS/opencodex-desktop"; + const PLIST: &str = "/fixture/home/Library/LaunchAgents/OpenCodex.plist"; + const REAL_PLIST: &str = "/fixture/home/Real.plist"; + const OTHER_PLIST: &str = "/fixture/home/Other.plist"; + + fn sibling(app: &str) -> String { + format!("{}/ocx", app.rsplit_once('/').unwrap().0) + } + + struct Fake { + files: BTreeSet, + links: BTreeMap, + plist: Option, + disabled: Option, + disabled_after: Option, + loaded: Result, + touch: bool, + disabled_reads: Cell, + plutil_calls: Cell, + } + + impl Fake { + fn new(plist: Option) -> Self { + let mut files: BTreeSet = [APP, OTHER_APP, OTHER_PLIST] + .into_iter() + .map(String::from) + .collect(); + files.insert(sibling(APP)); + files.insert(sibling(OTHER_APP)); + if plist.is_some() { + files.insert(PLIST.into()); + } + Self { + files, + links: BTreeMap::new(), + plist, + disabled: Some("disabled services = {\n}".into()), + disabled_after: None, + loaded: Err(RunError::Failed), + touch: false, + disabled_reads: Cell::new(0), + plutil_calls: Cell::new(0), + } + } + + fn known(&self, path: &Path) -> Option { + let path = path.to_str()?.to_string(); + (self.files.contains(&path) || self.links.contains_key(&path)).then_some(path) + } + } + + fn not_found() -> io::Error { + io::Error::from(io::ErrorKind::NotFound) + } + + impl Probe for Fake { + fn identity(&self, path: &Path) -> io::Result { + let path = self.known(path).ok_or_else(not_found)?; + let generation = if self.touch && path == PLIST { + self.plutil_calls.get() + } else { + 0 + }; + Ok(format!("{path}#{generation}")) + } + + fn realpath(&self, path: &Path) -> io::Result { + let path = self.known(path).ok_or_else(not_found)?; + Ok(PathBuf::from( + self.links.get(&path).cloned().unwrap_or(path), + )) + } + + fn executable(&self, path: &Path) -> io::Result<()> { + self.known(path).map(|_| ()).ok_or_else(not_found) + } + + fn run(&self, program: &str, args: &[&str]) -> Result { + match (program, args.first().copied()) { + ("/usr/bin/plutil", _) => { + self.plutil_calls.set(self.plutil_calls.get() + 1); + self.plist + .as_ref() + .map(Value::to_string) + .ok_or(RunError::Failed) + } + ("/bin/launchctl", Some("print-disabled")) => { + let reads = self.disabled_reads.get() + 1; + self.disabled_reads.set(reads); + let after = self.disabled_after.as_ref().filter(|_| reads > 1); + after + .or(self.disabled.as_ref()) + .cloned() + .ok_or(RunError::Failed) + } + ("/bin/launchctl", Some("print")) => self.loaded.clone(), + other => panic!("unexpected command {other:?}"), + } + } + } + + fn fill(text: &str) -> String { + text.replace("{APP}", APP) + .replace("{OTHER_APP}", OTHER_APP) + .replace("{MISSING_APP}", MISSING_APP) + .replace("{PLIST}", PLIST) + .replace("{OTHER_PLIST}", OTHER_PLIST) + .replace("{UID}", "501") + } + + fn fake_for(case: &Value) -> Fake { + let case: Value = serde_json::from_str(&fill(&case.to_string())).unwrap(); + let plist = case.get("plist").filter(|plist| !plist.is_null()).cloned(); + let present = plist.is_some(); + let mut fake = Fake::new(plist); + if case.get("plutilFails") == Some(&Value::Bool(true)) { + fake.plist = None; + } + if case.get("proxy") == Some(&Value::Bool(false)) { + fake.files.remove(&sibling(APP)); + } + if present && case.get("plistLink") == Some(&Value::Bool(true)) { + fake.files.remove(PLIST); + fake.files.insert(REAL_PLIST.into()); + fake.links.insert(PLIST.into(), REAL_PLIST.into()); + } + fake.touch = case.get("touchPlistOnRead") == Some(&Value::Bool(true)); + fake.disabled = case["disabled"].as_str().map(String::from); + fake.disabled_after = case + .get("disabledAfterFirstRead") + .and_then(Value::as_str) + .map(String::from); + let loaded = &case["loaded"]; + fake.loaded = if let Some(stdout) = loaded.get("stdout").and_then(Value::as_str) { + Ok(stdout.into()) + } else if let (Some(status), None) = (loaded.get("status"), loaded.get("signal")) { + Err(RunError::Exit { + status: status.as_i64().unwrap() as i32, + stderr: loaded["stderr"].as_str().unwrap().into(), + }) + } else { + Err(RunError::Failed) + }; + fake + } + + fn name(state: LoginState) -> &'static str { + match state { + LoginState::On => "on", + LoginState::Off => "off", + LoginState::DisabledBySystem => "disabled-by-system", + LoginState::OtherCopy => "other-copy", + LoginState::MissingCopy => "missing-copy", + LoginState::Unknown => "unknown", + } + } + + #[test] + fn shared_fixture_matches_startup_safety() { + let fixture: Value = serde_json::from_str(CASES).unwrap(); + let cases = fixture["cases"].as_array().unwrap(); + assert!(cases.len() > 40); + for case in cases { + let label = case["name"].as_str().unwrap(); + let fake = fake_for(case); + let state = classify(&fake, Path::new(HOME), 501, Path::new(APP)); + assert_eq!(name(state), case["expect"]["tray"], "{label}"); + assert_eq!( + Some(state.checked()), + case["expect"]["loginEnabled"].as_bool(), + "{label}" + ); + } + } + + #[test] + fn only_a_verified_registration_is_checked_or_rewritable() { + let table = [ + (LoginState::On, true, ClickAction::Disable, "Start at Login"), + ( + LoginState::Off, + false, + ClickAction::Enable, + "Start at Login", + ), + ( + LoginState::DisabledBySystem, + false, + ClickAction::OpenLoginItems, + "Start at Login (disabled by macOS)", + ), + ( + LoginState::OtherCopy, + false, + ClickAction::Nothing, + "Start at Login (another OpenCodex copy)", + ), + ( + LoginState::MissingCopy, + false, + ClickAction::Nothing, + "Start at Login (registered app is missing)", + ), + ( + LoginState::Unknown, + false, + ClickAction::Nothing, + "Start at Login (status unknown)", + ), + ]; + for (state, checked, action, label) in table { + assert_eq!(state.checked(), checked, "{state:?}"); + assert_eq!(state.click_action(), action, "{state:?}"); + assert_eq!( + state.clickable(), + action != ClickAction::Nothing, + "{state:?}" + ); + assert_eq!(state.label(), label, "{state:?}"); + } + } + + #[test] + fn migration_rewrites_only_this_copys_own_enabled_entry() { + let entry = |extra: Value| { + let mut plist = serde_json::json!({ + "Label": "OpenCodex", + "RunAtLoad": true, + "ProgramArguments": [APP], + }); + for (key, value) in extra.as_object().unwrap() { + plist[key] = value.clone(); + } + Some(plist) + }; + let cases = [ + ("absent", None, Migration::Absent), + ( + "legacy entry", + entry(serde_json::json!({})), + Migration::Rewrite, + ), + ( + "current arguments", + entry(serde_json::json!({ "ProgramArguments": [APP, "--autostart"] })), + Migration::Rewrite, + ), + ( + "Program equal", + entry(serde_json::json!({ "Program": APP })), + Migration::Leave, + ), + ( + "Disabled false", + entry(serde_json::json!({ "Disabled": false })), + Migration::Leave, + ), + ( + "a key the rewrite would drop", + entry(serde_json::json!({ "KeepAlive": true })), + Migration::Leave, + ), + ( + "another copy", + entry(serde_json::json!({ "ProgramArguments": [OTHER_APP] })), + Migration::Leave, + ), + ( + "missing copy", + entry(serde_json::json!({ "ProgramArguments": [MISSING_APP, "--autostart"] })), + Migration::Leave, + ), + ( + "Program names another copy", + entry(serde_json::json!({ "Program": OTHER_APP })), + Migration::Leave, + ), + ( + "Program names a missing copy", + entry(serde_json::json!({ "Program": MISSING_APP })), + Migration::Leave, + ), + ( + "disabled by its own definition", + entry(serde_json::json!({ "Disabled": true })), + Migration::Leave, + ), + ( + "Disabled is not a boolean", + entry(serde_json::json!({ "Disabled": "yes" })), + Migration::Leave, + ), + ( + "unknown argument", + entry(serde_json::json!({ "ProgramArguments": [APP, "--hidden"] })), + Migration::Leave, + ), + ( + "extra argument", + entry(serde_json::json!({ "ProgramArguments": [APP, "--autostart", "x"] })), + Migration::Leave, + ), + ( + "another label", + entry(serde_json::json!({ "Label": "com.example.OpenCodex" })), + Migration::Leave, + ), + ( + "RunAtLoad false", + entry(serde_json::json!({ "RunAtLoad": false })), + Migration::Leave, + ), + ]; + for (label, plist, expected) in cases { + let fake = Fake::new(plist); + assert_eq!( + migration(&fake, Path::new(HOME), Path::new(APP)), + expected, + "{label}" + ); + } + let mut unreadable = Fake::new(entry(serde_json::json!({}))); + unreadable.plist = None; + assert_eq!( + migration(&unreadable, Path::new(HOME), Path::new(APP)), + Migration::Leave + ); + // Another copy replacing the plist while it is read leaves it alone. + let mut replaced = Fake::new(entry(serde_json::json!({}))); + replaced.touch = true; + assert_eq!( + migration(&replaced, Path::new(HOME), Path::new(APP)), + Migration::Leave + ); + } + + #[test] + fn javascript_whitespace_and_line_terminators() { + assert_eq!(js_trim("\u{feff}\u{a0} x \u{3000}"), "x"); + assert_eq!(js_trim("\u{85}x"), "\u{85}x"); + assert_eq!( + line_value("a\u{2028}program = /x\u{2029}y", "program = "), + Some("/x") + ); + assert_eq!( + line_value("program = \rprogram = /y", "program = "), + Some("/y") + ); + } +} diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs index e8e76697ec9..c702bc7c240 100644 --- a/desktop/src-tauri/src/tray.rs +++ b/desktop/src-tauri/src/tray.rs @@ -14,6 +14,7 @@ use tauri::{ tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, AppHandle, Manager, Wry, }; +#[cfg(not(target_os = "macos"))] use tauri_plugin_autostart::ManagerExt; use tauri_plugin_opener::OpenerExt; @@ -28,6 +29,8 @@ pub struct TrayMenu { check_updates: MenuItem, install_update: MenuItem, stop: MenuItem, + #[cfg(target_os = "macos")] + pub(crate) login: CheckMenuItem, } impl Default for TrayState { @@ -79,6 +82,10 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { true, None::<&str>, )?; + // macOS shows only what Startup safety would verify, and probes it off the main thread. + #[cfg(target_os = "macos")] + let login = crate::tray_login::item(app)?; + #[cfg(not(target_os = "macos"))] let login = CheckMenuItem::with_id( app, "start-at-login", @@ -133,8 +140,12 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { check_updates: check_updates.clone(), install_update: install_update.clone(), stop: stop.clone(), + #[cfg(target_os = "macos")] + login: login.clone(), }); } + #[cfg(target_os = "macos")] + crate::tray_login::refresh(app); let builder = TrayIconBuilder::with_id("main") .icon(icon()) @@ -151,6 +162,11 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { let builder = builder.show_menu_on_left_click(false); let tray = builder .on_tray_icon_event(|tray, event| { + #[cfg(target_os = "macos")] + if let TrayIconEvent::Enter { .. } = event { + crate::tray_login::hover(tray.app_handle()); + return; + } if let TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, @@ -213,11 +229,16 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { .open_url(format!("{}#/usage", endpoint.url("/")), None::); } "start-at-login" => { - let enabled = app.autolaunch().is_enabled().unwrap_or(false); - if enabled { - let _ = app.autolaunch().disable(); - } else { - let _ = crate::login_autostart::enable(app); + #[cfg(target_os = "macos")] + crate::tray_login::click(app); + #[cfg(not(target_os = "macos"))] + { + let enabled = app.autolaunch().is_enabled().unwrap_or(false); + if enabled { + let _ = app.autolaunch().disable(); + } else { + let _ = crate::login_autostart::enable(app); + } } } "stop-proxy" => { @@ -254,6 +275,8 @@ pub fn install(app: &AppHandle) -> tauri::Result<()> { let mut tick = 0; loop { tokio::time::sleep(std::time::Duration::from_secs(60)).await; + #[cfg(target_os = "macos")] + crate::tray_login::refresh(&app); let Some(proxy) = app .try_state::() .and_then(|state| state.proxy()) @@ -287,7 +310,7 @@ fn refresh(app: &AppHandle, tray: &tauri::tray::TrayIcon) { /// the main thread and takes this same mutex while doing so, so calling a setter with the lock held /// is a cycle: a background update owns the mutex and waits for the main thread, and the main /// thread waits for the mutex. The app would stop answering Quit. -fn menu_handles(app: &AppHandle) -> Option { +pub(crate) fn menu_handles(app: &AppHandle) -> Option { let state = app.try_state::()?; let handles = state.menu.lock().ok()?; handles.as_ref().cloned() diff --git a/desktop/src-tauri/src/tray_login.rs b/desktop/src-tauri/src/tray_login.rs new file mode 100644 index 00000000000..5126c1f44e7 --- /dev/null +++ b/desktop/src-tauri/src/tray_login.rs @@ -0,0 +1,163 @@ +//! The macOS tray's Start at Login item (#6972). +//! +//! The item shows what [`login_registration::judge`] verifies, the judgment Startup safety uses, +//! and is checked only for [`LoginState::On`]. Reading launchd runs subprocesses, so every probe +//! runs on a blocking worker; one mutex serializes probes with the mutation a click performs. +//! +//! Presentation is applied in one closure on the main thread, the thread that also delivers +//! clicks, so a click and a refresh can never interleave their setters. muda flips a check item +//! before the click reaches the app; the click handler puts back what was shown and holds the +//! item disabled until its own job answers, and refresh results that land meanwhile are dropped. + +use crate::login_registration::{self, ClickAction, LoginState}; +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Mutex, PoisonError, +}; +use std::time::{Duration, Instant}; +use tauri::{menu::CheckMenuItem, AppHandle, Manager, Wry}; +use tauri_plugin_autostart::ManagerExt; +use tauri_plugin_opener::OpenerExt; + +const HOVER_INTERVAL: Duration = Duration::from_secs(2); + +#[derive(Default)] +pub struct LoginTray { + /// The state the item shows; `None` until the first probe answers. + shown: Mutex>, + /// Clicks whose job has not applied its answer yet. + pending: AtomicUsize, + /// Serializes probes and the mutation a click performs. + probe: Mutex<()>, + /// When hovering last asked for a refresh. + hovered: Mutex>, +} + +/// The item as built, before anything has been verified: unchecked and inert. +pub fn item(app: &AppHandle) -> tauri::Result> { + app.manage(LoginTray::default()); + let state = LoginState::Unknown; + CheckMenuItem::with_id( + app, + "start-at-login", + state.label(), + state.clickable(), + state.checked(), + None::<&str>, + ) +} + +fn judge(app: &AppHandle) -> LoginState { + match app.path().home_dir() { + Ok(home) => login_registration::judge(&home), + Err(_) => LoginState::Unknown, + } +} + +/// Show `state`. A refresh (`click == false`) yields to any click still waiting for its answer. +fn present(app: &AppHandle, state: LoginState, click: bool) { + let handle = app.clone(); + let _ = app.run_on_main_thread(move || { + let Some(login) = handle.try_state::() else { + return; + }; + if click { + login.pending.fetch_sub(1, Ordering::AcqRel); + } else if login.pending.load(Ordering::Acquire) > 0 { + return; + } + *login.shown.lock().unwrap_or_else(PoisonError::into_inner) = Some(state); + let busy = login.pending.load(Ordering::Acquire) > 0; + if let Some(menu) = crate::tray::menu_handles(&handle) { + let _ = menu.login.set_text(state.label()); + let _ = menu.login.set_checked(state.checked()); + let _ = menu.login.set_enabled(state.clickable() && !busy); + } + }); +} + +/// Probe again unless a probe or a click is already running. +pub fn refresh(app: &AppHandle) { + let app = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + let Some(login) = app.try_state::() else { + return; + }; + let Ok(_probe) = login.probe.try_lock() else { + return; + }; + let state = judge(&app); + present(&app, state, false); + }); +} + +/// The pointer entered the status item: the menu may be about to open. +pub fn hover(app: &AppHandle) { + let Some(login) = app.try_state::() else { + return; + }; + { + let mut hovered = login.hovered.lock().unwrap_or_else(PoisonError::into_inner); + let now = Instant::now(); + if hovered.is_some_and(|last| now.duration_since(last) < HOVER_INTERVAL) { + return; + } + *hovered = Some(now); + } + refresh(app); +} + +/// Handle a click on the item. Runs on the main thread and never waits for a probe. +pub fn click(app: &AppHandle) { + let Some(login) = app.try_state::() else { + return; + }; + let shown = login + .shown + .lock() + .unwrap_or_else(PoisonError::into_inner) + .unwrap_or(LoginState::Unknown); + login.pending.fetch_add(1, Ordering::AcqRel); + if let Some(menu) = crate::tray::menu_handles(app) { + let _ = menu.login.set_checked(shown.checked()); + let _ = menu.login.set_enabled(false); + } + let app = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + let state = match app.try_state::() { + Some(login) => { + let _probe = login.probe.lock().unwrap_or_else(PoisonError::into_inner); + let fresh = judge(&app); + // The click answered what was shown. If the registration has changed since, the + // person has not seen the new state yet: show it and do nothing else. + if fresh == shown && fresh.click_action() != ClickAction::Nothing { + perform(&app, fresh.click_action()); + judge(&app) + } else { + fresh + } + } + None => LoginState::Unknown, + }; + present(&app, state, true); + }); +} + +fn perform(app: &AppHandle, action: ClickAction) { + let result = match action { + ClickAction::Disable => app + .autolaunch() + .disable() + .map_err(|error| error.to_string()), + ClickAction::Enable => crate::login_autostart::enable(app), + // launchd keeps its override whatever the plist says; the person turns it back on. + ClickAction::OpenLoginItems => app + .opener() + .open_url(login_registration::LOGIN_ITEMS_SETTINGS, None::) + .map_err(|error| error.to_string()), + ClickAction::Nothing => Ok(()), + }; + if let Err(error) = result { + crate::logging::log_once("start at login", &error); + } +} diff --git a/devlog/_plan/261012_4956_spawnsync_exit_stall/000_plan.md b/devlog/_plan/261012_4956_spawnsync_exit_stall/000_plan.md new file mode 100644 index 00000000000..9a7eef39518 --- /dev/null +++ b/devlog/_plan/261012_4956_spawnsync_exit_stall/000_plan.md @@ -0,0 +1,27 @@ +# #4956 — Bun 1.4.0 spawnSync loses a child exit inside multi-file `bun test --isolate` + +## Evidence + +- All three hosted stalls of Linux shard 1/4 batch 5/57 (run 38162375606 attempts 1 and 2, job 114537864404 / 114545178864; PR run 38146205183 job 114490736681) stop at the same test: `tests/ci-workflows/release-version-sources.test.ts` "the check CLI fails naming the drifted source and passes when they agree". Five tests of that file printed; its sixth never did and no summary line followed. The issue comment's "every test passed, then Bun failed to exit" misreads the log. +- That test calls `Bun.spawnSync([bun, cli], { timeout: 15000, killSignal: "SIGKILL" })` several times. About 112 s passed between the last printed result and the 120 s batch kill, far beyond the 15 s bound, so the child-process timeout did not return. The hosted log alone does not say which call stalled; the scratch reproduction below does. +- Bun 1.4.0 (34cbb9a40) `spawn_maybe_sync` waits on a private uWS loop and, for the call's duration, repoints `vm.event_loop_handle` at it. A GC finalizer that runs during the call (an earlier file's `process.stderr` sink, a pipe reader, a `KeepAlive`) resolves its loop through the VM and decrements the private loop's `num_polls`. That loop is cached for the life of the process. Once the drift cancels a later call's polls, `us_loop_run_bun_tick` returns without calling `epoll_wait`/`kevent`, the child's exit poll is never read, and after the timeout fires the following ticks carry no deadline: the call spins at 100% CPU forever. Upstream: oven-sh/bun#44581 (merged 2026-10-05, `13a98b0dbd`, fixes oven-sh/bun#34069), first released in Bun v1.4.3 (2026-10-10). +- Local reproduction (macOS arm64) with upstream's fixture, one finalized `Bun.stderr` writer, stderr piped, `BUN_JSC_slowPathAllocsBetweenGCs=3`, inner spawnSync timeout 2 s, outer 6 s alarm: Bun 1.4.0 hung past its own timeout in 10/12 runs at 100% CPU; Bun 1.4.3 returned the child's output in 12/12 (~115 ms). With three writers 1.4.0 returns only at the timeout with empty output (3/3), 1.4.3 returns output (3/3). +- Composition dependence: in the suite the finalized objects come from earlier files in the same process (`--isolate` retires realms but keeps the VM, the FilePoll hive and the cached private loop), which can explain why each file alone passes and attribution sweeps stay clean. The mechanism itself does not need other files: a single script reproduces it. +- Hosted reproduction (scratch branch `codex/4956-diag-repro`, not for merge): 450 isolated runs of the 12-file batch never stalled (run 38213203939), but running shard 1/4 batches 1-5 in sequence like CI did, on Bun 1.4.0 (run 38213764818): 8 of 252 batch-5 runs stalled at the same test. Every dump shows the test process main thread in state R with no wait channel, symbolized as `spawn_maybe_sync` (js_bun_spawn_bindings.rs:1982/1956) -> `tick_with_timeout` -> `tick_tasks_only`; the CLI child is a zombie whose parent is the test process; its pidfd is registered level-triggered (`events: 19`) in spawnSync's private epoll and readable, yet never waited on. That state is consistent with the `num_polls == 0` early return #44581 removes (the dumps show the loop never waiting, not the counter itself). Hosted 1.4.3 A/B: run 38215466714. + +## Change + +1. `package.json` `testRunnerBun`: 1.4.0 → 1.4.3. The shipped runtime (`dependencies.bun` 1.4.2) is unchanged; unifying it is a product decision outside this fix. +2. Regression test `tests/ci-workflows/test-runner-spawnsync-finalizer.test.ts` + child `tests/fixtures/spawnsync-finalizer-drift-child.ts`: runs upstream's mechanism in a child of the test-runner Bun with stderr piped, for one and three finalized writers, and asserts the child saw every writer collected during the first call and that the second call returned the grandchild's output without timing out. The parent never blocks: a 10 s deadline kills the child, a 3 s reap grace follows, and the case settles whether or not the exit or either pipe EOF arrives (unref on expiry, kill failure ignored, timers cleared on every path). Three writers is the deterministic red oracle on 1.4.0; one writer exercises the unbounded spin. Skipped on Windows like upstream's case (POSIX mechanism, uses `sh`/`true`). +3. `tests/ci-workflows/install-scripts.test.ts` asserts the exact pin (`testRunnerBun` 1.4.0 -> 1.4.3) and its comment keeps the 1.4.2 history and adds this reason. +4. Rationale text where the pin is explained: `.github/actions/setup-project-bun/action.yml`, `.github/workflows/ci.yml` Bun-roles comment, `structure/runtime.md`, `CONTRIBUTING.md`, `docs-site/src/content/docs/contributing.md`. Injected 1.4.0 fixture values in `test-runner-bun.test.ts` and `ci-scope-gaps.test.ts` and transport minimums stay as they are. +5. Layout registration in `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`. + +## Risks and gates + +- 1.4.2's multi-file `--isolate` preload segfault (#4821, #6713) is the reason for the pin. 1.4.3 must show no runtime crash across Cross-platform CI `lane=all` (Linux 4 shards, macOS 2, Windows 9, serial families) at the exact PR head. If it reappears, this patch is abandoned and re-planned: the runner pin, the regression contract (which correctly fails on 1.4.0) and a workaround proof (async spawn with a bounded kill at the spawnSync-with-timeout fixtures) move together. Restoring 1.4.0 while skipping or weakening the regression is not an acceptable fallback. +- Version-coupled tests (`tests/test-layout-tooling.test.ts` asserts `Bun.version === testRunnerBun`) follow automatically. + +## Verification + +`bun run typecheck`; focused tests under 1.4.3 via `OCX_TEST_RUNNER_BUN` (new regression red on 1.4.0 / green on 1.4.3, 20 fresh children per case; install-scripts, test-runner-bun, ci-scope-gaps, docs-bun-source-requirement, both layout guards, cold-spawn-warmup); bounded-settlement fault cases (never-exiting child, grandchild holding pipes) in scratch; the 12-file batch repeated under 1.4.3; hosted 1.4.3 sequence A/B; `bun run test` under 1.4.3; `bun run structure:check`; `bun run privacy:scan`; exact-head `lane=all` inspected per batch for preload crashes and missing summaries. Workflow/dependency-pin surfaces need the maintainer security review named in MAINTAINERS.md before merge. diff --git a/devlog/_plan/261012_6969_pool_overload_failover/000_plan.md b/devlog/_plan/261012_6969_pool_overload_failover/000_plan.md new file mode 100644 index 00000000000..230904908d0 --- /dev/null +++ b/devlog/_plan/261012_6969_pool_overload_failover/000_plan.md @@ -0,0 +1,70 @@ +# 6969 — pooled native Codex: retry and fail over on a pre-output upstream overload + +Issue: [#6969](https://github.com/lidge-jun/opencodex/issues/6969). Base: `origin/dev` `dc45bb2ca9`. + +## Symptom + +Pooled native Codex requests to `gpt-6.1-sol` end after one send when the ChatGPT backend +declines before any output with `server_is_overloaded` (503) or `upstream_server_error` (502): +`sendCount: 1`, `recoveryKinds: []`, `failureStage: protocol-prelude`. The pool neither retries +the account nor moves to another one, so the Codex client reports "Selected model is at capacity". + +## Field evidence (content-free stage fields only) + +Aggregated from the reporter's own request log (status, error code, `codexWsStage` counters, +failure stage/cause; no bodies, headers or account identifiers), OpenCodex 2.83.0: + +| Shape | Rows | +| --- | --- | +| WS, `relayedEvents: 3`, `controlFrames: 2`, `protocol-prelude`, 502 `upstream-fault` | 213 | +| WS, `relayedEvents: 1` (error is the first Responses event), 502 `upstream-fault` | 47 | +| WS, `relayedEvents: 3`, 503 `server_is_overloaded`, `upstream-declined` | 20 | + +One further 502 `upstream-fault` row relayed 336 events (output had started) and is an outlier +kept out of the shapes above. Every row carries a `codexWsStage`, so every observed failure rode the +upstream WebSocket. The counters are consistent with `response.created`, `response.in_progress` and a +terminal `error` frame, but they count frames, not event types, and cannot prove which events those +were or that no hosted tool ran. They explain the failure; they authorize nothing — replay +eligibility is decided from the frames themselves at run time (010). Time from the first Responses event to that refusal, over the +281 authoritative refusals (`upstream-declined` and `upstream-fault`): 70% within 2 s, 97.5% within +5 s, 98.2% within 10 s, and no further gain up to 60 s. + +## Why each path misses the existing recovery + +The recovery already exists: `fetchWithTransientRetry` (`src/lib/upstream-retry.ts`) retries a +transient 5xx on the same account up to three sends with capped, jittered, abort-aware backoff +against the request's shared send budget, and the pool block in `passthrough-dispatch.ts` moves a +surviving `shouldRetryCodexPoolAccountTransient` 5xx to one alternate account +(`retryCodexPoolOnAlternateAccount`, guarded by fixed accounts, a spent stored-401 budget, uploaded +files, the account-failover permit and the pool recovery window). Both read the HTTP status. + +- **WebSocket (all field rows).** `codexWsExchange` resolves a 200 SSE Response at the first + non-control frame (`commitResponse`). `wrappedRejectionResponse` projects only a pre-commit + **4xx** `error` frame into an HTTP status, by design ("unlike its native client, this relay + converts only precommit 4xx"). So a 5xx `error` frame — first, or after an empty + `response.created`/`response.in_progress` preamble — is relayed inside a committed 200 body. No + retry layer sees a 5xx; the failure is only classified later from the SSE terminal. +- **HTTP status 5xx (HTTP SSE/JSON).** Handled: the same-account ladder runs, then the pool move. +- **HTTP SSE in-stream zero-output failure** (200 head, then `response.failed`/`error`). Not + retried; direct passthrough returns headers before the first event. No field evidence; out of + scope here and recorded as residual. +- **Compact (`/v1/responses/compact`).** `compact.ts` runs the same-account ladder on an HTTP 5xx, + but its alternate-account move recognises only 429/402, so a 5xx that survives the ladder never + moves accounts. + +## What is deliberately not replayed + +Ingwannu's triage separates an authoritative terminal refusal from socket death or silence after +the send. The latter are settled as non-replayable gateway statuses +(`codexWsPreResponseFailure`) because the origin may still be running the turn; only an operator +`retryOnReset` grant may replace them. That boundary does not move. A refusal frame is different +evidence: the origin itself terminated that response with a structured server status, and the +native Codex client treats the same wrapped frame as a terminal HTTP error even after +`response.created`. That is client-visible terminality, not a backend proof that nothing ran, so +010 replays only when every relayed event was an empty lifecycle frame and the terminal carries no +output — nothing the client could have seen or acted on. + +## Unit map + +- `010_plan.md` — the single implementation work-phase (wp1), diff level. + diff --git a/devlog/_plan/261012_6969_pool_overload_failover/010_plan.md b/devlog/_plan/261012_6969_pool_overload_failover/010_plan.md new file mode 100644 index 00000000000..afc2cc974d2 --- /dev/null +++ b/devlog/_plan/261012_6969_pool_overload_failover/010_plan.md @@ -0,0 +1,217 @@ +# 010 — wp1: recover a pre-output upstream refusal on the native Codex pool + +Research and evidence: [000_plan.md](000_plan.md). Base `dc45bb2ca9`. + +## Loop-spec header + +- Class: C3 (transport contract change on a shared path; no persistence or credential change). +- Outcome: an authoritative upstream 5xx refusal that arrives on the Codex WebSocket before any + output reaches the existing same-account transient ladder and the pool alternate-account move; + compact moves accounts on a surviving transient 5xx. Exhausted recovery delivers exactly what the + client receives today. +- Tool/credential scope: local checkout, isolated temp homes and fake sockets only. No live + `~/.opencodex`, :10100 service, Codex app or GOAT server. Write scope: the files below. +- Resource bounds: none stated; host limits apply. +- Terminal outcomes: DONE (PR open, review PASS, exact-head CI green), BLOCKED/NEEDS_HUMAN only for + secrets, cost, publication or permission decisions. + +## Architect consultation (round 1, gpt-6.1-sol high) and dispositions + +| ID | Proposal | Disposition | +| --- | --- | --- | +| D1 | Internal opt-in threaded ProviderFetchOptions → codexWsUpstreamFetch → codexWsExchange; canonical metadata exchanges only; no native control, sidecars or combos-by-accident | Accepted. Enabled only on Pool forward sends (`usesCodexForwardPoolAuth`), fixed Pool accounts included (same-account ladder only). | +| D2 | Hold only structurally empty lifecycle frames, check every held snapshot; freeze prelude headers and `metadata.commit()` at the first Responses event; non-resetting 10 s bound | Accepted. Bound = `min(10_000, floor(connectMs / 2))`. | +| D3 | Strict refusal identity: correlation first, no foreign `stream_id`, empty preamble and empty terminal output, explicit structured evidence only; no message-text authorization | Accepted. See "Refusal identity". | +| D4 | Commit before failing only when a Responses preamble was actually held | Accepted. | +| D5 | Restore immediately after the Pool block on every loop pass, carrying WS/quota markers and the same stage object; single use | Accepted. Affinity diagnostics (captured before the Pool block) record the true refusal status; they are diagnostics only. | +| D6 | Compact: transient predicate, guards kept, quota precedence kept, admission secured before cancelling A, no promise of B promotion | Accepted. | +| D7 | Exactly-once health outcome: unmoved projection must not be recorded both in the retry helper and by the restored stream's terminal recorder | Accepted as the exactly-once marker (audit dispositions 2 and 4). | +| D8 | Amend the "only precommit 4xx" comment and the transport contract doc | Accepted. | +| Alt | Route Pool over HTTP | Rejected: loses WS latency, still misses HTTP in-stream refusals. | + +## Refusal identity (codex-ws-wire.ts, new `codexWsPreOutputRefusalStatus(payload)`) + +Returns a status only for: + +1. `type: "error"`, no `stream_id`, exactly one integer wrapper alias (`codexWsWrapperStatus`) with + `isTransientUpstreamStatus`; or no wrapper alias at all and `recognizedUpstreamError(error)` is the + overload class (503). +2. `type: "response.failed"` whose `response.output` is absent or empty and whose + `response.error` is the recognized overload class (503). + +Never for: a rate-limit/quota code (`recognizedUpstreamError` 429, `insufficient_quota`, +`usage_limit_reached`, `rate_limit_*`), a message that `isRateLimitOrQuotaFailureMessage` reads as +quota, a 4xx, competing aliases, a malformed `error` member. + +## Reflection round 2 (MISALIGNED, narrow) — corrections folded + +1. The whole new refusal path, first-frame and held, runs only under `holdEnabled && sent && !responseCommitted`; opt-out and native-control exchanges are byte-identical to today. +2. A replay-eligibility latch, independent of correlation being present: it drops on any frame carrying a `stream_id`, on a lifecycle or `response.failed` frame whose `response.id` differs from the first held id, and on any non-empty snapshot. Once dropped, the exchange commits and behaves as today. Cold, reused and one-shot sockets all apply it. +3. First-frame and held refusals settle identically: the terminal frame goes through today's frame-size and queue checks, is recorded as `terminalEvent`, enqueued, the stream closed, the session released as a failed terminal, and only then the projection resolves. A restore therefore always carries the full original bytes. +4. Tests add: foreign id on a cold socket, metadata during the hold (restored headers equal the frozen prelude), queue overflow during the hold, denied alternate admission keeping the original body, single health/quota record, combo consumer receiving restored SSE. + +## Audit round 1 (FAIL, 3 blockers) — dispositions + +1. **Foreign terminal on a cold socket (accepted).** The latch drops on every identity signal + `CodexWsCorrelation.accept` reads, independent of `strict`: any `stream_id`; a lifecycle or + `response.failed` frame whose `response.id` or top-level `response_id` differs from the first held + `response.created` id; a frame carrying two different ids; an `error` frame whose `response_id` + differs from the held id. A refusal with no held id may carry at most one id and no `stream_id`. + Test: held id A, then `response.failed` with `response.id = A`, `response_id = B` → one send. +2. **Late alternate-admission denial (accepted; revised after audit round 2).** The helper's + order is NOT changed (moving A's record/identity after admission charged B's permit under A's + spend identity and lost A's outcome on the credits-off exit). Instead an exactly-once marker: + `noteCodexWsPreOutputRefusalOutcome(projection)` is set whenever the helper records A's outcome for + a projection (`recordFirstOutcome`, `recordUnmovedTransientOutcome`); `recordUnmovedTransientOutcome` + skips a projection already marked (late window/permit denial after `recordFirstOutcome`); the + restore carries the marker to the restored response; delivery's terminal recorder skips the + health write for a marked restored response while still running its other terminal callbacks. + An unmarked restored projection (fixed account, non-Pool forward account, no move attempted) is + recorded once by delivery. Credits-off keeps today's order: A recorded, then the policy refusal. + Residual, pre-existing and shared with the HTTP 5xx path: after a late denial the attempt's log + identity already names B while A's (restored) bytes are delivered; health is attributed to A. + Tests: no alternate available, window denial → one A outcome, A's bytes, zero B sends. +3. **Compact admission and spend identity (accepted, without an early reservation).** The transient + arm switches `logCtx.accountLogLabel`/`outcomeCtx` to B and runs `sendSingleCompactAttempt` for B + with A's body still uncancelled, so every budget/spend check runs under B's identity. A + `SendBudgetExhaustedError` from that helper is always pre-send; on it the arm restores A's label + and context, releases B's probe lease and continues with A's response untouched (recorded once at + the end as today). After B is actually sent (success, HTTP failure or transport failure) A is + recorded with its real 5xx and its body cancelled. `permit.use()` failure inside the helper now + releases its reservation. The quota arm keeps its current order. Tests: B admitted → B serves; + B refused (budget spent) → A's 503 body and one A outcome. + +4. **Abort or local refusal during the same-account backoff (audit rounds 3-4, accepted).** The + initial-send closure in `passthrough-dispatch.ts` keeps the last projection it observed together + with the account context that produced it. One owner per exceptional exit of + `fetchWithTransientRetry`: + - caller abort (`options.abortSignal`/`req.signal` aborted) or `SendBudgetExhaustedError`: + `transportFailureResponse` writes no account health on these exits (it returns at its first two + branches), so the dispatch catch records the unmarked projection once as its real 5xx against + the producing account, marks it, then calls `transportFailureResponse` unchanged; + - any other exception (a later send's timeout/connection error wrapped in + `UpstreamRetryEvidenceError`): the dispatch catch writes nothing and `transportFailureResponse` + remains the single writer, as today. + Recording happens before `transportFailureResponse` releases the probe lease. Tests: abort during + the first backoff → no second socket, exactly one A transient write; 503 then timeout and 503 + then connection error → exactly one A write. +5. **Compact verification matrix (audit rounds 3-4, accepted; all mandatory).** B served from + remaining base sends; B admitted from the final recovery reserve after A's three sends; B refused by + a spent budget (A's body and outcome intact, B's probe released, zero B sends); reservation granted + then `permit.use()` fails → reservation released; caller cancellation while B is in flight; unequal + A/B identity balances on both the base and the reserve path (B's identity governs admission), using + the ledger fixtures from `tests/lib/execution-budget-permits.test.ts`. + +Notes taken: the 10 s hold starts at the first lifecycle event and `fetchWithTransientRetry` +returns an attempt slower than 15 s as-is, so "three same-account sends" holds for refusals within +that budget (tests use an immediate refusal and one slow case). Combo children +(`options.comboAttempt`) do not opt in — their preflight already hops on a zero-output terminal. +401-refresh and rebuild legs do not opt in (documented limitation). Tests assert the restored +attempt's `sendCount`, `recoveryKinds` and `codexWsStage` counters. + +## File change map + +### MODIFY `src/server/responses/codex-ws-wire.ts` +- ADD `codexWsPreOutputRefusalStatus(payload)` (above). +- ADD `isEmptyCodexWsLifecycleEvent(payload)`: `response.created` / `response.in_progress` / + `response.queued` whose `response` is an object with `output` absent or `[]`. +- ADD restore registry: `markCodexWsPreOutputRefusal(projection, restore)`, + `isCodexWsPreOutputRefusal(response)`, `restoreCodexWsPreOutputRefusal(response)` (single use; + returns the response unchanged when not a projection). + +### MODIFY `src/server/responses/codex-ws-exchange.ts` +- ExchangeOptions: `preOutputRefusalHoldMs?: number`. +- `holdEnabled = hold > 0 && metadata && !nativeControl`. +- On a non-control frame while `sent && !responseCommitted`, after correlation: + - first frame is a refusal → project (no preamble). + - `isEmptyCodexWsLifecycleEvent` and holdEnabled → on the first held event freeze + `heldHeaders = metadata.snapshot()`, `metadata.commit()`, arm one non-resetting hold timer + (`commitResponse` on expiry); enqueue the frame; do not resolve. + - held preamble and the frame is a refusal → enqueue, close, `session.release(null)`-style + dispose, resolve the projection. + - anything else → `commitResponse()` (uses `heldHeaders` when present), then today's code. + - The existing pre-commit 4xx projection only runs when nothing was held (today's boundary). +- Projection: JSON `{error:{type,code,message}}` from the frame (bounded via existing + `rejectionHeaders`), the frame's status, `markCodexWsPreludeProjection`, stage record, NOT + non-replayable; restore builds the 200 SSE over the same closed stream with the markers + `commitResponse` sets and the same stage object. +- `failStream` / `cancelExchange`: when a preamble is held, `commitResponse()` first so the + settle is today's committed body failure (no socket-death marker, no new 502/504). +- Clear the hold timer in `cleanup` and `commitResponse`. Amend the "only precommit 4xx" comment. + +### MODIFY `src/server/responses/ws-upstream.ts`, `fetch-helpers.ts` +- `ProviderFetchOptions.preOutputRefusalHoldMs`; pass through `codexWsUpstreamFetch` (new trailing + parameter) to the exchange. + +### MODIFY `src/server/responses/passthrough-dispatch.ts` +- Initial send closure inside `fetchWithTransientRetry` (~1027): pass + `preOutputRefusalHoldMs` when `usesCodexForwardPoolAuth(admissionState.authCtx, route.provider)` + and the leg carries no native control. +- Immediately after the Pool block (~1704), every loop pass: + `upstreamResponse = restoreCodexWsPreOutputRefusal(upstreamResponse)`. + +### MODIFY `src/server/responses/core-codex-account.ts` +- Alternate-account providerFetch (~917): pass the same hold so a second refusal is also a + projection (restored by dispatch). Hold value threaded via `CodexPoolAccountRetryArgs`. +- Exactly-once marker as in audit disposition 2 (record + mark; skip when marked). +- Update the `shouldRetryCodexPoolAccountTransient` doc comment. + +### MODIFY `src/server/responses/compact.ts` +- Alternate condition gains a transient arm: `shouldRetryCodexPoolAccountTransient(upstream)` and + not body-confirmed quota (`shouldRetryCodexPoolAccountQuota`), same guards. Transient arm skips + the workspace-scope quota check and the quota-cache refresh, admits B's send before cancelling A, + records A with its real 5xx (transient) after B was sent and before every B return (success, HTTP failure, transport failure), keeps the conversation-state scrub. Spend/budget identity is B by construction: the label switches before the helper runs and both the base reporter and the reserve path read it lazily. + +### Tests (NEW, registered in `scripts/test-layout/layout.json` and +`tests/fixtures/test-layout-expected.json`) +- `tests/responses/ws-pre-output-refusal.test.ts` — exchange-level: first-frame 503/502 and + after-preamble refusals project with status, stage and restore; output/tool/non-empty snapshot, + socket death and silence before/after preamble, 4xx/429 frames, quota-coded 5xx, foreign + `stream_id`, conflicting aliases, native control, hold expiry, opt-out (no hold option) keep + today's results. +- `tests/responses/responses-pool-overload-failover.test.ts` — handleResponses pool end to end + with fake sockets: single account (3 same-account sends, original stream), two accounts all + overloaded (3 + 1 sends, original stream from B), recovery on B, fixed account (no move), output + started (1 send), abort during backoff (no further send), health recorded once. +- `tests/responses/compact-transient-alternate.test.ts` — compact A 503×3 → B success; B 503; + fixed account; quota-bodied 5xx keeps today's path. + +### Docs +- `structure/transports/responses-wire-shapes.md` (WS prelude contract) and + `structure/transports/responses-failover.md` (pool transient move, compact) — the exception and + its bounds. + +## Accept criteria with activation scenarios + +| Path | Activation | Observable proof | +| --- | --- | --- | +| Refusal after preamble | fake socket sends created, in_progress, `{type:error,status:503}` | dispatch sends again; second socket succeeds; client gets the success stream | +| Same-account bound | every socket refuses, one account | exactly 3 sockets; client body is the original created/in_progress/error stream | +| Account move | two accounts, all refuse | 3 + 1 sockets, B's frame carries B's token | +| Output started | created, output_item.added, error 503 | 1 socket; client sees the error in-stream | +| Socket death / silence | close after created; 90 s silence before any event | today's 200 body error / 504 non-replayable | +| Abort in backoff | abort during first backoff | no second socket | +| Fixed account | pinned Pool account | ≤3 sockets, never another account | +| Compact | A 503 thrice, B 200 | 4 HTTP sends, B serves, A recorded transient | + +## Verifiers (run in C) + +`bun run typecheck`; `bun test` on the three new files plus `tests/responses/ws-*.test.ts`, +`tests/responses/responses-pool-*.test.ts`, compact suites; `bun run test:changed`; +`bun run structure:check`; `bun run privacy:scan`; `bun test tests/test-layout*.test.ts`. + +## Residual risk + +HTTP SSE in-stream zero-output refusals and frames with an unrecognized code and no wrapper status +stay unrecovered. Native steering/injection exchanges keep today's behaviour. + +## Implementation review follow-ups + +- Review round 1 (gpt-6.1-sol high, FAIL): projection headers built before any settle, falling + back to committed delivery when they cannot be projected; `rate_limit*`/`*quota*` veto; dispatch + logic moved to `PoolPreOutputRefusals` in `core-codex-account.ts` for the 2,000-line ratchet; + real spend-ledger, queue-overflow, non-resetting-deadline and header-timeout tests. Round 2: PASS. +- PR review (Codex connector): the hold is now `{ maxMs, until }`, with `until` set per send to + half of `connectTimeoutMs` after it starts, so a late first lifecycle frame cannot hold past the + header deadline; compact keeps A whenever B never physically dispatched (tracked through + `onPhysicalDispatch`), not only on a spent budget. diff --git a/devlog/_plan/261012_6972_tray_start_at_login/000_plan.md b/devlog/_plan/261012_6972_tray_start_at_login/000_plan.md new file mode 100644 index 00000000000..a0f617280a0 --- /dev/null +++ b/devlog/_plan/261012_6972_tray_start_at_login/000_plan.md @@ -0,0 +1,228 @@ +# 6972 — Tray Start at Login uses the Startup safety judgment + +Issue: lidge-jun/opencodex#6972. Related: #6980 (Startup page label only), #6963. +Branch: `codex/6972-tray-start-at-login-launchd-261012`, target `dev`. + +## Problem + +On macOS the tray's **Start at Login** `CheckMenuItem` is built from +`app.autolaunch().is_enabled()` (tauri-plugin-autostart 2.5.0 → auto-launch 0.5.0), which +is `Path::exists(~/Library/LaunchAgents/OpenCodex.plist)`. Startup safety +(`src/service/desktop-startup.ts` `macLoginRegistration`) credits the registration only when: + +1. the plist has the auto-launch shape (`Label` OpenCodex, `RunAtLoad` true, + `ProgramArguments = [<…>/Contents/MacOS/opencodex-desktop, --autostart]`, `Program` + absent or equal, `Disabled` absent or boolean), app and sibling `ocx` resolve and are + executable; +2. `launchctl print-disabled gui/` is well formed and either does not mention + `"OpenCodex"` (then plist `Disabled !== true`) or mentions it exactly once as + `enabled`/`false`; +3. `launchctl print gui//OpenCodex` either names this app as `program` and the + registration plist as `path` (`loaded`), or fails with the exact service-not-found result + (status 113, exact stderr) and a full re-capture is unchanged (`registered-not-loaded`); +4. the registered app equals the app being judged (unowned supervision path: + `login.app === app`). + +So a launchd-disabled job, another app copy, or a loaded job from an old copy show a checked +tray item while Startup reports Start at Login unverified. Clicking writes the plist again, +which neither clears a launchd override nor respects another copy's registration. + +## Decision + +Port the judgment to Rust (`desktop/src-tauri/src/login_registration.rs`) as a pure +classifier over an injected probe, and prove parity with one shared fixture file read by both +a Rust unit test and a bun test that drives the TypeScript judgment. The tray checks the item +only for the verified state. The classifier subdivides "not verified" so the tray can explain +and choose a safe click action: + +| State | TS `loginEnabled` | Checked | Label | Clickable | Click action (after a fresh re-probe that still returns the same state) | +|---|---|---|---|---|---| +| `On` (loaded or registered-not-loaded, this copy) | true | yes | Start at Login | yes | plugin `disable()` (unchanged) | +| `Off` (plist absent) | false | no | Start at Login | yes | `login_autostart::enable` (unchanged) | +| `DisabledBySystem` (this copy; print-disabled override disabled/true) | false | no | Start at Login (off in System Settings) | yes | open `x-apple.systempreferences:com.apple.LoginItems-Settings.extension`; never rewrites the plist | +| `OtherCopy` (registered app or loaded program resolves to another existing copy) | false | no | Start at Login (another OpenCodex copy) | no | none | +| `MissingCopy` (registered app path does not exist) | false | no | Start at Login (registered app is missing) | yes | `login_autostart::enable` for this copy | +| `Unknown` (probe failure, timeout, malformed output, shape mismatch, plist Disabled=true without override, path mismatch, changed during probe, not yet probed) | false | no | Start at Login (status unknown) | no | none | + +Invariant: `checked ⇔ state == On ⇔ TS loginEnabled for this app`. Nothing but `On` is +checked; an unverified result can never become checked. + +Why the Rust port instead of asking the running proxy: the tray must work when no proxy is +attached or when the attached proxy is an older runtime, and the proxy's Startup diagnostics +judge the supervising app, which is not necessarily this copy. The shared fixture is the +drift guard. + +## Diff plan + +### `desktop/src-tauri/src/login_registration.rs` (new) + +- `pub enum LoginState { On, Off, DisabledBySystem, OtherCopy, MissingCopy, Unknown }` with + `checked()`, `label()`, `clickable()`, `click_action() -> ClickAction`. +- `pub trait Probe { identity(&Path) -> io::Result; realpath(&Path) -> io::Result; + executable(&Path) -> io::Result<()>; run(&str, &[&str]) -> Result }`, + `RunError::{Exit { status, stderr }, Failed}` (timeout, signal, spawn failure, oversize → + `Failed`). +- `pub fn classify(probe, home, uid, current_app) -> LoginState` mirroring + `macLoginRegistration` step for step: capture (realpath + identity of plist and target, + plutil JSON, shape, app/ocx realpath + X_OK + identity, print-disabled parse, re-check of + every path/identity), then loaded/not-loaded branch with full re-capture comparison. + Parsers mirror the TS regexes by hand (no new crate). +- `#[cfg(target_os = "macos")] pub struct SystemProbe`: `std::fs` realpath and + `MetadataExt` identity (dev, ino, size, mode, mtime ns, ctime ns), and + `std::process::Command` with a 750 ms deadline (try_wait poll, kill on timeout), 128 KiB + cap and trimmed UTF-8 stdout, matching TS `run`. `getuid` and `access(X_OK)` are declared + through `extern "C"` (precedent: `geteuid` in `cli_command_record.rs`), so executability + matches TS `accessSync(X_OK)` with no new dependency. +- `#[cfg(test)]` parity test reading `../../../tests/fixtures/desktop-login-registration-cases.json` + through `include_str!` with a fake probe; plus the invariant `checked == startupLoginEnabled`. + +### `desktop/src-tauri/src/tray.rs` + +- macOS: build the item unchecked with the `Unknown` label and disabled, store it in + `TrayMenu`, then spawn the first probe. Keep the plugin path on Linux/Windows unchanged. +- `"start-at-login"` on macOS: `spawn_blocking` → take the probe lock, re-probe; if the + fresh state differs from the state the menu displayed, apply it and stop; otherwise run the + state's click action, re-probe and apply. Always re-apply so muda's automatic check toggle + never survives. +- Refresh (try-lock, skip when busy): at install, `TrayIconEvent::Enter` (throttled to 2 s), + and the existing 60 s loop (before the "no proxy" `continue`). +- Setters run only on background threads and never while holding the menu mutex + (existing `menu_handles` rule). + +### `desktop/src-tauri/src/lib.rs` + +- `#[cfg(any(target_os = "macos", test))] mod login_registration;` + +### `src/service/desktop-startup.ts` + +- Extract the existing unowned-path lambda into + `export function macLoginFactsFor(app, deps)` (no behavior change) so the parity test + judges exactly what Startup safety uses for "this app". + +### Tests + +- `tests/fixtures/desktop-login-registration-cases.json` (shared cases, placeholders + `{APP}`, `{OTHER_APP}`, `{MISSING_APP}`, `{PLIST}`, `{OTHER_PLIST}`, `{UID}`). + Cases: loaded current; registered-not-loaded (both stderr forms); override enabled / + false; override disabled / true; plist absent; plist Disabled=true without override; + plist Disabled=true with enabled override; other copy registered; loaded job from other + copy; loaded path mismatch; registered app missing; ocx missing; plutil failure; shape + mismatches (no --autostart, wrong Label, wrong suffix); print-disabled failure, malformed, + duplicate mentions; print failure non-113, timeout/signal, 113 with other stderr; + registration changed during the not-loaded re-capture. +- `tests/service/service-desktop-login-registration.test.ts`: materializes the fixture in a + temp home, drives `macLoginFactsFor`, asserts `loginEnabled`/`loginRegistration` and the + tray invariant. Registered in `scripts/test-layout/layout.json` and + `tests/fixtures/test-layout-expected.json`. + +### Docs + +- `structure/desktop-shell.md`: tray Start at Login judgment, states, click rules, parity + fixture. +- `docs-site/src/content/docs/guides/macos-menu-bar.md` and `desktop-app.md` (English): + the checkbox shows only a verified registration; reason labels. + +## Out of scope + +Linux/Windows tray, `first_run.rs` (its once-per-install migration still uses the plugin), +`gui/`, `app/`, TS judgment semantics, any real launchd/LaunchAgents state. + +## Verification + +- `cargo fmt --manifest-path desktop/src-tauri/Cargo.toml --check` +- `cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --all-targets -- -D warnings` +- `cargo test --manifest-path desktop/src-tauri/Cargo.toml` +- `bun test tests/service/service-desktop-login-registration.test.ts tests/service/service-desktop-startup.test.ts tests/service/service-desktop-startup-health.test.ts` +- `bun run typecheck`, `bun run structure:check`, `bun test tests/test-layout.test.ts tests/test-layout-tooling.test.ts`, `bun run privacy:scan` +- No real launchd: tests use fakes and temp homes only. + + +## Audit round 1 (GPT-6.1-Sol high): FAIL → folded + +1. **MissingCopy takeover** — accepted. `MissingCopy` is non-clickable like `OtherCopy`; a + missing path may be an installation being replaced or on an unmounted volume. Table: + MissingCopy → Clickable no, action none. +2. **first_run migration rewrites another copy** — accepted. On macOS + `adopt_launch_origin_argument` only rewrites when the plist's `ProgramArguments[0]` + resolves to this running copy (legacy entries without `--autostart` included, via + `login_registration::registered_to`). Foreign, missing or unreadable registrations are left + untouched and the marker is not written, so a later launch can still migrate this copy's own + entry. Covered by Rust unit cases (this copy / other copy / missing / legacy shape / plutil + failure / absent). +3. **auto-toggle before probe** — accepted. The menu callback (main thread; Tauri runs setters + inline there, `tauri-runtime-wry` `send_user_message`) immediately restores the last + applied presentation and disables the item, then spawns the blocking job. It never waits on + the probe lock. Jobs and refreshes are serialized by one probe mutex; refreshes use + `try_lock`. The residual is AppKit's own toggle between the click and the callback. +4. **X_OK** — already folded: `access(path, X_OK)` via `extern "C"`. +5. **parser boundaries** — accepted: fixture adds delimiter/newline/EOF, invalid suffix + (`enabledx`), duplicate mentions, malformed envelope, non-boolean `Disabled`, `Program` + equal/different, symlinked plist, 113 with signal-style failure, wrong uid in stderr, + changed registration during re-capture. +6. **Linux dead code** — accepted: portable tests exercise `label`/`clickable`/ + `click_action`; macOS-only constants and helpers sit behind `cfg(target_os = "macos")`; + CI clippy runs on Linux, local run on macOS, both `--all-targets -D warnings`. +7. **label overclaims** — accepted: "Start at Login (disabled by macOS)"; click opens Login + Items settings, opener failure is logged once, and nothing is credited until a fresh probe. +8. **translations** — accepted: update `ko/guides/desktop-app.md` and any locale that + describes the checkbox as file existence; update `structure/desktop-shell.md` first-run + paragraph. + + +## Audit round 2 (GPT-6.1-Sol high): FAIL → folded + +1. **first-run guard and `Program`** — accepted. `login_registration::migration` returns + `Rewrite` only for `Label` OpenCodex, `RunAtLoad` true, `ProgramArguments` = + `[<…>/Contents/MacOS/opencodex-desktop]` or `[…, --autostart]`, `Program` absent or equal to + argument zero, and argument zero resolving to this running copy. Everything else is `Leave`. +2. **first-run re-enables a self-disabled plist** — accepted. `Disabled` must be absent or + `false`; a `true` or non-boolean value is `Leave`. A launchd override is untouched by a + plist rewrite, so it needs no special case. `Leave` does not write the origin marker; + `Absent` does (unchanged from the current `Ok(false)` branch). +3. **ECMAScript line terminators** — accepted. `line_value` splits on `\n`, `\r`, U+2028 and + U+2029. Shared fixture adds CR / U+2028 / U+2029 terminated `launchctl print` output, a + program value cut at U+2028, a program value that starts after a terminator, and a path value + cut at CR (58 cases). +4. Inline main-thread setters confirmed (`tauri-runtime-wry` 2.11.4). + +Final state table (supersedes the one above): + +| State | Checked | Label | Clickable | Action | +|---|---|---|---|---| +| On | yes | Start at Login | yes | disable | +| Off | no | Start at Login | yes | enable for this copy | +| DisabledBySystem | no | Start at Login (disabled by macOS) | yes | open Login Items settings | +| OtherCopy | no | Start at Login (another OpenCodex copy) | no | — | +| MissingCopy | no | Start at Login (registered app is missing) | no | — | +| Unknown | no | Start at Login (status unknown) | no | — | + + +## Audit round 3 (GPT-6.1-Sol high): FAIL → 4 folded, 1 rebutted + +1. **migration drops keys** — folded. `Rewrite` requires exactly the three keys auto-launch + writes (`Label`, `ProgramArguments`, `RunAtLoad`); `Program`, `Disabled`, `KeepAlive` + or any other key is `Leave`. +2. **cross-process check-then-act** — rebutted as residual. Every mutation re-probes + immediately before acting and the window is the few milliseconds between that probe and the + plugin's file write; the only actor that can interleave is another OpenCodex copy of the + same user acting in that window. The `Off` → enable write is the existing plugin path. + Cross-process locking or a custom plist writer is disproportionate to #6972 and would replace + plugin-owned code; recorded as a known limit in the PR. +3. **lossy UTF-8** — folded: `String::from_utf8_lossy` like Node's `encoding: "utf8"`. +4. **reader joins** — folded: readers send over channels and are collected with + `recv_timeout` inside the same 750 ms deadline; a blocked reader is abandoned. +5. **presentation interleaving** — folded: every presentation is applied in one + main-thread closure. A click increments a pending counter and restores/disables on the main + thread; refresh results are dropped while a click is pending; the click job's own result + decrements the counter and applies. + +## Code review round 1 (GPT-6.1-Sol high, commit 4c5fb7734d): FAIL → folded + +1. **migration accepted a plist replaced during its own read** — folded: `migration` now + captures the plist identity, its resolved target and target identity before `plutil`, and + re-reads them plus the registered executable's resolution afterwards; any change is + `Leave`. Regression: `migration_rewrites_only_this_copys_own_enabled_entry` replacement + case. The reviewer accepted the cross-process rebuttal for the window after a stable final + probe. +2. **structure doc said two actionable states** — folded: three (verified disables, absent + enables, launchd-disabled opens Login Items settings). diff --git a/devlog/_plan/261012_6972_tray_start_at_login/010_publish.md b/devlog/_plan/261012_6972_tray_start_at_login/010_publish.md new file mode 100644 index 00000000000..dbe1a98a6a3 --- /dev/null +++ b/devlog/_plan/261012_6972_tray_start_at_login/010_publish.md @@ -0,0 +1,18 @@ +# 010 — Publish #6972 + +Previous D (wp1): implementation at a0b181577f passed the focused gates (receipt +`.codexclaw/evidence/.../test-receipt.json`) and the GPT-6.1-Sol code review round 2 PASS. +Direction unchanged: publish for the merge-management thread; never merge here. + +Steps: + +1. `git fetch origin dev`; rebase onto `origin/dev`; if it moved, rerun `.tmp/check.sh`. +2. Push `codex/6972-tray-start-at-login-launchd-261012` to `origin` (maintainer branch). +3. Open a PR to `dev` from `.github/PULL_REQUEST_TEMPLATE.md` (Summary, Verification with the + independent review results, Checklist), `Closes #6972` (close manually after landing on + `dev`). No `gui/` change, so no screenshot. +4. `link_pull_request`; `goat_thread_send` to mcp:284c76b4-6e51-464d-acf4-ff040b9dc299 with PR + number, head SHA and verification summary. +5. Observe exact-head CI; fix author-owned failures; report status. + +Out of scope: merging, closing the issue before `dev` lands, touching live launchd/service state. diff --git a/devlog/_plan/261012_6991_6989_anthropic_pool_quota/010_plan.md b/devlog/_plan/261012_6991_6989_anthropic_pool_quota/010_plan.md new file mode 100644 index 00000000000..f9dcd03d946 --- /dev/null +++ b/devlog/_plan/261012_6991_6989_anthropic_pool_quota/010_plan.md @@ -0,0 +1,100 @@ +# 010 — Anthropic pool quota exhaustion: terminal usage limit (#6991) and fresh-read recovery (#6989) + +Base: origin/dev dc45bb2ca9. Branch: codex/6989-6991-anthropic-pool-quota-261012. + +## Evidence on dev + +- #6991. Local pool refusals become a retryable 429 `rate_limit_error` / `rate_limit_exceeded` at four sites: + `src/server/responses/request-transport.ts:786-793` (pool-enabled initial selection, `all-cooled`), + `src/server/responses/adapter-dispatch.ts:528-530` and `:788-790` (`AnthropicAccountCooldownError` after pacing / on the recovery leg), + `src/server/responses/passthrough-dispatch.ts:951-953`. `formatErrorResponse` (`src/bridge/errors.ts:19`) classifies every 429 as + `rate_limit_exceeded` (`src/lib/errors.ts:448-458`) and has no slot for `resets_at`. The first exhausted request returns the upstream + 429 as `Provider error 429: ...` with `rate_limit_exceeded` (`adapter-dispatch.ts:1679-1722`) after `rotateAnthropicAccountOnRefusal` + finds no replacement (`src/oauth/anthropic-routing.ts:1062-1073`). +- Codex (openai/codex 6c372fbe, `codex-api/src/api_bridge.rs:181-218`) treats HTTP 429 with `error.type == "usage_limit_reached"` as + non-retryable `UsageLimitReached` and reads `resets_at` as integer epoch seconds; a wrapped WebSocket `error` frame reuses the same + classifier (`responses_websocket.rs:591-662`). OpenCodex's WS bridge keeps the nested error object (`src/server/ws-bridge.ts:369-403`). + Without `plan_type` Codex prints its generic usage-limit copy; a ChatGPT plan type would print ChatGPT upgrade copy, so none is sent. +- #6989. `captureAnthropicCooldownRecovery` and `settleAnthropicCooldownRecovery` (`anthropic-routing.ts:379-412`) only claim + `cooldownSource === "reset-derived"`. A shared-quota 429 that carries Retry-After records source `retry-after` (`:1131-1141`), so a fresh + usage read showing headroom never lifts it. #6953 already records `refusedQuotaWindows` for every 429 regardless of deadline source and + its reset-grant admission documents this gap (`src/providers/anthropic-reset-admission.ts:4-8`). + +## Classification rule (#6991) + +`recordAnthropicAccountRefusal` only records a 429 health entry for a `shared-quota` classification; transient throttles become admission +pauses and family-only rejections become family evidence. For each account the pool would otherwise consider (same filter as +`getAnthropicPoolRetryAfterSeconds`, including route/fallback expansion), collect its blocks: + +| Block | Until | Quota? | +|---|---|---| +| live health entry, refusalStatus 429 | cooldownUntil | yes (shared-quota by construction, any deadline source) | +| live health entry, refusalStatus 403 | cooldownUntil | no (entitlement) | +| admission pause (transient Retry-After) | pause until | no | +| family exclusion, not revalidating | family retry time | yes | +| family exclusion, revalidation in flight (busy) | now + 1 s | no | + +The account is quota-held when its latest block is a quota block (ties favor transient). The pool answers `usage_limit_reached` only when +no route candidate is eligible and every blocked account is quota-held. `resets_at` (epoch seconds) is computed apart from the +admission order: per account, the latest upstream window reset over all its quota holds (unknown if any rejected window lacks a +reset header or a family has no reset), then the earliest across accounts, omitted when any held account is unknown. Retry-After +stays the admission deadline. Anything else keeps today's `rate_limit_error` / `rate_limit_exceeded` with Retry-After. + +## Recovery rule (#6989) + +A cooldown is claimable by a fresh usage read when it is live, `refusalStatus === 429`, and either source is `reset-derived` (unchanged) or +the refusal named at least one rejected window (`refusedQuotaWindows`). Settlement requires the same generation, `quota.updatedAt >= +claimedAt` (unchanged stale-read fence) and `percent < 100` for every window in `rejectedQuotaWindows` if present, else +`refusedQuotaWindows`. 403 refusals and windowless non-reset-derived 429s are never claimed; a reset-derived aggregate refusal stays +claimable as before but is always retained. Claimed probes already force a fresh dispatch +(`requiresFreshDispatch`), so a cached or joined older read cannot settle. + +## Changes (as implemented) + +1. `src/oauth/anthropic-routing.ts`: `AnthropicAccountCooldownError` gains `usageLimit: { resetsAt: number | null } | null` and + `retryableMessage`; its message names the usage limit and, when known, the ISO reset. 429 health entries record `quotaResetAt` + via `rejectedWindowsResetAt` (only when every rejected window carries a reset). `anthropicAccountHold` returns the admission winner + plus the account's quota reset computed over all quota holds. `anthropicPoolCooldownError(now, decision, model)` builds the refusal; + `getAnthropicPoolRetryAfterSeconds` delegates to it; `resolveAnthropicDispatchAccountId` throws it. + `anthropicPoolUsageLimitAfterRefusal` returns it only for a usage limit. Recovery claim/settle widened as above. +2. `src/oauth/anthropic-model-quota.ts`: `anthropicFamilyHoldState` (revalidating flag and family window reset). +3. `src/bridge/errors.ts`: `formatUsageLimitReachedResponse(message, resetsAtMs | null, retryAfterSeconds)`. +4. `src/server/responses/core-errors.ts`: `anthropicPoolCooldownResponse(refusal, { comboAttempt, logCtx })` — usage limit for direct + requests (sets `logCtx.errorCode`), the old `rate_limit_error` body and wording for combo children. +5. Sites: `request-transport.ts` pool-enabled `all-cooled` refusal; both `AnthropicAccountCooldownError` catches in + `adapter-dispatch.ts`; the passthrough catch; the final non-combo upstream 429 in `adapter-dispatch.ts`; the first-iteration 429 of a + routed web-search loop in `sidecar-execution.ts`. +6. Native Messages keeps Anthropic `rate_limit_error`; its message carries the usage-limit wording. +7. Docs: `src/providers/anthropic-reset-admission.ts` comment, `structure/providers/anthropic-account-pool.md` (new section), + `structure/transports/responses-failover.md`, docs-site `reference/configuration/providers.md`. + +## Boundaries (unchanged identity) + +Image bridge terminal, opt-in empty-completion continuation, Chat Completions translation, combo final-error identity +(`core-combo-failure.ts`), Codex-account cooldown path (`src/codex/auth-context.ts`), dashboard reason label for retry-after quota +cooldowns (`src/oauth/health.ts:224`). #6962 shares `adapter-dispatch.ts`, `request-transport.ts` and `messages-native.ts` but not the +edited statements; rerun billing tests on the union once it lands. + +## Tests + +- `tests/adapters/anthropic/anthropic-pool-usage-limit.test.ts` (12): quota-held pool answers `usage_limit_reached` with `resets_at` + (pool on and off); unequal Retry-After/reset; unknown reset omits `resets_at`; partial reset headers; reset independent of admission + winner; transient throttle and 403 keep `rate_limit_exceeded`; last upstream refusal answers the usage limit; throttled sibling keeps + the provider rate limit; family quota vs revalidation; routed web-search loop; combo child body byte-identical; no account material + in bodies or warn logs. +- `tests/adapters/anthropic/anthropic-quota-cooldown-recovery.test.ts` (11): Retry-After quota cooldown lifted by a fresh read (both + instances); partial window retained; stale and superseded claims; default-source recovery; 403 and windowless never claimed; + aggregate reset-derived retained; read started before a refusal cannot lift it; instance isolation; tie with a transient pause. +- Updated: `anthropic-cooldown-recovery.test.ts` (named-window Retry-After/default now clear; windowless/403 do not), + `anthropic-model-routes.test.ts` (helper-named spent window answers `usage_limit_reached`). + +## Verification + +`bun run typecheck`, `bun test --isolate` over `tests/adapters/anthropic/`, `tests/oauth/`, layout/lab/core-module/reset-grant files, +`bun run test:changed`, `bun run structure:check`, `bun run privacy:scan`, `git diff --check`. + +## Independent audit (GPT-6.1-Sol, builder Claude) + +- r1 FAIL: reset vs admission deadline, combo prose, race tests, surfaces, docs — folded except image/continuation/Chat (boundary). +- r2 FAIL: partial reset headers, reset vs admission winner, routed web-search terminal, stale test expectation, plan text — all folded. +- r3 NEAR-PASS: no runtime defect; low finding on this document's stale text, fixed here. diff --git a/devlog/_plan/261013_rt284_l1_owner_bundle/000_record.md b/devlog/_plan/261013_rt284_l1_owner_bundle/000_record.md new file mode 100644 index 00000000000..0e95712b17f --- /dev/null +++ b/devlog/_plan/261013_rt284_l1_owner_bundle/000_record.md @@ -0,0 +1,19 @@ +# L1 owner bundle — record + +Lane L1 of the 2.84.0 release train. The coordinator thread owns merge, release and PR decisions. + +## Bundle (on dev a63267c134, after #7008) + +One squashed commit per owner PR, patch-identical to the reviewed squashes: #6986 (Closes #6984), #6994 (#6982), #6993 (#6988), #6995 (#6989, #6991), #6997 (#6969), #6996 (#6972), #6959 (#6957), #6961 (#6675). + +## #6999 split out (coordinator decision) + +#6999 (request-history rebuild off the serving loop, #6990) failed hosted windows 6/9 in every run (event-loop lag 545, 508, 707, 667 and 1769 ms; the 667 ms run was the step-attribution round) across three repair rounds: + +1. Index-backed health queries and native index paths. +2. A MEMORY journal for private builds and time-bounded ingest sub-transactions. On the Windows desktop the journal change alone took the rebuild from 5170 ms to 2400 ms, and both together measured 2190–2268 ms. +3. An asynchronous rename and warm-up during publication. + +Step instrumentation then placed the remaining stalls in synchronous SQLite work on the serving thread: the open of the new generation (552 ms) and the first tail commit on the WAL connection (1204 ms), both with low process CPU. Bounding those needs SQLite off the serving thread, a change to how history is served that does not fit this release. No assertion was relaxed. The coordinator removed #6999 from 2.84.0. + +All #6999 work, including the three repair rounds, their tests and their devlog, is preserved on `codex/6990-history-rebuild-followup` at `e831e863326089585b194b6939f4b7569fb12f14`. Plans and evidence are in that branch's `devlog/_plan/261013_rt284_l1_owner_bundle/` (000–070). The PR #6999 disposition belongs to the coordinator. diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index 888b80644a2..aa8668df8c5 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -45,8 +45,8 @@ bun run privacy:scan # credential/privacy scan used by CI bun run prepare:package # refresh package launchers/assets ``` -The suite uses Bun 1.4.0 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.2. -`scripts/test.ts` finds a 1.4.0 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`. +The suite uses Bun 1.4.3 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.2. +`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`. Install that version locally if it is missing; the resolver never downloads it. Run `bun run test` by default. If a full run is disproportionately expensive for the task size, diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md index 92910bf7206..ef610f23906 100644 --- a/docs-site/src/content/docs/fr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md @@ -251,6 +251,10 @@ rotation automatique peut déclencher des restrictions du fournisseur. Seul un 429 confirmant le rejet d'un quota partagé de cinq heures ou hebdomadaire déclenche la temporisation et le basculement du compte. Une limitation temporaire suspend son admission en conservant l'affinité : au plus un bref nouvel essai sur le même compte et un détour vers un autre compte admissible par requête. Sans en-tête probant, un 429 permet un seul bref nouvel essai sur le même compte, sans refroidir le compte ni inventer Retry-After. Le comportement par défaut avec un seul compte reste identique. Un rejet propre à Fable laisse Sonnet admissible ; choix manuel et affinité vérifient aussi les quotas partagés et ceux de la famille demandée. Les observations passives de famille expirent après trente minutes ou à leur réinitialisation connue et sont revalidées par une seule requête à la fois. Les seuils restent des préférences souples avec le repli existant lorsque tous les candidats sont épuisés, sans plafond strict d'utilisation ou de facturation. L'affinité est locale au processus et de taille bornée. Les erreurs de renouvellement du jeton suivent les règles de réauthentification existantes. Un 403 confirmé lié à un abonnement ou à la facturation du compte permet un basculement avant la sortie, avec une temporisation selon `Retry-After` ou de dix minutes par défaut. Un refus d’autorisation ordinaire ne déclenche pas de basculement. Si tous les comptes admissibles sont en temporisation, les clients reçoivent un 429 accompagné de `Retry-After` lorsqu'il est connu, et non une erreur d'authentification. +Lorsque chaque compte utilisable par la requête est retenu sur un quota épuisé (une fenêtre partagée de cinq heures ou hebdomadaire, ou la fenêtre hebdomadaire de la famille du modèle demandé), ce 429 est une limite d'usage et non une limite de débit : le corps Responses porte `error.type` et `error.code` `usage_limit_reached` et, si la réinitialisation amont de chaque compte retenu est connue, `resets_at` (secondes epoch de la plus proche). Codex s'arrête alors et indique quand réessayer au lieu de réessayer jusqu'à « exceeded retry limit ». Le dernier refus amont pour quota épuisé reçoit la même réponse quand aucun remplaçant ne reste. Si un compte retenu est seulement limité en débit, revalide une famille de modèles ou est refusé pour droits d'accès, la réponse reste `rate_limit_exceeded`, réessayable. Les cibles de combo gardent la forme de limite de débit pour passer à la cible suivante. `/v1/messages` natif garde le type `rate_limit_error` d'Anthropic, avec un message qui nomme la limite d'usage. + +Une temporisation de quota se lève plus tôt dès qu'une lecture d'usage récente (page Fournisseurs du tableau de bord, ou `ocx account list anthropic --quota --refresh`) montre sous 100 % chaque fenêtre rejetée, qu'elle vienne de `Retry-After` ou d'une réinitialisation de fenêtre. Cela couvre une montée d'offre ou une fenêtre rouverte plus tôt. Une lecture antérieure au refus ne la lève jamais, et les temporisations 403 de droits d'accès attendent leur échéance. + :::caution[Expérimental] Laissez cette option désactivée, sauf si vous comprenez les risques liés aux règles d'Anthropic concernant les comptes. En cas de doute, préférez le changement manuel avec `ocx account use anthropic `. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index f10ab318276..f418d94ce73 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -959,6 +959,16 @@ ocx service install # persistent: auto-starts on login and respawns on crash `ocx status` shows whether the proxy is running and prints the same restart hint when it is not; `ocx doctor` reports restart safety (service/shim coverage). +### Upstream overload before any output + +When the ChatGPT backend declines a pooled native Codex turn with `server_is_overloaded` or +another server error before it has produced any output, opencodex retries the same account +with a short, capped backoff and then moves the turn to one other eligible Pool account. +A pinned account (an account-qualified model) is retried but never moved. Once any output +has reached Codex the turn is never sent again. If every attempt is declined, Codex receives +the same error it would have received without the retries. Native compact requests move to +another Pool account on a server error in the same way. + ### Codex autostart shim Run `ocx codex-shim install` to install the optional launcher wrapper. It runs diff --git a/docs-site/src/content/docs/guides/desktop-app.md b/docs-site/src/content/docs/guides/desktop-app.md index b16551e1e5e..05ace2e004e 100644 --- a/docs-site/src/content/docs/guides/desktop-app.md +++ b/docs-site/src/content/docs/guides/desktop-app.md @@ -104,9 +104,14 @@ Turn on **Start at Login** in the OpenCodex menu instead of installing a backgro service. Startup safety credits the app only when its login registration is verified; if it cannot be verified, follow the Desktop guidance in status. The Startup page then labels the Desktop row **Running · Start at Login not verified**. -The menu checkbox only shows that the login item file exists. If it is already checked, -make sure macOS allows OpenCodex to open at login under System Settings → General → -Login Items & Extensions. While the app runs the proxy, the Startup page keeps the +On macOS the menu's **Start at Login** item is checked only when Startup safety would +verify the same registration. Otherwise the item is unchecked and its label says why: +**disabled by macOS** opens System Settings → General → Login Items & Extensions, where +you allow OpenCodex to open at login; **another OpenCodex copy** means the login item +belongs to a different copy of the app, so turn it off from that copy; **registered app +is missing** means the login item names an app that no longer exists, so delete +`~/Library/LaunchAgents/OpenCodex.plist` and turn the item on again; **status unknown** +means the registration could not be read. While the app runs the proxy, the Startup page keeps the background service and launcher shim installers disabled; to use them instead, quit OpenCodex and run `ocx service install` or `ocx codex-shim install` in a terminal. diff --git a/docs-site/src/content/docs/guides/macos-menu-bar.md b/docs-site/src/content/docs/guides/macos-menu-bar.md index 38af14941bb..c5da2bd6ce3 100644 --- a/docs-site/src/content/docs/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -13,7 +13,7 @@ Download `OpenCodex--macos.dmg` from the [latest release](https://githu Release builds of `OpenCodex.app` are signed with a Developer ID, use the hardened runtime, and are notarized by Apple with the ticket stapled to the app. On first launch, macOS normally asks only for the standard confirmation for an app downloaded from the internet. If macOS still blocks it, open **System Settings → Privacy & Security** and choose **Open Anyway** for OpenCodex. Apps you build yourself are ad-hoc signed; see [Build from source](#build-from-source). -The app shows its startup progress in a window when you open it. It enables **Start at Login** once on first launch; you can turn that off from the tray menu. Later launches from the login item start with the window hidden while the tray remains available. +The app shows its startup progress in a window when you open it. It enables **Start at Login** once on first launch; you can turn that off from the tray menu. The tray item is checked only when this copy of the app is registered and macOS has not disabled the login item; otherwise its label says why (see [Desktop app](/guides/desktop-app/)). Later launches from the login item start with the window hidden while the tray remains available. ## Menu bar and usage panel diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 3a17e3610e9..e3dbbf673e3 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -65,6 +65,16 @@ A failed link does not silently switch to a local provider. The Child returns a retryable `503` without forwarding the link key or request when its tunnel is failed, stopped, or not supervised. It also keeps the key local when another process owns the tunnel port; free that port so the Child's SSH tunnel can bind it. +## What a Child shows and manages + +A Child runs on the Home's providers, accounts and settings, so the Home is where you change them. + +- **Dashboard.** The Child's dashboard shows the Home's state read-only and says "Managed by Home over Remote Link". Dashboard and Providers read the Home through the link, and the usage strip in the header shows the Home's quota. The model list is not shown on a Child, because building it runs provider discovery on the Home; Codex on the Child already offers the Home's models. For the same reason, the delegation and sidecar model pickers on a Child list the native models and the Home's current choices, not the Home's full routed list. Saving a change there is refused with a message that names the Home; make the change in the Home's dashboard. The Child's browser never receives a Home credential. +- **Codex settings.** `ocx sync` on the Child (and connecting it) applies the Codex settings OpenCodex manages on the Home: Fast mode, the multi-agent mode, the web search sidecar switch, and the native sub-agent defaults when the Home syncs them. `ocx sync` and `ocx status` list the values that came from the Home. The default reasoning effort and the effort levels shown in the Codex app's model picker are Codex app settings on each computer; OpenCodex does not manage them on any computer. +- **CLI.** Commands that only read, such as `ocx account list`, `ocx provider quota` and `ocx usage`, read the Home. They show what the Home has already observed: a read from a Child never makes the Home probe a provider, refresh a token or start a process, so quota and account details are as fresh as the Home's own activity keeps them. Reads that only exist to run provider discovery or a forced refresh, such as `ocx models live`, `ocx models preset show` and `ocx account list --quota`, are refused like changes. Commands that change configuration are refused with one message that names the Home and the link and says no request was sent. `ocx status` describes the Home and the link. `ocx provider list` and `ocx config get` still show this computer's own configuration, labelled as local and inactive while the computer is linked. `ocx config set`, `ocx config unset` and `ocx config import` still write locally, each with the same note, and `ocx provider add`, `remove` and `set-default` are refused because those providers are not used while linked. + +These reads need an OpenCodex version with the Remote Link management relay on both the Home and the Child. An older Home answers with a message asking you to upgrade it. + ## Remove a Child Select **Disconnect** for the Child and confirm the alias. The Home stops the tunnel, revokes that Child's link key, and removes the saved link record. diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 13184d191e5..b57ae0e51ca 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -227,6 +227,10 @@ affinity を維持します。これらの戦略は provider enforcement を回 共有の5時間・週次クォータが拒否された429だけがアカウントのクールダウンと切り替えを行います。一時的な速度制限はアフィニティを維持して送信受付だけを停止し、リクエストごとに短い同一アカウント再試行1回と別の対象アカウントへの切り替え1回までです。根拠ヘッダーのない429は同一アカウントで1回だけ再試行し、アカウントを冷却せず、Retry-Afterも生成しません。既定の単一アカウント動作は変わりません。Fable専用の拒否はSonnetを制限しません。手動選択とアフィニティもリクエストモデルの共有・ファミリークォータを確認します。パッシブなファミリー情報は30分または既知のリセットで期限切れとなり、1つの送信リクエストで再確認します。使用量しきい値はソフトな優先設定で、全候補消耗時のフォールバックを維持します。使用量・課金のハード上限ではありません。アフィニティはプロセスローカルであり、サイズ制限があります。トークン更新の失敗は既存の再認証ルールに従います。確認済みの契約・アカウント請求の 403 は出力前に別アカウントへ切り替え、`Retry-After` または既定の10分間のクールダウンを記録します。一般的な権限拒否では切り替えません。すべての対象となるアカウントが冷却されている場合、クライアントは、既知の場合、認証エラーではなく、`Retry-After` を含む 429 を受け取ります。 +リクエストが使えるすべてのアカウントが使い切ったクォータ(共有の5時間・週間ウィンドウ、または要求モデルのファミリー週間ウィンドウ)で保留されている場合、その 429 はレート制限ではなく使用量上限です。Responses の本文は `error.type` と `error.code` に `usage_limit_reached` を持ち、保留中の全アカウントの上流リセット時刻が分かる場合は `resets_at`(最も早いリセットのエポック秒)を含みます。Codex は「exceeded retry limit」まで再試行せず、いつ再試行できるかを表示して停止します。最後のアカウントがクォータ切れで拒否し、代替が残っていない場合も同じ応答になります。保留中のアカウントのいずれかが一時的なスロットリング、モデルファミリーの再検証中、または権限による拒否である場合は、再試行可能な `rate_limit_exceeded` のままです。Combo ターゲットは次のターゲットへ進めるようレート制限の形を保ちます。ネイティブ `/v1/messages` は Anthropic の `rate_limit_error` 型を保ち、メッセージで使用量上限を示します。 + +新しい使用量の読み取り(ダッシュボードのプロバイダーページ、または `ocx account list anthropic --quota --refresh`)で、拒否された各ウィンドウが 100% 未満と分かれば、クォータのクールダウンは `Retry-After` 由来でもウィンドウのリセット由来でも早期に解除されます。プランのアップグレードや早期のウィンドウ再開がこれに当たります。拒否より前の読み取りでは解除されず、権限による 403 のクールダウンは期限まで待ちます。 + :::caution[実験的] Anthropic アカウント ポリシーのリスクを理解していない限り、これは無効のままにしてください。不明な場合は、`ocx account use anthropic ` を手動で切り替えることをお勧めします。 ::: diff --git a/docs-site/src/content/docs/ko/guides/desktop-app.md b/docs-site/src/content/docs/ko/guides/desktop-app.md index 6c0078f6543..7645930f3e4 100644 --- a/docs-site/src/content/docs/ko/guides/desktop-app.md +++ b/docs-site/src/content/docs/ko/guides/desktop-app.md @@ -80,9 +80,13 @@ macOS와 Linux에서 데스크톱 앱이 번들 프록시를 실행하는 것으 등록이 확인되어야 재시작 후 보호가 적용됩니다. 등록을 확인할 수 없으면 status에 표시되는 데스크톱 안내를 따르세요. 실행 관계 확인은 소유권을 생성하지 않습니다. 이때 시작 안전 페이지의 데스크톱 앱 행에는 **실행 중 · 로그인 시 시작 미확인**이 표시됩니다. -메뉴의 체크 표시는 로그인 항목 파일이 있다는 것만 나타냅니다. 이미 체크되어 있다면 -macOS의 시스템 설정 → 일반 → 로그인 항목 및 확장 프로그램에서 OpenCodex가 로그인 시 -열리도록 허용되어 있는지 확인하세요. 앱이 프록시를 실행하는 동안 시작 안전 페이지는 +macOS에서 메뉴의 **Start at Login** 항목은 시작 안전 페이지도 같은 등록을 확인할 때만 +체크됩니다. 그렇지 않으면 체크가 해제된 채 이유가 라벨에 붙습니다. **disabled by macOS**는 +시스템 설정 → 일반 → 로그인 항목 및 확장 프로그램을 열며, 거기서 OpenCodex가 로그인 시 +열리도록 허용하면 됩니다. **another OpenCodex copy**는 로그인 항목이 다른 앱 사본의 것이라는 +뜻이니 그 사본에서 끄세요. **registered app is missing**은 로그인 항목이 더 이상 없는 앱을 +가리킨다는 뜻이니 `~/Library/LaunchAgents/OpenCodex.plist`를 지우고 항목을 다시 켜세요. +**status unknown**은 등록을 읽지 못했다는 뜻입니다. 앱이 프록시를 실행하는 동안 시작 안전 페이지는 백그라운드 서비스와 런처 shim 설치를 막아 둡니다. 이를 대신 쓰려면 OpenCodex를 종료하고 터미널에서 `ocx service install` 또는 `ocx codex-shim install`을 실행하세요. diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 40db0c8ca04..1b482a58d61 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -229,6 +229,10 @@ affinity 초기화 뒤의 기존 작업도 포함될 수 있습니다. 출력 공유 5시간/주간 할당량의 거부가 확인된 429만 계정 쿨다운과 전환을 유발합니다. 일시적인 속도 제한은 affinity를 유지하며 해당 계정의 전송만 잠시 중지합니다. 요청마다 짧은 동일 계정 재시도 한 번과 적격한 다른 계정 전환 한 번까지만 허용됩니다. 근거 헤더가 없는 429는 동일 계정에서 한 번만 짧게 재시도하며 계정 상태를 냉각하지 않고 Retry-After를 만들어 붙이지 않습니다. 기본 단일 계정 동작은 유지됩니다. Fable 전용 거부는 Sonnet을 제한하지 않습니다. 수동 선택과 affinity도 요청 모델의 공유/계열 할당량을 확인합니다. 수동 조회가 아닌 응답 헤더의 계열 근거는 30분 또는 알려진 리셋 시각에 만료되며 요청 하나로 재확인합니다. 사용량 임계값은 소프트 선호이고 모든 후보가 소진되면 기존 폴백을 유지합니다. 사용량 또는 결제 하드 캡이 아닙니다. 결속은 프로세스 로컬이며 크기가 제한됩니다. 토큰 갱신 실패는 기존 재인증 규칙을 따릅니다. 확인된 구독 또는 계정 결제 403은 출력 전에 다른 계정으로 전환하며 `Retry-After` 또는 기본 10분 동안 냉각합니다. 일반 권한 거부는 전환하지 않습니다. 적격한 계정이 모두 쿨다운 중이면, 클라이언트는 인증 오류가 아니라 알려진 경우 `Retry-After`가 포함된 429를 받습니다. +요청이 쓸 수 있는 모든 계정이 소진된 할당량(공유 5시간·주간 창, 또는 요청 모델 계열의 주간 창)으로 묶여 있으면 그 429는 속도 제한이 아니라 사용량 한도입니다. Responses 본문의 `error.type`과 `error.code`는 `usage_limit_reached`이고, 묶인 모든 계정의 업스트림 리셋 시각을 알면 `resets_at`(가장 이른 리셋의 epoch 초)도 담습니다. Codex는 "exceeded retry limit"까지 재시도하지 않고 언제 다시 시도할지 보여 주며 멈춥니다. 마지막 계정이 할당량 소진으로 거부하고 대체 계정이 없을 때도 같은 응답을 받습니다. 묶인 계정 중 하나라도 일시적 속도 제한, 모델 계열 재확인 중, 권한 거부 상태라면 재시도 가능한 `rate_limit_exceeded`를 유지합니다. Combo 대상은 다음 대상으로 넘어갈 수 있도록 속도 제한 형식을 유지합니다. 네이티브 `/v1/messages`는 Anthropic의 `rate_limit_error` 타입을 유지하고 메시지에서 사용량 한도를 알립니다. + +새 사용량 조회(대시보드 프로바이더 페이지 또는 `ocx account list anthropic --quota --refresh`)에서 거부된 모든 창이 100% 미만으로 나오면, 할당량 쿨다운은 `Retry-After`에서 왔든 창 리셋에서 왔든 일찍 풀립니다. 플랜 업그레이드나 창이 일찍 다시 열린 경우가 여기에 해당합니다. 거부 이전에 시작된 조회는 쿨다운을 풀지 않으며, 권한 403 쿨다운은 기한까지 기다립니다. + :::caution[실험적 기능] Anthropic 계정 정책 위험을 이해하지 못한다면 이 기능은 꺼두십시오. 확신이 없으면 수동 `ocx account use anthropic ` 전환을 우선하십시오. ::: diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index b07aac6f57a..60050d9b663 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -227,7 +227,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `modelSupportsServiceTier?` | `Record` | Exact upstream model capability overrides. Exact `true` enables canonical Fast for that model; exact `false` narrows provider defaults. An explicit provider-level `supportsServiceTier: false` remains fail-closed and cannot be reopened. Exact `true` does not authorize foreign caller-tier forwarding on Chat. Undeclared models fall back to provider-wide behavior. Management `PATCH /api/providers` merges entries and accepts `null` to clear one. | | `chatServiceTier?` | `boolean` | Provider-wide Chat-wire opt-in for forwarding caller `service_tier` values. On a classified route it governs foreign values such as `flex`, not proxy-owned canonical Fast after capability validation; on an unclassified route it governs every caller value because no Fast capability has been validated. Exact model capability does not authorize foreign forwarding. Responses routes retain their capability-based caller forwarding behavior. | | `promptCacheKey?` | `boolean` | Provider-wide `openai-chat` opt-in for forwarding a `prompt_cache_key`. The adapter forwards the key it is given and never invents one, but the key is not always the caller's: Claude Messages translation derives one from `metadata.user_id`, or from a model/system/tools cohort when no metadata is sent. Default off. Enable only when the upstream documents support, because strict gateways may reject the unknown field with HTTP 400. | -| `preserveResponsesReasoningContent?` | `boolean` | Keep plaintext reasoning content on replayed Responses reasoning items instead of blanking it (blanking is the ChatGPT backend's rule). Enable for upstreams whose contract accepts reasoning replay, such as DeepSeek or a self-hosted Kimi on vLLM. Proxy-minted `ocxr1` envelopes are always stripped. A hand-configured `openai-responses` provider without it logs one warning per host the first time plaintext reasoning is dropped. `preserveReasoningContentModels` does not affect this wire. | +| `preserveResponsesReasoningContent?` | `boolean` | Default off for custom providers. In **Providers → Settings**, enable **Replay plaintext reasoning** for a custom Responses endpoint only if it accepts this history. This sends prior plaintext reasoning to that endpoint and may increase token use. Keep plaintext reasoning content on replayed Responses reasoning items instead of blanking it (blanking is the ChatGPT backend's rule). Enable for upstreams whose contract accepts reasoning replay, such as DeepSeek or a self-hosted Kimi on vLLM. Proxy-minted `ocxr1` envelopes are always stripped. A hand-configured `openai-responses` provider without it logs one warning per host the first time plaintext reasoning is dropped. `preserveReasoningContentModels` does not affect this wire. | | `preserveResponsesInputItemIds?` | `boolean` | Default off. Preserve valid `input[].id` when `store: false` for explicitly trusted launcher relays that require turn identity. Ordinary Responses providers keep stripping IDs; xAI custom-call ID repair remains active. | | `preserveResponsesMessageMetadata?` | `boolean` | Default off. Preserve private `internal_chat_message_metadata_passthrough` on input items for explicitly trusted launcher relays. This forwards private turn/session metadata to that destination; do not enable for ordinary gateways. | | `dropResponsesReasoningItems?` | `boolean` | Remove replayed Responses `reasoning` items from continuation input entirely, instead of forwarding them with blanked content. This is lossy: summaries, item ids, and `encrypted_content` go with them, so no reasoning item from the previous turn remains in the forwarded input. It does not remove `previous_response_id`, so an upstream holding its own turn state may still reach it. Enable only for an upstream that rejects reasoning replay outright — the built-in Volcengine Ark Coding Plan preset sets it because validated tool continuations answered `400 InvalidParameter` otherwise. Set it to `false` on that provider to forward reasoning items again. | @@ -950,6 +950,23 @@ Affinity is process-local and size-bounded. Token-refresh credential failures retain the existing reauthentication policy. Classified pre-output account-entitlement/billing 403s clear affinity and cool the account for `Retry-After`, or ten minutes by default, before trying an eligible replacement. Generic or request-level 403s remain terminal; see [Claude account recovery](/guides/claude-code/). If all eligible accounts are cooling, clients receive 429 with `Retry-After` when known, not an authentication error. +When every account the request can use is held on spent quota (a shared 5-hour or weekly window, +or the requested model family's weekly window), that 429 is a usage limit rather than a rate limit: +the Responses body carries `error.type` and `error.code` `usage_limit_reached` and, when the +upstream reset of every held account is known, `resets_at` (epoch seconds of the earliest reset). +Codex stops on this answer and shows when to try again instead of retrying until +"exceeded retry limit". The last account's own spent-quota refusal gets the same answer once no +replacement is left. If any held account is only throttled, revalidating a model family, or +refused for entitlement, the answer stays the retryable `rate_limit_exceeded`. Combo targets keep +the rate-limit form so the combo can move to its next target. Native `/v1/messages` keeps +Anthropic's `rate_limit_error` type; its message names the usage limit. + +A quota cooldown lifts early once a fresh usage reading (the dashboard Providers page, or +`ocx account list anthropic --quota --refresh`) shows every window the refusal rejected below +100%, whether the cooldown came from `Retry-After` or a window reset. This covers a plan upgrade +or a window that reopened early. A reading taken before the refusal never lifts it, and +entitlement 403 cooldowns wait for their deadline. + Anthropic responses also report the serving account's 5-hour and weekly utilization, and whichever of those two a given response carries is recorded against that account — each window independently, on refusals as well as successes. Usage-aware selection therefore works from the accounts you diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 424cd5af5aa..31eb6dcee0d 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -266,6 +266,10 @@ reauth или эффективного порога исчерпания это Если все eligible-аккаунты в cooldown, клиент получает 429 с `Retry-After`, если он известен, а не authentication error. +Если каждый аккаунт, доступный запросу, удерживается исчерпанной квотой (общее пятичасовое или недельное окно либо недельное окно семейства запрошенной модели), этот 429 — лимит использования, а не лимит скорости: тело Responses содержит `error.type` и `error.code` `usage_limit_reached` и, если известен upstream-сброс каждого удерживаемого аккаунта, `resets_at` (epoch-секунды ближайшего сброса). Codex останавливается и показывает, когда повторить, вместо повторов до «exceeded retry limit». Последний отказ upstream из-за исчерпанной квоты получает тот же ответ, когда замены не осталось. Если хотя бы один удерживаемый аккаунт лишь временно ограничен по скорости, перепроверяет семейство моделей или получил отказ по правам доступа, ответ остаётся повторяемым `rate_limit_exceeded`. Цели combo сохраняют форму лимита скорости, чтобы combo перешёл к следующей цели. Нативный `/v1/messages` сохраняет тип Anthropic `rate_limit_error`, а сообщение называет лимит использования. + +Cooldown по квоте снимается раньше, как только свежее чтение использования (страница Providers в dashboard или `ocx account list anthropic --quota --refresh`) показывает каждое отклонённое окно ниже 100%, независимо от того, задан ли cooldown через `Retry-After` или сбросом окна. Это покрывает повышение тарифа и досрочно открывшееся окно. Чтение, начатое до отказа, его никогда не снимает, а cooldown после 403 по правам доступа ждёт своего срока. + :::caution[Experimental] Оставляйте эту функцию выключенной, если не понимаете policy-risk аккаунтов Anthropic. Если сомневаетесь, безопаснее переключать аккаунты вручную через diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md index 7feb6aff39d..18291029c9b 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/providers.md +++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md @@ -282,6 +282,10 @@ Yalnızca ortak 5 saatlik veya haftalık kotanın reddini doğrulayan 429 hesab boyut sınırlıdır. Token yenileme hataları mevcut yeniden kimlik doğrulama kurallarını korur. Doğrulanmış abonelik veya hesap ödeme 403 hatası çıktı başlamadan hesap değiştirebilir ve `Retry-After` veya varsayılan on dakika soğuma uygular. Genel izin reddi hesap değiştirmez. Uygun tüm hesaplar soğuyorsa istemciler bir kimlik doğrulama hatası değil, bilindiğinde `Retry-After` ile 429 alır. +İsteğin kullanabileceği her hesap tükenmiş kotada tutuluyorsa (ortak 5 saatlik veya haftalık pencere ya da istenen model ailesinin haftalık penceresi), bu 429 hız sınırı değil kullanım sınırıdır: Responses gövdesi `error.type` ve `error.code` olarak `usage_limit_reached` taşır ve tutulan her hesabın upstream sıfırlama zamanı biliniyorsa `resets_at` (en erken sıfırlamanın epoch saniyesi) ekler. Codex "exceeded retry limit" olana kadar yeniden denemek yerine durur ve ne zaman tekrar deneneceğini gösterir. Son hesabın tükenmiş kota reddi, yedek kalmadığında aynı yanıtı alır. Tutulan hesaplardan biri yalnızca geçici olarak hız sınırına takılmışsa, bir model ailesini yeniden doğruluyorsa veya yetki nedeniyle reddedilmişse yanıt yeniden denenebilir `rate_limit_exceeded` olarak kalır. Combo hedefleri, combo'nun sonraki hedefe geçebilmesi için hız sınırı biçimini korur. Yerel `/v1/messages` Anthropic'in `rate_limit_error` türünü korur; mesajı kullanım sınırını belirtir. + +Taze bir kullanım okuması (dashboard Sağlayıcılar sayfası veya `ocx account list anthropic --quota --refresh`) reddedilen her pencerenin %100'ün altında olduğunu gösterdiğinde kota soğuması, `Retry-After`'dan da pencere sıfırlamasından da gelse erken kalkar. Plan yükseltmesi veya erken yeniden açılan bir pencere buna örnektir. Retten önce başlayan bir okuma onu asla kaldırmaz; yetki 403 soğumaları sürelerini bekler. + :::caution[Deneysel] Anthropic hesap politikası riskini anlamadığınız sürece bunu devre dışı bırakın. Emin olmadığınızda manuel `ocx account use anthropic ` geçişini tercih edin. diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index a02f4d243b4..a399340db0d 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -253,6 +253,10 @@ affinity。这些策略不能规避 provider enforcement。 只有共享5小时或每周额度明确拒绝的429才会冷却账户并切换。临时速率限制保留亲和性,只暂停该账户的请求准入;每个请求最多一次短暂的同账户重试和一次合格兄弟账户切换。没有依据响应头的429只允许一次同账户短暂重试,不冷却账户,也不生成Retry-After。默认单账户行为不变。Fable专属拒绝不限制Sonnet;手动选择和亲和性均检查请求模型的共享与家族额度。被动家族信息在30分钟或已知重置时到期,由一个服务请求串行重新验证。使用阈值仍是软偏好,全部候选耗尽时保留原有回退,不是用量或账单硬上限。亲和性是进程本地的,并且有大小上限。令牌刷新失败保留原有重新认证规则。明确的订阅或账户计费 403 可在输出前切换账户,并按 `Retry-After` 或默认十分钟冷却;普通权限拒绝不会切换。如果所有合格账户都在冷却,客户端会在已知时收到带 `Retry-After` 的 429,而不是身份验证错误。 +当请求可用的每个账户都因配额耗尽而被挂起(共享 5 小时或每周窗口,或所请求模型家族的每周窗口)时,这个 429 是用量上限而不是速率限制:Responses 正文的 `error.type` 和 `error.code` 为 `usage_limit_reached`;若每个被挂起账户的上游重置时间都已知,还会带上 `resets_at`(最早重置的 epoch 秒)。Codex 会停止并显示何时可以重试,而不是一直重试到 "exceeded retry limit"。最后一个账户因配额耗尽被拒且没有可替换账户时,也会得到同样的响应。只要有一个被挂起账户只是临时限速、正在重新验证模型家族或因权限被拒,响应就保持可重试的 `rate_limit_exceeded`。Combo 目标保持速率限制形式,以便 combo 转到下一个目标。原生 `/v1/messages` 保持 Anthropic 的 `rate_limit_error` 类型,消息中会说明用量上限。 + +只要一次新的用量读取(仪表盘的提供方页面,或 `ocx account list anthropic --quota --refresh`)显示被拒绝的每个窗口都低于 100%,配额冷却就会提前解除,无论它来自 `Retry-After` 还是窗口重置。这适用于升级套餐或窗口提前重新开放的情况。在拒绝之前开始的读取永远不会解除它,权限类 403 冷却会等到其截止时间。 + :::caution[Experimental] 除非你理解 Anthropic 账户策略风险,否则请保持关闭。若不确定,优先手动使用 `ocx account use anthropic ` 切换。 ::: diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 9689311fb77..f89509ceff0 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -187,6 +187,10 @@ API-key 供應商可持有字面值金鑰或環境參考。OAuth 供應商使用 只有共用5小時或每週額度明確拒絕的429才會冷卻帳號並切換。暫時速率限制保留親和性,只暫停該帳號的請求准入;每個請求最多一次短暫的同帳號重試及一次合格兄弟帳號切換。沒有依據回應標頭的429只允許一次同帳號短暫重試,不冷卻帳號,也不產生Retry-After。預設單帳號行為不變。Fable專屬拒絕不限制Sonnet;手動選擇和親和性均檢查請求模型的共用與家族額度。被動家族資訊在30分鐘或已知重設時到期,由一個服務請求依序重新驗證。用量門檻仍是軟偏好,全部候選耗盡時保留原有退回機制,不是用量或帳單硬上限。親和性為行程本地且有界。Token 更新失敗保留原有重新認證規則。已分類的訂閱或帳號計費 403 可在輸出前切換帳號,並按 `Retry-After` 或預設十分鐘冷卻;一般權限拒絕不切換。若所有合格帳號都在冷卻,客戶端收到附帶已知 `Retry-After` 的 429,而非認證錯誤。 +當請求可用的每個帳號都因配額耗盡而被保留(共用 5 小時或每週視窗,或所請求模型家族的每週視窗)時,這個 429 是用量上限而非速率限制:Responses 本文的 `error.type` 與 `error.code` 為 `usage_limit_reached`;若每個被保留帳號的上游重設時間都已知,還會附上 `resets_at`(最早重設的 epoch 秒)。Codex 會停止並顯示何時可再試,而不是一路重試到 "exceeded retry limit"。最後一個帳號因配額耗盡被拒且沒有可替換帳號時,也會得到相同回應。只要有一個被保留帳號只是暫時限速、正在重新驗證模型家族或因權限被拒,回應就維持可重試的 `rate_limit_exceeded`。Combo 目標維持速率限制形式,讓 combo 能轉到下一個目標。原生 `/v1/messages` 維持 Anthropic 的 `rate_limit_error` 類型,訊息中會說明用量上限。 + +只要一次新的用量讀取(儀表板的提供者頁面,或 `ocx account list anthropic --quota --refresh`)顯示每個被拒絕的視窗都低於 100%,配額冷卻就會提前解除,不論它來自 `Retry-After` 或視窗重設。這適用於升級方案或視窗提早重新開放的情況。在拒絕之前開始的讀取永遠不會解除它,權限類 403 冷卻會等到其截止時間。 + :::caution[實驗性] 除非你了解 Anthropic 帳號政策風險,否則保持停用。不確定時偏好手動 `ocx account use anthropic ` 切換。 ::: diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 1ffb929af2c..10becb34b20 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -569,6 +569,14 @@ export default function App() { onFocusOrphaned={focusAfterSwitcher} /> )} + {/* Outside the page boundary: a page that fails to render must not hide whose state this is. */} + {targetsSettled && targets.link && ( + + {targets.link.home + ? t("connection.linkManaged", { home: targets.link.home, linkId: targets.link.linkId }) + : t("connection.linkManagedUnnamed", { linkId: targets.link.linkId })} + + )} ; + if (typeof row.linkId !== "string" || !/^lnk_[0-9a-f]{16}$/.test(row.linkId)) return undefined; + const home = typeof row.home === "string" && row.home.trim() && row.home.length <= 255 ? row.home.trim() : null; + return { linkId: row.linkId, home }; +} + export function relayUrlForPath(shared: ApiTarget, path: string): string { if (shared.transport !== "relay" || (!path.startsWith("/api/") && path !== "/opencodex-session")) { throw new TypeError("path is not eligible for the fixed hub relay"); @@ -174,7 +186,8 @@ export function targetsFromMachineStatus(initialBase: string, status: MachineSta const shared = status.managementTransport === "relay" ? target("shared", `${trimBase(initialBase)}/api/machine/hub-relay`, sharedOrigin, "relay") : target("shared", sharedOrigin, sharedOrigin, "direct"); - return { connected: true, machine, shared, apiKeyId: status.apiKeyId, catalogSyncedAt: status.catalogSyncedAt }; + const link = linkFromStatus(status.link); + return { connected: true, machine, shared, apiKeyId: status.apiKeyId, catalogSyncedAt: status.catalogSyncedAt, ...(link ? { link } : {}) }; } export function apiBaseForPlane(plane: ApiPlane, targets: ApiTargets): string { diff --git a/gui/src/components/add-provider-form-pane.tsx b/gui/src/components/add-provider-form-pane.tsx index ba60e4902d4..8122f080a1e 100644 --- a/gui/src/components/add-provider-form-pane.tsx +++ b/gui/src/components/add-provider-form-pane.tsx @@ -5,6 +5,8 @@ import type { CatalogPreset } from "./provider-catalog/provider-presets"; import type { ProviderPayloadForm } from "../provider-payload"; import { AddProviderField } from "./add-provider-modal-field"; import { baseUrlForChoice } from "../base-url-choice"; +import { canConfigureResponsesReasoningReplay } from "../responses-reasoning-replay"; +import { ResponsesReasoningReplayControl } from "./responses-reasoning-replay-control"; import { isSubscriptionCliProvider, SUBSCRIPTION_CLI_API_KEY_ALTERNATIVE } from "../provider-workspace/subscription-cli"; const FORM_ADAPTERS = ["openai-responses", "openai-chat", "anthropic", "google", "azure-openai", "cursor"]; @@ -131,6 +133,10 @@ export function AddProviderFormPane({

{t("modal.allowPrivateNetworkHint")}

)} } + {isCustom && canConfigureResponsesReasoningReplay(form) && ( + onFormChange({ ...form, preserveResponsesReasoningContent: checked })} /> + )} {form.authMode === "forward" ? (
{presetDescription(preset)} diff --git a/gui/src/components/provider-workspace/ProviderSettings.tsx b/gui/src/components/provider-workspace/ProviderSettings.tsx index 436dc3a97d5..9856e7b90f9 100644 --- a/gui/src/components/provider-workspace/ProviderSettings.tsx +++ b/gui/src/components/provider-workspace/ProviderSettings.tsx @@ -18,6 +18,8 @@ import { useT } from "../../i18n/shared"; import { IconLock } from "../../icons"; import { isCatalogProviderId } from "../../provider-icons"; import { openAiAccountProviderState } from "../../provider-payload"; +import { canConfigureResponsesReasoningReplay } from "../../responses-reasoning-replay"; +import { ResponsesReasoningReplayControl } from "../responses-reasoning-replay-control"; import { providerSupportsLiveModelDiscovery } from "../../provider-workspace/catalog"; import type { CatalogPreset } from "../provider-catalog/provider-presets"; import { authModeLabel } from "./ProviderRail"; @@ -84,6 +86,7 @@ export default function ProviderSettings({ const [apiKeyTransport, setApiKeyTransport] = useState(item.apiKeyTransport ?? "x-api-key"); const [note, setNote] = useState(item.note ?? ""); const [allowPrivateNetwork, setAllowPrivateNetwork] = useState(item.allowPrivateNetwork ?? false); + const [preserveReasoningReplay, setPreserveReasoningReplay] = useState(item.preserveResponsesReasoningContent === true); const [liveModels, setLiveModels] = useState(savedLiveModels); const [cursorHttpVersion, setCursorHttpVersion] = useState(savedCursorHttpVersion); const [saving, setSaving] = useState(false); @@ -114,6 +117,7 @@ export default function ProviderSettings({ setApiKeyTransport(item.apiKeyTransport ?? "x-api-key"); setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); + setPreserveReasoningReplay(item.preserveResponsesReasoningContent === true); setLiveModels(savedLiveModels); setCursorHttpVersion(savedCursorHttpVersion); setPacingEnabled(item.requestPacing?.enabled === true); @@ -124,7 +128,7 @@ export default function ProviderSettings({ setMsg(null); setModeMsg(null); queueMicrotask(() => setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl))); - }, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, savedLiveModels, savedCursorHttpVersion, item.requestPacing, baseUrlChoices]); + }, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, item.preserveResponsesReasoningContent, savedLiveModels, savedCursorHttpVersion, item.requestPacing, baseUrlChoices]); /* eslint-enable react-hooks/set-state-in-effect */ // Account mode syncs on its own: a mode PATCH refresh must not reset an in-progress @@ -194,7 +198,10 @@ export default function ProviderSettings({ ...(Object.keys(pacingModels).length > 0 ? { models: pacingModels } : {}), }), [pacingConcurrency, pacingDelay, pacingEnabled, pacingModels, pacingRpm]); - const dirty = adapter.trim() !== item.adapter + const supportsReasoningReplay = !isCatalogProviderId(item.name) + && canConfigureResponsesReasoningReplay({ adapter, baseUrl, authMode }); + const reasoningReplayDirty = supportsReasoningReplay && preserveReasoningReplay !== (item.preserveResponsesReasoningContent === true); + const dirty = reasoningReplayDirty || adapter.trim() !== item.adapter || baseUrl.trim() !== item.baseUrl || defaultModel.trim() !== (item.defaultModel ?? "") || authMode !== String(item.authMode ?? (item.keyOptional ? "local" : "key")) @@ -262,6 +269,7 @@ export default function ProviderSettings({ // Keep omitted legacy values omitted unless the user actually changes this toggle. // Otherwise an unrelated settings save manufactures `liveModels: true` provenance. if (liveModelDiscoverySupported && liveModels !== (item.liveModels !== false)) patch.liveModels = liveModels; + if (reasoningReplayDirty) patch.preserveResponsesReasoningContent = preserveReasoningReplay; if (adapter.trim() === "cursor" && cursorHttpVersion !== savedCursorHttpVersion) { patch.upstreamHttpVersion = cursorHttpVersion === "http1.1" ? "http1.1" : null; } @@ -328,6 +336,7 @@ export default function ProviderSettings({ setDefaultModel(item.defaultModel ?? ""); setAuthMode(initialAuth); setApiKeyTransport(item.apiKeyTransport ?? "x-api-key"); setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(savedLiveModels); + setPreserveReasoningReplay(item.preserveResponsesReasoningContent === true); setCursorHttpVersion(savedCursorHttpVersion); setMsg(null); setPacingEnabled(item.requestPacing?.enabled === true); setPacingRpm(numberDraft(item.requestPacing?.requestsPerMinute)); setPacingDelay(numberDraft(item.requestPacing?.minIntervalMs)); @@ -502,6 +511,7 @@ export default function ProviderSettings({ setAllowPrivateNetwork(e.target.checked)} /> {t("pws.allowPrivateNetwork")} + {supportsReasoningReplay && }