eve-memory-client 0.3.0__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.
- eve_client/__init__.py +24 -0
- eve_client/__main__.py +4 -0
- eve_client/_version.py +28 -0
- eve_client/apply.py +324 -0
- eve_client/atomic.py +58 -0
- eve_client/auth/__init__.py +12 -0
- eve_client/auth/base.py +59 -0
- eve_client/auth/file_store.py +34 -0
- eve_client/auth/keyring_store.py +34 -0
- eve_client/auth/local_store.py +162 -0
- eve_client/backup.py +68 -0
- eve_client/claude_hook_entry.py +4 -0
- eve_client/claude_hooks.py +498 -0
- eve_client/cli.py +2028 -0
- eve_client/config.py +335 -0
- eve_client/detect/__init__.py +5 -0
- eve_client/detect/base.py +98 -0
- eve_client/gemini_hook_entry.py +4 -0
- eve_client/gemini_hooks.py +454 -0
- eve_client/importer/__init__.py +47 -0
- eve_client/importer/adapters.py +685 -0
- eve_client/importer/ledger.py +712 -0
- eve_client/importer/models.py +185 -0
- eve_client/importer/upload.py +790 -0
- eve_client/integrations/__init__.py +5 -0
- eve_client/integrations/claude_code.py +112 -0
- eve_client/integrations/claude_desktop.py +36 -0
- eve_client/integrations/codex_cli.py +86 -0
- eve_client/integrations/gemini_cli.py +128 -0
- eve_client/integrations/provider.py +56 -0
- eve_client/integrations/registry.py +21 -0
- eve_client/integrity.py +89 -0
- eve_client/interactive.py +312 -0
- eve_client/lock.py +116 -0
- eve_client/manifest.py +173 -0
- eve_client/memory_cli.py +240 -0
- eve_client/merge.py +815 -0
- eve_client/models.py +144 -0
- eve_client/oauth_device.py +188 -0
- eve_client/operation_policy.py +99 -0
- eve_client/operations.py +140 -0
- eve_client/path_policy.py +76 -0
- eve_client/plan.py +67 -0
- eve_client/recovery.py +27 -0
- eve_client/safe_fs.py +92 -0
- eve_client/scope.py +263 -0
- eve_client/server.py +574 -0
- eve_client/state_binding.py +167 -0
- eve_client/state_dir.py +72 -0
- eve_client/tool_state.py +79 -0
- eve_client/transaction_state.py +57 -0
- eve_client/tty.py +13 -0
- eve_client/uninstall.py +185 -0
- eve_client/verify.py +302 -0
- eve_memory_client-0.3.0.dist-info/METADATA +488 -0
- eve_memory_client-0.3.0.dist-info/RECORD +59 -0
- eve_memory_client-0.3.0.dist-info/WHEEL +4 -0
- eve_memory_client-0.3.0.dist-info/entry_points.txt +5 -0
- eve_memory_client-0.3.0.dist-info/licenses/LICENSE +200 -0
eve_client/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Eve client installer package."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .auth import CredentialRecord, CredentialStore, LocalCredentialStore
|
|
5
|
+
from .cli import app, main
|
|
6
|
+
from .config import ResolvedConfig
|
|
7
|
+
from .integrations.provider import ToolProvider
|
|
8
|
+
from .models import ApplyResult, InstallPlan, PlannedAction, RollbackResult, ToolPlan
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ApplyResult",
|
|
12
|
+
"CredentialRecord",
|
|
13
|
+
"CredentialStore",
|
|
14
|
+
"InstallPlan",
|
|
15
|
+
"LocalCredentialStore",
|
|
16
|
+
"PlannedAction",
|
|
17
|
+
"ResolvedConfig",
|
|
18
|
+
"RollbackResult",
|
|
19
|
+
"ToolPlan",
|
|
20
|
+
"ToolProvider",
|
|
21
|
+
"__version__",
|
|
22
|
+
"app",
|
|
23
|
+
"main",
|
|
24
|
+
]
|
eve_client/__main__.py
ADDED
eve_client/_version.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from importlib.metadata import PackageNotFoundError
|
|
6
|
+
from importlib.metadata import version as installed_version
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _version_from_pyproject() -> str | None:
|
|
11
|
+
pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
|
12
|
+
if not pyproject_path.exists():
|
|
13
|
+
return None
|
|
14
|
+
data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
|
|
15
|
+
return str(data["project"]["version"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolve_version() -> str:
|
|
19
|
+
build_version = os.environ.get("EVE_CLIENT_BUILD_VERSION")
|
|
20
|
+
if build_version:
|
|
21
|
+
return build_version
|
|
22
|
+
try:
|
|
23
|
+
return installed_version("eve-memory-client")
|
|
24
|
+
except PackageNotFoundError:
|
|
25
|
+
return _version_from_pyproject() or "0.0.0+unknown"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__version__ = resolve_version()
|
eve_client/apply.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Install plan execution with transaction-aware rollback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from uuid import uuid4
|
|
8
|
+
|
|
9
|
+
from eve_client.auth import CredentialStore
|
|
10
|
+
from eve_client.backup import create_backup, restore_backup, sha256_file, validate_config
|
|
11
|
+
from eve_client.config import ResolvedConfig
|
|
12
|
+
from eve_client.lock import installer_lock
|
|
13
|
+
from eve_client.manifest import load_manifest, write_manifest
|
|
14
|
+
from eve_client.models import (
|
|
15
|
+
ApplyResult,
|
|
16
|
+
InstallPlan,
|
|
17
|
+
ManifestRecord,
|
|
18
|
+
PlannedAction,
|
|
19
|
+
RollbackResult,
|
|
20
|
+
)
|
|
21
|
+
from eve_client.operation_policy import OperationPolicyError, validate_action_policy
|
|
22
|
+
from eve_client.operations import OperationContext, OperationError, execute_operation
|
|
23
|
+
from eve_client.plan import feature_enabled_for_tool
|
|
24
|
+
from eve_client.safe_fs import SafeFS
|
|
25
|
+
from eve_client.transaction_state import clear_transaction_state, write_transaction_state
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ApplyPlanError(RuntimeError):
|
|
29
|
+
"""Raised when an install plan cannot be applied safely."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RollbackConflictError(ApplyPlanError):
|
|
33
|
+
"""Raised when rollback would overwrite newer local changes."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, message: str, *, conflicted_paths: list[str] | None = None) -> None:
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.conflicted_paths = conflicted_paths or []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class AppliedWrite:
|
|
42
|
+
action: PlannedAction
|
|
43
|
+
path: Path
|
|
44
|
+
backup_path: Path | None
|
|
45
|
+
backup_sha256: str | None
|
|
46
|
+
created_new_file: bool
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _allowed_roots_for_action(action: PlannedAction, config: ResolvedConfig) -> list[Path]:
|
|
50
|
+
if action.scope == "state":
|
|
51
|
+
return [config.state_dir]
|
|
52
|
+
if action.scope == "global-config" and action.path:
|
|
53
|
+
return [action.path.parent]
|
|
54
|
+
if action.scope == "project" and action.path:
|
|
55
|
+
return [config.project_root]
|
|
56
|
+
return [config.state_dir]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _apply_action(
|
|
60
|
+
action: PlannedAction,
|
|
61
|
+
config: ResolvedConfig,
|
|
62
|
+
credential_store: CredentialStore,
|
|
63
|
+
secret: str | None,
|
|
64
|
+
auth_mode: str | None,
|
|
65
|
+
transaction_id: str,
|
|
66
|
+
) -> AppliedWrite | None:
|
|
67
|
+
try:
|
|
68
|
+
validate_action_policy(action, config)
|
|
69
|
+
except OperationPolicyError as exc:
|
|
70
|
+
raise ApplyPlanError(str(exc)) from exc
|
|
71
|
+
try:
|
|
72
|
+
rendered = execute_operation(
|
|
73
|
+
OperationContext(
|
|
74
|
+
config=config,
|
|
75
|
+
credentials=credential_store,
|
|
76
|
+
action=action,
|
|
77
|
+
secret=secret,
|
|
78
|
+
auth_mode=auth_mode,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
except OperationError as exc:
|
|
82
|
+
raise ApplyPlanError(str(exc)) from exc
|
|
83
|
+
if rendered.content is None:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
if action.path is None:
|
|
87
|
+
raise ApplyPlanError(f"{action.action_id} has no path")
|
|
88
|
+
|
|
89
|
+
path = action.path
|
|
90
|
+
created_new_file = not path.exists()
|
|
91
|
+
backup_path, backup_sha256 = (
|
|
92
|
+
create_backup(
|
|
93
|
+
path,
|
|
94
|
+
state_dir=config.state_dir,
|
|
95
|
+
transaction_id=transaction_id,
|
|
96
|
+
action_id=action.action_id,
|
|
97
|
+
)
|
|
98
|
+
if action.requires_backup and path.exists()
|
|
99
|
+
else (None, None)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
SafeFS.from_roots(_allowed_roots_for_action(action, config)).write_text_atomic(
|
|
103
|
+
path,
|
|
104
|
+
rendered.content,
|
|
105
|
+
permissions=rendered.permissions or 0o600,
|
|
106
|
+
)
|
|
107
|
+
if action.action_type == "write_config" and not validate_config(
|
|
108
|
+
path, action.details["config_format"]
|
|
109
|
+
):
|
|
110
|
+
raise ApplyPlanError(f"Rendered config for {action.tool} failed validation")
|
|
111
|
+
|
|
112
|
+
return AppliedWrite(
|
|
113
|
+
action=action,
|
|
114
|
+
path=path,
|
|
115
|
+
backup_path=backup_path,
|
|
116
|
+
backup_sha256=backup_sha256,
|
|
117
|
+
created_new_file=created_new_file,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _rollback_applied_writes(applied_writes: list[AppliedWrite], config: ResolvedConfig) -> None:
|
|
122
|
+
for applied in reversed(applied_writes):
|
|
123
|
+
if applied.backup_path:
|
|
124
|
+
restore_backup(
|
|
125
|
+
applied.backup_path,
|
|
126
|
+
applied.path,
|
|
127
|
+
allowed_roots=_allowed_roots_for_action(applied.action, config),
|
|
128
|
+
)
|
|
129
|
+
elif applied.created_new_file and applied.path.exists():
|
|
130
|
+
SafeFS.from_roots(_allowed_roots_for_action(applied.action, config)).delete_file(
|
|
131
|
+
applied.path
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _ensure_record_matches_current_file(record: ManifestRecord) -> None:
|
|
136
|
+
path = Path(record.path)
|
|
137
|
+
if record.sha256 is None:
|
|
138
|
+
return
|
|
139
|
+
if not path.exists():
|
|
140
|
+
return
|
|
141
|
+
current_sha = sha256_file(path)
|
|
142
|
+
if current_sha != record.sha256:
|
|
143
|
+
raise RollbackConflictError(
|
|
144
|
+
f"Refusing to rollback {path}; file changed since Eve wrote it."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _ensure_backup_integrity(record: ManifestRecord) -> None:
|
|
149
|
+
if not record.backup_path or not record.backup_sha256:
|
|
150
|
+
return
|
|
151
|
+
backup_path = Path(record.backup_path)
|
|
152
|
+
if not backup_path.exists():
|
|
153
|
+
raise RollbackConflictError(f"Backup missing for rollback: {backup_path}")
|
|
154
|
+
current_sha = sha256_file(backup_path)
|
|
155
|
+
if current_sha != record.backup_sha256:
|
|
156
|
+
raise RollbackConflictError(f"Backup integrity check failed for {backup_path}")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _verify_restored_target(record: ManifestRecord) -> None:
|
|
160
|
+
if not record.backup_sha256 or not record.path:
|
|
161
|
+
return
|
|
162
|
+
path = Path(record.path)
|
|
163
|
+
if not path.exists():
|
|
164
|
+
raise RollbackConflictError(f"Rollback target missing after restore: {path}")
|
|
165
|
+
restored_sha = sha256_file(path)
|
|
166
|
+
if restored_sha != record.backup_sha256:
|
|
167
|
+
raise RollbackConflictError(f"Rollback restore hash mismatch for {path}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _preflight_rollback(records: list[ManifestRecord]) -> None:
|
|
171
|
+
conflicts: list[str] = []
|
|
172
|
+
for record in reversed(records):
|
|
173
|
+
try:
|
|
174
|
+
_ensure_backup_integrity(record)
|
|
175
|
+
_ensure_record_matches_current_file(record)
|
|
176
|
+
except RollbackConflictError as exc:
|
|
177
|
+
conflicts.append(str(exc))
|
|
178
|
+
if conflicts:
|
|
179
|
+
raise RollbackConflictError(
|
|
180
|
+
"Rollback blocked by file conflicts or backup integrity failures.",
|
|
181
|
+
conflicted_paths=conflicts,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def apply_install_plan(
|
|
186
|
+
plan: InstallPlan,
|
|
187
|
+
config: ResolvedConfig,
|
|
188
|
+
credential_store: CredentialStore,
|
|
189
|
+
provided_secrets: dict[str, str] | None = None,
|
|
190
|
+
provided_api_keys: dict[str, str] | None = None,
|
|
191
|
+
auth_overrides: dict[str, str] | None = None,
|
|
192
|
+
allowed_tools: list[str] | None = None,
|
|
193
|
+
) -> ApplyResult:
|
|
194
|
+
transaction_id = str(uuid4())
|
|
195
|
+
provided_secrets = provided_secrets or provided_api_keys or {}
|
|
196
|
+
auth_overrides = auth_overrides or {}
|
|
197
|
+
with installer_lock(config.state_dir):
|
|
198
|
+
for tool_plan in plan.tool_plans:
|
|
199
|
+
if allowed_tools and tool_plan.tool not in allowed_tools:
|
|
200
|
+
continue
|
|
201
|
+
if (
|
|
202
|
+
tool_plan.tool == "codex-cli"
|
|
203
|
+
and tool_plan.supported
|
|
204
|
+
and bool(tool_plan.actions)
|
|
205
|
+
and not feature_enabled_for_tool(tool_plan.tool, config)
|
|
206
|
+
):
|
|
207
|
+
raise ApplyPlanError(
|
|
208
|
+
"Codex CLI steps are present in this plan, but Codex is disabled at execution time."
|
|
209
|
+
)
|
|
210
|
+
all_records = load_manifest(
|
|
211
|
+
config.state_dir, allow_file_fallback=config.allow_file_secret_fallback
|
|
212
|
+
)
|
|
213
|
+
write_transaction_state(
|
|
214
|
+
config.state_dir,
|
|
215
|
+
{
|
|
216
|
+
"transaction_id": transaction_id,
|
|
217
|
+
"phase": "applying",
|
|
218
|
+
"environment": plan.environment,
|
|
219
|
+
"tools": [tool_plan.tool for tool_plan in plan.tool_plans],
|
|
220
|
+
},
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
applied_tools: list[str] = []
|
|
224
|
+
applied_actions = 0
|
|
225
|
+
for tool_plan in plan.tool_plans:
|
|
226
|
+
if allowed_tools and tool_plan.tool not in allowed_tools:
|
|
227
|
+
continue
|
|
228
|
+
if not feature_enabled_for_tool(tool_plan.tool, config):
|
|
229
|
+
continue
|
|
230
|
+
if not tool_plan.supported:
|
|
231
|
+
continue
|
|
232
|
+
applied_writes: list[AppliedWrite] = []
|
|
233
|
+
try:
|
|
234
|
+
for action in tool_plan.actions:
|
|
235
|
+
write_transaction_state(
|
|
236
|
+
config.state_dir,
|
|
237
|
+
{
|
|
238
|
+
"transaction_id": transaction_id,
|
|
239
|
+
"phase": "applying",
|
|
240
|
+
"environment": plan.environment,
|
|
241
|
+
"tool": tool_plan.tool,
|
|
242
|
+
"action_id": action.action_id,
|
|
243
|
+
"action_type": action.action_type,
|
|
244
|
+
},
|
|
245
|
+
)
|
|
246
|
+
applied = _apply_action(
|
|
247
|
+
action,
|
|
248
|
+
config,
|
|
249
|
+
credential_store,
|
|
250
|
+
provided_secrets.get(tool_plan.tool),
|
|
251
|
+
auth_overrides.get(tool_plan.tool) or tool_plan.auth_mode,
|
|
252
|
+
transaction_id,
|
|
253
|
+
)
|
|
254
|
+
if applied:
|
|
255
|
+
applied_writes.append(applied)
|
|
256
|
+
applied_actions += 1
|
|
257
|
+
except Exception:
|
|
258
|
+
_rollback_applied_writes(applied_writes, config)
|
|
259
|
+
raise
|
|
260
|
+
|
|
261
|
+
for applied in applied_writes:
|
|
262
|
+
all_records.append(
|
|
263
|
+
ManifestRecord(
|
|
264
|
+
transaction_id=transaction_id,
|
|
265
|
+
tool=applied.action.tool,
|
|
266
|
+
action_id=applied.action.action_id,
|
|
267
|
+
action_type=applied.action.action_type,
|
|
268
|
+
path=str(applied.path),
|
|
269
|
+
backup_path=str(applied.backup_path) if applied.backup_path else None,
|
|
270
|
+
sha256=sha256_file(applied.path),
|
|
271
|
+
backup_sha256=applied.backup_sha256,
|
|
272
|
+
scope=applied.action.scope,
|
|
273
|
+
environment=plan.environment,
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
if tool_plan.actions:
|
|
277
|
+
applied_tools.append(tool_plan.tool)
|
|
278
|
+
|
|
279
|
+
write_manifest(
|
|
280
|
+
config.state_dir, all_records, allow_file_fallback=config.allow_file_secret_fallback
|
|
281
|
+
)
|
|
282
|
+
clear_transaction_state(config.state_dir)
|
|
283
|
+
return ApplyResult(
|
|
284
|
+
transaction_id=transaction_id,
|
|
285
|
+
applied_actions=applied_actions,
|
|
286
|
+
applied_tools=applied_tools,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def rollback_transaction(config: ResolvedConfig, transaction_id: str) -> RollbackResult:
|
|
291
|
+
with installer_lock(config.state_dir):
|
|
292
|
+
write_transaction_state(
|
|
293
|
+
config.state_dir,
|
|
294
|
+
{"transaction_id": transaction_id, "phase": "rollback"},
|
|
295
|
+
)
|
|
296
|
+
records = load_manifest(
|
|
297
|
+
config.state_dir, allow_file_fallback=config.allow_file_secret_fallback
|
|
298
|
+
)
|
|
299
|
+
target = [record for record in records if record.transaction_id == transaction_id]
|
|
300
|
+
_preflight_rollback(target)
|
|
301
|
+
restored = 0
|
|
302
|
+
for record in reversed(target):
|
|
303
|
+
path = Path(record.path)
|
|
304
|
+
if record.backup_path:
|
|
305
|
+
if record.scope == "state":
|
|
306
|
+
allowed_roots = [config.state_dir]
|
|
307
|
+
elif record.scope == "project":
|
|
308
|
+
allowed_roots = [config.project_root]
|
|
309
|
+
else:
|
|
310
|
+
allowed_roots = [path.parent]
|
|
311
|
+
restore_backup(Path(record.backup_path), path, allowed_roots=allowed_roots)
|
|
312
|
+
_verify_restored_target(record)
|
|
313
|
+
elif path.exists():
|
|
314
|
+
SafeFS.from_roots(
|
|
315
|
+
[config.project_root] if record.scope == "project" else [path.parent]
|
|
316
|
+
).delete_file(path)
|
|
317
|
+
restored += 1
|
|
318
|
+
write_manifest(
|
|
319
|
+
config.state_dir,
|
|
320
|
+
[record for record in records if record.transaction_id != transaction_id],
|
|
321
|
+
allow_file_fallback=config.allow_file_secret_fallback,
|
|
322
|
+
)
|
|
323
|
+
clear_transaction_state(config.state_dir)
|
|
324
|
+
return RollbackResult(transaction_id=transaction_id, restored_actions=restored)
|
eve_client/atomic.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Atomic write helpers for Eve client installer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import stat
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from eve_client.path_policy import PathPolicy, ensure_path_is_safe
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _validate_existing_target(dir_fd: int, target_name: str, target_path: Path) -> None:
|
|
15
|
+
try:
|
|
16
|
+
stats = os.stat(target_name, dir_fd=dir_fd, follow_symlinks=False)
|
|
17
|
+
except FileNotFoundError:
|
|
18
|
+
return
|
|
19
|
+
if not stat.S_ISREG(stats.st_mode):
|
|
20
|
+
raise OSError(f"Refusing to operate on non-regular file: {target_path}")
|
|
21
|
+
if stats.st_nlink > 1:
|
|
22
|
+
raise OSError(f"Refusing to operate on multiply-linked file: {target_path}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def atomic_write(
|
|
26
|
+
path: Path,
|
|
27
|
+
content: str,
|
|
28
|
+
permissions: int = 0o600,
|
|
29
|
+
allowed_roots: list[Path] | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
policy = PathPolicy.from_roots(allowed_roots or [path.parent])
|
|
32
|
+
target = ensure_path_is_safe(path, policy)
|
|
33
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
dir_flags = os.O_RDONLY
|
|
35
|
+
if hasattr(os, "O_DIRECTORY"):
|
|
36
|
+
dir_flags |= os.O_DIRECTORY
|
|
37
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
38
|
+
dir_flags |= os.O_NOFOLLOW
|
|
39
|
+
dir_fd = os.open(target.parent, dir_flags)
|
|
40
|
+
_validate_existing_target(dir_fd, target.name, target)
|
|
41
|
+
tmp_name = f".{target.name}.{secrets.token_hex(8)}.tmp"
|
|
42
|
+
file_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
43
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
44
|
+
file_flags |= os.O_NOFOLLOW
|
|
45
|
+
fd = os.open(tmp_name, file_flags, permissions, dir_fd=dir_fd)
|
|
46
|
+
try:
|
|
47
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
48
|
+
handle.write(content)
|
|
49
|
+
handle.flush()
|
|
50
|
+
os.fsync(handle.fileno())
|
|
51
|
+
os.replace(tmp_name, target.name, src_dir_fd=dir_fd, dst_dir_fd=dir_fd)
|
|
52
|
+
os.fsync(dir_fd)
|
|
53
|
+
except BaseException:
|
|
54
|
+
with contextlib.suppress(OSError):
|
|
55
|
+
os.unlink(tmp_name, dir_fd=dir_fd)
|
|
56
|
+
raise
|
|
57
|
+
finally:
|
|
58
|
+
os.close(dir_fd)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Credential storage for Eve client."""
|
|
2
|
+
|
|
3
|
+
from .base import CredentialRecord, CredentialStore, CredentialStoreUnavailableError, OAuthSession
|
|
4
|
+
from .local_store import LocalCredentialStore
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"CredentialRecord",
|
|
8
|
+
"CredentialStore",
|
|
9
|
+
"CredentialStoreUnavailableError",
|
|
10
|
+
"LocalCredentialStore",
|
|
11
|
+
"OAuthSession",
|
|
12
|
+
]
|
eve_client/auth/base.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Credential store interfaces for the Eve client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
from eve_client.models import ToolName
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class CredentialRecord:
|
|
13
|
+
tool: ToolName
|
|
14
|
+
auth_mode: str
|
|
15
|
+
source: str
|
|
16
|
+
value_masked: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class OAuthSession:
|
|
21
|
+
tool: ToolName
|
|
22
|
+
access_token: str
|
|
23
|
+
refresh_token: str | None = None
|
|
24
|
+
expires_at: int | None = None
|
|
25
|
+
scope: str | None = None
|
|
26
|
+
token_type: str = "Bearer"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CredentialStore(Protocol):
|
|
30
|
+
def set_api_key(self, tool: ToolName, api_key: str) -> CredentialRecord:
|
|
31
|
+
"""Persist an API key for a supported tool."""
|
|
32
|
+
|
|
33
|
+
def get_api_key(self, tool: ToolName) -> tuple[str | None, str | None]:
|
|
34
|
+
"""Load an API key for a supported tool."""
|
|
35
|
+
|
|
36
|
+
def delete_api_key(self, tool: ToolName) -> None:
|
|
37
|
+
"""Delete any stored API key for a supported tool."""
|
|
38
|
+
|
|
39
|
+
def set_bearer_token(self, tool: ToolName, token: str) -> CredentialRecord:
|
|
40
|
+
"""Persist a bearer token for a supported tool."""
|
|
41
|
+
|
|
42
|
+
def get_bearer_token(self, tool: ToolName) -> tuple[str | None, str | None]:
|
|
43
|
+
"""Load a bearer token for a supported tool."""
|
|
44
|
+
|
|
45
|
+
def delete_bearer_token(self, tool: ToolName) -> None:
|
|
46
|
+
"""Delete any stored bearer token for a supported tool."""
|
|
47
|
+
|
|
48
|
+
def set_oauth_session(self, session: OAuthSession) -> CredentialRecord:
|
|
49
|
+
"""Persist a structured OAuth session for a supported tool."""
|
|
50
|
+
|
|
51
|
+
def get_oauth_session(self, tool: ToolName) -> tuple[OAuthSession | None, str | None]:
|
|
52
|
+
"""Load a structured OAuth session for a supported tool."""
|
|
53
|
+
|
|
54
|
+
def delete_oauth_session(self, tool: ToolName) -> None:
|
|
55
|
+
"""Delete any stored structured OAuth session for a supported tool."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CredentialStoreUnavailableError(RuntimeError):
|
|
59
|
+
"""Raised when no approved credential backend is available."""
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""File-based credential storage fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from eve_client.safe_fs import SafeFS
|
|
9
|
+
from eve_client.state_dir import ensure_private_state_dir
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FileCredentialStore:
|
|
13
|
+
def __init__(self, path: Path, state_dir: Path) -> None:
|
|
14
|
+
self.path = path
|
|
15
|
+
self.state_dir = state_dir
|
|
16
|
+
|
|
17
|
+
def load(self) -> dict[str, str]:
|
|
18
|
+
if not self.path.exists():
|
|
19
|
+
return {}
|
|
20
|
+
try:
|
|
21
|
+
ensure_private_state_dir(self.state_dir)
|
|
22
|
+
return json.loads(
|
|
23
|
+
SafeFS.from_roots([self.state_dir]).read_text(self.path, encoding="utf-8")
|
|
24
|
+
)
|
|
25
|
+
except (OSError, json.JSONDecodeError):
|
|
26
|
+
return {}
|
|
27
|
+
|
|
28
|
+
def write(self, payload: dict[str, str]) -> None:
|
|
29
|
+
ensure_private_state_dir(self.state_dir)
|
|
30
|
+
SafeFS.from_roots([self.state_dir]).write_text_atomic(
|
|
31
|
+
self.path,
|
|
32
|
+
json.dumps(payload, indent=2) + "\n",
|
|
33
|
+
permissions=0o600,
|
|
34
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Keyring-backed credential storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import keyring
|
|
6
|
+
from keyring.errors import KeyringError, PasswordDeleteError
|
|
7
|
+
|
|
8
|
+
SERVICE_NAME = "eve-client"
|
|
9
|
+
WEAK_BACKEND_MARKERS = ("fail", "plaintext", "chainer", "null")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class KeyringCredentialStore:
|
|
13
|
+
def get(self, key_name: str) -> str | None:
|
|
14
|
+
return keyring.get_password(SERVICE_NAME, key_name)
|
|
15
|
+
|
|
16
|
+
def set(self, key_name: str, secret: str) -> None:
|
|
17
|
+
keyring.set_password(SERVICE_NAME, key_name, secret)
|
|
18
|
+
|
|
19
|
+
def delete(self, key_name: str) -> None:
|
|
20
|
+
try:
|
|
21
|
+
keyring.delete_password(SERVICE_NAME, key_name)
|
|
22
|
+
except PasswordDeleteError:
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
def backend_name(self) -> str:
|
|
26
|
+
backend = keyring.get_keyring()
|
|
27
|
+
return f"{backend.__class__.__module__}.{backend.__class__.__name__}"
|
|
28
|
+
|
|
29
|
+
def backend_is_low_assurance(self) -> bool:
|
|
30
|
+
name = self.backend_name().lower()
|
|
31
|
+
return any(marker in name for marker in WEAK_BACKEND_MARKERS)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = ["KeyringCredentialStore", "KeyringError"]
|