@alfe.ai/openclaw-linkedin 0.1.0

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.
@@ -0,0 +1,149 @@
1
+ """Own the worker's unreaped PID until its private process group is quiescent.
2
+
3
+ The supervisor is outside that group. Only it may signal the group, and it
4
+ never reaps its direct child until the last signal and liveness check finish.
5
+ Node sends cancellation and receives the cleanup acknowledgement over fd 3;
6
+ the worker never inherits that channel. No provider data crosses this channel.
7
+ """
8
+
9
+ import os
10
+ import re
11
+ import select
12
+ import signal
13
+ import subprocess
14
+ import sys
15
+ import time
16
+
17
+ CONTROL_FD = 3
18
+
19
+
20
+ def observe_child(pid):
21
+ # WNOWAIT preserves the PID reservation, including after the leader exits.
22
+ # Never use Popen.poll()/wait() or a reaping SIGCHLD handler for this child.
23
+ return os.waitid(os.P_PID, pid, os.WEXITED | os.WNOHANG | os.WNOWAIT)
24
+
25
+
26
+ def signal_owned_group(pid, value):
27
+ # Losing wait ownership must fail closed, not signal a possibly reused ID.
28
+ observe_child(pid)
29
+ try:
30
+ os.killpg(pid, value)
31
+ except (ProcessLookupError, PermissionError):
32
+ # Darwin reports EPERM for an all-zombie group. The independent state
33
+ # check still must prove quiescence; a live inaccessible group parks.
34
+ pass
35
+
36
+
37
+ def process_snapshot():
38
+ """Use the exact same bounded process inspection for readiness and cleanup."""
39
+ result = subprocess.run(
40
+ ["/bin/ps", "-axo", "pgid=,stat="],
41
+ stdout=subprocess.PIPE,
42
+ stderr=subprocess.DEVNULL,
43
+ check=True,
44
+ timeout=5,
45
+ text=True,
46
+ )
47
+ rows = [line.split() for line in result.stdout.splitlines() if line.strip()]
48
+ if not rows or any(
49
+ len(row) != 2
50
+ or not re.fullmatch(r"[0-9]+", row[0])
51
+ or not re.fullmatch(r"[RSDTtZXIWUPEN][A-Za-z<>+]*", row[1])
52
+ for row in rows
53
+ ):
54
+ raise ValueError("Process inspection output is incompatible")
55
+ # Empty/truncated output must not masquerade as an absent worker group.
56
+ # This inspector is running and must be visible in its own process table.
57
+ if not any(int(row[0]) == os.getpgrp() for row in rows):
58
+ raise ValueError("Process inspection omitted the inspector")
59
+ return rows
60
+
61
+
62
+ def group_is_quiescent(pid):
63
+ # The leader stays waitable while ps runs, so this PGID cannot be recycled.
64
+ # Already-dead orphan zombies cannot retain browser sockets or fork again.
65
+ try:
66
+ rows = process_snapshot()
67
+ except (OSError, ValueError, subprocess.SubprocessError):
68
+ return False
69
+ group = [row for row in rows if int(row[0]) == pid]
70
+ # The unreaped leader must still appear, even as a zombie. An absent group
71
+ # is incomplete inspection, not proof that our reserved identity vanished.
72
+ return bool(group) and all(row[1].startswith(("Z", "X")) for row in group)
73
+
74
+
75
+ def supervise(pid, control_fd, grace_seconds):
76
+ stopping_at = None
77
+ control_open = True
78
+ while True:
79
+ status = observe_child(pid)
80
+ now = time.monotonic()
81
+ if status is not None or (stopping_at is not None and now >= stopping_at + grace_seconds):
82
+ signal_owned_group(pid, signal.SIGKILL)
83
+ if observe_child(pid) is not None and group_is_quiescent(pid):
84
+ # This is the ONLY reaping call, and no signaling follows it.
85
+ _, wait_status = os.waitpid(pid, 0)
86
+ return os.waitstatus_to_exitcode(wait_status)
87
+
88
+ if control_open:
89
+ readable, _, _ = select.select([control_fd], [], [], 0.025)
90
+ if readable:
91
+ command = os.read(control_fd, 64)
92
+ if not command:
93
+ control_open = False
94
+ # Any control input (or parent EOF) cancels the exact operation.
95
+ if stopping_at is None:
96
+ stopping_at = time.monotonic()
97
+ signal_owned_group(pid, signal.SIGTERM)
98
+ else:
99
+ time.sleep(0.025)
100
+
101
+
102
+ def main():
103
+ if sys.argv[1:] == ["--check"]:
104
+ # No worker/browser capability exists during this preflight. Do not
105
+ # reuse the unavailable/quiescent control acknowledgements here.
106
+ try:
107
+ process_snapshot()
108
+ except (OSError, ValueError, subprocess.SubprocessError):
109
+ return 78
110
+ print("ready")
111
+ return 0
112
+
113
+ required = ("fork", "setsid", "waitid", "WNOWAIT", "WEXITED", "WNOHANG", "P_PID")
114
+ if not all(hasattr(os, name) for name in required):
115
+ # No worker exists, so Node can safely reject this unsupported runtime.
116
+ os.write(CONTROL_FD, b"unavailable\n")
117
+ return 78
118
+
119
+ signal.signal(signal.SIGCHLD, signal.SIG_DFL)
120
+ ready_read, ready_write = os.pipe()
121
+ pid = os.fork()
122
+ if pid == 0:
123
+ try:
124
+ os.close(CONTROL_FD)
125
+ os.close(ready_read)
126
+ os.setsid()
127
+ os.write(ready_write, b"ready")
128
+ os.close(ready_write)
129
+ os.execv(sys.executable, [sys.executable, "-I", sys.argv[1]])
130
+ finally:
131
+ os._exit(127)
132
+
133
+ os.close(ready_write)
134
+ ready = os.read(ready_read, 5)
135
+ os.close(ready_read)
136
+ if ready != b"ready":
137
+ # The child did not reach exec and therefore could not create a driver.
138
+ os.waitpid(pid, 0)
139
+ os.write(CONTROL_FD, b"quiescent\n")
140
+ return 127
141
+
142
+ exit_code = supervise(pid, CONTROL_FD, float(sys.argv[2]) / 1000)
143
+ # The callback must not settle on stdout alone, or on an unacknowledged exit.
144
+ os.write(CONTROL_FD, b"quiescent\n")
145
+ return exit_code if exit_code >= 0 else 128 - exit_code
146
+
147
+
148
+ if __name__ == "__main__":
149
+ sys.exit(main())
@@ -0,0 +1,235 @@
1
+ """One bounded extraction against an exact Alfe-owned browser target.
2
+
3
+ No MCP bootstrap, authentication manager, cookie import, browser launch or close.
4
+ The Node owner holds its shared operation gate until this process and driver exit.
5
+ """
6
+ import asyncio
7
+ import importlib.metadata
8
+ import json
9
+ import logging
10
+ import os
11
+ import re
12
+ import signal
13
+ import sys
14
+ from urllib.parse import urljoin, urlparse, urlunparse
15
+
16
+ # Set before importing upstream; explicit debug dirs override trace_mode upstream.
17
+ os.environ["LINKEDIN_TRACE_MODE"] = "off"
18
+ os.environ["LINKEDIN_DEBUG_TRACE_DIR"] = ""
19
+ os.environ["PYTHON_DOTENV_DISABLED"] = "1"
20
+ logging.disable(logging.CRITICAL)
21
+
22
+ EXTRACTOR_VERSION = "4.24.0"
23
+ PATCHRIGHT_VERSION = "1.61.2"
24
+ DOTENV_VERSION = "1.2.3"
25
+ MAX_INPUT = 24 * 1024
26
+ MAX_OUTPUT = 384 * 1024
27
+
28
+
29
+ class RequestError(Exception):
30
+ pass
31
+
32
+
33
+ def validate_request(value):
34
+ if not isinstance(value, dict):
35
+ raise RequestError()
36
+ endpoint = value.get("browserWSEndpoint")
37
+ target = value.get("targetId")
38
+ parsed = urlparse(endpoint) if isinstance(endpoint, str) else None
39
+ if (not parsed or parsed.scheme != "ws" or parsed.hostname not in ("127.0.0.1", "localhost", "::1")
40
+ or parsed.username or parsed.password or not parsed.path.startswith("/devtools/browser/")
41
+ or parsed.query or parsed.fragment or len(endpoint) > 2048):
42
+ raise RequestError()
43
+ if not isinstance(target, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", target):
44
+ raise RequestError()
45
+ if value.get("action") not in ("status", "login", "inbox", "conversation", "search", "prepare_message", "send_message"):
46
+ raise RequestError()
47
+ params = value.get("params", {})
48
+ if not isinstance(params, dict):
49
+ raise RequestError()
50
+ action = value["action"]
51
+ if action in ("inbox", "search"):
52
+ limit = params.get("limit", 20)
53
+ if type(limit) is not int or not 1 <= limit <= 50:
54
+ raise RequestError()
55
+ if action == "search":
56
+ bounded_string(params, "keywords", 200)
57
+ if action == "conversation":
58
+ from linkedin_mcp_server.scraping.identifiers import normalize_thread_id
59
+ params["threadId"] = normalize_reference(bounded_string(params, "threadId", 2048), normalize_thread_id)
60
+ if action in ("prepare_message", "send_message"):
61
+ from linkedin_mcp_server.scraping.identifiers import normalize_person_identifier
62
+ params["username"] = normalize_reference(bounded_string(params, "username", 2048), normalize_person_identifier)
63
+ if len(params["username"]) > 100:
64
+ raise RequestError()
65
+ bounded_string(params, "message", 3000)
66
+ if action == "send_message" and params.get("confirm") is not True:
67
+ raise RequestError()
68
+ return value
69
+
70
+
71
+ def normalize_reference(value, normalizer):
72
+ if "://" in value:
73
+ parsed = urlparse(value)
74
+ if parsed.scheme != "https" or parsed.hostname != "www.linkedin.com" or parsed.username or parsed.password or parsed.port:
75
+ raise RequestError()
76
+ try:
77
+ return normalizer(value)
78
+ except Exception:
79
+ raise RequestError() from None
80
+
81
+
82
+ def bounded_string(params, key, maximum):
83
+ value = params.get(key)
84
+ if not isinstance(value, str) or not value.strip() or len(value) > maximum or "\x00" in value:
85
+ raise RequestError()
86
+ return value
87
+
88
+
89
+ async def exact_page(browser, target_id):
90
+ """Enumerate all attached pages, then match Chrome target identity, never order."""
91
+ matches = []
92
+ for context in browser.contexts:
93
+ for page in context.pages:
94
+ if page.is_closed():
95
+ continue
96
+ session = await context.new_cdp_session(page)
97
+ try:
98
+ info = await session.send("Target.getTargetInfo")
99
+ if info.get("targetInfo", {}).get("targetId") == target_id:
100
+ matches.append(page)
101
+ finally:
102
+ await session.detach()
103
+ if len(matches) != 1 or matches[0].is_closed():
104
+ raise RequestError()
105
+ return matches[0]
106
+
107
+
108
+ def project_read(result):
109
+ sections = {}
110
+ budget = 120_000
111
+ for key, value in result.get("sections", {}).items():
112
+ if key in ("inbox", "conversation", "search_results") and isinstance(value, str):
113
+ sections[key] = value[:budget]
114
+ budget -= len(sections[key])
115
+ references = []
116
+ for items in result.get("references", {}).values():
117
+ if not isinstance(items, list):
118
+ continue
119
+ for item in items:
120
+ if not isinstance(item, dict) or not isinstance(item.get("url"), str):
121
+ continue
122
+ parsed = urlparse(urljoin("https://www.linkedin.com", item["url"]))
123
+ if parsed.scheme != "https" or parsed.hostname != "www.linkedin.com" or parsed.username or parsed.password or parsed.port:
124
+ continue
125
+ url = urlunparse(("https", "www.linkedin.com", parsed.path, "", "", ""))
126
+ if len(url) > 2048:
127
+ continue
128
+ label = item.get("label", item.get("text", "LinkedIn reference"))
129
+ references.append({"label": str(label)[:300], "url": url})
130
+ if len(references) >= 100:
131
+ break
132
+ if len(references) >= 100:
133
+ break
134
+ return {"status": "ok", "sections": sections, "references": references}
135
+
136
+
137
+ async def perform(page, request, extractor_class, detect_auth_barrier, is_logged_in):
138
+ action = request["action"]
139
+ params = request.get("params", {})
140
+ if action == "login":
141
+ await page.goto("https://www.linkedin.com/login", wait_until="domcontentloaded", timeout=30_000)
142
+ return {"status": "needs_login", "code": "needs_login"}
143
+ if action == "status":
144
+ await page.goto("https://www.linkedin.com/feed/", wait_until="domcontentloaded", timeout=30_000)
145
+ host = urlparse(page.url).hostname
146
+ if host != "www.linkedin.com" or await detect_auth_barrier(page) or not await is_logged_in(page):
147
+ return {"status": "needs_login", "code": "needs_login"}
148
+ return {"status": "ready"}
149
+
150
+ class SharedPageExtractor(extractor_class):
151
+ async def _goto_with_auth_checks(self, url, *, wait_until="domcontentloaded", allow_remember_me=False):
152
+ # Never click an account chooser or try to repair authentication.
153
+ # This narrow override is version-pinned and covered by a contract test.
154
+ return await super()._goto_with_auth_checks(url, wait_until=wait_until, allow_remember_me=False)
155
+
156
+ extractor = SharedPageExtractor(page)
157
+ if action == "inbox":
158
+ return project_read(await extractor.get_inbox(limit=params.get("limit", 20)))
159
+ if action == "conversation":
160
+ return project_read(await extractor.get_conversation(thread_id=params["threadId"]))
161
+ if action == "search":
162
+ return project_read(await extractor.search_conversations(params["keywords"], limit=params.get("limit", 20)))
163
+ if action in ("prepare_message", "send_message"):
164
+ sending = action == "send_message"
165
+ result = await extractor.send_message(params["username"], params["message"], confirm_send=sending)
166
+ if not sending:
167
+ if result.get("status") == "confirmation_required" and result.get("recipient_selected") is True:
168
+ return {"status": "prepared", "username": params["username"]}
169
+ return {"status": "error", "code": "recipient_unavailable"}
170
+ if result.get("status") == "sent" and result.get("sent") is True and result.get("recipient_selected") is True:
171
+ return {"status": "sent"}
172
+ return {"status": "error", "code": "send_unknown"}
173
+ raise RequestError()
174
+
175
+
176
+ async def run(request):
177
+ from patchright.async_api import async_playwright, TimeoutError as DriverTimeout
178
+ from linkedin_mcp_server.scraping.extractor import LinkedInExtractor
179
+ from linkedin_mcp_server.core.auth import detect_auth_barrier, is_logged_in
180
+ from linkedin_mcp_server.core.exceptions import AuthenticationError, RateLimitError, NetworkError, ProfileNotFoundError
181
+
182
+ current = asyncio.current_task()
183
+ loop = asyncio.get_running_loop()
184
+ if os.name != "nt":
185
+ loop.add_signal_handler(signal.SIGTERM, current.cancel)
186
+ driver = await async_playwright().start()
187
+ try:
188
+ browser = await driver.chromium.connect_over_cdp(request["browserWSEndpoint"], no_defaults=True, timeout=15_000)
189
+ page = await exact_page(browser, request["targetId"])
190
+ try:
191
+ return await perform(page, request, LinkedInExtractor, detect_auth_barrier, is_logged_in)
192
+ except AuthenticationError:
193
+ return {"status": "needs_login", "code": "needs_login"}
194
+ except RateLimitError:
195
+ return {"status": "error", "code": "rate_limited"}
196
+ except ProfileNotFoundError:
197
+ return {"status": "error", "code": "page_unavailable"}
198
+ except (NetworkError, DriverTimeout):
199
+ return {"status": "error", "code": "send_unknown" if request["action"] == "send_message" else "network_error"}
200
+ finally:
201
+ # Patchright.stop tears down this connection/driver, not Alfe's browser.
202
+ # Calling Browser.close, Context.close or Page.close here is forbidden.
203
+ await driver.stop()
204
+
205
+
206
+ def main():
207
+ try:
208
+ raw = sys.stdin.buffer.read(MAX_INPUT + 1)
209
+ if len(raw) > MAX_INPUT:
210
+ raise RequestError()
211
+ unvalidated = json.loads(raw)
212
+ if (importlib.metadata.version("mcp-server-linkedin") != EXTRACTOR_VERSION
213
+ or importlib.metadata.version("patchright") != PATCHRIGHT_VERSION
214
+ or importlib.metadata.version("python-dotenv") != DOTENV_VERSION):
215
+ result = {"status": "error", "code": "runtime_mismatch"}
216
+ else:
217
+ # Identifier normalization imports upstream too. Verify before ANY
218
+ # such import, including validation-only and mutation input paths.
219
+ request = validate_request(unvalidated)
220
+ result = asyncio.run(run(request))
221
+ except (RequestError, json.JSONDecodeError, UnicodeDecodeError):
222
+ result = {"status": "error", "code": "invalid_request"}
223
+ except importlib.metadata.PackageNotFoundError:
224
+ result = {"status": "error", "code": "runtime_mismatch"}
225
+ except Exception:
226
+ result = {"status": "error", "code": "extraction_failed"}
227
+ encoded = json.dumps(result, ensure_ascii=False).encode("utf-8")
228
+ if len(encoded) > MAX_OUTPUT:
229
+ encoded = b'{"status":"error","code":"extraction_failed"}'
230
+ sys.stdout.buffer.write(encoded)
231
+ sys.stdout.buffer.flush()
232
+
233
+
234
+ if __name__ == "__main__":
235
+ main()