azure-pim-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- azure_pim_cli/__init__.py +1 -0
- azure_pim_cli/__main__.py +4 -0
- azure_pim_cli/acrs_primer.py +189 -0
- azure_pim_cli/cache.py +67 -0
- azure_pim_cli/chrome_launcher.py +188 -0
- azure_pim_cli/cli.py +717 -0
- azure_pim_cli/graph_client.py +185 -0
- azure_pim_cli/token_grabber.py +488 -0
- azure_pim_cli-0.1.0.dist-info/METADATA +289 -0
- azure_pim_cli-0.1.0.dist-info/RECORD +13 -0
- azure_pim_cli-0.1.0.dist-info/WHEEL +4 -0
- azure_pim_cli-0.1.0.dist-info/entry_points.txt +2 -0
- azure_pim_cli-0.1.0.dist-info/licenses/LICENSE +181 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Prime an acrs=c1 (step-up MFA) Graph token via portal.
|
|
2
|
+
|
|
3
|
+
The Graph PIM activation POST refuses tokens lacking the `acrs=c1` claim
|
|
4
|
+
(`RoleAssignmentRequestAcrsValidationFailed`). Portal handles this by calling
|
|
5
|
+
MSAL with a claims challenge; if the user has a cached MFA session
|
|
6
|
+
(`kmsi`/`dvc_cmp`), portal upgrades silently and retries. We can't do that
|
|
7
|
+
step-up in raw Python (no refresh token), but we CAN drive portal to do it for
|
|
8
|
+
us via Playwright over CDP.
|
|
9
|
+
|
|
10
|
+
Flow:
|
|
11
|
+
1. Attach to authenticated Chrome (debug port 9222).
|
|
12
|
+
2. Navigate to PIM Activation blade.
|
|
13
|
+
3. Click Activate on the first eligible row.
|
|
14
|
+
4. Fill reason, submit.
|
|
15
|
+
5. Sniff the resulting Graph POST — its Authorization header contains a fresh
|
|
16
|
+
token with `acrs=c1`. Return that token.
|
|
17
|
+
|
|
18
|
+
If cached MFA has lapsed, portal shows an MFA modal in the visible browser
|
|
19
|
+
window — user completes it, script picks up the token afterwards. No signal
|
|
20
|
+
file / TEMP handshake needed.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import base64
|
|
26
|
+
import json
|
|
27
|
+
import sys
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
|
|
31
|
+
from playwright.sync_api import sync_playwright
|
|
32
|
+
|
|
33
|
+
ACTIVATION_URL = (
|
|
34
|
+
"https://portal.azure.com/#view/Microsoft_Azure_PIMCommon/ActivationMenuBlade/~/aadgroup"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _has_c1(token: str) -> bool:
|
|
39
|
+
try:
|
|
40
|
+
payload = token.split(".")[1]
|
|
41
|
+
payload += "=" * (-len(payload) % 4)
|
|
42
|
+
claims = json.loads(base64.urlsafe_b64decode(payload))
|
|
43
|
+
except Exception:
|
|
44
|
+
return False
|
|
45
|
+
acrs = claims.get("acrs") or []
|
|
46
|
+
if isinstance(acrs, str):
|
|
47
|
+
acrs = [acrs]
|
|
48
|
+
return "c1" in [str(a).lower() for a in acrs]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def prime_acrs(cdp_endpoint: str, justification: str = "acrs prime", timeout: int = 180) -> str:
|
|
52
|
+
"""Drive portal to fire a Graph POST bearing an acrs=c1 token; return that token.
|
|
53
|
+
|
|
54
|
+
Raises RuntimeError on timeout / capture failure.
|
|
55
|
+
"""
|
|
56
|
+
token_holder: dict[str, str] = {}
|
|
57
|
+
token_event = threading.Event()
|
|
58
|
+
|
|
59
|
+
def on_req(request):
|
|
60
|
+
if token_event.is_set():
|
|
61
|
+
return
|
|
62
|
+
try:
|
|
63
|
+
url = request.url
|
|
64
|
+
method = request.method
|
|
65
|
+
except Exception:
|
|
66
|
+
return
|
|
67
|
+
if "graph.microsoft.com" not in url:
|
|
68
|
+
return
|
|
69
|
+
if method != "POST":
|
|
70
|
+
return
|
|
71
|
+
if "assignmentScheduleRequests" not in url:
|
|
72
|
+
return
|
|
73
|
+
auth = request.headers.get("authorization", "")
|
|
74
|
+
if not auth.lower().startswith("bearer "):
|
|
75
|
+
return
|
|
76
|
+
candidate = auth.split(" ", 1)[1]
|
|
77
|
+
if not _has_c1(candidate):
|
|
78
|
+
return
|
|
79
|
+
token_holder["token"] = candidate
|
|
80
|
+
token_event.set()
|
|
81
|
+
|
|
82
|
+
with sync_playwright() as pw:
|
|
83
|
+
browser = pw.chromium.connect_over_cdp(cdp_endpoint)
|
|
84
|
+
ctx = browser.contexts[0]
|
|
85
|
+
|
|
86
|
+
# Register on ALL current + future pages (portal iframes fire XHRs)
|
|
87
|
+
for existing in ctx.pages:
|
|
88
|
+
existing.on("request", on_req)
|
|
89
|
+
ctx.on("page", lambda p: p.on("request", on_req))
|
|
90
|
+
|
|
91
|
+
# Prefer a portal tab; else new tab
|
|
92
|
+
page = None
|
|
93
|
+
for pg in ctx.pages:
|
|
94
|
+
if "portal.azure.com" in (pg.url or ""):
|
|
95
|
+
page = pg
|
|
96
|
+
break
|
|
97
|
+
if page is None:
|
|
98
|
+
page = ctx.new_page()
|
|
99
|
+
page.on("request", on_req)
|
|
100
|
+
|
|
101
|
+
print("[acrs] navigating portal tab to activation blade...", file=sys.stderr)
|
|
102
|
+
try:
|
|
103
|
+
page.goto(ACTIVATION_URL, wait_until="commit", timeout=60_000)
|
|
104
|
+
except Exception as e:
|
|
105
|
+
print(f"[acrs] nav warning: {e}", file=sys.stderr)
|
|
106
|
+
|
|
107
|
+
# Wait for eligibility grid to render
|
|
108
|
+
try:
|
|
109
|
+
page.wait_for_selector('button:has-text("Activeren")', timeout=30_000)
|
|
110
|
+
except Exception as e:
|
|
111
|
+
raise RuntimeError(f"activation blade did not load: {e}")
|
|
112
|
+
|
|
113
|
+
# Click first grid-row Activate (skip sidebar tab button by scoping to gridcell)
|
|
114
|
+
print("[acrs] clicking first Activate on eligibility row...", file=sys.stderr)
|
|
115
|
+
try:
|
|
116
|
+
page.locator('[role="gridcell"] button:has-text("Activeren")').first.click(
|
|
117
|
+
timeout=10_000
|
|
118
|
+
)
|
|
119
|
+
except Exception:
|
|
120
|
+
# Fallback: any visible Activeren, but skip the sidebar tab (has "in-/uitschakelen")
|
|
121
|
+
btns = page.locator(
|
|
122
|
+
'button:has-text("Activeren"):not(:has-text("in-/uitschakelen"))'
|
|
123
|
+
).all()
|
|
124
|
+
if not btns:
|
|
125
|
+
raise RuntimeError("could not find any Activate button on grid.")
|
|
126
|
+
btns[0].click()
|
|
127
|
+
|
|
128
|
+
# Wait for confirmation panel with reason textbox
|
|
129
|
+
try:
|
|
130
|
+
page.wait_for_selector(
|
|
131
|
+
'textbox[aria-label*="Reden"], textarea[aria-label*="Reden"], [role="textbox"][aria-label*="Reden"]',
|
|
132
|
+
timeout=15_000,
|
|
133
|
+
)
|
|
134
|
+
except Exception:
|
|
135
|
+
# Portal panel loads a bit slower sometimes
|
|
136
|
+
time.sleep(2)
|
|
137
|
+
|
|
138
|
+
# Fill reason
|
|
139
|
+
print("[acrs] filling reason + submitting...", file=sys.stderr)
|
|
140
|
+
try:
|
|
141
|
+
page.get_by_role("textbox", name="Reden").first.fill(justification)
|
|
142
|
+
except Exception:
|
|
143
|
+
try:
|
|
144
|
+
page.locator('[role="textbox"][aria-label*="Reden"]').first.fill(justification)
|
|
145
|
+
except Exception as e:
|
|
146
|
+
print(
|
|
147
|
+
f"[acrs] warn: could not fill reason ({e}); portal may accept empty for silent step-up.",
|
|
148
|
+
file=sys.stderr,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Click the panel Activate (bottom-most button in the aside/complementary panel)
|
|
152
|
+
try:
|
|
153
|
+
# Aside panel Activate — usually the last visible Activate button
|
|
154
|
+
panel_buttons = page.get_by_role("button", name="Activeren").all()
|
|
155
|
+
# Pick a panel button (not the row buttons). The confirmation panel
|
|
156
|
+
# button follows all row buttons; taking the last one is reliable.
|
|
157
|
+
panel_buttons[-1].click()
|
|
158
|
+
except Exception as e:
|
|
159
|
+
raise RuntimeError(f"could not click confirmation Activate button: {e}")
|
|
160
|
+
|
|
161
|
+
print(f"[acrs] waiting for acrs=c1 POST (up to {timeout}s)...", file=sys.stderr)
|
|
162
|
+
print(
|
|
163
|
+
"[acrs] if MFA prompt appears in the browser, complete it — script will pick up the token.",
|
|
164
|
+
file=sys.stderr,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
deadline = time.time() + timeout
|
|
168
|
+
while time.time() < deadline:
|
|
169
|
+
if token_event.wait(timeout=2):
|
|
170
|
+
break
|
|
171
|
+
|
|
172
|
+
if not token_event.is_set():
|
|
173
|
+
raise RuntimeError("no acrs=c1 token captured after portal prime (timeout).")
|
|
174
|
+
|
|
175
|
+
token = token_holder["token"]
|
|
176
|
+
print("[acrs] captured acrs=c1 token.", file=sys.stderr)
|
|
177
|
+
return token
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
import argparse
|
|
182
|
+
|
|
183
|
+
ap = argparse.ArgumentParser()
|
|
184
|
+
ap.add_argument("--cdp-endpoint", default="http://localhost:9222")
|
|
185
|
+
ap.add_argument("--justification", default="acrs prime")
|
|
186
|
+
ap.add_argument("--timeout", type=int, default=180)
|
|
187
|
+
a = ap.parse_args()
|
|
188
|
+
tok = prime_acrs(a.cdp_endpoint, a.justification, a.timeout)
|
|
189
|
+
print(tok)
|
azure_pim_cli/cache.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Cache load/save + NEW-diff for eligible PIM group assignments.
|
|
2
|
+
|
|
3
|
+
JSON schema matches Activate-PimGroup.ps1 so an existing PS cache seeds the diff.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from dateutil import parser as dateparser
|
|
14
|
+
|
|
15
|
+
CACHE_MAX_HOURS = 24
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cache_dir() -> Path:
|
|
19
|
+
if os.environ.get("LOCALAPPDATA"):
|
|
20
|
+
d = Path(os.environ["LOCALAPPDATA"]) / "pim_activate"
|
|
21
|
+
else:
|
|
22
|
+
d = Path.home() / ".pim_activate"
|
|
23
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
return d
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cache_file() -> Path:
|
|
28
|
+
return cache_dir() / "eligible_cache.json"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load() -> dict | None:
|
|
32
|
+
f = cache_file()
|
|
33
|
+
if not f.exists():
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
return json.loads(f.read_text(encoding="utf-8"))
|
|
37
|
+
except Exception:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_fresh(cache: dict, principal_id: str) -> bool:
|
|
42
|
+
if not cache or cache.get("principalId") != principal_id:
|
|
43
|
+
return False
|
|
44
|
+
fetched = cache.get("fetchedAt")
|
|
45
|
+
if not fetched:
|
|
46
|
+
return False
|
|
47
|
+
try:
|
|
48
|
+
age = datetime.now(UTC) - dateparser.isoparse(fetched)
|
|
49
|
+
except Exception:
|
|
50
|
+
return False
|
|
51
|
+
return age.total_seconds() < CACHE_MAX_HOURS * 3600
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def save(principal_id: str, eligible: list[dict]) -> None:
|
|
55
|
+
payload = {
|
|
56
|
+
"fetchedAt": datetime.now(UTC).isoformat(),
|
|
57
|
+
"principalId": principal_id,
|
|
58
|
+
"eligible": eligible,
|
|
59
|
+
}
|
|
60
|
+
cache_file().write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def mark_new(current: list[dict], previous: list[dict] | None) -> list[dict]:
|
|
64
|
+
prev_ids = {e["groupId"] for e in (previous or [])}
|
|
65
|
+
for e in current:
|
|
66
|
+
e["isNew"] = bool(prev_ids) and e["groupId"] not in prev_ids
|
|
67
|
+
return current
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Launch Chrome with remote debugging port, using a copy of the user's real profile.
|
|
2
|
+
|
|
3
|
+
Why: Intune device compliance cookies live in the user's real Chrome profile. Playwright's
|
|
4
|
+
own persistent profile lacks them, so Conditional Access refuses portal sign-in with
|
|
5
|
+
"U kunt op dit moment geen toegang krijgen". Copying the real profile into a debug-
|
|
6
|
+
friendly directory sidesteps both Intune's remote-debugging-port block (which only fires
|
|
7
|
+
on the managed real profile) and the CA compliance check (cookies came along in the copy).
|
|
8
|
+
|
|
9
|
+
Flow:
|
|
10
|
+
1. If debug port already responding -> reuse (fast path).
|
|
11
|
+
2. Else kill running chrome.exe (debug port requires exclusive use).
|
|
12
|
+
3. Copy real profile -> C:\\temp\\chrome_pim_profile (skip if target exists unless refresh).
|
|
13
|
+
4. Launch chrome.exe --remote-debugging-port=<port> --user-data-dir=<copy> <start-url>.
|
|
14
|
+
5. Poll /json/version until ready, return CDP endpoint URL.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
DEFAULT_PORT = 9222
|
|
28
|
+
DEFAULT_COPY_PROFILE = Path(r"C:\temp\chrome_pim_profile")
|
|
29
|
+
DEFAULT_START_URL = (
|
|
30
|
+
"https://portal.azure.com/#view/Microsoft_Azure_PIMCommon/ActivationMenuBlade/~/aadgroup"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _chrome_exe() -> str:
|
|
35
|
+
candidates = [
|
|
36
|
+
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
|
37
|
+
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
|
38
|
+
os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe"),
|
|
39
|
+
]
|
|
40
|
+
for p in candidates:
|
|
41
|
+
if os.path.isfile(p):
|
|
42
|
+
return p
|
|
43
|
+
raise RuntimeError(f"chrome.exe not found in any of: {candidates}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _default_source_profile() -> Path:
|
|
47
|
+
return Path(os.environ["LOCALAPPDATA"]) / "Google" / "Chrome" / "User Data"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _port_alive(port: int) -> bool:
|
|
51
|
+
try:
|
|
52
|
+
with urllib.request.urlopen(f"http://localhost:{port}/json/version", timeout=1) as r:
|
|
53
|
+
return r.status == 200
|
|
54
|
+
except (urllib.error.URLError, ConnectionResetError, OSError):
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _wait_ready(port: int, timeout: float = 30.0) -> bool:
|
|
59
|
+
deadline = time.time() + timeout
|
|
60
|
+
while time.time() < deadline:
|
|
61
|
+
if _port_alive(port):
|
|
62
|
+
return True
|
|
63
|
+
time.sleep(0.5)
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _kill_chrome() -> None:
|
|
68
|
+
subprocess.run(["taskkill", "/F", "/IM", "chrome.exe"], capture_output=True)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _copy_profile(src: Path, dst: Path) -> None:
|
|
72
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
# robocopy /E all subdirs, /XJ skip junctions (avoids symlink loops in Chrome cache),
|
|
74
|
+
# /R:1 /W:1 minimise retries on locked files, /NFL /NDL /NJH /NJS quiet output.
|
|
75
|
+
subprocess.run(
|
|
76
|
+
[
|
|
77
|
+
"robocopy",
|
|
78
|
+
str(src),
|
|
79
|
+
str(dst),
|
|
80
|
+
"/E",
|
|
81
|
+
"/XJ",
|
|
82
|
+
"/R:1",
|
|
83
|
+
"/W:1",
|
|
84
|
+
"/NFL",
|
|
85
|
+
"/NDL",
|
|
86
|
+
"/NJH",
|
|
87
|
+
"/NJS",
|
|
88
|
+
"/NC",
|
|
89
|
+
"/NS",
|
|
90
|
+
"/NP",
|
|
91
|
+
],
|
|
92
|
+
capture_output=True,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def launch_debug_chrome(
|
|
97
|
+
port: int = DEFAULT_PORT,
|
|
98
|
+
copy_profile: Path = DEFAULT_COPY_PROFILE,
|
|
99
|
+
source_profile: Path | None = None,
|
|
100
|
+
start_url: str = DEFAULT_START_URL,
|
|
101
|
+
force_profile_refresh: bool = False,
|
|
102
|
+
) -> str:
|
|
103
|
+
"""Ensure a Chrome with debug port is running against a profile that has Intune cookies.
|
|
104
|
+
|
|
105
|
+
Returns CDP endpoint URL (http://localhost:<port>).
|
|
106
|
+
"""
|
|
107
|
+
if _port_alive(port) and not force_profile_refresh:
|
|
108
|
+
print(f"[chrome] debug port {port} already responding; reusing.", file=sys.stderr)
|
|
109
|
+
return f"http://localhost:{port}"
|
|
110
|
+
if _port_alive(port) and force_profile_refresh:
|
|
111
|
+
print(
|
|
112
|
+
"[chrome] --refresh-chrome-profile set; restarting Chrome to apply fresh profile.",
|
|
113
|
+
file=sys.stderr,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
exe = _chrome_exe()
|
|
117
|
+
src = source_profile or _default_source_profile()
|
|
118
|
+
|
|
119
|
+
print("[chrome] killing chrome.exe (need exclusive debug port)...", file=sys.stderr)
|
|
120
|
+
_kill_chrome()
|
|
121
|
+
time.sleep(1)
|
|
122
|
+
|
|
123
|
+
need_copy = (
|
|
124
|
+
force_profile_refresh or not copy_profile.exists() or not any(copy_profile.iterdir())
|
|
125
|
+
)
|
|
126
|
+
if need_copy:
|
|
127
|
+
if not src.exists():
|
|
128
|
+
raise RuntimeError(f"source profile not found: {src}")
|
|
129
|
+
print(
|
|
130
|
+
f"[chrome] copying profile {src} -> {copy_profile} (may take 30-90s)...",
|
|
131
|
+
file=sys.stderr,
|
|
132
|
+
)
|
|
133
|
+
_copy_profile(src, copy_profile)
|
|
134
|
+
else:
|
|
135
|
+
print(
|
|
136
|
+
f"[chrome] reusing existing profile copy at {copy_profile} "
|
|
137
|
+
"(pass --refresh-chrome-profile to re-copy).",
|
|
138
|
+
file=sys.stderr,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
args = [
|
|
142
|
+
exe,
|
|
143
|
+
f"--remote-debugging-port={port}",
|
|
144
|
+
f"--user-data-dir={copy_profile}",
|
|
145
|
+
"--no-first-run",
|
|
146
|
+
"--no-default-browser-check",
|
|
147
|
+
start_url,
|
|
148
|
+
]
|
|
149
|
+
print(
|
|
150
|
+
f"[chrome] launching: {exe} --remote-debugging-port={port} --user-data-dir={copy_profile}",
|
|
151
|
+
file=sys.stderr,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# DETACHED_PROCESS = 0x00000008; keeps Chrome alive after Python exits.
|
|
155
|
+
creationflags = 0x00000008 if os.name == "nt" else 0
|
|
156
|
+
subprocess.Popen(args, creationflags=creationflags, close_fds=True)
|
|
157
|
+
|
|
158
|
+
print(f"[chrome] waiting for debug port {port}...", file=sys.stderr)
|
|
159
|
+
if not _wait_ready(port):
|
|
160
|
+
raise RuntimeError(
|
|
161
|
+
f"Chrome debug port {port} not responding after 30s. "
|
|
162
|
+
"Chrome may be blocked by policy or failed to launch."
|
|
163
|
+
)
|
|
164
|
+
print(f"[chrome] ready at http://localhost:{port}", file=sys.stderr)
|
|
165
|
+
print("[chrome] complete portal sign-in in the new Chrome window if prompted.", file=sys.stderr)
|
|
166
|
+
return f"http://localhost:{port}"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
import argparse
|
|
171
|
+
|
|
172
|
+
p = argparse.ArgumentParser(description="Launch Chrome with debug port on a copied profile.")
|
|
173
|
+
p.add_argument("--port", type=int, default=DEFAULT_PORT)
|
|
174
|
+
p.add_argument("--copy-profile", default=str(DEFAULT_COPY_PROFILE))
|
|
175
|
+
p.add_argument("--source-profile", default=None)
|
|
176
|
+
p.add_argument(
|
|
177
|
+
"--refresh", action="store_true", help="Re-copy real profile even if target exists."
|
|
178
|
+
)
|
|
179
|
+
p.add_argument("--url", default=DEFAULT_START_URL)
|
|
180
|
+
a = p.parse_args()
|
|
181
|
+
endpoint = launch_debug_chrome(
|
|
182
|
+
port=a.port,
|
|
183
|
+
copy_profile=Path(a.copy_profile),
|
|
184
|
+
source_profile=Path(a.source_profile) if a.source_profile else None,
|
|
185
|
+
start_url=a.url,
|
|
186
|
+
force_profile_refresh=a.refresh,
|
|
187
|
+
)
|
|
188
|
+
print(endpoint)
|