@openafw/openafw
Advanced tools
| import { atomicWrite, fileExists, paths } from "./secrets-evRw4cV3.js"; | ||
| import { readFile } from "node:fs/promises"; | ||
| //#region src/core/tool-providers.ts | ||
| const TOOL_PROVIDERS_VERSION = 1; | ||
| /** Seeded default — a keyless DuckDuckGo backend so first-run users | ||
| * have a working `web_search` without any setup. Quality is mediocre | ||
| * vs Brave/Tavily but it costs nothing and never asks for an account. */ | ||
| const SEEDED_DDG = { | ||
| id: "ddg", | ||
| label: "DuckDuckGo (built-in)", | ||
| kind: "web_search", | ||
| backend: "duckduckgo", | ||
| origin: "seeded" | ||
| }; | ||
| const EMPTY_STORE = { | ||
| version: TOOL_PROVIDERS_VERSION, | ||
| providers: [SEEDED_DDG], | ||
| active: {} | ||
| }; | ||
| function isObj(v) { | ||
| return typeof v === "object" && v !== null && !Array.isArray(v); | ||
| } | ||
| const KINDS = ["web_search"]; | ||
| const BACKENDS = [ | ||
| "duckduckgo", | ||
| "brave", | ||
| "searxng", | ||
| "tavily", | ||
| "baidu" | ||
| ]; | ||
| function normalizeProvider(raw) { | ||
| if (!isObj(raw)) return void 0; | ||
| if (typeof raw.id !== "string" || raw.id === "") return void 0; | ||
| if (!KINDS.includes(raw.kind)) return void 0; | ||
| if (!BACKENDS.includes(raw.backend)) return void 0; | ||
| return { | ||
| id: raw.id, | ||
| label: typeof raw.label === "string" && raw.label !== "" ? raw.label : raw.id, | ||
| kind: raw.kind, | ||
| backend: raw.backend, | ||
| ...typeof raw.baseUrl === "string" && raw.baseUrl !== "" ? { baseUrl: raw.baseUrl } : {}, | ||
| ...typeof raw.authRef === "string" && raw.authRef !== "" ? { authRef: raw.authRef } : {}, | ||
| ...typeof raw.costPerCall === "number" && raw.costPerCall >= 0 ? { costPerCall: raw.costPerCall } : {}, | ||
| origin: raw.origin === "manual" ? "manual" : "seeded" | ||
| }; | ||
| } | ||
| function normalizeToolProviders(raw) { | ||
| if (!isObj(raw)) return { | ||
| ...EMPTY_STORE, | ||
| providers: [SEEDED_DDG] | ||
| }; | ||
| const providers = Array.isArray(raw.providers) ? raw.providers.map(normalizeProvider).filter((p) => p != null) : []; | ||
| if (!providers.some((p) => p.kind === "web_search")) providers.push(SEEDED_DDG); | ||
| const active = {}; | ||
| if (isObj(raw.active)) for (const k of KINDS) { | ||
| const v = raw.active[k]; | ||
| if (typeof v === "string" && v !== "") active[k] = v; | ||
| } | ||
| return { | ||
| version: TOOL_PROVIDERS_VERSION, | ||
| providers, | ||
| active | ||
| }; | ||
| } | ||
| async function readToolProviders() { | ||
| if (!await fileExists(paths.toolProviders)) return { | ||
| ...EMPTY_STORE, | ||
| providers: [SEEDED_DDG] | ||
| }; | ||
| return normalizeToolProviders(JSON.parse(await readFile(paths.toolProviders, "utf8"))); | ||
| } | ||
| async function writeToolProviders(store) { | ||
| await atomicWrite(paths.toolProviders, `${JSON.stringify(store, null, 2)}\n`); | ||
| } | ||
| let writeChain = Promise.resolve(); | ||
| /** Serialized read-modify-write — see model-registry.ts mutateModelRegistry. */ | ||
| function mutateToolProviders(fn) { | ||
| const next = writeChain.then(async () => { | ||
| const store = await readToolProviders(); | ||
| const updated = fn(store); | ||
| if (updated) await writeToolProviders(updated); | ||
| return updated ?? store; | ||
| }); | ||
| writeChain = next.catch(() => {}); | ||
| return next; | ||
| } | ||
| /** Active provider for a kind — explicit `active[kind]` wins, then | ||
| * the first provider of that kind, then undefined (caller falls back | ||
| * to "no backend, return an error to the tool caller"). */ | ||
| function activeProviderFor(store, kind) { | ||
| const explicit = store.active[kind]; | ||
| if (explicit) { | ||
| const hit = store.providers.find((p) => p.id === explicit && p.kind === kind); | ||
| if (hit) return hit; | ||
| } | ||
| return store.providers.find((p) => p.kind === kind); | ||
| } | ||
| //#endregion | ||
| //#region src/core/web-search/backends.ts | ||
| const DDG_URL = "https://html.duckduckgo.com/html/"; | ||
| const DDG_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"; | ||
| async function searchDuckDuckGo(opts) { | ||
| const form = new URLSearchParams({ q: opts.query }); | ||
| let res; | ||
| try { | ||
| res = await fetch(DDG_URL, { | ||
| method: "POST", | ||
| headers: { | ||
| "content-type": "application/x-www-form-urlencoded", | ||
| "user-agent": DDG_USER_AGENT, | ||
| accept: "text/html" | ||
| }, | ||
| body: form.toString() | ||
| }); | ||
| } catch (err) { | ||
| return { | ||
| ok: false, | ||
| error: `duckduckgo unreachable: ${err.message}` | ||
| }; | ||
| } | ||
| if (!res.ok) return { | ||
| ok: false, | ||
| error: `duckduckgo HTTP ${res.status}` | ||
| }; | ||
| const html = await res.text(); | ||
| if (isDuckDuckGoAnomalyPage(html)) return { | ||
| ok: false, | ||
| error: "DuckDuckGo flagged this request as bot traffic and served a challenge page. This often resolves after a few minutes; for sustained use, switch to a key-based backend (Brave Search API) in Control · Tool Providers." | ||
| }; | ||
| const results = parseDuckDuckGoHtml(html).slice(0, opts.count ?? 10); | ||
| return { | ||
| ok: true, | ||
| results | ||
| }; | ||
| } | ||
| /** True when DDG returned its bot-challenge page instead of search | ||
| * results. The anomaly form's action URL is the strongest signal. */ | ||
| function isDuckDuckGoAnomalyPage(html) { | ||
| return /anomaly\.js/.test(html) || /id="challenge-form"/.test(html); | ||
| } | ||
| /** Parse DDG's HTML results page. Their non-JS endpoint renders each | ||
| * result as a `result__a` anchor + `result__snippet` span. URLs come | ||
| * through a `//duckduckgo.com/l/?uddg=<encoded>` redirector — we | ||
| * decode the inner URL so callers get the real destination. The | ||
| * parser is intentionally a regex scan (no DOM lib) so it works in | ||
| * the daemon's plain node runtime; markup churn risk is the tradeoff. */ | ||
| function parseDuckDuckGoHtml(html) { | ||
| const results = []; | ||
| const anchorRe = /<a[^>]+class="[^"]*\bresult__a\b[^"]*"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g; | ||
| const snippetRe = /<(?:a|span)[^>]+class="[^"]*\bresult__snippet\b[^"]*"[^>]*>([\s\S]*?)<\/(?:a|span)>/g; | ||
| const snippets = []; | ||
| let s; | ||
| while ((s = snippetRe.exec(html)) !== null) snippets.push(stripHtml(s[1] ?? "")); | ||
| let i = 0; | ||
| let m; | ||
| while ((m = anchorRe.exec(html)) !== null) { | ||
| const rawHref = decodeHtmlEntities(m[1] ?? ""); | ||
| const url = unwrapDdgRedirect(rawHref); | ||
| const title = stripHtml(m[2] ?? ""); | ||
| if (!url || !title) { | ||
| i++; | ||
| continue; | ||
| } | ||
| const snippet = snippets[i]; | ||
| results.push(snippet ? { | ||
| title, | ||
| url, | ||
| snippet | ||
| } : { | ||
| title, | ||
| url | ||
| }); | ||
| i++; | ||
| } | ||
| return results; | ||
| } | ||
| function unwrapDdgRedirect(href) { | ||
| const m = /[?&]uddg=([^&]+)/.exec(href); | ||
| if (m?.[1]) try { | ||
| return decodeURIComponent(m[1]); | ||
| } catch { | ||
| return ""; | ||
| } | ||
| if (href.startsWith("//")) return `https:${href}`; | ||
| if (href.startsWith("http")) return href; | ||
| return ""; | ||
| } | ||
| function stripHtml(s) { | ||
| return decodeHtmlEntities(s.replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim(); | ||
| } | ||
| function decodeHtmlEntities(s) { | ||
| return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/'/g, "'").replace(/ /g, " "); | ||
| } | ||
| const BRAVE_URL = "https://api.search.brave.com/res/v1/web/search"; | ||
| async function searchBrave(opts) { | ||
| const url = new URL(BRAVE_URL); | ||
| url.searchParams.set("q", opts.query); | ||
| url.searchParams.set("count", String(opts.count ?? 10)); | ||
| if (opts.locale) url.searchParams.set("country", opts.locale); | ||
| let res; | ||
| try { | ||
| res = await fetch(url, { headers: { | ||
| accept: "application/json", | ||
| "x-subscription-token": opts.apiKey | ||
| } }); | ||
| } catch (err) { | ||
| return { | ||
| ok: false, | ||
| error: `brave unreachable: ${err.message}` | ||
| }; | ||
| } | ||
| const text = await res.text(); | ||
| if (!res.ok) return { | ||
| ok: false, | ||
| error: `brave HTTP ${res.status}: ${text.slice(0, 200)}` | ||
| }; | ||
| let json; | ||
| try { | ||
| json = JSON.parse(text); | ||
| } catch { | ||
| return { | ||
| ok: false, | ||
| error: `brave returned non-JSON: ${text.slice(0, 200)}` | ||
| }; | ||
| } | ||
| return { | ||
| ok: true, | ||
| results: parseBraveJson(json).slice(0, opts.count ?? 10) | ||
| }; | ||
| } | ||
| function parseBraveJson(json) { | ||
| if (typeof json !== "object" || json === null) return []; | ||
| const web = json.web; | ||
| if (!web || !Array.isArray(web.results)) return []; | ||
| const out = []; | ||
| for (const r of web.results) { | ||
| if (typeof r !== "object" || r === null) continue; | ||
| const row = r; | ||
| const title = typeof row.title === "string" ? row.title : ""; | ||
| const url = typeof row.url === "string" ? row.url : ""; | ||
| const description = typeof row.description === "string" ? row.description : ""; | ||
| if (!title || !url) continue; | ||
| out.push(description ? { | ||
| title, | ||
| url, | ||
| snippet: description | ||
| } : { | ||
| title, | ||
| url | ||
| }); | ||
| } | ||
| return out; | ||
| } | ||
| const BAIDU_SMART_URL = "https://qianfan.baidubce.com/v2/ai_search/chat/completions"; | ||
| const BAIDU_WEB_URL = "https://qianfan.baidubce.com/v2/ai_search/web_search"; | ||
| const BAIDU_SMART_MODEL = "ernie-4.5-turbo-32k"; | ||
| async function searchBaidu(opts) { | ||
| const smart = await searchBaiduSmart(opts); | ||
| if (smart.ok) return smart; | ||
| const plain = await searchBaiduWeb(opts); | ||
| if (plain.ok) return plain; | ||
| return { | ||
| ok: false, | ||
| error: `baidu smart-search failed (${smart.error}); plain web_search also failed (${plain.error})` | ||
| }; | ||
| } | ||
| /** Smart search via /v2/ai_search/chat/completions. Carries a model | ||
| * parameter (the LLM summary is discarded by afw — the routed | ||
| * model synthesises its own answer — but the references[] field is | ||
| * the same shape we consume from plain web_search). */ | ||
| async function searchBaiduSmart(opts) { | ||
| const count = Math.max(1, Math.min(20, opts.count ?? 10)); | ||
| const body = { | ||
| messages: [{ | ||
| role: "user", | ||
| content: opts.query | ||
| }], | ||
| model: BAIDU_SMART_MODEL, | ||
| search_source: "baidu_search_v2", | ||
| stream: false, | ||
| resource_type_filter: [{ | ||
| type: "web", | ||
| top_k: count | ||
| }], | ||
| enable_reasoning: false, | ||
| enable_deep_search: false, | ||
| enable_followup_queries: false, | ||
| search_mode: "required" | ||
| }; | ||
| const filter = freshnessToFilter(opts.freshness); | ||
| if (filter) body.search_filter = filter; | ||
| return callBaidu(BAIDU_SMART_URL, opts.apiKey, body, count); | ||
| } | ||
| /** Plain web search via /v2/ai_search/web_search. No model invocation, | ||
| * larger monthly quota — the fallback when smart search refuses. */ | ||
| async function searchBaiduWeb(opts) { | ||
| const count = Math.max(1, Math.min(50, opts.count ?? 10)); | ||
| const body = { | ||
| messages: [{ | ||
| role: "user", | ||
| content: opts.query | ||
| }], | ||
| search_source: "baidu_search_v2", | ||
| resource_type_filter: [{ | ||
| type: "web", | ||
| top_k: count | ||
| }] | ||
| }; | ||
| const filter = freshnessToFilter(opts.freshness); | ||
| if (filter) body.search_filter = filter; | ||
| return callBaidu(BAIDU_WEB_URL, opts.apiKey, body, count); | ||
| } | ||
| async function callBaidu(url, apiKey, body, count) { | ||
| let res; | ||
| try { | ||
| res = await fetch(url, { | ||
| method: "POST", | ||
| headers: { | ||
| "content-type": "application/json", | ||
| accept: "application/json", | ||
| authorization: `Bearer ${apiKey}`, | ||
| "x-appbuilder-from": "afw" | ||
| }, | ||
| body: JSON.stringify(body) | ||
| }); | ||
| } catch (err) { | ||
| return { | ||
| ok: false, | ||
| error: `baidu unreachable: ${err.message}` | ||
| }; | ||
| } | ||
| const text = await res.text(); | ||
| if (!res.ok) return { | ||
| ok: false, | ||
| error: `baidu HTTP ${res.status}: ${text.slice(0, 200)}` | ||
| }; | ||
| let json; | ||
| try { | ||
| json = JSON.parse(text); | ||
| } catch { | ||
| return { | ||
| ok: false, | ||
| error: `baidu returned non-JSON: ${text.slice(0, 200)}` | ||
| }; | ||
| } | ||
| if (json && typeof json === "object" && "code" in json && json.code) { | ||
| const msg = json.message; | ||
| return { | ||
| ok: false, | ||
| error: `baidu error: ${typeof msg === "string" ? msg : JSON.stringify(msg)}` | ||
| }; | ||
| } | ||
| return { | ||
| ok: true, | ||
| results: parseBaiduJson(json).slice(0, count) | ||
| }; | ||
| } | ||
| function parseBaiduJson(json) { | ||
| if (typeof json !== "object" || json === null) return []; | ||
| const refs = json.references; | ||
| if (!Array.isArray(refs)) return []; | ||
| const out = []; | ||
| for (const r of refs) { | ||
| if (typeof r !== "object" || r === null) continue; | ||
| const row = r; | ||
| const title = typeof row.title === "string" ? row.title : ""; | ||
| const url = typeof row.url === "string" ? row.url : ""; | ||
| const snippetField = typeof row.snippet === "string" ? row.snippet : typeof row.content === "string" ? row.content : ""; | ||
| if (!title || !url) continue; | ||
| out.push(snippetField ? { | ||
| title, | ||
| url, | ||
| snippet: snippetField | ||
| } : { | ||
| title, | ||
| url | ||
| }); | ||
| } | ||
| return out; | ||
| } | ||
| /** Map the Baidu `freshness` shorthand to its `search_filter` JSON. | ||
| * Returns undefined when freshness is absent or malformed (caller | ||
| * omits the filter so Baidu's default time range applies). */ | ||
| function freshnessToFilter(freshness) { | ||
| if (!freshness) return void 0; | ||
| const now = new Date(); | ||
| const dayShift = (n) => { | ||
| const d = new Date(now); | ||
| d.setUTCDate(d.getUTCDate() - n); | ||
| return d.toISOString().slice(0, 10); | ||
| }; | ||
| const tomorrow = (() => { | ||
| const d = new Date(now); | ||
| d.setUTCDate(d.getUTCDate() + 1); | ||
| return d.toISOString().slice(0, 10); | ||
| })(); | ||
| let start; | ||
| let end = tomorrow; | ||
| if (freshness === "pd") start = dayShift(1); | ||
| else if (freshness === "pw") start = dayShift(6); | ||
| else if (freshness === "pm") start = dayShift(30); | ||
| else if (freshness === "py") start = dayShift(364); | ||
| else { | ||
| const m = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/.exec(freshness); | ||
| if (!m) return void 0; | ||
| start = m[1]; | ||
| end = m[2] ?? end; | ||
| } | ||
| return { range: { page_time: { | ||
| gte: start, | ||
| lt: end | ||
| } } }; | ||
| } | ||
| //#endregion | ||
| export { activeProviderFor, mutateToolProviders, readToolProviders, searchBaidu, searchBrave, searchDuckDuckGo }; |
| import { EMPTY_SECRETS, SECRETS_VERSION, getSecret, mutateSecrets, normalizeSecretStore, readSecrets, removeSecret, secretRefs, setSecret, writeSecrets } from "./secrets-evRw4cV3.js"; | ||
| export { readSecrets }; |
| import process from "node:process"; | ||
| import { dirname, join } from "node:path"; | ||
| import { access, chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises"; | ||
| import { homedir } from "node:os"; | ||
| //#region src/core/paths.ts | ||
| const HOME = homedir(); | ||
| /** Resolve the afw home directory. Honors an explicit `AFW_HOME` | ||
| * override; otherwise `~/.afw`. */ | ||
| function resolveHome() { | ||
| return process.env.AFW_HOME ?? join(HOME, ".afw"); | ||
| } | ||
| const AFW_HOME = resolveHome(); | ||
| const paths = { | ||
| home: AFW_HOME, | ||
| wire: { | ||
| dir: join(AFW_HOME, "wire"), | ||
| routes: join(AFW_HOME, "wire", "routes.json"), | ||
| traces: join(AFW_HOME, "wire", "traces"), | ||
| tracesArchive: join(AFW_HOME, "wire", "traces", "archive"), | ||
| daemonSock: join(AFW_HOME, "wire", "daemon.sock"), | ||
| daemonPid: join(AFW_HOME, "wire", "daemon.pid") | ||
| }, | ||
| backups: { | ||
| dir: join(AFW_HOME, "backups"), | ||
| manifest: join(AFW_HOME, "backups", "manifest.json") | ||
| }, | ||
| logs: { | ||
| dir: join(AFW_HOME, "logs"), | ||
| daemon: join(AFW_HOME, "logs", "daemon.log"), | ||
| daemonErr: join(AFW_HOME, "logs", "daemon.err") | ||
| }, | ||
| config: join(AFW_HOME, "config.json"), | ||
| update: join(AFW_HOME, "update.json"), | ||
| models: join(AFW_HOME, "models.json"), | ||
| routing: join(AFW_HOME, "routing.json"), | ||
| secrets: join(AFW_HOME, "secrets.json"), | ||
| oauth: { | ||
| dir: join(AFW_HOME, "oauth"), | ||
| claudeCode: join(AFW_HOME, "oauth", "claude-code.json"), | ||
| codex: join(AFW_HOME, "oauth", "codex.json") | ||
| }, | ||
| keys: join(AFW_HOME, "keys.json"), | ||
| tiers: join(AFW_HOME, "tiers.json"), | ||
| masking: join(AFW_HOME, "masking.json"), | ||
| toolProviders: join(AFW_HOME, "tool-providers.json"), | ||
| agent: { | ||
| claudeCode: { | ||
| settings: join(HOME, ".claude", "settings.json"), | ||
| legacy: join(HOME, ".claude.json") | ||
| }, | ||
| claudeDesktop: { | ||
| root: join(HOME, "Library", "Application Support", "Claude"), | ||
| mcpConfig: join(HOME, "Library", "Application Support", "Claude", "claude_desktop_config.json") | ||
| }, | ||
| openclaw: join(HOME, ".openclaw", "openclaw.json"), | ||
| opencode: join(HOME, ".config", "opencode", "opencode.json"), | ||
| hermes: { | ||
| config: join(HOME, ".hermes", "config.yaml"), | ||
| env: join(HOME, ".hermes", ".env") | ||
| }, | ||
| codex: { | ||
| config: join(HOME, ".codex", "config.toml"), | ||
| auth: join(HOME, ".codex", "auth.json") | ||
| }, | ||
| cursor: { | ||
| darwin: join(HOME, "Library", "Application Support", "Cursor", "User", "settings.json"), | ||
| linux: join(HOME, ".config", "Cursor", "User", "settings.json") | ||
| }, | ||
| gemini: join(HOME, ".gemini", ".env") | ||
| } | ||
| }; | ||
| const PRICING_OVERRIDE = join(AFW_HOME, "pricing.json"); | ||
| const PRICING_CATALOG_CACHE = join(AFW_HOME, "pricing-catalog.json"); | ||
| const DAEMON_PORT = (() => { | ||
| const p = process.env.AFW_PORT; | ||
| return p ? Number.parseInt(p, 10) : 9877; | ||
| })(); | ||
| const DAEMON_HOST = "localhost"; | ||
| const DAEMON_BASE_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}`; | ||
| //#endregion | ||
| //#region src/core/atomic-file.ts | ||
| async function atomicWrite(path, content, opts) { | ||
| await mkdir(dirname(path), { recursive: true }); | ||
| const tmp = `${path}.tmp.${process.pid}`; | ||
| await writeFile(tmp, content); | ||
| if (opts?.mode != null) await chmod(tmp, opts.mode); | ||
| await rename(tmp, path); | ||
| } | ||
| async function fileExists(path) { | ||
| try { | ||
| await access(path); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| //#endregion | ||
| //#region src/core/secrets.ts | ||
| const SECRETS_VERSION = 1; | ||
| const SECRETS_MODE = 384; | ||
| const EMPTY_SECRETS = { | ||
| version: SECRETS_VERSION, | ||
| secrets: {} | ||
| }; | ||
| function getSecret(store, ref) { | ||
| return store.secrets[ref]; | ||
| } | ||
| /** Refs present in the store, for the UI to show which keys are configured | ||
| * (it never receives the values themselves). */ | ||
| function secretRefs(store) { | ||
| return Object.keys(store.secrets); | ||
| } | ||
| function isObj(v) { | ||
| return typeof v === "object" && v !== null && !Array.isArray(v); | ||
| } | ||
| function normalizeSecretStore(raw) { | ||
| if (!isObj(raw)) return { ...EMPTY_SECRETS }; | ||
| if (raw.version !== SECRETS_VERSION) throw new Error(`secrets.json version ${String(raw.version)} not supported (expected ${SECRETS_VERSION})`); | ||
| const secrets = {}; | ||
| if (isObj(raw.secrets)) { | ||
| for (const [ref, value] of Object.entries(raw.secrets)) if (typeof value === "string") secrets[ref] = value; | ||
| } | ||
| return { | ||
| version: SECRETS_VERSION, | ||
| secrets | ||
| }; | ||
| } | ||
| async function readSecrets() { | ||
| if (!await fileExists(paths.secrets)) return { ...EMPTY_SECRETS }; | ||
| return normalizeSecretStore(JSON.parse(await readFile(paths.secrets, "utf8"))); | ||
| } | ||
| async function writeSecrets(store) { | ||
| await atomicWrite(paths.secrets, `${JSON.stringify(store, null, 2)}\n`, { mode: SECRETS_MODE }); | ||
| } | ||
| let writeChain = Promise.resolve(); | ||
| /** Serialized read-modify-write — see model-registry.ts mutateModelRegistry. */ | ||
| function mutateSecrets(fn) { | ||
| const next = writeChain.then(async () => { | ||
| const store = await readSecrets(); | ||
| const updated = fn(store); | ||
| if (updated) await writeSecrets(updated); | ||
| return updated ?? store; | ||
| }); | ||
| writeChain = next.catch(() => {}); | ||
| return next; | ||
| } | ||
| /** Store a secret value under a ref. */ | ||
| function setSecret(ref, value) { | ||
| return mutateSecrets((store) => ({ | ||
| ...store, | ||
| secrets: { | ||
| ...store.secrets, | ||
| [ref]: value | ||
| } | ||
| })); | ||
| } | ||
| /** Remove a secret. No-op if the ref is absent. */ | ||
| function removeSecret(ref) { | ||
| return mutateSecrets((store) => { | ||
| if (!(ref in store.secrets)) return void 0; | ||
| const secrets = { ...store.secrets }; | ||
| delete secrets[ref]; | ||
| return { | ||
| ...store, | ||
| secrets | ||
| }; | ||
| }); | ||
| } | ||
| //#endregion | ||
| export { AFW_HOME, DAEMON_BASE_URL, DAEMON_PORT, EMPTY_SECRETS, PRICING_CATALOG_CACHE, PRICING_OVERRIDE, SECRETS_VERSION, atomicWrite, fileExists, getSecret, mutateSecrets, normalizeSecretStore, paths, readSecrets, removeSecret, secretRefs, setSecret, writeSecrets }; |
Sorry, the diff of this file is too big to display
| #!/usr/bin/env node | ||
| import { getSecret, readSecrets } from "../secrets-Bj-gyv53.js"; | ||
| import { activeProviderFor, readToolProviders, searchBaidu, searchBrave, searchDuckDuckGo } from "../backends-Byh5VYtT.js"; | ||
| import { getSecret, readSecrets } from "../secrets-evRw4cV3.js"; | ||
| import { activeProviderFor, readToolProviders, searchBaidu, searchBrave, searchDuckDuckGo } from "../backends-BmyOv3bJ.js"; | ||
| import process from "node:process"; | ||
@@ -5,0 +5,0 @@ |
+1
-1
| { | ||
| "name": "@openafw/openafw", | ||
| "version": "0.5.2", | ||
| "version": "0.6.0", | ||
| "description": "The local firewall for AI agents: route and repair them, and keep your secrets off the model, the API relay, and the supply chain. Local credential masking, per-route model routing, and security detectors on the wire. Free and fully open source.", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
+2
-2
@@ -1,4 +0,4 @@ | ||
| # afw | ||
| # OpenAFW | ||
| > The local firewall for AI agents: route and repair them, and keep your | ||
| > The AI agent firewall that runs locally on your computer: route and fusion, and keep your | ||
| > secrets off the model, the API relay, and the supply chain. | ||
@@ -5,0 +5,0 @@ |
@@ -7,3 +7,3 @@ <!doctype html> | ||
| <title>afw</title> | ||
| <script type="module" crossorigin src="/assets/index-C9yCeZlD.js"></script> | ||
| <script type="module" crossorigin src="/assets/index-Cja3pO9A.js"></script> | ||
| <link rel="stylesheet" crossorigin href="/assets/index-BY6COSYk.css"> | ||
@@ -10,0 +10,0 @@ </head> |
| import { atomicWrite, fileExists, paths } from "./secrets-Bj-gyv53.js"; | ||
| import { readFile } from "node:fs/promises"; | ||
| //#region src/core/tool-providers.ts | ||
| const TOOL_PROVIDERS_VERSION = 1; | ||
| /** Seeded default — a keyless DuckDuckGo backend so first-run users | ||
| * have a working `web_search` without any setup. Quality is mediocre | ||
| * vs Brave/Tavily but it costs nothing and never asks for an account. */ | ||
| const SEEDED_DDG = { | ||
| id: "ddg", | ||
| label: "DuckDuckGo (built-in)", | ||
| kind: "web_search", | ||
| backend: "duckduckgo", | ||
| origin: "seeded" | ||
| }; | ||
| const EMPTY_STORE = { | ||
| version: TOOL_PROVIDERS_VERSION, | ||
| providers: [SEEDED_DDG], | ||
| active: {} | ||
| }; | ||
| function isObj(v) { | ||
| return typeof v === "object" && v !== null && !Array.isArray(v); | ||
| } | ||
| const KINDS = ["web_search"]; | ||
| const BACKENDS = [ | ||
| "duckduckgo", | ||
| "brave", | ||
| "searxng", | ||
| "tavily", | ||
| "baidu" | ||
| ]; | ||
| function normalizeProvider(raw) { | ||
| if (!isObj(raw)) return void 0; | ||
| if (typeof raw.id !== "string" || raw.id === "") return void 0; | ||
| if (!KINDS.includes(raw.kind)) return void 0; | ||
| if (!BACKENDS.includes(raw.backend)) return void 0; | ||
| return { | ||
| id: raw.id, | ||
| label: typeof raw.label === "string" && raw.label !== "" ? raw.label : raw.id, | ||
| kind: raw.kind, | ||
| backend: raw.backend, | ||
| ...typeof raw.baseUrl === "string" && raw.baseUrl !== "" ? { baseUrl: raw.baseUrl } : {}, | ||
| ...typeof raw.authRef === "string" && raw.authRef !== "" ? { authRef: raw.authRef } : {}, | ||
| ...typeof raw.costPerCall === "number" && raw.costPerCall >= 0 ? { costPerCall: raw.costPerCall } : {}, | ||
| origin: raw.origin === "manual" ? "manual" : "seeded" | ||
| }; | ||
| } | ||
| function normalizeToolProviders(raw) { | ||
| if (!isObj(raw)) return { | ||
| ...EMPTY_STORE, | ||
| providers: [SEEDED_DDG] | ||
| }; | ||
| const providers = Array.isArray(raw.providers) ? raw.providers.map(normalizeProvider).filter((p) => p != null) : []; | ||
| if (!providers.some((p) => p.kind === "web_search")) providers.push(SEEDED_DDG); | ||
| const active = {}; | ||
| if (isObj(raw.active)) for (const k of KINDS) { | ||
| const v = raw.active[k]; | ||
| if (typeof v === "string" && v !== "") active[k] = v; | ||
| } | ||
| return { | ||
| version: TOOL_PROVIDERS_VERSION, | ||
| providers, | ||
| active | ||
| }; | ||
| } | ||
| async function readToolProviders() { | ||
| if (!await fileExists(paths.toolProviders)) return { | ||
| ...EMPTY_STORE, | ||
| providers: [SEEDED_DDG] | ||
| }; | ||
| return normalizeToolProviders(JSON.parse(await readFile(paths.toolProviders, "utf8"))); | ||
| } | ||
| async function writeToolProviders(store) { | ||
| await atomicWrite(paths.toolProviders, `${JSON.stringify(store, null, 2)}\n`); | ||
| } | ||
| let writeChain = Promise.resolve(); | ||
| /** Serialized read-modify-write — see model-registry.ts mutateModelRegistry. */ | ||
| function mutateToolProviders(fn) { | ||
| const next = writeChain.then(async () => { | ||
| const store = await readToolProviders(); | ||
| const updated = fn(store); | ||
| if (updated) await writeToolProviders(updated); | ||
| return updated ?? store; | ||
| }); | ||
| writeChain = next.catch(() => {}); | ||
| return next; | ||
| } | ||
| /** Active provider for a kind — explicit `active[kind]` wins, then | ||
| * the first provider of that kind, then undefined (caller falls back | ||
| * to "no backend, return an error to the tool caller"). */ | ||
| function activeProviderFor(store, kind) { | ||
| const explicit = store.active[kind]; | ||
| if (explicit) { | ||
| const hit = store.providers.find((p) => p.id === explicit && p.kind === kind); | ||
| if (hit) return hit; | ||
| } | ||
| return store.providers.find((p) => p.kind === kind); | ||
| } | ||
| //#endregion | ||
| //#region src/core/web-search/backends.ts | ||
| const DDG_URL = "https://html.duckduckgo.com/html/"; | ||
| const DDG_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"; | ||
| async function searchDuckDuckGo(opts) { | ||
| const form = new URLSearchParams({ q: opts.query }); | ||
| let res; | ||
| try { | ||
| res = await fetch(DDG_URL, { | ||
| method: "POST", | ||
| headers: { | ||
| "content-type": "application/x-www-form-urlencoded", | ||
| "user-agent": DDG_USER_AGENT, | ||
| accept: "text/html" | ||
| }, | ||
| body: form.toString() | ||
| }); | ||
| } catch (err) { | ||
| return { | ||
| ok: false, | ||
| error: `duckduckgo unreachable: ${err.message}` | ||
| }; | ||
| } | ||
| if (!res.ok) return { | ||
| ok: false, | ||
| error: `duckduckgo HTTP ${res.status}` | ||
| }; | ||
| const html = await res.text(); | ||
| if (isDuckDuckGoAnomalyPage(html)) return { | ||
| ok: false, | ||
| error: "DuckDuckGo flagged this request as bot traffic and served a challenge page. This often resolves after a few minutes; for sustained use, switch to a key-based backend (Brave Search API) in Control · Tool Providers." | ||
| }; | ||
| const results = parseDuckDuckGoHtml(html).slice(0, opts.count ?? 10); | ||
| return { | ||
| ok: true, | ||
| results | ||
| }; | ||
| } | ||
| /** True when DDG returned its bot-challenge page instead of search | ||
| * results. The anomaly form's action URL is the strongest signal. */ | ||
| function isDuckDuckGoAnomalyPage(html) { | ||
| return /anomaly\.js/.test(html) || /id="challenge-form"/.test(html); | ||
| } | ||
| /** Parse DDG's HTML results page. Their non-JS endpoint renders each | ||
| * result as a `result__a` anchor + `result__snippet` span. URLs come | ||
| * through a `//duckduckgo.com/l/?uddg=<encoded>` redirector — we | ||
| * decode the inner URL so callers get the real destination. The | ||
| * parser is intentionally a regex scan (no DOM lib) so it works in | ||
| * the daemon's plain node runtime; markup churn risk is the tradeoff. */ | ||
| function parseDuckDuckGoHtml(html) { | ||
| const results = []; | ||
| const anchorRe = /<a[^>]+class="[^"]*\bresult__a\b[^"]*"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g; | ||
| const snippetRe = /<(?:a|span)[^>]+class="[^"]*\bresult__snippet\b[^"]*"[^>]*>([\s\S]*?)<\/(?:a|span)>/g; | ||
| const snippets = []; | ||
| let s; | ||
| while ((s = snippetRe.exec(html)) !== null) snippets.push(stripHtml(s[1] ?? "")); | ||
| let i = 0; | ||
| let m; | ||
| while ((m = anchorRe.exec(html)) !== null) { | ||
| const rawHref = decodeHtmlEntities(m[1] ?? ""); | ||
| const url = unwrapDdgRedirect(rawHref); | ||
| const title = stripHtml(m[2] ?? ""); | ||
| if (!url || !title) { | ||
| i++; | ||
| continue; | ||
| } | ||
| const snippet = snippets[i]; | ||
| results.push(snippet ? { | ||
| title, | ||
| url, | ||
| snippet | ||
| } : { | ||
| title, | ||
| url | ||
| }); | ||
| i++; | ||
| } | ||
| return results; | ||
| } | ||
| function unwrapDdgRedirect(href) { | ||
| const m = /[?&]uddg=([^&]+)/.exec(href); | ||
| if (m?.[1]) try { | ||
| return decodeURIComponent(m[1]); | ||
| } catch { | ||
| return ""; | ||
| } | ||
| if (href.startsWith("//")) return `https:${href}`; | ||
| if (href.startsWith("http")) return href; | ||
| return ""; | ||
| } | ||
| function stripHtml(s) { | ||
| return decodeHtmlEntities(s.replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim(); | ||
| } | ||
| function decodeHtmlEntities(s) { | ||
| return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/'/g, "'").replace(/ /g, " "); | ||
| } | ||
| const BRAVE_URL = "https://api.search.brave.com/res/v1/web/search"; | ||
| async function searchBrave(opts) { | ||
| const url = new URL(BRAVE_URL); | ||
| url.searchParams.set("q", opts.query); | ||
| url.searchParams.set("count", String(opts.count ?? 10)); | ||
| if (opts.locale) url.searchParams.set("country", opts.locale); | ||
| let res; | ||
| try { | ||
| res = await fetch(url, { headers: { | ||
| accept: "application/json", | ||
| "x-subscription-token": opts.apiKey | ||
| } }); | ||
| } catch (err) { | ||
| return { | ||
| ok: false, | ||
| error: `brave unreachable: ${err.message}` | ||
| }; | ||
| } | ||
| const text = await res.text(); | ||
| if (!res.ok) return { | ||
| ok: false, | ||
| error: `brave HTTP ${res.status}: ${text.slice(0, 200)}` | ||
| }; | ||
| let json; | ||
| try { | ||
| json = JSON.parse(text); | ||
| } catch { | ||
| return { | ||
| ok: false, | ||
| error: `brave returned non-JSON: ${text.slice(0, 200)}` | ||
| }; | ||
| } | ||
| return { | ||
| ok: true, | ||
| results: parseBraveJson(json).slice(0, opts.count ?? 10) | ||
| }; | ||
| } | ||
| function parseBraveJson(json) { | ||
| if (typeof json !== "object" || json === null) return []; | ||
| const web = json.web; | ||
| if (!web || !Array.isArray(web.results)) return []; | ||
| const out = []; | ||
| for (const r of web.results) { | ||
| if (typeof r !== "object" || r === null) continue; | ||
| const row = r; | ||
| const title = typeof row.title === "string" ? row.title : ""; | ||
| const url = typeof row.url === "string" ? row.url : ""; | ||
| const description = typeof row.description === "string" ? row.description : ""; | ||
| if (!title || !url) continue; | ||
| out.push(description ? { | ||
| title, | ||
| url, | ||
| snippet: description | ||
| } : { | ||
| title, | ||
| url | ||
| }); | ||
| } | ||
| return out; | ||
| } | ||
| const BAIDU_SMART_URL = "https://qianfan.baidubce.com/v2/ai_search/chat/completions"; | ||
| const BAIDU_WEB_URL = "https://qianfan.baidubce.com/v2/ai_search/web_search"; | ||
| const BAIDU_SMART_MODEL = "ernie-4.5-turbo-32k"; | ||
| async function searchBaidu(opts) { | ||
| const smart = await searchBaiduSmart(opts); | ||
| if (smart.ok) return smart; | ||
| const plain = await searchBaiduWeb(opts); | ||
| if (plain.ok) return plain; | ||
| return { | ||
| ok: false, | ||
| error: `baidu smart-search failed (${smart.error}); plain web_search also failed (${plain.error})` | ||
| }; | ||
| } | ||
| /** Smart search via /v2/ai_search/chat/completions. Carries a model | ||
| * parameter (the LLM summary is discarded by afw — the routed | ||
| * model synthesises its own answer — but the references[] field is | ||
| * the same shape we consume from plain web_search). */ | ||
| async function searchBaiduSmart(opts) { | ||
| const count = Math.max(1, Math.min(20, opts.count ?? 10)); | ||
| const body = { | ||
| messages: [{ | ||
| role: "user", | ||
| content: opts.query | ||
| }], | ||
| model: BAIDU_SMART_MODEL, | ||
| search_source: "baidu_search_v2", | ||
| stream: false, | ||
| resource_type_filter: [{ | ||
| type: "web", | ||
| top_k: count | ||
| }], | ||
| enable_reasoning: false, | ||
| enable_deep_search: false, | ||
| enable_followup_queries: false, | ||
| search_mode: "required" | ||
| }; | ||
| const filter = freshnessToFilter(opts.freshness); | ||
| if (filter) body.search_filter = filter; | ||
| return callBaidu(BAIDU_SMART_URL, opts.apiKey, body, count); | ||
| } | ||
| /** Plain web search via /v2/ai_search/web_search. No model invocation, | ||
| * larger monthly quota — the fallback when smart search refuses. */ | ||
| async function searchBaiduWeb(opts) { | ||
| const count = Math.max(1, Math.min(50, opts.count ?? 10)); | ||
| const body = { | ||
| messages: [{ | ||
| role: "user", | ||
| content: opts.query | ||
| }], | ||
| search_source: "baidu_search_v2", | ||
| resource_type_filter: [{ | ||
| type: "web", | ||
| top_k: count | ||
| }] | ||
| }; | ||
| const filter = freshnessToFilter(opts.freshness); | ||
| if (filter) body.search_filter = filter; | ||
| return callBaidu(BAIDU_WEB_URL, opts.apiKey, body, count); | ||
| } | ||
| async function callBaidu(url, apiKey, body, count) { | ||
| let res; | ||
| try { | ||
| res = await fetch(url, { | ||
| method: "POST", | ||
| headers: { | ||
| "content-type": "application/json", | ||
| accept: "application/json", | ||
| authorization: `Bearer ${apiKey}`, | ||
| "x-appbuilder-from": "afw" | ||
| }, | ||
| body: JSON.stringify(body) | ||
| }); | ||
| } catch (err) { | ||
| return { | ||
| ok: false, | ||
| error: `baidu unreachable: ${err.message}` | ||
| }; | ||
| } | ||
| const text = await res.text(); | ||
| if (!res.ok) return { | ||
| ok: false, | ||
| error: `baidu HTTP ${res.status}: ${text.slice(0, 200)}` | ||
| }; | ||
| let json; | ||
| try { | ||
| json = JSON.parse(text); | ||
| } catch { | ||
| return { | ||
| ok: false, | ||
| error: `baidu returned non-JSON: ${text.slice(0, 200)}` | ||
| }; | ||
| } | ||
| if (json && typeof json === "object" && "code" in json && json.code) { | ||
| const msg = json.message; | ||
| return { | ||
| ok: false, | ||
| error: `baidu error: ${typeof msg === "string" ? msg : JSON.stringify(msg)}` | ||
| }; | ||
| } | ||
| return { | ||
| ok: true, | ||
| results: parseBaiduJson(json).slice(0, count) | ||
| }; | ||
| } | ||
| function parseBaiduJson(json) { | ||
| if (typeof json !== "object" || json === null) return []; | ||
| const refs = json.references; | ||
| if (!Array.isArray(refs)) return []; | ||
| const out = []; | ||
| for (const r of refs) { | ||
| if (typeof r !== "object" || r === null) continue; | ||
| const row = r; | ||
| const title = typeof row.title === "string" ? row.title : ""; | ||
| const url = typeof row.url === "string" ? row.url : ""; | ||
| const snippetField = typeof row.snippet === "string" ? row.snippet : typeof row.content === "string" ? row.content : ""; | ||
| if (!title || !url) continue; | ||
| out.push(snippetField ? { | ||
| title, | ||
| url, | ||
| snippet: snippetField | ||
| } : { | ||
| title, | ||
| url | ||
| }); | ||
| } | ||
| return out; | ||
| } | ||
| /** Map the Baidu `freshness` shorthand to its `search_filter` JSON. | ||
| * Returns undefined when freshness is absent or malformed (caller | ||
| * omits the filter so Baidu's default time range applies). */ | ||
| function freshnessToFilter(freshness) { | ||
| if (!freshness) return void 0; | ||
| const now = new Date(); | ||
| const dayShift = (n) => { | ||
| const d = new Date(now); | ||
| d.setUTCDate(d.getUTCDate() - n); | ||
| return d.toISOString().slice(0, 10); | ||
| }; | ||
| const tomorrow = (() => { | ||
| const d = new Date(now); | ||
| d.setUTCDate(d.getUTCDate() + 1); | ||
| return d.toISOString().slice(0, 10); | ||
| })(); | ||
| let start; | ||
| let end = tomorrow; | ||
| if (freshness === "pd") start = dayShift(1); | ||
| else if (freshness === "pw") start = dayShift(6); | ||
| else if (freshness === "pm") start = dayShift(30); | ||
| else if (freshness === "py") start = dayShift(364); | ||
| else { | ||
| const m = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/.exec(freshness); | ||
| if (!m) return void 0; | ||
| start = m[1]; | ||
| end = m[2] ?? end; | ||
| } | ||
| return { range: { page_time: { | ||
| gte: start, | ||
| lt: end | ||
| } } }; | ||
| } | ||
| //#endregion | ||
| export { activeProviderFor, mutateToolProviders, readToolProviders, searchBaidu, searchBrave, searchDuckDuckGo }; |
| import { EMPTY_SECRETS, SECRETS_VERSION, getSecret, mutateSecrets, normalizeSecretStore, readSecrets, removeSecret, secretRefs, setSecret, writeSecrets } from "./secrets-Bj-gyv53.js"; | ||
| export { readSecrets }; |
| import process from "node:process"; | ||
| import { dirname, join } from "node:path"; | ||
| import { access, chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises"; | ||
| import { homedir } from "node:os"; | ||
| //#region src/core/paths.ts | ||
| const HOME = homedir(); | ||
| /** Resolve the afw home directory. Honors an explicit `AFW_HOME` | ||
| * override; otherwise `~/.afw`. */ | ||
| function resolveHome() { | ||
| return process.env.AFW_HOME ?? join(HOME, ".afw"); | ||
| } | ||
| const AFW_HOME = resolveHome(); | ||
| const paths = { | ||
| home: AFW_HOME, | ||
| wire: { | ||
| dir: join(AFW_HOME, "wire"), | ||
| routes: join(AFW_HOME, "wire", "routes.json"), | ||
| traces: join(AFW_HOME, "wire", "traces"), | ||
| tracesArchive: join(AFW_HOME, "wire", "traces", "archive"), | ||
| daemonSock: join(AFW_HOME, "wire", "daemon.sock"), | ||
| daemonPid: join(AFW_HOME, "wire", "daemon.pid") | ||
| }, | ||
| backups: { | ||
| dir: join(AFW_HOME, "backups"), | ||
| manifest: join(AFW_HOME, "backups", "manifest.json") | ||
| }, | ||
| logs: { | ||
| dir: join(AFW_HOME, "logs"), | ||
| daemon: join(AFW_HOME, "logs", "daemon.log"), | ||
| daemonErr: join(AFW_HOME, "logs", "daemon.err") | ||
| }, | ||
| config: join(AFW_HOME, "config.json"), | ||
| update: join(AFW_HOME, "update.json"), | ||
| models: join(AFW_HOME, "models.json"), | ||
| routing: join(AFW_HOME, "routing.json"), | ||
| secrets: join(AFW_HOME, "secrets.json"), | ||
| keys: join(AFW_HOME, "keys.json"), | ||
| tiers: join(AFW_HOME, "tiers.json"), | ||
| masking: join(AFW_HOME, "masking.json"), | ||
| toolProviders: join(AFW_HOME, "tool-providers.json"), | ||
| agent: { | ||
| claudeCode: { | ||
| settings: join(HOME, ".claude", "settings.json"), | ||
| legacy: join(HOME, ".claude.json") | ||
| }, | ||
| claudeDesktop: { | ||
| root: join(HOME, "Library", "Application Support", "Claude"), | ||
| mcpConfig: join(HOME, "Library", "Application Support", "Claude", "claude_desktop_config.json") | ||
| }, | ||
| openclaw: join(HOME, ".openclaw", "openclaw.json"), | ||
| opencode: join(HOME, ".config", "opencode", "opencode.json"), | ||
| hermes: { | ||
| config: join(HOME, ".hermes", "config.yaml"), | ||
| env: join(HOME, ".hermes", ".env") | ||
| }, | ||
| codex: { | ||
| config: join(HOME, ".codex", "config.toml"), | ||
| auth: join(HOME, ".codex", "auth.json") | ||
| }, | ||
| cursor: { | ||
| darwin: join(HOME, "Library", "Application Support", "Cursor", "User", "settings.json"), | ||
| linux: join(HOME, ".config", "Cursor", "User", "settings.json") | ||
| }, | ||
| gemini: join(HOME, ".gemini", ".env") | ||
| } | ||
| }; | ||
| const PRICING_OVERRIDE = join(AFW_HOME, "pricing.json"); | ||
| const PRICING_CATALOG_CACHE = join(AFW_HOME, "pricing-catalog.json"); | ||
| const DAEMON_PORT = (() => { | ||
| const p = process.env.AFW_PORT; | ||
| return p ? Number.parseInt(p, 10) : 9877; | ||
| })(); | ||
| const DAEMON_HOST = "localhost"; | ||
| const DAEMON_BASE_URL = `http://${DAEMON_HOST}:${DAEMON_PORT}`; | ||
| //#endregion | ||
| //#region src/core/atomic-file.ts | ||
| async function atomicWrite(path, content, opts) { | ||
| await mkdir(dirname(path), { recursive: true }); | ||
| const tmp = `${path}.tmp.${process.pid}`; | ||
| await writeFile(tmp, content); | ||
| if (opts?.mode != null) await chmod(tmp, opts.mode); | ||
| await rename(tmp, path); | ||
| } | ||
| async function fileExists(path) { | ||
| try { | ||
| await access(path); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| //#endregion | ||
| //#region src/core/secrets.ts | ||
| const SECRETS_VERSION = 1; | ||
| const SECRETS_MODE = 384; | ||
| const EMPTY_SECRETS = { | ||
| version: SECRETS_VERSION, | ||
| secrets: {} | ||
| }; | ||
| function getSecret(store, ref) { | ||
| return store.secrets[ref]; | ||
| } | ||
| /** Refs present in the store, for the UI to show which keys are configured | ||
| * (it never receives the values themselves). */ | ||
| function secretRefs(store) { | ||
| return Object.keys(store.secrets); | ||
| } | ||
| function isObj(v) { | ||
| return typeof v === "object" && v !== null && !Array.isArray(v); | ||
| } | ||
| function normalizeSecretStore(raw) { | ||
| if (!isObj(raw)) return { ...EMPTY_SECRETS }; | ||
| if (raw.version !== SECRETS_VERSION) throw new Error(`secrets.json version ${String(raw.version)} not supported (expected ${SECRETS_VERSION})`); | ||
| const secrets = {}; | ||
| if (isObj(raw.secrets)) { | ||
| for (const [ref, value] of Object.entries(raw.secrets)) if (typeof value === "string") secrets[ref] = value; | ||
| } | ||
| return { | ||
| version: SECRETS_VERSION, | ||
| secrets | ||
| }; | ||
| } | ||
| async function readSecrets() { | ||
| if (!await fileExists(paths.secrets)) return { ...EMPTY_SECRETS }; | ||
| return normalizeSecretStore(JSON.parse(await readFile(paths.secrets, "utf8"))); | ||
| } | ||
| async function writeSecrets(store) { | ||
| await atomicWrite(paths.secrets, `${JSON.stringify(store, null, 2)}\n`, { mode: SECRETS_MODE }); | ||
| } | ||
| let writeChain = Promise.resolve(); | ||
| /** Serialized read-modify-write — see model-registry.ts mutateModelRegistry. */ | ||
| function mutateSecrets(fn) { | ||
| const next = writeChain.then(async () => { | ||
| const store = await readSecrets(); | ||
| const updated = fn(store); | ||
| if (updated) await writeSecrets(updated); | ||
| return updated ?? store; | ||
| }); | ||
| writeChain = next.catch(() => {}); | ||
| return next; | ||
| } | ||
| /** Store a secret value under a ref. */ | ||
| function setSecret(ref, value) { | ||
| return mutateSecrets((store) => ({ | ||
| ...store, | ||
| secrets: { | ||
| ...store.secrets, | ||
| [ref]: value | ||
| } | ||
| })); | ||
| } | ||
| /** Remove a secret. No-op if the ref is absent. */ | ||
| function removeSecret(ref) { | ||
| return mutateSecrets((store) => { | ||
| if (!(ref in store.secrets)) return void 0; | ||
| const secrets = { ...store.secrets }; | ||
| delete secrets[ref]; | ||
| return { | ||
| ...store, | ||
| secrets | ||
| }; | ||
| }); | ||
| } | ||
| //#endregion | ||
| export { AFW_HOME, DAEMON_BASE_URL, DAEMON_PORT, EMPTY_SECRETS, PRICING_CATALOG_CACHE, PRICING_OVERRIDE, SECRETS_VERSION, atomicWrite, fileExists, getSecret, mutateSecrets, normalizeSecretStore, paths, readSecrets, removeSecret, secretRefs, setSecret, writeSecrets }; |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
1817061
3.01%48893
3.13%4
-20%