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.
- clink/__init__.py +19 -0
- clink/agents/__init__.py +30 -0
- clink/agents/base.py +70 -0
- clink/agents/claude.py +7 -0
- clink/agents/codex.py +7 -0
- clink/agents/gemini.py +7 -0
- clink/allowed_roots.py +356 -0
- clink/backend.py +765 -0
- clink/cli.py +594 -0
- clink/constants.py +78 -0
- clink/contracts.py +167 -0
- clink/files.py +70 -0
- clink/generation.py +274 -0
- clink/generation_capabilities.py +207 -0
- clink/generation_contracts.py +214 -0
- clink/generation_policy.py +174 -0
- clink/models.py +243 -0
- clink/native.py +409 -0
- clink/native_adapters.py +244 -0
- clink/native_contracts.py +68 -0
- clink/native_generation.py +421 -0
- clink/native_generation_claude_policy.py +59 -0
- clink/native_generation_contracts.py +264 -0
- clink/native_generation_families.py +218 -0
- clink/native_generation_family_policy.py +14 -0
- clink/native_generation_files.py +153 -0
- clink/native_generation_policy.py +82 -0
- clink/native_policy.py +86 -0
- clink/parsers/__init__.py +34 -0
- clink/parsers/agy.py +53 -0
- clink/parsers/agy_native.py +161 -0
- clink/parsers/agy_native_generation.py +189 -0
- clink/parsers/base.py +209 -0
- clink/parsers/claude.py +104 -0
- clink/parsers/claude_native.py +113 -0
- clink/parsers/claude_native_generation.py +346 -0
- clink/parsers/codex.py +172 -0
- clink/parsers/codex_generation.py +212 -0
- clink/parsers/codex_native.py +236 -0
- clink/parsers/codex_native_generation.py +367 -0
- clink/parsers/gemini.py +110 -0
- clink/parsers/grok.py +63 -0
- clink/parsers/grok_native.py +91 -0
- clink/parsers/grok_native_generation.py +157 -0
- clink/policy.py +180 -0
- clink/registry.py +544 -0
- clink/resources/CODEX-LICENSE +201 -0
- clink/resources/CODEX-NOTICE +6 -0
- clink/resources/PROVENANCE.md +16 -0
- clink/resources/codex-0.166.0-models-direct.json +1284 -0
- clink/resources/codex-0.166.0-models-generation.json +1269 -0
- clink/resources/native-agy-1.4.12-admission.json +746 -0
- clink/resources/native-claude-2.1.304-admission.json +99 -0
- clink/resources/native-generation-claude-2.1.304-admission.json +277 -0
- clink/resources/native-generation-codex-0.166.0-admission.json +309 -0
- clink/resources/native-generation-grok-1.0.70-admission.json +329 -0
- clink/resources/native-grok-1.0.70-admission.json +644 -0
- clink/runner.py +303 -0
- clink/setup.py +399 -0
- clink/tasks.py +695 -0
- clink_cli-0.2.1.dist-info/METADATA +225 -0
- clink_cli-0.2.1.dist-info/RECORD +141 -0
- clink_cli-0.2.1.dist-info/WHEEL +5 -0
- clink_cli-0.2.1.dist-info/entry_points.txt +3 -0
- clink_cli-0.2.1.dist-info/licenses/LICENSE +197 -0
- clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-LICENSE +201 -0
- clink_cli-0.2.1.dist-info/licenses/clink/resources/CODEX-NOTICE +6 -0
- clink_cli-0.2.1.dist-info/top_level.txt +7 -0
- conf/__init__.py +1 -0
- conf/cli_clients/agy.json +40 -0
- conf/cli_clients/claude.json +39 -0
- conf/cli_clients/codex.json +40 -0
- conf/cli_clients/gemini.json +23 -0
- conf/cli_clients/grok.json +40 -0
- config.py +40 -0
- server.py +896 -0
- systemprompts/__init__.py +35 -0
- systemprompts/analyze_prompt.py +87 -0
- systemprompts/chat_prompt.py +57 -0
- systemprompts/clink/codex_codereviewer.txt +8 -0
- systemprompts/clink/default.txt +8 -0
- systemprompts/clink/default_codereviewer.txt +8 -0
- systemprompts/clink/default_planner.txt +7 -0
- systemprompts/clink/native_codereviewer.txt +1 -0
- systemprompts/clink/native_default.txt +1 -0
- systemprompts/clink/native_planner.txt +1 -0
- systemprompts/codereview_prompt.py +91 -0
- systemprompts/consensus_prompt.py +120 -0
- systemprompts/debug_prompt.py +150 -0
- systemprompts/docgen_prompt.py +252 -0
- systemprompts/generate_code_prompt.py +181 -0
- systemprompts/planner_prompt.py +127 -0
- systemprompts/precommit_prompt.py +179 -0
- systemprompts/refactor_prompt.py +323 -0
- systemprompts/secaudit_prompt.py +420 -0
- systemprompts/testgen_prompt.py +133 -0
- systemprompts/thinkdeep_prompt.py +55 -0
- systemprompts/tracer_prompt.py +145 -0
- tools/__init__.py +43 -0
- tools/analyze.py +583 -0
- tools/apilookup.py +97 -0
- tools/challenge.py +198 -0
- tools/chat.py +187 -0
- tools/clink.py +130 -0
- tools/codereview.py +724 -0
- tools/consensus.py +790 -0
- tools/debug.py +573 -0
- tools/docgen.py +577 -0
- tools/listmodels.py +48 -0
- tools/models.py +373 -0
- tools/planner.py +533 -0
- tools/precommit.py +776 -0
- tools/refactor.py +645 -0
- tools/secaudit.py +685 -0
- tools/shared/__init__.py +19 -0
- tools/shared/base_models.py +167 -0
- tools/shared/base_tool.py +659 -0
- tools/shared/exceptions.py +20 -0
- tools/shared/schema_builders.py +159 -0
- tools/simple/__init__.py +18 -0
- tools/simple/base.py +539 -0
- tools/testgen.py +513 -0
- tools/thinkdeep.py +586 -0
- tools/tracer.py +763 -0
- tools/version.py +53 -0
- tools/workflow/__init__.py +22 -0
- tools/workflow/base.py +448 -0
- tools/workflow/schema_builders.py +166 -0
- tools/workflow/workflow_mixin.py +1195 -0
- utils/__init__.py +21 -0
- utils/call_state.py +349 -0
- utils/client_info.py +293 -0
- utils/consensus_manifest.py +131 -0
- utils/conversation_memory.py +582 -0
- utils/env.py +111 -0
- utils/file_types.py +271 -0
- utils/file_utils.py +803 -0
- utils/image_utils.py +94 -0
- utils/security_config.py +163 -0
- utils/storage_backend.py +177 -0
- 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()
|