← back to George Token Broker
george-token-broker: dry-run health-watchdog scaffold (process + token liveness, correct action per failure mode)
321b6cf576ac958953875fdf7ec38d32aeee053c · 2026-06-29 16:09:42 -0700 · Steve
Files touched
A .gitignoreA README.mdA com.steve.george-token-broker.plistA config.jsonA poll.mjs
Diff
commit 321b6cf576ac958953875fdf7ec38d32aeee053c
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jun 29 16:09:42 2026 -0700
george-token-broker: dry-run health-watchdog scaffold (process + token liveness, correct action per failure mode)
---
.gitignore | 10 +++
README.md | 54 +++++++++++++++
com.steve.george-token-broker.plist | 26 ++++++++
config.json | 8 +++
poll.mjs | 128 ++++++++++++++++++++++++++++++++++++
5 files changed, 226 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c36f991
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+data/latest.json
+data/poll.log
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..cbcbc7c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,54 @@
+# george-token-broker
+
+A **health-watchdog** for the local George Gmail agent (`com.steve.george-gmail`,
+`http://127.0.0.1:9850`). It does **not** re-mint tokens — minting a *user* OAuth
+refresh token requires interactive Google consent, so a headless re-minter is
+impossible. This is the "self-healing" piece of the Officer Idea Council's #1 idea
+(2026-06-29), reframed to what's actually buildable.
+
+## What it does (`poll.mjs`)
+Each run probes two layers and takes the **correct action per failure mode**:
+
+| Layer | Endpoint | Proves |
+|-------|----------|--------|
+| process liveness | `GET /health` (public) | George is up |
+| token liveness | `GET /api/profile?account=X` (auth) | the refresh token still works |
+
+- **process down / unreachable** → restart the launchd job + alert.
+- **token dead (`invalid_grant`)** → **alert only**. A restart reloads the *same*
+ dead token; the fix is interactive re-consent (and the real cure is publishing the
+ OAuth app to production — see the memo below).
+- **all ok** → quiet; records state.
+
+Writes `data/latest.json` (consumed by the meta-watchdog) and appends `data/poll.log`.
+
+## Safety — DRY_RUN by default
+`poll.mjs` defaults to **`DRY_RUN=true`**: it polls, classifies, and writes
+`latest.json`, but only **logs** `WOULD restart…` / `WOULD alert…` — it changes
+nothing live. The launchd plist is shipped **unloaded**. Nothing runs or acts until
+you choose to.
+
+```sh
+# one dry-run poll (safe — default):
+node poll.mjs
+
+# arm live alerts + restart:
+DRY_RUN=0 node poll.mjs
+
+# load the 15-min watchdog (still DRY_RUN unless you add DRY_RUN=0 to the plist):
+launchctl bootstrap gui/$(id -u) ~/Projects/george-token-broker/com.steve.george-token-broker.plist
+launchctl bootout gui/$(id -u) com.steve.george-token-broker # disable
+tail -f /tmp/george-token-broker.log
+```
+
+## The real root fix (gated — not in this repo's scope)
+This watchdog makes the next break **loud, not silent**. It does **not** fix the
+root cause — the ~weekly refresh-token expiry from the OAuth app being in Google
+"Testing" mode. That cure is one Google Cloud console change (publish app to "In
+production"), documented with full plan + officer sign-off in:
+
+`~/.claude/yolo-queue/pending-approval/george-oauth-durable-fix-2026-06-29.md`
+
+## Alert channel
+Fallback that does **not** depend on George (George may be the thing down): CNCP
+parking-lot `POST 127.0.0.1:3333/api/parking-lot`. Extend in `alert()` as needed.
diff --git a/com.steve.george-token-broker.plist b/com.steve.george-token-broker.plist
new file mode 100644
index 0000000..14e64c7
--- /dev/null
+++ b/com.steve.george-token-broker.plist
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<!-- George token-broker watchdog. NOT loaded by default — Steve loads it explicitly.
+ Runs in DRY_RUN by default (no env set => poll.mjs defaults DRY_RUN=true).
+ To arm live alerts/restart, add an EnvironmentVariables dict with DRY_RUN=0. -->
+<plist version="1.0">
+<dict>
+ <key>Label</key>
+ <string>com.steve.george-token-broker</string>
+ <key>ProgramArguments</key>
+ <array>
+ <string>/usr/local/bin/node</string>
+ <string>/Users/stevestudio2/Projects/george-token-broker/poll.mjs</string>
+ </array>
+ <key>WorkingDirectory</key>
+ <string>/Users/stevestudio2/Projects/george-token-broker</string>
+ <key>StartInterval</key>
+ <integer>900</integer>
+ <key>RunAtLoad</key>
+ <true/>
+ <key>StandardOutPath</key>
+ <string>/tmp/george-token-broker.log</string>
+ <key>StandardErrorPath</key>
+ <string>/tmp/george-token-broker.log</string>
+</dict>
+</plist>
diff --git a/config.json b/config.json
new file mode 100644
index 0000000..956892a
--- /dev/null
+++ b/config.json
@@ -0,0 +1,8 @@
+{
+ "george_base": "http://127.0.0.1:9850",
+ "auth": "admin:",
+ "accounts": ["steve-office", "info", "agentabrams", "steve-personal"],
+ "launchd_label": "com.steve.george-gmail",
+ "timeout_ms": 8000,
+ "cncp_base": "http://127.0.0.1:3333"
+}
diff --git a/poll.mjs b/poll.mjs
new file mode 100644
index 0000000..b99c46a
--- /dev/null
+++ b/poll.mjs
@@ -0,0 +1,128 @@
+#!/usr/bin/env node
+// george-token-broker / poll.mjs
+// Health-watchdog for the local George Gmail agent (NOT a token re-minter — minting a
+// USER refresh token needs interactive consent, so headless re-mint is impossible).
+//
+// Each run: probe George's /health (process liveness) + every account's /api/profile
+// (refresh-token liveness), classify, write data/latest.json for the meta-watchdog,
+// append data/poll.log. On failure it takes the CORRECT action per failure mode:
+// • process down / unreachable -> WOULD restart the launchd job + alert
+// • token dead (invalid_grant) -> WOULD alert ONLY (a restart reloads the same dead
+// token; this needs interactive re-consent)
+// • all ok -> quiet, just records state
+//
+// DRY_RUN (default true) logs "WOULD: ..." and changes NOTHING live. Set DRY_RUN=0 to
+// actually restart / alert. Full plan + gated root-fix:
+// ~/.claude/yolo-queue/pending-approval/george-oauth-durable-fix-2026-06-29.md
+
+import fs from 'node:fs';
+import { execFile } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+import path from 'node:path';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const cfg = JSON.parse(fs.readFileSync(path.join(HERE, 'config.json'), 'utf8'));
+const DRY_RUN = process.env.DRY_RUN !== '0'; // default true — safe
+const nowISO = () => new Date().toISOString();
+const authHeader = 'Basic ' + Buffer.from(cfg.auth).toString('base64');
+
+function logLine(s) {
+ const line = `[${nowISO()}] ${s}`;
+ console.log(line);
+ try { fs.appendFileSync(path.join(HERE, 'data', 'poll.log'), line + '\n'); } catch {}
+}
+
+async function fetchJSON(url, opts = {}) {
+ const ctrl = new AbortController();
+ const t = setTimeout(() => ctrl.abort(), cfg.timeout_ms);
+ try {
+ const r = await fetch(url, { ...opts, signal: ctrl.signal });
+ const text = await r.text();
+ let body; try { body = JSON.parse(text); } catch { body = { _raw: text }; }
+ return { ok: r.ok, status: r.status, body, text };
+ } catch (e) {
+ return { ok: false, status: 0, body: null, text: String(e && e.message || e) };
+ } finally { clearTimeout(t); }
+}
+
+// Classify one account probe -> 'ok' | 'token_dead' | 'error'
+function classify(res) {
+ if (res.ok && res.body && res.body.emailAddress) return { state: 'ok', detail: res.body.emailAddress };
+ const blob = (res.text || '') + JSON.stringify(res.body || {});
+ if (/invalid_grant|Token has been expired or revoked|invalid_request.*refresh/i.test(blob))
+ return { state: 'token_dead', detail: 'invalid_grant — refresh token expired/revoked (needs re-consent)' };
+ return { state: 'error', detail: `http ${res.status}: ${blob.slice(0, 160)}` };
+}
+
+function run(cmd, args) {
+ return new Promise((resolve) => {
+ execFile(cmd, args, { timeout: 15000 }, (err, stdout, stderr) =>
+ resolve({ err: err ? String(err.message) : null, stdout, stderr }));
+ });
+}
+
+async function restartGeorge() {
+ const label = cfg.launchd_label;
+ const target = `gui/${process.getuid()}/${label}`;
+ if (DRY_RUN) { logLine(`WOULD restart launchd job: launchctl kickstart -k ${target}`); return; }
+ logLine(`RESTART launchd job: ${target}`);
+ const r = await run('launchctl', ['kickstart', '-k', target]);
+ logLine(`restart result: err=${r.err || 'none'}`);
+}
+
+async function alert(summary, detail) {
+ if (DRY_RUN) { logLine(`WOULD alert -> "${summary}"`); return; }
+ // Fallback chain that does NOT depend on George (George may be the thing down):
+ // 1) CNCP parking-lot. 2) terminal-notifier if present. Never throws.
+ try {
+ await fetchJSON(`${cfg.cncp_base}/api/parking-lot`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ url: 'george-token-broker', note: `⚠ George: ${summary} — ${detail}` })
+ });
+ logLine(`alert -> CNCP parking-lot posted`);
+ } catch (e) { logLine(`alert CNCP failed: ${e}`); }
+}
+
+(async () => {
+ fs.mkdirSync(path.join(HERE, 'data'), { recursive: true });
+ const health = await fetchJSON(`${cfg.george_base}/health`);
+ const processUp = health.ok && health.body && health.body.status === 'ok';
+
+ const accounts = {};
+ if (processUp) {
+ for (const acct of cfg.accounts) {
+ const res = await fetchJSON(`${cfg.george_base}/api/profile?account=${encodeURIComponent(acct)}`,
+ { headers: { Authorization: authHeader } });
+ accounts[acct] = classify(res);
+ }
+ }
+
+ const deadTokens = Object.entries(accounts).filter(([, v]) => v.state === 'token_dead').map(([k]) => k);
+ const errored = Object.entries(accounts).filter(([, v]) => v.state === 'error').map(([k]) => k);
+ let overall = 'ok';
+ if (!processUp) overall = 'process_down';
+ else if (deadTokens.length) overall = 'token_dead';
+ else if (errored.length) overall = 'degraded';
+
+ const state = {
+ ts: nowISO(), overall, processUp,
+ uptime_s: processUp ? health.body.uptime : null,
+ accounts, deadTokens, errored, dry_run: DRY_RUN
+ };
+ fs.writeFileSync(path.join(HERE, 'data', 'latest.json'), JSON.stringify(state, null, 2));
+ logLine(`overall=${overall} processUp=${processUp} dead=[${deadTokens}] err=[${errored}]`);
+
+ // Correct action per failure mode:
+ if (!processUp) {
+ await restartGeorge();
+ await alert('process down/unreachable', health.text || 'no /health response');
+ } else if (deadTokens.length) {
+ // Restart will NOT help a dead refresh token — alert only.
+ await alert(`refresh token dead for ${deadTokens.join(', ')}`,
+ 'Publish OAuth app to production + re-consent. See pending-approval/george-oauth-durable-fix-2026-06-29.md');
+ } else if (errored.length) {
+ await alert(`probe error for ${errored.join(', ')}`, JSON.stringify(accounts));
+ }
+
+ process.exit(overall === 'ok' ? 0 : 3);
+})();
(oldest)
·
back to George Token Broker
·
george-token-broker: add pre-emptive 6-day token-age WARN be a14768c →