@stage-labs/metro 0.1.0-beta.270 → 0.1.0-beta.272

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 (38) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/node_modules/@metro-labs/core/src/tickets.ts +7 -0
  4. package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +13 -8
  5. package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -1
  6. package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +13 -1
  7. package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -2
  8. package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +6 -0
  9. package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +22 -5
  10. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +5 -2
  11. package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +10 -1
  12. package/runtime/node_modules/@metro-labs/daemon/src/net/tunnel.ts +10 -3
  13. package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +1 -1
  14. package/runtime/node_modules/@metro-labs/daemon/src/routes/gmail-attach.ts +67 -0
  15. package/runtime/node_modules/@metro-labs/daemon/src/routes/gmail-detach.ts +37 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +12 -6
  17. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-browser.ts +8 -3
  18. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-driver.ts +1 -1
  19. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-gmail.ts +78 -0
  20. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-session.ts +30 -4
  21. package/runtime/node_modules/@metro-labs/gmail/package.json +3 -1
  22. package/runtime/node_modules/@metro-labs/gmail/src/accounts.ts +63 -11
  23. package/runtime/node_modules/@metro-labs/gmail/src/actions.ts +8 -3
  24. package/runtime/node_modules/@metro-labs/gmail/src/api.ts +4 -1
  25. package/runtime/node_modules/@metro-labs/gmail/src/attachments.ts +4 -1
  26. package/runtime/node_modules/@metro-labs/gmail/src/auth.ts +15 -5
  27. package/runtime/node_modules/@metro-labs/gmail/src/config.ts +3 -1
  28. package/runtime/node_modules/@metro-labs/gmail/src/inbound.ts +1 -0
  29. package/runtime/node_modules/@metro-labs/gmail/src/login.ts +5 -3
  30. package/runtime/node_modules/@metro-labs/gmail/src/managed-binding.ts +33 -0
  31. package/runtime/node_modules/@metro-labs/gmail/src/managed.ts +45 -0
  32. package/runtime/node_modules/@metro-labs/gmail/src/outbound.ts +1 -0
  33. package/runtime/node_modules/@metro-labs/gmail/src/query.ts +2 -0
  34. package/runtime/node_modules/@metro-labs/gmail/src/state.ts +8 -2
  35. package/runtime/node_modules/@metro-labs/http/src/api-http.ts +2 -0
  36. package/runtime/runtime.json +1 -1
  37. package/runtime/sdk-runner/node_modules/@metro-labs/core/src/tickets.ts +7 -0
  38. 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.270",
3
+ "version": "0.1.0-beta.272",
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.270",
3
+ "version": "0.1.0-beta.272",
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",
@@ -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() });
@@ -26,6 +26,7 @@ import { setupAnswer, type SetupApiDeps } from './setup-api.js';
26
26
  import {
27
27
  autostartEnabled,
28
28
  ensureSession,
29
+ sessionSnapshot,
29
30
  sessionStatus,
30
31
  setAutostart,
31
32
  startSession,
@@ -270,6 +271,11 @@ export function handleClaudeRequest(
270
271
  apiSession(req)
271
272
  .then(async (session) => {
272
273
  if (!session) throw new ApiError('unauthorized', 401);
274
+ if (path === `${PREFIX}/session/snapshot`) {
275
+ if (req.method !== 'GET') throw new ApiError('method not allowed', 405);
276
+ sendJson(req, res, 200, sessionSnapshot(deps.session));
277
+ return;
278
+ }
273
279
  if (req.method !== 'GET' && ADMIN_ONLY.test(path)) requireAdmin(session);
274
280
  if (await streamed(req, res, path, search, (deps.dir ?? claudeDir)())) return;
275
281
  sendJson(req, res, 200, await routed(req, path, search, deps, session));
@@ -61,6 +61,13 @@ export interface SessionStatus {
61
61
  lastError: string | null;
62
62
  }
63
63
 
64
+ export interface SessionSnapshot {
65
+ running: boolean;
66
+ runner: HarnessRunner | null;
67
+ activity: RunnerActivity | null;
68
+ lastStartedAt: string | null;
69
+ }
70
+
64
71
  interface Memory {
65
72
  lastStartedAt: number | null;
66
73
  lastError: string | null;
@@ -267,17 +274,27 @@ export function stopSession(deps: SessionDeps = {}): SessionStatus {
267
274
  export const sessionLive = (deps: SessionDeps = {}): boolean =>
268
275
  sessionRunning(deps.tmux ?? 'tmux') || sdkAlive(readAgentActivity(sessionHome(deps)));
269
276
 
270
- export function sessionStatus(deps: SessionDeps = {}): SessionStatus {
277
+ export function sessionSnapshot(deps: SessionDeps = {}): SessionSnapshot {
271
278
  const activity = readAgentActivity(sessionHome(deps));
272
279
  const sdk = sdkAlive(activity);
280
+ const tmux = sessionRunning(deps.tmux ?? 'tmux');
281
+ const runner = sdk ? 'sdk' : tmux ? readState(deps.agents ?? agentsDir()).runner : null;
273
282
  return {
274
- name: SESSION_NAME,
275
- running: sessionRunning(deps.tmux ?? 'tmux') || sdk,
276
- runner: sdk ? 'sdk' : sessionRunner(deps),
283
+ running: tmux || sdk,
284
+ runner: isHarnessRunner(runner) ? runner : null,
277
285
  activity,
286
+ lastStartedAt: memory.lastStartedAt === null ? null : new Date(memory.lastStartedAt).toISOString(),
287
+ };
288
+ }
289
+
290
+ export function sessionStatus(deps: SessionDeps = {}): SessionStatus {
291
+ const snapshot = sessionSnapshot(deps);
292
+ return {
293
+ name: SESSION_NAME,
294
+ ...snapshot,
295
+ runner: snapshot.runner ?? sessionRunner(deps),
278
296
  autostart: autostartEnabled(deps.agents ?? agentsDir()),
279
297
  blocked: sessionBlocked(deps),
280
- lastStartedAt: memory.lastStartedAt === null ? null : new Date(memory.lastStartedAt).toISOString(),
281
298
  lastError: memory.lastError,
282
299
  };
283
300
  }
@@ -14,6 +14,7 @@ import {
14
14
  connectionOf,
15
15
  parseModelConfig,
16
16
  readModelConfig,
17
+ readModelSnapshot,
17
18
  removeConnection,
18
19
  setFallbacks,
19
20
  setRoute,
@@ -28,6 +29,7 @@ import { refreshUsage } from './usage-refresh.js';
28
29
  import { handleOpenRouterSignIn } from './openrouter-signin.js';
29
30
 
30
31
  const PATH = '/api/model';
32
+ const SNAPSHOT = '/api/model/snapshot';
31
33
  const CONNECTIONS = '/api/model/connections';
32
34
  const FALLBACKS = '/api/model/fallbacks';
33
35
  const CODEX = '/api/model/codex/';
@@ -201,12 +203,13 @@ const TABLES: [string, Record<string, Route>][] = [
201
203
  [BEDROCK, BEDROCK_ROUTES],
202
204
  [GEMINI, GEMINI_ROUTES],
203
205
  ];
204
- const EXACT = new Set([PATH, FALLBACKS, BUNDLE, RESTORE, CONNECTIONS]);
206
+ const EXACT = new Set([PATH, SNAPSHOT, FALLBACKS, BUNDLE, RESTORE, CONNECTIONS]);
205
207
  const PREFIXES = [`${CONNECTIONS}/`, CODEX, ...TABLES.map(([prefix]) => prefix)];
206
208
 
207
209
  const mine = (path: string): boolean => EXACT.has(path) || PREFIXES.some((prefix) => path.startsWith(prefix));
208
210
 
209
211
  function routeFor(path: string, method: string | undefined): Route | number {
212
+ if (path === SNAPSHOT) return method === 'GET' ? { method: 'GET', run: (_req, _deps, store) => Promise.resolve().then(() => settingsBody(store.read())).catch(asApiError) } : 405;
210
213
  if (path === PATH || path === FALLBACKS) return settingsRoute(path, method);
211
214
  if (path === BUNDLE || path === RESTORE) return bundleRoute(path, method);
212
215
  if (path === CONNECTIONS || path.startsWith(`${CONNECTIONS}/`)) return connectionsRoute(path, method);
@@ -232,7 +235,7 @@ export function handleModelRequest(req: IncomingMessage, res: ServerResponse, de
232
235
  return true;
233
236
  }
234
237
  const store: Store = {
235
- read: deps.read ?? readModelConfig,
238
+ read: deps.read ?? (path === SNAPSHOT ? readModelSnapshot : readModelConfig),
236
239
  write: (cfg) => {
237
240
  checkRunnerRoute(store.read(), cfg, deps);
238
241
  (deps.write ?? writeModelConfig)(cfg);
@@ -158,13 +158,22 @@ export function parseModelConfig(raw: unknown): ModelConfig {
158
158
  const needsIds = (raw: unknown): boolean =>
159
159
  isRecord(raw) && Array.isArray(raw.connections) && raw.connections.some((c: unknown) => isRecord(c) && text(c.id) === '');
160
160
 
161
+ const readStoredModel = (dir: string): unknown =>
162
+ readJson<unknown>(join(dir, MODEL_FILE), null, { warn: 'model-config: model.json is unreadable, so every request goes to Anthropic until it is fixed' });
163
+
161
164
  export function readModelConfig(dir = agentsDir()): ModelConfig {
162
- const raw = readJson<unknown>(join(dir, MODEL_FILE), null, { warn: 'model-config: model.json is unreadable, so every request goes to Anthropic until it is fixed' });
165
+ const raw = readStoredModel(dir);
163
166
  const cfg = parseModelConfig(raw);
164
167
  if (needsIds(raw)) writeModelConfig(cfg, dir);
165
168
  return cfg;
166
169
  }
167
170
 
171
+ export function readModelSnapshot(dir = agentsDir()): ModelConfig {
172
+ const raw = readStoredModel(dir);
173
+ if (needsIds(raw)) throw new ModelConfigError('model snapshot is unavailable until every connection has a saved id');
174
+ return parseModelConfig(raw);
175
+ }
176
+
168
177
  export function writeModelConfig(cfg: ModelConfig, dir = agentsDir()): void {
169
178
  writeSecure(join(dir, MODEL_FILE), JSON.stringify(cfg, null, 2));
170
179
  }
@@ -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