UNPKG

consortium

Version:

Remote control and session sharing CLI for AI coding agents

750 lines (736 loc) 25.7 kB
import{createRequire as _pkgrollCR}from"node:module";const require=_pkgrollCR(import.meta.url);import { promises } from 'node:fs'; import { isAbsolute, join } from 'node:path'; import { l as logger, c as configuration } from './types-DETLaopx.mjs'; import axios from 'axios'; import sodium from 'libsodium-wrappers'; import { randomBytes } from 'node:crypto'; import { mkdir, writeFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; function decryptBox(env, encryptedBundle, recipientSecretKey) { const { sodium } = env; const ephemeralPublicKey = encryptedBundle.slice(0, sodium.crypto_box_PUBLICKEYBYTES); const nonce = encryptedBundle.slice( sodium.crypto_box_PUBLICKEYBYTES, sodium.crypto_box_PUBLICKEYBYTES + sodium.crypto_box_NONCEBYTES ); const encrypted = encryptedBundle.slice(sodium.crypto_box_PUBLICKEYBYTES + sodium.crypto_box_NONCEBYTES); try { return sodium.crypto_box_open_easy(encrypted, nonce, ephemeralPublicKey, recipientSecretKey); } catch { return null; } } function decodeBase64(base64, encoding = "base64") { let normalizedBase64 = base64; if (encoding === "base64url") { normalizedBase64 = base64.replace(/-/g, "+").replace(/_/g, "/"); const padding = normalizedBase64.length % 4; if (padding) { normalizedBase64 += "=".repeat(4 - padding); } } const binaryString = atob(normalizedBase64); const len = binaryString.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes; } function decryptDriveDek(env, encrypted, privateKey) { if (encrypted[0] !== 0) return null; return decryptBox(env, encrypted.slice(1), privateKey); } function decryptDriveContent(env, encrypted, dek) { const { sodium } = env; const nonce = encrypted.slice(0, sodium.crypto_secretbox_NONCEBYTES); const ciphertext = encrypted.slice(sodium.crypto_secretbox_NONCEBYTES); try { return sodium.crypto_secretbox_open_easy(ciphertext, nonce, dek); } catch { return null; } } function decryptDriveMetadata(env, encrypted, dek) { try { const data = decodeBase64(encrypted, "base64"); const decrypted = decryptDriveContent(env, data, dek); if (!decrypted) return null; return JSON.parse(new TextDecoder().decode(decrypted)); } catch { return null; } } function sniffMimeFromBytes(buf) { if (buf.length < 4) return void 0; const b = buf instanceof Buffer ? buf : Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength); if (b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71) return "image/png"; if (b[0] === 255 && b[1] === 216 && b[2] === 255) return "image/jpeg"; if (b[0] === 71 && b[1] === 73 && b[2] === 70 && b[3] === 56) return "image/gif"; if (b[0] === 37 && b[1] === 80 && b[2] === 68 && b[3] === 70) return "application/pdf"; if (b.length >= 12 && b[0] === 82 && b[1] === 73 && b[2] === 70 && b[3] === 70 && b[8] === 87 && b[9] === 69 && b[10] === 66 && b[11] === 80) return "image/webp"; if (b.length >= 12 && b[4] === 102 && b[5] === 116 && b[6] === 121 && b[7] === 112) { if (b[8] === 113 && b[9] === 116 && b[10] === 32) return "video/quicktime"; return "video/mp4"; } if (b[0] === 26 && b[1] === 69 && b[2] === 223 && b[3] === 163) return "video/webm"; if (b[0] === 73 && b[1] === 68 && b[2] === 51) return "audio/mpeg"; if (b[0] === 255 && (b[1] & 224) === 224) return "audio/mpeg"; if (b.length >= 12 && b[0] === 82 && b[1] === 73 && b[2] === 70 && b[3] === 70 && b[8] === 87 && b[9] === 65 && b[10] === 86 && b[11] === 69) return "audio/wav"; if (b[0] === 79 && b[1] === 103 && b[2] === 103 && b[3] === 83) return "audio/ogg"; if (b[0] === 102 && b[1] === 76 && b[2] === 97 && b[3] === 67) return "audio/flac"; return void 0; } const MIME_BY_EXT = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", avif: "image/avif", heic: "image/heic", svg: "image/svg+xml", mp4: "video/mp4", mov: "video/quicktime", webm: "video/webm", mkv: "video/x-matroska", mp3: "audio/mpeg", wav: "audio/wav", ogg: "audio/ogg", m4a: "audio/mp4", flac: "audio/flac", pdf: "application/pdf" }; function mimeFromExtension(path) { const i = path.lastIndexOf("."); if (i < 0) return void 0; const ext = path.slice(i + 1).toLowerCase(); return MIME_BY_EXT[ext]; } const DEFAULTS = { maxBytes: 64 * 1024 * 1024, maxDepth: 6, maxCandidates: 8, maxFileAgeMs: 30 * 60 * 1e3 // 30 min — older than a turn, probably stale }; const BASE64ISH = /^[A-Za-z0-9+/=_-]{800,}$/; function looksLikePath(s) { return s.length > 1 && s.length < 4096 && isAbsolute(s) && !s.includes("\n"); } function looksLikeUrl(s) { return /^https?:\/\/[^\s]+$/i.test(s) && s.length < 2048; } function extractPathsFromProse(text) { if (!text || text.length > 1e5) return []; const out = []; const re = /\/[^\s'"`)\]]+\.(?:png|jpe?g|gif|webp|svg|avif|heic|mp4|mov|webm|mkv|mp3|wav|ogg|m4a|flac|pdf)\b/gi; let m; while ((m = re.exec(text)) !== null) { out.push(m[0]); if (out.length >= 4) break; } return out; } async function scanToolResultForMedia(content, onMedia, options = {}) { const opts = { ...DEFAULTS, ...options }; const seenBytes = /* @__PURE__ */ new Set(); let emitted = 0; const tryEmit = async (cand) => { if (emitted >= opts.maxCandidates) return; if (cand.bytes.length === 0 || cand.bytes.length > opts.maxBytes) return; const key = `${cand.mime}|${cand.bytes.length}`; if (seenBytes.has(key)) return; seenBytes.add(key); emitted++; try { await onMedia(cand); } catch (err) { logger.debug("[mediaScan] onMedia callback threw:", err); } }; const tryEmitFromPath = async (path, name) => { try { const stat = await promises.stat(path); if (!stat.isFile()) return; if (stat.size === 0 || stat.size > opts.maxBytes) return; if (Date.now() - stat.mtimeMs > opts.maxFileAgeMs) return; const extMime = mimeFromExtension(path); if (!extMime) return; const buf = await promises.readFile(path); const sniffed = sniffMimeFromBytes(buf) ?? extMime; const bytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); await tryEmit({ bytes, mime: sniffed, name: name ?? basenameOf(path) }); } catch (err) { logger.debug(`[mediaScan] path read failed (${path}):`, err instanceof Error ? err.message : err); } }; const tryEmitFromUrl = async (url, name) => { try { const res = await fetch(url, { method: "GET" }); if (!res.ok) return; const ct = res.headers.get("content-type") ?? ""; const len = Number(res.headers.get("content-length") ?? "0"); if (len > opts.maxBytes) return; const ab = await res.arrayBuffer(); const buf = Buffer.from(ab); if (buf.length === 0 || buf.length > opts.maxBytes) return; const sniffed = sniffMimeFromBytes(buf); const isMediaCt = ct.startsWith("image/") || ct.startsWith("video/") || ct.startsWith("audio/") || ct === "application/pdf"; if (!sniffed && !isMediaCt) return; const mime = sniffed ?? ct.split(";")[0].trim(); const bytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); await tryEmit({ bytes, mime, name: name ?? url.split("/").pop() }); } catch (err) { logger.debug(`[mediaScan] url fetch failed (${url}):`, err instanceof Error ? err.message : err); } }; const walk = async (node, depth, hintName) => { if (emitted >= opts.maxCandidates) return; if (node === null || node === void 0 || depth > opts.maxDepth) return; if (typeof node === "string") { const s = node; if (looksLikePath(s)) { await tryEmitFromPath(s, hintName); return; } if (looksLikeUrl(s)) { await tryEmitFromUrl(s, hintName); return; } if (BASE64ISH.test(s)) { try { const buf = Buffer.from(s, "base64"); if (buf.length > 0) { const sniffed = sniffMimeFromBytes(buf); if (sniffed) { const bytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); await tryEmit({ bytes, mime: sniffed, name: hintName }); } } } catch { } return; } for (const p of extractPathsFromProse(s)) { await tryEmitFromPath(p); if (emitted >= opts.maxCandidates) return; } return; } if (Array.isArray(node)) { for (const item of node) { await walk(item, depth + 1); if (emitted >= opts.maxCandidates) return; } return; } if (typeof node === "object") { const o = node; if (typeof o.type === "string") { if (o.type === "image" && typeof o.data === "string") { const mime = (typeof o.mimeType === "string" ? o.mimeType : void 0) ?? "image/png"; try { const buf = Buffer.from(o.data, "base64"); if (buf.length > 0) { const bytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); await tryEmit({ bytes, mime, name: typeof o.name === "string" ? o.name : void 0 }); } } catch { } return; } if (o.type === "audio" && typeof o.data === "string") { const mime = (typeof o.mimeType === "string" ? o.mimeType : void 0) ?? "audio/mpeg"; try { const buf = Buffer.from(o.data, "base64"); if (buf.length > 0) { const bytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); await tryEmit({ bytes, mime, name: typeof o.name === "string" ? o.name : void 0 }); } } catch { } return; } if (o.type === "resource" && o.resource && typeof o.resource === "object") { const r = o.resource; if (typeof r.blob === "string") { const mime = (typeof r.mimeType === "string" ? r.mimeType : void 0) ?? "application/octet-stream"; try { const buf = Buffer.from(r.blob, "base64"); if (buf.length > 0) { const sniffed = sniffMimeFromBytes(buf) ?? mime; const bytes = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); await tryEmit({ bytes, mime: sniffed, name: typeof r.uri === "string" ? basenameOf(r.uri) : void 0 }); } } catch { } return; } if (typeof r.uri === "string") { const uri = r.uri; if (uri.startsWith("file://")) { await tryEmitFromPath(uri.slice(7)); return; } } } } for (const [k, v] of Object.entries(o)) { const lk = k.toLowerCase(); const hint = (lk.includes("name") || lk.includes("filename")) && typeof o.name === "string" ? o.name : void 0; await walk(v, depth + 1, hint); if (emitted >= opts.maxCandidates) return; } } }; await walk(content, 0); } function basenameOf(p) { const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\")); return i >= 0 ? p.slice(i + 1) : p; } let ENV = null; let READY = null; async function getCryptoEnv() { if (ENV) return ENV; if (!READY) { READY = (async () => { await sodium.ready; const env = { // libsodium-wrappers exposes the required surface. // Cast: the .d.ts types are broader than SodiumLike but // structurally compatible. sodium, getRandomBytes: (size) => new Uint8Array(randomBytes(size)) }; ENV = env; return env; })(); } return READY; } class DriveAccessError extends Error { constructor(message, status) { super(message); this.status = status; this.name = "DriveAccessError"; } } class DriveDecryptError extends Error { constructor(message, driveId, nodeId) { super(message); this.driveId = driveId; this.nodeId = nodeId; this.name = "DriveDecryptError"; } } function isEncryptedWrapper(parsed) { return typeof parsed === "object" && parsed !== null && parsed.v === 1 && typeof parsed.enc === "string"; } class DriveClient { constructor(token, user, baseUrl = configuration.serverUrl) { this.user = user; this.http = axios.create({ baseURL: baseUrl, headers: { Authorization: `Bearer ${token}` } }); } http; dekCache = /* @__PURE__ */ new Map(); async getDriveInfo(driveId) { try { const { data } = await this.http.get(`/v1/drives/${driveId}`); return data; } catch (err) { throw this.wrapAccessError(err, `getDriveInfo ${driveId}`); } } async getNode(driveId, nodeId) { try { const { data } = await this.http.get( `/v1/drives/${driveId}/nodes/${nodeId}` ); return data; } catch (err) { throw this.wrapAccessError(err, `getNode ${driveId}/${nodeId}`); } } async getDownloadUrl(driveId, nodeId) { try { const { data } = await this.http.get( `/v1/drives/${driveId}/nodes/${nodeId}/download-url` ); return data.downloadUrl; } catch (err) { throw this.wrapAccessError(err, `getDownloadUrl ${driveId}/${nodeId}`); } } async listSessionAttachments(sessionId, messageLocalId) { try { const { data } = await this.http.get( `/v1/sessions/${sessionId}/attachments`, { params: messageLocalId ? { messageLocalId } : void 0 } ); return data.attachments; } catch (err) { throw this.wrapAccessError(err, `listSessionAttachments ${sessionId}`); } } /** * Fetch the node, download + decrypt its blob (if any), and decrypt * its metadata. Nodes without a storage blob return empty `bytes`. * * If decryption fails (metadata or content) with the cached DEK, * refresh the DEK once and retry; surface DriveDecryptError on a * second failure. */ async fetchAndDecrypt(driveId, nodeId) { const env = await getCryptoEnv(); const node = await this.getNode(driveId, nodeId); let parsed; try { parsed = JSON.parse(node.encryptedMetadata); } catch { parsed = void 0; } const downloadBlob = async () => { if (!node.storageBlobKey) return new Uint8Array(0); const downloadUrl = await this.getDownloadUrl(driveId, nodeId); try { const { data } = await axios.get(downloadUrl, { responseType: "arraybuffer" }); return new Uint8Array(data); } catch (err) { throw this.wrapAccessError(err, `download ${driveId}/${nodeId}`); } }; if (parsed !== void 0 && !isEncryptedWrapper(parsed)) { const bytes = await downloadBlob(); return { bytes: bytes ?? new Uint8Array(0), metadata: parsed, node }; } const encMetadata = isEncryptedWrapper(parsed) ? parsed.enc : node.encryptedMetadata; const attempt = async (dek2) => { const metadata = decryptDriveMetadata(env, encMetadata, dek2); if (metadata === null) return null; if (!node.storageBlobKey) { return { bytes: new Uint8Array(0), metadata, node }; } const ciphertext = await downloadBlob(); if (!ciphertext) return null; const bytes = decryptDriveContent(env, ciphertext, dek2); if (!bytes) return null; return { bytes, metadata, node }; }; let dek = await this.getDek(driveId, env); const first = await attempt(dek); if (first) return first; logger.debug(`[DriveClient] decrypt failed, refreshing DEK for drive ${driveId}`); this.dekCache.delete(driveId); dek = await this.getDek(driveId, env); const second = await attempt(dek); if (second) return second; throw new DriveDecryptError( `Failed to decrypt drive node ${nodeId} in drive ${driveId} after DEK refresh`, driveId, nodeId ); } /** * Seed the in-memory DEK cache with a key the caller obtained out * of band (e.g. from the user-message envelope). The daemon's * machineKey is a different x25519 keypair from the user's content * key, so without this seed `getDek()` cannot unwrap the stored * `encryptedDek` and every `fetchAndDecrypt` returns empty bytes. */ setKnownDek(driveId, dek) { this.dekCache.set(driveId, dek); } async getDek(driveId, env) { const cached = this.dekCache.get(driveId); if (cached) return cached; const info = await this.getDriveInfo(driveId); const wrapped = decodeBase64(info.encryptedDek, "base64"); const dek = decryptDriveDek(env, wrapped, this.user.x25519SecretKey); if (!dek) { throw new DriveDecryptError( `Failed to unwrap DEK for drive ${driveId}`, driveId ); } this.dekCache.set(driveId, dek); return dek; } wrapAccessError(err, context) { if (axios.isAxiosError(err)) { const status = err.response?.status; return new DriveAccessError( `${context}: HTTP ${status ?? "network"} ${err.message}`, status ); } return err instanceof Error ? err : new Error(String(err)); } } class AttachmentError extends Error { code; attachmentId; driveNodeId; constructor(code, message, opts = {}) { super(message); this.name = "AttachmentError"; this.code = code; this.attachmentId = opts.attachmentId; this.driveNodeId = opts.driveNodeId; if (opts.cause !== void 0) { this.cause = opts.cause; } } } function pickName(metadata, fallback) { if (metadata && typeof metadata === "object" && metadata !== null) { const m = metadata; if (typeof m.name === "string" && m.name.length > 0) return m.name; if (typeof m.filename === "string" && m.filename.length > 0) return m.filename; } return fallback; } function pickMime(metadata) { if (metadata && typeof metadata === "object" && metadata !== null) { const m = metadata; if (typeof m.mimeType === "string") return m.mimeType; if (typeof m.mime === "string") return m.mime; if (typeof m.contentType === "string") return m.contentType; } return void 0; } function classifyError(err) { if (err instanceof AttachmentError) return err.code; const msg = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase(); if (msg.includes("dek") || msg.includes("key")) return "dek-missing"; if (msg.includes("decrypt")) return "decrypt-failed"; if (msg.includes("not found") || msg.includes("404")) return "not-found"; if (msg.includes("quota") || msg.includes("storage full")) return "quota-exceeded"; return "drive-fetch-failed"; } function failed(code, base, err) { const message = err instanceof Error ? err.message : err === void 0 ? void 0 : String(err); return { ...base, status: { code, message } }; } class AttachmentResolver { constructor(drive, scratch) { this.drive = drive; this.scratch = scratch; } /** * Resolve every binding currently attached to `messageLocalId` (or * every binding for the session if `messageLocalId` is omitted). * Always returns a result per binding — failures are reported via * `status`, never silently dropped. */ async resolveForMessage(sessionId, messageLocalId) { let bindings; try { bindings = await this.drive.listSessionAttachments(sessionId, messageLocalId); } catch (err) { logger.debug("[AttachmentResolver] listSessionAttachments failed:", err); return []; } const out = []; for (const b of bindings) { try { out.push(await this.resolveBinding(b)); } catch (err) { const code = classifyError(err); const msg = err instanceof Error ? err.message : String(err); logger.debug( `[AttachmentResolver] resolve failed binding=${b.id} node=${b.driveNodeId} kind=${b.kind} driveId=${b.driveId ?? "null"} code=${code}: ${msg}` ); out.push(failed(code, { id: b.id, driveNodeId: b.driveNodeId, kind: b.kind, name: b.linkUri ?? b.driveNodeId, uri: b.linkUri ?? void 0 }, err)); } } return out; } /** * Resolve by raw drive node ids when the caller doesn't have a * binding row (e.g. ad-hoc references encoded directly in the * message envelope's `driveNodeIds`). */ async resolveByDriveNodeIds(driveId, nodeIds) { const out = []; for (const nodeId of nodeIds) { try { const fetched = await this.drive.fetchAndDecrypt(driveId, nodeId); if (!fetched.bytes || fetched.bytes.length === 0) { out.push(failed("dek-missing", { id: nodeId, driveNodeId: nodeId, kind: "drive_file", name: pickName(fetched.metadata, nodeId), mime: pickMime(fetched.metadata) })); continue; } out.push({ id: nodeId, driveNodeId: nodeId, kind: "drive_file", name: pickName(fetched.metadata, nodeId), mime: pickMime(fetched.metadata), bytes: fetched.bytes, status: { code: "ok" } }); } catch (err) { const code = classifyError(err); const msg = err instanceof Error ? err.message : String(err); logger.debug( `[AttachmentResolver] resolveByDriveNodeIds failed driveId=${driveId} node=${nodeId} code=${code}: ${msg}` ); out.push(failed(code, { id: nodeId, driveNodeId: nodeId, kind: "drive_file", name: nodeId }, err)); } } return out; } async resolveBinding(b) { if (b.kind === "link" || b.kind === "github_repo") { return { id: b.id, driveNodeId: b.driveNodeId, kind: b.kind, name: b.linkUri ?? b.driveNodeId, uri: b.linkUri ?? void 0, status: { code: "ok" } }; } if (!b.driveId) { return failed("not-found", { id: b.id, driveNodeId: b.driveNodeId, kind: b.kind, name: `drive-node:${b.driveNodeId}` }); } const fetched = await this.drive.fetchAndDecrypt(b.driveId, b.driveNodeId); if (!fetched.bytes || fetched.bytes.length === 0) { return failed("dek-missing", { id: b.id, driveNodeId: b.driveNodeId, kind: b.kind, name: pickName(fetched.metadata, b.driveNodeId), mime: pickMime(fetched.metadata) }); } return { id: b.id, driveNodeId: b.driveNodeId, kind: b.kind, name: pickName(fetched.metadata, b.driveNodeId), mime: pickMime(fetched.metadata), bytes: fetched.bytes, status: { code: "ok" } }; } } const ALL_DIRS = /* @__PURE__ */ new Set(); let HOOKS_REGISTERED = false; function registerProcessHooks() { if (HOOKS_REGISTERED) return; HOOKS_REGISTERED = true; const cleanupAll = () => { for (const dir of ALL_DIRS) { try { require("node:fs").rmSync(dir, { recursive: true, force: true }); } catch { } } ALL_DIRS.clear(); }; process.on("exit", cleanupAll); process.on("SIGINT", () => { cleanupAll(); }); process.on("SIGTERM", () => { cleanupAll(); }); } function sanitizeName(name) { return name.replace(/[\\/\0]/g, "_").replace(/\s+/g, "_").slice(0, 128) || "file"; } class ScratchDir { constructor(sessionId, opts = {}) { this.sessionId = sessionId; const root = opts.workspaceDir ? join(opts.workspaceDir, ".consortium", "scratch") : join(tmpdir(), "consortium", "sessions"); this.base = join(root, sessionId, "attachments"); registerProcessHooks(); } base; ensured = false; async ensure() { if (this.ensured) return; await mkdir(this.base, { recursive: true, mode: 448 }); ALL_DIRS.add(this.base); this.ensured = true; } async write(name, bytes) { await this.ensure(); const safe = sanitizeName(name); const prefix = randomBytes(6).toString("hex"); const filePath = join(this.base, `${prefix}-${safe}`); await writeFile(filePath, bytes, { mode: 384 }); return filePath; } async cleanup() { if (!this.ensured) return; try { await rm(this.base, { recursive: true, force: true }); ALL_DIRS.delete(this.base); } catch (err) { logger.debug("[ScratchDir] cleanup failed:", err); } } } function pickStringArray(v) { return Array.isArray(v) ? v.filter((x) => typeof x === "string") : []; } function pickString(v) { return typeof v === "string" && v.length > 0 ? v : null; } function normalizeAttachmentEnvelope(message) { const m = message ?? {}; const c = m.content ?? {}; const driveId = pickString(c.driveId); const driveDek = pickString(c.driveDek) ?? pickString(m.driveDek); const messageLocalId = pickString(c.localId) ?? pickString(m.localId); const inlineIds = pickStringArray(c.driveNodeIds); const topLevelIds = pickStringArray(m.attachmentIds); const rawIds = inlineIds.length > 0 ? inlineIds : topLevelIds; const driveNodeIds = driveId ? rawIds : []; const bindingOnlyNodeIds = driveId ? [] : rawIds; return { messageLocalId, driveId, driveNodeIds, driveDek, bindingOnlyNodeIds }; } function attachmentsKillSwitchOff() { const v = (process.env.CONSORTIUM_ATTACHMENTS_V1 ?? "").toLowerCase(); return v === "0" || v === "false" || v === "off" || v === "no"; } export { AttachmentResolver as A, DriveClient as D, ScratchDir as S, attachmentsKillSwitchOff as a, normalizeAttachmentEnvelope as n, scanToolResultForMedia as s };