mobygate 0.7.2 → 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,76 @@ 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
+
7
77
  ## [0.7.2] — 2026-04-25
8
78
 
9
79
  ### Fixed
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.
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.2",
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
@@ -286,12 +286,20 @@ function messagesToPrompt(messages, { resuming = false } = {}) {
286
286
  }
287
287
  }
288
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
+ }
289
299
  const parts = [];
290
300
  if (toolResultsText) parts.push(toolResultsText);
291
301
  if (userText) parts.push(userText);
292
- return {
293
- promptText: parts.join('\n\n') || extractContent(messages[messages.length - 1]?.content || ''),
294
- };
302
+ return { promptText: parts.join('\n\n') };
295
303
  }
296
304
 
297
305
  // Fresh request: serialize visible history as XML-wrapped text. No
@@ -396,9 +404,20 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
396
404
  const existing = getSession(sessionKey);
397
405
  const resuming = !!existing?.sdkSessionId;
398
406
  const toolsEnabled = hasTools(body);
399
- 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
+ }
400
413
  const images = collectImages(body.messages);
401
- 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.
402
421
  const model = resolveModel(body.model);
403
422
  // Build the in-process MCP server exposing client tools to the SDK.
404
423
  // null when toolsEnabled is false (or all tools are malformed).
@@ -450,6 +469,9 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
450
469
  resolvedModel = model;
451
470
  capturedSessionId = existing?.sdkSessionId || null;
452
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);
453
475
  for await (const message of query({
454
476
  prompt,
455
477
  options: {
@@ -623,9 +645,20 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
623
645
  const existing = getSession(sessionKey);
624
646
  const resuming = !!existing?.sdkSessionId;
625
647
  const toolsEnabled = hasTools(body);
626
- 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
+ }
627
654
  const images = collectImages(body.messages);
628
- 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.
629
662
  const model = resolveModel(body.model);
630
663
  const clientToolsServer = toolsEnabled ? buildClientToolsServer(body.tools) : null;
631
664
  const toolsGuidance = clientToolsServer ? buildToolUsageGuidance(body.tools) : null;
@@ -653,6 +686,9 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
653
686
  outputTokens = 0;
654
687
  capturedSessionId = existing?.sdkSessionId || null;
655
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);
656
692
  for await (const message of query({
657
693
  prompt,
658
694
  options: {
@@ -801,9 +837,17 @@ async function handleAnthropicNonStreaming(res, body, requestId, sessionKey) {
801
837
  const existing = getSession(sessionKey);
802
838
  const resuming = !!existing?.sdkSessionId;
803
839
  const toolsEnabled = hasAnthropicTools(body);
804
- 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
+ }
805
847
  const images = collectAnthropicImages(body.messages || []);
806
- 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.
807
851
  const model = resolveModel(body.model);
808
852
  // Translate Anthropic tool defs → OpenAI shape that buildClientToolsServer
809
853
  // expects. Both go through the same JSON-Schema → Zod path on the way to
@@ -843,6 +887,9 @@ async function handleAnthropicNonStreaming(res, body, requestId, sessionKey) {
843
887
  capturedSessionId = existing?.sdkSessionId || null;
844
888
  stopReason = 'end_turn';
845
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);
846
893
  for await (const message of query({
847
894
  prompt,
848
895
  options: {
@@ -949,9 +996,17 @@ async function handleAnthropicStreaming(req, res, body, requestId, sessionKey) {
949
996
  const existing = getSession(sessionKey);
950
997
  const resuming = !!existing?.sdkSessionId;
951
998
  const toolsEnabled = hasAnthropicTools(body);
952
- 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
+ }
953
1006
  const images = collectAnthropicImages(body.messages || []);
954
- 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.
955
1010
  const model = resolveModel(body.model);
956
1011
  const toolsForBridge = toolsEnabled
957
1012
  ? body.tools.map((t) => ({
@@ -1005,6 +1060,9 @@ async function handleAnthropicStreaming(req, res, body, requestId, sessionKey) {
1005
1060
  textEmittedSoFar = '';
1006
1061
  toolUseEmitted = false;
1007
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);
1008
1066
  for await (const message of query({
1009
1067
  prompt,
1010
1068
  options: {
@@ -1165,6 +1223,62 @@ async function handleAnthropicStreaming(req, res, body, requestId, sessionKey) {
1165
1223
  const app = express();
1166
1224
  app.use(express.json({ limit: '10mb' }));
1167
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
+
1168
1282
  // GET / — serve dashboard. No-cache headers so browsers always re-fetch
1169
1283
  // after a mobygate upgrade; otherwise they keep serving the old index.html
1170
1284
  // from cache and users see a stale dashboard long after the service updated.
@@ -1388,7 +1502,7 @@ app.get('/sessions/:key', (req, res) => {
1388
1502
  });
1389
1503
 
1390
1504
  // DELETE /sessions/:key — clear a session
1391
- app.delete('/sessions/:key', (req, res) => {
1505
+ app.delete('/sessions/:key', requireLocalOrigin, (req, res) => {
1392
1506
  const existed = sessions.delete(req.params.key);
1393
1507
  if (existed) {
1394
1508
  dashboardBus.emitEvent({ type: 'session.expired', key: req.params.key, reason: 'manual' });
@@ -1398,7 +1512,7 @@ app.delete('/sessions/:key', (req, res) => {
1398
1512
  });
1399
1513
 
1400
1514
  // DELETE /sessions — clear all sessions
1401
- app.delete('/sessions', (_req, res) => {
1515
+ app.delete('/sessions', requireLocalOrigin, (_req, res) => {
1402
1516
  const keys = [...sessions.keys()];
1403
1517
  const count = sessions.size;
1404
1518
  sessions.clear();
@@ -1439,7 +1553,7 @@ app.get('/auth/status', async (req, res) => {
1439
1553
 
1440
1554
  // POST /auth/refresh
1441
1555
  // Fires the refresh probe. Intended for use by cron / launchd.
1442
- app.post('/auth/refresh', async (_req, res) => {
1556
+ app.post('/auth/refresh', requireLocalOrigin, async (_req, res) => {
1443
1557
  const probe = await forceRefresh();
1444
1558
  dashboardBus.emitEvent({ type: 'auth.refresh', ok: probe.ok, durationMs: probe.durationMs, error: probe.error });
1445
1559
  res.status(probe.ok ? 200 : 502).json({
@@ -1453,7 +1567,7 @@ app.post('/auth/refresh', async (_req, res) => {
1453
1567
  // ---------------------------------------------------------------------------
1454
1568
 
1455
1569
  // GET /events — SSE stream of dashboard events
1456
- app.get('/events', (req, res) => {
1570
+ app.get('/events', requireLocalOrigin, (req, res) => {
1457
1571
  res.setHeader('Content-Type', 'text/event-stream');
1458
1572
  res.setHeader('Cache-Control', 'no-cache, no-transform');
1459
1573
  res.setHeader('Connection', 'keep-alive');
@@ -1528,7 +1642,7 @@ app.get('/dashboard/sessions', (_req, res) => {
1528
1642
  });
1529
1643
 
1530
1644
  // GET /dashboard/logs — tail the server log file
1531
- app.get('/dashboard/logs', async (req, res) => {
1645
+ app.get('/dashboard/logs', requireLocalOrigin, async (req, res) => {
1532
1646
  const lines = Math.min(2000, parseInt(req.query.lines || '200', 10));
1533
1647
  const logPath = join(LOGS_DIR, 'server.log');
1534
1648
  try {
@@ -1567,7 +1681,7 @@ app.get('/update/check', async (req, res) => {
1567
1681
  // `npm install -g mobygate@latest` (or `git pull && npm install`), then
1568
1682
  // restarts the service — which kills us. The dashboard polls
1569
1683
  // /update/status to show progress and reconnects once the new server is up.
1570
- app.post('/update/apply', (_req, res) => {
1684
+ app.post('/update/apply', requireLocalOrigin, (_req, res) => {
1571
1685
  try {
1572
1686
  const result = applyUpdate({});
1573
1687
  const status = result.started ? 202 : 409;