Files
bare-operating-system/packages/bare-os-coreutils/lib/agent/agent-helpers.js
T
2026-08-19 14:27:00 -04:00

459 lines
14 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
}
/**
* Expand ~ / $HOME / relative guest paths to an absolute VFS path.
* @param {string} raw
* @param {string} home
* @param {string} [cwd]
*/
function bareAgentExpandGuestPath(raw, home, cwd) {
let p = String(raw == null ? '' : raw).trim()
if (!p) return ''
p = p.replace(/\\/g, '/')
const h = String(home || '').replace(/\/+$/, '') || '/home/guest'
let c = String(cwd || h).replace(/\/+$/, '') || h
if (c === '~') c = h
else if (c.startsWith('~/')) c = h + c.slice(1)
else if (c.startsWith('$HOME/')) c = h + c.slice(5)
else if (c === '$HOME') c = h
if (p === '~' || p === '$HOME') return h
if (p.startsWith('~/')) {
p = h + '/' + p.slice(2)
} else if (p.startsWith('$HOME/')) {
p = h + '/' + p.slice(6)
} else if (!p.startsWith('/')) {
p = (c === '/' ? '/' + p : c + '/' + p)
}
p = p.replace(/\/{2,}/g, '/')
if (p.length > 1) p = p.replace(/\/+$/, '')
return p
}
/**
* Pick a path-like argument from a tool-call blob (path / file_path / dir / …).
* @param {Record<string, unknown>} args
*/
function bareAgentPickPathArg(args) {
if (!args || typeof args !== 'object') return ''
const keys = [
'path',
'file_path',
'file',
'filename',
'dir',
'directory',
'folder',
'from_path',
'to_path'
]
for (let i = 0; i < keys.length; i++) {
const v = args[keys[i]]
if (typeof v === 'string' && v.trim()) return v.trim()
}
return ''
}
/**
* @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/')))
}
/**
* Kernel-runner evals guest scripts as AsyncFunction('ctx','argv', body).
* Agent-authored code that redeclares ctx/argv or uses ESM export throws
* "Identifier 'ctx' has already been declared" — same contract as /bin
* utilities: define async function run(ctx, argv), never bind ctx yourself.
* @param {string} code
*/
function bareAgentNormalizeGuestScript(code) {
let src = String(code || '').replace(/^\uFEFF/, '')
src = src.replace(/^#!.*(?:\r?\n|$)/, '')
src = src.replace(/^\s*export\s*\{[^}]*\}\s*;?\s*$/gm, '')
src = src.replace(/^export\s+default\s+/gm, '')
src = src.replace(/^export\s+async\s+function\s+/gm, 'async function ')
src = src.replace(/^export\s+function\s+/gm, 'function ')
src = src.replace(/^export\s+const\s+/gm, 'const ')
src = src.replace(/^export\s+let\s+/gm, 'let ')
src = src.replace(/^export\s+var\s+/gm, 'var ')
src = src.replace(/^(?:const|let|var)\s+ctx\b[^;\n]*;?\s*$/gm, '')
src = src.replace(/^(?:const|let|var)\s+argv\b[^;\n]*;?\s*$/gm, '')
src = src.replace(
/^(?:const|let|var)\s*\{\s*ctx[\s,}][^;\n]*;?\s*$/gm,
''
)
if (
!/\b(?:async\s+)?function\s+run\s*\(/.test(src) &&
!/\brun\s*=\s*(?:async\s*)?(?:function\s*)?\(/.test(src)
) {
src = 'async function run(ctx, argv) {\n' + src.trim() + '\n}\n'
}
return src.trim() + '\n'
}
/**
* Trim glued prose off a curl/wget URL (`orgBut` → `org`).
* @param {string} cmd
*/
function bareAgentTrimGluedShellCommand(cmd) {
let s = String(cmd || '').trim()
s = s.replace(/(\.(?:org|com|net|io|dev|fyi|info|edu|co))([A-Z].*)$/i, '$1')
s = s.replace(/(https?:\/\/[^\s]+?)(?=[A-Z][a-z])/, '$1')
return s.trim()
}
/**
* If a run_js_script body is actually a guest CLI (curl/wget/ip), return
* the command so dispatch can divert to run_command.
* @param {string} code
* @returns {string}
*/
function bareAgentShellCommandFromJsScript(code) {
const s = String(code || '')
if (!s.trim()) return ''
const trimmed = s.trim()
const looksShell =
typeof bareAgentLooksLikeShellCommand === 'function'
? bareAgentLooksLikeShellCommand(trimmed)
: /^(curl|wget|ip|ping|ifconfig)\b/i.test(trimmed)
if (
looksShell &&
!/\bfunction\b|\bclass\b|\bawait\b|\bconst\b|\blet\b|\bvar\b|\bimport\b/.test(trimmed)
) {
return bareAgentTrimGluedShellCommand(trimmed)
}
const execRe =
/\b(?:execLine|execSync|exec|spawnSync|system)\s*\(\s*(['"`])([\s\S]*?)\1/g
let m
while ((m = execRe.exec(s))) {
const cmd = String(m[2] || '').trim()
if (/^(curl|wget|ip|ping|ifconfig)\b/i.test(cmd)) {
return bareAgentTrimGluedShellCommand(cmd)
}
}
const fetchRe = /\bfetch\s*\(\s*(['"`])(https?:\/\/[^'"`]+)\1/
m = fetchRe.exec(s)
if (m) return 'curl -s ' + String(m[2] || '').trim()
const curlRe = /(?:^|[\n;])\s*((?:curl|wget)\s+https?:\/\/[^\s;'"`]+)/im
m = curlRe.exec(s)
if (m) return bareAgentTrimGluedShellCommand(m[1])
return ''
}
/**
* task_complete used as a surrender after a failed tool, not a real finish.
* @param {string} summary
*/
function bareAgentLooksLikeFailedTaskComplete(summary) {
const s = String(summary || '').toLowerCase()
if (!s.trim()) return false
return (
/error occurred/.test(s) ||
/alternative method required/.test(s) ||
(/attempted to /.test(s) && /error|fail/.test(s)) ||
/failed due to/.test(s) ||
/could not (run|execute|retrieve|complete|get)/.test(s) ||
/not (able|allowed) to (run|execute|complete)/.test(s) ||
/top-level (scope|await|module)/.test(s)
)
}
/**
* User asked to run a guest CLI (curl / IP lookup), not write JS.
* @param {string} text
*/
function bareAgentLooksLikeGuestCliRequest(text) {
const s = String(text || '').toLowerCase()
if (!s.trim()) return false
return (
/\b(run|execute|use)\b.{0,48}\b(curl|wget|ping|ifconfig|ip addr)\b/.test(s) ||
/\b(curl|wget)\b.{0,48}\b(ip address|public ip|our ip|my ip)\b/.test(s) ||
/\b(find|get|check|show|lookup)\b.{0,48}\b(ip address|public ip|our ip|my ip)\b/.test(
s
)
)
}