[object Object]

← 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

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 →