From 7ddbc99f8db0744b9858405b41c211f0fa259289 Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Sat, 20 Jun 2026 15:06:40 +0200 Subject: [PATCH 1/6] 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. --- src/sharing/pairing.ts | 95 +++++++++++++++++++++++++++++++ tests/sharing/pairing.test.ts | 104 ++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 src/sharing/pairing.ts create mode 100644 tests/sharing/pairing.test.ts diff --git a/src/sharing/pairing.ts b/src/sharing/pairing.ts new file mode 100644 index 00000000..a4854527 --- /dev/null +++ b/src/sharing/pairing.ts @@ -0,0 +1,95 @@ +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') +} + +// 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() + + 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) + } +} diff --git a/tests/sharing/pairing.test.ts b/tests/sharing/pairing.test.ts new file mode 100644 index 00000000..5410cc9e --- /dev/null +++ b/tests/sharing/pairing.test.ts @@ -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) + }) +}) From 4ec3050fb5c9084a01140e4dfad2b33a9a8d86cd Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Sat, 20 Jun 2026 15:22:57 +0200 Subject: [PATCH 2/6] 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). --- package-lock.json | 279 ++++++++++++++++++++++++++++++++ package.json | 2 + src/sharing/client.ts | 83 ++++++++++ src/sharing/identity.ts | 58 +++++++ src/sharing/share-server.ts | 139 ++++++++++++++++ tests/sharing/transport.test.ts | 73 +++++++++ 6 files changed, 634 insertions(+) create mode 100644 src/sharing/client.ts create mode 100644 src/sharing/identity.ts create mode 100644 src/sharing/share-server.ts create mode 100644 tests/sharing/transport.test.ts diff --git a/package-lock.json b/package-lock.json index f35e877e..b6975cd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "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 +25,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", @@ -579,6 +581,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 +1145,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 +1372,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", @@ -1265,6 +1457,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", @@ -2574,6 +2775,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 +2877,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 +2972,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 +3096,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", @@ -3293,6 +3548,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 +3627,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", diff --git a/package.json b/package.json index b262b802..bb196762 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "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" @@ -59,6 +60,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", diff --git a/src/sharing/client.ts b/src/sharing/client.ts new file mode 100644 index 00000000..99eba537 --- /dev/null +++ b/src/sharing/client.ts @@ -0,0 +1,83 @@ +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 = {}, + body?: string, +): Promise { + 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 { + return call(ep, 'GET', '/api/peer/hello') +} + +export function pair(ep: PeerEndpoint, pin: string, name: string): Promise { + return call(ep, 'POST', '/api/peer/pair', {}, JSON.stringify({ pin, name })) +} + +export function fetchUsage(ep: PeerEndpoint, token: string, query: UsageQuery = {}): Promise { + 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 + } +} diff --git a/src/sharing/identity.ts b/src/sharing/identity.ts new file mode 100644 index 00000000..0ae73996 --- /dev/null +++ b/src/sharing/identity.ts @@ -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 { + 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 { + 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 +} diff --git a/src/sharing/share-server.ts b/src/sharing/share-server.ts new file mode 100644 index 00000000..bb870deb --- /dev/null +++ b/src/sharing/share-server.ts @@ -0,0 +1,139 @@ +import { createServer, type Server } from 'https' +import type { IncomingMessage, ServerResponse } from 'http' +import type { TLSSocket } from 'tls' +import type { AddressInfo } from 'net' + +import { certFingerprint, PeerStore, PairingWindow } from './pairing.js' +import type { Identity } from './identity.js' + +export type UsageQuery = { period?: string; from?: string; to?: string } + +export type ShareServerOptions = { + identity: Identity + peers: PeerStore + getUsage: (query: UsageQuery) => Promise + // Called after a successful pairing so the caller can persist the peer list. + onPaired?: () => void +} + +// 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 { + return new Promise((resolve, reject) => { + this.server.once('error', reject) + this.server.listen(port, host, () => resolve((this.server.address() as AddressInfo).port)) + }) + } + + close(): Promise { + 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 { + 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/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 { + 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 + } +} diff --git a/tests/sharing/transport.test.ts b/tests/sharing/transport.test.ts new file mode 100644 index 00000000..75872a5e --- /dev/null +++ b/tests/sharing/transport.test.ts @@ -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/) + }) +}) From 2ba8a22d01aa439141032379328f168375b53a4c Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Sat, 20 Jun 2026 15:31:13 +0200 Subject: [PATCH 3/6] 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 --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. --- src/main.ts | 48 ++++++++++++++++ src/sharing/host.ts | 113 +++++++++++++++++++++++++++++++++++++ src/sharing/share-run.ts | 81 ++++++++++++++++++++++++++ src/sharing/store.ts | 50 ++++++++++++++++ tests/sharing/host.test.ts | 61 ++++++++++++++++++++ 5 files changed, 353 insertions(+) create mode 100644 src/sharing/host.ts create mode 100644 src/sharing/share-run.ts create mode 100644 src/sharing/store.ts create mode 100644 tests/sharing/host.test.ts diff --git a/src/main.ts b/src/main.ts index f92c07f3..24f989ef 100644 --- a/src/main.ts +++ b/src/main.ts @@ -13,6 +13,10 @@ import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory import { aggregateModelEfficiency } from './model-efficiency.js' import { buildPeriodData, buildMenubarPayloadForRange } from './usage-aggregator.js' import { renderDashboard } from './dashboard.js' +import { hostname } from 'os' +import { runShareServer } from './sharing/share-run.js' +import { addRemote, pullDevices, renderDevices } from './sharing/host.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' @@ -468,6 +472,50 @@ 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 ', '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 paired devices. Actions: add --pin , rm ') + .option('--pin ', 'Pairing PIN shown on the device you are adding') + .option('-p, --period ', 'Period: today, week, 30days, month, all', 'month') + .option('--port ', '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) { + console.error('\n Usage: codeburn devices add --pin \n') + process.exit(1) + } + const device = await addRemote(target, opts.pin, { defaultPort: opts.port }) + console.log(`\n Paired with "${device.name}" (${device.host}:${device.port}).\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('status') diff --git a/src/sharing/host.ts b/src/sharing/host.ts new file mode 100644 index 00000000..b18e4949 --- /dev/null +++ b/src/sharing/host.ts @@ -0,0 +1,113 @@ +import { hello, pair, fetchUsage } from './client.js' +import { loadOrCreateIdentity } from './identity.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 { + 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 +} + +// Pull this machine's usage plus every paired remote's, each kept separate. +export async function pullDevices( + localGetUsage: (q: UsageQuery) => Promise, + query: UsageQuery, + localName: string, + opts: { dir?: string } = {}, +): Promise { + 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' +} diff --git a/src/sharing/share-run.ts b/src/sharing/share-run.ts new file mode 100644 index 00000000..3fe66310 --- /dev/null +++ b/src/sharing/share-run.ts @@ -0,0 +1,81 @@ +import { networkInterfaces } from 'os' + +import { loadOrCreateIdentity } from './identity.js' +import { PeerStore } from './pairing.js' +import { ShareServer, type UsageQuery } from './share-server.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 { + await loadPricing() + const dir = getSharingDir() + const identity = await loadOrCreateIdentity(dir) + const peers = new PeerStore(await loadPeers(dir)) + + const getUsage = async (q: UsageQuery): Promise => { + const customRange = parseDateRangeFlags(q.from, q.to) + const periodInfo = customRange + ? { range: customRange, label: formatDateRangeLabel(q.from, q.to) } + : getDateRange(toPeriod(q.period ?? 'month')) + return buildMenubarPayloadForRange(periodInfo, { provider: 'all', optimize: false }) + } + + const server = new ShareServer({ + identity, + peers, + getUsage, + onPaired: () => { + void savePeers(peers.list(), dir) + }, + }) + + const port = await server.listen(opts.port, '0.0.0.0') + const ip = lanAddress() ?? '127.0.0.1' + + process.stdout.write(`\n Sharing "${identity.name}" at ${ip}:${port}\n`) + process.stdout.write(` Fingerprint ${identity.fingerprint.slice(0, 16)}...\n`) + + if (opts.pair) { + const pin = server.openPairing(120_000) + process.stdout.write(`\n Pairing open for 2 minutes. On the other device, run:\n`) + process.stdout.write(` codeburn devices add ${ip}:${port} --pin ${pin}\n`) + } else if (peers.list().length === 0) { + process.stdout.write(`\n No paired devices yet. Re-run with --pair to add one.\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(() => { + /* run until interrupted */ + }) +} diff --git a/src/sharing/store.ts b/src/sharing/store.ts new file mode 100644 index 00000000..717222e7 --- /dev/null +++ b/src/sharing/store.ts @@ -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(path: string, fallback: T): Promise { + try { + return JSON.parse(await readFile(path, 'utf8')) as T + } catch { + return fallback + } +} + +async function writeJson(path: string, data: unknown): Promise { + 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 { + return readJson(join(dir, 'paired-peers.json'), [] as PairedPeer[]) +} +export function savePeers(peers: PairedPeer[], dir: string = getSharingDir()): Promise { + 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 { + return readJson(join(dir, 'remote-devices.json'), [] as RemoteDevice[]) +} +export function saveRemotes(remotes: RemoteDevice[], dir: string = getSharingDir()): Promise { + return writeJson(join(dir, 'remote-devices.json'), remotes) +} diff --git a/tests/sharing/host.test.ts b/tests/sharing/host.test.ts new file mode 100644 index 00000000..3d1dd188 --- /dev/null +++ b/tests/sharing/host.test.ts @@ -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') + }) +}) From 12875e4a1119565be8f189f1d7365879f8e23742 Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Sat, 20 Jun 2026 15:58:38 +0200 Subject: [PATCH 4/6] 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. --- package-lock.json | 48 +++++++++++++++++++++++++++ package.json | 1 + src/sharing/client.ts | 6 ++++ src/sharing/discovery.ts | 54 ++++++++++++++++++++++++++++++ src/sharing/pairing.ts | 10 ++++++ src/sharing/share-server.ts | 32 +++++++++++++++++- tests/sharing/approve.test.ts | 62 +++++++++++++++++++++++++++++++++++ 7 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 src/sharing/discovery.ts create mode 100644 tests/sharing/approve.test.ts diff --git a/package-lock.json b/package-lock.json index b6975cd0..d7b693f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "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", @@ -541,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", @@ -1432,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", @@ -1764,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", @@ -2573,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", @@ -3461,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", diff --git a/package.json b/package.json index bb196762..7eb386cb 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "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", diff --git a/src/sharing/client.ts b/src/sharing/client.ts index 99eba537..60d2886e 100644 --- a/src/sharing/client.ts +++ b/src/sharing/client.ts @@ -67,6 +67,12 @@ export function pair(ep: PeerEndpoint, pin: string, name: string): Promise { + return call(ep, 'POST', '/api/peer/pair-request', {}, JSON.stringify({ name })) +} + export function fetchUsage(ep: PeerEndpoint, token: string, query: UsageQuery = {}): Promise { const params = new URLSearchParams() for (const [k, v] of Object.entries(query)) if (v) params.set(k, v) diff --git a/src/sharing/discovery.ts b/src/sharing/discovery.ts new file mode 100644 index 00000000..9e348742 --- /dev/null +++ b/src/sharing/discovery.ts @@ -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 } + +// 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((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 { + return new Promise((resolve) => { + const bonjour = new Bonjour() + const found = new Map() + const browser = bonjour.find({ type: SERVICE_TYPE }, (service) => { + const txt = (service.txt ?? {}) as Record + 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?.() + }) +} diff --git a/src/sharing/pairing.ts b/src/sharing/pairing.ts index a4854527..faec7778 100644 --- a/src/sharing/pairing.ts +++ b/src/sharing/pairing.ts @@ -28,6 +28,16 @@ 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 { diff --git a/src/sharing/share-server.ts b/src/sharing/share-server.ts index bb870deb..bc6c9899 100644 --- a/src/sharing/share-server.ts +++ b/src/sharing/share-server.ts @@ -3,17 +3,23 @@ import type { IncomingMessage, ServerResponse } from 'http' import type { TLSSocket } from 'tls' import type { AddressInfo } from 'net' -import { certFingerprint, PeerStore, PairingWindow } from './pairing.js' +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 // 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 } // A device's HTTPS sharing endpoint. Mutual TLS: the server presents its own @@ -98,6 +104,30 @@ export class ShareServer { 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, '') diff --git a/tests/sharing/approve.test.ts b/tests/sharing/approve.test.ts new file mode 100644 index 00000000..b8872ec5 --- /dev/null +++ b/tests/sharing/approve.test.ts @@ -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) + }) +}) From 02669367a78d8fbf472325c41d23cbd67fc9084a Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Sat, 20 Jun 2026 16:01:23 +0200 Subject: [PATCH 5/6] 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 --pin stays as a fallback for networks that block mDNS. --- src/main.ts | 34 ++++++++++++++++++++++++++++------ src/sharing/host.ts | 26 +++++++++++++++++++++++++- src/sharing/prompt.ts | 30 ++++++++++++++++++++++++++++++ src/sharing/share-run.ts | 25 +++++++++++++++++++------ 4 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 src/sharing/prompt.ts diff --git a/src/main.ts b/src/main.ts index 24f989ef..1cfad6d3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,7 +15,9 @@ import { buildPeriodData, buildMenubarPayloadForRange } from './usage-aggregator import { renderDashboard } from './dashboard.js' import { hostname } from 'os' import { runShareServer } from './sharing/share-run.js' -import { addRemote, pullDevices, renderDevices } from './sharing/host.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' @@ -484,19 +486,39 @@ program program .command('devices [action] [target]') - .description('Combined usage across your paired devices. Actions: add --pin , rm ') + .description('Combined usage across your devices. Actions: add (find nearby & pair) | add --pin (manual) | rm ') .option('--pin ', 'Pairing PIN shown on the device you are adding') .option('-p, --period ', 'Period: today, week, 30days, month, all', 'month') .option('--port ', '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) { - console.error('\n Usage: codeburn devices add --pin \n') + 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) } - const device = await addRemote(target, opts.pin, { defaultPort: opts.port }) - console.log(`\n Paired with "${device.name}" (${device.host}:${device.port}).\n`) + 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') { diff --git a/src/sharing/host.ts b/src/sharing/host.ts index b18e4949..158782b4 100644 --- a/src/sharing/host.ts +++ b/src/sharing/host.ts @@ -1,5 +1,7 @@ -import { hello, pair, fetchUsage } from './client.js' +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' @@ -54,6 +56,28 @@ export async function addRemote( 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 { + 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, diff --git a/src/sharing/prompt.ts b/src/sharing/prompt.ts new file mode 100644 index 00000000..4f58a309 --- /dev/null +++ b/src/sharing/prompt.ts @@ -0,0 +1,30 @@ +import { createInterface } from 'readline' + +export function promptYesNo(question: string, timeoutMs?: number): Promise { + 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 { + 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) + }) + }) +} diff --git a/src/sharing/share-run.ts b/src/sharing/share-run.ts index 3fe66310..c5e06778 100644 --- a/src/sharing/share-run.ts +++ b/src/sharing/share-run.ts @@ -3,6 +3,8 @@ 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 { getSharingDir, loadPeers, savePeers } from './store.js' import { loadPricing } from '../models.js' import { buildMenubarPayloadForRange } from '../usage-aggregator.js' @@ -43,22 +45,33 @@ export async function runShareServer(opts: { port: number; pair: boolean; always 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 }) - process.stdout.write(`\n Sharing "${identity.name}" at ${ip}:${port}\n`) - process.stdout.write(` Fingerprint ${identity.fingerprint.slice(0, 16)}...\n`) + const shutdown = async (): Promise => { + 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 Pairing open for 2 minutes. On the other device, run:\n`) + process.stdout.write(`\n Manual fallback (if discovery is blocked):\n`) process.stdout.write(` codeburn devices add ${ip}:${port} --pin ${pin}\n`) - } else if (peers.list().length === 0) { - process.stdout.write(`\n No paired devices yet. Re-run with --pair to add one.\n`) } - process.stdout.write(`\n ${peers.list().length} paired device(s). Press Ctrl+C to stop.\n\n`) if (!opts.always) { From e34f4338047dea18751cd6ccee28726246ff1a04 Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Sat, 20 Jun 2026 16:14:15 +0200 Subject: [PATCH 6/6] 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. --- src/sharing/sanitize.ts | 16 ++++++++++++ src/sharing/share-run.ts | 3 ++- tests/sharing/sanitize.test.ts | 46 ++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 src/sharing/sanitize.ts create mode 100644 tests/sharing/sanitize.test.ts diff --git a/src/sharing/sanitize.ts b/src/sharing/sanitize.ts new file mode 100644 index 00000000..d0b77cd9 --- /dev/null +++ b/src/sharing/sanitize.ts @@ -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: [], + }, + } +} diff --git a/src/sharing/share-run.ts b/src/sharing/share-run.ts index c5e06778..f048b394 100644 --- a/src/sharing/share-run.ts +++ b/src/sharing/share-run.ts @@ -5,6 +5,7 @@ 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' @@ -35,7 +36,7 @@ export async function runShareServer(opts: { port: number; pair: boolean; always const periodInfo = customRange ? { range: customRange, label: formatDateRangeLabel(q.from, q.to) } : getDateRange(toPeriod(q.period ?? 'month')) - return buildMenubarPayloadForRange(periodInfo, { provider: 'all', optimize: false }) + return sanitizeForSharing(await buildMenubarPayloadForRange(periodInfo, { provider: 'all', optimize: false })) } const server = new ShareServer({ diff --git a/tests/sharing/sanitize.test.ts b/tests/sharing/sanitize.test.ts new file mode 100644 index 00000000..6ad7458b --- /dev/null +++ b/tests/sharing/sanitize.test.ts @@ -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') + }) +})