diff --git a/backend/cli/bin/openscience b/backend/cli/bin/openscience index e1c4427c..47b15206 100755 --- a/backend/cli/bin/openscience +++ b/backend/cli/bin/openscience @@ -34,9 +34,14 @@ function run(target) { if (typeof result.status === "number") { process.exit(result.status) } - // Killed by a signal (status is null): report failure using the shell - // convention 128 + signal number, instead of masking it as a success exit 0. if (result.signal) { + console.error( + `openscience was terminated by ${result.signal}. The prebuilt native binary may be ` + + `incompatible with this host (platform ${os.platform()}, arch ${os.arch()}). ` + + `Some ARM64 hosts (e.g. 64KB page-size kernels) cannot run the prebuilt binary. ` + + `Try reinstalling, set OPENSCIENCE_BIN_PATH to a compatible binary, or report at ` + + `https://github.com/synthetic-sciences/openscience/issues`, + ) process.exit(128 + (os.constants.signals[result.signal] || 0)) } process.exit(1) diff --git a/backend/cli/src/provider/provider.ts b/backend/cli/src/provider/provider.ts index 7a9a9bc9..af89c5f4 100644 --- a/backend/cli/src/provider/provider.ts +++ b/backend/cli/src/provider/provider.ts @@ -811,9 +811,14 @@ export namespace Provider { capabilities: { temperature: true, reasoning: true, - attachment: false, + // #192: this is a placeholder for a whitelisted model NOT in the + // local catalog — guessing `false` here silently drops images/PDFs + // for what may well be a vision-capable model. Guess permissive + // instead: a genuinely-unsupported attachment surfaces a real + // provider error rather than a fabricated "unsupported" one. + attachment: true, toolcall: true, - input: { text: true, audio: false, image: false, video: false, pdf: false }, + input: { text: true, audio: false, image: true, video: false, pdf: true }, output: { text: true, audio: false, image: false, video: false, pdf: false }, interleaved: false, }, diff --git a/backend/cli/src/provider/transform.ts b/backend/cli/src/provider/transform.ts index 07cd1ded..795032fb 100644 --- a/backend/cli/src/provider/transform.ts +++ b/backend/cli/src/provider/transform.ts @@ -290,7 +290,16 @@ export namespace ProviderTransform { if (part.type === "file") return fileToText(part) return part } - if (model.capabilities.input[modality]) return part + // #192: fine-grained input.image/input.pdf can be missing/wrong in the + // catalog (e.g. the synthetic OpenRouter model) even though the model + // is flagged attachment-capable. Fall back to the coarse `attachment` + // capability for image/pdf only — audio/video stay gated on their own + // modality flag. + if ( + model.capabilities.input[modality] || + (model.capabilities.attachment && (modality === "image" || modality === "pdf")) + ) + return part const name = filename ? `"${filename}"` : modality return { diff --git a/backend/cli/test/installation/launcher-signal.test.ts b/backend/cli/test/installation/launcher-signal.test.ts new file mode 100644 index 00000000..5ea2a291 --- /dev/null +++ b/backend/cli/test/installation/launcher-signal.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test" +import os from "node:os" +import path from "node:path" +import { chmod, copyFile, mkdtemp, rm, writeFile } from "node:fs/promises" + +// The launcher (backend/cli/bin/openscience) is a plain CJS Node script. When +// the resolved binary is killed by a signal (SIGSEGV/SIGILL on some ARM64 +// hosts, per #190), spawnSync returns status: null, signal: "SIG...", and the +// launcher must not silently exit 0 — see the task brief for the confirmed +// root cause. +const launcherSource = path.join(__dirname, "../../bin/openscience") + +describe("launcher signal handling (#190)", () => { + test("exits non-zero with an actionable diagnostic when the binary is killed by a signal", async () => { + // CRITICAL SAFETY: run() destructively cleans ~/.openscience (unlinks + // files, rmSync's node_modules). HOME must point at a throwaway temp dir + // so this test never touches the real home directory. + const tmpHome = await mkdtemp(path.join(os.tmpdir(), "openscience-signal-home-")) + const tmpBin = await mkdtemp(path.join(os.tmpdir(), "openscience-signal-bin-")) + const crashScript = path.join(tmpBin, "crash.sh") + // Run the launcher from a copy outside this repo's tree: this repo's own + // package.json declares "type": "module", which would make a bare `node + // ` load the extension-less script as ESM and blow up on `require` + // before any launcher logic runs. The published wrapper package (see + // script/publish.ts) ships a package.json with no "type" field, so real + // installs default to CommonJS — copying to a bare temp dir (no ambient + // package.json) reproduces that real-world resolution instead. + const launcherCopy = path.join(tmpBin, "openscience") + + try { + // A shell script that signals itself SEGV. isBinary() in the launcher + // accepts any existing non-.js file that isn't the wrapper itself, so + // this stands in for a Bun binary crashing on an incompatible host. + await writeFile(crashScript, "#!/bin/sh\nkill -s SEGV $$\n") + await chmod(crashScript, 0o755) + await copyFile(launcherSource, launcherCopy) + + const proc = Bun.spawn(["node", launcherCopy, "some-arg"], { + env: { ...process.env, HOME: tmpHome, OPENSCIENCE_BIN_PATH: crashScript }, + stdout: "pipe", + stderr: "pipe", + }) + + const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]) + + expect(exitCode).not.toBe(0) + expect(stderr).toContain("SIGSEGV") + expect(stderr).toContain("incompatible") + } finally { + await rm(tmpHome, { recursive: true, force: true }) + await rm(tmpBin, { recursive: true, force: true }) + } + }) +}) diff --git a/backend/cli/test/provider/transform.test.ts b/backend/cli/test/provider/transform.test.ts index 189f16e7..73e4c59a 100644 --- a/backend/cli/test/provider/transform.test.ts +++ b/backend/cli/test/provider/transform.test.ts @@ -1018,6 +1018,185 @@ describe("ProviderTransform.message - unsupported file attachments", () => { }) }) +describe("ProviderTransform.message - image/pdf attachment fallback (#192)", () => { + // #192: images were falsely rejected as "this model doesn't support image + // input" for vision-capable models whose fine-grained input.image/input.pdf + // flag was missing/wrong in the catalog (notably the synthetic OpenRouter + // model, which hardcoded these false). The gate must fall back to the + // coarse `attachment` capability for image/pdf so it isn't blocked purely + // on a missing modality flag — but a model with attachment:false is + // genuinely incapable and must still get the ERROR text. + const b64 = (s: string) => Buffer.from(s).toString("base64") + const validImageBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + + const createModel = (capabilityOverrides: Record) => + ({ + id: "openrouter/some-vision-model", + providerID: "openrouter", + api: { id: "some-vision-model", url: "https://openrouter.ai/api/v1", npm: "@openrouter/ai-sdk-provider" }, + name: "Some Vision Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + ...capabilityOverrides, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 128000, output: 8192 }, + status: "active", + options: {}, + headers: {}, + release_date: "", + }) as any + + test("image part survives when input.image=false but attachment=true (fallback)", () => { + const model = createModel({ + attachment: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + }) + const msgs = [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image", image: `data:image/png;base64,${validImageBase64}` }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + expect(result[0].content[1]).toEqual({ type: "image", image: `data:image/png;base64,${validImageBase64}` }) + }) + + test("image part still replaced with ERROR when input.image=false and attachment=false (genuinely incapable model)", () => { + const model = createModel({ + attachment: false, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + }) + const msgs = [ + { + role: "user", + content: [ + { type: "text", text: "What is in this image?" }, + { type: "image", image: `data:image/png;base64,${validImageBase64}` }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + expect(result[0].content[1]).toEqual({ + type: "text", + text: "ERROR: Cannot read image (this model does not support image input). Inform the user.", + }) + }) + + test("image part survives when input.image=true regardless of attachment (regression)", () => { + const model = createModel({ + attachment: false, + input: { text: true, audio: false, image: true, video: false, pdf: false }, + }) + const msgs = [ + { + role: "user", + content: [{ type: "image", image: `data:image/png;base64,${validImageBase64}` }], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + expect(result[0].content[0]).toEqual({ type: "image", image: `data:image/png;base64,${validImageBase64}` }) + }) + + test("pdf file part survives when input.pdf=false but attachment=true (fallback)", () => { + const model = createModel({ + attachment: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + }) + const msgs = [ + { + role: "user", + content: [ + { + type: "file", + mediaType: "application/pdf", + filename: "doc.pdf", + data: `data:application/pdf;base64,${b64("%PDF-1.4 fake")}`, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + expect((result[0].content as any[]).some((p: any) => p.type === "file")).toBe(true) + }) + + test("pdf file part replaced with ERROR when input.pdf=false and attachment=false", () => { + const model = createModel({ + attachment: false, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + }) + const msgs = [ + { + role: "user", + content: [ + { + type: "file", + mediaType: "application/pdf", + filename: "doc.pdf", + data: `data:application/pdf;base64,${b64("%PDF-1.4 fake")}`, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + const content = result[0].content as any[] + expect(content.some((p: any) => p.type === "file")).toBe(false) + expect(content[0]).toEqual({ + type: "text", + text: 'ERROR: Cannot read "doc.pdf" (this model does not support pdf input). Inform the user.', + }) + }) + + test("audio file part is NOT broadened by the attachment fallback (image/pdf only)", () => { + const model = createModel({ + attachment: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + }) + const msgs = [ + { + role: "user", + content: [ + { + type: "file", + mediaType: "audio/mpeg", + filename: "clip.mp3", + data: `data:audio/mpeg;base64,${b64("fake audio bytes")}`, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, {}) + + const content = result[0].content as any[] + expect(content.some((p: any) => p.type === "file")).toBe(false) + expect(content[0]).toEqual({ + type: "text", + text: 'ERROR: Cannot read "clip.mp3" (this model does not support audio input). Inform the user.', + }) + }) +}) + describe("ProviderTransform.message - providerOptions key remapping", () => { const createModel = (providerID: string, npm: string) => ({ diff --git a/frontend/docs/src/content/openscience/skills.mdx b/frontend/docs/src/content/openscience/skills.mdx index 2612ef08..63058587 100644 --- a/frontend/docs/src/content/openscience/skills.mdx +++ b/frontend/docs/src/content/openscience/skills.mdx @@ -47,6 +47,16 @@ 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 diff --git a/frontend/ui/src/components/markdown.test.ts b/frontend/ui/src/components/markdown.test.ts new file mode 100644 index 00000000..f38743b6 --- /dev/null +++ b/frontend/ui/src/components/markdown.test.ts @@ -0,0 +1,47 @@ +import { describe, test, expect } from "bun:test" +import katex from "katex" +import { sanitize } from "./markdown" + +const tex = "\\delta\\omega/\\omega < 10^{-6}" + +describe("sanitize (KaTeX MathML annotation)", () => { + test("keeps the wrapper so raw TeX doesn't leak as visible text", () => { + const katexHtml = katex.renderToString(tex, { throwOnError: false }) + const safe = sanitize(katexHtml) + expect(safe).toContain(", not as a bare child of ", () => { + const katexHtml = katex.renderToString(tex, { throwOnError: false }) + const safe = sanitize(katexHtml) + + const doc = new DOMParser().parseFromString(safe, "text/html") + const math = doc.querySelector("math") + expect(math).not.toBeNull() + + // No direct text-node child of should carry the raw TeX source. + const bareLeak = Array.from(math?.childNodes ?? []).some( + (node) => node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").includes(tex), + ) + expect(bareLeak).toBe(false) + + // The TeX source must actually be present, but only inside . + const annotation = doc.querySelector("annotation") + expect(annotation).not.toBeNull() + expect(annotation?.textContent).toContain(tex) + }) + + test("regression guard: still strips script-injection attributes across every node (sanitizer stays active)", () => { + const safe = sanitize("") + expect(safe).not.toContain("onerror") + expect(safe).not.toContain(" { + const safe = sanitize( + '', + ) + expect(safe).not.toContain("onerror") + expect(safe).not.toContain("', } -function sanitize(html: string) { +export function sanitize(html: string) { if (!DOMPurify.isSupported) return "" return DOMPurify.sanitize(html, config) }