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 +105 -0
- package/bin/mobygate.js +64 -41
- package/lib/anthropic.js +14 -3
- package/lib/platform.js +74 -0
- package/lib/tool-bridge.js +44 -0
- package/lib/updater.js +38 -20
- package/package.json +1 -1
- package/server.js +145 -17
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
|
|
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/tool-bridge.js
CHANGED
|
@@ -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
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|