@stage-labs/metro 0.1.0-beta.2 → 0.1.0-beta.21

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 (225) hide show
  1. package/dist/api.js +103 -0
  2. package/dist/cli.js +134 -565
  3. package/dist/control.js +102 -0
  4. package/dist/prompt.js +10 -0
  5. package/dist/runtime.js +94 -0
  6. package/dist/store.js +70 -0
  7. package/dist/update.js +33 -0
  8. package/dist/version.js +97 -0
  9. package/package.json +34 -22
  10. package/runtime/node_modules/@metro-labs/discord/package.json +28 -0
  11. package/runtime/node_modules/@metro-labs/discord/src/accounts.ts +119 -0
  12. package/runtime/node_modules/@metro-labs/discord/src/actions.ts +388 -0
  13. package/runtime/node_modules/@metro-labs/discord/src/api-base.ts +1 -0
  14. package/runtime/node_modules/@metro-labs/discord/src/attachments.ts +29 -0
  15. package/runtime/node_modules/@metro-labs/discord/src/format.ts +210 -0
  16. package/runtime/node_modules/@metro-labs/discord/src/group-actions.ts +50 -0
  17. package/runtime/node_modules/@metro-labs/discord/src/group.ts +115 -0
  18. package/runtime/node_modules/@metro-labs/discord/src/index.ts +127 -0
  19. package/runtime/node_modules/@metro-labs/discord/src/members.ts +215 -0
  20. package/runtime/node_modules/@metro-labs/discord/src/send-files.ts +43 -0
  21. package/runtime/node_modules/@metro-labs/discord/src/station.ts +19 -0
  22. package/runtime/node_modules/@metro-labs/discord/src/verify.ts +82 -0
  23. package/runtime/node_modules/@metro-labs/discord/src/voice-speak.ts +195 -0
  24. package/runtime/node_modules/@metro-labs/discord/src/voice-transcribe.ts +292 -0
  25. package/runtime/node_modules/@metro-labs/discord/src/voice.ts +197 -0
  26. package/runtime/node_modules/@metro-labs/discord/src/wire.ts +5 -0
  27. package/runtime/node_modules/@metro-labs/mcp/package.json +66 -0
  28. package/runtime/node_modules/@metro-labs/mcp/src/channels/dedupe.ts +24 -0
  29. package/runtime/node_modules/@metro-labs/mcp/src/channels/inbound.ts +367 -0
  30. package/runtime/node_modules/@metro-labs/mcp/src/channels/media-note.ts +85 -0
  31. package/runtime/node_modules/@metro-labs/mcp/src/channels/pending.ts +75 -0
  32. package/runtime/node_modules/@metro-labs/mcp/src/channels/relay.ts +129 -0
  33. package/runtime/node_modules/@metro-labs/mcp/src/channels/webhook-note.ts +51 -0
  34. package/runtime/node_modules/@metro-labs/mcp/src/daemon/account-api.ts +311 -0
  35. package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +335 -0
  36. package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-error.ts +8 -0
  37. package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +129 -0
  38. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-grant.ts +99 -0
  39. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-owner.ts +32 -0
  40. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-serve.ts +124 -0
  41. package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-session.ts +278 -0
  42. package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +322 -0
  43. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair-api.ts +111 -0
  44. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cli-pair.ts +20 -0
  45. package/runtime/node_modules/@metro-labs/mcp/src/daemon/collection-api.ts +202 -0
  46. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +338 -0
  47. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-json.ts +31 -0
  48. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth-routes.ts +157 -0
  49. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth.ts +127 -0
  50. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-tools.ts +100 -0
  51. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-url.ts +57 -0
  52. package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-verify.ts +333 -0
  53. package/runtime/node_modules/@metro-labs/mcp/src/daemon/cors.ts +29 -0
  54. package/runtime/node_modules/@metro-labs/mcp/src/daemon/crash-guard.ts +49 -0
  55. package/runtime/node_modules/@metro-labs/mcp/src/daemon/events.ts +117 -0
  56. package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-auth.ts +164 -0
  57. package/runtime/node_modules/@metro-labs/mcp/src/daemon/google-oauth.ts +208 -0
  58. package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +357 -0
  59. package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity.ts +79 -0
  60. package/runtime/node_modules/@metro-labs/mcp/src/daemon/log.ts +42 -0
  61. package/runtime/node_modules/@metro-labs/mcp/src/daemon/oauth-client.ts +162 -0
  62. package/runtime/node_modules/@metro-labs/mcp/src/daemon/oauth-discovery.ts +120 -0
  63. package/runtime/node_modules/@metro-labs/mcp/src/daemon/oauth-pending.ts +65 -0
  64. package/runtime/node_modules/@metro-labs/mcp/src/daemon/pair-codes.ts +58 -0
  65. package/runtime/node_modules/@metro-labs/mcp/src/daemon/paths.ts +134 -0
  66. package/runtime/node_modules/@metro-labs/mcp/src/daemon/project-api.ts +214 -0
  67. package/runtime/node_modules/@metro-labs/mcp/src/daemon/protocol.ts +224 -0
  68. package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-api.ts +125 -0
  69. package/runtime/node_modules/@metro-labs/mcp/src/daemon/run-pair.ts +18 -0
  70. package/runtime/node_modules/@metro-labs/mcp/src/daemon/runtime-source.ts +105 -0
  71. package/runtime/node_modules/@metro-labs/mcp/src/daemon/secure-fs.ts +57 -0
  72. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-api.ts +27 -0
  73. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +41 -0
  74. package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +218 -0
  75. package/runtime/node_modules/@metro-labs/mcp/src/daemon/supervisor-io.ts +97 -0
  76. package/runtime/node_modules/@metro-labs/mcp/src/daemon/supervisor.ts +323 -0
  77. package/runtime/node_modules/@metro-labs/mcp/src/daemon/train-call.ts +22 -0
  78. package/runtime/node_modules/@metro-labs/mcp/src/daemon/train-error.ts +53 -0
  79. package/runtime/node_modules/@metro-labs/mcp/src/daemon/tunnel.ts +169 -0
  80. package/runtime/node_modules/@metro-labs/mcp/src/daemon/upload-api.ts +295 -0
  81. package/runtime/node_modules/@metro-labs/mcp/src/daemon/upload-store.ts +181 -0
  82. package/runtime/node_modules/@metro-labs/mcp/src/db/account-attach.ts +91 -0
  83. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-admin.ts +215 -0
  84. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-map.ts +71 -0
  85. package/runtime/node_modules/@metro-labs/mcp/src/db/agent-scope.ts +64 -0
  86. package/runtime/node_modules/@metro-labs/mcp/src/db/client.ts +37 -0
  87. package/runtime/node_modules/@metro-labs/mcp/src/db/connector-collections.ts +218 -0
  88. package/runtime/node_modules/@metro-labs/mcp/src/db/connector-config.ts +147 -0
  89. package/runtime/node_modules/@metro-labs/mcp/src/db/connectors.ts +329 -0
  90. package/runtime/node_modules/@metro-labs/mcp/src/db/ids.ts +19 -0
  91. package/runtime/node_modules/@metro-labs/mcp/src/db/key-map.ts +45 -0
  92. package/runtime/node_modules/@metro-labs/mcp/src/db/materialize.ts +364 -0
  93. package/runtime/node_modules/@metro-labs/mcp/src/db/projects.ts +300 -0
  94. package/runtime/node_modules/@metro-labs/mcp/src/db/runtime-admin.ts +31 -0
  95. package/runtime/node_modules/@metro-labs/mcp/src/db/runtimes.ts +105 -0
  96. package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +131 -0
  97. package/runtime/node_modules/@metro-labs/mcp/src/db/token-carry.ts +45 -0
  98. package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +57 -0
  99. package/runtime/node_modules/@metro-labs/mcp/src/db/whatsapp-login.ts +18 -0
  100. package/runtime/node_modules/@metro-labs/mcp/src/mcp/accounts.ts +145 -0
  101. package/runtime/node_modules/@metro-labs/mcp/src/mcp/call-tools.ts +203 -0
  102. package/runtime/node_modules/@metro-labs/mcp/src/mcp/channel-owner.ts +29 -0
  103. package/runtime/node_modules/@metro-labs/mcp/src/mcp/ctx.ts +62 -0
  104. package/runtime/node_modules/@metro-labs/mcp/src/mcp/event-store.ts +91 -0
  105. package/runtime/node_modules/@metro-labs/mcp/src/mcp/frame-scope.ts +21 -0
  106. package/runtime/node_modules/@metro-labs/mcp/src/mcp/group-tools.ts +129 -0
  107. package/runtime/node_modules/@metro-labs/mcp/src/mcp/index.ts +192 -0
  108. package/runtime/node_modules/@metro-labs/mcp/src/mcp/member-tools.ts +72 -0
  109. package/runtime/node_modules/@metro-labs/mcp/src/mcp/permission-relay.ts +66 -0
  110. package/runtime/node_modules/@metro-labs/mcp/src/mcp/raw-get-stream.ts +207 -0
  111. package/runtime/node_modules/@metro-labs/mcp/src/mcp/request-identity.ts +73 -0
  112. package/runtime/node_modules/@metro-labs/mcp/src/mcp/session-registry.ts +172 -0
  113. package/runtime/node_modules/@metro-labs/mcp/src/mcp/session-route.ts +43 -0
  114. package/runtime/node_modules/@metro-labs/mcp/src/mcp/session.ts +211 -0
  115. package/runtime/node_modules/@metro-labs/mcp/src/mcp/str.ts +5 -0
  116. package/runtime/node_modules/@metro-labs/mcp/src/mcp/tool-dispatch.ts +212 -0
  117. package/runtime/node_modules/@metro-labs/mcp/src/mcp/tool-schemas.ts +397 -0
  118. package/runtime/node_modules/@metro-labs/mcp/src/mcp/upload-tool.ts +66 -0
  119. package/runtime/node_modules/@metro-labs/mcp/src/monitor/api.ts +240 -0
  120. package/runtime/node_modules/@metro-labs/mcp/src/server.ts +2 -0
  121. package/runtime/node_modules/@metro-labs/mcp/src/stations/account-store.ts +80 -0
  122. package/runtime/node_modules/@metro-labs/mcp/src/stations/attach-inline.ts +100 -0
  123. package/runtime/node_modules/@metro-labs/mcp/src/stations/attach-interactive.ts +161 -0
  124. package/runtime/node_modules/@metro-labs/mcp/src/stations/attach-resolve.ts +200 -0
  125. package/runtime/node_modules/@metro-labs/mcp/src/stations/attach-xmtp.ts +111 -0
  126. package/runtime/node_modules/@metro-labs/mcp/src/stations/attach.ts +182 -0
  127. package/runtime/node_modules/@metro-labs/mcp/src/stations/attachments.ts +196 -0
  128. package/runtime/node_modules/@metro-labs/mcp/src/stations/lines.ts +82 -0
  129. package/runtime/node_modules/@metro-labs/mcp/src/stations/messaging-normalize.ts +88 -0
  130. package/runtime/node_modules/@metro-labs/mcp/src/stations/registry.ts +35 -0
  131. package/runtime/node_modules/@metro-labs/mcp/src/stations/station-runtime.ts +64 -0
  132. package/runtime/node_modules/@metro-labs/mcp/src/stations/types.ts +107 -0
  133. package/runtime/node_modules/@metro-labs/telegram/package.json +25 -0
  134. package/runtime/node_modules/@metro-labs/telegram/src/accounts.ts +120 -0
  135. package/runtime/node_modules/@metro-labs/telegram/src/actions.ts +295 -0
  136. package/runtime/node_modules/@metro-labs/telegram/src/attachments.ts +128 -0
  137. package/runtime/node_modules/@metro-labs/telegram/src/format.ts +172 -0
  138. package/runtime/node_modules/@metro-labs/telegram/src/index.ts +103 -0
  139. package/runtime/node_modules/@metro-labs/telegram/src/media-actions.ts +166 -0
  140. package/runtime/node_modules/@metro-labs/telegram/src/members.ts +47 -0
  141. package/runtime/node_modules/@metro-labs/telegram/src/station.ts +17 -0
  142. package/runtime/node_modules/@metro-labs/telegram/src/types.ts +48 -0
  143. package/runtime/node_modules/@metro-labs/telegram/src/verify.ts +38 -0
  144. package/runtime/node_modules/@metro-labs/telegram/src/wire.ts +3 -0
  145. package/runtime/node_modules/@metro-labs/telegram-user/package.json +28 -0
  146. package/runtime/node_modules/@metro-labs/telegram-user/src/accounts.ts +81 -0
  147. package/runtime/node_modules/@metro-labs/telegram-user/src/actions.ts +254 -0
  148. package/runtime/node_modules/@metro-labs/telegram-user/src/attachments.ts +87 -0
  149. package/runtime/node_modules/@metro-labs/telegram-user/src/client.ts +77 -0
  150. package/runtime/node_modules/@metro-labs/telegram-user/src/format.ts +141 -0
  151. package/runtime/node_modules/@metro-labs/telegram-user/src/group.ts +261 -0
  152. package/runtime/node_modules/@metro-labs/telegram-user/src/history.ts +52 -0
  153. package/runtime/node_modules/@metro-labs/telegram-user/src/inbound.ts +78 -0
  154. package/runtime/node_modules/@metro-labs/telegram-user/src/index.ts +46 -0
  155. package/runtime/node_modules/@metro-labs/telegram-user/src/login.ts +166 -0
  156. package/runtime/node_modules/@metro-labs/telegram-user/src/media-actions.ts +80 -0
  157. package/runtime/node_modules/@metro-labs/telegram-user/src/members.ts +75 -0
  158. package/runtime/node_modules/@metro-labs/telegram-user/src/normalize.ts +22 -0
  159. package/runtime/node_modules/@metro-labs/telegram-user/src/reactions.ts +167 -0
  160. package/runtime/node_modules/@metro-labs/telegram-user/src/station.ts +24 -0
  161. package/runtime/node_modules/@metro-labs/telegram-user/src/types.ts +7 -0
  162. package/runtime/node_modules/@metro-labs/telegram-user/src/wire.ts +1 -0
  163. package/runtime/node_modules/@metro-labs/webhook/package.json +23 -0
  164. package/runtime/node_modules/@metro-labs/webhook/src/station.ts +40 -0
  165. package/runtime/node_modules/@metro-labs/whatsapp/package.json +29 -0
  166. package/runtime/node_modules/@metro-labs/whatsapp/src/accounts.ts +67 -0
  167. package/runtime/node_modules/@metro-labs/whatsapp/src/ack.ts +101 -0
  168. package/runtime/node_modules/@metro-labs/whatsapp/src/actions.ts +178 -0
  169. package/runtime/node_modules/@metro-labs/whatsapp/src/attachments.ts +72 -0
  170. package/runtime/node_modules/@metro-labs/whatsapp/src/auth-state.ts +129 -0
  171. package/runtime/node_modules/@metro-labs/whatsapp/src/client.ts +318 -0
  172. package/runtime/node_modules/@metro-labs/whatsapp/src/delivery.ts +49 -0
  173. package/runtime/node_modules/@metro-labs/whatsapp/src/format.ts +132 -0
  174. package/runtime/node_modules/@metro-labs/whatsapp/src/inbound.ts +57 -0
  175. package/runtime/node_modules/@metro-labs/whatsapp/src/index.ts +40 -0
  176. package/runtime/node_modules/@metro-labs/whatsapp/src/keys.ts +59 -0
  177. package/runtime/node_modules/@metro-labs/whatsapp/src/logger.ts +60 -0
  178. package/runtime/node_modules/@metro-labs/whatsapp/src/login.ts +176 -0
  179. package/runtime/node_modules/@metro-labs/whatsapp/src/media.ts +84 -0
  180. package/runtime/node_modules/@metro-labs/whatsapp/src/normalize.ts +22 -0
  181. package/runtime/node_modules/@metro-labs/whatsapp/src/outbox.ts +38 -0
  182. package/runtime/node_modules/@metro-labs/whatsapp/src/parse.ts +141 -0
  183. package/runtime/node_modules/@metro-labs/whatsapp/src/station.ts +17 -0
  184. package/runtime/node_modules/@metro-labs/whatsapp/src/token-store.ts +98 -0
  185. package/runtime/node_modules/@metro-labs/whatsapp/src/types.ts +6 -0
  186. package/runtime/node_modules/@metro-labs/whatsapp/src/wire.ts +5 -0
  187. package/runtime/node_modules/@metro-labs/xmtp/package.json +33 -0
  188. package/runtime/node_modules/@metro-labs/xmtp/src/accounts.ts +113 -0
  189. package/runtime/node_modules/@metro-labs/xmtp/src/actions-close.ts +110 -0
  190. package/runtime/node_modules/@metro-labs/xmtp/src/actions-conv.ts +299 -0
  191. package/runtime/node_modules/@metro-labs/xmtp/src/actions-meta.ts +157 -0
  192. package/runtime/node_modules/@metro-labs/xmtp/src/actions-push.ts +87 -0
  193. package/runtime/node_modules/@metro-labs/xmtp/src/actions.ts +375 -0
  194. package/runtime/node_modules/@metro-labs/xmtp/src/attachments.ts +107 -0
  195. package/runtime/node_modules/@metro-labs/xmtp/src/codecs.ts +171 -0
  196. package/runtime/node_modules/@metro-labs/xmtp/src/conv-helpers.ts +239 -0
  197. package/runtime/node_modules/@metro-labs/xmtp/src/emit-core.ts +55 -0
  198. package/runtime/node_modules/@metro-labs/xmtp/src/emit-payloads.ts +333 -0
  199. package/runtime/node_modules/@metro-labs/xmtp/src/emit.ts +78 -0
  200. package/runtime/node_modules/@metro-labs/xmtp/src/group-members.ts +34 -0
  201. package/runtime/node_modules/@metro-labs/xmtp/src/group.ts +56 -0
  202. package/runtime/node_modules/@metro-labs/xmtp/src/identity.ts +33 -0
  203. package/runtime/node_modules/@metro-labs/xmtp/src/index.ts +125 -0
  204. package/runtime/node_modules/@metro-labs/xmtp/src/labels.ts +157 -0
  205. package/runtime/node_modules/@metro-labs/xmtp/src/member-args.ts +40 -0
  206. package/runtime/node_modules/@metro-labs/xmtp/src/push.ts +349 -0
  207. package/runtime/node_modules/@metro-labs/xmtp/src/station.ts +13 -0
  208. package/runtime/node_modules/@metro-labs/xmtp/src/tools-handlers.ts +126 -0
  209. package/runtime/node_modules/@metro-labs/xmtp/src/tools.ts +212 -0
  210. package/runtime/node_modules/@metro-labs/xmtp/src/transcribe.ts +90 -0
  211. package/runtime/node_modules/@metro-labs/xmtp/src/verify.ts +84 -0
  212. package/runtime/node_modules/@metro-labs/xmtp/src/wire.ts +39 -0
  213. package/runtime/runtime.json +3 -0
  214. package/runtime/trains/.keep +0 -0
  215. package/LICENSE +0 -21
  216. package/README.md +0 -78
  217. package/dist/channels/discord.js +0 -148
  218. package/dist/channels/telegram.js +0 -137
  219. package/dist/lib/address.js +0 -21
  220. package/dist/lib/codex-rc.js +0 -274
  221. package/dist/lib/dotenv.js +0 -31
  222. package/dist/log.js +0 -12
  223. package/dist/paths.js +0 -45
  224. package/dist/tail.js +0 -161
  225. package/skills/metro/SKILL.md +0 -122
@@ -0,0 +1,169 @@
1
+ import { spawn, spawnSync, type ChildProcess } from 'node:child_process';
2
+ import { timingSafeEqual } from 'node:crypto';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { STATE_DIR } from './paths.js';
6
+ import { errMsg, log } from './log.js';
7
+ import { readJson } from './secure-fs.js';
8
+
9
+ const FILE = join(STATE_DIR, 'tunnel.json');
10
+ const LEGACY_WEBHOOKS_FILE = join(STATE_DIR, 'webhooks.json');
11
+ const RESTART_DELAY_MS = 2_000;
12
+
13
+ interface TunnelConfig {
14
+ name: string;
15
+ hostname: string;
16
+ }
17
+
18
+ export interface Endpoint {
19
+ id: string;
20
+ webhookId?: string;
21
+ label: string;
22
+ secret?: string;
23
+ session?: string;
24
+ createdAt: string;
25
+ }
26
+ interface AccountRecord {
27
+ id?: unknown;
28
+ webhookId?: unknown;
29
+ label?: unknown;
30
+ secret?: unknown;
31
+ session?: unknown;
32
+ createdAt?: unknown;
33
+ }
34
+
35
+ export const webhookPort = (): number =>
36
+ Number(process.env.METRO_WEBHOOK_PORT) || 8420;
37
+
38
+ const accountsFile = (): string =>
39
+ process.env.WEBHOOK_ACCOUNTS_FILE ??
40
+ join(homedir(), '.metro', 'webhook-accounts.json');
41
+
42
+ const str = (v: unknown): string | undefined =>
43
+ typeof v === 'string' && v !== '' ? v : undefined;
44
+
45
+ function toEndpoint(raw: AccountRecord): Endpoint | null {
46
+ const id = str(raw.id);
47
+ if (id === undefined) return null;
48
+ return {
49
+ id,
50
+ webhookId: str(raw.webhookId),
51
+ label: str(raw.label) ?? id,
52
+ secret: str(raw.secret),
53
+ session: str(raw.session),
54
+ createdAt: str(raw.createdAt) ?? '',
55
+ };
56
+ }
57
+
58
+ export function listEndpoints(): Endpoint[] {
59
+ const raw = readJson<AccountRecord[]>(accountsFile(), [], {
60
+ warn: 'webhook-accounts.json: malformed, ignoring',
61
+ });
62
+ if (!Array.isArray(raw)) return [];
63
+ return raw.map(toEndpoint).filter((e): e is Endpoint => e !== null);
64
+ }
65
+
66
+ export const findEndpointByWebhookId = (
67
+ webhookId: string,
68
+ ): Endpoint | undefined =>
69
+ listEndpoints().find((e) => e.webhookId === webhookId);
70
+
71
+ export function tokenMatches(secret: string, given: string): boolean {
72
+ const want = Buffer.from(secret);
73
+ const got = Buffer.from(given);
74
+ return want.length === got.length && timingSafeEqual(want, got);
75
+ }
76
+
77
+ export function warnOnLegacyWebhooks(): void {
78
+ const legacy = readJson<{ endpoints?: unknown[] }>(LEGACY_WEBHOOKS_FILE, {});
79
+ const count = Array.isArray(legacy.endpoints) ? legacy.endpoints.length : 0;
80
+ if (count > 0)
81
+ log.warn(
82
+ { file: LEGACY_WEBHOOKS_FILE, endpoints: count },
83
+ 'webhook: webhooks.json is no longer read — endpoints live in the accounts table; these endpoints are inactive',
84
+ );
85
+ }
86
+
87
+ export const loadTunnelConfig = (): TunnelConfig | null =>
88
+ readJson<TunnelConfig | null>(FILE, null, {
89
+ warn: 'tunnel.json: malformed, ignoring',
90
+ });
91
+
92
+ function fetchTunnelToken(name: string): string | null {
93
+ const r = spawnSync('cloudflared', ['tunnel', 'token', name], {
94
+ encoding: 'utf8',
95
+ });
96
+ if (r.status !== 0) return null;
97
+ const token = r.stdout.trim();
98
+ return token.length > 0 ? token : null;
99
+ }
100
+
101
+ export class Tunnel {
102
+ private child: ChildProcess | null = null;
103
+ private closed = false;
104
+ private token: string | null | undefined = undefined;
105
+
106
+ constructor(
107
+ private cfg: TunnelConfig,
108
+ private port: number,
109
+ ) {}
110
+
111
+ get hostname(): string {
112
+ return this.cfg.hostname;
113
+ }
114
+
115
+ start(): void {
116
+ if (this.closed) return;
117
+ if (this.token === undefined) this.token = fetchTunnelToken(this.cfg.name);
118
+ const mode = this.token ? 'token' : 'named';
119
+ log.info(
120
+ {
121
+ name: this.cfg.name,
122
+ hostname: this.cfg.hostname,
123
+ port: this.port,
124
+ mode,
125
+ },
126
+ 'cloudflared tunnel starting',
127
+ );
128
+ const args = [
129
+ '--no-autoupdate',
130
+ 'tunnel',
131
+ 'run',
132
+ '--url',
133
+ `http://127.0.0.1:${this.port}`,
134
+ ];
135
+ if (!this.token) args.push(this.cfg.name);
136
+ const env = this.token
137
+ ? { ...process.env, TUNNEL_TOKEN: this.token }
138
+ : process.env;
139
+ this.child = spawn('cloudflared', args, {
140
+ stdio: ['ignore', 'pipe', 'pipe'],
141
+ env,
142
+ });
143
+ this.child.stderr?.on('data', (d: Buffer | string) => {
144
+ log.debug(
145
+ {
146
+ cloudflared: (typeof d === 'string' ? d : d.toString('utf8')).trim(),
147
+ },
148
+ 'cloudflared',
149
+ );
150
+ });
151
+ this.child.on('exit', (code) => {
152
+ this.child = null;
153
+ if (this.closed) return;
154
+ log.warn({ code }, 'cloudflared exited; restarting');
155
+ setTimeout(() => {
156
+ this.start();
157
+ }, RESTART_DELAY_MS);
158
+ });
159
+ this.child.on('error', (err) => {
160
+ log.warn({ err: errMsg(err) }, 'cloudflared spawn error');
161
+ });
162
+ }
163
+
164
+ stop(): void {
165
+ this.closed = true;
166
+ this.child?.kill();
167
+ this.child = null;
168
+ }
169
+ }
@@ -0,0 +1,295 @@
1
+ import { open, rename, rm, type FileHandle } from 'node:fs/promises';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import { guessMime } from '../stations/attachments.js';
4
+ import { safeFileName } from '../stations/attach-inline.js';
5
+ import {
6
+ allowedAgents,
7
+ authConfigFromEnv,
8
+ authenticate,
9
+ extractToken,
10
+ } from '../mcp/request-identity.js';
11
+ import { ApiError } from './api-error.js';
12
+ import { apiFailure, cors, sendJson } from './api-http.js';
13
+ import { log } from './log.js';
14
+ import { writeSecure } from './secure-fs.js';
15
+ import {
16
+ createUploadSlot,
17
+ liveUploadBytes,
18
+ MAX_LIVE_UPLOAD_BYTES,
19
+ MAX_UPLOAD_BYTES,
20
+ readUpload,
21
+ removeUpload,
22
+ sweepUploads,
23
+ uploadPath,
24
+ uploadSlot,
25
+ uploadTicketAllows,
26
+ UPLOAD_TTL_MS,
27
+ type UploadRecord,
28
+ } from './upload-store.js';
29
+
30
+ const PREFIX = '/api/uploads';
31
+ const ITEM_METHODS = ['PUT', 'POST', 'DELETE'];
32
+ const DRAIN_MAX = 2 * MAX_UPLOAD_BYTES;
33
+ const VAGUE_MIMES = new Set([
34
+ 'application/x-www-form-urlencoded',
35
+ 'application/octet-stream',
36
+ '',
37
+ ]);
38
+
39
+ const mib = (n: number): string => `${Math.round(n / (1024 * 1024))} MiB`;
40
+
41
+ export const uploadLimit = (): string =>
42
+ `${mib(MAX_UPLOAD_BYTES)} (${MAX_UPLOAD_BYTES} bytes)`;
43
+
44
+ const tooLarge = (): string =>
45
+ `upload exceeds the ${uploadLimit()} per-file limit; ` +
46
+ 'send a link in `text` for anything larger';
47
+
48
+ const noRoom = (): string =>
49
+ `metro is already holding close to ${mib(MAX_LIVE_UPLOAD_BYTES)} of pending uploads, ` +
50
+ `which is the daemon-wide cap; each one expires ${Math.round(UPLOAD_TTL_MS / 60_000)} ` +
51
+ 'minutes after it is created, or DELETE one you no longer need';
52
+
53
+ type Target =
54
+ | { kind: 'collection' }
55
+ | { kind: 'item'; id: string }
56
+ | { kind: 'unknown' }
57
+ | null;
58
+
59
+ export function target(path: string): Target {
60
+ if (path === PREFIX || path === `${PREFIX}/`) return { kind: 'collection' };
61
+ if (!path.startsWith(`${PREFIX}/`)) return null;
62
+ const rest = path.slice(PREFIX.length + 1).split('/').filter(Boolean);
63
+ const id = rest.length === 1 ? rest[0] : undefined;
64
+ if (id === undefined || uploadPath(id) === null) return { kind: 'unknown' };
65
+ return { kind: 'item', id };
66
+ }
67
+
68
+ const query = (req: IncomingMessage, key: string): string | undefined =>
69
+ new URL(req.url ?? '/', 'http://localhost').searchParams.get(key) ?? undefined;
70
+
71
+ const identityScope = (req: IncomingMessage): Set<string> =>
72
+ allowedAgents(authenticate(req, authConfigFromEnv()) ?? undefined);
73
+
74
+ function ownerFromScope(req: IncomingMessage, allowed: Set<string>): string {
75
+ const requested = query(req, 'agent');
76
+ if (requested !== undefined) {
77
+ if (!allowed.has(requested))
78
+ throw new ApiError(`agent ${requested} is outside your scope`, 403);
79
+ return requested;
80
+ }
81
+ const [only] = [...allowed];
82
+ if (allowed.size === 1 && only !== undefined) return only;
83
+ throw new ApiError(
84
+ `this credential covers ${allowed.size} agents; ` +
85
+ 'name the owning agent with ?agent=<id>',
86
+ 400,
87
+ );
88
+ }
89
+
90
+ function mimeFor(req: IncomingMessage, name: string): string {
91
+ const explicit = query(req, 'mime');
92
+ if (explicit !== undefined && explicit !== '') return explicit;
93
+ const header = (req.headers['content-type'] ?? '').split(';')[0]?.trim() ?? '';
94
+ return VAGUE_MIMES.has(header.toLowerCase()) ? guessMime(name) : header;
95
+ }
96
+
97
+ function roomForUpload(): number {
98
+ sweepUploads();
99
+ const room = MAX_LIVE_UPLOAD_BYTES - liveUploadBytes();
100
+ if (room <= 0) throw new ApiError(noRoom(), 507);
101
+ return room;
102
+ }
103
+
104
+ const overflowAt = (total: number, room: number): ApiError | undefined => {
105
+ if (total > MAX_UPLOAD_BYTES) return new ApiError(tooLarge(), 413);
106
+ return total > room ? new ApiError(noRoom(), 507) : undefined;
107
+ };
108
+
109
+ function declaredLength(req: IncomingMessage): number | undefined {
110
+ const n = Number(req.headers['content-length']);
111
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
112
+ }
113
+
114
+ function assertRoomForDeclared(req: IncomingMessage, room: number): void {
115
+ const declared = declaredLength(req);
116
+ if (declared === undefined) return;
117
+ if (declared > MAX_UPLOAD_BYTES) throw new ApiError(tooLarge(), 413);
118
+ if (declared > room) throw new ApiError(noRoom(), 507);
119
+ }
120
+
121
+ async function drainInto(
122
+ req: IncomingMessage,
123
+ handle: FileHandle,
124
+ room: number,
125
+ ): Promise<number> {
126
+ let over: ApiError | undefined;
127
+ let total = 0;
128
+ try {
129
+ for await (const chunk of req) {
130
+ const buf = chunk as Buffer;
131
+ total += buf.length;
132
+ over ??= overflowAt(total, room);
133
+ if (over === undefined) await handle.write(buf);
134
+ else if (total > DRAIN_MAX) break;
135
+ }
136
+ } finally {
137
+ await handle.close();
138
+ }
139
+ if (over !== undefined) throw over;
140
+ if (total === 0) throw new ApiError('upload body is empty', 400);
141
+ return total;
142
+ }
143
+
144
+ async function streamToSlot(req: IncomingMessage, id: string): Promise<number> {
145
+ const part = uploadPath(id, '.part');
146
+ const final = uploadPath(id);
147
+ if (part === null || final === null) throw new ApiError('bad upload id', 400);
148
+ const room = roomForUpload();
149
+ assertRoomForDeclared(req, room);
150
+ const handle = await open(part, 'wx', 0o600).catch(() => {
151
+ throw new ApiError('this upload is already being written', 409);
152
+ });
153
+ const total = await drainInto(req, handle, room).catch(
154
+ async (err: unknown) => {
155
+ await rm(part, { force: true });
156
+ throw err;
157
+ },
158
+ );
159
+ await rename(part, final);
160
+ return total;
161
+ }
162
+
163
+ const payload = (rec: UploadRecord): Record<string, unknown> => ({
164
+ id: rec.id,
165
+ name: rec.name,
166
+ mime: rec.mime,
167
+ bytes: rec.bytes,
168
+ expires_at: new Date(rec.expiresAt).toISOString(),
169
+ attachment: { upload: rec.id },
170
+ });
171
+
172
+ async function fill(
173
+ req: IncomingMessage,
174
+ res: ServerResponse,
175
+ id: string,
176
+ status: number,
177
+ ): Promise<void> {
178
+ const bytes = await streamToSlot(req, id);
179
+ const rec = readUpload(id);
180
+ if (rec === undefined) throw new ApiError('upload expired while storing', 410);
181
+ log.info({ upload: id, agent: rec.agentId, bytes }, 'uploads: stored');
182
+ sendJson(req, res, status, payload(rec));
183
+ }
184
+
185
+ async function handleCreate(
186
+ req: IncomingMessage,
187
+ res: ServerResponse,
188
+ ): Promise<void> {
189
+ const allowed = identityScope(req);
190
+ if (allowed.size === 0) throw new ApiError('unauthorized', 401);
191
+ const name = safeFileName(query(req, 'name'));
192
+ const id = createUploadSlot(ownerFromScope(req, allowed), {
193
+ name,
194
+ mime: mimeFor(req, name),
195
+ });
196
+ try {
197
+ await fill(req, res, id, 201);
198
+ } catch (err) {
199
+ removeUpload(id);
200
+ throw err;
201
+ }
202
+ }
203
+
204
+ function authorizeItem(req: IncomingMessage, id: string): string {
205
+ const slot = uploadSlot(id);
206
+ if (slot === undefined) throw new ApiError('no such upload', 404);
207
+ if (uploadTicketAllows(id, slot.agentId, extractToken(req) ?? ''))
208
+ return slot.agentId;
209
+ if (identityScope(req).has(slot.agentId)) return slot.agentId;
210
+ throw new ApiError('no such upload', 404);
211
+ }
212
+
213
+ function relabelSlot(id: string, name: string, mime: string | undefined): void {
214
+ const slot = uploadSlot(id);
215
+ const path = uploadPath(id, '.meta');
216
+ if (slot === undefined || path === null) return;
217
+ writeSecure(
218
+ path,
219
+ JSON.stringify({
220
+ name,
221
+ mime: mime !== undefined && mime !== '' ? mime : guessMime(name),
222
+ createdAt: slot.createdAt,
223
+ }),
224
+ );
225
+ }
226
+
227
+ async function handleFill(
228
+ req: IncomingMessage,
229
+ res: ServerResponse,
230
+ id: string,
231
+ ): Promise<void> {
232
+ authorizeItem(req, id);
233
+ if (readUpload(id) !== undefined)
234
+ throw new ApiError('this upload already holds bytes', 409);
235
+ const name = query(req, 'name');
236
+ if (name !== undefined)
237
+ relabelSlot(id, safeFileName(name), query(req, 'mime'));
238
+ await fill(req, res, id, 200);
239
+ }
240
+
241
+ function handleDelete(
242
+ req: IncomingMessage,
243
+ res: ServerResponse,
244
+ id: string,
245
+ ): void {
246
+ authorizeItem(req, id);
247
+ removeUpload(id);
248
+ log.info({ upload: id }, 'uploads: deleted');
249
+ sendJson(req, res, 200, { id, deleted: true });
250
+ }
251
+
252
+ async function dispatch(
253
+ req: IncomingMessage,
254
+ res: ServerResponse,
255
+ tgt: { kind: 'collection' } | { kind: 'item'; id: string },
256
+ ): Promise<void> {
257
+ if (tgt.kind === 'collection') return handleCreate(req, res);
258
+ if (req.method === 'DELETE') handleDelete(req, res, tgt.id);
259
+ else await handleFill(req, res, tgt.id);
260
+ }
261
+
262
+ function closeIfBodyUnread(req: IncomingMessage, res: ServerResponse): void {
263
+ if (req.readableEnded) return;
264
+ const drop = (): void => {
265
+ req.socket?.destroy();
266
+ };
267
+ if (res.writableFinished) drop();
268
+ else res.once('finish', drop);
269
+ }
270
+
271
+ export function handleUploadRequest(
272
+ req: IncomingMessage,
273
+ res: ServerResponse,
274
+ ): boolean {
275
+ const tgt = target((req.url ?? '').split('?')[0] ?? '');
276
+ if (tgt === null) return false;
277
+ if (req.method === 'OPTIONS') {
278
+ res.writeHead(204, cors(req)).end();
279
+ return true;
280
+ }
281
+ if (tgt.kind === 'unknown') {
282
+ sendJson(req, res, 404, { error: 'no such upload' });
283
+ return true;
284
+ }
285
+ const methods = tgt.kind === 'collection' ? ['POST'] : ITEM_METHODS;
286
+ if (!methods.includes(req.method ?? '')) {
287
+ sendJson(req, res, 405, { error: 'method not allowed' });
288
+ return true;
289
+ }
290
+ dispatch(req, res, tgt).catch((err: unknown) => {
291
+ apiFailure(req, res, err, 'upload-api');
292
+ closeIfBodyUnread(req, res);
293
+ });
294
+ return true;
295
+ }
@@ -0,0 +1,181 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { readdirSync, rmSync, statSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { ensureSecureDir, readJson, writeSecure } from './secure-fs.js';
6
+ import { grantAllowsPath, issueGrant } from './attach-grant.js';
7
+ import { ownerOf, recordOwner } from './attach-owner.js';
8
+ import { errMsg, log } from './log.js';
9
+
10
+ export const UPLOAD_TTL_MS = 30 * 60 * 1000;
11
+ export const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
12
+ export const MAX_LIVE_UPLOAD_BYTES = 512 * 1024 * 1024;
13
+ export const UPLOAD_ID_RE = /^up_[A-Za-z0-9_-]{22}$/;
14
+
15
+ const SUFFIXES = ['', '.part', '.meta', '.owner', '.grant'] as const;
16
+ const SWEEP_MS = 60_000;
17
+
18
+ export const uploadDir = (): string =>
19
+ process.env.METRO_UPLOAD_DIR ?? join(tmpdir(), 'metro-uploads');
20
+
21
+ export const newUploadId = (): string =>
22
+ `up_${randomBytes(16).toString('base64url')}`;
23
+
24
+ export function uploadPath(id: string, suffix = ''): string | null {
25
+ return UPLOAD_ID_RE.test(id) ? `${uploadDir()}/${id}${suffix}` : null;
26
+ }
27
+
28
+ interface UploadMeta {
29
+ name: string;
30
+ mime: string;
31
+ createdAt: number;
32
+ }
33
+
34
+ export interface UploadRecord extends UploadMeta {
35
+ id: string;
36
+ agentId: string;
37
+ bytes: number;
38
+ path: string;
39
+ expiresAt: number;
40
+ }
41
+
42
+ const sizeOf = (path: string): number | undefined => {
43
+ try {
44
+ return statSync(path).size;
45
+ } catch {
46
+ return undefined;
47
+ }
48
+ };
49
+
50
+ function readMeta(id: string): UploadMeta | undefined {
51
+ const path = uploadPath(id, '.meta');
52
+ if (path === null) return undefined;
53
+ const meta = readJson<UploadMeta | null>(path, null);
54
+ if (meta === null || typeof meta.createdAt !== 'number') return undefined;
55
+ return meta;
56
+ }
57
+
58
+ export interface UploadSlot extends UploadMeta {
59
+ id: string;
60
+ agentId: string;
61
+ expiresAt: number;
62
+ }
63
+
64
+ export function uploadSlot(id: string): UploadSlot | undefined {
65
+ const base = uploadPath(id);
66
+ if (base === null) return undefined;
67
+ const meta = readMeta(id);
68
+ const agentId = ownerOf(base);
69
+ if (meta === undefined || agentId === undefined) return undefined;
70
+ const expiresAt = meta.createdAt + UPLOAD_TTL_MS;
71
+ return expiresAt <= Date.now() ? undefined : { ...meta, id, agentId, expiresAt };
72
+ }
73
+
74
+ export function readUpload(id: string): UploadRecord | undefined {
75
+ const slot = uploadSlot(id);
76
+ const path = uploadPath(id);
77
+ if (slot === undefined || path === null) return undefined;
78
+ const bytes = sizeOf(path);
79
+ return bytes === undefined ? undefined : { ...slot, bytes, path };
80
+ }
81
+
82
+ export function createUploadSlot(
83
+ agentId: string,
84
+ meta: { name: string; mime: string },
85
+ ): string {
86
+ ensureSecureDir(uploadDir());
87
+ const id = newUploadId();
88
+ const base = `${uploadDir()}/${id}`;
89
+ writeSecure(
90
+ `${base}.meta`,
91
+ JSON.stringify({ ...meta, createdAt: Date.now() }),
92
+ );
93
+ recordOwner(base, agentId);
94
+ return id;
95
+ }
96
+
97
+ export const issueUploadTicket = (
98
+ id: string,
99
+ agentId: string,
100
+ ): string | undefined => {
101
+ const base = uploadPath(id);
102
+ return base === null ? undefined : issueGrant(base, agentId, 'ut');
103
+ };
104
+
105
+ export const uploadTicketAllows = (
106
+ id: string,
107
+ owner: string,
108
+ presented: string,
109
+ ): boolean => {
110
+ const base = uploadPath(id);
111
+ return base === null ? false : grantAllowsPath(base, owner, presented);
112
+ };
113
+
114
+ export function removeUpload(id: string): void {
115
+ const base = uploadPath(id);
116
+ if (base === null) return;
117
+ for (const suffix of SUFFIXES)
118
+ rmSync(`${base}${suffix}`, { force: true, recursive: false });
119
+ }
120
+
121
+ const idsInDir = (): string[] => {
122
+ let entries: string[];
123
+ try {
124
+ entries = readdirSync(uploadDir());
125
+ } catch {
126
+ return [];
127
+ }
128
+ const ids = new Set<string>();
129
+ for (const entry of entries) {
130
+ const id = entry.split('.')[0] ?? '';
131
+ if (UPLOAD_ID_RE.test(id)) ids.add(id);
132
+ }
133
+ return [...ids];
134
+ };
135
+
136
+ export function liveUploadBytes(): number {
137
+ let total = 0;
138
+ for (const id of idsInDir())
139
+ for (const suffix of ['', '.part'])
140
+ total += sizeOf(`${uploadDir()}/${id}${suffix}`) ?? 0;
141
+ return total;
142
+ }
143
+
144
+ function ageOf(id: string, now: number): number {
145
+ const created = readMeta(id)?.createdAt;
146
+ if (created !== undefined) return now - created;
147
+ let newest = 0;
148
+ for (const suffix of SUFFIXES) {
149
+ let mtime: number;
150
+ try {
151
+ mtime = statSync(`${uploadDir()}/${id}${suffix}`).mtimeMs;
152
+ } catch {
153
+ continue;
154
+ }
155
+ newest = Math.max(newest, mtime);
156
+ }
157
+ return newest === 0 ? 0 : now - newest;
158
+ }
159
+
160
+ export function sweepUploads(now = Date.now()): number {
161
+ let removed = 0;
162
+ for (const id of idsInDir()) {
163
+ if (ageOf(id, now) <= UPLOAD_TTL_MS) continue;
164
+ removeUpload(id);
165
+ removed += 1;
166
+ }
167
+ return removed;
168
+ }
169
+
170
+ export function startUploadReaper(): void {
171
+ const run = (): void => {
172
+ try {
173
+ const removed = sweepUploads();
174
+ if (removed > 0) log.info({ removed }, 'uploads: reaped expired uploads');
175
+ } catch (err) {
176
+ log.warn({ err: errMsg(err) }, 'uploads: sweep failed');
177
+ }
178
+ };
179
+ run();
180
+ setInterval(run, SWEEP_MS).unref?.();
181
+ }