cdx-manager 0.21.4 → 0.21.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_21_5.md +26 -0
- package/changelogs/CHANGELOGS_0_21_6.md +33 -0
- package/checksums/release-archives.json +22 -0
- package/docs/cli.md +3 -3
- package/docs/operations.md +2 -0
- package/install.ps1 +7 -4
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +16 -6
- package/src/backup_bundle.py +60 -16
- package/src/claude_credentials.py +85 -0
- package/src/claude_usage.py +1 -1
- package/src/cli.py +6 -8
- package/src/cli_args.py +43 -15
- package/src/cli_helpers.py +12 -5
- package/src/codex_usage.py +1 -1
- package/src/commands/auth.py +6 -0
- package/src/commands/runs.py +3 -3
- package/src/commands/tray.py +0 -20
- package/src/config.py +17 -1
- package/src/context_store.py +11 -6
- package/src/errors.py +5 -1
- package/src/handoff.py +24 -0
- package/src/health.py +20 -2
- package/src/notify.py +40 -30
- package/src/provider_background.py +1 -1
- package/src/provider_runtime.py +55 -22
- package/src/run_registry.py +19 -7
- package/src/run_usage.py +16 -5
- package/src/session_helpers.py +18 -0
- package/src/session_service.py +34 -92
- package/src/session_status.py +4 -0
- package/src/session_store.py +6 -27
- package/src/status_view.py +18 -2
- package/src/tray_events.py +23 -4
- package/src/tray_instance.py +4 -15
- package/src/tray_logics.py +1 -1
- package/src/update_all.py +1 -1
- package/src/update_check.py +1 -1
- package/src/update_manager.py +7 -3
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br clear="left"/>
|
|
6
6
|
|
|
7
|
-
[](LICENSE) ](LICENSE)  
|
|
8
8
|
|
|
9
9
|
**The right AI coding account, when you need it.** CDX Manager keeps your Codex and Claude accounts in separate profiles, shows which ones have quota, and helps you pick the next usable session from your terminal.
|
|
10
10
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.21.
|
|
1
|
+
0.21.6
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# CDX Manager 0.21.5
|
|
2
|
+
|
|
3
|
+
## Claude logins
|
|
4
|
+
|
|
5
|
+
- `cdx status` shows each Claude profile's auth type in a TYPE column: `setup-token` or `full-login` (a `/login` run inside the profile). It also appears in the single-session view and as `auth_type` in `--json`. Within 30 days of a setup-token's one-year expiry, the column shows `setup-token 12d`, or `setup-token expired` once it has lapsed, and `cdx doctor` warns with the `cdx login` command to run. Based on work by Nicolas Rigaudy (#15, #16, #21).
|
|
6
|
+
- The operations guide explains why a setup-token never loads claude.ai connectors such as Microsoft 365, and how to switch with `/login`.
|
|
7
|
+
- On macOS, `cdx login` on a Claude profile now also removes the profile's keychain `/login`, so the new setup-token is the one used at launch. Before, the keychain login kept winning. The misleading "falling back to claude setup-token" message is reworded (#18).
|
|
8
|
+
|
|
9
|
+
## Fixes
|
|
10
|
+
|
|
11
|
+
- `cdx ready` never showed its notification: the scheduled job ran `cdx notify`, which now only serves agent hooks. On macOS the job was also written under the profile home inside a cdx session, where launchd never reads it (#18, #21).
|
|
12
|
+
- Windows:
|
|
13
|
+
- the session store lock now waits up to 120 seconds instead of failing after 10;
|
|
14
|
+
- the process-alive check no longer sends Ctrl+C to the process it probes (#18).
|
|
15
|
+
- Agent alert hooks work when the cdx path contains a space. Claude's `settings.json` is written atomically, through symlinks, and keeps its permissions (#18).
|
|
16
|
+
- Token prices reviewed on 2026-10-22:
|
|
17
|
+
- `claude-sonnet-5-5` cache reads are $0.10/MTok;
|
|
18
|
+
- `claude-haiku-5-5` is added;
|
|
19
|
+
- context variants such as `claude-opus-5-5[1m]` are priced at the base model's rates (#20).
|
|
20
|
+
|
|
21
|
+
## Housekeeping
|
|
22
|
+
|
|
23
|
+
- `runs.json` keeps every running run plus the newest 500 finished ones.
|
|
24
|
+
- Handoff entries older than 7 days are deleted when a new one is prepared.
|
|
25
|
+
- Dead log-rotation code and duplicated helpers are removed.
|
|
26
|
+
- New tests cover the state-changing `cdx tray` commands (#17, #19).
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# CDX Manager 0.21.6
|
|
2
|
+
|
|
3
|
+
## Security
|
|
4
|
+
|
|
5
|
+
- **Windows toasts.** PowerShell also ends a single-quoted string on the typographic quotes ‘ ’ ‚ ‛, so those are now escaped too, in cdx and in the tray. A crafted folder name or response preview could run code. French text such as "l’agent" no longer drops the toast (#23).
|
|
6
|
+
- **Tray over WSL.** The tray no longer passes its commands through the WSL shell, which expanded `$(...)` and split arguments containing spaces. It also finds the user's own `cdx` instead of a stale system-wide one (#23).
|
|
7
|
+
- **`cdx set <name> --scrub-env on`** launches Claude with `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1`. The Bash commands, hooks and MCP servers the agent starts then no longer receive credentials, including the setup-token. It is off by default; `docs/operations.md` explains the trade-off (#26).
|
|
8
|
+
- **Backup bundles that include auth** use a stronger key derivation (scrypt N=2^17, or PBKDF2 at 600k iterations) and record its cost. Older bundles still import, and a crafted cost is refused (#25).
|
|
9
|
+
- **Claude profile creation** no longer replaces an unparseable or symlinked `settings.json`, and keeps its permissions (#23).
|
|
10
|
+
- **PyPI** releases are published with Trusted Publishing, without a stored API token (#27).
|
|
11
|
+
|
|
12
|
+
## Fixes
|
|
13
|
+
|
|
14
|
+
- **Windows:**
|
|
15
|
+
- `cdx.cmd` now returns the real exit code; it always returned 0;
|
|
16
|
+
- `cdx ready` schedules its task on any Windows language, where `schtasks` refused the date on non-US systems;
|
|
17
|
+
- provider output is read as UTF-8;
|
|
18
|
+
- `cdx -v` and `--help` work without a home directory;
|
|
19
|
+
- the check after `cdx update` no longer prints "Could not determine home directory" (#22, #23).
|
|
20
|
+
- **`cdx run`** stops the provider when it receives SIGTERM or SIGHUP (a `kill`, a `systemctl stop` or a closed SSH session), not only on Ctrl+C (#24).
|
|
21
|
+
- **No more lost writes when two commands overlap:**
|
|
22
|
+
- tray events published while another is being acknowledged are kept;
|
|
23
|
+
- `cdx context set` and `clear` no longer race with an append (#24).
|
|
24
|
+
- **`cdx set`:**
|
|
25
|
+
- the usage line, help and docs list every flag;
|
|
26
|
+
- a bad on/off value names the flag, for example "Unsupported notify value: maybe (on|off)" (#25).
|
|
27
|
+
- **`cdx schema --json`** publishes every run error code and warning code. `cdx run` sets its error codes at the source instead of reading the message text (#25).
|
|
28
|
+
|
|
29
|
+
## Housekeeping
|
|
30
|
+
|
|
31
|
+
- CI also tests Python 3.9, the minimum version declared in pyproject.
|
|
32
|
+
- The tray builds with `cargo --locked`.
|
|
33
|
+
- The test suite no longer touches the developer's real macOS keychain (#24, #25).
|
|
@@ -518,6 +518,28 @@
|
|
|
518
518
|
"x86_64-pc-windows-msvc": "9729f245b46420ae4b11c1a50d3ba9cba682854866ef37a3f334dccbdd70ac89",
|
|
519
519
|
"x86_64-unknown-linux-musl": "182fb651dab29a6a21b3b8ef56c196e954fd75230891f8532c21b09ff5fbacee"
|
|
520
520
|
}
|
|
521
|
+
},
|
|
522
|
+
"v0.21.4": {
|
|
523
|
+
"github_tarball_sha256": "265936ceb35ffd43679e916f7ac2572b73e3a499803b86dfa8e87029a4188394",
|
|
524
|
+
"github_zip_sha256": "f9a648c4cba0d8c3204cac151f695d6fc4f86316d6dbdb6a1e8daf471dcd042b",
|
|
525
|
+
"tray_assets": {
|
|
526
|
+
"aarch64-apple-darwin": "b17aa8631acdcfa3e0de94ef8282b5b35791fd74d1e0be321bad42b20e7450ea",
|
|
527
|
+
"aarch64-unknown-linux-musl": "45eefeeb0d6c71269a24bece05780db98eaaa0e33630366f31cdb006f1dea946",
|
|
528
|
+
"x86_64-apple-darwin": "c10294583e88373570c7c5ad9094b711d57499d73358e9a1fa397bf5b76a12e3",
|
|
529
|
+
"x86_64-pc-windows-msvc": "0d4e6c736a789d8fddaf688cd61d899a305171fa6d7e856735b2cd7d2daef21c",
|
|
530
|
+
"x86_64-unknown-linux-musl": "8320025507801fd549b8c8dd4196a9378ddb25f137e48945e61e429bf2a864aa"
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
"v0.21.5": {
|
|
534
|
+
"github_tarball_sha256": "5a45b3ba4df72a4bba5bbd8c8c28728f7e804c67a4a7201d927f350d0276f432",
|
|
535
|
+
"github_zip_sha256": "b53d281da3540b85745a9b1e8abbdec797a0e97c1e1ec098d8c98753ffc334c2",
|
|
536
|
+
"tray_assets": {
|
|
537
|
+
"aarch64-apple-darwin": "e97c99054653a209b84190c2f08d3b1739527dc8e1d4795dd4b51271096fcdc7",
|
|
538
|
+
"aarch64-unknown-linux-musl": "04cfad61af12263eaa82f3b1fec12c8fad87762dc7a95b42e170f868156829e1",
|
|
539
|
+
"x86_64-apple-darwin": "b7a3beb55f34a9fae182ec164152d3295bf078a354c2788296c63c7c268c686e",
|
|
540
|
+
"x86_64-pc-windows-msvc": "d107e75e7700c2fcc7138d6bc14cdf6929fb3ad3e3d7a5b8648f44828f4ab4bb",
|
|
541
|
+
"x86_64-unknown-linux-musl": "00d93b4f16b58c203fbdc8cf80b15f1402ebf3fef8568162505142a3adb20272"
|
|
542
|
+
}
|
|
521
543
|
}
|
|
522
544
|
}
|
|
523
545
|
}
|
package/docs/cli.md
CHANGED
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
| `cdx config <name> [--json]` | Show persistent launch settings for a session |
|
|
24
24
|
| `cdx configs [--json]` | Show persistent launch settings for all sessions in one table |
|
|
25
25
|
| `cdx power\|perm\|fast\|model <name\|all\|provider:PROVIDER\|a,b> <value\|default> [--json]` | Shortcut commands for setting or clearing one launch setting |
|
|
26
|
-
| `cdx set <name>\|--sessions all\|a,b\|--provider PROVIDER [--power minimal\|low\|medium\|high\|xhigh] [--permission review\|default\|auto\|full] [--fast on\|off] [--rtk on\|off] [--logics on\|off] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--json]` | Persist launch settings for one or more sessions |
|
|
27
|
-
| `cdx unset <name>\|--sessions all\|a,b\|--provider PROVIDER (--power\|--permission\|--fast\|--rtk\|--logics\|--model\|--fallback-model\|--budget\|--extra-args\|--priority\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
|
|
26
|
+
| `cdx set <name>\|--sessions all\|a,b\|--provider PROVIDER [--power minimal\|low\|medium\|high\|xhigh] [--permission review\|default\|auto\|full] [--fast on\|off] [--rtk on\|off] [--logics on\|off] [--notify on\|off] [--notify-preview on\|off] [--scrub-env on\|off] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--json]` | Persist launch settings for one or more sessions |
|
|
27
|
+
| `cdx unset <name>\|--sessions all\|a,b\|--provider PROVIDER (--power\|--reasoning-effort\|--permission\|--fast\|--rtk\|--logics\|--notify\|--notify-preview\|--scrub-env\|--model\|--fallback-model\|--budget\|--extra-args\|--priority\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
|
|
28
28
|
| `cdx history [name] [--limit N] [--summary] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Show recent launch history or aggregate total launch time per assistant, optionally filtered by period |
|
|
29
29
|
| `cdx last [--json]` | Launch the most recent existing session from launch history |
|
|
30
30
|
| `cdx resume <name> [--json]` | Resume the provider-native conversation for a session using the named command form |
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
| `cdx runs [--limit N] [--since 7d\|today\|DATE] --json` | List recent runs; `--since` returns every run completed after the cursor and ignores `--limit` |
|
|
57
57
|
| `cdx schema --json` | Publish the enums, mutually-exclusive argument groups, and error codes programmatic callers should validate against |
|
|
58
58
|
| `cdx stats [name] [--since 7d\|today\|DATE] [--from DATE] [--to DATE] [--json]` | Aggregate launch counts, duration, and known token usage by session, for headless and interactive runs alike |
|
|
59
|
-
| `cdx status [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show token usage table for all sessions; empty credits, block, and reset columns are omitted; `--cached` skips live provider probes and returns only stored status |
|
|
59
|
+
| `cdx status [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show token usage table for all sessions; empty credits, block, and reset columns are omitted; `--cached` skips live provider probes and returns only stored status; Claude rows also report the auth type (`setup-token` or `full-login`) in a TYPE column and as `auth_type` in JSON. |
|
|
60
60
|
| `cdx status --small [--refresh\|--cached] [--timeout SECONDS]` / `cdx status -s [--refresh\|--cached] [--timeout SECONDS]` | Show compact token usage table without provider, blocking quota, credits, and updated columns |
|
|
61
61
|
| `cdx status <name> [--json] [--refresh\|--cached] [--timeout SECONDS]` | Show detailed usage breakdown for one session; `--cached` avoids live provider refreshes |
|
|
62
62
|
| `cdx tray status [--json] [--refresh]` | Emit the tray snapshot: one icon state, one line per enabled session, and how fresh each figure is. Reads stored status by default; `--refresh` is the only path that probes a provider |
|
package/docs/operations.md
CHANGED
|
@@ -151,6 +151,8 @@ Hosts keep independent rotating credentials, so copying an auth file is not ongo
|
|
|
151
151
|
credential sync.
|
|
152
152
|
|
|
153
153
|
- **`cdx <name>` fails with "not authenticated"** — run `cdx login <name>` first. `cdx doctor` uses the same Codex app-server probe as `cdx auth refresh`, so a local auth file alone is never reported as live authentication; a rejected refresh token is reported as `login_required` with the matching login command.
|
|
154
|
+
- **A Claude session has no claude.ai connectors (Microsoft 365, ...) in `/mcp`** — `cdx login <name>` stores a setup-token for Claude profiles, and Claude Code can only make model requests with it: it never loads claude.ai connectors. `cdx status` shows each Claude profile's auth type (`setup-token` or `full-login`; `auth_type` in `--json`). To switch, start the session and run `/login` inside it. Once the profile holds its own claude.ai login (its `.claude/.credentials.json`, or its profile-specific `Claude Code-credentials-<hash>` keychain entry on macOS), cdx stops passing the setup-token on later launches. If that login later expires, run `/login` again, or go back to a setup-token with `cdx login <name>`, which removes the profile's `/login`.
|
|
155
|
+
- **Keep credentials away from what a Claude agent runs** — by default Claude Code passes its credentials, including the setup-token cdx injects, to the Bash commands, hooks and stdio MCP servers it starts. `cdx set <name> --scrub-env on` launches that session with `CLAUDE_CODE_SUBPROCESS_ENV_SCRUB=1`, which strips credential-looking variables from those subprocesses (GitHub tokens and proxy settings stay). It is off by default: it also removes other secrets an agent may need (`NPM_TOKEN`, a `DATABASE_URL` with a password), and on Linux it isolates Bash from host processes, so `ps` and `kill` no longer see them. Worth turning on for unattended sessions that read untrusted content.
|
|
154
156
|
- **One of two Codex accounts keeps asking for login** — run `cdx doctor --json` and inspect each Codex session's `codex_auth_file`, `codex_live_auth`, and `codex_stale_auth_logs` checks. If recent logs mention expired auth, repair only that isolated profile with `cdx login <name>`. Codex Business profiles can share the same `tokens.account_id`; `cdx doctor` reports `codex_shared_account_id` as a workspace-level clue, not proof that two profiles use the same user. Newly created sessions seed from the current global `~/.codex/auth.json` when one exists. For two separate Codex accounts, create or repair each session by running `cdx login <name>` for that session; `cdx login` does not log out first, so use `cdx logout <name>` explicitly only when you want to clear that isolated profile.
|
|
155
157
|
- **`cdx` says no compatible Python 3 interpreter was found** — install Python 3 and make `py -3`, `python`, or `python3` available on PATH.
|
|
156
158
|
- **`cdx add` succeeds but the session does not appear** — check that `CDX_HOME` is consistent between calls; a mismatch creates two separate registries.
|
package/install.ps1
CHANGED
|
@@ -90,15 +90,18 @@ try {
|
|
|
90
90
|
Copy-Item -Path (Join-Path $sourceDir.FullName "*") -Destination $targetDir -Recurse -Force
|
|
91
91
|
|
|
92
92
|
$launcherPath = Join-Path $binDir "cdx.cmd"
|
|
93
|
+
# No parenthesized block: cmd expands %ERRORLEVEL% when it parses a block,
|
|
94
|
+
# before py runs, so `exit /b %ERRORLEVEL%` inside one always returned 0.
|
|
93
95
|
$launcher = @"
|
|
94
96
|
@echo off
|
|
95
97
|
set SCRIPT=%~dp0..\versions\$versionDir\bin\cdx
|
|
96
98
|
where py >nul 2>nul
|
|
97
|
-
if
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
if errorlevel 1 goto nopy
|
|
100
|
+
py -3 "%SCRIPT%" %*
|
|
101
|
+
exit /b %ERRORLEVEL%
|
|
102
|
+
:nopy
|
|
101
103
|
python "%SCRIPT%" %*
|
|
104
|
+
exit /b %ERRORLEVEL%
|
|
102
105
|
"@
|
|
103
106
|
Set-Content -Path $launcherPath -Value $launcher -Encoding ascii
|
|
104
107
|
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/agent_notify.py
CHANGED
|
@@ -15,6 +15,7 @@ import shutil
|
|
|
15
15
|
import subprocess
|
|
16
16
|
|
|
17
17
|
from .config import get_cdx_home
|
|
18
|
+
from .fs_utils import atomic_write
|
|
18
19
|
from .notify import notification_channel, send_desktop_notification
|
|
19
20
|
from .tray_alerts import alerts_enabled
|
|
20
21
|
from .tray_events import publish
|
|
@@ -71,7 +72,11 @@ def resolve_hook_command(env=None):
|
|
|
71
72
|
the provider inherits.
|
|
72
73
|
"""
|
|
73
74
|
env = env or os.environ
|
|
74
|
-
|
|
75
|
+
path = env.get("CDX_BIN") or shutil.which("cdx", path=env.get("PATH")) or "cdx"
|
|
76
|
+
# Hooks run through a shell, so a space in the path ("C:\\Users\\Jean Dupont",
|
|
77
|
+
# a macOS home with a space) would split it. Double quotes read the same in
|
|
78
|
+
# sh, Git Bash and cmd.exe; a path without whitespace stays as it was.
|
|
79
|
+
return f'"{path}"' if any(ch.isspace() for ch in path) else path
|
|
75
80
|
|
|
76
81
|
|
|
77
82
|
def read_hook_payload(args, stdin_text=None):
|
|
@@ -481,7 +486,7 @@ def _subprocess_spawn_sync(command, args, options):
|
|
|
481
486
|
[command, *args],
|
|
482
487
|
env=options.get("env"),
|
|
483
488
|
capture_output=True,
|
|
484
|
-
text=True,
|
|
489
|
+
text=True, encoding="utf-8", errors="replace",
|
|
485
490
|
timeout=60,
|
|
486
491
|
)
|
|
487
492
|
|
|
@@ -547,11 +552,16 @@ def _read_json(path):
|
|
|
547
552
|
|
|
548
553
|
|
|
549
554
|
def _write_json(path, document):
|
|
555
|
+
# Atomic, because Claude Code may rewrite the same settings file at the same
|
|
556
|
+
# moment. Through realpath so a symlinked settings.json stays a symlink, and
|
|
557
|
+
# with the file's own mode so a user-tightened file is not loosened.
|
|
550
558
|
try:
|
|
551
|
-
os.
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
559
|
+
target = os.path.realpath(path)
|
|
560
|
+
try:
|
|
561
|
+
mode = os.stat(target).st_mode & 0o777
|
|
562
|
+
except FileNotFoundError:
|
|
563
|
+
mode = 0o644
|
|
564
|
+
atomic_write(target, json.dumps(document, indent=2) + "\n", mode=mode)
|
|
555
565
|
except OSError:
|
|
556
566
|
# An unwritable home is a skipped provisioning step, not a failed launch.
|
|
557
567
|
return False
|
package/src/backup_bundle.py
CHANGED
|
@@ -11,10 +11,16 @@ BUNDLE_SCHEMA_VERSION = 1
|
|
|
11
11
|
_SALT_BYTES = 16
|
|
12
12
|
_NONCE_BYTES = 16
|
|
13
13
|
_AEAD_NONCE_BYTES = 12
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
# New bundles record their KDF cost in `kdf_params`, at OWASP-level costs
|
|
15
|
+
# (scrypt N=2^17, PBKDF2-SHA256 600k). Bundles without the field were written
|
|
16
|
+
# at the earlier costs, which stay readable. Recorded costs are capped so a
|
|
17
|
+
# crafted bundle cannot make an import burn minutes of CPU or gigabytes of RAM.
|
|
18
|
+
_SCRYPT_PARAMS = {"n": 2 ** 17, "r": 8, "p": 1}
|
|
19
|
+
_PBKDF2_PARAMS = {"iterations": 600000}
|
|
20
|
+
_LEGACY_SCRYPT_PARAMS = {"n": 2 ** 14, "r": 8, "p": 1}
|
|
21
|
+
_LEGACY_PBKDF2_PARAMS = {"iterations": 200000}
|
|
22
|
+
_MAX_SCRYPT_N = 2 ** 20
|
|
23
|
+
_MAX_PBKDF2_ITERATIONS = 10_000_000
|
|
18
24
|
_AEAD_ALGORITHM = "aes-256-gcm"
|
|
19
25
|
_LEGACY_ALGORITHM = "sha256-xor-hmac"
|
|
20
26
|
_KDF_SCRYPT = "scrypt"
|
|
@@ -78,19 +84,49 @@ def _bundle_kdf_candidates(wrapper):
|
|
|
78
84
|
return [recorded]
|
|
79
85
|
|
|
80
86
|
|
|
81
|
-
def
|
|
87
|
+
def _default_params(kdf):
|
|
88
|
+
return dict(_SCRYPT_PARAMS if kdf == _KDF_SCRYPT else _PBKDF2_PARAMS)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _bundle_kdf_params(wrapper, kdf):
|
|
92
|
+
"""The recorded cost, or the pre-`kdf_params` cost for older bundles."""
|
|
93
|
+
recorded = wrapper.get("kdf_params")
|
|
94
|
+
if recorded is None:
|
|
95
|
+
return dict(_LEGACY_SCRYPT_PARAMS if kdf == _KDF_SCRYPT else _LEGACY_PBKDF2_PARAMS)
|
|
96
|
+
if not isinstance(recorded, dict) or ("n" in recorded) != (kdf == _KDF_SCRYPT):
|
|
97
|
+
return None # recorded for the other KDF: not this candidate
|
|
98
|
+
try:
|
|
99
|
+
if kdf == _KDF_SCRYPT:
|
|
100
|
+
params = {key: int(recorded[key]) for key in ("n", "r", "p")}
|
|
101
|
+
valid = (2 <= params["n"] <= _MAX_SCRYPT_N and params["n"] & (params["n"] - 1) == 0
|
|
102
|
+
and 1 <= params["r"] <= 32 and 1 <= params["p"] <= 16)
|
|
103
|
+
else:
|
|
104
|
+
params = {"iterations": int(recorded["iterations"])}
|
|
105
|
+
valid = 1 <= params["iterations"] <= _MAX_PBKDF2_ITERATIONS
|
|
106
|
+
except (TypeError, ValueError, KeyError):
|
|
107
|
+
valid = False
|
|
108
|
+
if not valid:
|
|
109
|
+
raise CdxError("Unsupported bundle key derivation parameters.")
|
|
110
|
+
return params
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _derive_key_material(passphrase, salt, kdf, params=None):
|
|
114
|
+
params = params or _default_params(kdf)
|
|
82
115
|
if kdf == _KDF_SCRYPT:
|
|
83
116
|
if not _available_kdf(_KDF_SCRYPT):
|
|
84
117
|
raise CdxError(
|
|
85
118
|
"This bundle records the scrypt key derivation function, which this Python runtime "
|
|
86
119
|
"does not provide. Decode it with a Python built against OpenSSL scrypt support."
|
|
87
120
|
)
|
|
121
|
+
n, r, p = params["n"], params["r"], params["p"]
|
|
88
122
|
return hashlib.scrypt(
|
|
89
123
|
passphrase,
|
|
90
124
|
salt=salt,
|
|
91
|
-
n=
|
|
92
|
-
r=
|
|
93
|
-
p=
|
|
125
|
+
n=n,
|
|
126
|
+
r=r,
|
|
127
|
+
p=p,
|
|
128
|
+
# scrypt needs 128*r*n bytes; OpenSSL's 32 MiB default refuses N=2^17.
|
|
129
|
+
maxmem=2 * 128 * r * n * p + 1024 * 1024,
|
|
94
130
|
dklen=64,
|
|
95
131
|
)
|
|
96
132
|
if kdf == _KDF_PBKDF2:
|
|
@@ -98,23 +134,23 @@ def _derive_key_material(passphrase, salt, kdf):
|
|
|
98
134
|
"sha256",
|
|
99
135
|
passphrase,
|
|
100
136
|
salt,
|
|
101
|
-
|
|
137
|
+
params["iterations"],
|
|
102
138
|
dklen=64,
|
|
103
139
|
)
|
|
104
140
|
raise CdxError("Unsupported bundle key derivation function.")
|
|
105
141
|
|
|
106
142
|
|
|
107
|
-
def _derive_keys(passphrase, salt, kdf=None):
|
|
143
|
+
def _derive_keys(passphrase, salt, kdf=None, params=None):
|
|
108
144
|
if not passphrase:
|
|
109
145
|
raise CdxError("A non-empty passphrase is required for bundles that include auth data.")
|
|
110
146
|
if isinstance(passphrase, str):
|
|
111
147
|
passphrase = passphrase.encode("utf-8")
|
|
112
|
-
key_material = _derive_key_material(passphrase, salt, kdf or _default_kdf())
|
|
148
|
+
key_material = _derive_key_material(passphrase, salt, kdf or _default_kdf(), params)
|
|
113
149
|
return key_material[:32], key_material[32:]
|
|
114
150
|
|
|
115
151
|
|
|
116
|
-
def _derive_aead_key(passphrase, salt, kdf=None):
|
|
117
|
-
enc_key, _mac_key = _derive_keys(passphrase, salt, kdf)
|
|
152
|
+
def _derive_aead_key(passphrase, salt, kdf=None, params=None):
|
|
153
|
+
enc_key, _mac_key = _derive_keys(passphrase, salt, kdf, params)
|
|
118
154
|
return enc_key
|
|
119
155
|
|
|
120
156
|
|
|
@@ -152,11 +188,13 @@ def encode_bundle(payload, include_auth=False, passphrase=None):
|
|
|
152
188
|
salt = os.urandom(_SALT_BYTES)
|
|
153
189
|
nonce = os.urandom(_AEAD_NONCE_BYTES)
|
|
154
190
|
kdf = _default_kdf()
|
|
155
|
-
|
|
191
|
+
params = _default_params(kdf)
|
|
192
|
+
aesgcm = _load_aesgcm()(_derive_aead_key(passphrase, salt, kdf, params))
|
|
156
193
|
ciphertext = aesgcm.encrypt(nonce, payload_bytes, None)
|
|
157
194
|
wrapper.update({
|
|
158
195
|
"encryption": _AEAD_ALGORITHM,
|
|
159
196
|
"kdf": kdf,
|
|
197
|
+
"kdf_params": params,
|
|
160
198
|
"salt": _b64_encode(salt),
|
|
161
199
|
"nonce": _b64_encode(nonce),
|
|
162
200
|
"payload": _b64_encode(ciphertext),
|
|
@@ -186,9 +224,12 @@ def decode_bundle(data, passphrase=None):
|
|
|
186
224
|
payload_bytes = None
|
|
187
225
|
failure = None
|
|
188
226
|
for kdf in kdf_candidates:
|
|
227
|
+
params = _bundle_kdf_params(wrapper, kdf)
|
|
228
|
+
if params is None:
|
|
229
|
+
continue
|
|
189
230
|
# Derivation errors are about the bundle, not the passphrase, so
|
|
190
231
|
# they must escape rather than be retried as a bad secret.
|
|
191
|
-
cipher = aead(_derive_aead_key(passphrase, salt, kdf))
|
|
232
|
+
cipher = aead(_derive_aead_key(passphrase, salt, kdf, params))
|
|
192
233
|
try:
|
|
193
234
|
payload_bytes = cipher.decrypt(nonce, ciphertext, None)
|
|
194
235
|
break
|
|
@@ -200,7 +241,10 @@ def decode_bundle(data, passphrase=None):
|
|
|
200
241
|
expected_mac = _b64_decode(wrapper.get("hmac_sha256", ""))
|
|
201
242
|
payload_bytes = None
|
|
202
243
|
for kdf in kdf_candidates:
|
|
203
|
-
|
|
244
|
+
params = _bundle_kdf_params(wrapper, kdf)
|
|
245
|
+
if params is None:
|
|
246
|
+
continue
|
|
247
|
+
enc_key, mac_key = _derive_keys(passphrase, salt, kdf, params)
|
|
204
248
|
actual_mac = hmac.new(mac_key, nonce + ciphertext, hashlib.sha256).digest()
|
|
205
249
|
if hmac.compare_digest(actual_mac, expected_mac):
|
|
206
250
|
payload_bytes = _xor_keystream(ciphertext, enc_key, nonce)
|
|
@@ -81,6 +81,91 @@ def peek_keychain_credentials(auth_home):
|
|
|
81
81
|
return None
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
INFERENCE_SCOPE = "user:inference"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def is_full_claude_login(credentials):
|
|
88
|
+
"""True for a claude.ai login, False for an inference-only credential.
|
|
89
|
+
|
|
90
|
+
A setup-token can only make model requests, so Claude Code never loads
|
|
91
|
+
claude.ai connectors with it. A real `/login` stores a refresh token and
|
|
92
|
+
scopes beyond inference. Only the shape is inspected, never the values.
|
|
93
|
+
"""
|
|
94
|
+
oauth = credentials.get("claudeAiOauth") if isinstance(credentials, dict) else None
|
|
95
|
+
if not isinstance(oauth, dict) or not oauth.get("accessToken"):
|
|
96
|
+
return False
|
|
97
|
+
scopes = oauth.get("scopes")
|
|
98
|
+
extra_scopes = isinstance(scopes, list) and any(
|
|
99
|
+
isinstance(scope, str) and scope != INFERENCE_SCOPE for scope in scopes
|
|
100
|
+
)
|
|
101
|
+
return bool(oauth.get("refreshToken")) or extra_scopes
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def read_claude_login_file(auth_home):
|
|
105
|
+
try:
|
|
106
|
+
with open(os.path.join(auth_home, ".claude", ".credentials.json"), encoding="utf-8") as handle:
|
|
107
|
+
data = json.load(handle)
|
|
108
|
+
except (OSError, ValueError):
|
|
109
|
+
return None
|
|
110
|
+
return data if isinstance(data, dict) else None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def has_full_claude_login(auth_home):
|
|
114
|
+
"""Whether the profile holds a claude.ai login: the profile's
|
|
115
|
+
`.credentials.json` (Linux, Windows) or its keychain entry (macOS)."""
|
|
116
|
+
if not auth_home:
|
|
117
|
+
return False
|
|
118
|
+
return is_full_claude_login(read_claude_login_file(auth_home)) or is_full_claude_login(
|
|
119
|
+
peek_keychain_credentials(auth_home)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def claude_auth_type(auth_home):
|
|
124
|
+
"""`full-login`, `setup-token`, or None when the profile holds neither."""
|
|
125
|
+
if has_full_claude_login(auth_home):
|
|
126
|
+
return "full-login"
|
|
127
|
+
try:
|
|
128
|
+
with open(os.path.join(auth_home, "credentials", "default.json"), encoding="utf-8") as handle:
|
|
129
|
+
token = json.load(handle).get("access_token")
|
|
130
|
+
except (OSError, ValueError, AttributeError):
|
|
131
|
+
return None
|
|
132
|
+
return "setup-token" if token else None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# `claude setup-token` mints a token valid for one year. Tokens minted before
|
|
136
|
+
# cdx recorded `created_at` fall back to the file's mtime, which a copy or a
|
|
137
|
+
# backup import resets, so their date can read later than it really is.
|
|
138
|
+
SETUP_TOKEN_LIFETIME_SECONDS = 365 * 24 * 3600
|
|
139
|
+
SETUP_TOKEN_WARN_DAYS = 30
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def setup_token_expires_at(auth_home):
|
|
143
|
+
"""Epoch seconds when the profile's setup-token expires, or None."""
|
|
144
|
+
path = os.path.join(auth_home or "", "credentials", "default.json")
|
|
145
|
+
try:
|
|
146
|
+
with open(path, encoding="utf-8") as handle:
|
|
147
|
+
data = json.load(handle)
|
|
148
|
+
if not isinstance(data, dict) or not data.get("access_token"):
|
|
149
|
+
return None
|
|
150
|
+
created = data.get("created_at")
|
|
151
|
+
if isinstance(created, str):
|
|
152
|
+
from datetime import datetime
|
|
153
|
+
created = datetime.fromisoformat(created.replace("Z", "+00:00")).timestamp()
|
|
154
|
+
elif not isinstance(created, (int, float)):
|
|
155
|
+
created = os.path.getmtime(path)
|
|
156
|
+
except (OSError, ValueError):
|
|
157
|
+
return None
|
|
158
|
+
return created + SETUP_TOKEN_LIFETIME_SECONDS
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def setup_token_days_left(auth_home, now=None):
|
|
162
|
+
expires = setup_token_expires_at(auth_home)
|
|
163
|
+
if expires is None:
|
|
164
|
+
return None
|
|
165
|
+
import time
|
|
166
|
+
return int((expires - (time.time() if now is None else now)) // 86400)
|
|
167
|
+
|
|
168
|
+
|
|
84
169
|
def write_keychain_credentials(auth_home, data):
|
|
85
170
|
_reject_custom_oauth()
|
|
86
171
|
user, service = _keychain_identity(auth_home)
|
package/src/claude_usage.py
CHANGED
|
@@ -214,7 +214,7 @@ def _refresh_claude_cli_credentials(auth_home, runner=None, env=None):
|
|
|
214
214
|
command,
|
|
215
215
|
env=run_env,
|
|
216
216
|
capture_output=True,
|
|
217
|
-
text=True,
|
|
217
|
+
text=True, encoding="utf-8", errors="replace",
|
|
218
218
|
timeout=CLAUDE_AUTH_STATUS_TIMEOUT_SECONDS,
|
|
219
219
|
check=False,
|
|
220
220
|
)
|
package/src/cli.py
CHANGED
|
@@ -6,6 +6,7 @@ import sys
|
|
|
6
6
|
from datetime import datetime, timezone
|
|
7
7
|
|
|
8
8
|
from .agent_notify import handle_notify
|
|
9
|
+
from .cli_args import SET_USAGE
|
|
9
10
|
from .cli_commands import (
|
|
10
11
|
API_SCHEMA_VERSION,
|
|
11
12
|
STATUS_USAGE,
|
|
@@ -72,10 +73,6 @@ from .cli_render import (
|
|
|
72
73
|
)
|
|
73
74
|
from .config import PROVIDER_OLLAMA
|
|
74
75
|
from .errors import CdxError
|
|
75
|
-
from .provider_runtime import (
|
|
76
|
-
LOG_ROTATE_BYTES,
|
|
77
|
-
_rotate_log_if_needed,
|
|
78
|
-
)
|
|
79
76
|
from .session_service import create_session_service
|
|
80
77
|
from .status_view import (
|
|
81
78
|
_format_blocking_quota,
|
|
@@ -138,13 +135,11 @@ VERSION = _resolve_version()
|
|
|
138
135
|
# them here documents that and stops F401 from treating them as dead imports.
|
|
139
136
|
__all__ = [
|
|
140
137
|
"STATUS_USAGE",
|
|
141
|
-
"LOG_ROTATE_BYTES",
|
|
142
138
|
"_format_blocking_quota",
|
|
143
139
|
"_format_reset_time",
|
|
144
140
|
"_format_status_detail",
|
|
145
141
|
"_format_status_rows",
|
|
146
142
|
"_pad_table",
|
|
147
|
-
"_rotate_log_if_needed",
|
|
148
143
|
"_visible_len",
|
|
149
144
|
"cli_entry",
|
|
150
145
|
"format_json_error",
|
|
@@ -253,7 +248,7 @@ def _print_help(use_color=False):
|
|
|
253
248
|
f" {_style('cdx config <name> [--json]', '36', use_color)}",
|
|
254
249
|
f" {_style('cdx configs [--json]', '36', use_color)}",
|
|
255
250
|
f" {_style('cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]', '36', use_color)}",
|
|
256
|
-
f" {_style('
|
|
251
|
+
f" {_style(SET_USAGE[len('Usage: '):], '36', use_color)}",
|
|
257
252
|
f" {_style('cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--rtk|--logics|--notify|--notify-preview|--model|--priority|--all) [--json]', '36', use_color)}",
|
|
258
253
|
f" {_style('cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
|
|
259
254
|
f" {_style('cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]', '36', use_color)}",
|
|
@@ -539,7 +534,6 @@ def main(argv, options=None):
|
|
|
539
534
|
stderr = options.get("stderr", sys.stderr)
|
|
540
535
|
stdin_is_tty = options.get("stdin", {}).get("isTTY", hasattr(sys.stdin, "isatty") and sys.stdin.isatty())
|
|
541
536
|
use_color = _should_use_color(env, stdout)
|
|
542
|
-
service = options.get("service") or create_session_service({"env": env})
|
|
543
537
|
spawn = options.get("spawn")
|
|
544
538
|
spawn_sync = options.get("spawn_sync")
|
|
545
539
|
refresh_fn = options.get("refreshClaudeSessionStatus")
|
|
@@ -565,6 +559,10 @@ def main(argv, options=None):
|
|
|
565
559
|
out(f"{_print_version()}\n")
|
|
566
560
|
return 0
|
|
567
561
|
|
|
562
|
+
# Built after --help/--version: those must answer even where the home
|
|
563
|
+
# directory cannot be resolved (a service or task with a minimal environment).
|
|
564
|
+
service = options.get("service") or create_session_service({"env": env})
|
|
565
|
+
|
|
568
566
|
if argv == ["--json"]:
|
|
569
567
|
rows = service["format_list_rows"]()
|
|
570
568
|
notices = _get_update_notices(service, env, options)
|