diff --git a/README.md b/README.md index 216dbbf..f40cf5a 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ macOS 状态栏(Menu Bar)AI Agent 调度器 —— 让 Claude Code、Codex | 串行 / 有限并行 | `max_parallel`(默认 1 串行)+ `per_tool_limit` | | 完整生命周期 | queued / running / succeeded / failed / **waiting_quota** / paused / cancelled | | 额度耗尽不判失败 | 识别限流报错 → `waiting_quota`,解析恢复时间或指数退避,**自动 resume 原会话续跑** | -| 额度状态可见(不伪造) | 额度来源显式 opt-in;手动选择 Claude 模型家族或 Codex `metered_feature`,输入 OAuth Access Token 后才请求 | +| 额度状态可见(不伪造) | 额度来源显式 opt-in;Codex 可选 `limitId`,两种 CLI 登录态都自动检测,无需复制 Token;不支持时显示未知而非猜测 | | 可控制刷新 | 默认只在启动、保存配置或手动点击时请求一次;任务结束不再强制刷新全部来源,重复点击会合并 | | 菜单栏双环额度图标 | 外圈 Claude、内圈 Codex 用量一眼可见(同 aiusagebar 的外围圈样式),不用点开菜单;环心徽标显示调度状态(实心点=运行中、双竖条=已暂停),菜单栏只有这一个图标;模板图自适配深浅色;无可信数据只画轨道不编造 | | 键盘快捷键 | 面板/对话窗口前台时:`⌃W`/`⌘W` 关闭当前窗口,`⌃Q`/`⌘Q` 退出(走完整清理:停隧道/调度器/服务器) | @@ -77,6 +77,10 @@ AppKit/WebKit/登录/二维码/Markdown 模块、内置 Web 资源及 DMG 完整 架构跟随构建机(Apple Silicon 产出 arm64,Intel 产出 x86_64),当前不生成 Universal 2 包。 +AgentBar.app 不内置、复制或修改 Claude Code / Codex CLI。需要使用对应任务或 +额度状态时,请另行安装并登录官方 CLI;App 会沿用 `tool_paths`、当前 `PATH`, +以及 launchd 场景下登录 shell 能解析到的绝对路径。 + 启动后点菜单栏 🤖 →「打开任务面板」,或: ```bash @@ -115,21 +119,23 @@ uv run agentbar pause / resume / cancel / log | ⚠️ full | `--dangerously-skip-permissions` | `--dangerously-bypass-approvals-and-sandbox` | - **full 档默认禁用**:需在 `~/.agentbar/config.json` 设 `allow_full_profile: true` 并重启,UI 中也有显式警告。 -- API 所有写操作都要求 AgentBar token,并校验 Host 头防 DNS rebinding;手动额度凭据只接受本机回环地址提交,避免经局域网明文传输。 -- OAuth Token / Account ID 是只写字段:Web、状态快照、菜单调试文件均只返回“是否已配置”;落盘的 `config.json` 权限固定为 `0600`。 +- API 所有写操作都要求 AgentBar token,并校验 Host 头防 DNS rebinding;企业 Cookie 只接受本机回环地址提交,避免经局域网明文传输。 +- Claude/Codex 的 OAuth Token 与 Account ID 不进入 AgentBar 配置、Web API、状态快照或日志。Codex 额度由官方 App Server 代查;Claude 只调用 `claude auth status --json` 检测登录状态,不读取 Keychain 或登录文件。 - 子进程以 argv 数组直接 exec,无 shell 拼接;prompt 走 stdin,杜绝 flag 注入。 - 每个任务独立进程组,取消/超时(默认 2h)时整组终止,不留孤儿进程。 ## 额度状态的数据来源(诚实降级) -订阅版 CLI 没有承诺稳定的公开额度查询 API。AgentBar 因此只请求用户显式配置的来源,任何一种拿不到都如实降级: +AgentBar 只在用户显式启用来源后查询,任何一种拿不到都如实降级: -1. **usage API**(显式配置):Claude / Codex 默认关闭。在「额度设置」中启用来源、选择额度模型,并输入对应的 **OAuth Access Token** 后才会请求。Codex 可选填 Account ID。普通 Anthropic/OpenAI API key 不等于订阅 usage 凭据,接口拒绝时会如实显示错误。 +1. **CLI 登录态(自动)**:Claude / Codex 默认关闭。在「额度设置」中启用来源即可,无需填写密钥;Codex 还可从已发现的 `limitId` 中选择模型专属额度,留空则使用默认账户窗口。 + - Codex 通过官方 [`codex app-server`](https://developers.openai.com/codex/app-server) 的 stdio 接口尝试调用 `account/read` 与 `account/rateLimits/read`;OAuth Token、Account ID、`auth.json` 内容都不会暴露给 AgentBar。该命令目前仍由 Codex 标记为 **experimental**,AgentBar 会先做运行时能力探测;CLI 过旧、方法不存在、响应异常或超时时,立即结束短生命周期探测进程并降级为「未知」/observed,不读取 `auth.json` 兜底。API-key-only 登录不提供 ChatGPT 订阅额度,界面会如实提示。 + - Claude 只通过官方 `claude auth status --json` 检测登录状态,不读取或代用 OAuth 凭据。按照 Anthropic 的[认证与凭据使用边界](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use),Claude 剩余额度只展示调度器观测到的真实限流/恢复状态及可选 `ccusage` 本地成本,不伪造百分比。 2. **observed**:调度器自身观测的最近成功执行、真实限流和恢复时间;它会优先标记已确认的限流。 3. **ccusage**(可选增强):`npm i -g ccusage` 后补充 Claude 本地 5h 成本。 4. 无任何可用数据时显示「未知」,并显示失败原因。**不会估算或编造百分比。** -usage 响应不是稳定的公开契约,接口结构变化时会显示解析错误而非虚构数值。默认不周期轮询;如需显式开启: +Codex App Server 是官方但仍属实验性的能力;契约变化或本机 CLI 状态不可用时会显示明确错误而非虚构数值。默认不周期轮询;如需显式开启: ```jsonc { "usage_auto_refresh": true, "usage_refresh_seconds": 120 } @@ -181,18 +187,13 @@ agentbar.log # 调度器日志 "usage_auto_refresh": false, // false=默认手动;true=按上方间隔轮询 "tool_paths": {}, // {"claude": "/abs/path"} 手动覆盖 "title_provider": "claude", // 状态栏标题显示哪个 provider 的用量:claude/codex/mytoken/tokenverse - "quota_sources": { // 订阅额度:默认关闭,必须手动输入凭据 + "quota_sources": { // 订阅额度:默认关闭;探测 CLI 状态,但不把其凭据存入 AgentBar "claude": { - "enabled": false, - "model": "sonnet", // opus / sonnet;留空=账户通用窗口 - "access_token": "", // OAuth Access Token,非普通 API key - "account_id": "" + "enabled": false // 自动检测 Claude Code 登录;额度用 observed/ccusage }, "codex": { "enabled": false, - "model": "", // metered_feature;留空=账户总额度 - "access_token": "", // ChatGPT OAuth Access Token - "account_id": "" // 可选;JWT 不含账号时填写 + "model": "" // App Server limitId;留空=账户默认额度 } }, "providers": { // 快手内部额度 provider(默认关闭,可在面板一键导入 Cookie) @@ -263,7 +264,7 @@ bash -n scripts/*.sh # 安装、重启、卸载与 DMG 脚本语法 ## 已知限制(当前版本) -- usage 响应不是公开契约,接口结构变化时显示解析错误而非虚构数值。 +- Anthropic 未提供面向第三方的订阅剩余额度接口,因此 Claude 不显示猜测的百分比;Codex App Server 虽是官方接口但仍标记为 experimental,能力不可用时会诚实降级。 - 修改 `config.json` 需重启生效(无热加载)。 - 任务级依赖(A 完成才跑 B)未实现,当前是 FIFO + 并发上限。 - Menu Bar 使用原生矢量双环图标,自动适配深浅色;没有可信额度数据时只画轨道。 diff --git a/agentbar/__init__.py b/agentbar/__init__.py index 18563f9..0f4a62a 100644 --- a/agentbar/__init__.py +++ b/agentbar/__init__.py @@ -1,3 +1,3 @@ """AgentBar — macOS menu bar scheduler for AI CLI agents.""" -__version__ = "0.10.4" +__version__ = "0.10.5" diff --git a/agentbar/config.py b/agentbar/config.py index 9603879..310c40e 100644 --- a/agentbar/config.py +++ b/agentbar/config.py @@ -15,25 +15,18 @@ from pathlib import Path DEFAULT_PORT = 8737 -CONFIG_SCHEMA_VERSION = 2 +CONFIG_SCHEMA_VERSION = 3 LEGACY_CLAUDE_CREDENTIAL_CACHE = "claude_credentials.json" LEGACY_CLAUDE_CREDENTIAL_TEMP = "claude_credentials.tmp" -# Claude/Codex 订阅额度默认不读取本机登录态。用户必须在额度设置中 -# 显式启用、选择模型并输入 OAuth access token。这样未配置的来源不会读 Keychain、 -# 不会扫描 CLI 凭据,也不会发起网络请求。 +# 额度来源默认关闭。Claude 只提供 CLI 登录状态(无官方第三方 +# 订阅额度 API);Codex 通过官方 App Server 获取可选 limitId。配置中 +# 绝不保存两者的 OAuth token 或 account id。 DEFAULT_QUOTA_SOURCES: dict = { - "claude": { - "enabled": False, - "model": "", - "access_token": "", - "account_id": "", - }, + "claude": {"enabled": False}, "codex": { "enabled": False, "model": "", - "access_token": "", - "account_id": "", }, } @@ -98,7 +91,7 @@ def _merge_providers(user: dict | None) -> dict: def _merge_quota_sources(user: dict | None) -> dict: - """Return canonical quota-source config and migrate legacy ``api_key``.""" + """Return the token-free v3 quota-source configuration.""" merged = copy.deepcopy(DEFAULT_QUOTA_SOURCES) user = user if isinstance(user, dict) else {} for name, defaults in merged.items(): @@ -107,34 +100,15 @@ def _merge_quota_sources(user: dict | None) -> dict: enabled = override.get("enabled") defaults["enabled"] = enabled if isinstance(enabled, bool) else False - for key, max_len in (("model", 160), ("account_id", 256)): - value = override.get(key) - defaults[key] = ( + if name == "codex": + value = override.get("model") + defaults["model"] = ( value.strip() if isinstance(value, str) - and len(value) <= max_len + and len(value) <= 160 and not any(char in value for char in "\r\n\x00") else "" ) - - # v0.10.3 预发布配置曾把 OAuth token 误称为 api_key。 - # 新字段缺失时无损迁移;显式 access_token 始终优先。 - token = ( - override.get("access_token") - if "access_token" in override - else override.get("api_key") - ) - defaults["access_token"] = ( - token.strip() - if isinstance(token, str) - and len(token) <= 16_384 - and not any(char in token for char in "\r\n\x00") - else "" - ) - # Invalid/missing credentials must never leave an enabled source - # that crashes fetcher construction or repeatedly reports errors. - if defaults["enabled"] and not defaults["access_token"]: - defaults["enabled"] = False return merged @@ -165,7 +139,7 @@ class Settings: token: str = "" # 快手内部额度 provider 配置(见 DEFAULT_PROVIDERS);cookie 空 / enabled=False 则不拉取。 providers: dict = field(default_factory=lambda: copy.deepcopy(DEFAULT_PROVIDERS)) - # Claude/Codex 额度查询:显式选择来源/模型,并配置 OAuth access token。 + # Claude CLI 登录状态 / Codex App Server 额度;不持久化 OAuth 凭据。 quota_sources: dict = field(default_factory=lambda: copy.deepcopy(DEFAULT_QUOTA_SOURCES)) # 菜单栏标题显示哪个 provider 的用量百分比:claude / codex / mytoken / tokenverse title_provider: str = "claude" @@ -348,7 +322,8 @@ def _load_settings_holding_file_lock( raw_sources = data.get("quota_sources") legacy_quota_key = isinstance(raw_sources, dict) and any( - isinstance(source, dict) and "api_key" in source + isinstance(source, dict) + and bool({"api_key", "access_token", "account_id"} & set(source)) for source in raw_sources.values() ) changed = bool( @@ -474,6 +449,9 @@ def save_settings(s: Settings) -> None: # ``s._lock``, and reversing the order would deadlock against such a caller # while another thread is doing a plain save. with s._lock, _SAVE_LOCK: + # Never persist legacy credentials even if an older UI/client mutates a + # Settings object in memory before calling save. + s.quota_sources = _merge_quota_sources(s.quota_sources) cfg = s.config_path cfg.parent.mkdir(parents=True, exist_ok=True) lock_path = cfg.with_name(".config.lock") diff --git a/agentbar/menu_spec.py b/agentbar/menu_spec.py index e3b44ee..0cfb2da 100644 --- a/agentbar/menu_spec.py +++ b/agentbar/menu_spec.py @@ -144,12 +144,13 @@ def _quota_submenu(tool: str, qi: dict, quota_cfg: dict | None = None) -> dict: children: list[dict] = [] model = str(qi.get("model") or "").strip() if model: - children.append(_info(f"模型 {model}")) + selector_name = "limitId" if tool == "codex" else "额度分类" + children.append(_info(f"{selector_name} {model}")) available_models = [ str(value) for value in (qi.get("available_models") or []) if str(value).strip() ] if available_models: - children.append(_info(f"可选额度标识 {'、'.join(available_models[:4])}")) + children.append(_info(f"可选 limitId {'、'.join(available_models[:4])}")) for w in qi.get("windows") or []: line = f"{w['label']} 已用 {_window_value(w)}" if w.get("unit") in ("credits", "token"): @@ -172,11 +173,11 @@ def _quota_submenu(tool: str, qi: dict, quota_cfg: dict | None = None) -> dict: children.append(_action(f"⚠ {qi['error'][:70]}")) children.append(_sep()) if tool in ("claude", "codex"): - # 任务观测也可能产生 quota 行,但只有显式启用且已配 - # 手动密钥的来源才能请求上游;配置入口始终保留。 + # 任务观测也可能产生 quota 行。只有显式启用的来源才可 + # 请求来源;AgentBar 只调用 CLI 的受支持状态/额度接口,不读取凭据。 quota_cfg = quota_cfg or {} - if quota_cfg.get("enabled") and quota_cfg.get("key_set"): - children.append(_action("↻ 立即刷新额度", f"refresh_quota:{tool}")) + if quota_cfg.get("enabled"): + children.append(_action("↻ 重新检测并刷新额度", f"refresh_quota:{tool}")) children.append(_action(f"⚙ 配置 {_provider_name(tool)}…", "provider_settings")) else: children.append(_action("↻ 立即刷新额度", f"refresh_quota:{tool}")) @@ -190,27 +191,20 @@ def _quota_submenu(tool: str, qi: dict, quota_cfg: dict | None = None) -> dict: def _quota_source_setup_submenu(tool: str, cfg: dict) -> dict: - """Claude/Codex manual quota setup row when no live snapshot exists.""" + """Claude/Codex automatic CLI-auth setup row without a live snapshot.""" enabled = bool(cfg.get("enabled")) - key_set = bool(cfg.get("key_set")) model = str(cfg.get("model") or "").strip() - if enabled and key_set: + if enabled: state = "等待刷新" - detail = "已启用且额度凭据已配置。" - elif enabled: - state = "缺少密钥" - detail = "已启用,但尚未配置额度访问凭据。" - elif key_set: - state = "未启用" - detail = "额度凭据已保存;启用后才会请求额度。" + detail = "已启用;刷新时自动读取本机 CLI 登录态。" else: - state = "未配置" - detail = "点击配置,手动选择额度模型并输入访问凭据。" + state = "未启用" + detail = "启用后自动读取本机 CLI 登录态,无需填写密钥。" children = [_info(detail)] if model: - children.append(_info(f"模型 {model}")) - if enabled and key_set: - children.append(_action("↻ 立即刷新额度", f"refresh_quota:{tool}")) + children.append(_info(f"limitId {model}" if tool == "codex" else f"额度分类 {model}")) + if enabled: + children.append(_action("↻ 重新检测并刷新额度", f"refresh_quota:{tool}")) children.append(_action(f"⚙ 配置 {_provider_name(tool)}…", "provider_settings")) model_label = f" · {model}" if model else "" return _submenu( diff --git a/agentbar/provider_window.py b/agentbar/provider_window.py index 5d30b5e..2935fde 100644 --- a/agentbar/provider_window.py +++ b/agentbar/provider_window.py @@ -1,8 +1,8 @@ """Native quota-source settings window. -Claude/Codex use manually supplied OAuth access tokens. MyToken/Tokenverse use -corp browser cookies. Secret fields are always write-only: reopening the window -shows only whether a credential is configured, never the credential itself. +Claude/Codex credentials are discovered from their installed CLIs and never +cross the UI boundary. MyToken/Tokenverse keep their explicit, write-only corp +browser-cookie workflow. """ from __future__ import annotations @@ -18,6 +18,7 @@ NSApp, NSBackingStoreBuffered, NSButton, + NSComboBox, NSFont, NSMakeRect, NSPopUpButton, @@ -40,6 +41,7 @@ PROVIDER_UNITS, save_settings, ) +from .usage import credential_status log = logging.getLogger("agentbar.provider_window") @@ -98,10 +100,10 @@ def initWithCore_settings_(self, core, settings): self._cookie = {} self._status = {} self._source_model = {} - self._source_key = {} - self._source_account = {} - self._source_account_clear = {} self._source_refresh_buttons = {} + self._credential_status = {} + self._credential_generations = {source: 0 for source in _QUOTA_SOURCES} + self._credential_lock = threading.Lock() self._import_buttons = {} self._chrome_buttons = {} self._chrome_logins = {} @@ -156,7 +158,7 @@ def _build(self): view.addSubview_(_label("订阅额度", PAD, H - 142, 220, 20, bold=True)) view.addSubview_(_label( - "显式输入 OAuth Access Token;不会读取 Keychain 或 CLI 登录文件。", + "自动使用 Claude Code / Codex CLI 登录态;AgentBar 不接收或保存订阅凭据。", PAD + 84, H - 142, W - 2 * PAD - 84, 18, dim=True, )) self._build_quota_source_row(view, "claude", H - 235) @@ -189,50 +191,38 @@ def _build_quota_source_row(self, view, source: str, y: int): view.addSubview_(status) self._status[source] = status - model_label = "额度模型" if source == "claude" else "metered_feature" - view.addSubview_(_label(model_label, PAD, y + 41, 105)) - model = NSTextField.alloc().initWithFrame_(NSMakeRect(PAD + 108, y + 37, 245, 24)) - model.setPlaceholderString_( - "opus / sonnet;留空=通用窗口" - if source == "claude" - else "留空=账户总额度;或填 metered_feature" - ) - view.addSubview_(model) - self._source_model[source] = model - if source == "codex": - view.addSubview_(_label("Account ID", PAD + 370, y + 41, 78)) - account = NSTextField.alloc().initWithFrame_( - NSMakeRect(PAD + 450, y + 37, W - PAD * 2 - 450 - 82, 24) + view.addSubview_(_label("limitId", PAD, y + 41, 105)) + model = NSComboBox.alloc().initWithFrame_( + NSMakeRect(PAD + 108, y + 37, W - 2 * PAD - 108, 24) ) - account.setPlaceholderString_("可选;留空保留已配置值") - view.addSubview_(account) - self._source_account[source] = account - clear_account = _button( - "清 Account", W - PAD - 78, y + 36, 78, - self, "onClearAccount:", 26, + model.setCompletes_(True) + model.setNumberOfVisibleItems_(8) + model.setPlaceholderString_( + "留空=默认账户窗口;或填 App Server 返回的 limitId" ) - clear_account.setRepresentedObject_(source) - view.addSubview_(clear_account) - self._source_account_clear[source] = clear_account - - view.addSubview_(_label("OAuth Access Token", PAD, y + 9, 126)) - key = NSSecureTextField.alloc().initWithFrame_( - NSMakeRect(PAD + 130, y + 5, W - 2 * PAD - 130 - 174, 24) + view.addSubview_(model) + self._source_model[source] = model + else: + view.addSubview_(_label( + "Anthropic 未提供第三方订阅额度接口;展示任务观测与 ccusage 数据。", + PAD, y + 41, W - 2 * PAD, 18, dim=True, + )) + + hint = ( + "仅检测 Claude Code 登录状态,不读取 Keychain 或 OAuth 凭据" + if source == "claude" + else "额度由 Codex CLI app-server 自动提供,凭据不会暴露给 AgentBar" + ) + view.addSubview_(_label(hint, PAD, y + 10, W - 2 * PAD - 190, 18, dim=True)) + refresh = _button( + "重新检测 / 刷新", W - PAD - 176, y + 4, 176, + self, "onRefreshSource:", 26, ) - key.setPlaceholderString_("留空保留已配置的 Access Token") - view.addSubview_(key) - self._source_key[source] = key - - refresh = _button("刷新此来源", W - PAD - 166, y + 4, 92, self, "onRefreshSource:", 26) refresh.setRepresentedObject_(source) view.addSubview_(refresh) self._source_refresh_buttons[source] = refresh - clear = _button("清空", W - PAD - 68, y + 4, 68, self, "onClearSource:", 26) - clear.setRepresentedObject_(source) - view.addSubview_(clear) - def _build_provider_row(self, view, provider: str, y: int): name = _NAMES[provider] enabled = NSButton.alloc().initWithFrame_(NSMakeRect(PAD, y + 92, 150, 24)) @@ -305,34 +295,33 @@ def _reload_controls(self): self.title_popup.selectItemAtIndex_(1) self.auto_refresh_check.setState_(1 if usage_auto_refresh else 0) + try: + available_limit_ids = ( + ((self.core.snapshot().get("quota") or {}).get("codex") or {}).get( + "available_models" + ) or [] + ) + except Exception: + available_limit_ids = [] + codex_model = self._source_model.get("codex") + if codex_model is not None: + codex_model.removeAllItems() + codex_model.addItemsWithObjectValues_([ + str(value)[:160] + for value in available_limit_ids + if isinstance(value, str) and value.strip() + ]) for source in _QUOTA_SOURCES: defaults = DEFAULT_QUOTA_SOURCES[source] cfg = quota_sources[source] or defaults self._enabled[source].setState_(1 if cfg.get("enabled") else 0) - self._source_model[source].setStringValue_(str(cfg.get("model") or "")) - # Credentials are write-only. Empty controls mean "keep existing" on save. - self._source_key[source].setStringValue_("") - account = self._source_account.get(source) - if account is not None: - account.setStringValue_("") - key_set = bool(str(cfg.get("access_token") or "").strip()) - if key_set: - state = "OAuth Access Token 已配置" - if source == "codex" and str(cfg.get("account_id") or "").strip(): - state += " · Account ID 已配置" - if not cfg.get("enabled"): - state += " · 已停用" - elif cfg.get("enabled"): - state = "已启用,但缺少 OAuth Access Token" - else: - state = "未配置 OAuth Access Token" - self._status[source].setStringValue_(state) - self._source_refresh_buttons[source].setEnabled_( - bool(cfg.get("enabled") and key_set) - ) - account_clear = self._source_account_clear.get(source) - if account_clear is not None: - account_clear.setEnabled_(bool(str(cfg.get("account_id") or "").strip())) + model = self._source_model.get(source) + if model is not None: + model.setStringValue_(str(cfg.get("model") or "")) + self._status[source].setStringValue_("正在静默检测 CLI 登录态…") + self._source_refresh_buttons[source].setTitle_("检测中…") + self._source_refresh_buttons[source].setEnabled_(False) + self._start_credential_check(source) for provider in _PROVIDERS: defaults = DEFAULT_PROVIDERS[provider] @@ -356,88 +345,109 @@ def _reload_controls(self): state = "未配置 Cookie" self._status[provider].setStringValue_(state) - def onRefreshSource_(self, sender): - source = str(sender.representedObject() or "") - with self.settings._lock: - cfg = dict((self.settings.quota_sources or {}).get(source) or {}) - if source not in _QUOTA_SOURCES or not cfg.get("enabled") or not str( - cfg.get("access_token") or "" - ).strip(): - self._message.setStringValue_("请先保存并启用该来源的 OAuth Access Token。") - return - self.core.quota.refresh_now(source) - self._message.setStringValue_(f"{_NAMES[source]}:已触发一次手动额度刷新。") + @objc.python_method + def _credential_detector(self): + """Use an injected monitor detector in tests, otherwise the safe resolver.""" + return getattr(self.core.quota, "credential_status", credential_status) - def onClearSource_(self, sender): - source = str(sender.representedObject() or "") - if source not in DEFAULT_QUOTA_SOURCES: - return - alert = NSAlert.alloc().init() - alert.setMessageText_(f"清空 {_NAMES[source]} OAuth Access Token?") - alert.setInformativeText_("将删除 AgentBar 保存的 Access Token 和 Account ID,并停用该额度来源。") - alert.addButtonWithTitle_("清空") - alert.addButtonWithTitle_("取消") - if alert.runModal() != NSAlertFirstButtonReturn: + @objc.python_method + def _start_credential_check( + self, source: str, *, refresh: bool = False, + ) -> None: + if source not in _QUOTA_SOURCES: return - try: - with self.settings._lock: - old_sources = copy.deepcopy(self.settings.quota_sources) - try: - cfg = self.settings.quota_sources.setdefault( - source, dict(DEFAULT_QUOTA_SOURCES[source]) + with self._credential_lock: + self._credential_generations[source] += 1 + generation = self._credential_generations[source] + detector = self._credential_detector() + + def work(): + try: + result = detector( + source, + settings=self.settings, + allow_interactive=False, + refresh=bool(refresh), + ) + except Exception: + log.exception("%s credential detection failed", source) + result = { + "available": False, + "source": "none", + "status": "unavailable", + "detail": "登录态检测失败,请确认 CLI 已安装并重新登录。", + "needs_authorization": False, + } + refreshed = False + if refresh and bool(result.get("available")): + self.core.quota.reload_fetchers(refresh=False) + with self.settings._lock: + enabled = bool( + ((self.settings.quota_sources or {}).get(source) or {}).get( + "enabled" + ) ) - cfg["access_token"] = "" - cfg["account_id"] = "" - cfg["enabled"] = False - save_settings(self.settings) - except Exception: - self.settings.quota_sources = old_sources - raise - except Exception: - log.exception("failed to clear %s quota credential", source) - self._reload_controls() - self._alert("无法清空额度凭据", "配置文件写入失败,原设置未更改。") - return - # Clearing disables the source; rebuild without issuing unrelated - # requests. There is nothing useful to refresh for the cleared source. - self.core.quota.reload_fetchers(refresh=False) - self._reload_controls() - self._message.setStringValue_(f"{_NAMES[source]} OAuth Access Token 已清空并停用。") + if enabled: + self.core.quota.refresh_now(source) + refreshed = True + AppHelper.callAfter( + self._finish_credential_check, + source, + generation, + result, + refresh, + refreshed, + ) + + threading.Thread( + target=work, + name=f"agentbar-credential-{source}", + daemon=True, + ).start() - def onClearAccount_(self, sender): + @objc.python_method + def _finish_credential_check( + self, source, generation, result, requested_refresh, refreshed, + ) -> None: + with self._credential_lock: + if generation != self._credential_generations.get(source): + return + safe = { + "available": bool(result.get("available")), + "source": str(result.get("source") or "none")[:80], + "status": str(result.get("status") or "unavailable")[:80], + "detail": str(result.get("detail") or "")[:400], + "needs_authorization": bool(result.get("needs_authorization")), + } + self._credential_status[source] = safe + suffix = " · 已停用" if not self._enabled[source].state() else "" + self._status[source].setStringValue_( + (safe["detail"] or "未检测到可用 CLI 登录态") + suffix + ) + button = self._source_refresh_buttons[source] + button.setTitle_("重新检测 / 刷新") + button.setEnabled_(True) + if requested_refresh: + if refreshed: + message = f"{_NAMES[source]}:已重新检测登录态并触发额度刷新。" + elif safe["available"]: + message = f"{_NAMES[source]}:已检测到登录态;启用并保存后可刷新额度。" + else: + message = safe["detail"] or ( + f"未检测到 {_NAMES[source]} 登录态,请先在对应 CLI 中登录。" + ) + self._message.setStringValue_(message) + + def onRefreshSource_(self, sender): source = str(sender.representedObject() or "") - if source != "codex": + if source not in _QUOTA_SOURCES: return - alert = NSAlert.alloc().init() - alert.setMessageText_("清空 Codex Account ID?") - alert.setInformativeText_("OAuth Access Token 和额度来源开关保持不变。") - alert.addButtonWithTitle_("清空") - alert.addButtonWithTitle_("取消") - if alert.runModal() != NSAlertFirstButtonReturn: - return - try: - with self.settings._lock: - old_sources = copy.deepcopy(self.settings.quota_sources) - try: - cfg = self.settings.quota_sources.setdefault( - source, dict(DEFAULT_QUOTA_SOURCES[source]) - ) - cfg["account_id"] = "" - save_settings(self.settings) - ready = bool(cfg.get("enabled") and cfg.get("access_token")) - except Exception: - self.settings.quota_sources = old_sources - raise - except Exception: - log.exception("failed to clear Codex account id") - self._reload_controls() - self._alert("Account ID 清空失败", "配置文件写入失败,原设置未更改。") - return - self.core.quota.reload_fetchers(refresh=False) - if ready: - self.core.quota.refresh_now(source) - self._reload_controls() - self._message.setStringValue_("Codex Account ID 已清空;Access Token 保持不变。") + sender.setEnabled_(False) + sender.setTitle_("正在检测…") + self._message.setStringValue_( + f"正在重新检测 {_NAMES[source]} CLI 登录态…" + ) + self._start_credential_check(source, refresh=True) def onChromeLogin_(self, sender): provider = str(sender.representedObject() or "") @@ -616,22 +626,14 @@ def onSave_(self, _sender): try: for source in _QUOTA_SOURCES: enabled = bool(self._enabled[source].state()) - model = self._validated_field( - self._source_model[source], "额度模型", 160 + model_control = self._source_model.get(source) + model = ( + self._validated_field(model_control, "Codex limitId", 160) + if model_control is not None else "" ) - typed_key = self._validated_field( - self._source_key[source], "OAuth Access Token", 16_384 - ) - account = "" - if source == "codex": - account = self._validated_field( - self._source_account[source], "Codex Account ID", 256 - ) source_inputs[source] = { "enabled": enabled, "model": model, - "typed_key": typed_key, - "account_id": account, } for provider in _PROVIDERS: defaults = DEFAULT_PROVIDERS[provider] @@ -673,7 +675,18 @@ def onSave_(self, _sender): old_title = self.settings.title_provider old_auto_refresh = self.settings.usage_auto_refresh before_sources = { - source: dict((old_sources or {}).get(source) or {}) + source: { + "enabled": bool( + ((old_sources or {}).get(source) or {}).get("enabled") + ), + **( + {"model": str( + ((old_sources or {}).get(source) or {}).get("model") + or "" + )} + if source == "codex" else {} + ), + } for source in _QUOTA_SOURCES } before_providers = { @@ -681,27 +694,17 @@ def onSave_(self, _sender): for provider in _PROVIDERS } try: - for source, update in source_inputs.items(): - current = (self.settings.quota_sources or {}).get(source) or {} - effective_key = update["typed_key"] or str( - current.get("access_token") or "" - ).strip() - if update["enabled"] and not effective_key: - raise ValueError( - f"{_NAMES[source]} 启用前必须输入 OAuth Access Token" - ) - for source, update in source_inputs.items(): cfg = self.settings.quota_sources.setdefault( source, dict(DEFAULT_QUOTA_SOURCES[source]) ) + for legacy_secret in ("access_token", "api_key", "account_id"): + cfg.pop(legacy_secret, None) cfg["enabled"] = update["enabled"] - cfg["model"] = update["model"] - # Empty credential controls preserve the saved write-only value. - if update["typed_key"]: - cfg["access_token"] = update["typed_key"] - if source == "codex" and update["account_id"]: - cfg["account_id"] = update["account_id"] + if source == "codex": + cfg["model"] = update["model"] + else: + cfg.pop("model", None) for provider, update in provider_inputs.items(): cfg = self.settings.providers.setdefault( @@ -727,7 +730,21 @@ def onSave_(self, _sender): changed = [ source for source in _QUOTA_SOURCES if before_sources[source] - != dict((self.settings.quota_sources or {}).get(source) or {}) + != { + "enabled": bool( + ((self.settings.quota_sources or {}).get(source) or {}).get( + "enabled" + ) + ), + **( + {"model": str( + ((self.settings.quota_sources or {}).get(source) or {}).get( + "model" + ) or "" + )} + if source == "codex" else {} + ), + } ] changed.extend( provider for provider in _PROVIDERS @@ -738,9 +755,12 @@ def onSave_(self, _sender): for tool in changed: if tool in _QUOTA_SOURCES: cfg = (self.settings.quota_sources or {}).get(tool) or {} - ready = cfg.get("enabled") and str( - cfg.get("access_token") or "" - ).strip() + ready = bool( + cfg.get("enabled") + and (self._credential_status.get(tool) or {}).get( + "available" + ) + ) else: cfg = (self.settings.providers or {}).get(tool) or {} ready = cfg.get("enabled") and str( @@ -759,15 +779,44 @@ def onSave_(self, _sender): self._alert("无法保存额度设置", "配置文件写入失败,原设置未更改。") return - refreshed = [] - if changed or auto_refresh_changed: - self.core.quota.reload_fetchers(refresh=False) - if changed: - for tool in ready_tools: - self.core.quota.refresh_now(tool) - refreshed.append(_NAMES[tool]) self._reload_controls() mode = "已开启周期自动刷新" if auto_refresh_enabled else "之后仅手动刷新" + if not changed and not auto_refresh_changed: + self._message.setStringValue_(f"配置已保存;没有来源需要刷新;{mode}。") + return + + self._message.setStringValue_(f"配置已保存;正在后台应用来源变更;{mode}。") + + def apply_runtime_change(): + try: + self.core.quota.reload_fetchers(refresh=False) + refreshed = [] + for tool in ready_tools: + self.core.quota.refresh_now(tool) + refreshed.append(_NAMES[tool]) + error = "" + except Exception: + log.exception("failed to apply saved quota-source settings") + refreshed = [] + error = "配置已保存,但运行时刷新失败;请点“重新检测 / 刷新”重试。" + AppHelper.callAfter( + self._finish_saved_runtime_change, + refreshed, + mode, + error, + ) + + threading.Thread( + target=apply_runtime_change, + name="agentbar-provider-settings-apply", + daemon=True, + ).start() + + @objc.python_method + def _finish_saved_runtime_change(self, refreshed, mode, error): + if error: + self._message.setStringValue_(error) + return refresh_note = ( f"已刷新变更来源:{'、'.join(refreshed)}" if refreshed else "没有来源需要刷新" diff --git a/agentbar/quota.py b/agentbar/quota.py index 9151858..d36965e 100644 --- a/agentbar/quota.py +++ b/agentbar/quota.py @@ -1,7 +1,8 @@ """Quota status — honest, layered; never fabricated. 数据优先级(来源在 UI 明确标注,取不到就降级,绝不编造): - 1. usage API — 用户显式配置的 Claude OAuth usage / Codex wham usage; + 1. CLI source — 用户启用后自动检测 Claude Code 登录态,并通过 Codex + App Server 的账户接口读取其可用额度; 默认仅启动、保存设置或手动点击时请求,周期刷新需主动开启 2. observed — 调度器观测事实:真实任务的限流失败(ground truth,优先于 API 展示) 3. ccusage — 本机装了 ccusage 时补充 5h 窗口成本 @@ -25,7 +26,7 @@ log = logging.getLogger("agentbar.quota") -USAGE_STALE_SECONDS = 15 * 60 # usage API 结果超过该时长视为过期,不再参与判定 +USAGE_STALE_SECONDS = 15 * 60 # 额度来源结果超过该时长视为过期,不再参与判定 # MyToken's token-unit fetch can perform three sequential 12s HTTP calls. Give # the active request enough time to reach its own bounded timeout before stop() # reports a lifecycle failure. @@ -37,7 +38,7 @@ class QuotaStatus: tool: str state: str # "ok" | "limited" | "unknown" detail: str - source: str # "usage_api" | "observed" | "ccusage" | "none" 或组合 + source: str # fetcher source | "observed" | "ccusage" | "none" 或组合 reset_at: float | None = None windows: list = field(default_factory=list) # [{label, used_percent, resets_at}] plan: str | None = None @@ -127,10 +128,7 @@ def _fetcher_signature(fetcher: object) -> tuple: fields. Unknown/testing fetchers fall back to object identity so replacing one is conservatively treated as a real configuration change. """ - names = ( - "access_token", "api_key", "account_id", "model", - "cookie", "unit", "refresh_seconds", - ) + names = ("binary", "model", "cookie", "unit", "refresh_seconds") values = tuple((name, getattr(fetcher, name)) for name in names if hasattr(fetcher, name)) return (type(fetcher), values if values else id(fetcher)) @@ -248,7 +246,7 @@ def cooldown_until(self, tool: str, model: str | None = None) -> float | None: lq, ls = o.get("last_quota_at") or 0, o.get("last_success_at") or 0 if lq > ls and o.get("reset_at"): candidates.append(o["reset_at"]) - # usage API 显示某窗口已打满 → 主动冷却到重置时间(不用真跑一次失败) + # 额度来源显示某窗口已打满 → 主动冷却到重置时间(不用真跑一次失败) if snap and not snap.error and now - snap.fetched_at < USAGE_STALE_SECONDS: for w in snap.windows: # 一个 snapshot 可能同时含账户通用窗口和多个模型专属窗口。 @@ -295,7 +293,8 @@ def status(self, tool: str) -> QuotaStatus: tool, "limited" if limited else "ok", " · ".join(parts), - "usage_api" + ("+observed" if observed_limited else ""), + (snap.source or "quota_source") + + ("+observed" if observed_limited else ""), reset_at=worst_reset or (obs_reset if observed_limited else None), ) elif observed_limited: @@ -307,6 +306,17 @@ def status(self, tool: str) -> QuotaStatus: "observed") elif ls: st = QuotaStatus(tool, "ok", f"正常({_clock_day(ls)} 有成功执行)", "observed") + elif snap and not snap.error and snap.source == "claude_auth_status": + # Claude exposes a supported non-sensitive CLI login-status command, + # but no supported third-party subscription-usage endpoint. Preserve + # that useful distinction instead of presenting a logged-in source as + # an unexplained fetch failure or inventing a percentage. + st = QuotaStatus( + tool, + "unknown", + "Claude Code 已登录;订阅额度仅展示任务观测与 ccusage", + "claude_auth_status", + ) else: st = QuotaStatus(tool, "unknown", "未知(尚无额度数据)", "none") @@ -332,7 +342,7 @@ def status(self, tool: str) -> QuotaStatus: if snap and snap.windows: st.detail += f";上次刷新失败:{refresh_error.error}" elif st.source == "none": - st.detail += f";usage API: {refresh_error.error}" + st.detail += f";额度来源: {refresh_error.error}" if cc: st.detail += f";5h 已用 ${cc['cost']:.2f}(ccusage)" st.source += "+ccusage" @@ -523,7 +533,7 @@ def _run_refresh_batch(self, batch: _RefreshBatch) -> None: snap = fetcher.fetch() except Exception as e: # 任何异常都不能带崩后台线程 log.warning("usage fetch %s failed: %s", tool, e) - snap = UsageSnapshot(tool, source="usage_api", error=str(e)) + snap = UsageSnapshot(tool, source="quota_source", error=str(e)) if snap: with self._lock: # 配置切换/禁用时丢弃旧请求的迟到结果,避免把 A 模型数字误标为 B。 diff --git a/agentbar/scheduler.py b/agentbar/scheduler.py index 430bc56..3613eaa 100644 --- a/agentbar/scheduler.py +++ b/agentbar/scheduler.py @@ -668,9 +668,11 @@ def snapshot(self) -> dict: cfg = (self.settings.quota_sources or {}).get(name) or {} quota_source_config[name] = { "enabled": bool(cfg.get("enabled")), - "key_set": bool(str(cfg.get("access_token") or "").strip()), "model": str(cfg.get("model") or ""), - "account_id_set": bool(str(cfg.get("account_id") or "").strip()), + # CLI capability/login detection can spawn a subprocess. Keep + # this 2-second snapshot path non-blocking; settings/API views + # detect status on a worker instead. + "credential_mode": "auto", } return { "version": __version__, @@ -691,7 +693,7 @@ def snapshot(self) -> dict: # 菜单只需要无敏感信息的配置摘要,用于展示“未配置/待刷新”入口。 # Cookie 永远不进入 snapshot、日志或前端状态转储。 "provider_config": provider_config, - # access key/account id 永不进入 snapshot。 + # CLI 登录凭据及派生账户信息永不进入 snapshot。 "quota_source_config": quota_source_config, "cli_processes": self._cli_processes_snapshot(), "settings": { diff --git a/agentbar/server.py b/agentbar/server.py index 8fe4bef..c9c1caf 100644 --- a/agentbar/server.py +++ b/agentbar/server.py @@ -31,6 +31,7 @@ save_settings, ) from .scheduler import Scheduler +from .usage import credential_status log = logging.getLogger("agentbar.server") @@ -166,10 +167,74 @@ def _cookie_preview(cookie: str) -> str: return shown + (" ..." if len(names) > 4 else "") -def _provider_config_payload(settings: Settings) -> dict: +_CREDENTIAL_STATUSES = { + "available", + "not_logged_in", + "authorization_required", + "expired", + "invalid_credentials", + "api_key_unsupported", + "unavailable", + "missing", + "unsupported", + "timeout", + "error", +} + + +def _quota_credential_diagnostic( + source: str, *, settings: Settings | None = None, refresh: bool = False, +) -> dict: + """Return a strictly non-secret view of local CLI authentication state.""" + try: + raw = credential_status( + source, + settings=settings, + allow_interactive=False, + refresh=refresh, + ) + except Exception: + log.exception("%s CLI credential detection failed", source) + raw = {} + status = str(raw.get("status") or "unavailable") + if status not in _CREDENTIAL_STATUSES: + status = "unavailable" + detected_source = str(raw.get("source") or "none") + if not re.fullmatch(r"[a-zA-Z0-9_.-]{1,80}", detected_source): + detected_source = "unknown" + return { + "credential_available": bool(raw.get("available")) and status == "available", + "credential_source": detected_source, + "credential_status": status, + } + + +def _credential_action_message(source: str, diagnostic: dict) -> str: + name = "Claude Code" if source == "claude" else "Codex CLI" + status = diagnostic.get("credential_status") + if status == "api_key_unsupported": + return ( + "Codex CLI 当前是 API key 登录,无法提供 ChatGPT 订阅额度;" + "请在 Codex CLI 中登录 ChatGPT 账户后重新检测。" + ) + if status in {"expired", "invalid_credentials"}: + return f"{name} 登录态已失效;请在对应 CLI 中重新登录后再检测。" + if status == "timeout": + return f"{name} 登录态检测超时;请确认 CLI 可正常运行后重试。" + if status in {"unsupported", "unavailable", "error"}: + return f"无法检测 {name} 登录态;请确认 CLI 已安装并可正常运行。" + return f"未检测到 {name} 登录态;请先在对应 CLI 中完成登录,再重新检测。" + + +def _provider_config_payload(settings: Settings, *, refresh_credentials=False) -> dict: + with settings._lock: + saved_providers = json.loads(json.dumps(settings.providers or {})) + saved_sources = json.loads(json.dumps(settings.quota_sources or {})) + usage_auto_refresh = bool(settings.usage_auto_refresh) + title_provider = settings.title_provider providers = {} for name, defaults in DEFAULT_PROVIDERS.items(): - cfg = (settings.providers or {}).get(name) or {} + cfg = saved_providers.get(name) or {} cookie = str(cfg.get("cookie") or "") providers[name] = { "enabled": bool(cfg.get("enabled")), @@ -184,19 +249,19 @@ def _provider_config_payload(settings: Settings) -> dict: "providers": providers, "quota_sources": { name: { - "enabled": bool(((settings.quota_sources or {}).get(name) or {}).get("enabled")), - "model": str(((settings.quota_sources or {}).get(name) or {}).get("model") or ""), - "key_set": bool(str( - ((settings.quota_sources or {}).get(name) or {}).get("access_token") or "" - ).strip()), - "account_id_set": bool(str( - ((settings.quota_sources or {}).get(name) or {}).get("account_id") or "" - ).strip()), + "enabled": bool((saved_sources.get(name) or {}).get("enabled")), + **( + {"model": str((saved_sources.get(name) or {}).get("model") or "")} + if name == "codex" else {} + ), + **_quota_credential_diagnostic( + name, settings=settings, refresh=refresh_credentials, + ), } for name in DEFAULT_QUOTA_SOURCES }, - "usage_auto_refresh": bool(settings.usage_auto_refresh), - "title_provider": settings.title_provider, + "usage_auto_refresh": usage_auto_refresh, + "title_provider": title_provider, } @@ -207,6 +272,28 @@ def _apply_provider_settings( ) -> None: if not isinstance(payload, dict): raise ValueError("配置请求必须是 JSON object") + unknown_top_level = set(payload) - { + "providers", "quota_sources", "usage_auto_refresh", "title_provider", + } + if unknown_top_level: + raise ValueError(f"未知配置字段: {sorted(unknown_top_level)[0]!r}") + source_payload = payload.get("quota_sources", {}) + if not isinstance(source_payload, dict): + raise ValueError("quota_sources 必须是 JSON object") + unknown_sources = set(source_payload) - set(DEFAULT_QUOTA_SOURCES) + if unknown_sources: + raise ValueError(f"未知 quota source: {sorted(unknown_sources)[0]!r}") + for name, incoming in source_payload.items(): + if not isinstance(incoming, dict): + raise ValueError(f"quota_sources.{name} 必须是 JSON object") + allowed = {"enabled"} if name == "claude" else {"enabled", "model"} + rejected = set(incoming) - allowed + if rejected: + field = sorted(rejected)[0] + raise ValueError( + f"quota_sources.{name}.{field} 不再接受;" + f"{name} 凭据由本机 CLI 自动检测,API 不接收任何密钥或账号字段" + ) with settings._lock: providers = payload.get("providers", {}) if not isinstance(providers, dict): @@ -252,10 +339,11 @@ def _apply_provider_settings( }: raise ValueError("title_provider 无效") next_title = title - source_payload = payload.get("quota_sources", {}) - if not isinstance(source_payload, dict): - raise ValueError("quota_sources 必须是 JSON object") sources = json.loads(json.dumps(settings.quota_sources or DEFAULT_QUOTA_SOURCES)) + for cfg in sources.values(): + if isinstance(cfg, dict): + for legacy_secret in ("access_token", "api_key", "account_id"): + cfg.pop(legacy_secret, None) for name, defaults in DEFAULT_QUOTA_SOURCES.items(): incoming = source_payload.get(name) if incoming is None: @@ -263,35 +351,16 @@ def _apply_provider_settings( if not isinstance(incoming, dict): raise ValueError(f"quota_sources.{name} 必须是 JSON object") cfg = sources.setdefault(name, dict(defaults)) - cfg.pop("api_key", None) # 旧字段只读迁移,永不再落盘。 if "enabled" in incoming: cfg["enabled"] = _clean_bool_field( incoming.get("enabled"), f"quota_sources.{name}.enabled", ) - if "model" in incoming: - cfg["model"] = _clean_credential_field(incoming.get("model"), "模型", 160) - token_submitted = "access_token" in incoming or "api_key" in incoming - if token_submitted: - # access_token 是唯一正式字段;api_key 仅接受旧 Web 客户端迁移。 - raw_token = ( - incoming.get("access_token") - if "access_token" in incoming - else incoming.get("api_key") + if name == "codex" and "model" in incoming: + cfg["model"] = _clean_credential_field( + incoming.get("model"), "Codex limitId", 160, ) - cfg["access_token"] = _clean_credential_field( - raw_token, "OAuth Access Token", 16_384, - ) - if "account_id" in incoming: - cfg["account_id"] = _clean_credential_field( - incoming.get("account_id"), "Account ID", 256, - ) - if token_submitted and not cfg.get("access_token"): - # 显式清空凭据时同时停用,避免留下会持续报错的半配置。 - if incoming.get("enabled") is True: - raise ValueError(f"{name} 启用前必须输入 OAuth Access Token") - cfg["enabled"] = False - if cfg.get("enabled") and not str(cfg.get("access_token") or "").strip(): - raise ValueError(f"{name} 启用前必须输入 OAuth Access Token") + if name == "claude": + cfg.pop("model", None) next_auto_refresh = settings.usage_auto_refresh if "usage_auto_refresh" in payload: next_auto_refresh = _clean_bool_field( @@ -724,6 +793,7 @@ def do_POST(self): # a LAN hop only to receive a 403 after the bytes have been consumed. local_only = { "/api/provider-config", + "/api/provider-config/detect-credential", "/api/provider-config/import-cookie", "/api/debug/dispatch", } @@ -800,7 +870,18 @@ def do_POST(self): }) return if tool not in core.quota.provider_tools(): - self._json(400, {"ok": False, "error": f"未启用的额度来源: {tool!r}"}) + if tool in DEFAULT_QUOTA_SOURCES: + diagnostic = _quota_credential_diagnostic( + tool, settings=settings, + ) + error = ( + _credential_action_message(tool, diagnostic) + if not diagnostic["credential_available"] + else f"额度来源 {tool!r} 尚未启用,请先保存启用设置" + ) + else: + error = f"未启用的额度来源: {tool!r}" + self._json(400, {"ok": False, "error": error}) return try: admission.require_open() @@ -840,7 +921,57 @@ def do_POST(self): return self._json(200, { **_provider_config_payload(settings), - "message": "额度配置已保存并刷新", + "message": "额度配置已保存;已登录的来源将刷新,未登录来源保持待检测状态", + }) + return + if path == "/api/provider-config/detect-credential": + if not self._trusted_local(): + self._json(403, { + "ok": False, + "error": "CLI 登录态检测仅允许本机直连", + }) + return + if set(body) - {"source"}: + self._json(400, { + "ok": False, + "error": "登录态检测只接受 source 字段,不接受任何凭据", + }) + return + source = body.get("source") + if source not in DEFAULT_QUOTA_SOURCES: + self._json(400, { + "ok": False, + "error": f"未知 quota source: {source!r}", + }) + return + diagnostic = _quota_credential_diagnostic( + source, settings=settings, refresh=True, + ) + try: + admission.require_open() + core.quota.reload_fetchers(refresh=False) + except _AdmissionClosed: + self._reject_if_stopping() + return + with settings._lock: + enabled = bool( + ((settings.quota_sources or {}).get(source) or {}).get( + "enabled" + ) + ) + refreshed = False + if diagnostic["credential_available"] and enabled: + core.quota.refresh_now(source) + refreshed = True + message = ( + f"{source} CLI 登录态已重新检测" + + (",额度刷新已触发" if refreshed else "") + if diagnostic["credential_available"] + else _credential_action_message(source, diagnostic) + ) + self._json(200, { + **_provider_config_payload(settings), + "message": message, }) return if path == "/api/provider-config/import-cookie": diff --git a/agentbar/usage.py b/agentbar/usage.py index c658d33..893f1b0 100644 --- a/agentbar/usage.py +++ b/agentbar/usage.py @@ -1,33 +1,34 @@ -"""Real quota/usage fetchers — approach mirrored from ylab/aiusagebar (Swift). +"""Quota and login-state fetchers with no direct access to CLI OAuth tokens. -Claude: GET https://api.anthropic.com/api/oauth/usage - AgentBar 运行时只使用用户在额度设置中显式保存的 OAuth Access Token; - 响应: {five_hour|seven_day|seven_day_opus|seven_day_sonnet: - {utilization: 0-100, resets_at: ISO8601}} +Claude only invokes ``claude auth status --json`` and whitelists non-sensitive +login metadata. Anthropic exposes no supported third-party subscription quota +API, so AgentBar never fabricates Claude windows. -Codex: GET https://chatgpt.com/backend-api/wham/usage - AgentBar 运行时使用显式保存的 OAuth Access Token,以及可选 Account ID; - 响应: {rate_limits: {primary|secondary: - {used_percent: 0-100, resets_at: epoch_s, window_duration_mins}}} - -诚实原则:拿不到就返回带 error 的结果或 None,绝不编造数字。 +Codex uses a short-lived official App Server session (``account/read`` and +``account/rateLimits/read``), leaving credential management and refresh to the +Codex process itself. """ from __future__ import annotations -import base64 import json -import logging +import math +import os import re +import select +import shutil +import signal +import subprocess +import threading import time import urllib.error import urllib.request from dataclasses import dataclass, field -from datetime import datetime -log = logging.getLogger("agentbar.usage") +from . import __version__ HTTP_TIMEOUT = 12 +_STATUS_CACHE_TTL_SECONDS = 30.0 @dataclass @@ -92,25 +93,587 @@ def _http_get_json(url: str, headers: dict) -> dict: return json.loads(r.read().decode("utf-8")) -def _parse_iso(value) -> float | None: - if not value: - return None - if isinstance(value, (int, float)): - return float(value) +@dataclass(frozen=True) +class _CredentialResolution: + """Internal result whose representation intentionally omits credentials.""" + + credentials: dict | None = field(default=None, repr=False) + source: str = "none" + status: str = "not_logged_in" + detail: str = "未检测到登录态" + needs_authorization: bool = False + + def public(self) -> dict: + return { + "available": self.credentials is not None, + "source": self.source, + "status": self.status, + "detail": self.detail, + "needs_authorization": self.needs_authorization, + } + + +_credential_status_lock = threading.Lock() +_credential_status_condition = threading.Condition(_credential_status_lock) +_credential_status_cache: dict[str, tuple[float, dict]] = {} +_credential_status_inflight: set[str] = set() + + +def _remember_credential_status(tool: str, result: _CredentialResolution) -> dict: + public = result.public() + with _credential_status_condition: + _credential_status_cache[tool] = (time.monotonic(), public) + _credential_status_condition.notify_all() + return dict(public) + + +def _clear_credential_status_cache(tool: str | None = None) -> None: + """Clear only non-sensitive status cache (kept private except for tests).""" + with _credential_status_condition: + if tool is None: + _credential_status_cache.clear() + else: + for key in list(_credential_status_cache): + if key == tool or key.startswith(f"{tool}\0"): + _credential_status_cache.pop(key, None) + _credential_status_condition.notify_all() + + +def _clean_secret(value, *, max_length: int = 65_536) -> str: + if not isinstance(value, str): + return "" + cleaned = value.strip() + if not cleaned or len(cleaned) > max_length: + return "" + if any(ord(char) < 32 or ord(char) == 127 for char in cleaned): + return "" + return cleaned + + +def _clean_metadata(value, *, max_length: int = 512) -> str | None: + cleaned = _clean_secret(value, max_length=max_length) + return cleaned or None + + +# ================= Local CLI credentials ================= + + +_CLI_STATUS_TIMEOUT_SECONDS = 8.0 +_CLI_STATUS_MAX_OUTPUT_BYTES = 64 * 1024 +_CODEX_APP_SERVER_TIMEOUT_SECONDS = 15.0 +_CODEX_APP_SERVER_MAX_OUTPUT_BYTES = 2 * 1024 * 1024 +_CODEX_APP_SERVER_MAX_LINE_BYTES = 256 * 1024 +_codex_app_server_lock = threading.Lock() + + +class _CodexAppServerError(RuntimeError): + """Deliberately carries no child output, account data, or credentials.""" + + +def _terminate_process_group(proc: subprocess.Popen) -> None: + if proc.poll() is not None: + # The session leader may exit while a descendant keeps inherited FDs + # open. Its process group still has the original pid, so clean it too. + try: + os.killpg(proc.pid, signal.SIGTERM) + os.killpg(proc.pid, signal.SIGKILL) + except (OSError, ProcessLookupError): + pass + return + try: + os.killpg(proc.pid, signal.SIGTERM) + except (OSError, ProcessLookupError): + try: + proc.terminate() + except OSError: + pass + try: + proc.wait(timeout=0.5) + return + except subprocess.TimeoutExpired: + pass + try: + os.killpg(proc.pid, signal.SIGKILL) + except (OSError, ProcessLookupError): + try: + proc.kill() + except OSError: + pass + try: + proc.wait(timeout=0.5) + except subprocess.TimeoutExpired: + pass + + +def _run_bounded_json_command( + argv: list[str], + *, + env: dict | None = None, +) -> dict: + try: + proc = subprocess.Popen( + argv, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env=env, + start_new_session=True, + ) + except (OSError, ValueError) as exc: + raise _CodexAppServerError("start failed") from exc + deadline = time.monotonic() + _CLI_STATUS_TIMEOUT_SECONDS + output = bytearray() + try: + if proc.stdout is None: + raise _CodexAppServerError("missing stdout") + fd = proc.stdout.fileno() + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise _CodexAppServerError("timeout") + ready, _, _ = select.select([fd], [], [], remaining) + if not ready: + raise _CodexAppServerError("timeout") + try: + chunk = os.read(fd, 16_384) + except OSError as exc: + raise _CodexAppServerError("read failed") from exc + if not chunk: + break + output.extend(chunk) + if len(output) > _CLI_STATUS_MAX_OUTPUT_BYTES: + raise _CodexAppServerError("output limit exceeded") + try: + proc.wait(timeout=max(0.01, deadline - time.monotonic())) + except subprocess.TimeoutExpired as exc: + raise _CodexAppServerError("timeout") from exc + try: + parsed = json.loads(bytes(output).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise _CodexAppServerError("invalid JSON") from exc + if not isinstance(parsed, dict): + raise _CodexAppServerError("invalid response") + return parsed + finally: + _terminate_process_group(proc) + if proc.stdout is not None: + proc.stdout.close() + + +def _resolve_claude_credentials( + *, + binary: str = "", + env: dict | None = None, +) -> _CredentialResolution: + executable = (binary or "").strip() or shutil.which("claude") + if not executable: + return _CredentialResolution( + None, + "claude_auth_status", + "unavailable", + "未找到 Claude Code CLI", + ) try: - return datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp() - except ValueError: + raw = _run_bounded_json_command( + [executable, "auth", "status", "--json"], + env=env, + ) + except _CodexAppServerError: + return _CredentialResolution( + None, + "claude_auth_status", + "unavailable", + "无法通过 Claude Code CLI 读取登录状态", + ) + # Whitelist only non-sensitive status fields. In particular, intentionally + # discard email, orgId and orgName from Claude's JSON response. + if raw.get("loggedIn") is not True: + return _CredentialResolution( + None, + "claude_auth_status", + "not_logged_in", + "Claude Code 未登录,请先运行 claude 登录", + ) + auth_method = _clean_metadata(raw.get("authMethod"), max_length=160) + subscription = _clean_metadata(raw.get("subscriptionType"), max_length=160) + return _CredentialResolution( + {"plan": subscription, "auth_method": auth_method}, + "claude_auth_status", + "available", + "Claude Code 已登录;官方未提供第三方订阅额度接口", + ) + + +class _JsonLineReader: + def __init__(self, proc: subprocess.Popen, deadline: float): + if proc.stdout is None: + raise _CodexAppServerError("missing stdout") + self.proc = proc + self.fd = proc.stdout.fileno() + self.deadline = deadline + self.buffer = bytearray() + self.total = 0 + + def _next_line(self) -> bytes | None: + newline = self.buffer.find(b"\n") + if newline < 0: + return None + line = bytes(self.buffer[:newline]) + del self.buffer[:newline + 1] + return line + + def response(self, request_id: int) -> dict: + while True: + line = self._next_line() + if line is not None: + if not line: + continue + if len(line) > _CODEX_APP_SERVER_MAX_LINE_BYTES: + raise _CodexAppServerError("line limit exceeded") + try: + message = json.loads(line) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise _CodexAppServerError("invalid JSONL") from exc + if not isinstance(message, dict): + raise _CodexAppServerError("invalid message") + # Notifications have no id and are safe to ignore. A server + # request needs an explicit host response; fail closed instead + # of silently deadlocking or accidentally accepting its id. + if "method" in message and "id" in message: + raise _CodexAppServerError("unsupported server request") + if message.get("id") == request_id and "method" not in message: + return message + continue + + remaining = self.deadline - time.monotonic() + if remaining <= 0: + raise _CodexAppServerError("timeout") + ready, _, _ = select.select([self.fd], [], [], remaining) + if not ready: + raise _CodexAppServerError("timeout") + try: + chunk = os.read(self.fd, 65_536) + except OSError as exc: + raise _CodexAppServerError("read failed") from exc + if not chunk: + raise _CodexAppServerError("unexpected EOF") + self.total += len(chunk) + if self.total > _CODEX_APP_SERVER_MAX_OUTPUT_BYTES: + raise _CodexAppServerError("output limit exceeded") + self.buffer.extend(chunk) + if ( + b"\n" not in self.buffer + and len(self.buffer) > _CODEX_APP_SERVER_MAX_LINE_BYTES + ): + raise _CodexAppServerError("line limit exceeded") + + +def _sanitize_codex_window(value) -> dict | None: + if not isinstance(value, dict): + return None + sanitized = {} + for key in ("usedPercent", "windowDurationMins", "resetsAt"): + item = value.get(key) + if isinstance(item, (int, float)) and not isinstance(item, bool): + try: + number = float(item) + except (TypeError, ValueError, OverflowError): + continue + if math.isfinite(number): + sanitized[key] = number + return sanitized or None + + +def _sanitize_codex_limit(value, fallback_id: str = "") -> dict | None: + if not isinstance(value, dict): + return None + limit_id = _clean_metadata(value.get("limitId") or fallback_id, max_length=160) + if not limit_id: return None + sanitized = { + "limitId": limit_id, + "limitName": _clean_metadata(value.get("limitName"), max_length=160), + "planType": _clean_metadata(value.get("planType"), max_length=160), + "rateLimitReachedType": _clean_metadata( + value.get("rateLimitReachedType"), max_length=160 + ), + "primary": _sanitize_codex_window(value.get("primary")), + "secondary": _sanitize_codex_window(value.get("secondary")), + } + return sanitized + + +def _sanitize_codex_limits_result(value: dict) -> dict: + base = _sanitize_codex_limit(value.get("rateLimits")) + by_id = {} + raw_by_id = value.get("rateLimitsByLimitId") + if isinstance(raw_by_id, dict): + for raw_id, raw_limit in list(raw_by_id.items())[:128]: + key = _clean_metadata(raw_id, max_length=160) + if not key: + continue + sanitized = _sanitize_codex_limit(raw_limit, key) + if sanitized: + by_id[key] = sanitized + return {"rateLimits": base, "rateLimitsByLimitId": by_id} + + +def _send_app_server_message(proc: subprocess.Popen, message: dict) -> None: + if proc.stdin is None: + raise _CodexAppServerError("missing stdin") + try: + proc.stdin.write( + json.dumps(message, ensure_ascii=True, separators=(",", ":")).encode("utf-8") + + b"\n" + ) + proc.stdin.flush() + except (BrokenPipeError, OSError) as exc: + raise _CodexAppServerError("write failed") from exc + + +def _codex_app_server_session( + *, + binary: str = "", + env: dict | None = None, + include_rate_limits: bool, +) -> tuple[dict, dict | None]: + """Run one bounded official App Server session and return sanitized RPC data.""" + started_at = time.monotonic() + if not _codex_app_server_lock.acquire(timeout=_CODEX_APP_SERVER_TIMEOUT_SECONDS): + raise _CodexAppServerError("app server busy") + executable = (binary or "").strip() or shutil.which("codex") + if not executable: + _codex_app_server_lock.release() + raise _CodexAppServerError("codex unavailable") + proc = None + try: + try: + proc = subprocess.Popen( + [executable, "app-server", "--listen", "stdio://"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + env=env, + start_new_session=True, + ) + except (OSError, ValueError) as exc: + raise _CodexAppServerError("start failed") from exc + deadline = started_at + _CODEX_APP_SERVER_TIMEOUT_SECONDS + if time.monotonic() >= deadline: + raise _CodexAppServerError("timeout") + reader = _JsonLineReader(proc, deadline) + _send_app_server_message(proc, { + "method": "initialize", + "id": 0, + "params": { + "clientInfo": { + "name": "agentbar", + "title": "AgentBar", + "version": __version__, + } + }, + }) + initialized = reader.response(0) + if "error" in initialized or not isinstance(initialized.get("result"), dict): + raise _CodexAppServerError("initialize failed") + _send_app_server_message(proc, {"method": "initialized", "params": {}}) + _send_app_server_message(proc, { + "method": "account/read", + "id": 1, + # Keep status checks local: Codex must not refresh or access the + # network merely because AgentBar renders a credentials badge. + "params": {"refreshToken": False}, + }) + account_response = reader.response(1) + if "error" in account_response or not isinstance(account_response.get("result"), dict): + raise _CodexAppServerError("account read failed") + raw_account_result = account_response["result"] + raw_account = raw_account_result.get("account") + account_result = { + "account": ( + { + "type": _clean_metadata(raw_account.get("type"), max_length=80), + "planType": _clean_metadata(raw_account.get("planType"), max_length=160), + } + if isinstance(raw_account, dict) + else None + ), + "requiresOpenaiAuth": raw_account_result.get("requiresOpenaiAuth") is True, + } + limits_result = None + if include_rate_limits and _codex_account_is_chatgpt(account_result): + _send_app_server_message(proc, { + "method": "account/rateLimits/read", + "id": 2, + }) + limits_response = reader.response(2) + if "error" in limits_response or not isinstance(limits_response.get("result"), dict): + raise _CodexAppServerError("rate limits read failed") + limits_result = _sanitize_codex_limits_result(limits_response["result"]) + return account_result, limits_result + finally: + try: + if proc is not None and proc.stdin is not None: + try: + proc.stdin.close() + except OSError: + pass + if proc is not None: + _terminate_process_group(proc) + if proc is not None and proc.stdout is not None: + try: + proc.stdout.close() + except OSError: + pass + finally: + _codex_app_server_lock.release() + + +def _codex_account_is_chatgpt(account_result: dict) -> bool: + account = account_result.get("account") + if not isinstance(account, dict): + return False + return str(account.get("type") or "").casefold() == "chatgpt" + + +def _codex_account_resolution(account_result: dict) -> _CredentialResolution: + account = account_result.get("account") + if not isinstance(account, dict): + return _CredentialResolution( + None, + "codex_app_server", + "not_logged_in", + "未检测到 Codex ChatGPT 登录态,请先运行 codex login", + ) + account_type = str(account.get("type") or "").casefold() + if account_type == "apikey": + return _CredentialResolution( + None, + "codex_app_server", + "api_key_unsupported", + "Codex 当前使用 API Key 登录;API Key 不适用于 ChatGPT 订阅额度,请运行 codex login 选择 ChatGPT 登录", + ) + if account_type != "chatgpt": + return _CredentialResolution( + None, + "codex_app_server", + "unavailable", + "Codex 当前登录类型不支持 ChatGPT 订阅额度", + ) + return _CredentialResolution( + {"plan": _clean_metadata(account.get("planType"))}, + "codex_app_server", + "available", + "已通过 Codex App Server 读取 ChatGPT 登录态", + ) + + +def _resolve_codex_credentials( + *, + binary: str = "", + env: dict | None = None, +) -> _CredentialResolution: + try: + account_result, _ = _codex_app_server_session( + binary=binary, + env=env, + include_rate_limits=False, + ) + except _CodexAppServerError: + return _CredentialResolution( + None, + "codex_app_server", + "unavailable", + "无法通过 Codex App Server 读取登录态,请确认 Codex CLI 可用", + ) + return _codex_account_resolution(account_result) -def _jwt_payload(token: str) -> dict: +def credential_status( + tool: str, + *, + settings=None, + allow_interactive: bool = False, + refresh: bool = False, +) -> dict: + """Return cached, non-sensitive CLI credential state for UI/snapshots. + + ``refresh`` bypasses the status cache. ``allow_interactive`` is retained for + API compatibility but never enables a prompt: AgentBar only invokes the + CLIs' non-interactive status protocols and never reads OAuth credentials. + """ + normalized = (tool or "").strip().casefold() + if normalized not in {"claude", "codex"}: + return { + "available": False, + "source": "none", + "status": "unavailable", + "detail": "不支持的凭据来源", + "needs_authorization": False, + } + configured_binary = "" + if settings is not None: + configured_binary = str( + (getattr(settings, "tool_paths", None) or {}).get(normalized) or "" + ) + cache_key = normalized if not configured_binary else f"{normalized}\0{configured_binary}" + wait_deadline = time.monotonic() + _CODEX_APP_SERVER_TIMEOUT_SECONDS + 1.0 + with _credential_status_condition: + cached = _credential_status_cache.get(cache_key) + if ( + not refresh + and not allow_interactive + and cached + and time.monotonic() - cached[0] < _STATUS_CACHE_TTL_SECONDS + ): + return dict(cached[1]) + while cache_key in _credential_status_inflight: + remaining = wait_deadline - time.monotonic() + if remaining <= 0: + return { + "available": False, + "source": f"{normalized}_cli", + "status": "timeout", + "detail": "CLI 登录状态检测超时", + "needs_authorization": False, + } + _credential_status_condition.wait(remaining) + # Recheck after a concurrent detection finishes. Even explicit refresh + # callers share that one just-completed probe instead of immediately + # spawning the same CLI process again. + cached = _credential_status_cache.get(cache_key) + if cached and cached[0] >= wait_deadline - ( + _CODEX_APP_SERVER_TIMEOUT_SECONDS + 1.0 + ): + return dict(cached[1]) + _credential_status_inflight.add(cache_key) try: - part = token.split(".")[1] - part += "=" * (-len(part) % 4) - payload = json.loads(base64.urlsafe_b64decode(part)) - return payload if isinstance(payload, dict) else {} - except Exception: - return {} + binary, env = _tool_runtime(normalized, settings) + result = ( + _resolve_claude_credentials(binary=binary, env=env) + if normalized == "claude" + else _resolve_codex_credentials(binary=binary, env=env) + ) + return _remember_credential_status(cache_key, result) + finally: + with _credential_status_condition: + _credential_status_inflight.discard(cache_key) + _credential_status_condition.notify_all() + + +def _tool_runtime(tool: str, settings=None) -> tuple[str, dict | None]: + if settings is None: + return "", None + try: + if tool == "claude": + from .adapters.claude import ClaudeAdapter as AdapterClass + else: + from .adapters.codex import CodexAdapter as AdapterClass + adapter = AdapterClass(settings) + binary = adapter.binary() or "" + return binary, adapter.build_env(dict(os.environ)) + except (OSError, ValueError): + return "", dict(os.environ) # ================= Claude ================= @@ -118,79 +681,50 @@ def _jwt_payload(token: str) -> dict: class ClaudeUsageFetcher: tool = "claude" - URL = "https://api.anthropic.com/api/oauth/usage" - _WINDOW_KEYS = [ - ("five_hour", "5h"), - ("seven_day", "7d"), - ("seven_day_opus", "7d Opus"), - ("seven_day_sonnet", "7d Sonnet"), - ] - - def __init__(self, access_token: str = "", model: str = ""): - self.access_token = (access_token or "").strip() - self.model = (model or "").strip() + + def __init__( + self, + *, + binary: str = "", + env: dict | None = None, + settings=None, + status_cache_key: str = "claude", + ): + self.binary = (binary or "").strip() + self.env = env + self.settings = settings + self.status_cache_key = status_cache_key + + def _resolve(self) -> _CredentialResolution: + binary, env = ( + _tool_runtime("claude", self.settings) + if self.settings is not None + else (self.binary, self.env) + ) + result = _resolve_claude_credentials(binary=binary, env=env) + _remember_credential_status(self.status_cache_key, result) + return result def load_credentials(self) -> dict | None: - if not self.access_token: - return None - return {"token": self.access_token, "plan": None} + return self._resolve().credentials def fetch(self, interactive: bool = False) -> UsageSnapshot | None: - creds = self.load_credentials() - if not creds: + result = self._resolve() + if not result.credentials: return UsageSnapshot( - self.tool, source="oauth_api", - error="未配置 Claude OAuth Access Token", + self.tool, + source="claude_auth_status", + error=result.detail, ) - headers = { - "Authorization": f"Bearer {creds['token']}", - "Accept": "application/json", - "Content-Type": "application/json", - "anthropic-beta": "oauth-2025-04-20", - "User-Agent": "claude-code/2.1.0", - } - try: - data = _http_get_json(self.URL, headers) - except urllib.error.HTTPError as e: - return UsageSnapshot(self.tool, source="oauth_api", - error=f"usage 接口 HTTP {e.code}") - except (urllib.error.URLError, OSError, json.JSONDecodeError) as e: - return UsageSnapshot(self.tool, source="oauth_api", error=f"网络错误: {e}") - return self.parse(data, plan=creds.get("plan")) - - def parse(self, data: dict, plan: str | None = None) -> UsageSnapshot: - windows = [] - selected = self.model.casefold() - selected_family = ( - "opus" if "opus" in selected else "sonnet" if "sonnet" in selected else "" - ) - for key, label in self._WINDOW_KEYS: - # 选中 Opus/Sonnet 时,保留账户通用窗口,只隐藏另一个 - # 模型家族的专属周窗口。其他模型仍展示账户通用额度。 - if key.startswith("seven_day_") and ( - not selected_family or not key.endswith(selected_family) - ): - continue - w = data.get(key) - if not isinstance(w, dict) or w.get("utilization") is None: - continue - windows.append(UsageWindow( - label=label, - used_percent=max(0.0, min(100.0, float(w["utilization"]))), - resets_at=_parse_iso(w.get("resets_at")), - model=selected_family if key.startswith("seven_day_") else None, - )) - snap = UsageSnapshot( + # Anthropic does not expose a supported third-party subscription quota + # API. Keep this source status-only; scheduling continues to rely on + # observed CLI limit events and local ccusage data. + return UsageSnapshot( self.tool, - windows=windows, - plan=plan, - source="oauth_api", - model=self.model or None, - available_models=["opus", "sonnet"], + windows=[], + plan=result.credentials.get("plan"), + source="claude_auth_status", ) - if not windows: - snap.error = "usage 接口未返回可识别的额度窗口" - return snap # ================= Codex ================= @@ -198,50 +732,73 @@ def parse(self, data: dict, plan: str | None = None) -> UsageSnapshot: class CodexUsageFetcher: tool = "codex" - URL = "https://chatgpt.com/backend-api/wham/usage" - def __init__(self, access_token: str = "", account_id: str = "", model: str = ""): - self.access_token = (access_token or "").strip() - self.account_id = (account_id or "").strip() + def __init__( + self, + *, + model: str = "", + binary: str = "", + env: dict | None = None, + settings=None, + status_cache_key: str = "codex", + ): self.model = (model or "").strip() + self.binary = (binary or "").strip() + self.env = env + self.settings = settings + self.status_cache_key = status_cache_key def load_credentials(self) -> dict | None: - if not self.access_token: - return None - access_payload = _jwt_payload(self.access_token) - auth_claim = access_payload.get("https://api.openai.com/auth") or {} - account_id = ( - self.account_id - or auth_claim.get("chatgpt_account_id") - or access_payload.get("chatgpt_account_id") - or access_payload.get("account_id") + binary, env = ( + _tool_runtime("codex", self.settings) + if self.settings is not None + else (self.binary, self.env) ) - plan = auth_claim.get("chatgpt_plan_type") or access_payload.get("chatgpt_plan_type") - return {"token": self.access_token, "account_id": account_id, "plan": plan} + result = _resolve_codex_credentials(binary=binary, env=env) + _remember_credential_status(self.status_cache_key, result) + return result.credentials def fetch(self, interactive: bool = False) -> UsageSnapshot | None: - creds = self.load_credentials() - if not creds: - return UsageSnapshot(self.tool, source="wham_api", - error="未配置 Codex OAuth Access Token") - headers = { - "Authorization": f"Bearer {creds['token']}", - "Accept": "*/*", - "Referer": "https://chatgpt.com/codex/cloud/settings/analytics", - "x-openai-target-path": "/backend-api/wham/usage", - "x-openai-target-route": "/backend-api/wham/usage", - "User-Agent": "agentbar/0.2", - } - if creds.get("account_id"): - headers["chatgpt-account-id"] = creds["account_id"] + binary, env = ( + _tool_runtime("codex", self.settings) + if self.settings is not None + else (self.binary, self.env) + ) try: - data = _http_get_json(self.URL, headers) - except urllib.error.HTTPError as e: - return UsageSnapshot(self.tool, source="wham_api", - error=f"usage 接口 HTTP {e.code}") - except (urllib.error.URLError, OSError, json.JSONDecodeError) as e: - return UsageSnapshot(self.tool, source="wham_api", error=f"网络错误: {e}") - return self.parse(data, plan=creds.get("plan")) + account_result, limits_result = _codex_app_server_session( + binary=binary, + env=env, + include_rate_limits=True, + ) + except _CodexAppServerError: + result = _CredentialResolution( + None, + "codex_app_server", + "unavailable", + "Codex App Server 额度能力不可用;将继续使用本地观测的限额事件", + ) + _remember_credential_status(self.status_cache_key, result) + return UsageSnapshot( + self.tool, + source="codex_app_server", + error=result.detail, + ) + result = _codex_account_resolution(account_result) + _remember_credential_status(self.status_cache_key, result) + if not result.credentials: + return UsageSnapshot( + self.tool, + source="codex_app_server", + error=result.detail, + ) + if not isinstance(limits_result, dict): + return UsageSnapshot( + self.tool, + plan=result.credentials.get("plan"), + source="codex_app_server", + error="Codex App Server 未返回额度;将继续使用本地观测的限额事件", + ) + return self.parse(limits_result, plan=result.credentials.get("plan")) @staticmethod def _model_key(value: str) -> str: @@ -270,7 +827,14 @@ def _windows( seconds = w.get("limit_window_seconds") or w.get("limitWindowSeconds") label = fallback_label if mins: - label = f"{round(mins / 60)}h" if mins < 2880 else f"{round(mins / 1440)}d" + if mins < 60: + label = f"{round(mins)}m" + else: + label = ( + f"{round(mins / 60)}h" + if mins < 2880 + else f"{round(mins / 1440)}d" + ) elif seconds: label = f"{round(seconds / 3600)}h" if seconds < 2880 * 60 else f"{round(seconds / 86400)}d" reset = w.get( @@ -293,15 +857,37 @@ def _limited(limits: dict) -> bool: return ( bool(limits.get("limit_reached", limits.get("limitReached"))) or limits.get("allowed") is False + or bool(limits.get("rateLimitReachedType")) ) def parse(self, data: dict, plan: str | None = None) -> UsageSnapshot: - """Parse both observed WHAM response shapes. + """Parse official App Server limits and older saved fixture shapes. - Older clients expose ``rate_limits.primary`` with minute windows, while - the current ChatGPT-backed response exposes ``rate_limit.primary_window`` - with second windows and a ``limit_reached`` boolean. + Live fetching only uses ``rateLimits``/``rateLimitsByLimitId`` from the + Codex process; the compatibility shape is retained for existing local + snapshots and parser tests. """ + native_by_id = data.get("rateLimitsByLimitId") + native_base = data.get("rateLimits") + native_shape = isinstance(native_by_id, dict) or isinstance(native_base, dict) + if native_shape: + native_items = [] + for limit_id, limit in (native_by_id or {}).items(): + if isinstance(limit, dict): + native_items.append({ + "limit_name": limit.get("limitName"), + "metered_feature": limit.get("limitId") or limit_id, + "rate_limit": limit, + }) + data = { + "plan_type": ( + native_base.get("planType") + if isinstance(native_base, dict) + else None + ), + "rate_limit": native_base or {}, + "additional_rate_limits": native_items, + } additional = [ item for item in (data.get("additional_rate_limits") or []) if isinstance(item, dict) @@ -326,7 +912,7 @@ def parse(self, data: dict, plan: str | None = None) -> UsageSnapshot: return UsageSnapshot( self.tool, plan=plan or data.get("plan_type"), - source="wham_api", + source="codex_app_server", error=f"未找到模型额度 {self.model!r};接口可用:{choices}", model=self.model, available_models=available, @@ -346,11 +932,15 @@ def parse(self, data: dict, plan: str | None = None) -> UsageSnapshot: if selected_item is not None: selected_limits = selected_item.get("rate_limit") or {} selected_limited = self._limited(selected_limits) - windows = self._windows( - account_limits, - limited=account_limited, - label_prefix="账户 ", - ) + selected_id = str(selected_item.get("metered_feature") or "") + account_id = str(account_limits.get("limitId") or "") + windows = [] + if not native_shape or not account_id or selected_id != account_id: + windows = self._windows( + account_limits, + limited=account_limited, + label_prefix="账户 ", + ) windows.extend(self._windows( selected_limits, model=selected_label, @@ -364,7 +954,7 @@ def parse(self, data: dict, plan: str | None = None) -> UsageSnapshot: self.tool, windows=windows, plan=plan or data.get("plan_type"), - source="wham_api", + source="codex_app_server", # 保留 snapshot 级标记给旧 UI;调度决策应按 window.model # 逐窗口判断,避免模型专属限额污染账户级窗口。 limited=account_limited or selected_limited, @@ -372,7 +962,7 @@ def parse(self, data: dict, plan: str | None = None) -> UsageSnapshot: available_models=available, ) if not windows: - snap.error = "usage 接口未返回 rate_limits" + snap.error = "Codex App Server 未返回可识别的额度窗口" return snap @@ -568,26 +1158,30 @@ def fetch(self, interactive: bool = False) -> UsageSnapshot | None: def get_usage_fetchers(settings=None) -> dict[str, object]: - """只创建用户显式启用且已配凭据的额度来源。 + """只创建用户显式启用的额度/登录状态来源。 - 没有 Settings 也不创建隐式来源,确保任何调用路径都不会读取 CLI - 登录文件或系统凭据存储。 + Claude 仅查询官方 CLI 的非敏感登录状态;Codex 仅通过官方 + App Server 查额度。两者都不读取或保存 OAuth token。 """ fetchers: dict[str, object] = {} if settings is not None: sources = getattr(settings, "quota_sources", None) or {} claude = sources.get("claude") or {} - if claude.get("enabled") and (claude.get("access_token") or "").strip(): + if claude.get("enabled"): + configured = str((getattr(settings, "tool_paths", None) or {}).get("claude") or "") fetchers["claude"] = ClaudeUsageFetcher( - access_token=claude.get("access_token", ""), - model=claude.get("model", ""), + binary=os.path.expanduser(configured) if configured else "", + settings=settings, + status_cache_key=("claude" if not configured else f"claude\0{configured}"), ) codex = sources.get("codex") or {} - if codex.get("enabled") and (codex.get("access_token") or "").strip(): + if codex.get("enabled"): + configured = str((getattr(settings, "tool_paths", None) or {}).get("codex") or "") fetchers["codex"] = CodexUsageFetcher( - access_token=codex.get("access_token", ""), - account_id=codex.get("account_id", ""), model=codex.get("model", ""), + binary=os.path.expanduser(configured) if configured else "", + settings=settings, + status_cache_key=("codex" if not configured else f"codex\0{configured}"), ) providers = getattr(settings, "providers", None) or {} for name, cls in _CORP_FETCHERS.items(): diff --git a/agentbar/web/index.html b/agentbar/web/index.html index 63d1c60..f53f443 100644 --- a/agentbar/web/index.html +++ b/agentbar/web/index.html @@ -36,7 +36,6 @@ .providerTop input { min-width:0; flex:0; } .providerActions { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:8px; } .providerCookie { min-height:46px; font-family:ui-monospace,Menlo,monospace; } - .sourceSecret { width:100%; font-family:ui-monospace,Menlo,monospace; } .providerSubhead { margin:12px 0 7px; } .providerStatus { min-height:18px; margin-top:6px; } .meter { height:6px; border-radius:6px; overflow:hidden; background:#2a2f37; } @@ -127,7 +126,7 @@

添加任务

-

额度来源配置

Claude / Codex 必须显式输入 OAuth Access Token;密钥不会回显。 +

额度来源配置

自动检测 Claude Code / Codex CLI 登录态;AgentBar 不接收订阅凭据。
-
订阅来源 · 保存时仅刷新有变更且凭据就绪的来源;关闭周期刷新后不再自动轮询。
+
Claude 仅展示 CLI 登录状态与任务观测/ccusage;Codex 额度由 CLI App Server 提供。关闭周期刷新后不再自动轮询。
内部来源 · MyToken / Tokenverse 使用本机浏览器登录态;Cookie 只保存在 ~/.agentbar/config.json。
@@ -281,8 +280,11 @@

本机正在运行的 AI CLI

return `
${esc(w.label)}${esc(windowValue(w))}
${w.resets_at ? `重置:${dateClock(w.resets_at)}` : "未提供重置时间"}
`; }).join(""); const available = (qi.available_models || []).length - ? `
可选额度标识:${esc(qi.available_models.join(" / "))}
` : ""; - return `
${esc(PROVIDER_NAMES[tool] || tool)}${esc(qi.source || "unknown")}${qi.stale ? " · 已过期" : ""}
${qi.model ? `
额度模型 / metered_feature:${esc(qi.model)}
` : ""}${available}${windows || `
${esc(qi.detail || "暂无额度窗口")}
`}
${esc(qi.error || qi.detail || "")}
${qi.plan ? `套餐:${esc(qi.plan)} · ` : ""}${qi.fetched_at ? `更新:${dateClock(qi.fetched_at)}${qi.stale ? "(已过期,不参与调度判定)" : ""}` : "未取得实时数据"}
`; + ? `
可选 limitId:${esc(qi.available_models.join(" / "))}
` : ""; + const selection = qi.model + ? `
${tool === "codex" ? "limitId" : "观测模型"}:${esc(qi.model)}
` + : ""; + return `
${esc(PROVIDER_NAMES[tool] || tool)}${esc(qi.source || "unknown")}${qi.stale ? " · 已过期" : ""}
${selection}${available}${windows || `
${esc(qi.detail || "暂无额度窗口")}
`}
${esc(qi.error || qi.detail || "")}
${qi.plan ? `套餐:${esc(qi.plan)} · ` : ""}${qi.fetched_at ? `更新:${dateClock(qi.fetched_at)}${qi.stale ? "(已过期,不参与调度判定)" : ""}` : "未取得实时数据"}
`; }).join(""); } @@ -294,30 +296,22 @@

本机正在运行的 AI CLI

$("quotaSourceGrid").innerHTML = ["claude", "codex"].map(name => { const p = sources[name] || {}; const label = PROVIDER_NAMES[name] || name; - const configured = p.key_set - ? `OAuth Access Token 已配置${name === "codex" && p.account_id_set ? " · Account ID 已配置" : ""}` - : "未配置 OAuth Access Token"; - const modelLabel = name === "claude" ? "额度模型" : "metered_feature"; - const modelHint = name === "claude" - ? "opus / sonnet;留空=账户通用窗口" - : "接口返回的 metered_feature;留空=账户总额度"; - const account = name === "codex" ? `` : ""; + const stateText = credentialStateText(name, p); + const sourceText = p.credential_source && p.credential_source !== "none" + ? p.credential_source : "CLI 自动检测"; + const model = name === "codex" ? `
+ + +
` : `
Anthropic 未提供第三方订阅额度接口;此处只检测 Claude Code 是否已登录。
`; return `
- 手动 OAuth + ${esc(sourceText)}
-
- - ${account} -
- + ${model}
- - ${name === "codex" ? `` : ""} - - ${configured}${p.enabled ? "" : " · 已停用"} + + ${esc(stateText)}${p.enabled ? "" : " · 已停用"}
`; @@ -355,6 +349,24 @@

本机正在运行的 AI CLI

}).join(""); } +function credentialStateText(name, source) { + if (source.credential_available) { + return name === "codex" + ? "Codex CLI 已登录,可通过 App Server 读取额度" + : "Claude Code 已登录;额度仅展示任务观测与 ccusage"; + } + const status = source.credential_status || "unavailable"; + if (status === "api_key_unsupported") + return "Codex CLI 当前是 API key 登录;请登录 ChatGPT 账户以读取订阅额度"; + if (["expired", "invalid_credentials"].includes(status)) + return `${PROVIDER_NAMES[name] || name} 登录态已失效,请在 CLI 中重新登录`; + if (status === "timeout") + return `${PROVIDER_NAMES[name] || name} 登录态检测超时,请确认 CLI 可正常运行`; + if (["unsupported", "unavailable", "error"].includes(status)) + return `无法检测 ${PROVIDER_NAMES[name] || name} 登录态,请确认 CLI 已安装`; + return `未检测到 ${PROVIDER_NAMES[name] || name} 登录态,请先在 CLI 中登录`; +} + async function loadProviderConfig() { try { renderProviderConfig(await api("/api/provider-config")); } catch (e) { showBanner("无法读取内部额度配置: " + e.message); } @@ -372,21 +384,11 @@

本机正在运行的 AI CLI

if (cookie || includeEmptyCookie) providers[name].cookie = cookie; } const quotaSources = {}; - const configuredSources = (providerConfig && providerConfig.quota_sources) || {}; for (const name of ["claude", "codex"]) { - const accessToken = $(`q-${name}-key`).value.trim(); const enabled = $(`q-${name}-enabled`).checked; - if (enabled && !accessToken && !(configuredSources[name] || {}).key_set) - throw new Error(`${PROVIDER_NAMES[name] || name} 启用前必须输入 OAuth Access Token`); - quotaSources[name] = { - enabled, - model: $(`q-${name}-model`).value.trim(), - }; - if (accessToken) quotaSources[name].access_token = accessToken; - if (name === "codex") { - const accountId = $("q-codex-account").value.trim(); - if (accountId) quotaSources[name].account_id = accountId; - } + quotaSources[name] = { enabled }; + if (name === "codex") + quotaSources[name].model = $("q-codex-model").value.trim(); } return { providers, @@ -447,49 +449,29 @@

本机正在运行的 AI CLI

const status = $(`q-${name}-status`) || $(`p-${name}-status`); status.textContent = "正在触发一次手动刷新…"; try { - const j = await api("/api/quota/refresh", { + const cliSource = ["claude", "codex"].includes(name); + const j = await api( + cliSource + ? "/api/provider-config/detect-credential" + : "/api/quota/refresh", + { method:"POST", - body: JSON.stringify({ tool: name }), + body: JSON.stringify(cliSource ? { source: name } : { tool: name }), }); - status.textContent = j.message || "已触发手动刷新"; - setTimeout(refresh, 500); + if (cliSource) renderProviderConfig(j); + const updated = $(`q-${name}-status`) || $(`p-${name}-status`); + if (updated) { + updated.textContent = j.message || ( + cliSource ? "已重新检测 CLI 登录态" : "已触发手动刷新" + ); + } + await refresh(); } catch (e) { status.textContent = e.message; alert("刷新失败: " + e.message); } }; -window.clearQuotaSource = async name => { - if (!confirm(`清空 ${PROVIDER_NAMES[name] || name} OAuth Access Token 并停用该来源?`)) return; - const payload = { - quota_sources: { [name]: { - enabled: false, - access_token: "", - account_id: "", - } }, - }; - try { - const j = await api("/api/provider-config", { - method:"POST", - body: JSON.stringify(payload), - }); - renderProviderConfig(j); - await refresh(); - } catch (e) { alert("清空失败: " + e.message); } -}; - -window.clearCodexAccountId = async () => { - if (!confirm("清空 Codex Account ID?OAuth Access Token 保持不变。")) return; - try { - const j = await api("/api/provider-config", { - method:"POST", - body: JSON.stringify({ quota_sources: { codex: { account_id: "" } } }), - }); - renderProviderConfig(j); - await refresh(); - } catch (e) { alert("清空失败: " + e.message); } -}; - function renderProcesses(processes) { $("processBody").innerHTML = (processes || []).map(p => `${esc(p.tool)}${p.kind === "managed" ? "AgentBar" : "外部"}${esc(p.pid)}${esc(p.state)}${esc(p.elapsed)}${esc(shorten(p.cwd || "—"))}${p.title ? esc(p.title) : "外部任务(仅观测)"}`).join(""); $("processEmpty").style.display = processes && processes.length ? "none" : "block"; @@ -515,6 +497,11 @@

本机正在运行的 AI CLI

el.title = `${qi.detail}(来源: ${qi.source})`; } $("quotaGrid").innerHTML = quotaCards(s.quota); + const limitIds = $("codexLimitIds"); + if (limitIds) { + limitIds.innerHTML = ((s.quota.codex || {}).available_models || []) + .map(id => ``).join(""); + } const active = s.tasks.filter(t => ["queued","running","waiting_quota","paused"].includes(t.state)); const hist = s.tasks.filter(t => ["succeeded","failed","cancelled"].includes(t.state)).reverse(); $("activeBody").innerHTML = active.map(t => ` diff --git a/pyproject.toml b/pyproject.toml index fc18cd1..af3bbf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agentbar" -version = "0.10.4" +version = "0.10.5" description = "macOS menu bar scheduler for AI CLI agents (Claude Code, Codex, ...)" requires-python = ">=3.11" dependencies = [ diff --git a/scripts/build-dmg.sh b/scripts/build-dmg.sh index aea5eb9..16856cb 100755 --- a/scripts/build-dmg.sh +++ b/scripts/build-dmg.sh @@ -7,6 +7,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DIST="$ROOT/dist" TEMP_ROOT="${TMPDIR:-/tmp}" TEMP_ROOT="${TEMP_ROOT%/}" +if [[ -z "$TEMP_ROOT" ]]; then TEMP_ROOT="/"; fi PY2APP_VERSION="${AGENTBAR_PY2APP_VERSION:-0.28.10}" UV_BIN="$(command -v uv || true)" @@ -57,35 +58,75 @@ if [[ "$VERSION" != "$PROJECT_VERSION" ]]; then fi echo "==> AgentBar v$VERSION" -BUILD_VENV=$(/usr/bin/mktemp -d "$TEMP_ROOT/agentbar-build-venv.XXXXXX") -BUILD_LOG=$(/usr/bin/mktemp "$TEMP_ROOT/agentbar-py2app.XXXXXX.log") -REQUIREMENTS=$(/usr/bin/mktemp "$TEMP_ROOT/agentbar-requirements.XXXXXX.txt") +BUILD_VENV="" +BUILD_LOG="" +REQUIREMENTS="" SMOKE_DIR="" STAGE="" DMG_WORK="" -APP_BACKUP="" +PUBLISH_STAGE="" +PUBLISH_BACKUP="" +FINAL_APP="$DIST/AgentBar.app" +DMG_NAME="AgentBar-$VERSION.dmg" +FINAL_DMG="$DIST/$DMG_NAME" +OLD_APP_BACKED_UP=false +OLD_DMG_BACKED_UP=false +NEW_APP_PUBLISHED=false +NEW_DMG_PUBLISHED=false +PUBLISH_COMPLETE=false +PRESERVE_PUBLISH_BACKUP=false cleanup() { - /bin/rm -rf -- "$BUILD_VENV" - /bin/rm -f -- "$BUILD_LOG" "$REQUIREMENTS" + # Publishing is a two-artifact transaction. If either final rename failed or + # the build was interrupted between them, restore the exact previous pair. + if [[ "$PUBLISH_COMPLETE" != true ]]; then + if [[ "$NEW_APP_PUBLISHED" == true ]]; then + /bin/rm -rf -- "$FINAL_APP" + fi + if [[ "$NEW_DMG_PUBLISHED" == true ]]; then + /bin/rm -f -- "$FINAL_DMG" + fi + if [[ "$OLD_APP_BACKED_UP" == true \ + && ( -e "$PUBLISH_BACKUP/AgentBar.app" \ + || -L "$PUBLISH_BACKUP/AgentBar.app" ) ]]; then + if ! /bin/mv "$PUBLISH_BACKUP/AgentBar.app" "$FINAL_APP"; then + echo "严重:无法恢复上一版 AgentBar.app: $PUBLISH_BACKUP/AgentBar.app" >&2 + PRESERVE_PUBLISH_BACKUP=true + fi + fi + if [[ "$OLD_DMG_BACKED_UP" == true \ + && ( -e "$PUBLISH_BACKUP/$DMG_NAME" \ + || -L "$PUBLISH_BACKUP/$DMG_NAME" ) ]]; then + if ! /bin/mv "$PUBLISH_BACKUP/$DMG_NAME" "$FINAL_DMG"; then + echo "严重:无法恢复上一版 DMG: $PUBLISH_BACKUP/$DMG_NAME" >&2 + PRESERVE_PUBLISH_BACKUP=true + fi + fi + fi + if [[ -n "$BUILD_VENV" ]]; then /bin/rm -rf -- "$BUILD_VENV"; fi + if [[ -n "$BUILD_LOG" ]]; then /bin/rm -f -- "$BUILD_LOG"; fi + if [[ -n "$REQUIREMENTS" ]]; then /bin/rm -f -- "$REQUIREMENTS"; fi if [[ -n "$SMOKE_DIR" ]]; then /bin/rm -rf -- "$SMOKE_DIR"; fi if [[ -n "$STAGE" ]]; then /bin/rm -rf -- "$STAGE"; fi if [[ -n "$DMG_WORK" ]]; then /bin/rm -rf -- "$DMG_WORK"; fi + if [[ -n "$PUBLISH_STAGE" ]]; then /bin/rm -rf -- "$PUBLISH_STAGE"; fi + if [[ -n "$PUBLISH_BACKUP" && "$PRESERVE_PUBLISH_BACKUP" != true ]]; then + /bin/rm -rf -- "$PUBLISH_BACKUP" + fi /bin/rm -rf -- "$ROOT/packaging/build" "$ROOT/packaging/dist" \ "$ROOT/packaging/.eggs" "$ROOT/packaging/__pycache__" - if [[ -n "$APP_BACKUP" && -d "$APP_BACKUP" ]]; then - if [[ ! -e "$DIST/AgentBar.app" ]]; then - /bin/mv "$APP_BACKUP" "$DIST/AgentBar.app" || true - else - /bin/rm -rf -- "$APP_BACKUP" - fi - fi } trap cleanup EXIT trap 'exit 129' HUP trap 'exit 130' INT trap 'exit 143' TERM +# Install cleanup before the first allocation: if a later mktemp fails, every +# earlier temporary path is still reclaimed by the EXIT trap. +BUILD_VENV=$(/usr/bin/mktemp -d "$TEMP_ROOT/agentbar-build-venv.XXXXXX") +BUILD_LOG=$(/usr/bin/mktemp "$TEMP_ROOT/agentbar-py2app.XXXXXX.log") +REQUIREMENTS=$(/usr/bin/mktemp "$TEMP_ROOT/agentbar-requirements.XXXXXX.txt") + echo "==> 创建隔离构建环境: $PYFW" "$PYFW" -m venv "$BUILD_VENV" "$BUILD_VENV/bin/pip" -q install "py2app==$PY2APP_VERSION" @@ -128,26 +169,9 @@ AGENTBAR_STATE_DIR="$SMOKE_DIR" \ /bin/rm -rf -- "$SMOKE_DIR" SMOKE_DIR="" -# Publish the already-verified app while retaining the previous known-good app -# until the final rename succeeds. -/bin/mkdir -p "$DIST" -if [[ -e "$DIST/AgentBar.app" ]]; then - APP_BACKUP="$DIST/.AgentBar.app.previous.$$" - /bin/rm -rf -- "$APP_BACKUP" - /bin/mv "$DIST/AgentBar.app" "$APP_BACKUP" -fi -if ! /bin/mv "$CANDIDATE_APP" "$DIST/AgentBar.app"; then - echo "错误:无法发布新 AgentBar.app" >&2 - exit 1 -fi -if [[ -n "$APP_BACKUP" ]]; then - /bin/rm -rf -- "$APP_BACKUP" - APP_BACKUP="" -fi - echo "==> 生成并校验 DMG" STAGE=$(/usr/bin/mktemp -d "$TEMP_ROOT/agentbar-dmg-stage.XXXXXX") -/usr/bin/ditto "$DIST/AgentBar.app" "$STAGE/AgentBar.app" +/usr/bin/ditto "$CANDIDATE_APP" "$STAGE/AgentBar.app" /bin/ln -s /Applications "$STAGE/Applications" /bin/cat > "$STAGE/安装说明.txt" <<'EOF' AgentBar 安装: @@ -162,13 +186,37 @@ AgentBar 安装: EOF DMG_WORK=$(/usr/bin/mktemp -d "$TEMP_ROOT/agentbar-dmg-output.XXXXXX") -DMG_NAME="AgentBar-$VERSION.dmg" /usr/bin/hdiutil create -volname AgentBar -srcfolder "$STAGE" \ -format UDZO "$DMG_WORK/$DMG_NAME" -quiet /usr/bin/hdiutil verify "$DMG_WORK/$DMG_NAME" -quiet -/bin/mv -f "$DMG_WORK/$DMG_NAME" "$DIST/$DMG_NAME" -SIZE=$(/usr/bin/du -h "$DIST/$DMG_NAME" | /usr/bin/cut -f1) -SHA256=$(/usr/bin/shasum -a 256 "$DIST/$DMG_NAME" | /usr/bin/cut -d ' ' -f1) -echo "==> 完成: $DIST/$DMG_NAME ($SIZE)" +# Stage both verified artifacts on the destination filesystem, then replace the +# public pair together. The EXIT trap rolls back both names after any failure. +/bin/mkdir -p "$DIST" +PUBLISH_STAGE=$(/usr/bin/mktemp -d "$DIST/.agentbar-publish.XXXXXX") +PUBLISH_BACKUP=$(/usr/bin/mktemp -d "$DIST/.agentbar-publish-backup.XXXXXX") +/bin/mv "$CANDIDATE_APP" "$PUBLISH_STAGE/AgentBar.app" +/bin/mv "$DMG_WORK/$DMG_NAME" "$PUBLISH_STAGE/$DMG_NAME" +/usr/bin/codesign --verify --deep --strict "$PUBLISH_STAGE/AgentBar.app" +/usr/bin/hdiutil verify "$PUBLISH_STAGE/$DMG_NAME" -quiet + +SIZE=$(/usr/bin/du -h "$PUBLISH_STAGE/$DMG_NAME" | /usr/bin/cut -f1) +SHA256=$(/usr/bin/shasum -a 256 "$PUBLISH_STAGE/$DMG_NAME" | /usr/bin/cut -d ' ' -f1) + +if [[ -e "$FINAL_APP" || -L "$FINAL_APP" ]]; then + OLD_APP_BACKED_UP=true + /bin/mv "$FINAL_APP" "$PUBLISH_BACKUP/AgentBar.app" +fi +if [[ -e "$FINAL_DMG" || -L "$FINAL_DMG" ]]; then + OLD_DMG_BACKED_UP=true + /bin/mv "$FINAL_DMG" "$PUBLISH_BACKUP/$DMG_NAME" +fi + +NEW_APP_PUBLISHED=true +/bin/mv "$PUBLISH_STAGE/AgentBar.app" "$FINAL_APP" +NEW_DMG_PUBLISHED=true +/bin/mv "$PUBLISH_STAGE/$DMG_NAME" "$FINAL_DMG" +PUBLISH_COMPLETE=true + +echo "==> 完成: $FINAL_DMG ($SIZE)" echo "SHA-256: $SHA256" diff --git a/tests/test_config.py b/tests/test_config.py index 48f936b..c3d8d57 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -11,16 +11,11 @@ def test_quota_source_defaults_are_explicit_opt_in(tmp_path): settings = load_settings(tmp_path / "state") assert settings.usage_auto_refresh is False assert set(settings.quota_sources) == {"claude", "codex"} - for source in settings.quota_sources.values(): - assert source == { - "enabled": False, - "model": "", - "access_token": "", - "account_id": "", - } + assert settings.quota_sources["claude"] == {"enabled": False} + assert settings.quota_sources["codex"] == {"enabled": False, "model": ""} -def test_quota_source_model_and_secret_roundtrip_in_private_config(tmp_path): +def test_quota_source_model_roundtrip_drops_legacy_secrets(tmp_path): state_dir = tmp_path / "state" settings = load_settings(state_dir) settings.quota_sources["codex"].update({ @@ -36,9 +31,9 @@ def test_quota_source_model_and_secret_roundtrip_in_private_config(tmp_path): assert persisted["quota_sources"]["codex"] == { "enabled": True, "model": "codex_bengalfox", - "access_token": "oauth-secret", - "account_id": "account-123", } + assert "oauth-secret" not in settings.config_path.read_text(encoding="utf-8") + assert "account-123" not in settings.config_path.read_text(encoding="utf-8") loaded = load_settings(state_dir) assert loaded.quota_sources["codex"] == persisted["quota_sources"]["codex"] @@ -55,7 +50,7 @@ def checked_replace(source, destination): return real_replace(source, destination) monkeypatch.setattr(os, "replace", checked_replace) - settings.quota_sources["claude"]["access_token"] = "private-oauth-token" + settings.title_provider = "codex" save_settings(settings) assert seen_modes == [0o600] @@ -86,25 +81,33 @@ def test_quota_source_load_discards_unknown_sources_and_fields(tmp_path): assert loaded.quota_sources["codex"] == { "enabled": True, "model": "future-model-id", - "access_token": "secret", - "account_id": "account", } assert "unexpected" not in loaded.quota_sources["codex"] -def test_legacy_api_key_is_migrated_once_and_removed(tmp_path): +def test_legacy_quota_credentials_and_claude_model_are_removed(tmp_path): state_dir = tmp_path / "state" settings = load_settings(state_dir) payload = json.loads(settings.config_path.read_text(encoding="utf-8")) - payload["quota_sources"]["claude"].pop("access_token") + existing_admin_token = payload["token"] + payload["config_schema_version"] = 2 payload["quota_sources"]["claude"]["api_key"] = "legacy-oauth-token" + payload["quota_sources"]["claude"]["access_token"] = "legacy-access-token" + payload["quota_sources"]["claude"]["account_id"] = "legacy-account" + payload["quota_sources"]["claude"]["model"] = "opus" + payload["quota_sources"]["claude"]["enabled"] = True settings.config_path.write_text(json.dumps(payload), encoding="utf-8") loaded = load_settings(state_dir) - assert loaded.quota_sources["claude"]["access_token"] == "legacy-oauth-token" + assert loaded.quota_sources["claude"] == {"enabled": True} + assert loaded.token == existing_admin_token persisted = json.loads(loaded.config_path.read_text(encoding="utf-8")) - assert persisted["quota_sources"]["claude"]["access_token"] == "legacy-oauth-token" - assert "api_key" not in persisted["quota_sources"]["claude"] + assert persisted["config_schema_version"] == 3 + assert persisted["quota_sources"]["claude"] == {"enabled": True} + serialized = json.dumps(persisted) + assert "legacy-oauth-token" not in serialized + assert "legacy-access-token" not in serialized + assert "legacy-account" not in serialized def test_concurrent_saves_do_not_share_a_temporary_file(tmp_path): @@ -424,16 +427,11 @@ def test_nested_provider_and_quota_values_are_canonicalized(tmp_path): "refresh_seconds": 60, } assert loaded.quota_sources["claude"] == { - "enabled": False, - "model": "", - "access_token": "", - "account_id": "", + "enabled": True, } assert loaded.quota_sources["codex"] == { "enabled": True, "model": "model-id", - "access_token": "oauth-token", - "account_id": "", } persisted = json.loads(loaded.config_path.read_text(encoding="utf-8")) assert persisted["providers"] == loaded.providers @@ -452,7 +450,7 @@ def test_legacy_lan_default_is_migrated_to_loopback_once(tmp_path): assert loaded.lan_access is False assert loaded.token != "old-token" persisted = json.loads(loaded.config_path.read_text(encoding="utf-8")) - assert persisted["config_schema_version"] == 2 + assert persisted["config_schema_version"] == 3 assert persisted["lan_access"] is False assert persisted["token"] == loaded.token @@ -480,7 +478,7 @@ def test_non_migrating_client_load_does_not_rotate_live_legacy_token(tmp_path): server = load_settings(state_dir) assert server.token != legacy["token"] assert server.lan_access is False - assert json.loads(config_path.read_text(encoding="utf-8"))["config_schema_version"] == 2 + assert json.loads(config_path.read_text(encoding="utf-8"))["config_schema_version"] == 3 def test_non_migrating_client_load_does_not_rewrite_or_backup_damage(tmp_path): diff --git a/tests/test_menu_spec.py b/tests/test_menu_spec.py index 656e5f7..eb4e97f 100644 --- a/tests/test_menu_spec.py +++ b/tests/test_menu_spec.py @@ -58,8 +58,8 @@ def test_pause_resume_toggle(): def test_quota_submenu_contents(): now = time.time() spec = build_menu_spec(_snap(quota_source_config={ - "claude": {"enabled": True, "key_set": True}, - "codex": {"enabled": False, "key_set": False}, + "claude": {"enabled": True, "credential_mode": "auto"}, + "codex": {"enabled": False, "credential_mode": "auto"}, }, quota={ "claude": {"state": "limited", "windows": [{"label": "5h", "used_percent": 100.0, "resets_at": now + 600}, @@ -68,7 +68,7 @@ def test_quota_submenu_contents(): "detail": "", "error": None}, "codex": {"state": "unknown", "windows": [], "source": "none", "fetched_at": None, "plan": None, "detail": "未知(尚无额度数据)", - "error": "未读到 ~/.codex/auth.json(先运行 codex login)"}, + "error": "Codex CLI 尚未使用 ChatGPT 登录(请先运行 codex login)"}, })) subs = [n for n in spec if n["kind"] == "submenu" and "手机访问" not in n["title"]] assert len(subs) == 2 @@ -76,17 +76,17 @@ def test_quota_submenu_contents(): assert "100%" in claude["title"] or "5h 100%" in claude["title"] child_actions = [c["action"] for c in claude["children"] if c["kind"] == "action"] assert "refresh_quota:claude" in child_actions - # 手动 OAuth 模式不再提供 Keychain 授权入口。 + # 菜单只触发来源级重检,不读取或展示 CLI 登录凭据。 codex = subs[1] child_actions = [c["action"] for c in codex["children"] if c["kind"] == "action"] assert "authorize_keychain" not in child_actions -def test_legacy_keychain_error_never_reintroduces_authorize_action(): +def test_cli_auth_error_never_introduces_credential_authorize_action(): spec = build_menu_spec(_snap(quota={ "claude": {"state": "unknown", "windows": [], "source": "none", "fetched_at": None, "plan": None, "detail": "", - "error": "未读到 Claude 凭据(Keychain 静默读取被拒?菜单里可手动授权)"}, + "error": "Claude Code 尚未登录(请先运行 claude auth login)"}, })) sub = next(n for n in spec if n["kind"] == "submenu") child_actions = [c["action"] for c in sub["children"] if c["kind"] == "action"] @@ -120,7 +120,7 @@ def test_unconfigured_corp_providers_stay_discoverable_in_menu(): def test_quota_menu_shows_selected_model_and_refreshes_only_that_source(): now = time.time() spec = build_menu_spec(_snap(quota_source_config={ - "codex": {"enabled": True, "key_set": True, "model": "codex_bengalfox"}, + "codex": {"enabled": True, "credential_mode": "auto", "model": "codex_bengalfox"}, }, quota={ "codex": { "state": "ok", @@ -137,8 +137,8 @@ def test_quota_menu_shows_selected_model_and_refreshes_only_that_source(): })) row = next(n for n in spec if n["kind"] == "submenu" and "Codex" in n["title"]) assert "codex_bengalfox" in row["title"] - assert any("模型 codex_bengalfox" in child["title"] for child in row["children"]) - assert any("可选额度标识 codex_bengalfox" in child["title"] for child in row["children"]) + assert any("limitId codex_bengalfox" in child["title"] for child in row["children"]) + assert any("可选 limitId codex_bengalfox" in child["title"] for child in row["children"]) actions = [child.get("action") for child in row["children"]] assert "refresh_quota:codex" in actions assert "refresh_quota:claude" not in actions @@ -147,7 +147,7 @@ def test_quota_menu_shows_selected_model_and_refreshes_only_that_source(): def test_stale_quota_is_labeled_in_menu_instead_of_looking_current(): spec = build_menu_spec(_snap(quota_source_config={ - "codex": {"enabled": True, "key_set": True}, + "codex": {"enabled": True, "credential_mode": "auto"}, }, quota={ "codex": { "state": "unknown", @@ -165,14 +165,8 @@ def test_stale_quota_is_labeled_in_menu_instead_of_looking_current(): assert any("数据已过期" in child["title"] for child in row["children"]) -@pytest.mark.parametrize( - "source_cfg", - [ - {"enabled": False, "key_set": True, "model": "sonnet"}, - {"enabled": True, "key_set": False, "model": "sonnet"}, - ], -) -def test_observed_quota_for_unconfigured_source_has_setup_but_no_refresh(source_cfg): +def test_observed_quota_for_unconfigured_source_has_setup_but_no_refresh(): + source_cfg = {"enabled": False, "credential_mode": "auto"} spec = build_menu_spec(_snap( quota_source_config={"claude": source_cfg}, quota={ @@ -194,14 +188,13 @@ def test_observed_quota_for_unconfigured_source_has_setup_but_no_refresh(source_ assert "refresh_quota:claude" not in actions -def test_disabled_manual_quota_sources_stay_discoverable_without_refresh(): +def test_disabled_auto_quota_sources_stay_discoverable_without_refresh(): spec = build_menu_spec(_snap(quota_source_config={ - "claude": {"enabled": False, "key_set": False, "model": ""}, + "claude": {"enabled": False, "credential_mode": "auto", "model": ""}, "codex": { "enabled": False, - "key_set": True, + "credential_mode": "auto", "model": "codex_bengalfox", - "account_id_set": True, }, })) rows = [ @@ -212,7 +205,7 @@ def test_disabled_manual_quota_sources_stay_discoverable_without_refresh(): assert len(rows) == 2 claude = next(row for row in rows if "Claude" in row["title"]) codex = next(row for row in rows if "Codex" in row["title"]) - assert "未配置" in claude["title"] + assert "未启用" in claude["title"] assert "未启用" in codex["title"] assert "codex_bengalfox" in codex["title"] for row in rows: @@ -221,18 +214,16 @@ def test_disabled_manual_quota_sources_stay_discoverable_without_refresh(): assert not any(str(action).startswith("refresh_quota:") for action in actions) -def test_enabled_manual_quota_source_waiting_row_can_refresh_only_itself(): +def test_enabled_auto_quota_source_waiting_row_can_redetect_only_itself(): spec = build_menu_spec(_snap(quota_source_config={ "claude": { "enabled": True, - "key_set": True, - "model": "sonnet", - "account_id_set": False, + "credential_mode": "auto", }, })) row = next(n for n in spec if n["kind"] == "submenu" and "Claude" in n["title"]) assert "等待刷新" in row["title"] - assert "sonnet" in row["title"] + assert "limitId" not in row["title"] actions = [child.get("action") for child in row["children"]] assert "refresh_quota:claude" in actions assert "refresh_quota:codex" not in actions diff --git a/tests/test_menubar.py b/tests/test_menubar.py index 139712c..bc66f55 100644 --- a/tests/test_menubar.py +++ b/tests/test_menubar.py @@ -201,7 +201,9 @@ def test_web_panel_uses_fragment_and_session_storage_for_token_bootstrap(): assert 'localStorage.removeItem("agentbar_token"' in html assert 'localStorage.setItem("agentbar_token"' not in html assert ".api_key" not in html - assert "access_token" in html + assert "access_token" not in html + assert "account_id" not in html + assert "自动" in html assert 'id="btnRefreshQuota"' not in html assert "refreshQuotaSource('${name}')" in html # Clearing one cookie must not serialize unrelated unsaved form controls. diff --git a/tests/test_provider_window.py b/tests/test_provider_window.py index f7e7418..84f3db2 100644 --- a/tests/test_provider_window.py +++ b/tests/test_provider_window.py @@ -2,6 +2,7 @@ import copy import threading +import time from types import SimpleNamespace from AppKit import NSAlertFirstButtonReturn @@ -15,6 +16,22 @@ def __init__(self): self.reloads = 0 self.reload_args = [] self.refreshes = [] + self.statuses = { + "claude": { + "available": True, + "source": "claude_cli", + "status": "available", + "detail": "Claude Code 已登录", + "needs_authorization": False, + }, + "codex": { + "available": True, + "source": "codex_app_server", + "status": "available", + "detail": "Codex CLI 已登录", + "needs_authorization": False, + }, + } def reload_fetchers(self, refresh=True): self.reloads += 1 @@ -23,11 +40,23 @@ def reload_fetchers(self, refresh=True): def refresh_now(self, tool=None): self.refreshes.append(tool) + def credential_status(self, tool, **_kwargs): + return dict(self.statuses[tool]) + class _Core: def __init__(self): self.quota = _Quota() + def snapshot(self): + return { + "quota": { + "codex": { + "available_models": ["codex_primary", "codex_spark"], + }, + }, + } + def _controller(tmp_path): settings = load_settings(tmp_path / "provider-window-state") @@ -36,10 +65,22 @@ def _controller(tmp_path): core, settings ) controller._build() + controller._credential_status = { + source: dict(status) for source, status in core.quota.statuses.items() + } controller._reload_controls() return controller, core, settings +def _wait_for(predicate, timeout=2): + deadline = time.time() + timeout + while time.time() < deadline: + if predicate(): + return True + time.sleep(0.01) + return bool(predicate()) + + def test_native_provider_window_contains_all_quota_sources(tmp_path): controller, _, _ = _controller(tmp_path) try: @@ -51,16 +92,81 @@ def test_native_provider_window_contains_all_quota_sources(tmp_path): assert "未配置 Cookie" in str(controller._status["mytoken"].stringValue()) assert "浏览器登录" in str(controller._chrome_buttons["mytoken"].title()) assert "已有登录" in str(controller._import_buttons["mytoken"].title()) - assert "OAuth Access Token" in str(controller._status["claude"].stringValue()) - assert str(controller._source_key["claude"].stringValue()) == "" - assert "metered_feature" in str( + assert "检测" in str(controller._status["claude"].stringValue()) + assert "claude" not in controller._source_model + assert not hasattr(controller, "_source_key") + assert not hasattr(controller, "_source_account") + assert "limitId" in str( controller._source_model["codex"].placeholderString() ) + assert list(controller._source_model["codex"].objectValues()) == [ + "codex_primary", "codex_spark", + ] assert controller.auto_refresh_check.state() == 0 finally: controller.window.close() +def test_native_cli_detection_never_blocks_appkit_main_thread(tmp_path): + settings = load_settings(tmp_path / "nonblocking-provider-window") + core = _Core() + started = threading.Event() + release = threading.Event() + calls = [] + + def blocking_status(tool, **kwargs): + calls.append((tool, kwargs)) + started.set() + assert release.wait(2) + return dict(core.quota.statuses[tool]) + + core.quota.credential_status = blocking_status + controller = ProviderSettingsWindowController.alloc().initWithCore_settings_( + core, settings + ) + controller._build() + try: + before = time.monotonic() + controller._reload_controls() + elapsed = time.monotonic() - before + + assert elapsed < 0.2 + assert started.wait(1) + assert "检测" in str(controller._status["claude"].stringValue()) + finally: + release.set() + controller.window.close() + assert all(call[1]["allow_interactive"] is False for call in calls) + assert all(call[1]["settings"] is settings for call in calls) + + +def test_native_save_applies_slow_fetcher_reload_off_main_thread(tmp_path): + controller, core, settings = _controller(tmp_path) + started = threading.Event() + release = threading.Event() + + def slow_reload(refresh=True): + started.set() + assert release.wait(2) + core.quota.reloads += 1 + core.quota.reload_args.append(refresh) + + core.quota.reload_fetchers = slow_reload + controller._enabled["codex"].setState_(1) + try: + before = time.monotonic() + controller.onSave_(None) + elapsed = time.monotonic() - before + + assert elapsed < 0.2 + assert settings.quota_sources["codex"]["enabled"] is True + assert started.wait(1) + assert "正在后台" in str(controller._message.stringValue()) + finally: + release.set() + controller.window.close() + + def test_close_active_logins_is_idempotent_and_reclaims_workers(tmp_path): controller, _, _ = _controller(tmp_path) calls = [] @@ -170,6 +276,7 @@ def test_native_provider_save_updates_settings_and_refreshes(tmp_path): assert settings.providers["mytoken"]["refresh_seconds"] == 90 assert settings.providers["mytoken"]["cookie"] == "SESSION=secret" assert settings.title_provider == "mytoken" + assert _wait_for(lambda: core.quota.refreshes == ["mytoken"]) assert core.quota.reloads == 1 assert core.quota.reload_args == [False] assert core.quota.refreshes == ["mytoken"] @@ -178,43 +285,27 @@ def test_native_provider_save_updates_settings_and_refreshes(tmp_path): controller.window.close() -def test_native_subscription_sources_save_write_only_tokens(tmp_path): +def test_native_subscription_sources_use_detected_cli_login(tmp_path): controller, core, settings = _controller(tmp_path) try: controller._enabled["claude"].setState_(1) - controller._source_model["claude"].setStringValue_("sonnet") - controller._source_key["claude"].setStringValue_("claude-oauth-secret") - controller._enabled["codex"].setState_(1) controller._source_model["codex"].setStringValue_("codex_other") - controller._source_key["codex"].setStringValue_("codex-oauth-secret") - controller._source_account["codex"].setStringValue_("account-123") controller.onSave_(None) - assert settings.quota_sources["claude"] == { - "enabled": True, - "model": "sonnet", - "access_token": "claude-oauth-secret", - "account_id": "", - } + assert settings.quota_sources["claude"] == {"enabled": True} assert settings.quota_sources["codex"] == { - "enabled": True, - "model": "codex_other", - "access_token": "codex-oauth-secret", - "account_id": "account-123", + "enabled": True, "model": "codex_other", } + assert _wait_for(lambda: core.quota.refreshes == ["claude", "codex"]) assert core.quota.reloads == 1 assert core.quota.reload_args == [False] assert core.quota.refreshes == ["claude", "codex"] - # Reload after save must never put a persisted secret back into a control. - assert str(controller._source_key["claude"].stringValue()) == "" - assert str(controller._source_key["codex"].stringValue()) == "" - assert "secret" not in str(controller._status["claude"].stringValue()) finally: controller.window.close() -def test_native_blank_subscription_credentials_preserve_saved_values(tmp_path): +def test_native_save_removes_legacy_subscription_secrets(tmp_path): controller, core, settings = _controller(tmp_path) try: settings.quota_sources["claude"].update({ @@ -229,65 +320,19 @@ def test_native_blank_subscription_credentials_preserve_saved_values(tmp_path): "account_id": "keep-account", }) controller._reload_controls() - controller._source_model["claude"].setStringValue_("sonnet") + controller._source_model["codex"].setStringValue_("new-limit") controller.onSave_(None) - assert settings.quota_sources["claude"]["access_token"] == "keep-this-token" - assert settings.quota_sources["claude"]["model"] == "sonnet" - assert settings.quota_sources["codex"]["access_token"] == "keep-codex-token" - assert settings.quota_sources["codex"]["account_id"] == "keep-account" + assert "access_token" not in settings.quota_sources["claude"] + assert "account_id" not in settings.quota_sources["claude"] + assert "model" not in settings.quota_sources["claude"] + assert "access_token" not in settings.quota_sources["codex"] + assert "account_id" not in settings.quota_sources["codex"] + assert settings.quota_sources["codex"]["model"] == "new-limit" + assert _wait_for(lambda: core.quota.refreshes == ["codex"]) assert core.quota.reloads == 1 assert core.quota.reload_args == [False] - assert core.quota.refreshes == ["claude"] - finally: - controller.window.close() - - -def test_native_codex_account_id_can_be_cleared_without_deleting_token( - tmp_path, monkeypatch, -): - controller, core, settings = _controller(tmp_path) - settings.quota_sources["codex"].update({ - "enabled": True, - "access_token": "keep-codex-token", - "account_id": "remove-account", - }) - - class ConfirmAlert: - @classmethod - def alloc(cls): - return cls() - - def init(self): - return self - - def setMessageText_(self, _text): - pass - - def setInformativeText_(self, _text): - pass - - def addButtonWithTitle_(self, _title): - pass - - def runModal(self): - return NSAlertFirstButtonReturn - - monkeypatch.setattr("agentbar.provider_window.NSAlert", ConfirmAlert) - try: - controller._reload_controls() - assert controller._source_account_clear["codex"].isEnabled() - - controller.onClearAccount_( - SimpleNamespace(representedObject=lambda: "codex") - ) - - assert settings.quota_sources["codex"]["account_id"] == "" - assert settings.quota_sources["codex"]["access_token"] == "keep-codex-token" - assert settings.quota_sources["codex"]["enabled"] is True - assert core.quota.reload_args == [False] assert core.quota.refreshes == ["codex"] - assert not controller._source_account_clear["codex"].isEnabled() finally: controller.window.close() @@ -295,29 +340,34 @@ def runModal(self): def test_native_manual_refresh_is_source_scoped(tmp_path): controller, core, settings = _controller(tmp_path) try: - settings.quota_sources["claude"].update({ - "enabled": True, - "access_token": "configured-token", - }) + settings.quota_sources["claude"]["enabled"] = True controller._reload_controls() controller.onRefreshSource_(controller._source_refresh_buttons["claude"]) + deadline = time.time() + 2 + while time.time() < deadline and not core.quota.refreshes: + time.sleep(0.01) assert core.quota.refreshes == ["claude"] - assert "手动额度刷新" in str(controller._message.stringValue()) finally: controller.window.close() -def test_native_enabled_subscription_requires_access_token(tmp_path): +def test_native_can_save_enabled_source_while_cli_is_logged_out(tmp_path): controller, core, settings = _controller(tmp_path) try: alerts = [] controller._alert = lambda title, text: alerts.append((title, text)) + controller._credential_status["claude"] = { + "available": False, + "detail": "请先运行 claude auth login", + } controller._enabled["claude"].setState_(1) controller.onSave_(None) - assert core.quota.reloads == 0 - assert settings.quota_sources["claude"]["enabled"] is False - assert alerts and "OAuth Access Token" in alerts[0][1] + assert _wait_for(lambda: core.quota.reloads == 1) + assert core.quota.reloads == 1 + assert settings.quota_sources["claude"]["enabled"] is True + assert core.quota.refreshes == [] + assert alerts == [] finally: controller.window.close() @@ -343,6 +393,7 @@ def test_native_auto_refresh_toggle_reschedules_without_fetching(tmp_path): controller.onSave_(None) assert settings.usage_auto_refresh is True + assert _wait_for(lambda: core.quota.reloads == 1) assert core.quota.reload_args == [False] assert core.quota.refreshes == [] finally: @@ -420,14 +471,11 @@ def runModal(self): "mytoken", SimpleNamespace(header="session=secret", source="test", count=1), ) - controller.onClearSource_( - SimpleNamespace(representedObject=lambda: "claude") - ) controller.onClear_( SimpleNamespace(representedObject=lambda: "mytoken") ) - assert lock_states == [True, True, True, True] + assert lock_states == [True, True, True] finally: controller.window.close() @@ -443,7 +491,6 @@ def test_native_save_failure_rolls_back_all_settings(tmp_path, monkeypatch): alerts = [] controller._alert = lambda title, text: alerts.append((title, text)) controller._enabled["claude"].setState_(1) - controller._source_key["claude"].setStringValue_("new-secret") controller._enabled["mytoken"].setState_(1) controller._cookie["mytoken"].setStringValue_("SESSION=new-secret") controller.title_popup.selectItemAtIndex_(2) @@ -467,12 +514,8 @@ def fail_save(_settings): controller.window.close() -def test_native_destructive_save_failure_keeps_credentials(tmp_path, monkeypatch): +def test_native_destructive_provider_save_failure_keeps_cookie(tmp_path, monkeypatch): controller, core, settings = _controller(tmp_path) - settings.quota_sources["claude"].update({ - "enabled": True, - "access_token": "keep-token", - }) settings.providers["mytoken"].update({ "enabled": True, "cookie": "SESSION=keep-cookie", @@ -506,19 +549,16 @@ def runModal(self): lambda _settings: (_ for _ in ()).throw(OSError("read only")), ) try: - controller.onClearSource_(SimpleNamespace(representedObject=lambda: "claude")) controller.onClear_(SimpleNamespace(representedObject=lambda: "mytoken")) controller._save_imported_cookie( "mytoken", SimpleNamespace(header="SESSION=replacement", source="test", count=1), ) - assert settings.quota_sources["claude"]["access_token"] == "keep-token" - assert settings.quota_sources["claude"]["enabled"] is True assert settings.providers["mytoken"]["cookie"] == "SESSION=keep-cookie" assert settings.providers["mytoken"]["enabled"] is True assert core.quota.reloads == 0 assert core.quota.refreshes == [] - assert len(alerts) == 3 + assert len(alerts) == 2 finally: controller.window.close() diff --git a/tests/test_quota_monitor.py b/tests/test_quota_monitor.py index b4de53a..6b2029f 100644 --- a/tests/test_quota_monitor.py +++ b/tests/test_quota_monitor.py @@ -26,6 +26,23 @@ def test_unknown_without_observation(tmp_path): assert st.source == "none" +def test_claude_logged_in_status_is_honest_without_fabricated_usage(tmp_path): + m = _m(tmp_path) + m._usage["claude"] = UsageSnapshot( + "claude", + plan="pro", + source="claude_auth_status", + ) + + st = m.status("claude") + + assert st.state == "unknown" + assert st.source == "claude_auth_status" + assert st.windows == [] + assert st.plan == "pro" + assert "任务观测" in st.detail + + def test_limited_then_ok(tmp_path): m = _m(tmp_path) reset = time.time() + 600 @@ -411,7 +428,6 @@ def fetch(self): settings.quota_sources["codex"].update({ "enabled": True, "model": "new-model", - "access_token": "new-access-token", }) m.reload_fetchers(refresh=False) old.release.set() @@ -483,12 +499,12 @@ def test_reload_refreshes_only_changed_sources_and_preserves_other_snapshot( m = _m(tmp_path) class _ConfiguredFetcher(_CountingFetcher): - def __init__(self, tool, access_token): + def __init__(self, tool, model): super().__init__(tool) - self.access_token = access_token + self.model = model - claude_old = _ConfiguredFetcher("claude", "same-token") - codex_old = _ConfiguredFetcher("codex", "old-token") + claude_old = _ConfiguredFetcher("claude", "account") + codex_old = _ConfiguredFetcher("codex", "old-limit") m._fetchers = {"claude": claude_old, "codex": codex_old} claude_snapshot = UsageSnapshot( "claude", windows=[UsageWindow("5h", 10)], source="test" @@ -497,8 +513,8 @@ def __init__(self, tool, access_token): "claude": claude_snapshot, "codex": UsageSnapshot("codex", windows=[UsageWindow("5h", 20)], source="test"), } - claude_rebuilt = _ConfiguredFetcher("claude", "same-token") - codex_new = _ConfiguredFetcher("codex", "new-token") + claude_rebuilt = _ConfiguredFetcher("claude", "account") + codex_new = _ConfiguredFetcher("codex", "new-limit") monkeypatch.setattr( "agentbar.quota.get_usage_fetchers", lambda _settings: {"claude": claude_rebuilt, "codex": codex_new}, @@ -517,12 +533,27 @@ def __init__(self, tool, access_token): assert codex_new.calls == 1 -def test_reload_never_builds_fetcher_from_uncommitted_settings(tmp_path): +def test_reload_never_builds_fetcher_from_uncommitted_settings(tmp_path, monkeypatch): settings = Settings(state_dir=tmp_path) settings.quota_sources["codex"].update({ "enabled": True, - "access_token": "stable-token", + "model": "stable-limit", }) + class _ModelFetcher: + tool = "codex" + + def __init__(self, model): + self.model = model + + def fetch(self): + return UsageSnapshot("codex", source="test", model=self.model) + + monkeypatch.setattr( + "agentbar.quota.get_usage_fetchers", + lambda current: { + "codex": _ModelFetcher(current.quota_sources["codex"]["model"]) + }, + ) m = QuotaMonitor(settings) transaction_open = threading.Event() release_transaction = threading.Event() @@ -530,10 +561,10 @@ def test_reload_never_builds_fetcher_from_uncommitted_settings(tmp_path): def rolled_back_writer(): with settings._lock: - settings.quota_sources["codex"]["access_token"] = "transient-token" + settings.quota_sources["codex"]["model"] = "transient-limit" transaction_open.set() assert release_transaction.wait(2) - settings.quota_sources["codex"]["access_token"] = "stable-token" + settings.quota_sources["codex"]["model"] = "stable-limit" writer = threading.Thread(target=rolled_back_writer) writer.start() @@ -550,7 +581,7 @@ def rolled_back_writer(): reloader.join(timeout=2) assert reload_done.is_set() - assert m._fetchers["codex"].access_token == "stable-token" + assert m._fetchers["codex"].model == "stable-limit" def test_refresh_error_keeps_fresh_last_good_snapshot(tmp_path): diff --git a/tests/test_release_scripts.py b/tests/test_release_scripts.py index 5c98b93..9c37dde 100644 --- a/tests/test_release_scripts.py +++ b/tests/test_release_scripts.py @@ -63,9 +63,18 @@ def test_dmg_build_uses_locked_dependencies_and_cleans_temporary_outputs(): assert '"$UV_BIN" export' in build assert "--locked" in build assert "trap cleanup EXIT" in build + assert build.index("trap cleanup EXIT") < build.index( + 'BUILD_VENV=$(/usr/bin/mktemp' + ) assert "--bundle-smoke" in build assert "hdiutil verify" in build assert "codesign --verify" in build + assert 'ditto "$CANDIDATE_APP" "$STAGE/AgentBar.app"' in build + assert 'ditto "$DIST/AgentBar.app"' not in build + assert "PUBLISH_STAGE" in build + assert "PUBLISH_BACKUP" in build + assert "PUBLISH_COMPLETE=true" in build + assert build.index("hdiutil verify") < build.index("NEW_APP_PUBLISHED=true") def test_release_version_sources_match(): @@ -73,7 +82,29 @@ def test_release_version_sources_match(): init_text = (ROOT / "agentbar" / "__init__.py").read_text(encoding="utf-8") match = re.search(r'__version__ = "([^"]+)"', init_text) assert match is not None - assert match.group(1) == project["project"]["version"] + version = match.group(1) + assert re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version) + assert version == project["project"]["version"] + + lock = tomllib.loads((ROOT / "uv.lock").read_text(encoding="utf-8")) + locked_project_versions = [ + package.get("version") + for package in lock.get("package", []) + if package.get("name") == "agentbar" + and package.get("source", {}).get("editable") == "." + ] + assert locked_project_versions == [version] + + +def test_readme_documents_cli_credential_and_fallback_boundaries(): + readme = (ROOT / "README.md").read_text(encoding="utf-8") + assert "claude auth status --json" in readme + assert "不读取 Keychain" in readme + assert "legal-and-compliance#authentication-and-credential-use" in readme + assert "codex app-server" in readme + assert "experimental" in readme + assert "不读取 `auth.json` 兜底" in readme + assert "不内置、复制或修改 Claude Code / Codex CLI" in readme def test_py2app_declares_lazy_runtime_dependencies(): diff --git a/tests/test_server.py b/tests/test_server.py index 33a18f0..3fd26c8 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -10,6 +10,18 @@ from conftest import wait_for +@pytest.fixture(autouse=True) +def _stub_cli_credential_status(monkeypatch): + """HTTP tests never probe the developer machine's real CLI login state.""" + monkeypatch.setattr("agentbar.server.credential_status", lambda tool, **kwargs: { + "available": False, + "source": f"{tool}_cli", + "status": "not_logged_in", + "detail": "not logged in", + "needs_authorization": False, + }) + + @pytest.fixture def api(core, settings): settings.port = 0 # 随机端口 @@ -533,11 +545,17 @@ def test_quota_refresh_endpoint_targets_only_enabled_source(api): assert code == 202 and j["ok"] assert refreshed == ["codex"] + srv.core.quota.provider_tools = lambda: ["mytoken"] + code, j = _call(srv, "/api/quota/refresh", "POST", s.token, + {"tool": "mytoken"}) + assert code == 202 and j["ok"] + assert refreshed == ["codex", "mytoken"] + code, j = _call(srv, "/api/quota/refresh", "POST", s.token, {"tool": "claude"}) assert code == 400 - assert "未启用" in j["error"] - assert refreshed == ["codex"] + assert "未检测到 Claude Code 登录态" in j["error"] + assert refreshed == ["codex", "mytoken"] def test_provider_config_endpoint_masks_cookie(api): @@ -568,8 +586,13 @@ def test_provider_cookie_preview_never_echoes_raw_or_malformed_secret(api): assert secret not in json.dumps(payload) -def test_provider_config_endpoint_masks_manual_quota_credentials(api): +def test_provider_config_exposes_only_non_secret_cli_status(api, monkeypatch): srv, s = api + monkeypatch.setattr("agentbar.server.credential_status", lambda *a, **k: { + "available": True, + "source": "codex_app_server", + "status": "available", + }) s.quota_sources["codex"].update({ "enabled": True, "model": "codex_bengalfox", @@ -582,8 +605,9 @@ def test_provider_config_endpoint_masks_manual_quota_credentials(api): assert source == { "enabled": True, "model": "codex_bengalfox", - "key_set": True, - "account_id_set": True, + "credential_available": True, + "credential_source": "codex_app_server", + "credential_status": "available", } serialized = json.dumps(j) assert "oauth-super-secret" not in serialized @@ -600,6 +624,76 @@ def test_provider_config_endpoint_masks_manual_quota_credentials(api): assert "account_id" not in state["quota_source_config"]["codex"] +def test_cli_credential_redetect_is_local_noninteractive_and_source_scoped( + api, monkeypatch, +): + srv, settings = api + settings.quota_sources["codex"]["enabled"] = True + calls = [] + + def detect(tool, **kwargs): + calls.append((tool, kwargs)) + return { + "available": True, + "source": "codex_app_server", + "status": "available", + "detail": "must not be returned", + "account_id": "must-not-leak", + } + + monkeypatch.setattr("agentbar.server.credential_status", detect) + reloads = [] + refreshes = [] + srv.core.quota.reload_fetchers = lambda refresh=True: reloads.append(refresh) + srv.core.quota.refresh_now = refreshes.append + + code, payload = _call( + srv, + "/api/provider-config/detect-credential", + "POST", + settings.token, + {"source": "codex"}, + ) + + assert code == 200 + assert calls and all(call[1]["allow_interactive"] is False for call in calls) + assert calls[0][1]["refresh"] is True + assert calls[0][1]["settings"] is settings + assert reloads == [False] + assert refreshes == ["codex"] + source = payload["quota_sources"]["codex"] + assert source["credential_available"] is True + assert source["credential_source"] == "codex_app_server" + assert set(source) == { + "enabled", "model", "credential_available", + "credential_source", "credential_status", + } + serialized = json.dumps(payload) + assert "must-not-leak" not in serialized + assert "must not be returned" not in serialized + + +def test_cli_credential_redetect_rejects_secret_fields_before_detection( + api, monkeypatch, +): + srv, settings = api + monkeypatch.setattr( + "agentbar.server.credential_status", + lambda *a, **k: pytest.fail("secret payload reached detector"), + ) + + code, payload = _call( + srv, + "/api/provider-config/detect-credential", + "POST", + settings.token, + {"source": "codex", "access_token": "must-not-save"}, + ) + + assert code == 400 + assert "不接受任何凭据" in payload["error"] + + def test_provider_config_save_reloads_fetchers(api): srv, s = api reloaded = [] @@ -638,6 +732,7 @@ def test_provider_config_save_reloads_fetchers(api): {"quota_sources": {"claude": {"model": ["sonnet"]}}}, {"quota_sources": {"claude": {"access_token": {"token": "secret"}}}}, {"quota_sources": {"codex": {"account_id": ["account"]}}}, + {"access_token": "top-level-secret"}, {"usage_auto_refresh": "false"}, {"title_provider": {"name": "codex"}}, {"title_provider": "unknown"}, @@ -669,8 +764,11 @@ def test_provider_config_rejects_malformed_known_fields_without_mutation( ) == before -def test_provider_config_manual_source_model_roundtrip(api): +def test_provider_config_cli_source_and_limit_id_roundtrip(api, monkeypatch): srv, s = api + monkeypatch.setattr("agentbar.server.credential_status", lambda *a, **k: { + "available": True, "source": "codex_app_server", "status": "available", + }) reloaded = [] srv.core.quota.reload_fetchers = lambda: reloaded.append(True) code, j = _call(srv, "/api/provider-config", "POST", s.token, { @@ -678,8 +776,6 @@ def test_provider_config_manual_source_model_roundtrip(api): "codex": { "enabled": True, "model": "codex_bengalfox", - "access_token": "oauth-new-secret", - "account_id": "account-123", }, }, "usage_auto_refresh": False, @@ -689,41 +785,34 @@ def test_provider_config_manual_source_model_roundtrip(api): assert s.quota_sources["codex"] == { "enabled": True, "model": "codex_bengalfox", - "access_token": "oauth-new-secret", - "account_id": "account-123", } assert s.usage_auto_refresh is False assert s.title_provider == "codex" assert j["quota_sources"]["codex"] == { "enabled": True, "model": "codex_bengalfox", - "key_set": True, - "account_id_set": True, + "credential_available": True, + "credential_source": "codex_app_server", + "credential_status": "available", } - assert "oauth-new-secret" not in json.dumps(j) - assert "account-123" not in json.dumps(j) assert reloaded == [True] -def test_provider_config_accepts_legacy_api_key_but_only_persists_access_token(api): +def test_provider_config_rejects_legacy_secret_payload_without_mutation(api): srv, s = api - srv.core.quota.reload_fetchers = lambda: None + before = json.loads(json.dumps(s.quota_sources)) code, j = _call(srv, "/api/provider-config", "POST", s.token, { "quota_sources": { "claude": { - "enabled": True, - "model": "sonnet", "api_key": "legacy-client-token", }, }, }) - assert code == 200, j - assert s.quota_sources["claude"]["access_token"] == "legacy-client-token" - assert "api_key" not in s.quota_sources["claude"] + assert code == 400, j + assert s.quota_sources == before serialized = json.dumps(j) assert "legacy-client-token" not in serialized - assert "access_token" not in j["quota_sources"]["claude"] - assert "api_key" not in j["quota_sources"]["claude"] + assert "API 不接收任何密钥" in j["error"] def test_provider_config_is_direct_local_only_even_through_loopback_proxy(api): @@ -799,6 +888,16 @@ def test_cookie_import_and_debug_dispatch_are_direct_local_only(api, monkeypatch assert code == 403 assert imported == [] + code, _ = _call( + srv, + "/api/provider-config/detect-credential", + "POST", + s.token, + {"source": "codex"}, + host=tunnel_host, + ) + assert code == 403 + seen = [] srv.hooks["dispatch"] = seen.append code, _ = _call( @@ -848,10 +947,7 @@ def test_provider_config_persistence_failure_returns_503_without_secret_echo( ) code, payload = _call(srv, "/api/provider-config", "POST", settings.token, { - "quota_sources": {"claude": { - "enabled": True, - "access_token": "must-not-echo", - }}, + "title_provider": "mytoken", }) assert code == 503 @@ -861,8 +957,11 @@ def test_provider_config_persistence_failure_returns_503_without_secret_echo( assert settings.quota_sources == before -def test_provider_config_omitted_manual_secret_preserves_existing(api): +def test_provider_config_save_removes_existing_legacy_secrets(api, monkeypatch): srv, s = api + monkeypatch.setattr("agentbar.server.credential_status", lambda *a, **k: { + "available": True, "source": "codex_app_server", "status": "available", + }) s.quota_sources["codex"].update({ "enabled": True, "model": "old-model", @@ -879,14 +978,11 @@ def test_provider_config_omitted_manual_secret_preserves_existing(api): assert s.quota_sources["codex"] == { "enabled": True, "model": "new-model", - "access_token": "keep-this-secret", - "account_id": "keep-this-account", } - assert j["quota_sources"]["codex"]["key_set"] is True - assert j["quota_sources"]["codex"]["account_id_set"] is True + assert j["quota_sources"]["codex"]["credential_available"] is True -def test_provider_config_explicit_blank_clears_disabled_source(api): +def test_provider_config_rejects_blank_legacy_secret_fields(api): srv, s = api s.quota_sources["claude"].update({ "enabled": True, @@ -904,32 +1000,32 @@ def test_provider_config_explicit_blank_clears_disabled_source(api): }, }, }) - assert code == 200, j - assert s.quota_sources["claude"]["enabled"] is False - assert s.quota_sources["claude"]["access_token"] == "" - assert s.quota_sources["claude"]["account_id"] == "" - assert j["quota_sources"]["claude"]["key_set"] is False - assert j["quota_sources"]["claude"]["account_id_set"] is False + assert code == 400, j + assert s.quota_sources["claude"]["enabled"] is True + assert s.quota_sources["claude"]["access_token"] == "remove-this-secret" -def test_provider_config_rejects_enabling_source_with_blank_secret(api): +def test_provider_config_saves_enabled_source_while_cli_is_logged_out( + api, monkeypatch, +): srv, s = api + monkeypatch.setattr("agentbar.server.credential_status", lambda *a, **k: { + "available": False, "source": "claude_cli", "status": "not_logged_in", + }) reloaded = [] srv.core.quota.reload_fetchers = lambda: reloaded.append(True) code, j = _call(srv, "/api/provider-config", "POST", s.token, { "quota_sources": { "claude": { "enabled": True, - "model": "sonnet", - "access_token": "", }, }, }) - assert code == 400 - assert "必须输入" in j["error"] - assert s.quota_sources["claude"]["enabled"] is False - assert s.quota_sources["claude"]["access_token"] == "" - assert reloaded == [] + assert code == 200 + assert s.quota_sources["claude"]["enabled"] is True + assert j["quota_sources"]["claude"]["credential_available"] is False + assert j["quota_sources"]["claude"]["credential_status"] == "not_logged_in" + assert reloaded == [True] def test_provider_cookie_import_updates_config(api, monkeypatch): @@ -1046,4 +1142,11 @@ def test_desktop_editor_uses_adapter_capabilities_instead_of_hardcoded_lists(): assert "const presets = info.models || []" in html assert "const efforts = info.efforts || []" in html assert "sonnet\", \"opus\", \"fable" not in html - assert "clearCodexAccountId" in html + assert "clearCodexAccountId" not in html + assert "q-codex-account" not in html + assert "sourceSecret" not in html + assert "Codex 额度由 CLI App Server" in html + assert "可选 limitId" in html + assert 'cliSource ? { source: name } : { tool: name }' in html + assert '"/api/provider-config/detect-credential"' in html + assert '"/api/quota/refresh"' in html diff --git a/tests/test_usage.py b/tests/test_usage.py index 5543926..954ea30 100644 --- a/tests/test_usage.py +++ b/tests/test_usage.py @@ -1,64 +1,242 @@ +import json +import os +import textwrap +import time + import pytest from agentbar.config import Settings -from agentbar.usage import ClaudeUsageFetcher, CodexUsageFetcher +from agentbar.usage import ( + ClaudeUsageFetcher, + CodexUsageFetcher, + credential_status, +) -def test_claude_usage_parse_windows(): - snap = ClaudeUsageFetcher().parse({ - "five_hour": {"utilization": 32.5, "resets_at": "2026-07-13T12:00:00Z"}, - "seven_day": {"utilization": 88, "resets_at": "2026-07-19T12:00:00Z"}, - }, plan="pro") - assert snap.plan == "pro" - assert [(w.label, w.used_percent) for w in snap.windows] == [("5h", 32.5), ("7d", 88.0)] - assert [w.model for w in snap.windows] == [None, None] +def _make_cli(tmp_path, name, body): + path = tmp_path / name + path.write_text("#!/usr/bin/env python3\n" + textwrap.dedent(body), encoding="utf-8") + path.chmod(0o700) + return path + + +def test_claude_uses_status_command_only_and_discards_pii(tmp_path, monkeypatch): + cli = _make_cli(tmp_path, "claude", """ + import json + print(json.dumps({ + "loggedIn": True, + "authMethod": "claude.ai", + "subscriptionType": "max", + "email": "private@example.test", + "orgId": "secret-org-id", + "orgName": "Secret Org", + "accessToken": "must-never-escape", + })) + """) + monkeypatch.setattr( + "agentbar.usage._http_get_json", + lambda *args, **kwargs: pytest.fail("Claude must not call a private usage API"), + ) + fetcher = ClaudeUsageFetcher(binary=str(cli)) + creds = fetcher.load_credentials() + snap = fetcher.fetch() -def test_claude_selected_opus_keeps_common_and_opus_windows_only(): - snap = ClaudeUsageFetcher(model="claude-opus-4-5").parse({ - "five_hour": {"utilization": 10}, - "seven_day": {"utilization": 20}, - "seven_day_opus": {"utilization": 30}, - "seven_day_sonnet": {"utilization": 40}, - }) + assert creds == {"plan": "max", "auth_method": "claude.ai"} + assert snap.windows == [] + assert snap.plan == "max" + assert snap.source == "claude_auth_status" + serialized = json.dumps({"creds": creds, "snap": snap.to_dict()}) + assert "private@example" not in serialized + assert "secret-org" not in serialized + assert "must-never-escape" not in serialized - assert snap.model == "claude-opus-4-5" - assert snap.available_models == ["opus", "sonnet"] - assert [(w.label, w.used_percent, w.model) for w in snap.windows] == [ - ("5h", 10.0, None), - ("7d", 20.0, None), - ("7d Opus", 30.0, "opus"), - ] - assert snap.to_dict()["windows"][-1]["model"] == "opus" +def test_claude_logged_out_is_diagnostic_and_has_no_fake_windows(tmp_path): + cli = _make_cli(tmp_path, "claude", """ + import json + print(json.dumps({"loggedIn": False, "email": "private@example.test"})) + """) -def test_manual_credentials_are_the_only_credential_source(monkeypatch): - monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "must-not-be-read") - monkeypatch.setenv("CODEX_HOME", "/must/not/be/read") + snap = ClaudeUsageFetcher(binary=str(cli)).fetch() - assert ClaudeUsageFetcher().load_credentials() is None - assert CodexUsageFetcher().load_credentials() is None - assert ClaudeUsageFetcher(access_token=" configured-claude ").load_credentials() == { - "token": "configured-claude", - "plan": None, - } - assert CodexUsageFetcher( - access_token=" configured-codex ", account_id=" account-123 " - ).load_credentials() == { - "token": "configured-codex", - "account_id": "account-123", - "plan": None, - } + assert snap.windows == [] + assert "未登录" in snap.error + assert "private@example" not in snap.error + + +def test_credential_status_is_sanitized_and_cached(monkeypatch): + import agentbar.usage as usage_module + + usage_module._clear_credential_status_cache() + calls = [] + def fake_resolver(**kwargs): + calls.append(kwargs) + return usage_module._CredentialResolution( + {"token": "never-public"}, "claude_auth_status", "available", "ready" + ) -def test_codex_malformed_non_object_jwt_payload_does_not_break_manual_token(): - # base64url("[]") = W10; the access token is still sent, but no claims can be - # inferred from a non-object JWT payload. - assert CodexUsageFetcher(access_token="x.W10.y").load_credentials() == { - "token": "x.W10.y", - "account_id": None, - "plan": None, + monkeypatch.setattr(usage_module, "_resolve_claude_credentials", fake_resolver) + first = credential_status("claude") + second = credential_status("claude") + refreshed = credential_status("claude", refresh=True) + + assert first == second == refreshed == { + "available": True, + "source": "claude_auth_status", + "status": "available", + "detail": "ready", + "needs_authorization": False, } + assert len(calls) == 2 + assert "token" not in first + + +def test_codex_fetches_official_app_server_rate_limits(tmp_path): + cli = _make_cli(tmp_path, "codex", """ + import json, sys + for line in sys.stdin: + message = json.loads(line) + method = message.get("method") + if method == "initialize": + print(json.dumps({"id": message["id"], "result": {"serverInfo": {}}}), flush=True) + elif method == "account/read": + print(json.dumps({"id": message["id"], "result": { + "account": {"type": "chatgpt", "planType": "plus", "email": "private@example.test"}, + "requiresOpenaiAuth": True, + }}), flush=True) + elif method == "account/rateLimits/read": + base = { + "limitId": "codex", "limitName": None, "planType": "plus", + "primary": {"usedPercent": 25, "windowDurationMins": 300, "resetsAt": 1800000000}, + "secondary": {"usedPercent": 50, "windowDurationMins": 10080, "resetsAt": 1800100000}, + "rateLimitReachedType": None, + } + other = { + "limitId": "codex_other", "limitName": "Codex Other", + "primary": {"usedPercent": 42, "windowDurationMins": 60, "resetsAt": 1800200000}, + "secondary": None, "rateLimitReachedType": "rate_limit_reached", + } + print(json.dumps({"id": message["id"], "result": { + "rateLimits": base, + "rateLimitsByLimitId": {"codex": base, "codex_other": other}, + "untrustedSecret": "must-never-escape", + }}), flush=True) + """) + + snap = CodexUsageFetcher(binary=str(cli), model="codex_other").fetch() + + assert snap.error is None + assert snap.plan == "plus" + assert snap.available_models == ["codex", "codex_other"] + assert [(w.label, w.used_percent, w.model, w.limited) for w in snap.windows] == [ + ("账户 5h", 25.0, None, False), + ("账户 7d", 50.0, None, False), + ("Codex Other 1h", 42.0, "Codex Other", True), + ] + assert "private@example" not in json.dumps(snap.to_dict()) + assert "must-never-escape" not in json.dumps(snap.to_dict()) + + +def test_codex_api_key_auth_never_requests_subscription_limits(tmp_path): + marker = tmp_path / "rate-requested" + cli = _make_cli(tmp_path, "codex", f""" + import json, pathlib, sys + marker = pathlib.Path({str(marker)!r}) + for line in sys.stdin: + message = json.loads(line) + method = message.get("method") + if method == "initialize": + print(json.dumps({{"id": message["id"], "result": {{}}}}), flush=True) + elif method == "account/read": + print(json.dumps({{"id": message["id"], "result": {{ + "account": {{"type": "apiKey"}}, "requiresOpenaiAuth": True + }}}}), flush=True) + elif method == "account/rateLimits/read": + marker.write_text("unexpected") + """) + + snap = CodexUsageFetcher(binary=str(cli)).fetch() + + assert snap.windows == [] + assert "API Key" in snap.error + assert not marker.exists() + + +def test_codex_app_server_timeout_kills_process_group(tmp_path, monkeypatch): + import agentbar.usage as usage_module + + pid_file = tmp_path / "pid" + cli = _make_cli(tmp_path, "codex", f""" + import json, os, pathlib, sys, time + for line in sys.stdin: + message = json.loads(line) + if message.get("method") == "initialize": + print(json.dumps({{"id": message["id"], "result": {{}}}}), flush=True) + elif message.get("method") == "account/read": + pathlib.Path({str(pid_file)!r}).write_text(str(os.getpid())) + time.sleep(60) + """) + monkeypatch.setattr(usage_module, "_CODEX_APP_SERVER_TIMEOUT_SECONDS", 2.0) + + started = time.monotonic() + snap = CodexUsageFetcher(binary=str(cli)).fetch() + + assert time.monotonic() - started < 3 + assert "不可用" in snap.error + pid = int(pid_file.read_text()) + with pytest.raises(ProcessLookupError): + os.kill(pid, 0) + + +def test_codex_app_server_rejects_oversized_or_server_request_output( + tmp_path, monkeypatch +): + import agentbar.usage as usage_module + + cli = _make_cli(tmp_path, "codex", """ + import json, sys, time + for line in sys.stdin: + message = json.loads(line) + if message.get("method") == "initialize": + print(json.dumps({"id": message["id"], "result": {}}), flush=True) + elif message.get("method") == "account/read": + sys.stdout.write("x" * 1024) + sys.stdout.flush() + time.sleep(60) + """) + monkeypatch.setattr(usage_module, "_CODEX_APP_SERVER_MAX_LINE_BYTES", 128) + + snap = CodexUsageFetcher(binary=str(cli)).fetch() + + assert snap.windows == [] + assert "不可用" in snap.error + + +def test_codex_app_server_fails_closed_on_server_request(tmp_path): + cli = _make_cli(tmp_path, "codex", """ + import json, sys, time + for line in sys.stdin: + message = json.loads(line) + if message.get("method") == "initialize": + print(json.dumps({"id": message["id"], "result": {}}), flush=True) + elif message.get("method") == "account/read": + print(json.dumps({ + "id": 99, + "method": "account/chatgptAuthTokens/refresh", + "params": {"previousAccountId": "must-not-be-retained"}, + }), flush=True) + time.sleep(60) + """) + + started = time.monotonic() + snap = CodexUsageFetcher(binary=str(cli)).fetch() + + assert time.monotonic() - started < 2 + assert snap.windows == [] + assert "不可用" in snap.error + assert "must-not-be-retained" not in snap.error def test_codex_usage_parse_windows(): @@ -248,28 +426,17 @@ class S: assert get_usage_fetchers(None) == {} -def test_subscription_fetchers_require_enabled_and_manual_key(tmp_path, monkeypatch): +def test_subscription_fetchers_require_only_explicit_enablement(tmp_path): settings = Settings(state_dir=tmp_path) settings.quota_sources = { - "claude": {"enabled": True, "access_token": "", "model": "opus"}, - "codex": {"enabled": True, "access_token": "", "model": "codex_bengalfox"}, + "claude": {"enabled": True}, + "codex": {"enabled": True, "model": "codex_bengalfox"}, } - # Even discoverable legacy credentials must not opt a source in implicitly. - monkeypatch.setenv("CLAUDE_CODE_OAUTH_TOKEN", "legacy-claude") - monkeypatch.setenv("CODEX_HOME", str(tmp_path / "codex-home")) - - assert get_usage_fetchers(settings) == {} - - settings.quota_sources["claude"]["access_token"] = "manual-claude" - settings.quota_sources["codex"].update({ - "access_token": "manual-codex", - "account_id": "account-123", - }) fetchers = get_usage_fetchers(settings) assert set(fetchers) == {"claude", "codex"} - assert fetchers["claude"].access_token == "manual-claude" - assert fetchers["claude"].model == "opus" - assert fetchers["codex"].access_token == "manual-codex" - assert fetchers["codex"].account_id == "account-123" assert fetchers["codex"].model == "codex_bengalfox" + + settings.quota_sources["claude"]["enabled"] = False + settings.quota_sources["codex"]["enabled"] = False + assert get_usage_fetchers(settings) == {} diff --git a/uv.lock b/uv.lock index 610b92a..9d3c367 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.11" [[package]] name = "agentbar" -version = "0.10.4" +version = "0.10.5" source = { editable = "." } dependencies = [ { name = "mistune" },