@portel/photon 1.6.1 → 1.8.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/README.md +111 -160
- package/dist/auto-ui/beam.d.ts.map +1 -1
- package/dist/auto-ui/beam.js +218 -106
- package/dist/auto-ui/beam.js.map +1 -1
- package/dist/auto-ui/design-system/tokens.d.ts +1 -1
- package/dist/auto-ui/design-system/tokens.d.ts.map +1 -1
- package/dist/auto-ui/design-system/tokens.js +2 -2
- package/dist/auto-ui/design-system/tokens.js.map +1 -1
- package/dist/auto-ui/frontend/index.html +1 -1
- package/dist/auto-ui/platform-compat.d.ts.map +1 -1
- package/dist/auto-ui/platform-compat.js +12 -2
- package/dist/auto-ui/platform-compat.js.map +1 -1
- package/dist/auto-ui/playground-html.js +5 -5
- package/dist/auto-ui/rendering/components.d.ts.map +1 -1
- package/dist/auto-ui/rendering/components.js +568 -0
- package/dist/auto-ui/rendering/components.js.map +1 -1
- package/dist/auto-ui/rendering/field-analyzer.d.ts +56 -0
- package/dist/auto-ui/rendering/field-analyzer.d.ts.map +1 -1
- package/dist/auto-ui/rendering/field-analyzer.js +177 -0
- package/dist/auto-ui/rendering/field-analyzer.js.map +1 -1
- package/dist/auto-ui/rendering/layout-selector.d.ts +14 -2
- package/dist/auto-ui/rendering/layout-selector.d.ts.map +1 -1
- package/dist/auto-ui/rendering/layout-selector.js +125 -1
- package/dist/auto-ui/rendering/layout-selector.js.map +1 -1
- package/dist/auto-ui/streamable-http-transport.d.ts +1 -1
- package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
- package/dist/auto-ui/streamable-http-transport.js +370 -26
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/auto-ui/types.d.ts +7 -1
- package/dist/auto-ui/types.d.ts.map +1 -1
- package/dist/auto-ui/types.js.map +1 -1
- package/dist/beam.bundle.js +21932 -3307
- package/dist/beam.bundle.js.map +4 -4
- package/dist/cli/commands/info.d.ts.map +1 -1
- package/dist/cli/commands/info.js +37 -0
- package/dist/cli/commands/info.js.map +1 -1
- package/dist/cli/commands/package.d.ts.map +1 -1
- package/dist/cli/commands/package.js +16 -0
- package/dist/cli/commands/package.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +640 -17
- package/dist/cli.js.map +1 -1
- package/dist/context-store.d.ts +79 -0
- package/dist/context-store.d.ts.map +1 -0
- package/dist/context-store.js +210 -0
- package/dist/context-store.js.map +1 -0
- package/dist/daemon/client.d.ts +13 -4
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +138 -77
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/manager.d.ts +0 -25
- package/dist/daemon/manager.d.ts.map +1 -1
- package/dist/daemon/manager.js +10 -38
- package/dist/daemon/manager.js.map +1 -1
- package/dist/daemon/protocol.d.ts +7 -2
- package/dist/daemon/protocol.d.ts.map +1 -1
- package/dist/daemon/protocol.js.map +1 -1
- package/dist/daemon/server.js +317 -83
- package/dist/daemon/server.js.map +1 -1
- package/dist/daemon/session-manager.d.ts +24 -4
- package/dist/daemon/session-manager.d.ts.map +1 -1
- package/dist/daemon/session-manager.js +62 -12
- package/dist/daemon/session-manager.js.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -3
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts +3 -20
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +87 -77
- package/dist/loader.js.map +1 -1
- package/dist/markdown-utils.d.ts.map +1 -1
- package/dist/markdown-utils.js +2 -1
- package/dist/markdown-utils.js.map +1 -1
- package/dist/marketplace-manager.d.ts.map +1 -1
- package/dist/marketplace-manager.js +20 -3
- package/dist/marketplace-manager.js.map +1 -1
- package/dist/photon-cli-runner.d.ts.map +1 -1
- package/dist/photon-cli-runner.js +258 -218
- package/dist/photon-cli-runner.js.map +1 -1
- package/dist/photon-doc-extractor.d.ts +2 -0
- package/dist/photon-doc-extractor.d.ts.map +1 -1
- package/dist/photon-doc-extractor.js +45 -7
- package/dist/photon-doc-extractor.js.map +1 -1
- package/dist/photons/maker.photon.d.ts.map +1 -1
- package/dist/photons/maker.photon.js +22 -4
- package/dist/photons/maker.photon.js.map +1 -1
- package/dist/photons/maker.photon.ts +47 -11
- package/dist/security-scanner.d.ts.map +1 -1
- package/dist/security-scanner.js +8 -2
- package/dist/security-scanner.js.map +1 -1
- package/dist/serv/index.d.ts +1 -1
- package/dist/serv/index.d.ts.map +1 -1
- package/dist/serv/index.js +6 -4
- package/dist/serv/index.js.map +1 -1
- package/dist/server.d.ts +32 -15
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +525 -483
- package/dist/server.js.map +1 -1
- package/dist/shared/security.d.ts +79 -0
- package/dist/shared/security.d.ts.map +1 -0
- package/dist/shared/security.js +251 -0
- package/dist/shared/security.js.map +1 -0
- package/dist/shell-completions.d.ts +21 -0
- package/dist/shell-completions.d.ts.map +1 -0
- package/dist/shell-completions.js +102 -0
- package/dist/shell-completions.js.map +1 -0
- package/dist/template-manager.d.ts.map +1 -1
- package/dist/template-manager.js +10 -3
- package/dist/template-manager.js.map +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +12 -7
package/dist/auto-ui/beam.js
CHANGED
|
@@ -14,6 +14,7 @@ import * as os from 'os';
|
|
|
14
14
|
import { spawn } from 'child_process';
|
|
15
15
|
import { fileURLToPath } from 'url';
|
|
16
16
|
import { createHash } from 'crypto';
|
|
17
|
+
import { isPathWithin, isLocalRequest, setSecurityHeaders, readBody, SimpleRateLimiter, } from '../shared/security.js';
|
|
17
18
|
/**
|
|
18
19
|
* Generate a unique ID for a photon based on its path.
|
|
19
20
|
* This ensures photons with the same name from different paths are distinguishable.
|
|
@@ -28,15 +29,17 @@ const __dirname = path.dirname(__filename);
|
|
|
28
29
|
import { listPhotonMCPs, resolvePhotonPath } from '../path-resolver.js';
|
|
29
30
|
import { PhotonLoader } from '../loader.js';
|
|
30
31
|
import { logger, createLogger } from '../shared/logger.js';
|
|
32
|
+
import { getErrorMessage } from '../shared/error-handler.js';
|
|
31
33
|
import { toEnvVarName } from '../shared/config-docs.js';
|
|
32
34
|
import { MarketplaceManager } from '../marketplace-manager.js';
|
|
33
35
|
import { PhotonDocExtractor } from '../photon-doc-extractor.js';
|
|
34
36
|
import { TemplateManager } from '../template-manager.js';
|
|
35
37
|
import { subscribeChannel, pingDaemon } from '../daemon/client.js';
|
|
38
|
+
import { ensureDaemon } from '../daemon/manager.js';
|
|
36
39
|
import { SchemaExtractor, } from '@portel/photon-core';
|
|
37
40
|
import { generateOpenAPISpec } from './openapi-generator.js';
|
|
38
41
|
import { handleStreamableHTTP, broadcastNotification, broadcastToBeam, sendToSession, requestExternalElicitation, } from './streamable-http-transport.js';
|
|
39
|
-
import { SDKMCPClientFactory } from '
|
|
42
|
+
import { SDKMCPClientFactory } from '@portel/photon-core';
|
|
40
43
|
import { getBundledPhotonPath, BEAM_BUNDLED_PHOTONS } from '../shared-utils.js';
|
|
41
44
|
// SDK imports for direct resource access (transport wrapper doesn't expose these yet)
|
|
42
45
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
@@ -45,7 +48,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
|
|
|
45
48
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
46
49
|
import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
47
50
|
// Config file path
|
|
48
|
-
const CONFIG_FILE = path.join(os.homedir(), '.photon', 'config.json');
|
|
51
|
+
const CONFIG_FILE = process.env.PHOTON_CONFIG_FILE || path.join(os.homedir(), '.photon', 'config.json');
|
|
49
52
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
50
53
|
// EXTERNAL MCP STATE (module-level for MCP transport access)
|
|
51
54
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
@@ -178,8 +181,7 @@ async function loadExternalMCPs(config) {
|
|
|
178
181
|
try {
|
|
179
182
|
const resourcesResult = await sdkClient.listResources();
|
|
180
183
|
const resources = resourcesResult.resources || [];
|
|
181
|
-
const appResources = resources.filter((r) => r.uri?.startsWith('ui://') ||
|
|
182
|
-
r.mimeType === 'application/vnd.mcp.ui+html');
|
|
184
|
+
const appResources = resources.filter((r) => r.uri?.startsWith('ui://') || r.mimeType === 'application/vnd.mcp.ui+html');
|
|
183
185
|
// Count only non-UI resources (UI resources are internal implementation detail)
|
|
184
186
|
mcpInfo.resourceCount = resources.length - appResources.length;
|
|
185
187
|
if (appResources.length > 0) {
|
|
@@ -257,8 +259,7 @@ async function loadExternalMCPs(config) {
|
|
|
257
259
|
const resourcesResult = await sdkClient.listResources();
|
|
258
260
|
const resources = resourcesResult.resources || [];
|
|
259
261
|
// Check for MCP App resources (ui:// scheme or application/vnd.mcp.ui+html mime)
|
|
260
|
-
const appResources = resources.filter((r) => r.uri?.startsWith('ui://') ||
|
|
261
|
-
r.mimeType === 'application/vnd.mcp.ui+html');
|
|
262
|
+
const appResources = resources.filter((r) => r.uri?.startsWith('ui://') || r.mimeType === 'application/vnd.mcp.ui+html');
|
|
262
263
|
// Count only non-UI resources (UI resources are internal implementation detail)
|
|
263
264
|
mcpInfo.resourceCount = resources.length - appResources.length;
|
|
264
265
|
if (appResources.length > 0) {
|
|
@@ -278,19 +279,10 @@ async function loadExternalMCPs(config) {
|
|
|
278
279
|
mcpInfo.methods = methods;
|
|
279
280
|
}
|
|
280
281
|
catch (sdkError) {
|
|
281
|
-
// SDK client failed
|
|
282
|
-
|
|
283
|
-
//
|
|
284
|
-
|
|
285
|
-
methods = (tools || []).map((tool) => ({
|
|
286
|
-
name: tool.name,
|
|
287
|
-
description: tool.description || '',
|
|
288
|
-
params: tool.inputSchema || { type: 'object', properties: {} },
|
|
289
|
-
returns: { type: 'object' },
|
|
290
|
-
icon: tool['x-icon'],
|
|
291
|
-
}));
|
|
292
|
-
mcpInfo.connected = true;
|
|
293
|
-
mcpInfo.methods = methods;
|
|
282
|
+
// SDK client failed — don't fall back to wrapper for stdio MCPs
|
|
283
|
+
// (same command would fail identically, and wrapper spawns a process
|
|
284
|
+
// without stderr suppression, leaking raw Node.js stack traces)
|
|
285
|
+
throw sdkError;
|
|
294
286
|
}
|
|
295
287
|
}
|
|
296
288
|
else {
|
|
@@ -319,7 +311,17 @@ async function loadExternalMCPs(config) {
|
|
|
319
311
|
catch (error) {
|
|
320
312
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
321
313
|
mcpInfo.errorMessage = errorMsg.slice(0, 200);
|
|
322
|
-
|
|
314
|
+
// User-friendly error messages for common failures
|
|
315
|
+
const shortMsg = errorMsg.includes('Cannot find module')
|
|
316
|
+
? `Module not found (run npm build in the MCP directory)`
|
|
317
|
+
: errorMsg.includes('ENOENT')
|
|
318
|
+
? `Command not found: ${serverConfig.command}`
|
|
319
|
+
: errorMsg.includes('Connection timeout')
|
|
320
|
+
? `Connection timed out (server may not be running)`
|
|
321
|
+
: errorMsg.includes('Connection closed')
|
|
322
|
+
? `Server exited immediately (check configuration)`
|
|
323
|
+
: errorMsg.slice(0, 120);
|
|
324
|
+
logger.warn(`⚠️ External MCP "${name}" — ${shortMsg}`);
|
|
323
325
|
}
|
|
324
326
|
results.push(mcpInfo);
|
|
325
327
|
}
|
|
@@ -358,8 +360,7 @@ async function reconnectExternalMCP(name) {
|
|
|
358
360
|
try {
|
|
359
361
|
const resourcesResult = await sdkClient.listResources();
|
|
360
362
|
const resources = resourcesResult.resources || [];
|
|
361
|
-
const appResources = resources.filter((r) => r.uri?.startsWith('ui://') ||
|
|
362
|
-
r.mimeType === 'application/vnd.mcp.ui+html');
|
|
363
|
+
const appResources = resources.filter((r) => r.uri?.startsWith('ui://') || r.mimeType === 'application/vnd.mcp.ui+html');
|
|
363
364
|
// Count only non-UI resources (UI resources are internal implementation detail)
|
|
364
365
|
mcp.resourceCount = resources.length - appResources.length;
|
|
365
366
|
if (appResources.length > 0) {
|
|
@@ -671,8 +672,8 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
671
672
|
defaultValue: p.defaultValue,
|
|
672
673
|
}));
|
|
673
674
|
// Extract @ui template path from class-level JSDoc
|
|
674
|
-
const classJsdocMatch = source.match(/\/\*\*[\s\S]*?\*\/\s*(?=export\s+default\s+class)/)
|
|
675
|
-
|
|
675
|
+
const classJsdocMatch = source.match(/\/\*\*[\s\S]*?\*\/\s*(?=export\s+default\s+class)/) ||
|
|
676
|
+
source.match(/^\/\*\*([\s\S]*?)\*\//);
|
|
676
677
|
if (classJsdocMatch) {
|
|
677
678
|
const uiMatch = classJsdocMatch[0].match(/@ui\s+([^\s*]+)/);
|
|
678
679
|
if (uiMatch) {
|
|
@@ -745,7 +746,9 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
745
746
|
linkedUi: linkedAsset?.id,
|
|
746
747
|
...(schema.isStatic ? { isStatic: true } : {}),
|
|
747
748
|
...(schema.webhook ? { webhook: schema.webhook } : {}),
|
|
748
|
-
...(schema.scheduled || schema.cron
|
|
749
|
+
...(schema.scheduled || schema.cron
|
|
750
|
+
? { scheduled: schema.scheduled || schema.cron }
|
|
751
|
+
: {}),
|
|
749
752
|
...(schema.locked ? { locked: schema.locked } : {}),
|
|
750
753
|
};
|
|
751
754
|
});
|
|
@@ -799,6 +802,7 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
799
802
|
catch {
|
|
800
803
|
// No install metadata - that's fine
|
|
801
804
|
}
|
|
805
|
+
const isStateful = schemaSource ? /@stateful\b/.test(schemaSource) : false;
|
|
802
806
|
return {
|
|
803
807
|
id: generatePhotonId(photonPath),
|
|
804
808
|
name,
|
|
@@ -818,8 +822,10 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
818
822
|
resourceCount,
|
|
819
823
|
promptCount,
|
|
820
824
|
installSource,
|
|
825
|
+
...(isStateful && { stateful: true }),
|
|
821
826
|
...(constructorParams.length > 0 && { requiredParams: constructorParams }),
|
|
822
|
-
...(mcp.injectedPhotons &&
|
|
827
|
+
...(mcp.injectedPhotons &&
|
|
828
|
+
mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
|
|
823
829
|
};
|
|
824
830
|
}
|
|
825
831
|
catch (error) {
|
|
@@ -839,57 +845,58 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
839
845
|
}
|
|
840
846
|
}
|
|
841
847
|
const channelSubscriptions = new Map();
|
|
842
|
-
|
|
848
|
+
/** Buffer retention window — events older than this are purged */
|
|
849
|
+
const EVENT_BUFFER_DURATION_MS = 5 * 60 * 1000; // 5 minutes
|
|
843
850
|
const channelEventBuffers = new Map();
|
|
844
851
|
// Store an event in the channel buffer
|
|
845
852
|
function bufferEvent(channel, method, params) {
|
|
846
853
|
let buffer = channelEventBuffers.get(channel);
|
|
847
854
|
if (!buffer) {
|
|
848
|
-
buffer = { events: []
|
|
855
|
+
buffer = { events: [] };
|
|
849
856
|
channelEventBuffers.set(channel, buffer);
|
|
850
857
|
}
|
|
851
|
-
const
|
|
858
|
+
const now = Date.now();
|
|
852
859
|
const event = {
|
|
853
|
-
id:
|
|
860
|
+
id: now,
|
|
854
861
|
method,
|
|
855
862
|
params,
|
|
856
|
-
timestamp:
|
|
863
|
+
timestamp: now,
|
|
857
864
|
};
|
|
858
865
|
buffer.events.push(event);
|
|
859
|
-
//
|
|
860
|
-
|
|
866
|
+
// Purge events older than retention window
|
|
867
|
+
const cutoff = now - EVENT_BUFFER_DURATION_MS;
|
|
868
|
+
while (buffer.events.length > 0 && buffer.events[0].timestamp < cutoff) {
|
|
861
869
|
buffer.events.shift();
|
|
862
870
|
}
|
|
863
|
-
return
|
|
871
|
+
return now;
|
|
864
872
|
}
|
|
865
|
-
// Replay missed events to a specific session, or signal
|
|
866
|
-
function replayEventsToSession(sessionId, channel,
|
|
873
|
+
// Replay missed events to a specific session, or signal full sync needed
|
|
874
|
+
function replayEventsToSession(sessionId, channel, lastTimestamp) {
|
|
867
875
|
const buffer = channelEventBuffers.get(channel);
|
|
868
876
|
// No buffer = no events ever sent on this channel
|
|
869
877
|
if (!buffer || buffer.events.length === 0) {
|
|
870
878
|
return { replayed: 0, refreshNeeded: false };
|
|
871
879
|
}
|
|
872
|
-
// No
|
|
873
|
-
if (
|
|
880
|
+
// No lastTimestamp = client is fresh, no replay needed
|
|
881
|
+
if (lastTimestamp === undefined) {
|
|
874
882
|
return { replayed: 0, refreshNeeded: false };
|
|
875
883
|
}
|
|
876
884
|
const oldestEvent = buffer.events[0];
|
|
877
|
-
//
|
|
878
|
-
if (
|
|
885
|
+
// Stale: client's timestamp is older than buffer window → full sync needed
|
|
886
|
+
if (lastTimestamp < oldestEvent.timestamp) {
|
|
879
887
|
sendToSession(sessionId, 'photon/refresh-needed', { channel });
|
|
880
|
-
logger.info(`📡
|
|
888
|
+
logger.info(`📡 Stale client on ${channel} - last seen ${new Date(lastTimestamp).toISOString()}, oldest buffered ${new Date(oldestEvent.timestamp).toISOString()}, full sync needed`);
|
|
881
889
|
return { replayed: 0, refreshNeeded: true };
|
|
882
890
|
}
|
|
883
|
-
//
|
|
884
|
-
const eventsToReplay = buffer.events.filter((e) => e.
|
|
891
|
+
// Delta sync: replay events after client's last timestamp
|
|
892
|
+
const eventsToReplay = buffer.events.filter((e) => e.timestamp > lastTimestamp);
|
|
885
893
|
if (eventsToReplay.length === 0) {
|
|
886
894
|
return { replayed: 0, refreshNeeded: false };
|
|
887
895
|
}
|
|
888
|
-
// Replay each missed event to this session
|
|
889
896
|
for (const event of eventsToReplay) {
|
|
890
|
-
sendToSession(sessionId, event.method, { ...event.params, _eventId: event.
|
|
897
|
+
sendToSession(sessionId, event.method, { ...event.params, _eventId: event.timestamp });
|
|
891
898
|
}
|
|
892
|
-
logger.info(`📡
|
|
899
|
+
logger.info(`📡 Delta sync: ${channel} - replayed ${eventsToReplay.length} events`);
|
|
893
900
|
return { replayed: eventsToReplay.length, refreshNeeded: false };
|
|
894
901
|
}
|
|
895
902
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
@@ -963,8 +970,8 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
963
970
|
// Called when a client starts viewing a board (from MCP notification)
|
|
964
971
|
// photonId: hash of photon path (unique across servers)
|
|
965
972
|
// itemId: whatever the photon uses to identify the item (e.g., board name)
|
|
966
|
-
//
|
|
967
|
-
function onClientViewingBoard(sessionId, photonId, itemId,
|
|
973
|
+
// lastTimestamp: optional - if provided, delta sync missed events or signal full sync needed
|
|
974
|
+
function onClientViewingBoard(sessionId, photonId, itemId, lastTimestamp) {
|
|
968
975
|
const prevState = sessionViewState.get(sessionId);
|
|
969
976
|
// Unsubscribe from previous item if different
|
|
970
977
|
if (prevState?.itemId && (prevState.photonId !== photonId || prevState.itemId !== itemId)) {
|
|
@@ -975,9 +982,9 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
975
982
|
const channel = `${photonId}:${itemId}`;
|
|
976
983
|
sessionViewState.set(sessionId, { photonId, itemId });
|
|
977
984
|
subscribeToChannel(channel);
|
|
978
|
-
//
|
|
979
|
-
if (
|
|
980
|
-
replayEventsToSession(sessionId, channel,
|
|
985
|
+
// Delta sync missed events if lastTimestamp is provided
|
|
986
|
+
if (lastTimestamp !== undefined) {
|
|
987
|
+
replayEventsToSession(sessionId, channel, lastTimestamp);
|
|
981
988
|
}
|
|
982
989
|
}
|
|
983
990
|
// Called when a client disconnects
|
|
@@ -1014,8 +1021,12 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
1014
1021
|
return null; // UI asset not found
|
|
1015
1022
|
}
|
|
1016
1023
|
};
|
|
1024
|
+
// Security: rate limiter for API endpoints
|
|
1025
|
+
const apiRateLimiter = new SimpleRateLimiter(30, 60_000);
|
|
1017
1026
|
// Create HTTP server
|
|
1018
1027
|
const server = http.createServer(async (req, res) => {
|
|
1028
|
+
// Security: set standard security headers on all responses
|
|
1029
|
+
setSecurityHeaders(res);
|
|
1019
1030
|
const url = new URL(req.url || '/', `http://${req.headers.host}`);
|
|
1020
1031
|
// ══════════════════════════════════════════════════════════════════════════
|
|
1021
1032
|
// MCP Streamable HTTP Transport (standard MCP clients like Claude Desktop)
|
|
@@ -1133,17 +1144,18 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
1133
1144
|
root = path.resolve(workdirEnv);
|
|
1134
1145
|
}
|
|
1135
1146
|
}
|
|
1136
|
-
|
|
1147
|
+
// Security: default browse root to workingDir if not specified
|
|
1148
|
+
if (!root) {
|
|
1149
|
+
root = workingDir;
|
|
1150
|
+
}
|
|
1151
|
+
const dirPath = url.searchParams.get('path') || root;
|
|
1137
1152
|
try {
|
|
1138
1153
|
const resolved = path.resolve(dirPath);
|
|
1139
|
-
//
|
|
1140
|
-
if (root) {
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
res.end(JSON.stringify({ error: 'Access denied: outside allowed directory' }));
|
|
1145
|
-
return;
|
|
1146
|
-
}
|
|
1154
|
+
// Security: always enforce path boundary using isPathWithin
|
|
1155
|
+
if (!isPathWithin(resolved, root)) {
|
|
1156
|
+
res.writeHead(403);
|
|
1157
|
+
res.end(JSON.stringify({ error: 'Access denied: outside allowed directory' }));
|
|
1158
|
+
return;
|
|
1147
1159
|
}
|
|
1148
1160
|
const stat = await fs.stat(resolved);
|
|
1149
1161
|
if (!stat.isDirectory()) {
|
|
@@ -1187,6 +1199,12 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
1187
1199
|
return;
|
|
1188
1200
|
}
|
|
1189
1201
|
const resolved = path.resolve(filePath);
|
|
1202
|
+
// Security: prevent path traversal — file must be within working directory
|
|
1203
|
+
if (!isPathWithin(resolved, workingDir)) {
|
|
1204
|
+
res.writeHead(403);
|
|
1205
|
+
res.end('Access denied: outside allowed directory');
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1190
1208
|
try {
|
|
1191
1209
|
const fileStat = await fs.stat(resolved);
|
|
1192
1210
|
if (!fileStat.isFile()) {
|
|
@@ -1367,9 +1385,19 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
1367
1385
|
}
|
|
1368
1386
|
// Resolve template path relative to photon's directory
|
|
1369
1387
|
const photonDir = path.dirname(photon.path);
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1388
|
+
// Security: reject absolute template paths — must be relative to photon dir
|
|
1389
|
+
if (path.isAbsolute(templateFile)) {
|
|
1390
|
+
res.writeHead(403);
|
|
1391
|
+
res.end(JSON.stringify({ error: 'Absolute template paths are not allowed' }));
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
const fullTemplatePath = path.join(photonDir, templateFile);
|
|
1395
|
+
// Security: validate resolved path is within photon directory
|
|
1396
|
+
if (!isPathWithin(fullTemplatePath, photonDir)) {
|
|
1397
|
+
res.writeHead(403);
|
|
1398
|
+
res.end(JSON.stringify({ error: 'Template path traversal detected' }));
|
|
1399
|
+
return;
|
|
1400
|
+
}
|
|
1373
1401
|
try {
|
|
1374
1402
|
const templateContent = await fs.readFile(fullTemplatePath, 'utf-8');
|
|
1375
1403
|
res.setHeader('Content-Type', 'text/html');
|
|
@@ -1608,38 +1636,49 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
1608
1636
|
}
|
|
1609
1637
|
// Invoke API: Direct HTTP endpoint for method invocation (used by PWA)
|
|
1610
1638
|
if (url.pathname === '/api/invoke' && req.method === 'POST') {
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
}
|
|
1632
|
-
const result = await mcp.instance[method](args || {});
|
|
1633
|
-
res.setHeader('Content-Type', 'application/json');
|
|
1634
|
-
res.writeHead(200);
|
|
1635
|
-
res.end(JSON.stringify({ result }));
|
|
1639
|
+
// Security: only allow local requests
|
|
1640
|
+
if (!isLocalRequest(req)) {
|
|
1641
|
+
res.writeHead(403);
|
|
1642
|
+
res.end(JSON.stringify({ error: 'Forbidden: non-local request' }));
|
|
1643
|
+
return;
|
|
1644
|
+
}
|
|
1645
|
+
// Security: rate limiting
|
|
1646
|
+
const clientKey = req.socket?.remoteAddress || 'unknown';
|
|
1647
|
+
if (!apiRateLimiter.isAllowed(clientKey)) {
|
|
1648
|
+
res.writeHead(429);
|
|
1649
|
+
res.end(JSON.stringify({ error: 'Too many requests' }));
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
try {
|
|
1653
|
+
const body = await readBody(req);
|
|
1654
|
+
const { photon: photonName, method, args } = JSON.parse(body);
|
|
1655
|
+
if (!photonName || !method) {
|
|
1656
|
+
res.writeHead(400);
|
|
1657
|
+
res.end(JSON.stringify({ error: 'Missing photon or method' }));
|
|
1658
|
+
return;
|
|
1636
1659
|
}
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
res.writeHead(
|
|
1640
|
-
res.end(JSON.stringify({ error:
|
|
1660
|
+
const mcp = photonMCPs.get(photonName);
|
|
1661
|
+
if (!mcp || !mcp.instance) {
|
|
1662
|
+
res.writeHead(404);
|
|
1663
|
+
res.end(JSON.stringify({ error: `Photon not found: ${photonName}` }));
|
|
1664
|
+
return;
|
|
1641
1665
|
}
|
|
1642
|
-
|
|
1666
|
+
if (typeof mcp.instance[method] !== 'function') {
|
|
1667
|
+
res.writeHead(404);
|
|
1668
|
+
res.end(JSON.stringify({ error: `Method not found: ${method}` }));
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
const result = await mcp.instance[method](args || {});
|
|
1672
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1673
|
+
res.writeHead(200);
|
|
1674
|
+
res.end(JSON.stringify({ result }));
|
|
1675
|
+
}
|
|
1676
|
+
catch (err) {
|
|
1677
|
+
const status = err.message?.includes('too large') ? 413 : 500;
|
|
1678
|
+
res.setHeader('Content-Type', 'application/json');
|
|
1679
|
+
res.writeHead(status);
|
|
1680
|
+
res.end(JSON.stringify({ error: err.message || String(err) }));
|
|
1681
|
+
}
|
|
1643
1682
|
return;
|
|
1644
1683
|
}
|
|
1645
1684
|
// Platform Bridge API: Generate platform compatibility script
|
|
@@ -2232,6 +2271,26 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2232
2271
|
logger.info(isNewPhoton
|
|
2233
2272
|
? `✨ New photon detected: ${photonName}`
|
|
2234
2273
|
: `🔄 File change detected, reloading ${photonName}...`);
|
|
2274
|
+
// Auto-scaffold empty photon files with a starter template
|
|
2275
|
+
if (isNewPhoton) {
|
|
2276
|
+
try {
|
|
2277
|
+
const rawContent = await fs.readFile(photonPath, 'utf-8');
|
|
2278
|
+
if (rawContent.trim().length === 0) {
|
|
2279
|
+
const className = photonName
|
|
2280
|
+
.split(/[-_]/)
|
|
2281
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
2282
|
+
.join('');
|
|
2283
|
+
const scaffold = `/**\n * ${className} Photon\n */\n\nexport default class ${className} {\n /**\n * Example tool\n * @param message Message to echo\n */\n async echo(params: { message: string }) {\n return \`Echo: \${params.message}\`;\n }\n}\n`;
|
|
2284
|
+
await fs.writeFile(photonPath, scaffold, 'utf-8');
|
|
2285
|
+
logger.info(`📝 Scaffolded empty file: ${photonName}.photon.ts`);
|
|
2286
|
+
// The write triggers another watcher event which will load the scaffolded photon
|
|
2287
|
+
return;
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
catch {
|
|
2291
|
+
// File read failed, continue with normal load attempt
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2235
2294
|
// For new photons, check if configuration is needed first
|
|
2236
2295
|
if (isNewPhoton) {
|
|
2237
2296
|
const extractor = new SchemaExtractor();
|
|
@@ -2342,6 +2401,7 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2342
2401
|
// Can't extract params
|
|
2343
2402
|
}
|
|
2344
2403
|
backfillEnvDefaults(mcp.instance, reloadConstructorParams);
|
|
2404
|
+
const isStateful = /@stateful\b/.test(reloadSource);
|
|
2345
2405
|
const reloadedPhoton = {
|
|
2346
2406
|
id: generatePhotonId(photonPath),
|
|
2347
2407
|
name: photonName,
|
|
@@ -2353,8 +2413,10 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2353
2413
|
description: reloadClassMeta.description,
|
|
2354
2414
|
icon: reloadClassMeta.icon,
|
|
2355
2415
|
internal: reloadClassMeta.internal,
|
|
2416
|
+
...(isStateful && { stateful: true }),
|
|
2356
2417
|
...(reloadConstructorParams.length > 0 && { requiredParams: reloadConstructorParams }),
|
|
2357
|
-
...(mcp.injectedPhotons &&
|
|
2418
|
+
...(mcp.injectedPhotons &&
|
|
2419
|
+
mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
|
|
2358
2420
|
};
|
|
2359
2421
|
if (isNewPhoton) {
|
|
2360
2422
|
photons.push(reloadedPhoton);
|
|
@@ -2499,12 +2561,22 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2499
2561
|
process.exit(1);
|
|
2500
2562
|
}
|
|
2501
2563
|
});
|
|
2502
|
-
|
|
2564
|
+
// Security: bind to localhost by default, configurable via BEAM_BIND_ADDRESS
|
|
2565
|
+
const bindAddress = process.env.BEAM_BIND_ADDRESS || '127.0.0.1';
|
|
2566
|
+
server.listen(currentPort, bindAddress, () => {
|
|
2503
2567
|
process.env.BEAM_PORT = String(currentPort);
|
|
2504
2568
|
const url = `http://localhost:${currentPort}`;
|
|
2505
2569
|
console.log(`\n⚡ Photon Beam → ${url} (loading photons...)\n`);
|
|
2506
2570
|
resolve();
|
|
2507
2571
|
});
|
|
2572
|
+
// Configure server and socket timeouts to prevent premature disconnections
|
|
2573
|
+
// Disable server timeout for long-lived SSE connections (0 = no timeout)
|
|
2574
|
+
server.setTimeout(0);
|
|
2575
|
+
// Enable TCP keepalive on all connections to prevent intermediary timeouts
|
|
2576
|
+
server.on('connection', (socket) => {
|
|
2577
|
+
socket.setKeepAlive(true, 60000); // Send keepalive probe every 60s
|
|
2578
|
+
socket.setTimeout(0); // Disable socket inactivity timeout
|
|
2579
|
+
});
|
|
2508
2580
|
};
|
|
2509
2581
|
tryListen();
|
|
2510
2582
|
});
|
|
@@ -2529,12 +2601,49 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2529
2601
|
const photonStatus = unconfiguredCount > 0
|
|
2530
2602
|
? `${configuredCount} ready, ${unconfiguredCount} need setup`
|
|
2531
2603
|
: `${configuredCount} photon${configuredCount !== 1 ? 's' : ''} ready`;
|
|
2532
|
-
const mcpStatus = externalMCPList.length > 0
|
|
2533
|
-
? `, ${connectedMCPs}/${externalMCPList.length} MCPs`
|
|
2534
|
-
: '';
|
|
2604
|
+
const mcpStatus = externalMCPList.length > 0 ? `, ${connectedMCPs}/${externalMCPList.length} MCPs` : '';
|
|
2535
2605
|
console.log(`⚡ Photon Beam ready (${photonStatus}${mcpStatus})`);
|
|
2536
2606
|
// Notify connected clients that photon list is now available
|
|
2537
2607
|
broadcastPhotonChange();
|
|
2608
|
+
// Auto-start daemon and subscribe to state-changed events for stateful photons
|
|
2609
|
+
// Uses reconnect: true so subscriptions survive daemon restarts
|
|
2610
|
+
const statefulPhotons = photons.filter((p) => p.stateful && p.configured);
|
|
2611
|
+
if (statefulPhotons.length > 0) {
|
|
2612
|
+
try {
|
|
2613
|
+
await ensureDaemon();
|
|
2614
|
+
for (const photon of statefulPhotons) {
|
|
2615
|
+
const photonName = photon.name;
|
|
2616
|
+
const channel = `${photonName}:state-changed`;
|
|
2617
|
+
subscribeChannel(photonName, channel, (message) => {
|
|
2618
|
+
broadcastToBeam('photon/state-changed', {
|
|
2619
|
+
photon: photonName,
|
|
2620
|
+
method: message?.method,
|
|
2621
|
+
data: message?.data,
|
|
2622
|
+
});
|
|
2623
|
+
}, {
|
|
2624
|
+
reconnect: true,
|
|
2625
|
+
onReconnect: () => logger.info(`📡 Reconnected ${channel} subscription`),
|
|
2626
|
+
onRefreshNeeded: () => {
|
|
2627
|
+
logger.info(`📡 Refresh needed for ${channel} (events lost during daemon restart)`);
|
|
2628
|
+
broadcastToBeam('photon/state-changed', {
|
|
2629
|
+
photon: photonName,
|
|
2630
|
+
method: '_refresh',
|
|
2631
|
+
data: {},
|
|
2632
|
+
});
|
|
2633
|
+
},
|
|
2634
|
+
})
|
|
2635
|
+
.then(() => {
|
|
2636
|
+
logger.info(`📡 Subscribed to ${channel} for cross-client sync`);
|
|
2637
|
+
})
|
|
2638
|
+
.catch((err) => {
|
|
2639
|
+
logger.warn(`Failed to subscribe to ${channel}: ${getErrorMessage(err)}`);
|
|
2640
|
+
});
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
catch (err) {
|
|
2644
|
+
logger.warn(`Failed to start daemon for stateful photons: ${getErrorMessage(err)}`);
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2538
2647
|
// Set up file watchers for symlinked and bundled photon assets (now that photons are loaded)
|
|
2539
2648
|
for (const photon of photons) {
|
|
2540
2649
|
if (!photon.path) {
|
|
@@ -2673,7 +2782,9 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2673
2782
|
externalMCPSDKClients.delete(name);
|
|
2674
2783
|
}
|
|
2675
2784
|
}
|
|
2676
|
-
catch {
|
|
2785
|
+
catch {
|
|
2786
|
+
/* ignore */
|
|
2787
|
+
}
|
|
2677
2788
|
externalMCPClients.delete(name);
|
|
2678
2789
|
logger.info(`🔌 Removed external MCP: ${name}`);
|
|
2679
2790
|
}
|
|
@@ -2701,7 +2812,9 @@ export async function startBeam(rawWorkingDir, port) {
|
|
|
2701
2812
|
externalMCPSDKClients.delete(name);
|
|
2702
2813
|
}
|
|
2703
2814
|
}
|
|
2704
|
-
catch {
|
|
2815
|
+
catch {
|
|
2816
|
+
/* ignore */
|
|
2817
|
+
}
|
|
2705
2818
|
externalMCPClients.delete(name);
|
|
2706
2819
|
externalMCPs.splice(idx, 1);
|
|
2707
2820
|
}
|
|
@@ -2812,7 +2925,8 @@ async function configurePhotonViaMCP(photonName, config, photons, photonMCPs, lo
|
|
|
2812
2925
|
isApp,
|
|
2813
2926
|
appEntry: mainMethod,
|
|
2814
2927
|
assets: mcp.assets,
|
|
2815
|
-
...(mcp.injectedPhotons &&
|
|
2928
|
+
...(mcp.injectedPhotons &&
|
|
2929
|
+
mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
|
|
2816
2930
|
};
|
|
2817
2931
|
photons[photonIndex] = configuredPhoton;
|
|
2818
2932
|
logger.info(`✅ ${photonName} configured via MCP`);
|
|
@@ -2908,7 +3022,8 @@ async function reloadPhotonViaMCP(photonName, photons, photonMCPs, loader, saved
|
|
|
2908
3022
|
description: reloadClassMeta.description,
|
|
2909
3023
|
icon: reloadClassMeta.icon,
|
|
2910
3024
|
internal: reloadClassMeta.internal,
|
|
2911
|
-
...(mcp.injectedPhotons &&
|
|
3025
|
+
...(mcp.injectedPhotons &&
|
|
3026
|
+
mcp.injectedPhotons.length > 0 && { injectedPhotons: mcp.injectedPhotons }),
|
|
2912
3027
|
};
|
|
2913
3028
|
photons[photonIndex] = reloadedPhoton;
|
|
2914
3029
|
logger.info(`🔄 ${photonName} reloaded via MCP`);
|
|
@@ -3000,10 +3115,7 @@ async function generatePhotonHelpMarkdown(photonName, photons) {
|
|
|
3000
3115
|
const mdPath = path.join(sourceDir, `${photonName}.md`);
|
|
3001
3116
|
// Check if .md file already exists and is newer than the photon source
|
|
3002
3117
|
try {
|
|
3003
|
-
const [mdStat, srcStat] = await Promise.all([
|
|
3004
|
-
fs.stat(mdPath),
|
|
3005
|
-
fs.stat(photon.path),
|
|
3006
|
-
]);
|
|
3118
|
+
const [mdStat, srcStat] = await Promise.all([fs.stat(mdPath), fs.stat(photon.path)]);
|
|
3007
3119
|
if (mdStat.mtimeMs >= srcStat.mtimeMs) {
|
|
3008
3120
|
const existing = await fs.readFile(mdPath, 'utf-8');
|
|
3009
3121
|
if (existing.trim()) {
|