@marsnme/mcp-gateway 0.3.0 → 0.3.2
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/lib/auth-guard.mjs +54 -0
- package/package.json +3 -2
- package/scripts/tests/test_auth_guard.mjs +74 -0
- package/server.mjs +414 -12
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// MARS-422: bearer bypass guard.
|
|
2
|
+
//
|
|
3
|
+
// Before this module, the gateway decided "internal request" from the
|
|
4
|
+
// client-supplied `X-Forwarded-Host` header alone. Cloudflare forwards that header
|
|
5
|
+
// untouched, so any internet client could send `X-Forwarded-Host: 127.0.0.1` and get
|
|
6
|
+
// full unauthenticated access to coco / toto / draft.
|
|
7
|
+
//
|
|
8
|
+
// Rule now: the private bypass requires BOTH
|
|
9
|
+
// 1. no Cloudflare edge markers (cf-ray / cf-connecting-ip), and
|
|
10
|
+
// 2. a private socket peer (loopback or RFC1918 / IPv6 local).
|
|
11
|
+
// cloudflared always adds the edge markers, so public traffic can never qualify.
|
|
12
|
+
// In-process and LAN callers (dream runner, coco-memory-local over the SSH tunnel to
|
|
13
|
+
// 192.168.100.102) keep working because their socket peer is private.
|
|
14
|
+
|
|
15
|
+
const EDGE_MARKER_HEADERS = ['cf-ray', 'cf-connecting-ip'];
|
|
16
|
+
|
|
17
|
+
export function isEdgeRequest(headers = {}) {
|
|
18
|
+
return EDGE_MARKER_HEADERS.some((name) => Boolean(headers[name]));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ipv4IsPrivate(ip) {
|
|
22
|
+
const parts = ip.split('.').map((n) => Number.parseInt(n, 10));
|
|
23
|
+
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) return false;
|
|
24
|
+
const [a, b] = parts;
|
|
25
|
+
if (a === 10) return true;
|
|
26
|
+
if (a === 127) return true;
|
|
27
|
+
if (a === 192 && b === 168) return true;
|
|
28
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
29
|
+
if (a === 169 && b === 254) return true; // link-local
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isPrivatePeer(remoteAddress) {
|
|
34
|
+
if (!remoteAddress) return false;
|
|
35
|
+
let addr = String(remoteAddress).trim().toLowerCase();
|
|
36
|
+
// Normalise IPv4-mapped IPv6 (::ffff:192.168.1.5).
|
|
37
|
+
const mapped = addr.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
|
|
38
|
+
if (mapped) addr = mapped[1];
|
|
39
|
+
if (addr === '::1' || addr === '::') return true;
|
|
40
|
+
if (addr.includes(':')) {
|
|
41
|
+
// IPv6 link-local (fe80::/10) and unique-local (fc00::/7).
|
|
42
|
+
return addr.startsWith('fe8') || addr.startsWith('fe9') || addr.startsWith('fea') ||
|
|
43
|
+
addr.startsWith('feb') || addr.startsWith('fc') || addr.startsWith('fd');
|
|
44
|
+
}
|
|
45
|
+
return ipv4IsPrivate(addr);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function shouldRequireBearer({ headers, remoteAddress, requireBearer, bypassForPrivate }) {
|
|
49
|
+
if (!requireBearer) return false;
|
|
50
|
+
if (bypassForPrivate && !isEdgeRequest(headers) && isPrivatePeer(remoteAddress)) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@marsnme/mcp-gateway",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"mcpName": "io.github.Marsmanleo/marsnme",
|
|
5
5
|
"private": false,
|
|
6
6
|
"description": "Agent-agnostic, LLM-agnostic memory backend for MCP-compatible tools",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"marsnme": "server.mjs"
|
|
34
34
|
},
|
|
35
35
|
"files": [
|
|
36
|
+
"lib/",
|
|
36
37
|
"server.mjs",
|
|
37
38
|
"scripts/",
|
|
38
39
|
"deploy/phase2/",
|
|
@@ -49,4 +50,4 @@
|
|
|
49
50
|
"engines": {
|
|
50
51
|
"node": ">=20"
|
|
51
52
|
}
|
|
52
|
-
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// MARS-422: bearer bypass guard (RED first — see scripts/tests/test_auth_guard.mjs)
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { shouldRequireBearer } from '../../lib/auth-guard.mjs';
|
|
5
|
+
|
|
6
|
+
const base = { requireBearer: true, bypassForPrivate: true };
|
|
7
|
+
|
|
8
|
+
test('public request via Cloudflare edge must require bearer even with private X-Forwarded-Host', () => {
|
|
9
|
+
const headers = {
|
|
10
|
+
'cf-ray': '8b1f9c2d4e5f-HKG',
|
|
11
|
+
'cf-connecting-ip': '203.0.113.7',
|
|
12
|
+
'x-forwarded-host': '127.0.0.1',
|
|
13
|
+
host: 'coco-mcp.marsgroup.asia',
|
|
14
|
+
};
|
|
15
|
+
assert.equal(
|
|
16
|
+
shouldRequireBearer({ ...base, headers, remoteAddress: '127.0.0.1' }),
|
|
17
|
+
true,
|
|
18
|
+
'spoofed X-Forwarded-Host from the internet must NOT bypass auth'
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('edge request from a LAN socket peer still requires bearer', () => {
|
|
23
|
+
const headers = { 'cf-ray': 'x', 'x-forwarded-host': '192.168.100.102' };
|
|
24
|
+
assert.equal(shouldRequireBearer({ ...base, headers, remoteAddress: '192.168.100.102' }), true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('request with cf-connecting-ip only (no cf-ray) is also treated as edge', () => {
|
|
28
|
+
const headers = { 'cf-connecting-ip': '203.0.113.7', 'x-forwarded-host': '10.0.0.5' };
|
|
29
|
+
assert.equal(shouldRequireBearer({ ...base, headers, remoteAddress: '127.0.0.1' }), true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('loopback caller without edge headers keeps the private bypass', () => {
|
|
33
|
+
assert.equal(
|
|
34
|
+
shouldRequireBearer({ ...base, headers: { host: '127.0.0.1:18790' }, remoteAddress: '127.0.0.1' }),
|
|
35
|
+
false
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('LAN caller without edge headers keeps the private bypass (SSH tunnel case)', () => {
|
|
40
|
+
// Pi's coco-memory-local reaches the CT as 192.168.100.x over ssh -L.
|
|
41
|
+
const headers = { host: '192.168.100.102:18790', 'x-forwarded-host': '127.0.0.1' };
|
|
42
|
+
assert.equal(shouldRequireBearer({ ...base, headers, remoteAddress: '192.168.100.102' }), false);
|
|
43
|
+
assert.equal(shouldRequireBearer({ ...base, headers, remoteAddress: '10.4.5.6' }), false);
|
|
44
|
+
assert.equal(shouldRequireBearer({ ...base, headers, remoteAddress: '172.20.1.9' }), false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('public peer without edge headers must NOT bypass', () => {
|
|
48
|
+
const headers = { 'x-forwarded-host': '127.0.0.1', host: '127.0.0.1:18790' };
|
|
49
|
+
assert.equal(
|
|
50
|
+
shouldRequireBearer({ ...base, headers, remoteAddress: '203.0.113.7' }),
|
|
51
|
+
true,
|
|
52
|
+
'a public client reaching the port directly must not be trusted'
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('requireBearer=false disables auth entirely (unchanged behaviour)', () => {
|
|
57
|
+
const headers = { 'cf-ray': 'x', 'x-forwarded-host': '127.0.0.1' };
|
|
58
|
+
assert.equal(
|
|
59
|
+
shouldRequireBearer({ requireBearer: false, bypassForPrivate: true, headers, remoteAddress: '127.0.0.1' }),
|
|
60
|
+
false
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('bypassForPrivate=false always requires bearer', () => {
|
|
65
|
+
assert.equal(
|
|
66
|
+
shouldRequireBearer({
|
|
67
|
+
requireBearer: true,
|
|
68
|
+
bypassForPrivate: false,
|
|
69
|
+
headers: { host: '127.0.0.1:18790' },
|
|
70
|
+
remoteAddress: '127.0.0.1',
|
|
71
|
+
}),
|
|
72
|
+
true
|
|
73
|
+
);
|
|
74
|
+
});
|
package/server.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
5
5
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
7
|
import { dirname } from 'node:path';
|
|
8
|
+
import { shouldRequireBearer as guardShouldRequireBearer } from './lib/auth-guard.mjs';
|
|
8
9
|
|
|
9
10
|
const PROFILE_CONFIGS = {
|
|
10
11
|
coco: {
|
|
@@ -13,7 +14,7 @@ const PROFILE_CONFIGS = {
|
|
|
13
14
|
defaultPort: 18790,
|
|
14
15
|
gatewayDir: 'coco-mcp-gateway',
|
|
15
16
|
publicHostSuffix: 'coco-mcp.marsgroup.asia',
|
|
16
|
-
sourceWhitelist: ['perplexity', 'cursor', 'warp', 'openclaw', 'hermes', 'draft', 'grok'],
|
|
17
|
+
sourceWhitelist: ['perplexity', 'cursor', 'warp', 'openclaw', 'hermes', 'draft', 'grok', 'omp'],
|
|
17
18
|
recallBodyEnum: ['coco', 'toto', 'system'],
|
|
18
19
|
digestDefaultOrigin: 'hermes-coco-digest',
|
|
19
20
|
memoryIngestToolName: 'memory_ingest',
|
|
@@ -24,6 +25,7 @@ const PROFILE_CONFIGS = {
|
|
|
24
25
|
'perplexity-coco',
|
|
25
26
|
'cursor-coco',
|
|
26
27
|
'warp-coco',
|
|
28
|
+
'omp-coco',
|
|
27
29
|
'leo-manual',
|
|
28
30
|
'hermes-coco-digest'
|
|
29
31
|
],
|
|
@@ -261,7 +263,8 @@ const SOURCE_TO_AGENT_BODY_MAP = new Map([
|
|
|
261
263
|
['warp', 'warp'],
|
|
262
264
|
['openclaw', 'desktop'],
|
|
263
265
|
['hermes', 'desktop'],
|
|
264
|
-
['draft', 'desktop']
|
|
266
|
+
['draft', 'desktop'],
|
|
267
|
+
['omp', 'omp']
|
|
265
268
|
]);
|
|
266
269
|
const DEFAULT_AGENT_BODY = String(process.env.MCP_AGENT_BODY || '')
|
|
267
270
|
.trim()
|
|
@@ -613,7 +616,7 @@ function buildTools() {
|
|
|
613
616
|
{
|
|
614
617
|
name: 'session_close',
|
|
615
618
|
description:
|
|
616
|
-
`Save a ${PROFILE.displayName} session close summary before ending a conversation. The summary is stored as a short-term memory with 7-day retention, providing context for the next session boot. After saving, automatically promotes up to 5 soon-expiring short-term memories to long-term storage (alert window 48h); promote failures never fail the close. Include what was accomplished, what is still pending, and any important context for continuity.`,
|
|
619
|
+
`Save a ${PROFILE.displayName} session close summary before ending a conversation. The summary is stored as a short-term memory with 7-day retention, providing context for the next session boot. After saving, automatically promotes up to 5 soon-expiring short-term memories to long-term storage (alert window 48h); promote failures never fail the close. Also auto-posts a session-close broadcast to the CoCo family board (board_posts) so other bodies see what was accomplished/pending on next session_boot; board post failures never fail the close. Include what was accomplished, what is still pending, and any important context for continuity.`,
|
|
617
620
|
inputSchema: {
|
|
618
621
|
type: 'object',
|
|
619
622
|
properties: {
|
|
@@ -638,6 +641,26 @@ function buildTools() {
|
|
|
638
641
|
note: {
|
|
639
642
|
type: 'string',
|
|
640
643
|
description: 'Short context handoff for the recipient body (used with `to`)'
|
|
644
|
+
},
|
|
645
|
+
body_name: {
|
|
646
|
+
type: 'string',
|
|
647
|
+
description: 'Optional posting body name for the auto board_post (e.g. "四哥", "三哥"). Defaults to profile name.'
|
|
648
|
+
},
|
|
649
|
+
auto_board_post: {
|
|
650
|
+
type: 'boolean',
|
|
651
|
+
default: true,
|
|
652
|
+
description: 'When true (default), auto-post a session-close broadcast to the family board. Set false to skip.'
|
|
653
|
+
},
|
|
654
|
+
board_type: {
|
|
655
|
+
type: 'string',
|
|
656
|
+
enum: ['stuck', 'turn', 'collide', 'gate'],
|
|
657
|
+
default: 'turn',
|
|
658
|
+
description: 'Board post type for the auto broadcast (default "turn")'
|
|
659
|
+
},
|
|
660
|
+
board_topic: {
|
|
661
|
+
type: 'string',
|
|
662
|
+
default: 'session-close',
|
|
663
|
+
description: 'Board topic tag for the auto broadcast (default "session-close")'
|
|
641
664
|
}
|
|
642
665
|
},
|
|
643
666
|
required: ['source', 'summary'],
|
|
@@ -816,6 +839,80 @@ function buildTools() {
|
|
|
816
839
|
},
|
|
817
840
|
additionalProperties: false
|
|
818
841
|
}
|
|
842
|
+
},
|
|
843
|
+
// ── MARS-318: Board v0 ─────────────────────────────────────
|
|
844
|
+
{
|
|
845
|
+
name: 'board_read',
|
|
846
|
+
description: 'Read unread board posts (the CoCo family wall). Returns posts not yet acknowledged by the calling body. Called automatically by session_boot, but can also be called manually.',
|
|
847
|
+
inputSchema: {
|
|
848
|
+
type: 'object',
|
|
849
|
+
properties: {
|
|
850
|
+
body: { type: 'string', description: 'The body name reading the board (e.g. "三哥", "四哥", "大家姐", "leo")' },
|
|
851
|
+
limit: { type: 'number', minimum: 1, maximum: 50, default: 10, description: 'Maximum posts to return' },
|
|
852
|
+
topic: { type: 'string', description: 'Optional topic filter' },
|
|
853
|
+
include_acked: { type: 'boolean', default: false, description: 'Include already-acknowledged posts' }
|
|
854
|
+
},
|
|
855
|
+
required: ['body'],
|
|
856
|
+
additionalProperties: false
|
|
857
|
+
}
|
|
858
|
+
},
|
|
859
|
+
{
|
|
860
|
+
name: 'board_post',
|
|
861
|
+
description: 'Post a message to the CoCo family board (wall). to="all" broadcasts to the wall; to="<body>" is a private note.',
|
|
862
|
+
inputSchema: {
|
|
863
|
+
type: 'object',
|
|
864
|
+
properties: {
|
|
865
|
+
from_source: { type: 'string', description: 'Source platform (warp / cursor / perplexity / grok / hermes / draft)' },
|
|
866
|
+
from_body: { type: 'string', description: 'Posting body name (e.g. "三哥", "leo")' },
|
|
867
|
+
to: { type: 'string', default: 'all', description: '"all" for wall broadcast; specific body name for private note' },
|
|
868
|
+
topic: { type: 'string', description: 'Topic tag (e.g. "board-v0", "egress")' },
|
|
869
|
+
type: { type: 'string', enum: ['stuck', 'turn', 'collide', 'gate'], default: 'turn', description: 'Post type' },
|
|
870
|
+
text: { type: 'string', description: 'Post content (max 2000 chars)' },
|
|
871
|
+
expires_at: { type: 'string', description: 'Optional expiry in ISO 8601. null = permanent.' }
|
|
872
|
+
},
|
|
873
|
+
required: ['from_source', 'from_body', 'text'],
|
|
874
|
+
additionalProperties: false
|
|
875
|
+
}
|
|
876
|
+
},
|
|
877
|
+
{
|
|
878
|
+
name: 'board_ack',
|
|
879
|
+
description: 'Acknowledge (mark as read) one or more board posts for a specific body.',
|
|
880
|
+
inputSchema: {
|
|
881
|
+
type: 'object',
|
|
882
|
+
properties: {
|
|
883
|
+
body: { type: 'string', description: 'The body acknowledging (e.g. "三哥")' },
|
|
884
|
+
ids: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50, description: 'Board post UUIDs to acknowledge' }
|
|
885
|
+
},
|
|
886
|
+
required: ['body', 'ids'],
|
|
887
|
+
additionalProperties: false
|
|
888
|
+
}
|
|
889
|
+
},
|
|
890
|
+
// MARS-327: board_get_full — retrieve full text of a single board post
|
|
891
|
+
{
|
|
892
|
+
name: 'board_get_full',
|
|
893
|
+
description: 'Get the full untruncated text of a single board post. Use when board_read or session_boot shows truncated=true and you need the complete content.',
|
|
894
|
+
inputSchema: {
|
|
895
|
+
type: 'object',
|
|
896
|
+
properties: {
|
|
897
|
+
id: { type: 'string', description: 'Board post UUID' }
|
|
898
|
+
},
|
|
899
|
+
required: ['id'],
|
|
900
|
+
additionalProperties: false
|
|
901
|
+
}
|
|
902
|
+
},
|
|
903
|
+
// board_resolve — mark posts as globally resolved
|
|
904
|
+
{
|
|
905
|
+
name: 'board_resolve',
|
|
906
|
+
description: 'Resolve one or more board posts globally. Resolved posts are hidden from board_read and session_boot for ALL bodies (unlike ack which is per-body). Use to close out stuck/gate posts that have been addressed.',
|
|
907
|
+
inputSchema: {
|
|
908
|
+
type: 'object',
|
|
909
|
+
properties: {
|
|
910
|
+
body: { type: 'string', description: 'The body resolving (e.g. "三哥")' },
|
|
911
|
+
ids: { type: 'array', items: { type: 'string' }, minItems: 1, maxItems: 50, description: 'Board post UUIDs to resolve' }
|
|
912
|
+
},
|
|
913
|
+
required: ['body', 'ids'],
|
|
914
|
+
additionalProperties: false
|
|
915
|
+
}
|
|
819
916
|
}
|
|
820
917
|
];
|
|
821
918
|
}
|
|
@@ -1609,6 +1706,8 @@ function estimateToolUsageTokens(toolName, args = {}) {
|
|
|
1609
1706
|
appendTokenCharCount(safeArgs.summary, state);
|
|
1610
1707
|
appendTokenCharCount(safeArgs.topics, state);
|
|
1611
1708
|
appendTokenCharCount(safeArgs.mood, state);
|
|
1709
|
+
appendTokenCharCount(safeArgs.body_name, state);
|
|
1710
|
+
appendTokenCharCount(safeArgs.board_topic, state);
|
|
1612
1711
|
break;
|
|
1613
1712
|
case 'session_boot':
|
|
1614
1713
|
appendTokenCharCount(safeArgs.topic, state);
|
|
@@ -2440,6 +2539,54 @@ async function runDailyBoot(args = {}) {
|
|
|
2440
2539
|
}
|
|
2441
2540
|
}
|
|
2442
2541
|
|
|
2542
|
+
// MARS-318: Board v0 — auto-fetch unread wall posts for this body
|
|
2543
|
+
let boardUnread = [];
|
|
2544
|
+
const boardBody = bodyName || inferAgentBodyFromSource(source) || DB_PROFILE;
|
|
2545
|
+
try {
|
|
2546
|
+
const bQuery = new URLSearchParams();
|
|
2547
|
+
bQuery.set('select', 'id,from_source,from_body,to,topic,type,text,acked_by,created_at');
|
|
2548
|
+
bQuery.set('order', 'created_at.desc');
|
|
2549
|
+
bQuery.set('limit', '5');
|
|
2550
|
+
bQuery.set('archived_at', 'is.null');
|
|
2551
|
+
bQuery.set('resolved_at', 'is.null');
|
|
2552
|
+
bQuery.set('or', `(to.eq.all,to.eq.${boardBody})`);
|
|
2553
|
+
const bResult = await supabaseRequest(`/rest/v1/board_posts?${bQuery.toString()}`, { profile: DB_PROFILE });
|
|
2554
|
+
const bPosts = Array.isArray(bResult) ? bResult : [];
|
|
2555
|
+
const BOOT_BOARD_PREVIEW_MAX = 160;
|
|
2556
|
+
// MARS-328: priority types surface first
|
|
2557
|
+
const BOARD_PRIORITY_TYPES = new Set(['gate', 'stuck']);
|
|
2558
|
+
boardUnread = bPosts
|
|
2559
|
+
.filter(p => {
|
|
2560
|
+
const ab = p.acked_by && typeof p.acked_by === 'object' ? p.acked_by : {};
|
|
2561
|
+
return !ab[boardBody];
|
|
2562
|
+
})
|
|
2563
|
+
.map(p => {
|
|
2564
|
+
const fullText = p.text || '';
|
|
2565
|
+
const preview = fullText.slice(0, BOOT_BOARD_PREVIEW_MAX);
|
|
2566
|
+
return {
|
|
2567
|
+
id: p.id,
|
|
2568
|
+
from: `${p.from_body}@${p.from_source}`,
|
|
2569
|
+
to: p.to,
|
|
2570
|
+
topic: p.topic || null,
|
|
2571
|
+
type: p.type,
|
|
2572
|
+
text: preview,
|
|
2573
|
+
truncated: fullText.length > BOOT_BOARD_PREVIEW_MAX,
|
|
2574
|
+
full_length: fullText.length,
|
|
2575
|
+
preview_length: preview.length,
|
|
2576
|
+
created_at: p.created_at
|
|
2577
|
+
};
|
|
2578
|
+
})
|
|
2579
|
+
.sort((a, b) => {
|
|
2580
|
+
const aPri = BOARD_PRIORITY_TYPES.has(a.type) ? 0 : 1;
|
|
2581
|
+
const bPri = BOARD_PRIORITY_TYPES.has(b.type) ? 0 : 1;
|
|
2582
|
+
if (aPri !== bPri) return aPri - bPri;
|
|
2583
|
+
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
|
2584
|
+
});
|
|
2585
|
+
} catch {
|
|
2586
|
+
// board_posts table may not exist yet — degrade gracefully
|
|
2587
|
+
boardUnread = [];
|
|
2588
|
+
}
|
|
2589
|
+
|
|
2443
2590
|
return {
|
|
2444
2591
|
ok: true,
|
|
2445
2592
|
profile: DB_PROFILE,
|
|
@@ -2447,6 +2594,9 @@ async function runDailyBoot(args = {}) {
|
|
|
2447
2594
|
source,
|
|
2448
2595
|
date: dateKey,
|
|
2449
2596
|
notes,
|
|
2597
|
+
board_unread: boardUnread,
|
|
2598
|
+
// MARS-328: remind body to post stuck/gate when blocked mid-session
|
|
2599
|
+
board_reminder: '中途卡住請用 board_post type=stuck 上牆,唔好等到 session_close。',
|
|
2450
2600
|
heartbeat_id: savedHeartbeat?.id || null,
|
|
2451
2601
|
agent_name: queries.agent_name,
|
|
2452
2602
|
last_topic: previousTopic || null,
|
|
@@ -2580,6 +2730,51 @@ async function runDailyClose(args = {}) {
|
|
|
2580
2730
|
}
|
|
2581
2731
|
}
|
|
2582
2732
|
|
|
2733
|
+
// Auto-post a session-close broadcast to the CoCo family board.
|
|
2734
|
+
// Mirrors board_post insert; failures never break session_close (summary already stored).
|
|
2735
|
+
let boardPostResult = null;
|
|
2736
|
+
const autoBoardPost = args.auto_board_post !== false;
|
|
2737
|
+
if (autoBoardPost) {
|
|
2738
|
+
try {
|
|
2739
|
+
const boardFromSource = source;
|
|
2740
|
+
const boardFromBody = normalizeOptionalText(args.body_name, 60) || DB_PROFILE;
|
|
2741
|
+
const boardTo = 'all';
|
|
2742
|
+
const boardType = ['stuck', 'turn', 'collide', 'gate'].includes(args.board_type)
|
|
2743
|
+
? args.board_type
|
|
2744
|
+
: 'turn';
|
|
2745
|
+
const boardTopic = normalizeOptionalText(args.board_topic, 60) || 'session-close';
|
|
2746
|
+
const boardText = String(summary || '').slice(0, 2000);
|
|
2747
|
+
if (boardText) {
|
|
2748
|
+
const boardPayload = {
|
|
2749
|
+
from_source: boardFromSource,
|
|
2750
|
+
from_body: boardFromBody,
|
|
2751
|
+
to: boardTo,
|
|
2752
|
+
topic: boardTopic,
|
|
2753
|
+
type: boardType,
|
|
2754
|
+
text: boardText
|
|
2755
|
+
};
|
|
2756
|
+
const boardInserted = await supabaseRequest(
|
|
2757
|
+
'/rest/v1/board_posts?select=id,from_source,from_body,to,topic,type,text,created_at',
|
|
2758
|
+
{
|
|
2759
|
+
method: 'POST',
|
|
2760
|
+
profile: DB_PROFILE,
|
|
2761
|
+
prefer: 'return=representation',
|
|
2762
|
+
body: [boardPayload]
|
|
2763
|
+
}
|
|
2764
|
+
);
|
|
2765
|
+
boardPostResult = { posted: true, id: boardInserted?.[0]?.id || null };
|
|
2766
|
+
} else {
|
|
2767
|
+
boardPostResult = { posted: false, reason: 'empty summary' };
|
|
2768
|
+
}
|
|
2769
|
+
} catch (error) {
|
|
2770
|
+
// board_posts table may not exist yet, or insert failed — degrade gracefully.
|
|
2771
|
+
console.warn(`[session_close] auto board_post failed: ${String(error?.message || error)}`);
|
|
2772
|
+
boardPostResult = { posted: false, error: String(error?.message || error) };
|
|
2773
|
+
}
|
|
2774
|
+
} else {
|
|
2775
|
+
boardPostResult = { posted: false, skipped: true };
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2583
2778
|
return {
|
|
2584
2779
|
ok: true,
|
|
2585
2780
|
profile: DB_PROFILE,
|
|
@@ -2591,6 +2786,7 @@ async function runDailyClose(args = {}) {
|
|
|
2591
2786
|
mood: mood || null,
|
|
2592
2787
|
tool_usage_daily_summary: toolUsageDailySummary,
|
|
2593
2788
|
note: noteResult,
|
|
2789
|
+
board_post: boardPostResult,
|
|
2594
2790
|
promoted_count: promotedCount,
|
|
2595
2791
|
expiry_alert: {
|
|
2596
2792
|
soon_expiring_count: soonExpiringCount,
|
|
@@ -2660,6 +2856,15 @@ function evaluatePromotionCandidate(memory, nowTs, longTermTagSet) {
|
|
|
2660
2856
|
reason: '已 promoted'
|
|
2661
2857
|
};
|
|
2662
2858
|
}
|
|
2859
|
+
// Heartbeat / session_close lifecycle memories should never be promoted —
|
|
2860
|
+
// they contain query strings that pollute recall results with high similarity.
|
|
2861
|
+
if (isLifecycleMemoryRow(memory)) {
|
|
2862
|
+
return {
|
|
2863
|
+
recommend: 'N',
|
|
2864
|
+
score: 0,
|
|
2865
|
+
reason: 'lifecycle memory (heartbeat/session_close) 唔應升級'
|
|
2866
|
+
};
|
|
2867
|
+
}
|
|
2663
2868
|
const reasons = [];
|
|
2664
2869
|
let score = 0;
|
|
2665
2870
|
|
|
@@ -4258,6 +4463,204 @@ async function callTool(name, args = {}) {
|
|
|
4258
4463
|
};
|
|
4259
4464
|
}
|
|
4260
4465
|
|
|
4466
|
+
// ── MARS-318: Board v0 handlers ──────────────────────────
|
|
4467
|
+
if (toolName === 'board_read') {
|
|
4468
|
+
const body = String(args.body || '').trim();
|
|
4469
|
+
if (!body) throw new Error('body is required');
|
|
4470
|
+
const limitRaw = Number(args.limit ?? 10);
|
|
4471
|
+
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(50, Math.trunc(limitRaw))) : 10;
|
|
4472
|
+
const includeAcked = Boolean(args.include_acked);
|
|
4473
|
+
const topic = args.topic ? String(args.topic).trim() : '';
|
|
4474
|
+
|
|
4475
|
+
const query = new URLSearchParams();
|
|
4476
|
+
query.set('select', 'id,from_source,from_body,to,topic,type,text,acked_by,expires_at,archived_at,created_at');
|
|
4477
|
+
query.set('order', 'created_at.desc');
|
|
4478
|
+
query.set('limit', String(limit));
|
|
4479
|
+
query.set('archived_at', 'is.null');
|
|
4480
|
+
query.set('resolved_at', 'is.null');
|
|
4481
|
+
// Wall posts (to=all) + posts addressed to this body
|
|
4482
|
+
query.set('or', `(to.eq.all,to.eq.${body})`);
|
|
4483
|
+
if (topic) query.set('topic', `eq.${topic}`);
|
|
4484
|
+
// Exclude expired
|
|
4485
|
+
query.set('or', `(expires_at.is.null,expires_at.gt.${new Date().toISOString()})`);
|
|
4486
|
+
|
|
4487
|
+
const result = await supabaseRequest(`/rest/v1/board_posts?${query.toString()}`, { profile: DB_PROFILE });
|
|
4488
|
+
const posts = Array.isArray(result) ? result : [];
|
|
4489
|
+
|
|
4490
|
+
// Filter out already-acked posts unless include_acked
|
|
4491
|
+
const filtered = includeAcked
|
|
4492
|
+
? posts
|
|
4493
|
+
: posts.filter(p => {
|
|
4494
|
+
const ackedBy = p.acked_by && typeof p.acked_by === 'object' ? p.acked_by : {};
|
|
4495
|
+
return !ackedBy[body];
|
|
4496
|
+
});
|
|
4497
|
+
|
|
4498
|
+
const BOARD_PREVIEW_MAX = 160;
|
|
4499
|
+
// MARS-328: gate/stuck surface first
|
|
4500
|
+
const BOARD_READ_PRIORITY_TYPES = new Set(['gate', 'stuck']);
|
|
4501
|
+
const mapped = filtered.map(p => {
|
|
4502
|
+
const fullText = p.text || '';
|
|
4503
|
+
const preview = fullText.slice(0, BOARD_PREVIEW_MAX);
|
|
4504
|
+
return {
|
|
4505
|
+
id: p.id,
|
|
4506
|
+
from: `${p.from_body}@${p.from_source}`,
|
|
4507
|
+
to: p.to,
|
|
4508
|
+
topic: p.topic || null,
|
|
4509
|
+
type: p.type,
|
|
4510
|
+
text: preview,
|
|
4511
|
+
truncated: fullText.length > BOARD_PREVIEW_MAX,
|
|
4512
|
+
full_length: fullText.length,
|
|
4513
|
+
preview_length: preview.length,
|
|
4514
|
+
created_at: p.created_at,
|
|
4515
|
+
acked: !!(p.acked_by && p.acked_by[body])
|
|
4516
|
+
};
|
|
4517
|
+
}).sort((a, b) => {
|
|
4518
|
+
const aPri = BOARD_READ_PRIORITY_TYPES.has(a.type) ? 0 : 1;
|
|
4519
|
+
const bPri = BOARD_READ_PRIORITY_TYPES.has(b.type) ? 0 : 1;
|
|
4520
|
+
if (aPri !== bPri) return aPri - bPri;
|
|
4521
|
+
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
|
4522
|
+
});
|
|
4523
|
+
return {
|
|
4524
|
+
ok: true,
|
|
4525
|
+
count: mapped.length,
|
|
4526
|
+
body,
|
|
4527
|
+
posts: mapped
|
|
4528
|
+
};
|
|
4529
|
+
}
|
|
4530
|
+
|
|
4531
|
+
if (toolName === 'board_post') {
|
|
4532
|
+
const fromSource = String(args.from_source || '').trim();
|
|
4533
|
+
const fromBody = String(args.from_body || '').trim();
|
|
4534
|
+
const to = String(args.to || 'all').trim();
|
|
4535
|
+
const topic = args.topic ? String(args.topic).trim() : null;
|
|
4536
|
+
const type = args.type || 'turn';
|
|
4537
|
+
const text = String(args.text || '').trim();
|
|
4538
|
+
const expiresAt = args.expires_at ? String(args.expires_at).trim() : null;
|
|
4539
|
+
|
|
4540
|
+
if (!fromSource) throw new Error('from_source is required');
|
|
4541
|
+
if (!fromBody) throw new Error('from_body is required');
|
|
4542
|
+
if (!text) throw new Error('text is required');
|
|
4543
|
+
if (text.length > 2000) throw new Error('text too long (max 2000 chars)');
|
|
4544
|
+
|
|
4545
|
+
const payload = {
|
|
4546
|
+
from_source: fromSource,
|
|
4547
|
+
from_body: fromBody,
|
|
4548
|
+
to,
|
|
4549
|
+
topic,
|
|
4550
|
+
type,
|
|
4551
|
+
text
|
|
4552
|
+
};
|
|
4553
|
+
if (expiresAt) payload.expires_at = expiresAt;
|
|
4554
|
+
|
|
4555
|
+
const result = await supabaseRequest(
|
|
4556
|
+
'/rest/v1/board_posts?select=id,from_source,from_body,to,topic,type,text,created_at',
|
|
4557
|
+
{
|
|
4558
|
+
method: 'POST',
|
|
4559
|
+
profile: DB_PROFILE,
|
|
4560
|
+
prefer: 'return=representation',
|
|
4561
|
+
body: [payload]
|
|
4562
|
+
}
|
|
4563
|
+
);
|
|
4564
|
+
const inserted = result?.[0] ?? null;
|
|
4565
|
+
return {
|
|
4566
|
+
ok: true,
|
|
4567
|
+
inserted,
|
|
4568
|
+
text_length: text.length
|
|
4569
|
+
};
|
|
4570
|
+
}
|
|
4571
|
+
|
|
4572
|
+
if (toolName === 'board_ack') {
|
|
4573
|
+
const body = String(args.body || '').trim();
|
|
4574
|
+
const ids = Array.isArray(args.ids) ? args.ids.map(id => String(id).trim()).filter(Boolean) : [];
|
|
4575
|
+
if (!body) throw new Error('body is required');
|
|
4576
|
+
if (!ids.length) throw new Error('ids is required (at least 1)');
|
|
4577
|
+
|
|
4578
|
+
const now = new Date().toISOString();
|
|
4579
|
+
let acked = 0;
|
|
4580
|
+
const notFoundIds = [];
|
|
4581
|
+
for (const id of ids) {
|
|
4582
|
+
// Read current acked_by, merge, write back
|
|
4583
|
+
const rows = await supabaseRequest(
|
|
4584
|
+
`/rest/v1/board_posts?id=eq.${id}&select=id,acked_by`,
|
|
4585
|
+
{ profile: DB_PROFILE }
|
|
4586
|
+
);
|
|
4587
|
+
const row = rows?.[0];
|
|
4588
|
+
if (!row) { notFoundIds.push(id); continue; }
|
|
4589
|
+
const ackedBy = row.acked_by && typeof row.acked_by === 'object' ? { ...row.acked_by } : {};
|
|
4590
|
+
ackedBy[body] = now;
|
|
4591
|
+
await supabaseRequest(
|
|
4592
|
+
`/rest/v1/board_posts?id=eq.${id}`,
|
|
4593
|
+
{
|
|
4594
|
+
method: 'PATCH',
|
|
4595
|
+
profile: DB_PROFILE,
|
|
4596
|
+
prefer: 'return=minimal',
|
|
4597
|
+
body: { acked_by: ackedBy }
|
|
4598
|
+
}
|
|
4599
|
+
);
|
|
4600
|
+
acked++;
|
|
4601
|
+
}
|
|
4602
|
+
return { ok: true, acked, body, not_found_ids: notFoundIds };
|
|
4603
|
+
}
|
|
4604
|
+
|
|
4605
|
+
// board_resolve — mark posts as globally resolved (hidden for all bodies)
|
|
4606
|
+
if (toolName === 'board_resolve') {
|
|
4607
|
+
const body = String(args.body || '').trim();
|
|
4608
|
+
const ids = Array.isArray(args.ids) ? args.ids.map(id => String(id).trim()).filter(Boolean) : [];
|
|
4609
|
+
if (!body) throw new Error('body is required');
|
|
4610
|
+
if (!ids.length) throw new Error('ids is required (at least 1)');
|
|
4611
|
+
|
|
4612
|
+
const now = new Date().toISOString();
|
|
4613
|
+
let resolved = 0;
|
|
4614
|
+
const notFoundIds = [];
|
|
4615
|
+
for (const id of ids) {
|
|
4616
|
+
const rows = await supabaseRequest(
|
|
4617
|
+
`/rest/v1/board_posts?id=eq.${id}&select=id,resolved_at`,
|
|
4618
|
+
{ profile: DB_PROFILE }
|
|
4619
|
+
);
|
|
4620
|
+
const row = rows?.[0];
|
|
4621
|
+
if (!row) { notFoundIds.push(id); continue; }
|
|
4622
|
+
if (row.resolved_at) continue; // already resolved
|
|
4623
|
+
await supabaseRequest(
|
|
4624
|
+
`/rest/v1/board_posts?id=eq.${id}`,
|
|
4625
|
+
{
|
|
4626
|
+
method: 'PATCH',
|
|
4627
|
+
profile: DB_PROFILE,
|
|
4628
|
+
prefer: 'return=minimal',
|
|
4629
|
+
body: { resolved_at: now }
|
|
4630
|
+
}
|
|
4631
|
+
);
|
|
4632
|
+
resolved++;
|
|
4633
|
+
}
|
|
4634
|
+
return { ok: true, resolved, body, not_found_ids: notFoundIds };
|
|
4635
|
+
}
|
|
4636
|
+
|
|
4637
|
+
// MARS-327: board_get_full — single post full text retrieval
|
|
4638
|
+
if (toolName === 'board_get_full') {
|
|
4639
|
+
const id = String(args.id || '').trim();
|
|
4640
|
+
if (!id) throw new Error('id is required');
|
|
4641
|
+
|
|
4642
|
+
const query = new URLSearchParams();
|
|
4643
|
+
query.set('id', `eq.${id}`);
|
|
4644
|
+
query.set('select', 'id,from_source,from_body,to,topic,type,text,acked_by,expires_at,archived_at,created_at');
|
|
4645
|
+
const rows = await supabaseRequest(`/rest/v1/board_posts?${query.toString()}`, { profile: DB_PROFILE });
|
|
4646
|
+
const post = rows?.[0];
|
|
4647
|
+
if (!post) throw new Error(`board post not found: ${id}`);
|
|
4648
|
+
|
|
4649
|
+
return {
|
|
4650
|
+
ok: true,
|
|
4651
|
+
post: {
|
|
4652
|
+
id: post.id,
|
|
4653
|
+
from: `${post.from_body}@${post.from_source}`,
|
|
4654
|
+
to: post.to,
|
|
4655
|
+
topic: post.topic || null,
|
|
4656
|
+
type: post.type,
|
|
4657
|
+
text: post.text || '',
|
|
4658
|
+
full_length: (post.text || '').length,
|
|
4659
|
+
created_at: post.created_at
|
|
4660
|
+
}
|
|
4661
|
+
};
|
|
4662
|
+
}
|
|
4663
|
+
|
|
4261
4664
|
throw new Error(`Unknown tool: ${name}`);
|
|
4262
4665
|
} finally {
|
|
4263
4666
|
await writeToolUsageTelemetry({
|
|
@@ -4319,15 +4722,14 @@ function isPublicHost(host) {
|
|
|
4319
4722
|
}
|
|
4320
4723
|
|
|
4321
4724
|
function shouldRequireBearer(req) {
|
|
4322
|
-
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
}
|
|
4330
|
-
return true;
|
|
4725
|
+
// MARS-422: never trust client-supplied X-Forwarded-Host on edge traffic.
|
|
4726
|
+
// See lib/auth-guard.mjs and scripts/tests/test_auth_guard.mjs.
|
|
4727
|
+
return guardShouldRequireBearer({
|
|
4728
|
+
headers: req.headers,
|
|
4729
|
+
remoteAddress: req.socket?.remoteAddress,
|
|
4730
|
+
requireBearer: REQUIRE_BEARER,
|
|
4731
|
+
bypassForPrivate: BYPASS_BEARER_FOR_PRIVATE
|
|
4732
|
+
});
|
|
4331
4733
|
}
|
|
4332
4734
|
|
|
4333
4735
|
function oauthProtectedResourceMetadata(baseUrl) {
|