app
Adom Google
Public Made by Adomby adom
Drive your whole Google Workspace from the terminal, run by your AI. Read Gmail, write Google Docs, build Google Sheets and Google Slides, search Google Drive, manage Google Calendar and Google Tasks, and post to Google Chat, plus any Google API and a zero-setup personal Gmail.
← Commit history
v0.8.22: auth hints for issue #9 (pup lane, verifiable toast, status/auth --json, callback line, 'Something went wrong' recovery)
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" }