← Commit history

v0.8.22: auth hints for issue #9 (pup lane, verifiable toast, status/auth --json, callback line, 'Something went wrong' recovery)

John Lauer ·a0dc0a95f2 ·1mo ago ·parent 14552f5
2 files changed +128−52
bin/adom-google+124−49
@@ -17,13 +17,13 @@ Auth (one consent, long-lived refresh token):   adom-google auth            # Adom way — uses the OAuth Gateway (no localhost loopback)   adom-google auth --manual   # fallback — desktop-client paste flow (like adom-gmail)   adom-google auth-code '<code>'   # finish the --manual flow-  adom-google status          # show what's authorised (never prints secrets)+  adom-google status [--json] # show what's authorised (never prints secrets); exit 1 if not  See `adom-google help`. """ import sys, os, json, base64, argparse, urllib.parse, ssl, socket, hashlib, secrets, struct, time -ADOM_GOOGLE_VERSION = "0.8.21"+ADOM_GOOGLE_VERSION = "0.8.22" if sys.argv[1:2] == ["--version"]:     print(f"adom-google {ADOM_GOOGLE_VERSION}")     sys.exit(0)@@ -216,7 +216,7 @@ def _onboard_config(org=None): ONBOARD_HINT = """Hint: ORG ONBOARDING — drive the user's OWN browser through Google Cloud Console. Hint: This is the hand-held setup that gives a brand-new org their own Google OAuth client so Hint: adom-google works for their whole team. Use the `adom-google-onboarding` skill, which-Hint: drives the user's REAL logged-in browser (adom-desktop `nbrowser_*`; pup native-mode as+Hint: drives the user's REAL logged-in browser (adom-bridge `nbrowser_*`; pup native-mode as Hint: fallback) and AUTO-FILLS the Cloud Console fields. Get the exact values to type with: Hint:     adom-google onboard --json        # redirect_uri, the 9 APIs, scopes, consent settings Hint: Steps the skill performs (screenshotting + pausing for the user's approval clicks):@@ -1130,54 +1130,99 @@ def _store_broker_tokens(cfg, tokens, scopes, mode):   def _print_open_url(auth_link, step="1"):-    """Present the consent URL so an AI/shell can't mangle it.--    LEAD WITH THE HYPERLINK. Hydrogen intercepts a clicked link and shows its browser-    picker, so the USER chooses which browser and which profile — one click for them and-    nothing for the AI to get wrong. Driving the browser instead (nbrowser_open_window)-    needs the Adom extension installed in that specific Chrome PROFILE, and it very often-    is not: `nbrowser_profiles` reports extensionInstalled:false and the call fails with-    "native browser extension not connected", which nbrowser_wake_profile does not fix.-    That path cost a user a long stretch of a session before the fallback (hand them the-    URL) worked first time.--    The '&' warning below still matters: dropped UNQUOTED into a shell/open command the-    shell cuts the URL at the first '&', Google receives only client_id and errors+    """Present the consent URL so an AI can act on it without mangling it.++    THREE LANES, cheapest-for-the-human first (issue #9, #8):+      A. pup, hands-free: pup's durable `adom-you` profile + OS-keychain vault can sign into+         Google and click Allow with no human in the loop, once a Google login is saved there.+         On a machine where it is not, the FIRST pass is one interactive sign-in inside the pup+         window (toast the user with focus on it); every later `auth` is silent.+      B. a clickable hyperlink: Hydrogen's browser picker lets the USER choose browser/profile.+         One click, nothing for the AI to get wrong, no extension required.+      C. the Adom extension window (nbrowser_open_window), ONLY when `nbrowser_profiles` says+         extensionInstalled:true for that profile; it often is not, and the call then fails with+         "native browser extension not connected" (nbrowser_wake_profile does not fix it).++    The '&' warning matters in every lane: dropped UNQUOTED into a shell/open command the shell+    cuts the URL at the first '&', Google receives only client_id and errors     'invalid_request: missing response_type' (issue #3)."""     safe = json.dumps({"sessionId": "gauth", "url": auth_link})-    print(f"{step}) Give the USER this consent URL and have them click Allow.\n")-    print("   BEST — surface it to the user as a CLICKABLE HYPERLINK in your reply.")-    print("   Hydrogen's browser picker then lets them choose the browser/profile themselves.")-    print("   Do NOT put it in a code fence: a fence renders as plain text, so the user has")-    print("   to copy-paste it by hand and the picker never fires.\n")+    print(f"{step}) Get this consent URL in front of Google and get Allow clicked.\n")     print("   " + auth_link + "\n")     print("   ⚠ The URL contains '&'. Keep it as ONE whole value. Do NOT drop it UNQUOTED into")-    print("     a shell/open command — the shell cuts it at the first '&', Google then gets only")-    print("     client_id and shows \"Error 400: invalid_request — missing response_type\".\n")-    print("   ONLY IF the Adom extension is installed in the target profile — check first with")-    print("   `adom-desktop nbrowser_profiles` and require extensionInstalled:true — you may")-    print("   instead open it directly (URL rides inside JSON, so '&' can't truncate it):\n")-    print("     adom-desktop nbrowser_open_window '" + safe + "'")-    print("     # extension NOT installed there? Don't fall back to nbrowser_open_os_window:")-    print("     # it is extension-free and returns ok without confirming what rendered, so you")-    print("     # cannot tell whether the consent page actually loaded. Use the hyperlink.\n")+    print("     a shell/open command: the shell cuts it at the first '&', Google then gets only")+    print("     client_id and shows \"Error 400: invalid_request, missing response_type\".\n")+    print("   LANE A, HANDS-FREE (try first): drive the consent page in pup. pup's durable")+    print("   `adom-you` profile keeps the Google session, so once it is signed in the user")+    print("   never clicks again. Check the vault for a Google login first:")+    print("     adom-bridge credential_list        # any accounts.google.com / google.com entry?")+    print("     adom-bridge pup_open_window '" + safe + "'")+    print("     # sign-in page appeared and the vault has a Google login? submit it (reason-gated):")+    print("     adom-bridge pup_login '{\"sessionId\":\"gauth\",\"submit\":true,\"submitReason\":\"Google consent for adom-google\"}'")+    print("     # then pick the account + click Allow via pup_eval on the classic consent page:")+    print("     adom-bridge pup_eval '{\"sessionId\":\"gauth\",\"expression\":\"[...document.querySelectorAll(\\\"button\\\")].find(b=>/^(Allow|Continue)$/.test(b.innerText.trim()))?.click()\"}'")+    print("     Caveats: a FedCM \"Sign in with Google\" chooser rejects in-page clicks (the classic")+    print("     accounts.google.com consent page does not). NO Google login in the vault yet? Then")+    print("     the first pass is one interactive sign-in INSIDE that pup window: open it as above,")+    print("     toast the user with focus on it (below), they sign in once, and every later `auth`")+    print("     on this machine is silent. Do not read \"hands-free\" as \"always\".\n")+    print("   LANE B, ONE HUMAN CLICK (always works): surface the URL to the user as a CLICKABLE")+    print("   HYPERLINK in your reply. Hydrogen's browser picker lets them choose browser/profile.")+    print("   Do NOT put it in a code fence: a fence renders as plain text, the user has to")+    print("   copy-paste it by hand, and the picker never fires.\n")+    print("   LANE C, ONLY IF the Adom extension is installed in the target profile (check with")+    print("   `adom-bridge nbrowser_profiles`, require extensionInstalled:true):")+    print("     adom-bridge nbrowser_open_window '" + safe + "'")+    print("     # extension NOT installed there? Do not fall back to nbrowser_open_os_window: it is")+    print("     # extension-free and returns ok without confirming what rendered. Use lane B.\n")     # Foreground guidance. abe/nbrowser rightly defaults to BACKGROUND windows and the-    # foreground-etiquette skill says never steal focus — but OAuth consent is the one flow whose-    # entire purpose is a human physically clicking Allow. A silent background window plus a-    # taskbar flash is easy to miss, and a missed consent wastes the user's grant. So this is a-    # sanctioned exception: TELL THEM FIRST, then raise. Never raise a window silently.-    print("   TELLING THE USER (foreground exception — read before raising any window):")-    print("     Consent is the one flow where taking the foreground is justified: nothing")-    print("     progresses until a human clicks Allow. Announce it FIRST, never raise silently:\n")-    print("       adom-desktop notify_user '{\"title\":\"Google sign-in needed\",\"body\":\"Opening")-    print("         the consent page in your browser now - click Allow. You have ~60 min.\"}'")-    print("       # then raise that window (e.g. desktop_bring_to_front on its title)\n")+    # foreground-etiquette skill says never steal focus. OAuth consent is the one flow whose+    # entire purpose is a human physically clicking Allow, so this is a sanctioned exception:+    # TELL THEM, with a toast that lands the click ON the consent window, AFTER it exists.+    print("   TOASTING THE USER (lanes A-first-pass and C; the foreground exception):")+    print("     Consent is the one flow where taking the foreground is justified. Fire the toast")+    print("     AFTER the window exists (a toast that points at nothing is worse than none) and")+    print("     give it `focus` so the click raises the CONSENT window, not Adom Bridge:\n")+    print("       adom-bridge notify_user '{\"id\":\"gauth\",\"title\":\"Google sign-in needed\",")+    print("         \"body\":\"Click Allow in the window I just opened. You have ~60 min.\",")+    print("         \"scenario\":\"reminder\",\"buttons\":[{\"label\":\"Done\"},{\"label\":\"Cancel\"}],")+    print("         \"focus\":{\"titleContains\":\"Sign in - Google\"}}'")+    print("       # READ THE REPLY: require clickBehavior:\"focus-window\" and focus.resolved:true;")+    print("       # if it carries _couldHaveDoneBetter, do what it says (e.g. pass the hwnd from")+    print("       # the window verb's reply instead of a title). Then learn what they did:")+    print("       adom-bridge notify_response '{\"id\":\"gauth\"}'   # or add \"wait\":true above to block\n")     print("     GENTLER, prefer it when the user may be mid-task: leave the window in the")     print("     background and flash the taskbar (browser_alert_window) so they come to it when")-    print("     ready. The session survives a dropped socket now, so there is no rush either way.\n")+    print("     ready. The session survives a dropped socket, so there is no rush either way.\n")+    print("   IF GOOGLE SHOWS \"Sorry, something went wrong there. Try again.\": that is Google's")+    print("   generic first-load hiccup, not a scope or client problem. Stop this listener and")+    print("   re-run `adom-google auth` for a fresh URL; an earlier approval is recovered and")+    print("   nobody clicks Allow twice. This listener prints a line the moment the callback")+    print("   lands, so \"user clicked and Google failed\" (line printed, then an error) is")+    print("   distinguishable from \"user never clicked\" (still waiting).\n")+++def _auth_json_line(real_out, cfg, scopes, mode, via):+    """`auth --json`: the ONE machine-readable line an AI waits for (issue #9 §3)."""+    real_out.write(json.dumps({"ok": True, "authorized": True, "account": ACCOUNT, "mode": mode,+                               "via": via, "scopes": scopes, "config": CFG}) + "\n")+    real_out.flush()   def cmd_auth(a):+    # --json: every human-facing line (URL block, waiting notes) goes to STDERR and the ONLY+    # thing on stdout is one JSON line on success, so a waiter never has to poll `status`.+    as_json = getattr(a, "json", False)+    real_out = sys.stdout+    if as_json:+        sys.stdout = sys.stderr+    try:+        return _cmd_auth_impl(a, as_json, real_out)+    finally:+        sys.stdout = real_out+++def _cmd_auth_impl(a, as_json, real_out):     cfg = _maybe_load_raw() or {}     # FULL is the default (no first-run mode quiz — issue #2). `--safe` is an advanced opt-in     # for users who explicitly want the narrower create-only scopes. `--full` is a no-op kept@@ -1234,6 +1279,8 @@ def cmd_auth(a):     # Before minting a new sign-in, see whether an EARLIER one already completed while no CLI was     # listening. If so the user has already clicked Allow and must not be asked to do it twice.     if _pending_claim(cfg, scopes, mode, pr["gateway_url"]):+        if as_json:+            _auth_json_line(real_out, _maybe_load_raw() or cfg, scopes, mode, "recovered-approval")         return     state = secrets.token_urlsafe(24)     _pending_save(state, scopes, mode, pr["gateway_url"])@@ -1283,9 +1330,15 @@ def cmd_auth(a):         )     except Exception as e:         sys.exit(f"\nGateway flow failed: {e}\n"-                 f"If you saw a 'Session Expired' page or waited more than ~60 minutes, just\n"-                 f"re-run `adom-google auth`. If the gateway itself is down ({pr['gateway_url']}),\n"+                 f"If you saw a 'Session Expired' page, Google's generic 'Sorry, something went\n"+                 f"wrong there. Try again.', or waited more than ~60 minutes, just re-run\n"+                 f"`adom-google auth` (a fresh URL; an earlier approval is recovered, nobody clicks\n"+                 f"twice). If the gateway itself is down ({pr['gateway_url']}),\n"                  f"fallback: `adom-google auth --manual`.")+    # The callback landed. Say so BEFORE judging it, so an AI can tell "the user clicked and+    # Google returned an error" from "the user never clicked" (issue #9 §4).+    print(f"\n↳ Callback received from the gateway ({msg.get('type')}); the user reached the end of the consent flow.")+    sys.stdout.flush()     if msg.get("state") != state:         sys.exit("State mismatch from gateway — aborting.")     if msg.get("error") or (msg.get("type") == "callback" and not msg.get("code")):@@ -1297,11 +1350,15 @@ def cmd_auth(a):         _pending_clear()         print(f"\n✅ Authorized via gateway broker ({mode} mode). Token stored (0600); "               "client secret never touched this container.")+        via = "gateway-broker"     else:         _exchange_code(cfg, msg["code"], pr["gateway_redirect"], scopes, mode)         _pending_clear()         print(f"\n✅ Authorized via gateway ({mode} mode). Refresh token stored (0600).")+        via = "gateway-relay"     print("   Scopes: " + ", ".join(s.split("/")[-1] for s in scopes))+    if as_json:+        _auth_json_line(real_out, cfg, scopes, mode, via)   def _pending_path():@@ -1681,9 +1738,25 @@ def cmd_scopes(a):  def cmd_status(a):     cfg = _maybe_load_raw()-    if not cfg or not cfg.get("refresh_token"):-        print(SETUP_HINT)-        return+    authorized = bool(cfg and cfg.get("refresh_token"))+    if getattr(a, "json", False):+        # One object, one boolean, and the exit code agrees with it: a waiter can use either.+        try:+            pr = _provider()+        except SystemExit:+            pr = {}+        print(json.dumps({"authorized": authorized, "account": ACCOUNT, "config": CFG,+                          "mode": (cfg or {}).get("mode"), "scopes": (cfg or {}).get("scopes") or [],+                          "gateway": pr.get("gateway_url"), "app": pr.get("app"),+                          "hint": None if authorized else "not authorized: run `adom-google auth` (or `auth --json` to block until Allow is clicked)"}))+        sys.exit(0 if authorized else 1)+    if not authorized:+        # Failure text goes to STDERR and the exit code is 1. Before, `status` exited 0 and its+        # setup hint contained the words "authorized"/"FULL"/"access", so a naive+        # `until adom-google status | grep -qi authorized` fired instantly on the FAILURE+        # message (issue #9 §3). Waiters should use `auth --json` or `status --json` instead.+        sys.stderr.write(SETUP_HINT + "\n")+        sys.exit(1)     pr = _provider()     cid = cfg.get("client_id") or pr["client_id"] or "(none)"     src = "config" if cfg.get("client_id") else ("provider" if pr["client_id"] else "UNSET")@@ -1712,7 +1785,9 @@ AUTH (one time; one consent → long-lived refresh token, stored 0600):   adom-google auth --manual     fallback: desktop-client paste flow   adom-google auth-code '<code>'   finish the --manual flow   adom-google init <id> <secret>   set an explicit desktop OAuth client (for --manual)-  adom-google status            show access + what was REQUESTED at auth (never prints secrets)+  adom-google auth --json       tooling: guidance on stderr, ONE JSON line on stdout when authorized+  adom-google status [--json]   show access + what was REQUESTED at auth (never prints secrets).+                                Exit 1 when NOT authorized; --json gives authorized:true|false.   adom-google scopes [--json]   authoritative GRANTED scopes (live tokeninfo) + drift vs requested   adom-google scopes --require chat.spaces [--auto-widen]                                 gate for other tools: exit 2 + exact fix when a scope is missing;@@ -1926,7 +2001,7 @@ def main():         "Already authorized and just missing one capability? Do NOT re-run a bare `auth` -- use "         "`adom-google scopes --require <scope> --auto-widen`, which preserves what you already "         "hold. See granted-vs-requested with `adom-google scopes`."))-    s.add_argument("--manual", action="store_true", help="fallback when the gateway is unreachable: desktop-client copy/paste flow (finish with `auth-code '<code>'`)"); s.add_argument("--safe", action="store_true", help="advanced: request the NARROW create-only scope set (no delete-all); default is FULL"); s.add_argument("--full", action="store_true", help="Connector: request the FULL set incl. Gmail-read + all Drive (works for whitelisted testers now, everyone once CASA clears). No-op for the work client (already full)."); s.add_argument("--url-file", dest="url_file", help="also write the consent URL to this file (one clean line, for tooling)"); s.add_argument("--admin", action="store_true", help="ALSO request the Workspace ADMIN bundle (admin.directory.group + .group.member + .user.readonly + apps.groups.settings) so ONE consent covers Google Groups / distribution lists. Only a Workspace admin can grant it."); s.add_argument("--add-scope", dest="add_scopes", action="append", help="widen the scope set (repeatable; full URL or short tail like chat.spaces)"); s.set_defaults(fn=cmd_auth)+    s.add_argument("--manual", action="store_true", help="fallback when the gateway is unreachable: desktop-client copy/paste flow (finish with `auth-code '<code>'`)"); s.add_argument("--safe", action="store_true", help="advanced: request the NARROW create-only scope set (no delete-all); default is FULL"); s.add_argument("--full", action="store_true", help="Connector: request the FULL set incl. Gmail-read + all Drive (works for whitelisted testers now, everyone once CASA clears). No-op for the work client (already full)."); s.add_argument("--url-file", dest="url_file", help="also write the consent URL to this file (one clean line, for tooling)"); s.add_argument("--admin", action="store_true", help="ALSO request the Workspace ADMIN bundle (admin.directory.group + .group.member + .user.readonly + apps.groups.settings) so ONE consent covers Google Groups / distribution lists. Only a Workspace admin can grant it."); s.add_argument("--add-scope", dest="add_scopes", action="append", help="widen the scope set (repeatable; full URL or short tail like chat.spaces)"); s.add_argument("--json", action="store_true", help="AI/tooling mode: all guidance (consent URL, waiting notes) goes to STDERR; stdout carries exactly ONE JSON line on success ({\"ok\":true,\"authorized\":true,\"scopes\":[...]}) and the exit code is 0. auth already blocks until Allow is clicked, so this replaces polling `status`."); s.add_argument("--wait", action="store_true", help="accepted no-op: auth ALWAYS waits for the click (up to ~60 min); here so `auth --wait --json` reads naturally"); s.set_defaults(fn=cmd_auth)     s = sub.add_parser("scopes"); s.add_argument("--json", action="store_true"); s.add_argument("--require", nargs="+", help="scopes that MUST be granted (full URL or short tail); exit 2 + print the fix when short"); s.add_argument("--auto-widen", dest="auto_widen", action="store_true", help="with --require: re-run auth with the union instead of exiting"); s.add_argument("--url-file", dest="url_file", help="with --auto-widen: also write the consent URL here"); s.set_defaults(fn=cmd_scopes)     s = sub.add_parser("auth-code"); s.add_argument("code"); s.set_defaults(fn=cmd_auth_code)     s = sub.add_parser("init"); s.add_argument("client_id"); s.add_argument("client_secret"); s.set_defaults(fn=cmd_init)@@ -1936,7 +2011,7 @@ def main():     x.add_argument("--client-id", dest="client_id"); x.add_argument("--client-secret", dest="client_secret")     x.add_argument("--app"); x.set_defaults(fn=cmd_provider_set)     x = pvs.add_parser("show"); x.set_defaults(fn=cmd_provider_show)-    s = sub.add_parser("status"); s.set_defaults(fn=cmd_status)+    s = sub.add_parser("status"); s.add_argument("--json", action="store_true", help="one JSON object with authorized:true|false (+ account, mode, scopes, gateway); exit 1 when NOT authorized"); s.set_defaults(fn=cmd_status)     s = sub.add_parser("accounts"); s.add_argument("--json", action="store_true"); s.set_defaults(fn=cmd_accounts)     s = sub.add_parser("setup"); s.set_defaults(fn=cmd_setup)     s = sub.add_parser("connect-personal"); s.set_defaults(fn=cmd_connect_personal)
page.json+4−3
@@ -94,7 +94,7 @@   "skills": [],   "hero_path": "screenshots/hero-google-v5.png",   "hero_type": "image",-  "description": "Drive your whole Google Workspace from the terminal: Gmail, Contacts, Drive, Sheets, Docs, Slides, Calendar, Tasks, Google Chat, plus a generic `api` passthrough to hit ANY Google API. Connect a personal @gmail (or another org's Google account) with ZERO setup via Adom's hosted Connector (`adom-google connect-personal`), or bring your own token-broker gateway (`provider set`): Adom staff use the private adom-google-adom provider. One-click OAuth, no client secret on your machine.",+  "description": "Drive your whole Google Workspace from the terminal: Gmail, Contacts, Drive, Sheets, Docs, Slides, Calendar, Tasks, Google Chat, plus a generic `api` passthrough to hit ANY Google API. Open source, ships with NO OAuth provider baked in: point it at your own token-broker gateway (`provider set`), buy a managed one, or: Adom staff: install the private adom-google-adom provider. One-click OAuth, no client secret on your machine. First run asks safe mode (no delete) vs full mode (incl. delete).",   "keywords": [     "adom-google",     "google-workspace",@@ -118,6 +118,8 @@     "uninstall": "./uninstall.sh"   },   "files": [+    "bin/adom-google",+    "bin/adom-gmail",     "SKILL.md",     "skills/adom-google-onboarding/SKILL.md",     "README.md",@@ -126,8 +128,7 @@     "provider.example.json",     "install.sh",     "uninstall.sh",-    "package.json",-    "VERSION"+    "package.json"   ],   "org": "adom" }\ No newline at end of file