feat(sharing): securely combine usage across your own devices (#532)

* feat(sharing): pairing, token, and device-identity core

First piece of local device sharing: self-cert fingerprint identity
(trust-on-first-use), a one-time expiring pairing PIN, fingerprint-bound
tokens, and a peer store that authorizes a pull only when both the token
and the TLS peer fingerprint match the same paired device. Pure logic,
fully unit-tested; the TLS share server and host pull build on this.

* feat(sharing): secure mutual-TLS transport + pairing handshake

Add device identity (self-signed cert, persisted; fingerprint = sha256 of
the cert DER), an HTTPS share server (mutual TLS: presents its cert, reads
the client's, and serves /api/usage only when the bearer token AND the
client fingerprint match the same paired peer), a one-time-PIN pairing
endpoint, and a fingerprint-pinning client. Verified end to end on
loopback: PIN pairing, pinned authed pull, and rejection of a wrong PIN,
a token replayed from another device, and a mismatched server
fingerprint. Adds the selfsigned dep (Node cannot generate certs natively).

* feat(sharing): share + devices CLI (pair, pull, combine)

Phase 3 terminal flow: codeburn share runs the secure server on-demand
(stops after 10 min idle; --always to persist, --pair to add a device),
and codeburn devices add <host> --pin <pin> pairs and pins a remote.
codeburn devices pulls this machine plus every paired device, keeps each
separate, and prints a per-device table with a simple summed Combined
row (no server-side merge). Persists identity and peers under the config
dir. Host pair-and-pull flow covered by a loopback integration test.

* feat(sharing): mDNS discovery + approve-style (no-PIN) pairing

Add bonjour-service discovery (advertise/browse over the LAN), a short
confirmation code derived from both cert fingerprints (Bluetooth-style
'do these match?' check), and an approve-style pairing endpoint that
prompts the owner instead of requiring a typed PIN. Loopback-tested:
approved device gets a working token with a matching code on both sides,
declined device is rejected.

* feat(sharing): AirDrop-style discover + approve UX

codeburn share now advertises on the LAN and approves incoming devices
interactively (confirm the matching code, no typed PIN). codeburn devices
add (no args) discovers nearby devices, lets you pick one, shows the
confirmation code, and waits for the owner to approve. Manual
add <host> --pin stays as a fallback for networks that block mDNS.

* feat(sharing): share only aggregates, never project names or paths

Sanitize each device's payload before it leaves the machine: drop
topProjects and topSessions (project names + session detail) and send
only aggregate numbers (cost, tokens, models, tools, activities, daily).
What you are working on stays local; only the totals travel.
This commit is contained in:
Resham Joshi 2026-06-20 16:24:53 +02:00 committed by GitHub
parent 2d44aeaedb
commit 887374de2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1550 additions and 0 deletions

327
package-lock.json generated
View file

@ -10,10 +10,12 @@
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"bonjour-service": "^1.4.1",
"chalk": "^5.4.1",
"commander": "^13.1.0",
"ink": "^7.0.0",
"react": "^19.2.5",
"selfsigned": "^5.5.0",
"strip-ansi": "^7.2.0",
"undici": "^7.27.2",
"zod": "^3.25.76"
@ -24,6 +26,7 @@
"devDependencies": {
"@types/node": "^22.19.17",
"@types/react": "^19.2.14",
"@types/selfsigned": "^2.0.4",
"tsup": "^8.4.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0",
@ -539,6 +542,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@leichtgewicht/ip-codec": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
"license": "MIT"
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.29.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
@ -579,6 +588,175 @@
}
}
},
"node_modules/@noble/hashes": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz",
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@peculiar/asn1-cms": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz",
"integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"@peculiar/asn1-x509-attr": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-csr": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz",
"integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz",
"integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pfx": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz",
"integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.8.0",
"@peculiar/asn1-pkcs8": "^2.8.0",
"@peculiar/asn1-rsa": "^2.8.0",
"@peculiar/asn1-schema": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs8": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz",
"integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-pkcs9": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz",
"integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.8.0",
"@peculiar/asn1-pfx": "^2.8.0",
"@peculiar/asn1-pkcs8": "^2.8.0",
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"@peculiar/asn1-x509-attr": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-rsa": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz",
"integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz",
"integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==",
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz",
"integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509-attr": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz",
"integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.8.0",
"@peculiar/asn1-x509": "^2.8.0",
"asn1js": "^3.0.10",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/x509": {
"version": "1.14.3",
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz",
"integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-cms": "^2.6.0",
"@peculiar/asn1-csr": "^2.6.0",
"@peculiar/asn1-ecc": "^2.6.0",
"@peculiar/asn1-pkcs9": "^2.6.0",
"@peculiar/asn1-rsa": "^2.6.0",
"@peculiar/asn1-schema": "^2.6.0",
"@peculiar/asn1-x509": "^2.6.0",
"pvtsutils": "^1.3.6",
"reflect-metadata": "^0.2.2",
"tslib": "^2.8.1",
"tsyringe": "^4.10.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
@ -974,6 +1152,13 @@
"csstype": "^3.2.2"
}
},
"node_modules/@types/selfsigned": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/selfsigned/-/selfsigned-2.0.4.tgz",
"integrity": "sha512-vNmPhMatNNp9iZ/Ri2w1fciqNcPA2edA58qhzi5F/qDO49Ap4GtEGytuiTX1pSO1HJr81VopC8/INylO9s0keQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz",
@ -1194,6 +1379,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@ -1240,6 +1439,16 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/bonjour-service": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.1.tgz",
"integrity": "sha512-9KM4QMPKnaJqaja1v7gYO/+TXZGLtzPA05NmUTqDAJjcsWeVoOXKMvU9g0gfuuoYTQqJZ924hivICd5R/bCJbA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"multicast-dns": "^7.2.5"
}
},
"node_modules/bundle-require": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz",
@ -1265,6 +1474,15 @@
"node": ">= 0.8"
}
},
"node_modules/bytestreamjs": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz",
"integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@ -1563,6 +1781,18 @@
"node": ">= 0.8"
}
},
"node_modules/dns-packet": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
"integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",
"license": "MIT",
"dependencies": {
"@leichtgewicht/ip-codec": "^2.0.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -2372,6 +2602,19 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multicast-dns": {
"version": "7.2.5",
"resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
"integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==",
"license": "MIT",
"dependencies": {
"dns-packet": "^5.2.2",
"thunky": "^1.0.2"
},
"bin": {
"multicast-dns": "cli.js"
}
},
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@ -2574,6 +2817,23 @@
"pathe": "^2.0.1"
}
},
"node_modules/pkijs": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz",
"integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==",
"license": "BSD-3-Clause",
"dependencies": {
"@noble/hashes": "1.4.0",
"asn1js": "^3.0.6",
"bytestreamjs": "^2.0.1",
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.3",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@ -2659,6 +2919,24 @@
"node": ">= 0.10"
}
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
@ -2736,6 +3014,12 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0"
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@ -2854,6 +3138,19 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/selfsigned": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz",
"integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==",
"license": "MIT",
"dependencies": {
"@peculiar/x509": "^1.14.2",
"pkijs": "^3.3.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
@ -3206,6 +3503,12 @@
"node": ">=0.8"
}
},
"node_modules/thunky": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@ -3293,6 +3596,12 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsup": {
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz",
@ -3366,6 +3675,24 @@
"fsevents": "~2.3.3"
}
},
"node_modules/tsyringe": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
"license": "MIT",
"dependencies": {
"tslib": "^1.9.3"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/tsyringe/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/type-fest": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz",

View file

@ -49,10 +49,12 @@
"homepage": "https://github.com/getagentseal/codeburn#readme",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"bonjour-service": "^1.4.1",
"chalk": "^5.4.1",
"commander": "^13.1.0",
"ink": "^7.0.0",
"react": "^19.2.5",
"selfsigned": "^5.5.0",
"strip-ansi": "^7.2.0",
"undici": "^7.27.2",
"zod": "^3.25.76"
@ -60,6 +62,7 @@
"devDependencies": {
"@types/node": "^22.19.17",
"@types/react": "^19.2.14",
"@types/selfsigned": "^2.0.4",
"tsup": "^8.4.0",
"tsx": "^4.19.0",
"typescript": "^5.8.0",

View file

@ -15,6 +15,12 @@ import { buildPeriodData, buildMenubarPayloadForRange } from './usage-aggregator
import { renderDashboard } from './dashboard.js'
import { renderOverview } from './overview.js'
import { runWebDashboard } from './web-dashboard.js'
import { hostname } from 'os'
import { runShareServer } from './sharing/share-run.js'
import { addRemote, linkRemote, pullDevices, renderDevices } from './sharing/host.js'
import { browse } from './sharing/discovery.js'
import { promptChoice } from './sharing/prompt.js'
import { loadRemotes, saveRemotes } from './sharing/store.js'
import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js'
import { runOptimize } from './optimize.js'
import { renderCompare } from './compare.js'
@ -478,6 +484,71 @@ program
await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel, daySelection?.day)
})
program
.command('share')
.description("Securely share this device's usage with your other devices on the same network")
.option('--port <number>', 'Port to listen on', parseInteger, 7777)
.option('--pair', 'Open a pairing window and print a PIN to add a new device')
.option('--always', 'Keep sharing until stopped (default stops after 10 min idle)')
.action(async (opts) => {
await runShareServer({ port: opts.port, pair: !!opts.pair, always: !!opts.always })
})
program
.command('devices [action] [target]')
.description('Combined usage across your devices. Actions: add (find nearby & pair) | add <host> --pin <pin> (manual) | rm <name>')
.option('--pin <pin>', 'Pairing PIN shown on the device you are adding')
.option('-p, --period <period>', 'Period: today, week, 30days, month, all', 'month')
.option('--port <number>', 'Default port when adding a device', parseInteger, 7777)
.action(async (action: string | undefined, target: string | undefined, opts) => {
await loadPricing()
if (action === 'add') {
if (target && opts.pin) {
const device = await addRemote(target, opts.pin, { defaultPort: opts.port })
console.log(`\n Paired with "${device.name}" (${device.host}:${device.port}).\n`)
return
}
process.stdout.write('\n Looking for devices on your network...\n')
const found = await browse(3000)
if (found.length === 0) {
console.error(' No devices found. On the other Mac run `codeburn share`, and make sure both are on the same Wi-Fi.\n')
process.exit(1)
}
let chosen = found[0]!
if (found.length > 1) {
found.forEach((d, i) => process.stdout.write(` ${i + 1}) ${d.name} (${d.host})\n`))
const n = await promptChoice(' Connect to which? [number]', found.length)
if (n < 1) {
console.error(' Cancelled.\n')
process.exit(1)
}
chosen = found[n - 1]!
}
const device = await linkRemote(chosen, {
onCode: (code) =>
process.stdout.write(`\n Connecting to "${chosen.name}". Confirm this code on that device: ${code}\n Waiting for approval...\n`),
})
console.log(`\n Paired with "${device.name}".\n`)
return
}
if (action === 'rm' || action === 'remove') {
const remotes = await loadRemotes()
const next = remotes.filter((r) => r.name !== target && `${r.host}:${r.port}` !== target)
await saveRemotes(next)
console.log(`\n Removed ${remotes.length - next.length} device(s).\n`)
return
}
const localGetUsage = async (q: { period?: string; from?: string; to?: string }) => {
const customRange = parseDateRangeFlags(q.from, q.to)
const periodInfo = customRange
? { range: customRange, label: formatDateRangeLabel(q.from, q.to) }
: getDateRange(toPeriod(q.period ?? opts.period))
return buildMenubarPayloadForRange(periodInfo, { provider: 'all', optimize: false })
}
const results = await pullDevices(localGetUsage, { period: opts.period }, hostname(), {})
process.stdout.write('\n' + renderDevices(results))
})
program
.command('overview')
.description('Plain-text usage overview, copy-pasteable (defaults to this month)')

89
src/sharing/client.ts Normal file
View file

@ -0,0 +1,89 @@
import { request } from 'https'
import type { TLSSocket } from 'tls'
import { certFingerprint } from './pairing.js'
import type { Identity } from './identity.js'
import type { UsageQuery } from './share-server.js'
export type PeerEndpoint = {
identity: Identity // our own identity (we present our cert so the peer can bind a token to us)
host: string
port: number
// When set, the connection is aborted unless the peer's cert fingerprint matches.
expectedFingerprint?: string
}
export type Response = { status: number; serverFingerprint: string; json: unknown }
// One request to a peer. Self-signed certs are accepted at the TLS layer
// (rejectUnauthorized:false) but the peer is authenticated by pinning its cert
// fingerprint, the SSH/Syncthing trust-on-first-use model.
function call(
ep: PeerEndpoint,
method: string,
path: string,
headers: Record<string, string> = {},
body?: string,
): Promise<Response> {
return new Promise((resolve, reject) => {
const req = request(
{
host: ep.host,
port: ep.port,
method,
path,
key: ep.identity.key,
cert: ep.identity.cert,
rejectUnauthorized: false,
checkServerIdentity: () => undefined,
headers: { ...headers, ...(body ? { 'content-type': 'application/json' } : {}) },
},
(res) => {
const cert = (res.socket as TLSSocket).getPeerCertificate?.()
const serverFingerprint = cert?.raw ? certFingerprint(cert.raw) : ''
if (ep.expectedFingerprint && serverFingerprint !== ep.expectedFingerprint) {
res.destroy()
reject(new Error('server fingerprint mismatch'))
return
}
let data = ''
res.on('data', (chunk) => {
data += chunk
})
res.on('end', () => resolve({ status: res.statusCode ?? 0, serverFingerprint, json: safeJson(data) }))
},
)
req.on('error', reject)
if (body) req.write(body)
req.end()
})
}
export function hello(ep: PeerEndpoint): Promise<Response> {
return call(ep, 'GET', '/api/peer/hello')
}
export function pair(ep: PeerEndpoint, pin: string, name: string): Promise<Response> {
return call(ep, 'POST', '/api/peer/pair', {}, JSON.stringify({ pin, name }))
}
// Approve-style pairing: no PIN. The peer prompts its user to approve; this
// request stays open until they accept or decline.
export function pairRequest(ep: PeerEndpoint, name: string): Promise<Response> {
return call(ep, 'POST', '/api/peer/pair-request', {}, JSON.stringify({ name }))
}
export function fetchUsage(ep: PeerEndpoint, token: string, query: UsageQuery = {}): Promise<Response> {
const params = new URLSearchParams()
for (const [k, v] of Object.entries(query)) if (v) params.set(k, v)
const qs = params.toString()
return call(ep, 'GET', `/api/usage${qs ? `?${qs}` : ''}`, { authorization: `Bearer ${token}` })
}
function safeJson(s: string): unknown {
try {
return JSON.parse(s)
} catch {
return null
}
}

54
src/sharing/discovery.ts Normal file
View file

@ -0,0 +1,54 @@
import { Bonjour, type Service } from 'bonjour-service'
const SERVICE_TYPE = 'codeburn'
export type DiscoveredDevice = { name: string; host: string; port: number; fingerprint: string }
export type Advertiser = { stop: () => Promise<void> }
// Announce this device on the local network so others can find it without an IP.
export function advertise(opts: { name: string; port: number; fingerprint: string }): Advertiser {
const bonjour = new Bonjour()
bonjour.publish({
name: opts.name,
type: SERVICE_TYPE,
port: opts.port,
txt: { fp: opts.fingerprint, dn: opts.name, v: '1' },
})
return {
stop: () =>
new Promise<void>((resolve) => {
bonjour.unpublishAll(() => bonjour.destroy(() => resolve()))
}),
}
}
function pickAddress(service: Service): string | null {
const addrs = service.addresses ?? []
const ipv4 = addrs.find((a) => /^\d+\.\d+\.\d+\.\d+$/.test(a))
if (ipv4) return ipv4
if (service.host) return service.host
return addrs[0] ?? null
}
// Browse the local network for sharing devices for `timeoutMs`. Resolves to the
// devices found, deduped by fingerprint.
export function browse(timeoutMs = 2500): Promise<DiscoveredDevice[]> {
return new Promise((resolve) => {
const bonjour = new Bonjour()
const found = new Map<string, DiscoveredDevice>()
const browser = bonjour.find({ type: SERVICE_TYPE }, (service) => {
const txt = (service.txt ?? {}) as Record<string, string>
const fingerprint = txt['fp']
const address = pickAddress(service)
if (!fingerprint || !address) return
const name = txt['dn'] || service.name || address
found.set(fingerprint, { name, host: address, port: service.port, fingerprint })
})
const timer = setTimeout(() => {
browser.stop()
bonjour.destroy(() => resolve([...found.values()]))
}, timeoutMs)
timer.unref?.()
})
}

137
src/sharing/host.ts Normal file
View file

@ -0,0 +1,137 @@
import { hello, pair, pairRequest, fetchUsage } from './client.js'
import { loadOrCreateIdentity } from './identity.js'
import { pairingCode } from './pairing.js'
import type { DiscoveredDevice } from './discovery.js'
import type { UsageQuery } from './share-server.js'
import { getSharingDir, loadRemotes, saveRemotes, type RemoteDevice } from './store.js'
import { formatCost } from '../currency.js'
import { formatTokens } from '../format.js'
// Minimal shape we read from a device's usage payload (the menubar payload).
type DevicePayload = {
current?: { cost?: number; calls?: number; sessions?: number; inputTokens?: number; outputTokens?: number }
}
export type DeviceUsage = {
name: string
local: boolean
payload?: DevicePayload
error?: string
}
function parseHostPort(input: string, defaultPort: number): { host: string; port: number } {
const idx = input.lastIndexOf(':')
if (idx > 0 && /^\d+$/.test(input.slice(idx + 1))) {
return { host: input.slice(0, idx), port: Number(input.slice(idx + 1)) }
}
return { host: input, port: defaultPort }
}
// Pair with a device the user is currently sharing (PIN shown on that device),
// pin its fingerprint, store the issued token, and persist it.
export async function addRemote(
input: string,
pin: string,
opts: { defaultPort: number; dir?: string },
): Promise<RemoteDevice> {
const dir = opts.dir ?? getSharingDir()
const identity = await loadOrCreateIdentity(dir)
const { host, port } = parseHostPort(input, opts.defaultPort)
const h = await hello({ identity, host, port })
if (h.status !== 200) throw new Error(`could not reach a CodeBurn device at ${host}:${port}`)
const info = h.json as { fingerprint: string; name: string }
const pr = await pair({ identity, host, port, expectedFingerprint: info.fingerprint }, pin, identity.name)
if (pr.status !== 200) {
const err = (pr.json as { error?: string })?.error ?? `HTTP ${pr.status}`
throw new Error(`pairing failed: ${err}`)
}
const token = (pr.json as { token: string }).token
const device: RemoteDevice = { name: info.name, host, port, fingerprint: info.fingerprint, token, addedAt: Date.now() }
const remotes = (await loadRemotes(dir)).filter((r) => r.fingerprint !== device.fingerprint)
remotes.push(device)
await saveRemotes(remotes, dir)
return device
}
// Pair with a discovered device using approve-style pairing (no PIN). The owner
// of that device approves on their screen after confirming the matching code.
export async function linkRemote(
d: DiscoveredDevice,
opts: { dir?: string; onCode?: (code: string) => void } = {},
): Promise<RemoteDevice> {
const dir = opts.dir ?? getSharingDir()
const identity = await loadOrCreateIdentity(dir)
const code = pairingCode(identity.fingerprint, d.fingerprint)
opts.onCode?.(code)
const r = await pairRequest({ identity, host: d.host, port: d.port, expectedFingerprint: d.fingerprint }, identity.name)
if (r.status !== 200) {
throw new Error(r.status === 403 ? 'the other device declined' : `pairing failed (HTTP ${r.status})`)
}
const token = (r.json as { token: string }).token
const device: RemoteDevice = { name: d.name, host: d.host, port: d.port, fingerprint: d.fingerprint, token, addedAt: Date.now() }
const remotes = (await loadRemotes(dir)).filter((x) => x.fingerprint !== device.fingerprint)
remotes.push(device)
await saveRemotes(remotes, dir)
return device
}
// Pull this machine's usage plus every paired remote's, each kept separate.
export async function pullDevices(
localGetUsage: (q: UsageQuery) => Promise<DevicePayload>,
query: UsageQuery,
localName: string,
opts: { dir?: string } = {},
): Promise<DeviceUsage[]> {
const dir = opts.dir ?? getSharingDir()
const identity = await loadOrCreateIdentity(dir)
const remotes = await loadRemotes(dir)
const results: DeviceUsage[] = [{ name: localName, local: true, payload: await localGetUsage(query) }]
for (const r of remotes) {
try {
const res = await fetchUsage({ identity, host: r.host, port: r.port, expectedFingerprint: r.fingerprint }, r.token, query)
if (res.status === 200) results.push({ name: r.name, local: false, payload: res.json as DevicePayload })
else results.push({ name: r.name, local: false, error: res.status === 401 ? 'not authorized (re-pair?)' : `HTTP ${res.status}` })
} catch (e) {
results.push({ name: r.name, local: false, error: e instanceof Error ? e.message : String(e) })
}
}
return results
}
export function renderDevices(results: DeviceUsage[]): string {
const num = (n: number | undefined): number => n ?? 0
const rows = results.map((d) => {
const c = d.payload?.current
return {
name: d.name + (d.local ? ' (this Mac)' : ''),
cost: num(c?.cost),
tokens: num(c?.inputTokens) + num(c?.outputTokens),
calls: num(c?.calls),
sessions: num(c?.sessions),
error: d.error,
}
})
const combined = rows.reduce(
(a, r) => ({ cost: a.cost + r.cost, tokens: a.tokens + r.tokens, calls: a.calls + r.calls, sessions: a.sessions + r.sessions }),
{ cost: 0, tokens: 0, calls: 0, sessions: 0 },
)
const nameW = Math.max(8, ...rows.map((r) => r.name.length), 'Combined'.length)
const line = (name: string, cost: string, tokens: string, calls: string): string =>
` ${name.padEnd(nameW)} ${cost.padStart(11)} ${tokens.padStart(9)} ${calls.padStart(8)}`
const out: string[] = []
out.push(line('Device', 'Cost', 'Tokens', 'Calls'))
out.push(' ' + '-'.repeat(nameW + 11 + 9 + 8 + 6))
for (const r of rows) {
if (r.error) out.push(line(r.name, '-', '-', r.error))
else out.push(line(r.name, formatCost(r.cost), formatTokens(r.tokens), r.calls.toLocaleString()))
}
out.push(' ' + '-'.repeat(nameW + 11 + 9 + 8 + 6))
out.push(line('Combined', formatCost(combined.cost), formatTokens(combined.tokens), combined.calls.toLocaleString()))
return out.join('\n') + '\n'
}

58
src/sharing/identity.ts Normal file
View file

@ -0,0 +1,58 @@
import * as selfsigned from 'selfsigned'
import { X509Certificate } from 'crypto'
import { readFile, writeFile, mkdir } from 'fs/promises'
import { existsSync } from 'fs'
import { join } from 'path'
import { hostname } from 'os'
import { certFingerprint } from './pairing.js'
// A device's stable identity: a self-signed TLS keypair whose certificate
// fingerprint is the trust anchor (trust-on-first-use). No CA.
export type Identity = {
key: string // private key PEM
cert: string // certificate PEM
fingerprint: string // SHA-256 hex of the certificate DER
name: string // human label (defaults to the hostname)
}
export async function generateIdentity(name: string = hostname()): Promise<Identity> {
const attrs = [{ name: 'commonName', value: 'codeburn-device' }]
// @types/selfsigned is missing `days`; the runtime accepts it. selfsigned >=5
// resolves a Promise of { private, public, cert, fingerprint }.
const genOpts = { days: 3650, keySize: 2048, algorithm: 'sha256' } as unknown as Parameters<
typeof selfsigned.generate
>[1]
const pems = (await (selfsigned.generate(attrs, genOpts) as unknown as Promise<{ private: string; cert: string }>))
const der = new X509Certificate(pems.cert).raw
return { key: pems.private, cert: pems.cert, fingerprint: certFingerprint(der), name }
}
// Load the device identity from `dir`, creating and persisting it on first run.
export async function loadOrCreateIdentity(dir: string, name?: string): Promise<Identity> {
const keyPath = join(dir, 'device-key.pem')
const certPath = join(dir, 'device-cert.pem')
const namePath = join(dir, 'device-name')
if (existsSync(keyPath) && existsSync(certPath)) {
const [key, cert] = await Promise.all([readFile(keyPath, 'utf8'), readFile(certPath, 'utf8')])
let resolvedName = name ?? hostname()
try {
const stored = (await readFile(namePath, 'utf8')).trim()
if (stored) resolvedName = name ?? stored
} catch {
/* no stored name yet */
}
const der = new X509Certificate(cert).raw
return { key, cert, fingerprint: certFingerprint(der), name: resolvedName }
}
const id = await generateIdentity(name)
await mkdir(dir, { recursive: true })
await Promise.all([
writeFile(keyPath, id.key, { mode: 0o600 }),
writeFile(certPath, id.cert),
writeFile(namePath, id.name),
])
return id
}

105
src/sharing/pairing.ts Normal file
View file

@ -0,0 +1,105 @@
import { randomBytes, createHash, timingSafeEqual } from 'crypto'
// Device identity is the SHA-256 of its self-signed TLS certificate
// (trust-on-first-use, like SSH/Syncthing). No certificate authority involved:
// once two devices have each other's fingerprint, that pin is the trust anchor.
export function certFingerprint(cert: Buffer | string): string {
const buf = typeof cert === 'string' ? Buffer.from(cert) : cert
return createHash('sha256').update(buf).digest('hex')
}
// Short, human-typed pairing PIN: 6 uniform digits. Rejection-sampled so the
// distribution is even (no modulo bias across 0..999999).
export function generatePin(): string {
const limit = Math.floor(0xffffffff / 1_000_000) * 1_000_000
let n = randomBytes(4).readUInt32BE(0)
while (n >= limit) n = randomBytes(4).readUInt32BE(0)
return (n % 1_000_000).toString().padStart(6, '0')
}
export function constantTimeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a)
const bb = Buffer.from(b)
if (ab.length !== bb.length) return false
return timingSafeEqual(ab, bb)
}
export function mintToken(): string {
return randomBytes(32).toString('base64url')
}
// Short confirmation code shown on BOTH devices during an approve-style pairing.
// Derived from the two cert fingerprints, so a man-in-the-middle (whose cert
// differs) yields a different code; the user confirms the codes match. This is
// the Bluetooth/SAS "do these numbers match?" check, not a secret.
export function pairingCode(fingerprintA: string, fingerprintB: string): string {
const [lo, hi] = [fingerprintA, fingerprintB].sort()
const digest = createHash('sha256').update(`${lo}|${hi}`).digest()
return (digest.readUInt16BE(0) % 1000).toString().padStart(3, '0')
}
// An open pairing window on the device being added: a one-time PIN that expires.
// `now` is injectable so the lifecycle is deterministic in tests.
export class PairingWindow {
readonly pin: string
readonly openedAt: number
private used = false
constructor(ttlMs = 60_000, now: number = Date.now(), pin: string = generatePin()) {
this.ttlMs = ttlMs
this.pin = pin
this.openedAt = now
}
private readonly ttlMs: number
isOpen(now: number = Date.now()): boolean {
return !this.used && now - this.openedAt <= this.ttlMs
}
// Verify a submitted PIN. A correct match consumes the window (one-time use).
verify(pin: string, now: number = Date.now()): boolean {
if (!this.isOpen(now)) return false
if (!constantTimeEqual(pin, this.pin)) return false
this.used = true
return true
}
}
export type PairedPeer = {
fingerprint: string
name: string
token: string
pairedAt: number
}
// The devices this device trusts. A pull is authorized only when BOTH the
// bearer token AND the TLS peer fingerprint match the same paired peer, so a
// token stolen and replayed from a different device is useless on its own.
export class PeerStore {
private byFingerprint = new Map<string, PairedPeer>()
constructor(peers: PairedPeer[] = []) {
for (const p of peers) this.byFingerprint.set(p.fingerprint, p)
}
list(): PairedPeer[] {
return [...this.byFingerprint.values()]
}
pair(fingerprint: string, name: string, now: number = Date.now()): PairedPeer {
const peer: PairedPeer = { fingerprint, name, token: mintToken(), pairedAt: now }
this.byFingerprint.set(fingerprint, peer)
return peer
}
authorize(token: string, fingerprint: string): boolean {
const peer = this.byFingerprint.get(fingerprint)
if (!peer) return false
return constantTimeEqual(token, peer.token)
}
unpair(fingerprint: string): boolean {
return this.byFingerprint.delete(fingerprint)
}
}

30
src/sharing/prompt.ts Normal file
View file

@ -0,0 +1,30 @@
import { createInterface } from 'readline'
export function promptYesNo(question: string, timeoutMs?: number): Promise<boolean> {
const rl = createInterface({ input: process.stdin, output: process.stdout })
return new Promise((resolve) => {
let settled = false
const finish = (value: boolean): void => {
if (settled) return
settled = true
rl.close()
resolve(value)
}
if (timeoutMs) {
const t = setTimeout(() => finish(false), timeoutMs)
t.unref?.()
}
rl.question(`${question} [Y/n] `, (answer) => finish(!/^\s*n/i.test(answer)))
})
}
export function promptChoice(question: string, max: number): Promise<number> {
const rl = createInterface({ input: process.stdin, output: process.stdout })
return new Promise((resolve) => {
rl.question(`${question} `, (answer) => {
rl.close()
const n = Number.parseInt(answer.trim(), 10)
resolve(Number.isInteger(n) && n >= 1 && n <= max ? n : -1)
})
})
}

16
src/sharing/sanitize.ts Normal file
View file

@ -0,0 +1,16 @@
import type { MenubarPayload } from '../menubar-json.js'
// Strip identifying detail before usage leaves the device. We share aggregate
// numbers (cost, tokens, models, tools, activities, daily) but never project
// names, paths, or per-session detail, so "what you are working on" stays on
// the machine that produced it. Only the totals travel.
export function sanitizeForSharing(payload: MenubarPayload): MenubarPayload {
return {
...payload,
current: {
...payload.current,
topProjects: [],
topSessions: [],
},
}
}

95
src/sharing/share-run.ts Normal file
View file

@ -0,0 +1,95 @@
import { networkInterfaces } from 'os'
import { loadOrCreateIdentity } from './identity.js'
import { PeerStore } from './pairing.js'
import { ShareServer, type UsageQuery } from './share-server.js'
import { advertise } from './discovery.js'
import { promptYesNo } from './prompt.js'
import { sanitizeForSharing } from './sanitize.js'
import { getSharingDir, loadPeers, savePeers } from './store.js'
import { loadPricing } from '../models.js'
import { buildMenubarPayloadForRange } from '../usage-aggregator.js'
import { getDateRange, parseDateRangeFlags, formatDateRangeLabel, toPeriod } from '../cli-date.js'
function lanAddress(): string | null {
for (const list of Object.values(networkInterfaces())) {
for (const ni of list ?? []) {
if (ni.family === 'IPv4' && !ni.internal) return ni.address
}
}
return null
}
const IDLE_TIMEOUT_MS = 10 * 60_000
// Run the secure share server. On-demand by default: it stops after 10 minutes
// of no requests. `--always` keeps it up until Ctrl+C (the opt-in persistent
// mode). `--pair` opens a one-time pairing window and prints the PIN + command.
export async function runShareServer(opts: { port: number; pair: boolean; always: boolean }): Promise<void> {
await loadPricing()
const dir = getSharingDir()
const identity = await loadOrCreateIdentity(dir)
const peers = new PeerStore(await loadPeers(dir))
const getUsage = async (q: UsageQuery): Promise<unknown> => {
const customRange = parseDateRangeFlags(q.from, q.to)
const periodInfo = customRange
? { range: customRange, label: formatDateRangeLabel(q.from, q.to) }
: getDateRange(toPeriod(q.period ?? 'month'))
return sanitizeForSharing(await buildMenubarPayloadForRange(periodInfo, { provider: 'all', optimize: false }))
}
const server = new ShareServer({
identity,
peers,
getUsage,
onPaired: () => {
void savePeers(peers.list(), dir)
},
approve: async (req) => {
process.stdout.write(`\n "${req.name}" wants your usage.\n`)
process.stdout.write(` Confirm this code matches on that device: ${req.code}\n`)
const ok = await promptYesNo(' Approve?', 60_000)
process.stdout.write(ok ? ` Approved "${req.name}".\n\n` : ` Declined "${req.name}".\n\n`)
return ok
},
})
const port = await server.listen(opts.port, '0.0.0.0')
const ip = lanAddress() ?? '127.0.0.1'
const ad = advertise({ name: identity.name, port, fingerprint: identity.fingerprint })
const shutdown = async (): Promise<void> => {
await ad.stop().catch(() => {})
await server.close().catch(() => {})
process.exit(0)
}
process.on('SIGINT', () => void shutdown())
process.stdout.write(`\n Sharing "${identity.name}" - discoverable on your network.\n`)
process.stdout.write(` On your other Mac, run: codeburn devices add\n`)
if (opts.pair) {
const pin = server.openPairing(120_000)
process.stdout.write(`\n Manual fallback (if discovery is blocked):\n`)
process.stdout.write(` codeburn devices add ${ip}:${port} --pin ${pin}\n`)
}
process.stdout.write(`\n ${peers.list().length} paired device(s). Press Ctrl+C to stop.\n\n`)
if (!opts.always) {
let last = Date.now()
server.server.on('request', () => {
last = Date.now()
})
const timer = setInterval(() => {
if (Date.now() - last > IDLE_TIMEOUT_MS) {
process.stdout.write('\n Idle, stopping share. Run `codeburn share` again when you need it.\n')
process.exit(0)
}
}, 30_000)
timer.unref()
}
await new Promise<never>(() => {
/* run until interrupted */
})
}

169
src/sharing/share-server.ts Normal file
View file

@ -0,0 +1,169 @@
import { createServer, type Server } from 'https'
import type { IncomingMessage, ServerResponse } from 'http'
import type { TLSSocket } from 'tls'
import type { AddressInfo } from 'net'
import { certFingerprint, pairingCode, PeerStore, PairingWindow } from './pairing.js'
import type { Identity } from './identity.js'
export type UsageQuery = { period?: string; from?: string; to?: string }
// An approve-style pairing request, surfaced to the user on the sharing device.
export type PairRequest = { name: string; fingerprint: string; code: string }
export type ShareServerOptions = {
identity: Identity
peers: PeerStore
getUsage: (query: UsageQuery) => Promise<unknown>
// Called after a successful pairing so the caller can persist the peer list.
onPaired?: () => void
// Enables the interactive approve flow (POST /api/peer/pair-request): return
// true to accept. The user confirms the matching `code` shown on both devices.
approve?: (req: PairRequest) => Promise<boolean>
}
// A device's HTTPS sharing endpoint. Mutual TLS: the server presents its own
// self-signed cert (clients pin its fingerprint) and requests the client's cert
// so it can bind tokens to the caller's fingerprint. A pull is served only when
// the bearer token AND the client cert fingerprint match the same paired peer.
export class ShareServer {
readonly server: Server
private pairing: PairingWindow | null = null
constructor(private readonly opts: ShareServerOptions) {
this.server = createServer(
{ key: opts.identity.key, cert: opts.identity.cert, requestCert: true, rejectUnauthorized: false },
(req, res) => {
void this.handle(req, res)
},
)
}
// Open a one-time pairing window and return the PIN to show the user.
openPairing(ttlMs = 60_000): string {
this.pairing = new PairingWindow(ttlMs)
return this.pairing.pin
}
closePairing(): void {
this.pairing = null
}
listen(port: number, host = '0.0.0.0'): Promise<number> {
return new Promise((resolve, reject) => {
this.server.once('error', reject)
this.server.listen(port, host, () => resolve((this.server.address() as AddressInfo).port))
})
}
close(): Promise<void> {
return new Promise((resolve) => this.server.close(() => resolve()))
}
private clientFingerprint(req: IncomingMessage): string | null {
const cert = (req.socket as TLSSocket).getPeerCertificate?.()
if (!cert || !cert.raw) return null
return certFingerprint(cert.raw)
}
private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
const url = new URL(req.url ?? '/', 'https://localhost')
const json = (code: number, body: unknown): void => {
res.writeHead(code, { 'content-type': 'application/json' })
res.end(JSON.stringify(body))
}
// Unauthenticated: just enough for a joiner to learn who this is and whether
// pairing is currently open. No usage data here.
if (url.pathname === '/api/peer/hello' && req.method === 'GET') {
json(200, {
fingerprint: this.opts.identity.fingerprint,
name: this.opts.identity.name,
pairingOpen: !!this.pairing?.isOpen(),
})
return
}
if (url.pathname === '/api/peer/pair' && req.method === 'POST') {
const clientFp = this.clientFingerprint(req)
if (!clientFp) {
json(400, { error: 'client certificate required' })
return
}
const body = safeJson(await readBody(req)) as { pin?: unknown; name?: unknown } | null
const pin = typeof body?.pin === 'string' ? body.pin : ''
const name = typeof body?.name === 'string' ? body.name : 'device'
if (!this.pairing || !this.pairing.verify(pin)) {
json(401, { error: 'invalid or expired PIN' })
return
}
this.pairing = null
const peer = this.opts.peers.pair(clientFp, name)
this.opts.onPaired?.()
json(200, { token: peer.token, name: this.opts.identity.name, fingerprint: this.opts.identity.fingerprint })
return
}
if (url.pathname === '/api/peer/pair-request' && req.method === 'POST') {
const clientFp = this.clientFingerprint(req)
if (!clientFp) {
json(400, { error: 'client certificate required' })
return
}
if (!this.opts.approve) {
json(403, { error: 'this device is not accepting new pairings' })
return
}
const body = safeJson(await readBody(req)) as { name?: unknown } | null
const name = typeof body?.name === 'string' ? body.name : 'device'
const code = pairingCode(this.opts.identity.fingerprint, clientFp)
const approved = await this.opts.approve({ name, fingerprint: clientFp, code })
if (!approved) {
json(403, { error: 'pairing declined' })
return
}
const peer = this.opts.peers.pair(clientFp, name)
this.opts.onPaired?.()
json(200, { token: peer.token, name: this.opts.identity.name, fingerprint: this.opts.identity.fingerprint, code })
return
}
if (url.pathname === '/api/usage' && req.method === 'GET') {
const clientFp = this.clientFingerprint(req)
const token = (req.headers['authorization'] ?? '').replace(/^Bearer\s+/i, '')
if (!clientFp || !token || !this.opts.peers.authorize(token, clientFp)) {
json(401, { error: 'unauthorized' })
return
}
const payload = await this.opts.getUsage({
period: url.searchParams.get('period') ?? undefined,
from: url.searchParams.get('from') ?? undefined,
to: url.searchParams.get('to') ?? undefined,
})
json(200, payload)
return
}
json(404, { error: 'not found' })
}
}
function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve) => {
let data = ''
req.on('data', (chunk) => {
data += chunk
if (data.length > 1_000_000) req.destroy() // guard against oversized bodies
})
req.on('end', () => resolve(data))
req.on('error', () => resolve(data))
})
}
function safeJson(s: string): unknown {
try {
return JSON.parse(s)
} catch {
return null
}
}

50
src/sharing/store.ts Normal file
View file

@ -0,0 +1,50 @@
import { readFile, writeFile, mkdir } from 'fs/promises'
import { join, dirname } from 'path'
import { getConfigFilePath } from '../config.js'
import type { PairedPeer } from './pairing.js'
// A device this host can pull FROM: its address, the pinned server-cert
// fingerprint, and the token issued to us during pairing.
export type RemoteDevice = {
name: string
host: string
port: number
fingerprint: string
token: string
addedAt: number
}
// Sharing state lives next to the main config file.
export function getSharingDir(): string {
return join(dirname(getConfigFilePath()), 'sharing')
}
async function readJson<T>(path: string, fallback: T): Promise<T> {
try {
return JSON.parse(await readFile(path, 'utf8')) as T
} catch {
return fallback
}
}
async function writeJson(path: string, data: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true })
await writeFile(path, JSON.stringify(data, null, 2))
}
// Peers allowed to pull from this device (the sharing side, used by ShareServer).
export function loadPeers(dir: string = getSharingDir()): Promise<PairedPeer[]> {
return readJson(join(dir, 'paired-peers.json'), [] as PairedPeer[])
}
export function savePeers(peers: PairedPeer[], dir: string = getSharingDir()): Promise<void> {
return writeJson(join(dir, 'paired-peers.json'), peers)
}
// Devices this host pulls from (the host side, used by `codeburn devices`).
export function loadRemotes(dir: string = getSharingDir()): Promise<RemoteDevice[]> {
return readJson(join(dir, 'remote-devices.json'), [] as RemoteDevice[])
}
export function saveRemotes(remotes: RemoteDevice[], dir: string = getSharingDir()): Promise<void> {
return writeJson(join(dir, 'remote-devices.json'), remotes)
}

View file

@ -0,0 +1,62 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { generateIdentity, type Identity } from '../../src/sharing/identity.js'
import { PeerStore, pairingCode } from '../../src/sharing/pairing.js'
import { ShareServer } from '../../src/sharing/share-server.js'
import { pairRequest, fetchUsage } from '../../src/sharing/client.js'
describe('pairingCode', () => {
it('is order-independent, deterministic, and 3 digits', () => {
expect(pairingCode('aaa', 'bbb')).toBe(pairingCode('bbb', 'aaa'))
expect(pairingCode('aaa', 'bbb')).toMatch(/^\d{3}$/)
expect(pairingCode('aaa', 'bbb')).toBe(pairingCode('aaa', 'bbb'))
})
})
describe('approve-style pairing (no PIN)', () => {
let server: ShareServer
let serverId: Identity
let clientId: Identity
let port: number
let seenCode = ''
beforeAll(async () => {
serverId = await generateIdentity('MacBook')
clientId = await generateIdentity('Mac Studio')
server = new ShareServer({
identity: serverId,
peers: new PeerStore(),
getUsage: async () => ({ current: { cost: 7 } }),
approve: async (req) => {
seenCode = req.code
return req.name !== 'Intruder'
},
})
port = await server.listen(0, '127.0.0.1')
})
afterAll(async () => {
await server.close()
})
const ep = () => ({ identity: clientId, host: '127.0.0.1', port, expectedFingerprint: serverId.fingerprint })
it('accepts an approved device, with the same code on both sides, and the token works', async () => {
const r = await pairRequest(ep(), 'Mac Studio')
expect(r.status).toBe(200)
const body = r.json as { token: string; code: string }
expect(body.token).toBeTruthy()
// Both ends derive the same confirmation code from the two fingerprints.
expect(body.code).toBe(pairingCode(serverId.fingerprint, clientId.fingerprint))
expect(seenCode).toBe(body.code)
const usage = await fetchUsage(ep(), body.token)
expect(usage.status).toBe(200)
expect((usage.json as { current: { cost: number } }).current.cost).toBe(7)
})
it('rejects a declined device', async () => {
const r = await pairRequest(ep(), 'Intruder')
expect(r.status).toBe(403)
})
})

View file

@ -0,0 +1,61 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { mkdtemp, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { generateIdentity } from '../../src/sharing/identity.js'
import { PeerStore } from '../../src/sharing/pairing.js'
import { ShareServer } from '../../src/sharing/share-server.js'
import { addRemote, pullDevices, renderDevices, type DeviceUsage } from '../../src/sharing/host.js'
describe('host device flow (loopback)', () => {
let server: ShareServer
let port: number
let dir: string
const remoteUsage = { current: { cost: 100, calls: 10, sessions: 2, inputTokens: 1000, outputTokens: 200 } }
beforeAll(async () => {
dir = await mkdtemp(join(tmpdir(), 'cb-host-'))
const serverId = await generateIdentity('MacBook')
server = new ShareServer({ identity: serverId, peers: new PeerStore(), getUsage: async () => remoteUsage })
port = await server.listen(0, '127.0.0.1')
})
afterAll(async () => {
await server.close()
await rm(dir, { recursive: true, force: true })
})
it('pairs, persists, pulls both devices, and combines', async () => {
const pin = server.openPairing()
const device = await addRemote(`127.0.0.1:${port}`, pin, { defaultPort: port, dir })
expect(device.name).toBe('MacBook')
expect(device.token).toBeTruthy()
const localUsage = { current: { cost: 50, calls: 5, sessions: 1, inputTokens: 500, outputTokens: 100 } }
const results = await pullDevices(async () => localUsage, { period: 'month' }, 'Mac Studio', { dir })
expect(results).toHaveLength(2)
expect(results[0]!.local).toBe(true)
expect(results[0]!.payload!.current!.cost).toBe(50)
const remote = results.find((r) => !r.local)!
expect(remote.name).toBe('MacBook')
expect(remote.payload!.current!.cost).toBe(100)
const text = renderDevices(results)
expect(text).toContain('Mac Studio (this Mac)')
expect(text).toContain('MacBook')
expect(text).toContain('Combined')
expect(text).toContain('150') // combined cost 50 + 100
})
it('renders an unreachable device as an error without dropping the combined row', () => {
const results: DeviceUsage[] = [
{ name: 'Mac Studio', local: true, payload: { current: { cost: 10, calls: 1, sessions: 1, inputTokens: 1, outputTokens: 1 } } },
{ name: 'MacBook', local: false, error: 'connection refused' },
]
const text = renderDevices(results)
expect(text).toContain('connection refused')
expect(text).toContain('Combined')
})
})

View file

@ -0,0 +1,104 @@
import { describe, it, expect } from 'vitest'
import {
certFingerprint,
generatePin,
constantTimeEqual,
mintToken,
PairingWindow,
PeerStore,
} from '../../src/sharing/pairing.js'
describe('certFingerprint', () => {
it('is a deterministic 64-char hex digest', () => {
const fp = certFingerprint('cert-bytes')
expect(fp).toMatch(/^[0-9a-f]{64}$/)
expect(certFingerprint('cert-bytes')).toBe(fp)
})
it('differs for different certs', () => {
expect(certFingerprint('a')).not.toBe(certFingerprint('b'))
})
})
describe('generatePin', () => {
it('is always 6 digits', () => {
for (let i = 0; i < 200; i++) expect(generatePin()).toMatch(/^\d{6}$/)
})
it('varies', () => {
const pins = new Set(Array.from({ length: 50 }, () => generatePin()))
expect(pins.size).toBeGreaterThan(1)
})
})
describe('constantTimeEqual', () => {
it('matches equal strings and rejects different ones', () => {
expect(constantTimeEqual('abc', 'abc')).toBe(true)
expect(constantTimeEqual('abc', 'abd')).toBe(false)
expect(constantTimeEqual('abc', 'abcd')).toBe(false)
})
})
describe('mintToken', () => {
it('is url-safe and unique', () => {
const a = mintToken()
const b = mintToken()
expect(a).toMatch(/^[A-Za-z0-9_-]+$/)
expect(a).not.toBe(b)
})
})
describe('PairingWindow', () => {
it('accepts the correct PIN within the window', () => {
const w = new PairingWindow(1000, 1000, '123456')
expect(w.verify('123456', 1500)).toBe(true)
})
it('rejects a wrong PIN', () => {
const w = new PairingWindow(1000, 1000, '123456')
expect(w.verify('000000', 1200)).toBe(false)
})
it('rejects after the window expires', () => {
const w = new PairingWindow(1000, 1000, '123456')
expect(w.isOpen(3000)).toBe(false)
expect(w.verify('123456', 3000)).toBe(false)
})
it('is one-time: a consumed PIN cannot be reused', () => {
const w = new PairingWindow(10_000, 1000, '123456')
expect(w.verify('123456', 1100)).toBe(true)
expect(w.verify('123456', 1200)).toBe(false)
})
})
describe('PeerStore', () => {
it('authorizes only when token AND fingerprint both match the same peer', () => {
const store = new PeerStore()
const a = store.pair('fp-aaa', 'MacBook')
const b = store.pair('fp-bbb', 'Mac Studio')
// correct pairing
expect(store.authorize(a.token, 'fp-aaa')).toBe(true)
// right token, wrong device fingerprint -> denied (stolen-token defense)
expect(store.authorize(a.token, 'fp-bbb')).toBe(false)
// wrong token on the right device -> denied
expect(store.authorize('not-the-token', 'fp-aaa')).toBe(false)
// unknown device -> denied
expect(store.authorize(a.token, 'fp-ccc')).toBe(false)
expect(store.authorize(b.token, 'fp-bbb')).toBe(true)
})
it('revokes a peer on unpair', () => {
const store = new PeerStore()
const p = store.pair('fp-x', 'Laptop')
expect(store.authorize(p.token, 'fp-x')).toBe(true)
expect(store.unpair('fp-x')).toBe(true)
expect(store.authorize(p.token, 'fp-x')).toBe(false)
expect(store.list()).toHaveLength(0)
})
it('round-trips through serializable peer records', () => {
const store = new PeerStore()
store.pair('fp-1', 'A')
const restored = new PeerStore(store.list())
const peer = restored.list()[0]!
expect(restored.authorize(peer.token, 'fp-1')).toBe(true)
})
})

View file

@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { sanitizeForSharing } from '../../src/sharing/sanitize.js'
import type { MenubarPayload } from '../../src/menubar-json.js'
function fixture(): MenubarPayload {
return {
generated: 'now',
current: {
label: 'June',
cost: 100,
calls: 5,
sessions: 2,
oneShotRate: 1,
inputTokens: 10,
outputTokens: 20,
cacheHitPercent: 90,
codexCredits: 0,
topActivities: [{ name: 'Coding', cost: 50, savingsUSD: 0, turns: 3, oneShotRate: 1 }],
topModels: [{ name: 'Opus', cost: 80, savingsUSD: 0, savingsBaselineModel: '', calls: 4 }],
providers: { claude: 100 },
topProjects: [
{ name: 'secret-project', cost: 100, savingsUSD: 0, sessions: 2, avgCostPerSession: 50, sessionDetails: [] },
],
tools: [{ name: 'Bash', calls: 9 }],
topSessions: [{ project: 'secret-project', cost: 100, savingsUSD: 0, calls: 5, date: '2026-06-01' }],
},
history: { daily: [] },
} as unknown as MenubarPayload
}
describe('sanitizeForSharing', () => {
it('strips project names and session detail but keeps aggregates', () => {
const clean = sanitizeForSharing(fixture())
expect(clean.current.topProjects).toEqual([])
expect(clean.current.topSessions).toEqual([])
expect(clean.current.cost).toBe(100)
expect(clean.current.topModels[0]!.name).toBe('Opus')
expect(clean.current.providers).toEqual({ claude: 100 })
})
it('leaks no project name anywhere in the shared payload', () => {
const clean = sanitizeForSharing(fixture())
expect(JSON.stringify(clean)).not.toContain('secret-project')
})
})

View file

@ -0,0 +1,73 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { generateIdentity, type Identity } from '../../src/sharing/identity.js'
import { PeerStore } from '../../src/sharing/pairing.js'
import { ShareServer } from '../../src/sharing/share-server.js'
import { hello, pair, fetchUsage } from '../../src/sharing/client.js'
describe('device sharing transport (loopback mutual TLS)', () => {
let server: ShareServer
let serverId: Identity
let clientId: Identity
let peers: PeerStore
let port: number
beforeAll(async () => {
serverId = await generateIdentity('MacBook')
clientId = await generateIdentity('Mac Studio')
peers = new PeerStore()
server = new ShareServer({ identity: serverId, peers, getUsage: async () => ({ current: { cost: 42 } }) })
port = await server.listen(0, '127.0.0.1')
})
afterAll(async () => {
await server.close()
})
const ep = () => ({ identity: clientId, host: '127.0.0.1', port })
it('hello exposes name + fingerprint, and the client sees the right cert', async () => {
const r = await hello(ep())
expect(r.status).toBe(200)
const body = r.json as { name: string; fingerprint: string }
expect(body.name).toBe('MacBook')
expect(body.fingerprint).toBe(serverId.fingerprint)
expect(r.serverFingerprint).toBe(serverId.fingerprint)
})
it('denies usage before pairing', async () => {
const r = await fetchUsage(ep(), 'no-token')
expect(r.status).toBe(401)
})
it('pairs with a valid PIN, then authorizes a pinned usage pull', async () => {
const pin = server.openPairing()
const pr = await pair(ep(), pin, 'Mac Studio')
expect(pr.status).toBe(200)
const token = (pr.json as { token: string }).token
expect(token).toBeTruthy()
const ur = await fetchUsage({ ...ep(), expectedFingerprint: serverId.fingerprint }, token)
expect(ur.status).toBe(200)
expect((ur.json as { current: { cost: number } }).current.cost).toBe(42)
})
it('rejects a wrong PIN', async () => {
server.openPairing()
const pr = await pair(ep(), '000000', 'x')
expect(pr.status).toBe(401)
})
it('rejects a token replayed from a different device fingerprint', async () => {
const pin = server.openPairing()
const pr = await pair(ep(), pin, 'Mac Studio')
const token = (pr.json as { token: string }).token
const attacker = await generateIdentity('Evil')
const r = await fetchUsage({ identity: attacker, host: '127.0.0.1', port }, token)
expect(r.status).toBe(401)
})
it('aborts when the peer fingerprint does not match the pin', async () => {
await expect(hello({ ...ep(), expectedFingerprint: 'deadbeef' })).rejects.toThrow(/fingerprint mismatch/)
})
})