diff --git a/CHANGELOG.md b/CHANGELOG.md index 44c59ccb..b62a37d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,12 +20,24 @@ tagged release also ships native binaries for Linux, macOS, and Windows. sent-message surfaces, and Compute into a quieter results-first workspace. - Unified logical model names while keeping API-key and ChatGPT access routes explicit in both the composer and Settings. +- Let untrusted projects run routine terminal, kernel, shell, and local-compute + work immediately inside the enforced native sandbox, while keeping project + extensions, remote compute, package installation, and host execution behind + explicit trust or stricter managed policy. ### Fixed - Hardened research runs against repeated terminal URLs, guessed download-size escalation, substantially identical timed-out kernel work, stale tool outcomes, cross-process cancellation races, and orphaned kernel lifecycles. +- Made compute-job actions self-describing and recover harmless legacy aliases + and stringified targets without weakening canonical validation. +- Made brokered downloads derive their safe size from available workspace disk + instead of agent-guessed byte caps, with copy-ready root-download and + sandboxed move guidance for folder destinations. +- Removed the fixed Modal Volume browser-download ceiling and made large file + delivery use live disk-derived staging capacity plus cancellation-safe + streaming instead of buffering responses in memory. - Preserved exact session and tool-output filesystem capabilities across local work and delegated handoffs without broadening external-directory access. - Restored the v2 Review settings API, truthful runtime progress capture, and diff --git a/README.md b/README.md index 100f188f..2557e324 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for how the system fits together, [CONTRI ## Security -The permission system keeps you aware of what the agent is doing; it is not an isolation boundary by itself. OpenScience also includes an opt-in OS execution sandbox: macOS Seatbelt or Linux bubblewrap can confine writes to the workspace and deny network egress. It is off by default and is not a full jail, so run inside a container or VM for hostile code. Managed Atlas tokens stay out of general subprocess environments, arbitrary Python/R kernels receive a minimal environment, and credential-shaped values are redacted from output. To configure and verify containment, run `openscience sandbox enable` and `openscience sandbox test`; to report a vulnerability, see [SECURITY.md](SECURITY.md). +The permission system keeps you aware of what the agent is doing; it is not an isolation boundary by itself. OpenScience enables its OS execution sandbox by default: macOS Seatbelt or Linux bubblewrap confines commands to the workspace and approved paths and denies network egress. Routine terminals, kernels, shell commands, and local jobs can run immediately inside that verified boundary; remote jobs, kernel environment changes, project extensions, and host execution still require explicit project trust. The default fails closed when no backend is available, and the sandbox is not a full jail, so run inside a container or VM for hostile code. Managed Atlas tokens stay out of general subprocess environments, arbitrary Python/R kernels receive a minimal environment, and credential-shaped values are redacted from output. To inspect or verify containment, run `openscience sandbox` and `openscience sandbox test`; to report a vulnerability, see [SECURITY.md](SECURITY.md). ## License diff --git a/SECURITY.md b/SECURITY.md index de0c2ae8..25fa1938 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,9 +6,9 @@ OpenScience is an AI agent that runs locally on your machine. The agent can run ### Execution sandbox -The permission system prompts you before the agent runs a command or writes a file, so you stay aware of what it is doing. A permission prompt is not an isolation boundary by itself, and the execution sandbox is off by default. +The permission system decides whether the agent may take an action. A permission prompt is not an isolation boundary by itself. OpenScience enables its execution sandbox by default and refuses to run when a native backend is unavailable unless you explicitly choose a fallback policy. -When enabled, OpenScience wraps shell commands and Python/R kernel code in an OS sandbox: macOS Seatbelt or Linux bubblewrap. It confines writes to the workspace and approved paths and can deny network egress. Run `openscience sandbox enable`, then `openscience sandbox test`; if the test does not report **Containment verified**, do not rely on it. Reads and local IPC remain available, Windows has no sandbox backend, and the boundary is not a full jail. Use a container or VM for hostile code. +OpenScience wraps terminal and shell commands, Python/R kernels, and local compute jobs in macOS Seatbelt or Linux bubblewrap. It confines reads and writes to the session workspace and explicitly granted paths and denies network egress. Routine work can run immediately inside that verified boundary; remote jobs, kernel environment changes, project-owned extensions, and host execution still require explicit project trust. Run `openscience sandbox test`; if it does not report **Containment verified**, do not rely on that backend. Windows has no sandbox backend, and the boundary is not a full VM. Use a container or VM for hostile code. ### Server mode @@ -19,7 +19,7 @@ Server mode is opt-in. The server binds to localhost (127.0.0.1) only and enforc | Category | Why | | --------------------------- | -------------------------------------------------------------------- | | Server access when opted in | If you enable server mode, API access is expected behavior. | -| Full read isolation | The sandbox confines writes; it does not hide readable local files. | +| Granted-root contents | A command may read files inside roots explicitly granted to it. | | Windows sandboxing | Windows has no execution-sandbox backend yet. | | LLM provider data handling | Data you send to a provider is governed by that provider's policies. | | MCP server behavior | External MCP servers you configure are outside the trust boundary. | diff --git a/backend/cli/src/agent/prompt/research.txt b/backend/cli/src/agent/prompt/research.txt index 0fd6b9fc..aa0920f1 100644 --- a/backend/cli/src/agent/prompt/research.txt +++ b/backend/cli/src/agent/prompt/research.txt @@ -34,13 +34,11 @@ result; internal profiles and skills are implementation details, not user-facing - Use persistent Python/R for stateful analysis and shell for builds, tests, files, and scripts. Kernel state is working memory, not reproducibility: save source, inputs, parameters, and outputs, and clean-rerun material results when practical. -- Use WebFetch text mode only for bounded pages and API responses. For large or binary science data, - set WebFetch `output_path` to a simple workspace-root filename. If metadata gives an exact size, - set `max_bytes` once just above it; when size is unknown, omit it to use the bounded default. Never - probe the same URL by repeatedly raising the cap. - Stream once through the authorized broker into the session workspace, verify its digest, and - process it locally. Paginate APIs instead of repeatedly requesting an oversized response. Do not - assume Shell has network access. +- Use WebFetch text mode only for bounded pages and APIs. Download large or binary scientific data to a root + basename. For `papers/foo.pdf`, use `output_path:"foo.pdf"`; only after success run sandboxed Bash + `mkdir -p -- 'papers' && test ! -e 'papers/foo.pdf' && mv -- 'foo.pdf' 'papers/foo.pdf'`. Never probe folder paths or send retired + cap/size-evidence fields; WebFetch uses live free disk minus its reserve. Verify the brokered download, + process it locally, and paginate APIs. Do not assume Shell has network access. - Treat an explicitly requested immutable data release as an evidence constraint. If it cannot be retrieved and verified, disclose that early; stop that branch or clearly bound and label any live-release fallback rather than silently mixing releases. diff --git a/backend/cli/src/cli/cmd/cmd.ts b/backend/cli/src/cli/cmd/cmd.ts index fe6d62d7..2f652d20 100644 --- a/backend/cli/src/cli/cmd/cmd.ts +++ b/backend/cli/src/cli/cmd/cmd.ts @@ -1,7 +1,49 @@ -import type { CommandModule } from "yargs" +import type { ArgumentsCamelCase, CommandModule } from "yargs" +import { DataRootBarrier } from "../../global/data-root-barrier" type WithDoubleDash = T & { "--"?: string[] } +let dataRootOperation: DataRootBarrier.Operation | undefined + +// Only these commands intentionally keep serving after parse returns. Every +// other parsed command (including aliases and shell completion) gets one +// physical data-root marker for its complete middleware/handler lifetime. +// A negative list avoids silently dropping protection when a command adds an +// alias or a new short-lived top-level entry. +const longLivedCommands = new Set(["web", "serve"]) + +export async function runDataRootMiddleware( + command: string | undefined, + filepath: string, + action: () => T | Promise, + timeoutMs = 120_000, +): Promise { + if (!command || longLivedCommands.has(command)) return await action() + let operation = dataRootOperation + if (!operation) { + operation = await DataRootBarrier.enter(filepath, timeoutMs) + dataRootOperation = operation + } + return await operation.during(async () => await action()) +} + +export async function runInDataRootScope(action: () => T | Promise): Promise { + const operation = dataRootOperation + if (!operation) return await action() + return await operation.during(async () => await action()) +} + +export async function disposeDataRootOperation() { + const operation = dataRootOperation + dataRootOperation = undefined + await operation?.[Symbol.asyncDispose]() +} + export function cmd(input: CommandModule>) { - return input + const handler = input.handler + if (!handler) return input + return { + ...input, + handler: (args: ArgumentsCamelCase>) => runInDataRootScope(() => handler(args)), + } satisfies CommandModule> } diff --git a/backend/cli/src/cli/cmd/debug/index.ts b/backend/cli/src/cli/cmd/debug/index.ts index 8da6ff55..250e11a9 100644 --- a/backend/cli/src/cli/cmd/debug/index.ts +++ b/backend/cli/src/cli/cmd/debug/index.ts @@ -24,15 +24,17 @@ export const DebugCommand = cmd({ .command(SnapshotCommand) .command(AgentCommand) .command(PathsCommand) - .command({ - command: "wait", - describe: "wait indefinitely (for debugging)", - async handler() { - await bootstrap(process.cwd(), async () => { - await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24)) - }) - }, - }) + .command( + cmd({ + command: "wait", + describe: "wait indefinitely (for debugging)", + async handler() { + await bootstrap(process.cwd(), async () => { + await new Promise((resolve) => setTimeout(resolve, 1_000 * 60 * 60 * 24)) + }) + }, + }), + ) .demandCommand(), async handler() {}, }) diff --git a/backend/cli/src/cli/cmd/generate.ts b/backend/cli/src/cli/cmd/generate.ts index dc17116d..aad8d805 100644 --- a/backend/cli/src/cli/cmd/generate.ts +++ b/backend/cli/src/cli/cmd/generate.ts @@ -1,7 +1,7 @@ import { Server } from "../../server/server" -import type { CommandModule } from "yargs" +import { cmd } from "./cmd" -export const GenerateCommand = { +export const GenerateCommand = cmd({ command: "generate", handler: async () => { const specs = await Server.openapi() @@ -35,4 +35,4 @@ export const GenerateCommand = { }) }) }, -} satisfies CommandModule +}) diff --git a/backend/cli/src/cli/cmd/sandbox.ts b/backend/cli/src/cli/cmd/sandbox.ts index 9ae09cff..f2c5da38 100644 --- a/backend/cli/src/cli/cmd/sandbox.ts +++ b/backend/cli/src/cli/cmd/sandbox.ts @@ -19,7 +19,9 @@ function printStatus(config?: Config.Sandbox) { UI.println(`${S.TEXT_NORMAL_BOLD}Execution sandbox${S.TEXT_NORMAL}`) UI.println( ` status ${enabled ? `${S.TEXT_SUCCESS_BOLD}enabled` : `${S.TEXT_DIM}disabled`}${S.TEXT_NORMAL}` + - `${S.TEXT_DIM} (agent shell commands${enabled ? " are confined to the workspace" : " run with full user authority"})${S.TEXT_NORMAL}`, + `${S.TEXT_DIM} (agent shell commands${ + enabled ? " are confined to approved paths" : " require project trust before using full user authority" + })${S.TEXT_NORMAL}`, ) UI.println(` platform ${d.platform}`) UI.println( @@ -31,6 +33,9 @@ function printStatus(config?: Config.Sandbox) { ) if (enabled) { UI.println(` network ${config?.network ?? "deny"}`) + UI.println( + ` project trust ${config?.requireProjectTrust ? "required for all execution" : "routine sandboxed work allowed"}`, + ) UI.println(` on missing backend ${config?.onUnavailable ?? "error"}`) if (config?.allowWrite?.length) UI.println(` extra writable ${config.allowWrite.join(", ")}`) } @@ -38,7 +43,7 @@ function printStatus(config?: Config.Sandbox) { UI.println("") UI.println( ` ${S.TEXT_WARNING_BOLD}Note:${S.TEXT_NORMAL} sandbox is on but no backend exists here — ` + - `commands run per "${config?.onUnavailable ?? "error"}". It takes effect on machines with a backend.`, + `execution follows the "${config?.onUnavailable ?? "error"}" fallback policy. It takes effect on machines with a backend.`, ) } } @@ -78,6 +83,10 @@ const EnableCommand = cmd({ .option("on-unavailable", { choices: ["warn", "error", "allow"] as const, describe: "what to do when no backend exists on a machine (default: error)", + }) + .option("require-project-trust", { + type: "boolean", + describe: "require explicit project trust even for routine sandboxed commands", }), handler: async (args) => { await Instance.provide({ @@ -86,6 +95,9 @@ const EnableCommand = cmd({ const patch: Partial = { enabled: true } if (args.network) patch.network = args.network as "allow" | "deny" if (args["on-unavailable"]) patch.onUnavailable = args["on-unavailable"] as "warn" | "error" | "allow" + if (typeof args["require-project-trust"] === "boolean") { + patch.requireProjectTrust = args["require-project-trust"] + } const allow = args.allow as string[] | undefined if (allow?.length) { patch.allowWrite = allow.map((value) => { diff --git a/backend/cli/src/cli/cmd/uninstall.ts b/backend/cli/src/cli/cmd/uninstall.ts index ee5ec4fd..769475a4 100644 --- a/backend/cli/src/cli/cmd/uninstall.ts +++ b/backend/cli/src/cli/cmd/uninstall.ts @@ -7,6 +7,7 @@ import { $ } from "bun" import fs from "fs/promises" import path from "path" import os from "os" +import { cmd } from "./cmd" interface UninstallArgs { keepConfig?: boolean @@ -22,7 +23,7 @@ interface RemovalTargets { binary: string | null } -export const UninstallCommand = { +export const UninstallCommand = cmd({ command: "uninstall", describe: "uninstall openscience while keeping your work and settings by default", builder: (yargs: Argv) => @@ -88,7 +89,7 @@ export const UninstallCommand = { prompts.outro("Done") }, -} +}) async function collectRemovalTargets(args: UninstallArgs, method: Installation.Method): Promise { const directories = uninstallDirectories(args) diff --git a/backend/cli/src/cli/cmd/upgrade.ts b/backend/cli/src/cli/cmd/upgrade.ts index e3876043..6fe4b51a 100644 --- a/backend/cli/src/cli/cmd/upgrade.ts +++ b/backend/cli/src/cli/cmd/upgrade.ts @@ -2,8 +2,9 @@ import type { Argv } from "yargs" import { UI } from "../ui" import * as prompts from "@clack/prompts" import { Installation } from "../../installation" +import { cmd } from "./cmd" -export const UpgradeCommand = { +export const UpgradeCommand = cmd({ command: "upgrade [target]", describe: "upgrade openscience to the latest or a specific version", builder: (yargs: Argv) => { @@ -68,4 +69,4 @@ export const UpgradeCommand = { spinner.stop("Upgrade complete") prompts.outro("Done") }, -} +}) diff --git a/backend/cli/src/compute/jobs.ts b/backend/cli/src/compute/jobs.ts index 23f1b955..4917734d 100644 --- a/backend/cli/src/compute/jobs.ts +++ b/backend/cli/src/compute/jobs.ts @@ -569,10 +569,12 @@ export namespace ComputeJobs { .catch(() => undefined) .then(async () => { await using lease = await FileLease.acquire(`${metaOf(root)}.lock`) - const jobs = await read(root).catch((error) => preserve(root, error)) - const result = await edit(jobs) - await write(root, jobs) - return result + return await lease.during(async () => { + const jobs = await read(root).catch((error) => preserve(root, error)) + const result = await edit(jobs) + await write(root, jobs) + return result + }) }) locks.set( root, @@ -832,7 +834,7 @@ export namespace ComputeJobs { if (job.status === "queued" && Date.now() - Date.parse(job.created_at) < 5_000) return if (job.target.kind === "modal") { claims.add(key) - let lease: AsyncDisposable | undefined + let lease: FileLease.Lease | undefined let handedOff = false try { lease = await FileLease.acquire(modalLeaseOf(root, job.id), 25).catch((error) => { @@ -840,78 +842,95 @@ export namespace ComputeJobs { throw error }) if (!lease) return - const prior = await recovery(root, job) - if (prior.retry > Date.now()) return - const credentials = options.credentials ?? (await options.resolveCredentials?.().catch(() => undefined)) - if (!credentials || !job.authority) return - const provider = options.provider ?? ModalAdapter - const authorized = await currentAuthority(job.authority).then( - () => true, - async () => { - await cancel(job.id, { - ...options, - root, - workspace: scope.workspace, - credentials, - provider, - }).catch(() => undefined) - return false - }, - ) - if (!authorized) return - const current = await get(job.id, { root, workspace: scope.workspace }) - if (!current || current.status === "cancelled") return - await activate(key, { - detached: false, - authority: job.authority, - root, - workspace: scope.workspace, - id: job.id, - modal: credentials, - provider: options.provider, - }) - const cleanup = terminal.has(job.status) && lifecycle.delivery !== "pending" + const setup = Promise.withResolvers() const ready = Promise.withResolvers() - const task = cleanup - ? cleanupModal(job, scope, credentials, provider) - : recoverModal(job, scope, credentials, provider, ready.resolve) - const managed = task - .catch(async (error) => { - const current = await get(job.id, { root, workspace: scope.workspace }) - if (error instanceof ModalAdapter.HarvestError && current && !terminal.has(current.status)) { - await deferModal(job, scope, error) - return + let cleanup = false + let activated = false + const managed = lease + .during(async () => { + try { + const prior = await recovery(root, job) + if (prior.retry > Date.now()) return + const credentials = + options.credentials ?? (await options.resolveCredentials?.().catch(() => undefined)) + if (!credentials || !job.authority) return + const provider = options.provider ?? ModalAdapter + const authorized = await currentAuthority(job.authority).then( + () => true, + async () => { + await cancel(job.id, { + ...options, + root, + workspace: scope.workspace, + credentials, + provider, + }).catch(() => undefined) + return false + }, + ) + if (!authorized) return + const current = await get(job.id, { root, workspace: scope.workspace }) + if (!current || current.status === "cancelled") return + await activate(key, { + detached: false, + authority: job.authority, + root, + workspace: scope.workspace, + id: job.id, + modal: credentials, + provider: options.provider, + }) + activated = true + cleanup = terminal.has(job.status) && lifecycle.delivery !== "pending" + setup.resolve() + return await ( + cleanup + ? cleanupModal(job, scope, credentials, provider) + : recoverModal(job, scope, credentials, provider, ready.resolve) + ).catch(async (error) => { + const current = await get(job.id, { root, workspace: scope.workspace }) + if (error instanceof ModalAdapter.HarvestError && current && !terminal.has(current.status)) { + await deferModal(job, scope, error) + return + } + if (current && terminal.has(current.status) && current.lifecycle?.delivery === "pending") { + await failModal(current, scope, credentials, error, provider) + return + } + if (!current || terminal.has(current.status)) return + const message = OpenScience.redactSecrets( + error instanceof Error ? error.message : String(error), + ) + const attempt = prior.attempt + 1 + if (attempt >= recoveryLimit) { + await event(root, job.id, `Modal recovery failed after ${attempt} attempts: ${message}`) + await failModal(current, scope, credentials, error, provider, true) + return + } + await change(root, (jobs) => { + const stored = jobs.find((item) => item.id === job.id) + if (!stored) return + stored.recovery_attempts = attempt + stored.recovery_retry_at = new Date(Date.now() + recoveryDelay).toISOString() + }) + await event( + root, + job.id, + `Modal recovery attempt ${attempt}/${recoveryLimit} deferred for ${recoveryDelay / 1000} seconds: ${message}`, + ) + }) + } catch (error) { + setup.reject(error) + throw error + } finally { + setup.resolve() + if (activated) await deactivate(key) } - if (current && terminal.has(current.status) && current.lifecycle?.delivery === "pending") { - await failModal(current, scope, credentials, error, provider) - return - } - if (!current || terminal.has(current.status)) return - const message = OpenScience.redactSecrets(error instanceof Error ? error.message : String(error)) - const attempt = prior.attempt + 1 - if (attempt >= recoveryLimit) { - await event(root, job.id, `Modal recovery failed after ${attempt} attempts: ${message}`) - await failModal(current, scope, credentials, error, provider, true) - return - } - await change(root, (jobs) => { - const stored = jobs.find((item) => item.id === job.id) - if (!stored) return - stored.recovery_attempts = attempt - stored.recovery_retry_at = new Date(Date.now() + recoveryDelay).toISOString() - }) - await event( - root, - job.id, - `Modal recovery attempt ${attempt}/${recoveryLimit} deferred for ${recoveryDelay / 1000} seconds: ${message}`, - ) - }) - .finally(async () => { - await deactivate(key) - await releaseLease(lease!) }) + .finally(() => releaseLease(lease!)) handedOff = true void managed.catch(() => undefined) + await setup.promise if (!cleanup) await Promise.race([ ready.promise, @@ -929,7 +948,7 @@ export namespace ComputeJobs { } if (job.target.kind === "ssh") { claims.add(key) - let lease: AsyncDisposable | undefined + let lease: FileLease.Lease | undefined let handedOff = false try { lease = await FileLease.acquire(sshLeaseOf(root, job.id), 25).catch((error) => { @@ -937,47 +956,60 @@ export namespace ComputeJobs { throw error }) if (!lease) return - const prior = await recovery(root, job) - if (prior.retry > Date.now()) return - await activate(key, { - detached: false, - authority: job.authority!, - root, - workspace: scope.workspace, - id: job.id, - host: job.ssh?.host, - }) - const managed = recoverSsh(job, scope) - .then(async () => { - if (!job.recovery_attempts && !job.recovery_retry_at) return - await change(root, (jobs) => { - const stored = jobs.find((item) => item.id === job.id) - if (!stored) return - stored.recovery_attempts = undefined - stored.recovery_retry_at = undefined - }) - }) - .catch(async (error) => { - const attempt = prior.attempt + 1 - const delay = Math.min(5 * 60_000, recoveryDelay * 2 ** Math.min(attempt - 1, 5)) - await change(root, (jobs) => { - const stored = jobs.find((item) => item.id === job.id) - if (!stored) return - stored.recovery_attempts = attempt - stored.recovery_retry_at = new Date(Date.now() + delay).toISOString() - }) - await event( - root, - job.id, - `SSH recovery attempt ${attempt} deferred for ${delay / 1000} seconds: ${error instanceof Error ? error.message : String(error)}`, - ) - }) - .finally(async () => { - await deactivate(key) - await releaseLease(lease!) + const setup = Promise.withResolvers() + let activated = false + const managed = lease + .during(async () => { + try { + const prior = await recovery(root, job) + if (prior.retry > Date.now()) return + await activate(key, { + detached: false, + authority: job.authority!, + root, + workspace: scope.workspace, + id: job.id, + host: job.ssh?.host, + }) + activated = true + setup.resolve() + return await recoverSsh(job, scope) + .then(async () => { + if (!job.recovery_attempts && !job.recovery_retry_at) return + await change(root, (jobs) => { + const stored = jobs.find((item) => item.id === job.id) + if (!stored) return + stored.recovery_attempts = undefined + stored.recovery_retry_at = undefined + }) + }) + .catch(async (error) => { + const attempt = prior.attempt + 1 + const delay = Math.min(5 * 60_000, recoveryDelay * 2 ** Math.min(attempt - 1, 5)) + await change(root, (jobs) => { + const stored = jobs.find((item) => item.id === job.id) + if (!stored) return + stored.recovery_attempts = attempt + stored.recovery_retry_at = new Date(Date.now() + delay).toISOString() + }) + await event( + root, + job.id, + `SSH recovery attempt ${attempt} deferred for ${delay / 1000} seconds: ${error instanceof Error ? error.message : String(error)}`, + ) + }) + } catch (error) { + setup.reject(error) + throw error + } finally { + setup.resolve() + if (activated) await deactivate(key) + } }) + .finally(() => releaseLease(lease!)) handedOff = true void managed.catch(() => undefined) + await setup.promise } finally { if (lease && !handedOff) await releaseLease(lease) claims.delete(key) @@ -986,7 +1018,7 @@ export namespace ComputeJobs { } if (job.target.kind === "local") { claims.add(key) - let lease: AsyncDisposable | undefined + let lease: FileLease.Lease | undefined let handedOff = false try { lease = await FileLease.acquire(localLeaseOf(root, job.id), 25).catch((error) => { @@ -994,86 +1026,97 @@ export namespace ComputeJobs { throw error }) if (!lease) return - const current = await get(job.id, { root, workspace: scope.workspace }) - if (!current || terminal.has(current.status)) return - const exit = await localExit(root, current.id) - if (exit !== undefined) { - await change(root, (jobs) => { - const index = jobs.findIndex((item) => item.id === current.id) - if (index < 0 || terminal.has(jobs[index]!.status)) return - const finished = move( - jobs[index]!, - { type: "finish", outcome: exit === 0 ? "succeeded" : "failed" }, - { - completed_at: new Date().toISOString(), - exit_code: exit, - pid: undefined, - process_identity: undefined, - }, - ) - const closed = move(finished, { type: "close" }) - jobs[index] = Job.parse({ ...closed, provenance: provenance(closed) }) - }) - return - } - if (!current.pid || !(await owns(current.pid, current.process_identity))) { - // A normal wrapper writes its exit marker before the owned - // supervisor disappears. Re-read after the identity check, - // then classify a genuinely markerless death while still - // holding the one durable local lifecycle lease. - const reported = await localExit(root, current.id) - await change(root, (jobs) => { - const index = jobs.findIndex((item) => item.id === current.id) - if (index < 0 || terminal.has(jobs[index]!.status)) return - const draft = - reported === undefined - ? move( + const setup = Promise.withResolvers() + let activated = false + const managed = lease + .during(async () => { + try { + const current = await get(job.id, { root, workspace: scope.workspace }) + if (!current || terminal.has(current.status)) return + const exit = await localExit(root, current.id) + if (exit !== undefined) { + await change(root, (jobs) => { + const index = jobs.findIndex((item) => item.id === current.id) + if (index < 0 || terminal.has(jobs[index]!.status)) return + const finished = move( jobs[index]!, - { type: "interrupt" }, + { type: "finish", outcome: exit === 0 ? "succeeded" : "failed" }, { completed_at: new Date().toISOString(), - exit_code: null, + exit_code: exit, pid: undefined, process_identity: undefined, - error: "The job process ended before it could report a result.", }, ) - : move( - jobs[index]!, - { type: "finish", outcome: reported === 0 ? "succeeded" : "failed" }, - { - completed_at: new Date().toISOString(), - exit_code: reported, - pid: undefined, - process_identity: undefined, - }, - ) - const closed = reported === undefined ? draft : move(draft, { type: "close" }) - jobs[index] = Job.parse({ ...closed, provenance: provenance(closed) }) + const closed = move(finished, { type: "close" }) + jobs[index] = Job.parse({ ...closed, provenance: provenance(closed) }) + }) + return + } + if (!current.pid || !(await owns(current.pid, current.process_identity))) { + // A normal wrapper writes its exit marker before the owned + // supervisor disappears. Re-read after the identity check, + // then classify a genuinely markerless death while still + // holding the one durable local lifecycle lease. + const reported = await localExit(root, current.id) + await change(root, (jobs) => { + const index = jobs.findIndex((item) => item.id === current.id) + if (index < 0 || terminal.has(jobs[index]!.status)) return + const draft = + reported === undefined + ? move( + jobs[index]!, + { type: "interrupt" }, + { + completed_at: new Date().toISOString(), + exit_code: null, + pid: undefined, + process_identity: undefined, + error: "The job process ended before it could report a result.", + }, + ) + : move( + jobs[index]!, + { type: "finish", outcome: reported === 0 ? "succeeded" : "failed" }, + { + completed_at: new Date().toISOString(), + exit_code: reported, + pid: undefined, + process_identity: undefined, + }, + ) + const closed = reported === undefined ? draft : move(draft, { type: "close" }) + jobs[index] = Job.parse({ ...closed, provenance: provenance(closed) }) + }) + return + } + if (!current.authority) return + await activate(key, { + dataRootOwner: + process.platform === "win32" + ? undefined + : { pid: current.pid, identity: current.process_identity! }, + detached: process.platform !== "win32", + authority: current.authority, + root, + workspace: scope.workspace, + id: current.id, + }) + activated = true + setup.resolve() + await recoverLocal(current, scope) + } catch (error) { + setup.reject(error) + throw error + } finally { + setup.resolve() + if (activated) await deactivate(key) + } }) - return - } - if (!current.authority) return - await activate(key, { - dataRootOwner: - process.platform === "win32" - ? undefined - : { pid: current.pid, identity: current.process_identity! }, - detached: process.platform !== "win32", - authority: current.authority, - root, - workspace: scope.workspace, - id: current.id, - }) - const managed = recoverLocal(current, scope).finally(async () => { - try { - await deactivate(key) - } finally { - await releaseLease(lease!) - } - }) + .finally(() => releaseLease(lease!)) handedOff = true void managed.catch(() => undefined) + await setup.promise } finally { claims.delete(key) if (lease && !handedOff) await releaseLease(lease) @@ -1996,50 +2039,52 @@ export namespace ComputeJobs { async function cancelSsh(job: Job, scope: Scope) { if (!job.ssh || !job.authority) throw new Error(`SSH job ${job.id} has no cancellable remote resource`) await using operation = await FileLease.acquire(sshOperationOf(scope.root, job.id)) - const current = await change(scope.root, (jobs) => { - const index = jobs.findIndex((item) => item.id === job.id) - if (index < 0) throw new Error(`Compute job ${job.id} was not found`) - const stored = jobs[index]! - if (terminal.has(stored.status)) return stored - const cancelled = move(stored, { type: "cancel" }, { completed_at: new Date().toISOString(), exit_code: null }) - jobs[index] = Job.parse({ ...cancelled, provenance: provenance(cancelled) }) - return jobs[index]! - }) - const remote = await (async () => { - if (!current.remote_id) return { closed: true, error: undefined } - const spec = await sshSpec(current, scope) - const checked = await sshRun(scope, current, current.ssh!.host, current.authority!, SshAdapter.inspect(spec), { - timeout: 30_000, - authorize: false, + return await operation.during(async () => { + const current = await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === job.id) + if (index < 0) throw new Error(`Compute job ${job.id} was not found`) + const stored = jobs[index]! + if (terminal.has(stored.status)) return stored + const cancelled = move(stored, { type: "cancel" }, { completed_at: new Date().toISOString(), exit_code: null }) + jobs[index] = Job.parse({ ...cancelled, provenance: provenance(cancelled) }) + return jobs[index]! }) - if (!SshAdapter.parse<{ exists: boolean }>(checked.stdout).exists) return { closed: true, error: undefined } - const cancelled = await sshRun( - scope, - current, - current.ssh!.host, - current.authority!, - SshAdapter.invoke(spec, "cancel", current.remote_id), - { timeout: 30_000, authorize: false }, - ).then((value) => SshAdapter.parse<{ cancelled: boolean }>(value.stdout).cancelled) - if (!cancelled) return { closed: false, error: "Remote scheduler did not confirm cancellation" } - await releaseSsh(current, scope, false) - return { closed: true, error: undefined } - })().catch((error) => ({ closed: false, error: error instanceof Error ? error.message : String(error) })) - if (remote.error) await event(scope.root, current.id, `Remote cancellation pending: ${remote.error}`) - return await change(scope.root, (jobs) => { - const index = jobs.findIndex((item) => item.id === current.id) - if (index < 0) throw new Error(`Compute job ${current.id} was not found`) - const stored = jobs[index]! - const abandoned = stored.lifecycle?.recoverable ? move(stored, { type: "abandon" }) : stored - const lifecycle = remote.closed ? move(abandoned, { type: "close" }) : move(abandoned, { type: "lose" }) - jobs[index] = Job.parse({ - ...lifecycle, - cleanup_error: remote.closed - ? undefined - : `Remote cancellation was not confirmed. ${remote.error ?? "Retry cancellation."}`, - provenance: provenance(lifecycle), + const remote = await (async () => { + if (!current.remote_id) return { closed: true, error: undefined } + const spec = await sshSpec(current, scope) + const checked = await sshRun(scope, current, current.ssh!.host, current.authority!, SshAdapter.inspect(spec), { + timeout: 30_000, + authorize: false, + }) + if (!SshAdapter.parse<{ exists: boolean }>(checked.stdout).exists) return { closed: true, error: undefined } + const cancelled = await sshRun( + scope, + current, + current.ssh!.host, + current.authority!, + SshAdapter.invoke(spec, "cancel", current.remote_id), + { timeout: 30_000, authorize: false }, + ).then((value) => SshAdapter.parse<{ cancelled: boolean }>(value.stdout).cancelled) + if (!cancelled) return { closed: false, error: "Remote scheduler did not confirm cancellation" } + await releaseSsh(current, scope, false) + return { closed: true, error: undefined } + })().catch((error) => ({ closed: false, error: error instanceof Error ? error.message : String(error) })) + if (remote.error) await event(scope.root, current.id, `Remote cancellation pending: ${remote.error}`) + return await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === current.id) + if (index < 0) throw new Error(`Compute job ${current.id} was not found`) + const stored = jobs[index]! + const abandoned = stored.lifecycle?.recoverable ? move(stored, { type: "abandon" }) : stored + const lifecycle = remote.closed ? move(abandoned, { type: "close" }) : move(abandoned, { type: "lose" }) + jobs[index] = Job.parse({ + ...lifecycle, + cleanup_error: remote.closed + ? undefined + : `Remote cancellation was not confirmed. ${remote.error ?? "Retry cancellation."}`, + provenance: provenance(lifecycle), + }) + return jobs[index]! }) - return jobs[index]! }) } @@ -2596,66 +2641,77 @@ export namespace ComputeJobs { throw new Error(`Compute job ${id} has no recoverable SSH output`) } await using operation = await FileLease.acquire(sshOperationOf(scope.root, id)) - await using lease = await FileLease.acquire(sshLeaseOf(scope.root, id)) - const retrying = await change(scope.root, (jobs) => { - const index = jobs.findIndex((item) => item.id === id) - if (index < 0) throw new Error(`Compute job ${id} was not found`) - const draft = move(jobs[index]!, { type: "retry_delivery" }, { capture_error: undefined, error: undefined }) - jobs[index] = Job.parse({ ...draft, provenance: provenance(draft) }) - return jobs[index]! + return await operation.during(async () => { + await using lease = await FileLease.acquire(sshLeaseOf(scope.root, id)) + return await lease.during(async () => { + const retrying = await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === id) + if (index < 0) throw new Error(`Compute job ${id} was not found`) + const draft = move(jobs[index]!, { type: "retry_delivery" }, { capture_error: undefined, error: undefined }) + jobs[index] = Job.parse({ ...draft, provenance: provenance(draft) }) + return jobs[index]! + }) + await finishSsh(retrying, scope, retrying.exit_code ?? 1) + return (await get(id, { root: scope.root, workspace: scope.workspace }))! + }) }) - await finishSsh(retrying, scope, retrying.exit_code ?? 1) - return (await get(id, { root: scope.root, workspace: scope.workspace }))! } // A Modal delivery failure can become visible just before its current // owner releases the in-memory runtime. The durable lease is the source of // truth across both this process and sibling servers: wait for that owner // instead of rejecting an explicit retry in the handoff window. await using operation = await FileLease.acquire(modalOperationOf(scope.root, id)) - const lease = await FileLease.acquire(modalLeaseOf(scope.root, id)) - let handedOff = false - try { - const provider = options.provider ?? ModalAdapter - const job = await change(scope.root, (jobs) => { - const index = jobs.findIndex((item) => item.id === id) - if (index < 0) throw new Error(`Compute job ${id} was not found`) - const current = jobs[index]! - if (current.target.kind !== "modal" || !current.modal || !current.cwd || !current.authority) { - throw new Error(`Compute job ${id} has no recoverable Modal output`) - } - if (!terminal.has(current.status) || !current.lifecycle?.recoverable) { - throw new Error(`Compute job ${id} has no recoverable Modal output`) - } - const draft = move(current, { type: "retry_delivery" }, { error: undefined, capture_error: undefined }) - const updated = Job.parse({ ...draft, provenance: provenance(draft) }) - jobs[index] = updated - return updated - }) - const context = await modalContext(options, "Enable Modal before retrying output delivery") - await activate(key, { - detached: false, - authority: job.authority!, - root: scope.root, - workspace: scope.workspace, - id: job.id, - modal: context, - provider, - }) - const managed = recoverModal(job, scope, context, provider) - .catch((error) => failModal(job, scope, context, error, provider)) - .finally(async () => { + return await operation.during(async () => { + const lease = await FileLease.acquire(modalLeaseOf(scope.root, id)) + let handedOff = false + try { + const provider = options.provider ?? ModalAdapter + const job = await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === id) + if (index < 0) throw new Error(`Compute job ${id} was not found`) + const current = jobs[index]! + if (current.target.kind !== "modal" || !current.modal || !current.cwd || !current.authority) { + throw new Error(`Compute job ${id} has no recoverable Modal output`) + } + if (!terminal.has(current.status) || !current.lifecycle?.recoverable) { + throw new Error(`Compute job ${id} has no recoverable Modal output`) + } + const draft = move(current, { type: "retry_delivery" }, { error: undefined, capture_error: undefined }) + const updated = Job.parse({ ...draft, provenance: provenance(draft) }) + jobs[index] = updated + return updated + }) + const context = await modalContext(options, "Enable Modal before retrying output delivery") + await activate(key, { + detached: false, + authority: job.authority!, + root: scope.root, + workspace: scope.workspace, + id: job.id, + modal: context, + provider, + }) + const managed = lease + .during(async () => { + try { + await recoverModal(job, scope, context, provider) + } catch (error) { + await failModal(job, scope, context, error, provider) + } finally { + await deactivate(key) + } + }) + .finally(() => releaseLease(lease)) + handedOff = true + void managed.catch(() => undefined) + return job + } finally { + if (!handedOff) { await deactivate(key) await releaseLease(lease) - }) - handedOff = true - void managed.catch(() => undefined) - return job - } finally { - if (!handedOff) { - await deactivate(key) - await releaseLease(lease) + } } - } + }) } export async function release(id: string, options: Options = {}): Promise { @@ -2667,46 +2723,54 @@ export namespace ComputeJobs { if (!terminal.has(stored.status)) throw new Error(`Cancel compute job ${id} before releasing its resources`) if (stored.status === "cancelled" && stored.lifecycle?.resource !== "closed") return cancelSsh(stored, scope) await using operation = await FileLease.acquire(sshOperationOf(scope.root, id)) - await using lease = await FileLease.acquire(sshLeaseOf(scope.root, id)) - await releaseSsh(stored, scope) - return await change(scope.root, (jobs) => { - const index = jobs.findIndex((item) => item.id === id) - if (index < 0) throw new Error(`Compute job ${id} was not found`) - const current = jobs[index]! - const abandoned = current.lifecycle?.recoverable ? move(current, { type: "abandon" }) : current - const closed = current.lifecycle?.resource === "closed" ? abandoned : move(abandoned, { type: "close" }) - jobs[index] = Job.parse({ ...closed, cleanup_error: undefined, provenance: provenance(closed) }) - return jobs[index]! + return await operation.during(async () => { + await using lease = await FileLease.acquire(sshLeaseOf(scope.root, id)) + return await lease.during(async () => { + await releaseSsh(stored, scope) + return await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === id) + if (index < 0) throw new Error(`Compute job ${id} was not found`) + const current = jobs[index]! + const abandoned = current.lifecycle?.recoverable ? move(current, { type: "abandon" }) : current + const closed = current.lifecycle?.resource === "closed" ? abandoned : move(abandoned, { type: "close" }) + jobs[index] = Job.parse({ ...closed, cleanup_error: undefined, provenance: provenance(closed) }) + return jobs[index]! + }) + }) }) } await using operation = await FileLease.acquire(modalOperationOf(scope.root, id)) - await using lease = await FileLease.acquire(modalLeaseOf(scope.root, id)) - const job = await get(id, { root: scope.root, workspace: scope.workspace }) - if (!job) throw new Error(`Compute job ${id} was not found`) - if (job.target.kind !== "modal" || !job.modal || !job.cwd) { - throw new Error(`Compute job ${id} has no Modal resources to release`) - } - if (!terminal.has(job.status)) throw new Error(`Cancel compute job ${id} before releasing its resources`) - if (job.lifecycle?.resource === "closed") return job - const context = await modalContext(options, "Enable Modal before releasing retained job resources") - const provider = options.provider ?? ModalAdapter - const spec = modalSpec(job, [], scope) - await provider.release(context, spec, job.remote_id) - if (job.remote_id) await event(scope.root, job.id, `Closed Modal sandbox ${job.remote_id}`) - await event(scope.root, job.id, `Released Modal volume ${spec.volume}`) - const released = await change(scope.root, (jobs) => { - const index = jobs.findIndex((item) => item.id === id) - if (index < 0) throw new Error(`Compute job ${id} was not found`) - const current = jobs[index]! - if (current.lifecycle?.resource === "closed") return current - const abandoned = current.lifecycle?.recoverable ? move(current, { type: "abandon" }) : current - const closed = move(abandoned, { type: "close" }) - const updated = Job.parse({ ...closed, provenance: provenance(closed) }) - jobs[index] = updated - return updated + return await operation.during(async () => { + await using lease = await FileLease.acquire(modalLeaseOf(scope.root, id)) + return await lease.during(async () => { + const job = await get(id, { root: scope.root, workspace: scope.workspace }) + if (!job) throw new Error(`Compute job ${id} was not found`) + if (job.target.kind !== "modal" || !job.modal || !job.cwd) { + throw new Error(`Compute job ${id} has no Modal resources to release`) + } + if (!terminal.has(job.status)) throw new Error(`Cancel compute job ${id} before releasing its resources`) + if (job.lifecycle?.resource === "closed") return job + const context = await modalContext(options, "Enable Modal before releasing retained job resources") + const provider = options.provider ?? ModalAdapter + const spec = modalSpec(job, [], scope) + await provider.release(context, spec, job.remote_id) + if (job.remote_id) await event(scope.root, job.id, `Closed Modal sandbox ${job.remote_id}`) + await event(scope.root, job.id, `Released Modal volume ${spec.volume}`) + const released = await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === id) + if (index < 0) throw new Error(`Compute job ${id} was not found`) + const current = jobs[index]! + if (current.lifecycle?.resource === "closed") return current + const abandoned = current.lifecycle?.recoverable ? move(current, { type: "abandon" }) : current + const closed = move(abandoned, { type: "close" }) + const updated = Job.parse({ ...closed, provenance: provenance(closed) }) + jobs[index] = updated + return updated + }) + await fs.rm(path.join(logsOf(scope.root), `${job.id}.modal`), { recursive: true, force: true }) + return released + }) }) - await fs.rm(path.join(logsOf(scope.root), `${job.id}.modal`), { recursive: true, force: true }) - return released } export async function plan(input: Request, options: Options = {}): Promise { @@ -2888,40 +2952,54 @@ export namespace ComputeJobs { const base = Job.parse({ ...draft, reproducibility }) const job = Job.parse({ ...base, provenance: provenance(base) }) await using admission = await FileLease.acquire(modalAdmissionOf(scope.root)) - await currentAuthority(authority) - await change(scope.root, (jobs) => { - const busy = jobs.filter(reservesModal).length - if (busy >= context.concurrency) { - throw new Error(`Modal concurrency limit reached for this project (${busy}/${context.concurrency})`) - } - jobs.push(job) + await admission.during(async () => { + await currentAuthority(authority) + await change(scope.root, (jobs) => { + const busy = jobs.filter(reservesModal).length + if (busy >= context.concurrency) { + throw new Error(`Modal concurrency limit reached for this project (${busy}/${context.concurrency})`) + } + jobs.push(job) + }) }) const lease = await FileLease.acquire(modalLeaseOf(scope.root, job.id)) let handedOff = false try { - await currentAuthority(authority) - await activate(key, { - detached: false, - authority, - root: scope.root, - workspace: scope.workspace, - id: job.id, - modal: context, - provider, - }) - const managed = executeModal(job, prepared.files, scope, context, provider) - .catch((error) => - error instanceof ModalAdapter.HarvestError - ? deferModal(job, scope, error) - : failModal(job, scope, context, error, provider), - ) - .finally(async () => { - await deactivate(key) - await releaseLease(lease) + const setup = Promise.withResolvers() + let activated = false + const managed = lease + .during(async () => { + try { + await currentAuthority(authority) + await activate(key, { + detached: false, + authority, + root: scope.root, + workspace: scope.workspace, + id: job.id, + modal: context, + provider, + }) + activated = true + setup.resolve() + await executeModal(job, prepared.files, scope, context, provider).catch((error) => + error instanceof ModalAdapter.HarvestError + ? deferModal(job, scope, error) + : failModal(job, scope, context, error, provider), + ) + } catch (error) { + setup.reject(error) + throw error + } finally { + setup.resolve() + if (activated) await deactivate(key) + } }) + .finally(() => releaseLease(lease)) handedOff = true void managed.catch(() => undefined) + await setup.promise return job } catch (error) { await change(scope.root, (jobs) => { @@ -2945,41 +3023,55 @@ export namespace ComputeJobs { const base = Job.parse({ ...draft, reproducibility }) const job = Job.parse({ ...base, provenance: provenance(base) }) await using admission = await FileLease.acquire(sshAdmissionOf(scope.root, host.id)) - await currentAuthority(authority) - await change(scope.root, (jobs) => { - const busy = jobs.filter((item) => reservesSsh(item, host.id)).length - if (busy >= host.concurrency) { - throw new Error(`SSH concurrency limit reached for ${host.label} (${busy}/${host.concurrency})`) - } - jobs.push(job) + await admission.during(async () => { + await currentAuthority(authority) + await change(scope.root, (jobs) => { + const busy = jobs.filter((item) => reservesSsh(item, host.id)).length + if (busy >= host.concurrency) { + throw new Error(`SSH concurrency limit reached for ${host.label} (${busy}/${host.concurrency})`) + } + jobs.push(job) + }) }) const lease = await FileLease.acquire(sshLeaseOf(scope.root, job.id)) const key = keyOf(scope.root, job.id) let handedOff = false try { - await activate(key, { - detached: false, - authority, - root: scope.root, - workspace: scope.workspace, - id: job.id, - host, - }) + const setup = Promise.withResolvers() const ready = Promise.withResolvers() - const managed = startSsh(job, scope, remote.files, ready.resolve) - .catch((error) => { - // A caller must never receive a successful handoff for a control - // process that failed before durable registration. Persist the - // terminal job in the background, but reject the launch now. - ready.reject(error) - return failSshStart(job, scope, error) - }) - .finally(async () => { - await deactivate(key) - await releaseLease(lease) + let activated = false + const managed = lease + .during(async () => { + try { + await activate(key, { + detached: false, + authority, + root: scope.root, + workspace: scope.workspace, + id: job.id, + host, + }) + activated = true + setup.resolve() + await startSsh(job, scope, remote.files, ready.resolve).catch((error) => { + // A caller must never receive a successful handoff for a control + // process that failed before durable registration. Persist the + // terminal job in the background, but reject the launch now. + ready.reject(error) + return failSshStart(job, scope, error) + }) + } catch (error) { + setup.reject(error) + throw error + } finally { + setup.resolve() + if (activated) await deactivate(key) + } }) + .finally(() => releaseLease(lease)) handedOff = true void managed.catch(() => undefined) + await setup.promise // Do not wait for network transfer or remote submission. This mirrors // local launch semantics: return as soon as the first credential- // bearing child is durably owned, while surfacing pre-registration @@ -3029,54 +3121,63 @@ export namespace ComputeJobs { const lease = await FileLease.acquire(localLeaseOf(scope.root, job.id)) let handedOff = false try { - await activate(key, { - detached: false, - authority, - root: scope.root, - workspace: scope.workspace, - id: job.id, - host, - }) + const setup = Promise.withResolvers() const ready = Promise.withResolvers() - const managed = execute(job, host, scope, authority, planned, ready.resolve) - .catch(async (error) => { - // `Sandbox.cleanup` is idempotent. This covers authority/env failures - // that happen before a child is spawned; exit/error listeners own the - // normal running-child path. - Sandbox.cleanup(planned) - await fs.mkdir(logsOf(scope.root), { recursive: true }) - await fs - .appendFile( - path.join(logsOf(scope.root), `${job.id}.log`), - `${error instanceof Error ? error.message : String(error)}\n`, - ) - .catch(() => {}) - await change(scope.root, (jobs) => { - const index = jobs.findIndex((item) => item.id === job.id) - if (index < 0 || terminal.has(jobs[index]!.status)) return - const message = error instanceof Error ? error.message : String(error) - const draft = move( - jobs[index]!, - { type: "finish", outcome: "failed", message }, - { - completed_at: new Date().toISOString(), - exit_code: null, - error: message, - }, - ) - const closed = move(draft, { type: "close" }) - jobs[index] = Job.parse({ ...closed, provenance: provenance(closed) }) - }).catch(() => {}) - }) - .finally(async () => { + let activated = false + const managed = lease + .during(async () => { try { - await deactivate(key) + await activate(key, { + detached: false, + authority, + root: scope.root, + workspace: scope.workspace, + id: job.id, + host, + }) + activated = true + setup.resolve() + await execute(job, host, scope, authority, planned, ready.resolve).catch(async (error) => { + // `Sandbox.cleanup` is idempotent. This covers authority/env failures + // that happen before a child is spawned; exit/error listeners own the + // normal running-child path. + Sandbox.cleanup(planned) + await fs.mkdir(logsOf(scope.root), { recursive: true }) + await fs + .appendFile( + path.join(logsOf(scope.root), `${job.id}.log`), + `${error instanceof Error ? error.message : String(error)}\n`, + ) + .catch(() => {}) + await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === job.id) + if (index < 0 || terminal.has(jobs[index]!.status)) return + const message = error instanceof Error ? error.message : String(error) + const draft = move( + jobs[index]!, + { type: "finish", outcome: "failed", message }, + { + completed_at: new Date().toISOString(), + exit_code: null, + error: message, + }, + ) + const closed = move(draft, { type: "close" }) + jobs[index] = Job.parse({ ...closed, provenance: provenance(closed) }) + }).catch(() => {}) + }) + } catch (error) { + setup.reject(error) + throw error } finally { - await releaseLease(lease) + setup.resolve() + if (activated) await deactivate(key) } }) + .finally(() => releaseLease(lease)) handedOff = true void managed.catch(() => undefined) + await setup.promise await Promise.race([ready.promise, managed.then(() => undefined)]) return job } finally { @@ -3126,169 +3227,176 @@ export namespace ComputeJobs { export async function cancel(id: string, options: Options = {}): Promise { const scope = await scoped(options) const runtime = active.get(keyOf(scope.root, id)) - let current = (await read(scope.root).catch((error) => preserve(scope.root, error))).find((job) => job.id === id) - if (!current) throw new Error(`Compute job ${id} was not found`) + const initial = (await read(scope.root).catch((error) => preserve(scope.root, error))).find((job) => job.id === id) + if (!initial) throw new Error(`Compute job ${id} was not found`) + let current: Job = initial if (current.target.kind === "ssh") return cancelSsh(current, scope) await using operation = current.target.kind === "modal" ? await FileLease.acquire(modalOperationOf(scope.root, id)) : undefined - if (current.target.kind === "modal") { - current = (await read(scope.root).catch((error) => preserve(scope.root, error))).find((job) => job.id === id) - if (!current) throw new Error(`Compute job ${id} was not found`) - } - const needs = - current.target.kind === "modal" && (!terminal.has(current.status) || current.lifecycle?.resource === "unknown") - const context = - runtime?.modal ?? - (needs ? await modalContext(options, "Enable Modal before cancelling this recovered job") : undefined) - const localCancellation = current.target.kind !== "modal" && !terminal.has(current.status) - if (localCancellation) { - // Preserve the live descendant closure before a best-effort process - // signal can kill the leader and reparent a setsid child. - await CredentialProcessLedger.revoke({ id: credentialProcessID(scope.root, id), kind: "compute" }) - } - const result = await change(scope.root, (jobs) => { - const index = jobs.findIndex((item) => item.id === id) - if (index < 0) throw new Error(`Compute job ${id} was not found`) - if (terminal.has(jobs[index]!.status)) { - const job = jobs[index]! - if (localCancellation && job.status === "failed" && job.exit_code === null) { - const lifecycle = ComputeLifecycle.State.parse({ - ...(job.lifecycle ?? ComputeLifecycle.from(job.status)), - execution: "cancelled", - resource: "closed", - error_kind: undefined, - system_hint: undefined, - }) - const reconciled = Job.parse({ - ...job, - status: "cancelled", - lifecycle, + const action = async () => { + if (current.target.kind === "modal") { + const refreshed = (await read(scope.root).catch((error) => preserve(scope.root, error))).find( + (job) => job.id === id, + ) + if (!refreshed) throw new Error(`Compute job ${id} was not found`) + current = refreshed + } + const needs = + current.target.kind === "modal" && (!terminal.has(current.status) || current.lifecycle?.resource === "unknown") + const context = + runtime?.modal ?? + (needs ? await modalContext(options, "Enable Modal before cancelling this recovered job") : undefined) + const localCancellation = current.target.kind !== "modal" && !terminal.has(current.status) + if (localCancellation) { + // Preserve the live descendant closure before a best-effort process + // signal can kill the leader and reparent a setsid child. + await CredentialProcessLedger.revoke({ id: credentialProcessID(scope.root, id), kind: "compute" }) + } + const result = await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === id) + if (index < 0) throw new Error(`Compute job ${id} was not found`) + if (terminal.has(jobs[index]!.status)) { + const job = jobs[index]! + if (localCancellation && job.status === "failed" && job.exit_code === null) { + const lifecycle = ComputeLifecycle.State.parse({ + ...(job.lifecycle ?? ComputeLifecycle.from(job.status)), + execution: "cancelled", + resource: "closed", + error_kind: undefined, + system_hint: undefined, + }) + const reconciled = Job.parse({ + ...job, + status: "cancelled", + lifecycle, + completed_at: new Date().toISOString(), + exit_code: null, + pid: undefined, + process_identity: undefined, + error: undefined, + }) + jobs[index] = Job.parse({ ...reconciled, provenance: provenance(reconciled) }) + return { job: jobs[index]!, changed: true, cleanup: false } + } + const cleanup = job.target.kind === "modal" && job.lifecycle?.resource === "unknown" && !!context + return { job, changed: false, cleanup } + } + if (jobs[index]!.target.kind === "modal" && !context) { + throw new Error("Enable Modal before cancelling this recovered job") + } + const draft = move( + jobs[index]!, + { type: "cancel" }, + { completed_at: new Date().toISOString(), exit_code: null, - pid: undefined, - process_identity: undefined, - error: undefined, - }) - jobs[index] = Job.parse({ ...reconciled, provenance: provenance(reconciled) }) - return { job: jobs[index]!, changed: true, cleanup: false } - } - const cleanup = job.target.kind === "modal" && job.lifecycle?.resource === "unknown" && !!context - return { job, changed: false, cleanup } + }, + ) + const cancelled = Job.parse({ ...draft, provenance: provenance(draft) }) + jobs[index] = cancelled + return { job: cancelled, changed: true, cleanup: false } + }) + const job = result.job + if (!result.changed && !result.cleanup) return job + if (job.target.kind === "modal") { + await event(scope.root, job.id, result.cleanup ? "Retrying Modal cleanup" : "Cancellation requested") } - if (jobs[index]!.target.kind === "modal" && !context) { - throw new Error("Enable Modal before cancelling this recovered job") + const proc = runtime?.process + const modalClosed = + job.target.kind === "modal" + ? context + ? await (options.provider ?? runtime?.provider ?? ModalAdapter) + .release(context, modalSpec(job, [], scope), job.remote_id) + .then( + () => true, + () => false, + ) + : false + : true + if (job.target.kind === "modal" && job.remote_id && modalClosed) { + await event(scope.root, job.id, `Closed Modal sandbox ${job.remote_id}`) } - const draft = move( - jobs[index]!, - { type: "cancel" }, - { - completed_at: new Date().toISOString(), - exit_code: null, - }, - ) - const cancelled = Job.parse({ ...draft, provenance: provenance(draft) }) - jobs[index] = cancelled - return { job: cancelled, changed: true, cleanup: false } - }) - const job = result.job - if (!result.changed && !result.cleanup) return job - if (job.target.kind === "modal") { - await event(scope.root, job.id, result.cleanup ? "Retrying Modal cleanup" : "Cancellation requested") - } - const proc = runtime?.process - const modalClosed = - job.target.kind === "modal" - ? context - ? await (options.provider ?? runtime?.provider ?? ModalAdapter) - .release(context, modalSpec(job, [], scope), job.remote_id) - .then( - () => true, - () => false, - ) - : false - : true - if (job.target.kind === "modal" && job.remote_id && modalClosed) { - await event(scope.root, job.id, `Closed Modal sandbox ${job.remote_id}`) - } - if (job.target.kind === "modal" && !modalClosed) { - await event(scope.root, job.id, "Modal did not confirm cancellation; the remote resource may still be billing") - } - if (proc) { - await Shell.killTree(proc, { - detached: runtime.detached, - exited: () => proc.exitCode !== null, - }) - } else if (job.pid && (await owns(job.pid, job.process_identity))) { - try { - if (process.platform === "win32") process.kill(job.pid, "SIGTERM") - else process.kill(-job.pid, "SIGTERM") - } catch {} - } else if (job.pid) { - await event( - scope.root, - job.id, - "Skipped process termination because the persisted PID no longer matched this job", - ) - } - const hostId = job.target.kind === "ssh" ? job.target.host_id : undefined - const host = hostId ? options.hosts?.find((item) => item.id === hostId) : undefined - if (host && host.scheduler !== "none") { - const action = - host.scheduler === "slurm" - ? `scancel --name ${quote(`os-${job.id}`)}` - : `qselect -N ${quote(name(`os-${job.id}`))} | xargs -r qdel` - const spec = command( - { id: job.id, name: job.name, command: action, cwd: host.workdir }, - { ...host, scheduler: "none" }, - ) - const planned = job.authority - ? Sandbox.wrapArgv({ - file: spec.argv[0]!, - args: spec.argv.slice(1), - workspace: job.authority.writable, - readable: job.authority.readable, - unreadable: OpenScience.kernelSensitivePaths(), - options: job.authority.sandbox, - }) - : { file: spec.argv[0]!, args: spec.argv.slice(1), temporary: undefined } - let proc: ChildProcess - try { - proc = spawn(planned.file, planned.args, { - cwd: job.authority?.workspace, - env: OpenScience.kernelEnv(process.env), - windowsHide: true, - stdio: "ignore", - }) - await new Promise((resolve) => { - proc.once("error", () => resolve()) - proc.once("exit", () => resolve()) + if (job.target.kind === "modal" && !modalClosed) { + await event(scope.root, job.id, "Modal did not confirm cancellation; the remote resource may still be billing") + } + if (proc) { + await Shell.killTree(proc, { + detached: runtime.detached, + exited: () => proc.exitCode !== null, }) - } finally { - Sandbox.cleanup(planned) + } else if (job.pid && (await owns(job.pid, job.process_identity))) { + try { + if (process.platform === "win32") process.kill(job.pid, "SIGTERM") + else process.kill(-job.pid, "SIGTERM") + } catch {} + } else if (job.pid) { + await event( + scope.root, + job.id, + "Skipped process termination because the persisted PID no longer matched this job", + ) + } + const hostId = job.target.kind === "ssh" ? job.target.host_id : undefined + const host = hostId ? options.hosts?.find((item) => item.id === hostId) : undefined + if (host && host.scheduler !== "none") { + const action = + host.scheduler === "slurm" + ? `scancel --name ${quote(`os-${job.id}`)}` + : `qselect -N ${quote(name(`os-${job.id}`))} | xargs -r qdel` + const spec = command( + { id: job.id, name: job.name, command: action, cwd: host.workdir }, + { ...host, scheduler: "none" }, + ) + const planned = job.authority + ? Sandbox.wrapArgv({ + file: spec.argv[0]!, + args: spec.argv.slice(1), + workspace: job.authority.writable, + readable: job.authority.readable, + unreadable: OpenScience.kernelSensitivePaths(), + options: job.authority.sandbox, + }) + : { file: spec.argv[0]!, args: spec.argv.slice(1), temporary: undefined } + let proc: ChildProcess + try { + proc = spawn(planned.file, planned.args, { + cwd: job.authority?.workspace, + env: OpenScience.kernelEnv(process.env), + windowsHide: true, + stdio: "ignore", + }) + await new Promise((resolve) => { + proc.once("error", () => resolve()) + proc.once("exit", () => resolve()) + }) + } finally { + Sandbox.cleanup(planned) + } } + return await change(scope.root, (jobs) => { + const index = jobs.findIndex((item) => item.id === id) + if (index < 0) throw new Error(`Compute job ${id} was not found`) + const current = jobs[index]! + const abandoned = modalClosed && current.lifecycle?.recoverable ? move(current, { type: "abandon" }) : current + const closed = modalClosed ? move(abandoned, { type: "close" }) : move(abandoned, { type: "lose" }) + const warning = + job.target.kind === "modal" && !modalClosed + ? "Cancellation was recorded, but Modal did not confirm that the sandbox and durable volume stopped. It may still be billing; retry cancellation or check Modal." + : undefined + const legacy = + !current.cleanup_error && + (current.error?.startsWith("Cancellation was recorded") || current.error?.startsWith("Modal cleanup failed")) + const updated = Job.parse({ + ...closed, + error: legacy ? undefined : current.error, + cleanup_error: warning, + provenance: provenance(closed), + }) + jobs[index] = updated + return jobs[index]! + }).finally(() => (runtime ? deactivate(keyOf(scope.root, id)) : undefined)) } - return await change(scope.root, (jobs) => { - const index = jobs.findIndex((item) => item.id === id) - if (index < 0) throw new Error(`Compute job ${id} was not found`) - const current = jobs[index]! - const abandoned = modalClosed && current.lifecycle?.recoverable ? move(current, { type: "abandon" }) : current - const closed = modalClosed ? move(abandoned, { type: "close" }) : move(abandoned, { type: "lose" }) - const warning = - job.target.kind === "modal" && !modalClosed - ? "Cancellation was recorded, but Modal did not confirm that the sandbox and durable volume stopped. It may still be billing; retry cancellation or check Modal." - : undefined - const legacy = - !current.cleanup_error && - (current.error?.startsWith("Cancellation was recorded") || current.error?.startsWith("Modal cleanup failed")) - const updated = Job.parse({ - ...closed, - error: legacy ? undefined : current.error, - cleanup_error: warning, - provenance: provenance(closed), - }) - jobs[index] = updated - return jobs[index]! - }).finally(() => (runtime ? deactivate(keyOf(scope.root, id)) : undefined)) + return await (operation ? operation.during(action) : action()) } async function cancelActive(match: (runtime: Runtime) => boolean, failClosed = false): Promise { diff --git a/backend/cli/src/compute/modal/adapter.ts b/backend/cli/src/compute/modal/adapter.ts index 0dfec21b..554dbe75 100644 --- a/backend/cli/src/compute/modal/adapter.ts +++ b/backend/cli/src/compute/modal/adapter.ts @@ -167,7 +167,18 @@ export namespace ModalAdapter { const paths = [ ...new Set([...(complete ? [codePath] : []), logPath, ...selected.map((entry) => clean(entry.path))]), ] - const downloaded = await ModalVolume.download(context, spec.volume, paths, spec.staging) + const sizes = new Map( + entries.filter((entry) => entry.type === "file").map((entry) => [clean(entry.path), entry.size]), + ) + const declared = paths.map((entry) => sizes.get(entry)) + let declaredBytes: number | undefined + if (declared.every((entry) => entry !== undefined)) { + declaredBytes = 0 + for (const entry of declared) { + declaredBytes = Math.min(Number.MAX_SAFE_INTEGER, declaredBytes + entry!) + } + } + const downloaded = await ModalVolume.download(context, spec.volume, paths, spec.staging, { declaredBytes }) const files = new Map(downloaded.map((entry) => [entry.path, entry])) const saved = files.get(codePath) const logged = files.get(logPath) diff --git a/backend/cli/src/compute/modal/volume.py b/backend/cli/src/compute/modal/volume.py index d4ef463d..f4a91612 100644 --- a/backend/cli/src/compute/modal/volume.py +++ b/backend/cli/src/compute/modal/volume.py @@ -6,10 +6,12 @@ No Modal Function or Sandbox is created by this driver. """ -import json +import errno import hashlib +import json import os import posixpath +import shutil import sys import time @@ -19,6 +21,46 @@ def fail(message): raise SystemExit(2) +class CapacityError(Exception): + def __init__(self, safe_capacity, response_bytes=None, storage_code=None): + self.safe_capacity = safe_capacity + self.response_bytes = response_bytes + self.storage_code = storage_code + + +def fail_capacity(error): + payload = {"safe_capacity_bytes": error.safe_capacity} + if error.response_bytes is not None: + payload["response_bytes"] = error.response_bytes + if error.storage_code is not None: + payload["storage_code"] = error.storage_code + print("modal volume bridge capacity: %s" % json.dumps(payload, separators=(",", ":")), file=sys.stderr) + raise SystemExit(2) + + +def capacity_value(spec, name): + value = spec.get(name) + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + fail("%s must be a non-negative integer" % name) + return value + + +def live_capacity(staging, initial_capacity, reserve, written): + stats = os.statvfs(staging) + available = stats.f_bavail * stats.f_frsize + live = max(0, available - reserve) + remaining = max(0, initial_capacity - written) + return written + min(remaining, live) + + +def storage_code(error): + if error.errno == errno.ENOSPC: + return "ENOSPC" + if hasattr(errno, "EDQUOT") and error.errno == errno.EDQUOT: + return "EDQUOT" + return None + + def relative(value): if not isinstance(value, str) or "\x00" in value: fail("invalid volume path") @@ -135,21 +177,54 @@ def main(): fail("staging must be an absolute path") if not isinstance(paths, list) or not all(isinstance(item, str) for item in paths): fail("paths must be a list of strings") - os.makedirs(staging, mode=0o700, exist_ok=True) - rows = [] - for item in paths: - remote = relative(item) - local = destination(staging, remote) - os.makedirs(os.path.dirname(local), mode=0o700, exist_ok=True) - size = 0 - digest = hashlib.sha256() - with open(local, "wb") as output: - for chunk in target.read_file(remote): - output.write(chunk) - size += len(chunk) - digest.update(chunk) - rows.append({"path": remote, "staging": local, "size": size, "sha256": digest.hexdigest()}) - print(json.dumps(rows)) + capacity = capacity_value(spec, "capacity_bytes") + reserve = capacity_value(spec, "reserve_bytes") + written = 0 + try: + os.makedirs(staging, mode=0o700, exist_ok=True) + rows = [] + for item in paths: + remote = relative(item) + local = destination(staging, remote) + os.makedirs(os.path.dirname(local), mode=0o700, exist_ok=True) + size = 0 + digest = hashlib.sha256() + with open(local, "wb", buffering=0) as output: + for chunk in target.read_file(remote): + view = memoryview(chunk) + while view: + safe = live_capacity(staging, capacity, reserve, written) + if written + len(view) > safe: + raise CapacityError(safe, written + len(view)) + try: + count = output.write(view) + except OSError as error: + code = storage_code(error) + if code is None: + raise + safe = live_capacity(staging, capacity, reserve, written) + raise CapacityError(safe, written + len(view), code) from error + if not isinstance(count, int) or count <= 0: + raise OSError("Modal Volume staging write made no progress") + digest.update(view[:count]) + size += count + written += count + view = view[count:] + rows.append({"path": remote, "staging": local, "size": size, "sha256": digest.hexdigest()}) + print(json.dumps(rows)) + except CapacityError as error: + shutil.rmtree(staging, ignore_errors=True) + fail_capacity(error) + except OSError as error: + code = storage_code(error) + if code is None: + raise + try: + safe = live_capacity(staging, capacity, reserve, written) + except OSError: + safe = written + shutil.rmtree(staging, ignore_errors=True) + fail_capacity(CapacityError(safe, written, code)) if __name__ == "__main__": diff --git a/backend/cli/src/compute/modal/volume.ts b/backend/cli/src/compute/modal/volume.ts index 2adec014..f6e62a74 100644 --- a/backend/cli/src/compute/modal/volume.ts +++ b/backend/cli/src/compute/modal/volume.ts @@ -13,6 +13,7 @@ import { Shell } from "../../shell/shell" export namespace ModalVolume { export const VERSION = "1.1.4" + export const DOWNLOAD_DISK_RESERVE_BYTES = 512 * 1024 * 1024 // preserve 512 MiB for the host export type Context = { tokenId: string @@ -42,6 +43,32 @@ export namespace ModalVolume { sha256: string } + export type DownloadOptions = { + signal?: AbortSignal + /** Aggregate size reported by the already-completed provider listing. */ + declaredBytes?: number + } + + export class DownloadCapacityError extends Error { + constructor( + readonly safeCapacityBytes: number, + readonly responseBytes?: number, + readonly storageCode?: "ENOSPC" | "EDQUOT", + ) { + const observed = responseBytes === undefined ? "" : `; response size ${responseBytes} bytes` + const heading = storageCode + ? `Modal Volume download could not continue because staging storage returned ${storageCode}. ` + + `The current disk-derived staging capacity is ${safeCapacityBytes} bytes${observed}. ` + : `Modal Volume download exceeds the current safe staging capacity of ${safeCapacityBytes} bytes${observed}. ` + super( + heading + + `This capacity is computed from live free disk minus the ${DOWNLOAD_DISK_RESERVE_BYTES}-byte (512 MiB) host reserve. ` + + "No partial staging files were kept. Free disk space, select smaller outputs, or use a dedicated approved transfer path before retrying.", + ) + this.name = "ModalVolumeDownloadCapacityError" + } + } + type Request = | { action: "check" } | { action: "volumes"; environment?: string } @@ -56,10 +83,17 @@ export namespace ModalVolume { attempts: number interval_ms: number } - | { action: "download"; volume: string; environment?: string; paths: string[]; staging: string } + | { + action: "download" + volume: string + environment?: string + paths: string[] + staging: string + capacity_bytes: number + reserve_bytes: number + } const LIST_TIMEOUT = 60_000 - const DOWNLOAD_TIMEOUT = 10 * 60_000 const PROBE_TIMEOUT = 15_000 const MAX_STDOUT = 8 * 1024 * 1024 const MAX_STDERR = 1024 * 1024 @@ -70,6 +104,45 @@ export namespace ModalVolume { if (!result || result.split("/").includes("..")) throw new Error(`Modal Volume returned an unsafe path: ${value}`) return result } + const abortReason = (signal: AbortSignal) => + signal.reason ?? new DOMException("Modal Volume request was aborted", "AbortError") + + function storageCapacityCode(error: unknown) { + const code = (error as NodeJS.ErrnoException | undefined)?.code + return code === "ENOSPC" || code === "EDQUOT" ? code : undefined + } + + async function availableDownloadBytes(root: string) { + const disk = await fs.statfs(root) + const available = disk.bavail * disk.bsize + if (!Number.isSafeInteger(available) || available < 0) { + throw new Error("Modal Volume staging capacity could not be represented safely; the download was not started") + } + return Math.max(0, available - DOWNLOAD_DISK_RESERVE_BYTES) + } + + function driverCapacityError(message: string) { + const prefix = "modal volume bridge capacity:" + const line = message + .split(/\r?\n/) + .map((item) => item.trim()) + .findLast((item) => item.startsWith(prefix)) + if (!line) return + try { + const value = JSON.parse(line.slice(prefix.length).trim()) as Record + const safe = value.safe_capacity_bytes + const response = value.response_bytes + const storage = value.storage_code + if (typeof safe !== "number" || !Number.isSafeInteger(safe) || safe < 0) return + if (response !== undefined && (typeof response !== "number" || !Number.isSafeInteger(response) || response < 0)) { + return + } + if (storage !== undefined && storage !== "ENOSPC" && storage !== "EDQUOT") return + return new DownloadCapacityError(safe, response as number | undefined, storage as "ENOSPC" | "EDQUOT" | undefined) + } catch { + return + } + } const cache: { path?: Promise } = {} @@ -98,7 +171,8 @@ export namespace ModalVolume { return cache.path } - export async function command(context: Context) { + export async function command(context: Context, signal?: AbortSignal) { + if (signal?.aborted) throw abortReason(signal) if (context.command) return context.command const file = await driverPath() const python = context.python ?? Bun.which("python3") ?? Bun.which("python") @@ -113,6 +187,8 @@ export namespace ModalVolume { environment({ ...process.env, ...context.env }), PROBE_TIMEOUT, "SDK probe", + undefined, + signal, ) if (probe.code === 0) return [python, "-I", file] } @@ -222,109 +298,147 @@ export namespace ModalVolume { await CredentialProcessLedger.revoke({ id, kind: "modal-volume" }) } - async function execute(argv: string[], env: Record, timeout: number, action: string, stdin?: Buffer) { + async function execute( + argv: string[], + env: Record, + timeout: number | undefined, + action: string, + stdin?: Buffer, + signal?: AbortSignal, + ) { + if (signal?.aborted) throw abortReason(signal) await using operation = await DataRootBarrier.enter(Global.Path.data) - const launched = await CredentialLifecycle.admit(async () => { - const linuxOwner = - process.platform === "linux" - ? await ProcessIdentity.capture(process.pid).then((identity) => - identity ? { pid: process.pid, identity } : undefined, - ) - : undefined - if (process.platform === "linux" && !linuxOwner) { - throw new Error("Could not capture the Linux server identity for Modal Volume launch") - } - const wrapped = WindowsJobLauncher.wrap({ - file: argv[0]!, - args: argv.slice(1), - linuxOwner, - }) - const detached = process.platform !== "win32" - const child = spawn(wrapped.file, wrapped.args, { - env, - detached, - windowsHide: true, - stdio: [stdin ? "pipe" : "ignore", "pipe", "pipe"], - }) - WindowsJobLauncher.bind(child, wrapped.release) - const completion = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { - child.once("error", reject) - child.once("close", (code, signal) => resolve({ code, signal })) - }) - const stdout = output(child.stdout!, MAX_STDOUT, `${action} stdout`) - const stderr = output(child.stderr!, MAX_STDERR, `${action} stderr`) - // Registration can fail before the main result race is installed. Keep - // these promises observed during that window without changing their - // eventual rejected state for the caller. - void completion.catch(() => undefined) - void stdout.catch(() => undefined) - void stderr.catch(() => undefined) - const id = `modal-volume-${crypto.randomUUID()}` - let identity: string | undefined - try { - if (!child.pid) throw new Error("Modal Volume bridge started without a process id") - identity = await CredentialProcessLedger.identity(child.pid) - if (!identity) throw new Error(`Could not establish a safe identity for Modal Volume ${action}`) - const registered = await CredentialProcessLedger.register({ - id, - kind: "modal-volume", - pid: child.pid, + return await operation.during(async () => { + const launched = await CredentialLifecycle.admit(async () => { + if (signal?.aborted) throw abortReason(signal) + const linuxOwner = + process.platform === "linux" + ? await ProcessIdentity.capture(process.pid).then((identity) => + identity ? { pid: process.pid, identity } : undefined, + ) + : undefined + if (process.platform === "linux" && !linuxOwner) { + throw new Error("Could not capture the Linux server identity for Modal Volume launch") + } + const wrapped = WindowsJobLauncher.wrap({ + file: argv[0]!, + args: argv.slice(1), + linuxOwner, + }) + const detached = process.platform !== "win32" + const child = spawn(wrapped.file, wrapped.args, { + env, detached, - identity, - windowsRelease: wrapped.release, + windowsHide: true, + stdio: [stdin ? "pipe" : "ignore", "pipe", "pipe"], }) - if (!registered) throw new Error(`Modal Volume ${action} exited before durable ownership was established`) - if (process.platform === "linux" && wrapped.release) { - await WindowsJobLauncher.release(wrapped.release, child.pid) + WindowsJobLauncher.bind(child, wrapped.release) + const completion = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + child.once("error", reject) + child.once("close", (code, signal) => resolve({ code, signal })) + }) + const stdout = output(child.stdout!, MAX_STDOUT, `${action} stdout`) + const stderr = output(child.stderr!, MAX_STDERR, `${action} stderr`) + // Registration can fail before the main result race is installed. Keep + // these promises observed during that window without changing their + // eventual rejected state for the caller. + void completion.catch(() => undefined) + void stdout.catch(() => undefined) + void stderr.catch(() => undefined) + const id = `modal-volume-${crypto.randomUUID()}` + let identity: string | undefined + try { + if (!child.pid) throw new Error("Modal Volume bridge started without a process id") + identity = await CredentialProcessLedger.identity(child.pid) + if (!identity) throw new Error(`Could not establish a safe identity for Modal Volume ${action}`) + const registered = await CredentialProcessLedger.register({ + id, + kind: "modal-volume", + pid: child.pid, + detached, + identity, + windowsRelease: wrapped.release, + }) + if (!registered) throw new Error(`Modal Volume ${action} exited before durable ownership was established`) + if (process.platform === "linux" && wrapped.release) { + await WindowsJobLauncher.release(wrapped.release, child.pid) + } + if (stdin) child.stdin!.end(stdin) + return { child, completion, stdout, stderr, id, detached, identity, release: wrapped.release } + } catch (error) { + await stop(id, child, detached, identity).catch(() => undefined) + await cleanupGate(wrapped.release) + throw error } - if (stdin) child.stdin!.end(stdin) - return { child, completion, stdout, stderr, id, detached, identity, release: wrapped.release } + }) + + let timer: ReturnType | undefined + const expired = + timeout === undefined + ? undefined + : new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Modal Volume ${action} timed out after ${timeout}ms`)), + timeout, + ) + }) + const result = Promise.all([launched.stdout, launched.stderr, launched.completion] as const) + const interrupted = signal ? Promise.withResolvers() : undefined + const abort = () => interrupted?.reject(abortReason(signal!)) + if (signal?.aborted) abort() + else signal?.addEventListener("abort", abort, { once: true }) + let normal = false + try { + const [stdout, stderr, status] = await Promise.race([ + result, + ...(expired ? [expired] : []), + ...(interrupted ? [interrupted.promise] : []), + ]) + normal = true + return { stdout, stderr, code: status.code, signal: status.signal } } catch (error) { - await stop(id, child, detached, identity).catch(() => undefined) - await cleanupGate(wrapped.release) + result.catch(() => undefined) + await stop(launched.id, launched.child, launched.detached, launched.identity) throw error + } finally { + if (timer) clearTimeout(timer) + signal?.removeEventListener("abort", abort) + if (normal) await complete(launched.id) + await cleanupGate(launched.release) } }) - - let timer: ReturnType | undefined - const expired = new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`Modal Volume ${action} timed out after ${timeout}ms`)), timeout) - }) - const result = Promise.all([launched.stdout, launched.stderr, launched.completion] as const) - let normal = false - try { - const [stdout, stderr, status] = await Promise.race([result, expired]) - normal = true - return { stdout, stderr, code: status.code, signal: status.signal } - } catch (error) { - result.catch(() => undefined) - await stop(launched.id, launched.child, launched.detached, launched.identity) - throw error - } finally { - if (timer) clearTimeout(timer) - if (normal) await complete(launched.id) - await cleanupGate(launched.release) - } } - async function invoke(request: Request, context: Context, timeout: number) { + async function invoke(request: Request, context: Context, timeout?: number, signal?: AbortSignal) { + if (signal?.aborted) throw abortReason(signal) const env = environment({ ...process.env, ...context.env }) env.MODAL_TOKEN_ID = context.tokenId env.MODAL_TOKEN_SECRET = context.tokenSecret - const { stdout, stderr, code, signal } = await execute( - await command(context), + const { + stdout, + stderr, + code, + signal: killed, + } = await execute( + await command(context, signal), env, timeout, request.action, Buffer.from(JSON.stringify(request)), + signal, ) - if (signal) throw new Error(`Modal Volume ${request.action} was killed by ${signal}`) + if (signal?.aborted) throw abortReason(signal) + if (killed) throw new Error(`Modal Volume ${request.action} was killed by ${killed}`) if (code !== 0) { const detail = stderr.byteLength ? stderr : stdout const message = [context.tokenId, context.tokenSecret].reduce( (value, secret) => (secret ? value.replaceAll(secret, "[REDACTED]") : value), text.decode(detail).trim(), ) + if (request.action === "download") { + const capacity = driverCapacityError(message) + if (capacity) throw capacity + } throw new Error(`Modal Volume ${request.action} failed (exit ${code}): ${message}`) } try { @@ -415,46 +529,79 @@ export namespace ModalVolume { volume: string, paths: string[], staging: string, + options: DownloadOptions = {}, ): Promise { + const { signal, declaredBytes } = options + if (signal?.aborted) throw abortReason(signal) const files = paths.map(safe) - await fs.rm(staging, { recursive: true, force: true }) - await fs.mkdir(staging, { recursive: true, mode: 0o700 }) - const result = await invoke( - { action: "download", volume, environment: context.environment, paths: files, staging }, - context, - DOWNLOAD_TIMEOUT, - ) - if (!Array.isArray(result)) throw new Error("Modal Volume download did not return an array") - const root = await fs.realpath(staging) - return Promise.all( - result.map(async (entry) => { - if (!entry || typeof entry !== "object") throw new Error("Modal Volume download returned an invalid entry") - if (!("path" in entry) || typeof entry.path !== "string") { - throw new Error("Modal Volume download returned an entry without a path") - } - if (!("staging" in entry) || typeof entry.staging !== "string") { - throw new Error(`Modal Volume download returned no local path for ${entry.path}`) - } - if ( - !("size" in entry) || - typeof entry.size !== "number" || - !Number.isSafeInteger(entry.size) || - entry.size < 0 - ) { - throw new Error(`Modal Volume download returned an invalid size for ${entry.path}`) - } - if (!("sha256" in entry) || typeof entry.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(entry.sha256)) { - throw new Error(`Modal Volume download returned an invalid checksum for ${entry.path}`) - } - const relative = safe(entry.path) - const expected = path.resolve(root, ...relative.split("/")) - const actual = await fs.realpath(entry.staging).catch(() => undefined) - if (actual !== expected) { - throw new Error(`Modal Volume download escaped its staging directory: ${entry.path}`) - } - return { path: relative, staging: expected, size: entry.size, sha256: entry.sha256 } - }), - ) + if (declaredBytes !== undefined && (!Number.isSafeInteger(declaredBytes) || declaredBytes < 0)) { + throw new Error("Modal Volume download received an invalid declared aggregate size") + } + let capacity = 0 + try { + await fs.rm(staging, { recursive: true, force: true }) + const parent = path.dirname(staging) + await fs.mkdir(parent, { recursive: true, mode: 0o700 }) + capacity = await availableDownloadBytes(parent) + if (capacity === 0 || (declaredBytes !== undefined && declaredBytes > capacity)) { + throw new DownloadCapacityError(capacity, declaredBytes) + } + await fs.mkdir(staging, { recursive: true, mode: 0o700 }) + const result = await invoke( + { + action: "download", + volume, + environment: context.environment, + paths: files, + staging, + capacity_bytes: capacity, + reserve_bytes: DOWNLOAD_DISK_RESERVE_BYTES, + }, + context, + undefined, + signal, + ) + if (signal?.aborted) throw abortReason(signal) + if (!Array.isArray(result)) throw new Error("Modal Volume download did not return an array") + const root = await fs.realpath(staging) + const downloaded = await Promise.all( + result.map(async (entry) => { + if (!entry || typeof entry !== "object") throw new Error("Modal Volume download returned an invalid entry") + if (!("path" in entry) || typeof entry.path !== "string") { + throw new Error("Modal Volume download returned an entry without a path") + } + if (!("staging" in entry) || typeof entry.staging !== "string") { + throw new Error(`Modal Volume download returned no local path for ${entry.path}`) + } + if ( + !("size" in entry) || + typeof entry.size !== "number" || + !Number.isSafeInteger(entry.size) || + entry.size < 0 + ) { + throw new Error(`Modal Volume download returned an invalid size for ${entry.path}`) + } + if (!("sha256" in entry) || typeof entry.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(entry.sha256)) { + throw new Error(`Modal Volume download returned an invalid checksum for ${entry.path}`) + } + const relative = safe(entry.path) + const expected = path.resolve(root, ...relative.split("/")) + const actual = await fs.realpath(entry.staging).catch(() => undefined) + if (actual !== expected) { + throw new Error(`Modal Volume download escaped its staging directory: ${entry.path}`) + } + return { path: relative, staging: expected, size: entry.size, sha256: entry.sha256 } + }), + ) + if (signal?.aborted) throw abortReason(signal) + return downloaded + } catch (error) { + await fs.rm(staging, { recursive: true, force: true }).catch(() => undefined) + if (error instanceof DownloadCapacityError) throw error + const storage = storageCapacityCode(error) + if (storage) throw new DownloadCapacityError(capacity, undefined, storage) + throw error + } } } diff --git a/backend/cli/src/config/config.ts b/backend/cli/src/config/config.ts index cbde5f48..737806bf 100644 --- a/backend/cli/src/config/config.ts +++ b/backend/cli/src/config/config.ts @@ -780,6 +780,12 @@ export namespace Config { .describe( "Behaviour when no sandbox backend exists on this platform: 'error' (default) refuses to run, 'warn' runs unsandboxed with a notice, and 'allow' runs unsandboxed silently.", ), + requireProjectTrust: z + .boolean() + .optional() + .describe( + "Require explicit project trust before any execution, even when a verified OS sandbox is available. Default: false.", + ), }) .meta({ ref: "SandboxConfig", @@ -1784,6 +1790,7 @@ export namespace Config { network: policy.network ?? "deny", allowWrite: policy.allowWrite ?? [], onUnavailable: policy.onUnavailable ?? "error", + requireProjectTrust: policy.requireProjectTrust ?? false, } } diff --git a/backend/cli/src/credentials/lifecycle.ts b/backend/cli/src/credentials/lifecycle.ts index ef733053..baab4415 100644 --- a/backend/cli/src/credentials/lifecycle.ts +++ b/backend/cli/src/credentials/lifecycle.ts @@ -152,15 +152,19 @@ export namespace CredentialLifecycle { /** Serialize credential-adjacent metadata writes without publishing a revision. */ export async function serialized(action: () => T | Promise): Promise { await using lease = await FileLease.acquire(mutationLock) - return await action() + return await lease.during(async () => { + return await action() + }) } /** Hold the cross-process mutation lease from freshness check through child * spawn and durable owner registration, closing the snapshot-to-spawn race. */ export async function admit(action: () => T | Promise): Promise { await using lease = await FileLease.acquire(mutationLock) - await ensureFresh() - return await action() + return await lease.during(async () => { + await ensureFresh() + return await action() + }) } /** @@ -203,24 +207,26 @@ export namespace CredentialLifecycle { let failed = false { await using lease = await FileLease.acquire(mutationLock) - const token = crypto.randomUUID() - const base = { - version: 1 as const, - token, - reason, - pid: process.pid, - } - await publish({ ...base, phase: "updating", updated_at: new Date().toISOString() }) + await lease.during(async () => { + const token = crypto.randomUUID() + const base = { + version: 1 as const, + token, + reason, + pid: process.pid, + } + await publish({ ...base, phase: "updating", updated_at: new Date().toISOString() }) - try { - value = await action() - } catch (error) { - failed = true - failure = error - } + try { + value = await action() + } catch (error) { + failed = true + failure = error + } - ready = { ...base, phase: "ready", updated_at: new Date().toISOString() } - await publish(ready) + ready = { ...base, phase: "ready", updated_at: new Date().toISOString() } + await publish(ready) + }) } if (options.reconcileLocal === false) seen = ready.token diff --git a/backend/cli/src/credentials/process-ledger.ts b/backend/cli/src/credentials/process-ledger.ts index 890355d8..dba3b0cd 100644 --- a/backend/cli/src/credentials/process-ledger.ts +++ b/backend/cli/src/credentials/process-ledger.ts @@ -115,6 +115,11 @@ export namespace CredentialProcessLedger { } } + async function serialized(action: () => Promise): Promise { + await using lease = await FileLease.acquire(lockpath) + return await lease.during(action) + } + function alive(pid: number): boolean { try { process.kill(pid, 0) @@ -465,128 +470,131 @@ export namespace CredentialProcessLedger { } // Close the capture/check window before publishing durable ownership. if (!(await owns(input.pid, processIdentity))) return false - await using lease = await FileLease.acquire(lockpath) - const entries = await read() - const index = entries.findIndex((entry) => entry.id === input.id) - // Replacing an ID without first closing its named Job would leave the old - // tree contained but unreachable from the durable ledger. - if ((process.platform === "win32" || process.platform === "darwin") && index >= 0) { - await teardownGroup(entries[index]!) - } - let darwinResponsibility: string | undefined - const windowsJob = - process.platform === "win32" - ? WindowsJob.assign({ id: input.id, pid: input.pid, expectedIdentity: processIdentity }) - : undefined - const next: Entry = { - version: 1, - id: input.id, - kind: input.kind, - pid: input.pid, - detached: input.detached, - ...(windowsJob ? { windows_job: windowsJob } : {}), - ...(process.platform === "linux" && input.windowsRelease ? { linux_subreaper: true } : {}), - identity: processIdentity, - owner_pid: process.pid, - created_at: new Date().toISOString(), - ...(input.projectID ? { project_id: input.projectID } : {}), - ...(input.sessionID ? { session_id: input.sessionID } : {}), - ...(input.authorityGeneration ? { authority_generation: input.authorityGeneration } : {}), - } - if (index < 0) entries.push(next) - else entries[index] = next - await write(entries).catch((error) => { - if (windowsJob) WindowsJob.terminate(windowsJob) - throw error - }) - if (windowsJob && input.windowsRelease) { - try { - WindowsJob.release(input.windowsRelease, input.pid) - } catch (error) { - await teardownGroup(next) - await write(entries.filter((entry) => entry.id !== input.id)) + return serialized(async () => { + const entries = await read() + const index = entries.findIndex((entry) => entry.id === input.id) + // Replacing an ID without first closing its named Job would leave the old + // tree contained but unreachable from the durable ledger. + if ((process.platform === "win32" || process.platform === "darwin") && index >= 0) { + await teardownGroup(entries[index]!) + } + let darwinResponsibility: string | undefined + const windowsJob = + process.platform === "win32" + ? WindowsJob.assign({ id: input.id, pid: input.pid, expectedIdentity: processIdentity }) + : undefined + const next: Entry = { + version: 1, + id: input.id, + kind: input.kind, + pid: input.pid, + detached: input.detached, + ...(windowsJob ? { windows_job: windowsJob } : {}), + ...(process.platform === "linux" && input.windowsRelease ? { linux_subreaper: true } : {}), + identity: processIdentity, + owner_pid: process.pid, + created_at: new Date().toISOString(), + ...(input.projectID ? { project_id: input.projectID } : {}), + ...(input.sessionID ? { session_id: input.sessionID } : {}), + ...(input.authorityGeneration ? { authority_generation: input.authorityGeneration } : {}), + } + if (index < 0) entries.push(next) + else entries[index] = next + await write(entries).catch((error) => { + if (windowsJob) WindowsJob.terminate(windowsJob) throw error + }) + if (windowsJob && input.windowsRelease) { + try { + WindowsJob.release(input.windowsRelease, input.pid) + } catch (error) { + await teardownGroup(next) + await write(entries.filter((entry) => entry.id !== input.id)) + throw error + } } - } - if (process.platform === "darwin" && input.windowsRelease) { - try { - await fs.writeFile(input.windowsRelease, String(input.pid), { encoding: "utf8", flag: "wx", mode: 0o600 }) - for (let attempt = 0; attempt < 3_000; attempt++) { - if (!(await owns(input.pid, processIdentity))) break - if (DarwinResponsibility.responsible(input.pid) === input.pid) { - darwinResponsibility = DarwinResponsibility.unique(input.pid) - if (darwinResponsibility) break - } - if (attempt === 2_999) { - throw new Error( - `Credential-bearing ${input.kind} child ${input.pid} did not become a macOS responsibility root`, - ) + if (process.platform === "darwin" && input.windowsRelease) { + try { + await fs.writeFile(input.windowsRelease, String(input.pid), { encoding: "utf8", flag: "wx", mode: 0o600 }) + for (let attempt = 0; attempt < 3_000; attempt++) { + if (!(await owns(input.pid, processIdentity))) break + if (DarwinResponsibility.responsible(input.pid) === input.pid) { + darwinResponsibility = DarwinResponsibility.unique(input.pid) + if (darwinResponsibility) break + } + if (attempt === 2_999) { + throw new Error( + `Credential-bearing ${input.kind} child ${input.pid} did not become a macOS responsibility root`, + ) + } + await Bun.sleep(10) } - await Bun.sleep(10) + } catch (error) { + await teardownGroup(next) + await write(entries.filter((entry) => entry.id !== input.id)) + throw error } - } catch (error) { + } + if (darwinResponsibility) { + next.darwin_responsibility_uniqueid = darwinResponsibility + const position = entries.findIndex((entry) => entry.id === input.id) + if (position >= 0) entries[position] = next + await write(entries) + try { + await fs.writeFile(`${input.windowsRelease}${DARWIN_RESPONSIBILITY_ACTIVATION_SUFFIX}`, String(input.pid), { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }) + } catch (error) { + await teardownGroup(next) + await write(entries.filter((entry) => entry.id !== input.id)) + throw error + } + } + if (darwinResponsibility && !DarwinResponsibility.uniquelyOwns(darwinResponsibility, input.pid)) { await teardownGroup(next) await write(entries.filter((entry) => entry.id !== input.id)) - throw error + throw new Error(`Credential-bearing ${input.kind} child ${input.pid} failed macOS responsibility handoff`) } - } - if (darwinResponsibility) { - next.darwin_responsibility_uniqueid = darwinResponsibility - const position = entries.findIndex((entry) => entry.id === input.id) - if (position >= 0) entries[position] = next - await write(entries) - try { - await fs.writeFile(`${input.windowsRelease}${DARWIN_RESPONSIBILITY_ACTIVATION_SUFFIX}`, String(input.pid), { - encoding: "utf8", - flag: "wx", - mode: 0o600, - }) - } catch (error) { - await teardownGroup(next) + // Persist first, then close the final observation window. If the leader + // exited during publication, durable ownership already exists and can + // reap every surviving original-group member before reporting a failed + // spawn. A teardown failure deliberately leaves the entry on disk. + if ( + !(await owns(input.pid, processIdentity)) || + (windowsJob && !WindowsJob.contains(windowsJob, input.pid)) || + (darwinResponsibility && !DarwinResponsibility.uniquelyOwns(darwinResponsibility, input.pid)) + ) { + if (next.detached || windowsJob) await teardownGroup(next) await write(entries.filter((entry) => entry.id !== input.id)) - throw error + return false } - } - if (darwinResponsibility && !DarwinResponsibility.uniquelyOwns(darwinResponsibility, input.pid)) { - await teardownGroup(next) - await write(entries.filter((entry) => entry.id !== input.id)) - throw new Error(`Credential-bearing ${input.kind} child ${input.pid} failed macOS responsibility handoff`) - } - // Persist first, then close the final observation window. If the leader - // exited during publication, durable ownership already exists and can - // reap every surviving original-group member before reporting a failed - // spawn. A teardown failure deliberately leaves the entry on disk. - if ( - !(await owns(input.pid, processIdentity)) || - (windowsJob && !WindowsJob.contains(windowsJob, input.pid)) || - (darwinResponsibility && !DarwinResponsibility.uniquelyOwns(darwinResponsibility, input.pid)) - ) { - if (next.detached || windowsJob) await teardownGroup(next) - await write(entries.filter((entry) => entry.id !== input.id)) - return false - } - return true + return true + }) } export async function remove(id: string): Promise { - await using lease = await FileLease.acquire(lockpath) - const entries = await read() - const remaining = entries.filter((entry) => entry.id !== id) - if (remaining.length !== entries.length) await write(remaining) + return serialized(async () => { + const entries = await read() + const remaining = entries.filter((entry) => entry.id !== id) + if (remaining.length !== entries.length) await write(remaining) + }) } /** Remove a normal-completion entry only after its exact process and every * same-group descendant are gone. Background work is reaped before durable * credential ownership can be dropped. */ export async function complete(id: string): Promise { - await using lease = await FileLease.acquire(lockpath) - const entries = await read() - const entry = entries.find((item) => item.id === id) - if (!entry) return true - if (await owns(entry.pid, entry.identity)) return false - if (entry.detached || entry.windows_job) await teardownGroup(entry) - await write(entries.filter((item) => item.id !== id)) - return true + return serialized(async () => { + const entries = await read() + const entry = entries.find((item) => item.id === id) + if (!entry) return true + if (await owns(entry.pid, entry.identity)) return false + if (entry.detached || entry.windows_job) await teardownGroup(entry) + await write(entries.filter((item) => item.id !== id)) + return true + }) } async function killExactProcess(entry: Entry): Promise { @@ -637,33 +645,34 @@ export namespace CredentialProcessLedger { /** Kill exact, identity-matched children even when their owner server died. */ export async function revoke(scope?: Kind | Scope, options: RevokeOptions = {}): Promise { - await using lease = await FileLease.acquire(lockpath) - const entries = await read() - const retained: Entry[] = [] - let killed = 0 - const failures: unknown[] = [] - for (const entry of entries) { - const match = - typeof scope === "string" - ? entry.kind === scope - : (!scope?.id || entry.id === scope.id) && - (!scope?.kind || entry.kind === scope.kind) && - (!scope?.projectID || !entry.project_id || entry.project_id === scope.projectID) && - (!scope?.sessionID || !entry.session_id || entry.session_id === scope.sessionID) - if (!match) { - retained.push(entry) - continue - } - try { - if (await teardown(entry, options)) killed++ - } catch (error) { - retained.push(entry) - failures.push(error) + return serialized(async () => { + const entries = await read() + const retained: Entry[] = [] + let killed = 0 + const failures: unknown[] = [] + for (const entry of entries) { + const match = + typeof scope === "string" + ? entry.kind === scope + : (!scope?.id || entry.id === scope.id) && + (!scope?.kind || entry.kind === scope.kind) && + (!scope?.projectID || !entry.project_id || entry.project_id === scope.projectID) && + (!scope?.sessionID || !entry.session_id || entry.session_id === scope.sessionID) + if (!match) { + retained.push(entry) + continue + } + try { + if (await teardown(entry, options)) killed++ + } catch (error) { + retained.push(entry) + failures.push(error) + } } - } - await write(retained) - if (failures.length) throw new AggregateError(failures, "Credential-bearing child revocation failed") - return killed + await write(retained) + if (failures.length) throw new AggregateError(failures, "Credential-bearing child revocation failed") + return killed + }) } export function pathForTests(): string { diff --git a/backend/cli/src/global/data-relocation.ts b/backend/cli/src/global/data-relocation.ts index 0c868676..9cb54615 100644 --- a/backend/cli/src/global/data-relocation.ts +++ b/backend/cli/src/global/data-relocation.ts @@ -4,6 +4,7 @@ import { createReadStream } from "node:fs" import fs from "node:fs/promises" import path from "node:path" import { Global } from "@/global" +import { AuthorityProcessLedger } from "@/project/authority-process" import { DataRoot } from "./data-root" import { DataRootBarrier } from "./data-root-barrier" @@ -237,6 +238,12 @@ export namespace DataRelocation { throw new Error("Storage relocation is disabled when OPENSCIENCE_DATA_DIR explicitly owns the data root") } await using barrier = await DataRootBarrier.exclusive(120_000) + // The exclusive barrier drains and blocks every ledger FileLease writer, + // including an older server that can still publish pre-containment kernel + // records. Read the now-stable ledger without entering a nested barrier; + // a retained legacy entry quarantines relocation even after its recorded + // leader exits and its child-owned operation marker becomes stale. + await AuthorityProcessLedger.assertRelocationSafe() // Resolve the physical source only after this process owns the global // relocation transaction. A queued second server must snapshot the root // selected by the first switch, never the stale root it observed before diff --git a/backend/cli/src/global/data-root-barrier.ts b/backend/cli/src/global/data-root-barrier.ts index f701bfc7..fe9bedf5 100644 --- a/backend/cli/src/global/data-root-barrier.ts +++ b/backend/cli/src/global/data-root-barrier.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises" import path from "node:path" import { randomUUID } from "node:crypto" +import { AsyncLocalStorage } from "node:async_hooks" import { ProcessIdentity } from "../process/process-identity" /** @@ -19,6 +20,11 @@ export namespace DataRootBarrier { export interface Operation extends AsyncDisposable { reassign(owner: Owner): Promise + during(action: () => Promise): Promise + } + + interface PhysicalOperation extends AsyncDisposable { + reassign(owner: Owner): Promise } interface Record { @@ -28,8 +34,25 @@ export namespace DataRootBarrier { } type Configuration = { root: string; config: string } + type ScopeState = "opening" | "active" | "closing" | "detached" | "closed" + interface Anchor { + configuration: Configuration + state: ScopeState + accepting: boolean + admissions: number + drained?: () => void + uses: number + unused?: () => void + } + interface ScopeFrame { + anchor: Anchor + parent?: ScopeFrame + active: boolean + } + let configuration: Configuration | undefined let self: Promise | undefined + const scopes = new AsyncLocalStorage() const pause = 20 const wait = 30_000 @@ -51,6 +74,88 @@ export namespace DataRootBarrier { return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) } + function configured(filepath: string) { + const current = configuration + if (!current || !relevant(path.resolve(filepath), path.resolve(current.root))) return + return current + } + + function noOperation(): Operation { + return { + async reassign() {}, + async during(action: () => Promise) { + return await action() + }, + async [Symbol.asyncDispose]() {}, + } + } + + function scopedAnchor(current: Configuration, start = scopes.getStore()) { + for (let frame = start; frame; frame = frame.parent) { + if (!frame.active) continue + const anchor = frame.anchor + if ((anchor.state === "active" || anchor.state === "closing") && anchor.configuration === current) return anchor + } + } + + function admissionAnchor(current: Configuration, start = scopes.getStore()) { + for (let frame = start; frame; frame = frame.parent) { + if (!frame.active) continue + const anchor = frame.anchor + // `accepting` gates new Operation.during calls. A frame that was already + // admitted must retain child publication until its callback settles; + // otherwise a close/reassign racing relocation can strand that callback + // behind intent while relocation waits for the callback's marker. + if (anchor.state === "active" && anchor.configuration === current) return anchor + } + } + + function admit(anchor: Anchor) { + anchor.admissions++ + let released = false + return () => { + if (released) return + released = true + anchor.admissions-- + if (!anchor.admissions) anchor.drained?.() + } + } + + async function drain(anchor: Anchor) { + if (!anchor.admissions) return + await new Promise((resolve) => (anchor.drained = resolve)) + } + + function inside(anchor: Anchor) { + for (let frame = scopes.getStore(); frame; frame = frame.parent) { + if (frame.active && frame.anchor === anchor) return true + } + return false + } + + function beginTransition(anchor: Anchor) { + anchor.accepting = false + if (!anchor.uses && anchor.state === "active") anchor.state = "closing" + } + + function use(anchor: Anchor) { + anchor.uses++ + let released = false + return () => { + if (released) return + released = true + anchor.uses-- + if (anchor.uses) return + if (!anchor.accepting && anchor.state === "active") anchor.state = "closing" + anchor.unused?.() + } + } + + async function finishUses(anchor: Anchor) { + if (!anchor.uses) return + await new Promise((resolve) => (anchor.unused = resolve)) + } + function running(pid: number) { try { process.kill(pid, 0) @@ -109,16 +214,13 @@ export namespace DataRootBarrier { } } - /** Mark one durable operation. Paths outside the managed root are no-ops. */ - export async function enter(filepath: string, timeoutMs = wait, requestedOwner?: Owner): Promise { - const current = configuration - if (!current || !relevant(path.resolve(filepath), path.resolve(current.root))) { - return { - async reassign() {}, - async [Symbol.asyncDispose]() {}, - } - } - + async function createOperation( + current: Configuration, + filepath: string, + timeoutMs: number, + requestedOwner?: Owner, + admittedBy?: Anchor, + ): Promise { const { intent, operations } = paths(current.config) const deadline = Date.now() + timeoutMs const operationOwner = await exactOwner(requestedOwner) @@ -126,23 +228,45 @@ export namespace DataRootBarrier { const token = randomUUID() const marker = path.join(operations, `${operationOwner.pid}.${token}.json`) for (;;) { - await waitForIntent(intent, deadline) - const handle = await fs.open(marker, "wx", 0o600) + // An active ancestor marker was admitted before the relocation intent. + // While that marker is retained, a descendant may publish its own marker + // without waiting on the intent; there is no coverage gap for the switch. + if (!admittedBy) await waitForIntent(intent, deadline) + // Build the complete record outside the scanned operations directory. + // Publishing the final name atomically prevents exclusive() from seeing + // an empty/partial record, classifying it as dead, and unlinking it while + // this process continues writing through an open handle. + const temporary = path.join( + current.config, + `.data-root-operation-${operationOwner.pid}.${token}.${randomUUID()}.pending`, + ) + const handle = await fs.open(temporary, "wx", 0o600) try { await handle.writeFile(JSON.stringify({ ...operationOwner, token, created: Date.now() })) await handle.sync() + await handle.close() + await fs.rename(temporary, marker) } catch (error) { await handle.close().catch(() => undefined) - await fs.rm(marker, { force: true }).catch(() => undefined) + await fs.rm(temporary, { force: true }).catch(() => undefined) throw error } - if (!(await fs.lstat(intent).catch(() => undefined))) { - let pending = Promise.resolve() + if (admittedBy || !(await fs.lstat(intent).catch(() => undefined))) { + let serial = Promise.resolve() let disposed = false + let disposal: Promise | undefined + const enqueue = (action: () => Promise) => { + const result = serial.then(action) + serial = result.then( + () => undefined, + () => undefined, + ) + return result + } return { reassign(value: Owner) { - pending = pending.then(async () => { - if (disposed) throw new Error("Cannot reassign a closed data-root operation") + if (disposed) return Promise.reject(new Error("Cannot reassign a closed data-root operation")) + return enqueue(async () => { const nextOwner = await exactOwner(value) const temporary = path.join(current.config, `.data-root-operation-${token}.${randomUUID()}.next`) const replacement = await fs.open(temporary, "wx", 0o600) @@ -157,28 +281,148 @@ export namespace DataRootBarrier { throw error } }) - return pending }, - async [Symbol.asyncDispose]() { - await pending + [Symbol.asyncDispose]() { + if (disposal) return disposal disposed = true - await handle.close().catch(() => undefined) - const record = await owner(marker) - if (record?.token === token) await fs.rm(marker, { force: true }).catch(() => undefined) + disposal = enqueue(async () => { + const record = await owner(marker) + if (record?.token === token) await fs.rm(marker, { force: true }).catch(() => undefined) + }) + return disposal }, } } - await handle.close().catch(() => undefined) - await fs.rm(marker, { force: true }).catch(() => undefined) + // A non-admitted entrant still rechecks intent after atomic publication. + // Remove its complete marker and retry only after relocation finishes. + await fs.rm(marker, { force: true }) + } + } + + function scopedOperation(anchor: Anchor, operation: PhysicalOperation): Operation { + let disposed = false + let disposal: Promise | undefined + let reassignments = 0 + let serial = Promise.resolve() + const enqueue = (action: () => Promise) => { + const result = serial.then(action) + serial = result.then( + () => undefined, + () => undefined, + ) + return result + } + return { + reassign(owner: Owner) { + if (disposed) return Promise.reject(new Error("Cannot reassign a closed data-root operation")) + if (inside(anchor)) { + return Promise.reject(new Error("Cannot reassign a data-root operation from inside its structured scope")) + } + reassignments++ + // Reject new structured uses immediately. Existing callbacks retain + // admission until they settle, so they cannot strand this marker + // behind an intent while the transition waits for them. + beginTransition(anchor) + return enqueue(async () => { + try { + await finishUses(anchor) + await drain(anchor) + await operation.reassign(owner) + // A foreign owner can exit while this process remains alive, so + // this marker can no longer admit same-process descendants. + anchor.state = "detached" + } finally { + reassignments-- + // Rename is the final fallible step, so a failed reassignment left + // the original self-owned marker intact. Re-open admission only + // when no later transition or disposal is waiting behind it. + if (!disposed && !reassignments && anchor.state === "closing") { + anchor.state = "active" + anchor.accepting = true + } + } + }) + }, + during(action: () => Promise) { + if (disposed || !anchor.accepting || anchor.state !== "active") { + return Promise.reject(new Error("Cannot scope work under a non-active data-root operation")) + } + // Use a fresh invocation-time parent. A creation-time parent may be a + // closed request, and installing it here would resurrect stale scope. + const frame: ScopeFrame = { anchor, parent: scopes.getStore(), active: true } + const release = use(anchor) + return scopes.run(frame, async () => { + try { + return await action() + } finally { + frame.active = false + release() + } + }) + }, + [Symbol.asyncDispose]() { + if (disposal) return disposal + if (inside(anchor)) { + return Promise.reject(new Error("Cannot dispose a data-root operation from inside its structured scope")) + } + disposed = true + beginTransition(anchor) + disposal = enqueue(async () => { + await finishUses(anchor) + await drain(anchor) + try { + await operation[Symbol.asyncDispose]() + } finally { + anchor.state = "closed" + } + }) + return disposal + }, + } + } + + /** Mark one durable operation. Paths outside the managed root are no-ops. + * Each relevant call owns a physical marker. A live async-local ancestor may + * admit the child past a newly-published relocation intent, but retains its + * own marker until the child's marker has been fully published. */ + export async function enter(filepath: string, timeoutMs = wait, requestedOwner?: Owner): Promise { + const current = configured(filepath) + if (!current) return noOperation() + + const ancestor = admissionAnchor(current) + // Admission is synchronous: a closing ancestor can never miss a child that + // has decided to rely on its marker while publishing under an intent. + const release = ancestor ? admit(ancestor) : undefined + const anchor: Anchor = { + configuration: current, + state: "opening", + accepting: false, + admissions: 0, + uses: 0, + } + try { + const operation = await createOperation(current, filepath, timeoutMs, requestedOwner, ancestor) + // Explicitly-owned operations are transferable child markers, not + // trustworthy same-process admission anchors. + anchor.state = requestedOwner ? "detached" : "active" + anchor.accepting = !requestedOwner + return scopedOperation(anchor, operation) + } catch (error) { + anchor.state = "closed" + throw error + } finally { + release?.() } } /** Keep a marker alive until the asynchronous operation has actually * settled. Returning an un-awaited Promise from an `await using` scope * releases the marker too early, so request/CLI boundaries use this helper. */ - export async function during(filepath: string, action: () => Promise, timeoutMs = wait): Promise { - await using operation = await enter(filepath, timeoutMs) - return await action() + export function during(filepath: string, action: () => Promise, timeoutMs = wait): Promise { + return (async () => { + await using operation = await enter(filepath, timeoutMs) + return await operation.during(action) + })() } async function acquire(filepath: string, timeoutMs: number) { @@ -218,6 +462,9 @@ export namespace DataRootBarrier { export async function exclusive(timeoutMs = wait): Promise { const current = configuration if (!current) throw new Error("The data-root barrier has not been configured") + if (scopedAnchor(current)) { + throw new Error("Cannot relocate the data root from inside an active data-root operation") + } const state = paths(current.config) await fs.mkdir(state.operations, { recursive: true }) const lock = await acquire(state.lock, timeoutMs) diff --git a/backend/cli/src/index.ts b/backend/cli/src/index.ts index 190951ca..462e4c68 100644 --- a/backend/cli/src/index.ts +++ b/backend/cli/src/index.ts @@ -46,8 +46,8 @@ import { DARWIN_RESPONSIBILITY_LAUNCHER_ARG, DarwinResponsibilityLauncher, } from "./process/darwin-responsibility-launcher" -import { DataRootBarrier } from "./global/data-root-barrier" import { Global } from "./global" +import { disposeDataRootOperation, runDataRootMiddleware } from "./cli/cmd/cmd" if (process.argv[2] === WINDOWS_JOB_LAUNCHER_ARG) { try { @@ -88,8 +88,6 @@ process.on("uncaughtException", (e) => { }) }) -const cliDataRootOperation = { current: undefined as AsyncDisposable | undefined } - const cli = yargs(hideBin(process.argv)) .parserConfiguration({ "populate--": true }) .scriptName("openscience") @@ -108,50 +106,48 @@ const cli = yargs(hideBin(process.argv)) choices: ["DEBUG", "INFO", "WARN", "ERROR"], }) .middleware(async (opts) => { - const command = typeof opts._[0] === "string" ? opts._[0] : "web" - if (command !== "web" && command !== "serve" && !cliDataRootOperation.current) { - // Non-server CLI commands can mutate the same local stores as a running - // workspace. Hold one cross-process operation marker for the entire - // command so a live relocation either precedes it or waits for it. - cliDataRootOperation.current = await DataRootBarrier.enter(Global.Path.data, 120_000) + const initialize = async () => { + await Log.init({ + print: process.argv.includes("--print-logs"), + dev: Installation.isLocal(), + level: (() => { + if (opts.logLevel) return opts.logLevel as Log.Level + if (Installation.isLocal()) return "DEBUG" + return "INFO" + })(), + }) + OpenScience.reportApiBaseOverride() + + process.env.AGENT = "1" + process.env.OPENSCIENCE = "1" + + Log.Default.info("openscience", { + version: Installation.VERSION, + args: process.argv.slice(2), + }) + + // Cheap /sync/version probe (10s TTL). When the server-side version + // has changed, a full /api/cli/sync runs in the background so the new + // env applies to the NEXT command — the current one uses whatever is + // already cached on disk. Replaces a blocking 5s Promise.race that + // ran on every invocation regardless of staleness. + await OpenScience.refreshIfStale().catch(() => {}) + + // Inject decrypted service credentials (settings ▸ Credentials) into the + // process env so skills/tools/connectors actually use them. Dynamic import + // keeps the credential route module out of every command's static graph. + await import("./server/routes/settings/credentials").then((m) => m.applyCredentialEnv()).catch(() => {}) + + // Legacy skill-based compute providers still consume their enabled keys + // from subprocess environments. Modal remains adapter-only. + await import("./server/routes/settings/compute").then((m) => m.ComputeSettings.applyComputeEnv()).catch(() => {}) + + // Retry any failed usage reports from previous sessions + OpenScience.flushPendingUsage().catch(() => {}) } - await Log.init({ - print: process.argv.includes("--print-logs"), - dev: Installation.isLocal(), - level: (() => { - if (opts.logLevel) return opts.logLevel as Log.Level - if (Installation.isLocal()) return "DEBUG" - return "INFO" - })(), - }) - OpenScience.reportApiBaseOverride() - - process.env.AGENT = "1" - process.env.OPENSCIENCE = "1" - - Log.Default.info("openscience", { - version: Installation.VERSION, - args: process.argv.slice(2), - }) - - // Cheap /sync/version probe (10s TTL). When the server-side version - // has changed, a full /api/cli/sync runs in the background so the new - // env applies to the NEXT command — the current one uses whatever is - // already cached on disk. Replaces a blocking 5s Promise.race that - // ran on every invocation regardless of staleness. - await OpenScience.refreshIfStale().catch(() => {}) - - // Inject decrypted service credentials (settings ▸ Credentials) into the - // process env so skills/tools/connectors actually use them. Dynamic import - // keeps the credential route module out of every command's static graph. - await import("./server/routes/settings/credentials").then((m) => m.applyCredentialEnv()).catch(() => {}) - - // Legacy skill-based compute providers still consume their enabled keys - // from subprocess environments. Modal remains adapter-only. - await import("./server/routes/settings/compute").then((m) => m.ComputeSettings.applyComputeEnv()).catch(() => {}) - - // Retry any failed usage reports from previous sessions - OpenScience.flushPendingUsage().catch(() => {}) + + const command = typeof opts._[0] === "string" ? opts._[0] : undefined + return await runDataRootMiddleware(command, Global.Path.data, initialize) }) .usage("\n" + UI.logo()) .completion("completion", "generate shell completion script") @@ -202,53 +198,55 @@ const cli = yargs(hideBin(process.argv)) }) .strict() -try { - await cli.parse() -} catch (e) { - let data: Record = {} - if (e instanceof NamedError) { - const obj = e.toObject() - Object.assign(data, { - ...obj.data, - }) - } +async function run() { + try { + await cli.parse() + } catch (e) { + let data: Record = {} + if (e instanceof NamedError) { + const obj = e.toObject() + Object.assign(data, { + ...obj.data, + }) + } - if (e instanceof Error) { - Object.assign(data, { - name: e.name, - message: e.message, - cause: e.cause?.toString(), - stack: e.stack, - }) - } + if (e instanceof Error) { + Object.assign(data, { + name: e.name, + message: e.message, + cause: e.cause?.toString(), + stack: e.stack, + }) + } - if (e instanceof ResolveMessage) { - Object.assign(data, { - name: e.name, - message: e.message, - code: e.code, - specifier: e.specifier, - referrer: e.referrer, - position: e.position, - importKind: e.importKind, - }) - } - Log.Default.error("fatal", data) - const formatted = FormatError(e) - if (formatted) UI.error(formatted) - if (formatted === undefined) { - UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL) - console.error(e instanceof Error ? e.message : String(e)) - } - process.exitCode = 1 -} finally { - // Some subprocesses don't react properly to SIGTERM and similar signals. - // Most notably, some docker-container-based MCP servers don't handle such signals unless - // run using `docker run --init`. - // Explicitly exit to avoid any hanging subprocesses. - if (cliDataRootOperation.current) { - await Promise.resolve(cliDataRootOperation.current[Symbol.asyncDispose]()).catch(() => undefined) + if (e instanceof ResolveMessage) { + Object.assign(data, { + name: e.name, + message: e.message, + code: e.code, + specifier: e.specifier, + referrer: e.referrer, + position: e.position, + importKind: e.importKind, + }) + } + Log.Default.error("fatal", data) + const formatted = FormatError(e) + if (formatted) UI.error(formatted) + if (formatted === undefined) { + UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL) + console.error(e instanceof Error ? e.message : String(e)) + } + process.exitCode = 1 + } finally { + // Some subprocesses don't react properly to SIGTERM and similar signals. + // Most notably, some docker-container-based MCP servers don't handle such signals unless + // run using `docker run --init`. + // Explicitly exit to avoid any hanging subprocesses. + await disposeDataRootOperation().catch(() => undefined) + await Log.flush().catch(() => undefined) + process.exit() } - await Log.flush().catch(() => undefined) - process.exit() } + +await run() diff --git a/backend/cli/src/process/darwin-responsibility-launcher.ts b/backend/cli/src/process/darwin-responsibility-launcher.ts index c1453247..ad056a34 100644 --- a/backend/cli/src/process/darwin-responsibility-launcher.ts +++ b/backend/cli/src/process/darwin-responsibility-launcher.ts @@ -75,22 +75,37 @@ export namespace DarwinResponsibilityLauncher { } async function reapOwned(): Promise { - const owner = DarwinResponsibility.unique(process.pid) - if (!owner) throw new Error(`Could not resolve macOS responsibility identity for ${process.pid}`) - for (let attempt = 0; attempt < 250; attempt++) { - const members = DarwinResponsibility.uniqueMembers(owner).filter((pid) => pid !== process.pid) + // The responsibility root is the durable containment marker owner. It may + // not exit while any member could still be alive, even when enumeration or + // signalling fails transiently. The external ledger has its own bounded + // wait and retains ownership on timeout; this supervisor deliberately has + // no timeout and keeps retrying until it observes an empty responsibility. + while (true) { + const owner = DarwinResponsibility.unique(process.pid) + if (!owner) { + await Bun.sleep(20) + continue + } + const members = (() => { + try { + return DarwinResponsibility.uniqueMembers(owner).filter((pid) => pid !== process.pid) + } catch { + return + } + })() + if (!members) { + await Bun.sleep(20) + continue + } if (!members.length) return for (const pid of members) { - if (!DarwinResponsibility.uniquelyOwns(owner, pid)) continue try { + if (!DarwinResponsibility.uniquelyOwns(owner, pid)) continue process.kill(pid, "SIGKILL") - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error - } + } catch {} } await Bun.sleep(20) } - throw new Error(`macOS responsibility root ${process.pid} could not reap every owned process`) } async function supervise(args: string[]): Promise { @@ -108,16 +123,62 @@ export namespace DarwinResponsibilityLauncher { // 130 before it can forward an interrupt to a persistent kernel. Replace // them with the supervisor-specific forwarding contract below. for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] as const) process.removeAllListeners(signal) + let child: ReturnType | undefined + let interruptPending = false + let teardownSignal: "SIGHUP" | "SIGTERM" | undefined + let requestTeardown: ((signal: "SIGHUP" | "SIGTERM") => void) | undefined + const teardownRequested = new Promise<"SIGHUP" | "SIGTERM">((resolve) => { + requestTeardown = resolve + }) + const forward = (signal: NodeJS.Signals) => { + if (!child) { + if (signal === "SIGINT") interruptPending = true + return + } + try { + // Forward exactly once to the payload leader. Responsibility teardown + // remains the descendant-wide hard-stop path; broad group delivery + // here can make runtime wrappers and their interpreter both translate + // the same interrupt. + child.kill(signal) + } catch {} + } + // Install the supervisor latches synchronously before activation can admit + // project code. TERM/HUP only record a control request; this responsibility + // root remains alive to reap. SIGINT remains a payload-only interrupt and + // is delivered once if it arrives just before the payload is spawned. + process.on("SIGINT", () => forward("SIGINT")) + for (const signal of ["SIGHUP", "SIGTERM"] as const) { + process.on(signal, () => { + if (teardownSignal) return + teardownSignal = signal + requestTeardown?.(signal) + }) + } + const latchReady = process.env.OPENSCIENCE_DARWIN_SUPERVISOR_TEST_READY + if (process.env.OPENSCIENCE_TEST_HOME && latchReady) { + await fs.writeFile(latchReady, String(process.pid), { encoding: "utf8", flag: "wx", mode: 0o600 }) + } // Do not expose project code until the durable ledger has persisted the // kernel responsibility unique ID. If registration fails, the supervisor // is still an empty process-group root that can be safely torn down. try { - await waitForRelease(activation) + const event = await Promise.race([ + waitForRelease(activation).then(() => "activated" as const), + teardownRequested.then(() => "teardown" as const), + ]) + if (event === "teardown") { + await reapOwned() + return teardownSignal === "SIGTERM" ? 143 : 129 + } } finally { await fs.rm(activation, { force: true }).catch(() => undefined) } - let child: ReturnType + if (teardownSignal) { + await reapOwned() + return teardownSignal === "SIGTERM" ? 143 : 129 + } try { child = spawn(file, commandArgs, { cwd: process.cwd(), @@ -140,16 +201,7 @@ export namespace DarwinResponsibilityLauncher { child.once("error", reject) child.once("exit", (code, signal) => resolve(code ?? (signal ? 128 : 1))) }) - const forward = (signal: NodeJS.Signals) => { - try { - // Forward exactly once to the payload leader. Responsibility teardown - // remains the descendant-wide hard-stop path; broad group delivery - // here can make runtime wrappers and their interpreter both translate - // the same interrupt. - child.kill(signal) - } catch {} - } - for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] as const) process.on(signal, () => forward(signal)) + if (interruptPending) forward("SIGINT") let settled = false let code = 1 @@ -169,6 +221,10 @@ export namespace DarwinResponsibilityLauncher { await reapOwned() return 137 } + if (teardownSignal) { + await reapOwned() + return teardownSignal === "SIGTERM" ? 143 : 129 + } if (settled) { // A normal command completion is also a lifecycle boundary. Reap any // background or fully reparented members before reporting the command diff --git a/backend/cli/src/process/linux-subreaper.ts b/backend/cli/src/process/linux-subreaper.ts index 6c6f449b..91a8f691 100644 --- a/backend/cli/src/process/linux-subreaper.ts +++ b/backend/cli/src/process/linux-subreaper.ts @@ -1,11 +1,13 @@ import fs from "node:fs/promises" import fsSync from "node:fs" -import { dlopen, FFIType, ptr } from "bun:ffi" +import { dlopen, FFIType, ptr, toArrayBuffer } from "bun:ffi" +import type { Pointer } from "bun:ffi" import { ProcessIdentity } from "./process-identity" const PR_SET_CHILD_SUBREAPER = 36 const PR_GET_CHILD_SUBREAPER = 37 const WNOHANG = 1 +const ECHILD = 10 const DRAIN_DELAY_MS = 20 type Library = ReturnType @@ -34,6 +36,9 @@ function openLibrary(): Library { args: [FFIType.i32, FFIType.ptr, FFIType.i32], returns: FFIType.i32, }, + __errno_location: { + returns: FFIType.ptr, + }, }) } catch (error) { failure = error @@ -77,6 +82,33 @@ async function processTable(): Promise { return rows } +async function taskChildren(pid: number): Promise { + const tasks = await fs.readdir(`/proc/${pid}/task`).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT" || error.code === "ESRCH") return undefined + throw error + }) + if (!tasks) return [] + const children = await Promise.all( + tasks + .filter((task) => /^\d+$/.test(task)) + .map((task) => + fs.readFile(`/proc/${pid}/task/${task}/children`, "utf8").catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT" || error.code === "ESRCH") return "" + throw error + }), + ), + ) + return [ + ...new Set( + children + .flatMap((value) => value.trim().split(/\s+/)) + .filter(Boolean) + .map(Number) + .filter((child) => Number.isSafeInteger(child) && child > 0), + ), + ] +} + interface Descendant { pid: number depth: number @@ -85,16 +117,39 @@ interface Descendant { async function descendants(): Promise { const rows = await processTable() + const table = new Map(rows.map((row) => [row.pid, row])) + const childrenByParent = new Map() + for (const row of rows) { + const children = childrenByParent.get(row.ppid) ?? [] + children.push(row.pid) + childrenByParent.set(row.ppid, children) + } const found: Descendant[] = [] const seen = new Set([process.pid]) + let parents = [process.pid] let depth = 1 - while (true) { - const added = rows.filter((row) => !seen.has(row.pid) && seen.has(row.ppid)) - if (!added.length) break - for (const row of added) { - seen.add(row.pid) - found.push({ pid: row.pid, depth, state: row.state }) + while (parents.length) { + const children = new Set() + for (const parent of parents) { + for (const child of childrenByParent.get(parent) ?? []) children.add(child) + // Linux records a child forked by a non-leader thread against that + // thread ID. `/proc`'s top-level process table contains only thread-group + // leaders, so PPID traversal alone can miss exactly the rapid worker + // forks kernels create. Each task's `children` file is the complete + // kernel-owned edge set and closes that escape without trusting names or + // process groups. + for (const child of await taskChildren(parent)) children.add(child) + } + const added: number[] = [] + for (const pid of children) { + if (seen.has(pid)) continue + const row = table.get(pid) ?? (await processRow(pid)) + if (!row) continue + seen.add(pid) + added.push(pid) + found.push({ pid, depth, state: row.state }) } + parents = added depth++ } return found @@ -135,6 +190,11 @@ async function signalExact(member: Pick, signa async function quiesce(primary?: LinuxSubreaper.Primary): Promise { let stable = "" while (true) { + // Pin the authenticated ancestry root before the first potentially large + // /proc sweep. Otherwise an unbounded fork loop can grow the table faster + // than it is enumerated and prevent containment from ever reaching the + // first SIGSTOP. + if (primary) await signalExact(primary, "SIGSTOP") const snapshot = await pinnedDescendants() snapshot.live.sort((a, b) => a.depth - b.depth) for (const member of snapshot.live) await signalExact(member, "SIGSTOP") @@ -194,6 +254,7 @@ export namespace LinuxSubreaper { arg5: number, ) => number const waitpid = library.symbols.waitpid as unknown as (pid: number, status: number, options: number) => number + const errnoLocation = library.symbols.__errno_location as unknown as () => number | bigint | null try { // Full /proc PPID snapshots are required for worker-thread forks as well // as main-thread children. Verify the inputs before spawning any body. @@ -209,7 +270,16 @@ export namespace LinuxSubreaper { retainedLibraries.add(library) const reap = () => { - while (waitpid(-1, 0, WNOHANG) > 0) {} + const status = Buffer.alloc(4) + while (true) { + const result = waitpid(-1, ptr(status), WNOHANG) + if (result > 0) continue + if (result === 0) return false + const location = errnoLocation() + if (!location) return false + const errno = new Int32Array(toArrayBuffer(location as Pointer, 0, 4))[0] + return errno === ECHILD + } } return { @@ -271,6 +341,7 @@ export namespace LinuxSubreaper { // Never drop the subreaper boundary while a descendant remains. An // uninterruptible child may delay completion, but returning would // reparent it to host init and violate the containment guarantee. + let stable = false while (true) { try { const stopped = await quiesce() @@ -278,12 +349,20 @@ export namespace LinuxSubreaper { for (const member of stopped) await signalExact(member, "SIGKILL") // The managed primary has already delivered its exit event // before drain() is called, so waitpid cannot steal Bun's child - // status. Every remaining direct child is adopted. - reap() - if (!(await descendants()).length) return + // status. Every remaining direct child is adopted. A zero from + // waitpid proves a live child remains even if a racing /proc + // census missed its worker-thread parent edge; only ECHILD plus + // two stable empty full-closure observations can release the + // subreaper anchor. + const reaped = reap() + const closure = await descendants() + const empty = reaped && !closure.length + if (empty && stable) return + stable = empty } catch { // Keep the verified subreaper alive and retry. Exiting on a // cleanup error would reparent the unresolved tree to host init. + stable = false } await Bun.sleep(DRAIN_DELAY_MS) } diff --git a/backend/cli/src/process/windows-job-launcher.ts b/backend/cli/src/process/windows-job-launcher.ts index 413a3d41..ae993d26 100644 --- a/backend/cli/src/process/windows-job-launcher.ts +++ b/backend/cli/src/process/windows-job-launcher.ts @@ -28,14 +28,18 @@ function latchLinuxControl(): LinuxControl { }) for (const candidate of ["SIGHUP", "SIGINT", "SIGTERM"] as const) { const inherited = process.listeners(candidate) + // Bun 1.3.9 drops the native signal watcher when the replacement listener + // is installed before inherited listeners are removed, even though + // listenerCount() still reports the replacement. This function runs + // synchronously before subreaper activation, gate release, or payload + // spawn, so remove the inherited server hooks first and then install the + // supervisor's sole native watcher without an externally visible gap. + for (const listener of inherited) process.removeListener(candidate, listener as (...args: unknown[]) => void) process.on(candidate, () => { if (signal) return signal = candidate request?.(candidate) }) - // Install the containment latch first, then remove server handlers. There - // is never a default-disposition window where a revoke can kill the gate. - for (const listener of inherited) process.removeListener(candidate, listener as (...args: unknown[]) => void) } return { get signal() { @@ -215,11 +219,13 @@ export namespace WindowsJobLauncher { } let subreaper: LinuxSubreaper.Handle | undefined try { + // Replace the statically imported server signal hooks before any + // containment/gate work can make this launcher externally targetable. + const control = latchLinuxControl() // This is established and kernel-verified before project code can run. // If prctl or /proc containment is unavailable, activation throws and // the body is never spawned. subreaper = LinuxSubreaper.activate() - const control = latchLinuxControl() for (let attempt = 0; attempt < 3_000; attempt++) { if (control.signal) return signalExitCode(control.signal) const assigned = await fs.readFile(release, "utf8").catch(() => undefined) diff --git a/backend/cli/src/project/authority-process.ts b/backend/cli/src/project/authority-process.ts index ea09cdfb..7af0e679 100644 --- a/backend/cli/src/project/authority-process.ts +++ b/backend/cli/src/project/authority-process.ts @@ -20,6 +20,7 @@ import { FileLease } from "@/util/file-lease" */ export namespace AuthorityProcessLedger { export type Kind = "pty" | "biology" | "kernel" + export type Containment = "linux_subreaper_v1" | "darwin_responsibility_v1" | "windows_job_v1" interface Entry { version: 1 @@ -30,6 +31,7 @@ export namespace AuthorityProcessLedger { owns_process_group: boolean darwin_responsibility_uniqueid?: string windows_job?: string + containment?: Containment owner_pid: number project_id: string session_id: string @@ -66,6 +68,10 @@ export namespace AuthorityProcessLedger { (typeof item.darwin_responsibility_uniqueid === "string" && /^[1-9][0-9]{0,19}$/.test(item.darwin_responsibility_uniqueid))) && (item.windows_job === undefined || WindowsJob.valid(item.windows_job)) && + (item.containment === undefined || + item.containment === "linux_subreaper_v1" || + item.containment === "darwin_responsibility_v1" || + item.containment === "windows_job_v1") && typeof item.owner_pid === "number" && Number.isSafeInteger(item.owner_pid) && item.owner_pid > 0 && @@ -113,6 +119,36 @@ export namespace AuthorityProcessLedger { } } + async function serialized(action: () => Promise): Promise { + await using lease = await FileLease.acquire(lockpath) + return await lease.during(action) + } + + const dataRootCoverage = new Map() + + async function disposeCoverage(id: string) { + const operation = dataRootCoverage.get(id) + if (!operation) return + await operation[Symbol.asyncDispose]() + if (dataRootCoverage.get(id) === operation) dataRootCoverage.delete(id) + } + + async function publishCoverage(entry: Entry) { + if (entry.kind !== "kernel") return + const operation = await DataRootBarrier.enter(Global.Path.data, 30_000, { + pid: entry.pid, + identity: entry.identity, + }) + const previous = dataRootCoverage.get(entry.id) + if (previous) { + await Promise.resolve(previous[Symbol.asyncDispose]()).catch(async (error: unknown) => { + await Promise.resolve(operation[Symbol.asyncDispose]()).catch(() => undefined) + throw error + }) + } + dataRootCoverage.set(entry.id, operation) + } + function alive(pid: number): boolean { try { process.kill(pid, 0) @@ -385,10 +421,63 @@ export namespace AuthorityProcessLedger { } return live || terminated } + const expectedContainment: Containment = + process.platform === "linux" ? "linux_subreaper_v1" : "darwin_responsibility_v1" + if (entry.kind === "kernel" && entry.containment !== expectedContainment) { + // A pre-containment POSIX record has no authenticated supervisor that can + // close a moving fork/setsid tree. Publish child-owned data-root coverage + // for its exact live anchor and retain both marker and ledger rather than + // reviving the old snapshot/SIGKILL path. An operator can stop the legacy + // process explicitly; automatic relocation must fail closed meanwhile. + await publishCoverage(entry) + throw new Error( + `Kernel process ${entry.pid} predates verified ${expectedContainment} containment; refusing unsafe automatic teardown`, + ) + } if (!entry.owns_process_group) { throw new Error(`Authorized ${entry.kind} process ${entry.pid} has no safely reapable process group`) } + if ( + entry.kind === "kernel" && + (entry.containment === "linux_subreaper_v1" || entry.containment === "darwin_responsibility_v1") + ) { + const live = await owns(entry.pid, entry.identity) + if (live) { + // The trusted containment anchor owns the moving process tree. Signal + // only that exact anchor: it repeatedly quiesces and drains descendants + // while remaining alive, so a concurrent fork/setsid cannot escape a + // stale ledger snapshot. Never SIGKILL this anchor on timeout. + try { + process.kill(entry.pid, "SIGTERM") + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error + } + for (let attempt = 0; attempt < 500; attempt++) { + if (!(await owns(entry.pid, entry.identity))) break + if (attempt === 499) { + throw new Error(`Kernel containment supervisor ${entry.pid} did not finish cooperative teardown`) + } + await Bun.sleep(20) + } + } + for (let attempt = 0; attempt < 500; attempt++) { + const remaining = await groupMembers(entry) + if (!remaining.length) return live + if (attempt === 499) { + throw new Error( + `Kernel containment supervisor ${entry.pid} exited before draining ${remaining.length} owned processes`, + ) + } + // The supervisor proves its own closure before exit, but a remote + // process-table observer can briefly see a killed member between its + // final signal and kernel removal. Wait boundedly for that independent + // observation; persistent members retain the ledger and marker. + await Bun.sleep(20) + } + return live + } + let signalled = false for (let attempt = 0; attempt < 100; attempt++) { const members = await groupMembers(entry) @@ -414,6 +503,7 @@ export namespace AuthorityProcessLedger { projectID: string sessionID: string authorityGeneration: string + containment?: Containment }): Promise { if ((process.platform === "win32" || process.platform === "darwin") && !input.windowsRelease) { throw new Error( @@ -423,6 +513,15 @@ export namespace AuthorityProcessLedger { if (process.platform === "darwin" && !DarwinResponsibility.available()) { throw new Error("macOS responsibility APIs are unavailable; refusing durable process registration") } + const expectedContainment: Containment = + process.platform === "linux" + ? "linux_subreaper_v1" + : process.platform === "darwin" + ? "darwin_responsibility_v1" + : "windows_job_v1" + if (input.kind === "kernel" && input.containment !== expectedContainment) { + throw new Error(`Kernel child ${input.pid} is missing verified ${expectedContainment} containment`) + } const processIdentity = await identity(input.pid) if (!processIdentity) { if (!alive(input.pid)) return false @@ -437,147 +536,205 @@ export namespace AuthorityProcessLedger { `Authorized ${input.kind} child ${input.pid} is not its own process-group leader; refusing an unreapable spawn`, ) } - await using lease = await FileLease.acquire(lockpath) - const entries = await read() - const index = entries.findIndex((entry) => entry.id === input.id) - // A duplicate durable ID must never orphan the previous Job handle/tree. - // Reap it while the shared ledger lease prevents a competing replacement. - if ((process.platform === "win32" || process.platform === "darwin") && index >= 0) { - await teardown(entries[index]!) - } - let darwinResponsibility: string | undefined - const windowsJob = - process.platform === "win32" - ? WindowsJob.assign({ id: input.id, pid: input.pid, expectedIdentity: processIdentity }) - : undefined - const next: Entry = { - version: 1, - id: input.id, - kind: input.kind, - pid: input.pid, - identity: processIdentity, - owns_process_group: ownsGroup, - ...(windowsJob ? { windows_job: windowsJob } : {}), - owner_pid: process.pid, - project_id: input.projectID, - session_id: input.sessionID, - authority_generation: input.authorityGeneration, - created_at: new Date().toISOString(), - } - if (index < 0) entries.push(next) - else entries[index] = next - await write(entries).catch((error) => { - if (windowsJob) WindowsJob.terminate(windowsJob) - throw error - }) - if (windowsJob && input.windowsRelease) { + return serialized(async () => { + const entries = await read() + const index = entries.findIndex((entry) => entry.id === input.id) + // A duplicate durable ID must never orphan the previous Job handle/tree. + // Reap it while the shared ledger lease prevents a competing replacement. + if (index >= 0) { + await teardown(entries[index]!) + await disposeCoverage(input.id) + } + let darwinResponsibility: string | undefined + const windowsJob = + process.platform === "win32" + ? WindowsJob.assign({ id: input.id, pid: input.pid, expectedIdentity: processIdentity }) + : undefined + const next: Entry = { + version: 1, + id: input.id, + kind: input.kind, + pid: input.pid, + identity: processIdentity, + owns_process_group: ownsGroup, + ...(windowsJob ? { windows_job: windowsJob } : {}), + ...(input.containment ? { containment: input.containment } : {}), + owner_pid: process.pid, + project_id: input.projectID, + session_id: input.sessionID, + authority_generation: input.authorityGeneration, + created_at: new Date().toISOString(), + } + if (index < 0) entries.push(next) + else entries[index] = next + await write(entries).catch((error) => { + if (windowsJob) WindowsJob.terminate(windowsJob) + throw error + }) try { - WindowsJob.release(input.windowsRelease, input.pid) + // Publish before opening any platform launch gate. The marker is owned + // by the containment leader, not the server. Linux's subreaper and the + // Darwin responsibility launcher keep this exact PID alive until all + // adopted workers drain; Windows binds it to the kill-on-close Job. + // The caller's parent marker covers the preceding ledger write. + await publishCoverage(next) } catch (error) { await teardown(next) + await disposeCoverage(input.id) await write(entries.filter((entry) => entry.id !== input.id)) throw error } - } - if (process.platform === "darwin" && input.windowsRelease) { - try { - await fs.writeFile(input.windowsRelease, String(input.pid), { encoding: "utf8", flag: "wx", mode: 0o600 }) - for (let attempt = 0; attempt < 3_000; attempt++) { - if (!(await owns(input.pid, processIdentity))) break - if (DarwinResponsibility.responsible(input.pid) === input.pid) { - darwinResponsibility = DarwinResponsibility.unique(input.pid) - if (darwinResponsibility) break - } - if (attempt === 2_999) { - throw new Error(`Authorized ${input.kind} child ${input.pid} did not become a macOS responsibility root`) + if (windowsJob && input.windowsRelease) { + try { + WindowsJob.release(input.windowsRelease, input.pid) + } catch (error) { + await teardown(next) + await disposeCoverage(input.id) + await write(entries.filter((entry) => entry.id !== input.id)) + throw error + } + } + if (process.platform === "darwin" && input.windowsRelease) { + try { + await fs.writeFile(input.windowsRelease, String(input.pid), { encoding: "utf8", flag: "wx", mode: 0o600 }) + for (let attempt = 0; attempt < 3_000; attempt++) { + if (!(await owns(input.pid, processIdentity))) break + if (DarwinResponsibility.responsible(input.pid) === input.pid) { + darwinResponsibility = DarwinResponsibility.unique(input.pid) + if (darwinResponsibility) break + } + if (attempt === 2_999) { + throw new Error(`Authorized ${input.kind} child ${input.pid} did not become a macOS responsibility root`) + } + await Bun.sleep(10) } - await Bun.sleep(10) + } catch (error) { + await teardown(next) + await disposeCoverage(input.id) + await write(entries.filter((entry) => entry.id !== input.id)) + throw error } - } catch (error) { + } + if (darwinResponsibility) { + next.darwin_responsibility_uniqueid = darwinResponsibility + const position = entries.findIndex((entry) => entry.id === input.id) + if (position >= 0) entries[position] = next + await write(entries) + try { + await fs.writeFile(`${input.windowsRelease}${DARWIN_RESPONSIBILITY_ACTIVATION_SUFFIX}`, String(input.pid), { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }) + } catch (error) { + await teardown(next) + await disposeCoverage(input.id) + await write(entries.filter((entry) => entry.id !== input.id)) + throw error + } + } + if (darwinResponsibility && !DarwinResponsibility.uniquelyOwns(darwinResponsibility, input.pid)) { await teardown(next) + await disposeCoverage(input.id) await write(entries.filter((entry) => entry.id !== input.id)) - throw error + throw new Error(`Authorized ${input.kind} child ${input.pid} failed macOS responsibility handoff`) } - } - if (darwinResponsibility) { - next.darwin_responsibility_uniqueid = darwinResponsibility - const position = entries.findIndex((entry) => entry.id === input.id) - if (position >= 0) entries[position] = next - await write(entries) - try { - await fs.writeFile(`${input.windowsRelease}${DARWIN_RESPONSIBILITY_ACTIVATION_SUFFIX}`, String(input.pid), { - encoding: "utf8", - flag: "wx", - mode: 0o600, - }) - } catch (error) { + // Persist first, then close the observation window. If the leader exited + // during registration, durable ownership already exists; tear down any + // surviving same-group children before returning a failed spawn. + if ( + !(await owns(input.pid, processIdentity)) || + (windowsJob && !WindowsJob.contains(windowsJob, input.pid)) || + (darwinResponsibility && !DarwinResponsibility.uniquelyOwns(darwinResponsibility, input.pid)) + ) { await teardown(next) + await disposeCoverage(input.id) await write(entries.filter((entry) => entry.id !== input.id)) - throw error + return false } - } - if (darwinResponsibility && !DarwinResponsibility.uniquelyOwns(darwinResponsibility, input.pid)) { - await teardown(next) - await write(entries.filter((entry) => entry.id !== input.id)) - throw new Error(`Authorized ${input.kind} child ${input.pid} failed macOS responsibility handoff`) - } - // Persist first, then close the observation window. If the leader exited - // during registration, durable ownership already exists; tear down any - // surviving same-group children before returning a failed spawn. - if ( - !(await owns(input.pid, processIdentity)) || - (windowsJob && !WindowsJob.contains(windowsJob, input.pid)) || - (darwinResponsibility && !DarwinResponsibility.uniquelyOwns(darwinResponsibility, input.pid)) - ) { - await teardown(next) - await write(entries.filter((entry) => entry.id !== input.id)) - return false - } - return true + return true + }) } /** A leader can exit while background work remains in its process group. * Normal completion therefore tears down and verifies the whole group before * dropping durable ownership. */ export async function complete(id: string): Promise { - await using lease = await FileLease.acquire(lockpath) - const entries = await read() - const entry = entries.find((item) => item.id === id) - if (!entry) return true - if (await owns(entry.pid, entry.identity)) return false - await teardown(entry) - await write(entries.filter((item) => item.id !== id)) - return true + return serialized(async () => { + const entries = await read() + const entry = entries.find((item) => item.id === id) + if (!entry) { + await disposeCoverage(id) + return true + } + if (await owns(entry.pid, entry.identity)) return false + await teardown(entry) + await disposeCoverage(id) + await write(entries.filter((item) => item.id !== id)) + return true + }) } /** Kill identity-matched children even when their owning server is gone. */ export async function revoke(scope: Scope = {}): Promise { - await using lease = await FileLease.acquire(lockpath) - const entries = await read() - const retained: Entry[] = [] - let killed = 0 - const failures: unknown[] = [] - for (const entry of entries) { - const match = - (!scope.id || entry.id === scope.id) && - (!scope.kind || entry.kind === scope.kind) && - (!scope.projectID || entry.project_id === scope.projectID) && - (!scope.sessionID || entry.session_id === scope.sessionID) && - (!scope.authorityGeneration || entry.authority_generation === scope.authorityGeneration) - if (!match) { - retained.push(entry) - continue - } - try { - if (await teardown(entry)) killed++ - } catch (error) { - retained.push(entry) - failures.push(error) + return serialized(async () => { + const entries = await read() + const retained: Entry[] = [] + let killed = 0 + const failures: unknown[] = [] + let matched = false + for (const entry of entries) { + const match = + (!scope.id || entry.id === scope.id) && + (!scope.kind || entry.kind === scope.kind) && + (!scope.projectID || entry.project_id === scope.projectID) && + (!scope.sessionID || entry.session_id === scope.sessionID) && + (!scope.authorityGeneration || entry.authority_generation === scope.authorityGeneration) + if (!match) { + retained.push(entry) + continue + } + matched = true + try { + if (await teardown(entry)) killed++ + await disposeCoverage(entry.id) + } catch (error) { + retained.push(entry) + failures.push(error) + } } - } - await write(retained) - if (failures.length) throw new AggregateError(failures, "Authorized child revocation failed") - return killed + if (scope.id && !matched) await disposeCoverage(scope.id) + await write(retained) + if (failures.length) throw new AggregateError(failures, "Authorized child revocation failed") + return killed + }) + } + + /** + * Read-only relocation quarantine. The caller must already hold the global + * data-root exclusive barrier: that drains every older ledger FileLease + * writer before this read and prevents a legacy server from publishing a new + * entry until the relocation finishes. Do not acquire a nested barrier here. + */ + export async function assertRelocationSafe(): Promise { + // Always parse first. A malformed or unreadable durable ledger is itself + // an unresolved ownership condition and must block every platform. + const entries = await read() + const expectedContainment: Containment = + process.platform === "linux" + ? "linux_subreaper_v1" + : process.platform === "darwin" + ? "darwin_responsibility_v1" + : "windows_job_v1" + const unsafe = entries.filter( + (entry) => + entry.kind === "kernel" && + (entry.containment !== expectedContainment || (process.platform === "win32" && !entry.windows_job)), + ) + if (!unsafe.length) return + throw new Error( + `Data-root relocation is blocked by ${unsafe.length} legacy kernel process ${unsafe.length === 1 ? "entry" : "entries"} without verified ${expectedContainment} containment${process.platform === "win32" ? " and durable Job Object ownership" : ""}`, + ) } export function pathForTests(): string { diff --git a/backend/cli/src/project/authority-signal.ts b/backend/cli/src/project/authority-signal.ts index a039d32e..60e06172 100644 --- a/backend/cli/src/project/authority-signal.ts +++ b/backend/cli/src/project/authority-signal.ts @@ -62,7 +62,7 @@ export namespace AuthoritySignal { await using lease = await FileLease.acquire(lock(), spawnOwnerWait) // Await inside this lexical scope so `await using` cannot dispose the // interprocess lease before the spawn/mutation callback has settled. - return await action() + return await lease.during(action) } async function current() { diff --git a/backend/cli/src/project/execution.ts b/backend/cli/src/project/execution.ts index 9e79c2c3..d36a4f57 100644 --- a/backend/cli/src/project/execution.ts +++ b/backend/cli/src/project/execution.ts @@ -35,6 +35,7 @@ export namespace ExecutionAuthority { export const Decision = z.object({ allowed: z.boolean(), reason: z.enum(["allowed", "project_untrusted", "sandbox_unavailable"]), + message: z.string().optional(), capability: Capability, mode: z.enum(["read_only", "sandboxed", "host"]), projectID: z.string(), @@ -53,6 +54,7 @@ export namespace ExecutionAuthority { network: z.enum(["allow", "deny"]), allowWrite: z.array(z.string()), onUnavailable: z.enum(["warn", "error", "allow"]), + requireProjectTrust: z.boolean().default(false), backend: z.enum(["seatbelt", "bubblewrap", "none"]), available: z.boolean(), enforced: z.boolean(), @@ -61,7 +63,43 @@ export namespace ExecutionAuthority { }) export type Decision = z.infer - export const DeniedError = NamedError.create("ExecutionAuthorityDeniedError", Decision) + const BaseDeniedError = NamedError.create("ExecutionAuthorityDeniedError", Decision) + + export class DeniedError extends BaseDeniedError { + constructor(data: z.input, options?: ErrorOptions) { + super(data, options) + if (data.message) this.message = data.message + } + } + + const routine = new Set(["terminal", "kernel", "shell", "local_job"]) + + function action(capability: Capability) { + switch (capability) { + case "terminal": + return "start a terminal" + case "kernel": + return "start a kernel" + case "shell": + return "run a shell command" + case "local_job": + return "dispatch a local compute job" + case "remote_job": + return "dispatch a remote compute job" + case "package_install": + return "install packages" + case "project_plugin": + return "start a project plugin" + case "project_mcp": + return "start a project MCP server" + case "project_formatter": + return "start a project formatter" + case "project_lsp": + return "start a project language server" + case "provider_token_command": + return "run a provider token command" + } + } export async function decide(input: { projectID?: string @@ -86,14 +124,26 @@ export namespace ExecutionAuthority { network: policy.network ?? "deny", allowWrite: policy.allowWrite ?? [], onUnavailable: policy.onUnavailable ?? "error", + requireProjectTrust: policy.requireProjectTrust ?? false, backend: backend.backend, available: backend.available, enforced: (policy.enabled ?? true) && backend.available, } - const untrusted = !trust.canExecuteProjectCode const unavailable = sandbox.enabled && !sandbox.available && sandbox.onUnavailable === "error" - const reason = untrusted ? "project_untrusted" : unavailable ? "sandbox_unavailable" : "allowed" - const mode = untrusted || unavailable ? "read_only" : sandbox.enabled ? "sandboxed" : "host" + const untrusted = !trust.canExecuteProjectCode + const needsTrust = sandbox.requireProjectTrust || !routine.has(input.capability) || !sandbox.enforced + const reason = unavailable ? "sandbox_unavailable" : untrusted && needsTrust ? "project_untrusted" : "allowed" + const mode = reason !== "allowed" ? "read_only" : sandbox.enforced ? "sandboxed" : "host" + const message = + reason === "sandbox_unavailable" + ? `A verified OS sandbox is required to ${action(input.capability)}, but OpenScience could not enforce one (${backend.reason}). Install the platform sandbox backend or update the global Sandbox settings.` + : reason === "project_untrusted" + ? sandbox.requireProjectTrust + ? `Trust this project to ${action(input.capability)} because the global Sandbox policy requires explicit trust for all execution.` + : !routine.has(input.capability) + ? `Trust this project to ${action(input.capability)}. This operation is not eligible for trust-free sandboxed execution.` + : `Trust this project to ${action(input.capability)} without an enforced OS sandbox, or enable a working sandbox backend first.` + : undefined const [readable, writable, workspace] = await Promise.all([ SessionFilesystem.processReadRoots(input.sessionID), SessionFilesystem.processWriteRoots(input.sessionID), @@ -115,6 +165,7 @@ export namespace ExecutionAuthority { return { allowed: reason === "allowed", reason, + message, capability: input.capability, mode, projectID: Instance.project.id, @@ -127,7 +178,7 @@ export namespace ExecutionAuthority { readable, writable, sandbox, - remediation: trust.remediation, + remediation: reason === "project_untrusted" ? trust.remediation : undefined, } } diff --git a/backend/cli/src/project/project.ts b/backend/cli/src/project/project.ts index 3f0ef588..bac5db22 100644 --- a/backend/cli/src/project/project.ts +++ b/backend/cli/src/project/project.ts @@ -314,50 +314,54 @@ export namespace Project { 120_000, ) - const found = await records(worktree) - const opaque = found.find((record) => record.id.startsWith("prj_")) - const source = opaque ?? found[0] - const id = opaque?.id ?? createID() - const current = found - .filter((record) => record.id !== source?.id) - .reduce( - (result, record) => merge(result, record.project), - source - ? { - ...source.project, - id, - sandboxes: [...(source.project.sandboxes ?? [])], - } - : { - id, - worktree, - vcs: vcs as Info["vcs"], - sandboxes: [], - time: { - created: Date.now(), - updated: Date.now(), + return await durable.during(async () => { + const found = await records(worktree) + const opaque = found.find((record) => record.id.startsWith("prj_")) + const source = opaque ?? found[0] + const id = opaque?.id ?? createID() + const current = found + .filter((record) => record.id !== source?.id) + .reduce( + (result, record) => merge(result, record.project), + source + ? { + ...source.project, + id, + sandboxes: [...(source.project.sandboxes ?? [])], + } + : { + id, + worktree, + vcs: vcs as Info["vcs"], + sandboxes: [], + time: { + created: Date.now(), + updated: Date.now(), + }, }, - }, - ) - - const result: Info = { - ...current, - worktree, - vcs: vcs as Info["vcs"], - time: { - ...current.time, - updated: Date.now(), - }, - } - if (sandbox !== result.worktree && !result.sandboxes.includes(sandbox)) result.sandboxes.push(sandbox) - result.sandboxes = [ - ...new Set( - result.sandboxes.filter((directory) => canonicalize(directory) !== result.worktree && existsSync(directory)), - ), - ] - await Storage.write(["project", id], result) - await adoptLegacy(id, worktree, found) - return result + ) + + const result: Info = { + ...current, + worktree, + vcs: vcs as Info["vcs"], + time: { + ...current.time, + updated: Date.now(), + }, + } + if (sandbox !== result.worktree && !result.sandboxes.includes(sandbox)) result.sandboxes.push(sandbox) + result.sandboxes = [ + ...new Set( + result.sandboxes.filter( + (directory) => canonicalize(directory) !== result.worktree && existsSync(directory), + ), + ), + ] + await Storage.write(["project", id], result) + await adoptLegacy(id, worktree, found) + return result + }) }) if (Flag.OPENSCIENCE_EXPERIMENTAL_ICON_DISCOVERY) discover(result) diff --git a/backend/cli/src/science/kernel/process.ts b/backend/cli/src/science/kernel/process.ts index 513ac66e..3dd5bc88 100644 --- a/backend/cli/src/science/kernel/process.ts +++ b/backend/cli/src/science/kernel/process.ts @@ -3,6 +3,7 @@ import fs from "node:fs" import type { ChildProcess } from "node:child_process" import { dlopen, FFIType, ptr } from "bun:ffi" import { WindowsJob } from "@/process/windows-job" +import { WindowsJobLauncher } from "@/process/windows-job-launcher" import { AuthorityProcessLedger } from "@/project/authority-process" import type { KernelProcess } from "./types" @@ -24,6 +25,11 @@ function rawToken(pid: number) { try { const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8") const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ") + // A zombie retains its start tick until the parent consumes SIGCHLD, but + // it cannot execute or own a surviving containment tree. Treat it as + // stopped so durable teardown does not fall through while Bun is still + // delivering the managed ChildProcess exit event. + if (fields[0] === "Z") return const start = fields[19] return start ? `linux:${start}` : undefined } catch { @@ -64,6 +70,7 @@ export namespace KernelProcessIdentity { sessionID: string authorityGeneration: string windowsRelease?: string + linuxOwner?: { pid: number; identity: string } } export function onExit(fn: () => void) { @@ -102,6 +109,12 @@ export namespace KernelProcessIdentity { export async function register(proc: ChildProcess, ownership?: Ownership): Promise { const identity = capture(proc) if (!identity || !ownership) return identity + if ( + process.platform === "linux" && + (!ownership.windowsRelease || !ownership.linuxOwner || !WindowsJobLauncher.isLinuxSubreaper(proc)) + ) { + throw new Error("Linux kernel manager did not use the durable owner-gated subreaper launcher") + } if (!identity.token) { throw new Error(`Could not establish a safe process identity for kernel child ${identity.pid}`) } @@ -110,8 +123,28 @@ export namespace KernelProcessIdentity { kind: "kernel", pid: identity.pid, expectedIdentity: identity.token, + containment: + process.platform === "linux" + ? "linux_subreaper_v1" + : process.platform === "darwin" + ? "darwin_responsibility_v1" + : "windows_job_v1", }) if (!registered) return + if (process.platform === "linux" && ownership.windowsRelease) { + try { + await WindowsJobLauncher.release(ownership.windowsRelease, identity.pid) + } catch (error) { + const failures: unknown[] = [] + await AuthorityProcessLedger.revoke({ id: ownership.id, kind: "kernel" }).catch((failure) => + failures.push(failure), + ) + if (failures.length) { + throw new AggregateError([error, ...failures], "Kernel launch ownership cleanup failed") + } + throw error + } + } return { ...identity, ownershipID: ownership.id } } @@ -119,18 +152,7 @@ export namespace KernelProcessIdentity { * not perform the standard immediate post-spawn registration themselves. */ export async function ensureRegistered(identity: KernelProcess | undefined, ownership: Ownership) { if (!identity || identity.ownershipID === ownership.id) return identity - if (!identity.token || !matchesRecorded(identity)) { - throw new Error("Kernel process exited or changed identity before durable registration") - } - const registered = await AuthorityProcessLedger.register({ - ...ownership, - kind: "kernel", - pid: identity.pid, - expectedIdentity: identity.token, - }) - if (!registered) throw new Error("Kernel process exited before durable registration") - identity.ownershipID = ownership.id - return identity + throw new Error("Kernel manager returned a process without trusted durable containment registration") } export function matches(proc: ChildProcess, identity?: KernelProcess) { @@ -155,15 +177,47 @@ export namespace KernelProcessIdentity { return matchesToken(identity.pid, identity.token) } - export async function terminate(identity?: KernelProcess) { - if (!identity) return false - if (identity.ownershipID) { + /** Synchronous process-exit handoff. A registered POSIX containment + * supervisor must remain alive to drain its tree, so exit hooks request its + * cooperative TERM path and never group-SIGKILL the anchor. On Windows this + * is a handoff, not synchronous proof: server handle closure atomically + * enforces the registered Job's KILL_ON_JOB_CLOSE policy. */ + export function terminateSync(identity?: KernelProcess): boolean { + if (!identity?.ownershipID) return false + if (process.platform === "win32") return true + if (!matchesRecorded(identity)) return true + try { + process.kill(identity.pid, "SIGTERM") + return true + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return true + return false + } + } + + export async function complete(identity?: KernelProcess): Promise { + if (!identity?.ownershipID) return false + return AuthorityProcessLedger.complete(identity.ownershipID) + } + + export async function terminate(identity?: KernelProcess, pendingOwnershipID?: string) { + const ownershipID = identity?.ownershipID ?? pendingOwnershipID + if (ownershipID) { // An ownership ID is only returned after the durable record is synced. // If another revoker already removed that record, its removal itself is // proof that the exact group was successfully torn down. - await AuthorityProcessLedger.revoke({ id: identity.ownershipID, kind: "kernel" }) - if (!matchesRecorded(identity)) return true + const revoked = await AuthorityProcessLedger.revoke({ id: ownershipID, kind: "kernel" }) + if (!identity || !matchesRecorded(identity)) return true + // A returned durable identity, or a matched live ledger entry, is a + // verified containment anchor. Never fall through to a raw process-group + // kill while it remains alive: that can kill the supervisor before it + // drains a setsid worker. A lexical ID with no ledger match is the sole + // pre-registration case and may use the ordinary group fallback below. + if (identity.ownershipID || revoked > 0) { + throw new Error(`Registered kernel containment ${identity.pid} remained live after durable revocation`) + } } + if (!identity) return false if (!matchesRecorded(identity)) return false const signal = (value: NodeJS.Signals) => { try { diff --git a/backend/cli/src/science/kernel/registry.ts b/backend/cli/src/science/kernel/registry.ts index d6b4440f..b1265aa1 100644 --- a/backend/cli/src/science/kernel/registry.ts +++ b/backend/cli/src/science/kernel/registry.ts @@ -11,6 +11,7 @@ import { Global } from "@/global" import { FileLease } from "@/util/file-lease" import { AuthoritySignal } from "@/project/authority-signal" import { KernelMetrics } from "./metrics" +import { ProcessIdentity } from "@/process/process-identity" import * as ExecutionFiles from "@/science/execution/files" import { ExecutionHistory } from "@/science/execution/history" import type { @@ -73,7 +74,8 @@ type Entry = { authority: ExecutionAuthority.Decision | null lastCell: KernelCell | null process: KernelProcess | null - lease?: AsyncDisposable + ownershipID: string | null + lease?: FileLease.Lease claiming?: Promise idle?: ReturnType expiring?: Promise @@ -91,6 +93,7 @@ type Pending = { type StartTicket = { cancelled: boolean minimumIncarnation: number + ownershipID: string incarnation?: number } @@ -107,6 +110,7 @@ const Persisted = z.object({ incarnation: z.number().int().nullable(), execution_count: z.number().int().nonnegative(), last_activity_at: z.number().nullable(), + ownership_id: z.string().nullable().optional(), process: z .object({ pid: z.number().int().positive(), @@ -226,6 +230,7 @@ async function persist(value: Entry) { incarnation: value.incarnation, execution_count: value.executionCount, last_activity_at: value.lastActivityAt, + ownership_id: value.ownershipID, process: value.kernel?.process ?? value.process, } satisfies z.infer) } @@ -247,6 +252,7 @@ function restore(value: z.infer) { authority: null, lastCell: null, process: value.process ?? null, + ownershipID: value.ownership_id ?? value.process?.ownershipID ?? null, } records().entries.set(id, entry) return entry @@ -291,6 +297,7 @@ const record = (identity: KernelIdentity) => { authority: null, lastCell: null, process: null, + ownershipID: null, } records().entries.set(id, value) return value @@ -301,6 +308,12 @@ async function releaseLease(value: Entry) { value.lease = undefined } +function withLease(value: Entry, action: () => Promise): Promise { + const lease = value.lease + if (!lease) return Promise.reject(new Error(`Kernel ${value.key} is missing its durable lease`)) + return lease.during(action) +} + function running(pid: number) { try { process.kill(pid, 0) @@ -310,13 +323,24 @@ function running(pid: number) { } } -async function reap(value: Entry) { +type ReapOptions = { + ownershipID?: string + preserveOwnership?: boolean +} + +async function reap(value: Entry, options: ReapOptions = {}) { const identity = value.process - if (!identity) return + const ownershipID = identity?.ownershipID ?? options.ownershipID ?? value.ownershipID ?? undefined + if (!identity && !ownershipID) return + if (!identity) { + await KernelProcessIdentity.terminate(undefined, ownershipID) + value.ownershipID = options.preserveOwnership ? (options.ownershipID ?? value.ownershipID) : null + return + } if (!identity.token && running(identity.pid)) { throw new Error(`Refusing to terminate unverified persisted kernel process ${identity.pid}.`) } - await KernelProcessIdentity.terminate(identity) + await KernelProcessIdentity.terminate(identity, ownershipID) const stopped = async (attempt = 0): Promise => { if (!KernelProcessIdentity.matchesRecorded(identity)) return true if (attempt >= 100) return false @@ -327,14 +351,14 @@ async function reap(value: Entry) { throw new Error(`Kernel process ${identity.pid} is still running after an identity-verified termination attempt.`) } value.process = null + value.ownershipID = options.preserveOwnership ? (options.ownershipID ?? value.ownershipID) : null } -async function reapCurrent(value: Entry) { +async function reapCurrent(value: Entry, options: ReapOptions = {}) { const identity = value.kernel?.process ?? value.process if (identity) value.process = identity - await reap(value).catch(async (error) => { + await reap(value, options).catch(async (error) => { await persist(value).catch(() => undefined) - await releaseLease(value) throw error }) return identity @@ -348,7 +372,7 @@ function reserveIncarnation(value: Entry, ticket?: StartTicket) { value.incarnation = Math.max(value.incarnation ?? 0, ticket.incarnation) } -async function claim(value: Entry, ticket?: StartTicket) { +async function claim(value: Entry, ticket?: StartTicket, options: ReapOptions = {}) { if (value.lease) { reserveIncarnation(value, ticket) return @@ -359,26 +383,31 @@ async function claim(value: Entry, ticket?: StartTicket) { return } const pending = (async () => { - value.lease = await FileLease.acquire(leasePath(value.key), 1_000).catch(() => { + const lease = await FileLease.acquire(leasePath(value.key), 1_000).catch(() => { throw new Error("This kernel is active in another OpenScience server. Stop it there before starting it here.") }) - const stored = await Storage.read(storageKey(value.identity)).catch(async (error) => { - if (Storage.NotFoundError.isInstance(error)) return + value.lease = lease + try { + await lease.during(async () => { + const stored = await Storage.read(storageKey(value.identity)).catch((error) => { + if (Storage.NotFoundError.isInstance(error)) return + throw error + }) + const parsed = Persisted.safeParse(stored) + if (parsed.success) { + value.incarnation = parsed.data.incarnation + value.executionCount = parsed.data.execution_count + value.lastActivityAt = parsed.data.last_activity_at + value.process = parsed.data.process ?? null + value.ownershipID = parsed.data.ownership_id ?? parsed.data.process?.ownershipID ?? null + } + await reap(value, options) + reserveIncarnation(value, ticket) + }) + } catch (error) { await releaseLease(value) throw error - }) - const parsed = Persisted.safeParse(stored) - if (parsed.success) { - value.incarnation = parsed.data.incarnation - value.executionCount = parsed.data.execution_count - value.lastActivityAt = parsed.data.last_activity_at - value.process = parsed.data.process ?? null } - await reap(value).catch(async (error) => { - await releaseLease(value) - throw error - }) - reserveIncarnation(value, ticket) })() value.claiming = pending await pending.finally(() => { @@ -400,44 +429,56 @@ async function reclaimEntry(value: Entry) { // A restart may win the authority lease before this pending start enters its // own spawn section; without this reservation the replacement reused // incarnation 1 and looked indistinguishable from the boot it cancelled. - await claim(value, pending?.ticket) + const ownershipID = pending?.ticket.ownershipID + const preserve = !!pending + await claim(value, pending?.ticket, { ownershipID, preserveOwnership: preserve }) // Reap through the durable ledger while the interpreter leader is still // alive. Its live descendant closure includes workers that called setsid() // and left the kernel's process group; killing the manager/leader first // would reparent those workers and erase the only safe ownership proof. - await reapCurrent(value) - const released = await value.manager.release(value.key).then( - () => ({ ok: true as const }), - (error) => ({ ok: false as const, error }), - ) + const firstLease = value.lease! + try { + await firstLease.during(() => + reapCurrent(value, { ownershipID, preserveOwnership: preserve }).then(() => undefined), + ) + } catch (error) { + await releaseLease(value) + throw error + } if (entered) await pending?.promise.catch(() => undefined) else void pending?.promise.catch(() => undefined) records().starts.delete(value.key) // A cancelled startup releases its lease in the pending promise. Reclaim it // before touching the durable record so a different server cannot start the // same kernel between cancellation and the final stopped-state write. - if (!value.lease) await claim(value) + if (!value.lease) await claim(value, undefined, { ownershipID, preserveOwnership: preserve }) // A cancelled startup may have crossed its spawn boundary after the first - // pass. Keep this second pass to reclaim that late durable registration. - const identity = await reapCurrent(value) - if (!released.ok && !identity) { - await releaseLease(value) - throw released.error - } - value.kernel = undefined - value.state = "stopped" - value.executionCount = 0 - value.environment = null - value.startedAt = null - value.lastActivityAt = Date.now() - value.authority = null - value.lastCell = null - value.process = null - await persist(value).catch(async (error) => { + // pass. Keep the lexical ticket ID until this second pass has reclaimed any + // late durable registration; only then may the pointer be cleared. + const finalLease = value.lease! + const failures: unknown[] = [] + try { + await finalLease.during(async () => { + await reapCurrent(value, { ownershipID }) + await value.manager.release(value.key).catch((error) => failures.push(error)) + value.kernel = undefined + value.state = "stopped" + value.executionCount = 0 + value.environment = null + value.startedAt = null + value.lastActivityAt = Date.now() + value.authority = null + value.lastCell = null + value.process = null + value.ownershipID = null + await persist(value) + }) + } catch (error) { await releaseLease(value) throw error - }) + } await releaseLease(value) + if (failures.length) throw new AggregateError(failures, "Kernel manager cleanup failed after durable teardown") } function releaseEntry(value: Entry) { @@ -627,6 +668,7 @@ const entry = async (identity: KernelIdentity, options?: KernelStartOptions, han return value } if (value.kernel?.ready) { + await reapCurrent(value) await value.manager.release(value.key) value.kernel = undefined value.state = "stopped" @@ -643,14 +685,17 @@ const entry = async (identity: KernelIdentity, options?: KernelStartOptions, han } if (pending) { pending.ticket.cancelled = true - await pending.manager.release(pending.key) + await KernelProcessIdentity.terminate(undefined, pending.ticket.ownershipID) await pending.promise.catch(() => undefined) + await KernelProcessIdentity.terminate(undefined, pending.ticket.ownershipID) + await pending.manager.release(pending.key) records().starts.delete(value.key) } const ticket: StartTicket = { cancelled: false, minimumIncarnation: (value.incarnation ?? 0) + 1, + ownershipID: `kernel-${crypto.randomUUID()}`, } const drop = () => { if (records().starts.get(value.key)?.ticket === ticket) records().starts.delete(value.key) @@ -658,8 +703,21 @@ const entry = async (identity: KernelIdentity, options?: KernelStartOptions, han const stale = () => ticket.cancelled || records().entries.get(value.key) !== value const abort = async () => { drop() - await value.manager.release(value.key) + const failures: unknown[] = [] + const terminated = await KernelProcessIdentity.terminate(undefined, ticket.ownershipID).then( + () => { + if (value.ownershipID === ticket.ownershipID) value.ownershipID = null + return true + }, + (error) => { + failures.push(error) + return false + }, + ) + if (terminated) await value.manager.release(value.key).catch((error) => failures.push(error)) + await persist(value).catch((error) => failures.push(error)) await releaseLease(value) + if (failures.length) throw new AggregateError(failures, "Cancelled kernel startup could not be safely reclaimed") throw new KernelStartupCancelled() } // Publish the pending start before acquiring the cross-process authority @@ -688,12 +746,27 @@ const entry = async (identity: KernelIdentity, options?: KernelStartOptions, han value.lastActivityAt = Date.now() value.authority = current value.lastCell = null + const linuxIdentity = process.platform === "linux" ? await ProcessIdentity.capture(process.pid) : undefined + if (process.platform === "linux" && !linuxIdentity) { + throw new Error("Could not capture the Linux server identity for kernel launch") + } const processOwnership: KernelProcessIdentity.Ownership = { - id: `kernel-${crypto.randomUUID()}`, + id: ticket.ownershipID, projectID: identity.projectID, sessionID: identity.sessionID, authorityGeneration: current.generation, + ...(linuxIdentity ? { linuxOwner: { pid: process.pid, identity: linuxIdentity } } : {}), } + // Persist the durable ownership ID before spawn. If the server dies after + // ledger registration but before the child pointer is published, a fresh + // server can still revoke the exact containment group by this ID. + value.ownershipID = processOwnership.id + const sandboxPolicy = Object.freeze({ + enabled: current.sandbox.enabled, + network: current.sandbox.network, + allowWrite: Object.freeze([...current.sandbox.allowWrite]), + onUnavailable: current.sandbox.onUnavailable, + }) return (async () => { await persist(value) const kernel = await value.manager.get(value.key, { @@ -701,15 +774,10 @@ const entry = async (identity: KernelIdentity, options?: KernelStartOptions, han sessionID: identity.sessionID, cwd: current.workspace, processOwnership, + sandboxPolicy, }) - const registered = await KernelProcessIdentity.ensureRegistered(kernel.process, processOwnership).catch( - async (error) => { - await value.manager.release(value.key).catch(() => undefined) - throw error - }, - ) + const registered = await KernelProcessIdentity.ensureRegistered(kernel.process, processOwnership) if (!registered) { - await value.manager.release(value.key).catch(() => undefined) throw new Error("Kernel manager did not expose a process for durable registration") } return kernel @@ -718,6 +786,7 @@ const entry = async (identity: KernelIdentity, options?: KernelStartOptions, han if (stale()) return abort() value.environment = kernel.environment ?? null value.process = kernel.process ?? null + value.ownershipID = kernel.process?.ownershipID ?? processOwnership.id value.authority = current value.startedAt = kernel.process?.startedAt ?? Date.now() value.lastActivityAt = value.startedAt @@ -733,11 +802,26 @@ const entry = async (identity: KernelIdentity, options?: KernelStartOptions, han return value }, async (error) => { + const failures: unknown[] = [] + const terminated = await KernelProcessIdentity.terminate(undefined, ticket.ownershipID).then( + () => { + if (value.ownershipID === ticket.ownershipID) value.ownershipID = null + return true + }, + (failure) => { + failures.push(failure) + return false + }, + ) + if (terminated) await value.manager.release(value.key).catch((failure) => failures.push(failure)) value.kernel = undefined value.authority = current value.state = ticket.cancelled ? "stopped" : "crashed" await persist(value) await releaseLease(value) + if (failures.length) { + throw new AggregateError([error, ...failures], "Kernel startup ownership cleanup failed") + } if (ticket.cancelled) throw new KernelStartupCancelled() throw error }, @@ -894,117 +978,119 @@ export namespace KernelRuntime { throw new Error("Kernel startup completed without a queued execution") } return execution.then( - async (result) => { - // The count belongs to this cell, so capture it before the awaits below. - // `value.executionCount` is the kernel's running total and every cell - // queued behind this one advances it — reading it back after the persist - // reported the count of whichever cell had most recently finished. - const count = result.executionCount ?? value.executionCount + 1 - value.executionCount = count - const completedAt = Date.now() - const startedAt = running.startedAt ?? completedAt - value.lastActivityAt = completedAt - const completeCell: KernelCell = { - ...(running.cell ?? cell(value)), - status: result.ok ? "succeeded" : "failed", - executionCount: count, - } - if (!value.lastCell || value.lastCell === running.cell) value.lastCell = completeCell - await persist(value) - scheduleIdle(value) - const complete = { ...result, executionCount: count } - await running.metricStart - const resources = - running.metricScope && kernel.process?.pid - ? await KernelMetrics.sampleAll(running.metricScope, [kernel.process.pid]) - .then((samples) => samples.get(kernel.process!.pid)) - .catch(() => undefined) - : undefined - const files = - running.fileRoot && running.fileStart - ? await running.fileStart - .then((before) => ExecutionFiles.changed(running.fileRoot!, before, completedAt)) - .catch(() => []) - : [] - const summary = complete.outputs.find((item) => item.type === "result")?.data?.["text/plain"] ?? "" - const fault = complete.outputs.find((item) => item.type === "error")?.error - await ExecutionHistory.complete(running.journal!, { - status: complete.ok ? "succeeded" : "failed", - completedAt, - summary, - stdout: complete.stdout, - stderr: complete.stderr, - error: fault?.traceback?.join("\n") ?? (fault ? `${fault.name}: ${fault.message}` : ""), - outputCount: complete.outputs.length, - resources, - files, - }) - const node = await provenance( - identity, - value, - code, - startedAt, - completedAt, - running.codeState, - options?.origin, - complete, - undefined, - resources, - complete.ok ? "succeeded" : "failed", - files, - running.sequence, - ) - await ExecutionHistory.link(running.journal!, node.id) - return { ...complete, provenanceID: node.id } - }, - async (error) => { - const completedAt = Date.now() - const startedAt = running.startedAt ?? completedAt - value.lastActivityAt = completedAt - const failedCell: KernelCell = { ...(running.cell ?? cell(value)), status: "failed" } - if (!value.lastCell || value.lastCell === running.cell) value.lastCell = failedCell - if (kernel.crashed) value.state = "crashed" - await persist(value) - scheduleIdle(value) - await running.metricStart - const resources = - running.metricScope && kernel.process?.pid - ? await KernelMetrics.sampleAll(running.metricScope, [kernel.process.pid]) - .then((samples) => samples.get(kernel.process!.pid)) - .catch(() => undefined) - : undefined - const files = - running.fileRoot && running.fileStart - ? await running.fileStart - .then((before) => ExecutionFiles.changed(running.fileRoot!, before, completedAt)) - .catch(() => []) - : [] - const status = options?.signal?.aborted ? "cancelled" : kernel.crashed ? "interrupted" : "failed" - await ExecutionHistory.complete(running.journal!, { - status, - completedAt, - error: error instanceof Error ? error.message : String(error), - resources, - files, - }) - const node = await provenance( - identity, - value, - code, - startedAt, - completedAt, - running.codeState, - options?.origin, - undefined, - error, - resources, - status, - files, - running.sequence, - ) - await ExecutionHistory.link(running.journal!, node.id) - throw new KernelExecutionError(error, node.id) - }, + (result) => + withLease(value, async () => { + // The count belongs to this cell, so capture it before the awaits below. + // `value.executionCount` is the kernel's running total and every cell + // queued behind this one advances it — reading it back after the persist + // reported the count of whichever cell had most recently finished. + const count = result.executionCount ?? value.executionCount + 1 + value.executionCount = count + const completedAt = Date.now() + const startedAt = running.startedAt ?? completedAt + value.lastActivityAt = completedAt + const completeCell: KernelCell = { + ...(running.cell ?? cell(value)), + status: result.ok ? "succeeded" : "failed", + executionCount: count, + } + if (!value.lastCell || value.lastCell === running.cell) value.lastCell = completeCell + await persist(value) + scheduleIdle(value) + const complete = { ...result, executionCount: count } + await running.metricStart + const resources = + running.metricScope && kernel.process?.pid + ? await KernelMetrics.sampleAll(running.metricScope, [kernel.process.pid]) + .then((samples) => samples.get(kernel.process!.pid)) + .catch(() => undefined) + : undefined + const files = + running.fileRoot && running.fileStart + ? await running.fileStart + .then((before) => ExecutionFiles.changed(running.fileRoot!, before, completedAt)) + .catch(() => []) + : [] + const summary = complete.outputs.find((item) => item.type === "result")?.data?.["text/plain"] ?? "" + const fault = complete.outputs.find((item) => item.type === "error")?.error + await ExecutionHistory.complete(running.journal!, { + status: complete.ok ? "succeeded" : "failed", + completedAt, + summary, + stdout: complete.stdout, + stderr: complete.stderr, + error: fault?.traceback?.join("\n") ?? (fault ? `${fault.name}: ${fault.message}` : ""), + outputCount: complete.outputs.length, + resources, + files, + }) + const node = await provenance( + identity, + value, + code, + startedAt, + completedAt, + running.codeState, + options?.origin, + complete, + undefined, + resources, + complete.ok ? "succeeded" : "failed", + files, + running.sequence, + ) + await ExecutionHistory.link(running.journal!, node.id) + return { ...complete, provenanceID: node.id } + }), + (error) => + withLease(value, async () => { + const completedAt = Date.now() + const startedAt = running.startedAt ?? completedAt + value.lastActivityAt = completedAt + const failedCell: KernelCell = { ...(running.cell ?? cell(value)), status: "failed" } + if (!value.lastCell || value.lastCell === running.cell) value.lastCell = failedCell + if (kernel.crashed) value.state = "crashed" + await persist(value) + scheduleIdle(value) + await running.metricStart + const resources = + running.metricScope && kernel.process?.pid + ? await KernelMetrics.sampleAll(running.metricScope, [kernel.process.pid]) + .then((samples) => samples.get(kernel.process!.pid)) + .catch(() => undefined) + : undefined + const files = + running.fileRoot && running.fileStart + ? await running.fileStart + .then((before) => ExecutionFiles.changed(running.fileRoot!, before, completedAt)) + .catch(() => []) + : [] + const status = options?.signal?.aborted ? "cancelled" : kernel.crashed ? "interrupted" : "failed" + await ExecutionHistory.complete(running.journal!, { + status, + completedAt, + error: error instanceof Error ? error.message : String(error), + resources, + files, + }) + const node = await provenance( + identity, + value, + code, + startedAt, + completedAt, + running.codeState, + options?.origin, + undefined, + error, + resources, + status, + files, + running.sequence, + ) + await ExecutionHistory.link(running.journal!, node.id) + throw new KernelExecutionError(error, node.id) + }), ) } diff --git a/backend/cli/src/science/kernel/types.ts b/backend/cli/src/science/kernel/types.ts index f77156cb..b5fa20fd 100644 --- a/backend/cli/src/science/kernel/types.ts +++ b/backend/cli/src/science/kernel/types.ts @@ -95,6 +95,15 @@ export interface ExecuteOptions { onStart?: () => void | Promise } +/** Sandbox policy captured by ExecutionAuthority at the final spawn boundary. + * Kernel managers consume this snapshot instead of re-reading mutable config. */ +export interface KernelSandboxPolicy { + readonly enabled: boolean + readonly network: "allow" | "deny" + readonly allowWrite: readonly string[] + readonly onUnavailable: "warn" | "error" | "allow" +} + export interface KernelStartOptions { /** Owning session, used to assemble its durable filesystem grants. */ sessionID?: string @@ -109,6 +118,9 @@ export interface KernelStartOptions { * Package mutations may contact package repositories even when ordinary * analysis runtimes inherit a deny-by-default project policy. */ sandboxNetwork?: "allow" | "deny" + /** Internal immutable sandbox snapshot authorized for this exact spawn. + * Registry-owned: callers cannot override the final authority decision. */ + sandboxPolicy?: KernelSandboxPolicy /** Interpreter binary override (e.g. a specific python/Rscript path). */ binary?: string /** Stable user-facing name for the selected interpreter environment. */ @@ -121,6 +133,8 @@ export interface KernelStartOptions { projectID: string sessionID: string authorityGeneration: string + /** Exact backend owner used by Linux's pre-exec subreaper gate. */ + linuxOwner?: { pid: number; identity: string } } } diff --git a/backend/cli/src/server/routes/settings/compute.ts b/backend/cli/src/server/routes/settings/compute.ts index d08a7c1c..d7f4ffa8 100644 --- a/backend/cli/src/server/routes/settings/compute.ts +++ b/backend/cli/src/server/routes/settings/compute.ts @@ -1,4 +1,5 @@ import { Hono, type Context } from "hono" +import { stream } from "hono/streaming" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" import crypto from "crypto" @@ -52,6 +53,29 @@ async function project(context: Context, fn: () => T): Promise { }) } +function modalDownloadDisposition(remote: string) { + const basename = + path.posix + .basename(remote) + .replace(/[\u0000-\u001f\u007f]/g, "_") + .trim() || "download" + const fallback = [...basename] + .map((character) => { + const code = character.charCodeAt(0) + return code >= 0x20 && code <= 0x7e && character !== '"' && character !== "\\" ? character : "_" + }) + .join("") + const extended = [...Buffer.from(basename, "utf8")] + .map((byte) => { + const character = String.fromCharCode(byte) + return /[A-Za-z0-9!#$&+.^_`|~-]/.test(character) + ? character + : `%${byte.toString(16).toUpperCase().padStart(2, "0")}` + }) + .join("") + return `attachment; filename="${fallback || "download"}"; filename*=UTF-8''${extended}` +} + // ── Compute settings store ────────────────────────────────────────────────── // // Durable backing store for the Compute settings panel — "where do runs @@ -770,24 +794,56 @@ export const ComputeSettingsRoutes = lazy(() => const entries = await ModalVolume.list(context, input.name, path.posix.dirname(query.path), false) const entry = entries.find((item) => item.path === query.path.replace(/^\/+/, "")) if (!entry || entry.type !== "file") return c.json({ error: "Modal Volume file not found" }, 404) - if (entry.size > 256 * 1024 * 1024) { - throw new HTTPException(400, { message: "Modal Volume browser downloads are limited to 256 MB." }) - } const staging = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-modal-volume-")) - const bytes = await ModalVolume.download(context, input.name, [entry.path], staging) - .then((files) => { + let cleanupPromise: Promise | undefined + const cleanup = () => (cleanupPromise ??= fs.rm(staging, { recursive: true, force: true })) + let handedOff = false + try { + const file = await ModalVolume.download(context, input.name, [entry.path], staging, { + signal: c.req.raw.signal, + declaredBytes: entry.size, + }).then((files) => { const file = files[0] if (!file) throw new Error(`Modal Volume did not download ${entry.path}`) - return Bun.file(file.staging).arrayBuffer() + return file }) - .finally(() => fs.rm(staging, { recursive: true, force: true })) - const filename = path.posix.basename(entry.path).replaceAll('"', "") || "download" - return new Response(bytes, { - headers: { - "content-type": "application/octet-stream", - "content-disposition": `attachment; filename="${filename}"`, - }, - }) + c.req.raw.signal.throwIfAborted() + c.header("content-type", "application/octet-stream") + c.header("content-disposition", modalDownloadDisposition(entry.path)) + c.header("content-length", String(file.size)) + const response = stream(c, async (output) => { + let reader: ReadableStreamDefaultReader | undefined + let cancelled: Promise | undefined + let complete = false + const abort = () => output.abort() + try { + reader = Bun.file(file.staging).stream().getReader() + output.onAbort(() => (cancelled ??= reader!.cancel().catch(() => undefined))) + if (c.req.raw.signal.aborted) abort() + else c.req.raw.signal.addEventListener("abort", abort, { once: true }) + while (!output.aborted) { + const next = await reader.read() + if (next.done) { + complete = true + break + } + await output.write(next.value) + } + } finally { + c.req.raw.signal.removeEventListener("abort", abort) + if (reader) { + if (!complete) cancelled ??= reader.cancel().catch(() => undefined) + await cancelled + reader.releaseLock() + } + await cleanup() + } + }) + handedOff = true + return response + } finally { + if (!handedOff) await cleanup() + } }, ) .post( diff --git a/backend/cli/src/server/routes/settings/local.ts b/backend/cli/src/server/routes/settings/local.ts index 3eb6e23c..34ef90b3 100644 --- a/backend/cli/src/server/routes/settings/local.ts +++ b/backend/cli/src/server/routes/settings/local.ts @@ -153,84 +153,86 @@ export namespace LocalRuntime { timeoutMs?: number }): Promise<{ alreadyRunning: boolean; value: T }> { await using lease = await FileLease.acquire(lockPath(input.id), (input.timeoutMs ?? 15_000) + 10_000) - const already = await input.probe() - if (already !== null) return { alreadyRunning: true, value: already } + return await lease.during(async () => { + const already = await input.probe() + if (already !== null) return { alreadyRunning: true, value: already } - const current = active.get(input.id) - if (current && !current.settled) await stopManaged(current) - const ledger = ledgerID(input.id) - // Recover exact ownership left by a killed prior server before replacing - // this stable runtime id. A second live server is serialized by the lease. - await CredentialProcessLedger.revoke({ id: ledger, kind: "local-runtime" }) + const current = active.get(input.id) + if (current && !current.settled) await stopManaged(current) + const ledger = ledgerID(input.id) + // Recover exact ownership left by a killed prior server before replacing + // this stable runtime id. A second live server is serialized by the lease. + await CredentialProcessLedger.revoke({ id: ledger, kind: "local-runtime" }) - const linuxOwner = - process.platform === "linux" - ? await ProcessIdentity.capture(process.pid).then((identity) => - identity ? { pid: process.pid, identity } : undefined, - ) - : undefined - if (process.platform === "linux" && !linuxOwner) { - throw new Error(`Could not capture the Linux server identity for local runtime ${input.id}`) - } - const wrapped = WindowsJobLauncher.wrap({ file: input.file, args: input.args, linuxOwner }) - const detached = process.platform !== "win32" - const child = spawn(wrapped.file, wrapped.args, { - env: environment(), - detached, - windowsHide: true, - stdio: "ignore", - }) - WindowsJobLauncher.bind(child, wrapped.release) - const managed: Managed = { id: input.id, ledger, child, detached, release: wrapped.release } - const completion = new Promise>((resolve) => { - child.once("error", (error) => resolve({ code: null, signal: null, error: error.message })) - child.once("close", (code, signal) => resolve({ code, signal })) - }) - try { - if (!child.pid) throw new Error(`Local runtime ${input.id} started without a process id`) - managed.identity = await CredentialProcessLedger.identity(child.pid) - if (!managed.identity) throw new Error(`Could not establish a safe identity for local runtime ${input.id}`) - const registered = await CredentialProcessLedger.register({ - id: ledger, - kind: "local-runtime", - pid: child.pid, + const linuxOwner = + process.platform === "linux" + ? await ProcessIdentity.capture(process.pid).then((identity) => + identity ? { pid: process.pid, identity } : undefined, + ) + : undefined + if (process.platform === "linux" && !linuxOwner) { + throw new Error(`Could not capture the Linux server identity for local runtime ${input.id}`) + } + const wrapped = WindowsJobLauncher.wrap({ file: input.file, args: input.args, linuxOwner }) + const detached = process.platform !== "win32" + const child = spawn(wrapped.file, wrapped.args, { + env: environment(), detached, - identity: managed.identity, - windowsRelease: wrapped.release, + windowsHide: true, + stdio: "ignore", }) - if (!registered) throw new Error(`Local runtime ${input.id} exited before durable ownership was established`) - if (process.platform === "linux" && wrapped.release) { - await WindowsJobLauncher.release(wrapped.release, child.pid) - } - active.set(input.id, managed) - void completion.then(async (settled) => { - managed.settled = settled - if (active.get(input.id) === managed) active.delete(input.id) - await complete(ledger).catch((error) => log.error("local runtime completion failed", { id: input.id, error })) - await cleanupGate(managed.release) + WindowsJobLauncher.bind(child, wrapped.release) + const managed: Managed = { id: input.id, ledger, child, detached, release: wrapped.release } + const completion = new Promise>((resolve) => { + child.once("error", (error) => resolve({ code: null, signal: null, error: error.message })) + child.once("close", (code, signal) => resolve({ code, signal })) }) - } catch (error) { - await stopManaged(managed).catch(() => undefined) - throw error - } - - const deadline = Date.now() + (input.timeoutMs ?? 15_000) - while (Date.now() < deadline) { - const value = await input.probe() - if (value !== null) { - // Do not report a daemonized/unowned endpoint as an OpenScience-managed - // start. The OS-owned supervisor must still be alive at handoff. - await Bun.sleep(100) - if (!managed.settled && active.get(input.id) === managed) return { alreadyRunning: false, value } + try { + if (!child.pid) throw new Error(`Local runtime ${input.id} started without a process id`) + managed.identity = await CredentialProcessLedger.identity(child.pid) + if (!managed.identity) throw new Error(`Could not establish a safe identity for local runtime ${input.id}`) + const registered = await CredentialProcessLedger.register({ + id: ledger, + kind: "local-runtime", + pid: child.pid, + detached, + identity: managed.identity, + windowsRelease: wrapped.release, + }) + if (!registered) throw new Error(`Local runtime ${input.id} exited before durable ownership was established`) + if (process.platform === "linux" && wrapped.release) { + await WindowsJobLauncher.release(wrapped.release, child.pid) + } + active.set(input.id, managed) + void completion.then(async (settled) => { + managed.settled = settled + if (active.get(input.id) === managed) active.delete(input.id) + await complete(ledger).catch((error) => log.error("local runtime completion failed", { id: input.id, error })) + await cleanupGate(managed.release) + }) + } catch (error) { + await stopManaged(managed).catch(() => undefined) + throw error } - if (managed.settled) { - const detail = managed.settled.error || `exit ${managed.settled.code ?? managed.settled.signal ?? "unknown"}` - throw new Error(`Local runtime ${input.id} did not remain under OpenScience ownership (${detail})`) + + const deadline = Date.now() + (input.timeoutMs ?? 15_000) + while (Date.now() < deadline) { + const value = await input.probe() + if (value !== null) { + // Do not report a daemonized/unowned endpoint as an OpenScience-managed + // start. The OS-owned supervisor must still be alive at handoff. + await Bun.sleep(100) + if (!managed.settled && active.get(input.id) === managed) return { alreadyRunning: false, value } + } + if (managed.settled) { + const detail = managed.settled.error || `exit ${managed.settled.code ?? managed.settled.signal ?? "unknown"}` + throw new Error(`Local runtime ${input.id} did not remain under OpenScience ownership (${detail})`) + } + await Bun.sleep(400) } - await Bun.sleep(400) - } - await stopManaged(managed) - throw new Error(`Local runtime ${input.id} did not answer within ${input.timeoutMs ?? 15_000}ms`) + await stopManaged(managed) + throw new Error(`Local runtime ${input.id} did not answer within ${input.timeoutMs ?? 15_000}ms`) + }) } } diff --git a/backend/cli/src/server/routes/settings/sandbox.ts b/backend/cli/src/server/routes/settings/sandbox.ts index 5b704a17..843110d3 100644 --- a/backend/cli/src/server/routes/settings/sandbox.ts +++ b/backend/cli/src/server/routes/settings/sandbox.ts @@ -13,6 +13,7 @@ const PatchSchema = z.object({ network: z.enum(["allow", "deny"]).optional(), allowWrite: z.array(z.string().trim().min(1).max(4096)).max(64).optional(), onUnavailable: z.enum(["warn", "error", "allow"]).optional(), + requireProjectTrust: z.boolean().optional(), }) async function currentConfig() { diff --git a/backend/cli/src/session/index.ts b/backend/cli/src/session/index.ts index db7fc1d7..10f93e03 100644 --- a/backend/cli/src/session/index.ts +++ b/backend/cli/src/session/index.ts @@ -427,59 +427,61 @@ export namespace Session { export const remove = fn(Identifier.schema("session"), async (sessionID) => { const project = Instance.project await using lease = await FileLease.acquire(deletionLock(project.id, sessionID), 60_000) - let pending = await deleting(project.id, sessionID) - const session = pending?.info ?? (await get(sessionID)) - if (!current(session)) bind(session) - try { - if (!pending) { - // Children must finish their own tombstone/reaper lifecycle before the - // parent becomes unroutable. - for (const child of await children(sessionID)) { - await remove(child.id) + return await lease.during(async () => { + let pending = await deleting(project.id, sessionID) + const session = pending?.info ?? (await get(sessionID)) + if (!current(session)) bind(session) + try { + if (!pending) { + // Children must finish their own tombstone/reaper lifecycle before the + // parent becomes unroutable. + for (const child of await children(sessionID)) { + await remove(child.id) + } + pending = { + version: 1, + info: session, + time: { created: Date.now() }, + } + // Publish the recovery record before any destructive mutation. A + // failed reaper or killed deleter can therefore retry by session id. + await Storage.write(deletionKey(project.id, sessionID), pending) } - pending = { - version: 1, + // Cancellation must be visible before deletion waits for the authority + // lease held by a booting kernel. Otherwise that boot can become ready, + // run its first cell, and only then be reaped by filesystem teardown. + KernelRuntime.cancelSession(sessionID) + await unshare(sessionID).catch(() => {}) + // Remove the routable session record before filesystem authority. A + // process start that wins the authority lease first is subsequently + // revoked; one that runs after filesystem removal cannot lazily recreate + // grants from a still-visible session record. The durable tombstone, + // unlike the old ordering, still makes cleanup retryable. + await Storage.remove(["session", project.id, sessionID]) + validated().delete(sessionID) + const signal = await SessionFilesystem.remove(sessionID) + await KernelRuntime.removeSession(project.id, sessionID) + await Bus.publish(Event.Deleted, { info: session, - time: { created: Date.now() }, - } - // Publish the recovery record before any destructive mutation. A - // failed reaper or killed deleter can therefore retry by session id. - await Storage.write(deletionKey(project.id, sessionID), pending) - } - // Cancellation must be visible before deletion waits for the authority - // lease held by a booting kernel. Otherwise that boot can become ready, - // run its first cell, and only then be reaped by filesystem teardown. - KernelRuntime.cancelSession(sessionID) - await unshare(sessionID).catch(() => {}) - // Remove the routable session record before filesystem authority. A - // process start that wins the authority lease first is subsequently - // revoked; one that runs after filesystem removal cannot lazily recreate - // grants from a still-visible session record. The durable tombstone, - // unlike the old ordering, still makes cleanup retryable. - await Storage.remove(["session", project.id, sessionID]) - validated().delete(sessionID) - const signal = await SessionFilesystem.remove(sessionID) - await KernelRuntime.removeSession(project.id, sessionID) - await Bus.publish(Event.Deleted, { - info: session, - }) - await AuthoritySignal.settle(signal.revision) - - // User data is erased only after every runtime reaper acknowledges the - // deletion. A crash during this phase leaves the tombstone last, so the - // remaining idempotent removals are retried on startup. - for (const msg of await Storage.list(["message", sessionID])) { - for (const part of await Storage.list(["part", msg.at(-1)!])) { - await Storage.remove(part) + }) + await AuthoritySignal.settle(signal.revision) + + // User data is erased only after every runtime reaper acknowledges the + // deletion. A crash during this phase leaves the tombstone last, so the + // remaining idempotent removals are retried on startup. + for (const msg of await Storage.list(["message", sessionID])) { + for (const part of await Storage.list(["part", msg.at(-1)!])) { + await Storage.remove(part) + } + await Storage.remove(msg) } - await Storage.remove(msg) + await SessionTraceStore.remove(sessionID) + await Storage.remove(deletionKey(project.id, sessionID)) + } catch (e) { + log.error(e) + throw e } - await SessionTraceStore.remove(sessionID) - await Storage.remove(deletionKey(project.id, sessionID)) - } catch (e) { - log.error(e) - throw e - } + }) }) /** Resume deletions whose durable tombstone outlived a failed/killed diff --git a/backend/cli/src/session/prompt/core.txt b/backend/cli/src/session/prompt/core.txt index 1f3b9ece..8646ef0c 100644 --- a/backend/cli/src/session/prompt/core.txt +++ b/backend/cli/src/session/prompt/core.txt @@ -47,13 +47,11 @@ verify, and save useful outputs with the smallest sufficient evidence. - Before paid API, model, or compute work, request approval with exact provider, scope, resources, expected duration, and estimated price. - Do not promise isolation, checkpointing, lifecycle, or recovery beyond the active runtime. -- Use WebFetch text mode for bounded text. For large or binary scientific data, set WebFetch - `output_path` to a workspace-root filename. Set `max_bytes` once from known size metadata, or use - the bounded default when size is unknown; never probe by incrementing the cap. Stream through - the broker into the session workspace, then verify and process it locally; do not - assume Shell has network access. Paginate large APIs. If a requested immutable release cannot be - retrieved and verified, disclose that constraint early and explicitly bound and label any - live-release fallback. +- Use WebFetch text mode for bounded text. Download large or binary scientific data to a root basename. For + `papers/foo.pdf`, use `output_path:"foo.pdf"`; only after success run sandboxed Bash + `mkdir -p -- 'papers' && test ! -e 'papers/foo.pdf' && mv -- 'foo.pdf' 'papers/foo.pdf'`. Never probe folder paths or send cap + or size-evidence fields; WebFetch uses live free disk minus its reserve. Paginate APIs; Shell need not have network. + If an immutable release cannot be verified, disclose it and label any live-release fallback. ## Outputs and review diff --git a/backend/cli/src/session/search-dedupe.ts b/backend/cli/src/session/search-dedupe.ts index 167ea7f2..5c1c72a7 100644 --- a/backend/cli/src/session/search-dedupe.ts +++ b/backend/cli/src/session/search-dedupe.ts @@ -24,14 +24,28 @@ export namespace SearchDedupe { return input.operation === "search" || input.operation === "ask" } + export function key(tool: string, value: unknown) { + const input = value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {} + if (!applies(tool, input)) return + return signature(input) + } + + function completedSignature(part: MessageV2.ToolPart & { state: MessageV2.ToolStateCompleted }) { + const stored = part.state.metadata.dedupeSignature + if (typeof stored === "string" && /^[a-f0-9]{64}$/.test(stored)) return stored + // Calls completed before canonical signatures were persisted retain the + // legacy exact-input behavior. Re-executing once is safer than applying + // today's schema defaults to an output produced under an older schema. + return signature(part.state.input) + } + export function find( messages: MessageV2.WithParts[], tool: string, value: unknown, ): (MessageV2.ToolPart & { state: MessageV2.ToolStateCompleted }) | undefined { - const input = value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {} - if (!applies(tool, input)) return - const expected = signature(input) + const expected = key(tool, value) + if (!expected) return return messages .flatMap((message) => message.parts) .filter( @@ -39,8 +53,7 @@ export namespace SearchDedupe { part.type === "tool" && part.state.status === "completed", ) .findLast( - (part) => - part.tool === tool && signature(part.state.input) === expected && part.state.metadata.dedupeHit !== true, + (part) => part.tool === tool && completedSignature(part) === expected && part.state.metadata.dedupeHit !== true, ) } diff --git a/backend/cli/src/session/tool-retry-guard.ts b/backend/cli/src/session/tool-retry-guard.ts index 39f2e645..ac7ffe76 100644 --- a/backend/cli/src/session/tool-retry-guard.ts +++ b/backend/cli/src/session/tool-retry-guard.ts @@ -11,6 +11,8 @@ type WebFetchFailure = { status_code?: 404 | 405 attempted_max_bytes?: number declared_size_bytes?: number + safe_capacity_bytes?: number + limit_kind?: "disk" | "legacy" } type KernelFailure = { @@ -40,33 +42,21 @@ class RetryGuardError extends Error { } } -type HistoryEvent = - | { - kind: "error" - at: number - tool: string - input: Record - error: string - failure?: Failure - callID?: string - } - | { - kind: "completed" - at: number - tool: string - input: Record - sizeEvidence: SizeEvidence - callID?: string - } - -type SizeEvidence = { - pairs: { normalizedURL: string; bytes: number }[] +type HistoryEvent = { + kind: "error" + at: number + tool: string + input: Record + error: string + failure?: Failure + callID?: string } type SessionHistory = { seeded: boolean events: Map contexts: WeakSet + webFetchMigrations: Set ordered?: HistoryEvent[] } @@ -81,7 +71,12 @@ function cache(sessionID: string) { sessionHistory.set(sessionID, found) return found } - const result: SessionHistory = { seeded: false, events: new Map(), contexts: new WeakSet() } + const result: SessionHistory = { + seeded: false, + events: new Map(), + contexts: new WeakSet(), + webFetchMigrations: new Set(), + } sessionHistory.set(sessionID, result) while (sessionHistory.size > SESSION_CACHE_LIMIT) sessionHistory.delete(sessionHistory.keys().next().value!) return result @@ -142,72 +137,6 @@ function stateTime(part: MessageV2.ToolPart) { return part.state.status === "running" ? part.state.time.start : part.state.time.end } -function exactSize(key: string, value: unknown) { - if (!/^(?:size|bytes|content[_ -]?length|contentLength)$/i.test(key)) return - const parsed = - typeof value === "number" ? value : typeof value === "string" && /^\d+$/.test(value) ? Number(value) : NaN - return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined -} - -function normalizedURL(value: unknown) { - if (typeof value !== "string" || !/^https?:\/\//i.test(value)) return - try { - return ToolRetryGuard.normalizeURL(value) - } catch { - return - } -} - -function extractSizeEvidence(output: string, metadata: Record): SizeEvidence { - const pairs = new Map() - const visit = (value: unknown) => { - if (Array.isArray(value)) { - for (const item of value) visit(item) - return - } - if (!value || typeof value !== "object") return - const entries = Object.entries(value as Record) - const urls = entries.flatMap(([, item]) => { - const url = normalizedURL(item) - return url ? [url] : [] - }) - const localSizes = entries.flatMap(([key, item]) => { - const size = exactSize(key, item) - return size === undefined ? [] : [size] - }) - // A record is evidence only when it binds one URL to one exact size. - // Never take a Cartesian product across a listing object containing - // multiple files and sizes; recurse so each unambiguous child record can - // still be cited independently. - if (urls.length === 1 && localSizes.length === 1) { - const url = urls[0]! - const size = localSizes[0]! - pairs.set(`${url}:${size}`, { normalizedURL: url, bytes: size }) - } - for (const [, item] of entries) visit(item) - } - visit(metadata) - try { - visit(JSON.parse(output)) - } catch { - for (const line of output.split("\n")) { - const urls = Array.from(line.matchAll(/https?:\/\/[^\s"'<>]+/gi), (match) => - normalizedURL(match[0].replace(/[),.;]+$/, "")), - ).filter((value): value is string => Boolean(value)) - const lineSizes = Array.from( - line.matchAll(/(?:size|bytes|content[_ -]?length)\s*[:=]?\s*(\d+)|\b(\d+)\s+bytes\b/gi), - (match) => Number(match[1] ?? match[2]), - ).filter((value) => Number.isSafeInteger(value) && value >= 0) - if (urls.length === 1 && lineSizes.length === 1) { - const url = urls[0]! - const size = lineSizes[0]! - pairs.set(`${url}:${size}`, { normalizedURL: url, bytes: size }) - } - } - } - return { pairs: [...pairs.values()] } -} - function messageEvents(messages: MessageV2.WithParts[]): HistoryEvent[] { const found = contextHistory.get(messages) if (found) return found @@ -215,27 +144,15 @@ function messageEvents(messages: MessageV2.WithParts[]): HistoryEvent[] { message.parts.flatMap((part): HistoryEvent[] => { if (part.type !== "tool" || part.state.status === "pending" || part.state.status === "running") return [] if (!["webfetch", "python", "notebook", "r", "rkernel"].includes(part.tool)) return [] - if (part.state.status === "error") { - return [ - { - kind: "error", - at: stateTime(part), - tool: part.tool, - input: part.state.input, - error: part.state.error, - failure: metadataFailure(part.state.metadata), - callID: part.callID, - }, - ] - } + if (part.state.status !== "error") return [] return [ { - kind: "completed", + kind: "error", at: stateTime(part), tool: part.tool, input: part.state.input, - sizeEvidence: - part.tool === "webfetch" ? extractSizeEvidence(part.state.output, part.state.metadata) : { pairs: [] }, + error: part.state.error, + failure: metadataFailure(part.state.metadata), callID: part.callID, }, ] @@ -292,13 +209,23 @@ export namespace ToolRetryGuard { function oldOversizeFailure(input: Record, error: string): WebFetchFailure | undefined { if (typeof input.url !== "string" || typeof input.output_path !== "string") return - if (!/Download exceeds max_bytes/i.test(error)) return + if ( + !/Download exceeds max_bytes|Download exceeds the current safe workspace capacity|Download could not continue because workspace storage returned/i.test( + error, + ) + ) + return + const diskCapacity = /(?:safe workspace capacity of|disk-derived workspace capacity is) [^(]*\((\d+) bytes\)/i.exec( + error, + )?.[1] return { version: 1, code: "webfetch_download_oversize", tool: "webfetch", normalized_url: normalizeURL(input.url), attempted_max_bytes: typeof input.max_bytes === "number" ? input.max_bytes : undefined, + safe_capacity_bytes: diskCapacity ? Number(diskCapacity) : undefined, + limit_kind: diskCapacity ? "disk" : "legacy", // Older builds emitted exact server Content-Length only for byte-sized // declared failures (`9 bytes > 8 bytes`). Rounded KiB/MiB and chunked // boundary messages are not exact evidence and intentionally stay unset. @@ -331,9 +258,8 @@ export namespace ToolRetryGuard { ) } - function sizeEvidence(event: HistoryEvent, callID: string, bytes: number, normalizedURL: string) { - if (event.kind !== "completed" || event.tool !== "webfetch" || event.callID !== callID) return false - return event.sizeEvidence.pairs.some((pair) => pair.normalizedURL === normalizedURL && pair.bytes === bytes) + function userTurnAfter(ctx: Tool.Context, at: number) { + return ctx.messages.some((message) => message.info.role === "user" && message.info.time.created > at) } export async function assertWebFetch( @@ -341,9 +267,6 @@ export namespace ToolRetryGuard { input: { url: string output_path?: string - max_bytes?: number - declared_size_bytes?: number - declared_size_evidence_call_id?: string }, ) { const normalized = normalizeURL(input.url) @@ -385,65 +308,50 @@ export namespace ToolRetryGuard { ) } - if (!input.output_path || input.declared_size_bytes === undefined) { - const oversize = failures.findLast((item) => item.failure.code === "webfetch_download_oversize") - if (!oversize || !input.output_path) return - const known = oversize.failure.declared_size_bytes + const oversize = failures.findLast((item) => item.failure.code === "webfetch_download_oversize") + if (!oversize || !input.output_path) return + // One migration attempt is safe for any pre-redesign max_bytes failure, + // including calls where the agent supplied that retired field. Record the + // allowance before network/permission work so an unrelated failure cannot + // turn migration into a same-turn loop. If the disk-policy call itself + // reaches capacity, its new disk failure also becomes the latest guard. + if (oversize.failure.limit_kind !== "disk") { + if (userTurnAfter(ctx, oversize.event.at)) return + const migration = `${normalized}:${eventKey(oversize.event)}` + const history = cache(ctx.sessionID) + if (!history.webFetchMigrations.has(migration)) { + history.webFetchMigrations.add(migration) + return + } throw blocked( { - code: "webfetch_download_size_required", + code: "webfetch_legacy_capacity_migration_used", tool: "webfetch", normalized_url: normalized, prior_call_id: oversize.event.callID, - attempted_max_bytes: oversize.failure.attempted_max_bytes, - known_declared_size_bytes: oversize.failure.declared_size_bytes, - }, - known !== undefined - ? "This URL already exceeded a download cap in this session, so another guessed max_bytes escalation was stopped before network access. " + - `The server previously declared exactly ${known} bytes. Retry at most once with ` + - `output_path: ${JSON.stringify(input.output_path)}, declared_size_bytes: ${known}, and max_bytes: ${known}. ` + - "These values come from the recorded Content-Length; do not substitute a guessed larger cap." - : "This URL already exceeded a download cap in this session, so another guessed max_bytes escalation was stopped before network access. " + - "Obtain the exact byte size from a metadata/listing endpoint, then retry at most once with max_bytes equal to declared_size_bytes and cite that completed call with declared_size_evidence_call_id. If no exact size evidence exists, choose a smaller or paginated source, or a different canonical download URL; do not probe with incrementally larger caps.", - ) - } - - if (!input.output_path) return - if (input.max_bytes === undefined || input.max_bytes < input.declared_size_bytes) { - throw blocked( - { - code: "webfetch_download_cap_below_declared_size", - tool: "webfetch", - normalized_url: normalized, - max_bytes: input.max_bytes, - declared_size_bytes: input.declared_size_bytes, + legacy_max_bytes: oversize.failure.attempted_max_bytes, }, - "max_bytes must be explicitly set to at least declared_size_bytes; this prevents a supposedly evidence-backed request from immediately repeating the same bounded failure.", + "This URL already used its one same-turn migration from the retired per-call cap policy to the live disk-derived policy. " + + "The repeat was stopped before permission or network access. Do not invent cap/evidence fields; wait for a new user turn or use a smaller or paginated source.", ) } - - const oversize = failures.findLast((item) => item.failure.code === "webfetch_download_oversize") - const known = oversize?.failure.declared_size_bytes - const cachedEvidence = known !== undefined && known === input.declared_size_bytes - const citedEvidence = - input.declared_size_evidence_call_id !== undefined && - history.some((event) => - sizeEvidence(event, input.declared_size_evidence_call_id!, input.declared_size_bytes!, normalized), - ) - if (cachedEvidence || citedEvidence) return - + // A later user turn may retry after actually freeing disk or selecting a + // different operational strategy. Within one assistant turn, repeated + // calls are stopped before permission and network access. + if (userTurnAfter(ctx, oversize.event.at)) return throw blocked( { - code: "webfetch_download_size_evidence_required", + code: "webfetch_download_capacity_strategy_required", tool: "webfetch", normalized_url: normalized, - declared_size_bytes: input.declared_size_bytes, - known_declared_size_bytes: known, - evidence_call_id: input.declared_size_evidence_call_id, + prior_call_id: oversize.event.callID, + safe_capacity_bytes: oversize.failure.safe_capacity_bytes, + legacy_max_bytes: oversize.failure.attempted_max_bytes, }, - known !== undefined - ? `declared_size_bytes must exactly match the server Content-Length already recorded for this URL (${known} bytes).` - : "declared_size_bytes needs auditable evidence. Supply declared_size_evidence_call_id for a completed WebFetch metadata response whose labelled size/content-length equals this exact byte value; an arbitrary larger number is not accepted.", + `This URL already exceeded the live safe workspace capacity${ + oversize.failure.safe_capacity_bytes === undefined ? "" : ` of ${oversize.failure.safe_capacity_bytes} bytes` + } in this assistant turn. The unchanged retry was stopped before permission or network access. ` + + "Do not invent a per-call byte cap or repeat the transfer. Use a smaller or paginated source, free disk space, a provider-native dataset client, or a dedicated approved transfer path.", ) } @@ -451,7 +359,7 @@ export namespace ToolRetryGuard { ctx: Tool.Context, input: Record & { url: string }, error: unknown, - details?: { attemptedMaxBytes?: number; declaredSizeBytes?: number }, + details?: { safeCapacityBytes?: number; declaredSizeBytes?: number }, ) { const message = text(error) const failure = @@ -463,9 +371,10 @@ export namespace ToolRetryGuard { code: "webfetch_download_oversize", tool: "webfetch", normalized_url: normalizeURL(input.url), - attempted_max_bytes: - details?.attemptedMaxBytes ?? (typeof input.max_bytes === "number" ? input.max_bytes : undefined), + attempted_max_bytes: typeof input.max_bytes === "number" ? input.max_bytes : undefined, declared_size_bytes: details?.declaredSizeBytes, + safe_capacity_bytes: details?.safeCapacityBytes, + limit_kind: details?.safeCapacityBytes !== undefined ? "disk" : "legacy", } satisfies WebFetchFailure) : undefined) if (!failure) return error instanceof Error ? error : new Error(message) @@ -484,23 +393,6 @@ export namespace ToolRetryGuard { return result } - export function recordWebFetchSuccess( - ctx: Tool.Context, - input: Record, - result: { output: string; metadata: Record }, - ) { - add(cache(ctx.sessionID), [ - { - kind: "completed", - at: Date.now(), - tool: "webfetch", - input, - sizeEvidence: extractSizeEvidence(result.output, result.metadata), - callID: ctx.callID, - }, - ]) - } - /** SessionProcessor persists this alongside ToolStateError. The public * Error.message stays human-readable; durable replay state never leaks into * error cards or provider-visible tool error text. */ diff --git a/backend/cli/src/settings/network.ts b/backend/cli/src/settings/network.ts index f5f3346a..4cdad4eb 100644 --- a/backend/cli/src/settings/network.ts +++ b/backend/cli/src/settings/network.ts @@ -4,6 +4,7 @@ import { BlockList, isIP } from "net" import { lookup } from "node:dns/promises" import { request as httpRequest } from "node:http" import { request as httpsRequest } from "node:https" +import { randomUUID } from "node:crypto" import { Readable } from "node:stream" import { domainToASCII } from "url" import z from "zod" @@ -11,6 +12,7 @@ import { Global } from "../global" import { Lock } from "../util/lock" import { Log } from "../util/log" import { DataRootBarrier } from "@/global/data-root-barrier" +import { FileLease } from "@/util/file-lease" // Outbound domain allow-list. A catalog of curated science/package domain // sets (each toggleable as a group) plus a validated list of custom domains. @@ -208,6 +210,10 @@ export namespace Network { const file = path.join(Global.Path.data, "settings", "network.json") const lock = "settings:network" + // Keep the cross-process lease outside the relocatable data root. Holding an + // in-root FileLease operation and then entering the barrier again to publish + // can deadlock with a relocation intent that lands between those two steps. + const mutationLease = path.join(Global.Path.config, "network-settings.lock") const version = 2 const legacyClinicalGroup = "clinical-pharma" const legacyClinicalCustom = "go.drugbank.com" @@ -336,38 +342,66 @@ export namespace Network { return denied() } - export async function get(): Promise { - const stored = await readStoredFile() - if (stored.kind === "missing") return defaults() - if (stored.kind === "unreadable") return invalidState(stored.error) + type EffectiveState = { kind: "resolved"; state: State } | { kind: "migrate"; state: State } + async function effectiveState(): Promise { + const stored = await readStoredFile() + if (stored.kind === "missing") return { kind: "resolved", state: defaults() } + if (stored.kind === "unreadable") return { kind: "resolved", state: invalidState(stored.error) } const decoded = decodeStoredState(stored.text) - if (decoded.kind === "current") return decoded.state - if (decoded.kind === "invalid") return invalidState(decoded.reason) - - // Migrations are serialized and re-read under the same lock used by set(), - // so concurrent readers cannot overwrite a newer explicit policy. - using _ = await Lock.write(lock) - const latest = await readStoredFile() - if (latest.kind === "missing") return defaults() - if (latest.kind === "unreadable") return invalidState(latest.error) - const current = decodeStoredState(latest.text) - if (current.kind === "current") return current.state - if (current.kind === "invalid") return invalidState(current.reason) - return persist(current.state) + if (decoded.kind === "current") return { kind: "resolved", state: decoded.state } + if (decoded.kind === "invalid") return { kind: "resolved", state: invalidState(decoded.reason) } + return decoded + } + + async function mutation(action: () => Promise): Promise { + return DataRootBarrier.during(file, async () => { + using _ = await Lock.write(lock) + await using lease = await FileLease.acquire(mutationLease) + return await lease.during(action) + }) + } + + async function effectiveStateUnderMutation(): Promise { + const current = await effectiveState() + return current.kind === "migrate" ? persist(current.state) : current.state + } + + export async function get(): Promise { + const current = await effectiveState() + if (current.kind === "resolved") return current.state + // Re-read after both the process-local lock and stable cross-process lease. + // A set/allow from another process may have replaced the legacy state while + // this caller waited and must never be overwritten by a stale migration. + return mutation(effectiveStateUnderMutation) } async function persist(state: State): Promise { await using operation = await DataRootBarrier.enter(file) await fs.mkdir(path.dirname(file), { recursive: true }) - await Bun.write(file, JSON.stringify({ version, ...state }, null, 2)) - return state + const temporary = `${file}.${process.pid}.${randomUUID()}.tmp` + try { + const handle = await fs.open(temporary, "wx", 0o600) + await handle + .writeFile(JSON.stringify({ version, ...state }, null, 2), "utf8") + .then(() => handle.sync()) + .finally(() => handle.close()) + await fs.rename(temporary, file) + // The file sync makes its contents durable; syncing the parent also + // makes the rename durable where the platform supports directory fsync. + const directory = await fs.open(path.dirname(file), "r").catch(() => undefined) + await directory?.sync().catch(() => undefined) + await directory?.close().catch(() => undefined) + return state + } catch (error) { + await fs.rm(temporary, { force: true }).catch(() => undefined) + throw error + } } export async function set(input: State): Promise { const state = State.parse(input) - using _ = await Lock.write(lock) - return persist(state) + return mutation(() => persist(state)) } // Effective flat list of allowed domains (enabled groups union custom). @@ -417,10 +451,11 @@ export namespace Network { * one another inside the backend process. */ export async function allow(domain: string): Promise { const host = canonicalDomain(domain) - using _ = await Lock.write(lock) - const state = await get() - if (domains(state).includes(host)) return state - return persist(State.parse({ ...state, custom: [...state.custom, host] })) + return mutation(async () => { + const state = await effectiveStateUnderMutation() + if (domains(state).includes(host)) return state + return persist(State.parse({ ...state, custom: [...state.custom, host] })) + }) } export interface FetchPolicy { diff --git a/backend/cli/src/tool/batch.ts b/backend/cli/src/tool/batch.ts index a41dee6e..5d4c1da8 100644 --- a/backend/cli/src/tool/batch.ts +++ b/backend/cli/src/tool/batch.ts @@ -5,20 +5,22 @@ import DESCRIPTION from "./batch.txt" const DISALLOWED = new Set(["batch"]) const FILTERED_FROM_SUGGESTIONS = new Set(["invalid", "patch", ...DISALLOWED]) -export const BatchTool = Tool.define("batch", async () => { +const BatchParameters = z.object({ + tool_calls: z + .array( + z.object({ + tool: z.string().describe("The name of the tool to execute"), + parameters: z.object({}).loose().describe("Parameters for the tool"), + }), + ) + .min(1, "Provide at least one tool call") + .describe("Array of tool calls to execute in parallel"), +}) + +export const BatchTool = Tool.define>("batch", async (initCtx) => { return { description: DESCRIPTION, - parameters: z.object({ - tool_calls: z - .array( - z.object({ - tool: z.string().describe("The name of the tool to execute"), - parameters: z.object({}).loose().describe("Parameters for the tool"), - }), - ) - .min(1, "Provide at least one tool call") - .describe("Array of tool calls to execute in parallel"), - }), + parameters: BatchParameters, formatValidationError(error) { const formattedErrors = error.issues .map((issue) => { @@ -37,7 +39,7 @@ export const BatchTool = Tool.define("batch", async () => { const discardedCalls = params.tool_calls.slice(25) const { ToolRegistry } = await import("./registry") - const availableTools = await ToolRegistry.tools({ modelID: "", providerID: "" }) + const availableTools = await ToolRegistry.tools({ modelID: "", providerID: "" }, initCtx?.agent) const toolMap = new Map(availableTools.map((t) => [t.id, t])) const aliases = new Set(["notebook", "rkernel"]) @@ -53,15 +55,14 @@ export const BatchTool = Tool.define("batch", async () => { } const tool = - toolMap.get(call.tool) ?? (aliases.has(call.tool) ? await ToolRegistry.resolve(call.tool) : undefined) + toolMap.get(call.tool) ?? + (aliases.has(call.tool) ? await ToolRegistry.resolve(call.tool, undefined, initCtx?.agent) : undefined) if (!tool) { const availableToolsList = Array.from(toolMap.keys()).filter((name) => !FILTERED_FROM_SUGGESTIONS.has(name)) throw new Error( `Tool '${call.tool}' not in registry. External tools (MCP, environment) cannot be batched - call them directly. Available tools: ${availableToolsList.join(", ")}`, ) } - const validatedParams = tool.parameters.parse(call.parameters) - await Session.updatePart({ id: partID, messageID: ctx.messageID, @@ -78,7 +79,10 @@ export const BatchTool = Tool.define("batch", async () => { }, }) - const result = await tool.execute(validatedParams, { ...ctx, callID: partID }) + // Tool.define owns normalization, canonical validation, defaults, + // dedupe, and tool-specific repair errors. Pre-parsing here bypasses + // that contract for batched calls (notably compute_job recovery). + const result = await tool.execute(call.parameters, { ...ctx, callID: partID }) await Session.updatePart({ id: partID, diff --git a/backend/cli/src/tool/compute-job.ts b/backend/cli/src/tool/compute-job.ts index c1d24e63..fe0e8cdb 100644 --- a/backend/cli/src/tool/compute-job.ts +++ b/backend/cli/src/tool/compute-job.ts @@ -4,44 +4,179 @@ import { Instance } from "@/project/instance" import { SessionFilesystem } from "@/session/filesystem" import { Tool } from "./tool" -const ComputeTarget = JobBroker.Target -const ComputeWorkload = z.object({ - name: z.string().trim().min(1).max(120), - purpose: z.string().trim().min(1).max(500), - command: z.string().trim().min(1).max(100_000), - cwd: z.string().trim().min(1).max(2_000).optional(), - target: ComputeTarget, - resources: JobBroker.Resources.optional(), - modules: z.array(z.string().trim().min(1).max(240)).max(64).optional(), - container: z.string().trim().min(1).max(2_000).optional(), - artifacts: z.array(z.string().trim().min(1).max(2_000)).max(100).optional(), - checkpoint: z.string().trim().min(1).max(2_000).optional(), - uploads: z.array(z.string().trim().min(1).max(2_000)).max(100).optional(), - packages: z.array(z.string().trim().min(1).max(500)).max(100).optional(), - image: z.string().trim().min(1).max(2_000).optional(), - gpu: z.string().trim().min(1).max(120).optional(), -}) +const COMPUTE_ACTIONS = [ + "targets", + "plan", + "start", + "list", + "status", + "logs", + "artifacts", + "cancel", + "retry_delivery", + "release", +] as const +type ComputeAction = (typeof COMPUTE_ACTIONS)[number] + +const ACTION_DESCRIPTIONS = { + targets: "Discover available local, saved SSH/scheduler, and Modal targets.", + plan: "Preview an immutable compute plan without dispatching it.", + start: "Create and dispatch a detached compute job after any required approval.", + list: "List project-scoped compute jobs, optionally filtered by status.", + status: "Inspect the latest state of one existing job.", + logs: "Read lifecycle events and bounded command output for one existing job.", + artifacts: "Inspect expected and delivered outputs for one existing job.", + cancel: "Stop one live job after dedicated approval.", + retry_delivery: "Retry delivery from retained Modal output without rerunning the command.", + release: "Discard retained remote resources after dedicated approval.", +} satisfies Record + +const ACTION_EXAMPLES = { + targets: '{"action":"targets"}', + plan: '{"action":"plan","name":"Environment probe","purpose":"Check the local runtime before starting work.","command":"python --version","target":{"kind":"local"}}', + start: + '{"action":"start","name":"Run analysis","purpose":"Produce the requested analysis output.","command":"python analysis.py","target":{"kind":"local"}}', + list: '{"action":"list","limit":20}', + status: '{"action":"status","job_id":"job_..."}', + logs: '{"action":"logs","job_id":"job_...","bytes":64000}', + artifacts: '{"action":"artifacts","job_id":"job_..."}', + cancel: '{"action":"cancel","job_id":"job_..."}', + retry_delivery: '{"action":"retry_delivery","job_id":"job_..."}', + release: '{"action":"release","job_id":"job_..."}', +} satisfies Record + +const ACTION_HELP = COMPUTE_ACTIONS.map( + (value) => `- ${value}: ${ACTION_DESCRIPTIONS[value]} Example: ${ACTION_EXAMPLES[value]}`, +).join("\n") + +function action(value: Value) { + return z.literal(value).describe(`${ACTION_DESCRIPTIONS[value]} Exact input: ${ACTION_EXAMPLES[value]}`) +} + +const ComputeTarget = JobBroker.Target.describe( + 'Pass a JSON object, never a quoted JSON string: {"kind":"local"}, {"kind":"modal"}, or {"kind":"ssh","host_id":"saved-host-id"}.', +) +const ComputeWorkload = z + .object({ + name: z.string().trim().min(1).max(120), + purpose: z.string().trim().min(1).max(500), + command: z.string().trim().min(1).max(100_000), + cwd: z.string().trim().min(1).max(2_000).optional(), + target: ComputeTarget, + resources: JobBroker.Resources.optional(), + modules: z.array(z.string().trim().min(1).max(240)).max(64).optional(), + container: z.string().trim().min(1).max(2_000).optional(), + artifacts: z.array(z.string().trim().min(1).max(2_000)).max(100).optional(), + checkpoint: z.string().trim().min(1).max(2_000).optional(), + uploads: z.array(z.string().trim().min(1).max(2_000)).max(100).optional(), + packages: z.array(z.string().trim().min(1).max(500)).max(100).optional(), + image: z.string().trim().min(1).max(2_000).optional(), + gpu: z.string().trim().min(1).max(120).optional(), + }) + .strict() + +export const ComputeJobParameters = z + .discriminatedUnion("action", [ + z + .object({ action: action("targets") }) + .strict() + .describe(ACTION_EXAMPLES.targets), + ComputeWorkload.extend({ action: action("plan") }).describe(ACTION_EXAMPLES.plan), + ComputeWorkload.extend({ action: action("start") }).describe(ACTION_EXAMPLES.start), + z + .object({ + action: action("list"), + status: JobBroker.Status.optional(), + limit: z.number().int().min(1).max(100).default(20), + }) + .strict() + .describe(ACTION_EXAMPLES.list), + z + .object({ action: action("status"), job_id: z.string().trim().min(1) }) + .strict() + .describe(ACTION_EXAMPLES.status), + z + .object({ + action: action("logs"), + job_id: z.string().trim().min(1), + bytes: z.number().int().min(1).max(256_000).default(64_000), + }) + .strict() + .describe(ACTION_EXAMPLES.logs), + z + .object({ action: action("artifacts"), job_id: z.string().trim().min(1) }) + .strict() + .describe(ACTION_EXAMPLES.artifacts), + z + .object({ action: action("cancel"), job_id: z.string().trim().min(1) }) + .strict() + .describe(ACTION_EXAMPLES.cancel), + z + .object({ action: action("retry_delivery"), job_id: z.string().trim().min(1) }) + .strict() + .describe(ACTION_EXAMPLES.retry_delivery), + z + .object({ action: action("release"), job_id: z.string().trim().min(1) }) + .strict() + .describe(ACTION_EXAMPLES.release), + ]) + .describe(`Select one action with the required action discriminator.\n${ACTION_HELP}`) + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function knownAction(value: unknown): value is ComputeAction { + return typeof value === "string" && (COMPUTE_ACTIONS as readonly string[]).includes(value) +} + +function normalizeInput(input: unknown): unknown { + if (!record(input)) return input + let output = input + let changed = false + const copy = () => { + if (!changed) output = { ...input } + changed = true + return output + } + + const operation = input.operation + const selected = input.action + if (knownAction(operation) && (selected === undefined || selected === operation)) { + const normalized = copy() + normalized.action = operation + delete normalized.operation + } + + const effective = changed ? output.action : selected + if ((effective === "plan" || effective === "start") && typeof output.target === "string") { + try { + const target = ComputeTarget.safeParse(JSON.parse(output.target)) + if (target.success) copy().target = target.data + } catch { + // Keep invalid strings unchanged so canonical validation can explain the error. + } + } + return output +} + +function formatValidationError(error: z.ZodError, input: unknown) { + const details = error.issues + .map((issue) => `- ${issue.path.length ? issue.path.join(".") : "input"}: ${issue.message}`) + .join("\n") + const selected = record(input) && knownAction(input.action) ? input.action : undefined + const examples = selected + ? `Copy-ready ${selected} shape (replace placeholder values only):\n${ACTION_EXAMPLES[selected]}` + : `Valid action values: ${COMPUTE_ACTIONS.join(", ")}\nCopy-ready action shapes:\n${ACTION_HELP}` -export const ComputeJobParameters = z.discriminatedUnion("action", [ - z.object({ action: z.literal("targets") }), - ComputeWorkload.extend({ action: z.literal("plan") }), - ComputeWorkload.extend({ action: z.literal("start") }), - z.object({ - action: z.literal("list"), - status: JobBroker.Status.optional(), - limit: z.number().int().min(1).max(100).default(20), - }), - z.object({ action: z.literal("status"), job_id: z.string().trim().min(1) }), - z.object({ - action: z.literal("logs"), - job_id: z.string().trim().min(1), - bytes: z.number().int().min(1).max(256_000).default(64_000), - }), - z.object({ action: z.literal("artifacts"), job_id: z.string().trim().min(1) }), - z.object({ action: z.literal("cancel"), job_id: z.string().trim().min(1) }), - z.object({ action: z.literal("retry_delivery"), job_id: z.string().trim().min(1) }), - z.object({ action: z.literal("release"), job_id: z.string().trim().min(1) }), -]) + return [ + "Invalid arguments for compute_job.", + details, + examples, + 'Use the field "action", not "operation". For plan/start, target must be a JSON object, not a quoted JSON string.', + 'Allowed targets: {"kind":"local"}, {"kind":"modal"}, or {"kind":"ssh","host_id":"saved-host-id"}.', + ].join("\n\n") +} type Input = z.infer type Metadata = { @@ -149,8 +284,12 @@ export function createComputeJobTool(base?: JobBroker.Options) { "Use list, status, logs, and artifacts for read-only checks; these never dispatch compute and never require paid-run approval.", "Use cancel to stop a live job, retry_delivery to harvest a retained Modal volume without rerunning the command, and release only when the user wants to discard retained remote resources.", "Never use a new modal dispatch to check an existing job. Never invoke the Modal SDK or CLI directly.", + 'Every call must use the "action" field (never "operation"). For plan/start, target is a nested object, never a JSON-encoded string.', + `Copy-ready action inputs:\n${ACTION_HELP}`, ].join("\n"), parameters: ComputeJobParameters, + normalizeInput, + formatValidationError, async execute(input: Input, ctx) { if (input.action === "targets") { const resolved = await options(ctx.sessionID, base) diff --git a/backend/cli/src/tool/notebook.ts b/backend/cli/src/tool/notebook.ts index 26bc328e..30ddff9c 100644 --- a/backend/cli/src/tool/notebook.ts +++ b/backend/cli/src/tool/notebook.ts @@ -7,7 +7,6 @@ import { accessSync, constants, mkdirSync, rmSync, statSync, unlinkSync } from " import { Shell } from "@/shell/shell" import { Instance } from "@/project/instance" import { OpenScience } from "@/openscience" -import { Config } from "@/config/config" import { SessionFilesystem } from "@/session/filesystem" import { Sandbox } from "@/sandbox/sandbox" import { KernelQueue } from "@/science/kernel/queue" @@ -304,6 +303,8 @@ class PythonKernel implements Kernel { async start(opts?: KernelStartOptions): Promise { if (this.ready) return + const policy = opts?.sandboxPolicy + if (!policy) throw new Error("Python kernel start is missing its authorized sandbox policy") this.intentional = false this.stderrTail = "" const scriptPath = path.join(os.tmpdir(), `openscience-pykernel-${this.id.slice(0, 8)}-${Date.now()}.py`) @@ -326,7 +327,6 @@ class PythonKernel implements Kernel { // Confine the kernel to the workspace when the execution sandbox is on: the // runtime runs arbitrary agent-authored code — the same threat model as the // bash tool — so it must not be able to escape the boundary bash respects. - const policy = await Config.trustedSandbox() const sandboxed = Sandbox.wrapArgv({ file: interpreter.binary, args: ["-u", scriptPath], @@ -334,7 +334,12 @@ class PythonKernel implements Kernel { readable, extraWritable: [scriptPath, configPath, cachePath, ...(opts?.extraWritable ?? [])], unreadable: OpenScience.kernelSensitivePaths(), - options: { ...policy, ...(opts?.sandboxNetwork ? { network: opts.sandboxNetwork } : {}) }, + options: { + enabled: policy.enabled, + network: opts?.sandboxNetwork ?? policy.network, + allowWrite: [...policy.allowWrite], + onUnavailable: policy.onUnavailable, + }, }) const cwd = opts?.cwd ?? (opts?.sessionID ? await SessionFilesystem.workspace(opts.sessionID) : Instance.directory) this.environment = { @@ -347,14 +352,18 @@ class PythonKernel implements Kernel { atlas: AtlasEnvironment, sandbox: { ...Sandbox.describe(), - requested: policy?.enabled === true, + requested: policy.enabled, enforced: sandboxed.sandboxed, backend: sandboxed.backend, - network: opts?.sandboxNetwork ?? policy?.network ?? "allow", + network: opts?.sandboxNetwork ?? policy.network, warning: sandboxed.warning, }, } - const wrapped = WindowsJobLauncher.wrap({ file: sandboxed.file, args: sandboxed.args }) + const wrapped = WindowsJobLauncher.wrap({ + file: sandboxed.file, + args: sandboxed.args, + linuxOwner: opts?.processOwnership?.linuxOwner, + }) let proc: ChildProcess try { proc = spawn(wrapped.file, wrapped.args, { @@ -375,6 +384,7 @@ class PythonKernel implements Kernel { // thrashing swap after an abort (#102). detached: process.platform !== "win32", }) + WindowsJobLauncher.bind(proc, wrapped.release) } catch (error) { Sandbox.cleanup(sandboxed) throw error @@ -383,15 +393,19 @@ class PythonKernel implements Kernel { proc.once("error", () => Sandbox.cleanup(sandboxed)) this.proc = proc this.process = KernelProcessIdentity.capture(proc) + const ownership = opts?.processOwnership ? { ...opts.processOwnership, windowsRelease: wrapped.release } : undefined try { - const ownership = opts?.processOwnership - ? { ...opts.processOwnership, windowsRelease: wrapped.release } - : undefined const registered = await KernelProcessIdentity.register(proc, ownership) if (!registered) throw new Error("Python kernel exited before durable process registration") this.process = registered + const complete = () => { + proc.off("exit", complete) + void KernelProcessIdentity.complete(registered).catch(() => undefined) + } + proc.once("exit", complete) + if (proc.exitCode !== null || proc.signalCode !== null) complete() } catch (error) { - await this.terminate(proc) + await this.terminate(proc, ownership?.id) throw error } proc.once("exit", () => { @@ -581,16 +595,21 @@ class PythonKernel implements Kernel { killSync(): void { this.intentional = true if (this.proc && KernelProcessIdentity.matches(this.proc, this.process)) { - Shell.killTreeSync(this.proc, { detached: process.platform !== "win32" }) + if (!KernelProcessIdentity.terminateSync(this.process)) { + Shell.killTreeSync(this.proc, { detached: process.platform !== "win32" }) + } } this.proc = undefined this.process = undefined this.cleanupScript() } - private terminate(proc: ChildProcess) { - if (!KernelProcessIdentity.matches(proc, this.process)) return Promise.resolve() - return Shell.killTree(proc, { exited: () => proc.exitCode !== null, detached: process.platform !== "win32" }) + private async terminate(proc: ChildProcess, pendingOwnershipID?: string) { + const identity = this.process + if (!KernelProcessIdentity.matches(proc, identity)) return + const stopped = await KernelProcessIdentity.terminate(identity, pendingOwnershipID) + if (stopped || !KernelProcessIdentity.matches(proc, identity)) return + await Shell.killTree(proc, { exited: () => proc.exitCode !== null, detached: process.platform !== "win32" }) } private cleanupScript(): void { diff --git a/backend/cli/src/tool/rkernel.ts b/backend/cli/src/tool/rkernel.ts index 2ac53068..9866cb87 100644 --- a/backend/cli/src/tool/rkernel.ts +++ b/backend/cli/src/tool/rkernel.ts @@ -7,7 +7,6 @@ import { accessSync, constants, mkdirSync, statSync, unlinkSync } from "fs" import { Shell } from "@/shell/shell" import { Instance } from "@/project/instance" import { OpenScience } from "@/openscience" -import { Config } from "@/config/config" import { SessionFilesystem } from "@/session/filesystem" import { Sandbox } from "@/sandbox/sandbox" import { KernelQueue } from "@/science/kernel/queue" @@ -241,6 +240,8 @@ class RKernel implements Kernel { async start(opts?: KernelStartOptions): Promise { if (this.ready) return + const policy = opts?.sandboxPolicy + if (!policy) throw new Error("R kernel start is missing its authorized sandbox policy") this.intentional = false this.stderrTail = "" const interpreter = await findRscript(opts?.binary) @@ -266,7 +267,6 @@ class RKernel implements Kernel { // Confine the kernel to the workspace when the execution sandbox is on: the R // kernel runs arbitrary agent-authored code — the same threat model as the // bash tool — so it must respect the same boundary. - const policy = await Config.trustedSandbox() const sandboxed = Sandbox.wrapArgv({ file: interpreter.binary, args: ["--vanilla", scriptPath], @@ -274,7 +274,12 @@ class RKernel implements Kernel { readable, extraWritable: [scriptPath, configPath, ...(opts?.extraWritable ?? [])], unreadable: OpenScience.kernelSensitivePaths(), - options: { ...policy, ...(opts?.sandboxNetwork ? { network: opts.sandboxNetwork } : {}) }, + options: { + enabled: policy.enabled, + network: opts?.sandboxNetwork ?? policy.network, + allowWrite: [...policy.allowWrite], + onUnavailable: policy.onUnavailable, + }, }) const cwd = opts?.cwd ?? (opts?.sessionID ? await SessionFilesystem.workspace(opts.sessionID) : Instance.directory) this.environment = { @@ -287,14 +292,18 @@ class RKernel implements Kernel { atlas: AtlasEnvironment, sandbox: { ...Sandbox.describe(), - requested: policy?.enabled === true, + requested: policy.enabled, enforced: sandboxed.sandboxed, backend: sandboxed.backend, - network: opts?.sandboxNetwork ?? policy?.network ?? "allow", + network: opts?.sandboxNetwork ?? policy.network, warning: sandboxed.warning, }, } - const wrapped = WindowsJobLauncher.wrap({ file: sandboxed.file, args: sandboxed.args }) + const wrapped = WindowsJobLauncher.wrap({ + file: sandboxed.file, + args: sandboxed.args, + linuxOwner: opts?.processOwnership?.linuxOwner, + }) let proc: ChildProcess try { proc = spawn(wrapped.file, wrapped.args, { @@ -308,6 +317,7 @@ class RKernel implements Kernel { // Own process group so killing the kernel reaps its worker children (#102). detached: process.platform !== "win32", }) + WindowsJobLauncher.bind(proc, wrapped.release) } catch (error) { Sandbox.cleanup(sandboxed) throw error @@ -316,15 +326,19 @@ class RKernel implements Kernel { proc.once("error", () => Sandbox.cleanup(sandboxed)) this.proc = proc this.process = KernelProcessIdentity.capture(proc) + const ownership = opts?.processOwnership ? { ...opts.processOwnership, windowsRelease: wrapped.release } : undefined try { - const ownership = opts?.processOwnership - ? { ...opts.processOwnership, windowsRelease: wrapped.release } - : undefined const registered = await KernelProcessIdentity.register(proc, ownership) if (!registered) throw new Error("R kernel exited before durable process registration") this.process = registered + const complete = () => { + proc.off("exit", complete) + void KernelProcessIdentity.complete(registered).catch(() => undefined) + } + proc.once("exit", complete) + if (proc.exitCode !== null || proc.signalCode !== null) complete() } catch (error) { - await this.terminate(proc) + await this.terminate(proc, ownership?.id) throw error } proc.once("exit", () => { @@ -461,16 +475,21 @@ class RKernel implements Kernel { killSync(): void { this.intentional = true if (this.proc && KernelProcessIdentity.matches(this.proc, this.process)) { - Shell.killTreeSync(this.proc, { detached: process.platform !== "win32" }) + if (!KernelProcessIdentity.terminateSync(this.process)) { + Shell.killTreeSync(this.proc, { detached: process.platform !== "win32" }) + } } this.proc = undefined this.process = undefined this.cleanupScript() } - private terminate(proc: ChildProcess) { - if (!KernelProcessIdentity.matches(proc, this.process)) return Promise.resolve() - return Shell.killTree(proc, { exited: () => proc.exitCode !== null, detached: process.platform !== "win32" }) + private async terminate(proc: ChildProcess, pendingOwnershipID?: string) { + const identity = this.process + if (!KernelProcessIdentity.matches(proc, identity)) return + const stopped = await KernelProcessIdentity.terminate(identity, pendingOwnershipID) + if (stopped || !KernelProcessIdentity.matches(proc, identity)) return + await Shell.killTree(proc, { exited: () => proc.exitCode !== null, detached: process.platform !== "win32" }) } private cleanupScript(): void { diff --git a/backend/cli/src/tool/task.txt b/backend/cli/src/tool/task.txt index 98248311..f29b0ec2 100644 --- a/backend/cli/src/tool/task.txt +++ b/backend/cli/src/tool/task.txt @@ -30,12 +30,11 @@ Rules: 6. A Task call blocks the lead until it returns. Issue genuinely independent calls together when parallelism is worthwhile. Continue a child only for new missing work, never merely to restate or reformat a result already returned. -7. WebFetch text mode is for bounded text and API responses. For large or binary scientific data, - set WebFetch `output_path` to a simple workspace-root filename. Set `max_bytes` once from known - size metadata, or omit it to use the bounded default when size is unknown. Never probe the same - URL by repeatedly increasing the cap. - Stream once through the authorized broker into the session workspace, verify its digest, and - process it locally. Paginate large APIs; do not assume Shell has network access. +7. WebFetch text mode is for bounded text and APIs. Download large or binary scientific data to a root basename. + For `papers/foo.pdf`, use `output_path:"foo.pdf"`; only after success run sandboxed Bash + `mkdir -p -- 'papers' && test ! -e 'papers/foo.pdf' && mv -- 'foo.pdf' 'papers/foo.pdf'`. Never probe folder paths or send retired + cap/size-evidence fields; WebFetch uses live free disk minus its reserve. Verify locally and paginate APIs; Shell + need not have network access. 8. If a requested immutable release cannot be retrieved and verified, report the constraint early. Stop that branch or explicitly bound and label any live-release fallback; never silently mix releases or spend repeated calls before disclosure. diff --git a/backend/cli/src/tool/tool.ts b/backend/cli/src/tool/tool.ts index 0c210872..333e4ddd 100644 --- a/backend/cli/src/tool/tool.ts +++ b/backend/cli/src/tool/tool.ts @@ -40,7 +40,8 @@ export namespace Tool { output: string attachments?: MessageV2.FilePart[] }> - formatValidationError?(error: z.ZodError): string + normalizeInput?(args: unknown): unknown + formatValidationError?(error: z.ZodError, args: unknown): string }> } @@ -58,30 +59,38 @@ export namespace Tool { const execute = toolInfo.execute toolInfo.execute = async (args, ctx) => { PlanMode.enforce(id, ctx.agent) + const normalized = toolInfo.normalizeInput ? toolInfo.normalizeInput(args) : args + let canonical: z.infer try { - toolInfo.parameters.parse(args) + // The parser's output is the public tool contract. Always execute + // and dedupe with it so defaults, transforms, stripped fields, and + // tool-specific normalization behave identically for direct and + // delegated calls. + canonical = toolInfo.parameters.parse(normalized) } catch (error) { if (error instanceof z.ZodError && toolInfo.formatValidationError) { - throw new Error(toolInfo.formatValidationError(error), { cause: error }) + throw new Error(toolInfo.formatValidationError(error, normalized), { cause: error }) } throw new Error( `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`, { cause: error }, ) } - const cached = SearchDedupe.find(ctx.messages, id, args) + const dedupeSignature = SearchDedupe.key(id, canonical) + const cached = SearchDedupe.find(ctx.messages, id, canonical) if (cached) return SearchDedupe.reuse(cached) as unknown as Awaited> - const result = await execute(args, ctx) + const result = await execute(canonical, ctx) + const metadata = dedupeSignature ? { ...result.metadata, dedupeSignature } : result.metadata // skip truncation for tools that handle it themselves if (result.metadata.truncated !== undefined) { - return result + return { ...result, metadata } } const truncated = await Truncate.output(result.output, { sessionID: ctx.sessionID }, initCtx?.agent) return { ...result, output: truncated.content, metadata: { - ...result.metadata, + ...metadata, truncated: truncated.truncated, ...(truncated.truncated && { outputPath: truncated.outputPath }), }, diff --git a/backend/cli/src/tool/webfetch.ts b/backend/cli/src/tool/webfetch.ts index ccebe101..3cacd2e2 100644 --- a/backend/cli/src/tool/webfetch.ts +++ b/backend/cli/src/tool/webfetch.ts @@ -17,9 +17,7 @@ const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds const MAX_TIMEOUT = 120 * 1000 // 2 minutes const DEFAULT_DOWNLOAD_TIMEOUT = 10 * 60 * 1000 // 10 minutes const MAX_DOWNLOAD_TIMEOUT = 30 * 60 * 1000 // 30 minutes -export const DEFAULT_DOWNLOAD_MAX_BYTES = 256 * 1024 * 1024 // 256 MiB -export const MAX_DOWNLOAD_MAX_BYTES = 2 * 1024 * 1024 * 1024 // 2 GiB -const DOWNLOAD_DISK_RESERVE_BYTES = 512 * 1024 * 1024 // preserve 512 MiB for the host +export const DOWNLOAD_DISK_RESERVE_BYTES = 512 * 1024 * 1024 // preserve 512 MiB for the host const parameters = z .object({ @@ -35,75 +33,38 @@ const parameters = z .optional(), output_path: z .string() - .optional() - .describe( - "Optional new filename at the root of this session's workspace. Streams the response to that file instead of returning its body. " + - "Use this for archives, compressed datasets, binary files, or text responses larger than 5 MiB.", - ), - max_bytes: z - .number() - .int() - .positive() - .max(MAX_DOWNLOAD_MAX_BYTES) - .optional() - .describe( - `Maximum allowed download size in bytes when output_path is set (default ${DEFAULT_DOWNLOAD_MAX_BYTES}; ` + - `hard maximum ${MAX_DOWNLOAD_MAX_BYTES}). Rejected before transfer when Content-Length exceeds it and during ` + - "streaming when the server omits Content-Length.", - ), - declared_size_bytes: z - .number() - .int() - .positive() - .max(MAX_DOWNLOAD_MAX_BYTES) - .optional() - .describe( - "Exact download size in bytes from evidence, used only after a prior max_bytes failure or for a one-shot known-size download. " + - "It must match the server Content-Length cached by WebFetch or a labelled size in declared_size_evidence_call_id.", - ), - declared_size_evidence_call_id: z - .string() - .trim() .min(1) - .max(256) + .refine((value) => value === value.trim(), "output_path must not be blank or have surrounding whitespace") .optional() .describe( - "Prior completed WebFetch call ID whose metadata response labels the exact declared_size_bytes. Not needed when the prior failure recorded server Content-Length.", + "Optional new filename at the root of this session's workspace. Folder paths are rejected so mutable intermediate " + + 'directories cannot redirect a brokered write. For papers/foo.pdf, download with output_path:"foo.pdf"; only after ' + + "success run sandboxed Bash: mkdir -p -- 'papers' && test ! -e 'papers/foo.pdf' && mv -- 'foo.pdf' 'papers/foo.pdf'. " + + "Use download mode for archives, " + + "compressed datasets, binary files, or text responses larger than 5 MiB.", ), }) - .superRefine((params, issue) => { - if (params.declared_size_bytes !== undefined && !params.output_path) { - issue.addIssue({ - code: "custom", - path: ["declared_size_bytes"], - message: "declared_size_bytes is only valid when output_path is set", - }) - } - if (params.declared_size_evidence_call_id !== undefined && params.declared_size_bytes === undefined) { - issue.addIssue({ - code: "custom", - path: ["declared_size_evidence_call_id"], - message: "declared_size_evidence_call_id requires declared_size_bytes", - }) - } - }) + .strict() export const WebFetchTool = Tool.define("webfetch", { description: DESCRIPTION, parameters, + // Parse defaults and strip retired max_bytes / declared-size fields from + // older callers. Download authority now comes only from live disk capacity. + normalizeInput(args) { + if (!args || typeof args !== "object" || Array.isArray(args)) return args + const result = { ...(args as Record) } + delete result.max_bytes + delete result.declared_size_bytes + delete result.declared_size_evidence_call_id + return result + }, async execute(params, ctx) { // Validate URL if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) { throw new Error("URL must start with http:// or https://") } - if (params.max_bytes !== undefined && !params.output_path) { - throw new Error("max_bytes is only valid when output_path is set") - } await ToolRetryGuard.assertWebFetch(ctx, params) - const complete = }>(result: T) => { - ToolRetryGuard.recordWebFetchSuccess(ctx, params, result) - return result - } // A domain outside the enforced allow-list asks instead of failing. // Answering "always" adds the domain to the persisted allow-list (visible // in Network settings); conversation/project scopes approve quietly on @@ -144,18 +105,25 @@ export const WebFetchTool = Tool.define("webfetch", { format: params.format, timeout: params.timeout, output_path: params.output_path, - max_bytes: params.max_bytes, - declared_size_bytes: params.declared_size_bytes, - declared_size_evidence_call_id: params.declared_size_evidence_call_id, }, }) - const download = params.output_path ? await resolveDownloadTarget(ctx.sessionID, params.output_path) : undefined + const download = + params.output_path !== undefined ? await resolveDownloadTarget(ctx.sessionID, params.output_path) : undefined + const downloadCapacity = download + ? await safeDownloadCapacity(download).catch((error) => { + if (error instanceof DownloadCapacityError) { + throw ToolRetryGuard.annotateWebFetch(ctx, params, error, { + safeCapacityBytes: error.safeCapacityBytes, + declaredSizeBytes: error.responseBytes, + }) + } + throw error + }) + : undefined const defaultTimeout = download ? DEFAULT_DOWNLOAD_TIMEOUT : DEFAULT_TIMEOUT const maxTimeout = download ? MAX_DOWNLOAD_TIMEOUT : MAX_TIMEOUT const timeout = Math.min((params.timeout ?? defaultTimeout / 1000) * 1000, maxTimeout) - const maxDownloadBytes = params.max_bytes ?? DEFAULT_DOWNLOAD_MAX_BYTES - const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), timeout) @@ -189,7 +157,7 @@ export const WebFetchTool = Tool.define("webfetch", { params.url, { signal, headers }, download - ? { authorize, streamResponse: true, maxResponseBytes: maxDownloadBytes } + ? { authorize, streamResponse: true, maxResponseBytes: downloadCapacity! } : { authorize, maxResponseBytes: MAX_RESPONSE_SIZE }, ) @@ -201,7 +169,7 @@ export const WebFetchTool = Tool.define("webfetch", { params.url, { signal, headers: { ...headers, "User-Agent": "openscience" } }, download - ? { authorize, streamResponse: true, maxResponseBytes: maxDownloadBytes } + ? { authorize, streamResponse: true, maxResponseBytes: downloadCapacity! } : { authorize, maxResponseBytes: MAX_RESPONSE_SIZE }, ) } @@ -234,22 +202,9 @@ export const WebFetchTool = Tool.define("webfetch", { contentLength: responseDeclaredBytes, }, } - if ( - download && - params.declared_size_bytes !== undefined && - responseDeclaredBytes !== undefined && - responseDeclaredBytes !== params.declared_size_bytes - ) { - await response.body?.cancel().catch(() => {}) - throw new Error( - `Server Content-Length (${responseDeclaredBytes} bytes) does not match declared_size_bytes ` + - `(${params.declared_size_bytes} bytes). No destination file was created; refresh the size evidence before retrying.`, - ) - } - if (download) { - const result = await streamDownload(response, download, maxDownloadBytes) - return complete({ + const result = await streamDownload(response, download, downloadCapacity!) + return { title: `Downloaded ${result.filename}`, output: [ "Downloaded through the authorized network broker into this session's workspace.", @@ -265,7 +220,7 @@ export const WebFetchTool = Tool.define("webfetch", { metadata: { download: { url: Network.finalURL(response) || params.url, ...result }, } as Record, - }) + } } const contentType = response.headers.get("content-type") || "" @@ -301,62 +256,64 @@ export const WebFetchTool = Tool.define("webfetch", { case "markdown": if (contentType.includes("text/html")) { const markdown = convertHTMLToMarkdown(content) - return complete({ + return { output: markdown, title, metadata: responseMetadata, - }) + } } - return complete({ + return { output: content, title, metadata: responseMetadata, - }) + } case "text": if (contentType.includes("text/html")) { const text = await extractTextFromHTML(content) - return complete({ + return { output: text, title, metadata: responseMetadata, - }) + } } - return complete({ + return { output: content, title, metadata: responseMetadata, - }) + } case "html": - return complete({ + return { output: content, title, metadata: responseMetadata, - }) + } default: - return complete({ + return { output: content, title, metadata: responseMetadata, - }) + } } } catch (error) { if (error instanceof Network.ResponseTooLargeError) { if (download) { - const failure = new Error( - `Download exceeds max_bytes (${formatBytes(error.declaredBytes ?? error.receivedBytes)} > ` + - `${formatBytes(error.limitBytes)}). No destination file was created. Choose a smaller source or explicitly ` + - "set max_bytes once from the declared size within the supported limit; do not retry with incremental caps.", - ) + const failure = downloadCapacityError(error.limitBytes, error.declaredBytes ?? error.receivedBytes) throw ToolRetryGuard.annotateWebFetch(ctx, params, failure, { - attemptedMaxBytes: error.limitBytes, + safeCapacityBytes: error.limitBytes, declaredSizeBytes: error.declaredBytes, }) } throw ToolRetryGuard.annotateWebFetch(ctx, params, responseTooLargeError(error)) } + if (error instanceof DownloadCapacityError) { + throw ToolRetryGuard.annotateWebFetch(ctx, params, error, { + safeCapacityBytes: error.safeCapacityBytes, + declaredSizeBytes: error.responseBytes, + }) + } if (controller.signal.aborted && !ctx.abort.aborted) { throw new Error( `Request timed out after ${timeout / 1000} seconds. Do not retry indefinitely; ` + @@ -392,6 +349,7 @@ function isTextualMime(mime: string) { function formatBytes(bytes: number | undefined) { if (bytes === undefined) return undefined + if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GiB` if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB` if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KiB` return `${bytes} bytes` @@ -399,9 +357,12 @@ function formatBytes(bytes: number | undefined) { function downloadGuidance() { return ( - "Do not repeat the same text-mode request. For a data file, call Web fetch again with output_path set to a simple " + - "workspace-root filename without directories; it will stream through the authorized network broker without entering model context. " + - "For a large JSON API response, request a smaller page and follow its pagination metadata." + "Do not repeat the same text-mode request. For a data file, call Web fetch again with a root-only filename such as " + + 'output_path:"foo.pdf"; it will stream through the authorized network broker without entering model context. ' + + "For a folder destination such as papers/foo.pdf, only after that download succeeds run sandboxed Bash: " + + "mkdir -p -- 'papers' && test ! -e 'papers/foo.pdf' && mv -- 'foo.pdf' 'papers/foo.pdf'. " + + "For a large JSON API response, request a smaller page " + + "and follow its pagination metadata. WebFetch derives download capacity from live free disk; never add download-cap or claimed-size override fields." ) } @@ -469,6 +430,76 @@ type DownloadTarget = { relative: string } +function exactBytes(bytes: number) { + return `${formatBytes(bytes)} (${bytes} bytes)` +} + +class DownloadCapacityError extends Error { + constructor( + readonly safeCapacityBytes: number, + readonly responseBytes?: number, + readonly storageCode?: "ENOSPC" | "EDQUOT", + ) { + const observedText = responseBytes === undefined ? "" : `; response size ${exactBytes(responseBytes)}` + const heading = storageCode + ? `Download could not continue because workspace storage returned ${storageCode}. ` + + `The current disk-derived workspace capacity is ${exactBytes(safeCapacityBytes)}${observedText}. ` + : `Download exceeds the current safe workspace capacity of ${exactBytes(safeCapacityBytes)}${observedText}. ` + super( + heading + + `This capacity is computed from live free disk minus the ${exactBytes(DOWNLOAD_DISK_RESERVE_BYTES)} host reserve. ` + + "No destination file was created. Use a smaller or paginated source, free disk space, a provider-native dataset " + + "client, or a dedicated approved transfer path. Do not retry the unchanged URL without changing that strategy.", + ) + this.name = "DownloadCapacityError" + } +} + +function downloadCapacityError(safeCapacityBytes: number, responseBytes?: number, storageCode?: "ENOSPC" | "EDQUOT") { + return new DownloadCapacityError(safeCapacityBytes, responseBytes, storageCode) +} + +function storageCapacityCode(error: unknown) { + const code = (error as NodeJS.ErrnoException | undefined)?.code + return code === "ENOSPC" || code === "EDQUOT" ? code : undefined +} + +async function availableDownloadBytes(target: DownloadTarget) { + const disk = await fs.statfs(target.root) + const available = disk.bavail * disk.bsize + if (!Number.isSafeInteger(available) || available < 0) { + throw new Error("Workspace disk capacity could not be represented safely; the download was not started") + } + return Math.max(0, available - DOWNLOAD_DISK_RESERVE_BYTES) +} + +async function safeDownloadCapacity(target: DownloadTarget) { + const capacity = await availableDownloadBytes(target) + if (capacity > 0) return capacity + throw downloadCapacityError(capacity) +} + +function shellQuote(value: string) { + return `'${value.replaceAll("'", `'\\''`)}'` +} + +function folderDestinationGuidance(requested: string) { + // WebFetch documents slash-separated workspace paths even on platforms + // whose native separator differs. Only offer a copy-ready move for a + // canonical relative spelling; traversal and ambiguous spellings fail with + // the generic root-only error below. + const parts = requested.split("/") + if (parts.length < 2 || parts.some((part) => !part || part === "." || part === "..")) return + const filename = parts.at(-1)! + const folder = parts.slice(0, -1).join("/") + const destination = parts.join("/") + return ( + "output_path is root-only by design: brokered downloads must not traverse mutable intermediate directories. " + + `Retry with output_path:${JSON.stringify(filename)}. Only after that download succeeds, run sandboxed Bash from the workspace: ` + + `mkdir -p -- ${shellQuote(folder)} && test ! -e ${shellQuote(destination)} && mv -- ${shellQuote(filename)} ${shellQuote(destination)}` + ) +} + async function resolveDownloadTarget(sessionID: string, requested: string): Promise { if (!requested || requested !== requested.trim() || requested.includes("\0")) { throw new Error("output_path must be a non-empty workspace-root filename without surrounding whitespace") @@ -477,6 +508,8 @@ async function resolveDownloadTarget(sessionID: string, requested: string): Prom throw new Error("output_path must be a workspace-root filename, not an absolute path") } if (requested !== path.basename(requested)) { + const guidance = folderDestinationGuidance(requested) + if (guidance) throw new Error(guidance) throw new Error("output_path must be a filename at the root of this session's workspace, without directories") } @@ -580,25 +613,30 @@ function validateDownloadedFormat(target: DownloadTarget, response: Response, pr } } -async function assertDownloadCapacity(response: Response, target: DownloadTarget, maxBytes: number) { - const disk = await fs.statfs(target.root) - const available = disk.bavail * disk.bsize +async function refreshDownloadCapacity(response: Response, target: DownloadTarget, initialCapacity: number) { + const currentCapacity = await safeDownloadCapacity(target) + const capacity = Math.min(initialCapacity, currentCapacity) const declared = parseContentLength(response.headers.get("content-length")) - const required = declared ?? maxBytes - const usable = Math.max(0, available - DOWNLOAD_DISK_RESERVE_BYTES) - if (required <= usable) return - throw new Error( - `Insufficient workspace disk for download: ${formatBytes(required)} may be written, but only ` + - `${formatBytes(usable)} is available after the ${formatBytes(DOWNLOAD_DISK_RESERVE_BYTES)} safety reserve. ` + - "Choose a smaller source, lower max_bytes, or free disk space.", - ) + if (declared !== undefined && declared > capacity) throw downloadCapacityError(capacity, declared) + return capacity +} + +async function refreshStreamingCapacity(target: DownloadTarget, initialCapacity: number, bytesWritten: number) { + const available = await availableDownloadBytes(target) + // statfs already reflects bytes written to the staged file. Add those bytes + // back only to express a total-transfer ceiling, and never grow beyond the + // initial snapshot. Rechecking before every write preserves the host reserve + // if another process consumes disk while an unknown/chunked body is flowing. + const remainingInitialBudget = Math.max(0, initialCapacity - bytesWritten) + return bytesWritten + Math.min(remainingInitialBudget, available) } -async function streamDownload(response: Response, target: DownloadTarget, maxBytes: number) { - await assertDownloadTarget(target) - await SafeFileIO.absent(target.path) +async function streamDownload(response: Response, target: DownloadTarget, initialCapacity: number) { + let capacity: number try { - await assertDownloadCapacity(response, target, maxBytes) + await assertDownloadTarget(target) + await SafeFileIO.absent(target.path) + capacity = await refreshDownloadCapacity(response, target, initialCapacity) } catch (error) { await response.body?.cancel().catch(() => {}) throw error @@ -608,7 +646,17 @@ async function streamDownload(response: Response, target: DownloadTarget, maxByt // output_path contract, a concurrent runtime cannot swap an intermediate // directory to redirect either the temporary write or final hard-link. const staged = path.join(path.dirname(target.root), `.openscience-download-${crypto.randomUUID()}.tmp`) - const handle = await fs.open(staged, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL | FS.O_NOFOLLOW, 0o644) + let handle: fs.FileHandle + try { + handle = await fs.open(staged, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL | FS.O_NOFOLLOW, 0o644) + } catch (error) { + await response.body?.cancel().catch(() => {}) + await fs.rm(staged, { force: true }).catch(() => {}) + const storageCode = storageCapacityCode(error) + if (!storageCode) throw error + const liveCapacity = await refreshStreamingCapacity(target, initialCapacity, 0).catch(() => capacity) + throw downloadCapacityError(liveCapacity, undefined, storageCode) + } const hash = crypto.createHash("sha256") let bytes = 0 const prefix = new Uint8Array(512) @@ -620,14 +668,10 @@ async function streamDownload(response: Response, target: DownloadTarget, maxByt while (true) { const next = await reader.read() if (next.done) break - if (bytes + next.value.byteLength > maxBytes) { + capacity = await refreshStreamingCapacity(target, initialCapacity, bytes) + if (bytes + next.value.byteLength > capacity) { await reader.cancel().catch(() => {}) - throw new Error( - `Download exceeds max_bytes (${formatBytes(maxBytes)}). Partial data was discarded; ` + - "use a metadata/listing endpoint to obtain the exact byte size for one evidence-backed retry, " + - "choose a smaller or paginated source, or use a different canonical download URL. " + - "Do not retry this URL with incrementally larger caps.", - ) + throw downloadCapacityError(capacity, bytes + next.value.byteLength) } await writeChunk(handle, next.value) if (prefixBytes < prefix.byteLength) { @@ -670,6 +714,11 @@ async function streamDownload(response: Response, target: DownloadTarget, maxByt sha256: hash.digest("hex"), contentType: response.headers.get("content-type") ?? "", } + } catch (error) { + const storageCode = storageCapacityCode(error) + if (!storageCode) throw error + const liveCapacity = await refreshStreamingCapacity(target, initialCapacity, bytes).catch(() => capacity) + throw downloadCapacityError(liveCapacity, bytes || undefined, storageCode) } finally { await handle.close().catch(() => {}) await fs.rm(staged, { force: true }) diff --git a/backend/cli/src/tool/webfetch.txt b/backend/cli/src/tool/webfetch.txt index 70d3ace4..7b28d73e 100644 --- a/backend/cli/src/tool/webfetch.txt +++ b/backend/cli/src/tool/webfetch.txt @@ -9,8 +9,9 @@ Usage notes: - The URL must be a fully-formed valid URL - HTTP and HTTPS URLs are supported - Format options: "markdown" (default), "text", or "html" - - Text mode is read-only. Download mode writes only the new `output_path` filename at the root of this session's authorized workspace and refuses to overwrite existing files. + - Text mode is read-only. Download mode writes only the new `output_path` filename at the root of this session's authorized workspace and refuses to overwrite existing files. This root-only rule prevents a mutable intermediate directory from redirecting a brokered write. - Text responses are limited to 5 MiB. Longer results within that limit may be shown as a preview while the full text is retained in managed tool-output storage. - - Use text mode for bounded pages and API responses. Set `output_path` to a simple workspace-root filename without directories for archives, compressed datasets, binary files, or larger text; the response streams through the authorized network broker and never enters model context. If metadata gives an exact size, set `max_bytes` once just above it. If size is unknown, omit `max_bytes` to use the bounded 256 MiB default. Never probe one URL by repeatedly increasing the cap. Downloads can never exceed 2 GiB and preserve a host-disk safety reserve. + - Use text mode for bounded pages and API responses. For archives, compressed datasets, binary files, or larger text, use a root filename with no directories. For `papers/foo.pdf`, call WebFetch with `output_path:"foo.pdf"`; only after that download succeeds run sandboxed Bash: `mkdir -p -- 'papers' && test ! -e 'papers/foo.pdf' && mv -- 'foo.pdf' 'papers/foo.pdf'`. Never submit `papers/foo.pdf` as `output_path` or probe nested paths. The response streams through the authorized network broker and never enters model context. + - WebFetch derives one safe download capacity from live free disk minus a 512 MiB host reserve. A declared Content-Length is accepted automatically when it fits; an unknown or understated body is streamed only to the same byte capacity. Do not send download-cap or claimed-size override fields. On a capacity error, use a smaller/paginated source, free disk, a provider-native client, or a dedicated approved transfer instead of retrying unchanged. - Downloads refuse to overwrite an existing file and return the destination filename, byte count, SHA-256 digest, and content type. - For large JSON APIs that support it, prefer a small page and follow pagination metadata. Do not retry a 404 or 405 with the same URL. diff --git a/backend/cli/src/util/file-lease.ts b/backend/cli/src/util/file-lease.ts index 24865653..392aa077 100644 --- a/backend/cli/src/util/file-lease.ts +++ b/backend/cli/src/util/file-lease.ts @@ -12,6 +12,10 @@ export namespace FileLease { created: number } + export interface Lease extends AsyncDisposable { + during(action: () => Promise): Promise + } + function running(pid: number) { try { process.kill(pid, 0) @@ -49,7 +53,7 @@ export namespace FileLease { return !!stat && Date.now() - stat.mtimeMs > grace } - export async function acquire(filepath: string, timeoutMs = timeout): Promise { + export async function acquire(filepath: string, timeoutMs = timeout): Promise { const operation = await DataRootBarrier.enter(filepath, timeoutMs) try { let blockedAt = Date.now() @@ -105,16 +109,45 @@ export namespace FileLease { await fs.rm(filepath, { force: true }).catch(() => undefined) throw error }) + let closing = false + let uses = 0 + let drained: (() => void) | undefined + let disposal: Promise | undefined + + const releaseUse = () => { + uses-- + if (uses) return + const resolve = drained + drained = undefined + resolve?.() + } + + const drain = async () => { + if (!uses) return + await new Promise((resolve) => (drained = resolve)) + } + return { - async [Symbol.asyncDispose]() { - await handle.close().catch(() => undefined) - const owner = await Bun.file(filepath) - .json() - .catch(() => undefined) - if (owner && typeof owner === "object" && "token" in owner && owner.token === token) { - await fs.rm(filepath, { force: true }).catch(() => undefined) - } - await Promise.resolve(operation[Symbol.asyncDispose]()).catch(() => undefined) + during(action: () => Promise) { + if (closing) return Promise.reject(new Error("Cannot scope work under a closing file lease")) + uses++ + return operation.during(action).finally(releaseUse) + }, + [Symbol.asyncDispose]() { + if (disposal) return disposal + closing = true + disposal = (async () => { + await drain() + await handle.close().catch(() => undefined) + const owner = await Bun.file(filepath) + .json() + .catch(() => undefined) + if (owner && typeof owner === "object" && "token" in owner && owner.token === token) { + await fs.rm(filepath, { force: true }).catch(() => undefined) + } + await Promise.resolve(operation[Symbol.asyncDispose]()).catch(() => undefined) + })() + return disposal }, } } catch (error) { diff --git a/backend/cli/test/agent/harness-contract.test.ts b/backend/cli/test/agent/harness-contract.test.ts index 649d465f..1c83ff46 100644 --- a/backend/cli/test/agent/harness-contract.test.ts +++ b/backend/cli/test/agent/harness-contract.test.ts @@ -10,6 +10,25 @@ import { const root = new URL("../../src/", import.meta.url) const read = (path: string) => Bun.file(new URL(path, root)).text() +const webFetchFolderDownload = 'output_path:"foo.pdf"' +const webFetchFolderMove = "mkdir -p -- 'papers' && test ! -e 'papers/foo.pdf' && mv -- 'foo.pdf' 'papers/foo.pdf'" + +test("every WebFetch instruction teaches one root-download then sandboxed-move sequence", async () => { + const prompts = await Promise.all([ + read("session/prompt/core.txt"), + read("agent/prompt/research.txt"), + read("tool/task.txt"), + read("tool/webfetch.txt"), + ]) + for (const prompt of prompts) { + expect(prompt).toContain(webFetchFolderDownload) + expect(prompt).toContain(webFetchFolderMove) + expect(prompt).toContain("only after") + expect(prompt).toContain("live free disk") + expect(prompt).not.toContain("max_bytes") + expect(prompt).not.toContain("declared_size") + } +}) test("every provider receives one compact product operating contract", () => { const instructions = SystemPrompt.instructions() diff --git a/backend/cli/test/cli/data-root-scope.test.ts b/backend/cli/test/cli/data-root-scope.test.ts new file mode 100644 index 00000000..9ae0a4b3 --- /dev/null +++ b/backend/cli/test/cli/data-root-scope.test.ts @@ -0,0 +1,151 @@ +import { afterEach, expect, test } from "bun:test" +import fs from "node:fs/promises" +import path from "node:path" +import yargs from "yargs" +import { cmd, disposeDataRootOperation, runDataRootMiddleware } from "@/cli/cmd/cmd" +import { DataRoot } from "@/global/data-root" +import { DataRootBarrier } from "@/global/data-root-barrier" +import { tmpdir } from "../fixture/fixture" + +async function waitForFile(filepath: string) { + const deadline = Date.now() + 2_000 + while (!(await Bun.file(filepath).exists())) { + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${filepath}`) + await Bun.sleep(10) + } +} + +async function markers(config: string) { + return fs.readdir(path.join(config, "data-root-operations")).catch(() => [] as string[]) +} + +afterEach(async () => { + await disposeDataRootOperation().catch(() => undefined) +}) + +test("a string option before the command still scopes parsed middleware and the complete handler", async () => { + await using tmp = await tmpdir() + const config = path.join(tmp.path, "config") + const managed = await DataRoot.ensure(config, path.join(tmp.path, "data"), false) + DataRootBarrier.configure({ root: managed.path, config }) + const handlerReady = Promise.withResolvers() + const startNested = Promise.withResolvers() + const nestedReady = Promise.withResolvers() + let middlewareCommand: string | undefined + + const command = cmd<{}, { model?: string }>({ + command: "run", + builder: (parser) => parser.option("model", { type: "string" }), + handler: async (args) => { + expect(args.model).toBe("provider/model") + handlerReady.resolve() + await startNested.promise + await using nested = await DataRootBarrier.enter(path.join(managed.path, "nested.json"), 2_000) + nestedReady.resolve() + void nested + }, + }) + const parser = yargs(["--model", "provider/model", "run"]) + .exitProcess(false) + .middleware(async (args) => { + middlewareCommand = typeof args._[0] === "string" ? args._[0] : undefined + await runDataRootMiddleware(middlewareCommand, managed.path, async () => undefined, 2_000) + }) + .command(command) + .strict() + const parsing = Promise.resolve(parser.parse()) + let switching: Promise | undefined + + try { + await handlerReady.promise + expect(middlewareCommand).toBe("run") + switching = DataRootBarrier.exclusive(2_000) + await waitForFile(path.join(config, "data-root-switch.intent")) + startNested.resolve() + expect(await Promise.race([nestedReady.promise.then(() => true), Bun.sleep(250).then(() => false)])).toBe(true) + await parsing + expect(await Promise.race([switching.then(() => true), Bun.sleep(50).then(() => false)])).toBe(false) + await disposeDataRootOperation() + const exclusive = await switching + await exclusive[Symbol.asyncDispose]() + } finally { + startNested.resolve() + await parsing.catch(() => undefined) + await disposeDataRootOperation().catch(() => undefined) + const exclusive = await switching?.catch(() => undefined) + await exclusive?.[Symbol.asyncDispose]() + } +}) + +test("shell completion is classified as a short-lived data-root command", async () => { + await using tmp = await tmpdir() + const config = path.join(tmp.path, "config") + const managed = await DataRoot.ensure(config, path.join(tmp.path, "data"), false) + DataRootBarrier.configure({ root: managed.path, config }) + + await runDataRootMiddleware("completion", managed.path, async () => { + expect(await markers(config)).toHaveLength(1) + }) + expect(await markers(config)).toHaveLength(1) + await disposeDataRootOperation() + expect(await markers(config)).toHaveLength(0) +}) + +for (const [canonical, alias] of [ + ["tools", "mcp"], + ["model", "models"], + ["keys", "auth"], +] as const) { + test(`top-level alias ${alias} receives the same complete data-root scope as ${canonical}`, async () => { + await using tmp = await tmpdir() + const config = path.join(tmp.path, "config") + const managed = await DataRoot.ensure(config, path.join(tmp.path, "data"), false) + DataRootBarrier.configure({ root: managed.path, config }) + const handlerReady = Promise.withResolvers() + const startNested = Promise.withResolvers() + const nestedReady = Promise.withResolvers() + let middlewareCommand: string | undefined + + const command = cmd({ + command: canonical, + aliases: [alias], + handler: async () => { + handlerReady.resolve() + await startNested.promise + await using nested = await DataRootBarrier.enter(path.join(managed.path, `${alias}.json`), 2_000) + nestedReady.resolve() + void nested + }, + }) + const parser = yargs([alias]) + .exitProcess(false) + .middleware(async (args) => { + middlewareCommand = typeof args._[0] === "string" ? args._[0] : undefined + await runDataRootMiddleware(middlewareCommand, managed.path, async () => undefined, 2_000) + }) + .command(command) + .strict() + const parsing = Promise.resolve(parser.parse()) + let switching: Promise | undefined + + try { + await handlerReady.promise + expect(middlewareCommand).toBe(alias) + switching = DataRootBarrier.exclusive(2_000) + await waitForFile(path.join(config, "data-root-switch.intent")) + startNested.resolve() + expect(await Promise.race([nestedReady.promise.then(() => true), Bun.sleep(250).then(() => false)])).toBe(true) + await parsing + expect(await Promise.race([switching.then(() => true), Bun.sleep(50).then(() => false)])).toBe(false) + await disposeDataRootOperation() + const exclusive = await switching + await exclusive[Symbol.asyncDispose]() + } finally { + startNested.resolve() + await parsing.catch(() => undefined) + await disposeDataRootOperation().catch(() => undefined) + const exclusive = await switching?.catch(() => undefined) + await exclusive?.[Symbol.asyncDispose]() + } + }) +} diff --git a/backend/cli/test/compute/modal-volume.test.ts b/backend/cli/test/compute/modal-volume.test.ts index ef47847e..41a72409 100644 --- a/backend/cli/test/compute/modal-volume.test.ts +++ b/backend/cli/test/compute/modal-volume.test.ts @@ -1,14 +1,48 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" import { ModalVolume } from "../../src/compute/modal/volume" +import { CredentialProcessLedger } from "../../src/credentials/process-ledger" const roots: string[] = [] +type LedgerEntry = { + id: string + kind: string + pid: number + identity: string +} + afterEach(async () => { await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))) }) +async function ledger(): Promise { + return Bun.file(CredentialProcessLedger.pathForTests()) + .json() + .catch(() => []) as Promise +} + +async function waitText(file: string) { + for (let attempt = 0; attempt < 500; attempt++) { + const value = await Bun.file(file) + .text() + .catch(() => undefined) + if (value?.trim()) return value.trim() + await Bun.sleep(10) + } + throw new Error(`Timed out waiting for ${file}`) +} + +async function waitEntry(previous: Set) { + for (let attempt = 0; attempt < 500; attempt++) { + const entry = (await ledger()).find((item) => item.kind === "modal-volume" && !previous.has(item.id)) + if (entry) return entry + await Bun.sleep(10) + } + throw new Error("Timed out waiting for the Modal Volume bridge ledger entry") +} + async function fixture() { const root = await fs.mkdtemp(path.join(process.env.TMPDIR ?? "/tmp", "openscience-modal-volume-")) roots.push(root) @@ -176,6 +210,60 @@ describe("ModalVolume", () => { expect(await Bun.file(path.join(item.staging, "outputs", "model.bin")).text()).toBe("weights") }, 30_000) + test("request abort revokes a blocked durable download bridge before rejecting", async () => { + const item = await fixture() + const python = Bun.which("python3") ?? Bun.which("python") + if (!python) throw new Error("Python is required for the Modal Volume driver test") + const blocker = path.join(item.root, "blocked-download.py") + const marker = path.join(item.staging, "started") + await Bun.write( + blocker, + [ + "import json, os, sys, time", + "request = json.load(sys.stdin)", + "marker = os.path.join(request['staging'], 'started')", + "with open(marker, 'w') as handle:", + " handle.write(str(os.getpid()))", + " handle.flush()", + " os.fsync(handle.fileno())", + "time.sleep(600)", + ].join("\n"), + ) + const previous = new Set((await ledger()).map((entry) => entry.id)) + const controller = new AbortController() + const reason = new DOMException("browser disconnected", "AbortError") + const running = ModalVolume.download( + { ...item.context, command: [python, "-I", blocker] }, + "job-volume", + ["outputs/model.bin"], + item.staging, + { signal: controller.signal }, + ).then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }), + ) + let entry: LedgerEntry | undefined + + try { + const active = await Promise.all([waitText(marker), waitEntry(previous)]).then(([, value]) => value) + entry = active + expect(await CredentialProcessLedger.owns(active.pid, active.identity)).toBe(true) + controller.abort(reason) + const result = await running + expect(result.ok).toBe(false) + if (result.ok) throw new Error("The blocked Modal Volume download unexpectedly completed") + expect(result.error).toBe(reason) + expect(await CredentialProcessLedger.owns(active.pid, active.identity)).toBe(false) + expect((await ledger()).some((item) => item.id === active.id)).toBe(false) + } finally { + controller.abort(reason) + await running + if (entry) { + await CredentialProcessLedger.revoke({ id: entry.id, kind: "modal-volume" }).catch(() => undefined) + } + } + }, 30_000) + test("waits for a durable marker inside one driver process", async () => { const item = await fixture() const marker = path.join(item.root, "volume", ".openscience-exit-code") @@ -195,6 +283,109 @@ describe("ModalVolume", () => { ) }) + test("rejects a declared aggregate above live safe capacity before launching the provider bridge", async () => { + const item = await fixture() + const launched = path.join(item.root, "provider-launched") + const python = Bun.which("python3") ?? Bun.which("python") + if (!python) throw new Error("Python is required for the Modal Volume driver test") + const statfs = spyOn(fs, "statfs").mockResolvedValue({ + bavail: ModalVolume.DOWNLOAD_DISK_RESERVE_BYTES + 6, + bsize: 1, + } as Awaited>) + + try { + const error = await ModalVolume.download( + { + ...item.context, + command: [ + python, + "-I", + "-c", + `from pathlib import Path; Path(${JSON.stringify(launched)}).write_text('launched')`, + ], + }, + "job-volume", + ["outputs/model.bin"], + item.staging, + { declaredBytes: 7 }, + ).catch((value) => value) + + expect(error).toBeInstanceOf(ModalVolume.DownloadCapacityError) + expect((error as ModalVolume.DownloadCapacityError).safeCapacityBytes).toBe(6) + expect((error as ModalVolume.DownloadCapacityError).responseBytes).toBe(7) + expect((error as Error).message).toContain("512 MiB") + expect(await Bun.file(launched).exists()).toBe(false) + expect(await Bun.file(item.staging).exists()).toBe(false) + } finally { + statfs.mockRestore() + } + }) + + test("bounds understated provider bytes and removes partial staging", async () => { + const item = await fixture() + const statfs = spyOn(fs, "statfs").mockResolvedValue({ + bavail: ModalVolume.DOWNLOAD_DISK_RESERVE_BYTES + 6, + bsize: 1, + } as Awaited>) + + try { + const error = await ModalVolume.download(item.context, "job-volume", ["outputs/model.bin"], item.staging, { + declaredBytes: 1, + }).catch((value) => value) + + expect(error).toBeInstanceOf(ModalVolume.DownloadCapacityError) + expect((error as ModalVolume.DownloadCapacityError).safeCapacityBytes).toBe(6) + expect((error as ModalVolume.DownloadCapacityError).responseBytes).toBe(7) + expect(await Bun.file(item.staging).exists()).toBe(false) + } finally { + statfs.mockRestore() + } + }, 30_000) + + test("classifies an ENOSPC staging write and removes the partial tree", async () => { + const item = await fixture() + const python = Bun.which("python3") ?? Bun.which("python") + if (!python) throw new Error("Python is required for the Modal Volume driver test") + const wrapper = path.join(item.root, "enospc-download.py") + await Bun.write( + wrapper, + [ + "import builtins, errno, os, runpy, sys", + "real_open = builtins.open", + `staging = os.path.realpath(${JSON.stringify(item.staging)})`, + "def guarded_open(file, mode='r', *args, **kwargs):", + " target = os.path.realpath(os.fspath(file))", + " if 'w' in mode and (target == staging or target.startswith(staging + os.sep)):", + " raise OSError(errno.ENOSPC, 'injected staging exhaustion')", + " return real_open(file, mode, *args, **kwargs)", + "builtins.open = guarded_open", + "sys.path.insert(0, sys.argv[1])", + "runpy.run_path(sys.argv[2], run_name='__main__')", + ].join("\n"), + ) + const statfs = spyOn(fs, "statfs").mockResolvedValue({ + bavail: ModalVolume.DOWNLOAD_DISK_RESERVE_BYTES + 64, + bsize: 1, + } as Awaited>) + + try { + const error = await ModalVolume.download( + { ...item.context, command: [python, "-I", wrapper, item.root, await ModalVolume.driverPath()] }, + "job-volume", + ["outputs/model.bin"], + item.staging, + { declaredBytes: 7 }, + ).catch((value) => value) + + expect(error).toBeInstanceOf(ModalVolume.DownloadCapacityError) + expect((error as ModalVolume.DownloadCapacityError).storageCode).toBe("ENOSPC") + expect((error as Error).message).toContain("staging storage returned ENOSPC") + expect(await Bun.file(item.staging).exists()).toBe(false) + } finally { + statfs.mockRestore() + } + }, 30_000) + test("accepts downloads when a staging parent is reached through a symlink", async () => { const item = await fixture() const real = path.join(item.root, "real") diff --git a/backend/cli/test/config/config.test.ts b/backend/cli/test/config/config.test.ts index facff740..a58f0679 100644 --- a/backend/cli/test/config/config.test.ts +++ b/backend/cli/test/config/config.test.ts @@ -35,6 +35,22 @@ test("loads config with defaults when no files exist", async () => { }) }) +test("trusted sandbox defaults project trust enforcement off and managed config can require it", async () => { + expect((await Config.trustedSandbox()).requireProjectTrust).toBe(false) + + await using tmp = await tmpdir({ + init: (dir) => writeConfig(dir, { sandbox: { requireProjectTrust: true } }), + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => expect((await Config.trustedSandbox()).requireProjectTrust).toBe(false), + }) + + await writeManagedSettings({ sandbox: { requireProjectTrust: true } }) + + expect((await Config.trustedSandbox()).requireProjectTrust).toBe(true) +}) + test("loads JSON config file", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/backend/cli/test/fixture/kernel-built-in-setsid.ts b/backend/cli/test/fixture/kernel-built-in-setsid.ts index 770dc6e7..12b4edea 100644 --- a/backend/cli/test/fixture/kernel-built-in-setsid.ts +++ b/backend/cli/test/fixture/kernel-built-in-setsid.ts @@ -6,7 +6,7 @@ import { Session } from "../../src/session" import "../../src/tool/notebook" import "../../src/tool/rkernel" -const [, , workspace, language, marker] = process.argv +const [, , workspace, language, marker, mode = "", ready = ""] = process.argv async function waitForMarker(attempt = 0): Promise { const value = await Bun.file(marker) @@ -87,6 +87,20 @@ await Instance.provide({ if (ancestor === kernelPID) break ancestor = (await processRow(ancestor)).ppid } + if (mode === "wait-for-signal") { + await Bun.write( + ready, + JSON.stringify({ + language, + kernelPID, + childPID, + childPPID: child.ppid, + childPGID: child.pgid, + childAncestors: ancestors, + }), + ) + await new Promise(() => {}) + } await KernelRuntime.release(identity) console.log( JSON.stringify({ diff --git a/backend/cli/test/fixture/kernel-data-root-lifecycle.ts b/backend/cli/test/fixture/kernel-data-root-lifecycle.ts new file mode 100644 index 00000000..2efc2719 --- /dev/null +++ b/backend/cli/test/fixture/kernel-data-root-lifecycle.ts @@ -0,0 +1,122 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { spawn } from "node:child_process" +import { DataRelocation } from "../../src/global/data-relocation" +import { Global } from "../../src/global" +import { WindowsJobLauncher } from "../../src/process/windows-job-launcher" +import { Instance } from "../../src/project/instance" +import { ProjectTrust } from "../../src/project/trust" +import { KernelProcessIdentity } from "../../src/science/kernel/process" +import { KernelRuntime } from "../../src/science/kernel/registry" +import type { Kernel, KernelStartOptions } from "../../src/science/kernel/types" +import { Session } from "../../src/session" + +const [, , workspace, mode, sessionID = "", signal = "", auxiliary = ""] = process.argv + +const wait = async (file: string, attempt = 0): Promise => { + if (await Bun.file(file).exists()) return + if (attempt >= 1_000) throw new Error(`Timed out waiting for ${file}`) + await Bun.sleep(10) + return wait(file, attempt + 1) +} + +if (mode === "relocate") { + const result = await DataRelocation.relocate(signal) + await fs.writeFile(auxiliary, JSON.stringify(result)) +} else { + await Instance.provide({ + directory: workspace, + fn: async () => { + if (mode === "setup") { + const status = await ProjectTrust.status(Instance.project) + if (!status.canExecuteProjectCode) { + await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + } + console.log((await Session.create({})).id) + return + } + + const kernels = new Map() + KernelRuntime.register({ + language: "data-root-lifecycle-test", + async get(id: string, options?: KernelStartOptions) { + const existing = kernels.get(id) + if (existing) return existing + const oldRoot = await Global.Path.dataTarget + const workerReady = path.join(path.dirname(signal), "worker.json") + const heldFile = path.join(oldRoot, "kernel-held-open.log") + const workerScript = [ + 'import fs from "node:fs/promises"', + "const [heldFile, ready] = process.argv.slice(1)", + 'const handle = await fs.open(heldFile, "a")', + "await fs.writeFile(ready, JSON.stringify({ pid: process.pid }))", + 'for (;;) { await handle.write("tick\\n"); await handle.sync(); await Bun.sleep(10) }', + ].join(";") + const payloadScript = [ + 'import fs from "node:fs/promises"', + "const [runtime, workerScript, heldFile, ready] = process.argv.slice(1)", + 'const worker = Bun.spawn([runtime, "-e", workerScript, heldFile, ready], { stdout: "ignore", stderr: "ignore" })', + "worker.unref()", + "await new Promise(() => {})", + ].join(";") + const wrapped = WindowsJobLauncher.wrap({ + file: process.execPath, + args: ["-e", payloadScript, process.execPath, workerScript, heldFile, workerReady], + linuxOwner: options?.processOwnership?.linuxOwner, + }) + const leader = spawn(wrapped.file, wrapped.args, { detached: true, stdio: "ignore" }) + WindowsJobLauncher.bind(leader, wrapped.release) + const ownership = options?.processOwnership + ? { ...options.processOwnership, windowsRelease: wrapped.release } + : undefined + const identity = await KernelProcessIdentity.register(leader, ownership) + if (!identity) throw new Error("Kernel containment leader exited before registration") + if (mode === "owner-crash-window") { + await fs.writeFile(signal, JSON.stringify(identity)) + await new Promise(() => {}) + } + await wait(workerReady) + const kernel: Kernel = { + id, + language: "data-root-lifecycle-test", + ready: true, + process: identity, + async start() {}, + async execute() { + return { ok: true, outputs: [], stdout: "", stderr: "" } + }, + async shutdown() { + await KernelProcessIdentity.terminate(identity) + }, + } + kernels.set(id, kernel) + return kernel + }, + async release(id: string) { + await kernels.get(id)?.shutdown() + kernels.delete(id) + }, + async shutdownAll() { + await Promise.all([...kernels.values()].map((kernel) => kernel.shutdown())) + kernels.clear() + }, + }) + + if (mode === "recover") { + await KernelRuntime.restoreSession(Instance.project.id, sessionID) + await Instance.dispose() + return + } + + const kernel = await KernelRuntime.get({ + projectID: Instance.project.id, + sessionID, + name: "data-root-lifecycle", + language: "data-root-lifecycle-test", + }) + const worker = (await Bun.file(path.join(path.dirname(signal), "worker.json")).json()) as { pid: number } + await fs.writeFile(signal, JSON.stringify({ process: kernel.process, worker })) + await new Promise(() => {}) + }, + }) +} diff --git a/backend/cli/test/fixture/kernel-leader-exit.ts b/backend/cli/test/fixture/kernel-leader-exit.ts index bf14f5b7..380d1282 100644 --- a/backend/cli/test/fixture/kernel-leader-exit.ts +++ b/backend/cli/test/fixture/kernel-leader-exit.ts @@ -48,8 +48,13 @@ await Instance.provide({ childFile, releaseFile, ] - const wrapped = WindowsJobLauncher.wrap({ file: process.execPath, args: command }) + const wrapped = WindowsJobLauncher.wrap({ + file: process.execPath, + args: command, + linuxOwner: options?.processOwnership?.linuxOwner, + }) const leader = spawn(wrapped.file, wrapped.args, { detached: true, stdio: "ignore" }) + WindowsJobLauncher.bind(leader, wrapped.release) const ownership = options?.processOwnership ? { ...options.processOwnership, windowsRelease: wrapped.release } : undefined diff --git a/backend/cli/test/fixture/kernel-registration-race.ts b/backend/cli/test/fixture/kernel-registration-race.ts new file mode 100644 index 00000000..ef31da42 --- /dev/null +++ b/backend/cli/test/fixture/kernel-registration-race.ts @@ -0,0 +1,213 @@ +import fs from "node:fs/promises" +import path from "node:path" +import { spawn } from "node:child_process" +import { Global } from "../../src/global" +import { WindowsJobLauncher } from "../../src/process/windows-job-launcher" +import { AuthorityProcessLedger } from "../../src/project/authority-process" +import { Instance } from "../../src/project/instance" +import { ProjectTrust } from "../../src/project/trust" +import { KernelProcessIdentity } from "../../src/science/kernel/process" +import { KernelRuntime } from "../../src/science/kernel/registry" +import type { Kernel, KernelProcess, KernelStartOptions } from "../../src/science/kernel/types" +import { Session } from "../../src/session" + +const [, , workspace, mode, sessionID = "", result = ""] = process.argv + +const wait = async (check: () => Promise, label: string, attempt = 0): Promise => { + if (await check()) return + if (attempt >= 1_000) throw new Error(`Timed out waiting for ${label}`) + await Bun.sleep(10) + return wait(check, label, attempt + 1) +} + +await Instance.provide({ + directory: workspace, + fn: async () => { + if (mode === "setup") { + const status = await ProjectTrust.status(Instance.project) + if (!status.canExecuteProjectCode) { + await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + } + console.log((await Session.create({})).id) + return + } + + const root = path.dirname(result) + const spawned = path.join(root, "spawned.json") + const release = path.join(root, "allow-register") + const registered = path.join(root, "registered") + const order = path.join(root, "release-order.log") + const forks = path.join(root, "forks.log") + const ledger = AuthorityProcessLedger.pathForTests() + const kernels = new Map() + KernelRuntime.register({ + language: "registration-race-test", + async get(id: string, options?: KernelStartOptions) { + const existing = kernels.get(id) + if (existing) return existing + const storm = [ + 'import fs from "node:fs/promises"', + 'import { spawn } from "node:child_process"', + 'process.on("SIGTERM", () => {})', + 'process.on("SIGHUP", () => {})', + `const file = ${JSON.stringify(forks)}`, + `const sleep = ${JSON.stringify(Bun.which("sleep") || "/bin/sleep")}`, + 'for (let index = 0; index < 128; index++) { const child = spawn(sleep, ["30"], { detached: true, stdio: "ignore" }); child.unref(); await fs.appendFile(file, String(child.pid) + "\\n"); await Bun.sleep(1) }', + "await new Promise(() => {})", + ].join(";") + const wrapped = WindowsJobLauncher.wrap({ + file: mode === "fork-storm" ? process.execPath : Bun.which("sleep") || "/bin/sleep", + args: mode === "fork-storm" ? ["-e", storm] : ["30"], + linuxOwner: options?.processOwnership?.linuxOwner, + }) + const child = spawn(wrapped.file, wrapped.args, { + detached: true, + stdio: mode === "fork-storm" ? ["ignore", "ignore", "inherit"] : "ignore", + }) + WindowsJobLauncher.bind(child, wrapped.release) + const initial = KernelProcessIdentity.capture(child) + if (!initial?.token || !options?.processOwnership) throw new Error("Missing race-test process ownership") + await fs.writeFile(spawned, JSON.stringify({ ...initial, ownershipID: options.processOwnership.id })) + if (mode === "race") await wait(() => Bun.file(release).exists(), release) + const identity = await KernelProcessIdentity.register(child, { + ...options.processOwnership, + windowsRelease: wrapped.release, + }) + if (!identity) throw new Error("Race-test containment leader exited before registration") + await fs.writeFile(registered, identity.ownershipID ?? "") + const kernel: Kernel = { + id, + language: "registration-race-test", + ready: true, + process: identity, + async start() {}, + async execute() { + return { ok: true, outputs: [], stdout: "", stderr: "" } + }, + async shutdown() { + await KernelProcessIdentity.terminate(identity) + }, + } + kernels.set(id, kernel) + return kernel + }, + async release(id: string) { + const entries = (await Bun.file(ledger) + .json() + .catch(() => [])) as Array<{ id?: string }> + const live = entries.some( + (entry) => entry.id === (kernels.get(id)?.process as KernelProcess | undefined)?.ownershipID, + ) + const phase = (await Bun.file(registered).exists()) ? "registered" : "pre-registration" + await fs.appendFile(order, `${phase}:${live ? "ledger-live" : "ledger-absent"}\n`) + await kernels.get(id)?.shutdown() + kernels.delete(id) + }, + async shutdownAll() { + await Promise.all([...kernels.values()].map((kernel) => kernel.shutdown())) + kernels.clear() + }, + }) + + const identity = { + projectID: Instance.project.id, + sessionID, + name: "registration-race", + language: "registration-race-test", + } + if (mode === "coverage") { + const kernel = await KernelRuntime.get(identity) + const processIdentity = kernel.process as KernelProcess & { ownershipID: string } + const operationPath = path.join(Global.Path.config, "data-root-operations") + const marker = async () => { + const operations = await fs.readdir(operationPath).catch(() => []) + const markers = await Promise.all(operations.map((name) => Bun.file(path.join(operationPath, name)).json())) + return markers.some((item) => item.pid === processIdentity.pid && item.identity === processIdentity.token) + } + if (!(await marker())) throw new Error("Missing child-owned data-root coverage before absent-entry revoke") + process.kill(processIdentity.pid, "SIGTERM") + await wait(() => Promise.resolve(!KernelProcessIdentity.matchesRecorded(processIdentity)), "containment exit") + await fs.writeFile(ledger, "[]") + await AuthorityProcessLedger.revoke({ id: processIdentity.ownershipID, kind: "kernel" }) + await fs.writeFile(result, JSON.stringify({ marker: await marker(), ledger: await Bun.file(ledger).json() })) + await KernelRuntime.release(identity) + return + } + if (mode === "fork-storm") { + const kernel = await KernelRuntime.get(identity) + const processIdentity = kernel.process as KernelProcess & { ownershipID: string } + const pids = async () => + (await fs.readFile(forks, "utf8").catch(() => "")) + .trim() + .split("\n") + .map(Number) + .filter((pid) => Number.isSafeInteger(pid) && pid > 0) + await wait(async () => (await pids()).length >= 30, "fork storm") + await KernelRuntime.release(identity) + const children = await pids() + const alive = children.filter((pid) => { + try { + process.kill(pid, 0) + return true + } catch { + return false + } + }) + const operations = await fs.readdir(path.join(Global.Path.config, "data-root-operations")).catch(() => []) + const markers = await Promise.all( + operations.map((name) => Bun.file(path.join(Global.Path.config, "data-root-operations", name)).json()), + ) + await fs.writeFile( + result, + JSON.stringify({ + forks: children.length, + alive, + containmentAlive: KernelProcessIdentity.matchesRecorded(processIdentity), + ledger: await Bun.file(ledger) + .json() + .catch(() => []), + marker: markers.some( + (marker) => marker.pid === processIdentity.pid && marker.identity === processIdentity.token, + ), + }), + ) + return + } + const boot = KernelRuntime.get(identity) + await wait(() => Bun.file(spawned).exists(), spawned) + const processIdentity = (await Bun.file(spawned).json()) as KernelProcess & { ownershipID: string } + await fs.rm(ledger, { force: true }) + const stop = KernelRuntime.release(identity) + // This is the audited interleaving: reclaim has completed its first exact + // ID revoke while register is still paused and the durable ledger is empty. + await wait(async () => { + const entries = await Bun.file(ledger) + .json() + .catch(() => undefined) + return Array.isArray(entries) && !entries.length + }, "the first missed-ledger revoke") + await fs.writeFile(release, "register") + const [started, stopped] = await Promise.allSettled([boot, stop]) + await wait(() => Promise.resolve(!KernelProcessIdentity.matchesRecorded(processIdentity)), "containment teardown") + const entries = await Bun.file(ledger) + .json() + .catch(() => []) + const operations = await fs.readdir(path.join(Global.Path.config, "data-root-operations")).catch(() => []) + const markers = await Promise.all( + operations.map((name) => Bun.file(path.join(Global.Path.config, "data-root-operations", name)).json()), + ) + await fs.writeFile( + result, + JSON.stringify({ + ownershipID: processIdentity.ownershipID, + started: started.status, + stopped: stopped.status, + ledger: entries, + marker: markers.some( + (marker) => marker.pid === processIdentity.pid && marker.identity === processIdentity.token, + ), + order: await fs.readFile(order, "utf8").catch(() => ""), + }), + ) + }, +}) diff --git a/backend/cli/test/global/data-root.test.ts b/backend/cli/test/global/data-root.test.ts index d61b8d1f..73b1a00a 100644 --- a/backend/cli/test/global/data-root.test.ts +++ b/backend/cli/test/global/data-root.test.ts @@ -1,10 +1,11 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect, spyOn, test } from "bun:test" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" import { DataRoot } from "@/global/data-root" import { DataRootBarrier } from "@/global/data-root-barrier" import { WindowsJunction } from "@/global/windows-junction" +import { ProcessIdentity } from "@/process/process-identity" const roots: string[] = [] @@ -18,6 +19,18 @@ async function root() { return value } +async function waitForFile(filepath: string) { + const deadline = Date.now() + 2_000 + while (!(await Bun.file(filepath).exists())) { + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${filepath}`) + await Bun.sleep(10) + } +} + +async function operationMarkers(config: string) { + return fs.readdir(path.join(config, "data-root-operations")).catch(() => [] as string[]) +} + describe("managed data root", () => { test("Windows junction reparse buffer carries a mount-point tag and two UTF-16 paths", () => { const target = "C:\\OpenScience Data" @@ -108,6 +121,447 @@ describe("managed data root", () => { await lease[Symbol.asyncDispose]() }) + test("admits a physical reassignable child after intent without releasing its ancestor early", async () => { + const base = await root() + const config = path.join(base, "config") + const data = path.join(base, "data") + const managed = await DataRoot.ensure(config, data, false) + DataRootBarrier.configure({ root: managed.path, config }) + const intent = path.join(config, "data-root-switch.intent") + const outerReady = Promise.withResolvers() + const startChild = Promise.withResolvers() + const childReady = Promise.withResolvers() + const releaseOuter = Promise.withResolvers() + let child: DataRootBarrier.Operation | undefined + + const command = DataRootBarrier.during( + managed.path, + async () => { + outerReady.resolve() + await startChild.promise + child = await DataRootBarrier.enter(path.join(managed.path, "child.json"), 2_000) + childReady.resolve() + await releaseOuter.promise + }, + 2_000, + ) + let switching: Promise | undefined + try { + await outerReady.promise + switching = DataRootBarrier.exclusive(1_000) + await waitForFile(intent) + startChild.resolve() + expect(await Promise.race([childReady.promise.then(() => true), Bun.sleep(250).then(() => false)])).toBe(true) + expect(await operationMarkers(config)).toHaveLength(2) + + const identity = await ProcessIdentity.capture(process.pid) + if (!identity) throw new Error("Current process identity is unavailable") + await child!.reassign({ pid: process.pid, identity }) + expect(await operationMarkers(config)).toHaveLength(2) + await expect(child!.during(async () => undefined)).rejects.toThrow( + "Cannot scope work under a non-active data-root operation", + ) + + releaseOuter.resolve() + await command + expect(await operationMarkers(config)).toHaveLength(1) + expect(await Promise.race([switching.then(() => true), Bun.sleep(50).then(() => false)])).toBe(false) + } finally { + startChild.resolve() + releaseOuter.resolve() + await Promise.resolve(child?.[Symbol.asyncDispose]()).catch(() => undefined) + const lease = await switching?.catch(() => undefined) + await lease?.[Symbol.asyncDispose]() + await command.catch(() => undefined) + } + }) + + test("keeps the parent marker until an admitted child marker is durable", async () => { + const base = await root() + const config = path.join(base, "config") + const data = path.join(base, "data") + const managed = await DataRoot.ensure(config, data, false) + DataRootBarrier.configure({ root: managed.path, config }) + const operations = path.join(config, "data-root-operations") + const outerReady = Promise.withResolvers() + const startChild = Promise.withResolvers() + const childPublishing = Promise.withResolvers() + const releasePublish = Promise.withResolvers() + const renameOriginal = fs.rename.bind(fs) + let intercepted = false + let restoreRename = () => {} + let child: Promise | undefined + let command: Promise | undefined + let switching: Promise | undefined + try { + command = (async () => { + await using outer = await DataRootBarrier.enter(managed.path, 2_000) + return await outer.during(async () => { + outerReady.resolve() + await startChild.promise + const gatedRename = spyOn(fs, "rename").mockImplementation(async (source, destination) => { + if ( + !intercepted && + path.dirname(String(source)) === config && + path.basename(String(source)).endsWith(".pending") && + path.dirname(String(destination)) === operations + ) { + intercepted = true + childPublishing.resolve() + await releasePublish.promise + } + return renameOriginal(source, destination) + }) + restoreRename = () => gatedRename.mockRestore() + try { + child = DataRootBarrier.enter(path.join(managed.path, "admitted.json"), 2_000) + } finally { + await childPublishing.promise + restoreRename() + } + }) + })() + await outerReady.promise + startChild.resolve() + await childPublishing.promise + expect(await operationMarkers(config)).toHaveLength(1) + expect(await Promise.race([command.then(() => true), Bun.sleep(50).then(() => false)])).toBe(false) + switching = DataRootBarrier.exclusive(2_000) + await waitForFile(path.join(config, "data-root-switch.intent")) + expect(await Promise.race([switching.then(() => true), Bun.sleep(50).then(() => false)])).toBe(false) + + releasePublish.resolve() + const admitted = await child! + await command + expect(await operationMarkers(config)).toHaveLength(1) + expect(await Promise.race([switching.then(() => true), Bun.sleep(50).then(() => false)])).toBe(false) + await admitted[Symbol.asyncDispose]() + const exclusive = await switching + await exclusive[Symbol.asyncDispose]() + } finally { + restoreRename() + startChild.resolve() + releasePublish.resolve() + await command?.catch(() => undefined) + const admitted = await child?.catch(() => undefined) + await admitted?.[Symbol.asyncDispose]() + const exclusive = await switching?.catch(() => undefined) + await exclusive?.[Symbol.asyncDispose]() + } + }) + + test("an inherited background context reacquires after its enclosing scope closes", async () => { + const base = await root() + const config = path.join(base, "config") + const data = path.join(base, "data") + const managed = await DataRoot.ensure(config, data, false) + DataRootBarrier.configure({ root: managed.path, config }) + const start = Promise.withResolvers() + const entered = Promise.withResolvers() + const finish = Promise.withResolvers() + let background: Promise | undefined + + await DataRootBarrier.during(managed.path, async () => { + background = (async () => { + await start.promise + await using operation = await DataRootBarrier.enter(path.join(managed.path, "background.json"), 2_000) + entered.resolve() + await finish.promise + void operation + })() + }) + + const exclusive = await DataRootBarrier.exclusive(2_000) + try { + start.resolve() + expect(await Promise.race([entered.promise.then(() => true), Bun.sleep(50).then(() => false)])).toBe(false) + } finally { + await exclusive[Symbol.asyncDispose]() + start.resolve() + await entered.promise + finish.resolve() + await background + } + }) + + test("an inherited callback cannot borrow a still-open operation after its invocation ends", async () => { + const base = await root() + const config = path.join(base, "config") + const data = path.join(base, "data") + const managed = await DataRoot.ensure(config, data, false) + DataRootBarrier.configure({ root: managed.path, config }) + const operation = await DataRootBarrier.enter(managed.path, 2_000) + const start = Promise.withResolvers() + const entered = Promise.withResolvers() + const finish = Promise.withResolvers() + let background: Promise | undefined + + await operation.during(async () => { + background = (async () => { + await start.promise + await using child = await DataRootBarrier.enter(path.join(managed.path, "stale-frame.json"), 2_000) + entered.resolve() + await finish.promise + void child + })() + }) + + const switching = DataRootBarrier.exclusive(2_000) + try { + await waitForFile(path.join(config, "data-root-switch.intent")) + start.resolve() + expect(await Promise.race([entered.promise.then(() => true), Bun.sleep(50).then(() => false)])).toBe(false) + await operation[Symbol.asyncDispose]() + const exclusive = await switching + expect(await Promise.race([entered.promise.then(() => true), Bun.sleep(50).then(() => false)])).toBe(false) + await exclusive[Symbol.asyncDispose]() + await entered.promise + finish.resolve() + await background + } finally { + start.resolve() + finish.resolve() + await Promise.resolve(operation[Symbol.asyncDispose]()).catch(() => undefined) + const exclusive = await switching.catch(() => undefined) + await exclusive?.[Symbol.asyncDispose]() + await background?.catch(() => undefined) + } + }) + + test("unrelated concurrent scopes keep independent physical coverage", async () => { + const base = await root() + const config = path.join(base, "config") + const data = path.join(base, "data") + const managed = await DataRoot.ensure(config, data, false) + DataRootBarrier.configure({ root: managed.path, config }) + const firstReady = Promise.withResolvers() + const secondReady = Promise.withResolvers() + const releaseFirst = Promise.withResolvers() + const releaseSecond = Promise.withResolvers() + const first = DataRootBarrier.during(path.join(managed.path, "first.json"), async () => { + firstReady.resolve() + await releaseFirst.promise + }) + const second = DataRootBarrier.during(path.join(managed.path, "second.json"), async () => { + secondReady.resolve() + await releaseSecond.promise + }) + + await Promise.all([firstReady.promise, secondReady.promise]) + expect(await operationMarkers(config)).toHaveLength(2) + releaseFirst.resolve() + await first + expect(await operationMarkers(config)).toHaveLength(1) + + const switching = DataRootBarrier.exclusive(2_000) + await waitForFile(path.join(config, "data-root-switch.intent")) + expect(await Promise.race([switching.then(() => true), Bun.sleep(50).then(() => false)])).toBe(false) + releaseSecond.resolve() + await second + const exclusive = await switching + await exclusive[Symbol.asyncDispose]() + }) + + test("a bare marker never lets a sibling operation bypass relocation intent", async () => { + const base = await root() + const config = path.join(base, "config") + const data = path.join(base, "data") + const managed = await DataRoot.ensure(config, data, false) + DataRootBarrier.configure({ root: managed.path, config }) + const first = await DataRootBarrier.enter(path.join(managed.path, "first.json"), 2_000) + let switching: Promise | undefined + let sibling: Promise | undefined + + try { + switching = DataRootBarrier.exclusive(2_000) + await waitForFile(path.join(config, "data-root-switch.intent")) + sibling = DataRootBarrier.enter(path.join(managed.path, "sibling.json"), 2_000) + expect(await Promise.race([sibling.then(() => true), Bun.sleep(50).then(() => false)])).toBe(false) + + await first[Symbol.asyncDispose]() + const exclusive = await switching + expect(await Promise.race([sibling.then(() => true), Bun.sleep(50).then(() => false)])).toBe(false) + await exclusive[Symbol.asyncDispose]() + const admitted = await sibling + await admitted[Symbol.asyncDispose]() + } finally { + await Promise.resolve(first[Symbol.asyncDispose]()).catch(() => undefined) + const exclusive = await switching?.catch(() => undefined) + await exclusive?.[Symbol.asyncDispose]() + const admitted = await sibling?.catch(() => undefined) + await admitted?.[Symbol.asyncDispose]() + } + }) + + test("exclusive relocation fails fast inside an active operation scope", async () => { + const base = await root() + const config = path.join(base, "config") + const data = path.join(base, "data") + const managed = await DataRoot.ensure(config, data, false) + DataRootBarrier.configure({ root: managed.path, config }) + + await DataRootBarrier.during(managed.path, async () => { + await expect(DataRootBarrier.exclusive()).rejects.toThrow( + "Cannot relocate the data root from inside an active data-root operation", + ) + expect(await Bun.file(path.join(config, "data-root-switch.intent")).exists()).toBe(false) + }) + }) + + test("same-tick transitions reject new scopes but preserve an active callback's nested admission", async () => { + const base = await root() + const config = path.join(base, "config") + const data = path.join(base, "data") + const managed = await DataRoot.ensure(config, data, false) + DataRootBarrier.configure({ root: managed.path, config }) + const identity = await ProcessIdentity.capture(process.pid) + if (!identity) throw new Error("Current process identity is unavailable") + const outer = await DataRootBarrier.enter(managed.path, 2_000) + const scopeReady = Promise.withResolvers() + const startChild = Promise.withResolvers() + const childDone = Promise.withResolvers() + const releaseScope = Promise.withResolvers() + const command = outer.during(async () => { + scopeReady.resolve() + await startChild.promise + await using child = await DataRootBarrier.enter(path.join(managed.path, "same-tick.json"), 2_000) + childDone.resolve() + await releaseScope.promise + void child + }) + let switching: Promise | undefined + let reassigning: Promise | undefined + let disposing: PromiseLike | undefined + try { + await scopeReady.promise + switching = DataRootBarrier.exclusive(2_000) + await waitForFile(path.join(config, "data-root-switch.intent")) + reassigning = outer.reassign({ pid: process.pid, identity }) + disposing = outer[Symbol.asyncDispose]() + await expect(outer.during(async () => undefined)).rejects.toThrow( + "Cannot scope work under a non-active data-root operation", + ) + startChild.resolve() + expect(await Promise.race([childDone.promise.then(() => true), Bun.sleep(250).then(() => false)])).toBe(true) + expect(await Promise.race([switching.then(() => true), Bun.sleep(50).then(() => false)])).toBe(false) + + releaseScope.resolve() + await command + await Promise.all([reassigning, disposing]) + const exclusive = await switching + await exclusive[Symbol.asyncDispose]() + } finally { + startChild.resolve() + releaseScope.resolve() + await command.catch(() => undefined) + await reassigning?.catch(() => undefined) + await Promise.resolve(disposing).catch(() => undefined) + await Promise.resolve(outer[Symbol.asyncDispose]()).catch(() => undefined) + const exclusive = await switching?.catch(() => undefined) + await exclusive?.[Symbol.asyncDispose]() + } + }) + + test("a closing inner scope falls back to its live structured parent for admission", async () => { + const base = await root() + const config = path.join(base, "config") + const data = path.join(base, "data") + const managed = await DataRoot.ensure(config, data, false) + DataRootBarrier.configure({ root: managed.path, config }) + const parent = await DataRootBarrier.enter(managed.path, 2_000) + const childReady = Promise.withResolvers() + const startNested = Promise.withResolvers() + const nestedDone = Promise.withResolvers() + const finishParent = Promise.withResolvers() + let child: DataRootBarrier.Operation | undefined + const command = parent.during(async () => { + child = await DataRootBarrier.enter(path.join(managed.path, "child-scope.json"), 2_000) + await child.during(async () => { + childReady.resolve() + await startNested.promise + await using nested = await DataRootBarrier.enter(path.join(managed.path, "nested.json"), 2_000) + nestedDone.resolve() + void nested + }) + await finishParent.promise + }) + let switching: Promise | undefined + + try { + await childReady.promise + switching = DataRootBarrier.exclusive(2_000) + await waitForFile(path.join(config, "data-root-switch.intent")) + const disposingChild = child![Symbol.asyncDispose]() + startNested.resolve() + expect(await Promise.race([nestedDone.promise.then(() => true), Bun.sleep(250).then(() => false)])).toBe(true) + await disposingChild + expect(await Promise.race([switching.then(() => true), Bun.sleep(50).then(() => false)])).toBe(false) + + finishParent.resolve() + await command + await parent[Symbol.asyncDispose]() + const exclusive = await switching + await exclusive[Symbol.asyncDispose]() + } finally { + startNested.resolve() + finishParent.resolve() + await command.catch(() => undefined) + await Promise.resolve(child?.[Symbol.asyncDispose]()).catch(() => undefined) + await Promise.resolve(parent[Symbol.asyncDispose]()).catch(() => undefined) + const exclusive = await switching?.catch(() => undefined) + await exclusive?.[Symbol.asyncDispose]() + } + }) + + test("failed reassignment restores self-owned admission when no later transition is queued", async () => { + const base = await root() + const config = path.join(base, "config") + const data = path.join(base, "data") + const managed = await DataRoot.ensure(config, data, false) + DataRootBarrier.configure({ root: managed.path, config }) + const identity = await ProcessIdentity.capture(process.pid) + if (!identity) throw new Error("Current process identity is unavailable") + const startNested = Promise.withResolvers() + const nestedDone = Promise.withResolvers() + const renameOriginal = fs.rename.bind(fs) + let restoreRename = () => {} + const outer = await DataRootBarrier.enter(managed.path, 2_000) + const rename = spyOn(fs, "rename").mockImplementation(async (source, destination) => { + if (path.basename(String(source)).startsWith(".data-root-operation-")) { + throw Object.assign(new Error("mock reassign failure"), { code: "EIO" }) + } + return renameOriginal(source, destination) + }) + restoreRename = () => rename.mockRestore() + let command: Promise | undefined + let switching: Promise | undefined + try { + await expect(outer.reassign({ pid: process.pid, identity })).rejects.toThrow("mock reassign failure") + restoreRename() + command = outer.during(async () => { + await startNested.promise + await using child = await DataRootBarrier.enter(path.join(managed.path, "after-failure.json"), 2_000) + nestedDone.resolve() + void child + }) + switching = DataRootBarrier.exclusive(1_000) + await waitForFile(path.join(config, "data-root-switch.intent")) + startNested.resolve() + expect(await Promise.race([nestedDone.promise.then(() => true), Bun.sleep(250).then(() => false)])).toBe(true) + await command + await outer[Symbol.asyncDispose]() + const exclusive = await switching + await exclusive[Symbol.asyncDispose]() + } finally { + restoreRename() + startNested.resolve() + await command?.catch(() => undefined) + await Promise.resolve(outer[Symbol.asyncDispose]()).catch(() => undefined) + const exclusive = await switching?.catch(() => undefined) + await exclusive?.[Symbol.asyncDispose]() + } + }) + test.skipIf(process.platform === "win32")( "keeps a reassigned child marker live after its owning server is SIGKILLed", async () => { diff --git a/backend/cli/test/process/darwin-responsibility.test.ts b/backend/cli/test/process/darwin-responsibility.test.ts index fb37f85a..faad0681 100644 --- a/backend/cli/test/process/darwin-responsibility.test.ts +++ b/backend/cli/test/process/darwin-responsibility.test.ts @@ -2,7 +2,12 @@ import { expect, test } from "bun:test" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" +import { spawn } from "node:child_process" import { DarwinResponsibility } from "../../src/process/darwin-responsibility" +import { + DARWIN_RESPONSIBILITY_ACTIVATION_SUFFIX, + DarwinResponsibilityLauncher, +} from "../../src/process/darwin-responsibility-launcher" async function text(file: string, attempt = 0): Promise { const value = await fs.readFile(file, "utf8").catch(() => undefined) @@ -23,6 +28,64 @@ async function gone(pid: number, attempt = 0): Promise { return gone(pid, attempt + 1) } +async function independentRoot(pid: number, attempt = 0): Promise { + if (DarwinResponsibility.responsible(pid) === pid && DarwinResponsibility.unique(pid)) return + if (attempt >= 500) throw new Error(`Timed out waiting for responsibility root ${pid}`) + await Bun.sleep(10) + return independentRoot(pid, attempt + 1) +} + +test.skipIf(process.platform !== "darwin")( + "SIGTERM before the Darwin activation gate cannot default-kill the responsibility root", + async () => { + expect(DarwinResponsibility.available()).toBe(true) + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-darwin-responsibility-signal-")) + const payload = path.join(root, "payload-ran") + const latchReady = path.join(root, "latch-ready") + const wrapped = DarwinResponsibilityLauncher.wrap({ + file: process.execPath, + args: ["-e", `await Bun.write(${JSON.stringify(payload)}, "unsafe")`], + }) + if (!wrapped.release) throw new Error("Darwin responsibility launch did not create a registration gate") + const child = spawn(wrapped.file, wrapped.args, { + cwd: path.resolve(import.meta.dir, "../.."), + detached: true, + env: { + ...process.env, + OPENSCIENCE_TEST_HOME: root, + OPENSCIENCE_DARWIN_SUPERVISOR_TEST_READY: latchReady, + }, + stdio: ["ignore", "ignore", "pipe"], + }) + let stderr = "" + child.stderr?.setEncoding("utf8") + child.stderr?.on("data", (chunk) => { + stderr += chunk + }) + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + child.once("exit", (code, signal) => resolve({ code, signal })) + }) + try { + await fs.writeFile(wrapped.release, String(child.pid), { encoding: "utf8", flag: "wx", mode: 0o600 }) + await independentRoot(child.pid!) + // Stage two has installed its native latch and is blocked on the separate + // activation marker. Signal before project code is admitted. + expect(Number(await text(latchReady))).toBe(child.pid!) + process.kill(child.pid!, "SIGTERM") + const outcome = await exited + expect(outcome.signal, stderr).toBeNull() + expect(outcome.code, stderr).toBe(143) + expect(await Bun.file(payload).exists()).toBe(false) + } finally { + child.kill("SIGKILL") + await fs.rm(wrapped.release, { force: true }) + await fs.rm(`${wrapped.release}${DARWIN_RESPONSIBILITY_ACTIVATION_SUFFIX}`, { force: true }) + await fs.rm(root, { recursive: true, force: true }) + } + }, + 15_000, +) + test.skipIf(process.platform !== "darwin")( "kernel responsibility tracks a setsid double-fork after it reparents to launchd", async () => { diff --git a/backend/cli/test/process/linux-subreaper.test.ts b/backend/cli/test/process/linux-subreaper.test.ts index d533a133..7d206192 100644 --- a/backend/cli/test/process/linux-subreaper.test.ts +++ b/backend/cli/test/process/linux-subreaper.test.ts @@ -68,6 +68,60 @@ linuxTest("a forged launcher argv marker cannot opt a raw process out of ordinar } }) +linuxTest( + "the supervisor's real SIGTERM watcher survives replacement of inherited server handlers", + async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-subreaper-signal-")) + const marker = path.join(root, "payload.pid") + const source = [ + `await Bun.write(${JSON.stringify(marker)}, String(process.pid))`, + 'process.on("SIGTERM", () => {})', + "await new Promise(() => {})", + ].join(";") + const wrapped = WindowsJobLauncher.wrap({ + file: process.execPath, + args: ["-e", source], + linuxOwner: await owner(), + }) + if (!wrapped.release) throw new Error("Linux subreaper launch did not create a registration gate") + const child = spawn(wrapped.file, wrapped.args, { + cwd: path.resolve(import.meta.dir, "../.."), + stdio: ["ignore", "ignore", "pipe"], + }) + let stderr = "" + child.stderr?.setEncoding("utf8") + child.stderr?.on("data", (chunk) => { + stderr += chunk + }) + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + child.once("exit", (code, signal) => resolve({ code, signal })) + }) + let payloadPID = 0 + let payloadIdentity: string | undefined + try { + await WindowsJobLauncher.release(wrapped.release, child.pid!) + payloadPID = Number(await waitText(marker)) + payloadIdentity = await ProcessIdentity.capture(payloadPID) + expect(payloadIdentity).toMatch(/^[a-f0-9]{64}$/) + process.kill(child.pid!, "SIGTERM") + const outcome = await exited + // Bun 1.3.9 used to report `signal === SIGTERM` here: adding the + // replacement listener before removing inherited handlers silently + // detached the native signal watcher despite listenerCount() being one. + expect(outcome.signal, stderr).toBeNull() + expect(outcome.code, stderr).toBe(143) + expect(await waitGone(payloadPID, payloadIdentity!)).toBe(true) + } finally { + child.kill("SIGKILL") + if (payloadPID && payloadIdentity && (await ProcessIdentity.owns(payloadPID, payloadIdentity))) { + process.kill(payloadPID, "SIGKILL") + } + await fs.rm(root, { recursive: true, force: true }) + } + }, + 20_000, +) + linuxTest( "normal payload completion drains an adopted setsid double-fork before the launcher exits", async () => { diff --git a/backend/cli/test/project/execution-authority.test.ts b/backend/cli/test/project/execution-authority.test.ts index 94a5a6ea..aa4067bc 100644 --- a/backend/cli/test/project/execution-authority.test.ts +++ b/backend/cli/test/project/execution-authority.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test" import path from "path" +import { Config } from "../../src/config/config" import { Instance } from "../../src/project/instance" import { ExecutionAuthority } from "../../src/project/execution" import { Project } from "../../src/project/project" @@ -46,24 +47,36 @@ test("session execution authority is inspectable through the project route", asy ) expect(response.status).toBe(200) - expect(ExecutionAuthority.Decision.parse(await response.json())).toMatchObject({ - allowed: false, - reason: "project_untrusted", + const decision = ExecutionAuthority.Decision.parse(await response.json()) + expect(decision).toMatchObject({ + allowed: Sandbox.available(), + reason: Sandbox.available() ? "allowed" : "sandbox_unavailable", capability: "terminal", - mode: "read_only", + mode: Sandbox.available() ? "sandboxed" : "read_only", projectID: project.project.id, sessionID, + sandbox: { + enabled: true, + enforced: Sandbox.available(), + requireProjectTrust: false, + }, }) + const { requireProjectTrust, ...legacySandbox } = decision.sandbox + expect(requireProjectTrust).toBe(false) + expect(ExecutionAuthority.Decision.parse({ ...decision, sandbox: legacySandbox }).sandbox.requireProjectTrust).toBe( + false, + ) }) -test("read-only project authority rejects terminal, shell, and kernel before process spawn", async () => { +test("untrusted projects run routine terminals, shells, and kernels only in an enforced sandbox", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, fn: async () => { await ProjectTrust.update(Instance.project, { trusted: false }) const session = await Session.create({}) - const marker = path.join(tmp.path, "process-spawned") + const shellMarker = path.join(tmp.path, "shell-spawned") + const kernelMarker = path.join(tmp.path, "kernel-spawned") const decision = await ExecutionAuthority.decide({ projectID: Instance.project.id, sessionID: session.id, @@ -71,9 +84,9 @@ test("read-only project authority rejects terminal, shell, and kernel before pro }) expect(decision).toMatchObject({ - allowed: false, - reason: "project_untrusted", - mode: "read_only", + allowed: Sandbox.available(), + reason: Sandbox.available() ? "allowed" : "sandbox_unavailable", + mode: Sandbox.available() ? "sandboxed" : "read_only", projectID: Instance.project.id, sessionID: session.id, trustRevision: 2, @@ -81,6 +94,8 @@ test("read-only project authority rejects terminal, shell, and kernel before pro enabled: true, network: "deny", onUnavailable: "error", + requireProjectTrust: false, + enforced: Sandbox.available(), }, }) expect(decision.grantRevision).toBeGreaterThanOrEqual(1) @@ -88,38 +103,150 @@ test("read-only project authority rejects terminal, shell, and kernel before pro expect(decision.workspace).toBe(await SessionFilesystem.workspace(session.id)) expect(decision.writable).toContain(tmp.path) - await expect(Pty.create({ sessionID: session.id })).rejects.toBeInstanceOf(ExecutionAuthority.DeniedError) - expect(Pty.list()).toEqual([]) - const bash = await BashTool.init() - await expect( - bash.execute( - { - command: `printf spawned > ${JSON.stringify(marker)}`, - description: "Attempt read-only spawn", - }, - context(session.id), - ), - ).rejects.toBeInstanceOf(ExecutionAuthority.DeniedError) - const identity = { projectID: Instance.project.id, sessionID: session.id, name: "authority-probe", language: "python" as const, } - await expect( - KernelRuntime.execute(identity, `open(${JSON.stringify(marker)}, "w").write("spawned")`), - ).rejects.toBeInstanceOf(ExecutionAuthority.DeniedError) - expect(KernelRuntime.status(identity)).toMatchObject({ - active: false, - process_id: null, + if (!Sandbox.available()) { + await expect(Pty.create({ sessionID: session.id })).rejects.toBeInstanceOf(ExecutionAuthority.DeniedError) + await expect( + bash.execute( + { + command: `printf spawned > ${JSON.stringify(shellMarker)}`, + description: "Attempt unavailable sandbox spawn", + }, + context(session.id), + ), + ).rejects.toBeInstanceOf(ExecutionAuthority.DeniedError) + await expect( + KernelRuntime.execute(identity, `open(${JSON.stringify(kernelMarker)}, "w").write("spawned")`), + ).rejects.toBeInstanceOf(ExecutionAuthority.DeniedError) + expect(await Bun.file(shellMarker).exists()).toBe(false) + expect(await Bun.file(kernelMarker).exists()).toBe(false) + return + } + + const terminal = await Pty.create({ sessionID: session.id }) + try { + expect(terminal.authority).toMatchObject({ allowed: true, mode: "sandboxed", sandbox: { enforced: true } }) + const result = await bash.execute( + { + command: `printf spawned > ${JSON.stringify(shellMarker)}`, + description: "Run sandboxed untrusted shell", + }, + context(session.id), + ) + expect(result.metadata.exit).toBe(0) + expect(await Bun.file(shellMarker).text()).toBe("spawned") + + await KernelRuntime.execute(identity, `open(${JSON.stringify(kernelMarker)}, "w").write("spawned")`) + expect(KernelRuntime.status(identity)).toMatchObject({ + active: true, + authority: { allowed: true, mode: "sandboxed", sandbox: { enforced: true } }, + }) + expect(await Bun.file(kernelMarker).text()).toBe("spawned") + } finally { + await Pty.remove(terminal.id) + await KernelRuntime.removeSession(identity.projectID, identity.sessionID) + } + }, + }) +}) + +test("non-routine remote execution still requires explicit project trust", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await ProjectTrust.update(Instance.project, { trusted: false }) + const session = await Session.create({}) + const sandboxed = await ExecutionAuthority.decide({ sessionID: session.id, capability: "remote_job" }) + expect(sandboxed).toMatchObject({ + allowed: false, + reason: Sandbox.available() ? "project_untrusted" : "sandbox_unavailable", + mode: "read_only", }) - expect(await Bun.file(marker).exists()).toBe(false) + if (Sandbox.available()) { + expect(sandboxed.message).toContain("Trust this project") + expect(sandboxed.remediation?.code).toBe("trust_project_required") + } }, }) }) +test("global policy can require project trust even for enforced sandbox execution", async () => { + const previous = await Config.trustedSandbox() + try { + await Config.setSandbox({ enabled: true, onUnavailable: "error", requireProjectTrust: true }) + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await ProjectTrust.update(Instance.project, { trusted: false }) + const session = await Session.create({}) + const decision = await ExecutionAuthority.decide({ sessionID: session.id, capability: "shell" }) + expect(decision).toMatchObject({ + allowed: false, + reason: Sandbox.available() ? "project_untrusted" : "sandbox_unavailable", + mode: "read_only", + sandbox: { requireProjectTrust: true }, + }) + if (Sandbox.available()) { + expect(decision.message).toContain("global Sandbox policy requires explicit trust") + expect(decision.remediation?.code).toBe("trust_project_required") + } + }, + }) + } finally { + await Config.setSandbox(previous) + } +}) + +test("unsandboxed host execution requires trust, then runs only after trust is granted", async () => { + const previous = await Config.trustedSandbox() + try { + await Config.setSandbox({ enabled: false, requireProjectTrust: false }) + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await ProjectTrust.update(Instance.project, { trusted: false }) + const session = await Session.create({}) + const denied = await ExecutionAuthority.decide({ sessionID: session.id, capability: "shell" }) + expect(denied).toMatchObject({ + allowed: false, + reason: "project_untrusted", + mode: "read_only", + sandbox: { enabled: false, enforced: false }, + }) + expect(denied.message).toContain("without an enforced OS sandbox") + const error = await ExecutionAuthority.require({ sessionID: session.id, capability: "shell" }).catch( + (cause) => cause, + ) + expect(error).toBeInstanceOf(ExecutionAuthority.DeniedError) + expect(error.message).toBe(denied.message) + expect(error.toObject()).toMatchObject({ + name: "ExecutionAuthorityDeniedError", + data: { message: denied.message }, + }) + + const status = await ProjectTrust.status(Instance.project) + await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + expect(await ExecutionAuthority.decide({ sessionID: session.id, capability: "shell" })).toMatchObject({ + allowed: true, + reason: "allowed", + mode: "host", + }) + }, + }) + } finally { + await Config.setSandbox(previous) + } +}) + test("authority generations change with trust and filesystem revisions", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ diff --git a/backend/cli/test/science/execution-files.test.ts b/backend/cli/test/science/execution-files.test.ts index 1c3835dc..e553f996 100644 --- a/backend/cli/test/science/execution-files.test.ts +++ b/backend/cli/test/science/execution-files.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import fs from "node:fs/promises" import path from "node:path" import { tmpdir } from "../fixture/fixture" import { changed, snapshot } from "../../src/science/execution/files" @@ -10,7 +11,14 @@ describe("execution workspace file observation", () => { await Bun.write(path.join(tmp.path, "changed.csv"), "a\n1\n") const before = await snapshot(tmp.path) - await Bun.write(path.join(tmp.path, "changed.csv"), "a\n2\n") + const changedPath = path.join(tmp.path, "changed.csv") + await Bun.write(changedPath, "a\n2\n") + // The observer deliberately fingerprints metadata before hashing bounded + // candidates. Pin a later mtime so this same-size rewrite is deterministic + // even on filesystems whose timestamp granularity collapses rapid writes. + const priorMtime = before.get("changed.csv")?.mtimeMs ?? Date.now() + const changedAt = new Date(Math.ceil(priorMtime) + 1_000) + await fs.utimes(changedPath, changedAt, changedAt) await Bun.write(path.join(tmp.path, "figure.txt"), "result") await Bun.write(path.join(tmp.path, ".venv", "ignored.txt"), "dependency") diff --git a/backend/cli/test/science/kernel-authority-policy.test.ts b/backend/cli/test/science/kernel-authority-policy.test.ts new file mode 100644 index 00000000..c635964b --- /dev/null +++ b/backend/cli/test/science/kernel-authority-policy.test.ts @@ -0,0 +1,92 @@ +import { expect, test } from "bun:test" +import { rmSync } from "fs" +import os from "os" +import path from "path" +import { Config } from "../../src/config/config" +import { Instance } from "../../src/project/instance" +import { ProjectTrust } from "../../src/project/trust" +import { Sandbox } from "../../src/sandbox/sandbox" +import { KernelRuntime, type KernelIdentity } from "../../src/science/kernel/registry" +import type { KernelStartOptions } from "../../src/science/kernel/types" +import { Session } from "../../src/session" +import { pythonKernels } from "../../src/tool/notebook" +import { tmpdir } from "../fixture/fixture" + +test("kernel spawn keeps the sandbox policy authorized before a global policy flip", async () => { + if (!Sandbox.available()) return + expect(Bun.which("python3") ?? Bun.which("python")).toBeTruthy() + + await using tmp = await tmpdir({ git: true }) + const outside = path.join(os.homedir(), `.openscience-kernel-policy-race-${crypto.randomUUID()}`) + rmSync(outside, { force: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await ProjectTrust.update(Instance.project, { trusted: false }) + const session = await Session.create({}) + const identity: KernelIdentity = { + projectID: Instance.project.id, + sessionID: session.id, + name: "authority-policy-race", + language: "python", + } + const authorizedPolicy = { + enabled: true, + network: "deny" as const, + allowWrite: [], + onUnavailable: "error" as const, + requireProjectTrust: false, + } + const mutableConfig = Config as { trustedSandbox: typeof Config.trustedSandbox } + const originalPolicyResolver = mutableConfig.trustedSandbox + const originalKernelGet = pythonKernels.get + let spawnBoundaryEntered = false + let policyReadsAfterAuthorization = 0 + let captured: KernelStartOptions["sandboxPolicy"] + + mutableConfig.trustedSandbox = async () => { + if (!spawnBoundaryEntered) return authorizedPolicy + policyReadsAfterAuthorization++ + return { ...authorizedPolicy, enabled: false } + } + pythonKernels.get = async (sessionID, options) => { + captured = options?.sandboxPolicy + // KernelRuntime invokes the manager only after its final + // ExecutionAuthority.require. Simulate the machine-wide setting + // changing at this exact boundary, before Python is spawned. + spawnBoundaryEntered = true + return originalKernelGet.call(pythonKernels, sessionID, options) + } + + try { + const result = await KernelRuntime.execute( + identity, + `from pathlib import Path\nPath(${JSON.stringify(outside)}).write_text("escaped")`, + { timeout: 30_000 }, + ) + + expect(captured).toMatchObject({ + enabled: true, + network: "deny", + onUnavailable: "error", + }) + expect(Object.isFrozen(captured)).toBe(true) + expect(Object.isFrozen(captured?.allowWrite)).toBe(true) + expect(policyReadsAfterAuthorization).toBe(0) + expect(result.ok).toBe(false) + expect(await Bun.file(outside).exists()).toBe(false) + expect(KernelRuntime.status(identity)).toMatchObject({ + active: true, + authority: { mode: "sandboxed", sandbox: { enabled: true, enforced: true } }, + environment: { sandbox: { requested: true, enforced: true, network: "deny" } }, + }) + } finally { + pythonKernels.get = originalKernelGet + mutableConfig.trustedSandbox = originalPolicyResolver + await KernelRuntime.removeSession(identity.projectID, identity.sessionID) + rmSync(outside, { force: true }) + } + }, + }) +}) diff --git a/backend/cli/test/science/kernel-lease.test.ts b/backend/cli/test/science/kernel-lease.test.ts index dcf19031..dd45d153 100644 --- a/backend/cli/test/science/kernel-lease.test.ts +++ b/backend/cli/test/science/kernel-lease.test.ts @@ -5,6 +5,69 @@ import path from "node:path" import { AuthorityProcessLedger } from "../../src/project/authority-process" import { KernelProcessIdentity } from "../../src/science/kernel/process" +const dataRootFixture = path.resolve(import.meta.dir, "../fixture/kernel-data-root-lifecycle.ts") +const registrationRaceFixture = path.resolve(import.meta.dir, "../fixture/kernel-registration-race.ts") + +function dataRootEnvironment(root: string): Record { + const env: Record = { + ...process.env, + OPENSCIENCE_CONFIG_DIR: path.join(root, "config"), + OPENSCIENCE_TEST_HOME: path.join(root, "home"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_HOME: path.join(root, "config-xdg"), + XDG_DATA_HOME: path.join(root, "data-xdg"), + XDG_STATE_HOME: path.join(root, "state-xdg"), + } + delete env.OPENSCIENCE_DATA_DIR + return env +} + +async function invokeDataRoot(root: string, workspace: string, ...args: string[]) { + const proc = Bun.spawn([process.execPath, dataRootFixture, workspace, ...args], { + env: dataRootEnvironment(root), + stdout: "pipe", + stderr: "pipe", + }) + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + return { code, stdout, stderr } +} + +async function waitDataRootJson(file: string, attempt = 0): Promise { + const value = await Bun.file(file) + .json() + .catch(() => undefined) + if (value) return value as T + if (attempt >= 1_000) throw new Error(`Timed out waiting for ${file}`) + await Bun.sleep(10) + return waitDataRootJson(file, attempt + 1) +} + +async function exactProcessGone(target: { pid: number; identity: string }, attempt = 0): Promise { + if (!(await AuthorityProcessLedger.owns(target.pid, target.identity))) return true + if (attempt >= 500) return false + await Bun.sleep(10) + return exactProcessGone(target, attempt + 1) +} + +async function findText(root: string, needle: string): Promise { + const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []) + for (const entry of entries) { + const target = path.join(root, entry.name) + if (entry.isDirectory()) { + const nested = await findText(target, needle) + if (nested) return nested + continue + } + if (!entry.isFile()) continue + const text = await fs.readFile(target, "utf8").catch(() => undefined) + if (text?.includes(needle)) return text + } +} + // This launches two real servers and waits for native ownership registration, // lease arbitration, and verified teardown; it is not a 5s unit operation. test("two servers cannot start the same persistent kernel identity", async () => { @@ -44,8 +107,13 @@ await Instance.provide({ directory: process.argv[2], fn: async () => { const existing = kernels.get(id) if (existing) return existing await fs.appendFile(${JSON.stringify(marker)}, "start\\n") - const wrapped = WindowsJobLauncher.wrap({ file: Bun.which("sleep") || "/bin/sleep", args: ["30"] }) + const wrapped = WindowsJobLauncher.wrap({ + file: Bun.which("sleep") || "/bin/sleep", + args: ["30"], + linuxOwner: options?.processOwnership?.linuxOwner, + }) const child = Bun.spawn([wrapped.file, ...wrapped.args], { detached: true, stdout: "ignore", stderr: "ignore" }) + WindowsJobLauncher.bind(child, wrapped.release) const ownership = options?.processOwnership ? { ...options.processOwnership, windowsRelease: wrapped.release } : undefined const identity = await KernelProcessIdentity.register(child, ownership) if (!identity) throw new Error("Kernel child exited before registration") @@ -168,8 +236,13 @@ await Instance.provide({ directory: process.argv[2], fn: async () => { async get(id, options) { const existing = kernels.get(id) if (existing) return existing - const wrapped = WindowsJobLauncher.wrap({ file: Bun.which("sleep") || "/bin/sleep", args: ["30"] }) + const wrapped = WindowsJobLauncher.wrap({ + file: Bun.which("sleep") || "/bin/sleep", + args: ["30"], + linuxOwner: options?.processOwnership?.linuxOwner, + }) const child = Bun.spawn([wrapped.file, ...wrapped.args], { detached: true, stdout: "ignore", stderr: "ignore" }) + WindowsJobLauncher.bind(child, wrapped.release) const ownership = options?.processOwnership ? { ...options.processOwnership, windowsRelease: wrapped.release } : undefined const identity = await KernelProcessIdentity.register(child, ownership) if (!identity) throw new Error("Kernel child exited before registration") @@ -308,7 +381,7 @@ await Instance.provide({ directory: process.argv[2], fn: async () => { } }, 30_000) -test("a fresh server reaps surviving kernel children after their recorded leader exits", async () => { +test("the registered containment leader drains workers after the payload leader exits", async () => { if (process.platform === "win32") return const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-kernel-leader-exit-")) const workspace = path.join(root, "workspace") @@ -386,7 +459,9 @@ test("a fresh server reaps surviving kernel children after their recorded leader return leaderGone(attempt + 1) } expect(await leaderGone()).toBe(true) - expect(await AuthorityProcessLedger.owns(child.pid, child.identity)).toBe(process.platform !== "darwin") + // The registered containment leader must not disappear until its payload + // leader's surviving same-group worker has been drained. + expect(await AuthorityProcessLedger.owns(child.pid, child.identity)).toBe(false) owner.kill("SIGKILL") await owner.exited @@ -405,7 +480,356 @@ test("a fresh server reaps surviving kernel children after their recorded leader } }, 30_000) -test("a cross-process trust revocation requested before spawn cannot leave an executable kernel", async () => { +test("server SIGKILL cannot let relocation pass a surviving kernel containment group", async () => { + if (process.platform === "win32") return + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-kernel-data-root-relocation-")) + const workspace = path.join(root, "workspace") + const ready = path.join(root, "owner.json") + const moved = path.join(root, "moved.json") + const target = path.join(root, "relocated") + await fs.mkdir(workspace, { recursive: true }) + let owner: ReturnType | undefined + let containment: { pid: number; identity: string } | undefined + let worker: { pid: number; identity: string } | undefined + try { + const setup = await invokeDataRoot(root, workspace, "setup") + expect(setup.code, setup.stderr).toBe(0) + const sessionID = setup.stdout.trim() + owner = Bun.spawn([process.execPath, dataRootFixture, workspace, "owner-ready", sessionID, ready], { + env: dataRootEnvironment(root), + stdout: "ignore", + stderr: "pipe", + }) + const published = await waitDataRootJson<{ + process: { pid: number; token: string; ownershipID: string } + worker: { pid: number } + }>(ready) + containment = { pid: published.process.pid, identity: published.process.token } + const workerIdentity = await AuthorityProcessLedger.identity(published.worker.pid) + expect(workerIdentity).toBeDefined() + worker = { pid: published.worker.pid, identity: workerIdentity! } + expect(await AuthorityProcessLedger.owns(containment.pid, containment.identity)).toBe(true) + expect(await AuthorityProcessLedger.owns(worker.pid, worker.identity)).toBe(true) + + const operationFiles = await fs.readdir(path.join(root, "config", "data-root-operations")) + const childMarker = await Promise.all( + operationFiles.map((name) => Bun.file(path.join(root, "config", "data-root-operations", name)).json()), + ).then((records) => + records.find((record) => record.pid === containment!.pid && record.identity === containment!.identity), + ) + expect(childMarker).toBeDefined() + const oldRoot = await fs.realpath(path.join(root, "home", ".openscience")) + const held = path.join(oldRoot, "kernel-held-open.log") + const before = (await fs.stat(held)).size + + owner.kill("SIGKILL") + await owner.exited + owner = undefined + const relocation = await invokeDataRoot(root, workspace, "relocate", "", target, moved) + expect(relocation.code, relocation.stderr).toBe(0) + const result = await waitDataRootJson<{ source: string; target: string }>(moved) + expect(result.source).toBe(oldRoot) + expect(await fs.realpath(result.target)).toBe(await fs.realpath(target)) + expect(await exactProcessGone(containment)).toBe(true) + expect(await exactProcessGone(worker)).toBe(true) + const settled = (await fs.stat(held)).size + expect(settled).toBeGreaterThanOrEqual(before) + await Bun.sleep(100) + expect((await fs.stat(held)).size).toBe(settled) + } finally { + owner?.kill("SIGKILL") + await owner?.exited.catch(() => undefined) + if (containment && !(await exactProcessGone(containment))) process.kill(containment.pid, "SIGKILL") + if (worker && !(await exactProcessGone(worker))) process.kill(worker.pid, "SIGKILL") + await fs.rm(root, { recursive: true, force: true }) + } +}, 30_000) + +test("recovery reclaims durable kernel ownership when child pointer publication is interrupted", async () => { + if (process.platform === "win32") return + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-kernel-pointer-crash-")) + const workspace = path.join(root, "workspace") + const registered = path.join(root, "registered.json") + await fs.mkdir(workspace, { recursive: true }) + let owner: ReturnType | undefined + let containment: { pid: number; identity: string } | undefined + try { + const setup = await invokeDataRoot(root, workspace, "setup") + expect(setup.code, setup.stderr).toBe(0) + const sessionID = setup.stdout.trim() + owner = Bun.spawn([process.execPath, dataRootFixture, workspace, "owner-crash-window", sessionID, registered], { + env: dataRootEnvironment(root), + stdout: "ignore", + stderr: "pipe", + }) + const processRecord = await waitDataRootJson<{ pid: number; token: string; ownershipID: string }>(registered) + containment = { pid: processRecord.pid, identity: processRecord.token } + const data = await fs.realpath(path.join(root, "home", ".openscience")) + const persisted = await findText(path.join(data, "storage", "kernel_registry"), processRecord.ownershipID) + expect(persisted).toContain(`"ownership_id": "${processRecord.ownershipID}"`) + expect(persisted).toContain('"process": null') + + owner.kill("SIGKILL") + await owner.exited + owner = undefined + const recovered = await invokeDataRoot(root, workspace, "recover", sessionID) + expect(recovered.code, recovered.stderr).toBe(0) + expect(await exactProcessGone(containment)).toBe(true) + expect(await Bun.file(path.join(root, "home", ".openscience", "authority-processes.json")).json()).toEqual([]) + const cleared = await findText(path.join(data, "storage", "kernel_registry"), processRecord.ownershipID) + expect(cleared).toBeUndefined() + } finally { + owner?.kill("SIGKILL") + await owner?.exited.catch(() => undefined) + if (containment && !(await exactProcessGone(containment))) process.kill(containment.pid, "SIGKILL") + await fs.rm(root, { recursive: true, force: true }) + } +}, 30_000) + +test("a missed first revoke preserves the lexical startup ID through late registration", async () => { + if (process.platform === "win32") return + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-kernel-registration-race-")) + const workspace = path.join(root, "workspace") + const result = path.join(root, "result.json") + await fs.mkdir(workspace, { recursive: true }) + try { + const setup = Bun.spawn([process.execPath, registrationRaceFixture, workspace, "setup"], { + env: dataRootEnvironment(root), + stdout: "pipe", + stderr: "pipe", + }) + const [setupCode, sessionID, setupError] = await Promise.all([ + setup.exited, + new Response(setup.stdout).text(), + new Response(setup.stderr).text(), + ]) + expect(setupCode, setupError).toBe(0) + + const race = Bun.spawn([process.execPath, registrationRaceFixture, workspace, "race", sessionID.trim(), result], { + env: dataRootEnvironment(root), + stdout: "pipe", + stderr: "pipe", + }) + const [code, stderr] = await Promise.all([race.exited, new Response(race.stderr).text()]) + expect(code, stderr).toBe(0) + const outcome = await waitDataRootJson<{ + ownershipID: string + started: PromiseSettledResult["status"] + stopped: PromiseSettledResult["status"] + ledger: unknown[] + marker: boolean + order: string + }>(result) + expect(outcome.ownershipID).toStartWith("kernel-") + expect(outcome.started).toBe("rejected") + expect(outcome.stopped).toBe("fulfilled") + expect(outcome.ledger).toEqual([]) + expect(outcome.marker).toBe(false) + expect(outcome.order).toContain("registered:ledger-absent") + expect(outcome.order).not.toContain("pre-registration") + expect(outcome.order).not.toContain("ledger-live") + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}, 30_000) + +test("an exact absent-ID revoke disposes same-process child data-root coverage", async () => { + if (process.platform === "win32") return + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-kernel-absent-coverage-")) + const workspace = path.join(root, "workspace") + const result = path.join(root, "result.json") + await fs.mkdir(workspace, { recursive: true }) + try { + const setup = Bun.spawn([process.execPath, registrationRaceFixture, workspace, "setup"], { + env: dataRootEnvironment(root), + stdout: "pipe", + stderr: "pipe", + }) + const [setupCode, sessionID, setupError] = await Promise.all([ + setup.exited, + new Response(setup.stdout).text(), + new Response(setup.stderr).text(), + ]) + expect(setupCode, setupError).toBe(0) + const coverage = Bun.spawn( + [process.execPath, registrationRaceFixture, workspace, "coverage", sessionID.trim(), result], + { env: dataRootEnvironment(root), stdout: "pipe", stderr: "pipe" }, + ) + const [code, stderr] = await Promise.all([coverage.exited, new Response(coverage.stderr).text()]) + expect(code, stderr).toBe(0) + expect(await waitDataRootJson<{ marker: boolean; ledger: unknown[] }>(result)).toEqual({ + marker: false, + ledger: [], + }) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}, 30_000) + +test("legacy POSIX kernel ownership quarantines relocation after its leader exits with an escaped worker", async () => { + if (process.platform === "win32") return + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-kernel-legacy-containment-")) + const runner = path.join(root, "legacy.ts") + const leader = path.join(root, "leader.ts") + const authority = new URL("../../src/project/authority-process.ts", import.meta.url).href + const relocation = new URL("../../src/global/data-relocation.ts", import.meta.url).href + const global = new URL("../../src/global/index.ts", import.meta.url).href + await fs.writeFile( + leader, + ` +import fs from "node:fs/promises" +const worker = Bun.spawn([${JSON.stringify(Bun.which("sleep") || "/bin/sleep")}, "30"], { + detached: true, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", +}) +worker.unref() +await fs.writeFile(process.argv[2], String(worker.pid)) +while (!(await Bun.file(process.argv[3]).exists())) await Bun.sleep(10) +`, + ) + await fs.writeFile( + runner, + ` +import fs from "node:fs/promises" +import path from "node:path" +import { AuthorityProcessLedger } from ${JSON.stringify(authority)} +import { DataRelocation } from ${JSON.stringify(relocation)} +import { Global } from ${JSON.stringify(global)} +const workerReady = path.join(${JSON.stringify(root)}, "worker-ready") +const leaderRelease = path.join(${JSON.stringify(root)}, "leader-release") +const child = Bun.spawn([process.execPath, ${JSON.stringify(leader)}, workerReady, leaderRelease], { + detached: true, + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", +}) +let leaderIdentity +for (let attempt = 0; attempt < 100 && !leaderIdentity; attempt++) { + leaderIdentity = await AuthorityProcessLedger.identity(child.pid) + if (!leaderIdentity) await Bun.sleep(10) +} +if (!leaderIdentity) throw new Error("legacy fixture has no leader identity") +for (let attempt = 0; attempt < 100 && !(await Bun.file(workerReady).exists()); attempt++) await Bun.sleep(10) +const workerPID = Number((await fs.readFile(workerReady, "utf8")).trim()) +const workerIdentity = await AuthorityProcessLedger.identity(workerPID) +if (!workerIdentity) throw new Error("legacy fixture has no escaped worker identity") +const id = "kernel-legacy-containment-test" +const ledger = AuthorityProcessLedger.pathForTests() +await fs.mkdir(path.dirname(ledger), { recursive: true }) +await fs.writeFile(ledger, JSON.stringify([{ + version: 1, + id, + kind: "kernel", + pid: child.pid, + identity: leaderIdentity, + owns_process_group: true, + owner_pid: process.pid, + project_id: "legacy-project", + session_id: "legacy-session", + authority_generation: "legacy-generation", + created_at: new Date().toISOString(), +}], null, 2)) +let rejected = false +try { await AuthorityProcessLedger.revoke({ id, kind: "kernel" }) } catch { rejected = true } +await fs.writeFile(leaderRelease, "release") +await child.exited +for (let attempt = 0; attempt < 100 && await AuthorityProcessLedger.owns(child.pid, leaderIdentity); attempt++) { + await Bun.sleep(10) +} +const leaderGone = !(await AuthorityProcessLedger.owns(child.pid, leaderIdentity)) +const workerAliveBefore = await AuthorityProcessLedger.owns(workerPID, workerIdentity) +const source = await Global.Path.dataTarget +const target = path.join(${JSON.stringify(root)}, "relocated") +let relocationError = "" +try { await DataRelocation.relocate(target) } catch (error) { relocationError = String(error) } +const entries = await Bun.file(ledger).json() +const files = await fs.readdir(path.join(Global.Path.config, "data-root-operations")).catch(() => []) +const markers = await Promise.all(files.map((name) => Bun.file(path.join(Global.Path.config, "data-root-operations", name)).json())) +const workerAliveAfter = await AuthorityProcessLedger.owns(workerPID, workerIdentity) +console.log(JSON.stringify({ + rejected, + leaderGone, + workerAliveBefore, + workerAliveAfter, + retained: entries.some((entry) => entry.id === id && entry.containment === undefined), + staleMarkerReaped: !markers.some((marker) => marker.pid === child.pid && marker.identity === leaderIdentity), + relocationBlocked: relocationError.includes("legacy kernel process"), + targetUnchanged: await Global.Path.dataTarget === source, +})) +try { process.kill(-workerPID, "SIGKILL") } catch {} +`, + ) + try { + const proc = Bun.spawn([process.execPath, runner], { + env: dataRootEnvironment(root), + stdout: "pipe", + stderr: "pipe", + }) + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + expect(code, stderr).toBe(0) + expect(JSON.parse(stdout.trim())).toEqual({ + rejected: true, + leaderGone: true, + workerAliveBefore: true, + workerAliveAfter: true, + retained: true, + staleMarkerReaped: true, + relocationBlocked: true, + targetUnchanged: true, + }) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}, 30_000) + +test("cooperative kernel teardown drains a concurrent detached fork storm before its anchor exits", async () => { + if (process.platform === "win32") return + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-kernel-fork-storm-")) + const workspace = path.join(root, "workspace") + const result = path.join(root, "result.json") + await fs.mkdir(workspace, { recursive: true }) + try { + const setup = Bun.spawn([process.execPath, registrationRaceFixture, workspace, "setup"], { + env: dataRootEnvironment(root), + stdout: "pipe", + stderr: "pipe", + }) + const [setupCode, sessionID, setupError] = await Promise.all([ + setup.exited, + new Response(setup.stdout).text(), + new Response(setup.stderr).text(), + ]) + expect(setupCode, setupError).toBe(0) + const storm = Bun.spawn( + [process.execPath, registrationRaceFixture, workspace, "fork-storm", sessionID.trim(), result], + { env: dataRootEnvironment(root), stdout: "pipe", stderr: "pipe" }, + ) + const [code, stderr] = await Promise.all([storm.exited, new Response(storm.stderr).text()]) + expect(code, stderr).toBe(0) + const outcome = await waitDataRootJson<{ + forks: number + alive: number[] + containmentAlive: boolean + ledger: unknown[] + marker: boolean + }>(result) + expect(outcome.forks).toBeGreaterThanOrEqual(30) + expect(outcome.alive).toEqual([]) + expect(outcome.containmentAlive).toBe(false) + expect(outcome.ledger).toEqual([]) + expect(outcome.marker).toBe(false) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}, 30_000) + +test("a cross-process strict trust revocation requested before spawn cannot leave an executable kernel", async () => { if (process.platform === "win32") return const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-kernel-authority-race-")) const workspace = path.join(root, "workspace") @@ -425,6 +849,11 @@ test("a cross-process trust revocation requested before spawn cannot leave an ex const execute = path.join(root, "execute") const result = path.join(root, "result") await fs.mkdir(workspace) + await fs.mkdir(path.join(root, "config")) + await Bun.write( + path.join(root, "config", "openscience.json"), + JSON.stringify({ sandbox: { requireProjectTrust: true } }), + ) await Bun.write( runner, ` @@ -463,8 +892,13 @@ await Instance.provide({ directory: process.argv[2], fn: async () => { async get(id, options) { await fs.writeFile(${JSON.stringify(entered)}, "entered") await wait(${JSON.stringify(release)}) - const wrapped = WindowsJobLauncher.wrap({ file: Bun.which("sleep") || "/bin/sleep", args: ["30"] }) + const wrapped = WindowsJobLauncher.wrap({ + file: Bun.which("sleep") || "/bin/sleep", + args: ["30"], + linuxOwner: options?.processOwnership?.linuxOwner, + }) const child = Bun.spawn([wrapped.file, ...wrapped.args], { detached: true, stdout: "ignore", stderr: "ignore" }) + WindowsJobLauncher.bind(child, wrapped.release) const ownership = options?.processOwnership ? { ...options.processOwnership, windowsRelease: wrapped.release } : undefined const identity = await KernelProcessIdentity.register(child, ownership) if (!identity) throw new Error("Kernel child exited before registration") diff --git a/backend/cli/test/science/kernel-process-order.test.ts b/backend/cli/test/science/kernel-process-order.test.ts index ce076ac0..e63285c7 100644 --- a/backend/cli/test/science/kernel-process-order.test.ts +++ b/backend/cli/test/science/kernel-process-order.test.ts @@ -6,6 +6,95 @@ import path from "node:path" const posixTest = process.platform === "win32" ? test.skip : test const fixture = path.resolve(import.meta.dir, "../fixture/kernel-built-in-setsid.ts") +test("the relocation quarantine parses every ledger and requires Windows Job containment", async () => { + const source = await fs.readFile(path.resolve(import.meta.dir, "../../src/project/authority-process.ts"), "utf8") + const start = source.indexOf("export async function assertRelocationSafe") + const end = source.indexOf("export function pathForTests", start) + const policy = source.slice(start, end) + expect(start).toBeGreaterThan(-1) + expect(end).toBeGreaterThan(start) + expect(policy.indexOf("await read()")).toBeLessThan(policy.indexOf("process.platform")) + expect(policy).toContain(': "windows_job_v1"') + expect(policy).toContain('(process.platform === "win32" && !entry.windows_job)') +}) + +test("registered synchronous exit never raw-kills its containment anchor", async () => { + const source = await fs.readFile(path.resolve(import.meta.dir, "../../src/science/kernel/process.ts"), "utf8") + const start = source.indexOf("export function terminateSync") + const end = source.indexOf("export async function complete", start) + const handoff = source.slice(start, end) + expect(start).toBeGreaterThan(-1) + expect(end).toBeGreaterThan(start) + expect(handoff).toContain("if (!identity?.ownershipID) return false") + expect(handoff).toContain('if (process.platform === "win32") return true') + expect(handoff).not.toContain("Shell.kill") +}) + +posixTest("built-in registration cleanup keeps its lexical ID and fails closed on ledger errors", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-kernel-register-cleanup-")) + const runner = path.join(root, "runner.ts") + const processModule = new URL("../../src/science/kernel/process.ts", import.meta.url).href + const ledgerModule = new URL("../../src/project/authority-process.ts", import.meta.url).href + const [notebook, rkernel, identitySource] = await Promise.all([ + fs.readFile(path.resolve(import.meta.dir, "../../src/tool/notebook.ts"), "utf8"), + fs.readFile(path.resolve(import.meta.dir, "../../src/tool/rkernel.ts"), "utf8"), + fs.readFile(path.resolve(import.meta.dir, "../../src/science/kernel/process.ts"), "utf8"), + ]) + expect(notebook).toContain("await this.terminate(proc, ownership?.id)") + expect(rkernel).toContain("await this.terminate(proc, ownership?.id)") + expect(identitySource).toContain("if (identity.ownershipID || revoked > 0)") + await fs.writeFile( + runner, + ` +import fs from "node:fs/promises" +import path from "node:path" +import { spawn } from "node:child_process" +import { KernelProcessIdentity } from ${JSON.stringify(processModule)} +import { AuthorityProcessLedger } from ${JSON.stringify(ledgerModule)} +const child = spawn(${JSON.stringify(Bun.which("sleep") || "/bin/sleep")}, ["30"], { detached: true, stdio: "ignore" }) +const captured = KernelProcessIdentity.capture(child) +if (!captured) throw new Error("missing child identity") +const ledger = AuthorityProcessLedger.pathForTests() +await fs.mkdir(path.dirname(ledger), { recursive: true }) +await fs.writeFile(ledger, "{corrupt") +let rejected = false +try { + await KernelProcessIdentity.terminate({ ...captured, ownershipID: "kernel-cleanup-order-test" }) +} catch { + rejected = true +} +const alive = KernelProcessIdentity.matchesRecorded(captured) +await fs.writeFile(ledger, "[]") +try { process.kill(-captured.pid, "SIGKILL") } catch {} +console.log(JSON.stringify({ rejected, alive })) +`, + ) + try { + const proc = Bun.spawn([process.execPath, runner], { + env: { + ...process.env, + OPENSCIENCE_CONFIG_DIR: path.join(root, "config"), + OPENSCIENCE_TEST_HOME: path.join(root, "home"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_HOME: path.join(root, "config-xdg"), + XDG_DATA_HOME: path.join(root, "data-xdg"), + XDG_STATE_HOME: path.join(root, "state-xdg"), + }, + stdout: "pipe", + stderr: "pipe", + }) + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + expect(code, stderr).toBe(0) + expect(JSON.parse(stdout.trim())).toEqual({ rejected: true, alive: true }) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + async function scenario(language: "python" | "r") { const root = await fs.mkdtemp(path.join(os.tmpdir(), `openscience-${language}-kernel-setsid-`)) const workspace = path.join(root, "workspace") @@ -63,6 +152,67 @@ posixTest( 30_000, ) +test.skipIf(process.platform !== "darwin")( + "graceful server exit cooperatively drains the Darwin supervisor responsibility", + async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "openscience-python-kernel-exit-sync-")) + const workspace = path.join(root, "workspace") + const marker = path.join(root, "descendant.pid") + const ready = path.join(root, "ready.json") + const config = path.join(root, "config") + await Promise.all([fs.mkdir(workspace), fs.mkdir(config)]) + await fs.writeFile(path.join(config, "config.json"), JSON.stringify({ sandbox: { enabled: false } })) + const owner = Bun.spawn([process.execPath, fixture, workspace, "python", marker, "wait-for-signal", ready], { + env: { + ...process.env, + OPENSCIENCE_CONFIG_CONTENT: JSON.stringify({ sandbox: { enabled: false } }), + OPENSCIENCE_DATA_DIR: path.join(root, "data"), + OPENSCIENCE_CONFIG_DIR: config, + OPENSCIENCE_TEST_HOME: path.join(root, "home"), + XDG_CACHE_HOME: path.join(root, "cache"), + XDG_CONFIG_HOME: path.join(root, "config-xdg"), + XDG_DATA_HOME: path.join(root, "data-xdg"), + XDG_STATE_HOME: path.join(root, "state-xdg"), + }, + stdout: "pipe", + stderr: "pipe", + }) + const waitReady = async (attempt = 0): Promise<{ kernelPID: number; childPID: number }> => { + const value = await Bun.file(ready) + .json() + .catch(() => undefined) + if (value) return value as { kernelPID: number; childPID: number } + if (attempt >= 500) throw new Error(`Timed out waiting for ${ready}`) + await Bun.sleep(10) + return waitReady(attempt + 1) + } + const gone = async (pid: number, attempt = 0): Promise => { + try { + process.kill(pid, 0) + } catch { + return true + } + if (attempt >= 500) return false + await Bun.sleep(10) + return gone(pid, attempt + 1) + } + try { + const published = await waitReady() + owner.kill("SIGTERM") + const code = await owner.exited + const stderr = await new Response(owner.stderr).text() + expect(code, stderr).toBe(143) + expect(await gone(published.childPID)).toBe(true) + expect(await gone(published.kernelPID)).toBe(true) + } finally { + owner.kill("SIGKILL") + await owner.exited.catch(() => undefined) + await fs.rm(root, { recursive: true, force: true }) + } + }, + 30_000, +) + test.skipIf(process.platform === "win32" || !Bun.which("Rscript"))( "built-in R release reaps a different-process-group descendant before killing the kernel leader", async () => { diff --git a/backend/cli/test/science/kernel/interpreter.test.ts b/backend/cli/test/science/kernel/interpreter.test.ts index ad0c2a7b..40380d6c 100644 --- a/backend/cli/test/science/kernel/interpreter.test.ts +++ b/backend/cli/test/science/kernel/interpreter.test.ts @@ -14,6 +14,8 @@ import { PythonTool } from "../../../src/tool/notebook" import { ExecutionAuthority } from "../../../src/project/execution" import { KernelRuntime, type KernelIdentity } from "../../../src/science/kernel/registry" import { AuthorityProcessLedger } from "../../../src/project/authority-process" +import { Sandbox } from "../../../src/sandbox/sandbox" +import { Global } from "../../../src/global" import "../../../src/tool/rkernel" test("Python environment names cannot escape the project virtual-environment directory", () => { @@ -44,7 +46,7 @@ test("a named Python environment resolves only its fixed project-local interpret expect(result.env?.PATH?.split(path.delimiter)[0]).toBe(bin) }) -test("an untrusted project .venv interpreter cannot execute during discovery", async () => { +test("project .venv discovery is side-effect free before sandboxed execution", async () => { await using tmp = await tmpdir({ git: true, init: async (dir) => { @@ -68,6 +70,9 @@ test("an untrusted project .venv interpreter cannot execute during discovery", a directory: tmp.path, fn: async () => { await ProjectTrust.update(Instance.project, { trusted: false }) + expect(await pythonEnvironment(tmp.path)).toMatchObject({ binary: expect.stringContaining(".venv") }) + expect(await Bun.file(tmp.extra.marker).exists()).toBe(false) + const session = await Session.create({}) const tool = await PythonTool.init() const run = tool.execute( @@ -84,8 +89,20 @@ test("an untrusted project .venv interpreter cannot execute during discovery", a }, ) - await expect(run).rejects.toBeInstanceOf(ExecutionAuthority.DeniedError) - expect(await Bun.file(tmp.extra.marker).exists()).toBe(false) + const error = await run.then( + () => undefined, + (cause) => cause, + ) + expect(error).toBeInstanceOf(Error) + if (Sandbox.describe().available) { + expect(error).not.toBeInstanceOf(ExecutionAuthority.DeniedError) + // Explicit execution may modify the granted project workspace; the + // authority-policy regression separately proves it cannot reach HOME. + expect(await Bun.file(tmp.extra.marker).text()).toBe("pwned") + } else { + expect(error).toBeInstanceOf(ExecutionAuthority.DeniedError) + expect(await Bun.file(tmp.extra.marker).exists()).toBe(false) + } }, }) }) @@ -130,16 +147,6 @@ done name: "r-discovery-boundary", language: "r", } - await expect( - KernelRuntime.execute(identity, "1 + 1", undefined, { - binary: tmp.extra.binary, - environmentName: "project-r", - }), - ).rejects.toBeInstanceOf(ExecutionAuthority.DeniedError) - expect(await Bun.file(tmp.extra.marker).exists()).toBe(false) - - const trust = await ProjectTrust.status(Instance.project) - await ProjectTrust.update(Instance.project, { trusted: true, root: trust.root }) try { const result = await KernelRuntime.execute(identity, "1 + 1", undefined, { binary: tmp.extra.binary, @@ -170,3 +177,61 @@ done }, 30_000, ) + +test.skipIf(process.platform === "win32")( + "spontaneous built-in launcher exits complete durable ownership without accumulation", + async () => { + if (!Bun.which("python3")) return + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await ProjectTrust.update(Instance.project, { trusted: false }) + const session = await Session.create({}) + const identity: KernelIdentity = { + projectID: Instance.project.id, + sessionID: session.id, + name: "python-spontaneous-exit", + language: "python", + } + const cleared = async ( + target: { pid: number; token?: string; ownershipID?: string }, + attempt = 0, + ): Promise => { + const ledger = (await Bun.file(AuthorityProcessLedger.pathForTests()) + .json() + .catch(() => [])) as Array<{ + id?: string + }> + const files = await fs.readdir(path.join(Global.Path.config, "data-root-operations")).catch(() => []) + const markers = await Promise.all( + files.map((name) => Bun.file(path.join(Global.Path.config, "data-root-operations", name)).json()), + ) + const owned = ledger.some((entry) => entry.id === target.ownershipID) + const marked = markers.some((marker) => marker.pid === target.pid && marker.identity === target.token) + if (!owned && !marked) return true + if (attempt >= 500) return false + await Bun.sleep(10) + return cleared(target, attempt + 1) + } + + try { + const ownership = new Set() + for (let cycle = 0; cycle < 3; cycle++) { + const kernel = await KernelRuntime.get(identity) + const processIdentity = kernel.process + expect(processIdentity?.ownershipID).toStartWith("kernel-") + expect(ownership.has(processIdentity!.ownershipID!)).toBe(false) + ownership.add(processIdentity!.ownershipID!) + await expect(KernelRuntime.execute(identity, "import os\nos._exit(0)")).rejects.toBeInstanceOf(Error) + expect(await cleared(processIdentity!)).toBe(true) + } + expect(ownership.size).toBe(3) + } finally { + await KernelRuntime.release(identity) + } + }, + }) + }, + 30_000, +) diff --git a/backend/cli/test/server/session-shell-security.test.ts b/backend/cli/test/server/session-shell-security.test.ts index 88b26b9b..9a3df5ef 100644 --- a/backend/cli/test/server/session-shell-security.test.ts +++ b/backend/cli/test/server/session-shell-security.test.ts @@ -2,11 +2,12 @@ import { expect, test } from "bun:test" import path from "node:path" import { Instance } from "../../src/project/instance" import { ProjectTrust } from "../../src/project/trust" +import { Sandbox } from "../../src/sandbox/sandbox" import { Server } from "../../src/server/server" import { Session } from "../../src/session" import { tmpdir } from "../fixture/fixture" -test("the legacy session shell route requires trust and keeps writes inside its sandbox", async () => { +test("the legacy session shell route runs untrusted projects only inside its sandbox", async () => { await using workspace = await tmpdir() await using outside = await tmpdir() const state = await Instance.provide({ @@ -32,21 +33,28 @@ test("the legacy session shell route requires trust and keeps writes inside its }, ) - const denied = await invoke() - expect(denied.status).toBe(403) - expect(await denied.json()).toMatchObject({ name: "ExecutionAuthorityDeniedError" }) + const initial = await invoke() + expect(initial.status).toBe(Sandbox.available() ? 200 : 403) + if (!Sandbox.available()) { + expect(await initial.json()).toMatchObject({ + name: "ExecutionAuthorityDeniedError", + data: { reason: "sandbox_unavailable" }, + }) + } expect(await Bun.file(target).exists()).toBe(false) - await Instance.provide({ - directory: workspace.path, - fn: async () => { - const status = await ProjectTrust.status(Instance.project) - await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) - }, - }) - const confined = await invoke() - expect(confined.status).toBe(200) - expect(await Bun.file(target).exists()).toBe(false) + if (Sandbox.available()) { + await Instance.provide({ + directory: workspace.path, + fn: async () => { + const status = await ProjectTrust.status(Instance.project) + await ProjectTrust.update(Instance.project, { trusted: true, root: status.root }) + }, + }) + const confined = await invoke() + expect(confined.status).toBe(200) + expect(await Bun.file(target).exists()).toBe(false) + } await Instance.provide({ directory: workspace.path, diff --git a/backend/cli/test/server/settings-billing.test.ts b/backend/cli/test/server/settings-billing.test.ts index 455a69e7..451531ce 100644 --- a/backend/cli/test/server/settings-billing.test.ts +++ b/backend/cli/test/server/settings-billing.test.ts @@ -1,4 +1,4 @@ -import { test, expect, afterEach } from "bun:test" +import { test, expect, beforeEach, afterEach } from "bun:test" import path from "path" import fs from "fs/promises" import { Global } from "../../src/global" @@ -7,17 +7,20 @@ import { BillingSettingsRoutes } from "../../src/server/routes/settings/billing" const file = path.join(Global.Path.config, "openscience.json") -afterEach(async () => { - await fs.rm(file, { force: true }).catch(() => {}) +async function resetGlobalConfig() { // globalConfigFile() (config.ts) also considers .jsonc and config.json - - // remove those too so a stray one left behind by another test/file in the - // same `bun test` run never shadows the openscience.json this file always - // writes and reads directly, and reset the in-memory Config.global cache - // (a deleted file alone does not un-memoize it). - await fs.rm(path.join(Global.Path.config, "openscience.jsonc"), { force: true }).catch(() => {}) - await fs.rm(path.join(Global.Path.config, "config.json"), { force: true }).catch(() => {}) + // establish the candidate-file precondition before every test as well as + // cleaning it afterwards, so a prior test cannot shadow the openscience.json + // this file writes and reads directly. A deleted file alone does not + // un-memoize Config.global, so reset that cache too. + for (const name of ["openscience.jsonc", "openscience.json", "config.json"]) { + await fs.rm(path.join(Global.Path.config, name), { force: true }).catch(() => {}) + } Config.global.reset() -}) +} + +beforeEach(resetGlobalConfig) +afterEach(resetGlobalConfig) test("PUT persists the toggle without baking resolved secrets into the config file", async () => { process.env["SPEND_TOGGLE_TEST_KEY"] = "sk-live-super-secret-123" diff --git a/backend/cli/test/server/settings-compute.test.ts b/backend/cli/test/server/settings-compute.test.ts index 114564b3..9fc1ec50 100644 --- a/backend/cli/test/server/settings-compute.test.ts +++ b/backend/cli/test/server/settings-compute.test.ts @@ -1,4 +1,4 @@ -import { test, expect, afterAll } from "bun:test" +import { test, expect, afterAll, spyOn } from "bun:test" import fs from "fs/promises" import path from "path" import { Project } from "../../src/project/project" @@ -12,6 +12,7 @@ import { ComputeSettings, ComputeSettingsRoutes } from "../../src/server/routes/ import { Sandbox } from "../../src/sandbox/sandbox" import { Log } from "../../src/util/log" import { Global } from "../../src/global" +import { ModalVolume } from "../../src/compute/modal/volume" import { executionSession, tmpdir } from "../fixture/fixture" Log.init({ print: false }) @@ -61,6 +62,14 @@ async function settle(url: string, id: string, headers: Record = throw new Error("Timed out waiting for route compute job") } +async function waitForRemoval(target: string) { + for (const _ of Array.from({ length: 100 })) { + if (!(await fs.stat(target).catch(() => undefined))) return + await Bun.sleep(10) + } + throw new Error(`Timed out waiting for ${target} to be removed`) +} + async function session(directory: string, trusted = true) { return Instance.provide({ directory, @@ -289,6 +298,213 @@ test("Modal config discovery defers inactive profile resolution until an enabled await ComputeSettings.disconnectProvider("modal") }) +test("Modal Volume browser downloads stream past the retired cap and clean up on cancel or abort", async () => { + const size = 256 * 1024 * 1024 + 1 + const staging: string[] = [] + const context = spyOn(ComputeSettings, "modalContext").mockResolvedValue({ + app: "openscience-test", + image: "python:3.12-slim", + network: "none", + timeoutMinutes: 10, + concurrency: 1, + tokenId: "ak-test", + tokenSecret: "as-test", + }) + const list = spyOn(ModalVolume, "list").mockResolvedValue([{ path: "large.bin", type: "file", size }]) + const download = spyOn(ModalVolume, "download").mockImplementation(async (_context, _volume, _paths, target) => { + staging.push(target) + const file = path.join(target, "large.bin") + const handle = await fs.open(file, "w") + await handle.truncate(size) + await handle.close() + return [{ path: "large.bin", staging: file, size, sha256: "0".repeat(64) }] + }) + try { + const cancelled = await ComputeSettingsRoutes().request("/modal/volumes/weights/file?path=/large.bin") + expect(cancelled.status).toBe(200) + expect(download.mock.calls[0]?.[4]?.declaredBytes).toBe(size) + expect(cancelled.headers.get("content-length")).toBe(String(size)) + expect(cancelled.headers.get("content-disposition")).toContain('filename="large.bin"') + const cancelledReader = cancelled.body!.getReader() + const first = await cancelledReader.read() + expect(first.done).toBe(false) + expect(first.value?.byteLength).toBeGreaterThan(0) + await cancelledReader.cancel() + await waitForRemoval(staging[0]!) + + const controller = new AbortController() + const aborted = await ComputeSettingsRoutes().request("/modal/volumes/weights/file?path=/large.bin", { + signal: controller.signal, + }) + const abortedReader = aborted.body!.getReader() + expect((await abortedReader.read()).done).toBe(false) + controller.abort() + await waitForRemoval(staging[1]!) + await abortedReader.cancel().catch(() => undefined) + } finally { + download.mockRestore() + list.mockRestore() + context.mockRestore() + await Promise.all(staging.map((target) => fs.rm(target, { recursive: true, force: true }))) + } +}) + +test("Modal Volume browser downloads abort blocked staging and await helper teardown before cleanup", async () => { + let staging: string | undefined + let stopped = false + const started = Promise.withResolvers() + const revoking = Promise.withResolvers() + const teardown = Promise.withResolvers() + const context = spyOn(ComputeSettings, "modalContext").mockResolvedValue({ + app: "openscience-test", + image: "python:3.12-slim", + network: "none", + timeoutMinutes: 10, + concurrency: 1, + tokenId: "ak-test", + tokenSecret: "as-test", + }) + const list = spyOn(ModalVolume, "list").mockResolvedValue([{ path: "blocked.bin", type: "file", size: 1 }]) + const download = spyOn(ModalVolume, "download").mockImplementation( + async (_context, _volume, _paths, target, options) => { + const signal = options?.signal + if (!signal) throw new Error("The Modal Volume route did not forward its request signal") + expect(options.declaredBytes).toBe(1) + staging = target + await fs.writeFile(path.join(target, "partial"), "staged") + const interrupted = Promise.withResolvers() + const abort = () => { + revoking.resolve() + void teardown.promise.then(() => { + stopped = true + interrupted.reject(signal.reason) + }) + } + if (signal.aborted) abort() + else signal.addEventListener("abort", abort, { once: true }) + started.resolve() + try { + return await interrupted.promise + } finally { + signal.removeEventListener("abort", abort) + } + }, + ) + const errors = spyOn(console, "error").mockImplementation(() => {}) + const controller = new AbortController() + const reason = new DOMException("browser disconnected", "AbortError") + + try { + const pending = ComputeSettingsRoutes().request("/modal/volumes/weights/file?path=/blocked.bin", { + signal: controller.signal, + }) + await started.promise + controller.abort(reason) + await revoking.promise + expect(stopped).toBe(false) + expect(await fs.stat(staging!).then((value) => value.isDirectory())).toBe(true) + teardown.resolve() + const response = await pending + expect(response.status).toBe(500) + expect(stopped).toBe(true) + await waitForRemoval(staging!) + } finally { + teardown.resolve() + controller.abort(reason) + errors.mockRestore() + download.mockRestore() + list.mockRestore() + context.mockRestore() + if (staging) await fs.rm(staging, { recursive: true, force: true }) + } +}) + +test("Modal Volume browser downloads sanitize hostile filenames and remove staging after completion", async () => { + let staging: string | undefined + const payload = Buffer.from("streamed bytes") + const remote = 'report"\r\nX-Injected: yes.csv' + const context = spyOn(ComputeSettings, "modalContext").mockResolvedValue({ + app: "openscience-test", + image: "python:3.12-slim", + network: "none", + timeoutMinutes: 10, + concurrency: 1, + tokenId: "ak-test", + tokenSecret: "as-test", + }) + const list = spyOn(ModalVolume, "list").mockResolvedValue([{ path: remote, type: "file", size: payload.byteLength }]) + const download = spyOn(ModalVolume, "download").mockImplementation(async (_context, _volume, _paths, target) => { + staging = target + const file = path.join(target, "result.bin") + await fs.writeFile(file, payload) + return [{ path: remote, staging: file, size: payload.byteLength, sha256: "0".repeat(64) }] + }) + + try { + const response = await ComputeSettingsRoutes().request( + `/modal/volumes/weights/file?path=${encodeURIComponent(`/${remote}`)}`, + ) + expect(response.status).toBe(200) + const disposition = response.headers.get("content-disposition")! + expect(disposition).toContain('filename="report___X-Injected: yes.csv"') + expect(disposition).toContain("filename*=UTF-8''report%22__X-Injected%3A%20yes.csv") + expect(disposition).not.toContain("\r") + expect(disposition).not.toContain("\n") + expect(response.headers.get("x-injected")).toBeNull() + expect(Buffer.from(await response.arrayBuffer())).toEqual(payload) + await waitForRemoval(staging!) + } finally { + download.mockRestore() + list.mockRestore() + context.mockRestore() + if (staging) await fs.rm(staging, { recursive: true, force: true }) + } +}) + +test("Modal Volume browser downloads remove staging when response handoff fails", async () => { + let staging: string | undefined + const payload = Buffer.from("staged") + const context = spyOn(ComputeSettings, "modalContext").mockResolvedValue({ + app: "openscience-test", + image: "python:3.12-slim", + network: "none", + timeoutMinutes: 10, + concurrency: 1, + tokenId: "ak-test", + tokenSecret: "as-test", + }) + const list = spyOn(ModalVolume, "list").mockResolvedValue([ + { path: "result.bin", type: "file", size: payload.byteLength }, + ]) + const download = spyOn(ModalVolume, "download").mockImplementation(async (_context, _volume, _paths, target) => { + staging = target + const file = path.join(target, "result.bin") + await fs.writeFile(file, payload) + return [{ path: "result.bin", staging: file, size: payload.byteLength, sha256: "0".repeat(64) }] + }) + const set = Headers.prototype.set + const headers = spyOn(Headers.prototype, "set").mockImplementation(function (this: Headers, name, value) { + if (name.toLowerCase() === "content-disposition" && value.startsWith("attachment;")) { + throw new TypeError("injected Content-Disposition failure") + } + return set.call(this, name, value) + }) + const errors = spyOn(console, "error").mockImplementation(() => {}) + + try { + const response = await ComputeSettingsRoutes().request("/modal/volumes/weights/file?path=/result.bin") + expect(response.status).toBe(500) + await waitForRemoval(staging!) + } finally { + errors.mockRestore() + headers.mockRestore() + download.mockRestore() + list.mockRestore() + context.mockRestore() + if (staging) await fs.rm(staging, { recursive: true, force: true }) + } +}) + test("connecting a provider does not overwrite an explicit shell export", async () => { process.env["VAST_API_KEY"] = "from-shell" const res = await connect("vast", "vast-stored-key") @@ -661,7 +877,7 @@ test( nativeLifecycleTimeout, ) -test("read-only projects cannot start compute jobs or create side effects", async () => { +test("untrusted projects start local compute only when the OS sandbox is enforced", async () => { await using tmp = await tmpdir() const created = await Project.fromDirectory(tmp.path) const current = await session(tmp.path, false) @@ -681,19 +897,35 @@ test("read-only projects cannot start compute jobs or create side effects", asyn }), }) - expect(response.status).toBe(403) - expect(await response.json()).toMatchObject({ - name: "ExecutionAuthorityDeniedError", - data: { - allowed: false, - reason: "project_untrusted", - capability: "local_job", - projectID: created.project.id, - sessionID: current.id, - }, + if (!Sandbox.available()) { + expect(response.status).toBe(403) + expect(await response.json()).toMatchObject({ + name: "ExecutionAuthorityDeniedError", + data: { + allowed: false, + reason: "sandbox_unavailable", + capability: "local_job", + projectID: created.project.id, + sessionID: current.id, + }, + }) + expect(await Bun.file(marker).exists()).toBe(false) + expect(await (await fetch(jobs, { headers })).json()).toEqual([]) + return + } + + expect(response.status).toBe(200) + const job = (await response.json()) as { + id: string + sandbox: { enforced: boolean } + authority: { allowed: boolean; mode: string } + } + expect(job).toMatchObject({ + sandbox: { enforced: true }, + authority: { allowed: true, mode: "sandboxed" }, }) - expect(await Bun.file(marker).exists()).toBe(false) - expect(await (await fetch(jobs, { headers })).json()).toEqual([]) + expect((await settle(jobs, job.id, headers)).status).toBe("succeeded") + expect(await Bun.file(marker).text()).toBe("started") }) test( diff --git a/backend/cli/test/server/settings-sandbox.test.ts b/backend/cli/test/server/settings-sandbox.test.ts index 9b0a5ac9..9c82d7b3 100644 --- a/backend/cli/test/server/settings-sandbox.test.ts +++ b/backend/cli/test/server/settings-sandbox.test.ts @@ -1,13 +1,47 @@ import { describe, expect, test } from "bun:test" import { SandboxSettingsRoutes } from "../../src/server/routes/settings/sandbox" import { Sandbox } from "../../src/sandbox/sandbox" +import { Config } from "../../src/config/config" +import { Global } from "../../src/global" +import fs from "node:fs/promises" import os from "node:os" import path from "node:path" const app = SandboxSettingsRoutes() +const configFiles = ["openscience.jsonc", "openscience.json", "config.json"].map((name) => + path.join(Global.Path.config, name), +) -// GET / and POST /test are read-only / write-to-temp-only, so these never touch -// the real global config (PUT does — that path is covered by the CLI e2e). +async function snapshotGlobalConfig() { + return Promise.all( + configFiles.map(async (file) => ({ + file, + content: await Bun.file(file) + .text() + .catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return + throw error + }), + })), + ) +} + +async function restoreGlobalConfig(snapshot: Awaited>) { + await Promise.all( + snapshot.map(async (item) => { + if (item.content === undefined) { + await fs.rm(item.file, { force: true }) + return + } + await Bun.write(item.file, item.content) + }), + ) + Config.global.reset() +} + +// GET / and POST /test are read-only / write-to-temp-only. The PUT regression +// snapshots and restores every candidate global config file byte-for-byte, +// including restoring absence, so it cannot leak config into another test. describe("/settings/sandbox routes", () => { test("GET / reports backend availability and a config object", async () => { const res = await app.request("/") @@ -55,4 +89,20 @@ describe("/settings/sandbox routes", () => { } expect(await (await app.request("/")).json()).toEqual(before) }) + + test("PUT persists the machine-wide explicit project-trust policy", async () => { + const snapshot = await snapshotGlobalConfig() + try { + const response = await app.request("/", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ requireProjectTrust: true }), + }) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ config: { requireProjectTrust: true } }) + expect((await Config.trustedSandbox()).requireProjectTrust).toBe(true) + } finally { + await restoreGlobalConfig(snapshot) + } + }) }) diff --git a/backend/cli/test/session/tool-retry-guard.test.ts b/backend/cli/test/session/tool-retry-guard.test.ts index 39efc1d9..f5a31ca4 100644 --- a/backend/cli/test/session/tool-retry-guard.test.ts +++ b/backend/cli/test/session/tool-retry-guard.test.ts @@ -69,6 +69,28 @@ function context(messages: Tool.Context["messages"]): Tool.Context { } } +function userMessage(sessionID: string, created: number): Tool.Context["messages"][number] { + return { + info: { + id: `message_user_${sessionID}`, + sessionID, + role: "user", + time: { created }, + agent: "research", + model: { providerID: "test", modelID: "test" }, + }, + parts: [ + { + id: `part_user_${sessionID}`, + sessionID, + messageID: `message_user_${sessionID}`, + type: "text", + text: "The strategy changed; retry now.", + }, + ], + } as unknown as Tool.Context["messages"][number] +} + test("kernel timeout similarity catches the P5 pandas retry but allows a raw-byte preflight", () => { const first = { environment: "python", @@ -391,7 +413,7 @@ test("URL normalization keeps resource identity but not client fragments", () => ) }) -test("legacy P5 oversize history blocks cap probing and exact byte history remains usable evidence", async () => { +test("a legacy agent-chosen cap gets exactly one same-turn disk-policy migration", async () => { const url = "https://www.ebi.ac.uk/gxa/sc/experiment/E-MTAB-6701/download/zip?fileType=quantification-raw" const legacyInput = { url, output_path: "raw.zip", max_bytes: 20_000_000 } const legacy = [ @@ -417,100 +439,76 @@ test("legacy P5 oversize history blocks cap probing and exact byte history remai }, ] as unknown as Tool.Context["messages"] const legacyContext = { ...context(legacy), sessionID: "session_legacy_webfetch" } - await expect(ToolRetryGuard.assertWebFetch(legacyContext, { ...legacyInput, max_bytes: 40_000_000 })).rejects.toThrow( - "another guessed max_bytes escalation was stopped before network access", + await expect(ToolRetryGuard.assertWebFetch(legacyContext, { url, output_path: "raw.zip" })).resolves.toBeUndefined() + await expect(ToolRetryGuard.assertWebFetch(legacyContext, { url, output_path: "raw.zip" })).rejects.toThrow( + "already used its one same-turn migration", ) - const exactInput = { url: "https://example.com/exact.bin", output_path: "exact.bin", max_bytes: 8 } - const exact = [ + const diskFailure = [ + ...legacy, { - info: { id: "message_exact_webfetch", sessionID: "session_exact_webfetch", role: "assistant" }, + info: { id: "message_disk_webfetch", sessionID: "session_legacy_webfetch", role: "assistant" }, parts: [ { - id: "part_exact_webfetch", - sessionID: "session_exact_webfetch", - messageID: "message_exact_webfetch", + id: "part_disk_webfetch", + sessionID: "session_legacy_webfetch", + messageID: "message_disk_webfetch", type: "tool", tool: "webfetch", - callID: "call_exact_webfetch", + callID: "call_disk_webfetch", state: { status: "error", - input: exactInput, + input: { url, output_path: "raw.zip" }, error: - "Download exceeds max_bytes (9 bytes > 8 bytes). No destination file was created. Choose a smaller source or explicitly set max_bytes once from the declared size.", + "Download exceeds the current safe workspace capacity of 12 bytes (12 bytes); response size 13 bytes (13 bytes). " + + "This capacity is computed from live free disk minus the 512.0 MiB (536870912 bytes) host reserve.", time: { start: 3, end: 4 }, }, }, ], }, ] as unknown as Tool.Context["messages"] - const exactContext = { ...context(exact), sessionID: "session_exact_webfetch" } - await expect(ToolRetryGuard.assertWebFetch(exactContext, { ...exactInput, max_bytes: 16 })).rejects.toThrow( - "The server previously declared exactly 9 bytes", - ) - await expect(ToolRetryGuard.assertWebFetch(exactContext, { ...exactInput, max_bytes: 16 })).rejects.toThrow( - 'output_path: "exact.bin", declared_size_bytes: 9, and max_bytes: 9', - ) await expect( - ToolRetryGuard.assertWebFetch(exactContext, { ...exactInput, max_bytes: 9, declared_size_bytes: 9 }), + ToolRetryGuard.assertWebFetch({ ...legacyContext, messages: diskFailure }, { url, output_path: "raw.zip" }), + ).rejects.toThrow("already exceeded the live safe workspace capacity of 12 bytes") + + await expect( + ToolRetryGuard.assertWebFetch( + { ...legacyContext, messages: [...diskFailure, userMessage("session_legacy_webfetch", 5)] }, + { url, output_path: "raw.zip" }, + ), ).resolves.toBeUndefined() }) -test("declared-size evidence rejects an ambiguous listing record", async () => { - const target = "https://example.com/target.bin" - const prior = { - id: "part_prior_ambiguous", - sessionID: "session_ambiguous_evidence", - messageID: "message_ambiguous_evidence", - type: "tool", - tool: "webfetch", - callID: "call_prior_ambiguous", - state: { - status: "error", - input: { url: target, output_path: "target.bin", max_bytes: 7 }, - error: "Download exceeds max_bytes (7 bytes). Partial data was discarded.", - time: { start: 1, end: 2 }, - }, - } - const evidence = { - id: "part_ambiguous_listing", - sessionID: "session_ambiguous_evidence", - messageID: "message_ambiguous_evidence", - type: "tool", - tool: "webfetch", - callID: "call_ambiguous_listing", - state: { - status: "completed", - input: { url: "https://example.com/listing", format: "text" }, - output: JSON.stringify({ - download_url: target, - mirror_url: "https://mirror.example.com/target.bin", - size: 8, - bytes: 12, - }), - title: "Ambiguous listing", - metadata: {}, - time: { start: 3, end: 4 }, - }, - } +test("a legacy default-cap failure migrates once to live disk policy without evidence", async () => { + const target = "https://example.com/legacy-default.bin" const messages = [ { - info: { id: "message_ambiguous_evidence", sessionID: "session_ambiguous_evidence", role: "assistant" }, - parts: [prior, evidence], + info: { id: "message_legacy_default", sessionID: "session_legacy_default", role: "assistant" }, + parts: [ + { + id: "part_legacy_default", + sessionID: "session_legacy_default", + messageID: "message_legacy_default", + type: "tool", + tool: "webfetch", + callID: "call_legacy_default", + state: { + status: "error", + input: { url: target, output_path: "target.bin" }, + error: "Download exceeds max_bytes (256.0 MiB). Partial data was discarded.", + time: { start: 1, end: 2 }, + }, + }, + ], }, ] as unknown as Tool.Context["messages"] await expect( ToolRetryGuard.assertWebFetch( - { ...context(messages), sessionID: "session_ambiguous_evidence" }, - { - url: target, - output_path: "target.bin", - max_bytes: 8, - declared_size_bytes: 8, - declared_size_evidence_call_id: "call_ambiguous_listing", - }, + { ...context(messages), sessionID: "session_legacy_default" }, + { url: target, output_path: "target.bin" }, ), - ).rejects.toThrow("declared_size_bytes needs auditable evidence") + ).resolves.toBeUndefined() }) test("current and legacy text oversize history require a body strategy change", async () => { @@ -541,9 +539,7 @@ test("current and legacy text oversize history require a body strategy change", await expect(ToolRetryGuard.assertWebFetch(ctx, { url })).rejects.toThrow( "already exceeded the WebFetch body-response limit", ) - await expect( - ToolRetryGuard.assertWebFetch(ctx, { url, output_path: "large.json", max_bytes: 10_000_000 }), - ).resolves.toBeUndefined() + await expect(ToolRetryGuard.assertWebFetch(ctx, { url, output_path: "large.json" })).resolves.toBeUndefined() await expect(ToolRetryGuard.assertWebFetch(ctx, { url: `${url}?page=2` })).resolves.toBeUndefined() } }) diff --git a/backend/cli/test/settings/network.test.ts b/backend/cli/test/settings/network.test.ts index c2b87996..45dc0206 100644 --- a/backend/cli/test/settings/network.test.ts +++ b/backend/cli/test/settings/network.test.ts @@ -1,10 +1,21 @@ -import { afterEach, expect, test } from "bun:test" +import { afterEach, expect, spyOn, test } from "bun:test" import { Network } from "../../src/settings/network" import { NetworkSettingsRoutes } from "../../src/server/routes/settings/network" import { Global } from "../../src/global" +import { FileLease } from "../../src/util/file-lease" +import { DataRootBarrier } from "../../src/global/data-root-barrier" +import { randomUUID } from "node:crypto" import path from "node:path" import fs from "node:fs/promises" +async function waitForFile(filepath: string) { + for (let attempt = 0; attempt < 500; attempt++) { + if (await Bun.file(filepath).exists()) return + await Bun.sleep(10) + } + throw new Error(`Timed out waiting for ${filepath}`) +} + afterEach(async () => { await Network.set({ allowlistEnabled: false, enabled: ["package-management"], custom: [] }) }) @@ -68,6 +79,138 @@ test("migrates legacy clinical policy without broadening and is serialized and i expect(await Bun.file(file).text()).toBe(before) }) +test("network policy publication exposes a complete old or new state, never an in-place partial write", async () => { + const file = path.join(Global.Path.data, "settings", "network.json") + const previous = { allowlistEnabled: true, enabled: ["ncbi-nih"], custom: ["old.example"] } + const next = { allowlistEnabled: true, enabled: ["proteomics"], custom: ["new.example"] } + await Network.set(previous) + + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const renameOriginal = fs.rename.bind(fs) + const rename = spyOn(fs, "rename").mockImplementation(async (source, destination) => { + if (destination === file && String(source).startsWith(`${file}.`) && String(source).endsWith(".tmp")) { + entered.resolve() + await release.promise + } + return renameOriginal(source, destination) + }) + let pending: Promise | undefined + try { + pending = Network.set(next) + await entered.promise + expect(await Network.get()).toEqual(previous) + expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ version: 2, ...previous }) + release.resolve() + expect(await pending).toEqual(next) + expect(await Network.get()).toEqual(next) + } finally { + release.resolve() + await pending?.catch(() => undefined) + rename.mockRestore() + } +}) + +test("failed atomic network policy publication preserves the old state and removes its temporary", async () => { + const file = path.join(Global.Path.data, "settings", "network.json") + const previous = { allowlistEnabled: true, enabled: ["ncbi-nih"], custom: ["old.example"] } + const next = { allowlistEnabled: true, enabled: ["proteomics"], custom: ["new.example"] } + await Network.set(previous) + + const renameOriginal = fs.rename.bind(fs) + const rename = spyOn(fs, "rename").mockImplementation(async (source, destination) => { + if (destination === file && String(source).startsWith(`${file}.`) && String(source).endsWith(".tmp")) { + throw Object.assign(new Error("mock rename failure"), { code: "EIO" }) + } + return renameOriginal(source, destination) + }) + try { + await expect(Network.set(next)).rejects.toThrow("mock rename failure") + } finally { + rename.mockRestore() + } + expect(await Network.get()).toEqual(previous) + expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ version: 2, ...previous }) + expect((await fs.readdir(path.dirname(file))).filter((name) => /^network\.json\..+\.tmp$/.test(name))).toEqual([]) +}) + +test("cross-process allow re-reads explicit policy after acquiring the stable settings lease", async () => { + const file = path.join(Global.Path.data, "settings", "network.json") + const leasePath = path.join(Global.Path.config, "network-settings.lock") + const ready = path.join(Global.Path.config, `network-child-${randomUUID()}.ready`) + const networkModule = new URL("../../src/settings/network.ts", import.meta.url).href + const initial = { allowlistEnabled: true, enabled: ["ncbi-nih"], custom: ["initial.example"] } + const explicit = { allowlistEnabled: true, enabled: ["proteomics"], custom: ["explicit.example"] } + await Network.set(initial) + + const source = [ + `import { Network } from ${JSON.stringify(networkModule)}`, + 'import fs from "node:fs/promises"', + `await fs.writeFile(${JSON.stringify(ready)}, "ready")`, + 'await Network.allow("child.example")', + ].join("\n") + let child: ReturnType | undefined + try { + { + await using lease = await FileLease.acquire(leasePath) + child = Bun.spawn([process.execPath, "-e", source], { + cwd: path.resolve(import.meta.dir, "../.."), + env: { ...process.env }, + stdout: "pipe", + stderr: "pipe", + }) + await waitForFile(ready) + expect(child.exitCode).toBeNull() + await Bun.write(file, JSON.stringify({ version: 2, ...explicit }, null, 2)) + void lease + } + const exit = await child.exited + if (exit !== 0) { + const error = child.stderr instanceof ReadableStream ? await new Response(child.stderr).text() : "" + throw new Error(`Network child failed: ${error}`) + } + expect(await Network.get()).toEqual({ ...explicit, custom: ["explicit.example", "child.example"] }) + } finally { + child?.kill() + await child?.exited.catch(() => undefined) + await fs.rm(ready, { force: true }) + } +}) + +test("nested network mutation completes when relocation intent lands behind its CLI scope", async () => { + const intent = path.join(Global.Path.config, "data-root-switch.intent") + const next = { allowlistEnabled: true, enabled: ["proteomics"], custom: ["nested.example"] } + const outerReady = Promise.withResolvers() + const startNested = Promise.withResolvers() + const nestedDone = Promise.withResolvers() + const command = (async () => { + await using outer = await DataRootBarrier.enter(Global.Path.data, 2_000) + return await outer.during(async () => { + outerReady.resolve() + await startNested.promise + await Network.set(next) + nestedDone.resolve() + }) + })() + let switching: Promise | undefined + try { + await outerReady.promise + switching = DataRootBarrier.exclusive(1_000) + await waitForFile(intent) + startNested.resolve() + expect(await Promise.race([nestedDone.promise.then(() => true), Bun.sleep(250).then(() => false)])).toBe(true) + await command + const exclusive = await switching + await exclusive[Symbol.asyncDispose]() + expect(await Network.get()).toEqual(next) + } finally { + startNested.resolve() + await command.catch(() => undefined) + const exclusive = await switching?.catch(() => undefined) + await exclusive?.[Symbol.asyncDispose]() + } +}) + test("invalid or unsupported persisted policy denies all instead of restoring install defaults", async () => { const file = path.join(Global.Path.data, "settings", "network.json") for (const value of [ diff --git a/backend/cli/test/tool/compute-job.test.ts b/backend/cli/test/tool/compute-job.test.ts index 7b36e331..4459ee8b 100644 --- a/backend/cli/test/tool/compute-job.test.ts +++ b/backend/cli/test/tool/compute-job.test.ts @@ -1,11 +1,12 @@ import { expect, test } from "bun:test" import fs from "node:fs/promises" import path from "node:path" +import z from "zod" import { ComputeJobs } from "../../src/compute/jobs" import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" import { SessionFilesystem } from "../../src/session/filesystem" -import { createComputeJobTool } from "../../src/tool/compute-job" +import { ComputeJobParameters, createComputeJobTool } from "../../src/tool/compute-job" import { tmpdir, trustProject } from "../fixture/fixture" type Asked = { permission: string; patterns: string[]; always?: string[]; metadata?: Record } @@ -23,6 +24,107 @@ const context = (sessionID: string, asked: Asked[]) => ({ }, }) +test("advertises the canonical action-discriminated compute schema", () => { + const schema = z.toJSONSchema(ComputeJobParameters) as { + description?: string + anyOf: Array<{ + properties: Record }> + required: string[] + }> + } + expect(schema.anyOf.map((variant) => variant.properties.action.const)).toEqual([ + "targets", + "plan", + "start", + "list", + "status", + "logs", + "artifacts", + "cancel", + "retry_delivery", + "release", + ]) + expect(JSON.stringify(schema)).not.toContain('"operation"') + const plan = schema.anyOf[1] + expect(plan.required).toEqual(["name", "purpose", "command", "target", "action"]) + expect(plan.properties.target.anyOf?.every((target) => target.type === "object")).toBe(true) + expect(schema.description).toContain('{"action":"targets"}') + expect(plan.properties.target.description).toContain("never a quoted JSON string") +}) + +test("normalizes only unambiguous action aliases and valid JSON-object targets", async () => { + await using tmp = await tmpdir({ git: true }) + const root = path.join(tmp.path, "compute") + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await trustProject() + const session = await Session.create({}) + const tool = await createComputeJobTool({ root, workspace: tmp.path }).init() + const targets = await tool.execute({ operation: "targets" } as never, context(session.id, [])) + expect(targets.output).toContain('"kind": "local"') + + const duplicate = await tool.execute( + { action: "targets", operation: "targets" } as never, + context(session.id, []), + ) + expect(duplicate.output).toContain('"kind": "local"') + + const preview = await tool.execute( + { + action: "plan", + name: "environment probe", + purpose: "Check the local runtime before starting work.", + command: "python --version", + target: '{"kind":"local"}', + } as never, + context(session.id, []), + ) + expect(preview.output).toContain('"provider": "local"') + + const dispatched = await tool.execute( + { + action: "start", + name: "normalization probe", + purpose: "Verify defaulted fields reach compute execution.", + command: "printf normalized", + target: { kind: "local" }, + }, + context(session.id, []), + ) + const job = dispatched.metadata.job + if (!job) throw new Error("compute_job did not return its normalization probe") + const listed = await tool.execute({ operation: "list" } as never, context(session.id, [])) + expect(listed.output).toContain(job.id) + await ComputeJobs.wait(job.id, { root, workspace: tmp.path, timeout: 5_000 }) + }, + }) +}) + +test("rejects ambiguous or invalid compute shapes with one copy-ready repair", async () => { + const tool = await createComputeJobTool().init() + await expect( + tool.execute({ action: "targets", operation: "plan" } as never, context("ses_validation", [])), + ).rejects.toThrow('Use the field "action", not "operation"') + await expect(tool.execute({ operation: "inspect" } as never, context("ses_validation", []))).rejects.toThrow( + "Valid action values: targets, plan, start, list, status, logs, artifacts, cancel, retry_delivery, release", + ) + await expect( + tool.execute( + { + action: "plan", + name: "environment probe", + purpose: "Check the local runtime before starting work.", + command: "python --version", + target: '{"kind":"ssh"}', + } as never, + context("ses_validation", []), + ), + ).rejects.toThrow( + '{"action":"plan","name":"Environment probe","purpose":"Check the local runtime before starting work.","command":"python --version","target":{"kind":"local"}}', + ) +}) + test("plans and starts a detached local job through the model-facing broker", async () => { await using tmp = await tmpdir({ git: true }) const root = path.join(tmp.path, "compute") diff --git a/backend/cli/test/tool/tool-canonicalization.test.ts b/backend/cli/test/tool/tool-canonicalization.test.ts new file mode 100644 index 00000000..9d226eff --- /dev/null +++ b/backend/cli/test/tool/tool-canonicalization.test.ts @@ -0,0 +1,173 @@ +import { expect, test } from "bun:test" +import z from "zod" +import { Agent } from "../../src/agent/agent" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { Identifier } from "../../src/id/id" +import { MessageV2 } from "../../src/session/message-v2" +import { BatchTool } from "../../src/tool/batch" +import { createComputeJobTool } from "../../src/tool/compute-job" +import { Tool } from "../../src/tool/tool" +import { ToolRegistry } from "../../src/tool/registry" +import { tmpdir } from "../fixture/fixture" + +function context(sessionID = "ses_canonical", messageID = "msg_canonical") { + return { + sessionID, + messageID, + callID: "call_canonical", + agent: "research", + abort: new AbortController().signal, + messages: [], + metadata() {}, + async ask() {}, + } +} + +test("Tool.define executes the canonical Zod output for every tool", async () => { + const tool = await Tool.define("canonical_probe", { + description: "Canonicalization probe", + parameters: z + .object({ + label: z + .string() + .trim() + .transform((value) => value.toUpperCase()), + limit: z.number().default(10), + }) + .strict(), + async execute(input) { + return { title: "Canonical probe", metadata: {}, output: JSON.stringify(input) } + }, + }).init() + + const result = await tool.execute({ label: " parsed " } as never, context()) + expect(JSON.parse(result.output)).toEqual({ label: "PARSED", limit: 10 }) +}) + +test("Tool.define dedupes against the canonical signature persisted with a raw call", async () => { + let executions = 0 + const tool = await Tool.define("science_search", { + description: "Canonical dedupe probe", + parameters: z + .object({ + query: z.string().trim(), + limit: z.number().default(10), + }) + .strict(), + async execute(input) { + executions++ + return { title: "Canonical dedupe probe", metadata: {}, output: JSON.stringify(input) } + }, + }).init() + + const rawInput = { query: " equivalent query " } + const first = await tool.execute(rawInput as never, context()) + const message: MessageV2.WithParts = { + info: { + id: "msg_previous", + sessionID: "ses_canonical", + role: "assistant", + time: { created: 1, completed: 2 }, + parentID: "msg_user", + modelID: "model", + providerID: "provider", + mode: "research", + agent: "research", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts: [ + { + id: "part_previous", + sessionID: "ses_canonical", + messageID: "msg_previous", + type: "tool", + callID: "call_previous", + tool: "science_search", + state: { + status: "completed", + input: rawInput, + output: first.output, + title: first.title, + metadata: first.metadata, + time: { start: 1, end: 2 }, + }, + }, + ], + } + + const second = await tool.execute({ query: "equivalent query", limit: 10 }, { ...context(), messages: [message] }) + expect(second.output).toBe(first.output) + expect(second.metadata).toMatchObject({ + dedupeHit: true, + dedupeOf: { messageID: "msg_previous", partID: "part_previous", callID: "call_previous" }, + }) + expect(executions).toBe(1) +}) + +test("batch delegates the same canonical inputs as direct calls for the active agent", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const agent = await Agent.get("research") + const directMessageID = Identifier.ascending("message") + const batchMessageID = Identifier.ascending("message") + const computeInput = { + operation: "plan", + name: "Batch normalization probe", + purpose: "Verify direct and delegated compute inputs use one canonical contract.", + command: "python --version", + target: '{"kind":"local"}', + } + + const direct = await createComputeJobTool().init({ agent }) + const directResult = await direct.execute(computeInput as never, context(session.id, directMessageID)) + + const defaultProbe = Tool.define("batch_default_probe", { + description: "Batch default probe", + parameters: z + .object({ + label: z + .string() + .trim() + .transform((value) => value.toUpperCase()), + limit: z.number().default(7), + }) + .strict(), + async execute(input) { + return { title: "Batch default probe", metadata: {}, output: JSON.stringify(input) } + }, + }) + await ToolRegistry.register(defaultProbe) + + const batch = await BatchTool.init({ agent }) + const batchResult = await batch.execute( + { + tool_calls: [ + { tool: "compute_job", parameters: computeInput }, + { tool: "batch_default_probe", parameters: { label: " delegated " } }, + ], + }, + context(session.id, batchMessageID), + ) + + expect(batchResult.metadata).toMatchObject({ totalCalls: 2, successful: 2, failed: 0 }) + const parts = (await MessageV2.parts(batchMessageID)).filter( + (part): part is MessageV2.ToolPart => part.type === "tool", + ) + const compute = parts.find((part) => part.tool === "compute_job") + const probe = parts.find((part) => part.tool === "batch_default_probe") + expect(compute?.state.status).toBe("completed") + expect(probe?.state.status).toBe("completed") + if (compute?.state.status !== "completed" || probe?.state.status !== "completed") { + throw new Error("Batch did not persist both delegated tool results") + } + expect(JSON.parse(compute.state.output)).toEqual(JSON.parse(directResult.output)) + expect(JSON.parse(probe.state.output)).toEqual({ label: "DELEGATED", limit: 7 }) + }, + }) +}) diff --git a/backend/cli/test/tool/webfetch-network.test.ts b/backend/cli/test/tool/webfetch-network.test.ts index 7cee941a..4f210549 100644 --- a/backend/cli/test/tool/webfetch-network.test.ts +++ b/backend/cli/test/tool/webfetch-network.test.ts @@ -1,17 +1,14 @@ import { afterEach, expect, spyOn, test } from "bun:test" import { Network } from "../../src/settings/network" -import { - DEFAULT_DOWNLOAD_MAX_BYTES, - MAX_DOWNLOAD_MAX_BYTES, - MAX_RESPONSE_SIZE, - WebFetchTool, -} from "../../src/tool/webfetch" +import { DOWNLOAD_DISK_RESERVE_BYTES, MAX_RESPONSE_SIZE, WebFetchTool } from "../../src/tool/webfetch" import type { Tool } from "../../src/tool/tool" import { SessionFilesystem } from "../../src/session/filesystem" import crypto from "node:crypto" +import type { StatsFs } from "node:fs" import fs from "node:fs/promises" import os from "node:os" import path from "node:path" +import z from "zod" const realFetch = globalThis.fetch @@ -65,37 +62,73 @@ function failedToolHistory(input: Record, error: string, callID ] as unknown as Tool.Context["messages"] } -function completedToolHistory(input: Record, output: string, callID: string) { - return [ - { - info: { id: "message_evidence", sessionID: "session_test", role: "assistant" }, - parts: [ - { - id: "part_evidence", - sessionID: "session_test", - messageID: "message_evidence", - type: "tool", - tool: "webfetch", - callID, - state: { - status: "completed", - input, - output, - title: "Metadata", - metadata: {}, - time: { start: 3, end: 4 }, - }, - }, - ], - }, - ] as unknown as Tool.Context["messages"] -} - afterEach(async () => { globalThis.fetch = realFetch await Network.set({ allowlistEnabled: false, enabled: ["package-management"], custom: [] }) }) +test("webfetch schema teaches the root-download then sandboxed-move sequence", async () => { + const webfetch = await WebFetchTool.init() + const schema = z.toJSONSchema(webfetch.parameters) as { + properties?: Record + } + const description = schema.properties?.output_path?.description + expect(description).toContain('output_path:"foo.pdf"') + expect(description).toContain("only after success") + expect(description).toContain( + "mkdir -p -- 'papers' && test ! -e 'papers/foo.pdf' && mv -- 'foo.pdf' 'papers/foo.pdf'", + ) + expect(schema.properties?.max_bytes).toBeUndefined() + expect(schema.properties?.declared_size_bytes).toBeUndefined() + expect(schema.properties?.declared_size_evidence_call_id).toBeUndefined() +}) + +test("webfetch rejects empty or whitespace-padded download paths before permission or network access", async () => { + await Network.set({ allowlistEnabled: false, enabled: [], custom: [] }) + let fetches = 0 + let asks = 0 + globalThis.fetch = (async () => { + fetches++ + return new Response("must not fetch") + }) as unknown as typeof fetch + + const webfetch = await WebFetchTool.init() + for (const output_path of ["", " ", " data.csv", "data.csv "]) { + await expect( + webfetch.execute( + { url: "https://example.com/data", format: "text", output_path }, + context(async () => { + asks++ + }), + ), + ).rejects.toThrow("invalid arguments") + } + expect(asks).toBe(0) + expect(fetches).toBe(0) +}) + +test("webfetch rejects an output_path typo before permission or network access", async () => { + await Network.set({ allowlistEnabled: false, enabled: [], custom: [] }) + let fetches = 0 + let asks = 0 + globalThis.fetch = (async () => { + fetches++ + return new Response("must not fetch") + }) as unknown as typeof fetch + + const webfetch = await WebFetchTool.init() + await expect( + webfetch.execute( + { url: "https://example.com/data", format: "text", outputPath: "data.csv" } as never, + context(async () => { + asks++ + }), + ), + ).rejects.toThrow("invalid arguments") + expect(asks).toBe(0) + expect(fetches).toBe(0) +}) + test("webfetch asks before reaching a blocked host and fails closed on deny", async () => { await Network.set({ allowlistEnabled: true, enabled: [], custom: ["allowed.test"] }) const webfetch = await WebFetchTool.init() @@ -173,8 +206,8 @@ test("webfetch rejects declared oversized text with terminal pagination and down ), ).rejects.toThrow( "Response is too large for Web fetch (5.0 MiB, application/json); the text-response limit is 5.0 MiB. " + - "Do not repeat the same text-mode request. For a data file, call Web fetch again with output_path set to a simple " + - "workspace-root filename without directories", + "Do not repeat the same text-mode request. For a data file, call Web fetch again with a root-only filename such as " + + 'output_path:"foo.pdf"', ) }) @@ -450,7 +483,7 @@ test("webfetch streams a brokered binary download through a reauthorized redirec } }) -test("webfetch download rejects directories, traversal, and existing destinations before fetching", async () => { +test("webfetch download gives a copy-ready root-first fallback for a folder destination", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "webfetch-contained-")) await fs.writeFile(path.join(root, "existing.bin"), "keep") const workspace = spyOn(SessionFilesystem, "workspace").mockResolvedValue(root) @@ -467,9 +500,20 @@ test("webfetch download rejects directories, traversal, and existing destination await expect( webfetch.execute({ url: "https://example.com/data", format: "text", output_path: "../outside.bin" }, ctx), ).rejects.toThrow("must be a filename at the root of this session's workspace, without directories") + const nested = await captureError( + webfetch.execute({ url: "https://example.com/data", format: "text", output_path: "papers/foo.pdf" }, ctx), + ) + expect(nested.message).toBe( + "output_path is root-only by design: brokered downloads must not traverse mutable intermediate directories. " + + 'Retry with output_path:"foo.pdf". Only after that download succeeds, run sandboxed Bash from the workspace: ' + + "mkdir -p -- 'papers' && test ! -e 'papers/foo.pdf' && mv -- 'foo.pdf' 'papers/foo.pdf'", + ) await expect( - webfetch.execute({ url: "https://example.com/data", format: "text", output_path: "nested/file.bin" }, ctx), - ).rejects.toThrow("must be a filename at the root of this session's workspace, without directories") + webfetch.execute( + { url: "https://example.com/data", format: "text", output_path: path.join(root, "absolute.bin") }, + ctx, + ), + ).rejects.toThrow("must be a workspace-root filename, not an absolute path") await expect( webfetch.execute({ url: "https://example.com/data", format: "text", output_path: "existing.bin" }, ctx), ).rejects.toThrow("Refusing to overwrite") @@ -545,9 +589,14 @@ test("webfetch download rejects a direct final-component symlink escape before f } }) -test("webfetch download stops guessed cap escalation and permits one server-declared retry", async () => { +test("webfetch uses live disk capacity and blocks a same-turn unchanged retry", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "webfetch-declared-limit-")) const workspace = spyOn(SessionFilesystem, "workspace").mockResolvedValue(root) + let safeCapacity = 8 + const statfs = spyOn(fs, "statfs").mockImplementation( + (async () => + ({ bavail: DOWNLOAD_DISK_RESERVE_BYTES + safeCapacity, bsize: 1 }) as StatsFs) as unknown as typeof fs.statfs, + ) await Network.set({ allowlistEnabled: false, enabled: [], custom: [] }) let cancelled = false let fetches = 0 @@ -579,7 +628,6 @@ test("webfetch download stops guessed cap escalation and permits one server-decl url: "https://example.com/too-large", format: "text" as const, output_path: "declared.bin", - max_bytes: 8, } const first = await captureError( webfetch.execute( @@ -588,47 +636,69 @@ test("webfetch download stops guessed cap escalation and permits one server-decl ), ) expect(first.message).toContain( - "Download exceeds max_bytes (9 bytes > 8 bytes). No destination file was created. Choose a smaller source or explicitly set max_bytes once from the declared size", + "Download exceeds the current safe workspace capacity of 8 bytes (8 bytes); response size 9 bytes (9 bytes)", ) + expect(first.message).toContain("computed from live free disk minus the 512.0 MiB (536870912 bytes) host reserve") expect(cancelled).toBe(true) expect(await fs.readdir(root)).toEqual([]) const history = failedToolHistory(input, first.message, "call_declared_oversize") - const guessed = await captureError( + const repeated = await captureError( webfetch.execute( - { ...input, output_path: "guessed.bin", max_bytes: 16 }, + input, context(async () => {}, history), ), ) - expect(guessed.message).toContain("another guessed max_bytes escalation was stopped before network access") - expect(guessed.message).toContain("The server previously declared exactly 9 bytes") - expect(guessed.message).toContain('output_path: "guessed.bin", declared_size_bytes: 9, and max_bytes: 9') - await expect( - webfetch.execute( - { ...input, output_path: "invented.bin", max_bytes: 10, declared_size_bytes: 10 }, - context(async () => {}, history), - ), - ).rejects.toThrow("must exactly match the server Content-Length already recorded for this URL (9 bytes)") + expect(repeated.message).toContain("already exceeded the live safe workspace capacity of 8 bytes") + expect(repeated.message).toContain("stopped before permission or network access") expect(fetches).toBe(1) + safeCapacity = 16 + const userTurn = { + info: { + id: "message_user_after_capacity", + sessionID: "session_test", + role: "user", + time: { created: Date.now() + 1_000 }, + agent: "research", + model: { providerID: "test", modelID: "test" }, + }, + parts: [ + { + id: "part_user_after_capacity", + sessionID: "session_test", + messageID: "message_user_after_capacity", + type: "text", + text: "I freed disk; retry the same download.", + }, + ], + } as unknown as Tool.Context["messages"][number] const result = await webfetch.execute( - { ...input, output_path: "declared.bin", max_bytes: 9, declared_size_bytes: 9 }, - context(async () => {}, history), + // Retired fields from an older caller are accepted as unknown input but + // stripped by schema normalization and cannot affect the disk policy. + { ...input, max_bytes: 9, declared_size_bytes: 9 } as never, + context(async () => {}, [...history, userTurn]), ) expect(result.metadata).toMatchObject({ download: { bytes: 9 } }) expect(await fs.readFile(path.join(root, "declared.bin"))).toEqual(Buffer.from(payload)) expect(fetches).toBe(2) } finally { + statfs.mockRestore() workspace.mockRestore() await fs.rm(root, { recursive: true, force: true }) } }) -test("webfetch download aborts a chunked body at max_bytes and removes the partial temp file", async () => { +test("webfetch bounds unknown or understated streamed bytes by live safe disk capacity", async () => { const base = await fs.mkdtemp(path.join(os.tmpdir(), "webfetch-chunk-limit-")) const root = path.join(base, "workspace") await fs.mkdir(root) const workspace = spyOn(SessionFilesystem, "workspace").mockResolvedValue(root) + let safeCapacity = 6 + const statfs = spyOn(fs, "statfs").mockImplementation( + (async () => + ({ bavail: DOWNLOAD_DISK_RESERVE_BYTES + safeCapacity, bsize: 1 }) as StatsFs) as unknown as typeof fs.statfs, + ) await Network.set({ allowlistEnabled: false, enabled: [], custom: [] }) let cancelled = false let fetches = 0 @@ -650,7 +720,7 @@ test("webfetch download aborts a chunked body at max_bytes and removes the parti cancelled = true }, }), - { headers: { "content-type": "application/octet-stream" } }, + { headers: { "content-type": "application/octet-stream", "content-length": "4" } }, ) }) as unknown as typeof fetch @@ -660,7 +730,6 @@ test("webfetch download aborts a chunked body at max_bytes and removes the parti url: "https://example.com/chunked-large", format: "text" as const, output_path: "chunked.bin", - max_bytes: 6, } const first = await captureError( webfetch.execute( @@ -668,79 +737,243 @@ test("webfetch download aborts a chunked body at max_bytes and removes the parti context(async () => {}), ), ) - expect(first.message).toContain( - "Download exceeds max_bytes (6 bytes). Partial data was discarded; use a metadata/listing endpoint to obtain the exact byte size for one evidence-backed retry, choose a smaller or paginated source, or use a different canonical download URL. Do not retry this URL with incrementally larger caps.", - ) + expect(first.message).toContain("Download exceeds the current safe workspace capacity of 6 bytes (6 bytes)") expect(cancelled).toBe(true) expect(await fs.readdir(root)).toEqual([]) expect(await fs.readdir(base)).toEqual(["workspace"]) await expect( webfetch.execute( - { ...input, max_bytes: 8, declared_size_bytes: 8 }, + { ...input, max_bytes: 8, declared_size_bytes: 8 } as never, context(async () => {}, failedToolHistory(input, first.message, "call_chunked_oversize")), ), - ).rejects.toThrow("declared_size_bytes needs auditable evidence") + ).rejects.toThrow("already exceeded the live safe workspace capacity of 6 bytes") expect(fetches).toBe(1) - const evidenceCallID = "call_size_metadata" - const history = [ - ...failedToolHistory(input, first.message, "call_chunked_oversize"), - ...completedToolHistory( - { url: "https://example.com/metadata", format: "text" }, - JSON.stringify({ download_url: input.url, size: 8 }), - evidenceCallID, - ), - ] - const recovered = await webfetch.execute( - { - ...input, - max_bytes: 8, - declared_size_bytes: 8, - declared_size_evidence_call_id: evidenceCallID, + safeCapacity = 8 + const history = failedToolHistory(input, first.message, "call_chunked_oversize") + const userTurn = { + info: { + id: "message_user_after_chunked", + sessionID: "session_test", + role: "user", + time: { created: Date.now() + 1_000 }, + agent: "research", + model: { providerID: "test", modelID: "test" }, }, - context(async () => {}, history), + parts: [ + { + id: "part_user_after_chunked", + sessionID: "session_test", + messageID: "message_user_after_chunked", + type: "text", + text: "Disk is available now; retry.", + }, + ], + } as unknown as Tool.Context["messages"][number] + const recovered = await webfetch.execute( + input, + context(async () => {}, [...history, userTurn]), ) expect(recovered.metadata).toMatchObject({ download: { bytes: 8 } }) expect(fetches).toBe(2) } finally { + statfs.mockRestore() workspace.mockRestore() await fs.rm(base, { recursive: true, force: true }) } }) -test("webfetch download uses a conservative default byte cap", async () => { - expect(DEFAULT_DOWNLOAD_MAX_BYTES).toBe(256 * 1024 * 1024) - expect(MAX_DOWNLOAD_MAX_BYTES).toBe(2 * 1024 * 1024 * 1024) +test("webfetch rechecks the host disk reserve before every streamed write", async () => { + const base = await fs.mkdtemp(path.join(os.tmpdir(), "webfetch-live-disk-race-")) + const root = path.join(base, "workspace") + await fs.mkdir(root) + const workspace = spyOn(SessionFilesystem, "workspace").mockResolvedValue(root) + let statfsCalls = 0 + const statfs = spyOn(fs, "statfs").mockImplementation((async () => { + statfsCalls++ + // Initial preflight, response preflight, and the first write each see an + // 8-byte budget. Before the second write, concurrent disk use leaves one + // byte above the reserve; four bytes already staged makes the new total + // transfer ceiling exactly five bytes. + const safeBytes = statfsCalls < 4 ? 8 : 1 + return { bavail: DOWNLOAD_DISK_RESERVE_BYTES + safeBytes, bsize: 1 } as StatsFs + }) as unknown as typeof fs.statfs) + await Network.set({ allowlistEnabled: false, enabled: [], custom: [] }) + let chunks = 0 + globalThis.fetch = (async () => + new Response( + new ReadableStream({ + pull(controller) { + if (chunks++ < 2) { + controller.enqueue(new Uint8Array([1, 2, 3, 4])) + return + } + controller.close() + }, + }), + { headers: { "content-type": "application/octet-stream" } }, + )) as unknown as typeof fetch - const webfetch = await WebFetchTool.init() - await expect( - webfetch.execute( + try { + const webfetch = await WebFetchTool.init() + await expect( + webfetch.execute( + { + url: "https://example.com/live-disk-race", + format: "text", + output_path: "race.bin", + }, + context(async () => {}), + ), + ).rejects.toThrow( + "Download exceeds the current safe workspace capacity of 5 bytes (5 bytes); response size 8 bytes (8 bytes)", + ) + expect(statfsCalls).toBe(4) + expect(await fs.readdir(root)).toEqual([]) + expect(await fs.readdir(base)).toEqual(["workspace"]) + } finally { + statfs.mockRestore() + workspace.mockRestore() + await fs.rm(base, { recursive: true, force: true }) + } +}) + +test("webfetch classifies storage exhaustion and stops its same-turn retry before network", async () => { + for (const storageCode of ["ENOSPC", "EDQUOT"] as const) { + const base = await fs.mkdtemp(path.join(os.tmpdir(), `webfetch-${storageCode.toLowerCase()}-`)) + const root = path.join(base, "workspace") + await fs.mkdir(root) + const workspace = spyOn(SessionFilesystem, "workspace").mockResolvedValue(root) + const statfs = spyOn(fs, "statfs").mockResolvedValue({ + bavail: DOWNLOAD_DISK_RESERVE_BYTES + 16, + bsize: 1, + } as StatsFs) + await Network.set({ allowlistEnabled: false, enabled: [], custom: [] }) + const realOpen = fs.open + const open = spyOn(fs, "open").mockImplementation((async (...args: unknown[]) => { + const staged = args[0] as Parameters[0] + if (!String(staged).includes(".openscience-download-")) { + return (realOpen as unknown as (...input: unknown[]) => Promise)(...args) + } + if (storageCode === "ENOSPC") { + throw Object.assign(new Error(`mock ${storageCode}`), { code: storageCode }) + } + await fs.writeFile(staged, new Uint8Array()) + return { + write: async () => { + throw Object.assign(new Error(`mock ${storageCode}`), { code: storageCode }) + }, + sync: async () => {}, + close: async () => {}, + } as unknown as fs.FileHandle + }) as unknown as typeof fs.open) + let fetches = 0 + let asks = 0 + let cancelled = false + globalThis.fetch = (async () => { + fetches++ + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3, 4])) + }, + cancel() { + cancelled = true + }, + }), + { headers: { "content-type": "application/octet-stream", "content-length": "4" } }, + ) + }) as unknown as typeof fetch + + try { + const webfetch = await WebFetchTool.init() + const input = { + url: `https://example.com/${storageCode.toLowerCase()}`, + format: "text" as const, + output_path: `${storageCode.toLowerCase()}.bin`, + } + const first = await captureError( + webfetch.execute( + input, + context(async () => { + asks++ + }), + ), + ) + expect(first.message).toContain(`workspace storage returned ${storageCode}`) + expect(first.message).toContain("current disk-derived workspace capacity is 16 bytes (16 bytes)") + expect(cancelled).toBe(true) + expect(await fs.readdir(root)).toEqual([]) + expect(await fs.readdir(base)).toEqual(["workspace"]) + + await expect( + webfetch.execute( + input, + context( + async () => { + asks++ + }, + failedToolHistory(input, first.message, `call_${storageCode.toLowerCase()}`), + ), + ), + ).rejects.toThrow("already exceeded the live safe workspace capacity of 16 bytes") + expect(fetches).toBe(1) + expect(asks).toBe(1) + } finally { + open.mockRestore() + statfs.mockRestore() + workspace.mockRestore() + await fs.rm(base, { recursive: true, force: true }) + } + } +}) + +test("webfetch automatically accepts declared bytes within live capacity and ignores retired caps", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "webfetch-live-capacity-")) + const workspace = spyOn(SessionFilesystem, "workspace").mockResolvedValue(root) + const statfs = spyOn(fs, "statfs").mockResolvedValue({ + bavail: DOWNLOAD_DISK_RESERVE_BYTES + 16, + bsize: 1, + } as StatsFs) + await Network.set({ allowlistEnabled: false, enabled: [], custom: [] }) + const payload = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9]) + globalThis.fetch = (async () => + new Response(payload, { + headers: { "content-type": "application/octet-stream", "content-length": String(payload.byteLength) }, + })) as unknown as typeof fetch + + try { + const webfetch = await WebFetchTool.init() + const result = await webfetch.execute( { url: "https://example.com/data", format: "text", output_path: "data.bin", - max_bytes: MAX_DOWNLOAD_MAX_BYTES + 1, - }, + max_bytes: 1, + declared_size_bytes: 1, + declared_size_evidence_call_id: "retired", + } as never, context(async () => {}), - ), - ).rejects.toThrow("invalid arguments") + ) + expect(result.metadata).toMatchObject({ download: { bytes: payload.byteLength } }) + expect(await fs.readFile(path.join(root, "data.bin"))).toEqual(Buffer.from(payload)) + } finally { + statfs.mockRestore() + workspace.mockRestore() + await fs.rm(root, { recursive: true, force: true }) + } }) test("webfetch download preserves a disk reserve before consuming the body", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "webfetch-disk-reserve-")) const workspace = spyOn(SessionFilesystem, "workspace").mockResolvedValue(root) - const statfs = spyOn(fs, "statfs").mockResolvedValue({ bavail: 1, bsize: 1 } as Awaited>) + const statfs = spyOn(fs, "statfs").mockResolvedValue({ bavail: 1, bsize: 1 } as StatsFs) await Network.set({ allowlistEnabled: false, enabled: [], custom: [] }) - let cancelled = false - globalThis.fetch = (async () => - new Response( - new ReadableStream({ - cancel() { - cancelled = true - }, - }), - { headers: { "content-type": "application/octet-stream", "content-length": "4" } }, - )) as unknown as typeof fetch + let fetches = 0 + globalThis.fetch = (async () => { + fetches++ + return new Response("must not fetch") + }) as unknown as typeof fetch try { const webfetch = await WebFetchTool.init() @@ -750,12 +983,11 @@ test("webfetch download preserves a disk reserve before consuming the body", asy url: "https://example.com/data", format: "text", output_path: "data.bin", - max_bytes: 8, }, context(async () => {}), ), - ).rejects.toThrow("Insufficient workspace disk for download") - expect(cancelled).toBe(true) + ).rejects.toThrow("current safe workspace capacity of 0 bytes (0 bytes)") + expect(fetches).toBe(0) expect(await fs.readdir(root)).toEqual([]) } finally { statfs.mockRestore() diff --git a/backend/cli/test/util/file-lease.test.ts b/backend/cli/test/util/file-lease.test.ts index e4ea9d69..ad27390c 100644 --- a/backend/cli/test/util/file-lease.test.ts +++ b/backend/cli/test/util/file-lease.test.ts @@ -1,9 +1,19 @@ import { expect, test } from "bun:test" import fs from "node:fs/promises" import path from "node:path" +import { DataRoot } from "../../src/global/data-root" +import { DataRootBarrier } from "../../src/global/data-root-barrier" import { FileLease } from "../../src/util/file-lease" import { tmpdir } from "../fixture/fixture" +async function waitForFile(filepath: string) { + const deadline = Date.now() + 2_000 + while (!(await Bun.file(filepath).exists())) { + if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${filepath}`) + await Bun.sleep(10) + } +} + test("a waiter follows exact-owner progress instead of timing out a healthy lease queue", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "progress.lock") @@ -30,3 +40,81 @@ test("a waiter still fails closed when one live owner stops making progress", as "Timed out waiting for another OpenScience process to release", ) }, 5_000) + +test("a structured lease admits a nested writer after relocation intent without a coverage gap", async () => { + await using tmp = await tmpdir() + const config = path.join(tmp.path, "config") + const data = path.join(tmp.path, "data") + const managed = await DataRoot.ensure(config, data, false) + DataRootBarrier.configure({ root: managed.path, config }) + const lease = await FileLease.acquire(path.join(managed.path, "leases", "writer.lock"), 2_000) + const scopeReady = Promise.withResolvers() + const startNested = Promise.withResolvers() + const nestedDone = Promise.withResolvers() + const command = lease.during(async () => { + scopeReady.resolve() + await startNested.promise + await using nested = await DataRootBarrier.enter(path.join(managed.path, "nested.json"), 2_000) + nestedDone.resolve() + void nested + }) + let switching: Promise | undefined + + try { + await scopeReady.promise + switching = DataRootBarrier.exclusive(2_000) + await waitForFile(path.join(config, "data-root-switch.intent")) + startNested.resolve() + expect(await Promise.race([nestedDone.promise.then(() => true), Bun.sleep(250).then(() => false)])).toBe(true) + await command + expect(await Promise.race([switching.then(() => true), Bun.sleep(50).then(() => false)])).toBe(false) + await lease[Symbol.asyncDispose]() + const exclusive = await switching + await exclusive[Symbol.asyncDispose]() + } finally { + startNested.resolve() + await command.catch(() => undefined) + await Promise.resolve(lease[Symbol.asyncDispose]()).catch(() => undefined) + const exclusive = await switching?.catch(() => undefined) + await exclusive?.[Symbol.asyncDispose]() + } +}) + +test("disposing a lease keeps its lock published until the active callback settles", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "draining.lock") + const first = await FileLease.acquire(filepath, 2_000) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const critical = first.during(async () => { + entered.resolve() + await release.promise + }) + await entered.promise + const disposing = first[Symbol.asyncDispose]() + let peerEntered = false + const waiting = FileLease.acquire(filepath, 2_000).then((lease) => { + peerEntered = true + return lease + }) + + try { + await expect(first.during(async () => undefined)).rejects.toThrow("Cannot scope work under a closing file lease") + await Bun.sleep(50) + expect(peerEntered).toBe(false) + expect(await Bun.file(filepath).exists()).toBe(true) + + release.resolve() + await critical + await disposing + const peer = await waiting + expect(peerEntered).toBe(true) + await peer[Symbol.asyncDispose]() + } finally { + release.resolve() + await critical.catch(() => undefined) + await Promise.resolve(disposing).catch(() => undefined) + const peer = await waiting.catch(() => undefined) + await peer?.[Symbol.asyncDispose]() + } +}) diff --git a/frontend/docs/src/content/openscience/commands.mdx b/frontend/docs/src/content/openscience/commands.mdx index 72810e95..e61ab1dd 100644 --- a/frontend/docs/src/content/openscience/commands.mdx +++ b/frontend/docs/src/content/openscience/commands.mdx @@ -66,11 +66,11 @@ Connecting an Atlas account is optional — see [Atlas](/openscience/atlas). Ope | Command | Use | | --- | --- | | `openscience sandbox` | Show sandbox status: OS backend + current policy. | -| `openscience sandbox enable` | Confine the agent's shell + notebook execution to the workspace (`--network deny`, `--allow `, `--on-unavailable `). | +| `openscience sandbox enable` | Confine local execution to approved paths (`--network deny`, `--allow `, `--on-unavailable `, `--[no-]require-project-trust`). | | `openscience sandbox disable` | Turn the sandbox off. | | `openscience sandbox test` | Prove containment on this machine (writes inside/outside the workspace, network egress). | -Off by default. See [Execution sandbox](/openscience/sandbox). +Enabled and fail-closed by default. Routine contained work does not require project trust; remote jobs, kernel environment changes, project extensions, and host execution do. See [Sandbox and project trust](/openscience/sandbox). ## Agent profiles diff --git a/frontend/docs/src/content/openscience/sandbox.mdx b/frontend/docs/src/content/openscience/sandbox.mdx index 870a9493..f66ce582 100644 --- a/frontend/docs/src/content/openscience/sandbox.mdx +++ b/frontend/docs/src/content/openscience/sandbox.mdx @@ -1,91 +1,118 @@ --- -title: "Execution sandbox" -description: "Confine the agent's shell commands to the workspace with a real OS sandbox — macOS Seatbelt, Linux bubblewrap. Opt-in, off by default." +title: "Sandbox and project trust" +description: "How OpenScience lets agents work autonomously inside enforced local boundaries." icon: "lock" --- -The permission system decides **whether** the agent runs a command; it does not decide **what that command can reach** once it runs. An approved (or auto-approved) command executes with your full user authority — it can touch `~/.ssh`, rewrite `~/.bashrc`, or delete another project. The permission prompt keeps you *aware*; it is not an isolation boundary. +OpenScience separates three controls that solve different problems: -The execution sandbox adds the missing boundary. When enabled, everything the agent executes — shell commands via the `bash` tool, and code run by the Python and R notebook kernels — is wrapped in a real OS sandbox that **confines writes to the workspace** and can optionally **deny network egress**. It is **off by default** — turn it on when you want the agent's blast radius contained. +- **Tool permissions** decide whether an agent may take an action. +- **The execution sandbox** limits what a spawned terminal, shell command, Python/R kernel, or local compute job can reach. +- **Project trust** controls remote jobs, kernel environment changes such as package installs, project-owned executable code such as plugins and MCP servers, and execution with full host authority. -## Quick start +This separation keeps routine work moving without treating every newly opened project as trusted code. By default, terminals, kernels, shell commands, and local jobs may run immediately **only when OpenScience can enforce the OS sandbox**. Remote jobs, kernel environment changes, project extensions, and unsandboxed execution remain blocked until you explicitly trust that project. -```bash -openscience sandbox enable # turn it on (global) -openscience sandbox test # prove it actually confines writes + network -openscience sandbox # show status: backend + current policy +## Default behavior + +The global defaults are: + +```jsonc +{ + "sandbox": { + "enabled": true, + "network": "deny", + "allowWrite": [], + "onUnavailable": "error", + "requireProjectTrust": false + } +} ``` -`sandbox test` runs real sandboxed commands — it writes inside a scratch workspace (must succeed), writes outside it (must be blocked), and checks that network-deny mode blocks egress — then prints a pass/fail for each. If it doesn't say **Containment verified**, don't rely on the sandbox. +With a working native backend, routine commands can read and write only the active session workspace and explicitly granted paths. Network access is denied, including loopback, LAN, link-local, and metadata endpoints. When no backend is available, the default `onUnavailable: "error"` refuses to run rather than silently falling back to the host. + +Set `requireProjectTrust: true` if you prefer the stricter posture where every new project must be trusted before it starts any local process, even inside a verified sandbox. + +## Permissions and sandboxing + +Permissions and sandboxing work together. A permission allows a particular tool action; it does not widen the filesystem or network boundary of the spawned process. Conversely, the sandbox does not approve a tool call that your permission rules deny or require you to review. + +This means ordinary approved work can continue autonomously inside a boundary you already chose. Crossing that boundary requires a separate policy change or grant rather than repeated command-by-command workarounds. + +## Project trust -## What it does +A new project starts with project-owned executable code disabled. You can still use sandboxed terminals, Python/R kernels, shell commands, and local compute unless **Require project trust** is enabled. -- **Writes** are denied everywhere except the workspace (your working directory and its worktree), the system temp dirs, and any extra paths you allow. Everything else on disk is read-only to the agent. -- **Reads** stay open. The threat model is *tampering and exfiltration*, not hiding your files from a tool that needs to read them. -- **Network** is allowed by default; set it to deny to stop sandboxed commands from reaching the network at all. +Trust a project only after reviewing its local configuration and code. Trust enables remote jobs, kernel environment changes such as package installs, project plugins, MCP processes, formatters, LSP commands, provider token commands/modules, publication exporters, repository/startup commands, and other project-defined executable hooks. It also allows routine execution when you intentionally turn the sandbox off or configure an unsandboxed fallback. -## Backends +Open **Settings → Permissions → Project code** to trust or revoke the current project. Revoking trust stops existing project processes and disables remote jobs, kernel environment changes, and project-owned extensions. Sandboxed routine work remains available unless your Sandbox policy requires trust for all execution. + +## Native backends | Platform | Backend | Requirement | | --- | --- | --- | | macOS | Seatbelt (`sandbox-exec`) | Built in. | -| Linux | bubblewrap (`bwrap`) | Install `bubblewrap`; unprivileged user namespaces must be enabled. | -| Windows | — | No backend; commands run per `onUnavailable` (see below). | +| Linux | bubblewrap (`bwrap`) | Install `bubblewrap`; unprivileged user namespaces must work. | +| Windows | unavailable | Commands follow `onUnavailable`; the default is to refuse. | + +On Ubuntu/Debian: + +```bash +sudo apt install bubblewrap +``` -Check what's available on your machine with `openscience sandbox` or `openscience doctor`. +OpenScience probes the backend before claiming it is available. Use the self-test below to verify the effective boundary on the current machine. -## The `sandbox` command +## CLI ```bash -openscience sandbox # status (backend + config) -openscience sandbox enable # turn on -openscience sandbox enable --network deny # also block network egress -openscience sandbox enable --allow /data/shared # extra writable path (repeatable) -openscience sandbox enable --on-unavailable error # refuse to run where no backend exists -openscience sandbox disable # turn off -openscience sandbox test # empirical containment self-test +openscience sandbox # show backend and effective global policy +openscience sandbox enable # enable fail-closed containment +openscience sandbox enable --network deny +openscience sandbox enable --allow /data/shared +openscience sandbox enable --on-unavailable error +openscience sandbox enable --require-project-trust +openscience sandbox enable --no-require-project-trust +openscience sandbox disable # host execution still requires project trust +openscience sandbox test # run real read/write/network containment probes ``` -The sandbox is a machine-wide safety setting, so it is always written to your **global** config. +`sandbox test` runs actual sandboxed commands. It requires an allowed workspace write to succeed, outside writes and ungranted reads to fail, and effective network isolation to match the backend claim. If it does not report **Containment verified**, do not rely on that backend. -| Flag | Meaning | -| --- | --- | -| `--network` | `allow` (default) or `deny` network egress from sandboxed commands. | -| `--allow` | An absolute path, beyond the workspace and temp dirs, the sandbox may write to. Repeatable. | -| `--on-unavailable` | Behaviour where no backend exists: `warn` (default, runs unsandboxed with a notice), `error` (refuses to run), `allow` (runs unsandboxed silently). | +## Workspace settings -## From the workspace GUI +Open **Settings → Sandbox** to: -Open **Settings → Sandbox**. The panel shows whether a backend is available on this machine, an on/off toggle, the network and fallback policies, an editor for extra writable paths, and a **run self-test** button that shows the same containment checks the CLI does. +- enable or disable native containment; +- require explicit project trust for all execution; +- inspect the detected backend and enforced capabilities; +- choose fail-closed behavior when containment is unavailable; +- add narrowly scoped writable roots outside the workspace; and +- run the empirical self-test. -## By hand (config) +Extra writable roots extend the sandbox; they do not trust project extensions. Prefer a narrow data or output directory over a home directory or other broad ancestor. -The sandbox is a `sandbox` block in your **global** `openscience.json` (or an enterprise **managed** config). `openscience sandbox enable` writes it for you. The policy is read **only** from global and managed config — never from a project's `openscience.json` — so opening an untrusted repo cannot weaken or disable your sandbox: +## Global and managed configuration -```jsonc -{ - "sandbox": { - "enabled": true, - "network": "deny", - "allowWrite": ["/data/shared"], - "onUnavailable": "error" - } -} -``` +Sandbox policy is read only from global and managed configuration. A project's `openscience.json` cannot weaken it. Managed configuration overrides the user's global values. + +| Key | Meaning | Default | +| --- | --- | --- | +| `enabled` | Request the native sandbox for local execution. | `true` | +| `network` | Requested network policy. Current native backends enforce deny-all. | `"deny"` | +| `allowWrite` | Extra absolute writable roots beyond session grants. | `[]` | +| `onUnavailable` | `"error"`, `"warn"`, or `"allow"` when no backend can run. | `"error"` | +| `requireProjectTrust` | Block all execution until the project is explicitly trusted. | `false` | -- `enabled` — master switch. Off by default. -- `network` — `allow` (default) or `deny`. -- `allowWrite` — extra absolute paths the sandbox may write to. -- `onUnavailable` — `warn` (default) · `error` · `allow`, for machines with no backend. +`warn` and `allow` may run a trusted project without OS containment. An untrusted project never receives host execution merely because fallback is permissive. -## Limitations +## Boundary and limitations -This is deliberately a **write-containment** sandbox, not a deny-by-default syscall jail. Research workflows run arbitrary compilers, package managers, and interpreters, and a strict jail would break far more than it protects. Concretely: +The sandbox applies to spawned commands, including shell utilities, compilers, package managers, notebook kernels, and local detached jobs. It is a real OS boundary, but it is not a VM: -- **Reads are not restricted.** A sandboxed command can still read files it has permission to read. -- **Local IPC is not blocked.** Network-deny stops IP egress, but a command can still reach host UNIX-domain sockets (e.g. a Docker daemon socket). Treat access to such a socket as equivalent to the privileges it exposes. -- **It is not a security boundary against a determined local attacker** the way a container or VM is. For hostile code, run OpenScience inside a container or VM as well. -- **Linux needs bubblewrap** and unprivileged user namespaces; where they're unavailable the sandbox can't engage (`onUnavailable` decides what happens). -- **Windows has no backend** yet. +- Only explicitly granted filesystem roots are mounted or allowed. Files already readable inside those roots remain readable to the command. +- Current macOS and Linux backends deny IP networking rather than trying to distinguish safe public destinations from loopback or private services. +- Host-brokered tools such as WebFetch apply their own URL, size, and destination controls outside the command sandbox. +- Linux requires a working bubblewrap/user-namespace setup. Windows currently has no filesystem sandbox backend. +- For actively hostile code or stronger kernel isolation, run OpenScience itself inside a container or VM as an additional boundary. -What it *does* reliably stop: an agent command writing outside your workspace, and — in deny mode — a command phoning home. Verify it on your machine with `openscience sandbox test`. +The safe low-friction preset is the default: sandbox enabled, unavailable backend refused, project trust not required for routine contained work. Full host access is an explicit combination: disable containment (or allow fallback) **and** trust the current project. diff --git a/frontend/docs/src/content/openscience/security.mdx b/frontend/docs/src/content/openscience/security.mdx index 24daeb2d..ca4d1403 100644 --- a/frontend/docs/src/content/openscience/security.mdx +++ b/frontend/docs/src/content/openscience/security.mdx @@ -4,13 +4,13 @@ description: "The trust boundary, credential storage, execution sandbox, and sub icon: "shield-check" --- -OpenScience is open source under Apache-2.0, so the whole security model below is auditable in the [repository](https://github.com/synthetic-sciences/openscience). The agent runs locally with the same filesystem and shell access you have. Two principles anchor the design: keep credentials out of subprocesses that do not need them, and make the trust boundary explicit instead of pretending the agent is a sandbox. +OpenScience is open source under Apache-2.0, so the whole security model below is auditable in the [repository](https://github.com/synthetic-sciences/openscience). The agent runs locally, while spawned commands are constrained by the native sandbox and explicit filesystem grants by default. Two principles anchor the design: keep credentials out of subprocesses that do not need them, and make each trust boundary explicit instead of treating permissions, sandboxing, and project trust as the same control. -## The agent is not a sandbox by default +## Permissions are not the sandbox -The permission system prompts you before the agent runs a command or writes a file. That keeps you aware of what it is doing — it is not, on its own, an isolation boundary. By default the agent can read, write, and execute anywhere your user can. +The permission system decides whether an agent may take an action. It is not, on its own, an isolation boundary. -You can add a real boundary: the opt-in [execution sandbox](/openscience/sandbox) wraps the agent's shell commands in an OS sandbox (macOS Seatbelt, Linux bubblewrap) that confines writes to the workspace and can deny network egress. Turn it on with `openscience sandbox enable` and verify it with `openscience sandbox test`. It is write-containment, not a full jail — for hostile code, still run OpenScience inside a container or a VM. +OpenScience enables a real [execution sandbox](/openscience/sandbox) by default. It wraps terminals, shell commands, Python/R kernels, and local jobs in macOS Seatbelt or Linux bubblewrap, limits them to the workspace and approved paths, and denies network egress. Routine work can start immediately when that boundary is enforced; remote jobs, kernel environment changes, project-owned extensions, and host execution still require explicit project trust. The default refuses to run when no native backend is available rather than silently falling back to the host. Verify the effective boundary with `openscience sandbox test`. It is OS containment, not a full VM — for actively hostile code, still run OpenScience inside a container or VM. ## Credential storage diff --git a/frontend/landing/public/docs/assets/index-BsAT-1zG.js b/frontend/landing/public/docs/assets/index-BsAT-1zG.js deleted file mode 100644 index 59d3f35a..00000000 --- a/frontend/landing/public/docs/assets/index-BsAT-1zG.js +++ /dev/null @@ -1,1533 +0,0 @@ -(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const c of document.querySelectorAll('link[rel="modulepreload"]'))u(c);new MutationObserver(c=>{for(const h of c)if(h.type==="childList")for(const f of h.addedNodes)f.tagName==="LINK"&&f.rel==="modulepreload"&&u(f)}).observe(document,{childList:!0,subtree:!0});function a(c){const h={};return c.integrity&&(h.integrity=c.integrity),c.referrerPolicy&&(h.referrerPolicy=c.referrerPolicy),c.crossOrigin==="use-credentials"?h.credentials="include":c.crossOrigin==="anonymous"?h.credentials="omit":h.credentials="same-origin",h}function u(c){if(c.ep)return;c.ep=!0;const h=a(c);fetch(c.href,h)}})();function yc(n){return n&&n.__esModule&&Object.prototype.hasOwnProperty.call(n,"default")?n.default:n}var js={exports:{}},va={};/** - * @license React - * react-jsx-runtime.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var Bp;function fb(){if(Bp)return va;Bp=1;var n=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function a(u,c,h){var f=null;if(h!==void 0&&(f=""+h),c.key!==void 0&&(f=""+c.key),"key"in c){h={};for(var p in c)p!=="key"&&(h[p]=c[p])}else h=c;return c=h.ref,{$$typeof:n,type:u,key:f,ref:c!==void 0?c:null,props:h}}return va.Fragment=r,va.jsx=a,va.jsxs=a,va}var Hp;function hb(){return Hp||(Hp=1,js.exports=fb()),js.exports}var H=hb(),Ls={exports:{}},ye={};/** - * @license React - * react.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var qp;function db(){if(qp)return ye;qp=1;var n=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),a=Symbol.for("react.fragment"),u=Symbol.for("react.strict_mode"),c=Symbol.for("react.profiler"),h=Symbol.for("react.consumer"),f=Symbol.for("react.context"),p=Symbol.for("react.forward_ref"),m=Symbol.for("react.suspense"),d=Symbol.for("react.memo"),b=Symbol.for("react.lazy"),y=Symbol.for("react.activity"),S=Symbol.iterator;function x(A){return A===null||typeof A!="object"?null:(A=S&&A[S]||A["@@iterator"],typeof A=="function"?A:null)}var T={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},U=Object.assign,K={};function D(A,Y,k){this.props=A,this.context=Y,this.refs=K,this.updater=k||T}D.prototype.isReactComponent={},D.prototype.setState=function(A,Y){if(typeof A!="object"&&typeof A!="function"&&A!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,A,Y,"setState")},D.prototype.forceUpdate=function(A){this.updater.enqueueForceUpdate(this,A,"forceUpdate")};function F(){}F.prototype=D.prototype;function Q(A,Y,k){this.props=A,this.context=Y,this.refs=K,this.updater=k||T}var ue=Q.prototype=new F;ue.constructor=Q,U(ue,D.prototype),ue.isPureReactComponent=!0;var re=Array.isArray;function L(){}var P={H:null,A:null,T:null,S:null},de=Object.prototype.hasOwnProperty;function me(A,Y,k){var ee=k.ref;return{$$typeof:n,type:A,key:Y,ref:ee!==void 0?ee:null,props:k}}function N(A,Y){return me(A.type,Y,A.props)}function te(A){return typeof A=="object"&&A!==null&&A.$$typeof===n}function B(A){var Y={"=":"=0",":":"=2"};return"$"+A.replace(/[=:]/g,function(k){return Y[k]})}var le=/\/+/g;function J(A,Y){return typeof A=="object"&&A!==null&&A.key!=null?B(""+A.key):Y.toString(36)}function $(A){switch(A.status){case"fulfilled":return A.value;case"rejected":throw A.reason;default:switch(typeof A.status=="string"?A.then(L,L):(A.status="pending",A.then(function(Y){A.status==="pending"&&(A.status="fulfilled",A.value=Y)},function(Y){A.status==="pending"&&(A.status="rejected",A.reason=Y)})),A.status){case"fulfilled":return A.value;case"rejected":throw A.reason}}throw A}function _(A,Y,k,ee,he){var oe=typeof A;(oe==="undefined"||oe==="boolean")&&(A=null);var Ae=!1;if(A===null)Ae=!0;else switch(oe){case"bigint":case"string":case"number":Ae=!0;break;case"object":switch(A.$$typeof){case n:case r:Ae=!0;break;case b:return Ae=A._init,_(Ae(A._payload),Y,k,ee,he)}}if(Ae)return he=he(A),Ae=ee===""?"."+J(A,0):ee,re(he)?(k="",Ae!=null&&(k=Ae.replace(le,"$&/")+"/"),_(he,Y,k,"",function(Yt){return Yt})):he!=null&&(te(he)&&(he=N(he,k+(he.key==null||A&&A.key===he.key?"":(""+he.key).replace(le,"$&/")+"/")+Ae)),Y.push(he)),1;Ae=0;var Ke=ee===""?".":ee+":";if(re(A))for(var Le=0;Le>>1,w=_[xe];if(0>>1;xec(k,ae))eec(he,k)?(_[xe]=he,_[ee]=ae,xe=ee):(_[xe]=k,_[Y]=ae,xe=Y);else if(eec(he,ae))_[xe]=he,_[ee]=ae,xe=ee;else break e}}return Z}function c(_,Z){var ae=_.sortIndex-Z.sortIndex;return ae!==0?ae:_.id-Z.id}if(n.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var h=performance;n.unstable_now=function(){return h.now()}}else{var f=Date,p=f.now();n.unstable_now=function(){return f.now()-p}}var m=[],d=[],b=1,y=null,S=3,x=!1,T=!1,U=!1,K=!1,D=typeof setTimeout=="function"?setTimeout:null,F=typeof clearTimeout=="function"?clearTimeout:null,Q=typeof setImmediate<"u"?setImmediate:null;function ue(_){for(var Z=a(d);Z!==null;){if(Z.callback===null)u(d);else if(Z.startTime<=_)u(d),Z.sortIndex=Z.expirationTime,r(m,Z);else break;Z=a(d)}}function re(_){if(U=!1,ue(_),!T)if(a(m)!==null)T=!0,L||(L=!0,B());else{var Z=a(d);Z!==null&&$(re,Z.startTime-_)}}var L=!1,P=-1,de=5,me=-1;function N(){return K?!0:!(n.unstable_now()-me_&&N());){var xe=y.callback;if(typeof xe=="function"){y.callback=null,S=y.priorityLevel;var w=xe(y.expirationTime<=_);if(_=n.unstable_now(),typeof w=="function"){y.callback=w,ue(_),Z=!0;break t}y===a(m)&&u(m),ue(_)}else u(m);y=a(m)}if(y!==null)Z=!0;else{var A=a(d);A!==null&&$(re,A.startTime-_),Z=!1}}break e}finally{y=null,S=ae,x=!1}Z=void 0}}finally{Z?B():L=!1}}}var B;if(typeof Q=="function")B=function(){Q(te)};else if(typeof MessageChannel<"u"){var le=new MessageChannel,J=le.port2;le.port1.onmessage=te,B=function(){J.postMessage(null)}}else B=function(){D(te,0)};function $(_,Z){P=D(function(){_(n.unstable_now())},Z)}n.unstable_IdlePriority=5,n.unstable_ImmediatePriority=1,n.unstable_LowPriority=4,n.unstable_NormalPriority=3,n.unstable_Profiling=null,n.unstable_UserBlockingPriority=2,n.unstable_cancelCallback=function(_){_.callback=null},n.unstable_forceFrameRate=function(_){0>_||125<_?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):de=0<_?Math.floor(1e3/_):5},n.unstable_getCurrentPriorityLevel=function(){return S},n.unstable_next=function(_){switch(S){case 1:case 2:case 3:var Z=3;break;default:Z=S}var ae=S;S=Z;try{return _()}finally{S=ae}},n.unstable_requestPaint=function(){K=!0},n.unstable_runWithPriority=function(_,Z){switch(_){case 1:case 2:case 3:case 4:case 5:break;default:_=3}var ae=S;S=_;try{return Z()}finally{S=ae}},n.unstable_scheduleCallback=function(_,Z,ae){var xe=n.unstable_now();switch(typeof ae=="object"&&ae!==null?(ae=ae.delay,ae=typeof ae=="number"&&0xe?(_.sortIndex=ae,r(d,_),a(m)===null&&_===a(d)&&(U?(F(P),P=-1):U=!0,$(re,ae-xe))):(_.sortIndex=w,r(m,_),T||x||(T=!0,L||(L=!0,B()))),_},n.unstable_shouldYield=N,n.unstable_wrapCallback=function(_){var Z=S;return function(){var ae=S;S=Z;try{return _.apply(this,arguments)}finally{S=ae}}}})(Hs)),Hs}var Vp;function gb(){return Vp||(Vp=1,Bs.exports=mb()),Bs.exports}var qs={exports:{}},pt={};/** - * @license React - * react-dom.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var Xp;function yb(){if(Xp)return pt;Xp=1;var n=bc();function r(m){var d="https://react.dev/errors/"+m;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(r){console.error(r)}}return n(),qs.exports=yb(),qs.exports}/** - * @license React - * react-dom-client.production.js - * - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var Kp;function vb(){if(Kp)return xa;Kp=1;var n=gb(),r=bc(),a=bb();function u(e){var t="https://react.dev/errors/"+e;if(1w||(e.current=xe[w],xe[w]=null,w--)}function k(e,t){w++,xe[w]=e.current,e.current=t}var ee=A(null),he=A(null),oe=A(null),Ae=A(null);function Ke(e,t){switch(k(oe,t),k(he,e),k(ee,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?up(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=up(t),e=op(t,e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}Y(ee),k(ee,e)}function Le(){Y(ee),Y(he),Y(oe)}function Yt(e){e.memoizedState!==null&&k(Ae,e);var t=ee.current,l=op(t,e.type);t!==l&&(k(he,e),k(ee,l))}function pn(e){he.current===e&&(Y(ee),Y(he)),Ae.current===e&&(Y(Ae),ma._currentValue=ae)}var Ci,La;function mn(e){if(Ci===void 0)try{throw Error()}catch(l){var t=l.stack.trim().match(/\n( *(at )?)/);Ci=t&&t[1]||"",La=-1)":-1o||E[i]!==M[o]){var q=` -`+E[i].replace(" at new "," at ");return e.displayName&&q.includes("")&&(q=q.replace("",e.displayName)),q}while(1<=i&&0<=o);break}}}finally{Ml=!1,Error.prepareStackTrace=l}return(l=e?e.displayName||e.name:"")?mn(l):""}function Ua(e,t){switch(e.tag){case 26:case 27:case 5:return mn(e.type);case 16:return mn("Lazy");case 13:return e.child!==t&&t!==null?mn("Suspense Fallback"):mn("Suspense");case 19:return mn("SuspenseList");case 0:case 15:return Dl(e.type,!1);case 11:return Dl(e.type.render,!1);case 1:return Dl(e.type,!0);case 31:return mn("Activity");default:return""}}function Ba(e){try{var t="",l=null;do t+=Ua(e,l),l=e,e=e.return;while(e);return t}catch(i){return` -Error generating stack: `+i.message+` -`+i.stack}}var Rl=Object.prototype.hasOwnProperty,Nl=n.unstable_scheduleCallback,Ti=n.unstable_cancelCallback,vu=n.unstable_shouldYield,xu=n.unstable_requestPaint,yt=n.unstable_now,Su=n.unstable_getCurrentPriorityLevel,G=n.unstable_ImmediatePriority,W=n.unstable_UserBlockingPriority,pe=n.unstable_NormalPriority,Se=n.unstable_LowPriority,Re=n.unstable_IdlePriority,Mt=n.log,gn=n.unstable_setDisableYieldValue,bt=null,it=null;function St(e){if(typeof Mt=="function"&&gn(e),it&&typeof it.setStrictMode=="function")try{it.setStrictMode(bt,e)}catch{}}var qe=Math.clz32?Math.clz32:$g,Ln=Math.log,en=Math.LN2;function $g(e){return e>>>=0,e===0?32:31-(Ln(e)/en|0)|0}var Ha=256,qa=262144,Ya=4194304;function sl(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Ga(e,t,l){var i=e.pendingLanes;if(i===0)return 0;var o=0,s=e.suspendedLanes,g=e.pingedLanes;e=e.warmLanes;var v=i&134217727;return v!==0?(i=v&~s,i!==0?o=sl(i):(g&=v,g!==0?o=sl(g):l||(l=v&~e,l!==0&&(o=sl(l))))):(v=i&~s,v!==0?o=sl(v):g!==0?o=sl(g):l||(l=i&~e,l!==0&&(o=sl(l)))),o===0?0:t!==0&&t!==o&&(t&s)===0&&(s=o&-o,l=t&-t,s>=l||s===32&&(l&4194048)!==0)?t:o}function zi(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function Wg(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Yc(){var e=Ya;return Ya<<=1,(Ya&62914560)===0&&(Ya=4194304),e}function ku(e){for(var t=[],l=0;31>l;l++)t.push(e);return t}function _i(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function Pg(e,t,l,i,o,s){var g=e.pendingLanes;e.pendingLanes=l,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=l,e.entangledLanes&=l,e.errorRecoveryDisabledLanes&=l,e.shellSuspendCounter=0;var v=e.entanglements,E=e.expirationTimes,M=e.hiddenUpdates;for(l=g&~l;0"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var ay=/[\n"\\]/g;function Vt(e){return e.replace(ay,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function zu(e,t,l,i,o,s,g,v){e.name="",g!=null&&typeof g!="function"&&typeof g!="symbol"&&typeof g!="boolean"?e.type=g:e.removeAttribute("type"),t!=null?g==="number"?(t===0&&e.value===""||e.value!=t)&&(e.value=""+Gt(t)):e.value!==""+Gt(t)&&(e.value=""+Gt(t)):g!=="submit"&&g!=="reset"||e.removeAttribute("value"),t!=null?_u(e,g,Gt(t)):l!=null?_u(e,g,Gt(l)):i!=null&&e.removeAttribute("value"),o==null&&s!=null&&(e.defaultChecked=!!s),o!=null&&(e.checked=o&&typeof o!="function"&&typeof o!="symbol"),v!=null&&typeof v!="function"&&typeof v!="symbol"&&typeof v!="boolean"?e.name=""+Gt(v):e.removeAttribute("name")}function ef(e,t,l,i,o,s,g,v){if(s!=null&&typeof s!="function"&&typeof s!="symbol"&&typeof s!="boolean"&&(e.type=s),t!=null||l!=null){if(!(s!=="submit"&&s!=="reset"||t!=null)){Tu(e);return}l=l!=null?""+Gt(l):"",t=t!=null?""+Gt(t):l,v||t===e.value||(e.value=t),e.defaultValue=t}i=i??o,i=typeof i!="function"&&typeof i!="symbol"&&!!i,e.checked=v?e.checked:!!i,e.defaultChecked=!!i,g!=null&&typeof g!="function"&&typeof g!="symbol"&&typeof g!="boolean"&&(e.name=g),Tu(e)}function _u(e,t,l){t==="number"&&Qa(e.ownerDocument)===e||e.defaultValue===""+l||(e.defaultValue=""+l)}function ql(e,t,l,i){if(e=e.options,t){t={};for(var o=0;o"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Nu=!1;if(vn)try{var Ri={};Object.defineProperty(Ri,"passive",{get:function(){Nu=!0}}),window.addEventListener("test",Ri,Ri),window.removeEventListener("test",Ri,Ri)}catch{Nu=!1}var Bn=null,ju=null,Za=null;function of(){if(Za)return Za;var e,t=ju,l=t.length,i,o="value"in Bn?Bn.value:Bn.textContent,s=o.length;for(e=0;e=Li),pf=" ",mf=!1;function gf(e,t){switch(e){case"keyup":return Ry.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function yf(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Xl=!1;function jy(e,t){switch(e){case"compositionend":return yf(t);case"keypress":return t.which!==32?null:(mf=!0,pf);case"textInput":return e=t.data,e===pf&&mf?null:e;default:return null}}function Ly(e,t){if(Xl)return e==="compositionend"||!qu&&gf(e,t)?(e=of(),Za=ju=Bn=null,Xl=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:l,offset:t-e};e=i}e:{for(;l;){if(l.nextSibling){l=l.nextSibling;break e}l=l.parentNode}l=void 0}l=Af(l)}}function Tf(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Tf(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function zf(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Qa(e.document);t instanceof e.HTMLIFrameElement;){try{var l=typeof t.contentWindow.location.href=="string"}catch{l=!1}if(l)e=t.contentWindow;else break;t=Qa(e.document)}return t}function Vu(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}var Xy=vn&&"documentMode"in document&&11>=document.documentMode,Ql=null,Xu=null,qi=null,Qu=!1;function _f(e,t,l){var i=l.window===l?l.document:l.nodeType===9?l:l.ownerDocument;Qu||Ql==null||Ql!==Qa(i)||(i=Ql,"selectionStart"in i&&Vu(i)?i={start:i.selectionStart,end:i.selectionEnd}:(i=(i.ownerDocument&&i.ownerDocument.defaultView||window).getSelection(),i={anchorNode:i.anchorNode,anchorOffset:i.anchorOffset,focusNode:i.focusNode,focusOffset:i.focusOffset}),qi&&Hi(qi,i)||(qi=i,i=qr(Xu,"onSelect"),0>=g,o-=g,on=1<<32-qe(t)+o|l<ve?(Te=ie,ie=null):Te=ie.sibling;var Me=R(z,ie,O[ve],V);if(Me===null){ie===null&&(ie=Te);break}e&&ie&&Me.alternate===null&&t(z,ie),C=s(Me,C,ve),Oe===null?se=Me:Oe.sibling=Me,Oe=Me,ie=Te}if(ve===O.length)return l(z,ie),ze&&Sn(z,ve),se;if(ie===null){for(;veve?(Te=ie,ie=null):Te=ie.sibling;var rl=R(z,ie,Me.value,V);if(rl===null){ie===null&&(ie=Te);break}e&&ie&&rl.alternate===null&&t(z,ie),C=s(rl,C,ve),Oe===null?se=rl:Oe.sibling=rl,Oe=rl,ie=Te}if(Me.done)return l(z,ie),ze&&Sn(z,ve),se;if(ie===null){for(;!Me.done;ve++,Me=O.next())Me=X(z,Me.value,V),Me!==null&&(C=s(Me,C,ve),Oe===null?se=Me:Oe.sibling=Me,Oe=Me);return ze&&Sn(z,ve),se}for(ie=i(ie);!Me.done;ve++,Me=O.next())Me=j(ie,z,ve,Me.value,V),Me!==null&&(e&&Me.alternate!==null&&ie.delete(Me.key===null?ve:Me.key),C=s(Me,C,ve),Oe===null?se=Me:Oe.sibling=Me,Oe=Me);return e&&ie.forEach(function(cb){return t(z,cb)}),ze&&Sn(z,ve),se}function He(z,C,O,V){if(typeof O=="object"&&O!==null&&O.type===U&&O.key===null&&(O=O.props.children),typeof O=="object"&&O!==null){switch(O.$$typeof){case x:e:{for(var se=O.key;C!==null;){if(C.key===se){if(se=O.type,se===U){if(C.tag===7){l(z,C.sibling),V=o(C,O.props.children),V.return=z,z=V;break e}}else if(C.elementType===se||typeof se=="object"&&se!==null&&se.$$typeof===de&&xl(se)===C.type){l(z,C.sibling),V=o(C,O.props),Ki(V,O),V.return=z,z=V;break e}l(z,C);break}else t(z,C);C=C.sibling}O.type===U?(V=ml(O.props.children,z.mode,V,O.key),V.return=z,z=V):(V=lr(O.type,O.key,O.props,null,z.mode,V),Ki(V,O),V.return=z,z=V)}return g(z);case T:e:{for(se=O.key;C!==null;){if(C.key===se)if(C.tag===4&&C.stateNode.containerInfo===O.containerInfo&&C.stateNode.implementation===O.implementation){l(z,C.sibling),V=o(C,O.children||[]),V.return=z,z=V;break e}else{l(z,C);break}else t(z,C);C=C.sibling}V=Wu(O,z.mode,V),V.return=z,z=V}return g(z);case de:return O=xl(O),He(z,C,O,V)}if($(O))return ne(z,C,O,V);if(B(O)){if(se=B(O),typeof se!="function")throw Error(u(150));return O=se.call(O),fe(z,C,O,V)}if(typeof O.then=="function")return He(z,C,cr(O),V);if(O.$$typeof===Q)return He(z,C,rr(z,O),V);fr(z,O)}return typeof O=="string"&&O!==""||typeof O=="number"||typeof O=="bigint"?(O=""+O,C!==null&&C.tag===6?(l(z,C.sibling),V=o(C,O),V.return=z,z=V):(l(z,C),V=$u(O,z.mode,V),V.return=z,z=V),g(z)):l(z,C)}return function(z,C,O,V){try{Qi=0;var se=He(z,C,O,V);return ni=null,se}catch(ie){if(ie===ti||ie===or)throw ie;var Oe=Rt(29,ie,null,z.mode);return Oe.lanes=V,Oe.return=z,Oe}finally{}}}var kl=Wf(!0),Pf=Wf(!1),Vn=!1;function co(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function fo(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Xn(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Qn(e,t,l){var i=e.updateQueue;if(i===null)return null;if(i=i.shared,(De&2)!==0){var o=i.pending;return o===null?t.next=t:(t.next=o.next,o.next=t),i.pending=t,t=nr(e),Lf(e,null,l),t}return tr(e,i,t,l),nr(e)}function Zi(e,t,l){if(t=t.updateQueue,t!==null&&(t=t.shared,(l&4194048)!==0)){var i=t.lanes;i&=e.pendingLanes,l|=i,t.lanes=l,Vc(e,l)}}function ho(e,t){var l=e.updateQueue,i=e.alternate;if(i!==null&&(i=i.updateQueue,l===i)){var o=null,s=null;if(l=l.firstBaseUpdate,l!==null){do{var g={lane:l.lane,tag:l.tag,payload:l.payload,callback:null,next:null};s===null?o=s=g:s=s.next=g,l=l.next}while(l!==null);s===null?o=s=t:s=s.next=t}else o=s=t;l={baseState:i.baseState,firstBaseUpdate:o,lastBaseUpdate:s,shared:i.shared,callbacks:i.callbacks},e.updateQueue=l;return}e=l.lastBaseUpdate,e===null?l.firstBaseUpdate=t:e.next=t,l.lastBaseUpdate=t}var po=!1;function Fi(){if(po){var e=ei;if(e!==null)throw e}}function Ii(e,t,l,i){po=!1;var o=e.updateQueue;Vn=!1;var s=o.firstBaseUpdate,g=o.lastBaseUpdate,v=o.shared.pending;if(v!==null){o.shared.pending=null;var E=v,M=E.next;E.next=null,g===null?s=M:g.next=M,g=E;var q=e.alternate;q!==null&&(q=q.updateQueue,v=q.lastBaseUpdate,v!==g&&(v===null?q.firstBaseUpdate=M:v.next=M,q.lastBaseUpdate=E))}if(s!==null){var X=o.baseState;g=0,q=M=E=null,v=s;do{var R=v.lane&-536870913,j=R!==v.lane;if(j?(Ce&R)===R:(i&R)===R){R!==0&&R===Pl&&(po=!0),q!==null&&(q=q.next={lane:0,tag:v.tag,payload:v.payload,callback:null,next:null});e:{var ne=e,fe=v;R=t;var He=l;switch(fe.tag){case 1:if(ne=fe.payload,typeof ne=="function"){X=ne.call(He,X,R);break e}X=ne;break e;case 3:ne.flags=ne.flags&-65537|128;case 0:if(ne=fe.payload,R=typeof ne=="function"?ne.call(He,X,R):ne,R==null)break e;X=y({},X,R);break e;case 2:Vn=!0}}R=v.callback,R!==null&&(e.flags|=64,j&&(e.flags|=8192),j=o.callbacks,j===null?o.callbacks=[R]:j.push(R))}else j={lane:R,tag:v.tag,payload:v.payload,callback:v.callback,next:null},q===null?(M=q=j,E=X):q=q.next=j,g|=R;if(v=v.next,v===null){if(v=o.shared.pending,v===null)break;j=v,v=j.next,j.next=null,o.lastBaseUpdate=j,o.shared.pending=null}}while(!0);q===null&&(E=X),o.baseState=E,o.firstBaseUpdate=M,o.lastBaseUpdate=q,s===null&&(o.shared.lanes=0),Jn|=g,e.lanes=g,e.memoizedState=X}}function eh(e,t){if(typeof e!="function")throw Error(u(191,e));e.call(t)}function th(e,t){var l=e.callbacks;if(l!==null)for(e.callbacks=null,e=0;es?s:8;var g=_.T,v={};_.T=v,Ro(e,!1,t,l);try{var E=o(),M=_.S;if(M!==null&&M(v,E),E!==null&&typeof E=="object"&&typeof E.then=="function"){var q=Py(E,i);Wi(e,t,q,Bt(e))}else Wi(e,t,i,Bt(e))}catch(X){Wi(e,t,{then:function(){},status:"rejected",reason:X},Bt())}finally{Z.p=s,g!==null&&v.types!==null&&(g.types=v.types),_.T=g}}function a1(){}function Mo(e,t,l,i){if(e.tag!==5)throw Error(u(476));var o=Rh(e).queue;Dh(e,o,t,ae,l===null?a1:function(){return Nh(e),l(i)})}function Rh(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:ae,baseState:ae,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:An,lastRenderedState:ae},next:null};var l={};return t.next={memoizedState:l,baseState:l,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:An,lastRenderedState:l},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function Nh(e){var t=Rh(e);t.next===null&&(t=e.alternate.memoizedState),Wi(e,t.next.queue,{},Bt())}function Do(){return ft(ma)}function jh(){return $e().memoizedState}function Lh(){return $e().memoizedState}function r1(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var l=Bt();e=Xn(l);var i=Qn(t,e,l);i!==null&&(zt(i,t,l),Zi(i,t,l)),t={cache:ro()},e.payload=t;return}t=t.return}}function u1(e,t,l){var i=Bt();l={lane:i,revertLane:0,gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null},Sr(e)?Bh(t,l):(l=Iu(e,t,l,i),l!==null&&(zt(l,e,i),Hh(l,t,i)))}function Uh(e,t,l){var i=Bt();Wi(e,t,l,i)}function Wi(e,t,l,i){var o={lane:i,revertLane:0,gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null};if(Sr(e))Bh(t,o);else{var s=e.alternate;if(e.lanes===0&&(s===null||s.lanes===0)&&(s=t.lastRenderedReducer,s!==null))try{var g=t.lastRenderedState,v=s(g,l);if(o.hasEagerState=!0,o.eagerState=v,Dt(v,g))return tr(e,t,o,0),Ye===null&&er(),!1}catch{}finally{}if(l=Iu(e,t,o,i),l!==null)return zt(l,e,i),Hh(l,t,i),!0}return!1}function Ro(e,t,l,i){if(i={lane:2,revertLane:fs(),gesture:null,action:i,hasEagerState:!1,eagerState:null,next:null},Sr(e)){if(t)throw Error(u(479))}else t=Iu(e,l,i,2),t!==null&&zt(t,e,2)}function Sr(e){var t=e.alternate;return e===be||t!==null&&t===be}function Bh(e,t){ii=pr=!0;var l=e.pending;l===null?t.next=t:(t.next=l.next,l.next=t),e.pending=t}function Hh(e,t,l){if((l&4194048)!==0){var i=t.lanes;i&=e.pendingLanes,l|=i,t.lanes=l,Vc(e,l)}}var Pi={readContext:ft,use:yr,useCallback:Fe,useContext:Fe,useEffect:Fe,useImperativeHandle:Fe,useLayoutEffect:Fe,useInsertionEffect:Fe,useMemo:Fe,useReducer:Fe,useRef:Fe,useState:Fe,useDebugValue:Fe,useDeferredValue:Fe,useTransition:Fe,useSyncExternalStore:Fe,useId:Fe,useHostTransitionStatus:Fe,useFormState:Fe,useActionState:Fe,useOptimistic:Fe,useMemoCache:Fe,useCacheRefresh:Fe};Pi.useEffectEvent=Fe;var qh={readContext:ft,use:yr,useCallback:function(e,t){return vt().memoizedState=[e,t===void 0?null:t],e},useContext:ft,useEffect:wh,useImperativeHandle:function(e,t,l){l=l!=null?l.concat([e]):null,vr(4194308,4,Th.bind(null,t,e),l)},useLayoutEffect:function(e,t){return vr(4194308,4,e,t)},useInsertionEffect:function(e,t){vr(4,2,e,t)},useMemo:function(e,t){var l=vt();t=t===void 0?null:t;var i=e();if(wl){St(!0);try{e()}finally{St(!1)}}return l.memoizedState=[i,t],i},useReducer:function(e,t,l){var i=vt();if(l!==void 0){var o=l(t);if(wl){St(!0);try{l(t)}finally{St(!1)}}}else o=t;return i.memoizedState=i.baseState=o,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:o},i.queue=e,e=e.dispatch=u1.bind(null,be,e),[i.memoizedState,e]},useRef:function(e){var t=vt();return e={current:e},t.memoizedState=e},useState:function(e){e=Co(e);var t=e.queue,l=Uh.bind(null,be,t);return t.dispatch=l,[e.memoizedState,l]},useDebugValue:_o,useDeferredValue:function(e,t){var l=vt();return Oo(l,e,t)},useTransition:function(){var e=Co(!1);return e=Dh.bind(null,be,e.queue,!0,!1),vt().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,l){var i=be,o=vt();if(ze){if(l===void 0)throw Error(u(407));l=l()}else{if(l=t(),Ye===null)throw Error(u(349));(Ce&127)!==0||uh(i,t,l)}o.memoizedState=l;var s={value:l,getSnapshot:t};return o.queue=s,wh(sh.bind(null,i,s,e),[e]),i.flags|=2048,ri(9,{destroy:void 0},oh.bind(null,i,s,l,t),null),l},useId:function(){var e=vt(),t=Ye.identifierPrefix;if(ze){var l=sn,i=on;l=(i&~(1<<32-qe(i)-1)).toString(32)+l,t="_"+t+"R_"+l,l=mr++,0<\/script>",s=s.removeChild(s.firstChild);break;case"select":s=typeof i.is=="string"?g.createElement("select",{is:i.is}):g.createElement("select"),i.multiple?s.multiple=!0:i.size&&(s.size=i.size);break;default:s=typeof i.is=="string"?g.createElement(o,{is:i.is}):g.createElement(o)}}s[st]=t,s[kt]=i;e:for(g=t.child;g!==null;){if(g.tag===5||g.tag===6)s.appendChild(g.stateNode);else if(g.tag!==4&&g.tag!==27&&g.child!==null){g.child.return=g,g=g.child;continue}if(g===t)break e;for(;g.sibling===null;){if(g.return===null||g.return===t)break e;g=g.return}g.sibling.return=g.return,g=g.sibling}t.stateNode=s;e:switch(dt(s,o,i),o){case"button":case"input":case"select":case"textarea":i=!!i.autoFocus;break e;case"img":i=!0;break e;default:i=!1}i&&Tn(t)}}return Qe(t),Zo(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,l),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==i&&Tn(t);else{if(typeof i!="string"&&t.stateNode===null)throw Error(u(166));if(e=oe.current,$l(t)){if(e=t.stateNode,l=t.memoizedProps,i=null,o=ct,o!==null)switch(o.tag){case 27:case 5:i=o.memoizedProps}e[st]=t,e=!!(e.nodeValue===l||i!==null&&i.suppressHydrationWarning===!0||ap(e.nodeValue,l)),e||Yn(t,!0)}else e=Yr(e).createTextNode(i),e[st]=t,t.stateNode=e}return Qe(t),null;case 31:if(l=t.memoizedState,e===null||e.memoizedState!==null){if(i=$l(t),l!==null){if(e===null){if(!i)throw Error(u(318));if(e=t.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(u(557));e[st]=t}else gl(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;Qe(t),e=!1}else l=no(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=l),e=!0;if(!e)return t.flags&256?(jt(t),t):(jt(t),null);if((t.flags&128)!==0)throw Error(u(558))}return Qe(t),null;case 13:if(i=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(o=$l(t),i!==null&&i.dehydrated!==null){if(e===null){if(!o)throw Error(u(318));if(o=t.memoizedState,o=o!==null?o.dehydrated:null,!o)throw Error(u(317));o[st]=t}else gl(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;Qe(t),o=!1}else o=no(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=o),o=!0;if(!o)return t.flags&256?(jt(t),t):(jt(t),null)}return jt(t),(t.flags&128)!==0?(t.lanes=l,t):(l=i!==null,e=e!==null&&e.memoizedState!==null,l&&(i=t.child,o=null,i.alternate!==null&&i.alternate.memoizedState!==null&&i.alternate.memoizedState.cachePool!==null&&(o=i.alternate.memoizedState.cachePool.pool),s=null,i.memoizedState!==null&&i.memoizedState.cachePool!==null&&(s=i.memoizedState.cachePool.pool),s!==o&&(i.flags|=2048)),l!==e&&l&&(t.child.flags|=8192),Cr(t,t.updateQueue),Qe(t),null);case 4:return Le(),e===null&&ms(t.stateNode.containerInfo),Qe(t),null;case 10:return wn(t.type),Qe(t),null;case 19:if(Y(Je),i=t.memoizedState,i===null)return Qe(t),null;if(o=(t.flags&128)!==0,s=i.rendering,s===null)if(o)ta(i,!1);else{if(Ie!==0||e!==null&&(e.flags&128)!==0)for(e=t.child;e!==null;){if(s=dr(e),s!==null){for(t.flags|=128,ta(i,!1),e=s.updateQueue,t.updateQueue=e,Cr(t,e),t.subtreeFlags=0,e=l,l=t.child;l!==null;)Uf(l,e),l=l.sibling;return k(Je,Je.current&1|2),ze&&Sn(t,i.treeForkCount),t.child}e=e.sibling}i.tail!==null&&yt()>Mr&&(t.flags|=128,o=!0,ta(i,!1),t.lanes=4194304)}else{if(!o)if(e=dr(s),e!==null){if(t.flags|=128,o=!0,e=e.updateQueue,t.updateQueue=e,Cr(t,e),ta(i,!0),i.tail===null&&i.tailMode==="hidden"&&!s.alternate&&!ze)return Qe(t),null}else 2*yt()-i.renderingStartTime>Mr&&l!==536870912&&(t.flags|=128,o=!0,ta(i,!1),t.lanes=4194304);i.isBackwards?(s.sibling=t.child,t.child=s):(e=i.last,e!==null?e.sibling=s:t.child=s,i.last=s)}return i.tail!==null?(e=i.tail,i.rendering=e,i.tail=e.sibling,i.renderingStartTime=yt(),e.sibling=null,l=Je.current,k(Je,o?l&1|2:l&1),ze&&Sn(t,i.treeForkCount),e):(Qe(t),null);case 22:case 23:return jt(t),go(),i=t.memoizedState!==null,e!==null?e.memoizedState!==null!==i&&(t.flags|=8192):i&&(t.flags|=8192),i?(l&536870912)!==0&&(t.flags&128)===0&&(Qe(t),t.subtreeFlags&6&&(t.flags|=8192)):Qe(t),l=t.updateQueue,l!==null&&Cr(t,l.retryQueue),l=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(l=e.memoizedState.cachePool.pool),i=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(i=t.memoizedState.cachePool.pool),i!==l&&(t.flags|=2048),e!==null&&Y(vl),null;case 24:return l=null,e!==null&&(l=e.memoizedState.cache),t.memoizedState.cache!==l&&(t.flags|=2048),wn(Pe),Qe(t),null;case 25:return null;case 30:return null}throw Error(u(156,t.tag))}function h1(e,t){switch(eo(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return wn(Pe),Le(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return pn(t),null;case 31:if(t.memoizedState!==null){if(jt(t),t.alternate===null)throw Error(u(340));gl()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(jt(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(u(340));gl()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Y(Je),null;case 4:return Le(),null;case 10:return wn(t.type),null;case 22:case 23:return jt(t),go(),e!==null&&Y(vl),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return wn(Pe),null;case 25:return null;default:return null}}function cd(e,t){switch(eo(t),t.tag){case 3:wn(Pe),Le();break;case 26:case 27:case 5:pn(t);break;case 4:Le();break;case 31:t.memoizedState!==null&&jt(t);break;case 13:jt(t);break;case 19:Y(Je);break;case 10:wn(t.type);break;case 22:case 23:jt(t),go(),e!==null&&Y(vl);break;case 24:wn(Pe)}}function na(e,t){try{var l=t.updateQueue,i=l!==null?l.lastEffect:null;if(i!==null){var o=i.next;l=o;do{if((l.tag&e)===e){i=void 0;var s=l.create,g=l.inst;i=s(),g.destroy=i}l=l.next}while(l!==o)}}catch(v){je(t,t.return,v)}}function Fn(e,t,l){try{var i=t.updateQueue,o=i!==null?i.lastEffect:null;if(o!==null){var s=o.next;i=s;do{if((i.tag&e)===e){var g=i.inst,v=g.destroy;if(v!==void 0){g.destroy=void 0,o=t;var E=l,M=v;try{M()}catch(q){je(o,E,q)}}}i=i.next}while(i!==s)}}catch(q){je(t,t.return,q)}}function fd(e){var t=e.updateQueue;if(t!==null){var l=e.stateNode;try{th(t,l)}catch(i){je(e,e.return,i)}}}function hd(e,t,l){l.props=El(e.type,e.memoizedProps),l.state=e.memoizedState;try{l.componentWillUnmount()}catch(i){je(e,t,i)}}function la(e,t){try{var l=e.ref;if(l!==null){switch(e.tag){case 26:case 27:case 5:var i=e.stateNode;break;case 30:i=e.stateNode;break;default:i=e.stateNode}typeof l=="function"?e.refCleanup=l(i):l.current=i}}catch(o){je(e,t,o)}}function cn(e,t){var l=e.ref,i=e.refCleanup;if(l!==null)if(typeof i=="function")try{i()}catch(o){je(e,t,o)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof l=="function")try{l(null)}catch(o){je(e,t,o)}else l.current=null}function dd(e){var t=e.type,l=e.memoizedProps,i=e.stateNode;try{e:switch(t){case"button":case"input":case"select":case"textarea":l.autoFocus&&i.focus();break e;case"img":l.src?i.src=l.src:l.srcSet&&(i.srcset=l.srcSet)}}catch(o){je(e,e.return,o)}}function Fo(e,t,l){try{var i=e.stateNode;N1(i,e.type,l,t),i[kt]=t}catch(o){je(e,e.return,o)}}function pd(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&tl(e.type)||e.tag===4}function Io(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||pd(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&tl(e.type)||e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Jo(e,t,l){var i=e.tag;if(i===5||i===6)e=e.stateNode,t?(l.nodeType===9?l.body:l.nodeName==="HTML"?l.ownerDocument.body:l).insertBefore(e,t):(t=l.nodeType===9?l.body:l.nodeName==="HTML"?l.ownerDocument.body:l,t.appendChild(e),l=l._reactRootContainer,l!=null||t.onclick!==null||(t.onclick=bn));else if(i!==4&&(i===27&&tl(e.type)&&(l=e.stateNode,t=null),e=e.child,e!==null))for(Jo(e,t,l),e=e.sibling;e!==null;)Jo(e,t,l),e=e.sibling}function Tr(e,t,l){var i=e.tag;if(i===5||i===6)e=e.stateNode,t?l.insertBefore(e,t):l.appendChild(e);else if(i!==4&&(i===27&&tl(e.type)&&(l=e.stateNode),e=e.child,e!==null))for(Tr(e,t,l),e=e.sibling;e!==null;)Tr(e,t,l),e=e.sibling}function md(e){var t=e.stateNode,l=e.memoizedProps;try{for(var i=e.type,o=t.attributes;o.length;)t.removeAttributeNode(o[0]);dt(t,i,l),t[st]=e,t[kt]=l}catch(s){je(e,e.return,s)}}var zn=!1,nt=!1,$o=!1,gd=typeof WeakSet=="function"?WeakSet:Set,ut=null;function d1(e,t){if(e=e.containerInfo,bs=Fr,e=zf(e),Vu(e)){if("selectionStart"in e)var l={start:e.selectionStart,end:e.selectionEnd};else e:{l=(l=e.ownerDocument)&&l.defaultView||window;var i=l.getSelection&&l.getSelection();if(i&&i.rangeCount!==0){l=i.anchorNode;var o=i.anchorOffset,s=i.focusNode;i=i.focusOffset;try{l.nodeType,s.nodeType}catch{l=null;break e}var g=0,v=-1,E=-1,M=0,q=0,X=e,R=null;t:for(;;){for(var j;X!==l||o!==0&&X.nodeType!==3||(v=g+o),X!==s||i!==0&&X.nodeType!==3||(E=g+i),X.nodeType===3&&(g+=X.nodeValue.length),(j=X.firstChild)!==null;)R=X,X=j;for(;;){if(X===e)break t;if(R===l&&++M===o&&(v=g),R===s&&++q===i&&(E=g),(j=X.nextSibling)!==null)break;X=R,R=X.parentNode}X=j}l=v===-1||E===-1?null:{start:v,end:E}}else l=null}l=l||{start:0,end:0}}else l=null;for(vs={focusedElem:e,selectionRange:l},Fr=!1,ut=t;ut!==null;)if(t=ut,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,ut=e;else for(;ut!==null;){switch(t=ut,s=t.alternate,e=t.flags,t.tag){case 0:if((e&4)!==0&&(e=t.updateQueue,e=e!==null?e.events:null,e!==null))for(l=0;l title"))),dt(s,i,l),s[st]=e,rt(s),i=s;break e;case"link":var g=kp("link","href",o).get(i+(l.href||""));if(g){for(var v=0;vHe&&(g=He,He=fe,fe=g);var z=Cf(v,fe),C=Cf(v,He);if(z&&C&&(j.rangeCount!==1||j.anchorNode!==z.node||j.anchorOffset!==z.offset||j.focusNode!==C.node||j.focusOffset!==C.offset)){var O=X.createRange();O.setStart(z.node,z.offset),j.removeAllRanges(),fe>He?(j.addRange(O),j.extend(C.node,C.offset)):(O.setEnd(C.node,C.offset),j.addRange(O))}}}}for(X=[],j=v;j=j.parentNode;)j.nodeType===1&&X.push({element:j,left:j.scrollLeft,top:j.scrollTop});for(typeof v.focus=="function"&&v.focus(),v=0;vl?32:l,_.T=null,l=is,is=null;var s=Wn,g=Rn;if(at=0,fi=Wn=null,Rn=0,(De&6)!==0)throw Error(u(331));var v=De;if(De|=4,Td(s.current),Ed(s,s.current,g,l),De=v,sa(0,!1),it&&typeof it.onPostCommitFiberRoot=="function")try{it.onPostCommitFiberRoot(bt,s)}catch{}return!0}finally{Z.p=o,_.T=i,Qd(e,t)}}function Zd(e,t,l){t=Qt(l,t),t=Uo(e.stateNode,t,2),e=Qn(e,t,2),e!==null&&(_i(e,2),fn(e))}function je(e,t,l){if(e.tag===3)Zd(e,e,l);else for(;t!==null;){if(t.tag===3){Zd(t,e,l);break}else if(t.tag===1){var i=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof i.componentDidCatch=="function"&&($n===null||!$n.has(i))){e=Qt(l,e),l=Fh(2),i=Qn(t,l,2),i!==null&&(Ih(l,i,t,e),_i(i,2),fn(i));break}}t=t.return}}function os(e,t,l){var i=e.pingCache;if(i===null){i=e.pingCache=new g1;var o=new Set;i.set(t,o)}else o=i.get(t),o===void 0&&(o=new Set,i.set(t,o));o.has(l)||(es=!0,o.add(l),e=S1.bind(null,e,t,l),t.then(e,e))}function S1(e,t,l){var i=e.pingCache;i!==null&&i.delete(t),e.pingedLanes|=e.suspendedLanes&l,e.warmLanes&=~l,Ye===e&&(Ce&l)===l&&(Ie===4||Ie===3&&(Ce&62914560)===Ce&&300>yt()-Or?(De&2)===0&&hi(e,0):ts|=l,ci===Ce&&(ci=0)),fn(e)}function Fd(e,t){t===0&&(t=Yc()),e=pl(e,t),e!==null&&(_i(e,t),fn(e))}function k1(e){var t=e.memoizedState,l=0;t!==null&&(l=t.retryLane),Fd(e,l)}function w1(e,t){var l=0;switch(e.tag){case 31:case 13:var i=e.stateNode,o=e.memoizedState;o!==null&&(l=o.retryLane);break;case 19:i=e.stateNode;break;case 22:i=e.stateNode._retryCache;break;default:throw Error(u(314))}i!==null&&i.delete(t),Fd(e,l)}function E1(e,t){return Nl(e,t)}var Ur=null,pi=null,ss=!1,Br=!1,cs=!1,el=0;function fn(e){e!==pi&&e.next===null&&(pi===null?Ur=pi=e:pi=pi.next=e),Br=!0,ss||(ss=!0,C1())}function sa(e,t){if(!cs&&Br){cs=!0;do for(var l=!1,i=Ur;i!==null;){if(e!==0){var o=i.pendingLanes;if(o===0)var s=0;else{var g=i.suspendedLanes,v=i.pingedLanes;s=(1<<31-qe(42|e)+1)-1,s&=o&~(g&~v),s=s&201326741?s&201326741|1:s?s|2:0}s!==0&&(l=!0,Wd(i,s))}else s=Ce,s=Ga(i,i===Ye?s:0,i.cancelPendingCommit!==null||i.timeoutHandle!==-1),(s&3)===0||zi(i,s)||(l=!0,Wd(i,s));i=i.next}while(l);cs=!1}}function A1(){Id()}function Id(){Br=ss=!1;var e=0;el!==0&&L1()&&(e=el);for(var t=yt(),l=null,i=Ur;i!==null;){var o=i.next,s=Jd(i,t);s===0?(i.next=null,l===null?Ur=o:l.next=o,o===null&&(pi=l)):(l=i,(e!==0||(s&3)!==0)&&(Br=!0)),i=o}at!==0&&at!==5||sa(e),el!==0&&(el=0)}function Jd(e,t){for(var l=e.suspendedLanes,i=e.pingedLanes,o=e.expirationTimes,s=e.pendingLanes&-62914561;0v)break;var q=E.transferSize,X=E.initiatorType;q&&rp(X)&&(E=E.responseEnd,g+=q*(E"u"?null:document;function bp(e,t,l){var i=mi;if(i&&typeof t=="string"&&t){var o=Vt(t);o='link[rel="'+e+'"][href="'+o+'"]',typeof l=="string"&&(o+='[crossorigin="'+l+'"]'),yp.has(o)||(yp.add(o),e={rel:e,crossOrigin:l,href:t},i.querySelector(o)===null&&(t=i.createElement("link"),dt(t,"link",e),rt(t),i.head.appendChild(t)))}}function Q1(e){Nn.D(e),bp("dns-prefetch",e,null)}function K1(e,t){Nn.C(e,t),bp("preconnect",e,t)}function Z1(e,t,l){Nn.L(e,t,l);var i=mi;if(i&&e&&t){var o='link[rel="preload"][as="'+Vt(t)+'"]';t==="image"&&l&&l.imageSrcSet?(o+='[imagesrcset="'+Vt(l.imageSrcSet)+'"]',typeof l.imageSizes=="string"&&(o+='[imagesizes="'+Vt(l.imageSizes)+'"]')):o+='[href="'+Vt(e)+'"]';var s=o;switch(t){case"style":s=gi(e);break;case"script":s=yi(e)}$t.has(s)||(e=y({rel:"preload",href:t==="image"&&l&&l.imageSrcSet?void 0:e,as:t},l),$t.set(s,e),i.querySelector(o)!==null||t==="style"&&i.querySelector(da(s))||t==="script"&&i.querySelector(pa(s))||(t=i.createElement("link"),dt(t,"link",e),rt(t),i.head.appendChild(t)))}}function F1(e,t){Nn.m(e,t);var l=mi;if(l&&e){var i=t&&typeof t.as=="string"?t.as:"script",o='link[rel="modulepreload"][as="'+Vt(i)+'"][href="'+Vt(e)+'"]',s=o;switch(i){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":s=yi(e)}if(!$t.has(s)&&(e=y({rel:"modulepreload",href:e},t),$t.set(s,e),l.querySelector(o)===null)){switch(i){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(l.querySelector(pa(s)))return}i=l.createElement("link"),dt(i,"link",e),rt(i),l.head.appendChild(i)}}}function I1(e,t,l){Nn.S(e,t,l);var i=mi;if(i&&e){var o=Bl(i).hoistableStyles,s=gi(e);t=t||"default";var g=o.get(s);if(!g){var v={loading:0,preload:null};if(g=i.querySelector(da(s)))v.loading=5;else{e=y({rel:"stylesheet",href:e,"data-precedence":t},l),(l=$t.get(s))&&Cs(e,l);var E=g=i.createElement("link");rt(E),dt(E,"link",e),E._p=new Promise(function(M,q){E.onload=M,E.onerror=q}),E.addEventListener("load",function(){v.loading|=1}),E.addEventListener("error",function(){v.loading|=2}),v.loading|=4,Vr(g,t,i)}g={type:"stylesheet",instance:g,count:1,state:v},o.set(s,g)}}}function J1(e,t){Nn.X(e,t);var l=mi;if(l&&e){var i=Bl(l).hoistableScripts,o=yi(e),s=i.get(o);s||(s=l.querySelector(pa(o)),s||(e=y({src:e,async:!0},t),(t=$t.get(o))&&Ts(e,t),s=l.createElement("script"),rt(s),dt(s,"link",e),l.head.appendChild(s)),s={type:"script",instance:s,count:1,state:null},i.set(o,s))}}function $1(e,t){Nn.M(e,t);var l=mi;if(l&&e){var i=Bl(l).hoistableScripts,o=yi(e),s=i.get(o);s||(s=l.querySelector(pa(o)),s||(e=y({src:e,async:!0,type:"module"},t),(t=$t.get(o))&&Ts(e,t),s=l.createElement("script"),rt(s),dt(s,"link",e),l.head.appendChild(s)),s={type:"script",instance:s,count:1,state:null},i.set(o,s))}}function vp(e,t,l,i){var o=(o=oe.current)?Gr(o):null;if(!o)throw Error(u(446));switch(e){case"meta":case"title":return null;case"style":return typeof l.precedence=="string"&&typeof l.href=="string"?(t=gi(l.href),l=Bl(o).hoistableStyles,i=l.get(t),i||(i={type:"style",instance:null,count:0,state:null},l.set(t,i)),i):{type:"void",instance:null,count:0,state:null};case"link":if(l.rel==="stylesheet"&&typeof l.href=="string"&&typeof l.precedence=="string"){e=gi(l.href);var s=Bl(o).hoistableStyles,g=s.get(e);if(g||(o=o.ownerDocument||o,g={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},s.set(e,g),(s=o.querySelector(da(e)))&&!s._p&&(g.instance=s,g.state.loading=5),$t.has(e)||(l={rel:"preload",as:"style",href:l.href,crossOrigin:l.crossOrigin,integrity:l.integrity,media:l.media,hrefLang:l.hrefLang,referrerPolicy:l.referrerPolicy},$t.set(e,l),s||W1(o,e,l,g.state))),t&&i===null)throw Error(u(528,""));return g}if(t&&i!==null)throw Error(u(529,""));return null;case"script":return t=l.async,l=l.src,typeof l=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(t=yi(l),l=Bl(o).hoistableScripts,i=l.get(t),i||(i={type:"script",instance:null,count:0,state:null},l.set(t,i)),i):{type:"void",instance:null,count:0,state:null};default:throw Error(u(444,e))}}function gi(e){return'href="'+Vt(e)+'"'}function da(e){return'link[rel="stylesheet"]['+e+"]"}function xp(e){return y({},e,{"data-precedence":e.precedence,precedence:null})}function W1(e,t,l,i){e.querySelector('link[rel="preload"][as="style"]['+t+"]")?i.loading=1:(t=e.createElement("link"),i.preload=t,t.addEventListener("load",function(){return i.loading|=1}),t.addEventListener("error",function(){return i.loading|=2}),dt(t,"link",l),rt(t),e.head.appendChild(t))}function yi(e){return'[src="'+Vt(e)+'"]'}function pa(e){return"script[async]"+e}function Sp(e,t,l){if(t.count++,t.instance===null)switch(t.type){case"style":var i=e.querySelector('style[data-href~="'+Vt(l.href)+'"]');if(i)return t.instance=i,rt(i),i;var o=y({},l,{"data-href":l.href,"data-precedence":l.precedence,href:null,precedence:null});return i=(e.ownerDocument||e).createElement("style"),rt(i),dt(i,"style",o),Vr(i,l.precedence,e),t.instance=i;case"stylesheet":o=gi(l.href);var s=e.querySelector(da(o));if(s)return t.state.loading|=4,t.instance=s,rt(s),s;i=xp(l),(o=$t.get(o))&&Cs(i,o),s=(e.ownerDocument||e).createElement("link"),rt(s);var g=s;return g._p=new Promise(function(v,E){g.onload=v,g.onerror=E}),dt(s,"link",i),t.state.loading|=4,Vr(s,l.precedence,e),t.instance=s;case"script":return s=yi(l.src),(o=e.querySelector(pa(s)))?(t.instance=o,rt(o),o):(i=l,(o=$t.get(s))&&(i=y({},l),Ts(i,o)),e=e.ownerDocument||e,o=e.createElement("script"),rt(o),dt(o,"link",i),e.head.appendChild(o),t.instance=o);case"void":return null;default:throw Error(u(443,t.type))}else t.type==="stylesheet"&&(t.state.loading&4)===0&&(i=t.instance,t.state.loading|=4,Vr(i,l.precedence,e));return t.instance}function Vr(e,t,l){for(var i=l.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),o=i.length?i[i.length-1]:null,s=o,g=0;g title"):null)}function P1(e,t,l){if(l===1||t.itemProp!=null)return!1;switch(e){case"meta":case"title":return!0;case"style":if(typeof t.precedence!="string"||typeof t.href!="string"||t.href==="")break;return!0;case"link":if(typeof t.rel!="string"||typeof t.href!="string"||t.href===""||t.onLoad||t.onError)break;switch(t.rel){case"stylesheet":return e=t.disabled,typeof t.precedence=="string"&&e==null;default:return!0}case"script":if(t.async&&typeof t.async!="function"&&typeof t.async!="symbol"&&!t.onLoad&&!t.onError&&t.src&&typeof t.src=="string")return!0}return!1}function Ep(e){return!(e.type==="stylesheet"&&(e.state.loading&3)===0)}function eb(e,t,l,i){if(l.type==="stylesheet"&&(typeof i.media!="string"||matchMedia(i.media).matches!==!1)&&(l.state.loading&4)===0){if(l.instance===null){var o=gi(i.href),s=t.querySelector(da(o));if(s){t=s._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(e.count++,e=Qr.bind(e),t.then(e,e)),l.state.loading|=4,l.instance=s,rt(s);return}s=t.ownerDocument||t,i=xp(i),(o=$t.get(o))&&Cs(i,o),s=s.createElement("link"),rt(s);var g=s;g._p=new Promise(function(v,E){g.onload=v,g.onerror=E}),dt(s,"link",i),l.instance=s}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(l,t),(t=l.state.preload)&&(l.state.loading&3)===0&&(e.count++,l=Qr.bind(e),t.addEventListener("load",l),t.addEventListener("error",l))}}var zs=0;function tb(e,t){return e.stylesheets&&e.count===0&&Zr(e,e.stylesheets),0zs?50:800)+t);return e.unsuspend=l,function(){e.unsuspend=null,clearTimeout(i),clearTimeout(o)}}:null}function Qr(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Zr(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Kr=null;function Zr(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Kr=new Map,t.forEach(nb,e),Kr=null,Qr.call(e))}function nb(e,t){if(!(t.state.loading&4)){var l=Kr.get(e);if(l)var i=l.get(null);else{l=new Map,Kr.set(e,l);for(var o=e.querySelectorAll("link[data-precedence],style[data-precedence]"),s=0;s"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(r){console.error(r)}}return n(),Us.exports=vb(),Us.exports}var Sb=xb();const kb='---\ntitle: "Agents"\ndescription: "The research agent roster — a default research agent, three domain specialists, critique sub-agents, and a read-only plan mode — plus how to build your own."\nicon: "bot"\n---\n\nThe default agent is `research`, a scientific research agent that runs the whole loop: literature review, hypothesis, code, experiments on real compute, analysis, and write-up. Three domain specialists ship alongside it, backed by read-only critique sub-agents and a `plan` mode that cannot edit files.\n\n## Built-in roster\n\n| Agent | Role | What it does |\n| --- | --- | --- |\n| `research` | Default | Scientific research across the full skill library — literature, data analysis, GPU compute, and synthesis. |\n| `biology` | Specialist | Computational biology: bioinformatics workflows and 30+ biological database integrations. |\n| `physics` | Specialist | Computational physics: simulation, PDE solving, dynamical systems, symbolic regression. |\n| `ml` | Specialist | Trains, evaluates, and analyzes models end to end — deep learning, LLMs, classical ML, RL. |\n| `plan` | Mode | Read-only planning. Edit tools are disabled except for plan files. |\n\nTwo sub-agents back the roster. The main agent delegates to them like tools:\n\n| Sub-agent | What it does |\n| --- | --- |\n| `critique` | Read-only scientific critique. Finds blocking errors — data leakage, wrong statistics, unsupported claims — before expensive or irreversible actions. |\n| `literature-review` | Full PRISMA literature review: systematic search, screening, eligibility, synthesis, verification. |\n\nRun `openscience agent list` to print the complete set on your install, including utility sub-agents like `explore` and `reviewer`.\n\n## Pick an agent\n\n```bash\n# one-shot run with a specific agent\nopenscience run --agent physics "Fit the dispersion relation in data/spectra.csv"\n```\n\nIn the workspace, switch agents from the session picker. To change the default, set `default_agent` in `openscience.json`. Only agents with mode `primary` or `all` can lead a session; `subagent` profiles are reachable only by delegation.\n\n## Create a custom agent\n\n`openscience agent create` walks you through it interactively. The non-interactive form:\n\n```bash\nopenscience agent create \\\n --path .openscience \\\n --description "Reviews analysis notebooks for data leakage and unsound statistics" \\\n --mode subagent \\\n --tools "read,grep,glob" \\\n --model anthropic/claude-sonnet-5\n```\n\n| Flag | Use |\n| --- | --- |\n| `--path ` | Where to write the definition; an `agent/` folder is created inside it. Omit to choose project or global interactively. |\n| `--description ` | What the agent is for. A model expands this into the name, system prompt, and delegation description. |\n| `--mode ` | `primary`: leads a session. `subagent`: only reachable by delegation. `all`: both. |\n| `--tools ` | Allow-list of tools. Anything left off the list is disabled. |\n| `--model ` | Default model for this agent. |\n\nThe result is a Markdown file: YAML frontmatter (description, mode, tools) plus a body that is the system prompt. Edit it like any prose file.\n\n## Tool restrictions\n\n`--tools "read,grep,glob"` produces a read-only agent that cannot be tricked into editing files. The available tools are `bash`, `read`, `write`, `edit`, `list`, `glob`, `grep`, `webfetch`, `task`, `todowrite`, and `todoread` — run `openscience agent create --help` for the list on your version.\n\n## Where agents live\n\n```text\n.openscience/agent/ # project-scoped\n~/.config/openscience/agent/ # user-global\n # ships with OpenScience\n```\n\nResolution is project-local, then user-global, then built-in.\n\n## What\'s next\n\n\n \n The 250+ skill library the agents draw on.\n \n \n Start, resume, and share sessions with any agent.\n \n \n Per-agent default models and provider routing.\n \n \n Full `agent create` and `agent list` reference.\n \n\n',wb=`--- -title: "Connect to Atlas" -description: "Optionally link OpenScience to the Atlas managed platform for wallet-billed frontier models, synced credentials, and research recorded into Atlas Graphs." -icon: "link" ---- - -OpenScience never requires an account: bring-your-own-key usage is free and never gated. Atlas is the managed platform, and connecting to it is optional. Atlas only meters the models it serves. - -## What connecting adds - -- **A managed model route.** Curated frontier models billed from a prepaid wallet, so you do not need per-provider keys. The default endpoint is \`app.syntheticsciences.ai\` (override with \`OPENSCIENCE_API_BASE\`). -- **Spend controls.** Managed/BYOK toggles for LLM and compute spend, per surface, in **Settings → Billing** on the dashboard. -- **Synced service credentials.** Store cloud and ML keys (Hugging Face, W&B, Modal, and others) once on your account and pull them to any machine. -- **Research graphs.** Record sessions and findings into Atlas Graphs, the durable research map. - -## Link your account - - - - \`\`\`bash - openscience login - \`\`\` - Opens your browser to approve the device. On headless or CI machines, pass \`--no-browser\` and paste a key, or run \`openscience login --key thk_...\` with a key created at \`app.syntheticsciences.ai/cli\`. - - - \`\`\`bash - openscience status - \`\`\` - Shows the connected user, this device, and how many service credentials synced. - - - \`\`\`bash - openscience sync - \`\`\` - Refreshes synced service credentials after you change them on the dashboard. - - - -## Model routing and billing - -Key routing is per-provider and automatic: if you set a BYOK key for a provider, OpenScience uses it; otherwise the request goes through the Atlas managed route and debits your wallet. - -\`\`\`bash -openscience wallet # wallet balance and current key routing -openscience wallet topup # opens the Plan tab — $50 or $200, one-time or recurring monthly -\`\`\` - -Flip managed versus BYOK per surface (LLM and compute) in **Settings → Billing**. BYOK works on every plan. - -## Where credentials live - -Connected, your account holds the canonical copy of every synced credential; the local \`~/.config/openscience/credentials.json\` is a mirror you can delete and re-pull with \`openscience sync\`. In BYOK mode, keys exist only on your machine — see [Security](/openscience/security) for the storage and subprocess rules. - -## Account commands - -| Command | Use | -| --- | --- | -| \`openscience login\` | Authenticate via browser; \`--key thk_...\` or \`--no-browser\` for headless machines. | -| \`openscience status\` | Connection, user, device, and synced credential count. | -| \`openscience sync\` | Re-pull synced service credentials from the dashboard. | -| \`openscience devices\` | List authenticated devices; revoke from the Devices tab on the dashboard. | -| \`openscience logout\` | Disconnect this machine. BYOK keeps working. | - -## What's next - - - - Run one continuous research session end to end. - - - Every subcommand, including connect and billing. - - - BYOK provider setup and model selection. - - - What leaves your machine in each mode. - - -`,Eb='---\ntitle: "Command reference"\ndescription: "Every openscience subcommand: workspace, run, sessions, models, agents, skills, and lifecycle."\nicon: "terminal"\n---\n\nThe OpenScience command surface. For live help, run `openscience --help` and `openscience --help`.\n\nThe bare `openscience` (no arguments) starts the local server and opens the **browser workspace** in your working directory. Everything else is grouped below.\n\n## Global flags\n\n| Flag | Use |\n| --- | --- |\n| `-v, --version` | Print the installed version. |\n| `-h, --help` | Print help for a command. |\n| `--print-logs` | Stream agent diagnostics to stderr. |\n| `--log-level ` | Override the log threshold. |\n\n## Workspace and runs\n\n| Command | Use |\n| --- | --- |\n| `openscience` | Start the server and open the browser workspace. |\n| `openscience run [message..]` | One-shot prompt in the terminal; streams, then exits. |\n| `openscience session list` | List recent sessions (`-n`, `--format`). |\n\n`run` flags: `-c/--continue`, `-s/--session `, `-m/--model `, `--variant ` (provider-specific reasoning effort), `--agent `, `--format `, `-f/--file `, `--attach ` (attach to a running server, e.g. `http://localhost:4096`), `--port`, `--title `.\n\n```bash\nopenscience run "Plot the attention entropy across layers for this checkpoint"\nopenscience run -c "Now sweep the temperature and re-plot"\nopenscience run --format json "Summarize the experiment in results/" > summary.json\n```\n\n## Account\n\nConnecting an Atlas account is optional — see [Atlas](/openscience/atlas). OpenScience runs fully standalone with your own provider keys.\n\n| Command | Use |\n| --- | --- |\n| `openscience init` | First-run setup wizard — choose managed models, your own keys, or skip. Rerun anytime (alias `onboard`). |\n| `openscience login` | Connect your Atlas account (browser flow, or `--key thk_…`). |\n| `openscience status` | Show the active account, synced services, subscription, and wallet (alias `whoami`). |\n| `openscience sync` | Re-pull synced credentials. |\n| `openscience devices` | List authorized devices. |\n| `openscience logout` | Disconnect this device from Atlas. |\n| `openscience doctor` | Report what\'s configured: account, provider keys, wallet, default model. |\n\n`openscience connect …` still works as an alias for `login` / `logout` / `status` / `sync` / `devices`.\n\n## Providers and routing\n\n| Command | Use |\n| --- | --- |\n| `openscience keys add` | Add a provider API key (BYOK; OAuth sign-in where supported). Alias: `auth`. |\n| `openscience keys signin` | Sign in with ChatGPT / Codex (subscription). |\n| `openscience keys list` | List saved provider keys and active env vars. |\n| `openscience keys rm` | Remove a saved provider key. |\n| `openscience models` | List configured providers and their models. |\n| `openscience wallet` | Wallet balance and key routing (alias `billing`). |\n| `openscience wallet topup` | Open the app to add credit. |\n\n## Agent profiles\n\n| Command | Use |\n| --- | --- |\n| `openscience agent list` | Print the agent roster on this install. |\n| `openscience agent create` | Scaffold a custom agent (system prompt + tool set + routing policy). |\n\nFlags: `--path`, `--description`, `--mode `, `--tools`, `--model`. See [Agents](/openscience/agents).\n\n## Skills\n\n| Command | Use |\n| --- | --- |\n| `openscience skill list` | Show installed skills (`--all` for the bundled set). |\n| `openscience skill show ` | Print a skill\'s metadata and entrypoints. |\n| `openscience skill add ` | Install a skill (URL or `gh:owner/repo`). |\n| `openscience skill new` / `edit` / `validate` | Author, edit, and check user skills. |\n| `openscience skill remove ` | Uninstall a skill. |\n| `openscience skill set-entries ` | Update which skills surface in the `/` picker. |\n\n## Local server and protocols\n\n| Command | Use |\n| --- | --- |\n| `openscience web` | Start the server and open the browser workspace (prints the URL, e.g. `http://localhost:4096`). |\n| `openscience serve` | Headless local server — loopback-only (`127.0.0.1`), no browser. |\n| `openscience acp` | Start an Agent Client Protocol server for editors like Zed. |\n| `openscience mcp` | Manage MCP servers (`list`, `add`, `remove`, `auth`). |\n\n`web` and `serve` take `--port` and `--cors`. See [Workspace](/openscience/workspace).\n\n## GitHub and PRs\n\n| Command | Use |\n| --- | --- |\n| `openscience github` | GitHub agent for CI/Actions (`install`, `run`). |\n| `openscience pr ` | Check out a PR branch, then launch the agent on it. |\n\n## Project and lifecycle\n\n| Command | Use |\n| --- | --- |\n| `openscience project` | Pin the Atlas project root for this folder (`merge`). |\n| `openscience export` / `openscience import` | Export or import a session as JSON. |\n| `openscience generate` | Emit the OpenAPI spec for the local server to stdout. |\n| `openscience stats` | Local token-usage and cost stats. |\n| `openscience debug` | Structured diagnostics for support (`debug paths` shows data/config dirs). |\n| `openscience upgrade` | Update to the latest version. |\n| `openscience uninstall` | Remove the binary and local config. |\n| `openscience completion` | Shell completion for bash, zsh, or fish. |\n\n```bash\nopenscience upgrade\nopenscience completion zsh > ~/.zsh/completions/_openscience\n```\n\n## What\'s next\n\n\n \n The browser workspace: files, editor, terminal, inline scientific rendering.\n \n \n Credential boundary, env hygiene, the trust boundary.\n \n \n 250+ research skills and the scientific databases.\n \n \n The research agent, the specialists, and custom profiles.\n \n\n',Ab=`--- -title: "OpenScience" -description: "The open-source AI workbench for scientific research. Give it a goal — it reads the literature, writes and runs code, runs the experiments, and writes up what it found." -icon: "flask-conical" ---- - - - -OpenScience is an AI workbench for scientific research. You give it a goal, and it works through the research loop the way a capable collaborator would: it reads the papers that matter, forms a hypothesis, writes and runs code, runs experiments on real compute, queries the major scientific databases, and writes up the result. - -It runs as a workspace in your browser, works with any frontier or open-weight model using your own API keys, and requires no account. It is model-agnostic, Apache-2.0 licensed, and built to do real work in machine learning, biology, physics, and chemistry. - -\`\`\`bash -npm install -g @synsci/openscience -openscience -\`\`\` - -That's the whole install. The command is \`openscience\`, and it opens the workspace in your browser. Prefer not to install globally? \`npx synsci\` does the same thing in one step. - -## What we built - -Everything below ships in the open-source CLI — no gated tiers, no server-side magic you can't read. - - - - Literature review, hypothesis, code, experiment, analysis, and write-up in one continuous session. Queue follow-up prompts while it streams; rewind with undo-from-here. - - - A \`research\` agent by default, plus \`biology\`, \`physics\`, and \`ml\` specialists — with critique and literature-review sub-agents and a read-only plan mode. - - - Training (DeepSpeed, PEFT, TRL), evaluation, dataset work, molecular and clinical biology, cheminformatics, papers and LaTeX, figures, and cloud compute. - - - UniProt, PDB, Ensembl, ChEMBL, PubChem, arXiv, OpenAlex, Semantic Scholar, and around 30 more, queryable directly by the agent. - - - A browser UI with a file tree, an editor, a terminal, session history, and inline rendering for molecules, structures, genomes, and plots. - - - LSP integration, MCP servers, plugins, custom agents and commands, and a TypeScript SDK. - - - -## Any model, your keys - -Set an API key from any provider and start working. Keys stay on your machine. - -\`\`\`bash -export ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY, GEMINI_API_KEY, ... -openscience -\`\`\` - -OpenScience routes to frontier and open-weight models from Anthropic, OpenAI, Google, and dozens of other providers. Reasoning-effort tiers are first-class: \`--variant high|max|minimal\` on any run. See [Models](/openscience/models). - -## Open source, all the way down - -The workbench is [Apache-2.0 on GitHub](https://github.com/synthetic-sciences/openscience). No account, no telemetry wall, no closed core: the agent loop, the skills, the database tools, and the workspace are all in the repo. Read it, fork it, extend it — and if it does real work for you, [star it](https://github.com/synthetic-sciences/openscience) so other researchers find it. - -- **Site** — [openscience.sh](https://openscience.sh) -- **Issues and ideas** — [github.com/synthetic-sciences/openscience/issues](https://github.com/synthetic-sciences/openscience/issues) -- **Releases and platform binaries** — [GitHub Releases](https://github.com/synthetic-sciences/openscience/releases) -- **Package** — [\`@synsci/openscience\` on npm](https://www.npmjs.com/package/@synsci/openscience) - -## Works with Atlas, never requires it - -Connect an Atlas account and OpenScience records its research into [Atlas graphs](/openscience/atlas) — durable hypotheses, runs, and decisions your whole team can audit — plus an optional managed model route with spend controls. Standalone mode keeps everything local. See [Connect to Atlas](/openscience/atlas). - -## Start here - - - - Install, set a key, and run your first research session in five minutes. - - - A tour of the browser workspace and the local server behind it. - - -`,Cb=`--- -title: "Models & providers" -description: "Bring your own keys for any provider, switch models per run, and optionally route through Atlas managed." -icon: "package-check" ---- - -OpenScience is model-agnostic. A **model** is one inference endpoint the agent routes through; a **provider** is a vendor that exposes models. Routing happens on your machine: with your own keys (the default), each call goes straight from your machine to the provider — no gateway in the middle, no account required. - -## Bring your own keys - -Set a key from any provider and start working: - -\`\`\`bash -export ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY, GEMINI_API_KEY, ... -openscience -\`\`\` - -Keys stay on your machine. Anthropic, OpenAI, Google, OpenRouter, Groq, Mistral, xAI, DeepSeek, and dozens more work the same way — the catalogue is models.dev-backed, so any \`provider/model\` id a provider currently exposes is addressable. You can also paste keys into the Credentials panel in the [workspace](/openscience/workspace) instead of exporting them, and OpenAI Codex signs in via OAuth. - -## List what is configured - -\`\`\`bash -openscience models # grouped by provider, with a routing label per provider -openscience models anthropic # one provider -\`\`\` - -Each provider is labeled \`your key\`, \`managed\`, \`Signed in with Codex\`, or \`unconfigured\`, so you always know which route a model takes. \`--verbose\` adds per-model metadata like costs, \`--refresh\` refetches the catalogue, and \`--flat\` prints one \`provider/model\` id per line for scripting. - -## Pick a model per run - -\`\`\`bash -openscience run --model anthropic/claude-opus-4-8 --variant xhigh "Design the ablation study" -openscience run --model anthropic/claude-sonnet-5 "Clean up the plotting code" -\`\`\` - -\`--model /\` overrides the default for one run; in the workspace, use the model selector per session. \`--variant\` picks a reasoning-effort tier — \`minimal\`, \`high\`, \`max\`, and so on. Available tiers depend on the model: Claude Opus 4.8 exposes the full range up to \`xhigh\` and \`max\`; other adaptive Claude models cap at \`max\`. - -## Atlas managed (optional) - -[Atlas](/openscience/atlas) is the managed platform. Connecting adds a curated set of frontier models billed from a prepaid wallet, so you can skip per-provider keys entirely: - -\`\`\`bash -openscience login # defaults to app.syntheticsciences.ai -\`\`\` - -Once connected, launching the workspace syncs your provider config and credentials, and Settings → Billing has independent managed/BYOK spend toggles for LLM calls and compute — mix routes freely, per concern. BYOK usage is always free and never gated; Atlas only meters the models it serves. - -| Path | What runs where | Billing | -| --- | --- | --- | -| BYOK (default) | Your machine calls the provider directly. | Your provider account. | -| Atlas managed | Your machine calls Atlas, which calls the provider. | Prepaid wallet. See [Connect to Atlas](/openscience/atlas). | - -## Key hygiene - -Provider keys and synced credentials are filtered out of the environment of every subprocess the agent spawns and redacted from output. The agent talks to providers itself; the code it runs never needs your keys. Credentials are stored per user in your home directory, never in the project's \`.openscience/\` directory, so they cannot end up in a commit. See [Security](/openscience/security). - -## What's next - - - - First key to first result. - - - Per-run model and variant flags in context. - - - What connecting to Atlas adds. - - - The env allow-list and trust boundary. - - -`,Tb=`--- -title: "Quickstart" -description: "Install OpenScience, set a provider key, and run your first research session." -icon: "rocket" ---- - -Five minutes from zero to a running research session. No account required. - - - - \`\`\`bash - npm install -g @synsci/openscience - \`\`\` - - Or without a global install, \`npx synsci\` runs the same workbench in one step. The install script (\`curl -fsSL https://openscience.sh/install | bash\`) drops a standalone \`openscience\` binary into \`~/.openscience/bin\`, and platform binaries are attached to every [GitHub release](https://github.com/synthetic-sciences/openscience/releases). - - - \`\`\`bash - export ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY, GEMINI_API_KEY, ... - \`\`\` - - Any provider works — bring your own key and it stays on your machine. \`openscience keys add\` gives you an interactive sign-in for providers that support it. See [Models](/openscience/models) for routing and reasoning-effort tiers. - - - \`\`\`bash - openscience - \`\`\` - - The command starts a local server and opens the browser workspace: file tree, editor, terminal, and session history, with inline rendering for molecules, structures, genomes, and plots. See [Workspace](/openscience/workspace). - - - Ask for the outcome, not the steps. The default \`research\` agent plans, reads, codes, runs, and reports: - - \`\`\`text - Reproduce the headline result of arXiv:2305.13245 on a small model - and tell me whether it holds at 125M parameters. - \`\`\` - - Prefer the terminal? One-shot runs work without the browser: - - \`\`\`bash - openscience run "Profile train.py and find the input-pipeline bottleneck" - \`\`\` - - - \`\`\`bash - openscience login - \`\`\` - - Connecting an [Atlas](/openscience/atlas) account records your research into durable graphs and unlocks a managed model route with spend controls. OpenScience never requires it. See [Connect to Atlas](/openscience/atlas). - - - -## Verify the install - -\`\`\`bash -openscience --version -openscience models # configured providers and models -openscience skill list # installed skills -\`\`\` - -## Where next - - - - The research agent, the biology / physics / ml specialists, and plan mode. - - - Every subcommand: runs, sessions, skills, server, lifecycle. - - -`,zb='---\ntitle: "Security"\ndescription: "The trust boundary, credential storage, and subprocess environment hygiene — in an open-source agent you can audit end to end."\nicon: "shield-check"\n---\n\nOpenScience is open source under Apache-2.0, so the whole security model below is auditable in the [repository](https://github.com/synthetic-sciences/openscience). The agent runs locally with the same filesystem and shell access you have. Two principles anchor the design: keep credentials out of subprocesses that do not need them, and make the trust boundary explicit instead of pretending the agent is a sandbox.\n\n## The agent is not a sandbox\n\nThe permission system prompts you before the agent runs a command or writes a file. That keeps you aware of what it is doing — it is not an isolation boundary. The agent can read, write, and execute anywhere your user can. For real isolation, run OpenScience inside a container or a VM.\n\n## Credential storage\n\n| Surface | Where | Notes |\n| --- | --- | --- |\n| BYOK provider keys | Environment variables or the local credential store | Never leave your machine; requests go straight to the provider. No account required. |\n| Atlas session | `~/.config/openscience/config.json` | `thk_*` key created by `openscience login`; revocable from the dashboard. |\n| Synced service credentials | `~/.config/openscience/credentials.json` | Present only when [connected to Atlas](/openscience/atlas); refresh with `openscience sync`. |\n| Native binary (curl install) | `~/.openscience/bin/openscience` | Added to PATH through your shell rc. |\n\nOverride the config parent directory with `XDG_CONFIG_HOME`.\n\n## Subprocess environment allow-list\n\nWhen the agent shells out, it rebuilds the subprocess environment from a curated allow-list rather than inheriting your full shell. Provider keys stay out of commands that have no business seeing them.\n\n**Always passed through:** `PATH`, `HOME`, `USER`, `SHELL`, `TERM`, `LANG`, `LC_*`, `TMPDIR`, `XDG_*`, `EDITOR`, `VISUAL`.\n\n**Passed through only when a subprocess needs them:** `HF_TOKEN`, `WANDB_API_KEY`, `MODAL_TOKEN_ID`, `MODAL_TOKEN_SECRET`, `LAMBDA_API_KEY`, `RUNPOD_API_KEY`, `PRIME_INTELLECT_API_KEY`, `TENSORPOOL_API_KEY`, `VAST_API_KEY`, `TINKER_API_KEY`, `LANGSMITH_API_KEY`, `PINECONE_API_KEY`, `TOGETHER_API_KEY`, `GROQ_API_KEY`, `FIREWORKS_API_KEY`, `OPENROUTER_API_KEY`.\n\n**Explicitly filtered out, even if set in your shell:** `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_GENERATIVE_AI_API_KEY`, `GEMINI_API_KEY`. The agent talks to model providers itself; subprocesses do not need these keys.\n\n## Output redaction\n\nKnown credential patterns are redacted from agent output before it lands in a transcript. If you spot an unredacted secret, report it (see below).\n\n## Server mode\n\nServer mode is opt-in. The server binds to localhost (127.0.0.1) only and enforces a Host and Origin allowlist to block DNS-rebinding and cross-origin requests. It is not built for remote exposure — if you tunnel or reverse-proxy it, securing that exposure is on you.\n\n## What leaves your machine\n\n- Prompts and responses sent to your model provider, governed by that provider\'s policy.\n- Nothing else, in BYOK mode. If you [connect to Atlas](/openscience/atlas), synced credentials and usage metering are held against your account.\n\nSource files stay local unless the agent explicitly uploads them through a tool you approve, and local environment variables outside the allow-list above are never forwarded.\n\n## Reporting a vulnerability\n\nReport security issues through the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/synthetic-sciences/openscience/security/advisories/new) form — not on public issue trackers. If you do not hear back within six business days, email security@syntheticsciences.ai.\n',_b='---\ntitle: "Sessions & one-shot runs"\ndescription: "One agent conversation: history, tool calls, and a working directory. Create, resume, attach, and export."\nicon: "layers"\n---\n\nA **session** is a single agent conversation: a thread of prompts and replies, the tool calls the agent made, and the working directory those calls ran against. Sessions are stored on disk, so everything below works offline and without an account.\n\n## Start a session\n\n| Entrypoint | Use |\n| --- | --- |\n| `openscience` | Open the [browser workspace](/openscience/workspace) in the current directory. |\n| `openscience run [message..]` | Send one prompt from the terminal, stream the result, and exit. Good for pipelines, CI, and git hooks. |\n| `openscience serve` | Start a headless server (no browser) for `run --attach` to target. |\n\nThe workspace is interactive; `openscience run` is the one to script. Each produces a session you can resume from either surface.\n\n## One-shot runs\n\n```bash\nopenscience run "Set up a DFT optimization for this structure" -f data/sample.cif\ngit diff | openscience run "Review this diff for race conditions"\n```\n\nPiped stdin is appended to the message. `openscience run` supports:\n\n| Flag | Use |\n| --- | --- |\n| `-c, --continue` | Continue the most recent session. |\n| `-s, --session ` | Continue a specific session by id. |\n| `-m, --model ` | Model for this run, e.g. `anthropic/claude-opus-4-8`. See [Models](/openscience/models). |\n| `--variant ` | Reasoning-effort tier (`high`, `max`, `minimal`; model-dependent). |\n| `--agent ` | Run a specific primary agent. See [Agents](/openscience/agents). |\n| `--command ` | Run a custom command, with the message as its arguments. |\n| `-f, --file ` | Attach a local file to the message (repeatable). |\n| `--title ` | Title for the session in `session list`. |\n| `--format ` | `json` emits one JSON event per line (`tool_use`, `step_start`, `step_finish`, `text`, `error`), tagged with the session id — pipe it to a file and parse line by line. |\n| `--attach ` | Send the prompt to a running server instead of starting one. |\n| `--port ` | Port for the throwaway local server (random by default). |\n\nTo pick up a file, always use `-f`; `--attach` targets a server, it does not fetch documents.\n\n## Continue or resume\n\n```bash\nopenscience session list # recent sessions (-n 10 to cap, --format json)\nopenscience run -c "Now add tests for the refactored module"\nopenscience run -s ses_8f2ka91xk "Rerun the sweep with lr=3e-4"\n```\n\n`session list` prints a table of ids, titles, and update times (paged through `less` in a terminal). In the workspace, the same sessions appear in the session history pane, where you can also queue prompts and undo from any message.\n\n## Attach to a running server\n\nLeave `openscience serve` (or the workspace) running and drive it from another terminal:\n\n```bash\nopenscience run --attach http://localhost:4096 "Summarize today\'s results" # new session on the server\nopenscience run --attach http://localhost:4096 -s ses_8f2ka91xk "Continue" # a specific session (-c: latest)\n```\n\nThe run shares the server\'s sessions, so work started in the browser continues from the terminal and vice versa.\n\n## Export and import\n\n```bash\nopenscience export ses_8f2ka91xk > session.json # no id: interactive picker\nopenscience import session.json\n```\n\n`export` writes the full session as JSON to stdout; `import` loads it on another machine. Useful for sharing reproductions or attaching a transcript to an issue.\n\n## What\'s next\n\n\n \n The browser surface for the same sessions.\n \n \n Per-run and per-session model choice.\n \n \n Primary agents, specialists, and plan mode.\n \n \n The full command reference.\n \n\n',Ob=`--- -title: "Skills" -description: "250+ bundled research skills, direct access to around 30 scientific databases, and commands for installing, writing, and pinning skills." -icon: "book-open" ---- - -A **skill** is a portable instruction bundle the agent loads into a session to prime it for a domain. OpenScience ships more than 250 of them, spanning the surface a working scientist actually hits. - -## Built-in categories - -| Category | Covers | -| --- | --- | -| Training | DeepSpeed, PEFT, TRL, distributed and parameter-efficient fine-tuning. | -| Evaluation | Harnesses, benchmarks, regression suites. | -| Datasets | Acquisition, cleaning, splits, augmentation. | -| Molecular & clinical biology | Sequence analysis, structures, omics, clinical data workflows. | -| Cheminformatics | Molecule handling, descriptors, property prediction. | -| Papers & LaTeX | Manuscript drafting, citations, submission-ready LaTeX. | -| Figures | Publication-quality plots and layouts. | -| Cloud compute | Modal, Tinker, and other GPU backends. | - -Run \`openscience skill list --all\` to print the full bundled set, grouped by category. - -## Scientific databases - -The major scientific databases are wired in as tools, not skills: UniProt, PDB, Ensembl, ChEMBL, PubChem, arXiv, OpenAlex, Semantic Scholar, and around 30 in total. The agent queries them directly during a session — no API keys, no manual downloads — and the specialist agents lean on them heavily (see [Agents](/openscience/agents)). - -## Skill commands - -| Command | Use | -| --- | --- | -| \`openscience skill list\` | Show learned and installed skills; \`--all\` includes the bundled set. | -| \`openscience skill show [/]\` | Namespace summary, or a single skill's full \`SKILL.md\`. | -| \`openscience skill add \` | Install every skill from a public git repo; \`gh:owner/repo\` shorthand works. Runs a safety review. | -| \`openscience skill new \` | Scaffold a local user skill. | -| \`openscience skill edit \` | Open a user skill in \`$EDITOR\`. | -| \`openscience skill validate \` | Check a skill's frontmatter and safety (\`--strict\` fails on warnings). | -| \`openscience skill set-entries \` | Choose which skills in a namespace surface in the \`/\` picker. | -| \`openscience skill remove \` | Uninstall a skill or a whole namespace. | - -## Install third-party skills - -\`\`\`bash -openscience skill add gh:anthropics/superpowers -openscience skill list -\`\`\` - -Each skill in the repo passes a static safety check and an LLM safety review before it installs; rejected skills are skipped and reported. Installed skills are namespaced by repo, so \`remove \` uninstalls the whole set. - -## Write your own - -\`\`\`bash -openscience skill new leakage-checks --description "Checklists for spotting data leakage" --editor -\`\`\` - -A skill is a \`SKILL.md\` file with \`name\`, \`description\`, and \`category\` frontmatter and an instruction body. Iterate with \`openscience skill edit\` and check it with \`openscience skill validate\`. - -## Pin skills to a project - -Point the project config at extra skill folders with \`skills.paths\` in \`openscience.json\` at the project root: - -\`\`\`json -{ - "skills": { "paths": ["./skills", "../shared-skills"] } -} -\`\`\` - -Sessions started in that directory load those skills, so teammates and CI get the same primed agent. Existing \`.claude/skills/\` directories are picked up unchanged. - -## What's next - - - - Pair skills with custom agent profiles to build specialists. - - - Full \`skill\` subcommand reference. - - - How the safety review and the subprocess allow-list protect you. - - - Sync the cloud and ML credentials a skill needs. - - -`,Mb=`--- -title: "Workspace" -description: "The bare openscience command opens the browser workspace: files, editor, terminal, sessions, and inline scientific rendering." -icon: "app-window" ---- - -The workspace is how you use OpenScience. Run the bare command and it starts a local server, opens your browser, and gives you the full research surface: a file tree, an editor, a terminal, session history, and inline rendering for molecules, structures, genomes, and plots. - -\`\`\`bash -openscience # open the workspace in the current directory -openscience ~/code/my-project # open it in a specific project -\`\`\` - -The server is local by design: it binds \`127.0.0.1\`, holds the agent in its own process, and serves the UI from the same port (\`http://localhost:4096\` by default; if 4096 is taken it falls back to a free port and prints the URL). Closing the browser tab does not kill the agent — reopen the URL to pick up where you left off. - -| Flag | Use | -| --- | --- | -| \`--port \` | Bind a specific local port instead of the default 4096. | -| \`--cors \` | Allow an extra CORS origin (repeatable). | - -The server always binds localhost — the deprecated \`server.hostname\` / \`server.mdns\` config keys are parsed but ignored. Persistent settings live in \`~/.config/openscience/openscience.json\` under \`server.port\` and \`server.cors\`. - -## What the workspace gives you - -- **File tree and editor.** Browse and edit the project the agent is working in; edits and diffs render inline. -- **Terminal.** A real shell in the project directory, alongside the agent's own tool calls. -- **Session history.** Every conversation is a session; switch between them without losing context. -- **Inline scientific rendering.** Molecules, protein structures, genomes, and plots render directly in the transcript instead of as file paths. -- **Prompt queueing.** Type your next prompt while the agent is still streaming; queued prompts run in order. -- **Undo from here.** Roll the session back to any message and take a different path from that point. -- **Model selector and credentials panel.** Pick any configured model per session and add provider keys without leaving the browser. See [Models](/openscience/models). - -## Headless server - -\`openscience serve\` starts the same server without opening a browser — useful for keeping a long-lived agent running that other terminals attach to. It takes the same \`--port\` and \`--cors\` flags and is loopback-only (\`127.0.0.1\`). - -\`\`\`bash -openscience serve --port 4096 -openscience run --attach http://localhost:4096 "Continue the ablation sweep" -\`\`\` - -There is no separate attach subcommand: \`openscience run --attach \` sends a one-shot prompt to the running server. See [Sessions](/openscience/sessions). - - -The workspace is intended for local use on a machine you control. The server binds \`127.0.0.1\` only and has no remote-access mode; do not reverse-proxy it to the public internet. - - -## macOS Full Disk Access - -On macOS, launching the workspace probes whether the binary can read \`~/Desktop\`. Without Full Disk Access, macOS silently returns empty listings for \`~/Desktop\`, \`~/Documents\`, and \`~/Downloads\`, so the folder picker and file tree look empty. If the probe fails, OpenScience opens System Settings on the Privacy & Security pane and prints the binary path to add: - -1. In **Full Disk Access**, click **+**, press ⌘⇧G, and paste the printed path. -2. Toggle the \`openscience\` entry on. -3. Quit (Ctrl+C) and relaunch \`openscience\`. - -## What's next - - - - Resume, attach, export, and one-shot runs. - - - BYOK providers and per-session model switching. - - - The research agent and its specialists. - - - The trust boundary and credential handling. - - -`,Db="https://mintlify.com/docs.json",Rb="OpenScience",Nb={tabs:[{tab:"Guides",groups:[{group:"Start",pages:["index","quickstart","workspace"]},{group:"Use the agent",pages:["agents","models","skills","sessions","atlas"]}]},{tab:"Reference",groups:[{group:"CLI",pages:["commands","security"]}]}],global:{anchors:[{anchor:"openscience.sh",href:"https://openscience.sh"},{anchor:"GitHub",href:"https://github.com/synthetic-sciences/openscience"},{anchor:"npm",href:"https://www.npmjs.com/package/@synsci/openscience"},{anchor:"Releases",href:"https://github.com/synthetic-sciences/openscience/releases"}]}},jb={primary:{type:"button",label:"Star on GitHub",href:"https://github.com/synthetic-sciences/openscience"}},Lb={$schema:Db,name:Rb,navigation:Nb,navbar:jb};function Ub(n,r){const a={};return(n[n.length-1]===""?[...n,""]:n).join((a.padRight?" ":"")+","+(a.padLeft===!1?"":" ")).trim()}const Bb=/^[$_\p{ID_Start}][$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,Hb=/^[$_\p{ID_Start}][-$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,qb={};function Fp(n,r){return(qb.jsx?Hb:Bb).test(n)}const Yb=/[ \t\n\f\r]/g;function Gb(n){return typeof n=="object"?n.type==="text"?Ip(n.value):!1:Ip(n)}function Ip(n){return n.replace(Yb,"")===""}class Ra{constructor(r,a,u){this.normal=a,this.property=r,u&&(this.space=u)}}Ra.prototype.normal={};Ra.prototype.property={};Ra.prototype.space=void 0;function Vm(n,r){const a={},u={};for(const c of n)Object.assign(a,c.property),Object.assign(u,c.normal);return new Ra(a,u,r)}function ac(n){return n.toLowerCase()}class Ot{constructor(r,a){this.attribute=a,this.property=r}}Ot.prototype.attribute="";Ot.prototype.booleanish=!1;Ot.prototype.boolean=!1;Ot.prototype.commaOrSpaceSeparated=!1;Ot.prototype.commaSeparated=!1;Ot.prototype.defined=!1;Ot.prototype.mustUseProperty=!1;Ot.prototype.number=!1;Ot.prototype.overloadedBoolean=!1;Ot.prototype.property="";Ot.prototype.spaceSeparated=!1;Ot.prototype.space=void 0;let Vb=0;const ge=Ol(),lt=Ol(),rc=Ol(),I=Ol(),Ge=Ol(),zl=Ol(),Ht=Ol();function Ol(){return 2**++Vb}const uc=Object.freeze(Object.defineProperty({__proto__:null,boolean:ge,booleanish:lt,commaOrSpaceSeparated:Ht,commaSeparated:zl,number:I,overloadedBoolean:rc,spaceSeparated:Ge},Symbol.toStringTag,{value:"Module"})),Ys=Object.keys(uc);class vc extends Ot{constructor(r,a,u,c){let h=-1;if(super(r,a),Jp(this,"space",c),typeof u=="number")for(;++h4&&a.slice(0,4)==="data"&&Fb.test(r)){if(r.charAt(4)==="-"){const h=r.slice(5).replace($p,$b);u="data"+h.charAt(0).toUpperCase()+h.slice(1)}else{const h=r.slice(4);if(!$p.test(h)){let f=h.replace(Zb,Jb);f.charAt(0)!=="-"&&(f="-"+f),r="data"+f}}c=vc}return new c(u,r)}function Jb(n){return"-"+n.toLowerCase()}function $b(n){return n.charAt(1).toUpperCase()}const Wb=Vm([Xm,Xb,Zm,Fm,Im],"html"),xc=Vm([Xm,Qb,Zm,Fm,Im],"svg");function Pb(n){return n.join(" ").trim()}var vi={},Gs,Wp;function e0(){if(Wp)return Gs;Wp=1;var n=/\/\*[^*]*\*+([^/*][^*]*\*+)*\//g,r=/\n/g,a=/^\s*/,u=/^(\*?[-#/*\\\w]+(\[[0-9a-z_-]+\])?)\s*/,c=/^:\s*/,h=/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^)]*?\)|[^};])+)/,f=/^[;\s]*/,p=/^\s+|\s+$/g,m=` -`,d="/",b="*",y="",S="comment",x="declaration";function T(K,D){if(typeof K!="string")throw new TypeError("First argument must be a string");if(!K)return[];D=D||{};var F=1,Q=1;function ue(J){var $=J.match(r);$&&(F+=$.length);var _=J.lastIndexOf(m);Q=~_?J.length-_:Q+J.length}function re(){var J={line:F,column:Q};return function($){return $.position=new L(J),me(),$}}function L(J){this.start=J,this.end={line:F,column:Q},this.source=D.source}L.prototype.content=K;function P(J){var $=new Error(D.source+":"+F+":"+Q+": "+J);if($.reason=J,$.filename=D.source,$.line=F,$.column=Q,$.source=K,!D.silent)throw $}function de(J){var $=J.exec(K);if($){var _=$[0];return ue(_),K=K.slice(_.length),$}}function me(){de(a)}function N(J){var $;for(J=J||[];$=te();)$!==!1&&J.push($);return J}function te(){var J=re();if(!(d!=K.charAt(0)||b!=K.charAt(1))){for(var $=2;y!=K.charAt($)&&(b!=K.charAt($)||d!=K.charAt($+1));)++$;if($+=2,y===K.charAt($-1))return P("End of comment missing");var _=K.slice(2,$-2);return Q+=2,ue(_),K=K.slice($),Q+=2,J({type:S,comment:_})}}function B(){var J=re(),$=de(u);if($){if(te(),!de(c))return P("property missing ':'");var _=de(h),Z=J({type:x,property:U($[0].replace(n,y)),value:_?U(_[0].replace(n,y)):y});return de(f),Z}}function le(){var J=[];N(J);for(var $;$=B();)$!==!1&&(J.push($),N(J));return J}return me(),le()}function U(K){return K?K.replace(p,y):y}return Gs=T,Gs}var Pp;function t0(){if(Pp)return vi;Pp=1;var n=vi&&vi.__importDefault||function(u){return u&&u.__esModule?u:{default:u}};Object.defineProperty(vi,"__esModule",{value:!0}),vi.default=a;const r=n(e0());function a(u,c){let h=null;if(!u||typeof u!="string")return h;const f=(0,r.default)(u),p=typeof c=="function";return f.forEach(m=>{if(m.type!=="declaration")return;const{property:d,value:b}=m;p?c(d,b,m):b&&(h=h||{},h[d]=b)}),h}return vi}var Sa={},em;function n0(){if(em)return Sa;em=1,Object.defineProperty(Sa,"__esModule",{value:!0}),Sa.camelCase=void 0;var n=/^--[a-zA-Z0-9_-]+$/,r=/-([a-z])/g,a=/^[^-]+$/,u=/^-(webkit|moz|ms|o|khtml)-/,c=/^-(ms)-/,h=function(d){return!d||a.test(d)||n.test(d)},f=function(d,b){return b.toUpperCase()},p=function(d,b){return"".concat(b,"-")},m=function(d,b){return b===void 0&&(b={}),h(d)?d:(d=d.toLowerCase(),b.reactCompat?d=d.replace(c,p):d=d.replace(u,p),d.replace(r,f))};return Sa.camelCase=m,Sa}var ka,tm;function l0(){if(tm)return ka;tm=1;var n=ka&&ka.__importDefault||function(c){return c&&c.__esModule?c:{default:c}},r=n(t0()),a=n0();function u(c,h){var f={};return!c||typeof c!="string"||(0,r.default)(c,function(p,m){p&&m&&(f[(0,a.camelCase)(p,h)]=m)}),f}return u.default=u,ka=u,ka}var i0=l0();const a0=yc(i0),Jm=$m("end"),Sc=$m("start");function $m(n){return r;function r(a){const u=a&&a.position&&a.position[n]||{};if(typeof u.line=="number"&&u.line>0&&typeof u.column=="number"&&u.column>0)return{line:u.line,column:u.column,offset:typeof u.offset=="number"&&u.offset>-1?u.offset:void 0}}}function r0(n){const r=Sc(n),a=Jm(n);if(r&&a)return{start:r,end:a}}function Aa(n){return!n||typeof n!="object"?"":"position"in n||"type"in n?nm(n.position):"start"in n||"end"in n?nm(n):"line"in n||"column"in n?oc(n):""}function oc(n){return lm(n&&n.line)+":"+lm(n&&n.column)}function nm(n){return oc(n&&n.start)+"-"+oc(n&&n.end)}function lm(n){return n&&typeof n=="number"?n:1}class gt extends Error{constructor(r,a,u){super(),typeof a=="string"&&(u=a,a=void 0);let c="",h={},f=!1;if(a&&("line"in a&&"column"in a?h={place:a}:"start"in a&&"end"in a?h={place:a}:"type"in a?h={ancestors:[a],place:a.position}:h={...a}),typeof r=="string"?c=r:!h.cause&&r&&(f=!0,c=r.message,h.cause=r),!h.ruleId&&!h.source&&typeof u=="string"){const m=u.indexOf(":");m===-1?h.ruleId=u:(h.source=u.slice(0,m),h.ruleId=u.slice(m+1))}if(!h.place&&h.ancestors&&h.ancestors){const m=h.ancestors[h.ancestors.length-1];m&&(h.place=m.position)}const p=h.place&&"start"in h.place?h.place.start:h.place;this.ancestors=h.ancestors||void 0,this.cause=h.cause||void 0,this.column=p?p.column:void 0,this.fatal=void 0,this.file="",this.message=c,this.line=p?p.line:void 0,this.name=Aa(h.place)||"1:1",this.place=h.place||void 0,this.reason=this.message,this.ruleId=h.ruleId||void 0,this.source=h.source||void 0,this.stack=f&&h.cause&&typeof h.cause.stack=="string"?h.cause.stack:"",this.actual=void 0,this.expected=void 0,this.note=void 0,this.url=void 0}}gt.prototype.file="";gt.prototype.name="";gt.prototype.reason="";gt.prototype.message="";gt.prototype.stack="";gt.prototype.column=void 0;gt.prototype.line=void 0;gt.prototype.ancestors=void 0;gt.prototype.cause=void 0;gt.prototype.fatal=void 0;gt.prototype.place=void 0;gt.prototype.ruleId=void 0;gt.prototype.source=void 0;const kc={}.hasOwnProperty,u0=new Map,o0=/[A-Z]/g,s0=new Set(["table","tbody","thead","tfoot","tr"]),c0=new Set(["td","th"]),Wm="https://github.com/syntax-tree/hast-util-to-jsx-runtime";function f0(n,r){if(!r||r.Fragment===void 0)throw new TypeError("Expected `Fragment` in options");const a=r.filePath||void 0;let u;if(r.development){if(typeof r.jsxDEV!="function")throw new TypeError("Expected `jsxDEV` in options when `development: true`");u=v0(a,r.jsxDEV)}else{if(typeof r.jsx!="function")throw new TypeError("Expected `jsx` in production options");if(typeof r.jsxs!="function")throw new TypeError("Expected `jsxs` in production options");u=b0(a,r.jsx,r.jsxs)}const c={Fragment:r.Fragment,ancestors:[],components:r.components||{},create:u,elementAttributeNameCase:r.elementAttributeNameCase||"react",evaluater:r.createEvaluater?r.createEvaluater():void 0,filePath:a,ignoreInvalidStyle:r.ignoreInvalidStyle||!1,passKeys:r.passKeys!==!1,passNode:r.passNode||!1,schema:r.space==="svg"?xc:Wb,stylePropertyNameCase:r.stylePropertyNameCase||"dom",tableCellAlignToStyle:r.tableCellAlignToStyle!==!1},h=Pm(c,n,void 0);return h&&typeof h!="string"?h:c.create(n,c.Fragment,{children:h||void 0},void 0)}function Pm(n,r,a){if(r.type==="element")return h0(n,r,a);if(r.type==="mdxFlowExpression"||r.type==="mdxTextExpression")return d0(n,r);if(r.type==="mdxJsxFlowElement"||r.type==="mdxJsxTextElement")return m0(n,r,a);if(r.type==="mdxjsEsm")return p0(n,r);if(r.type==="root")return g0(n,r,a);if(r.type==="text")return y0(n,r)}function h0(n,r,a){const u=n.schema;let c=u;r.tagName.toLowerCase()==="svg"&&u.space==="html"&&(c=xc,n.schema=c),n.ancestors.push(r);const h=tg(n,r.tagName,!1),f=x0(n,r);let p=Ec(n,r);return s0.has(r.tagName)&&(p=p.filter(function(m){return typeof m=="string"?!Gb(m):!0})),eg(n,f,h,r),wc(f,p),n.ancestors.pop(),n.schema=u,n.create(r,h,f,a)}function d0(n,r){if(r.data&&r.data.estree&&n.evaluater){const u=r.data.estree.body[0];return u.type,n.evaluater.evaluateExpression(u.expression)}Ma(n,r.position)}function p0(n,r){if(r.data&&r.data.estree&&n.evaluater)return n.evaluater.evaluateProgram(r.data.estree);Ma(n,r.position)}function m0(n,r,a){const u=n.schema;let c=u;r.name==="svg"&&u.space==="html"&&(c=xc,n.schema=c),n.ancestors.push(r);const h=r.name===null?n.Fragment:tg(n,r.name,!0),f=S0(n,r),p=Ec(n,r);return eg(n,f,h,r),wc(f,p),n.ancestors.pop(),n.schema=u,n.create(r,h,f,a)}function g0(n,r,a){const u={};return wc(u,Ec(n,r)),n.create(r,n.Fragment,u,a)}function y0(n,r){return r.value}function eg(n,r,a,u){typeof a!="string"&&a!==n.Fragment&&n.passNode&&(r.node=u)}function wc(n,r){if(r.length>0){const a=r.length>1?r:r[0];a&&(n.children=a)}}function b0(n,r,a){return u;function u(c,h,f,p){const d=Array.isArray(f.children)?a:r;return p?d(h,f,p):d(h,f)}}function v0(n,r){return a;function a(u,c,h,f){const p=Array.isArray(h.children),m=Sc(u);return r(c,h,f,p,{columnNumber:m?m.column-1:void 0,fileName:n,lineNumber:m?m.line:void 0},void 0)}}function x0(n,r){const a={};let u,c;for(c in r.properties)if(c!=="children"&&kc.call(r.properties,c)){const h=k0(n,c,r.properties[c]);if(h){const[f,p]=h;n.tableCellAlignToStyle&&f==="align"&&typeof p=="string"&&c0.has(r.tagName)?u=p:a[f]=p}}if(u){const h=a.style||(a.style={});h[n.stylePropertyNameCase==="css"?"text-align":"textAlign"]=u}return a}function S0(n,r){const a={};for(const u of r.attributes)if(u.type==="mdxJsxExpressionAttribute")if(u.data&&u.data.estree&&n.evaluater){const h=u.data.estree.body[0];h.type;const f=h.expression;f.type;const p=f.properties[0];p.type,Object.assign(a,n.evaluater.evaluateExpression(p.argument))}else Ma(n,r.position);else{const c=u.name;let h;if(u.value&&typeof u.value=="object")if(u.value.data&&u.value.data.estree&&n.evaluater){const p=u.value.data.estree.body[0];p.type,h=n.evaluater.evaluateExpression(p.expression)}else Ma(n,r.position);else h=u.value===null?!0:u.value;a[c]=h}return a}function Ec(n,r){const a=[];let u=-1;const c=n.passKeys?new Map:u0;for(;++uc?0:c+r:r=r>c?c:r,a=a>0?a:0,u.length<1e4)f=Array.from(u),f.unshift(r,a),n.splice(...f);else for(a&&n.splice(r,a);h0?(qt(n,n.length,0,r),n):r}const rm={}.hasOwnProperty;function lg(n){const r={};let a=-1;for(;++a13&&a<32||a>126&&a<160||a>55295&&a<57344||a>64975&&a<65008||(a&65535)===65535||(a&65535)===65534||a>1114111?"�":String.fromCodePoint(a)}function un(n){return n.replace(/[\t\n\r ]+/g," ").replace(/^ | $/g,"").toLowerCase().toUpperCase()}const xt=ol(/[A-Za-z]/),mt=ol(/[\dA-Za-z]/),M0=ol(/[#-'*+\--9=?A-Z^-~]/);function ou(n){return n!==null&&(n<32||n===127)}const sc=ol(/\d/),D0=ol(/[\dA-Fa-f]/),R0=ol(/[!-/:-@[-`{-~]/);function ce(n){return n!==null&&n<-2}function Ve(n){return n!==null&&(n<0||n===32)}function Ee(n){return n===-2||n===-1||n===32}const pu=ol(new RegExp("\\p{P}|\\p{S}","u")),_l=ol(/\s/);function ol(n){return r;function r(a){return a!==null&&a>-1&&n.test(String.fromCharCode(a))}}function Ai(n){const r=[];let a=-1,u=0,c=0;for(;++a55295&&h<57344){const p=n.charCodeAt(a+1);h<56320&&p>56319&&p<57344?(f=String.fromCharCode(h,p),c=1):f="�"}else f=String.fromCharCode(h);f&&(r.push(n.slice(u,a),encodeURIComponent(f)),u=a+c+1,f=""),c&&(a+=c,c=0)}return r.join("")+n.slice(u)}function _e(n,r,a,u){const c=u?u-1:Number.POSITIVE_INFINITY;let h=0;return f;function f(m){return Ee(m)?(n.enter(a),p(m)):r(m)}function p(m){return Ee(m)&&h++f))return;const P=r.events.length;let de=P,me,N;for(;de--;)if(r.events[de][0]==="exit"&&r.events[de][1].type==="chunkFlow"){if(me){N=r.events[de][1].end;break}me=!0}for(D(u),L=P;LQ;){const re=a[ue];r.containerState=re[1],re[0].exit.call(r,n)}a.length=Q}function F(){c.write([null]),h=void 0,c=void 0,r.containerState._closeFlow=void 0}}function B0(n,r,a){return _e(n,n.attempt(this.parser.constructs.document,r,a),"linePrefix",this.parser.constructs.disable.null.includes("codeIndented")?void 0:4)}function wi(n){if(n===null||Ve(n)||_l(n))return 1;if(pu(n))return 2}function mu(n,r,a){const u=[];let c=-1;for(;++c1&&n[a][1].end.offset-n[a][1].start.offset>1?2:1;const y={...n[u][1].end},S={...n[a][1].start};om(y,-m),om(S,m),f={type:m>1?"strongSequence":"emphasisSequence",start:y,end:{...n[u][1].end}},p={type:m>1?"strongSequence":"emphasisSequence",start:{...n[a][1].start},end:S},h={type:m>1?"strongText":"emphasisText",start:{...n[u][1].end},end:{...n[a][1].start}},c={type:m>1?"strong":"emphasis",start:{...f.start},end:{...p.end}},n[u][1].end={...f.start},n[a][1].start={...p.end},d=[],n[u][1].end.offset-n[u][1].start.offset&&(d=Pt(d,[["enter",n[u][1],r],["exit",n[u][1],r]])),d=Pt(d,[["enter",c,r],["enter",f,r],["exit",f,r],["enter",h,r]]),d=Pt(d,mu(r.parser.constructs.insideSpan.null,n.slice(u+1,a),r)),d=Pt(d,[["exit",h,r],["enter",p,r],["exit",p,r],["exit",c,r]]),n[a][1].end.offset-n[a][1].start.offset?(b=2,d=Pt(d,[["enter",n[a][1],r],["exit",n[a][1],r]])):b=0,qt(n,u-1,a-u+3,d),a=u+d.length-b-2;break}}for(a=-1;++a0&&Ee(L)?_e(n,F,"linePrefix",h+1)(L):F(L)}function F(L){return L===null||ce(L)?n.check(sm,U,ue)(L):(n.enter("codeFlowValue"),Q(L))}function Q(L){return L===null||ce(L)?(n.exit("codeFlowValue"),F(L)):(n.consume(L),Q)}function ue(L){return n.exit("codeFenced"),r(L)}function re(L,P,de){let me=0;return N;function N($){return L.enter("lineEnding"),L.consume($),L.exit("lineEnding"),te}function te($){return L.enter("codeFencedFence"),Ee($)?_e(L,B,"linePrefix",u.parser.constructs.disable.null.includes("codeIndented")?void 0:4)($):B($)}function B($){return $===p?(L.enter("codeFencedFenceSequence"),le($)):de($)}function le($){return $===p?(me++,L.consume($),le):me>=f?(L.exit("codeFencedFenceSequence"),Ee($)?_e(L,J,"whitespace")($):J($)):de($)}function J($){return $===null||ce($)?(L.exit("codeFencedFence"),P($)):de($)}}}function J0(n,r,a){const u=this;return c;function c(f){return f===null?a(f):(n.enter("lineEnding"),n.consume(f),n.exit("lineEnding"),h)}function h(f){return u.parser.lazy[u.now().line]?a(f):r(f)}}const Xs={name:"codeIndented",tokenize:W0},$0={partial:!0,tokenize:P0};function W0(n,r,a){const u=this;return c;function c(d){return n.enter("codeIndented"),_e(n,h,"linePrefix",5)(d)}function h(d){const b=u.events[u.events.length-1];return b&&b[1].type==="linePrefix"&&b[2].sliceSerialize(b[1],!0).length>=4?f(d):a(d)}function f(d){return d===null?m(d):ce(d)?n.attempt($0,f,m)(d):(n.enter("codeFlowValue"),p(d))}function p(d){return d===null||ce(d)?(n.exit("codeFlowValue"),f(d)):(n.consume(d),p)}function m(d){return n.exit("codeIndented"),r(d)}}function P0(n,r,a){const u=this;return c;function c(f){return u.parser.lazy[u.now().line]?a(f):ce(f)?(n.enter("lineEnding"),n.consume(f),n.exit("lineEnding"),c):_e(n,h,"linePrefix",5)(f)}function h(f){const p=u.events[u.events.length-1];return p&&p[1].type==="linePrefix"&&p[2].sliceSerialize(p[1],!0).length>=4?r(f):ce(f)?c(f):a(f)}}const ev={name:"codeText",previous:nv,resolve:tv,tokenize:lv};function tv(n){let r=n.length-4,a=3,u,c;if((n[a][1].type==="lineEnding"||n[a][1].type==="space")&&(n[r][1].type==="lineEnding"||n[r][1].type==="space")){for(u=a;++u=this.left.length+this.right.length)throw new RangeError("Cannot access index `"+r+"` in a splice buffer of size `"+(this.left.length+this.right.length)+"`");return rthis.left.length?this.right.slice(this.right.length-u+this.left.length,this.right.length-r+this.left.length).reverse():this.left.slice(r).concat(this.right.slice(this.right.length-u+this.left.length).reverse())}splice(r,a,u){const c=a||0;this.setCursor(Math.trunc(r));const h=this.right.splice(this.right.length-c,Number.POSITIVE_INFINITY);return u&&wa(this.left,u),h.reverse()}pop(){return this.setCursor(Number.POSITIVE_INFINITY),this.left.pop()}push(r){this.setCursor(Number.POSITIVE_INFINITY),this.left.push(r)}pushMany(r){this.setCursor(Number.POSITIVE_INFINITY),wa(this.left,r)}unshift(r){this.setCursor(0),this.right.push(r)}unshiftMany(r){this.setCursor(0),wa(this.right,r.reverse())}setCursor(r){if(!(r===this.left.length||r>this.left.length&&this.right.length===0||r<0&&this.left.length===0))if(r=4?r(f):n.interrupt(u.parser.constructs.flow,a,r)(f)}}function sg(n,r,a,u,c,h,f,p,m){const d=m||Number.POSITIVE_INFINITY;let b=0;return y;function y(D){return D===60?(n.enter(u),n.enter(c),n.enter(h),n.consume(D),n.exit(h),S):D===null||D===32||D===41||ou(D)?a(D):(n.enter(u),n.enter(f),n.enter(p),n.enter("chunkString",{contentType:"string"}),U(D))}function S(D){return D===62?(n.enter(h),n.consume(D),n.exit(h),n.exit(c),n.exit(u),r):(n.enter(p),n.enter("chunkString",{contentType:"string"}),x(D))}function x(D){return D===62?(n.exit("chunkString"),n.exit(p),S(D)):D===null||D===60||ce(D)?a(D):(n.consume(D),D===92?T:x)}function T(D){return D===60||D===62||D===92?(n.consume(D),x):x(D)}function U(D){return!b&&(D===null||D===41||Ve(D))?(n.exit("chunkString"),n.exit(p),n.exit(f),n.exit(u),r(D)):b999||x===null||x===91||x===93&&!m||x===94&&!p&&"_hiddenFootnoteSupport"in f.parser.constructs?a(x):x===93?(n.exit(h),n.enter(c),n.consume(x),n.exit(c),n.exit(u),r):ce(x)?(n.enter("lineEnding"),n.consume(x),n.exit("lineEnding"),b):(n.enter("chunkString",{contentType:"string"}),y(x))}function y(x){return x===null||x===91||x===93||ce(x)||p++>999?(n.exit("chunkString"),b(x)):(n.consume(x),m||(m=!Ee(x)),x===92?S:y)}function S(x){return x===91||x===92||x===93?(n.consume(x),p++,y):y(x)}}function fg(n,r,a,u,c,h){let f;return p;function p(S){return S===34||S===39||S===40?(n.enter(u),n.enter(c),n.consume(S),n.exit(c),f=S===40?41:S,m):a(S)}function m(S){return S===f?(n.enter(c),n.consume(S),n.exit(c),n.exit(u),r):(n.enter(h),d(S))}function d(S){return S===f?(n.exit(h),m(f)):S===null?a(S):ce(S)?(n.enter("lineEnding"),n.consume(S),n.exit("lineEnding"),_e(n,d,"linePrefix")):(n.enter("chunkString",{contentType:"string"}),b(S))}function b(S){return S===f||S===null||ce(S)?(n.exit("chunkString"),d(S)):(n.consume(S),S===92?y:b)}function y(S){return S===f||S===92?(n.consume(S),b):b(S)}}function Ca(n,r){let a;return u;function u(c){return ce(c)?(n.enter("lineEnding"),n.consume(c),n.exit("lineEnding"),a=!0,u):Ee(c)?_e(n,u,a?"linePrefix":"lineSuffix")(c):r(c)}}const fv={name:"definition",tokenize:dv},hv={partial:!0,tokenize:pv};function dv(n,r,a){const u=this;let c;return h;function h(x){return n.enter("definition"),f(x)}function f(x){return cg.call(u,n,p,a,"definitionLabel","definitionLabelMarker","definitionLabelString")(x)}function p(x){return c=un(u.sliceSerialize(u.events[u.events.length-1][1]).slice(1,-1)),x===58?(n.enter("definitionMarker"),n.consume(x),n.exit("definitionMarker"),m):a(x)}function m(x){return Ve(x)?Ca(n,d)(x):d(x)}function d(x){return sg(n,b,a,"definitionDestination","definitionDestinationLiteral","definitionDestinationLiteralMarker","definitionDestinationRaw","definitionDestinationString")(x)}function b(x){return n.attempt(hv,y,y)(x)}function y(x){return Ee(x)?_e(n,S,"whitespace")(x):S(x)}function S(x){return x===null||ce(x)?(n.exit("definition"),u.parser.defined.push(c),r(x)):a(x)}}function pv(n,r,a){return u;function u(p){return Ve(p)?Ca(n,c)(p):a(p)}function c(p){return fg(n,h,a,"definitionTitle","definitionTitleMarker","definitionTitleString")(p)}function h(p){return Ee(p)?_e(n,f,"whitespace")(p):f(p)}function f(p){return p===null||ce(p)?r(p):a(p)}}const mv={name:"hardBreakEscape",tokenize:gv};function gv(n,r,a){return u;function u(h){return n.enter("hardBreakEscape"),n.consume(h),c}function c(h){return ce(h)?(n.exit("hardBreakEscape"),r(h)):a(h)}}const yv={name:"headingAtx",resolve:bv,tokenize:vv};function bv(n,r){let a=n.length-2,u=3,c,h;return n[u][1].type==="whitespace"&&(u+=2),a-2>u&&n[a][1].type==="whitespace"&&(a-=2),n[a][1].type==="atxHeadingSequence"&&(u===a-1||a-4>u&&n[a-2][1].type==="whitespace")&&(a-=u+1===a?2:4),a>u&&(c={type:"atxHeadingText",start:n[u][1].start,end:n[a][1].end},h={type:"chunkText",start:n[u][1].start,end:n[a][1].end,contentType:"text"},qt(n,u,a-u+1,[["enter",c,r],["enter",h,r],["exit",h,r],["exit",c,r]])),n}function vv(n,r,a){let u=0;return c;function c(b){return n.enter("atxHeading"),h(b)}function h(b){return n.enter("atxHeadingSequence"),f(b)}function f(b){return b===35&&u++<6?(n.consume(b),f):b===null||Ve(b)?(n.exit("atxHeadingSequence"),p(b)):a(b)}function p(b){return b===35?(n.enter("atxHeadingSequence"),m(b)):b===null||ce(b)?(n.exit("atxHeading"),r(b)):Ee(b)?_e(n,p,"whitespace")(b):(n.enter("atxHeadingText"),d(b))}function m(b){return b===35?(n.consume(b),m):(n.exit("atxHeadingSequence"),p(b))}function d(b){return b===null||b===35||Ve(b)?(n.exit("atxHeadingText"),p(b)):(n.consume(b),d)}}const xv=["address","article","aside","base","basefont","blockquote","body","caption","center","col","colgroup","dd","details","dialog","dir","div","dl","dt","fieldset","figcaption","figure","footer","form","frame","frameset","h1","h2","h3","h4","h5","h6","head","header","hr","html","iframe","legend","li","link","main","menu","menuitem","nav","noframes","ol","optgroup","option","p","param","search","section","summary","table","tbody","td","tfoot","th","thead","title","tr","track","ul"],fm=["pre","script","style","textarea"],Sv={concrete:!0,name:"htmlFlow",resolveTo:Ev,tokenize:Av},kv={partial:!0,tokenize:Tv},wv={partial:!0,tokenize:Cv};function Ev(n){let r=n.length;for(;r--&&!(n[r][0]==="enter"&&n[r][1].type==="htmlFlow"););return r>1&&n[r-2][1].type==="linePrefix"&&(n[r][1].start=n[r-2][1].start,n[r+1][1].start=n[r-2][1].start,n.splice(r-2,2)),n}function Av(n,r,a){const u=this;let c,h,f,p,m;return d;function d(k){return b(k)}function b(k){return n.enter("htmlFlow"),n.enter("htmlFlowData"),n.consume(k),y}function y(k){return k===33?(n.consume(k),S):k===47?(n.consume(k),h=!0,U):k===63?(n.consume(k),c=3,u.interrupt?r:w):xt(k)?(n.consume(k),f=String.fromCharCode(k),K):a(k)}function S(k){return k===45?(n.consume(k),c=2,x):k===91?(n.consume(k),c=5,p=0,T):xt(k)?(n.consume(k),c=4,u.interrupt?r:w):a(k)}function x(k){return k===45?(n.consume(k),u.interrupt?r:w):a(k)}function T(k){const ee="CDATA[";return k===ee.charCodeAt(p++)?(n.consume(k),p===ee.length?u.interrupt?r:B:T):a(k)}function U(k){return xt(k)?(n.consume(k),f=String.fromCharCode(k),K):a(k)}function K(k){if(k===null||k===47||k===62||Ve(k)){const ee=k===47,he=f.toLowerCase();return!ee&&!h&&fm.includes(he)?(c=1,u.interrupt?r(k):B(k)):xv.includes(f.toLowerCase())?(c=6,ee?(n.consume(k),D):u.interrupt?r(k):B(k)):(c=7,u.interrupt&&!u.parser.lazy[u.now().line]?a(k):h?F(k):Q(k))}return k===45||mt(k)?(n.consume(k),f+=String.fromCharCode(k),K):a(k)}function D(k){return k===62?(n.consume(k),u.interrupt?r:B):a(k)}function F(k){return Ee(k)?(n.consume(k),F):N(k)}function Q(k){return k===47?(n.consume(k),N):k===58||k===95||xt(k)?(n.consume(k),ue):Ee(k)?(n.consume(k),Q):N(k)}function ue(k){return k===45||k===46||k===58||k===95||mt(k)?(n.consume(k),ue):re(k)}function re(k){return k===61?(n.consume(k),L):Ee(k)?(n.consume(k),re):Q(k)}function L(k){return k===null||k===60||k===61||k===62||k===96?a(k):k===34||k===39?(n.consume(k),m=k,P):Ee(k)?(n.consume(k),L):de(k)}function P(k){return k===m?(n.consume(k),m=null,me):k===null||ce(k)?a(k):(n.consume(k),P)}function de(k){return k===null||k===34||k===39||k===47||k===60||k===61||k===62||k===96||Ve(k)?re(k):(n.consume(k),de)}function me(k){return k===47||k===62||Ee(k)?Q(k):a(k)}function N(k){return k===62?(n.consume(k),te):a(k)}function te(k){return k===null||ce(k)?B(k):Ee(k)?(n.consume(k),te):a(k)}function B(k){return k===45&&c===2?(n.consume(k),_):k===60&&c===1?(n.consume(k),Z):k===62&&c===4?(n.consume(k),A):k===63&&c===3?(n.consume(k),w):k===93&&c===5?(n.consume(k),xe):ce(k)&&(c===6||c===7)?(n.exit("htmlFlowData"),n.check(kv,Y,le)(k)):k===null||ce(k)?(n.exit("htmlFlowData"),le(k)):(n.consume(k),B)}function le(k){return n.check(wv,J,Y)(k)}function J(k){return n.enter("lineEnding"),n.consume(k),n.exit("lineEnding"),$}function $(k){return k===null||ce(k)?le(k):(n.enter("htmlFlowData"),B(k))}function _(k){return k===45?(n.consume(k),w):B(k)}function Z(k){return k===47?(n.consume(k),f="",ae):B(k)}function ae(k){if(k===62){const ee=f.toLowerCase();return fm.includes(ee)?(n.consume(k),A):B(k)}return xt(k)&&f.length<8?(n.consume(k),f+=String.fromCharCode(k),ae):B(k)}function xe(k){return k===93?(n.consume(k),w):B(k)}function w(k){return k===62?(n.consume(k),A):k===45&&c===2?(n.consume(k),w):B(k)}function A(k){return k===null||ce(k)?(n.exit("htmlFlowData"),Y(k)):(n.consume(k),A)}function Y(k){return n.exit("htmlFlow"),r(k)}}function Cv(n,r,a){const u=this;return c;function c(f){return ce(f)?(n.enter("lineEnding"),n.consume(f),n.exit("lineEnding"),h):a(f)}function h(f){return u.parser.lazy[u.now().line]?a(f):r(f)}}function Tv(n,r,a){return u;function u(c){return n.enter("lineEnding"),n.consume(c),n.exit("lineEnding"),n.attempt(Na,r,a)}}const zv={name:"htmlText",tokenize:_v};function _v(n,r,a){const u=this;let c,h,f;return p;function p(w){return n.enter("htmlText"),n.enter("htmlTextData"),n.consume(w),m}function m(w){return w===33?(n.consume(w),d):w===47?(n.consume(w),re):w===63?(n.consume(w),Q):xt(w)?(n.consume(w),de):a(w)}function d(w){return w===45?(n.consume(w),b):w===91?(n.consume(w),h=0,T):xt(w)?(n.consume(w),F):a(w)}function b(w){return w===45?(n.consume(w),x):a(w)}function y(w){return w===null?a(w):w===45?(n.consume(w),S):ce(w)?(f=y,Z(w)):(n.consume(w),y)}function S(w){return w===45?(n.consume(w),x):y(w)}function x(w){return w===62?_(w):w===45?S(w):y(w)}function T(w){const A="CDATA[";return w===A.charCodeAt(h++)?(n.consume(w),h===A.length?U:T):a(w)}function U(w){return w===null?a(w):w===93?(n.consume(w),K):ce(w)?(f=U,Z(w)):(n.consume(w),U)}function K(w){return w===93?(n.consume(w),D):U(w)}function D(w){return w===62?_(w):w===93?(n.consume(w),D):U(w)}function F(w){return w===null||w===62?_(w):ce(w)?(f=F,Z(w)):(n.consume(w),F)}function Q(w){return w===null?a(w):w===63?(n.consume(w),ue):ce(w)?(f=Q,Z(w)):(n.consume(w),Q)}function ue(w){return w===62?_(w):Q(w)}function re(w){return xt(w)?(n.consume(w),L):a(w)}function L(w){return w===45||mt(w)?(n.consume(w),L):P(w)}function P(w){return ce(w)?(f=P,Z(w)):Ee(w)?(n.consume(w),P):_(w)}function de(w){return w===45||mt(w)?(n.consume(w),de):w===47||w===62||Ve(w)?me(w):a(w)}function me(w){return w===47?(n.consume(w),_):w===58||w===95||xt(w)?(n.consume(w),N):ce(w)?(f=me,Z(w)):Ee(w)?(n.consume(w),me):_(w)}function N(w){return w===45||w===46||w===58||w===95||mt(w)?(n.consume(w),N):te(w)}function te(w){return w===61?(n.consume(w),B):ce(w)?(f=te,Z(w)):Ee(w)?(n.consume(w),te):me(w)}function B(w){return w===null||w===60||w===61||w===62||w===96?a(w):w===34||w===39?(n.consume(w),c=w,le):ce(w)?(f=B,Z(w)):Ee(w)?(n.consume(w),B):(n.consume(w),J)}function le(w){return w===c?(n.consume(w),c=void 0,$):w===null?a(w):ce(w)?(f=le,Z(w)):(n.consume(w),le)}function J(w){return w===null||w===34||w===39||w===60||w===61||w===96?a(w):w===47||w===62||Ve(w)?me(w):(n.consume(w),J)}function $(w){return w===47||w===62||Ve(w)?me(w):a(w)}function _(w){return w===62?(n.consume(w),n.exit("htmlTextData"),n.exit("htmlText"),r):a(w)}function Z(w){return n.exit("htmlTextData"),n.enter("lineEnding"),n.consume(w),n.exit("lineEnding"),ae}function ae(w){return Ee(w)?_e(n,xe,"linePrefix",u.parser.constructs.disable.null.includes("codeIndented")?void 0:4)(w):xe(w)}function xe(w){return n.enter("htmlTextData"),f(w)}}const Tc={name:"labelEnd",resolveAll:Rv,resolveTo:Nv,tokenize:jv},Ov={tokenize:Lv},Mv={tokenize:Uv},Dv={tokenize:Bv};function Rv(n){let r=-1;const a=[];for(;++r=3&&(d===null||ce(d))?(n.exit("thematicBreak"),r(d)):a(d)}function m(d){return d===c?(n.consume(d),u++,m):(n.exit("thematicBreakSequence"),Ee(d)?_e(n,p,"whitespace")(d):p(d))}}const _t={continuation:{tokenize:Fv},exit:Jv,name:"list",tokenize:Zv},Qv={partial:!0,tokenize:$v},Kv={partial:!0,tokenize:Iv};function Zv(n,r,a){const u=this,c=u.events[u.events.length-1];let h=c&&c[1].type==="linePrefix"?c[2].sliceSerialize(c[1],!0).length:0,f=0;return p;function p(x){const T=u.containerState.type||(x===42||x===43||x===45?"listUnordered":"listOrdered");if(T==="listUnordered"?!u.containerState.marker||x===u.containerState.marker:sc(x)){if(u.containerState.type||(u.containerState.type=T,n.enter(T,{_container:!0})),T==="listUnordered")return n.enter("listItemPrefix"),x===42||x===45?n.check(ru,a,d)(x):d(x);if(!u.interrupt||x===49)return n.enter("listItemPrefix"),n.enter("listItemValue"),m(x)}return a(x)}function m(x){return sc(x)&&++f<10?(n.consume(x),m):(!u.interrupt||f<2)&&(u.containerState.marker?x===u.containerState.marker:x===41||x===46)?(n.exit("listItemValue"),d(x)):a(x)}function d(x){return n.enter("listItemMarker"),n.consume(x),n.exit("listItemMarker"),u.containerState.marker=u.containerState.marker||x,n.check(Na,u.interrupt?a:b,n.attempt(Qv,S,y))}function b(x){return u.containerState.initialBlankLine=!0,h++,S(x)}function y(x){return Ee(x)?(n.enter("listItemPrefixWhitespace"),n.consume(x),n.exit("listItemPrefixWhitespace"),S):a(x)}function S(x){return u.containerState.size=h+u.sliceSerialize(n.exit("listItemPrefix"),!0).length,r(x)}}function Fv(n,r,a){const u=this;return u.containerState._closeFlow=void 0,n.check(Na,c,h);function c(p){return u.containerState.furtherBlankLines=u.containerState.furtherBlankLines||u.containerState.initialBlankLine,_e(n,r,"listItemIndent",u.containerState.size+1)(p)}function h(p){return u.containerState.furtherBlankLines||!Ee(p)?(u.containerState.furtherBlankLines=void 0,u.containerState.initialBlankLine=void 0,f(p)):(u.containerState.furtherBlankLines=void 0,u.containerState.initialBlankLine=void 0,n.attempt(Kv,r,f)(p))}function f(p){return u.containerState._closeFlow=!0,u.interrupt=void 0,_e(n,n.attempt(_t,r,a),"linePrefix",u.parser.constructs.disable.null.includes("codeIndented")?void 0:4)(p)}}function Iv(n,r,a){const u=this;return _e(n,c,"listItemIndent",u.containerState.size+1);function c(h){const f=u.events[u.events.length-1];return f&&f[1].type==="listItemIndent"&&f[2].sliceSerialize(f[1],!0).length===u.containerState.size?r(h):a(h)}}function Jv(n){n.exit(this.containerState.type)}function $v(n,r,a){const u=this;return _e(n,c,"listItemPrefixWhitespace",u.parser.constructs.disable.null.includes("codeIndented")?void 0:5);function c(h){const f=u.events[u.events.length-1];return!Ee(h)&&f&&f[1].type==="listItemPrefixWhitespace"?r(h):a(h)}}const hm={name:"setextUnderline",resolveTo:Wv,tokenize:Pv};function Wv(n,r){let a=n.length,u,c,h;for(;a--;)if(n[a][0]==="enter"){if(n[a][1].type==="content"){u=a;break}n[a][1].type==="paragraph"&&(c=a)}else n[a][1].type==="content"&&n.splice(a,1),!h&&n[a][1].type==="definition"&&(h=a);const f={type:"setextHeading",start:{...n[u][1].start},end:{...n[n.length-1][1].end}};return n[c][1].type="setextHeadingText",h?(n.splice(c,0,["enter",f,r]),n.splice(h+1,0,["exit",n[u][1],r]),n[u][1].end={...n[h][1].end}):n[u][1]=f,n.push(["exit",f,r]),n}function Pv(n,r,a){const u=this;let c;return h;function h(d){let b=u.events.length,y;for(;b--;)if(u.events[b][1].type!=="lineEnding"&&u.events[b][1].type!=="linePrefix"&&u.events[b][1].type!=="content"){y=u.events[b][1].type==="paragraph";break}return!u.parser.lazy[u.now().line]&&(u.interrupt||y)?(n.enter("setextHeadingLine"),c=d,f(d)):a(d)}function f(d){return n.enter("setextHeadingLineSequence"),p(d)}function p(d){return d===c?(n.consume(d),p):(n.exit("setextHeadingLineSequence"),Ee(d)?_e(n,m,"lineSuffix")(d):m(d))}function m(d){return d===null||ce(d)?(n.exit("setextHeadingLine"),r(d)):a(d)}}const ex={tokenize:tx};function tx(n){const r=this,a=n.attempt(Na,u,n.attempt(this.parser.constructs.flowInitial,c,_e(n,n.attempt(this.parser.constructs.flow,c,n.attempt(rv,c)),"linePrefix")));return a;function u(h){if(h===null){n.consume(h);return}return n.enter("lineEndingBlank"),n.consume(h),n.exit("lineEndingBlank"),r.currentConstruct=void 0,a}function c(h){if(h===null){n.consume(h);return}return n.enter("lineEnding"),n.consume(h),n.exit("lineEnding"),r.currentConstruct=void 0,a}}const nx={resolveAll:dg()},lx=hg("string"),ix=hg("text");function hg(n){return{resolveAll:dg(n==="text"?ax:void 0),tokenize:r};function r(a){const u=this,c=this.parser.constructs[n],h=a.attempt(c,f,p);return f;function f(b){return d(b)?h(b):p(b)}function p(b){if(b===null){a.consume(b);return}return a.enter("data"),a.consume(b),m}function m(b){return d(b)?(a.exit("data"),h(b)):(a.consume(b),m)}function d(b){if(b===null)return!0;const y=c[b];let S=-1;if(y)for(;++S-1){const p=f[0];typeof p=="string"?f[0]=p.slice(u):f.shift()}h>0&&f.push(n[c].slice(0,h))}return f}function bx(n,r){let a=-1;const u=[];let c;for(;++a0){const Mt=pe.tokenStack[pe.tokenStack.length-1];(Mt[1]||pm).call(pe,void 0,Mt[0])}for(W.position={start:ul(G.length>0?G[0][1].start:{line:1,column:1,offset:0}),end:ul(G.length>0?G[G.length-2][1].end:{line:1,column:1,offset:0})},Re=-1;++Re0&&(u.className=["language-"+c[0]]);let h={type:"element",tagName:"code",properties:u,children:[{type:"text",value:a}]};return r.meta&&(h.data={meta:r.meta}),n.patch(r,h),h=n.applyData(r,h),h={type:"element",tagName:"pre",properties:{},children:[h]},n.patch(r,h),h}function Dx(n,r){const a={type:"element",tagName:"del",properties:{},children:n.all(r)};return n.patch(r,a),n.applyData(r,a)}function Rx(n,r){const a={type:"element",tagName:"em",properties:{},children:n.all(r)};return n.patch(r,a),n.applyData(r,a)}function Nx(n,r){const a=typeof n.options.clobberPrefix=="string"?n.options.clobberPrefix:"user-content-",u=String(r.identifier).toUpperCase(),c=Ai(u.toLowerCase()),h=n.footnoteOrder.indexOf(u);let f,p=n.footnoteCounts.get(u);p===void 0?(p=0,n.footnoteOrder.push(u),f=n.footnoteOrder.length):f=h+1,p+=1,n.footnoteCounts.set(u,p);const m={type:"element",tagName:"a",properties:{href:"#"+a+"fn-"+c,id:a+"fnref-"+c+(p>1?"-"+p:""),dataFootnoteRef:!0,ariaDescribedBy:["footnote-label"]},children:[{type:"text",value:String(f)}]};n.patch(r,m);const d={type:"element",tagName:"sup",properties:{},children:[m]};return n.patch(r,d),n.applyData(r,d)}function jx(n,r){const a={type:"element",tagName:"h"+r.depth,properties:{},children:n.all(r)};return n.patch(r,a),n.applyData(r,a)}function Lx(n,r){if(n.options.allowDangerousHtml){const a={type:"raw",value:r.value};return n.patch(r,a),n.applyData(r,a)}}function gg(n,r){const a=r.referenceType;let u="]";if(a==="collapsed"?u+="[]":a==="full"&&(u+="["+(r.label||r.identifier)+"]"),r.type==="imageReference")return[{type:"text",value:"!["+r.alt+u}];const c=n.all(r),h=c[0];h&&h.type==="text"?h.value="["+h.value:c.unshift({type:"text",value:"["});const f=c[c.length-1];return f&&f.type==="text"?f.value+=u:c.push({type:"text",value:u}),c}function Ux(n,r){const a=String(r.identifier).toUpperCase(),u=n.definitionById.get(a);if(!u)return gg(n,r);const c={src:Ai(u.url||""),alt:r.alt};u.title!==null&&u.title!==void 0&&(c.title=u.title);const h={type:"element",tagName:"img",properties:c,children:[]};return n.patch(r,h),n.applyData(r,h)}function Bx(n,r){const a={src:Ai(r.url)};r.alt!==null&&r.alt!==void 0&&(a.alt=r.alt),r.title!==null&&r.title!==void 0&&(a.title=r.title);const u={type:"element",tagName:"img",properties:a,children:[]};return n.patch(r,u),n.applyData(r,u)}function Hx(n,r){const a={type:"text",value:r.value.replace(/\r?\n|\r/g," ")};n.patch(r,a);const u={type:"element",tagName:"code",properties:{},children:[a]};return n.patch(r,u),n.applyData(r,u)}function qx(n,r){const a=String(r.identifier).toUpperCase(),u=n.definitionById.get(a);if(!u)return gg(n,r);const c={href:Ai(u.url||"")};u.title!==null&&u.title!==void 0&&(c.title=u.title);const h={type:"element",tagName:"a",properties:c,children:n.all(r)};return n.patch(r,h),n.applyData(r,h)}function Yx(n,r){const a={href:Ai(r.url)};r.title!==null&&r.title!==void 0&&(a.title=r.title);const u={type:"element",tagName:"a",properties:a,children:n.all(r)};return n.patch(r,u),n.applyData(r,u)}function Gx(n,r,a){const u=n.all(r),c=a?Vx(a):yg(r),h={},f=[];if(typeof r.checked=="boolean"){const b=u[0];let y;b&&b.type==="element"&&b.tagName==="p"?y=b:(y={type:"element",tagName:"p",properties:{},children:[]},u.unshift(y)),y.children.length>0&&y.children.unshift({type:"text",value:" "}),y.children.unshift({type:"element",tagName:"input",properties:{type:"checkbox",checked:r.checked,disabled:!0},children:[]}),h.className=["task-list-item"]}let p=-1;for(;++p1}function Xx(n,r){const a={},u=n.all(r);let c=-1;for(typeof r.start=="number"&&r.start!==1&&(a.start=r.start);++c0){const f={type:"element",tagName:"tbody",properties:{},children:n.wrap(a,!0)},p=Sc(r.children[1]),m=Jm(r.children[r.children.length-1]);p&&m&&(f.position={start:p,end:m}),c.push(f)}const h={type:"element",tagName:"table",properties:{},children:n.wrap(c,!0)};return n.patch(r,h),n.applyData(r,h)}function Ix(n,r,a){const u=a?a.children:void 0,h=(u?u.indexOf(r):1)===0?"th":"td",f=a&&a.type==="table"?a.align:void 0,p=f?f.length:r.children.length;let m=-1;const d=[];for(;++m0,!0),u[0]),c=u.index+u[0].length,u=a.exec(r);return h.push(ym(r.slice(c),c>0,!1)),h.join("")}function ym(n,r,a){let u=0,c=n.length;if(r){let h=n.codePointAt(u);for(;h===mm||h===gm;)u++,h=n.codePointAt(u)}if(a){let h=n.codePointAt(c-1);for(;h===mm||h===gm;)c--,h=n.codePointAt(c-1)}return c>u?n.slice(u,c):""}function Wx(n,r){const a={type:"text",value:$x(String(r.value))};return n.patch(r,a),n.applyData(r,a)}function Px(n,r){const a={type:"element",tagName:"hr",properties:{},children:[]};return n.patch(r,a),n.applyData(r,a)}const eS={blockquote:_x,break:Ox,code:Mx,delete:Dx,emphasis:Rx,footnoteReference:Nx,heading:jx,html:Lx,imageReference:Ux,image:Bx,inlineCode:Hx,linkReference:qx,link:Yx,listItem:Gx,list:Xx,paragraph:Qx,root:Kx,strong:Zx,table:Fx,tableCell:Jx,tableRow:Ix,text:Wx,thematicBreak:Px,toml:tu,yaml:tu,definition:tu,footnoteDefinition:tu};function tu(){}const bg=-1,gu=0,Ta=1,su=2,zc=3,_c=4,Oc=5,Mc=6,vg=7,xg=8,tS=typeof self=="object"?self:globalThis,bm=(n,r)=>{switch(n){case"Function":case"SharedWorker":case"Worker":case"eval":case"setInterval":case"setTimeout":throw new TypeError("unable to deserialize "+n)}return new tS[n](r)},nS=(n,r)=>{const a=(c,h)=>(n.set(h,c),c),u=c=>{if(n.has(c))return n.get(c);const[h,f]=r[c];switch(h){case gu:case bg:return a(f,c);case Ta:{const p=a([],c);for(const m of f)p.push(u(m));return p}case su:{const p=a({},c);for(const[m,d]of f)p[u(m)]=u(d);return p}case zc:return a(new Date(f),c);case _c:{const{source:p,flags:m}=f;return a(new RegExp(p,m),c)}case Oc:{const p=a(new Map,c);for(const[m,d]of f)p.set(u(m),u(d));return p}case Mc:{const p=a(new Set,c);for(const m of f)p.add(u(m));return p}case vg:{const{name:p,message:m}=f;return a(bm(p,m),c)}case xg:return a(BigInt(f),c);case"BigInt":return a(Object(BigInt(f)),c);case"ArrayBuffer":return a(new Uint8Array(f).buffer,f);case"DataView":{const{buffer:p}=new Uint8Array(f);return a(new DataView(p),f)}}return a(bm(h,f),c)};return u},vm=n=>nS(new Map,n)(0),xi="",{toString:lS}={},{keys:iS}=Object,Ea=n=>{const r=typeof n;if(r!=="object"||!n)return[gu,r];const a=lS.call(n).slice(8,-1);switch(a){case"Array":return[Ta,xi];case"Object":return[su,xi];case"Date":return[zc,xi];case"RegExp":return[_c,xi];case"Map":return[Oc,xi];case"Set":return[Mc,xi];case"DataView":return[Ta,a]}return a.includes("Array")?[Ta,a]:a.includes("Error")?[vg,a]:[su,a]},nu=([n,r])=>n===gu&&(r==="function"||r==="symbol"),aS=(n,r,a,u)=>{const c=(f,p)=>{const m=u.push(f)-1;return a.set(p,m),m},h=f=>{if(a.has(f))return a.get(f);let[p,m]=Ea(f);switch(p){case gu:{let b=f;switch(m){case"bigint":p=xg,b=f.toString();break;case"function":case"symbol":if(n)throw new TypeError("unable to serialize "+m);b=null;break;case"undefined":return c([bg],f)}return c([p,b],f)}case Ta:{if(m){let S=f;return m==="DataView"?S=new Uint8Array(f.buffer):m==="ArrayBuffer"&&(S=new Uint8Array(f)),c([m,[...S]],f)}const b=[],y=c([p,b],f);for(const S of f)b.push(h(S));return y}case su:{if(m)switch(m){case"BigInt":return c([m,f.toString()],f);case"Boolean":case"Number":case"String":return c([m,f.valueOf()],f)}if(r&&"toJSON"in f)return h(f.toJSON());const b=[],y=c([p,b],f);for(const S of iS(f))(n||!nu(Ea(f[S])))&&b.push([h(S),h(f[S])]);return y}case zc:return c([p,f.toISOString()],f);case _c:{const{source:b,flags:y}=f;return c([p,{source:b,flags:y}],f)}case Oc:{const b=[],y=c([p,b],f);for(const[S,x]of f)(n||!(nu(Ea(S))||nu(Ea(x))))&&b.push([h(S),h(x)]);return y}case Mc:{const b=[],y=c([p,b],f);for(const S of f)(n||!nu(Ea(S)))&&b.push(h(S));return y}}const{message:d}=f;return c([p,{name:m,message:d}],f)};return h},xm=(n,{json:r,lossy:a}={})=>{const u=[];return aS(!(r||a),!!r,new Map,u)(n),u},cu=typeof structuredClone=="function"?(n,r)=>r&&("json"in r||"lossy"in r)?vm(xm(n,r)):structuredClone(n):(n,r)=>vm(xm(n,r));function rS(n,r){const a=[{type:"text",value:"↩"}];return r>1&&a.push({type:"element",tagName:"sup",properties:{},children:[{type:"text",value:String(r)}]}),a}function uS(n,r){return"Back to reference "+(n+1)+(r>1?"-"+r:"")}function oS(n){const r=typeof n.options.clobberPrefix=="string"?n.options.clobberPrefix:"user-content-",a=n.options.footnoteBackContent||rS,u=n.options.footnoteBackLabel||uS,c=n.options.footnoteLabel||"Footnotes",h=n.options.footnoteLabelTagName||"h2",f=n.options.footnoteLabelProperties||{className:["sr-only"]},p=[];let m=-1;for(;++m0&&T.push({type:"text",value:" "});let F=typeof a=="string"?a:a(m,x);typeof F=="string"&&(F={type:"text",value:F}),T.push({type:"element",tagName:"a",properties:{href:"#"+r+"fnref-"+S+(x>1?"-"+x:""),dataFootnoteBackref:"",ariaLabel:typeof u=="string"?u:u(m,x),className:["data-footnote-backref"]},children:Array.isArray(F)?F:[F]})}const K=b[b.length-1];if(K&&K.type==="element"&&K.tagName==="p"){const F=K.children[K.children.length-1];F&&F.type==="text"?F.value+=" ":K.children.push({type:"text",value:" "}),K.children.push(...T)}else b.push(...T);const D={type:"element",tagName:"li",properties:{id:r+"fn-"+S},children:n.wrap(b,!0)};n.patch(d,D),p.push(D)}if(p.length!==0)return{type:"element",tagName:"section",properties:{dataFootnotes:!0,className:["footnotes"]},children:[{type:"element",tagName:h,properties:{...cu(f),id:"footnote-label"},children:[{type:"text",value:c}]},{type:"text",value:` -`},{type:"element",tagName:"ol",properties:{},children:n.wrap(p,!0)},{type:"text",value:` -`}]}}const yu=(function(n){if(n==null)return hS;if(typeof n=="function")return bu(n);if(typeof n=="object")return Array.isArray(n)?sS(n):cS(n);if(typeof n=="string")return fS(n);throw new Error("Expected function, string, or object as test")});function sS(n){const r=[];let a=-1;for(;++a":""))+")"})}return S;function S(){let x=Sg,T,U,K;if((!r||h(m,d,b[b.length-1]||void 0))&&(x=gS(a(m,b)),x[0]===fc))return x;if("children"in m&&m.children){const D=m;if(D.children&&x[0]!==mS)for(U=(u?D.children.length:-1)+f,K=b.concat(D);U>-1&&U0&&a.push({type:"text",value:` -`}),a}function Sm(n){let r=0,a=n.charCodeAt(r);for(;a===9||a===32;)r++,a=n.charCodeAt(r);return n.slice(r)}function km(n,r){const a=bS(n,r),u=a.one(n,void 0),c=oS(a),h=Array.isArray(u)?{type:"root",children:u}:u||{type:"root",children:[]};return c&&h.children.push({type:"text",value:` -`},c),h}function wS(n,r){return n&&"run"in n?async function(a,u){const c=km(a,{file:u,...r});await n.run(c,u)}:function(a,u){return km(a,{file:u,...n||r})}}function wm(n){if(n)throw n}var Ks,Em;function ES(){if(Em)return Ks;Em=1;var n=Object.prototype.hasOwnProperty,r=Object.prototype.toString,a=Object.defineProperty,u=Object.getOwnPropertyDescriptor,c=function(d){return typeof Array.isArray=="function"?Array.isArray(d):r.call(d)==="[object Array]"},h=function(d){if(!d||r.call(d)!=="[object Object]")return!1;var b=n.call(d,"constructor"),y=d.constructor&&d.constructor.prototype&&n.call(d.constructor.prototype,"isPrototypeOf");if(d.constructor&&!b&&!y)return!1;var S;for(S in d);return typeof S>"u"||n.call(d,S)},f=function(d,b){a&&b.name==="__proto__"?a(d,b.name,{enumerable:!0,configurable:!0,value:b.newValue,writable:!0}):d[b.name]=b.newValue},p=function(d,b){if(b==="__proto__")if(n.call(d,b)){if(u)return u(d,b).value}else return;return d[b]};return Ks=function m(){var d,b,y,S,x,T,U=arguments[0],K=1,D=arguments.length,F=!1;for(typeof U=="boolean"&&(F=U,U=arguments[1]||{},K=2),(U==null||typeof U!="object"&&typeof U!="function")&&(U={});Kf.length;let m;p&&f.push(c);try{m=n.apply(this,f)}catch(d){const b=d;if(p&&a)throw b;return c(b)}p||(m&&m.then&&typeof m.then=="function"?m.then(h,c):m instanceof Error?c(m):h(m))}function c(f,...p){a||(a=!0,r(f,...p))}function h(f){c(null,f)}}const hn={basename:zS,dirname:_S,extname:OS,join:MS,sep:"/"};function zS(n,r){if(r!==void 0&&typeof r!="string")throw new TypeError('"ext" argument must be a string');ja(n);let a=0,u=-1,c=n.length,h;if(r===void 0||r.length===0||r.length>n.length){for(;c--;)if(n.codePointAt(c)===47){if(h){a=c+1;break}}else u<0&&(h=!0,u=c+1);return u<0?"":n.slice(a,u)}if(r===n)return"";let f=-1,p=r.length-1;for(;c--;)if(n.codePointAt(c)===47){if(h){a=c+1;break}}else f<0&&(h=!0,f=c+1),p>-1&&(n.codePointAt(c)===r.codePointAt(p--)?p<0&&(u=c):(p=-1,u=f));return a===u?u=f:u<0&&(u=n.length),n.slice(a,u)}function _S(n){if(ja(n),n.length===0)return".";let r=-1,a=n.length,u;for(;--a;)if(n.codePointAt(a)===47){if(u){r=a;break}}else u||(u=!0);return r<0?n.codePointAt(0)===47?"/":".":r===1&&n.codePointAt(0)===47?"//":n.slice(0,r)}function OS(n){ja(n);let r=n.length,a=-1,u=0,c=-1,h=0,f;for(;r--;){const p=n.codePointAt(r);if(p===47){if(f){u=r+1;break}continue}a<0&&(f=!0,a=r+1),p===46?c<0?c=r:h!==1&&(h=1):c>-1&&(h=-1)}return c<0||a<0||h===0||h===1&&c===a-1&&c===u+1?"":n.slice(c,a)}function MS(...n){let r=-1,a;for(;++r0&&n.codePointAt(n.length-1)===47&&(a+="/"),r?"/"+a:a}function RS(n,r){let a="",u=0,c=-1,h=0,f=-1,p,m;for(;++f<=n.length;){if(f2){if(m=a.lastIndexOf("/"),m!==a.length-1){m<0?(a="",u=0):(a=a.slice(0,m),u=a.length-1-a.lastIndexOf("/")),c=f,h=0;continue}}else if(a.length>0){a="",u=0,c=f,h=0;continue}}r&&(a=a.length>0?a+"/..":"..",u=2)}else a.length>0?a+="/"+n.slice(c+1,f):a=n.slice(c+1,f),u=f-c-1;c=f,h=0}else p===46&&h>-1?h++:h=-1}return a}function ja(n){if(typeof n!="string")throw new TypeError("Path must be a string. Received "+JSON.stringify(n))}const NS={cwd:jS};function jS(){return"/"}function pc(n){return!!(n!==null&&typeof n=="object"&&"href"in n&&n.href&&"protocol"in n&&n.protocol&&n.auth===void 0)}function LS(n){if(typeof n=="string")n=new URL(n);else if(!pc(n)){const r=new TypeError('The "path" argument must be of type string or an instance of URL. Received `'+n+"`");throw r.code="ERR_INVALID_ARG_TYPE",r}if(n.protocol!=="file:"){const r=new TypeError("The URL must be of scheme file");throw r.code="ERR_INVALID_URL_SCHEME",r}return US(n)}function US(n){if(n.hostname!==""){const u=new TypeError('File URL host must be "localhost" or empty on darwin');throw u.code="ERR_INVALID_FILE_URL_HOST",u}const r=n.pathname;let a=-1;for(;++a0){let[x,...T]=b;const U=u[S][1];dc(U)&&dc(x)&&(x=Zs(!0,U,x)),u[S]=[d,x,...T]}}}}const YS=new Rc().freeze();function $s(n,r){if(typeof r!="function")throw new TypeError("Cannot `"+n+"` without `parser`")}function Ws(n,r){if(typeof r!="function")throw new TypeError("Cannot `"+n+"` without `compiler`")}function Ps(n,r){if(r)throw new Error("Cannot call `"+n+"` on a frozen processor.\nCreate a new processor first, by calling it: use `processor()` instead of `processor`.")}function Cm(n){if(!dc(n)||typeof n.type!="string")throw new TypeError("Expected node, got `"+n+"`")}function Tm(n,r,a){if(!a)throw new Error("`"+n+"` finished async. Use `"+r+"` instead")}function lu(n){return GS(n)?n:new wg(n)}function GS(n){return!!(n&&typeof n=="object"&&"message"in n&&"messages"in n)}function VS(n){return typeof n=="string"||XS(n)}function XS(n){return!!(n&&typeof n=="object"&&"byteLength"in n&&"byteOffset"in n)}const QS="https://github.com/remarkjs/react-markdown/blob/main/changelog.md",zm=[],_m={allowDangerousHtml:!0},KS=/^(https?|ircs?|mailto|xmpp)$/i,ZS=[{from:"astPlugins",id:"remove-buggy-html-in-markdown-parser"},{from:"allowDangerousHtml",id:"remove-buggy-html-in-markdown-parser"},{from:"allowNode",id:"replace-allownode-allowedtypes-and-disallowedtypes",to:"allowElement"},{from:"allowedTypes",id:"replace-allownode-allowedtypes-and-disallowedtypes",to:"allowedElements"},{from:"disallowedTypes",id:"replace-allownode-allowedtypes-and-disallowedtypes",to:"disallowedElements"},{from:"escapeHtml",id:"remove-buggy-html-in-markdown-parser"},{from:"includeElementIndex",id:"#remove-includeelementindex"},{from:"includeNodeIndex",id:"change-includenodeindex-to-includeelementindex"},{from:"linkTarget",id:"remove-linktarget"},{from:"plugins",id:"change-plugins-to-remarkplugins",to:"remarkPlugins"},{from:"rawSourcePos",id:"#remove-rawsourcepos"},{from:"renderers",id:"change-renderers-to-components",to:"components"},{from:"source",id:"change-source-to-children",to:"children"},{from:"sourcePos",id:"#remove-sourcepos"},{from:"transformImageUri",id:"#add-urltransform",to:"urlTransform"},{from:"transformLinkUri",id:"#add-urltransform",to:"urlTransform"}];function FS(n){const r=IS(n),a=JS(n);return $S(r.runSync(r.parse(a),a),n)}function IS(n){const r=n.rehypePlugins||zm,a=n.remarkPlugins||zm,u=n.remarkRehypeOptions?{...n.remarkRehypeOptions,..._m}:_m;return YS().use(zx).use(a).use(wS,u).use(r)}function JS(n){const r=n.children||"",a=new wg;return typeof r=="string"&&(a.value=r),a}function $S(n,r){const a=r.allowedElements,u=r.allowElement,c=r.components,h=r.disallowedElements,f=r.skipHtml,p=r.unwrapDisallowed,m=r.urlTransform||WS;for(const b of ZS)Object.hasOwn(r,b.from)&&(""+b.from+(b.to?"use `"+b.to+"` instead":"remove it")+QS+b.id,void 0);return r.className&&(n={type:"element",tagName:"div",properties:{className:r.className},children:n.type==="root"?n.children:[n]}),Dc(n,d),f0(n,{Fragment:H.Fragment,components:c,ignoreInvalidStyle:!0,jsx:H.jsx,jsxs:H.jsxs,passKeys:!0,passNode:!0});function d(b,y,S){if(b.type==="raw"&&S&&typeof y=="number")return f?S.children.splice(y,1):S.children[y]={type:"text",value:b.value},y;if(b.type==="element"){let x;for(x in Vs)if(Object.hasOwn(Vs,x)&&Object.hasOwn(b.properties,x)){const T=b.properties[x],U=Vs[x];(U===null||U.includes(b.tagName))&&(b.properties[x]=m(String(T||""),x,b))}}if(b.type==="element"){let x=a?!a.includes(b.tagName):h?h.includes(b.tagName):!1;if(!x&&u&&typeof y=="number"&&(x=!u(b,y,S)),x&&S&&typeof y=="number")return p&&b.children?S.children.splice(y,1,...b.children):S.children.splice(y,1),y}}}function WS(n){const r=n.indexOf(":"),a=n.indexOf("?"),u=n.indexOf("#"),c=n.indexOf("/");return r===-1||c!==-1&&r>c||a!==-1&&r>a||u!==-1&&r>u||KS.test(n.slice(0,r))?n:""}function Om(n,r){const a=String(n);if(typeof r!="string")throw new TypeError("Expected character");let u=0,c=a.indexOf(r);for(;c!==-1;)u++,c=a.indexOf(r,c+r.length);return u}function PS(n){if(typeof n!="string")throw new TypeError("Expected a string");return n.replace(/[|\\{}()[\]^$+*?.]/g,"\\$&").replace(/-/g,"\\x2d")}function ek(n,r,a){const c=yu((a||{}).ignore||[]),h=tk(r);let f=-1;for(;++f0?{type:"text",value:L}:void 0),L===!1?S.lastIndex=ue+1:(T!==ue&&F.push({type:"text",value:d.value.slice(T,ue)}),Array.isArray(L)?F.push(...L):L&&F.push(L),T=ue+Q[0].length,D=!0),!S.global)break;Q=S.exec(d.value)}return D?(T?\]}]+$/.exec(n);if(!r)return[n,void 0];n=n.slice(0,r.index);let a=r[0],u=a.indexOf(")");const c=Om(n,"(");let h=Om(n,")");for(;u!==-1&&c>h;)n+=a.slice(0,u+1),a=a.slice(u+1),u=a.indexOf(")"),h++;return[n,a]}function Eg(n,r){const a=n.input.charCodeAt(n.index-1);return(n.index===0||_l(a)||pu(a))&&(!r||a!==47)}Ag.peek=Ek;function gk(){this.buffer()}function yk(n){this.enter({type:"footnoteReference",identifier:"",label:""},n)}function bk(){this.buffer()}function vk(n){this.enter({type:"footnoteDefinition",identifier:"",label:"",children:[]},n)}function xk(n){const r=this.resume(),a=this.stack[this.stack.length-1];a.type,a.identifier=un(this.sliceSerialize(n)).toLowerCase(),a.label=r}function Sk(n){this.exit(n)}function kk(n){const r=this.resume(),a=this.stack[this.stack.length-1];a.type,a.identifier=un(this.sliceSerialize(n)).toLowerCase(),a.label=r}function wk(n){this.exit(n)}function Ek(){return"["}function Ag(n,r,a,u){const c=a.createTracker(u);let h=c.move("[^");const f=a.enter("footnoteReference"),p=a.enter("reference");return h+=c.move(a.safe(a.associationId(n),{after:"]",before:h})),p(),f(),h+=c.move("]"),h}function Ak(){return{enter:{gfmFootnoteCallString:gk,gfmFootnoteCall:yk,gfmFootnoteDefinitionLabelString:bk,gfmFootnoteDefinition:vk},exit:{gfmFootnoteCallString:xk,gfmFootnoteCall:Sk,gfmFootnoteDefinitionLabelString:kk,gfmFootnoteDefinition:wk}}}function Ck(n){let r=!1;return n&&n.firstLineBlank&&(r=!0),{handlers:{footnoteDefinition:a,footnoteReference:Ag},unsafe:[{character:"[",inConstruct:["label","phrasing","reference"]}]};function a(u,c,h,f){const p=h.createTracker(f);let m=p.move("[^");const d=h.enter("footnoteDefinition"),b=h.enter("label");return m+=p.move(h.safe(h.associationId(u),{before:m,after:"]"})),b(),m+=p.move("]:"),u.children&&u.children.length>0&&(p.shift(4),m+=p.move((r?` -`:" ")+h.indentLines(h.containerFlow(u,p.current()),r?Cg:Tk))),d(),m}}function Tk(n,r,a){return r===0?n:Cg(n,r,a)}function Cg(n,r,a){return(a?"":" ")+n}const zk=["autolink","destinationLiteral","destinationRaw","reference","titleQuote","titleApostrophe"];Tg.peek=Rk;function _k(){return{canContainEols:["delete"],enter:{strikethrough:Mk},exit:{strikethrough:Dk}}}function Ok(){return{unsafe:[{character:"~",inConstruct:"phrasing",notInConstruct:zk}],handlers:{delete:Tg}}}function Mk(n){this.enter({type:"delete",children:[]},n)}function Dk(n){this.exit(n)}function Tg(n,r,a,u){const c=a.createTracker(u),h=a.enter("strikethrough");let f=c.move("~~");return f+=a.containerPhrasing(n,{...c.current(),before:f,after:"~"}),f+=c.move("~~"),h(),f}function Rk(){return"~"}function Nk(n){return n.length}function jk(n,r){const a=r||{},u=(a.align||[]).concat(),c=a.stringLength||Nk,h=[],f=[],p=[],m=[];let d=0,b=-1;for(;++bd&&(d=n[b].length);++Dm[D])&&(m[D]=Q)}U.push(F)}f[b]=U,p[b]=K}let y=-1;if(typeof u=="object"&&"length"in u)for(;++ym[y]&&(m[y]=F),x[y]=F),S[y]=Q}f.splice(1,0,S),p.splice(1,0,x),b=-1;const T=[];for(;++b "),h.shift(2);const f=a.indentLines(a.containerFlow(n,h.current()),Bk);return c(),f}function Bk(n,r,a){return">"+(a?"":" ")+n}function Hk(n,r){return Dm(n,r.inConstruct,!0)&&!Dm(n,r.notInConstruct,!1)}function Dm(n,r,a){if(typeof r=="string"&&(r=[r]),!r||r.length===0)return a;let u=-1;for(;++uf&&(f=h):h=1,c=u+r.length,u=a.indexOf(r,c);return f}function Yk(n,r){return!!(r.options.fences===!1&&n.value&&!n.lang&&/[^ \r\n]/.test(n.value)&&!/^[\t ]*(?:[\r\n]|$)|(?:^|[\r\n])[\t ]*$/.test(n.value))}function Gk(n){const r=n.options.fence||"`";if(r!=="`"&&r!=="~")throw new Error("Cannot serialize code with `"+r+"` for `options.fence`, expected `` ` `` or `~`");return r}function Vk(n,r,a,u){const c=Gk(a),h=n.value||"",f=c==="`"?"GraveAccent":"Tilde";if(Yk(n,a)){const y=a.enter("codeIndented"),S=a.indentLines(h,Xk);return y(),S}const p=a.createTracker(u),m=c.repeat(Math.max(qk(h,c)+1,3)),d=a.enter("codeFenced");let b=p.move(m);if(n.lang){const y=a.enter(`codeFencedLang${f}`);b+=p.move(a.safe(n.lang,{before:b,after:" ",encode:["`"],...p.current()})),y()}if(n.lang&&n.meta){const y=a.enter(`codeFencedMeta${f}`);b+=p.move(" "),b+=p.move(a.safe(n.meta,{before:b,after:` -`,encode:["`"],...p.current()})),y()}return b+=p.move(` -`),h&&(b+=p.move(h+` -`)),b+=p.move(m),d(),b}function Xk(n,r,a){return(a?"":" ")+n}function Nc(n){const r=n.options.quote||'"';if(r!=='"'&&r!=="'")throw new Error("Cannot serialize title with `"+r+"` for `options.quote`, expected `\"`, or `'`");return r}function Qk(n,r,a,u){const c=Nc(a),h=c==='"'?"Quote":"Apostrophe",f=a.enter("definition");let p=a.enter("label");const m=a.createTracker(u);let d=m.move("[");return d+=m.move(a.safe(a.associationId(n),{before:d,after:"]",...m.current()})),d+=m.move("]: "),p(),!n.url||/[\0- \u007F]/.test(n.url)?(p=a.enter("destinationLiteral"),d+=m.move("<"),d+=m.move(a.safe(n.url,{before:d,after:">",...m.current()})),d+=m.move(">")):(p=a.enter("destinationRaw"),d+=m.move(a.safe(n.url,{before:d,after:n.title?" ":` -`,...m.current()}))),p(),n.title&&(p=a.enter(`title${h}`),d+=m.move(" "+c),d+=m.move(a.safe(n.title,{before:d,after:c,...m.current()})),d+=m.move(c),p()),f(),d}function Kk(n){const r=n.options.emphasis||"*";if(r!=="*"&&r!=="_")throw new Error("Cannot serialize emphasis with `"+r+"` for `options.emphasis`, expected `*`, or `_`");return r}function Da(n){return"&#x"+n.toString(16).toUpperCase()+";"}function fu(n,r,a){const u=wi(n),c=wi(r);return u===void 0?c===void 0?a==="_"?{inside:!0,outside:!0}:{inside:!1,outside:!1}:c===1?{inside:!0,outside:!0}:{inside:!1,outside:!0}:u===1?c===void 0?{inside:!1,outside:!1}:c===1?{inside:!0,outside:!0}:{inside:!1,outside:!1}:c===void 0?{inside:!1,outside:!1}:c===1?{inside:!0,outside:!1}:{inside:!1,outside:!1}}zg.peek=Zk;function zg(n,r,a,u){const c=Kk(a),h=a.enter("emphasis"),f=a.createTracker(u),p=f.move(c);let m=f.move(a.containerPhrasing(n,{after:c,before:p,...f.current()}));const d=m.charCodeAt(0),b=fu(u.before.charCodeAt(u.before.length-1),d,c);b.inside&&(m=Da(d)+m.slice(1));const y=m.charCodeAt(m.length-1),S=fu(u.after.charCodeAt(0),y,c);S.inside&&(m=m.slice(0,-1)+Da(y));const x=f.move(c);return h(),a.attentionEncodeSurroundingInfo={after:S.outside,before:b.outside},p+m+x}function Zk(n,r,a){return a.options.emphasis||"*"}function Fk(n,r){let a=!1;return Dc(n,function(u){if("value"in u&&/\r?\n|\r/.test(u.value)||u.type==="break")return a=!0,fc}),!!((!n.depth||n.depth<3)&&Ac(n)&&(r.options.setext||a))}function Ik(n,r,a,u){const c=Math.max(Math.min(6,n.depth||1),1),h=a.createTracker(u);if(Fk(n,a)){const b=a.enter("headingSetext"),y=a.enter("phrasing"),S=a.containerPhrasing(n,{...h.current(),before:` -`,after:` -`});return y(),b(),S+` -`+(c===1?"=":"-").repeat(S.length-(Math.max(S.lastIndexOf("\r"),S.lastIndexOf(` -`))+1))}const f="#".repeat(c),p=a.enter("headingAtx"),m=a.enter("phrasing");h.move(f+" ");let d=a.containerPhrasing(n,{before:"# ",after:` -`,...h.current()});return/^[\t ]/.test(d)&&(d=Da(d.charCodeAt(0))+d.slice(1)),d=d?f+" "+d:f,a.options.closeAtx&&(d+=" "+f),m(),p(),d}_g.peek=Jk;function _g(n){return n.value||""}function Jk(){return"<"}Og.peek=$k;function Og(n,r,a,u){const c=Nc(a),h=c==='"'?"Quote":"Apostrophe",f=a.enter("image");let p=a.enter("label");const m=a.createTracker(u);let d=m.move("![");return d+=m.move(a.safe(n.alt,{before:d,after:"]",...m.current()})),d+=m.move("]("),p(),!n.url&&n.title||/[\0- \u007F]/.test(n.url)?(p=a.enter("destinationLiteral"),d+=m.move("<"),d+=m.move(a.safe(n.url,{before:d,after:">",...m.current()})),d+=m.move(">")):(p=a.enter("destinationRaw"),d+=m.move(a.safe(n.url,{before:d,after:n.title?" ":")",...m.current()}))),p(),n.title&&(p=a.enter(`title${h}`),d+=m.move(" "+c),d+=m.move(a.safe(n.title,{before:d,after:c,...m.current()})),d+=m.move(c),p()),d+=m.move(")"),f(),d}function $k(){return"!"}Mg.peek=Wk;function Mg(n,r,a,u){const c=n.referenceType,h=a.enter("imageReference");let f=a.enter("label");const p=a.createTracker(u);let m=p.move("![");const d=a.safe(n.alt,{before:m,after:"]",...p.current()});m+=p.move(d+"]["),f();const b=a.stack;a.stack=[],f=a.enter("reference");const y=a.safe(a.associationId(n),{before:m,after:"]",...p.current()});return f(),a.stack=b,h(),c==="full"||!d||d!==y?m+=p.move(y+"]"):c==="shortcut"?m=m.slice(0,-1):m+=p.move("]"),m}function Wk(){return"!"}Dg.peek=Pk;function Dg(n,r,a){let u=n.value||"",c="`",h=-1;for(;new RegExp("(^|[^`])"+c+"([^`]|$)").test(u);)c+="`";for(/[^ \r\n]/.test(u)&&(/^[ \r\n]/.test(u)&&/[ \r\n]$/.test(u)||/^`|`$/.test(u))&&(u=" "+u+" ");++h\u007F]/.test(n.url))}Ng.peek=e2;function Ng(n,r,a,u){const c=Nc(a),h=c==='"'?"Quote":"Apostrophe",f=a.createTracker(u);let p,m;if(Rg(n,a)){const b=a.stack;a.stack=[],p=a.enter("autolink");let y=f.move("<");return y+=f.move(a.containerPhrasing(n,{before:y,after:">",...f.current()})),y+=f.move(">"),p(),a.stack=b,y}p=a.enter("link"),m=a.enter("label");let d=f.move("[");return d+=f.move(a.containerPhrasing(n,{before:d,after:"](",...f.current()})),d+=f.move("]("),m(),!n.url&&n.title||/[\0- \u007F]/.test(n.url)?(m=a.enter("destinationLiteral"),d+=f.move("<"),d+=f.move(a.safe(n.url,{before:d,after:">",...f.current()})),d+=f.move(">")):(m=a.enter("destinationRaw"),d+=f.move(a.safe(n.url,{before:d,after:n.title?" ":")",...f.current()}))),m(),n.title&&(m=a.enter(`title${h}`),d+=f.move(" "+c),d+=f.move(a.safe(n.title,{before:d,after:c,...f.current()})),d+=f.move(c),m()),d+=f.move(")"),p(),d}function e2(n,r,a){return Rg(n,a)?"<":"["}jg.peek=t2;function jg(n,r,a,u){const c=n.referenceType,h=a.enter("linkReference");let f=a.enter("label");const p=a.createTracker(u);let m=p.move("[");const d=a.containerPhrasing(n,{before:m,after:"]",...p.current()});m+=p.move(d+"]["),f();const b=a.stack;a.stack=[],f=a.enter("reference");const y=a.safe(a.associationId(n),{before:m,after:"]",...p.current()});return f(),a.stack=b,h(),c==="full"||!d||d!==y?m+=p.move(y+"]"):c==="shortcut"?m=m.slice(0,-1):m+=p.move("]"),m}function t2(){return"["}function jc(n){const r=n.options.bullet||"*";if(r!=="*"&&r!=="+"&&r!=="-")throw new Error("Cannot serialize items with `"+r+"` for `options.bullet`, expected `*`, `+`, or `-`");return r}function n2(n){const r=jc(n),a=n.options.bulletOther;if(!a)return r==="*"?"-":"*";if(a!=="*"&&a!=="+"&&a!=="-")throw new Error("Cannot serialize items with `"+a+"` for `options.bulletOther`, expected `*`, `+`, or `-`");if(a===r)throw new Error("Expected `bullet` (`"+r+"`) and `bulletOther` (`"+a+"`) to be different");return a}function l2(n){const r=n.options.bulletOrdered||".";if(r!=="."&&r!==")")throw new Error("Cannot serialize items with `"+r+"` for `options.bulletOrdered`, expected `.` or `)`");return r}function Lg(n){const r=n.options.rule||"*";if(r!=="*"&&r!=="-"&&r!=="_")throw new Error("Cannot serialize rules with `"+r+"` for `options.rule`, expected `*`, `-`, or `_`");return r}function i2(n,r,a,u){const c=a.enter("list"),h=a.bulletCurrent;let f=n.ordered?l2(a):jc(a);const p=n.ordered?f==="."?")":".":n2(a);let m=r&&a.bulletLastUsed?f===a.bulletLastUsed:!1;if(!n.ordered){const b=n.children?n.children[0]:void 0;if((f==="*"||f==="-")&&b&&(!b.children||!b.children[0])&&a.stack[a.stack.length-1]==="list"&&a.stack[a.stack.length-2]==="listItem"&&a.stack[a.stack.length-3]==="list"&&a.stack[a.stack.length-4]==="listItem"&&a.indexStack[a.indexStack.length-1]===0&&a.indexStack[a.indexStack.length-2]===0&&a.indexStack[a.indexStack.length-3]===0&&(m=!0),Lg(a)===f&&b){let y=-1;for(;++y-1?r.start:1)+(a.options.incrementListMarker===!1?0:r.children.indexOf(n))+h);let f=h.length+1;(c==="tab"||c==="mixed"&&(r&&r.type==="list"&&r.spread||n.spread))&&(f=Math.ceil(f/4)*4);const p=a.createTracker(u);p.move(h+" ".repeat(f-h.length)),p.shift(f);const m=a.enter("listItem"),d=a.indentLines(a.containerFlow(n,p.current()),b);return m(),d;function b(y,S,x){return S?(x?"":" ".repeat(f))+y:(x?h:h+" ".repeat(f-h.length))+y}}function u2(n,r,a,u){const c=a.enter("paragraph"),h=a.enter("phrasing"),f=a.containerPhrasing(n,u);return h(),c(),f}const o2=yu(["break","delete","emphasis","footnote","footnoteReference","image","imageReference","inlineCode","inlineMath","link","linkReference","mdxJsxTextElement","mdxTextExpression","strong","text","textDirective"]);function s2(n,r,a,u){return(n.children.some(function(f){return o2(f)})?a.containerPhrasing:a.containerFlow).call(a,n,u)}function c2(n){const r=n.options.strong||"*";if(r!=="*"&&r!=="_")throw new Error("Cannot serialize strong with `"+r+"` for `options.strong`, expected `*`, or `_`");return r}Ug.peek=f2;function Ug(n,r,a,u){const c=c2(a),h=a.enter("strong"),f=a.createTracker(u),p=f.move(c+c);let m=f.move(a.containerPhrasing(n,{after:c,before:p,...f.current()}));const d=m.charCodeAt(0),b=fu(u.before.charCodeAt(u.before.length-1),d,c);b.inside&&(m=Da(d)+m.slice(1));const y=m.charCodeAt(m.length-1),S=fu(u.after.charCodeAt(0),y,c);S.inside&&(m=m.slice(0,-1)+Da(y));const x=f.move(c+c);return h(),a.attentionEncodeSurroundingInfo={after:S.outside,before:b.outside},p+m+x}function f2(n,r,a){return a.options.strong||"*"}function h2(n,r,a,u){return a.safe(n.value,u)}function d2(n){const r=n.options.ruleRepetition||3;if(r<3)throw new Error("Cannot serialize rules with repetition `"+r+"` for `options.ruleRepetition`, expected `3` or more");return r}function p2(n,r,a){const u=(Lg(a)+(a.options.ruleSpaces?" ":"")).repeat(d2(a));return a.options.ruleSpaces?u.slice(0,-1):u}const Bg={blockquote:Uk,break:Rm,code:Vk,definition:Qk,emphasis:zg,hardBreak:Rm,heading:Ik,html:_g,image:Og,imageReference:Mg,inlineCode:Dg,link:Ng,linkReference:jg,list:i2,listItem:r2,paragraph:u2,root:s2,strong:Ug,text:h2,thematicBreak:p2};function m2(){return{enter:{table:g2,tableData:Nm,tableHeader:Nm,tableRow:b2},exit:{codeText:v2,table:y2,tableData:lc,tableHeader:lc,tableRow:lc}}}function g2(n){const r=n._align;this.enter({type:"table",align:r.map(function(a){return a==="none"?null:a}),children:[]},n),this.data.inTable=!0}function y2(n){this.exit(n),this.data.inTable=void 0}function b2(n){this.enter({type:"tableRow",children:[]},n)}function lc(n){this.exit(n)}function Nm(n){this.enter({type:"tableCell",children:[]},n)}function v2(n){let r=this.resume();this.data.inTable&&(r=r.replace(/\\([\\|])/g,x2));const a=this.stack[this.stack.length-1];a.type,a.value=r,this.exit(n)}function x2(n,r){return r==="|"?r:n}function S2(n){const r=n||{},a=r.tableCellPadding,u=r.tablePipeAlign,c=r.stringLength,h=a?" ":"|";return{unsafe:[{character:"\r",inConstruct:"tableCell"},{character:` -`,inConstruct:"tableCell"},{atBreak:!0,character:"|",after:"[ :-]"},{character:"|",inConstruct:"tableCell"},{atBreak:!0,character:":",after:"-"},{atBreak:!0,character:"-",after:"[:|-]"}],handlers:{inlineCode:S,table:f,tableCell:m,tableRow:p}};function f(x,T,U,K){return d(b(x,U,K),x.align)}function p(x,T,U,K){const D=y(x,U,K),F=d([D]);return F.slice(0,F.indexOf(` -`))}function m(x,T,U,K){const D=U.enter("tableCell"),F=U.enter("phrasing"),Q=U.containerPhrasing(x,{...K,before:h,after:h});return F(),D(),Q}function d(x,T){return jk(x,{align:T,alignDelimiters:u,padding:a,stringLength:c})}function b(x,T,U){const K=x.children;let D=-1;const F=[],Q=T.enter("table");for(;++D0&&!a&&(n[n.length-1][1]._gfmAutolinkLiteralWalkedInto=!0),a}const H2={tokenize:Z2,partial:!0};function q2(){return{document:{91:{name:"gfmFootnoteDefinition",tokenize:X2,continuation:{tokenize:Q2},exit:K2}},text:{91:{name:"gfmFootnoteCall",tokenize:V2},93:{name:"gfmPotentialFootnoteCall",add:"after",tokenize:Y2,resolveTo:G2}}}}function Y2(n,r,a){const u=this;let c=u.events.length;const h=u.parser.gfmFootnotes||(u.parser.gfmFootnotes=[]);let f;for(;c--;){const m=u.events[c][1];if(m.type==="labelImage"){f=m;break}if(m.type==="gfmFootnoteCall"||m.type==="labelLink"||m.type==="label"||m.type==="image"||m.type==="link")break}return p;function p(m){if(!f||!f._balanced)return a(m);const d=un(u.sliceSerialize({start:f.end,end:u.now()}));return d.codePointAt(0)!==94||!h.includes(d.slice(1))?a(m):(n.enter("gfmFootnoteCallLabelMarker"),n.consume(m),n.exit("gfmFootnoteCallLabelMarker"),r(m))}}function G2(n,r){let a=n.length;for(;a--;)if(n[a][1].type==="labelImage"&&n[a][0]==="enter"){n[a][1];break}n[a+1][1].type="data",n[a+3][1].type="gfmFootnoteCallLabelMarker";const u={type:"gfmFootnoteCall",start:Object.assign({},n[a+3][1].start),end:Object.assign({},n[n.length-1][1].end)},c={type:"gfmFootnoteCallMarker",start:Object.assign({},n[a+3][1].end),end:Object.assign({},n[a+3][1].end)};c.end.column++,c.end.offset++,c.end._bufferIndex++;const h={type:"gfmFootnoteCallString",start:Object.assign({},c.end),end:Object.assign({},n[n.length-1][1].start)},f={type:"chunkString",contentType:"string",start:Object.assign({},h.start),end:Object.assign({},h.end)},p=[n[a+1],n[a+2],["enter",u,r],n[a+3],n[a+4],["enter",c,r],["exit",c,r],["enter",h,r],["enter",f,r],["exit",f,r],["exit",h,r],n[n.length-2],n[n.length-1],["exit",u,r]];return n.splice(a,n.length-a+1,...p),n}function V2(n,r,a){const u=this,c=u.parser.gfmFootnotes||(u.parser.gfmFootnotes=[]);let h=0,f;return p;function p(y){return n.enter("gfmFootnoteCall"),n.enter("gfmFootnoteCallLabelMarker"),n.consume(y),n.exit("gfmFootnoteCallLabelMarker"),m}function m(y){return y!==94?a(y):(n.enter("gfmFootnoteCallMarker"),n.consume(y),n.exit("gfmFootnoteCallMarker"),n.enter("gfmFootnoteCallString"),n.enter("chunkString").contentType="string",d)}function d(y){if(h>999||y===93&&!f||y===null||y===91||Ve(y))return a(y);if(y===93){n.exit("chunkString");const S=n.exit("gfmFootnoteCallString");return c.includes(un(u.sliceSerialize(S)))?(n.enter("gfmFootnoteCallLabelMarker"),n.consume(y),n.exit("gfmFootnoteCallLabelMarker"),n.exit("gfmFootnoteCall"),r):a(y)}return Ve(y)||(f=!0),h++,n.consume(y),y===92?b:d}function b(y){return y===91||y===92||y===93?(n.consume(y),h++,d):d(y)}}function X2(n,r,a){const u=this,c=u.parser.gfmFootnotes||(u.parser.gfmFootnotes=[]);let h,f=0,p;return m;function m(T){return n.enter("gfmFootnoteDefinition")._container=!0,n.enter("gfmFootnoteDefinitionLabel"),n.enter("gfmFootnoteDefinitionLabelMarker"),n.consume(T),n.exit("gfmFootnoteDefinitionLabelMarker"),d}function d(T){return T===94?(n.enter("gfmFootnoteDefinitionMarker"),n.consume(T),n.exit("gfmFootnoteDefinitionMarker"),n.enter("gfmFootnoteDefinitionLabelString"),n.enter("chunkString").contentType="string",b):a(T)}function b(T){if(f>999||T===93&&!p||T===null||T===91||Ve(T))return a(T);if(T===93){n.exit("chunkString");const U=n.exit("gfmFootnoteDefinitionLabelString");return h=un(u.sliceSerialize(U)),n.enter("gfmFootnoteDefinitionLabelMarker"),n.consume(T),n.exit("gfmFootnoteDefinitionLabelMarker"),n.exit("gfmFootnoteDefinitionLabel"),S}return Ve(T)||(p=!0),f++,n.consume(T),T===92?y:b}function y(T){return T===91||T===92||T===93?(n.consume(T),f++,b):b(T)}function S(T){return T===58?(n.enter("definitionMarker"),n.consume(T),n.exit("definitionMarker"),c.includes(h)||c.push(h),_e(n,x,"gfmFootnoteDefinitionWhitespace")):a(T)}function x(T){return r(T)}}function Q2(n,r,a){return n.check(Na,r,n.attempt(H2,r,a))}function K2(n){n.exit("gfmFootnoteDefinition")}function Z2(n,r,a){const u=this;return _e(n,c,"gfmFootnoteDefinitionIndent",5);function c(h){const f=u.events[u.events.length-1];return f&&f[1].type==="gfmFootnoteDefinitionIndent"&&f[2].sliceSerialize(f[1],!0).length===4?r(h):a(h)}}function F2(n){let a=(n||{}).singleTilde;const u={name:"strikethrough",tokenize:h,resolveAll:c};return a==null&&(a=!0),{text:{126:u},insideSpan:{null:[u]},attentionMarkers:{null:[126]}};function c(f,p){let m=-1;for(;++m1?m(T):(f.consume(T),y++,x);if(y<2&&!a)return m(T);const K=f.exit("strikethroughSequenceTemporary"),D=wi(T);return K._open=!D||D===2&&!!U,K._close=!U||U===2&&!!D,p(T)}}}class I2{constructor(){this.map=[]}add(r,a,u){J2(this,r,a,u)}consume(r){if(this.map.sort(function(h,f){return h[0]-f[0]}),this.map.length===0)return;let a=this.map.length;const u=[];for(;a>0;)a-=1,u.push(r.slice(this.map[a][0]+this.map[a][1]),this.map[a][2]),r.length=this.map[a][0];u.push(r.slice()),r.length=0;let c=u.pop();for(;c;){for(const h of c)r.push(h);c=u.pop()}this.map.length=0}}function J2(n,r,a,u){let c=0;if(!(a===0&&u.length===0)){for(;c-1;){const J=u.events[te][1].type;if(J==="lineEnding"||J==="linePrefix")te--;else break}const B=te>-1?u.events[te][1].type:null,le=B==="tableHead"||B==="tableRow"?L:m;return le===L&&u.parser.lazy[u.now().line]?a(N):le(N)}function m(N){return n.enter("tableHead"),n.enter("tableRow"),d(N)}function d(N){return N===124||(f=!0,h+=1),b(N)}function b(N){return N===null?a(N):ce(N)?h>1?(h=0,u.interrupt=!0,n.exit("tableRow"),n.enter("lineEnding"),n.consume(N),n.exit("lineEnding"),x):a(N):Ee(N)?_e(n,b,"whitespace")(N):(h+=1,f&&(f=!1,c+=1),N===124?(n.enter("tableCellDivider"),n.consume(N),n.exit("tableCellDivider"),f=!0,b):(n.enter("data"),y(N)))}function y(N){return N===null||N===124||Ve(N)?(n.exit("data"),b(N)):(n.consume(N),N===92?S:y)}function S(N){return N===92||N===124?(n.consume(N),y):y(N)}function x(N){return u.interrupt=!1,u.parser.lazy[u.now().line]?a(N):(n.enter("tableDelimiterRow"),f=!1,Ee(N)?_e(n,T,"linePrefix",u.parser.constructs.disable.null.includes("codeIndented")?void 0:4)(N):T(N))}function T(N){return N===45||N===58?K(N):N===124?(f=!0,n.enter("tableCellDivider"),n.consume(N),n.exit("tableCellDivider"),U):re(N)}function U(N){return Ee(N)?_e(n,K,"whitespace")(N):K(N)}function K(N){return N===58?(h+=1,f=!0,n.enter("tableDelimiterMarker"),n.consume(N),n.exit("tableDelimiterMarker"),D):N===45?(h+=1,D(N)):N===null||ce(N)?ue(N):re(N)}function D(N){return N===45?(n.enter("tableDelimiterFiller"),F(N)):re(N)}function F(N){return N===45?(n.consume(N),F):N===58?(f=!0,n.exit("tableDelimiterFiller"),n.enter("tableDelimiterMarker"),n.consume(N),n.exit("tableDelimiterMarker"),Q):(n.exit("tableDelimiterFiller"),Q(N))}function Q(N){return Ee(N)?_e(n,ue,"whitespace")(N):ue(N)}function ue(N){return N===124?T(N):N===null||ce(N)?!f||c!==h?re(N):(n.exit("tableDelimiterRow"),n.exit("tableHead"),r(N)):re(N)}function re(N){return a(N)}function L(N){return n.enter("tableRow"),P(N)}function P(N){return N===124?(n.enter("tableCellDivider"),n.consume(N),n.exit("tableCellDivider"),P):N===null||ce(N)?(n.exit("tableRow"),r(N)):Ee(N)?_e(n,P,"whitespace")(N):(n.enter("data"),de(N))}function de(N){return N===null||N===124||Ve(N)?(n.exit("data"),P(N)):(n.consume(N),N===92?me:de)}function me(N){return N===92||N===124?(n.consume(N),de):de(N)}}function ew(n,r){let a=-1,u=!0,c=0,h=[0,0,0,0],f=[0,0,0,0],p=!1,m=0,d,b,y;const S=new I2;for(;++aa[2]+1){const T=a[2]+1,U=a[3]-a[2]-1;n.add(T,U,[])}}n.add(a[3]+1,0,[["exit",y,r]])}return c!==void 0&&(h.end=Object.assign({},ki(r.events,c)),n.add(c,0,[["exit",h,r]]),h=void 0),h}function Lm(n,r,a,u,c){const h=[],f=ki(r.events,a);c&&(c.end=Object.assign({},f),h.push(["exit",c,r])),u.end=Object.assign({},f),h.push(["exit",u,r]),n.add(a+1,0,h)}function ki(n,r){const a=n[r],u=a[0]==="enter"?"start":"end";return a[1][u]}const tw={name:"tasklistCheck",tokenize:lw};function nw(){return{text:{91:tw}}}function lw(n,r,a){const u=this;return c;function c(m){return u.previous!==null||!u._gfmTasklistFirstContentOfListItem?a(m):(n.enter("taskListCheck"),n.enter("taskListCheckMarker"),n.consume(m),n.exit("taskListCheckMarker"),h)}function h(m){return Ve(m)?(n.enter("taskListCheckValueUnchecked"),n.consume(m),n.exit("taskListCheckValueUnchecked"),f):m===88||m===120?(n.enter("taskListCheckValueChecked"),n.consume(m),n.exit("taskListCheckValueChecked"),f):a(m)}function f(m){return m===93?(n.enter("taskListCheckMarker"),n.consume(m),n.exit("taskListCheckMarker"),n.exit("taskListCheck"),p):a(m)}function p(m){return ce(m)?r(m):Ee(m)?n.check({tokenize:iw},r,a)(m):a(m)}}function iw(n,r,a){return _e(n,u,"whitespace");function u(c){return c===null?a(c):r(c)}}function aw(n){return lg([O2(),q2(),F2(n),W2(),nw()])}const rw={};function uw(n){const r=this,a=n||rw,u=r.data(),c=u.micromarkExtensions||(u.micromarkExtensions=[]),h=u.fromMarkdownExtensions||(u.fromMarkdownExtensions=[]),f=u.toMarkdownExtensions||(u.toMarkdownExtensions=[]);c.push(aw(a)),h.push(C2()),f.push(T2(a))}/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const ow=n=>n.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),Zg=(...n)=>n.filter((r,a,u)=>!!r&&u.indexOf(r)===a).join(" ");/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */var sw={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const cw=We.forwardRef(({color:n="currentColor",size:r=24,strokeWidth:a=2,absoluteStrokeWidth:u,className:c="",children:h,iconNode:f,...p},m)=>We.createElement("svg",{ref:m,...sw,width:r,height:r,stroke:n,strokeWidth:u?Number(a)*24/Number(r):a,className:Zg("lucide",c),...p},[...f.map(([d,b])=>We.createElement(d,b)),...Array.isArray(h)?h:[h]]));/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const ot=(n,r)=>{const a=We.forwardRef(({className:u,...c},h)=>We.createElement(cw,{ref:h,iconNode:r,className:Zg(`lucide-${ow(n)}`,u),...c}));return a.displayName=`${n}`,a};/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const hu=ot("ArrowUpRight",[["path",{d:"M7 7h10v10",key:"1tivn9"}],["path",{d:"M7 17 17 7",key:"1vkiza"}]]);/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Fg=ot("BookOpen",[["path",{d:"M12 7v14",key:"1akyts"}],["path",{d:"M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z",key:"ruj8y"}]]);/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const fw=ot("Bot",[["path",{d:"M12 8V4H8",key:"hb8ula"}],["rect",{width:"16",height:"12",x:"4",y:"8",rx:"2",key:"enze0r"}],["path",{d:"M2 14h2",key:"vft8re"}],["path",{d:"M20 14h2",key:"4cs60a"}],["path",{d:"M15 13v2",key:"1xurst"}],["path",{d:"M9 13v2",key:"rq6x2g"}]]);/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const hw=ot("Check",[["path",{d:"M20 6 9 17l-5-5",key:"1gmf2c"}]]);/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const dw=ot("ChevronLeft",[["path",{d:"m15 18-6-6 6-6",key:"1wnfg3"}]]);/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Um=ot("ChevronRight",[["path",{d:"m9 18 6-6-6-6",key:"mthhwq"}]]);/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const pw=ot("Copy",[["rect",{width:"14",height:"14",x:"8",y:"8",rx:"2",ry:"2",key:"17jyea"}],["path",{d:"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2",key:"zix9uf"}]]);/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const mw=ot("FlaskConical",[["path",{d:"M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45l-5.069-10.127A2 2 0 0 1 14 9.527V2",key:"pzvekw"}],["path",{d:"M8.5 2h7",key:"csnxdl"}],["path",{d:"M7 16h10",key:"wp8him"}]]);/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Bm=ot("GitBranch",[["line",{x1:"6",x2:"6",y1:"3",y2:"15",key:"17qcm7"}],["circle",{cx:"18",cy:"6",r:"3",key:"1h7g24"}],["circle",{cx:"6",cy:"18",r:"3",key:"fqmcym"}],["path",{d:"M18 9a9 9 0 0 1-9 9",key:"n2h4wq"}]]);/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const gw=ot("Layers",[["path",{d:"m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z",key:"8b97xw"}],["path",{d:"m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65",key:"dd6zsq"}],["path",{d:"m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65",key:"ep9fru"}]]);/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const yw=ot("Moon",[["path",{d:"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z",key:"a7tn18"}]]);/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const bw=ot("PackageCheck",[["path",{d:"m16 16 2 2 4-4",key:"gfu2re"}],["path",{d:"M21 10V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l2-1.14",key:"e7tb2h"}],["path",{d:"m7.5 4.27 9 5.15",key:"1c824w"}],["polyline",{points:"3.29 7 12 12 20.71 7",key:"ousv84"}],["line",{x1:"12",x2:"12",y1:"22",y2:"12",key:"a4e8g8"}]]);/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const vw=ot("Rocket",[["path",{d:"M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z",key:"m3kijz"}],["path",{d:"m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z",key:"1fmvmk"}],["path",{d:"M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0",key:"1f8sc4"}],["path",{d:"M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5",key:"qeys4"}]]);/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const xw=ot("Search",[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["path",{d:"m21 21-4.3-4.3",key:"1qie3q"}]]);/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Sw=ot("ShieldCheck",[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z",key:"oel41y"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]]);/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const kw=ot("Star",[["polygon",{points:"12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2",key:"8f66p6"}]]);/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const ww=ot("Sun",[["circle",{cx:"12",cy:"12",r:"4",key:"4exip2"}],["path",{d:"M12 2v2",key:"tus03m"}],["path",{d:"M12 20v2",key:"1lh1kg"}],["path",{d:"m4.93 4.93 1.41 1.41",key:"149t6j"}],["path",{d:"m17.66 17.66 1.41 1.41",key:"ptbguv"}],["path",{d:"M2 12h2",key:"1t8f8n"}],["path",{d:"M20 12h2",key:"1q8mjw"}],["path",{d:"m6.34 17.66-1.41 1.41",key:"1m8zz5"}],["path",{d:"m19.07 4.93-1.41 1.41",key:"1shlcs"}]]);/** - * @license lucide-react v0.453.0 - ISC - * - * This source code is licensed under the ISC license. - * See the LICENSE file in the root directory of this source tree. - */const Hm=ot("Terminal",[["polyline",{points:"4 17 10 11 4 5",key:"akl6gq"}],["line",{x1:"12",x2:"20",y1:"19",y2:"19",key:"q2wloq"}]]);function Ew(){if(typeof window>"u")return"light";try{const n=window.localStorage.getItem("docs-theme");if(n==="light"||n==="dark")return n}catch{}return"light"}function Aw(n){typeof document>"u"||(document.documentElement.classList.toggle("dark",n==="dark"),document.documentElement.style.colorScheme=n)}function Cw(){const[n,r]=We.useState(Ew);return We.useEffect(()=>{Aw(n);try{window.localStorage.setItem("docs-theme",n)}catch{}},[n]),{theme:n,toggle:()=>r(u=>u==="dark"?"light":"dark")}}const an='"JetBrains Mono", "SF Mono", ui-monospace, monospace',Wt='"Computer Modern Concrete", "Concrete Roman", Georgia, "Times New Roman", serif',uu=[{key:"openscience",label:"OpenScience",short:"OpenScience",tagline:"Open-source AI workbench",lead:!1}],Uc=uu.map(n=>n.key),Tw=Object.assign({"./content/openscience/agents.mdx":kb,"./content/openscience/atlas.mdx":wb,"./content/openscience/commands.mdx":Eb,"./content/openscience/index.mdx":Ab,"./content/openscience/models.mdx":Cb,"./content/openscience/quickstart.mdx":Tb,"./content/openscience/security.mdx":zb,"./content/openscience/sessions.mdx":_b,"./content/openscience/skills.mdx":Ob,"./content/openscience/workspace.mdx":Mb}),zw=Object.assign({"./content/openscience/docs.json":Lb}),qm={index:H.jsx(Bm,{size:17,strokeWidth:1.8}),quickstart:H.jsx(vw,{size:17,strokeWidth:1.8}),workspace:H.jsx(gw,{size:17,strokeWidth:1.8}),agents:H.jsx(fw,{size:17,strokeWidth:1.8}),models:H.jsx(bw,{size:17,strokeWidth:1.8}),skills:H.jsx(Fg,{size:17,strokeWidth:1.8}),sessions:H.jsx(Hm,{size:17,strokeWidth:1.8}),atlas:H.jsx(Bm,{size:17,strokeWidth:1.8}),commands:H.jsx(Hm,{size:17,strokeWidth:1.8}),security:H.jsx(Sw,{size:17,strokeWidth:1.8})},_w={openscience:H.jsx(mw,{size:17,strokeWidth:1.8})},Ow=/^---\n([\s\S]*?)\n---\n?/;function Mw(n){const r=n.match(Ow);if(!r)return{title:"Untitled",description:"",body:n};const a=r[1],u=n.slice(r[0].length),c=h=>{const f=a.split(` -`).find(p=>p.trim().startsWith(`${h}:`));return f?f.split(":").slice(1).join(":").trim().replace(/^["']|["']$/g,""):""};return{title:c("title")||"Untitled",description:c("description"),body:u}}function Dw(n){return n.split(` -`).filter(r=>r.startsWith("## ")).map(r=>r.replace(/^##\s+/,"").trim()).slice(0,10)}function Si(n){return n.flatMap(r=>typeof r=="string"?[r]:r.pages)}function Rw(n,r){const a=n.split("/").pop()??n;return qm[n]??qm[a]??_w[r]}function Nw(n){const r=`./content/${n}/`,a={};for(const[u,c]of Object.entries(Tw)){if(!u.startsWith(r))continue;const h=u.slice(r.length).replace(/\.(mdx|md)$/,""),f=Mw(c);a[h]={path:h,title:f.title,description:f.description,icon:Rw(h,n),body:f.body,headings:Dw(f.body)}}return a}const za={openscience:Nw("openscience")},Ym={openscience:zw["./content/openscience/docs.json"]};function _a(n,r){var a;return!!((a=za[n])!=null&&a[r])}const jw={"agent-cli":"openscience"},Lw={"first-session":"sessions","sub-agents":"agents","web-ui":"workspace",credentials:"atlas"},Uw={"cli:index":{section:"openscience",path:"index"},"cli:installation":{section:"openscience",path:"quickstart"},"cli:quickstart":{section:"openscience",path:"quickstart"},"cli:first-session":{section:"openscience",path:"sessions"},"cli:sessions":{section:"openscience",path:"sessions"},"cli:models":{section:"openscience",path:"models"},"cli:codex":{section:"openscience",path:"models"},"cli:sub-agents":{section:"openscience",path:"agents"},"cli:skills":{section:"openscience",path:"skills"},"cli:cli-runtime":{section:"openscience",path:"commands"},"cli:connect":{section:"openscience",path:"atlas"},"cli:credentials":{section:"openscience",path:"atlas"},"cli:security":{section:"openscience",path:"security"},"cli:feature-map":{section:"openscience",path:"commands"},"cli:commands":{section:"openscience",path:"commands"},"cli:web-ui":{section:"openscience",path:"workspace"},"cli:server-mode":{section:"openscience",path:"workspace"}};function ic(){return{section:"openscience",path:"index"}}function Gm(){if(typeof window>"u")return ic();const n=decodeURIComponent(window.location.hash.replace(/^#\/?/,"")).replace(/\/$/,"");if(!n)return ic();const r=n.split("/"),a=r[0];if(Uc.includes(a)){const h=r.slice(1).join("/")||"index";return _a(a,h)?{section:a,path:h}:{section:a,path:"index"}}const u=jw[r[0]];if(u){const h=r.slice(1).join("/")||"index",f=Lw[h]??h;return _a(u,f)?{section:u,path:f}:{section:u,path:"index"}}const c=Uw[`cli:${n}`];return c&&_a(c.section,c.path)?c:ic()}function rn(n,r){return`#/${n}/${r}`}let Oa="openscience";function Bc(n){if(!n||n.startsWith("http")||n.startsWith("#")||n.startsWith("mailto:"))return n;if(n.startsWith("/")){const r=n.slice(1).replace(/\/$/,"");if(!r)return rn(Oa,"index");const a=r.split("/"),u=a[0];if(Uc.includes(u)){const c=a.slice(1).join("/")||"index";if(_a(u,c))return rn(u,c)}if(_a(Oa,r))return rn(Oa,r)}return n}function Bw(n){const u=n.replace(/^#\/?/,"").replace(/\/$/,"").split("/")[0];return Uc.includes(u)?u:Oa}function Hc(n){const r={};for(const a of n.matchAll(/([\w-]+)(?:=(?:"([^"]*)"|'([^']*)'|\{([^}]*)\}))?/g)){const u=a[1];u&&(r[u]=a[2]??a[3]??a[4]??!0)}return r}function qc(n){const r=n.replace(/\t/g," ").split(` -`);let a=1/0;for(const u of r){if(u.trim()==="")continue;const c=u.match(/^( *)/);c&&(a=Math.min(a,c[1].length))}return!Number.isFinite(a)||a===0?n:r.map(u=>u.length>=a?u.slice(a):u).join(` -`)}function Ig(n){return Array.from(n.matchAll(/]*)>\s*([\s\S]*?)\s*<\/Card>/g)).map(r=>{const a=Hc(r[1]??"");return{title:String(a.title??"Untitled"),href:String(a.href??"#"),icon:a.icon?String(a.icon):void 0,horizontal:!!a.horizontal,body:qc(r[2]??"").trim()}})}function Hw(n){var a;const r=Bc(n.href);if(r&&r.startsWith("#/")){const u=Bw(r),c=r.replace(/^#\/?/,"").replace(/\/$/,"").split("/").slice(1).join("/"),h=(a=za[u])==null?void 0:a[c];if(h)return h.icon}return H.jsx(Fg,{size:17,strokeWidth:1.8})}const au="synthetic-sciences/openscience";function qw(n){return n>=1e3?`${(n/1e3).toFixed(1).replace(/\.0$/,"")}k`:String(n)}function Yw(){const[n,r]=We.useState(null);return We.useEffect(()=>{let a=!1;const u=`docs-gh-stars:${au}`;try{const c=JSON.parse(window.localStorage.getItem(u)??"null");if(c&&Date.now()-c.at<3600*1e3){r(c.stars);return}}catch{}return fetch(`https://api.github.com/repos/${au}`).then(c=>c.ok?c.json():null).then(c=>{const h=c==null?void 0:c.stargazers_count;if(!(typeof h!="number"||a)){r(h);try{window.localStorage.setItem(u,JSON.stringify({stars:h,at:Date.now()}))}catch{}}}).catch(()=>{}),()=>{a=!0}},[]),H.jsxs("div",{className:"docs-ghstars",children:[H.jsxs("a",{className:"docs-ghstars-primary",href:`https://github.com/${au}`,target:"_blank",rel:"noreferrer",children:[H.jsx(kw,{size:13,strokeWidth:1.8}),H.jsx("span",{children:"Star on GitHub"}),n!==null?H.jsx("em",{children:qw(n)}):null]}),H.jsx("a",{href:`https://github.com/${au}/blob/main/LICENSE`,target:"_blank",rel:"noreferrer",children:"Apache-2.0"}),H.jsx("a",{href:"https://www.npmjs.com/package/@synsci/openscience",target:"_blank",rel:"noreferrer",children:"npm · @synsci/openscience"})]})}function Gw({text:n}){const[r,a]=We.useState(!1);return H.jsxs("button",{type:"button",className:"docs-copy",onClick:()=>{navigator.clipboard.writeText(n),a(!0),window.setTimeout(()=>a(!1),1200)},"aria-label":"copy code",title:"copy code",children:[r?H.jsx(hw,{size:13,strokeWidth:1.8}):H.jsx(pw,{size:13,strokeWidth:1.8}),H.jsx("span",{children:r?"copied":"copy"})]})}const Vw={h2({children:n}){const r=String(n).toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");return H.jsx("h2",{id:r,children:n})},a({href:n,children:r}){const a=n==null?void 0:n.startsWith("http"),u=n?Bc(n):void 0;return H.jsxs("a",{href:u,target:a?"_blank":void 0,rel:a?"noreferrer":void 0,children:[r,a?H.jsx(hu,{size:12,strokeWidth:1.8}):null]})},pre({children:n}){const r=gc(n);return H.jsxs("div",{className:"docs-code-wrap",children:[H.jsx(Gw,{text:r}),H.jsx("pre",{children:n})]})},code({className:n,children:r}){const a=n==null?void 0:n.startsWith("language-");return H.jsx("code",{className:a?n:"docs-inline-code",children:r})},table({children:n}){return H.jsx("div",{className:"docs-table-wrap",children:H.jsx("table",{children:n})})},blockquote({children:n}){return H.jsx("blockquote",{className:"docs-callout",children:n})}};function gc(n){if(typeof n=="string")return n;if(Array.isArray(n))return n.map(gc).join("");if(n&&typeof n=="object"&&"props"in n){const r=n.props;return gc((r==null?void 0:r.children)??"")}return""}function du({children:n}){return n.trim()?H.jsx(FS,{remarkPlugins:[uw],components:Vw,children:n}):null}function Jg({card:n}){const r=n.href.startsWith("http");return H.jsxs("a",{className:n.horizontal?"docs-card docs-card-horizontal":"docs-card",href:Bc(n.href),target:r?"_blank":void 0,rel:r?"noreferrer":void 0,children:[H.jsx("span",{className:"docs-card-icon",children:Hw(n)}),H.jsxs("span",{className:"docs-card-copy",children:[H.jsx("strong",{children:n.title}),H.jsx("small",{children:n.body})]}),H.jsx(hu,{size:14,strokeWidth:1.8})]})}function Xw({source:n,cols:r}){const a=Ig(n);return a.length===0?null:H.jsx("div",{className:"docs-card-grid",style:{"--docs-card-cols":String(r)},children:a.map(u=>H.jsx(Jg,{card:u},`${u.title}-${u.href}`))})}function Qw({source:n}){const r=Array.from(n.matchAll(/]*)>\s*([\s\S]*?)\s*<\/Step>/g)).map(a=>{const u=Hc(a[1]??"");return{title:String(u.title??"Step"),body:qc(a[2]??"").trim()}});return r.length===0?null:H.jsx("div",{className:"docs-step-list",children:r.map((a,u)=>H.jsxs("section",{className:"docs-step",children:[H.jsx("span",{children:u+1}),H.jsxs("div",{children:[H.jsx("h3",{children:a.title}),H.jsx(du,{children:a.body})]})]},`${a.title}-${u}`))})}function Kw({children:n}){return H.jsx("blockquote",{className:"docs-callout docs-callout-warning",children:H.jsx(du,{children:qc(n).trim()})})}function Zw(n){const r=[],a=/<(Columns|CardGroup)\b([^>]*)>\s*([\s\S]*?)\s*<\/\1>|]*)>\s*([\s\S]*?)\s*<\/Card>|\s*([\s\S]*?)\s*<\/Steps>|\s*([\s\S]*?)\s*<\/Warning>|/g;let u=0,c=0;for(const f of n.matchAll(a)){const p=f.index??0,m=n.slice(u,p);if(m.trim()&&r.push(H.jsx(du,{children:m},`md-${c++}`)),f[1]){const d=Hc(f[2]??""),b=Number(d.cols??2);r.push(H.jsx(Xw,{source:f[3]??"",cols:Number.isFinite(b)&&b>0?b:2},`cards-${c++}`))}else if(f[4]!==void 0){const d=Ig(`${f[5]??""}`)[0];d&&r.push(H.jsx(Jg,{card:d},`card-${c++}`))}else f[6]!==void 0?r.push(H.jsx(Qw,{source:f[6]??""},`steps-${c++}`)):f[7]!==void 0?r.push(H.jsx(Kw,{children:f[7]??""},`warning-${c++}`)):f[0].startsWith("Gm()),c=a.section;Oa=c;const h=za[c],f=Ym[c],p=uu.find(B=>B.key===c)??uu[0],m=h[a.path]??h.index,[d,b]=We.useState(""),[y,S]=We.useState(!1),x=f.navigation.tabs,T=We.useMemo(()=>x.flatMap(B=>B.groups.flatMap(le=>Si(le.pages))).filter(B=>h[B]),[x,h]),U=We.useMemo(()=>x.find(B=>B.groups.some(le=>Si(le.pages).includes(m.path)))??x[0],[m.path,x]),K=We.useMemo(()=>U==null?void 0:U.groups.find(B=>Si(B.pages).includes(m.path)),[m.path,U]),D=T.indexOf(m.path),F=D>0?h[T[D-1]]:null,Q=D>=0&&D{const B=uu.flatMap(J=>Ym[J.key].navigation.tabs.flatMap(_=>_.groups.flatMap(Z=>Si(Z.pages))).map(_=>za[J.key][_]).filter(Boolean).map(_=>({path:_.path,title:_.title,description:_.description,icon:_.icon,section:J.key,sectionLabel:J.label}))),le=d.trim().toLowerCase();return le?B.filter(J=>{var Z;const $=((Z=za[J.section][J.path])==null?void 0:Z.body)??"";return`${J.title} ${J.description} ${J.sectionLabel} ${$}`.toLowerCase().includes(le)}).slice(0,8):B.filter(J=>J.section===c).slice(0,6)},[d,c]),re=B=>{window.location.hash=rn(B.section,B.path),u(B)};return We.useEffect(()=>{const B=()=>u(Gm());return window.addEventListener("hashchange",B),()=>window.removeEventListener("hashchange",B)},[]),We.useEffect(()=>{const B=rn(a.section,a.path);window.location.hash!==B&&window.history.replaceState(null,"",B)},[a.section,a.path]),We.useEffect(()=>{const B=le=>{var J;(le.metaKey||le.ctrlKey)&&le.key.toLowerCase()==="k"&&(le.preventDefault(),S(!0),(J=document.querySelector(".docs-search-input"))==null||J.focus())};return window.addEventListener("keydown",B),()=>window.removeEventListener("keydown",B)},[]),H.jsxs("div",{className:"docs-page",children:[H.jsxs("header",{className:"docs-topbar",children:[H.jsxs("a",{href:"https://openscience.sh",className:"docs-brand",children:[H.jsx("img",{src:"/docs/favicon.svg",alt:""}),H.jsxs("span",{className:"docs-brand-text",children:[H.jsx("small",{children:"OpenScience"}),H.jsx("strong",{children:"Docs"})]})]}),H.jsxs("div",{className:"docs-search",role:"search",children:[H.jsx(xw,{size:14,strokeWidth:1.8}),H.jsx("input",{className:"docs-search-input","aria-label":"Search documentation",value:d,onBlur:()=>window.setTimeout(()=>S(!1),120),onChange:B=>{b(B.target.value),S(!0)},onFocus:()=>S(!0),placeholder:"Search all docs...",type:"search"}),H.jsx("kbd",{children:"⌘K"}),y?H.jsx("div",{className:"docs-search-results",role:"listbox","aria-label":"documentation search results",children:ue.length>0?ue.map(B=>H.jsxs("a",{href:rn(B.section,B.path),role:"option","aria-selected":c===B.section&&a.path===B.path,onMouseDown:le=>{le.preventDefault(),re({section:B.section,path:B.path}),b(""),S(!1)},children:[H.jsx("span",{children:B.icon}),H.jsx("strong",{children:B.title}),H.jsx("small",{children:B.sectionLabel})]},`${B.section}/${B.path}`)):H.jsx("span",{className:"docs-search-empty",children:"No docs match that query."})}):null]}),H.jsxs("nav",{className:"docs-actions","aria-label":"documentation actions",children:[H.jsxs("button",{type:"button",className:"docs-theme-toggle",onClick:r,"aria-label":n==="dark"?"switch to light mode":"switch to dark mode",title:n==="dark"?"light mode":"dark mode",children:[n==="dark"?H.jsx(ww,{size:14,strokeWidth:1.8}):H.jsx(yw,{size:14,strokeWidth:1.8}),H.jsx("span",{children:n==="dark"?"light":"dark"})]}),H.jsxs("a",{className:"docs-topbar-cta",href:((P=(L=f.navbar)==null?void 0:L.primary)==null?void 0:P.href)??"https://github.com/synthetic-sciences/openscience",children:[(((me=(de=f.navbar)==null?void 0:de.primary)==null?void 0:me.label)??"Star on GitHub").toLowerCase(),H.jsx(hu,{size:13,strokeWidth:1.8})]})]})]}),H.jsxs("div",{className:"docs-shell",children:[H.jsxs("aside",{className:"docs-sidebar","aria-label":"documentation navigation",children:[H.jsxs("div",{className:"docs-sidebar-title",children:[H.jsx("span",{children:p.label}),H.jsx("small",{children:p.tagline})]}),x.length>1?H.jsx("nav",{className:"docs-section-tabs","aria-label":"documentation sections",children:x.map(B=>{const le=B.groups.flatMap(J=>Si(J.pages)).find(J=>h[J]);return le?H.jsx("a",{className:(U==null?void 0:U.tab)===B.tab?"active":void 0,href:rn(c,le),onClick:()=>re({section:c,path:le}),children:B.tab},B.tab):null})}):null,U?H.jsx("div",{children:U.groups.map(B=>H.jsxs("div",{className:"docs-sidebar-group",children:[H.jsx("span",{children:B.group}),Si(B.pages).map(le=>{const J=h[le];return J?H.jsxs("a",{href:rn(c,le),className:a.path===le?"active":void 0,onClick:()=>re({section:c,path:le}),children:[H.jsx("span",{children:J.icon}),J.title]},le):null})]},B.group))},U.tab):null]}),H.jsxs("main",{className:"docs-main",children:[H.jsxs("nav",{className:"docs-breadcrumbs","aria-label":"breadcrumbs",children:[H.jsx("a",{href:rn(c,"index"),children:p.label}),H.jsx(Um,{size:13,strokeWidth:1.8}),K?H.jsx("span",{children:K.group}):null]}),H.jsxs("section",{className:"docs-hero",children:[H.jsx("h1",{children:m.title}),m.description?H.jsx("p",{children:m.description}):null]}),H.jsx("article",{className:"docs-markdown",children:Zw(m.body)}),H.jsxs("nav",{className:"docs-pagination","aria-label":"documentation pagination",children:[F?H.jsxs("a",{href:rn(c,F.path),onClick:()=>re({section:c,path:F.path}),children:[H.jsx(dw,{size:16,strokeWidth:1.8}),H.jsxs("span",{children:[H.jsx("small",{children:"Previous"}),F.title]})]}):H.jsx("span",{}),Q?H.jsxs("a",{href:rn(c,Q.path),onClick:()=>re({section:c,path:Q.path}),children:[H.jsxs("span",{children:[H.jsx("small",{children:"Next"}),Q.title]}),H.jsx(Um,{size:16,strokeWidth:1.8})]}):H.jsx("span",{})]})]}),H.jsxs("aside",{className:"docs-toc","aria-label":"on this page",children:[H.jsx("span",{children:"On this page"}),m.headings.length>0?m.headings.map(B=>H.jsx("a",{href:`#${B.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"")}`,children:B},B)):H.jsx("span",{className:"docs-toc-empty",children:"No sections"}),(((N=f.navigation.global)==null?void 0:N.anchors)??[]).length>0?H.jsxs("div",{className:"docs-agent-links",children:[H.jsx("span",{children:"Agent resources"}),(((te=f.navigation.global)==null?void 0:te.anchors)??[]).map(B=>H.jsxs("a",{href:B.href,target:B.href.startsWith("http")?"_blank":void 0,rel:"noreferrer",children:[B.anchor,H.jsx(hu,{size:11,strokeWidth:1.8})]},B.href))]}):null]})]}),H.jsx("style",{children:Iw})]})}const Iw=` - .docs-page { - --color-bg: #fafbfc; - --color-bg-subtle: #f1f3f5; - --color-bg-elevated: #ffffff; - --color-border: rgba(15, 23, 42, 0.10); - --color-text: #0f172a; - --color-text-muted: #475569; - --color-text-faint: #94a3b8; - --docs-accent: #2f6f54; - min-height: 100dvh; - color: var(--color-text); - background: var(--color-bg); - font-family: ${Wt}; - font-feature-settings: "kern", "liga"; - } - - /* No italics anywhere - the font family ships regular and bold only. */ - .docs-page em, - .docs-page i, - .docs-page cite, - .docs-page dfn, - .docs-page address { - font-style: normal; - } - - .dark .docs-page { - --color-bg: #0a0a0b; - --color-bg-subtle: #141417; - --color-bg-elevated: #1c1c20; - --color-border: rgba(255, 255, 255, 0.10); - --color-text: #f1f5f9; - --color-text-muted: #b8bbc4; - --color-text-faint: #6c7280; - --docs-accent: #9bd6b4; - } - - .docs-topbar { - height: 60px; - display: grid; - grid-template-columns: minmax(200px, 1fr) minmax(240px, 520px) minmax(200px, 1fr); - align-items: center; - gap: 20px; - padding: 0 28px; - border-bottom: 1px solid var(--color-border); - background: color-mix(in srgb, var(--color-bg) 94%, transparent); - backdrop-filter: blur(14px); - position: sticky; - top: 0; - z-index: 30; - } - - .docs-topbar nav, - .docs-topbar nav a, - .docs-search, - .docs-copy, - .docs-markdown a { - display: flex; - align-items: center; - } - - .docs-brand { - display: inline-flex; - align-items: center; - gap: 12px; - color: var(--color-text); - text-decoration: none; - min-width: 0; - padding: 4px 6px; - border-radius: 6px; - transition: background 120ms ease; - } - - .docs-brand:hover { - background: var(--color-bg-elevated); - } - - .docs-brand img { - width: 28px; - height: 28px; - flex-shrink: 0; - } - - .docs-brand-text { - display: flex; - flex-direction: column; - gap: 1px; - min-width: 0; - line-height: 1.1; - } - - .docs-brand-text small { - font-family: ${an}; - font-size: 9.5px; - font-weight: 500; - letter-spacing: 0.12em; - text-transform: uppercase; - color: var(--color-text-faint); - } - - .docs-brand-text strong { - font-family: ${Wt}; - font-size: 15px; - font-weight: 400; - letter-spacing: 0; - color: var(--color-text); - } - - .docs-search { - position: relative; - height: 34px; - gap: 9px; - border: 1px solid var(--color-border); - border-radius: 6px; - background: var(--color-bg-elevated); - padding: 0 8px 0 12px; - color: var(--color-text-faint); - transition: border-color 120ms ease, background 120ms ease; - } - - .docs-search:focus-within { - border-color: var(--color-text-faint); - background: var(--color-bg); - } - - .docs-search input { - min-width: 0; - flex: 1; - border: 0; - outline: 0; - background: transparent; - color: var(--color-text); - font-family: ${Wt}; - font-size: 14px; - } - - .docs-search input::placeholder { - color: var(--color-text-faint); - font-family: ${Wt}; - } - - .docs-search kbd { - min-width: 32px; - height: 20px; - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid var(--color-border); - border-radius: 4px; - background: var(--color-bg); - color: var(--color-text-faint); - font-family: ${an}; - font-size: 10.5px; - font-weight: 500; - flex-shrink: 0; - } - - .docs-search-results { - position: absolute; - top: calc(100% + 8px); - left: 0; - right: 0; - display: flex; - flex-direction: column; - gap: 3px; - padding: 7px; - border: 1px solid var(--color-border); - border-radius: 8px; - background: var(--color-bg-elevated); - box-shadow: 0 18px 48px rgba(0, 0, 0, 0.12); - z-index: 50; - } - - .docs-search-results a { - display: grid; - grid-template-columns: 24px minmax(0, 1fr); - gap: 1px 8px; - align-items: center; - padding: 8px; - border-radius: 7px; - color: var(--color-text); - text-decoration: none; - } - - .docs-search-results a:hover { - background: var(--color-bg-subtle); - } - - .docs-search-results a > span { - grid-row: span 2; - color: var(--color-text-faint); - } - - .docs-search-results strong { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 13px; - font-weight: 700; - } - - .docs-search-results small, - .docs-search-empty { - overflow: hidden; - color: var(--color-text-muted); - text-overflow: ellipsis; - white-space: nowrap; - font-size: 12px; - } - - .docs-search-empty { - padding: 10px; - } - - .docs-topbar nav { - gap: 8px; - justify-content: flex-end; - } - - .docs-actions { - justify-content: flex-end; - gap: 8px; - } - - .docs-topbar nav a { - gap: 6px; - height: 34px; - padding: 0 14px; - border-radius: 6px; - color: var(--color-text-muted); - text-decoration: none; - font-family: ${Wt}; - font-size: 14px; - font-weight: 400; - border: 1px solid transparent; - transition: background 120ms ease, color 120ms ease, border-color 120ms ease; - } - - .docs-topbar nav a:hover { - color: var(--color-text); - background: var(--color-bg-elevated); - border-color: var(--color-border); - } - - .docs-topbar-cta { - color: var(--color-text) !important; - border-color: var(--color-border) !important; - background: var(--color-bg-elevated); - } - - .docs-theme-toggle { - display: inline-flex; - align-items: center; - gap: 6px; - height: 34px; - padding: 0 12px; - border-radius: 6px; - border: 1px solid transparent; - background: transparent; - color: var(--color-text-muted); - font-family: ${Wt}; - font-size: 14px; - font-weight: 400; - line-height: 1; - cursor: pointer; - transition: background 120ms ease, color 120ms ease, border-color 120ms ease; - } - - .docs-theme-toggle:hover { - color: var(--color-text); - background: var(--color-bg-elevated); - border-color: var(--color-border); - } - - .docs-theme-toggle:focus-visible { - outline: 2px solid var(--docs-accent); - outline-offset: 2px; - } - - .docs-shell { - display: grid; - grid-template-columns: 236px minmax(0, 760px) 176px; - gap: 34px; - max-width: 1240px; - margin: 0 auto; - padding: 30px 28px 84px; - } - - .docs-sidebar, - .docs-toc { - position: sticky; - top: 84px; - align-self: start; - max-height: calc(100dvh - 104px); - overflow: auto; - } - - .docs-sidebar { - padding-right: 4px; - } - - .docs-sidebar-title { - display: flex; - flex-direction: column; - gap: 2px; - margin: 0 0 14px 4px; - } - - .docs-sidebar-title span { - font-size: 13px; - font-weight: 700; - } - - .docs-sidebar-title small { - color: var(--color-text-faint); - font-family: ${an}; - font-size: 11px; - } - - .docs-section-tabs { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 4px; - margin: 0 0 20px; - padding: 3px; - border: 1px solid var(--color-border); - border-radius: 8px; - background: var(--color-bg-subtle); - } - - .docs-section-tabs a { - display: flex; - min-height: 28px; - align-items: center; - justify-content: center; - border-radius: 6px; - color: var(--color-text-muted); - text-decoration: none; - font-size: 12px; - } - - .docs-section-tabs a.active { - color: var(--color-text); - background: var(--color-bg-elevated); - box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03); - } - - .docs-sidebar-group { - display: flex; - flex-direction: column; - gap: 2px; - margin-bottom: 20px; - } - - .docs-sidebar-group > span, - .docs-toc > span, - .docs-toc-empty, - .docs-copy { - font-family: ${an}; - font-size: 11px; - letter-spacing: 0; - } - - .docs-sidebar-group > span, - .docs-toc > span, - .docs-toc-empty { - color: var(--color-text-faint); - } - - .docs-sidebar-group > span { - margin: 0 0 7px 4px; - text-transform: uppercase; - } - - .docs-sidebar a, - .docs-toc a { - color: var(--color-text-muted); - text-decoration: none; - font-size: 13px; - line-height: 1.45; - } - - .docs-sidebar a { - display: flex; - align-items: center; - gap: 9px; - min-height: 30px; - padding: 0 6px; - border-radius: 6px; - } - - .docs-sidebar a span { - color: var(--color-text-faint); - line-height: 0; - } - - .docs-sidebar a:hover { - color: var(--color-text); - background: var(--color-bg-subtle); - } - - .docs-sidebar a.active { - color: var(--color-text); - background: color-mix(in srgb, var(--color-bg-subtle) 82%, var(--docs-accent) 8%); - font-weight: 700; - } - - .docs-sidebar a.active span { - color: var(--color-text); - } - - .docs-main { - min-width: 0; - } - - .docs-breadcrumbs { - display: flex; - align-items: center; - gap: 7px; - margin: 2px 0 16px; - color: var(--color-text-faint); - font-size: 13px; - } - - .docs-breadcrumbs a { - color: inherit; - text-decoration: none; - } - - .docs-breadcrumbs a:hover { - color: var(--color-text); - } - - .docs-hero { - padding: 0 0 24px; - border-bottom: 1px solid var(--color-border); - margin-bottom: 30px; - } - - .docs-hero h1 { - margin: 0; - font-family: ${Wt}; - font-size: 40px; - line-height: 1.08; - font-weight: 700; - letter-spacing: -0.005em; - color: var(--color-text); - } - - .docs-hero p { - max-width: 680px; - margin: 12px 0 0; - font-family: ${Wt}; - color: var(--color-text-muted); - font-size: 17px; - line-height: 1.55; - } - - .docs-markdown { - color: var(--color-text); - font-family: ${Wt}; - } - - .docs-markdown > *:first-child { - margin-top: 0; - } - - .docs-markdown p, - .docs-markdown li, - .docs-markdown td { - color: var(--color-text-muted); - font-family: ${Wt}; - font-size: 16px; - line-height: 1.7; - font-feature-settings: "kern", "liga", "onum"; - } - - .docs-markdown p { - margin: 0 0 16px; - } - - .docs-markdown h2 { - margin: 36px 0 12px; - padding-top: 6px; - font-family: ${Wt}; - font-size: 24px; - line-height: 1.2; - font-weight: 700; - letter-spacing: 0; - color: var(--color-text); - scroll-margin-top: 84px; - } - - .docs-markdown h3 { - margin: 26px 0 10px; - font-family: ${Wt}; - font-size: 18px; - line-height: 1.28; - font-weight: 700; - letter-spacing: 0; - color: var(--color-text); - } - - .docs-markdown strong { - font-weight: 700; - color: var(--color-text); - } - - .docs-markdown em { - font-style: normal; - color: var(--color-text); - font-weight: 700; - } - - .docs-markdown ul, - .docs-markdown ol { - margin: 0 0 18px; - padding-left: 20px; - } - - .docs-markdown a { - display: inline-flex; - gap: 5px; - color: var(--color-text); - text-decoration: underline; - text-decoration-color: var(--color-text-faint); - text-underline-offset: 3px; - } - - .docs-ghstars { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; - margin: 2px 0 26px; - } - - .docs-ghstars a { - display: inline-flex; - align-items: center; - gap: 7px; - height: 30px; - padding: 0 13px; - border: 1px solid var(--color-border); - border-radius: 999px; - background: var(--color-bg-elevated); - color: var(--color-text-muted); - text-decoration: none; - font-family: ${an}; - font-size: 12px; - transition: border-color 120ms ease, color 120ms ease, background 120ms ease; - } - - .docs-ghstars a:hover { - color: var(--color-text); - border-color: color-mix(in srgb, var(--docs-accent) 44%, var(--color-border)); - } - - .docs-ghstars-primary { - color: var(--color-text) !important; - font-weight: 500; - } - - .docs-ghstars-primary em { - font-style: normal; - font-weight: 700; - padding-left: 8px; - border-left: 1px solid var(--color-border); - color: var(--docs-accent); - } - - .docs-card-grid { - display: grid; - grid-template-columns: repeat(var(--docs-card-cols, 2), minmax(0, 1fr)); - gap: 10px; - margin: 18px 0 26px; - } - - .docs-card { - position: relative; - display: grid !important; - grid-template-columns: minmax(0, 1fr) 14px; - gap: 10px; - align-items: start !important; - min-height: 96px; - padding: 15px; - border: 1px solid var(--color-border); - border-radius: 8px; - background: var(--color-bg-elevated); - color: var(--color-text) !important; - text-decoration: none !important; - box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02); - } - - .docs-card:hover { - border-color: color-mix(in srgb, var(--docs-accent) 34%, var(--color-border)); - background: var(--color-bg-subtle); - } - - .docs-card-horizontal { - min-height: 78px; - } - - .docs-card-icon { - display: none; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - border: 1px solid var(--color-border); - border-radius: 8px; - color: var(--color-text); - background: var(--color-bg); - } - - .docs-card-copy { - display: flex; - flex-direction: column; - gap: 7px; - min-width: 0; - } - - .docs-card-copy strong { - font-size: 13.5px; - line-height: 1.25; - font-weight: 700; - } - - .docs-card-copy small { - color: var(--color-text-muted); - font-size: 12.75px; - line-height: 1.55; - } - - .docs-code-wrap { - position: relative; - margin: 16px 0 22px; - overflow: hidden; - border: 1px solid var(--color-border); - border-radius: 8px; - background: #11150f; - } - - .docs-code-wrap pre { - margin: 0; - padding: 18px 16px; - overflow: auto; - font-family: ${an}; - font-size: 12px; - line-height: 1.72; - color: #eef4ee; - } - - .docs-copy { - position: absolute; - top: 8px; - right: 8px; - gap: 6px; - min-height: 25px; - padding: 0 8px; - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 6px; - background: rgba(255, 255, 255, 0.06); - color: rgba(238, 244, 238, 0.78); - cursor: pointer; - } - - .docs-inline-code { - font-family: ${an}; - font-size: 12px; - border: 1px solid var(--color-border); - background: var(--color-bg-subtle); - border-radius: 5px; - color: var(--color-text); - padding: 1px 5px; - } - - .docs-table-wrap { - overflow: auto; - border: 1px solid var(--color-border); - border-radius: 8px; - margin: 16px 0 22px; - } - - .docs-table-wrap table { - width: 100%; - border-collapse: collapse; - min-width: 560px; - } - - .docs-table-wrap th, - .docs-table-wrap td { - padding: 10px 13px; - border-bottom: 1px solid var(--color-border); - text-align: left; - vertical-align: top; - } - - .docs-table-wrap th { - font-family: ${an}; - font-size: 11px; - color: var(--color-text-faint); - background: var(--color-bg-subtle); - } - - .docs-callout { - margin: 18px 0; - padding: 14px 16px; - border: 1px solid rgba(164, 120, 48, 0.28); - border-left: 3px solid rgba(164, 120, 48, 0.64); - border-radius: 7px; - background: color-mix(in srgb, var(--color-bg-subtle) 74%, rgba(164, 120, 48, 0.12)); - } - - .docs-callout p { - margin: 0; - color: var(--color-text); - } - - .docs-callout .docs-markdown p { - margin: 0; - } - - .docs-step-list { - display: flex; - flex-direction: column; - gap: 10px; - margin: 18px 0 28px; - } - - .docs-step { - display: grid; - grid-template-columns: 30px minmax(0, 1fr); - gap: 13px; - padding: 15px; - border: 1px solid var(--color-border); - border-radius: 8px; - background: var(--color-bg-elevated); - } - - .docs-step > span { - width: 27px; - height: 27px; - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid var(--color-border); - border-radius: 999px; - background: var(--color-bg-subtle); - color: var(--color-text); - font-family: ${an}; - font-size: 12px; - font-weight: 700; - } - - .docs-step h3 { - margin: 2px 0 8px; - } - - .docs-toc { - display: flex; - flex-direction: column; - gap: 7px; - padding-left: 4px; - } - - .docs-toc > span { - margin-bottom: 4px; - } - - .docs-toc a { - line-height: 1.45; - } - - .docs-agent-links { - display: flex; - flex-direction: column; - gap: 7px; - margin-top: 20px; - padding-top: 14px; - border-top: 1px solid var(--color-border); - } - - .docs-agent-links > span { - font-family: ${an}; - font-size: 11px; - color: var(--color-text-faint); - } - - .docs-agent-links a { - display: inline-flex; - gap: 5px; - align-items: center; - } - - .docs-pagination { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; - margin-top: 48px; - padding-top: 24px; - border-top: 1px solid var(--color-border); - } - - .docs-pagination a { - display: flex; - align-items: center; - gap: 10px; - min-height: 68px; - padding: 13px 14px; - border: 1px solid var(--color-border); - border-radius: 8px; - color: var(--color-text); - text-decoration: none; - background: var(--color-bg); - } - - .docs-pagination a:hover { - background: var(--color-bg-subtle); - } - - .docs-pagination a:last-child { - justify-content: flex-end; - text-align: right; - } - - .docs-pagination small { - display: block; - margin-bottom: 4px; - color: var(--color-text-faint); - font-family: ${an}; - font-size: 11px; - } - - @media (max-width: 1180px) { - .docs-shell { - grid-template-columns: 224px minmax(0, 1fr); - gap: 30px; - } - .docs-toc { - display: none; - } - } - - @media (max-width: 860px) { - .docs-topbar { - grid-template-columns: minmax(0, 1fr) auto; - padding: 0 16px; - } - - .docs-search { - grid-column: 1 / -1; - order: 2; - display: none; - } - - .docs-topbar nav a:not(.docs-topbar-cta) { - display: none; - } - - .docs-shell { - display: block; - padding: 22px 16px 64px; - } - - .docs-sidebar { - position: static; - border: 1px solid var(--color-border); - border-radius: 10px; - padding: 14px 12px; - margin-bottom: 22px; - max-height: none; - } - - .docs-hero h1 { - font-size: 34px; - } - - .docs-pagination { - grid-template-columns: 1fr; - } - - .docs-card-grid { - grid-template-columns: 1fr; - } - } -`;function Jw(){return H.jsx(Fw,{})}Sb.createRoot(document.getElementById("root")).render(H.jsx(pb.StrictMode,{children:H.jsx(Jw,{})})); diff --git a/frontend/landing/public/docs/assets/index-DsOx0Kfh.js b/frontend/landing/public/docs/assets/index-DsOx0Kfh.js new file mode 100644 index 00000000..74a78146 --- /dev/null +++ b/frontend/landing/public/docs/assets/index-DsOx0Kfh.js @@ -0,0 +1,1735 @@ +(function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const c of document.querySelectorAll('link[rel="modulepreload"]'))o(c);new MutationObserver(c=>{for(const d of c)if(d.type==="childList")for(const f of d.addedNodes)f.tagName==="LINK"&&f.rel==="modulepreload"&&o(f)}).observe(document,{childList:!0,subtree:!0});function a(c){const d={};return c.integrity&&(d.integrity=c.integrity),c.referrerPolicy&&(d.referrerPolicy=c.referrerPolicy),c.crossOrigin==="use-credentials"?d.credentials="include":c.crossOrigin==="anonymous"?d.credentials="omit":d.credentials="same-origin",d}function o(c){if(c.ep)return;c.ep=!0;const d=a(c);fetch(c.href,d)}})();function yc(n){return n&&n.__esModule&&Object.prototype.hasOwnProperty.call(n,"default")?n.default:n}var Ns={exports:{}},va={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Bp;function fb(){if(Bp)return va;Bp=1;var n=Symbol.for("react.transitional.element"),r=Symbol.for("react.fragment");function a(o,c,d){var f=null;if(d!==void 0&&(f=""+d),c.key!==void 0&&(f=""+c.key),"key"in c){d={};for(var p in c)p!=="key"&&(d[p]=c[p])}else d=c;return c=d.ref,{$$typeof:n,type:o,key:f,ref:c!==void 0?c:null,props:d}}return va.Fragment=r,va.jsx=a,va.jsxs=a,va}var Hp;function db(){return Hp||(Hp=1,Ns.exports=fb()),Ns.exports}var H=db(),Ls={exports:{}},ye={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var qp;function hb(){if(qp)return ye;qp=1;var n=Symbol.for("react.transitional.element"),r=Symbol.for("react.portal"),a=Symbol.for("react.fragment"),o=Symbol.for("react.strict_mode"),c=Symbol.for("react.profiler"),d=Symbol.for("react.consumer"),f=Symbol.for("react.context"),p=Symbol.for("react.forward_ref"),m=Symbol.for("react.suspense"),h=Symbol.for("react.memo"),b=Symbol.for("react.lazy"),y=Symbol.for("react.activity"),k=Symbol.iterator;function x(E){return E===null||typeof E!="object"?null:(E=k&&E[k]||E["@@iterator"],typeof E=="function"?E:null)}var T={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},U=Object.assign,Z={};function D(E,Y,S){this.props=E,this.context=Y,this.refs=Z,this.updater=S||T}D.prototype.isReactComponent={},D.prototype.setState=function(E,Y){if(typeof E!="object"&&typeof E!="function"&&E!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,E,Y,"setState")},D.prototype.forceUpdate=function(E){this.updater.enqueueForceUpdate(this,E,"forceUpdate")};function F(){}F.prototype=D.prototype;function Q(E,Y,S){this.props=E,this.context=Y,this.refs=Z,this.updater=S||T}var oe=Q.prototype=new F;oe.constructor=Q,U(oe,D.prototype),oe.isPureReactComponent=!0;var re=Array.isArray;function L(){}var P={H:null,A:null,T:null,S:null},he=Object.prototype.hasOwnProperty;function me(E,Y,S){var ee=S.ref;return{$$typeof:n,type:E,key:Y,ref:ee!==void 0?ee:null,props:S}}function j(E,Y){return me(E.type,Y,E.props)}function te(E){return typeof E=="object"&&E!==null&&E.$$typeof===n}function B(E){var Y={"=":"=0",":":"=2"};return"$"+E.replace(/[=:]/g,function(S){return Y[S]})}var le=/\/+/g;function J(E,Y){return typeof E=="object"&&E!==null&&E.key!=null?B(""+E.key):Y.toString(36)}function $(E){switch(E.status){case"fulfilled":return E.value;case"rejected":throw E.reason;default:switch(typeof E.status=="string"?E.then(L,L):(E.status="pending",E.then(function(Y){E.status==="pending"&&(E.status="fulfilled",E.value=Y)},function(Y){E.status==="pending"&&(E.status="rejected",E.reason=Y)})),E.status){case"fulfilled":return E.value;case"rejected":throw E.reason}}throw E}function O(E,Y,S,ee,de){var ue=typeof E;(ue==="undefined"||ue==="boolean")&&(E=null);var Ee=!1;if(E===null)Ee=!0;else switch(ue){case"bigint":case"string":case"number":Ee=!0;break;case"object":switch(E.$$typeof){case n:case r:Ee=!0;break;case b:return Ee=E._init,O(Ee(E._payload),Y,S,ee,de)}}if(Ee)return de=de(E),Ee=ee===""?"."+J(E,0):ee,re(de)?(S="",Ee!=null&&(S=Ee.replace(le,"$&/")+"/"),O(de,Y,S,"",function(Yt){return Yt})):de!=null&&(te(de)&&(de=j(de,S+(de.key==null||E&&E.key===de.key?"":(""+de.key).replace(le,"$&/")+"/")+Ee)),Y.push(de)),1;Ee=0;var Ze=ee===""?".":ee+":";if(re(E))for(var Le=0;Le>>1,w=O[xe];if(0>>1;xec(S,ae))eec(de,S)?(O[xe]=de,O[ee]=ae,xe=ee):(O[xe]=S,O[Y]=ae,xe=Y);else if(eec(de,ae))O[xe]=de,O[ee]=ae,xe=ee;else break e}}return K}function c(O,K){var ae=O.sortIndex-K.sortIndex;return ae!==0?ae:O.id-K.id}if(n.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var d=performance;n.unstable_now=function(){return d.now()}}else{var f=Date,p=f.now();n.unstable_now=function(){return f.now()-p}}var m=[],h=[],b=1,y=null,k=3,x=!1,T=!1,U=!1,Z=!1,D=typeof setTimeout=="function"?setTimeout:null,F=typeof clearTimeout=="function"?clearTimeout:null,Q=typeof setImmediate<"u"?setImmediate:null;function oe(O){for(var K=a(h);K!==null;){if(K.callback===null)o(h);else if(K.startTime<=O)o(h),K.sortIndex=K.expirationTime,r(m,K);else break;K=a(h)}}function re(O){if(U=!1,oe(O),!T)if(a(m)!==null)T=!0,L||(L=!0,B());else{var K=a(h);K!==null&&$(re,K.startTime-O)}}var L=!1,P=-1,he=5,me=-1;function j(){return Z?!0:!(n.unstable_now()-meO&&j());){var xe=y.callback;if(typeof xe=="function"){y.callback=null,k=y.priorityLevel;var w=xe(y.expirationTime<=O);if(O=n.unstable_now(),typeof w=="function"){y.callback=w,oe(O),K=!0;break t}y===a(m)&&o(m),oe(O)}else o(m);y=a(m)}if(y!==null)K=!0;else{var E=a(h);E!==null&&$(re,E.startTime-O),K=!1}}break e}finally{y=null,k=ae,x=!1}K=void 0}}finally{K?B():L=!1}}}var B;if(typeof Q=="function")B=function(){Q(te)};else if(typeof MessageChannel<"u"){var le=new MessageChannel,J=le.port2;le.port1.onmessage=te,B=function(){J.postMessage(null)}}else B=function(){D(te,0)};function $(O,K){P=D(function(){O(n.unstable_now())},K)}n.unstable_IdlePriority=5,n.unstable_ImmediatePriority=1,n.unstable_LowPriority=4,n.unstable_NormalPriority=3,n.unstable_Profiling=null,n.unstable_UserBlockingPriority=2,n.unstable_cancelCallback=function(O){O.callback=null},n.unstable_forceFrameRate=function(O){0>O||125xe?(O.sortIndex=ae,r(h,O),a(m)===null&&O===a(h)&&(U?(F(P),P=-1):U=!0,$(re,ae-xe))):(O.sortIndex=w,r(m,O),T||x||(T=!0,L||(L=!0,B()))),O},n.unstable_shouldYield=j,n.unstable_wrapCallback=function(O){var K=k;return function(){var ae=k;k=K;try{return O.apply(this,arguments)}finally{k=ae}}}})(Hs)),Hs}var Vp;function gb(){return Vp||(Vp=1,Bs.exports=mb()),Bs.exports}var qs={exports:{}},pt={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Xp;function yb(){if(Xp)return pt;Xp=1;var n=bc();function r(m){var h="https://react.dev/errors/"+m;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(r){console.error(r)}}return n(),qs.exports=yb(),qs.exports}/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Zp;function vb(){if(Zp)return xa;Zp=1;var n=gb(),r=bc(),a=bb();function o(e){var t="https://react.dev/errors/"+e;if(1w||(e.current=xe[w],xe[w]=null,w--)}function S(e,t){w++,xe[w]=e.current,e.current=t}var ee=E(null),de=E(null),ue=E(null),Ee=E(null);function Ze(e,t){switch(S(ue,t),S(de,e),S(ee,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?op(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=op(t),e=up(t,e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}Y(ee),S(ee,e)}function Le(){Y(ee),Y(de),Y(ue)}function Yt(e){e.memoizedState!==null&&S(Ee,e);var t=ee.current,l=up(t,e.type);t!==l&&(S(de,e),S(ee,l))}function pn(e){de.current===e&&(Y(ee),Y(de)),Ee.current===e&&(Y(Ee),ma._currentValue=ae)}var Ai,La;function mn(e){if(Ai===void 0)try{throw Error()}catch(l){var t=l.stack.trim().match(/\n( *(at )?)/);Ai=t&&t[1]||"",La=-1)":-1u||C[i]!==M[u]){var q=` +`+C[i].replace(" at new "," at ");return e.displayName&&q.includes("")&&(q=q.replace("",e.displayName)),q}while(1<=i&&0<=u);break}}}finally{Ml=!1,Error.prepareStackTrace=l}return(l=e?e.displayName||e.name:"")?mn(l):""}function Ua(e,t){switch(e.tag){case 26:case 27:case 5:return mn(e.type);case 16:return mn("Lazy");case 13:return e.child!==t&&t!==null?mn("Suspense Fallback"):mn("Suspense");case 19:return mn("SuspenseList");case 0:case 15:return Dl(e.type,!1);case 11:return Dl(e.type.render,!1);case 1:return Dl(e.type,!0);case 31:return mn("Activity");default:return""}}function Ba(e){try{var t="",l=null;do t+=Ua(e,l),l=e,e=e.return;while(e);return t}catch(i){return` +Error generating stack: `+i.message+` +`+i.stack}}var Rl=Object.prototype.hasOwnProperty,jl=n.unstable_scheduleCallback,Ti=n.unstable_cancelCallback,xo=n.unstable_shouldYield,ko=n.unstable_requestPaint,yt=n.unstable_now,So=n.unstable_getCurrentPriorityLevel,G=n.unstable_ImmediatePriority,W=n.unstable_UserBlockingPriority,pe=n.unstable_NormalPriority,ke=n.unstable_LowPriority,Re=n.unstable_IdlePriority,Mt=n.log,gn=n.unstable_setDisableYieldValue,bt=null,it=null;function kt(e){if(typeof Mt=="function"&&gn(e),it&&typeof it.setStrictMode=="function")try{it.setStrictMode(bt,e)}catch{}}var qe=Math.clz32?Math.clz32:$g,Ln=Math.log,en=Math.LN2;function $g(e){return e>>>=0,e===0?32:31-(Ln(e)/en|0)|0}var Ha=256,qa=262144,Ya=4194304;function sl(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Ga(e,t,l){var i=e.pendingLanes;if(i===0)return 0;var u=0,s=e.suspendedLanes,g=e.pingedLanes;e=e.warmLanes;var v=i&134217727;return v!==0?(i=v&~s,i!==0?u=sl(i):(g&=v,g!==0?u=sl(g):l||(l=v&~e,l!==0&&(u=sl(l))))):(v=i&~s,v!==0?u=sl(v):g!==0?u=sl(g):l||(l=i&~e,l!==0&&(u=sl(l)))),u===0?0:t!==0&&t!==u&&(t&s)===0&&(s=u&-u,l=t&-t,s>=l||s===32&&(l&4194048)!==0)?t:u}function zi(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function Wg(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Yc(){var e=Ya;return Ya<<=1,(Ya&62914560)===0&&(Ya=4194304),e}function wo(e){for(var t=[],l=0;31>l;l++)t.push(e);return t}function Oi(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function Pg(e,t,l,i,u,s){var g=e.pendingLanes;e.pendingLanes=l,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=l,e.entangledLanes&=l,e.errorRecoveryDisabledLanes&=l,e.shellSuspendCounter=0;var v=e.entanglements,C=e.expirationTimes,M=e.hiddenUpdates;for(l=g&~l;0"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var ay=/[\n"\\]/g;function Vt(e){return e.replace(ay,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function Oo(e,t,l,i,u,s,g,v){e.name="",g!=null&&typeof g!="function"&&typeof g!="symbol"&&typeof g!="boolean"?e.type=g:e.removeAttribute("type"),t!=null?g==="number"?(t===0&&e.value===""||e.value!=t)&&(e.value=""+Gt(t)):e.value!==""+Gt(t)&&(e.value=""+Gt(t)):g!=="submit"&&g!=="reset"||e.removeAttribute("value"),t!=null?_o(e,g,Gt(t)):l!=null?_o(e,g,Gt(l)):i!=null&&e.removeAttribute("value"),u==null&&s!=null&&(e.defaultChecked=!!s),u!=null&&(e.checked=u&&typeof u!="function"&&typeof u!="symbol"),v!=null&&typeof v!="function"&&typeof v!="symbol"&&typeof v!="boolean"?e.name=""+Gt(v):e.removeAttribute("name")}function ef(e,t,l,i,u,s,g,v){if(s!=null&&typeof s!="function"&&typeof s!="symbol"&&typeof s!="boolean"&&(e.type=s),t!=null||l!=null){if(!(s!=="submit"&&s!=="reset"||t!=null)){zo(e);return}l=l!=null?""+Gt(l):"",t=t!=null?""+Gt(t):l,v||t===e.value||(e.value=t),e.defaultValue=t}i=i??u,i=typeof i!="function"&&typeof i!="symbol"&&!!i,e.checked=v?e.checked:!!i,e.defaultChecked=!!i,g!=null&&typeof g!="function"&&typeof g!="symbol"&&typeof g!="boolean"&&(e.name=g),zo(e)}function _o(e,t,l){t==="number"&&Qa(e.ownerDocument)===e||e.defaultValue===""+l||(e.defaultValue=""+l)}function ql(e,t,l,i){if(e=e.options,t){t={};for(var u=0;u"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),No=!1;if(vn)try{var Ri={};Object.defineProperty(Ri,"passive",{get:function(){No=!0}}),window.addEventListener("test",Ri,Ri),window.removeEventListener("test",Ri,Ri)}catch{No=!1}var Bn=null,Lo=null,Ka=null;function uf(){if(Ka)return Ka;var e,t=Lo,l=t.length,i,u="value"in Bn?Bn.value:Bn.textContent,s=u.length;for(e=0;e=Li),pf=" ",mf=!1;function gf(e,t){switch(e){case"keyup":return Ry.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function yf(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Xl=!1;function Ny(e,t){switch(e){case"compositionend":return yf(t);case"keypress":return t.which!==32?null:(mf=!0,pf);case"textInput":return e=t.data,e===pf&&mf?null:e;default:return null}}function Ly(e,t){if(Xl)return e==="compositionend"||!Yo&&gf(e,t)?(e=uf(),Ka=Lo=Bn=null,Xl=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:l,offset:t-e};e=i}e:{for(;l;){if(l.nextSibling){l=l.nextSibling;break e}l=l.parentNode}l=void 0}l=Ef(l)}}function Tf(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Tf(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function zf(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Qa(e.document);t instanceof e.HTMLIFrameElement;){try{var l=typeof t.contentWindow.location.href=="string"}catch{l=!1}if(l)e=t.contentWindow;else break;t=Qa(e.document)}return t}function Xo(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}var Xy=vn&&"documentMode"in document&&11>=document.documentMode,Ql=null,Qo=null,qi=null,Zo=!1;function Of(e,t,l){var i=l.window===l?l.document:l.nodeType===9?l:l.ownerDocument;Zo||Ql==null||Ql!==Qa(i)||(i=Ql,"selectionStart"in i&&Xo(i)?i={start:i.selectionStart,end:i.selectionEnd}:(i=(i.ownerDocument&&i.ownerDocument.defaultView||window).getSelection(),i={anchorNode:i.anchorNode,anchorOffset:i.anchorOffset,focusNode:i.focusNode,focusOffset:i.focusOffset}),qi&&Hi(qi,i)||(qi=i,i=qr(Qo,"onSelect"),0>=g,u-=g,un=1<<32-qe(t)+u|l<ve?(Te=ie,ie=null):Te=ie.sibling;var Me=R(z,ie,_[ve],V);if(Me===null){ie===null&&(ie=Te);break}e&&ie&&Me.alternate===null&&t(z,ie),A=s(Me,A,ve),_e===null?se=Me:_e.sibling=Me,_e=Me,ie=Te}if(ve===_.length)return l(z,ie),ze&&kn(z,ve),se;if(ie===null){for(;ve<_.length;ve++)ie=X(z,_[ve],V),ie!==null&&(A=s(ie,A,ve),_e===null?se=ie:_e.sibling=ie,_e=ie);return ze&&kn(z,ve),se}for(ie=i(ie);ve<_.length;ve++)Te=N(ie,z,ve,_[ve],V),Te!==null&&(e&&Te.alternate!==null&&ie.delete(Te.key===null?ve:Te.key),A=s(Te,A,ve),_e===null?se=Te:_e.sibling=Te,_e=Te);return e&&ie.forEach(function(rl){return t(z,rl)}),ze&&kn(z,ve),se}function fe(z,A,_,V){if(_==null)throw Error(o(151));for(var se=null,_e=null,ie=A,ve=A=0,Te=null,Me=_.next();ie!==null&&!Me.done;ve++,Me=_.next()){ie.index>ve?(Te=ie,ie=null):Te=ie.sibling;var rl=R(z,ie,Me.value,V);if(rl===null){ie===null&&(ie=Te);break}e&&ie&&rl.alternate===null&&t(z,ie),A=s(rl,A,ve),_e===null?se=rl:_e.sibling=rl,_e=rl,ie=Te}if(Me.done)return l(z,ie),ze&&kn(z,ve),se;if(ie===null){for(;!Me.done;ve++,Me=_.next())Me=X(z,Me.value,V),Me!==null&&(A=s(Me,A,ve),_e===null?se=Me:_e.sibling=Me,_e=Me);return ze&&kn(z,ve),se}for(ie=i(ie);!Me.done;ve++,Me=_.next())Me=N(ie,z,ve,Me.value,V),Me!==null&&(e&&Me.alternate!==null&&ie.delete(Me.key===null?ve:Me.key),A=s(Me,A,ve),_e===null?se=Me:_e.sibling=Me,_e=Me);return e&&ie.forEach(function(cb){return t(z,cb)}),ze&&kn(z,ve),se}function He(z,A,_,V){if(typeof _=="object"&&_!==null&&_.type===U&&_.key===null&&(_=_.props.children),typeof _=="object"&&_!==null){switch(_.$$typeof){case x:e:{for(var se=_.key;A!==null;){if(A.key===se){if(se=_.type,se===U){if(A.tag===7){l(z,A.sibling),V=u(A,_.props.children),V.return=z,z=V;break e}}else if(A.elementType===se||typeof se=="object"&&se!==null&&se.$$typeof===he&&xl(se)===A.type){l(z,A.sibling),V=u(A,_.props),Zi(V,_),V.return=z,z=V;break e}l(z,A);break}else t(z,A);A=A.sibling}_.type===U?(V=ml(_.props.children,z.mode,V,_.key),V.return=z,z=V):(V=lr(_.type,_.key,_.props,null,z.mode,V),Zi(V,_),V.return=z,z=V)}return g(z);case T:e:{for(se=_.key;A!==null;){if(A.key===se)if(A.tag===4&&A.stateNode.containerInfo===_.containerInfo&&A.stateNode.implementation===_.implementation){l(z,A.sibling),V=u(A,_.children||[]),V.return=z,z=V;break e}else{l(z,A);break}else t(z,A);A=A.sibling}V=Po(_,z.mode,V),V.return=z,z=V}return g(z);case he:return _=xl(_),He(z,A,_,V)}if($(_))return ne(z,A,_,V);if(B(_)){if(se=B(_),typeof se!="function")throw Error(o(150));return _=se.call(_),fe(z,A,_,V)}if(typeof _.then=="function")return He(z,A,cr(_),V);if(_.$$typeof===Q)return He(z,A,rr(z,_),V);fr(z,_)}return typeof _=="string"&&_!==""||typeof _=="number"||typeof _=="bigint"?(_=""+_,A!==null&&A.tag===6?(l(z,A.sibling),V=u(A,_),V.return=z,z=V):(l(z,A),V=Wo(_,z.mode,V),V.return=z,z=V),g(z)):l(z,A)}return function(z,A,_,V){try{Qi=0;var se=He(z,A,_,V);return ni=null,se}catch(ie){if(ie===ti||ie===ur)throw ie;var _e=Rt(29,ie,null,z.mode);return _e.lanes=V,_e.return=z,_e}finally{}}}var Sl=Wf(!0),Pf=Wf(!1),Vn=!1;function fu(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function du(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Xn(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Qn(e,t,l){var i=e.updateQueue;if(i===null)return null;if(i=i.shared,(De&2)!==0){var u=i.pending;return u===null?t.next=t:(t.next=u.next,u.next=t),i.pending=t,t=nr(e),Lf(e,null,l),t}return tr(e,i,t,l),nr(e)}function Ki(e,t,l){if(t=t.updateQueue,t!==null&&(t=t.shared,(l&4194048)!==0)){var i=t.lanes;i&=e.pendingLanes,l|=i,t.lanes=l,Vc(e,l)}}function hu(e,t){var l=e.updateQueue,i=e.alternate;if(i!==null&&(i=i.updateQueue,l===i)){var u=null,s=null;if(l=l.firstBaseUpdate,l!==null){do{var g={lane:l.lane,tag:l.tag,payload:l.payload,callback:null,next:null};s===null?u=s=g:s=s.next=g,l=l.next}while(l!==null);s===null?u=s=t:s=s.next=t}else u=s=t;l={baseState:i.baseState,firstBaseUpdate:u,lastBaseUpdate:s,shared:i.shared,callbacks:i.callbacks},e.updateQueue=l;return}e=l.lastBaseUpdate,e===null?l.firstBaseUpdate=t:e.next=t,l.lastBaseUpdate=t}var pu=!1;function Fi(){if(pu){var e=ei;if(e!==null)throw e}}function Ii(e,t,l,i){pu=!1;var u=e.updateQueue;Vn=!1;var s=u.firstBaseUpdate,g=u.lastBaseUpdate,v=u.shared.pending;if(v!==null){u.shared.pending=null;var C=v,M=C.next;C.next=null,g===null?s=M:g.next=M,g=C;var q=e.alternate;q!==null&&(q=q.updateQueue,v=q.lastBaseUpdate,v!==g&&(v===null?q.firstBaseUpdate=M:v.next=M,q.lastBaseUpdate=C))}if(s!==null){var X=u.baseState;g=0,q=M=C=null,v=s;do{var R=v.lane&-536870913,N=R!==v.lane;if(N?(Ae&R)===R:(i&R)===R){R!==0&&R===Pl&&(pu=!0),q!==null&&(q=q.next={lane:0,tag:v.tag,payload:v.payload,callback:null,next:null});e:{var ne=e,fe=v;R=t;var He=l;switch(fe.tag){case 1:if(ne=fe.payload,typeof ne=="function"){X=ne.call(He,X,R);break e}X=ne;break e;case 3:ne.flags=ne.flags&-65537|128;case 0:if(ne=fe.payload,R=typeof ne=="function"?ne.call(He,X,R):ne,R==null)break e;X=y({},X,R);break e;case 2:Vn=!0}}R=v.callback,R!==null&&(e.flags|=64,N&&(e.flags|=8192),N=u.callbacks,N===null?u.callbacks=[R]:N.push(R))}else N={lane:R,tag:v.tag,payload:v.payload,callback:v.callback,next:null},q===null?(M=q=N,C=X):q=q.next=N,g|=R;if(v=v.next,v===null){if(v=u.shared.pending,v===null)break;N=v,v=N.next,N.next=null,u.lastBaseUpdate=N,u.shared.pending=null}}while(!0);q===null&&(C=X),u.baseState=C,u.firstBaseUpdate=M,u.lastBaseUpdate=q,s===null&&(u.shared.lanes=0),Jn|=g,e.lanes=g,e.memoizedState=X}}function ed(e,t){if(typeof e!="function")throw Error(o(191,e));e.call(t)}function td(e,t){var l=e.callbacks;if(l!==null)for(e.callbacks=null,e=0;es?s:8;var g=O.T,v={};O.T=v,Ru(e,!1,t,l);try{var C=u(),M=O.S;if(M!==null&&M(v,C),C!==null&&typeof C=="object"&&typeof C.then=="function"){var q=Py(C,i);Wi(e,t,q,Bt(e))}else Wi(e,t,i,Bt(e))}catch(X){Wi(e,t,{then:function(){},status:"rejected",reason:X},Bt())}finally{K.p=s,g!==null&&v.types!==null&&(g.types=v.types),O.T=g}}function a1(){}function Mu(e,t,l,i){if(e.tag!==5)throw Error(o(476));var u=Rd(e).queue;Dd(e,u,t,ae,l===null?a1:function(){return jd(e),l(i)})}function Rd(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:ae,baseState:ae,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:En,lastRenderedState:ae},next:null};var l={};return t.next={memoizedState:l,baseState:l,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:En,lastRenderedState:l},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function jd(e){var t=Rd(e);t.next===null&&(t=e.alternate.memoizedState),Wi(e,t.next.queue,{},Bt())}function Du(){return ft(ma)}function Nd(){return $e().memoizedState}function Ld(){return $e().memoizedState}function r1(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var l=Bt();e=Xn(l);var i=Qn(t,e,l);i!==null&&(zt(i,t,l),Ki(i,t,l)),t={cache:ou()},e.payload=t;return}t=t.return}}function o1(e,t,l){var i=Bt();l={lane:i,revertLane:0,gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null},kr(e)?Bd(t,l):(l=Jo(e,t,l,i),l!==null&&(zt(l,e,i),Hd(l,t,i)))}function Ud(e,t,l){var i=Bt();Wi(e,t,l,i)}function Wi(e,t,l,i){var u={lane:i,revertLane:0,gesture:null,action:l,hasEagerState:!1,eagerState:null,next:null};if(kr(e))Bd(t,u);else{var s=e.alternate;if(e.lanes===0&&(s===null||s.lanes===0)&&(s=t.lastRenderedReducer,s!==null))try{var g=t.lastRenderedState,v=s(g,l);if(u.hasEagerState=!0,u.eagerState=v,Dt(v,g))return tr(e,t,u,0),Ye===null&&er(),!1}catch{}finally{}if(l=Jo(e,t,u,i),l!==null)return zt(l,e,i),Hd(l,t,i),!0}return!1}function Ru(e,t,l,i){if(i={lane:2,revertLane:fs(),gesture:null,action:i,hasEagerState:!1,eagerState:null,next:null},kr(e)){if(t)throw Error(o(479))}else t=Jo(e,l,i,2),t!==null&&zt(t,e,2)}function kr(e){var t=e.alternate;return e===be||t!==null&&t===be}function Bd(e,t){ii=pr=!0;var l=e.pending;l===null?t.next=t:(t.next=l.next,l.next=t),e.pending=t}function Hd(e,t,l){if((l&4194048)!==0){var i=t.lanes;i&=e.pendingLanes,l|=i,t.lanes=l,Vc(e,l)}}var Pi={readContext:ft,use:yr,useCallback:Fe,useContext:Fe,useEffect:Fe,useImperativeHandle:Fe,useLayoutEffect:Fe,useInsertionEffect:Fe,useMemo:Fe,useReducer:Fe,useRef:Fe,useState:Fe,useDebugValue:Fe,useDeferredValue:Fe,useTransition:Fe,useSyncExternalStore:Fe,useId:Fe,useHostTransitionStatus:Fe,useFormState:Fe,useActionState:Fe,useOptimistic:Fe,useMemoCache:Fe,useCacheRefresh:Fe};Pi.useEffectEvent=Fe;var qd={readContext:ft,use:yr,useCallback:function(e,t){return vt().memoizedState=[e,t===void 0?null:t],e},useContext:ft,useEffect:wd,useImperativeHandle:function(e,t,l){l=l!=null?l.concat([e]):null,vr(4194308,4,Td.bind(null,t,e),l)},useLayoutEffect:function(e,t){return vr(4194308,4,e,t)},useInsertionEffect:function(e,t){vr(4,2,e,t)},useMemo:function(e,t){var l=vt();t=t===void 0?null:t;var i=e();if(wl){kt(!0);try{e()}finally{kt(!1)}}return l.memoizedState=[i,t],i},useReducer:function(e,t,l){var i=vt();if(l!==void 0){var u=l(t);if(wl){kt(!0);try{l(t)}finally{kt(!1)}}}else u=t;return i.memoizedState=i.baseState=u,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:u},i.queue=e,e=e.dispatch=o1.bind(null,be,e),[i.memoizedState,e]},useRef:function(e){var t=vt();return e={current:e},t.memoizedState=e},useState:function(e){e=Au(e);var t=e.queue,l=Ud.bind(null,be,t);return t.dispatch=l,[e.memoizedState,l]},useDebugValue:Ou,useDeferredValue:function(e,t){var l=vt();return _u(l,e,t)},useTransition:function(){var e=Au(!1);return e=Dd.bind(null,be,e.queue,!0,!1),vt().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,l){var i=be,u=vt();if(ze){if(l===void 0)throw Error(o(407));l=l()}else{if(l=t(),Ye===null)throw Error(o(349));(Ae&127)!==0||od(i,t,l)}u.memoizedState=l;var s={value:l,getSnapshot:t};return u.queue=s,wd(sd.bind(null,i,s,e),[e]),i.flags|=2048,ri(9,{destroy:void 0},ud.bind(null,i,s,l,t),null),l},useId:function(){var e=vt(),t=Ye.identifierPrefix;if(ze){var l=sn,i=un;l=(i&~(1<<32-qe(i)-1)).toString(32)+l,t="_"+t+"R_"+l,l=mr++,0<\/script>",s=s.removeChild(s.firstChild);break;case"select":s=typeof i.is=="string"?g.createElement("select",{is:i.is}):g.createElement("select"),i.multiple?s.multiple=!0:i.size&&(s.size=i.size);break;default:s=typeof i.is=="string"?g.createElement(u,{is:i.is}):g.createElement(u)}}s[st]=t,s[St]=i;e:for(g=t.child;g!==null;){if(g.tag===5||g.tag===6)s.appendChild(g.stateNode);else if(g.tag!==4&&g.tag!==27&&g.child!==null){g.child.return=g,g=g.child;continue}if(g===t)break e;for(;g.sibling===null;){if(g.return===null||g.return===t)break e;g=g.return}g.sibling.return=g.return,g=g.sibling}t.stateNode=s;e:switch(ht(s,u,i),u){case"button":case"input":case"select":case"textarea":i=!!i.autoFocus;break e;case"img":i=!0;break e;default:i=!1}i&&Tn(t)}}return Qe(t),Ku(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,l),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==i&&Tn(t);else{if(typeof i!="string"&&t.stateNode===null)throw Error(o(166));if(e=ue.current,$l(t)){if(e=t.stateNode,l=t.memoizedProps,i=null,u=ct,u!==null)switch(u.tag){case 27:case 5:i=u.memoizedProps}e[st]=t,e=!!(e.nodeValue===l||i!==null&&i.suppressHydrationWarning===!0||ap(e.nodeValue,l)),e||Yn(t,!0)}else e=Yr(e).createTextNode(i),e[st]=t,t.stateNode=e}return Qe(t),null;case 31:if(l=t.memoizedState,e===null||e.memoizedState!==null){if(i=$l(t),l!==null){if(e===null){if(!i)throw Error(o(318));if(e=t.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(o(557));e[st]=t}else gl(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;Qe(t),e=!1}else l=lu(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=l),e=!0;if(!e)return t.flags&256?(Nt(t),t):(Nt(t),null);if((t.flags&128)!==0)throw Error(o(558))}return Qe(t),null;case 13:if(i=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(u=$l(t),i!==null&&i.dehydrated!==null){if(e===null){if(!u)throw Error(o(318));if(u=t.memoizedState,u=u!==null?u.dehydrated:null,!u)throw Error(o(317));u[st]=t}else gl(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;Qe(t),u=!1}else u=lu(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=u),u=!0;if(!u)return t.flags&256?(Nt(t),t):(Nt(t),null)}return Nt(t),(t.flags&128)!==0?(t.lanes=l,t):(l=i!==null,e=e!==null&&e.memoizedState!==null,l&&(i=t.child,u=null,i.alternate!==null&&i.alternate.memoizedState!==null&&i.alternate.memoizedState.cachePool!==null&&(u=i.alternate.memoizedState.cachePool.pool),s=null,i.memoizedState!==null&&i.memoizedState.cachePool!==null&&(s=i.memoizedState.cachePool.pool),s!==u&&(i.flags|=2048)),l!==e&&l&&(t.child.flags|=8192),Ar(t,t.updateQueue),Qe(t),null);case 4:return Le(),e===null&&ms(t.stateNode.containerInfo),Qe(t),null;case 10:return wn(t.type),Qe(t),null;case 19:if(Y(Je),i=t.memoizedState,i===null)return Qe(t),null;if(u=(t.flags&128)!==0,s=i.rendering,s===null)if(u)ta(i,!1);else{if(Ie!==0||e!==null&&(e.flags&128)!==0)for(e=t.child;e!==null;){if(s=hr(e),s!==null){for(t.flags|=128,ta(i,!1),e=s.updateQueue,t.updateQueue=e,Ar(t,e),t.subtreeFlags=0,e=l,l=t.child;l!==null;)Uf(l,e),l=l.sibling;return S(Je,Je.current&1|2),ze&&kn(t,i.treeForkCount),t.child}e=e.sibling}i.tail!==null&&yt()>Mr&&(t.flags|=128,u=!0,ta(i,!1),t.lanes=4194304)}else{if(!u)if(e=hr(s),e!==null){if(t.flags|=128,u=!0,e=e.updateQueue,t.updateQueue=e,Ar(t,e),ta(i,!0),i.tail===null&&i.tailMode==="hidden"&&!s.alternate&&!ze)return Qe(t),null}else 2*yt()-i.renderingStartTime>Mr&&l!==536870912&&(t.flags|=128,u=!0,ta(i,!1),t.lanes=4194304);i.isBackwards?(s.sibling=t.child,t.child=s):(e=i.last,e!==null?e.sibling=s:t.child=s,i.last=s)}return i.tail!==null?(e=i.tail,i.rendering=e,i.tail=e.sibling,i.renderingStartTime=yt(),e.sibling=null,l=Je.current,S(Je,u?l&1|2:l&1),ze&&kn(t,i.treeForkCount),e):(Qe(t),null);case 22:case 23:return Nt(t),gu(),i=t.memoizedState!==null,e!==null?e.memoizedState!==null!==i&&(t.flags|=8192):i&&(t.flags|=8192),i?(l&536870912)!==0&&(t.flags&128)===0&&(Qe(t),t.subtreeFlags&6&&(t.flags|=8192)):Qe(t),l=t.updateQueue,l!==null&&Ar(t,l.retryQueue),l=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(l=e.memoizedState.cachePool.pool),i=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(i=t.memoizedState.cachePool.pool),i!==l&&(t.flags|=2048),e!==null&&Y(vl),null;case 24:return l=null,e!==null&&(l=e.memoizedState.cache),t.memoizedState.cache!==l&&(t.flags|=2048),wn(Pe),Qe(t),null;case 25:return null;case 30:return null}throw Error(o(156,t.tag))}function d1(e,t){switch(tu(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return wn(Pe),Le(),e=t.flags,(e&65536)!==0&&(e&128)===0?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return pn(t),null;case 31:if(t.memoizedState!==null){if(Nt(t),t.alternate===null)throw Error(o(340));gl()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(Nt(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(o(340));gl()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Y(Je),null;case 4:return Le(),null;case 10:return wn(t.type),null;case 22:case 23:return Nt(t),gu(),e!==null&&Y(vl),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return wn(Pe),null;case 25:return null;default:return null}}function ch(e,t){switch(tu(t),t.tag){case 3:wn(Pe),Le();break;case 26:case 27:case 5:pn(t);break;case 4:Le();break;case 31:t.memoizedState!==null&&Nt(t);break;case 13:Nt(t);break;case 19:Y(Je);break;case 10:wn(t.type);break;case 22:case 23:Nt(t),gu(),e!==null&&Y(vl);break;case 24:wn(Pe)}}function na(e,t){try{var l=t.updateQueue,i=l!==null?l.lastEffect:null;if(i!==null){var u=i.next;l=u;do{if((l.tag&e)===e){i=void 0;var s=l.create,g=l.inst;i=s(),g.destroy=i}l=l.next}while(l!==u)}}catch(v){Ne(t,t.return,v)}}function Fn(e,t,l){try{var i=t.updateQueue,u=i!==null?i.lastEffect:null;if(u!==null){var s=u.next;i=s;do{if((i.tag&e)===e){var g=i.inst,v=g.destroy;if(v!==void 0){g.destroy=void 0,u=t;var C=l,M=v;try{M()}catch(q){Ne(u,C,q)}}}i=i.next}while(i!==s)}}catch(q){Ne(t,t.return,q)}}function fh(e){var t=e.updateQueue;if(t!==null){var l=e.stateNode;try{td(t,l)}catch(i){Ne(e,e.return,i)}}}function dh(e,t,l){l.props=Cl(e.type,e.memoizedProps),l.state=e.memoizedState;try{l.componentWillUnmount()}catch(i){Ne(e,t,i)}}function la(e,t){try{var l=e.ref;if(l!==null){switch(e.tag){case 26:case 27:case 5:var i=e.stateNode;break;case 30:i=e.stateNode;break;default:i=e.stateNode}typeof l=="function"?e.refCleanup=l(i):l.current=i}}catch(u){Ne(e,t,u)}}function cn(e,t){var l=e.ref,i=e.refCleanup;if(l!==null)if(typeof i=="function")try{i()}catch(u){Ne(e,t,u)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof l=="function")try{l(null)}catch(u){Ne(e,t,u)}else l.current=null}function hh(e){var t=e.type,l=e.memoizedProps,i=e.stateNode;try{e:switch(t){case"button":case"input":case"select":case"textarea":l.autoFocus&&i.focus();break e;case"img":l.src?i.src=l.src:l.srcSet&&(i.srcset=l.srcSet)}}catch(u){Ne(e,e.return,u)}}function Fu(e,t,l){try{var i=e.stateNode;j1(i,e.type,l,t),i[St]=t}catch(u){Ne(e,e.return,u)}}function ph(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&tl(e.type)||e.tag===4}function Iu(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||ph(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&tl(e.type)||e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Ju(e,t,l){var i=e.tag;if(i===5||i===6)e=e.stateNode,t?(l.nodeType===9?l.body:l.nodeName==="HTML"?l.ownerDocument.body:l).insertBefore(e,t):(t=l.nodeType===9?l.body:l.nodeName==="HTML"?l.ownerDocument.body:l,t.appendChild(e),l=l._reactRootContainer,l!=null||t.onclick!==null||(t.onclick=bn));else if(i!==4&&(i===27&&tl(e.type)&&(l=e.stateNode,t=null),e=e.child,e!==null))for(Ju(e,t,l),e=e.sibling;e!==null;)Ju(e,t,l),e=e.sibling}function Tr(e,t,l){var i=e.tag;if(i===5||i===6)e=e.stateNode,t?l.insertBefore(e,t):l.appendChild(e);else if(i!==4&&(i===27&&tl(e.type)&&(l=e.stateNode),e=e.child,e!==null))for(Tr(e,t,l),e=e.sibling;e!==null;)Tr(e,t,l),e=e.sibling}function mh(e){var t=e.stateNode,l=e.memoizedProps;try{for(var i=e.type,u=t.attributes;u.length;)t.removeAttributeNode(u[0]);ht(t,i,l),t[st]=e,t[St]=l}catch(s){Ne(e,e.return,s)}}var zn=!1,nt=!1,$u=!1,gh=typeof WeakSet=="function"?WeakSet:Set,ot=null;function h1(e,t){if(e=e.containerInfo,bs=Fr,e=zf(e),Xo(e)){if("selectionStart"in e)var l={start:e.selectionStart,end:e.selectionEnd};else e:{l=(l=e.ownerDocument)&&l.defaultView||window;var i=l.getSelection&&l.getSelection();if(i&&i.rangeCount!==0){l=i.anchorNode;var u=i.anchorOffset,s=i.focusNode;i=i.focusOffset;try{l.nodeType,s.nodeType}catch{l=null;break e}var g=0,v=-1,C=-1,M=0,q=0,X=e,R=null;t:for(;;){for(var N;X!==l||u!==0&&X.nodeType!==3||(v=g+u),X!==s||i!==0&&X.nodeType!==3||(C=g+i),X.nodeType===3&&(g+=X.nodeValue.length),(N=X.firstChild)!==null;)R=X,X=N;for(;;){if(X===e)break t;if(R===l&&++M===u&&(v=g),R===s&&++q===i&&(C=g),(N=X.nextSibling)!==null)break;X=R,R=X.parentNode}X=N}l=v===-1||C===-1?null:{start:v,end:C}}else l=null}l=l||{start:0,end:0}}else l=null;for(vs={focusedElem:e,selectionRange:l},Fr=!1,ot=t;ot!==null;)if(t=ot,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,ot=e;else for(;ot!==null;){switch(t=ot,s=t.alternate,e=t.flags,t.tag){case 0:if((e&4)!==0&&(e=t.updateQueue,e=e!==null?e.events:null,e!==null))for(l=0;l title"))),ht(s,i,l),s[st]=e,rt(s),i=s;break e;case"link":var g=Sp("link","href",u).get(i+(l.href||""));if(g){for(var v=0;vHe&&(g=He,He=fe,fe=g);var z=Af(v,fe),A=Af(v,He);if(z&&A&&(N.rangeCount!==1||N.anchorNode!==z.node||N.anchorOffset!==z.offset||N.focusNode!==A.node||N.focusOffset!==A.offset)){var _=X.createRange();_.setStart(z.node,z.offset),N.removeAllRanges(),fe>He?(N.addRange(_),N.extend(A.node,A.offset)):(_.setEnd(A.node,A.offset),N.addRange(_))}}}}for(X=[],N=v;N=N.parentNode;)N.nodeType===1&&X.push({element:N,left:N.scrollLeft,top:N.scrollTop});for(typeof v.focus=="function"&&v.focus(),v=0;vl?32:l,O.T=null,l=is,is=null;var s=Wn,g=Rn;if(at=0,fi=Wn=null,Rn=0,(De&6)!==0)throw Error(o(331));var v=De;if(De|=4,Th(s.current),Ch(s,s.current,g,l),De=v,sa(0,!1),it&&typeof it.onPostCommitFiberRoot=="function")try{it.onPostCommitFiberRoot(bt,s)}catch{}return!0}finally{K.p=u,O.T=i,Qh(e,t)}}function Kh(e,t,l){t=Qt(l,t),t=Uu(e.stateNode,t,2),e=Qn(e,t,2),e!==null&&(Oi(e,2),fn(e))}function Ne(e,t,l){if(e.tag===3)Kh(e,e,l);else for(;t!==null;){if(t.tag===3){Kh(t,e,l);break}else if(t.tag===1){var i=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof i.componentDidCatch=="function"&&($n===null||!$n.has(i))){e=Qt(l,e),l=Fd(2),i=Qn(t,l,2),i!==null&&(Id(l,i,t,e),Oi(i,2),fn(i));break}}t=t.return}}function us(e,t,l){var i=e.pingCache;if(i===null){i=e.pingCache=new g1;var u=new Set;i.set(t,u)}else u=i.get(t),u===void 0&&(u=new Set,i.set(t,u));u.has(l)||(es=!0,u.add(l),e=k1.bind(null,e,t,l),t.then(e,e))}function k1(e,t,l){var i=e.pingCache;i!==null&&i.delete(t),e.pingedLanes|=e.suspendedLanes&l,e.warmLanes&=~l,Ye===e&&(Ae&l)===l&&(Ie===4||Ie===3&&(Ae&62914560)===Ae&&300>yt()-_r?(De&2)===0&&di(e,0):ts|=l,ci===Ae&&(ci=0)),fn(e)}function Fh(e,t){t===0&&(t=Yc()),e=pl(e,t),e!==null&&(Oi(e,t),fn(e))}function S1(e){var t=e.memoizedState,l=0;t!==null&&(l=t.retryLane),Fh(e,l)}function w1(e,t){var l=0;switch(e.tag){case 31:case 13:var i=e.stateNode,u=e.memoizedState;u!==null&&(l=u.retryLane);break;case 19:i=e.stateNode;break;case 22:i=e.stateNode._retryCache;break;default:throw Error(o(314))}i!==null&&i.delete(t),Fh(e,l)}function C1(e,t){return jl(e,t)}var Ur=null,pi=null,ss=!1,Br=!1,cs=!1,el=0;function fn(e){e!==pi&&e.next===null&&(pi===null?Ur=pi=e:pi=pi.next=e),Br=!0,ss||(ss=!0,A1())}function sa(e,t){if(!cs&&Br){cs=!0;do for(var l=!1,i=Ur;i!==null;){if(e!==0){var u=i.pendingLanes;if(u===0)var s=0;else{var g=i.suspendedLanes,v=i.pingedLanes;s=(1<<31-qe(42|e)+1)-1,s&=u&~(g&~v),s=s&201326741?s&201326741|1:s?s|2:0}s!==0&&(l=!0,Wh(i,s))}else s=Ae,s=Ga(i,i===Ye?s:0,i.cancelPendingCommit!==null||i.timeoutHandle!==-1),(s&3)===0||zi(i,s)||(l=!0,Wh(i,s));i=i.next}while(l);cs=!1}}function E1(){Ih()}function Ih(){Br=ss=!1;var e=0;el!==0&&L1()&&(e=el);for(var t=yt(),l=null,i=Ur;i!==null;){var u=i.next,s=Jh(i,t);s===0?(i.next=null,l===null?Ur=u:l.next=u,u===null&&(pi=l)):(l=i,(e!==0||(s&3)!==0)&&(Br=!0)),i=u}at!==0&&at!==5||sa(e),el!==0&&(el=0)}function Jh(e,t){for(var l=e.suspendedLanes,i=e.pingedLanes,u=e.expirationTimes,s=e.pendingLanes&-62914561;0v)break;var q=C.transferSize,X=C.initiatorType;q&&rp(X)&&(C=C.responseEnd,g+=q*(C"u"?null:document;function bp(e,t,l){var i=mi;if(i&&typeof t=="string"&&t){var u=Vt(t);u='link[rel="'+e+'"][href="'+u+'"]',typeof l=="string"&&(u+='[crossorigin="'+l+'"]'),yp.has(u)||(yp.add(u),e={rel:e,crossOrigin:l,href:t},i.querySelector(u)===null&&(t=i.createElement("link"),ht(t,"link",e),rt(t),i.head.appendChild(t)))}}function Q1(e){jn.D(e),bp("dns-prefetch",e,null)}function Z1(e,t){jn.C(e,t),bp("preconnect",e,t)}function K1(e,t,l){jn.L(e,t,l);var i=mi;if(i&&e&&t){var u='link[rel="preload"][as="'+Vt(t)+'"]';t==="image"&&l&&l.imageSrcSet?(u+='[imagesrcset="'+Vt(l.imageSrcSet)+'"]',typeof l.imageSizes=="string"&&(u+='[imagesizes="'+Vt(l.imageSizes)+'"]')):u+='[href="'+Vt(e)+'"]';var s=u;switch(t){case"style":s=gi(e);break;case"script":s=yi(e)}$t.has(s)||(e=y({rel:"preload",href:t==="image"&&l&&l.imageSrcSet?void 0:e,as:t},l),$t.set(s,e),i.querySelector(u)!==null||t==="style"&&i.querySelector(ha(s))||t==="script"&&i.querySelector(pa(s))||(t=i.createElement("link"),ht(t,"link",e),rt(t),i.head.appendChild(t)))}}function F1(e,t){jn.m(e,t);var l=mi;if(l&&e){var i=t&&typeof t.as=="string"?t.as:"script",u='link[rel="modulepreload"][as="'+Vt(i)+'"][href="'+Vt(e)+'"]',s=u;switch(i){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":s=yi(e)}if(!$t.has(s)&&(e=y({rel:"modulepreload",href:e},t),$t.set(s,e),l.querySelector(u)===null)){switch(i){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(l.querySelector(pa(s)))return}i=l.createElement("link"),ht(i,"link",e),rt(i),l.head.appendChild(i)}}}function I1(e,t,l){jn.S(e,t,l);var i=mi;if(i&&e){var u=Bl(i).hoistableStyles,s=gi(e);t=t||"default";var g=u.get(s);if(!g){var v={loading:0,preload:null};if(g=i.querySelector(ha(s)))v.loading=5;else{e=y({rel:"stylesheet",href:e,"data-precedence":t},l),(l=$t.get(s))&&As(e,l);var C=g=i.createElement("link");rt(C),ht(C,"link",e),C._p=new Promise(function(M,q){C.onload=M,C.onerror=q}),C.addEventListener("load",function(){v.loading|=1}),C.addEventListener("error",function(){v.loading|=2}),v.loading|=4,Vr(g,t,i)}g={type:"stylesheet",instance:g,count:1,state:v},u.set(s,g)}}}function J1(e,t){jn.X(e,t);var l=mi;if(l&&e){var i=Bl(l).hoistableScripts,u=yi(e),s=i.get(u);s||(s=l.querySelector(pa(u)),s||(e=y({src:e,async:!0},t),(t=$t.get(u))&&Ts(e,t),s=l.createElement("script"),rt(s),ht(s,"link",e),l.head.appendChild(s)),s={type:"script",instance:s,count:1,state:null},i.set(u,s))}}function $1(e,t){jn.M(e,t);var l=mi;if(l&&e){var i=Bl(l).hoistableScripts,u=yi(e),s=i.get(u);s||(s=l.querySelector(pa(u)),s||(e=y({src:e,async:!0,type:"module"},t),(t=$t.get(u))&&Ts(e,t),s=l.createElement("script"),rt(s),ht(s,"link",e),l.head.appendChild(s)),s={type:"script",instance:s,count:1,state:null},i.set(u,s))}}function vp(e,t,l,i){var u=(u=ue.current)?Gr(u):null;if(!u)throw Error(o(446));switch(e){case"meta":case"title":return null;case"style":return typeof l.precedence=="string"&&typeof l.href=="string"?(t=gi(l.href),l=Bl(u).hoistableStyles,i=l.get(t),i||(i={type:"style",instance:null,count:0,state:null},l.set(t,i)),i):{type:"void",instance:null,count:0,state:null};case"link":if(l.rel==="stylesheet"&&typeof l.href=="string"&&typeof l.precedence=="string"){e=gi(l.href);var s=Bl(u).hoistableStyles,g=s.get(e);if(g||(u=u.ownerDocument||u,g={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},s.set(e,g),(s=u.querySelector(ha(e)))&&!s._p&&(g.instance=s,g.state.loading=5),$t.has(e)||(l={rel:"preload",as:"style",href:l.href,crossOrigin:l.crossOrigin,integrity:l.integrity,media:l.media,hrefLang:l.hrefLang,referrerPolicy:l.referrerPolicy},$t.set(e,l),s||W1(u,e,l,g.state))),t&&i===null)throw Error(o(528,""));return g}if(t&&i!==null)throw Error(o(529,""));return null;case"script":return t=l.async,l=l.src,typeof l=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(t=yi(l),l=Bl(u).hoistableScripts,i=l.get(t),i||(i={type:"script",instance:null,count:0,state:null},l.set(t,i)),i):{type:"void",instance:null,count:0,state:null};default:throw Error(o(444,e))}}function gi(e){return'href="'+Vt(e)+'"'}function ha(e){return'link[rel="stylesheet"]['+e+"]"}function xp(e){return y({},e,{"data-precedence":e.precedence,precedence:null})}function W1(e,t,l,i){e.querySelector('link[rel="preload"][as="style"]['+t+"]")?i.loading=1:(t=e.createElement("link"),i.preload=t,t.addEventListener("load",function(){return i.loading|=1}),t.addEventListener("error",function(){return i.loading|=2}),ht(t,"link",l),rt(t),e.head.appendChild(t))}function yi(e){return'[src="'+Vt(e)+'"]'}function pa(e){return"script[async]"+e}function kp(e,t,l){if(t.count++,t.instance===null)switch(t.type){case"style":var i=e.querySelector('style[data-href~="'+Vt(l.href)+'"]');if(i)return t.instance=i,rt(i),i;var u=y({},l,{"data-href":l.href,"data-precedence":l.precedence,href:null,precedence:null});return i=(e.ownerDocument||e).createElement("style"),rt(i),ht(i,"style",u),Vr(i,l.precedence,e),t.instance=i;case"stylesheet":u=gi(l.href);var s=e.querySelector(ha(u));if(s)return t.state.loading|=4,t.instance=s,rt(s),s;i=xp(l),(u=$t.get(u))&&As(i,u),s=(e.ownerDocument||e).createElement("link"),rt(s);var g=s;return g._p=new Promise(function(v,C){g.onload=v,g.onerror=C}),ht(s,"link",i),t.state.loading|=4,Vr(s,l.precedence,e),t.instance=s;case"script":return s=yi(l.src),(u=e.querySelector(pa(s)))?(t.instance=u,rt(u),u):(i=l,(u=$t.get(s))&&(i=y({},l),Ts(i,u)),e=e.ownerDocument||e,u=e.createElement("script"),rt(u),ht(u,"link",i),e.head.appendChild(u),t.instance=u);case"void":return null;default:throw Error(o(443,t.type))}else t.type==="stylesheet"&&(t.state.loading&4)===0&&(i=t.instance,t.state.loading|=4,Vr(i,l.precedence,e));return t.instance}function Vr(e,t,l){for(var i=l.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),u=i.length?i[i.length-1]:null,s=u,g=0;g title"):null)}function P1(e,t,l){if(l===1||t.itemProp!=null)return!1;switch(e){case"meta":case"title":return!0;case"style":if(typeof t.precedence!="string"||typeof t.href!="string"||t.href==="")break;return!0;case"link":if(typeof t.rel!="string"||typeof t.href!="string"||t.href===""||t.onLoad||t.onError)break;switch(t.rel){case"stylesheet":return e=t.disabled,typeof t.precedence=="string"&&e==null;default:return!0}case"script":if(t.async&&typeof t.async!="function"&&typeof t.async!="symbol"&&!t.onLoad&&!t.onError&&t.src&&typeof t.src=="string")return!0}return!1}function Cp(e){return!(e.type==="stylesheet"&&(e.state.loading&3)===0)}function eb(e,t,l,i){if(l.type==="stylesheet"&&(typeof i.media!="string"||matchMedia(i.media).matches!==!1)&&(l.state.loading&4)===0){if(l.instance===null){var u=gi(i.href),s=t.querySelector(ha(u));if(s){t=s._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(e.count++,e=Qr.bind(e),t.then(e,e)),l.state.loading|=4,l.instance=s,rt(s);return}s=t.ownerDocument||t,i=xp(i),(u=$t.get(u))&&As(i,u),s=s.createElement("link"),rt(s);var g=s;g._p=new Promise(function(v,C){g.onload=v,g.onerror=C}),ht(s,"link",i),l.instance=s}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(l,t),(t=l.state.preload)&&(l.state.loading&3)===0&&(e.count++,l=Qr.bind(e),t.addEventListener("load",l),t.addEventListener("error",l))}}var zs=0;function tb(e,t){return e.stylesheets&&e.count===0&&Kr(e,e.stylesheets),0zs?50:800)+t);return e.unsuspend=l,function(){e.unsuspend=null,clearTimeout(i),clearTimeout(u)}}:null}function Qr(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Kr(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Zr=null;function Kr(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Zr=new Map,t.forEach(nb,e),Zr=null,Qr.call(e))}function nb(e,t){if(!(t.state.loading&4)){var l=Zr.get(e);if(l)var i=l.get(null);else{l=new Map,Zr.set(e,l);for(var u=e.querySelectorAll("link[data-precedence],style[data-precedence]"),s=0;s"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(r){console.error(r)}}return n(),Us.exports=vb(),Us.exports}var kb=xb();const Sb=`--- +title: "Agents" +description: "One adaptive Research agent, bounded internal task profiles, a read-only plan mode, and custom agent profiles." +icon: "bot" +--- + +\`research\` is the single user-facing built-in agent. It owns the whole loop: literature review, hypothesis, code, experiments on real compute, analysis, and write-up. It loads narrow domain skills when they help and delegates only when a bounded piece of work is genuinely independent. + +## Built-in roster + +| Agent | Role | What it does | +| --- | --- | --- | +| \`research\` | Default | Scientific research across the full skill library — literature, data analysis, compute, and synthesis. | +| \`plan\` | Mode | Read-only planning. Edit tools are disabled except for plan files. | + +Research has three canonical internal task profiles. They are hidden from the picker and selected by the kind of work, not by a user-facing persona: + +| Profile | What it does | +| --- | --- | +| Explore | Bounded read/search work: inspect a codebase, source set, or project state. | +| Execute | Bounded implementation or computation using the active project permissions. | +| Review | Proportionate read-only review of files, results, citations, and provenance. | + +Older domain and helper names remain as hidden compatibility profiles so existing config and sessions keep working; they are not the product roster. Run \`openscience agent list\` to inspect the complete registry on your install, including hidden compatibility and system profiles. + +## Choose Research effort + +\`\`\`bash +# focused by default +openscience run "Fit the dispersion relation in data/spectra.csv" + +# wider bounded investigation when independent branches justify it +openscience run --effort ultra "Compare three defensible fitting approaches" +\`\`\` + +Normal and Ultra use the same Research harness. Ultra raises the bounded delegation allowance; it does not switch to a different persona or force delegation. Plan mode is available when you want to agree on method, spend, or consequential actions before execution. + +Custom agents remain available for teams with a deliberate specialized workflow. Set a custom agent with mode \`primary\` or \`all\` as \`default_agent\` in \`openscience.json\`; \`subagent\` profiles are reachable only by delegation. + +## Create a custom agent + +\`openscience agent create\` walks you through it interactively. The non-interactive form: + +\`\`\`bash +openscience agent create \\ + --path .openscience \\ + --description "Reviews analysis notebooks for data leakage and unsound statistics" \\ + --mode subagent \\ + --tools "read,grep,glob" \\ + --model anthropic/claude-sonnet-5 +\`\`\` + +| Flag | Use | +| --- | --- | +| \`--path \` | Where to write the definition; an \`agent/\` folder is created inside it. Omit to choose project or global interactively. | +| \`--description \` | What the agent is for. A model expands this into the name, system prompt, and delegation description. | +| \`--mode \` | \`primary\`: leads a session. \`subagent\`: only reachable by delegation. \`all\`: both. | +| \`--tools \` | Allow-list of tools. Anything left off the list is disabled. | +| \`--model \` | Default model for this agent. | + +The result is a Markdown file: YAML frontmatter (description, mode, tools) plus a body that is the system prompt. Edit it like any prose file. + +## Tool restrictions + +\`--tools "read,grep,glob"\` produces a read-only agent that cannot be tricked into editing files. The available tools are \`bash\`, \`read\`, \`write\`, \`edit\`, \`list\`, \`glob\`, \`grep\`, \`webfetch\`, \`task\`, \`todowrite\`, and \`todoread\` — run \`openscience agent create --help\` for the list on your version. + +## Where agents live + +\`\`\`text +.openscience/agent/ # project-scoped +~/.config/openscience/agent/ # user-global + # ships with OpenScience +\`\`\` + +Resolution is project-local, then user-global, then built-in. + +## What's next + + + + The 295-skill library Research draws on. + + + Start, resume, and share sessions with any agent. + + + Per-agent default models and provider routing. + + + Full \`agent create\` and \`agent list\` reference. + + +`,wb=`--- +title: "Connect to Atlas" +description: "Optionally link OpenScience to the Atlas managed platform for wallet-billed frontier models, synced credentials, and research recorded into Atlas Graphs." +icon: "link" +--- + +OpenScience never requires an account: bring-your-own-key usage is free and never gated. Atlas is the managed platform, and connecting to it is optional. Atlas only meters the models it serves. + +## What connecting adds + +- **A managed model route.** Curated frontier models billed from a prepaid wallet, so you do not need per-provider keys. The default endpoint is \`app.syntheticsciences.ai\` (override with \`OPENSCIENCE_API_BASE\`). +- **Spend controls.** Managed/BYOK toggles for LLM and compute spend, per surface, in **Settings → Billing** on the dashboard. +- **Synced service credentials.** Store cloud and ML keys (Hugging Face, W&B, Modal, and others) once on your account and pull them to any machine. +- **Research graphs.** Record sessions and findings into Atlas Graphs, the durable research map. + +## Link your account + + + + \`\`\`bash + openscience login + \`\`\` + Opens your browser to approve the device. On headless or CI machines, pass \`--no-browser\` and paste a key, or run \`openscience login --key thk_...\` with a key created at \`app.syntheticsciences.ai/cli\`. + + + \`\`\`bash + openscience status + \`\`\` + One answer to "what's my Atlas state?" — the connected user and device, synced-credential count, wallet balance and lifetime spend, recent usage, whether managed compute is available, and the bundled \`atlas\` companion version. + + + \`\`\`bash + openscience sync + \`\`\` + Refreshes synced service credentials after you change them on the dashboard. + + + +## Model routing and billing + +Key routing is per-provider and automatic: if you set a BYOK key for a provider, OpenScience uses it; otherwise the request goes through the Atlas managed route and debits your wallet. + +\`\`\`bash +openscience wallet # wallet balance and current key routing +openscience wallet topup # opens the Plan tab — $50 or $200, one-time or recurring monthly +\`\`\` + +Flip managed versus BYOK per surface (LLM and compute) in **Settings → Billing**. BYOK works on every plan. + +## Where credentials live + +Connected, your account holds the canonical copy of every synced credential; the local \`~/.config/openscience/credentials.json\` is a mirror you can delete and re-pull with \`openscience sync\`. In BYOK mode, keys exist only on your machine — see [Security](/openscience/security) for the storage and subprocess rules. + +## Account commands + +| Command | Use | +| --- | --- | +| \`openscience login\` | Authenticate via browser; \`--key thk_...\` or \`--no-browser\` for headless machines. | +| \`openscience status\` | Connection, user, device, and synced credential count. | +| \`openscience sync\` | Re-pull synced service credentials from the dashboard. | +| \`openscience devices\` | List authenticated devices; revoke from the Devices tab on the dashboard. | +| \`openscience logout\` | Disconnect this machine. BYOK keeps working. | + +## What's next + + + + Run one continuous research session end to end. + + + Every subcommand, including connect and billing. + + + BYOK provider setup and model selection. + + + What leaves your machine in each mode. + + +`,Cb='---\ntitle: "Command reference"\ndescription: "Every openscience subcommand: workspace, run, sessions, models, agents, skills, and lifecycle."\nicon: "terminal"\n---\n\nThe OpenScience command surface. For live help, run `openscience --help` and `openscience --help`.\n\nThe bare `openscience` (no arguments) starts the local server and opens the **browser workspace** in your working directory. Everything else is grouped below.\n\n## Global flags\n\n| Flag | Use |\n| --- | --- |\n| `-v, --version` | Print the installed version. |\n| `-h, --help` | Print help for a command. |\n| `--print-logs` | Stream agent diagnostics to stderr. |\n| `--log-level ` | Override the log threshold. |\n\n## Workspace and runs\n\n| Command | Use |\n| --- | --- |\n| `openscience` | Start the server and open the browser workspace. |\n| `openscience run [message..]` | One-shot prompt in the terminal; streams, then exits. |\n| `openscience session list` | List recent sessions (`-n`, `--format`). |\n\n`run` flags: `-c/--continue`, `-s/--session `, `-m/--model `, `--variant ` (provider-specific reasoning effort), `--effort ` (Research breadth), `--format `, `-f/--file `, `--attach ` (attach to a running server, e.g. `http://localhost:4096`), `--port`, `--title `.\n\n```bash\nopenscience run "Plot the attention entropy across layers for this checkpoint"\nopenscience run -c "Now sweep the temperature and re-plot"\nopenscience run --format json "Summarize the experiment in results/" > summary.json\n```\n\n## Account\n\nConnecting an Atlas account is optional — see [Atlas](/openscience/atlas). OpenScience runs fully standalone with your own provider keys.\n\n| Command | Use |\n| --- | --- |\n| `openscience init` | First-run setup wizard — choose managed models, your own keys, or skip. Rerun anytime (alias `onboard`). |\n| `openscience login` | Connect your Atlas account (browser flow, or `--key thk_…`). |\n| `openscience status` | One view of your Atlas state — account, synced services, subscription, wallet balance + lifetime spend, recent usage, managed-compute availability, and the bundled `atlas` companion version (alias `whoami`). |\n| `openscience sync` | Re-pull synced credentials. |\n| `openscience devices` | List authorized devices. |\n| `openscience logout` | Disconnect this device from Atlas. |\n| `openscience doctor` | Report what\'s configured: account, provider keys, wallet, default model. |\n\n`openscience connect …` still works as an alias for `login` / `logout` / `status` / `sync` / `devices`.\n\n## Providers and routing\n\n| Command | Use |\n| --- | --- |\n| `openscience keys add` | Add a provider API key (BYOK; OAuth sign-in where supported). Alias: `auth`. |\n| `openscience keys signin` | Sign in with ChatGPT / Codex (subscription). |\n| `openscience keys list` | List saved provider keys and active env vars. |\n| `openscience keys rm` | Remove a saved provider key. |\n| `openscience models` | List configured providers and their models. |\n| `openscience wallet` | Wallet balance and key routing (alias `billing`). |\n| `openscience wallet topup` | Open the app to add credit. |\n\n## Sandbox\n\n| Command | Use |\n| --- | --- |\n| `openscience sandbox` | Show sandbox status: OS backend + current policy. |\n| `openscience sandbox enable` | Confine local execution to approved paths (`--network deny`, `--allow `, `--on-unavailable `, `--[no-]require-project-trust`). |\n| `openscience sandbox disable` | Turn the sandbox off. |\n| `openscience sandbox test` | Prove containment on this machine (writes inside/outside the workspace, network egress). |\n\nEnabled and fail-closed by default. Routine contained work does not require project trust; remote jobs, kernel environment changes, project extensions, and host execution do. See [Sandbox and project trust](/openscience/sandbox).\n\n## Agent profiles\n\n| Command | Use |\n| --- | --- |\n| `openscience agent list` | Print the agent roster on this install. |\n| `openscience agent create` | Scaffold a custom agent (system prompt + tool set + routing policy). |\n\nFlags: `--path`, `--description`, `--mode `, `--tools`, `--model`. See [Agents](/openscience/agents).\n\n## Skills\n\n| Command | Use |\n| --- | --- |\n| `openscience skill list` | Show installed skills (`--all` for the bundled set). |\n| `openscience skill show ` | Print a skill\'s metadata and entrypoints. |\n| `openscience skill add ` | Install a skill (URL or `gh:owner/repo`). |\n| `openscience skill new` / `edit` / `validate` | Author, edit, and check user skills. |\n| `openscience skill remove ` | Uninstall a skill. |\n| `openscience skill set-entries ` | Update which skills surface in the `/` picker. |\n\n## Local server and protocols\n\n| Command | Use |\n| --- | --- |\n| `openscience web` | Start the server and open the browser workspace (prints the URL, e.g. `http://localhost:4096`). |\n| `openscience serve` | Headless local server — loopback-only (`127.0.0.1`), no browser. |\n| `openscience acp` | Start an Agent Client Protocol server for editors like Zed. |\n| `openscience mcp` | Manage MCP servers (`list`, `add`, `remove`, `auth`). |\n\n`web` and `serve` take `--port` and `--cors`. See [Workspace](/openscience/workspace).\n\n## GitHub and PRs\n\n| Command | Use |\n| --- | --- |\n| `openscience github` | GitHub agent for CI/Actions (`install`, `run`). |\n| `openscience pr ` | Check out a PR branch, then launch the agent on it. |\n\n## Project and lifecycle\n\n| Command | Use |\n| --- | --- |\n| `openscience project` | Pin the Atlas project root for this folder (`merge`). |\n| `openscience export` / `openscience import` | Export or import a session as JSON. |\n| `openscience generate` | Emit the OpenAPI spec for the local server to stdout. |\n| `openscience stats` | Local token-usage and cost stats. |\n| `openscience debug` | Structured diagnostics for support (`debug paths` shows data/config dirs). |\n| `openscience upgrade` | Update to the latest version. |\n| `openscience uninstall` | Remove the binary and local config. |\n| `openscience completion` | Shell completion for bash, zsh, or fish. |\n\n```bash\nopenscience upgrade\nopenscience completion zsh > ~/.zsh/completions/_openscience\n```\n\n## What\'s next\n\n\n \n The browser workspace: files, editor, sessions, and inline scientific rendering.\n \n \n Credential boundary, env hygiene, the trust boundary.\n \n \n 295 bundled research skills and the scientific databases.\n \n \n The Research harness, effort levels, plan mode, and custom profiles.\n \n\n',Eb=`--- +title: "OpenScience" +description: "The open-source AI workbench for scientific research. Give it a goal — it reads the literature, writes and runs code, runs the experiments, and writes up what it found." +icon: "flask-conical" +--- + + + +OpenScience is an AI workbench for scientific research. You give it a goal, and it works through the research loop the way a capable collaborator would: it reads the papers that matter, forms a hypothesis, writes and runs code, runs experiments on real compute, queries the major scientific databases, and writes up the result. + +It runs as a workspace in your browser, works with any frontier or open-weight model using your own API keys, and requires no account. It is model-agnostic, Apache-2.0 licensed, and built to do real work in machine learning, biology, physics, and chemistry. + +\`\`\`bash +npm install -g @synsci/openscience +openscience +\`\`\` + +That's the whole install. The command is \`openscience\`, and it opens the workspace in your browser. Prefer not to install globally? \`npx synsci\` does the same thing in one step. + +## What we built + +Everything below ships in the open-source CLI — no gated tiers, no server-side magic you can't read. + + + + Literature review, hypothesis, code, experiment, analysis, and write-up in one continuous session. Queue follow-up prompts while it streams; rewind with undo-from-here. + + + One user-facing harness owns the task end to end, loads domain skills lazily, and delegates bounded Explore, Execute, or Review work only when useful. Includes Normal and Ultra effort plus read-only plan mode. + + + Training (DeepSpeed, PEFT, TRL), evaluation, dataset work, molecular and clinical biology, cheminformatics, papers and LaTeX, figures, and cloud compute. + + + UniProt, PDB, Ensembl, ChEMBL, PubChem, arXiv, OpenAlex, Semantic Scholar, and around 30 more, queryable directly by the agent. + + + A browser UI with a file tree, an editor, session history, and inline rendering for molecules, structures, genomes, and plots. + + + LSP integration, MCP servers, plugins, custom agents and commands, and a TypeScript SDK. + + + +## Any model, your keys + +Set an API key from any provider and start working. Keys stay on your machine. + +\`\`\`bash +export ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY, GEMINI_API_KEY, ... +openscience +\`\`\` + +OpenScience routes to frontier and open-weight models from Anthropic, OpenAI, Google, and dozens of other providers. Reasoning-effort tiers are first-class: \`--variant high|max|minimal\` on any run. See [Models](/openscience/models). + +## Open source, all the way down + +The workbench is [Apache-2.0 on GitHub](https://github.com/synthetic-sciences/openscience). No account, no telemetry wall, no closed core: the agent loop, the skills, the database tools, and the workspace are all in the repo. Read it, fork it, extend it — and if it does real work for you, [star it](https://github.com/synthetic-sciences/openscience) so other researchers find it. + +- **Site** — [openscience.sh](https://openscience.sh) +- **Issues and ideas** — [github.com/synthetic-sciences/openscience/issues](https://github.com/synthetic-sciences/openscience/issues) +- **Releases and platform binaries** — [GitHub Releases](https://github.com/synthetic-sciences/openscience/releases) +- **Package** — [\`@synsci/openscience\` on npm](https://www.npmjs.com/package/@synsci/openscience) + +## Works with Atlas, never requires it + +Connect an Atlas account and OpenScience records its research into [Atlas graphs](/openscience/atlas) — durable hypotheses, runs, and decisions your whole team can audit — plus an optional managed model route with spend controls. Standalone mode keeps everything local. See [Connect to Atlas](/openscience/atlas). + +## Start here + + + + Install, set a key, and run your first research session in five minutes. + + + A tour of the browser workspace and the local server behind it. + + +`,Ab='---\ntitle: "Local models"\ndescription: "Run OpenScience against a local LLM — Ollama, LM Studio, llama.cpp, vLLM, or any OpenAI-compatible endpoint. Free, offline, no API key."\nicon: "server"\n---\n\nOpenScience talks to any **OpenAI-compatible** endpoint running on your machine. That covers [Ollama](https://ollama.com), [LM Studio](https://lmstudio.ai), [llama.cpp](https://github.com/ggml-org/llama.cpp)\'s server, [vLLM](https://github.com/vllm-project/vllm), [Jan](https://jan.ai), and anything else that speaks the OpenAI `/v1` API. Local models run entirely on your hardware and are **free, offline, and never metered against Credits**.\n\n## Quick start (Ollama)\n\n```bash\nollama serve # start the server (usually already running)\nollama pull llama3.1 # pull a model if you haven\'t\n\nopenscience local add # detects Ollama, lists its models, registers them\nopenscience run --model ollama/llama3.1 "explain diffusion models"\n```\n\n`openscience local add` probes the well-known local ports, discovers the models each running server exposes (`GET /v1/models`), lets you pick which to add, writes them to your config, and offers to set a default. That\'s it — the model then shows up in `openscience models` and the [workspace](/openscience/workspace) picker like any other provider.\n\n## The `local` command\n\n```bash\nopenscience local # interactive wizard (same as `local add`)\nopenscience local add # detect + add a local endpoint\nopenscience local list # show configured local providers\nopenscience local remove \n```\n\nNon-interactive (scripting / CI):\n\n```bash\n# Register every model an endpoint exposes:\nopenscience local add --url http://localhost:11434/v1 --id ollama --default\n\n# Register a specific model without discovery:\nopenscience local add --url http://localhost:1234/v1 --id lmstudio --model qwen2.5-coder\n```\n\n| Flag | Meaning |\n| --- | --- |\n| `--url` | Endpoint base URL (a bare `host:port` is normalized to `http://host:port/v1`). |\n| `--model` | Model id(s) to register; repeatable. Omit to auto-discover all. |\n| `--id` | Provider id to register under (defaults to the runtime name or `local-`). |\n| `--key` | API key, if your endpoint requires one (most local servers don\'t). |\n| `--project` | Write to the project\'s `openscience.json` instead of the global config. |\n| `--default` | Set the first model as the default. |\n\n## Common endpoints\n\n| Runtime | Default base URL |\n| --- | --- |\n| Ollama | `http://localhost:11434/v1` |\n| LM Studio | `http://localhost:1234/v1` |\n| llama.cpp (`llama-server`) | `http://localhost:8080/v1` |\n| vLLM | `http://localhost:8000/v1` |\n| Jan | `http://localhost:1337/v1` |\n\n## From the workspace GUI\n\nOpen **Settings → Local models**. You can start a detected runtime, copy commands for your system terminal, or connect an endpoint:\n\n- **Run a model locally** — for a detected runtime (Ollama, LM Studio), click **start** and OpenScience launches the server for you (`ollama serve`), waits for it, and adds its models. If the runtime isn\'t installed, the button links to its installer.\n- **Pull a model** — type a model name and click **Copy command**. Paste and run `ollama pull ` in your system terminal; OpenScience copies the command but does not execute it.\n- **Custom endpoint** — paste any `http://host:port/v1`, list its models, and pick which to add.\n\nThe workspace does not provide a Terminal tab. To run `ollama serve` or `ollama pull ` yourself, copy the command from the Local models panel and paste it into your system terminal. When the command finishes, return to the panel and click **rescan**; OpenScience detects the running server automatically.\n\n## By hand (config)\n\nA local endpoint is just a provider block in `openscience.json`. `openscience local add` writes this for you, but you can edit it directly:\n\n```jsonc\n{\n "provider": {\n "ollama": {\n "name": "Ollama (local)",\n "npm": "@ai-sdk/openai-compatible",\n "options": { "baseURL": "http://localhost:11434/v1" },\n "models": {\n "llama3.1": { "limit": { "context": 32768, "output": 8192 } }\n }\n }\n }\n}\n```\n\n- `models` is **required** — an id with no models is dropped.\n- `apiKey` under `options` is optional; omit it for a keyless server.\n- Give the block a loopback `baseURL` (`localhost` / `127.0.0.1`) and OpenScience treats it as a local, free, BYOK-class provider.\n\n## Notes\n\n- **Billing:** local providers are always classified as your-own (BYOK) — never wallet-gated, never billed. They stay available even if your Atlas LLM spend is set to *managed* (the wallet routes through the curated OpenRouter catalog and the direct Meta/Muse proxy, never through local endpoints).\n- **Tools & reasoning:** models are registered with `tool_call: true` by default. If a local model doesn\'t support tool calling, set `"tool_call": false` on that model in config.\n- **New models:** after `ollama pull `, re-run `openscience local add` to register it, or add it under `models` in config.\n',Tb='---\ntitle: "Models & providers"\ndescription: "Bring your own keys for any provider, switch models per run, and optionally route through Atlas managed."\nicon: "package-check"\n---\n\nOpenScience is model-agnostic. A **model** is one inference endpoint the agent routes through; a **provider** is a vendor that exposes models. Routing happens on your machine: with your own keys (the default), each call goes straight from your machine to the provider — no gateway in the middle, no account required.\n\n## Bring your own keys\n\nSet a key from any provider and start working:\n\n```bash\nexport ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY, GEMINI_API_KEY, ...\nopenscience\n```\n\nKeys stay on your machine. Anthropic, OpenAI, Google, OpenRouter, Groq, Mistral, xAI, Meta Model API, DeepSeek, and dozens more work the same way — the catalogue is models.dev-backed, so any `provider/model` id a provider currently exposes is addressable. You can also paste keys into the Credentials panel in the [workspace](/openscience/workspace) instead of exporting them, and OpenAI Codex signs in via OAuth.\n\nThe current frontier families are available on both paths:\n\n| Family | BYOK model | Atlas managed model |\n| --- | --- | --- |\n| GPT-5.6 Sol / Terra / Luna | `openai/gpt-5.6-*` | `openrouter/openai/gpt-5.6-*` |\n| Grok 4.5 | `xai/grok-4.5` | `openrouter/x-ai/grok-4.5` |\n| Muse Spark 1.1 | `meta/muse-spark-1.1` | `meta/muse-spark-1.1` through the Atlas Meta proxy |\n\nFor BYOK, use `OPENAI_API_KEY`, `XAI_API_KEY`, or `META_MODEL_API_KEY`. The managed Meta route sends only your Atlas session token and proxy URL to OpenScience; Atlas keeps its shared Meta credential server-side.\n\nCurrent frontier families include:\n\n| Provider | Model IDs |\n| --- | --- |\n| Anthropic | `claude-opus-5`, `claude-sonnet-5` |\n| OpenAI | `gpt-5.6`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` |\n| xAI | `grok-4.5`, `grok-4.3`, `grok-build-0.1`, and Grok 4.20 reasoning, non-reasoning, and multi-agent models |\n| Moonshot AI | `kimi-k3` |\n| Vercel AI Gateway | `meta/muse-spark-1.1` |\n\nThis is not a hardcoded allowlist for your own provider keys. OpenScience refreshes models.dev at startup and hourly, so newly published models appear without waiting for an OpenScience release. The OpenAI Codex OAuth provider is the exception: it only shows models verified to work through a ChatGPT subscription, currently including the full GPT-5.6 family.\n\n## Local models\n\nPrefer to run offline? OpenScience talks to any OpenAI-compatible endpoint on your machine — Ollama, LM Studio, llama.cpp, vLLM, and more:\n\n```bash\nopenscience local add # detects Ollama / LM Studio, lists + adds their models\nopenscience run --model ollama/llama3.1 "hello"\n```\n\nLocal models are free and never metered. See **[Local models](/openscience/local-models)** for the full guide.\n\n## List what is configured\n\n```bash\nopenscience models # grouped by provider, with a routing label per provider\nopenscience models anthropic # one provider\n```\n\nEach provider is labeled `your key`, `managed`, `local`, `Signed in with Codex`, or `unconfigured`, so you always know which route a model takes. `--verbose` adds per-model metadata like costs, `--refresh` refetches the catalogue, and `--flat` prints one `provider/model` id per line for scripting.\n\n## Pick a model per run\n\n```bash\nopenscience run --model anthropic/claude-opus-5 --variant xhigh "Design the ablation study"\nopenscience run --model anthropic/claude-sonnet-5 "Clean up the plotting code"\nopenscience run --model openai/gpt-5.6-terra --variant max "Review the analysis"\nopenscience run --model moonshotai/kimi-k3 "Implement the pipeline"\n```\n\n`--model /` overrides the default for one run; in the workspace, use the model selector per session. `--variant` picks a reasoning-effort tier — `minimal`, `high`, `max`, and so on. Available tiers depend on the model: Claude Opus 5 and GPT-5.6 extend through `xhigh` and `max`, Kimi K3 supports `low`, `high`, and `max`, and current Grok models expose their provider-supported ladders.\n\n## Atlas managed (optional)\n\n[Atlas](/openscience/atlas) is the managed platform. Connecting adds a curated set of frontier models billed from a prepaid wallet, so you can skip per-provider keys entirely:\n\n```bash\nopenscience login # defaults to app.syntheticsciences.ai\n```\n\nOnce connected, launching the workspace syncs your provider config and credentials, and Settings → Billing has independent managed/BYOK spend toggles for LLM calls and compute — mix routes freely, per concern. BYOK usage is always free and never gated; Atlas only meters the models it serves.\n\n| Path | What runs where | Billing |\n| --- | --- | --- |\n| BYOK (default) | Your machine calls the provider directly. | Your provider account. |\n| Atlas managed | Your machine calls Atlas, which calls the provider. | Prepaid wallet. See [Connect to Atlas](/openscience/atlas). |\n\n## Key hygiene\n\nProvider keys and synced credentials are filtered out of the environment of every subprocess the agent spawns and redacted from output. The agent talks to providers itself; the code it runs never needs your keys. Credentials are stored per user in your home directory, never in the project\'s `.openscience/` directory, so they cannot end up in a commit. See [Security](/openscience/security).\n\n## What\'s next\n\n\n \n First key to first result.\n \n \n Per-run model and variant flags in context.\n \n \n What connecting to Atlas adds.\n \n \n The env allow-list and trust boundary.\n \n\n',zb=`--- +title: "Quickstart" +description: "Install OpenScience, set a provider key, and run your first research session." +icon: "rocket" +--- + +Five minutes from zero to a running research session. No account required. + + + + \`\`\`bash + npm install -g @synsci/openscience + \`\`\` + + Or without a global install, \`npx synsci\` runs the same workbench in one step. The install script (\`curl -fsSL https://openscience.sh/install | bash\`) drops a standalone \`openscience\` binary into \`~/.openscience/bin\`, and platform binaries are attached to every [GitHub release](https://github.com/synthetic-sciences/openscience/releases). + + Linux requires kernel 5.1 or newer. Glibc builds require glibc 2.17 or newer, and musl builds are selected separately. CentOS 7's stock 3.10 kernel is not supported; use a newer host kernel or VM. + + + \`\`\`bash + export ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY, GEMINI_API_KEY, ... + \`\`\` + + Any provider works — bring your own key and it stays on your machine. \`openscience keys add\` gives you an interactive sign-in for providers that support it. See [Models](/openscience/models) for routing and reasoning-effort tiers. + + + \`\`\`bash + openscience + \`\`\` + + The command starts a local server and opens the browser workspace: file tree, editor, and session history, with inline rendering for molecules, structures, genomes, and plots. See [Workspace](/openscience/workspace). + + + Ask for the outcome, not the steps. The default \`research\` agent plans, reads, codes, runs, and reports: + + \`\`\`text + Reproduce the headline result of arXiv:2305.13245 on a small model + and tell me whether it holds at 125M parameters. + \`\`\` + + Prefer your system terminal? One-shot runs work without the browser: + + \`\`\`bash + openscience run "Profile train.py and find the input-pipeline bottleneck" + \`\`\` + + + \`\`\`bash + openscience login + \`\`\` + + Connecting an [Atlas](/openscience/atlas) account records your research into durable graphs and unlocks a managed model route with spend controls. OpenScience never requires it. See [Connect to Atlas](/openscience/atlas). + + + +## Verify the install + +\`\`\`bash +openscience --version +openscience models # configured providers and models +openscience skill list # installed skills +\`\`\` + +## Where next + + + + One adaptive Research agent, Normal and Ultra effort, and read-only plan mode. + + + Every subcommand: runs, sessions, skills, server, lifecycle. + + +`,Ob=`--- +title: "Sandbox and project trust" +description: "How OpenScience lets agents work autonomously inside enforced local boundaries." +icon: "lock" +--- + +OpenScience separates three controls that solve different problems: + +- **Tool permissions** decide whether an agent may take an action. +- **The execution sandbox** limits what a spawned terminal, shell command, Python/R kernel, or local compute job can reach. +- **Project trust** controls remote jobs, kernel environment changes such as package installs, project-owned executable code such as plugins and MCP servers, and execution with full host authority. + +This separation keeps routine work moving without treating every newly opened project as trusted code. By default, terminals, kernels, shell commands, and local jobs may run immediately **only when OpenScience can enforce the OS sandbox**. Remote jobs, kernel environment changes, project extensions, and unsandboxed execution remain blocked until you explicitly trust that project. + +## Default behavior + +The global defaults are: + +\`\`\`jsonc +{ + "sandbox": { + "enabled": true, + "network": "deny", + "allowWrite": [], + "onUnavailable": "error", + "requireProjectTrust": false + } +} +\`\`\` + +With a working native backend, routine commands can read and write only the active session workspace and explicitly granted paths. Network access is denied, including loopback, LAN, link-local, and metadata endpoints. When no backend is available, the default \`onUnavailable: "error"\` refuses to run rather than silently falling back to the host. + +Set \`requireProjectTrust: true\` if you prefer the stricter posture where every new project must be trusted before it starts any local process, even inside a verified sandbox. + +## Permissions and sandboxing + +Permissions and sandboxing work together. A permission allows a particular tool action; it does not widen the filesystem or network boundary of the spawned process. Conversely, the sandbox does not approve a tool call that your permission rules deny or require you to review. + +This means ordinary approved work can continue autonomously inside a boundary you already chose. Crossing that boundary requires a separate policy change or grant rather than repeated command-by-command workarounds. + +## Project trust + +A new project starts with project-owned executable code disabled. You can still use sandboxed terminals, Python/R kernels, shell commands, and local compute unless **Require project trust** is enabled. + +Trust a project only after reviewing its local configuration and code. Trust enables remote jobs, kernel environment changes such as package installs, project plugins, MCP processes, formatters, LSP commands, provider token commands/modules, publication exporters, repository/startup commands, and other project-defined executable hooks. It also allows routine execution when you intentionally turn the sandbox off or configure an unsandboxed fallback. + +Open **Settings → Permissions → Project code** to trust or revoke the current project. Revoking trust stops existing project processes and disables remote jobs, kernel environment changes, and project-owned extensions. Sandboxed routine work remains available unless your Sandbox policy requires trust for all execution. + +## Native backends + +| Platform | Backend | Requirement | +| --- | --- | --- | +| macOS | Seatbelt (\`sandbox-exec\`) | Built in. | +| Linux | bubblewrap (\`bwrap\`) | Install \`bubblewrap\`; unprivileged user namespaces must work. | +| Windows | unavailable | Commands follow \`onUnavailable\`; the default is to refuse. | + +On Ubuntu/Debian: + +\`\`\`bash +sudo apt install bubblewrap +\`\`\` + +OpenScience probes the backend before claiming it is available. Use the self-test below to verify the effective boundary on the current machine. + +## CLI + +\`\`\`bash +openscience sandbox # show backend and effective global policy +openscience sandbox enable # enable fail-closed containment +openscience sandbox enable --network deny +openscience sandbox enable --allow /data/shared +openscience sandbox enable --on-unavailable error +openscience sandbox enable --require-project-trust +openscience sandbox enable --no-require-project-trust +openscience sandbox disable # host execution still requires project trust +openscience sandbox test # run real read/write/network containment probes +\`\`\` + +\`sandbox test\` runs actual sandboxed commands. It requires an allowed workspace write to succeed, outside writes and ungranted reads to fail, and effective network isolation to match the backend claim. If it does not report **Containment verified**, do not rely on that backend. + +## Workspace settings + +Open **Settings → Sandbox** to: + +- enable or disable native containment; +- require explicit project trust for all execution; +- inspect the detected backend and enforced capabilities; +- choose fail-closed behavior when containment is unavailable; +- add narrowly scoped writable roots outside the workspace; and +- run the empirical self-test. + +Extra writable roots extend the sandbox; they do not trust project extensions. Prefer a narrow data or output directory over a home directory or other broad ancestor. + +## Global and managed configuration + +Sandbox policy is read only from global and managed configuration. A project's \`openscience.json\` cannot weaken it. Managed configuration overrides the user's global values. + +| Key | Meaning | Default | +| --- | --- | --- | +| \`enabled\` | Request the native sandbox for local execution. | \`true\` | +| \`network\` | Requested network policy. Current native backends enforce deny-all. | \`"deny"\` | +| \`allowWrite\` | Extra absolute writable roots beyond session grants. | \`[]\` | +| \`onUnavailable\` | \`"error"\`, \`"warn"\`, or \`"allow"\` when no backend can run. | \`"error"\` | +| \`requireProjectTrust\` | Block all execution until the project is explicitly trusted. | \`false\` | + +\`warn\` and \`allow\` may run a trusted project without OS containment. An untrusted project never receives host execution merely because fallback is permissive. + +## Boundary and limitations + +The sandbox applies to spawned commands, including shell utilities, compilers, package managers, notebook kernels, and local detached jobs. It is a real OS boundary, but it is not a VM: + +- Only explicitly granted filesystem roots are mounted or allowed. Files already readable inside those roots remain readable to the command. +- Current macOS and Linux backends deny IP networking rather than trying to distinguish safe public destinations from loopback or private services. +- Host-brokered tools such as WebFetch apply their own URL, size, and destination controls outside the command sandbox. +- Linux requires a working bubblewrap/user-namespace setup. Windows currently has no filesystem sandbox backend. +- For actively hostile code or stronger kernel isolation, run OpenScience itself inside a container or VM as an additional boundary. + +The safe low-friction preset is the default: sandbox enabled, unavailable backend refused, project trust not required for routine contained work. Full host access is an explicit combination: disable containment (or allow fallback) **and** trust the current project. +`,_b=`--- +title: "Security" +description: "The trust boundary, credential storage, execution sandbox, and subprocess environment hygiene — in an open-source agent you can audit end to end." +icon: "shield-check" +--- + +OpenScience is open source under Apache-2.0, so the whole security model below is auditable in the [repository](https://github.com/synthetic-sciences/openscience). The agent runs locally, while spawned commands are constrained by the native sandbox and explicit filesystem grants by default. Two principles anchor the design: keep credentials out of subprocesses that do not need them, and make each trust boundary explicit instead of treating permissions, sandboxing, and project trust as the same control. + +## Permissions are not the sandbox + +The permission system decides whether an agent may take an action. It is not, on its own, an isolation boundary. + +OpenScience enables a real [execution sandbox](/openscience/sandbox) by default. It wraps terminals, shell commands, Python/R kernels, and local jobs in macOS Seatbelt or Linux bubblewrap, limits them to the workspace and approved paths, and denies network egress. Routine work can start immediately when that boundary is enforced; remote jobs, kernel environment changes, project-owned extensions, and host execution still require explicit project trust. The default refuses to run when no native backend is available rather than silently falling back to the host. Verify the effective boundary with \`openscience sandbox test\`. It is OS containment, not a full VM — for actively hostile code, still run OpenScience inside a container or VM. + +## Credential storage + +| Surface | Where | Notes | +| --- | --- | --- | +| BYOK provider keys | Environment variables or \`/auth.json\` | Model requests go straight to the provider. No account required. Approved skill subprocesses can receive supported user-owned keys; arbitrary Python/R kernels receive a minimal environment without provider keys. | +| Atlas session | \`/openscience-session.json\` | \`thk_*\` key created by \`openscience login\`; revocable from the dashboard. | +| Synced service credentials | \`/credentials.json\` | Present only when [connected to Atlas](/openscience/atlas); encrypted at rest and refreshed with \`openscience sync\`. | +| Native binary (curl install) | \`~/.openscience/bin/openscience\` | Added to PATH through your shell rc. | + +The data root defaults to \`~/.openscience\` and can be relocated from Storage settings or with \`OPENSCIENCE_DATA_DIR\`. Override the config directory with \`OPENSCIENCE_CONFIG_DIR\` (or its XDG parent with \`XDG_CONFIG_HOME\`). Run \`openscience debug paths\` to print the resolved data, config, cache, and state directories on your machine. + +## Subprocess environment boundaries + +Shell tools receive a sanitized environment: managed Atlas tokens and control-plane variables are stripped, while ordinary user environment values and supported user-owned provider or service credentials may be available to approved commands. Credential files, SSH/cloud config, and other sensitive paths are denied to OS-sandboxed Python/R kernels; kernels also receive a minimal runtime and locale environment without provider, Atlas, or cloud keys. + +Managed Modal credentials are narrower still: they resolve only inside the trusted compute adapter after approval and are not injected into general shell or kernel environments. + +The browser's interactive system terminal is a user-owned shell rather than an agent tool. It inherits the user's environment (minus terminal-session bookkeeping), so treat it like any terminal you launch yourself. + +## Output redaction + +Known credential patterns are redacted from agent output before it lands in a transcript. If you spot an unredacted secret, report it (see below). + +## Server mode + +Server mode is opt-in. The server binds to localhost (127.0.0.1) only and enforces a Host and Origin allowlist to block DNS-rebinding and cross-origin requests. It is not built for remote exposure — if you tunnel or reverse-proxy it, securing that exposure is on you. + +## What leaves your machine + +- Prompts and responses sent to your model provider, governed by that provider's policy. +- Nothing else automatically, in BYOK mode. If you [connect to Atlas](/openscience/atlas), synced credentials and usage metering are held against your account. + +Source files stay local unless the agent explicitly uploads them through a tool you approve. Approved shell commands and user-owned terminals can still access credentials in their environment, while Python/R kernels stay on the minimal environment described above. + +## Reporting a vulnerability + +Report security issues through the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/synthetic-sciences/openscience/security/advisories/new) form — not on public issue trackers. If you do not hear back within six business days, email security@syntheticsciences.ai. +`,Mb='---\ntitle: "Sessions & one-shot runs"\ndescription: "One agent conversation: history, tool calls, and a working directory. Create, resume, attach, and export."\nicon: "layers"\n---\n\nA **session** is a single agent conversation: a thread of prompts and replies, the tool calls the agent made, and the working directory those calls ran against. Sessions are stored on disk, so everything below works offline and without an account.\n\n## Start a session\n\n| Entrypoint | Use |\n| --- | --- |\n| `openscience` | Open the [browser workspace](/openscience/workspace) in the current directory. |\n| `openscience run [message..]` | Send one prompt from the terminal, stream the result, and exit. Good for pipelines, CI, and git hooks. |\n| `openscience serve` | Start a headless server (no browser) for `run --attach` to target. |\n\nThe workspace is interactive; `openscience run` is the one to script. Each produces a session you can resume from either surface.\n\n## One-shot runs\n\n```bash\nopenscience run "Set up a DFT optimization for this structure" -f data/sample.cif\ngit diff | openscience run "Review this diff for race conditions"\n```\n\nPiped stdin is appended to the message. `openscience run` supports:\n\n| Flag | Use |\n| --- | --- |\n| `-c, --continue` | Continue the most recent session. |\n| `-s, --session ` | Continue a specific session by id. |\n| `-m, --model ` | Model for this run, e.g. `anthropic/claude-opus-4-8`. See [Models](/openscience/models). |\n| `--variant ` | Reasoning-effort tier (`high`, `max`, `minimal`; model-dependent). |\n| `--effort ` | Research breadth. Normal is focused; Ultra permits a wider bounded investigation. |\n| `--command ` | Run a custom command, with the message as its arguments. |\n| `-f, --file ` | Attach a local file to the message (repeatable). |\n| `--title ` | Title for the session in `session list`. |\n| `--format ` | `json` emits one JSON event per line (`tool_use`, `step_start`, `step_finish`, `text`, `error`), tagged with the session id — pipe it to a file and parse line by line. |\n| `--attach ` | Send the prompt to a running server instead of starting one. |\n| `--port ` | Port for the throwaway local server (random by default). |\n\nTo pick up a file, always use `-f`; `--attach` targets a server, it does not fetch documents.\n\n## Continue or resume\n\n```bash\nopenscience session list # recent sessions (-n 10 to cap, --format json)\nopenscience run -c "Now add tests for the refactored module"\nopenscience run -s ses_8f2ka91xk "Rerun the sweep with lr=3e-4"\n```\n\n`session list` prints a table of ids, titles, and update times (paged through `less` in a terminal). In the workspace, the same sessions appear in the session history pane, where you can also queue prompts and undo from any message.\n\n## Attach to a running server\n\nLeave `openscience serve` (or the workspace) running and drive it from another terminal:\n\n```bash\nopenscience run --attach http://localhost:4096 "Summarize today\'s results" # new session on the server\nopenscience run --attach http://localhost:4096 -s ses_8f2ka91xk "Continue" # a specific session (-c: latest)\n```\n\nThe run shares the server\'s sessions, so work started in the browser continues from the terminal and vice versa.\n\n## Export and import\n\n```bash\nopenscience export ses_8f2ka91xk > session.json # no id: interactive picker\nopenscience import session.json\n```\n\n`export` writes the full session as JSON to stdout; `import` loads it on another machine. Useful for sharing reproductions or attaching a transcript to an issue.\n\n## What\'s next\n\n\n \n The browser surface for the same sessions.\n \n \n Per-run and per-session model choice.\n \n \n The Research harness, effort levels, and plan mode.\n \n \n The full command reference.\n \n\n',Db=`--- +title: "Skills" +description: "295 bundled research skills, direct access to around 30 scientific databases, and commands for installing, writing, and pinning skills." +icon: "book-open" +--- + +A **skill** is a portable instruction bundle the agent loads into a session to prime it for a domain. OpenScience ships 295 of them, spanning the surface a working scientist actually hits. + +## Built-in categories + +| Category | Covers | +| --- | --- | +| Training | DeepSpeed, PEFT, TRL, distributed and parameter-efficient fine-tuning. | +| Evaluation | Harnesses, benchmarks, regression suites. | +| Datasets | Acquisition, cleaning, splits, augmentation. | +| Molecular & clinical biology | Sequence analysis, structures, omics, clinical data workflows. | +| Cheminformatics | Molecule handling, descriptors, property prediction. | +| Papers & LaTeX | Manuscript drafting, citations, submission-ready LaTeX. | +| Figures | Publication-quality plots and layouts. | +| Cloud compute | Modal, Tinker, and other GPU backends. | + +Run \`openscience skill list --all\` to print the full bundled set, grouped by category. + +## Scientific databases + +The major scientific databases are wired in as tools, not skills: UniProt, PDB, Ensembl, ChEMBL, PubChem, arXiv, OpenAlex, Semantic Scholar, and around 30 in total. Research queries them directly during a session — no API keys and no manual downloads required for public sources — and loads domain skills when their procedures or references help (see [Agents](/openscience/agents)). + +## Skill commands + +| Command | Use | +| --- | --- | +| \`openscience skill list\` | Show learned and installed skills; \`--all\` includes the bundled set. | +| \`openscience skill show [/]\` | Namespace summary, or a single skill's full \`SKILL.md\`. | +| \`openscience skill add \` | Install every skill from a public git repo; \`gh:owner/repo\` shorthand works. Runs a safety review. | +| \`openscience skill new \` | Scaffold a local user skill. | +| \`openscience skill edit \` | Open a user skill in \`$EDITOR\`. | +| \`openscience skill validate \` | Check a skill's frontmatter and safety (\`--strict\` fails on warnings). | +| \`openscience skill set-entries \` | Choose which skills in a namespace surface in the \`/\` picker. | +| \`openscience skill remove \` | Uninstall a skill or a whole namespace. | + +## Install third-party skills + +\`\`\`bash +openscience skill add gh:anthropics/superpowers +openscience skill list +\`\`\` + +Each skill in the repo passes a static safety check and an LLM safety review before it installs; rejected skills are skipped and reported. Installed skills are namespaced by repo, so \`remove \` uninstalls the whole set. + +### Community skills + +Skills contributed by the community that install with the command above: + +- **1000 Genomes genotypes** — [\`dnaerys/onekgpd-skill\`](https://github.com/dnaerys/onekgpd-skill) (MIT). Individual-level queries over the 1000 Genomes cohort (3,202 WGS samples, GRCh38): which individuals carry variants matching given criteria and zygosity, kinship between individuals, and cohort population/pedigree metadata. Queries a public, keyless [Dnaerys](https://dnaerys.org) endpoint (an external service the maintainers don't control), with an offline metadata tier. + + \`\`\`bash + openscience skill add gh:dnaerys/onekgpd-skill + \`\`\` + +## Write your own + +\`\`\`bash +openscience skill new leakage-checks --description "Checklists for spotting data leakage" --editor +\`\`\` + +A skill is a \`SKILL.md\` file with \`name\`, \`description\`, and \`category\` frontmatter and an instruction body. Iterate with \`openscience skill edit\` and check it with \`openscience skill validate\`. + +## Pin skills to a project + +Point the project config at extra skill folders with \`skills.paths\` in \`openscience.json\` at the project root: + +\`\`\`json +{ + "skills": { "paths": ["./skills", "../shared-skills"] } +} +\`\`\` + +Sessions started in that directory load those skills, so teammates and CI get the same primed agent. Existing \`.claude/skills/\` directories are picked up unchanged. + +## What's next + + + + Pair skills with custom agent profiles to build specialists. + + + Full \`skill\` subcommand reference. + + + How the safety review and the subprocess allow-list protect you. + + + Sync the cloud and ML credentials a skill needs. + + +`,Rb=`--- +title: "Workspace" +description: "The bare openscience command opens the browser workspace: files, editor, sessions, and inline scientific rendering." +icon: "app-window" +--- + +The workspace is how you use OpenScience. Run the bare command and it starts a local server, opens your browser, and gives you the full research surface: a file tree, an editor, session history, and inline rendering for molecules, structures, genomes, and plots. + +\`\`\`bash +openscience # open the workspace in the current directory +openscience ~/code/my-project # open it in a specific project +\`\`\` + +The server is local by design: it binds \`127.0.0.1\`, holds the agent in its own process, and serves the UI from the same port (\`http://localhost:4096\` by default; if 4096 is taken it falls back to a free port and prints the URL). Closing the browser tab does not kill the agent — reopen the URL to pick up where you left off. + +| Flag | Use | +| --- | --- | +| \`--port \` | Bind a specific local port instead of the default 4096. | +| \`--cors \` | Allow an extra CORS origin (repeatable). | + +The server always binds localhost — the deprecated \`server.hostname\` / \`server.mdns\` config keys are parsed but ignored. Persistent settings live in \`~/.config/openscience/openscience.json\` under \`server.port\` and \`server.cors\`. + +## What the workspace gives you + +- **File tree and editor.** Browse and edit the project the agent is working in; edits and diffs render inline. +- **System-terminal workflow.** The browser workspace does not include a Terminal tab; run project commands in your system terminal alongside the browser. +- **Session history.** Every conversation is a session; switch between them without losing context. +- **Inline scientific rendering.** Molecules, protein structures, genomes, and plots render directly in the transcript instead of as file paths. +- **Prompt queueing.** Type your next prompt while the agent is still streaming; queued prompts run in order. +- **Undo from here.** Roll the session back to any message and take a different path from that point. +- **Model selector and credentials panel.** Pick any configured model per session and add provider keys without leaving the browser. See [Models](/openscience/models). +- **Settings panels.** Manage credentials, skills, and storage, and — when connected to Atlas — a **Wallet** panel showing your balance, billing mode, and recent usage, without leaving the browser. + +## Headless server + +\`openscience serve\` starts the same server without opening a browser — useful for keeping a long-lived agent running that other terminals attach to. It takes the same \`--port\` and \`--cors\` flags and is loopback-only (\`127.0.0.1\`). + +\`\`\`bash +openscience serve --port 4096 +openscience run --attach http://localhost:4096 "Continue the ablation sweep" +\`\`\` + +There is no separate attach subcommand: \`openscience run --attach \` sends a one-shot prompt to the running server. See [Sessions](/openscience/sessions). + + +The workspace is intended for local use on a machine you control. The server binds \`127.0.0.1\` only and has no remote-access mode; do not reverse-proxy it to the public internet. + + +## macOS Full Disk Access + +On macOS, launching the workspace probes whether the binary can read \`~/Desktop\`. Without Full Disk Access, macOS silently returns empty listings for \`~/Desktop\`, \`~/Documents\`, and \`~/Downloads\`, so the folder picker and file tree look empty. If the probe fails, OpenScience opens System Settings on the Privacy & Security pane and prints the binary path to add: + +1. In **Full Disk Access**, click **+**, press ⌘⇧G, and paste the printed path. +2. Toggle the \`openscience\` entry on. +3. Quit (Ctrl+C) and relaunch \`openscience\`. + +## What's next + + + + Resume, attach, export, and one-shot runs. + + + BYOK providers and per-session model switching. + + + The Research harness and its bounded internal task profiles. + + + The trust boundary and credential handling. + + +`,jb="https://mintlify.com/docs.json",Nb="OpenScience",Lb={tabs:[{tab:"Guides",groups:[{group:"Start",pages:["index","quickstart","workspace"]},{group:"Use the agent",pages:["agents","models","local-models","skills","sessions","atlas"]}]},{tab:"Reference",groups:[{group:"CLI",pages:["commands","security","sandbox"]}]}],global:{anchors:[{anchor:"openscience.sh",href:"https://openscience.sh"},{anchor:"GitHub",href:"https://github.com/synthetic-sciences/openscience"},{anchor:"npm",href:"https://www.npmjs.com/package/@synsci/openscience"},{anchor:"Releases",href:"https://github.com/synthetic-sciences/openscience/releases"}]}},Ub={primary:{type:"button",label:"Star on GitHub",href:"https://github.com/synthetic-sciences/openscience"}},Bb={$schema:jb,name:Nb,navigation:Lb,navbar:Ub};function Hb(n,r){const a={};return(n[n.length-1]===""?[...n,""]:n).join((a.padRight?" ":"")+","+(a.padLeft===!1?"":" ")).trim()}const qb=/^[$_\p{ID_Start}][$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,Yb=/^[$_\p{ID_Start}][-$_\u{200C}\u{200D}\p{ID_Continue}]*$/u,Gb={};function Fp(n,r){return(Gb.jsx?Yb:qb).test(n)}const Vb=/[ \t\n\f\r]/g;function Xb(n){return typeof n=="object"?n.type==="text"?Ip(n.value):!1:Ip(n)}function Ip(n){return n.replace(Vb,"")===""}class Ra{constructor(r,a,o){this.normal=a,this.property=r,o&&(this.space=o)}}Ra.prototype.normal={};Ra.prototype.property={};Ra.prototype.space=void 0;function Vm(n,r){const a={},o={};for(const c of n)Object.assign(a,c.property),Object.assign(o,c.normal);return new Ra(a,o,r)}function ac(n){return n.toLowerCase()}class _t{constructor(r,a){this.attribute=a,this.property=r}}_t.prototype.attribute="";_t.prototype.booleanish=!1;_t.prototype.boolean=!1;_t.prototype.commaOrSpaceSeparated=!1;_t.prototype.commaSeparated=!1;_t.prototype.defined=!1;_t.prototype.mustUseProperty=!1;_t.prototype.number=!1;_t.prototype.overloadedBoolean=!1;_t.prototype.property="";_t.prototype.spaceSeparated=!1;_t.prototype.space=void 0;let Qb=0;const ge=_l(),lt=_l(),rc=_l(),I=_l(),Ge=_l(),zl=_l(),Ht=_l();function _l(){return 2**++Qb}const oc=Object.freeze(Object.defineProperty({__proto__:null,boolean:ge,booleanish:lt,commaOrSpaceSeparated:Ht,commaSeparated:zl,number:I,overloadedBoolean:rc,spaceSeparated:Ge},Symbol.toStringTag,{value:"Module"})),Ys=Object.keys(oc);class vc extends _t{constructor(r,a,o,c){let d=-1;if(super(r,a),Jp(this,"space",c),typeof o=="number")for(;++d4&&a.slice(0,4)==="data"&&Jb.test(r)){if(r.charAt(4)==="-"){const d=r.slice(5).replace($p,Pb);o="data"+d.charAt(0).toUpperCase()+d.slice(1)}else{const d=r.slice(4);if(!$p.test(d)){let f=d.replace(Ib,Wb);f.charAt(0)!=="-"&&(f="-"+f),r="data"+f}}c=vc}return new c(o,r)}function Wb(n){return"-"+n.toLowerCase()}function Pb(n){return n.charAt(1).toUpperCase()}const e0=Vm([Xm,Zb,Km,Fm,Im],"html"),xc=Vm([Xm,Kb,Km,Fm,Im],"svg");function t0(n){return n.join(" ").trim()}var vi={},Gs,Wp;function n0(){if(Wp)return Gs;Wp=1;var n=/\/\*[^*]*\*+([^/*][^*]*\*+)*\//g,r=/\n/g,a=/^\s*/,o=/^(\*?[-#/*\\\w]+(\[[0-9a-z_-]+\])?)\s*/,c=/^:\s*/,d=/^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^)]*?\)|[^};])+)/,f=/^[;\s]*/,p=/^\s+|\s+$/g,m=` +`,h="/",b="*",y="",k="comment",x="declaration";function T(Z,D){if(typeof Z!="string")throw new TypeError("First argument must be a string");if(!Z)return[];D=D||{};var F=1,Q=1;function oe(J){var $=J.match(r);$&&(F+=$.length);var O=J.lastIndexOf(m);Q=~O?J.length-O:Q+J.length}function re(){var J={line:F,column:Q};return function($){return $.position=new L(J),me(),$}}function L(J){this.start=J,this.end={line:F,column:Q},this.source=D.source}L.prototype.content=Z;function P(J){var $=new Error(D.source+":"+F+":"+Q+": "+J);if($.reason=J,$.filename=D.source,$.line=F,$.column=Q,$.source=Z,!D.silent)throw $}function he(J){var $=J.exec(Z);if($){var O=$[0];return oe(O),Z=Z.slice(O.length),$}}function me(){he(a)}function j(J){var $;for(J=J||[];$=te();)$!==!1&&J.push($);return J}function te(){var J=re();if(!(h!=Z.charAt(0)||b!=Z.charAt(1))){for(var $=2;y!=Z.charAt($)&&(b!=Z.charAt($)||h!=Z.charAt($+1));)++$;if($+=2,y===Z.charAt($-1))return P("End of comment missing");var O=Z.slice(2,$-2);return Q+=2,oe(O),Z=Z.slice($),Q+=2,J({type:k,comment:O})}}function B(){var J=re(),$=he(o);if($){if(te(),!he(c))return P("property missing ':'");var O=he(d),K=J({type:x,property:U($[0].replace(n,y)),value:O?U(O[0].replace(n,y)):y});return he(f),K}}function le(){var J=[];j(J);for(var $;$=B();)$!==!1&&(J.push($),j(J));return J}return me(),le()}function U(Z){return Z?Z.replace(p,y):y}return Gs=T,Gs}var Pp;function l0(){if(Pp)return vi;Pp=1;var n=vi&&vi.__importDefault||function(o){return o&&o.__esModule?o:{default:o}};Object.defineProperty(vi,"__esModule",{value:!0}),vi.default=a;const r=n(n0());function a(o,c){let d=null;if(!o||typeof o!="string")return d;const f=(0,r.default)(o),p=typeof c=="function";return f.forEach(m=>{if(m.type!=="declaration")return;const{property:h,value:b}=m;p?c(h,b,m):b&&(d=d||{},d[h]=b)}),d}return vi}var ka={},em;function i0(){if(em)return ka;em=1,Object.defineProperty(ka,"__esModule",{value:!0}),ka.camelCase=void 0;var n=/^--[a-zA-Z0-9_-]+$/,r=/-([a-z])/g,a=/^[^-]+$/,o=/^-(webkit|moz|ms|o|khtml)-/,c=/^-(ms)-/,d=function(h){return!h||a.test(h)||n.test(h)},f=function(h,b){return b.toUpperCase()},p=function(h,b){return"".concat(b,"-")},m=function(h,b){return b===void 0&&(b={}),d(h)?h:(h=h.toLowerCase(),b.reactCompat?h=h.replace(c,p):h=h.replace(o,p),h.replace(r,f))};return ka.camelCase=m,ka}var Sa,tm;function a0(){if(tm)return Sa;tm=1;var n=Sa&&Sa.__importDefault||function(c){return c&&c.__esModule?c:{default:c}},r=n(l0()),a=i0();function o(c,d){var f={};return!c||typeof c!="string"||(0,r.default)(c,function(p,m){p&&m&&(f[(0,a.camelCase)(p,d)]=m)}),f}return o.default=o,Sa=o,Sa}var r0=a0();const o0=yc(r0),Jm=$m("end"),kc=$m("start");function $m(n){return r;function r(a){const o=a&&a.position&&a.position[n]||{};if(typeof o.line=="number"&&o.line>0&&typeof o.column=="number"&&o.column>0)return{line:o.line,column:o.column,offset:typeof o.offset=="number"&&o.offset>-1?o.offset:void 0}}}function u0(n){const r=kc(n),a=Jm(n);if(r&&a)return{start:r,end:a}}function Ea(n){return!n||typeof n!="object"?"":"position"in n||"type"in n?nm(n.position):"start"in n||"end"in n?nm(n):"line"in n||"column"in n?uc(n):""}function uc(n){return lm(n&&n.line)+":"+lm(n&&n.column)}function nm(n){return uc(n&&n.start)+"-"+uc(n&&n.end)}function lm(n){return n&&typeof n=="number"?n:1}class gt extends Error{constructor(r,a,o){super(),typeof a=="string"&&(o=a,a=void 0);let c="",d={},f=!1;if(a&&("line"in a&&"column"in a?d={place:a}:"start"in a&&"end"in a?d={place:a}:"type"in a?d={ancestors:[a],place:a.position}:d={...a}),typeof r=="string"?c=r:!d.cause&&r&&(f=!0,c=r.message,d.cause=r),!d.ruleId&&!d.source&&typeof o=="string"){const m=o.indexOf(":");m===-1?d.ruleId=o:(d.source=o.slice(0,m),d.ruleId=o.slice(m+1))}if(!d.place&&d.ancestors&&d.ancestors){const m=d.ancestors[d.ancestors.length-1];m&&(d.place=m.position)}const p=d.place&&"start"in d.place?d.place.start:d.place;this.ancestors=d.ancestors||void 0,this.cause=d.cause||void 0,this.column=p?p.column:void 0,this.fatal=void 0,this.file="",this.message=c,this.line=p?p.line:void 0,this.name=Ea(d.place)||"1:1",this.place=d.place||void 0,this.reason=this.message,this.ruleId=d.ruleId||void 0,this.source=d.source||void 0,this.stack=f&&d.cause&&typeof d.cause.stack=="string"?d.cause.stack:"",this.actual=void 0,this.expected=void 0,this.note=void 0,this.url=void 0}}gt.prototype.file="";gt.prototype.name="";gt.prototype.reason="";gt.prototype.message="";gt.prototype.stack="";gt.prototype.column=void 0;gt.prototype.line=void 0;gt.prototype.ancestors=void 0;gt.prototype.cause=void 0;gt.prototype.fatal=void 0;gt.prototype.place=void 0;gt.prototype.ruleId=void 0;gt.prototype.source=void 0;const Sc={}.hasOwnProperty,s0=new Map,c0=/[A-Z]/g,f0=new Set(["table","tbody","thead","tfoot","tr"]),d0=new Set(["td","th"]),Wm="https://github.com/syntax-tree/hast-util-to-jsx-runtime";function h0(n,r){if(!r||r.Fragment===void 0)throw new TypeError("Expected `Fragment` in options");const a=r.filePath||void 0;let o;if(r.development){if(typeof r.jsxDEV!="function")throw new TypeError("Expected `jsxDEV` in options when `development: true`");o=k0(a,r.jsxDEV)}else{if(typeof r.jsx!="function")throw new TypeError("Expected `jsx` in production options");if(typeof r.jsxs!="function")throw new TypeError("Expected `jsxs` in production options");o=x0(a,r.jsx,r.jsxs)}const c={Fragment:r.Fragment,ancestors:[],components:r.components||{},create:o,elementAttributeNameCase:r.elementAttributeNameCase||"react",evaluater:r.createEvaluater?r.createEvaluater():void 0,filePath:a,ignoreInvalidStyle:r.ignoreInvalidStyle||!1,passKeys:r.passKeys!==!1,passNode:r.passNode||!1,schema:r.space==="svg"?xc:e0,stylePropertyNameCase:r.stylePropertyNameCase||"dom",tableCellAlignToStyle:r.tableCellAlignToStyle!==!1},d=Pm(c,n,void 0);return d&&typeof d!="string"?d:c.create(n,c.Fragment,{children:d||void 0},void 0)}function Pm(n,r,a){if(r.type==="element")return p0(n,r,a);if(r.type==="mdxFlowExpression"||r.type==="mdxTextExpression")return m0(n,r);if(r.type==="mdxJsxFlowElement"||r.type==="mdxJsxTextElement")return y0(n,r,a);if(r.type==="mdxjsEsm")return g0(n,r);if(r.type==="root")return b0(n,r,a);if(r.type==="text")return v0(n,r)}function p0(n,r,a){const o=n.schema;let c=o;r.tagName.toLowerCase()==="svg"&&o.space==="html"&&(c=xc,n.schema=c),n.ancestors.push(r);const d=tg(n,r.tagName,!1),f=S0(n,r);let p=Cc(n,r);return f0.has(r.tagName)&&(p=p.filter(function(m){return typeof m=="string"?!Xb(m):!0})),eg(n,f,d,r),wc(f,p),n.ancestors.pop(),n.schema=o,n.create(r,d,f,a)}function m0(n,r){if(r.data&&r.data.estree&&n.evaluater){const o=r.data.estree.body[0];return o.type,n.evaluater.evaluateExpression(o.expression)}Ma(n,r.position)}function g0(n,r){if(r.data&&r.data.estree&&n.evaluater)return n.evaluater.evaluateProgram(r.data.estree);Ma(n,r.position)}function y0(n,r,a){const o=n.schema;let c=o;r.name==="svg"&&o.space==="html"&&(c=xc,n.schema=c),n.ancestors.push(r);const d=r.name===null?n.Fragment:tg(n,r.name,!0),f=w0(n,r),p=Cc(n,r);return eg(n,f,d,r),wc(f,p),n.ancestors.pop(),n.schema=o,n.create(r,d,f,a)}function b0(n,r,a){const o={};return wc(o,Cc(n,r)),n.create(r,n.Fragment,o,a)}function v0(n,r){return r.value}function eg(n,r,a,o){typeof a!="string"&&a!==n.Fragment&&n.passNode&&(r.node=o)}function wc(n,r){if(r.length>0){const a=r.length>1?r:r[0];a&&(n.children=a)}}function x0(n,r,a){return o;function o(c,d,f,p){const h=Array.isArray(f.children)?a:r;return p?h(d,f,p):h(d,f)}}function k0(n,r){return a;function a(o,c,d,f){const p=Array.isArray(d.children),m=kc(o);return r(c,d,f,p,{columnNumber:m?m.column-1:void 0,fileName:n,lineNumber:m?m.line:void 0},void 0)}}function S0(n,r){const a={};let o,c;for(c in r.properties)if(c!=="children"&&Sc.call(r.properties,c)){const d=C0(n,c,r.properties[c]);if(d){const[f,p]=d;n.tableCellAlignToStyle&&f==="align"&&typeof p=="string"&&d0.has(r.tagName)?o=p:a[f]=p}}if(o){const d=a.style||(a.style={});d[n.stylePropertyNameCase==="css"?"text-align":"textAlign"]=o}return a}function w0(n,r){const a={};for(const o of r.attributes)if(o.type==="mdxJsxExpressionAttribute")if(o.data&&o.data.estree&&n.evaluater){const d=o.data.estree.body[0];d.type;const f=d.expression;f.type;const p=f.properties[0];p.type,Object.assign(a,n.evaluater.evaluateExpression(p.argument))}else Ma(n,r.position);else{const c=o.name;let d;if(o.value&&typeof o.value=="object")if(o.value.data&&o.value.data.estree&&n.evaluater){const p=o.value.data.estree.body[0];p.type,d=n.evaluater.evaluateExpression(p.expression)}else Ma(n,r.position);else d=o.value===null?!0:o.value;a[c]=d}return a}function Cc(n,r){const a=[];let o=-1;const c=n.passKeys?new Map:s0;for(;++oc?0:c+r:r=r>c?c:r,a=a>0?a:0,o.length<1e4)f=Array.from(o),f.unshift(r,a),n.splice(...f);else for(a&&n.splice(r,a);d0?(qt(n,n.length,0,r),n):r}const rm={}.hasOwnProperty;function lg(n){const r={};let a=-1;for(;++a13&&a<32||a>126&&a<160||a>55295&&a<57344||a>64975&&a<65008||(a&65535)===65535||(a&65535)===65534||a>1114111?"�":String.fromCodePoint(a)}function on(n){return n.replace(/[\t\n\r ]+/g," ").replace(/^ | $/g,"").toLowerCase().toUpperCase()}const xt=ul(/[A-Za-z]/),mt=ul(/[\dA-Za-z]/),R0=ul(/[#-'*+\--9=?A-Z^-~]/);function uo(n){return n!==null&&(n<32||n===127)}const sc=ul(/\d/),j0=ul(/[\dA-Fa-f]/),N0=ul(/[!-/:-@[-`{-~]/);function ce(n){return n!==null&&n<-2}function Ve(n){return n!==null&&(n<0||n===32)}function Ce(n){return n===-2||n===-1||n===32}const mo=ul(new RegExp("\\p{P}|\\p{S}","u")),Ol=ul(/\s/);function ul(n){return r;function r(a){return a!==null&&a>-1&&n.test(String.fromCharCode(a))}}function Ei(n){const r=[];let a=-1,o=0,c=0;for(;++a55295&&d<57344){const p=n.charCodeAt(a+1);d<56320&&p>56319&&p<57344?(f=String.fromCharCode(d,p),c=1):f="�"}else f=String.fromCharCode(d);f&&(r.push(n.slice(o,a),encodeURIComponent(f)),o=a+c+1,f=""),c&&(a+=c,c=0)}return r.join("")+n.slice(o)}function Oe(n,r,a,o){const c=o?o-1:Number.POSITIVE_INFINITY;let d=0;return f;function f(m){return Ce(m)?(n.enter(a),p(m)):r(m)}function p(m){return Ce(m)&&d++f))return;const P=r.events.length;let he=P,me,j;for(;he--;)if(r.events[he][0]==="exit"&&r.events[he][1].type==="chunkFlow"){if(me){j=r.events[he][1].end;break}me=!0}for(D(o),L=P;LQ;){const re=a[oe];r.containerState=re[1],re[0].exit.call(r,n)}a.length=Q}function F(){c.write([null]),d=void 0,c=void 0,r.containerState._closeFlow=void 0}}function q0(n,r,a){return Oe(n,n.attempt(this.parser.constructs.document,r,a),"linePrefix",this.parser.constructs.disable.null.includes("codeIndented")?void 0:4)}function wi(n){if(n===null||Ve(n)||Ol(n))return 1;if(mo(n))return 2}function go(n,r,a){const o=[];let c=-1;for(;++c1&&n[a][1].end.offset-n[a][1].start.offset>1?2:1;const y={...n[o][1].end},k={...n[a][1].start};um(y,-m),um(k,m),f={type:m>1?"strongSequence":"emphasisSequence",start:y,end:{...n[o][1].end}},p={type:m>1?"strongSequence":"emphasisSequence",start:{...n[a][1].start},end:k},d={type:m>1?"strongText":"emphasisText",start:{...n[o][1].end},end:{...n[a][1].start}},c={type:m>1?"strong":"emphasis",start:{...f.start},end:{...p.end}},n[o][1].end={...f.start},n[a][1].start={...p.end},h=[],n[o][1].end.offset-n[o][1].start.offset&&(h=Pt(h,[["enter",n[o][1],r],["exit",n[o][1],r]])),h=Pt(h,[["enter",c,r],["enter",f,r],["exit",f,r],["enter",d,r]]),h=Pt(h,go(r.parser.constructs.insideSpan.null,n.slice(o+1,a),r)),h=Pt(h,[["exit",d,r],["enter",p,r],["exit",p,r],["exit",c,r]]),n[a][1].end.offset-n[a][1].start.offset?(b=2,h=Pt(h,[["enter",n[a][1],r],["exit",n[a][1],r]])):b=0,qt(n,o-1,a-o+3,h),a=o+h.length-b-2;break}}for(a=-1;++a0&&Ce(L)?Oe(n,F,"linePrefix",d+1)(L):F(L)}function F(L){return L===null||ce(L)?n.check(sm,U,oe)(L):(n.enter("codeFlowValue"),Q(L))}function Q(L){return L===null||ce(L)?(n.exit("codeFlowValue"),F(L)):(n.consume(L),Q)}function oe(L){return n.exit("codeFenced"),r(L)}function re(L,P,he){let me=0;return j;function j($){return L.enter("lineEnding"),L.consume($),L.exit("lineEnding"),te}function te($){return L.enter("codeFencedFence"),Ce($)?Oe(L,B,"linePrefix",o.parser.constructs.disable.null.includes("codeIndented")?void 0:4)($):B($)}function B($){return $===p?(L.enter("codeFencedFenceSequence"),le($)):he($)}function le($){return $===p?(me++,L.consume($),le):me>=f?(L.exit("codeFencedFenceSequence"),Ce($)?Oe(L,J,"whitespace")($):J($)):he($)}function J($){return $===null||ce($)?(L.exit("codeFencedFence"),P($)):he($)}}}function W0(n,r,a){const o=this;return c;function c(f){return f===null?a(f):(n.enter("lineEnding"),n.consume(f),n.exit("lineEnding"),d)}function d(f){return o.parser.lazy[o.now().line]?a(f):r(f)}}const Xs={name:"codeIndented",tokenize:ev},P0={partial:!0,tokenize:tv};function ev(n,r,a){const o=this;return c;function c(h){return n.enter("codeIndented"),Oe(n,d,"linePrefix",5)(h)}function d(h){const b=o.events[o.events.length-1];return b&&b[1].type==="linePrefix"&&b[2].sliceSerialize(b[1],!0).length>=4?f(h):a(h)}function f(h){return h===null?m(h):ce(h)?n.attempt(P0,f,m)(h):(n.enter("codeFlowValue"),p(h))}function p(h){return h===null||ce(h)?(n.exit("codeFlowValue"),f(h)):(n.consume(h),p)}function m(h){return n.exit("codeIndented"),r(h)}}function tv(n,r,a){const o=this;return c;function c(f){return o.parser.lazy[o.now().line]?a(f):ce(f)?(n.enter("lineEnding"),n.consume(f),n.exit("lineEnding"),c):Oe(n,d,"linePrefix",5)(f)}function d(f){const p=o.events[o.events.length-1];return p&&p[1].type==="linePrefix"&&p[2].sliceSerialize(p[1],!0).length>=4?r(f):ce(f)?c(f):a(f)}}const nv={name:"codeText",previous:iv,resolve:lv,tokenize:av};function lv(n){let r=n.length-4,a=3,o,c;if((n[a][1].type==="lineEnding"||n[a][1].type==="space")&&(n[r][1].type==="lineEnding"||n[r][1].type==="space")){for(o=a;++o=this.left.length+this.right.length)throw new RangeError("Cannot access index `"+r+"` in a splice buffer of size `"+(this.left.length+this.right.length)+"`");return rthis.left.length?this.right.slice(this.right.length-o+this.left.length,this.right.length-r+this.left.length).reverse():this.left.slice(r).concat(this.right.slice(this.right.length-o+this.left.length).reverse())}splice(r,a,o){const c=a||0;this.setCursor(Math.trunc(r));const d=this.right.splice(this.right.length-c,Number.POSITIVE_INFINITY);return o&&wa(this.left,o),d.reverse()}pop(){return this.setCursor(Number.POSITIVE_INFINITY),this.left.pop()}push(r){this.setCursor(Number.POSITIVE_INFINITY),this.left.push(r)}pushMany(r){this.setCursor(Number.POSITIVE_INFINITY),wa(this.left,r)}unshift(r){this.setCursor(0),this.right.push(r)}unshiftMany(r){this.setCursor(0),wa(this.right,r.reverse())}setCursor(r){if(!(r===this.left.length||r>this.left.length&&this.right.length===0||r<0&&this.left.length===0))if(r=4?r(f):n.interrupt(o.parser.constructs.flow,a,r)(f)}}function sg(n,r,a,o,c,d,f,p,m){const h=m||Number.POSITIVE_INFINITY;let b=0;return y;function y(D){return D===60?(n.enter(o),n.enter(c),n.enter(d),n.consume(D),n.exit(d),k):D===null||D===32||D===41||uo(D)?a(D):(n.enter(o),n.enter(f),n.enter(p),n.enter("chunkString",{contentType:"string"}),U(D))}function k(D){return D===62?(n.enter(d),n.consume(D),n.exit(d),n.exit(c),n.exit(o),r):(n.enter(p),n.enter("chunkString",{contentType:"string"}),x(D))}function x(D){return D===62?(n.exit("chunkString"),n.exit(p),k(D)):D===null||D===60||ce(D)?a(D):(n.consume(D),D===92?T:x)}function T(D){return D===60||D===62||D===92?(n.consume(D),x):x(D)}function U(D){return!b&&(D===null||D===41||Ve(D))?(n.exit("chunkString"),n.exit(p),n.exit(f),n.exit(o),r(D)):b999||x===null||x===91||x===93&&!m||x===94&&!p&&"_hiddenFootnoteSupport"in f.parser.constructs?a(x):x===93?(n.exit(d),n.enter(c),n.consume(x),n.exit(c),n.exit(o),r):ce(x)?(n.enter("lineEnding"),n.consume(x),n.exit("lineEnding"),b):(n.enter("chunkString",{contentType:"string"}),y(x))}function y(x){return x===null||x===91||x===93||ce(x)||p++>999?(n.exit("chunkString"),b(x)):(n.consume(x),m||(m=!Ce(x)),x===92?k:y)}function k(x){return x===91||x===92||x===93?(n.consume(x),p++,y):y(x)}}function fg(n,r,a,o,c,d){let f;return p;function p(k){return k===34||k===39||k===40?(n.enter(o),n.enter(c),n.consume(k),n.exit(c),f=k===40?41:k,m):a(k)}function m(k){return k===f?(n.enter(c),n.consume(k),n.exit(c),n.exit(o),r):(n.enter(d),h(k))}function h(k){return k===f?(n.exit(d),m(f)):k===null?a(k):ce(k)?(n.enter("lineEnding"),n.consume(k),n.exit("lineEnding"),Oe(n,h,"linePrefix")):(n.enter("chunkString",{contentType:"string"}),b(k))}function b(k){return k===f||k===null||ce(k)?(n.exit("chunkString"),h(k)):(n.consume(k),k===92?y:b)}function y(k){return k===f||k===92?(n.consume(k),b):b(k)}}function Aa(n,r){let a;return o;function o(c){return ce(c)?(n.enter("lineEnding"),n.consume(c),n.exit("lineEnding"),a=!0,o):Ce(c)?Oe(n,o,a?"linePrefix":"lineSuffix")(c):r(c)}}const hv={name:"definition",tokenize:mv},pv={partial:!0,tokenize:gv};function mv(n,r,a){const o=this;let c;return d;function d(x){return n.enter("definition"),f(x)}function f(x){return cg.call(o,n,p,a,"definitionLabel","definitionLabelMarker","definitionLabelString")(x)}function p(x){return c=on(o.sliceSerialize(o.events[o.events.length-1][1]).slice(1,-1)),x===58?(n.enter("definitionMarker"),n.consume(x),n.exit("definitionMarker"),m):a(x)}function m(x){return Ve(x)?Aa(n,h)(x):h(x)}function h(x){return sg(n,b,a,"definitionDestination","definitionDestinationLiteral","definitionDestinationLiteralMarker","definitionDestinationRaw","definitionDestinationString")(x)}function b(x){return n.attempt(pv,y,y)(x)}function y(x){return Ce(x)?Oe(n,k,"whitespace")(x):k(x)}function k(x){return x===null||ce(x)?(n.exit("definition"),o.parser.defined.push(c),r(x)):a(x)}}function gv(n,r,a){return o;function o(p){return Ve(p)?Aa(n,c)(p):a(p)}function c(p){return fg(n,d,a,"definitionTitle","definitionTitleMarker","definitionTitleString")(p)}function d(p){return Ce(p)?Oe(n,f,"whitespace")(p):f(p)}function f(p){return p===null||ce(p)?r(p):a(p)}}const yv={name:"hardBreakEscape",tokenize:bv};function bv(n,r,a){return o;function o(d){return n.enter("hardBreakEscape"),n.consume(d),c}function c(d){return ce(d)?(n.exit("hardBreakEscape"),r(d)):a(d)}}const vv={name:"headingAtx",resolve:xv,tokenize:kv};function xv(n,r){let a=n.length-2,o=3,c,d;return n[o][1].type==="whitespace"&&(o+=2),a-2>o&&n[a][1].type==="whitespace"&&(a-=2),n[a][1].type==="atxHeadingSequence"&&(o===a-1||a-4>o&&n[a-2][1].type==="whitespace")&&(a-=o+1===a?2:4),a>o&&(c={type:"atxHeadingText",start:n[o][1].start,end:n[a][1].end},d={type:"chunkText",start:n[o][1].start,end:n[a][1].end,contentType:"text"},qt(n,o,a-o+1,[["enter",c,r],["enter",d,r],["exit",d,r],["exit",c,r]])),n}function kv(n,r,a){let o=0;return c;function c(b){return n.enter("atxHeading"),d(b)}function d(b){return n.enter("atxHeadingSequence"),f(b)}function f(b){return b===35&&o++<6?(n.consume(b),f):b===null||Ve(b)?(n.exit("atxHeadingSequence"),p(b)):a(b)}function p(b){return b===35?(n.enter("atxHeadingSequence"),m(b)):b===null||ce(b)?(n.exit("atxHeading"),r(b)):Ce(b)?Oe(n,p,"whitespace")(b):(n.enter("atxHeadingText"),h(b))}function m(b){return b===35?(n.consume(b),m):(n.exit("atxHeadingSequence"),p(b))}function h(b){return b===null||b===35||Ve(b)?(n.exit("atxHeadingText"),p(b)):(n.consume(b),h)}}const Sv=["address","article","aside","base","basefont","blockquote","body","caption","center","col","colgroup","dd","details","dialog","dir","div","dl","dt","fieldset","figcaption","figure","footer","form","frame","frameset","h1","h2","h3","h4","h5","h6","head","header","hr","html","iframe","legend","li","link","main","menu","menuitem","nav","noframes","ol","optgroup","option","p","param","search","section","summary","table","tbody","td","tfoot","th","thead","title","tr","track","ul"],fm=["pre","script","style","textarea"],wv={concrete:!0,name:"htmlFlow",resolveTo:Av,tokenize:Tv},Cv={partial:!0,tokenize:Ov},Ev={partial:!0,tokenize:zv};function Av(n){let r=n.length;for(;r--&&!(n[r][0]==="enter"&&n[r][1].type==="htmlFlow"););return r>1&&n[r-2][1].type==="linePrefix"&&(n[r][1].start=n[r-2][1].start,n[r+1][1].start=n[r-2][1].start,n.splice(r-2,2)),n}function Tv(n,r,a){const o=this;let c,d,f,p,m;return h;function h(S){return b(S)}function b(S){return n.enter("htmlFlow"),n.enter("htmlFlowData"),n.consume(S),y}function y(S){return S===33?(n.consume(S),k):S===47?(n.consume(S),d=!0,U):S===63?(n.consume(S),c=3,o.interrupt?r:w):xt(S)?(n.consume(S),f=String.fromCharCode(S),Z):a(S)}function k(S){return S===45?(n.consume(S),c=2,x):S===91?(n.consume(S),c=5,p=0,T):xt(S)?(n.consume(S),c=4,o.interrupt?r:w):a(S)}function x(S){return S===45?(n.consume(S),o.interrupt?r:w):a(S)}function T(S){const ee="CDATA[";return S===ee.charCodeAt(p++)?(n.consume(S),p===ee.length?o.interrupt?r:B:T):a(S)}function U(S){return xt(S)?(n.consume(S),f=String.fromCharCode(S),Z):a(S)}function Z(S){if(S===null||S===47||S===62||Ve(S)){const ee=S===47,de=f.toLowerCase();return!ee&&!d&&fm.includes(de)?(c=1,o.interrupt?r(S):B(S)):Sv.includes(f.toLowerCase())?(c=6,ee?(n.consume(S),D):o.interrupt?r(S):B(S)):(c=7,o.interrupt&&!o.parser.lazy[o.now().line]?a(S):d?F(S):Q(S))}return S===45||mt(S)?(n.consume(S),f+=String.fromCharCode(S),Z):a(S)}function D(S){return S===62?(n.consume(S),o.interrupt?r:B):a(S)}function F(S){return Ce(S)?(n.consume(S),F):j(S)}function Q(S){return S===47?(n.consume(S),j):S===58||S===95||xt(S)?(n.consume(S),oe):Ce(S)?(n.consume(S),Q):j(S)}function oe(S){return S===45||S===46||S===58||S===95||mt(S)?(n.consume(S),oe):re(S)}function re(S){return S===61?(n.consume(S),L):Ce(S)?(n.consume(S),re):Q(S)}function L(S){return S===null||S===60||S===61||S===62||S===96?a(S):S===34||S===39?(n.consume(S),m=S,P):Ce(S)?(n.consume(S),L):he(S)}function P(S){return S===m?(n.consume(S),m=null,me):S===null||ce(S)?a(S):(n.consume(S),P)}function he(S){return S===null||S===34||S===39||S===47||S===60||S===61||S===62||S===96||Ve(S)?re(S):(n.consume(S),he)}function me(S){return S===47||S===62||Ce(S)?Q(S):a(S)}function j(S){return S===62?(n.consume(S),te):a(S)}function te(S){return S===null||ce(S)?B(S):Ce(S)?(n.consume(S),te):a(S)}function B(S){return S===45&&c===2?(n.consume(S),O):S===60&&c===1?(n.consume(S),K):S===62&&c===4?(n.consume(S),E):S===63&&c===3?(n.consume(S),w):S===93&&c===5?(n.consume(S),xe):ce(S)&&(c===6||c===7)?(n.exit("htmlFlowData"),n.check(Cv,Y,le)(S)):S===null||ce(S)?(n.exit("htmlFlowData"),le(S)):(n.consume(S),B)}function le(S){return n.check(Ev,J,Y)(S)}function J(S){return n.enter("lineEnding"),n.consume(S),n.exit("lineEnding"),$}function $(S){return S===null||ce(S)?le(S):(n.enter("htmlFlowData"),B(S))}function O(S){return S===45?(n.consume(S),w):B(S)}function K(S){return S===47?(n.consume(S),f="",ae):B(S)}function ae(S){if(S===62){const ee=f.toLowerCase();return fm.includes(ee)?(n.consume(S),E):B(S)}return xt(S)&&f.length<8?(n.consume(S),f+=String.fromCharCode(S),ae):B(S)}function xe(S){return S===93?(n.consume(S),w):B(S)}function w(S){return S===62?(n.consume(S),E):S===45&&c===2?(n.consume(S),w):B(S)}function E(S){return S===null||ce(S)?(n.exit("htmlFlowData"),Y(S)):(n.consume(S),E)}function Y(S){return n.exit("htmlFlow"),r(S)}}function zv(n,r,a){const o=this;return c;function c(f){return ce(f)?(n.enter("lineEnding"),n.consume(f),n.exit("lineEnding"),d):a(f)}function d(f){return o.parser.lazy[o.now().line]?a(f):r(f)}}function Ov(n,r,a){return o;function o(c){return n.enter("lineEnding"),n.consume(c),n.exit("lineEnding"),n.attempt(ja,r,a)}}const _v={name:"htmlText",tokenize:Mv};function Mv(n,r,a){const o=this;let c,d,f;return p;function p(w){return n.enter("htmlText"),n.enter("htmlTextData"),n.consume(w),m}function m(w){return w===33?(n.consume(w),h):w===47?(n.consume(w),re):w===63?(n.consume(w),Q):xt(w)?(n.consume(w),he):a(w)}function h(w){return w===45?(n.consume(w),b):w===91?(n.consume(w),d=0,T):xt(w)?(n.consume(w),F):a(w)}function b(w){return w===45?(n.consume(w),x):a(w)}function y(w){return w===null?a(w):w===45?(n.consume(w),k):ce(w)?(f=y,K(w)):(n.consume(w),y)}function k(w){return w===45?(n.consume(w),x):y(w)}function x(w){return w===62?O(w):w===45?k(w):y(w)}function T(w){const E="CDATA[";return w===E.charCodeAt(d++)?(n.consume(w),d===E.length?U:T):a(w)}function U(w){return w===null?a(w):w===93?(n.consume(w),Z):ce(w)?(f=U,K(w)):(n.consume(w),U)}function Z(w){return w===93?(n.consume(w),D):U(w)}function D(w){return w===62?O(w):w===93?(n.consume(w),D):U(w)}function F(w){return w===null||w===62?O(w):ce(w)?(f=F,K(w)):(n.consume(w),F)}function Q(w){return w===null?a(w):w===63?(n.consume(w),oe):ce(w)?(f=Q,K(w)):(n.consume(w),Q)}function oe(w){return w===62?O(w):Q(w)}function re(w){return xt(w)?(n.consume(w),L):a(w)}function L(w){return w===45||mt(w)?(n.consume(w),L):P(w)}function P(w){return ce(w)?(f=P,K(w)):Ce(w)?(n.consume(w),P):O(w)}function he(w){return w===45||mt(w)?(n.consume(w),he):w===47||w===62||Ve(w)?me(w):a(w)}function me(w){return w===47?(n.consume(w),O):w===58||w===95||xt(w)?(n.consume(w),j):ce(w)?(f=me,K(w)):Ce(w)?(n.consume(w),me):O(w)}function j(w){return w===45||w===46||w===58||w===95||mt(w)?(n.consume(w),j):te(w)}function te(w){return w===61?(n.consume(w),B):ce(w)?(f=te,K(w)):Ce(w)?(n.consume(w),te):me(w)}function B(w){return w===null||w===60||w===61||w===62||w===96?a(w):w===34||w===39?(n.consume(w),c=w,le):ce(w)?(f=B,K(w)):Ce(w)?(n.consume(w),B):(n.consume(w),J)}function le(w){return w===c?(n.consume(w),c=void 0,$):w===null?a(w):ce(w)?(f=le,K(w)):(n.consume(w),le)}function J(w){return w===null||w===34||w===39||w===60||w===61||w===96?a(w):w===47||w===62||Ve(w)?me(w):(n.consume(w),J)}function $(w){return w===47||w===62||Ve(w)?me(w):a(w)}function O(w){return w===62?(n.consume(w),n.exit("htmlTextData"),n.exit("htmlText"),r):a(w)}function K(w){return n.exit("htmlTextData"),n.enter("lineEnding"),n.consume(w),n.exit("lineEnding"),ae}function ae(w){return Ce(w)?Oe(n,xe,"linePrefix",o.parser.constructs.disable.null.includes("codeIndented")?void 0:4)(w):xe(w)}function xe(w){return n.enter("htmlTextData"),f(w)}}const Tc={name:"labelEnd",resolveAll:Nv,resolveTo:Lv,tokenize:Uv},Dv={tokenize:Bv},Rv={tokenize:Hv},jv={tokenize:qv};function Nv(n){let r=-1;const a=[];for(;++r=3&&(h===null||ce(h))?(n.exit("thematicBreak"),r(h)):a(h)}function m(h){return h===c?(n.consume(h),o++,m):(n.exit("thematicBreakSequence"),Ce(h)?Oe(n,p,"whitespace")(h):p(h))}}const Ot={continuation:{tokenize:Jv},exit:Wv,name:"list",tokenize:Iv},Kv={partial:!0,tokenize:Pv},Fv={partial:!0,tokenize:$v};function Iv(n,r,a){const o=this,c=o.events[o.events.length-1];let d=c&&c[1].type==="linePrefix"?c[2].sliceSerialize(c[1],!0).length:0,f=0;return p;function p(x){const T=o.containerState.type||(x===42||x===43||x===45?"listUnordered":"listOrdered");if(T==="listUnordered"?!o.containerState.marker||x===o.containerState.marker:sc(x)){if(o.containerState.type||(o.containerState.type=T,n.enter(T,{_container:!0})),T==="listUnordered")return n.enter("listItemPrefix"),x===42||x===45?n.check(ro,a,h)(x):h(x);if(!o.interrupt||x===49)return n.enter("listItemPrefix"),n.enter("listItemValue"),m(x)}return a(x)}function m(x){return sc(x)&&++f<10?(n.consume(x),m):(!o.interrupt||f<2)&&(o.containerState.marker?x===o.containerState.marker:x===41||x===46)?(n.exit("listItemValue"),h(x)):a(x)}function h(x){return n.enter("listItemMarker"),n.consume(x),n.exit("listItemMarker"),o.containerState.marker=o.containerState.marker||x,n.check(ja,o.interrupt?a:b,n.attempt(Kv,k,y))}function b(x){return o.containerState.initialBlankLine=!0,d++,k(x)}function y(x){return Ce(x)?(n.enter("listItemPrefixWhitespace"),n.consume(x),n.exit("listItemPrefixWhitespace"),k):a(x)}function k(x){return o.containerState.size=d+o.sliceSerialize(n.exit("listItemPrefix"),!0).length,r(x)}}function Jv(n,r,a){const o=this;return o.containerState._closeFlow=void 0,n.check(ja,c,d);function c(p){return o.containerState.furtherBlankLines=o.containerState.furtherBlankLines||o.containerState.initialBlankLine,Oe(n,r,"listItemIndent",o.containerState.size+1)(p)}function d(p){return o.containerState.furtherBlankLines||!Ce(p)?(o.containerState.furtherBlankLines=void 0,o.containerState.initialBlankLine=void 0,f(p)):(o.containerState.furtherBlankLines=void 0,o.containerState.initialBlankLine=void 0,n.attempt(Fv,r,f)(p))}function f(p){return o.containerState._closeFlow=!0,o.interrupt=void 0,Oe(n,n.attempt(Ot,r,a),"linePrefix",o.parser.constructs.disable.null.includes("codeIndented")?void 0:4)(p)}}function $v(n,r,a){const o=this;return Oe(n,c,"listItemIndent",o.containerState.size+1);function c(d){const f=o.events[o.events.length-1];return f&&f[1].type==="listItemIndent"&&f[2].sliceSerialize(f[1],!0).length===o.containerState.size?r(d):a(d)}}function Wv(n){n.exit(this.containerState.type)}function Pv(n,r,a){const o=this;return Oe(n,c,"listItemPrefixWhitespace",o.parser.constructs.disable.null.includes("codeIndented")?void 0:5);function c(d){const f=o.events[o.events.length-1];return!Ce(d)&&f&&f[1].type==="listItemPrefixWhitespace"?r(d):a(d)}}const dm={name:"setextUnderline",resolveTo:ex,tokenize:tx};function ex(n,r){let a=n.length,o,c,d;for(;a--;)if(n[a][0]==="enter"){if(n[a][1].type==="content"){o=a;break}n[a][1].type==="paragraph"&&(c=a)}else n[a][1].type==="content"&&n.splice(a,1),!d&&n[a][1].type==="definition"&&(d=a);const f={type:"setextHeading",start:{...n[o][1].start},end:{...n[n.length-1][1].end}};return n[c][1].type="setextHeadingText",d?(n.splice(c,0,["enter",f,r]),n.splice(d+1,0,["exit",n[o][1],r]),n[o][1].end={...n[d][1].end}):n[o][1]=f,n.push(["exit",f,r]),n}function tx(n,r,a){const o=this;let c;return d;function d(h){let b=o.events.length,y;for(;b--;)if(o.events[b][1].type!=="lineEnding"&&o.events[b][1].type!=="linePrefix"&&o.events[b][1].type!=="content"){y=o.events[b][1].type==="paragraph";break}return!o.parser.lazy[o.now().line]&&(o.interrupt||y)?(n.enter("setextHeadingLine"),c=h,f(h)):a(h)}function f(h){return n.enter("setextHeadingLineSequence"),p(h)}function p(h){return h===c?(n.consume(h),p):(n.exit("setextHeadingLineSequence"),Ce(h)?Oe(n,m,"lineSuffix")(h):m(h))}function m(h){return h===null||ce(h)?(n.exit("setextHeadingLine"),r(h)):a(h)}}const nx={tokenize:lx};function lx(n){const r=this,a=n.attempt(ja,o,n.attempt(this.parser.constructs.flowInitial,c,Oe(n,n.attempt(this.parser.constructs.flow,c,n.attempt(uv,c)),"linePrefix")));return a;function o(d){if(d===null){n.consume(d);return}return n.enter("lineEndingBlank"),n.consume(d),n.exit("lineEndingBlank"),r.currentConstruct=void 0,a}function c(d){if(d===null){n.consume(d);return}return n.enter("lineEnding"),n.consume(d),n.exit("lineEnding"),r.currentConstruct=void 0,a}}const ix={resolveAll:hg()},ax=dg("string"),rx=dg("text");function dg(n){return{resolveAll:hg(n==="text"?ox:void 0),tokenize:r};function r(a){const o=this,c=this.parser.constructs[n],d=a.attempt(c,f,p);return f;function f(b){return h(b)?d(b):p(b)}function p(b){if(b===null){a.consume(b);return}return a.enter("data"),a.consume(b),m}function m(b){return h(b)?(a.exit("data"),d(b)):(a.consume(b),m)}function h(b){if(b===null)return!0;const y=c[b];let k=-1;if(y)for(;++k-1){const p=f[0];typeof p=="string"?f[0]=p.slice(o):f.shift()}d>0&&f.push(n[c].slice(0,d))}return f}function xx(n,r){let a=-1;const o=[];let c;for(;++a0){const Mt=pe.tokenStack[pe.tokenStack.length-1];(Mt[1]||pm).call(pe,void 0,Mt[0])}for(W.position={start:ol(G.length>0?G[0][1].start:{line:1,column:1,offset:0}),end:ol(G.length>0?G[G.length-2][1].end:{line:1,column:1,offset:0})},Re=-1;++Re0&&(o.className=["language-"+c[0]]);let d={type:"element",tagName:"code",properties:o,children:[{type:"text",value:a}]};return r.meta&&(d.data={meta:r.meta}),n.patch(r,d),d=n.applyData(r,d),d={type:"element",tagName:"pre",properties:{},children:[d]},n.patch(r,d),d}function jx(n,r){const a={type:"element",tagName:"del",properties:{},children:n.all(r)};return n.patch(r,a),n.applyData(r,a)}function Nx(n,r){const a={type:"element",tagName:"em",properties:{},children:n.all(r)};return n.patch(r,a),n.applyData(r,a)}function Lx(n,r){const a=typeof n.options.clobberPrefix=="string"?n.options.clobberPrefix:"user-content-",o=String(r.identifier).toUpperCase(),c=Ei(o.toLowerCase()),d=n.footnoteOrder.indexOf(o);let f,p=n.footnoteCounts.get(o);p===void 0?(p=0,n.footnoteOrder.push(o),f=n.footnoteOrder.length):f=d+1,p+=1,n.footnoteCounts.set(o,p);const m={type:"element",tagName:"a",properties:{href:"#"+a+"fn-"+c,id:a+"fnref-"+c+(p>1?"-"+p:""),dataFootnoteRef:!0,ariaDescribedBy:["footnote-label"]},children:[{type:"text",value:String(f)}]};n.patch(r,m);const h={type:"element",tagName:"sup",properties:{},children:[m]};return n.patch(r,h),n.applyData(r,h)}function Ux(n,r){const a={type:"element",tagName:"h"+r.depth,properties:{},children:n.all(r)};return n.patch(r,a),n.applyData(r,a)}function Bx(n,r){if(n.options.allowDangerousHtml){const a={type:"raw",value:r.value};return n.patch(r,a),n.applyData(r,a)}}function gg(n,r){const a=r.referenceType;let o="]";if(a==="collapsed"?o+="[]":a==="full"&&(o+="["+(r.label||r.identifier)+"]"),r.type==="imageReference")return[{type:"text",value:"!["+r.alt+o}];const c=n.all(r),d=c[0];d&&d.type==="text"?d.value="["+d.value:c.unshift({type:"text",value:"["});const f=c[c.length-1];return f&&f.type==="text"?f.value+=o:c.push({type:"text",value:o}),c}function Hx(n,r){const a=String(r.identifier).toUpperCase(),o=n.definitionById.get(a);if(!o)return gg(n,r);const c={src:Ei(o.url||""),alt:r.alt};o.title!==null&&o.title!==void 0&&(c.title=o.title);const d={type:"element",tagName:"img",properties:c,children:[]};return n.patch(r,d),n.applyData(r,d)}function qx(n,r){const a={src:Ei(r.url)};r.alt!==null&&r.alt!==void 0&&(a.alt=r.alt),r.title!==null&&r.title!==void 0&&(a.title=r.title);const o={type:"element",tagName:"img",properties:a,children:[]};return n.patch(r,o),n.applyData(r,o)}function Yx(n,r){const a={type:"text",value:r.value.replace(/\r?\n|\r/g," ")};n.patch(r,a);const o={type:"element",tagName:"code",properties:{},children:[a]};return n.patch(r,o),n.applyData(r,o)}function Gx(n,r){const a=String(r.identifier).toUpperCase(),o=n.definitionById.get(a);if(!o)return gg(n,r);const c={href:Ei(o.url||"")};o.title!==null&&o.title!==void 0&&(c.title=o.title);const d={type:"element",tagName:"a",properties:c,children:n.all(r)};return n.patch(r,d),n.applyData(r,d)}function Vx(n,r){const a={href:Ei(r.url)};r.title!==null&&r.title!==void 0&&(a.title=r.title);const o={type:"element",tagName:"a",properties:a,children:n.all(r)};return n.patch(r,o),n.applyData(r,o)}function Xx(n,r,a){const o=n.all(r),c=a?Qx(a):yg(r),d={},f=[];if(typeof r.checked=="boolean"){const b=o[0];let y;b&&b.type==="element"&&b.tagName==="p"?y=b:(y={type:"element",tagName:"p",properties:{},children:[]},o.unshift(y)),y.children.length>0&&y.children.unshift({type:"text",value:" "}),y.children.unshift({type:"element",tagName:"input",properties:{type:"checkbox",checked:r.checked,disabled:!0},children:[]}),d.className=["task-list-item"]}let p=-1;for(;++p1}function Zx(n,r){const a={},o=n.all(r);let c=-1;for(typeof r.start=="number"&&r.start!==1&&(a.start=r.start);++c0){const f={type:"element",tagName:"tbody",properties:{},children:n.wrap(a,!0)},p=kc(r.children[1]),m=Jm(r.children[r.children.length-1]);p&&m&&(f.position={start:p,end:m}),c.push(f)}const d={type:"element",tagName:"table",properties:{},children:n.wrap(c,!0)};return n.patch(r,d),n.applyData(r,d)}function $x(n,r,a){const o=a?a.children:void 0,d=(o?o.indexOf(r):1)===0?"th":"td",f=a&&a.type==="table"?a.align:void 0,p=f?f.length:r.children.length;let m=-1;const h=[];for(;++m0,!0),o[0]),c=o.index+o[0].length,o=a.exec(r);return d.push(ym(r.slice(c),c>0,!1)),d.join("")}function ym(n,r,a){let o=0,c=n.length;if(r){let d=n.codePointAt(o);for(;d===mm||d===gm;)o++,d=n.codePointAt(o)}if(a){let d=n.codePointAt(c-1);for(;d===mm||d===gm;)c--,d=n.codePointAt(c-1)}return c>o?n.slice(o,c):""}function ek(n,r){const a={type:"text",value:Px(String(r.value))};return n.patch(r,a),n.applyData(r,a)}function tk(n,r){const a={type:"element",tagName:"hr",properties:{},children:[]};return n.patch(r,a),n.applyData(r,a)}const nk={blockquote:Mx,break:Dx,code:Rx,delete:jx,emphasis:Nx,footnoteReference:Lx,heading:Ux,html:Bx,imageReference:Hx,image:qx,inlineCode:Yx,linkReference:Gx,link:Vx,listItem:Xx,list:Zx,paragraph:Kx,root:Fx,strong:Ix,table:Jx,tableCell:Wx,tableRow:$x,text:ek,thematicBreak:tk,toml:to,yaml:to,definition:to,footnoteDefinition:to};function to(){}const bg=-1,yo=0,Ta=1,so=2,zc=3,Oc=4,_c=5,Mc=6,vg=7,xg=8,lk=typeof self=="object"?self:globalThis,bm=(n,r)=>{switch(n){case"Function":case"SharedWorker":case"Worker":case"eval":case"setInterval":case"setTimeout":throw new TypeError("unable to deserialize "+n)}return new lk[n](r)},ik=(n,r)=>{const a=(c,d)=>(n.set(d,c),c),o=c=>{if(n.has(c))return n.get(c);const[d,f]=r[c];switch(d){case yo:case bg:return a(f,c);case Ta:{const p=a([],c);for(const m of f)p.push(o(m));return p}case so:{const p=a({},c);for(const[m,h]of f)p[o(m)]=o(h);return p}case zc:return a(new Date(f),c);case Oc:{const{source:p,flags:m}=f;return a(new RegExp(p,m),c)}case _c:{const p=a(new Map,c);for(const[m,h]of f)p.set(o(m),o(h));return p}case Mc:{const p=a(new Set,c);for(const m of f)p.add(o(m));return p}case vg:{const{name:p,message:m}=f;return a(bm(p,m),c)}case xg:return a(BigInt(f),c);case"BigInt":return a(Object(BigInt(f)),c);case"ArrayBuffer":return a(new Uint8Array(f).buffer,f);case"DataView":{const{buffer:p}=new Uint8Array(f);return a(new DataView(p),f)}}return a(bm(d,f),c)};return o},vm=n=>ik(new Map,n)(0),xi="",{toString:ak}={},{keys:rk}=Object,Ca=n=>{const r=typeof n;if(r!=="object"||!n)return[yo,r];const a=ak.call(n).slice(8,-1);switch(a){case"Array":return[Ta,xi];case"Object":return[so,xi];case"Date":return[zc,xi];case"RegExp":return[Oc,xi];case"Map":return[_c,xi];case"Set":return[Mc,xi];case"DataView":return[Ta,a]}return a.includes("Array")?[Ta,a]:a.includes("Error")?[vg,a]:[so,a]},no=([n,r])=>n===yo&&(r==="function"||r==="symbol"),ok=(n,r,a,o)=>{const c=(f,p)=>{const m=o.push(f)-1;return a.set(p,m),m},d=f=>{if(a.has(f))return a.get(f);let[p,m]=Ca(f);switch(p){case yo:{let b=f;switch(m){case"bigint":p=xg,b=f.toString();break;case"function":case"symbol":if(n)throw new TypeError("unable to serialize "+m);b=null;break;case"undefined":return c([bg],f)}return c([p,b],f)}case Ta:{if(m){let k=f;return m==="DataView"?k=new Uint8Array(f.buffer):m==="ArrayBuffer"&&(k=new Uint8Array(f)),c([m,[...k]],f)}const b=[],y=c([p,b],f);for(const k of f)b.push(d(k));return y}case so:{if(m)switch(m){case"BigInt":return c([m,f.toString()],f);case"Boolean":case"Number":case"String":return c([m,f.valueOf()],f)}if(r&&"toJSON"in f)return d(f.toJSON());const b=[],y=c([p,b],f);for(const k of rk(f))(n||!no(Ca(f[k])))&&b.push([d(k),d(f[k])]);return y}case zc:return c([p,f.toISOString()],f);case Oc:{const{source:b,flags:y}=f;return c([p,{source:b,flags:y}],f)}case _c:{const b=[],y=c([p,b],f);for(const[k,x]of f)(n||!(no(Ca(k))||no(Ca(x))))&&b.push([d(k),d(x)]);return y}case Mc:{const b=[],y=c([p,b],f);for(const k of f)(n||!no(Ca(k)))&&b.push(d(k));return y}}const{message:h}=f;return c([p,{name:m,message:h}],f)};return d},xm=(n,{json:r,lossy:a}={})=>{const o=[];return ok(!(r||a),!!r,new Map,o)(n),o},co=typeof structuredClone=="function"?(n,r)=>r&&("json"in r||"lossy"in r)?vm(xm(n,r)):structuredClone(n):(n,r)=>vm(xm(n,r));function uk(n,r){const a=[{type:"text",value:"↩"}];return r>1&&a.push({type:"element",tagName:"sup",properties:{},children:[{type:"text",value:String(r)}]}),a}function sk(n,r){return"Back to reference "+(n+1)+(r>1?"-"+r:"")}function ck(n){const r=typeof n.options.clobberPrefix=="string"?n.options.clobberPrefix:"user-content-",a=n.options.footnoteBackContent||uk,o=n.options.footnoteBackLabel||sk,c=n.options.footnoteLabel||"Footnotes",d=n.options.footnoteLabelTagName||"h2",f=n.options.footnoteLabelProperties||{className:["sr-only"]},p=[];let m=-1;for(;++m0&&T.push({type:"text",value:" "});let F=typeof a=="string"?a:a(m,x);typeof F=="string"&&(F={type:"text",value:F}),T.push({type:"element",tagName:"a",properties:{href:"#"+r+"fnref-"+k+(x>1?"-"+x:""),dataFootnoteBackref:"",ariaLabel:typeof o=="string"?o:o(m,x),className:["data-footnote-backref"]},children:Array.isArray(F)?F:[F]})}const Z=b[b.length-1];if(Z&&Z.type==="element"&&Z.tagName==="p"){const F=Z.children[Z.children.length-1];F&&F.type==="text"?F.value+=" ":Z.children.push({type:"text",value:" "}),Z.children.push(...T)}else b.push(...T);const D={type:"element",tagName:"li",properties:{id:r+"fn-"+k},children:n.wrap(b,!0)};n.patch(h,D),p.push(D)}if(p.length!==0)return{type:"element",tagName:"section",properties:{dataFootnotes:!0,className:["footnotes"]},children:[{type:"element",tagName:d,properties:{...co(f),id:"footnote-label"},children:[{type:"text",value:c}]},{type:"text",value:` +`},{type:"element",tagName:"ol",properties:{},children:n.wrap(p,!0)},{type:"text",value:` +`}]}}const bo=(function(n){if(n==null)return pk;if(typeof n=="function")return vo(n);if(typeof n=="object")return Array.isArray(n)?fk(n):dk(n);if(typeof n=="string")return hk(n);throw new Error("Expected function, string, or object as test")});function fk(n){const r=[];let a=-1;for(;++a":""))+")"})}return k;function k(){let x=kg,T,U,Z;if((!r||d(m,h,b[b.length-1]||void 0))&&(x=bk(a(m,b)),x[0]===fc))return x;if("children"in m&&m.children){const D=m;if(D.children&&x[0]!==yk)for(U=(o?D.children.length:-1)+f,Z=b.concat(D);U>-1&&U0&&a.push({type:"text",value:` +`}),a}function km(n){let r=0,a=n.charCodeAt(r);for(;a===9||a===32;)r++,a=n.charCodeAt(r);return n.slice(r)}function Sm(n,r){const a=xk(n,r),o=a.one(n,void 0),c=ck(a),d=Array.isArray(o)?{type:"root",children:o}:o||{type:"root",children:[]};return c&&d.children.push({type:"text",value:` +`},c),d}function Ek(n,r){return n&&"run"in n?async function(a,o){const c=Sm(a,{file:o,...r});await n.run(c,o)}:function(a,o){return Sm(a,{file:o,...n||r})}}function wm(n){if(n)throw n}var Zs,Cm;function Ak(){if(Cm)return Zs;Cm=1;var n=Object.prototype.hasOwnProperty,r=Object.prototype.toString,a=Object.defineProperty,o=Object.getOwnPropertyDescriptor,c=function(h){return typeof Array.isArray=="function"?Array.isArray(h):r.call(h)==="[object Array]"},d=function(h){if(!h||r.call(h)!=="[object Object]")return!1;var b=n.call(h,"constructor"),y=h.constructor&&h.constructor.prototype&&n.call(h.constructor.prototype,"isPrototypeOf");if(h.constructor&&!b&&!y)return!1;var k;for(k in h);return typeof k>"u"||n.call(h,k)},f=function(h,b){a&&b.name==="__proto__"?a(h,b.name,{enumerable:!0,configurable:!0,value:b.newValue,writable:!0}):h[b.name]=b.newValue},p=function(h,b){if(b==="__proto__")if(n.call(h,b)){if(o)return o(h,b).value}else return;return h[b]};return Zs=function m(){var h,b,y,k,x,T,U=arguments[0],Z=1,D=arguments.length,F=!1;for(typeof U=="boolean"&&(F=U,U=arguments[1]||{},Z=2),(U==null||typeof U!="object"&&typeof U!="function")&&(U={});Zf.length;let m;p&&f.push(c);try{m=n.apply(this,f)}catch(h){const b=h;if(p&&a)throw b;return c(b)}p||(m&&m.then&&typeof m.then=="function"?m.then(d,c):m instanceof Error?c(m):d(m))}function c(f,...p){a||(a=!0,r(f,...p))}function d(f){c(null,f)}}const dn={basename:_k,dirname:Mk,extname:Dk,join:Rk,sep:"/"};function _k(n,r){if(r!==void 0&&typeof r!="string")throw new TypeError('"ext" argument must be a string');Na(n);let a=0,o=-1,c=n.length,d;if(r===void 0||r.length===0||r.length>n.length){for(;c--;)if(n.codePointAt(c)===47){if(d){a=c+1;break}}else o<0&&(d=!0,o=c+1);return o<0?"":n.slice(a,o)}if(r===n)return"";let f=-1,p=r.length-1;for(;c--;)if(n.codePointAt(c)===47){if(d){a=c+1;break}}else f<0&&(d=!0,f=c+1),p>-1&&(n.codePointAt(c)===r.codePointAt(p--)?p<0&&(o=c):(p=-1,o=f));return a===o?o=f:o<0&&(o=n.length),n.slice(a,o)}function Mk(n){if(Na(n),n.length===0)return".";let r=-1,a=n.length,o;for(;--a;)if(n.codePointAt(a)===47){if(o){r=a;break}}else o||(o=!0);return r<0?n.codePointAt(0)===47?"/":".":r===1&&n.codePointAt(0)===47?"//":n.slice(0,r)}function Dk(n){Na(n);let r=n.length,a=-1,o=0,c=-1,d=0,f;for(;r--;){const p=n.codePointAt(r);if(p===47){if(f){o=r+1;break}continue}a<0&&(f=!0,a=r+1),p===46?c<0?c=r:d!==1&&(d=1):c>-1&&(d=-1)}return c<0||a<0||d===0||d===1&&c===a-1&&c===o+1?"":n.slice(c,a)}function Rk(...n){let r=-1,a;for(;++r0&&n.codePointAt(n.length-1)===47&&(a+="/"),r?"/"+a:a}function Nk(n,r){let a="",o=0,c=-1,d=0,f=-1,p,m;for(;++f<=n.length;){if(f2){if(m=a.lastIndexOf("/"),m!==a.length-1){m<0?(a="",o=0):(a=a.slice(0,m),o=a.length-1-a.lastIndexOf("/")),c=f,d=0;continue}}else if(a.length>0){a="",o=0,c=f,d=0;continue}}r&&(a=a.length>0?a+"/..":"..",o=2)}else a.length>0?a+="/"+n.slice(c+1,f):a=n.slice(c+1,f),o=f-c-1;c=f,d=0}else p===46&&d>-1?d++:d=-1}return a}function Na(n){if(typeof n!="string")throw new TypeError("Path must be a string. Received "+JSON.stringify(n))}const Lk={cwd:Uk};function Uk(){return"/"}function pc(n){return!!(n!==null&&typeof n=="object"&&"href"in n&&n.href&&"protocol"in n&&n.protocol&&n.auth===void 0)}function Bk(n){if(typeof n=="string")n=new URL(n);else if(!pc(n)){const r=new TypeError('The "path" argument must be of type string or an instance of URL. Received `'+n+"`");throw r.code="ERR_INVALID_ARG_TYPE",r}if(n.protocol!=="file:"){const r=new TypeError("The URL must be of scheme file");throw r.code="ERR_INVALID_URL_SCHEME",r}return Hk(n)}function Hk(n){if(n.hostname!==""){const o=new TypeError('File URL host must be "localhost" or empty on darwin');throw o.code="ERR_INVALID_FILE_URL_HOST",o}const r=n.pathname;let a=-1;for(;++a0){let[x,...T]=b;const U=o[k][1];hc(U)&&hc(x)&&(x=Ks(!0,U,x)),o[k]=[h,x,...T]}}}}const Vk=new Rc().freeze();function $s(n,r){if(typeof r!="function")throw new TypeError("Cannot `"+n+"` without `parser`")}function Ws(n,r){if(typeof r!="function")throw new TypeError("Cannot `"+n+"` without `compiler`")}function Ps(n,r){if(r)throw new Error("Cannot call `"+n+"` on a frozen processor.\nCreate a new processor first, by calling it: use `processor()` instead of `processor`.")}function Am(n){if(!hc(n)||typeof n.type!="string")throw new TypeError("Expected node, got `"+n+"`")}function Tm(n,r,a){if(!a)throw new Error("`"+n+"` finished async. Use `"+r+"` instead")}function lo(n){return Xk(n)?n:new wg(n)}function Xk(n){return!!(n&&typeof n=="object"&&"message"in n&&"messages"in n)}function Qk(n){return typeof n=="string"||Zk(n)}function Zk(n){return!!(n&&typeof n=="object"&&"byteLength"in n&&"byteOffset"in n)}const Kk="https://github.com/remarkjs/react-markdown/blob/main/changelog.md",zm=[],Om={allowDangerousHtml:!0},Fk=/^(https?|ircs?|mailto|xmpp)$/i,Ik=[{from:"astPlugins",id:"remove-buggy-html-in-markdown-parser"},{from:"allowDangerousHtml",id:"remove-buggy-html-in-markdown-parser"},{from:"allowNode",id:"replace-allownode-allowedtypes-and-disallowedtypes",to:"allowElement"},{from:"allowedTypes",id:"replace-allownode-allowedtypes-and-disallowedtypes",to:"allowedElements"},{from:"disallowedTypes",id:"replace-allownode-allowedtypes-and-disallowedtypes",to:"disallowedElements"},{from:"escapeHtml",id:"remove-buggy-html-in-markdown-parser"},{from:"includeElementIndex",id:"#remove-includeelementindex"},{from:"includeNodeIndex",id:"change-includenodeindex-to-includeelementindex"},{from:"linkTarget",id:"remove-linktarget"},{from:"plugins",id:"change-plugins-to-remarkplugins",to:"remarkPlugins"},{from:"rawSourcePos",id:"#remove-rawsourcepos"},{from:"renderers",id:"change-renderers-to-components",to:"components"},{from:"source",id:"change-source-to-children",to:"children"},{from:"sourcePos",id:"#remove-sourcepos"},{from:"transformImageUri",id:"#add-urltransform",to:"urlTransform"},{from:"transformLinkUri",id:"#add-urltransform",to:"urlTransform"}];function Jk(n){const r=$k(n),a=Wk(n);return Pk(r.runSync(r.parse(a),a),n)}function $k(n){const r=n.rehypePlugins||zm,a=n.remarkPlugins||zm,o=n.remarkRehypeOptions?{...n.remarkRehypeOptions,...Om}:Om;return Vk().use(_x).use(a).use(Ek,o).use(r)}function Wk(n){const r=n.children||"",a=new wg;return typeof r=="string"&&(a.value=r),a}function Pk(n,r){const a=r.allowedElements,o=r.allowElement,c=r.components,d=r.disallowedElements,f=r.skipHtml,p=r.unwrapDisallowed,m=r.urlTransform||eS;for(const b of Ik)Object.hasOwn(r,b.from)&&(""+b.from+(b.to?"use `"+b.to+"` instead":"remove it")+Kk+b.id,void 0);return r.className&&(n={type:"element",tagName:"div",properties:{className:r.className},children:n.type==="root"?n.children:[n]}),Dc(n,h),h0(n,{Fragment:H.Fragment,components:c,ignoreInvalidStyle:!0,jsx:H.jsx,jsxs:H.jsxs,passKeys:!0,passNode:!0});function h(b,y,k){if(b.type==="raw"&&k&&typeof y=="number")return f?k.children.splice(y,1):k.children[y]={type:"text",value:b.value},y;if(b.type==="element"){let x;for(x in Vs)if(Object.hasOwn(Vs,x)&&Object.hasOwn(b.properties,x)){const T=b.properties[x],U=Vs[x];(U===null||U.includes(b.tagName))&&(b.properties[x]=m(String(T||""),x,b))}}if(b.type==="element"){let x=a?!a.includes(b.tagName):d?d.includes(b.tagName):!1;if(!x&&o&&typeof y=="number"&&(x=!o(b,y,k)),x&&k&&typeof y=="number")return p&&b.children?k.children.splice(y,1,...b.children):k.children.splice(y,1),y}}}function eS(n){const r=n.indexOf(":"),a=n.indexOf("?"),o=n.indexOf("#"),c=n.indexOf("/");return r===-1||c!==-1&&r>c||a!==-1&&r>a||o!==-1&&r>o||Fk.test(n.slice(0,r))?n:""}function _m(n,r){const a=String(n);if(typeof r!="string")throw new TypeError("Expected character");let o=0,c=a.indexOf(r);for(;c!==-1;)o++,c=a.indexOf(r,c+r.length);return o}function tS(n){if(typeof n!="string")throw new TypeError("Expected a string");return n.replace(/[|\\{}()[\]^$+*?.]/g,"\\$&").replace(/-/g,"\\x2d")}function nS(n,r,a){const c=bo((a||{}).ignore||[]),d=lS(r);let f=-1;for(;++f0?{type:"text",value:L}:void 0),L===!1?k.lastIndex=oe+1:(T!==oe&&F.push({type:"text",value:h.value.slice(T,oe)}),Array.isArray(L)?F.push(...L):L&&F.push(L),T=oe+Q[0].length,D=!0),!k.global)break;Q=k.exec(h.value)}return D?(T?\]}]+$/.exec(n);if(!r)return[n,void 0];n=n.slice(0,r.index);let a=r[0],o=a.indexOf(")");const c=_m(n,"(");let d=_m(n,")");for(;o!==-1&&c>d;)n+=a.slice(0,o+1),a=a.slice(o+1),o=a.indexOf(")"),d++;return[n,a]}function Cg(n,r){const a=n.input.charCodeAt(n.index-1);return(n.index===0||Ol(a)||mo(a))&&(!r||a!==47)}Eg.peek=AS;function bS(){this.buffer()}function vS(n){this.enter({type:"footnoteReference",identifier:"",label:""},n)}function xS(){this.buffer()}function kS(n){this.enter({type:"footnoteDefinition",identifier:"",label:"",children:[]},n)}function SS(n){const r=this.resume(),a=this.stack[this.stack.length-1];a.type,a.identifier=on(this.sliceSerialize(n)).toLowerCase(),a.label=r}function wS(n){this.exit(n)}function CS(n){const r=this.resume(),a=this.stack[this.stack.length-1];a.type,a.identifier=on(this.sliceSerialize(n)).toLowerCase(),a.label=r}function ES(n){this.exit(n)}function AS(){return"["}function Eg(n,r,a,o){const c=a.createTracker(o);let d=c.move("[^");const f=a.enter("footnoteReference"),p=a.enter("reference");return d+=c.move(a.safe(a.associationId(n),{after:"]",before:d})),p(),f(),d+=c.move("]"),d}function TS(){return{enter:{gfmFootnoteCallString:bS,gfmFootnoteCall:vS,gfmFootnoteDefinitionLabelString:xS,gfmFootnoteDefinition:kS},exit:{gfmFootnoteCallString:SS,gfmFootnoteCall:wS,gfmFootnoteDefinitionLabelString:CS,gfmFootnoteDefinition:ES}}}function zS(n){let r=!1;return n&&n.firstLineBlank&&(r=!0),{handlers:{footnoteDefinition:a,footnoteReference:Eg},unsafe:[{character:"[",inConstruct:["label","phrasing","reference"]}]};function a(o,c,d,f){const p=d.createTracker(f);let m=p.move("[^");const h=d.enter("footnoteDefinition"),b=d.enter("label");return m+=p.move(d.safe(d.associationId(o),{before:m,after:"]"})),b(),m+=p.move("]:"),o.children&&o.children.length>0&&(p.shift(4),m+=p.move((r?` +`:" ")+d.indentLines(d.containerFlow(o,p.current()),r?Ag:OS))),h(),m}}function OS(n,r,a){return r===0?n:Ag(n,r,a)}function Ag(n,r,a){return(a?"":" ")+n}const _S=["autolink","destinationLiteral","destinationRaw","reference","titleQuote","titleApostrophe"];Tg.peek=NS;function MS(){return{canContainEols:["delete"],enter:{strikethrough:RS},exit:{strikethrough:jS}}}function DS(){return{unsafe:[{character:"~",inConstruct:"phrasing",notInConstruct:_S}],handlers:{delete:Tg}}}function RS(n){this.enter({type:"delete",children:[]},n)}function jS(n){this.exit(n)}function Tg(n,r,a,o){const c=a.createTracker(o),d=a.enter("strikethrough");let f=c.move("~~");return f+=a.containerPhrasing(n,{...c.current(),before:f,after:"~"}),f+=c.move("~~"),d(),f}function NS(){return"~"}function LS(n){return n.length}function US(n,r){const a=r||{},o=(a.align||[]).concat(),c=a.stringLength||LS,d=[],f=[],p=[],m=[];let h=0,b=-1;for(;++bh&&(h=n[b].length);++Dm[D])&&(m[D]=Q)}U.push(F)}f[b]=U,p[b]=Z}let y=-1;if(typeof o=="object"&&"length"in o)for(;++ym[y]&&(m[y]=F),x[y]=F),k[y]=Q}f.splice(1,0,k),p.splice(1,0,x),b=-1;const T=[];for(;++b "),d.shift(2);const f=a.indentLines(a.containerFlow(n,d.current()),qS);return c(),f}function qS(n,r,a){return">"+(a?"":" ")+n}function YS(n,r){return Dm(n,r.inConstruct,!0)&&!Dm(n,r.notInConstruct,!1)}function Dm(n,r,a){if(typeof r=="string"&&(r=[r]),!r||r.length===0)return a;let o=-1;for(;++of&&(f=d):d=1,c=o+r.length,o=a.indexOf(r,c);return f}function VS(n,r){return!!(r.options.fences===!1&&n.value&&!n.lang&&/[^ \r\n]/.test(n.value)&&!/^[\t ]*(?:[\r\n]|$)|(?:^|[\r\n])[\t ]*$/.test(n.value))}function XS(n){const r=n.options.fence||"`";if(r!=="`"&&r!=="~")throw new Error("Cannot serialize code with `"+r+"` for `options.fence`, expected `` ` `` or `~`");return r}function QS(n,r,a,o){const c=XS(a),d=n.value||"",f=c==="`"?"GraveAccent":"Tilde";if(VS(n,a)){const y=a.enter("codeIndented"),k=a.indentLines(d,ZS);return y(),k}const p=a.createTracker(o),m=c.repeat(Math.max(GS(d,c)+1,3)),h=a.enter("codeFenced");let b=p.move(m);if(n.lang){const y=a.enter(`codeFencedLang${f}`);b+=p.move(a.safe(n.lang,{before:b,after:" ",encode:["`"],...p.current()})),y()}if(n.lang&&n.meta){const y=a.enter(`codeFencedMeta${f}`);b+=p.move(" "),b+=p.move(a.safe(n.meta,{before:b,after:` +`,encode:["`"],...p.current()})),y()}return b+=p.move(` +`),d&&(b+=p.move(d+` +`)),b+=p.move(m),h(),b}function ZS(n,r,a){return(a?"":" ")+n}function jc(n){const r=n.options.quote||'"';if(r!=='"'&&r!=="'")throw new Error("Cannot serialize title with `"+r+"` for `options.quote`, expected `\"`, or `'`");return r}function KS(n,r,a,o){const c=jc(a),d=c==='"'?"Quote":"Apostrophe",f=a.enter("definition");let p=a.enter("label");const m=a.createTracker(o);let h=m.move("[");return h+=m.move(a.safe(a.associationId(n),{before:h,after:"]",...m.current()})),h+=m.move("]: "),p(),!n.url||/[\0- \u007F]/.test(n.url)?(p=a.enter("destinationLiteral"),h+=m.move("<"),h+=m.move(a.safe(n.url,{before:h,after:">",...m.current()})),h+=m.move(">")):(p=a.enter("destinationRaw"),h+=m.move(a.safe(n.url,{before:h,after:n.title?" ":` +`,...m.current()}))),p(),n.title&&(p=a.enter(`title${d}`),h+=m.move(" "+c),h+=m.move(a.safe(n.title,{before:h,after:c,...m.current()})),h+=m.move(c),p()),f(),h}function FS(n){const r=n.options.emphasis||"*";if(r!=="*"&&r!=="_")throw new Error("Cannot serialize emphasis with `"+r+"` for `options.emphasis`, expected `*`, or `_`");return r}function Da(n){return"&#x"+n.toString(16).toUpperCase()+";"}function fo(n,r,a){const o=wi(n),c=wi(r);return o===void 0?c===void 0?a==="_"?{inside:!0,outside:!0}:{inside:!1,outside:!1}:c===1?{inside:!0,outside:!0}:{inside:!1,outside:!0}:o===1?c===void 0?{inside:!1,outside:!1}:c===1?{inside:!0,outside:!0}:{inside:!1,outside:!1}:c===void 0?{inside:!1,outside:!1}:c===1?{inside:!0,outside:!1}:{inside:!1,outside:!1}}zg.peek=IS;function zg(n,r,a,o){const c=FS(a),d=a.enter("emphasis"),f=a.createTracker(o),p=f.move(c);let m=f.move(a.containerPhrasing(n,{after:c,before:p,...f.current()}));const h=m.charCodeAt(0),b=fo(o.before.charCodeAt(o.before.length-1),h,c);b.inside&&(m=Da(h)+m.slice(1));const y=m.charCodeAt(m.length-1),k=fo(o.after.charCodeAt(0),y,c);k.inside&&(m=m.slice(0,-1)+Da(y));const x=f.move(c);return d(),a.attentionEncodeSurroundingInfo={after:k.outside,before:b.outside},p+m+x}function IS(n,r,a){return a.options.emphasis||"*"}function JS(n,r){let a=!1;return Dc(n,function(o){if("value"in o&&/\r?\n|\r/.test(o.value)||o.type==="break")return a=!0,fc}),!!((!n.depth||n.depth<3)&&Ec(n)&&(r.options.setext||a))}function $S(n,r,a,o){const c=Math.max(Math.min(6,n.depth||1),1),d=a.createTracker(o);if(JS(n,a)){const b=a.enter("headingSetext"),y=a.enter("phrasing"),k=a.containerPhrasing(n,{...d.current(),before:` +`,after:` +`});return y(),b(),k+` +`+(c===1?"=":"-").repeat(k.length-(Math.max(k.lastIndexOf("\r"),k.lastIndexOf(` +`))+1))}const f="#".repeat(c),p=a.enter("headingAtx"),m=a.enter("phrasing");d.move(f+" ");let h=a.containerPhrasing(n,{before:"# ",after:` +`,...d.current()});return/^[\t ]/.test(h)&&(h=Da(h.charCodeAt(0))+h.slice(1)),h=h?f+" "+h:f,a.options.closeAtx&&(h+=" "+f),m(),p(),h}Og.peek=WS;function Og(n){return n.value||""}function WS(){return"<"}_g.peek=PS;function _g(n,r,a,o){const c=jc(a),d=c==='"'?"Quote":"Apostrophe",f=a.enter("image");let p=a.enter("label");const m=a.createTracker(o);let h=m.move("![");return h+=m.move(a.safe(n.alt,{before:h,after:"]",...m.current()})),h+=m.move("]("),p(),!n.url&&n.title||/[\0- \u007F]/.test(n.url)?(p=a.enter("destinationLiteral"),h+=m.move("<"),h+=m.move(a.safe(n.url,{before:h,after:">",...m.current()})),h+=m.move(">")):(p=a.enter("destinationRaw"),h+=m.move(a.safe(n.url,{before:h,after:n.title?" ":")",...m.current()}))),p(),n.title&&(p=a.enter(`title${d}`),h+=m.move(" "+c),h+=m.move(a.safe(n.title,{before:h,after:c,...m.current()})),h+=m.move(c),p()),h+=m.move(")"),f(),h}function PS(){return"!"}Mg.peek=e2;function Mg(n,r,a,o){const c=n.referenceType,d=a.enter("imageReference");let f=a.enter("label");const p=a.createTracker(o);let m=p.move("![");const h=a.safe(n.alt,{before:m,after:"]",...p.current()});m+=p.move(h+"]["),f();const b=a.stack;a.stack=[],f=a.enter("reference");const y=a.safe(a.associationId(n),{before:m,after:"]",...p.current()});return f(),a.stack=b,d(),c==="full"||!h||h!==y?m+=p.move(y+"]"):c==="shortcut"?m=m.slice(0,-1):m+=p.move("]"),m}function e2(){return"!"}Dg.peek=t2;function Dg(n,r,a){let o=n.value||"",c="`",d=-1;for(;new RegExp("(^|[^`])"+c+"([^`]|$)").test(o);)c+="`";for(/[^ \r\n]/.test(o)&&(/^[ \r\n]/.test(o)&&/[ \r\n]$/.test(o)||/^`|`$/.test(o))&&(o=" "+o+" ");++d\u007F]/.test(n.url))}jg.peek=n2;function jg(n,r,a,o){const c=jc(a),d=c==='"'?"Quote":"Apostrophe",f=a.createTracker(o);let p,m;if(Rg(n,a)){const b=a.stack;a.stack=[],p=a.enter("autolink");let y=f.move("<");return y+=f.move(a.containerPhrasing(n,{before:y,after:">",...f.current()})),y+=f.move(">"),p(),a.stack=b,y}p=a.enter("link"),m=a.enter("label");let h=f.move("[");return h+=f.move(a.containerPhrasing(n,{before:h,after:"](",...f.current()})),h+=f.move("]("),m(),!n.url&&n.title||/[\0- \u007F]/.test(n.url)?(m=a.enter("destinationLiteral"),h+=f.move("<"),h+=f.move(a.safe(n.url,{before:h,after:">",...f.current()})),h+=f.move(">")):(m=a.enter("destinationRaw"),h+=f.move(a.safe(n.url,{before:h,after:n.title?" ":")",...f.current()}))),m(),n.title&&(m=a.enter(`title${d}`),h+=f.move(" "+c),h+=f.move(a.safe(n.title,{before:h,after:c,...f.current()})),h+=f.move(c),m()),h+=f.move(")"),p(),h}function n2(n,r,a){return Rg(n,a)?"<":"["}Ng.peek=l2;function Ng(n,r,a,o){const c=n.referenceType,d=a.enter("linkReference");let f=a.enter("label");const p=a.createTracker(o);let m=p.move("[");const h=a.containerPhrasing(n,{before:m,after:"]",...p.current()});m+=p.move(h+"]["),f();const b=a.stack;a.stack=[],f=a.enter("reference");const y=a.safe(a.associationId(n),{before:m,after:"]",...p.current()});return f(),a.stack=b,d(),c==="full"||!h||h!==y?m+=p.move(y+"]"):c==="shortcut"?m=m.slice(0,-1):m+=p.move("]"),m}function l2(){return"["}function Nc(n){const r=n.options.bullet||"*";if(r!=="*"&&r!=="+"&&r!=="-")throw new Error("Cannot serialize items with `"+r+"` for `options.bullet`, expected `*`, `+`, or `-`");return r}function i2(n){const r=Nc(n),a=n.options.bulletOther;if(!a)return r==="*"?"-":"*";if(a!=="*"&&a!=="+"&&a!=="-")throw new Error("Cannot serialize items with `"+a+"` for `options.bulletOther`, expected `*`, `+`, or `-`");if(a===r)throw new Error("Expected `bullet` (`"+r+"`) and `bulletOther` (`"+a+"`) to be different");return a}function a2(n){const r=n.options.bulletOrdered||".";if(r!=="."&&r!==")")throw new Error("Cannot serialize items with `"+r+"` for `options.bulletOrdered`, expected `.` or `)`");return r}function Lg(n){const r=n.options.rule||"*";if(r!=="*"&&r!=="-"&&r!=="_")throw new Error("Cannot serialize rules with `"+r+"` for `options.rule`, expected `*`, `-`, or `_`");return r}function r2(n,r,a,o){const c=a.enter("list"),d=a.bulletCurrent;let f=n.ordered?a2(a):Nc(a);const p=n.ordered?f==="."?")":".":i2(a);let m=r&&a.bulletLastUsed?f===a.bulletLastUsed:!1;if(!n.ordered){const b=n.children?n.children[0]:void 0;if((f==="*"||f==="-")&&b&&(!b.children||!b.children[0])&&a.stack[a.stack.length-1]==="list"&&a.stack[a.stack.length-2]==="listItem"&&a.stack[a.stack.length-3]==="list"&&a.stack[a.stack.length-4]==="listItem"&&a.indexStack[a.indexStack.length-1]===0&&a.indexStack[a.indexStack.length-2]===0&&a.indexStack[a.indexStack.length-3]===0&&(m=!0),Lg(a)===f&&b){let y=-1;for(;++y-1?r.start:1)+(a.options.incrementListMarker===!1?0:r.children.indexOf(n))+d);let f=d.length+1;(c==="tab"||c==="mixed"&&(r&&r.type==="list"&&r.spread||n.spread))&&(f=Math.ceil(f/4)*4);const p=a.createTracker(o);p.move(d+" ".repeat(f-d.length)),p.shift(f);const m=a.enter("listItem"),h=a.indentLines(a.containerFlow(n,p.current()),b);return m(),h;function b(y,k,x){return k?(x?"":" ".repeat(f))+y:(x?d:d+" ".repeat(f-d.length))+y}}function s2(n,r,a,o){const c=a.enter("paragraph"),d=a.enter("phrasing"),f=a.containerPhrasing(n,o);return d(),c(),f}const c2=bo(["break","delete","emphasis","footnote","footnoteReference","image","imageReference","inlineCode","inlineMath","link","linkReference","mdxJsxTextElement","mdxTextExpression","strong","text","textDirective"]);function f2(n,r,a,o){return(n.children.some(function(f){return c2(f)})?a.containerPhrasing:a.containerFlow).call(a,n,o)}function d2(n){const r=n.options.strong||"*";if(r!=="*"&&r!=="_")throw new Error("Cannot serialize strong with `"+r+"` for `options.strong`, expected `*`, or `_`");return r}Ug.peek=h2;function Ug(n,r,a,o){const c=d2(a),d=a.enter("strong"),f=a.createTracker(o),p=f.move(c+c);let m=f.move(a.containerPhrasing(n,{after:c,before:p,...f.current()}));const h=m.charCodeAt(0),b=fo(o.before.charCodeAt(o.before.length-1),h,c);b.inside&&(m=Da(h)+m.slice(1));const y=m.charCodeAt(m.length-1),k=fo(o.after.charCodeAt(0),y,c);k.inside&&(m=m.slice(0,-1)+Da(y));const x=f.move(c+c);return d(),a.attentionEncodeSurroundingInfo={after:k.outside,before:b.outside},p+m+x}function h2(n,r,a){return a.options.strong||"*"}function p2(n,r,a,o){return a.safe(n.value,o)}function m2(n){const r=n.options.ruleRepetition||3;if(r<3)throw new Error("Cannot serialize rules with repetition `"+r+"` for `options.ruleRepetition`, expected `3` or more");return r}function g2(n,r,a){const o=(Lg(a)+(a.options.ruleSpaces?" ":"")).repeat(m2(a));return a.options.ruleSpaces?o.slice(0,-1):o}const Bg={blockquote:HS,break:Rm,code:QS,definition:KS,emphasis:zg,hardBreak:Rm,heading:$S,html:Og,image:_g,imageReference:Mg,inlineCode:Dg,link:jg,linkReference:Ng,list:r2,listItem:u2,paragraph:s2,root:f2,strong:Ug,text:p2,thematicBreak:g2};function y2(){return{enter:{table:b2,tableData:jm,tableHeader:jm,tableRow:x2},exit:{codeText:k2,table:v2,tableData:lc,tableHeader:lc,tableRow:lc}}}function b2(n){const r=n._align;this.enter({type:"table",align:r.map(function(a){return a==="none"?null:a}),children:[]},n),this.data.inTable=!0}function v2(n){this.exit(n),this.data.inTable=void 0}function x2(n){this.enter({type:"tableRow",children:[]},n)}function lc(n){this.exit(n)}function jm(n){this.enter({type:"tableCell",children:[]},n)}function k2(n){let r=this.resume();this.data.inTable&&(r=r.replace(/\\([\\|])/g,S2));const a=this.stack[this.stack.length-1];a.type,a.value=r,this.exit(n)}function S2(n,r){return r==="|"?r:n}function w2(n){const r=n||{},a=r.tableCellPadding,o=r.tablePipeAlign,c=r.stringLength,d=a?" ":"|";return{unsafe:[{character:"\r",inConstruct:"tableCell"},{character:` +`,inConstruct:"tableCell"},{atBreak:!0,character:"|",after:"[ :-]"},{character:"|",inConstruct:"tableCell"},{atBreak:!0,character:":",after:"-"},{atBreak:!0,character:"-",after:"[:|-]"}],handlers:{inlineCode:k,table:f,tableCell:m,tableRow:p}};function f(x,T,U,Z){return h(b(x,U,Z),x.align)}function p(x,T,U,Z){const D=y(x,U,Z),F=h([D]);return F.slice(0,F.indexOf(` +`))}function m(x,T,U,Z){const D=U.enter("tableCell"),F=U.enter("phrasing"),Q=U.containerPhrasing(x,{...Z,before:d,after:d});return F(),D(),Q}function h(x,T){return US(x,{align:T,alignDelimiters:o,padding:a,stringLength:c})}function b(x,T,U){const Z=x.children;let D=-1;const F=[],Q=T.enter("table");for(;++D0&&!a&&(n[n.length-1][1]._gfmAutolinkLiteralWalkedInto=!0),a}const Y2={tokenize:I2,partial:!0};function G2(){return{document:{91:{name:"gfmFootnoteDefinition",tokenize:Z2,continuation:{tokenize:K2},exit:F2}},text:{91:{name:"gfmFootnoteCall",tokenize:Q2},93:{name:"gfmPotentialFootnoteCall",add:"after",tokenize:V2,resolveTo:X2}}}}function V2(n,r,a){const o=this;let c=o.events.length;const d=o.parser.gfmFootnotes||(o.parser.gfmFootnotes=[]);let f;for(;c--;){const m=o.events[c][1];if(m.type==="labelImage"){f=m;break}if(m.type==="gfmFootnoteCall"||m.type==="labelLink"||m.type==="label"||m.type==="image"||m.type==="link")break}return p;function p(m){if(!f||!f._balanced)return a(m);const h=on(o.sliceSerialize({start:f.end,end:o.now()}));return h.codePointAt(0)!==94||!d.includes(h.slice(1))?a(m):(n.enter("gfmFootnoteCallLabelMarker"),n.consume(m),n.exit("gfmFootnoteCallLabelMarker"),r(m))}}function X2(n,r){let a=n.length;for(;a--;)if(n[a][1].type==="labelImage"&&n[a][0]==="enter"){n[a][1];break}n[a+1][1].type="data",n[a+3][1].type="gfmFootnoteCallLabelMarker";const o={type:"gfmFootnoteCall",start:Object.assign({},n[a+3][1].start),end:Object.assign({},n[n.length-1][1].end)},c={type:"gfmFootnoteCallMarker",start:Object.assign({},n[a+3][1].end),end:Object.assign({},n[a+3][1].end)};c.end.column++,c.end.offset++,c.end._bufferIndex++;const d={type:"gfmFootnoteCallString",start:Object.assign({},c.end),end:Object.assign({},n[n.length-1][1].start)},f={type:"chunkString",contentType:"string",start:Object.assign({},d.start),end:Object.assign({},d.end)},p=[n[a+1],n[a+2],["enter",o,r],n[a+3],n[a+4],["enter",c,r],["exit",c,r],["enter",d,r],["enter",f,r],["exit",f,r],["exit",d,r],n[n.length-2],n[n.length-1],["exit",o,r]];return n.splice(a,n.length-a+1,...p),n}function Q2(n,r,a){const o=this,c=o.parser.gfmFootnotes||(o.parser.gfmFootnotes=[]);let d=0,f;return p;function p(y){return n.enter("gfmFootnoteCall"),n.enter("gfmFootnoteCallLabelMarker"),n.consume(y),n.exit("gfmFootnoteCallLabelMarker"),m}function m(y){return y!==94?a(y):(n.enter("gfmFootnoteCallMarker"),n.consume(y),n.exit("gfmFootnoteCallMarker"),n.enter("gfmFootnoteCallString"),n.enter("chunkString").contentType="string",h)}function h(y){if(d>999||y===93&&!f||y===null||y===91||Ve(y))return a(y);if(y===93){n.exit("chunkString");const k=n.exit("gfmFootnoteCallString");return c.includes(on(o.sliceSerialize(k)))?(n.enter("gfmFootnoteCallLabelMarker"),n.consume(y),n.exit("gfmFootnoteCallLabelMarker"),n.exit("gfmFootnoteCall"),r):a(y)}return Ve(y)||(f=!0),d++,n.consume(y),y===92?b:h}function b(y){return y===91||y===92||y===93?(n.consume(y),d++,h):h(y)}}function Z2(n,r,a){const o=this,c=o.parser.gfmFootnotes||(o.parser.gfmFootnotes=[]);let d,f=0,p;return m;function m(T){return n.enter("gfmFootnoteDefinition")._container=!0,n.enter("gfmFootnoteDefinitionLabel"),n.enter("gfmFootnoteDefinitionLabelMarker"),n.consume(T),n.exit("gfmFootnoteDefinitionLabelMarker"),h}function h(T){return T===94?(n.enter("gfmFootnoteDefinitionMarker"),n.consume(T),n.exit("gfmFootnoteDefinitionMarker"),n.enter("gfmFootnoteDefinitionLabelString"),n.enter("chunkString").contentType="string",b):a(T)}function b(T){if(f>999||T===93&&!p||T===null||T===91||Ve(T))return a(T);if(T===93){n.exit("chunkString");const U=n.exit("gfmFootnoteDefinitionLabelString");return d=on(o.sliceSerialize(U)),n.enter("gfmFootnoteDefinitionLabelMarker"),n.consume(T),n.exit("gfmFootnoteDefinitionLabelMarker"),n.exit("gfmFootnoteDefinitionLabel"),k}return Ve(T)||(p=!0),f++,n.consume(T),T===92?y:b}function y(T){return T===91||T===92||T===93?(n.consume(T),f++,b):b(T)}function k(T){return T===58?(n.enter("definitionMarker"),n.consume(T),n.exit("definitionMarker"),c.includes(d)||c.push(d),Oe(n,x,"gfmFootnoteDefinitionWhitespace")):a(T)}function x(T){return r(T)}}function K2(n,r,a){return n.check(ja,r,n.attempt(Y2,r,a))}function F2(n){n.exit("gfmFootnoteDefinition")}function I2(n,r,a){const o=this;return Oe(n,c,"gfmFootnoteDefinitionIndent",5);function c(d){const f=o.events[o.events.length-1];return f&&f[1].type==="gfmFootnoteDefinitionIndent"&&f[2].sliceSerialize(f[1],!0).length===4?r(d):a(d)}}function J2(n){let a=(n||{}).singleTilde;const o={name:"strikethrough",tokenize:d,resolveAll:c};return a==null&&(a=!0),{text:{126:o},insideSpan:{null:[o]},attentionMarkers:{null:[126]}};function c(f,p){let m=-1;for(;++m1?m(T):(f.consume(T),y++,x);if(y<2&&!a)return m(T);const Z=f.exit("strikethroughSequenceTemporary"),D=wi(T);return Z._open=!D||D===2&&!!U,Z._close=!U||U===2&&!!D,p(T)}}}class $2{constructor(){this.map=[]}add(r,a,o){W2(this,r,a,o)}consume(r){if(this.map.sort(function(d,f){return d[0]-f[0]}),this.map.length===0)return;let a=this.map.length;const o=[];for(;a>0;)a-=1,o.push(r.slice(this.map[a][0]+this.map[a][1]),this.map[a][2]),r.length=this.map[a][0];o.push(r.slice()),r.length=0;let c=o.pop();for(;c;){for(const d of c)r.push(d);c=o.pop()}this.map.length=0}}function W2(n,r,a,o){let c=0;if(!(a===0&&o.length===0)){for(;c-1;){const J=o.events[te][1].type;if(J==="lineEnding"||J==="linePrefix")te--;else break}const B=te>-1?o.events[te][1].type:null,le=B==="tableHead"||B==="tableRow"?L:m;return le===L&&o.parser.lazy[o.now().line]?a(j):le(j)}function m(j){return n.enter("tableHead"),n.enter("tableRow"),h(j)}function h(j){return j===124||(f=!0,d+=1),b(j)}function b(j){return j===null?a(j):ce(j)?d>1?(d=0,o.interrupt=!0,n.exit("tableRow"),n.enter("lineEnding"),n.consume(j),n.exit("lineEnding"),x):a(j):Ce(j)?Oe(n,b,"whitespace")(j):(d+=1,f&&(f=!1,c+=1),j===124?(n.enter("tableCellDivider"),n.consume(j),n.exit("tableCellDivider"),f=!0,b):(n.enter("data"),y(j)))}function y(j){return j===null||j===124||Ve(j)?(n.exit("data"),b(j)):(n.consume(j),j===92?k:y)}function k(j){return j===92||j===124?(n.consume(j),y):y(j)}function x(j){return o.interrupt=!1,o.parser.lazy[o.now().line]?a(j):(n.enter("tableDelimiterRow"),f=!1,Ce(j)?Oe(n,T,"linePrefix",o.parser.constructs.disable.null.includes("codeIndented")?void 0:4)(j):T(j))}function T(j){return j===45||j===58?Z(j):j===124?(f=!0,n.enter("tableCellDivider"),n.consume(j),n.exit("tableCellDivider"),U):re(j)}function U(j){return Ce(j)?Oe(n,Z,"whitespace")(j):Z(j)}function Z(j){return j===58?(d+=1,f=!0,n.enter("tableDelimiterMarker"),n.consume(j),n.exit("tableDelimiterMarker"),D):j===45?(d+=1,D(j)):j===null||ce(j)?oe(j):re(j)}function D(j){return j===45?(n.enter("tableDelimiterFiller"),F(j)):re(j)}function F(j){return j===45?(n.consume(j),F):j===58?(f=!0,n.exit("tableDelimiterFiller"),n.enter("tableDelimiterMarker"),n.consume(j),n.exit("tableDelimiterMarker"),Q):(n.exit("tableDelimiterFiller"),Q(j))}function Q(j){return Ce(j)?Oe(n,oe,"whitespace")(j):oe(j)}function oe(j){return j===124?T(j):j===null||ce(j)?!f||c!==d?re(j):(n.exit("tableDelimiterRow"),n.exit("tableHead"),r(j)):re(j)}function re(j){return a(j)}function L(j){return n.enter("tableRow"),P(j)}function P(j){return j===124?(n.enter("tableCellDivider"),n.consume(j),n.exit("tableCellDivider"),P):j===null||ce(j)?(n.exit("tableRow"),r(j)):Ce(j)?Oe(n,P,"whitespace")(j):(n.enter("data"),he(j))}function he(j){return j===null||j===124||Ve(j)?(n.exit("data"),P(j)):(n.consume(j),j===92?me:he)}function me(j){return j===92||j===124?(n.consume(j),he):he(j)}}function nw(n,r){let a=-1,o=!0,c=0,d=[0,0,0,0],f=[0,0,0,0],p=!1,m=0,h,b,y;const k=new $2;for(;++aa[2]+1){const T=a[2]+1,U=a[3]-a[2]-1;n.add(T,U,[])}}n.add(a[3]+1,0,[["exit",y,r]])}return c!==void 0&&(d.end=Object.assign({},Si(r.events,c)),n.add(c,0,[["exit",d,r]]),d=void 0),d}function Lm(n,r,a,o,c){const d=[],f=Si(r.events,a);c&&(c.end=Object.assign({},f),d.push(["exit",c,r])),o.end=Object.assign({},f),d.push(["exit",o,r]),n.add(a+1,0,d)}function Si(n,r){const a=n[r],o=a[0]==="enter"?"start":"end";return a[1][o]}const lw={name:"tasklistCheck",tokenize:aw};function iw(){return{text:{91:lw}}}function aw(n,r,a){const o=this;return c;function c(m){return o.previous!==null||!o._gfmTasklistFirstContentOfListItem?a(m):(n.enter("taskListCheck"),n.enter("taskListCheckMarker"),n.consume(m),n.exit("taskListCheckMarker"),d)}function d(m){return Ve(m)?(n.enter("taskListCheckValueUnchecked"),n.consume(m),n.exit("taskListCheckValueUnchecked"),f):m===88||m===120?(n.enter("taskListCheckValueChecked"),n.consume(m),n.exit("taskListCheckValueChecked"),f):a(m)}function f(m){return m===93?(n.enter("taskListCheckMarker"),n.consume(m),n.exit("taskListCheckMarker"),n.exit("taskListCheck"),p):a(m)}function p(m){return ce(m)?r(m):Ce(m)?n.check({tokenize:rw},r,a)(m):a(m)}}function rw(n,r,a){return Oe(n,o,"whitespace");function o(c){return c===null?a(c):r(c)}}function ow(n){return lg([D2(),G2(),J2(n),ew(),iw()])}const uw={};function sw(n){const r=this,a=n||uw,o=r.data(),c=o.micromarkExtensions||(o.micromarkExtensions=[]),d=o.fromMarkdownExtensions||(o.fromMarkdownExtensions=[]),f=o.toMarkdownExtensions||(o.toMarkdownExtensions=[]);c.push(ow(a)),d.push(z2()),f.push(O2(a))}/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const cw=n=>n.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),Kg=(...n)=>n.filter((r,a,o)=>!!r&&o.indexOf(r)===a).join(" ");/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */var fw={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const dw=We.forwardRef(({color:n="currentColor",size:r=24,strokeWidth:a=2,absoluteStrokeWidth:o,className:c="",children:d,iconNode:f,...p},m)=>We.createElement("svg",{ref:m,...fw,width:r,height:r,stroke:n,strokeWidth:o?Number(a)*24/Number(r):a,className:Kg("lucide",c),...p},[...f.map(([h,b])=>We.createElement(h,b)),...Array.isArray(d)?d:[d]]));/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const ut=(n,r)=>{const a=We.forwardRef(({className:o,...c},d)=>We.createElement(dw,{ref:d,iconNode:r,className:Kg(`lucide-${cw(n)}`,o),...c}));return a.displayName=`${n}`,a};/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const ho=ut("ArrowUpRight",[["path",{d:"M7 7h10v10",key:"1tivn9"}],["path",{d:"M7 17 17 7",key:"1vkiza"}]]);/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Fg=ut("BookOpen",[["path",{d:"M12 7v14",key:"1akyts"}],["path",{d:"M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z",key:"ruj8y"}]]);/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const hw=ut("Bot",[["path",{d:"M12 8V4H8",key:"hb8ula"}],["rect",{width:"16",height:"12",x:"4",y:"8",rx:"2",key:"enze0r"}],["path",{d:"M2 14h2",key:"vft8re"}],["path",{d:"M20 14h2",key:"4cs60a"}],["path",{d:"M15 13v2",key:"1xurst"}],["path",{d:"M9 13v2",key:"rq6x2g"}]]);/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const pw=ut("Check",[["path",{d:"M20 6 9 17l-5-5",key:"1gmf2c"}]]);/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const mw=ut("ChevronLeft",[["path",{d:"m15 18-6-6 6-6",key:"1wnfg3"}]]);/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Um=ut("ChevronRight",[["path",{d:"m9 18 6-6-6-6",key:"mthhwq"}]]);/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const gw=ut("Copy",[["rect",{width:"14",height:"14",x:"8",y:"8",rx:"2",ry:"2",key:"17jyea"}],["path",{d:"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2",key:"zix9uf"}]]);/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const yw=ut("FlaskConical",[["path",{d:"M10 2v7.527a2 2 0 0 1-.211.896L4.72 20.55a1 1 0 0 0 .9 1.45h12.76a1 1 0 0 0 .9-1.45l-5.069-10.127A2 2 0 0 1 14 9.527V2",key:"pzvekw"}],["path",{d:"M8.5 2h7",key:"csnxdl"}],["path",{d:"M7 16h10",key:"wp8him"}]]);/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Bm=ut("GitBranch",[["line",{x1:"6",x2:"6",y1:"3",y2:"15",key:"17qcm7"}],["circle",{cx:"18",cy:"6",r:"3",key:"1h7g24"}],["circle",{cx:"6",cy:"18",r:"3",key:"fqmcym"}],["path",{d:"M18 9a9 9 0 0 1-9 9",key:"n2h4wq"}]]);/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const bw=ut("Layers",[["path",{d:"m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z",key:"8b97xw"}],["path",{d:"m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65",key:"dd6zsq"}],["path",{d:"m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65",key:"ep9fru"}]]);/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const vw=ut("Moon",[["path",{d:"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z",key:"a7tn18"}]]);/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const xw=ut("PackageCheck",[["path",{d:"m16 16 2 2 4-4",key:"gfu2re"}],["path",{d:"M21 10V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l2-1.14",key:"e7tb2h"}],["path",{d:"m7.5 4.27 9 5.15",key:"1c824w"}],["polyline",{points:"3.29 7 12 12 20.71 7",key:"ousv84"}],["line",{x1:"12",x2:"12",y1:"22",y2:"12",key:"a4e8g8"}]]);/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const kw=ut("Rocket",[["path",{d:"M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z",key:"m3kijz"}],["path",{d:"m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z",key:"1fmvmk"}],["path",{d:"M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0",key:"1f8sc4"}],["path",{d:"M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5",key:"qeys4"}]]);/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Sw=ut("Search",[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["path",{d:"m21 21-4.3-4.3",key:"1qie3q"}]]);/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const ww=ut("ShieldCheck",[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z",key:"oel41y"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]]);/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Cw=ut("Star",[["polygon",{points:"12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2",key:"8f66p6"}]]);/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Ew=ut("Sun",[["circle",{cx:"12",cy:"12",r:"4",key:"4exip2"}],["path",{d:"M12 2v2",key:"tus03m"}],["path",{d:"M12 20v2",key:"1lh1kg"}],["path",{d:"m4.93 4.93 1.41 1.41",key:"149t6j"}],["path",{d:"m17.66 17.66 1.41 1.41",key:"ptbguv"}],["path",{d:"M2 12h2",key:"1t8f8n"}],["path",{d:"M20 12h2",key:"1q8mjw"}],["path",{d:"m6.34 17.66-1.41 1.41",key:"1m8zz5"}],["path",{d:"m19.07 4.93-1.41 1.41",key:"1shlcs"}]]);/** + * @license lucide-react v0.453.0 - ISC + * + * This source code is licensed under the ISC license. + * See the LICENSE file in the root directory of this source tree. + */const Hm=ut("Terminal",[["polyline",{points:"4 17 10 11 4 5",key:"akl6gq"}],["line",{x1:"12",x2:"20",y1:"19",y2:"19",key:"q2wloq"}]]);function Aw(){if(typeof window>"u")return"light";try{const n=window.localStorage.getItem("docs-theme");if(n==="light"||n==="dark")return n}catch{}return"light"}function Tw(n){typeof document>"u"||(document.documentElement.classList.toggle("dark",n==="dark"),document.documentElement.style.colorScheme=n)}function zw(){const[n,r]=We.useState(Aw);return We.useEffect(()=>{Tw(n);try{window.localStorage.setItem("docs-theme",n)}catch{}},[n]),{theme:n,toggle:()=>r(o=>o==="dark"?"light":"dark")}}const an='"JetBrains Mono", "SF Mono", ui-monospace, monospace',Wt='"Computer Modern Concrete", "Concrete Roman", Georgia, "Times New Roman", serif',oo=[{key:"openscience",label:"OpenScience",short:"OpenScience",tagline:"Open-source AI workbench",lead:!1}],Uc=oo.map(n=>n.key),Ow=Object.assign({"./content/openscience/agents.mdx":Sb,"./content/openscience/atlas.mdx":wb,"./content/openscience/commands.mdx":Cb,"./content/openscience/index.mdx":Eb,"./content/openscience/local-models.mdx":Ab,"./content/openscience/models.mdx":Tb,"./content/openscience/quickstart.mdx":zb,"./content/openscience/sandbox.mdx":Ob,"./content/openscience/security.mdx":_b,"./content/openscience/sessions.mdx":Mb,"./content/openscience/skills.mdx":Db,"./content/openscience/workspace.mdx":Rb}),_w=Object.assign({"./content/openscience/docs.json":Bb}),qm={index:H.jsx(Bm,{size:17,strokeWidth:1.8}),quickstart:H.jsx(kw,{size:17,strokeWidth:1.8}),workspace:H.jsx(bw,{size:17,strokeWidth:1.8}),agents:H.jsx(hw,{size:17,strokeWidth:1.8}),models:H.jsx(xw,{size:17,strokeWidth:1.8}),skills:H.jsx(Fg,{size:17,strokeWidth:1.8}),sessions:H.jsx(Hm,{size:17,strokeWidth:1.8}),atlas:H.jsx(Bm,{size:17,strokeWidth:1.8}),commands:H.jsx(Hm,{size:17,strokeWidth:1.8}),security:H.jsx(ww,{size:17,strokeWidth:1.8})},Mw={openscience:H.jsx(yw,{size:17,strokeWidth:1.8})},Dw=/^---\n([\s\S]*?)\n---\n?/;function Rw(n){const r=n.match(Dw);if(!r)return{title:"Untitled",description:"",body:n};const a=r[1],o=n.slice(r[0].length),c=d=>{const f=a.split(` +`).find(p=>p.trim().startsWith(`${d}:`));return f?f.split(":").slice(1).join(":").trim().replace(/^["']|["']$/g,""):""};return{title:c("title")||"Untitled",description:c("description"),body:o}}function jw(n){return n.split(` +`).filter(r=>r.startsWith("## ")).map(r=>r.replace(/^##\s+/,"").trim()).slice(0,10)}function ki(n){return n.flatMap(r=>typeof r=="string"?[r]:r.pages)}function Nw(n,r){const a=n.split("/").pop()??n;return qm[n]??qm[a]??Mw[r]}function Lw(n){const r=`./content/${n}/`,a={};for(const[o,c]of Object.entries(Ow)){if(!o.startsWith(r))continue;const d=o.slice(r.length).replace(/\.(mdx|md)$/,""),f=Rw(c);a[d]={path:d,title:f.title,description:f.description,icon:Nw(d,n),body:f.body,headings:jw(f.body)}}return a}const za={openscience:Lw("openscience")},Ym={openscience:_w["./content/openscience/docs.json"]};function Oa(n,r){var a;return!!((a=za[n])!=null&&a[r])}const Uw={"agent-cli":"openscience"},Bw={"first-session":"sessions","sub-agents":"agents","web-ui":"workspace",credentials:"atlas"},Hw={"cli:index":{section:"openscience",path:"index"},"cli:installation":{section:"openscience",path:"quickstart"},"cli:quickstart":{section:"openscience",path:"quickstart"},"cli:first-session":{section:"openscience",path:"sessions"},"cli:sessions":{section:"openscience",path:"sessions"},"cli:models":{section:"openscience",path:"models"},"cli:codex":{section:"openscience",path:"models"},"cli:sub-agents":{section:"openscience",path:"agents"},"cli:skills":{section:"openscience",path:"skills"},"cli:cli-runtime":{section:"openscience",path:"commands"},"cli:connect":{section:"openscience",path:"atlas"},"cli:credentials":{section:"openscience",path:"atlas"},"cli:security":{section:"openscience",path:"security"},"cli:feature-map":{section:"openscience",path:"commands"},"cli:commands":{section:"openscience",path:"commands"},"cli:web-ui":{section:"openscience",path:"workspace"},"cli:server-mode":{section:"openscience",path:"workspace"}};function ic(){return{section:"openscience",path:"index"}}function Gm(){if(typeof window>"u")return ic();const n=decodeURIComponent(window.location.hash.replace(/^#\/?/,"")).replace(/\/$/,"");if(!n)return ic();const r=n.split("/"),a=r[0];if(Uc.includes(a)){const d=r.slice(1).join("/")||"index";return Oa(a,d)?{section:a,path:d}:{section:a,path:"index"}}const o=Uw[r[0]];if(o){const d=r.slice(1).join("/")||"index",f=Bw[d]??d;return Oa(o,f)?{section:o,path:f}:{section:o,path:"index"}}const c=Hw[`cli:${n}`];return c&&Oa(c.section,c.path)?c:ic()}function rn(n,r){return`#/${n}/${r}`}let _a="openscience";function Bc(n){if(!n||n.startsWith("http")||n.startsWith("#")||n.startsWith("mailto:"))return n;if(n.startsWith("/")){const r=n.slice(1).replace(/\/$/,"");if(!r)return rn(_a,"index");const a=r.split("/"),o=a[0];if(Uc.includes(o)){const c=a.slice(1).join("/")||"index";if(Oa(o,c))return rn(o,c)}if(Oa(_a,r))return rn(_a,r)}return n}function qw(n){const o=n.replace(/^#\/?/,"").replace(/\/$/,"").split("/")[0];return Uc.includes(o)?o:_a}function Hc(n){const r={};for(const a of n.matchAll(/([\w-]+)(?:=(?:"([^"]*)"|'([^']*)'|\{([^}]*)\}))?/g)){const o=a[1];o&&(r[o]=a[2]??a[3]??a[4]??!0)}return r}function qc(n){const r=n.replace(/\t/g," ").split(` +`);let a=1/0;for(const o of r){if(o.trim()==="")continue;const c=o.match(/^( *)/);c&&(a=Math.min(a,c[1].length))}return!Number.isFinite(a)||a===0?n:r.map(o=>o.length>=a?o.slice(a):o).join(` +`)}function Ig(n){return Array.from(n.matchAll(/]*)>\s*([\s\S]*?)\s*<\/Card>/g)).map(r=>{const a=Hc(r[1]??"");return{title:String(a.title??"Untitled"),href:String(a.href??"#"),icon:a.icon?String(a.icon):void 0,horizontal:!!a.horizontal,body:qc(r[2]??"").trim()}})}function Yw(n){var a;const r=Bc(n.href);if(r&&r.startsWith("#/")){const o=qw(r),c=r.replace(/^#\/?/,"").replace(/\/$/,"").split("/").slice(1).join("/"),d=(a=za[o])==null?void 0:a[c];if(d)return d.icon}return H.jsx(Fg,{size:17,strokeWidth:1.8})}const ao="synthetic-sciences/openscience";function Gw(n){return n>=1e3?`${(n/1e3).toFixed(1).replace(/\.0$/,"")}k`:String(n)}function Vw(){const[n,r]=We.useState(null);return We.useEffect(()=>{let a=!1;const o=`docs-gh-stars:${ao}`;try{const c=JSON.parse(window.localStorage.getItem(o)??"null");if(c&&Date.now()-c.at<3600*1e3){r(c.stars);return}}catch{}return fetch(`https://api.github.com/repos/${ao}`).then(c=>c.ok?c.json():null).then(c=>{const d=c==null?void 0:c.stargazers_count;if(!(typeof d!="number"||a)){r(d);try{window.localStorage.setItem(o,JSON.stringify({stars:d,at:Date.now()}))}catch{}}}).catch(()=>{}),()=>{a=!0}},[]),H.jsxs("div",{className:"docs-ghstars",children:[H.jsxs("a",{className:"docs-ghstars-primary",href:`https://github.com/${ao}`,target:"_blank",rel:"noreferrer",children:[H.jsx(Cw,{size:13,strokeWidth:1.8}),H.jsx("span",{children:"Star on GitHub"}),n!==null?H.jsx("em",{children:Gw(n)}):null]}),H.jsx("a",{href:`https://github.com/${ao}/blob/main/LICENSE`,target:"_blank",rel:"noreferrer",children:"Apache-2.0"}),H.jsx("a",{href:"https://www.npmjs.com/package/@synsci/openscience",target:"_blank",rel:"noreferrer",children:"npm · @synsci/openscience"})]})}function Xw({text:n}){const[r,a]=We.useState(!1);return H.jsxs("button",{type:"button",className:"docs-copy",onClick:()=>{navigator.clipboard.writeText(n),a(!0),window.setTimeout(()=>a(!1),1200)},"aria-label":"copy code",title:"copy code",children:[r?H.jsx(pw,{size:13,strokeWidth:1.8}):H.jsx(gw,{size:13,strokeWidth:1.8}),H.jsx("span",{children:r?"copied":"copy"})]})}const Qw={h2({children:n}){const r=String(n).toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");return H.jsx("h2",{id:r,children:n})},a({href:n,children:r}){const a=n==null?void 0:n.startsWith("http"),o=n?Bc(n):void 0;return H.jsxs("a",{href:o,target:a?"_blank":void 0,rel:a?"noreferrer":void 0,children:[r,a?H.jsx(ho,{size:12,strokeWidth:1.8}):null]})},pre({children:n}){const r=gc(n);return H.jsxs("div",{className:"docs-code-wrap",children:[H.jsx(Xw,{text:r}),H.jsx("pre",{children:n})]})},code({className:n,children:r}){const a=n==null?void 0:n.startsWith("language-");return H.jsx("code",{className:a?n:"docs-inline-code",children:r})},table({children:n}){return H.jsx("div",{className:"docs-table-wrap",children:H.jsx("table",{children:n})})},blockquote({children:n}){return H.jsx("blockquote",{className:"docs-callout",children:n})}};function gc(n){if(typeof n=="string")return n;if(Array.isArray(n))return n.map(gc).join("");if(n&&typeof n=="object"&&"props"in n){const r=n.props;return gc((r==null?void 0:r.children)??"")}return""}function po({children:n}){return n.trim()?H.jsx(Jk,{remarkPlugins:[sw],components:Qw,children:n}):null}function Jg({card:n}){const r=n.href.startsWith("http");return H.jsxs("a",{className:n.horizontal?"docs-card docs-card-horizontal":"docs-card",href:Bc(n.href),target:r?"_blank":void 0,rel:r?"noreferrer":void 0,children:[H.jsx("span",{className:"docs-card-icon",children:Yw(n)}),H.jsxs("span",{className:"docs-card-copy",children:[H.jsx("strong",{children:n.title}),H.jsx("small",{children:n.body})]}),H.jsx(ho,{size:14,strokeWidth:1.8})]})}function Zw({source:n,cols:r}){const a=Ig(n);return a.length===0?null:H.jsx("div",{className:"docs-card-grid",style:{"--docs-card-cols":String(r)},children:a.map(o=>H.jsx(Jg,{card:o},`${o.title}-${o.href}`))})}function Kw({source:n}){const r=Array.from(n.matchAll(/]*)>\s*([\s\S]*?)\s*<\/Step>/g)).map(a=>{const o=Hc(a[1]??"");return{title:String(o.title??"Step"),body:qc(a[2]??"").trim()}});return r.length===0?null:H.jsx("div",{className:"docs-step-list",children:r.map((a,o)=>H.jsxs("section",{className:"docs-step",children:[H.jsx("span",{children:o+1}),H.jsxs("div",{children:[H.jsx("h3",{children:a.title}),H.jsx(po,{children:a.body})]})]},`${a.title}-${o}`))})}function Fw({children:n}){return H.jsx("blockquote",{className:"docs-callout docs-callout-warning",children:H.jsx(po,{children:qc(n).trim()})})}function Iw(n){const r=[],a=/<(Columns|CardGroup)\b([^>]*)>\s*([\s\S]*?)\s*<\/\1>|]*)>\s*([\s\S]*?)\s*<\/Card>|\s*([\s\S]*?)\s*<\/Steps>|\s*([\s\S]*?)\s*<\/Warning>|/g;let o=0,c=0;for(const f of n.matchAll(a)){const p=f.index??0,m=n.slice(o,p);if(m.trim()&&r.push(H.jsx(po,{children:m},`md-${c++}`)),f[1]){const h=Hc(f[2]??""),b=Number(h.cols??2);r.push(H.jsx(Zw,{source:f[3]??"",cols:Number.isFinite(b)&&b>0?b:2},`cards-${c++}`))}else if(f[4]!==void 0){const h=Ig(`${f[5]??""}`)[0];h&&r.push(H.jsx(Jg,{card:h},`card-${c++}`))}else f[6]!==void 0?r.push(H.jsx(Kw,{source:f[6]??""},`steps-${c++}`)):f[7]!==void 0?r.push(H.jsx(Fw,{children:f[7]??""},`warning-${c++}`)):f[0].startsWith("Gm()),c=a.section;_a=c;const d=za[c],f=Ym[c],p=oo.find(B=>B.key===c)??oo[0],m=d[a.path]??d.index,[h,b]=We.useState(""),[y,k]=We.useState(!1),x=f.navigation.tabs,T=We.useMemo(()=>x.flatMap(B=>B.groups.flatMap(le=>ki(le.pages))).filter(B=>d[B]),[x,d]),U=We.useMemo(()=>x.find(B=>B.groups.some(le=>ki(le.pages).includes(m.path)))??x[0],[m.path,x]),Z=We.useMemo(()=>U==null?void 0:U.groups.find(B=>ki(B.pages).includes(m.path)),[m.path,U]),D=T.indexOf(m.path),F=D>0?d[T[D-1]]:null,Q=D>=0&&D{const B=oo.flatMap(J=>Ym[J.key].navigation.tabs.flatMap(O=>O.groups.flatMap(K=>ki(K.pages))).map(O=>za[J.key][O]).filter(Boolean).map(O=>({path:O.path,title:O.title,description:O.description,icon:O.icon,section:J.key,sectionLabel:J.label}))),le=h.trim().toLowerCase();return le?B.filter(J=>{var K;const $=((K=za[J.section][J.path])==null?void 0:K.body)??"";return`${J.title} ${J.description} ${J.sectionLabel} ${$}`.toLowerCase().includes(le)}).slice(0,8):B.filter(J=>J.section===c).slice(0,6)},[h,c]),re=B=>{window.location.hash=rn(B.section,B.path),o(B)};return We.useEffect(()=>{const B=()=>o(Gm());return window.addEventListener("hashchange",B),()=>window.removeEventListener("hashchange",B)},[]),We.useEffect(()=>{const B=rn(a.section,a.path);window.location.hash!==B&&window.history.replaceState(null,"",B)},[a.section,a.path]),We.useEffect(()=>{const B=le=>{var J;(le.metaKey||le.ctrlKey)&&le.key.toLowerCase()==="k"&&(le.preventDefault(),k(!0),(J=document.querySelector(".docs-search-input"))==null||J.focus())};return window.addEventListener("keydown",B),()=>window.removeEventListener("keydown",B)},[]),H.jsxs("div",{className:"docs-page",children:[H.jsxs("header",{className:"docs-topbar",children:[H.jsxs("a",{href:"https://openscience.sh",className:"docs-brand",children:[H.jsx("img",{src:"/docs/favicon.svg",alt:""}),H.jsxs("span",{className:"docs-brand-text",children:[H.jsx("small",{children:"OpenScience"}),H.jsx("strong",{children:"Docs"})]})]}),H.jsxs("div",{className:"docs-search",role:"search",children:[H.jsx(Sw,{size:14,strokeWidth:1.8}),H.jsx("input",{className:"docs-search-input","aria-label":"Search documentation",value:h,onBlur:()=>window.setTimeout(()=>k(!1),120),onChange:B=>{b(B.target.value),k(!0)},onFocus:()=>k(!0),placeholder:"Search all docs...",type:"search"}),H.jsx("kbd",{children:"⌘K"}),y?H.jsx("div",{className:"docs-search-results",role:"listbox","aria-label":"documentation search results",children:oe.length>0?oe.map(B=>H.jsxs("a",{href:rn(B.section,B.path),role:"option","aria-selected":c===B.section&&a.path===B.path,onMouseDown:le=>{le.preventDefault(),re({section:B.section,path:B.path}),b(""),k(!1)},children:[H.jsx("span",{children:B.icon}),H.jsx("strong",{children:B.title}),H.jsx("small",{children:B.sectionLabel})]},`${B.section}/${B.path}`)):H.jsx("span",{className:"docs-search-empty",children:"No docs match that query."})}):null]}),H.jsxs("nav",{className:"docs-actions","aria-label":"documentation actions",children:[H.jsxs("button",{type:"button",className:"docs-theme-toggle",onClick:r,"aria-label":n==="dark"?"switch to light mode":"switch to dark mode",title:n==="dark"?"light mode":"dark mode",children:[n==="dark"?H.jsx(Ew,{size:14,strokeWidth:1.8}):H.jsx(vw,{size:14,strokeWidth:1.8}),H.jsx("span",{children:n==="dark"?"light":"dark"})]}),H.jsxs("a",{className:"docs-topbar-cta",href:((P=(L=f.navbar)==null?void 0:L.primary)==null?void 0:P.href)??"https://github.com/synthetic-sciences/openscience",children:[(((me=(he=f.navbar)==null?void 0:he.primary)==null?void 0:me.label)??"Star on GitHub").toLowerCase(),H.jsx(ho,{size:13,strokeWidth:1.8})]})]})]}),H.jsxs("div",{className:"docs-shell",children:[H.jsxs("aside",{className:"docs-sidebar","aria-label":"documentation navigation",children:[H.jsxs("div",{className:"docs-sidebar-title",children:[H.jsx("span",{children:p.label}),H.jsx("small",{children:p.tagline})]}),x.length>1?H.jsx("nav",{className:"docs-section-tabs","aria-label":"documentation sections",children:x.map(B=>{const le=B.groups.flatMap(J=>ki(J.pages)).find(J=>d[J]);return le?H.jsx("a",{className:(U==null?void 0:U.tab)===B.tab?"active":void 0,href:rn(c,le),onClick:()=>re({section:c,path:le}),children:B.tab},B.tab):null})}):null,U?H.jsx("div",{children:U.groups.map(B=>H.jsxs("div",{className:"docs-sidebar-group",children:[H.jsx("span",{children:B.group}),ki(B.pages).map(le=>{const J=d[le];return J?H.jsxs("a",{href:rn(c,le),className:a.path===le?"active":void 0,onClick:()=>re({section:c,path:le}),children:[H.jsx("span",{children:J.icon}),J.title]},le):null})]},B.group))},U.tab):null]}),H.jsxs("main",{className:"docs-main",children:[H.jsxs("nav",{className:"docs-breadcrumbs","aria-label":"breadcrumbs",children:[H.jsx("a",{href:rn(c,"index"),children:p.label}),H.jsx(Um,{size:13,strokeWidth:1.8}),Z?H.jsx("span",{children:Z.group}):null]}),H.jsxs("section",{className:"docs-hero",children:[H.jsx("h1",{children:m.title}),m.description?H.jsx("p",{children:m.description}):null]}),H.jsx("article",{className:"docs-markdown",children:Iw(m.body)}),H.jsxs("nav",{className:"docs-pagination","aria-label":"documentation pagination",children:[F?H.jsxs("a",{href:rn(c,F.path),onClick:()=>re({section:c,path:F.path}),children:[H.jsx(mw,{size:16,strokeWidth:1.8}),H.jsxs("span",{children:[H.jsx("small",{children:"Previous"}),F.title]})]}):H.jsx("span",{}),Q?H.jsxs("a",{href:rn(c,Q.path),onClick:()=>re({section:c,path:Q.path}),children:[H.jsxs("span",{children:[H.jsx("small",{children:"Next"}),Q.title]}),H.jsx(Um,{size:16,strokeWidth:1.8})]}):H.jsx("span",{})]})]}),H.jsxs("aside",{className:"docs-toc","aria-label":"on this page",children:[H.jsx("span",{children:"On this page"}),m.headings.length>0?m.headings.map(B=>H.jsx("a",{href:`#${B.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"")}`,children:B},B)):H.jsx("span",{className:"docs-toc-empty",children:"No sections"}),(((j=f.navigation.global)==null?void 0:j.anchors)??[]).length>0?H.jsxs("div",{className:"docs-agent-links",children:[H.jsx("span",{children:"Agent resources"}),(((te=f.navigation.global)==null?void 0:te.anchors)??[]).map(B=>H.jsxs("a",{href:B.href,target:B.href.startsWith("http")?"_blank":void 0,rel:"noreferrer",children:[B.anchor,H.jsx(ho,{size:11,strokeWidth:1.8})]},B.href))]}):null]})]}),H.jsx("style",{children:$w})]})}const $w=` + .docs-page { + --color-bg: #fafbfc; + --color-bg-subtle: #f1f3f5; + --color-bg-elevated: #ffffff; + --color-border: rgba(15, 23, 42, 0.10); + --color-text: #0f172a; + --color-text-muted: #475569; + --color-text-faint: #94a3b8; + --docs-accent: #2f6f54; + min-height: 100dvh; + color: var(--color-text); + background: var(--color-bg); + font-family: ${Wt}; + font-feature-settings: "kern", "liga"; + } + + /* No italics anywhere - the font family ships regular and bold only. */ + .docs-page em, + .docs-page i, + .docs-page cite, + .docs-page dfn, + .docs-page address { + font-style: normal; + } + + .dark .docs-page { + --color-bg: #0a0a0b; + --color-bg-subtle: #141417; + --color-bg-elevated: #1c1c20; + --color-border: rgba(255, 255, 255, 0.10); + --color-text: #f1f5f9; + --color-text-muted: #b8bbc4; + --color-text-faint: #6c7280; + --docs-accent: #9bd6b4; + } + + .docs-topbar { + height: 60px; + display: grid; + grid-template-columns: minmax(200px, 1fr) minmax(240px, 520px) minmax(200px, 1fr); + align-items: center; + gap: 20px; + padding: 0 28px; + border-bottom: 1px solid var(--color-border); + background: color-mix(in srgb, var(--color-bg) 94%, transparent); + backdrop-filter: blur(14px); + position: sticky; + top: 0; + z-index: 30; + } + + .docs-topbar nav, + .docs-topbar nav a, + .docs-search, + .docs-copy, + .docs-markdown a { + display: flex; + align-items: center; + } + + .docs-brand { + display: inline-flex; + align-items: center; + gap: 12px; + color: var(--color-text); + text-decoration: none; + min-width: 0; + padding: 4px 6px; + border-radius: 6px; + transition: background 120ms ease; + } + + .docs-brand:hover { + background: var(--color-bg-elevated); + } + + .docs-brand img { + width: 28px; + height: 28px; + flex-shrink: 0; + } + + .docs-brand-text { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + line-height: 1.1; + } + + .docs-brand-text small { + font-family: ${an}; + font-size: 9.5px; + font-weight: 500; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-text-faint); + } + + .docs-brand-text strong { + font-family: ${Wt}; + font-size: 15px; + font-weight: 400; + letter-spacing: 0; + color: var(--color-text); + } + + .docs-search { + position: relative; + height: 34px; + gap: 9px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-bg-elevated); + padding: 0 8px 0 12px; + color: var(--color-text-faint); + transition: border-color 120ms ease, background 120ms ease; + } + + .docs-search:focus-within { + border-color: var(--color-text-faint); + background: var(--color-bg); + } + + .docs-search input { + min-width: 0; + flex: 1; + border: 0; + outline: 0; + background: transparent; + color: var(--color-text); + font-family: ${Wt}; + font-size: 14px; + } + + .docs-search input::placeholder { + color: var(--color-text-faint); + font-family: ${Wt}; + } + + .docs-search kbd { + min-width: 32px; + height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-bg); + color: var(--color-text-faint); + font-family: ${an}; + font-size: 10.5px; + font-weight: 500; + flex-shrink: 0; + } + + .docs-search-results { + position: absolute; + top: calc(100% + 8px); + left: 0; + right: 0; + display: flex; + flex-direction: column; + gap: 3px; + padding: 7px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg-elevated); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.12); + z-index: 50; + } + + .docs-search-results a { + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + gap: 1px 8px; + align-items: center; + padding: 8px; + border-radius: 7px; + color: var(--color-text); + text-decoration: none; + } + + .docs-search-results a:hover { + background: var(--color-bg-subtle); + } + + .docs-search-results a > span { + grid-row: span 2; + color: var(--color-text-faint); + } + + .docs-search-results strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-weight: 700; + } + + .docs-search-results small, + .docs-search-empty { + overflow: hidden; + color: var(--color-text-muted); + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + } + + .docs-search-empty { + padding: 10px; + } + + .docs-topbar nav { + gap: 8px; + justify-content: flex-end; + } + + .docs-actions { + justify-content: flex-end; + gap: 8px; + } + + .docs-topbar nav a { + gap: 6px; + height: 34px; + padding: 0 14px; + border-radius: 6px; + color: var(--color-text-muted); + text-decoration: none; + font-family: ${Wt}; + font-size: 14px; + font-weight: 400; + border: 1px solid transparent; + transition: background 120ms ease, color 120ms ease, border-color 120ms ease; + } + + .docs-topbar nav a:hover { + color: var(--color-text); + background: var(--color-bg-elevated); + border-color: var(--color-border); + } + + .docs-topbar-cta { + color: var(--color-text) !important; + border-color: var(--color-border) !important; + background: var(--color-bg-elevated); + } + + .docs-theme-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + height: 34px; + padding: 0 12px; + border-radius: 6px; + border: 1px solid transparent; + background: transparent; + color: var(--color-text-muted); + font-family: ${Wt}; + font-size: 14px; + font-weight: 400; + line-height: 1; + cursor: pointer; + transition: background 120ms ease, color 120ms ease, border-color 120ms ease; + } + + .docs-theme-toggle:hover { + color: var(--color-text); + background: var(--color-bg-elevated); + border-color: var(--color-border); + } + + .docs-theme-toggle:focus-visible { + outline: 2px solid var(--docs-accent); + outline-offset: 2px; + } + + .docs-shell { + display: grid; + grid-template-columns: 236px minmax(0, 760px) 176px; + gap: 34px; + max-width: 1240px; + margin: 0 auto; + padding: 30px 28px 84px; + } + + .docs-sidebar, + .docs-toc { + position: sticky; + top: 84px; + align-self: start; + max-height: calc(100dvh - 104px); + overflow: auto; + } + + .docs-sidebar { + padding-right: 4px; + } + + .docs-sidebar-title { + display: flex; + flex-direction: column; + gap: 2px; + margin: 0 0 14px 4px; + } + + .docs-sidebar-title span { + font-size: 13px; + font-weight: 700; + } + + .docs-sidebar-title small { + color: var(--color-text-faint); + font-family: ${an}; + font-size: 11px; + } + + .docs-section-tabs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px; + margin: 0 0 20px; + padding: 3px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg-subtle); + } + + .docs-section-tabs a { + display: flex; + min-height: 28px; + align-items: center; + justify-content: center; + border-radius: 6px; + color: var(--color-text-muted); + text-decoration: none; + font-size: 12px; + } + + .docs-section-tabs a.active { + color: var(--color-text); + background: var(--color-bg-elevated); + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03); + } + + .docs-sidebar-group { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 20px; + } + + .docs-sidebar-group > span, + .docs-toc > span, + .docs-toc-empty, + .docs-copy { + font-family: ${an}; + font-size: 11px; + letter-spacing: 0; + } + + .docs-sidebar-group > span, + .docs-toc > span, + .docs-toc-empty { + color: var(--color-text-faint); + } + + .docs-sidebar-group > span { + margin: 0 0 7px 4px; + text-transform: uppercase; + } + + .docs-sidebar a, + .docs-toc a { + color: var(--color-text-muted); + text-decoration: none; + font-size: 13px; + line-height: 1.45; + } + + .docs-sidebar a { + display: flex; + align-items: center; + gap: 9px; + min-height: 30px; + padding: 0 6px; + border-radius: 6px; + } + + .docs-sidebar a span { + color: var(--color-text-faint); + line-height: 0; + } + + .docs-sidebar a:hover { + color: var(--color-text); + background: var(--color-bg-subtle); + } + + .docs-sidebar a.active { + color: var(--color-text); + background: color-mix(in srgb, var(--color-bg-subtle) 82%, var(--docs-accent) 8%); + font-weight: 700; + } + + .docs-sidebar a.active span { + color: var(--color-text); + } + + .docs-main { + min-width: 0; + } + + .docs-breadcrumbs { + display: flex; + align-items: center; + gap: 7px; + margin: 2px 0 16px; + color: var(--color-text-faint); + font-size: 13px; + } + + .docs-breadcrumbs a { + color: inherit; + text-decoration: none; + } + + .docs-breadcrumbs a:hover { + color: var(--color-text); + } + + .docs-hero { + padding: 0 0 24px; + border-bottom: 1px solid var(--color-border); + margin-bottom: 30px; + } + + .docs-hero h1 { + margin: 0; + font-family: ${Wt}; + font-size: 40px; + line-height: 1.08; + font-weight: 700; + letter-spacing: -0.005em; + color: var(--color-text); + } + + .docs-hero p { + max-width: 680px; + margin: 12px 0 0; + font-family: ${Wt}; + color: var(--color-text-muted); + font-size: 17px; + line-height: 1.55; + } + + .docs-markdown { + color: var(--color-text); + font-family: ${Wt}; + } + + .docs-markdown > *:first-child { + margin-top: 0; + } + + .docs-markdown p, + .docs-markdown li, + .docs-markdown td { + color: var(--color-text-muted); + font-family: ${Wt}; + font-size: 16px; + line-height: 1.7; + font-feature-settings: "kern", "liga", "onum"; + } + + .docs-markdown p { + margin: 0 0 16px; + } + + .docs-markdown h2 { + margin: 36px 0 12px; + padding-top: 6px; + font-family: ${Wt}; + font-size: 24px; + line-height: 1.2; + font-weight: 700; + letter-spacing: 0; + color: var(--color-text); + scroll-margin-top: 84px; + } + + .docs-markdown h3 { + margin: 26px 0 10px; + font-family: ${Wt}; + font-size: 18px; + line-height: 1.28; + font-weight: 700; + letter-spacing: 0; + color: var(--color-text); + } + + .docs-markdown strong { + font-weight: 700; + color: var(--color-text); + } + + .docs-markdown em { + font-style: normal; + color: var(--color-text); + font-weight: 700; + } + + .docs-markdown ul, + .docs-markdown ol { + margin: 0 0 18px; + padding-left: 20px; + } + + .docs-markdown a { + display: inline-flex; + gap: 5px; + color: var(--color-text); + text-decoration: underline; + text-decoration-color: var(--color-text-faint); + text-underline-offset: 3px; + } + + .docs-ghstars { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 2px 0 26px; + } + + .docs-ghstars a { + display: inline-flex; + align-items: center; + gap: 7px; + height: 30px; + padding: 0 13px; + border: 1px solid var(--color-border); + border-radius: 999px; + background: var(--color-bg-elevated); + color: var(--color-text-muted); + text-decoration: none; + font-family: ${an}; + font-size: 12px; + transition: border-color 120ms ease, color 120ms ease, background 120ms ease; + } + + .docs-ghstars a:hover { + color: var(--color-text); + border-color: color-mix(in srgb, var(--docs-accent) 44%, var(--color-border)); + } + + .docs-ghstars-primary { + color: var(--color-text) !important; + font-weight: 500; + } + + .docs-ghstars-primary em { + font-style: normal; + font-weight: 700; + padding-left: 8px; + border-left: 1px solid var(--color-border); + color: var(--docs-accent); + } + + .docs-card-grid { + display: grid; + grid-template-columns: repeat(var(--docs-card-cols, 2), minmax(0, 1fr)); + gap: 10px; + margin: 18px 0 26px; + } + + .docs-card { + position: relative; + display: grid !important; + grid-template-columns: minmax(0, 1fr) 14px; + gap: 10px; + align-items: start !important; + min-height: 96px; + padding: 15px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg-elevated); + color: var(--color-text) !important; + text-decoration: none !important; + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02); + } + + .docs-card:hover { + border-color: color-mix(in srgb, var(--docs-accent) 34%, var(--color-border)); + background: var(--color-bg-subtle); + } + + .docs-card-horizontal { + min-height: 78px; + } + + .docs-card-icon { + display: none; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid var(--color-border); + border-radius: 8px; + color: var(--color-text); + background: var(--color-bg); + } + + .docs-card-copy { + display: flex; + flex-direction: column; + gap: 7px; + min-width: 0; + } + + .docs-card-copy strong { + font-size: 13.5px; + line-height: 1.25; + font-weight: 700; + } + + .docs-card-copy small { + color: var(--color-text-muted); + font-size: 12.75px; + line-height: 1.55; + } + + .docs-code-wrap { + position: relative; + margin: 16px 0 22px; + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: 8px; + background: #11150f; + } + + .docs-code-wrap pre { + margin: 0; + padding: 18px 16px; + overflow: auto; + font-family: ${an}; + font-size: 12px; + line-height: 1.72; + color: #eef4ee; + } + + .docs-copy { + position: absolute; + top: 8px; + right: 8px; + gap: 6px; + min-height: 25px; + padding: 0 8px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + background: rgba(255, 255, 255, 0.06); + color: rgba(238, 244, 238, 0.78); + cursor: pointer; + } + + .docs-inline-code { + font-family: ${an}; + font-size: 12px; + border: 1px solid var(--color-border); + background: var(--color-bg-subtle); + border-radius: 5px; + color: var(--color-text); + padding: 1px 5px; + } + + .docs-table-wrap { + overflow: auto; + border: 1px solid var(--color-border); + border-radius: 8px; + margin: 16px 0 22px; + } + + .docs-table-wrap table { + width: 100%; + border-collapse: collapse; + min-width: 560px; + } + + .docs-table-wrap th, + .docs-table-wrap td { + padding: 10px 13px; + border-bottom: 1px solid var(--color-border); + text-align: left; + vertical-align: top; + } + + .docs-table-wrap th { + font-family: ${an}; + font-size: 11px; + color: var(--color-text-faint); + background: var(--color-bg-subtle); + } + + .docs-callout { + margin: 18px 0; + padding: 14px 16px; + border: 1px solid rgba(164, 120, 48, 0.28); + border-left: 3px solid rgba(164, 120, 48, 0.64); + border-radius: 7px; + background: color-mix(in srgb, var(--color-bg-subtle) 74%, rgba(164, 120, 48, 0.12)); + } + + .docs-callout p { + margin: 0; + color: var(--color-text); + } + + .docs-callout .docs-markdown p { + margin: 0; + } + + .docs-step-list { + display: flex; + flex-direction: column; + gap: 10px; + margin: 18px 0 28px; + } + + .docs-step { + display: grid; + grid-template-columns: 30px minmax(0, 1fr); + gap: 13px; + padding: 15px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg-elevated); + } + + .docs-step > span { + width: 27px; + height: 27px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--color-border); + border-radius: 999px; + background: var(--color-bg-subtle); + color: var(--color-text); + font-family: ${an}; + font-size: 12px; + font-weight: 700; + } + + .docs-step h3 { + margin: 2px 0 8px; + } + + .docs-toc { + display: flex; + flex-direction: column; + gap: 7px; + padding-left: 4px; + } + + .docs-toc > span { + margin-bottom: 4px; + } + + .docs-toc a { + line-height: 1.45; + } + + .docs-agent-links { + display: flex; + flex-direction: column; + gap: 7px; + margin-top: 20px; + padding-top: 14px; + border-top: 1px solid var(--color-border); + } + + .docs-agent-links > span { + font-family: ${an}; + font-size: 11px; + color: var(--color-text-faint); + } + + .docs-agent-links a { + display: inline-flex; + gap: 5px; + align-items: center; + } + + .docs-pagination { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + margin-top: 48px; + padding-top: 24px; + border-top: 1px solid var(--color-border); + } + + .docs-pagination a { + display: flex; + align-items: center; + gap: 10px; + min-height: 68px; + padding: 13px 14px; + border: 1px solid var(--color-border); + border-radius: 8px; + color: var(--color-text); + text-decoration: none; + background: var(--color-bg); + } + + .docs-pagination a:hover { + background: var(--color-bg-subtle); + } + + .docs-pagination a:last-child { + justify-content: flex-end; + text-align: right; + } + + .docs-pagination small { + display: block; + margin-bottom: 4px; + color: var(--color-text-faint); + font-family: ${an}; + font-size: 11px; + } + + @media (max-width: 1180px) { + .docs-shell { + grid-template-columns: 224px minmax(0, 1fr); + gap: 30px; + } + .docs-toc { + display: none; + } + } + + @media (max-width: 860px) { + .docs-topbar { + grid-template-columns: minmax(0, 1fr) auto; + padding: 0 16px; + } + + .docs-search { + grid-column: 1 / -1; + order: 2; + display: none; + } + + .docs-topbar nav a:not(.docs-topbar-cta) { + display: none; + } + + .docs-shell { + display: block; + padding: 22px 16px 64px; + } + + .docs-sidebar { + position: static; + border: 1px solid var(--color-border); + border-radius: 10px; + padding: 14px 12px; + margin-bottom: 22px; + max-height: none; + } + + .docs-hero h1 { + font-size: 34px; + } + + .docs-pagination { + grid-template-columns: 1fr; + } + + .docs-card-grid { + grid-template-columns: 1fr; + } + } +`;function Ww(){return H.jsx(Jw,{})}kb.createRoot(document.getElementById("root")).render(H.jsx(pb.StrictMode,{children:H.jsx(Ww,{})})); diff --git a/frontend/landing/public/docs/index.html b/frontend/landing/public/docs/index.html index 377d134b..42d75ea9 100644 --- a/frontend/landing/public/docs/index.html +++ b/frontend/landing/public/docs/index.html @@ -14,7 +14,7 @@ - + diff --git a/frontend/workspace/src/atlas/FilesPane.test.ts b/frontend/workspace/src/atlas/FilesPane.test.ts index 3ea37a23..5cef39c6 100644 --- a/frontend/workspace/src/atlas/FilesPane.test.ts +++ b/frontend/workspace/src/atlas/FilesPane.test.ts @@ -443,6 +443,43 @@ describe("files pane", () => { expect(host.querySelector('[role="tablist"]')).toBeNull() }) + test("navigates large Volume downloads directly instead of buffering them through transport", async () => { + let downloaded: { href: string; name: string } | undefined + const capture = (event: Event) => { + const anchor = event.target instanceof Element ? event.target.closest("a[download]") : null + if (!anchor) return + event.preventDefault() + downloaded = { href: anchor.href, name: anchor.download } + } + document.addEventListener("click", capture, true) + cleanups.push(() => document.removeEventListener("click", capture, true)) + const { calls, request } = modal({ + files: [{ path: "model.safetensors", type: "file", size: 4 * 1024 * 1024 * 1024 }], + }) + const host = mount(() => + subject.FilesPane({ + request, + url: (route, query) => { + const target = new URL(route, "http://openscience.local") + for (const [key, value] of Object.entries(query)) target.searchParams.set(key, value) + return target.toString() + }, + }), + ) + await settle() + await enterModal(host) + host.querySelector('[data-file-row="weights"]')?.click() + await settle() + + host.querySelector('[data-file-row="model.safetensors"]')?.click() + await settle() + + expect(downloaded?.name).toBe("model.safetensors") + expect(new URL(downloaded!.href).pathname).toBe("/settings/compute/modal/volumes/weights/file") + expect(new URL(downloaded!.href).searchParams.get("path")).toBe("/model.safetensors") + expect(calls).not.toContain("/settings/compute/modal/volumes/weights/file?path=/model.safetensors") + }) + test("renders the browser directly before any file is opened", async () => { startOn("project") const host = mount(() => diff --git a/frontend/workspace/src/atlas/FilesPane.tsx b/frontend/workspace/src/atlas/FilesPane.tsx index 35d8e332..021b5f4f 100644 --- a/frontend/workspace/src/atlas/FilesPane.tsx +++ b/frontend/workspace/src/atlas/FilesPane.tsx @@ -181,14 +181,14 @@ export function FilesPane( /** Test/integration seam. Production delegates to uiStore.openFile. */ onOpenFile?: (file: PaneFile) => void /** - * Builds an absolute URL for an artifact's bytes. `sdk.request.url` supplies - * it in production; a standalone mount has no SDK, and `transport` returns a - * Response rather than a URL, so the thumbnail's and the Download link - * need this seam. + * Builds an absolute URL for browser-native downloads and artifact bytes. + * `sdk.request.url` supplies it in production; a standalone mount has no SDK, + * and `transport` returns a Response rather than a URL, so direct browser + * surfaces need this seam. */ url?: (path: string, query: Record) => string onOpenArtifact?: (artifact: StoredArtifact) => void - /** Receives a downloaded Modal Volume file instead of clicking an anchor. */ + /** Bounded test/integration seam. Production downloads through a direct browser navigation. */ onDownload?: (name: string, blob: Blob) => void onRenameArtifact?: (artifact: StoredArtifact, submit: (title: string) => Promise) => void } = {}, @@ -601,26 +601,35 @@ export function FilesPane( const downloadRemote = async (row: FileRow) => { const volume = path()[0] if (!volume) return - setBusy(true) // Leading slash on purpose. The route resolves the containing directory with // path.posix.dirname (routes/settings/compute.ts), and dirname("hello.txt") // is ".", which Modal answers with NOT_FOUND -- so a file at a Volume's root // could not be downloaded at all. "/hello.txt" gives dirname "/", the root. const target = `/${(row.path ?? row.name).replace(/^\/+/, "")}` - return transport(`/settings/compute/modal/volumes/${encodeURIComponent(volume)}/file`, undefined, { path: target }) - .then(async (response) => { - if (!response.ok) throw new Error((await response.text()) || `Download failed (${response.status})`) - const blob = await response.blob() - if (props.onDownload) return props.onDownload(row.name, blob) - const url = URL.createObjectURL(blob) + const route = `/settings/compute/modal/volumes/${encodeURIComponent(volume)}/file` + if (!props.onDownload) { + const build = props.url ?? sdk?.request.url + try { + if (!build) throw new Error("A direct download URL is unavailable.") const anchor = document.createElement("a") - anchor.href = url + anchor.href = build(route, { path: target }) anchor.download = row.name anchor.hidden = true document.body.append(anchor) anchor.click() anchor.remove() - setTimeout(() => URL.revokeObjectURL(url), 0) + setError("") + } catch (value) { + setError(`${row.name} could not be downloaded. ${concise(value)}`) + } + return + } + setBusy(true) + return transport(route, undefined, { path: target }) + .then(async (response) => { + if (!response.ok) throw new Error((await response.text()) || `Download failed (${response.status})`) + const blob = await response.blob() + props.onDownload?.(row.name, blob) }) .catch((value) => setError(`${row.name} could not be downloaded. ${concise(value)}`)) .finally(() => setBusy(false)) diff --git a/frontend/workspace/src/atlas/execution-authority.test.ts b/frontend/workspace/src/atlas/execution-authority.test.ts index cb8d73e3..c3ebfb88 100644 --- a/frontend/workspace/src/atlas/execution-authority.test.ts +++ b/frontend/workspace/src/atlas/execution-authority.test.ts @@ -26,6 +26,7 @@ const decision = (value: Partial = {}): ExecutionDecision => network: "deny", allowWrite: [], onUnavailable: "error", + requireProjectTrust: false, backend: "seatbelt", available: true, enforced: true, @@ -91,6 +92,15 @@ describe("frontend execution authority", () => { expect(executionAuthorityError(new Error("503 Service Unavailable"))).toBe( "Execution access could not be verified. 503 Service Unavailable", ) + expect( + executionAuthorityMessage( + decision({ + allowed: false, + reason: "project_untrusted", + message: "Trust is required by the stricter global policy.", + }), + ), + ).toBe("Trust is required by the stricter global policy.") }) test("submits only the canonical trust remediation returned by the server", async () => { @@ -139,5 +149,7 @@ describe("frontend execution authority", () => { expect(hook).toContain("value.capability === expected.capability") expect(hook).toContain("await api.trust(value)") expect(hook).toContain("await controls.refetch()") + expect(hook).toContain('sdk.event.on("server.instance.disposed", refresh)') + expect(hook).toContain("onCleanup(instance)") }) }) diff --git a/frontend/workspace/src/atlas/execution-authority.ts b/frontend/workspace/src/atlas/execution-authority.ts index a173e96a..d63662a2 100644 --- a/frontend/workspace/src/atlas/execution-authority.ts +++ b/frontend/workspace/src/atlas/execution-authority.ts @@ -16,6 +16,7 @@ export type ExecutionCapability = export interface ExecutionDecision { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: ExecutionCapability mode: "read_only" | "sandboxed" | "host" projectID: string @@ -30,6 +31,7 @@ export interface ExecutionDecision { network: "allow" | "deny" allowWrite: string[] onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -114,6 +116,7 @@ export function createExecutionAuthorityAPI(request: ProjectRequest) { export function executionAuthorityMessage(decision: ExecutionDecision): string | undefined { if (decision.allowed) return + if (decision.message) return decision.message const action = labels[decision.capability] if (decision.reason === "project_untrusted") return `Trust this project to ${action} in this session.` return `A verified OS sandbox is required to ${action}. OpenScience could not enforce one on this computer.` diff --git a/frontend/workspace/src/atlas/use-execution-authority.ts b/frontend/workspace/src/atlas/use-execution-authority.ts index eb842391..3466ac5c 100644 --- a/frontend/workspace/src/atlas/use-execution-authority.ts +++ b/frontend/workspace/src/atlas/use-execution-authority.ts @@ -33,8 +33,13 @@ export function useExecutionAuthority(capability: ExecutionCapability | Accessor if (event.properties.sessionID !== params.id) return refresh() }) + // Global/managed sandbox policy writes dispose the project instance so the + // next request observes the new immutable policy. Refresh immediately rather + // than leaving controls on the previous decision until a page reload. + const instance = sdk.event.on("server.instance.disposed", refresh) onCleanup(trust) onCleanup(grant) + onCleanup(instance) const message = createMemo(() => { if (!params.id || params.id === "new") return "Save this session before starting a process." diff --git a/frontend/workspace/src/components/settings-permissions.test.ts b/frontend/workspace/src/components/settings-permissions.test.ts index ed6e8e8d..b01313e7 100644 --- a/frontend/workspace/src/components/settings-permissions.test.ts +++ b/frontend/workspace/src/components/settings-permissions.test.ts @@ -44,13 +44,19 @@ describe("permission defaults shown in Settings", () => { expect(busy).toBe(false) }) - test("keeps project trust explicit and revocable from the reachable Permissions panel", () => { + test("keeps project-code trust explicit without blocking routine sandboxed work", () => { expect(panel).toContain("sdk.client.project.trust.get(input)") expect(panel).toContain("sdk.client.project.trust.update({") expect(panel).toContain('title: trusted ? "Trust this project?" : "Revoke project trust?"') expect(panel).toContain("body: trusted ? { trusted: true, root: status.root } : { trusted: false }") expect(panel).toContain('trust()?.canExecuteProjectCode ? "Revoke trust" : "Trust project"') - expect(panel).toContain("If sandboxing is off or unavailable") + expect(panel).toContain("Sandboxed terminals, kernels, and local jobs do not require project trust") + expect(panel).toContain( + "Remote jobs, kernel environment changes such as package installs, project-owned extensions, and unsandboxed execution will be blocked", + ) + expect(panel).toContain('title="Project code"') + expect(panel).toContain('"Project extensions blocked"') + expect(panel).toContain('"Restricted"') expect(panel).not.toContain("verified OpenScience sandbox") expect(panel).not.toContain("verified OS sandbox") }) diff --git a/frontend/workspace/src/components/settings/Permissions.tsx b/frontend/workspace/src/components/settings/Permissions.tsx index 32a4e0a7..4b7a5ab7 100644 --- a/frontend/workspace/src/components/settings/Permissions.tsx +++ b/frontend/workspace/src/components/settings/Permissions.tsx @@ -83,8 +83,8 @@ const Permissions: Component = () => { const confirmed = await confirmDialog(dialog, { title: trusted ? "Trust this project?" : "Revoke project trust?", message: trusted - ? `Allow project code under ${status.root} to run using the current execution policy. If sandboxing is off or unavailable and fallback permits it, code may run with your user authority. Review Sandbox settings first.` - : "New terminals, kernels, package installs, and compute jobs will stay blocked until you trust this project again. Existing processes are stopped when trust is revoked.", + ? `Allow project-owned code under ${status.root}, including plugins, MCP servers, formatters, language servers, provider commands, and startup hooks. Trust also permits remote jobs, kernel environment changes such as package installs, and host execution when the sandbox is off or explicitly configured to fall back without containment. Sandboxed terminals, kernels, and local jobs do not require project trust unless you enable that stricter policy in Sandbox settings.` + : "Remote jobs, kernel environment changes such as package installs, project-owned extensions, and unsandboxed execution will be blocked. Existing project processes are stopped. Sandboxed terminals, kernels, and local jobs remain available unless Sandbox settings require project trust for all execution.", confirmLabel: trusted ? "Trust project" : "Revoke trust", danger: !trusted, }) @@ -118,8 +118,8 @@ const Permissions: Component = () => {
{
- {trust()?.canExecuteProjectCode ? "Trusted project" : "Execution blocked"} + + {trust()?.canExecuteProjectCode ? "Project code enabled" : "Project extensions blocked"} + {trust()?.root}
- {trust()?.canExecuteProjectCode ? "Trusted" : "Blocked"} + {trust()?.canExecuteProjectCode ? "Trusted" : "Restricted"}
{
-
+
+
+
+ Require project trust + + {config().requireProjectTrust === true + ? "Every project must be trusted before it can start terminals, kernels, or local jobs." + : "Sandboxed terminals, kernels, and local jobs can run immediately. Remote jobs, kernel environment changes, project extensions, and unsandboxed execution still require trust."} + +
+ + patch({ requireProjectTrust: checked }, "trust", "Couldn't update the project trust policy") + } + > + Require project trust + +
+
Network access diff --git a/tooling/sdk/js/src/v2/gen/sdk.gen.ts b/tooling/sdk/js/src/v2/gen/sdk.gen.ts index 3f20a418..8c10784d 100644 --- a/tooling/sdk/js/src/v2/gen/sdk.gen.ts +++ b/tooling/sdk/js/src/v2/gen/sdk.gen.ts @@ -7453,6 +7453,7 @@ export class OpenScienceClient extends HeyApiClient { network?: "allow" | "deny" allowWrite?: Array onUnavailable?: "warn" | "error" | "allow" + requireProjectTrust?: boolean }, options?: Options, ) { @@ -7465,6 +7466,7 @@ export class OpenScienceClient extends HeyApiClient { { in: "body", key: "network" }, { in: "body", key: "allowWrite" }, { in: "body", key: "onUnavailable" }, + { in: "body", key: "requireProjectTrust" }, ], }, ], diff --git a/tooling/sdk/js/src/v2/gen/types.gen.ts b/tooling/sdk/js/src/v2/gen/types.gen.ts index 397ceb60..e6cc843c 100644 --- a/tooling/sdk/js/src/v2/gen/types.gen.ts +++ b/tooling/sdk/js/src/v2/gen/types.gen.ts @@ -36,20 +36,6 @@ export type BadRequestError = { success: false } -export type EventServerConnected = { - type: "server.connected" - properties: { - [key: string]: unknown - } -} - -export type EventGlobalDisposed = { - type: "global.disposed" - properties: { - [key: string]: unknown - } -} - export type EventInstallationUpdated = { type: "installation.updated" properties: { @@ -105,6 +91,20 @@ export type EventProjectTrustChanged = { } } +export type EventServerConnected = { + type: "server.connected" + properties: { + [key: string]: unknown + } +} + +export type EventGlobalDisposed = { + type: "global.disposed" + properties: { + [key: string]: unknown + } +} + export type EventLspClientDiagnostics = { type: "lsp.client.diagnostics" properties: { @@ -897,6 +897,7 @@ export type Pty = { authority: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -924,6 +925,7 @@ export type Pty = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -988,13 +990,13 @@ export type EventWorktreeFailed = { } export type Event = - | EventServerConnected - | EventGlobalDisposed | EventInstallationUpdated | EventInstallationUpdateAvailable | EventProjectUpdated | EventServerInstanceDisposed | EventProjectTrustChanged + | EventServerConnected + | EventGlobalDisposed | EventLspClientDiagnostics | EventLspUpdated | EventFileWatcherUpdated @@ -1757,6 +1759,10 @@ export type SandboxConfig = { * Behaviour when no sandbox backend exists on this platform: 'error' (default) refuses to run, 'warn' runs unsandboxed with a notice, and 'allow' runs unsandboxed silently. */ onUnavailable?: "warn" | "error" | "allow" + /** + * Require explicit project trust before any execution, even when a verified OS sandbox is available. Default: false. + */ + requireProjectTrust?: boolean } export type Config = { @@ -4425,6 +4431,7 @@ export type SettingsComputeJobsListResponses = { authority?: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -4452,6 +4459,7 @@ export type SettingsComputeJobsListResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -5172,6 +5180,7 @@ export type SettingsComputeJobsStartResponses = { authority?: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -5199,6 +5208,7 @@ export type SettingsComputeJobsStartResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -6118,6 +6128,7 @@ export type SettingsComputeJobsRetryResponses = { authority?: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -6145,6 +6156,7 @@ export type SettingsComputeJobsRetryResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -6836,6 +6848,7 @@ export type SettingsComputeJobsReleaseResponses = { authority?: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -6863,6 +6876,7 @@ export type SettingsComputeJobsReleaseResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -7550,6 +7564,7 @@ export type SettingsComputeJobsCancelResponses = { authority?: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -7577,6 +7592,7 @@ export type SettingsComputeJobsCancelResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -7846,6 +7862,7 @@ export type PutSettingsSandboxData = { network?: "allow" | "deny" allowWrite?: Array onUnavailable?: "warn" | "error" | "allow" + requireProjectTrust?: boolean } path?: never query?: never @@ -8218,6 +8235,7 @@ export type ProjectExecutionResponses = { 200: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -8245,6 +8263,7 @@ export type ProjectExecutionResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -12419,6 +12438,7 @@ export type KernelsListResponses = { authority: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -12446,6 +12466,7 @@ export type KernelsListResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -12546,6 +12567,7 @@ export type KernelsRestartByIdResponses = { authority: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -12573,6 +12595,7 @@ export type KernelsRestartByIdResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -12672,6 +12695,7 @@ export type KernelsStopByIdResponses = { authority: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -12699,6 +12723,7 @@ export type KernelsStopByIdResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -12798,6 +12823,7 @@ export type KernelsInterruptByIdResponses = { authority: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -12825,6 +12851,7 @@ export type KernelsInterruptByIdResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -12968,6 +12995,7 @@ export type KernelsStatusResponses = { authority: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -12995,6 +13023,7 @@ export type KernelsStatusResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -13094,6 +13123,7 @@ export type KernelsRestartResponses = { authority: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -13121,6 +13151,7 @@ export type KernelsRestartResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -13220,6 +13251,7 @@ export type KernelsStopResponses = { authority: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -13247,6 +13279,7 @@ export type KernelsStopResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -13346,6 +13379,7 @@ export type KernelsInterruptResponses = { authority: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -13373,6 +13407,7 @@ export type KernelsInterruptResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -13550,6 +13585,7 @@ export type NotebookKernelsResponses = { authority: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -13577,6 +13613,7 @@ export type NotebookKernelsResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -13677,6 +13714,7 @@ export type NotebookKernelRestartResponses = { authority: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -13704,6 +13742,7 @@ export type NotebookKernelRestartResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -13803,6 +13842,7 @@ export type NotebookKernelStopResponses = { authority: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -13830,6 +13870,7 @@ export type NotebookKernelStopResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -13929,6 +13970,7 @@ export type NotebookKernelInterruptResponses = { authority: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -13956,6 +13998,7 @@ export type NotebookKernelInterruptResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -14100,6 +14143,7 @@ export type NotebookStatusResponses = { authority: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -14127,6 +14171,7 @@ export type NotebookStatusResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -14227,6 +14272,7 @@ export type NotebookRestartResponses = { authority: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -14254,6 +14300,7 @@ export type NotebookRestartResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -14354,6 +14401,7 @@ export type NotebookStopResponses = { authority: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -14381,6 +14429,7 @@ export type NotebookStopResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean @@ -14481,6 +14530,7 @@ export type NotebookInterruptResponses = { authority: { allowed: boolean reason: "allowed" | "project_untrusted" | "sandbox_unavailable" + message?: string capability: | "terminal" | "kernel" @@ -14508,6 +14558,7 @@ export type NotebookInterruptResponses = { network: "allow" | "deny" allowWrite: Array onUnavailable: "warn" | "error" | "allow" + requireProjectTrust?: boolean backend: "seatbelt" | "bubblewrap" | "none" available: boolean enforced: boolean diff --git a/tooling/sdk/openapi.json b/tooling/sdk/openapi.json index 51646566..afc136fc 100644 --- a/tooling/sdk/openapi.json +++ b/tooling/sdk/openapi.json @@ -6362,6 +6362,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -6450,6 +6453,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -8759,6 +8766,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -8847,6 +8857,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -12089,6 +12103,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -12177,6 +12194,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -14515,6 +14536,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -14603,6 +14627,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -16941,6 +16969,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -17029,6 +17060,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -18005,6 +18040,9 @@ "error", "allow" ] + }, + "requireProjectTrust": { + "type": "boolean" } } } @@ -18920,6 +18958,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -19008,6 +19049,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -31410,6 +31455,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -31498,6 +31546,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -32008,6 +32060,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -32096,6 +32151,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -32618,6 +32677,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -32706,6 +32768,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -33228,6 +33294,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -33316,6 +33385,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -33982,6 +34055,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -34070,6 +34146,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -34565,6 +34645,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -34653,6 +34736,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -35181,6 +35268,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -35269,6 +35359,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -35798,6 +35892,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -35886,6 +35983,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -36625,6 +36726,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -36713,6 +36817,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -37223,6 +37331,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -37311,6 +37422,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -37833,6 +37948,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -37921,6 +38039,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -38443,6 +38565,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -38531,6 +38656,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -39208,6 +39337,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -39296,6 +39428,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -39791,6 +39927,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -39879,6 +40018,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -40413,6 +40556,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -40501,6 +40647,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -41036,6 +41186,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -41124,6 +41277,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -43683,40 +43840,6 @@ "success" ] }, - "Event.server.connected": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "server.connected" - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": [ - "type", - "properties" - ] - }, - "Event.global.disposed": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "global.disposed" - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": [ - "type", - "properties" - ] - }, "Event.installation.updated": { "type": "object", "properties": { @@ -43927,6 +44050,40 @@ "properties" ] }, + "Event.server.connected": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "server.connected" + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": [ + "type", + "properties" + ] + }, + "Event.global.disposed": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "global.disposed" + }, + "properties": { + "type": "object", + "properties": {} + } + }, + "required": [ + "type", + "properties" + ] + }, "Event.lsp.client.diagnostics": { "type": "object", "properties": { @@ -46678,6 +46835,9 @@ "sandbox_unavailable" ] }, + "message": { + "type": "string" + }, "capability": { "type": "string", "enum": [ @@ -46766,6 +46926,10 @@ "allow" ] }, + "requireProjectTrust": { + "default": false, + "type": "boolean" + }, "backend": { "type": "string", "enum": [ @@ -47030,12 +47194,6 @@ }, "Event": { "anyOf": [ - { - "$ref": "#/components/schemas/Event.server.connected" - }, - { - "$ref": "#/components/schemas/Event.global.disposed" - }, { "$ref": "#/components/schemas/Event.installation.updated" }, @@ -47051,6 +47209,12 @@ { "$ref": "#/components/schemas/Event.project.trust.changed" }, + { + "$ref": "#/components/schemas/Event.server.connected" + }, + { + "$ref": "#/components/schemas/Event.global.disposed" + }, { "$ref": "#/components/schemas/Event.lsp.client.diagnostics" }, @@ -48438,6 +48602,10 @@ "error", "allow" ] + }, + "requireProjectTrust": { + "description": "Require explicit project trust before any execution, even when a verified OS sandbox is available. Default: false.", + "type": "boolean" } } },