@stage-labs/metro 0.1.0-beta.271 → 0.1.0-beta.273

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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/marketplace/plugin/STAGE.md +3 -0
  4. package/runtime/node_modules/@metro-labs/core/src/tickets.ts +7 -0
  5. package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +13 -8
  6. package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +13 -1
  8. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -2
  9. package/runtime/node_modules/@metro-labs/daemon/src/claude/setup.ts +1 -0
  10. package/runtime/node_modules/@metro-labs/daemon/src/net/tunnel.ts +10 -3
  11. package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +1 -1
  12. package/runtime/node_modules/@metro-labs/daemon/src/routes/gmail-attach.ts +67 -0
  13. package/runtime/node_modules/@metro-labs/daemon/src/routes/gmail-detach.ts +37 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +12 -6
  15. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-browser.ts +8 -3
  16. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-driver.ts +1 -1
  17. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-gmail.ts +78 -0
  18. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-session.ts +30 -4
  19. package/runtime/node_modules/@metro-labs/gmail/package.json +3 -1
  20. package/runtime/node_modules/@metro-labs/gmail/src/accounts.ts +63 -11
  21. package/runtime/node_modules/@metro-labs/gmail/src/actions.ts +8 -3
  22. package/runtime/node_modules/@metro-labs/gmail/src/api.ts +4 -1
  23. package/runtime/node_modules/@metro-labs/gmail/src/attachments.ts +4 -1
  24. package/runtime/node_modules/@metro-labs/gmail/src/auth.ts +15 -5
  25. package/runtime/node_modules/@metro-labs/gmail/src/config.ts +3 -1
  26. package/runtime/node_modules/@metro-labs/gmail/src/inbound.ts +1 -0
  27. package/runtime/node_modules/@metro-labs/gmail/src/login.ts +5 -3
  28. package/runtime/node_modules/@metro-labs/gmail/src/managed-binding.ts +33 -0
  29. package/runtime/node_modules/@metro-labs/gmail/src/managed.ts +45 -0
  30. package/runtime/node_modules/@metro-labs/gmail/src/outbound.ts +1 -0
  31. package/runtime/node_modules/@metro-labs/gmail/src/query.ts +2 -0
  32. package/runtime/node_modules/@metro-labs/gmail/src/state.ts +8 -2
  33. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +2 -0
  34. package/runtime/runtime.json +1 -1
  35. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/tickets.ts +7 -0
  36. package/runtime/sdk-runner/runner.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.271",
3
+ "version": "0.1.0-beta.273",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.271",
3
+ "version": "0.1.0-beta.273",
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",
@@ -45,6 +45,9 @@ The separate `metadata.status` and `metadata.priority` fields are optional. Stat
45
45
  - Delete one of your own messages: `delete {"line": "…", "message_id": "…"}`. XMTP has no edit and no typing.
46
46
  - History: `read {"line": "…", "limit": 20}`.
47
47
  - Poll: `ask {"line": "…", "question": "Ship today?", "options": ["Yes", "No"]}`.
48
+ - Poll votes are numeric reactions with zero-based indexes: `0` is the first option, `1` is the second, and so on, in the poll's stored option order. For the poll above, `0` means Yes and `1` means No. Never use a one-based guess. Match the inbound reaction's `target_id` to the poll's `messageId` returned by `ask`, not its `pollId` or the reaction's own `message_id`.
49
+ - With several questions, the first question still uses bare option indexes (`0`, `1`, …); later questions use `q:o`, with both indexes zero-based. `1:0` means the first option of the second question. Explicit `0:0` also means the first option of the first question.
50
+ - Keep the poll's questions, options and `messageId`: `read` shows `[poll]` and `[reaction]`, not their choices or vote values. If the original mapping is unavailable, do not guess.
48
51
  - File: `create_upload {"name": "report.pdf"}`, run the `curl` line it returns in a shell, then `send {"line": "…", "attachments": [{"upload": "<upload_id>"}]}`. Files that are not images are refused over about 190 KiB.
49
52
 
50
53
  **Frames.** A frame is a small interactive view: a report, a choice, a form. Stage shows the frame itself in the chat (its `start` screen when it has screens), at image size, at most 400 x 400, clipped with a fade when taller. A tap opens it full screen. Its buttons do nothing in the chat: they work in the full view.
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
2
2
 
3
3
  export interface TicketStore<T> {
4
4
  mint: (value: T, now?: number) => { ticket: string; expiresAt: number };
5
+ peek: (ticket: string, now?: number) => T | undefined;
5
6
  take: (ticket: string, now?: number) => T | undefined;
6
7
  size: (now?: number) => number;
7
8
  }
@@ -24,6 +25,12 @@ export function ticketStore<T>(ttlMs: number, max: number): TicketStore<T> {
24
25
  open.set(ticket, { value, expiresAt });
25
26
  return { ticket, expiresAt };
26
27
  },
28
+ peek(ticket, now = Date.now()) {
29
+ const entry = open.get(ticket);
30
+ if (entry !== undefined && entry.expiresAt > now) return entry.value;
31
+ open.delete(ticket);
32
+ return undefined;
33
+ },
27
34
  take(ticket, now = Date.now()) {
28
35
  const entry = open.get(ticket);
29
36
  open.delete(ticket);
@@ -5,7 +5,7 @@ import { approversForAccount } from './map.js';
5
5
  import { isRecord } from '@metro-labs/core/is-record';
6
6
  import type { RecentSender } from './senders.js';
7
7
  import { ApiError } from '@metro-labs/http/api-error';
8
- import { apiFailure, bodyField, readJsonBody, sendJson } from '@metro-labs/http/api-http';
8
+ import { apiFailure, bodyField, readJsonBody, sendJson, type ApiSession } from '@metro-labs/http/api-http';
9
9
  import { type AccountRef } from './account-attach.js';
10
10
  import { type AccountRoute } from './account-routes.js';
11
11
  import { handlePolicy, type SetPolicy } from './policy-route.js';
@@ -138,7 +138,7 @@ async function storeAccount(
138
138
 
139
139
  function asInput(body: unknown): Record<string, unknown> {
140
140
  const out: Record<string, unknown> = {};
141
- for (const key of ['apiId', 'apiHash', 'phone', 'mailbox', 'clientId', 'clientSecret'])
141
+ for (const key of ['apiId', 'apiHash', 'phone', 'mailbox', 'clientId', 'clientSecret', 'mode', 'sendEnabled', 'accountId'])
142
142
  out[key] = bodyField(body, key);
143
143
  return out;
144
144
  }
@@ -148,12 +148,13 @@ async function handleStart(
148
148
  res: ServerResponse,
149
149
  deps: AccountApiDeps,
150
150
  agentId: string,
151
+ owner: AttachOwner,
151
152
  ): Promise<void> {
152
153
  const body = await readJsonBody(req);
153
154
  const station = bodyField(body, 'station');
154
155
  if (isInteractiveStation(station)) {
155
156
  const view = await deps.attachSessions.start(
156
- { agentId },
157
+ owner,
157
158
  station,
158
159
  asInput(body),
159
160
  );
@@ -320,10 +321,12 @@ async function dispatchAttach(
320
321
  deps: AccountApiDeps,
321
322
  agentId: string,
322
323
  route: Extract<AccountRoute, { kind: 'start' | 'session' | 'step' }>,
324
+ session?: ApiSession,
323
325
  ): Promise<void> {
324
- if (route.kind === 'start') return handleStart(req, res, deps, agentId);
325
- if (route.kind === 'session') return handleSession(req, res, deps, { agentId }, route.attachId);
326
- return handleStep(req, res, deps, { agentId }, route.attachId);
326
+ const owner: AttachOwner = { agentId, organization: session?.subject, userId: session?.userId, sessionId: session?.sessionId, authorization: req.headers.authorization };
327
+ if (route.kind === 'start') return handleStart(req, res, deps, agentId, owner);
328
+ if (route.kind === 'session') return handleSession(req, res, deps, owner, route.attachId);
329
+ return handleStep(req, res, deps, owner, route.attachId);
327
330
  }
328
331
 
329
332
  async function dispatchRoute(
@@ -332,8 +335,9 @@ async function dispatchRoute(
332
335
  deps: AccountApiDeps,
333
336
  agentId: string,
334
337
  route: AccountRoute,
338
+ session?: ApiSession,
335
339
  ): Promise<void> {
336
- if (route.kind === 'start' || route.kind === 'session' || route.kind === 'step') return dispatchAttach(req, res, deps, agentId, route);
340
+ if (route.kind === 'start' || route.kind === 'session' || route.kind === 'step') return dispatchAttach(req, res, deps, agentId, route, session);
337
341
  if (route.kind === 'allowlist') return handleAllowlist(req, res, deps, agentId, route);
338
342
  if (route.kind === 'enabled') return handleEnabled(req, res, deps, agentId, route);
339
343
  if (route.kind === 'policy') return handlePolicy(req, res, deps, agentId, route);
@@ -348,9 +352,10 @@ export async function handleAccountRoute(
348
352
  deps: AccountApiDeps,
349
353
  agentId: string,
350
354
  route: AccountRoute,
355
+ session?: ApiSession,
351
356
  ): Promise<void> {
352
357
  try {
353
- await dispatchRoute(req, res, deps, agentId, route);
358
+ await dispatchRoute(req, res, deps, agentId, route, session);
354
359
  } catch (err) {
355
360
  apiFailure(req, res, err);
356
361
  }
@@ -25,6 +25,7 @@ export interface AgentApiDeps extends AccountApiDeps {
25
25
  capabilities: () => Record<string, string[]>;
26
26
  toolGroups?: () => Record<string, { name: string; group: string }[]>;
27
27
  attachable?: string[];
28
+ features?: string[];
28
29
  connectorIds: (agentIds: string[]) => Promise<Map<string, string[]>>;
29
30
  }
30
31
 
@@ -69,6 +70,7 @@ async function handleList(
69
70
  const base = {
70
71
  agents: list.map((a) => agentPayload(a, connectors)),
71
72
  capabilities: deps.capabilities(),
73
+ features: deps.features ?? [],
72
74
  attachable: deps.attachable ?? ATTACHABLE,
73
75
  ...(deps.toolGroups === undefined ? {} : { tools: deps.toolGroups() }),
74
76
  };
@@ -100,7 +102,7 @@ async function dispatch(
100
102
  sendJson(req, res, 401, { error: 'unauthorized' });
101
103
  return;
102
104
  }
103
- if (tgt.kind === 'accounts') await handleAccountRoute(req, res, deps, tgt.id, tgt.route);
105
+ if (tgt.kind === 'accounts') await handleAccountRoute(req, res, deps, tgt.id, tgt.route, session);
104
106
  else await handleList(req, res, deps);
105
107
  }
106
108
 
@@ -116,6 +116,7 @@ function assertUnclaimed(existing: Stored[], agent: LoadedAgent): void {
116
116
  }
117
117
 
118
118
  function assertImportable(agent: LoadedAgent): void {
119
+ if (agent.accounts.some((a) => a.station === 'gmail' && a.config.managed === true)) throw new AgentAdminError('Managed Gmail cannot be imported. Connect the mailbox again on this box.', 400);
119
120
  const immovable = agent.accounts.find((a) => !MOVABLE_STATIONS.has(a.station));
120
121
  if (immovable !== undefined)
121
122
  throw new AgentAdminError(
@@ -170,6 +171,7 @@ function mergedStations(
170
171
  const fromMetro = agent.accounts
171
172
  .map((a) => ({ ...a, allowlist: a.allowlist ?? ['*'] }))
172
173
  .filter((m) => mode === 'overwrite' || !here(m.station, m.id));
174
+ if (fromMetro.some((m) => m.station === 'gmail' && before.some((s) => s.station === 'gmail' && s.id === m.id && s.config.managed === true))) throw new AgentAdminError('Managed Gmail cannot be overwritten by an import. Delete the connection first.', 400);
173
175
  const kept = before.filter((s) => !fromMetro.some((m) => m.station === s.station && m.id === s.id));
174
176
  return [...fromMetro, ...kept];
175
177
  }
@@ -198,6 +200,16 @@ function fileFor(
198
200
  }
199
201
  }
200
202
 
203
+ export function localRenewGmail(agentId: string, accountId: string, previous: string, config: Record<string, unknown>, dir = agentsDir()): AccountRef {
204
+ const stored = agentOrThrow(agentId, dir);
205
+ const account = stored.file.stations.find((s) => s.station === 'gmail' && s.id === accountId);
206
+ if (account === undefined || (account.config.authorizationId ?? account.config.refreshToken) !== previous) throw new AgentAdminError('This Gmail connection changed. Start again.', 409);
207
+ if (account.config.accountEmail !== config.accountEmail) throw new AgentAdminError('The Gmail mailbox did not match. Nothing was changed.', 400);
208
+ account.config = { ...account.config, ...config };
209
+ save(stored);
210
+ return { agentId, station: 'gmail', accountId };
211
+ }
212
+
201
213
  export function readLocalAgentFile(agentId: string, dir = agentsDir()): AgentFile {
202
214
  const found = storedAgents(dir).find((s) => s.file.id === agentId);
203
215
  if (found === undefined) throw missing();
@@ -233,7 +245,7 @@ export async function localAttachAccount(
233
245
  assertCredentialFree(storedAgents(dir), station, config);
234
246
  const taken = new Set(stored.file.stations.map((a) => a.id));
235
247
  const accountId = freshId(taken);
236
- stored.file.stations.push({ station, id: accountId, allowlist: ['*'], enabled: true, config });
248
+ stored.file.stations.push({ station, id: accountId, allowlist: ['*'], enabled: true, ...(station === 'gmail' && config.sendEnabled === false ? { policy: { write: 'deny' as const } } : {}), config });
237
249
  save(stored);
238
250
  return Promise.resolve({ agentId, station, accountId });
239
251
  }
@@ -7,7 +7,8 @@ import { acquireLock, STATE_DIR, trainsDir } from './paths.js';
7
7
  import { installCrashGuard, markDaemonReady } from './crash-guard.js';
8
8
  import { METRO_VERSION } from '@metro-labs/core/version';
9
9
  import { funnelDriver, Tunnel, tunnelWanted } from '../net/tunnel.js';
10
- import { webhookPort } from '../files/attach-serve.js';
10
+ import { publicBaseUrl, webhookPort } from '../files/attach-serve.js';
11
+ import { setManagedHost } from '@metro-labs/gmail/managed-binding';
11
12
  import {
12
13
  localConnectHint,
13
14
  publicConnectHint,
@@ -80,7 +81,8 @@ supervisor.onTrainEvent((env, train) => {
80
81
  });
81
82
 
82
83
  let webhookServer: Server | null = null;
83
- const tunnel = tunnelWanted() ? new Tunnel(funnelDriver(webhookPort()), announcePublic) : null;
84
+ const syncGmailHost = (): void => { setManagedHost(publicBaseUrl()); };
85
+ const tunnel = tunnelWanted() ? new Tunnel(funnelDriver(webhookPort()), announcePublic, undefined, syncGmailHost) : null;
84
86
 
85
87
  function announcePublic(url: string): void {
86
88
  process.stderr.write(`\n${publicConnectHint(url, localOwner())}`);
@@ -140,6 +142,7 @@ installBearerSessions(agentsDir(), localOwner);
140
142
  log.info({ agent: await ensureLocalAgent() }, 'local daemon: agent');
141
143
  await materializeFrom(fileSource);
142
144
  forgetOrphans(knownAccounts());
145
+ syncGmailHost();
143
146
  supervisor.start();
144
147
  sweepLeftoverCalls();
145
148
  const metroMcp = await createMetroMcp({ liveEvents: liveEvents() });
@@ -160,6 +160,7 @@ const PRIOR_STAGE: ReadonlySet<string> = new Set([
160
160
  'f87e9494ea6151d140b742c1b88b4f435ae653559be641fddc2160cd24dd4e1b',
161
161
  'cb4307b016855c1a09d48bc8b3924a3080bd78b205963df3d6f20401d0486df9',
162
162
  'f2be51c5290d2e61b47f10c379fa45d051a59a6b3f06d99174c2a170074bc8f5',
163
+ '4c727aa110c9023371cfb364f3304ad9db22cdf2501208ef1e42e6ccc245b00f',
163
164
  ]);
164
165
  const PRIOR_MEMORY: ReadonlySet<string> = new Set(['08795399635c8cdec2b50cb4e336f89ae880f3997ae52560ef647a9cdc8f1b08']);
165
166
 
@@ -187,12 +187,18 @@ export class Tunnel {
187
187
  private driver: TunnelDriver,
188
188
  private onUrl: (url: string) => void = () => undefined,
189
189
  private resolves: Resolves = resolvesPublicly,
190
+ private onChange: () => void = () => undefined,
190
191
  ) {}
191
192
 
193
+ private setUrl(url: string | null): void {
194
+ liveUrl = url;
195
+ this.onChange();
196
+ }
197
+
192
198
  private noticeUrl(text: string): void {
193
199
  const url = this.driver.urlIn(text);
194
200
  if (url === null || url === liveUrl) return;
195
- liveUrl = url;
201
+ this.setUrl(url);
196
202
  this.restartDelay = RESTART_DELAY_MS;
197
203
  log.info({ url }, `${this.driver.name} up; waiting for its name to resolve`);
198
204
  this.announceWhenResolvable(url).catch((err: unknown) => {
@@ -240,7 +246,7 @@ export class Tunnel {
240
246
  }, SILENT_MS).unref();
241
247
  child.on('exit', (code) => {
242
248
  this.child = null;
243
- liveUrl = null;
249
+ this.setUrl(null);
244
250
  if (this.closed) return;
245
251
  this.afterExit(code).catch((err: unknown) => {
246
252
  log.warn({ err: errMsg(err) }, `${this.driver.name}: exit handling failed`);
@@ -289,7 +295,7 @@ export class Tunnel {
289
295
  if (misses < ADOPTED_MISSES) return;
290
296
  this.unwatch();
291
297
  log.warn({ url }, `${this.driver.name}: the Funnel this daemon adopted no longer answers; publishing our own`);
292
- liveUrl = null;
298
+ this.setUrl(null);
293
299
  this.restartDelay = RESTART_DELAY_MS;
294
300
  this.start();
295
301
  };
@@ -312,5 +318,6 @@ export class Tunnel {
312
318
  this.unwatch();
313
319
  this.child?.kill('SIGINT');
314
320
  this.child = null;
321
+ this.setUrl(null);
315
322
  }
316
323
  }
@@ -28,7 +28,7 @@ export function bearerSessionsFor(owner: () => string | null, keys: SigningKeys)
28
28
  const held = owner();
29
29
  if (held === null || session.organization === null || session.organization !== held)
30
30
  throw new ApiError('this machine belongs to another organization', 403);
31
- return { subject: held, role: session.role === 'admin' ? 'admin' : 'member', ...(isOperatorUser(session.userId) ? { operator: true as const } : {}) };
31
+ return { subject: held, userId: session.userId, sessionId: session.sessionId, role: session.role === 'admin' ? 'admin' : 'member', ...(isOperatorUser(session.userId) ? { operator: true as const } : {}) };
32
32
  };
33
33
  }
34
34
 
@@ -0,0 +1,67 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { ApiError } from '@metro-labs/http/api-error';
3
+ import { stringOf } from '@metro-labs/http/api-http';
4
+ import { publicBaseUrl } from '../files/attach-serve.js';
5
+ import { localOwner, readLocalAgentFile, localRenewGmail } from '../agents/file-admin.js';
6
+ import { startManagedGmail } from '../stations/attach-gmail.js';
7
+ import { startGmail } from '../stations/attach-browser.js';
8
+ import type { AttachOwner, StartAttach } from '../stations/attach-session.js';
9
+ import type { AttachOutcome, DriverHooks } from '../stations/attach-driver.js';
10
+ import { assertGmailNotDeleting } from './gmail-detach.js';
11
+
12
+ export function assertAttachOwner(owner: AttachOwner): void {
13
+ readLocalAgentFile(owner.agentId);
14
+ if (owner.organization !== undefined && owner.organization !== localOwner()) throw new ApiError('This box changed organization. Start again.', 403);
15
+ }
16
+
17
+ function gmailHost(): string {
18
+ const raw = publicBaseUrl();
19
+ const url = raw === null ? null : new URL(raw);
20
+ if (url === null || url.protocol !== 'https:' || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new ApiError('Managed Gmail needs this box registered in Metro with its HTTPS address.', 400);
21
+ return url.host;
22
+ }
23
+
24
+ interface Upgrade {
25
+ accountId: string;
26
+ previous: string;
27
+ }
28
+
29
+ function gmailInput(owner: AttachOwner, input: Record<string, unknown>): { fields: Record<string, unknown>; upgrade?: Upgrade } {
30
+ if (input.mode !== 'upgrade') return { fields: { ...input, sendEnabled: false } };
31
+ const accountId = stringOf(input.accountId);
32
+ assertGmailNotDeleting(owner.agentId, accountId);
33
+ const account = readLocalAgentFile(owner.agentId).stations.find((s) => s.station === 'gmail' && s.id === accountId);
34
+ if (account === undefined) throw new ApiError('No such Gmail connection.', 404);
35
+ if (account.config.sendEnabled !== false) throw new ApiError('Sending is already authorized for this Gmail connection.', 409);
36
+ return {
37
+ fields: { clientId: account.config.clientId, clientSecret: account.config.clientSecret, mailbox: account.config.accountEmail, sendEnabled: true, mode: account.config.managed === true ? 'managed' : 'byo' },
38
+ upgrade: { accountId, previous: stringOf(account.config.authorizationId ?? account.config.refreshToken) },
39
+ };
40
+ }
41
+
42
+ export const startGmailAttach: StartAttach = async (_station, input, hooks, owner) => {
43
+ assertAttachOwner(owner);
44
+ const { fields, upgrade } = gmailInput(owner, input);
45
+ const bound: DriverHooks = {
46
+ ...hooks,
47
+ done: (outcome: AttachOutcome) => {
48
+ hooks.done({ ...outcome, config: { ...outcome.config, authorizationId: randomUUID(), ...(upgrade === undefined ? {} : { gmailUpgrade: upgrade }) } });
49
+ },
50
+ };
51
+ if (fields.mode !== 'managed') return startGmail(fields, bound);
52
+ const host = gmailHost();
53
+ return startManagedGmail({ host, owner, mailbox: fields.mailbox, sendEnabled: fields.sendEnabled === true, check: () => {
54
+ assertAttachOwner(owner);
55
+ if (gmailHost() !== host) throw new ApiError('This box changed address. Start again.', 409);
56
+ } }, bound);
57
+ };
58
+
59
+ export function finishGmailUpgrade(owner: AttachOwner, config: Record<string, unknown>): ReturnType<typeof localRenewGmail> | null {
60
+ const value = config.gmailUpgrade;
61
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
62
+ const upgrade = value as Record<string, unknown>;
63
+ assertGmailNotDeleting(owner.agentId, stringOf(upgrade.accountId));
64
+ const saved = { ...config };
65
+ delete saved.gmailUpgrade;
66
+ return localRenewGmail(owner.agentId, stringOf(upgrade.accountId), stringOf(upgrade.previous), saved);
67
+ }
@@ -0,0 +1,37 @@
1
+ import { ApiError } from '@metro-labs/http/api-error';
2
+ import { localDetachAccount, localOwner, readLocalAgentFile } from '../agents/file-admin.js';
3
+ import { forwardTrainCall } from '../stations/train-call.js';
4
+
5
+ const deleting = new Set<string>();
6
+ const keyOf = (agentId: string, accountId: string): string => `${agentId}/${accountId}`;
7
+
8
+ export function assertGmailNotDeleting(agentId: string, accountId: string): void {
9
+ if (deleting.has(keyOf(agentId, accountId))) throw new ApiError('This Gmail connection is being deleted. Try again after it finishes.', 409);
10
+ }
11
+
12
+ async function revokeConnection(accountId: string, authorizationId: unknown): Promise<void> {
13
+ try {
14
+ const response = await forwardTrainCall('gmail', 'disconnect', { account: accountId, authorizationId });
15
+ const result = response.result as { revoked?: unknown } | undefined;
16
+ if (response.error !== undefined || result?.revoked !== true) throw new Error('revocation not confirmed');
17
+ } catch {
18
+ throw new ApiError('Google access could not be revoked. The Gmail channel was kept. Keep the Gmail service running and try deleting again.', 503);
19
+ }
20
+ }
21
+
22
+ export const detachWithGmailRevocation: typeof localDetachAccount = async (agentId, station, accountId, dir) => {
23
+ const config = station === 'gmail' ? readLocalAgentFile(agentId, dir).stations.find((a) => a.station === station && a.id === accountId)?.config : undefined;
24
+ if (config?.managed !== true) return localDetachAccount(agentId, station, accountId, dir);
25
+ assertGmailNotDeleting(agentId, accountId);
26
+ const key = keyOf(agentId, accountId);
27
+ const owner = localOwner(dir);
28
+ deleting.add(key);
29
+ try {
30
+ await revokeConnection(accountId, config.authorizationId);
31
+ const current = readLocalAgentFile(agentId, dir).stations.find((a) => a.station === station && a.id === accountId)?.config;
32
+ if (localOwner(dir) !== owner || current?.authorizationId !== config.authorizationId) throw new ApiError('This box or Gmail connection changed during deletion. Google access was revoked; review the connection before trying again.', 409);
33
+ return await localDetachAccount(agentId, station, accountId, dir);
34
+ } finally {
35
+ deleting.delete(key);
36
+ }
37
+ };
@@ -1,6 +1,9 @@
1
1
  import { ApiError } from '@metro-labs/http/api-error';
2
2
  import { errMsg, log } from '@metro-labs/core/log';
3
3
  import { AttachSessions } from '../stations/attach-session.js';
4
+ import { startInteractiveAttach } from '../stations/attach-interactive.js';
5
+ import { assertAttachOwner, finishGmailUpgrade, startGmailAttach } from './gmail-attach.js';
6
+ import { detachWithGmailRevocation } from './gmail-detach.js';
4
7
  import { recentSenders } from '../agents/senders.js';
5
8
  import { forwardTrainCall } from '../stations/train-call.js';
6
9
  import { syncPluginServers } from '../connectors/plugin-sync.js';
@@ -36,7 +39,6 @@ import { blockedReason } from '../connectors/gates.js';
36
39
  import {
37
40
  setLocalOwner,
38
41
  localAttachAccount,
39
- localDetachAccount,
40
42
  localSetAllowlist,
41
43
  localSetPolicy,
42
44
  localSetAccountEnabled,
@@ -64,11 +66,14 @@ export interface LocalModeDeps {
64
66
  function attachSessions(deps: LocalModeDeps): AttachSessions {
65
67
  return new AttachSessions({
66
68
  authorize: (owner) => {
67
- readLocalAgentFile(owner.agentId);
69
+ assertAttachOwner(owner);
68
70
  return Promise.resolve();
69
71
  },
70
- complete: async (owner, station, config) => {
71
- const ref = await localAttachAccount(owner.agentId, station, config);
72
+ start: (station, input, hooks, owner) => station === 'gmail' ? startGmailAttach(station, input, hooks, owner) : startInteractiveAttach(station, input, hooks),
73
+ complete: async (owner, station, config, active) => {
74
+ assertAttachOwner(owner);
75
+ if (!active()) throw new ApiError('This sign-in was cancelled or expired.', 409);
76
+ const ref = (station === 'gmail' ? finishGmailUpgrade(owner, config) : null) ?? await localAttachAccount(owner.agentId, station, config);
72
77
  const activated = await deps.syncStations(station).then(
73
78
  () => true,
74
79
  (err: unknown) => {
@@ -92,10 +97,11 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
92
97
  capabilities: deps.capabilities,
93
98
  ...(deps.toolGroups === undefined ? {} : { toolGroups: deps.toolGroups }),
94
99
  attachable: ATTACHABLE.filter((s) => s !== 'webhook'),
100
+ features: ['gmail-managed'],
95
101
  connectorIds: connectorIdsOfLocalAgents,
96
102
  prepareAccount: deps.prepareAccount,
97
103
  attachAccount: localAttachAccount,
98
- detachAccount: localDetachAccount,
104
+ detachAccount: detachWithGmailRevocation,
99
105
  syncStations: deps.syncStations,
100
106
  setAllowlist: localSetAllowlist,
101
107
  setPolicy: localSetPolicy,
@@ -151,7 +157,7 @@ function bundleApi(deps: LocalModeDeps): BundleApiDeps {
151
157
  const file = readLocalAgentFile(agentId);
152
158
  const bundle: AgentBundle = {
153
159
  version: 1,
154
- agent: { id: file.id, name: file.name ?? '', stations: file.stations },
160
+ agent: { id: file.id, name: file.name ?? '', stations: file.stations.filter((a) => a.station !== 'gmail' || a.config.managed !== true) },
155
161
  connectors: readLocalConnectors().map((c) => ({ id: c.id, name: c.name, url: c.url, config: { ...c.config } })),
156
162
  };
157
163
  return Promise.resolve(bundle);
@@ -34,6 +34,7 @@ function refused(err: unknown, provider: string): StationAttachError {
34
34
  }
35
35
 
36
36
  function browserAttach(login: BrowserLogin, hooks: DriverHooks, how: BrowserAttach): StartedAttach {
37
+ let cancelled = false;
37
38
  const finish = async (input: StepInput): Promise<void> => {
38
39
  const state = text(input.state);
39
40
  if (state !== login.state) throw new StationAttachError(OTHER_ATTEMPT, 400);
@@ -45,7 +46,8 @@ function browserAttach(login: BrowserLogin, hooks: DriverHooks, how: BrowserAtta
45
46
  const code = text(input.code);
46
47
  if (code === '') throw new StationAttachError(`${how.provider} did not send a sign-in code back`, 400);
47
48
  try {
48
- hooks.done(await login.finish(code, state));
49
+ const outcome = await login.finish(code, state);
50
+ if (!cancelled) hooks.done(outcome);
49
51
  } catch (err) {
50
52
  hooks.fail(said(err, `Metro could not finish the ${how.provider} sign-in.`));
51
53
  }
@@ -55,7 +57,10 @@ function browserAttach(login: BrowserLogin, hooks: DriverHooks, how: BrowserAtta
55
57
  prompt: { step: 'browser', prompt: how.prompt, authorizeUrl: login.authorizeUrl },
56
58
  expiresAt: Date.now() + BROWSER_TTL_MS,
57
59
  driver: {
58
- cancel: () => how.cancel?.() ?? Promise.resolve(),
60
+ cancel: () => {
61
+ cancelled = true;
62
+ return how.cancel?.() ?? Promise.resolve();
63
+ },
59
64
  submit: (input) => (input.mode === 'device' && device !== undefined ? device() : finish(input)),
60
65
  },
61
66
  };
@@ -94,7 +99,7 @@ export async function startGmail(input: Record<string, unknown>, hooks: DriverHo
94
99
  const { GmailBrowserLogin, failureOf } = await import('@metro-labs/gmail/login');
95
100
  let login;
96
101
  try {
97
- login = new GmailBrowserLogin({ clientId: input.clientId, clientSecret: input.clientSecret, mailbox: input.mailbox });
102
+ login = new GmailBrowserLogin({ clientId: input.clientId, clientSecret: input.clientSecret, mailbox: input.mailbox, sendEnabled: input.sendEnabled === true });
98
103
  } catch (err) {
99
104
  throw refused(err, 'Google');
100
105
  }
@@ -31,7 +31,7 @@ export interface StepInput {
31
31
  }
32
32
 
33
33
  export interface AttachDriver {
34
- submit: (input: StepInput) => Promise<void>;
34
+ submit: (input: StepInput, authorization?: string) => Promise<void>;
35
35
  cancel: () => Promise<void>;
36
36
  }
37
37
 
@@ -0,0 +1,78 @@
1
+ import { parseMailbox, pkcePair, SignInError } from '@metro-labs/core/stations/oauth';
2
+ import { managedRequest, managedTokens } from '@metro-labs/gmail/managed';
3
+ import { ApiError } from '@metro-labs/http/api-error';
4
+ import { stringOf } from '@metro-labs/http/api-http';
5
+ import type { AttachOwner } from './attach-session.js';
6
+ import type { AttachOutcome, DriverHooks, StartedAttach } from './attach-driver.js';
7
+
8
+ interface ManagedStart {
9
+ host: string;
10
+ owner: AttachOwner;
11
+ mailbox: unknown;
12
+ sendEnabled: boolean;
13
+ check: () => void;
14
+ }
15
+
16
+ function authorizationOf(owner: AttachOwner): string {
17
+ if (!owner.userId || !owner.sessionId || !owner.organization || !owner.authorization) throw new ApiError('Sign in to Metro again before connecting Gmail.', 401);
18
+ return owner.authorization;
19
+ }
20
+
21
+ function signInLink(started: Record<string, unknown>): { state: string; authorizeUrl: string; expiresAt: number } {
22
+ const state = stringOf(started.state);
23
+ const authorizeUrl = stringOf(started.authorizeUrl);
24
+ const url = new URL(authorizeUrl);
25
+ const expiresAt = started.expiresAt;
26
+ if (!state || url.origin !== 'https://accounts.google.com' || url.pathname !== '/o/oauth2/v2/auth' || url.searchParams.get('state') !== state || url.searchParams.get('redirect_uri') !== 'https://metro.box/' || typeof expiresAt !== 'number' || !Number.isFinite(expiresAt) || expiresAt <= Date.now()) throw new SignInError('Metro returned an invalid Gmail sign-in link.');
27
+ return { state, authorizeUrl, expiresAt };
28
+ }
29
+
30
+ function outcomeOf(result: Record<string, unknown>, input: ManagedStart, mailbox: string | null): AttachOutcome {
31
+ const accountEmail = parseMailbox(result.accountEmail);
32
+ if (accountEmail === null || (mailbox !== null && accountEmail !== mailbox) || result.managed !== true || result.managedHost !== input.host || result.sendEnabled !== input.sendEnabled) throw new SignInError('Metro returned a Gmail connection for a different mailbox or access level.');
33
+ return { config: { ...managedTokens(result), accountEmail, managed: true, managedHost: input.host, managedOrganization: input.owner.organization, sendEnabled: input.sendEnabled }, identity: { email: accountEmail } };
34
+ }
35
+
36
+ export async function startManagedGmail(input: ManagedStart, hooks: DriverHooks): Promise<StartedAttach> {
37
+ const { host, owner, sendEnabled, check } = input;
38
+ const bearer = authorizationOf(owner);
39
+ const pair = pkcePair();
40
+ const mailbox = parseMailbox(input.mailbox);
41
+ check();
42
+ const started = await managedRequest('start', { host, agentId: owner.agentId, challenge: pair.challenge, ...(mailbox === null ? {} : { mailbox }), sendEnabled }, bearer);
43
+ check();
44
+ const { state, authorizeUrl, expiresAt } = signInLink(started);
45
+ let cancelled = false;
46
+ let used = false;
47
+ return {
48
+ expiresAt,
49
+ prompt: { step: 'browser', prompt: sendEnabled ? 'Approve sending for this mailbox on Google. Metro write permissions stay unchanged.' : 'Choose a Google account and approve read-only Gmail access. Metro cannot send mail with this connection.', authorizeUrl },
50
+ driver: {
51
+ cancel: async () => {
52
+ cancelled = true;
53
+ await managedRequest('cancel', { state, host, agentId: owner.agentId }, bearer);
54
+ },
55
+ submit: async (value, authorization) => {
56
+ if (cancelled || used || Date.now() >= expiresAt) throw new ApiError('This sign-in is finished or expired. Start again.', 409);
57
+ if (stringOf(value.state) !== state) throw new ApiError('This sign-in belongs to another attempt.', 400);
58
+ if (!authorization) throw new ApiError('Sign in to Metro again.', 401);
59
+ used = true;
60
+ try {
61
+ if (stringOf(value.error)) {
62
+ await managedRequest('cancel', { state, host, agentId: owner.agentId }, authorization);
63
+ hooks.fail('Google sign-in was declined or blocked. Nothing was connected.');
64
+ return;
65
+ }
66
+ const code = stringOf(value.code);
67
+ if (!code) throw new ApiError('Google did not return a sign-in code.', 400);
68
+ check();
69
+ const result = await managedRequest('exchange', { state, code, verifier: pair.verifier, host, agentId: owner.agentId }, authorization);
70
+ check();
71
+ if (!cancelled) hooks.done(outcomeOf(result, input, mailbox));
72
+ } catch {
73
+ hooks.fail('Metro could not finish this Gmail sign-in. Start again.');
74
+ }
75
+ },
76
+ },
77
+ };
78
+ }
@@ -18,6 +18,7 @@ export type StartAttach = (
18
18
  station: InteractiveStation,
19
19
  input: Record<string, unknown>,
20
20
  hooks: DriverHooks,
21
+ owner: AttachOwner,
21
22
  ) => Promise<StartedAttach>;
22
23
 
23
24
  export const ATTACH_ID_RE = /^as_[A-Za-z0-9_-]{22}$/;
@@ -30,6 +31,10 @@ const MAX_TOTAL = 40;
30
31
 
31
32
  export interface AttachOwner {
32
33
  agentId: string;
34
+ organization?: string;
35
+ userId?: string;
36
+ sessionId?: string;
37
+ authorization?: string;
33
38
  }
34
39
 
35
40
  export interface AttachView {
@@ -54,6 +59,7 @@ type CompleteAttach = (
54
59
  owner: AttachOwner,
55
60
  station: StationName,
56
61
  config: Record<string, unknown>,
62
+ active: () => boolean,
57
63
  ) => Promise<{ accountId: string; activated: boolean }>;
58
64
 
59
65
  type AuthorizeAttach = (owner: AttachOwner) => Promise<void>;
@@ -68,6 +74,8 @@ interface Session {
68
74
  owner: AttachOwner;
69
75
  driver: AttachDriver | null;
70
76
  view: AttachView;
77
+ finishing?: boolean;
78
+ committing?: boolean;
71
79
  }
72
80
 
73
81
  const missing = (): ApiError => new ApiError('no such attach session', 404);
@@ -114,7 +122,8 @@ export class AttachSessions {
114
122
 
115
123
  private own(owner: AttachOwner, attachId: string): Session {
116
124
  const session = this.sessions.get(attachId);
117
- if (session?.owner.agentId !== owner.agentId) throw missing();
125
+ if (session === undefined || session.owner.agentId !== owner.agentId || session.owner.organization !== owner.organization || session.owner.userId !== owner.userId || session.owner.sessionId !== owner.sessionId) throw missing();
126
+ if (session.view.expiresAt <= Date.now()) throw new ApiError('this sign-in expired; start again', 410);
118
127
  return session;
119
128
  }
120
129
 
@@ -153,12 +162,21 @@ export class AttachSessions {
153
162
  outcome: AttachOutcome,
154
163
  ): Promise<void> {
155
164
  const session = this.sessions.get(attachId);
156
- if (session?.view.status !== 'pending') return;
165
+ const active = (): boolean => session !== undefined && this.sessions.get(attachId) === session && session.view.status === 'pending' && session.view.expiresAt > Date.now();
166
+ if (!active() || session === undefined || session.finishing === true) return;
167
+ session.finishing = true;
157
168
  try {
169
+ await this.deps.authorize(session.owner);
170
+ if (!active()) return;
158
171
  const landed = await this.deps.complete(
159
172
  session.owner,
160
173
  station,
161
174
  outcome.config,
175
+ () => {
176
+ if (!active()) return false;
177
+ session.committing = true;
178
+ return true;
179
+ },
162
180
  );
163
181
  this.settle(session, {
164
182
  status: 'done',
@@ -229,7 +247,12 @@ export class AttachSessions {
229
247
  station,
230
248
  input,
231
249
  this.hooksFor(attachId, station),
250
+ owner,
232
251
  );
252
+ if (this.sessions.get(attachId) !== session || session.view.expiresAt <= Date.now()) {
253
+ await started.driver.cancel();
254
+ throw new ApiError('this sign-in expired or stopped; start again', 410);
255
+ }
233
256
  session.driver = started.driver;
234
257
  if (started.expiresAt !== undefined) session.view.expiresAt = started.expiresAt;
235
258
  this.hooksFor(attachId, station).prompt(started.prompt);
@@ -257,12 +280,15 @@ export class AttachSessions {
257
280
  const driver = session.driver;
258
281
  if (session.view.status !== 'pending' || !driver)
259
282
  throw new ApiError('this sign-in is already finished', 409);
260
- await driver.submit(input);
283
+ await this.deps.authorize(owner);
284
+ this.own(owner, attachId);
285
+ await driver.submit(input, owner.authorization);
261
286
  return { ...session.view };
262
287
  }
263
288
 
264
289
  async cancel(owner: AttachOwner, attachId: string): Promise<void> {
265
290
  const session = this.own(owner, attachId);
291
+ if (session.committing === true) throw new ApiError('this sign-in has already been saved', 409);
266
292
  this.sessions.delete(attachId);
267
293
  await session.driver?.cancel().catch(() => undefined);
268
294
  log.info({ attachId }, 'attach-session: cancelled');
@@ -270,7 +296,7 @@ export class AttachSessions {
270
296
 
271
297
  async sweep(now = Date.now()): Promise<void> {
272
298
  for (const [attachId, session] of [...this.sessions]) {
273
- if (session.view.expiresAt > now) continue;
299
+ if (session.view.expiresAt > now || (session.committing === true && session.view.status === 'pending')) continue;
274
300
  this.sessions.delete(attachId);
275
301
  const driver = session.driver;
276
302
  session.driver = null;
@@ -6,7 +6,9 @@
6
6
  "exports": {
7
7
  ".": "./src/station.ts",
8
8
  "./train": "./src/index.ts",
9
- "./login": "./src/login.ts"
9
+ "./login": "./src/login.ts",
10
+ "./managed": "./src/managed.ts",
11
+ "./managed-binding": "./src/managed-binding.ts"
10
12
  },
11
13
  "scripts": {
12
14
  "build": "tsc",
@@ -3,13 +3,18 @@ import { threadOfLine } from '@metro-labs/core/stations/mail';
3
3
  import { TokenKeeper, type FetchLike } from '@metro-labs/core/stations/oauth';
4
4
  import { TrainError } from '@metro-labs/core/train-error';
5
5
  import { refreshTokens } from './auth.js';
6
+ import { renewManaged, revokeManaged } from './managed.js';
7
+ import { checkManagedBinding, type ManagedBinding } from './managed-binding.js';
6
8
  import { loadState, saveState, type AccountState } from './state.js';
7
9
 
8
- export interface AccountConfig {
10
+ export interface AccountConfig extends ManagedBinding {
9
11
  id: string;
10
12
  accountEmail: string;
11
- clientId: string;
12
- clientSecret: string;
13
+ clientId?: string;
14
+ clientSecret?: string;
15
+ refreshGrant?: string;
16
+ authorizationId?: string;
17
+ sendEnabled?: boolean;
13
18
  refreshToken: string;
14
19
  accessToken?: string;
15
20
  expiresAt?: number;
@@ -18,9 +23,15 @@ export interface AccountConfig {
18
23
 
19
24
  const filled = (v: unknown): boolean => typeof v === 'string' && v !== '';
20
25
 
26
+ function checkClient(a: AccountConfig, die: Die): void {
27
+ if (a.managed === true) {
28
+ if (!filled(a.refreshGrant) || !filled(a.managedHost) || !filled(a.managedOrganization) || !filled(a.authorizationId) || typeof a.sendEnabled !== 'boolean') die(`account '${a.id}' has an incomplete managed sign-in`);
29
+ } else if (!filled(a.clientId) || !filled(a.clientSecret)) die(`account '${a.id}' has no OAuth client`);
30
+ }
31
+
21
32
  function checkAccount(a: AccountConfig, die: Die): void {
22
33
  if (!filled(a.accountEmail) || !a.accountEmail.includes('@')) die(`account '${a.id}' has no mailbox address`);
23
- if (!filled(a.clientId) || !filled(a.clientSecret)) die(`account '${a.id}' has no OAuth client`);
34
+ checkClient(a, die);
24
35
  if (!filled(a.refreshToken)) die(`account '${a.id}' has no refresh token`);
25
36
  }
26
37
 
@@ -39,15 +50,24 @@ export class Account {
39
50
  readonly email: string;
40
51
  readonly state: AccountState;
41
52
  private readonly keeper: TokenKeeper;
53
+ private readonly pendingTokens = new Set<Promise<string>>();
54
+ private closing = false;
55
+ private revoked = false;
42
56
 
43
57
  constructor(
44
58
  readonly cfg: AccountConfig,
45
59
  private readonly fetchImpl: FetchLike = (input, init) => fetch(input, init),
46
60
  ) {
47
61
  this.email = cfg.accountEmail.toLowerCase();
48
- this.state = loadState(cfg.id, { refreshToken: cfg.refreshToken, accessToken: cfg.accessToken ?? '', expiresAt: cfg.expiresAt ?? 0 });
49
- const client = { clientId: cfg.clientId, clientSecret: cfg.clientSecret };
50
- this.keeper = new TokenKeeper(this.state, (refreshToken) => refreshTokens(client, refreshToken, this.fetchImpl).catch(signedOut), () => {
62
+ this.state = loadState(cfg.id, cfg);
63
+ const client = { clientId: cfg.clientId ?? '', clientSecret: cfg.clientSecret ?? '' };
64
+ this.keeper = new TokenKeeper(this.state, async (refreshToken) => {
65
+ checkManagedBinding(cfg);
66
+ const tokens = await (cfg.managed === true
67
+ ? renewManaged(refreshToken, this.state.refreshGrant ?? '', cfg.managedHost ?? '', this.fetchImpl)
68
+ : refreshTokens(client, refreshToken, this.fetchImpl, Date.now(), cfg.sendEnabled)).catch(signedOut);
69
+ return tokens;
70
+ }, () => {
51
71
  this.save();
52
72
  });
53
73
  }
@@ -60,12 +80,44 @@ export class Account {
60
80
  saveState(this.cfg.id, this.state);
61
81
  }
62
82
 
63
- token(force = false): Promise<string> {
64
- return this.keeper.token(force);
83
+ check(): void {
84
+ if (this.closing) throw new TrainError('gmail_disconnecting', 'This Gmail connection is disconnecting.', { retryable: false });
85
+ checkManagedBinding(this.cfg);
86
+ }
87
+
88
+ async token(force = false): Promise<string> {
89
+ this.check();
90
+ const pending = this.keeper.token(force);
91
+ this.pendingTokens.add(pending);
92
+ try {
93
+ const token = await pending;
94
+ this.check();
95
+ return token;
96
+ } finally {
97
+ this.pendingTokens.delete(pending);
98
+ }
99
+ }
100
+
101
+ async disconnect(authorizationId: unknown): Promise<void> {
102
+ if (this.cfg.managed !== true || !authorizationId || authorizationId !== this.cfg.authorizationId) throw new TrainError('gmail_connection_changed', 'This Gmail connection changed. Try deleting again.', { retryable: false });
103
+ if (this.revoked) return;
104
+ if (this.closing) throw new TrainError('gmail_disconnecting', 'This Gmail connection is disconnecting.', { retryable: false });
105
+ this.closing = true;
106
+ try {
107
+ await Promise.allSettled([...this.pendingTokens]);
108
+ await revokeManaged(this.state.refreshToken, this.state.refreshGrant ?? '', this.cfg.managedHost ?? '', this.fetchImpl);
109
+ this.revoked = true;
110
+ } catch (err) {
111
+ this.closing = false;
112
+ throw err;
113
+ }
65
114
  }
66
115
 
67
- fetch(input: string, init?: RequestInit): Promise<Response> {
68
- return this.fetchImpl(input, init);
116
+ async fetch(input: string, init?: RequestInit): Promise<Response> {
117
+ this.check();
118
+ const result = await this.fetchImpl(input, init);
119
+ this.check();
120
+ return result;
69
121
  }
70
122
  }
71
123
 
@@ -1,14 +1,19 @@
1
1
  import { makeStation, respond } from '@metro-labs/core/stations/station-runtime';
2
- import { accounts } from './accounts.js';
2
+ import { accountOf, accounts } from './accounts.js';
3
3
  import { MAILBOX_URL } from './config.js';
4
4
  import { reply, send } from './outbound.js';
5
5
  import { read } from './query.js';
6
6
 
7
7
  function listAccounts(id: string): void {
8
- const list = [...accounts.values()].map((a) => ({ id: a.id, handle: a.email, url: `${MAILBOX_URL}mail/u/${a.email}/`, email: a.email }));
8
+ const list = [...accounts.values()].map((a) => ({ id: a.id, handle: a.email, url: `${MAILBOX_URL}mail/u/${a.email}/`, email: a.email, managed: a.cfg.managed === true, ...(a.cfg.sendEnabled === undefined ? {} : { sendEnabled: a.cfg.sendEnabled }) }));
9
9
  respond(id, { result: { accounts: list } });
10
10
  }
11
11
 
12
+ async function disconnect(id: string, args: Record<string, unknown>): Promise<void> {
13
+ await accountOf(args).disconnect(args.authorizationId);
14
+ respond(id, { result: { revoked: true } });
15
+ }
16
+
12
17
  export const handleCall = makeStation({
13
- handlers: { accounts: listAccounts, send, reply, read },
18
+ handlers: { accounts: listAccounts, send, reply, read, disconnect },
14
19
  });
@@ -21,11 +21,14 @@ export async function gmail(acct: Account, path: string, init: RequestInit = {})
21
21
  if (res.status === 401) res = await call(acct, path, init, true);
22
22
  if (res.ok) return res;
23
23
  const detail = await googleMessage(res);
24
+ acct.check();
24
25
  throw res.status === 404 && path.startsWith(`${USER}/history`) ? new HistoryGone(detail) : refusalOf(GMAIL, res.status, detail);
25
26
  }
26
27
 
27
28
  export async function gmailJson<T>(acct: Account, path: string, init: RequestInit = {}): Promise<T> {
28
- return (await (await gmail(acct, path, init)).json()) as T;
29
+ const body = (await (await gmail(acct, path, init)).json()) as T;
30
+ acct.check();
31
+ return body;
29
32
  }
30
33
 
31
34
  export const queryOf = (params: [string, string][]): string => new URLSearchParams(params).toString();
@@ -20,7 +20,10 @@ export async function saveFile(acct: Account, messageId: string, file: MailFile,
20
20
  if (file.size !== undefined) assertAttachmentSize(file.size);
21
21
  const data = file.data ?? (file.attachmentId === null ? null : await attachmentData(acct, messageId, file.attachmentId));
22
22
  if (data === null) throw new TrainError('gmail_file_missing', `Gmail gave no content for ${file.name}`, { retryable: false });
23
- return saveBufferToCache(new Uint8Array(Buffer.from(data, 'base64url')), messageId, index, { mime: file.mime, name: file.name });
23
+ acct.check();
24
+ const saved = await saveBufferToCache(new Uint8Array(Buffer.from(data, 'base64url')), messageId, index, { mime: file.mime, name: file.name });
25
+ acct.check();
26
+ return saved;
24
27
  }
25
28
 
26
29
  export async function assertSendable(files: OutgoingFile[]): Promise<void> {
@@ -1,5 +1,12 @@
1
1
  import { postForm, SignInError, tokensOf, type FetchLike, type OAuthBody, type Tokens } from '@metro-labs/core/stations/oauth';
2
- import { apiBase, authorizeBase, redirectUri, SCOPES, tokenUrl } from './config.js';
2
+ import { apiBase, authorizeBase, redirectUri, scopesFor, tokenUrl } from './config.js';
3
+
4
+ export function checkScopes(scope: unknown, sendEnabled: boolean): void {
5
+ const granted = typeof scope === 'string' ? scope.split(/\s+/).filter(Boolean) : [];
6
+ const wanted = scopesFor(sendEnabled).split(' ');
7
+ if (wanted.some((s) => !granted.includes(s)) || granted.some((s) => !wanted.includes(s)))
8
+ throw new SignInError('Google did not grant exactly the requested Gmail access. Review this app in your Google Account, then connect again.');
9
+ }
3
10
 
4
11
  export interface GmailClient {
5
12
  clientId: string;
@@ -30,13 +37,14 @@ export function failureOf(body: OAuthBody): string {
30
37
  return detail === '' ? `Google refused the sign-in (${error === '' ? 'no reason given' : error}).` : `Google refused the sign-in: ${detail}`;
31
38
  }
32
39
 
33
- export function authorizeUrl(client: GmailClient, challenge: string, state: string, loginHint: string | null): string {
40
+ export function authorizeUrl(client: GmailClient, challenge: string, state: string, loginHint: string | null, sendEnabled = false): string {
34
41
  const query = new URLSearchParams({
35
42
  ...(loginHint === null ? {} : { login_hint: loginHint }),
36
43
  client_id: client.clientId,
37
44
  redirect_uri: redirectUri(),
38
45
  response_type: 'code',
39
- scope: SCOPES,
46
+ scope: scopesFor(sendEnabled),
47
+ ...(sendEnabled ? { include_granted_scopes: 'true' } : {}),
40
48
  state,
41
49
  code_challenge: challenge,
42
50
  code_challenge_method: 'S256',
@@ -49,7 +57,7 @@ export function authorizeUrl(client: GmailClient, challenge: string, state: stri
49
57
  const post = (fields: Record<string, string>, fetchImpl: FetchLike): Promise<{ status: number; body: OAuthBody }> =>
50
58
  postForm(tokenUrl(), fields, fetchImpl, 'Google');
51
59
 
52
- export async function redeemCode(client: GmailClient, code: string, verifier: string, fetchImpl: FetchLike, now = Date.now()): Promise<Tokens> {
60
+ export async function redeemCode(client: GmailClient, code: string, verifier: string, fetchImpl: FetchLike, now = Date.now(), sendEnabled = false): Promise<Tokens> {
53
61
  const { status, body } = await post(
54
62
  {
55
63
  grant_type: 'authorization_code',
@@ -62,15 +70,17 @@ export async function redeemCode(client: GmailClient, code: string, verifier: st
62
70
  fetchImpl,
63
71
  );
64
72
  if (status !== 200) throw new SignInError(failureOf(body));
73
+ checkScopes(body.scope, sendEnabled);
65
74
  return tokensOf(body, now, 'Google');
66
75
  }
67
76
 
68
- export async function refreshTokens(client: GmailClient, refreshToken: string, fetchImpl: FetchLike, now = Date.now()): Promise<Tokens> {
77
+ export async function refreshTokens(client: GmailClient, refreshToken: string, fetchImpl: FetchLike, now = Date.now(), sendEnabled?: boolean): Promise<Tokens> {
69
78
  const { status, body } = await post(
70
79
  { grant_type: 'refresh_token', refresh_token: refreshToken, client_id: client.clientId, client_secret: client.clientSecret },
71
80
  fetchImpl,
72
81
  );
73
82
  if (status !== 200) throw new SignInError(`Google refused to renew this mailbox's sign-in, so connect Gmail again. ${failureOf(body)}`);
83
+ if (sendEnabled !== undefined) checkScopes(body.scope, sendEnabled);
74
84
  return tokensOf(body, now, 'Google', refreshToken);
75
85
  }
76
86
 
@@ -1,6 +1,8 @@
1
1
  import { envUrl, envValue } from '@metro-labs/core/stations/oauth';
2
2
 
3
- export const SCOPES = ['https://www.googleapis.com/auth/gmail.readonly', 'https://www.googleapis.com/auth/gmail.send'].join(' ');
3
+ export const READ_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly';
4
+ export const SEND_SCOPE = 'https://www.googleapis.com/auth/gmail.send';
5
+ export const scopesFor = (sendEnabled = false): string => [READ_SCOPE, ...(sendEnabled ? [SEND_SCOPE] : [])].join(' ');
4
6
 
5
7
  export const MAILBOX_URL = 'https://mail.google.com/';
6
8
 
@@ -46,6 +46,7 @@ async function deliver(acct: Account, messageId: string): Promise<boolean> {
46
46
  }
47
47
  const files = mailFilesOf(m);
48
48
  const env = mailEnvelope('gmail', acct.id, acct.email, item, files.map(metaOf), verdict.verified);
49
+ acct.check();
49
50
  emitInbound(acct.id, env);
50
51
  const at = { station: 'gmail', account: acct.id, line: String(env.line), forId: String(env.id) };
51
52
  for (const [index, file] of files.entries())
@@ -12,6 +12,7 @@ export interface GmailLoginDeps {
12
12
  clientId?: unknown;
13
13
  clientSecret?: unknown;
14
14
  mailbox?: unknown;
15
+ sendEnabled?: boolean;
15
16
  fetch?: FetchLike;
16
17
  now?: () => number;
17
18
  }
@@ -22,13 +23,14 @@ export class GmailBrowserLogin extends BrowserSignIn<GmailLoginResult> {
22
23
  const mailbox = parseMailbox(deps.mailbox);
23
24
  const fetchImpl: FetchLike = deps.fetch ?? ((input, init) => fetch(input, init));
24
25
  const now = deps.now ?? Date.now;
26
+ const sendEnabled = deps.sendEnabled === true;
25
27
  super({
26
- authorize: (challenge, state) => authorizeUrl(client, challenge, state, mailbox),
28
+ authorize: (challenge, state) => authorizeUrl(client, challenge, state, mailbox, sendEnabled),
27
29
  redeem: async (code, verifier) => {
28
- const tokens = await redeemCode(client, code, verifier, fetchImpl, now());
30
+ const tokens = await redeemCode(client, code, verifier, fetchImpl, now(), sendEnabled);
29
31
  const email = await verifyMailbox(tokens.accessToken, fetchImpl, mailbox);
30
32
  return {
31
- config: { accountEmail: email, ...client, ...tokens, createdAt: new Date(now()).toISOString() },
33
+ config: { accountEmail: email, ...client, ...tokens, sendEnabled, createdAt: new Date(now()).toISOString() },
32
34
  identity: { email },
33
35
  };
34
36
  },
@@ -0,0 +1,33 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { ensureSecureDir, writeSecure } from '@metro-labs/core/secure-fs';
5
+ import { TrainError } from '@metro-labs/core/train-error';
6
+
7
+ export interface ManagedBinding {
8
+ managed?: boolean;
9
+ managedOrganization?: string;
10
+ managedHost?: string;
11
+ }
12
+
13
+ const bindingDir = (): string => process.env.METRO_AGENTS_DIR ?? join(homedir(), '.metro', 'agents');
14
+
15
+ export function setManagedHost(url: string | null): void {
16
+ const dir = bindingDir();
17
+ ensureSecureDir(dir);
18
+ writeSecure(join(dir, '.gmail-host'), url === null ? '' : new URL(url).host);
19
+ }
20
+
21
+ export function checkManagedBinding(config: ManagedBinding): void {
22
+ if (config.managed !== true) return;
23
+ let owner = '';
24
+ let host = '';
25
+ try {
26
+ owner = readFileSync(join(bindingDir(), '.owner'), 'utf8').trim();
27
+ host = readFileSync(join(bindingDir(), '.gmail-host'), 'utf8').trim();
28
+ } catch {
29
+ throw new TrainError('gmail_binding_changed', 'Reconnect managed Gmail on this box before using it.', { retryable: false });
30
+ }
31
+ if (!owner || owner !== config.managedOrganization || !host || host !== config.managedHost)
32
+ throw new TrainError('gmail_binding_changed', 'This box changed owner or address. Reconnect managed Gmail before using it.', { retryable: false });
33
+ }
@@ -0,0 +1,45 @@
1
+ import { SignInError, type FetchLike, type Tokens } from '@metro-labs/core/stations/oauth';
2
+
3
+ const BASE = 'https://api.metro.box/api/gmail';
4
+ const reach: FetchLike = (input, init) => fetch(input, init);
5
+
6
+ export interface ManagedTokens extends Tokens {
7
+ refreshGrant: string;
8
+ }
9
+
10
+ export async function managedRequest(path: 'start' | 'exchange' | 'cancel' | 'refresh' | 'revoke', body: Record<string, unknown>, bearer?: string, fetchImpl = reach): Promise<Record<string, unknown>> {
11
+ let response: Response;
12
+ try {
13
+ response = await fetchImpl(`${BASE}/${path}`, {
14
+ method: 'POST',
15
+ headers: { 'content-type': 'application/json', ...(bearer === undefined ? {} : { authorization: bearer }) },
16
+ body: JSON.stringify(body),
17
+ redirect: 'error',
18
+ signal: AbortSignal.timeout(25_000),
19
+ });
20
+ } catch {
21
+ throw new SignInError('Metro could not reach managed Gmail sign-in. Try again.');
22
+ }
23
+ if (!response.ok) {
24
+ const retry = response.status >= 500 ? 'Try again later.' : 'Start Gmail sign-in again. Your Google Workspace administrator may need to allow the app.';
25
+ throw new SignInError(`Metro could not complete managed Gmail sign-in. ${retry}`);
26
+ }
27
+ const value: unknown = await response.json().catch(() => null);
28
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new SignInError('Metro returned an invalid Gmail answer.');
29
+ return value as Record<string, unknown>;
30
+ }
31
+
32
+ export function managedTokens(value: Record<string, unknown>): ManagedTokens {
33
+ if (typeof value.accessToken !== 'string' || !value.accessToken || typeof value.refreshToken !== 'string' || !value.refreshToken || typeof value.refreshGrant !== 'string' || !value.refreshGrant || typeof value.expiresAt !== 'number' || !Number.isFinite(value.expiresAt))
34
+ throw new SignInError('Metro returned an incomplete Gmail sign-in.');
35
+ return { accessToken: value.accessToken, refreshToken: value.refreshToken, refreshGrant: value.refreshGrant, expiresAt: value.expiresAt };
36
+ }
37
+
38
+ export async function renewManaged(refreshToken: string, refreshGrant: string, host: string, fetchImpl = reach): Promise<ManagedTokens> {
39
+ return managedTokens(await managedRequest('refresh', { refreshToken, refreshGrant, host }, undefined, fetchImpl));
40
+ }
41
+
42
+ export async function revokeManaged(refreshToken: string, refreshGrant: string, host: string, fetchImpl = reach): Promise<void> {
43
+ const result = await managedRequest('revoke', { refreshToken, refreshGrant, host }, undefined, fetchImpl);
44
+ if (result.revoked !== true) throw new SignInError('Metro did not confirm Google revocation. Try again.');
45
+ }
@@ -74,6 +74,7 @@ async function outgoing(args: Args): Promise<Outgoing> {
74
74
  const parsed = threadOfLine('gmail', line);
75
75
  if (parsed === null) throw new TrainError('gmail_bad_line', `not a valid gmail line: ${line}`, { retryable: false });
76
76
  const acct = accountOf(args);
77
+ if (acct.cfg.sendEnabled === false) throw new TrainError('gmail_read_only', 'This Gmail connection is read-only. Allow sending with Google in channel settings, then choose a Metro write policy.', { retryable: false });
77
78
  const files = filesOf(args.attachments);
78
79
  const text = typeof args.text === 'string' ? args.text : '';
79
80
  if (text.trim() === '' && files.length === 0) throw new TrainError('gmail_text_required', 'give some text or a file to send', { retryable: false });
@@ -69,6 +69,7 @@ async function searched(acct: Account, q: ReadQuery, beforeAt: string): Promise<
69
69
  async function list(id: string, acct: Account, q: ReadQuery): Promise<void> {
70
70
  const beforeAt = await receivedAt(acct, q.before);
71
71
  const found = q.threadId !== null && q.query === '' ? await inThread(acct, q, q.threadId, beforeAt) : await searched(acct, q, beforeAt);
72
+ acct.check();
72
73
  respond(id, {
73
74
  result: {
74
75
  account: acct.id,
@@ -85,6 +86,7 @@ async function one(id: string, acct: Account, messageId: string): Promise<void>
85
86
  const saved = await saveFile(acct, m.id, file, index);
86
87
  attachments.push({ name: file.name, mime: file.mime, kind: file.kind, size: saved.bytes, local_path: saved.path });
87
88
  }
89
+ acct.check();
88
90
  respond(id, { result: { account: acct.id, message: { ...mailFull('gmail', acct.id, itemOf(m)), attachments } } });
89
91
  }
90
92
 
@@ -4,16 +4,22 @@ import { seenOf } from '@metro-labs/core/stations/mail';
4
4
  import { tokenStateOf, type Tokens } from '@metro-labs/core/stations/oauth';
5
5
 
6
6
  export interface AccountState extends Tokens {
7
+ refreshGrant?: string;
8
+ authorizationId?: string;
7
9
  historyId: string | null;
8
10
  seen: string[];
9
11
  }
10
12
 
11
13
  export const stateFiles = accountFiles('GMAIL_STATE_DIR', 'gmail-state-');
12
14
 
13
- export function loadState(accountId: string, seed: Partial<Tokens>): AccountState {
15
+ export function loadState(accountId: string, seed: Partial<AccountState>): AccountState {
14
16
  const saved = readJson<Record<string, unknown>>(stateFiles.path(accountId), {});
17
+ const same = saved.authorizationId === seed.authorizationId;
18
+ const held = same ? saved : {};
15
19
  return {
16
- ...tokenStateOf(saved, seed),
20
+ ...tokenStateOf(held, seed),
21
+ refreshGrant: typeof held.refreshGrant === 'string' ? held.refreshGrant : seed.refreshGrant,
22
+ authorizationId: seed.authorizationId,
17
23
  historyId: typeof saved.historyId === 'string' && saved.historyId !== '' ? saved.historyId : null,
18
24
  seen: seenOf(saved.seen),
19
25
  };
@@ -8,6 +8,8 @@ export type Role = 'admin' | 'member';
8
8
 
9
9
  export interface ApiSession {
10
10
  subject: string;
11
+ userId?: string;
12
+ sessionId?: string;
11
13
  role: Role;
12
14
  operator?: true;
13
15
  }
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.271"
2
+ "version": "0.1.0-beta.273"
3
3
  }
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
2
2
 
3
3
  export interface TicketStore<T> {
4
4
  mint: (value: T, now?: number) => { ticket: string; expiresAt: number };
5
+ peek: (ticket: string, now?: number) => T | undefined;
5
6
  take: (ticket: string, now?: number) => T | undefined;
6
7
  size: (now?: number) => number;
7
8
  }
@@ -24,6 +25,12 @@ export function ticketStore<T>(ttlMs: number, max: number): TicketStore<T> {
24
25
  open.set(ticket, { value, expiresAt });
25
26
  return { ticket, expiresAt };
26
27
  },
28
+ peek(ticket, now = Date.now()) {
29
+ const entry = open.get(ticket);
30
+ if (entry !== undefined && entry.expiresAt > now) return entry.value;
31
+ open.delete(ticket);
32
+ return undefined;
33
+ },
27
34
  take(ticket, now = Date.now()) {
28
35
  const entry = open.get(ticket);
29
36
  open.delete(ticket);
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.0-beta.271",
2
+ "version": "0.1.0-beta.273",
3
3
  "dependencies": {
4
4
  "@anthropic-ai/claude-agent-sdk": "0.3.287",
5
5
  "@modelcontextprotocol/sdk": "^1.29.0",