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.
Files changed (54) hide show
  1. worthless/__init__.py +0 -0
  2. worthless/adapters/__init__.py +15 -0
  3. worthless/adapters/anthropic.py +42 -0
  4. worthless/adapters/openai.py +37 -0
  5. worthless/adapters/registry.py +17 -0
  6. worthless/adapters/types.py +126 -0
  7. worthless/cli/__init__.py +5 -0
  8. worthless/cli/app.py +107 -0
  9. worthless/cli/bootstrap.py +235 -0
  10. worthless/cli/commands/__init__.py +1 -0
  11. worthless/cli/commands/down.py +97 -0
  12. worthless/cli/commands/lock.py +325 -0
  13. worthless/cli/commands/mcp.py +19 -0
  14. worthless/cli/commands/revoke.py +92 -0
  15. worthless/cli/commands/scan.py +320 -0
  16. worthless/cli/commands/status.py +125 -0
  17. worthless/cli/commands/unlock.py +215 -0
  18. worthless/cli/commands/up.py +234 -0
  19. worthless/cli/commands/wrap.py +233 -0
  20. worthless/cli/console.py +99 -0
  21. worthless/cli/default_command.py +255 -0
  22. worthless/cli/dotenv_rewriter.py +105 -0
  23. worthless/cli/enroll_stub.py +58 -0
  24. worthless/cli/errors.py +120 -0
  25. worthless/cli/key_patterns.py +50 -0
  26. worthless/cli/keystore.py +181 -0
  27. worthless/cli/platform.py +104 -0
  28. worthless/cli/process.py +391 -0
  29. worthless/cli/scanner.py +139 -0
  30. worthless/crypto/__init__.py +13 -0
  31. worthless/crypto/charsets.py +14 -0
  32. worthless/crypto/splitter.py +321 -0
  33. worthless/crypto/types.py +97 -0
  34. worthless/defaults.py +7 -0
  35. worthless/exceptions.py +5 -0
  36. worthless/mcp/__init__.py +1 -0
  37. worthless/mcp/server.py +223 -0
  38. worthless/proxy/__init__.py +5 -0
  39. worthless/proxy/app.py +447 -0
  40. worthless/proxy/config.py +85 -0
  41. worthless/proxy/errors.py +96 -0
  42. worthless/proxy/metering.py +243 -0
  43. worthless/proxy/rules.py +369 -0
  44. worthless/storage/__init__.py +6 -0
  45. worthless/storage/repository.py +502 -0
  46. worthless/storage/schema.py +145 -0
  47. worthless/testing/__init__.py +0 -0
  48. worthless/testing/runners.py +35 -0
  49. worthless-0.3.0.dist-info/METADATA +187 -0
  50. worthless-0.3.0.dist-info/RECORD +54 -0
  51. worthless-0.3.0.dist-info/WHEEL +5 -0
  52. worthless-0.3.0.dist-info/entry_points.txt +7 -0
  53. worthless-0.3.0.dist-info/licenses/LICENSE +661 -0
  54. 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
+ )
@@ -0,0 +1,5 @@
1
+ """Worthless CLI — enrollment and key management stubs."""
2
+
3
+ from worthless.cli.app import app
4
+
5
+ __all__ = ["app"]
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
+ )