vidbyte-cli 0.1.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.
- vidbyte_cli/__init__.py +11 -0
- vidbyte_cli/__main__.py +6 -0
- vidbyte_cli/cli.py +18 -0
- vidbyte_cli/commands/__init__.py +86 -0
- vidbyte_cli/commands/agent_options.py +53 -0
- vidbyte_cli/commands/agents/__init__.py +31 -0
- vidbyte_cli/commands/agents/suggestion/__init__.py +38 -0
- vidbyte_cli/commands/agents/suggestion/prompts/__init__.py +3 -0
- vidbyte_cli/commands/agents/suggestion/prompts/all_categories.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/approaches.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/avoid.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/blockers.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/capability.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/categories.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/category.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/completed.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/constraint.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/context.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/count.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/critic_model.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/decision.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/dry_run.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/extra_compute.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_accept.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_group.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_project.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_reason.md +98 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_reject.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/feedback_suggestion.md +101 -0
- vidbyte_cli/commands/agents/suggestion/prompts/files.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/forbidden.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/goal.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/handoff.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/horizon.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/hypotheses.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/idea.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/in_progress.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/input.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/library.py +29 -0
- vidbyte_cli/commands/agents/suggestion/prompts/max_messages.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/max_output_tokens.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/max_total_tokens.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/mistakes.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/outcomes.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_create.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_description.md +98 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_group.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_key.md +96 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_list.md +7 -0
- vidbyte_cli/commands/agents/suggestion/prompts/project_title.md +99 -0
- vidbyte_cli/commands/agents/suggestion/prompts/provider.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/question.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/risks.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/rounds.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/run.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/success.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/timeout.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/trajectory.md +100 -0
- vidbyte_cli/commands/agents/suggestion/prompts/view.md +5 -0
- vidbyte_cli/commands/agents/suggestion/prompts/view_all.md +5 -0
- vidbyte_cli/commands/agents/suggestion/render.py +77 -0
- vidbyte_cli/commands/agents/suggestion/request_builder.py +240 -0
- vidbyte_cli/commands/agents/suggestion/suggest.py +154 -0
- vidbyte_cli/commands/agents/suggestion/suggestion_categories.py +77 -0
- vidbyte_cli/commands/agents/suggestion/suggestion_feedback.py +85 -0
- vidbyte_cli/commands/agents/suggestion/suggestion_handoff.py +67 -0
- vidbyte_cli/commands/agents/suggestion/suggestion_projects.py +95 -0
- vidbyte_cli/commands/auth/__init__.py +0 -0
- vidbyte_cli/commands/auth/login.py +100 -0
- vidbyte_cli/commands/auth/logout.py +41 -0
- vidbyte_cli/commands/auth/whoami.py +51 -0
- vidbyte_cli/commands/billing/__init__.py +20 -0
- vidbyte_cli/commands/billing/prompts/__init__.py +1 -0
- vidbyte_cli/commands/billing/prompts/confirm.md +16 -0
- vidbyte_cli/commands/billing/prompts/group.md +13 -0
- vidbyte_cli/commands/billing/prompts/idempotency_key.md +16 -0
- vidbyte_cli/commands/billing/prompts/library.py +18 -0
- vidbyte_cli/commands/billing/prompts/top_up.md +16 -0
- vidbyte_cli/commands/billing/top_up.py +65 -0
- vidbyte_cli/commands/config/__init__.py +0 -0
- vidbyte_cli/commands/config/get.py +45 -0
- vidbyte_cli/commands/config/set.py +39 -0
- vidbyte_cli/commands/provider/__init__.py +9 -0
- vidbyte_cli/commands/provider/login.py +102 -0
- vidbyte_cli/commands/provider/logout.py +45 -0
- vidbyte_cli/commands/provider/whoami.py +49 -0
- vidbyte_cli/commands/research/__init__.py +5 -0
- vidbyte_cli/commands/research/add.py +41 -0
- vidbyte_cli/commands/research/options.py +134 -0
- vidbyte_cli/commands/research/render.py +157 -0
- vidbyte_cli/commands/research/resume.py +37 -0
- vidbyte_cli/commands/research/start.py +37 -0
- vidbyte_cli/commands/research/status.py +32 -0
- vidbyte_cli/commands/research/thread.py +30 -0
- vidbyte_cli/commands/research/threads.py +35 -0
- vidbyte_cli/commands/research/watch.py +93 -0
- vidbyte_cli/commands/runtime/__init__.py +23 -0
- vidbyte_cli/commands/runtime/adversarial_team.py +41 -0
- vidbyte_cli/commands/runtime/doctor.py +44 -0
- vidbyte_cli/commands/runtime/list.py +47 -0
- vidbyte_cli/commands/runtime/persistence.py +141 -0
- vidbyte_cli/commands/runtime/same_host_ensemble.py +221 -0
- vidbyte_cli/commands/runtime/stages.py +553 -0
- vidbyte_cli/commands/runtime/task_board.py +1342 -0
- vidbyte_cli/commands/setup/__init__.py +0 -0
- vidbyte_cli/commands/setup/doctor.py +64 -0
- vidbyte_cli/commands/setup/prompts/__init__.py +1 -0
- vidbyte_cli/commands/setup/prompts/browser.md +7 -0
- vidbyte_cli/commands/setup/prompts/library.py +18 -0
- vidbyte_cli/commands/setup/prompts/star.md +13 -0
- vidbyte_cli/commands/setup/star.py +132 -0
- vidbyte_cli/lib/__init__.py +0 -0
- vidbyte_cli/lib/api/__init__.py +0 -0
- vidbyte_cli/lib/api/client.py +236 -0
- vidbyte_cli/lib/api/endpoints/__init__.py +0 -0
- vidbyte_cli/lib/api/endpoints/auth.py +24 -0
- vidbyte_cli/lib/api/endpoints/billing.py +36 -0
- vidbyte_cli/lib/api/endpoints/research.py +103 -0
- vidbyte_cli/lib/api/endpoints/runtime.py +113 -0
- vidbyte_cli/lib/api/problem.py +86 -0
- vidbyte_cli/lib/api/response.py +101 -0
- vidbyte_cli/lib/api/retry.py +116 -0
- vidbyte_cli/lib/api/runtime_payment.py +89 -0
- vidbyte_cli/lib/auth/__init__.py +16 -0
- vidbyte_cli/lib/auth/credentials.py +223 -0
- vidbyte_cli/lib/auth/input.py +59 -0
- vidbyte_cli/lib/auth/keyring_store.py +126 -0
- vidbyte_cli/lib/auth/provider_credentials.py +157 -0
- vidbyte_cli/lib/auth/provider_input.py +54 -0
- vidbyte_cli/lib/auth/provider_resolver.py +89 -0
- vidbyte_cli/lib/auth/provider_store.py +149 -0
- vidbyte_cli/lib/auth/provider_verifier.py +219 -0
- vidbyte_cli/lib/auth/resolver.py +63 -0
- vidbyte_cli/lib/auth/verifier.py +38 -0
- vidbyte_cli/lib/config/__init__.py +25 -0
- vidbyte_cli/lib/config/atomic.py +47 -0
- vidbyte_cli/lib/config/config.py +141 -0
- vidbyte_cli/lib/config/migration.py +81 -0
- vidbyte_cli/lib/config/models.py +145 -0
- vidbyte_cli/lib/config/paths.py +66 -0
- vidbyte_cli/lib/config/resolver.py +137 -0
- vidbyte_cli/lib/constants/__init__.py +1 -0
- vidbyte_cli/lib/constants/runtime.py +186 -0
- vidbyte_cli/lib/errors/__init__.py +12 -0
- vidbyte_cli/lib/errors/cli_error.py +86 -0
- vidbyte_cli/lib/errors/codes.py +43 -0
- vidbyte_cli/lib/errors/failures.py +2910 -0
- vidbyte_cli/lib/errors/handler.py +72 -0
- vidbyte_cli/lib/files/__init__.py +6 -0
- vidbyte_cli/lib/files/documents.py +94 -0
- vidbyte_cli/lib/files/store.py +156 -0
- vidbyte_cli/lib/io/__init__.py +16 -0
- vidbyte_cli/lib/io/attachments.py +129 -0
- vidbyte_cli/lib/io/codex_attachments.py +47 -0
- vidbyte_cli/lib/io/prompt.py +71 -0
- vidbyte_cli/lib/io/streams.py +37 -0
- vidbyte_cli/lib/io/terminal.py +66 -0
- vidbyte_cli/lib/output/__init__.py +7 -0
- vidbyte_cli/lib/output/formats.py +29 -0
- vidbyte_cli/lib/output/manager.py +95 -0
- vidbyte_cli/lib/output/models.py +49 -0
- vidbyte_cli/lib/runtime/__init__.py +9 -0
- vidbyte_cli/lib/runtime/application.py +134 -0
- vidbyte_cli/lib/runtime/context.py +250 -0
- vidbyte_cli/lib/runtime/options.py +159 -0
- vidbyte_cli/lib/runtime/version.py +30 -0
- vidbyte_cli/lib/runtime_primitives/__init__.py +28 -0
- vidbyte_cli/lib/runtime_primitives/executor.py +54 -0
- vidbyte_cli/lib/runtime_primitives/gate.py +105 -0
- vidbyte_cli/lib/runtime_primitives/hosts.py +45 -0
- vidbyte_cli/lib/runtime_primitives/planner.py +79 -0
- vidbyte_cli/lib/runtime_primitives/stages.py +338 -0
- vidbyte_cli/lib/runtime_primitives/task_board.py +982 -0
- vidbyte_cli/lib/runtime_primitives/task_board_checkpoints.py +473 -0
- vidbyte_cli/lib/runtime_primitives/task_board_system.md +33 -0
- vidbyte_cli/lib/runtime_primitives/task_board_tasks.py +111 -0
- vidbyte_cli/lib/runtime_primitives/verification.py +68 -0
- vidbyte_cli/services/__init__.py +5 -0
- vidbyte_cli/services/ensemble/__init__.py +7 -0
- vidbyte_cli/services/ensemble/prompts/__init__.py +6 -0
- vidbyte_cli/services/ensemble/prompts/implementer_system.md +546 -0
- vidbyte_cli/services/ensemble/prompts/implementer_turn.md +49 -0
- vidbyte_cli/services/ensemble/prompts/library.py +188 -0
- vidbyte_cli/services/ensemble/prompts/planner_system.md +728 -0
- vidbyte_cli/services/ensemble/prompts/planner_turn.md +6 -0
- vidbyte_cli/services/ensemble/prompts/role_system.md +104 -0
- vidbyte_cli/services/ensemble/prompts/role_turn.md +19 -0
- vidbyte_cli/services/ensemble/prompts/selector_final_turn.md +19 -0
- vidbyte_cli/services/ensemble/prompts/selector_round_turn.md +17 -0
- vidbyte_cli/services/ensemble/prompts/selector_system.md +89 -0
- vidbyte_cli/services/ensemble/runner.py +30 -0
- vidbyte_cli/services/ensemble/sdk.py +134 -0
- vidbyte_cli/services/ensemble/service.py +233 -0
- vidbyte_cli/services/ensemble/settings.py +60 -0
- vidbyte_cli/services/persistence/__init__.py +6 -0
- vidbyte_cli/services/persistence/prompts/__init__.py +6 -0
- vidbyte_cli/services/persistence/prompts/library.py +46 -0
- vidbyte_cli/services/persistence/prompts/persistence_system.md +1 -0
- vidbyte_cli/services/persistence/prompts/persistence_turn.md +3 -0
- vidbyte_cli/services/persistence/runner.py +35 -0
- vidbyte_cli/services/persistence/session.py +116 -0
- vidbyte_cli/services/suggestions/__init__.py +7 -0
- vidbyte_cli/services/suggestions/categories.py +270 -0
- vidbyte_cli/services/suggestions/context.py +222 -0
- vidbyte_cli/services/suggestions/extra_compute.py +53 -0
- vidbyte_cli/services/suggestions/handoff.py +173 -0
- vidbyte_cli/services/suggestions/message_tools.py +47 -0
- vidbyte_cli/services/suggestions/project.py +253 -0
- vidbyte_cli/services/suggestions/prompts/__init__.py +1 -0
- vidbyte_cli/services/suggestions/prompts/categories/__init__.py +3 -0
- vidbyte_cli/services/suggestions/prompts/categories/adjacent_opportunity.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/alternative.md +38 -0
- vidbyte_cli/services/suggestions/prompts/categories/big_bets.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/bottleneck.md +38 -0
- vidbyte_cli/services/suggestions/prompts/categories/business_growth.md +38 -0
- vidbyte_cli/services/suggestions/prompts/categories/completion.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/continuation.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/coordination.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/creative_exploration.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/cross_domain.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/delegation.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/experiment.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/feedback.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/goal_clarification.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/immediate_next_steps.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/investigation.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/learning.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/leverage.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/long_term_directions.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/preparation.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/prerequisite.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/prioritization.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/product_experience.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/quick_wins.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/reframing.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/risk_prevention.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/simplification.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/stop_or_defer.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/strategy.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/technical_possibilities.md +37 -0
- vidbyte_cli/services/suggestions/prompts/categories/verification.md +37 -0
- vidbyte_cli/services/suggestions/prompts/critic.md +209 -0
- vidbyte_cli/services/suggestions/prompts/critic_message.md +8 -0
- vidbyte_cli/services/suggestions/prompts/generator.md +64 -0
- vidbyte_cli/services/suggestions/prompts/library.py +72 -0
- vidbyte_cli/services/suggestions/prompts/message_generator_argument.md +1 -0
- vidbyte_cli/services/suggestions/prompts/message_generator_tool.md +1 -0
- vidbyte_cli/services/suggestions/prompts/message_parent_argument.md +1 -0
- vidbyte_cli/services/suggestions/prompts/message_parent_tool.md +1 -0
- vidbyte_cli/services/suggestions/prompts/message_receipt.md +1 -0
- vidbyte_cli/services/suggestions/prompts/revision.md +8 -0
- vidbyte_cli/services/suggestions/sdk.py +228 -0
- vidbyte_cli/services/suggestions/selection.py +78 -0
- vidbyte_cli/services/suggestions/service.py +540 -0
- vidbyte_cli/types/__init__.py +0 -0
- vidbyte_cli/types/api.py +101 -0
- vidbyte_cli/types/attachments.py +79 -0
- vidbyte_cli/types/ensemble.py +189 -0
- vidbyte_cli/types/provider.py +62 -0
- vidbyte_cli/types/research.py +260 -0
- vidbyte_cli/types/runtime.py +1098 -0
- vidbyte_cli/types/suggestions.py +518 -0
- vidbyte_cli-0.1.0.dist-info/METADATA +335 -0
- vidbyte_cli-0.1.0.dist-info/RECORD +269 -0
- vidbyte_cli-0.1.0.dist-info/WHEEL +5 -0
- vidbyte_cli-0.1.0.dist-info/entry_points.txt +2 -0
- vidbyte_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- vidbyte_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Which provider credential this invocation uses and where it came from.
|
|
2
|
+
|
|
3
|
+
Order is environment → keyring → restricted file. Secrets get their own precedence
|
|
4
|
+
because a CI job exporting OPENAI_API_KEY must win over whatever was stored locally.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
|
|
13
|
+
from ...types.provider import PROVIDER_ENV_VARS, Provider
|
|
14
|
+
from ..errors.failures import InvalidProviderEnvironmentKey
|
|
15
|
+
from .provider_credentials import ProviderCredentials
|
|
16
|
+
from .provider_store import ProviderCredentialStore
|
|
17
|
+
|
|
18
|
+
_MAX_KEY_CHARACTERS = 4096
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ProviderSource(StrEnum):
|
|
22
|
+
"""Stable non-secret provenance for a resolved provider credential."""
|
|
23
|
+
|
|
24
|
+
ENVIRONMENT = "environment"
|
|
25
|
+
KEYRING = "keyring"
|
|
26
|
+
RESTRICTED_FILE = "restricted_file"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class ResolvedProviderCredential:
|
|
31
|
+
"""Secret-safe credential plus non-secret provenance."""
|
|
32
|
+
|
|
33
|
+
credentials: ProviderCredentials
|
|
34
|
+
source: ProviderSource
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ProviderResolver:
|
|
38
|
+
"""Read one scoped provider credential according to documented precedence."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, store: ProviderCredentialStore, environment: Mapping[str, str]) -> None:
|
|
41
|
+
# Store and environment injected for test control.
|
|
42
|
+
self._store = store
|
|
43
|
+
self._environment = environment
|
|
44
|
+
|
|
45
|
+
def resolve(self, profile: str, provider: Provider) -> ResolvedProviderCredential | None:
|
|
46
|
+
# Env var is highest; set-but-unusable is an error, not a silent miss.
|
|
47
|
+
if provider == Provider.GEMINI:
|
|
48
|
+
return self._resolve_gemini(profile)
|
|
49
|
+
env_var = PROVIDER_ENV_VARS[provider]
|
|
50
|
+
environment_value = self._environment.get(env_var)
|
|
51
|
+
if environment_value is not None:
|
|
52
|
+
token = environment_value.strip()
|
|
53
|
+
if not token or len(token) > _MAX_KEY_CHARACTERS:
|
|
54
|
+
raise InvalidProviderEnvironmentKey(provider.value, env_var)
|
|
55
|
+
if not ProviderCredentials.is_live_format(provider, token):
|
|
56
|
+
raise InvalidProviderEnvironmentKey(provider.value, env_var)
|
|
57
|
+
return ResolvedProviderCredential(
|
|
58
|
+
ProviderCredentials(provider=provider, api_key=token), # type: ignore[arg-type]
|
|
59
|
+
ProviderSource.ENVIRONMENT,
|
|
60
|
+
)
|
|
61
|
+
keyring_value = self._store.keyring.read(profile, provider)
|
|
62
|
+
if keyring_value is not None:
|
|
63
|
+
return ResolvedProviderCredential(keyring_value, ProviderSource.KEYRING)
|
|
64
|
+
file_value = self._store.file.read(profile, provider)
|
|
65
|
+
if file_value is not None:
|
|
66
|
+
return ResolvedProviderCredential(file_value, ProviderSource.RESTRICTED_FILE)
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def _resolve_gemini(self, profile: str) -> ResolvedProviderCredential | None:
|
|
70
|
+
# Google supports GEMINI_API_KEY and legacy GOOGLE_API_KEY, with GOOGLE taking precedence.
|
|
71
|
+
for env_var in ("GOOGLE_API_KEY", "GEMINI_API_KEY"):
|
|
72
|
+
environment_value = self._environment.get(env_var)
|
|
73
|
+
if environment_value is not None:
|
|
74
|
+
token = environment_value.strip()
|
|
75
|
+
if not token or len(token) > _MAX_KEY_CHARACTERS:
|
|
76
|
+
raise InvalidProviderEnvironmentKey(Provider.GEMINI.value, env_var)
|
|
77
|
+
if not ProviderCredentials.is_live_format(Provider.GEMINI, token):
|
|
78
|
+
raise InvalidProviderEnvironmentKey(Provider.GEMINI.value, env_var)
|
|
79
|
+
return ResolvedProviderCredential(
|
|
80
|
+
ProviderCredentials(provider=Provider.GEMINI, api_key=token), # type: ignore[arg-type]
|
|
81
|
+
ProviderSource.ENVIRONMENT,
|
|
82
|
+
)
|
|
83
|
+
keyring_value = self._store.keyring.read(profile, Provider.GEMINI)
|
|
84
|
+
if keyring_value is not None:
|
|
85
|
+
return ResolvedProviderCredential(keyring_value, ProviderSource.KEYRING)
|
|
86
|
+
file_value = self._store.file.read(profile, Provider.GEMINI)
|
|
87
|
+
if file_value is not None:
|
|
88
|
+
return ResolvedProviderCredential(file_value, ProviderSource.RESTRICTED_FILE)
|
|
89
|
+
return None
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Keyring-first provider credential facade.
|
|
2
|
+
|
|
3
|
+
Mirrors CredentialStore but namespaces to vidbyte-cli-provider and scopes by provider.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING, Protocol, cast
|
|
9
|
+
|
|
10
|
+
import keyring
|
|
11
|
+
from keyring.errors import KeyringError
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
|
|
14
|
+
from ...types.provider import Provider
|
|
15
|
+
from ..config.paths import VidbytePaths
|
|
16
|
+
from ..errors.failures import NoApprovedProviderStore, ProviderStoreUnavailable
|
|
17
|
+
from .provider_credentials import (
|
|
18
|
+
FileProviderStore,
|
|
19
|
+
ProviderCredentials,
|
|
20
|
+
ProviderScope,
|
|
21
|
+
ProviderStorage,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
_PROVIDER_SERVICE = "vidbyte-cli-provider"
|
|
28
|
+
_MINIMUM_BACKEND_PRIORITY = 1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ProviderKeyringBackend(Protocol):
|
|
32
|
+
"""Small structural surface needed from the provider keyring backend."""
|
|
33
|
+
|
|
34
|
+
priority: float
|
|
35
|
+
|
|
36
|
+
def get_password(self, service: str, username: str) -> str | None: ...
|
|
37
|
+
|
|
38
|
+
def set_password(self, service: str, username: str, password: str) -> None: ...
|
|
39
|
+
|
|
40
|
+
def delete_password(self, service: str, username: str) -> None: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class KeyringProviderStore:
|
|
44
|
+
"""Secret store for provider keys backed by the OS keyring."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, backend: ProviderKeyringBackend | None = None) -> None:
|
|
47
|
+
# Backend injectable for tests; otherwise resolved lazily.
|
|
48
|
+
self._backend = backend
|
|
49
|
+
|
|
50
|
+
def available(self) -> bool:
|
|
51
|
+
# Availability is a question, not a failure.
|
|
52
|
+
try:
|
|
53
|
+
return float(self._get_backend().priority) >= _MINIMUM_BACKEND_PRIORITY
|
|
54
|
+
except (KeyringError, RuntimeError, TypeError, ValueError):
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
def read(self, profile: str, provider: Provider) -> ProviderCredentials | None:
|
|
58
|
+
# Returns None when unavailable or missing; raises only on backend errors.
|
|
59
|
+
if not self.available():
|
|
60
|
+
return None
|
|
61
|
+
try:
|
|
62
|
+
value = self._get_backend().get_password(
|
|
63
|
+
_PROVIDER_SERVICE,
|
|
64
|
+
ProviderScope(profile, provider).account,
|
|
65
|
+
)
|
|
66
|
+
except KeyringError as error:
|
|
67
|
+
raise ProviderStoreUnavailable(error) from error
|
|
68
|
+
if value is None:
|
|
69
|
+
return None
|
|
70
|
+
try:
|
|
71
|
+
return ProviderCredentials(provider=provider, api_key=value) # type: ignore[arg-type]
|
|
72
|
+
except ValidationError as error:
|
|
73
|
+
raise ProviderStoreUnavailable(error) from error
|
|
74
|
+
|
|
75
|
+
def write(self, credentials: ProviderCredentials, profile: str, provider: Provider) -> None:
|
|
76
|
+
# Verifies write by reading back; a lying backend must not report success.
|
|
77
|
+
if not self.available():
|
|
78
|
+
raise ProviderStoreUnavailable()
|
|
79
|
+
account = ProviderScope(profile, provider).account
|
|
80
|
+
try:
|
|
81
|
+
backend = self._get_backend()
|
|
82
|
+
backend.set_password(_PROVIDER_SERVICE, account, credentials.secret_value())
|
|
83
|
+
read_back = backend.get_password(_PROVIDER_SERVICE, account)
|
|
84
|
+
except KeyringError as error:
|
|
85
|
+
raise ProviderStoreUnavailable(error) from error
|
|
86
|
+
if read_back != credentials.secret_value():
|
|
87
|
+
raise ProviderStoreUnavailable()
|
|
88
|
+
|
|
89
|
+
def clear(self, profile: str, provider: Provider) -> bool:
|
|
90
|
+
# Clears the keyring entry if present.
|
|
91
|
+
if not self.available():
|
|
92
|
+
return False
|
|
93
|
+
account = ProviderScope(profile, provider).account
|
|
94
|
+
try:
|
|
95
|
+
backend = self._get_backend()
|
|
96
|
+
if backend.get_password(_PROVIDER_SERVICE, account) is None:
|
|
97
|
+
return False
|
|
98
|
+
backend.delete_password(_PROVIDER_SERVICE, account)
|
|
99
|
+
return True
|
|
100
|
+
except KeyringError as error:
|
|
101
|
+
raise ProviderStoreUnavailable(error) from error
|
|
102
|
+
|
|
103
|
+
def _get_backend(self) -> ProviderKeyringBackend:
|
|
104
|
+
# Resolved on first use and cached; discovery can open a keychain session.
|
|
105
|
+
if self._backend is None:
|
|
106
|
+
self._backend = cast(ProviderKeyringBackend, keyring.get_keyring())
|
|
107
|
+
return self._backend
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ProviderCredentialStore:
|
|
111
|
+
"""Keyring-first durable provider credential facade."""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
keyring_store: KeyringProviderStore | None = None,
|
|
116
|
+
file_store: FileProviderStore | None = None,
|
|
117
|
+
paths: VidbytePaths | None = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
# Composition mirrors CredentialStore for test injection.
|
|
120
|
+
resolved_paths = paths or VidbytePaths.default()
|
|
121
|
+
self.keyring = keyring_store or KeyringProviderStore()
|
|
122
|
+
self.file = file_store or FileProviderStore(resolved_paths)
|
|
123
|
+
|
|
124
|
+
def read(self, profile: str, provider: Provider) -> ProviderCredentials | None:
|
|
125
|
+
# Keyring outranks file; file is fallback.
|
|
126
|
+
return self.keyring.read(profile, provider) or self.file.read(profile, provider)
|
|
127
|
+
|
|
128
|
+
def write(
|
|
129
|
+
self,
|
|
130
|
+
credentials: ProviderCredentials,
|
|
131
|
+
profile: str,
|
|
132
|
+
provider: Provider,
|
|
133
|
+
*,
|
|
134
|
+
allow_file_fallback: bool = False,
|
|
135
|
+
) -> ProviderStorage:
|
|
136
|
+
# Writes to keyring when available; otherwise file with consent.
|
|
137
|
+
if self.keyring.available():
|
|
138
|
+
self.keyring.write(credentials, profile, provider)
|
|
139
|
+
return ProviderStorage.KEYRING
|
|
140
|
+
if not allow_file_fallback:
|
|
141
|
+
raise NoApprovedProviderStore(provider.value)
|
|
142
|
+
self.file.write(credentials, profile, provider)
|
|
143
|
+
return ProviderStorage.RESTRICTED_FILE
|
|
144
|
+
|
|
145
|
+
def clear(self, profile: str, provider: Provider) -> bool:
|
|
146
|
+
# Clears both stores; a stale entry in either would stay effective.
|
|
147
|
+
keyring_cleared = self.keyring.clear(profile, provider)
|
|
148
|
+
file_cleared = self.file.clear(profile, provider)
|
|
149
|
+
return keyring_cleared or file_cleared
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Live probe verifiers for provider BYOK keys.
|
|
2
|
+
|
|
3
|
+
Verifies OpenAI via GET https://api.openai.com/v1/models with Authorization: Bearer,
|
|
4
|
+
Claude via GET https://api.anthropic.com/v1/models with x-api-key + anthropic-version,
|
|
5
|
+
and Grok/DeepSeek/GLM/Muse via Bearer probes to their native /v1/models endpoints.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from typing import Protocol
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict
|
|
14
|
+
|
|
15
|
+
from ...types.provider import PROVIDER_PROBE_URLS, Provider
|
|
16
|
+
from ..errors.failures import (
|
|
17
|
+
ProviderApiProtocolError,
|
|
18
|
+
ProviderApiUnreachable,
|
|
19
|
+
ProviderCredentialsRejected,
|
|
20
|
+
ProviderRateLimited,
|
|
21
|
+
ProviderRequestRejected,
|
|
22
|
+
)
|
|
23
|
+
from .provider_credentials import ProviderCredentials
|
|
24
|
+
|
|
25
|
+
_MAX_RESPONSE_BYTES = 1_048_576
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ProviderIdentity(BaseModel):
|
|
29
|
+
"""Non-secret identity behind a verified provider key."""
|
|
30
|
+
|
|
31
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
32
|
+
|
|
33
|
+
provider: Provider
|
|
34
|
+
verified: bool = True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ProviderVerifier(Protocol):
|
|
38
|
+
"""Proves a candidate provider key against its native endpoint."""
|
|
39
|
+
|
|
40
|
+
def verify(self, credentials: ProviderCredentials) -> ProviderIdentity: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _BaseProviderVerifier:
|
|
44
|
+
"""Shared transport and decoding for one provider probe."""
|
|
45
|
+
|
|
46
|
+
def _headers(self, credentials: ProviderCredentials) -> dict[str, str]:
|
|
47
|
+
# Subclasses define exact auth headers per provider docs.
|
|
48
|
+
raise NotImplementedError
|
|
49
|
+
|
|
50
|
+
def _url(self, provider: Provider) -> str:
|
|
51
|
+
# Canonical probe URL per provider.
|
|
52
|
+
return PROVIDER_PROBE_URLS[provider]
|
|
53
|
+
|
|
54
|
+
def _probe(self, credentials: ProviderCredentials, timeout_seconds: float) -> None:
|
|
55
|
+
# Executes the live GET and classifies the outcome.
|
|
56
|
+
import httpx
|
|
57
|
+
|
|
58
|
+
url = self._url(credentials.provider)
|
|
59
|
+
headers = {"Accept": "application/json", **self._headers(credentials)}
|
|
60
|
+
try:
|
|
61
|
+
with httpx.Client(timeout=timeout_seconds, follow_redirects=False) as client:
|
|
62
|
+
response = client.get(url, headers=headers)
|
|
63
|
+
except httpx.HTTPError as error:
|
|
64
|
+
raise ProviderApiUnreachable(credentials.provider.value, error) from error
|
|
65
|
+
self._classify_status(credentials, response)
|
|
66
|
+
self._validate_body(credentials, response)
|
|
67
|
+
|
|
68
|
+
def _classify_status(self, credentials: ProviderCredentials, response: object) -> None:
|
|
69
|
+
# Maps status to a typed failure before any body is inspected.
|
|
70
|
+
status = int(getattr(response, "status_code", 0))
|
|
71
|
+
headers = getattr(response, "headers", {})
|
|
72
|
+
if status == 429:
|
|
73
|
+
retry = headers.get("Retry-After") if hasattr(headers, "get") else None
|
|
74
|
+
try:
|
|
75
|
+
retry_after = int(retry) if retry is not None else None
|
|
76
|
+
except ValueError:
|
|
77
|
+
retry_after = None
|
|
78
|
+
raise ProviderRateLimited(credentials.provider.value, retry_after)
|
|
79
|
+
if status in (401, 403):
|
|
80
|
+
raise ProviderCredentialsRejected(credentials.provider.value)
|
|
81
|
+
if status in (400, 404, 409, 422):
|
|
82
|
+
raise ProviderRequestRejected(credentials.provider.value)
|
|
83
|
+
if 500 <= status < 600:
|
|
84
|
+
raise ProviderApiUnreachable(credentials.provider.value)
|
|
85
|
+
if not 200 <= status < 300:
|
|
86
|
+
raise ProviderRequestRejected(credentials.provider.value)
|
|
87
|
+
|
|
88
|
+
def _validate_body(self, credentials: ProviderCredentials, response: object) -> None:
|
|
89
|
+
# Bounds, parses, and shape-checks the 2xx body.
|
|
90
|
+
content = getattr(response, "content", b"")
|
|
91
|
+
if not content or len(content) > _MAX_RESPONSE_BYTES:
|
|
92
|
+
raise ProviderApiProtocolError(credentials.provider.value)
|
|
93
|
+
try:
|
|
94
|
+
payload = json.loads(content)
|
|
95
|
+
except (ValueError, json.JSONDecodeError) as error:
|
|
96
|
+
raise ProviderApiProtocolError(credentials.provider.value, error) from error
|
|
97
|
+
if not isinstance(payload, dict):
|
|
98
|
+
raise ProviderApiProtocolError(credentials.provider.value)
|
|
99
|
+
# OpenAI-style uses data; Gemini/Cohere use models.
|
|
100
|
+
data = payload.get("data")
|
|
101
|
+
if isinstance(data, list):
|
|
102
|
+
return
|
|
103
|
+
models = payload.get("models")
|
|
104
|
+
if isinstance(models, list):
|
|
105
|
+
return
|
|
106
|
+
raise ProviderApiProtocolError(credentials.provider.value)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class OpenAIVerifier(_BaseProviderVerifier):
|
|
110
|
+
"""Proves an OpenAI key via GET /v1/models with Bearer auth."""
|
|
111
|
+
|
|
112
|
+
def verify(self, credentials: ProviderCredentials) -> ProviderIdentity:
|
|
113
|
+
# Uses a short timeout derived from caller's config where available.
|
|
114
|
+
self._probe(credentials, timeout_seconds=15.0)
|
|
115
|
+
return ProviderIdentity(provider=credentials.provider)
|
|
116
|
+
|
|
117
|
+
def _headers(self, credentials: ProviderCredentials) -> dict[str, str]:
|
|
118
|
+
# Exact string per OpenAI docs: Authorization: Bearer $OPENAI_API_KEY
|
|
119
|
+
return {"Authorization": f"Bearer {credentials.secret_value()}"}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ClaudeVerifier(_BaseProviderVerifier):
|
|
123
|
+
"""Proves a Claude key via GET /v1/models with x-api-key + version."""
|
|
124
|
+
|
|
125
|
+
def verify(self, credentials: ProviderCredentials) -> ProviderIdentity:
|
|
126
|
+
# Anthropic requires anthropic-version header on every request.
|
|
127
|
+
self._probe(credentials, timeout_seconds=15.0)
|
|
128
|
+
return ProviderIdentity(provider=credentials.provider)
|
|
129
|
+
|
|
130
|
+
def _headers(self, credentials: ProviderCredentials) -> dict[str, str]:
|
|
131
|
+
# Per Anthropic docs: x-api-key + anthropic-version: 2023-06-01
|
|
132
|
+
return {
|
|
133
|
+
"x-api-key": credentials.secret_value(),
|
|
134
|
+
"anthropic-version": "2023-06-01",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class GrokVerifier(_BaseProviderVerifier):
|
|
139
|
+
"""Proves a Grok key via GET /v1/models with Bearer auth."""
|
|
140
|
+
|
|
141
|
+
def verify(self, credentials: ProviderCredentials) -> ProviderIdentity:
|
|
142
|
+
# xAI uses OpenAI-compatible Bearer on https://api.x.ai/v1/models.
|
|
143
|
+
self._probe(credentials, timeout_seconds=15.0)
|
|
144
|
+
return ProviderIdentity(provider=credentials.provider)
|
|
145
|
+
|
|
146
|
+
def _headers(self, credentials: ProviderCredentials) -> dict[str, str]:
|
|
147
|
+
# Exact string per xAI docs: Authorization: Bearer $XAI_API_KEY
|
|
148
|
+
return {"Authorization": f"Bearer {credentials.secret_value()}"}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class DeepSeekVerifier(_BaseProviderVerifier):
|
|
152
|
+
"""Proves a DeepSeek key via GET /v1/models with Bearer auth."""
|
|
153
|
+
|
|
154
|
+
def verify(self, credentials: ProviderCredentials) -> ProviderIdentity:
|
|
155
|
+
# DeepSeek is OpenAI-compatible on https://api.deepseek.com/v1/models.
|
|
156
|
+
self._probe(credentials, timeout_seconds=15.0)
|
|
157
|
+
return ProviderIdentity(provider=credentials.provider)
|
|
158
|
+
|
|
159
|
+
def _headers(self, credentials: ProviderCredentials) -> dict[str, str]:
|
|
160
|
+
# Per DeepSeek docs: Authorization: Bearer $DEEPSEEK_API_KEY
|
|
161
|
+
return {"Authorization": f"Bearer {credentials.secret_value()}"}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class GlmVerifier(_BaseProviderVerifier):
|
|
165
|
+
"""Proves a GLM key via GET /models with Bearer auth."""
|
|
166
|
+
|
|
167
|
+
def verify(self, credentials: ProviderCredentials) -> ProviderIdentity:
|
|
168
|
+
# Z.AI is OpenAI-compatible on https://api.z.ai/api/paas/v4/models.
|
|
169
|
+
self._probe(credentials, timeout_seconds=15.0)
|
|
170
|
+
return ProviderIdentity(provider=credentials.provider)
|
|
171
|
+
|
|
172
|
+
def _headers(self, credentials: ProviderCredentials) -> dict[str, str]:
|
|
173
|
+
# Per Z.AI docs: Authorization: Bearer $ZAI_API_KEY
|
|
174
|
+
return {"Authorization": f"Bearer {credentials.secret_value()}"}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class MuseVerifier(_BaseProviderVerifier):
|
|
178
|
+
"""Proves a Muse key via GET /v1/models with Bearer auth."""
|
|
179
|
+
|
|
180
|
+
def verify(self, credentials: ProviderCredentials) -> ProviderIdentity:
|
|
181
|
+
# Meta is OpenAI-compatible on https://api.meta.ai/v1/models.
|
|
182
|
+
self._probe(credentials, timeout_seconds=15.0)
|
|
183
|
+
return ProviderIdentity(provider=credentials.provider)
|
|
184
|
+
|
|
185
|
+
def _headers(self, credentials: ProviderCredentials) -> dict[str, str]:
|
|
186
|
+
# Per Meta docs: Authorization: Bearer $MODEL_API_KEY
|
|
187
|
+
return {"Authorization": f"Bearer {credentials.secret_value()}"}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class GeminiVerifier(_BaseProviderVerifier):
|
|
191
|
+
"""Proves a Gemini key via GET /v1beta/models with x-goog-api-key."""
|
|
192
|
+
|
|
193
|
+
def verify(self, credentials: ProviderCredentials) -> ProviderIdentity:
|
|
194
|
+
# Google uses x-goog-api-key on generativelanguage.googleapis.com.
|
|
195
|
+
self._probe(credentials, timeout_seconds=15.0)
|
|
196
|
+
return ProviderIdentity(provider=credentials.provider)
|
|
197
|
+
|
|
198
|
+
def _headers(self, credentials: ProviderCredentials) -> dict[str, str]:
|
|
199
|
+
# Per Google docs: x-goog-api-key: $GEMINI_API_KEY
|
|
200
|
+
return {"x-goog-api-key": credentials.secret_value()}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def verifier_for_provider(provider: Provider) -> ProviderVerifier:
|
|
204
|
+
# Factory keeps command branching closed over Provider variants.
|
|
205
|
+
if provider == Provider.OPENAI:
|
|
206
|
+
return OpenAIVerifier()
|
|
207
|
+
if provider == Provider.CLAUDE:
|
|
208
|
+
return ClaudeVerifier()
|
|
209
|
+
if provider == Provider.GROK:
|
|
210
|
+
return GrokVerifier()
|
|
211
|
+
if provider == Provider.DEEPSEEK:
|
|
212
|
+
return DeepSeekVerifier()
|
|
213
|
+
if provider == Provider.GLM:
|
|
214
|
+
return GlmVerifier()
|
|
215
|
+
if provider == Provider.MUSE:
|
|
216
|
+
return MuseVerifier()
|
|
217
|
+
if provider == Provider.GEMINI:
|
|
218
|
+
return GeminiVerifier()
|
|
219
|
+
raise ValueError(f"unsupported provider {provider}")
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Which credential this invocation uses, and where it came from.
|
|
2
|
+
|
|
3
|
+
Order is environment → OS keyring → restricted file. Secrets get their own precedence
|
|
4
|
+
because a CI job exporting `VIDBYTE_API_KEY` must win over whatever a developer once stored
|
|
5
|
+
on the same machine, while non-secret config follows the profile layering in `lib/config`.
|
|
6
|
+
|
|
7
|
+
Reading is all this does. Login deliberately does not resolve through here, which is what
|
|
8
|
+
keeps an environment token from being written into durable storage as if it were entered.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
|
|
17
|
+
from ..errors.failures import EnvironmentApiKeyNotLive, InvalidEnvironmentApiKey
|
|
18
|
+
from .credentials import Credentials, CredentialStore
|
|
19
|
+
|
|
20
|
+
_MAX_KEY_CHARACTERS = 4096
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CredentialSource(StrEnum):
|
|
24
|
+
"""Stable non-secret provenance for a resolved credential."""
|
|
25
|
+
|
|
26
|
+
ENVIRONMENT = "environment"
|
|
27
|
+
KEYRING = "keyring"
|
|
28
|
+
RESTRICTED_FILE = "restricted_file"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class ResolvedCredential:
|
|
33
|
+
"""Secret-safe credential plus non-secret provenance."""
|
|
34
|
+
|
|
35
|
+
credentials: Credentials
|
|
36
|
+
source: CredentialSource
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CredentialResolver:
|
|
40
|
+
"""Read one scoped credential according to documented precedence."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, store: CredentialStore, environment: Mapping[str, str]) -> None:
|
|
43
|
+
self._store = store
|
|
44
|
+
self._environment = environment
|
|
45
|
+
|
|
46
|
+
def resolve(self, profile: str, api_url: str) -> ResolvedCredential | None:
|
|
47
|
+
environment_value = self._environment.get("VIDBYTE_API_KEY")
|
|
48
|
+
if environment_value is not None:
|
|
49
|
+
# Set-but-unusable is an error, not a miss: falling through would silently
|
|
50
|
+
# authenticate as whoever is stored locally, which the caller did not ask for.
|
|
51
|
+
token = environment_value.strip()
|
|
52
|
+
if not token or len(token) > _MAX_KEY_CHARACTERS:
|
|
53
|
+
raise InvalidEnvironmentApiKey()
|
|
54
|
+
if not Credentials.is_live_format(token):
|
|
55
|
+
raise EnvironmentApiKeyNotLive()
|
|
56
|
+
return ResolvedCredential(Credentials.from_value(token), CredentialSource.ENVIRONMENT)
|
|
57
|
+
keyring_value = self._store.keyring.read(profile, api_url)
|
|
58
|
+
if keyring_value is not None:
|
|
59
|
+
return ResolvedCredential(keyring_value, CredentialSource.KEYRING)
|
|
60
|
+
file_value = self._store.file.read(profile, api_url)
|
|
61
|
+
if file_value is not None:
|
|
62
|
+
return ResolvedCredential(file_value, CredentialSource.RESTRICTED_FILE)
|
|
63
|
+
return None
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""The verify-before-persist boundary login must cross before a key reaches storage.
|
|
2
|
+
|
|
3
|
+
Making it a protocol states the invariant in the type system: `CredentialStore.write` is
|
|
4
|
+
only ever reached through a `verify` that returned. A syntactically valid token is not a
|
|
5
|
+
working one, and a stored bad key fails every later command with no obvious cause.
|
|
6
|
+
|
|
7
|
+
`verify` returns the identity rather than nothing so `login` and `whoami` can share one call:
|
|
8
|
+
login discards the result, whoami prints it. Two commands asking the same question of the
|
|
9
|
+
backend cannot then drift into asking it two different ways.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Protocol
|
|
15
|
+
|
|
16
|
+
from ...types.api import KeyIdentity
|
|
17
|
+
from ..api.client import ApiClient
|
|
18
|
+
from ..api.endpoints.auth import AuthEndpoints
|
|
19
|
+
from ..config import ResolvedConfig
|
|
20
|
+
from ..errors.failures import ApiResponseUnsupported
|
|
21
|
+
from .credentials import Credentials
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CredentialVerifier(Protocol):
|
|
25
|
+
def verify(self, credentials: Credentials, config: ResolvedConfig) -> KeyIdentity: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ApiCredentialVerifier:
|
|
29
|
+
"""Prove a candidate key against the backend before it may be persisted."""
|
|
30
|
+
|
|
31
|
+
def verify(self, credentials: Credentials, config: ResolvedConfig) -> KeyIdentity:
|
|
32
|
+
# Calls the permission-free liveness check and returns the non-secret identity.
|
|
33
|
+
identity = AuthEndpoints(ApiClient(config, credentials)).validate()
|
|
34
|
+
if not identity.success:
|
|
35
|
+
# A success status that denies success is a contract this CLI cannot act on, and
|
|
36
|
+
# treating it as acceptance would store a key the backend has just refused.
|
|
37
|
+
raise ApiResponseUnsupported()
|
|
38
|
+
return identity
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Typed, versioned non-secret CLI configuration contracts."""
|
|
2
|
+
|
|
3
|
+
from .config import ConfigSnapshot, ConfigStore
|
|
4
|
+
from .models import (
|
|
5
|
+
ConfigDocument,
|
|
6
|
+
ConfigField,
|
|
7
|
+
ConfigSource,
|
|
8
|
+
ProfileConfig,
|
|
9
|
+
ResolvedConfig,
|
|
10
|
+
)
|
|
11
|
+
from .paths import VidbytePaths
|
|
12
|
+
from .resolver import ConfigOverrides, ConfigResolver
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"ConfigDocument",
|
|
16
|
+
"ConfigField",
|
|
17
|
+
"ConfigOverrides",
|
|
18
|
+
"ConfigResolver",
|
|
19
|
+
"ConfigSnapshot",
|
|
20
|
+
"ConfigSource",
|
|
21
|
+
"ConfigStore",
|
|
22
|
+
"ProfileConfig",
|
|
23
|
+
"ResolvedConfig",
|
|
24
|
+
"VidbytePaths",
|
|
25
|
+
]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""The one way local CLI state is replaced on disk: owner-only, fsynced, atomic.
|
|
2
|
+
|
|
3
|
+
Config, the fallback credential file, and migrated copies all go through here, so the
|
|
4
|
+
permission and durability rules live in one reviewable place instead of at each store.
|
|
5
|
+
|
|
6
|
+
The write goes to a randomized sibling in the destination directory before `os.replace`,
|
|
7
|
+
because replace is only atomic within a filesystem, and an interrupted write must leave the
|
|
8
|
+
previous file rather than a truncated one. This layer knows nothing about JSON or secrets.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
|
|
17
|
+
from ..errors.failures import StateWriteFailed, StateWriteThroughSymlink
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AtomicFileWriter:
|
|
21
|
+
"""Write exact files through a flushed sibling and atomic replacement."""
|
|
22
|
+
|
|
23
|
+
def write(self, path: Path, content: bytes, *, mode: int = 0o600) -> None:
|
|
24
|
+
# Replacing a symlink mutates the link entry on some platforms and its target on
|
|
25
|
+
# others. That is not uniform enough to stand as a security boundary for a path
|
|
26
|
+
# that may hold a credential, so refuse rather than pick a behavior.
|
|
27
|
+
if path.is_symlink():
|
|
28
|
+
raise StateWriteThroughSymlink()
|
|
29
|
+
temporary_path = path.with_name(f".{path.name}.{uuid4().hex}.tmp")
|
|
30
|
+
try:
|
|
31
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
32
|
+
# O_EXCL with a random name means this process owns the temporary file outright.
|
|
33
|
+
descriptor = os.open(temporary_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
|
|
34
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
35
|
+
stream.write(content)
|
|
36
|
+
stream.flush()
|
|
37
|
+
os.fsync(stream.fileno())
|
|
38
|
+
# Windows ignores the mode passed to os.open, and os.replace preserves the
|
|
39
|
+
# destination's existing permissions, so restate the mode on both sides.
|
|
40
|
+
os.chmod(temporary_path, mode)
|
|
41
|
+
os.replace(temporary_path, path)
|
|
42
|
+
os.chmod(path, mode)
|
|
43
|
+
except OSError as error:
|
|
44
|
+
raise StateWriteFailed(error) from error
|
|
45
|
+
finally:
|
|
46
|
+
# Only this call's randomized sibling is ever removed, never the destination.
|
|
47
|
+
temporary_path.unlink(missing_ok=True)
|