@askalf/dario 3.12.0 → 3.13.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 +44 -4
- package/dist/cc-template.js +15 -0
- package/dist/live-fingerprint.d.ts +82 -0
- package/dist/live-fingerprint.js +94 -0
- package/dist/pool.d.ts +48 -0
- package/dist/pool.js +99 -1
- package/dist/proxy.js +168 -2
- package/dist/sealed-pool.d.ts +202 -0
- package/dist/sealed-pool.js +416 -0
- package/dist/shim/runtime.cjs +146 -20
- package/package.json +2 -2
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sealed-sender overflow pool — RSA blind signatures for unlinkable capacity
|
|
3
|
+
* sharing inside a trust group.
|
|
4
|
+
*
|
|
5
|
+
* The problem this solves: in a federated pool where several friends lend
|
|
6
|
+
* each other account capacity, a naive design leaks who is borrowing what.
|
|
7
|
+
* If member A sends "I'm borrowing from your pool" to member B's dario
|
|
8
|
+
* instance, B learns exactly which of their friends is running which
|
|
9
|
+
* workload — and over time B can build a pretty detailed surveillance log
|
|
10
|
+
* of everyone else's agent sessions. That's the opposite of what a private
|
|
11
|
+
* friend pool should provide.
|
|
12
|
+
*
|
|
13
|
+
* The solution is Chaum's 1983 blind signature construction. A trusted
|
|
14
|
+
* admin (one member of the group, selected by social consensus) issues
|
|
15
|
+
* signed borrow tokens to each member. The admin never sees the token
|
|
16
|
+
* values — they sign blinded values, and the blinding is unlinkable at
|
|
17
|
+
* the cryptographic level. When a member sends a token to a lender, the
|
|
18
|
+
* lender can verify "this was signed by the group admin" without learning
|
|
19
|
+
* WHICH member holds that token. The lender sees a valid group credential
|
|
20
|
+
* and nothing more.
|
|
21
|
+
*
|
|
22
|
+
* From Anthropic's perspective nothing changes: the request still hits
|
|
23
|
+
* their API under the lender's identity, fully attributable to a real
|
|
24
|
+
* paying Max subscriber. The privacy property is entirely INSIDE the
|
|
25
|
+
* trust group — no member can surveil another member's usage through
|
|
26
|
+
* the pool layer.
|
|
27
|
+
*
|
|
28
|
+
* What this is NOT: this is not anonymity from Anthropic, not onion
|
|
29
|
+
* routing, not credential laundering. It is a privacy layer on top of
|
|
30
|
+
* a legitimate friends-pool arrangement. Members opt in, the admin is
|
|
31
|
+
* known, membership is revocable by rotating the group key. It's the
|
|
32
|
+
* same trust model as a family Netflix account, with unlinkability as
|
|
33
|
+
* a feature for the pool's internal telemetry.
|
|
34
|
+
*
|
|
35
|
+
* Implementation notes:
|
|
36
|
+
* - RSA-2048 with FDH (full-domain hash) padding via MGF1-SHA256.
|
|
37
|
+
* - Node's crypto.publicEncrypt / privateDecrypt with RSA_NO_PADDING
|
|
38
|
+
* for raw RSA operations. All modular arithmetic happens in BigInt.
|
|
39
|
+
* - Tokens are 32 random bytes each, single-use (lender tracks SHA-256
|
|
40
|
+
* hashes of seen tokens to prevent double-spend).
|
|
41
|
+
* - Admin does not need to be online for members to use tokens. Admin
|
|
42
|
+
* only runs when issuing a new batch (typically once per day/week).
|
|
43
|
+
*/
|
|
44
|
+
import { generateKeyPairSync, publicEncrypt, privateDecrypt, constants, createPublicKey, randomBytes, createHash, } from 'node:crypto';
|
|
45
|
+
// ======================================================================
|
|
46
|
+
// BigInt / buffer helpers
|
|
47
|
+
// ======================================================================
|
|
48
|
+
function bigintToBytes(n, len) {
|
|
49
|
+
let hex = n.toString(16);
|
|
50
|
+
if (hex.length % 2)
|
|
51
|
+
hex = '0' + hex;
|
|
52
|
+
const buf = Buffer.from(hex, 'hex');
|
|
53
|
+
if (buf.length > len)
|
|
54
|
+
throw new Error('value too large for buffer');
|
|
55
|
+
if (buf.length === len)
|
|
56
|
+
return buf;
|
|
57
|
+
const padded = Buffer.alloc(len);
|
|
58
|
+
buf.copy(padded, len - buf.length);
|
|
59
|
+
return padded;
|
|
60
|
+
}
|
|
61
|
+
function bytesToBigint(buf) {
|
|
62
|
+
if (buf.length === 0)
|
|
63
|
+
return 0n;
|
|
64
|
+
return BigInt('0x' + buf.toString('hex'));
|
|
65
|
+
}
|
|
66
|
+
function egcd(a, b) {
|
|
67
|
+
let [oldR, r] = [a, b];
|
|
68
|
+
let [oldS, s] = [1n, 0n];
|
|
69
|
+
let [oldT, t] = [0n, 1n];
|
|
70
|
+
while (r !== 0n) {
|
|
71
|
+
const q = oldR / r;
|
|
72
|
+
[oldR, r] = [r, oldR - q * r];
|
|
73
|
+
[oldS, s] = [s, oldS - q * s];
|
|
74
|
+
[oldT, t] = [t, oldT - q * t];
|
|
75
|
+
}
|
|
76
|
+
return [oldR, oldS, oldT];
|
|
77
|
+
}
|
|
78
|
+
function modInverse(a, n) {
|
|
79
|
+
const norm = ((a % n) + n) % n;
|
|
80
|
+
const [g, x] = egcd(norm, n);
|
|
81
|
+
if (g !== 1n)
|
|
82
|
+
throw new Error('no modular inverse');
|
|
83
|
+
return ((x % n) + n) % n;
|
|
84
|
+
}
|
|
85
|
+
// ======================================================================
|
|
86
|
+
// Full-domain hash (FDH) for RSA blind signatures
|
|
87
|
+
// ======================================================================
|
|
88
|
+
/**
|
|
89
|
+
* Map a message to a uniformly-distributed integer in [1, n). Standard
|
|
90
|
+
* FDH construction via MGF1-SHA256 with a counter-based retry loop to
|
|
91
|
+
* handle the edge case where the candidate is ≥ n.
|
|
92
|
+
*
|
|
93
|
+
* Why FDH matters: without it, RSA signatures are vulnerable to
|
|
94
|
+
* multiplicative forgery attacks (signatures can be combined to forge
|
|
95
|
+
* signatures on products of messages). FDH destroys the algebraic
|
|
96
|
+
* structure of the message so no such combination exists.
|
|
97
|
+
*/
|
|
98
|
+
function fdh(message, modulus, modulusBytes) {
|
|
99
|
+
for (let counter = 0; counter < 1000; counter++) {
|
|
100
|
+
const out = Buffer.alloc(modulusBytes);
|
|
101
|
+
let written = 0;
|
|
102
|
+
let i = 0;
|
|
103
|
+
while (written < modulusBytes) {
|
|
104
|
+
const hash = createHash('sha256');
|
|
105
|
+
hash.update(message);
|
|
106
|
+
const counterBuf = Buffer.alloc(4);
|
|
107
|
+
counterBuf.writeUInt32BE(counter, 0);
|
|
108
|
+
hash.update(counterBuf);
|
|
109
|
+
const iBuf = Buffer.alloc(4);
|
|
110
|
+
iBuf.writeUInt32BE(i, 0);
|
|
111
|
+
hash.update(iBuf);
|
|
112
|
+
const digest = hash.digest();
|
|
113
|
+
const take = Math.min(digest.length, modulusBytes - written);
|
|
114
|
+
digest.copy(out, written, 0, take);
|
|
115
|
+
written += take;
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
// Clear the top bit to reduce the chance of value ≥ n on the first try.
|
|
119
|
+
out[0] &= 0x7f;
|
|
120
|
+
const candidate = bytesToBigint(out);
|
|
121
|
+
if (candidate > 0n && candidate < modulus)
|
|
122
|
+
return candidate;
|
|
123
|
+
}
|
|
124
|
+
throw new Error('FDH: exhausted counter without finding candidate');
|
|
125
|
+
}
|
|
126
|
+
function rawPublicOp(key, value) {
|
|
127
|
+
const input = bigintToBytes(value, key.modulusBytes);
|
|
128
|
+
const output = publicEncrypt({ key: key.keyObj, padding: constants.RSA_NO_PADDING }, input);
|
|
129
|
+
return bytesToBigint(output);
|
|
130
|
+
}
|
|
131
|
+
function rawPrivateOp(key, value) {
|
|
132
|
+
const input = bigintToBytes(value, key.modulusBytes);
|
|
133
|
+
const output = privateDecrypt({ key: key.keyObjPriv, padding: constants.RSA_NO_PADDING }, input);
|
|
134
|
+
return bytesToBigint(output);
|
|
135
|
+
}
|
|
136
|
+
// ======================================================================
|
|
137
|
+
// Blind signature protocol
|
|
138
|
+
// ======================================================================
|
|
139
|
+
/**
|
|
140
|
+
* Blind a token: pick r ∈ [2, n), compute blinded = FDH(token) · r^e mod n.
|
|
141
|
+
* The admin sees only the blinded value (uniform over Z_n*) and learns
|
|
142
|
+
* nothing about the token.
|
|
143
|
+
*/
|
|
144
|
+
export function blindToken(tokenBytes, pubKey) {
|
|
145
|
+
const m = fdh(tokenBytes, pubKey.n, pubKey.modulusBytes);
|
|
146
|
+
let r = 0n;
|
|
147
|
+
for (let attempts = 0; attempts < 32; attempts++) {
|
|
148
|
+
const rBytes = randomBytes(pubKey.modulusBytes);
|
|
149
|
+
rBytes[0] &= 0x7f;
|
|
150
|
+
const candidate = bytesToBigint(rBytes);
|
|
151
|
+
if (candidate > 1n && candidate < pubKey.n) {
|
|
152
|
+
r = candidate;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (r === 0n)
|
|
157
|
+
throw new Error('blindToken: failed to sample r');
|
|
158
|
+
const rE = rawPublicOp(pubKey, r);
|
|
159
|
+
const blinded = (m * rE) % pubKey.n;
|
|
160
|
+
return { blinded, r };
|
|
161
|
+
}
|
|
162
|
+
/** Admin-side: sign a blinded value. No knowledge of the original token. */
|
|
163
|
+
export function signBlinded(blinded, privKey) {
|
|
164
|
+
return rawPrivateOp(privKey, blinded);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Member-side: given the admin's signature on the blinded value, remove
|
|
168
|
+
* the blinding factor to obtain a raw RSA-FDH signature over the original
|
|
169
|
+
* token that the admin never saw.
|
|
170
|
+
*
|
|
171
|
+
* Math: signed_blinded = (FDH(t) · r^e)^d = FDH(t)^d · r mod n.
|
|
172
|
+
* Multiplying by r^(-1) mod n yields FDH(t)^d = the raw signature.
|
|
173
|
+
*/
|
|
174
|
+
export function unblindSignature(blindedSignature, r, pubKey) {
|
|
175
|
+
const rInv = modInverse(r, pubKey.n);
|
|
176
|
+
return (blindedSignature * rInv) % pubKey.n;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Verify a (token, signature) pair against the admin's public key.
|
|
180
|
+
* True iff signature^e ≡ FDH(token) (mod n).
|
|
181
|
+
*/
|
|
182
|
+
export function verifyTokenSignature(tokenBytes, signature, pubKey) {
|
|
183
|
+
if (signature <= 0n || signature >= pubKey.n)
|
|
184
|
+
return false;
|
|
185
|
+
const expected = fdh(tokenBytes, pubKey.n, pubKey.modulusBytes);
|
|
186
|
+
const actual = rawPublicOp(pubKey, signature);
|
|
187
|
+
return expected === actual;
|
|
188
|
+
}
|
|
189
|
+
// ======================================================================
|
|
190
|
+
// Key generation and export/import
|
|
191
|
+
// ======================================================================
|
|
192
|
+
export function generateGroupKey(bits = 2048) {
|
|
193
|
+
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
|
|
194
|
+
modulusLength: bits,
|
|
195
|
+
publicExponent: 65537,
|
|
196
|
+
});
|
|
197
|
+
const jwk = publicKey.export({ format: 'jwk' });
|
|
198
|
+
const n = bytesToBigint(Buffer.from(jwk.n, 'base64url'));
|
|
199
|
+
const e = bytesToBigint(Buffer.from(jwk.e, 'base64url'));
|
|
200
|
+
const modulusBytes = Math.ceil(bits / 8);
|
|
201
|
+
return {
|
|
202
|
+
n, e, modulusBytes,
|
|
203
|
+
keyObj: publicKey,
|
|
204
|
+
keyObjPriv: privateKey,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
export function exportGroupPublicKey(key) {
|
|
208
|
+
return {
|
|
209
|
+
n: key.n.toString(16),
|
|
210
|
+
e: key.e.toString(16),
|
|
211
|
+
modulusBytes: key.modulusBytes,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
export function importGroupPublicKey(exported) {
|
|
215
|
+
const n = BigInt('0x' + exported.n);
|
|
216
|
+
const e = BigInt('0x' + exported.e);
|
|
217
|
+
const nBytes = bigintToBytes(n, exported.modulusBytes);
|
|
218
|
+
let eHex = e.toString(16);
|
|
219
|
+
if (eHex.length % 2)
|
|
220
|
+
eHex = '0' + eHex;
|
|
221
|
+
const eBytes = Buffer.from(eHex, 'hex');
|
|
222
|
+
const keyObj = createPublicKey({
|
|
223
|
+
key: {
|
|
224
|
+
kty: 'RSA',
|
|
225
|
+
n: nBytes.toString('base64url'),
|
|
226
|
+
e: eBytes.toString('base64url'),
|
|
227
|
+
},
|
|
228
|
+
format: 'jwk',
|
|
229
|
+
});
|
|
230
|
+
return { n, e, modulusBytes: exported.modulusBytes, keyObj };
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Admin holds the group private key and a roster of authorized members.
|
|
234
|
+
* Admin does NOT hold any of the tokens, by design — blind signing means
|
|
235
|
+
* the admin never sees what they signed. This is the key privacy property.
|
|
236
|
+
*
|
|
237
|
+
* The admin's responsibilities are purely social: decide who's in the
|
|
238
|
+
* group, set per-member quotas, rotate the group key when someone leaves.
|
|
239
|
+
*/
|
|
240
|
+
export class GroupAdmin {
|
|
241
|
+
groupId;
|
|
242
|
+
key;
|
|
243
|
+
members;
|
|
244
|
+
constructor(groupId, key, members) {
|
|
245
|
+
this.groupId = groupId;
|
|
246
|
+
this.key = key;
|
|
247
|
+
this.members = members;
|
|
248
|
+
}
|
|
249
|
+
static create(groupId, bits = 2048) {
|
|
250
|
+
return new GroupAdmin(groupId, generateGroupKey(bits), new Map());
|
|
251
|
+
}
|
|
252
|
+
addMember(pubkey, quotaPerBatch = 100, validForDays = 365) {
|
|
253
|
+
this.members.set(pubkey, {
|
|
254
|
+
pubkey,
|
|
255
|
+
expiresAt: Date.now() + validForDays * 86400_000,
|
|
256
|
+
quotaPerBatch,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
removeMember(pubkey) {
|
|
260
|
+
return this.members.delete(pubkey);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Sign a batch of blinded tokens submitted by a member. The admin
|
|
264
|
+
* authenticates the request out-of-band (member identity auth happens
|
|
265
|
+
* at the HTTP layer via a member signing key — not modelled here).
|
|
266
|
+
*
|
|
267
|
+
* Throws on: unknown member, expired membership, batch-too-large.
|
|
268
|
+
*/
|
|
269
|
+
signBatch(memberPubkey, blinded) {
|
|
270
|
+
const member = this.members.get(memberPubkey);
|
|
271
|
+
if (!member)
|
|
272
|
+
throw new Error(`unknown member: ${memberPubkey.slice(0, 16)}...`);
|
|
273
|
+
if (member.expiresAt < Date.now())
|
|
274
|
+
throw new Error('member membership expired');
|
|
275
|
+
if (blinded.length > member.quotaPerBatch) {
|
|
276
|
+
throw new Error(`batch size ${blinded.length} exceeds quota ${member.quotaPerBatch}`);
|
|
277
|
+
}
|
|
278
|
+
return blinded.map((b) => signBlinded(b, this.key));
|
|
279
|
+
}
|
|
280
|
+
publicKey() {
|
|
281
|
+
return exportGroupPublicKey(this.key);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Member holds an identity pubkey (used by the admin for roster lookup)
|
|
286
|
+
* and a local stash of unused (token, signature) pairs. Tokens are single-
|
|
287
|
+
* use — consume one per borrow. Admin never saw any of these tokens.
|
|
288
|
+
*/
|
|
289
|
+
export class GroupMember {
|
|
290
|
+
memberPubkey;
|
|
291
|
+
groupPublicKey;
|
|
292
|
+
tokens = [];
|
|
293
|
+
constructor(memberPubkey, groupPublicKey) {
|
|
294
|
+
this.memberPubkey = memberPubkey;
|
|
295
|
+
this.groupPublicKey = groupPublicKey;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Step 1 of a token batch: generate random tokens, blind each, return
|
|
299
|
+
* the blinded values (to send to admin) plus the per-token state
|
|
300
|
+
* (kept locally for unblinding after admin responds).
|
|
301
|
+
*/
|
|
302
|
+
prepareBatch(count) {
|
|
303
|
+
const blinded = [];
|
|
304
|
+
const state = [];
|
|
305
|
+
for (let i = 0; i < count; i++) {
|
|
306
|
+
const token = randomBytes(32);
|
|
307
|
+
const { blinded: b, r } = blindToken(token, this.groupPublicKey);
|
|
308
|
+
blinded.push(b);
|
|
309
|
+
state.push({ token, r });
|
|
310
|
+
}
|
|
311
|
+
return { blinded, state };
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Step 2 of a token batch: unblind each admin-signed value, verify the
|
|
315
|
+
* resulting raw signature, and add the (token, signature) pair to the
|
|
316
|
+
* local stash. Any verification failure throws — we never store a token
|
|
317
|
+
* whose signature doesn't check out.
|
|
318
|
+
*/
|
|
319
|
+
finalizeBatch(signedBlinded, state) {
|
|
320
|
+
if (signedBlinded.length !== state.length) {
|
|
321
|
+
throw new Error('finalizeBatch: length mismatch');
|
|
322
|
+
}
|
|
323
|
+
const toStore = [];
|
|
324
|
+
for (let i = 0; i < signedBlinded.length; i++) {
|
|
325
|
+
const signature = unblindSignature(signedBlinded[i], state[i].r, this.groupPublicKey);
|
|
326
|
+
if (!verifyTokenSignature(state[i].token, signature, this.groupPublicKey)) {
|
|
327
|
+
throw new Error(`finalizeBatch: signature ${i} failed verification`);
|
|
328
|
+
}
|
|
329
|
+
toStore.push({ token: state[i].token, signature });
|
|
330
|
+
}
|
|
331
|
+
this.tokens.push(...toStore);
|
|
332
|
+
}
|
|
333
|
+
consumeToken() {
|
|
334
|
+
return this.tokens.shift() ?? null;
|
|
335
|
+
}
|
|
336
|
+
tokenCount() {
|
|
337
|
+
return this.tokens.length;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Lender holds the group public key and a set of token hashes that have
|
|
342
|
+
* already been redeemed. Memory-only in v1; a persisted set would be a
|
|
343
|
+
* sqlite table keyed on token hash, or just a file of sha256 hex lines.
|
|
344
|
+
*
|
|
345
|
+
* The lender LEARNS NOTHING about which member borrowed. That's the
|
|
346
|
+
* whole point — blind signatures decouple "who signed this request"
|
|
347
|
+
* (the admin, uniformly) from "who holds the token" (one specific
|
|
348
|
+
* member who is anonymous to the lender).
|
|
349
|
+
*/
|
|
350
|
+
export class GroupLender {
|
|
351
|
+
groupId;
|
|
352
|
+
groupPublicKey;
|
|
353
|
+
seenTokens = new Set();
|
|
354
|
+
maxSeenTokens;
|
|
355
|
+
constructor(groupId, groupPublicKey, opts = {}) {
|
|
356
|
+
this.groupId = groupId;
|
|
357
|
+
this.groupPublicKey = groupPublicKey;
|
|
358
|
+
this.maxSeenTokens = opts.maxSeenTokens ?? 100_000;
|
|
359
|
+
}
|
|
360
|
+
acceptBorrow(token, signature) {
|
|
361
|
+
if (!verifyTokenSignature(token, signature, this.groupPublicKey)) {
|
|
362
|
+
return { ok: false, reason: 'invalid_signature' };
|
|
363
|
+
}
|
|
364
|
+
const hash = createHash('sha256').update(token).digest('hex');
|
|
365
|
+
if (this.seenTokens.has(hash)) {
|
|
366
|
+
return { ok: false, reason: 'double_spend' };
|
|
367
|
+
}
|
|
368
|
+
this.seenTokens.add(hash);
|
|
369
|
+
if (this.seenTokens.size > this.maxSeenTokens) {
|
|
370
|
+
const oldest = this.seenTokens.values().next().value;
|
|
371
|
+
if (oldest)
|
|
372
|
+
this.seenTokens.delete(oldest);
|
|
373
|
+
}
|
|
374
|
+
return { ok: true };
|
|
375
|
+
}
|
|
376
|
+
seenCount() {
|
|
377
|
+
return this.seenTokens.size;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
export function encodeBorrowEnvelope(groupId, bt, request) {
|
|
381
|
+
const env = {
|
|
382
|
+
v: 1,
|
|
383
|
+
groupId,
|
|
384
|
+
token: bt.token.toString('base64url'),
|
|
385
|
+
sig: bt.signature.toString(16),
|
|
386
|
+
request,
|
|
387
|
+
};
|
|
388
|
+
return JSON.stringify(env);
|
|
389
|
+
}
|
|
390
|
+
export function decodeBorrowEnvelope(s) {
|
|
391
|
+
try {
|
|
392
|
+
const obj = JSON.parse(s);
|
|
393
|
+
if (obj?.v !== 1 ||
|
|
394
|
+
typeof obj.groupId !== 'string' ||
|
|
395
|
+
typeof obj.token !== 'string' ||
|
|
396
|
+
typeof obj.sig !== 'string' ||
|
|
397
|
+
obj.request === undefined) {
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
return obj;
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
export function parseBorrowToken(env) {
|
|
407
|
+
try {
|
|
408
|
+
return {
|
|
409
|
+
token: Buffer.from(env.token, 'base64url'),
|
|
410
|
+
signature: BigInt('0x' + env.sig),
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
}
|
package/dist/shim/runtime.cjs
CHANGED
|
@@ -31,20 +31,64 @@ function log(msg) {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Detect the JS runtime we've been loaded into. Shim was designed for
|
|
36
|
+
* Node — Bun ships its own fetch + undici with slightly different
|
|
37
|
+
* internals, and Deno's fetch is a completely different implementation.
|
|
38
|
+
* Patching globalThis.fetch works in all three, but body/header semantics
|
|
39
|
+
* may drift. We log a warning for non-Node runtimes so surprising
|
|
40
|
+
* behavior is traceable to the root cause.
|
|
41
|
+
*
|
|
42
|
+
* When Anthropic eventually ships a Bun-compiled / single-binary CC,
|
|
43
|
+
* this detector is the canary — a user running `dario shim -- claude ...`
|
|
44
|
+
* against a Bun CC will see the warning and know to expect quirks.
|
|
45
|
+
*/
|
|
46
|
+
function detectRuntime() {
|
|
47
|
+
if (typeof globalThis.Bun !== 'undefined') return 'bun';
|
|
48
|
+
if (typeof globalThis.Deno !== 'undefined') return 'deno';
|
|
49
|
+
if (typeof process !== 'undefined' && process.versions && process.versions.node) return 'node';
|
|
50
|
+
return 'unknown';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const RUNTIME = detectRuntime();
|
|
54
|
+
if (RUNTIME !== 'node') {
|
|
55
|
+
log(`running under ${RUNTIME} — shim was validated against Node. Body/header semantics may differ.`);
|
|
56
|
+
}
|
|
57
|
+
|
|
34
58
|
let template = null;
|
|
59
|
+
let templateMtime = 0;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Load the template, re-reading from disk if the file's mtime has changed.
|
|
63
|
+
* Auto-refresh matters for long-running shim sessions: dario's live
|
|
64
|
+
* fingerprint capture may update the template file mid-session (daily
|
|
65
|
+
* refresh), and we'd like the shim to pick up the new version without
|
|
66
|
+
* requiring a child restart.
|
|
67
|
+
*
|
|
68
|
+
* Cached in memory between calls so we don't stat on every intercept.
|
|
69
|
+
*/
|
|
35
70
|
function loadTemplate() {
|
|
36
|
-
if (template) return template;
|
|
37
71
|
try {
|
|
72
|
+
const stat = fs.statSync(TEMPLATE_PATH);
|
|
73
|
+
if (template && stat.mtimeMs === templateMtime) return template;
|
|
38
74
|
const raw = fs.readFileSync(TEMPLATE_PATH, 'utf-8');
|
|
39
75
|
const parsed = JSON.parse(raw);
|
|
40
76
|
if (parsed && parsed.agent_identity && parsed.system_prompt && Array.isArray(parsed.tools)) {
|
|
77
|
+
const prevVersion = template && template.cc_version;
|
|
41
78
|
template = parsed;
|
|
42
|
-
|
|
79
|
+
templateMtime = stat.mtimeMs;
|
|
80
|
+
if (prevVersion && prevVersion !== parsed.cc_version) {
|
|
81
|
+
log(`template reloaded: cc_version ${prevVersion} → ${parsed.cc_version}`);
|
|
82
|
+
} else {
|
|
83
|
+
log(`template loaded from ${TEMPLATE_PATH} (cc_version=${parsed.cc_version || 'unknown'}${
|
|
84
|
+
Array.isArray(parsed.header_order) ? `, header_order=${parsed.header_order.length}` : ''
|
|
85
|
+
})`);
|
|
86
|
+
}
|
|
43
87
|
return template;
|
|
44
88
|
}
|
|
45
89
|
log(`template at ${TEMPLATE_PATH} missing required fields — passthrough`);
|
|
46
90
|
} catch (e) {
|
|
47
|
-
log(`template load failed: ${e.message} — passthrough`);
|
|
91
|
+
if (e.code !== 'ENOENT') log(`template load failed: ${e.message} — passthrough`);
|
|
48
92
|
}
|
|
49
93
|
return null;
|
|
50
94
|
}
|
|
@@ -75,31 +119,108 @@ function rewriteBody(bodyText, tmpl) {
|
|
|
75
119
|
try { body = JSON.parse(bodyText); } catch (_) { return null; }
|
|
76
120
|
if (!body || typeof body !== 'object') return null;
|
|
77
121
|
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
122
|
+
// Defensive shape check. Real CC sends:
|
|
123
|
+
// system: [billing_tag, agent_identity, system_prompt] (length 3, all text blocks)
|
|
124
|
+
// If we see anything else — a one-element system, a four-element system,
|
|
125
|
+
// an image block in system[0], CC shipping a restructured system array
|
|
126
|
+
// in a future release — passthrough instead of rewriting. Blindly
|
|
127
|
+
// replacing blocks we don't understand can corrupt the request in ways
|
|
128
|
+
// that break the child silently (think: 400 with "unexpected block type").
|
|
129
|
+
//
|
|
130
|
+
// The old logic accepted `length >= 1`, creating [1] and [2] out of thin
|
|
131
|
+
// air when they didn't exist. That's a recipe for template drift incidents
|
|
132
|
+
// when CC's shape changes. Strict check, log, passthrough on mismatch.
|
|
133
|
+
if (!Array.isArray(body.system) || body.system.length !== 3) {
|
|
134
|
+
log(`body rewrite skipped: system has ${Array.isArray(body.system) ? body.system.length : 'no'} blocks, expected 3`);
|
|
135
|
+
return null;
|
|
88
136
|
}
|
|
137
|
+
const allText = body.system.every((b) =>
|
|
138
|
+
b && typeof b === 'object' && b.type === 'text' && typeof b.text === 'string',
|
|
139
|
+
);
|
|
140
|
+
if (!allText) {
|
|
141
|
+
log('body rewrite skipped: system contains non-text blocks');
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const billingTag = body.system[0];
|
|
146
|
+
body.system = [
|
|
147
|
+
billingTag,
|
|
148
|
+
{ type: 'text', text: tmpl.agent_identity, cache_control: { type: 'ephemeral', ttl: '1h' } },
|
|
149
|
+
{ type: 'text', text: tmpl.system_prompt, cache_control: { type: 'ephemeral', ttl: '1h' } },
|
|
150
|
+
];
|
|
89
151
|
body.tools = tmpl.tools;
|
|
90
152
|
return JSON.stringify(body);
|
|
91
153
|
}
|
|
92
154
|
|
|
93
155
|
function rewriteHeaders(headers, tmpl) {
|
|
94
156
|
// Headers in fetch() init can be Headers, plain object, or array of pairs.
|
|
95
|
-
//
|
|
96
|
-
|
|
157
|
+
// We normalize into a Map (lowercased keys, insertion-order iteration),
|
|
158
|
+
// then return an array of [name, value] pairs — a valid HeadersInit —
|
|
159
|
+
// which fetch() will serialize on the wire in our exact order.
|
|
160
|
+
//
|
|
161
|
+
// A plain Headers object won't do: per the fetch spec, Headers iteration
|
|
162
|
+
// is sorted alphabetically, so building a Headers with sets in order
|
|
163
|
+
// would succeed internally but iteration (and any downstream code that
|
|
164
|
+
// reads via for...of) would see sorted order. Using an array bypasses
|
|
165
|
+
// that entirely — the HTTP layer writes pairs in array order.
|
|
166
|
+
//
|
|
167
|
+
// This is the v3.13 "hide in the population" hook: when the live capture
|
|
168
|
+
// has recorded CC's header sequence, we replay it on every outbound
|
|
169
|
+
// request so Anthropic sees the same shape we observed.
|
|
170
|
+
const src = new Headers(headers || {});
|
|
171
|
+
const snapshot = new Map();
|
|
172
|
+
for (const [name, value] of src) {
|
|
173
|
+
snapshot.set(name.toLowerCase(), value);
|
|
174
|
+
}
|
|
97
175
|
if (tmpl.cc_version) {
|
|
98
|
-
|
|
99
|
-
|
|
176
|
+
snapshot.set('user-agent', `claude-cli/${tmpl.cc_version} (external, cli)`);
|
|
177
|
+
snapshot.set('x-anthropic-billing-header', `cc_version=${tmpl.cc_version}`);
|
|
178
|
+
}
|
|
179
|
+
snapshot.set('anthropic-beta', tmpl.anthropic_beta || 'claude-code-20250219');
|
|
180
|
+
|
|
181
|
+
if (!Array.isArray(tmpl.header_order) || tmpl.header_order.length === 0) {
|
|
182
|
+
return [...snapshot.entries()];
|
|
100
183
|
}
|
|
101
|
-
|
|
102
|
-
|
|
184
|
+
|
|
185
|
+
// Rebuild in the captured order. Any header the caller supplied that
|
|
186
|
+
// wasn't in the captured order gets appended at the end so we don't
|
|
187
|
+
// silently drop host-added headers (content-type, content-length).
|
|
188
|
+
const ordered = [];
|
|
189
|
+
const seen = new Set();
|
|
190
|
+
for (const name of tmpl.header_order) {
|
|
191
|
+
const key = name.toLowerCase();
|
|
192
|
+
if (snapshot.has(key)) {
|
|
193
|
+
ordered.push([key, snapshot.get(key)]);
|
|
194
|
+
seen.add(key);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const [key, value] of snapshot) {
|
|
198
|
+
if (!seen.has(key)) {
|
|
199
|
+
ordered.push([key, value]);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return ordered;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Warn when the child's user-agent cc_version differs from the template's.
|
|
207
|
+
* Useful signal during a CC upgrade: the user installed a new CC but the
|
|
208
|
+
* live template cache is stale, so we're about to fingerprint as an older
|
|
209
|
+
* version than the actual CC binary. The shim still works — we overwrite
|
|
210
|
+
* the user-agent regardless — but logging the drift makes debugging
|
|
211
|
+
* easier when a user reports "Anthropic started seeing me as 2.1.200 even
|
|
212
|
+
* though I'm running 2.1.250".
|
|
213
|
+
*/
|
|
214
|
+
function checkVersionDrift(headers, tmpl) {
|
|
215
|
+
if (!tmpl || !tmpl.cc_version) return;
|
|
216
|
+
try {
|
|
217
|
+
const h = new Headers(headers || {});
|
|
218
|
+
const ua = h.get('user-agent') || '';
|
|
219
|
+
const match = ua.match(/claude-cli\/(\d+\.\d+\.\d+)/);
|
|
220
|
+
if (match && match[1] && match[1] !== tmpl.cc_version) {
|
|
221
|
+
log(`version drift: child cc_version=${match[1]}, template cc_version=${tmpl.cc_version} — shim will impersonate template version`);
|
|
222
|
+
}
|
|
223
|
+
} catch (_) { /* noop */ }
|
|
103
224
|
}
|
|
104
225
|
|
|
105
226
|
function shouldIntercept(input, init) {
|
|
@@ -144,10 +265,12 @@ async function darioShimFetch(input, init) {
|
|
|
144
265
|
return originalFetch.call(this, input, init);
|
|
145
266
|
}
|
|
146
267
|
|
|
268
|
+
const srcHeaders = (init && init.headers) || (input && input.headers);
|
|
269
|
+
checkVersionDrift(srcHeaders, tmpl);
|
|
147
270
|
const newInit = Object.assign({}, init || {}, {
|
|
148
271
|
method: 'POST',
|
|
149
272
|
body: rewritten,
|
|
150
|
-
headers: rewriteHeaders(
|
|
273
|
+
headers: rewriteHeaders(srcHeaders, tmpl),
|
|
151
274
|
});
|
|
152
275
|
const url = typeof input === 'string' ? input : input.url;
|
|
153
276
|
|
|
@@ -179,6 +302,9 @@ if (process.env.DARIO_SHIM === '1') {
|
|
|
179
302
|
module.exports = {
|
|
180
303
|
_rewriteBody: rewriteBody,
|
|
181
304
|
_rewriteHeaders: rewriteHeaders,
|
|
305
|
+
_checkVersionDrift: checkVersionDrift,
|
|
306
|
+
_detectRuntime: detectRuntime,
|
|
307
|
+
_loadTemplate: loadTemplate,
|
|
182
308
|
_shouldIntercept: shouldIntercept,
|
|
183
309
|
_isAnthropicMessages: isAnthropicMessages,
|
|
184
310
|
_darioShimFetch: darioShimFetch,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.13.0",
|
|
4
4
|
"description": "A local LLM router. One endpoint, every provider — Claude subscriptions, OpenAI, OpenRouter, Groq, local LiteLLM, any OpenAI-compat endpoint — your tools don't need to change.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
23
|
"build": "tsc && cp src/cc-template-data.json dist/ && node -e \"require('fs').mkdirSync('dist/shim',{recursive:true})\" && cp src/shim/runtime.cjs dist/shim/",
|
|
24
|
-
"test": "node test/issue-29-tool-translation.mjs && node test/hybrid-tools.mjs && node test/scrub-paths.mjs && node test/provider-prefix.mjs && node test/analytics-recording.mjs && node test/analytics-billing-bucket.mjs && node test/failover-429.mjs && node test/live-fingerprint.mjs && node test/shim-runtime.mjs && node test/shim-e2e.mjs",
|
|
24
|
+
"test": "node test/issue-29-tool-translation.mjs && node test/hybrid-tools.mjs && node test/scrub-paths.mjs && node test/provider-prefix.mjs && node test/analytics-recording.mjs && node test/analytics-billing-bucket.mjs && node test/failover-429.mjs && node test/pool-sticky.mjs && node test/sealed-pool.mjs && node test/live-fingerprint.mjs && node test/shim-runtime.mjs && node test/shim-e2e.mjs",
|
|
25
25
|
"audit": "npm audit --production --audit-level=high",
|
|
26
26
|
"prepublishOnly": "npm run build",
|
|
27
27
|
"start": "node dist/cli.js",
|