claude-dev-env 2.12.0 → 2.13.0
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/_shared/pr-loop/scripts/code_rules_gate.py +2 -3
- package/_shared/pr-loop/scripts/test_code_rules_gate.py +0 -26
- package/agents/CLAUDE.md +1 -2
- package/agents/pr-description-writer.md +11 -81
- package/bin/install.mjs +48 -9
- package/bin/install.prune.test.mjs +26 -0
- package/bin/install.settings-defaults.test.mjs +60 -33
- package/bin/install.test.mjs +2 -4
- package/commands/sr-loop.md +9 -5
- package/docs/CLAUDE.md +0 -1
- package/docs/references/CLAUDE.md +0 -1
- package/hooks/CLAUDE.md +1 -1
- package/hooks/blocking/CLAUDE.md +2 -27
- package/hooks/blocking/config/CLAUDE.md +1 -13
- package/hooks/blocking/conventional_pr_title_gate.py +1 -2
- package/hooks/blocking/send_user_file_open_locally_blocker.py +1 -1
- package/hooks/blocking/stop_dispatcher.py +4 -4
- package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +0 -3
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +6 -7
- package/hooks/blocking/test_send_user_file_open_locally_blocker.py +4 -3
- package/hooks/blocking/test_shared_stdin_adoption.py +0 -2
- package/hooks/diagnostic/CLAUDE.md +3 -3
- package/hooks/diagnostic/hook_log_extractor.py +2 -36
- package/hooks/diagnostic/hook_log_stop_wrapper.py +6 -155
- package/hooks/diagnostic/test_hook_log_extractor.py +8 -21
- package/hooks/diagnostic/test_hook_log_stop_wrapper.py +3 -331
- package/hooks/git-hooks/git_hooks_constants/__init__.py +0 -6
- package/hooks/git-hooks/pre_push.py +3 -85
- package/hooks/git-hooks/test_pre_push.py +0 -130
- package/hooks/hooks.json +0 -35
- package/hooks/hooks_constants/CLAUDE.md +0 -9
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +0 -11
- package/hooks/hooks_constants/convergence_branch_constants.py +0 -1
- package/hooks/hooks_constants/fable_spawn_gate_constants.py +3 -4
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +0 -8
- package/hooks/hooks_constants/send_user_file_open_locally_blocker_constants.py +1 -6
- package/hooks/hooks_constants/stop_dispatcher_constants.py +0 -1
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -26
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +0 -5
- package/hooks/hooks_constants/test_stop_dispatcher_constants.py +0 -1
- package/hooks/lifecycle/CLAUDE.md +1 -3
- package/package.json +1 -1
- package/rules/CLAUDE.md +0 -2
- package/rules/gh-cli-conventions.md +0 -1
- package/rules/git-workflow.md +1 -3
- package/scripts/CLAUDE.md +0 -1
- package/scripts/_code_review_test_support.py +0 -95
- package/scripts/dev_env_scripts_constants/code_review_constants.py +9 -98
- package/scripts/dev_env_scripts_constants/test_code_review_constants.py +1 -44
- package/scripts/invoke_code_review.py +8 -451
- package/scripts/test_invoke_code_review.py +5 -61
- package/scripts/test_invoke_code_review_cli.py +1 -45
- package/scripts/test_invoke_code_review_contract.py +1 -118
- package/settings.json +1 -10
- package/skills/CLAUDE.md +8 -0
- package/skills/autoconverge/reference/CLAUDE.md +1 -1
- package/skills/autoconverge/reference/convergence.md +7 -7
- package/skills/autoconverge/reference/gotchas.md +3 -10
- package/skills/autoconverge/workflow/converge.contract.test.mjs +53 -1388
- package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +4 -3
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +33 -493
- package/skills/autoconverge/workflow/converge.merge-conflict.test.mjs +15 -97
- package/skills/autoconverge/workflow/converge.mjs +201 -238
- package/skills/autoconverge/workflow/converge.precatch.test.mjs +1 -1
- package/skills/bugteam/CONSTRAINTS.md +1 -1
- package/skills/bugteam/SKILL.md +9 -0
- package/skills/bugteam/reference/audit-and-teammates.md +10 -6
- package/skills/bugteam/test_skill_additions.py +9 -0
- package/skills/comments/SKILL.md +65 -0
- package/skills/descriptions/SKILL.md +51 -0
- package/skills/emergencies/SKILL.md +42 -0
- package/skills/imagegen/SKILL.md +21 -0
- package/skills/imagegen/scripts/config/__init__.py +1 -0
- package/skills/imagegen/scripts/config/constants.py +28 -0
- package/skills/imagegen/scripts/imagegen.py +45 -0
- package/skills/imagegen/scripts/imagegen_core.py +528 -0
- package/skills/imagegen/scripts/test_imagegen.py +204 -0
- package/skills/orchestrator/SKILL.md +2 -4
- package/skills/plan-to-pr/SKILL.md +6 -7
- package/skills/plan-to-pr/reference/final-validation-tasks.md +1 -1
- package/skills/plan-to-pr/reference/process-inventory.md +2 -2
- package/skills/plan-to-pr/reference/review-loop.md +2 -2
- package/skills/plan-to-pr/reference/run-record.schema.json +1 -1
- package/skills/plan-to-pr/reference/task-seeds.md +1 -1
- package/skills/plan-to-pr/reference/task-ticket.md +3 -4
- package/skills/plan-to-pr/scripts/config/constants.py +1 -2
- package/skills/plan-to-pr/scripts/test_validate_protocol.py +1 -1
- package/skills/plan-to-pr/test_skill_contract.py +2 -2
- package/skills/pr-converge/SKILL.md +13 -16
- package/skills/pr-converge/reference/progress-checklist.md +6 -5
- package/skills/pr-converge/reference/state-schema.md +1 -27
- package/skills/pr-loop-cloud-transport/SKILL.md +1 -1
- package/skills/pr-loop-cloud-transport/reference/identity-and-hooks.md +3 -3
- package/skills/pr-loop-lifecycle/SKILL.md +1 -1
- package/skills/pr-loop-lifecycle/reference/teardown-publish-permissions.md +1 -1
- package/skills/prototype/SKILL.md +4 -4
- package/skills/prototype/reference/honest-limitations.md +4 -4
- package/skills/prototype/reference/promotion-tasks.md +2 -2
- package/skills/prototype/workflows/promotion.md +4 -6
- package/skills/reviews/SKILL.md +85 -0
- package/skills/small-cl/SKILL.md +52 -0
- package/agents/code-verifier.md +0 -74
- package/docs/PR_DESCRIPTION_GUIDE.md +0 -157
- package/docs/references/code-review-enforcement.md +0 -132
- package/hooks/blocking/code_review_enforcement_config_bootstrap.py +0 -53
- package/hooks/blocking/code_review_gate_deny.py +0 -74
- package/hooks/blocking/code_review_pr_create_gate.py +0 -198
- package/hooks/blocking/code_review_push_gate.py +0 -145
- package/hooks/blocking/code_review_stamp_directory_write_blocker.py +0 -348
- package/hooks/blocking/code_review_stamp_store.py +0 -233
- package/hooks/blocking/code_review_stamp_write_blocker_parts/__init__.py +0 -7
- package/hooks/blocking/code_review_stamp_write_blocker_parts/conftest.py +0 -15
- package/hooks/blocking/code_review_stamp_write_blocker_parts/obfuscated_stamp_path_reference.py +0 -212
- package/hooks/blocking/code_review_stamp_write_blocker_parts/split_directory_change_into_stamp.py +0 -138
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_obfuscated_stamp_path_reference.py +0 -49
- package/hooks/blocking/code_review_stamp_write_blocker_parts/test_split_directory_change_into_stamp.py +0 -38
- package/hooks/blocking/code_verifier_spawn_preflight_gate.py +0 -483
- package/hooks/blocking/config/code_review_enforcement_constants.py +0 -143
- package/hooks/blocking/config/test_code_review_enforcement_constants.py +0 -166
- package/hooks/blocking/config/verified_commit_constants.py +0 -160
- package/hooks/blocking/config/verified_commit_context_constants.py +0 -21
- package/hooks/blocking/config/verified_commit_gate_output_constants.py +0 -14
- package/hooks/blocking/conftest.py +0 -123
- package/hooks/blocking/convergence_gate_blocker.py +0 -310
- package/hooks/blocking/pr_converge_bugteam_enforcer.py +0 -170
- package/hooks/blocking/pr_description_body_audit.py +0 -148
- package/hooks/blocking/pr_description_command_parser.py +0 -237
- package/hooks/blocking/pr_description_enforcer.py +0 -227
- package/hooks/blocking/pr_description_pr_number.py +0 -155
- package/hooks/blocking/pr_description_proof_of_work.py +0 -385
- package/hooks/blocking/pr_description_readability.py +0 -364
- package/hooks/blocking/reviewer_spawn_gate.py +0 -182
- package/hooks/blocking/test_code_review_enforcement_config_bootstrap.py +0 -62
- package/hooks/blocking/test_code_review_gate_deny.py +0 -54
- package/hooks/blocking/test_code_review_pr_create_gate.py +0 -199
- package/hooks/blocking/test_code_review_push_gate.py +0 -205
- package/hooks/blocking/test_code_review_stamp_directory_write_blocker.py +0 -199
- package/hooks/blocking/test_code_review_stamp_store.py +0 -205
- package/hooks/blocking/test_code_verifier_spawn_preflight_gate.py +0 -710
- package/hooks/blocking/test_code_verifier_tools_contract.py +0 -28
- package/hooks/blocking/test_convergence_gate_blocker.py +0 -428
- package/hooks/blocking/test_pr_converge_bugteam_enforcer.py +0 -311
- package/hooks/blocking/test_pr_converge_bugteam_enforcer_state_tolerance.py +0 -184
- package/hooks/blocking/test_pr_description_enforcer.py +0 -221
- package/hooks/blocking/test_pr_description_enforcer_body_audit.py +0 -247
- package/hooks/blocking/test_pr_description_enforcer_body_rules.py +0 -493
- package/hooks/blocking/test_pr_description_enforcer_command_parser.py +0 -366
- package/hooks/blocking/test_pr_description_enforcer_pr_number.py +0 -159
- package/hooks/blocking/test_pr_description_enforcer_proof_gate.py +0 -175
- package/hooks/blocking/test_pr_description_enforcer_readability.py +0 -443
- package/hooks/blocking/test_pr_description_proof_of_work.py +0 -313
- package/hooks/blocking/test_reviewer_spawn_gate.py +0 -230
- package/hooks/blocking/test_verdict_directory_write_blocker.py +0 -804
- package/hooks/blocking/test_verification_verdict_store.py +0 -974
- package/hooks/blocking/test_verified_commit_config_bootstrap.py +0 -67
- package/hooks/blocking/test_verified_commit_docs_delta.py +0 -176
- package/hooks/blocking/test_verified_commit_gate.py +0 -581
- package/hooks/blocking/test_verified_commit_gate_additional_context.py +0 -134
- package/hooks/blocking/test_verified_commit_message_accuracy_blocker.py +0 -131
- package/hooks/blocking/test_verifier_verdict_minter.py +0 -299
- package/hooks/blocking/tests/test_verified_commit_gate.py +0 -41
- package/hooks/blocking/verdict_directory_write_blocker.py +0 -687
- package/hooks/blocking/verification_verdict_store.py +0 -1039
- package/hooks/blocking/verified_commit_config_bootstrap.py +0 -63
- package/hooks/blocking/verified_commit_gate.py +0 -113
- package/hooks/blocking/verified_commit_gate_parts/CLAUDE.md +0 -28
- package/hooks/blocking/verified_commit_gate_parts/__init__.py +0 -1
- package/hooks/blocking/verified_commit_gate_parts/command_tokenization.py +0 -174
- package/hooks/blocking/verified_commit_gate_parts/deny_payload.py +0 -53
- package/hooks/blocking/verified_commit_gate_parts/deny_reason.py +0 -80
- package/hooks/blocking/verified_commit_gate_parts/directory_resolution.py +0 -170
- package/hooks/blocking/verified_commit_gate_parts/gated_invocations.py +0 -217
- package/hooks/blocking/verified_commit_gate_parts/tests/conftest.py +0 -10
- package/hooks/blocking/verified_commit_gate_parts/tests/test_command_tokenization.py +0 -94
- package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_payload.py +0 -17
- package/hooks/blocking/verified_commit_gate_parts/tests/test_deny_reason.py +0 -38
- package/hooks/blocking/verified_commit_gate_parts/tests/test_directory_resolution.py +0 -71
- package/hooks/blocking/verified_commit_gate_parts/tests/test_gated_invocations.py +0 -96
- package/hooks/blocking/verified_commit_message_accuracy_blocker.py +0 -167
- package/hooks/blocking/verifier_verdict_minter.py +0 -280
- package/hooks/hooks_constants/code_verifier_spawn_preflight_gate_constants.py +0 -64
- package/hooks/hooks_constants/convergence_gate_blocker_constants.py +0 -53
- package/hooks/hooks_constants/pr_converge_bugteam_enforcer_constants.py +0 -55
- package/hooks/hooks_constants/pr_converge_bugteam_enforcer_state.py +0 -67
- package/hooks/hooks_constants/pr_description_enforcer_constants.py +0 -167
- package/hooks/hooks_constants/pr_description_proof_of_work_constants.py +0 -107
- package/hooks/hooks_constants/reviewer_spawn_gate_constants.py +0 -41
- package/hooks/hooks_constants/test_pr_description_enforcer_constants.py +0 -292
- package/hooks/lifecycle/pr_converge_bugteam_skill_tracker.py +0 -198
- package/hooks/lifecycle/test_pr_converge_bugteam_skill_tracker.py +0 -283
- package/rules/proof-of-work-pr-comments.md +0 -22
- package/rules/verified-commit-gate-skip.md +0 -4
- package/scripts/Show-Asset.ps1 +0 -106
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
"""Exact-resolution image generation with secret-safe provider boundaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import secrets
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from io import BytesIO
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from PIL import Image
|
|
22
|
+
|
|
23
|
+
from config.constants import (
|
|
24
|
+
ALL_SUPPORTED_BACKENDS,
|
|
25
|
+
ALL_SUPPORTED_RESIZE_POLICIES,
|
|
26
|
+
CODEX_TIMEOUT_SECONDS,
|
|
27
|
+
CODEX_TOOL_NAME,
|
|
28
|
+
DEFAULT_PROVIDER_EDGE,
|
|
29
|
+
JSON_INDENT_LEVEL,
|
|
30
|
+
MAXIMUM_CODEX_ARTIFACT_COUNT,
|
|
31
|
+
MAXIMUM_CODEX_OUTPUT_BYTES,
|
|
32
|
+
MAXIMUM_IMAGE_EDGE,
|
|
33
|
+
MAXIMUM_PIXEL_COUNT,
|
|
34
|
+
MAXIMUM_PROVIDER_BYTES,
|
|
35
|
+
MINIMUM_DIMENSION,
|
|
36
|
+
MINIMUM_PIXEL_COUNT,
|
|
37
|
+
OPENAI_API_KEY_NAME,
|
|
38
|
+
OPENAI_API_URL,
|
|
39
|
+
OPENAI_MODEL,
|
|
40
|
+
OPENAI_OUTPUT_FORMAT,
|
|
41
|
+
OPENAI_TIMEOUT_SECONDS,
|
|
42
|
+
PNG_FORMAT,
|
|
43
|
+
PNG_SUFFIX,
|
|
44
|
+
RECEIPT_SUFFIX,
|
|
45
|
+
SIZE_COMPONENT_COUNT,
|
|
46
|
+
SIZE_MULTIPLE,
|
|
47
|
+
SIZE_SEPARATOR,
|
|
48
|
+
TEMPORARY_DIRECTORY_PREFIX,
|
|
49
|
+
TEMPORARY_FILE_TOKEN_BYTES,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ImagegenError(RuntimeError):
|
|
54
|
+
"""Raised when generation cannot produce a truthful receipt."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class ImageSize:
|
|
59
|
+
"""A validated square image size."""
|
|
60
|
+
|
|
61
|
+
width: int
|
|
62
|
+
height: int
|
|
63
|
+
|
|
64
|
+
def as_text(self) -> str:
|
|
65
|
+
return f"{self.width}{SIZE_SEPARATOR}{self.height}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class ProviderArtifact:
|
|
70
|
+
"""Provider bytes, requested dimensions, and observed source dimensions."""
|
|
71
|
+
|
|
72
|
+
image_bytes: bytes
|
|
73
|
+
source_size: ImageSize
|
|
74
|
+
provider_requested_size: ImageSize | None
|
|
75
|
+
model_or_tool: str
|
|
76
|
+
credential_source: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
Transport = Callable[[str, bytes, Mapping[str, str]], bytes]
|
|
80
|
+
Runner = Callable[[Sequence[str], Path, Mapping[str, str]], None]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_size(size_text: str) -> ImageSize:
|
|
84
|
+
"""Parse a square final artifact size within the output safety limits.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
size_text: Width and height separated by ``x``.
|
|
88
|
+
Returns:
|
|
89
|
+
The parsed final artifact size.
|
|
90
|
+
Raises:
|
|
91
|
+
ImagegenError: If the size is malformed or exceeds the safety limits.
|
|
92
|
+
"""
|
|
93
|
+
pieces = size_text.lower().split(SIZE_SEPARATOR)
|
|
94
|
+
if len(pieces) != SIZE_COMPONENT_COUNT or not all(piece.isdigit() for piece in pieces):
|
|
95
|
+
raise ImagegenError("size must use WIDTHxHEIGHT notation")
|
|
96
|
+
image_size = ImageSize(int(pieces[0]), int(pieces[1]))
|
|
97
|
+
validate_final_size(image_size)
|
|
98
|
+
return image_size
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def validate_final_size(image_size: ImageSize) -> None:
|
|
102
|
+
"""Validate square final dimensions for both CLI and library callers.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
image_size: Candidate final dimensions.
|
|
106
|
+
Raises:
|
|
107
|
+
ImagegenError: If dimensions are non-square or exceed output limits.
|
|
108
|
+
"""
|
|
109
|
+
if (
|
|
110
|
+
image_size.width < MINIMUM_DIMENSION
|
|
111
|
+
or image_size.height < MINIMUM_DIMENSION
|
|
112
|
+
or image_size.width != image_size.height
|
|
113
|
+
or image_size.width > MAXIMUM_IMAGE_EDGE
|
|
114
|
+
or image_size.width * image_size.height > MAXIMUM_PIXEL_COUNT
|
|
115
|
+
):
|
|
116
|
+
raise ImagegenError("size exceeds output limits")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def is_native_openai_size(image_size: ImageSize) -> bool:
|
|
120
|
+
"""Return whether GPT Image 2 accepts the requested provider size.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
image_size: Candidate provider dimensions.
|
|
124
|
+
Returns:
|
|
125
|
+
True when the dimensions satisfy the GPT Image 2 contract.
|
|
126
|
+
"""
|
|
127
|
+
return (
|
|
128
|
+
image_size.width == image_size.height
|
|
129
|
+
and image_size.width % SIZE_MULTIPLE == 0
|
|
130
|
+
and image_size.height % SIZE_MULTIPLE == 0
|
|
131
|
+
and image_size.width <= MAXIMUM_IMAGE_EDGE
|
|
132
|
+
and image_size.width * image_size.height >= MINIMUM_PIXEL_COUNT
|
|
133
|
+
and image_size.width * image_size.height <= MAXIMUM_PIXEL_COUNT
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def select_provider_size(final_size: ImageSize) -> ImageSize:
|
|
138
|
+
"""Select a valid GPT Image 2 size with the final aspect ratio.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
final_size: Requested final artifact dimensions.
|
|
142
|
+
Returns:
|
|
143
|
+
A valid native provider size with the same aspect ratio.
|
|
144
|
+
"""
|
|
145
|
+
if is_native_openai_size(final_size):
|
|
146
|
+
return final_size
|
|
147
|
+
return ImageSize(DEFAULT_PROVIDER_EDGE, DEFAULT_PROVIDER_EDGE)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def require_png_within_limits(image: Image.Image) -> None:
|
|
151
|
+
"""Require a PNG whose pixel count stays within the safety limit.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
image: Open Pillow image.
|
|
155
|
+
Raises:
|
|
156
|
+
ImagegenError: If the format is not PNG or the pixel count is too high.
|
|
157
|
+
"""
|
|
158
|
+
if image.format != PNG_FORMAT:
|
|
159
|
+
raise ImagegenError("provider image must be PNG")
|
|
160
|
+
if image.width * image.height > MAXIMUM_PIXEL_COUNT:
|
|
161
|
+
raise ImagegenError("provider image exceeds the pixel limit")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def require_provider_byte_limit(image_bytes: bytes) -> None:
|
|
165
|
+
"""Require encoded image bytes stay within the provider byte limit.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
image_bytes: Encoded image payload.
|
|
169
|
+
Raises:
|
|
170
|
+
ImagegenError: If the payload is larger than the allowed byte limit.
|
|
171
|
+
"""
|
|
172
|
+
if len(image_bytes) > MAXIMUM_PROVIDER_BYTES:
|
|
173
|
+
raise ImagegenError("provider image exceeds the byte limit")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def decode_image(image_bytes: bytes) -> ImageSize:
|
|
177
|
+
"""Decode image bytes with Pillow and return observed dimensions.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
image_bytes: Encoded image bytes from a provider or transformation.
|
|
181
|
+
Returns:
|
|
182
|
+
Decoded image dimensions.
|
|
183
|
+
Raises:
|
|
184
|
+
ImagegenError: If bytes are oversized, malformed, or exceed pixel limits.
|
|
185
|
+
"""
|
|
186
|
+
require_provider_byte_limit(image_bytes)
|
|
187
|
+
try:
|
|
188
|
+
with Image.open(BytesIO(image_bytes)) as image:
|
|
189
|
+
require_png_within_limits(image)
|
|
190
|
+
image.verify()
|
|
191
|
+
with Image.open(BytesIO(image_bytes)) as image:
|
|
192
|
+
image.load()
|
|
193
|
+
return ImageSize(*image.size)
|
|
194
|
+
except ImagegenError:
|
|
195
|
+
raise
|
|
196
|
+
except (OSError, SyntaxError, ValueError, Image.DecompressionBombError) as error:
|
|
197
|
+
raise ImagegenError("provider returned an invalid image") from error
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def build_openai_transport() -> Transport:
|
|
201
|
+
"""Build the standard-library HTTPS transport.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
A transport callable for the OpenAI image-generation endpoint.
|
|
205
|
+
"""
|
|
206
|
+
def send_request(url: str, body: bytes, all_headers: Mapping[str, str]) -> bytes:
|
|
207
|
+
"""Send one authenticated request.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
url: HTTPS endpoint.
|
|
211
|
+
body: JSON request bytes.
|
|
212
|
+
all_headers: Request headers, including authentication.
|
|
213
|
+
Returns:
|
|
214
|
+
Raw endpoint bytes.
|
|
215
|
+
Raises:
|
|
216
|
+
ImagegenError: If the HTTPS request fails.
|
|
217
|
+
"""
|
|
218
|
+
request = urllib.request.Request(url, data=body, headers=dict(all_headers), method="POST")
|
|
219
|
+
try:
|
|
220
|
+
with urllib.request.urlopen(request, timeout=OPENAI_TIMEOUT_SECONDS) as http_reply:
|
|
221
|
+
return http_reply.read()
|
|
222
|
+
except (OSError, TimeoutError, urllib.error.URLError) as error:
|
|
223
|
+
raise ImagegenError("OpenAI image request failed") from error
|
|
224
|
+
|
|
225
|
+
return send_request
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def request_openai_image(prompt: str, requested_size: ImageSize, transport: Transport) -> ProviderArtifact:
|
|
229
|
+
"""Request a GPT Image 2 artifact using the environment API key.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
prompt: Image-generation prompt.
|
|
233
|
+
requested_size: Native provider dimensions.
|
|
234
|
+
transport: Injected HTTPS transport.
|
|
235
|
+
Returns:
|
|
236
|
+
Provider artifact with observed source dimensions.
|
|
237
|
+
Raises:
|
|
238
|
+
ImagegenError: If credentials, transport, or provider bytes are invalid.
|
|
239
|
+
"""
|
|
240
|
+
api_key = os.environ.get(OPENAI_API_KEY_NAME)
|
|
241
|
+
if not api_key:
|
|
242
|
+
raise ImagegenError("OPENAI_API_KEY is required for openai-api")
|
|
243
|
+
request_body = json.dumps({"model": OPENAI_MODEL, "prompt": prompt, "size": requested_size.as_text(), "output_format": OPENAI_OUTPUT_FORMAT}).encode()
|
|
244
|
+
all_headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
245
|
+
try:
|
|
246
|
+
api_payload = json.loads(transport(OPENAI_API_URL, request_body, all_headers))
|
|
247
|
+
image_records = api_payload.get("data", [])
|
|
248
|
+
image_record = image_records[0] if image_records else {}
|
|
249
|
+
if image_record.get("b64_json"):
|
|
250
|
+
image_bytes = base64.b64decode(image_record["b64_json"], validate=True)
|
|
251
|
+
elif image_record.get("url"):
|
|
252
|
+
image_bytes = download_https_image(image_record["url"])
|
|
253
|
+
else:
|
|
254
|
+
raise ImagegenError("OpenAI returned no image artifact")
|
|
255
|
+
except ImagegenError:
|
|
256
|
+
raise
|
|
257
|
+
except (AttributeError, binascii.Error, IndexError, KeyError, OSError, TypeError, ValueError, urllib.error.URLError, json.JSONDecodeError) as error:
|
|
258
|
+
raise ImagegenError("OpenAI returned an invalid image response") from error
|
|
259
|
+
return ProviderArtifact(image_bytes, decode_image(image_bytes), requested_size, OPENAI_MODEL, OPENAI_API_KEY_NAME)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class _RejectRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
263
|
+
"""Refuse HTTP redirects so an HTTPS URL cannot land on a weaker scheme."""
|
|
264
|
+
|
|
265
|
+
def redirect_request(
|
|
266
|
+
self,
|
|
267
|
+
request: urllib.request.Request,
|
|
268
|
+
file_pointer: object,
|
|
269
|
+
status_code: int,
|
|
270
|
+
message: str,
|
|
271
|
+
all_headers: Mapping[str, str],
|
|
272
|
+
new_url: str,
|
|
273
|
+
) -> None:
|
|
274
|
+
del request, file_pointer, status_code, message, all_headers, new_url
|
|
275
|
+
raise ImagegenError("OpenAI image URL redirects are not allowed")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def download_https_image(image_url: str) -> bytes:
|
|
279
|
+
"""Download provider image bytes from an HTTPS URL only.
|
|
280
|
+
|
|
281
|
+
Args:
|
|
282
|
+
image_url: Provider-returned image location.
|
|
283
|
+
Returns:
|
|
284
|
+
Raw image bytes capped by the provider byte limit.
|
|
285
|
+
Raises:
|
|
286
|
+
ImagegenError: If the URL is not HTTPS, redirects, download fails, or bytes exceed the limit.
|
|
287
|
+
"""
|
|
288
|
+
if not image_url.casefold().startswith("https://"):
|
|
289
|
+
raise ImagegenError("OpenAI image URL must use HTTPS")
|
|
290
|
+
request = urllib.request.Request(image_url, method="GET")
|
|
291
|
+
opener = urllib.request.build_opener(_RejectRedirectHandler)
|
|
292
|
+
try:
|
|
293
|
+
with opener.open(request, timeout=OPENAI_TIMEOUT_SECONDS) as image_download:
|
|
294
|
+
if not image_download.geturl().casefold().startswith("https://"):
|
|
295
|
+
raise ImagegenError("OpenAI image URL must use HTTPS")
|
|
296
|
+
image_bytes = image_download.read(MAXIMUM_PROVIDER_BYTES + 1)
|
|
297
|
+
except ImagegenError:
|
|
298
|
+
raise
|
|
299
|
+
except (OSError, TimeoutError, urllib.error.URLError) as error:
|
|
300
|
+
raise ImagegenError("OpenAI image download failed") from error
|
|
301
|
+
require_provider_byte_limit(image_bytes)
|
|
302
|
+
return image_bytes
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def build_oauth_environment() -> dict[str, str]:
|
|
306
|
+
"""Copy the child environment while removing the API credential."""
|
|
307
|
+
child_environment = dict(os.environ)
|
|
308
|
+
child_environment.pop(OPENAI_API_KEY_NAME, None)
|
|
309
|
+
return child_environment
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def discover_oauth_artifact(work_directory: Path, expected_path: Path) -> bytes:
|
|
313
|
+
"""Find exactly one PNG emitted by the Codex image tool.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
work_directory: Isolated directory scanned for provider artifacts.
|
|
317
|
+
expected_path: Exact PNG path declared in the Codex prompt.
|
|
318
|
+
Returns:
|
|
319
|
+
The single PNG artifact bytes.
|
|
320
|
+
Raises:
|
|
321
|
+
ImagegenError: If the provider emits zero, multiple, or unreadable PNGs.
|
|
322
|
+
"""
|
|
323
|
+
all_images = sorted(work_directory.rglob(f"*{PNG_SUFFIX}"))
|
|
324
|
+
if len(all_images) != MAXIMUM_CODEX_ARTIFACT_COUNT:
|
|
325
|
+
raise ImagegenError("Codex must produce exactly one PNG artifact")
|
|
326
|
+
if all_images[0] != expected_path:
|
|
327
|
+
raise ImagegenError("Codex artifact path does not match the declared path")
|
|
328
|
+
try:
|
|
329
|
+
return all_images[0].read_bytes()
|
|
330
|
+
except OSError as error:
|
|
331
|
+
raise ImagegenError("Codex artifact could not be read") from error
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def request_oauth_image(prompt: str, runner: Runner) -> ProviderArtifact:
|
|
335
|
+
"""Run Codex in an isolated directory and measure its PNG artifact.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
prompt: Image-generation prompt.
|
|
339
|
+
runner: Injected Codex process runner.
|
|
340
|
+
Returns:
|
|
341
|
+
Provider artifact with runtime-observed dimensions.
|
|
342
|
+
Raises:
|
|
343
|
+
ImagegenError: If Codex or its artifact contract fails.
|
|
344
|
+
"""
|
|
345
|
+
with tempfile.TemporaryDirectory(prefix=TEMPORARY_DIRECTORY_PREFIX) as temporary_directory:
|
|
346
|
+
work_directory = Path(temporary_directory)
|
|
347
|
+
artifact_path = work_directory / f"generated{PNG_SUFFIX}"
|
|
348
|
+
contract = f"Generate exactly one PNG image for this prompt: {prompt}. Save it at {artifact_path}."
|
|
349
|
+
codex_command = shutil.which("codex") or "codex"
|
|
350
|
+
runner((codex_command, "exec", "--skip-git-repo-check", "-s", "workspace-write", "-C", str(work_directory), contract), work_directory, build_oauth_environment())
|
|
351
|
+
image_bytes = discover_oauth_artifact(work_directory, artifact_path)
|
|
352
|
+
return ProviderArtifact(image_bytes, decode_image(image_bytes), None, CODEX_TOOL_NAME, "codex OAuth")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def resize_image(image_bytes: bytes, target_size: ImageSize) -> bytes:
|
|
356
|
+
"""Resize decoded image bytes to the requested dimensions as PNG.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
image_bytes: Valid source image bytes.
|
|
360
|
+
target_size: Final dimensions with the same aspect ratio.
|
|
361
|
+
Returns:
|
|
362
|
+
Resampled PNG bytes.
|
|
363
|
+
Raises:
|
|
364
|
+
ImagegenError: If the source is invalid or has a mismatched aspect ratio.
|
|
365
|
+
"""
|
|
366
|
+
require_provider_byte_limit(image_bytes)
|
|
367
|
+
try:
|
|
368
|
+
with Image.open(BytesIO(image_bytes)) as source_image:
|
|
369
|
+
require_png_within_limits(source_image)
|
|
370
|
+
source_image.load()
|
|
371
|
+
if source_image.width != source_image.height:
|
|
372
|
+
raise ImagegenError("provider image must be square")
|
|
373
|
+
resized_image = source_image.resize((target_size.width, target_size.height), Image.Resampling.LANCZOS)
|
|
374
|
+
destination = BytesIO()
|
|
375
|
+
resized_image.save(destination, format=PNG_FORMAT)
|
|
376
|
+
return destination.getvalue()
|
|
377
|
+
except ImagegenError:
|
|
378
|
+
raise
|
|
379
|
+
except (OSError, TypeError, ValueError, Image.DecompressionBombError) as error:
|
|
380
|
+
raise ImagegenError("image resize failed") from error
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def build_receipt(prompt: str, backend: str, artifact: ProviderArtifact, requested_size: ImageSize, final_bytes: bytes) -> dict[str, object]:
|
|
384
|
+
"""Build receipt fields from observed provider and final bytes.
|
|
385
|
+
|
|
386
|
+
Args:
|
|
387
|
+
prompt: Image-generation prompt.
|
|
388
|
+
backend: Selected provider backend.
|
|
389
|
+
artifact: Verified provider artifact.
|
|
390
|
+
requested_size: Requested final dimensions.
|
|
391
|
+
final_bytes: Verified final PNG bytes.
|
|
392
|
+
Returns:
|
|
393
|
+
JSON-compatible receipt fields.
|
|
394
|
+
Raises:
|
|
395
|
+
ImagegenError: If final bytes do not match the requested dimensions.
|
|
396
|
+
"""
|
|
397
|
+
final_size = decode_image(final_bytes)
|
|
398
|
+
if final_size != requested_size:
|
|
399
|
+
raise ImagegenError("final image dimensions do not match requested size")
|
|
400
|
+
transformation = "native" if artifact.source_size == requested_size else "resized"
|
|
401
|
+
transformations = [] if transformation == "native" else ["resize:lanczos"]
|
|
402
|
+
provider_size = artifact.provider_requested_size.as_text() if artifact.provider_requested_size else None
|
|
403
|
+
source_digest = hashlib.sha256(artifact.image_bytes).hexdigest()
|
|
404
|
+
final_digest = source_digest if transformation == "native" else hashlib.sha256(final_bytes).hexdigest()
|
|
405
|
+
return {"prompt_sha256": hashlib.sha256(prompt.encode()).hexdigest(), "backend": backend, "model_or_tool": artifact.model_or_tool, "requested_size": requested_size.as_text(), "provider_size": provider_size, "source_size": artifact.source_size.as_text(), "final_size": final_size.as_text(), "transformation": transformation, "transformations": transformations, "source_sha256": source_digest, "final_sha256": final_digest, "credential_source": artifact.credential_source}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def require_png_destination(destination_path: Path) -> None:
|
|
409
|
+
"""Require a destination path that ends with ``.png``.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
destination_path: Candidate final image path.
|
|
413
|
+
Raises:
|
|
414
|
+
ImagegenError: If the path does not use a ``.png`` suffix.
|
|
415
|
+
"""
|
|
416
|
+
if destination_path.suffix.casefold() != PNG_SUFFIX:
|
|
417
|
+
raise ImagegenError("destination must use a .png suffix")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def publish_artifact(destination_path: Path, image_bytes: bytes, all_receipt: dict[str, object], should_overwrite: bool) -> None:
|
|
421
|
+
"""Publish image and receipt atomically after overwrite checks.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
destination_path: Final PNG path.
|
|
425
|
+
image_bytes: Verified final PNG bytes.
|
|
426
|
+
all_receipt: Receipt fields describing the published bytes.
|
|
427
|
+
should_overwrite: Whether existing destinations may be replaced.
|
|
428
|
+
Raises:
|
|
429
|
+
ImagegenError: If publication cannot proceed safely.
|
|
430
|
+
"""
|
|
431
|
+
require_png_destination(destination_path)
|
|
432
|
+
receipt_path = destination_path.with_suffix(RECEIPT_SUFFIX)
|
|
433
|
+
if receipt_path == destination_path:
|
|
434
|
+
raise ImagegenError("destination and receipt paths must differ")
|
|
435
|
+
if not should_overwrite and (destination_path.exists() or receipt_path.exists()):
|
|
436
|
+
raise ImagegenError("output or receipt already exists; use --overwrite")
|
|
437
|
+
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
|
438
|
+
previous_image = destination_path.read_bytes() if destination_path.exists() else None
|
|
439
|
+
previous_receipt = receipt_path.read_bytes() if receipt_path.exists() else None
|
|
440
|
+
temporary_paths: list[Path] = []
|
|
441
|
+
was_image_replaced = False
|
|
442
|
+
was_receipt_replaced = False
|
|
443
|
+
try:
|
|
444
|
+
for each_destination, each_content in ((destination_path, image_bytes), (receipt_path, json.dumps(all_receipt, indent=JSON_INDENT_LEVEL).encode())):
|
|
445
|
+
temporary_path = each_destination.with_name(f".{each_destination.name}.{secrets.token_hex(TEMPORARY_FILE_TOKEN_BYTES)}.tmp")
|
|
446
|
+
temporary_paths.append(temporary_path)
|
|
447
|
+
temporary_path.write_bytes(each_content)
|
|
448
|
+
os.replace(temporary_paths[0], destination_path)
|
|
449
|
+
was_image_replaced = True
|
|
450
|
+
os.replace(temporary_paths[1], receipt_path)
|
|
451
|
+
was_receipt_replaced = True
|
|
452
|
+
except (OSError, TypeError, ValueError) as error:
|
|
453
|
+
if was_receipt_replaced:
|
|
454
|
+
_restore_path(receipt_path, previous_receipt)
|
|
455
|
+
if was_image_replaced:
|
|
456
|
+
_restore_path(destination_path, previous_image)
|
|
457
|
+
raise ImagegenError("artifact publication failed") from error
|
|
458
|
+
finally:
|
|
459
|
+
for each_temporary_path in temporary_paths:
|
|
460
|
+
try:
|
|
461
|
+
each_temporary_path.unlink(missing_ok=True)
|
|
462
|
+
except OSError:
|
|
463
|
+
pass
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _restore_path(path: Path, previous_bytes: bytes | None) -> None:
|
|
467
|
+
"""Restore one path during publication rollback."""
|
|
468
|
+
try:
|
|
469
|
+
if previous_bytes is None:
|
|
470
|
+
path.unlink(missing_ok=True)
|
|
471
|
+
else:
|
|
472
|
+
path.write_bytes(previous_bytes)
|
|
473
|
+
except OSError:
|
|
474
|
+
pass
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def generate_image(prompt: str, backend: str, requested_size: ImageSize, destination_path: Path, resize_policy: str, should_overwrite: bool, transport: Transport | None = None, runner: Runner | None = None) -> dict[str, object]:
|
|
478
|
+
"""Generate, verify, optionally resize, and publish one image.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
prompt: Image-generation prompt.
|
|
482
|
+
backend: ``openai-api`` or ``codex-oauth``.
|
|
483
|
+
requested_size: Final artifact dimensions.
|
|
484
|
+
destination_path: Final PNG path.
|
|
485
|
+
resize_policy: ``forbid`` or ``allow``.
|
|
486
|
+
should_overwrite: Whether existing destinations may be replaced.
|
|
487
|
+
transport: Optional injected OpenAI transport.
|
|
488
|
+
runner: Optional injected Codex runner.
|
|
489
|
+
Returns:
|
|
490
|
+
Published receipt fields.
|
|
491
|
+
Raises:
|
|
492
|
+
ImagegenError: If provider, validation, transformation, or publication fails.
|
|
493
|
+
"""
|
|
494
|
+
validate_final_size(requested_size)
|
|
495
|
+
require_png_destination(destination_path)
|
|
496
|
+
if backend not in ALL_SUPPORTED_BACKENDS or resize_policy not in ALL_SUPPORTED_RESIZE_POLICIES:
|
|
497
|
+
raise ImagegenError("unsupported backend or resize policy")
|
|
498
|
+
if backend == "openai-api":
|
|
499
|
+
provider_size = select_provider_size(requested_size)
|
|
500
|
+
if provider_size != requested_size and resize_policy == "forbid":
|
|
501
|
+
raise ImagegenError("requested size is not supported natively; use --resize-policy allow")
|
|
502
|
+
artifact = request_openai_image(prompt, provider_size, transport or build_openai_transport())
|
|
503
|
+
else:
|
|
504
|
+
artifact = request_oauth_image(prompt, runner or run_codex)
|
|
505
|
+
if artifact.source_size != requested_size and resize_policy == "forbid":
|
|
506
|
+
raise ImagegenError("provider dimensions differ from requested size")
|
|
507
|
+
final_bytes = artifact.image_bytes if artifact.source_size == requested_size else resize_image(artifact.image_bytes, requested_size)
|
|
508
|
+
receipt = build_receipt(prompt, backend, artifact, requested_size, final_bytes)
|
|
509
|
+
publish_artifact(destination_path, final_bytes, receipt, should_overwrite)
|
|
510
|
+
return receipt
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def run_codex(all_arguments: Sequence[str], work_directory: Path, all_environment: Mapping[str, str]) -> None:
|
|
514
|
+
"""Invoke Codex without exposing credentials in arguments.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
all_arguments: Executable and arguments for the Codex process.
|
|
518
|
+
work_directory: Isolated temporary working directory.
|
|
519
|
+
all_environment: Sanitized child environment.
|
|
520
|
+
Raises:
|
|
521
|
+
ImagegenError: If Codex cannot complete within the bounded process contract.
|
|
522
|
+
"""
|
|
523
|
+
try:
|
|
524
|
+
completed_process = subprocess.run(all_arguments, cwd=work_directory, env=all_environment, shell=False, check=True, capture_output=True, text=False, timeout=CODEX_TIMEOUT_SECONDS)
|
|
525
|
+
if len(completed_process.stdout) > MAXIMUM_CODEX_OUTPUT_BYTES or len(completed_process.stderr) > MAXIMUM_CODEX_OUTPUT_BYTES:
|
|
526
|
+
raise ImagegenError("Codex output exceeds the limit")
|
|
527
|
+
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
|
|
528
|
+
raise ImagegenError("Codex image generation failed") from error
|