handmux 0.23.0 → 0.25.0

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 (327) hide show
  1. package/README.md +31 -8
  2. package/README.zh-CN.md +31 -8
  3. package/bin/handmux.js +14 -688
  4. package/bin/node-version.js +20 -0
  5. package/dist/bin/handmux.js +1015 -0
  6. package/dist/connectors/bridgeClient.js +385 -0
  7. package/dist/connectors/claude/index.js +476 -0
  8. package/dist/connectors/pi/bridgeClient.js +8 -0
  9. package/dist/connectors/pi/index.js +851 -0
  10. package/dist/hooks/handmux-notify.sh +64 -0
  11. package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
  12. package/dist/hooks/handmux-write.cjs +264 -0
  13. package/dist/package.json +127 -0
  14. package/dist/public/assets/index-CTrqTqtT.css +32 -0
  15. package/dist/public/assets/index-DmjeMSQA.js +346 -0
  16. package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
  17. package/{public → dist/public}/index.html +5 -4
  18. package/dist/src/agent-runtime/adapter.js +204 -0
  19. package/dist/src/agent-runtime/bridge.js +1006 -0
  20. package/dist/src/agent-runtime/bridgeStore.js +25 -0
  21. package/dist/src/agent-runtime/bridgeTransport.js +619 -0
  22. package/dist/src/agent-runtime/bridgeTypes.js +13 -0
  23. package/dist/src/agent-runtime/builtinRuntime.js +193 -0
  24. package/dist/src/agent-runtime/conversation.js +2179 -0
  25. package/dist/src/agent-runtime/conversationActivation.js +75 -0
  26. package/dist/src/agent-runtime/conversationActivity.js +69 -0
  27. package/dist/src/agent-runtime/conversationControls.js +206 -0
  28. package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
  29. package/dist/src/agent-runtime/conversationStore.js +352 -0
  30. package/dist/src/agent-runtime/conversationTypes.js +1 -0
  31. package/dist/src/agent-runtime/conversationValidation.js +339 -0
  32. package/dist/src/agent-runtime/inbox.js +973 -0
  33. package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
  34. package/dist/src/agent-runtime/inboxStore.js +25 -0
  35. package/dist/src/agent-runtime/inboxTypes.js +1 -0
  36. package/dist/src/agent-runtime/interaction.js +829 -0
  37. package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
  38. package/dist/src/agent-runtime/interactionStore.js +18 -0
  39. package/dist/src/agent-runtime/interactionTypes.js +1 -0
  40. package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
  41. package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
  42. package/dist/src/agent-runtime/resourceTypes.js +1 -0
  43. package/dist/src/agent-runtime/resources.js +245 -0
  44. package/dist/src/agent-runtime/run.js +328 -0
  45. package/dist/src/agent-runtime/runtime.js +793 -0
  46. package/dist/src/agent-runtime/sessionControl.js +164 -0
  47. package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
  48. package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
  49. package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
  50. package/dist/src/agents/claude.js +203 -0
  51. package/dist/src/agents/claudeConversation.js +362 -0
  52. package/dist/src/agents/claudeInteraction.js +134 -0
  53. package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
  54. package/dist/src/agents/codex.js +262 -0
  55. package/dist/src/agents/codexConversation.js +1103 -0
  56. package/dist/src/agents/codexConversationActivation.js +121 -0
  57. package/dist/src/agents/codexConversationControls.js +256 -0
  58. package/dist/src/agents/codexInteraction.js +306 -0
  59. package/dist/src/agents/codexSessionControl.js +118 -0
  60. package/dist/src/agents/codexSubscriptionUsage.js +313 -0
  61. package/dist/src/agents/codexTerminalControl.js +23 -0
  62. package/dist/src/agents/conversationProjectionSafety.js +416 -0
  63. package/dist/src/agents/index.js +39 -0
  64. package/dist/src/agents/nativeInbox.js +264 -0
  65. package/dist/src/agents/nativeInboxSources.js +115 -0
  66. package/dist/src/agents/pi.js +28 -0
  67. package/dist/src/agents/piConversation.js +523 -0
  68. package/dist/src/agents/piConversationHistory.js +502 -0
  69. package/dist/src/agents/piInboxBridge.js +368 -0
  70. package/dist/src/agents/piSessionControl.js +32 -0
  71. package/dist/src/agents/processIdentity.js +94 -0
  72. package/dist/src/agents/scanUtils.js +262 -0
  73. package/dist/src/apiAccountProviders.js +194 -0
  74. package/dist/src/apiAccounts.js +592 -0
  75. package/dist/src/apiErrors.js +86 -0
  76. package/{src → dist/src}/appName.js +12 -13
  77. package/dist/src/asr/iflyConfig.js +7 -0
  78. package/dist/src/asr/iflySign.js +14 -0
  79. package/dist/src/auth.js +27 -0
  80. package/dist/src/browser/bootstrap.js +72 -0
  81. package/dist/src/browser/cookieProfiles.js +413 -0
  82. package/dist/src/browser/coordinator.js +51 -0
  83. package/dist/src/browser/hammerheadCompat.js +104 -0
  84. package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
  85. package/dist/src/browser/manager.js +560 -0
  86. package/dist/src/browser/originLabel.js +15 -0
  87. package/dist/src/browser/profilePersistence.js +235 -0
  88. package/dist/src/browser/publicProxy.js +176 -0
  89. package/dist/src/browser/routes.js +159 -0
  90. package/dist/src/browser/sessionStore.js +72 -0
  91. package/dist/src/browser/siteVersion.js +121 -0
  92. package/dist/src/browser/targetPolicy.js +163 -0
  93. package/{src → dist/src}/browser/worker.js +14 -18
  94. package/dist/src/browser/workerClient.js +286 -0
  95. package/dist/src/browser/workerServer.js +91 -0
  96. package/dist/src/captureBackground.js +95 -0
  97. package/dist/src/claudeEvents.js +525 -0
  98. package/dist/src/cli/agentIntegration.js +131 -0
  99. package/dist/src/cli/agentLauncher.js +26 -0
  100. package/dist/src/cli/claudeHooks.js +290 -0
  101. package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
  102. package/dist/src/cli/cloudflared.js +106 -0
  103. package/dist/src/cli/codexManaged.js +80 -0
  104. package/{src → dist/src}/cli/cpolarUrl.js +8 -9
  105. package/dist/src/cli/drivers.js +98 -0
  106. package/dist/src/cli/hookScaffold.js +39 -0
  107. package/dist/src/cli/i18n/en.js +392 -0
  108. package/{src → dist/src}/cli/i18n/index.js +27 -25
  109. package/dist/src/cli/i18n/zh.js +391 -0
  110. package/dist/src/cli/legacyCodexHooks.js +52 -0
  111. package/{src → dist/src}/cli/natappUrl.js +2 -3
  112. package/dist/src/cli/options.js +245 -0
  113. package/dist/src/cli/piExtension.js +155 -0
  114. package/dist/src/cli/probe.js +16 -0
  115. package/{src → dist/src}/cli/prompt.js +4 -6
  116. package/dist/src/cli/pushCmd.js +102 -0
  117. package/{src → dist/src}/cli/qr.js +19 -19
  118. package/dist/src/cli/service.js +154 -0
  119. package/dist/src/cli/setupModel.js +194 -0
  120. package/dist/src/cli/setupWizard.js +647 -0
  121. package/dist/src/cli/shortcutEditor.js +351 -0
  122. package/dist/src/cli/sshTunnel.js +18 -0
  123. package/dist/src/cli/state.js +117 -0
  124. package/dist/src/cli/statusLine.js +115 -0
  125. package/dist/src/cli/supervisor.js +278 -0
  126. package/dist/src/cli/supervisorHealth.js +21 -0
  127. package/dist/src/cli/supervisorLaunch.js +148 -0
  128. package/dist/src/cli/supervisorProcesses.js +62 -0
  129. package/dist/src/cli/supervisorState.js +62 -0
  130. package/dist/src/cli/tmuxVersion.js +53 -0
  131. package/{src → dist/src}/cli/tunlite.js +7 -10
  132. package/dist/src/cli/tunnelClients.js +72 -0
  133. package/{src → dist/src}/cli/updateCheck.js +98 -69
  134. package/{src → dist/src}/cli/urlHost.js +8 -4
  135. package/dist/src/cli/workspaceCmd.js +347 -0
  136. package/dist/src/codexAppServer.js +3436 -0
  137. package/dist/src/codexConversationProjection.js +199 -0
  138. package/dist/src/codexDiff.js +118 -0
  139. package/dist/src/codexMessageIdentity.js +18 -0
  140. package/dist/src/codexPlan.js +30 -0
  141. package/dist/src/codexQueueProtocol.js +137 -0
  142. package/dist/src/codexStreamProtocol.js +207 -0
  143. package/dist/src/codexToolProtocol.js +68 -0
  144. package/dist/src/codexTranscriptParse.js +835 -0
  145. package/dist/src/codexUsageSnapshot.js +228 -0
  146. package/dist/src/config.js +8 -0
  147. package/{src → dist/src}/docPath.js +22 -24
  148. package/dist/src/docs.js +337 -0
  149. package/dist/src/git.js +284 -0
  150. package/dist/src/healthProtocol.js +97 -0
  151. package/dist/src/httpApi.js +96 -0
  152. package/dist/src/jsonStore.js +15 -0
  153. package/{src → dist/src}/keyNames.js +3 -3
  154. package/dist/src/notifications.js +111 -0
  155. package/dist/src/orphans.js +167 -0
  156. package/dist/src/paneInput.js +42 -0
  157. package/dist/src/pendingPrompt.js +194 -0
  158. package/dist/src/previewServer.js +202 -0
  159. package/dist/src/previews.js +179 -0
  160. package/dist/src/privateStateStore.js +124 -0
  161. package/dist/src/projectTask/backup.js +224 -0
  162. package/dist/src/projectTask/lock.js +108 -0
  163. package/dist/src/projectTask/migrations.js +90 -0
  164. package/dist/src/projectTask/routes.js +155 -0
  165. package/dist/src/projectTask/runtime.js +182 -0
  166. package/dist/src/projectTask/schema.js +56 -0
  167. package/dist/src/projectTask/store.js +457 -0
  168. package/dist/src/push.js +224 -0
  169. package/dist/src/routes/agents.js +631 -0
  170. package/dist/src/routes/apiAccounts.js +82 -0
  171. package/dist/src/routes/files.js +230 -0
  172. package/dist/src/routes/git.js +89 -0
  173. package/dist/src/routes/health.js +16 -0
  174. package/dist/src/routes/notifications.js +16 -0
  175. package/dist/src/routes/previews.js +43 -0
  176. package/dist/src/routes/push.js +145 -0
  177. package/dist/src/routes/sessions.js +244 -0
  178. package/dist/src/routes/system.js +220 -0
  179. package/dist/src/routes/terminal.js +244 -0
  180. package/dist/src/routes/workspace.js +267 -0
  181. package/dist/src/server.js +315 -0
  182. package/dist/src/shortcutConfig.js +83 -0
  183. package/{src → dist/src}/staticCache.js +5 -3
  184. package/{src → dist/src}/staticCompression.js +5 -6
  185. package/dist/src/terminalStream.js +441 -0
  186. package/{src → dist/src}/tmux/commands.js +185 -172
  187. package/dist/src/tmux/format.js +47 -0
  188. package/dist/src/transcriptParse.js +261 -0
  189. package/dist/src/transcriptReader.js +209 -0
  190. package/{src → dist/src}/trimCapture.js +11 -11
  191. package/dist/src/uploadTypes.js +31 -0
  192. package/dist/src/urlPolicy.js +18 -0
  193. package/dist/src/usagePaths.js +15 -0
  194. package/dist/src/workspace/agentRunner.js +301 -0
  195. package/dist/src/workspace/atomicJson.js +31 -0
  196. package/dist/src/workspace/capture.js +139 -0
  197. package/dist/src/workspace/checkpointer.js +228 -0
  198. package/dist/src/workspace/environment.js +71 -0
  199. package/dist/src/workspace/lock.js +137 -0
  200. package/dist/src/workspace/mapping.js +128 -0
  201. package/dist/src/workspace/operations.js +271 -0
  202. package/dist/src/workspace/paths.js +15 -0
  203. package/dist/src/workspace/planner.js +292 -0
  204. package/dist/src/workspace/restore.js +363 -0
  205. package/dist/src/workspace/runtime.js +318 -0
  206. package/dist/src/workspace/schema.js +156 -0
  207. package/dist/src/workspace/store.js +464 -0
  208. package/dist/src/workspace/tmuxAdapter.js +704 -0
  209. package/dist/src/workspaceProtocol.js +158 -0
  210. package/package.json +30 -17
  211. package/hooks/handmux-notify.sh +0 -22
  212. package/hooks/handmux-write.cjs +0 -78
  213. package/public/assets/index-C3N7OkyP.css +0 -32
  214. package/public/assets/index-D1DWcC5u.js +0 -337
  215. package/src/.gitkeep +0 -0
  216. package/src/agents/claude.js +0 -159
  217. package/src/agents/codex.js +0 -168
  218. package/src/agents/index.js +0 -21
  219. package/src/agents/processIdentity.js +0 -72
  220. package/src/agents/scanUtils.js +0 -225
  221. package/src/asr/iflyConfig.js +0 -10
  222. package/src/asr/iflySign.js +0 -16
  223. package/src/auth.js +0 -30
  224. package/src/browser/bootstrap.js +0 -68
  225. package/src/browser/cookieProfiles.js +0 -403
  226. package/src/browser/coordinator.js +0 -47
  227. package/src/browser/hammerheadCompat.js +0 -86
  228. package/src/browser/hammerheadRedirectCompat.js +0 -38
  229. package/src/browser/manager.js +0 -556
  230. package/src/browser/originLabel.js +0 -17
  231. package/src/browser/profilePersistence.js +0 -241
  232. package/src/browser/publicProxy.js +0 -175
  233. package/src/browser/routes.js +0 -154
  234. package/src/browser/sessionStore.js +0 -78
  235. package/src/browser/siteVersion.js +0 -124
  236. package/src/browser/targetPolicy.js +0 -137
  237. package/src/browser/workerClient.js +0 -253
  238. package/src/browser/workerServer.js +0 -98
  239. package/src/captureBackground.js +0 -93
  240. package/src/claudeEvents.js +0 -399
  241. package/src/cli/claudeHooks.js +0 -206
  242. package/src/cli/cloudflared.js +0 -99
  243. package/src/cli/codexManaged.js +0 -77
  244. package/src/cli/drivers.js +0 -97
  245. package/src/cli/hookScaffold.js +0 -33
  246. package/src/cli/i18n/en.js +0 -385
  247. package/src/cli/i18n/zh.js +0 -384
  248. package/src/cli/legacyCodexHooks.js +0 -49
  249. package/src/cli/options.js +0 -211
  250. package/src/cli/probe.js +0 -16
  251. package/src/cli/pushCmd.js +0 -60
  252. package/src/cli/service.js +0 -126
  253. package/src/cli/setupModel.js +0 -154
  254. package/src/cli/setupWizard.js +0 -506
  255. package/src/cli/shortcutEditor.js +0 -288
  256. package/src/cli/sshTunnel.js +0 -12
  257. package/src/cli/state.js +0 -90
  258. package/src/cli/statusLine.js +0 -90
  259. package/src/cli/supervisor.js +0 -183
  260. package/src/cli/supervisorProcesses.js +0 -57
  261. package/src/cli/tmuxVersion.js +0 -49
  262. package/src/cli/tunnelClients.js +0 -62
  263. package/src/cli/workspaceCmd.js +0 -300
  264. package/src/codexAppServer.js +0 -1620
  265. package/src/codexDiff.js +0 -108
  266. package/src/codexTranscriptParse.js +0 -413
  267. package/src/codexUsageSnapshot.cjs +0 -129
  268. package/src/config.js +0 -9
  269. package/src/docs.js +0 -228
  270. package/src/git.js +0 -208
  271. package/src/httpApi.js +0 -72
  272. package/src/jsonStore.js +0 -24
  273. package/src/notifications.js +0 -68
  274. package/src/orphans.js +0 -152
  275. package/src/pendingPrompt.js +0 -163
  276. package/src/previewServer.js +0 -168
  277. package/src/previews.js +0 -116
  278. package/src/push.js +0 -183
  279. package/src/routes/codex.js +0 -473
  280. package/src/routes/files.js +0 -172
  281. package/src/routes/git.js +0 -57
  282. package/src/routes/notifications.js +0 -19
  283. package/src/routes/previews.js +0 -35
  284. package/src/routes/push.js +0 -119
  285. package/src/routes/sessions.js +0 -168
  286. package/src/routes/system.js +0 -132
  287. package/src/routes/terminal.js +0 -210
  288. package/src/routes/transcript.js +0 -171
  289. package/src/routes/workspace.js +0 -267
  290. package/src/server.js +0 -186
  291. package/src/shortcutConfig.js +0 -75
  292. package/src/terminalStream.js +0 -391
  293. package/src/tmux/format.js +0 -45
  294. package/src/transcriptParse.js +0 -180
  295. package/src/transcriptReader.js +0 -101
  296. package/src/uploadTypes.js +0 -30
  297. package/src/urlPolicy.js +0 -14
  298. package/src/usage.js +0 -260
  299. package/src/workspace/agentRunner.js +0 -188
  300. package/src/workspace/atomicJson.js +0 -32
  301. package/src/workspace/capture.js +0 -139
  302. package/src/workspace/checkpointer.js +0 -187
  303. package/src/workspace/environment.js +0 -75
  304. package/src/workspace/lock.js +0 -128
  305. package/src/workspace/mapping.js +0 -124
  306. package/src/workspace/operations.js +0 -252
  307. package/src/workspace/paths.js +0 -16
  308. package/src/workspace/planner.js +0 -247
  309. package/src/workspace/restore.js +0 -279
  310. package/src/workspace/runtime.js +0 -314
  311. package/src/workspace/schema.js +0 -136
  312. package/src/workspace/store.js +0 -382
  313. package/src/workspace/tmuxAdapter.js +0 -554
  314. /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  315. /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
  316. /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
  317. /package/{public → dist/public}/icons/badge-96.png +0 -0
  318. /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
  319. /package/{public → dist/public}/icons/icon-192.png +0 -0
  320. /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
  321. /package/{public → dist/public}/icons/icon-512.png +0 -0
  322. /package/{public → dist/public}/icons/logo.svg +0 -0
  323. /package/{public → dist/public}/manifest.webmanifest +0 -0
  324. /package/{public → dist/public}/offline.html +0 -0
  325. /package/{public → dist/public}/sw.js +0 -0
  326. /package/{src → dist/src}/browser/protocol.js +0 -0
  327. /package/{src → dist/src}/cli/cfNamed.js +0 -0
@@ -0,0 +1,592 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, randomUUID, } from 'node:crypto';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+ import { ensurePrivateDirectorySync, fsyncDirectorySync, PrivateStateStore } from './privateStateStore.js';
5
+ import { builtInApiAccountProviders, isProviderType, ProviderQueryError, publicProviderResult, validProviderResult, } from './apiAccountProviders.js';
6
+ export { createDeepSeekProvider, createMoonshotProvider, ProviderQueryError, isProviderType, providerTypes, } from './apiAccountProviders.js';
7
+ class PostRenameDurabilityError extends Error {
8
+ failure;
9
+ constructor(failure) {
10
+ super('api account directory durability failed after rename');
11
+ this.failure = failure;
12
+ this.name = 'PostRenameDurabilityError';
13
+ }
14
+ }
15
+ class ApiAccountStorageUncertainError extends Error {
16
+ constructor() {
17
+ super('api account storage state is uncertain');
18
+ this.name = 'ApiAccountStorageUncertainError';
19
+ }
20
+ }
21
+ const API_ACCOUNTS_AAD = Buffer.from('handmux-api-accounts:v2');
22
+ const API_ACCOUNTS_KEY_BYTES = 32;
23
+ const API_ACCOUNTS_IV_BYTES = 12;
24
+ const API_ACCOUNTS_TAG_BYTES = 16;
25
+ function isRecord(value) {
26
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
27
+ }
28
+ function hasExactKeys(value, keys) {
29
+ const actual = Object.keys(value).sort();
30
+ const expected = [...keys].sort();
31
+ return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
32
+ }
33
+ const ERROR_CODES = new Set([
34
+ 'invalid_credential', 'rate_limited', 'provider_timeout', 'provider_unreachable', 'unsupported_response',
35
+ ]);
36
+ const isTimestamp = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
37
+ const isNullableTimestamp = (value) => value === null || isTimestamp(value);
38
+ const isNullableError = (value) => value === null
39
+ || (typeof value === 'string' && ERROR_CODES.has(value));
40
+ function validAccount(value) {
41
+ if (!isRecord(value)
42
+ || !hasExactKeys(value, [
43
+ 'id', 'name', 'providerType', 'credential', 'credentialVersion', 'createdAt', 'updatedAt',
44
+ 'latestSuccess', 'lastSuccessAt', 'lastAttemptAt', 'lastErrorCode',
45
+ ])
46
+ || typeof value.id !== 'string' || !/^[0-9a-f-]{36}$/i.test(value.id)
47
+ || typeof value.name !== 'string' || value.name !== value.name.trim()
48
+ || [...value.name].length < 1 || [...value.name].length > 64
49
+ || !isProviderType(value.providerType) || !isRecord(value.credential)
50
+ || !hasExactKeys(value.credential, ['kind', 'value']) || value.credential.kind !== 'apiKey'
51
+ || typeof value.credential.value !== 'string' || value.credential.value.length < 1 || value.credential.value.length > 4096
52
+ || !Number.isSafeInteger(value.credentialVersion) || value.credentialVersion < 1
53
+ || !isTimestamp(value.createdAt) || !isTimestamp(value.updatedAt)
54
+ || !isNullableTimestamp(value.lastSuccessAt) || !isNullableTimestamp(value.lastAttemptAt)
55
+ || !isNullableError(value.lastErrorCode))
56
+ return false;
57
+ return value.latestSuccess === null || validProviderResult(value.latestSuccess, value.providerType);
58
+ }
59
+ function validateFile(value) {
60
+ if (!isRecord(value) || !hasExactKeys(value, ['version', 'accounts'])
61
+ || value.version !== 1 || !Array.isArray(value.accounts)
62
+ || value.accounts.length > 1000 || !value.accounts.every(validAccount)
63
+ || new Set(value.accounts.map((account) => account.id)).size !== value.accounts.length) {
64
+ throw new Error('invalid api account store');
65
+ }
66
+ return value;
67
+ }
68
+ function validBase64(value, bytes) {
69
+ if (typeof value !== 'string' || value.length === 0 || value.length % 4 !== 0
70
+ || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value))
71
+ return false;
72
+ const decoded = Buffer.from(value, 'base64');
73
+ return (bytes === undefined || decoded.length === bytes) && decoded.toString('base64') === value;
74
+ }
75
+ function validateEncryptedFile(value) {
76
+ if (!isRecord(value) || !hasExactKeys(value, ['version', 'algorithm', 'iv', 'tag', 'ciphertext'])
77
+ || value.version !== 2 || value.algorithm !== 'aes-256-gcm'
78
+ || !validBase64(value.iv, API_ACCOUNTS_IV_BYTES)
79
+ || !validBase64(value.tag, API_ACCOUNTS_TAG_BYTES)
80
+ || !validBase64(value.ciphertext))
81
+ throw new Error('invalid encrypted api account store');
82
+ return value;
83
+ }
84
+ function apiAccountsKeyPath(file) {
85
+ return path.join(path.dirname(path.resolve(file)), 'api-accounts.key');
86
+ }
87
+ class EncryptedApiAccountStore {
88
+ #store;
89
+ #keyFile;
90
+ constructor(file) {
91
+ this.#store = new PrivateStateStore(file);
92
+ this.#keyFile = apiAccountsKeyPath(file);
93
+ }
94
+ get file() { return this.#store.file; }
95
+ readRaw() {
96
+ const value = this.#store.readStrict();
97
+ return value === null ? null : { value, serialized: fs.readFileSync(this.file, 'utf8') };
98
+ }
99
+ decrypt(raw) {
100
+ const envelope = validateEncryptedFile(raw);
101
+ const key = this.#readExistingKey();
102
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(envelope.iv, 'base64'));
103
+ decipher.setAAD(API_ACCOUNTS_AAD);
104
+ decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
105
+ const plaintext = Buffer.concat([
106
+ decipher.update(Buffer.from(envelope.ciphertext, 'base64')),
107
+ decipher.final(),
108
+ ]).toString('utf8');
109
+ return validateFile(JSON.parse(plaintext));
110
+ }
111
+ write(value) {
112
+ const previous = this.#currentBytes();
113
+ try {
114
+ this.#replaceFile(`${JSON.stringify(this.#encrypt(value), null, 2)}\n`, 'write');
115
+ }
116
+ catch (error) {
117
+ if (!(error instanceof PostRenameDurabilityError))
118
+ throw error;
119
+ try {
120
+ this.#rollback(previous);
121
+ }
122
+ catch {
123
+ throw new ApiAccountStorageUncertainError();
124
+ }
125
+ throw error.failure;
126
+ }
127
+ }
128
+ migrate(value, originalPlaintext) {
129
+ const envelope = this.#encrypt(value);
130
+ try {
131
+ this.#replaceFile(`${JSON.stringify(envelope, null, 2)}\n`, 'migration');
132
+ }
133
+ catch (error) {
134
+ if (!(error instanceof PostRenameDurabilityError))
135
+ throw error;
136
+ try {
137
+ this.#replaceFile(originalPlaintext, 'migration-rollback');
138
+ }
139
+ catch {
140
+ throw new ApiAccountStorageUncertainError();
141
+ }
142
+ throw error.failure;
143
+ }
144
+ }
145
+ #replaceFile(serialized, operation) {
146
+ const temporary = `${this.file}.${process.pid}.${randomUUID()}.${operation}.tmp`;
147
+ let descriptor = null;
148
+ let renamed = false;
149
+ try {
150
+ ensurePrivateDirectorySync(path.dirname(this.file));
151
+ descriptor = fs.openSync(temporary, 'wx', 0o600);
152
+ fs.fchmodSync(descriptor, 0o600);
153
+ fs.writeFileSync(descriptor, serialized, 'utf8');
154
+ fs.fsyncSync(descriptor);
155
+ fs.closeSync(descriptor);
156
+ descriptor = null;
157
+ fs.renameSync(temporary, this.file);
158
+ renamed = true;
159
+ fsyncDirectorySync(path.dirname(this.file));
160
+ }
161
+ catch (error) {
162
+ if (descriptor !== null) {
163
+ try {
164
+ fs.closeSync(descriptor);
165
+ }
166
+ catch { /* already closed */ }
167
+ }
168
+ try {
169
+ fs.unlinkSync(temporary);
170
+ }
171
+ catch { /* absent or already removed */ }
172
+ if (renamed)
173
+ throw new PostRenameDurabilityError(error);
174
+ throw error;
175
+ }
176
+ }
177
+ #currentBytes() {
178
+ try {
179
+ return fs.readFileSync(this.file);
180
+ }
181
+ catch (error) {
182
+ if (isRecord(error) && error.code === 'ENOENT')
183
+ return null;
184
+ throw error;
185
+ }
186
+ }
187
+ #rollback(previous) {
188
+ if (previous !== null) {
189
+ this.#replaceFile(previous, 'rollback');
190
+ return;
191
+ }
192
+ try {
193
+ fs.unlinkSync(this.file);
194
+ }
195
+ catch (error) {
196
+ if (!isRecord(error) || error.code !== 'ENOENT')
197
+ throw error;
198
+ }
199
+ fsyncDirectorySync(path.dirname(this.file));
200
+ }
201
+ #encrypt(value) {
202
+ const key = this.#readOrCreateKey();
203
+ const iv = randomBytes(API_ACCOUNTS_IV_BYTES);
204
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
205
+ cipher.setAAD(API_ACCOUNTS_AAD);
206
+ const ciphertext = Buffer.concat([
207
+ cipher.update(JSON.stringify(value), 'utf8'),
208
+ cipher.final(),
209
+ ]);
210
+ return {
211
+ version: 2,
212
+ algorithm: 'aes-256-gcm',
213
+ iv: iv.toString('base64'),
214
+ tag: cipher.getAuthTag().toString('base64'),
215
+ ciphertext: ciphertext.toString('base64'),
216
+ };
217
+ }
218
+ quarantine() {
219
+ const quarantined = this.#store.quarantine();
220
+ if (quarantined)
221
+ fsyncDirectorySync(path.dirname(this.file));
222
+ return quarantined;
223
+ }
224
+ #readExistingKey() {
225
+ fs.chmodSync(this.#keyFile, 0o600);
226
+ const key = fs.readFileSync(this.#keyFile);
227
+ if (key.length !== API_ACCOUNTS_KEY_BYTES)
228
+ throw new Error('invalid api account encryption key');
229
+ return key;
230
+ }
231
+ #readOrCreateKey() {
232
+ const directory = path.dirname(this.#keyFile);
233
+ try {
234
+ const existing = this.#readExistingKey();
235
+ fsyncDirectorySync(directory);
236
+ return existing;
237
+ }
238
+ catch (error) {
239
+ if (!isRecord(error) || error.code !== 'ENOENT')
240
+ throw error;
241
+ }
242
+ const key = randomBytes(API_ACCOUNTS_KEY_BYTES);
243
+ let descriptor = null;
244
+ let complete = false;
245
+ try {
246
+ ensurePrivateDirectorySync(directory);
247
+ descriptor = fs.openSync(this.#keyFile, 'wx', 0o600);
248
+ fs.fchmodSync(descriptor, 0o600);
249
+ fs.writeFileSync(descriptor, key);
250
+ fs.fsyncSync(descriptor);
251
+ fs.closeSync(descriptor);
252
+ descriptor = null;
253
+ complete = true;
254
+ fsyncDirectorySync(directory);
255
+ return key;
256
+ }
257
+ catch (error) {
258
+ if (descriptor !== null) {
259
+ try {
260
+ fs.closeSync(descriptor);
261
+ }
262
+ catch { /* already closed */ }
263
+ }
264
+ if (isRecord(error) && error.code === 'EEXIST') {
265
+ const existing = this.#readExistingKey();
266
+ fsyncDirectorySync(directory);
267
+ return existing;
268
+ }
269
+ // A complete key is a safe reusable orphan when the directory durability step or the later data
270
+ // commit fails. Only an incomplete key is removed; deleting a complete one could orphan ciphertext.
271
+ if (!complete)
272
+ try {
273
+ fs.unlinkSync(this.#keyFile);
274
+ }
275
+ catch { /* absent or incomplete key already removed */ }
276
+ throw error;
277
+ }
278
+ }
279
+ }
280
+ function hasQuarantinedCopy(file) {
281
+ const directory = path.dirname(file);
282
+ if (!fs.existsSync(directory))
283
+ return false;
284
+ const prefix = `${path.basename(file)}.corrupt.`;
285
+ return fs.readdirSync(directory).some((name) => name.startsWith(prefix));
286
+ }
287
+ function cleanName(value) {
288
+ if (typeof value !== 'string')
289
+ throw new ApiAccountInputError('invalid_name');
290
+ const name = value.trim();
291
+ if (!name || [...name].length > 64)
292
+ throw new ApiAccountInputError('invalid_name');
293
+ return name;
294
+ }
295
+ function cleanCredential(value) {
296
+ if (!isRecord(value) || value.kind !== 'apiKey' || typeof value.value !== 'string'
297
+ || value.value.length < 1 || value.value.length > 4096)
298
+ throw new ApiAccountInputError('invalid_credential_input');
299
+ return { kind: 'apiKey', value: value.value };
300
+ }
301
+ export class ApiAccountInputError extends Error {
302
+ code;
303
+ constructor(code) { super(code); this.name = 'ApiAccountInputError'; this.code = code; }
304
+ }
305
+ const viewOf = (account) => ({
306
+ id: account.id,
307
+ name: account.name,
308
+ providerType: account.providerType,
309
+ credentialConfigured: true,
310
+ createdAt: account.createdAt,
311
+ updatedAt: account.updatedAt,
312
+ latestSuccess: account.latestSuccess === null ? null : publicProviderResult(account.latestSuccess),
313
+ lastSuccessAt: account.lastSuccessAt,
314
+ lastAttemptAt: account.lastAttemptAt,
315
+ lastErrorCode: account.lastErrorCode,
316
+ });
317
+ export class ApiAccountService {
318
+ #store;
319
+ #unavailableMarker;
320
+ #providers;
321
+ #now;
322
+ #accounts = [];
323
+ #storageError = false;
324
+ #writes = Promise.resolve();
325
+ #inflight = new Map();
326
+ #lastCompleted = new Map();
327
+ constructor({ file, providers = builtInApiAccountProviders(), now = Date.now }) {
328
+ this.#store = new EncryptedApiAccountStore(file);
329
+ this.#unavailableMarker = new PrivateStateStore(`${file}.unavailable`);
330
+ this.#providers = new Map(providers.map((provider) => [provider.type, provider]));
331
+ this.#now = now;
332
+ // Marker presence alone is authoritative. Its contents are deliberately not read: a partial marker
333
+ // write must keep storage unavailable and must never send a potentially recovered primary file to
334
+ // quarantine. A quarantined primary is the fallback sentinel if writing the marker itself failed.
335
+ if (fs.existsSync(this.#unavailableMarker.file)) {
336
+ this.#storageError = true;
337
+ return;
338
+ }
339
+ try {
340
+ const stored = this.#store.readRaw();
341
+ if (stored) {
342
+ const raw = stored.value;
343
+ if (isRecord(raw) && raw.version === 1) {
344
+ const plaintext = validateFile(raw);
345
+ try {
346
+ this.#store.migrate(plaintext, stored.serialized);
347
+ }
348
+ catch {
349
+ // Migration must be all-or-nothing. Its final atomic rename leaves the original plaintext
350
+ // at the primary path when the encrypted replacement cannot be committed.
351
+ try {
352
+ this.#unavailableMarker.write({ version: 1, reason: 'corrupt' });
353
+ }
354
+ catch { /* migration failure wins */ }
355
+ this.#storageError = true;
356
+ return;
357
+ }
358
+ this.#accounts = plaintext.accounts;
359
+ }
360
+ else {
361
+ this.#accounts = this.#store.decrypt(raw).accounts;
362
+ }
363
+ }
364
+ else if (hasQuarantinedCopy(this.#store.file)) {
365
+ this.#storageError = true;
366
+ }
367
+ }
368
+ catch {
369
+ try {
370
+ this.#store.quarantine();
371
+ }
372
+ catch { /* keep original failure private */ }
373
+ try {
374
+ this.#unavailableMarker.write({ version: 1, reason: 'corrupt' });
375
+ }
376
+ catch { /* original failure wins */ }
377
+ this.#storageError = true;
378
+ }
379
+ }
380
+ #assertAvailable() { if (this.#storageError)
381
+ throw new ApiAccountInputError('storage_unavailable'); }
382
+ #provider(type) {
383
+ if (!isProviderType(type))
384
+ throw new ApiAccountInputError('invalid_provider');
385
+ const provider = this.#providers.get(type);
386
+ if (!provider)
387
+ throw new ApiAccountInputError('invalid_provider');
388
+ return provider;
389
+ }
390
+ async #mutate(change) {
391
+ this.#assertAvailable();
392
+ const previous = this.#writes;
393
+ let release;
394
+ this.#writes = new Promise((resolve) => { release = resolve; });
395
+ await previous;
396
+ try {
397
+ this.#assertAvailable();
398
+ const before = structuredClone(this.#accounts);
399
+ try {
400
+ const result = change();
401
+ try {
402
+ this.#store.write({ version: 1, accounts: this.#accounts });
403
+ }
404
+ catch (error) {
405
+ this.#accounts = before;
406
+ this.#storageError = true;
407
+ if (error instanceof ApiAccountStorageUncertainError) {
408
+ try {
409
+ this.#unavailableMarker.write({ version: 1, reason: 'corrupt' });
410
+ }
411
+ catch {
412
+ // The uncertain primary state remains the original failure; marker persistence is best-effort.
413
+ }
414
+ }
415
+ throw new ApiAccountInputError('storage_unavailable');
416
+ }
417
+ return result;
418
+ }
419
+ catch (error) {
420
+ this.#accounts = before;
421
+ throw error;
422
+ }
423
+ }
424
+ finally {
425
+ release();
426
+ }
427
+ }
428
+ async #callProvider(provider, credential, externalSignal) {
429
+ const controller = new AbortController();
430
+ const timer = setTimeout(() => controller.abort(), 10_000);
431
+ const abort = () => controller.abort();
432
+ if (externalSignal?.aborted)
433
+ controller.abort();
434
+ else
435
+ externalSignal?.addEventListener('abort', abort, { once: true });
436
+ try {
437
+ const result = await provider.queryBalance(credential, controller.signal);
438
+ if (externalSignal?.aborted)
439
+ throw new ApiAccountInputError('request_cancelled');
440
+ if (!validProviderResult(result, provider.type))
441
+ throw new ProviderQueryError('unsupported_response');
442
+ return result;
443
+ }
444
+ catch (error) {
445
+ if (error instanceof ApiAccountInputError)
446
+ throw error;
447
+ if (externalSignal?.aborted)
448
+ throw new ApiAccountInputError('request_cancelled');
449
+ if (error instanceof ProviderQueryError)
450
+ throw error;
451
+ throw new ProviderQueryError(controller.signal.aborted ? 'provider_timeout' : 'provider_unreachable');
452
+ }
453
+ finally {
454
+ clearTimeout(timer);
455
+ externalSignal?.removeEventListener('abort', abort);
456
+ }
457
+ }
458
+ list() { this.#assertAvailable(); return this.#accounts.map(viewOf); }
459
+ async create(input, signal) {
460
+ this.#assertAvailable();
461
+ if (this.#accounts.length >= 1000)
462
+ throw new ApiAccountInputError('account_limit_reached');
463
+ const provider = this.#provider(input.providerType);
464
+ const name = cleanName(input.name);
465
+ const credential = cleanCredential(input.credential);
466
+ const attemptAt = this.#now();
467
+ const result = await this.#callProvider(provider, credential, signal);
468
+ return this.#mutate(() => {
469
+ if (signal?.aborted)
470
+ throw new ApiAccountInputError('request_cancelled');
471
+ if (this.#accounts.length >= 1000)
472
+ throw new ApiAccountInputError('account_limit_reached');
473
+ const now = this.#now();
474
+ const account = {
475
+ id: randomUUID(), name, providerType: provider.type, credential, credentialVersion: 1,
476
+ createdAt: now, updatedAt: now, latestSuccess: result,
477
+ lastSuccessAt: now, lastAttemptAt: attemptAt, lastErrorCode: null,
478
+ };
479
+ this.#accounts.push(account);
480
+ return viewOf(account);
481
+ });
482
+ }
483
+ async patch(id, input, signal) {
484
+ this.#assertAvailable();
485
+ const snapshot = this.#accounts.find((account) => account.id === id);
486
+ if (!snapshot)
487
+ throw new ApiAccountInputError('not_found');
488
+ if (input.name === undefined && input.credential === undefined)
489
+ throw new ApiAccountInputError('invalid_name');
490
+ const name = input.name === undefined ? null : cleanName(input.name);
491
+ const credentialVersion = snapshot.credentialVersion;
492
+ if (input.credential === undefined)
493
+ return this.#mutate(() => {
494
+ const account = this.#accounts.find((candidate) => candidate.id === id);
495
+ if (!account)
496
+ throw new ApiAccountInputError('not_found');
497
+ account.name = name;
498
+ account.updatedAt = this.#now();
499
+ return viewOf(account);
500
+ });
501
+ const credential = cleanCredential(input.credential);
502
+ const attemptAt = this.#now();
503
+ const result = await this.#callProvider(this.#provider(snapshot.providerType), credential, signal);
504
+ return this.#mutate(() => {
505
+ if (signal?.aborted)
506
+ throw new ApiAccountInputError('request_cancelled');
507
+ const account = this.#accounts.find((candidate) => candidate.id === id);
508
+ if (!account)
509
+ throw new ApiAccountInputError('not_found');
510
+ if (account.credentialVersion !== credentialVersion)
511
+ throw new ApiAccountInputError('conflict');
512
+ const now = this.#now();
513
+ if (name !== null)
514
+ account.name = name;
515
+ account.credential = credential;
516
+ account.credentialVersion += 1;
517
+ account.updatedAt = now;
518
+ account.latestSuccess = result;
519
+ account.lastSuccessAt = now;
520
+ account.lastAttemptAt = attemptAt;
521
+ account.lastErrorCode = null;
522
+ this.#lastCompleted.delete(id);
523
+ return viewOf(account);
524
+ });
525
+ }
526
+ async remove(id) {
527
+ await this.#mutate(() => {
528
+ const index = this.#accounts.findIndex((account) => account.id === id);
529
+ if (index < 0)
530
+ throw new ApiAccountInputError('not_found');
531
+ this.#accounts.splice(index, 1);
532
+ this.#lastCompleted.delete(id);
533
+ });
534
+ }
535
+ query(id) {
536
+ this.#assertAvailable();
537
+ const existing = this.#inflight.get(id);
538
+ if (existing)
539
+ return existing;
540
+ const completedAt = this.#lastCompleted.get(id);
541
+ const now = this.#now();
542
+ if (completedAt !== undefined && now - completedAt < 10_000) {
543
+ throw new ProviderQueryError('rate_limited', Math.max(1, Math.ceil((10_000 - (now - completedAt)) / 1000)));
544
+ }
545
+ const account = this.#accounts.find((candidate) => candidate.id === id);
546
+ if (!account)
547
+ throw new ApiAccountInputError('not_found');
548
+ const version = account.credentialVersion;
549
+ const credential = account.credential;
550
+ const provider = this.#provider(account.providerType);
551
+ const promise = (async () => {
552
+ const attemptAt = this.#now();
553
+ try {
554
+ const result = await this.#callProvider(provider, credential);
555
+ return await this.#mutate(() => {
556
+ const current = this.#accounts.find((candidate) => candidate.id === id);
557
+ if (!current || current.credentialVersion !== version)
558
+ throw new ApiAccountInputError('conflict');
559
+ const successAt = this.#now();
560
+ current.latestSuccess = result;
561
+ current.lastSuccessAt = successAt;
562
+ current.lastAttemptAt = attemptAt;
563
+ current.lastErrorCode = null;
564
+ return viewOf(current);
565
+ });
566
+ }
567
+ catch (error) {
568
+ if (error instanceof ProviderQueryError) {
569
+ const recorded = await this.#mutate(() => {
570
+ const current = this.#accounts.find((candidate) => candidate.id === id);
571
+ if (current && current.credentialVersion === version) {
572
+ current.lastAttemptAt = attemptAt;
573
+ current.lastErrorCode = error.code;
574
+ return true;
575
+ }
576
+ return false;
577
+ });
578
+ if (!recorded)
579
+ throw new ApiAccountInputError('conflict');
580
+ }
581
+ throw error;
582
+ }
583
+ finally {
584
+ this.#lastCompleted.set(id, this.#now());
585
+ this.#inflight.delete(id);
586
+ }
587
+ })();
588
+ this.#inflight.set(id, promise);
589
+ return promise;
590
+ }
591
+ }
592
+ export const apiAccountsPath = (home) => path.join(home, '.handmux', 'api-accounts.json');
@@ -0,0 +1,86 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ export class PublicApiError extends Error {
3
+ status;
4
+ code;
5
+ retryAfterSeconds;
6
+ constructor(status, code, message, retryAfterSeconds) {
7
+ super(message);
8
+ this.name = 'PublicApiError';
9
+ this.status = status;
10
+ this.code = code;
11
+ if (retryAfterSeconds !== undefined)
12
+ this.retryAfterSeconds = retryAfterSeconds;
13
+ }
14
+ }
15
+ function requestIdFor(locals, idFactory) {
16
+ if (typeof locals.requestId === 'string' && locals.requestId)
17
+ return locals.requestId;
18
+ const requestId = idFactory();
19
+ locals.requestId = requestId;
20
+ return requestId;
21
+ }
22
+ function errorFields(error) {
23
+ if (!(error instanceof Error))
24
+ return { value: String(error) };
25
+ return { name: error.name, message: error.message, stack: error.stack };
26
+ }
27
+ function isInvalidJson(error) {
28
+ if (!(error instanceof SyntaxError) || !error || typeof error !== 'object')
29
+ return false;
30
+ const candidate = error;
31
+ return candidate.status === 400 && candidate.type === 'entity.parse.failed';
32
+ }
33
+ export function apiRequestContext({ idFactory = randomUUID } = {}) {
34
+ return (_req, res, next) => {
35
+ const requestId = requestIdFor(res.locals, idFactory);
36
+ res.setHeader('X-Request-Id', requestId);
37
+ next();
38
+ };
39
+ }
40
+ export function apiNotFound({ idFactory = randomUUID } = {}) {
41
+ return (_req, res) => {
42
+ const requestId = requestIdFor(res.locals, idFactory);
43
+ res.setHeader('X-Request-Id', requestId);
44
+ const body = { error: 'not found', code: 'not_found', requestId };
45
+ res.status(404).json(body);
46
+ };
47
+ }
48
+ export function apiErrorBoundary({ idFactory = randomUUID, log = console, } = {}) {
49
+ return (error, req, res, next) => {
50
+ if (res.headersSent) {
51
+ next(error);
52
+ return;
53
+ }
54
+ const requestId = requestIdFor(res.locals, idFactory);
55
+ res.setHeader('X-Request-Id', requestId);
56
+ let status = 500;
57
+ let code = 'internal_error';
58
+ let message = 'internal server error';
59
+ if (error instanceof PublicApiError) {
60
+ status = error.status;
61
+ code = error.code;
62
+ message = error.message;
63
+ }
64
+ else if (isInvalidJson(error)) {
65
+ status = 400;
66
+ code = 'invalid_json';
67
+ message = 'invalid json';
68
+ }
69
+ if (status >= 500) {
70
+ log.error('[handmux] api error', {
71
+ requestId,
72
+ method: req.method,
73
+ path: req.originalUrl,
74
+ error: errorFields(error),
75
+ });
76
+ }
77
+ const retryAfterSeconds = error instanceof PublicApiError ? error.retryAfterSeconds : undefined;
78
+ if (retryAfterSeconds !== undefined)
79
+ res.setHeader('Retry-After', String(retryAfterSeconds));
80
+ const body = {
81
+ error: message, code, requestId,
82
+ ...(retryAfterSeconds === undefined ? {} : { retryAfterSeconds }),
83
+ };
84
+ res.status(status).json(body);
85
+ };
86
+ }