Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions packages/bridge-server/fileStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Local file store for bridge file uploads.
*
* Stores uploaded files in ~/.claude/bridge-files/ keyed by UUID.
* Provides upload (multipart) and serve (GET by UUID) handlers.
*/

import { existsSync, mkdirSync } from 'fs'
import { homedir } from 'os'
import { join } from 'path'
import { randomUUID } from 'crypto'

const STORE_DIR = join(homedir(), '.claude', 'bridge-files')

function ensureDir(): void {
if (!existsSync(STORE_DIR)) {
mkdirSync(STORE_DIR, { recursive: true })
}
}

export async function storeFile(file: Blob): Promise<string> {
ensureDir()
const uuid = randomUUID()
const path = join(STORE_DIR, uuid)
await Bun.write(path, file)
return uuid
}

export function getFilePath(uuid: string): string | null {
// Sanitize: only allow UUID-shaped strings (no path traversal)
if (!/^[a-f0-9-]{36}$/.test(uuid)) return null
const path = join(STORE_DIR, uuid)
return existsSync(path) ? path : null
}
85 changes: 85 additions & 0 deletions packages/bridge-server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env bun
/**
* Local bridge server entry point.
*
* Usage:
* bun run packages/bridge-server/index.ts
* bun run packages/bridge-server/index.ts --port 8080
* bun run packages/bridge-server/index.ts --host 0.0.0.0 # for Tailscale/WireGuard
*
* The server emulates the Anthropic CCR v2 protocol so the existing
* bridge client code connects transparently. Set the CLI to connect:
* CLAUDE_BRIDGE_BASE_URL=http://localhost:4080 openclaude
*/

import { randomUUID } from 'crypto'
import { createServer } from './server.js'

function parseArgs(): { port: number; host: string; jwtSecret: string } {
const args = process.argv.slice(2)
let port = 4080
let host = 'localhost'
let jwtSecret = process.env.BRIDGE_JWT_SECRET ?? randomUUID()

for (let i = 0; i < args.length; i++) {
if (args[i] === '--port' && args[i + 1]) {
port = parseInt(args[++i]!, 10)
} else if (args[i] === '--host' && args[i + 1]) {
host = args[++i]!
} else if (args[i] === '--secret' && args[i + 1]) {
jwtSecret = args[++i]!
} else if (args[i] === '--help' || args[i] === '-h') {
console.log(`
openclaude bridge-server — local CCR v2 bridge

Usage:
bun run packages/bridge-server/index.ts [options]

Options:
--port <n> Port to listen on (default: 4080)
--host <addr> Host to bind to (default: localhost)
Use 0.0.0.0 for remote access via Tailscale/WireGuard
--secret <key> JWT signing secret (default: random per run)
--help, -h Show this help

Connect the CLI:
CLAUDE_BRIDGE_BASE_URL=http://localhost:4080 openclaude
`)
process.exit(0)
}
}

return { port, host, jwtSecret }
}

const config = parseArgs()
const server = createServer(config)

const displayHost = config.host === '0.0.0.0' ? 'localhost' : config.host
console.log(`
╔══════════════════════════════════════════════════╗
║ openclaude bridge-server ║
║ Local CCR v2 protocol emulation ║
╠══════════════════════════════════════════════════╣
║ Listening: http://${displayHost}:${config.port}${' '.repeat(Math.max(0, 24 - displayHost.length - String(config.port).length))}║
║ Protocol: CCR v2 (env-less) ║
║ Auth: Local JWT (HS256) ║
║ Files: ~/.claude/bridge-files/ ║
╠══════════════════════════════════════════════════╣
║ Connect: ║
║ CLAUDE_BRIDGE_BASE_URL=http://${displayHost}:${config.port}${' '.repeat(Math.max(0, 10 - displayHost.length - String(config.port).length))}║
║ openclaude ║
╚══════════════════════════════════════════════════╝
`)

// Graceful shutdown
process.on('SIGINT', () => {
console.log('\nShutting down bridge server...')
server.stop()
process.exit(0)
})

process.on('SIGTERM', () => {
server.stop()
process.exit(0)
})
60 changes: 60 additions & 0 deletions packages/bridge-server/jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Local JWT signing and validation.
*
* The CLI decodes JWT claims (exp, session_id) for refresh scheduling
* but does NOT validate the signature. The server validates on each
* request using HMAC-SHA256 with a local secret.
*/

import { createHmac } from 'crypto'

function base64url(data: string | Buffer): string {
const buf = typeof data === 'string' ? Buffer.from(data) : data
return buf.toString('base64url')
}

export function signJwt(
sessionId: string,
epoch: number,
secret: string,
expiresInSeconds = 3600,
): string {
const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const now = Math.floor(Date.now() / 1000)
const payload = base64url(
JSON.stringify({
session_id: sessionId,
role: 'worker',
epoch,
iat: now,
exp: now + expiresInSeconds,
}),
)
const signature = createHmac('sha256', secret)
.update(`${header}.${payload}`)
.digest('base64url')
return `${header}.${payload}.${signature}`
}

export function verifyJwt(
token: string,
secret: string,
): { sessionId: string; epoch: number } | null {
const parts = token.split('.')
if (parts.length !== 3) return null

const [header, payload, signature] = parts
const expected = createHmac('sha256', secret)
.update(`${header}.${payload}`)
.digest('base64url')

if (signature !== expected) return null

try {
const claims = JSON.parse(Buffer.from(payload!, 'base64url').toString())
if (claims.exp && claims.exp < Math.floor(Date.now() / 1000)) return null
return { sessionId: claims.session_id, epoch: claims.epoch }
} catch {
return null
}
}
Loading
Loading