This commit is contained in:
Raven Scott
2026-04-03 20:11:00 -04:00
parent 0334ece75a
commit 58d1571627
29 changed files with 1939 additions and 179 deletions
+5 -3
View File
@@ -23,8 +23,10 @@ The following are set on `ctx` before the kernel starts (unless noted as overwri
| Field | Role |
| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`bareOsCtxApiVersion`** | String semver for the documented **`ctx`** contract (e.g. **`1.3.0`**). Bump in [`bare-os-ctx-api.js`](../packages/bare-os-booter/lib/bare-os-ctx-api.js) when you make breaking changes to stable fields. |
| **`bareOsRuntimeCaps`** | **Frozen** snapshot from [`bare-os-runtime-caps.js`](../packages/bare-os-booter/lib/bare-os-runtime-caps.js): **`ctxApiVersion`**, resolved simulated **pipeline** limits (from **`env`** / defaults), documented pseudo **`/`** paths (**`pseudoFsPaths`**), and **`features`** flags (**`simulatedPipelines`**, **`bareInitd`**, **`vfsTwoDrive`**, **`identityVault`**, **`httpDelegate`**, **`gitDelegate`**, **`systemctlDelegate`**). Reflects session env at **`executeKernel`** start (including host **`BARE_OS_PIPELINE_*`** when set). |
| **`bareOsCtxApiVersion`** | String semver for the documented **`ctx`** contract (e.g. **`1.4.0`**). Bump in [`bare-os-ctx-api.js`](../packages/bare-os-booter/lib/bare-os-ctx-api.js) when you make breaking changes to stable fields. |
| **`bareOsRuntimeCaps`** | **Frozen** snapshot from [`bare-os-runtime-caps.js`](../packages/bare-os-booter/lib/bare-os-runtime-caps.js): **`ctxApiVersion`**, simulated **pipeline** limits, **`pseudoFsPaths`**, and **`features`** (including **`jobControl`**, **`shellHereString`**, **`bootReadyPseudoFs`**, **`vfsChown`**, **`auditLog`**, **`initdRequiresWants`**, **`seederRpcExtended`**, …). |
| **`bareOsPublishBootReady(patch)`** | Kernel-only: merge **`patch`** into the session boot-ready state exposed as **`/run/bare-os/boot.json`** and **`/run/bare-os/ready`**. |
| **`bareOsSessionStats`** | Mutable **`{ execLineCount, pipelineBytesTotal }`** mirrored in **`/proc/bare_os_session_stats`**. |
| **`bareOsBootStartedMs`** | Epoch milliseconds when the booter started building the session (used for synthetic **`/proc/uptime`**). |
| **`bareOsSkipRepl`** | **`true`** when **`BARE_OS_SKIP_REPL=1`** (non-interactive stdin); kernels may shorten banners. |
| **`disk`** | Disk bundle used during boot (includes drives and helpers); advanced use |
@@ -45,7 +47,7 @@ The following are set on `ctx` before the kernel starts (unless noted as overwri
| **`runBinCommand(argv)`** | Runs a command with the **same** resolution rules as the interactive shell (used by **`time`**, **`xargs`**, and similar) |
| **`registerKernelShutdownHook(fn)`** | Register an async or sync function to run when the REPL session ends, **before** **`stopBareInitd`** and initd **disposers**. Pair with **`registerBareInitdDisposer(fn)`** in [`bare-initd.js`](../packages/bare-os-booter/lib/bare-initd.js) when you need teardown **after** shutdown hooks but still inside **`stopBareInitd`** (intervals, sync cleanup). |
Kernel boot composition lives on the **system image** (`/boot/init.js`, `/etc/bare-os/rc`, `/etc/bare-os/rc.d/`, optional **`/etc/bare-os/rc.local`**, optional **`/etc/bare-os/kernel.d/`** (same digit-prefix rules as **`rc.d`**), optional **`/etc/bare-os/profile`** / **`rc.profile.*`**, **`/etc/bare-os/onboot`**), not on `ctx`—extend the image or hooks like **`registerKernelShutdownHook`** rather than adding boot fields to the context object. The booter seeds **`ctx.env`** from the host for **`BARE_OS_PIPELINE_MAX_STAGES`**, **`BARE_OS_PIPELINE_MAX_BYTES`**, **`BARE_OS_PIPELINE_MAX_LINES`**, **`BARE_OS_BOOT_PROFILE`**, **`BARE_OS_ONBOOT`**, **`BARE_OS_BOOT_STRICT`**, and **`BARE_OS_RC_D_SKIP`** when those variables are set, always sets **`BARE_OS_BOOT_PROFILE_RESOLVED`** for **`rc.profile.*`** and **`/run/bare-os/boot_profile`**, and assigns **`BARE_OS_SESSION_ID`** (UUID) for **`/run/bare-os/session`** and **`/proc/self/environ`**. When **`ctx.httpFetch`** handles **`curl`**, check optional **`init.bareOsCurlTls`** (`insecure`, **`caPem`**) for **`--cacert`** / **`-k`** semantics.
Kernel boot composition lives on the **system image** (`/boot/init.js`, `/etc/bare-os/rc`, `/etc/bare-os/rc.d/`, optional **`/etc/bare-os/rc.local`**, optional **`/etc/bare-os/kernel.d/`** (same digit-prefix rules as **`rc.d`**), optional **`/etc/bare-os/profile`** / **`rc.profile.*`**, **`/etc/bare-os/onboot`**), not on `ctx`—extend the image or hooks like **`registerKernelShutdownHook`** rather than adding boot fields to the context object. The booter seeds **`ctx.env`** from the host for **`BARE_OS_PIPELINE_*`**, **`BARE_OS_BOOT_PROFILE`**, **`BARE_OS_ONBOOT`**, **`BARE_OS_BOOT_STRICT`**, **`BARE_OS_RC_D_SKIP`**, **`BARE_OS_BOOT_MINIMAL`**, **`BARE_OS_BOOT_SKIP`**, **`BARE_OS_BOOT_TRACE`**, **`BARE_OS_KERNEL_SELFTEST`**, **`BARE_OS_AUDIT`**, and **`BARE_OS_IMAGE_DIGEST`** when set; always sets **`BARE_OS_BOOT_PROFILE_RESOLVED`** and **`BARE_OS_SESSION_ID`**. When **`ctx.httpFetch`** handles **`curl`**, check optional **`init.bareOsCurlTls`** (`insecure`, **`caPem`**) for **`--cacert`** / **`-k`** semantics.
After [`createKernelReplSession`](../packages/bare-os-booter/lib/repl-session.js) returns:
+13 -1
View File
@@ -25,6 +25,17 @@ Staged from **`kernel/init.js`**. Responsibilities (typical):
The **prompt** (`[user@host:path] > `) is applied by the booters readline layer, not by `init.js`.
### Boot profiles, recovery, and readiness
| Host / kernel env | Purpose |
| ----------------- | ------- |
| **`BARE_OS_BOOT_PROFILE`** / **`/etc/bare-os/profile`** | First line names **`/etc/bare-os/rc.profile.<name>`** (before **`rc`**). Suggested names: **`dev`** (extra PATH, aliases), **`ci`** (**`BARE_OS_SKIP_REPL`** + **`BARE_OS_ONBOOT`** smoke lines), **`kiosk`** (minimal **`rc.d`**, fixed **`onboot`**). |
| **`BARE_OS_BOOT_MINIMAL`** | Skip **`rc`**, **`rc.d`**, **`rc.local`**, **`kernel.d`**, **`onboot`**, and profile **`rc.profile.*`** — recovery shell with **`os-release`** / **`motd`** only. |
| **`BARE_OS_BOOT_SKIP`** | Comma list of phases to skip: **`profile`**, **`rc`**, **`rc.d`**, **`rc.local`**, **`kernel.d`**, **`onboot`**. |
| **`BARE_OS_BOOT_TRACE=ndjson`** | One JSON object per boot phase on stderr (**`type`**, **`phase`**, **`ms`**, **`sessionId`**, **`ts`**). |
| **`BARE_OS_KERNEL_SELFTEST`** | After boot snippets, run a short trusted **`execLine`** checklist. |
| **`ctx.bareOsPublishBootReady(...)`** | Kernel calls this when boot is complete; populates **`/run/bare-os/ready`** (**`1`** / **`0`**) and **`/run/bare-os/boot.json`**. |
### Custom `init.js` contract
Replacing **`/boot/init.js`** on the system image is supported: the booter loads it with **`AsyncFunction`** and expects a top-level **`async function start(ctx)`**. Stable **`ctx`** fields for kernels are documented in [Chapter 4](04-the-booter-runtime.md) and the [context object developer guide](../developer-guide/02-the-context-object.md), including **`ctx.bareOsRuntimeCaps`** for pipeline limits, pseudo path lists, and **`features`** (e.g. **`httpDelegate`**, **`gitDelegate`**, **`systemctlDelegate`**). Use **`ctx.registerKernelShutdownHook`** for teardown that must run before **`stopBareInitd`**. Boot snippets under **`/etc/bare-os/rc`**, **`/etc/bare-os/rc.d/`** (only **`rc.d`** files whose names start with a digit), **`rc.local`**, **`kernel.d/`** (same digit-prefix rules), **`rc.profile.*`**, and **`onboot`** are **trusted** (full **`execLine`** power); keep them minimal.
@@ -83,7 +94,8 @@ Hyperdrive entries carry optional **`metadata.bareOs`** (mode, uid, gid, names,
| `sed`, `awk` | Large JavaScript engines in **`lib/*-engine.js`** — not byte-identical to GNU/POSIX everywhere; see ch. 9 |
| `tee`, `find`, `du`, `cksum` | Pipe tee, limited **`find`** (**`-maxdepth`**, **`-mindepth`**, **`-name`**, **`-type`**), **`du -k`**, POSIX CRC **`cksum`** |
| `time`, `logname` | Wall-clock **`time`** via **`ctx.runBinCommand`**; identity string |
| `chown`, `chgrp`, `mkfifo` | Stubs with explicit “not supported” messages |
| `chown`, `chgrp` | Update ownership in **`metadata.bareOs`** on the personal drive (same writable scope as **`chmod`**); supports **`:group`** and numeric ids; root vs owner rules as in VFS |
| `mkfifo` | Stub with explicit “not supported” message |
| `getconf`, `xargs` | Documented Bare subsets (fixed **`getconf`** table; bounded **`xargs`** via **`ctx.runBinCommand`**) |
| `pathchk` | Path sanity |
| `pwd` | Logical cwd |
+1 -1
View File
@@ -19,7 +19,7 @@ Files in this directory are **read from disk by the seeder** (or copied into `pa
- **`share/man/man.json`** — Merged manual database for **`/bin/man`** (built by **`bare-os-coreutils`**; see [handbook ch.10](../handbook/10-manpages-and-online-help.md)).
- **`etc/os-release`** — Static OS metadata (`NAME`, `VERSION`, …).
- **`etc/motd`** — Optional message printed after **`os-release`** (distributors can customize).
- **`etc/bare-os/banner`** or **`/etc/issue`** — If present on the system drive, the default kernel prints one of these instead of the built-in session hint (unless **`BARE_OS_SKIP_REPL`** shortens the banner). Set **`BARE_OS_BOOT_TRACE=1`** or **`true`** for **`[boot] phase: Nms`** lines on stderr, or **`json`** for **`{"phase":"…","ms":n}`** per phase.
- **`etc/bare-os/banner`** or **`/etc/issue`** — If present on the system drive, the default kernel prints one of these instead of the built-in session hint (unless **`BARE_OS_SKIP_REPL`** shortens the banner). Set **`BARE_OS_BOOT_TRACE=1`** or **`true`** for **`[boot] phase: Nms`** lines on stderr, **`json`** for **`{"phase":"…","ms":n}`** per phase, or **`ndjson`** for machine-readable lines with **`sessionId`**. Recovery: **`BARE_OS_BOOT_MINIMAL`**, granular **`BARE_OS_BOOT_SKIP`**, optional **`BARE_OS_KERNEL_SELFTEST`**, readiness via **`ctx.bareOsPublishBootReady`** → **`/run/bare-os/ready`** and **`/run/bare-os/boot.json`** (see [handbook ch.6](../handbook/06-kernel-and-binaries.md)).
- **`etc/bare-os/rc`** — Optional boot snippet: one **`execLine`** per non-comment line (trusted).
- **`etc/bare-os/rc.d/`** — Optional extra snippets (basename must start with a digit), same line rules, run after **`rc`** in filename order. Human-oriented notes live in **`.README`** (a dotfile so legacy **`init.js`** never executes it).
+84 -5
View File
@@ -60,9 +60,88 @@ function barePosixBlocks(size) {
return Math.ceil(Number(size) / 512) || 0
}
async function run(ctx, argv) {
ctx.console.error(
'chgrp: changing group is not supported on Bare OS (single-user Hyperdrive metadata).'
)
ctx.exitCode = 1
/**
* chgrp — change file group (Hyperdrive metadata on writable paths).
*/
/**
* @param {string} spec
* @param {Record<string, string>} env
* @returns {number | null}
*/
function parseGroupSpec(spec, env) {
const s = String(spec).trim()
if (!s) return null
if (/^\d+$/.test(s)) {
const n = Number.parseInt(s, 10)
return Number.isFinite(n) ? n : null
}
const g = (env.GROUP || env.USER || 'guest').trim()
if (s === 'root') return 0
if (s === 'guest' || s === 'nobody') return 65534
if (g && s === g) {
const gid = Number.parseInt(String(env.GID ?? '65534'), 10)
return Number.isFinite(gid) ? gid : 65534
}
return null
}
async function run(ctx, argv) {
let i = 1
const files = []
for (; i < argv.length; i++) {
const a = argv[i]
if (a === '-h' || a === '--help') {
ctx.console.log(
'usage: chgrp [-h] GROUP FILE...\n' +
'GROUP may be a numeric gid or root/guest/nobody/current GROUP name.'
)
ctx.exitCode = 0
return
}
if (!a.startsWith('-')) break
ctx.console.error('chgrp: unsupported option: ' + a)
ctx.exitCode = 1
return
}
const groupSpec = argv[i++]
if (!groupSpec || i >= argv.length) {
ctx.console.error('usage: chgrp GROUP FILE...')
ctx.exitCode = 1
return
}
for (; i < argv.length; i++) files.push(argv[i])
const gid = parseGroupSpec(groupSpec, ctx.vfs.env)
if (gid == null) {
ctx.console.error('chgrp: invalid group: ' + groupSpec)
ctx.exitCode = 1
return
}
const gname = /^\d+$/.test(groupSpec.trim())
? undefined
: groupSpec.trim() || undefined
for (const f of files) {
try {
const st = await ctx.vfs.stat(f)
if (!st) {
ctx.console.error('chgrp: ' + f + ': No such file')
ctx.exitCode = 1
continue
}
if (typeof ctx.vfs.chown !== 'function') {
ctx.console.error('chgrp: not supported by this VFS')
ctx.exitCode = 1
return
}
await ctx.vfs.chown(f, {
uid: st.uid,
gid,
uname: st.user,
gname
})
} catch (e) {
ctx.console.error('chgrp: ' + f + ': ' + (e.message || e))
ctx.exitCode = 1
}
}
}
+152 -5
View File
@@ -60,9 +60,156 @@ function barePosixBlocks(size) {
return Math.ceil(Number(size) / 512) || 0
}
async function run(ctx, argv) {
ctx.console.error(
'chown: changing file ownership is not supported on Bare OS (single-user Hyperdrive metadata).'
)
ctx.exitCode = 1
/**
* chown — change file owner and group (Hyperdrive metadata on writable paths).
*/
/**
* @param {string} spec
* @param {Record<string, string>} env
* @returns {{ uid: number | null, gid: number | null }}
*/
function parseIdSpec(spec, env) {
const s = String(spec).trim()
if (!s) return { uid: null, gid: null }
if (/^\d+$/.test(s)) {
const n = Number.parseInt(s, 10)
return { uid: Number.isFinite(n) ? n : null, gid: null }
}
const u = (env.USER || env.LOGNAME || '').trim()
if (s === 'root') return { uid: 0, gid: null }
if (s === 'guest' || s === 'nobody') return { uid: 65534, gid: null }
if (u && s === u) {
const uid = Number.parseInt(String(env.UID ?? '65534'), 10)
return { uid: Number.isFinite(uid) ? uid : 65534, gid: null }
}
return { uid: null, gid: null }
}
/**
* @param {string} spec
* @param {Record<string, string>} env
* @returns {number | null}
*/
function parseGroupSpec(spec, env) {
const s = String(spec).trim()
if (!s) return null
if (/^\d+$/.test(s)) {
const n = Number.parseInt(s, 10)
return Number.isFinite(n) ? n : null
}
const g = (env.GROUP || env.USER || 'guest').trim()
if (s === 'root') return 0
if (s === 'guest' || s === 'nobody') return 65534
if (g && s === g) {
const gid = Number.parseInt(String(env.GID ?? '65534'), 10)
return Number.isFinite(gid) ? gid : 65534
}
return null
}
async function run(ctx, argv) {
let i = 1
const files = []
for (; i < argv.length; i++) {
const a = argv[i]
if (a === '-h' || a === '--help') {
ctx.console.log(
'usage: chown [-h] OWNER[:GROUP] FILE...\n' +
'OWNER/GROUP may be numeric ids or root/guest/nobody/current USER/GROUP.'
)
ctx.exitCode = 0
return
}
if (!a.startsWith('-')) break
ctx.console.error('chown: unsupported option: ' + a)
ctx.exitCode = 1
return
}
const ownSpec = argv[i++]
if (!ownSpec || i >= argv.length) {
ctx.console.error('usage: chown OWNER[:GROUP] FILE...')
ctx.exitCode = 1
return
}
for (; i < argv.length; i++) files.push(argv[i])
const env = ctx.vfs.env
const colon = ownSpec.indexOf(':')
let uid
let gid
let uname
let gname
if (colon === 0 && ownSpec.length > 1) {
const gPart = ownSpec.slice(1)
gid = parseGroupSpec(gPart, env)
gname = /^\d+$/.test(gPart.trim()) ? undefined : gPart.trim() || undefined
if (gid == null) {
ctx.console.error('chown: invalid group: ' + gPart)
ctx.exitCode = 1
return
}
for (const f of files) {
try {
if (typeof ctx.vfs.chown !== 'function') {
ctx.console.error('chown: not supported by this VFS')
ctx.exitCode = 1
return
}
const st = await ctx.vfs.stat(f)
if (!st) {
ctx.console.error('chown: ' + f + ': No such file')
ctx.exitCode = 1
continue
}
await ctx.vfs.chown(f, {
uid: st.uid,
gid,
uname: st.user,
gname
})
} catch (e) {
ctx.console.error('chown: ' + f + ': ' + (e.message || e))
ctx.exitCode = 1
}
}
return
}
if (colon >= 0) {
const uPart = ownSpec.slice(0, colon)
const gPart = ownSpec.slice(colon + 1)
const pu = parseIdSpec(uPart, env)
uid = pu.uid
uname = /^\d+$/.test(uPart.trim()) ? undefined : uPart.trim() || undefined
if (gPart === '') {
gid = Number.parseInt(String(env.GID ?? '65534'), 10)
gname = env.GROUP || env.USER
} else {
gid = parseGroupSpec(gPart, env)
gname = /^\d+$/.test(gPart.trim()) ? undefined : gPart.trim() || undefined
}
} else {
const pu = parseIdSpec(ownSpec, env)
uid = pu.uid
uname = /^\d+$/.test(ownSpec.trim()) ? undefined : ownSpec.trim() || undefined
gid = Number.parseInt(String(env.GID ?? '65534'), 10)
gname = env.GROUP || env.USER
}
if (uid == null || gid == null) {
ctx.console.error('chown: invalid owner or group: ' + ownSpec)
ctx.exitCode = 1
return
}
for (const f of files) {
try {
if (typeof ctx.vfs.chown !== 'function') {
ctx.console.error('chown: not supported by this VFS')
ctx.exitCode = 1
return
}
await ctx.vfs.chown(f, { uid, gid, uname, gname })
} catch (e) {
ctx.console.error('chown: ' + f + ': ' + (e.message || e))
ctx.exitCode = 1
}
}
}
+31 -4
View File
@@ -64,7 +64,7 @@ function barePosixBlocks(size) {
* curDepth is distance from the search root directory (0 at the initial path).
* Printed paths use gnu-like depth curDepth + 1 (immediate children of the root are depth 1).
*/
async function walk(ctx, dir, nameRe, wantType, maxDepth, minDepth, curDepth) {
async function walk(ctx, dir, nameRe, pathRe, wantType, maxDepth, minDepth, curDepth) {
if (maxDepth >= 0 && curDepth > maxDepth) return
let names
try {
@@ -83,13 +83,24 @@ async function walk(ctx, dir, nameRe, wantType, maxDepth, minDepth, curDepth) {
}
if (!st) continue
const gnuDepth = curDepth + 1
if (!nameRe || nameRe.test(n)) {
const pathOk = !pathRe || pathRe.test(path)
const nameOk = !nameRe || nameRe.test(n)
if (pathOk && nameOk) {
if (!wantType || st.type === wantType) {
if (gnuDepth >= minDepth) ctx.console.log(path)
}
}
if (st.type === 'directory')
await walk(ctx, path, nameRe, wantType, maxDepth, minDepth, curDepth + 1)
await walk(
ctx,
path,
nameRe,
pathRe,
wantType,
maxDepth,
minDepth,
curDepth + 1
)
}
}
@@ -99,6 +110,8 @@ async function run(ctx, argv) {
let minDepth = 1
/** @type {string | null} */
let nameGlob = null
/** @type {string | null} */
let pathGlob = null
/** @type {'file' | 'directory' | 'symlink' | null} */
let wantType = null
const rest = []
@@ -113,6 +126,10 @@ async function run(ctx, argv) {
if (!Number.isFinite(minDepth) || minDepth < 1) minDepth = 1
continue
}
if (a === '-path' && argv[i + 1]) {
pathGlob = argv[++i]
continue
}
if (a === '-name' && argv[i + 1]) {
nameGlob = argv[++i]
continue
@@ -144,6 +161,16 @@ async function run(ctx, argv) {
.replace(/\?/g, '.')
nameRe = new RegExp('^' + esc + '$')
}
let pathRe = null
if (pathGlob) {
const esc = pathGlob
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*\*/g, '\0GLOBSTAR\0')
.replace(/\*/g, '[^/]*')
.replace(/\?/g, '[^/]')
.replace(/\0GLOBSTAR\0/g, '.*')
pathRe = new RegExp('^' + esc + '$')
}
const abs = ctx.vfs.resolveLogical(root)
await walk(ctx, abs, nameRe, wantType, maxDepth, minDepth, 0)
await walk(ctx, abs, nameRe, pathRe, wantType, maxDepth, minDepth, 0)
}
+165 -13
View File
@@ -15,6 +15,13 @@
* in file order (trusted). Then readLine yields EOF.
*
* BARE_OS_BOOT_TRACE=json logs one JSON object per phase on stderr: {"phase":"…","ms":n}.
* BARE_OS_BOOT_TRACE=ndjson logs {"type":"boot","phase","ms","sessionId","ts"} per line.
*
* BARE_OS_BOOT_MINIMAL=1 or true: skip profile rc, rc, rc.d, rc.local, kernel.d (recovery shell).
* BARE_OS_BOOT_SKIP: comma-separated phases to skip among:
* profile, rc, rc.d, rc.local, kernel.d, onboot (os-release and motd always run).
*
* BARE_OS_KERNEL_SELFTEST=1 or true: after boot snippets, run a short trusted self-check via execLine.
*
* BARE_OS_BOOT_STRICT=1 or true: first execLine throw in trusted boot snippets calls
* requestBooterExit(1) and stops further boot phases.
@@ -30,7 +37,12 @@
*/
function wantBootTrace(ctx) {
const v = ctx.env && ctx.env.BARE_OS_BOOT_TRACE
return v === '1' || v === 'true' || v === 'json'
return (
v === '1' ||
v === 'true' ||
v === 'json' ||
v === 'ndjson'
)
}
/**
@@ -40,6 +52,56 @@ function isBootTraceJson(ctx) {
return ctx.env && ctx.env.BARE_OS_BOOT_TRACE === 'json'
}
/**
* @param {Record<string, unknown>} ctx
*/
function isBootTraceNdjson(ctx) {
return ctx.env && ctx.env.BARE_OS_BOOT_TRACE === 'ndjson'
}
/**
* @param {Record<string, unknown>} ctx
*/
function bootMinimal(ctx) {
const v = ctx.env && ctx.env.BARE_OS_BOOT_MINIMAL
return v === '1' || v === 'true'
}
/**
* @param {Record<string, unknown>} ctx
* @returns {Set<string>}
*/
function parseBootSkip(ctx) {
const raw = ctx.env && ctx.env.BARE_OS_BOOT_SKIP
const out = new Set()
if (raw == null || !String(raw).trim()) return out
for (const p of String(raw)
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean)) {
out.add(p)
}
return out
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} phase
*/
function shouldSkipBootPhase(ctx, phase) {
if (bootMinimal(ctx)) {
return (
phase === 'profile' ||
phase === 'rc' ||
phase === 'rc.d' ||
phase === 'rc.local' ||
phase === 'kernel.d' ||
phase === 'onboot'
)
}
return parseBootSkip(ctx).has(phase.toLowerCase())
}
/**
* @param {Record<string, unknown>} ctx
*/
@@ -80,13 +142,26 @@ function shouldSkipRcDName(name, patterns) {
* @param {Record<string, unknown>} ctx
* @param {string} label
* @param {() => void | Promise<void>} fn
* @param {string[]} phaseLog
*/
async function bootTimed(ctx, label, fn) {
async function bootTimed(ctx, label, fn, phaseLog) {
const t0 = Date.now()
await fn()
if (wantBootTrace(ctx)) {
const ms = Date.now() - t0
if (isBootTraceJson(ctx)) {
phaseLog.push(label)
if (wantBootTrace(ctx)) {
if (isBootTraceNdjson(ctx)) {
const sid =
(ctx.env && ctx.env.BARE_OS_SESSION_ID) || ''
const rec = {
type: 'boot',
phase: label,
ms,
sessionId: sid,
ts: Date.now()
}
ctx.console.error(JSON.stringify(rec))
} else if (isBootTraceJson(ctx)) {
ctx.console.error(JSON.stringify({ phase: label, ms }))
} else {
ctx.console.error(`[boot] ${label}: ${ms}ms`)
@@ -348,7 +423,7 @@ async function printSessionBanner(ctx) {
}
}
const defaultBanner =
'Bare operating system — guest session (login [--new] <passphrase> to unlock) | shell: cd, export, if/fi, && || ;, |, exit | services: systemctl list-units, journalctl -u UNIT | try: help, ls /bin, crontab -l'
'Bare operating system — guest session (login [--new] <passphrase> to unlock) | shell: cd, export, if/fi, && || ;, |, &, jobs/fg, <<<, exit | services: systemctl list-units, journalctl -u UNIT | try: help, ls /bin, crontab -l'
if (ctx.bareOsSkipRepl) {
console.log(
'Bare operating system — non-interactive session (BARE_OS_SKIP_REPL).'
@@ -358,38 +433,115 @@ async function printSessionBanner(ctx) {
console.log(defaultBanner)
}
/**
* @param {Record<string, unknown>} ctx
* @returns {Promise<boolean>}
*/
async function runKernelSelftest(ctx) {
const v = ctx.env && ctx.env.BARE_OS_KERNEL_SELFTEST
if (v !== '1' && v !== 'true') return true
const { execLine, console } = ctx
const strict = bootStrict(ctx)
const lines = [
':',
'true',
'false; echo selftest_false_ok',
'systemctl list-units 2>/dev/null || true'
]
for (const line of lines) {
try {
await execLine(line)
} catch (e) {
console.error('selftest: ' + ((e && e.message) || String(e)))
if (strict) {
if (typeof ctx.requestBooterExit === 'function') ctx.requestBooterExit(1)
return false
}
}
}
return true
}
/**
* @param {Record<string, unknown>} ctx
* @param {string[]} phaseLog
*/
function publishBootReady(ctx, phaseLog) {
if (typeof ctx.bareOsPublishBootReady !== 'function') return
const sid = (ctx.env && ctx.env.BARE_OS_SESSION_ID) || ''
ctx.bareOsPublishBootReady({
ready: true,
phases: [...phaseLog],
sessionId: sid,
completedAtMs: Date.now(),
minimal: bootMinimal(ctx)
})
}
async function start(ctx) {
const { readLine, execLine, console } = ctx
await bootTimed(ctx, 'os-release', () => printOsRelease(ctx))
await bootTimed(ctx, 'motd', () => printMotd(ctx))
/** @type {string[]} */
const phaseLog = []
await bootTimed(ctx, 'os-release', () => printOsRelease(ctx), phaseLog)
await bootTimed(ctx, 'motd', () => printMotd(ctx), phaseLog)
const profileName = await resolveBootProfileName(ctx)
/** @type {boolean} */
let bootOk = true
if (!shouldSkipBootPhase(ctx, 'profile')) {
await bootTimed(ctx, 'rc.profile', async () => {
bootOk = await runProfileRc(ctx, profileName)
})
}, phaseLog)
} else {
phaseLog.push('rc.profile(skipped)')
}
if (!bootOk) return
if (!shouldSkipBootPhase(ctx, 'rc')) {
await bootTimed(ctx, 'rc', async () => {
bootOk = await runRcFileAt(ctx, '/etc/bare-os/rc', 'rc')
})
}, phaseLog)
} else {
phaseLog.push('rc(skipped)')
}
if (!bootOk) return
if (!shouldSkipBootPhase(ctx, 'rc.d')) {
await bootTimed(ctx, 'rc.d', async () => {
bootOk = await runBareOsRcDir(ctx)
})
}, phaseLog)
} else {
phaseLog.push('rc.d(skipped)')
}
if (!bootOk) return
if (!shouldSkipBootPhase(ctx, 'rc.local')) {
await bootTimed(ctx, 'rc.local', async () => {
bootOk = await runRcFileAt(ctx, '/etc/bare-os/rc.local', 'rc.local')
})
}, phaseLog)
} else {
phaseLog.push('rc.local(skipped)')
}
if (!bootOk) return
if (!shouldSkipBootPhase(ctx, 'kernel.d')) {
await bootTimed(ctx, 'kernel.d', async () => {
bootOk = await runBareOsKernelDir(ctx)
})
}, phaseLog)
} else {
phaseLog.push('kernel.d(skipped)')
}
if (!bootOk) return
await printSessionBanner(ctx)
phaseLog.push('banner')
if (!shouldSkipBootPhase(ctx, 'onboot')) {
await bootTimed(ctx, 'onboot', async () => {
bootOk = await runOnboot(ctx)
})
}, phaseLog)
} else {
phaseLog.push('onboot(skipped)')
}
if (!bootOk) return
await bootTimed(ctx, 'selftest', async () => {
bootOk = await runKernelSelftest(ctx)
}, phaseLog)
if (!bootOk) return
publishBootReady(ctx, phaseLog)
while (true) {
const line = await readLine('')
if (line == null) break
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -44,7 +44,8 @@ HTTP in Pear/Bare uses **WHATWG Fetch** (Node `fetch` or **`bare-fetch`** on `ba
| `-c` / `--continue` | supported | `Range: bytes=<existing>-` when output file exists; 206 appends, 200 replaces full file |
| `-V` / `-h` | supported | |
| Recursive / spider / FTP / WARC | out of scope | |
| `--no-clobber`, `-N` timestamping | out of scope | |
| `--no-clobber` / `-nc` | supported | Skip download when the target file already exists and has size &gt; 0 (not combined with `--continue`) |
| `-N` timestamping | out of scope | |
## Fetch / bare-fetch vs curl (`-I` + `-L`)
@@ -54,6 +55,8 @@ We **do not** depend on bare-fetch for that case: [`fetchHeadWithLocationFollow`
## Upstream (bare-fetch)
**Tracking:** Bare OS implements **`curl -I -L`** with manual redirects so the first hop can stay **HEAD** and a **3xx** response can be followed with **GET** (see **`fetchHeadWithLocationFollow`**). Consider an upstream **`bare-fetch`** option for “curl-like” redirect method switching so other callers get the same behavior without a custom loop.
**Suggested issue title:** Redirect following preserves original method (HEAD on every hop); optional curl-like mode?
**Body (paste into GitHub):**
+51 -3
View File
@@ -1,4 +1,4 @@
import { randomUUID } from 'node:crypto'
import { randomUUID, randomBytes } from 'node:crypto'
import Hyperswarm from 'hyperswarm'
import Protomux from 'protomux'
import b4a from 'b4a'
@@ -39,6 +39,7 @@ import {
listBareServices,
getBareServiceRuntime
} from './lib/bare-initd.js'
import { appendVarLog, AUDIT_LOG } from './lib/bare-os-var-log.js'
import { BARE_OS_CTX_API_VERSION } from './lib/bare-os-ctx-api.js'
import { buildBareOsRuntimeCaps } from './lib/bare-os-runtime-caps.js'
import './lib/bare-cron.js'
@@ -239,7 +240,13 @@ async function executeKernel(disk, store, swarm, initSource) {
'BARE_OS_BOOT_PROFILE',
'BARE_OS_ONBOOT',
'BARE_OS_BOOT_STRICT',
'BARE_OS_RC_D_SKIP'
'BARE_OS_RC_D_SKIP',
'BARE_OS_BOOT_MINIMAL',
'BARE_OS_BOOT_SKIP',
'BARE_OS_BOOT_TRACE',
'BARE_OS_KERNEL_SELFTEST',
'BARE_OS_AUDIT',
'BARE_OS_IMAGE_DIGEST'
]) {
const v = hostEnv[k]
if (v != null && v !== '') shellEnv[k] = v
@@ -263,6 +270,16 @@ async function executeKernel(disk, store, swarm, initSource) {
shellEnv.BARE_OS_BOOT_PROFILE_RESOLVED = bootProfileResolved
const bareOsSessionId = randomUUID()
shellEnv.BARE_OS_SESSION_ID = bareOsSessionId
/** @type {{ ready: boolean, phases: string[], sessionId: string, completedAtMs: number | null, minimal: boolean }} */
const bootReadyStateRef = {
ready: false,
phases: [],
sessionId: bareOsSessionId,
completedAtMs: null,
minimal: false
}
/** @type {{ execLineCount: number, pipelineBytesTotal: number }} */
const sessionStatsRef = { execLineCount: 0, pipelineBytesTotal: 0 }
/** @type {{ getMounts: () => Map<string, { drive: import('hyperdrive').default, writable: boolean }> }} */
const vfsMountRef = { getMounts: () => new Map() }
const bootStartedMs = Date.now()
@@ -291,6 +308,22 @@ async function executeKernel(disk, store, swarm, initSource) {
lines.push(`${s.name}\t${phase}\t${started}\t${desc}`)
}
return lines.join('\n') + '\n'
},
sessionStatsText() {
return `${JSON.stringify(sessionStatsRef)}\n`
},
bootReadyMarkerText() {
return bootReadyStateRef.ready ? '1\n' : '0\n'
},
bootReadyJsonText() {
return `${JSON.stringify(bootReadyStateRef)}\n`
},
buildIdText() {
const d = shellEnv.BARE_OS_IMAGE_DIGEST
return (d != null && String(d).trim() ? String(d).trim() : 'unknown') + '\n'
},
secureRandomBytes(n) {
return new Uint8Array(randomBytes(Math.min(65536, Math.max(1, n | 0))))
}
})
@@ -402,7 +435,16 @@ async function executeKernel(disk, store, swarm, initSource) {
*/
registerKernelShutdownHook(fn) {
registerKernelShutdownHook(fn)
}
},
/**
* Kernel publishes boot completion for pseudo `/run/bare-os/boot.json` and `ready`.
* @param {Partial<{ ready: boolean, phases: string[], sessionId: string, completedAtMs: number, minimal: boolean }>} patch
*/
bareOsPublishBootReady(patch) {
if (patch && typeof patch === 'object') Object.assign(bootReadyStateRef, patch)
},
/** Mutable ref mirrored in `/proc/bare_os_session_stats` (execLineCount, pipelineBytesTotal). */
bareOsSessionStats: sessionStatsRef
}
ctx.replStdin = sessionStdin
@@ -430,6 +472,12 @@ async function executeKernel(disk, store, swarm, initSource) {
const sessionReadLine = session.readLine
ctx.execLine = async (line) => {
const t = line.trim()
sessionStatsRef.execLineCount++
const auditOn =
shellEnv.BARE_OS_AUDIT === '1' || shellEnv.BARE_OS_AUDIT === 'true'
if (auditOn && t) {
void appendVarLog(ctx, AUDIT_LOG, 'exec', t.slice(0, 500))
}
const exitMatch = t.match(/^exit(?:\s+(-?\d+))?$/)
if (exitMatch) {
const ec =
+157 -28
View File
@@ -5,10 +5,13 @@
import b4a from 'b4a'
/** One unit name per line; blank lines and `#…` comments ignored. */
/** One unit per line; blank lines and `#…` comments ignored. */
export const BARE_INITD_DISABLED_FILE = '~/.config/bare-os/initd/disabled.txt'
/** Optional `unit.unit` files: `[Unit]` with `After=other-unit …`. */
/**
* Optional `name.unit` files under this directory. Supports `[Unit]` keys:
* After=, Requires=, Wants=, TimeoutStartSec=, TimeoutStopSec=, Restart=, RestartSec=, ExecStartPost=.
*/
export const BARE_INITD_UNITS_DIR = '~/.config/bare-os/units'
/** Built-in ordering when no drop-in file exists (kernel-logger before cron). */
@@ -16,6 +19,88 @@ export const BARE_INITD_DEFAULT_AFTER = Object.freeze({
'bare-cron': ['kernel-logger']
})
/**
* @typedef {{
* after: string[],
* requires: string[],
* wants: string[],
* timeoutStartSec: number | null,
* timeoutStopSec: number | null,
* restart: string | null,
* restartSec: number | null,
* execStartPost: string | null
* }} BareInitdUnitDropIn
*/
/** @returns {BareInitdUnitDropIn} */
export function emptyUnitDropIn() {
return {
after: [],
requires: [],
wants: [],
timeoutStartSec: null,
timeoutStopSec: null,
restart: null,
restartSec: null,
execStartPost: null
}
}
/**
* @param {string} val
* @returns {string[]}
*/
function parseList(val) {
return val
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean)
}
/**
* Parse a systemd-inspired `.unit` snippet (only `[Unit]` keys are honored).
* @param {string} text
* @returns {BareInitdUnitDropIn}
*/
export function parseUnitDropInText(text) {
const out = emptyUnitDropIn()
let section = ''
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim()
if (!line || line.startsWith('#')) continue
if (/^\[[^\]]+\]$/.test(line)) {
section = line.slice(1, -1).toLowerCase()
continue
}
if (section !== 'unit') continue
const eq = line.indexOf('=')
if (eq < 1) continue
const key = line.slice(0, eq).trim().toLowerCase()
const val = line.slice(eq + 1).trim()
if (key === 'after') out.after.push(...parseList(val))
else if (key === 'requires') out.requires.push(...parseList(val))
else if (key === 'wants') out.wants.push(...parseList(val))
else if (key === 'timeoutstartsec') {
const n = Number.parseInt(val, 10)
if (Number.isFinite(n) && n > 0) out.timeoutStartSec = n
} else if (key === 'timeoutstopsec') {
const n = Number.parseInt(val, 10)
if (Number.isFinite(n) && n > 0) out.timeoutStopSec = n
} else if (key === 'restart') {
out.restart = val.toLowerCase()
} else if (key === 'restartsec') {
const n = Number.parseInt(val, 10)
if (Number.isFinite(n) && n >= 0) out.restartSec = n
} else if (key === 'execstartpost') {
out.execStartPost = val
}
}
out.after = [...new Set(out.after)]
out.requires = [...new Set(out.requires)]
out.wants = [...new Set(out.wants)]
return out
}
/**
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
* @returns {Promise<Set<string>>}
@@ -60,16 +145,20 @@ export async function writeInitdDisabledSet(vfs, disabled) {
}
/**
* @param {string} text
* @returns {string[]}
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
* @param {string} unitName
* @returns {Promise<BareInitdUnitDropIn>}
*/
function parseAfterLine(text) {
const m = text.match(/^\s*After\s*=\s*(.+)$/im)
if (!m) return []
return m[1]
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean)
export async function readUnitDropIn(vfs, unitName) {
if (!/^[a-zA-Z0-9._-]+$/.test(unitName)) return emptyUnitDropIn()
const path = `${BARE_INITD_UNITS_DIR}/${unitName}.unit`
try {
const buf = await vfs.readFile(path)
if (!buf) return emptyUnitDropIn()
return parseUnitDropInText(b4a.toString(buf, 'utf8'))
} catch {
return emptyUnitDropIn()
}
}
/**
@@ -78,16 +167,26 @@ function parseAfterLine(text) {
* @returns {Promise<string[]>}
*/
export async function readUnitAfterFromDropIn(vfs, unitName) {
if (!/^[a-zA-Z0-9._-]+$/.test(unitName)) return []
const path = `${BARE_INITD_UNITS_DIR}/${unitName}.unit`
try {
const buf = await vfs.readFile(path)
if (!buf) return []
const text = b4a.toString(buf, 'utf8')
return parseAfterLine(text)
} catch {
return []
const d = await readUnitDropIn(vfs, unitName)
return d.after
}
/**
* @param {{ readFile: (p: string) => Promise<Uint8Array | null> }} vfs
* @param {{ name: string }[]} services
* @param {Readonly<Record<string, string[]>>} defaultAfter
* @returns {Promise<Map<string, BareInitdUnitDropIn>>}
*/
export async function loadInitdUnitDropIns(vfs, services, defaultAfter) {
/** @type {Map<string, BareInitdUnitDropIn>} */
const map = new Map()
for (const s of services) {
const fromFile = await readUnitDropIn(vfs, s.name)
const def = defaultAfter[s.name] || []
fromFile.after = [...new Set([...def, ...fromFile.after])]
map.set(s.name, fromFile)
}
return map
}
/**
@@ -97,26 +196,50 @@ export async function readUnitAfterFromDropIn(vfs, unitName) {
* @returns {Promise<Map<string, string[]>>}
*/
export async function loadInitdAfterMap(vfs, services, defaultAfter) {
const full = await loadInitdUnitDropIns(vfs, services, defaultAfter)
const map = new Map()
for (const s of services) {
const fromFile = await readUnitAfterFromDropIn(vfs, s.name)
const def = defaultAfter[s.name] || []
const merged = [...new Set([...def, ...fromFile])]
map.set(s.name, merged)
}
for (const [k, v] of full) map.set(k, v.after)
return map
}
/**
* @template T extends { name: string }
* @param {T[]} services
* @param {Set<string>} disabled
* @param {Map<string, string[]>} requiresMap
*/
function filterSatisfiedRequires(services, disabled, requiresMap) {
const names = new Set(services.map((s) => s.name))
return services.filter((s) => {
for (const r of requiresMap.get(s.name) || []) {
if (!names.has(r)) return false
if (disabled.has(r)) return false
}
return true
})
}
/**
* Topological sort; cycles fall back to registration order for the remainder.
* @template T extends { name: string }
* @param {T[]} services
* @param {Set<string>} disabled
* @param {Map<string, string[]>} afterMap
* @param {Map<string, string[]>} [requiresMap]
* @param {Map<string, string[]>} [wantsMap]
* @returns {T[]}
*/
export function sortServicesForBoot(services, disabled, afterMap) {
const active = services.filter((s) => !disabled.has(s.name))
export function sortServicesForBoot(
services,
disabled,
afterMap,
requiresMap,
wantsMap
) {
const reqM = requiresMap || new Map()
const wanM = wantsMap || new Map()
let active = services.filter((s) => !disabled.has(s.name))
active = filterSatisfiedRequires(active, disabled, reqM)
const nameSet = new Set(active.map((s) => s.name))
/** @type {Map<string, Set<string>>} */
const prereq = new Map()
@@ -125,6 +248,12 @@ export function sortServicesForBoot(services, disabled, afterMap) {
for (const a of afterMap.get(s.name) || []) {
if (nameSet.has(a)) inc.add(a)
}
for (const r of reqM.get(s.name) || []) {
if (nameSet.has(r)) inc.add(r)
}
for (const w of wanM.get(s.name) || []) {
if (nameSet.has(w)) inc.add(w)
}
prereq.set(s.name, inc)
}
const remaining = new Set(active.map((s) => s.name))
+122 -10
View File
@@ -11,8 +11,10 @@ import {
} from './bare-os-var-log.js'
import {
BARE_INITD_DEFAULT_AFTER,
loadInitdAfterMap,
emptyUnitDropIn,
loadInitdUnitDropIns,
readInitdDisabledSet,
readUnitDropIn,
sortServicesForBoot
} from './bare-initd-user.js'
@@ -135,10 +137,30 @@ export async function startBareService(ctx, name) {
if (rt?.phase === 'active') {
return { noop: true, message: `${name} is already active` }
}
const vfs = ctx.vfs
const di =
vfs && typeof vfs.readFile === 'function'
? await readUnitDropIn(vfs, name)
: emptyUnitDropIn()
const startSec = di.timeoutStartSec
const t0 = Date.now()
try {
await s.start(ctx)
await withTimeoutSec(s.start(ctx), startSec, `start ${name}`)
runtime.set(name, { phase: 'active', startedAtMs: t0 })
const post = di.execStartPost
if (post && typeof ctx.execLine === 'function' && post.trim()) {
try {
await ctx.execLine(post.trim())
} catch (e) {
const msg = e?.message || String(e)
try {
ctx.console?.error?.(`[bare-initd] ${name} ExecStartPost: ${msg}`)
} catch {
/* ignore */
}
void appendVarLog(ctx, INITD_LOG, name, 'ExecStartPost: ' + msg)
}
}
return { ok: true }
} catch (e) {
const msg = e?.message || String(e)
@@ -154,16 +176,43 @@ export async function startBareService(ctx, name) {
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} name
* @param {Promise<void>} promise
* @param {number | null | undefined} sec
* @param {string} label
*/
async function withTimeoutSec(promise, sec, label) {
if (sec == null || !Number.isFinite(sec) || sec <= 0) return promise
const ms = Math.round(sec * 1000)
return await new Promise((resolve, reject) => {
const t = setTimeout(() => {
reject(new Error(`${label} timed out after ${sec}s`))
}, ms)
Promise.resolve(promise).then(
() => {
clearTimeout(t)
resolve(undefined)
},
(e) => {
clearTimeout(t)
reject(e)
}
)
})
}
export async function stopBareService(ctx, name) {
const s = findBareServiceDefinition(name)
if (!s) throw new Error(`Unknown unit: ${name}`)
if (typeof s.stop !== 'function') {
throw new Error(`Unit ${name} does not support stop (no stop handler)`)
}
await s.stop(ctx)
const vfs = ctx.vfs
let stopSec = null
if (vfs && typeof vfs.readFile === 'function') {
const di = await readUnitDropIn(vfs, name)
stopSec = di.timeoutStopSec
}
await withTimeoutSec(s.stop(ctx), stopSec, `stop ${name}`)
const prev = runtime.get(name)
runtime.set(name, {
phase: 'inactive',
@@ -183,10 +232,16 @@ export async function restartBareService(ctx, name) {
`Unit ${name} does not support restart (no stop handler; session teardown only)`
)
}
await s.stop(ctx)
await stopBareService(ctx, name)
const vfs = ctx.vfs
const di =
vfs && typeof vfs.readFile === 'function'
? await readUnitDropIn(vfs, name)
: null
const startSec = di?.timeoutStartSec
const t0 = Date.now()
try {
await s.start(ctx)
await withTimeoutSec(s.start(ctx), startSec, `start ${name}`)
runtime.set(name, { phase: 'active', startedAtMs: t0 })
} catch (e) {
const msg = e?.message || String(e)
@@ -211,20 +266,75 @@ export async function startBareInitd(ctx) {
let ordered = registry
if (vfs && typeof vfs.readFile === 'function') {
const disabled = await readInitdDisabledSet(vfs)
const afterMap = await loadInitdAfterMap(
const dropIns = await loadInitdUnitDropIns(
vfs,
registry,
BARE_INITD_DEFAULT_AFTER
)
ordered = sortServicesForBoot(registry, disabled, afterMap)
/** @type {Map<string, string[]>} */
const requiresMap = new Map()
/** @type {Map<string, string[]>} */
const wantsMap = new Map()
/** @type {Map<string, string[]>} */
const afterMap = new Map()
for (const [name, di] of dropIns) {
afterMap.set(name, di.after)
requiresMap.set(name, di.requires)
wantsMap.set(name, di.wants)
}
ordered = sortServicesForBoot(
registry,
disabled,
afterMap,
requiresMap,
wantsMap
)
}
for (const s of ordered) {
const t0 = Date.now()
/** @type {import('./bare-initd-user.js').BareInitdUnitDropIn | null} */
let dropIn = null
if (vfs && typeof vfs.readFile === 'function') {
dropIn = await readUnitDropIn(vfs, s.name)
}
const startSec = dropIn?.timeoutStartSec
const maxAttempts =
dropIn?.restart === 'on-failure' || dropIn?.restart === 'always'
? 3
: 1
const restartDelayMs =
dropIn?.restartSec != null && dropIn.restartSec >= 0
? Math.round(dropIn.restartSec * 1000)
: 1000
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
await s.start(ctx)
if (attempt > 0) {
await new Promise((r) => setTimeout(r, restartDelayMs))
}
await withTimeoutSec(s.start(ctx), startSec, `start ${s.name}`)
runtime.set(s.name, { phase: 'active', startedAtMs: t0 })
const post = dropIn?.execStartPost
if (
post &&
typeof ctx.execLine === 'function' &&
post.trim()
) {
try {
await ctx.execLine(post.trim())
} catch (e) {
const msg = e?.message || String(e)
try {
ctx.console?.error?.(`[bare-initd] ${s.name} ExecStartPost: ${msg}`)
} catch {
/* ignore */
}
void appendVarLog(ctx, INITD_LOG, s.name, 'ExecStartPost: ' + msg)
}
}
break
} catch (e) {
const msg = e?.message || String(e)
if (attempt === maxAttempts - 1) {
runtime.set(s.name, { phase: 'failed', startedAtMs: t0, error: msg })
try {
ctx.console?.error?.(`[bare-initd] ${s.name}: ${msg}`)
@@ -234,6 +344,8 @@ export async function startBareInitd(ctx) {
void appendVarLog(ctx, INITD_LOG, s.name, msg)
}
}
}
}
}
function stopKernelLogger(ctx) {
@@ -2,4 +2,4 @@
* Semantic version of the booter `ctx` contract for custom kernels.
* Bump when adding/removing/renaming documented `ctx` fields or changing behavior.
*/
export const BARE_OS_CTX_API_VERSION = '1.3.0'
export const BARE_OS_CTX_API_VERSION = '1.4.0'
@@ -21,6 +21,7 @@ export const BARE_OS_PSEUDO_FS_PATHS = Object.freeze([
'/dev/zero',
'/dev/urandom',
'/proc',
'/proc/bare_os_session_stats',
'/proc/bare_os_version',
'/proc/cpuinfo',
'/proc/loadavg',
@@ -34,12 +35,15 @@ export const BARE_OS_PSEUDO_FS_PATHS = Object.freeze([
'/proc/version',
'/run',
'/run/bare-os',
'/run/bare-os/boot.json',
'/run/bare-os/boot_profile',
'/run/bare-os/ready',
'/run/bare-os/session',
'/run/bare-os/units',
'/sys',
'/sys/fs',
'/sys/fs/bare_os',
'/sys/fs/bare_os/build_id',
'/sys/fs/bare_os/version'
])
@@ -48,6 +52,11 @@ export const BARE_OS_PSEUDO_FS_PATHS = Object.freeze([
*/
export function buildBareOsRuntimeCaps(shellEnv) {
const pl = getBareOsPipelineLimits(shellEnv)
const auditOn =
shellEnv.BARE_OS_AUDIT === '1' || shellEnv.BARE_OS_AUDIT === 'true'
const minimalBoot =
shellEnv.BARE_OS_BOOT_MINIMAL === '1' ||
shellEnv.BARE_OS_BOOT_MINIMAL === 'true'
return Object.freeze({
ctxApiVersion: BARE_OS_CTX_API_VERSION,
pipeline: Object.freeze({
@@ -73,7 +82,19 @@ export function buildBareOsRuntimeCaps(shellEnv) {
identityVault: true,
httpDelegate: true,
gitDelegate: true,
systemctlDelegate: true
systemctlDelegate: true,
jobControl: true,
shellHereString: true,
bootReadyPseudoFs: true,
sessionStatsProc: true,
initdRequiresWants: true,
initdTimeoutsRestart: true,
vfsChown: true,
auditLog: auditOn,
recoveryBootCapable: true,
minimalBootActive: minimalBoot,
vfsWatch: false,
seederRpcExtended: true
})
})
}
@@ -10,12 +10,15 @@ export const CRON_LOG = `${BARE_OS_VAR_LOG_DIR}/cron.log`
export const INITD_LOG = `${BARE_OS_VAR_LOG_DIR}/initd.log`
export const AUDIT_LOG = `${BARE_OS_VAR_LOG_DIR}/audit.log`
const README_REL = `${BARE_OS_VAR_LOG_DIR}/README`
const README_TEXT = `Bare OS session logs (mirrored on your personal drive under /.bare-os/var/log/<HOME-basename>/).
kernel-console.log console.log / console.error from the kernel session
cron.log bare-cron job errors
initd.log bare-initd service start failures
audit.log optional execLine audit when BARE_OS_AUDIT=1
`
/** Max size before trimming older log bytes (best-effort). */
+159 -18
View File
@@ -13,7 +13,10 @@ const SHELL_BUILTINS = new Set([
'type',
'login',
'logout',
'exit'
'exit',
'jobs',
'fg',
'bg'
])
function isShellBuiltin(cmd) {
@@ -332,6 +335,11 @@ export function tokenize(line) {
continue
}
if (c === '<') {
if (line[i + 1] === '<' && line[i + 2] === '<') {
tokens.push({ type: 'op', value: '<<<' })
i += 3
continue
}
tokens.push({ type: 'op', value: '<' })
i++
continue
@@ -444,7 +452,8 @@ export function expandWord(s, env) {
* redirAppend: boolean,
* redirErr: string | null,
* redirErrAppend: boolean,
* mergeStderrToStdout: boolean
* mergeStderrToStdout: boolean,
* redirHereDoc: string | null
* }} SimpleCmd
*/
@@ -476,6 +485,8 @@ function parseSimpleCommand(seg) {
let redirErr = null
let redirErrAppend = false
let mergeStderrToStdout = false
/** @type {string | null} */
let redirHereDoc = null
/** @type {string[]} */
const argvWords = []
let seenCommand = false
@@ -515,6 +526,14 @@ function parseSimpleCommand(seg) {
continue
}
}
if (t.value === '<<<') {
const n = seg[w + 1]
if (n && n.type === 'word') {
redirHereDoc = n.value
w += 2
continue
}
}
w++
continue
}
@@ -574,6 +593,11 @@ function parseSimpleCommand(seg) {
argvWords.splice(i, 2)
continue
}
if (argvWords[i] === '<<<') {
redirHereDoc = argvWords[i + 1] ?? ''
argvWords.splice(i, 2)
continue
}
i++
}
@@ -585,7 +609,8 @@ function parseSimpleCommand(seg) {
redirAppend,
redirErr,
redirErrAppend,
mergeStderrToStdout
mergeStderrToStdout,
redirHereDoc
}
}
@@ -681,7 +706,9 @@ async function execParsedPipeline(ctx, pipeline) {
}
const name = argv[0]
if (cmd.redirIn) {
if (cmd.redirHereDoc) {
stdinText = expandWord(cmd.redirHereDoc, env)
} else if (cmd.redirIn) {
const p = expandWord(cmd.redirIn, env)
const buf = await vfs.readFile(p)
stdinText = buf ? ctx.b4a.toString(buf) : ''
@@ -710,11 +737,21 @@ async function execParsedPipeline(ctx, pipeline) {
}
}
const pushOut = (...args) => {
outChunks.push(args.map(String).join(' ') + '\n')
const line = args.map(String).join(' ') + '\n'
outChunks.push(line)
const st = ctx.bareOsSessionStats
if (st && typeof st.pipelineBytesTotal === 'number') {
st.pipelineBytesTotal += line.length
}
checkCaptureSize(outChunks)
}
const pushErr = (...args) => {
errChunks.push(args.map(String).join(' ') + '\n')
const line = args.map(String).join(' ') + '\n'
errChunks.push(line)
const st = ctx.bareOsSessionStats
if (st && typeof st.pipelineBytesTotal === 'number') {
st.pipelineBytesTotal += line.length
}
checkCaptureSize(errChunks)
}
@@ -920,6 +957,45 @@ async function execParsedPipeline(ctx, pipeline) {
origErr.call(ctx.console, 'logout: not supported in this environment')
ctx.exitCode = 1
}
} else if (name === 'jobs') {
const list = ctx.shellBackgroundJobs?.list || []
if (!list.length) {
origLog.call(ctx.console, '')
} else {
for (const j of list) {
const st = j.done ? 'Done' : 'Running'
origLog.call(ctx.console, `[${j.id}]+ ${st} ${j.label}`)
}
}
} else if (name === 'fg') {
const list = ctx.shellBackgroundJobs?.list || []
let candidates = list.filter((j) => !j.done)
const arg = argv[1]
if (arg) {
const raw = arg.startsWith('%') ? arg.slice(1) : arg
const id = Number.parseInt(raw, 10)
if (Number.isFinite(id)) {
candidates = list.filter((j) => j.id === id)
}
}
const j = candidates.length ? candidates[candidates.length - 1] : null
if (!j) {
origErr.call(ctx.console, 'fg: no such job')
ctx.exitCode = 1
} else {
try {
await j.promise
} catch {
/* background errors already logged */
}
ctx.exitCode = 0
}
} else if (name === 'bg') {
origErr.call(
ctx.console,
'bg: job resume is not supported (no stopped jobs)'
)
ctx.exitCode = 1
} else if (name === 'exit') {
code = 'exit'
let ec = 0
@@ -1024,6 +1100,70 @@ function splitTopLevelStatements(tokens) {
return out
}
/**
* Split on `&` at depth 0 (outside `if`/`fi`). Each segment except the last runs in the background.
* @param {Token[]} tokens
* @returns {Token[][]}
*/
function splitTopLevelByAmpersand(tokens) {
/** @type {Token[][]} */
const out = []
/** @type {Token[]} */
let cur = []
let depth = 0
for (const t of tokens) {
if (t.type === 'word') {
if (t.value === 'if') depth++
else if (t.value === 'fi') depth = Math.max(0, depth - 1)
}
if (t.type === 'op' && t.value === '&' && depth === 0) {
out.push(cur)
cur = []
} else {
cur.push(t)
}
}
out.push(cur)
return out
}
/**
* @param {Record<string, unknown>} ctx
* @param {Token[]} toks
*/
function scheduleBackgroundShell(ctx, toks) {
if (!ctx.shellBackgroundJobs) {
ctx.shellBackgroundJobs = { nextId: 1, list: [] }
}
const id = ctx.shellBackgroundJobs.nextId++
const label = toks
.filter((t) => t.type === 'word')
.map((t) => t.value)
.slice(0, 6)
.join(' ')
const childCtx = Object.assign({}, ctx)
const promise = (async () => {
const statements = splitTopLevelStatements(toks)
for (const stmt of statements) {
if (!stmt.length) continue
const r =
stmt[0]?.type === 'word' && stmt[0].value === 'if'
? await execIfConstruct(childCtx, stmt)
: await execAndOrList(childCtx, stmt)
if (r === 'exit') return r
}
syncBareOsExitStatusEnv(childCtx)
return 'ok'
})()
/** @type {{ id: number, label: string, promise: Promise<string>, done: boolean }} */
const entry = { id, label, promise, done: false }
ctx.shellBackgroundJobs.list.push(entry)
promise.finally(() => {
entry.done = true
})
ctx.console.error(`[${id}] ${label || '(job)'} &`)
}
/**
* @param {Token[]} tokens
* @param {number} start index after `if`
@@ -1208,27 +1348,28 @@ export async function execShellLine(ctx, line) {
const tokens = tokenize(raw)
if (!tokens.length) return 'ok'
if (tokens.some((t) => t.type === 'op' && t.value === '&')) {
ctx.console.error(
'shell: unsupported operator & (job control is not available)'
)
ctx.exitCode = 2
syncBareOsExitStatusEnv(ctx)
return 'ok'
}
const statements = splitTopLevelStatements(tokens)
for (const stmt of statements) {
if (!stmt.length) continue
const ampParts = splitTopLevelByAmpersand(stmt)
for (let ai = 0; ai < ampParts.length; ai++) {
const part = ampParts[ai]
if (!part.length) continue
const isBg = ai < ampParts.length - 1
if (isBg) {
scheduleBackgroundShell(ctx, part)
continue
}
const r =
stmt[0]?.type === 'word' && stmt[0].value === 'if'
? await execIfConstruct(ctx, stmt)
: await execAndOrList(ctx, stmt)
part[0]?.type === 'word' && part[0].value === 'if'
? await execIfConstruct(ctx, part)
: await execAndOrList(ctx, part)
if (r === 'exit') {
syncBareOsExitStatusEnv(ctx)
return 'exit'
}
}
}
syncBareOsExitStatusEnv(ctx)
return 'ok'
}
+168 -4
View File
@@ -2,6 +2,7 @@ import unixPathResolve from 'unix-path-resolve'
import b4a from 'b4a'
import {
extractBareOs,
identityNames,
isPersonalRoute,
isVirtualMountPoint,
mergeBareOsOnWrite,
@@ -33,7 +34,12 @@ const DIR_MARKER = '.bareos_empty'
* bootStartedMs?: number,
* initdRunText?: () => string,
* bootProfileText?: () => string,
* sessionText?: () => string
* sessionText?: () => string,
* sessionStatsText?: () => string,
* bootReadyMarkerText?: () => string,
* bootReadyJsonText?: () => string,
* buildIdText?: () => string,
* secureRandomBytes?: (n: number) => Uint8Array
* }} [vfsOptions]
*/
export function createVfs(
@@ -60,6 +66,26 @@ export function createVfs(
typeof vfsOptions.sessionText === 'function'
? vfsOptions.sessionText
: null
const sessionStatsText =
typeof vfsOptions.sessionStatsText === 'function'
? vfsOptions.sessionStatsText
: null
const bootReadyMarkerText =
typeof vfsOptions.bootReadyMarkerText === 'function'
? vfsOptions.bootReadyMarkerText
: null
const bootReadyJsonText =
typeof vfsOptions.bootReadyJsonText === 'function'
? vfsOptions.bootReadyJsonText
: null
const buildIdText =
typeof vfsOptions.buildIdText === 'function'
? vfsOptions.buildIdText
: null
const secureRandomBytes =
typeof vfsOptions.secureRandomBytes === 'function'
? vfsOptions.secureRandomBytes
: null
const HOME = () => env.HOME || '/home/guest'
let cwd = env.PWD || HOME()
@@ -190,9 +216,19 @@ export function createVfs(
return '/bin/sh (bare-os stub; not a real inode)\n'
}
/** Bounded pseudo-random buffer; **not** suitable for cryptography. */
/**
* When the booter supplies `secureRandomBytes` (e.g. node:crypto), reads are suitable
* for cryptographic use; otherwise falls back to Math.random (not crypto-grade).
*/
function pseudoUrandomBytes() {
const n = 4096
if (secureRandomBytes) {
try {
return secureRandomBytes(n)
} catch {
/* fall through */
}
}
const u = new Uint8Array(n)
for (let i = 0; i < n; i++) u[i] = Math.floor(Math.random() * 256)
return u
@@ -204,6 +240,10 @@ export function createVfs(
if (f === 'version' && (k === 'proc' || k === 'sys')) {
return utf8Encode(pseudoVersionText())
}
if (k === 'sys' && f === 'build_id') {
const t = buildIdText ? buildIdText() : 'unknown\n'
return utf8Encode(t)
}
if (k === 'proc') {
if (f === 'cmdline') return utf8Encode(pseudoCmdlineText())
if (f === 'environ') return pseudoEnvironBytes()
@@ -213,6 +253,12 @@ export function createVfs(
if (f === 'loadavg') return utf8Encode(pseudoLoadavgText())
if (f === 'exe') return utf8Encode(pseudoSelfExeText())
if (f === 'mounts') return utf8Encode(pseudoMountsText())
if (f === 'bare_os_session_stats') {
const t = sessionStatsText
? sessionStatsText()
: '{}\n'
return utf8Encode(t)
}
}
if (k === 'run' && f === 'units') {
const t = initdRunText
@@ -228,6 +274,14 @@ export function createVfs(
const t = sessionText ? sessionText() : '\n'
return utf8Encode(t)
}
if (k === 'run' && f === 'ready') {
const t = bootReadyMarkerText ? bootReadyMarkerText() : '0\n'
return utf8Encode(t)
}
if (k === 'run' && f === 'boot_json') {
const t = bootReadyJsonText ? bootReadyJsonText() : '{}\n'
return utf8Encode(t)
}
if (k === 'dev' && f === 'null') return utf8Encode('')
if (k === 'dev' && f === 'zero') return new Uint8Array(65536)
if (k === 'dev' && f === 'urandom') return pseudoUrandomBytes()
@@ -275,6 +329,14 @@ export function createVfs(
if (sub === 'mounts') {
return { virtualPseudo: true, kind: 'proc', node: 'file', file: 'mounts' }
}
if (sub === 'bare_os_session_stats') {
return {
virtualPseudo: true,
kind: 'proc',
node: 'file',
file: 'bare_os_session_stats'
}
}
return { virtualPseudo: true, kind: 'proc', node: 'enoent' }
}
if (n === '/run' || n.startsWith('/run/')) {
@@ -303,6 +365,17 @@ export function createVfs(
file: 'session'
}
}
if (n === '/run/bare-os/ready') {
return { virtualPseudo: true, kind: 'run', node: 'file', file: 'ready' }
}
if (n === '/run/bare-os/boot.json') {
return {
virtualPseudo: true,
kind: 'run',
node: 'file',
file: 'boot_json'
}
}
return { virtualPseudo: true, kind: 'run', node: 'enoent' }
}
if (n === '/dev' || n.startsWith('/dev/')) {
@@ -333,6 +406,9 @@ export function createVfs(
if (n === '/sys/fs/bare_os/version') {
return { virtualPseudo: true, kind: 'sys', node: 'file', file: 'version' }
}
if (n === '/sys/fs/bare_os/build_id') {
return { virtualPseudo: true, kind: 'sys', node: 'file', file: 'build_id' }
}
return { virtualPseudo: true, kind: 'sys', node: 'enoent' }
}
return null
@@ -823,6 +899,7 @@ export function createVfs(
}
if (pr.kind === 'proc' && pr.node === 'root') {
return [
'bare_os_session_stats',
'bare_os_version',
'cpuinfo',
'loadavg',
@@ -843,13 +920,13 @@ export function createVfs(
return ['bare_os']
}
if (pr.kind === 'sys' && pr.node === 'dir' && pr.dir === 'bare_os') {
return ['version']
return ['build_id', 'version']
}
if (pr.kind === 'run' && pr.node === 'root') {
return ['bare-os']
}
if (pr.kind === 'run' && pr.node === 'dir' && pr.dir === 'bare_os') {
return ['boot_profile', 'session', 'units']
return ['boot.json', 'boot_profile', 'ready', 'session', 'units']
}
if (pr.kind === 'dev' && pr.node === 'root') {
return ['null', 'urandom', 'zero']
@@ -1201,6 +1278,93 @@ export function createVfs(
})
},
/**
* Update stored ownership on the personal drive (same writable scope as chmod). Root (euid 0)
* may set any uid/gid; non-root may only change the group of files they own.
* @param {string} userPath
* @param {{ uid: number, gid: number, uname?: string, gname?: string }} next
*/
async chown(userPath, next) {
const abs = resolveLogical(userPath)
const r = route(abs)
if (
r.virtualHomeDir ||
r.virtualMntRoot ||
r.virtualVarRoot ||
r.virtualPseudo
) {
throw new Error('chown: ' + userPath + ': Operation not supported')
}
if (r.mntReadOnly === true) {
throw new Error('Read-only mount: ' + userPath)
}
const { drive, path: p } = r
if (drive !== personalDrive) {
throw new Error('chown: read-only system path: ' + userPath)
}
if (isHyperdriveRootPath(p)) {
throw new Error('chown: invalid path')
}
const st = await lstatFromAbs(abs)
if (!st) throw new Error('chown: ' + userPath + ': No such file')
const { uid: euid } = parseUidGid(env)
const nu = Number(next.uid)
const ng = Number(next.gid)
if (!Number.isFinite(nu) || !Number.isFinite(ng)) {
throw new Error('chown: invalid uid/gid')
}
if (euid !== 0) {
if (euid !== st.uid) {
throw new Error('chown: ' + userPath + ': Operation not permitted')
}
if (nu !== st.uid) {
throw new Error('chown: ' + userPath + ': Operation not permitted')
}
}
const { user: defU, group: defG } = identityNames(env)
const uname =
typeof next.uname === 'string' && next.uname.trim()
? next.uname.trim()
: defU
const gname =
typeof next.gname === 'string' && next.gname.trim()
? next.gname.trim()
: defG
const e = await entryOn(drive, p, { follow: false })
if (!e?.value) {
throw new Error('chown: cannot change inferred directory: ' + userPath)
}
const v = e.value
const prevBare = extractBareOs(v)
const now = Date.now()
const bo = prevBare
? {
...prevBare,
uid: nu,
gid: ng,
uname,
gname,
mtimeMs: prevBare.mtimeMs,
ctimeMs: now
}
: {
mode: S_IFREG | 0o644,
uid: nu,
gid: ng,
uname,
gname,
mtimeMs: st.mtimeMs,
ctimeMs: now
}
const executable = !!(v.blob && (bo.mode & 0o111))
await drive.putEntry(p, {
executable,
linkname: v.linkname ?? null,
blob: v.blob ?? null,
metadata: mergeEntryMetadata(v.metadata, bo)
})
},
/**
* Like stat but do not follow symlinks at the final path (for isomorphic-git lstat).
*/
+32
View File
@@ -162,6 +162,7 @@ export async function runWgetCli(ctx, argv) {
userAgent: /** @type {string | null} */ (null),
timeoutMs: 0,
continuePartial: false,
noClobber: false,
extraHeaders: /** @type {string[]} */ ([]),
postData: /** @type {string | null} */ (null),
postFile: /** @type {string | null} */ (null)
@@ -207,6 +208,12 @@ export async function runWgetCli(ctx, argv) {
continue
}
if (a === '-nc' || a === '--no-clobber') {
st.noClobber = true
i++
continue
}
if (a === '-O' || a === '--output-document') {
if (i + 1 >= args.length) {
ctx.console.error('wget: option requires an argument: ' + a)
@@ -345,6 +352,7 @@ export async function runWgetCli(ctx, argv) {
userAgent,
timeoutMs,
continuePartial,
noClobber,
extraHeaders,
postData,
postFile
@@ -380,6 +388,12 @@ export async function runWgetCli(ctx, argv) {
return
}
if (noClobber && continuePartial) {
ctx.console.error('wget: --no-clobber cannot be used with --continue')
ctx.exitCode = 2
return
}
if (outputDocument && directoryPrefix) {
ctx.console.error('wget: cannot combine -O and -P')
ctx.exitCode = 2
@@ -509,6 +523,24 @@ export async function runWgetCli(ctx, argv) {
buf = combined
}
if (
noClobber &&
targetPath != null &&
outputDocument !== '-' &&
!continuePartial
) {
try {
const ex = await vfs.stat(targetPath)
if (ex && ex.type === 'file' && (ex.size ?? 0) > 0) {
if (!quiet)
ctx.console.error('wget: ' + targetPath + ': File exists (--no-clobber)')
continue
}
} catch {
/* treat as absent */
}
}
if (outputDocument === '-') {
ctx.console.log(ctx.b4a.toString(buf))
} else if (outputDocument != null) {
+3
View File
@@ -691,6 +691,7 @@ test('vfs /proc /sys read-only pseudo files; write rejected', async (t) => {
t.ok(root.includes('run'))
t.ok(root.includes('dev'))
t.alike(await vfs.readdir('/proc').then((a) => [...a].sort()), [
'bare_os_session_stats',
'bare_os_version',
'cpuinfo',
'loadavg',
@@ -741,7 +742,9 @@ test('vfs /proc /sys read-only pseudo files; write rejected', async (t) => {
const z = await vfs.readFile('/dev/zero')
t.ok(z && z.byteLength === 65536)
t.alike(await vfs.readdir('/run/bare-os').then((a) => [...a].sort()), [
'boot.json',
'boot_profile',
'ready',
'session',
'units'
])
+84 -5
View File
@@ -1,6 +1,85 @@
async function run(ctx, argv) {
ctx.console.error(
'chgrp: changing group is not supported on Bare OS (single-user Hyperdrive metadata).'
)
ctx.exitCode = 1
/**
* chgrp change file group (Hyperdrive metadata on writable paths).
*/
/**
* @param {string} spec
* @param {Record<string, string>} env
* @returns {number | null}
*/
function parseGroupSpec(spec, env) {
const s = String(spec).trim()
if (!s) return null
if (/^\d+$/.test(s)) {
const n = Number.parseInt(s, 10)
return Number.isFinite(n) ? n : null
}
const g = (env.GROUP || env.USER || 'guest').trim()
if (s === 'root') return 0
if (s === 'guest' || s === 'nobody') return 65534
if (g && s === g) {
const gid = Number.parseInt(String(env.GID ?? '65534'), 10)
return Number.isFinite(gid) ? gid : 65534
}
return null
}
async function run(ctx, argv) {
let i = 1
const files = []
for (; i < argv.length; i++) {
const a = argv[i]
if (a === '-h' || a === '--help') {
ctx.console.log(
'usage: chgrp [-h] GROUP FILE...\n' +
'GROUP may be a numeric gid or root/guest/nobody/current GROUP name.'
)
ctx.exitCode = 0
return
}
if (!a.startsWith('-')) break
ctx.console.error('chgrp: unsupported option: ' + a)
ctx.exitCode = 1
return
}
const groupSpec = argv[i++]
if (!groupSpec || i >= argv.length) {
ctx.console.error('usage: chgrp GROUP FILE...')
ctx.exitCode = 1
return
}
for (; i < argv.length; i++) files.push(argv[i])
const gid = parseGroupSpec(groupSpec, ctx.vfs.env)
if (gid == null) {
ctx.console.error('chgrp: invalid group: ' + groupSpec)
ctx.exitCode = 1
return
}
const gname = /^\d+$/.test(groupSpec.trim())
? undefined
: groupSpec.trim() || undefined
for (const f of files) {
try {
const st = await ctx.vfs.stat(f)
if (!st) {
ctx.console.error('chgrp: ' + f + ': No such file')
ctx.exitCode = 1
continue
}
if (typeof ctx.vfs.chown !== 'function') {
ctx.console.error('chgrp: not supported by this VFS')
ctx.exitCode = 1
return
}
await ctx.vfs.chown(f, {
uid: st.uid,
gid,
uname: st.user,
gname
})
} catch (e) {
ctx.console.error('chgrp: ' + f + ': ' + (e.message || e))
ctx.exitCode = 1
}
}
}
+152 -5
View File
@@ -1,6 +1,153 @@
async function run(ctx, argv) {
ctx.console.error(
'chown: changing file ownership is not supported on Bare OS (single-user Hyperdrive metadata).'
)
ctx.exitCode = 1
/**
* chown change file owner and group (Hyperdrive metadata on writable paths).
*/
/**
* @param {string} spec
* @param {Record<string, string>} env
* @returns {{ uid: number | null, gid: number | null }}
*/
function parseIdSpec(spec, env) {
const s = String(spec).trim()
if (!s) return { uid: null, gid: null }
if (/^\d+$/.test(s)) {
const n = Number.parseInt(s, 10)
return { uid: Number.isFinite(n) ? n : null, gid: null }
}
const u = (env.USER || env.LOGNAME || '').trim()
if (s === 'root') return { uid: 0, gid: null }
if (s === 'guest' || s === 'nobody') return { uid: 65534, gid: null }
if (u && s === u) {
const uid = Number.parseInt(String(env.UID ?? '65534'), 10)
return { uid: Number.isFinite(uid) ? uid : 65534, gid: null }
}
return { uid: null, gid: null }
}
/**
* @param {string} spec
* @param {Record<string, string>} env
* @returns {number | null}
*/
function parseGroupSpec(spec, env) {
const s = String(spec).trim()
if (!s) return null
if (/^\d+$/.test(s)) {
const n = Number.parseInt(s, 10)
return Number.isFinite(n) ? n : null
}
const g = (env.GROUP || env.USER || 'guest').trim()
if (s === 'root') return 0
if (s === 'guest' || s === 'nobody') return 65534
if (g && s === g) {
const gid = Number.parseInt(String(env.GID ?? '65534'), 10)
return Number.isFinite(gid) ? gid : 65534
}
return null
}
async function run(ctx, argv) {
let i = 1
const files = []
for (; i < argv.length; i++) {
const a = argv[i]
if (a === '-h' || a === '--help') {
ctx.console.log(
'usage: chown [-h] OWNER[:GROUP] FILE...\n' +
'OWNER/GROUP may be numeric ids or root/guest/nobody/current USER/GROUP.'
)
ctx.exitCode = 0
return
}
if (!a.startsWith('-')) break
ctx.console.error('chown: unsupported option: ' + a)
ctx.exitCode = 1
return
}
const ownSpec = argv[i++]
if (!ownSpec || i >= argv.length) {
ctx.console.error('usage: chown OWNER[:GROUP] FILE...')
ctx.exitCode = 1
return
}
for (; i < argv.length; i++) files.push(argv[i])
const env = ctx.vfs.env
const colon = ownSpec.indexOf(':')
let uid
let gid
let uname
let gname
if (colon === 0 && ownSpec.length > 1) {
const gPart = ownSpec.slice(1)
gid = parseGroupSpec(gPart, env)
gname = /^\d+$/.test(gPart.trim()) ? undefined : gPart.trim() || undefined
if (gid == null) {
ctx.console.error('chown: invalid group: ' + gPart)
ctx.exitCode = 1
return
}
for (const f of files) {
try {
if (typeof ctx.vfs.chown !== 'function') {
ctx.console.error('chown: not supported by this VFS')
ctx.exitCode = 1
return
}
const st = await ctx.vfs.stat(f)
if (!st) {
ctx.console.error('chown: ' + f + ': No such file')
ctx.exitCode = 1
continue
}
await ctx.vfs.chown(f, {
uid: st.uid,
gid,
uname: st.user,
gname
})
} catch (e) {
ctx.console.error('chown: ' + f + ': ' + (e.message || e))
ctx.exitCode = 1
}
}
return
}
if (colon >= 0) {
const uPart = ownSpec.slice(0, colon)
const gPart = ownSpec.slice(colon + 1)
const pu = parseIdSpec(uPart, env)
uid = pu.uid
uname = /^\d+$/.test(uPart.trim()) ? undefined : uPart.trim() || undefined
if (gPart === '') {
gid = Number.parseInt(String(env.GID ?? '65534'), 10)
gname = env.GROUP || env.USER
} else {
gid = parseGroupSpec(gPart, env)
gname = /^\d+$/.test(gPart.trim()) ? undefined : gPart.trim() || undefined
}
} else {
const pu = parseIdSpec(ownSpec, env)
uid = pu.uid
uname = /^\d+$/.test(ownSpec.trim()) ? undefined : ownSpec.trim() || undefined
gid = Number.parseInt(String(env.GID ?? '65534'), 10)
gname = env.GROUP || env.USER
}
if (uid == null || gid == null) {
ctx.console.error('chown: invalid owner or group: ' + ownSpec)
ctx.exitCode = 1
return
}
for (const f of files) {
try {
if (typeof ctx.vfs.chown !== 'function') {
ctx.console.error('chown: not supported by this VFS')
ctx.exitCode = 1
return
}
await ctx.vfs.chown(f, { uid, gid, uname, gname })
} catch (e) {
ctx.console.error('chown: ' + f + ': ' + (e.message || e))
ctx.exitCode = 1
}
}
}
+31 -4
View File
@@ -2,7 +2,7 @@
* curDepth is distance from the search root directory (0 at the initial path).
* Printed paths use gnu-like depth curDepth + 1 (immediate children of the root are depth 1).
*/
async function walk(ctx, dir, nameRe, wantType, maxDepth, minDepth, curDepth) {
async function walk(ctx, dir, nameRe, pathRe, wantType, maxDepth, minDepth, curDepth) {
if (maxDepth >= 0 && curDepth > maxDepth) return
let names
try {
@@ -21,13 +21,24 @@ async function walk(ctx, dir, nameRe, wantType, maxDepth, minDepth, curDepth) {
}
if (!st) continue
const gnuDepth = curDepth + 1
if (!nameRe || nameRe.test(n)) {
const pathOk = !pathRe || pathRe.test(path)
const nameOk = !nameRe || nameRe.test(n)
if (pathOk && nameOk) {
if (!wantType || st.type === wantType) {
if (gnuDepth >= minDepth) ctx.console.log(path)
}
}
if (st.type === 'directory')
await walk(ctx, path, nameRe, wantType, maxDepth, minDepth, curDepth + 1)
await walk(
ctx,
path,
nameRe,
pathRe,
wantType,
maxDepth,
minDepth,
curDepth + 1
)
}
}
@@ -37,6 +48,8 @@ async function run(ctx, argv) {
let minDepth = 1
/** @type {string | null} */
let nameGlob = null
/** @type {string | null} */
let pathGlob = null
/** @type {'file' | 'directory' | 'symlink' | null} */
let wantType = null
const rest = []
@@ -51,6 +64,10 @@ async function run(ctx, argv) {
if (!Number.isFinite(minDepth) || minDepth < 1) minDepth = 1
continue
}
if (a === '-path' && argv[i + 1]) {
pathGlob = argv[++i]
continue
}
if (a === '-name' && argv[i + 1]) {
nameGlob = argv[++i]
continue
@@ -82,6 +99,16 @@ async function run(ctx, argv) {
.replace(/\?/g, '.')
nameRe = new RegExp('^' + esc + '$')
}
let pathRe = null
if (pathGlob) {
const esc = pathGlob
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*\*/g, '\0GLOBSTAR\0')
.replace(/\*/g, '[^/]*')
.replace(/\?/g, '[^/]')
.replace(/\0GLOBSTAR\0/g, '.*')
pathRe = new RegExp('^' + esc + '$')
}
const abs = ctx.vfs.resolveLogical(root)
await walk(ctx, abs, nameRe, wantType, maxDepth, minDepth, 0)
await walk(ctx, abs, nameRe, pathRe, wantType, maxDepth, minDepth, 0)
}
+27
View File
@@ -58,6 +58,33 @@ export function setupSeedChannel(mux, localRAM, replicateDrive) {
})
return
}
if (m.module === 'bare_os' && m.method === 'health') {
chan.messages[6].send({
id: m.id,
success: true,
result: JSON.stringify({
ok: true,
protocol: PROTOCOL_NAME,
role: 'seeder'
}),
error: ''
})
return
}
if (m.module === 'bare_os' && m.method === 'kernel_info') {
chan.messages[6].send({
id: m.id,
success: true,
result: JSON.stringify({
protocol: PROTOCOL_NAME,
rpc: ['bare_os.version', 'bare_os.health', 'bare_os.kernel_info'],
note:
'Kernel and image semver are resolved at boot on the booter; this channel is seeder-local.'
}),
error: ''
})
return
}
chan.messages[6].send({
id: m.id,
success: false,
+1 -1
View File
@@ -19,7 +19,7 @@ Files in this directory are **read from disk by the seeder** (or copied into `pa
- **`share/man/man.json`** — Merged manual database for **`/bin/man`** (built by **`bare-os-coreutils`**; see [handbook ch.10](../handbook/10-manpages-and-online-help.md)).
- **`etc/os-release`** — Static OS metadata (`NAME`, `VERSION`, …).
- **`etc/motd`** — Optional message printed after **`os-release`** (distributors can customize).
- **`etc/bare-os/banner`** or **`/etc/issue`** — If present on the system drive, the default kernel prints one of these instead of the built-in session hint (unless **`BARE_OS_SKIP_REPL`** shortens the banner). Set **`BARE_OS_BOOT_TRACE=1`** or **`true`** for **`[boot] phase: Nms`** lines on stderr, or **`json`** for **`{"phase":"…","ms":n}`** per phase.
- **`etc/bare-os/banner`** or **`/etc/issue`** — If present on the system drive, the default kernel prints one of these instead of the built-in session hint (unless **`BARE_OS_SKIP_REPL`** shortens the banner). Set **`BARE_OS_BOOT_TRACE=1`** or **`true`** for **`[boot] phase: Nms`** lines on stderr, **`json`** for **`{"phase":"…","ms":n}`** per phase, or **`ndjson`** for machine-readable lines with **`sessionId`**. Recovery: **`BARE_OS_BOOT_MINIMAL`**, granular **`BARE_OS_BOOT_SKIP`**, optional **`BARE_OS_KERNEL_SELFTEST`**, readiness via **`ctx.bareOsPublishBootReady`** → **`/run/bare-os/ready`** and **`/run/bare-os/boot.json`** (see [handbook ch.6](../handbook/06-kernel-and-binaries.md)).
- **`etc/bare-os/rc`** — Optional boot snippet: one **`execLine`** per non-comment line (trusted).
- **`etc/bare-os/rc.d/`** — Optional extra snippets (basename must start with a digit), same line rules, run after **`rc`** in filename order. Human-oriented notes live in **`.README`** (a dotfile so legacy **`init.js`** never executes it).
+84 -5
View File
@@ -60,9 +60,88 @@ function barePosixBlocks(size) {
return Math.ceil(Number(size) / 512) || 0
}
async function run(ctx, argv) {
ctx.console.error(
'chgrp: changing group is not supported on Bare OS (single-user Hyperdrive metadata).'
)
ctx.exitCode = 1
/**
* chgrp — change file group (Hyperdrive metadata on writable paths).
*/
/**
* @param {string} spec
* @param {Record<string, string>} env
* @returns {number | null}
*/
function parseGroupSpec(spec, env) {
const s = String(spec).trim()
if (!s) return null
if (/^\d+$/.test(s)) {
const n = Number.parseInt(s, 10)
return Number.isFinite(n) ? n : null
}
const g = (env.GROUP || env.USER || 'guest').trim()
if (s === 'root') return 0
if (s === 'guest' || s === 'nobody') return 65534
if (g && s === g) {
const gid = Number.parseInt(String(env.GID ?? '65534'), 10)
return Number.isFinite(gid) ? gid : 65534
}
return null
}
async function run(ctx, argv) {
let i = 1
const files = []
for (; i < argv.length; i++) {
const a = argv[i]
if (a === '-h' || a === '--help') {
ctx.console.log(
'usage: chgrp [-h] GROUP FILE...\n' +
'GROUP may be a numeric gid or root/guest/nobody/current GROUP name.'
)
ctx.exitCode = 0
return
}
if (!a.startsWith('-')) break
ctx.console.error('chgrp: unsupported option: ' + a)
ctx.exitCode = 1
return
}
const groupSpec = argv[i++]
if (!groupSpec || i >= argv.length) {
ctx.console.error('usage: chgrp GROUP FILE...')
ctx.exitCode = 1
return
}
for (; i < argv.length; i++) files.push(argv[i])
const gid = parseGroupSpec(groupSpec, ctx.vfs.env)
if (gid == null) {
ctx.console.error('chgrp: invalid group: ' + groupSpec)
ctx.exitCode = 1
return
}
const gname = /^\d+$/.test(groupSpec.trim())
? undefined
: groupSpec.trim() || undefined
for (const f of files) {
try {
const st = await ctx.vfs.stat(f)
if (!st) {
ctx.console.error('chgrp: ' + f + ': No such file')
ctx.exitCode = 1
continue
}
if (typeof ctx.vfs.chown !== 'function') {
ctx.console.error('chgrp: not supported by this VFS')
ctx.exitCode = 1
return
}
await ctx.vfs.chown(f, {
uid: st.uid,
gid,
uname: st.user,
gname
})
} catch (e) {
ctx.console.error('chgrp: ' + f + ': ' + (e.message || e))
ctx.exitCode = 1
}
}
}
+152 -5
View File
@@ -60,9 +60,156 @@ function barePosixBlocks(size) {
return Math.ceil(Number(size) / 512) || 0
}
async function run(ctx, argv) {
ctx.console.error(
'chown: changing file ownership is not supported on Bare OS (single-user Hyperdrive metadata).'
)
ctx.exitCode = 1
/**
* chown — change file owner and group (Hyperdrive metadata on writable paths).
*/
/**
* @param {string} spec
* @param {Record<string, string>} env
* @returns {{ uid: number | null, gid: number | null }}
*/
function parseIdSpec(spec, env) {
const s = String(spec).trim()
if (!s) return { uid: null, gid: null }
if (/^\d+$/.test(s)) {
const n = Number.parseInt(s, 10)
return { uid: Number.isFinite(n) ? n : null, gid: null }
}
const u = (env.USER || env.LOGNAME || '').trim()
if (s === 'root') return { uid: 0, gid: null }
if (s === 'guest' || s === 'nobody') return { uid: 65534, gid: null }
if (u && s === u) {
const uid = Number.parseInt(String(env.UID ?? '65534'), 10)
return { uid: Number.isFinite(uid) ? uid : 65534, gid: null }
}
return { uid: null, gid: null }
}
/**
* @param {string} spec
* @param {Record<string, string>} env
* @returns {number | null}
*/
function parseGroupSpec(spec, env) {
const s = String(spec).trim()
if (!s) return null
if (/^\d+$/.test(s)) {
const n = Number.parseInt(s, 10)
return Number.isFinite(n) ? n : null
}
const g = (env.GROUP || env.USER || 'guest').trim()
if (s === 'root') return 0
if (s === 'guest' || s === 'nobody') return 65534
if (g && s === g) {
const gid = Number.parseInt(String(env.GID ?? '65534'), 10)
return Number.isFinite(gid) ? gid : 65534
}
return null
}
async function run(ctx, argv) {
let i = 1
const files = []
for (; i < argv.length; i++) {
const a = argv[i]
if (a === '-h' || a === '--help') {
ctx.console.log(
'usage: chown [-h] OWNER[:GROUP] FILE...\n' +
'OWNER/GROUP may be numeric ids or root/guest/nobody/current USER/GROUP.'
)
ctx.exitCode = 0
return
}
if (!a.startsWith('-')) break
ctx.console.error('chown: unsupported option: ' + a)
ctx.exitCode = 1
return
}
const ownSpec = argv[i++]
if (!ownSpec || i >= argv.length) {
ctx.console.error('usage: chown OWNER[:GROUP] FILE...')
ctx.exitCode = 1
return
}
for (; i < argv.length; i++) files.push(argv[i])
const env = ctx.vfs.env
const colon = ownSpec.indexOf(':')
let uid
let gid
let uname
let gname
if (colon === 0 && ownSpec.length > 1) {
const gPart = ownSpec.slice(1)
gid = parseGroupSpec(gPart, env)
gname = /^\d+$/.test(gPart.trim()) ? undefined : gPart.trim() || undefined
if (gid == null) {
ctx.console.error('chown: invalid group: ' + gPart)
ctx.exitCode = 1
return
}
for (const f of files) {
try {
if (typeof ctx.vfs.chown !== 'function') {
ctx.console.error('chown: not supported by this VFS')
ctx.exitCode = 1
return
}
const st = await ctx.vfs.stat(f)
if (!st) {
ctx.console.error('chown: ' + f + ': No such file')
ctx.exitCode = 1
continue
}
await ctx.vfs.chown(f, {
uid: st.uid,
gid,
uname: st.user,
gname
})
} catch (e) {
ctx.console.error('chown: ' + f + ': ' + (e.message || e))
ctx.exitCode = 1
}
}
return
}
if (colon >= 0) {
const uPart = ownSpec.slice(0, colon)
const gPart = ownSpec.slice(colon + 1)
const pu = parseIdSpec(uPart, env)
uid = pu.uid
uname = /^\d+$/.test(uPart.trim()) ? undefined : uPart.trim() || undefined
if (gPart === '') {
gid = Number.parseInt(String(env.GID ?? '65534'), 10)
gname = env.GROUP || env.USER
} else {
gid = parseGroupSpec(gPart, env)
gname = /^\d+$/.test(gPart.trim()) ? undefined : gPart.trim() || undefined
}
} else {
const pu = parseIdSpec(ownSpec, env)
uid = pu.uid
uname = /^\d+$/.test(ownSpec.trim()) ? undefined : ownSpec.trim() || undefined
gid = Number.parseInt(String(env.GID ?? '65534'), 10)
gname = env.GROUP || env.USER
}
if (uid == null || gid == null) {
ctx.console.error('chown: invalid owner or group: ' + ownSpec)
ctx.exitCode = 1
return
}
for (const f of files) {
try {
if (typeof ctx.vfs.chown !== 'function') {
ctx.console.error('chown: not supported by this VFS')
ctx.exitCode = 1
return
}
await ctx.vfs.chown(f, { uid, gid, uname, gname })
} catch (e) {
ctx.console.error('chown: ' + f + ': ' + (e.message || e))
ctx.exitCode = 1
}
}
}
+31 -4
View File
@@ -64,7 +64,7 @@ function barePosixBlocks(size) {
* curDepth is distance from the search root directory (0 at the initial path).
* Printed paths use gnu-like depth curDepth + 1 (immediate children of the root are depth 1).
*/
async function walk(ctx, dir, nameRe, wantType, maxDepth, minDepth, curDepth) {
async function walk(ctx, dir, nameRe, pathRe, wantType, maxDepth, minDepth, curDepth) {
if (maxDepth >= 0 && curDepth > maxDepth) return
let names
try {
@@ -83,13 +83,24 @@ async function walk(ctx, dir, nameRe, wantType, maxDepth, minDepth, curDepth) {
}
if (!st) continue
const gnuDepth = curDepth + 1
if (!nameRe || nameRe.test(n)) {
const pathOk = !pathRe || pathRe.test(path)
const nameOk = !nameRe || nameRe.test(n)
if (pathOk && nameOk) {
if (!wantType || st.type === wantType) {
if (gnuDepth >= minDepth) ctx.console.log(path)
}
}
if (st.type === 'directory')
await walk(ctx, path, nameRe, wantType, maxDepth, minDepth, curDepth + 1)
await walk(
ctx,
path,
nameRe,
pathRe,
wantType,
maxDepth,
minDepth,
curDepth + 1
)
}
}
@@ -99,6 +110,8 @@ async function run(ctx, argv) {
let minDepth = 1
/** @type {string | null} */
let nameGlob = null
/** @type {string | null} */
let pathGlob = null
/** @type {'file' | 'directory' | 'symlink' | null} */
let wantType = null
const rest = []
@@ -113,6 +126,10 @@ async function run(ctx, argv) {
if (!Number.isFinite(minDepth) || minDepth < 1) minDepth = 1
continue
}
if (a === '-path' && argv[i + 1]) {
pathGlob = argv[++i]
continue
}
if (a === '-name' && argv[i + 1]) {
nameGlob = argv[++i]
continue
@@ -144,6 +161,16 @@ async function run(ctx, argv) {
.replace(/\?/g, '.')
nameRe = new RegExp('^' + esc + '$')
}
let pathRe = null
if (pathGlob) {
const esc = pathGlob
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*\*/g, '\0GLOBSTAR\0')
.replace(/\*/g, '[^/]*')
.replace(/\?/g, '[^/]')
.replace(/\0GLOBSTAR\0/g, '.*')
pathRe = new RegExp('^' + esc + '$')
}
const abs = ctx.vfs.resolveLogical(root)
await walk(ctx, abs, nameRe, wantType, maxDepth, minDepth, 0)
await walk(ctx, abs, nameRe, pathRe, wantType, maxDepth, minDepth, 0)
}
+165 -13
View File
@@ -15,6 +15,13 @@
* in file order (trusted). Then readLine yields EOF.
*
* BARE_OS_BOOT_TRACE=json logs one JSON object per phase on stderr: {"phase":"…","ms":n}.
* BARE_OS_BOOT_TRACE=ndjson logs {"type":"boot","phase","ms","sessionId","ts"} per line.
*
* BARE_OS_BOOT_MINIMAL=1 or true: skip profile rc, rc, rc.d, rc.local, kernel.d (recovery shell).
* BARE_OS_BOOT_SKIP: comma-separated phases to skip among:
* profile, rc, rc.d, rc.local, kernel.d, onboot (os-release and motd always run).
*
* BARE_OS_KERNEL_SELFTEST=1 or true: after boot snippets, run a short trusted self-check via execLine.
*
* BARE_OS_BOOT_STRICT=1 or true: first execLine throw in trusted boot snippets calls
* requestBooterExit(1) and stops further boot phases.
@@ -30,7 +37,12 @@
*/
function wantBootTrace(ctx) {
const v = ctx.env && ctx.env.BARE_OS_BOOT_TRACE
return v === '1' || v === 'true' || v === 'json'
return (
v === '1' ||
v === 'true' ||
v === 'json' ||
v === 'ndjson'
)
}
/**
@@ -40,6 +52,56 @@ function isBootTraceJson(ctx) {
return ctx.env && ctx.env.BARE_OS_BOOT_TRACE === 'json'
}
/**
* @param {Record<string, unknown>} ctx
*/
function isBootTraceNdjson(ctx) {
return ctx.env && ctx.env.BARE_OS_BOOT_TRACE === 'ndjson'
}
/**
* @param {Record<string, unknown>} ctx
*/
function bootMinimal(ctx) {
const v = ctx.env && ctx.env.BARE_OS_BOOT_MINIMAL
return v === '1' || v === 'true'
}
/**
* @param {Record<string, unknown>} ctx
* @returns {Set<string>}
*/
function parseBootSkip(ctx) {
const raw = ctx.env && ctx.env.BARE_OS_BOOT_SKIP
const out = new Set()
if (raw == null || !String(raw).trim()) return out
for (const p of String(raw)
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean)) {
out.add(p)
}
return out
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} phase
*/
function shouldSkipBootPhase(ctx, phase) {
if (bootMinimal(ctx)) {
return (
phase === 'profile' ||
phase === 'rc' ||
phase === 'rc.d' ||
phase === 'rc.local' ||
phase === 'kernel.d' ||
phase === 'onboot'
)
}
return parseBootSkip(ctx).has(phase.toLowerCase())
}
/**
* @param {Record<string, unknown>} ctx
*/
@@ -80,13 +142,26 @@ function shouldSkipRcDName(name, patterns) {
* @param {Record<string, unknown>} ctx
* @param {string} label
* @param {() => void | Promise<void>} fn
* @param {string[]} phaseLog
*/
async function bootTimed(ctx, label, fn) {
async function bootTimed(ctx, label, fn, phaseLog) {
const t0 = Date.now()
await fn()
if (wantBootTrace(ctx)) {
const ms = Date.now() - t0
if (isBootTraceJson(ctx)) {
phaseLog.push(label)
if (wantBootTrace(ctx)) {
if (isBootTraceNdjson(ctx)) {
const sid =
(ctx.env && ctx.env.BARE_OS_SESSION_ID) || ''
const rec = {
type: 'boot',
phase: label,
ms,
sessionId: sid,
ts: Date.now()
}
ctx.console.error(JSON.stringify(rec))
} else if (isBootTraceJson(ctx)) {
ctx.console.error(JSON.stringify({ phase: label, ms }))
} else {
ctx.console.error(`[boot] ${label}: ${ms}ms`)
@@ -348,7 +423,7 @@ async function printSessionBanner(ctx) {
}
}
const defaultBanner =
'Bare operating system — guest session (login [--new] <passphrase> to unlock) | shell: cd, export, if/fi, && || ;, |, exit | services: systemctl list-units, journalctl -u UNIT | try: help, ls /bin, crontab -l'
'Bare operating system — guest session (login [--new] <passphrase> to unlock) | shell: cd, export, if/fi, && || ;, |, &, jobs/fg, <<<, exit | services: systemctl list-units, journalctl -u UNIT | try: help, ls /bin, crontab -l'
if (ctx.bareOsSkipRepl) {
console.log(
'Bare operating system — non-interactive session (BARE_OS_SKIP_REPL).'
@@ -358,38 +433,115 @@ async function printSessionBanner(ctx) {
console.log(defaultBanner)
}
/**
* @param {Record<string, unknown>} ctx
* @returns {Promise<boolean>}
*/
async function runKernelSelftest(ctx) {
const v = ctx.env && ctx.env.BARE_OS_KERNEL_SELFTEST
if (v !== '1' && v !== 'true') return true
const { execLine, console } = ctx
const strict = bootStrict(ctx)
const lines = [
':',
'true',
'false; echo selftest_false_ok',
'systemctl list-units 2>/dev/null || true'
]
for (const line of lines) {
try {
await execLine(line)
} catch (e) {
console.error('selftest: ' + ((e && e.message) || String(e)))
if (strict) {
if (typeof ctx.requestBooterExit === 'function') ctx.requestBooterExit(1)
return false
}
}
}
return true
}
/**
* @param {Record<string, unknown>} ctx
* @param {string[]} phaseLog
*/
function publishBootReady(ctx, phaseLog) {
if (typeof ctx.bareOsPublishBootReady !== 'function') return
const sid = (ctx.env && ctx.env.BARE_OS_SESSION_ID) || ''
ctx.bareOsPublishBootReady({
ready: true,
phases: [...phaseLog],
sessionId: sid,
completedAtMs: Date.now(),
minimal: bootMinimal(ctx)
})
}
async function start(ctx) {
const { readLine, execLine, console } = ctx
await bootTimed(ctx, 'os-release', () => printOsRelease(ctx))
await bootTimed(ctx, 'motd', () => printMotd(ctx))
/** @type {string[]} */
const phaseLog = []
await bootTimed(ctx, 'os-release', () => printOsRelease(ctx), phaseLog)
await bootTimed(ctx, 'motd', () => printMotd(ctx), phaseLog)
const profileName = await resolveBootProfileName(ctx)
/** @type {boolean} */
let bootOk = true
if (!shouldSkipBootPhase(ctx, 'profile')) {
await bootTimed(ctx, 'rc.profile', async () => {
bootOk = await runProfileRc(ctx, profileName)
})
}, phaseLog)
} else {
phaseLog.push('rc.profile(skipped)')
}
if (!bootOk) return
if (!shouldSkipBootPhase(ctx, 'rc')) {
await bootTimed(ctx, 'rc', async () => {
bootOk = await runRcFileAt(ctx, '/etc/bare-os/rc', 'rc')
})
}, phaseLog)
} else {
phaseLog.push('rc(skipped)')
}
if (!bootOk) return
if (!shouldSkipBootPhase(ctx, 'rc.d')) {
await bootTimed(ctx, 'rc.d', async () => {
bootOk = await runBareOsRcDir(ctx)
})
}, phaseLog)
} else {
phaseLog.push('rc.d(skipped)')
}
if (!bootOk) return
if (!shouldSkipBootPhase(ctx, 'rc.local')) {
await bootTimed(ctx, 'rc.local', async () => {
bootOk = await runRcFileAt(ctx, '/etc/bare-os/rc.local', 'rc.local')
})
}, phaseLog)
} else {
phaseLog.push('rc.local(skipped)')
}
if (!bootOk) return
if (!shouldSkipBootPhase(ctx, 'kernel.d')) {
await bootTimed(ctx, 'kernel.d', async () => {
bootOk = await runBareOsKernelDir(ctx)
})
}, phaseLog)
} else {
phaseLog.push('kernel.d(skipped)')
}
if (!bootOk) return
await printSessionBanner(ctx)
phaseLog.push('banner')
if (!shouldSkipBootPhase(ctx, 'onboot')) {
await bootTimed(ctx, 'onboot', async () => {
bootOk = await runOnboot(ctx)
})
}, phaseLog)
} else {
phaseLog.push('onboot(skipped)')
}
if (!bootOk) return
await bootTimed(ctx, 'selftest', async () => {
bootOk = await runKernelSelftest(ctx)
}, phaseLog)
if (!bootOk) return
publishBootReady(ctx, phaseLog)
while (true) {
const line = await readLine('')
if (line == null) break
File diff suppressed because one or more lines are too long