whale-code 6.4.0 → 6.5.0
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/bin/swagmanager-mcp.js +7 -0
- package/dist/cli/app.js +30 -2
- package/dist/cli/chat/ChatApp.d.ts +4 -4
- package/dist/cli/chat/ChatApp.js +114 -44
- package/dist/cli/chat/ChatInput.d.ts +13 -6
- package/dist/cli/chat/ChatInput.js +433 -89
- package/dist/cli/chat/MemoryManager.d.ts +15 -0
- package/dist/cli/chat/MemoryManager.js +61 -0
- package/dist/cli/chat/MessageList.d.ts +8 -0
- package/dist/cli/chat/MessageList.js +1 -1
- package/dist/cli/chat/NodeManager.d.ts +30 -0
- package/dist/cli/chat/NodeManager.js +89 -0
- package/dist/cli/chat/NodeSelector.d.ts +19 -0
- package/dist/cli/chat/NodeSelector.js +37 -0
- package/dist/cli/chat/PlanApproval.d.ts +17 -0
- package/dist/cli/chat/PlanApproval.js +82 -0
- package/dist/cli/chat/SessionManager.d.ts +16 -0
- package/dist/cli/chat/SessionManager.js +43 -0
- package/dist/cli/chat/SlashMenu.d.ts +38 -0
- package/dist/cli/chat/SlashMenu.js +208 -0
- package/dist/cli/chat/StatusBar.d.ts +16 -0
- package/dist/cli/chat/StatusBar.js +22 -0
- package/dist/cli/chat/ThemeSelector.d.ts +14 -0
- package/dist/cli/chat/ThemeSelector.js +29 -0
- package/dist/cli/chat/ToolIndicator.d.ts +8 -0
- package/dist/cli/chat/ToolIndicator.js +33 -9
- package/dist/cli/chat/hooks/useAgentLoop.d.ts +2 -1
- package/dist/cli/chat/hooks/useAgentLoop.js +22 -17
- package/dist/cli/chat/hooks/useSlashCommands.d.ts +19 -0
- package/dist/cli/chat/hooks/useSlashCommands.js +254 -15
- package/dist/cli/commands/config-cmd.js +4 -25
- package/dist/cli/commands/db.d.ts +13 -0
- package/dist/cli/commands/db.js +243 -0
- package/dist/cli/commands/doctor.js +6 -9
- package/dist/cli/commands/mcp.js +1 -20
- package/dist/cli/services/agent-events.d.ts +22 -1
- package/dist/cli/services/agent-events.js +9 -0
- package/dist/cli/services/agent-loop.js +66 -2
- package/dist/cli/services/agent-worker-base.js +21 -6
- package/dist/cli/services/api-retry.d.ts +25 -0
- package/dist/cli/services/api-retry.js +91 -0
- package/dist/cli/services/auth-service.d.ts +1 -1
- package/dist/cli/services/auth-service.js +40 -19
- package/dist/cli/services/background-processes.js +26 -2
- package/dist/cli/services/config-store.d.ts +13 -1
- package/dist/cli/services/config-store.js +116 -13
- package/dist/cli/services/format-server-response.js +12 -6
- package/dist/cli/services/ink-resize-fix.d.ts +18 -0
- package/dist/cli/services/ink-resize-fix.js +66 -0
- package/dist/cli/services/interactive-tools.d.ts +14 -0
- package/dist/cli/services/interactive-tools.js +47 -2
- package/dist/cli/services/keybinding-manager.js +1 -1
- package/dist/cli/services/local-tools.js +35 -2
- package/dist/cli/services/server-tools.js +175 -3
- package/dist/cli/services/subagent.js +15 -3
- package/dist/cli/services/system-prompt.js +5 -3
- package/dist/cli/services/task-decomposer.d.ts +35 -0
- package/dist/cli/services/task-decomposer.js +199 -0
- package/dist/cli/services/team-lead.d.ts +18 -0
- package/dist/cli/services/team-lead.js +80 -0
- package/dist/cli/services/teammate.js +5 -5
- package/dist/cli/services/telemetry.d.ts +8 -2
- package/dist/cli/services/telemetry.js +116 -92
- package/dist/cli/services/tools/agent-tools.d.ts +1 -0
- package/dist/cli/services/tools/agent-tools.js +50 -4
- package/dist/cli/services/tools/file-ops.d.ts +2 -0
- package/dist/cli/services/tools/file-ops.js +71 -19
- package/dist/cli/services/tools/shell-exec.js +22 -12
- package/dist/cli/shared/Theme.d.ts +1 -2
- package/dist/cli/shared/Theme.js +1 -1
- package/dist/cli/shared/WhaleBanner.d.ts +4 -1
- package/dist/cli/shared/WhaleBanner.js +12 -8
- package/dist/cli/shared/markdown.d.ts +5 -4
- package/dist/cli/shared/markdown.js +376 -334
- package/dist/cli/shared/theme-manager.d.ts +27 -0
- package/dist/cli/shared/theme-manager.js +178 -0
- package/dist/cli/shared/theme-presets.d.ts +16 -0
- package/dist/cli/shared/theme-presets.js +265 -0
- package/dist/index.js +0 -51
- package/dist/node/adapters/imessage.d.ts +10 -0
- package/dist/node/adapters/imessage.js +45 -6
- package/dist/node/cli.js +459 -8
- package/dist/node/config.d.ts +17 -0
- package/dist/node/gateway-client.d.ts +55 -0
- package/dist/node/gateway-client.js +201 -0
- package/dist/node/portal/clipboard.d.ts +28 -0
- package/dist/node/portal/clipboard.js +183 -0
- package/dist/node/portal/discovery.d.ts +29 -0
- package/dist/node/portal/discovery.js +61 -0
- package/dist/node/portal/forward.d.ts +30 -0
- package/dist/node/portal/forward.js +90 -0
- package/dist/node/portal/index.d.ts +47 -0
- package/dist/node/portal/index.js +250 -0
- package/dist/node/portal/multiplexer.d.ts +48 -0
- package/dist/node/portal/multiplexer.js +207 -0
- package/dist/node/portal/permissions.d.ts +36 -0
- package/dist/node/portal/permissions.js +131 -0
- package/dist/node/portal/protocol.d.ts +140 -0
- package/dist/node/portal/protocol.js +193 -0
- package/dist/node/portal/screen.d.ts +18 -0
- package/dist/node/portal/screen.js +93 -0
- package/dist/node/portal/session.d.ts +68 -0
- package/dist/node/portal/session.js +127 -0
- package/dist/node/portal/shell.d.ts +26 -0
- package/dist/node/portal/shell.js +142 -0
- package/dist/node/portal/stream.d.ts +43 -0
- package/dist/node/portal/stream.js +90 -0
- package/dist/node/portal/transfer.d.ts +33 -0
- package/dist/node/portal/transfer.js +231 -0
- package/dist/node/portal/ui.d.ts +16 -0
- package/dist/node/portal/ui.js +148 -0
- package/dist/node/remote-desktop/compile-helper.d.ts +13 -0
- package/dist/node/remote-desktop/compile-helper.js +73 -0
- package/dist/node/remote-desktop/index.d.ts +67 -0
- package/dist/node/remote-desktop/index.js +220 -0
- package/dist/node/remote-desktop/protocol.d.ts +96 -0
- package/dist/node/remote-desktop/protocol.js +67 -0
- package/dist/node/runtime.d.ts +8 -1
- package/dist/node/runtime.js +117 -9
- package/dist/server/handlers/__test-utils__/test-db.d.ts +25 -0
- package/dist/server/handlers/__test-utils__/test-db.js +128 -0
- package/dist/server/handlers/api-keys.js +26 -2
- package/dist/server/handlers/browser.d.ts +0 -4
- package/dist/server/handlers/browser.js +0 -46
- package/dist/server/handlers/catalog.js +37 -14
- package/dist/server/handlers/clickhouse.d.ts +10 -0
- package/dist/server/handlers/clickhouse.js +215 -0
- package/dist/server/handlers/comms.d.ts +308 -4
- package/dist/server/handlers/comms.js +444 -11
- package/dist/server/handlers/creations.js +1 -1
- package/dist/server/handlers/crm.d.ts +54 -8
- package/dist/server/handlers/crm.js +353 -68
- package/dist/server/handlers/embeddings.js +3 -3
- package/dist/server/handlers/enrichment.js +39 -55
- package/dist/server/handlers/inventory.js +1 -1
- package/dist/server/handlers/kali.d.ts +9 -1
- package/dist/server/handlers/kali.js +50 -1
- package/dist/server/handlers/media.d.ts +8 -0
- package/dist/server/handlers/media.js +902 -0
- package/dist/server/handlers/meta-ads.js +6 -3
- package/dist/server/handlers/nodes.d.ts +2 -0
- package/dist/server/handlers/nodes.js +331 -40
- package/dist/server/handlers/operations.d.ts +4 -6
- package/dist/server/handlers/operations.js +99 -38
- package/dist/server/handlers/platform.js +224 -107
- package/dist/server/handlers/remove-bg.d.ts +6 -0
- package/dist/server/handlers/remove-bg.js +96 -0
- package/dist/server/handlers/storefront.d.ts +6 -0
- package/dist/server/handlers/storefront.js +477 -0
- package/dist/server/handlers/supply-chain.js +21 -3
- package/dist/server/handlers/workflow-steps.js +87 -31
- package/dist/server/handlers/workflows.js +4 -1
- package/dist/server/index.js +334 -88
- package/dist/server/lib/clickhouse-buffer.d.ts +48 -0
- package/dist/server/lib/clickhouse-buffer.js +175 -0
- package/dist/server/lib/clickhouse-client.d.ts +112 -0
- package/dist/server/lib/clickhouse-client.js +141 -0
- package/dist/server/lib/coa-renderer.d.ts +91 -0
- package/dist/server/lib/coa-renderer.js +411 -0
- package/dist/server/lib/compaction-service.js +45 -1
- package/dist/server/lib/pdf-renderer.d.ts +143 -0
- package/dist/server/lib/pdf-renderer.js +867 -0
- package/dist/server/lib/react-pdf-layout.d.ts +40 -0
- package/dist/server/lib/react-pdf-layout.js +437 -0
- package/dist/server/lib/server-agent-loop.d.ts +2 -0
- package/dist/server/lib/server-agent-loop.js +61 -15
- package/dist/server/lib/server-subagent.d.ts +3 -0
- package/dist/server/lib/server-subagent.js +7 -4
- package/dist/server/lib/supabase-client.js +51 -3
- package/dist/server/lib/template-resolver.js +14 -4
- package/dist/server/lib/utils.js +15 -0
- package/dist/server/local-agent-gateway.d.ts +44 -0
- package/dist/server/local-agent-gateway.js +389 -49
- package/dist/server/providers/anthropic.js +12 -2
- package/dist/server/providers/gemini.js +17 -2
- package/dist/server/proxy-handlers.js +151 -0
- package/dist/server/tool-router.d.ts +2 -2
- package/dist/server/tool-router.js +25 -35
- package/dist/shared/agent-core.d.ts +5 -2
- package/dist/shared/agent-core.js +30 -4
- package/dist/shared/api-client.js +54 -3
- package/dist/shared/sse-parser.d.ts +1 -1
- package/dist/shared/sse-parser.js +5 -2
- package/dist/shared/tool-dispatch.js +1 -1
- package/package.json +16 -10
- package/dist/server/handlers/__test-utils__/mock-supabase.d.ts +0 -11
- package/dist/server/handlers/__test-utils__/mock-supabase.js +0 -393
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PortalManager — coordinator for portal connections.
|
|
3
|
+
*
|
|
4
|
+
* Manages portal sessions, handles incoming requests on the target side,
|
|
5
|
+
* and provides the public API used by the CLI and runtime.
|
|
6
|
+
*/
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { EventEmitter } from "node:events";
|
|
9
|
+
import { PORTAL_MARKER } from "./protocol.js";
|
|
10
|
+
import { PortalSession } from "./session.js";
|
|
11
|
+
import { handleShell } from "./shell.js";
|
|
12
|
+
import { handleFilePush, handleFilePull } from "./transfer.js";
|
|
13
|
+
import { handleForward } from "./forward.js";
|
|
14
|
+
import { handleScreen } from "./screen.js";
|
|
15
|
+
import { handleClipboard } from "./clipboard.js";
|
|
16
|
+
import { checkPermission } from "./permissions.js";
|
|
17
|
+
export class PortalManager extends EventEmitter {
|
|
18
|
+
config;
|
|
19
|
+
sessions = new Map();
|
|
20
|
+
gateway;
|
|
21
|
+
constructor(config) {
|
|
22
|
+
super();
|
|
23
|
+
this.config = config;
|
|
24
|
+
this.gateway = config.gateway;
|
|
25
|
+
this.setupHandlers();
|
|
26
|
+
}
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// PUBLIC API — used by CLI commands (initiator side)
|
|
29
|
+
// ============================================================================
|
|
30
|
+
/**
|
|
31
|
+
* Open a portal to a target node.
|
|
32
|
+
* Returns a PortalSession that can be used to open streams.
|
|
33
|
+
*/
|
|
34
|
+
async connect(targetNodeId, capabilities) {
|
|
35
|
+
const sessionId = randomUUID();
|
|
36
|
+
const session = new PortalSession({
|
|
37
|
+
sessionId,
|
|
38
|
+
isInitiator: true,
|
|
39
|
+
targetNodeId,
|
|
40
|
+
sendBinary: (data) => this.gateway.sendBinary(data),
|
|
41
|
+
sendJson: (msg) => this.sendJson(msg),
|
|
42
|
+
});
|
|
43
|
+
this.sessions.set(sessionId, session);
|
|
44
|
+
// Send portal request via JSON
|
|
45
|
+
this.sendJson({
|
|
46
|
+
type: "portal_request",
|
|
47
|
+
session_id: sessionId,
|
|
48
|
+
target_node_id: targetNodeId,
|
|
49
|
+
capabilities,
|
|
50
|
+
requester_node_id: this.config.nodeConfig.node_id,
|
|
51
|
+
requester_hostname: (await import("node:os")).hostname(),
|
|
52
|
+
requester_role: "admin", // role determined by server/target
|
|
53
|
+
});
|
|
54
|
+
// Wait for accept/reject
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const timeout = setTimeout(() => {
|
|
57
|
+
this.sessions.delete(sessionId);
|
|
58
|
+
session.close("timeout");
|
|
59
|
+
reject(new Error("Portal request timed out"));
|
|
60
|
+
}, 30_000);
|
|
61
|
+
session.once("accepted", () => {
|
|
62
|
+
clearTimeout(timeout);
|
|
63
|
+
resolve(session);
|
|
64
|
+
});
|
|
65
|
+
session.once("close", (reason) => {
|
|
66
|
+
clearTimeout(timeout);
|
|
67
|
+
this.sessions.delete(sessionId);
|
|
68
|
+
reject(new Error(`Portal rejected: ${reason}`));
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Get all active sessions.
|
|
74
|
+
*/
|
|
75
|
+
getSessions() {
|
|
76
|
+
return Array.from(this.sessions.values()).map(s => s.getInfo());
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Close a session by ID.
|
|
80
|
+
*/
|
|
81
|
+
closeSession(sessionId) {
|
|
82
|
+
const session = this.sessions.get(sessionId);
|
|
83
|
+
if (!session)
|
|
84
|
+
return false;
|
|
85
|
+
session.close();
|
|
86
|
+
this.sessions.delete(sessionId);
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Close all sessions.
|
|
91
|
+
*/
|
|
92
|
+
closeAll() {
|
|
93
|
+
for (const [id, session] of this.sessions) {
|
|
94
|
+
session.close();
|
|
95
|
+
this.sessions.delete(id);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// ============================================================================
|
|
99
|
+
// SETUP — wire gateway handlers
|
|
100
|
+
// ============================================================================
|
|
101
|
+
setupHandlers() {
|
|
102
|
+
// Handle incoming JSON messages (portal_request, portal_accept, etc.)
|
|
103
|
+
this.gateway.onCommand("portal_request", async (msg) => {
|
|
104
|
+
await this.handlePortalRequest(msg);
|
|
105
|
+
return null;
|
|
106
|
+
});
|
|
107
|
+
// Handle binary frames — route to correct session
|
|
108
|
+
this.gateway.onBinary((data) => {
|
|
109
|
+
if (data.length > 17 && data[0] === PORTAL_MARKER) {
|
|
110
|
+
this.routeBinaryFrame(data);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
// Also handle portal_accept, portal_reject, portal_close as regular messages
|
|
114
|
+
this.gateway.onCommand("portal_accept", async (msg) => {
|
|
115
|
+
const session = this.sessions.get(msg.session_id);
|
|
116
|
+
if (session) {
|
|
117
|
+
session.accept();
|
|
118
|
+
if (msg.hostname) {
|
|
119
|
+
session.targetHostname = msg.hostname;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
});
|
|
124
|
+
this.gateway.onCommand("portal_reject", async (msg) => {
|
|
125
|
+
const session = this.sessions.get(msg.session_id);
|
|
126
|
+
if (session) {
|
|
127
|
+
this.sessions.delete(msg.session_id);
|
|
128
|
+
session.close(msg.reason || "rejected");
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
});
|
|
132
|
+
this.gateway.onCommand("portal_close", async (msg) => {
|
|
133
|
+
const session = this.sessions.get(msg.session_id);
|
|
134
|
+
if (session) {
|
|
135
|
+
this.sessions.delete(msg.session_id);
|
|
136
|
+
session.close("remote closed");
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
// ============================================================================
|
|
142
|
+
// TARGET SIDE — handle incoming portal requests
|
|
143
|
+
// ============================================================================
|
|
144
|
+
async handlePortalRequest(request) {
|
|
145
|
+
const maxSessions = this.config.maxSessions || 5;
|
|
146
|
+
if (this.sessions.size >= maxSessions) {
|
|
147
|
+
this.sendJson({
|
|
148
|
+
type: "portal_reject",
|
|
149
|
+
session_id: request.session_id,
|
|
150
|
+
reason: `Max sessions reached (${maxSessions})`,
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// Check permissions
|
|
155
|
+
const autoAccept = this.config.autoAcceptAdmins !== false;
|
|
156
|
+
const { accepted, reason } = await checkPermission(request, this.config.nodeConfig.store_id, autoAccept);
|
|
157
|
+
if (!accepted) {
|
|
158
|
+
this.sendJson({
|
|
159
|
+
type: "portal_reject",
|
|
160
|
+
session_id: request.session_id,
|
|
161
|
+
reason: reason || "Permission denied",
|
|
162
|
+
});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
// Create session (responder side)
|
|
166
|
+
const os = await import("node:os");
|
|
167
|
+
const session = new PortalSession({
|
|
168
|
+
sessionId: request.session_id,
|
|
169
|
+
isInitiator: false,
|
|
170
|
+
targetNodeId: request.requester_node_id,
|
|
171
|
+
targetHostname: request.requester_hostname,
|
|
172
|
+
sendBinary: (data) => this.gateway.sendBinary(data),
|
|
173
|
+
sendJson: (msg) => this.sendJson(msg),
|
|
174
|
+
});
|
|
175
|
+
this.sessions.set(request.session_id, session);
|
|
176
|
+
// Handle incoming streams (shell, file, etc.)
|
|
177
|
+
session.on("stream", (stream, meta) => {
|
|
178
|
+
this.handleIncomingStream(stream, meta);
|
|
179
|
+
});
|
|
180
|
+
// Accept
|
|
181
|
+
session.accept();
|
|
182
|
+
this.sendJson({
|
|
183
|
+
type: "portal_accept",
|
|
184
|
+
session_id: request.session_id,
|
|
185
|
+
hostname: os.hostname(),
|
|
186
|
+
});
|
|
187
|
+
// Cleanup on close
|
|
188
|
+
session.on("close", () => {
|
|
189
|
+
this.sessions.delete(request.session_id);
|
|
190
|
+
});
|
|
191
|
+
console.log(`[portal] Accepted session ${request.session_id.slice(0, 8)} from ${request.requester_hostname}`);
|
|
192
|
+
}
|
|
193
|
+
// ============================================================================
|
|
194
|
+
// STREAM ROUTING
|
|
195
|
+
// ============================================================================
|
|
196
|
+
handleIncomingStream(stream, meta) {
|
|
197
|
+
switch (meta.channel) {
|
|
198
|
+
case "shell":
|
|
199
|
+
handleShell(stream, meta).catch(err => {
|
|
200
|
+
console.error("[portal:shell]", err.message);
|
|
201
|
+
});
|
|
202
|
+
break;
|
|
203
|
+
case "file":
|
|
204
|
+
if ("direction" in meta) {
|
|
205
|
+
if (meta.direction === "push") {
|
|
206
|
+
handleFilePush(stream, meta, this.config.receiveDir).catch(err => {
|
|
207
|
+
console.error("[portal:file:push]", err.message);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
handleFilePull(stream, meta).catch(err => {
|
|
212
|
+
console.error("[portal:file:pull]", err.message);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
break;
|
|
217
|
+
case "forward":
|
|
218
|
+
handleForward(stream, meta);
|
|
219
|
+
break;
|
|
220
|
+
case "screen":
|
|
221
|
+
handleScreen(stream, meta, this.config.screenCapture || null);
|
|
222
|
+
break;
|
|
223
|
+
case "clipboard":
|
|
224
|
+
handleClipboard(stream, meta);
|
|
225
|
+
break;
|
|
226
|
+
default:
|
|
227
|
+
stream.destroy(new Error(`Unknown channel type: ${meta.channel}`));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// ============================================================================
|
|
231
|
+
// BINARY ROUTING
|
|
232
|
+
// ============================================================================
|
|
233
|
+
routeBinaryFrame(data) {
|
|
234
|
+
// Extract session UUID from bytes 1-16
|
|
235
|
+
const uuidHex = data.subarray(1, 17).toString("hex");
|
|
236
|
+
const sessionId = `${uuidHex.slice(0, 8)}-${uuidHex.slice(8, 12)}-${uuidHex.slice(12, 16)}-${uuidHex.slice(16, 20)}-${uuidHex.slice(20)}`;
|
|
237
|
+
const session = this.sessions.get(sessionId);
|
|
238
|
+
if (session) {
|
|
239
|
+
session.receiveRelay(data);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// ============================================================================
|
|
243
|
+
// HELPERS
|
|
244
|
+
// ============================================================================
|
|
245
|
+
sendJson(msg) {
|
|
246
|
+
if (this.gateway.isConnected) {
|
|
247
|
+
this.gateway.sendJson(msg);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multiplexer — manages multiple streams over a single binary channel.
|
|
3
|
+
*
|
|
4
|
+
* Sits between the WebSocket transport and individual PortalStreams.
|
|
5
|
+
* Handles frame routing, stream lifecycle (SYN/ACK/FIN/RST), and keepalive.
|
|
6
|
+
*/
|
|
7
|
+
import { EventEmitter } from "node:events";
|
|
8
|
+
import { type SynPayload } from "./protocol.js";
|
|
9
|
+
import { PortalStream } from "./stream.js";
|
|
10
|
+
export interface MultiplexerOptions {
|
|
11
|
+
/** Callback to send raw bytes to the transport (WebSocket) */
|
|
12
|
+
sendRaw: (data: Buffer) => void;
|
|
13
|
+
/** Whether this side is the initiator (odd stream IDs) or responder (even stream IDs) */
|
|
14
|
+
isInitiator: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare class Multiplexer extends EventEmitter {
|
|
17
|
+
private streams;
|
|
18
|
+
private nextStreamId;
|
|
19
|
+
private sendRaw;
|
|
20
|
+
private recvBuffer;
|
|
21
|
+
private keepaliveTimer;
|
|
22
|
+
private lastRecv;
|
|
23
|
+
private closed;
|
|
24
|
+
constructor(opts: MultiplexerOptions);
|
|
25
|
+
/**
|
|
26
|
+
* Open a new stream with the given channel metadata.
|
|
27
|
+
* Returns a Duplex stream that can be piped to/from.
|
|
28
|
+
*/
|
|
29
|
+
openStream(meta: SynPayload): PortalStream;
|
|
30
|
+
/**
|
|
31
|
+
* Feed raw bytes from the transport into the multiplexer.
|
|
32
|
+
* Call this whenever data arrives on the WebSocket.
|
|
33
|
+
*/
|
|
34
|
+
receive(data: Buffer): void;
|
|
35
|
+
/**
|
|
36
|
+
* Get a stream by ID.
|
|
37
|
+
*/
|
|
38
|
+
getStream(streamId: number): PortalStream | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Close all streams and stop keepalive.
|
|
41
|
+
*/
|
|
42
|
+
close(): void;
|
|
43
|
+
get activeStreams(): number;
|
|
44
|
+
get isClosed(): boolean;
|
|
45
|
+
private handleFrame;
|
|
46
|
+
private createStream;
|
|
47
|
+
private sendStreamData;
|
|
48
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multiplexer — manages multiple streams over a single binary channel.
|
|
3
|
+
*
|
|
4
|
+
* Sits between the WebSocket transport and individual PortalStreams.
|
|
5
|
+
* Handles frame routing, stream lifecycle (SYN/ACK/FIN/RST), and keepalive.
|
|
6
|
+
*/
|
|
7
|
+
import { EventEmitter } from "node:events";
|
|
8
|
+
import { FrameType, decodeFrame, makeDataFrame, makeSynFrame, makeAckFrame, makeFinFrame, makeRstFrame, makePingFrame, makePongFrame, } from "./protocol.js";
|
|
9
|
+
import { PortalStream } from "./stream.js";
|
|
10
|
+
const CHUNK_SIZE = 64 * 1024; // 64KB max per DATA frame
|
|
11
|
+
const KEEPALIVE_INTERVAL = 15_000;
|
|
12
|
+
const KEEPALIVE_TIMEOUT = 30_000;
|
|
13
|
+
export class Multiplexer extends EventEmitter {
|
|
14
|
+
streams = new Map();
|
|
15
|
+
nextStreamId;
|
|
16
|
+
sendRaw;
|
|
17
|
+
recvBuffer = Buffer.alloc(0);
|
|
18
|
+
keepaliveTimer = null;
|
|
19
|
+
lastRecv = Date.now();
|
|
20
|
+
closed = false;
|
|
21
|
+
constructor(opts) {
|
|
22
|
+
super();
|
|
23
|
+
this.sendRaw = opts.sendRaw;
|
|
24
|
+
// Initiator uses odd IDs (1, 3, 5...), responder uses even (2, 4, 6...)
|
|
25
|
+
this.nextStreamId = opts.isInitiator ? 1 : 2;
|
|
26
|
+
this.keepaliveTimer = setInterval(() => {
|
|
27
|
+
if (this.closed)
|
|
28
|
+
return;
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
if (now - this.lastRecv > KEEPALIVE_TIMEOUT) {
|
|
31
|
+
this.emit("timeout");
|
|
32
|
+
this.close();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
this.sendRaw(makePingFrame());
|
|
36
|
+
}, KEEPALIVE_INTERVAL);
|
|
37
|
+
this.keepaliveTimer.unref();
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Open a new stream with the given channel metadata.
|
|
41
|
+
* Returns a Duplex stream that can be piped to/from.
|
|
42
|
+
*/
|
|
43
|
+
openStream(meta) {
|
|
44
|
+
if (this.closed)
|
|
45
|
+
throw new Error("Multiplexer is closed");
|
|
46
|
+
const streamId = this.nextStreamId;
|
|
47
|
+
this.nextStreamId += 2;
|
|
48
|
+
const stream = this.createStream(streamId, meta);
|
|
49
|
+
this.streams.set(streamId, stream);
|
|
50
|
+
// Send SYN
|
|
51
|
+
this.sendRaw(makeSynFrame(streamId, meta));
|
|
52
|
+
return stream;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Feed raw bytes from the transport into the multiplexer.
|
|
56
|
+
* Call this whenever data arrives on the WebSocket.
|
|
57
|
+
*/
|
|
58
|
+
receive(data) {
|
|
59
|
+
if (this.closed)
|
|
60
|
+
return;
|
|
61
|
+
this.lastRecv = Date.now();
|
|
62
|
+
// Append to buffer
|
|
63
|
+
if (this.recvBuffer.length === 0) {
|
|
64
|
+
this.recvBuffer = data;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
const combined = Buffer.concat([this.recvBuffer, data]);
|
|
68
|
+
this.recvBuffer = combined;
|
|
69
|
+
}
|
|
70
|
+
// Decode frames
|
|
71
|
+
let offset = 0;
|
|
72
|
+
while (true) {
|
|
73
|
+
let result;
|
|
74
|
+
try {
|
|
75
|
+
result = decodeFrame(this.recvBuffer, offset);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
this.emit("error", err);
|
|
79
|
+
// Skip corrupted data
|
|
80
|
+
this.recvBuffer = Buffer.alloc(0);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!result)
|
|
84
|
+
break;
|
|
85
|
+
offset += result.bytesConsumed;
|
|
86
|
+
this.handleFrame(result.frame);
|
|
87
|
+
}
|
|
88
|
+
// Compact buffer
|
|
89
|
+
if (offset > 0) {
|
|
90
|
+
this.recvBuffer = this.recvBuffer.subarray(offset);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Get a stream by ID.
|
|
95
|
+
*/
|
|
96
|
+
getStream(streamId) {
|
|
97
|
+
return this.streams.get(streamId);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Close all streams and stop keepalive.
|
|
101
|
+
*/
|
|
102
|
+
close() {
|
|
103
|
+
if (this.closed)
|
|
104
|
+
return;
|
|
105
|
+
this.closed = true;
|
|
106
|
+
if (this.keepaliveTimer) {
|
|
107
|
+
clearInterval(this.keepaliveTimer);
|
|
108
|
+
this.keepaliveTimer = null;
|
|
109
|
+
}
|
|
110
|
+
for (const [id, stream] of this.streams) {
|
|
111
|
+
stream.pushRst("Multiplexer closed");
|
|
112
|
+
this.streams.delete(id);
|
|
113
|
+
}
|
|
114
|
+
this.emit("close");
|
|
115
|
+
}
|
|
116
|
+
get activeStreams() {
|
|
117
|
+
return this.streams.size;
|
|
118
|
+
}
|
|
119
|
+
get isClosed() {
|
|
120
|
+
return this.closed;
|
|
121
|
+
}
|
|
122
|
+
// ============================================================================
|
|
123
|
+
// INTERNAL
|
|
124
|
+
// ============================================================================
|
|
125
|
+
handleFrame(frame) {
|
|
126
|
+
switch (frame.type) {
|
|
127
|
+
case FrameType.DATA: {
|
|
128
|
+
const stream = this.streams.get(frame.streamId);
|
|
129
|
+
if (stream) {
|
|
130
|
+
stream.pushData(frame.payload);
|
|
131
|
+
}
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
case FrameType.SYN: {
|
|
135
|
+
// Remote wants to open a new stream
|
|
136
|
+
let meta;
|
|
137
|
+
try {
|
|
138
|
+
meta = JSON.parse(frame.payload.toString());
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
this.sendRaw(makeRstFrame(frame.streamId, "Invalid SYN payload"));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const stream = this.createStream(frame.streamId, meta);
|
|
145
|
+
this.streams.set(frame.streamId, stream);
|
|
146
|
+
// Send ACK
|
|
147
|
+
this.sendRaw(makeAckFrame(frame.streamId));
|
|
148
|
+
this.emit("stream", stream, meta);
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
case FrameType.ACK: {
|
|
152
|
+
const stream = this.streams.get(frame.streamId);
|
|
153
|
+
if (stream) {
|
|
154
|
+
this.emit("stream:ack", stream);
|
|
155
|
+
}
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
case FrameType.FIN: {
|
|
159
|
+
const stream = this.streams.get(frame.streamId);
|
|
160
|
+
if (stream) {
|
|
161
|
+
stream.pushFin();
|
|
162
|
+
if (stream.isFullyClosed) {
|
|
163
|
+
this.streams.delete(frame.streamId);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
case FrameType.RST: {
|
|
169
|
+
const stream = this.streams.get(frame.streamId);
|
|
170
|
+
if (stream) {
|
|
171
|
+
stream.pushRst(frame.payload.length > 0 ? frame.payload.toString() : undefined);
|
|
172
|
+
this.streams.delete(frame.streamId);
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
case FrameType.PING: {
|
|
177
|
+
this.sendRaw(makePongFrame());
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
case FrameType.PONG: {
|
|
181
|
+
// Just updates lastRecv (already done at top of receive())
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
createStream(streamId, meta) {
|
|
187
|
+
return new PortalStream({
|
|
188
|
+
streamId,
|
|
189
|
+
channelMeta: meta,
|
|
190
|
+
sendFrame: (id, data) => this.sendStreamData(id, data),
|
|
191
|
+
sendFin: (id) => this.sendRaw(makeFinFrame(id)),
|
|
192
|
+
sendRst: (id, reason) => this.sendRaw(makeRstFrame(id, reason)),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
sendStreamData(streamId, data) {
|
|
196
|
+
if (this.closed)
|
|
197
|
+
return;
|
|
198
|
+
// Chunk large writes
|
|
199
|
+
let offset = 0;
|
|
200
|
+
while (offset < data.length) {
|
|
201
|
+
const end = Math.min(offset + CHUNK_SIZE, data.length);
|
|
202
|
+
const chunk = data.subarray(offset, end);
|
|
203
|
+
this.sendRaw(makeDataFrame(streamId, chunk));
|
|
204
|
+
offset = end;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portal Permissions — role checks, approval prompts, and approval cache.
|
|
3
|
+
*
|
|
4
|
+
* Permission model:
|
|
5
|
+
* Admin → all capabilities, auto-accept
|
|
6
|
+
* Member → shell + file + clipboard, requires first-connect approval
|
|
7
|
+
* Viewer → screen (view only) + file pull, always requires approval
|
|
8
|
+
*/
|
|
9
|
+
import type { ChannelType, PortalRequest } from "./protocol.js";
|
|
10
|
+
export type Role = "admin" | "member" | "viewer";
|
|
11
|
+
export declare function setApprovalTtl(minutes: number): void;
|
|
12
|
+
export declare function cacheApproval(requesterNodeId: string, storeId: string, capabilities: ChannelType[]): void;
|
|
13
|
+
export declare function getCachedApproval(requesterNodeId: string, storeId: string): ChannelType[] | null;
|
|
14
|
+
export declare function clearApprovalCache(): void;
|
|
15
|
+
/**
|
|
16
|
+
* Check if a role can access the requested capabilities.
|
|
17
|
+
* Returns the subset of capabilities that are allowed.
|
|
18
|
+
*/
|
|
19
|
+
export declare function getAllowedCapabilities(role: Role, requested: ChannelType[]): ChannelType[];
|
|
20
|
+
/**
|
|
21
|
+
* Check if a role requires interactive approval.
|
|
22
|
+
*/
|
|
23
|
+
export declare function requiresApproval(role: Role): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Show an interactive approval prompt on the target node's terminal.
|
|
26
|
+
* Returns true if accepted, false if denied.
|
|
27
|
+
*/
|
|
28
|
+
export declare function promptApproval(request: PortalRequest): Promise<boolean>;
|
|
29
|
+
/**
|
|
30
|
+
* Decide whether to accept a portal request.
|
|
31
|
+
* For admins: auto-accept. For others: check cache, then prompt.
|
|
32
|
+
*/
|
|
33
|
+
export declare function checkPermission(request: PortalRequest, storeId: string, autoAcceptAdmins: boolean): Promise<{
|
|
34
|
+
accepted: boolean;
|
|
35
|
+
reason?: string;
|
|
36
|
+
}>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portal Permissions — role checks, approval prompts, and approval cache.
|
|
3
|
+
*
|
|
4
|
+
* Permission model:
|
|
5
|
+
* Admin → all capabilities, auto-accept
|
|
6
|
+
* Member → shell + file + clipboard, requires first-connect approval
|
|
7
|
+
* Viewer → screen (view only) + file pull, always requires approval
|
|
8
|
+
*/
|
|
9
|
+
import { createInterface } from "node:readline";
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// CAPABILITY MATRIX
|
|
12
|
+
// ============================================================================
|
|
13
|
+
const ROLE_CAPABILITIES = {
|
|
14
|
+
admin: ["shell", "file", "clipboard", "forward", "screen"],
|
|
15
|
+
member: ["shell", "file", "clipboard"],
|
|
16
|
+
viewer: ["screen", "file"], // file = pull only, enforced at channel level
|
|
17
|
+
};
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// APPROVAL CACHE
|
|
20
|
+
// ============================================================================
|
|
21
|
+
const approvalCache = new Map();
|
|
22
|
+
let approvalTtlMs = 60 * 60 * 1000; // default 1 hour
|
|
23
|
+
export function setApprovalTtl(minutes) {
|
|
24
|
+
approvalTtlMs = minutes * 60 * 1000;
|
|
25
|
+
}
|
|
26
|
+
function getCacheKey(requesterNodeId, storeId) {
|
|
27
|
+
return `${storeId}:${requesterNodeId}`;
|
|
28
|
+
}
|
|
29
|
+
export function cacheApproval(requesterNodeId, storeId, capabilities) {
|
|
30
|
+
approvalCache.set(getCacheKey(requesterNodeId, storeId), {
|
|
31
|
+
requesterNodeId,
|
|
32
|
+
capabilities,
|
|
33
|
+
expiresAt: Date.now() + approvalTtlMs,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export function getCachedApproval(requesterNodeId, storeId) {
|
|
37
|
+
const key = getCacheKey(requesterNodeId, storeId);
|
|
38
|
+
const entry = approvalCache.get(key);
|
|
39
|
+
if (!entry)
|
|
40
|
+
return null;
|
|
41
|
+
if (Date.now() > entry.expiresAt) {
|
|
42
|
+
approvalCache.delete(key);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return entry.capabilities;
|
|
46
|
+
}
|
|
47
|
+
export function clearApprovalCache() {
|
|
48
|
+
approvalCache.clear();
|
|
49
|
+
}
|
|
50
|
+
// ============================================================================
|
|
51
|
+
// PERMISSION CHECKS
|
|
52
|
+
// ============================================================================
|
|
53
|
+
/**
|
|
54
|
+
* Check if a role can access the requested capabilities.
|
|
55
|
+
* Returns the subset of capabilities that are allowed.
|
|
56
|
+
*/
|
|
57
|
+
export function getAllowedCapabilities(role, requested) {
|
|
58
|
+
const allowed = ROLE_CAPABILITIES[role];
|
|
59
|
+
return requested.filter(c => allowed.includes(c));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Check if a role requires interactive approval.
|
|
63
|
+
*/
|
|
64
|
+
export function requiresApproval(role) {
|
|
65
|
+
return role !== "admin";
|
|
66
|
+
}
|
|
67
|
+
// ============================================================================
|
|
68
|
+
// APPROVAL PROMPT
|
|
69
|
+
// ============================================================================
|
|
70
|
+
/**
|
|
71
|
+
* Show an interactive approval prompt on the target node's terminal.
|
|
72
|
+
* Returns true if accepted, false if denied.
|
|
73
|
+
*/
|
|
74
|
+
export async function promptApproval(request) {
|
|
75
|
+
const caps = request.capabilities.join(", ");
|
|
76
|
+
const from = request.requester_hostname || request.requester_node_id;
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
const rl = createInterface({
|
|
79
|
+
input: process.stdin,
|
|
80
|
+
output: process.stderr, // use stderr so it doesn't interfere with stdout piping
|
|
81
|
+
});
|
|
82
|
+
process.stderr.write(`\n[portal] Connection request from "${from}" (${request.requester_role})\n` +
|
|
83
|
+
` Capabilities: ${caps}\n` +
|
|
84
|
+
` [A]ccept / [D]eny: `);
|
|
85
|
+
const timeout = setTimeout(() => {
|
|
86
|
+
process.stderr.write("\n[portal] Approval timed out, denying.\n");
|
|
87
|
+
rl.close();
|
|
88
|
+
resolve(false);
|
|
89
|
+
}, 30_000);
|
|
90
|
+
rl.once("line", (line) => {
|
|
91
|
+
clearTimeout(timeout);
|
|
92
|
+
rl.close();
|
|
93
|
+
const answer = line.trim().toLowerCase();
|
|
94
|
+
resolve(answer === "a" || answer === "accept" || answer === "y" || answer === "yes");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Decide whether to accept a portal request.
|
|
100
|
+
* For admins: auto-accept. For others: check cache, then prompt.
|
|
101
|
+
*/
|
|
102
|
+
export async function checkPermission(request, storeId, autoAcceptAdmins) {
|
|
103
|
+
const role = (request.requester_role || "member");
|
|
104
|
+
// Admin auto-accept
|
|
105
|
+
if (role === "admin" && autoAcceptAdmins) {
|
|
106
|
+
return { accepted: true };
|
|
107
|
+
}
|
|
108
|
+
// Check capability restrictions
|
|
109
|
+
const allowed = getAllowedCapabilities(role, request.capabilities);
|
|
110
|
+
if (allowed.length === 0) {
|
|
111
|
+
return { accepted: false, reason: `Role "${role}" has no access to requested capabilities` };
|
|
112
|
+
}
|
|
113
|
+
// Check approval cache
|
|
114
|
+
const cached = getCachedApproval(request.requester_node_id, storeId);
|
|
115
|
+
if (cached) {
|
|
116
|
+
const allCached = request.capabilities.every(c => cached.includes(c));
|
|
117
|
+
if (allCached) {
|
|
118
|
+
return { accepted: true };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Interactive prompt (if terminal available)
|
|
122
|
+
if (process.stdin.isTTY) {
|
|
123
|
+
const accepted = await promptApproval(request);
|
|
124
|
+
if (accepted) {
|
|
125
|
+
cacheApproval(request.requester_node_id, storeId, allowed);
|
|
126
|
+
}
|
|
127
|
+
return { accepted, reason: accepted ? undefined : "Denied by user" };
|
|
128
|
+
}
|
|
129
|
+
// Headless mode — deny unless cached
|
|
130
|
+
return { accepted: false, reason: "No interactive terminal for approval (headless mode)" };
|
|
131
|
+
}
|