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.
Files changed (187) hide show
  1. package/bin/swagmanager-mcp.js +7 -0
  2. package/dist/cli/app.js +30 -2
  3. package/dist/cli/chat/ChatApp.d.ts +4 -4
  4. package/dist/cli/chat/ChatApp.js +114 -44
  5. package/dist/cli/chat/ChatInput.d.ts +13 -6
  6. package/dist/cli/chat/ChatInput.js +433 -89
  7. package/dist/cli/chat/MemoryManager.d.ts +15 -0
  8. package/dist/cli/chat/MemoryManager.js +61 -0
  9. package/dist/cli/chat/MessageList.d.ts +8 -0
  10. package/dist/cli/chat/MessageList.js +1 -1
  11. package/dist/cli/chat/NodeManager.d.ts +30 -0
  12. package/dist/cli/chat/NodeManager.js +89 -0
  13. package/dist/cli/chat/NodeSelector.d.ts +19 -0
  14. package/dist/cli/chat/NodeSelector.js +37 -0
  15. package/dist/cli/chat/PlanApproval.d.ts +17 -0
  16. package/dist/cli/chat/PlanApproval.js +82 -0
  17. package/dist/cli/chat/SessionManager.d.ts +16 -0
  18. package/dist/cli/chat/SessionManager.js +43 -0
  19. package/dist/cli/chat/SlashMenu.d.ts +38 -0
  20. package/dist/cli/chat/SlashMenu.js +208 -0
  21. package/dist/cli/chat/StatusBar.d.ts +16 -0
  22. package/dist/cli/chat/StatusBar.js +22 -0
  23. package/dist/cli/chat/ThemeSelector.d.ts +14 -0
  24. package/dist/cli/chat/ThemeSelector.js +29 -0
  25. package/dist/cli/chat/ToolIndicator.d.ts +8 -0
  26. package/dist/cli/chat/ToolIndicator.js +33 -9
  27. package/dist/cli/chat/hooks/useAgentLoop.d.ts +2 -1
  28. package/dist/cli/chat/hooks/useAgentLoop.js +22 -17
  29. package/dist/cli/chat/hooks/useSlashCommands.d.ts +19 -0
  30. package/dist/cli/chat/hooks/useSlashCommands.js +254 -15
  31. package/dist/cli/commands/config-cmd.js +4 -25
  32. package/dist/cli/commands/db.d.ts +13 -0
  33. package/dist/cli/commands/db.js +243 -0
  34. package/dist/cli/commands/doctor.js +6 -9
  35. package/dist/cli/commands/mcp.js +1 -20
  36. package/dist/cli/services/agent-events.d.ts +22 -1
  37. package/dist/cli/services/agent-events.js +9 -0
  38. package/dist/cli/services/agent-loop.js +66 -2
  39. package/dist/cli/services/agent-worker-base.js +21 -6
  40. package/dist/cli/services/api-retry.d.ts +25 -0
  41. package/dist/cli/services/api-retry.js +91 -0
  42. package/dist/cli/services/auth-service.d.ts +1 -1
  43. package/dist/cli/services/auth-service.js +40 -19
  44. package/dist/cli/services/background-processes.js +26 -2
  45. package/dist/cli/services/config-store.d.ts +13 -1
  46. package/dist/cli/services/config-store.js +116 -13
  47. package/dist/cli/services/format-server-response.js +12 -6
  48. package/dist/cli/services/ink-resize-fix.d.ts +18 -0
  49. package/dist/cli/services/ink-resize-fix.js +66 -0
  50. package/dist/cli/services/interactive-tools.d.ts +14 -0
  51. package/dist/cli/services/interactive-tools.js +47 -2
  52. package/dist/cli/services/keybinding-manager.js +1 -1
  53. package/dist/cli/services/local-tools.js +35 -2
  54. package/dist/cli/services/server-tools.js +175 -3
  55. package/dist/cli/services/subagent.js +15 -3
  56. package/dist/cli/services/system-prompt.js +5 -3
  57. package/dist/cli/services/task-decomposer.d.ts +35 -0
  58. package/dist/cli/services/task-decomposer.js +199 -0
  59. package/dist/cli/services/team-lead.d.ts +18 -0
  60. package/dist/cli/services/team-lead.js +80 -0
  61. package/dist/cli/services/teammate.js +5 -5
  62. package/dist/cli/services/telemetry.d.ts +8 -2
  63. package/dist/cli/services/telemetry.js +116 -92
  64. package/dist/cli/services/tools/agent-tools.d.ts +1 -0
  65. package/dist/cli/services/tools/agent-tools.js +50 -4
  66. package/dist/cli/services/tools/file-ops.d.ts +2 -0
  67. package/dist/cli/services/tools/file-ops.js +71 -19
  68. package/dist/cli/services/tools/shell-exec.js +22 -12
  69. package/dist/cli/shared/Theme.d.ts +1 -2
  70. package/dist/cli/shared/Theme.js +1 -1
  71. package/dist/cli/shared/WhaleBanner.d.ts +4 -1
  72. package/dist/cli/shared/WhaleBanner.js +12 -8
  73. package/dist/cli/shared/markdown.d.ts +5 -4
  74. package/dist/cli/shared/markdown.js +376 -334
  75. package/dist/cli/shared/theme-manager.d.ts +27 -0
  76. package/dist/cli/shared/theme-manager.js +178 -0
  77. package/dist/cli/shared/theme-presets.d.ts +16 -0
  78. package/dist/cli/shared/theme-presets.js +265 -0
  79. package/dist/index.js +0 -51
  80. package/dist/node/adapters/imessage.d.ts +10 -0
  81. package/dist/node/adapters/imessage.js +45 -6
  82. package/dist/node/cli.js +459 -8
  83. package/dist/node/config.d.ts +17 -0
  84. package/dist/node/gateway-client.d.ts +55 -0
  85. package/dist/node/gateway-client.js +201 -0
  86. package/dist/node/portal/clipboard.d.ts +28 -0
  87. package/dist/node/portal/clipboard.js +183 -0
  88. package/dist/node/portal/discovery.d.ts +29 -0
  89. package/dist/node/portal/discovery.js +61 -0
  90. package/dist/node/portal/forward.d.ts +30 -0
  91. package/dist/node/portal/forward.js +90 -0
  92. package/dist/node/portal/index.d.ts +47 -0
  93. package/dist/node/portal/index.js +250 -0
  94. package/dist/node/portal/multiplexer.d.ts +48 -0
  95. package/dist/node/portal/multiplexer.js +207 -0
  96. package/dist/node/portal/permissions.d.ts +36 -0
  97. package/dist/node/portal/permissions.js +131 -0
  98. package/dist/node/portal/protocol.d.ts +140 -0
  99. package/dist/node/portal/protocol.js +193 -0
  100. package/dist/node/portal/screen.d.ts +18 -0
  101. package/dist/node/portal/screen.js +93 -0
  102. package/dist/node/portal/session.d.ts +68 -0
  103. package/dist/node/portal/session.js +127 -0
  104. package/dist/node/portal/shell.d.ts +26 -0
  105. package/dist/node/portal/shell.js +142 -0
  106. package/dist/node/portal/stream.d.ts +43 -0
  107. package/dist/node/portal/stream.js +90 -0
  108. package/dist/node/portal/transfer.d.ts +33 -0
  109. package/dist/node/portal/transfer.js +231 -0
  110. package/dist/node/portal/ui.d.ts +16 -0
  111. package/dist/node/portal/ui.js +148 -0
  112. package/dist/node/remote-desktop/compile-helper.d.ts +13 -0
  113. package/dist/node/remote-desktop/compile-helper.js +73 -0
  114. package/dist/node/remote-desktop/index.d.ts +67 -0
  115. package/dist/node/remote-desktop/index.js +220 -0
  116. package/dist/node/remote-desktop/protocol.d.ts +96 -0
  117. package/dist/node/remote-desktop/protocol.js +67 -0
  118. package/dist/node/runtime.d.ts +8 -1
  119. package/dist/node/runtime.js +117 -9
  120. package/dist/server/handlers/__test-utils__/test-db.d.ts +25 -0
  121. package/dist/server/handlers/__test-utils__/test-db.js +128 -0
  122. package/dist/server/handlers/api-keys.js +26 -2
  123. package/dist/server/handlers/browser.d.ts +0 -4
  124. package/dist/server/handlers/browser.js +0 -46
  125. package/dist/server/handlers/catalog.js +37 -14
  126. package/dist/server/handlers/clickhouse.d.ts +10 -0
  127. package/dist/server/handlers/clickhouse.js +215 -0
  128. package/dist/server/handlers/comms.d.ts +308 -4
  129. package/dist/server/handlers/comms.js +444 -11
  130. package/dist/server/handlers/creations.js +1 -1
  131. package/dist/server/handlers/crm.d.ts +54 -8
  132. package/dist/server/handlers/crm.js +353 -68
  133. package/dist/server/handlers/embeddings.js +3 -3
  134. package/dist/server/handlers/enrichment.js +39 -55
  135. package/dist/server/handlers/inventory.js +1 -1
  136. package/dist/server/handlers/kali.d.ts +9 -1
  137. package/dist/server/handlers/kali.js +50 -1
  138. package/dist/server/handlers/media.d.ts +8 -0
  139. package/dist/server/handlers/media.js +902 -0
  140. package/dist/server/handlers/meta-ads.js +6 -3
  141. package/dist/server/handlers/nodes.d.ts +2 -0
  142. package/dist/server/handlers/nodes.js +331 -40
  143. package/dist/server/handlers/operations.d.ts +4 -6
  144. package/dist/server/handlers/operations.js +99 -38
  145. package/dist/server/handlers/platform.js +224 -107
  146. package/dist/server/handlers/remove-bg.d.ts +6 -0
  147. package/dist/server/handlers/remove-bg.js +96 -0
  148. package/dist/server/handlers/storefront.d.ts +6 -0
  149. package/dist/server/handlers/storefront.js +477 -0
  150. package/dist/server/handlers/supply-chain.js +21 -3
  151. package/dist/server/handlers/workflow-steps.js +87 -31
  152. package/dist/server/handlers/workflows.js +4 -1
  153. package/dist/server/index.js +334 -88
  154. package/dist/server/lib/clickhouse-buffer.d.ts +48 -0
  155. package/dist/server/lib/clickhouse-buffer.js +175 -0
  156. package/dist/server/lib/clickhouse-client.d.ts +112 -0
  157. package/dist/server/lib/clickhouse-client.js +141 -0
  158. package/dist/server/lib/coa-renderer.d.ts +91 -0
  159. package/dist/server/lib/coa-renderer.js +411 -0
  160. package/dist/server/lib/compaction-service.js +45 -1
  161. package/dist/server/lib/pdf-renderer.d.ts +143 -0
  162. package/dist/server/lib/pdf-renderer.js +867 -0
  163. package/dist/server/lib/react-pdf-layout.d.ts +40 -0
  164. package/dist/server/lib/react-pdf-layout.js +437 -0
  165. package/dist/server/lib/server-agent-loop.d.ts +2 -0
  166. package/dist/server/lib/server-agent-loop.js +61 -15
  167. package/dist/server/lib/server-subagent.d.ts +3 -0
  168. package/dist/server/lib/server-subagent.js +7 -4
  169. package/dist/server/lib/supabase-client.js +51 -3
  170. package/dist/server/lib/template-resolver.js +14 -4
  171. package/dist/server/lib/utils.js +15 -0
  172. package/dist/server/local-agent-gateway.d.ts +44 -0
  173. package/dist/server/local-agent-gateway.js +389 -49
  174. package/dist/server/providers/anthropic.js +12 -2
  175. package/dist/server/providers/gemini.js +17 -2
  176. package/dist/server/proxy-handlers.js +151 -0
  177. package/dist/server/tool-router.d.ts +2 -2
  178. package/dist/server/tool-router.js +25 -35
  179. package/dist/shared/agent-core.d.ts +5 -2
  180. package/dist/shared/agent-core.js +30 -4
  181. package/dist/shared/api-client.js +54 -3
  182. package/dist/shared/sse-parser.d.ts +1 -1
  183. package/dist/shared/sse-parser.js +5 -2
  184. package/dist/shared/tool-dispatch.js +1 -1
  185. package/package.json +16 -10
  186. package/dist/server/handlers/__test-utils__/mock-supabase.d.ts +0 -11
  187. 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
+ }