@portel/photon 1.23.1 → 1.24.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 +66 -0
- package/dist/auto-ui/streamable-http-transport.d.ts.map +1 -1
- package/dist/auto-ui/streamable-http-transport.js +262 -18
- package/dist/auto-ui/streamable-http-transport.js.map +1 -1
- package/dist/beam.bundle.js +58287 -56177
- package/dist/beam.bundle.js.map +4 -4
- package/dist/capability-negotiator.d.ts +9 -0
- package/dist/capability-negotiator.d.ts.map +1 -1
- package/dist/capability-negotiator.js +14 -0
- package/dist/capability-negotiator.js.map +1 -1
- package/dist/cli/commands/claim.d.ts +17 -0
- package/dist/cli/commands/claim.d.ts.map +1 -0
- package/dist/cli/commands/claim.js +124 -0
- package/dist/cli/commands/claim.js.map +1 -0
- package/dist/cli/commands/run.d.ts.map +1 -1
- package/dist/cli/commands/run.js +2 -0
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/daemon/claims.d.ts +108 -0
- package/dist/daemon/claims.d.ts.map +1 -0
- package/dist/daemon/claims.js +245 -0
- package/dist/daemon/claims.js.map +1 -0
- package/dist/daemon/client.d.ts.map +1 -1
- package/dist/daemon/client.js +15 -29
- package/dist/daemon/client.js.map +1 -1
- package/dist/daemon/cron.d.ts +36 -0
- package/dist/daemon/cron.d.ts.map +1 -0
- package/dist/daemon/cron.js +216 -0
- package/dist/daemon/cron.js.map +1 -0
- package/dist/daemon/schedule-loader.d.ts +76 -0
- package/dist/daemon/schedule-loader.d.ts.map +1 -0
- package/dist/daemon/schedule-loader.js +124 -0
- package/dist/daemon/schedule-loader.js.map +1 -0
- package/dist/daemon/server.js +76 -226
- package/dist/daemon/server.js.map +1 -1
- package/dist/deploy/cloudflare.d.ts.map +1 -1
- package/dist/deploy/cloudflare.js +68 -3
- package/dist/deploy/cloudflare.js.map +1 -1
- package/dist/loader.d.ts +22 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +162 -7
- package/dist/loader.js.map +1 -1
- package/dist/photon-cli-runner.d.ts.map +1 -1
- package/dist/photon-cli-runner.js +17 -0
- package/dist/photon-cli-runner.js.map +1 -1
- package/dist/server.d.ts +10 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +50 -1
- package/dist/server.js.map +1 -1
- package/dist/shared/memory-sqlite.d.ts +37 -0
- package/dist/shared/memory-sqlite.d.ts.map +1 -0
- package/dist/shared/memory-sqlite.js +143 -0
- package/dist/shared/memory-sqlite.js.map +1 -0
- package/package.json +2 -2
- package/templates/cloudflare/worker.ts.template +44 -73
package/README.md
CHANGED
|
@@ -280,6 +280,9 @@ Things you don't build because Photon handles them:
|
|
|
280
280
|
| **Cross-photon calls** | `this.call()` invokes another photon's methods |
|
|
281
281
|
| **Real-time events** | `this.emit()` fires named events to the browser UI with zero wiring |
|
|
282
282
|
| **Live rendering** | `this.render()` pushes formatted output to CLI and Beam in real time |
|
|
283
|
+
| **Delegated LLM** | `this.sample()` asks the driving agent's model to generate text — no API key, agent pays |
|
|
284
|
+
| **Inline confirm / input** | `this.confirm()` and `this.elicit()` route through the client's native UI (Beam dialog, Claude prompt) |
|
|
285
|
+
| **Scoped remote access** | `photon claim` generates a short-lived code to scope a remote MCP session to one directory |
|
|
283
286
|
| **Standalone binaries** | `photon build` compiles any photon to a single executable via Bun |
|
|
284
287
|
| **Dependency management** | `@dependencies` auto-installs npm packages on first run |
|
|
285
288
|
|
|
@@ -327,6 +330,69 @@ The same pattern applies beyond games: approval workflows where a human reviews
|
|
|
327
330
|
|
|
328
331
|
---
|
|
329
332
|
|
|
333
|
+
## MCP Primitives on `this`
|
|
334
|
+
|
|
335
|
+
The MCP protocol's user-facing primitives are surfaced as plain methods
|
|
336
|
+
on every photon instance — no decorators, no capability flags, no SDK
|
|
337
|
+
imports. The runtime routes each call through whichever surface the
|
|
338
|
+
request arrived on (Beam, Claude Desktop, Cursor, CLI).
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
export default class Editor {
|
|
342
|
+
async summarize(params: { text: string }) {
|
|
343
|
+
// Ask the driving agent's LLM. No API key. Agent pays.
|
|
344
|
+
return await this.sample({
|
|
345
|
+
prompt: `Summarize in one sentence:\n\n${params.text}`,
|
|
346
|
+
maxTokens: 128,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async deploy() {
|
|
351
|
+
if (!(await this.confirm('Ship to production?'))) return;
|
|
352
|
+
const env = await this.elicit<string>({
|
|
353
|
+
ask: 'select',
|
|
354
|
+
message: 'Which environment?',
|
|
355
|
+
options: ['staging', 'prod'],
|
|
356
|
+
});
|
|
357
|
+
await this.run(env);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
| Primitive | What it does |
|
|
363
|
+
|---|---|
|
|
364
|
+
| `await this.sample({ prompt })` | Delegates LLM generation to the caller's model via MCP sampling |
|
|
365
|
+
| `await this.confirm(question)` | Yes/no prompt — returns `boolean` |
|
|
366
|
+
| `await this.elicit(params)` | Arbitrary input (text, select, form, file, etc.) |
|
|
367
|
+
| `this.status(msg)` / `this.progress(v)` | Live feedback during long work |
|
|
368
|
+
|
|
369
|
+
Full reference: [`docs/reference/MCP-PRIMITIVES.md`](docs/reference/MCP-PRIMITIVES.md).
|
|
370
|
+
|
|
371
|
+
---
|
|
372
|
+
|
|
373
|
+
## Remote Access: Claim Codes
|
|
374
|
+
|
|
375
|
+
By default every installed photon is visible to every connected MCP
|
|
376
|
+
client. When you want to pair a *remote* agent with a *subset* of your
|
|
377
|
+
photons — your phone driving Beam, a teammate reviewing one project,
|
|
378
|
+
a CI agent scoped to a single directory — generate a claim code:
|
|
379
|
+
|
|
380
|
+
```bash
|
|
381
|
+
$ photon claim --scope /workspace/proj --ttl 4h --label "phone"
|
|
382
|
+
✓ Claim code: R3K-9QZ
|
|
383
|
+
Scope: /workspace/proj
|
|
384
|
+
Expires in: 4h
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
The remote client presents the code as the `Mcp-Claim-Code` header on
|
|
388
|
+
its MCP session. `tools/list` then only exposes photons whose source
|
|
389
|
+
lives under that directory. Sessions without a code keep full access —
|
|
390
|
+
the feature is strictly opt-in.
|
|
391
|
+
|
|
392
|
+
Full reference: [`docs/reference/CLAIM-CODES.md`](docs/reference/CLAIM-CODES.md).
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
330
396
|
## Marketplace
|
|
331
397
|
|
|
332
398
|
32 photons ready to install: databases, APIs, developer tools, and more.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"streamable-http-transport.d.ts","sourceRoot":"","sources":["../../src/auto-ui/streamable-http-transport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,MAAM,CAAC;AAoB5D,OAAO,KAAK,EAOV,aAAa,EACb,cAAc,EACd,eAAe,EAChB,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"streamable-http-transport.d.ts","sourceRoot":"","sources":["../../src/auto-ui/streamable-http-transport.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,MAAM,CAAC;AAoB5D,OAAO,KAAK,EAOV,aAAa,EACb,cAAc,EACd,eAAe,EAChB,MAAM,YAAY,CAAC;AA+bpB,wBAAgB,kBAAkB,IAAI,IAAI,CAKzC;AAwhHD,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,aAAa,EAAE,CAAC;IACzB,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IACxC,YAAY,CAAC,EAAE,eAAe,EAAE,CAAC;IACjC,kBAAkB,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACtC,qBAAqB,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IACzC,oBAAoB,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvF,WAAW,EAAE,CACX,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,KACT,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,gBAAgB,EAAE,OAAO,CAAA;KAAE,GAAG,IAAI,CAAC,CAAC;IACpE,mEAAmE;IACnE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,CAChB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KACxB,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACnD,YAAY,CAAC,EAAE,CACb,UAAU,EAAE,MAAM,KACf,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,GAAG,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACjE,YAAY,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACrF,cAAc,CAAC,EAAE,CACf,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,GAAG,IAAI,EACzB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,KAC1B,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACnD,kBAAkB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7D,MAAM,CAAC,EAAE;QAAE,WAAW,EAAE,CAAC,GAAG,EAAE,GAAG,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,OAAO,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC,GAAG,CAAC,CAAA;KAAE,CAAC;IACjG,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,mBAAmB,CAAC,EAAE;QACpB,oBAAoB,EAAE,CACpB,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,MAAM,EACb,aAAa,CAAC,EAAE,MAAM,KACnB,IAAI,CAAC;QACV,kBAAkB,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;KACjD,CAAC;CACH;AAED;;GAEG;AACH,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,OAAO,CAAC,CAkTlB;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,QAAQ,UAAQ,GACf,IAAI,CAqCN;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAEtF;AAUD;;GAEG;AACH,wBAAgB,qBAAqB,IAAI;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAUvE;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC/B,OAAO,CAkBT;AAED;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE;IACP,IAAI,EAAE,MAAM,GAAG,KAAK,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,eAAe,CAAC,EAAE,GAAG,CAAC;IACtB,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,GACA,OAAO,CAAC;IAAE,MAAM,EAAE,QAAQ,GAAG,SAAS,GAAG,QAAQ,CAAC;IAAC,OAAO,CAAC,EAAE,GAAG,CAAA;CAAE,CAAC,CAmCrE"}
|
|
@@ -38,6 +38,7 @@ import { createTask, getTask, updateTask, listTasks, registerController, unregis
|
|
|
38
38
|
import { toWireFormat, relatedTaskMeta, TERMINAL_STATES } from '../tasks/types.js';
|
|
39
39
|
import { runTaskExecution, resolveTaskInput, waitForTerminalOrInput } from '../tasks/executor.js';
|
|
40
40
|
import { generateAgentCard } from '../a2a/card-generator.js';
|
|
41
|
+
import { isPathInScope } from '../daemon/claims.js';
|
|
41
42
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
42
43
|
// JWT HELPERS
|
|
43
44
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
@@ -73,6 +74,71 @@ function decodeJWTCaller(authHeader) {
|
|
|
73
74
|
// ════════════════════════════════════════════════════════════════════════════════
|
|
74
75
|
const sessions = new Map();
|
|
75
76
|
const pendingElicitations = new Map();
|
|
77
|
+
const pendingServerRequests = new Map();
|
|
78
|
+
let nextServerRequestId = 1;
|
|
79
|
+
/**
|
|
80
|
+
* Send a JSON-RPC request to a Beam session over its SSE stream and
|
|
81
|
+
* await the reply. Backs photon-facing providers (sampling, future
|
|
82
|
+
* roots/list) that route through the human at the browser.
|
|
83
|
+
*
|
|
84
|
+
* 30-min timeout accommodates the sampling-modal's serial queue:
|
|
85
|
+
* a queued request can sit behind an open modal for the user's full
|
|
86
|
+
* read+reply time, so 5 min would let entries expire off-screen.
|
|
87
|
+
*/
|
|
88
|
+
function requestSession(sessionId, method, params, timeoutMs = 30 * 60_000) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const session = sessions.get(sessionId);
|
|
91
|
+
if (!session || !session.sseResponse || session.sseResponse.writableEnded) {
|
|
92
|
+
reject(new Error(`requestSession: session ${sessionId} has no live SSE stream — the ` +
|
|
93
|
+
`browser must be connected for server→client requests to work.`));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const id = `srv-${nextServerRequestId++}`;
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
pendingServerRequests.delete(id);
|
|
99
|
+
reject(new Error(`requestSession: ${method} timed out after ${timeoutMs}ms`));
|
|
100
|
+
}, timeoutMs);
|
|
101
|
+
pendingServerRequests.set(id, { resolve, reject, sessionId, timer });
|
|
102
|
+
const payload = `data: ${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n\n`;
|
|
103
|
+
try {
|
|
104
|
+
session.sseResponse.write(payload);
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
pendingServerRequests.delete(id);
|
|
109
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Build a sampling provider for a specific Beam session — the
|
|
115
|
+
* person at the browser plays the role of the LLM. The provider
|
|
116
|
+
* returns a `CreateMessageResult` shape with `model: 'human@beam'`
|
|
117
|
+
* so the photon can tell (via the returned model field) that the
|
|
118
|
+
* response came from a person rather than a real model.
|
|
119
|
+
*/
|
|
120
|
+
function makeHumanSamplingProvider(sessionId) {
|
|
121
|
+
return async (params) => {
|
|
122
|
+
const result = (await requestSession(sessionId, 'sampling/createMessage', params));
|
|
123
|
+
// Defensive normalisation: some clients return just a string;
|
|
124
|
+
// wrap it in the canonical CreateMessageResult shape so photon-core
|
|
125
|
+
// can index into content[0].text without extra checks.
|
|
126
|
+
if (typeof result === 'string') {
|
|
127
|
+
return {
|
|
128
|
+
role: 'assistant',
|
|
129
|
+
content: { type: 'text', text: result },
|
|
130
|
+
model: 'human@beam',
|
|
131
|
+
stopReason: 'endTurn',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
role: result?.role ?? 'assistant',
|
|
136
|
+
content: result?.content ?? { type: 'text', text: '' },
|
|
137
|
+
model: result?.model ?? 'human@beam',
|
|
138
|
+
stopReason: result?.stopReason ?? 'endTurn',
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
}
|
|
76
142
|
const APPROVALS_DIR = join(homedir(), '.photon', 'state');
|
|
77
143
|
// Simple async mutex for file operations
|
|
78
144
|
function createMutex() {
|
|
@@ -451,6 +517,19 @@ function buildToolResult(result, methodInfo) {
|
|
|
451
517
|
}
|
|
452
518
|
return toolResult;
|
|
453
519
|
}
|
|
520
|
+
/**
|
|
521
|
+
* Is the given task visible to this session under its claim scope?
|
|
522
|
+
* Out-of-scope tasks AND tasks whose photon has been removed are
|
|
523
|
+
* both hidden, so a stale task can't leak info to a scoped caller.
|
|
524
|
+
*/
|
|
525
|
+
function isTaskInScope(task, scopeDir, photons) {
|
|
526
|
+
if (!scopeDir)
|
|
527
|
+
return true;
|
|
528
|
+
const info = photons.find((p) => p.name === task.photon);
|
|
529
|
+
if (!info)
|
|
530
|
+
return false;
|
|
531
|
+
return isPathInScope(info.path, scopeDir);
|
|
532
|
+
}
|
|
454
533
|
const handlers = {
|
|
455
534
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
456
535
|
// Lifecycle
|
|
@@ -777,8 +856,16 @@ const handlers = {
|
|
|
777
856
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
778
857
|
'tools/list': async (req, session, ctx) => {
|
|
779
858
|
const tools = [];
|
|
859
|
+
// Claim-code scoping: when the session presented a valid claim on
|
|
860
|
+
// initialize, only photons whose source file lives under that
|
|
861
|
+
// directory are visible. Unscoped sessions keep the prior behavior
|
|
862
|
+
// (every configured photon is listed).
|
|
863
|
+
const scopeDir = session.claimScopeDir;
|
|
864
|
+
const visiblePhotons = scopeDir
|
|
865
|
+
? ctx.photons.filter((p) => isPathInScope(p.path, scopeDir))
|
|
866
|
+
: ctx.photons;
|
|
780
867
|
// Add configured photon methods as tools
|
|
781
|
-
for (const photon of
|
|
868
|
+
for (const photon of visiblePhotons) {
|
|
782
869
|
if (!photon.configured || !photon.methods)
|
|
783
870
|
continue;
|
|
784
871
|
for (const method of photon.methods) {
|
|
@@ -826,7 +913,7 @@ const handlers = {
|
|
|
826
913
|
}
|
|
827
914
|
}
|
|
828
915
|
// Add runtime-injected instance tools for stateful photons
|
|
829
|
-
for (const photon of
|
|
916
|
+
for (const photon of visiblePhotons) {
|
|
830
917
|
if (!photon.configured || !photon.stateful)
|
|
831
918
|
continue;
|
|
832
919
|
tools.push({
|
|
@@ -1136,6 +1223,13 @@ const handlers = {
|
|
|
1136
1223
|
},
|
|
1137
1224
|
'tools/call': async (req, session, ctx) => {
|
|
1138
1225
|
const { name, arguments: args } = req.params;
|
|
1226
|
+
// MCP spec: if the caller supplied `_meta.progressToken`, every
|
|
1227
|
+
// notifications/progress we emit for this request MUST echo that
|
|
1228
|
+
// token so the client can correlate progress events with the
|
|
1229
|
+
// specific in-flight request. Falling back to a synthetic token
|
|
1230
|
+
// stranded progress notifications — clients filtered them out
|
|
1231
|
+
// because no listener was registered for the synthetic key.
|
|
1232
|
+
const clientProgressToken = req.params?._meta?.progressToken;
|
|
1139
1233
|
// Handle beam system tools
|
|
1140
1234
|
if (name === 'beam/configure') {
|
|
1141
1235
|
return handleBeamConfigure(req, ctx, args || {});
|
|
@@ -1189,6 +1283,30 @@ const handlers = {
|
|
|
1189
1283
|
const methodName = name.slice(slashIndex + 1);
|
|
1190
1284
|
// Per-photon auth check: if this photon requires auth but caller is anonymous, reject
|
|
1191
1285
|
const targetPhoton = ctx.photons.find((p) => p.name === serverName);
|
|
1286
|
+
// Claim-code scope enforcement: filtering tools/list alone is not a
|
|
1287
|
+
// gate — a caller that knows a tool name (cached from before the
|
|
1288
|
+
// claim was scoped, or inferred) could still invoke it via
|
|
1289
|
+
// tools/call. Every call therefore re-checks scope against the
|
|
1290
|
+
// session's `claimScopeDir`. Unscoped sessions bypass this check.
|
|
1291
|
+
if (session.claimScopeDir) {
|
|
1292
|
+
if (!targetPhoton || !isPathInScope(targetPhoton.path, session.claimScopeDir)) {
|
|
1293
|
+
return {
|
|
1294
|
+
jsonrpc: '2.0',
|
|
1295
|
+
id: req.id,
|
|
1296
|
+
result: {
|
|
1297
|
+
content: [
|
|
1298
|
+
{
|
|
1299
|
+
type: 'text',
|
|
1300
|
+
text: `Tool ${name} is not available in the current claim scope. ` +
|
|
1301
|
+
`The claim code presented on this session only grants access to photons ` +
|
|
1302
|
+
`under ${session.claimScopeDir}.`,
|
|
1303
|
+
},
|
|
1304
|
+
],
|
|
1305
|
+
isError: true,
|
|
1306
|
+
},
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1192
1310
|
if (targetPhoton?.configured && targetPhoton.auth === 'required') {
|
|
1193
1311
|
if (!ctx.caller || ctx.caller.anonymous) {
|
|
1194
1312
|
return {
|
|
@@ -1504,12 +1622,17 @@ const handlers = {
|
|
|
1504
1622
|
const task = createTask(photonName, methodName, args, ttl);
|
|
1505
1623
|
const controller = new AbortController();
|
|
1506
1624
|
registerController(task.id, controller);
|
|
1507
|
-
// Build execution function that the executor will run
|
|
1625
|
+
// Build execution function that the executor will run.
|
|
1626
|
+
// Same human-sampling provider shape as the sync path — scoped
|
|
1627
|
+
// to the session that kicked off the task so the modal appears
|
|
1628
|
+
// in the right browser tab.
|
|
1508
1629
|
const executeFn = async (inputProvider, outputHandler) => {
|
|
1509
1630
|
if (ctx.loader) {
|
|
1631
|
+
const samplingProvider = makeHumanSamplingProvider(session.id);
|
|
1510
1632
|
return ctx.loader.executeTool(mcp, methodName, args || {}, {
|
|
1511
1633
|
outputHandler,
|
|
1512
1634
|
inputProvider,
|
|
1635
|
+
samplingProvider,
|
|
1513
1636
|
caller: ctx.caller,
|
|
1514
1637
|
});
|
|
1515
1638
|
}
|
|
@@ -1550,6 +1673,12 @@ const handlers = {
|
|
|
1550
1673
|
const outputHandler = (yieldValue) => {
|
|
1551
1674
|
if (!ctx.broadcast)
|
|
1552
1675
|
return;
|
|
1676
|
+
// Echo the caller's progressToken when supplied so the client
|
|
1677
|
+
// can route notifications back to the originating panel. Fall
|
|
1678
|
+
// back to the synthetic `progress_<photon>_<method>` only when
|
|
1679
|
+
// the caller didn't send one (e.g. server-initiated task
|
|
1680
|
+
// progress with no user request to correlate against).
|
|
1681
|
+
const progressToken = clientProgressToken ?? `progress_${photonName}_${methodName}`;
|
|
1553
1682
|
// Forward progress events as MCP notifications
|
|
1554
1683
|
if (yieldValue?.emit === 'progress') {
|
|
1555
1684
|
const rawValue = typeof yieldValue.value === 'number' ? yieldValue.value : 0;
|
|
@@ -1558,7 +1687,7 @@ const handlers = {
|
|
|
1558
1687
|
jsonrpc: '2.0',
|
|
1559
1688
|
method: 'notifications/progress',
|
|
1560
1689
|
params: {
|
|
1561
|
-
progressToken
|
|
1690
|
+
progressToken,
|
|
1562
1691
|
progress,
|
|
1563
1692
|
total: 100,
|
|
1564
1693
|
message: yieldValue.message || null,
|
|
@@ -1572,7 +1701,7 @@ const handlers = {
|
|
|
1572
1701
|
jsonrpc: '2.0',
|
|
1573
1702
|
method: 'notifications/progress',
|
|
1574
1703
|
params: {
|
|
1575
|
-
progressToken
|
|
1704
|
+
progressToken,
|
|
1576
1705
|
progress: 0,
|
|
1577
1706
|
total: 100,
|
|
1578
1707
|
message: yieldValue.message || '',
|
|
@@ -1747,13 +1876,23 @@ const handlers = {
|
|
|
1747
1876
|
});
|
|
1748
1877
|
};
|
|
1749
1878
|
// Use loader.executeTool if available (sets up execution context for this.emit())
|
|
1750
|
-
// Fall back to direct method call for backward compatibility
|
|
1879
|
+
// Fall back to direct method call for backward compatibility.
|
|
1880
|
+
//
|
|
1881
|
+
// Build a samplingProvider that forwards `sampling/createMessage`
|
|
1882
|
+
// to the Beam session driving this call. The browser's request
|
|
1883
|
+
// handler (src/auto-ui/frontend/components/sampling-modal.ts)
|
|
1884
|
+
// pops a modal, the human types a response, and that text flows
|
|
1885
|
+
// back as the photon's `this.sample()` return value. Declaring
|
|
1886
|
+
// `sampling: {}` on the client without this provider would let
|
|
1887
|
+
// the photon hang — the earlier codex P1 finding.
|
|
1751
1888
|
let result;
|
|
1752
1889
|
const startTime = Date.now();
|
|
1753
1890
|
if (ctx.loader) {
|
|
1891
|
+
const samplingProvider = makeHumanSamplingProvider(session.id);
|
|
1754
1892
|
result = await ctx.loader.executeTool(mcp, methodName, args || {}, {
|
|
1755
1893
|
outputHandler,
|
|
1756
1894
|
inputProvider,
|
|
1895
|
+
samplingProvider,
|
|
1757
1896
|
caller: ctx.caller,
|
|
1758
1897
|
});
|
|
1759
1898
|
}
|
|
@@ -2086,6 +2225,24 @@ const handlers = {
|
|
|
2086
2225
|
error: { code: -32602, message: 'Missing required params: photon, method' },
|
|
2087
2226
|
};
|
|
2088
2227
|
}
|
|
2228
|
+
// Claim-code scope enforcement on the async task API. tools/call
|
|
2229
|
+
// has the same gate at the sync path; without this, a scoped
|
|
2230
|
+
// client could bypass scope by starting work via tasks/create.
|
|
2231
|
+
if (session.claimScopeDir) {
|
|
2232
|
+
const targetInfo = ctx.photons.find((p) => p.name === photonName);
|
|
2233
|
+
if (!targetInfo || !isPathInScope(targetInfo.path, session.claimScopeDir)) {
|
|
2234
|
+
return {
|
|
2235
|
+
jsonrpc: '2.0',
|
|
2236
|
+
id: req.id,
|
|
2237
|
+
error: {
|
|
2238
|
+
code: -32602,
|
|
2239
|
+
message: `Photon ${photonName} is not available in the current claim scope. ` +
|
|
2240
|
+
`The claim code presented on this session only grants access to photons ` +
|
|
2241
|
+
`under ${session.claimScopeDir}.`,
|
|
2242
|
+
},
|
|
2243
|
+
};
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2089
2246
|
const mcp = ctx.photonMCPs.get(photonName);
|
|
2090
2247
|
if (!mcp?.instance) {
|
|
2091
2248
|
return {
|
|
@@ -2121,7 +2278,7 @@ const handlers = {
|
|
|
2121
2278
|
result: { task: toWireFormat(task) },
|
|
2122
2279
|
};
|
|
2123
2280
|
},
|
|
2124
|
-
'tasks/get': async (req,
|
|
2281
|
+
'tasks/get': async (req, session, ctx) => {
|
|
2125
2282
|
const { taskId } = req.params;
|
|
2126
2283
|
if (!taskId) {
|
|
2127
2284
|
return {
|
|
@@ -2131,7 +2288,11 @@ const handlers = {
|
|
|
2131
2288
|
};
|
|
2132
2289
|
}
|
|
2133
2290
|
const task = getTask(taskId);
|
|
2134
|
-
|
|
2291
|
+
// Scope-gated: a scoped session sees "not found" whether the task
|
|
2292
|
+
// is truly absent or simply out of scope. Collapsing both cases
|
|
2293
|
+
// into the same response avoids leaking existence to callers
|
|
2294
|
+
// that shouldn't see this photon.
|
|
2295
|
+
if (!task || !isTaskInScope(task, session.claimScopeDir, ctx.photons)) {
|
|
2135
2296
|
return {
|
|
2136
2297
|
jsonrpc: '2.0',
|
|
2137
2298
|
id: req.id,
|
|
@@ -2140,9 +2301,14 @@ const handlers = {
|
|
|
2140
2301
|
}
|
|
2141
2302
|
return { jsonrpc: '2.0', id: req.id, result: toWireFormat(task) };
|
|
2142
2303
|
},
|
|
2143
|
-
'tasks/list': async (req,
|
|
2304
|
+
'tasks/list': async (req, session, ctx) => {
|
|
2144
2305
|
const { cursor } = (req.params || {});
|
|
2145
|
-
|
|
2306
|
+
let allTasks = listTasks();
|
|
2307
|
+
// Filter scoped sessions before paginating so the cursor indexes
|
|
2308
|
+
// align with what the caller actually sees.
|
|
2309
|
+
if (session.claimScopeDir) {
|
|
2310
|
+
allTasks = allTasks.filter((t) => isTaskInScope(t, session.claimScopeDir, ctx.photons));
|
|
2311
|
+
}
|
|
2146
2312
|
// Simple pagination: cursor is the offset index
|
|
2147
2313
|
const offset = cursor ? parseInt(cursor, 10) || 0 : 0;
|
|
2148
2314
|
const pageSize = 50;
|
|
@@ -2157,7 +2323,7 @@ const handlers = {
|
|
|
2157
2323
|
},
|
|
2158
2324
|
};
|
|
2159
2325
|
},
|
|
2160
|
-
'tasks/cancel': async (req,
|
|
2326
|
+
'tasks/cancel': async (req, session, ctx) => {
|
|
2161
2327
|
const { taskId } = req.params;
|
|
2162
2328
|
if (!taskId) {
|
|
2163
2329
|
return {
|
|
@@ -2167,7 +2333,7 @@ const handlers = {
|
|
|
2167
2333
|
};
|
|
2168
2334
|
}
|
|
2169
2335
|
const task = getTask(taskId);
|
|
2170
|
-
if (!task) {
|
|
2336
|
+
if (!task || !isTaskInScope(task, session.claimScopeDir, ctx.photons)) {
|
|
2171
2337
|
return {
|
|
2172
2338
|
jsonrpc: '2.0',
|
|
2173
2339
|
id: req.id,
|
|
@@ -2201,7 +2367,7 @@ const handlers = {
|
|
|
2201
2367
|
};
|
|
2202
2368
|
}
|
|
2203
2369
|
const task = getTask(taskId);
|
|
2204
|
-
if (!task) {
|
|
2370
|
+
if (!task || !isTaskInScope(task, session.claimScopeDir, ctx.photons)) {
|
|
2205
2371
|
return {
|
|
2206
2372
|
jsonrpc: '2.0',
|
|
2207
2373
|
id: req.id,
|
|
@@ -3308,7 +3474,7 @@ export async function handleStreamableHTTP(req, res, options) {
|
|
|
3308
3474
|
// CORS headers
|
|
3309
3475
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
3310
3476
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
3311
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id, Authorization');
|
|
3477
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id, Mcp-Claim-Code, Authorization');
|
|
3312
3478
|
res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
|
|
3313
3479
|
// Handle preflight
|
|
3314
3480
|
if (req.method === 'OPTIONS') {
|
|
@@ -3348,6 +3514,39 @@ export async function handleStreamableHTTP(req, res, options) {
|
|
|
3348
3514
|
sessionId = url.searchParams.get('sessionId') || undefined;
|
|
3349
3515
|
}
|
|
3350
3516
|
const session = getOrCreateSession(sessionId);
|
|
3517
|
+
// Claim-code scoping: if the client presents `Mcp-Claim-Code` (header
|
|
3518
|
+
// or query param for SSE), validate it on EVERY request and stamp
|
|
3519
|
+
// the allowed scopeDir onto the session. Re-validating per request
|
|
3520
|
+
// (rather than once at session init) means revoke + TTL expiry take
|
|
3521
|
+
// effect immediately on the next call — otherwise a scoped session
|
|
3522
|
+
// keeps its access after `photon claim revoke` or TTL, which would
|
|
3523
|
+
// defeat the point of short-lived codes.
|
|
3524
|
+
//
|
|
3525
|
+
// Absent or invalid codes leave the session unscoped (full access) —
|
|
3526
|
+
// claims are strictly additive, never a gate on unclaimed sessions.
|
|
3527
|
+
// See `src/daemon/claims.ts` for the store and the scoping contract.
|
|
3528
|
+
{
|
|
3529
|
+
const rawCode = req.headers['mcp-claim-code'] ||
|
|
3530
|
+
url.searchParams.get('claim') ||
|
|
3531
|
+
undefined;
|
|
3532
|
+
if (rawCode) {
|
|
3533
|
+
try {
|
|
3534
|
+
const { validateClaimSync } = await import('../daemon/claims.js');
|
|
3535
|
+
const result = validateClaimSync(rawCode);
|
|
3536
|
+
session.claimScopeDir = result.ok ? result.claim.scopeDir : undefined;
|
|
3537
|
+
}
|
|
3538
|
+
catch {
|
|
3539
|
+
// Claim store unreadable — fall through to unscoped access so
|
|
3540
|
+
// we don't hard-break when `.data/claims.json` is missing.
|
|
3541
|
+
session.claimScopeDir = undefined;
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
else if (session.claimScopeDir) {
|
|
3545
|
+
// Session was previously scoped but the client stopped sending
|
|
3546
|
+
// the code. Treat the missing header as revocation.
|
|
3547
|
+
session.claimScopeDir = undefined;
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3351
3550
|
// GET - Open SSE stream for server notifications
|
|
3352
3551
|
if (req.method === 'GET') {
|
|
3353
3552
|
const accept = req.headers.accept || '';
|
|
@@ -3369,13 +3568,15 @@ export async function handleStreamableHTTP(req, res, options) {
|
|
|
3369
3568
|
res.socket?.setKeepAlive(true, 60000);
|
|
3370
3569
|
// Store SSE response for server-initiated messages
|
|
3371
3570
|
session.sseResponse = res;
|
|
3372
|
-
// Keep connection alive with SSE
|
|
3571
|
+
// Keep connection alive with SSE comments (every 15s). Comments are
|
|
3572
|
+
// silently dropped by all spec-compliant parsers including the MCP
|
|
3573
|
+
// SDK's EventSourceParserStream, so they don't clutter JSON-RPC
|
|
3574
|
+
// message routing. Prevents intermediary proxies (nginx) from closing
|
|
3575
|
+
// idle connections.
|
|
3373
3576
|
const keepAlive = setInterval(() => {
|
|
3374
|
-
// Check if response is still writable before sending keepalive
|
|
3375
3577
|
if (!res.writableEnded && !res.destroyed) {
|
|
3376
3578
|
try {
|
|
3377
|
-
|
|
3378
|
-
res.write('data: {"type":"keepalive"}\n\n');
|
|
3579
|
+
res.write(': keepalive\n\n');
|
|
3379
3580
|
}
|
|
3380
3581
|
catch (err) {
|
|
3381
3582
|
// If write fails, connection is dead - clean up
|
|
@@ -3391,6 +3592,21 @@ export async function handleStreamableHTTP(req, res, options) {
|
|
|
3391
3592
|
const cleanup = () => {
|
|
3392
3593
|
clearInterval(keepAlive);
|
|
3393
3594
|
session.sseResponse = undefined;
|
|
3595
|
+
// Reject any server→client requests still waiting on this
|
|
3596
|
+
// session. Without this, a disconnect during `sampling/createMessage`
|
|
3597
|
+
// leaves the pending entry alive until the 5-minute timeout,
|
|
3598
|
+
// and because Beam's sampling-modal serializes via a single
|
|
3599
|
+
// inFlight promise, the queue wedges for every later request
|
|
3600
|
+
// in that tab. Each pending entry carries the originating
|
|
3601
|
+
// sessionId so we can target precisely this session's work.
|
|
3602
|
+
for (const [id, pending] of pendingServerRequests) {
|
|
3603
|
+
if (pending.sessionId !== session.id)
|
|
3604
|
+
continue;
|
|
3605
|
+
if (pending.timer)
|
|
3606
|
+
clearTimeout(pending.timer);
|
|
3607
|
+
pendingServerRequests.delete(id);
|
|
3608
|
+
pending.reject(new Error(`requestSession: session ${session.id} disconnected before response`));
|
|
3609
|
+
}
|
|
3394
3610
|
// Clean up subscriptions when client disconnects
|
|
3395
3611
|
if (options.subscriptionManager) {
|
|
3396
3612
|
options.subscriptionManager.onClientDisconnect(session.id);
|
|
@@ -3444,6 +3660,34 @@ export async function handleStreamableHTTP(req, res, options) {
|
|
|
3444
3660
|
// Process requests
|
|
3445
3661
|
const responses = [];
|
|
3446
3662
|
for (const request of requests) {
|
|
3663
|
+
// Response to a server→client request: no method, has id, has
|
|
3664
|
+
// either result or error. Route to the pending-request map so
|
|
3665
|
+
// the samplingProvider / future server-initiated primitives see
|
|
3666
|
+
// the browser's reply. These never produce an outgoing response.
|
|
3667
|
+
//
|
|
3668
|
+
// SECURITY: the reply's session MUST match the session that
|
|
3669
|
+
// originated the server→client request. Ids are globally
|
|
3670
|
+
// monotonic (`srv-1`, `srv-2`, ...), so without a session cross
|
|
3671
|
+
// check, session A could POST a reply carrying session B's id
|
|
3672
|
+
// and inject a fabricated sampling result into B's photon.
|
|
3673
|
+
// Drop mismatched replies silently — the original timeout on
|
|
3674
|
+
// the pending entry stays the only way to fail legitimately.
|
|
3675
|
+
if (!request.method && request.id !== undefined) {
|
|
3676
|
+
const msg = request;
|
|
3677
|
+
const pending = pendingServerRequests.get(msg.id);
|
|
3678
|
+
if (pending && pending.sessionId === session.id) {
|
|
3679
|
+
if (pending.timer)
|
|
3680
|
+
clearTimeout(pending.timer);
|
|
3681
|
+
pendingServerRequests.delete(msg.id);
|
|
3682
|
+
if (msg.error) {
|
|
3683
|
+
pending.reject(new Error(msg.error.message || 'server→client request failed'));
|
|
3684
|
+
}
|
|
3685
|
+
else {
|
|
3686
|
+
pending.resolve(msg.result);
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
continue;
|
|
3690
|
+
}
|
|
3447
3691
|
const handler = handlers[request.method];
|
|
3448
3692
|
if (!handler) {
|
|
3449
3693
|
if (request.id !== undefined) {
|