Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
doctor.ts13.8 KB · 285 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
// Doctor — one-shot public-readiness audit of a LIVE Gluecron instance.
//
// Certifies the surface an external AI platform needs: endpoint health,
// the OAuth provider (discovery, DCR, token-endpoint error semantics),
// and the MCP server (anonymous handshake, auth challenges per RFC 6750,
// authenticated tool calls, tool-manifest drift vs local code).
//
// Run:  bun run scripts/doctor.ts [--full] [--md]
// Env:  GLUECRON_HOST  target instance (default https://gluecron.com)
//       GLUECRON_PAT   optional PAT — enables the authenticated sections
//                      (without it they report SKIP, not FAIL)
// Exit: 0 iff every non-skipped check passes.
//
// Companion: scripts/agent-journey.ts exercises the full write path
// (push → PR → review → merge). Keep doctor read-only + residue-free,
// except one recognizably-named `doctor-*` OAuth client from the DCR
// round-trip (RFC 7592 delete isn't implemented).

import {
  runChecks,
  formatTable,
  CHECKS,
  type CheckResult,
} from "../src/lib/post-deploy-smoke";
import { defaultTools } from "../src/lib/mcp-tools";

const HOST = (process.env.GLUECRON_HOST || "https://gluecron.com").replace(/\/+$/, "");
const PAT = process.env.GLUECRON_PAT?.trim() || "";
const FULL = process.argv.includes("--full");
const MD = process.argv.includes("--md");

interface Row extends CheckResult {
  skipped?: boolean;
}

function row(name: string, ok: boolean, opts: Partial<Row> = {}): Row {
  return { name, url: opts.url ?? "", status: opts.status ?? 0, durationMs: opts.durationMs ?? 0, ok, ...(opts.error ? { error: opts.error } : {}), ...(opts.skipped ? { skipped: true } : {}) };
}

function skip(name: string, why: string): Row {
  return { name, url: "", status: 0, durationMs: 0, ok: true, skipped: true, error: why };
}

async function timed<T>(fn: () => Promise<T>): Promise<[T, number]> {
  const t0 = Date.now();
  const v = await fn();
  return [v, Date.now() - t0];
}

async function jget(path: string, headers: Record<string, string> = {}) {
  const res = await fetch(HOST + path, { headers });
  const text = await res.text();
  let json: any = null;
  try { json = JSON.parse(text); } catch { /* non-JSON */ }
  return { status: res.status, headers: res.headers, text, json };
}

async function jpost(path: string, body: unknown, headers: Record<string, string> = {}) {
  const res = await fetch(HOST + path, {
    method: "POST",
    headers: { "content-type": "application/json", ...headers },
    body: JSON.stringify(body),
  });
  const text = await res.text();
  let json: any = null;
  try { json = JSON.parse(text); } catch { /* non-JSON */ }
  return { status: res.status, headers: res.headers, text, json };
}

let rpcId = 0;
async function mcpRpc(method: string, params?: unknown, bearer?: string) {
  const headers: Record<string, string> = {};
  if (bearer) headers.authorization = `Bearer ${bearer}`;
  return jpost("/mcp", { jsonrpc: "2.0", id: ++rpcId, method, ...(params !== undefined ? { params } : {}) }, headers);
}

async function mcpCall(tool: string, args: Record<string, unknown>, bearer?: string) {
  return mcpRpc("tools/call", { name: tool, arguments: args }, bearer);
}

// ─── Section (a): endpoint smoke — the deploy-gate 15 ───────────────

async function sectionSmoke(): Promise<Row[]> {
  const summary = await runChecks({ baseUrl: HOST, fetchImpl: fetch as any, checks: CHECKS, log: () => undefined });
  return summary.results;
}

// ─── Section (b): OAuth provider surface ────────────────────────────

async function sectionOauth(): Promise<Row[]> {
  const out: Row[] = [];

  const [as, asMs] = await timed(() => jget("/.well-known/oauth-authorization-server"));
  const grants: string[] = as.json?.grant_types_supported ?? [];
  out.push(row("as-metadata advertises refresh_token", as.status === 200 && grants.includes("refresh_token"), {
    url: "/.well-known/oauth-authorization-server", status: as.status, durationMs: asMs,
    error: as.status !== 200 ? "not 200" : grants.includes("refresh_token") ? undefined : `grants: ${grants.join(",")}`,
  }));
  out.push(row("as-metadata has registration_endpoint", Boolean(as.json?.registration_endpoint), { status: as.status }));

  for (const p of ["/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"]) {
    const [pr, prMs] = await timed(() => jget(p));
    out.push(row(`protected-resource ${p.endsWith("mcp") ? "(mcp)" : "(root)"}`, pr.status === 200 && typeof pr.json?.resource === "string", { url: p, status: pr.status, durationMs: prMs }));
  }

  // DCR round-trip. Residue: one client row named doctor-<ts>.
  const [reg, regMs] = await timed(() =>
    jpost("/oauth/register", {
      client_name: `doctor-${new Date().toISOString().replace(/[:.]/g, "-")}`,
      redirect_uris: ["https://localhost/doctor-callback"],
      token_endpoint_auth_method: "none",
    })
  );
  const clientId: string | undefined = reg.json?.client_id;
  out.push(row("DCR round-trip returns client_id", (reg.status === 200 || reg.status === 201) && Boolean(clientId), {
    url: "/oauth/register", status: reg.status, durationMs: regMs, error: clientId ? undefined : reg.text.slice(0, 120),
  }));

  // Token endpoint error semantics (the signals refresh clients key off).
  if (clientId) {
    const badCode = await jpost("/oauth/token", { grant_type: "authorization_code", code: "garbage", client_id: clientId, redirect_uri: "https://localhost/doctor-callback", code_verifier: "x".repeat(43) });
    out.push(row("token: garbage code → invalid_grant", badCode.status === 400 && badCode.json?.error === "invalid_grant", { status: badCode.status, error: badCode.json?.error }));

    const badRefresh = await jpost("/oauth/token", { grant_type: "refresh_token", refresh_token: "glcr_garbage", client_id: clientId });
    out.push(row("token: garbage refresh → invalid_grant", badRefresh.status === 400 && badRefresh.json?.error === "invalid_grant", { status: badRefresh.status, error: badRefresh.json?.error }));

    const badGrant = await jpost("/oauth/token", { grant_type: "password", client_id: clientId });
    out.push(row("token: bogus grant_type → unsupported_grant_type", badGrant.status === 400 && badGrant.json?.error === "unsupported_grant_type", { status: badGrant.status, error: badGrant.json?.error }));
  } else {
    out.push(skip("token endpoint error semantics", "no client_id from DCR"));
  }

  return out;
}

// ─── Section (c): MCP anonymous surface + RFC 6750 challenges ───────

async function sectionMcpAnon(): Promise<Row[]> {
  const out: Row[] = [];

  const [init, initMs] = await timed(() => mcpRpc("initialize"));
  out.push(row("anonymous initialize → 200", init.status === 200 && Boolean(init.json?.result?.protocolVersion), { status: init.status, durationMs: initMs }));

  const [list, listMs] = await timed(() => mcpRpc("tools/list"));
  const liveNames: string[] = (list.json?.result?.tools ?? []).map((t: any) => t.name).sort();
  const localNames = Object.keys(defaultTools()).sort();
  const missing = localNames.filter((n) => !liveNames.includes(n));
  const extra = liveNames.filter((n) => !localNames.includes(n));
  out.push(row(`tools/list matches local manifest (${localNames.length} tools)`, list.status === 200 && missing.length === 0 && extra.length === 0, {
    status: list.status, durationMs: listMs,
    error: missing.length || extra.length ? `live missing: [${missing.join(",")}] live extra: [${extra.join(",")}]` : undefined,
  }));

  const anon = await mcpCall("gluecron_repo_search", { query: "x" });
  const anonWww = anon.headers.get("www-authenticate") || "";
  out.push(row("anonymous tools/call → 401 challenge (no error=)", anon.status === 401 && anonWww.includes("resource_metadata=") && !anonWww.includes("error="), {
    status: anon.status, error: anonWww ? undefined : "missing WWW-Authenticate",
  }));

  // Live proof of the 2026-07-14 fix: dead bearer must signal invalid_token
  // on the HANDSHAKE, not just tools/call — this is what breaks the
  // reconnect loop.
  for (const method of ["initialize", "tools/list"]) {
    const dead = await mcpRpc(method, undefined, "glct_deadbeef");
    const www = dead.headers.get("www-authenticate") || "";
    out.push(row(`dead bearer on ${method} → 401 invalid_token`, dead.status === 401 && www.includes('error="invalid_token"'), {
      status: dead.status, error: dead.status === 401 ? (www.includes('error="invalid_token"') ? undefined : "no invalid_token attr") : "not 401 (fix not deployed?)",
    }));
  }

  return out;
}

// ─── Section (d): MCP authenticated reads (needs GLUECRON_PAT) ──────

async function sectionMcpAuthed(): Promise<Row[]> {
  if (!PAT) return [skip("authenticated MCP reads", "GLUECRON_PAT not set")];
  const out: Row[] = [];

  const probes: Array<[string, Record<string, unknown>]> = [
    ["gluecron_search_repos", { query: "gluecron" }],
    ["gluecron_repo_health", { owner: "ccantynz", repo: "Gluecron.com" }],
    ["gluecron_list_prs", { owner: "ccantynz", repo: "Gluecron.com", state: "open" }],
  ];
  for (const [tool, args] of probes) {
    const [res, ms] = await timed(() => mcpCall(tool, args, PAT));
    const ok = res.status === 200 && !res.json?.error && !res.json?.result?.isError;
    out.push(row(`authed ${tool}`, ok, { status: res.status, durationMs: ms, error: ok ? undefined : JSON.stringify(res.json?.error ?? res.json?.result)?.slice(0, 140) }));
  }
  return out;
}

// ─── Section (e): --full read-only tool matrix ──────────────────────

// Canned args per read-only tool; write tools belong to agent-journey.ts.
const READ_MATRIX: Record<string, Record<string, unknown>> = {
  gluecron_repo_search: { query: "gluecron" },
  gluecron_search_repos: { query: "gluecron" },
  gluecron_repo_health: { owner: "ccantynz", repo: "Gluecron.com" },
  gluecron_list_prs: { owner: "ccantynz", repo: "Gluecron.com", state: "open" },
  gluecron_search_prs: { owner: "ccantynz", repo: "Gluecron.com", query: "fix" },
  gluecron_repo_list_issues: { owner: "ccantynz", repo: "Gluecron.com", state: "open" },
  gluecron_search_issues: { owner: "ccantynz", repo: "Gluecron.com", query: "deploy" },
  gluecron_list_tree: { owner: "ccantynz", repo: "Gluecron.com", ref: "main", path: "" },
  gluecron_repo_read_file: { owner: "ccantynz", repo: "Gluecron.com", path: "README.md" },
  gluecron_read_file: { owner: "ccantynz", repo: "Gluecron.com", path: "README.md" },
  gluecron_clone_url: { owner: "ccantynz", repo: "Gluecron.com" },
  gluecron_pr_status_summary: { owner: "ccantynz", repo: "Gluecron.com" },
};

async function sectionMatrix(): Promise<Row[]> {
  if (!FULL) return [skip("read-only tool matrix", "pass --full to run")];
  if (!PAT) return [skip("read-only tool matrix", "GLUECRON_PAT not set")];
  const out: Row[] = [];
  for (const [tool, args] of Object.entries(READ_MATRIX)) {
    let probeArgs = args;
    if (tool === "gluecron_pr_status_summary") {
      const prsRes = await mcpCall("gluecron_list_prs", { owner: "ccantynz", repo: "Gluecron.com", state: "all" }, PAT);
      const textPart = (prsRes.json?.result?.content ?? []).find((p: any) => p.type === "text")?.text;
      let listed: any;
      try { listed = JSON.parse(textPart); } catch { listed = textPart; }
      const number = listed?.prs?.[0]?.number;
      if (typeof number !== "number") {
        out.push(skip(`matrix ${tool}`, "no PRs to summarize"));
        continue;
      }
      probeArgs = { owner: "ccantynz", repo: "Gluecron.com", number };
    }
    const [res, ms] = await timed(() => mcpCall(tool, probeArgs, PAT));
    const ok = res.status === 200 && !res.json?.error && !res.json?.result?.isError;
    out.push(row(`matrix ${tool}`, ok, { status: res.status, durationMs: ms, error: ok ? undefined : JSON.stringify(res.json?.error ?? res.json?.result)?.slice(0, 140) }));
  }
  return out;
}

// ─── Main ───────────────────────────────────────────────────────────

async function main() {
  console.log(`# Gluecron doctor — ${HOST}${new Date().toISOString()}`);
  console.log(`# PAT: ${PAT ? "present" : "absent (authed sections will SKIP)"}  mode: ${FULL ? "full" : "standard"}\n`);

  const sections: Array<[string, () => Promise<Row[]>]> = [
    ["a. Endpoint smoke (deploy-gate 15)", sectionSmoke],
    ["b. OAuth provider surface", sectionOauth],
    ["c. MCP anonymous surface + RFC 6750", sectionMcpAnon],
    ["d. MCP authenticated reads", sectionMcpAuthed],
    ["e. Read-only tool matrix", sectionMatrix],
  ];

  let failed = 0;
  let skipped = 0;
  const mdLines: string[] = [];

  for (const [title, run] of sections) {
    let rows: Row[];
    try {
      rows = await run();
    } catch (err) {
      rows = [row(title, false, { error: `section crashed: ${(err as Error).message}` })];
    }
    const sectionFailed = rows.filter((r) => !r.ok && !r.skipped).length;
    const sectionSkipped = rows.filter((r) => r.skipped).length;
    failed += sectionFailed;
    skipped += sectionSkipped;

    console.log(`\n## ${title}${sectionFailed === 0 ? "PASS" : `FAIL (${sectionFailed})`}${sectionSkipped ? ` (${sectionSkipped} skipped)` : ""}\n`);
    console.log(formatTable(rows.map((r) => (r.skipped ? { ...r, ok: true, error: `SKIP: ${r.error}` } : r))));

    if (MD) {
      mdLines.push(`### ${title}`, "");
      for (const r of rows) mdLines.push(`- ${r.skipped ? "⬜" : r.ok ? "✅" : "🚫"} ${r.name}${r.error ? ` — ${r.error}` : ""}`);
      mdLines.push("");
    }
  }

  if (MD) console.log(`\n<!-- markdown -->\n${mdLines.join("\n")}`);

  console.log(`\n# doctor: ${failed === 0 ? "ALL GREEN" : `${failed} FAILURE(S)`}${skipped ? ` — ${skipped} skipped` : ""}`);
  process.exit(failed === 0 ? 0 : 1);
}

main().catch((err) => {
  console.error("doctor crashed:", err);
  process.exit(2);
});