@stage-labs/metro 0.1.0-beta.284 → 0.1.0-beta.286
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude.js +4 -2
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/approvals/owner-line.ts +45 -0
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +8 -17
- package/runtime/node_modules/@metro-labs/daemon/src/channels/react-note.ts +13 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/frame-schema.ts +40 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +12 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/tool-schemas.ts +2 -32
- package/runtime/node_modules/@metro-labs/xmtp/src/codecs.ts +1 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/emit.ts +1 -1
- package/runtime/node_modules/@metro-labs/xmtp/src/frames.ts +12 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/runner.json +1 -1
package/dist/claude.js
CHANGED
|
@@ -44,6 +44,8 @@ export function gatewayEnv(base, agentKey, port) {
|
|
|
44
44
|
}
|
|
45
45
|
export const channelEnv = (env) => set(env, 'MCP_PROTOCOL_NEGOTIATION') ? env : { ...env, MCP_PROTOCOL_NEGOTIATION: 'legacy' };
|
|
46
46
|
export const toolSearchEnv = (env) => set(env, 'ENABLE_TOOL_SEARCH') ? env : { ...env, ENABLE_TOOL_SEARCH: 'true' };
|
|
47
|
+
const RM_PROMPT_OFF = 'CLAUDE_CODE_DISABLE_INLINE_SHELL_RM_PROMPT';
|
|
48
|
+
export const rmPromptEnv = (env) => set(env, RM_PROMPT_OFF) ? env : { ...env, [RM_PROMPT_OFF]: '1' };
|
|
47
49
|
const OWN_CREDENTIALS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN'];
|
|
48
50
|
export function credentialEnv(env, agentKey, signedIn) {
|
|
49
51
|
if (signedIn || OWN_CREDENTIALS.some((name) => set(env, name)))
|
|
@@ -164,7 +166,7 @@ function inferenceEnv(decision, port) {
|
|
|
164
166
|
return process.env;
|
|
165
167
|
}
|
|
166
168
|
export async function headlessEnv() {
|
|
167
|
-
return keepInSession(inferenceEnv(await verdict(), localPort()));
|
|
169
|
+
return keepInSession(rmPromptEnv(inferenceEnv(await verdict(), localPort())));
|
|
168
170
|
}
|
|
169
171
|
const continues = (args) => args.includes('-c') || args.includes('--continue');
|
|
170
172
|
async function reclaimConversation(extra) {
|
|
@@ -194,7 +196,7 @@ export async function launchClaude(extra) {
|
|
|
194
196
|
if (prompt !== null)
|
|
195
197
|
process.stderr.write('metro claude: the system prompt from the Harness page is appended to this session\n');
|
|
196
198
|
try {
|
|
197
|
-
return await runClaude(claudeArgs(nativeRecoveryArgs(extra), mcp?.path, mode, prompt), keepInSession(channelEnv(inferenceEnv(decision, port))));
|
|
199
|
+
return await runClaude(claudeArgs(nativeRecoveryArgs(extra), mcp?.path, mode, prompt), keepInSession(rmPromptEnv(channelEnv(inferenceEnv(decision, port)))));
|
|
198
200
|
}
|
|
199
201
|
finally {
|
|
200
202
|
mcp?.cleanup();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.286",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
2
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
|
|
4
|
+
|
|
5
|
+
export interface OwnerLine {
|
|
6
|
+
line: string;
|
|
7
|
+
from: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let file: string | undefined;
|
|
11
|
+
let known: OwnerLine | undefined;
|
|
12
|
+
let saved: string | undefined;
|
|
13
|
+
|
|
14
|
+
const textOf = (owner: OwnerLine): string => JSON.stringify({ line: owner.line, from: owner.from });
|
|
15
|
+
|
|
16
|
+
function parse(raw: unknown): OwnerLine | undefined {
|
|
17
|
+
if (!isRecord(raw) || typeof raw.line !== 'string' || typeof raw.from !== 'string') return undefined;
|
|
18
|
+
return raw.line.startsWith('metro://') && raw.from.startsWith('metro://') ? { line: raw.line, from: raw.from } : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function setOwnerLineFile(path: string): void {
|
|
22
|
+
file = path;
|
|
23
|
+
known = parse(readJson<unknown>(path, null, { warn: 'approvals: the owner chat file is not valid JSON, so it was ignored' }));
|
|
24
|
+
saved = known === undefined ? undefined : textOf(known);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const ownerLine = (): OwnerLine | undefined => known;
|
|
28
|
+
|
|
29
|
+
export function noteOwnerLine(line: string, from: string): void {
|
|
30
|
+
known = { line, from };
|
|
31
|
+
const next = textOf(known);
|
|
32
|
+
if (file === undefined || next === saved) return;
|
|
33
|
+
try {
|
|
34
|
+
writeSecure(file, `${next}\n`);
|
|
35
|
+
saved = next;
|
|
36
|
+
} catch (err) {
|
|
37
|
+
log.warn({ err: errMsg(err) }, 'approvals: the owner chat could not be saved, so it is kept in memory only');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function forgetOwnerLine(): void {
|
|
42
|
+
file = undefined;
|
|
43
|
+
known = undefined;
|
|
44
|
+
saved = undefined;
|
|
45
|
+
}
|
|
@@ -41,6 +41,7 @@ import { localSessionApis } from '../routes/local-mode.js';
|
|
|
41
41
|
import type { SessionApis } from '../routes/session-apis.js';
|
|
42
42
|
import { createMetroMcp } from '../mcp/index.js';
|
|
43
43
|
import { startPromptExpiry } from '../approvals/pending.js';
|
|
44
|
+
import { setOwnerLineFile } from '../approvals/owner-line.js';
|
|
44
45
|
import { watchPolicySnapshot } from '../mcp/policy-snapshot.js';
|
|
45
46
|
import { gatherAccountsForAgents } from '../mcp/accounts.js';
|
|
46
47
|
import { stationToolGroups } from '../mcp/tool-catalog.js';
|
|
@@ -61,6 +62,7 @@ import { isCallEvent, leaveCallsForShutdown, onCallEvent, sweepLeftoverCalls } f
|
|
|
61
62
|
|
|
62
63
|
installCrashGuard();
|
|
63
64
|
acquireLock(join(STATE_DIR, '.tail-lock'));
|
|
65
|
+
setOwnerLineFile(join(STATE_DIR, 'approval-line.json'));
|
|
64
66
|
|
|
65
67
|
log.info({ self: userSelf() }, 'user identity');
|
|
66
68
|
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from './media-note.js';
|
|
9
9
|
import { buildWebhookNote } from './webhook-note.js';
|
|
10
10
|
import { replyMeta } from './addressed.js';
|
|
11
|
+
import { reactContent, reactionEmoji } from './react-note.js';
|
|
11
12
|
import {
|
|
12
13
|
capSet,
|
|
13
14
|
displayNameMeta,
|
|
@@ -32,26 +33,13 @@ const accountStrippedLine = (line: string): string => {
|
|
|
32
33
|
const dedupeKey = (station: string, line: string, kind: string, messageId: string): string =>
|
|
33
34
|
`${station} ${accountStrippedLine(line)} ${kind} ${messageId}`;
|
|
34
35
|
|
|
35
|
-
const shortId = (id: string): string => (id.length > 10 ? `${id.slice(0, 6)}…` : id);
|
|
36
|
-
|
|
37
|
-
function reactionEmoji(raw: unknown): string {
|
|
38
|
-
if (typeof raw === 'string') return raw;
|
|
39
|
-
const obj = raw as { name?: string; reaction?: string } | undefined;
|
|
40
|
-
return obj?.name ?? obj?.reaction ?? '';
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function reactContent(emoji: string, target: string, removed: boolean): string {
|
|
44
|
-
const verb = removed ? 'removed from' : 'reacted to';
|
|
45
|
-
const label = removed ? emoji || 'reaction' : emoji || 'reacted';
|
|
46
|
-
return `${label} ${verb} message ${shortId(target)}`.trim();
|
|
47
|
-
}
|
|
48
|
-
|
|
49
36
|
interface InboundDeps {
|
|
50
37
|
mcp: Server;
|
|
51
38
|
log: (...a: unknown[]) => void;
|
|
52
39
|
getStations: () => Set<string>;
|
|
53
40
|
senderAllowed: (from: string, line: string, verified?: boolean) => boolean;
|
|
54
41
|
approves?: (station: string) => boolean;
|
|
42
|
+
noteDirect?: (line: string, from: string) => void;
|
|
55
43
|
callMeta?: (event: Record<string, unknown>) => () => Record<string, string>;
|
|
56
44
|
answerPermission?: (requestId: string, behavior: 'allow' | 'deny', line: string, from: string) => Promise<boolean>;
|
|
57
45
|
}
|
|
@@ -287,8 +275,11 @@ export class InboundRelay {
|
|
|
287
275
|
return this.deps.approves?.(station) !== false;
|
|
288
276
|
}
|
|
289
277
|
|
|
290
|
-
private noteLine(base: EventBase): void {
|
|
291
|
-
if (base.evType !== 'system' && this.approves(base.station))
|
|
278
|
+
private noteLine(base: EventBase, direct: boolean): void {
|
|
279
|
+
if (base.evType !== 'system' && this.approves(base.station)) {
|
|
280
|
+
this.lastLine = base.line;
|
|
281
|
+
if (direct) this.deps.noteDirect?.(base.line, base.from);
|
|
282
|
+
}
|
|
292
283
|
if (!base.line) return;
|
|
293
284
|
this.allowedLines.add(base.line);
|
|
294
285
|
capSet(this.allowedLines, ALLOWED_LINES_MAX);
|
|
@@ -372,7 +363,7 @@ export class InboundRelay {
|
|
|
372
363
|
|
|
373
364
|
const base = this.routable(ev, replay);
|
|
374
365
|
if (!base) return;
|
|
375
|
-
this.noteLine(base);
|
|
366
|
+
this.noteLine(base, ev.isPrivate === true);
|
|
376
367
|
|
|
377
368
|
const atts = (ev.payload as { attachments?: PendingAtt[] } | undefined)
|
|
378
369
|
?.attachments;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const shortId = (id: string): string => (id.length > 10 ? `${id.slice(0, 6)}…` : id);
|
|
2
|
+
|
|
3
|
+
export function reactionEmoji(raw: unknown): string {
|
|
4
|
+
if (typeof raw === 'string') return raw;
|
|
5
|
+
const obj = raw as { name?: string; reaction?: string } | undefined;
|
|
6
|
+
return obj?.name ?? obj?.reaction ?? '';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function reactContent(emoji: string, target: string, removed: boolean): string {
|
|
10
|
+
const verb = removed ? 'removed from' : 'reacted to';
|
|
11
|
+
const label = removed ? emoji || 'reaction' : emoji || 'reacted';
|
|
12
|
+
return `${label} ${verb} message ${shortId(target)}`.trim();
|
|
13
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const frameSchema = {
|
|
2
|
+
type: 'object',
|
|
3
|
+
description:
|
|
4
|
+
'Stage (XMTP) only: a frame, a small interactive view. Stage shows it in the chat at image size ' +
|
|
5
|
+
'(400 x 400 at most, its start screen, clipped with a fade) and opens it full screen on a tap; its ' +
|
|
6
|
+
'buttons work only there. `widget` is OpenAI ChatKit widget JSON (a Card, ListView ' +
|
|
7
|
+
'or Basic root, 64K characters at most); a missing `title` or `description` is taken from the ' +
|
|
8
|
+
'widget. Or give `screens` instead of `widget` for several screens in one frame. A tap there comes back ' +
|
|
9
|
+
'to you as a reply to the frame: "Frame action: <type> <payload ' +
|
|
10
|
+
"JSON>\". It is sent after the text, and the message_id returned is the frame's. The stage " +
|
|
11
|
+
'skill lists the widget nodes, with an example.',
|
|
12
|
+
properties: {
|
|
13
|
+
widget: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
description:
|
|
16
|
+
'ChatKit widget JSON, e.g. {"type":"Card","children":[{"type":"Title","value":"Deploy?"},' +
|
|
17
|
+
'{"type":"Button","label":"Ship it","onClickAction":{"type":"deploy","payload":{"env":"prod"}}}]}.',
|
|
18
|
+
additionalProperties: true,
|
|
19
|
+
},
|
|
20
|
+
screens: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
description:
|
|
23
|
+
'Instead of `widget`: up to 50 screens by id, each a widget or {"title", "widget"}, 64K ' +
|
|
24
|
+
'characters in all. An action {"type":"frame.open","payload":{"screen":"<id>"}} opens a ' +
|
|
25
|
+
'screen and {"type":"frame.back"} goes back, in Stage, with no message to you.',
|
|
26
|
+
additionalProperties: true,
|
|
27
|
+
},
|
|
28
|
+
start: { type: 'string', description: 'Required with `screens`: the id of the first screen.' },
|
|
29
|
+
title: { type: 'string', description: 'Card title in the chat (200 characters at most).' },
|
|
30
|
+
description: { type: 'string', description: 'Card description in the chat (1000 characters at most).' },
|
|
31
|
+
source: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
description:
|
|
34
|
+
'Optional: {"url": "https://…"}, a node URL that serves this widget live (2048 characters at most). ' +
|
|
35
|
+
'Add to dashboard on the frame then adds a live widget loaded from that URL instead of a copy.',
|
|
36
|
+
properties: { url: { type: 'string' } },
|
|
37
|
+
required: ['url'],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
} as const;
|
|
@@ -2,7 +2,9 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import type { InboundRelay } from '../channels/inbound.js';
|
|
4
4
|
import { cancelPrompt, holdPrompt, type Behavior } from '../approvals/pending.js';
|
|
5
|
-
import {
|
|
5
|
+
import { ownerLine, type OwnerLine } from '../approvals/owner-line.js';
|
|
6
|
+
import { approversForLine, lineReceives, mayApprove } from '../agents/map.js';
|
|
7
|
+
import { sharedCalls } from '../voice/shared.js';
|
|
6
8
|
import { metroCall } from './ctx.js';
|
|
7
9
|
import { promptBody } from './permission-prompt.js';
|
|
8
10
|
import { permissionCall } from './call-permission.js';
|
|
@@ -34,12 +36,20 @@ export interface PermissionRelayDeps {
|
|
|
34
36
|
log: (...a: unknown[]) => void;
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
const callChat = (deps: PermissionRelayDeps): string | undefined => {
|
|
40
|
+
const route = sharedCalls.snapshot();
|
|
41
|
+
return route !== null && deps.inScope(route.line) ? route.line : undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const ownerChat = (deps: PermissionRelayDeps, owner: OwnerLine | undefined): string | undefined =>
|
|
45
|
+
owner !== undefined && mayApprove(owner.line, owner.from) && deps.inScope(owner.line) && lineReceives(owner.line) ? owner.line : undefined;
|
|
46
|
+
|
|
37
47
|
function relayLine(deps: PermissionRelayDeps, requestId: string, callLine?: string): string | undefined {
|
|
38
48
|
if (!deps.live()) {
|
|
39
49
|
deps.log('permission_request: live events are off, so no chat answer can arrive; held for the page only', requestId);
|
|
40
50
|
return undefined;
|
|
41
51
|
}
|
|
42
|
-
const line = callLine ?? deps.relay.knownLine;
|
|
52
|
+
const line = callLine ?? callChat(deps) ?? ownerChat(deps, ownerLine()) ?? deps.relay.knownLine;
|
|
43
53
|
if (!line) {
|
|
44
54
|
deps.log('permission_request: no known line, held for the page only', requestId);
|
|
45
55
|
return undefined;
|
|
@@ -2,6 +2,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
2
2
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
3
3
|
import { InboundRelay } from '../channels/inbound.js';
|
|
4
4
|
import { answerPrompt, forgetPromptsOf, promptLine } from '../approvals/pending.js';
|
|
5
|
+
import { noteOwnerLine } from '../approvals/owner-line.js';
|
|
5
6
|
import { ChannelRelay, type ReplayLedger } from '../channels/relay.js';
|
|
6
7
|
import { errMsg } from '@metro-labs/core/log';
|
|
7
8
|
import { allowlistForLine, mayApprove, senderPermitted } from '../agents/map.js';
|
|
@@ -26,6 +27,10 @@ const senderAllowed = (from: string, line: string, verified?: boolean): boolean
|
|
|
26
27
|
|
|
27
28
|
const approves = (station: string): boolean => stationByName(station)?.approvals !== false;
|
|
28
29
|
|
|
30
|
+
const noteDirect = (line: string, from: string): void => {
|
|
31
|
+
if (mayApprove(line, from)) noteOwnerLine(line, from);
|
|
32
|
+
};
|
|
33
|
+
|
|
29
34
|
async function answerPermission(requestId: string, behavior: 'allow' | 'deny', line: string, from: string): Promise<boolean> {
|
|
30
35
|
if (promptLine(requestId) !== line || !mayApprove(line, from)) return false;
|
|
31
36
|
return (await answerPrompt(requestId, behavior, 'chat', line)) !== undefined;
|
|
@@ -115,6 +120,7 @@ export class McpSession {
|
|
|
115
120
|
getStations,
|
|
116
121
|
senderAllowed,
|
|
117
122
|
approves,
|
|
123
|
+
noteDirect,
|
|
118
124
|
answerPermission,
|
|
119
125
|
callMeta: (event) => voice ? () => ({}) : sharedCalls.bindChat(event, this.scope),
|
|
120
126
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolDef } from './tool-def.js';
|
|
2
2
|
import { attachmentItem, CREATE_UPLOAD_TOOL } from './attachment-schema.js';
|
|
3
|
+
import { frameSchema } from './frame-schema.js';
|
|
3
4
|
import { speechSchema } from './speech-schema.js';
|
|
4
5
|
|
|
5
6
|
const lineProp = {
|
|
@@ -69,38 +70,7 @@ export const COMMON_TOOLS: ToolDef[] = [
|
|
|
69
70
|
description: 'Optional files to attach.',
|
|
70
71
|
items: attachmentItem,
|
|
71
72
|
},
|
|
72
|
-
frame:
|
|
73
|
-
type: 'object',
|
|
74
|
-
description:
|
|
75
|
-
'Stage (XMTP) only: a frame, a small interactive view. Stage shows it in the chat at image size ' +
|
|
76
|
-
'(400 x 400 at most, its start screen, clipped with a fade) and opens it full screen on a tap; its ' +
|
|
77
|
-
'buttons work only there. `widget` is OpenAI ChatKit widget JSON (a Card, ListView ' +
|
|
78
|
-
'or Basic root, 64K characters at most); a missing `title` or `description` is taken from the ' +
|
|
79
|
-
'widget. Or give `screens` instead of `widget` for several screens in one frame. A tap there comes back ' +
|
|
80
|
-
'to you as a reply to the frame: "Frame action: <type> <payload ' +
|
|
81
|
-
"JSON>\". It is sent after the text, and the message_id returned is the frame's. The stage " +
|
|
82
|
-
'skill lists the widget nodes, with an example.',
|
|
83
|
-
properties: {
|
|
84
|
-
widget: {
|
|
85
|
-
type: 'object',
|
|
86
|
-
description:
|
|
87
|
-
'ChatKit widget JSON, e.g. {"type":"Card","children":[{"type":"Title","value":"Deploy?"},' +
|
|
88
|
-
'{"type":"Button","label":"Ship it","onClickAction":{"type":"deploy","payload":{"env":"prod"}}}]}.',
|
|
89
|
-
additionalProperties: true,
|
|
90
|
-
},
|
|
91
|
-
screens: {
|
|
92
|
-
type: 'object',
|
|
93
|
-
description:
|
|
94
|
-
'Instead of `widget`: up to 50 screens by id, each a widget or {"title", "widget"}, 64K ' +
|
|
95
|
-
'characters in all. An action {"type":"frame.open","payload":{"screen":"<id>"}} opens a ' +
|
|
96
|
-
'screen and {"type":"frame.back"} goes back, in Stage, with no message to you.',
|
|
97
|
-
additionalProperties: true,
|
|
98
|
-
},
|
|
99
|
-
start: { type: 'string', description: 'Required with `screens`: the id of the first screen.' },
|
|
100
|
-
title: { type: 'string', description: 'Card title in the chat (200 characters at most).' },
|
|
101
|
-
description: { type: 'string', description: 'Card description in the chat (1000 characters at most).' },
|
|
102
|
-
},
|
|
103
|
-
},
|
|
73
|
+
frame: frameSchema,
|
|
104
74
|
wallet: {
|
|
105
75
|
type: 'object',
|
|
106
76
|
description:
|
|
@@ -196,6 +196,7 @@ export interface FrameContent {
|
|
|
196
196
|
widget?: Record<string, unknown>;
|
|
197
197
|
screens?: Record<string, unknown>;
|
|
198
198
|
start?: string;
|
|
199
|
+
source?: { url: string };
|
|
199
200
|
}
|
|
200
201
|
export const frameFallback = (c: FrameContent): string => {
|
|
201
202
|
const head = c.title ? `Frame: ${c.title}` : 'Frame';
|
|
@@ -21,7 +21,7 @@ export function envelope(
|
|
|
21
21
|
line,
|
|
22
22
|
from: `metro://xmtp/${accountId}/user/${msg.senderInboxId}`,
|
|
23
23
|
message_id: msg.id,
|
|
24
|
-
is_private:
|
|
24
|
+
is_private: 'peerInboxId' in conv,
|
|
25
25
|
};
|
|
26
26
|
rememberUid(base.id, msg.id);
|
|
27
27
|
if (typeof c === 'string')
|
|
@@ -7,6 +7,7 @@ const MAX_SCREEN_ID = 120;
|
|
|
7
7
|
const FRAME_OPEN = 'frame.open';
|
|
8
8
|
const MAX_TITLE = 200;
|
|
9
9
|
const MAX_DESCRIPTION = 1000;
|
|
10
|
+
const MAX_SOURCE_URL = 2048;
|
|
10
11
|
const SUMMARY_SCAN = 80;
|
|
11
12
|
const TEXT_TYPES = new Set(['Text', 'Caption', 'Markdown']);
|
|
12
13
|
|
|
@@ -150,6 +151,16 @@ function frameBody(args: Node): FrameBody {
|
|
|
150
151
|
return parseScreens(args.screens, args.start);
|
|
151
152
|
}
|
|
152
153
|
|
|
154
|
+
function sourcePart(raw: unknown): Pick<FrameContent, 'source'> {
|
|
155
|
+
if (raw === undefined) return {};
|
|
156
|
+
const url = isNode(raw) && typeof raw.url === 'string' ? raw.url.trim() : '';
|
|
157
|
+
if (url.length > MAX_SOURCE_URL)
|
|
158
|
+
throw bad(`frame source url is ${url.length} characters; the limit is ${MAX_SOURCE_URL}`);
|
|
159
|
+
if (!URL.canParse(url) || new URL(url).protocol !== 'https:')
|
|
160
|
+
throw bad('frame source must be {"url": "https://…"}, the node that serves this widget');
|
|
161
|
+
return { source: { url } };
|
|
162
|
+
}
|
|
163
|
+
|
|
153
164
|
export function buildFrameContent(raw: unknown): { frame: FrameContent; title: string } {
|
|
154
165
|
const args = parseJson(raw, 'frame');
|
|
155
166
|
if (!isNode(args))
|
|
@@ -162,6 +173,7 @@ export function buildFrameContent(raw: unknown): { frame: FrameContent; title: s
|
|
|
162
173
|
...(title ? { title } : {}),
|
|
163
174
|
...(description && description !== title ? { description } : {}),
|
|
164
175
|
...body.content,
|
|
176
|
+
...sourcePart(args.source),
|
|
165
177
|
};
|
|
166
178
|
return { frame, title: title ?? 'Frame' };
|
|
167
179
|
}
|
package/runtime/runtime.json
CHANGED