cdx-manager 0.9.14 → 0.9.16
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/bin/python-runner.js +4 -7
- package/changelogs/CHANGELOGS_0_9_15.md +39 -0
- package/changelogs/CHANGELOGS_0_9_16.md +19 -0
- package/checksums/release-archives.json +8 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/backup_bundle.py +2 -0
- package/src/claude_refresh.py +9 -2
- package/src/claude_usage.py +36 -5
- package/src/cli.py +5 -4
- package/src/cli_args.py +13 -3
- package/src/cli_commands.py +26 -21
- package/src/cli_render.py +1 -1
- package/src/codex_usage.py +3 -1
- package/src/context_store.py +3 -4
- package/src/fs_utils.py +32 -0
- package/src/provider_runtime.py +55 -14
- package/src/run_registry.py +47 -14
- package/src/run_usage.py +3 -1
- package/src/session_service.py +137 -100
- package/src/session_store.py +11 -0
- package/src/status_source.py +101 -25
- package/src/status_view.py +19 -6
- package/src/update_check.py +4 -0
- package/src/update_manager.py +3 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
4
4
|
|
|
5
5
|
**Run multiple Codex, Claude, Antigravity, and Ollama sessions from one terminal. Switch between accounts instantly.**
|
|
6
6
|
|
package/bin/python-runner.js
CHANGED
|
@@ -7,12 +7,6 @@ const { spawnSync } = require("node:child_process");
|
|
|
7
7
|
|
|
8
8
|
const PYTHON_VERSION_CHECK = "import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)";
|
|
9
9
|
const PYTHON_CACHE_PREFIX = path.join(os.tmpdir(), "cdx-manager-pycache");
|
|
10
|
-
const SIGNAL_EXIT_CODES = {
|
|
11
|
-
SIGHUP: 129,
|
|
12
|
-
SIGINT: 130,
|
|
13
|
-
SIGTERM: 143,
|
|
14
|
-
};
|
|
15
|
-
|
|
16
10
|
const WINDOWS_CANDIDATES = [
|
|
17
11
|
{ command: "py", args: ["-3"], label: "py -3" },
|
|
18
12
|
{ command: "python", args: [], label: "python" },
|
|
@@ -148,7 +142,10 @@ function runPython(args, options = {}) {
|
|
|
148
142
|
}
|
|
149
143
|
|
|
150
144
|
if (result.signal) {
|
|
151
|
-
|
|
145
|
+
// Conventional 128+signum so supervisors can tell SIGKILL (137) from
|
|
146
|
+
// SIGSEGV (139) instead of a flat 128.
|
|
147
|
+
const signum = os.constants.signals[result.signal];
|
|
148
|
+
return signum ? 128 + signum : 128;
|
|
152
149
|
}
|
|
153
150
|
|
|
154
151
|
return typeof result.status === "number" ? result.status : 1;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# CDX Manager 0.9.15
|
|
2
|
+
|
|
3
|
+
## Highlights
|
|
4
|
+
|
|
5
|
+
- Hardened concurrent session, run, and file updates against lost or partial writes.
|
|
6
|
+
- Improved status accuracy when multiple Codex and Claude data sources disagree.
|
|
7
|
+
- Tightened credential handling, headless process cleanup, CLI parsing, and release gating.
|
|
8
|
+
|
|
9
|
+
## Changes
|
|
10
|
+
|
|
11
|
+
### Reliable state and status
|
|
12
|
+
|
|
13
|
+
Session and run read-modify-write operations now stay under a single lock, and context, settings, and bundle exports use atomic replacement. Merge imports preserve live local runtime state, malformed JSONL usage records no longer discard an entire run, and refresh tolerates late or failed per-session writes.
|
|
14
|
+
|
|
15
|
+
Status selection now ranks structured, attributed, and fresh records consistently, preserves compatible fields across equally trusted candidates, handles year-wrapped reset timestamps, and treats a zero credit balance as no remaining credit.
|
|
16
|
+
|
|
17
|
+
### Safer auth and process handling
|
|
18
|
+
|
|
19
|
+
Credential files and directories are private from creation, and temporary Claude setup-token transcripts are removed on both success and failure.
|
|
20
|
+
|
|
21
|
+
Headless timeouts terminate the complete process group, while signal exits consistently return `128 + signum`.
|
|
22
|
+
|
|
23
|
+
### CLI, updates, and release automation
|
|
24
|
+
|
|
25
|
+
Argument parsing now rejects conflicting target selectors and flags used as values, while subcommand help remains scoped to the subcommand. Headless selection no longer imposes an implicit reasoning-effort floor.
|
|
26
|
+
|
|
27
|
+
Failed Logics Manager version checks are no longer cached, missing updater return codes fail safely, non-epoch Claude reset headers no longer abort refresh, and publication now gates on the latest CI run for the release commit.
|
|
28
|
+
|
|
29
|
+
## Validation
|
|
30
|
+
|
|
31
|
+
- `npm run release:validate`
|
|
32
|
+
- `npm run lint`
|
|
33
|
+
- `npm test`
|
|
34
|
+
- `logics-manager lint --require-status`
|
|
35
|
+
- `logics-manager audit`
|
|
36
|
+
- `git diff --check`
|
|
37
|
+
- `npm --cache /private/tmp/cdx-npm-cache pack --dry-run`
|
|
38
|
+
- `python -m build`
|
|
39
|
+
- `python -m twine check dist/*`
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# CDX Manager 0.9.16
|
|
2
|
+
|
|
3
|
+
## Highlights
|
|
4
|
+
|
|
5
|
+
- Clear error message when a provider CLI binary is broken instead of a raw Python traceback.
|
|
6
|
+
|
|
7
|
+
## Changes
|
|
8
|
+
|
|
9
|
+
### Friendlier auth probe failures
|
|
10
|
+
|
|
11
|
+
Launching a session whose provider CLI exists but cannot be executed (corrupted install, wrong architecture, or a script missing its shebang — e.g. `Exec format error` on `~/.local/bin/claude`) previously crashed with an unhandled `OSError` traceback. The auth probe now catches all `OSError` failures and reports an actionable message telling the user to reinstall the provider CLI, exiting with code 126.
|
|
12
|
+
|
|
13
|
+
## Validation
|
|
14
|
+
|
|
15
|
+
- `npm run release:validate`
|
|
16
|
+
- `npm run lint`
|
|
17
|
+
- `npm test`
|
|
18
|
+
- `git diff --check`
|
|
19
|
+
- `npm --cache /private/tmp/cdx-npm-cache pack --dry-run`
|
|
@@ -120,6 +120,14 @@
|
|
|
120
120
|
"v0.9.13": {
|
|
121
121
|
"github_tarball_sha256": "4541dcecbfc12705f8e50e61ab1cb1f53dd6a209cc4a6d33ec3a4e533a3da3eb",
|
|
122
122
|
"github_zip_sha256": "a8df11be1910a0562e0f97f69e9f35ee2fdf90ef50cb40689c85bf5ee5937027"
|
|
123
|
+
},
|
|
124
|
+
"v0.9.14": {
|
|
125
|
+
"github_tarball_sha256": "5469be5448eee56b5f0834d0c42d9ed3ca088158f08052bd8addc552635fe33d",
|
|
126
|
+
"github_zip_sha256": "7eb5650cb348e3ef2a082cd7fb39d7f4c170d4a095fe88719a7ebbfb7601db48"
|
|
127
|
+
},
|
|
128
|
+
"v0.9.15": {
|
|
129
|
+
"github_tarball_sha256": "08e6c5a3459fdd65d1f6085ca03a3614f07b4bf44c57a995a3ef47446aa70482",
|
|
130
|
+
"github_zip_sha256": "c8ac01f4a65e85b417f5eba5139868ab17b8f0952bf99f17f0a79f73ef0f36d6"
|
|
123
131
|
}
|
|
124
132
|
}
|
|
125
133
|
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/backup_bundle.py
CHANGED
|
@@ -40,6 +40,8 @@ def read_bundle_meta(data):
|
|
|
40
40
|
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
41
41
|
raise CdxError("Invalid bundle format.") from error
|
|
42
42
|
|
|
43
|
+
if not isinstance(wrapper, dict):
|
|
44
|
+
raise CdxError("Invalid bundle format.")
|
|
43
45
|
if wrapper.get("schema_version") != BUNDLE_SCHEMA_VERSION:
|
|
44
46
|
raise CdxError("Unsupported bundle schema version.")
|
|
45
47
|
return wrapper
|
package/src/claude_refresh.py
CHANGED
|
@@ -83,9 +83,16 @@ def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=
|
|
|
83
83
|
for t in threads:
|
|
84
84
|
t.join(timeout=10)
|
|
85
85
|
|
|
86
|
+
# A worker that outlived its join can still insert into results/errors;
|
|
87
|
+
# snapshot both so late writes can't mutate what we iterate.
|
|
88
|
+
results = dict(results)
|
|
89
|
+
errors = list(errors)
|
|
90
|
+
|
|
91
|
+
recorded = []
|
|
86
92
|
for name, usage in results.items():
|
|
87
93
|
try:
|
|
88
94
|
service["record_status"](name, usage)
|
|
89
|
-
|
|
95
|
+
recorded.append(name)
|
|
96
|
+
except (CdxError, OSError):
|
|
90
97
|
errors.append({"session": name, "error": CdxError(f"Failed to record Claude status for {name}")})
|
|
91
|
-
return {"refreshed": sorted(
|
|
98
|
+
return {"refreshed": sorted(recorded), "errors": errors}
|
package/src/claude_usage.py
CHANGED
|
@@ -103,10 +103,41 @@ def _refresh_claude_cli_credentials(auth_home, runner=None, env=None):
|
|
|
103
103
|
|
|
104
104
|
|
|
105
105
|
def _format_reset_date(unix_seconds):
|
|
106
|
-
|
|
106
|
+
try:
|
|
107
|
+
dt = datetime.fromtimestamp(unix_seconds, tz=timezone.utc).astimezone()
|
|
108
|
+
except (TypeError, ValueError, OSError):
|
|
109
|
+
return None
|
|
107
110
|
return f"{MONTH_ABBR[dt.month - 1]} {dt.day} {str(dt.hour).zfill(2)}:{str(dt.minute).zfill(2)}"
|
|
108
111
|
|
|
109
112
|
|
|
113
|
+
def _parse_reset_epoch(value):
|
|
114
|
+
# The reset headers are unix seconds today, but an RFC3339 value must
|
|
115
|
+
# not abort the whole refresh.
|
|
116
|
+
if value in (None, ""):
|
|
117
|
+
return None
|
|
118
|
+
text = str(value).strip()
|
|
119
|
+
try:
|
|
120
|
+
return float(text)
|
|
121
|
+
except ValueError:
|
|
122
|
+
pass
|
|
123
|
+
try:
|
|
124
|
+
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
125
|
+
except ValueError:
|
|
126
|
+
return None
|
|
127
|
+
if parsed.tzinfo is None:
|
|
128
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
129
|
+
return parsed.timestamp()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _safe_float(value):
|
|
133
|
+
if value is None:
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
return float(value)
|
|
137
|
+
except (TypeError, ValueError):
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
110
141
|
def _remaining_from_utilization(value):
|
|
111
142
|
if value is None:
|
|
112
143
|
return None
|
|
@@ -184,11 +215,11 @@ def fetch_claude_rate_limit_headers(access_token):
|
|
|
184
215
|
if util_5h is None and util_7d is None:
|
|
185
216
|
return None
|
|
186
217
|
|
|
187
|
-
utilization_5h =
|
|
188
|
-
utilization_7d =
|
|
218
|
+
utilization_5h = _safe_float(util_5h)
|
|
219
|
+
utilization_7d = _safe_float(util_7d)
|
|
189
220
|
|
|
190
|
-
reset_5h_at = _format_reset_date(
|
|
191
|
-
reset_week_at = _format_reset_date(
|
|
221
|
+
reset_5h_at = _format_reset_date(_parse_reset_epoch(reset_5h)) if reset_5h else None
|
|
222
|
+
reset_week_at = _format_reset_date(_parse_reset_epoch(reset_7d)) if reset_7d else None
|
|
192
223
|
reset_at = reset_week_at or reset_5h_at
|
|
193
224
|
|
|
194
225
|
return {
|
package/src/cli.py
CHANGED
|
@@ -66,7 +66,7 @@ from .status_view import (
|
|
|
66
66
|
)
|
|
67
67
|
from .update_check import check_for_update, check_logics_manager_for_update
|
|
68
68
|
|
|
69
|
-
VERSION = "0.9.
|
|
69
|
+
VERSION = "0.9.16"
|
|
70
70
|
|
|
71
71
|
# Public surface: this module is a facade. Names below are imported above
|
|
72
72
|
# purely to be re-exported (consumed by tests and external callers); listing
|
|
@@ -324,14 +324,15 @@ def main(argv, options=None):
|
|
|
324
324
|
def err(text):
|
|
325
325
|
stderr.write(text)
|
|
326
326
|
|
|
327
|
-
# Flags
|
|
328
|
-
|
|
327
|
+
# Flags — only when leading, so 'cdx history -h' reaches the subcommand
|
|
328
|
+
# instead of being hijacked as a malformed top-level help call.
|
|
329
|
+
if argv[:1] in (["--help"], ["-h"]):
|
|
329
330
|
if len(argv) != 1:
|
|
330
331
|
raise CdxError("Usage: cdx --help")
|
|
331
332
|
out(f"{_print_help(use_color=use_color)}\n")
|
|
332
333
|
return 0
|
|
333
334
|
|
|
334
|
-
if "--version"
|
|
335
|
+
if argv[:1] in (["--version"], ["-v"]):
|
|
335
336
|
if len(argv) != 1:
|
|
336
337
|
raise CdxError("Usage: cdx --version")
|
|
337
338
|
out(f"{_print_version()}\n")
|
package/src/cli_args.py
CHANGED
|
@@ -55,7 +55,7 @@ def _parse_flag_args(args, schema, usage, positionals_key=None, max_positionals=
|
|
|
55
55
|
parsed[spec["key"]] = True
|
|
56
56
|
index += 1
|
|
57
57
|
continue
|
|
58
|
-
value, index = _read_option_value(args, index, usage)
|
|
58
|
+
value, index = _read_option_value(args, index, usage, schema=schema)
|
|
59
59
|
parsed[spec["key"]] = spec.get("transform", lambda item: item)(value)
|
|
60
60
|
continue
|
|
61
61
|
if arg.startswith("--") and "=" in arg:
|
|
@@ -162,6 +162,8 @@ def _parse_set_args(args):
|
|
|
162
162
|
raise CdxError(SET_USAGE)
|
|
163
163
|
if parsed["names"] and parsed["provider"]:
|
|
164
164
|
raise CdxError(SET_USAGE)
|
|
165
|
+
if parsed["sessions"] and parsed["provider"]:
|
|
166
|
+
raise CdxError(SET_USAGE)
|
|
165
167
|
if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
|
|
166
168
|
raise CdxError(SET_USAGE)
|
|
167
169
|
settings = {
|
|
@@ -200,6 +202,8 @@ def _parse_unset_args(args):
|
|
|
200
202
|
raise CdxError(UNSET_USAGE)
|
|
201
203
|
if parsed["names"] and parsed["provider"]:
|
|
202
204
|
raise CdxError(UNSET_USAGE)
|
|
205
|
+
if parsed["sessions"] and parsed["provider"]:
|
|
206
|
+
raise CdxError(UNSET_USAGE)
|
|
203
207
|
if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
|
|
204
208
|
raise CdxError(UNSET_USAGE)
|
|
205
209
|
keys = ["power", "permission", "fast", "rtk", "logics", "model", "priority"] if parsed["all"] else [
|
|
@@ -501,10 +505,16 @@ def _parse_stats_args(args, now=None):
|
|
|
501
505
|
}
|
|
502
506
|
|
|
503
507
|
|
|
504
|
-
def _read_option_value(args, index, usage):
|
|
508
|
+
def _read_option_value(args, index, usage, schema=None):
|
|
505
509
|
if index + 1 >= len(args):
|
|
506
510
|
raise CdxError(usage)
|
|
507
|
-
|
|
511
|
+
value = args[index + 1]
|
|
512
|
+
# A known flag is never a value: '--model --json' is a missing value,
|
|
513
|
+
# not model='--json'. Literal flag-like values remain expressible as
|
|
514
|
+
# '--model=--json'.
|
|
515
|
+
if schema and value in schema:
|
|
516
|
+
raise CdxError(usage)
|
|
517
|
+
return value, index + 2
|
|
508
518
|
|
|
509
519
|
|
|
510
520
|
def _parse_session_names(value):
|
package/src/cli_commands.py
CHANGED
|
@@ -79,6 +79,7 @@ from .context_store import (
|
|
|
79
79
|
write_context,
|
|
80
80
|
)
|
|
81
81
|
from .errors import CdxError
|
|
82
|
+
from .fs_utils import atomic_write
|
|
82
83
|
from .health import collect_health_report, format_health_report
|
|
83
84
|
from .notify import (
|
|
84
85
|
format_notify_event,
|
|
@@ -170,20 +171,19 @@ def _extract_claude_oauth_token(text):
|
|
|
170
171
|
|
|
171
172
|
def _write_claude_oauth_token(auth_home, token):
|
|
172
173
|
cred_dir = os.path.join(auth_home, "credentials")
|
|
173
|
-
os.makedirs(cred_dir, exist_ok=True)
|
|
174
|
+
os.makedirs(cred_dir, mode=0o700, exist_ok=True)
|
|
175
|
+
try:
|
|
176
|
+
os.chmod(cred_dir, 0o700)
|
|
177
|
+
except OSError:
|
|
178
|
+
pass
|
|
174
179
|
cred_path = os.path.join(cred_dir, "default.json")
|
|
175
180
|
payload = {
|
|
176
181
|
"version": "1.0",
|
|
177
182
|
"type": "oauth_token",
|
|
178
183
|
"access_token": token,
|
|
179
184
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
handle.write("\n")
|
|
183
|
-
try:
|
|
184
|
-
os.chmod(cred_path, 0o600)
|
|
185
|
-
except OSError:
|
|
186
|
-
pass
|
|
185
|
+
# atomic_write keeps the token 0o600 from creation — no umask window.
|
|
186
|
+
atomic_write(cred_path, json.dumps(payload, indent=2) + "\n", mode=0o600)
|
|
187
187
|
return cred_path
|
|
188
188
|
|
|
189
189
|
|
|
@@ -204,18 +204,22 @@ def _bootstrap_claude_setup_token(session, ctx):
|
|
|
204
204
|
"Claude setup-token completed, but cdx could not capture the token. "
|
|
205
205
|
"Run claude setup-token and save the token under credentials/default.json."
|
|
206
206
|
)
|
|
207
|
-
|
|
208
|
-
|
|
207
|
+
try:
|
|
208
|
+
with open(transcript_path, encoding="utf-8", errors="replace") as handle:
|
|
209
|
+
transcript = handle.read()
|
|
210
|
+
finally:
|
|
211
|
+
# The transcript holds the ~1-year OAuth token in cleartext; it must
|
|
212
|
+
# not outlive the extraction, whether it succeeds or not.
|
|
213
|
+
try:
|
|
214
|
+
os.remove(transcript_path)
|
|
215
|
+
except OSError:
|
|
216
|
+
pass
|
|
209
217
|
token = _extract_claude_oauth_token(transcript)
|
|
210
218
|
if not token:
|
|
211
219
|
raise CdxError(
|
|
212
220
|
"Claude setup-token completed, but cdx could not find CLAUDE_CODE_OAUTH_TOKEN in the output. "
|
|
213
|
-
|
|
221
|
+
"Run claude setup-token manually and save the token under credentials/default.json."
|
|
214
222
|
)
|
|
215
|
-
try:
|
|
216
|
-
os.remove(transcript_path)
|
|
217
|
-
except OSError:
|
|
218
|
-
pass
|
|
219
223
|
auth_home = session.get("authHome") or ""
|
|
220
224
|
cred_path = _write_claude_oauth_token(auth_home, token)
|
|
221
225
|
# ponytail: drop short-lived claude login creds so the ~1yr token wins at launch (provider_runtime _read_claude_launch_oauth_token)
|
|
@@ -819,11 +823,12 @@ def _select_headless_session(ctx, provider, min_reasoning_effort=None, require_r
|
|
|
819
823
|
session for session in ctx["service"]["list_sessions"]()
|
|
820
824
|
if session.get("provider") == provider and session.get("enabled", True) is not False
|
|
821
825
|
]
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
826
|
+
if min_reasoning_effort:
|
|
827
|
+
minimum = _reasoning_rank(min_reasoning_effort)
|
|
828
|
+
sessions = [
|
|
829
|
+
session for session in sessions
|
|
830
|
+
if _reasoning_rank(_session_reasoning_effort(session)) >= minimum
|
|
831
|
+
]
|
|
827
832
|
rows = ctx["service"]["get_status_rows"](force_refresh=force_refresh)
|
|
828
833
|
row_by_name = {row.get("session_name"): row for row in rows}
|
|
829
834
|
candidates = []
|
|
@@ -1971,7 +1976,7 @@ def handle_update(rest, ctx):
|
|
|
1971
1976
|
base_prefix=ctx["options"].get("basePrefix"),
|
|
1972
1977
|
)
|
|
1973
1978
|
results = run_update_plan(plan, runner=ctx["options"].get("runUpdate"), env=ctx.get("env"))
|
|
1974
|
-
failed = any((result.get("returncode")
|
|
1979
|
+
failed = any((result.get("returncode") != 0) for result in results)
|
|
1975
1980
|
if failed:
|
|
1976
1981
|
raise CdxError(format_update_failure(results))
|
|
1977
1982
|
|
package/src/cli_render.py
CHANGED
|
@@ -10,7 +10,7 @@ def _format_relative_age(iso_value):
|
|
|
10
10
|
if not iso_value:
|
|
11
11
|
return "-"
|
|
12
12
|
try:
|
|
13
|
-
ts = datetime.fromisoformat(iso_value.replace("Z", "+00:00"))
|
|
13
|
+
ts = datetime.fromisoformat(str(iso_value).replace("Z", "+00:00"))
|
|
14
14
|
delta_s = (datetime.now(timezone.utc) - ts).total_seconds()
|
|
15
15
|
except (ValueError, TypeError):
|
|
16
16
|
return "-"
|
package/src/codex_usage.py
CHANGED
|
@@ -6,6 +6,8 @@ import subprocess
|
|
|
6
6
|
import threading
|
|
7
7
|
from datetime import datetime, timezone
|
|
8
8
|
|
|
9
|
+
from .status_source import _is_zero_credit_balance
|
|
10
|
+
|
|
9
11
|
try:
|
|
10
12
|
import fcntl
|
|
11
13
|
except ImportError: # pragma: no cover - non-POSIX platforms
|
|
@@ -92,7 +94,7 @@ def normalize_codex_rate_limit_snapshot(snapshot):
|
|
|
92
94
|
credit_balance = None
|
|
93
95
|
if isinstance(credits, dict):
|
|
94
96
|
credit_balance = credits.get("balance")
|
|
95
|
-
if not credits.get("hasCredits") and not credits.get("unlimited") and
|
|
97
|
+
if not credits.get("hasCredits") and not credits.get("unlimited") and _is_zero_credit_balance(credit_balance):
|
|
96
98
|
credit_balance = None
|
|
97
99
|
elif credits is not None:
|
|
98
100
|
credit_balance = credits
|
package/src/context_store.py
CHANGED
|
@@ -5,6 +5,7 @@ import subprocess
|
|
|
5
5
|
from datetime import datetime
|
|
6
6
|
|
|
7
7
|
from .errors import CdxError
|
|
8
|
+
from .fs_utils import atomic_write
|
|
8
9
|
from .session_store import _ensure_dir
|
|
9
10
|
|
|
10
11
|
DEFAULT_CONTEXT_TEMPLATE = """# Shared Context
|
|
@@ -57,8 +58,7 @@ def read_context(base_dir, cwd=None):
|
|
|
57
58
|
def write_context(base_dir, content, cwd=None):
|
|
58
59
|
path = get_context_path(base_dir, cwd)
|
|
59
60
|
_ensure_dir(os.path.dirname(path))
|
|
60
|
-
|
|
61
|
-
handle.write(content.rstrip() + "\n")
|
|
61
|
+
atomic_write(path, content.rstrip() + "\n", mode=0o644)
|
|
62
62
|
return {
|
|
63
63
|
"path": path,
|
|
64
64
|
"updated_at": _local_now_iso(),
|
|
@@ -117,8 +117,7 @@ def install_context_for_session(base_dir, session, cwd=None):
|
|
|
117
117
|
raise CdxError(f"Session auth home missing for {session['name']}")
|
|
118
118
|
target_path = os.path.join(auth_home, "shared-context.md")
|
|
119
119
|
_ensure_dir(os.path.dirname(target_path))
|
|
120
|
-
|
|
121
|
-
handle.write(content.rstrip() + "\n")
|
|
120
|
+
atomic_write(target_path, content.rstrip() + "\n", mode=0o644)
|
|
122
121
|
return {
|
|
123
122
|
"source_path": get_context_path(base_dir, cwd),
|
|
124
123
|
"target_path": target_path,
|
package/src/fs_utils.py
CHANGED
|
@@ -2,6 +2,38 @@ import inspect
|
|
|
2
2
|
import os
|
|
3
3
|
import shutil
|
|
4
4
|
import stat
|
|
5
|
+
import tempfile
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def atomic_write(path, data, mode=None):
|
|
9
|
+
"""Write str/bytes via temp file + rename so a crash can't leave a torn file.
|
|
10
|
+
|
|
11
|
+
mkstemp creates the temp file 0o600, so the content is never readable by
|
|
12
|
+
other users mid-write; pass mode to set the final permissions before the
|
|
13
|
+
rename makes the file visible.
|
|
14
|
+
"""
|
|
15
|
+
directory = os.path.dirname(os.path.abspath(path))
|
|
16
|
+
os.makedirs(directory, exist_ok=True)
|
|
17
|
+
binary = isinstance(data, (bytes, bytearray))
|
|
18
|
+
fd, temp_path = tempfile.mkstemp(prefix=f".{os.path.basename(path)}.", suffix=".tmp", dir=directory)
|
|
19
|
+
try:
|
|
20
|
+
if binary:
|
|
21
|
+
handle = os.fdopen(fd, "wb")
|
|
22
|
+
else:
|
|
23
|
+
handle = os.fdopen(fd, "w", encoding="utf-8")
|
|
24
|
+
with handle:
|
|
25
|
+
handle.write(data)
|
|
26
|
+
handle.flush()
|
|
27
|
+
os.fsync(handle.fileno())
|
|
28
|
+
if mode is not None and os.name != "nt":
|
|
29
|
+
os.chmod(temp_path, mode)
|
|
30
|
+
os.replace(temp_path, path)
|
|
31
|
+
except Exception:
|
|
32
|
+
try:
|
|
33
|
+
os.unlink(temp_path)
|
|
34
|
+
except FileNotFoundError:
|
|
35
|
+
pass
|
|
36
|
+
raise
|
|
5
37
|
|
|
6
38
|
|
|
7
39
|
def _make_user_writable(path):
|
package/src/provider_runtime.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import errno
|
|
1
2
|
import json
|
|
2
3
|
import os
|
|
3
4
|
import re
|
|
@@ -695,6 +696,45 @@ def _redact_sensitive_args(spec):
|
|
|
695
696
|
return [REDACTED_PROMPT_ARG if arg in sensitive else arg for arg in args]
|
|
696
697
|
|
|
697
698
|
|
|
699
|
+
def _signal_child_group(child, sig):
|
|
700
|
+
# Providers spawn helpers of their own; signal the whole group so
|
|
701
|
+
# grandchildren die with the child. Falls back to False when the child
|
|
702
|
+
# shares our group (test doubles spawned without start_new_session).
|
|
703
|
+
pid = getattr(child, "pid", None)
|
|
704
|
+
if not pid or not hasattr(os, "getpgid"):
|
|
705
|
+
return False
|
|
706
|
+
try:
|
|
707
|
+
pgid = os.getpgid(pid)
|
|
708
|
+
if pgid == os.getpgid(0):
|
|
709
|
+
return False
|
|
710
|
+
os.killpg(pgid, sig)
|
|
711
|
+
return True
|
|
712
|
+
except (OSError, TypeError):
|
|
713
|
+
return False
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _terminate_child_tree(child):
|
|
717
|
+
if not _signal_child_group(child, signal.SIGTERM):
|
|
718
|
+
try:
|
|
719
|
+
child.terminate()
|
|
720
|
+
except Exception:
|
|
721
|
+
pass
|
|
722
|
+
try:
|
|
723
|
+
child.wait(timeout=5)
|
|
724
|
+
return
|
|
725
|
+
except Exception:
|
|
726
|
+
pass
|
|
727
|
+
if not _signal_child_group(child, signal.SIGKILL):
|
|
728
|
+
try:
|
|
729
|
+
child.kill()
|
|
730
|
+
except Exception:
|
|
731
|
+
pass
|
|
732
|
+
try:
|
|
733
|
+
child.wait()
|
|
734
|
+
except Exception:
|
|
735
|
+
pass
|
|
736
|
+
|
|
737
|
+
|
|
698
738
|
def _run_headless_provider_command(session, cwd=None, env_override=None, initial_prompt=None,
|
|
699
739
|
timeout_seconds=None, spawn=None, run_id=None):
|
|
700
740
|
spawn = spawn or subprocess.Popen
|
|
@@ -714,12 +754,16 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
|
|
|
714
754
|
timed_out = False
|
|
715
755
|
with open(paths["stdout_path"], "w", encoding="utf-8", errors="replace") as stdout_file, \
|
|
716
756
|
open(paths["stderr_path"], "w", encoding="utf-8", errors="replace") as stderr_file:
|
|
757
|
+
options = {k: v for k, v in spec.get("options", {}).items() if k not in ("stdio", "stdout", "stderr")}
|
|
758
|
+
if spawn is subprocess.Popen:
|
|
759
|
+
# Own process group so a timeout can kill grandchildren too.
|
|
760
|
+
options["start_new_session"] = True
|
|
717
761
|
try:
|
|
718
762
|
child = spawn(
|
|
719
763
|
[command] + spec["args"],
|
|
720
764
|
stdout=stdout_file,
|
|
721
765
|
stderr=stderr_file,
|
|
722
|
-
**
|
|
766
|
+
**options,
|
|
723
767
|
)
|
|
724
768
|
except FileNotFoundError as error:
|
|
725
769
|
_combine_headless_transcript(paths)
|
|
@@ -740,18 +784,7 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
|
|
|
740
784
|
child.wait()
|
|
741
785
|
except subprocess.TimeoutExpired:
|
|
742
786
|
timed_out = True
|
|
743
|
-
|
|
744
|
-
child.terminate()
|
|
745
|
-
child.wait(timeout=5)
|
|
746
|
-
except Exception:
|
|
747
|
-
try:
|
|
748
|
-
child.kill()
|
|
749
|
-
except Exception:
|
|
750
|
-
pass
|
|
751
|
-
try:
|
|
752
|
-
child.wait()
|
|
753
|
-
except Exception:
|
|
754
|
-
pass
|
|
787
|
+
_terminate_child_tree(child)
|
|
755
788
|
|
|
756
789
|
_combine_headless_transcript(paths)
|
|
757
790
|
end_time = datetime.now(timezone.utc)
|
|
@@ -849,6 +882,14 @@ def _format_probe_failure(session, spec, error):
|
|
|
849
882
|
f"Install {command} and retry cdx add {session['name']}.",
|
|
850
883
|
127,
|
|
851
884
|
)
|
|
885
|
+
if isinstance(error, OSError) and error.errno == errno.ENOEXEC:
|
|
886
|
+
target = getattr(error, "filename", None) or command
|
|
887
|
+
return CdxError(
|
|
888
|
+
f"Failed to check login status for {session['name']}: {target} is not a valid executable "
|
|
889
|
+
f"(corrupted install, wrong architecture, or a script missing its shebang). "
|
|
890
|
+
f"Reinstall {command} and retry.",
|
|
891
|
+
126,
|
|
892
|
+
)
|
|
852
893
|
message = getattr(error, "message", None) or str(error)
|
|
853
894
|
return CdxError(f"Failed to check login status for {session['name']}: {message}")
|
|
854
895
|
|
|
@@ -883,7 +924,7 @@ def _probe_provider_auth(session, spawn_sync=None, env_override=None, trust_loca
|
|
|
883
924
|
stdout = result.get("stdout") if isinstance(result, dict) else getattr(result, "stdout", "")
|
|
884
925
|
stderr = result.get("stderr") if isinstance(result, dict) else getattr(result, "stderr", "")
|
|
885
926
|
output = (stdout or "") + (stderr or "")
|
|
886
|
-
except
|
|
927
|
+
except OSError as error:
|
|
887
928
|
raise _format_probe_failure(session, spec, error) from error
|
|
888
929
|
return spec["parser"](output)
|
|
889
930
|
|