moonlighter-core 0.1.0__tar.gz
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.
- moonlighter_core-0.1.0/.gitignore +25 -0
- moonlighter_core-0.1.0/PKG-INFO +16 -0
- moonlighter_core-0.1.0/moonlighter/core/__init__.py +0 -0
- moonlighter_core-0.1.0/moonlighter/core/browser.py +214 -0
- moonlighter_core-0.1.0/moonlighter/core/config.py +368 -0
- moonlighter_core-0.1.0/moonlighter/core/db.py +100 -0
- moonlighter_core-0.1.0/moonlighter/core/llm.py +168 -0
- moonlighter_core-0.1.0/moonlighter/core/log.py +135 -0
- moonlighter_core-0.1.0/moonlighter/core/metrics.py +71 -0
- moonlighter_core-0.1.0/moonlighter/core/migrations.py +135 -0
- moonlighter_core-0.1.0/moonlighter/core/parsing.py +48 -0
- moonlighter_core-0.1.0/moonlighter/core/plugins.py +27 -0
- moonlighter_core-0.1.0/moonlighter/core/sources.py +23 -0
- moonlighter_core-0.1.0/moonlighter/py.typed +0 -0
- moonlighter_core-0.1.0/pyproject.toml +28 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.pyc
|
|
3
|
+
.DS_Store
|
|
4
|
+
.venv/
|
|
5
|
+
*.egg-info/
|
|
6
|
+
.superpowers/
|
|
7
|
+
.claude/
|
|
8
|
+
.claude.local.md
|
|
9
|
+
|
|
10
|
+
# Personal data — kept on disk locally, out of the repo. Generic templates
|
|
11
|
+
# (.example) get added when preparing the public release.
|
|
12
|
+
config.yaml
|
|
13
|
+
profile/
|
|
14
|
+
docs/
|
|
15
|
+
specs/
|
|
16
|
+
company_list.yaml
|
|
17
|
+
blocklist_learned.yaml
|
|
18
|
+
TODO.md
|
|
19
|
+
*.db
|
|
20
|
+
|
|
21
|
+
# Coverage
|
|
22
|
+
.coverage
|
|
23
|
+
.coverage.*
|
|
24
|
+
htmlcov/
|
|
25
|
+
coverage.xml
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: moonlighter-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Core database, configuration, and LLM primitives for moonlighter, with an optional browser driver (the [browser] extra)
|
|
5
|
+
Project-URL: Homepage, https://github.com/albertosca/moonlighter
|
|
6
|
+
Project-URL: Repository, https://github.com/albertosca/moonlighter
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/albertosca/moonlighter/issues
|
|
8
|
+
Author-email: Alberto de Sá Cavalcanti de Albuquerque <albertoalbuquerque01@gmail.com>
|
|
9
|
+
License: AGPL-3.0-only
|
|
10
|
+
Requires-Python: >=3.14
|
|
11
|
+
Requires-Dist: anthropic>=0.40
|
|
12
|
+
Requires-Dist: peewee>=3.17
|
|
13
|
+
Requires-Dist: pyyaml>=6.0
|
|
14
|
+
Requires-Dist: rich>=13.7
|
|
15
|
+
Provides-Extra: browser
|
|
16
|
+
Requires-Dist: playwright>=1.47; extra == 'browser'
|
|
File without changes
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import subprocess
|
|
4
|
+
import urllib.request
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from moonlighter.core.config import browser_executable
|
|
9
|
+
from moonlighter.core.log import get_logger
|
|
10
|
+
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
_playwright: Any = None
|
|
15
|
+
_browser: Browser | None = None
|
|
16
|
+
_browser_process: subprocess.Popen[bytes] | None = None
|
|
17
|
+
|
|
18
|
+
_DEVTOOLS_PORT_FILE = "DevToolsActivePort"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _read_devtools_port(session_dir: Path) -> int | None:
|
|
22
|
+
"""Read the port Chromium chose for --remote-debugging-port=0 from the
|
|
23
|
+
DevToolsActivePort file it writes inside OUR OWN user-data-dir (S-03).
|
|
24
|
+
None if the file doesn't exist yet or is malformed (browser still starting
|
|
25
|
+
up) — we never trust a fixed port nor "whatever answers" on a known
|
|
26
|
+
port."""
|
|
27
|
+
port_file = session_dir / _DEVTOOLS_PORT_FILE
|
|
28
|
+
if not port_file.exists():
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
first_line = port_file.read_text().splitlines()[0]
|
|
32
|
+
return int(first_line)
|
|
33
|
+
except IndexError, ValueError:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _devtools_ready(port: int) -> bool:
|
|
38
|
+
try:
|
|
39
|
+
urllib.request.urlopen(f"http://localhost:{port}/json/version", timeout=1)
|
|
40
|
+
return True
|
|
41
|
+
except Exception:
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def _first_or_new_context(browser: Browser) -> BrowserContext:
|
|
46
|
+
return browser.contexts[0] if browser.contexts else await browser.new_context()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _launch_browser(config: dict[str, Any], session_dir: Path) -> int:
|
|
50
|
+
"""Launch the browser (Chrome/Chromium/Brave) on a RANDOM debug port
|
|
51
|
+
chosen by the OS itself (--remote-debugging-port=0), and return the real
|
|
52
|
+
port, read from DevToolsActivePort inside OUR OWN user-data-dir (S-03) —
|
|
53
|
+
never a fixed port, and never "whatever answers": the port comes from a
|
|
54
|
+
file that only the process we just launched writes."""
|
|
55
|
+
global _browser_process
|
|
56
|
+
port_file = session_dir / _DEVTOOLS_PORT_FILE
|
|
57
|
+
port_file.unlink(missing_ok=True) # discard the port from a previous dead session
|
|
58
|
+
|
|
59
|
+
logger.info("Launching browser (random debug port)")
|
|
60
|
+
# S603: browser_executable(config) is the operator's local YAML config, which the
|
|
61
|
+
# trust model treats as trusted input -- never reachable from scraped job text, ATS
|
|
62
|
+
# DOM, or email bodies. See specs/2026-07-09-security-audit-findings.md ("Trust model").
|
|
63
|
+
_browser_process = subprocess.Popen( # noqa: S603
|
|
64
|
+
[
|
|
65
|
+
browser_executable(config),
|
|
66
|
+
"--remote-debugging-port=0",
|
|
67
|
+
f"--user-data-dir={session_dir}",
|
|
68
|
+
"--no-first-run",
|
|
69
|
+
],
|
|
70
|
+
stdout=subprocess.DEVNULL,
|
|
71
|
+
stderr=subprocess.DEVNULL,
|
|
72
|
+
)
|
|
73
|
+
for _ in range(60):
|
|
74
|
+
port = _read_devtools_port(session_dir)
|
|
75
|
+
if port is not None and _devtools_ready(port):
|
|
76
|
+
return port
|
|
77
|
+
await asyncio.sleep(0.5)
|
|
78
|
+
_browser_process.kill()
|
|
79
|
+
_browser_process = None
|
|
80
|
+
raise RuntimeError("Browser did not become available (DevToolsActivePort) within 30s")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
async def get_context(config: dict[str, Any]) -> BrowserContext:
|
|
84
|
+
"""Return a browser context via CDP. Launches the browser if not running."""
|
|
85
|
+
global _playwright, _browser
|
|
86
|
+
|
|
87
|
+
if _browser is not None and _browser.is_connected():
|
|
88
|
+
return await _first_or_new_context(_browser)
|
|
89
|
+
|
|
90
|
+
session_dir = Path(config["browser_session_dir"]).expanduser()
|
|
91
|
+
session_dir.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
port = _read_devtools_port(session_dir)
|
|
93
|
+
if port is None or not _devtools_ready(port):
|
|
94
|
+
port = await _launch_browser(config, session_dir)
|
|
95
|
+
|
|
96
|
+
_playwright = await async_playwright().start()
|
|
97
|
+
_browser = await _playwright.chromium.connect_over_cdp(
|
|
98
|
+
f"http://localhost:{port}",
|
|
99
|
+
slow_mo=config.get("slow_mo_ms", 300),
|
|
100
|
+
)
|
|
101
|
+
logger.info("CDP connected")
|
|
102
|
+
return await _first_or_new_context(_browser)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def new_page(config: dict[str, Any]) -> Page:
|
|
106
|
+
context = await get_context(config)
|
|
107
|
+
page = await context.new_page()
|
|
108
|
+
logger.debug("new_page created")
|
|
109
|
+
return page
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def save_screenshot(page: Page, job_id: int, step: str, config: dict[str, Any]) -> str:
|
|
113
|
+
"""Capture the page, restoring a minimized window for the duration.
|
|
114
|
+
|
|
115
|
+
`page.screenshot()` captures from the compositor surface, and a minimized
|
|
116
|
+
window produces no new frames — the call then blocks until it times out.
|
|
117
|
+
The browser-driven filler this predates minimized the window before
|
|
118
|
+
filling, which made the 03-filled review artifact impossible to produce,
|
|
119
|
+
on every ATS. Reproduced on both Greenhouse and Recruitee; `fromSurface:
|
|
120
|
+
False` was measured as an alternative and took 175s, so restoring around
|
|
121
|
+
the capture it is. Kept for browser-based extensions (e.g. LinkedIn
|
|
122
|
+
scanning) that still drive a page — the assisted flow that replaced the
|
|
123
|
+
in-repo filler never opens a browser.
|
|
124
|
+
|
|
125
|
+
The window is put back exactly as it was, so the two screenshots taken with
|
|
126
|
+
the window deliberately visible are not minimized as a side effect. Every
|
|
127
|
+
window-state call is best-effort (as elsewhere in this module): losing the
|
|
128
|
+
minimized posture must never cost us the screenshot.
|
|
129
|
+
"""
|
|
130
|
+
screenshots_dir = Path(config["screenshots_dir"]) / str(job_id)
|
|
131
|
+
screenshots_dir.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
path = str(screenshots_dir / f"{step}.png")
|
|
133
|
+
|
|
134
|
+
cdp = None
|
|
135
|
+
window_id = None
|
|
136
|
+
with contextlib.suppress(Exception):
|
|
137
|
+
cdp = await page.context.new_cdp_session(page)
|
|
138
|
+
info = await cdp.send("Browser.getWindowForTarget")
|
|
139
|
+
if info.get("bounds", {}).get("windowState") == "minimized":
|
|
140
|
+
window_id = info["windowId"]
|
|
141
|
+
await cdp.send(
|
|
142
|
+
"Browser.setWindowBounds",
|
|
143
|
+
{"windowId": window_id, "bounds": {"windowState": "normal"}},
|
|
144
|
+
)
|
|
145
|
+
try:
|
|
146
|
+
# full_page, because this is a review artifact: the viewport is ~750 CSS
|
|
147
|
+
# px and a real application form runs to ~4500, so a viewport capture
|
|
148
|
+
# showed 17% of it — asking a human to approve what they cannot see.
|
|
149
|
+
await page.screenshot(path=path, full_page=True)
|
|
150
|
+
finally:
|
|
151
|
+
if cdp is not None and window_id is not None:
|
|
152
|
+
with contextlib.suppress(Exception):
|
|
153
|
+
await cdp.send(
|
|
154
|
+
"Browser.setWindowBounds",
|
|
155
|
+
{"windowId": window_id, "bounds": {"windowState": "minimized"}},
|
|
156
|
+
)
|
|
157
|
+
return path
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def _set_window_state(page: Page, window_state: str) -> None:
|
|
161
|
+
cdp = await page.context.new_cdp_session(page)
|
|
162
|
+
target_info = await cdp.send("Browser.getWindowForTarget")
|
|
163
|
+
await cdp.send(
|
|
164
|
+
"Browser.setWindowBounds",
|
|
165
|
+
{"windowId": target_info["windowId"], "bounds": {"windowState": window_state}},
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def hide_window(page: Page) -> None:
|
|
170
|
+
"""Minimize the browser window via CDP. Idempotent."""
|
|
171
|
+
await _set_window_state(page, "minimized")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def show_window(page: Page) -> None:
|
|
175
|
+
"""Restore and focus the browser window via CDP. Idempotent."""
|
|
176
|
+
await _set_window_state(page, "normal")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def detach() -> None:
|
|
180
|
+
"""Let go of the browser completely, leaving it running for the human.
|
|
181
|
+
|
|
182
|
+
Unlike close(), the browser PROCESS survives — the window, its tabs and a
|
|
183
|
+
filled-in form stay exactly as they are. Only the Playwright driver goes
|
|
184
|
+
away, so the page stops being automation-controlled.
|
|
185
|
+
|
|
186
|
+
This exists for captcha: a token minted inside a CDP-controlled tab does not
|
|
187
|
+
validate server-side (Recruitee answers HTTP 422 on captchaToken), so the
|
|
188
|
+
only honest handover is to actually stop driving. Errors on the way out are
|
|
189
|
+
swallowed, but the handles are cleared regardless — a stale connection would
|
|
190
|
+
be reused by the next call.
|
|
191
|
+
"""
|
|
192
|
+
global _playwright, _browser
|
|
193
|
+
if _browser:
|
|
194
|
+
with contextlib.suppress(Exception):
|
|
195
|
+
await _browser.close()
|
|
196
|
+
_browser = None
|
|
197
|
+
if _playwright:
|
|
198
|
+
with contextlib.suppress(Exception):
|
|
199
|
+
await _playwright.stop()
|
|
200
|
+
_playwright = None
|
|
201
|
+
logger.info("browser: detached — no longer automation-controlled")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def close() -> None:
|
|
205
|
+
global _playwright, _browser, _browser_process
|
|
206
|
+
if _browser:
|
|
207
|
+
await _browser.close()
|
|
208
|
+
_browser = None
|
|
209
|
+
if _playwright:
|
|
210
|
+
await _playwright.stop()
|
|
211
|
+
_playwright = None
|
|
212
|
+
if _browser_process:
|
|
213
|
+
_browser_process.terminate()
|
|
214
|
+
_browser_process = None
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def moonlighter_home() -> Path:
|
|
9
|
+
return Path(os.environ.get("MOONLIGHTER_HOME", "~/.moonlighter")).expanduser()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def resolve_under_home(value: str) -> Path:
|
|
13
|
+
"""A config-supplied path, resolved under MOONLIGHTER_HOME when it is relative.
|
|
14
|
+
|
|
15
|
+
Same convention as cv.default (see application/answers/cv.py's
|
|
16
|
+
configured_cv_path): an absolute path, or a '~'-prefixed one, is honored
|
|
17
|
+
exactly as given; anything else is joined onto moonlighter_home(). Callers
|
|
18
|
+
are expected to reject an empty string themselves with a message naming the
|
|
19
|
+
config key -- Path("").expanduser() is ".", a directory that always exists,
|
|
20
|
+
so resolving it silently here would trade a clear "not configured" error for
|
|
21
|
+
a confusing "Is a directory" failure downstream.
|
|
22
|
+
"""
|
|
23
|
+
path = Path(value).expanduser()
|
|
24
|
+
if not path.is_absolute():
|
|
25
|
+
path = moonlighter_home() / path
|
|
26
|
+
return path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _learned_blocklist_path() -> Path:
|
|
30
|
+
return moonlighter_home() / "blocklist_learned.yaml"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def browser_executable(config: dict[str, Any]) -> str:
|
|
34
|
+
"""Browser executable path. Reads 'browser_path'; falls back to the legacy
|
|
35
|
+
'brave_path' key when browser_path is empty."""
|
|
36
|
+
path: str = config.get("browser_path") or config.get("brave_path", "")
|
|
37
|
+
return path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Sentinel for a form field the LLM did not (or should not) answer, stopping in front
|
|
41
|
+
# of the operator instead of guessing. This is a constant, not a config key: every
|
|
42
|
+
# producer (base.py, work_auth.py) and every consumer (service.py's submission gate,
|
|
43
|
+
# greenhouse.py's skip list) must agree on the exact same string, or an unanswered
|
|
44
|
+
# field silently degrades into whatever literal text was configured — typed into a
|
|
45
|
+
# real form field and submitted, with no operator stop. There is no way to make that
|
|
46
|
+
# divergence safe by configuration; the fix is for the string to have exactly one
|
|
47
|
+
# source of truth.
|
|
48
|
+
NEEDS_REVIEW_SENTINEL = "__NEEDS_REVIEW__"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
DEFAULTS: dict[str, Any] = {
|
|
52
|
+
# Browser executable path (Chrome/Chromium/Brave). Empty by default:
|
|
53
|
+
# set browser_path in config.yaml. Accepts brave_path (legacy) as a fallback.
|
|
54
|
+
"browser_path": "",
|
|
55
|
+
# Which LLM backend runs evaluations and answer generation.
|
|
56
|
+
# "cli" -> the `claude` CLI, using the claude.ai subscription. No API key.
|
|
57
|
+
# "api" -> the Anthropic SDK. Requires ANTHROPIC_API_KEY.
|
|
58
|
+
# Default is "cli" because that is what `moonlighter init`, the README, and
|
|
59
|
+
# config.example.yaml all lead with -- an installer coming through
|
|
60
|
+
# `uvx moonlighter` has Claude Code far more often than an API key.
|
|
61
|
+
"llm_backend": "cli",
|
|
62
|
+
"score_threshold": 6.5,
|
|
63
|
+
"llm_model": "claude-sonnet-4-6",
|
|
64
|
+
"eval_model": "claude-haiku-4-5-20251001",
|
|
65
|
+
"slow_mo_ms": 300,
|
|
66
|
+
"title_blocklist": [],
|
|
67
|
+
# Max parallel LLM evaluations in the scan. Bounds the token burst and the
|
|
68
|
+
# waste after the spend-limit (in-flight siblings). With batching (scan_batch_size),
|
|
69
|
+
# this is the number of BATCHES in parallel. Effective concurrency = scan_concurrency × scan_batch_size.
|
|
70
|
+
"scan_concurrency": 5,
|
|
71
|
+
# Jobs evaluated per LLM call. The profile is sent once per batch, cutting
|
|
72
|
+
# re-transmission by a factor of K. 1 disables batching (1 job per call).
|
|
73
|
+
"scan_batch_size": 5,
|
|
74
|
+
# CV per company. Paths relative to MOONLIGHTER_HOME; case-insensitive match.
|
|
75
|
+
# 'default' used when the company has no entry, and defaults to 'cv.pdf'
|
|
76
|
+
# (i.e. MOONLIGHTER_HOME/cv.pdf) — the same file the startup warning names.
|
|
77
|
+
# Can be overridden in the local config.yaml. If the chosen file doesn't
|
|
78
|
+
# exist, the composer emits a gap on the file-upload question instead of
|
|
79
|
+
# naming a file to attach.
|
|
80
|
+
"cv": {
|
|
81
|
+
"default": "cv.pdf",
|
|
82
|
+
"by_company": {},
|
|
83
|
+
},
|
|
84
|
+
# Country-dependent work authorization. The candidate is authorized to work
|
|
85
|
+
# only in their citizenship country. When the job's country cannot be
|
|
86
|
+
# confidently inferred, the field becomes __NEEDS_REVIEW__ (manual decision — never a guess).
|
|
87
|
+
"work_authorization": {
|
|
88
|
+
"citizenship_country": "",
|
|
89
|
+
"authorized_answer": "Yes",
|
|
90
|
+
"not_authorized_answer": "No",
|
|
91
|
+
},
|
|
92
|
+
# Gmail response tracking. Relative filenames, resolved under MOONLIGHTER_HOME
|
|
93
|
+
# by resolve_under_home() at the point of use (same convention as cv.default) —
|
|
94
|
+
# NOT hardcoded to ~/.moonlighter, which ignores a MOONLIGHTER_HOME override.
|
|
95
|
+
# setup_email() creates the token after the OAuth consent. NOTE: setup_email()
|
|
96
|
+
# writes (and overwrites) whatever file token_path names — point it at an
|
|
97
|
+
# absolute path elsewhere only if you own that file.
|
|
98
|
+
"email": {
|
|
99
|
+
"credentials_path": "gmail-client.json",
|
|
100
|
+
"token_path": "gmail-token.json",
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_PATH_KEYS = ("browser_session_dir", "screenshots_dir")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class ConfigError(Exception):
|
|
108
|
+
"""Raised when config.yaml has an unknown key or a value of the wrong type."""
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# (type, ...) — a tuple of acceptable types; bool is excluded from int keys explicitly.
|
|
112
|
+
_INT = (int,)
|
|
113
|
+
_NUM = (int, float)
|
|
114
|
+
|
|
115
|
+
# Top-level key -> acceptable types. Nested dict blocks use a sub-schema below.
|
|
116
|
+
_CONFIG_SCHEMA: dict[str, tuple[type, ...]] = {
|
|
117
|
+
"browser_path": (str,),
|
|
118
|
+
"brave_path": (str,),
|
|
119
|
+
"browser_session_dir": (str,),
|
|
120
|
+
"screenshots_dir": (str,),
|
|
121
|
+
"score_threshold": _NUM,
|
|
122
|
+
"slow_mo_ms": _INT,
|
|
123
|
+
"scan_concurrency": _INT,
|
|
124
|
+
"scan_batch_size": _INT,
|
|
125
|
+
"llm_model": (str,),
|
|
126
|
+
"eval_model": (str,),
|
|
127
|
+
"llm_backend": (str,),
|
|
128
|
+
"title_blocklist": (list,),
|
|
129
|
+
"cv": (dict,),
|
|
130
|
+
"work_authorization": (dict,),
|
|
131
|
+
"email": (dict,),
|
|
132
|
+
"scan_gupy": (bool,),
|
|
133
|
+
"scan_remoteok": (bool,),
|
|
134
|
+
"scan_remotive": (bool,),
|
|
135
|
+
"scan_wwr": (bool,),
|
|
136
|
+
"scan_hn_whoishiring": (bool,),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_CV_SCHEMA: dict[str, tuple[type, ...]] = {"default": (str,), "by_company": (dict,)}
|
|
140
|
+
_WORK_AUTH_SCHEMA: dict[str, tuple[type, ...]] = {
|
|
141
|
+
"citizenship_country": (str,),
|
|
142
|
+
"authorized_answer": (str,),
|
|
143
|
+
"not_authorized_answer": (str,),
|
|
144
|
+
}
|
|
145
|
+
_EMAIL_SCHEMA: dict[str, tuple[type, ...]] = {
|
|
146
|
+
"address": (str,),
|
|
147
|
+
"credentials_path": (str,),
|
|
148
|
+
"token_path": (str,),
|
|
149
|
+
"processed_label": (str,),
|
|
150
|
+
"mark_processed": (bool,),
|
|
151
|
+
"lookback_days": (int,),
|
|
152
|
+
"interview_stages": (list,),
|
|
153
|
+
}
|
|
154
|
+
_NESTED_SCHEMAS = {
|
|
155
|
+
"cv": _CV_SCHEMA,
|
|
156
|
+
"work_authorization": _WORK_AUTH_SCHEMA,
|
|
157
|
+
"email": _EMAIL_SCHEMA,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _check_type(key: str, value: Any, types: tuple[type, ...]) -> None:
|
|
162
|
+
# bool is a subclass of int; reject it where int is required (and bool is not listed).
|
|
163
|
+
if isinstance(value, bool) and bool not in types:
|
|
164
|
+
raise ConfigError(
|
|
165
|
+
f"config key '{key}' must be {', '.join(t.__name__ for t in types)}, got bool"
|
|
166
|
+
)
|
|
167
|
+
if not isinstance(value, types):
|
|
168
|
+
raise ConfigError(
|
|
169
|
+
f"config key '{key}' must be {', '.join(t.__name__ for t in types)}, "
|
|
170
|
+
f"got {type(value).__name__}"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
LLM_BACKENDS = ("cli", "api")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def llm_backend(config: dict[str, Any]) -> str:
|
|
178
|
+
"""The configured LLM backend, validated.
|
|
179
|
+
|
|
180
|
+
Single source of truth for every site that branches on the backend -- the
|
|
181
|
+
caller factory and the startup checks -- so a warning can never describe a
|
|
182
|
+
different backend from the one that will actually run. Raises ConfigError
|
|
183
|
+
on anything outside LLM_BACKENDS: an unrecognized value used to fall
|
|
184
|
+
through to 'api' in silence, which turned the typo 'CLI' into a demand for
|
|
185
|
+
an API key the user had no reason to own.
|
|
186
|
+
"""
|
|
187
|
+
backend: str = config.get("llm_backend", DEFAULTS["llm_backend"])
|
|
188
|
+
if backend not in LLM_BACKENDS:
|
|
189
|
+
raise ConfigError(
|
|
190
|
+
f"config key 'llm_backend' must be one of {', '.join(LLM_BACKENDS)}, got {backend!r}"
|
|
191
|
+
)
|
|
192
|
+
return backend
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def validate_config(config: dict[str, Any]) -> None:
|
|
196
|
+
"""Strict, closed-schema validation. Raises ConfigError on the first unknown key or
|
|
197
|
+
wrong-typed value (naming the key). Runs after the DEFAULTS merge, so an omitted key is
|
|
198
|
+
filled by defaults and never fails here — only wrong types and unknown extras fail."""
|
|
199
|
+
for key, value in config.items():
|
|
200
|
+
if key not in _CONFIG_SCHEMA:
|
|
201
|
+
raise ConfigError(f"unknown config key '{key}'")
|
|
202
|
+
_check_type(key, value, _CONFIG_SCHEMA[key])
|
|
203
|
+
if key == "llm_backend":
|
|
204
|
+
llm_backend(config)
|
|
205
|
+
sub_schema = _NESTED_SCHEMAS.get(key)
|
|
206
|
+
if sub_schema is not None:
|
|
207
|
+
for sub_key, sub_value in value.items():
|
|
208
|
+
if sub_key not in sub_schema:
|
|
209
|
+
raise ConfigError(f"unknown config key '{key}.{sub_key}'")
|
|
210
|
+
_check_type(f"{key}.{sub_key}", sub_value, sub_schema[sub_key])
|
|
211
|
+
if key == "email" and sub_key == "lookback_days" and sub_value <= 0:
|
|
212
|
+
# Gmail's newer_than:0d matches zero messages (verified live) —
|
|
213
|
+
# a non-positive value silently disables the sync forever, reading
|
|
214
|
+
# exactly like an empty mailbox instead of a config mistake.
|
|
215
|
+
raise ConfigError(
|
|
216
|
+
f"config key 'email.lookback_days' must be positive, got {sub_value}"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def load_config(config_path: str | Path | None = None) -> dict[str, Any]:
|
|
221
|
+
"""
|
|
222
|
+
Load configuration from YAML file, merging with defaults.
|
|
223
|
+
|
|
224
|
+
Expands home directory (~) paths for designated path keys.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
config_path: Path to config.yaml file
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
dict with merged config (defaults + overrides)
|
|
231
|
+
"""
|
|
232
|
+
config_path = (
|
|
233
|
+
Path(config_path) if config_path is not None else moonlighter_home() / "config.yaml"
|
|
234
|
+
)
|
|
235
|
+
home = moonlighter_home()
|
|
236
|
+
config: dict[str, Any] = {
|
|
237
|
+
**DEFAULTS,
|
|
238
|
+
"browser_session_dir": str(home / "browser-session"),
|
|
239
|
+
"screenshots_dir": str(home / "screenshots"),
|
|
240
|
+
}
|
|
241
|
+
if config_path.exists():
|
|
242
|
+
user = yaml.safe_load(config_path.read_text()) or {}
|
|
243
|
+
config.update(user)
|
|
244
|
+
for key in _PATH_KEYS:
|
|
245
|
+
config[key] = str(Path(config[key]).expanduser())
|
|
246
|
+
|
|
247
|
+
# Merge learned blocklist (blocklist_learned.yaml) into title_blocklist
|
|
248
|
+
learned = _learned_blocklist_path()
|
|
249
|
+
if learned.exists():
|
|
250
|
+
data = yaml.safe_load(learned.read_text()) or {}
|
|
251
|
+
learned_patterns = data.get("title_blocklist", [])
|
|
252
|
+
if learned_patterns:
|
|
253
|
+
manual = config.get("title_blocklist", [])
|
|
254
|
+
merged = list(dict.fromkeys(manual + learned_patterns)) # dedup, manual first
|
|
255
|
+
config["title_blocklist"] = merged
|
|
256
|
+
|
|
257
|
+
return config
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def load_profile(profile_path: str | Path | None = None) -> dict[str, Any]:
|
|
261
|
+
"""
|
|
262
|
+
Load profile from YAML file.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
profile_path: Path to profile.yaml file
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
dict with profile data (skills, experience, preferences, criteria, etc.)
|
|
269
|
+
"""
|
|
270
|
+
profile_path = (
|
|
271
|
+
Path(profile_path) if profile_path is not None else moonlighter_home() / "profile.yaml"
|
|
272
|
+
)
|
|
273
|
+
return yaml.safe_load(profile_path.read_text()) or {}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def load_company_list(path: str | Path | None = None, phase: str | None = None) -> dict[str, Any]:
|
|
277
|
+
"""Load the company list from YAML, optionally filtered by phase.
|
|
278
|
+
|
|
279
|
+
company_list.yaml groups entries by ATS and phase:
|
|
280
|
+
greenhouse:
|
|
281
|
+
phase1: [slug, ...]
|
|
282
|
+
phase2: [slug, ...]
|
|
283
|
+
|
|
284
|
+
An entry is an ATS slug ("nubank") or, for Recruitee, optionally a full
|
|
285
|
+
custom career domain ("jobs.channable.com").
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
path: path to company_list.yaml.
|
|
289
|
+
phase: "phase1", "phase2", "phase3", or None for all phases combined.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
dict: {source: [entry, ...]}
|
|
293
|
+
"""
|
|
294
|
+
path = Path(path) if path is not None else moonlighter_home() / "company_list.yaml"
|
|
295
|
+
if not path.exists():
|
|
296
|
+
return {}
|
|
297
|
+
raw = yaml.safe_load(path.read_text()) or {}
|
|
298
|
+
|
|
299
|
+
result = {}
|
|
300
|
+
for source, value in raw.items():
|
|
301
|
+
if isinstance(value, list):
|
|
302
|
+
# Legacy format: flat list without phases
|
|
303
|
+
result[source] = value
|
|
304
|
+
elif isinstance(value, dict):
|
|
305
|
+
if phase:
|
|
306
|
+
result[source] = value.get(phase, [])
|
|
307
|
+
else:
|
|
308
|
+
# All phases concatenated
|
|
309
|
+
slugs = []
|
|
310
|
+
for slugs_in_phase in value.values():
|
|
311
|
+
if isinstance(slugs_in_phase, list):
|
|
312
|
+
slugs.extend(slugs_in_phase)
|
|
313
|
+
result[source] = slugs
|
|
314
|
+
else:
|
|
315
|
+
result[source] = []
|
|
316
|
+
|
|
317
|
+
for source, entries in result.items():
|
|
318
|
+
if not isinstance(entries, list):
|
|
319
|
+
# A phase filter selecting a non-list value (e.g. a scalar phase
|
|
320
|
+
# entry) leaves `entries` as that raw value -- a string iterates
|
|
321
|
+
# character-by-character, and every single-char "slug" is a str,
|
|
322
|
+
# so the entry-level check below would silently pass.
|
|
323
|
+
raise ConfigError(
|
|
324
|
+
f"company_list.yaml: source {source!r} did not resolve to a list "
|
|
325
|
+
f"(got {type(entries).__name__}: {entries!r})"
|
|
326
|
+
)
|
|
327
|
+
for entry in entries:
|
|
328
|
+
if not isinstance(entry, str):
|
|
329
|
+
raise ConfigError(
|
|
330
|
+
f"company_list.yaml: source '{source}' has a non-string entry: {entry!r}"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
return result
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
_HARDEN_FILES = (
|
|
337
|
+
"moonlighter.db",
|
|
338
|
+
"profile.yaml",
|
|
339
|
+
"config.yaml",
|
|
340
|
+
"app.log",
|
|
341
|
+
"blocklist_learned.yaml",
|
|
342
|
+
)
|
|
343
|
+
_HARDEN_DIRS = ("browser-session", "screenshots")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def harden_permissions() -> list[str]:
|
|
347
|
+
"""Set 0600/0700 on the sensitive files/subdirectories under ~/.moonlighter
|
|
348
|
+
(S-07): moonlighter.db, profile.yaml and config.yaml carry full PII;
|
|
349
|
+
browser-session/ holds cookies equivalent to LinkedIn credentials.
|
|
350
|
+
Best-effort and never raises — a permission error becomes a warning,
|
|
351
|
+
since the server must stay up even on an unusual filesystem/ACL setup."""
|
|
352
|
+
home = moonlighter_home()
|
|
353
|
+
warnings: list[str] = []
|
|
354
|
+
for name in _HARDEN_FILES:
|
|
355
|
+
path = home / name
|
|
356
|
+
if path.exists():
|
|
357
|
+
try:
|
|
358
|
+
path.chmod(0o600)
|
|
359
|
+
except OSError as e:
|
|
360
|
+
warnings.append(f"could not restrict permissions on {path}: {e}")
|
|
361
|
+
for name in _HARDEN_DIRS:
|
|
362
|
+
path = home / name
|
|
363
|
+
if path.exists():
|
|
364
|
+
try:
|
|
365
|
+
path.chmod(0o700)
|
|
366
|
+
except OSError as e:
|
|
367
|
+
warnings.append(f"could not restrict permissions on {path}: {e}")
|
|
368
|
+
return warnings
|