@stage-labs/metro 0.1.0-beta.176 → 0.1.0-beta.177

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.176",
3
+ "version": "0.1.0-beta.177",
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.176",
3
+ "version": "0.1.0-beta.177",
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",
@@ -19,6 +19,7 @@
19
19
  "./lines": "./src/lines.ts",
20
20
  "./endpoints": "./src/endpoints.ts",
21
21
  "./stations/types": "./src/stations/types.ts",
22
+ "./stations/account-files": "./src/stations/account-files.ts",
22
23
  "./stations/station-runtime": "./src/stations/station-runtime.ts",
23
24
  "./stations/account-store": "./src/stations/account-store.ts",
24
25
  "./stations/attachments": "./src/stations/attachments.ts",
@@ -0,0 +1,41 @@
1
+ import { readdirSync, rmSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { log } from '../log.js';
5
+
6
+ export interface AccountFiles {
7
+ path: (accountId: string) => string;
8
+ forget: (accountId: string) => void;
9
+ forgetExcept: (keptAccountIds: readonly string[]) => void;
10
+ }
11
+
12
+ const safeSegment = (s: string): string => s.replace(/[^A-Za-z0-9_-]/g, '_');
13
+
14
+ function listed(dir: string): string[] {
15
+ try {
16
+ return readdirSync(dir);
17
+ } catch {
18
+ return [];
19
+ }
20
+ }
21
+
22
+ export function accountFiles(dirEnv: string, prefix: string): AccountFiles {
23
+ const dir = (): string => process.env[dirEnv] ?? join(homedir(), '.metro');
24
+ const name = (accountId: string): string => `${prefix}${safeSegment(accountId)}.json`;
25
+ const remove = (file: string, reason: string): void => {
26
+ rmSync(join(dir(), file), { force: true });
27
+ log.info({ file: join(dir(), file) }, reason);
28
+ };
29
+ return {
30
+ path: (accountId) => join(dir(), name(accountId)),
31
+ forget: (accountId) => {
32
+ remove(name(accountId), 'station: removed the files of a detached account');
33
+ },
34
+ forgetExcept: (keptAccountIds) => {
35
+ const kept = new Set(keptAccountIds.map(name));
36
+ for (const file of listed(dir()))
37
+ if (file.startsWith(prefix) && file.endsWith('.json') && !kept.has(file))
38
+ remove(file, 'station: removed the files of an account that is no longer attached');
39
+ },
40
+ };
41
+ }
@@ -108,6 +108,8 @@ export interface Station {
108
108
  readFilters?: ReadonlySet<ReadFilter>;
109
109
  approvals?: boolean;
110
110
  attachmentMode: AttachmentMode;
111
+ forget?: (accountId: string) => void;
112
+ forgetExcept?: (keptAccountIds: readonly string[]) => void;
111
113
  sendAttachments?(
112
114
  line: string,
113
115
  atts: CanonicalAttachment[],
@@ -246,6 +246,18 @@ async function handleDetach(
246
246
  detached: true,
247
247
  activated: await activate(deps, ref.station),
248
248
  });
249
+ forgetAccount(ref.station, ref.accountId);
250
+ }
251
+
252
+ function forgetAccount(station: StationName, accountId: string): void {
253
+ try {
254
+ stationByName(station)?.forget?.(accountId);
255
+ } catch (err) {
256
+ log.warn(
257
+ { station, account: accountId, err: errMsg(err) },
258
+ 'account-api: could not remove the files of a detached account',
259
+ );
260
+ }
249
261
  }
250
262
 
251
263
  async function applied(deps: AccountApiDeps): Promise<boolean> {
@@ -36,9 +36,11 @@ import { createMetroMcp } from '../mcp/index.js';
36
36
  import { gatherAccountsForAgents } from '../mcp/accounts.js';
37
37
  import {
38
38
  accountStationCapabilities,
39
+ forgetOrphans,
39
40
  stationByName,
40
41
  } from '../stations/registry.js';
41
42
  import { prepareAccount } from '../stations/attach.js';
43
+ import { knownAccounts } from '../agents/map.js';
42
44
  import { materializeFrom, reloadFrom } from '../stations/materialize.js';
43
45
  import type { StationName } from '@metro-labs/core/station-names';
44
46
  import { startUploadReaper } from '../files/upload-store.js';
@@ -116,6 +118,7 @@ installBearerSessions(agentsDir(), localOwner);
116
118
  migrateAgentLayout();
117
119
  log.info({ agent: await ensureLocalAgent() }, 'local daemon: agent');
118
120
  await materializeFrom(fileSource);
121
+ forgetOrphans(knownAccounts());
119
122
  supervisor.start();
120
123
  const metroMcp = await createMetroMcp();
121
124
  webhookServer = await startWebhookServer(
@@ -127,7 +127,7 @@ export async function startInteractiveAttach(
127
127
  input: Record<string, unknown>,
128
128
  hooks: DriverHooks,
129
129
  ): Promise<StartedAttach> {
130
- if (station === 'outlook') return startOutlook(hooks);
130
+ if (station === 'outlook') return startOutlook(input, hooks);
131
131
  return station === 'telegram'
132
132
  ? startTelegramUser(input, hooks)
133
133
  : startWhatsapp(input, hooks);
@@ -11,13 +11,14 @@ const BROWSER_TTL_MS = 15 * 60_000;
11
11
 
12
12
  const text = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
13
13
 
14
- export async function startOutlook(hooks: DriverHooks): Promise<StartedAttach> {
15
- const { OutlookBrowserLogin, OutlookLogin: DeviceLogin, OutlookLoginError, failureOf } = await import('@metro-labs/outlook/login');
14
+ export async function startOutlook(input: Record<string, unknown>, hooks: DriverHooks): Promise<StartedAttach> {
15
+ const { OutlookBrowserLogin, OutlookLogin: DeviceLogin, OutlookLoginError, failureOf, parseMailbox } = await import('@metro-labs/outlook/login');
16
16
  const said = (err: unknown, fallback: string): string =>
17
17
  err instanceof OutlookLoginError && err.message !== '' ? err.message : fallback;
18
- const browser = (() => {
18
+ const { browser, mailbox } = (() => {
19
19
  try {
20
- return new OutlookBrowserLogin();
20
+ const wanted = parseMailbox(input.mailbox);
21
+ return { browser: new OutlookBrowserLogin({ mailbox: wanted }), mailbox: wanted };
21
22
  } catch (err) {
22
23
  throw new StationAttachError(said(err, 'Microsoft refused to start the sign-in'), 400);
23
24
  }
@@ -26,7 +27,7 @@ export async function startOutlook(hooks: DriverHooks): Promise<StartedAttach> {
26
27
 
27
28
  const toDevice = async (): Promise<void> => {
28
29
  if (device !== null) return;
29
- const login = new DeviceLogin({ onDone: hooks.done, onFailed: hooks.fail });
30
+ const login = new DeviceLogin({ onDone: hooks.done, onFailed: hooks.fail }, { mailbox });
30
31
  const code = await login.start().catch((err: unknown) => {
31
32
  throw new StationAttachError(said(err, 'Microsoft refused to start the sign-in'), 400);
32
33
  });
@@ -1,5 +1,6 @@
1
1
  import type { Station } from '@metro-labs/core/stations/types';
2
2
  import { Line } from '@metro-labs/core/lines';
3
+ import { errMsg, log } from '@metro-labs/core/log';
3
4
  import { xmtpStation } from '@metro-labs/xmtp';
4
5
  import { telegramBotStation } from '@metro-labs/telegram-bot';
5
6
  import { telegramStation } from '@metro-labs/telegram';
@@ -37,3 +38,14 @@ export const accountStationCapabilities = (): Record<string, string[]> => {
37
38
  if (s.hasAccounts) out[s.name] = [...s.messageVerbs].sort();
38
39
  return out;
39
40
  };
41
+
42
+ export function forgetOrphans(known: readonly { station: string; id: string }[]): void {
43
+ for (const s of STATIONS) {
44
+ if (s.forgetExcept === undefined) continue;
45
+ try {
46
+ s.forgetExcept(known.filter((a) => a.station === s.name).map((a) => a.id));
47
+ } catch (err) {
48
+ log.warn({ station: s.name, err: errMsg(err) }, 'stations: could not remove the files of detached accounts');
49
+ }
50
+ }
51
+ }
@@ -13,8 +13,9 @@ export function pkcePair(verifier = randomBytes(32).toString('base64url')): Pkce
13
13
 
14
14
  export const newState = (): string => randomBytes(24).toString('base64url');
15
15
 
16
- export function authorizeUrl(challenge: string, state: string): string {
16
+ export function authorizeUrl(challenge: string, state: string, loginHint: string | null = null): string {
17
17
  const query = new URLSearchParams({
18
+ ...(loginHint === null ? {} : { login_hint: loginHint }),
18
19
  client_id: requireClientId(),
19
20
  response_type: 'code',
20
21
  redirect_uri: redirectUri(),
@@ -10,6 +10,8 @@ import {
10
10
  import { authorizeUrl, newState, pkcePair, redeemCode, type Pkce } from './browser.js';
11
11
  import { verifyMailbox, type Mailbox } from './me.js';
12
12
 
13
+ export { parseMailbox } from './me.js';
14
+
13
15
  export { OutlookAuthError as OutlookLoginError } from './auth.js';
14
16
  export { NOT_SET_UP } from './config.js';
15
17
  export { failureOf } from './auth.js';
@@ -27,6 +29,7 @@ export interface OutlookLoginEvents {
27
29
  export interface OutlookLoginDeps {
28
30
  fetch?: FetchLike;
29
31
  now?: () => number;
32
+ mailbox?: string | null;
30
33
  }
31
34
 
32
35
  const SLOW_DOWN_MS = 5_000;
@@ -52,11 +55,13 @@ export class OutlookBrowserLogin {
52
55
  private used = false;
53
56
  private readonly fetchImpl: FetchLike;
54
57
  private readonly now: () => number;
58
+ private readonly mailbox: string | null;
55
59
 
56
60
  constructor(deps: OutlookLoginDeps = {}) {
57
61
  this.fetchImpl = deps.fetch ?? ((input, init) => fetch(input, init));
58
62
  this.now = deps.now ?? Date.now;
59
- this.authorizeUrl = authorizeUrl(this.pkce.challenge, this.state);
63
+ this.mailbox = deps.mailbox ?? null;
64
+ this.authorizeUrl = authorizeUrl(this.pkce.challenge, this.state, this.mailbox);
60
65
  }
61
66
 
62
67
  async finish(code: string, state: string): Promise<OutlookLoginResult> {
@@ -65,7 +70,7 @@ export class OutlookBrowserLogin {
65
70
  if (this.used) throw new OutlookAuthError('This sign-in was already used. Start again from the Channels page.');
66
71
  this.used = true;
67
72
  const { tokens, tenantId } = await redeemCode(code, this.pkce.verifier, this.fetchImpl, this.now());
68
- return resultOf(tokens, tenantId, await verifyMailbox(tokens.accessToken, this.fetchImpl), this.now());
73
+ return resultOf(tokens, tenantId, await verifyMailbox(tokens.accessToken, this.fetchImpl, this.mailbox), this.now());
69
74
  }
70
75
  }
71
76
 
@@ -75,10 +80,12 @@ export class OutlookLogin {
75
80
  private stopped = false;
76
81
  private readonly fetchImpl: FetchLike;
77
82
  private readonly now: () => number;
83
+ private readonly mailbox: string | null;
78
84
 
79
85
  constructor(private readonly events: OutlookLoginEvents, deps: OutlookLoginDeps = {}) {
80
86
  this.fetchImpl = deps.fetch ?? ((input, init) => fetch(input, init));
81
87
  this.now = deps.now ?? Date.now;
88
+ this.mailbox = deps.mailbox ?? null;
82
89
  }
83
90
 
84
91
  async start(): Promise<DeviceCode> {
@@ -135,7 +142,7 @@ export class OutlookLogin {
135
142
  this.fail(poll.message);
136
143
  return;
137
144
  }
138
- const mailbox = await verifyMailbox(poll.tokens.accessToken, this.fetchImpl);
145
+ const mailbox = await verifyMailbox(poll.tokens.accessToken, this.fetchImpl, this.mailbox);
139
146
  if (this.stopped) return;
140
147
  this.stopped = true;
141
148
  this.events.onDone(resultOf(poll.tokens, poll.tenantId, mailbox, this.now()));
@@ -8,7 +8,20 @@ export interface Mailbox {
8
8
 
9
9
  const text = (v: unknown): string => (typeof v === 'string' ? v.trim() : '');
10
10
 
11
- export async function verifyMailbox(accessToken: string, fetchImpl: FetchLike): Promise<Mailbox> {
11
+ const MAILBOX_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
12
+
13
+ export function parseMailbox(value: unknown): string | null {
14
+ const mailbox = text(value).toLowerCase();
15
+ if (mailbox === '') return null;
16
+ if (!MAILBOX_RE.test(mailbox))
17
+ throw new OutlookAuthError('Type the whole address of the mailbox, like andy@company.com, or leave it blank.');
18
+ return mailbox;
19
+ }
20
+
21
+ const wrongMailbox = (actual: string, wanted: string): string =>
22
+ `You signed in as ${actual}, not ${wanted}. Nothing was connected. Start again and pick ${wanted} on Microsoft's page, or use "Use another account".`;
23
+
24
+ export async function verifyMailbox(accessToken: string, fetchImpl: FetchLike, wanted: string | null = null): Promise<Mailbox> {
12
25
  let res: Response;
13
26
  try {
14
27
  res = await fetchImpl(`${graphBase()}/me?$select=mail,userPrincipalName,displayName`, {
@@ -19,7 +32,10 @@ export async function verifyMailbox(accessToken: string, fetchImpl: FetchLike):
19
32
  }
20
33
  if (!res.ok) throw new OutlookAuthError(`Microsoft Graph refused the new sign-in (${String(res.status)}), so nothing was connected.`);
21
34
  const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
22
- const email = text(body.mail) || text(body.userPrincipalName);
35
+ const mail = text(body.mail).toLowerCase();
36
+ const principal = text(body.userPrincipalName).toLowerCase();
37
+ const email = mail || principal;
23
38
  if (email === '') throw new OutlookAuthError('Microsoft Graph did not say which mailbox this is, so nothing was connected.');
24
- return { email: email.toLowerCase(), name: text(body.displayName) };
39
+ if (wanted !== null && wanted !== mail && wanted !== principal) throw new OutlookAuthError(wrongMailbox(email, wanted));
40
+ return { email, name: text(body.displayName) };
25
41
  }
@@ -1,6 +1,5 @@
1
- import { homedir } from 'node:os';
2
- import { join } from 'node:path';
3
1
  import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
2
+ import { accountFiles } from '@metro-labs/core/stations/account-files';
4
3
 
5
4
  export interface AccountState {
6
5
  accessToken: string;
@@ -13,12 +12,9 @@ export interface AccountState {
13
12
 
14
13
  const SEEN_MAX = 500;
15
14
 
16
- const safeSegment = (s: string): string => s.replace(/[^A-Za-z0-9_-]/g, '_');
15
+ export const stateFiles = accountFiles('OUTLOOK_STATE_DIR', 'outlook-state-');
17
16
 
18
- export function statePath(accountId: string): string {
19
- const dir = process.env.OUTLOOK_STATE_DIR ?? join(homedir(), '.metro');
20
- return join(dir, `outlook-state-${safeSegment(accountId)}.json`);
21
- }
17
+ const statePath =(accountId: string): string => stateFiles.path(accountId);
22
18
 
23
19
  const text = (v: unknown): string => (typeof v === 'string' ? v : '');
24
20
  const nullable = (v: unknown): string | null => (typeof v === 'string' && v !== '' ? v : null);
@@ -1,4 +1,5 @@
1
1
  import type { ReadFilter, Station, Verb } from '@metro-labs/core/stations/types';
2
+ import { stateFiles } from './state.js';
2
3
 
3
4
  export const outlookStation: Station = {
4
5
  name: 'outlook',
@@ -8,5 +9,7 @@ export const outlookStation: Station = {
8
9
  readFilters: new Set<ReadFilter>(['account', 'query', 'from', 'until', 'unread_only', 'message_id']),
9
10
  approvals: false,
10
11
  attachmentMode: 'canonical',
12
+ forget: stateFiles.forget,
13
+ forgetExcept: stateFiles.forgetExcept,
11
14
  tools: [],
12
15
  };
@@ -1,6 +1,6 @@
1
1
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
- import { dirname, join } from 'node:path';
2
+ import { dirname } from 'node:path';
3
+ import { accountFiles } from '@metro-labs/core/stations/account-files';
4
4
  import type { GroupRef } from './messages.js';
5
5
 
6
6
  export const GROUP_RE = /^([A-Z0-9]{8}|\*[A-Z0-9]{7})-([0-9a-f]{16})$/i;
@@ -19,8 +19,9 @@ export function parseGroupKey(resource: string): GroupRef | null {
19
19
  return m === null ? null : { creator: (m[1] ?? '').toUpperCase(), groupId: (m[2] ?? '').toLowerCase() };
20
20
  }
21
21
 
22
- const groupsFile = (accountId: string): string =>
23
- join(process.env.THREEMA_GROUPS_DIR ?? join(homedir(), '.metro'), `threema-groups-${accountId}.json`);
22
+ export const groupFiles = accountFiles('THREEMA_GROUPS_DIR', 'threema-groups-');
23
+
24
+ const groupsFile = (accountId: string): string => groupFiles.path(accountId);
24
25
 
25
26
  function readRosters(accountId: string): Map<string, GroupRoster> {
26
27
  try {
@@ -1,4 +1,5 @@
1
1
  import type { Station, Verb } from '@metro-labs/core/stations/types';
2
+ import { groupFiles } from './groups.js';
2
3
 
3
4
  export const threemaStation: Station = {
4
5
  name: 'threema',
@@ -6,5 +7,7 @@ export const threemaStation: Station = {
6
7
  hasTrain: true,
7
8
  messageVerbs: new Set<Verb>(['send', 'reply', 'react', 'unreact']),
8
9
  attachmentMode: 'canonical',
10
+ forget: groupFiles.forget,
11
+ forgetExcept: groupFiles.forgetExcept,
9
12
  tools: [],
10
13
  };
@@ -1,5 +1,6 @@
1
1
  import type { Station, Verb } from '@metro-labs/core/stations/types';
2
2
  import { PROFILE_FIELDS, type ProfileField } from '@metro-labs/core/stations/profile';
3
+ import { tokenFiles } from './token-store.js';
3
4
 
4
5
  export const whatsappStation: Station = {
5
6
  name: 'whatsapp',
@@ -17,5 +18,7 @@ export const whatsappStation: Station = {
17
18
  profileFields: new Set<ProfileField>(PROFILE_FIELDS),
18
19
  readsProfiles: true,
19
20
  resolvesSenders: true,
21
+ forget: tokenFiles.forget,
22
+ forgetExcept: tokenFiles.forgetExcept,
20
23
  tools: [],
21
24
  };
@@ -1,6 +1,5 @@
1
- import { homedir } from 'node:os';
2
- import { join } from 'node:path';
3
1
  import { readJson, writeSecure } from '@metro-labs/core/secure-fs';
2
+ import { accountFiles } from '@metro-labs/core/stations/account-files';
4
3
  import { errMsg } from '@metro-labs/core/log';
5
4
 
6
5
  export const PERSISTED_KEY_TYPES = ['tctoken', 'lid-mapping'] as const;
@@ -14,12 +13,9 @@ const WRITE_DEBOUNCE_MS = 1000;
14
13
  export const isPersistedKeyType = (type: string): boolean =>
15
14
  PERSISTED.has(type);
16
15
 
17
- const safeSegment = (s: string): string => s.replace(/[^A-Za-z0-9_-]/g, '_');
16
+ export const tokenFiles = accountFiles('WHATSAPP_TOKEN_DIR', 'whatsapp-tokens-');
18
17
 
19
- export function tokenStorePath(accountId: string): string {
20
- const dir = process.env.WHATSAPP_TOKEN_DIR ?? join(homedir(), '.metro');
21
- return join(dir, `whatsapp-tokens-${safeSegment(accountId)}.json`);
22
- }
18
+ export const tokenStorePath = (accountId: string): string => tokenFiles.path(accountId);
23
19
 
24
20
  export function persistedSubset(table: KeyTable): KeyTable {
25
21
  const out: KeyTable = {};
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.176"
2
+ "version": "0.1.0-beta.177"
3
3
  }