scriptit-cli 0.2.2__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.
scriptit_cli/auth.py ADDED
@@ -0,0 +1,553 @@
1
+ """``scriptit auth`` — connect the workstation CLI to the Script.it platform.
2
+
3
+ Login on Script.it Cloud is a browser approval. The CLI keeps a random
4
+ verifier and opens — or, where it cannot, prints —
5
+ ``/app/cli-auth?challenge=<sha256(verifier)>``. The signed-in page approves
6
+ that digest, and the CLI polls the backend with the verifier until the
7
+ approval lands, receiving a backend-minted Firebase custom token that it
8
+ exchanges for an ID + refresh token pair via the public identitytoolkit
9
+ endpoint. The stored record is that pair plus what a refresh needs (the API
10
+ key, the deployment's URLs, the expiry) — no password ever touches the CLI, and
11
+ nothing in it is reusable as one.
12
+
13
+ Only the digest leaves this process, so nothing printed, and nothing a
14
+ transcript carrying the URL holds, can be redeemed by anyone else; and the
15
+ browser never has to reach this machine, so one flow serves a laptop, an SSH
16
+ host and a hosted agent's container alike. Self-hosted deployments sign in
17
+ through their Keycloak realm's device grant instead.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import contextlib
23
+ import hashlib
24
+ import os
25
+ import secrets
26
+ import sys
27
+ import time
28
+ import webbrowser
29
+ from typing import Any, Dict, Optional
30
+
31
+ import requests
32
+
33
+ from scriptit_cli.analytics import analytics
34
+ from scriptit_cli.client import detect_client
35
+ from scriptit_cli.config import _config_dir
36
+ from scriptit_cli.output import emit, fail, note, warn
37
+ from scriptit_cli.remote_auth import (
38
+ RemoteAuthError,
39
+ credentials_path,
40
+ current_profile_name,
41
+ delete_credentials,
42
+ exchange_custom_token,
43
+ get_fresh_id_token,
44
+ keycloak_device_login,
45
+ list_profiles,
46
+ load_credentials,
47
+ save_credentials,
48
+ set_current_profile,
49
+ )
50
+ from scriptit_cli.util import update_json_file
51
+
52
+ DEFAULT_API_URL = "https://api.script.it"
53
+ LOGIN_TIMEOUT_SECONDS = 300
54
+
55
+ PENDING_LOGIN_FILE = "pending-login.json"
56
+
57
+ # How often a waiting CLI asks whether its login was approved. Each poll is one
58
+ # indexed DELETE on the backend, and nobody reading a code off a screen can
59
+ # tell this apart from anything faster.
60
+ _APPROVAL_POLL_SECONDS = 2
61
+
62
+ # The longest one poll may take, and the least it is given: a poll gets only
63
+ # what is left of the wait, so one that stalls cannot hold the command past
64
+ # its timeout, and the floor still lets a zero timeout check once.
65
+ _REDEEM_TIMEOUT_SECONDS = 30
66
+ _REDEEM_MIN_TIMEOUT_SECONDS = 1
67
+
68
+ # How long a printed approval link stays resumable. A hosted agent's harness
69
+ # may time out the waiting command; running it again inside this window shows
70
+ # the same link and collects an approval already given, rather than asking the
71
+ # user to approve a second one.
72
+ _PENDING_LOGIN_MAX_AGE_SECONDS = 1800
73
+
74
+
75
+ def _fetch_auth_config(api_url: str) -> Dict[str, Any]:
76
+ resp = requests.get(f"{api_url}/api/v1/auth/config", timeout=30)
77
+ if resp.status_code != 200:
78
+ raise RemoteAuthError(f"could not reach {api_url}/api/v1/auth/config ({resp.status_code})")
79
+ return resp.json()
80
+
81
+
82
+ def _should_open_browser(no_browser: bool) -> bool:
83
+ """Whether to pop a browser window for the login URL.
84
+
85
+ An agent harness has its own browser and cannot see the one
86
+ ``webbrowser.open`` launches, so opening it puts a login page in front of
87
+ the human instead of the agent — while the agent, which is the caller,
88
+ learns nothing. :func:`detect_client` already knows when a harness is
89
+ driving (it reads the same markers used for session attribution), so the
90
+ URL is simply printed there and whoever is driving decides what to do
91
+ with it.
92
+ """
93
+ return not no_browser and detect_client() is None
94
+
95
+
96
+ def _pending_login_path() -> str:
97
+ return os.path.join(_config_dir(), PENDING_LOGIN_FILE)
98
+
99
+
100
+ def _is_resumable(entry: Any, now: float) -> bool:
101
+ if not isinstance(entry, dict) or not entry.get("verifier"):
102
+ return False
103
+ try:
104
+ return now - float(entry.get("created_at")) < _PENDING_LOGIN_MAX_AGE_SECONDS
105
+ except (TypeError, ValueError):
106
+ return False
107
+
108
+
109
+ def _take_verifier(key: str) -> str:
110
+ """This login's pending verifier if it is still resumable, else a new one.
111
+
112
+ Kept beside the credential store, 0600, until the approval is collected or
113
+ refused. It is a secret for as long as it lives: whoever holds it collects
114
+ any approval of its challenge.
115
+ """
116
+ now = time.time()
117
+ taken: Dict[str, str] = {}
118
+
119
+ def _mutate(data: Dict[str, Any]) -> Dict[str, Any]:
120
+ live = {k: v for k, v in data.items() if _is_resumable(v, now)}
121
+ if key in live:
122
+ taken["verifier"] = str(live[key]["verifier"])
123
+ else:
124
+ taken["verifier"] = secrets.token_urlsafe(32)
125
+ live[key] = {"verifier": taken["verifier"], "created_at": now}
126
+ return live
127
+
128
+ update_json_file(_pending_login_path(), _mutate)
129
+ return taken["verifier"]
130
+
131
+
132
+ def _forget_verifier(key: str) -> None:
133
+ update_json_file(
134
+ _pending_login_path(), lambda data: {k: v for k, v in data.items() if k != key}
135
+ )
136
+
137
+
138
+ def _response_detail(resp: requests.Response) -> str:
139
+ with contextlib.suppress(ValueError, KeyError, TypeError):
140
+ return str(resp.json()["detail"])
141
+ return resp.text[:300]
142
+
143
+
144
+ def _approval_refused(status: int, detail: str) -> RemoteAuthError:
145
+ if status in (401, 404, 405):
146
+ # A backend without the redeem route answers the unauthenticated poll
147
+ # from its auth middleware, before routing.
148
+ return RemoteAuthError(
149
+ "this Script.it deployment does not support browser-approved login — "
150
+ "sign in to it with an earlier scriptit-cli release"
151
+ )
152
+ if status == 403:
153
+ return RemoteAuthError("the approving account is suspended or deleted")
154
+ return RemoteAuthError(f"login approval failed ({status}): {detail}")
155
+
156
+
157
+ def _receive_via_approval(
158
+ api_url: str,
159
+ login_url_base: str,
160
+ pending_key: str,
161
+ timeout: int,
162
+ open_browser: bool = False,
163
+ ) -> Dict[str, Any]:
164
+ """Show an approval link, then poll until a signed-in browser approves it.
165
+
166
+ Only ``sha256(verifier)`` leaves this process. The verifier is forgotten
167
+ once the backend answers for it either way — collected, or refused — and
168
+ kept on a timeout, so running the command again resumes the same link.
169
+ """
170
+ verifier = _take_verifier(pending_key)
171
+ challenge = hashlib.sha256(verifier.encode("utf-8")).hexdigest()
172
+ url = f"{login_url_base}?challenge={challenge}"
173
+ # `note` flushes: agents and scripts read this through pipes, and the URL
174
+ # must be visible before the wait begins.
175
+ if open_browser:
176
+ note("Opening your browser to approve this login…")
177
+ note(f" {url}")
178
+ note("(If the browser doesn't open, open the URL yourself.)")
179
+ # A headless or misconfigured machine simply doesn't open one; the URL
180
+ # is already printed above, which is the fallback.
181
+ with contextlib.suppress(Exception):
182
+ webbrowser.open(url)
183
+ else:
184
+ note("Open this URL in a browser where you're signed in to Script.it:")
185
+ note(f" {url}")
186
+ note(f"Approve it if the page shows the code {challenge[:4].upper()}-{challenge[4:8].upper()}.")
187
+ note("If this command is interrupted, run it again: it resumes this same login.")
188
+
189
+ deadline = time.monotonic() + timeout
190
+ # Failures that leave a given approval where it is: the connection
191
+ # dropping, and the server answering 5xx or 429 (a deploy rolling, a cold
192
+ # start, a failed mint, which the backend runs before spending the
193
+ # approval). The wait rides them out, saying once per new failure why it
194
+ # is still waiting.
195
+ last_error: Optional[str] = None
196
+ while True:
197
+ error: Optional[str] = None
198
+ remaining = deadline - time.monotonic()
199
+ try:
200
+ resp = requests.post(
201
+ f"{api_url}/api/v1/auth/cli/redeem",
202
+ json={"verifier": verifier},
203
+ timeout=min(_REDEEM_TIMEOUT_SECONDS, max(remaining, _REDEEM_MIN_TIMEOUT_SECONDS)),
204
+ )
205
+ except requests.RequestException as exc:
206
+ error = str(exc)
207
+ else:
208
+ detail = _response_detail(resp)
209
+ if resp.status_code == 200:
210
+ _forget_verifier(pending_key)
211
+ with contextlib.suppress(ValueError):
212
+ body = resp.json()
213
+ if (
214
+ isinstance(body, dict)
215
+ and body.get("custom_token")
216
+ and body.get("firebase_api_key")
217
+ ):
218
+ return body
219
+ raise RemoteAuthError("login approval returned an unexpected response")
220
+ if resp.status_code == 429 or resp.status_code >= 500:
221
+ error = f"{resp.status_code} {detail}"
222
+ elif not (resp.status_code == 400 and detail == "authorization_pending"):
223
+ _forget_verifier(pending_key)
224
+ raise _approval_refused(resp.status_code, detail)
225
+ if error and error != last_error:
226
+ note(f"Still waiting — Script.it could not answer ({error}); retrying.")
227
+ last_error = error
228
+ remaining = deadline - time.monotonic()
229
+ if remaining <= 0:
230
+ break
231
+ time.sleep(min(_APPROVAL_POLL_SECONDS, remaining))
232
+
233
+ reason = f" (last error: {last_error})" if last_error else ""
234
+ raise RemoteAuthError(
235
+ f"not approved within {timeout}s{reason} — run the same command again to keep "
236
+ "waiting on this link"
237
+ )
238
+
239
+
240
+ def _verify_stored_login(api_url: str, creds: Dict[str, Any], profile: Optional[str]) -> None:
241
+ """Confirm the credential we just stored is one the backend accepts.
242
+
243
+ Both login modes end here: a token that mints cleanly can still be
244
+ rejected — a Keycloak realm that withholds the ID token is the case this
245
+ catches — and finding out now beats finding out on the user's next command.
246
+
247
+ Rollback names the profile explicitly. ``delete_credentials()`` with no
248
+ argument resolves the *ambient* profile, so under ``SCRIPTIT_PROFILE`` a
249
+ failed login into one profile would delete a different, working one.
250
+ """
251
+ try:
252
+ resp = requests.get(
253
+ f"{api_url}/api/v1/auth/me",
254
+ headers={"Authorization": f"Bearer {get_fresh_id_token(creds)}"},
255
+ timeout=30,
256
+ )
257
+ except requests.RequestException as exc:
258
+ warn(f"Warning: login stored but /auth/me failed: {exc}")
259
+ return
260
+ if resp.status_code != 200:
261
+ delete_credentials(profile=creds.get("profile") or profile)
262
+ fail(f"backend rejected the new credential ({resp.status_code}): {resp.text[:200]}")
263
+ me = resp.json()
264
+ # Attribution is the bearer token the backend just accepted; nothing about
265
+ # the user is read out of this response and sent anywhere.
266
+ analytics.bind(api_url, lambda: get_fresh_id_token(creds))
267
+ analytics.track("cli_login_succeeded", mode=creds.get("mode"))
268
+ emit(
269
+ {
270
+ "logged_in": True,
271
+ "api_url": api_url,
272
+ "user": {"id": me.get("id"), "email": me.get("email")},
273
+ "profile": creds.get("profile") or profile or current_profile_name(),
274
+ "credentials_file": credentials_path(),
275
+ },
276
+ lambda: print(f"Logged in to {api_url} as {me.get('email') or me.get('id')}"),
277
+ )
278
+
279
+
280
+ class AuthCommands:
281
+ """Authentication for remote (workstation) use of the scriptit CLI."""
282
+
283
+ def login(
284
+ self,
285
+ api_url: Optional[str] = None,
286
+ app_url: Optional[str] = None,
287
+ manual: bool = False,
288
+ timeout: int = LOGIN_TIMEOUT_SECONDS,
289
+ profile: Optional[str] = None,
290
+ no_browser: bool = False,
291
+ ) -> None:
292
+ """Connect this machine to your Script.it account.
293
+
294
+ Args:
295
+ api_url: Platform API origin (default: existing login's, else
296
+ https://api.script.it). Pass a self-hosted deployment's
297
+ origin to sign in to that instead.
298
+ app_url: Frontend origin override (normally discovered from the API).
299
+ manual: The same as --no-browser; kept so instructions that
300
+ pass it keep working.
301
+ timeout: Seconds to wait for the approval. Run again after a
302
+ timeout or an interruption, the command resumes the same link.
303
+ profile: Named profile to store this login under (multiple
304
+ accounts side by side; switch with `scriptit auth use`,
305
+ override per-invocation with SCRIPTIT_PROFILE).
306
+ no_browser: Print the login URL instead of opening a window.
307
+ Implied when an agent harness is driving.
308
+ """
309
+ existing = load_credentials(profile) or {}
310
+ api_url = str(api_url or existing.get("api_url") or DEFAULT_API_URL).rstrip("/")
311
+
312
+ try:
313
+ config = _fetch_auth_config(api_url)
314
+ except (RemoteAuthError, ValueError, requests.RequestException) as exc:
315
+ fail(f"cannot reach {api_url}: {exc}")
316
+
317
+ if config.get("mode") == "keycloak":
318
+ self._login_keycloak(api_url, config, timeout, profile)
319
+ return
320
+ if config.get("mode") != "firebase":
321
+ fail(f"unknown auth mode {config.get('mode')!r}")
322
+
323
+ app_url = (app_url or config.get("app_url") or "").rstrip("/")
324
+ if not app_url:
325
+ fail("the API did not advertise an app URL; pass --app-url")
326
+
327
+ try:
328
+ payload = _receive_via_approval(
329
+ api_url,
330
+ f"{app_url}/app/cli-auth",
331
+ pending_key=f"{current_profile_name(profile)} {api_url}",
332
+ timeout=timeout,
333
+ open_browser=_should_open_browser(no_browser or manual),
334
+ )
335
+ tokens = exchange_custom_token(
336
+ str(payload["firebase_api_key"]),
337
+ str(payload["custom_token"]),
338
+ app_url=app_url,
339
+ )
340
+ except RemoteAuthError as exc:
341
+ fail(str(exc))
342
+
343
+ creds: Dict[str, Any] = {
344
+ "mode": "firebase",
345
+ "api_url": api_url,
346
+ "app_url": app_url,
347
+ "firebase_api_key": payload["firebase_api_key"],
348
+ "user_email": payload.get("user_email"),
349
+ **tokens,
350
+ }
351
+ save_credentials(creds, profile=profile)
352
+
353
+ # Verify end-to-end: the stored credential must satisfy the backend.
354
+ _verify_stored_login(api_url, creds, profile)
355
+
356
+ def _login_keycloak(
357
+ self,
358
+ api_url: str,
359
+ config: Dict[str, Any],
360
+ timeout: int,
361
+ profile: Optional[str] = None,
362
+ ) -> None:
363
+ """RFC 8628 device grant against the realm advertised by /auth/config."""
364
+ keycloak = config.get("keycloak") or {}
365
+ if not (keycloak.get("url") and keycloak.get("realm") and keycloak.get("client_id")):
366
+ fail("the API did not advertise Keycloak realm details")
367
+ # The realm's own CLI client when the backend names one: a session
368
+ # issued to it is what the backend treats as an outside agent, so the
369
+ # account can govern it. A backend that predates the field advertises
370
+ # only the web client, which is then the only way in.
371
+ client_id = str(keycloak.get("cli_client_id") or keycloak["client_id"])
372
+ try:
373
+ creds = keycloak_device_login(
374
+ str(keycloak["url"]),
375
+ str(keycloak["realm"]),
376
+ client_id,
377
+ timeout=timeout,
378
+ )
379
+ except RemoteAuthError as exc:
380
+ fail(str(exc))
381
+ creds["api_url"] = api_url
382
+ save_credentials(creds, profile=profile)
383
+ _verify_stored_login(api_url, creds, profile)
384
+
385
+ def browser_url(
386
+ self,
387
+ next: str = "/app",
388
+ profile: Optional[str] = None,
389
+ ) -> None:
390
+ """Print a one-time URL that signs a browser in as this machine's user.
391
+
392
+ The inverse of `login`. There a signed-in browser lends a session to
393
+ the CLI; here a signed-in CLI lends one to a browser — for an agent
394
+ that can already drive the platform through this CLI but has no way to
395
+ *see* it.
396
+
397
+ An agent cannot log in on the user's behalf: the login page mints its
398
+ token because it is already signed in, and an agent's browser is not,
399
+ so the only way through would be typing the user's password. Borrowing
400
+ this machine's existing session is what an agent can do instead.
401
+
402
+ The URL is a credential. It is good for 60 seconds, once, and the code
403
+ rides in the fragment so it reaches no server log. Treat it as you
404
+ would a password for that minute: the backend records every redeem and
405
+ emails the account when one arrives from an unfamiliar address.
406
+
407
+ Script.it Cloud only. An on-prem deployment authenticates through
408
+ Keycloak, which has no equivalent, and the backend says so rather than
409
+ failing obscurely.
410
+
411
+ Args:
412
+ next: Same-origin path to land on, e.g. `/app/s/<session_id>`.
413
+ The page refuses anything that would leave the origin.
414
+ profile: Named profile to use (default: the active one).
415
+ """
416
+ creds = load_credentials(profile)
417
+ if not creds:
418
+ fail("not logged in — run `scriptit auth login`")
419
+
420
+ app_url = str(creds.get("app_url") or "").rstrip("/")
421
+ if not app_url:
422
+ fail(
423
+ "this login recorded no browser origin — run `scriptit auth login` "
424
+ "again to refresh it"
425
+ )
426
+
427
+ api_url = str(creds["api_url"]).rstrip("/")
428
+ try:
429
+ token = get_fresh_id_token(creds)
430
+ except RemoteAuthError as exc:
431
+ fail(str(exc))
432
+
433
+ try:
434
+ resp = requests.post(
435
+ f"{api_url}/api/v1/auth/browser-handoff",
436
+ headers={"Authorization": f"Bearer {token}"},
437
+ json={"client": detect_client()},
438
+ timeout=30,
439
+ )
440
+ except requests.RequestException as exc:
441
+ fail(f"could not reach {api_url}: {exc}")
442
+
443
+ if resp.status_code != 200:
444
+ # 409 is the deployment saying it cannot do this at all (Keycloak).
445
+ # Its wording is the useful part, so it is passed through rather
446
+ # than replaced with a guess about why.
447
+ detail = resp.text[:300]
448
+ with contextlib.suppress(ValueError, KeyError, TypeError):
449
+ detail = resp.json()["detail"]
450
+ fail(f"could not mint a browser link ({resp.status_code}): {detail}")
451
+
452
+ body = resp.json()
453
+ # The code goes in the fragment, never the query string: a fragment is
454
+ # not sent to any server and never lands in an access log or a Referer.
455
+ url = f"{app_url}/app/cli-handoff#code={body['code']}&next={next}"
456
+ expires_in = int(body.get("expires_in") or 60)
457
+
458
+ def _human() -> None:
459
+ print(url)
460
+ print(
461
+ f"\nOpen it within {expires_in}s — it works once. Anyone who reads it "
462
+ "in that window can sign in as you.",
463
+ file=sys.stderr,
464
+ )
465
+
466
+ emit({"url": url, "expires_in": expires_in}, _human)
467
+
468
+ def list(self) -> None:
469
+ """List stored login profiles (the active one is starred)."""
470
+ profiles = list_profiles()
471
+ current = current_profile_name() if profiles else None
472
+ rows = [
473
+ {
474
+ "profile": name,
475
+ "email": creds.get("user_email"),
476
+ "api_url": creds.get("api_url"),
477
+ "active": name == current,
478
+ }
479
+ for name, creds in sorted(profiles.items())
480
+ ]
481
+
482
+ def _human() -> None:
483
+ if not rows:
484
+ print("No profiles. Run `scriptit auth login`.")
485
+ return
486
+ for row in rows:
487
+ marker = " *" if row["active"] else ""
488
+ print(f"{row['profile']}{marker} {row['email'] or '?'} {row['api_url']}")
489
+
490
+ emit({"profiles": rows, "current": current}, _human)
491
+
492
+ def use(self, profile: str) -> None:
493
+ """Make a stored profile the default for subsequent commands."""
494
+ if not set_current_profile(profile):
495
+ fail(f"no profile named '{profile}' — see `scriptit auth list`")
496
+ emit({"profile": profile}, lambda: print(f"Active profile: {profile}"))
497
+
498
+ def status(self, profile: Optional[str] = None) -> None:
499
+ """Show who this machine is connected as."""
500
+ creds = load_credentials(profile)
501
+ if not creds:
502
+ emit(
503
+ {"logged_in": False},
504
+ lambda: print("Not logged in. Run `scriptit auth login`."),
505
+ )
506
+ return
507
+ api_url = creds["api_url"]
508
+ try:
509
+ token = get_fresh_id_token(creds)
510
+ resp = requests.get(
511
+ f"{api_url}/api/v1/auth/me",
512
+ headers={"Authorization": f"Bearer {token}"},
513
+ timeout=30,
514
+ )
515
+ except (RemoteAuthError, requests.RequestException) as exc:
516
+ fail(
517
+ f"logged in to {api_url} but the credential is unusable: {exc} "
518
+ f"(credentials file: {credentials_path()})"
519
+ )
520
+ return
521
+ if resp.status_code != 200:
522
+ fail(f"logged in to {api_url}, but /auth/me returned {resp.status_code}")
523
+ return
524
+ me = resp.json()
525
+ profile_name = current_profile_name(profile)
526
+ payload = {
527
+ "logged_in": True,
528
+ "api_url": api_url,
529
+ "user": {"id": me.get("id"), "email": me.get("email")},
530
+ "profile": profile_name,
531
+ "credentials_file": credentials_path(),
532
+ }
533
+
534
+ def _human() -> None:
535
+ print(
536
+ f"Logged in to {api_url} as {me.get('email') or me.get('id')} "
537
+ f"(profile: {profile_name})"
538
+ )
539
+ print(f"Credentials file: {credentials_path()}")
540
+
541
+ emit(payload, _human)
542
+
543
+ def logout(self, profile: Optional[str] = None, all_profiles: bool = False) -> None:
544
+ """Forget stored credentials (current profile; --all-profiles for every one)."""
545
+ removed = delete_credentials(profile=profile, all_profiles=all_profiles)
546
+ emit(
547
+ {"removed": bool(removed)},
548
+ lambda: print(
549
+ "Logged out (local credentials removed)."
550
+ if removed
551
+ else "Nothing to remove — not logged in."
552
+ ),
553
+ )
scriptit_cli/client.py ADDED
@@ -0,0 +1,42 @@
1
+ """Which harness, if any, is driving this CLI.
2
+
3
+ Its own module because everything wants it and it depends on nothing: the
4
+ session labels itself with it, analytics reports it, and `auth login` uses it
5
+ to decide whether opening a browser window would help anyone. Left in
6
+ ``remote``, the analytics module could only reach it through an import inside a
7
+ function, since ``remote`` imports analytics itself.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from typing import List, Optional, Tuple
14
+
15
+ # Harness markers, checked in order. Self-declaration via SCRIPTIT_CLIENT
16
+ # (documented in the entry-point skill) always wins; these cover harnesses
17
+ # that export an identifying variable without being asked.
18
+ _CLIENT_ENV_MARKERS: List[Tuple[str, str]] = [
19
+ ("CLAUDECODE", "claude-code"),
20
+ ("CLAUDE_CODE_ENTRYPOINT", "claude-code"),
21
+ ("CODEX_THREAD_ID", "codex"),
22
+ ("CODEX_SANDBOX", "codex"),
23
+ ("CURSOR_TRACE_ID", "cursor"),
24
+ ("GEMINI_CLI", "gemini-cli"),
25
+ ]
26
+
27
+
28
+ def detect_client() -> Optional[str]:
29
+ """The driving harness's slug (``claude-code``, ``codex``, ...), or None.
30
+
31
+ ``SCRIPTIT_CLIENT`` is the explicit contract — agents are told to set it
32
+ before ``scriptit session new``. Known harness env markers are a
33
+ best-effort fallback; both are self-reported, so this is attribution
34
+ for the session UI and analytics, not a security boundary.
35
+ """
36
+ explicit = os.environ.get("SCRIPTIT_CLIENT", "").strip().lower()
37
+ if explicit:
38
+ return "".join(ch for ch in explicit if ch.isalnum() or ch in "-_")[:32] or None
39
+ for var, slug in _CLIENT_ENV_MARKERS:
40
+ if os.environ.get(var):
41
+ return slug
42
+ return None