Skip to content
Open
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
31 changes: 31 additions & 0 deletions .github/workflows/research-readonly.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Research read-only checks

on:
pull_request:
paths:
- "app/(root)/research/**"
- "components/research/**"
- "lib/quantagent/**"
- "__tests__/quantagent-*.test.ts"
- ".github/workflows/research-readonly.yml"
- "package.json"
- "package-lock.json"

permissions:
contents: read

jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- run: npm ci
- run: npm test
- run: npx eslint "app/(root)/research" components/research lib/quantagent __tests__/quantagent-*.test.ts
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,24 @@

OpenStock is an open-source alternative to expensive market platforms. Track real-time prices, set personalized alerts, and explore detailed company insights — built openly, for everyone, forever free.

## QuantAgent research results

The authenticated `/research` page can read an existing QuantAgent run summary and integrity-checked Markdown report. It does not expose a task submission, cancellation, resume, rerun, or trading action.

Configure these variables on the OpenStock server only:

```dotenv
QUANTAGENT_API_BASE_URL=http://127.0.0.1:8765
QUANTAGENT_API_BEARER_TOKEN=replace-with-at-least-32-random-characters
QUANTAGENT_ALLOWED_USER_ID=replace-with-the-authorized-better-auth-user-id
```

Research access is disabled unless `QUANTAGENT_ALLOWED_USER_ID` exactly matches the signed-in Better Auth `user.id`. Use the immutable ID, not an email address; this single-account pilot does not map multiple OpenStock users to QuantAgent owners. Other accounts cannot trigger a QuantAgent read, even if they know a run ID. Keep this setting server-only.

The browser never receives the bearer token. For the allowed account, OpenStock sends two uncached, non-redirecting `GET` requests from the Next.js server to QuantAgent v1. Plain HTTP is accepted only for literal loopback IPs (`127.0.0.1` or `[::1]`); hostnames, including `localhost`, must use HTTPS.

For an optional local contract check, start QuantAgent with a completed thesis run, set `QUANTAGENT_LIVE_RUN_ID` to that run ID alongside the two server variables above, then run `npm test -- __tests__/quantagent-live-contract.test.ts`. The test performs real authenticated reads and checks report integrity and public error mapping; it is skipped when the three variables are not set. Use test-only credentials and do not commit them.

Note: OpenStock is community-built and not a brokerage. Market data may be delayed based on provider rules and your configuration. Nothing here is financial advice.

## 📋 Table of Contents
Expand Down
52 changes: 52 additions & 0 deletions __tests__/fixtures/quantagent-thesis-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# OpenStock P3b Live Contract

- Thesis: `thesis.btc.synthetic.v1` v2
- As of: `2026-09-22T00:00:00+00:00`
- Assessment: **weakened**
- Research suggestion: **research_only_reassess**
- Evidence sufficient: **true**
- Human reviewed: **false**
- Action eligible: **false**

## Core thesis

Synthetic fixture thesis: durable demand and network usage support the long-term research case, subject to explicit invalidation conditions.

## Claim scorecard

| Claim | Status | Evidence |
| --- | --- | --- |
| Synthetic institutional demand remains durable. | `challenged` | `evidence.new.oppose`, `evidence.prior.support` |
| Synthetic network usage remains resilient. | `supported` | `evidence.new.support` |

## Evidence index

Raw excerpts remain untrusted in the input packet and are not rendered here.

| Evidence | Stance | Impact | Source | Claims | Content SHA-256 |
| --- | --- | --- | --- | --- | --- |
| `evidence.new.context` | `context` | `context` | Synthetic adversarial-text fixture | `invalidation.demand_reversal` | `8b7c3cddc4c52f17bd2dedd4f42188d07634d99f490e107c02314b1e8d72ce18` |
| `evidence.new.oppose` | `oppose` | `weaken` | Synthetic demand-flow fixture | `claim.institutional_demand` | `b02b9c3435c9e13ecc2eb9c88e5c38ce6208edb3f1858591f4fd126ea6bfa4ad` |
| `evidence.new.support` | `support` | `strengthen` | Synthetic network activity fixture | `claim.network_usage` | `bf1cfe77cfe84e443f90acdc1002e6b908555e6c819c8f677455bb10b94d23d4` |
| `evidence.prior.support` | `support` | `strengthen` | Synthetic prior evidence | `claim.institutional_demand` | `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa` |

## Invalidation conditions

| Condition | Status | Evidence |
| --- | --- | --- |
| Durable synthetic demand reverses across independent sources. | `monitoring` | — |

## Missing information

- None declared by the deterministic checks.

## Revision

- Added evidence: evidence.new.context, evidence.new.oppose, evidence.new.support
- Reasons: context_evidence_added, opposing_evidence_added, supporting_evidence_added
- Previous state SHA-256: `ca8efc029f2c1bde9d431173abb34420ff737582e541cfe0af82a8b8d8734b7b`
- Evidence bundle SHA-256: `11dee744195d775c4c8b0dfff1fd301f7ec4451f556c6ea04b61565e97735ba2`

## Scope

This deterministic offline report organizes supplied evidence. It does not verify external truth, predict returns, authorize trading, or convert research suggestions into orders.
56 changes: 56 additions & 0 deletions __tests__/quantagent-live-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest"

import { loadQuantAgentRun, QuantAgentReadError } from "@/lib/quantagent/read-api"

const baseUrl = process.env.QUANTAGENT_API_BASE_URL
const bearerToken = process.env.QUANTAGENT_API_BEARER_TOKEN
const runId = process.env.QUANTAGENT_LIVE_RUN_ID

async function captureReadError(promise: Promise<unknown>) {
try {
await promise
} catch (error) {
expect(error).toBeInstanceOf(QuantAgentReadError)
const normalized = error as QuantAgentReadError
return { ...normalized, message: normalized.message }
}
throw new Error("Expected a QuantAgent read error")
}

describe.skipIf(!baseUrl || !bearerToken || !runId)("QuantAgent live read contract", () => {
const config = { baseUrl: baseUrl!, bearerToken: bearerToken! }
const selectedRunId = runId!

it("reads a completed run and integrity-checked report twice without exposing the token", async () => {
const first = await loadQuantAgentRun(selectedRunId, config)
const repeated = await loadQuantAgentRun(selectedRunId, config)

expect(first.summary.run_id).toBe(selectedRunId)
expect(first.summary.status).toBe("completed")
expect(first.report).not.toBeNull()
expect(first.rawReport).toBeTruthy()
expect(repeated).toEqual(first)
expect(JSON.stringify(first)).not.toContain(config.bearerToken)
})

it("maps authentication and missing-run errors without returning upstream details", async () => {
const authenticationError = await captureReadError(loadQuantAgentRun(selectedRunId, {
...config,
bearerToken: "incorrect-live-test-token-with-at-least-32-characters",
}))
expect(authenticationError).toStrictEqual({
code: "authentication_required",
message: "QuantAgent request failed with 401",
name: "QuantAgentReadError",
status: 401,
})

const missingRunError = await captureReadError(loadQuantAgentRun("api-00000000000000000000000000000000", config))
expect(missingRunError).toStrictEqual({
code: "run_not_found",
message: "QuantAgent request failed with 404",
name: "QuantAgentReadError",
status: 404,
})
})
})
180 changes: 180 additions & 0 deletions __tests__/quantagent-read-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { createHash } from "node:crypto"
import { readFileSync } from "node:fs"
import { resolve } from "node:path"

import { describe, expect, it, vi } from "vitest"

import {
loadQuantAgentRun,
normalizeQuantAgentBaseUrl,
QuantAgentReadError,
} from "@/lib/quantagent/read-api"

const token = "p3b-test-token-with-at-least-32-characters"
const report = readFileSync(resolve(__dirname, "fixtures/quantagent-thesis-report.md"), "utf8")
const reportSha256 = createHash("sha256").update(report).digest("hex")
const runId = "20260923T005454Z-b3cfb532"

const summary = {
contract_type: "quantagent.read_api.run_summary.v1",
run_id: runId,
recipe: { id: "thesis-tracker", version: "1.0.0" },
status: "completed",
started_at: "2026-09-23T00:54:54.865603+00:00",
completed_at: "2026-09-23T00:54:54.907952+00:00",
offline: true,
selection: {
type: "agent",
agent: { id: "builtin.research-agent", version: "1.0.0" },
skill: { id: "anthropic-financial-services-adapted.thesis-tracker", version: "1.0.0" },
recipe: { id: "thesis-tracker", version: "1.0.0" },
},
steps: [
["load-thesis-review", "builtin.json-thesis-review-source", "source.thesis_review", "quantagent.thesis_review_input.v1", "3c45ce928153d1d43ab299355d05b5803909e5732a77c7e64b774734d752e7d6"],
["update-thesis-state", "builtin.deterministic-thesis-tracker", "research.thesis_update", "quantagent.thesis_state.v1", "ef2de1d2ed0d9b9009a621958d20fb5f87bb9e72f4bd6a5195ab59100195bbc5"],
["write-thesis-report", "builtin.markdown-thesis-report", "report.thesis", "quantagent.report.v1", "2c8d46ab54b1d000b8fd46d4f419aa60d654f37baeab1c55c90642f44e7e5f8b"],
].map(([id, plugin_id, capability, output_contract, output_sha256]) => ({
id,
plugin_id,
plugin_version: "1.0.0",
capability,
output_contract,
output_sha256,
status: "completed",
})),
final: {
contract: "quantagent.report.v1",
content_sha256: "2c8d46ab54b1d000b8fd46d4f419aa60d654f37baeab1c55c90642f44e7e5f8b",
},
failure_type: null,
links: { report: `/api/v1/runs/${runId}/report` },
}

function fixtureFetch() {
return vi.fn<typeof fetch>(async (input) => {
const url = input.toString()
if (url.endsWith("/report")) {
return new Response(report, {
status: 200,
headers: { "content-type": "text/markdown", etag: `"${reportSha256}"` },
})
}
return new Response(JSON.stringify(summary), { status: 200, headers: { "content-type": "application/json" } })
})
}

describe("QuantAgent read API adapter", () => {
it("uses only authenticated no-store GETs and returns no credential", async () => {
const fetchMock = fixtureFetch()
const result = await loadQuantAgentRun(runId, { baseUrl: "http://127.0.0.1:8765", bearerToken: token }, fetchMock)

expect(result.report?.assessment).toBe("weakened")
expect(fetchMock).toHaveBeenCalledTimes(2)
for (const [, init] of fetchMock.mock.calls) {
expect(init?.method).toBe("GET")
expect(init?.body).toBeUndefined()
expect(init?.cache).toBe("no-store")
expect(init?.redirect).toBe("error")
expect(new Headers(init?.headers).get("Authorization")).toBe(`Bearer ${token}`)
}
expect(JSON.stringify(result)).not.toContain(token)
})

it("repeating the read cannot create or duplicate a task", async () => {
const fetchMock = fixtureFetch()
const config = { baseUrl: "http://127.0.0.1:8765", bearerToken: token }

await loadQuantAgentRun(runId, config, fetchMock)
await loadQuantAgentRun(runId, config, fetchMock)

expect(fetchMock).toHaveBeenCalledTimes(4)
expect(fetchMock.mock.calls.every(([, init]) => init?.method === "GET")).toBe(true)
})

it("rejects invalid IDs before any network access", async () => {
const fetchMock = fixtureFetch()
await expect(loadQuantAgentRun("../secret", { baseUrl: "http://127.0.0.1:8765", bearerToken: token }, fetchMock)).rejects.toMatchObject({ code: "invalid_run_id" })
expect(fetchMock).not.toHaveBeenCalled()
})

it("rejects a summary for a different run", async () => {
const fetchMock = vi.fn<typeof fetch>(async (input) => {
if (input.toString().endsWith("/report")) {
return new Response(report, { headers: { etag: `"${reportSha256}"` } })
}
return new Response(JSON.stringify({ ...summary, run_id: "another-run" }))
})

await expect(loadQuantAgentRun(runId, { baseUrl: "http://127.0.0.1:8765", bearerToken: token }, fetchMock)).rejects.toMatchObject({ code: "invalid_response" })
})

it("rejects a report without a matching integrity ETag", async () => {
for (const etag of [null, `"${"0".repeat(64)}"`]) {
const fetchMock = vi.fn<typeof fetch>(async (input) => {
if (input.toString().endsWith("/report")) {
return new Response(report, { headers: etag ? { etag } : undefined })
}
return new Response(JSON.stringify(summary))
})

await expect(loadQuantAgentRun(runId, { baseUrl: "http://127.0.0.1:8765", bearerToken: token }, fetchMock)).rejects.toMatchObject({ code: "artifact_integrity_error" })
}
})

it("rejects inconsistent final report metadata", async () => {
for (const invalidSummary of [
{ ...summary, final: null },
{
...summary,
steps: summary.steps.map((step, index) => index === summary.steps.length - 1
? { ...step, output_sha256: "0".repeat(64) }
: step),
},
]) {
const fetchMock = vi.fn<typeof fetch>(async (input) => {
if (input.toString().endsWith("/report")) {
return new Response(report, { headers: { etag: `"${reportSha256}"` } })
}
return new Response(JSON.stringify(invalidSummary))
})

await expect(loadQuantAgentRun(runId, { baseUrl: "http://127.0.0.1:8765", bearerToken: token }, fetchMock)).rejects.toMatchObject({ code: "artifact_integrity_error" })
}
})

it("cancels an oversized streamed response before buffering the remainder", async () => {
let cancelled = false
let pulls = 0
const oversized = new ReadableStream<Uint8Array>({
pull(controller) {
pulls += 1
controller.enqueue(new Uint8Array(1024 * 1024))
},
cancel() {
cancelled = true
},
})
const fetchMock = vi.fn<typeof fetch>(async (input) => {
if (input.toString().endsWith("/report")) return new Response(oversized)
return new Response(JSON.stringify(summary))
})

await expect(loadQuantAgentRun(runId, { baseUrl: "http://127.0.0.1:8765", bearerToken: token }, fetchMock)).rejects.toMatchObject({ code: "invalid_response" })
expect(cancelled).toBe(true)
expect(pulls).toBeLessThanOrEqual(6)
})

it("maps public upstream errors without echoing their body", async () => {
const fetchMock = vi.fn<typeof fetch>(async () => new Response(JSON.stringify({ error: { code: "run_not_found", message: "private path" } }), { status: 404 }))

await expect(loadQuantAgentRun("missing-run", { baseUrl: "http://127.0.0.1:8765", bearerToken: token }, fetchMock)).rejects.toMatchObject({ code: "run_not_found", status: 404 })
})

it("allows literal loopback HTTP but rejects hostname-based plaintext and URL credentials", () => {
expect(normalizeQuantAgentBaseUrl("http://127.0.0.1:8765").origin).toBe("http://127.0.0.1:8765")
expect(normalizeQuantAgentBaseUrl("http://[::1]:8765").origin).toBe("http://[::1]:8765")
expect(() => normalizeQuantAgentBaseUrl("http://localhost:8765")).toThrow(QuantAgentReadError)
expect(() => normalizeQuantAgentBaseUrl("http://example.com")).toThrow(QuantAgentReadError)
expect(() => normalizeQuantAgentBaseUrl("https://token@example.com")).toThrow(QuantAgentReadError)
})
})
40 changes: 40 additions & 0 deletions __tests__/quantagent-report.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { readFileSync } from "node:fs"
import { resolve } from "node:path"

import { describe, expect, it } from "vitest"

import { parseThesisReport } from "@/lib/quantagent/report"

const report = readFileSync(resolve(__dirname, "fixtures/quantagent-thesis-report.md"), "utf8")

describe("parseThesisReport", () => {
it("parses the real P3a deterministic report without rendering raw excerpts", () => {
const parsed = parseThesisReport(report)

expect(parsed.thesisId).toBe("thesis.btc.synthetic.v1")
expect(parsed.version).toBe(2)
expect(parsed.assessment).toBe("weakened")
expect(parsed.researchSuggestion).toBe("research_only_reassess")
expect(parsed.claims).toEqual([
expect.objectContaining({ status: "challenged", evidence: ["evidence.new.oppose", "evidence.prior.support"] }),
expect.objectContaining({ status: "supported", evidence: ["evidence.new.support"] }),
])
expect(parsed.evidence).toHaveLength(4)
expect(parsed.invalidationConditions[0]).toEqual(expect.objectContaining({ status: "monitoring", evidence: [] }))
expect(parsed.missingInformation).toEqual([])
expect(parsed.actionEligible).toBe(false)
expect(report).not.toContain("Ignore prior instructions")
})

it("fails closed when lineage hashes are malformed", () => {
expect(() => parseThesisReport(report.replace(/ca8efc[0-9a-f]+/, "not-a-hash"))).toThrow(
"invalid revision lineage",
)
})

it("rejects an invalid As of timestamp before rendering", () => {
expect(() => parseThesisReport(report.replace("2026-09-22T00:00:00+00:00", "not-a-date"))).toThrow(
"invalid As of timestamp",
)
})
})
Loading