291 lines
8.8 KiB
JavaScript
291 lines
8.8 KiB
JavaScript
/** Shared helpers for /bin/agent tools (preamble for agent bundle). */
|
|
|
|
/**
|
|
* Read-only base system (kernel / system Hyperdrive + virtual fs).
|
|
* Everything else is writable by default (denylist, not allowlist).
|
|
* @type {readonly string[]}
|
|
*/
|
|
var BARE_AGENT_MUTATE_DENY_PREFIXES = Object.freeze([
|
|
'/bin',
|
|
'/etc',
|
|
'/boot',
|
|
'/lib',
|
|
'/usr',
|
|
'/share',
|
|
'/proc',
|
|
'/dev',
|
|
'/sys',
|
|
'/run'
|
|
])
|
|
|
|
/**
|
|
* Seed list for diagnostic snapshots. Live reads allow any /proc path.
|
|
* @type {readonly string[]}
|
|
*/
|
|
var BARE_AGENT_PROC_READ_ALLOWLIST = Object.freeze([
|
|
'/proc/bare_os/metrics_live.json',
|
|
'/proc/bare_os/features',
|
|
'/proc/bare_os/features.json',
|
|
'/proc/bare_os/swarm.json',
|
|
'/proc/bare_os/capabilities.json',
|
|
'/proc/bare_os/swarm_replication_status.json',
|
|
'/proc/bare_os/swarm_relay_status.json',
|
|
'/proc/bare_os/swarm_datagrams_status.json',
|
|
'/proc/bare_os/swarm_connection_manager_status.json',
|
|
'/proc/bare_os/swarm_key_broker_status.json',
|
|
'/proc/bare_os/swarm_holepunch_status.json',
|
|
'/proc/bare_os/swarm_datagram_replication_status.json',
|
|
'/proc/bare_os/swarm_status.json'
|
|
])
|
|
|
|
/**
|
|
* @param {string} absPath
|
|
* @returns {string}
|
|
*/
|
|
function bareAgentNormalizeAbsPath(absPath) {
|
|
const p = String(absPath || '').replace(/\\/g, '/')
|
|
if (!p.startsWith('/') || p.includes('..')) return ''
|
|
if (p.length > 1) return p.replace(/\/+$/, '')
|
|
return p
|
|
}
|
|
|
|
/**
|
|
* @param {string} absPath
|
|
* @param {unknown} prefixes
|
|
* @returns {boolean}
|
|
*/
|
|
function bareAgentPrefixDenied(absPath, prefixes) {
|
|
const p = bareAgentNormalizeAbsPath(absPath)
|
|
if (!p) return true
|
|
const list =
|
|
Array.isArray(prefixes) && prefixes.length
|
|
? prefixes
|
|
: BARE_AGENT_MUTATE_DENY_PREFIXES
|
|
for (let i = 0; i < list.length; i++) {
|
|
const pref = String(list[i] || '').replace(/\/+$/, '')
|
|
if (!pref) continue
|
|
if (p === pref || p.startsWith(pref + '/')) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
/**
|
|
* Any absolute guest path is readable (denylist-empty).
|
|
* @param {string} absPath
|
|
*/
|
|
function bareAgentPathAllowedRead(absPath) {
|
|
return Boolean(bareAgentNormalizeAbsPath(absPath))
|
|
}
|
|
|
|
/**
|
|
* Writes/renames/deletes: whole VFS except the read-only base system.
|
|
* @param {string} absPath
|
|
* @param {unknown} [prefixes]
|
|
*/
|
|
function bareAgentPathAllowedMutate(absPath, prefixes) {
|
|
const p = bareAgentNormalizeAbsPath(absPath)
|
|
if (!p || p === '/') return false
|
|
return !bareAgentPrefixDenied(p, prefixes)
|
|
}
|
|
|
|
/**
|
|
* @param {unknown} statObj
|
|
* @param {string} path
|
|
*/
|
|
function bareAgentSerializeStat(statObj, path) {
|
|
if (!statObj || typeof statObj !== 'object')
|
|
return { path, error: 'no_stat' }
|
|
const s = /** @type {Record<string, unknown>} */ (statObj)
|
|
/** @type {'file' | 'dir' | 'symlink' | 'other'} */
|
|
let kind = 'other'
|
|
try {
|
|
if (typeof s.isDirectory === 'function' && s.isDirectory()) kind = 'dir'
|
|
else if (typeof s.isSymbolicLink === 'function' && s.isSymbolicLink()) kind = 'symlink'
|
|
else if (typeof s.isFile === 'function' && s.isFile()) kind = 'file'
|
|
else if (typeof s.mode === 'number') {
|
|
const M = Number(s.mode)
|
|
if ((M & 0o170000) === 0o040000) kind = 'dir'
|
|
else if ((M & 0o170000) === 0o120000) kind = 'symlink'
|
|
else if ((M & 0o170000) === 0o100000) kind = 'file'
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
/** @type {Record<string, unknown>} */
|
|
const out = {
|
|
path,
|
|
kind,
|
|
size: typeof s.size === 'number' ? s.size : undefined,
|
|
mode: typeof s.mode === 'number' ? s.mode : undefined,
|
|
mtimeMs: typeof s.mtimeMs === 'number' ? s.mtimeMs : undefined,
|
|
uid: typeof s.uid === 'number' ? s.uid : undefined,
|
|
gid: typeof s.gid === 'number' ? s.gid : undefined
|
|
}
|
|
if (typeof s.target === 'string') out.target = s.target
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @param {number} maxChars
|
|
*/
|
|
function bareAgentTruncateChars(text, maxChars) {
|
|
const t = String(text || '')
|
|
const n = Math.floor(maxChars)
|
|
if (!Number.isFinite(n) || n <= 0) return ''
|
|
if (t.length <= n) return t
|
|
return t.slice(0, n) + '\n… truncated'
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} page
|
|
* @param {number} maxChars
|
|
*/
|
|
function bareAgentManExtractPageSlice(page, maxChars) {
|
|
if (!page || typeof page !== 'object')
|
|
return { error: 'bad_page' }
|
|
const m = Math.min(Math.max(Math.floor(maxChars) || 8000, 500), 64_000)
|
|
const name = typeof page.name === 'string' ? page.name : ''
|
|
const section = typeof page.section === 'number' ? page.section : 0
|
|
const title = typeof page.title === 'string' ? page.title : ''
|
|
const synopsis = Array.isArray(page.synopsis)
|
|
? page.synopsis.map((x) => String(x)).join('\n')
|
|
: ''
|
|
const description = bareAgentTruncateChars(
|
|
typeof page.description === 'string' ? page.description : '',
|
|
Math.floor(m * 0.55)
|
|
)
|
|
let opts = ''
|
|
if (Array.isArray(page.options)) {
|
|
const lines = []
|
|
for (const o of page.options) {
|
|
if (!o || typeof o !== 'object') continue
|
|
const fl = typeof o.flag === 'string' ? o.flag : ''
|
|
const me = typeof o.meaning === 'string' ? o.meaning : ''
|
|
if (fl || me) lines.push(fl + (fl && me ? ' — ' : '') + me)
|
|
}
|
|
opts = bareAgentTruncateChars(lines.join('\n'), Math.floor(m * 0.35))
|
|
}
|
|
const blob =
|
|
name +
|
|
'(' +
|
|
section +
|
|
') — ' +
|
|
title +
|
|
'\n\nSYNOPSIS\n' +
|
|
synopsis +
|
|
'\n\nDESCRIPTION\n' +
|
|
description +
|
|
(opts ? '\n\nOPTIONS\n' + opts : '')
|
|
return {
|
|
name,
|
|
section,
|
|
title,
|
|
synopsis,
|
|
description,
|
|
options_text: opts || undefined,
|
|
text: bareAgentTruncateChars(blob, m)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Same semantics as `man -k`: substring match on indexed keywords (merged DB).
|
|
* @param {unknown} db
|
|
* @param {string} needle
|
|
* @param {number} maxHits
|
|
*/
|
|
function bareAgentManAproposHits(db, needle, maxHits) {
|
|
const n = String(needle || '').toLowerCase()
|
|
const max = Math.min(Math.max(Math.floor(maxHits) || 40, 1), 200)
|
|
if (!n || !db || typeof db !== 'object')
|
|
return /** @type {{ lines: string[], truncated: boolean }} */ ({
|
|
lines: [],
|
|
truncated: false
|
|
})
|
|
const d = /** @type {Record<string, unknown>} */ (db)
|
|
const pages = Array.isArray(d.pages) ? d.pages : []
|
|
const apropos = Array.isArray(d.apropos) ? d.apropos : []
|
|
const seen = new Set()
|
|
/** @type {string[]} */
|
|
const lines = []
|
|
let truncated = false
|
|
for (const row of apropos) {
|
|
if (!row || typeof row !== 'object') continue
|
|
const kw = typeof row.kw === 'string' ? row.kw : ''
|
|
if (!kw.includes(n)) continue
|
|
const idx = row.pageRef
|
|
if (typeof idx !== 'number' || !pages[idx]) continue
|
|
if (seen.has(idx)) continue
|
|
seen.add(idx)
|
|
const p = /** @type {Record<string, unknown>} */ (pages[idx])
|
|
const name = typeof p.name === 'string' ? p.name : ''
|
|
const sec = typeof p.section === 'number' ? p.section : 0
|
|
const title = typeof p.title === 'string' ? p.title : ''
|
|
lines.push(name + '(' + sec + ') - ' + title)
|
|
if (lines.length >= max) {
|
|
truncated = true
|
|
break
|
|
}
|
|
}
|
|
lines.sort()
|
|
return { lines, truncated }
|
|
}
|
|
|
|
/**
|
|
* @param {Record<string, unknown>} ctx
|
|
* @param {unknown} vfs
|
|
* @param {{ db: unknown | null }} cacheRef
|
|
* @returns {Promise<unknown | null>}
|
|
*/
|
|
async function bareAgentManEnsureDbLoaded(ctx, vfs, cacheRef) {
|
|
if (cacheRef.db) return cacheRef.db
|
|
if (!vfs || typeof vfs.readFile !== 'function') return null
|
|
try {
|
|
const buf = await vfs.readFile('/share/man/man.json')
|
|
if (!buf || !buf.length) return null
|
|
const t =
|
|
typeof ctx.b4a !== 'undefined' && ctx.b4a && typeof ctx.b4a.toString === 'function'
|
|
? ctx.b4a.toString(buf)
|
|
: String(new TextDecoder().decode(buf))
|
|
const parsed = JSON.parse(t)
|
|
cacheRef.db = parsed
|
|
return parsed
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve one manual page from merged DB (like `man [[section] name]`).
|
|
* @param {unknown} db
|
|
* @param {string} topic
|
|
* @param {number | null} sectionExplicit
|
|
*/
|
|
function bareAgentManResolvePage(db, topic, sectionExplicit) {
|
|
const name = String(topic || '').toLowerCase()
|
|
if (!name || !db || typeof db !== 'object')
|
|
return { error: 'not_found' }
|
|
const d = /** @type {Record<string, unknown>} */ (db)
|
|
const index = d.index
|
|
if (!index || typeof index !== 'object') return { error: 'not_found' }
|
|
const idx = /** @type {Record<string, unknown>} */ (index)[name]
|
|
if (typeof idx !== 'number') return { error: 'not_found' }
|
|
const pages = Array.isArray(d.pages) ? d.pages : []
|
|
const page = pages[idx]
|
|
if (!page || typeof page !== 'object') return { error: 'not_found' }
|
|
const sec = typeof page.section === 'number' ? page.section : 0
|
|
if (sectionExplicit !== null && sectionExplicit !== sec) {
|
|
return { error: 'wrong_section', foundSection: sec }
|
|
}
|
|
return { page: /** @type {Record<string, unknown>} */ (page) }
|
|
}
|
|
|
|
/**
|
|
* @param {string} absPath
|
|
* @returns {boolean}
|
|
*/
|
|
function bareAgentProcReadPathAllowed(absPath) {
|
|
const p = bareAgentNormalizeAbsPath(absPath)
|
|
return Boolean(p && (p === '/proc' || p.startsWith('/proc/')))
|
|
}
|