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/models.py ADDED
@@ -0,0 +1,243 @@
1
+ """Strict manifest models and frozen resolved clink configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any, Literal
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
10
+
11
+ _NAME_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,63}$")
12
+ _ENVIRONMENT_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
13
+
14
+
15
+ def _validate_name(value: str) -> str:
16
+ if not _NAME_PATTERN.fullmatch(value):
17
+ raise ValueError("must start with a letter and contain only letters, digits, underscores, or hyphens")
18
+ return value
19
+
20
+
21
+ def _validate_model(value: str | None) -> str | None:
22
+ if value is None or value == "auto":
23
+ return None
24
+ if (
25
+ not value
26
+ or value != value.strip()
27
+ or value.startswith("-")
28
+ or len(value) > 256
29
+ or any(ord(char) < 32 or ord(char) == 127 for char in value)
30
+ ):
31
+ raise ValueError("model must be a nonempty native identifier without control characters")
32
+ return value
33
+
34
+
35
+ class OutputCaptureConfig(BaseModel):
36
+ """Legacy import compatibility; generic output capture is not admitted by manifests."""
37
+
38
+ model_config = ConfigDict(extra="forbid", frozen=True)
39
+
40
+ flag_template: str
41
+ cleanup: bool = True
42
+
43
+
44
+ class CLIRoleConfig(BaseModel):
45
+ """Operator-owned role manifest with no argument or type coercion."""
46
+
47
+ model_config = ConfigDict(extra="forbid", strict=True)
48
+
49
+ prompt_path: str | None = None
50
+ role_args: list[str] = Field(default_factory=list)
51
+ description: str | None = None
52
+ model: str | None = None
53
+
54
+ _validate_native_model = field_validator("model")(_validate_model)
55
+
56
+
57
+ class NativeTaskConfig(BaseModel):
58
+ """Opt-in task profile; inherited environment is configured by name only."""
59
+
60
+ model_config = ConfigDict(extra="forbid", strict=True)
61
+
62
+ enabled: bool = False
63
+ inherit_env: list[str] = Field(default_factory=list, max_length=128)
64
+ required_env: list[str] = Field(default_factory=list, max_length=128)
65
+ roles: dict[str, CLIRoleConfig] = Field(default_factory=dict, max_length=64)
66
+ default_model: str | None = None
67
+
68
+ _validate_native_model = field_validator("default_model")(_validate_model)
69
+
70
+ @field_validator("inherit_env", "required_env")
71
+ @classmethod
72
+ def _environment_names(cls, names: list[str]) -> list[str]:
73
+ for name in names:
74
+ if not _ENVIRONMENT_NAME.fullmatch(name) or name.startswith("CLINK_"):
75
+ raise ValueError("native environment entries must be valid names outside the server namespace")
76
+ if len(names) != len(set(names)):
77
+ raise ValueError("native environment names must be unique")
78
+ return names
79
+
80
+ @field_validator("roles")
81
+ @classmethod
82
+ def _role_names(cls, roles: dict[str, CLIRoleConfig]) -> dict[str, CLIRoleConfig]:
83
+ for name in roles:
84
+ _validate_name(name)
85
+ return roles
86
+
87
+ @model_validator(mode="after")
88
+ def _required_are_inherited(self) -> NativeTaskConfig:
89
+ if not set(self.required_env).issubset(self.inherit_env):
90
+ raise ValueError("required_env names must also appear in inherit_env")
91
+ return self
92
+
93
+
94
+ class GenerationNativeConfig(BaseModel):
95
+ """Operator-owned environment names for separate native generation only."""
96
+
97
+ model_config = ConfigDict(extra="forbid", strict=True)
98
+
99
+ inherit_env: list[str] = Field(default_factory=list, max_length=128)
100
+ required_env: list[str] = Field(default_factory=list, max_length=128)
101
+
102
+ @field_validator("inherit_env", "required_env")
103
+ @classmethod
104
+ def _environment_names(cls, names: list[str]) -> list[str]:
105
+ for name in names:
106
+ if not _ENVIRONMENT_NAME.fullmatch(name) or name.startswith("CLINK_"):
107
+ raise ValueError("native environment entries must be valid names outside the server namespace")
108
+ if len(names) != len(set(names)):
109
+ raise ValueError("native environment names must be unique")
110
+ return names
111
+
112
+ @model_validator(mode="after")
113
+ def _required_are_inherited(self) -> GenerationNativeConfig:
114
+ if not set(self.required_env).issubset(self.inherit_env):
115
+ raise ValueError("required_env names must also appear in inherit_env")
116
+ return self
117
+
118
+
119
+ class CLIClientConfig(BaseModel):
120
+ """Raw version-one manifest; every recognized field is validated explicitly."""
121
+
122
+ model_config = ConfigDict(extra="forbid", strict=True)
123
+
124
+ schema_version: int = Field(default=1, ge=1, le=1)
125
+ name: str
126
+ adapter: str | None = None
127
+ command: str | None = None
128
+ working_dir: str | None = None
129
+ allowed_roots: list[str] | None = None
130
+ additional_args: list[str] = Field(default_factory=list)
131
+ env: dict[str, str] = Field(default_factory=dict)
132
+ timeout_seconds: int | None = Field(default=None, gt=0, le=1800)
133
+ roles: dict[str, CLIRoleConfig] = Field(default_factory=dict)
134
+ default_model: str | None = None
135
+ native_task: NativeTaskConfig | None = None
136
+ generation_native: GenerationNativeConfig | None = None
137
+
138
+ _validate_client_name = field_validator("name")(_validate_name)
139
+ _validate_native_model = field_validator("default_model")(_validate_model)
140
+
141
+ @field_validator("roles")
142
+ @classmethod
143
+ def _validate_role_names(cls, value: dict[str, CLIRoleConfig]) -> dict[str, CLIRoleConfig]:
144
+ for name in value:
145
+ _validate_name(name)
146
+ return value
147
+
148
+
149
+ class ConsumerCLIConfig(BaseModel):
150
+ """Closed generation-only manifest for one independently bound consumer."""
151
+
152
+ model_config = ConfigDict(extra="forbid", strict=True)
153
+
154
+ schema_version: int = Field(ge=1, le=1)
155
+ name: Literal["claude", "codex", "agy", "grok"]
156
+ adapter: Literal["claude", "codex", "agy", "grok"]
157
+ command: str
158
+ allowed_roots: list[str]
159
+ generation_native: GenerationNativeConfig
160
+
161
+ @model_validator(mode="after")
162
+ def _matching_adapter(self) -> ConsumerCLIConfig:
163
+ if self.name != self.adapter:
164
+ raise ValueError("consumer name must match its adapter")
165
+ return self
166
+
167
+
168
+ class ResolvedCLIRole(BaseModel):
169
+ """Role snapshot, including prompt text read once at registry initialization."""
170
+
171
+ model_config = ConfigDict(extra="forbid", frozen=True)
172
+
173
+ name: str
174
+ prompt_path: Path
175
+ prompt_text: str = ""
176
+ role_args: tuple[str, ...] = ()
177
+ description: str | None = None
178
+ model: str | None = None
179
+
180
+
181
+ class ResolvedNativeTask(BaseModel):
182
+ """Native capability profile, separate from advisory configuration."""
183
+
184
+ model_config = ConfigDict(extra="forbid", frozen=True)
185
+
186
+ enabled: bool = False
187
+ inherit_env: tuple[str, ...] = ()
188
+ required_env: tuple[str, ...] = ()
189
+ roles: dict[str, ResolvedCLIRole] = Field(default_factory=dict)
190
+ default_model: str | None = None
191
+
192
+
193
+ class ResolvedGenerationNative(BaseModel):
194
+ """Freeze environment names independently of native-task roles and authority."""
195
+
196
+ model_config = ConfigDict(extra="forbid", frozen=True)
197
+
198
+ inherit_env: tuple[str, ...] = ()
199
+ required_env: tuple[str, ...] = ()
200
+
201
+
202
+ class ResolvedCLIClient(BaseModel):
203
+ """Frozen runtime fields; backend targets additionally snapshot nested mappings."""
204
+
205
+ model_config = ConfigDict(extra="forbid", frozen=True)
206
+
207
+ name: str
208
+ adapter: str
209
+ executable: tuple[str, ...]
210
+ working_dir: Path | None
211
+ allowed_roots: tuple[Path, ...] = ()
212
+ allowed_roots_explicit: bool = False
213
+ configuration_scope: str = "unknown"
214
+ internal_args: tuple[str, ...] = ()
215
+ config_args: tuple[str, ...] = ()
216
+ env: dict[str, str] = Field(default_factory=dict)
217
+ timeout_seconds: int
218
+ parser: str
219
+ runner: str | None = None
220
+ roles: dict[str, ResolvedCLIRole]
221
+ default_model: str | None = None
222
+ native_task: ResolvedNativeTask | None = None
223
+ generation_native: ResolvedGenerationNative | None = None
224
+ output_to_file: None = None
225
+
226
+ @model_validator(mode="before")
227
+ @classmethod
228
+ def _legacy_adapter_default(cls, value: Any) -> Any:
229
+ # Older callers construct built-in resolved clients directly in tests and integrations.
230
+ if isinstance(value, dict) and "adapter" not in value:
231
+ value = dict(value)
232
+ value["adapter"] = str(value.get("name", "")).lower()
233
+ return value
234
+
235
+ def list_roles(self) -> list[str]:
236
+ return list(self.roles)
237
+
238
+ def get_role(self, role_name: str | None) -> ResolvedCLIRole:
239
+ key = role_name or "default"
240
+ if key not in self.roles:
241
+ available = ", ".join(sorted(self.roles))
242
+ raise KeyError(f"Role '{role_name}' not configured for CLI '{self.name}'. Available roles: {available}")
243
+ return self.roles[key]
clink/native.py ADDED
@@ -0,0 +1,409 @@
1
+ """One-shot native task attempts through the backend's shared process limits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import json
8
+ import math
9
+ import os
10
+ import platform
11
+ import re
12
+ import shutil
13
+ import time
14
+ from collections.abc import Callable
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from clink.backend import CLIBackend, _directory, _model, get_backend
19
+ from clink.contracts import AdapterPolicy, BackendError, BackendTarget, ClientSnapshot, CLIRequest, RoleSnapshot
20
+ from clink.native_adapters import get_native_adapter, prepare_native_launch
21
+ from clink.native_contracts import FrozenNativeContext, NativeRunOutcome
22
+ from clink.native_policy import native_environment_names, native_recipe_digest
23
+ from clink.parsers.base import ParserError
24
+ from clink.policy import executable_digest
25
+
26
+ PROMPT_BYTES = 1024 * 1024
27
+ # This profile has its own genuine native evidence. Advisory receipts cannot admit it.
28
+ _VERIFIED_NATIVE_POLICIES: dict[str, AdapterPolicy] = {
29
+ "claude": AdapterPolicy(
30
+ adapter="claude",
31
+ version="2.1.304 (Claude Code)",
32
+ verified=True,
33
+ verification_status="verified_native_lifecycle",
34
+ file_references=True,
35
+ repository_instructions="native user/project instructions retained",
36
+ read_scope="native permissions; approved roots constrain supplied context only",
37
+ evidence="clink/resources/native-claude-2.1.304-admission.json",
38
+ evidence_sha256="ed3cd9bf385ac533b8f24c06cc14a431c0dbac0100f1eb23f24953eab6ca4de2",
39
+ executable_sha256="b9ac5374839f14abf3f1f45610637c332b5daee5ce0d1634726cef87aab78e6a",
40
+ recipe_sha256="de885baaadfdf5810321e32992c7e961f3a360dda90b903ab98ffc243564885e",
41
+ ),
42
+ "grok": AdapterPolicy(
43
+ adapter="grok",
44
+ version="grok 1.0.70 (f50904eaee6c)",
45
+ verified=True,
46
+ verification_status="verified_native_lifecycle",
47
+ file_references=True,
48
+ repository_instructions="native user/project instructions retained",
49
+ read_scope="native permissions; approved roots constrain supplied context only",
50
+ evidence="clink/resources/native-grok-1.0.70-admission.json",
51
+ evidence_sha256="809f49238544d7fa317110c403952ba3fa791b9627066fa51d256ad16fc46ec1",
52
+ executable_sha256="5c8a688251bf61e35370b828b6a353b0a20fcc3f64000de8a91d426039f29cec",
53
+ recipe_sha256="b2e236f6490d3d00b51dac2a634c8b4f845b649ad9386c6510c6ae902f730cd3",
54
+ ),
55
+ "agy": AdapterPolicy(
56
+ adapter="agy",
57
+ version="1.4.12",
58
+ verified=True,
59
+ verification_status="verified_native_lifecycle",
60
+ file_references=True,
61
+ repository_instructions="native user/project instructions retained",
62
+ read_scope="native permissions; approved roots constrain supplied context only",
63
+ evidence="clink/resources/native-agy-1.4.12-admission.json",
64
+ evidence_sha256="7c845424c38d57c3b5027eb2f91b159bf00d633f3ffa8de772f69f2c49c36381",
65
+ executable_sha256="34423fc9b0af3841e7646aa72a2d40ffeab8218b22d1cb99eac7355f9eba31e3",
66
+ recipe_sha256="da740ab92a35b480344a4dc07a517681dfbd5768f18ea01fbb65074337659a4f",
67
+ ),
68
+ }
69
+ _VERIFIED_NATIVE_PLATFORMS = {"grok": ("Darwin", "arm64"), "agy": ("Darwin", "arm64")}
70
+
71
+
72
+ class NativeRuntime:
73
+ """Freeze native configuration without granting capabilities beyond native policy."""
74
+
75
+ def __init__(self, backend: CLIBackend | None = None):
76
+ self.backend = backend or get_backend()
77
+ self.pool = self.backend.pool
78
+
79
+ @staticmethod
80
+ def _policy(adapter: str) -> AdapterPolicy:
81
+ policy = _VERIFIED_NATIVE_POLICIES.get(adapter, AdapterPolicy(adapter))
82
+ scope = _VERIFIED_NATIVE_PLATFORMS.get(adapter)
83
+ if scope is not None and (platform.system(), platform.machine()) != scope:
84
+ return AdapterPolicy(adapter, verification_status="platform_unverified")
85
+ return policy
86
+
87
+ def describe(self) -> list[dict[str, Any]]:
88
+ """Report native profiles without authentication, inference, or executable probes."""
89
+ descriptions = []
90
+ for name in self.backend.registry.list_clients():
91
+ client = self.backend.registry.get_client(name)
92
+ profile = client.native_task
93
+ if profile is None:
94
+ continue
95
+ policy = self._policy(client.adapter)
96
+ adapter = get_native_adapter(client.adapter)
97
+ installed = bool(client.executable and shutil.which(client.executable[0]))
98
+ descriptions.append(
99
+ {
100
+ "cli_name": client.name,
101
+ "adapter": client.adapter,
102
+ "enabled": profile.enabled,
103
+ "installed": installed,
104
+ "available": profile.enabled and installed and policy.verified,
105
+ "verification_status": policy.verification_status,
106
+ "version": policy.version,
107
+ "version_origin": "admission_receipt",
108
+ "launch_validation": "exact executable, recipe and receipt rechecked for each attempt",
109
+ "authentication": "unknown",
110
+ "roles": sorted(profile.roles),
111
+ "default_model": profile.default_model,
112
+ "default_model_origin": "native_profile" if profile.default_model else "native_cli_configuration",
113
+ "environment_names": list(profile.inherit_env),
114
+ "required_environment_names": list(profile.required_env),
115
+ "permission_handling": adapter.permission_handling,
116
+ "prompt_transport": adapter.prompt_channel,
117
+ "read_scope": "native policy; approved roots only constrain server-supplied context",
118
+ }
119
+ )
120
+ return descriptions
121
+
122
+ def _check_depth(self) -> None:
123
+ depth = os.environ.get("CLINK_DELEGATION_DEPTH", "0")
124
+ if not re.fullmatch(r"[0-9]+", depth):
125
+ raise BackendError("invalid_config", "CLINK_DELEGATION_DEPTH must be a nonnegative integer.")
126
+ if self.backend._nested or depth.strip("0"):
127
+ raise BackendError("recursion_blocked", "A known nested clink server cannot submit native tasks.")
128
+
129
+ @staticmethod
130
+ def _check_environment_names(context: FrozenNativeContext) -> None:
131
+ missing = [name for name in context.required_environment_names if not os.environ.get(name)]
132
+ if missing:
133
+ raise BackendError(
134
+ "missing_environment",
135
+ "Required native environment names are unavailable in the server process.",
136
+ metadata={"missing_environment_names": missing},
137
+ )
138
+
139
+ def prepare(
140
+ self,
141
+ cli_name: str,
142
+ role: str | None,
143
+ model: str | None,
144
+ project_dir: str,
145
+ files: tuple[str, ...] = (),
146
+ images: tuple[str, ...] = (),
147
+ absolute_deadline: float | None = None,
148
+ ) -> FrozenNativeContext:
149
+ """Validate and freeze names/context before reserving a task, without CLI calls."""
150
+ self._check_depth()
151
+ if absolute_deadline is not None and (
152
+ isinstance(absolute_deadline, bool)
153
+ or not isinstance(absolute_deadline, (int, float))
154
+ or not math.isfinite(absolute_deadline)
155
+ ):
156
+ raise BackendError("invalid_config", "A native deadline must be a finite monotonic timestamp.")
157
+ if not isinstance(cli_name, str) or not cli_name:
158
+ raise BackendError("invalid_config", "A native task requires an explicit configured CLI name.")
159
+ try:
160
+ client = self.backend.registry.get_client(cli_name)
161
+ except KeyError as exc:
162
+ raise BackendError("invalid_config", "The requested native CLI client is not configured.") from exc
163
+ profile = client.native_task
164
+ if profile is None or not profile.enabled:
165
+ raise BackendError("unsupported_capability", "This client has no enabled native task profile.")
166
+ adapter = get_native_adapter(client.adapter)
167
+ selected_role = profile.roles.get(role or "default")
168
+ if selected_role is None:
169
+ raise BackendError("invalid_config", "The requested role is not configured in the native task profile.")
170
+ policy = self._policy(client.adapter)
171
+ if not policy.verified:
172
+ raise BackendError(
173
+ "policy_unverified", "The native task profile has no admitted version-specific evidence."
174
+ )
175
+ command = tuple(client.executable)
176
+ found = shutil.which(command[0]) if command else None
177
+ if not found:
178
+ raise BackendError("cli_missing", "The configured native CLI executable is unavailable.")
179
+ executable = (str(Path(found).resolve()), *command[1:])
180
+ roots = tuple(Path(root) for root in client.allowed_roots)
181
+ cwd = _directory(project_dir, roots)
182
+ requested_model = _model(model)
183
+ role_model = _model(selected_role.model)
184
+ effective_model = requested_model or role_model or _model(profile.default_model)
185
+ target = BackendTarget(
186
+ client=ClientSnapshot(
187
+ name=client.name,
188
+ adapter=client.adapter,
189
+ executable=executable,
190
+ working_dir=Path(client.working_dir),
191
+ allowed_roots=roots,
192
+ config_args=tuple(client.config_args),
193
+ env=tuple(sorted(client.env.items())),
194
+ timeout_seconds=client.timeout_seconds,
195
+ parser=f"{adapter.name}_native",
196
+ default_model=profile.default_model,
197
+ ),
198
+ role=RoleSnapshot(selected_role.name, selected_role.prompt_text, model=selected_role.model),
199
+ requested_model=requested_model,
200
+ effective_model=effective_model,
201
+ working_directory=cwd,
202
+ policy=policy,
203
+ default_origin=(
204
+ "request" if requested_model else "role" if role_model else "client" if effective_model else "native"
205
+ ),
206
+ )
207
+ validated_files = self.validate_context(target, files, images)
208
+ identity = {
209
+ "client": client.name,
210
+ "adapter": client.adapter,
211
+ "executable": executable,
212
+ "project": str(cwd),
213
+ "roots": [str(root) for root in roots],
214
+ "model": effective_model,
215
+ "role": selected_role.name,
216
+ "role_sha256": hashlib.sha256(selected_role.prompt_text.encode()).hexdigest(),
217
+ "environment_names": profile.inherit_env,
218
+ "required_environment_names": profile.required_env,
219
+ "configured_environment_names": sorted(client.env),
220
+ "configured_environment_sha256": hashlib.sha256(
221
+ json.dumps(sorted(client.env.items()), separators=(",", ":")).encode()
222
+ ).hexdigest(),
223
+ "config_args": client.config_args,
224
+ "recipe": native_recipe_digest(client.adapter),
225
+ }
226
+ profile_id = hashlib.sha256(json.dumps(identity, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
227
+ context = FrozenNativeContext(
228
+ target=target,
229
+ profile_id=profile_id,
230
+ files=validated_files,
231
+ deadline=absolute_deadline,
232
+ environment_names=tuple(profile.inherit_env),
233
+ required_environment_names=tuple(profile.required_env),
234
+ )
235
+ self._check_environment_names(context)
236
+ return context
237
+
238
+ def validate_context(
239
+ self, target: BackendTarget, files: tuple[str, ...], images: tuple[str, ...] = ()
240
+ ) -> tuple[str, ...]:
241
+ if images:
242
+ raise BackendError("unsupported_capability", "Native image input has not been verified for this adapter.")
243
+ validated, _ = self.backend._context(CLIRequest(prompt="", target=target, files=files))
244
+ return validated
245
+
246
+ @staticmethod
247
+ def _compose(context: FrozenNativeContext, prompt: str) -> bytes:
248
+ if not isinstance(prompt, str) or not prompt.strip():
249
+ raise BackendError("invalid_config", "A native task prompt must be nonempty text.")
250
+ sections = [
251
+ "Execute the assigned scoped task using your configured native capabilities and permissions. "
252
+ "Preserve project/user instructions. Report changes, checks, unfinished work, and possible partial effects. "
253
+ "Do not claim independent verification for checks you did not perform.",
254
+ "<subordinate_role_expertise_json>\n"
255
+ + json.dumps(context.target.role.prompt_text, ensure_ascii=False)
256
+ + "\n</subordinate_role_expertise_json>",
257
+ "<declared_context_paths_json>\n"
258
+ + json.dumps({"files": context.files}, ensure_ascii=False)
259
+ + "\n</declared_context_paths_json>",
260
+ "<assigned_task_json>\n" + json.dumps(prompt, ensure_ascii=False) + "\n</assigned_task_json>",
261
+ ]
262
+ content = "\n\n".join(sections).encode("utf-8")
263
+ if len(content) > PROMPT_BYTES:
264
+ raise BackendError("input_limit", "The assembled native prompt exceeds 1 MiB UTF-8.")
265
+ return content
266
+
267
+ @staticmethod
268
+ def _command(
269
+ context: FrozenNativeContext, session_id: str | None, prompt_path: str | None = None
270
+ ) -> tuple[str, ...]:
271
+ return get_native_adapter(context.target.client.adapter).command(context, session_id, prompt_path)
272
+
273
+ @staticmethod
274
+ def _environment(context: FrozenNativeContext) -> dict[str, str]:
275
+ baseline_names = native_environment_names(context.target.client.adapter)
276
+ names = frozenset((*baseline_names, *context.environment_names))
277
+ result = {name: value for name, value in os.environ.items() if name in names}
278
+ additions = dict(context.target.client.env)
279
+ if any(name not in baseline_names for name in additions):
280
+ raise BackendError("invalid_config", "Native static environment additions must use baseline names.")
281
+ result.update(additions)
282
+ result["CLINK_DELEGATION_DEPTH"] = "1"
283
+ return result
284
+
285
+ def _validate_admission(self, context: FrozenNativeContext) -> None:
286
+ target = context.target
287
+ policy = self._policy(target.client.adapter)
288
+ if not policy.verified or policy != target.policy:
289
+ raise BackendError("policy_unverified", "The native admission snapshot is unavailable or changed.")
290
+ if not policy.version or not policy.executable_sha256 or not policy.recipe_sha256:
291
+ raise BackendError("policy_unverified", "Native admission must bind an exact binary/version and recipe.")
292
+ if native_recipe_digest(target.client.adapter) != policy.recipe_sha256:
293
+ raise BackendError("policy_unverified", "The native launch recipe changed after verification.")
294
+ if not policy.evidence or not policy.evidence_sha256:
295
+ raise BackendError("policy_unverified", "Native admission requires a pinned verification receipt.")
296
+ project = Path(__file__).resolve().parent.parent
297
+ path = (project / policy.evidence).resolve()
298
+ evidence_roots = (project / "clink" / "resources", project / "docs" / "verification")
299
+ if not any(root in path.parents for root in evidence_roots):
300
+ raise BackendError("policy_unverified", "Native evidence is outside the verification directory.")
301
+ try:
302
+ with path.open("rb") as stream:
303
+ evidence = stream.read(PROMPT_BYTES + 1)
304
+ except OSError as exc:
305
+ raise BackendError("policy_unverified", "Native admission evidence is unavailable.") from exc
306
+ if len(evidence) > PROMPT_BYTES or hashlib.sha256(evidence).hexdigest() != policy.evidence_sha256:
307
+ raise BackendError("policy_unverified", "Native admission evidence changed after verification.")
308
+
309
+ async def run(
310
+ self,
311
+ context: FrozenNativeContext,
312
+ prompt: str,
313
+ session_id: str | None = None,
314
+ *,
315
+ attempt_id: str,
316
+ started: Callable[[], None] | None = None,
317
+ ) -> NativeRunOutcome:
318
+ """Submit exactly once; process cleanup completes before cancellation propagates."""
319
+ started_at = time.monotonic()
320
+ configured_deadline = started_at + context.target.client.timeout_seconds
321
+ deadline = min(context.deadline, configured_deadline) if context.deadline is not None else configured_deadline
322
+ self._check_depth()
323
+ self._check_environment_names(context)
324
+ self._validate_admission(context)
325
+ content = self._compose(context, prompt)
326
+ adapter = get_native_adapter(context.target.client.adapter)
327
+ with prepare_native_launch(adapter, context, content, session_id) as launch:
328
+ try:
329
+ observed = await asyncio.wait_for(
330
+ asyncio.to_thread(executable_digest, launch.command[0]), max(0.0, deadline - time.monotonic())
331
+ )
332
+ except asyncio.TimeoutError as exc:
333
+ raise BackendError(
334
+ "timeout", "The native attempt deadline expired during executable verification."
335
+ ) from exc
336
+ except OSError as exc:
337
+ raise BackendError("cli_missing", "The admitted native executable is unavailable.") from exc
338
+ if observed != context.target.policy.executable_sha256:
339
+ raise BackendError(
340
+ "policy_unverified", "The installed native binary differs from its admission evidence."
341
+ )
342
+ environment: dict[str, str] = {}
343
+
344
+ def prelaunch() -> None:
345
+ self._check_depth()
346
+ self._check_environment_names(context)
347
+ self._validate_admission(context)
348
+ if executable_digest(launch.command[0]) != context.target.policy.executable_sha256:
349
+ raise BackendError("policy_unverified", "The admitted native executable changed before launch.")
350
+ cwd = _directory(context.target.working_directory, context.target.client.allowed_roots)
351
+ if cwd != context.target.working_directory:
352
+ raise BackendError("path_not_allowed", "The native project changed before launch.")
353
+ self.validate_context(context.target, context.files, context.images)
354
+ launch.validate_input()
355
+ environment.update(self._environment(context))
356
+
357
+ output = await self.pool.execute(
358
+ launch.command,
359
+ content=launch.stdin,
360
+ cwd=str(context.target.working_directory),
361
+ env=environment,
362
+ deadline=deadline,
363
+ request_id=attempt_id,
364
+ metadata={"client": context.target.cli_name, "role": context.target.role.name, "tool": "clink"},
365
+ prelaunch=prelaunch,
366
+ on_started=started,
367
+ )
368
+ metadata: dict[str, Any] = {
369
+ "exit_status": output.returncode,
370
+ "stdout_bytes": output.stdout_bytes,
371
+ "stderr_bytes": output.stderr_bytes,
372
+ "queue_seconds": output.queue_seconds,
373
+ "claims_verification": "agent_reported_unverified",
374
+ "goal_achieved": "unverified",
375
+ "configuration_sources": ["native_user", "native_project", "operator_environment_names"],
376
+ "verification_status": context.target.policy.verification_status,
377
+ }
378
+ try:
379
+ parsed = adapter.parse(output.stdout, output.stderr)
380
+ except ParserError as exc:
381
+ state = (
382
+ "failed"
383
+ if output.returncode != 0 or exc.code in {"authentication_required", "rate_limited"}
384
+ else "uncertain"
385
+ )
386
+ return NativeRunOutcome(state, metadata={**metadata, "error_code": exc.code})
387
+ if session_id is not None and parsed.session_id is not None and session_id != parsed.session_id:
388
+ return NativeRunOutcome("uncertain", metadata={**metadata, "error_code": "native_session_mismatch"})
389
+ state = parsed.state
390
+ if output.returncode != 0 and state == "completed":
391
+ state = "failed"
392
+ metadata["error_code"] = "nonzero_exit"
393
+ return NativeRunOutcome(
394
+ state,
395
+ content=parsed.content,
396
+ metadata={**metadata, **parsed.metadata},
397
+ session_id=parsed.session_id,
398
+ partial_effects=True,
399
+ )
400
+
401
+
402
+ _RUNTIME: NativeRuntime | None = None
403
+
404
+
405
+ def get_native_runtime() -> NativeRuntime:
406
+ global _RUNTIME
407
+ if _RUNTIME is None:
408
+ _RUNTIME = NativeRuntime()
409
+ return _RUNTIME