worthless 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.
- worthless/__init__.py +0 -0
- worthless/adapters/__init__.py +15 -0
- worthless/adapters/anthropic.py +42 -0
- worthless/adapters/openai.py +37 -0
- worthless/adapters/registry.py +17 -0
- worthless/adapters/types.py +126 -0
- worthless/cli/__init__.py +5 -0
- worthless/cli/app.py +107 -0
- worthless/cli/bootstrap.py +235 -0
- worthless/cli/commands/__init__.py +1 -0
- worthless/cli/commands/down.py +97 -0
- worthless/cli/commands/lock.py +325 -0
- worthless/cli/commands/mcp.py +19 -0
- worthless/cli/commands/revoke.py +92 -0
- worthless/cli/commands/scan.py +320 -0
- worthless/cli/commands/status.py +125 -0
- worthless/cli/commands/unlock.py +215 -0
- worthless/cli/commands/up.py +234 -0
- worthless/cli/commands/wrap.py +233 -0
- worthless/cli/console.py +99 -0
- worthless/cli/default_command.py +255 -0
- worthless/cli/dotenv_rewriter.py +105 -0
- worthless/cli/enroll_stub.py +58 -0
- worthless/cli/errors.py +120 -0
- worthless/cli/key_patterns.py +50 -0
- worthless/cli/keystore.py +181 -0
- worthless/cli/platform.py +104 -0
- worthless/cli/process.py +391 -0
- worthless/cli/scanner.py +139 -0
- worthless/crypto/__init__.py +13 -0
- worthless/crypto/charsets.py +14 -0
- worthless/crypto/splitter.py +321 -0
- worthless/crypto/types.py +97 -0
- worthless/defaults.py +7 -0
- worthless/exceptions.py +5 -0
- worthless/mcp/__init__.py +1 -0
- worthless/mcp/server.py +223 -0
- worthless/proxy/__init__.py +5 -0
- worthless/proxy/app.py +447 -0
- worthless/proxy/config.py +85 -0
- worthless/proxy/errors.py +96 -0
- worthless/proxy/metering.py +243 -0
- worthless/proxy/rules.py +369 -0
- worthless/storage/__init__.py +6 -0
- worthless/storage/repository.py +502 -0
- worthless/storage/schema.py +145 -0
- worthless/testing/__init__.py +0 -0
- worthless/testing/runners.py +35 -0
- worthless-0.3.0.dist-info/METADATA +187 -0
- worthless-0.3.0.dist-info/RECORD +54 -0
- worthless-0.3.0.dist-info/WHEEL +5 -0
- worthless-0.3.0.dist-info/entry_points.txt +7 -0
- worthless-0.3.0.dist-info/licenses/LICENSE +661 -0
- worthless-0.3.0.dist-info/top_level.txt +1 -0
worthless/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Provider adapters for upstream API request/response transformation."""
|
|
2
|
+
|
|
3
|
+
from worthless.adapters.anthropic import AnthropicAdapter
|
|
4
|
+
from worthless.adapters.openai import OpenAIAdapter
|
|
5
|
+
from worthless.adapters.registry import get_adapter
|
|
6
|
+
from worthless.adapters.types import AdapterRequest, AdapterResponse, ProviderAdapter
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AdapterRequest",
|
|
10
|
+
"AdapterResponse",
|
|
11
|
+
"AnthropicAdapter",
|
|
12
|
+
"OpenAIAdapter",
|
|
13
|
+
"ProviderAdapter",
|
|
14
|
+
"get_adapter",
|
|
15
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Anthropic provider adapter — stateless request/response transformer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from worthless.adapters.types import (
|
|
10
|
+
AdapterRequest,
|
|
11
|
+
AdapterResponse,
|
|
12
|
+
relay_response,
|
|
13
|
+
strip_internal_headers,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
UPSTREAM_URL = os.environ.get(
|
|
17
|
+
"WORTHLESS_UPSTREAM_ANTHROPIC_URL",
|
|
18
|
+
"https://api.anthropic.com/v1/messages",
|
|
19
|
+
)
|
|
20
|
+
DEFAULT_ANTHROPIC_VERSION = "2023-06-01"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AnthropicAdapter:
|
|
24
|
+
"""Transforms requests for the Anthropic messages API."""
|
|
25
|
+
|
|
26
|
+
def prepare_request(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
body: bytes,
|
|
30
|
+
headers: dict[str, str],
|
|
31
|
+
api_key: bytearray,
|
|
32
|
+
) -> AdapterRequest:
|
|
33
|
+
out_headers = strip_internal_headers(headers)
|
|
34
|
+
out_headers["x-api-key"] = api_key.decode()
|
|
35
|
+
|
|
36
|
+
if "anthropic-version" not in out_headers:
|
|
37
|
+
out_headers["anthropic-version"] = DEFAULT_ANTHROPIC_VERSION
|
|
38
|
+
|
|
39
|
+
return AdapterRequest(url=UPSTREAM_URL, headers=out_headers, body=body)
|
|
40
|
+
|
|
41
|
+
async def relay_response(self, response: httpx.Response) -> AdapterResponse:
|
|
42
|
+
return await relay_response(response)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""OpenAI provider adapter — stateless request/response transformer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from worthless.adapters.types import (
|
|
10
|
+
AdapterRequest,
|
|
11
|
+
AdapterResponse,
|
|
12
|
+
relay_response,
|
|
13
|
+
strip_internal_headers,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
UPSTREAM_URL = os.environ.get(
|
|
17
|
+
"WORTHLESS_UPSTREAM_OPENAI_URL",
|
|
18
|
+
"https://api.openai.com/v1/chat/completions",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OpenAIAdapter:
|
|
23
|
+
"""Transforms requests for the OpenAI chat completions API."""
|
|
24
|
+
|
|
25
|
+
def prepare_request(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
body: bytes,
|
|
29
|
+
headers: dict[str, str],
|
|
30
|
+
api_key: bytearray,
|
|
31
|
+
) -> AdapterRequest:
|
|
32
|
+
out_headers = strip_internal_headers(headers)
|
|
33
|
+
out_headers["authorization"] = f"Bearer {api_key.decode()}"
|
|
34
|
+
return AdapterRequest(url=UPSTREAM_URL, headers=out_headers, body=body)
|
|
35
|
+
|
|
36
|
+
async def relay_response(self, response: httpx.Response) -> AdapterResponse:
|
|
37
|
+
return await relay_response(response)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Path-based adapter lookup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from worthless.adapters.anthropic import AnthropicAdapter
|
|
6
|
+
from worthless.adapters.openai import OpenAIAdapter
|
|
7
|
+
from worthless.adapters.types import ProviderAdapter
|
|
8
|
+
|
|
9
|
+
_ADAPTERS: dict[str, ProviderAdapter] = {
|
|
10
|
+
"/v1/chat/completions": OpenAIAdapter(),
|
|
11
|
+
"/v1/messages": AnthropicAdapter(),
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_adapter(path: str) -> ProviderAdapter | None:
|
|
16
|
+
"""Return the adapter for the given request path, or None if unrecognized."""
|
|
17
|
+
return _ADAPTERS.get(path)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Adapter contracts for provider request/response transformation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
INTERNAL_HEADER_PREFIX = "x-worthless-"
|
|
12
|
+
|
|
13
|
+
# RFC 2616 hop-by-hop headers that must not be forwarded by proxies.
|
|
14
|
+
_HOP_BY_HOP_HEADERS: frozenset[str] = frozenset(
|
|
15
|
+
{
|
|
16
|
+
"connection",
|
|
17
|
+
"transfer-encoding",
|
|
18
|
+
"te",
|
|
19
|
+
"upgrade",
|
|
20
|
+
"proxy-authorization",
|
|
21
|
+
"host",
|
|
22
|
+
"keep-alive",
|
|
23
|
+
"trailer",
|
|
24
|
+
"proxy-connection",
|
|
25
|
+
}
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
SSE_RESPONSE_HEADERS: dict[str, str] = {
|
|
29
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
30
|
+
"Cache-Control": "no-cache",
|
|
31
|
+
"X-Accel-Buffering": "no",
|
|
32
|
+
"Connection": "keep-alive",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_SENSITIVE_HEADER_KEYS: frozenset[str] = frozenset({"authorization", "x-api-key"})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class AdapterRequest:
|
|
41
|
+
"""Upstream request prepared by a provider adapter."""
|
|
42
|
+
|
|
43
|
+
url: str
|
|
44
|
+
headers: dict[str, str]
|
|
45
|
+
body: bytes
|
|
46
|
+
|
|
47
|
+
def __repr__(self) -> str:
|
|
48
|
+
redacted: dict[str, str] = {}
|
|
49
|
+
for k, v in self.headers.items():
|
|
50
|
+
if k.lower() in _SENSITIVE_HEADER_KEYS:
|
|
51
|
+
redacted[k] = "REDACTED"
|
|
52
|
+
else:
|
|
53
|
+
redacted[k] = v
|
|
54
|
+
return (
|
|
55
|
+
f"AdapterRequest(url={self.url!r}, headers={redacted!r}, body=<{len(self.body)} bytes>)"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class AdapterResponse:
|
|
61
|
+
"""Upstream response relayed by a provider adapter."""
|
|
62
|
+
|
|
63
|
+
status_code: int
|
|
64
|
+
headers: dict[str, str]
|
|
65
|
+
body: bytes
|
|
66
|
+
is_streaming: bool = False
|
|
67
|
+
stream: AsyncIterator[bytes] | None = field(default=None, compare=False)
|
|
68
|
+
|
|
69
|
+
def __repr__(self) -> str:
|
|
70
|
+
return (
|
|
71
|
+
f"AdapterResponse(status_code={self.status_code}, "
|
|
72
|
+
f"headers=<{len(self.headers)} entries>, "
|
|
73
|
+
f"body=<{len(self.body)} bytes>, "
|
|
74
|
+
f"is_streaming={self.is_streaming})"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ProviderAdapter(Protocol):
|
|
79
|
+
"""Protocol that all provider adapters must satisfy."""
|
|
80
|
+
|
|
81
|
+
def prepare_request(
|
|
82
|
+
self,
|
|
83
|
+
*,
|
|
84
|
+
body: bytes,
|
|
85
|
+
headers: dict[str, str],
|
|
86
|
+
api_key: bytearray,
|
|
87
|
+
) -> AdapterRequest: ...
|
|
88
|
+
|
|
89
|
+
async def relay_response(self, response: httpx.Response) -> AdapterResponse: ...
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def strip_internal_headers(headers: dict[str, str]) -> dict[str, str]:
|
|
93
|
+
"""Copy headers, dropping x-worthless-*, hop-by-hop headers, and lowercasing keys."""
|
|
94
|
+
return {
|
|
95
|
+
low: v
|
|
96
|
+
for k, v in headers.items()
|
|
97
|
+
if not (low := k.lower()).startswith(INTERNAL_HEADER_PREFIX)
|
|
98
|
+
and low not in _HOP_BY_HOP_HEADERS
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def relay_response(response: httpx.Response) -> AdapterResponse:
|
|
103
|
+
"""Shared relay logic for all adapters — handles streaming and non-streaming.
|
|
104
|
+
|
|
105
|
+
Supports responses sent with stream=True: for non-SSE responses, reads the
|
|
106
|
+
body with aread() before accessing content.
|
|
107
|
+
"""
|
|
108
|
+
content_type = response.headers.get("content-type", "")
|
|
109
|
+
ct_main = content_type.split(";")[0].strip().lower()
|
|
110
|
+
if ct_main == "text/event-stream":
|
|
111
|
+
return AdapterResponse(
|
|
112
|
+
status_code=response.status_code,
|
|
113
|
+
headers=dict(SSE_RESPONSE_HEADERS),
|
|
114
|
+
body=b"",
|
|
115
|
+
is_streaming=True,
|
|
116
|
+
stream=response.aiter_bytes(),
|
|
117
|
+
)
|
|
118
|
+
# For non-streaming: read body if sent with stream=True
|
|
119
|
+
if not hasattr(response, "_content"):
|
|
120
|
+
await response.aread()
|
|
121
|
+
return AdapterResponse(
|
|
122
|
+
status_code=response.status_code,
|
|
123
|
+
headers=dict(response.headers),
|
|
124
|
+
body=response.content,
|
|
125
|
+
is_streaming=False,
|
|
126
|
+
)
|
worthless/cli/app.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Typer application — CLI entry point for ``worthless``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import traceback
|
|
7
|
+
from importlib.metadata import version as pkg_version
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from worthless.cli.console import WorthlessConsole, get_console, set_console
|
|
12
|
+
from worthless.cli.default_command import run_default
|
|
13
|
+
from worthless.cli.errors import WorthlessError, set_debug
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _version_callback(value: bool) -> None:
|
|
17
|
+
if value:
|
|
18
|
+
typer.echo(f"worthless {pkg_version('worthless')}")
|
|
19
|
+
raise typer.Exit()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(
|
|
23
|
+
name="worthless",
|
|
24
|
+
help=(
|
|
25
|
+
"Protect your API keys in 90 seconds.\n\n"
|
|
26
|
+
"Run 'worthless' with no arguments to auto-detect keys, "
|
|
27
|
+
"lock them, and start the proxy."
|
|
28
|
+
),
|
|
29
|
+
no_args_is_help=False,
|
|
30
|
+
invoke_without_command=True,
|
|
31
|
+
pretty_exceptions_enable=False,
|
|
32
|
+
add_completion=False,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.callback(invoke_without_command=True)
|
|
37
|
+
def _main(
|
|
38
|
+
ctx: typer.Context,
|
|
39
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress non-error output"),
|
|
40
|
+
json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON"),
|
|
41
|
+
debug: bool = typer.Option(False, "--debug", help="Show full tracebacks on error"),
|
|
42
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Auto-approve prompts"),
|
|
43
|
+
version: bool = typer.Option(
|
|
44
|
+
False,
|
|
45
|
+
"--version",
|
|
46
|
+
"-V",
|
|
47
|
+
help="Show version and exit",
|
|
48
|
+
callback=_version_callback,
|
|
49
|
+
is_eager=True,
|
|
50
|
+
),
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Worthless — make leaked API keys worthless."""
|
|
53
|
+
set_debug(debug)
|
|
54
|
+
set_console(WorthlessConsole(quiet=quiet, json_mode=json_output))
|
|
55
|
+
|
|
56
|
+
# When no subcommand is given, run the magic default pipeline
|
|
57
|
+
if ctx.invoked_subcommand is None:
|
|
58
|
+
try:
|
|
59
|
+
interactive = hasattr(sys.stdin, "isatty") and sys.stdin.isatty()
|
|
60
|
+
run_default(interactive=interactive, yes=yes, json_mode=json_output)
|
|
61
|
+
except WorthlessError as exc:
|
|
62
|
+
if debug:
|
|
63
|
+
traceback.print_exc(file=sys.stderr)
|
|
64
|
+
else:
|
|
65
|
+
get_console().print_error(exc)
|
|
66
|
+
raise typer.Exit(code=exc.exit_code) from exc
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# -- Register command modules --------------------------------------------------
|
|
70
|
+
from worthless.cli.commands.lock import register_lock_commands # noqa: E402
|
|
71
|
+
|
|
72
|
+
register_lock_commands(app)
|
|
73
|
+
|
|
74
|
+
from worthless.cli.commands.unlock import register_unlock_commands # noqa: E402
|
|
75
|
+
|
|
76
|
+
register_unlock_commands(app)
|
|
77
|
+
|
|
78
|
+
from worthless.cli.commands.scan import register_scan_commands # noqa: E402
|
|
79
|
+
|
|
80
|
+
register_scan_commands(app)
|
|
81
|
+
|
|
82
|
+
from worthless.cli.commands.status import register_status_commands # noqa: E402
|
|
83
|
+
|
|
84
|
+
register_status_commands(app)
|
|
85
|
+
|
|
86
|
+
from worthless.cli.commands.wrap import register_wrap_commands # noqa: E402
|
|
87
|
+
|
|
88
|
+
register_wrap_commands(app)
|
|
89
|
+
|
|
90
|
+
from worthless.cli.commands.up import register_up_commands # noqa: E402
|
|
91
|
+
|
|
92
|
+
register_up_commands(app)
|
|
93
|
+
|
|
94
|
+
from worthless.cli.commands.down import register_down_commands # noqa: E402
|
|
95
|
+
|
|
96
|
+
register_down_commands(app)
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
from worthless.cli.commands.mcp import register_mcp_commands # noqa: E402
|
|
100
|
+
|
|
101
|
+
register_mcp_commands(app)
|
|
102
|
+
except ImportError:
|
|
103
|
+
pass # mcp extra not installed — worthless[mcp]
|
|
104
|
+
|
|
105
|
+
from worthless.cli.commands.revoke import register_revoke_commands # noqa: E402
|
|
106
|
+
|
|
107
|
+
register_revoke_commands(app)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""First-run ~/.worthless/ initialization and lock management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from collections.abc import Generator
|
|
12
|
+
|
|
13
|
+
from cryptography.fernet import Fernet
|
|
14
|
+
|
|
15
|
+
from worthless.cli.errors import ErrorCode, WorthlessError, sanitize_exception
|
|
16
|
+
from worthless.cli.keystore import migrate_file_to_keyring, read_fernet_key, store_fernet_key
|
|
17
|
+
from worthless.cli.platform import IS_WINDOWS
|
|
18
|
+
|
|
19
|
+
_DEFAULT_BASE = Path.home() / ".worthless"
|
|
20
|
+
_STALE_LOCK_SECONDS = 300 # 5 minutes
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class WorthlessHome:
|
|
25
|
+
"""Paths within the ``~/.worthless/`` directory tree."""
|
|
26
|
+
|
|
27
|
+
base_dir: Path = field(default_factory=lambda: _DEFAULT_BASE)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def db_path(self) -> Path:
|
|
31
|
+
return self.base_dir / "worthless.db"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def fernet_key_path(self) -> Path:
|
|
35
|
+
env_path = os.environ.get("WORTHLESS_FERNET_KEY_PATH")
|
|
36
|
+
if env_path:
|
|
37
|
+
return Path(env_path)
|
|
38
|
+
return self.base_dir / "fernet.key"
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def shard_a_dir(self) -> Path:
|
|
42
|
+
return self.base_dir / "shard_a"
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def lock_file(self) -> Path:
|
|
46
|
+
return self.base_dir / ".lock-in-progress"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def fernet_key(self) -> bytearray:
|
|
50
|
+
"""Read the Fernet key via keystore cascade (SR-01: mutable bytearray)."""
|
|
51
|
+
return read_fernet_key(self.base_dir)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ensure_home(base_dir: Path | None = None) -> WorthlessHome:
|
|
55
|
+
"""Create ``~/.worthless/`` structure on first run (idempotent).
|
|
56
|
+
|
|
57
|
+
Creates directories with 0700 permissions, generates a Fernet key if
|
|
58
|
+
missing, and initialises the SQLite database.
|
|
59
|
+
"""
|
|
60
|
+
home = WorthlessHome(base_dir=base_dir or _DEFAULT_BASE)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
# Create directories
|
|
64
|
+
home.base_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
65
|
+
home.shard_a_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
66
|
+
|
|
67
|
+
# Ensure permissions are correct even if dir already existed
|
|
68
|
+
if not IS_WINDOWS:
|
|
69
|
+
home.base_dir.chmod(0o700)
|
|
70
|
+
home.shard_a_dir.chmod(0o700)
|
|
71
|
+
|
|
72
|
+
# Validate custom fernet key path if set via env var
|
|
73
|
+
fernet_path = home.fernet_key_path
|
|
74
|
+
fernet_parent = fernet_path.parent
|
|
75
|
+
if os.environ.get("WORTHLESS_FERNET_KEY_PATH") and not fernet_parent.is_dir():
|
|
76
|
+
raise WorthlessError(
|
|
77
|
+
ErrorCode.BOOTSTRAP_FAILED,
|
|
78
|
+
f"WORTHLESS_FERNET_KEY_PATH directory does not exist: {fernet_parent}\n"
|
|
79
|
+
"Create it or mount a volume at that path.",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Generate Fernet key if missing (keyring or file fallback)
|
|
83
|
+
try:
|
|
84
|
+
read_fernet_key(home.base_dir)
|
|
85
|
+
except WorthlessError as exc:
|
|
86
|
+
if exc.code != ErrorCode.KEY_NOT_FOUND:
|
|
87
|
+
raise
|
|
88
|
+
key = Fernet.generate_key()
|
|
89
|
+
store_fernet_key(key, home_dir=home.base_dir)
|
|
90
|
+
else:
|
|
91
|
+
migrate_file_to_keyring(home.base_dir)
|
|
92
|
+
except WorthlessError:
|
|
93
|
+
raise
|
|
94
|
+
except OSError as exc:
|
|
95
|
+
fernet_env = os.environ.get("WORTHLESS_FERNET_KEY_PATH")
|
|
96
|
+
if fernet_env:
|
|
97
|
+
raise WorthlessError(
|
|
98
|
+
ErrorCode.BOOTSTRAP_FAILED,
|
|
99
|
+
f"Cannot write fernet key to {fernet_env}: "
|
|
100
|
+
f"{sanitize_exception(exc, generic='permission denied or path invalid')}\n"
|
|
101
|
+
"Check that the directory exists and is writable.",
|
|
102
|
+
) from exc
|
|
103
|
+
raise WorthlessError(
|
|
104
|
+
ErrorCode.BOOTSTRAP_FAILED,
|
|
105
|
+
sanitize_exception(exc, generic="failed to initialise home directory"),
|
|
106
|
+
) from exc
|
|
107
|
+
|
|
108
|
+
# Initialise database (idempotent — CREATE TABLE IF NOT EXISTS)
|
|
109
|
+
try:
|
|
110
|
+
_init_db(home)
|
|
111
|
+
except (OSError, sqlite3.DatabaseError) as exc:
|
|
112
|
+
raise WorthlessError(
|
|
113
|
+
ErrorCode.SHARD_STORAGE_FAILED,
|
|
114
|
+
sanitize_exception(exc, generic="failed to initialise database"),
|
|
115
|
+
) from exc
|
|
116
|
+
|
|
117
|
+
return home
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _init_db(home: WorthlessHome) -> None:
|
|
121
|
+
"""Create the SQLite database using the canonical schema and run migrations."""
|
|
122
|
+
import asyncio
|
|
123
|
+
|
|
124
|
+
from worthless.storage.schema import SCHEMA, migrate_db
|
|
125
|
+
|
|
126
|
+
conn = sqlite3.connect(str(home.db_path))
|
|
127
|
+
try:
|
|
128
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
129
|
+
|
|
130
|
+
# Run forward-only migrations BEFORE the full schema so that
|
|
131
|
+
# upgraded installs whose enrollments table pre-dates the
|
|
132
|
+
# decoy_hash column get it added before CREATE INDEX touches it.
|
|
133
|
+
tables = {
|
|
134
|
+
row[0]
|
|
135
|
+
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
|
136
|
+
}
|
|
137
|
+
if "enrollments" in tables:
|
|
138
|
+
cursor = conn.execute("PRAGMA table_info(enrollments)")
|
|
139
|
+
columns = {row[1] for row in cursor.fetchall()}
|
|
140
|
+
if "decoy_hash" not in columns:
|
|
141
|
+
try:
|
|
142
|
+
conn.execute("ALTER TABLE enrollments ADD COLUMN decoy_hash TEXT")
|
|
143
|
+
conn.commit()
|
|
144
|
+
except sqlite3.OperationalError as exc:
|
|
145
|
+
if "duplicate column" not in str(exc).lower():
|
|
146
|
+
raise
|
|
147
|
+
|
|
148
|
+
conn.executescript(SCHEMA)
|
|
149
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
150
|
+
conn.commit()
|
|
151
|
+
finally:
|
|
152
|
+
conn.close()
|
|
153
|
+
|
|
154
|
+
# Run async migrations (WOR-183: rules engine columns, spend_log cleanup)
|
|
155
|
+
try:
|
|
156
|
+
asyncio.get_running_loop()
|
|
157
|
+
# Already in async context — schedule as a task won't work from sync.
|
|
158
|
+
# Use a new thread's event loop instead.
|
|
159
|
+
import concurrent.futures
|
|
160
|
+
|
|
161
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
162
|
+
pool.submit(asyncio.run, migrate_db(str(home.db_path))).result()
|
|
163
|
+
except RuntimeError:
|
|
164
|
+
# No running loop — safe to use asyncio.run()
|
|
165
|
+
asyncio.run(migrate_db(str(home.db_path)))
|
|
166
|
+
|
|
167
|
+
# Restrict DB file permissions (no-op on Windows — NTFS ACLs are different)
|
|
168
|
+
if not IS_WINDOWS:
|
|
169
|
+
home.db_path.chmod(0o600)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@contextmanager
|
|
173
|
+
def acquire_lock(home: WorthlessHome) -> Generator[None, None, None]:
|
|
174
|
+
"""Acquire an exclusive lock file using O_CREAT|O_EXCL."""
|
|
175
|
+
check_stale_lock(home)
|
|
176
|
+
try:
|
|
177
|
+
fd = os.open(
|
|
178
|
+
str(home.lock_file),
|
|
179
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
180
|
+
0o600,
|
|
181
|
+
)
|
|
182
|
+
os.close(fd)
|
|
183
|
+
except FileExistsError:
|
|
184
|
+
raise WorthlessError(
|
|
185
|
+
ErrorCode.LOCK_IN_PROGRESS,
|
|
186
|
+
"Another worthless operation is in progress. "
|
|
187
|
+
"Remove ~/.worthless/.lock-in-progress if stale.",
|
|
188
|
+
) from None # FileExistsError context is not useful to callers
|
|
189
|
+
try:
|
|
190
|
+
yield
|
|
191
|
+
finally:
|
|
192
|
+
try:
|
|
193
|
+
home.lock_file.unlink()
|
|
194
|
+
except FileNotFoundError:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def get_home() -> WorthlessHome:
|
|
199
|
+
"""Resolve WorthlessHome from WORTHLESS_HOME env var or default."""
|
|
200
|
+
env_home = os.environ.get("WORTHLESS_HOME")
|
|
201
|
+
if env_home:
|
|
202
|
+
return ensure_home(Path(env_home))
|
|
203
|
+
return ensure_home()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def resolve_home() -> WorthlessHome | None:
|
|
207
|
+
"""Try to load WorthlessHome; return None if not initialized."""
|
|
208
|
+
try:
|
|
209
|
+
env_home = os.environ.get("WORTHLESS_HOME")
|
|
210
|
+
if env_home:
|
|
211
|
+
base = Path(env_home)
|
|
212
|
+
if base.exists():
|
|
213
|
+
return ensure_home(base)
|
|
214
|
+
return None
|
|
215
|
+
default = Path.home() / ".worthless"
|
|
216
|
+
if default.exists():
|
|
217
|
+
return ensure_home(default)
|
|
218
|
+
return None
|
|
219
|
+
except Exception:
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def check_stale_lock(home: WorthlessHome) -> None:
|
|
224
|
+
"""Remove stale lock files (> 5 min old), raise on fresh locks."""
|
|
225
|
+
if not home.lock_file.exists():
|
|
226
|
+
return
|
|
227
|
+
age = time.time() - home.lock_file.stat().st_mtime
|
|
228
|
+
if age > _STALE_LOCK_SECONDS:
|
|
229
|
+
home.lock_file.unlink(missing_ok=True)
|
|
230
|
+
else:
|
|
231
|
+
raise WorthlessError(
|
|
232
|
+
ErrorCode.LOCK_IN_PROGRESS,
|
|
233
|
+
f"Lock file is {int(age)}s old (< {_STALE_LOCK_SECONDS}s). "
|
|
234
|
+
"Another operation may be running.",
|
|
235
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command modules for worthless."""
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""down command -- stop a running proxy daemon.
|
|
2
|
+
|
|
3
|
+
``worthless down`` reads the PID file, sends SIGTERM to the process group,
|
|
4
|
+
polls for exit, escalates to SIGKILL after a timeout, and cleans up.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from worthless.cli.bootstrap import get_home
|
|
15
|
+
from worthless.cli.console import WorthlessConsole, get_console
|
|
16
|
+
from worthless.cli.errors import ErrorCode, WorthlessError, error_boundary
|
|
17
|
+
from worthless.cli.platform import kill_tree, warn_windows_once
|
|
18
|
+
from worthless.cli.process import MAX_VALID_PID, check_pid, pid_path, read_pid
|
|
19
|
+
|
|
20
|
+
# Tunable for tests
|
|
21
|
+
_TERM_TIMEOUT: float = 5.0
|
|
22
|
+
_POLL_INTERVAL: float = 0.2
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _done(pf: Path, console: WorthlessConsole, msg: str, *, success: bool = False) -> None:
|
|
26
|
+
"""Clean PID file and print status."""
|
|
27
|
+
pf.unlink(missing_ok=True)
|
|
28
|
+
if success:
|
|
29
|
+
console.print_success(msg)
|
|
30
|
+
else:
|
|
31
|
+
console.print_warning(msg)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def register_down_commands(app: typer.Typer) -> None:
|
|
35
|
+
"""Register the ``down`` command on the Typer app."""
|
|
36
|
+
|
|
37
|
+
@app.command()
|
|
38
|
+
@error_boundary
|
|
39
|
+
def down() -> None:
|
|
40
|
+
"""Stop the running proxy daemon."""
|
|
41
|
+
console = get_console()
|
|
42
|
+
home = get_home()
|
|
43
|
+
warn_windows_once(quiet=console.quiet)
|
|
44
|
+
|
|
45
|
+
pf = pid_path(home)
|
|
46
|
+
info = read_pid(pf)
|
|
47
|
+
|
|
48
|
+
# No PID file or corrupt → nothing to stop (idempotent)
|
|
49
|
+
if info is None:
|
|
50
|
+
_done(pf, console, "Proxy is not running.")
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
pid, port = info
|
|
54
|
+
|
|
55
|
+
# Reject dangerous or out-of-range PID values
|
|
56
|
+
if pid <= 1 or pid > MAX_VALID_PID:
|
|
57
|
+
_done(pf, console, f"Proxy is not running (invalid PID {pid} cleaned up).")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
# Stale PID → clean up
|
|
61
|
+
if not check_pid(pid):
|
|
62
|
+
_done(pf, console, f"Proxy is not running (stale PID {pid} cleaned up).")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
# Kill the process tree (SIGTERM via psutil)
|
|
66
|
+
try:
|
|
67
|
+
kill_tree(pid)
|
|
68
|
+
except PermissionError as exc:
|
|
69
|
+
raise WorthlessError(
|
|
70
|
+
ErrorCode.PROXY_NOT_RUNNING,
|
|
71
|
+
f"Cannot stop PID {pid}: permission denied. "
|
|
72
|
+
f"Try: sudo kill -9 {pid}, then: worthless down",
|
|
73
|
+
) from exc
|
|
74
|
+
|
|
75
|
+
console.print_hint(f"Stopping proxy (PID {pid})...")
|
|
76
|
+
|
|
77
|
+
# Poll for graceful exit
|
|
78
|
+
deadline = time.monotonic() + _TERM_TIMEOUT
|
|
79
|
+
while time.monotonic() < deadline:
|
|
80
|
+
if not check_pid(pid):
|
|
81
|
+
_done(pf, console, f"Proxy stopped (was PID {pid} on port {port}).", success=True)
|
|
82
|
+
return
|
|
83
|
+
time.sleep(_POLL_INTERVAL)
|
|
84
|
+
|
|
85
|
+
# Escalate: force kill (SIGKILL on Unix, TerminateProcess on Windows)
|
|
86
|
+
try:
|
|
87
|
+
kill_tree(pid, force=True)
|
|
88
|
+
except PermissionError:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
time.sleep(_POLL_INTERVAL)
|
|
92
|
+
_done(
|
|
93
|
+
pf,
|
|
94
|
+
console,
|
|
95
|
+
f"Proxy did not respond to SIGTERM within {_TERM_TIMEOUT:.0f}s; "
|
|
96
|
+
f"force-killed (was PID {pid} on port {port}).",
|
|
97
|
+
)
|