Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions backend/cli/bin/openscience
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 7 additions & 2 deletions backend/cli/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
11 changes: 10 additions & 1 deletion backend/cli/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
54 changes: 54 additions & 0 deletions backend/cli/test/installation/launcher-signal.test.ts
Original file line number Diff line number Diff line change
@@ -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
// <path>` 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 })
}
})
})
179 changes: 179 additions & 0 deletions backend/cli/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) =>
({
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) =>
({
Expand Down
10 changes: 10 additions & 0 deletions frontend/docs/src/content/openscience/skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <namespace>` 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
Expand Down
47 changes: 47 additions & 0 deletions frontend/ui/src/components/markdown.test.ts
Original file line number Diff line number Diff line change
@@ -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 <annotation> wrapper so raw TeX doesn't leak as visible text", () => {
const katexHtml = katex.renderToString(tex, { throwOnError: false })
const safe = sanitize(katexHtml)
expect(safe).toContain("<annotation")
})

test("the TeX source appears only inside <annotation>, not as a bare child of <math>", () => {
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 <math> 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 <annotation>.
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("<img src=x onerror=alert(1)><img src=y onerror=alert(2)><script>evil()</script>")
expect(safe).not.toContain("onerror")
expect(safe).not.toContain("<script")
})

test("regression guard: neutralizes payloads nested in the annotation-xml HTML integration point (#194)", () => {
const safe = sanitize(
'<math><semantics><annotation-xml encoding="text/html"><img src=x onerror=alert(1)><script>evil()</script></annotation-xml></semantics></math>',
)
expect(safe).not.toContain("onerror")
expect(safe).not.toContain("<script")
})
})
4 changes: 3 additions & 1 deletion frontend/ui/src/components/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,16 @@ const config = {
SANITIZE_NAMED_PROPS: true,
FORBID_TAGS: ["style"],
FORBID_CONTENTS: ["style", "script"],
ADD_TAGS: ["semantics", "annotation", "annotation-xml"],
ADD_ATTR: ["encoding"],
}

const iconPaths = {
copy: '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>',
check: '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>',
}

function sanitize(html: string) {
export function sanitize(html: string) {
if (!DOMPurify.isSupported) return ""
return DOMPurify.sanitize(html, config)
}
Expand Down