clink-cli 0.2.1__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.
Files changed (141) hide show
  1. clink/__init__.py +19 -0
  2. clink/agents/__init__.py +30 -0
  3. clink/agents/base.py +70 -0
  4. clink/agents/claude.py +7 -0
  5. clink/agents/codex.py +7 -0
  6. clink/agents/gemini.py +7 -0
  7. clink/allowed_roots.py +356 -0
  8. clink/backend.py +765 -0
  9. clink/cli.py +594 -0
  10. clink/constants.py +78 -0
  11. clink/contracts.py +167 -0
  12. clink/files.py +70 -0
  13. clink/generation.py +274 -0
  14. clink/generation_capabilities.py +207 -0
  15. clink/generation_contracts.py +214 -0
  16. clink/generation_policy.py +174 -0
  17. clink/models.py +243 -0
  18. clink/native.py +409 -0
  19. clink/native_adapters.py +244 -0
  20. clink/native_contracts.py +68 -0
  21. clink/native_generation.py +421 -0
  22. clink/native_generation_claude_policy.py +59 -0
  23. clink/native_generation_contracts.py +264 -0
  24. clink/native_generation_families.py +218 -0
  25. clink/native_generation_family_policy.py +14 -0
  26. clink/native_generation_files.py +153 -0
  27. clink/native_generation_policy.py +82 -0
  28. clink/native_policy.py +86 -0
  29. clink/parsers/__init__.py +34 -0
  30. clink/parsers/agy.py +53 -0
  31. clink/parsers/agy_native.py +161 -0
  32. clink/parsers/agy_native_generation.py +189 -0
  33. clink/parsers/base.py +209 -0
  34. clink/parsers/claude.py +104 -0
  35. clink/parsers/claude_native.py +113 -0
  36. clink/parsers/claude_native_generation.py +346 -0
  37. clink/parsers/codex.py +172 -0
  38. clink/parsers/codex_generation.py +212 -0
  39. clink/parsers/codex_native.py +236 -0
  40. clink/parsers/codex_native_generation.py +367 -0
  41. clink/parsers/gemini.py +110 -0
  42. clink/parsers/grok.py +63 -0
  43. clink/parsers/grok_native.py +91 -0
  44. clink/parsers/grok_native_generation.py +157 -0
  45. clink/policy.py +180 -0
  46. clink/registry.py +544 -0
  47. clink/resources/CODEX-LICENSE +201 -0
  48. clink/resources/CODEX-NOTICE +6 -0
  49. clink/resources/PROVENANCE.md +16 -0
  50. clink/resources/codex-0.166.0-models-direct.json +1284 -0
  51. clink/resources/codex-0.166.0-models-generation.json +1269 -0
  52. clink/resources/native-agy-1.4.12-admission.json +746 -0
  53. clink/resources/native-claude-2.1.304-admission.json +99 -0
  54. clink/resources/native-generation-claude-2.1.304-admission.json +277 -0
  55. clink/resources/native-generation-codex-0.166.0-admission.json +309 -0
  56. clink/resources/native-generation-grok-1.0.70-admission.json +329 -0
  57. clink/resources/native-grok-1.0.70-admission.json +644 -0
  58. clink/runner.py +303 -0
  59. clink/setup.py +399 -0
  60. clink/tasks.py +695 -0
  61. clink_cli-0.2.1.dist-info/METADATA +225 -0
  62. clink_cli-0.2.1.dist-info/RECORD +141 -0
  63. clink_cli-0.2.1.dist-info/WHEEL +5 -0
  64. clink_cli-0.2.1.dist-info/entry_points.txt +3 -0
  65. clink_cli-0.2.1.dist-info/licenses/LICENSE +197 -0
  66. clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-LICENSE +201 -0
  67. clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-NOTICE +6 -0
  68. clink_cli-0.2.1.dist-info/top_level.txt +7 -0
  69. conf/__init__.py +1 -0
  70. conf/cli_clients/agy.json +40 -0
  71. conf/cli_clients/claude.json +39 -0
  72. conf/cli_clients/codex.json +40 -0
  73. conf/cli_clients/gemini.json +23 -0
  74. conf/cli_clients/grok.json +40 -0
  75. config.py +40 -0
  76. server.py +896 -0
  77. systemprompts/__init__.py +35 -0
  78. systemprompts/analyze_prompt.py +87 -0
  79. systemprompts/chat_prompt.py +57 -0
  80. systemprompts/clink/codex_codereviewer.txt +8 -0
  81. systemprompts/clink/default.txt +8 -0
  82. systemprompts/clink/default_codereviewer.txt +8 -0
  83. systemprompts/clink/default_planner.txt +7 -0
  84. systemprompts/clink/native_codereviewer.txt +1 -0
  85. systemprompts/clink/native_default.txt +1 -0
  86. systemprompts/clink/native_planner.txt +1 -0
  87. systemprompts/codereview_prompt.py +91 -0
  88. systemprompts/consensus_prompt.py +120 -0
  89. systemprompts/debug_prompt.py +150 -0
  90. systemprompts/docgen_prompt.py +252 -0
  91. systemprompts/generate_code_prompt.py +181 -0
  92. systemprompts/planner_prompt.py +127 -0
  93. systemprompts/precommit_prompt.py +179 -0
  94. systemprompts/refactor_prompt.py +323 -0
  95. systemprompts/secaudit_prompt.py +420 -0
  96. systemprompts/testgen_prompt.py +133 -0
  97. systemprompts/thinkdeep_prompt.py +55 -0
  98. systemprompts/tracer_prompt.py +145 -0
  99. tools/__init__.py +43 -0
  100. tools/analyze.py +583 -0
  101. tools/apilookup.py +97 -0
  102. tools/challenge.py +198 -0
  103. tools/chat.py +187 -0
  104. tools/clink.py +130 -0
  105. tools/codereview.py +724 -0
  106. tools/consensus.py +790 -0
  107. tools/debug.py +573 -0
  108. tools/docgen.py +577 -0
  109. tools/listmodels.py +48 -0
  110. tools/models.py +373 -0
  111. tools/planner.py +533 -0
  112. tools/precommit.py +776 -0
  113. tools/refactor.py +645 -0
  114. tools/secaudit.py +685 -0
  115. tools/shared/__init__.py +19 -0
  116. tools/shared/base_models.py +167 -0
  117. tools/shared/base_tool.py +659 -0
  118. tools/shared/exceptions.py +20 -0
  119. tools/shared/schema_builders.py +159 -0
  120. tools/simple/__init__.py +18 -0
  121. tools/simple/base.py +539 -0
  122. tools/testgen.py +513 -0
  123. tools/thinkdeep.py +586 -0
  124. tools/tracer.py +763 -0
  125. tools/version.py +53 -0
  126. tools/workflow/__init__.py +22 -0
  127. tools/workflow/base.py +448 -0
  128. tools/workflow/schema_builders.py +166 -0
  129. tools/workflow/workflow_mixin.py +1195 -0
  130. utils/__init__.py +21 -0
  131. utils/call_state.py +349 -0
  132. utils/client_info.py +293 -0
  133. utils/consensus_manifest.py +131 -0
  134. utils/conversation_memory.py +582 -0
  135. utils/env.py +111 -0
  136. utils/file_types.py +271 -0
  137. utils/file_utils.py +803 -0
  138. utils/image_utils.py +94 -0
  139. utils/security_config.py +163 -0
  140. utils/storage_backend.py +177 -0
  141. utils/token_utils.py +54 -0
clink/contracts.py ADDED
@@ -0,0 +1,167 @@
1
+ """Provider-independent, immutable contracts for advisory CLI delegation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import time
7
+ import uuid
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ class BackendError(RuntimeError):
14
+ """An explicit terminal backend failure with public, content-free diagnostics."""
15
+
16
+ def __init__(self, code: str, message: str, remedy: str = "", metadata: dict[str, Any] | None = None):
17
+ super().__init__(message)
18
+ self.code = code
19
+ self.remedy = remedy
20
+ self.metadata = metadata or {}
21
+
22
+ def to_dict(self) -> dict[str, Any]:
23
+ return {"code": self.code, "message": str(self), "remedy": self.remedy, **self.metadata}
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ClientSnapshot:
28
+ """Deep immutable launch configuration, independent of future registry changes."""
29
+
30
+ name: str
31
+ adapter: str
32
+ executable: tuple[str, ...]
33
+ working_dir: Path
34
+ allowed_roots: tuple[Path, ...]
35
+ config_args: tuple[str, ...] = ()
36
+ env: tuple[tuple[str, str], ...] = ()
37
+ timeout_seconds: int = 1800
38
+ parser: str = ""
39
+ default_model: str | None = None
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class RoleSnapshot:
44
+ """Resolved role expertise, captured before any asynchronous execution."""
45
+
46
+ name: str
47
+ prompt_text: str
48
+ role_args: tuple[str, ...] = ()
49
+ description: str | None = None
50
+ model: str | None = None
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class AdapterPolicy:
55
+ """Observed adapter admission, never inferred from installation or help alone."""
56
+
57
+ adapter: str
58
+ version: str | None = None
59
+ verified: bool = False
60
+ verification_status: str = "unverified"
61
+ images: bool = False
62
+ native_search: bool = False
63
+ file_references: bool = False
64
+ repository_instructions: str = "unverified"
65
+ read_scope: str = "unverified"
66
+ evidence: str | None = None
67
+ evidence_sha256: str | None = None
68
+ executable_sha256: str | None = None
69
+ recipe_sha256: str | None = None
70
+ resource_files: tuple[tuple[str, str], ...] = ()
71
+ supported_models: tuple[str, ...] = ()
72
+ native_default_model: str | None = None
73
+
74
+ def __post_init__(self) -> None:
75
+ object.__setattr__(self, "resource_files", tuple(tuple(pair) for pair in self.resource_files))
76
+ object.__setattr__(self, "supported_models", tuple(self.supported_models))
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class BackendTarget:
81
+ """A frozen client/role/model selection ready for common preflight."""
82
+
83
+ client: ClientSnapshot
84
+ role: RoleSnapshot
85
+ requested_model: str | None
86
+ effective_model: str | None
87
+ working_directory: Path
88
+ policy: AdapterPolicy
89
+ warnings: tuple[tuple[str, str], ...] = ()
90
+ default_origin: str = "native"
91
+
92
+ @property
93
+ def cli_name(self) -> str:
94
+ return self.client.name
95
+
96
+ @property
97
+ def role_prompt(self) -> str:
98
+ return self.role.prompt_text
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class CLIRequest:
103
+ """One immutable, bounded call; deadline includes upstream lock and queue waits."""
104
+
105
+ prompt: str
106
+ target: BackendTarget
107
+ system_prompt: str = ""
108
+ files: tuple[str, ...] = ()
109
+ images: tuple[str, ...] = ()
110
+ request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
111
+ admitted_at: float = field(default_factory=time.monotonic)
112
+ deadline: float | None = None
113
+ tool_name: str = "clink"
114
+ require_search: bool = False
115
+ context_manifest_json: str | None = None
116
+ history_turns: tuple[str, ...] = ()
117
+ history_omitted_turns: int = 0
118
+
119
+ def __post_init__(self) -> None:
120
+ if not isinstance(self.prompt, str) or not isinstance(self.system_prompt, str):
121
+ raise BackendError("invalid_config", "CLI prompt and tool instructions must be strings.")
122
+ if self.context_manifest_json is not None and not isinstance(self.context_manifest_json, str):
123
+ raise BackendError("invalid_config", "The context manifest must be immutable JSON text.")
124
+ if isinstance(self.files, str) or isinstance(self.images, str) or isinstance(self.history_turns, str):
125
+ raise BackendError("invalid_config", "Context paths and replay turns must be sequences of strings.")
126
+ object.__setattr__(self, "files", tuple(self.files))
127
+ object.__setattr__(self, "images", tuple(self.images))
128
+ object.__setattr__(self, "history_turns", tuple(self.history_turns))
129
+ if any(not isinstance(turn, str) for turn in self.history_turns):
130
+ raise BackendError("invalid_config", "Replay turns must be strings.")
131
+ if type(self.history_omitted_turns) is not int or self.history_omitted_turns < 0:
132
+ raise BackendError("invalid_config", "Omitted replay turn count must be a nonnegative integer.")
133
+ for name, value in (("admitted_at", self.admitted_at), ("deadline", self.deadline)):
134
+ if value is not None and (
135
+ isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value)
136
+ ):
137
+ raise BackendError("invalid_config", f"CLI request {name} must be a finite monotonic timestamp.")
138
+
139
+
140
+ @dataclass(frozen=True)
141
+ class CLIResult:
142
+ """Normalized output without raw streams, argv, or native session identities."""
143
+
144
+ content: str
145
+ status: str
146
+ request_id: str
147
+ cli_name: str
148
+ role: str
149
+ requested_model: str | None
150
+ actual_model: str | None
151
+ duration_seconds: float
152
+ warnings: tuple[dict[str, str], ...] = ()
153
+ truncated: bool = False
154
+ metadata: dict[str, Any] = field(default_factory=dict)
155
+
156
+ def to_metadata(self) -> dict[str, Any]:
157
+ return {
158
+ "request_id": self.request_id,
159
+ "backend": {"cli_name": self.cli_name, "role": self.role},
160
+ "requested_model": self.requested_model,
161
+ "actual_model": self.actual_model,
162
+ "duration_seconds": self.duration_seconds,
163
+ "status": self.status,
164
+ "warnings": list(self.warnings),
165
+ "truncated": self.truncated,
166
+ **self.metadata,
167
+ }
clink/files.py ADDED
@@ -0,0 +1,70 @@
1
+ """Open declared context through anchored descriptors without following symlinks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ from pathlib import Path
8
+
9
+ from clink.contracts import BackendError
10
+
11
+
12
+ def _identity(info: os.stat_result) -> tuple[int, ...]:
13
+ return info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns, info.st_ctime_ns
14
+
15
+
16
+ def read_context(path: Path, roots: tuple[Path, ...], limit: int) -> bytes:
17
+ """Read a bounded regular file, refusing swapped leaf/parent links and special files."""
18
+ if os.name != "posix":
19
+ raise BackendError("unsupported_platform", "Safe context admission requires verified POSIX descriptors.")
20
+ if not path.is_absolute() or not any(path == root or root in path.parents for root in roots):
21
+ raise BackendError("path_not_allowed", "The context file is outside the approved roots.")
22
+ descriptors: list[int] = []
23
+ parents: list[tuple[Path, int]] = []
24
+ flags = os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW | os.O_CLOEXEC
25
+ try:
26
+ descriptor = os.open("/", flags | os.O_DIRECTORY)
27
+ descriptors.append(descriptor)
28
+ current = Path("/")
29
+ parents.append((current, descriptor))
30
+ for component in path.parts[1:-1]:
31
+ descriptor = os.open(component, flags | os.O_DIRECTORY, dir_fd=descriptor)
32
+ descriptors.append(descriptor)
33
+ current /= component
34
+ parents.append((current, descriptor))
35
+ descriptor = os.open(path.name, flags, dir_fd=descriptor)
36
+ descriptors.append(descriptor)
37
+ before = os.fstat(descriptor)
38
+ if not stat.S_ISREG(before.st_mode):
39
+ raise BackendError("path_not_allowed", "Declared inline context must be a regular file.")
40
+ chunks = []
41
+ remaining = limit
42
+ while remaining > 0:
43
+ block = os.read(descriptor, min(65536, remaining))
44
+ if not block:
45
+ break
46
+ chunks.append(block)
47
+ remaining -= len(block)
48
+ after = os.fstat(descriptor)
49
+ leaf = path.lstat()
50
+ if (
51
+ not stat.S_ISREG(leaf.st_mode)
52
+ or _identity(before) != _identity(after)
53
+ or _identity(after) != _identity(leaf)
54
+ ):
55
+ raise BackendError("path_not_allowed", "The context file changed during admission.")
56
+ for parent, opened in parents:
57
+ observed, retained = parent.lstat(), os.fstat(opened)
58
+ if not stat.S_ISDIR(observed.st_mode) or (observed.st_dev, observed.st_ino) != (
59
+ retained.st_dev,
60
+ retained.st_ino,
61
+ ):
62
+ raise BackendError("path_not_allowed", "A context directory changed during admission.")
63
+ return b"".join(chunks)
64
+ except BackendError:
65
+ raise
66
+ except (OSError, ValueError) as exc:
67
+ raise BackendError("path_not_allowed", "The context file is unavailable or changed during admission.") from exc
68
+ finally:
69
+ for descriptor in reversed(descriptors):
70
+ os.close(descriptor)
clink/generation.py ADDED
@@ -0,0 +1,274 @@
1
+ """Single-attempt generation through the existing bounded process supervisor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import re
8
+ import shutil
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from clink.backend import _directory
14
+ from clink.contracts import BackendError
15
+ from clink.generation_contracts import (
16
+ OUTPUT_BYTES,
17
+ PROMPT_BYTES,
18
+ GenerationRequest,
19
+ canonical_json,
20
+ execution_identity,
21
+ sha256,
22
+ terminal_result,
23
+ )
24
+ from clink.generation_policy import (
25
+ CATALOG_SHA256,
26
+ DEFAULT_ADMISSION,
27
+ GENERATION_INSTRUCTIONS,
28
+ RUNTIME_BLOCKERS,
29
+ GenerationAdmission,
30
+ generation_arguments,
31
+ generation_catalog_digest,
32
+ generation_recipe_digest,
33
+ supported_generation_models,
34
+ )
35
+ from clink.parsers.base import ParserError, cli_failure
36
+ from clink.parsers.codex_generation import CodexGenerationParser, parser_contract_digest
37
+ from clink.policy import ENVIRONMENT_KEYS, executable_digest
38
+ from clink.registry import ClinkRegistry, RegistryLoadError
39
+ from clink.runner import ProcessPool
40
+
41
+
42
+ def compose_prompt(request: GenerationRequest) -> bytes:
43
+ """Keep exact dynamic strings in stdin; fixed authority uses the native developer role."""
44
+ content = canonical_json({"instructions": request.instructions, "input": request.input})
45
+ if len(GENERATION_INSTRUCTIONS.encode("utf-8")) + len(content) > PROMPT_BYTES:
46
+ raise BackendError("input_limit", "The assembled generation prompt exceeds the 1 MiB byte limit.")
47
+ return content
48
+
49
+
50
+ class GenerationService:
51
+ """Keep generation admission and authority separate from consultation and native tasks."""
52
+
53
+ def __init__(
54
+ self,
55
+ registry: ClinkRegistry | None = None,
56
+ pool: ProcessPool | None = None,
57
+ admission: GenerationAdmission | None = None,
58
+ ):
59
+ self.registry = registry
60
+ self.pool = pool if pool is not None else ProcessPool()
61
+ self.admission = admission if admission is not None else DEFAULT_ADMISSION
62
+
63
+ @staticmethod
64
+ def _require_runtime_ceiling() -> None:
65
+ """Fail closed on implementation limits independently of injected admission evidence."""
66
+ if RUNTIME_BLOCKERS:
67
+ raise BackendError(
68
+ "policy_unverified",
69
+ "Generation is blocked: ordinary_mcp_runtime_ceiling_unavailable. "
70
+ "The pinned recipe cannot suppress ordinary MCP servers at runtime.",
71
+ )
72
+
73
+ @staticmethod
74
+ def _evidence_digest(admission: GenerationAdmission) -> str:
75
+ if not admission.evidence_path:
76
+ raise BackendError("policy_unverified", "Generation admission has no supporting evidence binding.")
77
+ path = Path(admission.evidence_path)
78
+ if not path.is_absolute():
79
+ path = Path(__file__).resolve().parent.parent / path
80
+ try:
81
+ with path.open("rb") as stream:
82
+ content = stream.read(1024 * 1024 + 1)
83
+ except OSError:
84
+ raise BackendError("policy_unverified", "Generation admission evidence is unavailable.") from None
85
+ if len(content) > 1024 * 1024:
86
+ raise BackendError("policy_unverified", "Generation admission evidence exceeds its bounded size.")
87
+ return sha256(content)
88
+
89
+ def _verify_bindings(self, executable: str, admission: GenerationAdmission) -> str:
90
+ if not admission.verified or not all(
91
+ (
92
+ admission.version,
93
+ admission.executable_sha256,
94
+ admission.recipe_sha256,
95
+ admission.catalog_sha256,
96
+ admission.parser_sha256,
97
+ admission.evidence_sha256,
98
+ )
99
+ ):
100
+ raise BackendError("policy_unverified", "Generation requires its own verified binary and policy admission.")
101
+ if admission != self.admission:
102
+ raise BackendError("policy_unverified", "Generation admission changed while the request was queued.")
103
+ try:
104
+ resources_match = (
105
+ generation_recipe_digest() == admission.recipe_sha256
106
+ and admission.catalog_sha256 == CATALOG_SHA256
107
+ and generation_catalog_digest() == admission.catalog_sha256
108
+ and parser_contract_digest() == admission.parser_sha256
109
+ and self._evidence_digest(admission) == admission.evidence_sha256
110
+ )
111
+ except OSError:
112
+ raise BackendError("policy_unverified", "A generation policy resource is unavailable.") from None
113
+ if not resources_match:
114
+ raise BackendError("policy_unverified", "Generation resources differ from their verified policy bindings.")
115
+ try:
116
+ observed = executable_digest(executable)
117
+ except OSError:
118
+ raise BackendError("cli_missing", "The admitted generation executable is unavailable.") from None
119
+ if observed != admission.executable_sha256:
120
+ raise BackendError("policy_unverified", "The generation executable differs from its verified binary.")
121
+ return observed
122
+
123
+ async def run(self, request: GenerationRequest) -> dict[str, Any]:
124
+ """Return one terminal envelope; cancellation returns only after shared-pool cleanup."""
125
+ identity = execution_identity(request)
126
+ try:
127
+ if os.environ.get("CLINK_DELEGATION_DEPTH", "0") != "0":
128
+ raise BackendError("recursion_blocked", "Nested generation delegation is prohibited.")
129
+ if time.monotonic() >= request.deadline:
130
+ raise BackendError("timeout", "The generation deadline expired before launch.")
131
+ content = compose_prompt(request)
132
+ admission = self.admission
133
+ identity.update(
134
+ recipe_sha256=generation_recipe_digest(),
135
+ catalog_sha256=CATALOG_SHA256,
136
+ admission="unverified",
137
+ )
138
+ if request.cli != "codex" or request.model not in supported_generation_models():
139
+ raise BackendError("unsupported_model", "The model is outside the generation catalog.")
140
+ self._require_runtime_ceiling()
141
+ # Reject before registry discovery or binary access when production admission is absent.
142
+ if not admission.verified:
143
+ raise BackendError(
144
+ "policy_unverified", "Generation requires its own verified binary and policy admission."
145
+ )
146
+ registry = self.registry if self.registry is not None else ClinkRegistry()
147
+ try:
148
+ client = registry.get_client("codex")
149
+ except KeyError:
150
+ raise BackendError("invalid_config", "No Codex generation client is configured.") from None
151
+ if client.adapter != "codex" or len(client.executable) != 1 or client.config_args:
152
+ raise BackendError(
153
+ "invalid_config", "Generation requires a Codex executable without operator arguments."
154
+ )
155
+ found = shutil.which(client.executable[0])
156
+ if not found:
157
+ raise BackendError("cli_missing", "The configured Codex executable is unavailable.")
158
+ try:
159
+ executable = str(Path(found).resolve(strict=True))
160
+ except (OSError, RuntimeError):
161
+ raise BackendError("cli_missing", "The configured Codex executable is unavailable.") from None
162
+ cwd = Path(client.working_dir)
163
+ roots = tuple(Path(root) for root in client.allowed_roots)
164
+ environment = {name: os.environ[name] for name in ENVIRONMENT_KEYS if name in os.environ}
165
+ if any(name not in ENVIRONMENT_KEYS for name in client.env):
166
+ raise BackendError("invalid_config", "Generation environment additions exceed the verified allowlist.")
167
+ environment.update(client.env)
168
+ environment["CLINK_DELEGATION_DEPTH"] = "1"
169
+ # Immutable values are captured once, before queuing; role/native/default arguments never enter argv.
170
+ command = (executable, *generation_arguments(rendered=True), "--model", request.model, "-")
171
+
172
+ def prelaunch() -> None:
173
+ identity["admission"] = "unverified"
174
+ self._require_runtime_ceiling()
175
+ if _directory(cwd, roots) != cwd:
176
+ raise BackendError("path_not_allowed", "The configured generation directory changed while queued.")
177
+ identity["executable_sha256"] = self._verify_bindings(executable, admission)
178
+ identity["admission"] = "verified"
179
+
180
+ prelaunch()
181
+ if time.monotonic() >= request.deadline:
182
+ raise BackendError("timeout", "The generation deadline expired during admission checks.")
183
+ try:
184
+ output = await self.pool.execute(
185
+ command,
186
+ content=content,
187
+ cwd=str(cwd),
188
+ env=environment,
189
+ deadline=request.deadline,
190
+ request_id=request.request_id,
191
+ metadata={"client": "codex", "tool": "generate"},
192
+ prelaunch=prelaunch,
193
+ )
194
+ except (BackendError, asyncio.CancelledError):
195
+ raise
196
+ except Exception:
197
+ raise BackendError(
198
+ "cleanup_unconfirmed", "Process supervision failed and complete cleanup could not be confirmed."
199
+ ) from None
200
+ if output.returncode != 0:
201
+ failure = cli_failure(output.stderr, code="nonzero_exit")
202
+ metadata: dict[str, Any] = {}
203
+ if failure.code == "rate_limited":
204
+ match = re.search(r"(?im)^retry-after:\s*([0-9]{1,6})\s*$", output.stderr)
205
+ if match and int(match.group(1)) <= 86400:
206
+ metadata["retry_after_seconds"] = int(match.group(1))
207
+ raise BackendError(failure.code, "The generation CLI did not complete successfully.", metadata=metadata)
208
+ parsed = CodexGenerationParser().parse(output.stdout, output.stderr)
209
+ identity["actual_model"] = parsed.metadata.get("actual_model")
210
+ if identity["actual_model"] is not None and identity["actual_model"] != request.model:
211
+ raise BackendError("unsupported_model", "The CLI reported a different actual model.")
212
+ if not parsed.content.strip():
213
+ raise BackendError("protocol_error", "The generation CLI returned no nonempty final text.")
214
+ raw_text = parsed.content.encode("utf-8")
215
+ if len(raw_text) > OUTPUT_BYTES:
216
+ prefix = raw_text[:OUTPUT_BYTES].decode("utf-8", errors="ignore")
217
+ return terminal_result(
218
+ started_at=request.started_at,
219
+ request=request,
220
+ status="incomplete",
221
+ output=prefix,
222
+ truncated=True,
223
+ code="output_limit",
224
+ message="The generated text exceeded the 131072-byte output limit.",
225
+ execution=identity,
226
+ )
227
+ return terminal_result(
228
+ started_at=request.started_at,
229
+ request=request,
230
+ status="completed",
231
+ output=parsed.content,
232
+ execution=identity,
233
+ )
234
+ except asyncio.CancelledError:
235
+ return terminal_result(
236
+ started_at=request.started_at,
237
+ request=request,
238
+ status="cancelled",
239
+ code="cancelled",
240
+ message="The generation attempt was cancelled after controlled cleanup.",
241
+ execution=identity,
242
+ )
243
+ except (BackendError, ParserError) as exc:
244
+ code = "protocol_error" if exc.code == "parse_error" else exc.code
245
+ retry_after = exc.metadata.get("retry_after_seconds") if isinstance(exc, BackendError) else None
246
+ return terminal_result(
247
+ started_at=request.started_at,
248
+ request=request,
249
+ status="timed_out" if code == "timeout" else "cancelled" if code == "cancelled" else "failed",
250
+ code=code,
251
+ message=str(exc),
252
+ retry_after_seconds=retry_after,
253
+ execution=identity,
254
+ )
255
+ except RegistryLoadError:
256
+ return terminal_result(
257
+ started_at=request.started_at,
258
+ request=request,
259
+ code="invalid_config",
260
+ message="Generation client configuration is invalid.",
261
+ execution=identity,
262
+ )
263
+ except Exception:
264
+ # Never reflect arbitrary configuration, streams, commands or request text from unexpected errors.
265
+ return terminal_result(
266
+ started_at=request.started_at,
267
+ request=request,
268
+ code="internal_error",
269
+ message="The generation attempt failed internally.",
270
+ execution=identity,
271
+ )
272
+
273
+ async def shutdown(self) -> None:
274
+ await self.pool.shutdown()