From 895b4d1165dabb7d18d4d5842dfdb8104c9885d1 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 00:57:12 -0400 Subject: [PATCH 01/37] Add runtime identity and connector contracts RuntimeIdentity, the keep-alive default, and the runtime output separator live in @oxbit/protocol; the RuntimeConnector service types live in @oxbit/workbench. --- packages/protocol/src/index.ts | 26 +++++++ packages/workbench/src/index.tsx | 1 + packages/workbench/src/runtime-connector.ts | 82 +++++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 packages/workbench/src/runtime-connector.ts diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index fdf06290..adc27036 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -3,6 +3,32 @@ export const MAX_MESSAGE_BYTES = 2 * 1024 * 1024; export const MAX_BUFFER_BYTES = 1024 * 1024; /** Base64 of one chunk plus envelope stays below MAX_BUFFER_BYTES, so fs.readBytes never trips the slow-consumer close. */ export const READ_CHUNK_BYTES = 512 * 1024; +/** Mirrored in the Bonjour `_oxbit._tcp` TXT record. */ +export const RUNTIME_SERVICE_TYPE = "_oxbit._tcp"; +/** Served by `/api/health`, and as `runtime` in the `/api/pair` and `auth.authenticate` results. */ +export interface RuntimeIdentity { + /** Persisted in the runtime data directory, so it survives restarts and port changes. */ + id: string; + /** Host name of the machine serving the runtime. */ + name: string; + version: string; + /** Epoch milliseconds when this process started listening. */ + startedAt: number; +} +export function isRuntimeIdentity(value: unknown): value is RuntimeIdentity { + const identity = value as Partial | null; + return typeof identity?.id === "string" && /^[A-Za-z0-9-]{8,64}$/.test(identity.id) && + typeof identity.name === "string" && typeof identity.version === "string" && + typeof identity.startedAt === "number"; +} +/** Milliseconds a launched runtime keeps running after its last client and heartbeat; 0 runs until stopped. */ +export const DEFAULT_KEEP_ALIVE_MS = 75_000; +/** Separates a startup error message from the runtime output tail appended to it. */ +export const RUNTIME_OUTPUT_SEPARATOR = "\n--- runtime output ---\n"; +export function splitRuntimeError(text: string): { message: string; detail?: string } { + const index = text.indexOf(RUNTIME_OUTPUT_SEPARATOR); + return index === -1 ? { message: text } : { message: text.slice(0, index), detail: text.slice(index + RUNTIME_OUTPUT_SEPARATOR.length) }; +} export type ClientMessage = | { v: 1; diff --git a/packages/workbench/src/index.tsx b/packages/workbench/src/index.tsx index 017f2f2e..1ca5148d 100644 --- a/packages/workbench/src/index.tsx +++ b/packages/workbench/src/index.tsx @@ -60,6 +60,7 @@ import { displayShortcut, } from "./shortcuts.js"; export * from "./controller.js"; +export * from "./runtime-connector.js"; export { workspaceEntries } from "./files.js"; export { normalizeShortcut, diff --git a/packages/workbench/src/runtime-connector.ts b/packages/workbench/src/runtime-connector.ts new file mode 100644 index 00000000..2867cc1d --- /dev/null +++ b/packages/workbench/src/runtime-connector.ts @@ -0,0 +1,82 @@ +/** Kernel service id; each app registers one connector per workbench session. */ +export const RUNTIME_CONNECTOR_SERVICE = "runtimeConnector"; + +export type RuntimeConnectionState = "connected" | "connecting" | "reconnecting" | "disconnected" | "failed"; + +export interface RuntimeProblem { + message: string; + /** Runtime stderr tail, remote output, or the underlying error text. */ + detail?: string; + at: number; +} + +export interface RuntimeStatus { + state: RuntimeConnectionState; + kind?: "local" | "url" | "ssh"; + /** Host name reported by the runtime, or the SSH host label. */ + name?: string; + url?: string; + host?: string; + port?: number; + version?: string; + /** Epoch milliseconds when the runtime started listening. */ + startedAt?: number; + runtimeId?: string; + workspace?: string; + trusted?: boolean; + owner?: boolean; + progress?: string; + error?: RuntimeProblem; +} + +/** A runtime this device knows how to reach again. */ +export interface RuntimeTarget { + key: string; + kind: "url" | "ssh"; + name: string; + runtimeId?: string; + url?: string; + hostId?: string; + path?: string; + detail?: string; + lastConnected?: number; +} + +export interface DiscoveredRuntime { + runtimeId: string; + name: string; + version?: string; + host: string; + port: number; + url: string; + /** A credential for this runtime id is saved, so connecting needs no code. */ + paired: boolean; +} + +export interface SshHostSummary { + id: string; + label: string; + detail: string; + lastPath?: string; +} + +/** Optional members are capabilities; the Runtime page hides controls a host does not provide. */ +export interface RuntimeConnector { + status(): RuntimeStatus; + subscribe(listener: () => void): () => void; + /** The runtime this workspace already knows: an SSH host and folder, or a saved runtime. */ + quickTarget(): RuntimeTarget | undefined; + connect(target: RuntimeTarget): Promise; + pair(url: string, code: string): Promise; + recents(): Promise; + forget?(target: RuntimeTarget): Promise; + disconnect?(): Promise; + restart?(): Promise; + trust?(trusted: boolean): Promise; + discover?(listener: (runtimes: DiscoveredRuntime[]) => void): () => void; + sshHosts?(): Promise; + startSsh?(hostId: string, path: string): Promise; + manageSsh?(): void; + /** Shell command shown with the pairing form. */ + startCommand?: string; +} From a4dff5d0da5f50ab8819d09c185eaab022b95e65 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 01:06:51 -0400 Subject: [PATCH 02/37] Start listening before the first watcher scan The runtime listens before the workspace watcher scans; watcher failures become fs.watchError events and a stderr line. node-pty loads on first terminal use, the CLI exits on Node below 24, desktop error frames carry the error message, SSH startup errors carry the remote stderr tail, and foreground serve failures print one line. --- apps/runtime/src/cli.ts | 26 +++++++++ apps/runtime/src/desktop.ts | 14 +++-- apps/runtime/src/index.ts | 2 + apps/runtime/src/node-version.ts | 14 +++++ apps/runtime/src/processes.ts | 21 ++++++- apps/runtime/src/runtime.ts | 37 ++++++------ apps/runtime/src/ssh.ts | 16 ++++-- apps/runtime/tests/startup.test.ts | 91 ++++++++++++++++++++++++++++++ 8 files changed, 189 insertions(+), 32 deletions(-) create mode 100644 apps/runtime/src/node-version.ts create mode 100644 apps/runtime/tests/startup.test.ts diff --git a/apps/runtime/src/cli.ts b/apps/runtime/src/cli.ts index bd10011b..aef460a3 100644 --- a/apps/runtime/src/cli.ts +++ b/apps/runtime/src/cli.ts @@ -141,11 +141,37 @@ export async function resolveTarget( return { root, file: path.relative(root, file).split(path.sep).join("/") }; } +export function serveFailure(error: unknown, host: string, port: number | undefined) { + const failure = error as NodeJS.ErrnoException; + const address = `${host}:${port ?? 0}`; + if (failure.code === "EADDRINUSE") + return `Cannot serve on ${address}: the port is in use. Stop the process holding it or choose another with --port.`; + if (failure.code === "EACCES" || failure.code === "EPERM") + return `Cannot serve on ${address}: permission denied. Choose a port above 1023 or another --host.`; + if (failure.code === "EADDRNOTAVAIL") + return `Cannot serve on ${address}: the address is not available on this machine.`; + return `The runtime could not start: ${failure.message ?? String(error)}`; +} + async function serve( invocation: Invocation, target: Target, dataDir: string, env: Environment, +) { + try { + return await serveRuntime(invocation, target, dataDir, env); + } catch (error) { + process.stderr.write(serveFailure(error, invocation.host, invocation.port) + "\n"); + return 1; + } +} + +async function serveRuntime( + invocation: Invocation, + target: Target, + dataDir: string, + env: Environment, ) { const host = invocation.host; // A defaulted port may move aside for whatever holds it; an explicit one fails loudly instead. diff --git a/apps/runtime/src/desktop.ts b/apps/runtime/src/desktop.ts index bd70440f..08f1943f 100644 --- a/apps/runtime/src/desktop.ts +++ b/apps/runtime/src/desktop.ts @@ -6,7 +6,7 @@ import { createHash, randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; import { createInterface } from "node:readline"; import { connectSsh } from "./ssh.js"; -import { acquireRemoteWorkspace, RemoteWorkspaceBusy } from "./ssh-workspace.js"; +import { acquireRemoteWorkspace } from "./ssh-workspace.js"; import { observeOwnedProcesses, terminateOwnedProcesses } from "./owned-processes.js"; import { createRuntime } from "./runtime.js"; @@ -26,7 +26,6 @@ const send = (message: object) => process.stdout.write(JSON.stringify({ version: console.log = console.info = console.debug = (...args: unknown[]) => console.error(...args); let runtime: Awaited> | undefined; let remote: Awaited> | undefined; -let connectingRemote = false; let releaseRemoteWorkspace: (() => Promise) | undefined; const connectionAbort = new AbortController(); let starting = false; @@ -63,9 +62,13 @@ process.stdin.on("data", (chunk: Buffer) => { if (bytes > 65536) void shutdown(1); if (chunk.includes(10)) bytes = 0; }); +// Parse errors quote the input, and the launch frame carries the token. +const parseFrame = (line: string): unknown => { + try { return JSON.parse(line); } catch { throw new Error("Invalid supervisor frame"); } +}; lines.on("line", (line) => { void (async () => { - const config = JSON.parse(line) as Launch | { version: 1; type: "heartbeat" } | { version: 1; type: "shutdown" } | { version: 1; type: "rotate"; token: string; request: string } | { version: 1; type: "taskForwarded"; request: string; port?: number; error?: string }; + const config = parseFrame(line) as Launch | { version: 1; type: "heartbeat" } | { version: 1; type: "shutdown" } | { version: 1; type: "rotate"; token: string; request: string } | { version: 1; type: "taskForwarded"; request: string; port?: number; error?: string }; if (config.version !== 1) throw new Error("Unsupported supervisor protocol"); if (config.type === "heartbeat") { if (isRemoteRuntime) refreshRemoteLease(); return; } if (config.type === "taskForwarded") { @@ -87,7 +90,6 @@ lines.on("line", (line) => { if (config.type !== "launch" || starting) throw new Error("Expected one launch frame"); starting = true; if (typeof config.root === "string" && config.root.startsWith("ssh://") && !config.remoteRuntime) { - connectingRemote = true; clearTimeout(deadline); const timeout = setTimeout(() => connectionAbort.abort(), 240000); try { @@ -133,8 +135,8 @@ lines.on("line", (line) => { clearTimeout(deadline); send({ type: "ready", port: runtime.port, workspaceKey: config.workspaceKey, root, openFile }); })().catch((error: unknown) => { - // Never echo launch frames, environment, credentials, or arbitrary exception text. - send({ type: "error", code: "STARTUP_FAILED", message: connectionAbort.signal.aborted ? "SSH connection cancelled or timed out." : (connectingRemote && error instanceof Error || error instanceof RemoteWorkspaceBusy) ? error.message : "Runtime startup failed. Check the project path and packaged resources." }); + // Launch frames, environment, and credentials stay out of this message. + send({ type: "error", code: "STARTUP_FAILED", message: connectionAbort.signal.aborted ? "SSH connection cancelled or timed out." : error instanceof Error ? error.message : "Runtime startup failed. Check the project path and packaged resources." }); void shutdown(1); }); }); diff --git a/apps/runtime/src/index.ts b/apps/runtime/src/index.ts index 89ec5918..9af0152a 100644 --- a/apps/runtime/src/index.ts +++ b/apps/runtime/src/index.ts @@ -1,3 +1,5 @@ +// Evaluated first so an unsupported Node exits before the runtime modules load. +import "./node-version.js"; import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { run } from "./cli.js"; diff --git a/apps/runtime/src/node-version.ts b/apps/runtime/src/node-version.ts new file mode 100644 index 00000000..c5a24325 --- /dev/null +++ b/apps/runtime/src/node-version.ts @@ -0,0 +1,14 @@ +export const REQUIRED_NODE_MAJOR = 24; + +export function nodeVersionError(version = process.versions.node) { + const major = Number(version.split(".")[0]); + return major >= REQUIRED_NODE_MAJOR + ? undefined + : `Oxbit requires Node.js ${REQUIRED_NODE_MAJOR} or later; found Node.js ${version}. Install Node.js ${REQUIRED_NODE_MAJOR} and retry.`; +} + +const failure = nodeVersionError(); +if (failure) { + process.stderr.write(failure + "\n"); + process.exit(1); +} diff --git a/apps/runtime/src/processes.ts b/apps/runtime/src/processes.ts index cab735eb..f89140e9 100644 --- a/apps/runtime/src/processes.ts +++ b/apps/runtime/src/processes.ts @@ -3,7 +3,7 @@ import { trackChild, trackProcess } from "./owned-processes.js"; import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { StringDecoder } from "node:string_decoder"; -import * as pty from "node-pty"; +import type { IPty } from "node-pty"; import { RpcError, MAX_BUFFER_BYTES } from "@oxbit/protocol"; import { resolveTerminalShell } from "./terminal-shell.js"; @@ -21,7 +21,7 @@ type Emit = (event: ProcessEvent, owner?: string) => void; interface Terminal { id: string; owner: string; - pty: pty.IPty; + pty: IPty; seq: number; chunks: Chunk[]; bytes: number; @@ -30,6 +30,12 @@ interface Terminal { paused: boolean; } export { killProcess } from "./process-lifecycle.js"; +export type PtyModule = Pick; +let ptyModule: Promise | undefined; +const loadNodePty = () => (ptyModule ??= import("node-pty").catch((error: unknown) => { + ptyModule = undefined; + throw error; +})); export async function runCommand( command: string, args: string[], @@ -94,6 +100,7 @@ export class Processes { constructor( private root: string, private emit: Emit, + private loadPty: () => Promise = loadNodePty, ) {} private terminal(id: string, owner: string) { const value = this.terminals.get(id); @@ -101,7 +108,15 @@ export class Processes { throw new RpcError("NOT_FOUND", "Terminal session was not found"); return value; } - create(owner: string, connection: string, cols = 100, rows = 30) { + private async pty() { + try { + return await this.loadPty(); + } catch (error) { + throw new RpcError("TERMINAL_UNAVAILABLE", `node-pty failed to load: ${error instanceof Error ? error.message : String(error)}`); + } + } + async create(owner: string, connection: string, cols = 100, rows = 30) { + const pty = await this.pty(); for (const [id, terminal] of this.terminals) if (this.terminals.size >= 64 && terminal.exitCode !== undefined) this.terminals.delete(id); if ( [...this.terminals.values()].filter((t) => t.exitCode === undefined) diff --git a/apps/runtime/src/runtime.ts b/apps/runtime/src/runtime.ts index cb346089..88d3ffbe 100644 --- a/apps/runtime/src/runtime.ts +++ b/apps/runtime/src/runtime.ts @@ -1280,12 +1280,17 @@ export async function createRuntime(options: RuntimeOptions) { }); let watcher: ReturnType | undefined, watcherTransition = Promise.resolve(); - let watcherReadyResolve: () => void, - watcherReadyReject: (error: unknown) => void; - const watcherReady = new Promise((resolve, reject) => { + let watcherReadyResolve!: () => void; + const watcherReady = new Promise((resolve) => { watcherReadyResolve = resolve; - watcherReadyReject = reject; }); + const watchFailed = (error: NodeJS.ErrnoException) => { + process.stderr.write(`Oxbit file watcher failed: ${error.code ? `${error.code} ` : ""}${error.message}\n`); + for (const c of connections.values()) + if (c.watching && c.session) + event(c, "fs.watchError", { message: error.message, code: error.code }); + watcherReadyResolve(); + }; let fileChangeQueue: Promise = Promise.resolve(); const watchedChange = (kind: string, full: string) => { fileChangeQueue = fileChangeQueue.catch(() => {}).then(async () => { @@ -1361,27 +1366,14 @@ export async function createRuntime(options: RuntimeOptions) { .then(() => { if (!closed) startWatcher(true); }) - .catch(watcherReadyReject); + .catch(watchFailed); for (const c of connections.values()) if (c.watching && c.session) event(c, "fs.watchStatus", { mode: "polling", reason: error.code }); - } else { - for (const c of connections.values()) - if (c.watching && c.session) - event(c, "fs.watchError", { - message: error.message, - code: error.code, - }); - watcherReadyReject(error); - } + } else watchFailed(error); }); current.add(root); }; - startWatcher( - setting("WATCH_POLLING") === "1" || - setting("WATCH_POLLING") === "true", - ); - await watcherReady; await new Promise((resolve, reject) => { server.once("error", reject); server.listen(port, host, () => { @@ -1391,12 +1383,19 @@ export async function createRuntime(options: RuntimeOptions) { }); const address = server.address(); if (address && typeof address === "object") port = address.port; + try { + startWatcher(setting("WATCH_POLLING") === "1" || setting("WATCH_POLLING") === "true"); + } catch (error) { + watchFailed(error as NodeJS.ErrnoException); + } return { server, port, pairingCode, root, dataDir, + /** Resolves after the watcher's first full scan, or once watching has failed. */ + watcherReady, async rotateDesktopToken(token: string) { if (!options.desktop || token.length < 32) throw new Error("Invalid desktop rotation"); const previous = [...sessions.entries()].find(([, session]) => session.id === "desktop-owner"); diff --git a/apps/runtime/src/ssh.ts b/apps/runtime/src/ssh.ts index 549a9e5d..ef0f7b6d 100644 --- a/apps/runtime/src/ssh.ts +++ b/apps/runtime/src/ssh.ts @@ -6,6 +6,7 @@ import net from "node:net"; import { createHash } from "node:crypto"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { pipeline } from "node:stream/promises"; +import { RUNTIME_OUTPUT_SEPARATOR } from "@oxbit/protocol"; import { installScript, parseSshTarget, @@ -279,14 +280,21 @@ export async function connectSsh(options: SshOptions) { `cd ${remote} || exit 1; unset NODE_OPTIONS NODE_PATH OXBIT_LSP_COMMAND; exec ${remote}/bin/node ${remote}/desktop.js`, ), ); - session.stderr.resume(); + let remoteOutput = ""; + session.stderr.on("data", (chunk: Buffer) => { + remoteOutput = (remoteOutput + chunk.toString()).slice(-4096); + }); + const withOutput = (message: string) => { + const tail = remoteOutput.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "").trim(); + return new Error(tail ? message + RUNTIME_OUTPUT_SEPARATOR + tail : message); + }; const localPort = await freePort(); let buffer = ""; const ready = new Promise((resolve, reject) => { const timer = setTimeout( () => reject( - new Error( + withOutput( "Remote runtime startup timed out. Check the remote folder and operating system compatibility.", ), ), @@ -295,7 +303,7 @@ export async function connectSsh(options: SshOptions) { const failed = () => { clearTimeout(timer); reject( - new Error( + withOutput( "Remote runtime stopped. Check the folder path, available disk space, and platform compatibility.", ), ); @@ -324,7 +332,7 @@ export async function connectSsh(options: SshOptions) { else if (frame.type === "taskForward") forwardTask(frame); else if (frame.type === "error") { clearTimeout(timer); - reject(new Error(frame.message ?? "Remote startup failed")); + reject(withOutput(frame.message ?? "Remote startup failed")); } // Remote process IDs are meaningful only on the remote machine. } catch { diff --git a/apps/runtime/tests/startup.test.ts b/apps/runtime/tests/startup.test.ts new file mode 100644 index 00000000..f584631b --- /dev/null +++ b/apps/runtime/tests/startup.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import * as fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { RpcError } from "@oxbit/protocol"; + +const watchers: FakeWatcher[] = []; +class FakeWatcher extends EventEmitter { + closed = false; + add() { return this; } + async close() { this.closed = true; } +} +// The settings store watches named paths and needs "ready"; the workspace watcher starts empty and never scans. +vi.mock("chokidar", () => ({ + default: { + watch: (paths: string[]) => { + const watcher = new FakeWatcher(); + if (paths.length) setImmediate(() => watcher.emit("ready")); + else watchers.push(watcher); + return watcher; + }, + }, +})); +const { createRuntime } = await import("../src/runtime.js"); +const { Processes } = await import("../src/processes.js"); +const { nodeVersionError } = await import("../src/node-version.js"); + +const directories: string[] = []; +async function start() { + const directory = await fs.mkdtemp(path.join(await fs.realpath(os.tmpdir()), "oxbit-startup-")); + directories.push(directory); + await fs.mkdir(path.join(directory, "workspace")); + return createRuntime({ + root: path.join(directory, "workspace"), + tasksHome: directory, + port: 0, + dataDir: path.join(directory, "state"), + projectsDir: path.join(directory, "projects"), + settingsFile: path.join(directory, "settings.json"), + }); +} +afterEach(async () => { + watchers.length = 0; + for (const directory of directories.splice(0)) await fs.rm(directory, { recursive: true, force: true }); +}); + +describe("startup", () => { + it("answers health while the first watcher scan is still running", async () => { + const runtime = await start(); + let ready = false; + void runtime.watcherReady.then(() => { ready = true; }); + const response = await fetch(`http://127.0.0.1:${runtime.port}/api/health`); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ ok: true, protocol: 1 }); + expect(ready).toBe(false); + await runtime.close(); + expect(watchers[0]!.closed).toBe(true); + }); + + it("logs a watcher error after listening and keeps serving", async () => { + const runtime = await start(); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + try { + watchers[0]!.emit("error", Object.assign(new Error("watch broke"), { code: "EIO" })); + await runtime.watcherReady; + expect(stderr).toHaveBeenCalledWith(expect.stringContaining("EIO watch broke")); + } finally { + stderr.mockRestore(); + } + expect((await fetch(`http://127.0.0.1:${runtime.port}/api/health`)).status).toBe(200); + await runtime.close(); + }); +}); + +describe("terminal module loading", () => { + it("reports a node-pty load failure as an RpcError", async () => { + const processes = new Processes(os.tmpdir(), () => {}, () => Promise.reject(new Error("dlopen failed: wrong ABI"))); + const failure = await processes.create("owner", "connection").catch((error: unknown) => error); + expect(failure).toBeInstanceOf(RpcError); + expect((failure as RpcError).message).toBe("node-pty failed to load: dlopen failed: wrong ABI"); + }); +}); + +describe("node version", () => { + it("names the found and required versions below Node 24", () => { + expect(nodeVersionError("22.11.0")).toContain("found Node.js 22.11.0"); + expect(nodeVersionError("22.11.0")).toContain("Node.js 24"); + expect(nodeVersionError("24.0.0")).toBeUndefined(); + }); +}); From 9faebfac2228a5bedb36d0be7ae18ffe4ddbfcdb Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 01:08:05 -0400 Subject: [PATCH 03/37] Show runtime stderr in desktop startup errors The supervisor pipes the runtime's stderr into a 4 KiB tail, appends it to startup errors after the runtime output separator, and sends it as detail with desktop-runtime-failed. desktop_connection takes an optional keepAlive that the launch frame carries. --- apps/desktop/src-tauri/src/commands.rs | 5 +- apps/desktop/src-tauri/src/supervisor.rs | 153 ++++++++++++++++++----- 2 files changed, 124 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index edb86049..59c9a9d4 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -290,6 +290,7 @@ pub async fn desktop_connection( window: WebviewWindow, key: String, restart: Option, + keep_alive: Option, ) -> Result { let project = authorized(&app, &window, &key)?; tauri::async_runtime::spawn_blocking(move || { @@ -313,8 +314,8 @@ pub async fn desktop_connection( let progress_app = app.clone(); let progress_key = key.clone(); let process = OwnedRuntime::launch(&resource.join("bin/node"), &resource.join("desktop.js"), Launch { version: 1, r#type: "launch", root: &project.path, data_dir: &data.to_string_lossy(), workspace_key: &key, token: &token, - rg_path: &resource.join("bin/rg").to_string_lossy(), git_path: git.as_deref(), development: cfg!(debug_assertions) && !cfg!(feature = "custom-protocol"), - }, &desktop.environment, move || { let _ = event_app.emit("desktop-runtime-failed", json!({"key":event_key})); }, move |message| { let _ = progress_app.emit("desktop-remote-progress", json!({"key":progress_key,"message":message})); })?; + rg_path: &resource.join("bin/rg").to_string_lossy(), git_path: git.as_deref(), development: cfg!(debug_assertions) && !cfg!(feature = "custom-protocol"), keep_alive, + }, &desktop.environment, move |detail| { let _ = event_app.emit("desktop-runtime-failed", json!({"key":event_key,"detail":detail})); }, move |message| { let _ = progress_app.emit("desktop-remote-progress", json!({"key":progress_key,"message":message})); })?; // Ownership can change while the runtime starts. Never return credentials to a former owner. desktop.model.lock().unwrap().authorize(window.label(), &key)?; let result = json!({"url":format!("http://127.0.0.1:{}", process.port), "token":token, "key":key, "openFile":process.open_file}); diff --git a/apps/desktop/src-tauri/src/supervisor.rs b/apps/desktop/src-tauri/src/supervisor.rs index f4ab7358..d7d1ebc1 100644 --- a/apps/desktop/src-tauri/src/supervisor.rs +++ b/apps/desktop/src-tauri/src/supervisor.rs @@ -1,11 +1,11 @@ use crate::environment::Environment; use serde::{Deserialize, Serialize}; use std::{ - collections::{HashMap, HashSet}, + collections::{HashMap, HashSet, VecDeque}, io::{BufRead, BufReader, Write}, os::unix::process::CommandExt, path::Path, - process::{Child, ChildStdin, Command, Stdio}, + process::{Child, ChildStderr, ChildStdin, Command, Stdio}, sync::{ atomic::{AtomicBool, Ordering}, mpsc, Arc, Mutex, @@ -26,8 +26,13 @@ pub struct Launch<'a> { pub rg_path: &'a str, pub git_path: Option<&'a str>, pub development: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub keep_alive: Option, } +pub const OUTPUT_SEPARATOR: &str = "\n--- runtime output ---\n"; +const OUTPUT_LIMIT: usize = 4096; + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct Frame { @@ -66,13 +71,62 @@ fn terminate_groups(groups: &Mutex>) { } } +fn drain_output(stderr: ChildStderr, tail: Arc>>) -> thread::JoinHandle<()> { + let tee = std::env::var_os("OXBIT_RUNTIME_STDERR").is_some(); + thread::spawn(move || { + let mut stderr = stderr; + let mut chunk = [0u8; 4096]; + loop { + let count = match std::io::Read::read(&mut stderr, &mut chunk) { + Ok(0) => break, + Ok(count) => count, + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + }; + if tee { + let _ = std::io::stderr().write_all(&chunk[..count]); + } + let mut tail = tail.lock().unwrap(); + tail.extend(&chunk[..count]); + let excess = tail.len().saturating_sub(OUTPUT_LIMIT); + tail.drain(..excess); + } + }) +} + +// Grandchildren outside the process group can hold the pipe open, so EOF is awaited briefly. +fn settle_output(drain: thread::JoinHandle<()>) { + let deadline = Instant::now() + Duration::from_millis(200); + while !drain.is_finished() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(5)); + } +} + +fn output_text(tail: &Mutex>) -> String { + let bytes: Vec = tail.lock().unwrap().iter().copied().collect(); + let text: String = String::from_utf8_lossy(&bytes) + .chars() + .filter(|c| !c.is_control() || *c == '\n' || *c == '\t') + .collect(); + text.trim().to_string() +} + +fn with_output(message: String, tail: &Mutex>) -> String { + let output = output_text(tail); + if output.is_empty() { + message + } else { + format!("{message}{OUTPUT_SEPARATOR}{output}") + } +} + impl OwnedRuntime { pub fn launch( node: &Path, entry: &Path, config: Launch<'_>, environment: &Environment, - crashed: impl Fn() + Send + Sync + 'static, + crashed: impl Fn(String) + Send + Sync + 'static, progress: impl Fn(&str), ) -> Result, String> { let timeout = if config.root.starts_with("ssh://") { @@ -88,7 +142,7 @@ impl OwnedRuntime { entry: &Path, config: Launch<'_>, environment: &Environment, - crashed: impl Fn() + Send + Sync + 'static, + crashed: impl Fn(String) + Send + Sync + 'static, progress: impl Fn(&str), timeout: Duration, ) -> Result, String> { @@ -103,18 +157,17 @@ impl OwnedRuntime { .env_remove("OXBIT_LSP_COMMAND") .stdin(Stdio::piped()) .stdout(Stdio::piped()) - // Runtime failures reach the user as an RPC message with no stack behind it. - // OXBIT_RUNTIME_STDERR keeps the child's diagnostics when one needs chasing. - .stderr(if std::env::var_os("OXBIT_RUNTIME_STDERR").is_some() { - Stdio::inherit() - } else { - Stdio::null() - }) + // Startup errors and crash events carry the stderr tail. + // OXBIT_RUNTIME_STDERR also copies the child's stderr to the parent's. + .stderr(Stdio::piped()) .process_group(0) .spawn() .map_err(|_| "Bundled runtime could not be started. Reinstall Oxbit.")?; let mut input = child.stdin.take().ok_or("Runtime input is unavailable")?; let stdout = child.stdout.take().ok_or("Runtime output is unavailable")?; + let stderr = child.stderr.take().ok_or("Runtime output is unavailable")?; + let tail = Arc::new(Mutex::new(VecDeque::new())); + let drain = drain_output(stderr, tail.clone()); let frame = serde_json::to_vec(&config).map_err(|_| "Invalid launch configuration")?; if input .write_all(&frame) @@ -123,7 +176,8 @@ impl OwnedRuntime { { let _ = child.kill(); let _ = child.wait(); - return Err("Runtime launch channel closed".into()); + settle_output(drain); + return Err(with_output("Runtime launch channel closed".into(), &tail)); } let (send, receive) = mpsc::channel::(); thread::spawn(move || { @@ -190,10 +244,12 @@ impl OwnedRuntime { } let _ = child.wait(); terminate_groups(&startup_groups); - return Err(detail.unwrap_or_else(|| { + settle_output(drain); + let message = detail.unwrap_or_else(|| { "Runtime startup failed or timed out. Retry or reinstall the application." .into() - })); + }); + return Err(with_output(message, &tail)); } }; let runtime = Arc::new(Self { @@ -235,10 +291,11 @@ impl OwnedRuntime { while let Ok(frame) = receive.try_recv() { runtime.observe(frame); } - runtime.alive.store(false, Ordering::SeqCst); terminate_groups(&runtime.groups); + settle_output(drain); + runtime.alive.store(false, Ordering::SeqCst); if !runtime.stopped.load(Ordering::SeqCst) { - crashed(); + crashed(output_text(&tail)); } break; } @@ -350,7 +407,7 @@ mod tests { } fn launch( &self, - crashed: impl Fn() + Send + Sync + 'static, + crashed: impl Fn(String) + Send + Sync + 'static, ) -> Result, String> { let mut env = Environment::new(); env.insert("PATH".into(), "/usr/bin:/bin".into()); @@ -358,17 +415,7 @@ mod tests { OwnedRuntime::launch_with_timeout( Path::new("/bin/sh"), &self.directory.join("entry.sh"), - Launch { - version: 1, - r#type: "launch", - root: "/tmp/workspace", - data_dir: "/tmp/state", - workspace_key: "project", - token: "private-token", - rg_path: "/bin/false", - git_path: None, - development: false, - }, + config(None), &env, crashed, |_| {}, @@ -382,6 +429,20 @@ mod tests { .unwrap() } } + fn config(keep_alive: Option) -> Launch<'static> { + Launch { + version: 1, + r#type: "launch", + root: "/tmp/workspace", + data_dir: "/tmp/state", + workspace_key: "project", + token: "private-token", + rg_path: "/bin/false", + git_path: None, + development: false, + keep_alive, + } + } impl Drop for Fixture { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.directory); @@ -397,7 +458,7 @@ mod tests { "[ -z \"${{NODE_OPTIONS+x}}\" ] || exit 1\n{READY}\nIFS= read -r shutdown" )); let runtime = fixture - .launch(|| panic!("graceful stop must not report a crash")) + .launch(|_| panic!("graceful stop must not report a crash")) .unwrap(); assert_eq!(runtime.port, 12345); runtime.stop(); @@ -411,7 +472,7 @@ mod tests { echo '{{\"version\":1,\"type\":\"process\",\"pid\":'\"$$\"',\"running\":false}}'\n\ {READY}\nIFS= read -r shutdown" )); - let runtime = fixture.launch(|| {}).unwrap(); + let runtime = fixture.launch(|_| {}).unwrap(); assert_eq!(runtime.port, 12345); assert!(runtime.groups.lock().unwrap().is_empty()); runtime.stop(); @@ -426,12 +487,40 @@ mod tests { ] { let fixture = Fixture::new(&body); let start = Instant::now(); - assert!(fixture.launch(|| {}).is_err()); + assert!(fixture.launch(|_| {}).is_err()); assert!(start.elapsed() < Duration::from_secs(2)); assert!(gone(fixture.pid())); } } #[test] + fn exit_before_ready_reports_runtime_output() { + let fixture = + Fixture::new("echo 'boom from runtime' >&2\nprintf '\\033[31mred\\033[0m' >&2\nexit 3"); + let error = fixture.launch(|_| {}).err().unwrap(); + assert!(error.starts_with("Runtime startup failed or timed out.")); + assert!(error.ends_with(&format!("{OUTPUT_SEPARATOR}boom from runtime\n[31mred[0m"))); + } + #[test] + fn crash_detail_carries_runtime_output() { + let fixture = Fixture::new(&format!("{READY}\necho 'fatal after ready' >&2\nexit 1")); + let (send, receive) = mpsc::channel(); + let send = Mutex::new(send); + let _runtime = fixture + .launch(move |detail| { + let _ = send.lock().unwrap().send(detail); + }) + .unwrap(); + let detail = receive.recv_timeout(Duration::from_secs(2)).unwrap(); + assert_eq!(detail, "fatal after ready"); + } + #[test] + fn keep_alive_serializes_only_when_set() { + let omitted = serde_json::to_string(&config(None)).unwrap(); + assert!(!omitted.contains("keepAlive")); + let zero = serde_json::to_string(&config(Some(0))).unwrap(); + assert!(zero.contains("\"keepAlive\":0")); + } + #[test] fn abrupt_exit_reports_failure_once_and_cleans_inherited_children() { let fixture = Fixture::new(&format!( "{READY}\nsleep 30 &\nprintf '%s' \"$!\" > child-pid\nsleep 0.1\nexit 1" @@ -446,7 +535,7 @@ mod tests { let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let observed = calls.clone(); let runtime = fixture - .launch(move || { + .launch(move |_| { observed.fetch_add(1, Ordering::SeqCst); }) .unwrap(); From f3a4ce4a51ffb0774019f65ccc1e7362aca96f25 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 01:08:47 -0400 Subject: [PATCH 04/37] Serve runtime identity and bind a random port The runtime persists an id in runtime.json and serves { id, name, version, startedAt } from /api/health, /api/pair, and auth.authenticate. The CLI binds an OS-assigned port unless --port or PORT is set, records the id in daemon.json, and prints it from --status. The dev script keeps port 9277. --- apps/runtime/package.json | 2 +- apps/runtime/src/cli.ts | 39 +++++--------- apps/runtime/src/daemon.ts | 21 +------- apps/runtime/src/runtime.ts | 18 +++++-- apps/runtime/src/version.ts | 16 ++++++ apps/runtime/tests/cli.test.ts | 2 +- apps/runtime/tests/identity.test.ts | 81 +++++++++++++++++++++++++++++ apps/runtime/vite.config.ts | 3 ++ apps/runtime/vite.desktop.config.ts | 3 ++ 9 files changed, 132 insertions(+), 53 deletions(-) create mode 100644 apps/runtime/src/version.ts create mode 100644 apps/runtime/tests/identity.test.ts diff --git a/apps/runtime/package.json b/apps/runtime/package.json index 0cbbb39e..b890fb75 100644 --- a/apps/runtime/package.json +++ b/apps/runtime/package.json @@ -5,7 +5,7 @@ "type": "module", "exports": "./src/index.ts", "scripts": { - "dev": "node ../../scripts/repair-pty.mjs && tsx watch src/index.ts --foreground", + "dev": "node ../../scripts/repair-pty.mjs && tsx watch src/index.ts --foreground --port 9277", "start": "node ../../scripts/repair-pty.mjs && node dist/index.js --foreground --no-open", "build": "node ../../scripts/repair-pty.mjs && vite build", "build:desktop": "vite build --config vite.desktop.config.ts" diff --git a/apps/runtime/src/cli.ts b/apps/runtime/src/cli.ts index aef460a3..7bea8011 100644 --- a/apps/runtime/src/cli.ts +++ b/apps/runtime/src/cli.ts @@ -5,8 +5,8 @@ import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { createRuntime } from "./runtime.js"; import { setting } from "./branding.js"; +import { runtimeVersion } from "./version.js"; import { - available, dataDirFor, delay, launchBrowser, @@ -20,7 +20,6 @@ import { type Environment, } from "./daemon.js"; -export const DEFAULT_PORT = 9277; export const DEFAULT_HOST = "127.0.0.1"; const READY_TIMEOUT = 60000; const STOP_TIMEOUT = 10000; @@ -33,7 +32,7 @@ already serving it, then opens the paired editor in the default browser. path File or directory to open (default: $OXBIT_WORKSPACE or .) Options - -p, --port Port to serve on (default: $PORT or ${DEFAULT_PORT}) + -p, --port Port to serve on (default: $PORT or a free port) --host
Address to bind (default: $HOST or ${DEFAULT_HOST}) --desktop Open in the installed Oxbit desktop application --no-open Leave the browser closed @@ -46,7 +45,8 @@ Options export interface Invocation { target: string; - port?: number; + /** 0 asks the OS for a free port. */ + port: number; host: string; open: boolean; foreground: boolean; @@ -95,11 +95,8 @@ export function parse( if (values.desktop && (values.stop || values.status || values.foreground || values["no-open"] || values.port || values.host)) return { error: "--desktop cannot be combined with browser runtime options" }; const requested = text(values.port) ?? env.PORT; - const port = requested === undefined ? undefined : Number(requested); - if ( - port !== undefined && - (!Number.isInteger(port) || port < 0 || port > 65535) - ) + const port = requested === undefined ? 0 : Number(requested); + if (!Number.isInteger(port) || port < 0 || port > 65535) return { error: `Port must be a whole number between 0 and 65535, received "${requested}"`, }; @@ -174,10 +171,7 @@ async function serveRuntime( env: Environment, ) { const host = invocation.host; - // A defaulted port may move aside for whatever holds it; an explicit one fails loudly instead. - const port = - invocation.port ?? - ((await available(host, DEFAULT_PORT)) ? DEFAULT_PORT : 0); + const port = invocation.port; const runtime = await createRuntime({ root: target.root, port, @@ -189,6 +183,7 @@ async function serveRuntime( webRoot: setting("WEB_ROOT", env), }); const daemon: Daemon = { + id: runtime.identity.id, pid: process.pid, host, port: runtime.port, @@ -199,7 +194,7 @@ async function serveRuntime( await writeRecord(dataDir, daemon); const url = launchUrl(daemon, target.file); process.stdout.write( - `Oxbit runtime: http://${host}:${runtime.port}\nWorkspace: ${runtime.root}\nOwner pairing code: ${runtime.pairingCode}\nOpen: ${url}\n`, + `Oxbit runtime: http://${host}:${runtime.port}\nWorkspace: ${runtime.root}\nOwner pairing code: ${runtime.pairingCode}\nRuntime id: ${runtime.identity.id}\nOpen: ${url}\n`, ); if (invocation.open) launchBrowser(url); await new Promise((resolve) => { @@ -224,8 +219,7 @@ async function detach( await fs.mkdir(dataDir, { recursive: true, mode: 0o700 }); const log = await fs.open(logFile(dataDir), "a", 0o600); const args = [entry, "--foreground", "--no-open", "--host", invocation.host]; - if (invocation.port !== undefined) - args.push("--port", String(invocation.port)); + if (invocation.port) args.push("--port", String(invocation.port)); args.push(target.root); // The daemon takes its workspace and port from the arguments, so inherited settings cannot contradict them. const env: Environment = { ...process.env, OXBIT_DATA_DIR: dataDir }; @@ -289,20 +283,11 @@ async function status(dataDir: string, root: string) { return 1; } process.stdout.write( - `Workspace: ${daemon.root}\nRuntime: http://${daemon.host}:${daemon.port}\nProcess: ${daemon.pid}\nOpen: ${launchUrl(daemon)}\n`, + `Workspace: ${daemon.root}\nRuntime: http://${daemon.host}:${daemon.port}\n${daemon.id ? `Runtime id: ${daemon.id}\n` : ""}Process: ${daemon.pid}\nOpen: ${launchUrl(daemon)}\n`, ); return 0; } -async function version() { - try { - const manifest = fileURLToPath(new URL("../package.json", import.meta.url)); - return JSON.parse(await fs.readFile(manifest, "utf8")).version; - } catch { - return "unknown"; - } -} - export interface Context { env?: Environment; cwd?: string; @@ -329,7 +314,7 @@ export async function run( return 0; } if (invocation.action === "version") { - process.stdout.write(`${await version()}\n`); + process.stdout.write(`${runtimeVersion()}\n`); return 0; } let target: Target; diff --git a/apps/runtime/src/daemon.ts b/apps/runtime/src/daemon.ts index 9501d8b6..860aa934 100644 --- a/apps/runtime/src/daemon.ts +++ b/apps/runtime/src/daemon.ts @@ -1,10 +1,11 @@ import * as fs from "node:fs/promises"; -import net from "node:net"; import path from "node:path"; import { spawn } from "node:child_process"; import { setting, workspaceDataDir } from "./branding.js"; export interface Daemon { + /** Runtime identity id; absent in records written before identities existed. */ + id?: string; pid: number; host: string; port: number; @@ -93,21 +94,3 @@ export async function liveDaemon(dataDir: string) { if (!daemon || !running(daemon.pid)) return undefined; return (await serving(daemon)) ? daemon : undefined; } - -export async function available(host: string, port: number) { - const probe = net.createServer(); - try { - await new Promise((resolve, reject) => { - probe.once("error", reject); - probe.listen(port, host, () => { - probe.removeListener("error", reject); - resolve(); - }); - }); - return true; - } catch { - return false; - } finally { - await new Promise((resolve) => probe.close(() => resolve())); - } -} diff --git a/apps/runtime/src/runtime.ts b/apps/runtime/src/runtime.ts index 88d3ffbe..2ff26cef 100644 --- a/apps/runtime/src/runtime.ts +++ b/apps/runtime/src/runtime.ts @@ -21,6 +21,7 @@ import { MAX_BUFFER_BYTES, READ_CHUNK_BYTES, operationMethods, + type RuntimeIdentity, type ServerMessage, } from "@oxbit/protocol"; import { WorkspaceFiles } from "./filesystem.js"; @@ -38,6 +39,7 @@ import { Collaboration } from "./collaboration.js"; import { AgentACP } from "./agent-acp.js"; import type { ACPLaunch } from "@oxbit/sdk"; import { RuntimeExtensions } from "./extensions.js"; +import { runtimeVersion } from "./version.js"; export interface RuntimeOptions { root: string; @@ -140,10 +142,12 @@ export async function createRuntime(options: RuntimeOptions) { inflight = new Map>(), connections = new Map(); let trusted = false, - closed = false; + closed = false, + runtimeId: string = randomUUID(); try { const state = JSON.parse(await fs.readFile(stateFile, "utf8")); trusted = state.trusted === true; + if (typeof state.id === "string" && /^[A-Za-z0-9-]{8,64}$/.test(state.id)) runtimeId = state.id; for (const session of state.sessions ?? []) sessions.set(session.hash, session); for (const operation of state.operations ?? []) { @@ -172,6 +176,7 @@ export async function createRuntime(options: RuntimeOptions) { let persistence = Promise.resolve(); const persist = () => { const payload = JSON.stringify({ + id: runtimeId, trusted, sessions: [...sessions.values()], operations: [...operations.values()], @@ -189,7 +194,8 @@ export async function createRuntime(options: RuntimeOptions) { const pairingAttemptsPerMinute = options.pairingAttemptsPerMinute ?? 10; if (!Number.isInteger(pairingAttemptsPerMinute) || pairingAttemptsPerMinute < 1) throw new Error("Pairing attempts per minute must be a positive whole number"); - let port = options.port ?? 9277; + let port = options.port ?? 0; + const identity: RuntimeIdentity = { id: runtimeId, name: os.hostname(), version: runtimeVersion(), startedAt: Date.now() }; const allowedOrigins = () => new Set([ "tauri://localhost", @@ -443,7 +449,7 @@ export async function createRuntime(options: RuntimeOptions) { } const url = new URL(request.url ?? "/", `http://${host}:${port}`); if (url.pathname === "/api/health") { - httpJson(response, 200, { ok: true, protocol: 1 }); + httpJson(response, 200, { ok: true, protocol: 1, ...identity }); return; } if (url.pathname === "/api/pair" && request.method === "POST") { @@ -478,7 +484,7 @@ export async function createRuntime(options: RuntimeOptions) { "Set-Cookie", `oxbit_session=${result.token}; HttpOnly; SameSite=Strict; Path=/`, ); - httpJson(response, 200, result); + httpJson(response, 200, { ...result, runtime: identity }); return; } if (url.pathname === "/api/grants") { @@ -659,7 +665,7 @@ export async function createRuntime(options: RuntimeOptions) { "Open a new connection to change sessions", ); connection.session = session; - return sessionInfo(session); + return { ...sessionInfo(session), runtime: identity }; } const workspace = params.workspaceId ?? "default"; if (workspace !== "default") @@ -1383,6 +1389,7 @@ export async function createRuntime(options: RuntimeOptions) { }); const address = server.address(); if (address && typeof address === "object") port = address.port; + identity.startedAt = Date.now(); try { startWatcher(setting("WATCH_POLLING") === "1" || setting("WATCH_POLLING") === "true"); } catch (error) { @@ -1391,6 +1398,7 @@ export async function createRuntime(options: RuntimeOptions) { return { server, port, + identity, pairingCode, root, dataDir, diff --git a/apps/runtime/src/version.ts b/apps/runtime/src/version.ts new file mode 100644 index 00000000..6e929f0f --- /dev/null +++ b/apps/runtime/src/version.ts @@ -0,0 +1,16 @@ +import { readFileSync } from "node:fs"; + +declare const __OXBIT_VERSION__: string | undefined; + +let cached: string | undefined; +// Bundles carry the version through a vite define; tsx reads the package manifest. +export function runtimeVersion() { + if (cached) return cached; + if (typeof __OXBIT_VERSION__ === "string") return (cached = __OXBIT_VERSION__); + try { + cached = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version; + } catch { + cached = undefined; + } + return cached ?? "unknown"; +} diff --git a/apps/runtime/tests/cli.test.ts b/apps/runtime/tests/cli.test.ts index b054800d..f20eb7ec 100644 --- a/apps/runtime/tests/cli.test.ts +++ b/apps/runtime/tests/cli.test.ts @@ -40,7 +40,7 @@ describe("argument parsing", () => { it("defaults to the current directory, port and loopback host", () => { expect(parse([], env)).toEqual({ target: ".", - port: undefined, + port: 0, host: DEFAULT_HOST, open: true, foreground: false, diff --git a/apps/runtime/tests/identity.test.ts b/apps/runtime/tests/identity.test.ts new file mode 100644 index 00000000..4f049c8d --- /dev/null +++ b/apps/runtime/tests/identity.test.ts @@ -0,0 +1,81 @@ +import { afterAll, describe, expect, it } from "vitest"; +import * as fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { WebSocket } from "ws"; +import { isRuntimeIdentity } from "@oxbit/protocol"; +import { createRuntime } from "../src/runtime.js"; +import { runtimeVersion } from "../src/version.js"; + +const directories: string[] = []; +afterAll(async () => { + for (const directory of directories) await fs.rm(directory, { recursive: true, force: true }); +}); +async function workspace() { + const directory = await fs.mkdtemp(path.join(await fs.realpath(os.tmpdir()), "oxbit-identity-")); + directories.push(directory); + await fs.mkdir(path.join(directory, "workspace")); + return directory; +} +const start = (directory: string) => + createRuntime({ + root: path.join(directory, "workspace"), + tasksHome: directory, + dataDir: path.join(directory, "state"), + projectsDir: path.join(directory, "projects"), + settingsFile: path.join(directory, "settings.json"), + pairingCode: "identity-code", + }); +function authenticate(port: number, token: string) { + const socket = new WebSocket(`ws://127.0.0.1:${port}/ws`, { origin: `http://127.0.0.1:${port}` }); + return new Promise((resolve, reject) => { + socket.once("error", reject); + socket.once("open", () => socket.send(JSON.stringify({ v: 1, type: "request", id: "auth", method: "auth.authenticate", params: { token } }))); + socket.on("message", (bytes) => { + const message = JSON.parse(bytes.toString()); + if (message.id !== "auth") return; + socket.close(); + if (message.error) reject(message.error); + else resolve(message.result); + }); + }); +} + +describe("runtime identity", () => { + it("binds an OS-assigned port and keeps its id across restarts", async () => { + const directory = await workspace(); + const first = await start(directory); + await first.close(); + const second = await start(directory); + try { + expect(first.port).toBeGreaterThan(0); + expect(isRuntimeIdentity(first.identity)).toBe(true); + expect(second.identity.id).toBe(first.identity.id); + expect(second.identity).toMatchObject({ name: os.hostname(), version: runtimeVersion() }); + expect(runtimeVersion()).toMatch(/^\d+\.\d+\.\d+/); + const state = JSON.parse(await fs.readFile(path.join(directory, "state", "runtime.json"), "utf8")); + expect(state.id).toBe(first.identity.id); + } finally { + await second.close(); + } + }); + + it("serves the identity from health, pairing, and authentication", async () => { + const runtime = await start(await workspace()); + try { + const base = `http://127.0.0.1:${runtime.port}`; + const health = await (await fetch(`${base}/api/health`)).json(); + expect(health).toEqual({ ok: true, protocol: 1, ...runtime.identity }); + expect(health.startedAt).toBeGreaterThan(0); + const pair = await (await fetch(`${base}/api/pair`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: "identity-code" }), + })).json(); + expect(pair.runtime).toEqual(runtime.identity); + expect((await authenticate(runtime.port, pair.token)).runtime).toEqual(runtime.identity); + } finally { + await runtime.close(); + } + }); +}); diff --git a/apps/runtime/vite.config.ts b/apps/runtime/vite.config.ts index 4221fb14..9cf1063d 100644 --- a/apps/runtime/vite.config.ts +++ b/apps/runtime/vite.config.ts @@ -1,6 +1,8 @@ import { chmod } from "node:fs/promises"; import { resolve } from "node:path"; +import { readFileSync } from "node:fs"; import { defineConfig } from "vite"; +const { version } = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")); export default defineConfig({ plugins: [{ name: "runtime-cli-permissions", @@ -11,6 +13,7 @@ export default defineConfig({ await chmod(resolve(options.dir ?? "dist", file.fileName), 0o755); }, }], + define: { __OXBIT_VERSION__: JSON.stringify(version) }, ssr: { noExternal: ["@oxbit/protocol", "@oxbit/sdk", "@oxbit/core", "semver"] }, build: { ssr: "src/index.ts", diff --git a/apps/runtime/vite.desktop.config.ts b/apps/runtime/vite.desktop.config.ts index 5eacfdfb..fefd4065 100644 --- a/apps/runtime/vite.desktop.config.ts +++ b/apps/runtime/vite.desktop.config.ts @@ -1,5 +1,8 @@ +import { readFileSync } from "node:fs"; import { defineConfig } from "vite"; +const { version } = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8")); export default defineConfig({ + define: { __OXBIT_VERSION__: JSON.stringify(version) }, ssr: { noExternal: ["@oxbit/protocol", "@oxbit/sdk", "@oxbit/core", "semver"] }, build: { ssr: "src/desktop.ts", outDir: "dist-desktop", target: "node24", sourcemap: false, From 7903d66333d0c876da48ca14b83b1ad542610deb Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 01:09:56 -0400 Subject: [PATCH 05/37] Add the Runtime page and one-tap cloud button on web New @oxbit/feature-runtime package: Runtime tab, status item, runtime.open and runtime.cloud commands, runtime.keepAlive and runtime.autoReconnect settings. The web app supplies a RuntimeConnector that replaces ConnectionDialog; RuntimeClient stores tokens by runtime id and exposes the runtime identity. --- apps/web/package.json | 1 + .../public/schemas/settings.v1.schema.json | 25 ++ apps/web/src/main.tsx | 233 ++++-------------- apps/web/src/runtime-connector.ts | 139 +++++++++++ apps/web/tsconfig.json | 3 + bun.lock | 22 +- packages/app-workbench/package.json | 1 + packages/app-workbench/src/index.ts | 8 +- packages/app-workbench/tsconfig.json | 3 + packages/features/runtime/package.json | 22 ++ .../features/runtime/src/configuration.ts | 41 +++ packages/features/runtime/src/index.tsx | 77 ++++++ packages/features/runtime/src/page.tsx | 162 ++++++++++++ packages/features/runtime/src/runtime.css | 104 ++++++++ packages/features/runtime/src/runtime.test.ts | 80 ++++++ packages/features/runtime/src/sections.tsx | 140 +++++++++++ packages/features/runtime/src/store.ts | 77 ++++++ packages/features/runtime/tsconfig.json | 30 +++ packages/host-runtime/src/index.test.ts | 39 +++ packages/host-runtime/src/index.ts | 37 ++- packages/sdk/src/settings.schema.json | 25 ++ packages/ui/src/locales/de.json | 56 ++++- packages/ui/src/locales/en.json | 54 +++- packages/ui/src/locales/es.json | 54 +++- packages/ui/src/locales/ja.json | 54 +++- packages/ui/src/locales/zh.json | 54 +++- packages/workbench/src/index.tsx | 50 +++- packages/workbench/src/runtime-connector.ts | 2 + tsconfig.json | 3 + 29 files changed, 1379 insertions(+), 217 deletions(-) create mode 100644 apps/web/src/runtime-connector.ts create mode 100644 packages/features/runtime/package.json create mode 100644 packages/features/runtime/src/configuration.ts create mode 100644 packages/features/runtime/src/index.tsx create mode 100644 packages/features/runtime/src/page.tsx create mode 100644 packages/features/runtime/src/runtime.css create mode 100644 packages/features/runtime/src/runtime.test.ts create mode 100644 packages/features/runtime/src/sections.tsx create mode 100644 packages/features/runtime/src/store.ts create mode 100644 packages/features/runtime/tsconfig.json diff --git a/apps/web/package.json b/apps/web/package.json index 37a8847e..d83d0717 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,7 @@ "@oxbit/feature-editor": "workspace:*", "@oxbit/feature-explorer": "workspace:*", "@oxbit/feature-settings": "workspace:*", + "@oxbit/feature-runtime": "workspace:*", "@oxbit/feature-extensions": "workspace:*", "@oxbit/feature-themes": "workspace:*", "@oxbit/feature-language": "workspace:*", diff --git a/apps/web/public/schemas/settings.v1.schema.json b/apps/web/public/schemas/settings.v1.schema.json index 893e9e93..926a1d6b 100644 --- a/apps/web/public/schemas/settings.v1.schema.json +++ b/apps/web/public/schemas/settings.v1.schema.json @@ -65,6 +65,25 @@ "emacs" ] }, + "runtime.keepAlive": { + "title": "Keep Runtime Alive", + "description": "How long a runtime Oxbit started keeps running after the last client disconnects or its connection heartbeats stop. Terminals and tasks continue while it runs.", + "type": "string", + "default": "75s", + "enum": [ + "75s", + "15m", + "1h", + "8h", + "untilStopped" + ] + }, + "runtime.autoReconnect": { + "title": "Reconnect Automatically", + "description": "Reconnect to the runtime after the connection drops, including when the iOS app returns to the foreground.", + "type": "boolean", + "default": true + }, "workbench.tooltipDelay": { "title": "Tooltip Delay", "description": "Delay in milliseconds before showing a tooltip on hover. Keyboard focus shows tooltips immediately.", @@ -690,6 +709,12 @@ "workbench.keymap": { "$ref": "#/properties/workbench.keymap" }, + "runtime.keepAlive": { + "$ref": "#/properties/runtime.keepAlive" + }, + "runtime.autoReconnect": { + "$ref": "#/properties/runtime.autoReconnect" + }, "workbench.tooltipDelay": { "$ref": "#/properties/workbench.tooltipDelay" }, diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 4ddb728d..9f3d53fb 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -11,13 +11,12 @@ import { import { RuntimeClient, RuntimeFileSystem } from "@oxbit/host-runtime"; import { Workbench, - WorkbenchController, workspaceEntries, themeMode, themeVariables, currentTheme, } from "@oxbit/workbench"; -import { Dialog, OxbitLogo } from "@oxbit/ui"; +import { OxbitLogo } from "@oxbit/ui"; import { type FileSystem, type FileSystemProvider, @@ -25,6 +24,7 @@ import { import seed from "./seed.json"; import { WorkspaceDialog } from "./workspace-dialog"; import { createWorkbenchSession, type Session } from "@oxbit/app-workbench"; +import { WebRuntimeConnector, describeFailure } from "./runtime-connector"; import "@oxbit/ui/tokens.css"; import "@oxbit/ui/workbench.css"; (globalThis as any).__OXBIT_REACT__ = ReactHost; @@ -33,6 +33,11 @@ const persistence = new IndexedDBPersistence(); let live: Session | undefined; let bootQueue: Promise = Promise.resolve(); const browserFilesystem = new BrowserFileSystem(persistence, "browser"); +let installRuntimeSession: (session: Session) => void = () => {}; +const connector = new WebRuntimeConnector({ + persistence, + open: async runtime => installRuntimeSession(await boot(new RuntimeFileSystem(runtime), runtime)), +}); // The oxbit command opens a URL carrying the owner pairing code and the file to focus. Consume them once // so a reload does not mint a second owner session, and so they stop trailing the address bar. function takeLaunchParams() { @@ -43,8 +48,8 @@ function takeLaunchParams() { history.replaceState(null, "", location.pathname + location.search); return { pair, open }; } -async function openRuntime(url: string, pairingCode?: string) { - const existing = new RuntimeClient(url); +async function openRuntime(url: string, pairingCode?: string, runtimeId?: string) { + const existing = new RuntimeClient(url, "default", { runtimeId }); try { // Sessions outlive a runtime restart, so reuse one before spending the code on a duplicate grant. // A restricted grant already held by this tab must not shadow an owner pairing code. @@ -80,7 +85,7 @@ function boot( } async function openSession(filesystem: FileSystem, runtime?: RuntimeClient): Promise { await live?.persist(); - const next = await createWorkbenchSession({ filesystem, runtime, persistence, preserveFilesystem: filesystem === browserFilesystem }); + const next = await createWorkbenchSession({ filesystem, runtime, persistence, preserveFilesystem: filesystem === browserFilesystem, runtimeConnector: connector }); await live?.dispose(); live = next; return next; @@ -89,9 +94,10 @@ async function openSession(filesystem: FileSystem, runtime?: RuntimeClient): Pro function App() { const [session, setSession] = useState(), [error, setError] = useState(""), - [overlay, setOverlay] = useState<"connection" | "workspace">(), + [overlay, setOverlay] = useState<"workspace">(), [loading, setLoading] = useState(true); const installSession = useCallback((s: Session) => { + connector.attach(s); setSession(s); setLoading(false); (globalThis as any).__oxbit = { @@ -118,25 +124,11 @@ function App() { setLoading(false); } }, [installSession]); - const connectRuntime = useCallback( - async (url: string, code: string) => { - setLoading(true); - const runtime = new RuntimeClient(url); - try { - await runtime.pair(code); - await runtime.connect(); - const s = await boot(new RuntimeFileSystem(runtime), runtime); - await persistence.set("last-host", { url }); - installSession(s); - setOverlay(undefined); - } catch (e) { - runtime.dispose(); - setLoading(false); - throw e; - } - }, - [installSession], - ); + installRuntimeSession = (s: Session) => { + installSession(s); + setOverlay(undefined); + }; + const connectRuntime = useCallback((url: string, code: string) => connector.pair(url, code), []); useEffect(() => { let stopped = false; const start = async () => { @@ -144,18 +136,24 @@ function App() { let saved = await persistence.get("last-host"); let runtime: RuntimeClient | undefined; let pairingError = ""; + let reconnectError = ""; if (pairingCode || saved?.url) try { runtime = await openRuntime( pairingCode ? location.origin : saved.url, pairingCode, + pairingCode ? undefined : saved.runtimeId, ); - if (pairingCode) { - saved = { url: runtime.url }; + if (pairingCode || runtime.identity?.id !== saved?.runtimeId) { + saved = { url: runtime.url, runtimeId: runtime.identity?.id }; await persistence.set("last-host", saved); } } catch (failure) { - if (pairingCode) pairingError = String(failure); + const url = pairingCode ? location.origin : saved.url; + const message = await describeFailure(url, failure); + if (pairingCode) pairingError = message; + else reconnectError = message; + connector.store.fail(message); } const directory = saved === "directory" ? await restoreDirectory(persistence) : undefined; @@ -168,10 +166,13 @@ function App() { ) await browserFilesystem.import(seed); let next = await boot(filesystem, runtime); - if (pairingError) + if (pairingError || reconnectError) next.workbench.notify( - tr("Runtime pairing failed: {0}", { 0: pairingError }), + pairingError + ? tr("Runtime pairing failed: {0}", { 0: pairingError }) + : tr("Could not reconnect to the runtime: {0}", { 0: reconnectError }), "error", + { actions: [{ title: tr("Open Runtime"), command: "runtime.open" }] }, ); if (saved === "directory" && !directory) next.workbench.notify( @@ -244,7 +245,7 @@ function App() { }), add("workspace.browser", "Open Browser Workspace", useBrowserWorkspace), add("workspace.runtime", "Connect Runtime", () => - setOverlay("connection"), + session.workbench.run("runtime.cloud"), ), add("workspace.export", "Export Browser Workspace", async () => { const files = []; @@ -305,9 +306,8 @@ function App() { session={session} overlay={overlay} close={() => setOverlay(undefined)} - connectRuntime={connectRuntime} useBrowserWorkspace={useBrowserWorkspace} - onConnect={() => setOverlay("connection")} + onConnect={() => void session.workbench.run("runtime.cloud")} onOpenWorkspace={() => setOverlay("workspace")} openProvider={async (id) => { const contribution = session.kernel.contributions @@ -356,7 +356,6 @@ function SessionView({ session, overlay, close, - connectRuntime, useBrowserWorkspace, onConnect, onOpenWorkspace, @@ -365,9 +364,8 @@ function SessionView({ importWorkspace, }: { session: Session; - overlay?: "connection" | "workspace"; + overlay?: "workspace"; close: () => void; - connectRuntime: (url: string, code: string) => Promise; useBrowserWorkspace: () => Promise; onConnect: () => void; onOpenWorkspace: () => void; @@ -399,163 +397,18 @@ function SessionView({ fontSize: "var(--font-body-font-size)", }} > - {overlay === "connection" ? ( - - ) : ( - - )} + { close(); void session.workbench.run("runtime.open"); }} + openProvider={openProvider} + importWorkspace={importWorkspace} + /> )} ); } -function ConnectionDialog({ - runtime, - close, - connect, - workbench, -}: { - runtime?: RuntimeClient; - close: () => void; - connect: (url: string, code: string) => Promise; - workbench: WorkbenchController; -}) { - const [url, setUrl] = useState(runtime?.url || location.origin), - [code, setCode] = useState(""), - [error, setError] = useState(""), - [busy, setBusy] = useState(false), - [trusted, setTrusted] = useState(!!runtime?.session?.trusted); - useEffect(() => { - const off = runtime?.subscribe("workspace.trust", ({ trusted }) => { - setTrusted(trusted === true); - }); - return () => { off?.(); }; - }, [runtime]); - const submit = async (e: React.FormEvent) => { - e.preventDefault(); - setBusy(true); - setError(""); - try { - await connect(url, code); - } catch (error) { - setError(error instanceof Error ? error.message : String(error)); - } finally { - setBusy(false); - } - }; - const toggleTrust = async () => { - if (!runtime || busy) return; - setBusy(true); - setError(""); - try { - await runtime.trust(!trusted); - setTrusted(!!runtime.session?.trusted); - workbench.kernel.context.set("trusted", !!runtime.session?.trusted); - workbench.touch(); - } catch (error) { - setError(error instanceof Error ? error.message : String(error)); - } finally { - setBusy(false); - } - }; - return ( - - {runtime?.connected ? ( -
-

- {tr("Connected to")} {runtime.url}. -

-

- {tr("Workspace tool execution:")}{" "} - {trusted ? tr("Trusted") : tr("Restricted")} -

-

- {tr( - "Trust allows terminals, tasks, Git hooks and language servers to execute workspace code.", - )} -

- - - {error && ( -

- {error} -

- )} -
- ) : ( -
void submit(e)}> -

- {tr("Start the Node runtime and enter its owner pairing code.")} -

- - - {error && ( -

- {error} -

- )} -
- - -
-
- )} -
- ); -} createRoot(document.getElementById("root")!).render(); diff --git a/apps/web/src/runtime-connector.ts b/apps/web/src/runtime-connector.ts new file mode 100644 index 00000000..754d5479 --- /dev/null +++ b/apps/web/src/runtime-connector.ts @@ -0,0 +1,139 @@ +import { translate as tr } from "@oxbit/ui"; +import type { Persistence } from "@oxbit/sdk"; +import { RuntimeClient } from "@oxbit/host-runtime"; +import { AUTO_RECONNECT_SETTING, RuntimeStatusStore, clientStatus } from "@oxbit/feature-runtime"; +import type { RuntimeConnector, RuntimeTarget } from "@oxbit/workbench"; +import type { Session } from "@oxbit/app-workbench"; + +const SAVED_KEY = "runtimes"; +const LIMIT = 12; + +export interface WebRuntimeHost { + persistence: Persistence; + /** Boots a workbench session on the client and makes it current. */ + open(runtime: RuntimeClient): Promise; +} + +export const targetKey = (url: string, runtimeId?: string) => runtimeId ? "url:" + runtimeId : "url:" + url; + +/** Explains a failed connection with what the address actually answered. */ +export async function describeFailure(url: string, error: unknown) { + const text = error instanceof Error ? error.message : String(error); + if (!/cannot connect|connection closed|timed out|failed to fetch/i.test(text)) return text; + try { + const health = await fetch(url + "/api/health", { signal: AbortSignal.timeout(4000) }); + if (!health.ok) return tr("{0} answered HTTP {1}; it is not an Oxbit runtime.", { 0: url, 1: health.status }); + return tr("The runtime at {0} is running but refused this browser. Pair it again with its pairing code.", { 0: url }); + } catch { + // Another origin's health reply is unreadable without CORS, but an opaque reply still proves something listens. + const reachable = await fetch(url + "/api/health", { mode: "no-cors", signal: AbortSignal.timeout(4000) }).then(() => true, () => false); + return reachable + ? tr("{0} is reachable but did not accept this page. Open Oxbit from the address the runtime prints, or pair it again.", { 0: url }) + : tr("No runtime answered at {0}. Start one with oxbit and check the address.", { 0: url }); + } +} + +/** One per page load; workbench sessions come and go as runtimes are connected. */ +export class WebRuntimeConnector implements RuntimeConnector { + readonly store = new RuntimeStatusStore(); + readonly startCommand = "oxbit --no-open"; + private session?: Session; + private detach: (() => void)[] = []; + constructor(private readonly host: WebRuntimeHost) {} + status = this.store.get; + subscribe = this.store.subscribe; + + /** Follows the runtime of the session now on screen. */ + attach(session: Session) { + for (const off of this.detach) off(); + this.session = session; + const runtime = session.runtime; + if (!runtime) { + this.detach = []; + this.store.replace({ state: "disconnected", error: this.store.get().error }); + return; + } + const sync = () => this.store.replace({ + state: runtime.connected ? "connected" : this.store.get().state === "failed" ? "failed" : "reconnecting", + ...clientStatus(runtime, "url"), targetKey: targetKey(runtime.url, runtime.identity?.id), + }); + const applySetting = () => { runtime.autoReconnect = session.kernel.configuration.get(AUTO_RECONNECT_SETTING) !== false; }; + applySetting(); + sync(); + this.detach = [ + runtime.subscribe("connection.change", ({ state }) => state === "disconnected" ? this.store.set({ state: "disconnected" }) : sync()), + runtime.subscribe("workspace.trust", ({ trusted }) => this.store.set({ trusted: trusted === true })), + session.kernel.configuration.subscribe(applySetting), + ]; + } + + quickTarget(): RuntimeTarget | undefined { + const runtime = this.session?.runtime; + if (runtime) return { key: targetKey(runtime.url, runtime.identity?.id), kind: "url", name: runtime.identity?.name ?? new URL(runtime.url).host, url: runtime.url, runtimeId: runtime.identity?.id }; + return this.saved[0]; + } + private saved: RuntimeTarget[] = []; + async load() { + const stored = await this.host.persistence.get(SAVED_KEY); + const last = await this.host.persistence.get<{ url?: string; runtimeId?: string }>("last-host"); + this.saved = Array.isArray(stored) ? stored : []; + if (!this.saved.length && last?.url) + this.saved = [{ key: targetKey(last.url, last.runtimeId), kind: "url", name: new URL(last.url).host, url: last.url, runtimeId: last.runtimeId }]; + return this.saved; + } + recents = () => this.load(); + async forget(target: RuntimeTarget) { + this.saved = (await this.load()).filter(item => item.key !== target.key && item.url !== target.url); + await this.host.persistence.set(SAVED_KEY, this.saved); + } + private async remember(runtime: RuntimeClient) { + const id = runtime.identity?.id; + const entry: RuntimeTarget = { + key: targetKey(runtime.url, id), kind: "url", runtimeId: id, url: runtime.url, lastConnected: Date.now(), + name: runtime.identity?.name ?? new URL(runtime.url).host, detail: runtime.session?.workspaceName ? `${runtime.session.workspaceName} Ā· ${runtime.url}` : runtime.url, + }; + this.saved = [entry, ...(await this.load()).filter(item => item.key !== entry.key && item.url !== entry.url && (!id || item.runtimeId !== id))].slice(0, LIMIT); + await this.host.persistence.set(SAVED_KEY, this.saved); + await this.host.persistence.set("last-host", { url: runtime.url, runtimeId: id }); + } + + private async establish(url: string, label: string, start: (runtime: RuntimeClient) => Promise, runtimeId?: string) { + this.store.replace({ ...this.store.get(), state: "connecting", progress: tr("Connecting to {0}…", { 0: label }), error: undefined }); + const runtime = new RuntimeClient(url.replace(/\/$/, ""), "default", { runtimeId }); + try { + await start(runtime); + this.store.progress(tr("Opening the workspace…")); + await this.host.open(runtime); + await this.remember(runtime); + } catch (error) { + runtime.dispose(); + this.store.fail(await describeFailure(runtime.url, error)); + throw error; + } + } + connect(target: RuntimeTarget) { + if (!target.url) return Promise.reject(new Error(tr("This runtime has no address. Pair it again."))); + return this.establish(target.url, target.name, runtime => runtime.connect(), target.runtimeId); + } + pair(url: string, code: string) { + let address: URL; + try { address = new URL(url); } catch { return Promise.reject(new Error(tr("Enter the runtime's http:// or https:// address."))); } + return this.establish(address.origin, address.host, async runtime => { + await runtime.pair(code); + await runtime.connect(); + }); + } + async disconnect() { + this.session?.runtime?.disconnect(); + this.store.set({ state: "disconnected", progress: undefined }); + this.session?.workbench.touch(); + } + async trust(trusted: boolean) { + const runtime = this.session?.runtime; + if (!runtime) return; + await runtime.trust(trusted); + this.store.set({ trusted: !!runtime.session?.trusted }); + this.session!.kernel.context.set("trusted", !!runtime.session?.trusted); + this.session!.workbench.touch(); + } +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 542b0531..4b125746 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -47,6 +47,9 @@ { "path": "../../packages/features/settings" }, + { + "path": "../../packages/features/runtime" + }, { "path": "../../packages/features/extensions" }, diff --git a/bun.lock b/bun.lock index c26de945..aef2409d 100644 --- a/bun.lock +++ b/bun.lock @@ -31,7 +31,7 @@ }, "apps/desktop": { "name": "@oxbit/desktop", - "version": "0.3.0-alpha.3", + "version": "0.4.1", "dependencies": { "@oxbit/app-workbench": "workspace:*", "@oxbit/host-browser": "workspace:*", @@ -52,7 +52,7 @@ }, "apps/ios": { "name": "@oxbit/ios", - "version": "0.3.0-alpha.3", + "version": "0.4.1", "dependencies": { "@oxbit/app-workbench": "workspace:*", "@oxbit/host-browser": "workspace:*", @@ -71,7 +71,7 @@ }, "apps/runtime": { "name": "@oxbit/runtime", - "version": "0.3.0-alpha.3", + "version": "0.4.1", "dependencies": { "@oxbit/core": "workspace:*", "@oxbit/protocol": "workspace:*", @@ -109,6 +109,7 @@ "@oxbit/feature-git": "workspace:*", "@oxbit/feature-language": "workspace:*", "@oxbit/feature-previews": "workspace:*", + "@oxbit/feature-runtime": "workspace:*", "@oxbit/feature-search": "workspace:*", "@oxbit/feature-settings": "workspace:*", "@oxbit/feature-tasks": "workspace:*", @@ -152,6 +153,7 @@ "@oxbit/feature-keymaps": "workspace:*", "@oxbit/feature-language": "workspace:*", "@oxbit/feature-previews": "workspace:*", + "@oxbit/feature-runtime": "workspace:*", "@oxbit/feature-search": "workspace:*", "@oxbit/feature-settings": "workspace:*", "@oxbit/feature-tasks": "workspace:*", @@ -346,6 +348,18 @@ "react-dom": "19.1.0", }, }, + "packages/features/runtime": { + "name": "@oxbit/feature-runtime", + "version": "1.0.0", + "dependencies": { + "@oxbit/protocol": "workspace:*", + "@oxbit/sdk": "workspace:*", + "@oxbit/ui": "workspace:*", + "@oxbit/workbench": "workspace:*", + "react": "19.1.0", + "react-dom": "19.1.0", + }, + }, "packages/features/search": { "name": "@oxbit/feature-search", "version": "1.0.0", @@ -806,6 +820,8 @@ "@oxbit/feature-previews": ["@oxbit/feature-previews@workspace:packages/features/previews"], + "@oxbit/feature-runtime": ["@oxbit/feature-runtime@workspace:packages/features/runtime"], + "@oxbit/feature-search": ["@oxbit/feature-search@workspace:packages/features/search"], "@oxbit/feature-settings": ["@oxbit/feature-settings@workspace:packages/features/settings"], diff --git a/packages/app-workbench/package.json b/packages/app-workbench/package.json index 77f9c965..860ec489 100644 --- a/packages/app-workbench/package.json +++ b/packages/app-workbench/package.json @@ -19,6 +19,7 @@ "@oxbit/feature-editor": "workspace:*", "@oxbit/feature-explorer": "workspace:*", "@oxbit/feature-settings": "workspace:*", + "@oxbit/feature-runtime": "workspace:*", "@oxbit/feature-extensions": "workspace:*", "@oxbit/feature-themes": "workspace:*", "@oxbit/feature-keymaps": "workspace:*", diff --git a/packages/app-workbench/src/index.ts b/packages/app-workbench/src/index.ts index cbdcf203..53a6a1b1 100644 --- a/packages/app-workbench/src/index.ts +++ b/packages/app-workbench/src/index.ts @@ -3,7 +3,7 @@ import { translate as tr } from "@oxbit/ui"; import { createKernel } from "@oxbit/core"; import { DocumentService } from "@oxbit/documents"; import { RuntimeClient } from "@oxbit/host-runtime"; -import { WorkbenchController, createWorkbenchFeature } from "@oxbit/workbench"; +import { WorkbenchController, createWorkbenchFeature, RUNTIME_CONNECTOR_SERVICE, type RuntimeConnector } from "@oxbit/workbench"; import { languageForKernel, type FileSystem, @@ -14,6 +14,7 @@ import { import { createFeature as editorFeature } from "@oxbit/feature-editor"; import { createFeature as explorerFeature } from "@oxbit/feature-explorer"; import { createFeature as settingsFeature } from "@oxbit/feature-settings"; +import { createFeature as runtimeFeature } from "@oxbit/feature-runtime"; import { createFeature as extensionsFeature } from "@oxbit/feature-extensions"; import { createFeature as themesFeature, @@ -86,6 +87,7 @@ export async function createWorkbenchSession({ preserveFilesystem = false, additionalExtensionOrigins, iconPackStore, + runtimeConnector, }: { filesystem: FileSystem; runtime?: RuntimeClient; @@ -96,6 +98,8 @@ export async function createWorkbenchSession({ preserveFilesystem?: boolean; additionalExtensionOrigins?: readonly string[]; iconPackStore?: PackStore; + /** Backs the Runtime page and the one-tap cloud button. */ + runtimeConnector?: RuntimeConnector; }): Promise { const configurationPersistence = new ScopedConfigurationPersistence( persistence, @@ -153,6 +157,7 @@ export async function createWorkbenchSession({ kernel.services.register("filesystem", filesystem); kernel.services.register("persistence", persistence); if (runtime) kernel.services.register("runtime", runtime); + if (runtimeConnector) kernel.services.register(RUNTIME_CONNECTOR_SERVICE, runtimeConnector); kernel.context.set("workspace", true); kernel.context.set("connected", !!runtime?.connected); kernel.context.set("trusted", !!runtime?.session?.trusted); @@ -179,6 +184,7 @@ export async function createWorkbenchSession({ }); const features = [ settingsFeature(options), + runtimeFeature(options), themesFeature(options), createVSCodeFeature(options), createVSCodeHighContrastFeature(options), diff --git a/packages/app-workbench/tsconfig.json b/packages/app-workbench/tsconfig.json index 5c1c6657..24b4d484 100644 --- a/packages/app-workbench/tsconfig.json +++ b/packages/app-workbench/tsconfig.json @@ -47,6 +47,9 @@ { "path": "../features/settings" }, + { + "path": "../features/runtime" + }, { "path": "../features/extensions" }, diff --git a/packages/features/runtime/package.json b/packages/features/runtime/package.json new file mode 100644 index 00000000..879248d9 --- /dev/null +++ b/packages/features/runtime/package.json @@ -0,0 +1,22 @@ +{ + "name": "@oxbit/feature-runtime", + "version": "1.0.0", + "private": true, + "type": "module", + "exports": "./src/index.tsx", + "scripts": {}, + "dependencies": { + "@oxbit/sdk": "workspace:*", + "@oxbit/protocol": "workspace:*", + "react": "19.1.0", + "react-dom": "19.1.0", + "@oxbit/ui": "workspace:*", + "@oxbit/workbench": "workspace:*" + }, + "license": "MIT", + "author": { + "name": "Ryan Yannelli", + "email": "ryanyannelli@gmail.com", + "url": "https://github.com/yannelli" + } +} diff --git a/packages/features/runtime/src/configuration.ts b/packages/features/runtime/src/configuration.ts new file mode 100644 index 00000000..9935d37d --- /dev/null +++ b/packages/features/runtime/src/configuration.ts @@ -0,0 +1,41 @@ +import type { Setting } from "@oxbit/sdk"; + +export const KEEP_ALIVE_SETTING = "runtime.keepAlive"; +export const AUTO_RECONNECT_SETTING = "runtime.autoReconnect"; + +const keepAliveDurations = { + "75s": 75_000, + "15m": 15 * 60_000, + "1h": 60 * 60_000, + "8h": 8 * 60 * 60_000, + untilStopped: 0, +} as const; +export type KeepAlive = keyof typeof keepAliveDurations; +export const keepAliveOptions = Object.keys(keepAliveDurations) as KeepAlive[]; + +/** Milliseconds for the launch frame; 0 runs until stopped. Unknown values use the default. */ +export function keepAliveMs(value: unknown): number { + return keepAliveDurations[(value as KeepAlive) in keepAliveDurations ? (value as KeepAlive) : "75s"]; +} + +export const runtimeConfiguration: Setting[] = [ + { + id: KEEP_ALIVE_SETTING, + title: "Keep Runtime Alive", + description: + "How long a runtime Oxbit started keeps running after the last client disconnects or its connection heartbeats stop. Terminals and tasks continue while it runs.", + type: "string", + category: "Runtime", + default: "75s", + enum: keepAliveOptions, + }, + { + id: AUTO_RECONNECT_SETTING, + title: "Reconnect Automatically", + description: + "Reconnect to the runtime after the connection drops, including when the iOS app returns to the foreground.", + type: "boolean", + category: "Runtime", + default: true, + }, +]; diff --git a/packages/features/runtime/src/index.tsx b/packages/features/runtime/src/index.tsx new file mode 100644 index 00000000..566bbdf6 --- /dev/null +++ b/packages/features/runtime/src/index.tsx @@ -0,0 +1,77 @@ +import { useSyncExternalStore } from "react"; +import { Icon, translate as tr } from "@oxbit/ui"; +import type { Extension, Kernel } from "@oxbit/sdk"; +import { RUNTIME_CONNECTOR_SERVICE, type RuntimeConnector, type RuntimeStatus, type WorkbenchController } from "@oxbit/workbench"; +import { runtimeConfiguration } from "./configuration.js"; +import { RuntimePage } from "./page.js"; +import type { RuntimeSettingsAccess } from "./sections.js"; +import { oneTap } from "./store.js"; +import "./runtime.css"; + +export { RuntimePage } from "./page.js"; +export type { RuntimeSettingsAccess } from "./sections.js"; +export { RuntimeStatusStore, clientStatus, oneTap, formatUptime, type RuntimeClientLike } from "./store.js"; +export { runtimeConfiguration, keepAliveMs, keepAliveOptions, KEEP_ALIVE_SETTING, AUTO_RECONNECT_SETTING, type KeepAlive } from "./configuration.js"; + +const connectorOf = (kernel: Kernel) => kernel.services.optional(RUNTIME_CONNECTOR_SERVICE); +const idle: RuntimeStatus = { state: "disconnected" }; +const noSubscription = () => () => {}; + +export function kernelSettings(kernel: Kernel): RuntimeSettingsAccess { + return { + get: id => kernel.configuration.get(id), + set: (id, value) => kernel.configuration.set(id, value as never), + }; +} + +function RuntimeTab({ kernel }: { kernel: Kernel; workbench: WorkbenchController }) { + const connector = connectorOf(kernel); + if (!connector) + return

{tr("This window has no runtime connection.")}

; + return ; +} + +function RuntimeStatusItem({ kernel, workbench }: { kernel: Kernel; workbench: WorkbenchController }) { + const connector = connectorOf(kernel); + const status = useSyncExternalStore(connector?.subscribe ?? noSubscription, connector?.status ?? (() => idle)); + const busy = status.state === "connecting" || status.state === "reconnecting"; + const label = status.state === "connected" ? status.name ?? tr("Connected") + : busy ? tr("Connecting…") : status.state === "failed" ? tr("Connection failed") : tr("Local"); + return ( + + ); +} + +export function createFeature({ kernel, workbench }: { kernel: Kernel; workbench: WorkbenchController }): Extension { + const open = () => void workbench.openView("runtime", "Runtime", RuntimeTab, {}, { contributionId: "runtime" }); + return { + manifest: { + manifestVersion: 1, + id: "oxbit.runtime", + name: "Runtime", + version: "1.0.0", + sdk: "^1.0.0", + description: "Connect, pair, and keep runtimes alive from one page.", + environments: ["browser", "embedded"], + activation: ["*"], + capabilities: [], + configuration: runtimeConfiguration.map(setting => ({ ...setting, enum: setting.enum?.slice() })), + }, + activate(ctx) { + ctx.own(ctx.contributions.register({ id: "runtime", kind: "tab", title: "Runtime", component: RuntimeTab })); + ctx.own(ctx.contributions.register({ + id: "runtime.status", kind: "statusItem", title: "Runtime connection", + component: () => , + })); + ctx.own(ctx.commands.register({ id: "runtime.open", title: "Open Runtime", category: "Runtime", run: open })); + ctx.own(ctx.commands.register({ + id: "runtime.cloud", title: "Connect Runtime", category: "Runtime", + run: () => oneTap(connectorOf(kernel), open), + })); + }, + }; +} diff --git a/packages/features/runtime/src/page.tsx b/packages/features/runtime/src/page.tsx new file mode 100644 index 00000000..c797dccf --- /dev/null +++ b/packages/features/runtime/src/page.tsx @@ -0,0 +1,162 @@ +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { Icon, translate as tr } from "@oxbit/ui"; +import type { RuntimeConnector, RuntimeStatus } from "@oxbit/workbench"; +import { Card, DiscoveredRuntimes, KeepAliveSettings, SavedRuntimes, SshServers, type RuntimeSettingsAccess } from "./sections.js"; +import { formatUptime } from "./store.js"; + +const stateLabels: Record = { + connected: "Connected", + connecting: "Connecting…", + reconnecting: "Reconnecting…", + disconnected: "Not connected", + failed: "Connection failed", +}; +const stateIcons: Record = { + connected: "cloudCheck", + connecting: "sync", + reconnecting: "sync", + disconnected: "cloud", + failed: "cloudOff", +}; + +export function RuntimePage({ connector, settings, onClose }: { + connector: RuntimeConnector; + settings?: RuntimeSettingsAccess; + onClose?: () => void; +}) { + const status = useSyncExternalStore(connector.subscribe, connector.status); + const [busy, setBusy] = useState(); + const [actionError, setActionError] = useState(); + const [, tick] = useState(0); + const pairCode = useRef(null); + const [pairUrl, setPairUrl] = useState(""); + useEffect(() => { + const timer = setInterval(() => tick(value => value + 1), 30_000); + return () => clearInterval(timer); + }, []); + useEffect(() => { + if (!pairUrl && status.url && status.kind !== "local") setPairUrl(status.url); + }, [status.url, status.kind, pairUrl]); + const perform = useCallback((key: string, action: () => Promise) => { + if (busy) return; + setBusy(key); + setActionError(undefined); + void action().catch(error => setActionError(error instanceof Error ? error.message : String(error))).finally(() => setBusy(undefined)); + }, [busy]); + const connected = status.state === "connected"; + const quick = connected ? undefined : connector.quickTarget(); + const uptime = connected ? formatUptime(status.startedAt) : undefined; + const pairWith = (url: string) => { + setPairUrl(url); + pairCode.current?.scrollIntoView({ block: "center", behavior: "smooth" }); + pairCode.current?.focus(); + }; + const facts: [string, string | undefined][] = [ + [tr("Host"), status.host], + [tr("Port"), status.port ? String(status.port) : undefined], + [tr("Version"), status.version], + [tr("Uptime"), uptime], + [tr("Workspace"), status.workspace], + ]; + return ( +
+
+
+
+
+

{tr("Runtime")}

+ {onClose && } +
+

+ {status.name ?? quick?.name ?? tr("No runtime connected")} + {tr(stateLabels[status.state])} +

+ {status.progress && status.state !== "connected" &&

} + {facts.some(([, value]) => value) && ( +
+ {facts.filter(([, value]) => value).map(([label, value]) =>
{label}
{value}
)} +
+ )} +
+ {connected ? <> + {connector.disconnect && } + {connector.restart && } + : quick ? ( + + ) : ( + + )} + {!connected && connector.restart && status.kind && } +
+
+
+ {(status.error && status.state !== "connected" || actionError) && ( +
+ +
+ {actionError ?? status.error!.message} + {!actionError && status.error?.detail && ( +
{tr("Details")}
{status.error.detail}
+ )} +
+
+ )} +
+ {connected && connector.trust && } + {settings && } + + {connector.discover && } + {connector.sshHosts && connector.startSsh && } + +
+
+ ); +} + +function TrustCard({ status, busy, perform, trust }: { + status: RuntimeStatus; busy?: string; perform: (key: string, action: () => Promise) => void; trust: (value: boolean) => Promise; +}) { + return ( + +

{tr("Trust allows terminals, tasks, Git hooks and language servers to execute workspace code.")}

+ + {status.owner === false &&

{tr("Only the workspace owner can change trust.")}

} +
+ ); +} + +function PairCard({ connector, url, setUrl, codeRef, busy, perform }: { + connector: RuntimeConnector; url: string; setUrl: (value: string) => void; codeRef: React.RefObject; + busy?: string; perform: (key: string, action: () => Promise) => void; +}) { + const [code, setCode] = useState(""); + return ( + +
{ event.preventDefault(); perform("pair", async () => { await connector.pair(url.trim(), code.trim()); setCode(""); }); }}> + {connector.startCommand && <> +

{tr("Start a runtime on your computer, then enter its address and owner pairing code.")}

+ {connector.startCommand} + } + + +
+ +
+
+
+ ); +} diff --git a/packages/features/runtime/src/runtime.css b/packages/features/runtime/src/runtime.css new file mode 100644 index 00000000..2e61225e --- /dev/null +++ b/packages/features/runtime/src/runtime.css @@ -0,0 +1,104 @@ +.runtime-page { + height: 100%; + overflow: auto; + box-sizing: border-box; + padding: 24px 28px 48px; +} +.runtime-page > * { max-width: 1080px; } +.runtime-hero { display: flex; gap: 20px; margin-bottom: 20px; } +.runtime-hero-icon { + width: 80px; height: 80px; flex: none; display: grid; place-items: center; + border-radius: 10px; background: var(--accent-soft); color: var(--accent); +} +.runtime-hero[data-state=connected] .runtime-hero-icon { color: var(--ok); } +.runtime-hero[data-state=failed] .runtime-hero-icon { color: var(--err); } +.runtime-hero:is([data-state=connecting],[data-state=reconnecting]) .runtime-hero-icon svg { animation: runtime-spin 1.2s linear infinite; } +.runtime-hero-body { flex: 1; min-width: 0; } +.runtime-title-row { display: flex; align-items: center; gap: 8px; } +.runtime-title-row > h1 { flex: 1; margin: 0; } +.runtime-name { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 6px 0 10px; color: var(--fg-2); overflow-wrap: anywhere; } +.runtime-badge { + display: inline-flex; align-items: center; gap: 6px; padding: 1px 8px; border-radius: 999px; + border: 1px solid var(--bd-strong); color: var(--fg-2); font-size: var(--font-small-font-size); white-space: nowrap; +} +.runtime-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--fg-3); } +.runtime-badge[data-state=connected]::before { background: var(--ok); } +.runtime-badge:is([data-state=connecting],[data-state=reconnecting])::before { background: var(--warn); } +.runtime-badge[data-state=failed]::before { background: var(--err); } +.runtime-facts { display: flex; flex-wrap: wrap; gap: 6px 22px; margin: 0 0 14px; } +.runtime-facts > div { min-width: 0; } +.runtime-facts dt { color: var(--fg-3); font-size: var(--font-small-font-size); } +.runtime-facts dd { margin: 2px 0 0; font-family: var(--font-mono); font-size: var(--font-mono-font-size); overflow-wrap: anywhere; } +.runtime-progress, .runtime-searching { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; color: var(--fg-2); } +.runtime-spinner { + width: 12px; height: 12px; flex: none; border-radius: 50%; + border: 2px solid var(--bd-strong); border-top-color: var(--accent); animation: runtime-spin 0.9s linear infinite; +} +@keyframes runtime-spin { to { transform: rotate(360deg); } } +.runtime-error { + display: flex; gap: 12px; align-items: flex-start; margin-bottom: 20px; padding: 12px 14px; + border: 1px solid var(--err); border-radius: 8px; background: color-mix(in srgb, var(--err) 10%, transparent); +} +.runtime-error > svg { color: var(--err); flex: none; margin-top: 2px; } +.runtime-error > div { min-width: 0; flex: 1; } +.runtime-error summary { margin-top: 6px; cursor: pointer; color: var(--fg-2); } +.runtime-error pre { + margin: 8px 0 0; max-height: 240px; overflow: auto; padding: 8px 10px; border-radius: 6px; + background: var(--bg-editor); font-family: var(--font-mono); font-size: var(--font-mono-font-size); white-space: pre-wrap; overflow-wrap: anywhere; +} +.runtime-sections { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; align-items: start; } +.runtime-card { + display: flex; flex-direction: column; gap: 10px; min-width: 0; padding: 14px 16px; + border: 1px solid var(--bd); border-radius: 8px; background: var(--bg-surface); +} +.runtime-card.wide { grid-column: 1 / -1; } +.runtime-card > h2 { + display: flex; align-items: center; gap: 8px; margin: 0; color: var(--fg-2); + font-size: var(--font-label-font-size); font-weight: var(--font-label-font-weight); text-transform: uppercase; letter-spacing: 0.04em; +} +.runtime-card .custom-select { width: 100%; max-width: 320px; } +.runtime-hint { margin: 0; color: var(--fg-3); font-size: var(--font-small-font-size); } +.runtime-switch { display: flex; align-items: center; gap: 10px; min-height: 28px; cursor: pointer; } +.runtime-switch > input { + appearance: none; -webkit-appearance: none; flex: none; position: relative; margin: 0; width: 34px; height: 20px; + border-radius: 999px; background: var(--bd-strong); transition: background 0.15s; cursor: inherit; +} +.runtime-switch > input::after { + content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; + background: var(--bg-editor); transition: transform 0.15s; +} +.runtime-switch > input:checked { background: var(--accent); } +.runtime-switch > input:checked::after { transform: translateX(14px); } +.runtime-switch > input:disabled { opacity: 0.5; } +.runtime-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; } +.runtime-list > li { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-top: 1px solid var(--bd); } +.runtime-list > li:first-child { border-top: 0; } +.runtime-list > li > svg { color: var(--fg-3); flex: none; } +.runtime-list > li[aria-current=true] > svg { color: var(--ok); } +.runtime-list-text { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow-wrap: anywhere; } +.runtime-list-text > small { color: var(--fg-3); font-size: var(--font-small-font-size); } +.runtime-ssh { flex-wrap: wrap; } +.runtime-ssh > input { flex: 1 1 140px; min-width: 0; height: 28px; font-family: var(--font-mono); } +.runtime-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 10px 16px; align-items: end; } +.runtime-pair > :is(p, .runtime-command, .toolbar) { grid-column: 1 / -1; } +.runtime-pair label { display: flex; flex-direction: column; gap: 4px; color: var(--fg-2); } +.runtime-pair input { height: 30px; } +.runtime-command { + display: block; padding: 8px 10px; border-radius: 6px; background: var(--bg-editor); + font-family: var(--font-mono); font-size: var(--font-mono-font-size); user-select: text; -webkit-user-select: text; overflow-wrap: anywhere; +} +.runtime-status-item[data-state=connected] { color: var(--fg); } +.runtime-status-item[data-state=failed] > svg { color: var(--err); } +.runtime-status-item[aria-busy=true] > svg, .connection-button[aria-busy=true] > svg { animation: runtime-spin 1.2s linear infinite; } + +@media (max-width: 599px) { + .runtime-page { padding: 16px 12px calc(32px + env(safe-area-inset-bottom)); } + .runtime-hero { gap: 12px; } + .runtime-hero-icon { width: 48px; height: 48px; border-radius: 8px; } + .runtime-hero-icon svg { width: 28px; height: 28px; } + .runtime-title-row > h1 { font-size: calc(var(--font-heading-font-size) * 0.85); } + .runtime-sections { grid-template-columns: 1fr; gap: 12px; } + .runtime-hero .button, .runtime-card .button { min-height: 40px; } + .runtime-list > li { padding: 10px 0; } + .runtime-pair input, .runtime-ssh > input { height: 40px; } +} diff --git a/packages/features/runtime/src/runtime.test.ts b/packages/features/runtime/src/runtime.test.ts new file mode 100644 index 00000000..d860f188 --- /dev/null +++ b/packages/features/runtime/src/runtime.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; +import type { RuntimeConnector, RuntimeStatus, RuntimeTarget } from "@oxbit/workbench"; +import { keepAliveMs, keepAliveOptions } from "./configuration.js"; +import { RuntimeStatusStore, clientStatus, formatUptime, oneTap } from "./store.js"; + +const target: RuntimeTarget = { key: "url:abc12345", kind: "url", name: "studio", url: "http://192.168.1.5:50123", runtimeId: "abc12345" }; + +function connector(state: RuntimeStatus["state"], quick?: RuntimeTarget, connect = vi.fn(async () => {})): RuntimeConnector { + return { + status: () => ({ state }), + subscribe: () => () => {}, + quickTarget: () => quick, + connect, + pair: vi.fn(), + recents: async () => [], + }; +} + +describe("keep-alive setting", () => { + it("maps every option to milliseconds and falls back to the default", () => { + expect(keepAliveOptions).toEqual(["75s", "15m", "1h", "8h", "untilStopped"]); + expect(keepAliveMs("15m")).toBe(900_000); + expect(keepAliveMs("untilStopped")).toBe(0); + expect(keepAliveMs("bogus")).toBe(75_000); + expect(keepAliveMs(undefined)).toBe(75_000); + }); +}); + +describe("one-tap cloud", () => { + it("opens the page when connected", async () => { + const open = vi.fn(); + const runtime = connector("connected", target); + await oneTap(runtime, open); + expect(open).toHaveBeenCalledOnce(); + expect(runtime.connect).not.toHaveBeenCalled(); + }); + it("connects to the known target without opening the page", async () => { + const open = vi.fn(); + const runtime = connector("disconnected", target); + await oneTap(runtime, open); + expect(runtime.connect).toHaveBeenCalledWith(target); + expect(open).not.toHaveBeenCalled(); + }); + it("opens the page when the connection fails", async () => { + const open = vi.fn(); + await oneTap(connector("failed", target, vi.fn(async () => { throw new Error("refused"); })), open); + expect(open).toHaveBeenCalledOnce(); + }); + it("opens the page when nothing is known or there is no connector", async () => { + const open = vi.fn(); + await oneTap(connector("disconnected"), open); + await oneTap(undefined, open); + expect(open).toHaveBeenCalledTimes(2); + }); +}); + +describe("status store", () => { + it("splits runtime output from a failure and notifies listeners", () => { + const store = new RuntimeStatusStore(); + const listener = vi.fn(); + store.subscribe(listener); + store.fail(new Error("Runtime startup failed\n--- runtime output ---\nError: Cannot find module 'node-pty'")); + expect(store.get()).toMatchObject({ state: "failed", error: { message: "Runtime startup failed", detail: "Error: Cannot find module 'node-pty'" } }); + expect(listener).toHaveBeenCalledOnce(); + }); + it("describes a client from its URL and identity", () => { + expect(clientStatus({ + url: "http://10.0.0.4:51234", connected: true, + identity: { id: "abc12345", name: "studio", version: "0.4.2", startedAt: 1 }, + session: { trusted: true, owner: true, workspaceName: "oxbit" }, + }, "url")).toMatchObject({ host: "10.0.0.4", port: 51234, name: "studio", version: "0.4.2", runtimeId: "abc12345", workspace: "oxbit", trusted: true }); + }); + it("formats uptime", () => { + expect(formatUptime(undefined)).toBeUndefined(); + expect(formatUptime(0, 30_000)).toBeUndefined(); + expect(formatUptime(1, 30_000)).toBe("<1m"); + expect(formatUptime(1, 1 + 125 * 60_000)).toBe("2h 5m"); + expect(formatUptime(1, 1 + 26 * 3_600_000)).toBe("1d 2h"); + }); +}); diff --git a/packages/features/runtime/src/sections.tsx b/packages/features/runtime/src/sections.tsx new file mode 100644 index 00000000..776a06ec --- /dev/null +++ b/packages/features/runtime/src/sections.tsx @@ -0,0 +1,140 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { Icon, Select, translate as tr } from "@oxbit/ui"; +import type { DiscoveredRuntime, RuntimeConnector, RuntimeTarget, SshHostSummary } from "@oxbit/workbench"; +import { AUTO_RECONNECT_SETTING, KEEP_ALIVE_SETTING, keepAliveOptions } from "./configuration.js"; + +/** Reads and writes runtime settings with or without a workbench kernel. */ +export interface RuntimeSettingsAccess { + get(id: string): T | undefined; + set(id: string, value: unknown): void | Promise; +} + +export type Perform = (key: string, action: () => Promise) => void; + +export function Card({ title, icon, children, wide = false }: { title: string; icon: string; children: ReactNode; wide?: boolean }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +export function KeepAliveSettings({ settings }: { settings: RuntimeSettingsAccess }) { + const [keepAlive, setKeepAlive] = useState(String(settings.get(KEEP_ALIVE_SETTING) ?? "75s")); + const [reconnect, setReconnect] = useState(settings.get(AUTO_RECONNECT_SETTING) !== false); + return ( + +

{tr("How long a runtime Oxbit started keeps running after the last client disconnects. Terminals and tasks continue while it runs.")}

+ { setReconnect(event.target.checked); void settings.set(AUTO_RECONNECT_SETTING, event.target.checked); }} /> + {tr("Reconnect Automatically")} + +
+ ); +} + +export function SavedRuntimes({ connector, current, busy, perform }: { connector: RuntimeConnector; current?: string; busy?: string; perform: Perform }) { + const [targets, setTargets] = useState(); + useEffect(() => { + let live = true; + void connector.recents().then(value => { if (live) setTargets(value); }, () => { if (live) setTargets([]); }); + return () => { live = false; }; + }, [connector, current]); + if (!targets?.length) return null; + return ( + +
    + {targets.map(target => ( +
  • + + {target.name}{target.detail ?? target.url ?? target.path} + {target.key === current ? {tr("In use")} : ( + + )} + {connector.forget && target.key !== current && ( + + )} +
  • + ))} +
+
+ ); +} + +export function DiscoveredRuntimes({ connector, busy, perform, onPair }: { connector: RuntimeConnector; busy?: string; perform: Perform; onPair: (url: string) => void }) { + const [found, setFound] = useState([]); + useEffect(() => connector.discover?.(setFound), [connector]); + return ( + + {found.length ? ( +
    + {found.map(runtime => { + const key = "lan:" + runtime.runtimeId; + return ( +
  • + + {runtime.name}{`${runtime.host}:${runtime.port}${runtime.version ? ` Ā· v${runtime.version}` : ""}`} + +
  • + ); + })} +
+ ) : ( +

+ )} +

{tr("Run oxbit --lan on your computer to list it here.")}

+
+ ); +} + +export function SshServers({ connector, busy, perform }: { connector: RuntimeConnector; busy?: string; perform: Perform }) { + const [hosts, setHosts] = useState(); + const [paths, setPaths] = useState>({}); + useEffect(() => { + let live = true; + void connector.sshHosts?.().then(value => { if (live) setHosts(value); }, () => { if (live) setHosts([]); }); + return () => { live = false; }; + }, [connector]); + return ( + + {hosts?.length ? ( +
    + {hosts.map(host => { + const key = "ssh:" + host.id, path = paths[host.id] ?? host.lastPath ?? "~"; + return ( +
  • + {host.label}{host.detail} + setPaths(current => ({ ...current, [host.id]: event.target.value }))} /> + +
  • + ); + })} +
+ ) : hosts &&

{tr("No SSH hosts saved yet.")}

} + {connector.manageSsh && } +
+ ); +} diff --git a/packages/features/runtime/src/store.ts b/packages/features/runtime/src/store.ts new file mode 100644 index 00000000..298bc2cb --- /dev/null +++ b/packages/features/runtime/src/store.ts @@ -0,0 +1,77 @@ +import { splitRuntimeError, type RuntimeIdentity } from "@oxbit/protocol"; +import type { RuntimeConnector, RuntimeStatus } from "@oxbit/workbench"; + +/** Observable connection status an app keeps across workbench sessions. */ +export class RuntimeStatusStore { + private listeners = new Set<() => void>(); + private value: RuntimeStatus = { state: "disconnected" }; + get = () => this.value; + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + }; + set(patch: Partial) { + this.value = { ...this.value, ...patch }; + for (const listener of [...this.listeners]) listener(); + } + replace(next: RuntimeStatus) { + this.value = next; + for (const listener of [...this.listeners]) listener(); + } + progress(message: string) { + this.set({ state: "connecting", progress: message }); + } + fail(error: unknown) { + const { message, detail } = splitRuntimeError(error instanceof Error ? error.message : String(error)); + this.set({ state: "failed", progress: undefined, error: { message, detail, at: Date.now() } }); + } +} + +/** The parts of RuntimeClient the page reads; structural so apps can pass any client. */ +export interface RuntimeClientLike { + url: string; + connected: boolean; + identity?: RuntimeIdentity; + session?: { trusted: boolean; owner: boolean; workspaceName?: string }; +} + +export function clientStatus(client: RuntimeClientLike, kind: RuntimeStatus["kind"], name?: string): Partial { + let url: URL | undefined; + try { url = new URL(client.url); } catch { url = undefined; } + return { + kind, + url: client.url, + host: url?.hostname, + port: url ? Number(url.port || (url.protocol === "https:" ? 443 : 80)) : undefined, + name: client.identity?.name ?? name ?? url?.hostname, + version: client.identity?.version, + startedAt: client.identity?.startedAt, + runtimeId: client.identity?.id, + workspace: client.session?.workspaceName, + trusted: client.session?.trusted, + owner: client.session?.owner, + }; +} + +/** Cloud button: open the page when connected or nothing is known; otherwise connect, opening the page on failure. */ +export async function oneTap(connector: RuntimeConnector | undefined, open: () => void) { + const status = connector?.status(); + const target = connector?.quickTarget(); + if (!connector || !target || status?.state === "connected" || status?.state === "connecting") { + open(); + return; + } + try { + await connector.connect(target); + } catch { + open(); + } +} + +export function formatUptime(startedAt: number | undefined, now = Date.now()) { + if (!startedAt || startedAt > now) return undefined; + const minutes = Math.floor((now - startedAt) / 60_000); + if (minutes < 1) return "<1m"; + const days = Math.floor(minutes / 1440), hours = Math.floor((minutes % 1440) / 60), rest = minutes % 60; + return days ? `${days}d ${hours}h` : hours ? `${hours}h ${rest}m` : `${rest}m`; +} diff --git a/packages/features/runtime/tsconfig.json b/packages/features/runtime/tsconfig.json new file mode 100644 index 00000000..166329e8 --- /dev/null +++ b/packages/features/runtime/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist-types", + "tsBuildInfoFile": "dist-types/tsconfig.tsbuildinfo" + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.json" + ], + "exclude": [ + "src/**/*.test.ts" + ], + "references": [ + { + "path": "../../../packages/sdk" + }, + { + "path": "../../../packages/protocol" + }, + { + "path": "../../../packages/ui" + }, + { + "path": "../../workbench" + } + ] +} diff --git a/packages/host-runtime/src/index.test.ts b/packages/host-runtime/src/index.test.ts index 52912e80..188f8777 100644 --- a/packages/host-runtime/src/index.test.ts +++ b/packages/host-runtime/src/index.test.ts @@ -78,3 +78,42 @@ describe('desktop credentials and recovery identity', () => { a.dispose(); b.dispose(); }); }); + +describe('runtime identity', () => { + class IdentitySocket extends Socket { + send(raw: string) { + const message = JSON.parse(raw); this.sent.push(message); + if (message.method === 'auth.authenticate') queueMicrotask(() => this.receive({ v: 1, type: 'response', id: message.id, result: { workspaceId: 'default', trusted: false, owner: true, capabilities: [], runtime: { id: 'runtime-1234', name: 'studio', version: '0.4.2', startedAt: 5 } } })); + } + } + it('moves a URL-keyed token to the runtime id so a new port finds it', async () => { + const store = new Map([['oxbit.runtime.token:http://10.0.0.4:50001', 'saved-token']]); + vi.stubGlobal('sessionStorage', { getItem: (key: string) => store.get(key) ?? null, setItem: (key: string, value: string) => store.set(key, value), removeItem: (key: string) => store.delete(key) }); + vi.stubGlobal('WebSocket', IdentitySocket); + const first = new RuntimeClient('http://10.0.0.4:50001'); + await first.connect(); + expect(first.identity).toMatchObject({ id: 'runtime-1234', name: 'studio' }); + expect(store.get('oxbit.runtime.token:id:runtime-1234')).toBe('saved-token'); + expect(store.has('oxbit.runtime.token:http://10.0.0.4:50001')).toBe(false); + first.dispose(); + const moved = new RuntimeClient('http://10.0.0.4:50999', 'default', { runtimeId: 'runtime-1234' }); + await moved.connect(); + expect(Socket.instances.at(-1)!.sent[0].params.token).toBe('saved-token'); + moved.dispose(); + }); + it('stays disconnected after a drop when automatic reconnects are off', async () => { + vi.useFakeTimers(); vi.stubGlobal('WebSocket', Socket); + const client = new RuntimeClient('http://runtime.test', 'default', { persistToken: false }); + await client.connect(); + client.autoReconnect = false; + const states: string[] = []; + client.subscribe('connection.change', ({ state }) => states.push(state)); + Socket.instances[0].close(); + await vi.advanceTimersByTimeAsync(20000); + expect(states).toEqual(['disconnected']); + expect(Socket.instances).toHaveLength(1); + await client.reconnect(); + expect(client.connected).toBe(true); + client.dispose(); + }); +}); diff --git a/packages/host-runtime/src/index.ts b/packages/host-runtime/src/index.ts index ce1d72ce..7763f024 100644 --- a/packages/host-runtime/src/index.ts +++ b/packages/host-runtime/src/index.ts @@ -7,7 +7,8 @@ import type { FileChange, Disposable, } from "@oxbit/sdk"; -import { RpcError, MAX_MESSAGE_BYTES, MAX_BUFFER_BYTES, READ_CHUNK_BYTES, operationMethods, type ServerMessage } from "@oxbit/protocol"; +import { RpcError, MAX_MESSAGE_BYTES, MAX_BUFFER_BYTES, READ_CHUNK_BYTES, operationMethods, isRuntimeIdentity, type RuntimeIdentity, type ServerMessage } from "@oxbit/protocol"; +const TOKEN_KEY = "oxbit.runtime.token:"; type Pending = { resolve: (v: any) => void; reject: (e: unknown) => void; @@ -18,6 +19,8 @@ export interface RuntimeClientOptions { token?: string; /** Desktop credentials never touch WebView storage. Browser pairing keeps its default. */ persistToken?: boolean; + /** Reads the token saved for this runtime id before the URL-keyed one. */ + runtimeId?: string; } export class RuntimeClient implements RpcClient { private socket?: WebSocket; @@ -32,6 +35,9 @@ export class RuntimeClient implements RpcClient { private recoveringOperations = false; private opening?: Promise; connected = false; + /** When false, a dropped connection stays disconnected until connect() runs again. */ + autoReconnect = true; + identity?: RuntimeIdentity; session?: { token: string; workspaceId: string; @@ -48,13 +54,22 @@ export class RuntimeClient implements RpcClient { private readonly options: RuntimeClientOptions = {}, ) { this.url = url.replace(/\/$/, ""); - this.token = options.token ?? (options.persistToken === false ? undefined : - globalThis.sessionStorage?.getItem("oxbit.runtime.token:" + this.url) ?? undefined); + const storage = options.persistToken === false ? undefined : globalThis.sessionStorage; + this.token = options.token ?? (options.runtimeId ? storage?.getItem(TOKEN_KEY + "id:" + options.runtimeId) : undefined) ?? + storage?.getItem(TOKEN_KEY + this.url) ?? undefined; } private rememberToken(token: string) { this.token = token; - if (this.options.persistToken !== false) - globalThis.sessionStorage?.setItem("oxbit.runtime.token:" + this.url, token); + this.persistToken(); + } + /** Moves a URL-keyed token to the runtime id, so a new port finds it. */ + private persistToken() { + const storage = this.options.persistToken === false ? undefined : globalThis.sessionStorage; + if (!storage || !this.token) return; + if (this.identity) { + storage.setItem(TOKEN_KEY + "id:" + this.identity.id, this.token); + storage.removeItem(TOKEN_KEY + this.url); + } else storage.setItem(TOKEN_KEY + this.url, this.token); } async pair(code: string) { if (code.startsWith("grant:")) { @@ -72,6 +87,7 @@ export class RuntimeClient implements RpcClient { if (!r.ok) throw new Error(data.error?.message ?? data.error ?? "Pairing failed"); this.session = data; + if (isRuntimeIdentity(data.runtime)) this.identity = data.runtime; this.rememberToken(data.token); return data; } @@ -101,6 +117,10 @@ export class RuntimeClient implements RpcClient { clearTimeout(timer); this.connected = true; this.retries = 0; + if (isRuntimeIdentity(session.runtime)) { + this.identity = session.runtime; + this.persistToken(); + } this.session = { token: this.token ?? "", workspaceId: String(session.workspaceId ?? this.workspaceId), @@ -168,7 +188,7 @@ export class RuntimeClient implements RpcClient { ws.onclose = (event) => { clearTimeout(timer); if (this.socket !== ws) return; - if (event.code === 4003) this.stopped = true; + if (event.code === 4003 || !this.autoReconnect) this.stopped = true; this.connected = false; this.opening = undefined; for (const [id, p] of this.pending) { @@ -307,6 +327,11 @@ export class RuntimeClient implements RpcClient { } } } + /** Skips the backoff wait, for a return to the foreground or a user retry. */ + reconnect() { + this.retries = 0; + return this.connected ? Promise.resolve() : this.connect(); + } disconnect() { this.stopped = true; clearTimeout(this.retry); diff --git a/packages/sdk/src/settings.schema.json b/packages/sdk/src/settings.schema.json index 893e9e93..926a1d6b 100644 --- a/packages/sdk/src/settings.schema.json +++ b/packages/sdk/src/settings.schema.json @@ -65,6 +65,25 @@ "emacs" ] }, + "runtime.keepAlive": { + "title": "Keep Runtime Alive", + "description": "How long a runtime Oxbit started keeps running after the last client disconnects or its connection heartbeats stop. Terminals and tasks continue while it runs.", + "type": "string", + "default": "75s", + "enum": [ + "75s", + "15m", + "1h", + "8h", + "untilStopped" + ] + }, + "runtime.autoReconnect": { + "title": "Reconnect Automatically", + "description": "Reconnect to the runtime after the connection drops, including when the iOS app returns to the foreground.", + "type": "boolean", + "default": true + }, "workbench.tooltipDelay": { "title": "Tooltip Delay", "description": "Delay in milliseconds before showing a tooltip on hover. Keyboard focus shows tooltips immediately.", @@ -690,6 +709,12 @@ "workbench.keymap": { "$ref": "#/properties/workbench.keymap" }, + "runtime.keepAlive": { + "$ref": "#/properties/runtime.keepAlive" + }, + "runtime.autoReconnect": { + "$ref": "#/properties/runtime.autoReconnect" + }, "workbench.tooltipDelay": { "$ref": "#/properties/workbench.tooltipDelay" }, diff --git a/packages/ui/src/locales/de.json b/packages/ui/src/locales/de.json index 71471920..a217bfed 100644 --- a/packages/ui/src/locales/de.json +++ b/packages/ui/src/locales/de.json @@ -780,5 +780,59 @@ "de": "Deutsch", "es": "Spanisch", "ja": "Japanisch", - "zh": "Chinesisch (vereinfacht)" + "zh": "Chinesisch (vereinfacht)", + "Host": "Host", + "Port": "Port", + "Version": "Version", + "Uptime": "Laufzeit", + "Runtime": "Runtime", + "Done": "Fertig", + "No runtime connected": "Keine Runtime verbunden", + "Restarting…": "Wird neu gestartet…", + "Restart": "Neu starten", + "Connect to {0}": "Mit {0} verbinden", + "Pair a runtime": "Runtime koppeln", + "Workspace trust": "Vertrauen des Arbeitsbereichs", + "Only the workspace owner can change trust.": "Nur der Besitzer des Arbeitsbereichs kann das Vertrauen Ƥndern.", + "Pair with a runtime": "Mit einer Runtime koppeln", + "Start a runtime on your computer, then enter its address and owner pairing code.": "Starten Sie eine Runtime auf Ihrem Computer und geben Sie dann ihre Adresse und den Besitzer-Kopplungscode ein.", + "Keep alive": "Aktiv halten", + "How long a runtime Oxbit started keeps running after the last client disconnects. Terminals and tasks continue while it runs.": "Wie lange eine von Oxbit gestartete Runtime weiterlƤuft, nachdem sich der letzte Client getrennt hat. Terminals und Aufgaben laufen in dieser Zeit weiter.", + "Keep Runtime Alive": "Runtime aktiv halten", + "Reconnect Automatically": "Automatisch neu verbinden", + "Saved runtimes": "Gespeicherte Runtimes", + "Forget {0}": "{0} vergessen", + "In use": "Aktiv", + "On this network": "In diesem Netzwerk", + "Pair": "Koppeln", + "Looking for runtimes on this network…": "Runtimes in diesem Netzwerk werden gesucht…", + "Run oxbit --lan on your computer to list it here.": "Führen Sie oxbit --lan auf Ihrem Computer aus, damit er hier erscheint.", + "SSH servers": "SSH-Server", + "Folder on {0}": "Ordner auf {0}", + "Starting…": "Wird gestartet…", + "Start Oxbit on this server": "Oxbit auf diesem Server starten", + "No SSH hosts saved yet.": "Noch keine SSH-Hosts gespeichert.", + "Manage SSH hosts…": "SSH-Hosts verwalten…", + "This window has no runtime connection.": "Dieses Fenster hat keine Runtime-Verbindung.", + "{0} answered HTTP {1}; it is not an Oxbit runtime.": "{0} antwortete mit HTTP {1}; das ist keine Oxbit-Runtime.", + "The runtime at {0} is running but refused this browser. Pair it again with its pairing code.": "Die Runtime unter {0} lƤuft, hat diesen Browser aber abgelehnt. Koppeln Sie sie erneut mit ihrem Kopplungscode.", + "{0} is reachable but did not accept this page. Open Oxbit from the address the runtime prints, or pair it again.": "{0} ist erreichbar, hat diese Seite aber nicht akzeptiert. Ɩffnen Sie Oxbit über die Adresse, die die Runtime ausgibt, oder koppeln Sie sie erneut.", + "No runtime answered at {0}. Start one with oxbit and check the address.": "Unter {0} hat keine Runtime geantwortet. Starten Sie eine mit oxbit und prüfen Sie die Adresse.", + "Connecting to {0}…": "Verbindung mit {0} wird hergestellt…", + "Opening the workspace…": "Arbeitsbereich wird geƶffnet…", + "This runtime has no address. Pair it again.": "Diese Runtime hat keine Adresse. Koppeln Sie sie erneut.", + "Enter the runtime's http:// or https:// address.": "Geben Sie die http://- oder https://-Adresse der Runtime ein.", + "Could not reconnect to the runtime: {0}": "Erneute Verbindung zur Runtime fehlgeschlagen: {0}", + "Open Runtime": "Runtime ƶffnen", + "Reconnecting…": "Verbindung wird wiederhergestellt…", + "Not connected": "Nicht verbunden", + "Connect": "Verbinden", + "Connection failed": "Verbindung fehlgeschlagen", + "75s": "75 Sekunden", + "15m": "15 Minuten", + "1h": "1 Stunde", + "8h": "8 Stunden", + "untilStopped": "Bis zum Beenden", + "How long a runtime Oxbit started keeps running after the last client disconnects or its connection heartbeats stop. Terminals and tasks continue while it runs.": "Wie lange eine von Oxbit gestartete Runtime weiterlƤuft, nachdem sich der letzte Client getrennt hat oder ihre Verbindungssignale ausbleiben. Terminals und Aufgaben laufen in dieser Zeit weiter.", + "Reconnect to the runtime after the connection drops, including when the iOS app returns to the foreground.": "Nach einem Verbindungsabbruch erneut mit der Runtime verbinden, auch wenn die iOS-App in den Vordergrund zurückkehrt." } diff --git a/packages/ui/src/locales/en.json b/packages/ui/src/locales/en.json index 46164a89..c03819d2 100644 --- a/packages/ui/src/locales/en.json +++ b/packages/ui/src/locales/en.json @@ -808,5 +808,57 @@ "de": "Deutsch", "es": "Spanish", "ja": "Japanese", - "zh": "Chinese (Simplified)" + "zh": "Chinese (Simplified)", + "Host": "Host", + "Port": "Port", + "Version": "Version", + "Uptime": "Uptime", + "Runtime": "Runtime", + "Done": "Done", + "No runtime connected": "No runtime connected", + "Restarting…": "Restarting…", + "Restart": "Restart", + "Connect to {0}": "Connect to {0}", + "Pair a runtime": "Pair a runtime", + "Workspace trust": "Workspace trust", + "Only the workspace owner can change trust.": "Only the workspace owner can change trust.", + "Pair with a runtime": "Pair with a runtime", + "Start a runtime on your computer, then enter its address and owner pairing code.": "Start a runtime on your computer, then enter its address and owner pairing code.", + "Keep alive": "Keep alive", + "How long a runtime Oxbit started keeps running after the last client disconnects. Terminals and tasks continue while it runs.": "How long a runtime Oxbit started keeps running after the last client disconnects. Terminals and tasks continue while it runs.", + "Keep Runtime Alive": "Keep Runtime Alive", + "Reconnect Automatically": "Reconnect Automatically", + "Saved runtimes": "Saved runtimes", + "Forget {0}": "Forget {0}", + "In use": "In use", + "On this network": "On this network", + "Pair": "Pair", + "Looking for runtimes on this network…": "Looking for runtimes on this network…", + "Run oxbit --lan on your computer to list it here.": "Run oxbit --lan on your computer to list it here.", + "SSH servers": "SSH servers", + "Folder on {0}": "Folder on {0}", + "Starting…": "Starting…", + "Start Oxbit on this server": "Start Oxbit on this server", + "No SSH hosts saved yet.": "No SSH hosts saved yet.", + "Manage SSH hosts…": "Manage SSH hosts…", + "This window has no runtime connection.": "This window has no runtime connection.", + "{0} answered HTTP {1}; it is not an Oxbit runtime.": "{0} answered HTTP {1}; it is not an Oxbit runtime.", + "The runtime at {0} is running but refused this browser. Pair it again with its pairing code.": "The runtime at {0} is running but refused this browser. Pair it again with its pairing code.", + "{0} is reachable but did not accept this page. Open Oxbit from the address the runtime prints, or pair it again.": "{0} is reachable but did not accept this page. Open Oxbit from the address the runtime prints, or pair it again.", + "No runtime answered at {0}. Start one with oxbit and check the address.": "No runtime answered at {0}. Start one with oxbit and check the address.", + "Connecting to {0}…": "Connecting to {0}…", + "Opening the workspace…": "Opening the workspace…", + "This runtime has no address. Pair it again.": "This runtime has no address. Pair it again.", + "Enter the runtime's http:// or https:// address.": "Enter the runtime's http:// or https:// address.", + "Could not reconnect to the runtime: {0}": "Could not reconnect to the runtime: {0}", + "Open Runtime": "Open Runtime", + "Reconnecting…": "Reconnecting…", + "Not connected": "Not connected", + "75s": "75 seconds", + "15m": "15 minutes", + "1h": "1 hour", + "8h": "8 hours", + "untilStopped": "Until stopped", + "How long a runtime Oxbit started keeps running after the last client disconnects or its connection heartbeats stop. Terminals and tasks continue while it runs.": "How long a runtime Oxbit started keeps running after the last client disconnects or its connection heartbeats stop. Terminals and tasks continue while it runs.", + "Reconnect to the runtime after the connection drops, including when the iOS app returns to the foreground.": "Reconnect to the runtime after the connection drops, including when the iOS app returns to the foreground." } diff --git a/packages/ui/src/locales/es.json b/packages/ui/src/locales/es.json index bc31d33c..d763d79c 100644 --- a/packages/ui/src/locales/es.json +++ b/packages/ui/src/locales/es.json @@ -808,5 +808,57 @@ "de": "AlemĆ”n", "es": "EspaƱol", "ja": "JaponĆ©s", - "zh": "Chino (simplificado)" + "zh": "Chino (simplificado)", + "Host": "Host", + "Port": "Puerto", + "Version": "Versión", + "Uptime": "Tiempo activo", + "Runtime": "Runtime", + "Done": "Listo", + "No runtime connected": "NingĆŗn runtime conectado", + "Restarting…": "Reiniciando…", + "Restart": "Reiniciar", + "Connect to {0}": "Conectar a {0}", + "Pair a runtime": "Vincular un runtime", + "Workspace trust": "Confianza del espacio de trabajo", + "Only the workspace owner can change trust.": "Solo el propietario del espacio de trabajo puede cambiar la confianza.", + "Pair with a runtime": "Vincular con un runtime", + "Start a runtime on your computer, then enter its address and owner pairing code.": "Inicia un runtime en tu ordenador y escribe su dirección y el código de vinculación del propietario.", + "Keep alive": "Mantener activo", + "How long a runtime Oxbit started keeps running after the last client disconnects. Terminals and tasks continue while it runs.": "CuĆ”nto tiempo sigue en marcha un runtime iniciado por Oxbit despuĆ©s de que se desconecte el Ćŗltimo cliente. Los terminales y las tareas continĆŗan mientras se ejecuta.", + "Keep Runtime Alive": "Mantener el runtime activo", + "Reconnect Automatically": "Reconectar automĆ”ticamente", + "Saved runtimes": "Runtimes guardados", + "Forget {0}": "Olvidar {0}", + "In use": "En uso", + "On this network": "En esta red", + "Pair": "Vincular", + "Looking for runtimes on this network…": "Buscando runtimes en esta red…", + "Run oxbit --lan on your computer to list it here.": "Ejecuta oxbit --lan en tu ordenador para que aparezca aquĆ­.", + "SSH servers": "Servidores SSH", + "Folder on {0}": "Carpeta en {0}", + "Starting…": "Iniciando…", + "Start Oxbit on this server": "Iniciar Oxbit en este servidor", + "No SSH hosts saved yet.": "AĆŗn no hay hosts SSH guardados.", + "Manage SSH hosts…": "Gestionar hosts SSH…", + "This window has no runtime connection.": "Esta ventana no tiene conexión con un runtime.", + "{0} answered HTTP {1}; it is not an Oxbit runtime.": "{0} respondió HTTP {1}; no es un runtime de Oxbit.", + "The runtime at {0} is running but refused this browser. Pair it again with its pairing code.": "El runtime en {0} estĆ” en marcha pero rechazó este navegador. VincĆŗlalo de nuevo con su código.", + "{0} is reachable but did not accept this page. Open Oxbit from the address the runtime prints, or pair it again.": "{0} es accesible pero no aceptó esta pĆ”gina. Abre Oxbit desde la dirección que muestra el runtime o vuelve a vincularlo.", + "No runtime answered at {0}. Start one with oxbit and check the address.": "NingĆŗn runtime respondió en {0}. Inicia uno con oxbit y comprueba la dirección.", + "Connecting to {0}…": "Conectando a {0}…", + "Opening the workspace…": "Abriendo el espacio de trabajo…", + "This runtime has no address. Pair it again.": "Este runtime no tiene dirección. VincĆŗlalo de nuevo.", + "Enter the runtime's http:// or https:// address.": "Escribe la dirección http:// o https:// del runtime.", + "Could not reconnect to the runtime: {0}": "No se pudo volver a conectar al runtime: {0}", + "Open Runtime": "Abrir runtime", + "Reconnecting…": "Reconectando…", + "Not connected": "Sin conexión", + "75s": "75 segundos", + "15m": "15 minutos", + "1h": "1 hora", + "8h": "8 horas", + "untilStopped": "Hasta que se detenga", + "How long a runtime Oxbit started keeps running after the last client disconnects or its connection heartbeats stop. Terminals and tasks continue while it runs.": "CuĆ”nto tiempo sigue en marcha un runtime iniciado por Oxbit despuĆ©s de que se desconecte el Ćŗltimo cliente o se detengan las seƱales de su conexión. Los terminales y las tareas continĆŗan mientras se ejecuta.", + "Reconnect to the runtime after the connection drops, including when the iOS app returns to the foreground.": "Volver a conectar al runtime cuando se pierde la conexión, tambiĆ©n cuando la app de iOS vuelve al primer plano." } diff --git a/packages/ui/src/locales/ja.json b/packages/ui/src/locales/ja.json index 845d1ead..2b1821bb 100644 --- a/packages/ui/src/locales/ja.json +++ b/packages/ui/src/locales/ja.json @@ -808,5 +808,57 @@ "de": "ćƒ‰ć‚¤ćƒ„čŖž", "es": "ć‚¹ćƒšć‚¤ćƒ³čŖž", "ja": "ę—„ęœ¬čŖž", - "zh": "äø­å›½čŖžļ¼ˆē°”ä½“å­—ļ¼‰" + "zh": "äø­å›½čŖžļ¼ˆē°”ä½“å­—ļ¼‰", + "Host": "ćƒ›ć‚¹ćƒˆ", + "Port": "ćƒćƒ¼ćƒˆ", + "Version": "ćƒćƒ¼ć‚øćƒ§ćƒ³", + "Uptime": "ēØ¼åƒę™‚é–“", + "Runtime": "ćƒ©ćƒ³ć‚æć‚¤ćƒ ", + "Done": "å®Œäŗ†", + "No runtime connected": "ęŽ„ē¶šäø­ć®ćƒ©ćƒ³ć‚æć‚¤ćƒ ćÆć‚ć‚Šć¾ć›ć‚“", + "Restarting…": "å†čµ·å‹•äø­ā€¦", + "Restart": "å†čµ·å‹•", + "Connect to {0}": "{0} ć«ęŽ„ē¶š", + "Pair a runtime": "ćƒ©ćƒ³ć‚æć‚¤ćƒ ć‚’ćƒšć‚¢ćƒŖćƒ³ć‚°", + "Workspace trust": "ćƒÆćƒ¼ć‚Æć‚¹ćƒšćƒ¼ć‚¹ć®äæ”é ¼", + "Only the workspace owner can change trust.": "äæ”é ¼ć‚’å¤‰ę›“ć§ćć‚‹ć®ćÆćƒÆćƒ¼ć‚Æć‚¹ćƒšćƒ¼ć‚¹ć®ę‰€ęœ‰č€…ć ć‘ć§ć™ć€‚", + "Pair with a runtime": "ćƒ©ćƒ³ć‚æć‚¤ćƒ ćØćƒšć‚¢ćƒŖćƒ³ć‚°", + "Start a runtime on your computer, then enter its address and owner pairing code.": "ć‚³ćƒ³ćƒ”ćƒ„ćƒ¼ć‚æć§ćƒ©ćƒ³ć‚æć‚¤ćƒ ć‚’čµ·å‹•ć—ć€ćć®ć‚¢ćƒ‰ćƒ¬ć‚¹ćØę‰€ęœ‰č€…ć®ćƒšć‚¢ćƒŖćƒ³ć‚° ć‚³ćƒ¼ćƒ‰ć‚’å…„åŠ›ć—ć¾ć™ć€‚", + "Keep alive": "ēØ¼åƒć®ē¶­ęŒ", + "How long a runtime Oxbit started keeps running after the last client disconnects. Terminals and tasks continue while it runs.": "ęœ€å¾Œć®ć‚Æćƒ©ć‚¤ć‚¢ćƒ³ćƒˆćŒåˆ‡ę–­ć•ć‚ŒćŸå¾Œć€Oxbit ćŒčµ·å‹•ć—ćŸćƒ©ćƒ³ć‚æć‚¤ćƒ ć‚’å‹•ć‹ć—ē¶šć‘ć‚‹ę™‚é–“ć§ć™ć€‚ćć®é–“ć€ć‚æćƒ¼ćƒŸćƒŠćƒ«ćØć‚æć‚¹ć‚ÆćÆå®Ÿč”Œć‚’ē¶šć‘ć¾ć™ć€‚", + "Keep Runtime Alive": "ćƒ©ćƒ³ć‚æć‚¤ćƒ ć®ēØ¼åƒć‚’ē¶­ęŒ", + "Reconnect Automatically": "č‡Ŗå‹•ēš„ć«å†ęŽ„ē¶š", + "Saved runtimes": "äæå­˜ęøˆćæć®ćƒ©ćƒ³ć‚æć‚¤ćƒ ", + "Forget {0}": "{0} ć‚’å‰Šé™¤", + "In use": "使用中", + "On this network": "ć“ć®ćƒćƒƒćƒˆćƒÆćƒ¼ć‚ÆäøŠ", + "Pair": "ćƒšć‚¢ćƒŖćƒ³ć‚°", + "Looking for runtimes on this network…": "ć“ć®ćƒćƒƒćƒˆćƒÆćƒ¼ć‚ÆäøŠć®ćƒ©ćƒ³ć‚æć‚¤ćƒ ć‚’ę¤œē“¢äø­ā€¦", + "Run oxbit --lan on your computer to list it here.": "ć‚³ćƒ³ćƒ”ćƒ„ćƒ¼ć‚æć§ oxbit --lan ć‚’å®Ÿč”Œć™ć‚‹ćØć€ć“ć“ć«č”Øē¤ŗć•ć‚Œć¾ć™ć€‚", + "SSH servers": "SSH ć‚µćƒ¼ćƒćƒ¼", + "Folder on {0}": "{0} äøŠć®ćƒ•ć‚©ćƒ«ćƒ€ćƒ¼", + "Starting…": "起動中…", + "Start Oxbit on this server": "ć“ć®ć‚µćƒ¼ćƒćƒ¼ć§ Oxbit ć‚’čµ·å‹•", + "No SSH hosts saved yet.": "äæå­˜ć•ć‚ŒćŸ SSH ćƒ›ć‚¹ćƒˆćÆć¾ć ć‚ć‚Šć¾ć›ć‚“ć€‚", + "Manage SSH hosts…": "SSH ćƒ›ć‚¹ćƒˆć‚’ē®”ē†ā€¦", + "This window has no runtime connection.": "ć“ć®ć‚¦ć‚£ćƒ³ćƒ‰ć‚¦ć«ćÆćƒ©ćƒ³ć‚æć‚¤ćƒ ęŽ„ē¶šćŒć‚ć‚Šć¾ć›ć‚“ć€‚", + "{0} answered HTTP {1}; it is not an Oxbit runtime.": "{0} は HTTP {1} ć‚’čæ”ć—ć¾ć—ćŸć€‚Oxbit ćƒ©ćƒ³ć‚æć‚¤ćƒ ć§ćÆć‚ć‚Šć¾ć›ć‚“ć€‚", + "The runtime at {0} is running but refused this browser. Pair it again with its pairing code.": "{0} ć®ćƒ©ćƒ³ć‚æć‚¤ćƒ ćÆå‹•ä½œć—ć¦ć„ć¾ć™ćŒć€ć“ć®ćƒ–ćƒ©ć‚¦ć‚¶ćƒ¼ć‚’ę‹’å¦ć—ć¾ć—ćŸć€‚ćƒšć‚¢ćƒŖćƒ³ć‚° ć‚³ćƒ¼ćƒ‰ć§ć‚‚ć†äø€åŗ¦ćƒšć‚¢ćƒŖćƒ³ć‚°ć—ć¦ćć ć•ć„ć€‚", + "{0} is reachable but did not accept this page. Open Oxbit from the address the runtime prints, or pair it again.": "{0} ć«ćÆåˆ°é”ć§ćć¾ć™ćŒć€ć“ć®ćƒšćƒ¼ć‚øćÆå—ć‘å…„ć‚Œć‚‰ć‚Œć¾ć›ć‚“ć§ć—ćŸć€‚ćƒ©ćƒ³ć‚æć‚¤ćƒ ćŒč”Øē¤ŗć™ć‚‹ć‚¢ćƒ‰ćƒ¬ć‚¹ć‹ć‚‰ Oxbit ć‚’é–‹ćć‹ć€ć‚‚ć†äø€åŗ¦ćƒšć‚¢ćƒŖćƒ³ć‚°ć—ć¦ćć ć•ć„ć€‚", + "No runtime answered at {0}. Start one with oxbit and check the address.": "{0} ć§ćƒ©ćƒ³ć‚æć‚¤ćƒ ćŒåæœē­”ć—ć¾ć›ć‚“ć§ć—ćŸć€‚oxbit ć§čµ·å‹•ć—ć€ć‚¢ćƒ‰ćƒ¬ć‚¹ć‚’ē¢ŗčŖć—ć¦ćć ć•ć„ć€‚", + "Connecting to {0}…": "{0} ć«ęŽ„ē¶šäø­ā€¦", + "Opening the workspace…": "ćƒÆćƒ¼ć‚Æć‚¹ćƒšćƒ¼ć‚¹ć‚’é–‹ć„ć¦ć„ć¾ć™ā€¦", + "This runtime has no address. Pair it again.": "ć“ć®ćƒ©ćƒ³ć‚æć‚¤ćƒ ć«ćÆć‚¢ćƒ‰ćƒ¬ć‚¹ćŒć‚ć‚Šć¾ć›ć‚“ć€‚ć‚‚ć†äø€åŗ¦ćƒšć‚¢ćƒŖćƒ³ć‚°ć—ć¦ćć ć•ć„ć€‚", + "Enter the runtime's http:// or https:// address.": "ćƒ©ćƒ³ć‚æć‚¤ćƒ ć® http:// または https:// ć‚¢ćƒ‰ćƒ¬ć‚¹ć‚’å…„åŠ›ć—ć¦ćć ć•ć„ć€‚", + "Could not reconnect to the runtime: {0}": "ćƒ©ćƒ³ć‚æć‚¤ćƒ ć«å†ęŽ„ē¶šć§ćć¾ć›ć‚“ć§ć—ćŸ: {0}", + "Open Runtime": "ćƒ©ćƒ³ć‚æć‚¤ćƒ ć‚’é–‹ć", + "Reconnecting…": "å†ęŽ„ē¶šäø­ā€¦", + "Not connected": "ęœŖęŽ„ē¶š", + "75s": "75 ē§’", + "15m": "15 分", + "1h": "1 Ꙃ間", + "8h": "8 Ꙃ間", + "untilStopped": "åœę­¢ć™ć‚‹ć¾ć§", + "How long a runtime Oxbit started keeps running after the last client disconnects or its connection heartbeats stop. Terminals and tasks continue while it runs.": "ęœ€å¾Œć®ć‚Æćƒ©ć‚¤ć‚¢ćƒ³ćƒˆćŒåˆ‡ę–­ć•ć‚Œć‚‹ć‹ęŽ„ē¶šć®ćƒćƒ¼ćƒˆćƒ“ćƒ¼ćƒˆćŒę­¢ć¾ć£ćŸå¾Œć€Oxbit ćŒčµ·å‹•ć—ćŸćƒ©ćƒ³ć‚æć‚¤ćƒ ć‚’å‹•ć‹ć—ē¶šć‘ć‚‹ę™‚é–“ć§ć™ć€‚ćć®é–“ć€ć‚æćƒ¼ćƒŸćƒŠćƒ«ćØć‚æć‚¹ć‚ÆćÆå®Ÿč”Œć‚’ē¶šć‘ć¾ć™ć€‚", + "Reconnect to the runtime after the connection drops, including when the iOS app returns to the foreground.": "ęŽ„ē¶šćŒåˆ‡ć‚ŒćŸå¾Œć€iOS ć‚¢ćƒ—ćƒŖćŒćƒ•ć‚©ć‚¢ć‚°ćƒ©ć‚¦ćƒ³ćƒ‰ć«ęˆ»ć£ćŸćØćć‚‚å«ć‚ć¦ćƒ©ćƒ³ć‚æć‚¤ćƒ ć«å†ęŽ„ē¶šć—ć¾ć™ć€‚" } diff --git a/packages/ui/src/locales/zh.json b/packages/ui/src/locales/zh.json index aa837bce..acffca09 100644 --- a/packages/ui/src/locales/zh.json +++ b/packages/ui/src/locales/zh.json @@ -808,5 +808,57 @@ "de": "德语", "es": "č„æē­ē‰™čÆ­", "ja": "旄语", - "zh": "简体中文" + "zh": "简体中文", + "Host": "主机", + "Port": "ē«Æå£", + "Version": "ē‰ˆęœ¬", + "Uptime": "čæč”Œę—¶é•æ", + "Runtime": "čæč”Œę—¶", + "Done": "完成", + "No runtime connected": "ęœŖčæžęŽ„čæč”Œę—¶", + "Restarting…": "ę­£åœØé‡åÆā€¦", + "Restart": "é‡åÆ", + "Connect to {0}": "čæžęŽ„åˆ° {0}", + "Pair a runtime": "é…åÆ¹čæč”Œę—¶", + "Workspace trust": "巄作区俔任", + "Only the workspace owner can change trust.": "åŖęœ‰å·„ä½œåŒŗę‰€ęœ‰č€…åÆä»„ę›“ę”¹äæ”ä»»č®¾ē½®ć€‚", + "Pair with a runtime": "äøŽčæč”Œę—¶é…åÆ¹", + "Start a runtime on your computer, then enter its address and owner pairing code.": "åœØē”µč„‘äøŠåÆåŠØčæč”Œę—¶ļ¼Œē„¶åŽč¾“å…„å…¶åœ°å€å’Œę‰€ęœ‰č€…é…åÆ¹ē ć€‚", + "Keep alive": "äæęŒčæč”Œ", + "How long a runtime Oxbit started keeps running after the last client disconnects. Terminals and tasks continue while it runs.": "ęœ€åŽäø€äøŖå®¢ęˆ·ē«Æę–­å¼€åŽļ¼Œē”± Oxbit åÆåŠØēš„čæč”Œę—¶ē»§ē»­čæč”Œēš„ę—¶é•æć€‚čæč”ŒęœŸé—“ē»ˆē«Æå’Œä»»åŠ”ä¼šē»§ē»­ę‰§č”Œć€‚", + "Keep Runtime Alive": "äæęŒčæč”Œę—¶čæč”Œ", + "Reconnect Automatically": "č‡ŖåŠØé‡ę–°čæžęŽ„", + "Saved runtimes": "å·²äæå­˜ēš„čæč”Œę—¶", + "Forget {0}": "忘记 {0}", + "In use": "使用中", + "On this network": "ę­¤ē½‘ē»œäø­", + "Pair": "é…åÆ¹", + "Looking for runtimes on this network…": "ę­£åœØęŸ„ę‰¾ę­¤ē½‘ē»œäø­ēš„čæč”Œę—¶ā€¦", + "Run oxbit --lan on your computer to list it here.": "åœØē”µč„‘äøŠčæč”Œ oxbit --lan å³åÆåœØę­¤åˆ—å‡ŗć€‚", + "SSH servers": "SSH ęœåŠ”å™Ø", + "Folder on {0}": "{0} äøŠēš„ę–‡ä»¶å¤¹", + "Starting…": "ę­£åœØåÆåŠØā€¦", + "Start Oxbit on this server": "åœØę­¤ęœåŠ”å™ØäøŠåÆåŠØ Oxbit", + "No SSH hosts saved yet.": "å°šęœŖäæå­˜ SSH äø»ęœŗć€‚", + "Manage SSH hosts…": "箔理 SSH äø»ęœŗā€¦", + "This window has no runtime connection.": "ę­¤ēŖ—å£ę²”ęœ‰čæč”Œę—¶čæžęŽ„ć€‚", + "{0} answered HTTP {1}; it is not an Oxbit runtime.": "{0} čæ”å›žäŗ† HTTP {1}ļ¼›å®ƒäøę˜Æ Oxbit čæč”Œę—¶ć€‚", + "The runtime at {0} is running but refused this browser. Pair it again with its pairing code.": "{0} äøŠēš„čæč”Œę—¶ę­£åœØčæč”Œļ¼Œä½†ę‹’ē»äŗ†ę­¤ęµč§ˆå™Øć€‚čÆ·ä½æē”Øé…åÆ¹ē é‡ę–°é…åÆ¹ć€‚", + "{0} is reachable but did not accept this page. Open Oxbit from the address the runtime prints, or pair it again.": "{0} åÆä»„č®æé—®ļ¼Œä½†ęœŖęŽ„å—ę­¤é”µé¢ć€‚čÆ·ä»Žčæč”Œę—¶ę˜¾ē¤ŗēš„åœ°å€ę‰“å¼€ Oxbitļ¼Œęˆ–é‡ę–°é…åÆ¹ć€‚", + "No runtime answered at {0}. Start one with oxbit and check the address.": "{0} ę²”ęœ‰čæč”Œę—¶å“åŗ”ć€‚čÆ·ē”Ø oxbit åÆåŠØäø€äøŖå¹¶ę£€ęŸ„åœ°å€ć€‚", + "Connecting to {0}…": "ę­£åœØčæžęŽ„åˆ° {0}…", + "Opening the workspace…": "ę­£åœØę‰“å¼€å·„ä½œåŒŗā€¦", + "This runtime has no address. Pair it again.": "ę­¤čæč”Œę—¶ę²”ęœ‰åœ°å€ć€‚čÆ·é‡ę–°é…åÆ¹ć€‚", + "Enter the runtime's http:// or https:// address.": "čÆ·č¾“å…„čæč”Œę—¶ēš„ http:// ꈖ https:// åœ°å€ć€‚", + "Could not reconnect to the runtime: {0}": "ę— ę³•é‡ę–°čæžęŽ„åˆ°čæč”Œę—¶ļ¼š{0}", + "Open Runtime": "ę‰“å¼€čæč”Œę—¶", + "Reconnecting…": "ę­£åœØé‡ę–°čæžęŽ„ā€¦", + "Not connected": "ęœŖčæžęŽ„", + "75s": "75 ē§’", + "15m": "15 分钟", + "1h": "1 å°ę—¶", + "8h": "8 å°ę—¶", + "untilStopped": "ē›“åˆ°åœę­¢", + "How long a runtime Oxbit started keeps running after the last client disconnects or its connection heartbeats stop. Terminals and tasks continue while it runs.": "ęœ€åŽäø€äøŖå®¢ęˆ·ē«Æę–­å¼€ęˆ–čæžęŽ„åæƒč·³åœę­¢åŽļ¼Œē”± Oxbit åÆåŠØēš„čæč”Œę—¶ē»§ē»­čæč”Œēš„ę—¶é•æć€‚čæč”ŒęœŸé—“ē»ˆē«Æå’Œä»»åŠ”ä¼šē»§ē»­ę‰§č”Œć€‚", + "Reconnect to the runtime after the connection drops, including when the iOS app returns to the foreground.": "čæžęŽ„ę–­å¼€åŽé‡ę–°čæžęŽ„åˆ°čæč”Œę—¶ļ¼ŒåŒ…ę‹¬ iOS åŗ”ē”Øčæ”å›žå‰å°ę—¶ć€‚" } diff --git a/packages/workbench/src/index.tsx b/packages/workbench/src/index.tsx index 1ca5148d..49a8c02b 100644 --- a/packages/workbench/src/index.tsx +++ b/packages/workbench/src/index.tsx @@ -61,6 +61,15 @@ import { } from "./shortcuts.js"; export * from "./controller.js"; export * from "./runtime-connector.js"; +import { RUNTIME_CONNECTOR_SERVICE, type RuntimeConnector, type RuntimeStatus } from "./runtime-connector.js"; +const noConnector = () => () => {}; +const idleConnection: RuntimeStatus = { state: "disconnected" }; +const connectionLabels: Record = { + connected: "Connected", connecting: "Connecting…", reconnecting: "Reconnecting…", failed: "Connection failed", disconnected: "Connect", +}; +const connectionIcons: Record = { + connected: "cloudCheck", connecting: "sync", reconnecting: "sync", failed: "cloudOff", disconnected: "cloud", +}; export { workspaceEntries } from "./files.js"; export { normalizeShortcut, @@ -390,6 +399,9 @@ export function Workbench({ const [conn, setConn] = useState( runtime?.connected ? "Connected" : "Browser workspace", ); + const connector = kernel.services.optional(RUNTIME_CONNECTOR_SERVICE); + const connection = useSyncExternalStore(connector?.subscribe ?? noConnector, connector?.status ?? (() => idleConnection)); + const connecting = connection.state === "connecting" || connection.state === "reconnecting"; useEffect(() => { const resize = () => setWidth(innerWidth); window.addEventListener("resize", resize); @@ -543,17 +555,31 @@ export function Workbench({
{onConnect && ( - + connector ? ( + + ) : ( + + ) )} {mode === "phone" && status @@ -763,7 +789,7 @@ export function Workbench({ )} {!s.focus && mode !== "phone" && (
- {onConnect && ( + {onConnect && !connector && (
); } +function RuntimeError({ text }: { text: string }) { + const { message, detail } = splitRuntimeError(text); + return ( + + {message} + {detail &&
Details
{detail}
} +
+ ); +} function ProjectPanels({ entry, active, children }: { entry: ReturnType["projects"][number]; active: boolean; children: ReactNode }) { const workbench = entry.session!.workbench; useEffect(() => { @@ -696,41 +708,6 @@ function ActiveWorkbench({ /> ); } -function TrustDialog({ - session, - close, -}: { - session: Session; - close: () => void; -}) { - const [trusted, setTrusted] = useState(!!session.runtime?.session?.trusted); - const [error, setError] = useState(""); - return ( - -

- Opening a folder enables editing. Trust this project to run terminals, - tasks, Git, language services, and runtime extensions. -

-

- {trusted - ? "This project is trusted." - : "This project’s tools are disabled."} -

- {error &&

{error}

} - -
- ); -} async function startGhLogin(session: Session) { const gh = (await native.tools()).find((tool) => tool.name === "gh")?.path; if (!gh) diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index d1601348..b130d88b 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../packages/themes" + }, + { + "path": "../../packages/protocol" } ] } diff --git a/bun.lock b/bun.lock index aef2409d..db6faf6a 100644 --- a/bun.lock +++ b/bun.lock @@ -37,6 +37,7 @@ "@oxbit/host-browser": "workspace:*", "@oxbit/host-desktop": "workspace:*", "@oxbit/host-runtime": "workspace:*", + "@oxbit/protocol": "workspace:*", "@oxbit/sdk": "workspace:*", "@oxbit/themes": "workspace:*", "@oxbit/ui": "workspace:*", @@ -444,9 +445,12 @@ "version": "1.0.0", "dependencies": { "@oxbit/app-workbench": "workspace:*", + "@oxbit/feature-runtime": "workspace:*", "@oxbit/host-runtime": "workspace:*", "@oxbit/icon-themes": "workspace:*", + "@oxbit/protocol": "workspace:*", "@oxbit/sdk": "workspace:*", + "@oxbit/workbench": "workspace:*", "@tauri-apps/api": "2.11.1", }, }, diff --git a/packages/features/runtime/src/page.tsx b/packages/features/runtime/src/page.tsx index c797dccf..d14079df 100644 --- a/packages/features/runtime/src/page.tsx +++ b/packages/features/runtime/src/page.tsx @@ -87,7 +87,7 @@ export function RuntimePage({ connector, settings, onClose }: { onClick={() => perform("quick", () => connector.connect(quick))}> {busy === "quick" || status.state === "connecting" ? tr("Connecting…") : tr("Connect to {0}", { 0: quick.name })} - ) : ( + ) : connector.pair && ( )} {!connected && connector.restart && status.kind && } @@ -111,7 +111,7 @@ export function RuntimePage({ connector, settings, onClose }: { {connector.discover && } {connector.sshHosts && connector.startSsh && } - + {connector.pair && } ); @@ -133,17 +133,17 @@ function TrustCard({ status, busy, perform, trust }: { ); } -function PairCard({ connector, url, setUrl, codeRef, busy, perform }: { - connector: RuntimeConnector; url: string; setUrl: (value: string) => void; codeRef: React.RefObject; +function PairCard({ pair, startCommand, url, setUrl, codeRef, busy, perform }: { + pair: (url: string, code: string) => Promise; startCommand?: string; url: string; setUrl: (value: string) => void; codeRef: React.RefObject; busy?: string; perform: (key: string, action: () => Promise) => void; }) { const [code, setCode] = useState(""); return ( -
{ event.preventDefault(); perform("pair", async () => { await connector.pair(url.trim(), code.trim()); setCode(""); }); }}> - {connector.startCommand && <> + { event.preventDefault(); perform("pair", async () => { await pair(url.trim(), code.trim()); setCode(""); }); }}> + {startCommand && <>

{tr("Start a runtime on your computer, then enter its address and owner pairing code.")}

- {connector.startCommand} + {startCommand} }

Uses your SSH config and keys. Connect once in a terminal to verify a - new host. Supports Linux x64 and macOS Apple Silicon. + new host. Supports Linux x64 and arm64, and macOS Apple Silicon.

{error &&

{error}

}
diff --git a/apps/ios/src-tauri/src/remote_runtime.rs b/apps/ios/src-tauri/src/remote_runtime.rs index 7588a186..590bb3d5 100644 --- a/apps/ios/src-tauri/src/remote_runtime.rs +++ b/apps/ios/src-tauri/src/remote_runtime.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; include!(concat!(env!("OUT_DIR"), "/remote_runtime_manifest.rs")); -pub const PLATFORMS: [&str; 2] = ["darwin-arm64", "linux-x64"]; +pub const PLATFORMS: [&str; 3] = ["darwin-arm64", "linux-x64", "linux-arm64"]; const RELEASE_DOWNLOADS: &str = "https://github.com/yannelli/oxbit/releases/download"; #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] @@ -99,20 +99,21 @@ mod tests { const DARWIN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; const LINUX: &str = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; + const ARM: &str = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"; fn manifest(version: &str, platforms: &str) -> String { format!(r#"{{"version":"{version}","platforms":{{{platforms}}}}}"#) } - fn both() -> String { + fn all() -> String { format!( - r#""darwin-arm64":{{"sha256":"{DARWIN}","size":52000000}},"linux-x64":{{"sha256":"{LINUX}","size":48000000}}"# + r#""darwin-arm64":{{"sha256":"{DARWIN}","size":52000000}},"linux-x64":{{"sha256":"{LINUX}","size":48000000}},"linux-arm64":{{"sha256":"{ARM}","size":47000000}}"# ) } #[test] fn parses_a_release_manifest_and_builds_download_urls() { - let parsed = parse(&manifest("0.4.0-alpha.1", &both()), "0.4.0-alpha.1").unwrap(); + let parsed = parse(&manifest("0.4.0-alpha.1", &all()), "0.4.0-alpha.1").unwrap(); assert_eq!( parsed.download("linux-x64").unwrap(), Download { @@ -125,12 +126,20 @@ mod tests { parsed.download("darwin-arm64").unwrap().url, "https://github.com/yannelli/oxbit/releases/download/v0.4.0-alpha.1/remote-runtime-darwin-arm64.tar.gz" ); + assert_eq!( + parsed.download("linux-arm64").unwrap(), + Download { + url: "https://github.com/yannelli/oxbit/releases/download/v0.4.0-alpha.1/remote-runtime-linux-arm64.tar.gz".into(), + sha256: ARM.into(), + size: 47_000_000, + } + ); assert!(parsed.download("darwin-x64").is_err()); } #[test] fn rejects_a_manifest_for_another_version() { - let error = parse(&manifest("0.3.3", &both()), "0.3.4").unwrap_err(); + let error = parse(&manifest("0.3.3", &all()), "0.3.4").unwrap_err(); assert!(error.contains("0.3.3"), "{error}"); } @@ -138,10 +147,11 @@ mod tests { fn rejects_missing_or_unknown_platforms() { let darwin_only = format!(r#""darwin-arm64":{{"sha256":"{DARWIN}","size":1}}"#); assert!(parse(&manifest("0.3.4", &darwin_only), "0.3.4").is_err()); - let with_intel = format!( - r#"{},"darwin-x64":{{"sha256":"{DARWIN}","size":1}}"#, - both() + let without_arm = format!( + r#""darwin-arm64":{{"sha256":"{DARWIN}","size":1}},"linux-x64":{{"sha256":"{LINUX}","size":1}}"# ); + assert!(parse(&manifest("0.3.4", &without_arm), "0.3.4").is_err()); + let with_intel = format!(r#"{},"darwin-x64":{{"sha256":"{DARWIN}","size":1}}"#, all()); assert!(parse(&manifest("0.3.4", &with_intel), "0.3.4").is_err()); } @@ -154,7 +164,7 @@ mod tests { (DARWIN, "0"), ] { let platforms = format!( - r#""darwin-arm64":{{"sha256":"{digest}","size":{size}}},"linux-x64":{{"sha256":"{LINUX}","size":1}}"# + r#""darwin-arm64":{{"sha256":"{digest}","size":{size}}},"linux-x64":{{"sha256":"{LINUX}","size":1}},"linux-arm64":{{"sha256":"{ARM}","size":1}}"# ); assert!( parse(&manifest("0.3.4", &platforms), "0.3.4").is_err(), @@ -163,7 +173,7 @@ mod tests { } let extra = format!( r#"{{"version":"0.3.4","schema":1,"platforms":{{{}}}}}"#, - both() + all() ); assert!(parse(&extra, "0.3.4").is_err()); } diff --git a/apps/ios/src-tauri/src/ssh/live_runtime_task_tests.rs b/apps/ios/src-tauri/src/ssh/live_runtime_task_tests.rs index 2ae34f6b..f11d858d 100644 --- a/apps/ios/src-tauri/src/ssh/live_runtime_task_tests.rs +++ b/apps/ios/src-tauri/src/ssh/live_runtime_task_tests.rs @@ -1,7 +1,7 @@ //! Live remote runtime cases on the Docker sshd fixture: the wget install, a task service reached //! through `taskForward`, and the relaunch budget after repeated failures. use super::{ - live_runtime_tests::{live, payload, probe, source, start, Live, Seen}, + live_runtime_tests::{live, payload, probe, remote_platform, source, start, Live, Seen}, live_tests::{docker, exec}, runtime_process::Event, runtime_protocol::{Downloader, Install}, @@ -68,7 +68,12 @@ async fn live_runtime_wget_tasks_and_budget() { } = live().await; let (_, sha256) = payload(); let node = format!("/root/.oxbit/remote/runtimes/{sha256}/bin/node"); - let hosted = || source(format!("{served}/linux-x64.tar.gz"), scratch.clone()); + let hosted = || { + source( + format!("{served}/{}.tar.gz", remote_platform()), + scratch.clone(), + ) + }; exec(&container, "mv /usr/bin/curl /usr/bin/curl.off"); let seen: Seen = Arc::default(); diff --git a/apps/ios/src-tauri/src/ssh/live_runtime_tests.rs b/apps/ios/src-tauri/src/ssh/live_runtime_tests.rs index 83cc5821..089c743f 100644 --- a/apps/ios/src-tauri/src/ssh/live_runtime_tests.rs +++ b/apps/ios/src-tauri/src/ssh/live_runtime_tests.rs @@ -21,15 +21,21 @@ use std::{ pub(super) type Seen = Arc>>; +/// The fixture's runtime platform; scripts/ios/ssh-live.mjs sets it from the container architecture. +pub(super) fn remote_platform() -> String { + std::env::var("OXBIT_SSH_TEST_REMOTE_PLATFORM").unwrap_or_else(|_| "linux-x64".into()) +} + pub(super) fn payload() -> (PathBuf, String) { let directory = PathBuf::from(env("OXBIT_SSH_TEST_PAYLOAD_DIR")); let manifest: serde_json::Value = serde_json::from_slice(&std::fs::read(directory.join("manifest.json")).unwrap()).unwrap(); - let sha256 = manifest["platforms"]["linux-x64"]["sha256"] + let platform = remote_platform(); + let sha256 = manifest["platforms"][&platform]["sha256"] .as_str() .unwrap() .to_string(); - (directory.join("linux-x64.tar.gz"), sha256) + (directory.join(format!("{platform}.tar.gz")), sha256) } /// `url` is what the host downloads; the device side copies the local archive. @@ -39,7 +45,7 @@ pub(super) fn source(url: String, scratch: PathBuf) -> Source { let download = Download { url, sha256, size }; Source { lookup: Arc::new(move |platform| { - assert_eq!(platform, "linux-x64"); + assert_eq!(platform, remote_platform()); Ok(download.clone()) }), fetch: Arc::new(move |download| { @@ -222,7 +228,10 @@ async fn live_remote_runtime() { exec(&container, "rm -rf /root/.oxbit/remote/runtimes"); let seen: Seen = Arc::default(); - let hosted = source(format!("{served}/linux-x64.tar.gz"), scratch.clone()); + let hosted = source( + format!("{served}/{}.tar.gz", remote_platform()), + scratch.clone(), + ); let runtime = start(&pool, "live", hosted.clone(), &seen).await; let ready = runtime.ensure(true).await.unwrap(); assert_eq!( diff --git a/apps/ios/src-tauri/src/ssh/runtime_protocol.rs b/apps/ios/src-tauri/src/ssh/runtime_protocol.rs index 2f301e75..06e1455b 100644 --- a/apps/ios/src-tauri/src/ssh/runtime_protocol.rs +++ b/apps/ios/src-tauri/src/ssh/runtime_protocol.rs @@ -5,10 +5,11 @@ use crate::fs_core::{Error, Result}; pub fn platform(system: &str, machine: &str) -> Result<&'static str> { match (system.trim(), machine.trim()) { ("Linux", "x86_64") => Ok("linux-x64"), + ("Linux", "aarch64") => Ok("linux-arm64"), ("Darwin", "arm64") => Ok("darwin-arm64"), _ => Err(Error::new( "REMOTE_UNSUPPORTED", - "Remote SSH supports Linux x64 (glibc) and macOS Apple Silicon.", + "Remote SSH supports Linux x64 and arm64 (glibc) and macOS Apple Silicon.", )), } } diff --git a/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs b/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs index 33eb9a39..fcb4de85 100644 --- a/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs +++ b/apps/ios/src-tauri/src/ssh/runtime_protocol_tests.rs @@ -13,16 +13,17 @@ fn frame(value: Value) -> Vec { #[test] fn maps_uname_to_the_desktop_platform_names() { assert_eq!(platform("Linux", "x86_64").unwrap(), "linux-x64"); + assert_eq!(platform("Linux", "aarch64").unwrap(), "linux-arm64"); assert_eq!(platform("Darwin\n", "arm64\n").unwrap(), "darwin-arm64"); for (system, machine) in [ - ("Linux", "aarch64"), + ("Linux", "armv7l"), ("Darwin", "x86_64"), ("FreeBSD", "amd64"), ] { let error = platform(system, machine).unwrap_err(); assert_eq!( error.message, - "Remote SSH supports Linux x64 (glibc) and macOS Apple Silicon." + "Remote SSH supports Linux x64 and arm64 (glibc) and macOS Apple Silicon." ); } assert_eq!( @@ -33,6 +34,10 @@ fn maps_uname_to_the_desktop_platform_names() { parse_probe("Darwin\narm64\nwget\n").unwrap(), ("darwin-arm64", Some(Downloader::Wget)) ); + assert_eq!( + parse_probe("Linux\naarch64\ncurl\n").unwrap(), + ("linux-arm64", Some(Downloader::Curl)) + ); assert_eq!( parse_probe("Linux\nx86_64\nnone\n").unwrap(), ("linux-x64", None) diff --git a/apps/ios/src/ssh-connect.tsx b/apps/ios/src/ssh-connect.tsx index 47baa9ba..5cb3635e 100644 --- a/apps/ios/src/ssh-connect.tsx +++ b/apps/ios/src/ssh-connect.tsx @@ -106,7 +106,7 @@ export function SshConnect({ preset, mode = "files", progress, lastFolders = {}, event.preventDefault(); if (host && !busy) perform(() => connect(host)); }}> - {runtime &&

Oxbit installs its runtime on the server and runs terminals, tasks, and agents there. Linux x64 and macOS Apple Silicon servers are supported.

} + {runtime &&

Oxbit installs its runtime on the server and runs terminals, tasks, and agents there. Linux x64 and arm64 servers and macOS Apple Silicon servers are supported.

} {hosts && !hosts.length ?

Add a server in SSH Hosts and Keys first.

: <> -

Use your computer’s network address. Your iPhone and computer must be able to reach each other.

- -

Enter the owner pairing code shown by the runtime. Connecting opens that computer’s workspace; your on-device folders stay available.

-
- - -
- } - {error &&

{error}

} - ; -} diff --git a/apps/ios/src/runtime-connector.ts b/apps/ios/src/runtime-connector.ts new file mode 100644 index 00000000..f3b90104 --- /dev/null +++ b/apps/ios/src/runtime-connector.ts @@ -0,0 +1,147 @@ +import { AUTO_RECONNECT_SETTING, KEEP_ALIVE_SETTING, RuntimeStatusStore, clientStatus, keepAliveMs, type RuntimeSettingsAccess } from "@oxbit/feature-runtime"; +import type { DiscoveredRuntime, RuntimeConnector, RuntimeTarget, SshHostSummary } from "@oxbit/workbench"; +import { SESSION_SCOPE, discovery, native, ssh, type RecentWorkspace } from "@oxbit/host-ios"; +import type { OpenRequest, OpenWorkspace } from "./workspaces.js"; + +const SETTINGS_KEY = "runtime-settings"; +const runtimeSettings = [KEEP_ALIVE_SETTING, AUTO_RECONNECT_SETTING]; + +/** What the app shell does for the connector; set once the shell mounts. */ +export interface IosRuntimeHost { + /** Resolves to an error message when the workspace did not open. */ + open(request: OpenRequest): Promise; + close(): Promise; + forget(id: string): Promise; + startSsh(hostId: string, path: string): void; + manageSsh(): void; +} + +function targetOf(recent: RecentWorkspace): RuntimeTarget { + return recent.kind === "sshRuntime" + ? { key: recent.id, kind: "ssh", name: recent.name, hostId: recent.hostId, path: recent.remotePath, detail: `Oxbit runtime Ā· ${recent.remotePath}`, lastConnected: recent.lastOpened } + : { key: recent.id, kind: "url", name: recent.name, url: recent.url, runtimeId: recent.runtimeId, detail: recent.url, lastConnected: recent.lastOpened }; +} + +/** One per app launch; the open workspace changes as the user connects and closes runtimes. */ +export class IosRuntimeConnector implements RuntimeConnector { + readonly store = new RuntimeStatusStore(); + readonly startCommand = "oxbit --lan"; + host?: IosRuntimeHost; + private workspace?: OpenWorkspace; + private saved: RecentWorkspace[] = []; + private mirror: Record = {}; + private detach: (() => void)[] = []; + status = this.store.get; + subscribe = this.store.subscribe; + + /** The start screen has no kernel, so runtime settings are mirrored to app storage. */ + async loadSettings() { + this.mirror = await native.storageGet>(SESSION_SCOPE, SETTINGS_KEY).catch(() => null) ?? {}; + } + readonly settings: RuntimeSettingsAccess = { + get: (id: string) => (this.workspace ? this.workspace.session.kernel.configuration.get(id) : this.mirror[id] as T | undefined), + set: (id, value) => { + this.remember(id, value); + if (this.workspace) return this.workspace.session.kernel.configuration.set(id, value as never); + }, + }; + private remember(id: string, value: unknown) { + if (this.mirror[id] === value) return; + this.mirror = { ...this.mirror, [id]: value }; + void native.storageSet(SESSION_SCOPE, SETTINGS_KEY, this.mirror).catch(() => {}); + } + keepAlive() { return keepAliveMs(this.settings.get(KEEP_ALIVE_SETTING)); } + autoReconnect() { return this.settings.get(AUTO_RECONNECT_SETTING) !== false; } + + setRecents(recents: RecentWorkspace[]) { this.saved = recents.filter(item => item.kind === "runtime" || item.kind === "sshRuntime"); } + + attach(workspace?: OpenWorkspace) { + for (const off of this.detach) off(); + this.detach = []; + this.workspace = workspace; + const runtime = workspace?.session.runtime; + if (!workspace) return this.store.replace({ state: "disconnected", error: this.store.get().error }); + const configuration = workspace.session.kernel.configuration; + for (const id of runtimeSettings) + if (id in this.mirror && configuration.get(id) !== this.mirror[id]) void configuration.set(id, this.mirror[id] as never); + const follow = () => { + for (const id of runtimeSettings) this.remember(id, configuration.get(id)); + if (runtime) runtime.autoReconnect = this.autoReconnect(); + }; + follow(); + this.detach.push(configuration.subscribe(follow)); + if (!runtime) return this.store.replace({ state: "disconnected", error: this.store.get().error }); + const remote = workspace.recent.kind === "sshRuntime"; + const sync = () => this.store.replace({ + state: runtime.connected ? "connected" : this.store.get().state === "failed" ? "failed" : "reconnecting", + ...clientStatus(runtime, remote ? "ssh" : "url", workspace.recent.name), targetKey: workspace.recent.id, + ...(remote ? { host: workspace.recent.name.split(":")[0], port: undefined, workspace: workspace.recent.remotePath } : {}), + }); + sync(); + this.detach.push( + runtime.subscribe("connection.change", ({ state }) => state === "disconnected" ? this.store.set({ state: "disconnected" }) : sync()), + runtime.subscribe("workspace.trust", ({ trusted }) => this.store.set({ trusted: trusted === true })), + ); + } + + quickTarget(): RuntimeTarget | undefined { + const current = this.workspace?.recent; + if (current && (current.kind === "runtime" || current.kind === "sshRuntime")) return targetOf(current); + return this.saved[0] && targetOf(this.saved[0]); + } + async recents() { return this.saved.map(targetOf); } + async forget(target: RuntimeTarget) { await this.host?.forget(target.key); } + + private async open(request: OpenRequest, label: string) { + if (!this.host) throw new Error("Oxbit is still starting."); + this.store.replace({ ...this.store.get(), state: "connecting", progress: `Connecting to ${label}…`, error: undefined }); + const error = await this.host.open(request); + if (error) { + this.store.fail(error); + throw new Error(error); + } + } + async connect(target: RuntimeTarget) { + if (target.kind === "ssh") { + if (target.hostId) this.host?.startSsh(target.hostId, target.path ?? "~"); + return; + } + if (!target.url) throw new Error("This runtime has no address. Pair it again."); + await this.open({ kind: "runtime", url: target.url, runtimeId: target.runtimeId }, target.name); + } + pair(url: string, code: string) { + return this.open({ kind: "runtime", url, code }, url); + } + async disconnect() { + if (this.workspace?.session.runtime) await this.host?.close(); + } + async restart() { + const workspace = this.workspace; + if (workspace?.remote) await ssh.runtimeResume(workspace.remote.id); + else workspace?.session.runtime?.reconnect(); + } + async trust(trusted: boolean) { + const session = this.workspace?.session; + if (!session?.runtime) return; + await session.runtime.trust(trusted); + this.store.set({ trusted: !!session.runtime.session?.trusted }); + session.kernel.context.set("trusted", !!session.runtime.session?.trusted); + session.workbench.touch(); + } + discover(listener: (runtimes: DiscoveredRuntime[]) => void) { + return discovery.watch(runtimes => listener(runtimes.map(runtime => ({ + ...runtime, paired: this.saved.some(recent => recent.runtimeId === runtime.runtimeId), + })))); + } + async sshHosts(): Promise { + const hosts = await ssh.hosts(); + return hosts.map(host => ({ + id: host.id, label: host.label, detail: `${host.username}@${host.hostname}${host.port === 22 ? "" : ":" + host.port}`, + lastPath: this.saved.find(recent => recent.hostId === host.id)?.remotePath, + })); + } + async startSsh(hostId: string, path: string) { this.host?.startSsh(hostId, path); } + manageSsh() { this.host?.manageSsh(); } +} + +export const runtimeConnector = new IosRuntimeConnector(); diff --git a/apps/ios/src/runtime.test.ts b/apps/ios/src/runtime.test.ts index 97a3a85a..d33bae31 100644 --- a/apps/ios/src/runtime.test.ts +++ b/apps/ios/src/runtime.test.ts @@ -45,11 +45,24 @@ describe("iOS runtime connection", () => { }); it("stops a failed client so background retries cannot outlive the connection attempt", async () => { - mocks.connect.mockRejectedValue(new Error("Cannot connect to runtime")); - await expect(connectRuntime("http://computer.local:9277")).rejects.toThrow("Cannot connect"); + mocks.connect.mockRejectedValue(new Error("Pairing expired")); + await expect(connectRuntime("http://computer.local:9277")).rejects.toThrow("Pairing expired"); expect(mocks.dispose).toHaveBeenCalledOnce(); }); + it("explains a refused connection from the native health check", async () => { + mocks.connect.mockRejectedValue(new Error("Cannot connect to runtime")); + mocks.credentials.mockImplementation(async ({ operation }) => operation === "health" + ? { runtime: { id: "abc12345", name: "studio", version: "0.4.2", startedAt: 1 } } : { token: "native-test-token" }); + await expect(connectRuntime("http://computer.local:9277", undefined, "abc12345")).rejects.toThrow("studio is running but did not accept this device"); + expect(mocks.credentials).toHaveBeenCalledWith({ operation: "get", url: "http://computer.local:9277", code: undefined, runtimeId: "abc12345" }); + mocks.credentials.mockImplementation(async ({ operation }) => { + if (operation === "health") throw new Error("The runtime is unreachable: Could not connect to the server."); + return { token: "native-test-token" }; + }); + await expect(connectRuntime("http://computer.local:9277")).rejects.toThrow(/No runtime answered[^]*Could not connect to the server/); + }); + it("keeps drafts scoped to the remote workspace, with a filesystem-safe identity", async () => { const remote = (key: string, port = 9277) => ({ url: `http://computer.local:${port}`, workspaceId: "default", session: { workspaceKey: key } }) as RuntimeClient; const first = await runtimeScope(remote("project-one")); diff --git a/apps/ios/src/runtime.ts b/apps/ios/src/runtime.ts index e4272df6..504b279d 100644 --- a/apps/ios/src/runtime.ts +++ b/apps/ios/src/runtime.ts @@ -1,5 +1,6 @@ import { RuntimeClient } from "@oxbit/app-workbench"; import { native } from "@oxbit/host-ios"; +import { RUNTIME_OUTPUT_SEPARATOR } from "@oxbit/protocol"; export function runtimeUrl(value: string): string { let url: URL; @@ -10,10 +11,10 @@ export function runtimeUrl(value: string): string { return url.origin; } -export async function connectRuntime(address: string, code?: string): Promise { +export async function connectRuntime(address: string, code?: string, runtimeId?: string): Promise { const url = runtimeUrl(address); const credential = await native.runtimeCredentials({ - operation: code?.trim() ? "pair" : "get", url, code: code?.trim(), + operation: code?.trim() ? "pair" : "get", url, code: code?.trim(), runtimeId, }); if (!credential.token) throw new Error("Enter the pairing code shown by the runtime on your computer."); const runtime = new RuntimeClient(url, "default", { token: credential.token, persistToken: false }); @@ -22,7 +23,20 @@ export async function connectRuntime(address: string, code?: string): Promise void } | { kind: "recent"; recent: RecentWorkspace }; @@ -58,15 +59,16 @@ export async function openWorkspace(request: OpenRequest): Promise("extension-disabled"))?.includes(languageFeature.manifest.id)) @@ -111,7 +113,7 @@ async function openSsh(request: Extract): Promise< let session: Session | undefined; try { session = await createWorkbenchSession({ - filesystem, persistence: new IosPersistence(filesystem.id), protectUnload: false, preserveFilesystem: true, iconPackStore, + filesystem, persistence: new IosPersistence(filesystem.id), protectUnload: false, preserveFilesystem: true, iconPackStore, runtimeConnector, }); const recent: RecentWorkspace = { id: "ssh:" + filesystem.id.slice(4), kind: "ssh", name: `${request.label}: ${filesystem.name}`, @@ -134,12 +136,12 @@ async function openSshRuntime(request: Extract item.id === id); const next = await forgetWorkspace(id); if (recent?.kind === "runtime" && recent.url) { - if (!next.some(item => item.kind === "runtime" && item.url === recent.url)) - await native.runtimeCredentials({ operation: "forget", url: recent.url }); + if (!next.some(item => item.kind === "runtime" && (item.url === recent.url || (recent.runtimeId && item.runtimeId === recent.runtimeId)))) + await native.runtimeCredentials({ operation: "forget", url: recent.url, runtimeId: recent.runtimeId }); } else if (recent?.kind === "bookmark") await native.forgetFolder(id).catch(() => {}); if ((await native.storageGet(SESSION_SCOPE, LAST_KEY)) === id) await native.storageSet(SESSION_SCOPE, LAST_KEY, null); return next; diff --git a/apps/ios/tsconfig.json b/apps/ios/tsconfig.json index dfbbd995..9f064f32 100644 --- a/apps/ios/tsconfig.json +++ b/apps/ios/tsconfig.json @@ -14,12 +14,32 @@ "src/**/*.test.ts" ], "references": [ - { "path": "../../packages/app-workbench" }, - { "path": "../../packages/host-ios" }, - { "path": "../../packages/host-browser" }, - { "path": "../../packages/workbench" }, - { "path": "../../packages/sdk" }, - { "path": "../../packages/ui" }, - { "path": "../../packages/themes" } + { + "path": "../../packages/app-workbench" + }, + { + "path": "../../packages/host-ios" + }, + { + "path": "../../packages/host-browser" + }, + { + "path": "../../packages/workbench" + }, + { + "path": "../../packages/sdk" + }, + { + "path": "../../packages/ui" + }, + { + "path": "../../packages/themes" + }, + { + "path": "../../packages/features/runtime" + }, + { + "path": "../../packages/protocol" + } ] } diff --git a/bun.lock b/bun.lock index db6faf6a..7e32704d 100644 --- a/bun.lock +++ b/bun.lock @@ -56,8 +56,10 @@ "version": "0.4.1", "dependencies": { "@oxbit/app-workbench": "workspace:*", + "@oxbit/feature-runtime": "workspace:*", "@oxbit/host-browser": "workspace:*", "@oxbit/host-ios": "workspace:*", + "@oxbit/protocol": "workspace:*", "@oxbit/sdk": "workspace:*", "@oxbit/themes": "workspace:*", "@oxbit/ui": "workspace:*", From 976ef9f7b01c6a69fe61ae3f0b6000b3dffe41fc Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 01:24:37 -0400 Subject: [PATCH 19/37] Test the Runtime page and one-tap on web, and fix its actions The page called connector.trust, disconnect, and restart unbound, so trust failed with 'this.session' undefined. Switch styles now outrank the global checkbox rules. New browser spec covers pairing from the page, one-tap reconnect, and the failure explanation; the mobile and tooltip specs use the page in place of the removed dialog. --- apps/web/src/main.tsx | 1 + packages/features/runtime/src/page.tsx | 8 +-- packages/features/runtime/src/runtime.css | 21 ++++---- tests/browser/mobile.spec.ts | 29 ++++++----- tests/browser/runtime-page.spec.ts | 63 +++++++++++++++++++++++ tests/browser/tooltips.spec.ts | 2 +- 6 files changed, 97 insertions(+), 27 deletions(-) create mode 100644 tests/browser/runtime-page.spec.ts diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 9f3d53fb..e23250c1 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -104,6 +104,7 @@ function App() { ...s, ready: true, connectRuntime, + runtimeConnector: connector, useBrowserWorkspace, openFile: (path: string, options?: any) => s.workbench.openFile(path, options), diff --git a/packages/features/runtime/src/page.tsx b/packages/features/runtime/src/page.tsx index d14079df..27f5ca68 100644 --- a/packages/features/runtime/src/page.tsx +++ b/packages/features/runtime/src/page.tsx @@ -79,8 +79,8 @@ export function RuntimePage({ connector, settings, onClose }: { )}
{connected ? <> - {connector.disconnect && } - {connector.restart && } + {connector.restart && } : quick ? ( )} - {!connected && connector.restart && status.kind && } + {!connected && connector.restart && status.kind && }
@@ -106,7 +106,7 @@ export function RuntimePage({ connector, settings, onClose }: { )}
- {connected && connector.trust && } + {connected && connector.trust && connector.trust!(value)} />} {settings && } {connector.discover && } diff --git a/packages/features/runtime/src/runtime.css b/packages/features/runtime/src/runtime.css index 2e61225e..02c36c07 100644 --- a/packages/features/runtime/src/runtime.css +++ b/packages/features/runtime/src/runtime.css @@ -59,17 +59,20 @@ .runtime-card .custom-select { width: 100%; max-width: 320px; } .runtime-hint { margin: 0; color: var(--fg-3); font-size: var(--font-small-font-size); } .runtime-switch { display: flex; align-items: center; gap: 10px; min-height: 28px; cursor: pointer; } -.runtime-switch > input { - appearance: none; -webkit-appearance: none; flex: none; position: relative; margin: 0; width: 34px; height: 20px; - border-radius: 999px; background: var(--bd-strong); transition: background 0.15s; cursor: inherit; +.runtime-switch > input[type=checkbox] { + appearance: none; -webkit-appearance: none; display: block; flex: none; position: relative; margin: 0; padding: 0; + width: 34px; height: 20px; min-width: 34px; min-height: 20px; border: 0; border-radius: 999px; + background: var(--bd-strong); transition: background 0.15s; cursor: inherit; } -.runtime-switch > input::after { - content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; - background: var(--bg-editor); transition: transform 0.15s; +.runtime-switch > input[type=checkbox]::after { + content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border: 0; border-radius: 50%; + background: var(--bg-editor); transform: none; transition: transform 0.15s; } -.runtime-switch > input:checked { background: var(--accent); } -.runtime-switch > input:checked::after { transform: translateX(14px); } -.runtime-switch > input:disabled { opacity: 0.5; } +.runtime-switch > input[type=checkbox]:checked { background: var(--accent); } +.runtime-switch > input[type=checkbox]:checked::after { + width: 16px; height: 16px; border: 0; background: var(--bg-editor); transform: translateX(14px); +} +.runtime-switch > input[type=checkbox]:disabled { opacity: 0.5; } .runtime-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; } .runtime-list > li { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-top: 1px solid var(--bd); } .runtime-list > li:first-child { border-top: 0; } diff --git a/tests/browser/mobile.spec.ts b/tests/browser/mobile.spec.ts index 8bff03ce..56bbadd9 100644 --- a/tests/browser/mobile.spec.ts +++ b/tests/browser/mobile.spec.ts @@ -220,19 +220,19 @@ test("palette search and close remain reachable as the software keyboard moves", await insideViewport(page.getByRole("dialog", { name: "Quick Open" })); }); -test("connection and workspace dialogs stay usable on a narrow phone with a keyboard", async ({ page }, info) => { +test("the Runtime page and workspace dialog stay usable on a narrow phone with a keyboard", async ({ page }, info) => { await page.setViewportSize({ width: 320, height: 568 }); await ready(page); await page.getByRole("button", { name: "Runtime connection", exact: true }).first().tap(); + const view = page.locator(".runtime-page"); + await expect(view).toBeVisible(); + await page.screenshot({ path: "/tmp/oxbit-p1/web-phone-runtime.png", animations: "disabled" }); await keyboardViewport(page, 280, 80); - const dialog = page.getByRole("dialog", { name: "Runtime Connection" }); - await insideViewport(dialog); - const connect = dialog.getByRole("button", { name: "Pair and connect", exact: true }); + const connect = view.getByRole("button", { name: "Pair and connect", exact: true }); await connect.scrollIntoViewIfNeeded(); await insideViewport(connect); - await insideViewport(dialog.getByRole("button", { name: "Close dialog" })); await page.screenshot({ path: info.outputPath("connection-keyboard.png"), animations: "disabled" }); - await dialog.getByRole("button", { name: "Close dialog" }).tap(); + await keyboardViewport(page, 568); await page.locator(".workspace-title").tap(); await insideViewport(page.getByRole("dialog", { name: "Open Workspace" })); const closeWorkspace = page.getByRole("dialog").getByRole("button", { name: "Close Workspace", exact: true }); @@ -249,10 +249,13 @@ test("restricted workspaces expose trust, show progress, and retain trust after await expect(entry).toContainText("Trust workspace tools"); await insideViewport(entry); await entry.tap(); - const dialog = page.getByRole("dialog", { name: "Runtime Connection" }); - const trust = dialog.getByRole("button", { name: "Trust workspace tools", exact: true }); + const view = page.locator(".runtime-page"); + const trust = view.getByRole("switch", { name: /Restricted|Trusted/ }); + await expect(trust).not.toBeChecked(); + await trust.scrollIntoViewIfNeeded(); await insideViewport(trust); await page.screenshot({ path: info.outputPath("workspace-trust.png"), animations: "disabled" }); + await page.screenshot({ path: "/tmp/oxbit-p1/web-phone-runtime-connected.png", animations: "disabled" }); // Delay the real RPC so pending feedback and duplicate-tap protection are observable. await page.evaluate(() => { const runtime = (window as any).__oxbit.runtime; @@ -263,17 +266,17 @@ test("restricted workspaces expose trust, show progress, and retain trust after }; }); await trust.tap(); - await expect(dialog.getByRole("button", { name: "Working…" })).toBeDisabled(); - await expect(dialog.getByRole("button", { name: "Revoke workspace trust" })).toBeVisible(); + await expect(trust).toBeDisabled(); + await expect(trust).toBeChecked(); + await expect(trust).toBeEnabled(); await expect(entry).toHaveCount(0); expect(await page.evaluate(() => (window as any).__oxbit.kernel.context.get("trusted"))).toBe(true); await page.reload(); await page.waitForFunction(() => (window as any).__oxbit?.runtime?.connected); await expect(entry).toHaveCount(0); await page.getByRole("button", { name: "Runtime connection", exact: true }).first().tap(); - await page.getByRole("button", { name: "Revoke workspace trust" }).tap(); - await expect(dialog.getByRole("button", { name: "Trust workspace tools", exact: true })).toBeVisible(); - await page.getByRole("button", { name: "Close dialog" }).tap(); + await trust.tap(); + await expect(trust).not.toBeChecked(); await expect(entry).toBeVisible(); }); diff --git a/tests/browser/runtime-page.spec.ts b/tests/browser/runtime-page.spec.ts new file mode 100644 index 00000000..f7efafef --- /dev/null +++ b/tests/browser/runtime-page.spec.ts @@ -0,0 +1,63 @@ +import { expect, test, type Page } from "@playwright/test"; + +const PAIRING_CODE = "oxbit-acceptance-2026"; +const shots = "/tmp/oxbit-p1/"; + +async function ready(page: Page) { + await page.goto("/"); + await page.waitForFunction(() => (window as any).__oxbit?.ready === true); +} +const runtimePage = (page: Page) => page.locator(".runtime-page"); + +test("the cloud button opens the Runtime page, which pairs and then describes the runtime", async ({ page }) => { + await ready(page); + await page.locator(".connection-button").click(); + await expect(runtimePage(page)).toBeVisible(); + await expect(runtimePage(page).getByRole("status").first()).toHaveText("Not connected"); + await expect(runtimePage(page).getByRole("region", { name: "Keep alive" })).toBeVisible(); + await page.screenshot({ path: shots + "web-runtime-unpaired.png" }); + await page.getByRole("textbox", { name: "Runtime URL" }).fill(new URL(page.url()).origin); + await page.getByRole("textbox", { name: "Pairing code" }).fill(PAIRING_CODE); + await page.getByRole("button", { name: "Pair and connect" }).click(); + await page.waitForFunction(() => (window as any).__oxbit?.runtime?.connected === true); + + await page.locator(".connection-button").click(); + const view = runtimePage(page); + await expect(view.locator(".runtime-hero")).toHaveAttribute("data-state", "connected"); + await expect(view.locator(".runtime-facts")).toContainText("9278"); + await expect(view.locator(".runtime-facts")).toContainText("Version"); + await expect(view.getByRole("region", { name: "Saved runtimes" })).toContainText("In use"); + await expect(view.getByRole("switch", { name: /Trusted|Restricted/ })).toBeVisible(); + await page.screenshot({ path: shots + "web-runtime-connected.png" }); +}); + +test("one tap reconnects a saved runtime and opens the page with the reason when it fails", async ({ page }) => { + await ready(page); + await page.evaluate(code => (window as any).__oxbit.connectRuntime(location.origin, code), PAIRING_CODE); + await page.waitForFunction(() => (window as any).__oxbit?.runtime?.connected === true); + await page.evaluate(() => (window as any).__oxbit.runtimeConnector.disconnect()); + const cloud = page.locator(".connection-button"); + await expect(cloud).toHaveAttribute("data-state", "disconnected"); + + await cloud.click(); + await page.waitForFunction(() => (window as any).__oxbit?.runtime?.connected === true); + await expect(page.locator(".connection-button")).toHaveAttribute("data-state", "connected"); + await expect(runtimePage(page)).toHaveCount(0); + + await page.evaluate(() => (window as any).__oxbit.runtimeConnector.disconnect()); + // New sockets go to a closed port while HTTP still answers, as when a proxy drops WebSocket upgrades. + await page.evaluate(() => { + const Native = WebSocket; + (window as any).__nativeWebSocket = Native; + window.WebSocket = class extends Native { constructor(url: string | URL) { super(String(url).replace(/:\d+\//, ":9/")); } } as typeof WebSocket; + }); + await page.locator(".connection-button").click(); + const alert = runtimePage(page).getByRole("alert"); + await expect(alert).toContainText("refused this browser"); + await expect(page.locator(".connection-button")).toHaveAttribute("data-state", "failed"); + await page.screenshot({ path: shots + "web-runtime-failed.png" }); + + await page.evaluate(() => { window.WebSocket = (window as any).__nativeWebSocket; }); + await runtimePage(page).getByRole("button", { name: /^Connect to / }).click(); + await page.waitForFunction(() => (window as any).__oxbit?.runtime?.connected === true); +}); diff --git a/tests/browser/tooltips.spec.ts b/tests/browser/tooltips.spec.ts index 0e88c119..2ccbdb46 100644 --- a/tests/browser/tooltips.spec.ts +++ b/tests/browser/tooltips.spec.ts @@ -200,7 +200,7 @@ test("dialog icons have one tooltip and dismiss it before closing the dialog", a page, }) => { await ready(page); - await page.locator(".connection-button").click(); + await page.evaluate(() => (window as any).__oxbit.runCommand("workspace.open")); const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); await dialog From e0ca4f8d581f1eda5cab7aa17b41a5a6962d21a1 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 01:27:10 -0400 Subject: [PATCH 20/37] Test the iOS Runtime page, discovery, and one-tap in the harness The bridge mocks runtime_discovery and runtime_credentials and can seed app storage. Specs cover listing and pairing a network runtime, keep-alive set on the start screen carrying into a workspace, and one tap opening the page with the reason. The Runtime sheet is a labelled dialog. --- apps/ios/src/main.tsx | 2 +- tests/ios/bridge.ts | 24 +++++++++--- tests/ios/runtime.spec.ts | 67 ++++++++++++++++++++++++++++++++ tests/ios/source-control.spec.ts | 2 +- 4 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 tests/ios/runtime.spec.ts diff --git a/apps/ios/src/main.tsx b/apps/ios/src/main.tsx index 68c43bbe..ddd139fa 100644 --- a/apps/ios/src/main.tsx +++ b/apps/ios/src/main.tsx @@ -280,7 +280,7 @@ function App() { onConnect={() => void workspace.session.workbench.run("runtime.cloud")} /> )} {(sheet || !workspace) &&
{start}
} - {runtimePage &&
+ {runtimePage &&
setRuntimePage(false)} />
} {gitSettings && setGitSettings(false)} />} diff --git a/tests/ios/bridge.ts b/tests/ios/bridge.ts index c7014322..746c6ed1 100644 --- a/tests/ios/bridge.ts +++ b/tests/ios/bridge.ts @@ -1,11 +1,13 @@ import type { Page } from "@playwright/test"; +export interface BridgeRuntime { runtimeId: string; name: string; version?: string; host: string; port: number; url: string } export interface BridgeAccount { id: string; provider: "github" | "gitea"; host: string; url?: string; login: string; isDefault: boolean } /** Serves the iOS shell from memory. `repository` makes Documents a clean Git repository; `accounts` seeds Git accounts. */ /** An added token names its login; the token `rejected` fails validation. */ -export async function installBridge(page: Page, { repository = false, accounts = [] as BridgeAccount[] } = {}) { - await page.addInitScript(({ repository, accounts: seeded }) => { +/** `runtimes` are Bonjour results; `stored` seeds app storage as `scope:key`. No runtime answers its URL. */ +export async function installBridge(page: Page, { repository = false, accounts = [] as BridgeAccount[], runtimes = [] as BridgeRuntime[], stored = {} as Record } = {}) { + await page.addInitScript(({ repository, accounts: seeded, runtimes, stored }) => { const author = { name: "Oxbit Test", email: "oxbit@example.test" }; let accounts = seeded.map(account => ({ ...account })); let created = 0; @@ -38,7 +40,17 @@ export async function installBridge(page: Page, { repository = false, accounts = } return credentials(); } - const storage = new Map(); + const storage = new Map(Object.entries(stored)); + const runtimeRequests: Record[] = []; + function runtimeCredentials(request: { operation: string; url: string; runtimeId?: string; code?: string }) { + runtimeRequests.push({ ...request }); + if (request.operation === "health") throw { code: "RUNTIME_HEALTH", message: "The runtime is unreachable: Could not connect to the server." }; + if (request.operation === "pair") { + const runtime = runtimes.find(item => item.url === request.url); + return { token: "paired-token", runtime: runtime && { id: runtime.runtimeId, name: runtime.name, version: runtime.version ?? "0.4.1", startedAt: 1 } }; + } + return request.operation === "get" ? { token: "saved-token" } : {}; + } const roots = new Set(); const packs = new Map(); const closed: { id: string; title: string | null | undefined }[] = []; @@ -77,6 +89,8 @@ export async function installBridge(page: Page, { repository = false, accounts = return { id: "second", name: "Second", path: "/device/Second", stale: false }; if (command === "plugin:oxbit-files|close_folder") return; if (command === "plugin:oxbit-files|git_credentials") return gitCredentials(args.request); + if (command === "plugin:oxbit-files|runtime_credentials") return runtimeCredentials(args.request); + if (command === "plugin:oxbit-files|runtime_discovery") return { runtimes: args.request.operation === "stop" ? [] : runtimes }; if (command === "plugin:oxbit-files|commit_signing") { const request = args.request; if (request.operation === "generate") signing = { @@ -142,7 +156,7 @@ export async function installBridge(page: Page, { repository = false, accounts = Object.assign(window, { __TAURI_INTERNALS__: bridge, __TAURI_EVENT_PLUGIN_INTERNALS__: { unregisterListener() {} }, - __iosTest: { closed, copied, storage, get opened() { return opened; }, get signing() { return signing; } }, + __iosTest: { closed, copied, storage, runtimeRequests, get opened() { return opened; }, get signing() { return signing; } }, }); - }, { repository, accounts }); + }, { repository, accounts, runtimes, stored }); } diff --git a/tests/ios/runtime.spec.ts b/tests/ios/runtime.spec.ts new file mode 100644 index 00000000..dbd48717 --- /dev/null +++ b/tests/ios/runtime.spec.ts @@ -0,0 +1,67 @@ +import { expect, test, type Page } from "@playwright/test"; +import { installBridge, type BridgeRuntime } from "./bridge.js"; + +const studio: BridgeRuntime = { runtimeId: "abc12345-0000-4000-8000-000000000001", name: "studio", version: "0.4.1", host: "127.0.0.1", port: 1, url: "http://127.0.0.1:1" }; +const laptop: BridgeRuntime = { runtimeId: "abc12345-0000-4000-8000-000000000002", name: "laptop", host: "127.0.0.1", port: 2, url: "http://127.0.0.1:2" }; +const shots = "/tmp/oxbit-p1/"; +const requests = (page: Page) => page.evaluate(() => (window as any).__iosTest.runtimeRequests); + +test("Connect Runtime opens the Runtime page with network runtimes and pairs one", async ({ page }) => { + await installBridge(page, { + runtimes: [studio, laptop], + stored: { "session:recents": [{ id: "runtime:laptop", kind: "runtime", name: "laptop", url: laptop.url, runtimeId: laptop.runtimeId, lastOpened: 1 }] }, + }); + await page.goto("/"); + await page.getByRole("button", { name: "Connect Runtime…" }).click(); + const sheet = page.locator(".ios-runtime-sheet"); + const network = sheet.getByRole("region", { name: "On this network" }); + await expect(network).toContainText("studio"); + await expect(network.getByRole("button", { name: "Connect", exact: true })).toHaveCount(1); + await expect(sheet.getByRole("region", { name: "Saved runtimes" })).toContainText("laptop"); + await expect(sheet.locator(".runtime-command")).toHaveText("oxbit --lan"); + await page.screenshot({ path: shots + "ios-harness-runtime-sheet.png", fullPage: true }); + + await network.getByRole("button", { name: "Pair", exact: true }).click(); + await expect(sheet.getByRole("textbox", { name: "Runtime URL" })).toHaveValue(studio.url); + await expect(sheet.getByRole("textbox", { name: "Pairing code" })).toBeFocused(); + await sheet.getByRole("textbox", { name: "Pairing code" }).fill("owner-code"); + await sheet.getByRole("button", { name: "Pair and connect" }).click(); + const alert = sheet.getByRole("alert"); + await expect(alert).toContainText("No runtime answered at http://127.0.0.1:1", { timeout: 20000 }); + expect(await requests(page)).toContainEqual({ operation: "pair", url: studio.url, code: "owner-code" }); + await page.screenshot({ path: shots + "ios-harness-runtime-failed.png", fullPage: true }); + + await sheet.getByRole("button", { name: "Done" }).click(); + await expect(sheet).toHaveCount(0); +}); + +test("keep-alive chosen on the start screen reaches the workspace settings", async ({ page }) => { + await installBridge(page); + await page.goto("/"); + await page.getByRole("button", { name: "Connect Runtime…" }).click(); + const sheet = page.locator(".ios-runtime-sheet"); + await sheet.getByRole("combobox", { name: "Keep Runtime Alive" }).click(); + await page.getByRole("option", { name: "8 hours" }).click(); + await expect.poll(() => page.evaluate(() => (window as any).__iosTest.storage.get("session:runtime-settings"))).toMatchObject({ "runtime.keepAlive": "8h" }); + await sheet.getByRole("button", { name: "Done" }).click(); + await page.getByRole("button", { name: "Oxbit Files app" }).click(); + await expect(page.locator(".cm-content")).toContainText("/device/Documents"); + // Opening a workspace pushes the stored choice into its settings; a lost value would mirror back as 75s. + await page.waitForTimeout(500); + expect(await page.evaluate(() => (window as any).__iosTest.storage.get("session:runtime-settings"))).toMatchObject({ "runtime.keepAlive": "8h" }); +}); + +test("one tap inside a workspace connects the saved runtime and opens the page with the reason", async ({ page }) => { + await installBridge(page, { + stored: { "session:recents": [{ id: "runtime:laptop", kind: "runtime", name: "laptop", url: laptop.url, runtimeId: laptop.runtimeId, lastOpened: 1 }] }, + }); + await page.goto("/"); + await page.getByRole("button", { name: "Oxbit Files app" }).click(); + await expect(page.locator(".cm-content")).toContainText("/device/Documents"); + await page.getByRole("button", { name: "Runtime connection", exact: true }).first().click(); + const view = page.locator(".runtime-page"); + await expect(view.getByRole("alert")).toContainText("No runtime answered at http://127.0.0.1:2", { timeout: 20000 }); + expect(await requests(page)).toContainEqual({ operation: "get", url: laptop.url, runtimeId: laptop.runtimeId }); + await expect(view.getByRole("button", { name: "Connect to laptop" })).toBeVisible(); + await page.screenshot({ path: shots + "ios-harness-one-tap-failed.png", fullPage: true }); +}); diff --git a/tests/ios/source-control.spec.ts b/tests/ios/source-control.spec.ts index c4f7a240..b68a9c70 100644 --- a/tests/ios/source-control.spec.ts +++ b/tests/ios/source-control.spec.ts @@ -3,7 +3,7 @@ import { installBridge } from "./bridge.js"; import { installSshBridge } from "./ssh-bridge.js"; const dialogs = [ - { button: "Connect Runtime…", dialog: "Runtime Connection" }, + { button: "Connect Runtime…", dialog: "Runtime" }, { button: "Clone Repository…", dialog: "Clone Repository" }, { button: "Git Accounts and Commit Author…", dialog: "Git Accounts and Commit Author" }, { button: "Connect with SSH…", dialog: "Connect with SSH" }, From eb828e7da6682b2d233851794b96177ac8897a74 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 01:28:10 -0400 Subject: [PATCH 21/37] Document the Runtime page, runtime settings, and the random port README describes the assigned port, --lan, --keep-alive, and the cloud button. runtime.md gains a Runtime page section with the per-app connectors; settings.md lists runtime.keepAlive and runtime.autoReconnect; the index lists runtime.md and settings.md; ios.md drops the implemented SSH tunnel item. --- README.md | 16 ++++++++++------ docs/INDEX.md | 8 +++++--- docs/ios.md | 1 - docs/remote-ssh.md | 2 +- docs/runtime.md | 14 +++++++++++++- docs/settings.md | 11 +++++++++++ 6 files changed, 40 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 68791560..8f06e78d 100644 --- a/README.md +++ b/README.md @@ -45,13 +45,17 @@ See [npm packages](docs/npm.md). | `oxbit -f` | Keep the runtime attached to the terminal | | `oxbit --no-open` | Start without opening a browser | | `oxbit --port 9300` | Choose a port for a new runtime; stop an existing one first | +| `oxbit --lan` | Serve on your network and advertise the runtime to the iOS app | +| `oxbit --keep-alive 1h` | Stop the runtime after an hour with no connected client | | `oxbit --help` | Show all options | -The first runtime uses port 9277. Later runtimes choose a free port and record it -in `~/.oxbit/workspaces//daemon.json`. The printed URL includes a pairing code -in its fragment, which the editor consumes and removes from the address bar. -Opening `http://127.0.0.1:9277` without that fragment loads the browser sample -workspace; use **Runtime connection** to pair it with your project. +Each runtime binds a port the operating system assigns, unless `--port` or `PORT` +sets one, and records it in `~/.oxbit/workspaces//daemon.json`. The printed URL +includes a pairing code in its fragment, which the editor consumes and removes from +the address bar. Opening the runtime URL without that fragment loads the browser +sample workspace; the cloud button in the title bar opens the **Runtime** page, +where you pair the runtime with its printed code. Later, one tap on the cloud button +reconnects to the last runtime. Without a global install, run `OXBIT_WORKSPACE=/absolute/path bun run start` after building. See [runtime configuration](docs/runtime.md) for network hosting, @@ -82,7 +86,7 @@ GitHub tokens and runtime pairing credentials are stored in Keychain. Run `bun run ios:dev "Oxbit iPhone"` or `bun run ios:simulator` on macOS with Xcode. See [iOS setup and runtime connections](docs/ios.md). -The [documentation index](docs/INDEX.md) links native source control, SSH and SFTP, and language-server guides with official API references. +The [documentation index](docs/INDEX.md) links native source control, SSH and SFTP, language-server, runtime discovery and keep-alive guides with official API references. The [iOS release artwork guide](docs/ios-release-assets.md) covers icon exports, App Store screenshots, and promo exports with Apple's design and size references. diff --git a/docs/INDEX.md b/docs/INDEX.md index 720afe19..60fcf7bd 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,16 +1,18 @@ # Documentation index -Created: 2026-10-02. Last updated: 2026-10-08. +Created: 2026-10-02. Last updated: 2026-10-09. | Guide | Use when | | --- | --- | | [Signed releases](release.md) | Cutting a release; signing and publishing; versioned TestFlight notes, `Internal Testing` assignment, `Public Beta` promotion, and export compliance; secrets and renewal; Apple TestFlight API references for distribution changes. | | [npm packages](npm.md) | Publishing `@oxbit/sdk` and `@oxbit/cli`; staging layout; install scripts; trusted publishing setup and token removal; npm trusted publishing and provenance references. | -| [Remote workspaces over SSH](remote-ssh.md) | Building SSH runtime payloads; the release's `remote-runtime-*` assets and the manifest pinned into the iOS build. | -| [iOS setup](ios.md) | Building the iOS app or connecting to a computer runtime. | +| [Remote workspaces over SSH](remote-ssh.md) | Building SSH runtime payloads; the release's `remote-runtime-*` assets and the manifest pinned into the iOS build; the remote runtime lease and `runtime.keepAlive`. | +| [iOS setup](ios.md) | Building the iOS app, connecting to a computer runtime, Bonjour discovery of `oxbit --lan` runtimes, and keep-alive for SSH-started runtimes; NWBrowser, NSBonjourServices, RFC 6762/6763 and Tauri mobile plugin references. | | [iOS release artwork](ios-release-assets.md) | Rendering app icon exports, native App Store screenshots, promo banners, and listing drafts; includes Apple design, asset specifications, and app privacy references. | | [Withdrawn v0.3.1 audit](release-audit-0.3.1.md) | Comparing alpha.5 with the withdrawn release, confirmed corrections, and unresolved CI failures. | | [iOS language servers](ios-language-servers.md) | Changing device-local LSPs, JavaScriptCore, or the native filesystem bridge; includes Apple and Tauri API references. | | [iOS SSH and SFTP workspaces](ios-ssh.md) | Changing SSH keys, saved hosts, known-hosts trust, the russh session pool, the SFTP FileSystem, transfers, Git over SSH remotes, or the remote runtime started from iOS; includes russh/aws-lc build notes and russh, Keychain, SFTP, git2 transport, and pack protocol references. | | [iOS source control integration](ios-source-control.md) | On-device repositories, GitHub and Gitea credentials, OpenPGP commit signing, SSH remote key choice, native transport, and official API references | | [Source Control](source-control.md) | Git workflows in runtime workspaces | +| [Runtime, trust and recovery](runtime.md) | Starting runtimes, the random port, runtime identity, `oxbit --lan` and Bonjour advertising, keep-alive, and the Runtime page and its per-app connectors; RFC 6762 and RFC 6763 references for the mDNS responder. | +| [JSON settings](settings.md) | Settings files, precedence, the schema, and the `runtime.keepAlive` and `runtime.autoReconnect` settings. | diff --git a/docs/ios.md b/docs/ios.md index 2989735f..bac98697 100644 --- a/docs/ios.md +++ b/docs/ios.md @@ -166,5 +166,4 @@ upload or distribute a build. ## Not implemented -- SSH tunnel to a remote runtime: a Rust port of the local half of `apps/runtime/src/ssh.ts`. The `russh` session pool from [SSH and SFTP workspaces](ios-ssh.md) can carry its exec and direct-tcpip channels. - Touch drag and drop for editor tabs and explorer rows. diff --git a/docs/remote-ssh.md b/docs/remote-ssh.md index 3f42e5d8..02a9dfb1 100644 --- a/docs/remote-ssh.md +++ b/docs/remote-ssh.md @@ -1,6 +1,6 @@ # Remote workspaces over SSH -Created: 2026-09-08. Last updated: 2026-10-08. +Created: 2026-09-08. Last updated: 2026-10-09. Choose **Connect over SSH…** in Oxbit's project bar, File menu, or command palette. Enter an SSH config alias or `user@host`, a remote folder or file such as `~/projects/app`, and an optional port. Remote projects appear in the project switcher and recent projects with an **SSH** label and restore when the app starts. diff --git a/docs/runtime.md b/docs/runtime.md index d9e290b0..6df2cd55 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -7,7 +7,7 @@ The file explorer loads the workspace root and the immediate children of expande On macOS, dependency installation and the runtime's `build`, `dev`, and `start` scripts automatically repair missing execute permissions on `node-pty`'s `spawn-helper`. The build and launch checks also cover installs that skipped lifecycle scripts. Run `bun run repair:pty` to repair an existing installation manually if terminals report `posix_spawnp failed.` The repair does nothing on other operating systems and leaves already executable helpers unchanged. -`oxbit [path]` starts a runtime for a workspace and opens the paired editor, `oxbit --stop` ends it, and `oxbit --status` reports it. See [the README](../README.md#quick-start) for the global install. `bun run start` with `OXBIT_WORKSPACE=/absolute/workspace` serves the same runtime in the foreground under Node 24. The runtime serves the production browser app on `127.0.0.1` at a port the operating system assigns; `--port` or `PORT` fixes it, and `bun run dev` uses 9277 for the web dev proxy. The launch URL carries `#pair=` and, for a named file, `&open=`; both are consumed once and stripped from the address bar. Without the fragment the app starts in the browser sample workspace, and the printed owner pairing code can be entered in the connection dialog. `OXBIT_WATCH_POLLING=1` selects filesystem polling; the runtime accepts requests before the watcher's first scan finishes, watcher exhaustion (EMFILE/ENOSPC) switches to polling, and other watcher failures reach watching clients as `fs.watchError` and stderr. `PORT`, `HOST`, `OXBIT_DATA_DIR`, `OXBIT_PAIRING_CODE`, `OXBIT_WEB_ROOT` and comma-separated `OXBIT_ORIGINS` configure hosting; `--port` and `--host` override the first two. `OXBIT_PAIRING_ATTEMPTS_PER_MINUTE` sets the pairing attempts accepted per client address each minute (default 10); the browser test server raises it because every test pairs again. Each detached runtime records its runtime id, pid, host, port and pairing code in `/daemon.json` at mode 0600 and appends output to `/daemon.log`; reuse needs both a live pid and a healthy reply, so a stale record cannot hand you an unrelated process. Vite development access requires `OXBIT_ORIGINS=http://localhost:9279,http://127.0.0.1:9279`. Bind to loopback by default; use a TLS reverse proxy when exposing the runtime to a network. +`oxbit [path]` starts a runtime for a workspace and opens the paired editor, `oxbit --stop` ends it, and `oxbit --status` reports it. See [the README](../README.md#quick-start) for the global install. `bun run start` with `OXBIT_WORKSPACE=/absolute/workspace` serves the same runtime in the foreground under Node 24. The runtime serves the production browser app on `127.0.0.1` at a port the operating system assigns; `--port` or `PORT` fixes it, and `bun run dev` uses 9277 for the web dev proxy. The launch URL carries `#pair=` and, for a named file, `&open=`; both are consumed once and stripped from the address bar. Without the fragment the app starts in the browser sample workspace, and the printed owner pairing code can be entered on the Runtime page. `OXBIT_WATCH_POLLING=1` selects filesystem polling; the runtime accepts requests before the watcher's first scan finishes, watcher exhaustion (EMFILE/ENOSPC) switches to polling, and other watcher failures reach watching clients as `fs.watchError` and stderr. `PORT`, `HOST`, `OXBIT_DATA_DIR`, `OXBIT_PAIRING_CODE`, `OXBIT_WEB_ROOT` and comma-separated `OXBIT_ORIGINS` configure hosting; `--port` and `--host` override the first two. `OXBIT_PAIRING_ATTEMPTS_PER_MINUTE` sets the pairing attempts accepted per client address each minute (default 10); the browser test server raises it because every test pairs again. Each detached runtime records its runtime id, pid, host, port and pairing code in `/daemon.json` at mode 0600 and appends output to `/daemon.log`; reuse needs both a live pid and a healthy reply, so a stale record cannot hand you an unrelated process. Vite development access requires `OXBIT_ORIGINS=http://localhost:9279,http://127.0.0.1:9279`. Bind to loopback by default; use a TLS reverse proxy when exposing the runtime to a network. `POST /api/pair` accepts `{code}` and returns a random session token with the current workspace capabilities and trust state. It also sets an HttpOnly, SameSite=Strict cookie. WebSocket `/ws` validates the exact Origin, caps frames at 2 MiB, and requires `auth.authenticate {token}` within 10 seconds. The cookie substitutes for an omitted token. Pairing attempts are bounded per address. Persisted session records contain token hashes. @@ -54,6 +54,18 @@ LAN mode advertises ` ()._oxbit._tcp.local` ov The CLI exits with a message naming the found and required versions when Node is older than 24. `node-pty` loads on the first terminal request; when it cannot load, that request fails with `node-pty failed to load: ` and the rest of the runtime keeps serving. A foreground runtime that cannot bind (port in use, permission denied, address unavailable) prints one line and exits 1. +## Runtime page + +The `oxbit.runtime` feature (`packages/features/runtime`) adds the **Runtime** tab, the `runtime.open` and `runtime.cloud` commands, a status bar item, and the `runtime.keepAlive` and `runtime.autoReconnect` settings. The page shows the connection state, host, port, version, uptime and workspace; Disconnect or Restart; workspace trust; keep-alive settings; saved runtimes; runtimes found on the network; SSH hosts with **Start Oxbit on this server**; a pairing form; and the last error, with runtime output under **Details**. Each app registers a `RuntimeConnector` (`packages/workbench/src/runtime-connector.ts`) as the `runtimeConnector` kernel service, and the page hides any control its connector does not implement. + +`runtime.cloud` backs the cloud button and `workspace.runtime`. When connected or connecting, it opens the page. With a known target (the current runtime, the most recent saved runtime, or the current desktop project), it connects and shows progress on the button, and opens the page with the reason if that fails. With no target, it opens the page. + +| App | Connector | Targets and actions | +| --- | --- | --- | +| Web | `apps/web/src/runtime-connector.ts` | Saved runtimes keyed by runtime id in browser storage; pairs by URL and code; explains failures by probing `/api/health` | +| Desktop | `packages/host-desktop/src/runtime-connector.ts` | The project's runtime; Restart restarts it; SSH recents become hosts for **Start Oxbit on this server**; `runtime.keepAlive` goes to `desktop_connection` | +| iOS | `apps/ios/src/runtime-connector.ts` | Saved and Bonjour runtimes, Keychain credentials by runtime id, SSH hosts; `runtime.keepAlive` goes to `ios_ssh_runtime_start`; settings are mirrored to app storage for the start screen | + ## Collaboration Collaboration is disabled by default on web, desktop and iOS. To enable it, open Extensions, select Collaboration and choose Enable; the choice is stored in the `extension-enabled` list and applies to later sessions. While it is off, runtime documents open with `fs.read` and save with `fs.write` against the document's saved revision, and the Participants status item, panel and remote cursors are absent. After it is enabled, newly opened documents join their rooms. Documents that are already open join at once when no text is lost: clean documents take the room text, and dirty documents join when the room has no unsaved edits at the same disk revision. Other dirty documents stay local; they join after their next save when the room has no unsaved edits, and otherwise when they are reopened. Documents restored at startup follow the same rules. Disabling it leaves every room. Open documents keep their text and save through `fs.write`, so a disk change since the last shared save is reported as a conflict. diff --git a/docs/settings.md b/docs/settings.md index 9e24c09e..755a5ac3 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -1,5 +1,7 @@ # JSON settings +Created: 2026-09-08. Last updated: 2026-10-09. + Oxbit merges settings in this order, from lowest to highest priority: 1. Built-in defaults. @@ -84,6 +86,15 @@ The header has three rows: the title with a Keyboard Shortcuts button, the searc The All Settings and Source Control categories show a Git Accounts and Commit Author… button when the `git.account` command is registered and enabled. iOS registers it; desktop and web hide the button. +## Runtime settings + +| Setting | Values | Effect | +| --- | --- | --- | +| `runtime.keepAlive` | `75s` (default), `15m`, `1h`, `8h`, `untilStopped` | How long a runtime that Oxbit starts over SSH, from the desktop or iOS app, keeps running after its last client disconnects. CLI runtimes use `oxbit --keep-alive` instead. | +| `runtime.autoReconnect` | `true` (default), `false` | Reconnect a URL runtime after its connection drops, and on iOS when the app returns to the foreground. | + +Both appear in the **Runtime** category and on the Runtime page. See [Runtime page](runtime.md#runtime-page). + ## Verification `tests/browser/settings-header.spec.ts` checks header control heights at 320px, 393px and 1280px, the matching row above a 400px keyboard viewport on `webkit-phone`, and the Git Accounts visibility rule. From 8dbf4e63d79a54aad6bae927685db25f82637ee2 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 01:29:13 -0400 Subject: [PATCH 22/37] Regenerate the theme coverage inventory for the Runtime page styles --- docs/theme-coverage.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/theme-coverage.md b/docs/theme-coverage.md index 80e98f8e..eeeef276 100644 --- a/docs/theme-coverage.md +++ b/docs/theme-coverage.md @@ -4,13 +4,14 @@ Generated by `bun run themes:coverage`. Every shared palette variable maps to a | Surface source | Catalog roles consumed | | --- | --- | -| `apps/desktop/src/desktop.css` | `colors.accent.primary`, `colors.border.default`, `colors.control.hover.background`, `colors.diagnostic.warning`, `colors.list.selection.background`, `colors.surface.background`, `colors.text.muted`, `colors.text.primary`, `colors.workbench.background`, `typography.body`, `typography.small` | +| `apps/desktop/src/desktop.css` | `colors.accent.primary`, `colors.border.default`, `colors.control.hover.background`, `colors.diagnostic.warning`, `colors.list.selection.background`, `colors.surface.background`, `colors.text.muted`, `colors.text.primary`, `colors.workbench.background`, `typography.body`, `typography.mono`, `typography.small` | | `apps/web/src/main.tsx` | `colors.text.primary`, `typography.body` | | `packages/features/editor/src/index.tsx` | `colors.border.default`, `colors.diagnostic.error`, `colors.diagnostic.info`, `colors.diagnostic.warning`, `colors.editor.activeLine.background`, `colors.editor.background`, `colors.editor.cursor`, `colors.editor.guide`, `colors.editor.gutter.background`, `colors.editor.gutter.foreground`, `colors.editor.occurrence.background`, `colors.editor.search.activeBackground`, `colors.editor.search.background`, `colors.editor.selection.background`, `colors.editor.selection.foreground`, `colors.editor.selection.inactiveBackground`, `colors.input.background`, `colors.list.selection.background`, `colors.popover.background`, `colors.surface.background`, `colors.text.primary`, `effects.shadow`, `syntax.attr`, `syntax.comment`, `syntax.function`, `syntax.heading`, `syntax.ident`, `syntax.keyword`, `syntax.link`, `syntax.number`, `syntax.operator`, `syntax.property`, `syntax.punct`, `syntax.string`, `syntax.tag`, `syntax.text`, `syntax.type`, `syntax.variable` | | `packages/features/git/src/index.ts` | `colors.diff.added.background`, `colors.diff.deleted.background`, `colors.editor.background`, `typography.mono` | | `packages/features/language/src/log-links.ts` | `syntax.link` | | `packages/features/language/src/navigation.tsx` | `colors.editor.background`, `colors.editor.gutter.background`, `colors.editor.gutter.foreground`, `syntax.text`, `typography.editor` | | `packages/features/language/src/overlays.ts` | `colors.editor.occurrence.background`, `colors.popover.background`, `colors.text.muted`, `syntax.link` | +| `packages/features/runtime/src/runtime.css` | `colors.accent.background`, `colors.accent.primary`, `colors.border.default`, `colors.border.strong`, `colors.diagnostic.error`, `colors.diagnostic.success`, `colors.diagnostic.warning`, `colors.editor.background`, `colors.surface.background`, `colors.text.muted`, `colors.text.primary`, `colors.text.secondary`, `typography.heading`, `typography.label`, `typography.mono`, `typography.small` | | `packages/features/terminal/src/index.ts` | `typography.small` | | `packages/ui/src/classicos98.css` | `colors.accent.foreground`, `colors.accent.primary`, `colors.editor.background`, `colors.input.background`, `colors.list.selection.background`, `colors.selection.foreground`, `colors.text.primary`, `colors.workbench.background`, `effects.shadow` | | `packages/ui/src/tokens.css` | `colors.accent.background`, `colors.accent.primary`, `colors.border.default`, `colors.border.strong`, `colors.focus.border`, `colors.link.foreground`, `colors.link.hoverForeground`, `colors.popover.background`, `colors.popover.foreground`, `colors.selection.background`, `colors.selection.foreground`, `colors.text.primary`, `colors.workbench.background`, `typography.body` | From e919e1dd4658434b6858301959729c2ce1fb6b3a Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 01:44:37 -0400 Subject: [PATCH 23/37] Expect the Runtime page from the workspace dialog's connect action --- tests/browser/dialogs-symbols.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/browser/dialogs-symbols.spec.ts b/tests/browser/dialogs-symbols.spec.ts index d0b35eae..26a12ab5 100644 --- a/tests/browser/dialogs-symbols.spec.ts +++ b/tests/browser/dialogs-symbols.spec.ts @@ -166,7 +166,8 @@ test("dialog keyboard actions trap focus and preserve workspace actions", async await page.keyboard.press("Enter"); expect((await chooser).isMultiple()).toBe(false); await dialog.getByRole("button", { name: /Connect to a runtime/ }).click(); - await expect(dialog).toHaveAttribute("aria-label", "Runtime Connection"); + await expect(dialog).toHaveCount(0); + await expect(page.locator(".runtime-page")).toBeVisible(); }); test("symbols explain when the browser workspace has no language server", async ({ From 7c67dca7c3f3bf7a400c22ce9366195538528e72 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 01:52:17 -0400 Subject: [PATCH 24/37] Test the SSH runtime keep-alive lease live --- .../ios/src-tauri/src/ssh/live_lease_tests.rs | 117 ++++++++++++++++++ .../src/ssh/live_runtime_task_tests.rs | 4 +- .../src-tauri/src/ssh/live_runtime_tests.rs | 9 +- apps/ios/src-tauri/src/ssh/mod.rs | 2 + 4 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 apps/ios/src-tauri/src/ssh/live_lease_tests.rs diff --git a/apps/ios/src-tauri/src/ssh/live_lease_tests.rs b/apps/ios/src-tauri/src/ssh/live_lease_tests.rs new file mode 100644 index 00000000..0deb165f --- /dev/null +++ b/apps/ios/src-tauri/src/ssh/live_lease_tests.rs @@ -0,0 +1,117 @@ +//! Live keep-alive lease on the Docker sshd fixture: after the SSH session drops, a runtime with +//! lease 0 keeps running and one with a short lease exits once it expires. +use super::{ + live_runtime_tests::{live, remote_runtimes, source, start, Live, Seen}, + live_tests::exec, + runtime_process::Event, + runtime_session::RemoteRuntime, +}; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; + +/// Longer than the 10 s heartbeat, so only a dropped connection lets it expire. +const LEASE: u64 = 15_000; +/// Per-connection sshd on port 22: retitled `sshd: root@notty`, or the `-R` child under qemu. +const SESSION: &str = "'sshd: [r]oot|sshd -D.* -[R]$'"; + +fn processes(container: &str) -> String { + exec(container, "ps -eo pid,args") +} + +fn sessions(container: &str) -> String { + exec(container, &format!("pgrep -f {SESSION} || true")) +} + +/// Drops the last handle first so the closed channel finds no runtime to relaunch, then kills the +/// fixture's SSH sessions so no heartbeat or client remains. +async fn sever(container: &str, runtime: Arc) -> u32 { + let pid = runtime.pid().await.expect("the launch reports its PID"); + drop(runtime); + assert_ne!(sessions(container), "", "{}", processes(container)); + exec(container, &format!("pkill -9 -f {SESSION}")); + let deadline = Instant::now() + Duration::from_secs(5); + while !sessions(container).is_empty() && Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert_eq!(sessions(container), "", "{}", processes(container)); + pid +} + +async fn gone_within(container: &str, limit: Duration) -> Option { + let started = Instant::now(); + while started.elapsed() < limit { + if remote_runtimes(container).is_empty() { + return Some(started.elapsed()); + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + None +} + +fn reconnected(seen: &Seen) -> bool { + seen.lock() + .unwrap() + .iter() + .any(|event| matches!(event, Event::Reconnecting { .. })) +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "needs the Docker sshd fixture"] +async fn live_remote_runtime_lease() { + let Live { + container, + served, + pool, + scratch, + .. + } = live().await; + let hosted = source(format!("{served}/linux-x64.tar.gz"), scratch.clone()); + + let seen: Seen = Arc::default(); + let runtime = start(&pool, "live", hosted.clone(), &seen, Some(0)).await; + runtime.ensure(true).await.unwrap(); + let pid = sever(&container, runtime).await; + tokio::time::sleep(Duration::from_secs(15)).await; + assert_eq!( + remote_runtimes(&container), + pid.to_string(), + "{}", + processes(&container) + ); + assert!(!reconnected(&seen), "{:?}", seen.lock().unwrap()); + println!("PASS lease 0 keeps pid {pid} running 15 s after the SSH session dropped"); + exec(&container, &format!("kill -TERM {pid}")); + assert!( + gone_within(&container, Duration::from_secs(10)) + .await + .is_some(), + "{}", + processes(&container) + ); + + let seen: Seen = Arc::default(); + let runtime = start(&pool, "live", hosted, &seen, Some(LEASE)).await; + runtime.ensure(true).await.unwrap(); + let pid = runtime.pid().await.expect("the launch reports its PID"); + tokio::time::sleep(Duration::from_millis(LEASE + 5000)).await; + assert_eq!( + remote_runtimes(&container), + pid.to_string(), + "{}", + processes(&container) + ); + println!("PASS heartbeats hold a {LEASE} ms lease past its length while connected"); + sever(&container, runtime).await; + tokio::time::sleep(Duration::from_secs(1)).await; + assert_eq!(remote_runtimes(&container), pid.to_string()); + let gone = gone_within(&container, Duration::from_secs(30)).await; + assert!(gone.is_some(), "{}", processes(&container)); + assert!(!reconnected(&seen), "{:?}", seen.lock().unwrap()); + println!( + "PASS a {LEASE} ms lease ends pid {pid} {:.1} s after the SSH session dropped", + 1.0 + gone.unwrap().as_secs_f64() + ); + std::fs::remove_dir_all(&scratch).unwrap(); +} diff --git a/apps/ios/src-tauri/src/ssh/live_runtime_task_tests.rs b/apps/ios/src-tauri/src/ssh/live_runtime_task_tests.rs index 2ae34f6b..15e52528 100644 --- a/apps/ios/src-tauri/src/ssh/live_runtime_task_tests.rs +++ b/apps/ios/src-tauri/src/ssh/live_runtime_task_tests.rs @@ -72,7 +72,7 @@ async fn live_runtime_wget_tasks_and_budget() { exec(&container, "mv /usr/bin/curl /usr/bin/curl.off"); let seen: Seen = Arc::default(); - let runtime = start(&pool, "live", hosted(), &seen).await; + let runtime = start(&pool, "live", hosted(), &seen, None).await; let ready = runtime.ensure(true).await; exec(&container, "mv /usr/bin/curl.off /usr/bin/curl"); assert_eq!( @@ -98,7 +98,7 @@ async fn live_runtime_wget_tasks_and_budget() { ); let seen: Seen = Arc::default(); - let runtime = start(&pool, "live", hosted(), &seen).await; + let runtime = start(&pool, "live", hosted(), &seen, None).await; runtime.ensure(true).await.unwrap(); let pid = runtime.pid().await.unwrap(); let mark = seen.lock().unwrap().len(); diff --git a/apps/ios/src-tauri/src/ssh/live_runtime_tests.rs b/apps/ios/src-tauri/src/ssh/live_runtime_tests.rs index 2ee3113b..acddd667 100644 --- a/apps/ios/src-tauri/src/ssh/live_runtime_tests.rs +++ b/apps/ios/src-tauri/src/ssh/live_runtime_tests.rs @@ -60,6 +60,7 @@ pub(super) async fn start( host_id: &str, source: Source, seen: &Seen, + keep_alive: Option, ) -> Arc { let root = "/~/project".to_string(); let seen = seen.clone(); @@ -73,7 +74,7 @@ pub(super) async fn start( uuid::Uuid::new_v4().simple() ), ready_timeout: Duration::from_secs(120), - keep_alive: None, + keep_alive, }; let events = Arc::new(move |event| seen.lock().unwrap().push(event)); RemoteRuntime::open(pool.clone(), source, events, options) @@ -202,7 +203,7 @@ async fn live_remote_runtime() { } = live().await; let seen: Seen = Arc::default(); let missing = source(format!("{served}/missing.tar.gz"), scratch.clone()); - let runtime = start(&pool, "live", missing, &seen).await; + let runtime = start(&pool, "live", missing, &seen, None).await; let ready = runtime.ensure(true).await.unwrap(); assert_eq!(ready.installed, Some(Install::DeviceStream)); assert_eq!(ready.root, "/root/project"); @@ -224,7 +225,7 @@ async fn live_remote_runtime() { exec(&container, "rm -rf /root/.oxbit/remote/runtimes"); let seen: Seen = Arc::default(); let hosted = source(format!("{served}/linux-x64.tar.gz"), scratch.clone()); - let runtime = start(&pool, "live", hosted.clone(), &seen).await; + let runtime = start(&pool, "live", hosted.clone(), &seen, None).await; let ready = runtime.ensure(true).await.unwrap(); assert_eq!( ready.installed, @@ -235,7 +236,7 @@ async fn live_remote_runtime() { println!("PASS host-download install with curl"); let seen: Seen = Arc::default(); - let runtime = start(&pool, "live", hosted, &seen).await; + let runtime = start(&pool, "live", hosted, &seen, None).await; let ready = runtime.ensure(true).await.unwrap(); assert_eq!(ready.installed, Some(Install::Cached)); assert_eq!(progress(&seen), [STARTING]); diff --git a/apps/ios/src-tauri/src/ssh/mod.rs b/apps/ios/src-tauri/src/ssh/mod.rs index 50074a69..0868ce2a 100644 --- a/apps/ios/src-tauri/src/ssh/mod.rs +++ b/apps/ios/src-tauri/src/ssh/mod.rs @@ -12,6 +12,8 @@ pub mod known_hosts; #[cfg(test)] mod live_git_tests; #[cfg(test)] +mod live_lease_tests; +#[cfg(test)] mod live_runtime_task_tests; #[cfg(test)] mod live_runtime_tests; From de80ccabe4fc4faf6400acae5d232fa632fe3b8b Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 01:53:45 -0400 Subject: [PATCH 25/37] Install managed language servers on linux-arm64 - managedPlatforms adds linux-arm64; the install gate uses it. - artifacts.lock.json pins the upstream aarch64 Linux assets for marksman 2026-02-08, taplo 0.10.0, ShellCheck 0.11.0, Temurin JRE 21.0.12.1+1, and Laravel LSP 0.0.31, and the LemMinX 0.31.2 jar, with sha256 integrity computed from the downloads. - A unit test requires every native server for every managed platform. - scripts/language/package.ts bundles linux-arm64 caches; the language server workflow adds ubuntu-24.04-arm. - themes.spec.ts writes its screenshots under testInfo.outputPath() instead of /private/tmp, which does not exist on Linux. --- .github/workflows/language-servers.yml | 2 +- apps/runtime/src/managed/artifacts.lock.json | 60 ++++++++++++++++++++ apps/runtime/src/managed/install.ts | 5 +- apps/runtime/tests/lsp-manager.test.ts | 9 ++- docs/language-support.md | 4 +- docs/project-intelligence.md | 2 +- scripts/language/package.ts | 2 +- tests/browser/themes.spec.ts | 8 +-- 8 files changed, 81 insertions(+), 11 deletions(-) diff --git a/.github/workflows/language-servers.yml b/.github/workflows/language-servers.yml index 6b10153a..bf1aa6aa 100644 --- a/.github/workflows/language-servers.yml +++ b/.github/workflows/language-servers.yml @@ -19,7 +19,7 @@ jobs: strategy: fail-fast: false matrix: - runner: [macos-26, ubuntu-24.04] + runner: [macos-26, ubuntu-24.04, ubuntu-24.04-arm] runs-on: ${{ matrix.runner }} env: OXBIT_LSP_REPORT: evidence/language-milestone1/real-servers.json diff --git a/apps/runtime/src/managed/artifacts.lock.json b/apps/runtime/src/managed/artifacts.lock.json index 4171d260..94b1834b 100644 --- a/apps/runtime/src/managed/artifacts.lock.json +++ b/apps/runtime/src/managed/artifacts.lock.json @@ -120,6 +120,66 @@ "entry": "laravel-lsp", "license": "MIT", "integrity": "sha256-z4Ftro0baMSh9yiORiVzKj+H3w3M31oGfn4xW5ZDyII=" + }, + { + "id": "marksman", + "version": "2026-02-08", + "platform": "linux-arm64", + "url": "https://github.com/artempyanykh/marksman/releases/download/2026-02-08/marksman-linux-arm64", + "format": "file", + "entry": "marksman", + "license": "MIT", + "integrity": "sha256-244SRSf3+ASOPmyRghucUu8XPZLAHkfSIb8TN6/ZYvs=" + }, + { + "id": "taplo", + "version": "0.10.0", + "platform": "linux-arm64", + "url": "https://github.com/tamasfe/taplo/releases/download/0.10.0/taplo-linux-aarch64.gz", + "format": "gzip", + "entry": "taplo", + "license": "MIT", + "integrity": "sha256-AzaB0B7sg3bD/Tj6NwPHkxb14UuwE9hZlDtgoHvM3MM=" + }, + { + "id": "shellcheck", + "version": "0.11.0", + "platform": "linux-arm64", + "url": "https://github.com/koalaman/shellcheck/releases/download/v0.11.0/shellcheck-v0.11.0.linux.aarch64.tar.gz", + "format": "tar", + "entry": "shellcheck-v0.11.0/shellcheck", + "license": "GPL-3.0-or-later", + "integrity": "sha256-aKgTMZelC+uIA/jUL5kI0a8cVUDUuwX9/KjB+kfezvw=" + }, + { + "id": "jre", + "version": "21.0.12.1+1", + "platform": "linux-arm64", + "url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jre_aarch64_linux_hotspot_21.0.12.1_1.tar.gz", + "format": "tar", + "entry": "jdk-21.0.12.1+1-jre/bin/java", + "license": "GPL-2.0-with-classpath-exception", + "integrity": "sha256-FL4fNevb0fbo1X65EaP/t01tmqJVq8Xa8rEwIALPLPI=" + }, + { + "id": "lemminx", + "version": "0.31.2", + "platform": "linux-arm64", + "url": "https://repo.eclipse.org/content/repositories/lemminx-releases/org/eclipse/lemminx/org.eclipse.lemminx/0.31.2/org.eclipse.lemminx-0.31.2-uber.jar", + "format": "file", + "entry": "lemminx.jar", + "license": "EPL-2.0", + "integrity": "sha256-9P3hZOeFxjXl+GNh2/S5k7/ixfg/21KJHQWLffybz8g=" + }, + { + "id": "laravel", + "version": "0.0.31", + "platform": "linux-arm64", + "url": "https://github.com/laravel/lsp/releases/download/v0.0.31/server-v0.0.31-arm64-linux", + "format": "file", + "entry": "laravel-lsp", + "license": "MIT", + "integrity": "sha256-RfTFNT465eyrEYgqQhP88PBuNzUBx+HdsPF5toqPsnQ=" } ] } diff --git a/apps/runtime/src/managed/install.ts b/apps/runtime/src/managed/install.ts index c6cb3449..c6ab92e7 100644 --- a/apps/runtime/src/managed/install.ts +++ b/apps/runtime/src/managed/install.ts @@ -7,7 +7,8 @@ import { x as untar } from "tar"; import npmLock from "./npm/package-lock.json" with { type: "json" }; import nativeLock from "./artifacts.lock.json" with { type: "json" }; -export type ManagedPlatform = "darwin-arm64" | "linux-x64"; +export const managedPlatforms = ["darwin-arm64", "linux-x64", "linux-arm64"] as const; +export type ManagedPlatform = (typeof managedPlatforms)[number]; type Package = { version?: string; resolved?: string; integrity?: string; dependencies?: Record; optionalDependencies?: Record }; const packages = npmLock.packages as Record; export function fingerprint(value: unknown): string { @@ -85,7 +86,7 @@ export class ManagedInstaller { } finally { await fs.rm(archive, { force: true }); } } private async install(id: string, digest: string, build: (stage: string) => Promise, signal?: AbortSignal) { - if (!["darwin-arm64", "linux-x64"].includes(this.platform)) throw new Error(`Managed language servers are unavailable on ${this.platform}`); + if (!(managedPlatforms as readonly string[]).includes(this.platform)) throw new Error(`Managed language servers are unavailable on ${this.platform}`); const base = path.join(this.cache, `${id}-${this.platform}`), target = path.join(base, digest); const complete = async () => { try { return (await fs.readFile(path.join(target, ".complete"), "utf8")) === digest; } catch { return false; } }; if (await complete()) return target; diff --git a/apps/runtime/tests/lsp-manager.test.ts b/apps/runtime/tests/lsp-manager.test.ts index db055f9d..864f7cca 100644 --- a/apps/runtime/tests/lsp-manager.test.ts +++ b/apps/runtime/tests/lsp-manager.test.ts @@ -4,7 +4,8 @@ import path from "node:path"; import os from "node:os"; import { WorkspaceFiles } from "../src/filesystem.js"; import { LanguageServerManager } from "../src/lsp-manager.js"; -import { fingerprint, lockedPackages, ManagedInstaller, verifyIntegrity } from "../src/managed/install.js"; +import { fingerprint, lockedPackages, ManagedInstaller, managedPlatforms, verifyIntegrity } from "../src/managed/install.js"; +import nativeLock from "../src/managed/artifacts.lock.json" with { type: "json" }; import { createHash } from "node:crypto"; const cleanup: (() => Promise)[] = []; @@ -98,6 +99,12 @@ describe("managed installation locks", () => { await expect(installer.native("marksman", controller.signal)).rejects.toThrow(); await expect(new ManagedInstaller(root, "win32-x64" as any).native("marksman")).rejects.toThrow("No pinned"); }); + + it("pins every native server for every managed platform", () => { + const ids = [...new Set(nativeLock.artifacts.map(artifact => artifact.id))].sort(); + for (const platform of managedPlatforms) + expect(nativeLock.artifacts.filter(artifact => artifact.platform === platform).map(artifact => artifact.id).sort(), platform).toEqual(ids); + }); }); it("installs atomically once across concurrent callers and retains a working digest on failure", async () => { diff --git a/docs/language-support.md b/docs/language-support.md index 718b35e7..310e1021 100644 --- a/docs/language-support.md +++ b/docs/language-support.md @@ -79,7 +79,9 @@ node scripts/language/build-native.mjs bunx wdio run tests/desktop/language.conf.mjs ``` -`.github/workflows/language-servers.yml` provides a macOS/Ubuntu server matrix, clean and cached startup, real intelligence checks and reproducible bundles. +`.github/workflows/language-servers.yml` provides a macOS arm64, Ubuntu x64, and Ubuntu arm64 server matrix, clean and cached startup, real intelligence checks and reproducible bundles. + +Managed servers install on `darwin-arm64`, `linux-x64`, and `linux-arm64` (`managedPlatforms` in `apps/runtime/src/managed/install.ts`). `artifacts.lock.json` pins marksman, taplo, ShellCheck, the Temurin 21 JRE, LemMinX, and the Laravel LSP for each of them from the upstream release assets. The npm-based servers install from `npm/package-lock.json`, which has no platform-specific packages. Other platforms report `Managed language servers are unavailable on `. ## Capability matrix diff --git a/docs/project-intelligence.md b/docs/project-intelligence.md index 93ee8637..62f514ab 100644 --- a/docs/project-intelligence.md +++ b/docs/project-intelligence.md @@ -69,7 +69,7 @@ The syntax fallback and static project index do not implement full MDX compilati ## Laravel and Blade -The official [`laravel/lsp` 0.0.31](https://github.com/laravel/lsp/releases/tag/v0.0.31) standalone binaries are pinned with SHA-256 integrity for Apple Silicon macOS and Linux x64. The installer does not require a global Composer installation. PHP files inside an `artisan` root use Laravel alongside Intelephense; `.blade.php` files have the `blade` language ID and use Laravel with PHP/HTML syntax fallback. Plain PHP projects continue using Intelephense alone. Nested `composer.json` files do not displace the Laravel application's `artisan` root. +The official [`laravel/lsp` 0.0.31](https://github.com/laravel/lsp/releases/tag/v0.0.31) standalone binaries are pinned with SHA-256 integrity for Apple Silicon macOS, Linux x64, and Linux arm64. The installer does not require a global Composer installation. PHP files inside an `artisan` root use Laravel alongside Intelephense; `.blade.php` files have the `blade` language ID and use Laravel with PHP/HTML syntax fallback. Plain PHP projects continue using Intelephense alone. Nested `composer.json` files do not displace the Laravel application's `artisan` root. Laravel's server inspects a running project through its PHP environment. The application's dependencies and supported PHP environment must be available. It runs only through the existing trusted-runtime LSP boundary. The upstream auto-detection can be overridden using `languageServers.laravel.initializationOptions.phpEnvironment` or `phpCommand`, for example: diff --git a/scripts/language/package.ts b/scripts/language/package.ts index 4173df67..b2c3be75 100644 --- a/scripts/language/package.ts +++ b/scripts/language/package.ts @@ -11,7 +11,7 @@ await fs.mkdir(output, { recursive: true }); const records = []; for (const name of (await fs.readdir(cache)).sort()) { // The proprietary server is always obtained directly from upstream, including in CI. - if (name.startsWith("intelephense") || !/(?:darwin-arm64|linux-x64)$/.test(name)) continue; + if (name.startsWith("intelephense") || !/(?:darwin-arm64|linux-x64|linux-arm64)$/.test(name)) continue; for (const digest of (await fs.readdir(path.join(cache, name))).sort()) { if (!/^[a-f0-9]{64}$/.test(digest)) continue; const directory = path.join(cache, name, digest); diff --git a/tests/browser/themes.spec.ts b/tests/browser/themes.spec.ts index a50d856f..dc4d4db6 100644 --- a/tests/browser/themes.spec.ts +++ b/tests/browser/themes.spec.ts @@ -508,7 +508,7 @@ test("rendered Markdown, popovers and keyboard focus use the selected theme", as test("bundled creative themes and Rainbow icons switch, pair and persist", async ({ page, -}) => { +}, testInfo) => { await ready(page); await page.evaluate(async () => { const z = (window as any).__oxbit; @@ -554,7 +554,7 @@ test("bundled creative themes and Rainbow icons switch, pair and persist", async "A workbench for everyone.", ); await page.screenshot({ - path: "/private/tmp/oxbit-creative-" + id + ".png", + path: testInfo.outputPath(`oxbit-creative-${id}.png`), }); } await page.evaluate(() => @@ -589,7 +589,7 @@ test("bundled creative themes and Rainbow icons switch, pair and persist", async ); expect(broken).not.toContain(true); await page.screenshot({ - path: "/private/tmp/oxbit-creative-rainbow-icons-dark.png", + path: testInfo.outputPath("oxbit-creative-rainbow-icons-dark.png"), }); await page.evaluate(() => (window as any).__oxbit.runCommand("theme.toggle")); expect(await selected(page)).toBe("oxbit.creative/rainbow-light"); @@ -598,7 +598,7 @@ test("bundled creative themes and Rainbow icons switch, pair and persist", async "light", ); await page.screenshot({ - path: "/private/tmp/oxbit-creative-rainbow-icons-light.png", + path: testInfo.outputPath("oxbit-creative-rainbow-icons-light.png"), }); await page.reload(); await page.waitForFunction(() => !!(window as any).__oxbit?.ready); From 5c102068f2c95c79c8f4ed7047e1a56950c976b7 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 02:03:51 -0400 Subject: [PATCH 26/37] Pin tar 7.5.22 and reject unsafe managed archive entries without an uncaught exception tar 7.5.9 is affected by GHSA-qffp-2rhf-9h96, GHSA-9ppj-qmqm-q256, GHSA-vmf3-w455-68vh, GHSA-w8wr-v893-vjvp, GHSA-23hp-3jrh-7fpw, GHSA-8x88-c5mf-7j5w, GHSA-gvwx-54wh-qm9j and GHSA-r292-9mhp-454m. The extract filter threw inside tar's stream handler, which left the untar promise pending and raised an uncaught exception; it now records the rejection and throws after extraction. --- apps/runtime/package.json | 2 +- apps/runtime/src/managed/install.ts | 13 ++++++++----- apps/runtime/tests/lsp-manager.test.ts | 26 ++++++++++++++++++++++++++ bun.lock | 10 +++++----- 4 files changed, 40 insertions(+), 11 deletions(-) diff --git a/apps/runtime/package.json b/apps/runtime/package.json index 0cbbb39e..63752343 100644 --- a/apps/runtime/package.json +++ b/apps/runtime/package.json @@ -19,7 +19,7 @@ "jsonc-parser": "^3.3.1", "ignore": "7.0.5", "node-pty": "^1.1.0", - "tar": "7.5.9", + "tar": "7.5.22", "typescript": "^5.9.3", "typescript-language-server": "^5.0.0", "vscode-jsonrpc": "^8.2.1", diff --git a/apps/runtime/src/managed/install.ts b/apps/runtime/src/managed/install.ts index c6cb3449..316d0f40 100644 --- a/apps/runtime/src/managed/install.ts +++ b/apps/runtime/src/managed/install.ts @@ -68,21 +68,24 @@ export class ManagedInstaller { await fs.mkdir(directory, { recursive: true }); const archive = directory + ".tgz"; await fs.writeFile(archive, bytes); + // tar reports an exception thrown from filter as uncaught, so record the first rejection and skip entries. + let rejection: Error | undefined; try { await untar({ file: archive, cwd: directory, strip, strict: true, preservePaths: false, filter(name, candidate) { const entry = candidate as import("tar").ReadEntry; const normalized = name.split("/").slice(strip).join("/"); - if (name.startsWith("/") || name.includes("\\") || name.split("/").includes("..")) throw new Error("Unsafe archive path"); - if (!["File", "OldFile", "Directory", "SymbolicLink"].includes(entry.type)) throw new Error("Unsupported archive entry"); - if (entry.type === "SymbolicLink") { + if (name.startsWith("/") || name.includes("\\") || name.split("/").includes("..")) rejection ??= new Error("Unsafe archive path"); + else if (!["File", "OldFile", "Directory", "SymbolicLink"].includes(entry.type)) rejection ??= new Error("Unsupported archive entry"); + else if (entry.type === "SymbolicLink") { const target = path.resolve(directory, path.dirname(normalized), entry.linkpath ?? ""); - if (path.isAbsolute(entry.linkpath ?? "") || !target.startsWith(directory + path.sep)) throw new Error("Archive link leaves installation"); + if (path.isAbsolute(entry.linkpath ?? "") || !target.startsWith(directory + path.sep)) rejection ??= new Error("Archive link leaves installation"); } - return true; + return !rejection; }, }); } finally { await fs.rm(archive, { force: true }); } + if (rejection) throw rejection; } private async install(id: string, digest: string, build: (stage: string) => Promise, signal?: AbortSignal) { if (!["darwin-arm64", "linux-x64"].includes(this.platform)) throw new Error(`Managed language servers are unavailable on ${this.platform}`); diff --git a/apps/runtime/tests/lsp-manager.test.ts b/apps/runtime/tests/lsp-manager.test.ts index db055f9d..83052474 100644 --- a/apps/runtime/tests/lsp-manager.test.ts +++ b/apps/runtime/tests/lsp-manager.test.ts @@ -6,6 +6,7 @@ import { WorkspaceFiles } from "../src/filesystem.js"; import { LanguageServerManager } from "../src/lsp-manager.js"; import { fingerprint, lockedPackages, ManagedInstaller, verifyIntegrity } from "../src/managed/install.js"; import { createHash } from "node:crypto"; +import { c as createTar } from "tar"; const cleanup: (() => Promise)[] = []; afterEach(async () => { for (const fn of cleanup.splice(0).reverse()) await fn(); }); @@ -98,6 +99,31 @@ describe("managed installation locks", () => { await expect(installer.native("marksman", controller.signal)).rejects.toThrow(); await expect(new ManagedInstaller(root, "win32-x64" as any).native("marksman")).rejects.toThrow("No pinned"); }); + it("extracts package archives and rejects links that leave the installation", async () => { + const { root } = await setup(); + const installer = new ManagedInstaller(path.join(root, "installer")) as any; + async function archive(name: string, build: (directory: string) => Promise) { + const source = path.join(root, `${name}-source`), file = path.join(root, `${name}.tgz`); + await fs.mkdir(path.join(source, "package"), { recursive: true }); + await build(path.join(source, "package")); + await createTar({ gzip: true, cwd: source, file, portable: true }, ["package"]); + return fs.readFile(file); + } + const valid = await archive("valid", async directory => { + await fs.writeFile(path.join(directory, "server.js"), "export {};"); + await fs.symlink("server.js", path.join(directory, "alias.js")); + }); + await installer.extract(valid, path.join(root, "valid"), 1); + expect(await fs.readFile(path.join(root, "valid/alias.js"), "utf8")).toBe("export {};"); + const symlink = await archive("symlink", directory => fs.symlink("../../outside", path.join(directory, "escape"))); + await expect(installer.extract(symlink, path.join(root, "symlink"), 1)).rejects.toThrow("Archive link leaves installation"); + const hardlink = await archive("hardlink", async directory => { + await fs.writeFile(path.join(directory, "server.js"), "export {};"); + await fs.link(path.join(directory, "server.js"), path.join(directory, "linked.js")); + }); + await expect(installer.extract(hardlink, path.join(root, "hardlink"), 1)).rejects.toThrow("Unsupported archive entry"); + await expect(fs.lstat(path.join(root, "symlink/escape"))).rejects.toMatchObject({ code: "ENOENT" }); + }); }); it("installs atomically once across concurrent callers and retains a working digest on failure", async () => { diff --git a/bun.lock b/bun.lock index c26de945..4cab86d9 100644 --- a/bun.lock +++ b/bun.lock @@ -31,7 +31,7 @@ }, "apps/desktop": { "name": "@oxbit/desktop", - "version": "0.3.0-alpha.3", + "version": "0.4.1", "dependencies": { "@oxbit/app-workbench": "workspace:*", "@oxbit/host-browser": "workspace:*", @@ -52,7 +52,7 @@ }, "apps/ios": { "name": "@oxbit/ios", - "version": "0.3.0-alpha.3", + "version": "0.4.1", "dependencies": { "@oxbit/app-workbench": "workspace:*", "@oxbit/host-browser": "workspace:*", @@ -71,7 +71,7 @@ }, "apps/runtime": { "name": "@oxbit/runtime", - "version": "0.3.0-alpha.3", + "version": "0.4.1", "dependencies": { "@oxbit/core": "workspace:*", "@oxbit/protocol": "workspace:*", @@ -81,7 +81,7 @@ "ignore": "7.0.5", "jsonc-parser": "^3.3.1", "node-pty": "^1.1.0", - "tar": "7.5.9", + "tar": "7.5.22", "typescript": "^5.9.3", "typescript-language-server": "^5.0.0", "vscode-jsonrpc": "^8.2.1", @@ -1844,7 +1844,7 @@ "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], - "tar": ["tar@7.5.9", "", { "dependencies": { "@isaacs/fs-minipass": "4.0.1", "chownr": "3.0.0", "minipass": "7.1.3", "minizlib": "3.1.0", "yallist": "5.0.0" } }, "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg=="], + "tar": ["tar@7.5.22", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA=="], "tar-fs": ["tar-fs@3.1.3", "", { "dependencies": { "pump": "3.0.4", "tar-stream": "3.2.1" }, "optionalDependencies": { "bare-fs": "4.8.1", "bare-path": "3.1.2" } }, "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ=="], From a9b0c48c23304322b4f85003c60f0e4d11d31459 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 02:06:31 -0400 Subject: [PATCH 27/37] Upgrade markdown-it to 14.3.2 and DOMPurify to 3.4.16 markdown-it 10.0.0: GHSA-6v5v-wf23-fmfq, GHSA-6vfc-qv3f-vr6c, GHSA-253c-mchw-3w2r. linkify-it 2.2.0 -> 5.0.2: GHSA-22p9-wv53-3rq4, GHSA-v245-v573-v5vm. argparse 1 -> 2 removes sprintf-js from the markdown-it path. @types/markdown-it 14.2.0 removes highlight.js 9.18.5 (GHSA-7wwv-vh3v-89cq). dompurify 3.4.15: GHSA-p98j-92pf-mc4p, GHSA-6688-9rhm-gjv2. A render diff of 38 inputs across the previews, agent and hover configurations shows two parser changes: a pipe inside a table code span splits the cell (GFM), and linkify keeps a trailing '*b' inside the URL. Link validation and escaping output are unchanged. --- bun.lock | 44 +++++++++--------------- package.json | 2 +- packages/features/agent-acp/package.json | 4 +-- packages/features/language/package.json | 2 +- packages/features/previews/package.json | 4 +-- 5 files changed, 22 insertions(+), 34 deletions(-) diff --git a/bun.lock b/bun.lock index 4cab86d9..cd125170 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "oxbit", "devDependencies": { "@playwright/test": "1.62.1", - "@types/markdown-it": "^10.0.3", + "@types/markdown-it": "^14.2.0", "@types/node": "^24.3.0", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", @@ -191,8 +191,8 @@ "@oxbit/ui": "workspace:*", "@oxbit/workbench": "workspace:*", "diff": "8.0.4", - "dompurify": "^3.2.7", - "markdown-it": "^10.0.0", + "dompurify": "^3.4.16", + "markdown-it": "^14.3.2", "react": "19.1.0", "react-dom": "19.1.0", }, @@ -324,7 +324,7 @@ "@oxbit/documents": "workspace:*", "@oxbit/sdk": "workspace:*", "@oxbit/ui": "workspace:*", - "markdown-it": "^10.0.0", + "markdown-it": "^14.3.2", "react": "19.1.0", "react-dom": "19.1.0", "vscode-json-languageservice": "5.6.1", @@ -339,9 +339,9 @@ "@oxbit/documents": "workspace:*", "@oxbit/sdk": "workspace:*", "@oxbit/ui": "workspace:*", - "dompurify": "^3.2.7", + "dompurify": "^3.4.16", "htmlparser2": "^12.0.0", - "markdown-it": "^10.0.0", + "markdown-it": "^14.3.2", "react": "19.1.0", "react-dom": "19.1.0", }, @@ -954,8 +954,6 @@ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - "@types/highlight.js": ["@types/highlight.js@9.12.4", "", {}, "sha512-t2szdkwmg2JJyuCM20e8kR2X59WCE5Zkl4bzm1u1Oukjm79zpbiAv+QjnwLnuuV0WHEcX2NgUItu0pAMKuOPww=="], - "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], "@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "2.0.6" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="], @@ -968,7 +966,7 @@ "@types/lodash": ["@types/lodash@4.17.25", "", {}, "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ=="], - "@types/markdown-it": ["@types/markdown-it@10.0.3", "", { "dependencies": { "@types/highlight.js": "9.12.4", "@types/linkify-it": "5.0.0", "@types/mdurl": "2.0.0", "highlight.js": "9.18.5" } }, "sha512-daHJk22isOUvNssVGF2zDnnSyxHhFYhtjeX4oQaKD6QzL3ZR1QSgiD1g+Q6/WSWYVogNXYDXODtbgW/WiFCtyw=="], + "@types/markdown-it": ["@types/markdown-it@14.2.0", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ=="], "@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="], @@ -1126,7 +1124,7 @@ "archiver-utils": ["archiver-utils@5.0.2", "", { "dependencies": { "glob": "10.5.0", "graceful-fs": "4.2.11", "is-stream": "2.0.1", "lazystream": "1.0.1", "lodash": "4.18.1", "normalize-path": "3.0.0", "readable-stream": "4.7.0" } }, "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA=="], - "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "1.0.3" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], @@ -1270,7 +1268,7 @@ "domhandler": ["domhandler@6.0.1", "", { "dependencies": { "domelementtype": "3.0.0" } }, "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg=="], - "dompurify": ["dompurify@3.4.15", "", { "optionalDependencies": { "@types/trusted-types": "2.0.7" } }, "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw=="], + "dompurify": ["dompurify@3.4.16", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ=="], "domutils": ["domutils@4.0.2", "", { "dependencies": { "dom-serializer": "3.1.1", "domelementtype": "3.0.0", "domhandler": "6.0.1" } }, "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA=="], @@ -1294,7 +1292,7 @@ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - "entities": ["entities@2.0.3", "", {}, "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], @@ -1420,8 +1418,6 @@ "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], - "highlight.js": ["highlight.js@9.18.5", "", {}, "sha512-a5bFyofd/BHCX52/8i8uJkjr9DYwXIPnM/plwI6W7ezItLGqzt7X2G2nXuYSfsIJdkwwj/g9DG1LkcGJI/dDoA=="], - "hosted-git-info": ["hosted-git-info@8.1.0", "", { "dependencies": { "lru-cache": "10.4.3" } }, "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw=="], "html-parse-stringify": ["html-parse-stringify@4.0.1", "", {}, "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw=="], @@ -1560,7 +1556,7 @@ "lines-and-columns": ["lines-and-columns@2.0.4", "", {}, "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A=="], - "linkify-it": ["linkify-it@2.2.0", "", { "dependencies": { "uc.micro": "1.0.6" } }, "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw=="], + "linkify-it": ["linkify-it@5.0.2", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q=="], "locate-app": ["locate-app@2.5.0", "", { "dependencies": { "@promptbook/utils": "0.69.5", "type-fest": "4.26.0", "userhome": "1.0.1" } }, "sha512-xIqbzPMBYArJRmPGUZD9CzV9wOqmVtQnaAn3wrj3s6WYW0bQvPI7x+sPYUGmDTYMHefVK//zc6HEYZ1qnxIK+Q=="], @@ -1592,9 +1588,9 @@ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.6.0" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "markdown-it": ["markdown-it@10.0.0", "", { "dependencies": { "argparse": "1.0.10", "entities": "2.0.3", "linkify-it": "2.2.0", "mdurl": "1.0.1", "uc.micro": "1.0.6" }, "bin": { "markdown-it": "bin/markdown-it.js" } }, "sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg=="], + "markdown-it": ["markdown-it@14.3.2", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.5.0", "linkify-it": "^5.0.2", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q=="], - "mdurl": ["mdurl@1.0.1", "", {}, "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g=="], + "mdurl": ["mdurl@2.1.0", "", {}, "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg=="], "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "1.1.18" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], @@ -1712,6 +1708,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], + "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], @@ -1810,8 +1808,6 @@ "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], - "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], - "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], @@ -1886,7 +1882,7 @@ "typescript-language-server": ["typescript-language-server@5.3.0", "", { "dependencies": { "vscode-jsonrpc": "5.0.1", "vscode-languageserver-protocol": "3.17.5" }, "bin": { "typescript-language-server": "lib/cli.mjs" } }, "sha512-5puofxZHgFdAYtfNpmwCAvgtaYgg8wrUnH30m7Ze3QuguId5RNRadKASpOpyDxTyUdAF51FjhTdjntLw/EuWcQ=="], - "uc.micro": ["uc.micro@1.0.6", "", {}, "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA=="], + "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], "undici": ["undici@7.29.1", "", {}, "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q=="], @@ -2102,8 +2098,6 @@ "jest-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "4.3.0", "supports-color": "7.2.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "jszip/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], "jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "1.0.3", "inherits": "2.0.4", "isarray": "1.0.0", "process-nextick-args": "2.0.1", "safe-buffer": "5.1.2", "string_decoder": "1.1.1", "util-deprecate": "1.0.2" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], @@ -2230,8 +2224,6 @@ "cheerio/dom-serializer/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], - "cheerio/dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "cheerio/domhandler/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], "cheerio/domutils/domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], @@ -2304,10 +2296,6 @@ "@wdio/tauri-service/@wdio/types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "cheerio-select/domutils/dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - - "css-select/domutils/dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "expect-webdriverio/@vitest/snapshot/@vitest/pretty-format/tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], "expect-webdriverio/@vitest/snapshot/@vitest/utils/tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], diff --git a/package.json b/package.json index 04f09d65..c3955465 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ }, "devDependencies": { "@playwright/test": "1.62.1", - "@types/markdown-it": "^10.0.3", + "@types/markdown-it": "^14.2.0", "@types/node": "^24.3.0", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", diff --git a/packages/features/agent-acp/package.json b/packages/features/agent-acp/package.json index cedf5328..1aff5175 100644 --- a/packages/features/agent-acp/package.json +++ b/packages/features/agent-acp/package.json @@ -11,8 +11,8 @@ "@oxbit/ui": "workspace:*", "@oxbit/workbench": "workspace:*", "diff": "8.0.4", - "dompurify": "^3.2.7", - "markdown-it": "^10.0.0", + "dompurify": "^3.4.16", + "markdown-it": "^14.3.2", "react": "19.1.0", "react-dom": "19.1.0" }, diff --git a/packages/features/language/package.json b/packages/features/language/package.json index 0b96fbec..1ae169ea 100644 --- a/packages/features/language/package.json +++ b/packages/features/language/package.json @@ -18,7 +18,7 @@ "@oxbit/documents": "workspace:*", "@oxbit/sdk": "workspace:*", "@oxbit/ui": "workspace:*", - "markdown-it": "^10.0.0", + "markdown-it": "^14.3.2", "react": "19.1.0", "react-dom": "19.1.0", "vscode-json-languageservice": "5.6.1", diff --git a/packages/features/previews/package.json b/packages/features/previews/package.json index db8d8db8..83ab376b 100644 --- a/packages/features/previews/package.json +++ b/packages/features/previews/package.json @@ -11,9 +11,9 @@ "react-dom": "19.1.0", "@oxbit/documents": "workspace:*", "@oxbit/ui": "workspace:*", - "markdown-it": "^10.0.0", + "markdown-it": "^14.3.2", "htmlparser2": "^12.0.0", - "dompurify": "^3.2.7" + "dompurify": "^3.4.16" }, "license": "MIT", "author": { From 06c09ef3c9342ed30d83e8befc9ba717e5a1d90a Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 02:07:34 -0400 Subject: [PATCH 28/37] Override fast-uri to 3.1.8 fast-uri 3.1.7 via ajv 8 (root devDependency): GHSA-hrr3-gc8f-f4qj. --- bun.lock | 3 ++- package.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bun.lock b/bun.lock index cd125170..f4eb4877 100644 --- a/bun.lock +++ b/bun.lock @@ -528,6 +528,7 @@ ], "overrides": { "@xterm/addon-ligatures": "0.11.0-beta.213", + "fast-uri": "3.1.8", }, "packages": { "@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@11.9.3", "", { "dependencies": { "@jsdevtools/ono": "7.1.3", "@types/json-schema": "7.0.15", "js-yaml": "4.3.2" } }, "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ=="], @@ -1354,7 +1355,7 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="], + "fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="], "fast-xml-builder": ["fast-xml-builder@1.3.1", "", { "dependencies": { "path-expression-matcher": "1.6.2", "xml-naming": "0.3.0" } }, "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug=="], diff --git a/package.json b/package.json index c3955465..41323b78 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,8 @@ "vitest": "^3.2.4" }, "overrides": { - "@xterm/addon-ligatures": "0.11.0-beta.213" + "@xterm/addon-ligatures": "0.11.0-beta.213", + "fast-uri": "3.1.8" }, "trustedDependencies": [ "esbuild", From 489f8fc74bd727fa5cfb26c2c494b9d10423e923 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 02:10:30 -0400 Subject: [PATCH 29/37] Upgrade vitest to 4.1.11 and patch dev-tool transitive dependencies vitest 3.2.7 -> 4.1.11: @vitest/mocker GHSA-82fw-gwwq-j7x9; removes tinypool 1.1.1 (GHSA-5gmw-xhrv-c9v3, GHSA-85c8-ppgw-ccpr). Overrides: basic-ftp 6.2.2 (GHSA-c475-qrg2-pj4r), ip-address 10.7.1 (GHSA-j6r3-76f7-8jcv, GHSA-h3mg-xc3c-68pw), serialize-javascript 7.0.5 (GHSA-5c6j-r48x-rmvq, GHSA-qj8w-gfj5-8c6v), smol-toml 1.9.0 (GHSA-r4xh-jqrq-34v2), source-map-js 1.2.2 (GHSA-68fv-2mgg-jv7q). bun.lock entries for brace-expansion move to 1.1.21, 2.1.7 and 5.0.12 within their parents' ranges (GHSA-6j4f-fj2g-mc7p, GHSA-qhr7-859c-m2p7, GHSA-q2hr-2g5m-vwhr); bun applies a nested override to every parent, so the entries are set in the lock. --- bun.lock | 101 ++++++++++++++++++++------------------------------- package.json | 9 ++++- 2 files changed, 47 insertions(+), 63 deletions(-) diff --git a/bun.lock b/bun.lock index f4eb4877..7363a0b6 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.42.0", "vite": "^7.1.5", - "vitest": "^3.2.4", + "vitest": "^4.1.11", }, }, "apps/desktop": { @@ -528,7 +528,12 @@ ], "overrides": { "@xterm/addon-ligatures": "0.11.0-beta.213", + "basic-ftp": "6.2.2", "fast-uri": "3.1.8", + "ip-address": "10.7.1", + "serialize-javascript": "7.0.5", + "smol-toml": "1.9.0", + "source-map-js": "1.2.2", }, "packages": { "@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@11.9.3", "", { "dependencies": { "@jsdevtools/ono": "7.1.3", "@types/json-schema": "7.0.15", "js-yaml": "4.3.2" } }, "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ=="], @@ -909,6 +914,8 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "2.8.1" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], "@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="], @@ -1021,19 +1028,19 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "7.29.7", "@babel/plugin-transform-react-jsx-self": "7.29.7", "@babel/plugin-transform-react-jsx-source": "7.29.7", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "7.20.5", "react-refresh": "0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="], - "@vitest/expect": ["@vitest/expect@3.2.7", "", { "dependencies": { "@types/chai": "5.2.3", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "5.3.3", "tinyrainbow": "2.0.0" } }, "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w=="], + "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], - "@vitest/mocker": ["@vitest/mocker@3.2.7", "", { "dependencies": { "@vitest/spy": "3.2.7", "estree-walker": "3.0.3", "magic-string": "0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA=="], + "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], - "@vitest/pretty-format": ["@vitest/pretty-format@3.2.7", "", { "dependencies": { "tinyrainbow": "2.0.0" } }, "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "3.1.1" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], - "@vitest/runner": ["@vitest/runner@3.2.7", "", { "dependencies": { "@vitest/utils": "3.2.7", "pathe": "2.0.3", "strip-literal": "3.1.0" } }, "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA=="], + "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], "@vitest/snapshot": ["@vitest/snapshot@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "0.30.21", "pathe": "1.1.2" } }, "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ=="], - "@vitest/spy": ["@vitest/spy@3.2.7", "", { "dependencies": { "tinyspy": "4.0.6" } }, "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ=="], + "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], - "@vitest/utils": ["@vitest/utils@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "loupe": "3.2.1", "tinyrainbow": "2.0.0" } }, "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw=="], + "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "2.0.0", "tinyrainbow": "3.1.1" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="], @@ -1155,13 +1162,13 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.11.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ=="], - "basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="], + "basic-ftp": ["basic-ftp@6.2.2", "", {}, "sha512-LIkJcf/F6bTTfvE3k2YcTxrJsLKKVXiEpHZYkQP6JoqTb7khng7c13ckRTDQbqRh5IkehVH3F00sSlAa2F6DwA=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], - "brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "1.0.2", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], + "brace-expansion": ["brace-expansion@1.1.21", "", { "dependencies": { "balanced-match": "1.0.2", "concat-map": "0.0.1" } }, "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -1175,22 +1182,18 @@ "buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="], - "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], - "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="], - "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "2.0.1", "check-error": "2.1.3", "deep-eql": "5.0.2", "loupe": "3.2.1", "pathval": "2.0.1" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + "chai": ["chai@6.3.0", "", {}, "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "chardet": ["chardet@2.2.0", "", {}, "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA=="], - "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], - "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "2.1.0", "dom-serializer": "2.0.0", "domhandler": "5.0.3", "domutils": "3.2.2", "encoding-sniffer": "0.2.1", "htmlparser2": "10.1.0", "parse5": "7.3.0", "parse5-htmlparser2-tree-adapter": "7.1.0", "parse5-parser-stream": "7.1.2", "undici": "7.29.1", "whatwg-mimetype": "4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="], "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "1.0.0", "css-select": "5.2.2", "css-what": "6.2.2", "domelementtype": "2.3.0", "domhandler": "5.0.3", "domutils": "3.2.2" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], @@ -1297,7 +1300,7 @@ "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], - "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], @@ -1455,7 +1458,7 @@ "inquirer": ["inquirer@12.11.1", "", { "dependencies": { "@inquirer/ansi": "1.0.2", "@inquirer/core": "10.3.2", "@inquirer/prompts": "7.10.1", "@inquirer/type": "3.0.10", "mute-stream": "2.0.0", "run-async": "4.0.6", "rxjs": "7.8.2" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-9VF7mrY+3OmsAfjH3yKz/pLbJ5z22E23hENKw3/LNSaA/sAt3v49bDRY+Ygct1xwuKT+U+cBfTzjCPySna69Qw=="], - "ip-address": ["ip-address@10.7.0", "", {}, "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA=="], + "ip-address": ["ip-address@10.7.1", "", {}, "sha512-4OUAqU9Z1i3vCnS05hzGiFnEMDpQ+62pAD/MVQOp83fYyNC8GleCqaS0QikQBmcWCrKFiUs/B8ztRRiYOAXuCA=="], "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], @@ -1583,8 +1586,6 @@ "loglevel-plugin-prefix": ["loglevel-plugin-prefix@0.8.4", "", {}, "sha512-WpG9CcFAOjz/FtNht+QJeGpvVl/cdR6P0z6OcXSkr8wFJOsV2GRj2j10JLfjuA4aYkcKCNIEqRGCyTife9R8/g=="], - "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], - "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.6.0" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -1593,7 +1594,7 @@ "mdurl": ["mdurl@2.1.0", "", {}, "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg=="], - "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "1.1.18" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "1.1.21" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -1633,6 +1634,8 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1.0.2" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "opentype.js": ["opentype.js@0.8.0", "", { "dependencies": { "tiny-inflate": "1.0.3" }, "bin": { "ot": "./bin/ot" } }, "sha512-FQHR4oGP+a0m/f6yHoRpBOIbn/5ZWxKd4D/djHVJu8+KpBTYrJda0b7mLcgDEMWXE9xBCJm+qb0yv6FcvPjukg=="], @@ -1673,8 +1676,6 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], - "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -1713,8 +1714,6 @@ "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], - "randombytes": ["randombytes@2.1.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ=="], - "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], @@ -1771,7 +1770,7 @@ "serialize-error": ["serialize-error@12.0.0", "", { "dependencies": { "type-fest": "4.41.0" } }, "sha512-ZYkZLAvKTKQXWuh5XpBw7CdbSzagarX39WyZ2H07CDLC5/KfsRGlIXV8d4+tfqX1M7916mRqR1QfNHSij+c9Pw=="], - "serialize-javascript": ["serialize-javascript@6.0.2", "", { "dependencies": { "randombytes": "2.1.0" } }, "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g=="], + "serialize-javascript": ["serialize-javascript@7.0.5", "", {}, "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw=="], "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], @@ -1787,7 +1786,7 @@ "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], - "smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="], + "smol-toml": ["smol-toml@1.9.0", "", {}, "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ=="], "socks": ["socks@2.8.10", "", { "dependencies": { "ip-address": "10.7.0", "smart-buffer": "4.2.0" } }, "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ=="], @@ -1795,7 +1794,7 @@ "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], "spacetrim": ["spacetrim@0.11.59", "", {}, "sha512-lLYsktklSRKprreOm7NXReW8YiX2VBjbgmXYEziOoGf/qsJqAEACaDvoTtUOycwjpaSh+bT8eu0KrJn7UNxiCg=="], @@ -1813,7 +1812,7 @@ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], - "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "std-env": ["std-env@4.3.0", "", {}, "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag=="], "stream-buffers": ["stream-buffers@3.0.3", "", {}, "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw=="], @@ -1833,8 +1832,6 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], - "strnum": ["strnum@2.4.2", "", { "dependencies": { "anynum": "1.0.1" } }, "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw=="], "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], @@ -1855,15 +1852,11 @@ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.7" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], - - "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], - - "tinyspy": ["tinyspy@4.0.6", "", {}, "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg=="], + "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -1911,9 +1904,7 @@ "vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "0.28.2", "fdir": "6.5.0", "picomatch": "4.0.7", "postcss": "8.5.28", "rollup": "4.63.1", "tinyglobby": "0.2.17" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], - "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "6.7.14", "debug": "4.4.3", "es-module-lexer": "1.7.0", "pathe": "2.0.3", "vite": "7.3.6" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], - - "vitest": ["vitest@3.2.7", "", { "dependencies": { "@types/chai": "5.2.3", "@vitest/expect": "3.2.7", "@vitest/mocker": "3.2.7", "@vitest/pretty-format": "3.2.7", "@vitest/runner": "3.2.7", "@vitest/snapshot": "3.2.7", "@vitest/spy": "3.2.7", "@vitest/utils": "3.2.7", "chai": "5.3.3", "debug": "4.4.3", "expect-type": "1.4.0", "magic-string": "0.30.21", "pathe": "2.0.3", "picomatch": "4.0.7", "std-env": "3.10.0", "tinybench": "2.9.0", "tinyexec": "0.3.2", "tinyglobby": "0.2.17", "tinypool": "1.1.1", "tinyrainbow": "2.0.0", "vite": "7.3.6", "vite-node": "3.2.4", "why-is-node-running": "2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.7", "@vitest/ui": "3.2.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg=="], + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], "vscode-json-languageservice": ["vscode-json-languageservice@5.6.1", "", { "dependencies": { "@vscode/l10n": "0.0.18", "jsonc-parser": "3.3.1", "vscode-languageserver-textdocument": "1.0.12", "vscode-languageserver-types": "3.18.3", "vscode-uri": "3.2.0" } }, "sha512-IQIURBF2VMKBdWcMunbHSI3G2WmJ9H7613E1hRxIXX7YsAPSdBxnEiIUrTnsSW/3fk+QW1kfsvSigqgAFYIYtg=="], @@ -2013,7 +2004,7 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.8", "", {}, "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="], - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "5.0.9" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "5.0.12" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], @@ -2083,9 +2074,9 @@ "figures/is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "2.1.4" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "2.1.7" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], - "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "2.1.4" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "2.1.7" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -2117,7 +2108,7 @@ "mocha/glob": ["glob@8.1.0", "", { "dependencies": { "fs.realpath": "1.0.0", "inflight": "1.0.6", "inherits": "2.0.4", "minimatch": "5.1.9", "once": "1.4.0" } }, "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ=="], - "mocha/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "2.1.4" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "mocha/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "2.1.7" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "mocha/yargs": ["yargs@16.2.2", "", { "dependencies": { "cliui": "7.0.4", "escalade": "3.2.0", "get-caller-file": "2.0.5", "require-directory": "2.1.1", "string-width": "4.2.3", "y18n": "5.0.8", "yargs-parser": "20.2.9" } }, "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w=="], @@ -2139,13 +2130,11 @@ "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], - "randombytes/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "read-pkg/normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "7.0.2", "semver": "7.8.5", "validate-npm-package-license": "3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], "read-pkg-up/find-up": ["find-up@6.3.0", "", { "dependencies": { "locate-path": "7.2.0", "path-exists": "5.0.0" } }, "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw=="], - "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "2.1.4" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], + "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "2.1.7" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], "resq/fast-deep-equal": ["fast-deep-equal@2.0.1", "", {}, "sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w=="], @@ -2159,11 +2148,9 @@ "strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], - "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], - "typescript-language-server/vscode-jsonrpc": ["vscode-jsonrpc@5.0.1", "", {}, "sha512-JvONPptw3GAQGXlVV2utDcHx0BiY34FupW/kI6mZ5x06ER5DdPG/tXWMVHjTNULF5uKPOUUD0SaXg5QaubJL0A=="], - "vitest/@vitest/snapshot": ["@vitest/snapshot@3.2.7", "", { "dependencies": { "@vitest/pretty-format": "3.2.7", "magic-string": "0.30.21", "pathe": "2.0.3" } }, "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g=="], + "vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "0.30.21", "pathe": "2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], "vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], @@ -2201,7 +2188,7 @@ "@jest/types/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "4.0.4" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "4.0.4" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], "@vitest/snapshot/@vitest/pretty-format/tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="], @@ -2245,13 +2232,9 @@ "eslint/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "expect-webdriverio/@vitest/snapshot/@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "3.1.1" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], + "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], - "expect-webdriverio/@vitest/snapshot/@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "2.0.0", "tinyrainbow": "3.1.1" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], - - "filelist/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], - - "glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], + "glob/minimatch/brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], "jest-diff/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -2271,7 +2254,7 @@ "mocha/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "2.3.2" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], - "mocha/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], + "mocha/minimatch/brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], "mocha/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "4.2.3", "strip-ansi": "6.0.1", "wrap-ansi": "7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], @@ -2283,7 +2266,7 @@ "read-pkg/normalize-package-data/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "10.4.3" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], - "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], + "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.1.7", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g=="], "wait-port/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -2297,10 +2280,6 @@ "@wdio/tauri-service/@wdio/types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "expect-webdriverio/@vitest/snapshot/@vitest/pretty-format/tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], - - "expect-webdriverio/@vitest/snapshot/@vitest/utils/tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], - "mocha/chokidar/readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "mocha/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], diff --git a/package.json b/package.json index 41323b78..4d255cba 100644 --- a/package.json +++ b/package.json @@ -94,11 +94,16 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.42.0", "vite": "^7.1.5", - "vitest": "^3.2.4" + "vitest": "^4.1.11" }, "overrides": { "@xterm/addon-ligatures": "0.11.0-beta.213", - "fast-uri": "3.1.8" + "basic-ftp": "6.2.2", + "fast-uri": "3.1.8", + "ip-address": "10.7.1", + "serialize-javascript": "7.0.5", + "smol-toml": "1.9.0", + "source-map-js": "1.2.2" }, "trustedDependencies": [ "esbuild", From 83313e0d96db27b59c4189c9d6dedbfd3c4a1dd3 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 02:15:20 -0400 Subject: [PATCH 30/37] Update desktop rustls to 0.23.45 rustls 0.23.43: RUSTSEC-2026-0285 / GHSA-2mjx-qc3c-rqvc. The iOS host and oxbit-files locks do not include rustls. --- apps/desktop/src-tauri/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 85ebeb9e..6c1dcc62 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -3407,9 +3407,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "ring", From 68c279c44f7f7d7e16eef21d59dafbf8dd8d0a4a Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 02:19:32 -0400 Subject: [PATCH 31/37] Upgrade managed bash-language-server to 5.8.1 and override vulnerable transitive packages bash-language-server 5.6.0 -> 5.8.1 moves editorconfig to 3.0.2: minimatch 10.2.6 (GHSA-3ppc-4f35-3m26, GHSA-7r86-cg39-jmmj, GHSA-23c5-xmqv-rm74) and brace-expansion 5.0.12 (GHSA-6j4f-fj2g-mc7p, GHSA-qhr7-859c-m2p7, GHSA-q2hr-2g5m-vwhr). Overrides: fast-uri 3.1.8, source-map-js 1.2.2, ip-address 10.7.3, protobufjs 8.8.0, basic-ftp 6.2.2, @opentelemetry/core, resources and sdk-trace-base 2.12.0. intelephense stays at 1.18.5 (latest). Its lib/intelephense.js bundles its dependencies, so the overridden node_modules copies are not loaded. --- .../runtime/src/managed/npm/package-lock.json | 409 +++++------------- apps/runtime/src/managed/npm/package.json | 12 +- 2 files changed, 116 insertions(+), 305 deletions(-) diff --git a/apps/runtime/src/managed/npm/package-lock.json b/apps/runtime/src/managed/npm/package-lock.json index 269c2dd9..d88add2b 100644 --- a/apps/runtime/src/managed/npm/package-lock.json +++ b/apps/runtime/src/managed/npm/package-lock.json @@ -12,7 +12,7 @@ "@mdx-js/language-server": "0.6.4", "@vue/language-server": "3.3.11", "@vue/typescript-plugin": "3.3.11", - "bash-language-server": "5.6.0", + "bash-language-server": "5.8.1", "dockerfile-language-server-nodejs": "0.15.0", "intelephense": "1.18.5", "typescript": "5.9.3", @@ -178,21 +178,6 @@ "node": ">=20.0.0" } }, - "node_modules/@azure/opentelemetry-instrumentation-azure-sdk/node_modules/@opentelemetry/core": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.11.0.tgz", - "integrity": "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -515,9 +500,9 @@ } }, "node_modules/@one-ini/wasm": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.2.0.tgz", - "integrity": "sha512-n+L/BvrwKUn7q5O3wHGo+CJZAqfewh38+37sk+eBzv/39lM9pPgPRd4sOZRvSRzo0ukLxzyXso4WlGj2oKZ5hA==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.2.1.tgz", + "integrity": "sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg==", "license": "MIT" }, "node_modules/@opentelemetry/api": { @@ -542,29 +527,20 @@ } }, "node_modules/@opentelemetry/core": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", - "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.12.0.tgz", + "integrity": "sha512-1HsSAuvT4/my0QrXWsyXFdWjKaPeCHsismKZgEEIb8NK13LP6DrMRlsUbvD/0F9mDWK3xDom9AMv3IscxgOnOQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/semantic-conventions": "1.28.0" + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/core/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", - "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, "node_modules/@opentelemetry/instrumentation": { "version": "0.211.0", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.211.0.tgz", @@ -583,38 +559,29 @@ } }, "node_modules/@opentelemetry/resources": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz", - "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.12.0.tgz", + "integrity": "sha512-+rSRoOdln6NhXTVcBdHSlZxJDNzwxadeweK2ltZO3tVtfp6aklABc06xclDk6ovRR+EiGgIcSbh/6NgjFhnb3A==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "1.30.1", - "@opentelemetry/semantic-conventions": "1.28.0" + "@opentelemetry/core": "2.12.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", - "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "node_modules/@opentelemetry/sdk-trace": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.11.0.tgz", - "integrity": "sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.12.0.tgz", + "integrity": "sha512-ImVuRQ6faOsTi1Wna88s2avgrrf/iDgXPigIeynkUhjstMuoQUDOJXgkez2j1mlU4btjXcXBGa+q7qKFYR3GsA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.11.0", - "@opentelemetry/resources": "2.11.0", + "@opentelemetry/core": "2.12.0", + "@opentelemetry/resources": "2.12.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -625,29 +592,21 @@ } }, "node_modules/@opentelemetry/sdk-trace-base": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz", - "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.12.0.tgz", + "integrity": "sha512-JOERgZCxRM06FEJ5kEWxlSRChah6MmXDx3KwCruqEv/iEyLkTokCSemf2y1w8oIHtfrr8EVTpzebq/31vn2Cpg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "1.30.1", - "@opentelemetry/resources": "1.30.1", - "@opentelemetry/semantic-conventions": "1.28.0" + "@opentelemetry/core": "2.12.0", + "@opentelemetry/resources": "2.12.0", + "@opentelemetry/sdk-trace": "2.12.0", + "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", - "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" + "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "node_modules/@opentelemetry/sdk-trace-web": { @@ -666,86 +625,6 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/core": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.11.0.tgz", - "integrity": "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/resources": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.11.0.tgz", - "integrity": "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.11.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.11.0.tgz", - "integrity": "sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.11.0", - "@opentelemetry/resources": "2.11.0", - "@opentelemetry/sdk-trace": "2.11.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace/node_modules/@opentelemetry/core": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.11.0.tgz", - "integrity": "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace/node_modules/@opentelemetry/resources": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.11.0.tgz", - "integrity": "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.11.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, "node_modules/@opentelemetry/semantic-conventions": { "version": "1.43.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", @@ -1232,81 +1111,52 @@ } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/bash-language-server": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/bash-language-server/-/bash-language-server-5.6.0.tgz", - "integrity": "sha512-DCuV+/BZAAozsp5blvi6jDnU/ZDaTpJpWM0zqwGjnirfqv7iBsMK32xOze/jipxU0PUZ6CBUKgRUMKI7Kk70Lg==", + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/bash-language-server/-/bash-language-server-5.8.1.tgz", + "integrity": "sha512-F3g0hKZhCiKbLUf9wkrST6+ZypsHvJqaJxw/+J7WK2ywhFyc8+7jSHSTU1UDYncZkyWgbKV6+eZy5qWTZtoXww==", "license": "MIT", "dependencies": { - "editorconfig": "2.0.1", + "editorconfig": "3.0.2", "fast-glob": "3.3.3", "fuzzy-search": "3.2.1", - "node-fetch": "2.7.0", - "turndown": "7.2.0", - "vscode-languageserver": "8.0.2", - "vscode-languageserver-textdocument": "1.0.12", - "web-tree-sitter": "0.24.5", - "zod": "3.24.2" + "turndown": "7.2.4", + "vscode-languageserver": "10.1.1", + "vscode-languageserver-textdocument": "1.0.14", + "web-tree-sitter": "0.27.0", + "zod": "3.25.76" }, "bin": { "bash-language-server": "out/cli.js" }, "engines": { - "node": ">=16" - } - }, - "node_modules/bash-language-server/node_modules/vscode-jsonrpc": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.0.2.tgz", - "integrity": "sha512-RY7HwI/ydoC1Wwg4gJ3y6LpU9FJRZAUnTYMXthqhFXXu77ErDd/xkREpGuk4MyYkk4a+XDWAMqe0S3KkelYQEQ==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" + "node": ">=20" } }, "node_modules/bash-language-server/node_modules/vscode-languageserver": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-8.0.2.tgz", - "integrity": "sha512-bpEt2ggPxKzsAOZlXmCJ50bV7VrxwCS5BI4+egUmure/oI/t4OlFzi/YNtVvY24A2UDOZAgwFGgnZPwqSJubkA==", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-10.1.1.tgz", + "integrity": "sha512-/cG04Fiq99rEtoDrfPpv0k6NyUFlck5GNtZLQZv1rxkBkc/4FrJNvEKTN5k1Si3O1hRguP++ciYlX9cdjdYxFQ==", "license": "MIT", "dependencies": { - "vscode-languageserver-protocol": "3.17.2" + "vscode-languageserver-protocol": "3.18.3" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, - "node_modules/bash-language-server/node_modules/vscode-languageserver-protocol": { - "version": "3.17.2", - "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.2.tgz", - "integrity": "sha512-8kYisQ3z/SQ2kyjlNeQxbkkTNmVFoQCqkmGrzLH6A9ecPlgTbp3wDTnUNqaUxYr4vlAcloxx8zwy7G5WdguYNg==", - "license": "MIT", - "dependencies": { - "vscode-jsonrpc": "8.0.2", - "vscode-languageserver-types": "3.17.2" - } - }, - "node_modules/bash-language-server/node_modules/vscode-languageserver-textdocument": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", - "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", - "license": "MIT" - }, - "node_modules/bash-language-server/node_modules/vscode-languageserver-types": { - "version": "3.17.2", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.2.tgz", - "integrity": "sha512-zHhCWatviizPIq9B7Vh9uvrH6x3sK8itC84HkamnBWoDFJtzBf7SWlpLCZUit72b3os45h6RWQNC9xHRDF8dRA==", - "license": "MIT" - }, "node_modules/basic-ftp": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", - "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-6.2.2.tgz", + "integrity": "sha512-LIkJcf/F6bTTfvE3k2YcTxrJsLKKVXiEpHZYkQP6JoqTb7khng7c13ckRTDQbqRh5IkehVH3F00sSlAa2F6DwA==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -1319,12 +1169,15 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" } }, "node_modules/braces": { @@ -1457,12 +1310,12 @@ } }, "node_modules/commander": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", - "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/continuation-local-storage": { @@ -1807,21 +1660,21 @@ } }, "node_modules/editorconfig": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-2.0.1.tgz", - "integrity": "sha512-jMVc7LbF/M13cSpBiVWGut+qhIyOddIhSXPAntMSboEigGFGaQmBow9ZrVog0VT2K89qm0cyGHa7FRhcOqP8hA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-3.0.2.tgz", + "integrity": "sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==", "license": "MIT", "dependencies": { - "@one-ini/wasm": "0.2.0", - "commander": "^13.1.0", - "minimatch": "10.0.1", - "semver": "^7.7.1" + "@one-ini/wasm": "0.2.1", + "commander": "^14.0.3", + "minimatch": "~10.2.4", + "semver": "^7.7.4" }, "bin": { "editorconfig": "bin/editorconfig" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/emitter-listener": { @@ -2028,9 +1881,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", - "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -2387,19 +2240,6 @@ "node": ">=10" } }, - "node_modules/intelephense/node_modules/turndown": { - "version": "7.2.4", - "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", - "integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==", - "license": "MIT", - "dependencies": { - "@mixmark-io/domino": "^2.2.0" - }, - "engines": { - "node": ">=18", - "npm": ">=9" - } - }, "node_modules/intelephense/node_modules/vscode-languageserver-types": { "version": "3.17.5", "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", @@ -2413,9 +2253,9 @@ "license": "MIT" }, "node_modules/ip-address": { - "version": "10.7.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", - "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", + "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", "license": "MIT", "engines": { "node": ">= 12" @@ -3740,15 +3580,15 @@ } }, "node_modules/minimatch": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", - "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", - "license": "ISC", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -3781,26 +3621,6 @@ "node": ">= 0.4.0" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-html-parser": { "version": "6.1.13", "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", @@ -3945,13 +3765,12 @@ } }, "node_modules/protobufjs": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.3.0.tgz", - "integrity": "sha512-JpJpFaR7yKNb6WqKvJJ1MLbiuIQWQnbUUb06nDtf2/i8YWYYLEfP6xf9BwSJoJQg1wAy61EQB8dssQg64oX4aA==", - "hasInstallScript": true, + "version": "8.8.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.8.0.tgz", + "integrity": "sha512-N3xhQ5yyBx3vQq4gubBfASzYhJGNzeDbjqBpu61g7UVylsN/qyffU96TKWD3GbbLOKF82VGNRNvv1+BFgE31Eg==", "license": "BSD-3-Clause", "dependencies": { - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -4272,9 +4091,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -4334,12 +4153,6 @@ "integrity": "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==", "license": "MIT" }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/trough": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", @@ -4357,12 +4170,16 @@ "license": "0BSD" }, "node_modules/turndown": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.0.tgz", - "integrity": "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A==", + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz", + "integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==", "license": "MIT", "dependencies": { "@mixmark-io/domino": "^2.2.0" + }, + "engines": { + "node": ">=18", + "npm": ">=9" } }, "node_modules/turndown-plugin-gfm": { @@ -5032,27 +4849,11 @@ } }, "node_modules/web-tree-sitter": { - "version": "0.24.5", - "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.24.5.tgz", - "integrity": "sha512-+J/2VSHN8J47gQUAvF8KDadrfz6uFYVjxoxbKWDoXVsH2u7yLdarCnIURnrMA6uSRkgX3SdmqM5BOoQjPdSh5w==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.27.0.tgz", + "integrity": "sha512-XK08gj6RwTMQatAG7uVRP8MunqotL/XC19vHgkSPKmELgbGPBj4ECvB8haHOUnyj6ls2B8t42UTro14zxGgAHg==", "license": "MIT" }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", @@ -5135,9 +4936,9 @@ } }, "node_modules/zod": { - "version": "3.24.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/apps/runtime/src/managed/npm/package.json b/apps/runtime/src/managed/npm/package.json index 852e2a33..08f2262f 100644 --- a/apps/runtime/src/managed/npm/package.json +++ b/apps/runtime/src/managed/npm/package.json @@ -10,8 +10,18 @@ "@astrojs/language-server": "2.16.16", "vscode-langservers-extracted": "4.10.0", "dockerfile-language-server-nodejs": "0.15.0", - "bash-language-server": "5.6.0", + "bash-language-server": "5.8.1", "intelephense": "1.18.5", "@mdx-js/language-server": "0.6.4" + }, + "overrides": { + "fast-uri": "3.1.8", + "source-map-js": "1.2.2", + "ip-address": "10.7.3", + "protobufjs": "8.8.0", + "basic-ftp": "6.2.2", + "@opentelemetry/core": "2.12.0", + "@opentelemetry/resources": "2.12.0", + "@opentelemetry/sdk-trace-base": "2.12.0" } } From 214dcea6e19caabe63d1dff09c1bcf4dfd8d9444 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 02:19:56 -0400 Subject: [PATCH 32/37] Document dependency audit commands, fixes, and accepted advisories Adds scripts/osv-cargo.mjs to query OSV for Cargo.lock crates. --- README.md | 3 +- docs/INDEX.md | 3 +- docs/security.md | 69 +++++++++++++++++++++++++++++++++++++++++++ scripts/osv-cargo.mjs | 32 ++++++++++++++++++++ 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 scripts/osv-cargo.mjs diff --git a/README.md b/README.md index 68791560..9fcd453e 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,8 @@ watchers. | `examples/` | Extension and icon-pack examples | See [architecture](docs/architecture.md), [feature status](docs/feature-status.md), -[dependencies](docs/dependencies.md), and [design assets](design/README.md). +[dependencies](docs/dependencies.md), [dependency audits](docs/security.md#dependency-audit), +and [design assets](design/README.md). ### Checks diff --git a/docs/INDEX.md b/docs/INDEX.md index 720afe19..227b56aa 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,6 +1,6 @@ # Documentation index -Created: 2026-10-02. Last updated: 2026-10-08. +Created: 2026-10-02. Last updated: 2026-10-09. | Guide | Use when | | --- | --- | @@ -14,3 +14,4 @@ Created: 2026-10-02. Last updated: 2026-10-08. | [iOS SSH and SFTP workspaces](ios-ssh.md) | Changing SSH keys, saved hosts, known-hosts trust, the russh session pool, the SFTP FileSystem, transfers, Git over SSH remotes, or the remote runtime started from iOS; includes russh/aws-lc build notes and russh, Keychain, SFTP, git2 transport, and pack protocol references. | | [iOS source control integration](ios-source-control.md) | On-device repositories, GitHub and Gitea credentials, OpenPGP commit signing, SSH remote key choice, native transport, and official API references | | [Source Control](source-control.md) | Git workflows in runtime workspaces | +| [Dependency audit](security.md#dependency-audit) | Running `bun audit`, the managed language-server `npm audit`, and the Cargo OSV query; fixed versions and accepted advisories with reachability; OSV batch API reference. | diff --git a/docs/security.md b/docs/security.md index 111ddb64..e140e416 100644 --- a/docs/security.md +++ b/docs/security.md @@ -13,3 +13,72 @@ Markdown disables raw HTML, sanitizes rendered output, blocks embedded images/me Runtime operation IDs retain completion/failure/interrupted status. An uncertain command or commit is inspected through operation.status; connection recovery does not start it again. Terminal replay is bounded and reports truncation. Surviving sessions reattach after socket loss; runtime process loss terminates PTYs. Shared Yjs updates persist independently of disk saves and merge on reconnect. Browser drafts remain available after failed saves and disconnected runtime services. Revoking a grant closes its connections and terminates its owned processes. Revoking workspace trust terminates workspace tools. A runtime owner remains responsible for its operating-system account and credentials. Runtime Git uses existing credential helpers and environment configuration; secrets are not sent in browser bundles. + +## Dependency audit + +Created: 2026-10-09. Last updated: 2026-10-09. + +Dependabot and secret scanning are off for the repository. Run these audits before a release: + +| Scope | Command | +| --- | --- | +| Bun workspace (`bun.lock`) | `bun audit` | +| Managed language servers | `npm audit --package-lock-only` in `apps/runtime/src/managed/npm` | +| Cargo locks | `node scripts/osv-cargo.mjs apps/desktop/src-tauri/Cargo.lock apps/ios/src-tauri/Cargo.lock apps/ios/plugins/oxbit-files/Cargo.lock` | + +`scripts/osv-cargo.mjs` sends the crates.io name and version of each locked crate to the [OSV batch API](https://google.github.io/osv.dev/post-v1-querybatch/) and prints the advisory IDs. Use `bun why ` to find the real dependency path; the chains printed by `bun audit` can name the wrong parent. + +`bun audit fix` reports in-range transitive updates as blocked, because `bun.lock` stores each dependent's resolved version. Bun applies a nested `overrides` entry to every parent of that package, so a per-parent override cannot pin different majors. Use a top-level `overrides` entry when one version fits every parent; otherwise update the lock entry's version and integrity to the patched release inside the parent's range and confirm with `bun install --frozen-lockfile`. + +### Fixed on 2026-10-09 + +| Package | Old | New | Advisories | Shipped in | +| --- | --- | --- | --- | --- | +| tar | 7.5.9 | 7.5.22 | GHSA-qffp-2rhf-9h96, GHSA-9ppj-qmqm-q256, GHSA-vmf3-w455-68vh, GHSA-w8wr-v893-vjvp, GHSA-23hp-3jrh-7fpw, GHSA-8x88-c5mf-7j5w, GHSA-gvwx-54wh-qm9j, GHSA-r292-9mhp-454m | Runtime (CLI, desktop, SSH remote) | +| markdown-it | 10.0.0 | 14.3.2 | GHSA-6v5v-wf23-fmfq, GHSA-6vfc-qv3f-vr6c, GHSA-253c-mchw-3w2r | Web, desktop, iOS | +| linkify-it | 2.2.0 | 5.0.2 | GHSA-22p9-wv53-3rq4, GHSA-v245-v573-v5vm | Web, desktop, iOS | +| sprintf-js | 1.0.3 | removed | GHSA-hp3w-g68c-fv3c | markdown-it now uses argparse 2 | +| dompurify | 3.4.15 | 3.4.16 | GHSA-p98j-92pf-mc4p, GHSA-6688-9rhm-gjv2 | Web, desktop, iOS | +| highlight.js | 9.18.5 | removed | GHSA-7wwv-vh3v-89cq | Dev (`@types/markdown-it` 14.2.0) | +| fast-uri | 3.1.7 | 3.1.8 | GHSA-hrr3-gc8f-f4qj | Dev (ajv) | +| vitest, @vitest/mocker | 3.2.7 | 4.1.11 | GHSA-82fw-gwwq-j7x9 | Dev | +| tinypool | 1.1.1 | removed | GHSA-5gmw-xhrv-c9v3, GHSA-85c8-ppgw-ccpr | Dev (vitest 4) | +| brace-expansion | 1.1.18, 2.1.4, 5.0.9 | 1.1.21, 2.1.7, 5.0.12 | GHSA-6j4f-fj2g-mc7p, GHSA-qhr7-859c-m2p7, GHSA-q2hr-2g5m-vwhr | Dev (eslint, typescript-eslint, mocha) | +| source-map-js | 1.2.1 | 1.2.2 | GHSA-68fv-2mgg-jv7q | Dev (postcss) | +| basic-ftp | 5.3.1 | 6.2.2 | GHSA-c475-qrg2-pj4r | Dev (wdio) | +| ip-address | 10.7.0 | 10.7.1 | GHSA-j6r3-76f7-8jcv, GHSA-h3mg-xc3c-68pw | Dev (wdio) | +| serialize-javascript | 6.0.2 | 7.0.5 | GHSA-5c6j-r48x-rmvq, GHSA-qj8w-gfj5-8c6v | Dev (mocha) | +| smol-toml | 1.8.0 | 1.9.0 | GHSA-r4xh-jqrq-34v2 | Dev (wdio Tauri plugin) | +| rustls | 0.23.43 | 0.23.45 | RUSTSEC-2026-0285 | Desktop | + +The managed installer rejects archive paths with `..`, hardlinks, and symlinks that resolve outside the package directory. tar reports an exception thrown from its `filter` callback as uncaught, so `apps/runtime/src/managed/install.ts` records the first rejection and throws it after extraction. + +markdown-it 14 keeps the `html: false` and `linkify: true` settings, link validation, and escaping output. Two parser changes affect rendering: a `|` inside a table code span splits the cell, as GFM specifies, and linkify keeps `*` characters inside a URL. + +Managed language servers (`apps/runtime/src/managed/npm`, installed by the runtime with lock integrity checks): + +| Package | Old | New | Advisories | +| --- | --- | --- | --- | +| bash-language-server | 5.6.0 | 5.8.1 | Moves editorconfig to 3.0.2 | +| minimatch | 10.0.1 | 10.2.6 | GHSA-3ppc-4f35-3m26, GHSA-7r86-cg39-jmmj, GHSA-23c5-xmqv-rm74 | +| brace-expansion | 2.1.4 | 5.0.12 | GHSA-6j4f-fj2g-mc7p, GHSA-qhr7-859c-m2p7, GHSA-q2hr-2g5m-vwhr | +| fast-uri | 3.1.7 | 3.1.8 (override) | GHSA-hrr3-gc8f-f4qj | +| source-map-js | 1.2.1 | 1.2.2 (override) | GHSA-68fv-2mgg-jv7q | +| ip-address | 10.7.0 | 10.7.3 (override) | GHSA-j6r3-76f7-8jcv, GHSA-h3mg-xc3c-68pw | +| protobufjs | 8.3.0 | 8.8.0 (override) | GHSA-94rc-8x27-4472, GHSA-wcpc-wj8m-hjx6, GHSA-f38q-mgvj-vph7, GHSA-jfj6-75fj-8934, GHSA-j3f2-48v5-ccww | +| basic-ftp | 5.3.1 | 6.2.2 (override) | GHSA-c475-qrg2-pj4r | +| @opentelemetry/core, resources, sdk-trace-base | 1.30.1 | 2.12.0 (override) | GHSA-8988-4f7v-96qf | + +intelephense stays at 1.18.5, the latest release; `npm audit` suggests 1.18.2, a downgrade. `lib/intelephense.js` bundles applicationinsights, proxy-agent, and micromatch, and requires only Node built-ins, so the overridden copies in `node_modules` are downloaded and not loaded. Telemetry is off in `apps/runtime/src/managed/catalog.ts`. + +### Accepted advisories + +| Package | Advisory | Path | Reason | +| --- | --- | --- | --- | +| braces 3.0.3 | GHSA-vfj7-8cjw-p6xm | mocha 10 > chokidar 3.6.0 (wdio, dev) | No patched release. Mocha watch mode only; desktop WebDriver tests do not use it. | +| braces 3.0.3 | GHSA-vfj7-8cjw-p6xm | Managed lock: micromatch and fast-glob in bash-language-server and intelephense | No patched release. bash-language-server expands its `globPattern` setting (default `**/*@(.sh\|.inc\|.bash\|.command)`) with fast-glob; a nested-brace pattern needs control of the server settings. intelephense runs its bundled copy. | +| extract-zip 2.0.1 | GHSA-jmr9-qjv8-65gv, GHSA-7pqw-9j4j-h8q3 | wdio > @puppeteer/browsers 2.x (dev) | No patched release. Extracts browser driver archives during desktop WebDriver tests. @puppeteer/browsers 3 drops it and needs WebdriverIO 10. | +| rsa 0.9.10, 0.10.0-rc.18 | RUSTSEC-2023-0071 | iOS: russh 0.64.1 and ssh-key with the `rsa` feature; pgp 0.21.0 in oxbit-files | No upstream fix. New SSH and commit-signing keys are Ed25519 (`apps/ios/src-tauri/src/ssh/keys.rs`, `apps/ios/plugins/oxbit-files/src/commit_signing/keys.rs`). An imported RSA key signs SSH authentication and commits. The Marvin attack targets RSA decryption, which Oxbit does not call; russh has no RSA key exchange. | +| glib 0.18.5 | RUSTSEC-2024-0429 | gtk, wry, webkit2gtk on Linux | Linux only. Linux packages are CI artifacts and are not published by the release workflow. No crate in the tree calls `VariantStrIter`. | +| proc-macro-error 1.0.4 | RUSTSEC-2024-0370 (unmaintained) | glib-macros, gtk3-macros | Compile-time only, Linux only. | +| unic-* 0.9.0 | RUSTSEC-2025-0075, -0080, -0081, -0098, -0100 (unmaintained) | tauri-utils > urlpattern | Used to match remote URL capabilities; the desktop and iOS capability files define no remote URLs. | diff --git a/scripts/osv-cargo.mjs b/scripts/osv-cargo.mjs new file mode 100644 index 00000000..1608d73a --- /dev/null +++ b/scripts/osv-cargo.mjs @@ -0,0 +1,32 @@ +import { readFile } from "node:fs/promises"; + +const locks = process.argv.slice(2); +if (!locks.length) { + console.error("Usage: node scripts/osv-cargo.mjs ..."); + process.exit(2); +} +for (const lock of locks) { + const crates = new Map(); + for (const block of (await readFile(lock, "utf8")).split("[[package]]").slice(1)) { + const field = (key) => block.match(new RegExp(`^${key} = "([^"]+)"`, "m"))?.[1]; + if (field("source")?.includes("crates.io")) crates.set(`${field("name")}@${field("version")}`, [field("name"), field("version")]); + } + const packages = [...crates.values()].sort(([a, x], [b, y]) => a.localeCompare(b) || x.localeCompare(y)); + const found = []; + for (let start = 0; start < packages.length; start += 500) { + const chunk = packages.slice(start, start + 500); + const response = await fetch("https://api.osv.dev/v1/querybatch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ queries: chunk.map(([name, version]) => ({ package: { name, ecosystem: "crates.io" }, version })) }), + }); + if (!response.ok) throw new Error(`OSV query failed (${response.status})`); + const { results } = await response.json(); + results.forEach((result, index) => { + const ids = (result.vulns ?? []).map((vuln) => vuln.id); + if (ids.length) found.push(`${chunk[index].join("@")} ${ids.join(", ")}`); + }); + } + console.log(`== ${lock}: ${packages.length} crates, ${found.length} with advisories`); + for (const line of found) console.log(` ${line}`); +} From e7ff09178d192d7d9f54657ebc7b840f7f48e2a1 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 02:27:48 -0400 Subject: [PATCH 33/37] Regenerate the iOS Info.plist with the Bonjour service and local network text --- apps/ios/src-tauri/gen/apple/oxbit-ios_iOS/Info.plist | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/ios/src-tauri/gen/apple/oxbit-ios_iOS/Info.plist b/apps/ios/src-tauri/gen/apple/oxbit-ios_iOS/Info.plist index 08937772..b34b54f3 100644 --- a/apps/ios/src-tauri/gen/apple/oxbit-ios_iOS/Info.plist +++ b/apps/ios/src-tauri/gen/apple/oxbit-ios_iOS/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.3.1 + 0.4.1 CFBundleVersion - 1 + 0.4.1 LSRequiresIPhoneOS UILaunchStoryboardName @@ -83,7 +83,7 @@ NSLocalNetworkUsageDescription - Connect to the Oxbit runtime on your computer to open projects and use terminals, Git, and language tools. + Find Oxbit runtimes on your local network and connect to the one on your computer to open projects and use terminals, Git, and language tools. NSAppTransportSecurity NSAllowsLocalNetworking @@ -137,5 +137,9 @@ + NSBonjourServices + + _oxbit._tcp + \ No newline at end of file From 05854569b4d609ac1e48a40e7896efb43ec1a975 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 03:46:40 -0400 Subject: [PATCH 34/37] Drop diagnostics that arrive after a file's language server is turned off acceptDiagnostics waited on remoteVersions() and then stored diagnostics even if the file switch was turned off during the wait. Also type the linux-arm64 managed platform and note that Linux packages now ship. --- docs/security.md | 2 +- packages/features/language/src/index.ts | 2 +- packages/sdk/src/language-servers.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/security.md b/docs/security.md index e140e416..f78d5c43 100644 --- a/docs/security.md +++ b/docs/security.md @@ -79,6 +79,6 @@ intelephense stays at 1.18.5, the latest release; `npm audit` suggests 1.18.2, a | braces 3.0.3 | GHSA-vfj7-8cjw-p6xm | Managed lock: micromatch and fast-glob in bash-language-server and intelephense | No patched release. bash-language-server expands its `globPattern` setting (default `**/*@(.sh\|.inc\|.bash\|.command)`) with fast-glob; a nested-brace pattern needs control of the server settings. intelephense runs its bundled copy. | | extract-zip 2.0.1 | GHSA-jmr9-qjv8-65gv, GHSA-7pqw-9j4j-h8q3 | wdio > @puppeteer/browsers 2.x (dev) | No patched release. Extracts browser driver archives during desktop WebDriver tests. @puppeteer/browsers 3 drops it and needs WebdriverIO 10. | | rsa 0.9.10, 0.10.0-rc.18 | RUSTSEC-2023-0071 | iOS: russh 0.64.1 and ssh-key with the `rsa` feature; pgp 0.21.0 in oxbit-files | No upstream fix. New SSH and commit-signing keys are Ed25519 (`apps/ios/src-tauri/src/ssh/keys.rs`, `apps/ios/plugins/oxbit-files/src/commit_signing/keys.rs`). An imported RSA key signs SSH authentication and commits. The Marvin attack targets RSA decryption, which Oxbit does not call; russh has no RSA key exchange. | -| glib 0.18.5 | RUSTSEC-2024-0429 | gtk, wry, webkit2gtk on Linux | Linux only. Linux packages are CI artifacts and are not published by the release workflow. No crate in the tree calls `VariantStrIter`. | +| glib 0.18.5 | RUSTSEC-2024-0429 | gtk, wry, webkit2gtk on Linux | Linux desktop packages (AppImage, .deb) only. No crate in the tree calls `VariantStrIter`. | | proc-macro-error 1.0.4 | RUSTSEC-2024-0370 (unmaintained) | glib-macros, gtk3-macros | Compile-time only, Linux only. | | unic-* 0.9.0 | RUSTSEC-2025-0075, -0080, -0081, -0098, -0100 (unmaintained) | tauri-utils > urlpattern | Used to match remote URL capabilities; the desktop and iOS capability files define no remote URLs. | diff --git a/packages/features/language/src/index.ts b/packages/features/language/src/index.ts index 2ee051b7..a005d7a0 100644 --- a/packages/features/language/src/index.ts +++ b/packages/features/language/src/index.ts @@ -1118,7 +1118,7 @@ export class LanguageService { return; this.diagnosticVersions.set(path, params.version); } - if (this.disposed) return; + if (this.disposed || !this.fileEnabled(path)) return; this.lspDiagnostics.set(path, params.diagnostics ?? []); this.refreshDiagnostics(path); if (this.providerContext) this.providerContext.owner.providersChanged(); diff --git a/packages/sdk/src/language-servers.ts b/packages/sdk/src/language-servers.ts index a0d518aa..f2f7f692 100644 --- a/packages/sdk/src/language-servers.ts +++ b/packages/sdk/src/language-servers.ts @@ -25,7 +25,7 @@ export interface LanguageServerDefinition extends LanguageServerConfiguration { export interface LanguageServerInstallation { id: string; version: string; - platform: "darwin-arm64" | "linux-x64"; + platform: "darwin-arm64" | "linux-x64" | "linux-arm64"; directory: string; integrity: string; } From 405a9b75fefb6d8a908705a061ddaa19ae3320e0 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 03:49:08 -0400 Subject: [PATCH 35/37] Add TestFlight notes for 0.5.0 --- docs/testflight/0.5.0.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docs/testflight/0.5.0.md diff --git a/docs/testflight/0.5.0.md b/docs/testflight/0.5.0.md new file mode 100644 index 00000000..a829202e --- /dev/null +++ b/docs/testflight/0.5.0.md @@ -0,0 +1,19 @@ +Runtime page, one-tap connect, and keep-alive + +1. Open a device folder and tap the cloud button in the header. With no runtime saved yet, check that the Runtime page opens. It lists saved runtimes, runtimes on this network, SSH servers, keep-alive settings, and a pairing form. + +2. On a Mac or Linux computer on the same Wi-Fi, install the CLI (npm install -g @oxbit/cli) and run oxbit --lan in a project folder. Allow Local Network access when iOS asks. Check that the runtime appears under "On this network" within a few seconds. Tap it, enter the pairing code the computer printed, and check that the workspace opens. + +3. Close the workspace. On the start screen, tap Connect Runtime… and check that it connects to the same runtime without asking for the pairing code. In a device folder, tap the cloud button and check that it connects the same way and shows progress on the button. + +4. Stop the runtime on the computer (oxbit --stop). Tap the cloud button and check that the Runtime page opens with the reason the connection failed. + +5. On the Runtime page, set Keep Runtime Alive to 15 minutes. Start Oxbit on an SSH server, switch to another app for 5 minutes, and come back. Check that the workspace reconnects without starting the runtime again. Repeat with 75 seconds and a 3-minute wait, and check that the runtime was started again. + +6. Turn off Reconnect Automatically, turn on Airplane Mode briefly, turn it off, and check that the workspace offers Reconnect instead of reconnecting by itself. + +7. If you have a Linux arm64 server (for example a Raspberry Pi 5 or an AWS Graviton host), start Oxbit on it over SSH and check that the folder opens. + +8. Open a Markdown file with links, a table, and inline code, and check that the preview renders them. + +Report crashes, runtimes that do not appear on the network, pairing or reconnect failures, or a runtime that stops before its keep-alive time, with the computer or server OS, reproduction steps, device model, iOS version, and screenshots. From c159d7afa7b1a1c92167b97042b06f6ce8ae2968 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 03:52:28 -0400 Subject: [PATCH 36/37] Build test archives synchronously and format the lease test tar 7.5.22's async pack writes a hardlink entry after the stream ends in about one run in four, which surfaced as an uncaught ZlibError in vitest. --- apps/ios/src-tauri/src/ssh/live_lease_tests.rs | 5 ++++- apps/runtime/tests/lsp-manager.test.ts | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/ios/src-tauri/src/ssh/live_lease_tests.rs b/apps/ios/src-tauri/src/ssh/live_lease_tests.rs index 3fb3fab1..04269675 100644 --- a/apps/ios/src-tauri/src/ssh/live_lease_tests.rs +++ b/apps/ios/src-tauri/src/ssh/live_lease_tests.rs @@ -67,7 +67,10 @@ async fn live_remote_runtime_lease() { scratch, .. } = live().await; - let hosted = source(format!("{served}/{}.tar.gz", remote_platform()), scratch.clone()); + let hosted = source( + format!("{served}/{}.tar.gz", remote_platform()), + scratch.clone(), + ); let seen: Seen = Arc::default(); let runtime = start(&pool, "live", hosted.clone(), &seen, Some(0)).await; diff --git a/apps/runtime/tests/lsp-manager.test.ts b/apps/runtime/tests/lsp-manager.test.ts index de74f18b..99e182cb 100644 --- a/apps/runtime/tests/lsp-manager.test.ts +++ b/apps/runtime/tests/lsp-manager.test.ts @@ -114,7 +114,8 @@ describe("managed installation locks", () => { const source = path.join(root, `${name}-source`), file = path.join(root, `${name}.tgz`); await fs.mkdir(path.join(source, "package"), { recursive: true }); await build(path.join(source, "package")); - await createTar({ gzip: true, cwd: source, file, portable: true }, ["package"]); + // tar 7.5.22's async pack can write a hardlink entry after it ends ("write after end"), uncaught. + createTar({ gzip: true, cwd: source, file, portable: true, sync: true }, ["package"]); return fs.readFile(file); } const valid = await archive("valid", async directory => { From ab18608690f09133fb4f3a5b399b4682e1520959 Mon Sep 17 00:00:00 2001 From: Ryan Yannelli Date: Fri, 9 Oct 2026 04:38:00 -0400 Subject: [PATCH 37/37] Check SSH owner rejection with a different workspace key A launch with the owner's own workspace key now takes over its orphaned runtime, so the smoke test checks that another owner is still rejected. --- scripts/remote/smoke.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/remote/smoke.ts b/scripts/remote/smoke.ts index c8da746f..71db0593 100644 --- a/scripts/remote/smoke.ts +++ b/scripts/remote/smoke.ts @@ -182,7 +182,8 @@ try { request("terminal.create"), (e: any) => e.code === "UNTRUSTED", ); - await assert.rejects(connectSsh(options), /already open/); + const otherOwner = createHash("sha256").update(`${target}\0other`).digest("hex"); + await assert.rejects(connectSsh({ ...options, workspaceKey: otherOwner }), /already open/); checks.push( "automatic verified installation, remote file target, authenticated tunnel, trust gate, concurrent owner rejection", );