fixes
This commit is contained in:
@@ -12,7 +12,7 @@ BareCloud is a **free, self-service dashboard** for launching Bare OS (Pear) boo
|
||||
1. **Linux** host (bind-mount for per-instance Corestores into the shared Pear chroot).
|
||||
2. **`tmux`** on `PATH` (**required** for `POST /api/booters` / headless Pear) — resumable browser consoles and background Pear use the same detached session model.
|
||||
3. **Node.js 20+** and npm.
|
||||
4. **Pear** installed under `/opt/pear-home` (or set `PEAR_HOME` when building the jail) — see `scripts/host/install-host-stack.sh` in this repo’s parent tree or install Pear per [Pear Getting Started](https://docs.pears.com/guide/getting-started.html).
|
||||
4. **Pear** installed (BareCloud uses `/root/.nvm/versions/node/v20.20.2/bin/pear` for all instances). **Bootstrap shared Pear state once** on the host using the same `PEAR_HOME` BareCloud will use (default `/opt/pear-home`, or `BARECLOUD_PEAR_HOME` / `PEAR_HOME`), e.g. `PEAR_HOME=/opt/pear-home pear versions --json` — see `scripts/host/install-host-stack.sh` in this repo’s parent tree or [Pear Getting Started](https://docs.pears.com/guide/getting-started.html).
|
||||
5. **Chroot jail** built at `/var/lib/barecloud/pear-jail` (default) or override with `BARECLOUD_CHROOT_JAIL`:
|
||||
```bash
|
||||
sudo bash scripts/host/build-pear-jail.sh
|
||||
@@ -30,7 +30,7 @@ npm run dev
|
||||
- **API + terminal WebSocket:** [http://127.0.0.1:3000](http://127.0.0.1:3000) (default `PORT`).
|
||||
- **UI:** [http://127.0.0.1:5173](http://127.0.0.1:5173) — Vite dev server proxies `/api` (including WebSocket upgrades) to port 3000.
|
||||
|
||||
On non-Linux dev machines, terminal attach may require `BARECLOUD_ALLOW_NONLINUX_JAIL=1` (Corestore bind-mount is skipped — **not** for production).
|
||||
On non-Linux dev machines, terminal behavior is best-effort and not production-supported.
|
||||
|
||||
## Environment variables
|
||||
|
||||
@@ -44,9 +44,9 @@ Copy `[.env.example](.env.example)` to `.env`. BareCloud reads `**BARECLOUD_*`**
|
||||
| `BARECLOUD_BASE_PATH` | URL prefix for API + static app (same as `NEXT_PUBLIC_BASE_PATH` for builds). |
|
||||
| `BARECLOUD_CORESTORE_ROOT` | Host root for instance dirs (default `/user-data/corestores`). |
|
||||
| `BARECLOUD_PEAR_BOOT_LINK` | `pear://…` for `pear run --no-ask`. |
|
||||
| `BARECLOUD_PEAR_HOME`, `BARECLOUD_PEAR_BIN` | Shared Pear install / CLI path (defaults `/opt/pear-home`, `pear` on `PATH`). |
|
||||
| `BARECLOUD_SESSION_HOME_LAYOUT` | Per-booter `HOME` under `<id>/session-home` (default **on** except Windows). `0` = legacy layout with `BARE_OS_HOST_DATA` only. |
|
||||
| `BARECLOUD_PEAR_PATH_SESSION_BIN` | Per-instance Pear bin precedence for `<id>/session-home/.config/pear/bin`. Default **on**; set `0` to force shared/global Pear resolution. |
|
||||
| `BARECLOUD_PEAR_HOME` | Shared **Pear state** (`PEAR_HOME`): precedence `BARECLOUD_PEAR_HOME` → host `PEAR_HOME` → `/opt/pear-home`. Bootstrap once on the host (e.g. `PEAR_HOME=/opt/pear-home pear versions --json`) so new booters do not repeat full Pear install. CLI runtime is fixed to `/root/.nvm/versions/node/v20.20.2/bin/pear` for all instances. |
|
||||
| `BARECLOUD_SESSION_HOME_LAYOUT` | Default **on** (Linux): per-booter `HOME` under `<id>/session-home` (Bare OS under `~/.bare-os`); `PEAR_HOME` stays **shared** as above. `0` = legacy: one shared `HOME`/`PEAR_HOME` and `BARE_OS_HOST_DATA` per booter. |
|
||||
| `BARECLOUD_PEAR_PATH_SESSION_BIN` | Per-instance Pear bin precedence for `<id>/session-home/.config/pear/bin`. Default **off** (all booters use global Pear runtime); set `1` to enable session-bin precedence. |
|
||||
| `BARECLOUD_USE_PROOT`, `BARECLOUD_PROOT_BIN`, `BARECLOUD_PROOT_ARGS` | Linux opt-in: wrap Pear in `proot` (fragile with RocksDB). |
|
||||
| `BARECLOUD_USE_NETNS` | Linux: run each Pear in its **own network namespace** (default **on**) — separate `127.0.0.1` and ports; set `0` to disable. Needs `ip`/`iptables` + usually **root**. |
|
||||
| `BARECLOUD_NETNS_OUT_IFACE`, `BARECLOUD_NETNS_DISABLE_NAT`, `BARECLOUD_NETNS_DNS` | Optional: outbound iface for MASQUERADE (default: auto-detect route); set `BARECLOUD_NETNS_DISABLE_NAT=1` for loopback-only. DNS inside netns uses `BARECLOUD_NETNS_DNS` (comma/space-separated), otherwise non-loopback nameservers from host `/etc/resolv.conf`, fallback `1.1.1.1,9.9.9.9`. |
|
||||
@@ -72,6 +72,7 @@ Legacy references to `**BARECLOUD_CHROOT_JAIL`** / Docker-only booters apply onl
|
||||
|
||||
- SQLite database: `data/barecloud.db` (override with `BARECLOUD_DATA_DIR`).
|
||||
- Per-booter Corestore: `<BARECLOUD_CORESTORE_ROOT>/<uuid>/`.
|
||||
- With session-home layout (default on Linux), **`PEAR_HOME`** is **shared** across booters (see `BARECLOUD_PEAR_HOME`); **`HOME`** is `<uuid>/session-home` so Bare OS data under `~/.bare-os` stays per instance. **Many concurrent booters** therefore share one Pear runtime tree — warm it once on the host so launches do not repeat full Pear install. If you see updater or cache contention, tune **`BARE_OS_PEAR_UPDATER_*`** (forwarded into Pear from the host; see `lib/pm2-pear.ts`).
|
||||
- Each instance stores **`pear_boot_link`** (the `pear://…` tmux/terminal use). At launch: optional JSON body on `POST /api/booters` — `{ "pearBootLink": "pear://…" }` — then `BARECLOUD_PEAR_BOOT_LINK`, then the demo default. Use a link your Pear peers already know, or you will see **key is not known** / TRUST until the runtime learns it. `GET /api/config` exposes `pearBootLinkDefault` (effective host default).
|
||||
- **Expired instances** (`expires_at` in the past) are purged on an **hourly** schedule (`lib/cron.ts`, started from `server.ts`) and once at **process startup** (`purgeInactiveBooters` in `lib/booter-manager.ts`). Purge runs **`removeBooterStack`**: kills the Pear tmux session for that id, tears down netns as applicable, deletes the Corestore directory (unless `BARECLOUD_DELETE_CORESTORE_ON_REMOVE=0`), and removes the SQLite row.
|
||||
- New instances get **`expires_at = now + 7 days`**. **“Extend seven more days”** sets **`expires_at = now + 7 days`** from the moment you click (and updates `last_accessed`). Opening the console or polling status **does not** extend the deadline.
|
||||
@@ -79,6 +80,7 @@ Legacy references to `**BARECLOUD_CHROOT_JAIL`** / Docker-only booters apply onl
|
||||
## Security notes
|
||||
|
||||
- There are **no accounts**. Anyone with an instance UUID can hit the APIs unless you layer authentication at the edge.
|
||||
- **Shared `PEAR_HOME`:** Pear-level cache and config under the shared home are **not** isolated per instance (unlike each booter’s `session-home` / Corestore). Do not treat that tree as a per-user secret store.
|
||||
- **GET `/api/booters`** only returns rows when you pass known `?ids=` — it never dumps every row.
|
||||
- Run BareCloud with sufficient privilege to `**mount --bind**` Corestores into the jail (typically root).
|
||||
- **`BARECLOUD_USE_NETNS` (default on Linux):** creating namespaces, veth pairs, and NAT rules requires **root** or capabilities (`CAP_NET_ADMIN`, `CAP_NET_RAW`, and iptables/nft compatibility). Set `BARECLOUD_USE_NETNS=0` to disable netns; otherwise Pear start will fail if privileges are missing.
|
||||
|
||||
@@ -11,6 +11,10 @@ type Props = {
|
||||
booterId: string;
|
||||
};
|
||||
|
||||
type TerminalControlMessage =
|
||||
| { barecloudTerminalControl: 1; type: "flow_status"; status: string }
|
||||
| { barecloudTerminalControl: 1; type: string };
|
||||
|
||||
function appBasePath(): string {
|
||||
return (import.meta.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/$/, "");
|
||||
}
|
||||
@@ -149,6 +153,12 @@ function shouldAutoRestartAfterPearCleanExit(buf: string): boolean {
|
||||
return t.includes("reconnect the console") || t.includes("supervised instance");
|
||||
}
|
||||
|
||||
/** Treat any terminal `[exited]` marker as "nothing to attach to" and trigger the 3s restart flow. */
|
||||
function shouldAutoRestartAfterExitedAttach(buf: string): boolean {
|
||||
const t = stripAnsiForMatch(buf).toLowerCase();
|
||||
return t.includes("[exited]");
|
||||
}
|
||||
|
||||
/** Shown when the console keeps dropping — usually network, browser sleep, or a proxy in front of the site. */
|
||||
function terminalConnectionHelp(): string {
|
||||
return [
|
||||
@@ -164,6 +174,7 @@ export function Terminal({ booterId }: Props) {
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const [status, setStatus] = useState<"connecting" | "open" | "error">("connecting");
|
||||
const [errorDetail, setErrorDetail] = useState<string | null>(null);
|
||||
const [flowStatus, setFlowStatus] = useState<string | null>(null);
|
||||
const [kernelRebootBanner, setKernelRebootBanner] = useState<{
|
||||
seconds: number;
|
||||
detail: string;
|
||||
@@ -287,22 +298,25 @@ export function Terminal({ booterId }: Props) {
|
||||
|
||||
let resizePostTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastResizePosted = { cols: 0, rows: 0 };
|
||||
const postResize = async (cols: number, rows: number) => {
|
||||
if (cols < 2 || rows < 1) return;
|
||||
if (cols === lastResizePosted.cols && rows === lastResizePosted.rows) return;
|
||||
lastResizePosted = { cols, rows };
|
||||
try {
|
||||
await fetch(`${basePath}/api/terminal/${encodeURIComponent(booterId)}/resize`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cols, rows }),
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
const scheduleResizeNotify = () => {
|
||||
if (resizePostTimer) clearTimeout(resizePostTimer);
|
||||
resizePostTimer = setTimeout(() => {
|
||||
resizePostTimer = null;
|
||||
const cols = term.cols;
|
||||
const rows = term.rows;
|
||||
if (cols < 2 || rows < 1) return;
|
||||
if (cols === lastResizePosted.cols && rows === lastResizePosted.rows) return;
|
||||
lastResizePosted = { cols, rows };
|
||||
void fetch(`${basePath}/api/terminal/${encodeURIComponent(booterId)}/resize`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cols, rows }),
|
||||
}).catch(() => {
|
||||
/* ignore */
|
||||
});
|
||||
void postResize(term.cols, term.rows);
|
||||
}, 120);
|
||||
};
|
||||
|
||||
@@ -342,6 +356,7 @@ export function Terminal({ booterId }: Props) {
|
||||
const outputMatchBufRef = { current: "" };
|
||||
const kernelRebootOfferedRef = { current: false };
|
||||
const pearCleanExitRestartScheduledRef = { current: false };
|
||||
const sawExitedAttachRef = { current: false };
|
||||
let kernelRebootInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let postPearRestartEstablishTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -382,7 +397,7 @@ export function Terminal({ booterId }: Props) {
|
||||
/* After the first successful session, stay on "open" so the xterm is not covered by a blur overlay. */
|
||||
if (!welcomeShown) setStatus("connecting");
|
||||
const sec = Math.max(1, Math.ceil(afterMs / 1000));
|
||||
term.writeln(`\x1b[33m[BareCloud]\x1b[0m ${note} Retrying in ${sec}s… (attempt ${attempt}/${maxAttempts})`);
|
||||
setFlowStatus(`${note} Retrying in ${sec}s… (${attempt}/${maxAttempts})`);
|
||||
reconnectTimer = setTimeout(() => establishWs(), afterMs);
|
||||
};
|
||||
|
||||
@@ -394,6 +409,7 @@ export function Terminal({ booterId }: Props) {
|
||||
setKernelRebootBanner(null);
|
||||
attempt = 0;
|
||||
clearReconnect();
|
||||
setFlowStatus(null);
|
||||
establishWs();
|
||||
};
|
||||
kernelReconnectRef.current = performKernelReconnect;
|
||||
@@ -428,10 +444,8 @@ export function Terminal({ booterId }: Props) {
|
||||
outputMatchBufRef.current = "";
|
||||
clearReconnect();
|
||||
term.clear();
|
||||
term.writeln(
|
||||
"\x1b[36m[BareCloud]\x1b[0m Pear exited cleanly with nothing to attach to. The console will request a fresh Pear session in 3 seconds.",
|
||||
);
|
||||
term.writeln("");
|
||||
setFlowStatus("Reconnecting terminal…");
|
||||
setStatus("connecting");
|
||||
setTimeout(async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
@@ -444,20 +458,30 @@ export function Terminal({ booterId }: Props) {
|
||||
if (!res.ok) {
|
||||
throw new Error(j.error ?? "Restart failed");
|
||||
}
|
||||
term.writeln("\x1b[36m[BareCloud]\x1b[0m Restart requested — reconnecting…");
|
||||
term.writeln("");
|
||||
scheduleEstablishAfterPearRestart();
|
||||
pearCleanExitRestartScheduledRef.current = false;
|
||||
} catch (e) {
|
||||
pearCleanExitRestartScheduledRef.current = false;
|
||||
term.writeln(
|
||||
`\x1b[31m[BareCloud]\x1b[0m ${e instanceof Error ? e.message : "Restart failed"}. Refresh the page or use Restart instance in the sidebar.`,
|
||||
setFlowStatus(null);
|
||||
setErrorDetail(
|
||||
`${e instanceof Error ? e.message : "Restart failed"}. Refresh the page or use Restart instance in the sidebar.`,
|
||||
);
|
||||
term.writeln("");
|
||||
setStatus("error");
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const parseTerminalControl = (text: string): TerminalControlMessage | null => {
|
||||
if (!text.startsWith("{")) return null;
|
||||
try {
|
||||
const msg = JSON.parse(text) as TerminalControlMessage;
|
||||
if ((msg as { barecloudTerminalControl?: number }).barecloudTerminalControl !== 1) return null;
|
||||
return msg;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const establishWs = () => {
|
||||
if (cancelled) return;
|
||||
clearReconnect();
|
||||
@@ -495,12 +519,9 @@ export function Terminal({ booterId }: Props) {
|
||||
wasOpenRef.current = true;
|
||||
setStatus("open");
|
||||
setErrorDetail(null);
|
||||
setFlowStatus(null);
|
||||
if (!welcomeShown) {
|
||||
welcomeShown = true;
|
||||
term.writeln("\x1b[36m[BareCloud]\x1b[0m Connected — your Bare OS console is ready.");
|
||||
term.writeln("");
|
||||
} else {
|
||||
term.writeln("\x1b[36m[BareCloud]\x1b[0m Reconnected.");
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
runFit();
|
||||
@@ -580,6 +601,9 @@ export function Terminal({ booterId }: Props) {
|
||||
return;
|
||||
}
|
||||
if (wasOpen && ev.code === 1000) {
|
||||
if (!pearCleanExitRestartScheduledRef.current && sawExitedAttachRef.current) {
|
||||
schedulePearCleanExitRestart();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (wasOpen && ev.code !== 1000) {
|
||||
@@ -601,12 +625,24 @@ export function Terminal({ booterId }: Props) {
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
const d = ev.data;
|
||||
if (typeof d === "string") {
|
||||
const control = parseTerminalControl(d);
|
||||
if (control?.type === "flow_status") {
|
||||
setFlowStatus(control.status.trim() || null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const chunk = typeof d === "string" ? d : new TextDecoder().decode(d as ArrayBuffer);
|
||||
if (typeof d === "string") term.write(d);
|
||||
else term.write(new Uint8Array(d as ArrayBuffer));
|
||||
setFlowStatus((prev) => (prev ? null : prev));
|
||||
|
||||
const prev = outputMatchBufRef.current;
|
||||
outputMatchBufRef.current = (prev + chunk).slice(-OUTPUT_MATCH_BUF_MAX);
|
||||
const normalized = stripAnsiForMatch(outputMatchBufRef.current).toLowerCase();
|
||||
if (normalized.includes("[exited]")) {
|
||||
sawExitedAttachRef.current = true;
|
||||
}
|
||||
if (
|
||||
!kernelRebootOfferedRef.current &&
|
||||
shouldOfferPearFailureReboot(outputMatchBufRef.current)
|
||||
@@ -619,6 +655,12 @@ export function Terminal({ booterId }: Props) {
|
||||
shouldAutoRestartAfterPearCleanExit(outputMatchBufRef.current)
|
||||
) {
|
||||
schedulePearCleanExitRestart();
|
||||
} else if (
|
||||
!kernelRebootOfferedRef.current &&
|
||||
!pearCleanExitRestartScheduledRef.current &&
|
||||
shouldAutoRestartAfterExitedAttach(outputMatchBufRef.current)
|
||||
) {
|
||||
schedulePearCleanExitRestart();
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -636,7 +678,14 @@ export function Terminal({ booterId }: Props) {
|
||||
barecloudDebug("Terminal: multiplex mode enabled, using legacy terminal stream fallback", { booterId });
|
||||
}
|
||||
|
||||
establishWs();
|
||||
void (async () => {
|
||||
await new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
);
|
||||
runFit();
|
||||
await postResize(term.cols, term.rows);
|
||||
if (!cancelled) establishWs();
|
||||
})();
|
||||
|
||||
const d = term.onData((payload) => {
|
||||
const s = activeWsRef.current;
|
||||
@@ -669,6 +718,7 @@ export function Terminal({ booterId }: Props) {
|
||||
clearKernelRebootTimers();
|
||||
kernelRebootOfferedRef.current = false;
|
||||
pearCleanExitRestartScheduledRef.current = false;
|
||||
sawExitedAttachRef.current = false;
|
||||
if (postPearRestartEstablishTimer) {
|
||||
clearTimeout(postPearRestartEstablishTimer);
|
||||
postPearRestartEstablishTimer = null;
|
||||
@@ -743,7 +793,7 @@ export function Terminal({ booterId }: Props) {
|
||||
{status === "connecting" && (
|
||||
<div className="absolute left-0 right-0 top-0 z-10 flex items-center justify-center gap-2 border-b border-white/10 bg-zinc-950/95 py-2.5 text-sm text-white/90 shadow-md">
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
|
||||
Connecting to console…
|
||||
{flowStatus ?? "Connecting to console…"}
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
|
||||
@@ -85,6 +85,10 @@ export function BooterPage() {
|
||||
const [clearDialogOpen, setClearDialogOpen] = useState(false);
|
||||
/** Increment after restart / storage clear so `<Terminal>` remounts with a fresh session. */
|
||||
const [terminalResetKey, setTerminalResetKey] = useState(0);
|
||||
const [consoleFlowStatus, setConsoleFlowStatus] = useState<string | null>(null);
|
||||
const handleFlowStatusChange = useCallback((next: string | null) => {
|
||||
setConsoleFlowStatus((prev) => (prev === next ? prev : next));
|
||||
}, []);
|
||||
const consoleHostRef = useRef<HTMLElement>(null);
|
||||
const [consoleFullscreen, setConsoleFullscreen] = useState(false);
|
||||
|
||||
@@ -272,7 +276,11 @@ export function BooterPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative min-h-0 min-w-0 flex-1 overflow-visible">
|
||||
<Terminal key={`${id}-${terminalResetKey}`} booterId={id} />
|
||||
<Terminal
|
||||
key={`${id}-${terminalResetKey}`}
|
||||
booterId={id}
|
||||
onFlowStatusChange={handleFlowStatusChange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -303,7 +311,9 @@ export function BooterPage() {
|
||||
<Activity className="mt-0.5 h-4 w-4 text-white/45" />
|
||||
<div>
|
||||
<div className="text-xs text-white/45">Status</div>
|
||||
<div className="text-white/85">{loading ? "…" : uptimeLabel}</div>
|
||||
<div className="text-white/85">
|
||||
{loading ? "…" : (consoleFlowStatus ?? uptimeLabel)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/* global module, __dirname */
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "barecloud",
|
||||
cwd: __dirname,
|
||||
env_file: ".env",
|
||||
script: "/usr/bin/env",
|
||||
args: [
|
||||
"bash",
|
||||
"-lc",
|
||||
[
|
||||
"set -euo pipefail",
|
||||
'CORESTORE_ROOT="${BARECLOUD_CORESTORE_ROOT:-/user-data/corestores}"',
|
||||
'DATA_DIR="${BARECLOUD_DATA_DIR:-./data}"',
|
||||
'install -d -m 0755 /user-data "$CORESTORE_ROOT" "$DATA_DIR"',
|
||||
'chown -R root:root "$CORESTORE_ROOT" "$DATA_DIR"',
|
||||
'chmod 0755 /user-data "$CORESTORE_ROOT" "$DATA_DIR"',
|
||||
'rm -f "$CORESTORE_ROOT"/*/session-home/.config/pear/*.sock 2>/dev/null || true',
|
||||
"exec npm run start",
|
||||
].join(" && "),
|
||||
],
|
||||
interpreter: "none",
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
autorestart: true,
|
||||
max_restarts: 40,
|
||||
min_uptime: "10s",
|
||||
kill_timeout: 10_000,
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
HOME: "/root",
|
||||
PM2_HOME: "/root/.pm2",
|
||||
USER: "root",
|
||||
LOGNAME: "root",
|
||||
// Disable quota auto-tagging by default to avoid xfs_io failures
|
||||
// on transient pear.sock files under session-home.
|
||||
BARECLOUD_CORESTORE_QUOTA_ENABLED: "1",
|
||||
BARECLOUD_SESSION_HOME_LAYOUT: "1",
|
||||
BARECLOUD_PEAR_PATH_SESSION_BIN: "0",
|
||||
BARECLOUD_USE_NETNS: "1",
|
||||
BARECLOUD_NETNS_OUT_IFACE: "pia",
|
||||
BARECLOUD_NETNS_HAIRPIN: "1",
|
||||
BARECLOUD_NETNS_HAIRPIN_PUBLIC_IP: "66.56.80.54",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
+15
-5
@@ -33,7 +33,7 @@ export function pearSessionHomeDir(booterId: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Default (non-Windows): Pear `HOME` is `<booter>/session-home` and Bare OS uses `~/.bare-os` there (no `BARE_OS_HOST_DATA`).
|
||||
* Default (non-Windows): `HOME` is `<booter>/session-home` (Bare OS under `~/.bare-os`); `PEAR_HOME` is shared.
|
||||
* Opt out: `BARECLOUD_SESSION_HOME_LAYOUT=0`.
|
||||
* Windows defaults to off unless explicitly set to `1` (symlinks can require elevation).
|
||||
*/
|
||||
@@ -72,13 +72,13 @@ export function usePearSessionIsolation(): boolean {
|
||||
|
||||
/**
|
||||
* When **true** with session-home layout, prepend `<booter>/session-home/.config/pear/bin` to `PATH`.
|
||||
* Default **true** so each booter can resolve and run its own Pear install first.
|
||||
* Set `BARECLOUD_PEAR_PATH_SESSION_BIN=0` to force shared/global Pear resolution.
|
||||
* Default **false** so all booters resolve the shared/global Pear runtime first.
|
||||
* Set `BARECLOUD_PEAR_PATH_SESSION_BIN=1` to prefer a per-session Pear shim.
|
||||
*/
|
||||
export function pearPathIncludeSessionBin(): boolean {
|
||||
const v = process.env.BARECLOUD_PEAR_PATH_SESSION_BIN?.trim().toLowerCase();
|
||||
if (v === "0" || v === "false" || v === "no" || v === "off") return false;
|
||||
return true;
|
||||
if (v === "1" || v === "true" || v === "yes" || v === "on") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Log once at startup if proot is enabled but the binary is missing. */
|
||||
@@ -115,3 +115,13 @@ export function deleteCorestoreOnRemove(): boolean {
|
||||
const v = process.env.BARECLOUD_DELETE_CORESTORE_ON_REMOVE?.trim().toLowerCase();
|
||||
return v !== "0" && v !== "false" && v !== "no";
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional skeleton directory copied into each booter host-data dir on launch/reset.
|
||||
* Use this to pre-seed trusted Pear/session-home state.
|
||||
*/
|
||||
export function booterSkeletonDir(): string | null {
|
||||
const v = process.env.BARECLOUD_BOOTER_SKELETON_DIR?.trim();
|
||||
if (!v) return null;
|
||||
return path.resolve(v);
|
||||
}
|
||||
|
||||
+37
-1
@@ -1,5 +1,7 @@
|
||||
import fs from "fs";
|
||||
import { booterHostDataPath, deleteCorestoreOnRemove, pearBootLinkFromHostEnv } from "./barecloud-config";
|
||||
import { execFileSync } from "child_process";
|
||||
import path from "path";
|
||||
import { booterHostDataPath, booterSkeletonDir, deleteCorestoreOnRemove, pearBootLinkFromHostEnv } from "./barecloud-config";
|
||||
import { deleteBooterRecord, getBooterById, insertBooter, listExpiredBooterIds } from "./db";
|
||||
import { teardownBooterNetns } from "./booter-netns";
|
||||
import { ensureCorestoreQuota, removeCorestoreQuotaMapping } from "./corestore-quotas";
|
||||
@@ -26,9 +28,42 @@ function startHeadlessPearInTmux(booterId: string): void {
|
||||
ensureDetachedPearTmuxSession(booterId, defaultPearTermEnv());
|
||||
}
|
||||
|
||||
function seedBooterFromSkeletonIfConfigured(targetDir: string): void {
|
||||
const skeleton = booterSkeletonDir();
|
||||
if (!skeleton) return;
|
||||
if (!fs.existsSync(skeleton) || !fs.statSync(skeleton).isDirectory()) {
|
||||
throw new Error(`BareCloud skeleton is not a directory: ${skeleton}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const from = skeleton.endsWith(path.sep) ? skeleton : `${skeleton}${path.sep}`;
|
||||
const to = targetDir.endsWith(path.sep) ? targetDir : `${targetDir}${path.sep}`;
|
||||
execFileSync("rsync", [
|
||||
"-a",
|
||||
"--delete",
|
||||
"--exclude=**/*.sock",
|
||||
"--exclude=**/*.lock",
|
||||
from,
|
||||
to,
|
||||
]);
|
||||
return;
|
||||
} catch {
|
||||
// fallback to built-in copy if rsync is unavailable
|
||||
}
|
||||
|
||||
fs.cpSync(skeleton, targetDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
preserveTimestamps: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function launchBooter(opts: LaunchOpts): Promise<{ hostPort: number }> {
|
||||
const dir = booterHostDataPath(opts.id);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
seedBooterFromSkeletonIfConfigured(dir);
|
||||
ensureCorestoreQuota(opts.id, dir);
|
||||
|
||||
const pearBootLink = opts.pearBootLink?.trim() ? opts.pearBootLink.trim() : pearBootLinkFromHostEnv();
|
||||
@@ -204,6 +239,7 @@ export async function clearBooterCorestore(id: string): Promise<void> {
|
||||
try {
|
||||
fs.rmSync(p, { recursive: true, force: true });
|
||||
fs.mkdirSync(p, { recursive: true });
|
||||
seedBooterFromSkeletonIfConfigured(p);
|
||||
ensureCorestoreQuota(id, p);
|
||||
} catch (e) {
|
||||
throw new ClearCorestoreError(
|
||||
|
||||
+53
-4
@@ -121,6 +121,38 @@ function pickProjectId(booterId: string, map: QuotaMap, used: Set<number>): numb
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function pruneSocketInodes(rootDir: string): void {
|
||||
const stack = [rootDir];
|
||||
while (stack.length > 0) {
|
||||
const cur = stack.pop()!;
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(cur, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const p = path.join(cur, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(p);
|
||||
continue;
|
||||
}
|
||||
let st: fs.Stats;
|
||||
try {
|
||||
st = fs.lstatSync(p);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!st.isSocket()) continue;
|
||||
try {
|
||||
fs.unlinkSync(p);
|
||||
} catch {
|
||||
/* ignore: runtime sockets may disappear between scan and unlink */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureCorestoreQuota(booterId: string, dirPath: string): void {
|
||||
if (!quotasEnabled()) return;
|
||||
if (!commandExists("xfs_quota")) {
|
||||
@@ -149,11 +181,28 @@ export function ensureCorestoreQuota(booterId: string, dirPath: string): void {
|
||||
/*
|
||||
* Some hosts report `project -s` success without actually setting inode project IDs/inheritance.
|
||||
* `xfs_io` forces both recursively.
|
||||
* Socket inodes (e.g. Pear IPC sockets) are not supported by `xfs_io chattr/chproj`, so remove stale sockets first.
|
||||
*/
|
||||
execFileSync("xfs_io", ["-c", `chproj -R ${pid}`, "-c", "chattr -R +P", dirPath], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
});
|
||||
pruneSocketInodes(dirPath);
|
||||
try {
|
||||
execFileSync("xfs_io", ["-c", `chproj -R ${pid}`, "-c", "chattr -R +P", dirPath], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
});
|
||||
} catch (e) {
|
||||
/*
|
||||
* Some trees include inode types xfs_io cannot retag recursively (e.g. symlinks from seeded runtime state),
|
||||
* which surfaces as "setprojid: Operation not supported". `project -s` may already have applied the project
|
||||
* to the directory tree sufficiently for quota accounting, so accept that state instead of hard-failing launch.
|
||||
*/
|
||||
if (!projectTreeLooksSet(mountPoint, dirPath, pid)) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
throw new Error(`BareCloud quotas: xfs_io retag failed and project is not set for ${dirPath} (id ${pid}): ${msg}`);
|
||||
}
|
||||
console.warn(
|
||||
`[barecloud] quotas: xfs_io retag skipped for ${dirPath} (id ${pid}); continuing because project assignment is already set`,
|
||||
);
|
||||
}
|
||||
if (!projectTreeLooksSet(mountPoint, dirPath, pid)) {
|
||||
throw new Error(`BareCloud quotas: project id/inheritance is not set for ${dirPath} (id ${pid})`);
|
||||
}
|
||||
|
||||
+55
-114
@@ -1,4 +1,3 @@
|
||||
import { execFileSync } from "child_process";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
@@ -12,6 +11,8 @@ import {
|
||||
import { wrapPearSpawnWithNetnsIfNeeded } from "./booter-netns";
|
||||
import { pearBootLinkForBooter } from "./pear-boot-link";
|
||||
|
||||
const DEFAULT_PEAR_BIN = "/root/.nvm/versions/node/v20.20.2/bin/pear";
|
||||
|
||||
/** Drop `<BARECLOUD_CORESTORE_ROOT>/…` segments so `which pear` never resolves to a per-instance session shim. */
|
||||
function filterPathVarOfCorestores(pathVar: string): string {
|
||||
let rr: string;
|
||||
@@ -33,107 +34,9 @@ function filterPathVarOfCorestores(pathVar: string): string {
|
||||
return kept.join(sep);
|
||||
}
|
||||
|
||||
export function pearCliExecutable(booterId?: string): string {
|
||||
const v = process.env.BARECLOUD_PEAR_BIN?.trim();
|
||||
if (v) return path.resolve(v);
|
||||
if (booterId && usePearSessionIsolation()) {
|
||||
return ensurePerBooterPearCliShim(booterId);
|
||||
}
|
||||
const lookupBins: string[] = [];
|
||||
lookupBins.push(path.join(pearHomeDir(), ".config/pear/bin"));
|
||||
const pathForWhich = `${lookupBins.join(path.delimiter)}${path.delimiter}${filterPathVarOfCorestores(process.env.PATH ?? "")}`;
|
||||
try {
|
||||
const w = execFileSync("which", ["pear"], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, PATH: pathForWhich },
|
||||
}).trim();
|
||||
if (w) return w;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return "pear";
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure each isolated booter has its own CLI entrypoint path so launch never resolves a host-global binary.
|
||||
* We materialize a regular wrapper script in booter-local bin (not symlink), so recursive XFS project tagging
|
||||
* (`xfs_io ... chattr -R +P`) can succeed on quota-managed corestore trees while still executing the host Pear CLI.
|
||||
*/
|
||||
function ensurePerBooterPearCliShim(booterId: string): string {
|
||||
const sessionBin = path.join(pearSessionHomeDir(booterId), ".config/pear/bin");
|
||||
const localPear = path.join(sessionBin, "pear");
|
||||
fs.mkdirSync(sessionBin, { recursive: true });
|
||||
try {
|
||||
if (fs.existsSync(localPear)) fs.unlinkSync(localPear);
|
||||
} catch {
|
||||
/* ignore and continue */
|
||||
}
|
||||
|
||||
const sharedPear = path.join(pearBinDir(), "pear");
|
||||
if (fs.existsSync(sharedPear)) {
|
||||
try {
|
||||
writePearWrapper(localPear, sharedPear);
|
||||
return localPear;
|
||||
} catch {
|
||||
/* fall through to which-based discovery */
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const w = execFileSync("which", ["pear"], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, PATH: `${pearBinDir()}${path.delimiter}${filterPathVarOfCorestores(process.env.PATH ?? "")}` },
|
||||
}).trim();
|
||||
if (w) {
|
||||
try {
|
||||
writePearWrapper(localPear, w);
|
||||
return localPear;
|
||||
} catch {
|
||||
return w;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return localPear;
|
||||
}
|
||||
|
||||
function writePearWrapper(wrapperPath: string, targetPear: string): void {
|
||||
const target = targetPear.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
const script = `#!/usr/bin/env bash
|
||||
exec "${target}" "$@"
|
||||
`;
|
||||
fs.writeFileSync(wrapperPath, script, { encoding: "utf8" });
|
||||
fs.chmodSync(wrapperPath, 0o755);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force first-time Pear initialization inside the booter's own HOME/PEAR_HOME.
|
||||
* Without this, Pear can appear "already installed" from host-global state and skip per-instance setup.
|
||||
*/
|
||||
function ensurePerBooterPearBootstrap(
|
||||
booterId: string,
|
||||
pearExec: string,
|
||||
cwd: string,
|
||||
env: Record<string, string>,
|
||||
): void {
|
||||
if (!usePearSessionIsolation()) return;
|
||||
const sessionHome = pearSessionHomeDir(booterId);
|
||||
const marker = path.join(sessionHome, ".config/pear/.barecloud_bootstrapped");
|
||||
if (fs.existsSync(marker)) return;
|
||||
try {
|
||||
execFileSync(pearExec, ["versions", "--json"], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: "ignore",
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
});
|
||||
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
||||
fs.writeFileSync(marker, `${new Date().toISOString()}\n`, { encoding: "utf8" });
|
||||
console.info(`[barecloud] booter=${booterId} pear_bootstrap=ok`);
|
||||
} catch (e) {
|
||||
throw new Error(`BareCloud: pear bootstrap failed for booter=${booterId}: ${String(e)}`);
|
||||
}
|
||||
export function pearCliExecutable(): string {
|
||||
// Hard-pinned global runtime for all booters.
|
||||
return DEFAULT_PEAR_BIN;
|
||||
}
|
||||
|
||||
/** Arguments for the pear binary: always `run --no-ask <link>` so tmux / PTY never prompts interactively. */
|
||||
@@ -203,20 +106,25 @@ function pearBinDir(): string {
|
||||
return path.join(pearHomeDir(), ".config/pear/bin");
|
||||
}
|
||||
|
||||
/** Directory containing the configured global Pear runtime executable. */
|
||||
function pearRuntimeBinDir(): string {
|
||||
return path.dirname(pearCliExecutable());
|
||||
}
|
||||
|
||||
/**
|
||||
* Session layout: include `…/corestores/<id>/session-home/.config/pear/bin` on `PATH` by default so each booter
|
||||
* resolves its own Pear install first. Set `BARECLOUD_PEAR_PATH_SESSION_BIN=0` to use shared/global Pear instead.
|
||||
* Session layout: keep a global Pear runtime first by default; optional session-home shim can be prepended when enabled.
|
||||
*/
|
||||
function pearPathSessionHome(sessionHome: string, basePath: string): string {
|
||||
const sessionBin = path.join(sessionHome, ".config/pear/bin");
|
||||
const runtimeBin = pearRuntimeBinDir();
|
||||
const sharedBin = pearBinDir();
|
||||
if (path.resolve(sessionBin) === path.resolve(sharedBin)) {
|
||||
if (path.resolve(sessionBin) === path.resolve(runtimeBin)) {
|
||||
return `${sessionBin}:${basePath}`;
|
||||
}
|
||||
if (pearPathIncludeSessionBin()) {
|
||||
return `${sessionBin}:${sharedBin}:${basePath}`;
|
||||
return `${sessionBin}:${runtimeBin}:${sharedBin}:${basePath}`;
|
||||
}
|
||||
return `${sharedBin}:${basePath}`;
|
||||
return `${runtimeBin}:${sharedBin}:${basePath}`;
|
||||
}
|
||||
|
||||
function hostPathEnv(): { basePath: string; sslCert: string; nodeExtraCa: string } {
|
||||
@@ -230,22 +138,54 @@ function hostPathEnv(): { basePath: string; sslCert: string; nodeExtraCa: string
|
||||
}
|
||||
|
||||
/**
|
||||
* HOME is `<hostData>/session-home`; omit `BARE_OS_HOST_DATA` so bare-os uses `~/.bare-os`.
|
||||
* Pear uses `session-home/.config/pear` per booter (no shared symlink/bind — avoids RocksDB lock fights between instances).
|
||||
* Seed a new per-booter Pear config from the host's already-installed runtime.
|
||||
* This avoids "Installing Pear Runtime..." on first launch while keeping each booter isolated.
|
||||
*/
|
||||
function seedSessionPearConfigFromHostInstall(sessionHome: string): void {
|
||||
const sessionPear = path.join(sessionHome, ".config", "pear");
|
||||
const sessionCurrent = path.join(sessionPear, "current");
|
||||
if (fs.existsSync(sessionCurrent)) return;
|
||||
|
||||
const hostPear = "/root/.config/pear";
|
||||
if (!fs.existsSync(hostPear)) return;
|
||||
|
||||
fs.mkdirSync(sessionPear, { recursive: true });
|
||||
|
||||
const copyIfExists = (name: string) => {
|
||||
const src = path.join(hostPear, name);
|
||||
const dst = path.join(sessionPear, name);
|
||||
if (!fs.existsSync(src) || fs.existsSync(dst)) return;
|
||||
fs.cpSync(src, dst, { recursive: true, force: true });
|
||||
};
|
||||
|
||||
// Minimal runtime seed sufficient to skip Pear runtime bootstrap.
|
||||
copyIfExists("bin");
|
||||
copyIfExists("by-dkey");
|
||||
copyIfExists("current");
|
||||
fs.mkdirSync(path.join(sessionPear, "corestores"), { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* `HOME` is `<hostData>/session-home` so Bare OS uses `~/.bare-os` there (omit `BARE_OS_HOST_DATA`).
|
||||
* `PEAR_HOME` is {@link pearHomeDir} (shared host tree) for global Pear runtime metadata.
|
||||
* Keep `XDG_*` under session-home per booter so RocksDB/Corestore lock files remain isolated.
|
||||
*/
|
||||
function pearManagedEnvSessionHome(booterId: string, termEnv: string): Record<string, string> {
|
||||
const sessionHome = pearSessionHomeDir(booterId);
|
||||
const sharedPearHome = pearHomeDir();
|
||||
fs.mkdirSync(sessionHome, { recursive: true });
|
||||
fs.mkdirSync(path.join(sessionHome, ".config"), { recursive: true });
|
||||
fs.mkdirSync(path.join(sessionHome, ".config/pear/bin"), { recursive: true });
|
||||
fs.mkdirSync(path.join(sessionHome, ".local/share"), { recursive: true });
|
||||
fs.mkdirSync(path.join(sessionHome, ".cache"), { recursive: true });
|
||||
fs.mkdirSync(path.join(sessionHome, ".local/state"), { recursive: true });
|
||||
seedSessionPearConfigFromHostInstall(sessionHome);
|
||||
fs.mkdirSync(sharedPearHome, { recursive: true });
|
||||
const { basePath, sslCert, nodeExtraCa } = hostPathEnv();
|
||||
return {
|
||||
HOME: sessionHome,
|
||||
/* Prevent inherited global PEAR_HOME from tmux/server environment. */
|
||||
PEAR_HOME: sessionHome,
|
||||
/* Shared Pear runtime/cache; must be bootstrapped once on the host (see README). */
|
||||
PEAR_HOME: sharedPearHome,
|
||||
/*
|
||||
* tmux sessions can inherit server env; explicitly pin XDG roots per booter so Pear never falls back
|
||||
* to host-global config/data/cache paths.
|
||||
@@ -273,6 +213,7 @@ function pearManagedEnvLegacy(booterId: string, termEnv: string): Record<string,
|
||||
const coreDir = booterHostDataPath(booterId);
|
||||
const home = pearHomeDir();
|
||||
const { basePath, sslCert, nodeExtraCa } = hostPathEnv();
|
||||
const runtimeBin = pearRuntimeBinDir();
|
||||
const pearBD = pearBinDir();
|
||||
return {
|
||||
HOME: home,
|
||||
@@ -290,7 +231,7 @@ function pearManagedEnvLegacy(booterId: string, termEnv: string): Record<string,
|
||||
BARE_OS_HOST_DATA: coreDir,
|
||||
BARE_OS_BOOT_TIMEOUT_MS: bareOsBootTimeoutMs(),
|
||||
BARE_OS_NO_SPLASH: bareOsNoSplash(),
|
||||
PATH: `${pearBD}:${basePath}`,
|
||||
PATH: `${runtimeBin}:${pearBD}:${basePath}`,
|
||||
SSL_CERT_FILE: sslCert,
|
||||
NODE_EXTRA_CA_CERTS: nodeExtraCa,
|
||||
TERM: termEnv,
|
||||
@@ -310,6 +251,7 @@ export function pearManagedEnv(booterId: string, termEnv: string): Record<string
|
||||
* Merge parent `process.env` with {@link pearManagedEnv}, then drop `XDG_*` entries from the parent.
|
||||
* BareCloud often runs under systemd/cron with `XDG_CONFIG_HOME` etc. pointing at unrelated paths; Pear honors those
|
||||
* and may print misleading `export PATH=…` lines (see {@link pearPathSessionHome} — instance dirs stay off `PATH` by default).
|
||||
* Session layout: `PEAR_HOME` is shared; `HOME` / `XDG_*` are per booter.
|
||||
*/
|
||||
export function pearRegulatedChildEnv(booterId: string, termEnv: string): Record<string, string> {
|
||||
const env = { ...process.env, ...pearManagedEnv(booterId, termEnv) } as Record<string, string>;
|
||||
@@ -366,8 +308,7 @@ export function buildPearPtySpawn(booterId: string, termEnv: string): {
|
||||
} {
|
||||
const cwd = booterHostDataPath(booterId);
|
||||
const env = pearRegulatedChildEnv(booterId, termEnv);
|
||||
const pearExec = pearCliExecutable(booterId);
|
||||
ensurePerBooterPearBootstrap(booterId, pearExec, cwd, env);
|
||||
const pearExec = pearCliExecutable();
|
||||
console.info(`[barecloud] booter=${booterId} pear_exec=${pearExec}`);
|
||||
let inner: { file: string; args: string[]; cwd: string; env: Record<string, string> } = {
|
||||
file: pearExec,
|
||||
|
||||
+70
-31
@@ -43,6 +43,12 @@ type TerminalScrollControlMessage = {
|
||||
lines: number;
|
||||
};
|
||||
|
||||
type TerminalFlowStatusControlMessage = {
|
||||
barecloudTerminalControl: 1;
|
||||
type: "flow_status";
|
||||
status: string;
|
||||
};
|
||||
|
||||
function parseTerminalScrollControlMessage(data: RawData): TerminalScrollControlMessage | null {
|
||||
let text: string;
|
||||
if (typeof data === "string") {
|
||||
@@ -80,6 +86,20 @@ function parseTerminalScrollControlMessage(data: RawData): TerminalScrollControl
|
||||
}
|
||||
}
|
||||
|
||||
function sendTerminalFlowStatus(clientWs: WebSocket, status: string): void {
|
||||
if (clientWs.readyState !== WebSocket.OPEN) return;
|
||||
const payload: TerminalFlowStatusControlMessage = {
|
||||
barecloudTerminalControl: 1,
|
||||
type: "flow_status",
|
||||
status,
|
||||
};
|
||||
try {
|
||||
clientWs.send(JSON.stringify(payload), { binary: false, compress: false });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function booterIdFromSecWebSocketProtocol(req: IncomingMessage): string {
|
||||
const h = req.headers["sec-websocket-protocol"];
|
||||
const raw = typeof h === "string" ? h : Array.isArray(h) ? h.join(",") : "";
|
||||
@@ -401,19 +421,7 @@ export function pipeTerminalProxy(
|
||||
return;
|
||||
}
|
||||
|
||||
if (clientWs.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
const banner =
|
||||
sessionUsesTmux && hadDetachedBeforeConnect
|
||||
? "\r\n\x1b[90m[BareCloud]\x1b[0m Reconnected to your running session — your shell was kept open while you were away.\r\n"
|
||||
: sessionUsesTmux
|
||||
? "\r\n\x1b[90m[BareCloud]\x1b[0m Starting in a resumable session — you can close this tab and reopen this link later to pick up where you left off.\r\n"
|
||||
: "\r\n\x1b[90m[BareCloud]\x1b[0m Spawning Pear in an interactive terminal (boot splash and installer). When you disconnect, your instance keeps running in tmux when enabled.\r\n";
|
||||
clientWs.send(banner, { binary: false, compress: false });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
/* Keep terminal output app-only: lifecycle/status messaging is shown in the UI flow panel. */
|
||||
|
||||
let lastResize: { cols: number; rows: number } | null =
|
||||
initialSize != null ? { cols: initialSize.cols, rows: initialSize.rows } : null;
|
||||
@@ -518,10 +526,56 @@ export function pipeTerminalProxy(
|
||||
};
|
||||
|
||||
const WS_SEND_CHUNK = 24 * 1024;
|
||||
let pearInstallNoticeCarry = "";
|
||||
let bootingBannerShown = false;
|
||||
|
||||
function rewritePearInstallNotice(chunk: string): string {
|
||||
const startNeedle = "To complete Pear installation, prepend the following to the system $PATH:";
|
||||
const endNeedle = "Fix automatically with: pear run pear://runtime";
|
||||
|
||||
let input = pearInstallNoticeCarry + chunk;
|
||||
pearInstallNoticeCarry = "";
|
||||
let out = "";
|
||||
|
||||
while (true) {
|
||||
const startIdx = input.indexOf(startNeedle);
|
||||
if (startIdx === -1) break;
|
||||
|
||||
const blockStart = (() => {
|
||||
const prevNl = input.lastIndexOf("\n", startIdx);
|
||||
return prevNl === -1 ? 0 : prevNl + 1;
|
||||
})();
|
||||
const endIdx = input.indexOf(endNeedle, startIdx);
|
||||
if (endIdx === -1) {
|
||||
out += input.slice(0, blockStart);
|
||||
pearInstallNoticeCarry = input.slice(blockStart);
|
||||
input = "";
|
||||
break;
|
||||
}
|
||||
const endLineIdx = input.indexOf("\n", endIdx);
|
||||
if (endLineIdx === -1) {
|
||||
out += input.slice(0, blockStart);
|
||||
pearInstallNoticeCarry = input.slice(blockStart);
|
||||
input = "";
|
||||
break;
|
||||
}
|
||||
|
||||
out += input.slice(0, blockStart);
|
||||
if (!bootingBannerShown) {
|
||||
bootingBannerShown = true;
|
||||
sendTerminalFlowStatus(clientWs, "Trust accepted — downloading booter and starting Bare OS...");
|
||||
}
|
||||
input = input.slice(endLineIdx + 1);
|
||||
}
|
||||
|
||||
return out + input;
|
||||
}
|
||||
|
||||
pty.onData((data: string) => {
|
||||
if (clientWs.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
const rewritten = rewritePearInstallNotice(data);
|
||||
if (!rewritten) return;
|
||||
const ws = clientWs as WebSocket & { bufferedAmount?: number };
|
||||
if (ws.bufferedAmount >= WS_BUFFER_HIGH) {
|
||||
try {
|
||||
@@ -535,7 +589,7 @@ export function pipeTerminalProxy(
|
||||
}, 30);
|
||||
}
|
||||
}
|
||||
const buf = Buffer.from(data, "utf8");
|
||||
const buf = Buffer.from(rewritten, "utf8");
|
||||
for (let o = 0; o < buf.length; o += WS_SEND_CHUNK) {
|
||||
const part = buf.subarray(o, Math.min(buf.length, o + WS_SEND_CHUNK));
|
||||
clientWs.send(part.toString("utf8"), { binary: false, compress: false });
|
||||
@@ -558,23 +612,8 @@ export function pipeTerminalProxy(
|
||||
});
|
||||
|
||||
pty.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => {
|
||||
if (clientWs.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
const sig = signal ?? 0;
|
||||
const ec = exitCode ?? 0;
|
||||
let seg =
|
||||
" BareCloud will start the supervised instance again when this session closes. If it exits right away, check that peers are available on the network.";
|
||||
if (sig === 0 && ec === 0) {
|
||||
seg =
|
||||
" Reconnect the console when you want a new interactive session; the supervised instance will be running in the background.";
|
||||
}
|
||||
clientWs.send(
|
||||
`\r\n\x1b[90m[BareCloud]\x1b[0m Pear exited (exitCode=${exitCode ?? "null"}, signal=${sig}).${seg}\r\n`,
|
||||
{ binary: false, compress: false },
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (process.env.BARECLOUD_DEBUG_WS === "1") {
|
||||
console.error("[barecloud] terminal: pty_exit", booterId, { exitCode, signal: signal ?? 0 });
|
||||
}
|
||||
cleanup("pty_exit");
|
||||
});
|
||||
|
||||
+48
-1
@@ -117,6 +117,14 @@ function configureTmuxServerInput(): void {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
execFileSync(tmuxBin(), [...tmuxSocketArgs(), "set-option", "-g", "remain-on-exit", "off"], {
|
||||
stdio: "ignore",
|
||||
env: tmuxClientEnv(process.env),
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
for (const key of ["MouseDown3Pane", "MouseDown3Status", "MouseDown3Border"]) {
|
||||
try {
|
||||
execFileSync(tmuxBin(), [...tmuxSocketArgs(), "unbind-key", "-n", key], {
|
||||
@@ -187,6 +195,14 @@ function configureTmuxSessionForWebTerminal(booterId: string): void {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
execFileSync(tmuxBin(), [...tmuxSocketArgs(), "set-option", "-t", target, "remain-on-exit", "off"], {
|
||||
stdio: "ignore",
|
||||
env: tmuxClientEnv(process.env),
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
execFileSync(
|
||||
tmuxBin(),
|
||||
@@ -201,7 +217,7 @@ function configureTmuxSessionForWebTerminal(booterId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function hasTmuxPearSession(booterId: string): boolean {
|
||||
function tmuxSessionExists(booterId: string): boolean {
|
||||
if (!useTerminalTmuxPersistence()) return false;
|
||||
try {
|
||||
execFileSync(tmuxBin(), [...tmuxSocketArgs(), "has-session", "-t", tmuxExactSessionTarget(booterId)], {
|
||||
@@ -214,6 +230,34 @@ export function hasTmuxPearSession(booterId: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function tmuxSessionHasLivePane(booterId: string): boolean {
|
||||
if (!useTerminalTmuxPersistence()) return false;
|
||||
try {
|
||||
const out = execFileSync(
|
||||
tmuxBin(),
|
||||
[...tmuxSocketArgs(), "list-panes", "-t", tmuxExactSessionTarget(booterId), "-F", "#{pane_dead}"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
env: tmuxClientEnv(process.env),
|
||||
},
|
||||
);
|
||||
const states = out
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (states.length === 0) return false;
|
||||
return states.some((s) => s !== "1");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasTmuxPearSession(booterId: string): boolean {
|
||||
if (!tmuxSessionExists(booterId)) return false;
|
||||
return tmuxSessionHasLivePane(booterId);
|
||||
}
|
||||
|
||||
export function scrollTmuxPearSession(booterId: string, direction: "up" | "down", lines: number): boolean {
|
||||
if (!useTerminalTmuxPersistence()) return false;
|
||||
const count = Math.max(1, Math.min(200, Math.floor(lines)));
|
||||
@@ -255,6 +299,9 @@ function tmuxNewSessionEnvArgs(booterId: string, termEnv: string): string[] {
|
||||
/** Start Pear in a detached tmux session (no-op if session already exists). */
|
||||
export function ensureDetachedPearTmuxSession(booterId: string, termEnv: string): void {
|
||||
if (!useTerminalTmuxPersistence()) return;
|
||||
if (tmuxSessionExists(booterId) && !tmuxSessionHasLivePane(booterId)) {
|
||||
killTmuxPearSession(booterId, { force: true });
|
||||
}
|
||||
if (hasTmuxPearSession(booterId)) {
|
||||
configureTmuxSessionForWebTerminal(booterId);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user