@sleep2agi/commhub-server 0.9.0-preview.24 → 0.9.0-preview.26
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/README.md +9 -6
- package/package.json +1 -1
- package/src/auth.ts +5 -0
- package/src/avatar-validate.test.ts +52 -2
- package/src/avatar-validate.ts +35 -1
- package/src/ext-validation-regression.test.ts +138 -0
- package/src/file-download-authz.test.ts +50 -16
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +731 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-scope.ts +81 -0
- package/src/send-reply-attachments.test.ts +437 -0
- package/src/server.ts +298 -89
- package/src/tools.ts +148 -42
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +80 -3
- package/src/uploads.ts +81 -3
package/src/tools.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { db, uuidv4, logTaskEvent, chainReplyToParent, hashToken, generateId, ge
|
|
|
4
4
|
import { pushEvent, pushNetworkObserverEvent } from "./push.js";
|
|
5
5
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
6
6
|
import { getUserNetworkRole, createNetworkTokenForNode } from "./auth.js";
|
|
7
|
+
import { canRestWriteNetwork, getUserNetworkIds, singleNetworkId } from "./network-scope.js";
|
|
7
8
|
import {
|
|
8
9
|
buildAnetArgs as _unused_buildAnetArgs, // ensure module is loaded
|
|
9
10
|
validateName as validateChildName,
|
|
@@ -30,7 +31,7 @@ import {
|
|
|
30
31
|
vaultUpsert, vaultGet, vaultListKeys, vaultDelete,
|
|
31
32
|
VaultError,
|
|
32
33
|
} from "./vault.js";
|
|
33
|
-
import { stripHostLocalPathsForCrossHostSafe } from "./uploads.js";
|
|
34
|
+
import { stripHostLocalPathsForCrossHostSafe, validateAttachments } from "./uploads.js";
|
|
34
35
|
import {
|
|
35
36
|
validateBaseUrl as _validateBaseUrl,
|
|
36
37
|
SUPPORTED_VENDORS,
|
|
@@ -97,28 +98,54 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
97
98
|
}],
|
|
98
99
|
};
|
|
99
100
|
};
|
|
100
|
-
//
|
|
101
|
-
|
|
101
|
+
// MCP-side auth context in the shape network-scope.ts helpers take.
|
|
102
|
+
// null = legacy global-token / open-dev mode (unscoped, allow-all).
|
|
103
|
+
const mcpAuthCtx = enforceUserId ? { userId: enforceUserId, networkId: enforceNetworkId ?? null } : null;
|
|
104
|
+
|
|
105
|
+
// If enforceNetworkId is set (ntok_), override any client-supplied
|
|
106
|
+
// network_id. #517: for utok_ with no explicit network_id, fall back to
|
|
107
|
+
// the user's single membership via the SAME singleNetworkId used by REST
|
|
108
|
+
// POST /api/task (network-scope.ts) — utok_ rows carry network_id=null by
|
|
109
|
+
// design, so without this fallback single-network nodes could read
|
|
110
|
+
// everything but write nothing. Multi-network stays null (ambiguous) and
|
|
111
|
+
// canWrite rejects with network_id_required.
|
|
112
|
+
const getNetworkId = (clientNetId?: string | null) => {
|
|
113
|
+
const explicit = enforceNetworkId ?? clientNetId ?? null;
|
|
114
|
+
if (explicit !== null || !enforceUserId) return explicit;
|
|
115
|
+
return singleNetworkId({ networkId: null, networkIds: getUserNetworkIds(enforceUserId) });
|
|
116
|
+
};
|
|
102
117
|
|
|
103
|
-
// Check write access
|
|
104
|
-
//
|
|
105
|
-
//
|
|
118
|
+
// Check write access — delegates to the shared canRestWriteNetwork so MCP
|
|
119
|
+
// and REST cannot drift again (#517). isAdmin=false: MCP has no admin
|
|
120
|
+
// bypass today; keep behavior identical to before the extraction.
|
|
106
121
|
const canWrite = (effectiveNetworkId?: string | null): boolean => {
|
|
107
|
-
if (!enforceUserId) return true; // legacy global token mode, allow
|
|
108
122
|
const netId = enforceNetworkId ?? effectiveNetworkId ?? null;
|
|
109
|
-
|
|
110
|
-
const role = getUserNetworkRole(enforceUserId, netId);
|
|
111
|
-
return !!role && role !== "viewer"; // owner/admin/member can write
|
|
123
|
+
return canRestWriteNetwork(mcpAuthCtx, netId, false);
|
|
112
124
|
};
|
|
113
125
|
|
|
126
|
+
// #517: name the REAL cause. The old catch-all blamed permissions for
|
|
127
|
+
// what was actually an unresolvable network, sending operators down the
|
|
128
|
+
// wrong debugging path (roles/membership) for hours.
|
|
114
129
|
const writeDeniedReply = (effectiveNetworkId?: string | null, action = "write") => {
|
|
115
130
|
const netId = enforceNetworkId ?? effectiveNetworkId ?? null;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
131
|
+
let error: string;
|
|
132
|
+
let message: string;
|
|
133
|
+
if (!netId) {
|
|
134
|
+
const memberships = enforceUserId ? getUserNetworkIds(enforceUserId) : [];
|
|
135
|
+
error = "network_id_required";
|
|
136
|
+
message = memberships.length === 0
|
|
137
|
+
? "user token has no network memberships; join or create a network first"
|
|
138
|
+
: `user token spans ${memberships.length} networks; pass network_id explicitly (see /api/auth/me networks[].network_id)`;
|
|
139
|
+
} else if (enforceUserId && !getUserNetworkRole(enforceUserId, netId)) {
|
|
140
|
+
error = "access_denied";
|
|
141
|
+
message = "access denied to requested network (not a member)";
|
|
142
|
+
} else {
|
|
143
|
+
error = "permission_denied";
|
|
144
|
+
message = action === "send_task"
|
|
119
145
|
? "Viewer role cannot send tasks"
|
|
120
146
|
: "Viewer role cannot write to this network";
|
|
121
|
-
|
|
147
|
+
}
|
|
148
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error, message }) }] };
|
|
122
149
|
};
|
|
123
150
|
|
|
124
151
|
const addScope = (sql: string, params: any[], networkId?: string | null, column = "network_id"): string => {
|
|
@@ -130,13 +157,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
130
157
|
|
|
131
158
|
type ReadScope = { networkId?: string | null; networkIds?: string[] | null; denied?: string };
|
|
132
159
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
enforceUserId
|
|
138
|
-
).map((row) => row.network_id);
|
|
139
|
-
};
|
|
160
|
+
// Delegates to the shared membership query (network-scope.ts) — was a
|
|
161
|
+
// byte-for-byte duplicate of getUserNetworkIds before #517.
|
|
162
|
+
const getReadableNetworkIds = (): string[] =>
|
|
163
|
+
enforceUserId ? getUserNetworkIds(enforceUserId) : [];
|
|
140
164
|
|
|
141
165
|
const resolveReadScope = (clientNetId?: string | null): ReadScope => {
|
|
142
166
|
if (!enforceUserId) return { networkId: clientNetId ?? null, networkIds: null };
|
|
@@ -690,9 +714,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
690
714
|
alias: z.string().min(1).max(200).describe("Session alias"),
|
|
691
715
|
message_id: z.string().min(1).max(200),
|
|
692
716
|
response: z.string().max(10000).optional(),
|
|
717
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
693
718
|
},
|
|
694
|
-
async ({ alias, message_id, response }) => {
|
|
695
|
-
const effectiveNetId = getNetworkId(
|
|
719
|
+
async ({ alias, message_id, response, network_id: netId }) => {
|
|
720
|
+
const effectiveNetId = getNetworkId(netId);
|
|
696
721
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
697
722
|
console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
|
|
698
723
|
const ackParams: any[] = [message_id, alias];
|
|
@@ -1004,9 +1029,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1004
1029
|
alias: z.string().min(1).max(200).describe("Target session alias"),
|
|
1005
1030
|
message: z.string().min(1).max(10000).describe("Message content"),
|
|
1006
1031
|
from_session: z.string().max(200).optional(),
|
|
1032
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
1007
1033
|
},
|
|
1008
|
-
async ({ alias, message, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1009
|
-
const effectiveNetId = getNetworkId(
|
|
1034
|
+
async ({ alias, message, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1035
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1010
1036
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1011
1037
|
const canonical = resolveCanonicalAlias(effectiveNetId, alias);
|
|
1012
1038
|
const targetAlias = canonical.alias;
|
|
@@ -1065,11 +1091,48 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1065
1091
|
in_reply_to: z.string().max(200).optional().describe("Original task/message ID"),
|
|
1066
1092
|
status: z.enum(["replied", "failed", "cancelled"]).optional().default("replied").describe("Task outcome"),
|
|
1067
1093
|
from_session: z.string().max(200).optional(),
|
|
1094
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
1095
|
+
// #507 — top-level attachments (parity with REST /api/task L506). MCP Zod
|
|
1096
|
+
// otherwise silently strips unknown fields, so a caller passing
|
|
1097
|
+
// `attachments` before this schema entry existed would see `ok:true`
|
|
1098
|
+
// and never learn the attachments were dropped. Validated by
|
|
1099
|
+
// validateAttachments (uploads.ts) — the same helper the REST path uses.
|
|
1100
|
+
attachments: z.any().optional().describe("Optional attachment array; parity with send_task's meta.attachments. Each item: {type:'file', file_id, name?, mime?, size?}. Persisted into tasks.meta_json + inbox.meta_json."),
|
|
1101
|
+
// #507 — optional structured metadata (parity with send_task L837). If
|
|
1102
|
+
// both `attachments` (top-level) and `meta.attachments` are supplied,
|
|
1103
|
+
// top-level wins (same rule as REST /api/task L2101).
|
|
1104
|
+
meta: z.any().optional().describe("Optional structured reply metadata, e.g. { attachments: [...] }."),
|
|
1068
1105
|
},
|
|
1069
|
-
async ({ alias, text, in_reply_to, status: replyStatus, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1070
|
-
const effectiveNetId = getNetworkId(
|
|
1106
|
+
async ({ alias, text, in_reply_to, status: replyStatus, from_session: _fromIn, network_id: netId, attachments, meta }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1107
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1071
1108
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1072
|
-
|
|
1109
|
+
|
|
1110
|
+
// #507 — validate attachments BEFORE any DB write. Rejects malformed
|
|
1111
|
+
// input (bad file_id, >20 items, size > cap, non-object item, wrong
|
|
1112
|
+
// type field) with an explicit error the caller can act on. Empty /
|
|
1113
|
+
// absent attachments return { ok: true, attachments: [] } — the
|
|
1114
|
+
// reverse-(e) invariant (no attachments → behavior unchanged) is
|
|
1115
|
+
// pinned by tests that assert byte-identical response shape
|
|
1116
|
+
// before/after this validation runs.
|
|
1117
|
+
const attachmentsResult = validateAttachments(attachments ?? (meta && typeof meta === "object" ? (meta as any).attachments : undefined));
|
|
1118
|
+
if (!attachmentsResult.ok) {
|
|
1119
|
+
return {
|
|
1120
|
+
content: [{
|
|
1121
|
+
type: "text" as const,
|
|
1122
|
+
text: JSON.stringify({
|
|
1123
|
+
ok: false,
|
|
1124
|
+
error: "bad_attachments",
|
|
1125
|
+
message: attachmentsResult.error,
|
|
1126
|
+
}),
|
|
1127
|
+
}],
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
const mergedMeta = attachmentsResult.attachments.length
|
|
1131
|
+
? { ...(meta && typeof meta === "object" ? meta : {}), attachments: attachmentsResult.attachments }
|
|
1132
|
+
: meta;
|
|
1133
|
+
const metaJson = normalizeMetaJson(mergedMeta);
|
|
1134
|
+
|
|
1135
|
+
console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}${attachmentsResult.attachments.length ? ` [+${attachmentsResult.attachments.length} attachments]` : ""}`);
|
|
1073
1136
|
const id = uuidv4();
|
|
1074
1137
|
let taskBefore: { status: string } | null = null;
|
|
1075
1138
|
if (in_reply_to) {
|
|
@@ -1114,17 +1177,32 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1114
1177
|
if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
|
|
1115
1178
|
}
|
|
1116
1179
|
const replyLogged = db.transaction(() => {
|
|
1180
|
+
// #507 — write meta_json on inbox insert (parity with send_task L952).
|
|
1181
|
+
// Prior to this the attachments field on a send_reply call was
|
|
1182
|
+
// silently stripped by MCP Zod, leaving `ok:true` with no persisted
|
|
1183
|
+
// meta.
|
|
1117
1184
|
db.run(
|
|
1118
|
-
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
|
|
1119
|
-
VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7)`,
|
|
1120
|
-
[id, alias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null]
|
|
1185
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id, meta_json)
|
|
1186
|
+
VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7, ?8)`,
|
|
1187
|
+
[id, alias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null, metaJson]
|
|
1121
1188
|
);
|
|
1122
1189
|
|
|
1123
1190
|
// 更新 tasks 表
|
|
1124
1191
|
if (in_reply_to) {
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1192
|
+
// #507 — persist meta_json onto the tasks row too, so
|
|
1193
|
+
// dashboard's task view sees the reply's attachments alongside
|
|
1194
|
+
// the reply text (parity with send_task L957/959 which writes
|
|
1195
|
+
// meta_json into both inbox and tasks). The COALESCE guards
|
|
1196
|
+
// against clobbering pre-existing meta_json when this reply
|
|
1197
|
+
// brings no attachments (metaJson === null → keep the existing
|
|
1198
|
+
// task meta_json unchanged). Reverse-(e) invariant.
|
|
1199
|
+
const updateParams: any[] = [replyStatus, text, metaJson, in_reply_to];
|
|
1200
|
+
let updateSql = `UPDATE tasks
|
|
1201
|
+
SET status = ?1,
|
|
1202
|
+
result = ?2,
|
|
1203
|
+
completed_at = datetime('now'),
|
|
1204
|
+
meta_json = COALESCE(?3, meta_json)
|
|
1205
|
+
WHERE task_id = ?4 AND status IN ('created', 'delivered', 'acked', 'running')`;
|
|
1128
1206
|
updateSql = addScope(updateSql, updateParams, effectiveNetId);
|
|
1129
1207
|
const result = db.run(updateSql, updateParams);
|
|
1130
1208
|
if (result.changes === 0) {
|
|
@@ -1202,6 +1280,29 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1202
1280
|
? `Target "${alias}" is an agent node. commhub_reply/send_reply does NOT wake agent peers (they only log new_reply, they do not processInbox on it) — the peer will not see this reply in real time. For agent-to-agent replies, use commhub_send_task(alias="${alias}", task="<your reply>") so the peer wakes via new_task SSE and processes it. (RFC-030, Vincent 2026-07-28 全网规则.)`
|
|
1203
1281
|
: undefined;
|
|
1204
1282
|
|
|
1283
|
+
// #507 — echo attachments READ BACK FROM DB (lead 2b5f6634): the point
|
|
1284
|
+
// of the echo is to prove attachments actually landed in storage, not
|
|
1285
|
+
// to reflect the in-memory variable we tried to write. Reading
|
|
1286
|
+
// `attachmentsResult.attachments` here would still show `ok:true` +
|
|
1287
|
+
// full echo if the UPDATE failed with `changes=0` (e.g. task moved to
|
|
1288
|
+
// terminal between the pre-check and the transaction). SELECT from
|
|
1289
|
+
// inbox.meta_json (always written, uses the id we just generated) so
|
|
1290
|
+
// the echo means "these attachments are on disk now". Absent field
|
|
1291
|
+
// when the caller sent no attachments — reverse-(e) invariant: a
|
|
1292
|
+
// no-attachment call returns the exact same response shape as before
|
|
1293
|
+
// this PR. Persisted-attachments field is only added when the caller
|
|
1294
|
+
// actually asked for attachments.
|
|
1295
|
+
let attachmentsSaved: unknown[] | null = null;
|
|
1296
|
+
if (attachmentsResult.attachments.length > 0) {
|
|
1297
|
+
const persistedRow = db.get<{ meta_json: string | null }>(
|
|
1298
|
+
"SELECT meta_json FROM inbox WHERE id = ?1", [id]
|
|
1299
|
+
);
|
|
1300
|
+
const persistedMeta = parseMetaJson(persistedRow?.meta_json ?? null);
|
|
1301
|
+
attachmentsSaved = persistedMeta && typeof persistedMeta === "object" && Array.isArray((persistedMeta as any).attachments)
|
|
1302
|
+
? (persistedMeta as any).attachments
|
|
1303
|
+
: [];
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1205
1306
|
return {
|
|
1206
1307
|
content: [{
|
|
1207
1308
|
type: "text" as const,
|
|
@@ -1210,6 +1311,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1210
1311
|
message_id: id,
|
|
1211
1312
|
session_status: session?.status ?? "unknown",
|
|
1212
1313
|
...(warning ? { warning } : {}),
|
|
1314
|
+
...(attachmentsSaved !== null ? { attachments_saved: attachmentsSaved } : {}),
|
|
1213
1315
|
}),
|
|
1214
1316
|
}],
|
|
1215
1317
|
};
|
|
@@ -1223,9 +1325,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1223
1325
|
{
|
|
1224
1326
|
task_id: z.string().min(1).max(200).describe("Task ID to acknowledge"),
|
|
1225
1327
|
from_session: z.string().max(200).optional(),
|
|
1328
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
1226
1329
|
},
|
|
1227
|
-
async ({ task_id, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1228
|
-
const effectiveNetId = getNetworkId(
|
|
1330
|
+
async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1331
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1229
1332
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1230
1333
|
console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
|
|
1231
1334
|
const updateParams: any[] = [task_id];
|
|
@@ -1249,9 +1352,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1249
1352
|
{
|
|
1250
1353
|
task_id: z.string().min(1).max(200).describe("Task ID to retry"),
|
|
1251
1354
|
from_session: z.string().max(200).optional(),
|
|
1355
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
1252
1356
|
},
|
|
1253
|
-
async ({ task_id, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1254
|
-
const effectiveNetId = getNetworkId(
|
|
1357
|
+
async ({ task_id, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1358
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1255
1359
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1256
1360
|
console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
|
|
1257
1361
|
// Find the original task
|
|
@@ -1368,9 +1472,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1368
1472
|
task_id: z.string().min(1).max(200).describe("Task ID to cancel"),
|
|
1369
1473
|
reason: z.string().max(1000).optional().describe("Cancellation reason"),
|
|
1370
1474
|
from_session: z.string().max(200).optional(),
|
|
1475
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
1371
1476
|
},
|
|
1372
|
-
async ({ task_id, reason, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1373
|
-
const effectiveNetId = getNetworkId(
|
|
1477
|
+
async ({ task_id, reason, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1478
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1374
1479
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1375
1480
|
console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
|
|
1376
1481
|
const updateParams: any[] = [reason || "cancelled by " + from_session, task_id];
|
|
@@ -1400,9 +1505,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
1400
1505
|
task_id: z.string().min(1).max(200).describe("Task ID to reassign"),
|
|
1401
1506
|
new_alias: z.string().min(1).max(200).describe("Target agent alias"),
|
|
1402
1507
|
from_session: z.string().max(200).optional(),
|
|
1508
|
+
network_id: z.string().max(200).optional().describe("Network scope (auto-resolved for single-network user tokens)"),
|
|
1403
1509
|
},
|
|
1404
|
-
async ({ task_id, new_alias, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1405
|
-
const effectiveNetId = getNetworkId(
|
|
1510
|
+
async ({ task_id, new_alias, from_session: _fromIn, network_id: netId }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
|
|
1511
|
+
const effectiveNetId = getNetworkId(netId);
|
|
1406
1512
|
if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
|
|
1407
1513
|
console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
|
|
1408
1514
|
const taskParams: any[] = [task_id];
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// #503 F3=A static guard: every 4xx return in the /api/upload handler
|
|
2
|
+
// that fires BEFORE `req.formData()` drains the request body MUST be
|
|
3
|
+
// wrapped in `earlyReject` (which sets `Connection: close`). Otherwise
|
|
4
|
+
// the client's still-inbound body poisons the keepalive pool and the
|
|
5
|
+
// next pooled request stalls.
|
|
6
|
+
//
|
|
7
|
+
// Why a STATIC guard (not a runtime one): mutation M9 in
|
|
8
|
+
// docs/tests/p-503-file-network-scope/mutation-matrix.txt showed that
|
|
9
|
+
// under Bun's fetch client in this test setup, removing
|
|
10
|
+
// `Connection: close` does NOT turn any dynamic test red — the pool
|
|
11
|
+
// poisoning is real HTTP-level behavior but the client under test does
|
|
12
|
+
// not reliably reproduce it. Without a guard, deleting the helper or
|
|
13
|
+
// forgetting to wrap a NEW 15th pre-drain return goes silent. This
|
|
14
|
+
// test judges the SOURCE FILE, per lead 7a88ce29 and Constraint 3-1
|
|
15
|
+
// pattern (source-based, not runner-output-based).
|
|
16
|
+
//
|
|
17
|
+
// PRIOR-ART GOTCHA (lead 7a88ce29 踩过): parse boundaries MUST exclude
|
|
18
|
+
// comments. `req.formData()` appears in a doc comment near the top of
|
|
19
|
+
// the handler too; if the boundary matches on that, the pre-drain
|
|
20
|
+
// range collapses to empty and this guard silently becomes vacuously
|
|
21
|
+
// true. Same trap family as "范围为空的全称断言恒真". So this test
|
|
22
|
+
// has an explicit precondition: the parsed pre-drain return count must
|
|
23
|
+
// be ≥ 10 (currently 14). If the count drops to 0, the boundary parser
|
|
24
|
+
// broke, not the code.
|
|
25
|
+
|
|
26
|
+
import { describe, expect, test } from "bun:test";
|
|
27
|
+
import { readFileSync } from "fs";
|
|
28
|
+
import { join } from "path";
|
|
29
|
+
|
|
30
|
+
const SOURCE_PATH = join(import.meta.dirname, "server.ts");
|
|
31
|
+
const RAW = readFileSync(SOURCE_PATH, "utf-8");
|
|
32
|
+
const LINES = RAW.split("\n");
|
|
33
|
+
|
|
34
|
+
// Strip line-comments so we don't match on comment-mentioned tokens
|
|
35
|
+
// like the "req.formData()" doc references at the top of the handler.
|
|
36
|
+
// (Block comments /* */ are rare inside this handler; if you add one
|
|
37
|
+
// that mentions the boundary, extend this helper.)
|
|
38
|
+
//
|
|
39
|
+
// KNOWN LIMITATION (lead 50208c1b): this naive strip cuts on ANY `//`,
|
|
40
|
+
// including one inside a string literal (e.g. `"https://…"`). If a
|
|
41
|
+
// pre-drain line ever contains such a literal, the strip could snip
|
|
42
|
+
// off the return statement after it and produce a false NEGATIVE (miss
|
|
43
|
+
// a genuine unwrapped return). Currently no pre-drain line in the
|
|
44
|
+
// /api/upload handler holds this shape; the precondition floor of 10
|
|
45
|
+
// catches only "range collapsed to empty", not "silently minus one".
|
|
46
|
+
// If someone adds a URL-string literal in this range in the future,
|
|
47
|
+
// upgrade to a real tokenizer — not because block-comment mentions of
|
|
48
|
+
// req.formData() need it, but because the string-literal-with-`//`
|
|
49
|
+
// case is the actual gap.
|
|
50
|
+
function stripLineComment(s: string): string {
|
|
51
|
+
const idx = s.indexOf("//");
|
|
52
|
+
return idx >= 0 ? s.slice(0, idx) : s;
|
|
53
|
+
}
|
|
54
|
+
const CODE = LINES.map(stripLineComment);
|
|
55
|
+
|
|
56
|
+
// Find the /api/upload handler start (guard against a repo rename).
|
|
57
|
+
const handlerStartIdx = CODE.findIndex((line) =>
|
|
58
|
+
line.includes('url.pathname === "/api/upload"') &&
|
|
59
|
+
line.includes('req.method === "POST"'),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// Find the true body-drain boundary: `await req.formData()` INSIDE the
|
|
63
|
+
// handler. Search from handler start forward for the first NON-COMMENT
|
|
64
|
+
// occurrence (stripLineComment above already ate any `// ... formData ...`
|
|
65
|
+
// mention).
|
|
66
|
+
let drainIdx = -1;
|
|
67
|
+
if (handlerStartIdx >= 0) {
|
|
68
|
+
for (let i = handlerStartIdx + 1; i < CODE.length; i++) {
|
|
69
|
+
if (CODE[i].includes("req.formData()")) {
|
|
70
|
+
drainIdx = i;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Collect every `return` statement in the pre-drain range. Classify:
|
|
77
|
+
// earlyReject — the correct wrapping (sets Connection: close)
|
|
78
|
+
// withCors — bare wrapping without Connection: close (regression)
|
|
79
|
+
// other — produces a Response some OTHER way (e.g.
|
|
80
|
+
// `return Response.json(...)` / `return new Response(...)`);
|
|
81
|
+
// this shape ALSO skips earlyReject and MUST fail the guard.
|
|
82
|
+
// Detected via /Response\b/ so `return wrapped;` /
|
|
83
|
+
// `return;` / `return blob;` (helper body, non-response
|
|
84
|
+
// returns) are correctly skipped, but any new Response-
|
|
85
|
+
// producing path (Response.json / new Response / etc.) is
|
|
86
|
+
// caught. Lead 50208c1b: earlier version used implicit-skip
|
|
87
|
+
// which made the third assertion恒真 — it counted only what
|
|
88
|
+
// it recognised, so "unrecognised = 0" was vacuous.
|
|
89
|
+
// Order matters: earlyReject → withCors → other. A line
|
|
90
|
+
// like `return earlyReject(new Response(…))` contains
|
|
91
|
+
// both `earlyReject` AND `Response`, and must classify as
|
|
92
|
+
// earlyReject (already-wrapped), not as other.
|
|
93
|
+
type ReturnHit = { lineNumber: number; text: string; kind: "earlyReject" | "withCors" | "other" };
|
|
94
|
+
const preDrainReturns: ReturnHit[] = [];
|
|
95
|
+
if (handlerStartIdx >= 0 && drainIdx > handlerStartIdx) {
|
|
96
|
+
for (let i = handlerStartIdx; i < drainIdx; i++) {
|
|
97
|
+
const code = CODE[i];
|
|
98
|
+
if (!/\breturn\b/.test(code)) continue;
|
|
99
|
+
if (/\breturn earlyReject\b/.test(code)) {
|
|
100
|
+
preDrainReturns.push({ lineNumber: i + 1, text: LINES[i].trim(), kind: "earlyReject" });
|
|
101
|
+
} else if (/\breturn withCors\b/.test(code)) {
|
|
102
|
+
preDrainReturns.push({ lineNumber: i + 1, text: LINES[i].trim(), kind: "withCors" });
|
|
103
|
+
} else if (/Response\b/.test(code)) {
|
|
104
|
+
// `return new Response(...)` / `return Response.json(...)` / etc.
|
|
105
|
+
// — produces a Response but skips the earlyReject wrapper.
|
|
106
|
+
preDrainReturns.push({ lineNumber: i + 1, text: LINES[i].trim(), kind: "other" });
|
|
107
|
+
}
|
|
108
|
+
// Bare `return wrapped;`, `return;`, `return blob;` are non-response
|
|
109
|
+
// returns and correctly skipped (no `Response` token).
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe("#503 F3=A static guard: /api/upload pre-body-drain returns use earlyReject", () => {
|
|
114
|
+
test("precondition: scanner located handler + drain boundary + non-trivial return count (defends against parse collapse)", () => {
|
|
115
|
+
expect(handlerStartIdx).toBeGreaterThanOrEqual(0);
|
|
116
|
+
expect(drainIdx).toBeGreaterThan(handlerStartIdx);
|
|
117
|
+
// If boundary parsing collapses (someone renames req.formData(),
|
|
118
|
+
// or a comment-strip bug lets a doc reference win), this range
|
|
119
|
+
// becomes 0 and the second test would vacuously pass. Anchor
|
|
120
|
+
// the range: at time of writing (85e5c140 + #503) the count is
|
|
121
|
+
// 14; lead 7a88ce29 confirmed 14/14. Floor at 10 to leave room
|
|
122
|
+
// for genuine deletions while still catching a full collapse.
|
|
123
|
+
expect(preDrainReturns.length).toBeGreaterThanOrEqual(10);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("every pre-drain return is wrapped in earlyReject (not bare withCors)", () => {
|
|
127
|
+
const bareWithCors = preDrainReturns.filter((r) => r.kind === "withCors");
|
|
128
|
+
if (bareWithCors.length > 0) {
|
|
129
|
+
const detail = bareWithCors
|
|
130
|
+
.map((r) => ` L${r.lineNumber}: ${r.text}`)
|
|
131
|
+
.join("\n");
|
|
132
|
+
throw new Error(
|
|
133
|
+
`#503 F3=A regression: ${bareWithCors.length} pre-body-drain return(s) in ` +
|
|
134
|
+
`/api/upload use bare withCors() instead of earlyReject():\n${detail}\n\n` +
|
|
135
|
+
`Every early-reject in this range MUST set Connection: close. Otherwise ` +
|
|
136
|
+
`the client's in-flight request body poisons the keepalive pool and the ` +
|
|
137
|
+
`next pooled request stalls. Wrap with the earlyReject() helper defined ` +
|
|
138
|
+
`at the top of the upload handler.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
expect(bareWithCors.length).toBe(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("no pre-drain return produces a Response without going through earlyReject (catches new response shapes)", () => {
|
|
145
|
+
// Catches `return Response.json(...)` / `return new Response(...)` /
|
|
146
|
+
// any other pre-drain Response-producing path that skips the wrapper.
|
|
147
|
+
// Lead 50208c1b: previous version was 恒真 because unrecognised
|
|
148
|
+
// shapes were silently dropped from `preDrainReturns` — the filter
|
|
149
|
+
// then counted "recognised = recognised" and never failed. Now the
|
|
150
|
+
// collector classifies unrecognised Response-producers as "other";
|
|
151
|
+
// this assertion reports them by line, not just count.
|
|
152
|
+
const unknown = preDrainReturns.filter((r) => r.kind === "other");
|
|
153
|
+
expect(unknown.map((r) => `L${r.lineNumber}: ${r.text}`)).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
});
|
package/src/uploads-http.test.ts
CHANGED
|
@@ -71,13 +71,22 @@ function authHeaders(): Record<string, string> {
|
|
|
71
71
|
return { "Authorization": `Bearer ${userToken}` };
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// #503 — the bootstrap user is the first account in this suite's fresh
|
|
75
|
+
// DB, so it is a system admin, and admins must name the target network
|
|
76
|
+
// explicitly (attribution is never guessed). uploadNetworkId() keeps
|
|
77
|
+
// that detail in one place.
|
|
78
|
+
function uploadNetworkId(): string {
|
|
79
|
+
if (!userNetworkId) throw new Error("test bootstrap: userNetworkId missing");
|
|
80
|
+
return `?network_id=${encodeURIComponent(userNetworkId)}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
74
83
|
async function uploadFile(content: Uint8Array, filename: string, mime: string, opts: { skipAuth?: boolean; overrideContentLength?: string | null } = {}): Promise<Response> {
|
|
75
84
|
const form = new FormData();
|
|
76
85
|
form.append("file", new Blob([content], { type: mime }), filename);
|
|
77
86
|
const headers: Record<string, string> = opts.skipAuth ? {} : { ...authHeaders() };
|
|
78
87
|
// Note: fetch sets Content-Length automatically; only override when
|
|
79
88
|
// testing the missing-header path.
|
|
80
|
-
const res = await fetch(`${BASE}/api/upload`, { method: "POST", body: form, headers });
|
|
89
|
+
const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, { method: "POST", body: form, headers });
|
|
81
90
|
return res;
|
|
82
91
|
}
|
|
83
92
|
|
|
@@ -137,7 +146,7 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
|
|
|
137
146
|
controller.close();
|
|
138
147
|
},
|
|
139
148
|
});
|
|
140
|
-
const res = await fetch(`${BASE}/api/upload`, {
|
|
149
|
+
const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, {
|
|
141
150
|
method: "POST",
|
|
142
151
|
body: stream,
|
|
143
152
|
headers: { ...authHeaders(), "Content-Type": "multipart/form-data; boundary=xxx", "Transfer-Encoding": "chunked" },
|
|
@@ -153,7 +162,7 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
|
|
|
153
162
|
// this body" which is the security property we care about; the
|
|
154
163
|
// exact code depends on whether Bun's multipart parser bails before
|
|
155
164
|
// or after our Content-Type pre-check.
|
|
156
|
-
const res = await fetch(`${BASE}/api/upload`, {
|
|
165
|
+
const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, {
|
|
157
166
|
method: "POST",
|
|
158
167
|
body: JSON.stringify({ nope: 1 }),
|
|
159
168
|
headers: { ...authHeaders(), "Content-Type": "application/json" },
|
|
@@ -164,12 +173,18 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
|
|
|
164
173
|
|
|
165
174
|
describe("GET /api/files/:file_id — download safety", () => {
|
|
166
175
|
let fileId = "";
|
|
176
|
+
let rangeFileId = "";
|
|
177
|
+
const rangeContent = Uint8Array.from({ length: 64 }, (_, i) => i);
|
|
167
178
|
|
|
168
179
|
beforeAll(async () => {
|
|
169
180
|
const content = new TextEncoder().encode("hello-world-from-#221-integration-test");
|
|
170
181
|
const res = await uploadFile(content, "greeting.txt", "text/plain");
|
|
171
182
|
const body = await res.json() as any;
|
|
172
183
|
fileId = body.file_id;
|
|
184
|
+
|
|
185
|
+
const rangeRes = await uploadFile(rangeContent, "range.bin", "application/octet-stream");
|
|
186
|
+
const rangeBody = await rangeRes.json() as any;
|
|
187
|
+
rangeFileId = rangeBody.file_id;
|
|
173
188
|
});
|
|
174
189
|
|
|
175
190
|
test("(item 3) download returns the blob with Content-Disposition: attachment + X-Content-Type-Options: nosniff", async () => {
|
|
@@ -178,10 +193,72 @@ describe("GET /api/files/:file_id — download safety", () => {
|
|
|
178
193
|
expect(res.headers.get("Content-Disposition") ?? "").toMatch(/^attachment(;|$)/i);
|
|
179
194
|
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
180
195
|
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
|
|
196
|
+
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
|
181
197
|
const text = await res.text();
|
|
182
198
|
expect(text).toBe("hello-world-from-#221-integration-test");
|
|
183
199
|
});
|
|
184
200
|
|
|
201
|
+
test("#514 Range bytes=a-b returns exact 206 partial content, not a tolerant 200/full body", async () => {
|
|
202
|
+
const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
|
|
203
|
+
headers: { ...authHeaders(), Range: "bytes=3-8" },
|
|
204
|
+
});
|
|
205
|
+
expect(res.status).toBe(206);
|
|
206
|
+
expect(res.headers.get("Content-Range")).toBe("bytes 3-8/64");
|
|
207
|
+
expect(res.headers.get("Content-Length")).toBe("6");
|
|
208
|
+
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
|
209
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
210
|
+
expect(body.length).toBe(6);
|
|
211
|
+
expect([...body]).toEqual([...rangeContent.slice(3, 9)]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("#514 Range bytes=N- returns the exact open-ended tail", async () => {
|
|
215
|
+
const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
|
|
216
|
+
headers: { ...authHeaders(), Range: "bytes=60-" },
|
|
217
|
+
});
|
|
218
|
+
expect(res.status).toBe(206);
|
|
219
|
+
expect(res.headers.get("Content-Range")).toBe("bytes 60-63/64");
|
|
220
|
+
expect(res.headers.get("Content-Length")).toBe("4");
|
|
221
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
222
|
+
expect(body.length).toBe(4);
|
|
223
|
+
expect([...body]).toEqual([...rangeContent.slice(60)]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("#514 Range bytes=-N returns the exact suffix", async () => {
|
|
227
|
+
const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
|
|
228
|
+
headers: { ...authHeaders(), Range: "bytes=-7" },
|
|
229
|
+
});
|
|
230
|
+
expect(res.status).toBe(206);
|
|
231
|
+
expect(res.headers.get("Content-Range")).toBe("bytes 57-63/64");
|
|
232
|
+
expect(res.headers.get("Content-Length")).toBe("7");
|
|
233
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
234
|
+
expect(body.length).toBe(7);
|
|
235
|
+
expect([...body]).toEqual([...rangeContent.slice(57)]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("#514 unsatisfiable Range returns 416 with concrete Content-Range size", async () => {
|
|
239
|
+
const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
|
|
240
|
+
headers: { ...authHeaders(), Range: "bytes=64-70" },
|
|
241
|
+
});
|
|
242
|
+
expect(res.status).toBe(416);
|
|
243
|
+
expect(res.headers.get("Content-Range")).toBe("bytes */64");
|
|
244
|
+
expect(res.headers.get("Content-Length")).toBe("0");
|
|
245
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
246
|
+
expect(body.length).toBe(0);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("#514 HEAD Range mirrors GET status and range headers without a body", async () => {
|
|
250
|
+
const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
|
|
251
|
+
method: "HEAD",
|
|
252
|
+
headers: { ...authHeaders(), Range: "bytes=3-8" },
|
|
253
|
+
});
|
|
254
|
+
expect(res.status).toBe(206);
|
|
255
|
+
expect(res.headers.get("Content-Range")).toBe("bytes 3-8/64");
|
|
256
|
+
expect(res.headers.get("Content-Length")).toBe("6");
|
|
257
|
+
expect(res.headers.get("Accept-Ranges")).toBe("bytes");
|
|
258
|
+
const body = new Uint8Array(await res.arrayBuffer());
|
|
259
|
+
expect(body.length).toBe(0);
|
|
260
|
+
});
|
|
261
|
+
|
|
185
262
|
test("anonymous download is 401", async () => {
|
|
186
263
|
const res = await fetch(`${BASE}/api/files/${fileId}`);
|
|
187
264
|
expect(res.status).toBe(401);
|