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
39 changes: 34 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ All providers are raw `fetch` — no SDK dependencies. A provider is enabled whe
| Tavily | search | `TAVILY_API_KEY` | quality tier, ~$8/1k |
| Exa | search (semantic) | `EXA_API_KEY` | $7/1k, 1k free/mo |
| SerpAPI | SERP + ads/PAA | `SERPAPI_KEY` | paid |
| Crawl4AI (self-hosted) | fetch | `CRAWL4AI_URL` (default `https://ora-scraper.fly.dev`) | $0 |
| Crawl4AI (self-hosted) | fetch | reviewed domains plus an egress-hardened crawler attestation | $0 |
| Jina Reader | fetch fallback | `JINA_API_KEY` | free tier |
| Reddit | social | `REDDIT_CLIENT_ID`, `REDDIT_CLIENT_SECRET`, `REDDIT_USER_AGENT` | free |
| YouTube | video | `YOUTUBE_API_KEY` | free quota |
Expand All @@ -52,10 +52,32 @@ All providers are raw `fetch` — no SDK dependencies. A provider is enabled whe
| LinkedIn / FB Groups (ingestion layer) | ingestion | Bright Data: `BRIGHTDATA_API_TOKEN` (+ dataset IDs) | paid |
| Perplexity | managed deep research | `PERPLEXITY_API_KEY` | ~$0.40–1.30/query (sonar-deep-research) |

Model/pipeline keys: `ANTHROPIC_API_KEY` (synthesis/critique), `GEMINI_RESEARCH_KEY` or `GEMINI_API_KEY` (planner/extraction), `VOYAGE_API_KEY` (rerank/embed), optional `OPENROUTER_API_KEY`, `DEEPSEEK_API_KEY`.
Model/pipeline keys: `ANTHROPIC_API_KEY` (synthesis/critique),
`GEMINI_RESEARCH_KEY`, `GOOGLE_GEMINI_API_KEY`,
`GOOGLE_GENERATIVE_AI_API_KEY`, or `GEMINI_API_KEY` (one shared resolver for
planning/extraction/synthesis),
`VOYAGE_API_KEY` (rerank/embed), optional `OPENROUTER_API_KEY`,
`DEEPSEEK_API_KEY`.

If multiple Gemini aliases contain different values, the shared package
resolver fails closed. Set `BRAINTIED_GEMINI_KEY_NAME` to the approved variable
name; the local runner also accepts `--gemini-key-name` and reports the selected
name without printing its value.

> Env naming is standardized here — `SERPAPI_KEY` (not `SERP_API_KEY`), `SERPER_API_KEY`, `SEARXNG_URLS`.

Generic web fetches are fail-closed. The package accepts only public HTTP(S)
targets on the standard port, pins each DNS-validated connection, revalidates
every redirect, blocks private/link-local/reserved IPv4 and IPv6, and enforces
content-type and byte limits. The self-hosted Crawl4AI service is additionally
deny-by-default because a separate browser process cannot inherit the caller's
pinned DNS result. It remains disabled unless the separate crawler enforces
public-only DNS/IP checks on every navigation, redirect, and subresource and
the caller sets `BRAINTIED_CRAWL4AI_NETWORK_GUARD=enforced-v1` to attest that
boundary. `BRAINTIED_CRAWL4AI_ALLOWED_DOMAINS` is a second, comma-separated,
human-reviewed gate: entries are exact hosts, while `*.example.com` explicitly
allows subdomains. An allowlist alone is not an SSRF defense.

Instagram has a stricter boundary than the general web-fetch stack. Hashtag
search uses Bright Data snapshot discovery; direct `/p/`, `/reel/`, and `/tv/`
links use the Bright Data posts dataset; one-segment profile links use the
Expand Down Expand Up @@ -140,8 +162,15 @@ the run, that checkpoint also records the durable run ID; terminal success
replaces it with final metadata. Preserve the checkpoint and use
`--request-id <id>` to reattach after a local timeout or interruption.
`run-research.mjs` remains available as an explicit local-provider fallback; it
supports allowlisted interactive-shell environment loading and build-freshness
checks. Exact lane preflight requires an as-of date:
supports permission-checked, allowlisted dotenv loading, allowlisted
interactive-shell environment loading, and build-freshness checks. Use
`--research-env-file /absolute/path/to/.env` (or the
`BRAINTIED_RESEARCH_ENV_FILE` pointer) when approved credentials live in a
project file; this intentionally replaces or masks stale inherited research
variables without importing unrelated env entries. Never use Node's built-in
`--env-file` for this runner because Node imports every entry before the
allowlist can run; the runner refuses when it detects that preload. Exact lane
preflight requires an as-of date:

```bash
node skills/run-braintied-research/scripts/run-research.mjs \
Expand Down Expand Up @@ -207,7 +236,7 @@ npm run pack:release # → releases/braintied-research-<version>.tgz

```jsonc
// consumer package.json
"@braintied/research": "file:vendor/braintied-research-0.8.2.tgz"
"@braintied/research": "file:vendor/braintied-research-0.8.3.tgz"
```

## Development
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@braintied/research",
"version": "0.8.2",
"version": "0.8.4",
"description": "Deep-research engine — multi-provider search, quote extraction, synthesis, critique, grounded report assembly, and research-driven document generation (PRDs, briefs, specs)",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
Expand Down
14 changes: 12 additions & 2 deletions skills/run-braintied-research/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,18 @@ model providers; treat every brief as outbound data.

Use `run-research.mjs` only as an explicitly authorized local fallback when
the internal service is unavailable. That fallback requires package build
freshness plus its own provider credentials; `--load-shell-env` imports only
the documented provider allowlist and never Agent Auth. For multichannel
freshness plus its own provider credentials. When approved credentials live
in a project dotenv file, prefer the runner's
`--research-env-file /absolute/path/to/.env` option. Never use Node's built-in
`--env-file`: Node imports every entry before the runner's allowlist executes,
and the runner refuses when it detects that preload.
The runner imports only its documented allowlist, requires every supplied
file to be a private regular non-symlink on supported POSIX systems, and
treats blank assignments as explicit masks for inherited values.
`BRAINTIED_RESEARCH_ENV_FILE` may point to that approved path so the command
works from any workspace; an explicit `--research-env-file` wins.
`--load-shell-env` imports the same allowlist from the interactive shell,
can discover that pointer, and never imports Agent Auth. For multichannel
research, preflight exact lanes and an exact upper boundary:

```bash
Expand Down
53 changes: 44 additions & 9 deletions skills/run-braintied-research/references/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,34 @@ the common case where source supports newer behavior but ignored `dist/` still
contains an older build. Published packages intentionally omit source and report
freshness as unavailable; built-kind and export checks still apply there.

For project-owned dotenv credentials, use the runner's allowlisted loader:

```bash
node skills/run-braintied-research/scripts/run-research.mjs \
--check \
--kind standard \
--max-cost-usd 2 \
--research-env-file /absolute/path/to/.env.production.local
```

Never use Node's built-in `--env-file` for this runner: Node loads every entry
before the runner starts, bypassing its allowlist; the runner refuses when it
detects that preload. `--research-env-file`
deliberately replaces stale allowlisted values and treats an explicit blank as
a mask that prevents shell fallback. It never imports unrelated dotenv entries.
On POSIX, every supplied file must be a regular non-symlink with owner-only
permissions (0600 or stricter), and it is checked and read through one open file
handle. Secure dotenv loading fails closed on Windows; inject approved process
environment values there instead. `BRAINTIED_RESEARCH_ENV_FILE` may point to the
same approved absolute path so commands work from any workspace; an explicit
`--research-env-file` wins. The pointer contains no secret value, but access to
the target file still controls credential authority.

The allowlisted parser intentionally accepts single-line dotenv assignments
only: optional `export`, unquoted values with `#` comments, and single-, double-,
or backtick-quoted values. Multiline values are rejected. Double-quoted `\\n`
and `\\r` escapes are expanded; other backslashes are preserved.

## Local fallback credential matrix

The runner reports only whether a variable is present. Never print, echo, or
Expand Down Expand Up @@ -144,18 +172,24 @@ Recommended role/order:
- GitHub REST performs repository/issue/PR discovery; an optional project-owned
token raises the restrictive unauthenticated quota.

For compatibility, the runner maps `GEMINI_RESEARCH_KEY` to `GEMINI_API_KEY`
inside its child process when the latter is absent. It never persists the alias.
The Gemini resolver accepts `GEMINI_RESEARCH_KEY`, `GOOGLE_GEMINI_API_KEY`,
`GOOGLE_GENERATIVE_AI_API_KEY`, and `GEMINI_API_KEY`. If multiple aliases contain
different values, the shared package resolver fails without printing them.
Direct package consumers must set `BRAINTIED_GEMINI_KEY_NAME=NAME`; the runner
also accepts `--gemini-key-name NAME`. The chosen value becomes the canonical
planning/extraction/synthesis key for that process. Preflight reports only the
selected variable name, and the runner never persists the value.

`VOYAGE_API_KEY` is optional: without it, reranking preserves provider order.
Anthropic is optional for Gemini-backed runs: planner retries lose their Claude
fallback, and critique returns its documented permissive fallback.

Use credentials owned and approved for the project being researched. In Codex
Desktop, `--load-shell-env` imports only allowlisted provider variables and
defaults search to Braintied's two SearXNG instances. It never prints values.
Do not inspect or reuse a neighboring project's environment file merely because
it exists.
Desktop, `--research-env-file` imports only allowlisted values from the explicit
approved file. `--load-shell-env` then fills unmasked gaps from the interactive
shell and defaults search to Braintied's two SearXNG instances. Neither prints
values. Do not inspect or reuse a neighboring project's environment file merely
because it exists.

## Result contract

Expand Down Expand Up @@ -236,12 +270,13 @@ node skills/run-braintied-research/scripts/run-research.mjs \
--synthesis-model gemini-3-flash-preview \
--load-shell-env

node --env-file=/path/owned-by-this-project/.env \
skills/run-braintied-research/scripts/run-research.mjs \
node skills/run-braintied-research/scripts/run-research.mjs \
--check \
--kind quick \
--max-cost-usd 0.25 \
--synthesis-model gemini-3-flash-preview
--synthesis-model gemini-3-flash-preview \
--research-env-file /absolute/path/owned-by-this-project/.env \
--gemini-key-name GOOGLE_GEMINI_API_KEY

node skills/run-braintied-research/scripts/run-research.mjs \
--check \
Expand Down
50 changes: 50 additions & 0 deletions skills/run-braintied-research/scripts/research-env-file.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
export function parseDotenvValue(rawValue, filePath, lineNumber) {
const value = rawValue.trim();
if (value.length === 0) return '';

const quote = value[0];
if (quote === "'" || quote === '"' || quote === '`') {
let escaped = false;
let end = -1;
for (let index = 1; index < value.length; index += 1) {
const character = value[index];
if (quote === '"' && character === '\\' && !escaped) {
escaped = true;
continue;
}
if (character === quote && !escaped) {
end = index;
break;
}
escaped = false;
}
if (end === -1) {
throw new Error(`Unterminated quoted value in ${filePath}:${lineNumber}.`);
}
const trailing = value.slice(end + 1).trim();
if (trailing.length > 0 && !trailing.startsWith('#')) {
throw new Error(`Unexpected text after quoted value in ${filePath}:${lineNumber}.`);
}
const unquoted = value.slice(1, end);
if (quote !== '"') return unquoted;
return unquoted
.replace(/\\n/g, '\n')
.replace(/\\r/g, '\r');
}

const comment = value.indexOf('#');
return (comment === -1 ? value : value.slice(0, comment)).trim();
}

export function parseAllowlistedEnvFile(contents, filePath, allowedNames) {
const parsed = new Map();
const lines = contents.replace(/^\uFEFF/, '').split(/\r?\n/);
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index].trim();
if (line.length === 0 || line.startsWith('#')) continue;
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(line);
if (match === null || !allowedNames.has(match[1])) continue;
parsed.set(match[1], parseDotenvValue(match[2], filePath, index + 1));
}
return parsed;
}
Loading