mobygate 0.7.1 → 0.7.3

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 CHANGED
@@ -4,6 +4,111 @@ All notable changes to mobygate are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version numbers are
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.7.3] — 2026-04-25
8
+
9
+ Hotfix bundle from a thorough security + bugs + ops audit. Six items.
10
+
11
+ ### Fixed (security)
12
+
13
+ - **Same-origin gate on control-plane endpoints.** `/update/apply`,
14
+ `/auth/refresh`, `DELETE /sessions(/:key)`, `/dashboard/logs`, and
15
+ `/events` now require the request's `Host` header to be localhost
16
+ and (when present) the `Origin` header to match. This blocks the
17
+ DNS-rebinding scenario where a malicious site reroutes its DNS to
18
+ `127.0.0.1` and triggers `npm install -g`, drains Claude Max quota
19
+ via auth-refresh spam, or tails prompt content from server logs
20
+ through any browser tab the user has open. Proxy endpoints
21
+ (`/v1/chat/completions`, `/v1/messages`, `/v1/models`, `/health`)
22
+ stay open for client traffic.
23
+
24
+ ### Fixed (operational)
25
+
26
+ - **Dashboard "Update now" silently no-op'd on Windows.** `lib/updater.js`
27
+ hardcoded `WIN_SERVER_TASK = 'ai.mobygate.server'` while the task
28
+ `lib/platform.js` actually registers is `'mobygate-server'`. The
29
+ cmd.exe `&&` chain short-circuited on the failed `schtasks /End`
30
+ and never ran `npm install`. Now imports `WIN_LABELS` and
31
+ `LINUX_UNITS` directly from platform.js, single-source-of-truth.
32
+ - **`mobygate stop` and `mobygate update` now stop BOTH services**
33
+ (server + auth-refresh) on all three platforms. Earlier the auth
34
+ task could fire mid-update on Windows, grab file handles in
35
+ `node_modules\mobygate`, and trigger EBUSY — root cause of the
36
+ v0.6/v0.7 EBUSY churn. The dashboard `/update/apply` flow also
37
+ stops both services in the detached child script.
38
+ - **Mac auth-refresh plist now generated programmatically.** Earlier
39
+ releases shipped a static plist template (`launchd/ai.mobygate.auth-refresh.plist`)
40
+ with hardcoded paths from Farhan's machine and sed-replaced them
41
+ at install time. Anyone whose username, install path, or fnm
42
+ Node version didn't EXACTLY match the patterns ended up with a
43
+ plist pointing at non-existent paths and the cron silently
44
+ failed forever. New `writeMacAuthRefreshPlist()` in `lib/platform.js`
45
+ mirrors `writeMacServerPlist`, generates from the user's actual
46
+ paths, portable across any user.
47
+
48
+ ### Fixed (bugs)
49
+
50
+ - **Image + 401 auth-retry no longer hangs / returns empty.** When a
51
+ multimodal request hit the SDK right as the OAuth token expired,
52
+ `runWithAuthRetry` would invoke `runQuery` a second time with the
53
+ same already-exhausted async iterator (multimodal returns a
54
+ single-use generator). The SDK got an empty user message and the
55
+ client received an empty response. `prompt` is now built lazily
56
+ inside `runQuery` so each retry attempt rebuilds the iterator.
57
+ All four handlers fixed.
58
+ - **400 instead of "model responds to its own reply"** when a resumed
59
+ request's history terminates with an assistant turn. Earlier
60
+ `messagesToPrompt` in resume mode fell back to extracting whatever
61
+ was at `messages[-1]`, sending the assistant's previous reply to
62
+ the SDK as the new user prompt. Now both `messagesToPrompt`
63
+ (OpenAI) and `anthropicMessagesToPrompt` (Anthropic) return a
64
+ structured `{ promptText, error }` and the handler returns 400
65
+ with a readable message when the trailing turn isn't user/tool.
66
+
67
+ ### Notes
68
+
69
+ - For LAN-exposed installs (`bind: 0.0.0.0`), the same-origin gate
70
+ is necessary but not sufficient — anyone on the LAN can still hit
71
+ endpoints with a faked `Host` header. A real `MOBYGATE_TOKEN` for
72
+ LAN auth is queued for a follow-up release.
73
+ - The `launchd/ai.mobygate.auth-refresh.plist` template file is now
74
+ unused. Left in the package for backward compatibility — won't
75
+ ship in a future release.
76
+
77
+ ## [0.7.2] — 2026-04-25
78
+
79
+ ### Fixed
80
+
81
+ - **"I can't use the tool 'grep' here because it isn't available" refusals**
82
+ in long-running tasks. Even with `allowedTools: ['mcp__mobygate__*']`
83
+ blocking everything except client-defined tools, the model retains
84
+ strong priors from training for Claude Code's built-ins (Bash, Grep,
85
+ Read, Edit, Glob, WebFetch, ToolSearch, etc.). When a task seemed to
86
+ call for one — e.g., "find all TODOs" → instinctive reach for Grep —
87
+ the model would attempt it, get blocked, refuse the task, and stop.
88
+ Instead of falling back to the available client tool (`searchFiles`,
89
+ `terminal`, etc.).
90
+
91
+ **Fix:** for any tool-enabled request, append a short system-prompt
92
+ block (~150 tokens) via the SDK's
93
+ `systemPrompt: { type: 'preset', preset: 'claude_code', append: ... }`
94
+ option. The append explicitly lists the available client tools and
95
+ states that Claude Code's built-ins are NOT in this environment.
96
+ Calibrated to be matter-of-fact ("here's the environment, work
97
+ within it") rather than over-restrictive — the model now uses
98
+ available tools or briefly says what's missing, instead of refusing
99
+ silently.
100
+
101
+ Applies to both `/v1/chat/completions` and `/v1/messages`.
102
+
103
+ ### Notes
104
+
105
+ - New helper: `buildToolUsageGuidance(tools)` in `lib/tool-bridge.js`
106
+ produces the append text from the OpenAI-shape tool array. The
107
+ Anthropic surface translates its tool defs to OpenAI shape for the
108
+ bridge already, so the helper takes one input shape across both.
109
+ - Per-request token overhead: ~150 tokens, only when `tools` is non-empty.
110
+ No effect on text-only chat or non-tool requests.
111
+
7
112
  ## [0.7.1] — 2026-04-24
8
113
 
9
114
  Fixes a meaningful token-burn issue for clients that don't pass session
package/bin/mobygate.js CHANGED
@@ -28,7 +28,7 @@ import { loadConfig, writeConfig, writeState, readState, CONFIG_DIR, CONFIG_PATH
28
28
  import {
29
29
  PLATFORM, IS_MAC, IS_LINUX, IS_WIN,
30
30
  resolveNodeBin,
31
- writeMacServerPlist, launchctlLoad, launchctlUnload,
31
+ writeMacServerPlist, writeMacAuthRefreshPlist, launchctlLoad, launchctlUnload,
32
32
  plistPathForLabel, queryLaunchd, uninstallAllServices,
33
33
  installWindowsServices, uninstallWindowsServices,
34
34
  queryWindowsTask, startWindowsTask, stopWindowsTask, WIN_LABELS,
@@ -204,21 +204,18 @@ async function cmdInit() {
204
204
  launchctlLoad(serverPlist);
205
205
  ok(`Installed ${SERVER_LABEL} (launchd)`);
206
206
 
207
- // Auth refresh plist (we ship a template in launchd/ — copy + rewrite
208
- // WorkingDirectory, node path, and log paths to match this install).
209
- const authSrc = join(REPO_ROOT, 'launchd', 'ai.mobygate.auth-refresh.plist');
210
- if (existsSync(authSrc)) {
211
- const authDst = plistPathForLabel(AUTH_LABEL);
212
- const tmpl = readFileSync(authSrc, 'utf8')
213
- // WorkingDirectory + any path that referenced the repo root
214
- .replace(/\/Users\/farhan\/openclaude\/claude-max-sdk-proxy\/logs/g, logsDir)
215
- .replace(/\/Users\/farhan\/openclaude\/claude-max-sdk-proxy/g, REPO_ROOT)
216
- // node binary baked into ProgramArguments
217
- .replace(/\/Users\/farhan\/\.local\/share\/fnm\/aliases\/default\/bin\/node/g, nodeBin);
218
- writeFileSync(authDst, tmpl);
219
- launchctlLoad(authDst);
220
- ok(`Installed ${AUTH_LABEL} (launchd, every ${existing.auth_refresh_interval_hours}h)`);
221
- }
207
+ // Auth refresh plist generated programmatically with the user's
208
+ // actual paths. Earlier we shipped a static template and sed-replaced
209
+ // hardcoded paths inside it, which silently broke for anyone whose
210
+ // username/install-path/fnm-version didn't EXACTLY match Farhan's.
211
+ const authPlist = writeMacAuthRefreshPlist({
212
+ installPath: REPO_ROOT,
213
+ nodeBin,
214
+ logsDir,
215
+ intervalHours: existing.auth_refresh_interval_hours,
216
+ });
217
+ launchctlLoad(authPlist);
218
+ ok(`Installed ${AUTH_LABEL} (launchd, every ${existing.auth_refresh_interval_hours}h)`);
222
219
  } else if (IS_WIN) {
223
220
  // Register Task Scheduler entries and kick the server task now.
224
221
  const r = installWindowsServices({
@@ -320,25 +317,47 @@ function cmdStart() {
320
317
  }
321
318
 
322
319
  function cmdStop() {
320
+ // Stop BOTH services: the server AND the auth-refresh task. Earlier
321
+ // releases only stopped the server, leaving the 4-hourly auth-refresh
322
+ // cron free to fire mid-update and grab file handles in node_modules
323
+ // — that was the root cause of the v0.6/v0.7 EBUSY churn on Windows.
324
+ // We tolerate "not running" failures on both since the user just
325
+ // wants the end state of "nothing mobygate is running."
323
326
  if (IS_MAC) {
324
- const p = plistPathForLabel(SERVER_LABEL);
325
- launchctlUnload(p);
327
+ const serverPlist = plistPathForLabel(SERVER_LABEL);
328
+ const authPlist = plistPathForLabel(AUTH_LABEL);
329
+ launchctlUnload(serverPlist);
330
+ launchctlUnload(authPlist);
326
331
  ok(`Unloaded ${SERVER_LABEL}`);
332
+ ok(`Unloaded ${AUTH_LABEL}`);
327
333
  } else if (IS_WIN) {
328
- const r = stopWindowsTask(WIN_LABELS.server);
329
- if (!r.ok) return die(`Failed to stop ${WIN_LABELS.server}: ${r.stderr || 'unknown'}`);
330
- ok(`Stopped ${WIN_LABELS.server}`);
334
+ const rServer = stopWindowsTask(WIN_LABELS.server);
335
+ const rAuth = stopWindowsTask(WIN_LABELS.auth);
336
+ if (rServer.ok) ok(`Stopped ${WIN_LABELS.server}`);
337
+ else warn(`${WIN_LABELS.server}: ${rServer.stderr || 'not running or already stopped'}`);
338
+ if (rAuth.ok) ok(`Stopped ${WIN_LABELS.auth}`);
339
+ else warn(`${WIN_LABELS.auth}: ${rAuth.stderr || 'not running or already stopped'}`);
331
340
  } else if (IS_LINUX) {
332
- const r = stopLinuxUnit(LINUX_UNITS.server);
333
- if (!r.ok) return die(`Failed to stop ${LINUX_UNITS.server}: ${r.stderr || 'unknown'}`);
334
- ok(`Stopped ${LINUX_UNITS.server}`);
341
+ const rServer = stopLinuxUnit(LINUX_UNITS.server);
342
+ if (rServer.ok) ok(`Stopped ${LINUX_UNITS.server}`);
343
+ else warn(`${LINUX_UNITS.server}: ${rServer.stderr || 'not running or already stopped'}`);
344
+ if (LINUX_UNITS.timer) {
345
+ const rTimer = stopLinuxUnit(LINUX_UNITS.timer);
346
+ if (rTimer.ok) ok(`Stopped ${LINUX_UNITS.timer}`);
347
+ }
348
+ if (LINUX_UNITS.auth) {
349
+ const rAuth = stopLinuxUnit(LINUX_UNITS.auth);
350
+ if (rAuth.ok) ok(`Stopped ${LINUX_UNITS.auth}`);
351
+ }
335
352
  } else {
336
353
  die('`mobygate stop` not supported on this platform.');
337
354
  }
338
355
  }
339
356
 
340
357
  function cmdRestart() {
341
- cmdStop();
358
+ // Tolerate cmdStop failure (target may already be stopped). Only die
359
+ // on cmdStart errors, which are the actually-blocking ones.
360
+ try { cmdStop(); } catch {}
342
361
  cmdStart();
343
362
  }
344
363
 
@@ -581,23 +600,23 @@ async function cmdUpdate() {
581
600
  }
582
601
  print('');
583
602
 
584
- // ---- Stop the service FIRST on Windows, otherwise running Node holds
585
- // open file handles inside the install dir and `npm install -g` fails
586
- // with EBUSY when it tries to rename the directory. On macOS/Linux we
587
- // can replace open files freely, but stopping early there too is harmless
588
- // and gives a cleaner restart sequence — so we do it everywhere.
589
- let stoppedForUpdate = false;
603
+ // ---- Stop BOTH services first (server + auth-refresh). The auth task
604
+ // imports mobygate code from the same node_modules dir, so if it fires
605
+ // mid-install on Windows it grabs file handles and triggers EBUSY.
606
+ // POSIX systems can replace open files freely, but stopping early there
607
+ // too is harmless and gives a cleaner restart sequence — so we do it
608
+ // everywhere. Tolerate "already stopped" failures silently.
609
+ info('Stopping services so npm install can replace files...');
590
610
  if (IS_WIN) {
591
- info('Stopping service so npm install can replace files...');
592
611
  stopWindowsTask(WIN_LABELS.server);
593
- stoppedForUpdate = true;
612
+ stopWindowsTask(WIN_LABELS.auth);
594
613
  } else if (IS_MAC) {
595
- const p = plistPathForLabel(SERVER_LABEL);
596
- launchctlUnload(p);
597
- stoppedForUpdate = true;
614
+ launchctlUnload(plistPathForLabel(SERVER_LABEL));
615
+ launchctlUnload(plistPathForLabel(AUTH_LABEL));
598
616
  } else if (IS_LINUX) {
599
617
  stopLinuxUnit(LINUX_UNITS.server);
600
- stoppedForUpdate = true;
618
+ if (LINUX_UNITS.timer) stopLinuxUnit(LINUX_UNITS.timer);
619
+ if (LINUX_UNITS.auth) stopLinuxUnit(LINUX_UNITS.auth);
601
620
  }
602
621
 
603
622
  // ---- Perform the upgrade
@@ -618,19 +637,23 @@ async function cmdUpdate() {
618
637
  return die(`Install mode is "${mode}" — can't auto-update. Reinstall via npm or git.`);
619
638
  }
620
639
 
621
- // ---- Bring the service back up on the new code
640
+ // ---- Bring services back up on the new code (server first, then
641
+ // auth-refresh — server is the load-bearing one; auth restart is
642
+ // best-effort since it'll naturally fire on its next interval anyway).
622
643
  section('Restart');
623
- info('Starting service on the new build...');
644
+ info('Starting services on the new build...');
624
645
  if (IS_MAC) {
625
- const p = plistPathForLabel(SERVER_LABEL);
626
- launchctlLoad(p);
646
+ launchctlLoad(plistPathForLabel(SERVER_LABEL));
627
647
  ok(`Loaded ${SERVER_LABEL}`);
648
+ try { launchctlLoad(plistPathForLabel(AUTH_LABEL)); ok(`Loaded ${AUTH_LABEL}`); } catch {}
628
649
  } else if (IS_WIN) {
629
650
  startWindowsTask(WIN_LABELS.server);
630
651
  ok(`Started ${WIN_LABELS.server}`);
652
+ try { startWindowsTask(WIN_LABELS.auth); ok(`Started ${WIN_LABELS.auth}`); } catch {}
631
653
  } else if (IS_LINUX) {
632
654
  startLinuxUnit(LINUX_UNITS.server);
633
655
  ok(`Started ${LINUX_UNITS.server}`);
656
+ if (LINUX_UNITS.timer) { try { startLinuxUnit(LINUX_UNITS.timer); ok(`Started ${LINUX_UNITS.timer}`); } catch {} }
634
657
  }
635
658
  print('');
636
659
  info(`Tip: if the install-layout changed (new service file, new paths), run \`mobygate init\` to re-install the service definitions.`);
package/lib/anthropic.js CHANGED
@@ -112,15 +112,26 @@ export function anthropicMessagesToPrompt(body, { resuming = false } = {}) {
112
112
  // SDK has full history. Send only the new tail: tool_results from
113
113
  // the last user message (if any) plus any fresh user text.
114
114
  const last = messages[messages.length - 1];
115
- if (!last || last.role !== 'user') return '';
115
+ if (!last || last.role !== 'user') {
116
+ return {
117
+ promptText: '',
118
+ error: 'Resume mode requires the last message to be from the user. Last message has role "' + (last?.role || 'none') + '".',
119
+ };
120
+ }
116
121
  const trBlocks = anthropicToolResultsOf(last.content);
117
122
  const text = anthropicTextOf(last.content);
123
+ if (!trBlocks.length && !text) {
124
+ return {
125
+ promptText: '',
126
+ error: 'Resume mode requires the last user message to contain text or tool_result blocks.',
127
+ };
128
+ }
118
129
  const parts = [];
119
130
  if (trBlocks.length) {
120
131
  parts.push(`<tool_results>\n${trBlocks.map(formatToolResultBlock).join('\n')}\n</tool_results>`);
121
132
  }
122
133
  if (text) parts.push(text);
123
- return parts.join('\n\n');
134
+ return { promptText: parts.join('\n\n') };
124
135
  }
125
136
 
126
137
  // Fresh request: serialize visible history. System prompt at top, then
@@ -154,7 +165,7 @@ export function anthropicMessagesToPrompt(body, { resuming = false } = {}) {
154
165
  }
155
166
  }
156
167
  flushTools();
157
- return parts.join('\n').trim();
168
+ return { promptText: parts.join('\n').trim() };
158
169
  }
159
170
 
160
171
  /**
package/lib/platform.js CHANGED
@@ -110,6 +110,80 @@ export function writeMacServerPlist({ installPath, nodeBin, port, logsDir }) {
110
110
  return plistPath;
111
111
  }
112
112
 
113
+ /**
114
+ * Generate the macOS auth-refresh plist with the user's actual paths
115
+ * baked in. Earlier we shipped a static plist template and sed-replaced
116
+ * Farhan's hardcoded paths inside it — anyone who installed without an
117
+ * EXACT path match (different username, different fnm version, etc.)
118
+ * ended up with a plist pointing at /Users/farhan/... and the cron
119
+ * silently failed forever. This generator mirrors writeMacServerPlist
120
+ * and uses the same nodeBin / installPath / logsDir resolution so the
121
+ * resulting plist is portable across any user's machine.
122
+ */
123
+ export function writeMacAuthRefreshPlist({ installPath, nodeBin, logsDir, intervalHours = 4 }) {
124
+ if (!IS_MAC) throw new Error('writeMacAuthRefreshPlist called on non-macOS');
125
+ if (!existsSync(LAUNCH_AGENTS_DIR)) mkdirSync(LAUNCH_AGENTS_DIR, { recursive: true });
126
+ const plistPath = join(LAUNCH_AGENTS_DIR, `${AUTH_LABEL}.plist`);
127
+ const intervalSec = Math.max(60, parseInt(intervalHours, 10) * 3600);
128
+ const pathChain = [
129
+ dirname(nodeBin),
130
+ '/usr/local/bin', '/usr/bin', '/bin', '/opt/homebrew/bin',
131
+ join(homedir(), '.local/bin'),
132
+ ].join(':');
133
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
134
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
135
+ <!--
136
+ Generated by \`mobygate init\` on ${new Date().toISOString()}.
137
+ Proactive Claude Max OAuth refresh cron.
138
+ - Runs scripts/auth-refresh.js every ${intervalHours}h via launchd
139
+ - Anthropic OAuth tokens last ~8h, so ${intervalHours}h cadence keeps
140
+ us inside the valid window even if one run fails
141
+
142
+ Install: launchctl load ~/Library/LaunchAgents/${AUTH_LABEL}.plist
143
+ Uninstall: launchctl unload ~/Library/LaunchAgents/${AUTH_LABEL}.plist
144
+ -->
145
+ <plist version="1.0">
146
+ <dict>
147
+ <key>Label</key>
148
+ <string>${AUTH_LABEL}</string>
149
+
150
+ <key>ProgramArguments</key>
151
+ <array>
152
+ <string>${nodeBin}</string>
153
+ <string>scripts/auth-refresh.js</string>
154
+ </array>
155
+
156
+ <key>WorkingDirectory</key>
157
+ <string>${installPath}</string>
158
+
159
+ <key>EnvironmentVariables</key>
160
+ <dict>
161
+ <key>PATH</key>
162
+ <string>${pathChain}</string>
163
+ <key>HOME</key>
164
+ <string>${homedir()}</string>
165
+ </dict>
166
+
167
+ <key>StartInterval</key>
168
+ <integer>${intervalSec}</integer>
169
+
170
+ <key>RunAtLoad</key>
171
+ <true/>
172
+
173
+ <key>StandardOutPath</key>
174
+ <string>${logsDir}/auth-refresh.log</string>
175
+ <key>StandardErrorPath</key>
176
+ <string>${logsDir}/auth-refresh.err.log</string>
177
+
178
+ <key>KeepAlive</key>
179
+ <false/>
180
+ </dict>
181
+ </plist>
182
+ `;
183
+ writeFileSync(plistPath, xml);
184
+ return plistPath;
185
+ }
186
+
113
187
  /**
114
188
  * Install (copy + load) a plist. Returns {installed: true, path}.
115
189
  * Safe to call when already loaded — we unload first.
@@ -218,6 +218,50 @@ export function hasToolUse(assistantMessage) {
218
218
  // Tool results (OpenAI tool messages → Anthropic tool_result content blocks)
219
219
  // ---------------------------------------------------------------------------
220
220
 
221
+ // ---------------------------------------------------------------------------
222
+ // Strict-tool guidance (system-prompt append for tool-enabled requests)
223
+ // ---------------------------------------------------------------------------
224
+ // Even with native MCP registration + a tight `allowedTools` allowlist, the
225
+ // model retains strong priors for Claude Code's built-in tools (Bash, Read,
226
+ // Edit, Grep, Glob, WebFetch, ToolSearch, etc.) from training. When a task
227
+ // seems to need one of those, the model reaches for it, gets blocked by
228
+ // `allowedTools`, says "I can't use the tool 'grep' here because it isn't
229
+ // available," and gives up — instead of falling back to the available
230
+ // client-defined tools. We saw this in production OpenClaw use.
231
+ //
232
+ // The fix: append a short, explicit guidance block to Claude Code's system
233
+ // prompt (via `systemPrompt: { type: 'preset', preset: 'claude_code', append: ... }`)
234
+ // telling the model exactly which tools are available and that built-ins
235
+ // are NOT in this environment. The positive list reinforces what the model
236
+ // already sees via MCP registration; the negative list shuts down the
237
+ // trained-in instinct to reach for built-ins.
238
+ //
239
+ // Calibration matters: too directive and the model becomes over-conservative
240
+ // and refuses legitimate work. We aim for matter-of-fact "here's the
241
+ // environment, work within it" rather than threatening prohibition.
242
+
243
+ const KNOWN_BUILTINS = 'Bash, Read, Edit, Write, Grep, Glob, NotebookEdit, WebFetch, WebSearch, Task, ToolSearch';
244
+
245
+ export function buildToolUsageGuidance(openaiTools) {
246
+ if (!Array.isArray(openaiTools) || openaiTools.length === 0) return null;
247
+ const names = [];
248
+ for (const t of openaiTools) {
249
+ if (t?.type !== 'function' || !t.function?.name) continue;
250
+ names.push(t.function.name);
251
+ }
252
+ if (names.length === 0) return null;
253
+
254
+ return [
255
+ 'Tool environment: this session is running through a proxy that exposes only the client-defined tools listed below. Claude Code\'s default built-in tools',
256
+ `(${KNOWN_BUILTINS}, etc.) are NOT available in this environment and cannot be invoked — calls to them will fail.`,
257
+ '',
258
+ 'Available tools:',
259
+ ...names.map((n) => ` - ${n}`),
260
+ '',
261
+ 'If a task seems to require a built-in tool that isn\'t in this list, accomplish what you can with the available tools and briefly note what\'s missing — do not refuse silently or claim you have no tools.',
262
+ ].join('\n');
263
+ }
264
+
221
265
  /**
222
266
  * Format OpenAI role:'tool' messages as a single user-readable text
223
267
  * block to splice into a resumed prompt.
package/lib/updater.js CHANGED
@@ -26,6 +26,12 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, openSync } from 'fs
26
26
  import { join, sep, dirname } from 'path';
27
27
  import { fileURLToPath } from 'url';
28
28
  import { LOGS_DIR } from './config.js';
29
+ // Single-source service labels from platform.js — earlier we duplicated
30
+ // these constants here and they drifted (WIN_SERVER_TASK was 'ai.mobygate.server'
31
+ // while platform.js registered 'mobygate-server'), so the dashboard's
32
+ // "Update now" silently no-op'd on Windows because the schtasks /End in the
33
+ // update chain failed and short-circuited the rest via &&.
34
+ import { WIN_LABELS, LINUX_UNITS } from './platform.js';
29
35
 
30
36
  const __filename = fileURLToPath(import.meta.url);
31
37
  const REPO_ROOT = dirname(dirname(__filename)); // lib/updater.js → repo root
@@ -35,8 +41,7 @@ const IS_MAC = process.platform === 'darwin';
35
41
  const IS_LINUX = process.platform === 'linux';
36
42
 
37
43
  const SERVER_LABEL = 'ai.mobygate.server';
38
- const WIN_SERVER_TASK = 'ai.mobygate.server';
39
- const LINUX_SERVER_UNIT = 'mobygate-server.service';
44
+ const AUTH_LABEL = 'ai.mobygate.auth-refresh';
40
45
 
41
46
  const UPDATE_LOG = join(LOGS_DIR, 'update.log');
42
47
  const UPDATE_MARKER = join(LOGS_DIR, 'update.state.json');
@@ -174,14 +179,17 @@ function writeUpdateState(patch) {
174
179
  function buildUpdateCommand({ mode, repoRoot, logPath }) {
175
180
  if (IS_WIN) {
176
181
  // cmd.exe — `>>` for append, `2>&1` to merge. Each step on its own
177
- // line so failures short-circuit via `||`.
182
+ // line so failures short-circuit via `&&`. The auth-refresh task is
183
+ // also stopped because it's a separate scheduled task that imports
184
+ // mobygate code; if it fires mid-install it grabs file handles in
185
+ // node_modules\mobygate and we hit EBUSY just like the server task.
186
+ // Note: trailing 2>nul on End calls so "task not found" doesn't
187
+ // short-circuit the chain — the start steps will surface real errors.
178
188
  const steps = [];
179
189
  steps.push(`echo [mobygate-update] start at %DATE% %TIME%`);
180
- // Stop FIRST so npm can replace files without EBUSY. /F forces close
181
- // even if the process is mid-request; the SDK session map writes are
182
- // synchronous and the SIGTERM handler flushes before exit.
183
- steps.push(`echo [mobygate-update] stopping service`);
184
- steps.push(`schtasks /End /TN "${WIN_SERVER_TASK}"`);
190
+ steps.push(`echo [mobygate-update] stopping services`);
191
+ steps.push(`(schtasks /End /TN "${WIN_LABELS.server}" 2>nul) | rem`);
192
+ steps.push(`(schtasks /End /TN "${WIN_LABELS.auth}" 2>nul) | rem`);
185
193
  if (mode === 'npm') {
186
194
  steps.push(`npm install -g mobygate@latest`);
187
195
  } else if (mode === 'git') {
@@ -189,22 +197,29 @@ function buildUpdateCommand({ mode, repoRoot, logPath }) {
189
197
  steps.push(`git pull --ff-only`);
190
198
  steps.push(`npm install`);
191
199
  }
192
- steps.push(`echo [mobygate-update] restarting service`);
193
- steps.push(`schtasks /Run /TN "${WIN_SERVER_TASK}"`);
200
+ steps.push(`echo [mobygate-update] starting services on new build`);
201
+ steps.push(`schtasks /Run /TN "${WIN_LABELS.server}"`);
202
+ steps.push(`(schtasks /Run /TN "${WIN_LABELS.auth}" 2>nul) | rem`);
194
203
  steps.push(`echo [mobygate-update] done`);
195
204
  // Join with && so any failure stops the chain. Final redirect to log.
196
205
  const inner = steps.map((s) => `(${s})`).join(' && ');
197
206
  return { shell: 'cmd', cmd: `${inner} >> "${logPath}" 2>&1` };
198
207
  }
199
- // POSIX: sh -c, bail-on-first-failure via set -e. Stop service first
200
- // for the same reason symmetry, cleaner restart, no harm.
208
+ // POSIX: sh -c, bail-on-first-failure via set -e. Same dual-task stop
209
+ // applies auth-refresh runs on its own launchd plist / systemd timer
210
+ // and would lock files mid-install if not stopped. `|| true` because
211
+ // a not-loaded service shouldn't kill the chain.
201
212
  const parts = [`set -e`, `echo "[mobygate-update] start $(date)"`];
202
- parts.push(`echo "[mobygate-update] stopping service"`);
213
+ parts.push(`echo "[mobygate-update] stopping services"`);
203
214
  if (IS_MAC) {
204
- const plist = join(process.env.HOME || '~', 'Library', 'LaunchAgents', `${SERVER_LABEL}.plist`);
205
- parts.push(`launchctl unload "${plist}" 2>/dev/null || true`);
215
+ const serverPlist = join(process.env.HOME || '~', 'Library', 'LaunchAgents', `${SERVER_LABEL}.plist`);
216
+ const authPlist = join(process.env.HOME || '~', 'Library', 'LaunchAgents', `${AUTH_LABEL}.plist`);
217
+ parts.push(`launchctl unload "${serverPlist}" 2>/dev/null || true`);
218
+ parts.push(`launchctl unload "${authPlist}" 2>/dev/null || true`);
206
219
  } else if (IS_LINUX) {
207
- parts.push(`systemctl --user stop ${LINUX_SERVER_UNIT} 2>/dev/null || true`);
220
+ parts.push(`systemctl --user stop ${LINUX_UNITS.server} 2>/dev/null || true`);
221
+ if (LINUX_UNITS.timer) parts.push(`systemctl --user stop ${LINUX_UNITS.timer} 2>/dev/null || true`);
222
+ if (LINUX_UNITS.auth) parts.push(`systemctl --user stop ${LINUX_UNITS.auth} 2>/dev/null || true`);
208
223
  }
209
224
  if (mode === 'npm') {
210
225
  parts.push(`npm install -g mobygate@latest`);
@@ -213,12 +228,15 @@ function buildUpdateCommand({ mode, repoRoot, logPath }) {
213
228
  parts.push(`git pull --ff-only`);
214
229
  parts.push(`npm install`);
215
230
  }
216
- parts.push(`echo "[mobygate-update] starting service on new build"`);
231
+ parts.push(`echo "[mobygate-update] starting services on new build"`);
217
232
  if (IS_MAC) {
218
- const plist = join(process.env.HOME || '~', 'Library', 'LaunchAgents', `${SERVER_LABEL}.plist`);
219
- parts.push(`launchctl load "${plist}"`);
233
+ const serverPlist = join(process.env.HOME || '~', 'Library', 'LaunchAgents', `${SERVER_LABEL}.plist`);
234
+ const authPlist = join(process.env.HOME || '~', 'Library', 'LaunchAgents', `${AUTH_LABEL}.plist`);
235
+ parts.push(`launchctl load "${serverPlist}"`);
236
+ parts.push(`launchctl load "${authPlist}" 2>/dev/null || true`);
220
237
  } else if (IS_LINUX) {
221
- parts.push(`systemctl --user start ${LINUX_SERVER_UNIT}`);
238
+ parts.push(`systemctl --user start ${LINUX_UNITS.server}`);
239
+ if (LINUX_UNITS.timer) parts.push(`systemctl --user start ${LINUX_UNITS.timer} 2>/dev/null || true`);
222
240
  }
223
241
  parts.push(`echo "[mobygate-update] done"`);
224
242
  const script = parts.join('\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobygate",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "OpenAI-compatible local proxy for Claude Max. The Möbius-strip gateway: OpenAI shape in, Claude Max out.",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -55,6 +55,7 @@ import { loadSessions, saveSessions, flushSessionsNow } from './lib/session-stor
55
55
  import { LOGS_DIR } from './lib/config.js';
56
56
  import {
57
57
  buildClientToolsServer,
58
+ buildToolUsageGuidance,
58
59
  extractToolUses,
59
60
  hasToolUse,
60
61
  toolMessagesToText,
@@ -285,12 +286,20 @@ function messagesToPrompt(messages, { resuming = false } = {}) {
285
286
  }
286
287
  }
287
288
  const toolResultsText = toolMessagesToText(trailingToolMessages);
289
+ if (!userText && !toolResultsText) {
290
+ // Earlier code fell back to extracting whatever was at messages[-1],
291
+ // which on an assistant-terminated history sent the assistant's own
292
+ // previous reply back to the SDK as the new user prompt — and the
293
+ // model would "respond to its own reply." Catch this clearly instead.
294
+ return {
295
+ promptText: '',
296
+ error: 'Resume mode requires the request to end with a user message or tool result. Last message has role "' + (messages[messages.length - 1]?.role || 'unknown') + '".',
297
+ };
298
+ }
288
299
  const parts = [];
289
300
  if (toolResultsText) parts.push(toolResultsText);
290
301
  if (userText) parts.push(userText);
291
- return {
292
- promptText: parts.join('\n\n') || extractContent(messages[messages.length - 1]?.content || ''),
293
- };
302
+ return { promptText: parts.join('\n\n') };
294
303
  }
295
304
 
296
305
  // Fresh request: serialize visible history as XML-wrapped text. No
@@ -395,13 +404,30 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
395
404
  const existing = getSession(sessionKey);
396
405
  const resuming = !!existing?.sdkSessionId;
397
406
  const toolsEnabled = hasTools(body);
398
- const { promptText } = messagesToPrompt(body.messages, { resuming });
407
+ const { promptText, error: promptError } = messagesToPrompt(body.messages, { resuming });
408
+ if (promptError) {
409
+ return res.status(400).json({
410
+ error: { message: promptError, type: 'invalid_request_error', code: 'invalid_resume_messages' },
411
+ });
412
+ }
399
413
  const images = collectImages(body.messages);
400
- const prompt = buildQueryPrompt(promptText, images);
414
+ // NOTE: `prompt` is built inside runQuery (not here) when images are
415
+ // present, because buildQueryPrompt returns a single-use async iterator
416
+ // for multimodal requests. If we built it here and the SDK call hit a
417
+ // 401, runWithAuthRetry would invoke runQuery a second time with the
418
+ // same exhausted iterator → SDK gets an empty user message → silent
419
+ // empty response. Lazy construction inside runQuery rebuilds the
420
+ // iterator per attempt.
401
421
  const model = resolveModel(body.model);
402
422
  // Build the in-process MCP server exposing client tools to the SDK.
403
423
  // null when toolsEnabled is false (or all tools are malformed).
404
424
  const clientToolsServer = toolsEnabled ? buildClientToolsServer(body.tools) : null;
425
+ // System-prompt append: tells the model exactly which tools are
426
+ // available and that Claude Code's built-ins (Bash, Grep, Read, etc.)
427
+ // are NOT in this environment. Without this, the model trained-in
428
+ // priors lead it to call Grep/Bash, get blocked by allowedTools, and
429
+ // refuse the task instead of falling back to client tools. ~150 tokens.
430
+ const toolsGuidance = clientToolsServer ? buildToolUsageGuidance(body.tools) : null;
405
431
  if (images.length) console.log(` [multimodal] ${images.length} image block(s)`);
406
432
  if (toolsEnabled) console.log(` [tools] ${body.tools.length} client tool(s) registered as MCP`);
407
433
 
@@ -443,6 +469,9 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
443
469
  resolvedModel = model;
444
470
  capturedSessionId = existing?.sdkSessionId || null;
445
471
 
472
+ // Build the prompt lazily on each attempt — multimodal returns a
473
+ // single-use async iterator. Keeps 401 auth-retries safe.
474
+ const prompt = buildQueryPrompt(promptText, images);
446
475
  for await (const message of query({
447
476
  prompt,
448
477
  options: {
@@ -458,6 +487,7 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
458
487
  ? {
459
488
  mcpServers: { [MCP_SERVER_NAME]: clientToolsServer },
460
489
  allowedTools: [`${MCP_TOOL_PREFIX}*`],
490
+ systemPrompt: { type: 'preset', preset: 'claude_code', append: toolsGuidance },
461
491
  }
462
492
  : toolsEnabled
463
493
  // Tools were requested but none were valid — disable all tools.
@@ -615,11 +645,23 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
615
645
  const existing = getSession(sessionKey);
616
646
  const resuming = !!existing?.sdkSessionId;
617
647
  const toolsEnabled = hasTools(body);
618
- const { promptText } = messagesToPrompt(body.messages, { resuming });
648
+ const { promptText, error: promptError } = messagesToPrompt(body.messages, { resuming });
649
+ if (promptError) {
650
+ return res.status(400).json({
651
+ error: { message: promptError, type: 'invalid_request_error', code: 'invalid_resume_messages' },
652
+ });
653
+ }
619
654
  const images = collectImages(body.messages);
620
- const prompt = buildQueryPrompt(promptText, images);
655
+ // NOTE: `prompt` is built inside runQuery (not here) when images are
656
+ // present, because buildQueryPrompt returns a single-use async iterator
657
+ // for multimodal requests. If we built it here and the SDK call hit a
658
+ // 401, runWithAuthRetry would invoke runQuery a second time with the
659
+ // same exhausted iterator → SDK gets an empty user message → silent
660
+ // empty response. Lazy construction inside runQuery rebuilds the
661
+ // iterator per attempt.
621
662
  const model = resolveModel(body.model);
622
663
  const clientToolsServer = toolsEnabled ? buildClientToolsServer(body.tools) : null;
664
+ const toolsGuidance = clientToolsServer ? buildToolUsageGuidance(body.tools) : null;
623
665
  if (images.length) console.log(` [multimodal] ${images.length} image block(s)`);
624
666
  if (toolsEnabled) console.log(` [tools] ${body.tools.length} client tool(s) registered as MCP`);
625
667
 
@@ -644,6 +686,9 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
644
686
  outputTokens = 0;
645
687
  capturedSessionId = existing?.sdkSessionId || null;
646
688
 
689
+ // Build the prompt lazily on each attempt — multimodal returns a
690
+ // single-use async iterator. Keeps 401 auth-retries safe.
691
+ const prompt = buildQueryPrompt(promptText, images);
647
692
  for await (const message of query({
648
693
  prompt,
649
694
  options: {
@@ -656,6 +701,7 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
656
701
  ? {
657
702
  mcpServers: { [MCP_SERVER_NAME]: clientToolsServer },
658
703
  allowedTools: [`${MCP_TOOL_PREFIX}*`],
704
+ systemPrompt: { type: 'preset', preset: 'claude_code', append: toolsGuidance },
659
705
  }
660
706
  : toolsEnabled
661
707
  ? { allowedTools: [] }
@@ -791,9 +837,17 @@ async function handleAnthropicNonStreaming(res, body, requestId, sessionKey) {
791
837
  const existing = getSession(sessionKey);
792
838
  const resuming = !!existing?.sdkSessionId;
793
839
  const toolsEnabled = hasAnthropicTools(body);
794
- const promptText = anthropicMessagesToPrompt(body, { resuming });
840
+ const { promptText, error: promptError } = anthropicMessagesToPrompt(body, { resuming });
841
+ if (promptError) {
842
+ return res.status(400).json({
843
+ type: 'error',
844
+ error: { type: 'invalid_request_error', message: promptError },
845
+ });
846
+ }
795
847
  const images = collectAnthropicImages(body.messages || []);
796
- const prompt = buildQueryPrompt(promptText, images);
848
+ // See note in handleStreaming — `prompt` is built lazily inside runQuery
849
+ // because the multimodal path returns a single-use async iterator that
850
+ // a 401-retry would exhaust on the first attempt.
797
851
  const model = resolveModel(body.model);
798
852
  // Translate Anthropic tool defs → OpenAI shape that buildClientToolsServer
799
853
  // expects. Both go through the same JSON-Schema → Zod path on the way to
@@ -806,6 +860,7 @@ async function handleAnthropicNonStreaming(res, body, requestId, sessionKey) {
806
860
  }))
807
861
  : null;
808
862
  const clientToolsServer = toolsForBridge ? buildClientToolsServer(toolsForBridge) : null;
863
+ const toolsGuidance = clientToolsServer ? buildToolUsageGuidance(toolsForBridge) : null;
809
864
 
810
865
  if (images.length) console.log(` [multimodal] ${images.length} image block(s)`);
811
866
  if (toolsEnabled) console.log(` [tools] ${body.tools.length} client tool(s) registered as MCP`);
@@ -832,6 +887,9 @@ async function handleAnthropicNonStreaming(res, body, requestId, sessionKey) {
832
887
  capturedSessionId = existing?.sdkSessionId || null;
833
888
  stopReason = 'end_turn';
834
889
 
890
+ // Build the prompt lazily on each attempt — multimodal returns a
891
+ // single-use async iterator. Keeps 401 auth-retries safe.
892
+ const prompt = buildQueryPrompt(promptText, images);
835
893
  for await (const message of query({
836
894
  prompt,
837
895
  options: {
@@ -844,6 +902,7 @@ async function handleAnthropicNonStreaming(res, body, requestId, sessionKey) {
844
902
  ? {
845
903
  mcpServers: { [MCP_SERVER_NAME]: clientToolsServer },
846
904
  allowedTools: [`${MCP_TOOL_PREFIX}*`],
905
+ systemPrompt: { type: 'preset', preset: 'claude_code', append: toolsGuidance },
847
906
  }
848
907
  : toolsEnabled
849
908
  ? { allowedTools: [] }
@@ -937,9 +996,17 @@ async function handleAnthropicStreaming(req, res, body, requestId, sessionKey) {
937
996
  const existing = getSession(sessionKey);
938
997
  const resuming = !!existing?.sdkSessionId;
939
998
  const toolsEnabled = hasAnthropicTools(body);
940
- const promptText = anthropicMessagesToPrompt(body, { resuming });
999
+ const { promptText, error: promptError } = anthropicMessagesToPrompt(body, { resuming });
1000
+ if (promptError) {
1001
+ return res.status(400).json({
1002
+ type: 'error',
1003
+ error: { type: 'invalid_request_error', message: promptError },
1004
+ });
1005
+ }
941
1006
  const images = collectAnthropicImages(body.messages || []);
942
- const prompt = buildQueryPrompt(promptText, images);
1007
+ // See note in handleStreaming — `prompt` is built lazily inside runQuery
1008
+ // because the multimodal path returns a single-use async iterator that
1009
+ // a 401-retry would exhaust on the first attempt.
943
1010
  const model = resolveModel(body.model);
944
1011
  const toolsForBridge = toolsEnabled
945
1012
  ? body.tools.map((t) => ({
@@ -948,6 +1015,7 @@ async function handleAnthropicStreaming(req, res, body, requestId, sessionKey) {
948
1015
  }))
949
1016
  : null;
950
1017
  const clientToolsServer = toolsForBridge ? buildClientToolsServer(toolsForBridge) : null;
1018
+ const toolsGuidance = clientToolsServer ? buildToolUsageGuidance(toolsForBridge) : null;
951
1019
 
952
1020
  if (images.length) console.log(` [multimodal] ${images.length} image block(s)`);
953
1021
  if (toolsEnabled) console.log(` [tools] ${body.tools.length} client tool(s) registered as MCP`);
@@ -992,6 +1060,9 @@ async function handleAnthropicStreaming(req, res, body, requestId, sessionKey) {
992
1060
  textEmittedSoFar = '';
993
1061
  toolUseEmitted = false;
994
1062
 
1063
+ // Build the prompt lazily on each attempt — multimodal returns a
1064
+ // single-use async iterator. Keeps 401 auth-retries safe.
1065
+ const prompt = buildQueryPrompt(promptText, images);
995
1066
  for await (const message of query({
996
1067
  prompt,
997
1068
  options: {
@@ -1004,6 +1075,7 @@ async function handleAnthropicStreaming(req, res, body, requestId, sessionKey) {
1004
1075
  ? {
1005
1076
  mcpServers: { [MCP_SERVER_NAME]: clientToolsServer },
1006
1077
  allowedTools: [`${MCP_TOOL_PREFIX}*`],
1078
+ systemPrompt: { type: 'preset', preset: 'claude_code', append: toolsGuidance },
1007
1079
  }
1008
1080
  : toolsEnabled
1009
1081
  ? { allowedTools: [] }
@@ -1151,6 +1223,62 @@ async function handleAnthropicStreaming(req, res, body, requestId, sessionKey) {
1151
1223
  const app = express();
1152
1224
  app.use(express.json({ limit: '10mb' }));
1153
1225
 
1226
+ // ---------------------------------------------------------------------------
1227
+ // Same-origin gate for control-plane endpoints
1228
+ // ---------------------------------------------------------------------------
1229
+ // The proxy endpoints (/v1/chat/completions, /v1/messages, /v1/models,
1230
+ // /health) are intentionally open: clients from other localhost processes
1231
+ // (Hermes, OpenClaw, etc.) need to hit them. But the *control-plane*
1232
+ // endpoints — anything that triggers privileged actions (npm install,
1233
+ // auth refresh, session deletion) or exposes sensitive data (server log
1234
+ // containing prompt text, live event metadata) — must NOT be reachable
1235
+ // from a browser tab on a malicious site (DNS-rebinding) or a LAN
1236
+ // attacker (when bind: 0.0.0.0).
1237
+ //
1238
+ // Defense:
1239
+ // - Host header must resolve to localhost. DNS rebinding makes the
1240
+ // network connect to 127.0.0.1, but the browser still sends the
1241
+ // attacker's hostname in the Host header — block it.
1242
+ // - If Origin is present (browsers always send it on POST), the
1243
+ // hostname must also be local. Catches cross-origin fetches.
1244
+ // - Non-browser clients (curl, the dashboard's own JS from same
1245
+ // origin, programmatic callers) sail through fine.
1246
+ //
1247
+ // Limitation: this is NOT a substitute for real auth on a LAN-exposed
1248
+ // proxy. With bind: 0.0.0.0, anyone on the LAN can still hit endpoints
1249
+ // directly with a faked Host header. For v0.7.3 we accept that and warn
1250
+ // in the startup banner; a real `MOBYGATE_TOKEN` for LAN use is a
1251
+ // follow-up.
1252
+
1253
+ function isLocalHostname(name) {
1254
+ if (!name) return false;
1255
+ const lower = String(name).toLowerCase();
1256
+ // Strip optional brackets (IPv6) and port suffix.
1257
+ const stripped = lower.replace(/^\[|\]$/g, '').replace(/:[0-9]+$/, '');
1258
+ return stripped === '127.0.0.1' || stripped === 'localhost' || stripped === '::1';
1259
+ }
1260
+
1261
+ function requireLocalOrigin(req, res, next) {
1262
+ if (!isLocalHostname(req.headers.host)) {
1263
+ return res.status(403).json({
1264
+ error: { type: 'forbidden', message: 'Host header is not localhost. Mobygate refuses non-local origins on control-plane endpoints (DNS-rebinding protection).' },
1265
+ });
1266
+ }
1267
+ const origin = req.headers.origin;
1268
+ if (origin) {
1269
+ try {
1270
+ if (!isLocalHostname(new URL(origin).hostname)) {
1271
+ return res.status(403).json({
1272
+ error: { type: 'forbidden', message: 'Origin header is not localhost. Cross-origin fetch refused on control-plane endpoint.' },
1273
+ });
1274
+ }
1275
+ } catch {
1276
+ return res.status(403).json({ error: { type: 'forbidden', message: 'Invalid Origin header.' } });
1277
+ }
1278
+ }
1279
+ next();
1280
+ }
1281
+
1154
1282
  // GET / — serve dashboard. No-cache headers so browsers always re-fetch
1155
1283
  // after a mobygate upgrade; otherwise they keep serving the old index.html
1156
1284
  // from cache and users see a stale dashboard long after the service updated.
@@ -1374,7 +1502,7 @@ app.get('/sessions/:key', (req, res) => {
1374
1502
  });
1375
1503
 
1376
1504
  // DELETE /sessions/:key — clear a session
1377
- app.delete('/sessions/:key', (req, res) => {
1505
+ app.delete('/sessions/:key', requireLocalOrigin, (req, res) => {
1378
1506
  const existed = sessions.delete(req.params.key);
1379
1507
  if (existed) {
1380
1508
  dashboardBus.emitEvent({ type: 'session.expired', key: req.params.key, reason: 'manual' });
@@ -1384,7 +1512,7 @@ app.delete('/sessions/:key', (req, res) => {
1384
1512
  });
1385
1513
 
1386
1514
  // DELETE /sessions — clear all sessions
1387
- app.delete('/sessions', (_req, res) => {
1515
+ app.delete('/sessions', requireLocalOrigin, (_req, res) => {
1388
1516
  const keys = [...sessions.keys()];
1389
1517
  const count = sessions.size;
1390
1518
  sessions.clear();
@@ -1425,7 +1553,7 @@ app.get('/auth/status', async (req, res) => {
1425
1553
 
1426
1554
  // POST /auth/refresh
1427
1555
  // Fires the refresh probe. Intended for use by cron / launchd.
1428
- app.post('/auth/refresh', async (_req, res) => {
1556
+ app.post('/auth/refresh', requireLocalOrigin, async (_req, res) => {
1429
1557
  const probe = await forceRefresh();
1430
1558
  dashboardBus.emitEvent({ type: 'auth.refresh', ok: probe.ok, durationMs: probe.durationMs, error: probe.error });
1431
1559
  res.status(probe.ok ? 200 : 502).json({
@@ -1439,7 +1567,7 @@ app.post('/auth/refresh', async (_req, res) => {
1439
1567
  // ---------------------------------------------------------------------------
1440
1568
 
1441
1569
  // GET /events — SSE stream of dashboard events
1442
- app.get('/events', (req, res) => {
1570
+ app.get('/events', requireLocalOrigin, (req, res) => {
1443
1571
  res.setHeader('Content-Type', 'text/event-stream');
1444
1572
  res.setHeader('Cache-Control', 'no-cache, no-transform');
1445
1573
  res.setHeader('Connection', 'keep-alive');
@@ -1514,7 +1642,7 @@ app.get('/dashboard/sessions', (_req, res) => {
1514
1642
  });
1515
1643
 
1516
1644
  // GET /dashboard/logs — tail the server log file
1517
- app.get('/dashboard/logs', async (req, res) => {
1645
+ app.get('/dashboard/logs', requireLocalOrigin, async (req, res) => {
1518
1646
  const lines = Math.min(2000, parseInt(req.query.lines || '200', 10));
1519
1647
  const logPath = join(LOGS_DIR, 'server.log');
1520
1648
  try {
@@ -1553,7 +1681,7 @@ app.get('/update/check', async (req, res) => {
1553
1681
  // `npm install -g mobygate@latest` (or `git pull && npm install`), then
1554
1682
  // restarts the service — which kills us. The dashboard polls
1555
1683
  // /update/status to show progress and reconnects once the new server is up.
1556
- app.post('/update/apply', (_req, res) => {
1684
+ app.post('/update/apply', requireLocalOrigin, (_req, res) => {
1557
1685
  try {
1558
1686
  const result = applyUpdate({});
1559
1687
  const status = result.started ? 202 : 409;