@stage-labs/metro 0.1.0-beta.44 → 0.1.0-beta.46

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 (61) hide show
  1. package/dist/api.js +5 -5
  2. package/dist/bedrock-proxy.js +1 -1
  3. package/dist/cli.js +6 -6
  4. package/dist/plugin.js +1 -1
  5. package/dist/prompt.js +1 -1
  6. package/dist/runtime.js +1 -1
  7. package/dist/serve.js +1 -1
  8. package/package.json +1 -1
  9. package/runtime/node_modules/@metro-labs/discord-bot/src/accounts.ts +0 -1
  10. package/runtime/node_modules/@metro-labs/mcp/package.json +1 -5
  11. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-import.ts +2 -2
  12. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-grant.ts +1 -1
  13. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-serve.ts +2 -5
  14. package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +5 -23
  15. package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-files.ts +1 -3
  16. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +2 -4
  17. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connect-hint.ts +2 -2
  18. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth.ts +2 -2
  19. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-tools.ts +3 -5
  20. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-verify.ts +1 -4
  21. package/runtime/node_modules/@metro-labs/mcp/src/daemon/events.ts +2 -2
  22. package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +1 -2
  23. package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity.ts +4 -83
  24. package/runtime/node_modules/@metro-labs/mcp/src/daemon/is-record.ts +2 -0
  25. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-cli-api.ts +2 -2
  26. package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +1 -37
  27. package/runtime/node_modules/@metro-labs/mcp/src/daemon/oauth-client.ts +1 -4
  28. package/runtime/node_modules/@metro-labs/mcp/src/daemon/oauth-discovery.ts +1 -4
  29. package/runtime/node_modules/@metro-labs/mcp/src/daemon/paths.ts +0 -33
  30. package/runtime/node_modules/@metro-labs/mcp/src/daemon/protocol.ts +3 -3
  31. package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +2 -2
  32. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-config.ts +1 -6
  33. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +1 -42
  34. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-auth.ts +2 -11
  35. package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-routes.ts +2 -2
  36. package/runtime/node_modules/@metro-labs/mcp/src/daemon/tunnel.ts +8 -90
  37. package/runtime/node_modules/@metro-labs/mcp/src/daemon/upload-api.ts +1 -1
  38. package/runtime/node_modules/@metro-labs/mcp/src/daemon/version.ts +1 -0
  39. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-connectors.ts +1 -1
  40. package/runtime/node_modules/@metro-labs/mcp/src/db/connector-config.ts +1 -4
  41. package/runtime/node_modules/@metro-labs/mcp/src/db/connector-relay.ts +1 -1
  42. package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +6 -4
  43. package/runtime/node_modules/@metro-labs/mcp/src/db/file-source.ts +1 -3
  44. package/runtime/node_modules/@metro-labs/mcp/src/db/local-connectors.ts +11 -22
  45. package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +3 -13
  46. package/runtime/node_modules/@metro-labs/mcp/src/db/projects.ts +3 -3
  47. package/runtime/node_modules/@metro-labs/mcp/src/db/runtimes.ts +1 -1
  48. package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +2 -4
  49. package/runtime/node_modules/@metro-labs/mcp/src/mcp/event-store.ts +1 -1
  50. package/runtime/node_modules/@metro-labs/mcp/src/monitor/api.ts +1 -1
  51. package/runtime/node_modules/@metro-labs/mcp/src/stations/account-store.ts +1 -21
  52. package/runtime/node_modules/@metro-labs/mcp/src/stations/attach-resolve.ts +1 -1
  53. package/runtime/node_modules/@metro-labs/mcp/src/stations/lines.ts +1 -1
  54. package/runtime/node_modules/@metro-labs/telegram/package.json +0 -2
  55. package/runtime/node_modules/@metro-labs/telegram/src/accounts.ts +0 -1
  56. package/runtime/node_modules/@metro-labs/telegram-bot/src/accounts.ts +0 -1
  57. package/runtime/node_modules/@metro-labs/webhook/src/station.ts +2 -5
  58. package/runtime/node_modules/@metro-labs/whatsapp/src/accounts.ts +0 -1
  59. package/runtime/node_modules/@metro-labs/xmtp/src/accounts.ts +0 -1
  60. package/runtime/runtime.json +1 -1
  61. package/runtime/node_modules/@metro-labs/mcp/src/db/token-carry.ts +0 -45
@@ -1,4 +1,5 @@
1
1
  import { ConnectorVerifyError, parseConnectorUrl } from './connector-verify.js';
2
+ import { isRecord } from './is-record.js';
2
3
 
3
4
  const DISCOVERY_TIMEOUT_MS = 10_000;
4
5
 
@@ -14,10 +15,6 @@ function refused(message: string): ConnectorVerifyError {
14
15
  return new ConnectorVerifyError(message, 400);
15
16
  }
16
17
 
17
- function isRecord(value: unknown): value is Record<string, unknown> {
18
- return typeof value === 'object' && value !== null && !Array.isArray(value);
19
- }
20
-
21
18
  const str = (value: unknown): string =>
22
19
  typeof value === 'string' ? value : '';
23
20
 
@@ -1,7 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import {
3
3
  closeSync,
4
- existsSync,
5
4
  mkdirSync,
6
5
  openSync,
7
6
  readFileSync,
@@ -20,10 +19,6 @@ mkdirSync(STATE_DIR, { recursive: true });
20
19
  export const configDir = (): string =>
21
20
  process.env.METRO_CONFIG_DIR ??
22
21
  join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'metro');
23
- export const CONFIG_DIR = configDir();
24
- export const CONFIG_ENV_FILE = join(CONFIG_DIR, '.env');
25
-
26
- export const TRAINS_ENV_FILE = join(homedir(), '.metro', '.env');
27
22
 
28
23
  export function trainsDir(): string {
29
24
  const env = process.env.METRO_TRAINS_DIR?.trim();
@@ -34,34 +29,6 @@ export function trainsDir(): string {
34
29
  export const isLocalMode = (): boolean =>
35
30
  process.env.METRO_MODE?.trim() === 'local';
36
31
 
37
- export const envSources = (): readonly string[] => [
38
- join(process.cwd(), '.env'),
39
- TRAINS_ENV_FILE,
40
- CONFIG_ENV_FILE,
41
- ];
42
-
43
- const LINE_RE = /^\s*([A-Za-z_]\w*)\s*=\s*(.*?)\s*$/;
44
- const QUOTED_RE = /^(['"])(.*)\1$/;
45
-
46
- export function readDotenv(path: string): Record<string, string> {
47
- if (!existsSync(path)) return {};
48
- const out: Record<string, string> = {};
49
- for (const line of readFileSync(path, 'utf8').split('\n')) {
50
- const m = LINE_RE.exec(line);
51
- if (m?.[1] !== undefined && m[2] !== undefined)
52
- out[m[1]] = m[2].replace(QUOTED_RE, '$2');
53
- }
54
- return out;
55
- }
56
-
57
- export function loadMetroEnv(): void {
58
- for (const path of envSources()) {
59
- for (const [k, v] of Object.entries(readDotenv(path))) {
60
- process.env[k] ??= v;
61
- }
62
- }
63
- }
64
-
65
32
  function readLockPid(lockFile: string): number {
66
33
  try {
67
34
  return Number(readFileSync(lockFile, 'utf8').trim());
@@ -11,7 +11,7 @@ export {
11
11
  type TrainErrorInfo,
12
12
  } from './train-error.js';
13
13
 
14
- export const CALL_TIMEOUT_MS = 60_000;
14
+ const CALL_TIMEOUT_MS = 60_000;
15
15
 
16
16
  export interface Pending {
17
17
  resolve: (r: TrainCallResponse) => void;
@@ -24,7 +24,7 @@ export interface CallTarget {
24
24
  proc: ({ stdin?: unknown } & Record<string, unknown>) | null;
25
25
  }
26
26
 
27
- export const STDOUT_LINE_MAX = 4 * 1024 * 1024;
27
+ const STDOUT_LINE_MAX = 4 * 1024 * 1024;
28
28
 
29
29
  export type TrainEvent = {
30
30
  station?: string;
@@ -149,7 +149,7 @@ export function readCalls(
149
149
  });
150
150
  }
151
151
 
152
- export function encodeCall(id: string, action: string, args: unknown): string {
152
+ function encodeCall(id: string, action: string, args: unknown): string {
153
153
  return JSON.stringify({ op: 'call', id, action, args }) + '\n';
154
154
  }
155
155
 
@@ -9,7 +9,7 @@ import {
9
9
  sendJson,
10
10
  } from './api-http.js';
11
11
  import { AGENT_CODE_RE, takeAgentCode } from './agent-pair.js';
12
- import { sessionTtlFromEnv } from './session-config.js';
12
+ import { SESSION_TTL_SEC } from './session-config.js';
13
13
  import { signRunToken } from './session.js';
14
14
  import type { LoadedAgent } from '../db/materialize.js';
15
15
  import type { RuntimeLease } from '../db/runtimes.js';
@@ -80,7 +80,7 @@ async function handleClaim(
80
80
  const token = signRunToken(
81
81
  { subject: taken.subject, agentId: lease.agentId, runtimeId: lease.runtimeId },
82
82
  secret,
83
- { ttlSec: sessionTtlFromEnv() },
83
+ { ttlSec: SESSION_TTL_SEC },
84
84
  );
85
85
  log.info(
86
86
  { agent: lease.agentId, runtime: lease.runtimeId, label: lease.label },
@@ -1,11 +1,6 @@
1
- const DEFAULT_SESSION_TTL_SEC = 30 * 24 * 3600;
1
+ export const SESSION_TTL_SEC = 30 * 24 * 3600;
2
2
  const PREVIEW = /^([a-z0-9-]+--)?metro-ui\.netlify\.app$/;
3
3
 
4
- export function sessionTtlFromEnv(): number {
5
- const ttl = Number(process.env.METRO_SESSION_TTL_SEC);
6
- return Number.isFinite(ttl) && ttl > 0 ? ttl : DEFAULT_SESSION_TTL_SEC;
7
- }
8
-
9
4
  export function allowedWebHost(host: string): boolean {
10
5
  if (host === 'metro.box' || host === 'localhost' || host === '127.0.0.1')
11
6
  return true;
@@ -1,4 +1,4 @@
1
- import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
2
  import { ID_RE } from '../db/ids.js';
3
3
 
4
4
  export class SessionError extends Error {}
@@ -53,47 +53,6 @@ function verify(token: string, secret: string): Record<string, unknown> {
53
53
 
54
54
  const nowSec = (now?: number): number => Math.floor((now ?? Date.now()) / 1000);
55
55
 
56
- export function newNonce(): string {
57
- return b64url(randomBytes(16));
58
- }
59
-
60
- interface StateClaims {
61
- return_to: string;
62
- nonce: string;
63
- }
64
-
65
- export function signState(
66
- claims: StateClaims,
67
- secret: string,
68
- opts: { ttlSec?: number; now?: number } = {},
69
- ): string {
70
- const iat = nowSec(opts.now);
71
- return sign(
72
- {
73
- typ: 'state',
74
- return_to: claims.return_to,
75
- nonce: claims.nonce,
76
- iat,
77
- exp: iat + (opts.ttlSec ?? 600),
78
- },
79
- secret,
80
- );
81
- }
82
-
83
- export function verifyState(
84
- token: string,
85
- secret: string,
86
- now?: number,
87
- ): StateClaims {
88
- const p = verify(token, secret);
89
- if (p.typ !== 'state') throw new SessionError('wrong token type');
90
- if (typeof p.exp !== 'number' || p.exp < nowSec(now))
91
- throw new SessionError('state expired');
92
- if (typeof p.return_to !== 'string' || typeof p.nonce !== 'string')
93
- throw new SessionError('malformed state');
94
- return { return_to: p.return_to, nonce: p.nonce };
95
- }
96
-
97
56
  interface SessionClaims {
98
57
  subject: string;
99
58
  agentIds: string[];
@@ -10,16 +10,8 @@ export interface SiweLogin {
10
10
  signature: string;
11
11
  }
12
12
 
13
- export interface SiweVerifyArgs {
14
- address: `0x${string}`;
15
- message: string;
16
- signature: Hex;
17
- }
18
-
19
13
  export interface SiweDeps {
20
14
  takeNonce: (nonce: string) => boolean;
21
- now?: number;
22
- verify?: (args: SiweVerifyArgs) => Promise<boolean>;
23
15
  }
24
16
 
25
17
  const HEX_SIGNATURE = /^0x[0-9a-fA-F]{130}$/;
@@ -66,14 +58,13 @@ export async function verifySiweLogin(
66
58
  if (!HEX_SIGNATURE.test(input.signature))
67
59
  throw new SiweError('that is not an Ethereum signature', 400);
68
60
  const { address, domain, nonce, parsed } = fieldsOrThrow(input.message);
69
- const time = new Date(deps.now ?? Date.now());
61
+ const time = new Date();
70
62
  if (!validateSiweMessage({ message: parsed, domain, nonce, time }))
71
63
  throw new SiweError('this sign-in message is expired or does not match', 400);
72
64
  if (!deps.takeNonce(nonce))
73
65
  throw new SiweError('that sign-in has expired or was already used', 400);
74
- const verify = deps.verify ?? verifyMessage;
75
66
  const signature = input.signature as Hex;
76
- if (!(await verify({ address, message: input.message, signature })))
67
+ if (!(await verifyMessage({ address, message: input.message, signature })))
77
68
  throw new SiweError(
78
69
  'the signature does not match the address; smart-contract wallets are not supported yet',
79
70
  401,
@@ -10,7 +10,7 @@ import {
10
10
  import { mintNonce, takeNonce } from './siwe-nonces.js';
11
11
  import { verifySiweLogin } from './siwe-auth.js';
12
12
  import { signSession } from './session.js';
13
- import { sessionTtlFromEnv } from './session-config.js';
13
+ import { SESSION_TTL_SEC } from './session-config.js';
14
14
  import { ensureUserWithProject } from '../db/projects.js';
15
15
 
16
16
  const NONCE_PATH = '/auth/siwe/nonce';
@@ -39,7 +39,7 @@ async function handleVerify(
39
39
  const userId = await deps.ensureUser(address);
40
40
  log.info({ userId, address }, 'siwe: session issued');
41
41
  const session = signSession({ subject: address, agentIds: [] }, secret, {
42
- ttlSec: sessionTtlFromEnv(),
42
+ ttlSec: SESSION_TTL_SEC,
43
43
  });
44
44
  sendJson(req, res, 200, { session, address });
45
45
  }
@@ -1,31 +1,15 @@
1
- import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
1
+ import { spawn, type ChildProcess } from 'node:child_process';
2
2
  import { timingSafeEqual } from 'node:crypto';
3
3
  import { Resolver } from 'node:dns/promises';
4
4
  import { homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { STATE_DIR } from './paths.js';
7
6
  import { errMsg, log } from './log.js';
8
7
  import { readJson } from './secure-fs.js';
9
8
 
10
- const FILE = join(STATE_DIR, 'tunnel.json');
11
- const LEGACY_WEBHOOKS_FILE = join(STATE_DIR, 'webhooks.json');
12
9
  const RESTART_DELAY_MS = 2_000;
13
10
 
14
- interface NamedTunnel {
15
- name: string;
16
- hostname: string;
17
- }
18
-
19
- interface QuickTunnel {
20
- quick: true;
21
- }
22
-
23
- export type TunnelConfig = NamedTunnel | QuickTunnel;
24
-
25
- const isQuick = (cfg: TunnelConfig): cfg is QuickTunnel => 'quick' in cfg;
26
-
27
- export const tunnelConfigFromEnv = (): TunnelConfig | null =>
28
- process.env.METRO_TUNNEL?.trim() === 'quick' ? { quick: true } : null;
11
+ export const quickTunnelWanted = (): boolean =>
12
+ process.env.METRO_TUNNEL?.trim() === 'quick';
29
13
 
30
14
  const QUICK_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
31
15
 
@@ -61,17 +45,11 @@ async function untilResolvable(host: string, resolves: Resolves): Promise<boolea
61
45
  return false;
62
46
  }
63
47
 
64
- export function configuredTunnelHost(): string | null {
65
- const cfg = loadTunnelConfig();
66
- return cfg !== null && !isQuick(cfg) ? cfg.hostname : null;
67
- }
68
-
69
48
  export interface Endpoint {
70
49
  id: string;
71
50
  webhookId?: string;
72
51
  label: string;
73
52
  secret?: string;
74
- session?: string;
75
53
  createdAt: string;
76
54
  }
77
55
  interface AccountRecord {
@@ -79,7 +57,6 @@ interface AccountRecord {
79
57
  webhookId?: unknown;
80
58
  label?: unknown;
81
59
  secret?: unknown;
82
- session?: unknown;
83
60
  createdAt?: unknown;
84
61
  }
85
62
 
@@ -101,7 +78,6 @@ function toEndpoint(raw: AccountRecord): Endpoint | null {
101
78
  webhookId: str(raw.webhookId),
102
79
  label: str(raw.label) ?? id,
103
80
  secret: str(raw.secret),
104
- session: str(raw.session),
105
81
  createdAt: str(raw.createdAt) ?? '',
106
82
  };
107
83
  }
@@ -125,76 +101,21 @@ export function tokenMatches(secret: string, given: string): boolean {
125
101
  return want.length === got.length && timingSafeEqual(want, got);
126
102
  }
127
103
 
128
- export function warnOnLegacyWebhooks(): void {
129
- const legacy = readJson<{ endpoints?: unknown[] }>(LEGACY_WEBHOOKS_FILE, {});
130
- const count = Array.isArray(legacy.endpoints) ? legacy.endpoints.length : 0;
131
- if (count > 0)
132
- log.warn(
133
- { file: LEGACY_WEBHOOKS_FILE, endpoints: count },
134
- 'webhook: webhooks.json is no longer read — endpoints live in the accounts table; these endpoints are inactive',
135
- );
136
- }
137
-
138
- export const loadTunnelConfig = (): TunnelConfig | null =>
139
- readJson<TunnelConfig | null>(FILE, null, {
140
- warn: 'tunnel.json: malformed, ignoring',
141
- });
142
-
143
- function fetchTunnelToken(name: string): string | null {
144
- const r = spawnSync('cloudflared', ['tunnel', 'token', name], {
145
- encoding: 'utf8',
146
- });
147
- if (r.status !== 0) return null;
148
- const token = r.stdout.trim();
149
- return token.length > 0 ? token : null;
150
- }
151
-
152
- interface Plan {
153
- args: string[];
154
- env: NodeJS.ProcessEnv;
155
- mode: string;
156
- }
157
-
158
104
  export class Tunnel {
159
105
  private child: ChildProcess | null = null;
160
106
  private closed = false;
161
- private token: string | null | undefined = undefined;
162
107
 
163
108
  constructor(
164
- private cfg: TunnelConfig,
165
109
  private port: number,
166
110
  private onUrl: (url: string) => void = () => undefined,
167
111
  private resolves: Resolves = resolvesAtCloudflare,
168
112
  ) {}
169
113
 
170
- get hostname(): string {
171
- if (!isQuick(this.cfg)) return this.cfg.hostname;
172
- return liveUrl === null ? '' : new URL(liveUrl).host;
173
- }
174
-
175
114
  private origin(): string {
176
115
  return `http://127.0.0.1:${String(this.port)}`;
177
116
  }
178
117
 
179
- private plan(): Plan {
180
- if (isQuick(this.cfg))
181
- return {
182
- args: ['--no-autoupdate', 'tunnel', '--url', this.origin()],
183
- env: process.env,
184
- mode: 'quick',
185
- };
186
- if (this.token === undefined) this.token = fetchTunnelToken(this.cfg.name);
187
- const args = ['--no-autoupdate', 'tunnel', 'run', '--url', this.origin()];
188
- if (!this.token) args.push(this.cfg.name);
189
- return {
190
- args,
191
- env: this.token ? { ...process.env, TUNNEL_TOKEN: this.token } : process.env,
192
- mode: this.token ? 'token' : 'named',
193
- };
194
- }
195
-
196
118
  private noticeUrl(text: string): void {
197
- if (!isQuick(this.cfg)) return;
198
119
  const url = quickTunnelUrlIn(text);
199
120
  if (url === null || url === liveUrl) return;
200
121
  liveUrl = url;
@@ -213,15 +134,12 @@ export class Tunnel {
213
134
 
214
135
  start(): void {
215
136
  if (this.closed) return;
216
- const plan = this.plan();
217
- log.info(
218
- { tunnel: isQuick(this.cfg) ? 'quick' : this.cfg.name, port: this.port, mode: plan.mode },
219
- 'cloudflared tunnel starting',
137
+ log.info({ port: this.port }, 'cloudflared quick tunnel starting');
138
+ const child = spawn(
139
+ 'cloudflared',
140
+ ['--no-autoupdate', 'tunnel', '--url', this.origin()],
141
+ { stdio: ['ignore', 'pipe', 'pipe'] },
220
142
  );
221
- const child = spawn('cloudflared', plan.args, {
222
- stdio: ['ignore', 'pipe', 'pipe'],
223
- env: plan.env,
224
- });
225
143
  this.child = child;
226
144
  child.stderr?.on('data', (d: Buffer | string) => {
227
145
  const text = (typeof d === 'string' ? d : d.toString('utf8')).trim();
@@ -38,7 +38,7 @@ const VAGUE_MIMES = new Set([
38
38
 
39
39
  const mib = (n: number): string => `${Math.round(n / (1024 * 1024))} MiB`;
40
40
 
41
- export const uploadLimit = (): string =>
41
+ const uploadLimit = (): string =>
42
42
  `${mib(MAX_UPLOAD_BYTES)} (${MAX_UPLOAD_BYTES} bytes)`;
43
43
 
44
44
  const tooLarge = (): string =>
@@ -0,0 +1 @@
1
+ export const METRO_VERSION = process.env.npm_package_version ?? '0.1.0-beta.15';
@@ -56,7 +56,7 @@ export async function assertRenameFreeOfClash(
56
56
  if (clash !== null) throw nameClash(name, clash);
57
57
  }
58
58
 
59
- export async function connectorIdsOfAgent(agentId: string): Promise<string[]> {
59
+ async function connectorIdsOfAgent(agentId: string): Promise<string[]> {
60
60
  const rows = await getDb()
61
61
  .select({ connectorId: agentConnectors.connectorId })
62
62
  .from(agentConnectors)
@@ -5,6 +5,7 @@ import type {
5
5
  VerifiedServer,
6
6
  } from '../daemon/connector-verify.js';
7
7
  import { readStoredTools } from '../daemon/connector-tools.js';
8
+ import { isRecord } from '../daemon/is-record.js';
8
9
 
9
10
  const CONNECTOR_NAME_MAX = 64;
10
11
  const HEADER_NAME_RE = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/;
@@ -22,10 +23,6 @@ export interface ConnectorConfig {
22
23
  oauth: boolean;
23
24
  }
24
25
 
25
- function isRecord(value: unknown): value is Record<string, unknown> {
26
- return typeof value === 'object' && value !== null && !Array.isArray(value);
27
- }
28
-
29
26
  function text(value: unknown): string {
30
27
  return typeof value === 'string' ? value : '';
31
28
  }
@@ -25,7 +25,7 @@ export function staleUsable(auth: OAuthAuth, now = Date.now()): boolean {
25
25
  return auth.expiresAt === undefined || auth.expiresAt > now;
26
26
  }
27
27
 
28
- export function headerAuthHeaders(
28
+ function headerAuthHeaders(
29
29
  auth: ConnectorAuth,
30
30
  ): Record<string, string> | null {
31
31
  return auth.kind === 'header' ? { [auth.name]: auth.value } : null;
@@ -278,10 +278,12 @@ export function localSetAgentConnectors(
278
278
  save(stored);
279
279
  }
280
280
 
281
- export function localAgentsWith(connectorId: string, dir = agentsDir()): { id: string; name: string }[] {
282
- return storedAgents(dir)
283
- .filter((s) => s.file.connectors.includes(connectorId))
284
- .map((s) => ({ id: s.file.id, name: s.file.name }));
281
+ export function localHoldEverywhere(connectorId: string, dir = agentsDir()): void {
282
+ for (const stored of storedAgents(dir)) {
283
+ if (stored.file.connectors.includes(connectorId)) continue;
284
+ stored.file.connectors = [...stored.file.connectors, connectorId];
285
+ save(stored);
286
+ }
285
287
  }
286
288
 
287
289
  export function localDropConnectorEverywhere(connectorId: string, dir = agentsDir()): void {
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { AGENT_NAME_RE, ID_RE } from './ids.js';
5
5
  import { STATIONS, type StationName } from './schema.js';
6
6
  import type { LoadedAccount, LoadedAgent, StationSource } from './materialize.js';
7
+ import { isRecord } from '../daemon/is-record.js';
7
8
 
8
9
  export const AGENT_FILE = 'agent.json';
9
10
 
@@ -30,9 +31,6 @@ export function agentsDir(): string {
30
31
  : join(homedir(), '.metro', 'agents');
31
32
  }
32
33
 
33
- const isRecord = (v: unknown): v is Record<string, unknown> =>
34
- typeof v === 'object' && v !== null && !Array.isArray(v);
35
-
36
34
  function fail(path: string, reason: string): never {
37
35
  throw new AgentFileError(`${path}: ${reason}`);
38
36
  }
@@ -41,8 +41,8 @@ import {
41
41
  assertLocalOwner,
42
42
  connectorIdsOfLocalAgent,
43
43
  localAgentConnectorIds,
44
- localAgentsWith,
45
44
  localDropConnectorEverywhere,
45
+ localHoldEverywhere,
46
46
  localOwnedAgentOrThrow,
47
47
  localSetAgentConnectors,
48
48
  LOCAL_PROJECT_ID,
@@ -70,7 +70,7 @@ const isRow = (v: unknown): v is { id: string; name: string; url: string; config
70
70
  const asList = (value: unknown): unknown[] =>
71
71
  Array.isArray(value) ? value.map((item: unknown) => item) : [];
72
72
 
73
- export function readLocalConnectors(dir = agentsDir()): LocalConnectorRow[] {
73
+ function readLocalConnectors(dir = agentsDir()): LocalConnectorRow[] {
74
74
  const raw = readJson<{ connectors?: unknown }>(filePath(dir), {}, {
75
75
  warn: 'connectors.json: malformed, ignoring',
76
76
  });
@@ -125,9 +125,16 @@ export async function localGetConnector(subject: string, id: string, dir = agent
125
125
  return Promise.resolve(connectorFromRow(rowOrThrow(subject, id, dir)));
126
126
  }
127
127
 
128
+ function assertNameFree(name: string, exceptId: string | null, dir: string): void {
129
+ if (readLocalConnectors(dir).some((r) => r.id !== exceptId && r.name === name))
130
+ throw new ConnectorError(`a connector named '${name}' already exists on this daemon`, 409);
131
+ }
132
+
128
133
  function insert(dir: string, name: string, url: URL, config: ConnectorConfig): Connector {
134
+ assertNameFree(name, null, dir);
129
135
  const row: LocalConnectorRow = { id: newId(), name, url: connectorUrlText(url), transport: 'http', config };
130
136
  writeRows(dir, [...readLocalConnectors(dir), row]);
137
+ localHoldEverywhere(row.id, dir);
131
138
  return connectorFromRow(row);
132
139
  }
133
140
 
@@ -201,24 +208,10 @@ export async function localVerifyConnector(subject: string, id: string, dir = ag
201
208
  }
202
209
  }
203
210
 
204
- const nameClash = (name: string, agent: string): ConnectorError =>
205
- new ConnectorError(`the agent '${agent}' already has a connector named '${name}'`, 409);
206
-
207
- function clashFor(name: string, exceptId: string, agentIds: string[], dir: string): string | null {
208
- const rows = readLocalConnectors(dir);
209
- for (const agentId of agentIds) {
210
- const held = connectorIdsOfLocalAgent(agentId, dir) ?? [];
211
- const other = rows.find((r) => r.id !== exceptId && held.includes(r.id) && r.name === name);
212
- if (other !== undefined) return localAgentsWith(other.id, dir).find((a) => a.id === agentId)?.name ?? agentId;
213
- }
214
- return null;
215
- }
216
-
217
211
  export async function localRenameConnector(subject: string, id: string, raw: string, dir = agentsDir()): Promise<Connector> {
218
212
  const name = connectorName(raw);
219
213
  const row = rowOrThrow(subject, id, dir);
220
- const clash = clashFor(name, id, localAgentsWith(id, dir).map((a) => a.id), dir);
221
- if (clash !== null) throw nameClash(name, clash);
214
+ assertNameFree(name, id, dir);
222
215
  return Promise.resolve(replace(dir, { ...row, name }));
223
216
  }
224
217
 
@@ -252,11 +245,7 @@ export async function localAddConnector(subject: string, agentId: string, connec
252
245
  const row = readLocalConnectors(dir).find((r) => r.id === connectorId);
253
246
  if (row === undefined) throw missing();
254
247
  const held = localAgentConnectorIds(subject, agentId, dir);
255
- if (!held.includes(connectorId)) {
256
- const clash = clashFor(row.name, connectorId, [agentId], dir);
257
- if (clash !== null) throw nameClash(row.name, clash);
258
- localSetAgentConnectors(subject, agentId, [...held, connectorId], dir);
259
- }
248
+ if (!held.includes(connectorId)) localSetAgentConnectors(subject, agentId, [...held, connectorId], dir);
260
249
  return { ...agent, connectorIds: localAgentConnectorIds(subject, agentId, dir) };
261
250
  }
262
251
 
@@ -21,7 +21,6 @@ import {
21
21
  type AllowlistMap,
22
22
  } from './agent-map.js';
23
23
  import { setKeyMap } from './key-map.js';
24
- import { carryTokenStores, PREVIOUS_ACCOUNT_ID } from './token-carry.js';
25
24
  import { STATIONS, stations, agents, type StationName } from './schema.js';
26
25
 
27
26
  interface StationTarget {
@@ -149,7 +148,7 @@ export async function loadAgentForRuntime(
149
148
  };
150
149
  }
151
150
 
152
- export const pgSource: StationSource = () => loadAgents();
151
+ const pgSource: StationSource = () => loadAgents();
153
152
 
154
153
  async function loadAgents(): Promise<LoadedAgent[]> {
155
154
  const db = getDb();
@@ -182,14 +181,6 @@ async function loadAgents(): Promise<LoadedAgent[]> {
182
181
  return out;
183
182
  }
184
183
 
185
- function trainConfig(
186
- config: Record<string, unknown>,
187
- ): Record<string, unknown> {
188
- return Object.fromEntries(
189
- Object.entries(config).filter(([key]) => key !== PREVIOUS_ACCOUNT_ID),
190
- );
191
- }
192
-
193
184
  function trainStubPath(station: StationName): string {
194
185
  return join(trainsDir(), `${station}.ts`);
195
186
  }
@@ -208,7 +199,7 @@ export function writeIfChanged(path: string, content: string): boolean {
208
199
  return true;
209
200
  }
210
201
 
211
- export const isLocalRuntime = (): boolean =>
202
+ const isLocalRuntime = (): boolean =>
212
203
  (process.env.METRO_RUN_TOKEN?.trim() ?? '') !== '';
213
204
 
214
205
  export const stationRunsHere = (station: StationName): boolean =>
@@ -248,12 +239,11 @@ function writeStations(list: LoadedAgent[]): WrittenStations {
248
239
  }
249
240
  setAgentMap(map, names);
250
241
  setAllowlistMap(allow);
251
- carryTokenStores(list.flatMap((agent) => agent.accounts));
252
242
 
253
243
  const active = new Map<StationName, number>();
254
244
  const changed: StationName[] = [];
255
245
  for (const [station, accts] of byStation) {
256
- const records = accts.map((a) => ({ id: a.id, ...trainConfig(a.config) }));
246
+ const records = accts.map((a) => ({ id: a.id, ...a.config }));
257
247
  const path = accountFilePath(station);
258
248
  const content = JSON.stringify(records, null, 2);
259
249
  if (currentText(path) !== content) {
@@ -33,7 +33,7 @@ const CONTROL = /[\u0000-\u001f\u007f]/;
33
33
  const missing = (): ProjectError => new ProjectError('no such project', 404);
34
34
  const forbidden = (why: string): ProjectError => new ProjectError(why, 403);
35
35
 
36
- export function projectName(raw: unknown): string {
36
+ function projectName(raw: unknown): string {
37
37
  const name = typeof raw === 'string' ? raw.trim() : '';
38
38
  if (name === '' || name.length > NAME_MAX)
39
39
  throw new ProjectError(
@@ -54,7 +54,7 @@ export interface Access {
54
54
  role: ProjectRole;
55
55
  }
56
56
 
57
- export async function memberAccessOrThrow(
57
+ async function memberAccessOrThrow(
58
58
  subject: string,
59
59
  projectId: string,
60
60
  ): Promise<Access> {
@@ -101,7 +101,7 @@ async function addMemberRow(
101
101
  .onConflictDoNothing();
102
102
  }
103
103
 
104
- export async function ensureDefaultProject(userId: string): Promise<string> {
104
+ async function ensureDefaultProject(userId: string): Promise<string> {
105
105
  const existing = await getDb()
106
106
  .select({ id: projects.id })
107
107
  .from(projects)
@@ -91,7 +91,7 @@ export async function runtimeLabels(
91
91
  return out;
92
92
  }
93
93
 
94
- export async function runtimeFor(agentId: string): Promise<RuntimeView | null> {
94
+ async function runtimeFor(agentId: string): Promise<RuntimeView | null> {
95
95
  const rows = await getDb()
96
96
  .select({
97
97
  id: runtimes.id,