cdx-manager 0.24.3 → 0.24.5
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_24_4.md +16 -0
- package/changelogs/CHANGELOGS_0_24_5.md +16 -0
- package/checksums/release-archives.json +22 -0
- package/docs/cli.md +14 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli_args.py +6 -1
- package/src/commands/runs.py +5 -4
- package/src/commands/status.py +24 -0
- package/src/history_models.py +201 -0
- package/src/provider_runtime.py +27 -0
- package/src/run_command.py +1 -1
- package/src/session_service.py +13 -0
- package/src/session_store.py +53 -0
- package/src/workspace_sandbox.py +94 -0
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.24.
|
|
1
|
+
0.24.5
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# CDX Manager 0.24.4
|
|
2
|
+
|
|
3
|
+
Release date: 2026-10-29
|
|
4
|
+
|
|
5
|
+
This patch adds explicit recovery of missing serving models in existing run history, using evidence tied to each run. (#59)
|
|
6
|
+
|
|
7
|
+
## History and statistics
|
|
8
|
+
|
|
9
|
+
- Add `cdx history backfill-models`, with a read-only preview by default and `--dry-run`, `--apply`, and `--json` options. Recover models from preserved run output, matching run reports, or exact Codex and Claude conversation transcripts. (#59)
|
|
10
|
+
- Keep missing or ambiguous evidence unknown rather than guessing from configured models or the latest conversation. Preserve existing models and token counts; report recovery counts, unknown reasons, and samples. (#59)
|
|
11
|
+
- With `--apply`, back up the original history and replace it atomically under the history lock. Repeated runs leave recovered rows alone. Subsequent statistics can price recovered models when the local price table recognizes them. (#59)
|
|
12
|
+
|
|
13
|
+
## Maintenance
|
|
14
|
+
|
|
15
|
+
- Record tray companion checksums for v0.24.3. (`bae6f34b733c91ef44070d25bb86015fa31f71ca`)
|
|
16
|
+
- Record release archive checksums for v0.24.3. (`8a1d4938a11f83cbe2eb8abc324a55b909f72837`)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# CDX Manager 0.24.5
|
|
2
|
+
|
|
3
|
+
Release date: 2026-10-29
|
|
4
|
+
|
|
5
|
+
This patch adds isolated workspace runs with opt-in network access. (#61)
|
|
6
|
+
|
|
7
|
+
## Workspace runs
|
|
8
|
+
|
|
9
|
+
- Add `cdx run --isolate-workspace` for Linux workspace confinement through bubblewrap, exposing the checkout, permitted toolchains, shared caches, and provider authentication while keeping the operator's home and host processes outside the boundary. (#61)
|
|
10
|
+
- Add `--network-access` to enable Codex's workspace-write network setting while retaining its workspace sandbox. (#61)
|
|
11
|
+
- Preserve both options across detached runs and failover, and advertise `workspace_isolation` and `workspace_network` capabilities in `cdx schema`. (#61)
|
|
12
|
+
|
|
13
|
+
## Maintenance
|
|
14
|
+
|
|
15
|
+
- Record tray companion checksums for v0.24.4. (`7465b2e8244e90032d1ab8b7ad7af6a26ffe805c`)
|
|
16
|
+
- Record release archive checksums for v0.24.4. (`a0afa21ccc816769fb0aac5883912efd90bf1028`)
|
|
@@ -606,6 +606,28 @@
|
|
|
606
606
|
"x86_64-pc-windows-msvc": "eecc8f8c46d2ce50e6cf6ec6c48c10a10e165ec7ec0058cfb55c8aa486d17d7b",
|
|
607
607
|
"x86_64-unknown-linux-musl": "4cf3289c5d9d7a540a032a11d27e93093d69ec507e1796a726f429c0670f7bc5"
|
|
608
608
|
}
|
|
609
|
+
},
|
|
610
|
+
"v0.24.3": {
|
|
611
|
+
"github_tarball_sha256": "4dfcc2c6a8f500fca04436ea9a22525827924a8193f79f3f285dfc3025762c42",
|
|
612
|
+
"github_zip_sha256": "66950a74e2bf8a9a349818a612a50217e10a70b92621bfa414d2d19df00aff61",
|
|
613
|
+
"tray_assets": {
|
|
614
|
+
"aarch64-apple-darwin": "3aaf5564a4ec896bd1e2a8400e653661ecdb216dfd87970ee3af69ddf7d088b0",
|
|
615
|
+
"aarch64-unknown-linux-musl": "2ff0d2272fb5d2d21631df986c050f8196a687fb6381453216d07748ed1b873f",
|
|
616
|
+
"x86_64-apple-darwin": "db6b646575195365879c7b25d406c0734bdbef7154f25e7f38342cafbf88d74a",
|
|
617
|
+
"x86_64-pc-windows-msvc": "6da838217c01251b809bac1e188217dcfbb00c2102b901cfda43cf2fc0696aa2",
|
|
618
|
+
"x86_64-unknown-linux-musl": "c6651dd909d486c2b0bb209e0d813ab044dc7b5ffb1a07078c8ec7a69126e028"
|
|
619
|
+
}
|
|
620
|
+
},
|
|
621
|
+
"v0.24.4": {
|
|
622
|
+
"github_tarball_sha256": "279d60a1073f56ac1ccc361248c14668e9e018d81dd56e4eb177cb3f4bac4cf6",
|
|
623
|
+
"github_zip_sha256": "05dd935dc0e8e686a1dce96c0543b91757995a5f49ae2aed1155ddf97916e55d",
|
|
624
|
+
"tray_assets": {
|
|
625
|
+
"aarch64-apple-darwin": "9fdcddf98e9723f2af2007f0c0a183644b2200e599c33d354c097e2510a3901b",
|
|
626
|
+
"aarch64-unknown-linux-musl": "eef61a80c0d1107de9dfb980fb5914d85ec3b0a5d640ff559bf8e7faa4e857bd",
|
|
627
|
+
"x86_64-apple-darwin": "3296ee96b17442d8a2d8eaee6ceeba90dc498c74168973a6932793fa509afc66",
|
|
628
|
+
"x86_64-pc-windows-msvc": "1fd621d4ba7175c868c4e598850c5f5ccd914c91aaef3d79024376082e8fe81d",
|
|
629
|
+
"x86_64-unknown-linux-musl": "f4c3a6e7c2af75cd8214609971fe2cbef6f34e818796d70b661d9a9f7e9327af"
|
|
630
|
+
}
|
|
609
631
|
}
|
|
610
632
|
}
|
|
611
633
|
}
|
package/docs/cli.md
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
| `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] [--agent-state on\|off] [--notify-preview on\|off] [--scrub-env on\|off] [--shared-memory on\|off] [--retro on\|off] [--policy use\|ask-first\|never] [--model MODEL] [--fallback-model MODEL[,MODEL...]] [--budget USD] [--extra-args 'ARGS'] [--priority 0..100] [--json]` | Persist launch settings for one or more sessions |
|
|
29
29
|
| `cdx unset <name>\|--sessions all\|a,b\|--provider PROVIDER (--power\|--reasoning-effort\|--permission\|--fast\|--rtk\|--logics\|--notify\|--agent-state\|--notify-preview\|--scrub-env\|--shared-memory\|--retro\|--policy\|--model\|--fallback-model\|--budget\|--extra-args\|--priority\|--all) [--json]` | Remove persisted launch settings and fall back to provider defaults |
|
|
30
30
|
| `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 |
|
|
31
|
+
| `cdx history backfill-models [--dry-run \| --apply] [--json]` | Recover missing historical usage models from evidence tied to each run; defaults to a read-only preview |
|
|
31
32
|
| `cdx last [--json]` | Launch the most recent existing session from launch history |
|
|
32
33
|
| `cdx resume <name> [--conversation <id>] [--json]` | Resume the provider-native conversation for a session using the named command form; `--conversation` reopens that exact conversation and refuses with `conversation_not_found` if the profile has no such transcript |
|
|
33
34
|
| `cdx can-resume <name> [--conversation <id>] [--json]` | Check whether a session supports native resume without launching the provider; `--conversation` checks that exact conversation and reports `resumable: false` with `conversation_not_found` instead of falling back to the most recent one |
|
|
@@ -269,7 +270,19 @@ The figure uses standard public list prices, excluding Fast/priority, Batch/Flex
|
|
|
269
270
|
|
|
270
271
|
`cdx stats` counts completed history attempts. A headless failover has one logical run in `cdx runs` but one history row per attempted session, including failed attempts that reported usage. Period filtering includes the entire run in every period it overlaps, so adjacent daily results can both include a run crossing midnight; adding daily totals may exceed a combined period. JSON `accounting` names this policy and the current price-table source. `usage_runs` and `priced_runs` show how much of the token total has a dollar estimate. Live runs are absent until their history row is written, and unknown usage is different from measured zero.
|
|
271
272
|
|
|
272
|
-
Completed run JSON and registry reports expose `usage_model` separately from the requested `model`. Pricing uses the observed model from provider output; Codex can also supply it through the exact rollout identified by `thread.started.thread_id`. Configured defaults alone never establish serving-model identity, and missing or mixed model evidence stays unpriced. `--by model` groups Claude's `[1m]` context variant with its base model while preserving the original model string and per-run pricing.
|
|
273
|
+
Completed run JSON and registry reports expose `usage_model` separately from the requested `model`. Pricing uses the observed model from provider output; Codex can also supply it through the exact rollout identified by `thread.started.thread_id`. Configured defaults alone never establish serving-model identity, and missing or mixed model evidence stays unpriced. `--by model` groups Claude's `[1m]` context variant with its base model while preserving the original model string and per-run pricing. New runs record this evidence automatically. To recover missing models in existing history, run:
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
cdx history backfill-models # Preview only (the default)
|
|
277
|
+
cdx history backfill-models --dry-run --json # Machine-readable preview
|
|
278
|
+
cdx history backfill-models --apply # Save proven models with a backup
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
The command considers only history rows with usage and an empty or placeholder `usage_model`. Evidence is checked in order: the row's preserved JSON/JSONL stdout, a preserved run-registry report with the same run id, profile, provider and stdout path, then the exact Codex thread rollout or Claude session transcript. Thread/session ids must come from the row or its stdout, or from a preserved transcript path recorded as an exact conversation-id match. The current profile is used only to locate an exact transcript under its auth home; its configured model and latest conversation/transcript never establish a historical model. The requested `model` in a report is also ignored.
|
|
282
|
+
|
|
283
|
+
Only one serving model is accepted. Claude `[1m]` variants count as the same model for this check and for stats grouping; the observed spelling is preserved. Mixed models, incomplete turn models, malformed or oversized evidence, conflicting identities and missing exact transcripts stay unknown. For resumed conversations, the conservative rule is that all recorded turns in the exact transcript must agree: this command does not infer historical turn boundaries. Ambiguous higher-priority evidence stops recovery rather than being overridden by a lower-priority source. Missing files may fall through to the next source.
|
|
284
|
+
|
|
285
|
+
Text and JSON output include counts by evidence kind, counts of unknown reasons and up to 20 samples. Dry-run leaves history and run reports unchanged. `--apply` changes only missing models and their evidence annotation, preserving token counts and unrelated history bytes. It holds the history lock, saves the original bytes to a unique `state/launch_history.jsonl.models-<id>.bak` file, then atomically replaces history. The backup path is printed; a repeated apply leaves already recovered rows alone. `--apply` and `--dry-run` cannot be combined. Subsequent `cdx stats` calls price recovered models when the local price table knows them; unknown models remain unpriced.
|
|
273
286
|
|
|
274
287
|
The built-in price table ships with **cdx-manager**. Run `cdx update` to receive new model prices; updating Codex or Claude alone does not refresh it. Then check `cdx --version` and `cdx stats --json`. Existing runs with a recorded model are recalculated using the new table when stats are read. `CDX_TOKEN_PRICES` overrides still take precedence. Maintainers: follow [the model and pricing update runbook](https://github.com/AlexAgo83/cdx-manager/blob/main/logics/runbook/run_005_maintaining_the_token_price_table.md) for source checks, tests, release preparation and installed-version verification.
|
|
275
288
|
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/cli_args.py
CHANGED
|
@@ -47,7 +47,7 @@ CAN_RESUME_USAGE = "Usage: cdx can-resume <name> [--conversation <id>] [--json]"
|
|
|
47
47
|
SESSIONS_USAGE = "Usage: cdx sessions [--all] --json"
|
|
48
48
|
SELECT_USAGE = "Usage: cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json"
|
|
49
49
|
NEXT_USAGE = "Usage: cdx next [--json] [--refresh]"
|
|
50
|
-
RUN_USAGE = "Usage: cdx run [session] (--cwd|--dir) PATH (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review|retro] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] [--label TEXT] [--max-parallel N] [--detach] [--failover] [--refresh] --json"
|
|
50
|
+
RUN_USAGE = "Usage: cdx run [session] (--cwd|--dir) PATH (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review|retro] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--network-access] [--isolate-workspace] [--timeout-seconds N] [--label TEXT] [--max-parallel N] [--detach] [--failover] [--refresh] --json"
|
|
51
51
|
RUN_JSON_REQUIRED = "cdx run: --json is required."
|
|
52
52
|
RUN_TARGET_REQUIRED = "cdx run: specify a session name or --provider PROVIDER."
|
|
53
53
|
RUN_SESSION_PROVIDER_CONFLICT = "cdx run: cannot specify both a session name and --provider."
|
|
@@ -475,6 +475,7 @@ def cdx_schema():
|
|
|
475
475
|
# No `schema_version` here: the JSON envelope already carries it, and two
|
|
476
476
|
# keys of that name in one payload would be one too many.
|
|
477
477
|
return {
|
|
478
|
+
"capabilities": {"workspace_isolation": 1, "workspace_network": 1},
|
|
478
479
|
"enums": {
|
|
479
480
|
"permission": {
|
|
480
481
|
"accepted": list(RUN_PERMISSION_VALUES),
|
|
@@ -665,6 +666,8 @@ def _parse_run_args(args):
|
|
|
665
666
|
"--permission": {"key": "permission", "type": "str", "default": None, "transform": _normalize_run_permission},
|
|
666
667
|
"--timeout-seconds": {"key": "timeout_seconds", "type": "str", "default": None, "transform": _parse_run_timeout_seconds},
|
|
667
668
|
"--detach": {"key": "detach", "type": "bool", "default": False},
|
|
669
|
+
"--network-access": {"key": "network_access", "type": "bool", "default": False},
|
|
670
|
+
"--isolate-workspace": {"key": "isolate_workspace", "type": "bool", "default": False},
|
|
668
671
|
"--failover": {"key": "failover", "type": "bool", "default": False},
|
|
669
672
|
"--refresh": {"key": "refresh", "type": "bool", "default": False},
|
|
670
673
|
"--label": {"key": "label", "type": "str", "default": None},
|
|
@@ -716,6 +719,8 @@ def _parse_run_args(args):
|
|
|
716
719
|
"permission": parsed["permission"],
|
|
717
720
|
"timeout_seconds": parsed["timeout_seconds"],
|
|
718
721
|
"detach": parsed["detach"],
|
|
722
|
+
"network_access": parsed["network_access"],
|
|
723
|
+
"isolate_workspace": parsed["isolate_workspace"],
|
|
719
724
|
"failover": parsed["failover"],
|
|
720
725
|
"label": parsed["label"],
|
|
721
726
|
"max_parallel": parsed["max_parallel"],
|
package/src/commands/runs.py
CHANGED
|
@@ -206,8 +206,9 @@ def _detached_child_argv(parsed, prompt_path, session_name, cwd):
|
|
|
206
206
|
argv += ["--timeout-seconds", str(parsed["timeout_seconds"])]
|
|
207
207
|
# The child is the supervisor: it watches the run like any blocking one, so
|
|
208
208
|
# it applies the failover rule and records each move in the registry.
|
|
209
|
-
|
|
210
|
-
|
|
209
|
+
for key in ("network_access", "isolate_workspace", "failover"):
|
|
210
|
+
if parsed.get(key):
|
|
211
|
+
argv.append("--" + key.replace("_", "-"))
|
|
211
212
|
argv.append("--json")
|
|
212
213
|
return argv
|
|
213
214
|
|
|
@@ -572,7 +573,7 @@ def handle_run(rest, ctx):
|
|
|
572
573
|
raise CdxError(f"Invalid cwd: {parsed['cwd']}", code="invalid_cwd")
|
|
573
574
|
prompt = read_run_prompt(parsed, stdin=ctx.get("prompt_stdin"))
|
|
574
575
|
_consume_detached_prompt_file(parsed, detached_run_id)
|
|
575
|
-
launch_updates = {}
|
|
576
|
+
launch_updates = {key: parsed.get(key, False) for key in ("network_access", "isolate_workspace")}
|
|
576
577
|
if parsed.get("model"):
|
|
577
578
|
launch_updates["model"] = parsed["model"]
|
|
578
579
|
if parsed.get("permission"):
|
|
@@ -643,7 +644,7 @@ def handle_run(rest, ctx):
|
|
|
643
644
|
launch = None
|
|
644
645
|
# A provider's own background agent has no cdx process watching it,
|
|
645
646
|
# so nothing could fail it over; --failover keeps the in-house child.
|
|
646
|
-
if not parsed.get("failover") and supports_native_background(
|
|
647
|
+
if not parsed.get("isolate_workspace") and not parsed.get("network_access") and not parsed.get("failover") and supports_native_background(
|
|
647
648
|
run_session.get("provider"),
|
|
648
649
|
env=ctx.get("env"),
|
|
649
650
|
spawn_sync=ctx.get("spawn_text"),
|
package/src/commands/status.py
CHANGED
|
@@ -602,6 +602,30 @@ def handle_configs(rest, ctx):
|
|
|
602
602
|
return 0
|
|
603
603
|
|
|
604
604
|
def handle_history(rest, ctx):
|
|
605
|
+
if rest and rest[0] == "backfill-models":
|
|
606
|
+
usage = "Usage: cdx history backfill-models [--dry-run | --apply] [--json]"
|
|
607
|
+
parsed = _parse_flag_args(rest[1:], {
|
|
608
|
+
"--dry-run": {"key": "dry_run", "type": "bool", "default": False},
|
|
609
|
+
"--apply": {"key": "apply", "type": "bool", "default": False},
|
|
610
|
+
"--json": {"key": "json", "type": "bool", "default": False},
|
|
611
|
+
}, usage)
|
|
612
|
+
if parsed["apply"] and parsed["dry_run"]:
|
|
613
|
+
raise CdxError(usage)
|
|
614
|
+
result = ctx["service"]["backfill_history_models"](dry_run=not parsed["apply"])
|
|
615
|
+
if parsed["json"]:
|
|
616
|
+
_write_json(ctx, _json_success("history", "Backfilled history models", **result))
|
|
617
|
+
else:
|
|
618
|
+
mode = "Dry run" if result["dry_run"] else "Applied"
|
|
619
|
+
ctx["out"](f"{mode}: {result['candidates']} missing models; {result['still_unknown']} still unknown; {result['backfilled']} written.\n")
|
|
620
|
+
for kind, count in result["recoverable"].items():
|
|
621
|
+
ctx["out"](f"Recoverable from {kind}: {count}\n")
|
|
622
|
+
for reason, count in result["unknown_reasons"].items():
|
|
623
|
+
ctx["out"](f"Unknown ({reason}): {count}\n")
|
|
624
|
+
for sample in result["samples"]:
|
|
625
|
+
ctx["out"](f" {sample['session_name']} {sample['run_id'] or sample['started_at']}: {sample['model'] or sample['reason']} ({sample['evidence'] or 'no evidence'})\n")
|
|
626
|
+
if result["backup_path"]:
|
|
627
|
+
ctx["out"](f"Backup: {result['backup_path']}\n")
|
|
628
|
+
return 0
|
|
605
629
|
now_fn = ctx["options"].get("now") or time.time
|
|
606
630
|
now = datetime.fromtimestamp(now_fn()).astimezone()
|
|
607
631
|
parsed = _parse_history_args(rest, now=now)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Recover serving models using only evidence attached to a history row."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from .interactive_usage import MAX_TRANSCRIPT_BYTES, MAX_TRANSCRIPT_SCAN_SECONDS
|
|
8
|
+
from .run_registry import registry_path
|
|
9
|
+
|
|
10
|
+
EVIDENCE_KINDS = ("stdout", "run_report", "codex_rollout", "claude_transcript")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def missing_model(value):
|
|
14
|
+
return not value or (isinstance(value, str) and value.startswith("<"))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _read_records(path):
|
|
18
|
+
if not path:
|
|
19
|
+
return [], None
|
|
20
|
+
if not isinstance(path, (str, os.PathLike)):
|
|
21
|
+
return [], "invalid_evidence_path"
|
|
22
|
+
try:
|
|
23
|
+
with open(path, "rb") as handle:
|
|
24
|
+
raw = handle.read(MAX_TRANSCRIPT_BYTES + 1)
|
|
25
|
+
if len(raw) > MAX_TRANSCRIPT_BYTES:
|
|
26
|
+
return [], "evidence_too_large"
|
|
27
|
+
text = raw.decode("utf-8")
|
|
28
|
+
try:
|
|
29
|
+
records = [json.loads(text)]
|
|
30
|
+
except ValueError:
|
|
31
|
+
records = [json.loads(line) for line in text.splitlines() if line.strip()]
|
|
32
|
+
if not all(isinstance(record, dict) for record in records):
|
|
33
|
+
return [], "invalid_evidence"
|
|
34
|
+
return records, None
|
|
35
|
+
except FileNotFoundError:
|
|
36
|
+
return [], None
|
|
37
|
+
except (OSError, UnicodeError, ValueError):
|
|
38
|
+
return [], "unreadable_evidence"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _single_model(models):
|
|
42
|
+
if any(not isinstance(model, str) or missing_model(model) for model in models):
|
|
43
|
+
return None, "incomplete_model_evidence"
|
|
44
|
+
# Context size variants already share a model group in stats.
|
|
45
|
+
groups = {model.removesuffix("[1m]") if model.startswith("claude-") else model for model in models}
|
|
46
|
+
if len(groups) > 1:
|
|
47
|
+
return None, "ambiguous_models"
|
|
48
|
+
return (models[0], None) if models else (None, None)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _stdout_models(records):
|
|
52
|
+
models = []
|
|
53
|
+
for record in records:
|
|
54
|
+
if record.get("launcher") == "cdx" or record.get("action") == "run":
|
|
55
|
+
if not missing_model(record.get("usage_model")):
|
|
56
|
+
models.append(record["usage_model"])
|
|
57
|
+
continue
|
|
58
|
+
message = record.get("message")
|
|
59
|
+
if isinstance(message, dict) and message.get("model") == "<synthetic>":
|
|
60
|
+
continue
|
|
61
|
+
if isinstance(message, dict) and (message.get("role") == "assistant" or record.get("type") == "assistant"):
|
|
62
|
+
models.append(message.get("model"))
|
|
63
|
+
elif "model" in record and ("usage" in record or record.get("type") == "result"):
|
|
64
|
+
models.append(record["model"])
|
|
65
|
+
model_usage = record.get("modelUsage")
|
|
66
|
+
if isinstance(model_usage, dict):
|
|
67
|
+
models.extend(model_usage)
|
|
68
|
+
return models
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _exact_id(entry, records, provider):
|
|
72
|
+
key = "thread_id" if provider == "codex" else "session_id"
|
|
73
|
+
identifiers = {record[key] for record in records if isinstance(record.get(key), str) and record[key]}
|
|
74
|
+
for identifier in (entry.get(key), entry.get("conversation_id")):
|
|
75
|
+
if identifier:
|
|
76
|
+
if not isinstance(identifier, str):
|
|
77
|
+
return None, "invalid_conversation_id"
|
|
78
|
+
identifiers.add(identifier)
|
|
79
|
+
if len(identifiers) > 1:
|
|
80
|
+
return None, "ambiguous_conversation_ids"
|
|
81
|
+
return next(iter(identifiers), None), None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _matches_transcript(provider, name, identifier):
|
|
85
|
+
if provider == "codex":
|
|
86
|
+
return name.startswith("rollout-") and name.endswith(f"-{identifier}.jsonl")
|
|
87
|
+
return name == f"{identifier}.jsonl"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _exact_transcript(provider, auth_home, identifier):
|
|
91
|
+
if not auth_home:
|
|
92
|
+
return None, None
|
|
93
|
+
root = os.path.join(auth_home, "sessions") if provider == "codex" else os.path.join(auth_home, ".claude", "projects")
|
|
94
|
+
paths = []
|
|
95
|
+
deadline = time.monotonic() + MAX_TRANSCRIPT_SCAN_SECONDS
|
|
96
|
+
for directory, subdirs, names in os.walk(root):
|
|
97
|
+
if time.monotonic() > deadline:
|
|
98
|
+
return None, "transcript_scan_limit"
|
|
99
|
+
subdirs[:] = [name for name in subdirs if name != "subagents"]
|
|
100
|
+
for name in names:
|
|
101
|
+
if _matches_transcript(provider, name, identifier):
|
|
102
|
+
paths.append(os.path.join(directory, name))
|
|
103
|
+
if len(paths) > 1:
|
|
104
|
+
return None, "multiple_exact_transcripts"
|
|
105
|
+
return next(iter(paths), None), None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def recover_model(entry, session=None, reports=()):
|
|
109
|
+
"""Return (model, evidence kind, reason); never consult live model settings."""
|
|
110
|
+
provider = entry.get("provider")
|
|
111
|
+
records, error = _read_records(entry.get("stdout_path"))
|
|
112
|
+
if error:
|
|
113
|
+
return None, None, error
|
|
114
|
+
for record in records:
|
|
115
|
+
if record.get("launcher") == "cdx" or record.get("action") == "run":
|
|
116
|
+
if (not entry.get("run_id") or record.get("run_id") != entry["run_id"]
|
|
117
|
+
or record.get("session") != entry.get("session_name") or record.get("provider") != provider):
|
|
118
|
+
return None, None, "report_identity_mismatch"
|
|
119
|
+
model, error = _single_model(_stdout_models(records))
|
|
120
|
+
if model or error:
|
|
121
|
+
return model, "stdout", error
|
|
122
|
+
|
|
123
|
+
# A logical run may fail over across profiles. Its final report does not
|
|
124
|
+
# prove the model used by an earlier attempt with the same run id.
|
|
125
|
+
report_models = []
|
|
126
|
+
for report in reports:
|
|
127
|
+
if not entry.get("run_id") or report.get("run_id") != entry["run_id"]:
|
|
128
|
+
continue
|
|
129
|
+
if report.get("session") != entry.get("session_name") or report.get("provider") != provider:
|
|
130
|
+
continue
|
|
131
|
+
artifacts = report.get("artifacts") or {}
|
|
132
|
+
if not isinstance(artifacts, dict):
|
|
133
|
+
continue
|
|
134
|
+
if not entry.get("stdout_path") or artifacts.get("stdout_path") != entry["stdout_path"]:
|
|
135
|
+
continue
|
|
136
|
+
model = report.get("usage_model")
|
|
137
|
+
if isinstance(model, str) and not missing_model(model):
|
|
138
|
+
report_models.append(model)
|
|
139
|
+
model, error = _single_model(report_models)
|
|
140
|
+
if model or error:
|
|
141
|
+
return model, "run_report", error
|
|
142
|
+
|
|
143
|
+
if provider not in ("codex", "claude"):
|
|
144
|
+
return None, None, "no_run_model_evidence"
|
|
145
|
+
identifier, error = _exact_id(entry, records, provider)
|
|
146
|
+
if error:
|
|
147
|
+
return None, None, error
|
|
148
|
+
path = entry.get("provider_transcript_path")
|
|
149
|
+
# A preserved path resolved by recency is not proof of conversation identity.
|
|
150
|
+
if path and not isinstance(path, (str, os.PathLike)):
|
|
151
|
+
return None, None, "invalid_evidence_path"
|
|
152
|
+
if not identifier and path and entry.get("provider_transcript_match") == "conversation_id":
|
|
153
|
+
identifier = os.path.basename(path).removesuffix(".jsonl")
|
|
154
|
+
if provider == "codex":
|
|
155
|
+
identifier = identifier[-36:]
|
|
156
|
+
if not identifier:
|
|
157
|
+
return None, None, "no_run_conversation_id"
|
|
158
|
+
if not path:
|
|
159
|
+
path, error = _exact_transcript(provider, (session or {}).get("authHome"), identifier)
|
|
160
|
+
if error:
|
|
161
|
+
return None, None, error
|
|
162
|
+
elif not _matches_transcript(provider, os.path.basename(path), identifier):
|
|
163
|
+
return None, None, "transcript_identity_mismatch"
|
|
164
|
+
transcript, error = _read_records(path)
|
|
165
|
+
if error:
|
|
166
|
+
return None, None, error
|
|
167
|
+
if not transcript:
|
|
168
|
+
return None, None, "exact_transcript_missing"
|
|
169
|
+
models = []
|
|
170
|
+
for record in transcript:
|
|
171
|
+
recorded_id = record.get("sessionId") if provider == "claude" else None
|
|
172
|
+
if provider == "codex" and record.get("type") == "session_meta":
|
|
173
|
+
payload = record.get("payload")
|
|
174
|
+
if not isinstance(payload, dict):
|
|
175
|
+
return None, None, "invalid_evidence"
|
|
176
|
+
recorded_id = payload.get("id")
|
|
177
|
+
if recorded_id and recorded_id != identifier:
|
|
178
|
+
return None, None, "transcript_identity_mismatch"
|
|
179
|
+
if provider == "codex" and record.get("type") == "turn_context":
|
|
180
|
+
payload = record.get("payload")
|
|
181
|
+
if not isinstance(payload, dict):
|
|
182
|
+
return None, None, "invalid_evidence"
|
|
183
|
+
models.append(payload.get("model"))
|
|
184
|
+
elif provider == "claude" and record.get("type") == "assistant":
|
|
185
|
+
message = record.get("message")
|
|
186
|
+
if not isinstance(message, dict):
|
|
187
|
+
return None, None, "invalid_evidence"
|
|
188
|
+
model = message.get("model")
|
|
189
|
+
if model != "<synthetic>":
|
|
190
|
+
models.append(model)
|
|
191
|
+
# Requiring the whole exact conversation to agree is conservative for
|
|
192
|
+
# resumed runs: mixed conversations stay unknown without turn boundaries.
|
|
193
|
+
model, error = _single_model(models)
|
|
194
|
+
return model, "codex_rollout" if provider == "codex" else "claude_transcript", error or (None if model else "no_turn_models")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def read_run_reports(base_dir):
|
|
198
|
+
"""Read the preserved registry without refreshing or changing run state."""
|
|
199
|
+
records, _error = _read_records(registry_path(base_dir))
|
|
200
|
+
runs = records[0].get("runs", []) if records else []
|
|
201
|
+
return [run for run in runs if isinstance(run, dict)] if isinstance(runs, list) else []
|
package/src/provider_runtime.py
CHANGED
|
@@ -1210,6 +1210,33 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
|
|
|
1210
1210
|
env_override=env_override,
|
|
1211
1211
|
initial_prompt=initial_prompt,
|
|
1212
1212
|
)
|
|
1213
|
+
launch = session.get("launch") or {}
|
|
1214
|
+
if launch.get("network_access"):
|
|
1215
|
+
if session.get("provider") == PROVIDER_CODEX:
|
|
1216
|
+
# Keep the workspace sandbox; append overrides before the prompt separator.
|
|
1217
|
+
index = spec["args"].index("--") if "--" in spec["args"] else len(spec["args"])
|
|
1218
|
+
spec["args"][index:index] = ["-c", 'sandbox_workspace_write.network_access=true', '-c', 'sandbox_workspace_write.writable_roots=["/cache", "/home/agent"]']
|
|
1219
|
+
elif session.get("provider") != PROVIDER_CLAUDE:
|
|
1220
|
+
raise CdxError("Network override is unsupported for this provider")
|
|
1221
|
+
if launch.get("isolate_workspace"):
|
|
1222
|
+
from pathlib import Path
|
|
1223
|
+
|
|
1224
|
+
from .workspace_sandbox import sandbox_command
|
|
1225
|
+
if session.get("provider") == PROVIDER_CLAUDE:
|
|
1226
|
+
# The outer OS sandbox is the permission boundary for unattended Bash.
|
|
1227
|
+
index = spec["args"].index("--") if "--" in spec["args"] else len(spec["args"])
|
|
1228
|
+
spec["args"][index:index] = ["--permission-mode", "bypassPermissions"]
|
|
1229
|
+
try:
|
|
1230
|
+
argv, child_env = sandbox_command(
|
|
1231
|
+
[spec["command"], *spec["args"]], Path(cwd),
|
|
1232
|
+
network=launch.get("network_access", False),
|
|
1233
|
+
writable=launch.get("permission") != "review",
|
|
1234
|
+
source=spec["options"]["env"], auth_home=_get_auth_home(session),
|
|
1235
|
+
provider=session.get("provider"))
|
|
1236
|
+
except (OSError, RuntimeError) as error:
|
|
1237
|
+
raise CdxError(str(error)) from error
|
|
1238
|
+
spec.update(command=argv[0], args=argv[1:])
|
|
1239
|
+
spec["options"]["env"] = child_env
|
|
1213
1240
|
paths = _headless_artifact_paths(session, run_id=run_id)
|
|
1214
1241
|
start_time = datetime.now(timezone.utc)
|
|
1215
1242
|
command = spec["command"]
|
package/src/run_command.py
CHANGED
|
@@ -133,7 +133,7 @@ def run_warnings(parsed, session, selection_row=None):
|
|
|
133
133
|
})
|
|
134
134
|
provider = session.get("provider") if session else parsed.get("provider")
|
|
135
135
|
permission = run_effective_permission(parsed, session)
|
|
136
|
-
if headless_permission_disables_network(provider, permission):
|
|
136
|
+
if not parsed.get("network_access") and headless_permission_disables_network(provider, permission):
|
|
137
137
|
described = repr(permission) if permission else "its default (none set)"
|
|
138
138
|
warnings.append({
|
|
139
139
|
"code": "network_disabled_by_permission",
|
package/src/session_service.py
CHANGED
|
@@ -765,6 +765,18 @@ def backfill_interactive_usage(store, name=None, dry_run=True):
|
|
|
765
765
|
return store["backfill_launch_usage"](updates, dry_run=dry_run)
|
|
766
766
|
|
|
767
767
|
|
|
768
|
+
def backfill_history_models(store, base_dir, dry_run=True):
|
|
769
|
+
"""Recover historical models from evidence belonging to each history row."""
|
|
770
|
+
from .history_models import read_run_reports, recover_model
|
|
771
|
+
|
|
772
|
+
sessions = {session["name"]: session for session in store["list_sessions"]()}
|
|
773
|
+
reports = read_run_reports(base_dir)
|
|
774
|
+
return store["backfill_launch_models"](
|
|
775
|
+
lambda entry: recover_model(entry, sessions.get(entry.get("session_name")), reports),
|
|
776
|
+
dry_run=dry_run,
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
|
|
768
780
|
def refresh_interactive_usage_models(store, dry_run=True):
|
|
769
781
|
"""Replace a placeholder model with the exact transcript's real model."""
|
|
770
782
|
from .interactive_usage import MATCH_CONVERSATION_ID, extract_interactive_usage
|
|
@@ -997,6 +1009,7 @@ def create_session_service(options=None):
|
|
|
997
1009
|
"drop_unvouched_usage": store["drop_unvouched_usage"],
|
|
998
1010
|
"backfill_interactive_usage": partial(backfill_interactive_usage, store),
|
|
999
1011
|
"refresh_interactive_usage_models": partial(refresh_interactive_usage_models, store),
|
|
1012
|
+
"backfill_history_models": partial(backfill_history_models, store, base_dir),
|
|
1000
1013
|
"update_auth_state": partial(update_auth_state, store),
|
|
1001
1014
|
"get_status_row": partial(get_status_row, store, base_dir, env, fetch_codex_status),
|
|
1002
1015
|
"get_status_rows": partial(get_status_rows, store, base_dir, env, fetch_codex_status),
|
package/src/session_store.py
CHANGED
|
@@ -343,6 +343,58 @@ def create_session_store(base_dir):
|
|
|
343
343
|
_fsync_directory(state_dir)
|
|
344
344
|
return {"scanned": scanned, "backfilled": backfilled, "dry_run": dry_run}
|
|
345
345
|
|
|
346
|
+
def backfill_launch_models(resolve, dry_run=True):
|
|
347
|
+
"""Correct only missing models, under the history lock, with a backup."""
|
|
348
|
+
import uuid
|
|
349
|
+
|
|
350
|
+
from .history_models import EVIDENCE_KINDS, missing_model
|
|
351
|
+
|
|
352
|
+
result = {"dry_run": dry_run, "scanned": 0, "candidates": 0,
|
|
353
|
+
"recoverable": dict.fromkeys(EVIDENCE_KINDS, 0), "still_unknown": 0,
|
|
354
|
+
"backfilled": 0, "backup_path": None, "unknown_reasons": {}, "samples": []}
|
|
355
|
+
with _file_lock(lock_file):
|
|
356
|
+
try:
|
|
357
|
+
with open(launch_history_file, "rb") as handle:
|
|
358
|
+
original = handle.read()
|
|
359
|
+
except FileNotFoundError:
|
|
360
|
+
return result
|
|
361
|
+
rewritten = []
|
|
362
|
+
for line in original.splitlines(keepends=True):
|
|
363
|
+
try:
|
|
364
|
+
entry = json.loads(line)
|
|
365
|
+
except (ValueError, UnicodeError):
|
|
366
|
+
rewritten.append(line)
|
|
367
|
+
continue
|
|
368
|
+
if not isinstance(entry, dict):
|
|
369
|
+
rewritten.append(line)
|
|
370
|
+
continue
|
|
371
|
+
result["scanned"] += 1
|
|
372
|
+
if entry.get("usage") and missing_model(entry.get("usage_model")):
|
|
373
|
+
result["candidates"] += 1
|
|
374
|
+
model, kind, reason = resolve(entry)
|
|
375
|
+
if model:
|
|
376
|
+
result["recoverable"][kind] += 1
|
|
377
|
+
entry["usage_model"] = model
|
|
378
|
+
entry["usage_model_backfilled_reason"] = kind
|
|
379
|
+
line = (json.dumps(entry, separators=(",", ":")) + "\n").encode("utf-8")
|
|
380
|
+
else:
|
|
381
|
+
result["still_unknown"] += 1
|
|
382
|
+
reasons = result["unknown_reasons"]
|
|
383
|
+
reasons[reason] = reasons.get(reason, 0) + 1
|
|
384
|
+
if len(result["samples"]) < 20:
|
|
385
|
+
result["samples"].append({"session_name": entry.get("session_name"),
|
|
386
|
+
"run_id": entry.get("run_id"), "started_at": entry.get("started_at"),
|
|
387
|
+
"model": model, "evidence": kind, "reason": reason})
|
|
388
|
+
rewritten.append(line)
|
|
389
|
+
recovered = sum(result["recoverable"].values())
|
|
390
|
+
if not dry_run and recovered:
|
|
391
|
+
backup = f"{launch_history_file}.models-{uuid.uuid4().hex}.bak"
|
|
392
|
+
atomic_write(backup, original)
|
|
393
|
+
atomic_write(launch_history_file, b"".join(rewritten))
|
|
394
|
+
result["backup_path"] = backup
|
|
395
|
+
result["backfilled"] = recovered
|
|
396
|
+
return result
|
|
397
|
+
|
|
346
398
|
def list_launch_history(session_name=None, limit=20):
|
|
347
399
|
with _file_lock(lock_file):
|
|
348
400
|
try:
|
|
@@ -380,5 +432,6 @@ def create_session_store(base_dir):
|
|
|
380
432
|
"append_launch_history": append_launch_history,
|
|
381
433
|
"drop_unvouched_usage": drop_unvouched_usage,
|
|
382
434
|
"backfill_launch_usage": backfill_launch_usage,
|
|
435
|
+
"backfill_launch_models": backfill_launch_models,
|
|
383
436
|
"list_launch_history": list_launch_history,
|
|
384
437
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Linux workspace confinement shared with the cdx-manager integration patch.
|
|
2
|
+
|
|
3
|
+
Expose tools, caches and one checkout, never the operator's home or host processes.
|
|
4
|
+
Provider authentication is the only credential deliberately passed to the provider.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
CACHE = Path.home() / ".cdx" / "shared-cache"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def clean_env(source: dict) -> dict:
|
|
16
|
+
allowed = ("PATH", "LANG", "LC_ALL", "TERM", "TZ")
|
|
17
|
+
env = {key: source[key] for key in allowed if key in source}
|
|
18
|
+
env.update(HOME="/home/agent", XDG_CONFIG_HOME="/home/agent/.config",
|
|
19
|
+
XDG_CACHE_HOME="/cache", GIT_CONFIG_NOSYSTEM="1",
|
|
20
|
+
GIT_CONFIG_GLOBAL="/dev/null", GIT_TERMINAL_PROMPT="0", GIT_OPTIONAL_LOCKS="0",
|
|
21
|
+
GIT_CONFIG_COUNT="1", GIT_CONFIG_KEY_0="credential.helper", GIT_CONFIG_VALUE_0="",
|
|
22
|
+
GH_CONFIG_DIR="/home/agent/.config/gh", NPM_CONFIG_CACHE="/cache/npm",
|
|
23
|
+
PIP_CACHE_DIR="/cache/pip", UV_CACHE_DIR="/cache/uv",
|
|
24
|
+
PLAYWRIGHT_BROWSERS_PATH="/cache/playwright", CI="1")
|
|
25
|
+
return env
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sandbox_command(command: list[str], tree: Path, *, network: bool, writable: bool = True,
|
|
29
|
+
source: dict | None = None, auth_home: str = "", provider: str = "") -> tuple[list[str], dict]:
|
|
30
|
+
source = os.environ if source is None else source
|
|
31
|
+
binary = shutil.which(command[0], path=source.get("PATH"))
|
|
32
|
+
bwrap = shutil.which("bwrap", path=source.get("PATH"))
|
|
33
|
+
if not binary or not bwrap:
|
|
34
|
+
raise RuntimeError("Workspace isolation requires bubblewrap and " + command[0])
|
|
35
|
+
if tree.is_symlink() or not tree.is_dir():
|
|
36
|
+
raise RuntimeError("Workspace must be a directory")
|
|
37
|
+
tree = tree.resolve()
|
|
38
|
+
CACHE.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
args = [bwrap, "--die-with-parent", "--new-session", "--unshare-all", "--cap-drop", "ALL"]
|
|
40
|
+
# The provider needs transport even when its own tool sandbox denies network.
|
|
41
|
+
if network or provider:
|
|
42
|
+
args += ["--share-net"]
|
|
43
|
+
args += ["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--dir", "/home/agent"]
|
|
44
|
+
# An allowlist avoids overlooking another secret file or a credential symlink.
|
|
45
|
+
paths = [Path(p) for p in ("/usr", "/bin", "/sbin", "/lib", "/lib64")]
|
|
46
|
+
home = Path.home()
|
|
47
|
+
paths += [home / ".nvm", home / ".local/bin", home / ".local/share/claude",
|
|
48
|
+
home / ".local/share/uv"]
|
|
49
|
+
for path in paths:
|
|
50
|
+
if path.exists():
|
|
51
|
+
args += ["--ro-bind", str(path), str(path)]
|
|
52
|
+
for path in ("/etc/ssl", "/etc/resolv.conf", "/etc/hosts", "/etc/nsswitch.conf", "/etc/ld.so.cache",
|
|
53
|
+
"/etc/fonts", "/etc/passwd", "/etc/group", "/etc/alternatives"):
|
|
54
|
+
if Path(path).exists():
|
|
55
|
+
args += ["--ro-bind", path, path]
|
|
56
|
+
# Fail closed for a provider installed outside the explicitly permitted toolchains.
|
|
57
|
+
resolved = Path(binary).resolve()
|
|
58
|
+
if not any(resolved.is_relative_to(p.resolve()) for p in paths if p.exists()):
|
|
59
|
+
raise RuntimeError("Executable is outside the permitted toolchains: " + str(resolved))
|
|
60
|
+
args += ["--bind", str(CACHE), "/cache", "--bind" if writable else "--ro-bind", str(tree), str(tree)]
|
|
61
|
+
marker = tree / ".git"
|
|
62
|
+
if marker.is_file():
|
|
63
|
+
text = marker.read_text().strip()
|
|
64
|
+
if marker.is_symlink() or not text.startswith("gitdir: "):
|
|
65
|
+
raise RuntimeError("Invalid worktree Git marker")
|
|
66
|
+
gitdir = (tree / text.removeprefix("gitdir: ")).resolve()
|
|
67
|
+
common = (gitdir / (gitdir / "commondir").read_text().strip()).resolve()
|
|
68
|
+
# A writable marker must never turn into an arbitrary host-directory mount.
|
|
69
|
+
if (common.name != ".git" or gitdir.parent != common / "worktrees"
|
|
70
|
+
or common.is_relative_to(tree) or common.is_relative_to(CACHE.resolve())
|
|
71
|
+
or Path((gitdir / "gitdir").read_text().strip()).resolve() != marker):
|
|
72
|
+
raise RuntimeError("Invalid external worktree Git directory")
|
|
73
|
+
args += ["--ro-bind", str(marker), str(marker), "--ro-bind", str(common), str(common),
|
|
74
|
+
"--tmpfs", str(common / "worktrees"), "--ro-bind", str(gitdir), str(gitdir),
|
|
75
|
+
"--ro-bind", "/dev/null", str(common / "config"),
|
|
76
|
+
"--tmpfs", str(common / "hooks")]
|
|
77
|
+
if (gitdir / "config.worktree").exists():
|
|
78
|
+
args += ["--ro-bind", "/dev/null", str(gitdir / "config.worktree")]
|
|
79
|
+
elif marker.exists():
|
|
80
|
+
raise RuntimeError("Expected an isolated git worktree")
|
|
81
|
+
env = clean_env(source)
|
|
82
|
+
if provider == "codex":
|
|
83
|
+
env["CODEX_HOME"] = "/home/agent/.codex"
|
|
84
|
+
args += ["--ro-bind", str(Path(auth_home) / "auth.json"), "/home/agent/.codex/auth.json"]
|
|
85
|
+
elif provider == "claude":
|
|
86
|
+
token = source.get("CLAUDE_CODE_OAUTH_TOKEN")
|
|
87
|
+
if not token:
|
|
88
|
+
raise RuntimeError("Isolated Claude runs require a cdx OAuth setup token")
|
|
89
|
+
env.update(CLAUDE_CODE_OAUTH_TOKEN=token, CLAUDE_CODE_SUBPROCESS_ENV_SCRUB="1",
|
|
90
|
+
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1")
|
|
91
|
+
elif provider:
|
|
92
|
+
raise RuntimeError("Unsupported isolated provider: " + provider)
|
|
93
|
+
args += ["--chdir", str(tree), "--", binary, *command[1:]]
|
|
94
|
+
return args, env
|