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/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Public helpers for clink components."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .registry import ClinkRegistry, get_registry
|
|
9
|
+
|
|
10
|
+
__all__ = ["ClinkRegistry", "get_registry"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def __getattr__(name: str) -> Any:
|
|
14
|
+
"""Keep setup imports free of runtime dotenv and registry initialization."""
|
|
15
|
+
if name in __all__:
|
|
16
|
+
from .registry import ClinkRegistry, get_registry
|
|
17
|
+
|
|
18
|
+
return {"ClinkRegistry": ClinkRegistry, "get_registry": get_registry}[name]
|
|
19
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
clink/agents/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Agent factory for clink CLI integrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from clink.models import ResolvedCLIClient
|
|
6
|
+
|
|
7
|
+
from .base import AgentOutput, BaseCLIAgent, CLIAgentError
|
|
8
|
+
from .claude import ClaudeAgent
|
|
9
|
+
from .codex import CodexAgent
|
|
10
|
+
from .gemini import GeminiAgent
|
|
11
|
+
|
|
12
|
+
_AGENTS: dict[str, type[BaseCLIAgent]] = {
|
|
13
|
+
"gemini": GeminiAgent,
|
|
14
|
+
"codex": CodexAgent,
|
|
15
|
+
"claude": ClaudeAgent,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_agent(client: ResolvedCLIClient) -> BaseCLIAgent:
|
|
20
|
+
agent_key = (client.runner or client.name).lower()
|
|
21
|
+
agent_cls = _AGENTS.get(agent_key, BaseCLIAgent)
|
|
22
|
+
return agent_cls(client)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AgentOutput",
|
|
27
|
+
"BaseCLIAgent",
|
|
28
|
+
"CLIAgentError",
|
|
29
|
+
"create_agent",
|
|
30
|
+
]
|
clink/agents/base.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Legacy agent facade; every execution uses the shared advisory backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from clink.contracts import BackendError, CLIRequest
|
|
9
|
+
from clink.models import ResolvedCLIClient, ResolvedCLIRole
|
|
10
|
+
from clink.parsers import ParsedCLIResponse
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class AgentOutput:
|
|
15
|
+
"""Compatibility response without raw streams, commands or native sessions."""
|
|
16
|
+
|
|
17
|
+
parsed: ParsedCLIResponse
|
|
18
|
+
sanitized_command: list[str]
|
|
19
|
+
returncode: int
|
|
20
|
+
stdout: str
|
|
21
|
+
stderr: str
|
|
22
|
+
duration_seconds: float
|
|
23
|
+
parser_name: str
|
|
24
|
+
output_file_content: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CLIAgentError(BackendError):
|
|
28
|
+
"""Compatibility name for explicit backend errors."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BaseCLIAgent:
|
|
32
|
+
"""Preserve old callers while prohibiting independent launch-policy paths."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, client: ResolvedCLIClient):
|
|
35
|
+
self.client = client
|
|
36
|
+
|
|
37
|
+
async def run(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
role: ResolvedCLIRole,
|
|
41
|
+
prompt: str,
|
|
42
|
+
system_prompt: str | None = None,
|
|
43
|
+
files: Sequence[str],
|
|
44
|
+
images: Sequence[str],
|
|
45
|
+
) -> AgentOutput:
|
|
46
|
+
from clink.backend import get_backend
|
|
47
|
+
|
|
48
|
+
backend = get_backend()
|
|
49
|
+
try:
|
|
50
|
+
target = backend.resolve(cli_name=self.client.name, role=role.name, images=tuple(images))
|
|
51
|
+
result = await backend.run(
|
|
52
|
+
CLIRequest(
|
|
53
|
+
prompt=prompt,
|
|
54
|
+
target=target,
|
|
55
|
+
system_prompt=system_prompt or "",
|
|
56
|
+
files=tuple(files),
|
|
57
|
+
images=tuple(images),
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
except BackendError as exc:
|
|
61
|
+
raise CLIAgentError(exc.code, str(exc), exc.remedy, exc.metadata) from exc
|
|
62
|
+
return AgentOutput(
|
|
63
|
+
parsed=ParsedCLIResponse(result.content, result.to_metadata()),
|
|
64
|
+
sanitized_command=[],
|
|
65
|
+
returncode=0,
|
|
66
|
+
stdout="",
|
|
67
|
+
stderr="",
|
|
68
|
+
duration_seconds=result.duration_seconds,
|
|
69
|
+
parser_name=target.client.parser,
|
|
70
|
+
)
|
clink/agents/claude.py
ADDED
clink/agents/codex.py
ADDED
clink/agents/gemini.py
ADDED
clink/allowed_roots.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""CLI-owned, explicit enrollment of canonical directories in operator manifests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
import stat
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import clink.registry as registry_module
|
|
17
|
+
from clink.registry import ClinkRegistry, ManifestSource, RegistryLoadError
|
|
18
|
+
from clink.setup import (
|
|
19
|
+
SetupError,
|
|
20
|
+
_check_parent,
|
|
21
|
+
_directory,
|
|
22
|
+
_identity,
|
|
23
|
+
_json_object,
|
|
24
|
+
_PlannedFile,
|
|
25
|
+
_publish,
|
|
26
|
+
_read_destination,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EnrollmentError(RuntimeError):
|
|
31
|
+
"""An enrollment failed; publication state is structural, never parsed from prose."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self, message: str, *, grant_saved: bool | None = False, error_code: str = "enrollment_failed"
|
|
35
|
+
) -> None:
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.grant_saved = grant_saved
|
|
38
|
+
self.error_code = error_code
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def canonical_directory(value: str) -> Path:
|
|
42
|
+
try:
|
|
43
|
+
path = Path(value).expanduser().resolve(strict=True)
|
|
44
|
+
if not path.is_dir() or not os.access(path, os.R_OK | os.X_OK):
|
|
45
|
+
raise OSError
|
|
46
|
+
return path
|
|
47
|
+
except (OSError, RuntimeError, ValueError):
|
|
48
|
+
raise EnrollmentError(f"Choose an existing accessible directory: {json.dumps(value)}") from None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def allow_command(
|
|
52
|
+
target: str, cli_name: str, project: Path | None = None, consumer_config_dir: str | Path | None = None
|
|
53
|
+
) -> str:
|
|
54
|
+
arguments = ["clink"]
|
|
55
|
+
if consumer_config_dir is not None:
|
|
56
|
+
arguments.extend(("--consumer-config-dir", str(consumer_config_dir)))
|
|
57
|
+
arguments.extend(("allow", target, "--cli", cli_name))
|
|
58
|
+
if project is not None:
|
|
59
|
+
arguments.extend(("--project", str(project)))
|
|
60
|
+
return shlex.join([*arguments, "--yes"])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def approved(registry: ClinkRegistry, cli_name: str, target: Path, *, native: bool = False) -> bool:
|
|
64
|
+
client = registry.get_client(cli_name)
|
|
65
|
+
eligible = (
|
|
66
|
+
{"user", "legacy_user", "explicit", "consumer"} if native else {"user", "legacy_user", "explicit", "project"}
|
|
67
|
+
)
|
|
68
|
+
if not client.allowed_roots_explicit or client.configuration_scope not in eligible:
|
|
69
|
+
return False
|
|
70
|
+
for root in client.allowed_roots:
|
|
71
|
+
if canonical_directory(str(root)) != root:
|
|
72
|
+
raise EnrollmentError("An approved directory changed; review the operator manifest before proceeding.")
|
|
73
|
+
return any(target == root or root in target.parents for root in client.allowed_roots)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _replace_roots(content: bytes, roots: list[str], path: Path) -> bytes:
|
|
77
|
+
"""Replace only the top-level value span, or insert without rewriting existing bytes."""
|
|
78
|
+
_json_object(content, path)
|
|
79
|
+
text = content.decode("utf-8")
|
|
80
|
+
decoder = json.JSONDecoder()
|
|
81
|
+
index = text.index("{") + 1
|
|
82
|
+
last_end = index
|
|
83
|
+
indent = " "
|
|
84
|
+
while True:
|
|
85
|
+
index += len(text[index:]) - len(text[index:].lstrip())
|
|
86
|
+
if text[index] == "}":
|
|
87
|
+
addition = json.dumps("allowed_roots") + ": " + json.dumps(roots, ensure_ascii=False)
|
|
88
|
+
prefix = "," if last_end != text.index("{") + 1 else ""
|
|
89
|
+
separator = ("\r\n" if "\r\n" in text else "\n") + indent if "\n" in text else " "
|
|
90
|
+
return (text[:last_end] + prefix + separator + addition + text[last_end:]).encode("utf-8")
|
|
91
|
+
line_start = text.rfind("\n", 0, index) + 1
|
|
92
|
+
if line_start and not text[line_start:index].strip():
|
|
93
|
+
indent = text[line_start:index]
|
|
94
|
+
key, end = decoder.raw_decode(text, index)
|
|
95
|
+
index = end
|
|
96
|
+
index += len(text[index:]) - len(text[index:].lstrip())
|
|
97
|
+
index += 1 # Strict object validation above established the colon.
|
|
98
|
+
index += len(text[index:]) - len(text[index:].lstrip())
|
|
99
|
+
previous_roots, end = decoder.raw_decode(text, index)
|
|
100
|
+
if key == "allowed_roots":
|
|
101
|
+
value = json.dumps(roots, ensure_ascii=False)
|
|
102
|
+
if isinstance(previous_roots, list) and roots[: len(previous_roots)] == previous_roots:
|
|
103
|
+
value = text[index:end]
|
|
104
|
+
for root in roots[len(previous_roots) :]:
|
|
105
|
+
entry = json.dumps(root, ensure_ascii=False)
|
|
106
|
+
insert = len(value[:-1].rstrip())
|
|
107
|
+
if "\n" in value:
|
|
108
|
+
last_line = value[:insert].rsplit("\n", 1)[-1]
|
|
109
|
+
array_indent = re.match(r"[\t ]*", last_line).group()
|
|
110
|
+
separator = ("\r\n" if "\r\n" in value else "\n") + array_indent
|
|
111
|
+
else:
|
|
112
|
+
separator = " "
|
|
113
|
+
comma = "," if previous_roots else ""
|
|
114
|
+
value = value[:insert] + comma + separator + entry + value[insert:]
|
|
115
|
+
previous_roots.append(root)
|
|
116
|
+
return (text[:index] + value + text[end:]).encode("utf-8")
|
|
117
|
+
last_end = end
|
|
118
|
+
index = end + len(text[end:]) - len(text[end:].lstrip())
|
|
119
|
+
if text[index] == ",":
|
|
120
|
+
index += 1
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _portable(registry: ClinkRegistry, cli_name: str, source: ManifestSource) -> dict[str, Any]:
|
|
124
|
+
data = _json_object(source.content, source.path)
|
|
125
|
+
client = registry.get_client(cli_name)
|
|
126
|
+
data["working_dir"] = str(client.working_dir)
|
|
127
|
+
data["allowed_roots"] = [str(root) for root in client.allowed_roots]
|
|
128
|
+
data["command"] = shlex.join(client.executable)
|
|
129
|
+
role_maps = [(data.setdefault("roles", {}), client.roles)]
|
|
130
|
+
if client.native_task is not None:
|
|
131
|
+
role_maps.append((data["native_task"].setdefault("roles", {}), client.native_task.roles))
|
|
132
|
+
for raw_roles, resolved_roles in role_maps:
|
|
133
|
+
for name, role in resolved_roles.items():
|
|
134
|
+
raw_roles.setdefault(name, {})["prompt_path"] = str(role.prompt_path)
|
|
135
|
+
return data
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parent_identities(path: Path) -> tuple[tuple[Path, int, int], ...]:
|
|
139
|
+
identities = []
|
|
140
|
+
for parent in reversed((path, *path.parents)):
|
|
141
|
+
try:
|
|
142
|
+
info = parent.lstat()
|
|
143
|
+
except FileNotFoundError:
|
|
144
|
+
break
|
|
145
|
+
if not stat.S_ISDIR(info.st_mode):
|
|
146
|
+
raise EnrollmentError(f"Configuration parent must be a real directory: {parent}")
|
|
147
|
+
identities.append((parent, info.st_dev, info.st_ino))
|
|
148
|
+
return tuple(identities)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class Enrollment:
|
|
153
|
+
registry: ClinkRegistry
|
|
154
|
+
cli_name: str
|
|
155
|
+
target: Path
|
|
156
|
+
target_spelling: str
|
|
157
|
+
target_identity: tuple[int, int]
|
|
158
|
+
source: ManifestSource
|
|
159
|
+
destination: Path
|
|
160
|
+
content: bytes
|
|
161
|
+
previous: bytes | None
|
|
162
|
+
identity: tuple[int, ...] | None
|
|
163
|
+
mode: int
|
|
164
|
+
already_allowed: bool
|
|
165
|
+
native: bool
|
|
166
|
+
lock_root: Path
|
|
167
|
+
parents: tuple[tuple[Path, int, int], ...]
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def entry(self) -> str:
|
|
171
|
+
return json.dumps(str(self.target), ensure_ascii=False)
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def broad(self) -> bool:
|
|
175
|
+
return self.target in {Path(self.target.anchor), Path.home().resolve()}
|
|
176
|
+
|
|
177
|
+
def _registry_snapshot(self) -> ClinkRegistry:
|
|
178
|
+
return ClinkRegistry(
|
|
179
|
+
project_dir=self.registry.project_dir,
|
|
180
|
+
consumer_config_dir=self.registry.consumer_config_dir,
|
|
181
|
+
_consumer_identity=self.registry.consumer_config_identity,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def _validate(self) -> None:
|
|
185
|
+
target = canonical_directory(self.target_spelling)
|
|
186
|
+
info = target.stat()
|
|
187
|
+
if target != self.target or (info.st_dev, info.st_ino) != self.target_identity:
|
|
188
|
+
raise EnrollmentError("Selected directory changed during approval.")
|
|
189
|
+
for parent, device, inode in self.parents:
|
|
190
|
+
current_parent = parent.lstat()
|
|
191
|
+
if not stat.S_ISDIR(current_parent.st_mode) or (current_parent.st_dev, current_parent.st_ino) != (
|
|
192
|
+
device,
|
|
193
|
+
inode,
|
|
194
|
+
):
|
|
195
|
+
raise EnrollmentError("Configuration parent changed during approval.")
|
|
196
|
+
current = self._registry_snapshot()
|
|
197
|
+
if current.manifest_source(self.cli_name) != self.source or current.get_client(
|
|
198
|
+
self.cli_name
|
|
199
|
+
) != self.registry.get_client(self.cli_name):
|
|
200
|
+
raise EnrollmentError("Effective configuration changed during approval.")
|
|
201
|
+
|
|
202
|
+
def persist(
|
|
203
|
+
self, *, guard: Callable[[], None] | None = None, published: Callable[[], None] | None = None
|
|
204
|
+
) -> ClinkRegistry:
|
|
205
|
+
if self.already_allowed:
|
|
206
|
+
return self.registry
|
|
207
|
+
saved = False
|
|
208
|
+
|
|
209
|
+
def mark_saved() -> None:
|
|
210
|
+
nonlocal saved
|
|
211
|
+
saved = True
|
|
212
|
+
if published is not None:
|
|
213
|
+
published()
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
if os.name != "posix":
|
|
217
|
+
raise EnrollmentError("Directory enrollment requires POSIX no-follow file operations.")
|
|
218
|
+
import fcntl
|
|
219
|
+
|
|
220
|
+
root = Path(self.destination.anchor)
|
|
221
|
+
descriptor = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
222
|
+
try:
|
|
223
|
+
self._validate()
|
|
224
|
+
parts = self.destination.relative_to(root).parts
|
|
225
|
+
with _directory(descriptor, parts[:-1], root, create=True) as lock_fd:
|
|
226
|
+
assert lock_fd is not None
|
|
227
|
+
try:
|
|
228
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
229
|
+
except BlockingIOError:
|
|
230
|
+
raise EnrollmentError(
|
|
231
|
+
"Another directory enrollment is running; review before retrying."
|
|
232
|
+
) from None
|
|
233
|
+
self._validate()
|
|
234
|
+
|
|
235
|
+
def prepublish() -> None:
|
|
236
|
+
self._validate()
|
|
237
|
+
_check_parent(descriptor, lock_fd, root, parts[:-1])
|
|
238
|
+
if guard is not None:
|
|
239
|
+
guard()
|
|
240
|
+
|
|
241
|
+
planned = _PlannedFile(
|
|
242
|
+
parts,
|
|
243
|
+
self.content,
|
|
244
|
+
self.previous,
|
|
245
|
+
self.identity,
|
|
246
|
+
self.mode,
|
|
247
|
+
)
|
|
248
|
+
_publish(descriptor, root, planned, prepublish=prepublish, published=mark_saved)
|
|
249
|
+
finally:
|
|
250
|
+
os.close(descriptor)
|
|
251
|
+
refreshed = self._registry_snapshot()
|
|
252
|
+
if not approved(refreshed, self.cli_name, self.target, native=self.native):
|
|
253
|
+
raise EnrollmentError("Saved configuration did not establish the requested authority.")
|
|
254
|
+
return refreshed
|
|
255
|
+
except (EnrollmentError, RegistryLoadError, SetupError, OSError, ValueError) as exc:
|
|
256
|
+
state = (
|
|
257
|
+
"Grant saved, but enrollment did not finish; inspect it before retrying" if saved else "No grant saved"
|
|
258
|
+
)
|
|
259
|
+
raise EnrollmentError(
|
|
260
|
+
f"{state}: configuration {self.destination}; allowed_roots entry {self.entry} "
|
|
261
|
+
f"({type(exc).__name__}: {exc}).",
|
|
262
|
+
grant_saved=saved,
|
|
263
|
+
error_code=exc.error_code if isinstance(exc, EnrollmentError) else "enrollment_failed",
|
|
264
|
+
) from None
|
|
265
|
+
except (asyncio.CancelledError, KeyboardInterrupt) as exc:
|
|
266
|
+
# Keep cancellation semantics while preserving the publication fact for callers.
|
|
267
|
+
exc.grant_saved = saved
|
|
268
|
+
raise
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def prepare_enrollment(
|
|
272
|
+
registry: ClinkRegistry,
|
|
273
|
+
cli_name: str,
|
|
274
|
+
value: str,
|
|
275
|
+
*,
|
|
276
|
+
native: bool = False,
|
|
277
|
+
) -> Enrollment:
|
|
278
|
+
"""Prepare a bounded immutable change without creating directories or writing files."""
|
|
279
|
+
target = canonical_directory(value)
|
|
280
|
+
destination: Path | str = "selected operator manifest"
|
|
281
|
+
try:
|
|
282
|
+
if os.name != "posix":
|
|
283
|
+
raise EnrollmentError("Directory enrollment requires POSIX no-follow file operations.")
|
|
284
|
+
client = registry.get_client(cli_name)
|
|
285
|
+
source = registry.manifest_source(cli_name)
|
|
286
|
+
if (
|
|
287
|
+
registry.consumer_config_dir is None
|
|
288
|
+
and client.configuration_scope != "bundled"
|
|
289
|
+
and source.path.is_relative_to(registry_module.CONFIG_DIR.resolve())
|
|
290
|
+
):
|
|
291
|
+
raise EnrollmentError(
|
|
292
|
+
f"Refusing to edit a bundled manifest selected as operator configuration: {source.path}. "
|
|
293
|
+
"Select an independent operator manifest with CLI_CLIENTS_CONFIG_PATH."
|
|
294
|
+
)
|
|
295
|
+
native = native or registry.consumer_config_dir is not None
|
|
296
|
+
already = approved(registry, cli_name, target, native=native)
|
|
297
|
+
if native and registry.project_dir is not None:
|
|
298
|
+
raise EnrollmentError("Native generation requires independent user or explicit configuration.")
|
|
299
|
+
destination = source.path
|
|
300
|
+
portable = client.configuration_scope == "bundled"
|
|
301
|
+
if portable:
|
|
302
|
+
destination = registry_module.USER_CONFIG_DIR / f"{client.name.lower()}.json"
|
|
303
|
+
destination = destination.absolute()
|
|
304
|
+
lock_root = destination.parent
|
|
305
|
+
root = Path(destination.anchor)
|
|
306
|
+
descriptor = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
307
|
+
try:
|
|
308
|
+
previous, info = _read_destination(descriptor, destination.relative_to(root).parts, root)
|
|
309
|
+
finally:
|
|
310
|
+
os.close(descriptor)
|
|
311
|
+
if portable and previous is not None:
|
|
312
|
+
raise EnrollmentError("A user manifest already occupies the override destination.")
|
|
313
|
+
if not portable and (previous != source.content or info is None or _identity(info) != source.identity):
|
|
314
|
+
raise EnrollmentError("Selected configuration changed before approval.")
|
|
315
|
+
data = _json_object(source.content, source.path)
|
|
316
|
+
roots = list(data.get("allowed_roots") or [str(root) for root in client.allowed_roots])
|
|
317
|
+
if not any(target == root or root in target.parents for root in client.allowed_roots):
|
|
318
|
+
roots.append(str(target))
|
|
319
|
+
if portable:
|
|
320
|
+
data = _portable(registry, cli_name, source)
|
|
321
|
+
data["allowed_roots"] = [str(root) for root in client.allowed_roots]
|
|
322
|
+
if str(target) not in data["allowed_roots"]:
|
|
323
|
+
data["allowed_roots"].append(str(target))
|
|
324
|
+
content = (json.dumps(data, indent=2, ensure_ascii=False, allow_nan=False) + "\n").encode()
|
|
325
|
+
else:
|
|
326
|
+
content = _replace_roots(source.content, roots, source.path)
|
|
327
|
+
if len(content) > registry_module.MAX_CONFIG_BYTES:
|
|
328
|
+
raise EnrollmentError("Updated configuration exceeds the bounded file limit.")
|
|
329
|
+
registry.validate_manifest(
|
|
330
|
+
_json_object(content, destination),
|
|
331
|
+
source_path=destination,
|
|
332
|
+
configuration_scope=("user" if portable else client.configuration_scope),
|
|
333
|
+
)
|
|
334
|
+
info_target = target.stat()
|
|
335
|
+
return Enrollment(
|
|
336
|
+
registry,
|
|
337
|
+
cli_name,
|
|
338
|
+
target,
|
|
339
|
+
value,
|
|
340
|
+
(info_target.st_dev, info_target.st_ino),
|
|
341
|
+
source,
|
|
342
|
+
destination,
|
|
343
|
+
content,
|
|
344
|
+
previous,
|
|
345
|
+
_identity(info) if info else None,
|
|
346
|
+
stat.S_IMODE(info.st_mode) if info else 0o600,
|
|
347
|
+
already,
|
|
348
|
+
native,
|
|
349
|
+
lock_root,
|
|
350
|
+
_parent_identities(destination.parent),
|
|
351
|
+
)
|
|
352
|
+
except (EnrollmentError, RegistryLoadError, SetupError, OSError, KeyError, ValueError) as exc:
|
|
353
|
+
raise EnrollmentError(
|
|
354
|
+
f"No grant saved: configuration {destination}; allowed_roots entry {json.dumps(str(target))}: {exc}",
|
|
355
|
+
grant_saved=False,
|
|
356
|
+
) from None
|