handmux 0.24.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.
- package/README.md +29 -6
- package/README.zh-CN.md +29 -6
- package/bin/handmux.js +15 -3
- package/bin/node-version.js +20 -0
- package/dist/bin/handmux.js +200 -35
- package/dist/connectors/bridgeClient.js +385 -0
- package/dist/connectors/claude/index.js +476 -0
- package/dist/connectors/pi/bridgeClient.js +8 -0
- package/dist/connectors/pi/index.js +851 -0
- package/dist/hooks/handmux-notify.sh +45 -3
- package/dist/hooks/handmux-write.cjs +175 -10
- package/dist/package.json +10 -9
- package/dist/public/assets/index-CTrqTqtT.css +32 -0
- package/dist/public/assets/index-DmjeMSQA.js +346 -0
- package/dist/public/index.html +2 -2
- package/dist/src/agent-runtime/adapter.js +204 -0
- package/dist/src/agent-runtime/bridge.js +1006 -0
- package/dist/src/agent-runtime/bridgeStore.js +25 -0
- package/dist/src/agent-runtime/bridgeTransport.js +619 -0
- package/dist/src/agent-runtime/bridgeTypes.js +13 -0
- package/dist/src/agent-runtime/builtinRuntime.js +193 -0
- package/dist/src/agent-runtime/conversation.js +2179 -0
- package/dist/src/agent-runtime/conversationActivation.js +75 -0
- package/dist/src/agent-runtime/conversationActivity.js +69 -0
- package/dist/src/agent-runtime/conversationControls.js +206 -0
- package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
- package/dist/src/agent-runtime/conversationStore.js +352 -0
- package/dist/src/agent-runtime/conversationTypes.js +1 -0
- package/dist/src/agent-runtime/conversationValidation.js +339 -0
- package/dist/src/agent-runtime/inbox.js +973 -0
- package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
- package/dist/src/agent-runtime/inboxStore.js +25 -0
- package/dist/src/agent-runtime/inboxTypes.js +1 -0
- package/dist/src/agent-runtime/interaction.js +829 -0
- package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
- package/dist/src/agent-runtime/interactionStore.js +18 -0
- package/dist/src/agent-runtime/interactionTypes.js +1 -0
- package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
- package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
- package/dist/src/agent-runtime/resourceTypes.js +1 -0
- package/dist/src/agent-runtime/resources.js +245 -0
- package/dist/src/agent-runtime/run.js +328 -0
- package/dist/src/agent-runtime/runtime.js +793 -0
- package/dist/src/agent-runtime/sessionControl.js +164 -0
- package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
- package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
- package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
- package/dist/src/agents/claude.js +22 -0
- package/dist/src/agents/claudeConversation.js +362 -0
- package/dist/src/agents/claudeInteraction.js +134 -0
- package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
- package/dist/src/agents/codex.js +52 -7
- package/dist/src/agents/codexConversation.js +1103 -0
- package/dist/src/agents/codexConversationActivation.js +121 -0
- package/dist/src/agents/codexConversationControls.js +256 -0
- package/dist/src/agents/codexInteraction.js +306 -0
- package/dist/src/agents/codexSessionControl.js +118 -0
- package/dist/src/agents/codexSubscriptionUsage.js +313 -0
- package/dist/src/agents/codexTerminalControl.js +23 -0
- package/dist/src/agents/conversationProjectionSafety.js +416 -0
- package/dist/src/agents/index.js +27 -9
- package/dist/src/agents/nativeInbox.js +264 -0
- package/dist/src/agents/nativeInboxSources.js +115 -0
- package/dist/src/agents/pi.js +28 -0
- package/dist/src/agents/piConversation.js +523 -0
- package/dist/src/agents/piConversationHistory.js +502 -0
- package/dist/src/agents/piInboxBridge.js +368 -0
- package/dist/src/agents/piSessionControl.js +32 -0
- package/dist/src/agents/processIdentity.js +17 -3
- package/dist/src/apiAccountProviders.js +194 -0
- package/dist/src/apiAccounts.js +592 -0
- package/dist/src/apiErrors.js +11 -2
- package/dist/src/claudeEvents.js +35 -9
- package/dist/src/cli/agentIntegration.js +131 -0
- package/dist/src/cli/agentLauncher.js +26 -0
- package/dist/src/cli/claudeHooks.js +49 -3
- package/dist/src/cli/codexManaged.js +6 -3
- package/dist/src/cli/hookScaffold.js +4 -1
- package/dist/src/cli/i18n/en.js +30 -4
- package/dist/src/cli/i18n/zh.js +30 -4
- package/dist/src/cli/piExtension.js +155 -0
- package/dist/src/cli/service.js +19 -9
- package/dist/src/cli/state.js +3 -0
- package/dist/src/cli/statusLine.js +19 -11
- package/dist/src/codexAppServer.js +1296 -159
- package/dist/src/codexQueueProtocol.js +41 -0
- package/dist/src/codexTranscriptParse.js +256 -145
- package/dist/src/codexUsageSnapshot.js +29 -6
- package/dist/src/git.js +13 -1
- package/dist/src/httpApi.js +35 -7
- package/dist/src/orphans.js +13 -3
- package/dist/src/paneInput.js +42 -0
- package/dist/src/privateStateStore.js +35 -0
- package/dist/src/projectTask/backup.js +224 -0
- package/dist/src/projectTask/lock.js +108 -0
- package/dist/src/projectTask/migrations.js +90 -0
- package/dist/src/projectTask/routes.js +155 -0
- package/dist/src/projectTask/runtime.js +182 -0
- package/dist/src/projectTask/schema.js +56 -0
- package/dist/src/projectTask/store.js +457 -0
- package/dist/src/routes/agents.js +631 -0
- package/dist/src/routes/apiAccounts.js +82 -0
- package/dist/src/routes/git.js +11 -0
- package/dist/src/routes/sessions.js +16 -4
- package/dist/src/routes/system.js +69 -5
- package/dist/src/routes/terminal.js +22 -16
- package/dist/src/server.js +109 -6
- package/dist/src/tmux/commands.js +29 -1
- package/dist/src/transcriptParse.js +22 -1
- package/dist/src/transcriptReader.js +100 -4
- package/dist/src/uploadTypes.js +2 -0
- package/dist/src/usagePaths.js +15 -0
- package/dist/src/workspace/agentRunner.js +103 -6
- package/dist/src/workspace/restore.js +51 -2
- package/dist/src/workspace/tmuxAdapter.js +10 -0
- package/package.json +6 -5
- package/dist/public/assets/index-CicdY9E9.css +0 -32
- package/dist/public/assets/index-D4VP5t8h.js +0 -337
- package/dist/src/routes/codex.js +0 -606
- package/dist/src/routes/transcript.js +0 -197
- package/dist/src/usage.js +0 -260
|
@@ -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');
|
package/dist/src/apiErrors.js
CHANGED
|
@@ -2,11 +2,14 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
export class PublicApiError extends Error {
|
|
3
3
|
status;
|
|
4
4
|
code;
|
|
5
|
-
|
|
5
|
+
retryAfterSeconds;
|
|
6
|
+
constructor(status, code, message, retryAfterSeconds) {
|
|
6
7
|
super(message);
|
|
7
8
|
this.name = 'PublicApiError';
|
|
8
9
|
this.status = status;
|
|
9
10
|
this.code = code;
|
|
11
|
+
if (retryAfterSeconds !== undefined)
|
|
12
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
10
13
|
}
|
|
11
14
|
}
|
|
12
15
|
function requestIdFor(locals, idFactory) {
|
|
@@ -71,7 +74,13 @@ export function apiErrorBoundary({ idFactory = randomUUID, log = console, } = {}
|
|
|
71
74
|
error: errorFields(error),
|
|
72
75
|
});
|
|
73
76
|
}
|
|
74
|
-
const
|
|
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
|
+
};
|
|
75
84
|
res.status(status).json(body);
|
|
76
85
|
};
|
|
77
86
|
}
|