@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.
- package/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/src/tickets.ts +7 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +13 -8
- package/runtime/node_modules/@metro-labs/daemon/src/agents/api.ts +3 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +13 -1
- package/runtime/node_modules/@metro-labs/daemon/src/boot/boot.ts +5 -2
- package/runtime/node_modules/@metro-labs/daemon/src/claude/api.ts +6 -0
- package/runtime/node_modules/@metro-labs/daemon/src/claude/session.ts +22 -5
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-api.ts +5 -2
- package/runtime/node_modules/@metro-labs/daemon/src/gateway/model-config.ts +10 -1
- package/runtime/node_modules/@metro-labs/daemon/src/net/tunnel.ts +10 -3
- package/runtime/node_modules/@metro-labs/daemon/src/routes/bearer.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/routes/gmail-attach.ts +67 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/gmail-detach.ts +37 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +12 -6
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-browser.ts +8 -3
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-driver.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-gmail.ts +78 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach-session.ts +30 -4
- package/runtime/node_modules/@metro-labs/gmail/package.json +3 -1
- package/runtime/node_modules/@metro-labs/gmail/src/accounts.ts +63 -11
- package/runtime/node_modules/@metro-labs/gmail/src/actions.ts +8 -3
- package/runtime/node_modules/@metro-labs/gmail/src/api.ts +4 -1
- package/runtime/node_modules/@metro-labs/gmail/src/attachments.ts +4 -1
- package/runtime/node_modules/@metro-labs/gmail/src/auth.ts +15 -5
- package/runtime/node_modules/@metro-labs/gmail/src/config.ts +3 -1
- package/runtime/node_modules/@metro-labs/gmail/src/inbound.ts +1 -0
- package/runtime/node_modules/@metro-labs/gmail/src/login.ts +5 -3
- package/runtime/node_modules/@metro-labs/gmail/src/managed-binding.ts +33 -0
- package/runtime/node_modules/@metro-labs/gmail/src/managed.ts +45 -0
- package/runtime/node_modules/@metro-labs/gmail/src/outbound.ts +1 -0
- package/runtime/node_modules/@metro-labs/gmail/src/query.ts +2 -0
- package/runtime/node_modules/@metro-labs/gmail/src/state.ts +8 -2
- package/runtime/node_modules/@metro-labs/http/src/api-http.ts +2 -0
- package/runtime/runtime.json +1 -1
- package/runtime/sdk-runner/node_modules/@metro-labs/core/src/tickets.ts +7 -0
- package/runtime/sdk-runner/runner.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.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
|
-
|
|
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
|
-
|
|
325
|
-
if (route.kind === '
|
|
326
|
-
return
|
|
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
|
|
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
|
|
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
|
-
|
|
275
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
+
assertAttachOwner(owner);
|
|
68
70
|
return Promise.resolve();
|
|
69
71
|
},
|
|
70
|
-
|
|
71
|
-
|
|
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:
|
|
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
|
-
|
|
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: () =>
|
|
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
|
}
|