claude-dev-env 8.46.18 → 8.46.20
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.
|
@@ -431,6 +431,26 @@ def _existing_scratchpad_root(
|
|
|
431
431
|
return None
|
|
432
432
|
|
|
433
433
|
|
|
434
|
+
def _is_at_or_under_payload_working_directory(file_path: str, hook_payload: dict) -> bool:
|
|
435
|
+
"""Return True when file_path's real path sits at or under the payload ``cwd``.
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
file_path: The path the write targets.
|
|
439
|
+
hook_payload: The PreToolUse payload that may carry ``cwd``.
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
True when the payload names a working directory and file_path resolves
|
|
443
|
+
at or under its real path.
|
|
444
|
+
"""
|
|
445
|
+
working_directory = str(hook_payload.get("cwd") or "")
|
|
446
|
+
if not working_directory:
|
|
447
|
+
return False
|
|
448
|
+
return _is_at_or_under_root(
|
|
449
|
+
_normalized_absolute_comparison_path(os.path.realpath(file_path)),
|
|
450
|
+
_normalized_absolute_comparison_path(os.path.realpath(working_directory)),
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
434
454
|
def is_under_session_scratchpad(file_path: str, hook_payload: dict) -> bool:
|
|
435
455
|
"""Return True when file_path resolves under the harness session scratchpad.
|
|
436
456
|
|
|
@@ -460,13 +480,9 @@ def is_under_session_scratchpad(file_path: str, hook_payload: dict) -> bool:
|
|
|
460
480
|
session_id = _session_id_for_scratchpad(hook_payload)
|
|
461
481
|
if not session_id:
|
|
462
482
|
return False
|
|
463
|
-
|
|
464
|
-
working_directory = str(hook_payload.get("cwd") or "")
|
|
465
|
-
if working_directory and _is_at_or_under_root(
|
|
466
|
-
_normalized_absolute_comparison_path(real_target),
|
|
467
|
-
_normalized_absolute_comparison_path(os.path.realpath(working_directory)),
|
|
468
|
-
):
|
|
483
|
+
if _is_at_or_under_payload_working_directory(file_path, hook_payload):
|
|
469
484
|
return False
|
|
485
|
+
real_target = os.path.realpath(file_path)
|
|
470
486
|
real_temp_root = os.path.realpath(tempfile.gettempdir())
|
|
471
487
|
all_relative_parts = _relative_parts_under_temp_root(real_target, real_temp_root)
|
|
472
488
|
return (
|
|
@@ -482,10 +498,10 @@ def is_ephemeral_path(file_path: str, hook_payload: dict | None = None) -> bool:
|
|
|
482
498
|
scratch directories (``/tmp`` and ``$CLAUDE_JOB_DIR/tmp``), the harness
|
|
483
499
|
session scratchpad, and a coding agent's own home-directory tooling such as
|
|
484
500
|
``~/.grok/runs/``. A path inside the payload's own working directory
|
|
485
|
-
(``cwd``) is never treated as scratch, so a repository
|
|
486
|
-
``/tmp`` still receives full
|
|
487
|
-
|
|
488
|
-
variable. The code-rules and TDD gates call the underlying predicates
|
|
501
|
+
(``cwd``) is never treated as scratch or agent tooling, so a repository
|
|
502
|
+
checked out under ``/tmp`` or ``~/.grok/runs/`` still receives full
|
|
503
|
+
enforcement. The scratchpad match reads the session id from the payload
|
|
504
|
+
when supplied, else the harness environment variable. The code-rules and TDD gates call the underlying predicates
|
|
489
505
|
``is_ephemeral_script_path``, ``is_agent_home_tooling``, and
|
|
490
506
|
``is_under_session_scratchpad`` directly.
|
|
491
507
|
|
|
@@ -501,7 +517,9 @@ def is_ephemeral_path(file_path: str, hook_payload: dict | None = None) -> bool:
|
|
|
501
517
|
repository_root = str((hook_payload or {}).get("cwd") or "")
|
|
502
518
|
if is_ephemeral_script_path(file_path, repository_root):
|
|
503
519
|
return True
|
|
504
|
-
if is_agent_home_tooling(file_path)
|
|
520
|
+
if is_agent_home_tooling(file_path) and not _is_at_or_under_payload_working_directory(
|
|
521
|
+
file_path, hook_payload or {}
|
|
522
|
+
):
|
|
505
523
|
return True
|
|
506
524
|
return is_under_session_scratchpad(file_path, hook_payload or {})
|
|
507
525
|
|
|
@@ -357,3 +357,20 @@ def test_ephemeral_path_stays_true_for_scratchpad_file_outside_payload_cwd(
|
|
|
357
357
|
assert (
|
|
358
358
|
_SHARED_MODULE.is_ephemeral_path(str(throwaway_script), payload_with_worktree_cwd) is True
|
|
359
359
|
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_ephemeral_path_is_false_for_file_under_payload_cwd_inside_agent_home() -> None:
|
|
363
|
+
checkout_directory = os.path.join("/home/example", ".grok", "runs", "checkout")
|
|
364
|
+
repository_module = os.path.join(checkout_directory, "src", "service.py")
|
|
365
|
+
|
|
366
|
+
assert (
|
|
367
|
+
_SHARED_MODULE.is_ephemeral_path(repository_module, {"cwd": checkout_directory})
|
|
368
|
+
is False
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def test_ephemeral_path_stays_true_for_agent_home_file_outside_payload_cwd() -> None:
|
|
373
|
+
checkout_directory = os.path.join("/home/example", ".grok", "runs", "checkout")
|
|
374
|
+
helper_script = os.path.join("/home/example", ".grok", "runs", "worktree-health", "health.py")
|
|
375
|
+
|
|
376
|
+
assert _SHARED_MODULE.is_ephemeral_path(helper_script, {"cwd": checkout_directory}) is True
|
package/package.json
CHANGED
|
@@ -128,13 +128,22 @@ def readings_payload(all_readings: Sequence[Reading]) -> list[dict[str, object]]
|
|
|
128
128
|
]
|
|
129
129
|
|
|
130
130
|
|
|
131
|
+
def _stored_reset(raw: object, now: datetime) -> datetime | None:
|
|
132
|
+
if not isinstance(raw, (int, float)) or raw <= now.timestamp():
|
|
133
|
+
return None
|
|
134
|
+
try:
|
|
135
|
+
return datetime.fromtimestamp(raw, timezone.utc)
|
|
136
|
+
except (ValueError, OSError, OverflowError):
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
131
140
|
def _spent_for_readings(all_readings: Sequence[Reading], all_state: dict[str, object], now: datetime) -> tuple[frozenset[Account], dict[Account, datetime]]:
|
|
132
141
|
marks = all_state["spent"]
|
|
133
142
|
spent = {}
|
|
134
143
|
for each_reading in all_readings:
|
|
135
|
-
|
|
136
|
-
if
|
|
137
|
-
spent[each_reading.account] =
|
|
144
|
+
reset = _stored_reset(marks.get(_state_key(each_reading.account)), now)
|
|
145
|
+
if reset is not None:
|
|
146
|
+
spent[each_reading.account] = reset
|
|
138
147
|
return frozenset(spent), spent
|
|
139
148
|
|
|
140
149
|
|
|
@@ -147,21 +156,31 @@ def _mark_spent(all_state: dict[str, object], reading: Reading, now: datetime, r
|
|
|
147
156
|
return until
|
|
148
157
|
|
|
149
158
|
|
|
150
|
-
def _outside_roster_resets(product: Product, all_readings: Sequence[Reading], all_state: Mapping[str, object], now: datetime) ->
|
|
159
|
+
def _outside_roster_resets(product: Product, all_readings: Sequence[Reading], all_state: Mapping[str, object], now: datetime) -> dict[str, datetime]:
|
|
151
160
|
marks = all_state.get("spent") if isinstance(all_state.get("spent"), dict) else {}
|
|
152
161
|
all_roster_names = {each_reading.account.name for each_reading in all_readings}
|
|
153
162
|
prefix = f"{product.value}:"
|
|
154
163
|
placeholder_suffix = f":{Path()}"
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
164
|
+
all_resets_by_name = {}
|
|
165
|
+
for each_key, each_raw in marks.items():
|
|
166
|
+
if not (isinstance(each_key, str) and each_key.startswith(prefix) and each_key.endswith(placeholder_suffix)):
|
|
167
|
+
continue
|
|
168
|
+
name = each_key[len(prefix):-len(placeholder_suffix)]
|
|
169
|
+
reset = _stored_reset(each_raw, now)
|
|
170
|
+
if name not in all_roster_names and reset is not None:
|
|
171
|
+
all_resets_by_name[name] = reset
|
|
172
|
+
return all_resets_by_name
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _with_outside_spent_marks(decision: Decision, all_outside_resets_by_name: Mapping[str, datetime]) -> Decision:
|
|
176
|
+
if not all_outside_resets_by_name:
|
|
160
177
|
return decision
|
|
161
|
-
|
|
162
|
-
if decision.action
|
|
178
|
+
outside_name, outside_reset = min(all_outside_resets_by_name.items(), key=lambda each_item: each_item[1])
|
|
179
|
+
if decision.action != "wait":
|
|
180
|
+
return Decision("wait", None, outside_reset, f"account {outside_name} outside the roster is spent; next reset at {_time_text(outside_reset)}", "wait")
|
|
181
|
+
if decision.resets_at is not None and decision.resets_at <= outside_reset:
|
|
163
182
|
return decision
|
|
164
|
-
return Decision("wait", None,
|
|
183
|
+
return Decision("wait", None, outside_reset, f"no account has room; next reset at {_time_text(outside_reset)}", "wait")
|
|
165
184
|
|
|
166
185
|
|
|
167
186
|
def _write_report(path: Path, report: Report) -> None:
|
|
@@ -328,12 +347,17 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
328
347
|
|
|
329
348
|
def _parse_spent_mark(all_accounts_by_name: Mapping[str, Reading], mark: str, product: Product) -> tuple[Reading, datetime | None]:
|
|
330
349
|
name, separator, reset_text = mark.partition(":")
|
|
350
|
+
if not name:
|
|
351
|
+
raise BrokerConfigurationError(f"spent mark {mark!r} needs an account name")
|
|
331
352
|
try:
|
|
332
353
|
reset = datetime.fromtimestamp(float(reset_text), timezone.utc) if separator else None
|
|
333
354
|
except (ValueError, OSError, OverflowError) as error:
|
|
334
355
|
raise BrokerConfigurationError(f"invalid reset for account {name}") from error
|
|
335
356
|
reading = all_accounts_by_name.get(name)
|
|
336
|
-
|
|
357
|
+
if reading is None:
|
|
358
|
+
print(f"warning: spent mark names {name}, an account outside the roster; choose waits until its reset", file=sys.stderr)
|
|
359
|
+
return Reading(Account(product, name, Path(), False), None), reset
|
|
360
|
+
return reading, reset
|
|
337
361
|
|
|
338
362
|
|
|
339
363
|
def _save_spent_arguments(all_marks: Sequence[str], all_readings: Sequence[Reading], all_state: dict[str, object], now: datetime, product: Product) -> None:
|
|
@@ -696,6 +696,81 @@ def test_should_run_job_through_override(
|
|
|
696
696
|
assert calls == [b"input"]
|
|
697
697
|
|
|
698
698
|
|
|
699
|
+
@pytest.mark.parametrize("marked_name", ["retired", "first"])
|
|
700
|
+
def test_should_run_past_a_stored_spent_mark_beyond_the_platform_range(
|
|
701
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], marked_name: str
|
|
702
|
+
) -> None:
|
|
703
|
+
account = _account("first")
|
|
704
|
+
second = _account("second")
|
|
705
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account, second), {"first": _meters(80, 80), "second": _meters(80, 80)}))
|
|
706
|
+
_freeze_clock(monkeypatch, NOW)
|
|
707
|
+
marked_account = account if marked_name == "first" else Account(Product.CODEX, marked_name, Path(), False)
|
|
708
|
+
account_broker._save_state(
|
|
709
|
+
account_broker.broker_state_path(),
|
|
710
|
+
{"meters": {}, "spent": {account_broker._state_key(marked_account): 1e20}, "affinity": {}},
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
code = account_broker.main(("choose", "--product", "codex"))
|
|
714
|
+
|
|
715
|
+
decision = json.loads(capsys.readouterr().out)["decision"]
|
|
716
|
+
assert code == 0
|
|
717
|
+
assert decision["action"] == "run"
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def test_should_name_the_outside_spent_mark_when_it_forces_a_wait(
|
|
721
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
722
|
+
) -> None:
|
|
723
|
+
account = _account("first")
|
|
724
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"first": _meters(80, 80)}))
|
|
725
|
+
_freeze_clock(monkeypatch, NOW)
|
|
726
|
+
reset = NOW + timedelta(hours=2)
|
|
727
|
+
|
|
728
|
+
account_broker.main(("choose", "--product", "codex", "--spent", f"visitor:{int(reset.timestamp())}"))
|
|
729
|
+
|
|
730
|
+
decision = json.loads(capsys.readouterr().out)["decision"]
|
|
731
|
+
assert decision["action"] == "wait"
|
|
732
|
+
assert decision["reason"] == f"account visitor outside the roster is spent; next reset at {account_broker._time_text(reset)}"
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def test_should_warn_when_a_spent_mark_names_an_account_outside_the_roster(
|
|
736
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
737
|
+
) -> None:
|
|
738
|
+
account = _account("first")
|
|
739
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"first": _meters(80, 80)}))
|
|
740
|
+
_freeze_clock(monkeypatch, NOW)
|
|
741
|
+
|
|
742
|
+
account_broker.main(("choose", "--product", "codex", "--spent", "frist", "--spent", "first"))
|
|
743
|
+
|
|
744
|
+
all_warning_lines = capsys.readouterr().err.splitlines()
|
|
745
|
+
assert len(all_warning_lines) == 1
|
|
746
|
+
assert "frist" in all_warning_lines[0]
|
|
747
|
+
assert "outside the roster" in all_warning_lines[0]
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
@pytest.mark.parametrize("mark", ["", ":1800000000"])
|
|
751
|
+
def test_should_reject_a_spent_mark_without_an_account_name(
|
|
752
|
+
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], mark: str
|
|
753
|
+
) -> None:
|
|
754
|
+
account = _account("first")
|
|
755
|
+
monkeypatch.setitem(account_broker.all_product_adapters, Product.CODEX, _adapter((account,), {"first": _meters(80, 80)}))
|
|
756
|
+
|
|
757
|
+
code = account_broker.main(("choose", "--product", "codex", "--spent", mark))
|
|
758
|
+
|
|
759
|
+
assert code == 2
|
|
760
|
+
assert "account name" in capsys.readouterr().err
|
|
761
|
+
assert account_broker._load_state(account_broker.broker_state_path())["spent"] == {}
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def test_should_serialize_a_run_and_a_wait_decision() -> None:
|
|
765
|
+
account = _account("first")
|
|
766
|
+
reset = NOW + timedelta(hours=2)
|
|
767
|
+
|
|
768
|
+
run_payload = account_broker.decision_payload(account_broker.Decision("run", account, None, "first has 80% left", "normal"))
|
|
769
|
+
wait_payload = account_broker.decision_payload(account_broker.Decision("wait", None, reset, "no account has room", "wait"))
|
|
770
|
+
|
|
771
|
+
assert run_payload == {"action": "run", "account": "first", "home": str(Path("/profiles/first")), "reason": "first has 80% left", "tier": "normal", "resets_at": None}
|
|
772
|
+
assert wait_payload == {"action": "wait", "account": None, "home": None, "reason": "no account has room", "tier": "wait", "resets_at": reset.isoformat()}
|
|
773
|
+
|
|
699
774
|
|
|
700
775
|
def test_should_print_the_whole_rate_limit_result_for_one_home(
|
|
701
776
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], tmp_path: Path
|