negotium 0.1.12 → 0.1.13
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/dist/canonical-mcp-bridge.js +2 -3
- package/dist/canonical-mcp-bridge.js.map +1 -1
- package/dist/chunk-a9y2rwxf.js.map +1 -1
- package/dist/{chunk-j0kjscb7.js → chunk-s2gez3wg.js} +20 -2
- package/dist/{chunk-j0kjscb7.js.map → chunk-s2gez3wg.js.map} +2 -2
- package/dist/cron.js +49 -25
- package/dist/cron.js.map +15 -14
- package/dist/hosted-agent.js +3012 -17
- package/dist/hosted-agent.js.map +30 -3
- package/dist/main.js +31323 -36
- package/dist/main.js.map +198 -3
- package/dist/prompts.js +11 -8
- package/dist/prompts.js.map +4 -4
- package/dist/runtime/src/application/submit-user-message.ts +3 -0
- package/dist/runtime/src/index.ts +7 -0
- package/dist/runtime/src/mcp/runtime-spec.ts +3 -0
- package/dist/runtime/src/mcp/session-comm/server.ts +1 -1
- package/dist/runtime/src/platform/mcp-config.ts +13 -0
- package/dist/runtime/src/prompts/builders.ts +20 -6
- package/dist/runtime/src/query/active-rooms.ts +2 -0
- package/dist/runtime/src/runtime/delivery-ack.ts +112 -0
- package/dist/runtime/src/runtime/errors.ts +12 -12
- package/dist/runtime/src/runtime/turn-runner.ts +12 -0
- package/dist/runtime/src/storage/runtime-turn-requests.ts +1 -0
- package/dist/runtime/src/types/api.ts +2 -0
- package/dist/runtime/src/types.ts +2 -0
- package/dist/runtime/src/version.ts +1 -1
- package/dist/runtime-helpers.js.map +1 -1
- package/dist/storage.js.map +1 -1
- package/dist/types/packages/core/src/mcp/runtime-spec.d.ts +2 -0
- package/dist/types/packages/core/src/platform/mcp-config.d.ts +4 -0
- package/dist/types/packages/core/src/prompts/builders.d.ts +2 -0
- package/dist/types/packages/core/src/types/api.d.ts +2 -0
- package/dist/types/packages/core/src/types.d.ts +2 -0
- package/dist/types/packages/core/src/version.d.ts +1 -1
- package/dist/vault.js.map +1 -1
- package/package.json +1 -1
- package/dist/chunk-00q25k7p.js +0 -2387
- package/dist/chunk-00q25k7p.js.map +0 -26
- package/dist/chunk-03kv7mvj.js +0 -2152
- package/dist/chunk-03kv7mvj.js.map +0 -15
- package/dist/chunk-05pm51dh.js +0 -589
- package/dist/chunk-05pm51dh.js.map +0 -11
- package/dist/chunk-0eh16bk1.js +0 -2936
- package/dist/chunk-0eh16bk1.js.map +0 -20
- package/dist/chunk-0yfqyv88.js +0 -108
- package/dist/chunk-0yfqyv88.js.map +0 -10
- package/dist/chunk-1k7t7bnx.js +0 -97
- package/dist/chunk-1k7t7bnx.js.map +0 -10
- package/dist/chunk-3gkfr9vb.js +0 -431
- package/dist/chunk-3gkfr9vb.js.map +0 -11
- package/dist/chunk-5akd1gbj.js +0 -281
- package/dist/chunk-5akd1gbj.js.map +0 -10
- package/dist/chunk-7k19pcg8.js +0 -577
- package/dist/chunk-7k19pcg8.js.map +0 -14
- package/dist/chunk-81tbfbmt.js +0 -10
- package/dist/chunk-81tbfbmt.js.map +0 -10
- package/dist/chunk-82qhv1mq.js +0 -512
- package/dist/chunk-82qhv1mq.js.map +0 -13
- package/dist/chunk-8dy7e2a9.js +0 -103
- package/dist/chunk-8dy7e2a9.js.map +0 -10
- package/dist/chunk-8f2sqmrj.js +0 -1712
- package/dist/chunk-8f2sqmrj.js.map +0 -18
- package/dist/chunk-9hjeemfz.js +0 -372
- package/dist/chunk-9hjeemfz.js.map +0 -10
- package/dist/chunk-a4aa2ccv.js +0 -141
- package/dist/chunk-a4aa2ccv.js.map +0 -10
- package/dist/chunk-bsg215q3.js +0 -3536
- package/dist/chunk-bsg215q3.js.map +0 -20
- package/dist/chunk-bxnfsc7w.js +0 -58
- package/dist/chunk-bxnfsc7w.js.map +0 -10
- package/dist/chunk-c500xe9z.js +0 -33
- package/dist/chunk-c500xe9z.js.map +0 -10
- package/dist/chunk-cngrf7ab.js +0 -149
- package/dist/chunk-cngrf7ab.js.map +0 -10
- package/dist/chunk-d7tprf06.js +0 -53
- package/dist/chunk-d7tprf06.js.map +0 -10
- package/dist/chunk-ddnrmkrv.js +0 -61
- package/dist/chunk-ddnrmkrv.js.map +0 -10
- package/dist/chunk-edb07sjp.js +0 -567
- package/dist/chunk-edb07sjp.js.map +0 -14
- package/dist/chunk-ejcm6zx8.js +0 -591
- package/dist/chunk-ejcm6zx8.js.map +0 -11
- package/dist/chunk-ezs352qs.js +0 -237
- package/dist/chunk-ezs352qs.js.map +0 -11
- package/dist/chunk-f5xgprg1.js +0 -59
- package/dist/chunk-f5xgprg1.js.map +0 -10
- package/dist/chunk-fbtb3v0s.js +0 -1339
- package/dist/chunk-fbtb3v0s.js.map +0 -16
- package/dist/chunk-ffefbd3r.js +0 -17
- package/dist/chunk-ffefbd3r.js.map +0 -10
- package/dist/chunk-gaknyw04.js +0 -708
- package/dist/chunk-gaknyw04.js.map +0 -14
- package/dist/chunk-gxmny6tq.js +0 -1637
- package/dist/chunk-gxmny6tq.js.map +0 -15
- package/dist/chunk-h7bj5zwb.js +0 -3069
- package/dist/chunk-h7bj5zwb.js.map +0 -32
- package/dist/chunk-hp4g7wx7.js +0 -46
- package/dist/chunk-hp4g7wx7.js.map +0 -10
- package/dist/chunk-hwy3n31w.js +0 -21
- package/dist/chunk-hwy3n31w.js.map +0 -9
- package/dist/chunk-jzz91jwh.js +0 -70
- package/dist/chunk-jzz91jwh.js.map +0 -10
- package/dist/chunk-k778y16p.js +0 -153
- package/dist/chunk-k778y16p.js.map +0 -11
- package/dist/chunk-mh5mexsf.js +0 -147
- package/dist/chunk-mh5mexsf.js.map +0 -11
- package/dist/chunk-mnwaext5.js +0 -318
- package/dist/chunk-mnwaext5.js.map +0 -10
- package/dist/chunk-nkgs40ch.js +0 -23
- package/dist/chunk-nkgs40ch.js.map +0 -10
- package/dist/chunk-pz57vwsm.js +0 -250
- package/dist/chunk-pz57vwsm.js.map +0 -11
- package/dist/chunk-r6kdp7gn.js +0 -252
- package/dist/chunk-r6kdp7gn.js.map +0 -10
- package/dist/chunk-rkvrkcgx.js +0 -117
- package/dist/chunk-rkvrkcgx.js.map +0 -10
- package/dist/chunk-s74rdyqe.js +0 -117
- package/dist/chunk-s74rdyqe.js.map +0 -12
- package/dist/chunk-sa874j5c.js +0 -9
- package/dist/chunk-sa874j5c.js.map +0 -10
- package/dist/chunk-sffqdkvq.js +0 -122
- package/dist/chunk-sffqdkvq.js.map +0 -10
- package/dist/chunk-tcs9qjps.js +0 -64
- package/dist/chunk-tcs9qjps.js.map +0 -10
- package/dist/chunk-wx83ep8e.js +0 -131
- package/dist/chunk-wx83ep8e.js.map +0 -10
- package/dist/chunk-xag86ng3.js +0 -122
- package/dist/chunk-xag86ng3.js.map +0 -10
- package/dist/chunk-xfdkep71.js +0 -371
- package/dist/chunk-xfdkep71.js.map +0 -11
- package/dist/chunk-xhm1rfpt.js +0 -2907
- package/dist/chunk-xhm1rfpt.js.map +0 -24
- package/dist/chunk-xpcps28p.js +0 -2138
- package/dist/chunk-xpcps28p.js.map +0 -29
- package/dist/chunk-yb4rbev9.js +0 -214
- package/dist/chunk-yb4rbev9.js.map +0 -10
package/dist/chunk-0eh16bk1.js
DELETED
|
@@ -1,2936 +0,0 @@
|
|
|
1
|
-
// @bun
|
|
2
|
-
import {
|
|
3
|
-
claimEnrollment,
|
|
4
|
-
commitEnrollment,
|
|
5
|
-
isEnrollmentPending,
|
|
6
|
-
parseEnrollmentInvite,
|
|
7
|
-
pendingEnrollmentPath,
|
|
8
|
-
previewEnrollment
|
|
9
|
-
} from "./chunk-r6kdp7gn.js";
|
|
10
|
-
import {
|
|
11
|
-
configureOtiumCentral,
|
|
12
|
-
listPeerNodes,
|
|
13
|
-
mintPeerToken,
|
|
14
|
-
otiumCentralConfig,
|
|
15
|
-
resetPeerCentralCaches,
|
|
16
|
-
resolvePeerNodeByCellId,
|
|
17
|
-
selfPeerNode,
|
|
18
|
-
verifyPeerToken
|
|
19
|
-
} from "./chunk-cngrf7ab.js";
|
|
20
|
-
import {
|
|
21
|
-
assertSecureRelayUrl,
|
|
22
|
-
isJoinPersisted,
|
|
23
|
-
joinFilePath,
|
|
24
|
-
loadJoin,
|
|
25
|
-
parseInviteCode,
|
|
26
|
-
saveJoin
|
|
27
|
-
} from "./chunk-xfdkep71.js";
|
|
28
|
-
import {
|
|
29
|
-
acknowledgePeerTerminal,
|
|
30
|
-
bindOtiumTopic,
|
|
31
|
-
claimPeerInboxRequest,
|
|
32
|
-
claimPeerTurnRequest,
|
|
33
|
-
cleanupPeerStateForLocalTopic,
|
|
34
|
-
createPeerSession,
|
|
35
|
-
createRemoteAsk,
|
|
36
|
-
deletePeerReplyOutbox,
|
|
37
|
-
deleteRemoteAsk,
|
|
38
|
-
failInterruptedPeerTurnRequestsOnStartup,
|
|
39
|
-
getPeerSession,
|
|
40
|
-
getPeerTurnRequest,
|
|
41
|
-
getRemoteAsk,
|
|
42
|
-
listOtiumTopicBindings,
|
|
43
|
-
listPeerReplyOutbox,
|
|
44
|
-
listPeerTerminalOutbox,
|
|
45
|
-
markPeerTurnRequestFailed,
|
|
46
|
-
markPeerTurnRequestRunning,
|
|
47
|
-
peerInboxPayloadHash,
|
|
48
|
-
pruneRemoteAsks,
|
|
49
|
-
releasePeerInboxRequest,
|
|
50
|
-
setOtiumTopicPrivate,
|
|
51
|
-
shareOtiumTopic,
|
|
52
|
-
unbindOtiumTopic,
|
|
53
|
-
upsertPeerReplyOutbox,
|
|
54
|
-
upsertPeerTerminalOutbox
|
|
55
|
-
} from "./chunk-3gkfr9vb.js";
|
|
56
|
-
import {
|
|
57
|
-
MAX_PEER_INPUT_FILE_BYTES,
|
|
58
|
-
MAX_PEER_INPUT_REQUEST_BYTES,
|
|
59
|
-
MAX_PEER_MESSAGE_LENGTH,
|
|
60
|
-
PEER_PROTOCOL_VERSION,
|
|
61
|
-
parseExecutionSpec
|
|
62
|
-
} from "./chunk-c500xe9z.js";
|
|
63
|
-
import {
|
|
64
|
-
defineNegotiumAdapter
|
|
65
|
-
} from "./chunk-sa874j5c.js";
|
|
66
|
-
import {
|
|
67
|
-
executeExternalUserTurn,
|
|
68
|
-
flushSessionInbox
|
|
69
|
-
} from "./chunk-h7bj5zwb.js";
|
|
70
|
-
import {
|
|
71
|
-
checkAgentAuth
|
|
72
|
-
} from "./chunk-tcs9qjps.js";
|
|
73
|
-
import {
|
|
74
|
-
sessionInboxPath
|
|
75
|
-
} from "./chunk-7k19pcg8.js";
|
|
76
|
-
import {
|
|
77
|
-
failInterruptedRemoteAskCallbacks
|
|
78
|
-
} from "./chunk-8dy7e2a9.js";
|
|
79
|
-
import {
|
|
80
|
-
clearPendingAsk
|
|
81
|
-
} from "./chunk-9hjeemfz.js";
|
|
82
|
-
import {
|
|
83
|
-
registerPeerSessionBridge
|
|
84
|
-
} from "./chunk-1k7t7bnx.js";
|
|
85
|
-
import {
|
|
86
|
-
deliverAskCallbackToCaller,
|
|
87
|
-
registerPeerRuntimeBridge
|
|
88
|
-
} from "./chunk-xhm1rfpt.js";
|
|
89
|
-
import"./chunk-mnwaext5.js";
|
|
90
|
-
import {
|
|
91
|
-
errorResult,
|
|
92
|
-
fileHooks,
|
|
93
|
-
resolveAttachmentByFileId,
|
|
94
|
-
resolveUploadedFilePathByFileId,
|
|
95
|
-
setFileHooks
|
|
96
|
-
} from "./chunk-xpcps28p.js";
|
|
97
|
-
import {
|
|
98
|
-
runtimeBus
|
|
99
|
-
} from "./chunk-fbtb3v0s.js";
|
|
100
|
-
import {
|
|
101
|
-
clearTopicSessionId,
|
|
102
|
-
getTopic,
|
|
103
|
-
getTopicByNameForUser,
|
|
104
|
-
getTopicSessionId,
|
|
105
|
-
isTopicShared,
|
|
106
|
-
isTopicVisible,
|
|
107
|
-
listTopics,
|
|
108
|
-
upsertTopic
|
|
109
|
-
} from "./chunk-ejcm6zx8.js";
|
|
110
|
-
import"./chunk-hp4g7wx7.js";
|
|
111
|
-
import {
|
|
112
|
-
getRegistry
|
|
113
|
-
} from "./chunk-s74rdyqe.js";
|
|
114
|
-
import"./chunk-k778y16p.js";
|
|
115
|
-
import"./chunk-xag86ng3.js";
|
|
116
|
-
import {
|
|
117
|
-
getApiTopicConfig,
|
|
118
|
-
setApiTopicConfig
|
|
119
|
-
} from "./chunk-wx83ep8e.js";
|
|
120
|
-
import {
|
|
121
|
-
NEGOTIUM_VERSION,
|
|
122
|
-
OPTIONAL_FORUM_MCP_SERVERS,
|
|
123
|
-
VAULT_DESCRIPTION_MAX_LENGTH,
|
|
124
|
-
VAULT_VALUE_MAX_BYTES,
|
|
125
|
-
VAULT_VALUE_MIN_BYTES,
|
|
126
|
-
normalizeVaultKey,
|
|
127
|
-
registerPeerSessionBridgeIpcConfig,
|
|
128
|
-
validateVaultKey,
|
|
129
|
-
vaultDel,
|
|
130
|
-
vaultList,
|
|
131
|
-
vaultSet
|
|
132
|
-
} from "./chunk-00q25k7p.js";
|
|
133
|
-
import"./chunk-gaknyw04.js";
|
|
134
|
-
import {
|
|
135
|
-
appendJsonlEntry
|
|
136
|
-
} from "./chunk-mh5mexsf.js";
|
|
137
|
-
import {
|
|
138
|
-
DATA_DIR,
|
|
139
|
-
MAX_TELL_DEPTH,
|
|
140
|
-
SUPPORTED_AGENTS,
|
|
141
|
-
isAgentKind
|
|
142
|
-
} from "./chunk-ezs352qs.js";
|
|
143
|
-
import {
|
|
144
|
-
registerCanonicalMcpBridgeEnvProvider
|
|
145
|
-
} from "./chunk-j0kjscb7.js";
|
|
146
|
-
import {
|
|
147
|
-
abortRoom,
|
|
148
|
-
getRoomQuery
|
|
149
|
-
} from "./chunk-82qhv1mq.js";
|
|
150
|
-
import {
|
|
151
|
-
logger
|
|
152
|
-
} from "./chunk-ffefbd3r.js";
|
|
153
|
-
import {
|
|
154
|
-
db
|
|
155
|
-
} from "./chunk-81tbfbmt.js";
|
|
156
|
-
import"./chunk-sffqdkvq.js";
|
|
157
|
-
import"./chunk-f5xgprg1.js";
|
|
158
|
-
import"./chunk-hwy3n31w.js";
|
|
159
|
-
|
|
160
|
-
// ../../adapters/otium/src/canonical-mcp-bridge.ts
|
|
161
|
-
import { randomUUID } from "crypto";
|
|
162
|
-
var MAX_BODY_BYTES = 1024 * 1024;
|
|
163
|
-
var BODY_READ_TIMEOUT_MS = 1e4;
|
|
164
|
-
var MAX_INFLIGHT = 32;
|
|
165
|
-
var CAPABILITY_TTL_MS = 60 * 60 * 1000;
|
|
166
|
-
var MAX_CAPABILITIES = 2048;
|
|
167
|
-
var FORWARD_TIMEOUT_MS = 25000;
|
|
168
|
-
var TOOL_ALLOWLIST = {
|
|
169
|
-
task: new Set(["task_create", "task_update", "task_list", "task_get", "task_delete"]),
|
|
170
|
-
wiki: new Set([
|
|
171
|
-
"wiki_query",
|
|
172
|
-
"wiki_topic_brief",
|
|
173
|
-
"wiki_last_conversation",
|
|
174
|
-
"save_wiki_entry",
|
|
175
|
-
"index_upsert"
|
|
176
|
-
])
|
|
177
|
-
};
|
|
178
|
-
function readAuthorization(request) {
|
|
179
|
-
const value = request.headers.get("authorization");
|
|
180
|
-
return value?.startsWith("Bearer ") ? value.slice(7) : null;
|
|
181
|
-
}
|
|
182
|
-
async function readLimitedJson(request) {
|
|
183
|
-
const declared = Number(request.headers.get("content-length") ?? "0");
|
|
184
|
-
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES)
|
|
185
|
-
return null;
|
|
186
|
-
const reader = request.body?.getReader();
|
|
187
|
-
if (!reader)
|
|
188
|
-
return null;
|
|
189
|
-
const chunks = [];
|
|
190
|
-
let size = 0;
|
|
191
|
-
const deadline = Date.now() + BODY_READ_TIMEOUT_MS;
|
|
192
|
-
try {
|
|
193
|
-
while (true) {
|
|
194
|
-
const remaining = deadline - Date.now();
|
|
195
|
-
if (remaining <= 0)
|
|
196
|
-
throw new Error("body read timeout");
|
|
197
|
-
let timer;
|
|
198
|
-
const { done, value } = await Promise.race([
|
|
199
|
-
reader.read(),
|
|
200
|
-
new Promise((_, reject) => {
|
|
201
|
-
timer = setTimeout(() => reject(new Error("body read timeout")), remaining);
|
|
202
|
-
})
|
|
203
|
-
]).finally(() => {
|
|
204
|
-
if (timer)
|
|
205
|
-
clearTimeout(timer);
|
|
206
|
-
});
|
|
207
|
-
if (done)
|
|
208
|
-
break;
|
|
209
|
-
size += value.byteLength;
|
|
210
|
-
if (size > MAX_BODY_BYTES)
|
|
211
|
-
throw new Error("body too large");
|
|
212
|
-
chunks.push(value);
|
|
213
|
-
}
|
|
214
|
-
} catch {
|
|
215
|
-
await reader.cancel().catch(() => {
|
|
216
|
-
return;
|
|
217
|
-
});
|
|
218
|
-
return null;
|
|
219
|
-
}
|
|
220
|
-
try {
|
|
221
|
-
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
222
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
223
|
-
return null;
|
|
224
|
-
const body = parsed;
|
|
225
|
-
if (typeof body.tool !== "string" || body.tool.length > 128)
|
|
226
|
-
return null;
|
|
227
|
-
if (!body.input || typeof body.input !== "object" || Array.isArray(body.input))
|
|
228
|
-
return null;
|
|
229
|
-
return { tool: body.tool, input: body.input };
|
|
230
|
-
} catch {
|
|
231
|
-
return null;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
async function forwardCanonicalTool(capability, request) {
|
|
235
|
-
if (!TOOL_ALLOWLIST[capability.surface].has(request.tool)) {
|
|
236
|
-
return { error: `tool is not available on ${capability.surface}`, status: 403 };
|
|
237
|
-
}
|
|
238
|
-
const hub = await resolvePeerNodeByCellId(capability.hubCellId).catch(() => null);
|
|
239
|
-
if (!hub?.isPrimary || hub.self)
|
|
240
|
-
return { error: "canonical hub is unavailable", status: 503 };
|
|
241
|
-
try {
|
|
242
|
-
const peerToken = await mintPeerToken(hub.cellId);
|
|
243
|
-
const response = await fetch(`${hub.baseUrl.replace(/\/+$/, "")}/api/v1/peer/bridge/canonical-mcp`, {
|
|
244
|
-
method: "POST",
|
|
245
|
-
headers: { authorization: `Bearer ${peerToken}`, "content-type": "application/json" },
|
|
246
|
-
body: JSON.stringify({
|
|
247
|
-
v: PEER_PROTOCOL_VERSION,
|
|
248
|
-
surface: capability.surface,
|
|
249
|
-
userId: capability.userId,
|
|
250
|
-
hostTopicId: capability.hostTopicId,
|
|
251
|
-
hostQueryId: capability.hostQueryId,
|
|
252
|
-
tool: request.tool,
|
|
253
|
-
input: request.input
|
|
254
|
-
}),
|
|
255
|
-
signal: AbortSignal.timeout(FORWARD_TIMEOUT_MS)
|
|
256
|
-
});
|
|
257
|
-
const body = await response.json().catch(() => null);
|
|
258
|
-
if (!response.ok || !body?.ok || !body.result) {
|
|
259
|
-
return {
|
|
260
|
-
error: body?.error ?? `canonical hub failed (${response.status})`,
|
|
261
|
-
status: response.status
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
return { result: body.result };
|
|
265
|
-
} catch (cause) {
|
|
266
|
-
return { error: cause.message || "canonical hub unreachable", status: 502 };
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
function startCanonicalMcpBridge(options = {}) {
|
|
270
|
-
const capabilities = new Map;
|
|
271
|
-
let inflight = 0;
|
|
272
|
-
function sweep(now = Date.now()) {
|
|
273
|
-
for (const [token, capability] of capabilities) {
|
|
274
|
-
if (capability.expiresAt <= now)
|
|
275
|
-
capabilities.delete(token);
|
|
276
|
-
}
|
|
277
|
-
while (capabilities.size >= MAX_CAPABILITIES) {
|
|
278
|
-
const oldest = capabilities.keys().next().value;
|
|
279
|
-
if (!oldest)
|
|
280
|
-
break;
|
|
281
|
-
capabilities.delete(oldest);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
const server = Bun.serve({
|
|
285
|
-
hostname: "127.0.0.1",
|
|
286
|
-
port: 0,
|
|
287
|
-
async fetch(request) {
|
|
288
|
-
if (request.method !== "POST")
|
|
289
|
-
return new Response("method not allowed", { status: 405 });
|
|
290
|
-
const token = readAuthorization(request);
|
|
291
|
-
if (!token)
|
|
292
|
-
return new Response("unauthorized", { status: 401 });
|
|
293
|
-
const capability = capabilities.get(token);
|
|
294
|
-
if (!capability || capability.expiresAt <= Date.now()) {
|
|
295
|
-
capabilities.delete(token);
|
|
296
|
-
return new Response("unauthorized", { status: 401 });
|
|
297
|
-
}
|
|
298
|
-
if (inflight >= MAX_INFLIGHT)
|
|
299
|
-
return new Response("busy", { status: 503 });
|
|
300
|
-
inflight += 1;
|
|
301
|
-
try {
|
|
302
|
-
const payload = await readLimitedJson(request);
|
|
303
|
-
if (!payload)
|
|
304
|
-
return new Response("invalid request", { status: 400 });
|
|
305
|
-
if (!TOOL_ALLOWLIST[capability.surface].has(payload.tool)) {
|
|
306
|
-
return new Response("tool denied", { status: 403 });
|
|
307
|
-
}
|
|
308
|
-
const result = await (options.forwardTool ?? forwardCanonicalTool)(capability, payload);
|
|
309
|
-
return Response.json(result, { status: result.status ?? (result.result ? 200 : 502) });
|
|
310
|
-
} finally {
|
|
311
|
-
inflight -= 1;
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
});
|
|
315
|
-
const url = `http://127.0.0.1:${server.port}/`;
|
|
316
|
-
const unregister = registerCanonicalMcpBridgeEnvProvider((scope) => {
|
|
317
|
-
sweep();
|
|
318
|
-
const token = `${randomUUID()}${randomUUID()}`;
|
|
319
|
-
capabilities.set(token, {
|
|
320
|
-
surface: scope.surface,
|
|
321
|
-
userId: scope.userId,
|
|
322
|
-
hostTopicId: scope.peerBridge.hostTopicId,
|
|
323
|
-
hostQueryId: scope.peerBridge.hostQueryId,
|
|
324
|
-
hubCellId: scope.peerBridge.hubCellId,
|
|
325
|
-
expiresAt: Date.now() + CAPABILITY_TTL_MS
|
|
326
|
-
});
|
|
327
|
-
return {
|
|
328
|
-
env: {
|
|
329
|
-
NEGOTIUM_CANONICAL_MCP_BRIDGE_URL: url,
|
|
330
|
-
NEGOTIUM_CANONICAL_MCP_BRIDGE_TOKEN: token
|
|
331
|
-
},
|
|
332
|
-
revoke: () => {
|
|
333
|
-
capabilities.delete(token);
|
|
334
|
-
}
|
|
335
|
-
};
|
|
336
|
-
});
|
|
337
|
-
let stopped = false;
|
|
338
|
-
return {
|
|
339
|
-
url,
|
|
340
|
-
stop() {
|
|
341
|
-
if (stopped)
|
|
342
|
-
return;
|
|
343
|
-
stopped = true;
|
|
344
|
-
unregister();
|
|
345
|
-
capabilities.clear();
|
|
346
|
-
server.stop(true);
|
|
347
|
-
}
|
|
348
|
-
};
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// ../../adapters/otium/src/event-backflow.ts
|
|
352
|
-
var FORWARDED_TYPES = new Set([
|
|
353
|
-
"message",
|
|
354
|
-
"message_updated",
|
|
355
|
-
"typing",
|
|
356
|
-
"tool_call",
|
|
357
|
-
"tool_output",
|
|
358
|
-
"tool_status",
|
|
359
|
-
"visual",
|
|
360
|
-
"file_ready",
|
|
361
|
-
"ai_done",
|
|
362
|
-
"ai_error",
|
|
363
|
-
"ai_aborted"
|
|
364
|
-
]);
|
|
365
|
-
var TERMINAL_TYPES = new Set(["ai_done", "ai_error", "ai_aborted"]);
|
|
366
|
-
var PEER_EVENT_MAX_ATTEMPTS = 5;
|
|
367
|
-
var PEER_EVENT_RETRY_BASE_MS = 100;
|
|
368
|
-
var PEER_EVENT_TIMEOUT_MS = 15000;
|
|
369
|
-
var PEER_EVENT_MAX_PENDING = 256;
|
|
370
|
-
function hubEventSender(hubNode) {
|
|
371
|
-
return async (payload) => {
|
|
372
|
-
let token;
|
|
373
|
-
try {
|
|
374
|
-
token = await mintPeerToken(hubNode.cellId);
|
|
375
|
-
} catch (err) {
|
|
376
|
-
return { ok: false, error: `peer token mint failed: ${err.message}` };
|
|
377
|
-
}
|
|
378
|
-
let response;
|
|
379
|
-
try {
|
|
380
|
-
response = await fetch(`${hubNode.baseUrl.replace(/\/+$/, "")}/api/v1/peer/event`, {
|
|
381
|
-
method: "POST",
|
|
382
|
-
headers: {
|
|
383
|
-
authorization: `Bearer ${token}`,
|
|
384
|
-
"content-type": "application/json"
|
|
385
|
-
},
|
|
386
|
-
body: JSON.stringify(payload),
|
|
387
|
-
signal: AbortSignal.timeout(PEER_EVENT_TIMEOUT_MS)
|
|
388
|
-
});
|
|
389
|
-
} catch {
|
|
390
|
-
return { ok: false, error: `hub "${hubNode.nodeName ?? hubNode.cellId}" unreachable` };
|
|
391
|
-
}
|
|
392
|
-
const parsed = await response.json().catch(() => null);
|
|
393
|
-
if (!response.ok || !parsed?.ok) {
|
|
394
|
-
return {
|
|
395
|
-
ok: false,
|
|
396
|
-
error: parsed?.error ?? `peer event rejected (${response.status})`,
|
|
397
|
-
status: response.status
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
return { ok: true };
|
|
401
|
-
};
|
|
402
|
-
}
|
|
403
|
-
function defined(record) {
|
|
404
|
-
const out = {};
|
|
405
|
-
for (const [key, value] of Object.entries(record)) {
|
|
406
|
-
if (value !== undefined)
|
|
407
|
-
out[key] = value;
|
|
408
|
-
}
|
|
409
|
-
return out;
|
|
410
|
-
}
|
|
411
|
-
function translateBusEvent(event) {
|
|
412
|
-
const topicId = event.topicId;
|
|
413
|
-
if (event.type === "message") {
|
|
414
|
-
return { type: "message", topicId, message: event.payload };
|
|
415
|
-
}
|
|
416
|
-
if (event.type === "message-updated") {
|
|
417
|
-
const payload = event.payload;
|
|
418
|
-
return defined({
|
|
419
|
-
type: "message_updated",
|
|
420
|
-
topicId,
|
|
421
|
-
messageId: payload.messageId,
|
|
422
|
-
text: payload.patch.text,
|
|
423
|
-
deleted: payload.patch.deleted,
|
|
424
|
-
editedAt: payload.patch.editedAt,
|
|
425
|
-
usage: payload.patch.usage
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
if (event.type !== "ai-status")
|
|
429
|
-
return null;
|
|
430
|
-
const status = event.payload;
|
|
431
|
-
const kind = typeof status.kind === "string" ? status.kind : "";
|
|
432
|
-
switch (kind) {
|
|
433
|
-
case "typing":
|
|
434
|
-
return { type: "typing", topicId, userId: status.userId ?? "" };
|
|
435
|
-
case "tool_call":
|
|
436
|
-
return defined({
|
|
437
|
-
type: "tool_call",
|
|
438
|
-
topicId,
|
|
439
|
-
queryId: status.queryId,
|
|
440
|
-
name: status.name,
|
|
441
|
-
input: status.input,
|
|
442
|
-
label: status.label,
|
|
443
|
-
toolUseId: status.toolUseId
|
|
444
|
-
});
|
|
445
|
-
case "tool_output":
|
|
446
|
-
return defined({
|
|
447
|
-
type: "tool_output",
|
|
448
|
-
topicId,
|
|
449
|
-
queryId: status.queryId,
|
|
450
|
-
toolUseId: status.toolUseId,
|
|
451
|
-
content: status.content
|
|
452
|
-
});
|
|
453
|
-
case "tool_status":
|
|
454
|
-
return defined({
|
|
455
|
-
type: "tool_status",
|
|
456
|
-
topicId,
|
|
457
|
-
queryId: status.queryId,
|
|
458
|
-
kind: status.statusKind,
|
|
459
|
-
content: status.content,
|
|
460
|
-
toolName: status.toolName,
|
|
461
|
-
elapsed: status.elapsed
|
|
462
|
-
});
|
|
463
|
-
case "file_ready":
|
|
464
|
-
return defined({
|
|
465
|
-
type: "file_ready",
|
|
466
|
-
topicId,
|
|
467
|
-
queryId: status.queryId,
|
|
468
|
-
path: status.path,
|
|
469
|
-
source: status.source
|
|
470
|
-
});
|
|
471
|
-
case "visual":
|
|
472
|
-
return defined({
|
|
473
|
-
type: "visual",
|
|
474
|
-
topicId,
|
|
475
|
-
queryId: status.queryId,
|
|
476
|
-
url: status.url,
|
|
477
|
-
id: status.id,
|
|
478
|
-
title: status.title,
|
|
479
|
-
kind: status.visualKind
|
|
480
|
-
});
|
|
481
|
-
case "ai_done":
|
|
482
|
-
return defined({
|
|
483
|
-
type: "ai_done",
|
|
484
|
-
topicId,
|
|
485
|
-
queryId: status.queryId,
|
|
486
|
-
usage: status.usage,
|
|
487
|
-
agent: status.agent,
|
|
488
|
-
model: status.model
|
|
489
|
-
});
|
|
490
|
-
case "ai_error":
|
|
491
|
-
return defined({
|
|
492
|
-
type: "ai_error",
|
|
493
|
-
topicId,
|
|
494
|
-
queryId: status.queryId,
|
|
495
|
-
error: status.error
|
|
496
|
-
});
|
|
497
|
-
case "ai_aborted":
|
|
498
|
-
return defined({
|
|
499
|
-
type: "ai_aborted",
|
|
500
|
-
topicId,
|
|
501
|
-
queryId: status.queryId,
|
|
502
|
-
reason: status.reason
|
|
503
|
-
});
|
|
504
|
-
default:
|
|
505
|
-
return null;
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
var activeForwarders = new Map;
|
|
509
|
-
function getActiveForwarder(localTopicId) {
|
|
510
|
-
return activeForwarders.get(localTopicId);
|
|
511
|
-
}
|
|
512
|
-
function createTurnForwarder(opts) {
|
|
513
|
-
const { hostNodeId, requestId, localTopicId, sendEvent } = opts;
|
|
514
|
-
const retryBaseMs = opts.retryBaseMs ?? PEER_EVENT_RETRY_BASE_MS;
|
|
515
|
-
const maxPendingEvents = Math.max(2, opts.maxPendingEvents ?? PEER_EVENT_MAX_PENDING);
|
|
516
|
-
const forwarder = {
|
|
517
|
-
requestId,
|
|
518
|
-
queryId: null,
|
|
519
|
-
finished: false,
|
|
520
|
-
deliveryBlocked: false,
|
|
521
|
-
pendingEvents: 0,
|
|
522
|
-
seq: 0,
|
|
523
|
-
chain: Promise.resolve(),
|
|
524
|
-
tap: () => {},
|
|
525
|
-
finish: () => {}
|
|
526
|
-
};
|
|
527
|
-
let terminalQueued = false;
|
|
528
|
-
const detach = () => {
|
|
529
|
-
forwarder.finished = true;
|
|
530
|
-
if (activeForwarders.get(localTopicId) === forwarder) {
|
|
531
|
-
activeForwarders.delete(localTopicId);
|
|
532
|
-
}
|
|
533
|
-
};
|
|
534
|
-
const post = (event) => {
|
|
535
|
-
const isTerminal = TERMINAL_TYPES.has(String(event.type ?? ""));
|
|
536
|
-
if (isTerminal)
|
|
537
|
-
terminalQueued = true;
|
|
538
|
-
forwarder.seq += 1;
|
|
539
|
-
const seq = forwarder.seq;
|
|
540
|
-
forwarder.pendingEvents += 1;
|
|
541
|
-
forwarder.chain = forwarder.chain.then(async () => {
|
|
542
|
-
try {
|
|
543
|
-
if (forwarder.deliveryBlocked)
|
|
544
|
-
return;
|
|
545
|
-
if (isTerminal) {
|
|
546
|
-
upsertPeerTerminalOutbox({ hostNodeId, requestId, seq, event });
|
|
547
|
-
}
|
|
548
|
-
for (let attempt = 1;attempt <= PEER_EVENT_MAX_ATTEMPTS; attempt++) {
|
|
549
|
-
const result = await sendEvent({ v: PEER_PROTOCOL_VERSION, requestId, seq, event });
|
|
550
|
-
if (result.ok) {
|
|
551
|
-
if (isTerminal)
|
|
552
|
-
acknowledgePeerTerminal(hostNodeId, requestId);
|
|
553
|
-
return;
|
|
554
|
-
}
|
|
555
|
-
logger.warn({ requestId, seq, type: event.type, attempt, error: result.error }, "otium: peer event delivery to hub failed");
|
|
556
|
-
if (attempt < PEER_EVENT_MAX_ATTEMPTS) {
|
|
557
|
-
const delayMs = retryBaseMs * 2 ** (attempt - 1);
|
|
558
|
-
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
forwarder.deliveryBlocked = true;
|
|
562
|
-
logger.error({ requestId, seq, type: event.type }, "otium: peer event delivery exhausted");
|
|
563
|
-
} finally {
|
|
564
|
-
forwarder.pendingEvents -= 1;
|
|
565
|
-
}
|
|
566
|
-
});
|
|
567
|
-
};
|
|
568
|
-
forwarder.tap = (raw) => {
|
|
569
|
-
if (forwarder.finished)
|
|
570
|
-
return;
|
|
571
|
-
const type = String(raw.type ?? "");
|
|
572
|
-
if (!FORWARDED_TYPES.has(type))
|
|
573
|
-
return;
|
|
574
|
-
if (type === "message") {
|
|
575
|
-
const message = raw.message;
|
|
576
|
-
if (message?.authorId === "ai" && forwarder.queryId && message.queryId !== forwarder.queryId) {
|
|
577
|
-
return;
|
|
578
|
-
}
|
|
579
|
-
} else if (type !== "typing") {
|
|
580
|
-
const queryId = raw.queryId;
|
|
581
|
-
if (forwarder.queryId && typeof queryId === "string" && queryId !== forwarder.queryId) {
|
|
582
|
-
return;
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
const isTerminal = TERMINAL_TYPES.has(type);
|
|
586
|
-
if (!isTerminal && forwarder.pendingEvents >= maxPendingEvents - 1) {
|
|
587
|
-
if (!terminalQueued) {
|
|
588
|
-
post({
|
|
589
|
-
type: "ai_error",
|
|
590
|
-
topicId: localTopicId,
|
|
591
|
-
queryId: forwarder.queryId ?? requestId,
|
|
592
|
-
error: `worker event queue saturated (${maxPendingEvents})`
|
|
593
|
-
});
|
|
594
|
-
detach();
|
|
595
|
-
}
|
|
596
|
-
return;
|
|
597
|
-
}
|
|
598
|
-
post(raw);
|
|
599
|
-
if (isTerminal)
|
|
600
|
-
detach();
|
|
601
|
-
};
|
|
602
|
-
forwarder.finish = (event) => {
|
|
603
|
-
if (forwarder.finished)
|
|
604
|
-
return;
|
|
605
|
-
post(event);
|
|
606
|
-
detach();
|
|
607
|
-
};
|
|
608
|
-
return forwarder;
|
|
609
|
-
}
|
|
610
|
-
function registerTurnForwarder(localTopicId, forwarder) {
|
|
611
|
-
activeForwarders.set(localTopicId, forwarder);
|
|
612
|
-
ensureBackflowSubscription();
|
|
613
|
-
}
|
|
614
|
-
var unsubscribe = null;
|
|
615
|
-
var terminalOutboxTimer = null;
|
|
616
|
-
var terminalOutboxFlushInFlight = false;
|
|
617
|
-
async function flushPeerTerminalOutbox() {
|
|
618
|
-
if (terminalOutboxFlushInFlight)
|
|
619
|
-
return 0;
|
|
620
|
-
terminalOutboxFlushInFlight = true;
|
|
621
|
-
let acknowledged = 0;
|
|
622
|
-
try {
|
|
623
|
-
for (const row of listPeerTerminalOutbox()) {
|
|
624
|
-
const node = await resolvePeerNodeByCellId(row.host_node_id).catch(() => null);
|
|
625
|
-
if (!node)
|
|
626
|
-
continue;
|
|
627
|
-
let event;
|
|
628
|
-
try {
|
|
629
|
-
event = JSON.parse(row.event_json);
|
|
630
|
-
} catch {
|
|
631
|
-
continue;
|
|
632
|
-
}
|
|
633
|
-
const result = await hubEventSender(node)({
|
|
634
|
-
v: PEER_PROTOCOL_VERSION,
|
|
635
|
-
requestId: row.request_id,
|
|
636
|
-
seq: row.seq,
|
|
637
|
-
event
|
|
638
|
-
});
|
|
639
|
-
if (result.ok && acknowledgePeerTerminal(row.host_node_id, row.request_id))
|
|
640
|
-
acknowledged += 1;
|
|
641
|
-
}
|
|
642
|
-
return acknowledged;
|
|
643
|
-
} finally {
|
|
644
|
-
terminalOutboxFlushInFlight = false;
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
function onBusEvent(event) {
|
|
648
|
-
const forwarder = activeForwarders.get(event.topicId);
|
|
649
|
-
if (!forwarder)
|
|
650
|
-
return;
|
|
651
|
-
const raw = translateBusEvent(event);
|
|
652
|
-
if (raw)
|
|
653
|
-
forwarder.tap(raw);
|
|
654
|
-
}
|
|
655
|
-
function ensureBackflowSubscription() {
|
|
656
|
-
if (!unsubscribe)
|
|
657
|
-
unsubscribe = runtimeBus().subscribe(onBusEvent);
|
|
658
|
-
}
|
|
659
|
-
function startEventBackflow() {
|
|
660
|
-
ensureBackflowSubscription();
|
|
661
|
-
if (!terminalOutboxTimer) {
|
|
662
|
-
flushPeerTerminalOutbox();
|
|
663
|
-
terminalOutboxTimer = setInterval(() => void flushPeerTerminalOutbox(), 5000);
|
|
664
|
-
terminalOutboxTimer.unref?.();
|
|
665
|
-
}
|
|
666
|
-
return stopEventBackflow;
|
|
667
|
-
}
|
|
668
|
-
function stopEventBackflow() {
|
|
669
|
-
unsubscribe?.();
|
|
670
|
-
unsubscribe = null;
|
|
671
|
-
if (terminalOutboxTimer)
|
|
672
|
-
clearInterval(terminalOutboxTimer);
|
|
673
|
-
terminalOutboxTimer = null;
|
|
674
|
-
activeForwarders.clear();
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// ../../adapters/otium/src/peer-files.ts
|
|
678
|
-
import { randomUUID as randomUUID2 } from "crypto";
|
|
679
|
-
import { copyFileSync, mkdirSync, rmSync, statSync } from "fs";
|
|
680
|
-
import { basename, extname, join } from "path";
|
|
681
|
-
var PEER_FILES_DIR = join(DATA_DIR, "otium-peer-files");
|
|
682
|
-
db.exec(`
|
|
683
|
-
CREATE TABLE IF NOT EXISTS otium_peer_files (
|
|
684
|
-
id TEXT PRIMARY KEY,
|
|
685
|
-
topic_id TEXT,
|
|
686
|
-
owner_user_id TEXT,
|
|
687
|
-
filename TEXT NOT NULL,
|
|
688
|
-
mime_type TEXT NOT NULL,
|
|
689
|
-
size_bytes INTEGER NOT NULL,
|
|
690
|
-
path TEXT NOT NULL,
|
|
691
|
-
created_at TEXT NOT NULL
|
|
692
|
-
)
|
|
693
|
-
`);
|
|
694
|
-
function fileType(mimeType) {
|
|
695
|
-
if (mimeType.startsWith("image/"))
|
|
696
|
-
return "image";
|
|
697
|
-
if (mimeType.startsWith("audio/"))
|
|
698
|
-
return "audio";
|
|
699
|
-
return "file";
|
|
700
|
-
}
|
|
701
|
-
function attachment(row) {
|
|
702
|
-
return {
|
|
703
|
-
id: row.id,
|
|
704
|
-
type: fileType(row.mime_type),
|
|
705
|
-
filename: row.filename,
|
|
706
|
-
url: `/api/v1/peer/files/${row.id}`,
|
|
707
|
-
mimeType: row.mime_type,
|
|
708
|
-
sizeBytes: row.size_bytes
|
|
709
|
-
};
|
|
710
|
-
}
|
|
711
|
-
function rowFor(fileId) {
|
|
712
|
-
return db.query("SELECT * FROM otium_peer_files WHERE id = ?").get(fileId) ?? null;
|
|
713
|
-
}
|
|
714
|
-
function peerFileAllowsAccess(fileId, access) {
|
|
715
|
-
const row = rowFor(fileId);
|
|
716
|
-
return row?.topic_id === access.topicId && row.owner_user_id === access.ownerUserId;
|
|
717
|
-
}
|
|
718
|
-
function safeFilename(filename) {
|
|
719
|
-
const value = basename(filename).replace(/[^A-Za-z0-9._ -]/g, "_").slice(0, 120);
|
|
720
|
-
return value || "upload";
|
|
721
|
-
}
|
|
722
|
-
function mimeFromPath(path) {
|
|
723
|
-
const extension = extname(path).toLowerCase();
|
|
724
|
-
const known = {
|
|
725
|
-
".png": "image/png",
|
|
726
|
-
".jpg": "image/jpeg",
|
|
727
|
-
".jpeg": "image/jpeg",
|
|
728
|
-
".gif": "image/gif",
|
|
729
|
-
".webp": "image/webp",
|
|
730
|
-
".mp4": "video/mp4",
|
|
731
|
-
".webm": "video/webm",
|
|
732
|
-
".pdf": "application/pdf",
|
|
733
|
-
".txt": "text/plain"
|
|
734
|
-
};
|
|
735
|
-
return known[extension] ?? "application/octet-stream";
|
|
736
|
-
}
|
|
737
|
-
function recordFile(args) {
|
|
738
|
-
db.run(`INSERT INTO otium_peer_files
|
|
739
|
-
(id, topic_id, owner_user_id, filename, mime_type, size_bytes, path, created_at)
|
|
740
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
741
|
-
args.id,
|
|
742
|
-
args.topicId ?? null,
|
|
743
|
-
args.ownerUserId ?? null,
|
|
744
|
-
args.filename,
|
|
745
|
-
args.mimeType,
|
|
746
|
-
args.sizeBytes,
|
|
747
|
-
args.path,
|
|
748
|
-
new Date().toISOString()
|
|
749
|
-
]);
|
|
750
|
-
return attachment({
|
|
751
|
-
id: args.id,
|
|
752
|
-
topic_id: args.topicId ?? null,
|
|
753
|
-
owner_user_id: args.ownerUserId ?? null,
|
|
754
|
-
filename: args.filename,
|
|
755
|
-
mime_type: args.mimeType,
|
|
756
|
-
size_bytes: args.sizeBytes,
|
|
757
|
-
path: args.path
|
|
758
|
-
});
|
|
759
|
-
}
|
|
760
|
-
function insertLocalFile(args) {
|
|
761
|
-
const id = randomUUID2();
|
|
762
|
-
mkdirSync(PEER_FILES_DIR, { recursive: true });
|
|
763
|
-
const path = join(PEER_FILES_DIR, `${id}-${safeFilename(args.filename)}`);
|
|
764
|
-
copyFileSync(args.sourcePath, path);
|
|
765
|
-
return recordFile({ id, path, sizeBytes: statSync(path).size, ...args });
|
|
766
|
-
}
|
|
767
|
-
function deletePeerFilesForTopic(topicId) {
|
|
768
|
-
const rows = db.query("SELECT path FROM otium_peer_files WHERE topic_id = ?").all(topicId);
|
|
769
|
-
db.run("DELETE FROM otium_peer_files WHERE topic_id = ?", [topicId]);
|
|
770
|
-
for (const row of rows)
|
|
771
|
-
rmSync(row.path, { force: true });
|
|
772
|
-
}
|
|
773
|
-
async function storePeerInputFile(file, access) {
|
|
774
|
-
const id = randomUUID2();
|
|
775
|
-
mkdirSync(PEER_FILES_DIR, { recursive: true });
|
|
776
|
-
const filename = file.name || "upload";
|
|
777
|
-
const path = join(PEER_FILES_DIR, `${id}-${safeFilename(filename)}`);
|
|
778
|
-
const sizeBytes = await Bun.write(path, file);
|
|
779
|
-
return recordFile({
|
|
780
|
-
id,
|
|
781
|
-
path,
|
|
782
|
-
sizeBytes,
|
|
783
|
-
filename,
|
|
784
|
-
mimeType: file.type || "application/octet-stream",
|
|
785
|
-
...access
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
|
-
function installPeerFileHooks() {
|
|
789
|
-
const previous = fileHooks();
|
|
790
|
-
const hooks = {
|
|
791
|
-
resolveAttachmentByFileId(fileId) {
|
|
792
|
-
const row = rowFor(fileId);
|
|
793
|
-
return row ? attachment(row) : previous.resolveAttachmentByFileId(fileId);
|
|
794
|
-
},
|
|
795
|
-
resolveUploadedFilePathByFileId(fileId) {
|
|
796
|
-
return rowFor(fileId)?.path ?? previous.resolveUploadedFilePathByFileId(fileId);
|
|
797
|
-
},
|
|
798
|
-
storeLocalFileAsUpload(path, access) {
|
|
799
|
-
const existing = previous.storeLocalFileAsUpload(path, access);
|
|
800
|
-
if (existing)
|
|
801
|
-
return existing;
|
|
802
|
-
return insertLocalFile({
|
|
803
|
-
sourcePath: path,
|
|
804
|
-
filename: basename(path),
|
|
805
|
-
mimeType: mimeFromPath(path),
|
|
806
|
-
topicId: access?.topicId,
|
|
807
|
-
ownerUserId: access?.ownerUserId
|
|
808
|
-
});
|
|
809
|
-
},
|
|
810
|
-
async deleteFilesForTopic(topicId) {
|
|
811
|
-
await previous.deleteFilesForTopic?.(topicId);
|
|
812
|
-
deletePeerFilesForTopic(topicId);
|
|
813
|
-
}
|
|
814
|
-
};
|
|
815
|
-
setFileHooks(hooks);
|
|
816
|
-
return () => {
|
|
817
|
-
if (fileHooks() === hooks)
|
|
818
|
-
setFileHooks(previous);
|
|
819
|
-
};
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
// ../../adapters/otium/src/runtime-bridge.ts
|
|
823
|
-
import { randomUUID as randomUUID3 } from "crypto";
|
|
824
|
-
var PEER_BRIDGE_TIMEOUT_MS = 15000;
|
|
825
|
-
function isMcpToolResult(value) {
|
|
826
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
827
|
-
return false;
|
|
828
|
-
const result = value;
|
|
829
|
-
return Array.isArray(result.content) && result.content.every((entry) => entry !== null && typeof entry === "object" && entry.type === "text" && typeof entry.text === "string") && (result.isError === undefined || result.isError === true);
|
|
830
|
-
}
|
|
831
|
-
var otiumPeerRuntimeBridge = {
|
|
832
|
-
async flushEvents(localTopicId) {
|
|
833
|
-
const forwarder = getActiveForwarder(localTopicId);
|
|
834
|
-
if (!forwarder)
|
|
835
|
-
return false;
|
|
836
|
-
await forwarder.chain;
|
|
837
|
-
return !forwarder.deliveryBlocked;
|
|
838
|
-
},
|
|
839
|
-
async spawnSubagent(request) {
|
|
840
|
-
const hubNode = await resolvePeerNodeByCellId(request.bridge.hubCellId).catch(() => null);
|
|
841
|
-
if (!hubNode)
|
|
842
|
-
return errorResult("Error: Hub node is no longer attached.");
|
|
843
|
-
let token;
|
|
844
|
-
try {
|
|
845
|
-
token = await mintPeerToken(hubNode.cellId);
|
|
846
|
-
} catch (err) {
|
|
847
|
-
return errorResult(`Error: Failed to spawn on hub: ${err.message}`);
|
|
848
|
-
}
|
|
849
|
-
let response;
|
|
850
|
-
try {
|
|
851
|
-
response = await fetch(`${hubNode.baseUrl.replace(/\/+$/, "")}/api/v1/peer/bridge/spawn`, {
|
|
852
|
-
method: "POST",
|
|
853
|
-
headers: {
|
|
854
|
-
authorization: `Bearer ${token}`,
|
|
855
|
-
"content-type": "application/json"
|
|
856
|
-
},
|
|
857
|
-
body: JSON.stringify({
|
|
858
|
-
hostQueryId: request.bridge.hostQueryId,
|
|
859
|
-
userId: request.userId,
|
|
860
|
-
agent: request.agent,
|
|
861
|
-
...request.model ? { model: request.model } : {},
|
|
862
|
-
input: request.input
|
|
863
|
-
}),
|
|
864
|
-
signal: AbortSignal.timeout(PEER_BRIDGE_TIMEOUT_MS)
|
|
865
|
-
});
|
|
866
|
-
} catch (err) {
|
|
867
|
-
logger.warn({ err, node: hubNode.nodeName }, "otium: spawn bridge hub unreachable");
|
|
868
|
-
return errorResult(`Error: Failed to spawn on hub: node "${hubNode.nodeName ?? hubNode.cellId}" unreachable`);
|
|
869
|
-
}
|
|
870
|
-
const parsed = await response.json().catch(() => null);
|
|
871
|
-
if (!response.ok || !parsed?.ok) {
|
|
872
|
-
return errorResult(`Error: Failed to spawn on hub: ${parsed?.error ?? `peer call failed (${response.status})`}`);
|
|
873
|
-
}
|
|
874
|
-
if (!isMcpToolResult(parsed.result)) {
|
|
875
|
-
return errorResult("Error: Failed to spawn on hub: invalid tool result");
|
|
876
|
-
}
|
|
877
|
-
return parsed.result;
|
|
878
|
-
},
|
|
879
|
-
async askUser(request) {
|
|
880
|
-
const hubNode = await resolvePeerNodeByCellId(request.bridge.hubCellId).catch(() => null);
|
|
881
|
-
if (!hubNode)
|
|
882
|
-
return errorResult("Error: Hub node is no longer attached.");
|
|
883
|
-
let token;
|
|
884
|
-
try {
|
|
885
|
-
token = await mintPeerToken(hubNode.cellId);
|
|
886
|
-
} catch (error) {
|
|
887
|
-
return errorResult(`Error: Failed to open hub question: ${error.message}`);
|
|
888
|
-
}
|
|
889
|
-
const bridgeRequestId = `bridge-ask-${randomUUID3()}`;
|
|
890
|
-
const baseUrl = hubNode.baseUrl.replace(/\/+$/, "");
|
|
891
|
-
const post = async (path, body) => {
|
|
892
|
-
const response = await fetch(`${baseUrl}${path}`, {
|
|
893
|
-
method: "POST",
|
|
894
|
-
headers: {
|
|
895
|
-
authorization: `Bearer ${token}`,
|
|
896
|
-
"content-type": "application/json"
|
|
897
|
-
},
|
|
898
|
-
body: JSON.stringify(body),
|
|
899
|
-
signal: AbortSignal.timeout(PEER_BRIDGE_TIMEOUT_MS)
|
|
900
|
-
});
|
|
901
|
-
const parsed = await response.json().catch(() => null);
|
|
902
|
-
return { response, parsed };
|
|
903
|
-
};
|
|
904
|
-
try {
|
|
905
|
-
const started = await post("/api/v1/peer/bridge/ask/start", {
|
|
906
|
-
hostQueryId: request.bridge.hostQueryId,
|
|
907
|
-
bridgeRequestId,
|
|
908
|
-
userId: request.userId,
|
|
909
|
-
agent: request.agent,
|
|
910
|
-
...request.model ? { model: request.model } : {},
|
|
911
|
-
input: request.input
|
|
912
|
-
});
|
|
913
|
-
if (!started.response.ok || !started.parsed?.ok) {
|
|
914
|
-
return errorResult(`Error: Failed to open hub question: ${started.parsed?.error ?? `peer call failed (${started.response.status})`}`);
|
|
915
|
-
}
|
|
916
|
-
for (;; ) {
|
|
917
|
-
await Bun.sleep(500);
|
|
918
|
-
const polled = await post("/api/v1/peer/bridge/ask/result", {
|
|
919
|
-
hostQueryId: request.bridge.hostQueryId,
|
|
920
|
-
bridgeRequestId
|
|
921
|
-
});
|
|
922
|
-
if (!polled.response.ok || !polled.parsed?.ok) {
|
|
923
|
-
return errorResult(`Error: Failed to read hub answer: ${polled.parsed?.error ?? `peer call failed (${polled.response.status})`}`);
|
|
924
|
-
}
|
|
925
|
-
if (polled.parsed.pending !== false)
|
|
926
|
-
continue;
|
|
927
|
-
if (!isMcpToolResult(polled.parsed.result)) {
|
|
928
|
-
return errorResult("Error: Failed to read hub answer: invalid tool result");
|
|
929
|
-
}
|
|
930
|
-
return polled.parsed.result;
|
|
931
|
-
}
|
|
932
|
-
} catch (error) {
|
|
933
|
-
return errorResult(`Error: Hub question bridge failed: ${error.message}`);
|
|
934
|
-
}
|
|
935
|
-
},
|
|
936
|
-
async selfConfig(request) {
|
|
937
|
-
const hubNode = await resolvePeerNodeByCellId(request.bridge.hubCellId).catch(() => null);
|
|
938
|
-
if (!hubNode)
|
|
939
|
-
return errorResult("Error: Hub node is no longer attached.");
|
|
940
|
-
try {
|
|
941
|
-
const token = await mintPeerToken(hubNode.cellId);
|
|
942
|
-
const response = await fetch(`${hubNode.baseUrl.replace(/\/+$/, "")}/api/v1/peer/bridge/self-config`, {
|
|
943
|
-
method: "POST",
|
|
944
|
-
headers: {
|
|
945
|
-
authorization: `Bearer ${token}`,
|
|
946
|
-
"content-type": "application/json"
|
|
947
|
-
},
|
|
948
|
-
body: JSON.stringify({
|
|
949
|
-
hostQueryId: request.bridge.hostQueryId,
|
|
950
|
-
userId: request.userId,
|
|
951
|
-
tool: request.tool,
|
|
952
|
-
input: request.input,
|
|
953
|
-
...request.currentUserPrompt ? { currentUserPrompt: request.currentUserPrompt } : {}
|
|
954
|
-
}),
|
|
955
|
-
signal: AbortSignal.timeout(PEER_BRIDGE_TIMEOUT_MS)
|
|
956
|
-
});
|
|
957
|
-
const body = await response.json().catch(() => null);
|
|
958
|
-
if (!response.ok || !body?.ok) {
|
|
959
|
-
return errorResult(`Error: Failed to update hub topic config: ${body?.error ?? `peer call failed (${response.status})`}`);
|
|
960
|
-
}
|
|
961
|
-
return isMcpToolResult(body.result) ? body.result : errorResult("Error: Failed to update hub topic config: invalid tool result");
|
|
962
|
-
} catch (error) {
|
|
963
|
-
return errorResult(`Error: Hub self-config bridge failed: ${error.message}`);
|
|
964
|
-
}
|
|
965
|
-
},
|
|
966
|
-
async showVisual(request) {
|
|
967
|
-
const hubNode = await resolvePeerNodeByCellId(request.bridge.hubCellId).catch(() => null);
|
|
968
|
-
if (!hubNode)
|
|
969
|
-
return { ok: false, error: "hub node is no longer attached" };
|
|
970
|
-
let token;
|
|
971
|
-
try {
|
|
972
|
-
token = await mintPeerToken(hubNode.cellId);
|
|
973
|
-
} catch (error) {
|
|
974
|
-
return { ok: false, error: `peer token mint failed: ${error.message}` };
|
|
975
|
-
}
|
|
976
|
-
let hubFileId = request.fileId;
|
|
977
|
-
if (request.kind === "image" || request.kind === "video") {
|
|
978
|
-
const localPath = request.fileId ? resolveUploadedFilePathByFileId(request.fileId) : null;
|
|
979
|
-
const localAttachment = request.fileId ? resolveAttachmentByFileId(request.fileId) : null;
|
|
980
|
-
if (!localPath || !localAttachment) {
|
|
981
|
-
return { ok: false, error: "worker media file is unavailable" };
|
|
982
|
-
}
|
|
983
|
-
const form = new FormData;
|
|
984
|
-
form.set("hostQueryId", request.bridge.hostQueryId);
|
|
985
|
-
form.set("userId", request.userId);
|
|
986
|
-
form.set("agent", request.agent);
|
|
987
|
-
if (request.model)
|
|
988
|
-
form.set("model", request.model);
|
|
989
|
-
form.set("announce", "false");
|
|
990
|
-
form.set("file", Bun.file(localPath, { type: localAttachment.mimeType }), localAttachment.filename);
|
|
991
|
-
const fileResponse = await fetch(`${hubNode.baseUrl.replace(/\/+$/, "")}/api/v1/peer/bridge/file`, {
|
|
992
|
-
method: "POST",
|
|
993
|
-
headers: { authorization: `Bearer ${token}` },
|
|
994
|
-
body: form,
|
|
995
|
-
signal: AbortSignal.timeout(PEER_BRIDGE_TIMEOUT_MS)
|
|
996
|
-
}).catch(() => null);
|
|
997
|
-
const fileBody = fileResponse ? await fileResponse.json().catch(() => null) : null;
|
|
998
|
-
if (!fileResponse?.ok || !fileBody?.ok || !fileBody.attachment?.id) {
|
|
999
|
-
return { ok: false, error: fileBody?.error ?? "hub media upload failed" };
|
|
1000
|
-
}
|
|
1001
|
-
hubFileId = fileBody.attachment.id;
|
|
1002
|
-
}
|
|
1003
|
-
const response = await fetch(`${hubNode.baseUrl.replace(/\/+$/, "")}/api/v1/peer/bridge/visual`, {
|
|
1004
|
-
method: "POST",
|
|
1005
|
-
headers: {
|
|
1006
|
-
authorization: `Bearer ${token}`,
|
|
1007
|
-
"content-type": "application/json"
|
|
1008
|
-
},
|
|
1009
|
-
body: JSON.stringify({
|
|
1010
|
-
hostQueryId: request.bridge.hostQueryId,
|
|
1011
|
-
userId: request.userId,
|
|
1012
|
-
kind: request.kind,
|
|
1013
|
-
...request.title ? { title: request.title } : {},
|
|
1014
|
-
...request.html ? { html: request.html } : {},
|
|
1015
|
-
...request.code ? { code: request.code } : {},
|
|
1016
|
-
...request.theme ? { theme: request.theme } : {},
|
|
1017
|
-
...hubFileId ? { fileId: hubFileId } : {},
|
|
1018
|
-
...request.mimeType ? { mimeType: request.mimeType } : {},
|
|
1019
|
-
...request.source ? { source: request.source } : {}
|
|
1020
|
-
}),
|
|
1021
|
-
signal: AbortSignal.timeout(PEER_BRIDGE_TIMEOUT_MS)
|
|
1022
|
-
}).catch(() => null);
|
|
1023
|
-
const body = response ? await response.json().catch(() => null) : null;
|
|
1024
|
-
if (!response?.ok || !body?.ok || typeof body.id !== "number" || !body.url) {
|
|
1025
|
-
return { ok: false, error: body?.error ?? "hub visual bridge failed" };
|
|
1026
|
-
}
|
|
1027
|
-
return { ok: true, id: body.id, url: body.url, title: body.title ?? null };
|
|
1028
|
-
},
|
|
1029
|
-
async sendFile(request) {
|
|
1030
|
-
const hubNode = await resolvePeerNodeByCellId(request.bridge.hubCellId).catch(() => null);
|
|
1031
|
-
if (!hubNode)
|
|
1032
|
-
return { ok: false, error: "hub node is no longer attached" };
|
|
1033
|
-
try {
|
|
1034
|
-
const token = await mintPeerToken(hubNode.cellId);
|
|
1035
|
-
const form = new FormData;
|
|
1036
|
-
form.set("hostQueryId", request.bridge.hostQueryId);
|
|
1037
|
-
form.set("userId", request.userId);
|
|
1038
|
-
form.set("agent", request.agent);
|
|
1039
|
-
if (request.model)
|
|
1040
|
-
form.set("model", request.model);
|
|
1041
|
-
form.set("announce", "true");
|
|
1042
|
-
form.set("file", Bun.file(request.path), request.path.split("/").pop() ?? "output");
|
|
1043
|
-
const response = await fetch(`${hubNode.baseUrl.replace(/\/+$/, "")}/api/v1/peer/bridge/file`, {
|
|
1044
|
-
method: "POST",
|
|
1045
|
-
headers: { authorization: `Bearer ${token}` },
|
|
1046
|
-
body: form,
|
|
1047
|
-
signal: AbortSignal.timeout(PEER_BRIDGE_TIMEOUT_MS)
|
|
1048
|
-
});
|
|
1049
|
-
const body = await response.json().catch(() => null);
|
|
1050
|
-
return response.ok && body?.ok ? { ok: true } : { ok: false, error: body?.error ?? `peer call failed (${response.status})` };
|
|
1051
|
-
} catch (error) {
|
|
1052
|
-
return { ok: false, error: error.message };
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
};
|
|
1056
|
-
|
|
1057
|
-
// ../../adapters/otium/src/session-bridge.ts
|
|
1058
|
-
var PEER_TIMEOUT_MS = 15000;
|
|
1059
|
-
var PENDING_ASK_TTL_MS = 15 * 60 * 1000;
|
|
1060
|
-
function prunePendingRemoteAsks(now = Date.now()) {
|
|
1061
|
-
pruneRemoteAsks(now - PENDING_ASK_TTL_MS);
|
|
1062
|
-
}
|
|
1063
|
-
async function postPeer(node, path, body) {
|
|
1064
|
-
try {
|
|
1065
|
-
const token = await mintPeerToken(node.cellId);
|
|
1066
|
-
const response = await fetch(`${node.baseUrl.replace(/\/+$/, "")}${path}`, {
|
|
1067
|
-
method: "POST",
|
|
1068
|
-
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
1069
|
-
body: JSON.stringify(body),
|
|
1070
|
-
signal: AbortSignal.timeout(PEER_TIMEOUT_MS)
|
|
1071
|
-
});
|
|
1072
|
-
const parsed = await response.json().catch(() => null);
|
|
1073
|
-
if (!response.ok || !parsed?.ok) {
|
|
1074
|
-
return {
|
|
1075
|
-
ok: false,
|
|
1076
|
-
error: parsed?.error ?? `peer call failed (${response.status})`,
|
|
1077
|
-
status: response.status,
|
|
1078
|
-
...response.status >= 500 ? { ambiguous: true } : {}
|
|
1079
|
-
};
|
|
1080
|
-
}
|
|
1081
|
-
return parsed;
|
|
1082
|
-
} catch (error) {
|
|
1083
|
-
return {
|
|
1084
|
-
ok: false,
|
|
1085
|
-
error: error.message || "peer node unreachable",
|
|
1086
|
-
ambiguous: true
|
|
1087
|
-
};
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
async function postPeerIdempotent(node, path, body) {
|
|
1091
|
-
let result = await postPeer(node, path, body);
|
|
1092
|
-
for (const delayMs of [50, 150]) {
|
|
1093
|
-
if (result.ok || !result.ambiguous)
|
|
1094
|
-
return result;
|
|
1095
|
-
await Bun.sleep(delayMs);
|
|
1096
|
-
result = await postPeer(node, path, body);
|
|
1097
|
-
}
|
|
1098
|
-
return result;
|
|
1099
|
-
}
|
|
1100
|
-
async function peerSupportsRemoteAsk(node) {
|
|
1101
|
-
try {
|
|
1102
|
-
const token = await mintPeerToken(node.cellId);
|
|
1103
|
-
const response = await fetch(`${node.baseUrl.replace(/\/+$/, "")}/api/v1/peer/capabilities`, {
|
|
1104
|
-
headers: { authorization: `Bearer ${token}` },
|
|
1105
|
-
signal: AbortSignal.timeout(PEER_TIMEOUT_MS)
|
|
1106
|
-
});
|
|
1107
|
-
const body = await response.json().catch(() => null);
|
|
1108
|
-
return response.ok && body?.ok === true && body.features?.remoteAsk === true;
|
|
1109
|
-
} catch {
|
|
1110
|
-
return false;
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1113
|
-
async function findNode(nodeName) {
|
|
1114
|
-
const find = (nodes) => nodes.find((node) => node.nodeName === nodeName) ?? null;
|
|
1115
|
-
return find(await listPeerNodes()) ?? find(await listPeerNodes({ fresh: true }));
|
|
1116
|
-
}
|
|
1117
|
-
async function originLabel(args) {
|
|
1118
|
-
const self = await selfPeerNode();
|
|
1119
|
-
const local = args.fromTitle?.trim() || args.fromKey?.trim() || "peer";
|
|
1120
|
-
return self?.nodeName ? `${self.nodeName}/${local}` : local;
|
|
1121
|
-
}
|
|
1122
|
-
async function forward(args) {
|
|
1123
|
-
const node = await findNode(args.toNode).catch(() => null);
|
|
1124
|
-
if (!node || node.self)
|
|
1125
|
-
return { ok: false, error: `unknown remote node "${args.toNode}"` };
|
|
1126
|
-
const self = await selfPeerNode().catch(() => null);
|
|
1127
|
-
if (!self)
|
|
1128
|
-
return { ok: false, error: "local peer node is not attached" };
|
|
1129
|
-
if (!self.isPrimary && !node.isPrimary) {
|
|
1130
|
-
return { ok: false, error: "worker peer calls must target the primary hub" };
|
|
1131
|
-
}
|
|
1132
|
-
const fromLabel = await originLabel(args);
|
|
1133
|
-
const requestId = args.requestId;
|
|
1134
|
-
if (args.action === "ask") {
|
|
1135
|
-
if (!requestId || !args.fromTopicId || !args.fromKey || !args.message) {
|
|
1136
|
-
return { ok: false, error: "remote ask is missing caller metadata" };
|
|
1137
|
-
}
|
|
1138
|
-
if (!await peerSupportsRemoteAsk(node)) {
|
|
1139
|
-
return { ok: false, error: `node "${args.toNode}" does not support remote ask` };
|
|
1140
|
-
}
|
|
1141
|
-
prunePendingRemoteAsks();
|
|
1142
|
-
const created = createRemoteAsk({
|
|
1143
|
-
requestId,
|
|
1144
|
-
expectedCellId: node.cellId,
|
|
1145
|
-
userId: args.userId,
|
|
1146
|
-
callerTopicId: args.fromTopicId,
|
|
1147
|
-
from: args.fromKey,
|
|
1148
|
-
to: `${args.toNode}/${args.toTopic}`,
|
|
1149
|
-
...node.isPrimary && args.sourceQueryId ? { sourceQueryId: args.sourceQueryId } : {}
|
|
1150
|
-
});
|
|
1151
|
-
if (!created) {
|
|
1152
|
-
return { ok: false, error: `remote ask requestId "${requestId}" is already pending` };
|
|
1153
|
-
}
|
|
1154
|
-
const result = await postPeerIdempotent(node, "/api/v1/peer/ask", {
|
|
1155
|
-
v: PEER_PROTOCOL_VERSION,
|
|
1156
|
-
requestId,
|
|
1157
|
-
userId: args.userId,
|
|
1158
|
-
toTopic: args.toTopic,
|
|
1159
|
-
fromLabel,
|
|
1160
|
-
message: args.message,
|
|
1161
|
-
fromDepth: args.fromDepth ?? 0,
|
|
1162
|
-
replyTo: { topicId: args.fromTopicId },
|
|
1163
|
-
...args.sourceQueryId ? { sourceQueryId: args.sourceQueryId } : {}
|
|
1164
|
-
});
|
|
1165
|
-
if (!result.ok) {
|
|
1166
|
-
if (!result.ambiguous) {
|
|
1167
|
-
deleteRemoteAsk(requestId);
|
|
1168
|
-
return result;
|
|
1169
|
-
}
|
|
1170
|
-
logger.warn({ requestId, node: node.nodeName, error: result.error }, "otium: remote ask acceptance is uncertain; preserving durable reply route");
|
|
1171
|
-
return { ok: true };
|
|
1172
|
-
}
|
|
1173
|
-
return result;
|
|
1174
|
-
}
|
|
1175
|
-
if (args.action === "tell") {
|
|
1176
|
-
if (!requestId || !args.message)
|
|
1177
|
-
return { ok: false, error: "remote tell is incomplete" };
|
|
1178
|
-
const result = await postPeerIdempotent(node, "/api/v1/peer/tell", {
|
|
1179
|
-
v: PEER_PROTOCOL_VERSION,
|
|
1180
|
-
requestId,
|
|
1181
|
-
userId: args.userId,
|
|
1182
|
-
toTopic: args.toTopic,
|
|
1183
|
-
fromLabel,
|
|
1184
|
-
message: args.message,
|
|
1185
|
-
depth: args.depth ?? 0,
|
|
1186
|
-
...args.sourceQueryId ? { sourceQueryId: args.sourceQueryId } : {}
|
|
1187
|
-
});
|
|
1188
|
-
if (!result.ok && result.ambiguous) {
|
|
1189
|
-
logger.warn({ requestId, node: node.nodeName, error: result.error }, "otium: remote tell delivery is uncertain after idempotent retries");
|
|
1190
|
-
return { ok: true };
|
|
1191
|
-
}
|
|
1192
|
-
return result;
|
|
1193
|
-
}
|
|
1194
|
-
return postPeer(node, "/api/v1/peer/abort", {
|
|
1195
|
-
v: PEER_PROTOCOL_VERSION,
|
|
1196
|
-
userId: args.userId,
|
|
1197
|
-
toTopic: args.toTopic,
|
|
1198
|
-
...requestId ? { requestId } : {},
|
|
1199
|
-
...args.sourceQueryId ? { sourceQueryId: args.sourceQueryId } : {}
|
|
1200
|
-
});
|
|
1201
|
-
}
|
|
1202
|
-
async function sessions(userId, sourceQueryId) {
|
|
1203
|
-
const nodes = await listPeerNodes().catch(() => []);
|
|
1204
|
-
const self = nodes.find((node) => node.self);
|
|
1205
|
-
if (!self)
|
|
1206
|
-
return { ok: false, nodes: [] };
|
|
1207
|
-
return {
|
|
1208
|
-
ok: true,
|
|
1209
|
-
nodes: await Promise.all(nodes.filter((node) => !node.self && node.nodeName && (self.isPrimary || node.isPrimary)).map(async (node) => {
|
|
1210
|
-
const result = await postPeer(node, "/api/v1/peer/sessions", {
|
|
1211
|
-
v: PEER_PROTOCOL_VERSION,
|
|
1212
|
-
userId,
|
|
1213
|
-
...sourceQueryId ? { sourceQueryId } : {}
|
|
1214
|
-
});
|
|
1215
|
-
return result.ok ? { node: node.nodeName, sessions: result.sessions } : { node: node.nodeName, error: result.error };
|
|
1216
|
-
}))
|
|
1217
|
-
};
|
|
1218
|
-
}
|
|
1219
|
-
async function reply(route, sourceTitle, replyText, kind) {
|
|
1220
|
-
upsertPeerReplyOutbox({
|
|
1221
|
-
nodeCellId: route.nodeCellId,
|
|
1222
|
-
requestId: route.requestId,
|
|
1223
|
-
nodeName: route.nodeName,
|
|
1224
|
-
topicId: route.topicId,
|
|
1225
|
-
userId: route.userId,
|
|
1226
|
-
sourceTitle,
|
|
1227
|
-
replyText,
|
|
1228
|
-
kind
|
|
1229
|
-
});
|
|
1230
|
-
await flushPeerReplyOutbox();
|
|
1231
|
-
return true;
|
|
1232
|
-
}
|
|
1233
|
-
var peerReplyFlush = null;
|
|
1234
|
-
function flushPeerReplyOutbox() {
|
|
1235
|
-
if (peerReplyFlush)
|
|
1236
|
-
return peerReplyFlush;
|
|
1237
|
-
peerReplyFlush = (async () => {
|
|
1238
|
-
for (const pending of listPeerReplyOutbox()) {
|
|
1239
|
-
const node = await resolvePeerNodeByCellId(pending.node_cell_id).catch(() => null);
|
|
1240
|
-
if (!node)
|
|
1241
|
-
continue;
|
|
1242
|
-
const fromLabel = await originLabel({
|
|
1243
|
-
action: "ask",
|
|
1244
|
-
toNode: pending.node_name,
|
|
1245
|
-
toTopic: "",
|
|
1246
|
-
userId: pending.user_id,
|
|
1247
|
-
fromTitle: pending.source_title
|
|
1248
|
-
});
|
|
1249
|
-
const result = await postPeer(node, "/api/v1/peer/reply", {
|
|
1250
|
-
v: PEER_PROTOCOL_VERSION,
|
|
1251
|
-
requestId: pending.request_id,
|
|
1252
|
-
userId: pending.user_id,
|
|
1253
|
-
kind: pending.kind,
|
|
1254
|
-
replyText: pending.reply_text,
|
|
1255
|
-
fromLabel
|
|
1256
|
-
});
|
|
1257
|
-
if (result.ok || !result.ambiguous) {
|
|
1258
|
-
deletePeerReplyOutbox(pending.node_cell_id, pending.request_id);
|
|
1259
|
-
if (!result.ok && result.status !== 404) {
|
|
1260
|
-
logger.warn({ requestId: pending.request_id, status: result.status, error: result.error }, "otium: dropping definitively rejected peer reply");
|
|
1261
|
-
}
|
|
1262
|
-
continue;
|
|
1263
|
-
}
|
|
1264
|
-
logger.warn({ requestId: pending.request_id, error: result.error }, "otium: peer reply delivery failed; retained for retry");
|
|
1265
|
-
}
|
|
1266
|
-
})().finally(() => {
|
|
1267
|
-
peerReplyFlush = null;
|
|
1268
|
-
});
|
|
1269
|
-
return peerReplyFlush;
|
|
1270
|
-
}
|
|
1271
|
-
function startPeerReplyOutboxWorker() {
|
|
1272
|
-
flushPeerReplyOutbox();
|
|
1273
|
-
const timer = setInterval(() => void flushPeerReplyOutbox(), 5000);
|
|
1274
|
-
timer.unref?.();
|
|
1275
|
-
return () => clearInterval(timer);
|
|
1276
|
-
}
|
|
1277
|
-
async function acceptRemoteAskReplyResult(args) {
|
|
1278
|
-
prunePendingRemoteAsks();
|
|
1279
|
-
const pending = getRemoteAsk(args.requestId);
|
|
1280
|
-
if (!pending || pending.expected_cell_id !== args.fromCellId || pending.user_id !== args.userId) {
|
|
1281
|
-
return "missing";
|
|
1282
|
-
}
|
|
1283
|
-
if (pending.source_query_id) {
|
|
1284
|
-
const node = await resolvePeerNodeByCellId(pending.expected_cell_id).catch(() => null);
|
|
1285
|
-
if (!node?.isPrimary)
|
|
1286
|
-
return "missing";
|
|
1287
|
-
const delivered2 = await postPeer(node, "/api/v1/peer/ask-callback", {
|
|
1288
|
-
v: PEER_PROTOCOL_VERSION,
|
|
1289
|
-
requestId: args.requestId,
|
|
1290
|
-
sourceQueryId: pending.source_query_id,
|
|
1291
|
-
userId: pending.user_id,
|
|
1292
|
-
fromLabel: args.fromLabel,
|
|
1293
|
-
replyText: args.replyText,
|
|
1294
|
-
kind: args.kind
|
|
1295
|
-
});
|
|
1296
|
-
if (!delivered2.ok) {
|
|
1297
|
-
logger.warn({ requestId: args.requestId, error: delivered2.error }, "otium: canonical remote ask callback failed");
|
|
1298
|
-
return delivered2.ambiguous ? "retry" : "missing";
|
|
1299
|
-
}
|
|
1300
|
-
deleteRemoteAsk(args.requestId);
|
|
1301
|
-
clearPendingAsk({
|
|
1302
|
-
userId: pending.user_id,
|
|
1303
|
-
from: pending.from_key,
|
|
1304
|
-
to: pending.to_key,
|
|
1305
|
-
requestId: args.requestId
|
|
1306
|
-
});
|
|
1307
|
-
return "accepted";
|
|
1308
|
-
}
|
|
1309
|
-
const delivered = await deliverAskCallbackToCaller({
|
|
1310
|
-
requestId: args.requestId,
|
|
1311
|
-
callerTopicId: pending.caller_topic_id,
|
|
1312
|
-
callerUserId: pending.user_id,
|
|
1313
|
-
pendingAsk: {
|
|
1314
|
-
userId: pending.user_id,
|
|
1315
|
-
from: pending.from_key,
|
|
1316
|
-
to: pending.to_key,
|
|
1317
|
-
requestId: args.requestId
|
|
1318
|
-
}
|
|
1319
|
-
}, args.fromLabel, args.replyText, args.kind);
|
|
1320
|
-
if (!delivered)
|
|
1321
|
-
return "missing";
|
|
1322
|
-
deleteRemoteAsk(args.requestId);
|
|
1323
|
-
return "accepted";
|
|
1324
|
-
}
|
|
1325
|
-
var otiumPeerSessionBridge = { forward, sessions, reply };
|
|
1326
|
-
|
|
1327
|
-
// ../../adapters/otium/src/session-bridge-ipc.ts
|
|
1328
|
-
import { timingSafeEqual } from "crypto";
|
|
1329
|
-
var MAX_BODY_BYTES2 = 1024 * 1024;
|
|
1330
|
-
var MAX_INFLIGHT2 = 32;
|
|
1331
|
-
var BODY_TIMEOUT_MS = 1e4;
|
|
1332
|
-
function authorized(request, token) {
|
|
1333
|
-
const supplied = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? "";
|
|
1334
|
-
const expectedBytes = Buffer.from(token);
|
|
1335
|
-
const suppliedBytes = Buffer.from(supplied);
|
|
1336
|
-
return expectedBytes.length === suppliedBytes.length && timingSafeEqual(expectedBytes, suppliedBytes);
|
|
1337
|
-
}
|
|
1338
|
-
function boundedString(value, max = 16384) {
|
|
1339
|
-
return typeof value === "string" && value.length > 0 && value.length <= max;
|
|
1340
|
-
}
|
|
1341
|
-
function validRequest(payload) {
|
|
1342
|
-
if (!payload || typeof payload !== "object" || !boundedString(payload.action, 16))
|
|
1343
|
-
return false;
|
|
1344
|
-
if (payload.action === "sessions")
|
|
1345
|
-
return boundedString(payload.userId, 512);
|
|
1346
|
-
if (payload.action === "forward") {
|
|
1347
|
-
const args = payload.args;
|
|
1348
|
-
return Boolean(args) && ["tell", "ask", "abort"].includes(args.action) && boundedString(args.toNode, 512) && boundedString(args.toTopic, 512) && boundedString(args.userId, 512) && (args.message === undefined || boundedString(args.message, 256 * 1024));
|
|
1349
|
-
}
|
|
1350
|
-
if (payload.action === "reply") {
|
|
1351
|
-
return Boolean(payload.route) && boundedString(payload.route.userId, 512) && boundedString(payload.route.requestId, 512) && boundedString(payload.sourceTitle, 2048) && boundedString(payload.replyText, 256 * 1024) && ["reply", "error"].includes(payload.kind);
|
|
1352
|
-
}
|
|
1353
|
-
return false;
|
|
1354
|
-
}
|
|
1355
|
-
async function readLimitedJson2(request) {
|
|
1356
|
-
const declaredLength = Number(request.headers.get("content-length") ?? "0");
|
|
1357
|
-
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES2)
|
|
1358
|
-
return null;
|
|
1359
|
-
const reader = request.body?.getReader();
|
|
1360
|
-
if (!reader)
|
|
1361
|
-
return null;
|
|
1362
|
-
const chunks = [];
|
|
1363
|
-
let size = 0;
|
|
1364
|
-
const timeout = setTimeout(() => void reader.cancel("peer bridge request body timeout").catch(() => {
|
|
1365
|
-
return;
|
|
1366
|
-
}), BODY_TIMEOUT_MS);
|
|
1367
|
-
try {
|
|
1368
|
-
while (true) {
|
|
1369
|
-
const { done, value } = await reader.read();
|
|
1370
|
-
if (done)
|
|
1371
|
-
break;
|
|
1372
|
-
size += value.byteLength;
|
|
1373
|
-
if (size > MAX_BODY_BYTES2) {
|
|
1374
|
-
await reader.cancel();
|
|
1375
|
-
return null;
|
|
1376
|
-
}
|
|
1377
|
-
chunks.push(value);
|
|
1378
|
-
}
|
|
1379
|
-
} catch {
|
|
1380
|
-
return null;
|
|
1381
|
-
} finally {
|
|
1382
|
-
clearTimeout(timeout);
|
|
1383
|
-
}
|
|
1384
|
-
try {
|
|
1385
|
-
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
1386
|
-
} catch {
|
|
1387
|
-
return null;
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
function startPeerSessionBridgeIpc(bridge) {
|
|
1391
|
-
const token = crypto.randomUUID() + crypto.randomUUID();
|
|
1392
|
-
let inflight = 0;
|
|
1393
|
-
const server = Bun.serve({
|
|
1394
|
-
hostname: "127.0.0.1",
|
|
1395
|
-
port: 0,
|
|
1396
|
-
async fetch(request) {
|
|
1397
|
-
if (request.method !== "POST")
|
|
1398
|
-
return new Response("method not allowed", { status: 405 });
|
|
1399
|
-
if (!authorized(request, token))
|
|
1400
|
-
return new Response("unauthorized", { status: 401 });
|
|
1401
|
-
if (inflight >= MAX_INFLIGHT2)
|
|
1402
|
-
return new Response("busy", { status: 503 });
|
|
1403
|
-
inflight += 1;
|
|
1404
|
-
try {
|
|
1405
|
-
const payload = await readLimitedJson2(request);
|
|
1406
|
-
if (!payload || !validRequest(payload)) {
|
|
1407
|
-
return new Response("invalid request", { status: 400 });
|
|
1408
|
-
}
|
|
1409
|
-
if (payload.action === "forward") {
|
|
1410
|
-
return Response.json(await bridge.forward(payload.args));
|
|
1411
|
-
}
|
|
1412
|
-
if (payload.action === "sessions") {
|
|
1413
|
-
return Response.json(await bridge.sessions(payload.userId, payload.sourceQueryId));
|
|
1414
|
-
}
|
|
1415
|
-
if (payload.action === "reply") {
|
|
1416
|
-
return Response.json(await bridge.reply(payload.route, payload.sourceTitle, payload.replyText, payload.kind));
|
|
1417
|
-
}
|
|
1418
|
-
return new Response("invalid action", { status: 400 });
|
|
1419
|
-
} catch {
|
|
1420
|
-
return new Response("bridge failed", { status: 502 });
|
|
1421
|
-
} finally {
|
|
1422
|
-
inflight -= 1;
|
|
1423
|
-
}
|
|
1424
|
-
}
|
|
1425
|
-
});
|
|
1426
|
-
const url = `http://127.0.0.1:${server.port}/`;
|
|
1427
|
-
const unregisterConfig = registerPeerSessionBridgeIpcConfig({ url, token });
|
|
1428
|
-
let stopped = false;
|
|
1429
|
-
return {
|
|
1430
|
-
url,
|
|
1431
|
-
stop() {
|
|
1432
|
-
if (stopped)
|
|
1433
|
-
return;
|
|
1434
|
-
stopped = true;
|
|
1435
|
-
unregisterConfig();
|
|
1436
|
-
server.stop(true);
|
|
1437
|
-
}
|
|
1438
|
-
};
|
|
1439
|
-
}
|
|
1440
|
-
|
|
1441
|
-
// ../../adapters/otium/src/relay-protocol.ts
|
|
1442
|
-
var PROTOCOL_VERSION = 2;
|
|
1443
|
-
function encodeFrame(frame) {
|
|
1444
|
-
const encoded = JSON.stringify(frame);
|
|
1445
|
-
if (encoded.length > MAX_FRAME_CHARS || !decodeNodeFrame(encoded) && !decodeRelayFrame(encoded)) {
|
|
1446
|
-
throw new TypeError("relay frame exceeds protocol limits or is invalid");
|
|
1447
|
-
}
|
|
1448
|
-
return encoded;
|
|
1449
|
-
}
|
|
1450
|
-
var HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
1451
|
-
var BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
1452
|
-
var MAX_FRAME_CHARS = 512 * 1024;
|
|
1453
|
-
var MAX_CHUNK_BYTES = 256 * 1024;
|
|
1454
|
-
var MAX_ENCODED_CHUNK_CHARS = Math.ceil(MAX_CHUNK_BYTES / 3) * 4;
|
|
1455
|
-
var MAX_HEADER_PAIRS = 128;
|
|
1456
|
-
var MAX_HEADER_BYTES = 64 * 1024;
|
|
1457
|
-
var MAX_ID_BYTES = 256;
|
|
1458
|
-
var MAX_PATH_BYTES = 16 * 1024;
|
|
1459
|
-
var MAX_MESSAGE_BYTES = 4 * 1024;
|
|
1460
|
-
var MAX_REASON_BYTES = 123;
|
|
1461
|
-
var MAX_METHOD_BYTES = 32;
|
|
1462
|
-
var MAX_NODE_ID_BYTES = 256;
|
|
1463
|
-
var MAX_NODE_VERSION_BYTES = 256;
|
|
1464
|
-
function isRecord(value) {
|
|
1465
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1466
|
-
}
|
|
1467
|
-
function isString(value) {
|
|
1468
|
-
return typeof value === "string";
|
|
1469
|
-
}
|
|
1470
|
-
function hasMaxBytes(value, maxBytes) {
|
|
1471
|
-
return value.length <= maxBytes && Buffer.byteLength(value, "utf8") <= maxBytes;
|
|
1472
|
-
}
|
|
1473
|
-
function isBoundedString(value, maxBytes) {
|
|
1474
|
-
return isString(value) && hasMaxBytes(value, maxBytes);
|
|
1475
|
-
}
|
|
1476
|
-
function isNonEmptyBoundedString(value, maxBytes) {
|
|
1477
|
-
return isBoundedString(value, maxBytes) && value.length > 0;
|
|
1478
|
-
}
|
|
1479
|
-
function isOptionalBoundedString(value, maxBytes) {
|
|
1480
|
-
return value === undefined || isBoundedString(value, maxBytes);
|
|
1481
|
-
}
|
|
1482
|
-
function isFiniteNumber(value) {
|
|
1483
|
-
return typeof value === "number" && Number.isFinite(value);
|
|
1484
|
-
}
|
|
1485
|
-
function isInteger(value) {
|
|
1486
|
-
return isFiniteNumber(value) && Number.isInteger(value);
|
|
1487
|
-
}
|
|
1488
|
-
function isHeaders(value) {
|
|
1489
|
-
if (!Array.isArray(value) || value.length > MAX_HEADER_PAIRS)
|
|
1490
|
-
return false;
|
|
1491
|
-
let totalBytes = 0;
|
|
1492
|
-
for (const pair of value) {
|
|
1493
|
-
if (!Array.isArray(pair) || pair.length !== 2 || !isString(pair[0]) || !HEADER_NAME.test(pair[0]) || !isString(pair[1]) || /[\0\r\n]/.test(pair[1])) {
|
|
1494
|
-
return false;
|
|
1495
|
-
}
|
|
1496
|
-
totalBytes += Buffer.byteLength(pair[0]) + Buffer.byteLength(pair[1]);
|
|
1497
|
-
if (totalBytes > MAX_HEADER_BYTES)
|
|
1498
|
-
return false;
|
|
1499
|
-
}
|
|
1500
|
-
return true;
|
|
1501
|
-
}
|
|
1502
|
-
function isBase64(value) {
|
|
1503
|
-
if (!isString(value) || value.length > MAX_ENCODED_CHUNK_CHARS || value.length % 4 !== 0 || !BASE64.test(value))
|
|
1504
|
-
return false;
|
|
1505
|
-
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
|
|
1506
|
-
return value.length / 4 * 3 - padding <= MAX_CHUNK_BYTES;
|
|
1507
|
-
}
|
|
1508
|
-
function isWsData(frame) {
|
|
1509
|
-
return isBoundedString(frame.text, MAX_CHUNK_BYTES) && frame.dataB64 === undefined || frame.text === undefined && isBase64(frame.dataB64);
|
|
1510
|
-
}
|
|
1511
|
-
function isWsClose(frame) {
|
|
1512
|
-
return isNonEmptyBoundedString(frame.id, MAX_ID_BYTES) && (frame.code === undefined || isInteger(frame.code) && frame.code >= 0 && frame.code <= 65535) && isOptionalBoundedString(frame.reason, MAX_REASON_BYTES);
|
|
1513
|
-
}
|
|
1514
|
-
function decodeFrame(raw) {
|
|
1515
|
-
if (typeof raw !== "string" || raw.length > MAX_FRAME_CHARS)
|
|
1516
|
-
return null;
|
|
1517
|
-
try {
|
|
1518
|
-
const parsed = JSON.parse(raw);
|
|
1519
|
-
if (isRecord(parsed) && typeof parsed.type === "string")
|
|
1520
|
-
return parsed;
|
|
1521
|
-
return null;
|
|
1522
|
-
} catch {
|
|
1523
|
-
return null;
|
|
1524
|
-
}
|
|
1525
|
-
}
|
|
1526
|
-
function decodeNodeFrame(raw) {
|
|
1527
|
-
const frame = decodeFrame(raw);
|
|
1528
|
-
if (!frame)
|
|
1529
|
-
return null;
|
|
1530
|
-
switch (frame.type) {
|
|
1531
|
-
case "register":
|
|
1532
|
-
return isInteger(frame.protocolVersion) && frame.protocolVersion > 0 && isOptionalBoundedString(frame.nodeVersion, MAX_NODE_VERSION_BYTES) ? frame : null;
|
|
1533
|
-
case "pong":
|
|
1534
|
-
return isFiniteNumber(frame.ts) && frame.ts >= 0 ? frame : null;
|
|
1535
|
-
case "http_res_head":
|
|
1536
|
-
return isNonEmptyBoundedString(frame.id, MAX_ID_BYTES) && isInteger(frame.status) && frame.status >= 200 && frame.status <= 599 && isHeaders(frame.headers) ? frame : null;
|
|
1537
|
-
case "http_res_chunk":
|
|
1538
|
-
return isNonEmptyBoundedString(frame.id, MAX_ID_BYTES) && isBase64(frame.dataB64) ? frame : null;
|
|
1539
|
-
case "http_res_end":
|
|
1540
|
-
case "ws_open_ok":
|
|
1541
|
-
return isNonEmptyBoundedString(frame.id, MAX_ID_BYTES) ? frame : null;
|
|
1542
|
-
case "http_res_error":
|
|
1543
|
-
case "ws_open_error":
|
|
1544
|
-
return isNonEmptyBoundedString(frame.id, MAX_ID_BYTES) && isBoundedString(frame.message, MAX_MESSAGE_BYTES) ? frame : null;
|
|
1545
|
-
case "ws_data":
|
|
1546
|
-
return isNonEmptyBoundedString(frame.id, MAX_ID_BYTES) && isWsData(frame) ? frame : null;
|
|
1547
|
-
case "ws_close":
|
|
1548
|
-
return isWsClose(frame) ? frame : null;
|
|
1549
|
-
default:
|
|
1550
|
-
return null;
|
|
1551
|
-
}
|
|
1552
|
-
}
|
|
1553
|
-
function decodeRelayFrame(raw) {
|
|
1554
|
-
const frame = decodeFrame(raw);
|
|
1555
|
-
if (!frame)
|
|
1556
|
-
return null;
|
|
1557
|
-
switch (frame.type) {
|
|
1558
|
-
case "registered":
|
|
1559
|
-
return isNonEmptyBoundedString(frame.nodeId, MAX_NODE_ID_BYTES) && isInteger(frame.protocolVersion) && frame.protocolVersion > 0 && isInteger(frame.pingIntervalMs) && frame.pingIntervalMs > 0 ? frame : null;
|
|
1560
|
-
case "register_error":
|
|
1561
|
-
return (frame.code === "upgrade_required" || frame.code === "unauthorized" || frame.code === "replaced") && isBoundedString(frame.message, MAX_MESSAGE_BYTES) ? frame : null;
|
|
1562
|
-
case "ping":
|
|
1563
|
-
return isFiniteNumber(frame.ts) && frame.ts >= 0 ? frame : null;
|
|
1564
|
-
case "http_req_head":
|
|
1565
|
-
return isNonEmptyBoundedString(frame.id, MAX_ID_BYTES) && isNonEmptyBoundedString(frame.method, MAX_METHOD_BYTES) && isBoundedString(frame.path, MAX_PATH_BYTES) && frame.path.startsWith("/") && isHeaders(frame.headers) && typeof frame.hasBody === "boolean" ? frame : null;
|
|
1566
|
-
case "http_req_chunk":
|
|
1567
|
-
return isNonEmptyBoundedString(frame.id, MAX_ID_BYTES) && isBase64(frame.dataB64) ? frame : null;
|
|
1568
|
-
case "http_req_end":
|
|
1569
|
-
case "http_req_abort":
|
|
1570
|
-
return isNonEmptyBoundedString(frame.id, MAX_ID_BYTES) ? frame : null;
|
|
1571
|
-
case "ws_open":
|
|
1572
|
-
return isNonEmptyBoundedString(frame.id, MAX_ID_BYTES) && isBoundedString(frame.path, MAX_PATH_BYTES) && frame.path.startsWith("/") && isHeaders(frame.headers) ? frame : null;
|
|
1573
|
-
case "ws_data":
|
|
1574
|
-
return isNonEmptyBoundedString(frame.id, MAX_ID_BYTES) && isWsData(frame) ? frame : null;
|
|
1575
|
-
case "ws_close":
|
|
1576
|
-
return isWsClose(frame) ? frame : null;
|
|
1577
|
-
default:
|
|
1578
|
-
return null;
|
|
1579
|
-
}
|
|
1580
|
-
}
|
|
1581
|
-
function toB64(bytes) {
|
|
1582
|
-
if (bytes.byteLength > MAX_CHUNK_BYTES) {
|
|
1583
|
-
throw new RangeError(`relay chunk exceeds ${MAX_CHUNK_BYTES} bytes`);
|
|
1584
|
-
}
|
|
1585
|
-
return Buffer.from(bytes).toString("base64");
|
|
1586
|
-
}
|
|
1587
|
-
function fromB64(dataB64) {
|
|
1588
|
-
if (!isBase64(dataB64)) {
|
|
1589
|
-
throw new RangeError(`invalid relay chunk or chunk exceeds ${MAX_CHUNK_BYTES} bytes`);
|
|
1590
|
-
}
|
|
1591
|
-
return new Uint8Array(Buffer.from(dataB64, "base64"));
|
|
1592
|
-
}
|
|
1593
|
-
function* chunkBytes(bytes) {
|
|
1594
|
-
for (let offset = 0;offset < bytes.length; offset += MAX_CHUNK_BYTES) {
|
|
1595
|
-
yield bytes.subarray(offset, offset + MAX_CHUNK_BYTES);
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
function sanitizeCloseCode(code) {
|
|
1599
|
-
if (code === undefined)
|
|
1600
|
-
return;
|
|
1601
|
-
if (code === 1000 || code >= 3000 && code <= 4999)
|
|
1602
|
-
return code;
|
|
1603
|
-
return 1000;
|
|
1604
|
-
}
|
|
1605
|
-
var DROPPED_HEADERS = new Set([
|
|
1606
|
-
"connection",
|
|
1607
|
-
"keep-alive",
|
|
1608
|
-
"proxy-authenticate",
|
|
1609
|
-
"proxy-authorization",
|
|
1610
|
-
"proxy-connection",
|
|
1611
|
-
"te",
|
|
1612
|
-
"trailer",
|
|
1613
|
-
"transfer-encoding",
|
|
1614
|
-
"upgrade",
|
|
1615
|
-
"host",
|
|
1616
|
-
"accept-encoding",
|
|
1617
|
-
"content-length",
|
|
1618
|
-
"content-encoding"
|
|
1619
|
-
]);
|
|
1620
|
-
|
|
1621
|
-
// ../../adapters/otium/src/tunnel-client.ts
|
|
1622
|
-
var MAX_WS_UPGRADE_HEADER_BYTES = 32 * 1024;
|
|
1623
|
-
var WS_PROTOCOL_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
1624
|
-
function localWebSocketOptions(headers) {
|
|
1625
|
-
const forwarded = {};
|
|
1626
|
-
let totalBytes = 0;
|
|
1627
|
-
for (const pair of headers) {
|
|
1628
|
-
if (!Array.isArray(pair) || pair.length !== 2)
|
|
1629
|
-
return null;
|
|
1630
|
-
const [rawName, value] = pair;
|
|
1631
|
-
if (typeof rawName !== "string" || typeof value !== "string" || /[\0\r\n]/.test(value)) {
|
|
1632
|
-
return null;
|
|
1633
|
-
}
|
|
1634
|
-
const name = rawName.toLowerCase();
|
|
1635
|
-
if (name !== "cookie" && name !== "authorization" && name !== "sec-websocket-protocol") {
|
|
1636
|
-
return null;
|
|
1637
|
-
}
|
|
1638
|
-
totalBytes += Buffer.byteLength(name) + Buffer.byteLength(value);
|
|
1639
|
-
if (totalBytes > MAX_WS_UPGRADE_HEADER_BYTES || name in forwarded) {
|
|
1640
|
-
return null;
|
|
1641
|
-
}
|
|
1642
|
-
if (name === "sec-websocket-protocol") {
|
|
1643
|
-
if (!WS_PROTOCOL_TOKEN.test(value))
|
|
1644
|
-
return null;
|
|
1645
|
-
}
|
|
1646
|
-
forwarded[name] = value;
|
|
1647
|
-
}
|
|
1648
|
-
return { headers: forwarded };
|
|
1649
|
-
}
|
|
1650
|
-
var DEFAULT_MIN_RECONNECT_MS = 1000;
|
|
1651
|
-
var DEFAULT_MAX_RECONNECT_MS = 30000;
|
|
1652
|
-
var RECONNECT_JITTER_RATIO = 0.2;
|
|
1653
|
-
var LIVENESS_INTERVALS = 3;
|
|
1654
|
-
var noop = () => {};
|
|
1655
|
-
var silentLogger = { info: noop, warn: noop, error: noop };
|
|
1656
|
-
|
|
1657
|
-
class TunnelClient {
|
|
1658
|
-
opts;
|
|
1659
|
-
log;
|
|
1660
|
-
ws = null;
|
|
1661
|
-
connectionGeneration = 0;
|
|
1662
|
-
statusValue = "idle";
|
|
1663
|
-
reconnectDelayMs;
|
|
1664
|
-
reconnectTimer = null;
|
|
1665
|
-
livenessTimer = null;
|
|
1666
|
-
pingIntervalMs = 30000;
|
|
1667
|
-
pendingHttp = new Map;
|
|
1668
|
-
bridgedSockets = new Map;
|
|
1669
|
-
constructor(options) {
|
|
1670
|
-
assertSecureRelayUrl(options.relayUrl);
|
|
1671
|
-
this.opts = options;
|
|
1672
|
-
this.log = options.logger ?? silentLogger;
|
|
1673
|
-
this.reconnectDelayMs = options.minReconnectDelayMs ?? DEFAULT_MIN_RECONNECT_MS;
|
|
1674
|
-
}
|
|
1675
|
-
get status() {
|
|
1676
|
-
return this.statusValue;
|
|
1677
|
-
}
|
|
1678
|
-
start() {
|
|
1679
|
-
if (this.statusValue !== "idle" && this.statusValue !== "stopped")
|
|
1680
|
-
return;
|
|
1681
|
-
this.connect();
|
|
1682
|
-
}
|
|
1683
|
-
stop() {
|
|
1684
|
-
this.statusValue = "stopped";
|
|
1685
|
-
if (this.reconnectTimer)
|
|
1686
|
-
clearTimeout(this.reconnectTimer);
|
|
1687
|
-
this.reconnectTimer = null;
|
|
1688
|
-
this.clearLiveness();
|
|
1689
|
-
this.cleanupInFlight();
|
|
1690
|
-
const ws = this.ws;
|
|
1691
|
-
this.ws = null;
|
|
1692
|
-
this.connectionGeneration += 1;
|
|
1693
|
-
ws?.close(1000, "tunnel stopped");
|
|
1694
|
-
}
|
|
1695
|
-
tunnelUrl() {
|
|
1696
|
-
const base = this.opts.relayUrl.replace(/\/+$/, "").replace(/^http/, "ws");
|
|
1697
|
-
return `${base}/tunnel`;
|
|
1698
|
-
}
|
|
1699
|
-
connect() {
|
|
1700
|
-
this.statusValue = "connecting";
|
|
1701
|
-
const WS = WebSocket;
|
|
1702
|
-
const options = { headers: { authorization: `Bearer ${this.opts.token}` } };
|
|
1703
|
-
const ws = this.opts.webSocketFactory ? this.opts.webSocketFactory(this.tunnelUrl(), options) : new WS(this.tunnelUrl(), options);
|
|
1704
|
-
const generation = ++this.connectionGeneration;
|
|
1705
|
-
this.ws = ws;
|
|
1706
|
-
ws.onopen = () => {
|
|
1707
|
-
if (!this.isActiveConnection(ws, generation))
|
|
1708
|
-
return;
|
|
1709
|
-
this.send({
|
|
1710
|
-
type: "register",
|
|
1711
|
-
protocolVersion: PROTOCOL_VERSION,
|
|
1712
|
-
nodeVersion: this.opts.nodeVersion
|
|
1713
|
-
});
|
|
1714
|
-
this.resetLiveness();
|
|
1715
|
-
};
|
|
1716
|
-
ws.onmessage = (event) => {
|
|
1717
|
-
if (!this.isActiveConnection(ws, generation))
|
|
1718
|
-
return;
|
|
1719
|
-
try {
|
|
1720
|
-
const frame = decodeRelayFrame(event.data);
|
|
1721
|
-
if (!frame) {
|
|
1722
|
-
this.rejectProtocolFrame(ws, generation, "invalid relay frame");
|
|
1723
|
-
return;
|
|
1724
|
-
}
|
|
1725
|
-
this.resetLiveness(ws, generation);
|
|
1726
|
-
this.handleFrame(frame);
|
|
1727
|
-
} catch (err) {
|
|
1728
|
-
this.log.error({ error: err instanceof Error ? err.message : String(err) }, "relay frame dispatch failed");
|
|
1729
|
-
this.rejectProtocolFrame(ws, generation, "relay frame dispatch failed");
|
|
1730
|
-
}
|
|
1731
|
-
};
|
|
1732
|
-
ws.onclose = (event) => {
|
|
1733
|
-
this.disconnectActiveConnection(ws, generation, {
|
|
1734
|
-
code: event.code,
|
|
1735
|
-
reason: event.reason
|
|
1736
|
-
});
|
|
1737
|
-
};
|
|
1738
|
-
ws.onerror = () => {};
|
|
1739
|
-
}
|
|
1740
|
-
isActiveConnection(ws, generation) {
|
|
1741
|
-
return this.ws === ws && this.connectionGeneration === generation;
|
|
1742
|
-
}
|
|
1743
|
-
rejectProtocolFrame(ws, generation, reason) {
|
|
1744
|
-
this.log.warn({ reason }, "closing relay tunnel after protocol error");
|
|
1745
|
-
this.disconnectActiveConnection(ws, generation, { reason });
|
|
1746
|
-
try {
|
|
1747
|
-
ws.close(1002, reason);
|
|
1748
|
-
} catch {}
|
|
1749
|
-
}
|
|
1750
|
-
disconnectActiveConnection(ws, generation, details, close = false) {
|
|
1751
|
-
if (!this.isActiveConnection(ws, generation))
|
|
1752
|
-
return;
|
|
1753
|
-
this.ws = null;
|
|
1754
|
-
this.connectionGeneration += 1;
|
|
1755
|
-
this.clearLiveness();
|
|
1756
|
-
this.cleanupInFlight();
|
|
1757
|
-
if (this.statusValue !== "stopped" && this.statusValue !== "unsupported" && this.statusValue !== "superseded") {
|
|
1758
|
-
this.statusValue = "connecting";
|
|
1759
|
-
this.log.warn(details, "relay tunnel disconnected");
|
|
1760
|
-
this.scheduleReconnect();
|
|
1761
|
-
}
|
|
1762
|
-
if (close) {
|
|
1763
|
-
try {
|
|
1764
|
-
ws.close();
|
|
1765
|
-
} catch {}
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
|
-
scheduleReconnect() {
|
|
1769
|
-
if (this.reconnectTimer)
|
|
1770
|
-
return;
|
|
1771
|
-
const jitter = 1 + (Math.random() * 2 - 1) * RECONNECT_JITTER_RATIO;
|
|
1772
|
-
const delay = Math.max(1, Math.round(this.reconnectDelayMs * jitter));
|
|
1773
|
-
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, this.opts.maxReconnectDelayMs ?? DEFAULT_MAX_RECONNECT_MS);
|
|
1774
|
-
this.reconnectTimer = setTimeout(() => {
|
|
1775
|
-
this.reconnectTimer = null;
|
|
1776
|
-
if (this.statusValue === "stopped" || this.statusValue === "unsupported" || this.statusValue === "superseded")
|
|
1777
|
-
return;
|
|
1778
|
-
this.connect();
|
|
1779
|
-
}, delay);
|
|
1780
|
-
}
|
|
1781
|
-
resetLiveness(ws = this.ws, generation = this.connectionGeneration) {
|
|
1782
|
-
this.clearLiveness();
|
|
1783
|
-
if (!ws)
|
|
1784
|
-
return;
|
|
1785
|
-
this.livenessTimer = setTimeout(() => {
|
|
1786
|
-
this.log.warn({}, "relay tunnel silent past liveness window \u2014 reconnecting");
|
|
1787
|
-
this.disconnectActiveConnection(ws, generation, { reason: "liveness timeout" }, true);
|
|
1788
|
-
}, this.pingIntervalMs * LIVENESS_INTERVALS);
|
|
1789
|
-
}
|
|
1790
|
-
clearLiveness() {
|
|
1791
|
-
if (this.livenessTimer)
|
|
1792
|
-
clearTimeout(this.livenessTimer);
|
|
1793
|
-
this.livenessTimer = null;
|
|
1794
|
-
}
|
|
1795
|
-
cleanupInFlight() {
|
|
1796
|
-
for (const pending of this.pendingHttp.values()) {
|
|
1797
|
-
pending.abort.abort();
|
|
1798
|
-
try {
|
|
1799
|
-
pending.bodyController?.error(new Error("relay tunnel disconnected"));
|
|
1800
|
-
} catch {}
|
|
1801
|
-
}
|
|
1802
|
-
this.pendingHttp.clear();
|
|
1803
|
-
for (const bridged of this.bridgedSockets.values()) {
|
|
1804
|
-
try {
|
|
1805
|
-
bridged.ws.close(1000, "relay tunnel disconnected");
|
|
1806
|
-
} catch {}
|
|
1807
|
-
}
|
|
1808
|
-
this.bridgedSockets.clear();
|
|
1809
|
-
}
|
|
1810
|
-
send(frame) {
|
|
1811
|
-
if (this.ws?.readyState === WebSocket.OPEN)
|
|
1812
|
-
this.ws.send(encodeFrame(frame));
|
|
1813
|
-
}
|
|
1814
|
-
handleFrame(frame) {
|
|
1815
|
-
switch (frame.type) {
|
|
1816
|
-
case "registered":
|
|
1817
|
-
this.statusValue = "registered";
|
|
1818
|
-
this.pingIntervalMs = frame.pingIntervalMs;
|
|
1819
|
-
this.reconnectDelayMs = this.opts.minReconnectDelayMs ?? DEFAULT_MIN_RECONNECT_MS;
|
|
1820
|
-
this.resetLiveness(this.ws, this.connectionGeneration);
|
|
1821
|
-
this.log.info({ nodeId: frame.nodeId }, "relay tunnel registered");
|
|
1822
|
-
break;
|
|
1823
|
-
case "register_error":
|
|
1824
|
-
if (frame.code === "upgrade_required") {
|
|
1825
|
-
this.statusValue = "unsupported";
|
|
1826
|
-
this.log.error({ message: frame.message }, "relay rejected protocol version \u2014 update this node");
|
|
1827
|
-
} else if (frame.code === "unauthorized") {
|
|
1828
|
-
this.reconnectDelayMs = this.opts.maxReconnectDelayMs ?? DEFAULT_MAX_RECONNECT_MS;
|
|
1829
|
-
this.log.error({ message: frame.message }, "relay rejected node credentials");
|
|
1830
|
-
} else {
|
|
1831
|
-
this.statusValue = "superseded";
|
|
1832
|
-
this.log.info({ message: frame.message }, "relay tunnel superseded by newer connection");
|
|
1833
|
-
const ws = this.ws;
|
|
1834
|
-
if (ws) {
|
|
1835
|
-
this.disconnectActiveConnection(ws, this.connectionGeneration, { reason: "tunnel superseded" }, true);
|
|
1836
|
-
}
|
|
1837
|
-
}
|
|
1838
|
-
break;
|
|
1839
|
-
case "ping":
|
|
1840
|
-
this.send({ type: "pong", ts: frame.ts });
|
|
1841
|
-
break;
|
|
1842
|
-
case "http_req_head":
|
|
1843
|
-
this.startHttpRequest(frame);
|
|
1844
|
-
break;
|
|
1845
|
-
case "http_req_chunk": {
|
|
1846
|
-
const pending = this.pendingHttp.get(frame.id);
|
|
1847
|
-
try {
|
|
1848
|
-
pending?.bodyController?.enqueue(fromB64(frame.dataB64));
|
|
1849
|
-
} catch {}
|
|
1850
|
-
break;
|
|
1851
|
-
}
|
|
1852
|
-
case "http_req_end": {
|
|
1853
|
-
const pending = this.pendingHttp.get(frame.id);
|
|
1854
|
-
try {
|
|
1855
|
-
pending?.bodyController?.close();
|
|
1856
|
-
} catch {}
|
|
1857
|
-
break;
|
|
1858
|
-
}
|
|
1859
|
-
case "http_req_abort": {
|
|
1860
|
-
const pending = this.pendingHttp.get(frame.id);
|
|
1861
|
-
if (pending) {
|
|
1862
|
-
this.pendingHttp.delete(frame.id);
|
|
1863
|
-
pending.abort.abort();
|
|
1864
|
-
try {
|
|
1865
|
-
pending.bodyController?.error(new Error("aborted by relay"));
|
|
1866
|
-
} catch {}
|
|
1867
|
-
}
|
|
1868
|
-
break;
|
|
1869
|
-
}
|
|
1870
|
-
case "ws_open":
|
|
1871
|
-
this.openBridgedSocket(frame.id, frame.path, frame.headers);
|
|
1872
|
-
break;
|
|
1873
|
-
case "ws_data": {
|
|
1874
|
-
const bridged = this.bridgedSockets.get(frame.id);
|
|
1875
|
-
if (!bridged)
|
|
1876
|
-
break;
|
|
1877
|
-
if (!bridged.opened) {
|
|
1878
|
-
bridged.buffer.push(frame);
|
|
1879
|
-
break;
|
|
1880
|
-
}
|
|
1881
|
-
this.deliverToLocal(bridged.ws, frame);
|
|
1882
|
-
break;
|
|
1883
|
-
}
|
|
1884
|
-
case "ws_close": {
|
|
1885
|
-
const bridged = this.bridgedSockets.get(frame.id);
|
|
1886
|
-
if (!bridged)
|
|
1887
|
-
break;
|
|
1888
|
-
this.bridgedSockets.delete(frame.id);
|
|
1889
|
-
try {
|
|
1890
|
-
bridged.ws.close(sanitizeCloseCode(frame.code), frame.reason);
|
|
1891
|
-
} catch {}
|
|
1892
|
-
break;
|
|
1893
|
-
}
|
|
1894
|
-
}
|
|
1895
|
-
}
|
|
1896
|
-
startHttpRequest(frame) {
|
|
1897
|
-
const abort = new AbortController;
|
|
1898
|
-
const pending = { abort, bodyController: null };
|
|
1899
|
-
this.pendingHttp.set(frame.id, pending);
|
|
1900
|
-
let body;
|
|
1901
|
-
if (frame.hasBody) {
|
|
1902
|
-
body = new ReadableStream({
|
|
1903
|
-
start: (controller) => {
|
|
1904
|
-
pending.bodyController = controller;
|
|
1905
|
-
}
|
|
1906
|
-
});
|
|
1907
|
-
}
|
|
1908
|
-
const headers = new Headers;
|
|
1909
|
-
for (const [name, value] of frame.headers)
|
|
1910
|
-
headers.append(name, value);
|
|
1911
|
-
this.replayHttp(frame.id, frame.method, frame.path, headers, body, abort).finally(() => {
|
|
1912
|
-
this.pendingHttp.delete(frame.id);
|
|
1913
|
-
});
|
|
1914
|
-
}
|
|
1915
|
-
async replayHttp(id, method, path, headers, body, abort) {
|
|
1916
|
-
try {
|
|
1917
|
-
const res = await fetch(`${this.opts.targetOrigin}${path}`, {
|
|
1918
|
-
method,
|
|
1919
|
-
headers,
|
|
1920
|
-
body,
|
|
1921
|
-
signal: abort.signal,
|
|
1922
|
-
redirect: "manual"
|
|
1923
|
-
});
|
|
1924
|
-
const pairs = [];
|
|
1925
|
-
res.headers.forEach((value, name) => {
|
|
1926
|
-
const lower = name.toLowerCase();
|
|
1927
|
-
if (lower === "content-encoding" || lower === "content-length" || lower === "transfer-encoding" || lower === "date")
|
|
1928
|
-
return;
|
|
1929
|
-
pairs.push([name, value]);
|
|
1930
|
-
});
|
|
1931
|
-
this.send({ type: "http_res_head", id, status: res.status, headers: pairs });
|
|
1932
|
-
if (res.body) {
|
|
1933
|
-
const reader = res.body.getReader();
|
|
1934
|
-
while (true) {
|
|
1935
|
-
const { done, value } = await reader.read();
|
|
1936
|
-
if (done)
|
|
1937
|
-
break;
|
|
1938
|
-
for (const chunk of chunkBytes(value)) {
|
|
1939
|
-
this.send({ type: "http_res_chunk", id, dataB64: toB64(chunk) });
|
|
1940
|
-
}
|
|
1941
|
-
}
|
|
1942
|
-
}
|
|
1943
|
-
this.send({ type: "http_res_end", id });
|
|
1944
|
-
} catch (err) {
|
|
1945
|
-
if (!abort.signal.aborted) {
|
|
1946
|
-
this.send({
|
|
1947
|
-
type: "http_res_error",
|
|
1948
|
-
id,
|
|
1949
|
-
message: err instanceof Error ? err.message : String(err)
|
|
1950
|
-
});
|
|
1951
|
-
}
|
|
1952
|
-
}
|
|
1953
|
-
}
|
|
1954
|
-
openBridgedSocket(id, path, headers) {
|
|
1955
|
-
const wsOrigin = this.opts.targetOrigin.replace(/^http/, "ws");
|
|
1956
|
-
let local;
|
|
1957
|
-
try {
|
|
1958
|
-
const upgrade = localWebSocketOptions(headers);
|
|
1959
|
-
if (!upgrade)
|
|
1960
|
-
throw new Error("invalid websocket upgrade headers");
|
|
1961
|
-
const WS = WebSocket;
|
|
1962
|
-
local = new WS(`${wsOrigin}${path}`, { headers: upgrade.headers });
|
|
1963
|
-
} catch (err) {
|
|
1964
|
-
this.send({
|
|
1965
|
-
type: "ws_open_error",
|
|
1966
|
-
id,
|
|
1967
|
-
message: err instanceof Error ? err.message : String(err)
|
|
1968
|
-
});
|
|
1969
|
-
return;
|
|
1970
|
-
}
|
|
1971
|
-
const bridged = { ws: local, opened: false, buffer: [] };
|
|
1972
|
-
this.bridgedSockets.set(id, bridged);
|
|
1973
|
-
local.onopen = () => {
|
|
1974
|
-
bridged.opened = true;
|
|
1975
|
-
this.send({ type: "ws_open_ok", id });
|
|
1976
|
-
for (const frame of bridged.buffer)
|
|
1977
|
-
this.deliverToLocal(local, frame);
|
|
1978
|
-
bridged.buffer = [];
|
|
1979
|
-
};
|
|
1980
|
-
local.onmessage = (event) => {
|
|
1981
|
-
if (typeof event.data === "string") {
|
|
1982
|
-
this.send({ type: "ws_data", id, text: event.data });
|
|
1983
|
-
} else {
|
|
1984
|
-
const bytes = event.data instanceof ArrayBuffer ? new Uint8Array(event.data) : new Uint8Array(event.data);
|
|
1985
|
-
this.send({ type: "ws_data", id, dataB64: toB64(bytes) });
|
|
1986
|
-
}
|
|
1987
|
-
};
|
|
1988
|
-
local.onclose = (event) => {
|
|
1989
|
-
if (this.bridgedSockets.get(id) !== bridged)
|
|
1990
|
-
return;
|
|
1991
|
-
this.bridgedSockets.delete(id);
|
|
1992
|
-
this.send({ type: "ws_close", id, code: event.code, reason: event.reason });
|
|
1993
|
-
};
|
|
1994
|
-
local.onerror = () => {
|
|
1995
|
-
if (!bridged.opened) {
|
|
1996
|
-
this.bridgedSockets.delete(id);
|
|
1997
|
-
this.send({ type: "ws_open_error", id, message: "local websocket failed" });
|
|
1998
|
-
}
|
|
1999
|
-
};
|
|
2000
|
-
}
|
|
2001
|
-
deliverToLocal(local, frame) {
|
|
2002
|
-
if (local.readyState !== WebSocket.OPEN)
|
|
2003
|
-
return;
|
|
2004
|
-
if (frame.text !== undefined)
|
|
2005
|
-
local.send(frame.text);
|
|
2006
|
-
else if (frame.dataB64 !== undefined)
|
|
2007
|
-
local.send(fromB64(frame.dataB64));
|
|
2008
|
-
}
|
|
2009
|
-
}
|
|
2010
|
-
// ../../adapters/otium/src/peer-server.ts
|
|
2011
|
-
import { statfsSync } from "fs";
|
|
2012
|
-
import { cpus, freemem, loadavg, totalmem } from "os";
|
|
2013
|
-
|
|
2014
|
-
// ../../adapters/otium/src/turn-bridge.ts
|
|
2015
|
-
import { randomUUID as randomUUID4 } from "crypto";
|
|
2016
|
-
var EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
2017
|
-
function executionFor(payload) {
|
|
2018
|
-
if (payload.execution)
|
|
2019
|
-
return payload.execution;
|
|
2020
|
-
if (!payload.agent)
|
|
2021
|
-
return null;
|
|
2022
|
-
return {
|
|
2023
|
-
agent: payload.agent,
|
|
2024
|
-
model: payload.model ?? "",
|
|
2025
|
-
effort: payload.effort ?? "medium",
|
|
2026
|
-
mcp: [],
|
|
2027
|
-
canSpawnSubagents: false
|
|
2028
|
-
};
|
|
2029
|
-
}
|
|
2030
|
-
function provisionMirrorTopic(hostCellId, payload) {
|
|
2031
|
-
const execution = payload.execution;
|
|
2032
|
-
if (!isAgentKind(execution.agent)) {
|
|
2033
|
-
return { ok: false, error: `unknown agent "${execution.agent}"`, status: 400 };
|
|
2034
|
-
}
|
|
2035
|
-
const existing = getPeerSession(hostCellId, payload.hostTopicId);
|
|
2036
|
-
if (existing?.binding_mode === "shared") {
|
|
2037
|
-
const shared = getTopic(existing.local_topic_id);
|
|
2038
|
-
if (!shared) {
|
|
2039
|
-
return { ok: false, error: "bound local topic no longer exists", status: 404 };
|
|
2040
|
-
}
|
|
2041
|
-
if (!isTopicVisible(shared)) {
|
|
2042
|
-
return { ok: false, error: "bound local topic is hidden", status: 409 };
|
|
2043
|
-
}
|
|
2044
|
-
if (!isTopicShared(shared)) {
|
|
2045
|
-
return { ok: false, error: "bound local topic is private", status: 409 };
|
|
2046
|
-
}
|
|
2047
|
-
if (!shared.participants.some((participant) => participant.userId === payload.userId)) {
|
|
2048
|
-
return { ok: false, error: "bound local topic is not visible to this user", status: 403 };
|
|
2049
|
-
}
|
|
2050
|
-
return { ok: true, localTopicId: shared.id, bindingMode: "shared" };
|
|
2051
|
-
}
|
|
2052
|
-
const localTopicId = existing?.local_topic_id ?? `peer-${randomUUID4()}`;
|
|
2053
|
-
const now = new Date().toISOString();
|
|
2054
|
-
const current = existing ? getTopic(localTopicId) : null;
|
|
2055
|
-
const currentConfig = current ? getApiTopicConfig(localTopicId) : undefined;
|
|
2056
|
-
const currentModel = currentConfig?.model ?? current?.defaultModel ?? "";
|
|
2057
|
-
const nextModel = execution.model || current?.defaultModel || "";
|
|
2058
|
-
const providerSessionIsStale = Boolean(current) && (current?.agent !== execution.agent || currentModel !== nextModel);
|
|
2059
|
-
upsertTopic({
|
|
2060
|
-
id: localTopicId,
|
|
2061
|
-
title: payload.topicTitle,
|
|
2062
|
-
kind: "agent",
|
|
2063
|
-
agent: execution.agent,
|
|
2064
|
-
aiMode: "always",
|
|
2065
|
-
defaultModel: nextModel,
|
|
2066
|
-
defaultEffort: EFFORT_LEVELS.includes(execution.effort) ? execution.effort : current?.defaultEffort ?? "medium",
|
|
2067
|
-
participants: [{ userId: payload.userId, role: "owner" }],
|
|
2068
|
-
isSubagent: true,
|
|
2069
|
-
visibility: "hidden",
|
|
2070
|
-
accessMode: "shared",
|
|
2071
|
-
...execution.description ? { description: execution.description } : {},
|
|
2072
|
-
createdAt: current?.createdAt ?? now,
|
|
2073
|
-
lastMessageAt: now
|
|
2074
|
-
});
|
|
2075
|
-
setApiTopicConfig(localTopicId, {
|
|
2076
|
-
...execution.model ? { model: execution.model } : {},
|
|
2077
|
-
...EFFORT_LEVELS.includes(execution.effort) ? { effort: execution.effort } : {},
|
|
2078
|
-
mcp: execution.mcp
|
|
2079
|
-
});
|
|
2080
|
-
if (providerSessionIsStale) {
|
|
2081
|
-
clearTopicSessionId(localTopicId, "peer-execution-spec-changed");
|
|
2082
|
-
}
|
|
2083
|
-
if (!existing)
|
|
2084
|
-
createPeerSession(hostCellId, payload.hostTopicId, localTopicId);
|
|
2085
|
-
return { ok: true, localTopicId, bindingMode: "mirror" };
|
|
2086
|
-
}
|
|
2087
|
-
var turnTrigger;
|
|
2088
|
-
function runPeerTurn(hubNode, hostCellId, payload, opts = {}) {
|
|
2089
|
-
const execution = executionFor(payload);
|
|
2090
|
-
if (!execution || !isAgentKind(execution.agent)) {
|
|
2091
|
-
return { ok: false, error: `unknown agent "${execution?.agent ?? ""}"`, status: 400 };
|
|
2092
|
-
}
|
|
2093
|
-
const claim = claimPeerTurnRequest(hostCellId, payload.requestId, payload.hostTopicId);
|
|
2094
|
-
if (!claim.claimed) {
|
|
2095
|
-
if (claim.row.host_topic_id !== payload.hostTopicId) {
|
|
2096
|
-
return { ok: false, error: "requestId already belongs to another room", status: 409 };
|
|
2097
|
-
}
|
|
2098
|
-
if (claim.row.status === "failed") {
|
|
2099
|
-
return { ok: false, error: claim.row.error ?? "previous attempt failed", status: 409 };
|
|
2100
|
-
}
|
|
2101
|
-
logger.info({ requestId: payload.requestId, hostTopicId: payload.hostTopicId, status: claim.row.status }, "otium: peer turn replay acknowledged without re-execution");
|
|
2102
|
-
return { ok: true };
|
|
2103
|
-
}
|
|
2104
|
-
const provisioned = provisionMirrorTopic(hostCellId, {
|
|
2105
|
-
userId: payload.userId,
|
|
2106
|
-
hostTopicId: payload.hostTopicId,
|
|
2107
|
-
topicTitle: payload.topicTitle,
|
|
2108
|
-
execution
|
|
2109
|
-
});
|
|
2110
|
-
if (!provisioned.ok) {
|
|
2111
|
-
markPeerTurnRequestFailed(hostCellId, payload.requestId, provisioned.error);
|
|
2112
|
-
return provisioned;
|
|
2113
|
-
}
|
|
2114
|
-
const localTopicId = provisioned.localTopicId;
|
|
2115
|
-
const localTopic = getTopic(localTopicId);
|
|
2116
|
-
if (payload.attachments?.some((fileId) => !peerFileAllowsAccess(fileId, { topicId: localTopicId, ownerUserId: payload.userId }))) {
|
|
2117
|
-
markPeerTurnRequestFailed(hostCellId, payload.requestId, "attachment access denied");
|
|
2118
|
-
return { ok: false, error: "attachment access denied", status: 403 };
|
|
2119
|
-
}
|
|
2120
|
-
const turnAgent = provisioned.bindingMode === "shared" && localTopic?.agent ? localTopic.agent : execution.agent;
|
|
2121
|
-
const previous = getActiveForwarder(localTopicId);
|
|
2122
|
-
if (previous) {
|
|
2123
|
-
previous.finish({
|
|
2124
|
-
type: "ai_aborted",
|
|
2125
|
-
queryId: previous.queryId ?? previous.requestId,
|
|
2126
|
-
topicId: localTopicId,
|
|
2127
|
-
reason: "superseded"
|
|
2128
|
-
});
|
|
2129
|
-
}
|
|
2130
|
-
const forwarder = createTurnForwarder({
|
|
2131
|
-
hostNodeId: hostCellId,
|
|
2132
|
-
requestId: payload.requestId,
|
|
2133
|
-
localTopicId,
|
|
2134
|
-
sendEvent: opts.sendEvent ?? hubEventSender(hubNode)
|
|
2135
|
-
});
|
|
2136
|
-
registerTurnForwarder(localTopicId, forwarder);
|
|
2137
|
-
let queryId = null;
|
|
2138
|
-
try {
|
|
2139
|
-
queryId = executeExternalUserTurn({
|
|
2140
|
-
topicId: localTopicId,
|
|
2141
|
-
userId: payload.userId,
|
|
2142
|
-
text: payload.message,
|
|
2143
|
-
agent: turnAgent,
|
|
2144
|
-
options: {
|
|
2145
|
-
origin: "user",
|
|
2146
|
-
requestId: payload.requestId,
|
|
2147
|
-
injectAuthorId: payload.userId,
|
|
2148
|
-
attachments: payload.attachments,
|
|
2149
|
-
visualTools: true,
|
|
2150
|
-
onDispatched: (dispatchedQueryId) => {
|
|
2151
|
-
forwarder.queryId = dispatchedQueryId;
|
|
2152
|
-
},
|
|
2153
|
-
peerBridge: {
|
|
2154
|
-
hubCellId: hostCellId,
|
|
2155
|
-
hostTopicId: payload.hostTopicId,
|
|
2156
|
-
hostQueryId: payload.requestId,
|
|
2157
|
-
canSpawnSubagents: execution.canSpawnSubagents
|
|
2158
|
-
}
|
|
2159
|
-
},
|
|
2160
|
-
...turnTrigger ? { dispatch: turnTrigger } : {}
|
|
2161
|
-
});
|
|
2162
|
-
} catch (err) {
|
|
2163
|
-
forwarder.finish({
|
|
2164
|
-
type: "ai_error",
|
|
2165
|
-
queryId: payload.requestId,
|
|
2166
|
-
topicId: localTopicId,
|
|
2167
|
-
error: `worker dispatch crashed: ${err.message}`
|
|
2168
|
-
});
|
|
2169
|
-
return { ok: false, error: "failed to start turn", status: 500 };
|
|
2170
|
-
}
|
|
2171
|
-
if (!queryId) {
|
|
2172
|
-
forwarder.finish({
|
|
2173
|
-
type: "ai_error",
|
|
2174
|
-
queryId: payload.requestId,
|
|
2175
|
-
topicId: localTopicId,
|
|
2176
|
-
error: "worker could not start the turn"
|
|
2177
|
-
});
|
|
2178
|
-
return { ok: false, error: "failed to start turn", status: 500 };
|
|
2179
|
-
}
|
|
2180
|
-
forwarder.queryId = queryId;
|
|
2181
|
-
markPeerTurnRequestRunning(hostCellId, payload.requestId);
|
|
2182
|
-
logger.info({ requestId: payload.requestId, localTopicId, queryId, title: payload.topicTitle }, "otium: peer turn running");
|
|
2183
|
-
return { ok: true };
|
|
2184
|
-
}
|
|
2185
|
-
function abortHostedPeerTurn(hostNodeId, requestId, userId, topicTitle) {
|
|
2186
|
-
const request = getPeerTurnRequest(hostNodeId, requestId);
|
|
2187
|
-
if (!request || request.status !== "running")
|
|
2188
|
-
return false;
|
|
2189
|
-
const session = getPeerSession(hostNodeId, request.host_topic_id);
|
|
2190
|
-
if (!session)
|
|
2191
|
-
return false;
|
|
2192
|
-
const topic = getTopic(session.local_topic_id);
|
|
2193
|
-
if (!topic || topic.title !== topicTitle || !topic.participants.some((participant) => participant.userId === userId)) {
|
|
2194
|
-
return false;
|
|
2195
|
-
}
|
|
2196
|
-
const forwarder = getActiveForwarder(session.local_topic_id);
|
|
2197
|
-
if (!forwarder || forwarder.requestId !== requestId)
|
|
2198
|
-
return false;
|
|
2199
|
-
if (!getRoomQuery(session.local_topic_id))
|
|
2200
|
-
return false;
|
|
2201
|
-
return abortRoom(session.local_topic_id);
|
|
2202
|
-
}
|
|
2203
|
-
|
|
2204
|
-
// ../../adapters/otium/src/peer-server.ts
|
|
2205
|
-
var RUNTIME_VERSION = NEGOTIUM_VERSION;
|
|
2206
|
-
function jsonError(error, status) {
|
|
2207
|
-
return Response.json({ ok: false, error }, { status });
|
|
2208
|
-
}
|
|
2209
|
-
function bearer(req) {
|
|
2210
|
-
const auth = req.headers.get("authorization");
|
|
2211
|
-
return auth?.startsWith("Bearer ") ? auth.slice(7) : null;
|
|
2212
|
-
}
|
|
2213
|
-
async function readBody(req) {
|
|
2214
|
-
const body = await req.json().catch(() => null);
|
|
2215
|
-
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
2216
|
-
return null;
|
|
2217
|
-
return body;
|
|
2218
|
-
}
|
|
2219
|
-
function str(body, field) {
|
|
2220
|
-
const value = body[field];
|
|
2221
|
-
return typeof value === "string" && value.trim() ? value : null;
|
|
2222
|
-
}
|
|
2223
|
-
function checkProtocol(body) {
|
|
2224
|
-
const v = body.v;
|
|
2225
|
-
if (typeof v !== "number" || v > PEER_PROTOCOL_VERSION) {
|
|
2226
|
-
return jsonError(`unsupported peer protocol version (mine: ${PEER_PROTOCOL_VERSION})`, 400);
|
|
2227
|
-
}
|
|
2228
|
-
return null;
|
|
2229
|
-
}
|
|
2230
|
-
async function requirePeer(req) {
|
|
2231
|
-
if (!otiumCentralConfig()) {
|
|
2232
|
-
return { ok: false, response: jsonError("multi-node is disabled", 403) };
|
|
2233
|
-
}
|
|
2234
|
-
const token = bearer(req);
|
|
2235
|
-
if (!token)
|
|
2236
|
-
return { ok: false, response: jsonError("missing peer token", 401) };
|
|
2237
|
-
const verified = await verifyPeerToken(token);
|
|
2238
|
-
if (!verified)
|
|
2239
|
-
return { ok: false, response: jsonError("invalid peer token", 401) };
|
|
2240
|
-
return { ok: true, verified };
|
|
2241
|
-
}
|
|
2242
|
-
function requirePrimaryOrigin(peer) {
|
|
2243
|
-
return peer.verified.fromIsPrimary ? null : jsonError("only the workspace hub may call this endpoint", 403);
|
|
2244
|
-
}
|
|
2245
|
-
function localCapabilities() {
|
|
2246
|
-
const agents = SUPPORTED_AGENTS.map((kind) => {
|
|
2247
|
-
const auth = checkAgentAuth(kind);
|
|
2248
|
-
const registry = getRegistry(kind);
|
|
2249
|
-
return {
|
|
2250
|
-
kind,
|
|
2251
|
-
available: auth.ok,
|
|
2252
|
-
defaultModel: registry.defaultModel,
|
|
2253
|
-
validEfforts: registry.validEfforts,
|
|
2254
|
-
...!auth.ok ? { error: auth.error } : {}
|
|
2255
|
-
};
|
|
2256
|
-
});
|
|
2257
|
-
return {
|
|
2258
|
-
protocolVersion: PEER_PROTOCOL_VERSION,
|
|
2259
|
-
runtimeVersion: RUNTIME_VERSION,
|
|
2260
|
-
features: {
|
|
2261
|
-
remoteAsk: true,
|
|
2262
|
-
inputFiles: true,
|
|
2263
|
-
outputFiles: true,
|
|
2264
|
-
visualBridge: true,
|
|
2265
|
-
askUserBridge: true,
|
|
2266
|
-
selfConfigBridge: true
|
|
2267
|
-
},
|
|
2268
|
-
agents,
|
|
2269
|
-
optionalMcp: OPTIONAL_FORUM_MCP_SERVERS
|
|
2270
|
-
};
|
|
2271
|
-
}
|
|
2272
|
-
function localHealth() {
|
|
2273
|
-
const memory = process.memoryUsage();
|
|
2274
|
-
let disk;
|
|
2275
|
-
try {
|
|
2276
|
-
const stats = statfsSync(DATA_DIR);
|
|
2277
|
-
disk = {
|
|
2278
|
-
totalBytes: Number(stats.blocks) * Number(stats.bsize),
|
|
2279
|
-
freeBytes: Number(stats.bavail) * Number(stats.bsize)
|
|
2280
|
-
};
|
|
2281
|
-
} catch {
|
|
2282
|
-
disk = undefined;
|
|
2283
|
-
}
|
|
2284
|
-
return {
|
|
2285
|
-
uptimeSeconds: process.uptime(),
|
|
2286
|
-
cpu: { cores: cpus().length, loadAverage: loadavg() },
|
|
2287
|
-
memory: {
|
|
2288
|
-
totalBytes: totalmem(),
|
|
2289
|
-
freeBytes: freemem(),
|
|
2290
|
-
processRssBytes: memory.rss,
|
|
2291
|
-
processHeapUsedBytes: memory.heapUsed
|
|
2292
|
-
},
|
|
2293
|
-
...disk ? { disk } : {}
|
|
2294
|
-
};
|
|
2295
|
-
}
|
|
2296
|
-
async function handleProvision(req) {
|
|
2297
|
-
const peer = await requirePeer(req);
|
|
2298
|
-
if (!peer.ok)
|
|
2299
|
-
return peer.response;
|
|
2300
|
-
const originError = requirePrimaryOrigin(peer);
|
|
2301
|
-
if (originError)
|
|
2302
|
-
return originError;
|
|
2303
|
-
const body = await readBody(req);
|
|
2304
|
-
if (!body)
|
|
2305
|
-
return jsonError("invalid JSON body", 400);
|
|
2306
|
-
const protocolError = checkProtocol(body);
|
|
2307
|
-
if (protocolError)
|
|
2308
|
-
return protocolError;
|
|
2309
|
-
const userId = str(body, "userId");
|
|
2310
|
-
const hostTopicId = str(body, "hostTopicId");
|
|
2311
|
-
const topicTitle = str(body, "topicTitle");
|
|
2312
|
-
const execution = parseExecutionSpec(body.execution);
|
|
2313
|
-
if (!userId || !hostTopicId || !topicTitle || !execution) {
|
|
2314
|
-
return jsonError("invalid peer provision request", 400);
|
|
2315
|
-
}
|
|
2316
|
-
const result = provisionMirrorTopic(peer.verified.fromCellId, {
|
|
2317
|
-
userId,
|
|
2318
|
-
hostTopicId,
|
|
2319
|
-
topicTitle,
|
|
2320
|
-
execution
|
|
2321
|
-
});
|
|
2322
|
-
if (!result.ok)
|
|
2323
|
-
return jsonError(result.error, result.status);
|
|
2324
|
-
logger.info({ hostTopicId, localTopicId: result.localTopicId, fromNode: peer.verified.fromNodeName }, "otium: hidden mirror room provisioned");
|
|
2325
|
-
return Response.json({ ok: true });
|
|
2326
|
-
}
|
|
2327
|
-
async function handleBind(req) {
|
|
2328
|
-
const peer = await requirePeer(req);
|
|
2329
|
-
if (!peer.ok)
|
|
2330
|
-
return peer.response;
|
|
2331
|
-
const originError = requirePrimaryOrigin(peer);
|
|
2332
|
-
if (originError)
|
|
2333
|
-
return originError;
|
|
2334
|
-
const body = await readBody(req);
|
|
2335
|
-
if (!body)
|
|
2336
|
-
return jsonError("invalid JSON body", 400);
|
|
2337
|
-
const protocolError = checkProtocol(body);
|
|
2338
|
-
if (protocolError)
|
|
2339
|
-
return protocolError;
|
|
2340
|
-
const userId = str(body, "userId");
|
|
2341
|
-
const hostTopicId = str(body, "hostTopicId");
|
|
2342
|
-
const localTopicId = str(body, "localTopicId");
|
|
2343
|
-
if (!userId || !hostTopicId || !localTopicId) {
|
|
2344
|
-
return jsonError("invalid peer bind request", 400);
|
|
2345
|
-
}
|
|
2346
|
-
const result = bindOtiumTopic({
|
|
2347
|
-
hostNodeId: peer.verified.fromCellId,
|
|
2348
|
-
hostTopicId,
|
|
2349
|
-
localTopicId,
|
|
2350
|
-
userId
|
|
2351
|
-
});
|
|
2352
|
-
if (!result.ok)
|
|
2353
|
-
return jsonError(result.error, result.status);
|
|
2354
|
-
return Response.json({ ok: true, localTopicId: result.localTopicId, replaced: result.replaced });
|
|
2355
|
-
}
|
|
2356
|
-
async function handleUnbind(req) {
|
|
2357
|
-
const peer = await requirePeer(req);
|
|
2358
|
-
if (!peer.ok)
|
|
2359
|
-
return peer.response;
|
|
2360
|
-
const originError = requirePrimaryOrigin(peer);
|
|
2361
|
-
if (originError)
|
|
2362
|
-
return originError;
|
|
2363
|
-
const body = await readBody(req);
|
|
2364
|
-
if (!body)
|
|
2365
|
-
return jsonError("invalid JSON body", 400);
|
|
2366
|
-
const protocolError = checkProtocol(body);
|
|
2367
|
-
if (protocolError)
|
|
2368
|
-
return protocolError;
|
|
2369
|
-
const hostTopicId = str(body, "hostTopicId");
|
|
2370
|
-
if (!hostTopicId)
|
|
2371
|
-
return jsonError("hostTopicId is required", 400);
|
|
2372
|
-
const removed = unbindOtiumTopic(peer.verified.fromCellId, hostTopicId);
|
|
2373
|
-
return Response.json({ ok: true, removed });
|
|
2374
|
-
}
|
|
2375
|
-
async function handleTurn(req) {
|
|
2376
|
-
const peer = await requirePeer(req);
|
|
2377
|
-
if (!peer.ok)
|
|
2378
|
-
return peer.response;
|
|
2379
|
-
const originError = requirePrimaryOrigin(peer);
|
|
2380
|
-
if (originError)
|
|
2381
|
-
return originError;
|
|
2382
|
-
const body = await readBody(req);
|
|
2383
|
-
if (!body)
|
|
2384
|
-
return jsonError("invalid JSON body", 400);
|
|
2385
|
-
const protocolError = checkProtocol(body);
|
|
2386
|
-
if (protocolError)
|
|
2387
|
-
return protocolError;
|
|
2388
|
-
const requestId = str(body, "requestId");
|
|
2389
|
-
const userId = str(body, "userId");
|
|
2390
|
-
const hostTopicId = str(body, "hostTopicId");
|
|
2391
|
-
const topicTitle = str(body, "topicTitle");
|
|
2392
|
-
const execution = parseExecutionSpec(body.execution);
|
|
2393
|
-
if (body.execution !== undefined && !execution) {
|
|
2394
|
-
return jsonError("invalid placed-topic execution spec", 400);
|
|
2395
|
-
}
|
|
2396
|
-
const agent = execution?.agent ?? str(body, "agent");
|
|
2397
|
-
const message = str(body, "message");
|
|
2398
|
-
if (!requestId || !userId || !hostTopicId || !topicTitle || !agent || !message) {
|
|
2399
|
-
return jsonError("requestId, userId, hostTopicId, topicTitle, agent, message are required", 400);
|
|
2400
|
-
}
|
|
2401
|
-
if (message.length > MAX_PEER_MESSAGE_LENGTH)
|
|
2402
|
-
return jsonError("message too long", 400);
|
|
2403
|
-
const hubNode = await resolvePeerNodeByCellId(peer.verified.fromCellId).catch(() => null);
|
|
2404
|
-
if (!hubNode)
|
|
2405
|
-
return jsonError("calling node is not in this workspace", 403);
|
|
2406
|
-
const payload = {
|
|
2407
|
-
v: PEER_PROTOCOL_VERSION,
|
|
2408
|
-
requestId,
|
|
2409
|
-
userId,
|
|
2410
|
-
hostTopicId,
|
|
2411
|
-
topicTitle,
|
|
2412
|
-
...execution ? { execution } : {},
|
|
2413
|
-
...agent ? { agent } : {},
|
|
2414
|
-
...str(body, "model") ? { model: str(body, "model") } : {},
|
|
2415
|
-
...str(body, "effort") ? { effort: str(body, "effort") } : {},
|
|
2416
|
-
...Array.isArray(body.attachments) && body.attachments.every((entry) => typeof entry === "string") ? { attachments: body.attachments } : {},
|
|
2417
|
-
message
|
|
2418
|
-
};
|
|
2419
|
-
const result = runPeerTurn(hubNode, peer.verified.fromCellId, payload);
|
|
2420
|
-
if (!result.ok)
|
|
2421
|
-
return jsonError(result.error, result.status);
|
|
2422
|
-
logger.info({ requestId, hostTopicId, fromNode: peer.verified.fromNodeName }, "otium: peer turn accepted");
|
|
2423
|
-
return Response.json({ ok: true });
|
|
2424
|
-
}
|
|
2425
|
-
async function handleAbort(req) {
|
|
2426
|
-
const peer = await requirePeer(req);
|
|
2427
|
-
if (!peer.ok)
|
|
2428
|
-
return peer.response;
|
|
2429
|
-
const originError = requirePrimaryOrigin(peer);
|
|
2430
|
-
if (originError)
|
|
2431
|
-
return originError;
|
|
2432
|
-
const body = await readBody(req);
|
|
2433
|
-
if (!body)
|
|
2434
|
-
return jsonError("invalid JSON body", 400);
|
|
2435
|
-
const protocolError = checkProtocol(body);
|
|
2436
|
-
if (protocolError)
|
|
2437
|
-
return protocolError;
|
|
2438
|
-
const userId = str(body, "userId");
|
|
2439
|
-
const toTopic = str(body, "toTopic");
|
|
2440
|
-
if (!userId || !toTopic)
|
|
2441
|
-
return jsonError("userId and toTopic are required", 400);
|
|
2442
|
-
const requestId = str(body, "requestId");
|
|
2443
|
-
if (requestId) {
|
|
2444
|
-
const aborted = abortHostedPeerTurn(peer.verified.fromCellId, requestId, userId, toTopic);
|
|
2445
|
-
if (!aborted)
|
|
2446
|
-
return jsonError("turn not found or already completed", 404);
|
|
2447
|
-
logger.info({ fromNode: peer.verified.fromNodeName, toTopic, requestId }, "otium: exact peer turn abort accepted");
|
|
2448
|
-
return Response.json({ ok: true });
|
|
2449
|
-
}
|
|
2450
|
-
const topic = getTopicByNameForUser(toTopic, userId);
|
|
2451
|
-
if (!topic || !isTopicShared(topic)) {
|
|
2452
|
-
return jsonError(`shared topic "${toTopic}" not found on this node`, 404);
|
|
2453
|
-
}
|
|
2454
|
-
appendJsonlEntry(sessionInboxPath(userId, topic.id), {
|
|
2455
|
-
type: "abort",
|
|
2456
|
-
timestamp: new Date().toISOString()
|
|
2457
|
-
});
|
|
2458
|
-
flushSessionInbox();
|
|
2459
|
-
logger.info({ fromNode: peer.verified.fromNodeName, toTopic }, "otium: peer abort accepted");
|
|
2460
|
-
return Response.json({ ok: true });
|
|
2461
|
-
}
|
|
2462
|
-
async function handleTell(req) {
|
|
2463
|
-
const peer = await requirePeer(req);
|
|
2464
|
-
if (!peer.ok)
|
|
2465
|
-
return peer.response;
|
|
2466
|
-
const originError = requirePrimaryOrigin(peer);
|
|
2467
|
-
if (originError)
|
|
2468
|
-
return originError;
|
|
2469
|
-
const body = await readBody(req);
|
|
2470
|
-
if (!body)
|
|
2471
|
-
return jsonError("invalid JSON body", 400);
|
|
2472
|
-
const protocolError = checkProtocol(body);
|
|
2473
|
-
if (protocolError)
|
|
2474
|
-
return protocolError;
|
|
2475
|
-
const requestId = str(body, "requestId");
|
|
2476
|
-
const userId = str(body, "userId");
|
|
2477
|
-
const toTopic = str(body, "toTopic");
|
|
2478
|
-
const fromLabel = str(body, "fromLabel");
|
|
2479
|
-
const message = str(body, "message");
|
|
2480
|
-
const depth = typeof body.depth === "number" ? body.depth : Number.NaN;
|
|
2481
|
-
if (!requestId || !userId || !toTopic || !fromLabel || !message) {
|
|
2482
|
-
return jsonError("requestId, userId, toTopic, fromLabel, message are required", 400);
|
|
2483
|
-
}
|
|
2484
|
-
if (message.length > MAX_PEER_MESSAGE_LENGTH)
|
|
2485
|
-
return jsonError("message too long", 400);
|
|
2486
|
-
if (!Number.isInteger(depth) || depth < 0) {
|
|
2487
|
-
return jsonError("depth must be a non-negative integer", 400);
|
|
2488
|
-
}
|
|
2489
|
-
if (depth > MAX_TELL_DEPTH) {
|
|
2490
|
-
return jsonError(`tell depth limit exceeded (max ${MAX_TELL_DEPTH})`, 400);
|
|
2491
|
-
}
|
|
2492
|
-
const topic = getTopicByNameForUser(toTopic, userId);
|
|
2493
|
-
if (!topic || !isTopicShared(topic)) {
|
|
2494
|
-
return jsonError(`shared topic "${toTopic}" not found on this node`, 404);
|
|
2495
|
-
}
|
|
2496
|
-
const claim = claimInboundPeerMessage({
|
|
2497
|
-
fromCellId: peer.verified.fromCellId,
|
|
2498
|
-
requestId,
|
|
2499
|
-
kind: "tell",
|
|
2500
|
-
topicId: topic.id,
|
|
2501
|
-
payload: { userId, toTopic, fromLabel, message, depth }
|
|
2502
|
-
});
|
|
2503
|
-
if (claim === "conflict")
|
|
2504
|
-
return jsonError("requestId already belongs to another tell", 409);
|
|
2505
|
-
if (claim === "replay")
|
|
2506
|
-
return Response.json({ ok: true, replayed: true });
|
|
2507
|
-
try {
|
|
2508
|
-
appendJsonlEntry(sessionInboxPath(userId, topic.id), {
|
|
2509
|
-
type: "tell",
|
|
2510
|
-
requestId,
|
|
2511
|
-
from: fromLabel,
|
|
2512
|
-
fromTitle: fromLabel,
|
|
2513
|
-
message,
|
|
2514
|
-
depth,
|
|
2515
|
-
timestamp: new Date().toISOString()
|
|
2516
|
-
});
|
|
2517
|
-
} catch (error) {
|
|
2518
|
-
releasePeerInboxRequest(peer.verified.fromCellId, requestId, "tell");
|
|
2519
|
-
throw error;
|
|
2520
|
-
}
|
|
2521
|
-
flushSessionInbox();
|
|
2522
|
-
logger.info({ from: fromLabel, fromNode: peer.verified.fromNodeName, toTopic, requestId }, "otium: peer tell accepted");
|
|
2523
|
-
return Response.json({ ok: true });
|
|
2524
|
-
}
|
|
2525
|
-
function claimInboundPeerMessage(args) {
|
|
2526
|
-
return claimPeerInboxRequest({
|
|
2527
|
-
fromCellId: args.fromCellId,
|
|
2528
|
-
requestId: args.requestId,
|
|
2529
|
-
kind: args.kind,
|
|
2530
|
-
topicId: args.topicId,
|
|
2531
|
-
payloadHash: peerInboxPayloadHash(args.payload)
|
|
2532
|
-
}).outcome;
|
|
2533
|
-
}
|
|
2534
|
-
async function handleSessions(req) {
|
|
2535
|
-
const peer = await requirePeer(req);
|
|
2536
|
-
if (!peer.ok)
|
|
2537
|
-
return peer.response;
|
|
2538
|
-
const originError = requirePrimaryOrigin(peer);
|
|
2539
|
-
if (originError)
|
|
2540
|
-
return originError;
|
|
2541
|
-
const body = await readBody(req);
|
|
2542
|
-
if (!body)
|
|
2543
|
-
return jsonError("invalid JSON body", 400);
|
|
2544
|
-
const protocolError = checkProtocol(body);
|
|
2545
|
-
if (protocolError)
|
|
2546
|
-
return protocolError;
|
|
2547
|
-
const userId = str(body, "userId");
|
|
2548
|
-
if (!userId)
|
|
2549
|
-
return jsonError("userId is required", 400);
|
|
2550
|
-
const sessions2 = listTopics().filter((topic) => topic.kind !== "manager" && !topic.isSubagent && isTopicShared(topic) && topic.participants.some((p) => p.userId === userId)).map((topic) => ({
|
|
2551
|
-
topicId: topic.id,
|
|
2552
|
-
name: topic.title,
|
|
2553
|
-
agent: topic.agent ?? null,
|
|
2554
|
-
hasSession: Boolean(getTopicSessionId(topic.id)),
|
|
2555
|
-
...topic.description ? { description: topic.description } : {}
|
|
2556
|
-
}));
|
|
2557
|
-
return Response.json({ ok: true, sessions: sessions2 });
|
|
2558
|
-
}
|
|
2559
|
-
async function handleAsk(req) {
|
|
2560
|
-
const peer = await requirePeer(req);
|
|
2561
|
-
if (!peer.ok)
|
|
2562
|
-
return peer.response;
|
|
2563
|
-
const originError = requirePrimaryOrigin(peer);
|
|
2564
|
-
if (originError)
|
|
2565
|
-
return originError;
|
|
2566
|
-
const body = await readBody(req);
|
|
2567
|
-
if (!body)
|
|
2568
|
-
return jsonError("invalid JSON body", 400);
|
|
2569
|
-
const protocolError = checkProtocol(body);
|
|
2570
|
-
if (protocolError)
|
|
2571
|
-
return protocolError;
|
|
2572
|
-
const requestId = str(body, "requestId");
|
|
2573
|
-
const userId = str(body, "userId");
|
|
2574
|
-
const toTopic = str(body, "toTopic");
|
|
2575
|
-
const fromLabel = str(body, "fromLabel");
|
|
2576
|
-
const message = str(body, "message");
|
|
2577
|
-
const fromDepth = body.fromDepth === undefined ? 0 : typeof body.fromDepth === "number" ? body.fromDepth : Number.NaN;
|
|
2578
|
-
const replyTo = body.replyTo;
|
|
2579
|
-
const replyTopicId = typeof replyTo?.topicId === "string" ? replyTo.topicId : null;
|
|
2580
|
-
if (!requestId || !userId || !toTopic || !fromLabel || !message || !replyTopicId) {
|
|
2581
|
-
return jsonError("requestId, userId, toTopic, fromLabel, message, replyTo.topicId are required", 400);
|
|
2582
|
-
}
|
|
2583
|
-
if (message.length > MAX_PEER_MESSAGE_LENGTH)
|
|
2584
|
-
return jsonError("message too long", 400);
|
|
2585
|
-
if (!Number.isInteger(fromDepth) || fromDepth < 0) {
|
|
2586
|
-
return jsonError("fromDepth must be a non-negative integer", 400);
|
|
2587
|
-
}
|
|
2588
|
-
const topic = getTopicByNameForUser(toTopic, userId);
|
|
2589
|
-
if (!topic || !isTopicShared(topic)) {
|
|
2590
|
-
return jsonError(`shared topic "${toTopic}" not found on this node`, 404);
|
|
2591
|
-
}
|
|
2592
|
-
if (!topic.agent)
|
|
2593
|
-
return jsonError(`topic "${toTopic}" has no AI invited`, 409);
|
|
2594
|
-
const claim = claimInboundPeerMessage({
|
|
2595
|
-
fromCellId: peer.verified.fromCellId,
|
|
2596
|
-
requestId,
|
|
2597
|
-
kind: "ask",
|
|
2598
|
-
topicId: topic.id,
|
|
2599
|
-
payload: { userId, toTopic, fromLabel, message, fromDepth, replyTopicId }
|
|
2600
|
-
});
|
|
2601
|
-
if (claim === "conflict")
|
|
2602
|
-
return jsonError("requestId already belongs to another ask", 409);
|
|
2603
|
-
if (claim === "replay")
|
|
2604
|
-
return Response.json({ ok: true, replayed: true });
|
|
2605
|
-
try {
|
|
2606
|
-
appendJsonlEntry(sessionInboxPath(userId, topic.id), {
|
|
2607
|
-
type: "ask",
|
|
2608
|
-
requestId,
|
|
2609
|
-
from: fromLabel,
|
|
2610
|
-
fromTitle: fromLabel,
|
|
2611
|
-
message,
|
|
2612
|
-
fromDepth,
|
|
2613
|
-
timestamp: new Date().toISOString(),
|
|
2614
|
-
remoteReply: {
|
|
2615
|
-
nodeName: peer.verified.fromNodeName ?? "",
|
|
2616
|
-
nodeCellId: peer.verified.fromCellId,
|
|
2617
|
-
topicId: replyTopicId,
|
|
2618
|
-
userId,
|
|
2619
|
-
requestId
|
|
2620
|
-
}
|
|
2621
|
-
});
|
|
2622
|
-
} catch (error) {
|
|
2623
|
-
releasePeerInboxRequest(peer.verified.fromCellId, requestId, "ask");
|
|
2624
|
-
throw error;
|
|
2625
|
-
}
|
|
2626
|
-
flushSessionInbox();
|
|
2627
|
-
return Response.json({ ok: true });
|
|
2628
|
-
}
|
|
2629
|
-
async function handleReply(req) {
|
|
2630
|
-
const peer = await requirePeer(req);
|
|
2631
|
-
if (!peer.ok)
|
|
2632
|
-
return peer.response;
|
|
2633
|
-
const originError = requirePrimaryOrigin(peer);
|
|
2634
|
-
if (originError)
|
|
2635
|
-
return originError;
|
|
2636
|
-
const body = await readBody(req);
|
|
2637
|
-
if (!body)
|
|
2638
|
-
return jsonError("invalid JSON body", 400);
|
|
2639
|
-
const protocolError = checkProtocol(body);
|
|
2640
|
-
if (protocolError)
|
|
2641
|
-
return protocolError;
|
|
2642
|
-
const requestId = str(body, "requestId");
|
|
2643
|
-
const userId = str(body, "userId");
|
|
2644
|
-
const replyText = typeof body.replyText === "string" ? body.replyText : null;
|
|
2645
|
-
const fromLabel = str(body, "fromLabel") ?? "peer";
|
|
2646
|
-
const kind = body.kind === "error" ? "error" : "reply";
|
|
2647
|
-
if (!requestId || !userId || replyText === null) {
|
|
2648
|
-
return jsonError("requestId, userId and replyText are required", 400);
|
|
2649
|
-
}
|
|
2650
|
-
const accepted = await acceptRemoteAskReplyResult({
|
|
2651
|
-
fromCellId: peer.verified.fromCellId,
|
|
2652
|
-
requestId,
|
|
2653
|
-
userId,
|
|
2654
|
-
fromLabel,
|
|
2655
|
-
replyText,
|
|
2656
|
-
kind
|
|
2657
|
-
});
|
|
2658
|
-
if (accepted === "accepted")
|
|
2659
|
-
return Response.json({ ok: true });
|
|
2660
|
-
if (accepted === "retry")
|
|
2661
|
-
return jsonError("ask callback delivery is retryable", 503);
|
|
2662
|
-
return jsonError("no pending ask for this requestId", 404);
|
|
2663
|
-
}
|
|
2664
|
-
async function handleInputFile(req) {
|
|
2665
|
-
const peer = await requirePeer(req);
|
|
2666
|
-
if (!peer.ok)
|
|
2667
|
-
return peer.response;
|
|
2668
|
-
const originError = requirePrimaryOrigin(peer);
|
|
2669
|
-
if (originError)
|
|
2670
|
-
return originError;
|
|
2671
|
-
const contentLength = Number(req.headers.get("content-length"));
|
|
2672
|
-
if (Number.isFinite(contentLength) && contentLength > MAX_PEER_INPUT_REQUEST_BYTES) {
|
|
2673
|
-
return jsonError("file too large", 413);
|
|
2674
|
-
}
|
|
2675
|
-
const form = await req.formData().catch(() => null);
|
|
2676
|
-
if (!form)
|
|
2677
|
-
return jsonError("expected multipart/form-data", 400);
|
|
2678
|
-
const hostTopicId = form.get("hostTopicId");
|
|
2679
|
-
const userId = form.get("userId");
|
|
2680
|
-
const file = form.get("file");
|
|
2681
|
-
if (typeof hostTopicId !== "string" || typeof userId !== "string" || !(file instanceof File)) {
|
|
2682
|
-
return jsonError("hostTopicId, userId, and file are required", 400);
|
|
2683
|
-
}
|
|
2684
|
-
if (file.size > MAX_PEER_INPUT_FILE_BYTES)
|
|
2685
|
-
return jsonError("file too large", 413);
|
|
2686
|
-
const session = getPeerSession(peer.verified.fromCellId, hostTopicId);
|
|
2687
|
-
const topic = session ? getTopic(session.local_topic_id) : null;
|
|
2688
|
-
if (!session || !topic?.participants.some((participant) => participant.userId === userId)) {
|
|
2689
|
-
return jsonError("provisioned peer room not found", 404);
|
|
2690
|
-
}
|
|
2691
|
-
const stored = await storePeerInputFile(file, {
|
|
2692
|
-
topicId: session.local_topic_id,
|
|
2693
|
-
ownerUserId: userId
|
|
2694
|
-
});
|
|
2695
|
-
return Response.json({ ok: true, fileId: stored.id });
|
|
2696
|
-
}
|
|
2697
|
-
async function handleDeviceVault(req) {
|
|
2698
|
-
const peer = await requirePeer(req);
|
|
2699
|
-
if (!peer.ok)
|
|
2700
|
-
return peer.response;
|
|
2701
|
-
const originError = requirePrimaryOrigin(peer);
|
|
2702
|
-
if (originError)
|
|
2703
|
-
return originError;
|
|
2704
|
-
const body = await readBody(req);
|
|
2705
|
-
if (!body)
|
|
2706
|
-
return jsonError("invalid JSON body", 400);
|
|
2707
|
-
const protocolError = checkProtocol(body);
|
|
2708
|
-
if (protocolError)
|
|
2709
|
-
return protocolError;
|
|
2710
|
-
const userId = str(body, "userId");
|
|
2711
|
-
const operation = str(body, "operation");
|
|
2712
|
-
if (!userId || !operation)
|
|
2713
|
-
return jsonError("userId and operation are required", 400);
|
|
2714
|
-
if (operation === "list") {
|
|
2715
|
-
return Response.json({ ok: true, entries: vaultList(userId) });
|
|
2716
|
-
}
|
|
2717
|
-
const rawKey = str(body, "key");
|
|
2718
|
-
if (!rawKey || !validateVaultKey(rawKey))
|
|
2719
|
-
return jsonError("invalid vault key", 400);
|
|
2720
|
-
const key = normalizeVaultKey(rawKey);
|
|
2721
|
-
if (operation === "delete") {
|
|
2722
|
-
return vaultDel(userId, key) ? Response.json({ ok: true, deleted: key }) : jsonError(`vault key "${key}" not found`, 404);
|
|
2723
|
-
}
|
|
2724
|
-
if (operation !== "set")
|
|
2725
|
-
return jsonError("invalid vault operation", 400);
|
|
2726
|
-
const value = typeof body.value === "string" ? body.value : "";
|
|
2727
|
-
const valueBytes = Buffer.byteLength(value, "utf8");
|
|
2728
|
-
if (valueBytes < VAULT_VALUE_MIN_BYTES || valueBytes > VAULT_VALUE_MAX_BYTES) {
|
|
2729
|
-
return jsonError(`value must be between ${VAULT_VALUE_MIN_BYTES} and ${VAULT_VALUE_MAX_BYTES} bytes`, 400);
|
|
2730
|
-
}
|
|
2731
|
-
const description = body.description === undefined ? "" : body.description;
|
|
2732
|
-
if (typeof description !== "string" || description.length > VAULT_DESCRIPTION_MAX_LENGTH) {
|
|
2733
|
-
return jsonError(`description must be at most ${VAULT_DESCRIPTION_MAX_LENGTH} characters`, 400);
|
|
2734
|
-
}
|
|
2735
|
-
vaultSet(userId, key, value, description);
|
|
2736
|
-
return Response.json({ ok: true, key, ...description ? { description } : {} });
|
|
2737
|
-
}
|
|
2738
|
-
async function handleOtiumPeerRequest(req) {
|
|
2739
|
-
const url = new URL(req.url);
|
|
2740
|
-
const path = url.pathname;
|
|
2741
|
-
if (path === "/ready" && req.method === "GET") {
|
|
2742
|
-
if (!otiumCentralConfig())
|
|
2743
|
-
return null;
|
|
2744
|
-
return Response.json({ ok: true });
|
|
2745
|
-
}
|
|
2746
|
-
if (!path.startsWith("/api/v1/peer/"))
|
|
2747
|
-
return null;
|
|
2748
|
-
if (req.method === "GET") {
|
|
2749
|
-
if (path === "/api/v1/peer/capabilities") {
|
|
2750
|
-
const peer = await requirePeer(req);
|
|
2751
|
-
if (!peer.ok)
|
|
2752
|
-
return peer.response;
|
|
2753
|
-
return Response.json({ ok: true, ...localCapabilities() });
|
|
2754
|
-
}
|
|
2755
|
-
if (path === "/api/v1/peer/health") {
|
|
2756
|
-
const peer = await requirePeer(req);
|
|
2757
|
-
if (!peer.ok)
|
|
2758
|
-
return peer.response;
|
|
2759
|
-
return Response.json({ ok: true, ...localHealth() });
|
|
2760
|
-
}
|
|
2761
|
-
return jsonError("not found", 404);
|
|
2762
|
-
}
|
|
2763
|
-
if (req.method !== "POST")
|
|
2764
|
-
return jsonError("not found", 404);
|
|
2765
|
-
switch (path) {
|
|
2766
|
-
case "/api/v1/peer/provision":
|
|
2767
|
-
return handleProvision(req);
|
|
2768
|
-
case "/api/v1/peer/bind":
|
|
2769
|
-
return handleBind(req);
|
|
2770
|
-
case "/api/v1/peer/unbind":
|
|
2771
|
-
return handleUnbind(req);
|
|
2772
|
-
case "/api/v1/peer/turn":
|
|
2773
|
-
return handleTurn(req);
|
|
2774
|
-
case "/api/v1/peer/abort":
|
|
2775
|
-
return handleAbort(req);
|
|
2776
|
-
case "/api/v1/peer/tell":
|
|
2777
|
-
return handleTell(req);
|
|
2778
|
-
case "/api/v1/peer/ask":
|
|
2779
|
-
return handleAsk(req);
|
|
2780
|
-
case "/api/v1/peer/sessions":
|
|
2781
|
-
return handleSessions(req);
|
|
2782
|
-
case "/api/v1/peer/reply":
|
|
2783
|
-
return handleReply(req);
|
|
2784
|
-
case "/api/v1/peer/input-file":
|
|
2785
|
-
return handleInputFile(req);
|
|
2786
|
-
case "/api/v1/peer/device-vault":
|
|
2787
|
-
return handleDeviceVault(req);
|
|
2788
|
-
default:
|
|
2789
|
-
return jsonError("not found", 404);
|
|
2790
|
-
}
|
|
2791
|
-
}
|
|
2792
|
-
|
|
2793
|
-
// ../../adapters/otium/src/index.ts
|
|
2794
|
-
function startOtiumAdapter(options) {
|
|
2795
|
-
const { join: join2 } = options;
|
|
2796
|
-
configureOtiumCentral(join2);
|
|
2797
|
-
const failed = failInterruptedPeerTurnRequestsOnStartup();
|
|
2798
|
-
if (failed > 0) {
|
|
2799
|
-
logger.warn({ failed }, "otium: failed interrupted peer turns from previous process");
|
|
2800
|
-
}
|
|
2801
|
-
const stopBackflow = startEventBackflow();
|
|
2802
|
-
const unregisterRuntimeBridge = registerPeerRuntimeBridge(otiumPeerRuntimeBridge);
|
|
2803
|
-
const unregisterSessionBridge = registerPeerSessionBridge(otiumPeerSessionBridge);
|
|
2804
|
-
const sessionBridgeIpc = startPeerSessionBridgeIpc(otiumPeerSessionBridge);
|
|
2805
|
-
const canonicalMcpBridge = startCanonicalMcpBridge();
|
|
2806
|
-
const stopPeerReplyOutbox = startPeerReplyOutboxWorker();
|
|
2807
|
-
failInterruptedRemoteAskCallbacks().then((failedAsks) => {
|
|
2808
|
-
if (failedAsks > 0) {
|
|
2809
|
-
logger.warn({ failedAsks }, "otium: failed remote asks interrupted by previous process");
|
|
2810
|
-
}
|
|
2811
|
-
});
|
|
2812
|
-
const uninstallFileHooks = installPeerFileHooks();
|
|
2813
|
-
const unsubscribeTopicCleanup = runtimeBus().subscribe((event) => {
|
|
2814
|
-
if (event.type !== "topic-deleted")
|
|
2815
|
-
return;
|
|
2816
|
-
const removed = cleanupPeerStateForLocalTopic(event.topicId);
|
|
2817
|
-
if (removed.sessions + removed.turns + removed.inboxRequests + removed.remoteAsks > 0) {
|
|
2818
|
-
logger.info({ topicId: event.topicId, ...removed }, "otium: removed peer state for deleted local topic");
|
|
2819
|
-
}
|
|
2820
|
-
});
|
|
2821
|
-
let stopped = false;
|
|
2822
|
-
let tunnel = null;
|
|
2823
|
-
logger.info({ central: join2.central, cellId: join2.cellId }, "otium: worker mode enabled");
|
|
2824
|
-
selfPeerNode().then((self) => {
|
|
2825
|
-
if (self) {
|
|
2826
|
-
logger.info({ nodeName: self.nodeName, baseUrl: self.baseUrl }, "otium: attached to workspace");
|
|
2827
|
-
}
|
|
2828
|
-
}).catch((err) => {
|
|
2829
|
-
logger.warn({ err }, "otium: self check against central failed (will retry per request)");
|
|
2830
|
-
});
|
|
2831
|
-
return {
|
|
2832
|
-
name: "otium",
|
|
2833
|
-
join: join2,
|
|
2834
|
-
startTunnel: ({ targetOrigin, relayUrl }) => {
|
|
2835
|
-
if (stopped || tunnel)
|
|
2836
|
-
return;
|
|
2837
|
-
const selectedRelay = relayUrl?.trim() || join2.relay || process.env.OTIUM_RELAY_URL?.trim();
|
|
2838
|
-
if (!selectedRelay) {
|
|
2839
|
-
logger.info({}, "otium: relay tunnel disabled (no relay URL configured)");
|
|
2840
|
-
return;
|
|
2841
|
-
}
|
|
2842
|
-
tunnel = new TunnelClient({
|
|
2843
|
-
relayUrl: selectedRelay,
|
|
2844
|
-
token: join2.secret,
|
|
2845
|
-
targetOrigin,
|
|
2846
|
-
nodeVersion: `negotium@${NEGOTIUM_VERSION}`,
|
|
2847
|
-
logger
|
|
2848
|
-
});
|
|
2849
|
-
tunnel.start();
|
|
2850
|
-
},
|
|
2851
|
-
stop: () => {
|
|
2852
|
-
if (stopped)
|
|
2853
|
-
return;
|
|
2854
|
-
stopped = true;
|
|
2855
|
-
tunnel?.stop();
|
|
2856
|
-
tunnel = null;
|
|
2857
|
-
unsubscribeTopicCleanup();
|
|
2858
|
-
unregisterRuntimeBridge();
|
|
2859
|
-
unregisterSessionBridge();
|
|
2860
|
-
sessionBridgeIpc.stop();
|
|
2861
|
-
canonicalMcpBridge.stop();
|
|
2862
|
-
stopPeerReplyOutbox();
|
|
2863
|
-
uninstallFileHooks();
|
|
2864
|
-
stopBackflow();
|
|
2865
|
-
configureOtiumCentral(null);
|
|
2866
|
-
}
|
|
2867
|
-
};
|
|
2868
|
-
}
|
|
2869
|
-
var otiumAdapter = defineNegotiumAdapter({
|
|
2870
|
-
name: "otium",
|
|
2871
|
-
capabilities: {
|
|
2872
|
-
localUserInput: false,
|
|
2873
|
-
topicManagement: false,
|
|
2874
|
-
externalPlacedTurn: true
|
|
2875
|
-
},
|
|
2876
|
-
projection: {
|
|
2877
|
-
transcript: "full",
|
|
2878
|
-
historyBackfill: false,
|
|
2879
|
-
externalAuthors: "relayed"
|
|
2880
|
-
},
|
|
2881
|
-
start: startOtiumAdapter
|
|
2882
|
-
});
|
|
2883
|
-
function startOtiumWorker() {
|
|
2884
|
-
const join2 = loadJoin();
|
|
2885
|
-
if (!join2)
|
|
2886
|
-
return null;
|
|
2887
|
-
return startOtiumAdapter({ join: join2 });
|
|
2888
|
-
}
|
|
2889
|
-
export {
|
|
2890
|
-
verifyPeerToken,
|
|
2891
|
-
unbindOtiumTopic,
|
|
2892
|
-
translateBusEvent,
|
|
2893
|
-
stopEventBackflow,
|
|
2894
|
-
startOtiumWorker,
|
|
2895
|
-
startOtiumAdapter,
|
|
2896
|
-
startEventBackflow,
|
|
2897
|
-
shareOtiumTopic,
|
|
2898
|
-
setOtiumTopicPrivate,
|
|
2899
|
-
selfPeerNode,
|
|
2900
|
-
saveJoin,
|
|
2901
|
-
runPeerTurn,
|
|
2902
|
-
resolvePeerNodeByCellId,
|
|
2903
|
-
resetPeerCentralCaches,
|
|
2904
|
-
registerTurnForwarder,
|
|
2905
|
-
provisionMirrorTopic,
|
|
2906
|
-
previewEnrollment,
|
|
2907
|
-
pendingEnrollmentPath,
|
|
2908
|
-
parseInviteCode,
|
|
2909
|
-
parseEnrollmentInvite,
|
|
2910
|
-
otiumPeerRuntimeBridge,
|
|
2911
|
-
otiumCentralConfig,
|
|
2912
|
-
otiumAdapter,
|
|
2913
|
-
mintPeerToken,
|
|
2914
|
-
loadJoin,
|
|
2915
|
-
listPeerNodes,
|
|
2916
|
-
listOtiumTopicBindings,
|
|
2917
|
-
joinFilePath,
|
|
2918
|
-
isJoinPersisted,
|
|
2919
|
-
isEnrollmentPending,
|
|
2920
|
-
hubEventSender,
|
|
2921
|
-
handleOtiumPeerRequest,
|
|
2922
|
-
getActiveForwarder,
|
|
2923
|
-
failInterruptedPeerTurnRequestsOnStartup,
|
|
2924
|
-
createTurnForwarder,
|
|
2925
|
-
configureOtiumCentral,
|
|
2926
|
-
commitEnrollment,
|
|
2927
|
-
cleanupPeerStateForLocalTopic,
|
|
2928
|
-
claimEnrollment,
|
|
2929
|
-
bindOtiumTopic,
|
|
2930
|
-
abortHostedPeerTurn,
|
|
2931
|
-
TunnelClient,
|
|
2932
|
-
PROTOCOL_VERSION as RELAY_PROTOCOL_VERSION,
|
|
2933
|
-
PEER_PROTOCOL_VERSION
|
|
2934
|
-
};
|
|
2935
|
-
|
|
2936
|
-
//# debugId=F8CF1BBB03F509B664756E2164756E21
|