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 +70 -0
- package/bin/mobygate.js +64 -41
- package/lib/anthropic.js +14 -3
- package/lib/platform.js +74 -0
- package/lib/updater.js +38 -20
- package/package.json +1 -1
- package/server.js +131 -17
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
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
325
|
-
|
|
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
|
|
329
|
-
|
|
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
|
|
333
|
-
if (
|
|
334
|
-
|
|
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
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
// can replace open files freely, but stopping early there
|
|
588
|
-
// and gives a cleaner restart sequence — so we do it
|
|
589
|
-
|
|
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
|
-
|
|
612
|
+
stopWindowsTask(WIN_LABELS.auth);
|
|
594
613
|
} else if (IS_MAC) {
|
|
595
|
-
|
|
596
|
-
launchctlUnload(
|
|
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
|
-
|
|
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
|
|
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
|
|
644
|
+
info('Starting services on the new build...');
|
|
624
645
|
if (IS_MAC) {
|
|
625
|
-
|
|
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')
|
|
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
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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]
|
|
193
|
-
steps.push(`schtasks /Run /TN "${
|
|
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.
|
|
200
|
-
//
|
|
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
|
|
213
|
+
parts.push(`echo "[mobygate-update] stopping services"`);
|
|
203
214
|
if (IS_MAC) {
|
|
204
|
-
const
|
|
205
|
-
|
|
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 ${
|
|
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
|
|
231
|
+
parts.push(`echo "[mobygate-update] starting services on new build"`);
|
|
217
232
|
if (IS_MAC) {
|
|
218
|
-
const
|
|
219
|
-
|
|
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 ${
|
|
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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|