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,223 @@
|
|
|
1
|
+
"""Secret-safe credential models and the keyring-first store the rest of the CLI talks to.
|
|
2
|
+
|
|
3
|
+
The key is a `SecretStr`, so it stays out of reprs, logs, and model dumps by default;
|
|
4
|
+
`secret_value()` and `FileCredentialStore._encode` are the only two deliberate unwrappings.
|
|
5
|
+
|
|
6
|
+
The restricted file is a fallback, never a default. It stores a key readable by anything
|
|
7
|
+
running as this user, so `CredentialStore.write` refuses it unless the caller passes explicit
|
|
8
|
+
consent — a decision that belongs to the person logging in, not to this layer.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError, field_validator
|
|
18
|
+
|
|
19
|
+
from ..config.atomic import AtomicFileWriter
|
|
20
|
+
from ..config.models import DEFAULT_API_URL, DEFAULT_PROFILE
|
|
21
|
+
from ..config.paths import VidbytePaths
|
|
22
|
+
from ..errors.failures import (
|
|
23
|
+
LegacyCredentialRemovalFailed,
|
|
24
|
+
LegacyCredentialSymlink,
|
|
25
|
+
LegacyCredentialUnreadable,
|
|
26
|
+
NoApprovedCredentialStore,
|
|
27
|
+
StoredCredentialUnreadable,
|
|
28
|
+
)
|
|
29
|
+
from .keyring_store import CredentialScope, KeyringCredentialStore
|
|
30
|
+
|
|
31
|
+
# Bounds a hostile or accidentally-redirected file before it is parsed into memory.
|
|
32
|
+
_MAX_CREDENTIAL_BYTES = 1_000_000
|
|
33
|
+
_MAX_KEY_CHARACTERS = 4096
|
|
34
|
+
# The only prefix the backend gatekeeper extracts. A key without it yields no candidate at
|
|
35
|
+
# all, so the request is not rejected as malformed — it is treated as unauthenticated.
|
|
36
|
+
LIVE_API_KEY_PREFIX = "vb_live_"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Credentials(BaseModel):
|
|
40
|
+
"""One opaque Vidbyte API token with secret-safe representation."""
|
|
41
|
+
|
|
42
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
43
|
+
|
|
44
|
+
api_key: SecretStr
|
|
45
|
+
|
|
46
|
+
@field_validator("api_key")
|
|
47
|
+
@classmethod
|
|
48
|
+
def validate_api_key(cls, value: SecretStr) -> SecretStr:
|
|
49
|
+
if not 1 <= len(value.get_secret_value()) <= _MAX_KEY_CHARACTERS:
|
|
50
|
+
raise ValueError("API key must contain between 1 and 4096 characters")
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_value(cls, value: str) -> Credentials:
|
|
55
|
+
return cls(api_key=SecretStr(value))
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def is_live_format(cls, value: str) -> bool:
|
|
59
|
+
# Asked at the input boundaries only. Making it a field invariant would make a stored
|
|
60
|
+
# non-live key unreadable, and so unremovable by the logout that exists to clear it.
|
|
61
|
+
return value.startswith(LIVE_API_KEY_PREFIX)
|
|
62
|
+
|
|
63
|
+
def secret_value(self) -> str:
|
|
64
|
+
return self.api_key.get_secret_value()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CredentialStorage(StrEnum):
|
|
68
|
+
"""Durable storage selected for a verified login."""
|
|
69
|
+
|
|
70
|
+
KEYRING = "keyring"
|
|
71
|
+
RESTRICTED_FILE = "restricted_file"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CredentialDocument(BaseModel):
|
|
75
|
+
"""Version-one restricted fallback file."""
|
|
76
|
+
|
|
77
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
78
|
+
|
|
79
|
+
schema_version: Literal[1] = 1
|
|
80
|
+
entries: dict[str, SecretStr] = Field(default_factory=dict)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FileCredentialStore:
|
|
84
|
+
"""Permission-restricted fallback store, never an automatic first choice."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, paths: VidbytePaths, writer: AtomicFileWriter | None = None) -> None:
|
|
87
|
+
self._paths = paths
|
|
88
|
+
self._writer = writer or AtomicFileWriter()
|
|
89
|
+
|
|
90
|
+
def read(self, profile: str, api_url: str) -> Credentials | None:
|
|
91
|
+
secret = self._load().entries.get(CredentialScope(profile, api_url).account)
|
|
92
|
+
if secret is not None:
|
|
93
|
+
return Credentials(api_key=secret)
|
|
94
|
+
return self._read_legacy_unscoped(profile, api_url)
|
|
95
|
+
|
|
96
|
+
def write(self, credentials: Credentials, profile: str, api_url: str) -> None:
|
|
97
|
+
document = self._load()
|
|
98
|
+
entries = dict(document.entries)
|
|
99
|
+
entries[CredentialScope(profile, api_url).account] = credentials.api_key
|
|
100
|
+
self._writer.write(
|
|
101
|
+
self._paths.credentials_file(),
|
|
102
|
+
self._encode(CredentialDocument(entries=entries)),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def clear(self, profile: str, api_url: str) -> bool:
|
|
106
|
+
document = self._load()
|
|
107
|
+
account = CredentialScope(profile, api_url).account
|
|
108
|
+
if account not in document.entries:
|
|
109
|
+
return False
|
|
110
|
+
entries = dict(document.entries)
|
|
111
|
+
del entries[account]
|
|
112
|
+
self._writer.write(
|
|
113
|
+
self._paths.credentials_file(),
|
|
114
|
+
self._encode(CredentialDocument(entries=entries)),
|
|
115
|
+
)
|
|
116
|
+
return True
|
|
117
|
+
|
|
118
|
+
def clear_legacy(self, profile: str, api_url: str) -> bool:
|
|
119
|
+
# Logout is an explicit destructive request. Unlike migration, which preserves the
|
|
120
|
+
# legacy tree, it must ensure an old unscoped token cannot become effective again
|
|
121
|
+
# once the scoped stores are empty.
|
|
122
|
+
if profile != DEFAULT_PROFILE or api_url != DEFAULT_API_URL:
|
|
123
|
+
return False
|
|
124
|
+
path = self._paths.legacy_credentials_file()
|
|
125
|
+
if not path.exists():
|
|
126
|
+
return False
|
|
127
|
+
if path.is_symlink():
|
|
128
|
+
raise LegacyCredentialSymlink()
|
|
129
|
+
try:
|
|
130
|
+
path.unlink()
|
|
131
|
+
except OSError as error:
|
|
132
|
+
raise LegacyCredentialRemovalFailed(error) from error
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
def _load(self) -> CredentialDocument:
|
|
136
|
+
path = self._paths.credentials_file()
|
|
137
|
+
if not path.exists():
|
|
138
|
+
return CredentialDocument()
|
|
139
|
+
try:
|
|
140
|
+
raw = path.read_bytes()
|
|
141
|
+
if len(raw) > _MAX_CREDENTIAL_BYTES:
|
|
142
|
+
raise ValueError("credential file exceeds the size limit")
|
|
143
|
+
parsed = json.loads(raw)
|
|
144
|
+
if isinstance(parsed, dict) and parsed.get("schema_version", 1) != 1:
|
|
145
|
+
raise ValueError("unsupported credential schema")
|
|
146
|
+
return CredentialDocument.model_validate(parsed)
|
|
147
|
+
except (OSError, json.JSONDecodeError, ValidationError, ValueError) as error:
|
|
148
|
+
raise StoredCredentialUnreadable(error) from error
|
|
149
|
+
|
|
150
|
+
def _read_legacy_unscoped(self, profile: str, api_url: str) -> Credentials | None:
|
|
151
|
+
# The legacy file predates scoping, so it can only answer for the default scope.
|
|
152
|
+
if profile != DEFAULT_PROFILE or api_url != DEFAULT_API_URL:
|
|
153
|
+
return None
|
|
154
|
+
path = self._paths.legacy_credentials_file()
|
|
155
|
+
if not path.exists():
|
|
156
|
+
return None
|
|
157
|
+
try:
|
|
158
|
+
raw = path.read_bytes()
|
|
159
|
+
if len(raw) > _MAX_CREDENTIAL_BYTES:
|
|
160
|
+
raise ValueError("legacy credential file exceeds the size limit")
|
|
161
|
+
return Credentials.model_validate(json.loads(raw))
|
|
162
|
+
except (OSError, json.JSONDecodeError, ValidationError, ValueError) as error:
|
|
163
|
+
raise LegacyCredentialUnreadable(error) from error
|
|
164
|
+
|
|
165
|
+
def _encode(self, document: CredentialDocument) -> bytes:
|
|
166
|
+
# SecretStr's JSON serializer redacts values, which is right everywhere except here:
|
|
167
|
+
# this is the one intentional unwrapping point for the restricted credential file.
|
|
168
|
+
payload = {
|
|
169
|
+
"schema_version": document.schema_version,
|
|
170
|
+
"entries": {
|
|
171
|
+
account: secret.get_secret_value() for account, secret in document.entries.items()
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8") + b"\n"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class CredentialStore:
|
|
178
|
+
"""Keyring-first durable credential facade."""
|
|
179
|
+
|
|
180
|
+
def __init__(
|
|
181
|
+
self,
|
|
182
|
+
keyring_store: KeyringCredentialStore | None = None,
|
|
183
|
+
file_store: FileCredentialStore | None = None,
|
|
184
|
+
paths: VidbytePaths | None = None,
|
|
185
|
+
) -> None:
|
|
186
|
+
resolved_paths = paths or VidbytePaths.default()
|
|
187
|
+
self.keyring = keyring_store or KeyringCredentialStore()
|
|
188
|
+
self.file = file_store or FileCredentialStore(resolved_paths)
|
|
189
|
+
|
|
190
|
+
def read(
|
|
191
|
+
self,
|
|
192
|
+
profile: str = DEFAULT_PROFILE,
|
|
193
|
+
api_url: str = DEFAULT_API_URL,
|
|
194
|
+
) -> Credentials | None:
|
|
195
|
+
return self.keyring.read(profile, api_url) or self.file.read(profile, api_url)
|
|
196
|
+
|
|
197
|
+
def write(
|
|
198
|
+
self,
|
|
199
|
+
credentials: Credentials,
|
|
200
|
+
profile: str = DEFAULT_PROFILE,
|
|
201
|
+
api_url: str = DEFAULT_API_URL,
|
|
202
|
+
*,
|
|
203
|
+
allow_file_fallback: bool = False,
|
|
204
|
+
) -> CredentialStorage:
|
|
205
|
+
if self.keyring.available():
|
|
206
|
+
self.keyring.write(credentials, profile, api_url)
|
|
207
|
+
return CredentialStorage.KEYRING
|
|
208
|
+
if not allow_file_fallback:
|
|
209
|
+
raise NoApprovedCredentialStore()
|
|
210
|
+
self.file.write(credentials, profile, api_url)
|
|
211
|
+
return CredentialStorage.RESTRICTED_FILE
|
|
212
|
+
|
|
213
|
+
def clear(
|
|
214
|
+
self,
|
|
215
|
+
profile: str = DEFAULT_PROFILE,
|
|
216
|
+
api_url: str = DEFAULT_API_URL,
|
|
217
|
+
) -> bool:
|
|
218
|
+
# Every store is cleared, not just the first that answers: a stale entry left in one
|
|
219
|
+
# of them would keep authenticating after the user asked to be logged out.
|
|
220
|
+
keyring_cleared = self.keyring.clear(profile, api_url)
|
|
221
|
+
file_cleared = self.file.clear(profile, api_url)
|
|
222
|
+
legacy_cleared = self.file.clear_legacy(profile, api_url)
|
|
223
|
+
return keyring_cleared or file_cleared or legacy_cleared
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""The two channels a login token may arrive on: a hidden prompt, or explicit stdin.
|
|
2
|
+
|
|
3
|
+
There is deliberately no `--api-key` option. An argv secret is visible in `ps`, the shell
|
|
4
|
+
history file, and any CI log that echoes its command line, and no amount of care at the
|
|
5
|
+
raise sites takes it back out of those.
|
|
6
|
+
|
|
7
|
+
Stdin is read only when `--with-token` asks for it, so a command whose input happens to be
|
|
8
|
+
redirected fails with a usage error instead of consuming a stream meant for something else.
|
|
9
|
+
The environment is absent on purpose: login persists only what a person explicitly entered.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import getpass
|
|
15
|
+
|
|
16
|
+
from ..errors.failures import (
|
|
17
|
+
ApiKeyNotLiveFormat,
|
|
18
|
+
InvalidApiKeyInput,
|
|
19
|
+
NoninteractiveLoginRequiresToken,
|
|
20
|
+
)
|
|
21
|
+
from ..io import IOStreams
|
|
22
|
+
from ..io.terminal import TerminalCapabilities
|
|
23
|
+
from .credentials import Credentials
|
|
24
|
+
|
|
25
|
+
_MAX_TOKEN_CHARACTERS = 4096
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CredentialInput:
|
|
29
|
+
"""Acquire one token through an explicit secret-safe channel."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
streams: IOStreams,
|
|
34
|
+
terminal: TerminalCapabilities,
|
|
35
|
+
*,
|
|
36
|
+
no_input: bool,
|
|
37
|
+
) -> None:
|
|
38
|
+
self._streams = streams
|
|
39
|
+
self._terminal = terminal
|
|
40
|
+
self._no_input = no_input
|
|
41
|
+
|
|
42
|
+
def read(self, *, from_stdin: bool) -> Credentials:
|
|
43
|
+
if from_stdin:
|
|
44
|
+
# One past the bound, so an oversized stream is rejected rather than truncated
|
|
45
|
+
# into a token that looks plausible.
|
|
46
|
+
value = self._streams.stdin.read(_MAX_TOKEN_CHARACTERS + 1)
|
|
47
|
+
else:
|
|
48
|
+
if self._no_input or not self._terminal.interactive:
|
|
49
|
+
raise NoninteractiveLoginRequiresToken()
|
|
50
|
+
# The prompt goes to stderr so `login` stays usable inside a pipeline.
|
|
51
|
+
value = getpass.getpass("Vidbyte API key: ", stream=self._streams.stderr)
|
|
52
|
+
token = value.strip()
|
|
53
|
+
if not token or len(token) > _MAX_TOKEN_CHARACTERS:
|
|
54
|
+
raise InvalidApiKeyInput()
|
|
55
|
+
# Bounds run first, so an accidental file redirect is reported as oversized rather
|
|
56
|
+
# than prefix-tested in full.
|
|
57
|
+
if not Credentials.is_live_format(token):
|
|
58
|
+
raise ApiKeyNotLiveFormat()
|
|
59
|
+
return Credentials.from_value(token)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""The system keyring, adapted to profile- and API-origin-scoped Vidbyte credentials.
|
|
2
|
+
|
|
3
|
+
Every stored secret is scoped, so one profile cannot read another's key and a staging login
|
|
4
|
+
cannot authenticate against production. A backend failure is translated, never propagated:
|
|
5
|
+
keyring exceptions routinely quote account identifiers and backend internals.
|
|
6
|
+
|
|
7
|
+
A write is not believed until the same value reads back. Some backends accept a write and
|
|
8
|
+
store nothing, and a login that reports success while storing nothing is worse than failing.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING, Protocol, cast
|
|
14
|
+
|
|
15
|
+
import keyring
|
|
16
|
+
from keyring.errors import KeyringError
|
|
17
|
+
from pydantic import ValidationError
|
|
18
|
+
|
|
19
|
+
from ..config.models import ApiOrigin
|
|
20
|
+
from ..errors.failures import CredentialStoreUnavailable
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from .credentials import Credentials
|
|
24
|
+
|
|
25
|
+
_SERVICE_NAME = "vidbyte-cli"
|
|
26
|
+
# keyring's own null and fail backends sit below 1; anything at or above it is a real store.
|
|
27
|
+
_MINIMUM_BACKEND_PRIORITY = 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class KeyringBackend(Protocol):
|
|
31
|
+
"""Small structural surface needed from an installed keyring backend."""
|
|
32
|
+
|
|
33
|
+
priority: float
|
|
34
|
+
|
|
35
|
+
def get_password(self, service: str, username: str) -> str | None: ...
|
|
36
|
+
|
|
37
|
+
def set_password(self, service: str, username: str, password: str) -> None: ...
|
|
38
|
+
|
|
39
|
+
def delete_password(self, service: str, username: str) -> None: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CredentialScope:
|
|
43
|
+
"""The profile and API origin that one stored secret belongs to."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, profile: str, api_url: str) -> None:
|
|
46
|
+
self.profile = profile
|
|
47
|
+
self.origin = ApiOrigin.parse(api_url)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def account(self) -> str:
|
|
51
|
+
# The default port is omitted so `https://example.test` and `https://example.test:443`
|
|
52
|
+
# resolve to one entry rather than two credentials that silently disagree.
|
|
53
|
+
port = self.origin.port
|
|
54
|
+
scheme = self.origin.scheme
|
|
55
|
+
is_default_port = port is None or (scheme, port) in {("https", 443), ("http", 80)}
|
|
56
|
+
host = self.origin.host if is_default_port else f"{self.origin.host}:{port}"
|
|
57
|
+
return f"{self.profile}@{host}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class KeyringCredentialStore:
|
|
61
|
+
"""Secret store backed by the active system keyring."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, backend: KeyringBackend | None = None) -> None:
|
|
64
|
+
self._backend = backend
|
|
65
|
+
|
|
66
|
+
def available(self) -> bool:
|
|
67
|
+
# Availability is a question, not a failure: callers branch on it to choose the
|
|
68
|
+
# fallback path, so a broken backend answers False instead of raising.
|
|
69
|
+
try:
|
|
70
|
+
return float(self._get_backend().priority) >= _MINIMUM_BACKEND_PRIORITY
|
|
71
|
+
except (KeyringError, RuntimeError, TypeError, ValueError):
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
def read(self, profile: str, api_url: str) -> Credentials | None:
|
|
75
|
+
if not self.available():
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
value = self._get_backend().get_password(
|
|
79
|
+
_SERVICE_NAME,
|
|
80
|
+
CredentialScope(profile, api_url).account,
|
|
81
|
+
)
|
|
82
|
+
except KeyringError as error:
|
|
83
|
+
raise CredentialStoreUnavailable(error) from error
|
|
84
|
+
if value is None:
|
|
85
|
+
return None
|
|
86
|
+
# Deferred: importing at module scope would cycle, since credentials.py needs this
|
|
87
|
+
# module's scope type to address its own entries.
|
|
88
|
+
from .credentials import Credentials
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
return Credentials.from_value(value)
|
|
92
|
+
except ValidationError as error:
|
|
93
|
+
raise CredentialStoreUnavailable(error) from error
|
|
94
|
+
|
|
95
|
+
def write(self, credentials: Credentials, profile: str, api_url: str) -> None:
|
|
96
|
+
if not self.available():
|
|
97
|
+
raise CredentialStoreUnavailable()
|
|
98
|
+
account = CredentialScope(profile, api_url).account
|
|
99
|
+
try:
|
|
100
|
+
backend = self._get_backend()
|
|
101
|
+
backend.set_password(_SERVICE_NAME, account, credentials.secret_value())
|
|
102
|
+
read_back = backend.get_password(_SERVICE_NAME, account)
|
|
103
|
+
except KeyringError as error:
|
|
104
|
+
raise CredentialStoreUnavailable(error) from error
|
|
105
|
+
if read_back != credentials.secret_value():
|
|
106
|
+
raise CredentialStoreUnavailable()
|
|
107
|
+
|
|
108
|
+
def clear(self, profile: str, api_url: str) -> bool:
|
|
109
|
+
if not self.available():
|
|
110
|
+
return False
|
|
111
|
+
account = CredentialScope(profile, api_url).account
|
|
112
|
+
try:
|
|
113
|
+
backend = self._get_backend()
|
|
114
|
+
if backend.get_password(_SERVICE_NAME, account) is None:
|
|
115
|
+
return False
|
|
116
|
+
backend.delete_password(_SERVICE_NAME, account)
|
|
117
|
+
return True
|
|
118
|
+
except KeyringError as error:
|
|
119
|
+
raise CredentialStoreUnavailable(error) from error
|
|
120
|
+
|
|
121
|
+
def _get_backend(self) -> KeyringBackend:
|
|
122
|
+
# Resolved on first use and cached: backend discovery can open a keychain session,
|
|
123
|
+
# which must not happen while the command tree is being built.
|
|
124
|
+
if self._backend is None:
|
|
125
|
+
self._backend = cast(KeyringBackend, keyring.get_keyring())
|
|
126
|
+
return self._backend
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Secret-safe provider credential model and file document.
|
|
2
|
+
|
|
3
|
+
The key is a SecretStr so it stays out of reprs and dumps by default.
|
|
4
|
+
Scopes are profile@provider, not profile@host, so Vidbyte and provider keys never collide.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from enum import StrEnum
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, ConfigDict, Field, SecretStr, ValidationError, field_validator
|
|
14
|
+
|
|
15
|
+
from ...types.provider import PROVIDER_KEY_PREFIXES, Provider
|
|
16
|
+
from ..config.atomic import AtomicFileWriter
|
|
17
|
+
from ..config.paths import VidbytePaths
|
|
18
|
+
from ..errors.failures import StoredProviderCredentialUnreadable
|
|
19
|
+
from .keyring_store import CredentialScope # noqa: F401 - re-export scope concept reference
|
|
20
|
+
|
|
21
|
+
_MAX_CREDENTIAL_BYTES = 1_000_000
|
|
22
|
+
_MAX_KEY_CHARACTERS = 4096
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ProviderCredentials(BaseModel):
|
|
26
|
+
"""One provider API token with secret-safe representation."""
|
|
27
|
+
|
|
28
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
29
|
+
|
|
30
|
+
provider: Provider
|
|
31
|
+
api_key: SecretStr
|
|
32
|
+
|
|
33
|
+
@field_validator("api_key")
|
|
34
|
+
@classmethod
|
|
35
|
+
def validate_api_key(cls, value: SecretStr) -> SecretStr:
|
|
36
|
+
# Bounds before any network or storage write.
|
|
37
|
+
if not 1 <= len(value.get_secret_value()) <= _MAX_KEY_CHARACTERS:
|
|
38
|
+
raise ValueError("API key must contain between 1 and 4096 characters")
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def is_live_format(cls, provider: Provider, value: str) -> bool:
|
|
43
|
+
# Prefix: openai/deepseek sk- not sk-ant-; claude sk-ant-; muse LLM|; gemini AIza.
|
|
44
|
+
if provider == Provider.CLAUDE:
|
|
45
|
+
return value.startswith("sk-ant-")
|
|
46
|
+
if provider in (Provider.OPENAI, Provider.DEEPSEEK):
|
|
47
|
+
return value.startswith("sk-") and not value.startswith("sk-ant-")
|
|
48
|
+
if provider == Provider.MUSE:
|
|
49
|
+
return value.startswith("LLM|")
|
|
50
|
+
if provider == Provider.GEMINI:
|
|
51
|
+
return value.startswith("AIza")
|
|
52
|
+
if provider in (Provider.GROK, Provider.GLM):
|
|
53
|
+
return bool(value)
|
|
54
|
+
prefixes = PROVIDER_KEY_PREFIXES.get(provider, ())
|
|
55
|
+
if not prefixes:
|
|
56
|
+
return bool(value)
|
|
57
|
+
return any(value.startswith(prefix) for prefix in prefixes)
|
|
58
|
+
|
|
59
|
+
def secret_value(self) -> str:
|
|
60
|
+
# Single deliberate unwrapping point outside the file encoder.
|
|
61
|
+
return self.api_key.get_secret_value()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ProviderStorage(StrEnum):
|
|
65
|
+
"""Durable storage selected for a verified provider login."""
|
|
66
|
+
|
|
67
|
+
KEYRING = "keyring"
|
|
68
|
+
RESTRICTED_FILE = "restricted_file"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ProviderDocument(BaseModel):
|
|
72
|
+
"""Version-one restricted fallback file for provider keys."""
|
|
73
|
+
|
|
74
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
75
|
+
|
|
76
|
+
schema_version: Literal[1] = 1
|
|
77
|
+
entries: dict[str, SecretStr] = Field(default_factory=dict)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class ProviderScope:
|
|
81
|
+
"""The profile and provider that one stored secret belongs to."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, profile: str, provider: Provider) -> None:
|
|
84
|
+
# Minimal scope; provider is already a closed StrEnum.
|
|
85
|
+
self.profile = profile
|
|
86
|
+
self.provider = provider
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def account(self) -> str:
|
|
90
|
+
# Mirrors CredentialScope but keyed by provider name.
|
|
91
|
+
return f"{self.profile}@{self.provider.value}"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class FileProviderStore:
|
|
95
|
+
"""Permission-restricted fallback store for provider keys."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, paths: VidbytePaths, writer: AtomicFileWriter | None = None) -> None:
|
|
98
|
+
# Paths injected so tests can point at a temp directory.
|
|
99
|
+
self._paths = paths
|
|
100
|
+
self._writer = writer or AtomicFileWriter()
|
|
101
|
+
|
|
102
|
+
def read(self, profile: str, provider: Provider) -> ProviderCredentials | None:
|
|
103
|
+
# Looks up one entry; missing file means no stored key.
|
|
104
|
+
secret = self._load().entries.get(ProviderScope(profile, provider).account)
|
|
105
|
+
if secret is None:
|
|
106
|
+
return None
|
|
107
|
+
return ProviderCredentials(provider=provider, api_key=secret)
|
|
108
|
+
|
|
109
|
+
def write(self, credentials: ProviderCredentials, profile: str, provider: Provider) -> None:
|
|
110
|
+
# Inserts or replaces one entry and rewrites the whole file atomically.
|
|
111
|
+
document = self._load()
|
|
112
|
+
entries = dict(document.entries)
|
|
113
|
+
entries[ProviderScope(profile, provider).account] = credentials.api_key
|
|
114
|
+
self._writer.write(
|
|
115
|
+
self._paths.provider_credentials_file(),
|
|
116
|
+
self._encode(ProviderDocument(entries=entries)),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def clear(self, profile: str, provider: Provider) -> bool:
|
|
120
|
+
# Removes one entry if present; no-op otherwise.
|
|
121
|
+
document = self._load()
|
|
122
|
+
account = ProviderScope(profile, provider).account
|
|
123
|
+
if account not in document.entries:
|
|
124
|
+
return False
|
|
125
|
+
entries = dict(document.entries)
|
|
126
|
+
del entries[account]
|
|
127
|
+
self._writer.write(
|
|
128
|
+
self._paths.provider_credentials_file(),
|
|
129
|
+
self._encode(ProviderDocument(entries=entries)),
|
|
130
|
+
)
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
def _load(self) -> ProviderDocument:
|
|
134
|
+
# Reads and validates the whole file, bounded before parsing.
|
|
135
|
+
path = self._paths.provider_credentials_file()
|
|
136
|
+
if not path.exists():
|
|
137
|
+
return ProviderDocument()
|
|
138
|
+
try:
|
|
139
|
+
raw = path.read_bytes()
|
|
140
|
+
if len(raw) > _MAX_CREDENTIAL_BYTES:
|
|
141
|
+
raise ValueError("credential file exceeds the size limit")
|
|
142
|
+
parsed = json.loads(raw)
|
|
143
|
+
if isinstance(parsed, dict) and parsed.get("schema_version", 1) != 1:
|
|
144
|
+
raise ValueError("unsupported credential schema")
|
|
145
|
+
return ProviderDocument.model_validate(parsed)
|
|
146
|
+
except (OSError, json.JSONDecodeError, ValidationError, ValueError) as error:
|
|
147
|
+
raise StoredProviderCredentialUnreadable(error) from error
|
|
148
|
+
|
|
149
|
+
def _encode(self, document: ProviderDocument) -> bytes:
|
|
150
|
+
# One intentional unwrapping point for the restricted file.
|
|
151
|
+
payload = {
|
|
152
|
+
"schema_version": document.schema_version,
|
|
153
|
+
"entries": {
|
|
154
|
+
account: secret.get_secret_value() for account, secret in document.entries.items()
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8") + b"\n"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Acquisition of provider tokens via hidden prompt or stdin.
|
|
2
|
+
|
|
3
|
+
Mirrors CredentialInput but parameterizes by provider so prefix checks are exact.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import getpass
|
|
9
|
+
|
|
10
|
+
from ...types.provider import Provider
|
|
11
|
+
from ..errors.failures import (
|
|
12
|
+
InvalidProviderApiKeyInput,
|
|
13
|
+
NoninteractiveProviderLoginRequiresToken,
|
|
14
|
+
ProviderKeyNotLiveFormat,
|
|
15
|
+
)
|
|
16
|
+
from ..io import IOStreams
|
|
17
|
+
from ..io.terminal import TerminalCapabilities
|
|
18
|
+
from .provider_credentials import ProviderCredentials
|
|
19
|
+
|
|
20
|
+
_MAX_TOKEN_CHARACTERS = 4096
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProviderCredentialInput:
|
|
24
|
+
"""Acquire one provider token through an explicit secret-safe channel."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
streams: IOStreams,
|
|
29
|
+
terminal: TerminalCapabilities,
|
|
30
|
+
*,
|
|
31
|
+
no_input: bool,
|
|
32
|
+
provider: Provider,
|
|
33
|
+
) -> None:
|
|
34
|
+
# No_input and provider are fixed per command construction.
|
|
35
|
+
self._streams = streams
|
|
36
|
+
self._terminal = terminal
|
|
37
|
+
self._no_input = no_input
|
|
38
|
+
self._provider = provider
|
|
39
|
+
|
|
40
|
+
def read(self, *, from_stdin: bool) -> ProviderCredentials:
|
|
41
|
+
# Bounded read, then prefix check, so an accidental file redirect fails clearly.
|
|
42
|
+
if from_stdin:
|
|
43
|
+
value = self._streams.stdin.read(_MAX_TOKEN_CHARACTERS + 1)
|
|
44
|
+
else:
|
|
45
|
+
if self._no_input or not self._terminal.interactive:
|
|
46
|
+
raise NoninteractiveProviderLoginRequiresToken(self._provider.value)
|
|
47
|
+
prompt = f"{self._provider.value} API key: "
|
|
48
|
+
value = getpass.getpass(prompt, stream=self._streams.stderr)
|
|
49
|
+
token = value.strip()
|
|
50
|
+
if not token or len(token) > _MAX_TOKEN_CHARACTERS:
|
|
51
|
+
raise InvalidProviderApiKeyInput(self._provider.value)
|
|
52
|
+
if not ProviderCredentials.is_live_format(self._provider, token):
|
|
53
|
+
raise ProviderKeyNotLiveFormat(self._provider.value)
|
|
54
|
+
return ProviderCredentials(provider=self._provider, api_key=token) # type: ignore[arg-type]
|