@askalf/dario 3.11.1 → 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/cli.js +52 -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/host.d.ts +59 -0
- package/dist/shim/host.js +169 -0
- package/dist/shim/runtime.cjs +312 -0
- package/package.json +3 -3
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shim host — dario-side of the shim transport.
|
|
3
|
+
*
|
|
4
|
+
* Runs `dario shim -- <cmd> [args...]`. Spawns the child with NODE_OPTIONS
|
|
5
|
+
* pointing at the shim runtime, listens on a unix socket (or named pipe on
|
|
6
|
+
* Windows) for billing relay events from the runtime, feeds them into the
|
|
7
|
+
* Analytics class, and forwards the child's stdio to the host TTY so the
|
|
8
|
+
* user experience is identical to running the command directly.
|
|
9
|
+
*
|
|
10
|
+
* Why a socket and not a file or stdout: the runtime patches `globalThis.fetch`
|
|
11
|
+
* inside the *child*'s process — that child still owns its own stdout (the
|
|
12
|
+
* user's TTY), and we don't want shim relay traffic interleaved with CC's
|
|
13
|
+
* normal output. A unix socket gives us a clean side-channel, lets the host
|
|
14
|
+
* keep accumulating analytics across the child's lifetime, and stays open if
|
|
15
|
+
* the child re-execs (rare but possible with claude wrappers).
|
|
16
|
+
*
|
|
17
|
+
* See v3.12.0 CHANGELOG for the design rationale.
|
|
18
|
+
*/
|
|
19
|
+
import { Analytics } from './../analytics.js';
|
|
20
|
+
/**
|
|
21
|
+
* Locate the shim runtime CJS file. In the published package it lives at
|
|
22
|
+
* `dist/shim/runtime.cjs` next to this module's compiled output. In dev
|
|
23
|
+
* (running via tsx from src/) it lives at `src/shim/runtime.cjs`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function locateShimRuntime(): string;
|
|
26
|
+
interface RelayEvent {
|
|
27
|
+
kind: 'request' | 'response';
|
|
28
|
+
timestamp: number;
|
|
29
|
+
bytes?: number;
|
|
30
|
+
status?: number;
|
|
31
|
+
claim?: string | null;
|
|
32
|
+
overageUtil?: number | null;
|
|
33
|
+
}
|
|
34
|
+
export interface ShimHostOptions {
|
|
35
|
+
/** Command to spawn (the user's claude binary, or any node-based CC wrapper). */
|
|
36
|
+
command: string;
|
|
37
|
+
/** Args passed through to the child. */
|
|
38
|
+
args: string[];
|
|
39
|
+
/** Override the template path the runtime reads. Defaults to ~/.dario/cc-template.live.json. */
|
|
40
|
+
templatePath?: string;
|
|
41
|
+
/** Print per-event lines to stderr. */
|
|
42
|
+
verbose?: boolean;
|
|
43
|
+
/** Optional Analytics sink. If omitted, a fresh instance is created. */
|
|
44
|
+
analytics?: Analytics;
|
|
45
|
+
}
|
|
46
|
+
export interface ShimHostResult {
|
|
47
|
+
exitCode: number;
|
|
48
|
+
events: RelayEvent[];
|
|
49
|
+
analytics: Analytics;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Spawn the child command with the shim runtime injected, relay billing
|
|
53
|
+
* events to Analytics, return when the child exits.
|
|
54
|
+
*
|
|
55
|
+
* Stdio is inherited so the user sees the child's output exactly as if they
|
|
56
|
+
* had run it without the shim.
|
|
57
|
+
*/
|
|
58
|
+
export declare function runShim(opts: ShimHostOptions): Promise<ShimHostResult>;
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shim host — dario-side of the shim transport.
|
|
3
|
+
*
|
|
4
|
+
* Runs `dario shim -- <cmd> [args...]`. Spawns the child with NODE_OPTIONS
|
|
5
|
+
* pointing at the shim runtime, listens on a unix socket (or named pipe on
|
|
6
|
+
* Windows) for billing relay events from the runtime, feeds them into the
|
|
7
|
+
* Analytics class, and forwards the child's stdio to the host TTY so the
|
|
8
|
+
* user experience is identical to running the command directly.
|
|
9
|
+
*
|
|
10
|
+
* Why a socket and not a file or stdout: the runtime patches `globalThis.fetch`
|
|
11
|
+
* inside the *child*'s process — that child still owns its own stdout (the
|
|
12
|
+
* user's TTY), and we don't want shim relay traffic interleaved with CC's
|
|
13
|
+
* normal output. A unix socket gives us a clean side-channel, lets the host
|
|
14
|
+
* keep accumulating analytics across the child's lifetime, and stays open if
|
|
15
|
+
* the child re-execs (rare but possible with claude wrappers).
|
|
16
|
+
*
|
|
17
|
+
* See v3.12.0 CHANGELOG for the design rationale.
|
|
18
|
+
*/
|
|
19
|
+
import { createServer } from 'node:net';
|
|
20
|
+
import { spawn } from 'node:child_process';
|
|
21
|
+
import { mkdtempSync, existsSync } from 'node:fs';
|
|
22
|
+
import { tmpdir, homedir } from 'node:os';
|
|
23
|
+
import { join, dirname } from 'node:path';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { Analytics } from './../analytics.js';
|
|
26
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
27
|
+
const __dirname = dirname(__filename);
|
|
28
|
+
/**
|
|
29
|
+
* Locate the shim runtime CJS file. In the published package it lives at
|
|
30
|
+
* `dist/shim/runtime.cjs` next to this module's compiled output. In dev
|
|
31
|
+
* (running via tsx from src/) it lives at `src/shim/runtime.cjs`.
|
|
32
|
+
*/
|
|
33
|
+
export function locateShimRuntime() {
|
|
34
|
+
const candidates = [
|
|
35
|
+
join(__dirname, 'runtime.cjs'), // dist/shim/runtime.cjs (production)
|
|
36
|
+
join(__dirname, '..', '..', 'src', 'shim', 'runtime.cjs'), // dev: from dist/shim → ../../src/shim
|
|
37
|
+
join(__dirname, '..', 'src', 'shim', 'runtime.cjs'), // dev: from src/shim → ../src/shim (rare)
|
|
38
|
+
];
|
|
39
|
+
for (const c of candidates) {
|
|
40
|
+
if (existsSync(c))
|
|
41
|
+
return c;
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`shim runtime not found; checked: ${candidates.join(', ')}`);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Pick a socket path: unix domain socket on POSIX, named pipe on Windows.
|
|
47
|
+
* Both forms are accepted directly by net.createServer / net.connect.
|
|
48
|
+
*/
|
|
49
|
+
function makeSockPath() {
|
|
50
|
+
if (process.platform === 'win32') {
|
|
51
|
+
return `\\\\.\\pipe\\dario-shim-${process.pid}-${Date.now()}`;
|
|
52
|
+
}
|
|
53
|
+
const dir = mkdtempSync(join(tmpdir(), 'dario-shim-'));
|
|
54
|
+
return join(dir, 'sock');
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Build the child env. We *prepend* our --require to NODE_OPTIONS rather than
|
|
58
|
+
* overwrite, so existing user NODE_OPTIONS (debuggers, source maps, tracers)
|
|
59
|
+
* still apply. Quoting paths defends against spaces in the dario install dir.
|
|
60
|
+
*/
|
|
61
|
+
function buildChildEnv(parentEnv, runtimePath, sockPath, templatePath, verbose) {
|
|
62
|
+
const requireFlag = `--require=${JSON.stringify(runtimePath)}`;
|
|
63
|
+
const existing = parentEnv.NODE_OPTIONS ?? '';
|
|
64
|
+
const NODE_OPTIONS = existing ? `${requireFlag} ${existing}` : requireFlag;
|
|
65
|
+
return {
|
|
66
|
+
...parentEnv,
|
|
67
|
+
NODE_OPTIONS,
|
|
68
|
+
DARIO_SHIM: '1',
|
|
69
|
+
DARIO_SHIM_SOCK: sockPath,
|
|
70
|
+
DARIO_SHIM_TEMPLATE: templatePath,
|
|
71
|
+
...(verbose ? { DARIO_SHIM_VERBOSE: '1' } : {}),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** Stream parser: relay events arrive as newline-delimited JSON over the socket. */
|
|
75
|
+
function makeSocketHandler(events, analytics, verbose) {
|
|
76
|
+
return (sock) => {
|
|
77
|
+
let buf = '';
|
|
78
|
+
sock.setEncoding('utf-8');
|
|
79
|
+
sock.on('data', (chunk) => {
|
|
80
|
+
buf += chunk;
|
|
81
|
+
let nl;
|
|
82
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
83
|
+
const line = buf.slice(0, nl);
|
|
84
|
+
buf = buf.slice(nl + 1);
|
|
85
|
+
if (!line)
|
|
86
|
+
continue;
|
|
87
|
+
try {
|
|
88
|
+
const event = JSON.parse(line);
|
|
89
|
+
events.push(event);
|
|
90
|
+
if (event.kind === 'response') {
|
|
91
|
+
// Synthesize a minimal RequestRecord so this surfaces in /analytics.
|
|
92
|
+
// Token counts aren't available from the shim transport — the runtime
|
|
93
|
+
// would need to parse the SSE stream to extract them, which we
|
|
94
|
+
// explicitly chose not to do (it's expensive and intrusive). So this
|
|
95
|
+
// is a request-count + claim-tracking record, not a token-cost record.
|
|
96
|
+
analytics.record({
|
|
97
|
+
timestamp: event.timestamp ?? Date.now(),
|
|
98
|
+
account: 'shim',
|
|
99
|
+
model: 'unknown',
|
|
100
|
+
inputTokens: 0, outputTokens: 0,
|
|
101
|
+
cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
|
|
102
|
+
claim: event.claim ?? '',
|
|
103
|
+
util5h: 0, util7d: 0,
|
|
104
|
+
overageUtil: event.overageUtil ?? 0,
|
|
105
|
+
latencyMs: 0,
|
|
106
|
+
status: event.status ?? 0,
|
|
107
|
+
isStream: false,
|
|
108
|
+
isOpenAI: false,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (verbose) {
|
|
112
|
+
process.stderr.write(`[dario shim] ${JSON.stringify(event)}\n`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Malformed line — drop silently. The runtime is best-effort.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/** Internal: stand up the socket server and resolve when it's listening. */
|
|
123
|
+
function startSocketServer(sockPath, events, analytics, verbose) {
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
const server = createServer(makeSocketHandler(events, analytics, verbose));
|
|
126
|
+
server.once('error', reject);
|
|
127
|
+
server.listen(sockPath, () => resolve(server));
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Spawn the child command with the shim runtime injected, relay billing
|
|
132
|
+
* events to Analytics, return when the child exits.
|
|
133
|
+
*
|
|
134
|
+
* Stdio is inherited so the user sees the child's output exactly as if they
|
|
135
|
+
* had run it without the shim.
|
|
136
|
+
*/
|
|
137
|
+
export async function runShim(opts) {
|
|
138
|
+
const runtimePath = locateShimRuntime();
|
|
139
|
+
const sockPath = makeSockPath();
|
|
140
|
+
const templatePath = opts.templatePath ?? join(homedir(), '.dario', 'cc-template.live.json');
|
|
141
|
+
const verbose = opts.verbose ?? false;
|
|
142
|
+
const analytics = opts.analytics ?? new Analytics();
|
|
143
|
+
const events = [];
|
|
144
|
+
const server = await startSocketServer(sockPath, events, analytics, verbose);
|
|
145
|
+
let child;
|
|
146
|
+
try {
|
|
147
|
+
child = spawn(opts.command, opts.args, {
|
|
148
|
+
stdio: 'inherit',
|
|
149
|
+
env: buildChildEnv(process.env, runtimePath, sockPath, templatePath, verbose),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
server.close();
|
|
154
|
+
throw e;
|
|
155
|
+
}
|
|
156
|
+
const exitCode = await new Promise((resolve) => {
|
|
157
|
+
child.on('exit', (code, signal) => {
|
|
158
|
+
if (signal)
|
|
159
|
+
resolve(128 + (signal === 'SIGTERM' ? 15 : 1));
|
|
160
|
+
else
|
|
161
|
+
resolve(code ?? 0);
|
|
162
|
+
});
|
|
163
|
+
child.on('error', () => resolve(1));
|
|
164
|
+
});
|
|
165
|
+
// Give any in-flight relay writes a brief window to land before tearing down.
|
|
166
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
167
|
+
server.close();
|
|
168
|
+
return { exitCode, events, analytics };
|
|
169
|
+
}
|