mobygate 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +207 -0
- package/LICENSE +21 -0
- package/README.md +429 -0
- package/bin/mobygate.js +443 -0
- package/index.html +805 -0
- package/launchd/ai.mobygate.auth-refresh.plist +83 -0
- package/lib/ascii.js +108 -0
- package/lib/config.js +131 -0
- package/lib/dashboard-bus.js +158 -0
- package/lib/platform.js +584 -0
- package/lib/session-store.js +112 -0
- package/mcp-inspect.mjs +186 -0
- package/package.json +62 -0
- package/scripts/auth-helper.js +198 -0
- package/scripts/auth-refresh.js +41 -0
- package/scripts/auth-status.js +36 -0
- package/server.js +1076 -0
package/mcp-inspect.mjs
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mcp-inspect.mjs — raw MCP tool response inspector
|
|
4
|
+
*
|
|
5
|
+
* Purpose: diagnose whether an MCP server (e.g. Paper, Pencil) actually
|
|
6
|
+
* returns binary image content, or whether the image is being dropped by
|
|
7
|
+
* a client-side bridge (e.g. Hermes) during normalization.
|
|
8
|
+
*
|
|
9
|
+
* This connects directly to the MCP server over stdio, bypassing Hermes
|
|
10
|
+
* entirely. If the tool returns image content here, the problem is
|
|
11
|
+
* client-side; if it returns empty, the problem is in the MCP server.
|
|
12
|
+
*
|
|
13
|
+
* USAGE
|
|
14
|
+
* # List available tools
|
|
15
|
+
* node mcp-inspect.mjs --cmd "<server-exe>" --args '["<arg1>",...]' --list
|
|
16
|
+
*
|
|
17
|
+
* # Call a tool and dump raw response
|
|
18
|
+
* node mcp-inspect.mjs --cmd "<server-exe>" --args '["<arg1>",...]' \
|
|
19
|
+
* --tool <tool-name> --params '{"key":"value"}'
|
|
20
|
+
*
|
|
21
|
+
* # Env vars for the child
|
|
22
|
+
* node mcp-inspect.mjs --cmd "..." --env '{"API_KEY":"foo"}' --tool ...
|
|
23
|
+
*
|
|
24
|
+
* EXAMPLE (hypothetical Paper MCP server)
|
|
25
|
+
* node mcp-inspect.mjs \
|
|
26
|
+
* --cmd "npx" --args '["-y","@paper/mcp-server"]' \
|
|
27
|
+
* --tool get_screenshot --params '{"artboard":"WL-0"}'
|
|
28
|
+
*
|
|
29
|
+
* OUTPUT
|
|
30
|
+
* - Summary of content blocks by type, with sizes
|
|
31
|
+
* - First/last 60 chars of any base64 image payload (to confirm bytes are real)
|
|
32
|
+
* - Full raw JSON response (pretty-printed) at the end
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
36
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
37
|
+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
38
|
+
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
39
|
+
|
|
40
|
+
// ---------- Arg parsing ----------
|
|
41
|
+
|
|
42
|
+
function parseArgs(argv) {
|
|
43
|
+
const out = { list: false, params: {}, args: [], env: {}, transport: 'http' };
|
|
44
|
+
for (let i = 2; i < argv.length; i++) {
|
|
45
|
+
const a = argv[i];
|
|
46
|
+
const next = argv[i + 1];
|
|
47
|
+
switch (a) {
|
|
48
|
+
case '--cmd': out.cmd = next; i++; break;
|
|
49
|
+
case '--args': out.args = JSON.parse(next); i++; break;
|
|
50
|
+
case '--env': out.env = JSON.parse(next); i++; break;
|
|
51
|
+
case '--url': out.url = next; i++; break;
|
|
52
|
+
case '--transport': out.transport = next; i++; break; // http | sse
|
|
53
|
+
case '--tool': out.tool = next; i++; break;
|
|
54
|
+
case '--params': out.params = JSON.parse(next); i++; break;
|
|
55
|
+
case '--list': out.list = true; break;
|
|
56
|
+
case '--help':
|
|
57
|
+
case '-h':
|
|
58
|
+
printUsage();
|
|
59
|
+
process.exit(0);
|
|
60
|
+
default:
|
|
61
|
+
console.error(`Unknown arg: ${a}`);
|
|
62
|
+
printUsage();
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!out.cmd && !out.url) { console.error('ERROR: provide --cmd (stdio) or --url (http/sse)'); printUsage(); process.exit(1); }
|
|
67
|
+
if (!out.list && !out.tool) { console.error('ERROR: provide --tool <name> or --list'); process.exit(1); }
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function printUsage() {
|
|
72
|
+
console.error(`
|
|
73
|
+
Usage: node mcp-inspect.mjs --cmd "<exe>" [--args '["..."]'] [--env '{...}'] \\
|
|
74
|
+
(--list | --tool <name> [--params '{...}'])
|
|
75
|
+
|
|
76
|
+
Connects to an MCP server over stdio and dumps the raw response payload.
|
|
77
|
+
Useful to verify whether binary image data is reaching the MCP boundary
|
|
78
|
+
before any client-side normalization (e.g. Hermes) strips it.
|
|
79
|
+
`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------- Content-block summary ----------
|
|
83
|
+
|
|
84
|
+
function summarizeContent(content) {
|
|
85
|
+
if (!Array.isArray(content)) return `content is not an array: ${typeof content}`;
|
|
86
|
+
const rows = [];
|
|
87
|
+
for (let i = 0; i < content.length; i++) {
|
|
88
|
+
const block = content[i];
|
|
89
|
+
const type = block?.type || 'unknown';
|
|
90
|
+
if (type === 'text') {
|
|
91
|
+
const text = block.text || '';
|
|
92
|
+
rows.push(` [${i}] text — ${text.length} chars: ${JSON.stringify(text.slice(0, 80))}${text.length > 80 ? '…' : ''}`);
|
|
93
|
+
} else if (type === 'image') {
|
|
94
|
+
const data = block.data || '';
|
|
95
|
+
const mime = block.mimeType || '?';
|
|
96
|
+
const approxBytes = Math.floor(data.length * 0.75); // base64 → bytes
|
|
97
|
+
const head = data.slice(0, 60);
|
|
98
|
+
const tail = data.slice(-20);
|
|
99
|
+
rows.push(` [${i}] image — mime=${mime}, base64=${data.length} chars (~${approxBytes} bytes)`);
|
|
100
|
+
rows.push(` head: ${head}${data.length > 80 ? `…${tail}` : ''}`);
|
|
101
|
+
} else if (type === 'resource') {
|
|
102
|
+
rows.push(` [${i}] resource — uri=${block.resource?.uri || '?'}`);
|
|
103
|
+
} else {
|
|
104
|
+
rows.push(` [${i}] ${type} — ${JSON.stringify(block).slice(0, 140)}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return rows.join('\n') || ' (empty)';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------- Main ----------
|
|
111
|
+
|
|
112
|
+
async function main() {
|
|
113
|
+
const opts = parseArgs(process.argv);
|
|
114
|
+
|
|
115
|
+
let transport;
|
|
116
|
+
if (opts.url) {
|
|
117
|
+
console.log(`[${opts.transport}] ${opts.url}`);
|
|
118
|
+
const url = new URL(opts.url);
|
|
119
|
+
transport = opts.transport === 'sse'
|
|
120
|
+
? new SSEClientTransport(url)
|
|
121
|
+
: new StreamableHTTPClientTransport(url);
|
|
122
|
+
} else {
|
|
123
|
+
console.log(`[spawn] ${opts.cmd} ${opts.args.map((a) => JSON.stringify(a)).join(' ')}`);
|
|
124
|
+
transport = new StdioClientTransport({
|
|
125
|
+
command: opts.cmd,
|
|
126
|
+
args: opts.args,
|
|
127
|
+
env: { ...process.env, ...opts.env },
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const client = new Client(
|
|
132
|
+
{ name: 'mcp-inspect', version: '0.1.0' },
|
|
133
|
+
{ capabilities: {} }
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
await client.connect(transport);
|
|
138
|
+
console.log('[connected]');
|
|
139
|
+
|
|
140
|
+
if (opts.list) {
|
|
141
|
+
const res = await client.listTools();
|
|
142
|
+
console.log(`\n=== Tools (${res.tools?.length || 0}) ===`);
|
|
143
|
+
for (const t of res.tools || []) {
|
|
144
|
+
console.log(` ${t.name} — ${t.description?.slice(0, 80) || '(no description)'}`);
|
|
145
|
+
}
|
|
146
|
+
console.log('\n--- raw listTools response ---');
|
|
147
|
+
console.log(JSON.stringify(res, null, 2));
|
|
148
|
+
} else {
|
|
149
|
+
console.log(`\n[call] tool=${opts.tool} params=${JSON.stringify(opts.params)}`);
|
|
150
|
+
const started = Date.now();
|
|
151
|
+
const res = await client.callTool({ name: opts.tool, arguments: opts.params });
|
|
152
|
+
const dur = Date.now() - started;
|
|
153
|
+
|
|
154
|
+
console.log(`\n=== Response (${dur}ms) ===`);
|
|
155
|
+
console.log(`isError: ${res.isError === true ? 'YES' : 'no'}`);
|
|
156
|
+
console.log(`content blocks: ${Array.isArray(res.content) ? res.content.length : '(not an array)'}`);
|
|
157
|
+
if (Array.isArray(res.content)) {
|
|
158
|
+
const byType = {};
|
|
159
|
+
for (const b of res.content) byType[b.type || 'unknown'] = (byType[b.type || 'unknown'] || 0) + 1;
|
|
160
|
+
console.log(`by type: ${JSON.stringify(byType)}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
console.log('\n--- content summary ---');
|
|
164
|
+
console.log(summarizeContent(res.content));
|
|
165
|
+
|
|
166
|
+
console.log('\n--- raw response JSON ---');
|
|
167
|
+
// Truncate image base64 in the pretty-print to keep output readable;
|
|
168
|
+
// we already showed head/tail above.
|
|
169
|
+
const safe = JSON.parse(JSON.stringify(res, (key, val) => {
|
|
170
|
+
if (key === 'data' && typeof val === 'string' && val.length > 200) {
|
|
171
|
+
return `${val.slice(0, 80)}…[${val.length - 100} chars elided]…${val.slice(-20)}`;
|
|
172
|
+
}
|
|
173
|
+
return val;
|
|
174
|
+
}));
|
|
175
|
+
console.log(JSON.stringify(safe, null, 2));
|
|
176
|
+
}
|
|
177
|
+
} catch (e) {
|
|
178
|
+
console.error(`\n[ERROR] ${e.message}`);
|
|
179
|
+
if (e.stack) console.error(e.stack.split('\n').slice(0, 4).join('\n'));
|
|
180
|
+
process.exit(1);
|
|
181
|
+
} finally {
|
|
182
|
+
try { await client.close(); } catch {}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mobygate",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "OpenAI-compatible local proxy for Claude Max. The Möbius-strip gateway: OpenAI shape in, Claude Max out.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "server.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mobygate": "./bin/mobygate.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node server.js",
|
|
12
|
+
"dev": "node --watch server.js",
|
|
13
|
+
"up": "npm install && node server.js",
|
|
14
|
+
"auth:status": "node scripts/auth-status.js",
|
|
15
|
+
"auth:status:quick": "node scripts/auth-status.js --quick",
|
|
16
|
+
"auth:refresh": "node scripts/auth-refresh.js"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.112",
|
|
20
|
+
"express": "^5.1.0",
|
|
21
|
+
"js-yaml": "^4.1.1",
|
|
22
|
+
"uuid": "^11.1.0"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/khnfrhn/mobygate.git"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/khnfrhn/mobygate#readme",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/khnfrhn/mobygate/issues"
|
|
34
|
+
},
|
|
35
|
+
"author": "Farhan Khan",
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"keywords": [
|
|
38
|
+
"claude",
|
|
39
|
+
"claude-max",
|
|
40
|
+
"anthropic",
|
|
41
|
+
"openai-compatible",
|
|
42
|
+
"proxy",
|
|
43
|
+
"gateway",
|
|
44
|
+
"llm",
|
|
45
|
+
"ai",
|
|
46
|
+
"agent-sdk",
|
|
47
|
+
"localhost",
|
|
48
|
+
"dashboard"
|
|
49
|
+
],
|
|
50
|
+
"files": [
|
|
51
|
+
"bin",
|
|
52
|
+
"lib",
|
|
53
|
+
"scripts",
|
|
54
|
+
"launchd",
|
|
55
|
+
"server.js",
|
|
56
|
+
"index.html",
|
|
57
|
+
"mcp-inspect.mjs",
|
|
58
|
+
"README.md",
|
|
59
|
+
"CHANGELOG.md",
|
|
60
|
+
"LICENSE"
|
|
61
|
+
]
|
|
62
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth helper — bridges the proxy to Claude Code's CLI auth machinery.
|
|
3
|
+
*
|
|
4
|
+
* The Claude Agent SDK reads OAuth credentials from the macOS keychain
|
|
5
|
+
* ("Claude Code-credentials") via the bundled CLI. The SDK is supposed to
|
|
6
|
+
* silently refresh the access token on 401, but in practice it sometimes
|
|
7
|
+
* eats the refresh path and surfaces the 401 to the caller. This module
|
|
8
|
+
* provides three things:
|
|
9
|
+
*
|
|
10
|
+
* getAuthStatus() — shells `claude auth status --json` (loggedIn,
|
|
11
|
+
* authMethod, email, etc.). The CLI does NOT report
|
|
12
|
+
* token expiry directly, so we treat "loggedIn:true"
|
|
13
|
+
* as necessary-but-not-sufficient and pair it with
|
|
14
|
+
* a real probe when the caller needs verification.
|
|
15
|
+
*
|
|
16
|
+
* forceRefresh() — fires a 1-token `claude -p` probe. If the access
|
|
17
|
+
* token is expired the CLI will silently refresh
|
|
18
|
+
* using the still-valid refresh token. Returns
|
|
19
|
+
* { ok, refreshed, durationMs, output } so callers
|
|
20
|
+
* can log loudly.
|
|
21
|
+
*
|
|
22
|
+
* runWithAuthRetry() — wrap an SDK `query()` consumer. On 401-shaped
|
|
23
|
+
* errors, call forceRefresh() and retry the
|
|
24
|
+
* producer once. Idempotent because we only retry
|
|
25
|
+
* the failure that comes BEFORE the first chunk
|
|
26
|
+
* is yielded — we never replay mid-stream.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { spawn } from 'child_process';
|
|
30
|
+
|
|
31
|
+
// Default to the bare binary name so PATH lookup works on every platform.
|
|
32
|
+
// - macOS/Linux: typically ~/.local/bin/claude (on PATH after standard install)
|
|
33
|
+
// - Windows: claude.cmd or claude.exe on PATH after MSI / `npm install -g`
|
|
34
|
+
// If yours isn't on PATH, set CLAUDE_BIN to an absolute path.
|
|
35
|
+
const CLAUDE_BIN = process.env.CLAUDE_BIN || 'claude';
|
|
36
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
37
|
+
const PROBE_TIMEOUT_MS = 30_000;
|
|
38
|
+
|
|
39
|
+
function runClaude(args, { timeoutMs = PROBE_TIMEOUT_MS, input } = {}) {
|
|
40
|
+
return new Promise((resolve) => {
|
|
41
|
+
// On Windows, spawn can't execute .cmd/.bat shim wrappers without a
|
|
42
|
+
// shell. Rather than `shell: true` + args-array (deprecated in Node
|
|
43
|
+
// 20+ as DEP0190), we invoke cmd.exe explicitly with a quoted command
|
|
44
|
+
// string we build ourselves.
|
|
45
|
+
let proc;
|
|
46
|
+
if (IS_WINDOWS) {
|
|
47
|
+
const quote = (s) => /[\s"]/.test(s) ? `"${String(s).replace(/"/g, '""')}"` : String(s);
|
|
48
|
+
const cmdline = [quote(CLAUDE_BIN), ...args.map(quote)].join(' ');
|
|
49
|
+
proc = spawn('cmd.exe', ['/c', cmdline], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
50
|
+
} else {
|
|
51
|
+
proc = spawn(CLAUDE_BIN, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
52
|
+
}
|
|
53
|
+
let stdout = '';
|
|
54
|
+
let stderr = '';
|
|
55
|
+
let timedOut = false;
|
|
56
|
+
const timer = setTimeout(() => {
|
|
57
|
+
timedOut = true;
|
|
58
|
+
proc.kill('SIGKILL');
|
|
59
|
+
}, timeoutMs);
|
|
60
|
+
proc.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
61
|
+
proc.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
62
|
+
proc.on('error', (err) => {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
resolve({ code: -1, stdout, stderr: stderr + '\n' + err.message, timedOut });
|
|
65
|
+
});
|
|
66
|
+
proc.on('close', (code) => {
|
|
67
|
+
clearTimeout(timer);
|
|
68
|
+
resolve({ code, stdout, stderr, timedOut });
|
|
69
|
+
});
|
|
70
|
+
if (input != null) {
|
|
71
|
+
proc.stdin.write(input);
|
|
72
|
+
proc.stdin.end();
|
|
73
|
+
} else {
|
|
74
|
+
proc.stdin.end();
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function getAuthStatus() {
|
|
80
|
+
const { code, stdout, stderr } = await runClaude(['auth', 'status', '--json'], { timeoutMs: 10_000 });
|
|
81
|
+
if (code !== 0) {
|
|
82
|
+
return { ok: false, loggedIn: false, error: stderr.trim() || `claude auth status exited ${code}` };
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(stdout);
|
|
86
|
+
return { ok: true, ...parsed };
|
|
87
|
+
} catch (e) {
|
|
88
|
+
return { ok: false, loggedIn: false, error: `failed to parse claude auth status output: ${e.message}` };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Force the SDK/CLI to refresh its access token.
|
|
94
|
+
*
|
|
95
|
+
* Strategy: fire a 1-token print-mode query. If the access token is expired,
|
|
96
|
+
* the CLI's auth layer will detect the 401 from Anthropic and silently swap
|
|
97
|
+
* in a fresh access token via the refresh_token grant. Then it succeeds.
|
|
98
|
+
* If the token was already fresh, this just returns instantly.
|
|
99
|
+
*
|
|
100
|
+
* We can't observe "did a refresh actually happen" from outside — the CLI
|
|
101
|
+
* doesn't print that. We only know "after this, the token is valid OR we
|
|
102
|
+
* couldn't make it valid".
|
|
103
|
+
*/
|
|
104
|
+
export async function forceRefresh() {
|
|
105
|
+
const start = Date.now();
|
|
106
|
+
// -p forces print-mode round-trip. --max-turns 1 caps it at a single turn.
|
|
107
|
+
// We pipe '' to stdin to avoid hanging on TTY detection.
|
|
108
|
+
const { code, stdout, stderr, timedOut } = await runClaude(
|
|
109
|
+
['-p', 'reply with the single word OK', '--max-turns', '1'],
|
|
110
|
+
{ timeoutMs: 30_000, input: '' }
|
|
111
|
+
);
|
|
112
|
+
const durationMs = Date.now() - start;
|
|
113
|
+
if (timedOut) {
|
|
114
|
+
return { ok: false, refreshed: false, durationMs, error: 'probe timed out — refresh may still be in flight' };
|
|
115
|
+
}
|
|
116
|
+
if (code !== 0) {
|
|
117
|
+
return { ok: false, refreshed: false, durationMs, error: stderr.trim() || `probe exited ${code}`, output: stdout.trim() };
|
|
118
|
+
}
|
|
119
|
+
return { ok: true, refreshed: true, durationMs, output: stdout.trim() };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* True if the error looks like an auth failure from Anthropic.
|
|
124
|
+
*/
|
|
125
|
+
export function is401Error(err) {
|
|
126
|
+
if (!err) return false;
|
|
127
|
+
const msg = err.message || String(err);
|
|
128
|
+
if (err.status === 401 || err.statusCode === 401) return true;
|
|
129
|
+
if (/\b401\b/.test(msg)) return true;
|
|
130
|
+
if (/authentication_error|Invalid authentication credentials|invalid_grant|unauthorized/i.test(msg)) return true;
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* True if a completion result TEXT looks like an auth failure the SDK
|
|
136
|
+
* surfaced inline instead of throwing. Long-running proxies hit this
|
|
137
|
+
* after ~8h when the SDK's cached creds expire and Anthropic returns 401,
|
|
138
|
+
* but the SDK emits the error body as result text rather than an exception.
|
|
139
|
+
*
|
|
140
|
+
* Pattern seen in practice:
|
|
141
|
+
* "Failed to authenticate. API Error: 401 {\"type\":\"error\",..."
|
|
142
|
+
*/
|
|
143
|
+
export function isAuthFailureText(text) {
|
|
144
|
+
if (!text || typeof text !== 'string') return false;
|
|
145
|
+
if (/Failed to authenticate.*401/i.test(text)) return true;
|
|
146
|
+
if (/"type"\s*:\s*"authentication_error"/i.test(text)) return true;
|
|
147
|
+
if (/Invalid authentication credentials/i.test(text)) return true;
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Thrown when we detect an auth failure in result-message text so the
|
|
152
|
+
// outer runWithAuthRetry wrapper treats it the same as a real 401 exception.
|
|
153
|
+
export class AuthFailureInResultText extends Error {
|
|
154
|
+
constructor(text) {
|
|
155
|
+
super(`Auth failure surfaced as result text: ${text.slice(0, 160)}`);
|
|
156
|
+
this.name = 'AuthFailureInResultText';
|
|
157
|
+
this.statusCode = 401;
|
|
158
|
+
this.resultText = text;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Wrap an SDK query() consumer with one-shot auth retry.
|
|
164
|
+
*
|
|
165
|
+
* Usage:
|
|
166
|
+
* await runWithAuthRetry({
|
|
167
|
+
* attempt: async () => {
|
|
168
|
+
* for await (const m of query({...})) { ... }
|
|
169
|
+
* },
|
|
170
|
+
* onRefreshing: () => console.log('refreshing...'),
|
|
171
|
+
* onRetry: () => console.log('retrying...'),
|
|
172
|
+
* });
|
|
173
|
+
*
|
|
174
|
+
* The `attempt` fn must be safe to invoke twice. If the first failure happens
|
|
175
|
+
* AFTER you've started writing to a response stream, you should set
|
|
176
|
+
* `bailIfStarted: () => boolean` so we don't retry mid-stream.
|
|
177
|
+
*/
|
|
178
|
+
export async function runWithAuthRetry({ attempt, onRefreshing, onRetry, bailIfStarted }) {
|
|
179
|
+
try {
|
|
180
|
+
return await attempt();
|
|
181
|
+
} catch (err) {
|
|
182
|
+
if (!is401Error(err)) throw err;
|
|
183
|
+
if (bailIfStarted && bailIfStarted()) {
|
|
184
|
+
// Stream already started — can't safely retry.
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
if (onRefreshing) onRefreshing(err);
|
|
188
|
+
const refresh = await forceRefresh();
|
|
189
|
+
if (!refresh.ok) {
|
|
190
|
+
const e = new Error(`401 from Anthropic and proxy refresh failed: ${refresh.error || 'unknown'}`);
|
|
191
|
+
e.cause = err;
|
|
192
|
+
e.refreshAttempt = refresh;
|
|
193
|
+
throw e;
|
|
194
|
+
}
|
|
195
|
+
if (onRetry) onRetry(refresh);
|
|
196
|
+
return await attempt();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Standalone auth refresher — meant to be run from cron / launchd.
|
|
4
|
+
*
|
|
5
|
+
* Forces the Claude Code CLI to validate (and silently refresh) its OAuth
|
|
6
|
+
* access token. Prints a single JSON line so log output stays grep-able.
|
|
7
|
+
*
|
|
8
|
+
* Exit codes:
|
|
9
|
+
* 0 — token is valid (refreshed if needed)
|
|
10
|
+
* 2 — not logged in (manual `claude auth login` required)
|
|
11
|
+
* 3 — refresh probe failed (network? Anthropic outage?)
|
|
12
|
+
* 1 — unexpected error
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* node scripts/auth-refresh.js
|
|
16
|
+
* npm run auth:refresh
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { getAuthStatus, forceRefresh } from './auth-helper.js';
|
|
20
|
+
|
|
21
|
+
function emit(obj) {
|
|
22
|
+
process.stdout.write(JSON.stringify({ ts: new Date().toISOString(), ...obj }) + '\n');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const status = await getAuthStatus();
|
|
27
|
+
if (!status.ok || !status.loggedIn) {
|
|
28
|
+
emit({ stage: 'status', ok: false, status, hint: 'run: claude auth login' });
|
|
29
|
+
process.exit(2);
|
|
30
|
+
}
|
|
31
|
+
const refresh = await forceRefresh();
|
|
32
|
+
if (!refresh.ok) {
|
|
33
|
+
emit({ stage: 'refresh', ok: false, status, refresh });
|
|
34
|
+
process.exit(3);
|
|
35
|
+
}
|
|
36
|
+
emit({ stage: 'done', ok: true, email: status.email, subscription: status.subscriptionType, durationMs: refresh.durationMs });
|
|
37
|
+
process.exit(0);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
emit({ stage: 'error', ok: false, error: e.message, stack: e.stack });
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI wrapper that prints auth status as JSON, including a real probe.
|
|
4
|
+
*
|
|
5
|
+
* Unlike `claude auth status --json` which only checks "does a token object
|
|
6
|
+
* exist on disk", this also fires a 1-token probe to verify the access
|
|
7
|
+
* token is actually accepted by Anthropic right now. That's the difference
|
|
8
|
+
* between "loggedIn:true" (cheap) and "verified:true" (real).
|
|
9
|
+
*
|
|
10
|
+
* Pass --quick to skip the probe.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* node scripts/auth-status.js
|
|
14
|
+
* node scripts/auth-status.js --quick
|
|
15
|
+
* npm run auth:status
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { getAuthStatus, forceRefresh } from './auth-helper.js';
|
|
19
|
+
|
|
20
|
+
const quick = process.argv.includes('--quick');
|
|
21
|
+
|
|
22
|
+
const status = await getAuthStatus();
|
|
23
|
+
let verified = null;
|
|
24
|
+
let probe = null;
|
|
25
|
+
if (!quick && status.ok && status.loggedIn) {
|
|
26
|
+
probe = await forceRefresh();
|
|
27
|
+
verified = !!probe.ok;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const out = {
|
|
31
|
+
ts: new Date().toISOString(),
|
|
32
|
+
...status,
|
|
33
|
+
...(quick ? {} : { verified, probeMs: probe?.durationMs, probeError: probe?.error }),
|
|
34
|
+
};
|
|
35
|
+
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
|
|
36
|
+
process.exit(status.ok && status.loggedIn && (quick || verified) ? 0 : 1);
|