OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / backend / anongpt-buyer.js

318 lines · javascript ref:
/**
 * AnongptBuyer — page-scoped private buyer for the anonGPT app.
 *
 * Implements the runtime side of the PearBrowser anonGPT dev bridge
 * described in anongpt/docs/spec/02-pearbrowser-dev-bridge.md.
 *
 * The page-side surface is exactly one method:
 *
 *   await window.pear.anongpt.infer({
 *     input,           // user prompt
 *     sellerPubkey,    // 64-hex transport key of the HiveMind seller
 *     options,         // { maxTokens: 160 }
 *     rateCard         // { perCall, perInputToken, perOutputToken }
 *   })
 *
 * Privacy contract (anongpt/docs/spec/01-privacy-by-design.md):
 *   - never expose generic DHT/fs/shell APIs to the page
 *   - never expose buyer or seller private keys to the page
 *   - dial the seller directly over Hyperswarm and call ai.infer
 *   - verify the signed receipt locally before returning
 *   - return the answer text + signed receipt + verify result
 *   - fail closed if any of the above can't be honored
 *
 * Implementation status:
 *
 *   PHASE 1b — implements the real seller dial AND local receipt
 *     verification. Each call spins up a per-request Hyperswarm +
 *     ServiceProtocol, joins the seller's transport-key topic, awaits
 *     channel-open, calls `ai.infer`, then verifies the seller's signed
 *     HiveMind receipt in-process before returning. `verify.ok = true`
 *     is only ever locally computed; receipt/crypto failures stay
 *     fail-closed as `verify.ok = false`.
 *
 *   PHASE 2 (future) — Offer-based seller discovery (signed offer in
 *     place of pasted sellerPubkey), CircuitRelay fallback when
 *     direct dial fails.
 *
 * Per-call swarm trade-off: Phase 1a creates a fresh Hyperswarm per
 * infer call so the buyer state stays isolated (no leftover channel
 * subscriptions between calls). This is the same pattern HiveMind's
 * own reference buyer in bin/buyer.js uses. Phase 2 will optimize
 * to a long-lived swarm + protocol with topic add/remove.
 */

const { verifyReceipt, sha256Hex } = require('./anongpt-receipt.cjs')

const DEFAULT_INFER_TIMEOUT_MS = 60_000
const DEFAULT_DIAL_TIMEOUT_MS = 20_000
const DEFAULT_RATE_CARD = { perCall: 50, perInputToken: 1, perOutputToken: 4 }

class AnongptBuyer {
  constructor (opts = {}) {
    this._maxInputBytes = opts.maxInputBytes || 32 * 1024
    this._inferTimeoutMs = opts.inferTimeoutMs || DEFAULT_INFER_TIMEOUT_MS
    this._dialTimeoutMs = opts.dialTimeoutMs || DEFAULT_DIAL_TIMEOUT_MS
    // Statically-imported ESM modules injected from the root index.js.
    // See backend/pear-adapter.cjs bootBackend() — the root passes
    // { ServiceRegistry, ServiceProtocol } down because dynamic
    // import() from a CommonJS file in Bare has no referrer URL and
    // can't resolve any specifier form.
    this._services = opts.services || null
  }

  /**
   * Resolve ServiceRegistry / ServiceProtocol / Hyperswarm / b4a once.
   * The ESM bits come from the injected `services` object; the CJS bits
   * (hyperswarm, b4a) are loaded with plain require().
   */
  _resolveModules () {
    if (this._mods) return this._mods
    if (!this._services || !this._services.ServiceRegistry || !this._services.ServiceProtocol) {
      const err = new Error('AnongptBuyer: ServiceRegistry/ServiceProtocol not provided (root index.js must pass them via bootBackend({ esmModules }))')
      err.code = 'modules-unavailable'
      throw err
    }
    const Hyperswarm = require('hyperswarm')
    const b4a = require('b4a')
    this._mods = {
      ServiceRegistry: this._services.ServiceRegistry,
      ServiceProtocol: this._services.ServiceProtocol,
      Hyperswarm,
      b4a
    }
    return this._mods
  }

  /**
   * Handle one infer call from the page.
   *
   * Phase 1b flow:
   *   1. validate request shape (cheap — pre-network)
   *   2. require sellerPubkey (Phase 2 will accept signed Offers)
   *   3. lazy-load ServiceRegistry/ServiceProtocol/Hyperswarm
   *   4. open a per-call Hyperswarm + ServiceProtocol
   *   5. swarm.join(sellerKey, { client: true })
   *   6. await channel-open from the seller (with dial timeout)
   *   7. protocol.request('ai', 'infer', { input, options })
   *      (with inference timeout)
   *   8. cleanup swarm/protocol regardless of outcome
   *   9. verify the seller receipt over the raw output and sent prompt
   *  10. return { ok, result, verify, route } in the dev-bridge shape
   */
  async infer (req) {
    const validation = this._validate(req)
    if (!validation.ok) return validation
    if (!req.sellerPubkey) {
      return {
        ok: false,
        code: 'missing-seller',
        message: 'sellerPubkey is required (Offer-based discovery is Phase 2).',
        route: { kind: 'none' }
      }
    }
    const sellerHex = req.sellerPubkey.toLowerCase()
    let mods
    try {
      mods = this._resolveModules()
    } catch (e) {
      return {
        ok: false,
        code: e.code || 'modules-unavailable',
        message: e && e.message,
        route: { kind: 'none' }
      }
    }
    const { ServiceRegistry, ServiceProtocol, Hyperswarm, b4a } = mods
    const sellerKey = b4a.from(sellerHex, 'hex')

    const registry = new ServiceRegistry()
    const protocol = new ServiceProtocol(registry)
    const swarm = new Hyperswarm()

    // Wire connection → protocol BEFORE joining so we don't miss the
    // first peer arriving while listeners aren't attached yet.
    swarm.on('connection', (conn, info) => {
      try {
        const remote = info && info.publicKey ? b4a.toString(info.publicKey, 'hex') : undefined
        if (remote) protocol.attach(conn, remote)
        else protocol.attach(conn)
      } catch (_) {}
    })

    let dialTimer = null
    let inferTimer = null
    const cleanup = async () => {
      if (dialTimer) clearTimeout(dialTimer)
      if (inferTimer) clearTimeout(inferTimer)
      try { protocol.destroy() } catch (_) {}
      try { await swarm.destroy() } catch (_) {}
    }

    try {
      // Wait for the seller's channel to open. We resolve only when
      // the remotePubkey of the channel matches sellerHex — anyone
      // else who happens to peer with us is ignored.
      const channelOpened = new Promise((resolve, reject) => {
        const onChannelOpen = (info) => {
          if (info && info.remotePubkey === sellerHex) {
            protocol.removeListener('channel-open', onChannelOpen)
            resolve()
          }
        }
        protocol.on('channel-open', onChannelOpen)
        dialTimer = setTimeout(() => {
          protocol.removeListener('channel-open', onChannelOpen)
          reject(new Error('dial-timeout: no channel from ' + sellerHex.slice(0, 12) + '… within ' + (this._dialTimeoutMs / 1000) + 's'))
        }, this._dialTimeoutMs)
      })

      swarm.join(sellerKey, { client: true, server: false })

      try {
        await channelOpened
      } catch (err) {
        await cleanup()
        return {
          ok: false,
          code: 'dial-failed',
          message: err.message,
          route: { kind: 'none' }
        }
      }

      // Channel open — call ai.infer with the user's payload. Bound by
      // a separate inference timeout because real model calls can take
      // tens of seconds.
      const inferPayload = { input: req.input }
      if (req.options && typeof req.options === 'object') {
        inferPayload.options = req.options
      }

      let serverResult
      try {
        serverResult = await Promise.race([
          protocol.request(sellerHex, 'ai', 'infer', inferPayload),
          new Promise((resolve, reject) => {
            inferTimer = setTimeout(
              () => reject(new Error('infer-timeout: seller did not return within ' + (this._inferTimeoutMs / 1000) + 's')),
              this._inferTimeoutMs
            )
          })
        ])
      } catch (err) {
        await cleanup()
        return {
          ok: false,
          code: 'infer-failed',
          message: err && err.message,
          route: { kind: 'direct-p2p', sellerPubkey: sellerHex }
        }
      }

      await cleanup()

      if (!serverResult || typeof serverResult !== 'object') {
        return {
          ok: false,
          code: 'infer-bad-response',
          message: 'seller returned a non-object response',
          route: { kind: 'direct-p2p', sellerPubkey: sellerHex }
        }
      }

      // Verify the seller's signed receipt over the RAW output. The page
      // may strip/format text for display later, but trust is computed here
      // against exactly what the seller returned.
      const imageCommit = (serverResult.image && typeof serverResult.image === 'string')
        ? 'sha256:' + sha256Hex(serverResult.image)
        : null
      const boundOutput = imageCommit ?? serverResult.text
      if (typeof boundOutput !== 'string') {
        return {
          ok: false,
          code: 'infer-empty-output',
          message: 'seller returned no bindable output (no text and no image)',
          route: { kind: 'direct-p2p', sellerPubkey: sellerHex }
        }
      }
      const verify = await verifyReceipt(serverResult.receipt || {}, {
        input: req.input,
        output: boundOutput,
        rateCard: req.rateCard || DEFAULT_RATE_CARD
      })

      return {
        ok: true,
        result: {
          text: serverResult.text,
          image: serverResult.image,
          promptTokens: serverResult.promptTokens,
          tokens: serverResult.tokens,
          tokensPerSecond: serverResult.tokensPerSecond,
          backendDevice: serverResult.backendDevice,
          receipt: serverResult.receipt
        },
        // LOCALLY computed — never copied from the seller response.
        verify,
        route: { kind: 'direct-p2p', sellerPubkey: sellerHex }
      }
    } catch (err) {
      // Anything truly unexpected — emit a generic error and ensure
      // the swarm is torn down.
      await cleanup()
      return {
        ok: false,
        code: 'buyer-error',
        message: err && err.message,
        route: { kind: 'none' }
      }
    }
  }

  /**
   * Shape validation for the infer request body. Cheap; runs before
   * any network/crypto so a malformed page can't tie up resources.
   */
  _validate (req) {
    if (!req || typeof req !== 'object') {
      return { ok: false, code: 'invalid-request', message: 'request body must be an object' }
    }
    const { input, sellerPubkey, rateCard } = req
    if (typeof input !== 'string' || input.length === 0) {
      return { ok: false, code: 'invalid-request', message: '`input` must be a non-empty string' }
    }
    if (Buffer.byteLength(input, 'utf-8') > this._maxInputBytes) {
      return {
        ok: false,
        code: 'input-too-large',
        message: '`input` exceeds maxInputBytes (' + this._maxInputBytes + ')'
      }
    }
    if (sellerPubkey != null) {
      if (typeof sellerPubkey !== 'string' || !/^[0-9a-f]{64}$/i.test(sellerPubkey)) {
        return {
          ok: false,
          code: 'invalid-request',
          message: '`sellerPubkey` must be 64-char hex if provided'
        }
      }
    }
    if (rateCard != null && !validRateCard(rateCard)) {
      return {
        ok: false,
        code: 'invalid-request',
        message: '`rateCard` must include non-negative integer perCall, perInputToken, and perOutputToken'
      }
    }
    return { ok: true }
  }
}

function validRateCard (rateCard) {
  if (!rateCard || typeof rateCard !== 'object') return false
  return ['perCall', 'perInputToken', 'perOutputToken'].every((k) => Number.isInteger(rateCard[k]) && rateCard[k] >= 0)
}

module.exports = { AnongptBuyer }