@stage-labs/metro 0.1.0-beta.193 → 0.1.0-beta.195
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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/account-lookups.ts +55 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/account-routes.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +28 -67
- package/runtime/node_modules/@metro-labs/daemon/src/agents/allowlist.ts +13 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/files.ts +7 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/map.ts +17 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/sender-cards.ts +74 -0
- package/runtime/node_modules/@metro-labs/daemon/src/channels/inbound.ts +2 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +4 -2
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/permission-relay.ts +5 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/session.ts +3 -3
- package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +8 -0
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.195",
|
|
4
4
|
"description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.195",
|
|
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,55 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { StationName } from '@metro-labs/core/station-names';
|
|
3
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
|
+
import { bodyField, readJsonBody, sendJson } from '@metro-labs/http/api-http';
|
|
5
|
+
import { stationByName } from '../stations/registry.js';
|
|
6
|
+
import type { AccountRoute } from './account-routes.js';
|
|
7
|
+
import { handleSenderCards, type SenderCardDeps } from './sender-cards.js';
|
|
8
|
+
|
|
9
|
+
interface Target {
|
|
10
|
+
station: StationName;
|
|
11
|
+
accountId: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LookupDeps extends SenderCardDeps {
|
|
15
|
+
resolveSender: (station: StationName, accountId: string, query: string) => Promise<unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Lookup = (req: IncomingMessage, res: ServerResponse, deps: LookupDeps, target: Target) => Promise<void>;
|
|
19
|
+
|
|
20
|
+
function lookupQuery(req: IncomingMessage): string {
|
|
21
|
+
const params = new URLSearchParams((req.url ?? '').split('?')[1] ?? '');
|
|
22
|
+
return (params.get('q') ?? '').trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function handleResolve(req: IncomingMessage, res: ServerResponse, deps: LookupDeps, target: Target): Promise<void> {
|
|
26
|
+
if (stationByName(target.station)?.resolvesSenders !== true)
|
|
27
|
+
throw new ApiError(`metro cannot look a sender up on ${target.station}`, 400);
|
|
28
|
+
const query = lookupQuery(req);
|
|
29
|
+
if (query === '') throw new ApiError('a number to look up is required', 400);
|
|
30
|
+
sendJson(req, res, 200, await deps.resolveSender(target.station, target.accountId, query));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function handleName(req: IncomingMessage, res: ServerResponse, deps: LookupDeps, target: Target): Promise<void> {
|
|
34
|
+
if (stationByName(target.station)?.claimsName !== true)
|
|
35
|
+
throw new ApiError(`${target.station} accounts have no name to claim`, 400);
|
|
36
|
+
if (req.method === 'GET') {
|
|
37
|
+
sendJson(req, res, 200, await deps.accountCall(target.station, 'name', { account: target.accountId }));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const label = bodyField(await readJsonBody(req), 'label');
|
|
41
|
+
if (typeof label !== 'string' || label.trim() === '') throw new ApiError('a label is required', 400);
|
|
42
|
+
sendJson(req, res, 200, await deps.accountCall(target.station, 'claim_name', { account: target.accountId, label }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function handleRecent(req: IncomingMessage, res: ServerResponse, deps: LookupDeps, target: Target): Promise<void> {
|
|
46
|
+
sendJson(req, res, 200, { senders: deps.recentSenders(target.station, target.accountId) });
|
|
47
|
+
return Promise.resolve();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const LOOKUPS: Partial<Record<AccountRoute['kind'], Lookup>> = {
|
|
51
|
+
profiles: handleSenderCards,
|
|
52
|
+
resolve: handleResolve,
|
|
53
|
+
name: handleName,
|
|
54
|
+
senders: handleRecent,
|
|
55
|
+
};
|
|
@@ -12,6 +12,7 @@ export type AccountRoute =
|
|
|
12
12
|
| { kind: 'resolve'; station: StationName; accountId: string }
|
|
13
13
|
| { kind: 'enabled'; station: StationName; accountId: string }
|
|
14
14
|
| { kind: 'policy'; station: StationName; accountId: string }
|
|
15
|
+
| { kind: 'profiles'; station: StationName; accountId: string }
|
|
15
16
|
| { kind: 'name'; station: StationName; accountId: string };
|
|
16
17
|
|
|
17
18
|
const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
|
|
@@ -24,10 +25,11 @@ const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
|
|
|
24
25
|
resolve: ['GET'],
|
|
25
26
|
enabled: ['PUT'],
|
|
26
27
|
policy: ['PUT'],
|
|
28
|
+
profiles: ['GET'],
|
|
27
29
|
name: ['GET', 'POST'],
|
|
28
30
|
};
|
|
29
31
|
|
|
30
|
-
const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled', 'name', 'policy'] as const;
|
|
32
|
+
const SUB_ROUTES = ['allowlist', 'senders', 'resolve', 'enabled', 'name', 'policy', 'profiles'] as const;
|
|
31
33
|
|
|
32
34
|
type SubRoute = (typeof SUB_ROUTES)[number];
|
|
33
35
|
|
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
-
import { normalizeAllowlist } from './allowlist.js';
|
|
3
|
+
import { normalizeAllowlist, normalizeApprovers } from './allowlist.js';
|
|
4
|
+
import { approversForAccount } from './map.js';
|
|
5
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
6
|
import type { RecentSender } from './senders.js';
|
|
5
7
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
6
|
-
import {
|
|
7
|
-
apiFailure,
|
|
8
|
-
bodyField,
|
|
9
|
-
readJsonBody,
|
|
10
|
-
sendJson,
|
|
11
|
-
} from '@metro-labs/http/api-http';
|
|
8
|
+
import { apiFailure, bodyField, readJsonBody, sendJson } from '@metro-labs/http/api-http';
|
|
12
9
|
import { type AccountRef } from './account-attach.js';
|
|
13
10
|
import { type AccountRoute } from './account-routes.js';
|
|
14
11
|
import { handlePolicy, type SetPolicy } from './policy-route.js';
|
|
12
|
+
import { LOOKUPS } from './account-lookups.js';
|
|
15
13
|
import { stationByName } from '../stations/registry.js';
|
|
16
14
|
import type { StationName } from '@metro-labs/core/station-names';
|
|
17
15
|
import {
|
|
@@ -68,6 +66,7 @@ export interface AccountApiDeps {
|
|
|
68
66
|
station: StationName,
|
|
69
67
|
accountId: string,
|
|
70
68
|
allowlist: string[],
|
|
69
|
+
approvers?: string[],
|
|
71
70
|
) => Promise<string[]>;
|
|
72
71
|
setPolicy: SetPolicy;
|
|
73
72
|
recentSenders: (station: StationName, accountId: string) => RecentSender[];
|
|
@@ -89,46 +88,7 @@ export interface AccountApiDeps {
|
|
|
89
88
|
) => Promise<boolean>;
|
|
90
89
|
}
|
|
91
90
|
|
|
92
|
-
export const ATTACHABLE: string[] = [
|
|
93
|
-
...ATTACHABLE_STATIONS,
|
|
94
|
-
...INTERACTIVE_STATIONS,
|
|
95
|
-
];
|
|
96
|
-
|
|
97
|
-
function lookupQuery(req: IncomingMessage): string {
|
|
98
|
-
const params = new URLSearchParams((req.url ?? '').split('?')[1] ?? '');
|
|
99
|
-
return (params.get('q') ?? '').trim();
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
async function handleResolve(
|
|
103
|
-
req: IncomingMessage,
|
|
104
|
-
res: ServerResponse,
|
|
105
|
-
deps: AccountApiDeps,
|
|
106
|
-
target: { station: StationName; accountId: string },
|
|
107
|
-
): Promise<void> {
|
|
108
|
-
if (stationByName(target.station)?.resolvesSenders !== true)
|
|
109
|
-
throw new ApiError(`metro cannot look a sender up on ${target.station}`, 400);
|
|
110
|
-
const query = lookupQuery(req);
|
|
111
|
-
if (query === '') throw new ApiError('a number to look up is required', 400);
|
|
112
|
-
const found = await deps.resolveSender(target.station, target.accountId, query);
|
|
113
|
-
sendJson(req, res, 200, found);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async function handleName(
|
|
117
|
-
req: IncomingMessage,
|
|
118
|
-
res: ServerResponse,
|
|
119
|
-
deps: AccountApiDeps,
|
|
120
|
-
target: { station: StationName; accountId: string },
|
|
121
|
-
): Promise<void> {
|
|
122
|
-
if (stationByName(target.station)?.claimsName !== true)
|
|
123
|
-
throw new ApiError(`${target.station} accounts have no name to claim`, 400);
|
|
124
|
-
if (req.method === 'GET') {
|
|
125
|
-
sendJson(req, res, 200, await deps.accountCall(target.station, 'name', { account: target.accountId }));
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
const label = bodyField(await readJsonBody(req), 'label');
|
|
129
|
-
if (typeof label !== 'string' || label.trim() === '') throw new ApiError('a label is required', 400);
|
|
130
|
-
sendJson(req, res, 200, await deps.accountCall(target.station, 'claim_name', { account: target.accountId, label }));
|
|
131
|
-
}
|
|
91
|
+
export const ATTACHABLE: string[] = [...ATTACHABLE_STATIONS, ...INTERACTIVE_STATIONS];
|
|
132
92
|
|
|
133
93
|
async function activate(
|
|
134
94
|
deps: AccountApiDeps,
|
|
@@ -279,14 +239,18 @@ async function handleAllowlist(
|
|
|
279
239
|
agentId: string,
|
|
280
240
|
target: { station: StationName; accountId: string },
|
|
281
241
|
): Promise<void> {
|
|
282
|
-
const
|
|
283
|
-
const
|
|
242
|
+
const body = await readJsonBody(req);
|
|
243
|
+
const wanted = normalizeAllowlist(bodyField(body, 'allowlist'));
|
|
244
|
+
const asked = isRecord(body) && 'approvers' in body ? body.approvers : approversForAccount(target.station, target.accountId);
|
|
245
|
+
const approvers = normalizeApprovers(asked, wanted);
|
|
246
|
+
const allowlist = await deps.setAllowlist(agentId, target.station, target.accountId, wanted, approvers);
|
|
284
247
|
log.info({ agentId, station: target.station, account: target.accountId, senders: allowlist.length }, 'account-api: allowlist set');
|
|
285
248
|
sendJson(req, res, 200, {
|
|
286
249
|
agentId,
|
|
287
250
|
station: target.station,
|
|
288
251
|
accountId: target.accountId,
|
|
289
252
|
allowlist,
|
|
253
|
+
approvers,
|
|
290
254
|
activated: await applied(deps),
|
|
291
255
|
});
|
|
292
256
|
}
|
|
@@ -346,6 +310,18 @@ async function handleStep(
|
|
|
346
310
|
sendJson(req, res, 200, view);
|
|
347
311
|
}
|
|
348
312
|
|
|
313
|
+
async function dispatchAttach(
|
|
314
|
+
req: IncomingMessage,
|
|
315
|
+
res: ServerResponse,
|
|
316
|
+
deps: AccountApiDeps,
|
|
317
|
+
agentId: string,
|
|
318
|
+
route: Extract<AccountRoute, { kind: 'start' | 'session' | 'step' }>,
|
|
319
|
+
): Promise<void> {
|
|
320
|
+
if (route.kind === 'start') return handleStart(req, res, deps, agentId);
|
|
321
|
+
if (route.kind === 'session') return handleSession(req, res, deps, { agentId }, route.attachId);
|
|
322
|
+
return handleStep(req, res, deps, { agentId }, route.attachId);
|
|
323
|
+
}
|
|
324
|
+
|
|
349
325
|
async function dispatchRoute(
|
|
350
326
|
req: IncomingMessage,
|
|
351
327
|
res: ServerResponse,
|
|
@@ -353,27 +329,12 @@ async function dispatchRoute(
|
|
|
353
329
|
agentId: string,
|
|
354
330
|
route: AccountRoute,
|
|
355
331
|
): Promise<void> {
|
|
356
|
-
if (route.kind === 'start')
|
|
357
|
-
return handleStart(req, res, deps, agentId);
|
|
358
|
-
if (route.kind === 'session')
|
|
359
|
-
return handleSession(
|
|
360
|
-
req,
|
|
361
|
-
res,
|
|
362
|
-
deps,
|
|
363
|
-
{ agentId },
|
|
364
|
-
route.attachId,
|
|
365
|
-
);
|
|
366
|
-
if (route.kind === 'step')
|
|
367
|
-
return handleStep(req, res, deps, { agentId }, route.attachId);
|
|
332
|
+
if (route.kind === 'start' || route.kind === 'session' || route.kind === 'step') return dispatchAttach(req, res, deps, agentId, route);
|
|
368
333
|
if (route.kind === 'allowlist') return handleAllowlist(req, res, deps, agentId, route);
|
|
369
334
|
if (route.kind === 'enabled') return handleEnabled(req, res, deps, agentId, route);
|
|
370
335
|
if (route.kind === 'policy') return handlePolicy(req, res, deps, agentId, route);
|
|
371
|
-
|
|
372
|
-
if (
|
|
373
|
-
if (route.kind === 'senders') {
|
|
374
|
-
sendJson(req, res, 200, { senders: deps.recentSenders(route.station, route.accountId) });
|
|
375
|
-
return;
|
|
376
|
-
}
|
|
336
|
+
const lookup = LOOKUPS[route.kind];
|
|
337
|
+
if (lookup !== undefined && 'station' in route) return lookup(req, res, deps, route);
|
|
377
338
|
return handleDetach(req, res, deps, agentId, route);
|
|
378
339
|
}
|
|
379
340
|
|
|
@@ -29,6 +29,19 @@ function senderId(entry: unknown): string {
|
|
|
29
29
|
return value.startsWith('@') ? domainEntry(value) : value;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export function normalizeApprovers(raw: unknown, allowlist: string[]): string[] {
|
|
33
|
+
if (raw === undefined) return [];
|
|
34
|
+
if (!Array.isArray(raw)) throw new ApiError('approvers must be a list of sender ids', 400);
|
|
35
|
+
const listed = new Set(allowlist.map((entry) => entry.toLowerCase()));
|
|
36
|
+
const seen = new Set<string>();
|
|
37
|
+
return raw.map(senderId).filter((value) => {
|
|
38
|
+
const key = value.toLowerCase();
|
|
39
|
+
if (!listed.has(key) || seen.has(key)) return false;
|
|
40
|
+
seen.add(key);
|
|
41
|
+
return true;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
export function normalizeAllowlist(raw: unknown): string[] {
|
|
33
46
|
if (!Array.isArray(raw)) throw new ApiError('allowlist must be a list of sender ids', 400);
|
|
34
47
|
if (raw.length > MAX_ENTRIES) throw new ApiError(`allowlist holds at most ${String(MAX_ENTRIES)} senders`, 400);
|
|
@@ -7,6 +7,7 @@ import { isRecord } from '@metro-labs/core/is-record';
|
|
|
7
7
|
import { parseId } from '@metro-labs/core/ids';
|
|
8
8
|
import type { LoadedAccount, LoadedAgent, LoadedConnector } from '../stations/materialize.js';
|
|
9
9
|
import { STATIONS } from '@metro-labs/core/station-names';
|
|
10
|
+
import { approversOf } from './files.js';
|
|
10
11
|
|
|
11
12
|
const AGENTS = '/api/agents';
|
|
12
13
|
const RESTORE_PATH = `${AGENTS}/restore`;
|
|
@@ -60,6 +61,7 @@ function stationOf(raw: unknown): LoadedAccount {
|
|
|
60
61
|
station: raw.station as LoadedAccount['station'],
|
|
61
62
|
id: raw.id,
|
|
62
63
|
allowlist,
|
|
64
|
+
...approversOf(raw.approvers),
|
|
63
65
|
enabled: raw.enabled !== false,
|
|
64
66
|
...(policy === undefined ? {} : { policy }),
|
|
65
67
|
config: raw.config,
|
|
@@ -243,12 +243,15 @@ export async function localSetAllowlist(
|
|
|
243
243
|
station: StationName,
|
|
244
244
|
accountId: string,
|
|
245
245
|
allowlist: string[],
|
|
246
|
+
approvers: string[] = [],
|
|
246
247
|
dir = agentsDir(),
|
|
247
248
|
): Promise<string[]> {
|
|
248
249
|
const stored = agentOrThrow(agentId, dir);
|
|
249
250
|
const account = stored.file.stations.find((a) => a.station === station && a.id === accountId);
|
|
250
251
|
if (account === undefined) throw new AgentAdminError('no such account on this agent', 404);
|
|
251
252
|
account.allowlist = allowlist;
|
|
253
|
+
if (approvers.length === 0) delete account.approvers;
|
|
254
|
+
else account.approvers = approvers;
|
|
252
255
|
save(stored);
|
|
253
256
|
return Promise.resolve(allowlist);
|
|
254
257
|
}
|
|
@@ -49,6 +49,12 @@ function allowlistOf(raw: unknown, path: string, where: string): string[] | null
|
|
|
49
49
|
return list;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
export function approversOf(raw: unknown): { approvers?: string[] } {
|
|
53
|
+
if (!Array.isArray(raw)) return {};
|
|
54
|
+
const list = raw.filter((entry): entry is string => typeof entry === 'string' && entry !== '*' && entry.trim() !== '');
|
|
55
|
+
return list.length === 0 ? {} : { approvers: list };
|
|
56
|
+
}
|
|
57
|
+
|
|
52
58
|
function stationOf(raw: unknown, path: string, index: number): LoadedAccount {
|
|
53
59
|
const where = `stations[${String(index)}]`;
|
|
54
60
|
if (!isRecord(raw)) fail(path, `${where} is not an object`);
|
|
@@ -62,6 +68,7 @@ function stationOf(raw: unknown, path: string, index: number): LoadedAccount {
|
|
|
62
68
|
station,
|
|
63
69
|
id,
|
|
64
70
|
allowlist: allowlistOf(raw.allowlist, path, where),
|
|
71
|
+
...approversOf(raw.approvers),
|
|
65
72
|
enabled: raw.enabled !== false,
|
|
66
73
|
...(policy === undefined ? {} : { policy }),
|
|
67
74
|
config,
|
|
@@ -8,6 +8,7 @@ const mapKey = (station: string, accountId: string): string =>
|
|
|
8
8
|
let agentMap: AgentMap = {};
|
|
9
9
|
let agentNames: AgentNameMap = {};
|
|
10
10
|
let allowlistMap: AllowlistMap = {};
|
|
11
|
+
let approversMap: AllowlistMap = {};
|
|
11
12
|
let disabledAccounts = new Set<string>();
|
|
12
13
|
|
|
13
14
|
export function setAgentMap(map: AgentMap, names: AgentNameMap): void {
|
|
@@ -19,6 +20,22 @@ export function setAllowlistMap(map: AllowlistMap): void {
|
|
|
19
20
|
allowlistMap = map;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
export function setApproversMap(map: AllowlistMap): void {
|
|
24
|
+
approversMap = map;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const approversForAccount = (station: string, accountId: string): string[] => approversMap[mapKey(station, accountId)] ?? [];
|
|
28
|
+
|
|
29
|
+
export function approversForLine(line: string): string[] {
|
|
30
|
+
const a = accountFromLine(line);
|
|
31
|
+
return a ? approversForAccount(a.station, a.accountId) : [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const mayApprove = (line: string, from: string): boolean => {
|
|
35
|
+
const approvers = approversForLine(line);
|
|
36
|
+
return approvers.length > 0 && senderMatchesAllowlist(approvers, from);
|
|
37
|
+
};
|
|
38
|
+
|
|
22
39
|
export function setDisabledAccounts(ids: Set<string>): void {
|
|
23
40
|
disabledAccounts = ids;
|
|
24
41
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { StationName } from '@metro-labs/core/station-names';
|
|
3
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
4
|
+
import { sendJson } from '@metro-labs/http/api-http';
|
|
5
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
6
|
+
import { stationByName } from '../stations/registry.js';
|
|
7
|
+
import { allowlistForAccount } from './map.js';
|
|
8
|
+
import type { RecentSender } from './senders.js';
|
|
9
|
+
|
|
10
|
+
export interface SenderCard {
|
|
11
|
+
id: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
handle?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SenderCardDeps {
|
|
17
|
+
recentSenders: (station: StationName, accountId: string) => RecentSender[];
|
|
18
|
+
accountCall: (station: StationName, action: string, args: Record<string, unknown>) => Promise<unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MAX_LOOKUPS = 50;
|
|
22
|
+
const LOOKUP_MS = 8_000;
|
|
23
|
+
|
|
24
|
+
const filled = (value: unknown): string | undefined => (typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined);
|
|
25
|
+
|
|
26
|
+
function cardOf(id: string, profile: unknown, seenName: string | undefined): SenderCard {
|
|
27
|
+
const p = isRecord(profile) ? profile : {};
|
|
28
|
+
const display = filled(p.display_name);
|
|
29
|
+
const handle = filled(p.name);
|
|
30
|
+
const name = display ?? seenName ?? handle;
|
|
31
|
+
return {
|
|
32
|
+
id,
|
|
33
|
+
...(name === undefined ? {} : { name }),
|
|
34
|
+
...(handle === undefined || handle === name ? {} : { handle }),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function lookup(deps: SenderCardDeps, station: StationName, accountId: string, user: string): Promise<unknown> {
|
|
39
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
40
|
+
const late = new Promise<null>((resolve) => {
|
|
41
|
+
timer = setTimeout(() => {
|
|
42
|
+
resolve(null);
|
|
43
|
+
}, LOOKUP_MS);
|
|
44
|
+
});
|
|
45
|
+
try {
|
|
46
|
+
return await Promise.race([deps.accountCall(station, 'profile', { account: accountId, user }), late]);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
log.info({ station, account: accountId, err: errMsg(err) }, 'account-api: could not read the profile of a listed sender');
|
|
49
|
+
return null;
|
|
50
|
+
} finally {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function senderCards(deps: SenderCardDeps, station: StationName, accountId: string): Promise<SenderCard[]> {
|
|
56
|
+
const ids = (allowlistForAccount(station, accountId) ?? []).filter((id) => id !== '*' && !id.startsWith('@'));
|
|
57
|
+
const seen = new Map(deps.recentSenders(station, accountId).map((s) => [s.id.toLowerCase(), filled(s.name)]));
|
|
58
|
+
const reads = stationByName(station)?.readsProfiles === true;
|
|
59
|
+
return Promise.all(
|
|
60
|
+
ids.map(async (id, at) => {
|
|
61
|
+
const profile = reads && at < MAX_LOOKUPS ? await lookup(deps, station, accountId, id) : null;
|
|
62
|
+
return cardOf(id, profile, seen.get(id.toLowerCase()));
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function handleSenderCards(
|
|
68
|
+
req: IncomingMessage,
|
|
69
|
+
res: ServerResponse,
|
|
70
|
+
deps: SenderCardDeps,
|
|
71
|
+
target: { station: StationName; accountId: string },
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
sendJson(req, res, 200, { senders: await senderCards(deps, target.station, target.accountId) });
|
|
74
|
+
}
|
|
@@ -29,7 +29,7 @@ interface InboundDeps {
|
|
|
29
29
|
getStations: () => Set<string>;
|
|
30
30
|
senderAllowed: (from: string, line: string, verified?: boolean) => boolean;
|
|
31
31
|
approves?: (station: string) => boolean;
|
|
32
|
-
answerPermission?: (requestId: string, behavior: 'allow' | 'deny', line: string) => Promise<boolean>;
|
|
32
|
+
answerPermission?: (requestId: string, behavior: 'allow' | 'deny', line: string, from: string) => Promise<boolean>;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
const ATTACH_TIMEOUT_MS = 15_000;
|
|
@@ -253,7 +253,7 @@ export class InboundRelay {
|
|
|
253
253
|
if (answer === undefined || base.evType !== 'msg' || !this.approves(base.station) || ev.senderVerified === false) return false;
|
|
254
254
|
const m = PERMISSION_REPLY_RE.exec(base.text);
|
|
255
255
|
if (m?.[1] === undefined || m[2] === undefined) return false;
|
|
256
|
-
return answer(m[2].toLowerCase(), m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny', base.line);
|
|
256
|
+
return answer(m[2].toLowerCase(), m[1].toLowerCase().startsWith('y') ? 'allow' : 'deny', base.line, base.from);
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
private approves(station: string): boolean {
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from '../stations/registry.js';
|
|
7
7
|
import { listEndpoints } from '../stations/webhook-endpoints.js';
|
|
8
8
|
import { hookUrl, threemaCallbackUrl } from '../stations/attach.js';
|
|
9
|
-
import { accountEnabled, agentIdForAccount, allowlistForAccount, knownAccounts, type KnownAccount } from '../agents/map.js';
|
|
9
|
+
import { accountEnabled, agentIdForAccount, allowlistForAccount, approversForAccount, knownAccounts, type KnownAccount } from '../agents/map.js';
|
|
10
10
|
|
|
11
11
|
const accountId = (acc: unknown): string | undefined => {
|
|
12
12
|
const id = (acc as { id?: unknown }).id;
|
|
@@ -28,7 +28,9 @@ function withAgentId(station: string, acc: unknown): unknown {
|
|
|
28
28
|
const allowlist = allowlistForAccount(station, id);
|
|
29
29
|
const stored = storedPolicy({ kind: 'channel', station, account: id });
|
|
30
30
|
const policy = stored === undefined ? {} : { policy: stored };
|
|
31
|
-
const
|
|
31
|
+
const approvers = approversForAccount(station, id);
|
|
32
|
+
const extra = approvers.length === 0 ? policy : { ...policy, approvers };
|
|
33
|
+
const tagged = allowlist === undefined ? { ...rec, agentId, ...extra } : { ...rec, agentId, allowlist, ...extra };
|
|
32
34
|
return accountEnabled(station, id) ? tagged : { ...tagged, enabled: false };
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -2,6 +2,7 @@ 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 { holdPrompt, type Behavior } from '../approvals/pending.js';
|
|
5
|
+
import { approversForLine } from '../agents/map.js';
|
|
5
6
|
import { metroCall } from './ctx.js';
|
|
6
7
|
import { promptBody } from './permission-prompt.js';
|
|
7
8
|
|
|
@@ -40,6 +41,10 @@ function relayLine(deps: PermissionRelayDeps, requestId: string): string | undef
|
|
|
40
41
|
deps.log('permission_request: known line is outside the agent scope, held for the page only', requestId);
|
|
41
42
|
return undefined;
|
|
42
43
|
}
|
|
44
|
+
if (approversForLine(line).length === 0) {
|
|
45
|
+
deps.log('permission_request: nobody on that chat may approve, held for the page only', requestId);
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
43
48
|
return line;
|
|
44
49
|
}
|
|
45
50
|
|
|
@@ -4,7 +4,7 @@ import { InboundRelay } from '../channels/inbound.js';
|
|
|
4
4
|
import { answerPrompt, forgetPromptsOf, promptLine } from '../approvals/pending.js';
|
|
5
5
|
import { ChannelRelay, type ReplayLedger } from '../channels/relay.js';
|
|
6
6
|
import { errMsg } from '@metro-labs/core/log';
|
|
7
|
-
import { allowlistForLine, senderPermitted } from '../agents/map.js';
|
|
7
|
+
import { allowlistForLine, mayApprove, senderPermitted } from '../agents/map.js';
|
|
8
8
|
import { accountStationNames, stationByName } from '../stations/registry.js';
|
|
9
9
|
import { eventInScope } from '../agents/scope.js';
|
|
10
10
|
import { MCP_INSTRUCTIONS } from './instructions.js';
|
|
@@ -24,8 +24,8 @@ const senderAllowed = (from: string, line: string, verified?: boolean): boolean
|
|
|
24
24
|
|
|
25
25
|
const approves = (station: string): boolean => stationByName(station)?.approvals !== false;
|
|
26
26
|
|
|
27
|
-
async function answerPermission(requestId: string, behavior: 'allow' | 'deny', line: string): Promise<boolean> {
|
|
28
|
-
if (promptLine(requestId) !== line) return false;
|
|
27
|
+
async function answerPermission(requestId: string, behavior: 'allow' | 'deny', line: string, from: string): Promise<boolean> {
|
|
28
|
+
if (promptLine(requestId) !== line || !mayApprove(line, from)) return false;
|
|
29
29
|
return (await answerPrompt(requestId, behavior, 'chat', line)) !== undefined;
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -14,6 +14,7 @@ import { trainsDir } from '../boot/paths.js';
|
|
|
14
14
|
import {
|
|
15
15
|
setAgentMap,
|
|
16
16
|
setAllowlistMap,
|
|
17
|
+
setApproversMap,
|
|
17
18
|
setDisabledAccounts,
|
|
18
19
|
type AgentMap,
|
|
19
20
|
type AgentNameMap,
|
|
@@ -32,6 +33,7 @@ export interface LoadedAccount {
|
|
|
32
33
|
station: StationName;
|
|
33
34
|
id: string;
|
|
34
35
|
allowlist: string[] | null;
|
|
36
|
+
approvers?: string[];
|
|
35
37
|
enabled?: boolean;
|
|
36
38
|
policy?: ToolPolicy;
|
|
37
39
|
config: Record<string, unknown>;
|
|
@@ -137,6 +139,11 @@ const channelPolicies = (list: LoadedAgent[]): [PolicyTarget, ToolPolicy][] =>
|
|
|
137
139
|
),
|
|
138
140
|
);
|
|
139
141
|
|
|
142
|
+
const approversOf = (list: LoadedAgent[]): Record<string, string[]> =>
|
|
143
|
+
Object.fromEntries(
|
|
144
|
+
list.flatMap((agent) => agent.accounts.flatMap((a) => (a.approvers === undefined ? [] : [[`${a.station}/${a.id}`, a.approvers]]))),
|
|
145
|
+
);
|
|
146
|
+
|
|
140
147
|
function writeStations(list: LoadedAgent[]): WrittenStations {
|
|
141
148
|
mkdirSync(METRO_DIR, { recursive: true });
|
|
142
149
|
mkdirSync(trainsDir(), { recursive: true });
|
|
@@ -169,6 +176,7 @@ function writeStations(list: LoadedAgent[]): WrittenStations {
|
|
|
169
176
|
}
|
|
170
177
|
setAgentMap(map, names);
|
|
171
178
|
setAllowlistMap(allow);
|
|
179
|
+
setApproversMap(approversOf(list));
|
|
172
180
|
setDisabledAccounts(disabled);
|
|
173
181
|
setPolicies('channel', channelPolicies(list));
|
|
174
182
|
|
package/runtime/runtime.json
CHANGED