shikiru-cli 0.2.0__py3-none-any.whl
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.
- shikiru/__init__.py +1 -0
- shikiru/__main__.py +8 -0
- shikiru/agents/__init__.py +3 -0
- shikiru/agents/contracts.py +590 -0
- shikiru/agents/registry.py +87 -0
- shikiru/agents/sdk/__init__.py +17 -0
- shikiru/agents/sdk/reset_time.py +137 -0
- shikiru/agents/sdk/turn.py +335 -0
- shikiru/agents/settings.py +151 -0
- shikiru/agents/testing/__init__.py +26 -0
- shikiru/agents/testing/contract_suite.py +653 -0
- shikiru/agents/testing/resume.py +15 -0
- shikiru/bootstrap.py +1277 -0
- shikiru/cli/__init__.py +1 -0
- shikiru/cli/app.py +770 -0
- shikiru/cli/context.py +63 -0
- shikiru/cli/render.py +1475 -0
- shikiru/cli/report.py +156 -0
- shikiru/cli/session_prompt.py +63 -0
- shikiru/cli/view.py +131 -0
- shikiru/clock.py +41 -0
- shikiru/config/__init__.py +8 -0
- shikiru/config/defaults.toml +131 -0
- shikiru/config/loader.py +178 -0
- shikiru/config/settings.py +392 -0
- shikiru/dashboard/__init__.py +3 -0
- shikiru/dashboard/actions_list.py +799 -0
- shikiru/dashboard/activity.py +150 -0
- shikiru/dashboard/app.py +1765 -0
- shikiru/dashboard/blocker_evidence.py +122 -0
- shikiru/dashboard/blocker_rows.py +84 -0
- shikiru/dashboard/briefing_view.py +871 -0
- shikiru/dashboard/contract.py +1086 -0
- shikiru/dashboard/covered.py +32 -0
- shikiru/dashboard/detail.py +1209 -0
- shikiru/dashboard/detail_fold.py +244 -0
- shikiru/dashboard/detail_hint.py +68 -0
- shikiru/dashboard/details_list_view.py +150 -0
- shikiru/dashboard/diagnostics.py +80 -0
- shikiru/dashboard/diagnostics_sections.py +186 -0
- shikiru/dashboard/evidence_table.py +123 -0
- shikiru/dashboard/extra_details.py +214 -0
- shikiru/dashboard/fault_guidance.py +216 -0
- shikiru/dashboard/fold_bodies.py +276 -0
- shikiru/dashboard/help_keys.py +166 -0
- shikiru/dashboard/help_screen.py +204 -0
- shikiru/dashboard/interaction.py +17 -0
- shikiru/dashboard/ladder_editor.py +544 -0
- shikiru/dashboard/live_settings.py +62 -0
- shikiru/dashboard/motion.py +368 -0
- shikiru/dashboard/orchestration_fields.py +201 -0
- shikiru/dashboard/palette.py +1606 -0
- shikiru/dashboard/palette_app.py +89 -0
- shikiru/dashboard/palette_drafts.py +33 -0
- shikiru/dashboard/resolution.py +573 -0
- shikiru/dashboard/resolution_view.py +302 -0
- shikiru/dashboard/row_list.py +256 -0
- shikiru/dashboard/section_rows.py +173 -0
- shikiru/dashboard/services.py +665 -0
- shikiru/dashboard/session_history.py +402 -0
- shikiru/dashboard/shikiru_init_ascii_art.txt +40 -0
- shikiru/dashboard/task_picker.py +358 -0
- shikiru/dashboard/tick.py +59 -0
- shikiru/dashboard/timeline_filter.py +129 -0
- shikiru/dashboard/timeline_section.py +385 -0
- shikiru/dashboard/usage_body.py +269 -0
- shikiru/dashboard/views.py +2314 -0
- shikiru/dashboard/wizard.py +1389 -0
- shikiru/domain/__init__.py +1 -0
- shikiru/domain/acceptance.py +130 -0
- shikiru/domain/activity.py +28 -0
- shikiru/domain/attempts.py +333 -0
- shikiru/domain/briefing.py +257 -0
- shikiru/domain/commands.py +219 -0
- shikiru/domain/decisions.py +231 -0
- shikiru/domain/docker.py +262 -0
- shikiru/domain/engine_uptime.py +87 -0
- shikiru/domain/escalation.py +194 -0
- shikiru/domain/escalation_evidence.py +186 -0
- shikiru/domain/escalation_panel.py +144 -0
- shikiru/domain/events.py +71 -0
- shikiru/domain/evidence_export.py +89 -0
- shikiru/domain/faults.py +86 -0
- shikiru/domain/feedback.py +180 -0
- shikiru/domain/feedback_rounds.py +172 -0
- shikiru/domain/fencing.py +31 -0
- shikiru/domain/ladder.py +102 -0
- shikiru/domain/ladder_text.py +44 -0
- shikiru/domain/levels.py +44 -0
- shikiru/domain/motion.py +199 -0
- shikiru/domain/packet_evidence.py +377 -0
- shikiru/domain/pushes.py +47 -0
- shikiru/domain/quota_blocks.py +44 -0
- shikiru/domain/quota_waits.py +219 -0
- shikiru/domain/recovery.py +194 -0
- shikiru/domain/reports.py +498 -0
- shikiru/domain/results.py +217 -0
- shikiru/domain/runs.py +85 -0
- shikiru/domain/scheduling.py +231 -0
- shikiru/domain/status_tasks.py +40 -0
- shikiru/domain/steps.py +325 -0
- shikiru/domain/tasks.py +174 -0
- shikiru/domain/timeline.py +208 -0
- shikiru/domain/timeline_phrases.py +271 -0
- shikiru/domain/versions.py +18 -0
- shikiru/domain/workflow.py +372 -0
- shikiru/engine/__init__.py +0 -0
- shikiru/engine/acceptance.py +90 -0
- shikiru/engine/agents.py +61 -0
- shikiru/engine/cosmetic_decorator.py +99 -0
- shikiru/engine/daemon.py +409 -0
- shikiru/engine/dependency.py +37 -0
- shikiru/engine/escalation_filing.py +110 -0
- shikiru/engine/escalations.py +401 -0
- shikiru/engine/events.py +285 -0
- shikiru/engine/evidence.py +81 -0
- shikiru/engine/fault_reporter.py +144 -0
- shikiru/engine/feedback.py +577 -0
- shikiru/engine/feedback_audit.py +80 -0
- shikiru/engine/feedback_intake.py +95 -0
- shikiru/engine/feedback_snapshot.py +148 -0
- shikiru/engine/flow.py +87 -0
- shikiru/engine/handlers/__init__.py +33 -0
- shikiru/engine/handlers/base.py +148 -0
- shikiru/engine/handlers/engine.py +24 -0
- shikiru/engine/handlers/escalations.py +174 -0
- shikiru/engine/handlers/operator.py +81 -0
- shikiru/engine/handlers/tasks.py +237 -0
- shikiru/engine/history.py +128 -0
- shikiru/engine/integration.py +1234 -0
- shikiru/engine/integration_turns.py +337 -0
- shikiru/engine/layout_guard.py +362 -0
- shikiru/engine/lifecycle.py +60 -0
- shikiru/engine/monitor.py +317 -0
- shikiru/engine/notification_delivery.py +124 -0
- shikiru/engine/notification_feed.py +57 -0
- shikiru/engine/notifier.py +91 -0
- shikiru/engine/pull_request_closing.py +117 -0
- shikiru/engine/push_service.py +177 -0
- shikiru/engine/quota_probe_threads.py +79 -0
- shikiru/engine/quota_reconciler.py +210 -0
- shikiru/engine/quota_schedule.py +24 -0
- shikiru/engine/quota_waits.py +102 -0
- shikiru/engine/reconcile.py +91 -0
- shikiru/engine/recovery.py +284 -0
- shikiru/engine/retention.py +102 -0
- shikiru/engine/root_preparation.py +86 -0
- shikiru/engine/scheduler.py +320 -0
- shikiru/engine/session_guard.py +46 -0
- shikiru/engine/sessions.py +95 -0
- shikiru/engine/signalled.py +75 -0
- shikiru/engine/step_runner.py +140 -0
- shikiru/engine/supervisor.py +101 -0
- shikiru/engine/takeover.py +223 -0
- shikiru/engine/task_facts.py +46 -0
- shikiru/engine/tick_guard.py +95 -0
- shikiru/engine/titles.py +297 -0
- shikiru/engine/transitions.py +79 -0
- shikiru/engine/turn_context.py +206 -0
- shikiru/engine/turn_execution.py +30 -0
- shikiru/engine/turn_loop.py +230 -0
- shikiru/engine/turn_processes.py +79 -0
- shikiru/engine/turns.py +457 -0
- shikiru/errors.py +144 -0
- shikiru/helper/__init__.py +3 -0
- shikiru/helper/helper.py +182 -0
- shikiru/helper/models.py +11 -0
- shikiru/mcp/__init__.py +1 -0
- shikiru/mcp/server.py +196 -0
- shikiru/operator/__init__.py +33 -0
- shikiru/operator/attachment.py +24 -0
- shikiru/operator/commands.py +319 -0
- shikiru/operator/detectors.py +374 -0
- shikiru/operator/doctor.py +351 -0
- shikiru/operator/events.py +57 -0
- shikiru/operator/evidence_export.py +498 -0
- shikiru/operator/evidence_writer.py +78 -0
- shikiru/operator/lifecycle.py +294 -0
- shikiru/operator/plugin_checks.py +194 -0
- shikiru/operator/probe.py +287 -0
- shikiru/operator/queries.py +1601 -0
- shikiru/operator/services.py +36 -0
- shikiru/operator/sessions.py +378 -0
- shikiru/operator/strip_views.py +162 -0
- shikiru/operator/timeline.py +416 -0
- shikiru/operator/toml_text.py +62 -0
- shikiru/palette.py +18 -0
- shikiru/paths.py +91 -0
- shikiru/plugins/__init__.py +0 -0
- shikiru/plugins/agents/__init__.py +1 -0
- shikiru/plugins/agents/claude/__init__.py +5 -0
- shikiru/plugins/agents/claude/agent.py +251 -0
- shikiru/plugins/agents/claude/context.py +116 -0
- shikiru/plugins/agents/claude/headless.py +92 -0
- shikiru/plugins/agents/claude/hook.py +57 -0
- shikiru/plugins/agents/claude/model_catalog.py +105 -0
- shikiru/plugins/agents/claude/options.py +62 -0
- shikiru/plugins/agents/claude/turn.py +1018 -0
- shikiru/plugins/agents/codex/__init__.py +5 -0
- shikiru/plugins/agents/codex/agent.py +486 -0
- shikiru/plugins/agents/codex/headless.py +166 -0
- shikiru/plugins/agents/codex/home.py +14 -0
- shikiru/plugins/agents/codex/model_catalog.py +128 -0
- shikiru/plugins/agents/codex/options.py +94 -0
- shikiru/plugins/agents/codex/quota.py +195 -0
- shikiru/plugins/agents/codex/sandbox.py +31 -0
- shikiru/plugins/agents/codex/sandbox_probe.py +97 -0
- shikiru/plugins/agents/codex/turn.py +767 -0
- shikiru/plugins/agents/codex/usage.py +106 -0
- shikiru/plugins/agents/shared_settings.py +62 -0
- shikiru/plugins/widgets/__init__.py +3 -0
- shikiru/plugins/widgets/dialogue.py +144 -0
- shikiru/plugins/widgets/disk.py +123 -0
- shikiru/plugins/widgets/docker.py +225 -0
- shikiru/plugins/widgets/escalations.py +402 -0
- shikiru/plugins/widgets/faults.py +204 -0
- shikiru/plugins/widgets/header.py +321 -0
- shikiru/plugins/widgets/queue.py +1140 -0
- shikiru/plugins/widgets/queue_hint.py +93 -0
- shikiru/plugins/widgets/resources.py +105 -0
- shikiru/plugins/widgets/strips.py +82 -0
- shikiru/plugins/widgets/usage.py +176 -0
- shikiru/private.py +94 -0
- shikiru/process.py +535 -0
- shikiru/prompts/__init__.py +3 -0
- shikiru/prompts/renderer.py +85 -0
- shikiru/prompts/templates/_base.md.j2 +17 -0
- shikiru/prompts/templates/_macros.md.j2 +61 -0
- shikiru/prompts/templates/conflicts.md.j2 +22 -0
- shikiru/prompts/templates/consult.md.j2 +5 -0
- shikiru/prompts/templates/continue.md.j2 +4 -0
- shikiru/prompts/templates/feedback_analysis.md.j2 +29 -0
- shikiru/prompts/templates/feedback_rework.md.j2 +33 -0
- shikiru/prompts/templates/feedback_verify.md.j2 +33 -0
- shikiru/prompts/templates/handoff.md.j2 +23 -0
- shikiru/prompts/templates/implement.md.j2 +13 -0
- shikiru/prompts/templates/plan.md.j2 +30 -0
- shikiru/prompts/templates/rework.md.j2 +13 -0
- shikiru/prompts/templates/triage.md.j2 +20 -0
- shikiru/prompts/templates/verify.md.j2 +29 -0
- shikiru/store/__init__.py +1 -0
- shikiru/store/commands.py +242 -0
- shikiru/store/database.py +297 -0
- shikiru/store/escalations.py +240 -0
- shikiru/store/events.py +213 -0
- shikiru/store/feedback.py +266 -0
- shikiru/store/migrations/001_initial.sql +63 -0
- shikiru/store/migrations/002_headless_turns.sql +31 -0
- shikiru/store/migrations/003_drop_command_handled_at.sql +1 -0
- shikiru/store/migrations/004_repo_base_ref.sql +3 -0
- shikiru/store/migrations/005_complexity_explicit.sql +3 -0
- shikiru/store/migrations/006_events_kind_index.sql +1 -0
- shikiru/store/migrations/007_notification_cursor.sql +1 -0
- shikiru/store/migrations/008_reported_usage.sql +1 -0
- shikiru/store/migrations/009_billing_declarations.sql +2 -0
- shikiru/store/migrations/010_canonical_quota_windows.sql +3 -0
- shikiru/store/migrations/012_drop_task_quota_evidence.sql +2 -0
- shikiru/store/migrations/013_push_targets.sql +7 -0
- shikiru/store/migrations/014_agent_block_time.sql +6 -0
- shikiru/store/migrations/015_repair_orphan_agent_blocks.sql +3 -0
- shikiru/store/migrations/016_feedback_rounds.sql +29 -0
- shikiru/store/migrations/017_feedback_round_comments.sql +5 -0
- shikiru/store/migrations/__init__.py +1 -0
- shikiru/store/notification_cursor.py +29 -0
- shikiru/store/pushes.py +117 -0
- shikiru/store/quotas.py +153 -0
- shikiru/store/reader.py +310 -0
- shikiru/store/retention.py +92 -0
- shikiru/store/runs.py +227 -0
- shikiru/store/tasks.py +949 -0
- shikiru/telemetry/__init__.py +1 -0
- shikiru/telemetry/disk.py +167 -0
- shikiru/telemetry/docker.py +164 -0
- shikiru/telemetry/resources.py +296 -0
- shikiru/tmux/__init__.py +3 -0
- shikiru/tmux/client.py +637 -0
- shikiru/tmux/decorator.py +225 -0
- shikiru/tmux/layout.py +200 -0
- shikiru/tmux/session.py +381 -0
- shikiru/ui/__init__.py +9 -0
- shikiru/ui/age.py +45 -0
- shikiru/ui/bubbles.py +41 -0
- shikiru/ui/compact.py +1209 -0
- shikiru/ui/console.py +49 -0
- shikiru/ui/fold.py +50 -0
- shikiru/ui/gated_static.py +117 -0
- shikiru/ui/gauge.py +40 -0
- shikiru/ui/glyphs.py +109 -0
- shikiru/ui/keys.py +57 -0
- shikiru/ui/live_interval.py +42 -0
- shikiru/ui/popup.py +311 -0
- shikiru/ui/quota_status.py +26 -0
- shikiru/ui/recent_window.py +78 -0
- shikiru/ui/screen.py +57 -0
- shikiru/ui/section.py +193 -0
- shikiru/ui/session.py +65 -0
- shikiru/ui/size.py +17 -0
- shikiru/ui/splash.py +42 -0
- shikiru/ui/status.py +47 -0
- shikiru/ui/status_strip.py +149 -0
- shikiru/ui/stream.py +152 -0
- shikiru/ui/strip.py +332 -0
- shikiru/ui/theme.py +83 -0
- shikiru/ui/tokens.py +51 -0
- shikiru/view_log.py +126 -0
- shikiru/workspace/__init__.py +3 -0
- shikiru/workspace/base_refs.py +160 -0
- shikiru/workspace/caches.py +136 -0
- shikiru/workspace/facts.py +118 -0
- shikiru/workspace/git.py +601 -0
- shikiru/workspace/github.py +744 -0
- shikiru/workspace/messages.py +107 -0
- shikiru/workspace/reports.py +145 -0
- shikiru/workspace/roots.py +180 -0
- shikiru_cli-0.2.0.dist-info/METADATA +428 -0
- shikiru_cli-0.2.0.dist-info/RECORD +319 -0
- shikiru_cli-0.2.0.dist-info/WHEEL +4 -0
- shikiru_cli-0.2.0.dist-info/entry_points.txt +18 -0
- shikiru_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Codex CLI model catalog parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from shikiru.agents.contracts import ModelChoice
|
|
10
|
+
|
|
11
|
+
_MODELS_FIELD = "models"
|
|
12
|
+
_MODEL_SLUG_FIELD = "slug"
|
|
13
|
+
_MODEL_CONTEXT_WINDOW_FIELD = "context_window"
|
|
14
|
+
_MODEL_VISIBILITY_FIELD = "visibility"
|
|
15
|
+
_MODEL_SUPPORTED_IN_API_FIELD = "supported_in_api"
|
|
16
|
+
_MODEL_REASONING_LEVELS_FIELD = "supported_reasoning_levels"
|
|
17
|
+
_MODEL_EFFORT_FIELD = "effort"
|
|
18
|
+
_LIST_VISIBILITY = "list"
|
|
19
|
+
_HIDDEN_VISIBILITY = "hide"
|
|
20
|
+
_INVALID_CATALOG_MESSAGE = "Codex CLI model catalog has an invalid structure"
|
|
21
|
+
_INVALID_JSON_MESSAGE = "Codex CLI model catalog is not valid JSON"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_bundled_context_windows(output: str) -> dict[str, int]:
|
|
25
|
+
"""Parse positive model context windows from Codex CLI's bundled model catalog."""
|
|
26
|
+
windows: dict[str, int] = {}
|
|
27
|
+
for raw_model in _bundled_models(output):
|
|
28
|
+
if not isinstance(raw_model, Mapping):
|
|
29
|
+
continue
|
|
30
|
+
model = cast("Mapping[str, Any]", raw_model)
|
|
31
|
+
slug = model.get(_MODEL_SLUG_FIELD)
|
|
32
|
+
window = model.get(_MODEL_CONTEXT_WINDOW_FIELD)
|
|
33
|
+
if (
|
|
34
|
+
isinstance(slug, str)
|
|
35
|
+
and isinstance(window, int)
|
|
36
|
+
and not isinstance(window, bool)
|
|
37
|
+
and window > 0
|
|
38
|
+
):
|
|
39
|
+
windows[slug] = window
|
|
40
|
+
return windows
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_bundled_model_choices(output: str) -> tuple[ModelChoice, ...]:
|
|
44
|
+
"""Parse API-supported models and only their reported effort choices."""
|
|
45
|
+
return parse_catalog(output)[0]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_catalog(output: str) -> tuple[tuple[ModelChoice, ...], dict[str, int]]:
|
|
49
|
+
"""Parse model choices and context windows from one Codex catalog payload."""
|
|
50
|
+
choices: list[ModelChoice] = []
|
|
51
|
+
seen: set[str] = set()
|
|
52
|
+
context_windows: dict[str, int] = {}
|
|
53
|
+
for raw_model in _bundled_models(output):
|
|
54
|
+
if not isinstance(raw_model, Mapping):
|
|
55
|
+
raise TypeError(_INVALID_CATALOG_MESSAGE)
|
|
56
|
+
model = cast("Mapping[str, Any]", raw_model)
|
|
57
|
+
slug = model.get(_MODEL_SLUG_FIELD)
|
|
58
|
+
window = model.get(_MODEL_CONTEXT_WINDOW_FIELD)
|
|
59
|
+
if (
|
|
60
|
+
isinstance(slug, str)
|
|
61
|
+
and isinstance(window, int)
|
|
62
|
+
and not isinstance(window, bool)
|
|
63
|
+
and window > 0
|
|
64
|
+
):
|
|
65
|
+
context_windows[slug] = window
|
|
66
|
+
choice = _model_choice(model)
|
|
67
|
+
if choice is None:
|
|
68
|
+
continue
|
|
69
|
+
if choice.model in seen:
|
|
70
|
+
raise TypeError(_INVALID_CATALOG_MESSAGE)
|
|
71
|
+
seen.add(choice.model)
|
|
72
|
+
choices.append(choice)
|
|
73
|
+
return tuple(choices), context_windows
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _bundled_models(output: str) -> list[object]:
|
|
77
|
+
try:
|
|
78
|
+
payload = json.loads(output)
|
|
79
|
+
except json.JSONDecodeError as error:
|
|
80
|
+
raise TypeError(_INVALID_JSON_MESSAGE) from error
|
|
81
|
+
if not isinstance(payload, Mapping):
|
|
82
|
+
raise TypeError(_INVALID_CATALOG_MESSAGE)
|
|
83
|
+
typed_payload = cast("Mapping[str, Any]", payload)
|
|
84
|
+
raw_models = typed_payload.get(_MODELS_FIELD)
|
|
85
|
+
if not isinstance(raw_models, list):
|
|
86
|
+
raise TypeError(_INVALID_CATALOG_MESSAGE)
|
|
87
|
+
return cast("list[object]", raw_models)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _model_choice(raw_model: object) -> ModelChoice | None:
|
|
91
|
+
if not isinstance(raw_model, Mapping):
|
|
92
|
+
raise TypeError(_INVALID_CATALOG_MESSAGE)
|
|
93
|
+
model = cast("Mapping[str, Any]", raw_model)
|
|
94
|
+
visibility = model.get(_MODEL_VISIBILITY_FIELD)
|
|
95
|
+
if not isinstance(visibility, str):
|
|
96
|
+
raise TypeError(_INVALID_CATALOG_MESSAGE)
|
|
97
|
+
if visibility not in {_LIST_VISIBILITY, _HIDDEN_VISIBILITY}:
|
|
98
|
+
raise TypeError(_INVALID_CATALOG_MESSAGE)
|
|
99
|
+
supported_in_api = model.get(_MODEL_SUPPORTED_IN_API_FIELD)
|
|
100
|
+
if not isinstance(supported_in_api, bool):
|
|
101
|
+
raise TypeError(_INVALID_CATALOG_MESSAGE)
|
|
102
|
+
if not supported_in_api:
|
|
103
|
+
return None
|
|
104
|
+
slug = model.get(_MODEL_SLUG_FIELD)
|
|
105
|
+
if not isinstance(slug, str) or not slug:
|
|
106
|
+
raise TypeError(_INVALID_CATALOG_MESSAGE)
|
|
107
|
+
return ModelChoice(
|
|
108
|
+
model=slug,
|
|
109
|
+
efforts=_effort_choices(model),
|
|
110
|
+
offered=visibility == _LIST_VISIBILITY,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _effort_choices(model: Mapping[str, Any]) -> tuple[str, ...] | None:
|
|
115
|
+
raw_levels = model.get(_MODEL_REASONING_LEVELS_FIELD)
|
|
116
|
+
if raw_levels is None:
|
|
117
|
+
return None
|
|
118
|
+
if not isinstance(raw_levels, list):
|
|
119
|
+
raise TypeError(_INVALID_CATALOG_MESSAGE)
|
|
120
|
+
effort_values: list[str] = []
|
|
121
|
+
for raw_level in cast("list[object]", raw_levels):
|
|
122
|
+
if not isinstance(raw_level, Mapping):
|
|
123
|
+
raise TypeError(_INVALID_CATALOG_MESSAGE)
|
|
124
|
+
effort = cast("Mapping[str, Any]", raw_level).get(_MODEL_EFFORT_FIELD)
|
|
125
|
+
if not isinstance(effort, str) or not effort or effort in effort_values:
|
|
126
|
+
raise TypeError(_INVALID_CATALOG_MESSAGE)
|
|
127
|
+
effort_values.append(effort)
|
|
128
|
+
return tuple(effort_values)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Codex plugin options and protocol constants."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
from shikiru.agents.contracts import PERCENT, BillingMode
|
|
10
|
+
from shikiru.plugins.agents.shared_settings import (
|
|
11
|
+
billing_declaration_setting,
|
|
12
|
+
cli_args_setting,
|
|
13
|
+
compact_threshold_setting,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from shikiru.agents.settings import AgentSetting
|
|
18
|
+
|
|
19
|
+
CODEX_HOME_ENV_VAR = "CODEX_HOME"
|
|
20
|
+
CODEX_COMMAND = "codex"
|
|
21
|
+
DEBUG_SUBCOMMAND = "debug"
|
|
22
|
+
MODELS_SUBCOMMAND = "models"
|
|
23
|
+
MODELS_BUNDLED_FLAG = "--bundled"
|
|
24
|
+
MODELS_CACHE_FILENAME = "models_cache.json"
|
|
25
|
+
CODEX_MODEL_CATALOG_ARGV = (CODEX_COMMAND, DEBUG_SUBCOMMAND, MODELS_SUBCOMMAND)
|
|
26
|
+
PROFILE_FLAG = "--profile"
|
|
27
|
+
MODEL_LONG_FLAG = "--model"
|
|
28
|
+
EFFORT_LONG_FLAG = "--effort"
|
|
29
|
+
CONFIG_LONG_FLAG = "--config"
|
|
30
|
+
MODEL_CONFIG_OVERRIDE = "model_provider"
|
|
31
|
+
MODEL_OVERRIDE = "model"
|
|
32
|
+
REASONING_EFFORT_CONFIG = "model_reasoning_effort"
|
|
33
|
+
MODEL_CATALOG_ARGS_DETAIL = (
|
|
34
|
+
"Codex agent args select a model, effort, or profile; its effective model catalog "
|
|
35
|
+
"cannot be determined safely"
|
|
36
|
+
)
|
|
37
|
+
CODEX_BUNDLED_MODELS_ARGV = (
|
|
38
|
+
CODEX_COMMAND,
|
|
39
|
+
DEBUG_SUBCOMMAND,
|
|
40
|
+
MODELS_SUBCOMMAND,
|
|
41
|
+
MODELS_BUNDLED_FLAG,
|
|
42
|
+
)
|
|
43
|
+
RESUME_SUBCOMMAND = "resume"
|
|
44
|
+
NO_DAEMON_FLAG = "--no-daemon"
|
|
45
|
+
MODEL_FLAG = "-m"
|
|
46
|
+
CONFIG_FLAG = "-c"
|
|
47
|
+
EXEC_SUBCOMMAND = "exec"
|
|
48
|
+
EPHEMERAL_FLAG = "--ephemeral"
|
|
49
|
+
JSON_FLAG = "--json"
|
|
50
|
+
OUTPUT_SCHEMA_FLAG = "--output-schema"
|
|
51
|
+
SANDBOX_FLAG = "-s"
|
|
52
|
+
SANDBOX_MODE_CONFIG = "sandbox_mode"
|
|
53
|
+
WRITABLE_ROOTS_CONFIG = "sandbox_workspace_write.writable_roots"
|
|
54
|
+
READ_ONLY_SANDBOX = "read-only"
|
|
55
|
+
WORKSPACE_WRITE_SANDBOX = "workspace-write"
|
|
56
|
+
SKIP_GIT_REPO_CHECK_FLAG = "--skip-git-repo-check"
|
|
57
|
+
AUTO_COMPACT_LIMIT_CONFIG = "model_auto_compact_token_limit"
|
|
58
|
+
VERSION_ARGV = (CODEX_COMMAND, "--version")
|
|
59
|
+
LOGIN_STATUS_ARGV = (CODEX_COMMAND, "login", "status")
|
|
60
|
+
DEFAULT_COMPACT_AT_PCT = 80.0
|
|
61
|
+
USAGE_EVENT_TYPE = "token_count"
|
|
62
|
+
GIT_DIRS_TIMEOUT_SECONDS = 10.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class CodexOptions(BaseModel):
|
|
66
|
+
"""Options accepted by the Codex agent factory."""
|
|
67
|
+
|
|
68
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
69
|
+
|
|
70
|
+
args: list[str] = Field(default_factory=list)
|
|
71
|
+
compact_at_pct: float = Field(default=DEFAULT_COMPACT_AT_PCT, ge=0, le=PERCENT)
|
|
72
|
+
billing_mode: BillingMode | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
TESTED_CODEX_VERSION = "0.159.2"
|
|
76
|
+
SANDBOX_PROBE_TIMEOUT_SECONDS = 60.0
|
|
77
|
+
SANDBOX_PROBE_PARENT = (".cache", "shikiru")
|
|
78
|
+
SANDBOX_PROBE_PREFIX = "codex-sandbox-"
|
|
79
|
+
GIT_COMMAND = "git"
|
|
80
|
+
END_OF_OPTIONS_FLAG = "--"
|
|
81
|
+
GIT_DIRS_ARGV = (
|
|
82
|
+
GIT_COMMAND,
|
|
83
|
+
"rev-parse",
|
|
84
|
+
"--path-format=absolute",
|
|
85
|
+
"--absolute-git-dir",
|
|
86
|
+
"--git-common-dir",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
CODEX_SETTINGS: tuple[AgentSetting, ...] = (
|
|
91
|
+
compact_threshold_setting(DEFAULT_COMPACT_AT_PCT),
|
|
92
|
+
billing_declaration_setting(),
|
|
93
|
+
cli_args_setting(()),
|
|
94
|
+
)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Codex rollout quota parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
11
|
+
|
|
12
|
+
from shikiru.agents.contracts import (
|
|
13
|
+
QUOTA_WINDOW_FIVE_HOUR,
|
|
14
|
+
QUOTA_WINDOW_SEVEN_DAY,
|
|
15
|
+
QuotaReading,
|
|
16
|
+
QuotaWindow,
|
|
17
|
+
)
|
|
18
|
+
from shikiru.plugins.agents.codex.options import USAGE_EVENT_TYPE
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from shikiru.agents.sdk.reset_time import ResetTimeParser
|
|
24
|
+
from shikiru.clock import Clock
|
|
25
|
+
|
|
26
|
+
_EVENT_MESSAGE_TYPE = "event_msg"
|
|
27
|
+
_RATE_LIMITS_FIELD = "rate_limits"
|
|
28
|
+
_PRIMARY_WINDOW = "primary"
|
|
29
|
+
_SECONDARY_WINDOW = "secondary"
|
|
30
|
+
_DEFAULT_WINDOW_NAMES = {
|
|
31
|
+
_PRIMARY_WINDOW: QUOTA_WINDOW_FIVE_HOUR,
|
|
32
|
+
_SECONDARY_WINDOW: QUOTA_WINDOW_SEVEN_DAY,
|
|
33
|
+
}
|
|
34
|
+
_WINDOW_MINUTES_FIELD = "window_minutes"
|
|
35
|
+
_TIMESTAMP_FIELD = "timestamp"
|
|
36
|
+
_MINUTES_PER_HOUR = 60
|
|
37
|
+
_MINUTES_PER_DAY = 24 * _MINUTES_PER_HOUR
|
|
38
|
+
_USED_PERCENT_FIELD = "used_percent"
|
|
39
|
+
_RESETS_AT_FIELD = "resets_at"
|
|
40
|
+
_ROLLOUT_GLOB = "rollout-*.jsonl"
|
|
41
|
+
_LIMIT_ID_FIELD = "limit_id"
|
|
42
|
+
CODEX_LIMIT_ID = "codex"
|
|
43
|
+
_ROLLOUT_TAIL_BYTES = 1 << 20
|
|
44
|
+
_NEWLINE = b"\n"
|
|
45
|
+
_FILE_START = 0
|
|
46
|
+
_AFTER_SEPARATOR = -1
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class QuotaObservation:
|
|
51
|
+
"""A Codex quota sample and its provider timestamp, when present."""
|
|
52
|
+
|
|
53
|
+
windows: tuple[QuotaWindow, ...]
|
|
54
|
+
timestamp: datetime | None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class CodexQuotaProbe:
|
|
58
|
+
"""Reads the Codex-bucket quota windows from Codex rollouts."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, home: Path, clock: Clock, reset_time: ResetTimeParser) -> None:
|
|
61
|
+
"""Use `home`, `clock` and `reset_time` to inspect Codex session files."""
|
|
62
|
+
self._home = home
|
|
63
|
+
self._clock = clock
|
|
64
|
+
self._reset_time = reset_time
|
|
65
|
+
|
|
66
|
+
def get(self, rollout_path: Path | None = None) -> list[QuotaWindow]:
|
|
67
|
+
"""Return the latest primary and secondary quota windows, or no windows."""
|
|
68
|
+
path = rollout_path or self._newest_rollout()
|
|
69
|
+
if path is None or not path.is_file():
|
|
70
|
+
return []
|
|
71
|
+
return self.parse(path.read_text(encoding="utf-8"), self._clock.now())
|
|
72
|
+
|
|
73
|
+
def parse(self, text: str, observed_at: datetime) -> list[QuotaWindow]:
|
|
74
|
+
"""Parse quota windows from Codex rollout JSONL text."""
|
|
75
|
+
observation = self.parse_observation(text, observed_at)
|
|
76
|
+
return list(observation.windows) if observation is not None else []
|
|
77
|
+
|
|
78
|
+
def parse_observation(self, text: str, observed_at: datetime) -> QuotaObservation | None:
|
|
79
|
+
"""Parse the latest quota sample and preserve its provider timestamp."""
|
|
80
|
+
latest: tuple[Mapping[str, Any], datetime | None] | None = None
|
|
81
|
+
for line in text.splitlines():
|
|
82
|
+
try:
|
|
83
|
+
raw = json.loads(line)
|
|
84
|
+
except json.JSONDecodeError:
|
|
85
|
+
continue
|
|
86
|
+
if not isinstance(raw, Mapping):
|
|
87
|
+
continue
|
|
88
|
+
payload = _payload(cast("Mapping[str, Any]", raw))
|
|
89
|
+
if payload.get("type") != USAGE_EVENT_TYPE:
|
|
90
|
+
continue
|
|
91
|
+
limits = payload.get(_RATE_LIMITS_FIELD)
|
|
92
|
+
if _is_codex_bucket(limits) and _has_windows(cast("Mapping[str, Any]", limits)):
|
|
93
|
+
latest = (
|
|
94
|
+
cast("Mapping[str, Any]", limits),
|
|
95
|
+
_event_time(cast("Mapping[str, Any]", raw)),
|
|
96
|
+
)
|
|
97
|
+
if latest is None:
|
|
98
|
+
return None
|
|
99
|
+
rate_limits, event_time = latest
|
|
100
|
+
window_time = event_time or observed_at
|
|
101
|
+
windows = tuple(
|
|
102
|
+
self._window(key, cast("Mapping[str, Any]", rate_limits[key]), window_time)
|
|
103
|
+
for key in _DEFAULT_WINDOW_NAMES
|
|
104
|
+
if isinstance(rate_limits.get(key), Mapping)
|
|
105
|
+
)
|
|
106
|
+
return QuotaObservation(windows, event_time)
|
|
107
|
+
|
|
108
|
+
def latest_reading(self, since: datetime) -> QuotaReading | None:
|
|
109
|
+
"""Return the newest Codex-bucket sample stamped after `since` in any recent rollout.
|
|
110
|
+
|
|
111
|
+
Only rollouts written after `since` are read, from their tail; a sample without a
|
|
112
|
+
provider timestamp cannot show it is fresh and is ignored.
|
|
113
|
+
"""
|
|
114
|
+
sessions = self._home / "sessions"
|
|
115
|
+
if not sessions.is_dir():
|
|
116
|
+
return None
|
|
117
|
+
readings = [
|
|
118
|
+
QuotaReading(observation.windows, observation.timestamp)
|
|
119
|
+
for path in sessions.rglob(_ROLLOUT_GLOB)
|
|
120
|
+
if datetime.fromtimestamp(path.stat().st_mtime, tz=UTC) > since
|
|
121
|
+
for observation in [self.parse_observation(_rollout_tail(path), self._clock.now())]
|
|
122
|
+
if observation is not None
|
|
123
|
+
and observation.timestamp is not None
|
|
124
|
+
and observation.timestamp > since
|
|
125
|
+
]
|
|
126
|
+
return max(readings, key=lambda reading: reading.observed_at, default=None)
|
|
127
|
+
|
|
128
|
+
def _window(self, key: str, raw: Mapping[str, Any], observed_at: datetime) -> QuotaWindow:
|
|
129
|
+
value = raw.get(_USED_PERCENT_FIELD)
|
|
130
|
+
if not isinstance(value, int | float):
|
|
131
|
+
message = f"Codex quota window {key!r} has no numeric used_percent"
|
|
132
|
+
raise TypeError(message)
|
|
133
|
+
reset_value = raw.get(_RESETS_AT_FIELD)
|
|
134
|
+
resets_at = self._reset_time.parse(str(reset_value)) if reset_value is not None else None
|
|
135
|
+
return QuotaWindow(
|
|
136
|
+
name=_window_name(key, raw.get(_WINDOW_MINUTES_FIELD)),
|
|
137
|
+
used_pct=float(value),
|
|
138
|
+
resets_at=resets_at,
|
|
139
|
+
observed_at=observed_at,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def _newest_rollout(self) -> Path | None:
|
|
143
|
+
sessions = self._home / "sessions"
|
|
144
|
+
paths = list(sessions.rglob(_ROLLOUT_GLOB)) if sessions.is_dir() else []
|
|
145
|
+
return max(paths, key=lambda path: path.stat().st_mtime_ns) if paths else None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _is_codex_bucket(limits: object) -> bool:
|
|
149
|
+
if not isinstance(limits, Mapping):
|
|
150
|
+
return False
|
|
151
|
+
limit_id = cast("Mapping[str, Any]", limits).get(_LIMIT_ID_FIELD)
|
|
152
|
+
return limit_id is None or limit_id == CODEX_LIMIT_ID
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _has_windows(limits: Mapping[str, Any]) -> bool:
|
|
156
|
+
return any(isinstance(limits.get(key), Mapping) for key in _DEFAULT_WINDOW_NAMES)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _rollout_tail(path: Path) -> str:
|
|
160
|
+
with path.open("rb") as handle:
|
|
161
|
+
size = handle.seek(_FILE_START, os.SEEK_END)
|
|
162
|
+
handle.seek(max(size - _ROLLOUT_TAIL_BYTES, _FILE_START))
|
|
163
|
+
data = handle.read()
|
|
164
|
+
if size > _ROLLOUT_TAIL_BYTES:
|
|
165
|
+
data = data.partition(_NEWLINE)[_AFTER_SEPARATOR]
|
|
166
|
+
return data.decode("utf-8", errors="ignore")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _payload(entry: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
170
|
+
if entry.get("type") == _EVENT_MESSAGE_TYPE:
|
|
171
|
+
payload = entry.get("payload")
|
|
172
|
+
if isinstance(payload, Mapping):
|
|
173
|
+
return cast("Mapping[str, Any]", payload)
|
|
174
|
+
return entry
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _event_time(entry: Mapping[str, Any]) -> datetime | None:
|
|
178
|
+
value = entry.get(_TIMESTAMP_FIELD)
|
|
179
|
+
if not isinstance(value, str):
|
|
180
|
+
return None
|
|
181
|
+
try:
|
|
182
|
+
parsed = datetime.fromisoformat(value)
|
|
183
|
+
except ValueError:
|
|
184
|
+
return None
|
|
185
|
+
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _window_name(key: str, minutes: object) -> str:
|
|
189
|
+
if not isinstance(minutes, int) or isinstance(minutes, bool) or minutes <= 0:
|
|
190
|
+
return _DEFAULT_WINDOW_NAMES[key]
|
|
191
|
+
if minutes % _MINUTES_PER_DAY == 0:
|
|
192
|
+
return f"{minutes // _MINUTES_PER_DAY}d"
|
|
193
|
+
if minutes % _MINUTES_PER_HOUR == 0:
|
|
194
|
+
return f"{minutes // _MINUTES_PER_HOUR}h"
|
|
195
|
+
return f"{minutes}m"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Codex sandbox configuration shared by turns and the doctor probe."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from shikiru.plugins.agents.codex.options import (
|
|
9
|
+
CONFIG_FLAG,
|
|
10
|
+
SANDBOX_MODE_CONFIG,
|
|
11
|
+
WORKSPACE_WRITE_SANDBOX,
|
|
12
|
+
WRITABLE_ROOTS_CONFIG,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sandbox_config_args(roots: Sequence[Path]) -> list[str]:
|
|
21
|
+
"""Return the `-c` arguments that make `roots` writable under `workspace-write`.
|
|
22
|
+
|
|
23
|
+
Configuration overrides are the one mechanism `codex exec` and `codex exec resume` share;
|
|
24
|
+
`exec resume` has no `--sandbox` or `--add-dir`.
|
|
25
|
+
"""
|
|
26
|
+
return [
|
|
27
|
+
CONFIG_FLAG,
|
|
28
|
+
f'{SANDBOX_MODE_CONFIG}="{WORKSPACE_WRITE_SANDBOX}"',
|
|
29
|
+
CONFIG_FLAG,
|
|
30
|
+
f"{WRITABLE_ROOTS_CONFIG}={json.dumps([str(root) for root in roots])}",
|
|
31
|
+
]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Free Codex sandbox probe: a real `codex sandbox` run in a scratch linked worktree."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from shikiru.agents.contracts import CheckResult
|
|
12
|
+
from shikiru.errors import ShikiruError
|
|
13
|
+
from shikiru.plugins.agents.codex.options import (
|
|
14
|
+
CODEX_COMMAND,
|
|
15
|
+
END_OF_OPTIONS_FLAG,
|
|
16
|
+
GIT_COMMAND,
|
|
17
|
+
GIT_DIRS_ARGV,
|
|
18
|
+
SANDBOX_PROBE_PARENT,
|
|
19
|
+
SANDBOX_PROBE_PREFIX,
|
|
20
|
+
SANDBOX_PROBE_TIMEOUT_SECONDS,
|
|
21
|
+
)
|
|
22
|
+
from shikiru.plugins.agents.codex.sandbox import sandbox_config_args
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from shikiru.process import SubprocessRunner
|
|
26
|
+
|
|
27
|
+
SANDBOX_CHECK = "sandbox"
|
|
28
|
+
_SANDBOX_SUBCOMMAND = "sandbox"
|
|
29
|
+
_WORKTREE_DIR = "worktree"
|
|
30
|
+
_RUN_DIR = "run"
|
|
31
|
+
_MAIN_BRANCH = "main"
|
|
32
|
+
_PROBE_BRANCH = "probe"
|
|
33
|
+
_PROBE_FILE = ".shikiru-probe"
|
|
34
|
+
_GIT_IDENTITY = ("-c", "user.name=shikiru", "-c", "user.email=shikiru@localhost")
|
|
35
|
+
_TOUCH_SCRIPT = (
|
|
36
|
+
'for directory; do touch "$directory/'
|
|
37
|
+
+ _PROBE_FILE
|
|
38
|
+
+ '" 2>/dev/null || echo "$directory"; done'
|
|
39
|
+
)
|
|
40
|
+
_SHELL_ARGV = ("sh", "-c", _TOUCH_SCRIPT, "sh")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CodexSandboxProbe:
|
|
44
|
+
"""Check that a linked worktree's git directory and the run directory are writable."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, runner: SubprocessRunner) -> None:
|
|
47
|
+
self._runner = runner
|
|
48
|
+
|
|
49
|
+
async def check(self) -> CheckResult:
|
|
50
|
+
"""Run the probe outside `$TMPDIR` and report every root the sandbox kept read-only."""
|
|
51
|
+
parent = Path.home().joinpath(*SANDBOX_PROBE_PARENT)
|
|
52
|
+
await asyncio.to_thread(parent.mkdir, parents=True, exist_ok=True)
|
|
53
|
+
base = Path(
|
|
54
|
+
await asyncio.to_thread(tempfile.mkdtemp, prefix=SANDBOX_PROBE_PREFIX, dir=parent)
|
|
55
|
+
)
|
|
56
|
+
try:
|
|
57
|
+
return await self._probe(base)
|
|
58
|
+
except (OSError, ShikiruError) as error:
|
|
59
|
+
return CheckResult(SANDBOX_CHECK, False, str(error))
|
|
60
|
+
finally:
|
|
61
|
+
await asyncio.to_thread(shutil.rmtree, base, ignore_errors=True)
|
|
62
|
+
|
|
63
|
+
async def _probe(self, base: Path) -> CheckResult:
|
|
64
|
+
repository, worktree, run_dir = base / "repo", base / _WORKTREE_DIR, base / _RUN_DIR
|
|
65
|
+
run_dir.mkdir()
|
|
66
|
+
await self._git(base, "init", "-q", "-b", _MAIN_BRANCH, str(repository))
|
|
67
|
+
await self._git(repository, *_GIT_IDENTITY, "commit", "-q", "--allow-empty", "-m", "probe")
|
|
68
|
+
await self._git(repository, "worktree", "add", "-q", "-b", _PROBE_BRANCH, str(worktree))
|
|
69
|
+
git_dirs = (await self._git(worktree, *GIT_DIRS_ARGV[1:])).split()
|
|
70
|
+
targets = (*git_dirs, str(run_dir))
|
|
71
|
+
result = await self._runner.run(
|
|
72
|
+
(
|
|
73
|
+
CODEX_COMMAND,
|
|
74
|
+
_SANDBOX_SUBCOMMAND,
|
|
75
|
+
*sandbox_config_args((run_dir, *map(Path, git_dirs))),
|
|
76
|
+
END_OF_OPTIONS_FLAG,
|
|
77
|
+
*_SHELL_ARGV,
|
|
78
|
+
*targets,
|
|
79
|
+
),
|
|
80
|
+
timeout=SANDBOX_PROBE_TIMEOUT_SECONDS,
|
|
81
|
+
cwd=worktree,
|
|
82
|
+
check=False,
|
|
83
|
+
)
|
|
84
|
+
if result.returncode != 0:
|
|
85
|
+
return CheckResult(SANDBOX_CHECK, False, result.stderr.strip())
|
|
86
|
+
denied = result.stdout.split()
|
|
87
|
+
if denied:
|
|
88
|
+
return CheckResult(
|
|
89
|
+
SANDBOX_CHECK, False, f"not writable under the sandbox: {', '.join(denied)}"
|
|
90
|
+
)
|
|
91
|
+
return CheckResult(SANDBOX_CHECK, True, "worktree git directory and run directory writable")
|
|
92
|
+
|
|
93
|
+
async def _git(self, cwd: Path, *args: str) -> str:
|
|
94
|
+
result = await self._runner.run(
|
|
95
|
+
(GIT_COMMAND, *args), timeout=SANDBOX_PROBE_TIMEOUT_SECONDS, cwd=cwd, check=True
|
|
96
|
+
)
|
|
97
|
+
return result.stdout
|