Orginize
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Load vendored `bare-discord-js` onto `ctx.bare.discordJS`.
|
||||
*
|
||||
* discord.js is CJS and remaps Node builtins through `bare-node-runtime` on Bare.
|
||||
* Do not ESM-import the package from the booter (its ESM entry uses `node:module`).
|
||||
*/
|
||||
|
||||
import { readFileSync } from '#host-fs'
|
||||
import { tryRequireFromBooter } from './bare-os-ctx-bare.js'
|
||||
import { bareOsHostBooterWarn } from './bare-os-host-booter-log.js'
|
||||
import { takePackedDiscordJs } from './bare-os-ctx-discord-registry.js'
|
||||
|
||||
/** @type {Record<string, unknown> | undefined} */
|
||||
let discordJsCache
|
||||
let discordJsTried = false
|
||||
/** @type {string} */
|
||||
let discordJsLastError = ''
|
||||
|
||||
/** Last load failure (empty when unused or successful). */
|
||||
export function getBareDiscordJsLoadError() {
|
||||
return discordJsLastError
|
||||
}
|
||||
|
||||
function noteDiscordLoadError(err) {
|
||||
const msg =
|
||||
err && typeof err === 'object' && 'message' in err
|
||||
? String(/** @type {{ message?: unknown }} */ (err).message || err)
|
||||
: String(err || 'unknown error')
|
||||
discordJsLastError = msg.slice(0, 800)
|
||||
return discordJsLastError
|
||||
}
|
||||
|
||||
function bareRuntime() {
|
||||
if (typeof globalThis.Bare !== 'undefined') return true
|
||||
const v = globalThis.process?.versions
|
||||
return Boolean(v && typeof v.bare === 'string')
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord rejects tokens with trailing newlines, BOM, or copy-paste invisibles.
|
||||
* @param {unknown} raw
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeDiscordToken(raw) {
|
||||
if (typeof raw !== 'string') return ''
|
||||
let t = raw.trim()
|
||||
if (t.charCodeAt(0) === 0xfeff) t = t.slice(1)
|
||||
t = t.replace(/[\u200B-\u200D\uFEFF]/g, '')
|
||||
return t.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} line
|
||||
* @returns {{ key: string, value: string } | null}
|
||||
*/
|
||||
export function parseDotEnvLine(line) {
|
||||
let s = String(line)
|
||||
.replace(/^\uFEFF/, '')
|
||||
.trim()
|
||||
if (!s || s.startsWith('#')) return null
|
||||
if (s.startsWith('export ')) s = s.slice(7).trim()
|
||||
const eq = s.indexOf('=')
|
||||
if (eq === -1) return null
|
||||
const key = s.slice(0, eq).trim()
|
||||
if (!key) return null
|
||||
let val = s.slice(eq + 1).trim()
|
||||
if (
|
||||
(val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))
|
||||
) {
|
||||
val = val.slice(1, -1)
|
||||
}
|
||||
return { key, value: val }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
export function parseDotEnvText(text) {
|
||||
/** @type {Record<string, string>} */
|
||||
const out = {}
|
||||
if (typeof text !== 'string' || !text) return out
|
||||
let body = text
|
||||
if (body.charCodeAt(0) === 0xfeff) body = body.slice(1)
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const parsed = parseDotEnvLine(line)
|
||||
if (!parsed) continue
|
||||
out[parsed.key] = parsed.value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy host Discord token / env-file settings into the guest session.
|
||||
* When `DISCORD_ENV_FILE` (or `BARE_OS_DISCORD_ENV_FILE`) is a **host** path,
|
||||
* read it here so the guest does not need host filesystem access.
|
||||
*
|
||||
* @param {Record<string, string | undefined> | NodeJS.ProcessEnv | null | undefined} hostEnv
|
||||
* @param {Record<string, string>} shellEnv
|
||||
*/
|
||||
export function applyDiscordHostEnvToShellEnv(hostEnv, shellEnv) {
|
||||
if (!hostEnv || !shellEnv) return
|
||||
for (const k of [
|
||||
'BARE_OS_DISCORD',
|
||||
'DISCORD_TOKEN',
|
||||
'DISCORD_GUILD_ID',
|
||||
'DISCORD_ID_WHITELIST',
|
||||
'DISCORD_USER_INSTALL',
|
||||
'DISCORD_ENV_FILE',
|
||||
'BARE_OS_DISCORD_ENV_FILE'
|
||||
]) {
|
||||
const v = hostEnv[k]
|
||||
if (v != null && String(v).trim()) shellEnv[k] = String(v)
|
||||
}
|
||||
|
||||
const envFile = String(
|
||||
hostEnv.DISCORD_ENV_FILE || hostEnv.BARE_OS_DISCORD_ENV_FILE || ''
|
||||
).trim()
|
||||
if (!envFile) return
|
||||
try {
|
||||
const parsed = parseDotEnvText(readFileSync(envFile, 'utf8'))
|
||||
const token = normalizeDiscordToken(parsed.DISCORD_TOKEN || '')
|
||||
if (token && !String(shellEnv.DISCORD_TOKEN || '').trim()) {
|
||||
shellEnv.DISCORD_TOKEN = token
|
||||
}
|
||||
const guild = String(parsed.DISCORD_GUILD_ID || '').trim()
|
||||
if (guild && !String(shellEnv.DISCORD_GUILD_ID || '').trim()) {
|
||||
shellEnv.DISCORD_GUILD_ID = guild
|
||||
}
|
||||
const whitelist = String(parsed.DISCORD_ID_WHITELIST || '').trim()
|
||||
if (whitelist && !String(shellEnv.DISCORD_ID_WHITELIST || '').trim()) {
|
||||
shellEnv.DISCORD_ID_WHITELIST = whitelist
|
||||
}
|
||||
const userInstall = String(
|
||||
parsed.DISCORD_USER_INSTALL || parsed.BARE_OS_DISCORD_USER_INSTALL || ''
|
||||
).trim()
|
||||
if (userInstall && !String(shellEnv.DISCORD_USER_INSTALL || '').trim()) {
|
||||
shellEnv.DISCORD_USER_INSTALL = userInstall
|
||||
}
|
||||
} catch {
|
||||
// Guest may still read the same path from the VFS (`--env`).
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapDiscordModule(mod) {
|
||||
if (!mod || typeof mod !== 'object') return undefined
|
||||
const direct = /** @type {Record<string, unknown>} */ (mod)
|
||||
if (typeof direct.Client === 'function') return direct
|
||||
const def = direct.default
|
||||
if (def && typeof def === 'object' && typeof def.Client === 'function') {
|
||||
return /** @type {Record<string, unknown>} */ (def)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function applyBareProcessEmitWarning() {
|
||||
const proc = globalThis.process
|
||||
if (!proc || typeof proc.emitWarning === 'function') return
|
||||
proc.emitWarning = function emitWarning(warning, type, code) {
|
||||
const msg = warning instanceof Error ? warning.message : String(warning)
|
||||
const name = typeof type === 'string' ? type : 'Warning'
|
||||
const id = typeof code === 'string' ? code : ''
|
||||
const line = id ? name + ' [' + id + ']: ' + msg : name + ': ' + msg
|
||||
try {
|
||||
if (typeof proc.emit === 'function') proc.emit('warning', warning)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
console.error(line)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyBareTlsCompat() {
|
||||
const proc = globalThis.process
|
||||
if (!proc || !proc.env) return
|
||||
if (!proc.env.NODE_TLS_REJECT_UNAUTHORIZED) {
|
||||
proc.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
|
||||
}
|
||||
try {
|
||||
const req = globalThis.require
|
||||
if (typeof req !== 'function') return
|
||||
const bareHttps = req('bare-https')
|
||||
if (!bareHttps || typeof bareHttps.Agent !== 'function') return
|
||||
const insecureAgent = new bareHttps.Agent({ rejectUnauthorized: false })
|
||||
bareHttps.globalAgent = insecureAgent
|
||||
if (bareHttps.Agent) bareHttps.Agent.global = insecureAgent
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown> | null} [_ctx]
|
||||
* @returns {Promise<Record<string, unknown> | undefined>}
|
||||
*/
|
||||
export async function loadBareDiscordJs(_ctx) {
|
||||
void _ctx
|
||||
if (discordJsTried) return discordJsCache
|
||||
discordJsTried = true
|
||||
discordJsLastError = ''
|
||||
if (bareRuntime()) applyBareTlsCompat()
|
||||
applyBareProcessEmitWarning()
|
||||
|
||||
/** @type {unknown} */
|
||||
let lastErr = null
|
||||
|
||||
try {
|
||||
const packed = unwrapDiscordModule(takePackedDiscordJs())
|
||||
if (packed) {
|
||||
discordJsCache = packed
|
||||
return discordJsCache
|
||||
}
|
||||
} catch (err) {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
try {
|
||||
const mod = await tryRequireFromBooter('bare-discord-js')
|
||||
const discord = unwrapDiscordModule(mod)
|
||||
if (discord) {
|
||||
discordJsCache = discord
|
||||
return discordJsCache
|
||||
}
|
||||
if (!lastErr) {
|
||||
lastErr = new Error(
|
||||
String(import.meta.url || '').startsWith('bare:')
|
||||
? 'standalone pack did not register discord.js (bare-os-ctx-discord-packed.js)'
|
||||
: 'require("bare-discord-js") returned no Client'
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
lastErr = err
|
||||
}
|
||||
|
||||
const detail = noteDiscordLoadError(
|
||||
lastErr || 'vendored bare-discord-js unresolved'
|
||||
)
|
||||
bareOsHostBooterWarn(
|
||||
'ctx_bare_discord_js_load_failed',
|
||||
'ctx.bare.discordJS: failed to load vendored bare-discord-js',
|
||||
detail
|
||||
)
|
||||
discordJsCache = undefined
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* SSH PTY console newline helpers (no bare-ssh2 import — safe for brittle-node tests).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize embedded newlines to CRLF for SSH PTY output (multiline `console.log`, e.g. `man`).
|
||||
* @param {unknown} s
|
||||
* @returns {string}
|
||||
*/
|
||||
export function bareOpensshFormatConsoleTextForPty(s) {
|
||||
return String(s).replace(/\r?\n/g, '\r\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('stream').Duplex} stream
|
||||
* @param {unknown[]} args
|
||||
*/
|
||||
export function bareOpensshWritePtyConsoleLine(stream, args) {
|
||||
const body = args.map(String).join(' ')
|
||||
const normalized = bareOpensshFormatConsoleTextForPty(body)
|
||||
const out = normalized.endsWith('\r\n') ? normalized : normalized + '\r\n'
|
||||
if (stream.writable) stream.write(out)
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* Minimal SFTP over Bare OS VFS (bare-ssh2 SFTP server).
|
||||
* Readable paths match interactive shell / VFS (home, /mnt, system image, pseudo, …).
|
||||
* Writes are denied on typical read-only system prefixes; other paths defer to VFS ACLs.
|
||||
*/
|
||||
import {
|
||||
S_IFDIR,
|
||||
S_IFLNK,
|
||||
S_IFREG,
|
||||
formatLsMtime,
|
||||
formatModeString
|
||||
} from './vfs-posix-meta.js'
|
||||
|
||||
/**
|
||||
* @typedef {{ type: string, mode?: number, size?: number, uid?: number, gid?: number, user?: string, group?: string, mtimeMs?: number }} VfsStatRow
|
||||
*/
|
||||
|
||||
/**
|
||||
* Logical paths visible over SFTP (parity with shell `createVfs` routing).
|
||||
* @param {string} abs resolved absolute logical path
|
||||
* @param {string} homeLogical e.g. /home/guest
|
||||
*/
|
||||
export function bareOsSftpAllowedLogicalPath(abs, homeLogical) {
|
||||
const h = String(homeLogical || '/').replace(/\/+$/, '') || '/'
|
||||
const a = String(abs || '').replace(/\/+/g, '/') || '/'
|
||||
if (a === h || a.startsWith(h + '/')) return true
|
||||
/** @type {string[]} */
|
||||
const roots = [
|
||||
'/mnt',
|
||||
'/media',
|
||||
'/mount',
|
||||
'/mirror',
|
||||
'/bin',
|
||||
'/boot',
|
||||
'/lib',
|
||||
'/etc',
|
||||
'/usr',
|
||||
'/var',
|
||||
'/proc',
|
||||
'/sys',
|
||||
'/dev',
|
||||
'/run',
|
||||
'/tmp',
|
||||
'/snapshots',
|
||||
'/root',
|
||||
'/home',
|
||||
'/opt'
|
||||
]
|
||||
for (const pre of roots) {
|
||||
if (a === pre || a.startsWith(pre + '/')) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Deny OPEN when the client requests mutating access on typical system-image paths.
|
||||
* Personal home, /tmp, /var, /root, and /mnt are left to VFS (mounts may be writable).
|
||||
* @param {string} abs
|
||||
* @param {string} homeLogical
|
||||
* @param {number} flags SFTP OPEN_MODE bitmask
|
||||
* @param {{ READ: number, WRITE: number, APPEND: number, CREAT: number, TRUNC: number }} OPEN_MODE
|
||||
*/
|
||||
export function bareOsSftpDeniesWriteOpen(abs, homeLogical, flags, OPEN_MODE) {
|
||||
const h = String(homeLogical || '/').replace(/\/+$/, '') || '/'
|
||||
const a = String(abs || '').replace(/\/+/g, '/') || '/'
|
||||
const wantMutate =
|
||||
!!(flags & OPEN_MODE.WRITE) ||
|
||||
!!(flags & OPEN_MODE.APPEND) ||
|
||||
!!(flags & OPEN_MODE.CREAT) ||
|
||||
!!(flags & OPEN_MODE.TRUNC)
|
||||
if (!wantMutate) return false
|
||||
if (a === h || a.startsWith(h + '/')) return false
|
||||
if (a === '/tmp' || a.startsWith('/tmp/')) return false
|
||||
if (a === '/var' || a.startsWith('/var/')) return false
|
||||
if (a === '/root' || a.startsWith('/root/')) return false
|
||||
const readOnlyRoots = [
|
||||
'/bin',
|
||||
'/boot',
|
||||
'/lib',
|
||||
'/etc',
|
||||
'/usr',
|
||||
'/proc',
|
||||
'/sys',
|
||||
'/dev',
|
||||
'/run',
|
||||
'/snapshots',
|
||||
'/opt',
|
||||
'/mirror'
|
||||
]
|
||||
for (const pre of readOnlyRoots) {
|
||||
if (a === pre || a.startsWith(pre + '/')) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {VfsStatRow} st
|
||||
* @param {number} [sizeOverride]
|
||||
*/
|
||||
function vfsStatToSftpAttrs(st, sizeOverride) {
|
||||
const t = st.type
|
||||
const isDir = t === 'directory'
|
||||
const isLink = t === 'symlink'
|
||||
const mode =
|
||||
(isDir ? S_IFDIR : isLink ? S_IFLNK : S_IFREG) |
|
||||
((typeof st.mode === 'number' ? st.mode : 0) & 0o777)
|
||||
const sz =
|
||||
sizeOverride !== undefined && sizeOverride !== null
|
||||
? Number(sizeOverride)
|
||||
: Number(st.size || 0)
|
||||
const mtimeMs =
|
||||
typeof st.mtimeMs === 'number' && Number.isFinite(st.mtimeMs)
|
||||
? st.mtimeMs
|
||||
: Date.now()
|
||||
return {
|
||||
mode,
|
||||
uid: typeof st.uid === 'number' ? st.uid : 1000,
|
||||
gid: typeof st.gid === 'number' ? st.gid : 1000,
|
||||
size: sz,
|
||||
atime: mtimeMs,
|
||||
mtime: mtimeMs
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {VfsStatRow} st
|
||||
* @param {string} filename
|
||||
* @param {number} [sizeOverride]
|
||||
*/
|
||||
function vfsStatToSftpLongname(st, filename, sizeOverride) {
|
||||
const t = st.type
|
||||
const modeStr = formatModeString(
|
||||
typeof st.mode === 'number' ? st.mode : 0o644,
|
||||
t === 'directory' ? 'directory' : t === 'symlink' ? 'symlink' : 'file'
|
||||
)
|
||||
const u = String(st.user || 'user')
|
||||
const g = String(st.group || 'user')
|
||||
const sz =
|
||||
sizeOverride !== undefined && sizeOverride !== null
|
||||
? Number(sizeOverride)
|
||||
: Number(st.size || 0)
|
||||
const mtimeMs =
|
||||
typeof st.mtimeMs === 'number' && Number.isFinite(st.mtimeMs)
|
||||
? st.mtimeMs
|
||||
: Date.now()
|
||||
const mt = formatLsMtime(mtimeMs)
|
||||
return `${modeStr} 1 ${u} ${g} ${String(sz).padStart(8, ' ')} ${mt} ${filename}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dirAbs
|
||||
* @param {string} name
|
||||
*/
|
||||
function joinDirEntry(dirAbs, name) {
|
||||
const d = String(dirAbs || '/').replace(/\/+$/, '') || '/'
|
||||
const n = String(name || '')
|
||||
if (!n) return d
|
||||
return d === '/' ? '/' + n : d + '/' + n
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce vfs.readFile results for SFTP OPEN/READ. Empty string is falsy and used to make
|
||||
* READ return SSH_FX_FAILURE; ArrayBuffer and non-Buffer views may lack `.subarray`.
|
||||
* @param {unknown} x
|
||||
* @returns {Buffer | null}
|
||||
*/
|
||||
function normalizeVfsBytesForSftp(x) {
|
||||
if (x == null) return null
|
||||
if (typeof x === 'string') return Buffer.from(x, 'utf8')
|
||||
if (Buffer.isBuffer(x)) return x
|
||||
if (x instanceof ArrayBuffer) return Buffer.from(x)
|
||||
if (ArrayBuffer.isView(x))
|
||||
return Buffer.from(x.buffer, x.byteOffset, x.byteLength)
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* SFTP inbound handles come from bare-ssh2 bufferSlice → often Uint8Array / safer-buffer views
|
||||
* that do not satisfy `Buffer.isBuffer` (see vendor utils.js bufferSlice comment).
|
||||
* @param {unknown} h
|
||||
* @returns {number | null}
|
||||
*/
|
||||
function sftpHandleId(h) {
|
||||
if (h == null) return null
|
||||
if (Buffer.isBuffer(h) && h.length >= 4) return h.readUInt32BE(0)
|
||||
if (ArrayBuffer.isView(h) && h.byteLength >= 4) {
|
||||
return new DataView(h.buffer, h.byteOffset, 4).getUint32(0, false)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('events').EventEmitter} sftp
|
||||
* @param {Record<string, unknown>} vfs
|
||||
* @param {string} homeLogical absolute logical home (e.g. /home/guest)
|
||||
* @param {(p: string) => string} resolveLogical
|
||||
* @param {{ OPEN_MODE: Record<string, number>, STATUS_CODE: Record<string, number> }} sftpConsts
|
||||
*/
|
||||
export function attachBareOsSftp(
|
||||
sftp,
|
||||
vfs,
|
||||
homeLogical,
|
||||
resolveLogical,
|
||||
sftpConsts
|
||||
) {
|
||||
const { OPEN_MODE, STATUS_CODE } = sftpConsts
|
||||
/** @type {Map<number, { path: string, buf?: Buffer | null, pos: number, write?: boolean }>} */
|
||||
const handles = new Map()
|
||||
/** @type {Map<number, { path: string, entries?: string[], idx: number }>} */
|
||||
const dirs = new Map()
|
||||
/** Single counter so directory and file handles never share an id */
|
||||
let nextHandleId = 1
|
||||
|
||||
/**
|
||||
* Strict SFTP clients (e.g. FileZilla) expect outbound replies in the same order as
|
||||
* requests were received; overlapping async handlers otherwise reorder packets and
|
||||
* trigger "request ID mismatch". After serializing replies here, a rejected handler
|
||||
* that sent no packet could stall clients; thrown errors during SFTP encode have been
|
||||
* seen as native `bad_optional_access` (-fno-exceptions) killing sshd — always emit
|
||||
* STATUS FAILURE on handler failure.
|
||||
*/
|
||||
/** @type {Promise<void>} */
|
||||
let sftpReplyChain = Promise.resolve()
|
||||
|
||||
/**
|
||||
* @param {number} reqid
|
||||
* @param {() => void | Promise<void>} fn
|
||||
*/
|
||||
function enqueueSftpReply(reqid, fn) {
|
||||
const next = sftpReplyChain.then(async () => {
|
||||
try {
|
||||
await Promise.resolve().then(fn)
|
||||
} catch {
|
||||
try {
|
||||
sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
sftpReplyChain = next.catch(() => {})
|
||||
}
|
||||
|
||||
function ok(reqid) {
|
||||
sftp.status(reqid, STATUS_CODE.OK)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} p
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function mapPath(p) {
|
||||
const norm = String(p || '').replace(/\\/g, '/').replace(/\/+/g, '/')
|
||||
let logical
|
||||
if (norm === '.' || norm === '') logical = homeLogical
|
||||
else if (norm.startsWith('/')) logical = resolveLogical(norm)
|
||||
else logical = resolveLogical(`${homeLogical}/${norm}`.replace(/\/+/g, '/'))
|
||||
const abs = logical
|
||||
if (!bareOsSftpAllowedLogicalPath(abs, homeLogical)) return null
|
||||
return abs
|
||||
}
|
||||
|
||||
async function statLike(reqid, p, _isLstat) {
|
||||
const abs = mapPath(p)
|
||||
if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
|
||||
try {
|
||||
const st = await vfs.lstat(abs)
|
||||
if (!st) return sftp.status(reqid, STATUS_CODE.NO_SUCH_FILE)
|
||||
const attrs = vfsStatToSftpAttrs(/** @type {VfsStatRow} */ (st), undefined)
|
||||
sftp.attrs(reqid, attrs)
|
||||
} catch {
|
||||
sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} abs already-resolved logical path (no mapPath)
|
||||
*/
|
||||
async function statAbsolute(reqid, abs, sizeOverride) {
|
||||
try {
|
||||
const st = await vfs.lstat(abs)
|
||||
if (!st) return sftp.status(reqid, STATUS_CODE.NO_SUCH_FILE)
|
||||
const attrs = vfsStatToSftpAttrs(/** @type {VfsStatRow} */ (st), sizeOverride)
|
||||
sftp.attrs(reqid, attrs)
|
||||
} catch {
|
||||
sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
}
|
||||
}
|
||||
|
||||
sftp.on('REALPATH', (reqid, p) => {
|
||||
enqueueSftpReply(reqid, async () => {
|
||||
const abs = mapPath(p) || homeLogical
|
||||
try {
|
||||
const st = await vfs.lstat(abs)
|
||||
const attrs = st
|
||||
? vfsStatToSftpAttrs(/** @type {VfsStatRow} */ (st), undefined)
|
||||
: {}
|
||||
const name = [
|
||||
{
|
||||
filename: abs,
|
||||
longname: st
|
||||
? vfsStatToSftpLongname(
|
||||
/** @type {VfsStatRow} */ (st),
|
||||
abs.split('/').pop() || abs,
|
||||
undefined
|
||||
)
|
||||
: 'drwxr-xr-x 1 user user 0 Jan 1 00:00 ' + abs,
|
||||
attrs
|
||||
}
|
||||
]
|
||||
sftp.name(reqid, name)
|
||||
} catch {
|
||||
sftp.name(reqid, [
|
||||
{
|
||||
filename: abs,
|
||||
longname: 'drwxr-xr-x 1 user user 0 Jan 1 00:00 ' + abs,
|
||||
attrs: {}
|
||||
}
|
||||
])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
sftp.on('STAT', (reqid, p) => {
|
||||
enqueueSftpReply(reqid, () => statLike(reqid, p, false))
|
||||
})
|
||||
sftp.on('LSTAT', (reqid, p) => {
|
||||
enqueueSftpReply(reqid, () => statLike(reqid, p, true))
|
||||
})
|
||||
|
||||
sftp.on('OPENDIR', (reqid, p) => {
|
||||
enqueueSftpReply(reqid, async () => {
|
||||
const abs = mapPath(p)
|
||||
if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
|
||||
try {
|
||||
const list = await vfs.readdir(abs)
|
||||
if (!Array.isArray(list)) return sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
const h = Buffer.alloc(4)
|
||||
const id = nextHandleId++
|
||||
h.writeUInt32BE(id, 0)
|
||||
dirs.set(id, { path: abs, entries: list, idx: 0 })
|
||||
sftp.handle(reqid, h)
|
||||
} catch {
|
||||
sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
sftp.on('READDIR', (reqid, handle) => {
|
||||
enqueueSftpReply(reqid, async () => {
|
||||
const id = sftpHandleId(handle)
|
||||
if (id == null) return sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
const d = dirs.get(id)
|
||||
if (!d || !d.entries) return sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
if (d.idx >= d.entries.length) return sftp.status(reqid, STATUS_CODE.EOF)
|
||||
const chunk = d.entries.slice(d.idx, d.idx + 32)
|
||||
d.idx += chunk.length
|
||||
const names = []
|
||||
for (const ent of chunk) {
|
||||
const childAbs = joinDirEntry(d.path, ent)
|
||||
try {
|
||||
const st = await vfs.lstat(childAbs)
|
||||
if (!st) {
|
||||
names.push({
|
||||
filename: ent,
|
||||
longname: '-rw-r--r-- 1 user user 0 Jan 1 00:00 ' + ent,
|
||||
attrs: {}
|
||||
})
|
||||
continue
|
||||
}
|
||||
const bl = /** @type {VfsStatRow} */ (st)
|
||||
names.push({
|
||||
filename: ent,
|
||||
longname: vfsStatToSftpLongname(bl, ent, undefined),
|
||||
attrs: vfsStatToSftpAttrs(bl, undefined)
|
||||
})
|
||||
} catch {
|
||||
names.push({
|
||||
filename: ent,
|
||||
longname: '-rw-r--r-- 1 user user 0 Jan 1 00:00 ' + ent,
|
||||
attrs: {}
|
||||
})
|
||||
}
|
||||
}
|
||||
sftp.name(reqid, names)
|
||||
})
|
||||
})
|
||||
|
||||
sftp.on('OPEN', (reqid, filename, flags) => {
|
||||
enqueueSftpReply(reqid, async () => {
|
||||
const abs = mapPath(filename)
|
||||
if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
|
||||
if (bareOsSftpDeniesWriteOpen(abs, homeLogical, flags, OPEN_MODE)) {
|
||||
return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
|
||||
}
|
||||
const wantRead = !!(flags & OPEN_MODE.READ)
|
||||
const wantWrite =
|
||||
!!(flags & OPEN_MODE.WRITE) || !!(flags & OPEN_MODE.APPEND)
|
||||
const wantCreat = !!(flags & OPEN_MODE.CREAT)
|
||||
try {
|
||||
let buf = null
|
||||
if (!wantCreat) {
|
||||
try {
|
||||
buf = await vfs.readFile(abs)
|
||||
} catch {
|
||||
buf = null
|
||||
}
|
||||
}
|
||||
buf = normalizeVfsBytesForSftp(buf)
|
||||
if (buf == null && !wantCreat && wantRead) {
|
||||
return sftp.status(reqid, STATUS_CODE.NO_SUCH_FILE)
|
||||
}
|
||||
if (buf == null && wantCreat) buf = Buffer.alloc(0)
|
||||
if (buf == null) buf = Buffer.alloc(0)
|
||||
const h = Buffer.alloc(4)
|
||||
const id = nextHandleId++
|
||||
h.writeUInt32BE(id, 0)
|
||||
handles.set(id, {
|
||||
path: abs,
|
||||
buf,
|
||||
pos: 0,
|
||||
write: wantWrite || wantCreat
|
||||
})
|
||||
sftp.handle(reqid, h)
|
||||
} catch {
|
||||
sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
sftp.on('READ', (reqid, handle, offset, length) => {
|
||||
enqueueSftpReply(reqid, () => {
|
||||
try {
|
||||
const id = sftpHandleId(handle)
|
||||
if (id == null) return sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
const f = handles.get(id)
|
||||
if (!f || f.buf == null) return sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
const raw = f.buf
|
||||
const body =
|
||||
Buffer.isBuffer(raw) || ArrayBuffer.isView(raw)
|
||||
? raw
|
||||
: typeof raw === 'string'
|
||||
? Buffer.from(raw, 'utf8')
|
||||
: null
|
||||
if (!body) return sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
const off = Number(offset)
|
||||
const len = Number(length)
|
||||
if (!Number.isFinite(off) || !Number.isFinite(len)) {
|
||||
return sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
}
|
||||
const end = Math.min(off + len, body.length)
|
||||
if (off >= body.length) return sftp.status(reqid, STATUS_CODE.EOF)
|
||||
let slice = body.subarray(off, end)
|
||||
if (!Buffer.isBuffer(slice)) {
|
||||
slice = Buffer.from(slice.buffer, slice.byteOffset, slice.byteLength)
|
||||
}
|
||||
sftp.data(reqid, slice)
|
||||
} catch {
|
||||
sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
sftp.on('FSTAT', (reqid, handle) => {
|
||||
enqueueSftpReply(reqid, async () => {
|
||||
const id = sftpHandleId(handle)
|
||||
if (id == null) return sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
const f = handles.get(id)
|
||||
if (f) {
|
||||
const hint = f.buf ? f.buf.length : undefined
|
||||
return statAbsolute(reqid, f.path, hint)
|
||||
}
|
||||
const d = dirs.get(id)
|
||||
if (d && d.path) return statAbsolute(reqid, d.path, undefined)
|
||||
sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
})
|
||||
})
|
||||
|
||||
sftp.on('WRITE', (reqid, handle, offset, data) => {
|
||||
enqueueSftpReply(reqid, async () => {
|
||||
const id = sftpHandleId(handle)
|
||||
if (id == null) return sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
const f = handles.get(id)
|
||||
if (!f || !f.write) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
|
||||
try {
|
||||
const u8 = data instanceof Uint8Array ? data : new Uint8Array(data)
|
||||
const prev = f.buf ? new Uint8Array(f.buf) : new Uint8Array(0)
|
||||
const need = offset + u8.length
|
||||
const out = new Uint8Array(Math.max(prev.length, need))
|
||||
out.set(prev)
|
||||
out.set(u8, offset)
|
||||
f.buf = out
|
||||
await vfs.writeFile(f.path, out)
|
||||
ok(reqid)
|
||||
} catch {
|
||||
sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
sftp.on('CLOSE', (reqid, handle) => {
|
||||
enqueueSftpReply(reqid, () => {
|
||||
const id = sftpHandleId(handle)
|
||||
if (id == null) return sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
handles.delete(id)
|
||||
dirs.delete(id)
|
||||
ok(reqid)
|
||||
})
|
||||
})
|
||||
|
||||
sftp.on('REMOVE', (reqid, p) => {
|
||||
enqueueSftpReply(reqid, async () => {
|
||||
const abs = mapPath(p)
|
||||
if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
|
||||
try {
|
||||
await vfs.unlink(abs)
|
||||
ok(reqid)
|
||||
} catch {
|
||||
sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
sftp.on('MKDIR', (reqid, p) => {
|
||||
enqueueSftpReply(reqid, async () => {
|
||||
const abs = mapPath(p)
|
||||
if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
|
||||
try {
|
||||
await vfs.mkdir(abs, { recursive: true })
|
||||
ok(reqid)
|
||||
} catch {
|
||||
sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
sftp.on('RMDIR', (reqid, p) => {
|
||||
enqueueSftpReply(reqid, async () => {
|
||||
const abs = mapPath(p)
|
||||
if (!abs) return sftp.status(reqid, STATUS_CODE.PERMISSION_DENIED)
|
||||
try {
|
||||
await vfs.rmdir(abs)
|
||||
ok(reqid)
|
||||
} catch {
|
||||
sftp.status(reqid, STATUS_CODE.FAILURE)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
sftp.on('SETSTAT', (reqid) => {
|
||||
enqueueSftpReply(reqid, () => {
|
||||
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
|
||||
})
|
||||
})
|
||||
|
||||
sftp.on('FSETSTAT', (reqid) => {
|
||||
enqueueSftpReply(reqid, () => {
|
||||
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
|
||||
})
|
||||
})
|
||||
|
||||
sftp.on('EXTENDED', (reqid) => {
|
||||
enqueueSftpReply(reqid, () => {
|
||||
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
|
||||
})
|
||||
})
|
||||
|
||||
sftp.on('RENAME', (reqid) => {
|
||||
enqueueSftpReply(reqid, () => {
|
||||
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
|
||||
})
|
||||
})
|
||||
|
||||
sftp.on('READLINK', (reqid) => {
|
||||
enqueueSftpReply(reqid, () => {
|
||||
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
|
||||
})
|
||||
})
|
||||
sftp.on('SYMLINK', (reqid) => {
|
||||
enqueueSftpReply(reqid, () => {
|
||||
sftp.status(reqid, STATUS_CODE.OP_UNSUPPORTED)
|
||||
})
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Initd marker for swarm chat (`bare-os-chat-v1`): transport stays in the booter/swarm-disk;
|
||||
* this unit records that chat is intended to be active when env allows.
|
||||
*/
|
||||
import { registerBareService } from './bare-initd.js'
|
||||
import { bareOsChatMuxEnabled } from './bare-os-chat-service.js'
|
||||
|
||||
/** @type {boolean} */
|
||||
let bareOsChatInitdRegistered = false
|
||||
|
||||
/** @param {Record<string, string | undefined>} env */
|
||||
export function bareOsChatInitdEnabled(env) {
|
||||
if (!bareOsChatMuxEnabled(env)) return false
|
||||
const v = env.BARE_OS_CHAT_INITD
|
||||
if (v === '0' || v === 'false') return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
async function startBareOsChatInitd(ctx) {
|
||||
if (!bareOsChatMuxEnabled(globalThis.process?.env)) return
|
||||
ctx.console?.log?.(
|
||||
'[bare-os-chat] Protomux bare-os-chat-v1 enabled (join general on peer connect)'
|
||||
)
|
||||
}
|
||||
|
||||
async function stopBareOsChatInitd(_ctx) {}
|
||||
|
||||
/** @param {Record<string, string | undefined>} env */
|
||||
export function maybeRegisterBareOsChatInitd(env) {
|
||||
if (bareOsChatInitdRegistered) return
|
||||
if (!bareOsChatInitdEnabled(env || {})) return
|
||||
bareOsChatInitdRegistered = true
|
||||
registerBareService({
|
||||
name: 'bare-os-chat',
|
||||
description:
|
||||
'Swarm chat (bare-os-chat-v1); stock default on — set BARE_OS_PROTOMUX_CHAT_CHANNEL=0 or BARE_OS_CHAT_INITD=0 to disable',
|
||||
start: startBareOsChatInitd,
|
||||
stop: stopBareOsChatInitd
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import b4a from 'b4a'
|
||||
import { randomBytes } from 'bare-crypto'
|
||||
import {
|
||||
setupBareOsChatChannel,
|
||||
BARE_OS_CHAT_WIRE_SCHEMA_VERSION,
|
||||
BARE_OS_CHAT_EVT_TEXT,
|
||||
PROTOCOL_CHAT_CHANNEL_NAME,
|
||||
bareOsProtMuxChatChannelEnabled
|
||||
} from 'bare-os-protocol'
|
||||
|
||||
/** @typedef {{ chan: import('protomux').Channel, mux: import('protomux').Protomux, socket: any, id: string | null, chatChan?: import('protomux').Channel | null }} SwarmPeer */
|
||||
|
||||
/**
|
||||
* Stock default: swarm chat is **on** unless disabled via env (see `bare-os-protocol` `bareOsProtMuxChatChannelEnabled`).
|
||||
* @param {Record<string, string | undefined>} [env]
|
||||
*/
|
||||
export function bareOsChatMuxEnabled(env = globalThis.process?.env) {
|
||||
return bareOsProtMuxChatChannelEnabled(env || {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Swarm Protomux chat transport on `disk` (receive + relay + broadcast). Stock-on when mux env allows.
|
||||
* Independent of the **`bare-os-chat`** initd unit (that unit is only started after identity unlock).
|
||||
*
|
||||
* @param {import('./swarm-disk.js').SwarmDisk} disk
|
||||
* @param {Record<string, string | undefined>} [env]
|
||||
*/
|
||||
export function ensureDiskBareOsChatTransport(disk, env = {}) {
|
||||
if (!disk || !bareOsChatMuxEnabled(globalThis.process?.env)) return
|
||||
const merged = {
|
||||
.../** @type {Record<string, string | undefined>} */ (
|
||||
globalThis.process?.env || {}
|
||||
),
|
||||
...env
|
||||
}
|
||||
disk.bareOsChatService = createBareOsChatService({ env: merged })
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
function chatGossipTtlDefault(env) {
|
||||
const raw = String(env.BARE_OS_CHAT_GOSSIP_TTL ?? '').trim()
|
||||
const n = raw ? Number.parseInt(raw, 10) : NaN
|
||||
if (Number.isFinite(n) && n >= 0 && n <= 32) return n
|
||||
return 4
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
function chatHistoryMax(env) {
|
||||
const raw = String(env.BARE_OS_CHAT_HISTORY_MAX ?? '').trim()
|
||||
const n = raw ? Number.parseInt(raw, 10) : NaN
|
||||
if (Number.isFinite(n) && n >= 16 && n <= 10000) return n
|
||||
return 512
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
function chatMaxBodyBytes(env) {
|
||||
const raw = String(env.BARE_OS_CHAT_MAX_BODY_BYTES ?? '').trim()
|
||||
const n = raw ? Number.parseInt(raw, 10) : NaN
|
||||
if (Number.isFinite(n) && n >= 256 && n <= 65536) return n
|
||||
return 4096
|
||||
}
|
||||
|
||||
/**
|
||||
* Global swarm chat (host-side): fan-out, gossip, dedupe, metrics.
|
||||
*/
|
||||
export function createBareOsChatService(opts = {}) {
|
||||
const env = opts.env || globalThis.process?.env || {}
|
||||
const gossipTtlMax = chatGossipTtlDefault(
|
||||
/** @type {Record<string, string | undefined>} */ (env)
|
||||
)
|
||||
const historyMax = chatHistoryMax(
|
||||
/** @type {Record<string, string | undefined>} */ (env)
|
||||
)
|
||||
const maxBodyBytes = chatMaxBodyBytes(
|
||||
/** @type {Record<string, string | undefined>} */ (env)
|
||||
)
|
||||
|
||||
/** @type {Set<(ev: Record<string, unknown>) => void>} */
|
||||
const subscribers = new Set()
|
||||
/** @type {Map<string, number>} evt dedupe -> expireAtMs */
|
||||
const seenEvt = new Map()
|
||||
/** @type {Array<Record<string, unknown>>} */
|
||||
const history = []
|
||||
/** @type {{ rxEvent: number, txEvent: number, droppedRate: number, droppedVerify: number }} */
|
||||
const metrics = {
|
||||
rxEvent: 0,
|
||||
txEvent: 0,
|
||||
droppedRate: 0,
|
||||
droppedVerify: 0
|
||||
}
|
||||
/** @type {Map<string, { tokens: number, resetAt: number }>} */
|
||||
const ratePeer = new Map()
|
||||
const RATE_WINDOW_MS = 2000
|
||||
const RATE_MAX_MSG = 24
|
||||
|
||||
function trimDedupe() {
|
||||
const now = Date.now()
|
||||
for (const [k, exp] of seenEvt) {
|
||||
if (exp < now) seenEvt.delete(k)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} peerKey
|
||||
*/
|
||||
function allowRate(peerKey) {
|
||||
const now = Date.now()
|
||||
let r = ratePeer.get(peerKey)
|
||||
if (!r || r.resetAt < now) {
|
||||
r = { tokens: RATE_MAX_MSG, resetAt: now + RATE_WINDOW_MS }
|
||||
ratePeer.set(peerKey, r)
|
||||
}
|
||||
if (r.tokens <= 0) {
|
||||
metrics.droppedRate++
|
||||
return false
|
||||
}
|
||||
r.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
function pushHistory(rec) {
|
||||
history.push(rec)
|
||||
while (history.length > historyMax) history.shift()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} pk
|
||||
* @param {Uint8Array} evtId
|
||||
*/
|
||||
function dedupeKey(pk, evtId) {
|
||||
return `${b4a.toString(pk, 'hex')}:${b4a.toString(evtId, 'hex')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('./swarm-disk.js').SwarmDisk} disk
|
||||
* @param {SwarmPeer} fromPeer
|
||||
* @param {Record<string, unknown>} evt
|
||||
*/
|
||||
function relayEvent(disk, fromPeer, evt) {
|
||||
const ttl = typeof evt.ttl === 'number' ? evt.ttl : gossipTtlMax
|
||||
if (ttl <= 0) return
|
||||
const next = { ...evt, ttl: ttl - 1 }
|
||||
for (const p of disk.peers) {
|
||||
if (p === fromPeer) continue
|
||||
const ch = p.chatChan
|
||||
if (!ch || !ch.messages || !ch.messages[4]) continue
|
||||
try {
|
||||
ch.messages[4].send(next)
|
||||
metrics.txEvent++
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('./swarm-disk.js').SwarmDisk} disk
|
||||
* @param {SwarmPeer} fromPeer
|
||||
* @param {Record<string, unknown>} evt
|
||||
*/
|
||||
function ingestEvent(disk, fromPeer, evt) {
|
||||
const senderPk = /** @type {Uint8Array | undefined} */ (evt.senderPk)
|
||||
const evtId = /** @type {Uint8Array | undefined} */ (evt.evtId)
|
||||
if (
|
||||
!senderPk ||
|
||||
senderPk.byteLength !== 32 ||
|
||||
!evtId ||
|
||||
evtId.byteLength !== 16
|
||||
) {
|
||||
return
|
||||
}
|
||||
const body = String(evt.body ?? '')
|
||||
if (body.byteLength > maxBodyBytes) return
|
||||
|
||||
const sock = fromPeer.socket
|
||||
const ttlMaxGossip = Math.max(1, gossipTtlMax)
|
||||
if (
|
||||
sock &&
|
||||
sock.remotePublicKey &&
|
||||
senderPk &&
|
||||
senderPk.byteLength === 32
|
||||
) {
|
||||
if (!b4a.equals(sock.remotePublicKey, senderPk)) {
|
||||
const ttlNum = typeof evt.ttl === 'number' ? evt.ttl : -1
|
||||
// Direct frames: Noise remote PK must equal claimed senderPk.
|
||||
// Relayed hops: ttl is decremented before forward; mux peer ≠ origin — allow ttl < configured max TTL.
|
||||
if (!(ttlNum >= 0 && ttlNum < ttlMaxGossip)) {
|
||||
metrics.droppedVerify++
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pk = fromPeer.id || b4a.toString(senderPk, 'hex')
|
||||
if (!allowRate(pk)) return
|
||||
|
||||
trimDedupe()
|
||||
const dk = dedupeKey(senderPk, evtId)
|
||||
const now = Date.now()
|
||||
if (seenEvt.has(dk)) return
|
||||
seenEvt.set(dk, now + 120_000)
|
||||
|
||||
metrics.rxEvent++
|
||||
const rec = {
|
||||
...evt,
|
||||
body,
|
||||
receivedAtMs: now,
|
||||
fromPeerKey: pk
|
||||
}
|
||||
pushHistory(rec)
|
||||
for (const fn of subscribers) {
|
||||
try {
|
||||
fn(rec)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
relayEvent(disk, fromPeer, evt)
|
||||
}
|
||||
|
||||
return {
|
||||
PROTOCOL_CHAT_CHANNEL_NAME,
|
||||
metrics,
|
||||
history() {
|
||||
return [...history]
|
||||
},
|
||||
presence() {
|
||||
/** @type {Record<string, { displayName: string, roomId: string }>} */
|
||||
const out = {}
|
||||
return out
|
||||
},
|
||||
rooms() {
|
||||
return ['general']
|
||||
},
|
||||
subscribe(fn) {
|
||||
subscribers.add(fn)
|
||||
return () => subscribers.delete(fn)
|
||||
},
|
||||
/**
|
||||
* @param {import('./swarm-disk.js').SwarmDisk} disk
|
||||
* @param {import('protomux').Protomux} mux
|
||||
* @param {any} socket
|
||||
* @param {SwarmPeer} peer
|
||||
*/
|
||||
pairOnMux(disk, mux, socket, peer) {
|
||||
setupBareOsChatChannel(mux, {
|
||||
onHello(_m, chan) {
|
||||
try {
|
||||
chan.messages[1].send({
|
||||
chatSchemaVersion: 1,
|
||||
maxPayloadBytes: 16384
|
||||
})
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
onHelloAck(_m, chan) {
|
||||
try {
|
||||
const id16 = randomBytes(16)
|
||||
let pk = b4a.alloc(32)
|
||||
if (socket?.publicKey && socket.publicKey.byteLength === 32) {
|
||||
pk = b4a.from(socket.publicKey)
|
||||
} else if (
|
||||
disk.localNoiseWirePk &&
|
||||
disk.localNoiseWirePk.byteLength === 32
|
||||
) {
|
||||
pk = disk.localNoiseWirePk
|
||||
}
|
||||
chan.messages[2].send({
|
||||
roomId: 'general',
|
||||
displayName: String(env.USER || env.LOGNAME || 'peer'),
|
||||
senderPk: pk,
|
||||
clientInstanceId: id16
|
||||
})
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
onJoin(_m, _chan) {},
|
||||
onLeave(_m, _chan) {},
|
||||
onEvent(m, _chan) {
|
||||
try {
|
||||
disk.protomuxChatChannelRxTotal =
|
||||
(disk.protomuxChatChannelRxTotal || 0) + 1
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
ingestEvent(disk, peer, m)
|
||||
},
|
||||
onControl(_m, _chan) {},
|
||||
onChannelOpened(chan) {
|
||||
peer.chatChan = chan
|
||||
try {
|
||||
chan.messages[0].send({
|
||||
chatSchemaVersion: 1,
|
||||
swarmChatCapability: true
|
||||
})
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
mux.stream?.once?.('close', () => {
|
||||
peer.chatChan = null
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
/**
|
||||
* @param {import('./swarm-disk.js').SwarmDisk} disk
|
||||
* @param {string} text
|
||||
* @param {{ displayName?: string, senderPk?: Uint8Array }} [meta]
|
||||
*/
|
||||
broadcastLocal(disk, text, meta = {}) {
|
||||
const evtId = randomBytes(16)
|
||||
let pk =
|
||||
meta.senderPk && meta.senderPk.byteLength === 32 ? meta.senderPk : null
|
||||
if (!pk) {
|
||||
for (const p of disk.peers) {
|
||||
const sk = p.socket?.publicKey
|
||||
if (sk && sk.byteLength === 32) {
|
||||
pk = b4a.from(sk)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
!pk &&
|
||||
disk.localNoiseWirePk &&
|
||||
disk.localNoiseWirePk.byteLength === 32
|
||||
) {
|
||||
pk = disk.localNoiseWirePk
|
||||
}
|
||||
if (!pk || pk.byteLength !== 32) {
|
||||
pk = b4a.alloc(32)
|
||||
}
|
||||
const evt = {
|
||||
schemaVersion: BARE_OS_CHAT_WIRE_SCHEMA_VERSION,
|
||||
evtKind: BARE_OS_CHAT_EVT_TEXT,
|
||||
roomId: 'general',
|
||||
evtId,
|
||||
tsMs: Date.now(),
|
||||
senderPk: pk,
|
||||
displayName: meta.displayName || String(env.USER || 'local'),
|
||||
body: text,
|
||||
ttl: gossipTtlMax,
|
||||
sigDetached: null
|
||||
}
|
||||
pushHistory({ ...evt, local: true, receivedAtMs: Date.now() })
|
||||
for (const fn of subscribers) {
|
||||
try {
|
||||
fn({ ...evt, local: true })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
/** @type {Promise<void>[]} */
|
||||
const sends = []
|
||||
for (const p of disk.peers) {
|
||||
const ch = p.chatChan
|
||||
if (!ch || !ch.messages || !ch.messages[4]) continue
|
||||
const ready =
|
||||
typeof ch.fullyOpened === 'function'
|
||||
? Promise.race([
|
||||
ch.fullyOpened(),
|
||||
new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() => resolve(ch.opened !== false && !ch.destroyed),
|
||||
250
|
||||
)
|
||||
})
|
||||
])
|
||||
: true
|
||||
sends.push(
|
||||
Promise.resolve(ready).then((opened) => {
|
||||
if (opened === false || ch.destroyed) return
|
||||
if (p.chatChan !== ch) return
|
||||
try {
|
||||
ch.messages[4].send(evt)
|
||||
metrics.txEvent++
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
return Promise.all(sends)
|
||||
},
|
||||
snapshotMetrics() {
|
||||
return {
|
||||
...metrics,
|
||||
gossipTtlDefault: gossipTtlMax,
|
||||
historyMax,
|
||||
maxBodyBytes,
|
||||
protocol: PROTOCOL_CHAT_CHANNEL_NAME
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* Optional Discord bot initd unit (`bare-os-discord`).
|
||||
* Registered only when the guest VFS has ~/.discord/.env with DISCORD_TOKEN=.
|
||||
* Hidden from systemctl when that file is missing.
|
||||
*/
|
||||
import {
|
||||
findBareServiceDefinition,
|
||||
registerBareService,
|
||||
startBareService,
|
||||
stopBareService,
|
||||
unregisterBareService
|
||||
} from './bare-initd.js'
|
||||
import { appendVarLog, BARE_OS_VAR_LOG_DIR } from './bare-os-var-log.js'
|
||||
import {
|
||||
normalizeDiscordToken,
|
||||
parseDotEnvText
|
||||
} from './bare-discord-js-loader.js'
|
||||
// Static CJS import so bare-pack rewrites the binding into app.bundle.
|
||||
// Runtime require of a sibling path is MODULE_NOT_FOUND under bare:/app.bundle/.
|
||||
import discordCommandsNs from './bare-os-discord-commands-guest.cjs'
|
||||
|
||||
const discordCommands =
|
||||
discordCommandsNs &&
|
||||
(discordCommandsNs.dispatchInteraction || discordCommandsNs.buildSlashCommands)
|
||||
? discordCommandsNs
|
||||
: discordCommandsNs && discordCommandsNs.default
|
||||
? discordCommandsNs.default
|
||||
: discordCommandsNs
|
||||
|
||||
export const BARE_OS_DISCORD_SERVICE_ENV = '~/.discord/.env'
|
||||
export const BARE_OS_DISCORD_LOG = `${BARE_OS_VAR_LOG_DIR}/discord.log`
|
||||
export const BARE_OS_DISCORD_UNIT = 'bare-os-discord'
|
||||
/** Extra guest files scanned for DISCORD_ID_WHITELIST / guild / user-install. */
|
||||
export const BARE_OS_DISCORD_EXTRA_ENV_FILES = [
|
||||
'~/.discord/.env',
|
||||
'~/.discord.env',
|
||||
'~/discord.env',
|
||||
'~/.env',
|
||||
'./.env'
|
||||
]
|
||||
|
||||
/** @type {import('discord.js').Client | null} */
|
||||
let discordServiceClient = null
|
||||
/** Live session ctx (updated after login so commands are not stuck on guest). */
|
||||
let discordServiceCtx = null
|
||||
|
||||
export function bindBareOsDiscordSessionCtx(ctx) {
|
||||
if (ctx) discordServiceCtx = ctx
|
||||
return discordServiceCtx
|
||||
}
|
||||
|
||||
/** @param {Record<string, string | undefined>} env */
|
||||
export function bareOsDiscordInitdEnabled(env) {
|
||||
const v = env && (env.BARE_OS_DISCORD_INITD || env.BARE_OS_DISCORD)
|
||||
if (v === '0' || v === 'false') return false
|
||||
return true
|
||||
}
|
||||
|
||||
function discordVfsText(ctx, buf) {
|
||||
if (!buf) return ''
|
||||
if (typeof ctx.b4a?.toString === 'function') return ctx.b4a.toString(buf)
|
||||
return Buffer.from(buf).toString('utf8')
|
||||
}
|
||||
|
||||
async function readBareOsDiscordDotEnvFile(ctx, path) {
|
||||
const vfs = ctx && ctx.vfs
|
||||
if (!vfs || typeof vfs.readFile !== 'function') return null
|
||||
let buf
|
||||
try {
|
||||
buf = await vfs.readFile(path)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!buf) return null
|
||||
return parseDotEnvText(discordVfsText(ctx, buf))
|
||||
}
|
||||
|
||||
function discordSessionEnvMaps(ctx) {
|
||||
if (!ctx.env || typeof ctx.env !== 'object') ctx.env = {}
|
||||
const maps = [ctx.env]
|
||||
if (ctx.vfs && ctx.vfs.env && typeof ctx.vfs.env === 'object' && ctx.vfs.env !== ctx.env) {
|
||||
maps.push(ctx.vfs.env)
|
||||
}
|
||||
return maps
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy non-empty Discord extras onto both `ctx.env` and `ctx.vfs.env`.
|
||||
* Session values win; empty strings do not block a file value.
|
||||
*/
|
||||
export function applyDiscordExtrasToSession(ctx, parsed) {
|
||||
if (!parsed || !ctx) return
|
||||
const maps = discordSessionEnvMaps(ctx)
|
||||
const pairs = [
|
||||
['DISCORD_GUILD_ID', parsed.DISCORD_GUILD_ID],
|
||||
[
|
||||
'DISCORD_ID_WHITELIST',
|
||||
parsed.DISCORD_ID_WHITELIST || parsed.BARE_OS_DISCORD_ID_WHITELIST
|
||||
],
|
||||
[
|
||||
'DISCORD_USER_INSTALL',
|
||||
parsed.DISCORD_USER_INSTALL || parsed.BARE_OS_DISCORD_USER_INSTALL
|
||||
]
|
||||
]
|
||||
for (const [key, raw] of pairs) {
|
||||
const v = raw == null ? '' : String(raw).trim()
|
||||
if (!v) continue
|
||||
for (const env of maps) {
|
||||
if (!String(env[key] || '').trim()) env[key] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function discordSessionWhitelist(ctx) {
|
||||
const maps = []
|
||||
if (ctx && ctx.env && typeof ctx.env === 'object') maps.push(ctx.env)
|
||||
if (ctx && ctx.vfs && ctx.vfs.env && typeof ctx.vfs.env === 'object') {
|
||||
maps.push(ctx.vfs.env)
|
||||
}
|
||||
for (const env of maps) {
|
||||
const v = String(
|
||||
env.DISCORD_ID_WHITELIST || env.BARE_OS_DISCORD_ID_WHITELIST || ''
|
||||
).trim()
|
||||
if (v) return v
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export async function readBareOsDiscordServiceEnv(ctx) {
|
||||
const parsed = await readBareOsDiscordDotEnvFile(ctx, BARE_OS_DISCORD_SERVICE_ENV)
|
||||
if (!parsed) return null
|
||||
const token = normalizeDiscordToken(
|
||||
parsed.DISCORD_TOKEN || parsed.BOT_TOKEN || ''
|
||||
)
|
||||
if (!token) return null
|
||||
return {
|
||||
token,
|
||||
guildId: String(parsed.DISCORD_GUILD_ID || '').trim(),
|
||||
whitelist: String(
|
||||
parsed.DISCORD_ID_WHITELIST || parsed.BARE_OS_DISCORD_ID_WHITELIST || ''
|
||||
).trim(),
|
||||
userInstall: String(
|
||||
parsed.DISCORD_USER_INSTALL || parsed.BARE_OS_DISCORD_USER_INSTALL || ''
|
||||
).trim()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load token from ~/.discord/.env, then fill whitelist / guild / user-install
|
||||
* from that file, sibling env files (~/.env, …), and both session env maps.
|
||||
*/
|
||||
export async function hydrateBareOsDiscordSessionEnv(ctx) {
|
||||
const creds = await readBareOsDiscordServiceEnv(ctx)
|
||||
if (creds) applyDiscordExtrasToSession(ctx, creds)
|
||||
const seen = Object.create(null)
|
||||
for (const path of BARE_OS_DISCORD_EXTRA_ENV_FILES) {
|
||||
if (!path || seen[path]) continue
|
||||
seen[path] = true
|
||||
const parsed = await readBareOsDiscordDotEnvFile(ctx, path)
|
||||
if (parsed) applyDiscordExtrasToSession(ctx, parsed)
|
||||
}
|
||||
if (creds) {
|
||||
const wl = discordSessionWhitelist(ctx)
|
||||
if (wl) creds.whitelist = wl
|
||||
const guild = String(
|
||||
(ctx.env && ctx.env.DISCORD_GUILD_ID) || creds.guildId || ''
|
||||
).trim()
|
||||
if (guild) creds.guildId = guild
|
||||
const ui = String(
|
||||
(ctx.env && ctx.env.DISCORD_USER_INSTALL) || creds.userInstall || ''
|
||||
).trim()
|
||||
if (ui) creds.userInstall = ui
|
||||
}
|
||||
return creds
|
||||
}
|
||||
|
||||
function discordServiceLog(ctx, line) {
|
||||
try {
|
||||
ctx.console?.log?.('[bare-os-discord] ' + line)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
void appendVarLog(ctx, BARE_OS_DISCORD_LOG, 'discord', line)
|
||||
}
|
||||
|
||||
async function startBareOsDiscord(ctx) {
|
||||
const creds = await hydrateBareOsDiscordSessionEnv(ctx)
|
||||
if (!creds) {
|
||||
throw new Error('missing ' + BARE_OS_DISCORD_SERVICE_ENV + ' (DISCORD_TOKEN=)')
|
||||
}
|
||||
const dj = ctx.bare && ctx.bare.discordJS
|
||||
if (!dj || typeof dj.Client !== 'function') {
|
||||
throw new Error('ctx.bare.discordJS is unavailable')
|
||||
}
|
||||
discordServiceCtx = ctx
|
||||
if (discordServiceClient) return
|
||||
applyDiscordExtrasToSession(ctx, creds)
|
||||
|
||||
const osName =
|
||||
(typeof process !== 'undefined' && process.platform) || 'darwin'
|
||||
const client = new dj.Client({
|
||||
intents: [dj.GatewayIntentBits.Guilds],
|
||||
ws: {
|
||||
identifyProperties: {
|
||||
os: osName,
|
||||
browser: 'bare-os',
|
||||
device: 'bare-os'
|
||||
}
|
||||
}
|
||||
})
|
||||
if (dj.Events && dj.Events.Error) {
|
||||
client.on(dj.Events.Error, function (err) {
|
||||
discordServiceLog(ctx, 'client error: ' + ((err && err.message) || err))
|
||||
})
|
||||
}
|
||||
if (dj.Events && dj.Events.InteractionCreate) {
|
||||
client.on(dj.Events.InteractionCreate, async function (interaction) {
|
||||
const live = discordServiceCtx || ctx
|
||||
if (
|
||||
discordCommands &&
|
||||
typeof discordCommands.syncSessionIdentity === 'function'
|
||||
) {
|
||||
discordCommands.syncSessionIdentity(live)
|
||||
}
|
||||
const dispatch =
|
||||
discordCommands &&
|
||||
(discordCommands.dispatchInteraction ||
|
||||
(discordCommands.default &&
|
||||
discordCommands.default.dispatchInteraction))
|
||||
if (typeof dispatch === 'function') {
|
||||
await dispatch(live, interaction)
|
||||
return
|
||||
}
|
||||
if (
|
||||
!interaction.isChatInputCommand ||
|
||||
!interaction.isChatInputCommand()
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (interaction.commandName !== 'ping') return
|
||||
const uid =
|
||||
interaction.user && interaction.user.id ? String(interaction.user.id) : ''
|
||||
const allowed =
|
||||
discordCommands && typeof discordCommands.userAllowed === 'function'
|
||||
? discordCommands.userAllowed(live, uid, interaction)
|
||||
: true
|
||||
if (!allowed) {
|
||||
try {
|
||||
await interaction.reply({
|
||||
content:
|
||||
'Access denied. Your Discord user id is not on DISCORD_ID_WHITELIST.',
|
||||
flags: 64
|
||||
})
|
||||
} catch (err) {
|
||||
discordServiceLog(
|
||||
ctx,
|
||||
'deny failed: ' + ((err && err.message) || err)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
try {
|
||||
await interaction.reply({ content: 'pong' })
|
||||
} catch (err) {
|
||||
discordServiceLog(
|
||||
ctx,
|
||||
'/ping failed: ' + ((err && err.message) || err)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
await client.login(creds.token)
|
||||
discordServiceClient = client
|
||||
const tag =
|
||||
client.user && client.user.tag ? client.user.tag : String(client.user)
|
||||
discordServiceLog(ctx, 'logged in as ' + tag)
|
||||
if (typeof dj.REST === 'function' && dj.Routes) {
|
||||
try {
|
||||
const rest = new dj.REST().setToken(creds.token)
|
||||
const build =
|
||||
discordCommands &&
|
||||
(discordCommands.buildSlashCommands ||
|
||||
(discordCommands.default &&
|
||||
discordCommands.default.buildSlashCommands))
|
||||
const body =
|
||||
typeof build === 'function'
|
||||
? await Promise.resolve(build(dj, ctx))
|
||||
: [
|
||||
new dj.SlashCommandBuilder()
|
||||
.setName('ping')
|
||||
.setDescription('Replies with pong.')
|
||||
.toJSON()
|
||||
]
|
||||
const setReg =
|
||||
discordCommands &&
|
||||
(discordCommands.setPluginRegistrar ||
|
||||
(discordCommands.default && discordCommands.default.setPluginRegistrar))
|
||||
const userInstallOn =
|
||||
discordCommands &&
|
||||
typeof discordCommands.userInstallEnabled === 'function'
|
||||
? discordCommands.userInstallEnabled(ctx)
|
||||
: true
|
||||
async function registerBody(next) {
|
||||
if (
|
||||
userInstallOn &&
|
||||
discordCommands &&
|
||||
typeof discordCommands.enableUserInstallApp === 'function'
|
||||
) {
|
||||
try {
|
||||
await discordCommands.enableUserInstallApp(rest, dj.Routes)
|
||||
} catch (err) {
|
||||
discordServiceLog(
|
||||
ctx,
|
||||
'user-install app config failed: ' +
|
||||
((err && err.message) || err) +
|
||||
' (enable User Install under Developer Portal → Installation)'
|
||||
)
|
||||
}
|
||||
}
|
||||
if (
|
||||
discordCommands &&
|
||||
typeof discordCommands.putSlashCommands === 'function'
|
||||
) {
|
||||
const put = await discordCommands.putSlashCommands(
|
||||
rest,
|
||||
dj.Routes,
|
||||
client.user.id,
|
||||
next,
|
||||
{ guildId: creds.guildId, userInstall: userInstallOn }
|
||||
)
|
||||
if (put.global) {
|
||||
discordServiceLog(
|
||||
ctx,
|
||||
'registered ' +
|
||||
next.length +
|
||||
' global commands' +
|
||||
(userInstallOn ? ' (user-install + guild)' : '')
|
||||
)
|
||||
}
|
||||
if (put.guild) {
|
||||
discordServiceLog(
|
||||
ctx,
|
||||
'registered ' +
|
||||
next.length +
|
||||
' commands for guild ' +
|
||||
creds.guildId
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (creds.guildId) {
|
||||
await rest.put(
|
||||
dj.Routes.applicationGuildCommands(client.user.id, creds.guildId),
|
||||
{ body: next }
|
||||
)
|
||||
} else {
|
||||
await rest.put(dj.Routes.applicationCommands(client.user.id), {
|
||||
body: next
|
||||
})
|
||||
}
|
||||
}
|
||||
if (typeof setReg === 'function') {
|
||||
setReg(async function () {
|
||||
const next = await Promise.resolve(build(dj, ctx))
|
||||
await registerBody(next)
|
||||
})
|
||||
}
|
||||
await registerBody(body)
|
||||
if (userInstallOn) {
|
||||
const url =
|
||||
discordCommands &&
|
||||
typeof discordCommands.userInstallAuthorizeUrl === 'function'
|
||||
? discordCommands.userInstallAuthorizeUrl(client.user.id)
|
||||
: ''
|
||||
if (url) {
|
||||
discordServiceLog(ctx, 'add to your Discord profile: ' + url)
|
||||
}
|
||||
const wlN =
|
||||
discordCommands &&
|
||||
typeof discordCommands.whitelistCount === 'function'
|
||||
? discordCommands.whitelistCount(ctx)
|
||||
: 0
|
||||
if (wlN) {
|
||||
discordServiceLog(
|
||||
ctx,
|
||||
'DISCORD_ID_WHITELIST active (' + wlN + ' user id(s))'
|
||||
)
|
||||
} else {
|
||||
discordServiceLog(
|
||||
ctx,
|
||||
'user-install is on and DISCORD_ID_WHITELIST is empty — user-install / DM commands are denied'
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
discordServiceLog(
|
||||
ctx,
|
||||
'slash register failed: ' + ((err && err.message) || err)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function stopBareOsDiscord(_ctx) {
|
||||
const c = discordServiceClient
|
||||
discordServiceClient = null
|
||||
discordServiceCtx = null
|
||||
if (!c) return
|
||||
await Promise.resolve(c.destroy()).catch(function () {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register or drop the unit from systemctl based on ~/.discord/.env.
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
export async function syncBareOsDiscordInitd(ctx) {
|
||||
const env =
|
||||
ctx && ctx.env && typeof ctx.env === 'object'
|
||||
? /** @type {Record<string, string | undefined>} */ (ctx.env)
|
||||
: {}
|
||||
const want =
|
||||
bareOsDiscordInitdEnabled(env) && (await readBareOsDiscordServiceEnv(ctx))
|
||||
const have = Boolean(findBareServiceDefinition(BARE_OS_DISCORD_UNIT))
|
||||
if (!want) {
|
||||
if (have) {
|
||||
try {
|
||||
await stopBareService(ctx, BARE_OS_DISCORD_UNIT)
|
||||
} catch {
|
||||
/* may already be inactive */
|
||||
}
|
||||
unregisterBareService(BARE_OS_DISCORD_UNIT)
|
||||
}
|
||||
return false
|
||||
}
|
||||
registerBareService({
|
||||
name: BARE_OS_DISCORD_UNIT,
|
||||
description:
|
||||
'Bare OS Discord bot (/bare /sys /svc /fs /net …); requires ~/.discord/.env',
|
||||
logPath: BARE_OS_DISCORD_LOG,
|
||||
start: startBareOsDiscord,
|
||||
stop: stopBareOsDiscord
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* After identity unlock: show+start the unit only when ~/.discord/.env exists.
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
export async function maybeStartBareOsDiscordAfterIdentity(ctx) {
|
||||
bindBareOsDiscordSessionCtx(ctx)
|
||||
const shown = await syncBareOsDiscordInitd(ctx)
|
||||
if (!shown) return
|
||||
try {
|
||||
await startBareService(ctx, BARE_OS_DISCORD_UNIT)
|
||||
} catch (e) {
|
||||
const msg = (e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
||||
discordServiceLog(ctx, 'start failed: ' + msg)
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the unit on logout so it does not appear for the next session. */
|
||||
export async function stopBareOsDiscordInitd(ctx) {
|
||||
if (!findBareServiceDefinition(BARE_OS_DISCORD_UNIT)) return
|
||||
try {
|
||||
await stopBareService(ctx, BARE_OS_DISCORD_UNIT)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
unregisterBareService(BARE_OS_DISCORD_UNIT)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* MUST load before any discord.js / @discordjs/ws evaluation in the standalone
|
||||
* pack graph. Forces WHATWG WebSocket (bare-ws) so IDENTIFY produces READY.
|
||||
* Marker: bare-os-discord-gateway-ws
|
||||
*/
|
||||
import wsAdapter from '../vendor/bare-discord-js/src/adapters/whatwg-ws.cjs'
|
||||
|
||||
const install =
|
||||
(wsAdapter && wsAdapter.installBareOsDiscordGatewayWs) ||
|
||||
(wsAdapter &&
|
||||
wsAdapter.default &&
|
||||
wsAdapter.default.installBareOsDiscordGatewayWs)
|
||||
if (typeof install === 'function') install()
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* IRC dial policy for ctx.bareOsTlsConnect / /bin/irc.
|
||||
* BARE_OS_IRC=0 disables. BARE_OS_IRC_DENYLIST always wins.
|
||||
* Unset allowlist defaults to irc.libera.chat and irc.*.libera.chat.
|
||||
*/
|
||||
|
||||
import { parseHostPatternList } from './bare-os-http-policy.js'
|
||||
|
||||
export const BARE_OS_IRC_DEFAULT_ALLOW = [
|
||||
'irc.libera.chat',
|
||||
'irc.*.libera.chat'
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {string} pattern
|
||||
* @param {string} host
|
||||
*/
|
||||
export function bareOsIrcHostMatchesGlob(pattern, host) {
|
||||
const p = String(pattern || '').toLowerCase()
|
||||
const h = String(host || '').toLowerCase()
|
||||
if (p === '*' || p === '*:*') return true
|
||||
const esc = p
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*/g, '.*')
|
||||
.replace(/\?/g, '.')
|
||||
try {
|
||||
return new RegExp('^' + esc + '$').test(h)
|
||||
} catch {
|
||||
return h === p
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} host
|
||||
*/
|
||||
export function bareOsIrcIsLiberaHost(host) {
|
||||
const h = String(host || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return h === 'libera.chat' || h.endsWith('.libera.chat')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} host
|
||||
* @param {Record<string, string | undefined> | null | undefined} env
|
||||
* @returns {{ ok: boolean, reason?: string }}
|
||||
*/
|
||||
export function bareOsIrcHostAllowed(host, env) {
|
||||
const e = env && typeof env === 'object' ? env : {}
|
||||
const v = e.BARE_OS_IRC
|
||||
if (v === '0' || v === 'false') return { ok: false, reason: 'disabled' }
|
||||
const h = String(host || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!h) return { ok: false, reason: 'missing host' }
|
||||
const deny = parseHostPatternList(e.BARE_OS_IRC_DENYLIST)
|
||||
for (const pat of deny) {
|
||||
if (bareOsIrcHostMatchesGlob(pat, h))
|
||||
return { ok: false, reason: 'denylist' }
|
||||
}
|
||||
const rawAllow = e.BARE_OS_IRC_ALLOWLIST
|
||||
const allow =
|
||||
rawAllow == null || String(rawAllow).trim() === ''
|
||||
? BARE_OS_IRC_DEFAULT_ALLOW
|
||||
: parseHostPatternList(rawAllow)
|
||||
if (!allow.length) return { ok: true }
|
||||
for (const pat of allow) {
|
||||
if (bareOsIrcHostMatchesGlob(pat, h)) return { ok: true }
|
||||
}
|
||||
return { ok: false, reason: 'not in allowlist' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Libera must not be dialed in plaintext unless the operator forces it twice.
|
||||
* @param {string} host
|
||||
* @param {{ tls?: boolean, insecurePlain?: boolean }} opts
|
||||
*/
|
||||
export function bareOsIrcPlaintextAllowed(host, opts) {
|
||||
const o = opts || {}
|
||||
if (o.tls !== false) return { ok: true }
|
||||
if (bareOsIrcIsLiberaHost(host) && o.insecurePlain !== true) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'libera requires TLS (use --insecure-plain to override)'
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Diagnose a GGUF on disk after load failure (magic / size / sha256).
|
||||
*/
|
||||
|
||||
/** @type {Record<string, { size: number, sha256: string }>} */
|
||||
export const BARE_OS_QVAC_KNOWN_GGUF = {
|
||||
'Qwen3-0.6B-Q4_0.gguf': {
|
||||
size: 382156480,
|
||||
sha256: '33bcc57074ec7b6eada5a90651ee546ec0c2b271002c22baf9f1b2dd1e8f75cb'
|
||||
},
|
||||
'Qwen3-1.7B-Q4_0.gguf': {
|
||||
size: 1056782912,
|
||||
sha256: 'c876f159707a4e4f70e045106c69db15bfc935a4981706fd4f65c6e7ea1e81c5'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function bareOsQvacDiagnoseGguf(filePath) {
|
||||
const p = String(filePath || '').trim()
|
||||
if (!p) return 'gguf: (no path)'
|
||||
/** @type {string[]} */
|
||||
const lines = ['gguf path: ' + p]
|
||||
try {
|
||||
let fs
|
||||
try {
|
||||
fs = await import('bare-fs')
|
||||
} catch {
|
||||
fs = await import('fs')
|
||||
}
|
||||
const fsp = fs.promises || fs
|
||||
const st = await fsp.stat(p)
|
||||
lines.push('size: ' + st.size + ' bytes')
|
||||
|
||||
const base =
|
||||
p.split(/[/\\]/).pop() ||
|
||||
''
|
||||
const known = BARE_OS_QVAC_KNOWN_GGUF[base.replace(/^[0-9a-f]+_/i, '')] ||
|
||||
Object.entries(BARE_OS_QVAC_KNOWN_GGUF).find(([name]) =>
|
||||
base.endsWith(name)
|
||||
)?.[1]
|
||||
if (known) {
|
||||
lines.push(
|
||||
'expectedSize: ' +
|
||||
known.size +
|
||||
(st.size === known.size ? ' (ok)' : ' (MISMATCH)')
|
||||
)
|
||||
}
|
||||
|
||||
const buf = Buffer.alloc(4)
|
||||
let fh
|
||||
try {
|
||||
if (typeof fsp.open === 'function') {
|
||||
fh = await fsp.open(p, 'r')
|
||||
if (typeof fh.read === 'function') {
|
||||
await fh.read(buf, 0, 4, 0)
|
||||
} else if (typeof fs.read === 'function' && fh.fd != null) {
|
||||
await new Promise((resolve, reject) => {
|
||||
fs.read(fh.fd, buf, 0, 4, 0, (err) => (err ? reject(err) : resolve()))
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const fd = fs.openSync(p, 'r')
|
||||
fs.readSync(fd, buf, 0, 4, 0)
|
||||
fs.closeSync(fd)
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
if (fh && typeof fh.close === 'function') await fh.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const magic = buf.toString('utf8')
|
||||
lines.push('magic: ' + JSON.stringify(magic) + (magic === 'GGUF' ? ' (ok)' : ' (BAD)'))
|
||||
|
||||
if (known && st.size === known.size && st.size <= 2 * 1024 * 1024 * 1024) {
|
||||
try {
|
||||
let crypto
|
||||
try {
|
||||
crypto = await import('bare-crypto')
|
||||
} catch {
|
||||
crypto = await import('crypto')
|
||||
}
|
||||
const createHash = crypto.createHash || crypto.default?.createHash
|
||||
if (typeof createHash === 'function') {
|
||||
const hash = createHash('sha256')
|
||||
const stream = fs.createReadStream
|
||||
? fs.createReadStream(p)
|
||||
: null
|
||||
if (stream) {
|
||||
await new Promise((resolve, reject) => {
|
||||
stream.on('data', (c) => hash.update(c))
|
||||
stream.on('error', reject)
|
||||
stream.on('end', resolve)
|
||||
})
|
||||
const dig = hash.digest('hex')
|
||||
lines.push(
|
||||
'sha256: ' +
|
||||
dig +
|
||||
(dig === known.sha256 ? ' (ok)' : ' (MISMATCH want ' + known.sha256 + ')')
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
lines.push('sha256: (skip ' + String(e && e.message ? e.message : e) + ')')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
lines.push('diagnose error: ' + String(e && e.message ? e.message : e))
|
||||
}
|
||||
return lines.join('; ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a GGUF path from a QVAC / llama error string.
|
||||
* @param {string} msg
|
||||
* @returns {string}
|
||||
*/
|
||||
export function bareOsQvacExtractGgufPath(msg) {
|
||||
const m = /failed to load model '([^']+\.gguf)'/i.exec(String(msg || ''))
|
||||
if (m) return m[1]
|
||||
const m2 = /(\/[^\s']+\.gguf)/i.exec(String(msg || ''))
|
||||
return m2 ? m2[1] : ''
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
/**
|
||||
* Probe host GPUs for QVAC load ordering (highest device-local memory first).
|
||||
* Uses vulkaninfo when available; nvidia-smi / sysfs as weak fallbacks.
|
||||
*
|
||||
* Also prefers hardware Vulkan ICDs (NVIDIA/Intel/AMD) and disables Mesa
|
||||
* lavapipe/llvmpipe so llama.cpp does not "succeed" on a CPU Vulkan device.
|
||||
*
|
||||
* Static bare-subprocess import: dynamic import() from this module was not
|
||||
* linked in the bare-pack resolution map (MODULE_NOT_FOUND → sdk worker crash).
|
||||
*/
|
||||
|
||||
import bareSubprocess from 'bare-subprocess'
|
||||
import hostFs from '#host-fs'
|
||||
import path from '#host-path'
|
||||
import os from 'bare-os'
|
||||
|
||||
/** Standard Vulkan ICD manifest directories (Linux). */
|
||||
export const BARE_OS_QVAC_VULKAN_ICD_DIRS = [
|
||||
'/usr/share/vulkan/icd.d',
|
||||
'/etc/vulkan/icd.d',
|
||||
'/usr/local/share/vulkan/icd.d'
|
||||
]
|
||||
|
||||
/**
|
||||
* @typedef {{
|
||||
* index: number,
|
||||
* name: string,
|
||||
* memoryBytes: number,
|
||||
* deviceType: string,
|
||||
* source: string
|
||||
* }} BareOsQvacGpuInfo
|
||||
*/
|
||||
|
||||
/**
|
||||
* Run a short host command; return stdout (utf8) or null.
|
||||
* @param {string} file
|
||||
* @param {string[]} args
|
||||
* @param {{ timeoutMs?: number }} [opts]
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function bareOsQvacRunHostCommand(file, args, opts = {}) {
|
||||
const timeoutMs = Math.max(500, Math.min(30000, Number(opts.timeoutMs) || 8000))
|
||||
const argv = Array.isArray(args) ? args.map((x) => String(x)) : []
|
||||
|
||||
/**
|
||||
* @param {{ spawn: Function }} sp
|
||||
*/
|
||||
async function viaSpawn(sp) {
|
||||
return await new Promise((resolve) => {
|
||||
let settled = false
|
||||
/** @type {string[]} */
|
||||
const chunks = []
|
||||
let child
|
||||
try {
|
||||
child = sp.spawn(file, argv, {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
} catch {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
const finish = (out) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
try {
|
||||
if (child && typeof child.kill === 'function') child.kill()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
resolve(out)
|
||||
}
|
||||
const timer = setTimeout(() => finish(null), timeoutMs)
|
||||
try {
|
||||
if (child.stdout && typeof child.stdout.on === 'function') {
|
||||
child.stdout.on('data', (buf) => {
|
||||
chunks.push(Buffer.isBuffer(buf) ? buf.toString('utf8') : String(buf))
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const onDone = (code) => {
|
||||
clearTimeout(timer)
|
||||
if (code === 0 || chunks.length) finish(chunks.join(''))
|
||||
else finish(null)
|
||||
}
|
||||
try {
|
||||
child.on('exit', onDone)
|
||||
child.on('error', () => {
|
||||
clearTimeout(timer)
|
||||
finish(null)
|
||||
})
|
||||
} catch {
|
||||
clearTimeout(timer)
|
||||
finish(null)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const sp =
|
||||
bareSubprocess &&
|
||||
(typeof bareSubprocess.spawn === 'function'
|
||||
? bareSubprocess
|
||||
: bareSubprocess.default &&
|
||||
typeof bareSubprocess.default.spawn === 'function'
|
||||
? bareSubprocess.default
|
||||
: null)
|
||||
if (sp) {
|
||||
const out = await viaSpawn(sp)
|
||||
if (out != null) return out
|
||||
}
|
||||
} catch {
|
||||
/* try node next */
|
||||
}
|
||||
|
||||
try {
|
||||
const cp = await import('child_process')
|
||||
const execFile = cp.execFile || (cp.default && cp.default.execFile)
|
||||
if (typeof execFile !== 'function') return null
|
||||
return await new Promise((resolve) => {
|
||||
execFile(
|
||||
file,
|
||||
argv,
|
||||
{ timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024, encoding: 'utf8' },
|
||||
(err, stdout) => {
|
||||
if (err && !stdout) resolve(null)
|
||||
else resolve(stdout != null ? String(stdout) : null)
|
||||
}
|
||||
)
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `vulkaninfo` text for GPUs + device-local heap sizes.
|
||||
* @param {string} text
|
||||
* @returns {BareOsQvacGpuInfo[]}
|
||||
*/
|
||||
export function bareOsQvacParseVulkaninfo(text) {
|
||||
const src = String(text || '')
|
||||
if (!src.trim()) return []
|
||||
|
||||
/** @type {BareOsQvacGpuInfo[]} */
|
||||
const out = []
|
||||
// Split on GPU N: headers (vulkaninfo --summary and full dump).
|
||||
const parts = src.split(/(?=^GPU\d+:)/m)
|
||||
for (const part of parts) {
|
||||
const mIdx = /^GPU(\d+):/m.exec(part)
|
||||
if (!mIdx) continue
|
||||
const index = Number.parseInt(mIdx[1], 10)
|
||||
if (!Number.isFinite(index)) continue
|
||||
|
||||
const nameMatch =
|
||||
/deviceName\s*=\s*(.+)$/m.exec(part) ||
|
||||
/deviceName\s*=\s*(.+)$/im.exec(part)
|
||||
const name = nameMatch ? String(nameMatch[1]).trim() : 'GPU' + index
|
||||
|
||||
const typeMatch = /deviceType\s*=\s*(\S+)/m.exec(part)
|
||||
const deviceType = typeMatch ? String(typeMatch[1]).trim() : ''
|
||||
|
||||
let memoryBytes = 0
|
||||
// Full vulkaninfo: memoryHeaps[i] blocks with DEVICE_LOCAL
|
||||
const heapBlocks = part.split(/(?=memoryHeaps\[\d+\]:)/i)
|
||||
for (const block of heapBlocks) {
|
||||
if (!/memoryHeaps\[\d+\]:/i.test(block)) continue
|
||||
const szMatch = /\bsize\s*=\s*(\d+)/i.exec(block)
|
||||
if (!szMatch) continue
|
||||
const sz = Number(szMatch[1])
|
||||
if (!Number.isFinite(sz) || sz <= 0) continue
|
||||
if (/DEVICE_LOCAL/i.test(block) || sz > memoryBytes) {
|
||||
memoryBytes = Math.max(memoryBytes, sz)
|
||||
}
|
||||
}
|
||||
// Alternate: "heapSize = N"
|
||||
if (!memoryBytes) {
|
||||
const hs = [...part.matchAll(/heapSize\s*=\s*(\d+)/gi)]
|
||||
for (const x of hs) {
|
||||
const sz = Number(x[1])
|
||||
if (Number.isFinite(sz) && sz > memoryBytes) memoryBytes = sz
|
||||
}
|
||||
}
|
||||
// Summary-only: no heaps — score by type so discrete still ranks above integrated.
|
||||
if (!memoryBytes) {
|
||||
if (/DISCRETE/i.test(deviceType)) memoryBytes = 8 * 1024 * 1024 * 1024
|
||||
else if (/INTEGRATED/i.test(deviceType)) memoryBytes = 512 * 1024 * 1024
|
||||
else memoryBytes = 256 * 1024 * 1024
|
||||
}
|
||||
|
||||
out.push({
|
||||
index,
|
||||
name,
|
||||
memoryBytes,
|
||||
deviceType,
|
||||
source: 'vulkaninfo'
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse nvidia-smi CSV: name, memory.total (MiB).
|
||||
* Indices are NVIDIA order, NOT Vulkan — used only as a presence / size hint.
|
||||
* @param {string} text
|
||||
* @returns {{ name: string, memoryBytes: number }[]}
|
||||
*/
|
||||
export function bareOsQvacParseNvidiaSmi(text) {
|
||||
/** @type {{ name: string, memoryBytes: number }[]} */
|
||||
const out = []
|
||||
for (const line of String(text || '').split(/\r?\n/)) {
|
||||
const t = line.trim()
|
||||
if (!t) continue
|
||||
const parts = t.split(',').map((x) => x.trim())
|
||||
if (parts.length < 2) continue
|
||||
const name = parts[0]
|
||||
const mib = Number(parts[1])
|
||||
if (!name || !Number.isFinite(mib) || mib <= 0) continue
|
||||
out.push({ name, memoryBytes: Math.floor(mib * 1024 * 1024) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge nvidia memory hints onto vulkan devices by fuzzy name match.
|
||||
* @param {BareOsQvacGpuInfo[]} vulkan
|
||||
* @param {{ name: string, memoryBytes: number }[]} nvidia
|
||||
*/
|
||||
export function bareOsQvacMergeNvidiaHints(vulkan, nvidia) {
|
||||
if (!vulkan.length || !nvidia.length) return vulkan
|
||||
return vulkan.map((g) => {
|
||||
const gName = g.name.toLowerCase()
|
||||
let best = null
|
||||
let bestScore = 0
|
||||
for (const n of nvidia) {
|
||||
const nName = n.name.toLowerCase()
|
||||
if (gName.includes(nName) || nName.includes(gName) || gName.includes('nvidia')) {
|
||||
const score = n.memoryBytes
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
best = n
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best && best.memoryBytes > g.memoryBytes) {
|
||||
return {
|
||||
...g,
|
||||
memoryBytes: best.memoryBytes,
|
||||
source: g.source + '+nvidia-smi'
|
||||
}
|
||||
}
|
||||
return g
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* True for Mesa lavapipe / llvmpipe / SwiftShader / Vulkan CPU devices.
|
||||
* @param {{ name?: string, deviceType?: string } | null | undefined} g
|
||||
*/
|
||||
export function bareOsQvacIsSoftwareVulkanDevice(g) {
|
||||
if (!g || typeof g !== 'object') return false
|
||||
const name = String(g.name || '').toLowerCase()
|
||||
const type = String(g.deviceType || '').toLowerCase()
|
||||
if (
|
||||
/llvmpipe|lavapipe|swiftshader|softpipe|cpu rasterizer|microsoft basic render/.test(
|
||||
name
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
type === 'cpu' ||
|
||||
type.includes('physical_device_type_cpu') ||
|
||||
/(^|_)cpu($|_)/.test(type)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {BareOsQvacGpuInfo[]} gpus
|
||||
* @returns {BareOsQvacGpuInfo[]}
|
||||
*/
|
||||
export function bareOsQvacHardwareGpus(gpus) {
|
||||
return (Array.isArray(gpus) ? gpus : []).filter(
|
||||
(g) => !bareOsQvacIsSoftwareVulkanDevice(g)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Score an ICD JSON filename — higher is preferred; software drivers are negative.
|
||||
* @param {string} filename
|
||||
*/
|
||||
export function bareOsQvacScoreVulkanIcdFilename(filename) {
|
||||
const n = String(filename || '').toLowerCase()
|
||||
if (/lvp|llvmpipe|lavapipe|swiftshader/.test(n)) return -100
|
||||
if (/gfxstream|virtio/.test(n)) return -50
|
||||
if (/nvidia/.test(n)) return 100
|
||||
if (/radeon|amd|radv/.test(n)) return 80
|
||||
if (/intel|anv|iris/.test(n)) return 50
|
||||
if (/moltenvk|apple/.test(n)) return 40
|
||||
if (/asahi/.test(n)) return 20
|
||||
if (/nouveau/.test(n)) return 15
|
||||
return 10
|
||||
}
|
||||
|
||||
/**
|
||||
* Vendor family for a Vulkan ICD manifest filename.
|
||||
* @param {string} filename
|
||||
* @returns {'software' | 'virtual' | 'nvidia' | 'amd' | 'intel' | 'apple' | 'asahi' | 'nouveau' | 'other'}
|
||||
*/
|
||||
export function bareOsQvacVulkanIcdVendor(filename) {
|
||||
const n = String(filename || '').toLowerCase()
|
||||
if (/lvp|llvmpipe|lavapipe|swiftshader/.test(n)) return 'software'
|
||||
if (/gfxstream|virtio/.test(n)) return 'virtual'
|
||||
if (/nvidia/.test(n)) return 'nvidia'
|
||||
if (/radeon|amd|radv/.test(n)) return 'amd'
|
||||
if (/intel|anv|hasvk|iris/.test(n)) return 'intel'
|
||||
if (/moltenvk/.test(n)) return 'apple'
|
||||
if (/asahi/.test(n)) return 'asahi'
|
||||
if (/nouveau/.test(n)) return 'nouveau'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
/**
|
||||
* VK_LOADER_DRIVERS_SELECT glob for a preferred vendor.
|
||||
* @param {string} vendor
|
||||
*/
|
||||
export function bareOsQvacVulkanVendorSelectGlob(vendor) {
|
||||
switch (vendor) {
|
||||
case 'nvidia':
|
||||
return '*nvidia*'
|
||||
case 'amd':
|
||||
return '*radeon*,*amd*,*radv*'
|
||||
case 'intel':
|
||||
return '*intel*'
|
||||
case 'apple':
|
||||
return '*moltenvk*'
|
||||
case 'asahi':
|
||||
return '*asahi*'
|
||||
case 'nouveau':
|
||||
return '*nouveau*'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep ICDs for the highest-scored vendor so ggml does not enumerate unused
|
||||
* Mesa/emulator manifests (asahi/gfxstream/nouveau/virtio) next to RADV.
|
||||
* `icds` must already be score-sorted (best first), as from discover.
|
||||
* @param {string[]} icds
|
||||
* @returns {{ vendor: string, icds: string[], selectGlob: string }}
|
||||
*/
|
||||
export function bareOsQvacPreferredVulkanIcds(icds) {
|
||||
const list = (Array.isArray(icds) ? icds : []).filter(
|
||||
(p) => typeof p === 'string' && p
|
||||
)
|
||||
if (!list.length) return { vendor: '', icds: [], selectGlob: '' }
|
||||
const vendor = bareOsQvacVulkanIcdVendor(path.basename(list[0]))
|
||||
if (vendor === 'software' || vendor === 'virtual' || vendor === 'other') {
|
||||
return { vendor, icds: list, selectGlob: '' }
|
||||
}
|
||||
const filtered = list.filter(
|
||||
(p) => bareOsQvacVulkanIcdVendor(path.basename(p)) === vendor
|
||||
)
|
||||
return {
|
||||
vendor,
|
||||
icds: filtered.length ? filtered : list,
|
||||
selectGlob: bareOsQvacVulkanVendorSelectGlob(vendor)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List hardware Vulkan ICD manifest paths (software ICDs excluded).
|
||||
* @param {{
|
||||
* dirs?: string[],
|
||||
* readdirSync?: (dir: string) => string[],
|
||||
* existsSync?: (p: string) => boolean
|
||||
* }} [opts]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function bareOsQvacDiscoverHardwareVulkanIcds(opts = {}) {
|
||||
const dirs = Array.isArray(opts.dirs) ? opts.dirs : BARE_OS_QVAC_VULKAN_ICD_DIRS
|
||||
const exists =
|
||||
typeof opts.existsSync === 'function'
|
||||
? opts.existsSync
|
||||
: (p) => {
|
||||
try {
|
||||
return hostFs.existsSync(p)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const readdir =
|
||||
typeof opts.readdirSync === 'function'
|
||||
? opts.readdirSync
|
||||
: (dir) => {
|
||||
try {
|
||||
return hostFs.readdirSync(dir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {{ path: string, score: number }[]} */
|
||||
const found = []
|
||||
for (const dir of dirs) {
|
||||
if (!exists(dir)) continue
|
||||
let names = []
|
||||
try {
|
||||
names = readdir(dir) || []
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const name of names) {
|
||||
const base = String(name)
|
||||
if (!base.toLowerCase().endsWith('.json')) continue
|
||||
const score = bareOsQvacScoreVulkanIcdFilename(base)
|
||||
if (score < 0) continue
|
||||
found.push({ path: path.join(dir, base), score })
|
||||
}
|
||||
}
|
||||
found.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
|
||||
return found.map((x) => x.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer real GPU Vulkan drivers before llama.cpp / vulkaninfo enumerate.
|
||||
* Disables lavapipe and, on Optimus laptops, nudges NVIDIA offload.
|
||||
*
|
||||
* @param {Record<string, unknown>} [env] guest/host env overlay (BARE_OS_*)
|
||||
* @param {{
|
||||
* procEnv?: Record<string, string | undefined> | null,
|
||||
* icds?: string[],
|
||||
* hasNvidiaSmi?: boolean
|
||||
* }} [opts]
|
||||
* @returns {{
|
||||
* note: string,
|
||||
* icds: string[],
|
||||
* disabledSoftware: boolean,
|
||||
* preferNvidia: boolean,
|
||||
* preferAmd: boolean,
|
||||
* vendor: string
|
||||
* }}
|
||||
*/
|
||||
export function bareOsQvacApplyHardwareVulkanEnv(env = {}, opts = {}) {
|
||||
const procEnv =
|
||||
opts.procEnv !== undefined
|
||||
? opts.procEnv
|
||||
: globalThis.process && globalThis.process.env
|
||||
? globalThis.process.env
|
||||
: null
|
||||
const empty = {
|
||||
note: '',
|
||||
icds: /** @type {string[]} */ ([]),
|
||||
disabledSoftware: false,
|
||||
preferNvidia: false,
|
||||
preferAmd: false,
|
||||
vendor: ''
|
||||
}
|
||||
if (!procEnv) return empty
|
||||
|
||||
const keep = String(
|
||||
env.BARE_OS_QVAC_KEEP_VK_ENV ?? procEnv.BARE_OS_QVAC_KEEP_VK_ENV ?? ''
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (keep === '1' || keep === 'true') {
|
||||
return { ...empty, note: 'BARE_OS_QVAC_KEEP_VK_ENV — left Vulkan env unchanged' }
|
||||
}
|
||||
|
||||
const softwareDisableGlob = '*lvp*,*lavapipe*,*swiftshader*'
|
||||
const prevDisable = String(procEnv.VK_LOADER_DRIVERS_DISABLE || '').trim()
|
||||
if (!prevDisable) {
|
||||
procEnv.VK_LOADER_DRIVERS_DISABLE = softwareDisableGlob
|
||||
} else if (!/lvp|lavapipe|swiftshader/i.test(prevDisable)) {
|
||||
procEnv.VK_LOADER_DRIVERS_DISABLE = prevDisable + ',' + softwareDisableGlob
|
||||
}
|
||||
|
||||
const discovered =
|
||||
Array.isArray(opts.icds) && opts.icds.length
|
||||
? opts.icds
|
||||
: bareOsQvacDiscoverHardwareVulkanIcds()
|
||||
const preferred = bareOsQvacPreferredVulkanIcds(discovered)
|
||||
const preferNvidia =
|
||||
preferred.vendor === 'nvidia' || Boolean(opts.hasNvidiaSmi)
|
||||
const preferAmd = preferred.vendor === 'amd' && !preferNvidia
|
||||
const vendor = preferNvidia ? 'nvidia' : preferred.vendor
|
||||
// nvidia-smi without a listed NVIDIA ICD (unusual install): keep the full
|
||||
// hardware list and let VK_LOADER_DRIVERS_SELECT=*nvidia* filter at load.
|
||||
const icds =
|
||||
preferNvidia && preferred.vendor !== 'nvidia'
|
||||
? discovered
|
||||
: preferred.icds
|
||||
|
||||
const userDrivers = String(
|
||||
env.BARE_OS_QVAC_VK_DRIVER_FILES ??
|
||||
procEnv.BARE_OS_QVAC_VK_DRIVER_FILES ??
|
||||
''
|
||||
).trim()
|
||||
const alreadyForced = Boolean(
|
||||
String(procEnv.VK_DRIVER_FILES || '').trim() ||
|
||||
String(procEnv.VK_ICD_FILENAMES || '').trim() ||
|
||||
userDrivers
|
||||
)
|
||||
|
||||
if (userDrivers && !String(procEnv.VK_DRIVER_FILES || '').trim()) {
|
||||
procEnv.VK_DRIVER_FILES = userDrivers
|
||||
} else if (!alreadyForced && icds.length) {
|
||||
// One vendor's manifests only — AMD boxes often ship unused Mesa ICDs
|
||||
// (intel/asahi/nouveau/virtio/gfxstream) that confuse ggml enumeration.
|
||||
procEnv.VK_DRIVER_FILES = icds.join(':')
|
||||
}
|
||||
|
||||
const selectMode = String(
|
||||
env.BARE_OS_QVAC_VK_SELECT ?? procEnv.BARE_OS_QVAC_VK_SELECT ?? 'auto'
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
// Hybrid Intel+NVIDIA/AMD: bind the discrete vendor so ggml never lands on
|
||||
// iGPU/llvmpipe. Set BARE_OS_QVAC_VK_SELECT=off to keep every hardware ICD.
|
||||
const selectGlob = preferNvidia
|
||||
? '*nvidia*'
|
||||
: preferred.selectGlob
|
||||
const wantSelect =
|
||||
selectGlob &&
|
||||
(selectMode === 'auto' ||
|
||||
selectMode === vendor ||
|
||||
(preferNvidia && selectMode === 'nvidia'))
|
||||
if (wantSelect && !String(procEnv.VK_LOADER_DRIVERS_SELECT || '').trim()) {
|
||||
procEnv.VK_LOADER_DRIVERS_SELECT = selectGlob
|
||||
}
|
||||
|
||||
if (preferNvidia) {
|
||||
if (!String(procEnv.__NV_PRIME_RENDER_OFFLOAD || '').trim()) {
|
||||
procEnv.__NV_PRIME_RENDER_OFFLOAD = '1'
|
||||
}
|
||||
if (!String(procEnv.__GLX_VENDOR_LIBRARY_NAME || '').trim()) {
|
||||
procEnv.__GLX_VENDOR_LIBRARY_NAME = 'nvidia'
|
||||
}
|
||||
if (!String(procEnv.__VK_LAYER_NV_optimus || '').trim()) {
|
||||
procEnv.__VK_LAYER_NV_optimus = 'NVIDIA_only'
|
||||
}
|
||||
}
|
||||
|
||||
const noteParts = ['disabled software Vulkan ICDs (lavapipe/llvmpipe)']
|
||||
if (icds.length) {
|
||||
noteParts.push(
|
||||
'hardware ICDs=' + icds.map((p) => path.basename(p)).join(',')
|
||||
)
|
||||
}
|
||||
if (preferNvidia) noteParts.push('NVIDIA Optimus offload env set')
|
||||
if (preferAmd) noteParts.push('RADV/AMD ICD select')
|
||||
const selectNow = String(procEnv.VK_LOADER_DRIVERS_SELECT || '')
|
||||
if (selectNow) {
|
||||
noteParts.push('VK_LOADER_DRIVERS_SELECT=' + selectNow)
|
||||
}
|
||||
return {
|
||||
note: noteParts.join('; '),
|
||||
icds,
|
||||
disabledSoftware: true,
|
||||
preferNvidia,
|
||||
preferAmd,
|
||||
vendor
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True on macOS. ggml-metal is the GPU backend there (no Vulkan runtime).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function bareOsQvacHostIsDarwin() {
|
||||
try {
|
||||
if (typeof os.platform === 'function') return os.platform() === 'darwin'
|
||||
} catch {
|
||||
/* fall back to process below */
|
||||
}
|
||||
return Boolean(
|
||||
globalThis.process && globalThis.process.platform === 'darwin'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Metal VRAM field ("18 GB", "Shared system memory: 18 GB", or a
|
||||
* nested `{ spdisplays_vram: "16 GB" }`) to bytes. Returns 0 when absent.
|
||||
* @param {unknown} v
|
||||
* @returns {number}
|
||||
*/
|
||||
export function bareOsQvacParseMetalMemory(v) {
|
||||
const raw =
|
||||
v && typeof v === 'object' && !Array.isArray(v)
|
||||
? String(
|
||||
/** @type {Record<string, unknown>} */ (v).spdisplays_vram || ''
|
||||
)
|
||||
: String(v || '')
|
||||
const m = /(\d+(?:\.\d+)?)\s*(MB|GB|TB)/i.exec(raw)
|
||||
if (!m) return 0
|
||||
const n = Number(m[1])
|
||||
const unit = String(m[2]).toUpperCase()
|
||||
const mult =
|
||||
unit === 'GB'
|
||||
? 1024 * 1024 * 1024
|
||||
: unit === 'TB'
|
||||
? 1024 * 1024 * 1024 * 1024
|
||||
: 1024 * 1024
|
||||
return Math.floor(n * mult)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `system_profiler SPDisplaysDataType -json` for Apple GPUs.
|
||||
* @param {string} text
|
||||
* @returns {BareOsQvacGpuInfo[]}
|
||||
*/
|
||||
export function bareOsQvacParseSystemProfilerDisplays(text) {
|
||||
/** @type {BareOsQvacGpuInfo[]} */
|
||||
const out = []
|
||||
let data
|
||||
try {
|
||||
data = JSON.parse(String(text || ''))
|
||||
} catch {
|
||||
return out
|
||||
}
|
||||
const list = Array.isArray(data && data.SPDisplaysDataType)
|
||||
? /** @type {Array<Record<string, unknown>>} */ (data.SPDisplaysDataType)
|
||||
: []
|
||||
const seen = new Set()
|
||||
for (const item of list) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const name = String(
|
||||
item.chipset_model || item._name || ''
|
||||
).trim()
|
||||
if (!name || seen.has(name)) continue
|
||||
seen.add(name)
|
||||
const vramRaw = item.spdisplays_vram ?? item.spdisplays_vram_shared ?? ''
|
||||
const memoryBytes = bareOsQvacParseMetalMemory(vramRaw)
|
||||
const eGpu = /egpu|external/i.test(String(item.sppci_device_type || ''))
|
||||
const discrete = eGpu || Boolean(item.spdisplays_vram)
|
||||
out.push({
|
||||
index: out.length,
|
||||
name,
|
||||
memoryBytes,
|
||||
deviceType: discrete
|
||||
? 'PHYSICAL_DEVICE_TYPE_DISCRETE_GPU'
|
||||
: 'PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU',
|
||||
source: 'metal'
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort GPUs for load attempts: highest memory first; discrete before integrated on ties.
|
||||
* Software Vulkan devices are ranked last.
|
||||
* @param {BareOsQvacGpuInfo[]} gpus
|
||||
* @returns {BareOsQvacGpuInfo[]}
|
||||
*/
|
||||
export function bareOsQvacRankGpus(gpus) {
|
||||
return [...gpus].sort((a, b) => {
|
||||
const as = bareOsQvacIsSoftwareVulkanDevice(a) ? 1 : 0
|
||||
const bs = bareOsQvacIsSoftwareVulkanDevice(b) ? 1 : 0
|
||||
if (as !== bs) return as - bs
|
||||
if (b.memoryBytes !== a.memoryBytes) return b.memoryBytes - a.memoryBytes
|
||||
const ad = /DISCRETE/i.test(a.deviceType) ? 1 : 0
|
||||
const bd = /DISCRETE/i.test(b.deviceType) ? 1 : 0
|
||||
if (bd !== ad) return bd - ad
|
||||
return a.index - b.index
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ordered main-gpu candidates for auto mode.
|
||||
* @param {BareOsQvacGpuInfo[]} ranked
|
||||
* @returns {Array<string | number>}
|
||||
*/
|
||||
export function bareOsQvacMainGpuCandidates(ranked) {
|
||||
/** @type {Array<string | number>} */
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
const push = (v) => {
|
||||
const k = String(v)
|
||||
if (seen.has(k)) return
|
||||
seen.add(k)
|
||||
out.push(v)
|
||||
}
|
||||
|
||||
const hardware = bareOsQvacHardwareGpus(ranked)
|
||||
const vulkanBacked = hardware.filter((g) =>
|
||||
String(g.source || '').includes('vulkaninfo')
|
||||
)
|
||||
if (vulkanBacked.length) {
|
||||
for (const g of vulkanBacked) push(g.index)
|
||||
push('dedicated')
|
||||
return out
|
||||
}
|
||||
|
||||
// nvidia-smi indices ≠ Vulkan indices on hybrid laptops — prefer class + common slots.
|
||||
if (hardware.length) {
|
||||
push('dedicated')
|
||||
push(1)
|
||||
push(0)
|
||||
push(2)
|
||||
push(3)
|
||||
return out
|
||||
}
|
||||
|
||||
push('dedicated')
|
||||
push(1)
|
||||
push(0)
|
||||
push(2)
|
||||
push(3)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe host GPUs (best-effort). Never throws.
|
||||
* @param {{ runCommand?: typeof bareOsQvacRunHostCommand }} [opts]
|
||||
* @returns {Promise<BareOsQvacGpuInfo[]>}
|
||||
*/
|
||||
export async function bareOsQvacProbeGpus(opts = {}) {
|
||||
try {
|
||||
const run = opts.runCommand || bareOsQvacRunHostCommand
|
||||
|
||||
// macOS: no vulkaninfo/nvidia-smi. Metal is the only GPU backend, so probe
|
||||
// the Apple GPU via system_profiler for name/VRAM reporting.
|
||||
if (bareOsQvacHostIsDarwin()) {
|
||||
const sp = await run('system_profiler', ['SPDisplaysDataType', '-json'], {
|
||||
timeoutMs: 15000
|
||||
})
|
||||
const metal = bareOsQvacParseSystemProfilerDisplays(sp || '')
|
||||
return bareOsQvacRankGpus(metal)
|
||||
}
|
||||
|
||||
/** @type {BareOsQvacGpuInfo[]} */
|
||||
let vulkan = []
|
||||
|
||||
const full =
|
||||
(await run('vulkaninfo', [], { timeoutMs: 12000 })) ||
|
||||
(await run('vulkaninfo', ['--summary'], { timeoutMs: 8000 }))
|
||||
if (full) vulkan = bareOsQvacParseVulkaninfo(full)
|
||||
|
||||
const smi = await run(
|
||||
'nvidia-smi',
|
||||
['--query-gpu=name,memory.total', '--format=csv,noheader,nounits'],
|
||||
{ timeoutMs: 5000 }
|
||||
)
|
||||
const nvidia = smi ? bareOsQvacParseNvidiaSmi(smi) : []
|
||||
if (vulkan.length && nvidia.length) {
|
||||
vulkan = bareOsQvacMergeNvidiaHints(vulkan, nvidia)
|
||||
} else if (!vulkan.length && nvidia.length) {
|
||||
// No Vulkan enumeration — synthesize placeholders; load path still tries dedicated + indices.
|
||||
vulkan = nvidia.map((n, i) => ({
|
||||
index: i,
|
||||
name: n.name,
|
||||
memoryBytes: n.memoryBytes,
|
||||
deviceType: 'PHYSICAL_DEVICE_TYPE_DISCRETE_GPU',
|
||||
source: 'nvidia-smi'
|
||||
}))
|
||||
}
|
||||
|
||||
// Drop software adapters from the preferred list (keep rank helper for tests).
|
||||
return bareOsQvacRankGpus(vulkan)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,600 @@
|
||||
/**
|
||||
* QVAC GGUF storage: HDMS /mnt/models (durable) + host cache for llama.cpp load.
|
||||
*/
|
||||
|
||||
import path from '#host-path'
|
||||
import fs from '#host-fs'
|
||||
import {
|
||||
bareOsQvacHostModelsDir,
|
||||
bareOsQvacHostConfigPath
|
||||
} from './paths.js'
|
||||
|
||||
export const BARE_OS_QVAC_MODELS_HDMS_LABEL = 'models'
|
||||
export const BARE_OS_QVAC_MODELS_GUEST_ROOT = '/mnt/models'
|
||||
|
||||
/**
|
||||
* @param {string} [hostCacheDir]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function bareOsQvacModelsHostCacheDir(hostCacheDir) {
|
||||
const d = String(hostCacheDir || '').trim()
|
||||
return d || bareOsQvacHostModelsDir()
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest path for a GGUF basename under /mnt/models.
|
||||
* @param {string} fileName
|
||||
*/
|
||||
export function bareOsQvacModelsGuestPath(fileName) {
|
||||
const base = String(fileName || '')
|
||||
.replace(/^\/+/, '')
|
||||
.split('/')
|
||||
.pop()
|
||||
return BARE_OS_QVAC_MODELS_GUEST_ROOT + '/' + (base || 'model.gguf')
|
||||
}
|
||||
|
||||
/**
|
||||
* Host path for a GGUF basename under the cache dir.
|
||||
* @param {string} fileName
|
||||
* @param {string} [hostCacheDir]
|
||||
*/
|
||||
export function bareOsQvacModelsHostPath(fileName, hostCacheDir) {
|
||||
const base = String(fileName || '')
|
||||
.replace(/^\/+/, '')
|
||||
.split('/')
|
||||
.pop()
|
||||
return path.join(bareOsQvacModelsHostCacheDir(hostCacheDir), base || 'model.gguf')
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip leading content-hash prefix used by QVAC cache filenames.
|
||||
* `5b8aae816570a09d_Qwen3-0.6B-Q4_0.gguf` → `Qwen3-0.6B-Q4_0.gguf`
|
||||
* @param {string} fileName
|
||||
*/
|
||||
export function bareOsQvacModelsStripHashPrefix(fileName) {
|
||||
const base =
|
||||
String(fileName || '')
|
||||
.replace(/^\/+/, '')
|
||||
.split('/')
|
||||
.pop() || ''
|
||||
const m = /^[0-9a-f]{8,}_(.+)$/i.exec(base)
|
||||
return m ? m[1] : base
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether host file size matches an expected guest size (skip materialize).
|
||||
* @param {string} hostPath
|
||||
* @param {number} guestSize
|
||||
*/
|
||||
export function bareOsQvacHostGgufMatchesSize(hostPath, guestSize) {
|
||||
const want = Number(guestSize)
|
||||
if (!Number.isFinite(want) || want <= 0) return false
|
||||
try {
|
||||
const st = fs.statSync(hostPath)
|
||||
return st.isFile() && st.size === want
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a host filesystem path Bare's module resolver cannot load from
|
||||
* `bare:/app.bundle` (absolute POSIX / Windows, not a URL specifier).
|
||||
* @param {string} filePath
|
||||
*/
|
||||
export function bareOsQvacIsHostJsonConfigPath(filePath) {
|
||||
const p = String(filePath || '')
|
||||
if (!/\.json$/i.test(p)) return false
|
||||
// Windows drive paths (`C:\...`) look like a URL scheme if checked first.
|
||||
if (/^[A-Za-z]:[\\/]/.test(p)) return true
|
||||
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/i.test(p)) return false
|
||||
return p.startsWith('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a host JSON file for QVAC config. Returns undefined when the path is
|
||||
* not a host .json file or the read/parse fails (caller may delegate).
|
||||
* @param {unknown} id
|
||||
* @returns {unknown | undefined}
|
||||
*/
|
||||
export function bareOsQvacTryReadHostJsonConfig(id) {
|
||||
const filePath = String(id || '')
|
||||
if (!bareOsQvacIsHostJsonConfigPath(filePath)) return undefined
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bare's `@qvac/sdk` resolve-config.bare.js loads config via free `require(path)`.
|
||||
* In standalone that identifier is `globalThis.require` from ctx-bare's
|
||||
* `createRequire(bare:/app.bundle/.../bare-os-ctx-bare.js)`. That resolver
|
||||
* cannot see host files (`MODULE_NOT_FOUND` with `bare:/home/...` candidates).
|
||||
* Always wrap — do not skip when require already exists.
|
||||
*/
|
||||
export function bareOsQvacInstallConfigRequireShim() {
|
||||
const g = globalThis
|
||||
const prev = typeof g.require === 'function' ? g.require : null
|
||||
if (prev && prev.__bareOsQvacConfigRequire) return prev
|
||||
|
||||
function bareOsQvacConfigRequire(id, ...rest) {
|
||||
const parsed = bareOsQvacTryReadHostJsonConfig(id)
|
||||
if (parsed !== undefined) return parsed
|
||||
if (typeof prev === 'function') {
|
||||
return Reflect.apply(prev, this, [id, ...rest])
|
||||
}
|
||||
throw new ReferenceError(
|
||||
'require is not defined (Bare: only host .json config paths are shimmed)'
|
||||
)
|
||||
}
|
||||
bareOsQvacConfigRequire.__bareOsQvacConfigRequire = true
|
||||
if (prev) {
|
||||
for (const key of ['cache', 'extensions', 'main', 'resolve', 'addon', 'asset']) {
|
||||
try {
|
||||
if (prev[key] !== undefined) bareOsQvacConfigRequire[key] = prev[key]
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
g.require = bareOsQvacConfigRequire
|
||||
return bareOsQvacConfigRequire
|
||||
}
|
||||
|
||||
/**
|
||||
* Write runtime qvac.config.json with absolute cacheDirectory and set QVAC_CONFIG_PATH.
|
||||
* Must run before `@qvac/sdk` plugins() so setSDKConfig sees it once.
|
||||
* @param {{
|
||||
* cacheDir?: string,
|
||||
* loggerLevel?: string,
|
||||
* loggerConsoleOutput?: boolean
|
||||
* }} [opts]
|
||||
* @returns {{ cacheDir: string, configPath: string }}
|
||||
*/
|
||||
export function bareOsQvacPrepareHostCacheConfig(opts = {}) {
|
||||
bareOsQvacInstallConfigRequireShim()
|
||||
const cacheDir = bareOsQvacModelsHostCacheDir(opts.cacheDir)
|
||||
const configPath = bareOsQvacHostConfigPath()
|
||||
const qvacRoot = path.dirname(configPath)
|
||||
fs.mkdirSync(cacheDir, { recursive: true })
|
||||
fs.mkdirSync(qvacRoot, { recursive: true })
|
||||
const procEnv =
|
||||
globalThis.process && globalThis.process.env
|
||||
? globalThis.process.env
|
||||
: null
|
||||
// Quiet by default — SDK debug + llamacpp chatter floods the agent TTY.
|
||||
// Override with BARE_OS_QVAC_LOG_LEVEL / QVAC_LOG_LEVEL (e.g. debug).
|
||||
const envLevel = procEnv
|
||||
? String(
|
||||
procEnv.BARE_OS_QVAC_LOG_LEVEL || procEnv.QVAC_LOG_LEVEL || ''
|
||||
).trim()
|
||||
: ''
|
||||
const loggerLevel = opts.loggerLevel || envLevel || 'error'
|
||||
let loggerConsoleOutput = opts.loggerConsoleOutput
|
||||
if (loggerConsoleOutput === undefined && procEnv) {
|
||||
const ec = String(procEnv.BARE_OS_QVAC_LOGGER_CONSOLE || '').trim()
|
||||
if (ec === '1' || ec === 'true') loggerConsoleOutput = true
|
||||
else if (ec === '0' || ec === 'false') loggerConsoleOutput = false
|
||||
}
|
||||
if (loggerConsoleOutput === undefined) {
|
||||
// Keep console on so real errors still surface; level gates noise.
|
||||
loggerConsoleOutput = true
|
||||
}
|
||||
const body = {
|
||||
plugins: ['@qvac/sdk/llamacpp-completion/plugin'],
|
||||
cacheDirectory: cacheDir,
|
||||
loggerLevel,
|
||||
loggerConsoleOutput: Boolean(loggerConsoleOutput),
|
||||
httpDownloadConcurrency: 3,
|
||||
httpConnectionTimeoutMs: 15000
|
||||
}
|
||||
fs.writeFileSync(configPath, JSON.stringify(body, null, 2) + '\n')
|
||||
if (procEnv) {
|
||||
procEnv.QVAC_CONFIG_PATH = configPath
|
||||
// Also pin env so @qvac/logging picks the same level before config apply.
|
||||
if (!procEnv.QVAC_LOG_LEVEL) procEnv.QVAC_LOG_LEVEL = loggerLevel
|
||||
}
|
||||
return { cacheDir, configPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure writable HDMS label `models` exists (best-effort).
|
||||
* @param {{
|
||||
* ctx?: Record<string, unknown> | null,
|
||||
* hdmsController?: {
|
||||
* active?: boolean,
|
||||
* byLabel?: Map<string, unknown>,
|
||||
* create?: (ctx: Record<string, unknown>, label: string) => Promise<void>
|
||||
* } | null
|
||||
* }} opts
|
||||
* @returns {Promise<{ ok: boolean, created?: boolean, reason?: string, drive?: unknown }>}
|
||||
*/
|
||||
export async function bareOsQvacEnsureModelsHdms(opts = {}) {
|
||||
const hc = opts.hdmsController
|
||||
const ctx = opts.ctx
|
||||
if (!hc || typeof hc.create !== 'function') {
|
||||
return { ok: false, reason: 'no_hdms_controller' }
|
||||
}
|
||||
if (!hc.active) return { ok: false, reason: 'hdms_inactive' }
|
||||
if (hc.byLabel && hc.byLabel.has(BARE_OS_QVAC_MODELS_HDMS_LABEL)) {
|
||||
const slot = /** @type {{ drive?: unknown }} */ (
|
||||
hc.byLabel.get(BARE_OS_QVAC_MODELS_HDMS_LABEL)
|
||||
)
|
||||
return { ok: true, created: false, drive: slot && slot.drive }
|
||||
}
|
||||
if (!ctx || typeof ctx !== 'object') {
|
||||
return { ok: false, reason: 'no_ctx' }
|
||||
}
|
||||
try {
|
||||
await hc.create(ctx, BARE_OS_QVAC_MODELS_HDMS_LABEL)
|
||||
const slot = hc.byLabel
|
||||
? /** @type {{ drive?: unknown }} */ (
|
||||
hc.byLabel.get(BARE_OS_QVAC_MODELS_HDMS_LABEL)
|
||||
)
|
||||
: null
|
||||
return { ok: true, created: true, drive: slot && slot.drive }
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e && typeof e === 'object' && 'message' in e
|
||||
? String(/** @type {{ message: unknown }} */ (e).message)
|
||||
: String(e)
|
||||
if (/already exists/i.test(msg)) {
|
||||
const slot = hc.byLabel
|
||||
? /** @type {{ drive?: unknown }} */ (
|
||||
hc.byLabel.get(BARE_OS_QVAC_MODELS_HDMS_LABEL)
|
||||
)
|
||||
: null
|
||||
return { ok: true, created: false, drive: slot && slot.drive }
|
||||
}
|
||||
return { ok: false, reason: msg }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {unknown} vfs
|
||||
* @param {string} guestPath
|
||||
* @returns {Promise<{ size: number } | null>}
|
||||
*/
|
||||
export async function bareOsQvacGuestGgufStat(vfs, guestPath) {
|
||||
if (!vfs || typeof vfs !== 'object') return null
|
||||
const v = /** @type {Record<string, any>} */ (vfs)
|
||||
try {
|
||||
if (typeof v.stat === 'function') {
|
||||
const st = await v.stat(guestPath)
|
||||
const size = Number(st && (st.size ?? st.byteLength))
|
||||
if (Number.isFinite(size) && size > 0) return { size }
|
||||
}
|
||||
} catch {
|
||||
/* missing */
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream Hyperdrive path → host file (avoid buffering full GGUF).
|
||||
* @param {{ createReadStream?: (p: string) => import('stream').Readable, get?: Function }} drive
|
||||
* @param {string} drivePath e.g. /Qwen3-0.6B-Q4_0.gguf
|
||||
* @param {string} hostPath
|
||||
* @returns {Promise<number>} bytes written
|
||||
*/
|
||||
export async function bareOsQvacStreamDriveToHost(drive, drivePath, hostPath) {
|
||||
const rel = String(drivePath || '').startsWith('/')
|
||||
? String(drivePath)
|
||||
: '/' + String(drivePath || '').replace(/^\/+/, '')
|
||||
if (drive && typeof drive.createReadStream === 'function') {
|
||||
await new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
const fail = (err) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
reject(err)
|
||||
}
|
||||
const done = () => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(undefined)
|
||||
}
|
||||
try {
|
||||
const rs = drive.createReadStream(rel)
|
||||
const ws = fs.createWriteStream(hostPath)
|
||||
rs.on('error', fail)
|
||||
ws.on('error', fail)
|
||||
ws.on('finish', done)
|
||||
if (typeof rs.pipe === 'function') {
|
||||
rs.pipe(ws)
|
||||
} else {
|
||||
rs.on('data', (chunk) => {
|
||||
ws.write(chunk)
|
||||
})
|
||||
rs.on('end', () => ws.end())
|
||||
}
|
||||
} catch (e) {
|
||||
fail(e)
|
||||
}
|
||||
})
|
||||
return fs.statSync(hostPath).size
|
||||
}
|
||||
if (drive && typeof drive.get === 'function') {
|
||||
const buf = await drive.get(rel)
|
||||
if (!buf || !(buf.byteLength > 0 || buf.length > 0)) {
|
||||
throw new Error('empty_drive_get')
|
||||
}
|
||||
const u8 = Buffer.isBuffer(buf)
|
||||
? buf
|
||||
: Buffer.from(
|
||||
buf.buffer || buf,
|
||||
buf.byteOffset || 0,
|
||||
buf.byteLength || buf.length
|
||||
)
|
||||
fs.writeFileSync(hostPath, u8)
|
||||
return u8.byteLength
|
||||
}
|
||||
throw new Error('drive_no_stream')
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize guest GGUF → host cache path.
|
||||
* Prefers Hyperdrive createReadStream when `drive` is provided; else vfs.readFile.
|
||||
* Skips when host size already matches guest size.
|
||||
* @param {{
|
||||
* vfs?: Record<string, any> | null,
|
||||
* guestPath: string,
|
||||
* hostPath: string,
|
||||
* drive?: { createReadStream?: Function, get?: Function } | null,
|
||||
* drivePath?: string,
|
||||
* guestSize?: number
|
||||
* }} o
|
||||
* @returns {Promise<{ ok: boolean, bytes?: number, reason?: string, skipped?: boolean }>}
|
||||
*/
|
||||
export async function bareOsQvacMaterializeGgufToHost(o) {
|
||||
const guestPath = String(o.guestPath || '')
|
||||
const hostPath = String(o.hostPath || '')
|
||||
if (!guestPath || !hostPath) {
|
||||
return { ok: false, reason: 'missing_args' }
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(hostPath), { recursive: true })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
let guestSize = Number(o.guestSize)
|
||||
if (!(Number.isFinite(guestSize) && guestSize > 0) && o.vfs) {
|
||||
const st = await bareOsQvacGuestGgufStat(o.vfs, guestPath)
|
||||
if (st) guestSize = st.size
|
||||
}
|
||||
if (
|
||||
Number.isFinite(guestSize) &&
|
||||
guestSize > 0 &&
|
||||
bareOsQvacHostGgufMatchesSize(hostPath, guestSize)
|
||||
) {
|
||||
return { ok: true, bytes: guestSize, skipped: true, reason: 'size_match' }
|
||||
}
|
||||
|
||||
const base =
|
||||
guestPath.replace(/^\/+/, '').split('/').pop() || path.basename(hostPath)
|
||||
const drivePath =
|
||||
o.drivePath ||
|
||||
(base.startsWith('/') ? base : '/' + base)
|
||||
|
||||
if (o.drive) {
|
||||
try {
|
||||
const bytes = await bareOsQvacStreamDriveToHost(
|
||||
o.drive,
|
||||
drivePath,
|
||||
hostPath
|
||||
)
|
||||
return { ok: true, bytes }
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e && typeof e === 'object' && 'message' in e
|
||||
? String(/** @type {{ message: unknown }} */ (e).message)
|
||||
: String(e)
|
||||
// Fall through to vfs.readFile
|
||||
if (!o.vfs || typeof o.vfs.readFile !== 'function') {
|
||||
return { ok: false, reason: msg }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!o.vfs || typeof o.vfs.readFile !== 'function') {
|
||||
return { ok: false, reason: 'vfs_no_readFile' }
|
||||
}
|
||||
const buf = await o.vfs.readFile(guestPath)
|
||||
if (!buf || !(buf.byteLength > 0 || buf.length > 0)) {
|
||||
return { ok: false, reason: 'empty_guest' }
|
||||
}
|
||||
const u8 = Buffer.isBuffer(buf)
|
||||
? buf
|
||||
: Buffer.from(
|
||||
buf.buffer || buf,
|
||||
buf.byteOffset || 0,
|
||||
buf.byteLength || buf.length
|
||||
)
|
||||
fs.writeFileSync(hostPath, u8)
|
||||
return { ok: true, bytes: u8.byteLength }
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e && typeof e === 'object' && 'message' in e
|
||||
? String(/** @type {{ message: unknown }} */ (e).message)
|
||||
: String(e)
|
||||
return { ok: false, reason: msg }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror host GGUF into /mnt/models/<basename>.
|
||||
* Skips when guest size already matches host.
|
||||
* @param {{
|
||||
* vfs: Record<string, any>,
|
||||
* hostPath: string,
|
||||
* guestName?: string
|
||||
* }} o
|
||||
* @returns {Promise<{ ok: boolean, guestPath?: string, reason?: string, skipped?: boolean }>}
|
||||
*/
|
||||
export async function bareOsQvacMirrorHostGgufToHdms(o) {
|
||||
const hostPath = String(o.hostPath || '')
|
||||
if (!hostPath || !o.vfs || typeof o.vfs.writeFile !== 'function') {
|
||||
return { ok: false, reason: 'missing_args' }
|
||||
}
|
||||
const base =
|
||||
String(o.guestName || '')
|
||||
.replace(/^\/+/, '')
|
||||
.split('/')
|
||||
.pop() || path.basename(hostPath)
|
||||
const guestPath = bareOsQvacModelsGuestPath(base)
|
||||
let hostSize = 0
|
||||
try {
|
||||
hostSize = fs.statSync(hostPath).size
|
||||
} catch {
|
||||
return { ok: false, reason: 'host_missing' }
|
||||
}
|
||||
if (!(hostSize > 0)) return { ok: false, reason: 'empty_host' }
|
||||
const guest = await bareOsQvacGuestGgufStat(o.vfs, guestPath)
|
||||
if (guest && guest.size === hostSize) {
|
||||
return { ok: true, guestPath, skipped: true, reason: 'size_match' }
|
||||
}
|
||||
try {
|
||||
const buf = fs.readFileSync(hostPath)
|
||||
if (!buf || !buf.byteLength) return { ok: false, reason: 'empty_host' }
|
||||
await o.vfs.writeFile(guestPath, buf)
|
||||
return { ok: true, guestPath }
|
||||
} catch (e) {
|
||||
const msg =
|
||||
e && typeof e === 'object' && 'message' in e
|
||||
? String(/** @type {{ message: unknown }} */ (e).message)
|
||||
: String(e)
|
||||
return { ok: false, reason: msg }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List GGUF basenames under /mnt/models (best-effort).
|
||||
* @param {Record<string, any>} vfs
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
export async function bareOsQvacListHdmsGgufs(vfs) {
|
||||
if (!vfs || typeof vfs.readdir !== 'function') return []
|
||||
try {
|
||||
const entries = await vfs.readdir(BARE_OS_QVAC_MODELS_GUEST_ROOT)
|
||||
if (!Array.isArray(entries)) return []
|
||||
return entries
|
||||
.map((e) => {
|
||||
if (typeof e === 'string') return e
|
||||
if (e && typeof e === 'object' && 'name' in e) return String(e.name)
|
||||
return ''
|
||||
})
|
||||
.filter((n) => /\.gguf$/i.test(n))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List GGUF basenames in host cache dir.
|
||||
* @param {string} [hostCacheDir]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function bareOsQvacListHostGgufs(hostCacheDir) {
|
||||
const dir = bareOsQvacModelsHostCacheDir(hostCacheDir)
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((n) => typeof n === 'string' && /\.gguf$/i.test(n))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a guest GGUF matching model id / cache basename.
|
||||
* @param {Record<string, any>} vfs
|
||||
* @param {string} hintName e.g. Qwen3-0.6B-Q4_0.gguf or hashed cache name
|
||||
* @returns {Promise<string | null>} guest absolute path
|
||||
*/
|
||||
export async function bareOsQvacFindHdmsGguf(vfs, hintName) {
|
||||
const names = await bareOsQvacListHdmsGgufs(vfs)
|
||||
if (!names.length) return null
|
||||
const hint = String(hintName || '')
|
||||
const stripped = bareOsQvacModelsStripHashPrefix(hint)
|
||||
for (const n of names) {
|
||||
if (n === hint || n === stripped) return bareOsQvacModelsGuestPath(n)
|
||||
if (stripped && n.endsWith(stripped)) return bareOsQvacModelsGuestPath(n)
|
||||
if (hint && n.includes(stripped)) return bareOsQvacModelsGuestPath(n)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy any HDMS GGUFs missing/mismatched on the host cache (before loadModel).
|
||||
* @param {{
|
||||
* vfs: Record<string, any>,
|
||||
* hostCacheDir?: string,
|
||||
* drive?: { createReadStream?: Function, get?: Function } | null
|
||||
* }} o
|
||||
* @returns {Promise<{ materialized: string[], skipped: string[], errors: string[] }>}
|
||||
*/
|
||||
export async function bareOsQvacMaterializeHdmsModelsToHost(o) {
|
||||
/** @type {string[]} */
|
||||
const materialized = []
|
||||
/** @type {string[]} */
|
||||
const skipped = []
|
||||
/** @type {string[]} */
|
||||
const errors = []
|
||||
const cacheDir = bareOsQvacModelsHostCacheDir(o.hostCacheDir)
|
||||
try {
|
||||
fs.mkdirSync(cacheDir, { recursive: true })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const names = await bareOsQvacListHdmsGgufs(o.vfs)
|
||||
for (const name of names) {
|
||||
const guestPath = bareOsQvacModelsGuestPath(name)
|
||||
const hostPath = bareOsQvacModelsHostPath(name, cacheDir)
|
||||
const r = await bareOsQvacMaterializeGgufToHost({
|
||||
vfs: o.vfs,
|
||||
guestPath,
|
||||
hostPath,
|
||||
drive: o.drive || null,
|
||||
drivePath: '/' + name
|
||||
})
|
||||
if (r.ok && r.skipped) skipped.push(name)
|
||||
else if (r.ok) materialized.push(name)
|
||||
else errors.push(name + ': ' + (r.reason || 'fail'))
|
||||
}
|
||||
return { materialized, skipped, errors }
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror host-cache GGUFs into HDMS after download/load (best-effort).
|
||||
* @param {{
|
||||
* vfs: Record<string, any>,
|
||||
* hostCacheDir?: string
|
||||
* }} o
|
||||
* @returns {Promise<{ mirrored: string[], skipped: string[], errors: string[] }>}
|
||||
*/
|
||||
export async function bareOsQvacMirrorHostModelsToHdms(o) {
|
||||
/** @type {string[]} */
|
||||
const mirrored = []
|
||||
/** @type {string[]} */
|
||||
const skipped = []
|
||||
/** @type {string[]} */
|
||||
const errors = []
|
||||
const names = bareOsQvacListHostGgufs(o.hostCacheDir)
|
||||
for (const name of names) {
|
||||
const hostPath = bareOsQvacModelsHostPath(name, o.hostCacheDir)
|
||||
const r = await bareOsQvacMirrorHostGgufToHdms({
|
||||
vfs: o.vfs,
|
||||
hostPath,
|
||||
guestName: name
|
||||
})
|
||||
if (r.ok && r.skipped) skipped.push(name)
|
||||
else if (r.ok) mirrored.push(name)
|
||||
else errors.push(name + ': ' + (r.reason || 'fail'))
|
||||
}
|
||||
return { mirrored, skipped, errors }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Pack graph anchor for QVAC.
|
||||
* Static import so bare-pack embeds the bind module + @qvac/sdk (dynamic
|
||||
* import() alone was not present in app.bundle at runtime).
|
||||
*
|
||||
* Loaded from pack-imports / host only; SDK JS evaluates when this module loads.
|
||||
* When packing with --skip-qvac, `@qvac/sdk` is remapped to stubs.
|
||||
*/
|
||||
import * as qvacSdkBind from './bare-os-qvac-sdk-bind.mjs'
|
||||
|
||||
export { qvacSdkBind }
|
||||
|
||||
/** @deprecated kept for pack-imports reference */
|
||||
export function bareOsQvacEnsurePackGraph() {
|
||||
return qvacSdkBind
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* Static @qvac/sdk binding for Bare standalone pack + in-process Bare.
|
||||
*
|
||||
* Imported only via relative dynamic `import('./bare-os-qvac-sdk-bind.mjs')` so
|
||||
* booter startup does not evaluate the SDK (avoids CodeRange OOM). bare-pack
|
||||
* still embeds this module because pack-anchor / host reference the literal path.
|
||||
*
|
||||
* When packing with --skip-qvac, `@qvac/sdk` resolves to build/stubs (see bare-standalone).
|
||||
*/
|
||||
import * as sdkNs from '@qvac/sdk'
|
||||
import { llmPlugin } from '@qvac/sdk/llamacpp-completion/plugin'
|
||||
import LlmLlamacpp from '@qvac/llm-llamacpp'
|
||||
import barePath from 'bare-path'
|
||||
import hostFs from '#host-fs'
|
||||
import os from 'bare-os'
|
||||
import { createRequire } from 'module'
|
||||
import { bareOsQvacPrepareHostCacheConfig } from './bare-os-qvac-models-store.mjs'
|
||||
import {
|
||||
bareOsQvacHostBackendsRoot,
|
||||
bundledExecCandidates
|
||||
} from './paths.js'
|
||||
|
||||
const requireFromBind = createRequire(import.meta.url)
|
||||
|
||||
/** Well-known ggml backend filenames when bundle readdir fails. */
|
||||
const KNOWN_GGML_BACKEND_NAMES = [
|
||||
'libqvac-ggml-cpu-x64.so',
|
||||
'libqvac-ggml-cpu-sse42.so',
|
||||
'libqvac-ggml-cpu-haswell.so',
|
||||
'libqvac-ggml-cpu-ivybridge.so',
|
||||
'libqvac-ggml-cpu-sandybridge.so',
|
||||
'libqvac-ggml-cpu-skylake.so',
|
||||
'libqvac-ggml-cpu-skylakex.so',
|
||||
'libqvac-ggml-cpu-alderlake.so',
|
||||
'libqvac-ggml-cpu-cannonlake.so',
|
||||
'libqvac-ggml-cpu-cascadelake.so',
|
||||
'libqvac-ggml-cpu-cooperlake.so',
|
||||
'libqvac-ggml-cpu-icelake.so',
|
||||
'libqvac-ggml-cpu-sapphirerapids.so',
|
||||
'libqvac-ggml-cpu-piledriver.so',
|
||||
'libqvac-ggml-cpu-zen4.so',
|
||||
'libqvac-ggml-vulkan.so',
|
||||
'libqvac-ggml-cpu.dylib',
|
||||
'libqvac-ggml-metal.dylib',
|
||||
'qvac-ggml-cpu.dll',
|
||||
'qvac-ggml-vulkan.dll'
|
||||
]
|
||||
|
||||
/**
|
||||
* @param {string} probeDir absolute path to `<host>/qvac__llm-llamacpp`
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function backendsSlotHasCpu(probeDir) {
|
||||
try {
|
||||
if (!hostFs.existsSync(probeDir)) return false
|
||||
return hostFs
|
||||
.readdirSync(probeDir)
|
||||
.some((n) => typeof n === 'string' && /ggml-cpu/i.test(n))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} root prebuilds-equivalent root (contains `<host>/qvac__llm-llamacpp`)
|
||||
* @param {string} slot `<host>/qvac__llm-llamacpp`
|
||||
* @returns {string} root if valid, else ''
|
||||
*/
|
||||
function acceptBackendsRoot(root, slot) {
|
||||
if (!root) return ''
|
||||
const abs = barePath.resolve(root)
|
||||
return backendsSlotHasCpu(barePath.join(abs, slot)) ? abs : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy ggml backends from srcDir into host cache; return host root or ''.
|
||||
* @param {string} srcDir
|
||||
* @param {string} slot
|
||||
* @param {string[]} [names]
|
||||
* @returns {string}
|
||||
*/
|
||||
function materializeBackendsToHost(srcDir, slot, names) {
|
||||
/** @type {string[]} */
|
||||
let fileNames = names && names.length ? names.slice() : []
|
||||
if (!fileNames.length) {
|
||||
try {
|
||||
fileNames = hostFs
|
||||
.readdirSync(srcDir)
|
||||
.filter((n) => typeof n === 'string' && /\.(so|dylib|dll)$/i.test(n))
|
||||
} catch {
|
||||
fileNames = []
|
||||
}
|
||||
}
|
||||
if (!fileNames.length) {
|
||||
for (const name of KNOWN_GGML_BACKEND_NAMES) {
|
||||
try {
|
||||
if (hostFs.existsSync(barePath.join(srcDir, name))) fileNames.push(name)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fileNames.length) return ''
|
||||
|
||||
const hostRoot = bareOsQvacHostBackendsRoot()
|
||||
const destDir = barePath.join(hostRoot, slot)
|
||||
try {
|
||||
hostFs.mkdirSync(destDir, { recursive: true })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
for (const name of fileNames) {
|
||||
const from = barePath.join(srcDir, name)
|
||||
const to = barePath.join(destDir, name)
|
||||
try {
|
||||
let srcSize = -1
|
||||
try {
|
||||
srcSize = hostFs.statSync(from).size
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
if (hostFs.statSync(to).size === srcSize) continue
|
||||
} catch {
|
||||
/* copy */
|
||||
}
|
||||
if (typeof hostFs.copyFileSync === 'function') {
|
||||
try {
|
||||
hostFs.copyFileSync(from, to)
|
||||
continue
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
hostFs.writeFileSync(to, hostFs.readFileSync(from))
|
||||
} catch {
|
||||
/* best-effort per file */
|
||||
}
|
||||
}
|
||||
return acceptBackendsRoot(hostRoot, slot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer Bare `require.asset()` so packed `/app.bundle` assets extract to real paths.
|
||||
* @param {string} host
|
||||
* @param {string} slot
|
||||
* @returns {string}
|
||||
*/
|
||||
function materializeBackendsViaAsset(host, slot) {
|
||||
let llmReq
|
||||
try {
|
||||
const main = requireFromBind.resolve('@qvac/llm-llamacpp')
|
||||
llmReq = createRequire(main)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
if (typeof llmReq.asset !== 'function') return ''
|
||||
|
||||
const hostRoot = bareOsQvacHostBackendsRoot()
|
||||
const destDir = barePath.join(hostRoot, slot)
|
||||
try {
|
||||
hostFs.mkdirSync(destDir, { recursive: true })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
let wrote = 0
|
||||
/** @type {string} */
|
||||
let realAssetDir = ''
|
||||
for (const name of KNOWN_GGML_BACKEND_NAMES) {
|
||||
const rel = `./prebuilds/${host}/qvac__llm-llamacpp/${name}`
|
||||
try {
|
||||
const assetPath = String(llmReq.asset(rel) || '')
|
||||
if (!assetPath) continue
|
||||
const bundleLike = /(?:^|\/)app\.bundle(?:\/|$)/i.test(assetPath)
|
||||
try {
|
||||
const buf = hostFs.readFileSync(assetPath)
|
||||
if (buf && buf.byteLength) {
|
||||
hostFs.writeFileSync(barePath.join(destDir, name), buf)
|
||||
wrote += 1
|
||||
if (!bundleLike) realAssetDir = barePath.dirname(assetPath)
|
||||
}
|
||||
} catch {
|
||||
if (!bundleLike) {
|
||||
try {
|
||||
if (typeof hostFs.copyFileSync === 'function') {
|
||||
hostFs.copyFileSync(assetPath, barePath.join(destDir, name))
|
||||
wrote += 1
|
||||
realAssetDir = barePath.dirname(assetPath)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* asset missing for this name */
|
||||
}
|
||||
}
|
||||
void wrote
|
||||
|
||||
const hostHit = acceptBackendsRoot(hostRoot, slot)
|
||||
if (hostHit) return hostHit
|
||||
|
||||
// If assets landed in a real prebuilds tree, use that root directly.
|
||||
if (realAssetDir && backendsSlotHasCpu(realAssetDir)) {
|
||||
const prebuildsRoot = barePath.resolve(realAssetDir, '..', '..')
|
||||
const hit = acceptBackendsRoot(prebuildsRoot, slot)
|
||||
if (hit) return hit
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const sdk =
|
||||
sdkNs?.default && typeof sdkNs.default === 'object'
|
||||
? { ...sdkNs, ...sdkNs.default }
|
||||
: sdkNs
|
||||
|
||||
/** True when pack remapped @qvac/sdk to the CI stub (e.g. win32-arm64). */
|
||||
export const qvacBindIsStub = Boolean(
|
||||
sdk &&
|
||||
(sdk.BARE_OS_QVAC_SDK_STUB === true ||
|
||||
(llmPlugin &&
|
||||
typeof llmPlugin === 'object' &&
|
||||
/** @type {{ name?: string }} */ (llmPlugin).name === 'qvac-llm-plugin-stub'))
|
||||
)
|
||||
|
||||
let llmLoadPatched = false
|
||||
|
||||
/**
|
||||
* Host id matching `@qvac/llm-llamacpp/prebuilds/<host>/`.
|
||||
* Uses bare-os's native platform/arch (the runtime may not expose a
|
||||
* `globalThis.process`).
|
||||
* @returns {string}
|
||||
*/
|
||||
export function bareOsQvacNativeHostId() {
|
||||
let platform = ''
|
||||
let arch = ''
|
||||
try {
|
||||
platform = typeof os.platform === 'function' ? os.platform() : ''
|
||||
} catch {
|
||||
platform = ''
|
||||
}
|
||||
try {
|
||||
arch = typeof os.arch === 'function' ? os.arch() : ''
|
||||
} catch {
|
||||
arch = ''
|
||||
}
|
||||
const p = globalThis.process || {}
|
||||
if (!platform) platform = String(p.platform || 'linux')
|
||||
if (!arch) arch = String(p.arch || 'x64')
|
||||
if (arch === 'x86_64') arch = 'x64'
|
||||
if (arch === 'aarch64') arch = 'arm64'
|
||||
return platform + '-' + arch
|
||||
}
|
||||
|
||||
/**
|
||||
* Package root for `@qvac/llm-llamacpp` (may be `/app.bundle/...` in standalone).
|
||||
* @returns {string}
|
||||
*/
|
||||
export function bareOsQvacLlmPackageRoot() {
|
||||
try {
|
||||
return barePath.dirname(requireFromBind.resolve('@qvac/llm-llamacpp'))
|
||||
} catch {
|
||||
try {
|
||||
return barePath.dirname(
|
||||
requireFromBind.resolve('@qvac/llm-llamacpp/package.json')
|
||||
)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts whose ggml backends (CPU + Metal) are statically linked into the addon
|
||||
* and ship no external backend dylibs (the prebuilds dir only has the .bare).
|
||||
* External `backendsDir` loading is unnecessary there.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function bareOsQvacHostHasBuiltinBackends() {
|
||||
const host = bareOsQvacNativeHostId()
|
||||
return host.startsWith('darwin-') || host.startsWith('ios-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize ggml backend .so files to a real host path.
|
||||
* Bare standalone embeds them under `/app.bundle/...` which dlopen cannot open;
|
||||
* llama.cpp then fails with `make_cpu_buft_list: no CPU backend found`.
|
||||
*
|
||||
* Returns the prebuilds-equivalent root to pass as `backendsDir`
|
||||
* (native appends `<host>/qvac__llm-llamacpp`).
|
||||
* @returns {string}
|
||||
*/
|
||||
export function bareOsQvacEnsureHostBackendsDir() {
|
||||
const host = bareOsQvacNativeHostId()
|
||||
const slot = barePath.join(host, 'qvac__llm-llamacpp')
|
||||
|
||||
if (bareOsQvacHostHasBuiltinBackends()) {
|
||||
// CPU + Metal are built into the addon; no external ggml backend dylibs
|
||||
// are shipped for darwin/ios. When a real prebuilds tree exists on disk,
|
||||
// point backendsDir at it so ggml's load_all_from_path no-ops on the
|
||||
// missing slot dir (it tolerates absent directories).
|
||||
const pkgRoot = bareOsQvacLlmPackageRoot()
|
||||
if (pkgRoot) {
|
||||
const prebuilds = barePath.join(pkgRoot, 'prebuilds')
|
||||
try {
|
||||
if (hostFs.existsSync(prebuilds)) return prebuilds
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const envOverride = String(
|
||||
globalThis.process?.env?.BARE_OS_QVAC_BACKENDS_DIR || ''
|
||||
).trim()
|
||||
if (envOverride) {
|
||||
const hit = acceptBackendsRoot(envOverride, slot)
|
||||
if (hit) return hit
|
||||
}
|
||||
|
||||
// Already materialized under ~/.bare-os/qvac/backends
|
||||
{
|
||||
const hit = acceptBackendsRoot(bareOsQvacHostBackendsRoot(), slot)
|
||||
if (hit) return hit
|
||||
}
|
||||
|
||||
// Sidecar next to the standalone binary (pack + install.sh).
|
||||
const sidecarRoots = []
|
||||
for (const dir of bundledExecCandidates()) {
|
||||
sidecarRoots.push(barePath.join(dir, 'qvac-backends'))
|
||||
}
|
||||
try {
|
||||
const home = String(
|
||||
globalThis.process?.env?.HOME ||
|
||||
globalThis.process?.env?.USERPROFILE ||
|
||||
''
|
||||
)
|
||||
if (home) {
|
||||
sidecarRoots.push(
|
||||
barePath.join(home, '.local', 'share', 'bare-os', 'booter', 'qvac-backends')
|
||||
)
|
||||
sidecarRoots.push(
|
||||
barePath.join(home, '.bare-os', 'qvac', 'backends')
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
for (const beside of sidecarRoots) {
|
||||
const hit = acceptBackendsRoot(beside, slot)
|
||||
if (hit) return hit
|
||||
}
|
||||
|
||||
// Bare require.asset() → real host path (or readable bytes).
|
||||
{
|
||||
const hit = materializeBackendsViaAsset(host, slot)
|
||||
if (hit) return hit
|
||||
}
|
||||
|
||||
const pkgRoot = bareOsQvacLlmPackageRoot()
|
||||
if (!pkgRoot) return ''
|
||||
const srcDir = barePath.join(pkgRoot, 'prebuilds', slot)
|
||||
const srcPrebuilds = barePath.join(pkgRoot, 'prebuilds')
|
||||
const bundleLike = /(?:^|\/)app\.bundle(?:\/|$)/i.test(srcDir)
|
||||
|
||||
// Dev / unpacked: use package prebuilds directly when they exist on disk.
|
||||
if (!bundleLike) {
|
||||
const hit = acceptBackendsRoot(srcPrebuilds, slot)
|
||||
if (hit) return hit
|
||||
}
|
||||
|
||||
// Copy from package/bundle path into host cache when readable.
|
||||
return materializeBackendsToHost(srcDir, slot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute backendsDir for llama.cpp (real host path after materialize).
|
||||
* @returns {string}
|
||||
*/
|
||||
export function bareOsQvacResolveBackendsDir() {
|
||||
return bareOsQvacEnsureHostBackendsDir()
|
||||
}
|
||||
|
||||
/**
|
||||
* llama.cpp `--fit` trial-loads often fail on hybrid Vulkan (and then CPU
|
||||
* inherits the same opaque error). SDK schema rejects `fit`, so inject into
|
||||
* the native config map after transform via the addon constructor path.
|
||||
*/
|
||||
export function bareOsQvacPatchLlmLoadDefaults() {
|
||||
if (llmLoadPatched || qvacBindIsStub) return
|
||||
const proto = LlmLlamacpp && LlmLlamacpp.prototype
|
||||
if (!proto || typeof proto._load !== 'function') return
|
||||
llmLoadPatched = true
|
||||
const orig = proto._load
|
||||
proto._load = async function bareOsQvacPatchedLlmLoad() {
|
||||
const prev =
|
||||
this._config && typeof this._config === 'object' ? this._config : {}
|
||||
/** @type {Record<string, string>} */
|
||||
const next = { ...prev }
|
||||
// This fabric build rejects --no-mmap; only inject fit (not in SDK schema).
|
||||
const fitEnv = String(
|
||||
globalThis.process?.env?.BARE_OS_QVAC_FIT ?? 'off'
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (next.fit == null || next.fit === '') {
|
||||
next.fit = fitEnv === 'on' ? 'on' : 'off'
|
||||
}
|
||||
delete next.no_mmap
|
||||
delete next['no-mmap']
|
||||
// Re-resolve every load — first patch-time resolve can miss a late sidecar.
|
||||
const backendsDir = bareOsQvacResolveBackendsDir()
|
||||
if (backendsDir) {
|
||||
next.backendsDir = backendsDir
|
||||
} else if (bareOsQvacHostHasBuiltinBackends()) {
|
||||
// Builtin CPU + Metal on darwin/ios: no external backend dylibs to load.
|
||||
} else if (next.backendsDir == null || next.backendsDir === '') {
|
||||
// Avoid silent /app.bundle fallback: leave unset and let native fail loudly,
|
||||
// but surface a clear JS warning once.
|
||||
try {
|
||||
const g = /** @type {any} */ (globalThis)
|
||||
if (!g.__bareOsQvacBackendsDirWarned) {
|
||||
g.__bareOsQvacBackendsDirWarned = true
|
||||
console.warn(
|
||||
'[qvac] ggml backendsDir unresolved — deploy qvac-backends/ beside bare-os-booter, or set BARE_OS_QVAC_BACKENDS_DIR (else: no CPU backend found)'
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
this._config = next
|
||||
return orig.call(this)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register llamacpp completion and return bound SDK surface (registry + load/completion).
|
||||
* Writes Bare-OS host cache config before plugins() so downloads land under
|
||||
* `$BARE_OS_HOST_DATA/qvac/models` instead of `~/.qvac/models`.
|
||||
* @param {{ cacheDir?: string }} [opts]
|
||||
* @returns {Record<string, any>}
|
||||
*/
|
||||
export function createBoundQvacApi(opts = {}) {
|
||||
if (qvacBindIsStub) {
|
||||
throw new Error(
|
||||
'QVAC natives not packed for this host (use agent --config REST, or rebuild without --skip-qvac on a supported host)'
|
||||
)
|
||||
}
|
||||
const prepared = bareOsQvacPrepareHostCacheConfig({
|
||||
cacheDir: opts.cacheDir
|
||||
})
|
||||
bareOsQvacPatchLlmLoadDefaults()
|
||||
const pluginsFn = sdk.plugins
|
||||
if (typeof pluginsFn !== 'function') {
|
||||
throw new Error('@qvac/sdk plugins() unavailable')
|
||||
}
|
||||
const bound = pluginsFn([llmPlugin])
|
||||
return {
|
||||
...sdk,
|
||||
loadModel: bound.loadModel || sdk.loadModel,
|
||||
completion: bound.completion || sdk.completion,
|
||||
unloadModel: bound.unloadModel || sdk.unloadModel,
|
||||
cancel: bound.cancel || sdk.cancel,
|
||||
__bareOsQvacCacheDir: prepared.cacheDir,
|
||||
__bareOsQvacConfigPath: prepared.configPath,
|
||||
__bareOsQvacBackendsDir: bareOsQvacResolveBackendsDir()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Idempotent managed Holesail entry for the stock bare-openssh listen address (see `bare-openssh.js`).
|
||||
* Same persistence rules as **`bare-os-www-holesail.js`** (`~/.holesail/state.json`).
|
||||
*/
|
||||
import { bareHolesailEnvTruthy } from './bare-holesail-env.js'
|
||||
import {
|
||||
bareHolesailManagedEnsureServerSeedPersisted,
|
||||
bareHolesailManagedGenerateSeedHex,
|
||||
bareHolesailManagedReadState,
|
||||
bareHolesailManagedRuntimeRunning,
|
||||
bareHolesailManagedServiceIsRunning,
|
||||
bareHolesailManagedStartOne,
|
||||
bareHolesailManagedStopOne,
|
||||
bareHolesailManagedWriteState
|
||||
} from './bare-holesail-managed.js'
|
||||
|
||||
export const BARE_OS_SSH_HOLESAIL_CONNECTION_ID_PREFIX = 'bare-ssh-'
|
||||
|
||||
/**
|
||||
* @param {number} port
|
||||
*/
|
||||
export function bareOsSshHolesailConnectionId(port) {
|
||||
return `${BARE_OS_SSH_HOLESAIL_CONNECTION_ID_PREFIX}${port}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve canonical SSH connection id (always `bare-ssh-<live_port>`), while selecting
|
||||
* one existing row to carry forward for key/seed reuse and listing stale ids to prune.
|
||||
* @param {{ connections: Record<string, unknown> }} state
|
||||
* @param {number} port
|
||||
*/
|
||||
function bareOsSshResolveManagedIdentity(state, port) {
|
||||
const exactId = bareOsSshHolesailConnectionId(port)
|
||||
const allIds = Object.keys(state.connections).filter((id) =>
|
||||
id.startsWith(BARE_OS_SSH_HOLESAIL_CONNECTION_ID_PREFIX)
|
||||
)
|
||||
if (allIds.includes(exactId)) {
|
||||
return { id: exactId, sourceId: exactId, staleIds: allIds.filter((id) => id !== exactId) }
|
||||
}
|
||||
if (allIds.length === 0) return { id: exactId, sourceId: exactId, staleIds: [] }
|
||||
const idsSorted = allIds.sort()
|
||||
const sourceId = idsSorted[0]
|
||||
return { id: exactId, sourceId, staleIds: idsSorted }
|
||||
}
|
||||
|
||||
/**
|
||||
* Default on (set **`BARE_OS_SSH_HOLESAIL=0`** or **`false`** to disable).
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
export function bareOsSshHolesailAutoEnabled(env) {
|
||||
const v = env.BARE_OS_SSH_HOLESAIL
|
||||
if (v === '0' || v === 'false') return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize bind host for stable comparison (matches openssh listen string).
|
||||
* @param {string} host
|
||||
*/
|
||||
function normalizeWantHost(host) {
|
||||
const h = String(host ?? '').trim()
|
||||
return h || '127.0.0.1'
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a managed server tunnel for SSH `port` / `host` and start it if bare-holesail is already active.
|
||||
* Call after **`sshServer.listen`** succeeds with the **actual** bound port and host.
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {number} port
|
||||
* @param {string} [host]
|
||||
*/
|
||||
export async function ensureBareOsSshHolesailTunnel(ctx, env, port, host) {
|
||||
if (!bareOsSshHolesailAutoEnabled(env)) return
|
||||
const p =
|
||||
Number.isFinite(port) && port > 0 && port < 65536
|
||||
? Math.floor(port)
|
||||
: 2222
|
||||
const wantHost = normalizeWantHost(host)
|
||||
const vfs = ctx.vfs
|
||||
if (!vfs || typeof vfs.readFile !== 'function' || typeof vfs.writeFile !== 'function') {
|
||||
return
|
||||
}
|
||||
if (!ctx.b4a || typeof ctx.b4a.from !== 'function') return
|
||||
|
||||
const { path, state } = await bareHolesailManagedReadState(
|
||||
ctx,
|
||||
/** @type {Record<string, string | undefined>} */ (env)
|
||||
)
|
||||
const { id, sourceId, staleIds } = bareOsSshResolveManagedIdentity(state, p)
|
||||
/** @type {Record<string, unknown>} */
|
||||
const want = { server: true, port: p, host: wantHost, enabled: true }
|
||||
const existing = state.connections[sourceId]
|
||||
let shouldRestartRuntime = false
|
||||
if (existing && typeof existing === 'object') {
|
||||
const ex = /** @type {Record<string, unknown>} */ (existing)
|
||||
const curPort = Number.parseInt(String(ex.port ?? ''), 10)
|
||||
const server = bareHolesailEnvTruthy(ex.server)
|
||||
const enabled =
|
||||
ex.enabled === undefined || ex.enabled === null || String(ex.enabled) === ''
|
||||
? true
|
||||
: bareHolesailEnvTruthy(ex.enabled)
|
||||
const rawHost = String(ex.host ?? '').trim()
|
||||
const hostOk = rawHost === wantHost
|
||||
shouldRestartRuntime = !(curPort === p && hostOk && server && enabled)
|
||||
if (server && curPort === p && enabled && hostOk) {
|
||||
await bareHolesailManagedEnsureServerSeedPersisted(
|
||||
ctx,
|
||||
/** @type {Record<string, string | undefined>} */ (env),
|
||||
id
|
||||
)
|
||||
if (
|
||||
bareHolesailManagedServiceIsRunning() &&
|
||||
!bareHolesailManagedRuntimeRunning(id)
|
||||
) {
|
||||
try {
|
||||
await bareHolesailManagedStartOne(
|
||||
ctx,
|
||||
/** @type {Record<string, string | undefined>} */ (env),
|
||||
id
|
||||
)
|
||||
} catch {
|
||||
/* may already run */
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Record<string, unknown>} */
|
||||
const next = { ...want }
|
||||
if (existing && typeof existing === 'object') {
|
||||
const ex = /** @type {Record<string, unknown>} */ (existing)
|
||||
const k = String(ex.key ?? '').trim()
|
||||
if (k) next.key = k
|
||||
const s = String(ex.seed ?? '').trim()
|
||||
if (s) next.seed = s
|
||||
}
|
||||
const legacyHsOnly =
|
||||
/^hs:\/\//i.test(String(next.key ?? '').trim()) && !String(next.seed ?? '').trim()
|
||||
if (!legacyHsOnly && !String(next.seed ?? '').trim()) {
|
||||
next.seed = bareHolesailManagedGenerateSeedHex()
|
||||
}
|
||||
state.connections[id] = next
|
||||
for (const staleId of staleIds) delete state.connections[staleId]
|
||||
await bareHolesailManagedWriteState(
|
||||
ctx,
|
||||
/** @type {Record<string, string | undefined>} */ (env),
|
||||
state
|
||||
)
|
||||
await bareHolesailManagedEnsureServerSeedPersisted(
|
||||
ctx,
|
||||
/** @type {Record<string, string | undefined>} */ (env),
|
||||
id
|
||||
)
|
||||
void path
|
||||
if (shouldRestartRuntime && bareHolesailManagedRuntimeRunning(id)) {
|
||||
try {
|
||||
await bareHolesailManagedStopOne(ctx, id)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
for (const staleId of staleIds) {
|
||||
if (bareHolesailManagedRuntimeRunning(staleId)) {
|
||||
try {
|
||||
await bareHolesailManagedStopOne(ctx, staleId)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
bareHolesailManagedServiceIsRunning() &&
|
||||
!bareHolesailManagedRuntimeRunning(id)
|
||||
) {
|
||||
try {
|
||||
await bareHolesailManagedStartOne(
|
||||
ctx,
|
||||
/** @type {Record<string, string | undefined>} */ (env),
|
||||
id
|
||||
)
|
||||
} catch (e) {
|
||||
try {
|
||||
ctx.console?.error?.(
|
||||
`[bare-os-ssh] holesail start ${id}: ${(e && /** @type {{ message?: string }} */ (e).message) || String(e)}`
|
||||
)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Attach first-party guest TUI / SDK onto ctx (raw-JS IIFE, no guest import).
|
||||
*/
|
||||
import { BARE_OS_TUI_SDK_SOURCE } from './bare-os-tui-sdk.data.mjs'
|
||||
|
||||
/**
|
||||
* @param {Record<string, string> | null | undefined} env
|
||||
*/
|
||||
export function bareOsTuiEnabled(env) {
|
||||
const v = env && env.BARE_OS_TUI
|
||||
return v !== '0' && v !== 'false'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {{ source?: string }} [opts]
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function attachBareOsTuiSdk(ctx, opts = {}) {
|
||||
if (!ctx || typeof ctx !== 'object') return false
|
||||
const env =
|
||||
ctx.env && typeof ctx.env === 'object'
|
||||
? /** @type {Record<string, string>} */ (ctx.env)
|
||||
: {}
|
||||
if (!bareOsTuiEnabled(env)) return false
|
||||
if (
|
||||
typeof ctx.bareOsIsCtxMethodAllowed === 'function' &&
|
||||
ctx.bareOsIsCtxMethodAllowed('tui') === false
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const src =
|
||||
typeof opts.source === 'string' && opts.source
|
||||
? opts.source
|
||||
: BARE_OS_TUI_SDK_SOURCE
|
||||
if (!src) return false
|
||||
try {
|
||||
const factory = new Function(
|
||||
'ctx',
|
||||
'"use strict"; return (' + src + ')(ctx)'
|
||||
)
|
||||
const boxed = factory(ctx)
|
||||
if (!boxed || typeof boxed !== 'object' || !boxed.tui) return false
|
||||
ctx.tui = Object.freeze(boxed.tui)
|
||||
ctx.sdk = Object.freeze(boxed.sdk)
|
||||
return true
|
||||
} catch (e) {
|
||||
if (typeof ctx.console?.error === 'function') {
|
||||
ctx.console.error(
|
||||
'bare-os-tui: attach failed: ' + (e && /** @type {Error} */ (e).message)
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Idempotent managed Holesail entry for the stock www static server port.
|
||||
*/
|
||||
import { bareHolesailEnvTruthy } from './bare-holesail-env.js'
|
||||
import {
|
||||
bareHolesailManagedEnsureServerSeedPersisted,
|
||||
bareHolesailManagedGenerateSeedHex,
|
||||
bareHolesailManagedReadState,
|
||||
bareHolesailManagedRuntimeRunning,
|
||||
bareHolesailManagedServiceIsRunning,
|
||||
bareHolesailManagedStartOne,
|
||||
bareHolesailManagedStopOne,
|
||||
bareHolesailManagedWriteState
|
||||
} from './bare-holesail-managed.js'
|
||||
|
||||
export const BARE_OS_WWW_HOLESAIL_CONNECTION_ID_PREFIX = 'bare-www-'
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {number} port
|
||||
*/
|
||||
export function bareOsWwwHolesailConnectionId(port) {
|
||||
return `${BARE_OS_WWW_HOLESAIL_CONNECTION_ID_PREFIX}${port}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick one persisted WWW holesail entry to reuse and return any duplicates to prune.
|
||||
* @param {{ connections: Record<string, unknown> }} state
|
||||
* @param {number} port
|
||||
*/
|
||||
function bareOsWwwResolveManagedIdentity(state, port) {
|
||||
const exactId = bareOsWwwHolesailConnectionId(port)
|
||||
const allIds = Object.keys(state.connections).filter((id) =>
|
||||
id.startsWith(BARE_OS_WWW_HOLESAIL_CONNECTION_ID_PREFIX)
|
||||
)
|
||||
if (allIds.includes(exactId)) {
|
||||
return { id: exactId, staleIds: allIds.filter((id) => id !== exactId) }
|
||||
}
|
||||
if (allIds.length === 0) return { id: exactId, staleIds: [] }
|
||||
const idsSorted = allIds.sort()
|
||||
return { id: idsSorted[0], staleIds: idsSorted.slice(1) }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
export function bareOsWwwHolesailAutoEnabled(env) {
|
||||
const v = env.BARE_OS_WWW_HOLESAIL
|
||||
if (v === '0' || v === 'false') return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a managed server tunnel for `port` and start it if bare-holesail is already active.
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {Record<string, string | undefined>} env
|
||||
* @param {number} port
|
||||
*/
|
||||
export async function ensureBareOsWwwHolesailTunnel(ctx, env, port) {
|
||||
if (!bareOsWwwHolesailAutoEnabled(env)) return
|
||||
const p =
|
||||
Number.isFinite(port) && port > 0 && port < 65536
|
||||
? Math.floor(port)
|
||||
: 8088
|
||||
const vfs = ctx.vfs
|
||||
if (!vfs || typeof vfs.readFile !== 'function' || typeof vfs.writeFile !== 'function') {
|
||||
return
|
||||
}
|
||||
if (!ctx.b4a || typeof ctx.b4a.from !== 'function') return
|
||||
|
||||
const { path, state } = await bareHolesailManagedReadState(
|
||||
ctx,
|
||||
/** @type {Record<string, string | undefined>} */ (env)
|
||||
)
|
||||
const { id, staleIds } = bareOsWwwResolveManagedIdentity(state, p)
|
||||
/** Holesail server mode requires an explicit loopback host. */
|
||||
const wantHost = '127.0.0.1'
|
||||
/** @type {Record<string, unknown>} */
|
||||
const want = { server: true, port: p, host: wantHost, enabled: true }
|
||||
const existing = state.connections[id]
|
||||
let shouldRestartRuntime = false
|
||||
if (existing && typeof existing === 'object') {
|
||||
const ex = /** @type {Record<string, unknown>} */ (existing)
|
||||
const curPort = Number.parseInt(String(ex.port ?? ''), 10)
|
||||
const server = bareHolesailEnvTruthy(ex.server)
|
||||
const enabled =
|
||||
ex.enabled === undefined || ex.enabled === null || String(ex.enabled) === ''
|
||||
? true
|
||||
: bareHolesailEnvTruthy(ex.enabled)
|
||||
const rawHost = String(ex.host ?? '').trim()
|
||||
const hostOk = rawHost === wantHost
|
||||
shouldRestartRuntime = !(curPort === p && hostOk && server && enabled)
|
||||
if (server && curPort === p && enabled && hostOk) {
|
||||
await bareHolesailManagedEnsureServerSeedPersisted(
|
||||
ctx,
|
||||
/** @type {Record<string, string | undefined>} */ (env),
|
||||
id
|
||||
)
|
||||
if (
|
||||
bareHolesailManagedServiceIsRunning() &&
|
||||
!bareHolesailManagedRuntimeRunning(id)
|
||||
) {
|
||||
try {
|
||||
await bareHolesailManagedStartOne(
|
||||
ctx,
|
||||
/** @type {Record<string, string | undefined>} */ (env),
|
||||
id
|
||||
)
|
||||
} catch {
|
||||
/* may already run */
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {Record<string, unknown>} */
|
||||
const next = { ...want }
|
||||
if (existing && typeof existing === 'object') {
|
||||
const ex = /** @type {Record<string, unknown>} */ (existing)
|
||||
const k = String(ex.key ?? '').trim()
|
||||
if (k) next.key = k
|
||||
const s = String(ex.seed ?? '').trim()
|
||||
if (s) next.seed = s
|
||||
}
|
||||
const legacyHsOnly =
|
||||
/^hs:\/\//i.test(String(next.key ?? '').trim()) && !String(next.seed ?? '').trim()
|
||||
if (!legacyHsOnly && !String(next.seed ?? '').trim()) {
|
||||
next.seed = bareHolesailManagedGenerateSeedHex()
|
||||
}
|
||||
state.connections[id] = next
|
||||
for (const staleId of staleIds) delete state.connections[staleId]
|
||||
await bareHolesailManagedWriteState(
|
||||
ctx,
|
||||
/** @type {Record<string, string | undefined>} */ (env),
|
||||
state
|
||||
)
|
||||
await bareHolesailManagedEnsureServerSeedPersisted(
|
||||
ctx,
|
||||
/** @type {Record<string, string | undefined>} */ (env),
|
||||
id
|
||||
)
|
||||
void path
|
||||
if (shouldRestartRuntime && bareHolesailManagedRuntimeRunning(id)) {
|
||||
try {
|
||||
await bareHolesailManagedStopOne(ctx, id)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
for (const staleId of staleIds) {
|
||||
if (bareHolesailManagedRuntimeRunning(staleId)) {
|
||||
try {
|
||||
await bareHolesailManagedStopOne(ctx, staleId)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
bareHolesailManagedServiceIsRunning() &&
|
||||
!bareHolesailManagedRuntimeRunning(id)
|
||||
) {
|
||||
try {
|
||||
await bareHolesailManagedStartOne(
|
||||
ctx,
|
||||
/** @type {Record<string, string | undefined>} */ (env),
|
||||
id
|
||||
)
|
||||
} catch (e) {
|
||||
try {
|
||||
ctx.console?.error?.(
|
||||
`[bare-os-www] holesail start ${id}: ${(e && /** @type {{ message?: string }} */ (e).message) || String(e)}`
|
||||
)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* bare-os-www — static HTTP server on loopback for ~/.www (initd unit `bare-os-www`).
|
||||
* Uses `bare-node-http` so both Node and Bare/Pear paths stay on one host-import surface.
|
||||
*/
|
||||
import bareNodeHttp from 'bare-node-http'
|
||||
const http = bareNodeHttp
|
||||
import {
|
||||
findBareServiceDefinition,
|
||||
registerBareService,
|
||||
restartBareService
|
||||
} from './bare-initd.js'
|
||||
import { appendVarLog, BARE_OS_VAR_LOG_DIR } from './bare-os-var-log.js'
|
||||
import { ensureBareOsWwwHolesailTunnel } from './bare-os-www-holesail.js'
|
||||
import { listenTcpWithFallback } from './bare-os-bind-fallback.js'
|
||||
|
||||
export const BARE_OS_WWW_LOG = `${BARE_OS_VAR_LOG_DIR}/www.log`
|
||||
|
||||
/** @type {ReturnType<typeof http.createServer> | null} */
|
||||
let wwwServer = null
|
||||
|
||||
const MIME = Object.freeze({
|
||||
html: 'text/html; charset=utf-8',
|
||||
htm: 'text/html; charset=utf-8',
|
||||
css: 'text/css; charset=utf-8',
|
||||
js: 'text/javascript; charset=utf-8',
|
||||
mjs: 'text/javascript; charset=utf-8',
|
||||
json: 'application/json; charset=utf-8',
|
||||
svg: 'image/svg+xml',
|
||||
txt: 'text/plain; charset=utf-8',
|
||||
md: 'text/markdown; charset=utf-8',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
ico: 'image/x-icon',
|
||||
woff2: 'font/woff2',
|
||||
woff: 'font/woff',
|
||||
map: 'application/json; charset=utf-8'
|
||||
})
|
||||
|
||||
export const BARE_OS_WWW_DEFAULT_INDEX = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Bare OS</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark light;
|
||||
--bg: #0c0d10;
|
||||
--panel: #151821;
|
||||
--line: #2a3142;
|
||||
--text: #e8ecf4;
|
||||
--muted: #8b95a8;
|
||||
--accent: #6ee7b7;
|
||||
--glow: rgba(110, 231, 183, 0.15);
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f4f6fb;
|
||||
--panel: #ffffff;
|
||||
--line: #d8dee9;
|
||||
--text: #1c2433;
|
||||
--muted: #5c6a82;
|
||||
--accent: #0d9488;
|
||||
--glow: rgba(13, 148, 136, 0.12);
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif;
|
||||
background: radial-gradient(1200px 600px at 10% -10%, var(--glow), transparent 55%),
|
||||
radial-gradient(900px 500px at 90% 0%, var(--glow), transparent 50%),
|
||||
var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
main {
|
||||
max-width: 52rem;
|
||||
margin: 0 auto;
|
||||
padding: clamp(2rem, 5vw, 4rem) clamp(1.25rem, 4vw, 2rem);
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 1rem;
|
||||
padding: clamp(1.75rem, 4vw, 2.5rem);
|
||||
box-shadow: 0 24px 60px rgba(0,0,0,0.25);
|
||||
}
|
||||
h1 {
|
||||
font-size: clamp(1.75rem, 4vw, 2.35rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
.tag {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--accent);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
p { margin: 0.75rem 0; color: var(--muted); }
|
||||
p.lead { font-size: 1.1rem; color: var(--text); }
|
||||
ul { margin: 1rem 0 0; padding-left: 1.2rem; color: var(--muted); }
|
||||
li { margin: 0.35rem 0; }
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.9em;
|
||||
background: var(--bg);
|
||||
padding: 0.15em 0.45em;
|
||||
border-radius: 0.35rem;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
footer {
|
||||
margin-top: 2.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="card">
|
||||
<div class="tag">Welcome</div>
|
||||
<h1>Bare OS</h1>
|
||||
<p class="lead">Your personal site lives on the Hyperdrive-backed path <code>~/.www</code>. Drop files here to serve them from this host.</p>
|
||||
<p>Static pages are served over HTTP on the session port (default <code>8088</code>). When Holesail managed mode is enabled, a tunnel can expose this port to peers.</p>
|
||||
<ul>
|
||||
<li>Edit <code>index.html</code> under <code>~/.www</code> to replace this page.</li>
|
||||
<li>Optional HDMS label <code>www</code> mounts at <code>/mnt/www</code>; with that drive mounted, <code>~/.www</code> routes there.</li>
|
||||
</ul>
|
||||
<footer>Bare operating system — minimal, replicated, yours.</footer>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} [env]
|
||||
*/
|
||||
export function bareOsWwwInitdEnabled(env = globalThis.process?.env) {
|
||||
const v = env?.BARE_OS_WWW_INITD
|
||||
if (v === '0' || v === 'false') return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
function parseWwwPort(env) {
|
||||
const raw = String(env.BARE_OS_WWW_PORT ?? '').trim()
|
||||
const n = raw ? Number.parseInt(raw, 10) : 8088
|
||||
if (!Number.isFinite(n) || n < 1 || n > 65535) return 8088
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
function parseWwwHost(env) {
|
||||
const h = String(env.BARE_OS_WWW_HOST ?? '').trim()
|
||||
return h || '127.0.0.1'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} docrootNorm absolute, no trailing slash except root
|
||||
* @param {string} pathname request URL pathname
|
||||
* @returns {string | null} absolute vfs path
|
||||
*/
|
||||
export function bareOsWwwResolveStaticPath(docrootNorm, pathname) {
|
||||
let p = pathname || '/'
|
||||
try {
|
||||
p = decodeURIComponent(p)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!p.startsWith('/')) return null
|
||||
let rel = p.replace(/^\/+/, '')
|
||||
if (!rel || rel.endsWith('/')) {
|
||||
rel = rel.replace(/\/+$/, '')
|
||||
rel = rel ? `${rel}/index.html` : 'index.html'
|
||||
}
|
||||
const segs = rel.split('/').filter(Boolean)
|
||||
if (segs.some((s) => s === '..')) return null
|
||||
const d = docrootNorm.replace(/\/+$/, '') || '/'
|
||||
const tail = segs.join('/')
|
||||
const abs = tail ? `${d}/${tail}` : `${d}/index.html`
|
||||
if (d === '/') {
|
||||
if (!abs.startsWith('/') || abs.includes('//')) return null
|
||||
return abs
|
||||
}
|
||||
if (!(abs === d || abs.startsWith(`${d}/`))) return null
|
||||
return abs
|
||||
}
|
||||
|
||||
/**
|
||||
* `…/dir/index.html` under docroot when `dirAbs` is a served directory (path traversal guard).
|
||||
* @param {string} docrootNorm
|
||||
* @param {string} dirAbs
|
||||
* @returns {string | null}
|
||||
*/
|
||||
function bareOsWwwJoinIndexInDirectory(docrootNorm, dirAbs) {
|
||||
const d = docrootNorm.replace(/\/+$/, '') || '/'
|
||||
const base = String(dirAbs || '').replace(/\/+$/, '')
|
||||
if (!base) return null
|
||||
const idx = `${base}/index.html`
|
||||
if (d === '/') {
|
||||
if (!idx.startsWith('/') || idx.includes('//')) return null
|
||||
return idx
|
||||
}
|
||||
if (!(idx === d || idx.startsWith(`${d}/`))) return null
|
||||
return idx
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} absPath
|
||||
*/
|
||||
function mimeForPath(absPath) {
|
||||
const i = absPath.lastIndexOf('.')
|
||||
if (i <= 0) return 'application/octet-stream'
|
||||
const ext = absPath.slice(i + 1).toLowerCase()
|
||||
return MIME[/** @type {keyof typeof MIME} */ (ext)] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} line
|
||||
*/
|
||||
function wwwLog(ctx, line) {
|
||||
void appendVarLog(ctx, BARE_OS_WWW_LOG, 'www', line)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure `~/.www/` exists and contains default `index.html` when missing (idempotent).
|
||||
* Called from identity unlock/register and from the `bare-os-www` initd unit.
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
export async function ensureBareOsWwwHomeDefaults(ctx) {
|
||||
const vfs = ctx.vfs
|
||||
if (!vfs || typeof vfs.resolveLogical !== 'function') return
|
||||
try {
|
||||
await vfs.mkdir(vfs.resolveLogical('~/.www'), { recursive: true })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const idx = vfs.resolveLogical('~/.www/index.html')
|
||||
try {
|
||||
const cur = await vfs.readFile(idx)
|
||||
if (cur != null) {
|
||||
const u8 =
|
||||
cur instanceof Uint8Array
|
||||
? cur
|
||||
: ctx.b4a && typeof ctx.b4a.from === 'function'
|
||||
? ctx.b4a.from(cur)
|
||||
: null
|
||||
if (u8 && u8.byteLength > 0) return
|
||||
}
|
||||
} catch {
|
||||
/* missing or unreadable */
|
||||
}
|
||||
try {
|
||||
if (!ctx.b4a || typeof ctx.b4a.from !== 'function') return
|
||||
const body = ctx.b4a.from(BARE_OS_WWW_DEFAULT_INDEX, 'utf8')
|
||||
await vfs.writeFile(idx, body)
|
||||
} catch (e) {
|
||||
try {
|
||||
ctx.console?.error?.(
|
||||
`[bare-os-www] seed index: ${(e && /** @type {{ message?: string }} */ (e).message) || String(e)}`
|
||||
)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
async function ensureDefaultIndex(ctx) {
|
||||
await ensureBareOsWwwHomeDefaults(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('http').IncomingMessage} req
|
||||
* @param {import('http').ServerResponse} res
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string} docrootNorm
|
||||
*/
|
||||
async function handleWwwRequest(req, res, ctx, docrootNorm) {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.statusCode = 405
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
||||
res.end('Method Not Allowed\n')
|
||||
return
|
||||
}
|
||||
const u = new URL(req.url || '/', 'http://127.0.0.1')
|
||||
let abs = bareOsWwwResolveStaticPath(docrootNorm, u.pathname)
|
||||
if (!abs) {
|
||||
res.statusCode = 400
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
||||
res.end('Bad path\n')
|
||||
return
|
||||
}
|
||||
const vfs = ctx.vfs
|
||||
if (
|
||||
!vfs ||
|
||||
typeof vfs.readFile !== 'function' ||
|
||||
typeof vfs.stat !== 'function'
|
||||
) {
|
||||
res.statusCode = 500
|
||||
res.end('VFS unavailable\n')
|
||||
return
|
||||
}
|
||||
try {
|
||||
let st = await vfs.stat(abs)
|
||||
if (st && st.type === 'directory') {
|
||||
// Without a trailing slash, browsers resolve relative URLs against / not /site/
|
||||
// (RFC 3986 — "site" is treated as a single path segment, not a directory).
|
||||
if (!u.pathname.endsWith('/')) {
|
||||
const loc = `${u.pathname}/${u.search || ''}`
|
||||
res.statusCode = 308
|
||||
res.setHeader('Location', loc)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const idx = bareOsWwwJoinIndexInDirectory(docrootNorm, abs)
|
||||
if (!idx) {
|
||||
res.statusCode = 404
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
res.end(
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"/><title>404</title></head><body><h1>404</h1></body></html>\n'
|
||||
)
|
||||
return
|
||||
}
|
||||
abs = idx
|
||||
try {
|
||||
st = await vfs.stat(abs)
|
||||
} catch (e) {
|
||||
const code = /** @type {{ code?: string }} */ (e)?.code
|
||||
if (code === 'ENOENT') {
|
||||
res.statusCode = 404
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
res.end(
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"/><title>404</title></head><body><h1>404</h1></body></html>\n'
|
||||
)
|
||||
return
|
||||
}
|
||||
throw e
|
||||
}
|
||||
if (!st || st.type === 'directory') {
|
||||
res.statusCode = 404
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
res.end(
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"/><title>404</title></head><body><h1>404</h1></body></html>\n'
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === 'HEAD') {
|
||||
if (!st || st.type === 'directory') {
|
||||
res.statusCode = 404
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
res.end(
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"/><title>404</title></head><body><h1>404</h1></body></html>\n'
|
||||
)
|
||||
return
|
||||
}
|
||||
const len = Number(st.size) || 0
|
||||
res.statusCode = 200
|
||||
res.setHeader('Content-Type', mimeForPath(abs))
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Content-Length', String(len))
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const buf = await vfs.readFile(abs)
|
||||
if (buf == null) {
|
||||
res.statusCode = 404
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
res.end(
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"/><title>404</title></head><body><h1>404</h1></body></html>\n'
|
||||
)
|
||||
return
|
||||
}
|
||||
const u8 =
|
||||
buf instanceof Uint8Array
|
||||
? buf
|
||||
: ctx.b4a && typeof ctx.b4a.from === 'function'
|
||||
? ctx.b4a.from(buf)
|
||||
: new Uint8Array()
|
||||
res.statusCode = 200
|
||||
res.setHeader('Content-Type', mimeForPath(abs))
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
res.setHeader('Content-Length', String(u8.byteLength))
|
||||
res.end(u8)
|
||||
} catch (e) {
|
||||
const code = /** @type {{ code?: string }} */ (e)?.code
|
||||
if (code === 'ENOENT') {
|
||||
res.statusCode = 404
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
res.end(
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"/><title>404</title></head><body><h1>404</h1></body></html>\n'
|
||||
)
|
||||
return
|
||||
}
|
||||
res.statusCode = 500
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
||||
res.end('read error\n')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
async function startBareOsWww(ctx) {
|
||||
const env =
|
||||
ctx.env && typeof ctx.env === 'object'
|
||||
? /** @type {Record<string, string | undefined>} */ (ctx.env)
|
||||
: /** @type {Record<string, string | undefined>} */ ({})
|
||||
const port = parseWwwPort(env)
|
||||
const host = parseWwwHost(env)
|
||||
const vfs = ctx.vfs
|
||||
if (!vfs || typeof vfs.resolveLogical !== 'function') {
|
||||
wwwLog(ctx, 'skip: no vfs.resolveLogical')
|
||||
return
|
||||
}
|
||||
await ensureDefaultIndex(ctx)
|
||||
const docroot = vfs.resolveLogical('~/.www')
|
||||
const docrootNorm = docroot.replace(/\/+$/, '') || '/'
|
||||
|
||||
if (wwwServer) {
|
||||
wwwLog(ctx, 'already running')
|
||||
return
|
||||
}
|
||||
const server = http.createServer((req, res) => {
|
||||
void handleWwwRequest(req, res, ctx, docrootNorm)
|
||||
})
|
||||
let boundPort = port
|
||||
try {
|
||||
;({ port: boundPort } = await listenTcpWithFallback(server, port, host, env))
|
||||
} catch (e) {
|
||||
const msg = (e && /** @type {{ message?: string }} */ (e).message) || String(e)
|
||||
wwwLog(ctx, `listen error: ${msg}`)
|
||||
try {
|
||||
server.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw e
|
||||
}
|
||||
wwwServer = server
|
||||
wwwLog(ctx, `listening http://${host}:${boundPort} docroot=${docrootNorm}`)
|
||||
try {
|
||||
ctx.console?.log?.(`[bare-os-www] http://${host}:${boundPort} → ${docrootNorm}`)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await ensureBareOsWwwHolesailTunnel(ctx, env, boundPort)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
async function stopBareOsWww(ctx) {
|
||||
const s = wwwServer
|
||||
wwwServer = null
|
||||
if (!s) return
|
||||
await new Promise((resolve) => {
|
||||
try {
|
||||
s.close(() => resolve(null))
|
||||
} catch {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
wwwLog(ctx, 'stopped')
|
||||
}
|
||||
|
||||
let bareOsWwwInitdRegistered = false
|
||||
|
||||
/**
|
||||
* @param {Record<string, string | undefined>} env
|
||||
*/
|
||||
/**
|
||||
* After `HOME` / session identity changes (`login`, `logout`, `applyLoginKeys`, register),
|
||||
* recreate the HTTP listener — `http.createServer` closed over the previous `ctx`.
|
||||
* No-op when the unit is not registered or `BARE_OS_WWW_INITD` disables the service.
|
||||
* @param {Record<string, unknown>} ctx
|
||||
*/
|
||||
export async function maybeRestartBareOsWwwAfterIdentity(ctx) {
|
||||
const env =
|
||||
ctx.env && typeof ctx.env === 'object'
|
||||
? /** @type {Record<string, string | undefined>} */ (ctx.env)
|
||||
: /** @type {Record<string, string | undefined>} */ ({})
|
||||
if (!bareOsWwwInitdEnabled(env)) return
|
||||
if (!findBareServiceDefinition('bare-os-www')) return
|
||||
try {
|
||||
await restartBareService(ctx, 'bare-os-www')
|
||||
} catch (e) {
|
||||
try {
|
||||
ctx.console?.error?.(
|
||||
`[bare-os-www] restart after identity: ${(e && /** @type {{ message?: string }} */ (e).message) || String(e)}`
|
||||
)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function maybeRegisterBareOsWwwInitd(env) {
|
||||
if (bareOsWwwInitdRegistered) return
|
||||
if (!bareOsWwwInitdEnabled(env || {})) return
|
||||
bareOsWwwInitdRegistered = true
|
||||
registerBareService({
|
||||
name: 'bare-os-www',
|
||||
description:
|
||||
'Static HTTP for ~/.www (default port 8088, loopback); BARE_OS_WWW_INITD=0 to disable; BARE_OS_WWW_PORT / BARE_OS_WWW_HOST',
|
||||
logPath: BARE_OS_WWW_LOG,
|
||||
start: startBareOsWww,
|
||||
stop: stopBareOsWww
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Ed25519 key generation for Bare OS using bare-crypto (not OpenSSH wire private format).
|
||||
* Writes OpenSSH-compatible `.pub` line + a Bare-OS JSON private envelope.
|
||||
*
|
||||
* Usage: ssh-keygen -t ed25519 -f PATH [-N pass] [-C comment]
|
||||
* Encrypted private keys use bareOsKeySchema 2 (PBKDF2-SHA256 + ChaCha20-Poly1305), aligned with account crypto profile.
|
||||
*/
|
||||
import bareCrypto from 'bare-crypto'
|
||||
import b4a from 'b4a'
|
||||
import { PBKDF2_ITERATIONS, sealBytes } from './identity-account.js'
|
||||
|
||||
const { pbkdf2Sync, randomFillSync } = bareCrypto
|
||||
|
||||
/**
|
||||
* @param {string} str
|
||||
*/
|
||||
function utf8Bytes(str) {
|
||||
return b4a.from(String(str), 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Uint8Array} pub32
|
||||
*/
|
||||
function sshEd25519PublicBlob(pub32) {
|
||||
const enc = (s) => {
|
||||
const b = typeof s === 'string' ? utf8Bytes(s) : s
|
||||
const out = new Uint8Array(4 + b.length)
|
||||
new DataView(out.buffer).setUint32(0, b.length, false)
|
||||
out.set(b, 4)
|
||||
return out
|
||||
}
|
||||
const a = enc('ssh-ed25519')
|
||||
const b = enc(pub32)
|
||||
const merged = new Uint8Array(a.length + b.length)
|
||||
merged.set(a, 0)
|
||||
merged.set(b, a.length)
|
||||
return merged
|
||||
}
|
||||
|
||||
function b64(buf) {
|
||||
if (typeof Buffer !== 'undefined') return Buffer.from(buf).toString('base64')
|
||||
let bin = ''
|
||||
for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i])
|
||||
// eslint-disable-next-line no-undef
|
||||
return btoa(bin)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} ctx
|
||||
* @param {string[]} argv
|
||||
*/
|
||||
export async function runSshKeygenCli(ctx, argv) {
|
||||
const args = argv.slice(1)
|
||||
if (
|
||||
args.includes('-h') ||
|
||||
args.includes('--help') ||
|
||||
args.includes('-?') ||
|
||||
args.length === 0
|
||||
) {
|
||||
ctx.console.log(
|
||||
'Usage: ssh-keygen -t ed25519 -f KEYFILE [-N passphrase] [-C comment]\n' +
|
||||
'Bare OS: writes KEYFILE (JSON private envelope) and KEYFILE.pub (ssh-ed25519 line).\n' +
|
||||
'Passphrase: schema 2 sealed key (PBKDF2 + ChaCha20-Poly1305); empty -N for plaintext schema 1.\n'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let type = ''
|
||||
let keyPath = ''
|
||||
let pass = ''
|
||||
let comment = ''
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i]
|
||||
if (a === '-t') type = String(args[++i] || '')
|
||||
else if (a === '-f') keyPath = String(args[++i] || '')
|
||||
else if (a === '-N') pass = String(args[++i] ?? '')
|
||||
else if (a === '-C') comment = String(args[++i] || '')
|
||||
else if (a.startsWith('-')) {
|
||||
ctx.console.error('ssh-keygen: unsupported option ' + a)
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (type !== 'ed25519') {
|
||||
ctx.console.error('ssh-keygen: only -t ed25519 is supported')
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
if (!keyPath) {
|
||||
ctx.console.error('ssh-keygen: -f KEYFILE is required')
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
const vfs = ctx.vfs
|
||||
const b4 = ctx.b4a
|
||||
if (!vfs || typeof vfs.resolveLogical !== 'function' || !b4) {
|
||||
ctx.console.error('ssh-keygen: vfs unavailable')
|
||||
ctx.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const { publicKey, privateKey } = bareCrypto.generateKeyPair('ed25519')
|
||||
const pubRaw =
|
||||
typeof publicKey.export === 'function' ? publicKey.export() : publicKey._key
|
||||
const secRaw =
|
||||
typeof privateKey.export === 'function'
|
||||
? privateKey.export()
|
||||
: privateKey._key
|
||||
|
||||
const blob = sshEd25519PublicBlob(pubRaw)
|
||||
const pubLine =
|
||||
'ssh-ed25519 ' + b64(blob) + ' ' + (comment || 'bare-os-ed25519') + '\n'
|
||||
|
||||
/** @type {Record<string, unknown>} */
|
||||
let privEnvelope
|
||||
if (pass !== '') {
|
||||
const salt = new Uint8Array(16)
|
||||
randomFillSync(salt)
|
||||
const dk = pbkdf2Sync(
|
||||
utf8Bytes(pass),
|
||||
salt,
|
||||
PBKDF2_ITERATIONS,
|
||||
32,
|
||||
'sha256'
|
||||
)
|
||||
const inner = JSON.stringify({
|
||||
sk: b64(secRaw),
|
||||
pk: b64(pubRaw)
|
||||
})
|
||||
const sealed = sealBytes(dk, utf8Bytes(inner))
|
||||
privEnvelope = {
|
||||
bareOsKeySchema: 2,
|
||||
kty: 'ed25519',
|
||||
kdf: 'pbkdf2-sha256',
|
||||
aead: 'chacha20-poly1305',
|
||||
iterations: PBKDF2_ITERATIONS,
|
||||
salt: b64(salt),
|
||||
sealed: b64(sealed),
|
||||
comment: comment || 'bare-os-ed25519',
|
||||
atMs: Date.now()
|
||||
}
|
||||
} else {
|
||||
privEnvelope = {
|
||||
bareOsKeySchema: 1,
|
||||
kty: 'ed25519',
|
||||
sk: b64(secRaw),
|
||||
pk: b64(pubRaw),
|
||||
comment: comment || 'bare-os-ed25519',
|
||||
atMs: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
const privJson = JSON.stringify(privEnvelope, null, 2) + '\n'
|
||||
|
||||
const absKey = vfs.resolveLogical(keyPath)
|
||||
const absPub = vfs.resolveLogical(keyPath + '.pub')
|
||||
try {
|
||||
if (typeof vfs.writeFile !== 'function') {
|
||||
throw new Error('writeFile missing')
|
||||
}
|
||||
await vfs.writeFile(absKey, b4.from(privJson))
|
||||
await vfs.writeFile(absPub, b4.from(pubLine))
|
||||
ctx.console.log(
|
||||
'generated keypair: ' + keyPath + ' and ' + keyPath + '.pub'
|
||||
)
|
||||
} catch (e) {
|
||||
ctx.console.error(
|
||||
'ssh-keygen: ' + ((e && /** @type {Error} */ (e).message) || String(e))
|
||||
)
|
||||
ctx.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Minimal sshd_config subset parser (key value pairs and repeats).
|
||||
* Lives under bare-os-booter so Pear bundles it with the booter (not bare-os-openssh).
|
||||
*/
|
||||
|
||||
/** Default `AuthorizedKeysFile` when sshd_config omits it (OpenSSH-style relative → $HOME). */
|
||||
export const SSHD_DEFAULT_AUTHORIZED_KEYS_FILE = '.ssh/authorized_keys'
|
||||
|
||||
/**
|
||||
* Build the logical path bare-openssh uses before `vfs.resolveLogical` (must stay in sync).
|
||||
* @param {string} [home]
|
||||
* @param {string} [authKeysRel]
|
||||
*/
|
||||
export function logicalAuthorizedKeysPath(home, authKeysRel) {
|
||||
const h = String(home || '/home/guest').replace(/\/+$/, '')
|
||||
const rel = String(authKeysRel ?? SSHD_DEFAULT_AUTHORIZED_KEYS_FILE).trim()
|
||||
if (rel.startsWith('/') || rel.startsWith('~/')) return rel
|
||||
return `${h}/${rel.replace(/^\/+/, '')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @returns {{
|
||||
* port: number,
|
||||
* listenAddress: string,
|
||||
* hostKeyPaths: string[],
|
||||
* passwordAuthentication: boolean,
|
||||
* pubkeyAuthentication: boolean,
|
||||
* permitRootLogin: boolean,
|
||||
* allowTcpForwarding: boolean,
|
||||
* maxAuthTries: number,
|
||||
* clientAliveInterval: number,
|
||||
* authorizedKeysFile: string,
|
||||
* subsystemSftp: string
|
||||
* }}
|
||||
*/
|
||||
export function parseSshdConfig(text) {
|
||||
const out = {
|
||||
// >1024: Pear/Bare guests bind without root (port 22 is permission denied).
|
||||
port: 2222,
|
||||
listenAddress: '127.0.0.1',
|
||||
hostKeyPaths: [] /** @type {string[]} */,
|
||||
passwordAuthentication: true,
|
||||
pubkeyAuthentication: true,
|
||||
permitRootLogin: false,
|
||||
allowTcpForwarding: false,
|
||||
maxAuthTries: 6,
|
||||
clientAliveInterval: 0,
|
||||
authorizedKeysFile: SSHD_DEFAULT_AUTHORIZED_KEYS_FILE,
|
||||
subsystemSftp: 'internal-sftp'
|
||||
}
|
||||
|
||||
const lines = String(text || '').split(/\r?\n/)
|
||||
for (const raw of lines) {
|
||||
const line = raw.replace(/#.*$/, '').trim()
|
||||
if (!line) continue
|
||||
const m = /^([A-Za-z0-9]+)\s+(.+)$/.exec(line)
|
||||
if (!m) continue
|
||||
const key = m[1].toLowerCase()
|
||||
let val = m[2].trim()
|
||||
if (
|
||||
(val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))
|
||||
) {
|
||||
val = val.slice(1, -1)
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'port': {
|
||||
const n = Number.parseInt(val, 10)
|
||||
if (Number.isFinite(n) && n > 0 && n < 65536) out.port = n
|
||||
break
|
||||
}
|
||||
case 'listenaddress':
|
||||
out.listenAddress = val
|
||||
break
|
||||
case 'hostkey':
|
||||
out.hostKeyPaths.push(val)
|
||||
break
|
||||
case 'passwordauthentication':
|
||||
out.passwordAuthentication = isYes(val)
|
||||
break
|
||||
case 'pubkeyauthentication':
|
||||
out.pubkeyAuthentication = isYes(val)
|
||||
break
|
||||
case 'permitrootlogin':
|
||||
out.permitRootLogin = val.toLowerCase() === 'yes' || val === 'without-password'
|
||||
break
|
||||
case 'allowtcpforwarding':
|
||||
out.allowTcpForwarding = isYes(val)
|
||||
break
|
||||
case 'maxauthtries': {
|
||||
const n = Number.parseInt(val, 10)
|
||||
if (Number.isFinite(n) && n > 0) out.maxAuthTries = Math.min(n, 32)
|
||||
break
|
||||
}
|
||||
case 'clientaliveinterval': {
|
||||
const n = Number.parseInt(val, 10)
|
||||
if (Number.isFinite(n) && n >= 0) out.clientAliveInterval = n
|
||||
break
|
||||
}
|
||||
case 'authorizedkeysfile':
|
||||
out.authorizedKeysFile = val
|
||||
break
|
||||
case 'subsystem':
|
||||
if (val.toLowerCase().startsWith('sftp')) {
|
||||
const parts = val.split(/\s+/)
|
||||
out.subsystemSftp = parts.slice(1).join(' ') || 'internal-sftp'
|
||||
}
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (out.hostKeyPaths.length === 0) {
|
||||
out.hostKeyPaths.push(
|
||||
'~/.config/bare-os/ssh/host/ssh_host_ed25519_key'
|
||||
)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** @param {string} v */
|
||||
function isYes(v) {
|
||||
const s = String(v).toLowerCase()
|
||||
return s === 'yes' || s === 'true'
|
||||
}
|
||||
Reference in New Issue
Block a user