@oracle-agent/oracle 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,168 @@
1
+ // Deterministic, public-only receipts for prepared and executed actions.
2
+ //
3
+ // This module deliberately has no authority and no I/O: it does not authorize,
4
+ // sign, broadcast, or fetch. It only records facts supplied by its caller.
5
+
6
+ import { createHash } from "node:crypto";
7
+
8
+ export const ACTION_RECEIPT_VERSION = 1;
9
+
10
+ const SECRET_KEYS = new Set([
11
+ "privatekey",
12
+ "mnemonic",
13
+ "bearer",
14
+ "authorization",
15
+ "signature",
16
+ ]);
17
+
18
+ export class ActionReceiptSecretError extends Error {
19
+ constructor(path) {
20
+ super(`action receipt refused: secret-bearing field at ${path}`);
21
+ this.name = "ActionReceiptSecretError";
22
+ this.code = "ACTION_RECEIPT_SECRET";
23
+ }
24
+ }
25
+
26
+ function normalizedKey(key) {
27
+ return String(key).replace(/[^a-z0-9]/gi, "").toLowerCase();
28
+ }
29
+
30
+ function isSecretKey(key) {
31
+ const normalized = normalizedKey(key);
32
+ return [...SECRET_KEYS].some((secret) => normalized.includes(secret));
33
+ }
34
+
35
+ function publicValue(value, path = "$") {
36
+ if (value == null || typeof value === "string" || typeof value === "boolean") return value;
37
+ if (typeof value === "number") {
38
+ if (!Number.isFinite(value)) throw new TypeError(`action receipt: non-finite number at ${path}`);
39
+ return value;
40
+ }
41
+ if (typeof value === "bigint") return value.toString();
42
+ if (Array.isArray(value)) return value.map((item, index) => publicValue(item, `${path}[${index}]`));
43
+ if (typeof value !== "object" || Object.getPrototypeOf(value) !== Object.prototype) {
44
+ throw new TypeError(`action receipt: JSON-like value required at ${path}`);
45
+ }
46
+
47
+ const out = {};
48
+ for (const key of Object.keys(value).sort()) {
49
+ const childPath = `${path}.${key}`;
50
+ if (isSecretKey(key)) throw new ActionReceiptSecretError(childPath);
51
+ if (value[key] !== undefined) out[key] = publicValue(value[key], childPath);
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /** Recursively reject secret-bearing fields and return a detached public copy. */
57
+ export function assertNoReceiptSecrets(value) {
58
+ return publicValue(value);
59
+ }
60
+
61
+ /** Canonical JSON with recursively sorted object keys. */
62
+ export function canonicalReceiptJson(value) {
63
+ const safe = publicValue(value);
64
+ if (safe === null || typeof safe !== "object") return JSON.stringify(safe);
65
+ if (Array.isArray(safe)) return `[${safe.map(canonicalReceiptJson).join(",")}]`;
66
+ return `{${Object.keys(safe).map((key) => `${JSON.stringify(key)}:${canonicalReceiptJson(safe[key])}`).join(",")}}`;
67
+ }
68
+
69
+ /** Hash exactly the public receipt fields, excluding the self-referential id. */
70
+ export function computeReceiptId(receipt) {
71
+ if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) {
72
+ throw new TypeError("action receipt: object required");
73
+ }
74
+ const { receiptId: _ignored, summary: _derived, ...publicFields } = receipt;
75
+ return createHash("sha256").update(canonicalReceiptJson(publicFields)).digest("hex");
76
+ }
77
+
78
+ function supplied(input, ...keys) {
79
+ for (const key of keys) if (input[key] !== undefined) return input[key];
80
+ return undefined;
81
+ }
82
+
83
+ function required(input, keys, label) {
84
+ const value = supplied(input, ...keys);
85
+ if (value === undefined || value === null) throw new TypeError(`action receipt: ${label} required`);
86
+ return value;
87
+ }
88
+
89
+ function balanceFields(input) {
90
+ const balances = supplied(input, "balances", "balanceChanges");
91
+ const before = supplied(input, "beforeBalances", "balancesBefore", "before");
92
+ const after = supplied(input, "afterBalances", "balancesAfter", "after");
93
+ if (balances !== undefined) return { balances };
94
+ if (before === undefined && after === undefined) return {};
95
+ return { balances: { ...(before === undefined ? {} : { before }), ...(after === undefined ? {} : { after }) } };
96
+ }
97
+
98
+ /**
99
+ * Normalize caller-supplied prepare/execute facts into the versioned schema.
100
+ * Unknown input fields are intentionally omitted from the receipt.
101
+ */
102
+ export function normalizeActionReceipt(input = {}) {
103
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
104
+ throw new TypeError("action receipt: input object required");
105
+ }
106
+ // Scan the complete input before selecting fields so a secret cannot be
107
+ // hidden in an otherwise unknown property and accidentally appear later.
108
+ publicValue(input);
109
+
110
+ const txHash = supplied(input, "txHash", "transactionHash");
111
+ const phase = supplied(input, "phase", "stage", "resultType") ?? (txHash == null ? "prepare" : "execute");
112
+ if (phase !== "prepare" && phase !== "execute") {
113
+ throw new TypeError('action receipt: phase must be "prepare" or "execute"');
114
+ }
115
+
116
+ const fields = {
117
+ receiptVersion: ACTION_RECEIPT_VERSION,
118
+ phase,
119
+ intent: required(input, ["intent"], "intent"),
120
+ route: required(input, ["route"], "route"),
121
+ decodedAction: required(input, ["decodedAction", "action"], "decodedAction"),
122
+ policyChecks: required(input, ["policyChecks", "checks"], "policyChecks"),
123
+ allowlistHits: required(input, ["allowlistHits", "allowlistMatches"], "allowlistHits"),
124
+ prepareHash: required(input, ["prepareHash"], "prepareHash"),
125
+ ...(txHash == null ? {} : { txHash }),
126
+ ...balanceFields(input),
127
+ };
128
+ const receipt = publicValue(fields);
129
+ return Object.freeze({ ...receipt, receiptId: computeReceiptId(receipt) });
130
+ }
131
+
132
+ export const createActionReceipt = normalizeActionReceipt;
133
+
134
+ function label(value, preferred = []) {
135
+ if (typeof value === "string" || typeof value === "number") return String(value);
136
+ for (const key of preferred) if (value?.[key] != null) return String(value[key]);
137
+ return "recorded";
138
+ }
139
+
140
+ function checkResult(check) {
141
+ if (typeof check === "boolean") return check;
142
+ if (check && typeof check === "object") {
143
+ if (typeof check.ok === "boolean") return check.ok;
144
+ if (typeof check.allowed === "boolean") return check.allowed;
145
+ if (typeof check.passed === "boolean") return check.passed;
146
+ }
147
+ return null;
148
+ }
149
+
150
+ /** Produce a compact human-readable statement without changing the receipt. */
151
+ export function summarizeActionReceipt(receipt) {
152
+ if (!receipt || typeof receipt !== "object") throw new TypeError("action receipt: object required");
153
+ const action = label(receipt.decodedAction, ["type", "action", "method", "name"]);
154
+ const route = label(receipt.route, ["name", "provider", "venue", "id"]);
155
+ const checks = Array.isArray(receipt.policyChecks)
156
+ ? receipt.policyChecks
157
+ : Object.values(receipt.policyChecks || {});
158
+ const passed = checks.filter((check) => checkResult(check) === true).length;
159
+ const failed = checks.filter((check) => checkResult(check) === false).length;
160
+ const hits = Array.isArray(receipt.allowlistHits)
161
+ ? receipt.allowlistHits.length
162
+ : Object.keys(receipt.allowlistHits || {}).length;
163
+ const tx = receipt.txHash == null ? "no transaction hash" : `tx ${receipt.txHash}`;
164
+ const balances = receipt.balances == null ? "balances not provided" : "before/after balances recorded";
165
+ return `${receipt.phase} ${action} via ${route}; policy ${passed} passed, ${failed} failed; ${hits} allowlist hit${hits === 1 ? "" : "s"}; ${tx}; ${balances}; receipt ${receipt.receiptId}`;
166
+ }
167
+
168
+ export const formatActionReceipt = summarizeActionReceipt;
@@ -14,6 +14,47 @@ const FORBIDDEN_KEYS = new Set([
14
14
  "privatekey", "private_key", "secret", "seed", "mnemonic", "passphrase", "signature", "keystore",
15
15
  ]);
16
16
 
17
+ // Exact-name matching missed obvious aliases (secretKey, seedPhrase, wif,
18
+ // keyMaterial, xprv...). Match on a normalized key instead: strip separators,
19
+ // lowercase, then look for any secret-ish token as a substring. A label store
20
+ // has no legitimate field containing these words.
21
+ const FORBIDDEN_TOKENS = [
22
+ "privatekey", "privkey", "secretkey", "secret", "seedphrase", "seed", "mnemonic",
23
+ "passphrase", "password", "keystore", "keymaterial", "signature", "wif",
24
+ "xprv", "xpriv", "apikey", "bearer", "credential",
25
+ ];
26
+
27
+ // Key-shaped values must not be persisted even in a free-text field. WIF, xprv,
28
+ // and BIP39 shapes are unambiguous. A bare 64-hex string is NOT — a raw EVM
29
+ // private key and a bytes32 tx hash are byte-identical — so that rule matches
30
+ // only when the WHOLE field is the hex blob. "paid via tx 0xabc..." is a note,
31
+ // not a leaked key.
32
+ const KEY_SHAPED_VALUE = [
33
+ /\b[5KL][1-9A-HJ-NP-Za-km-z]{50,51}\b/,
34
+ /\bxprv[0-9A-Za-z]{50,}\b/,
35
+ /\b(?:[a-z]{3,8}\s+){11,}[a-z]{3,8}\b/i,
36
+ ];
37
+
38
+ const BARE_32_BYTE_HEX = /^\s*(?:0x)?[0-9a-fA-F]{64}\s*$/;
39
+
40
+ function normalizeKeyName(key) {
41
+ return String(key).toLowerCase().replace(/[^a-z0-9]/g, "");
42
+ }
43
+
44
+ function assertNoSecretKeyName(key, path) {
45
+ const normalized = normalizeKeyName(key);
46
+ if (FORBIDDEN_KEYS.has(String(key).toLowerCase()) || FORBIDDEN_TOKENS.some((t) => normalized.includes(t))) {
47
+ throw new Error(`address-book: ${path} is forbidden; the book stores labels, never key material`);
48
+ }
49
+ }
50
+
51
+ function assertNoSecretValue(value, path) {
52
+ if (typeof value !== "string") return;
53
+ if (KEY_SHAPED_VALUE.some((re) => re.test(value)) || BARE_32_BYTE_HEX.test(value)) {
54
+ throw new Error(`address-book: ${path} looks like key material; the book stores labels, never key material`);
55
+ }
56
+ }
57
+
17
58
  export function addressBookPath() {
18
59
  const override = env("ORACLE_ADDRESS_BOOK", "MAD_ADDRESS_BOOK", "");
19
60
  if (override) return path.resolve(override);
@@ -21,11 +62,18 @@ export function addressBookPath() {
21
62
  return path.join(dir, "address-book.json");
22
63
  }
23
64
 
24
- function assertNoSecrets(input) {
25
- for (const key of Object.keys(input || {})) {
26
- if (FORBIDDEN_KEYS.has(key.toLowerCase())) {
27
- throw new Error(`address-book: ${key} is forbidden; the book stores labels, never key material`);
28
- }
65
+ function assertNoSecrets(input, path = "field") {
66
+ if (Array.isArray(input)) {
67
+ input.forEach((item, i) => assertNoSecrets(item, `${path}[${i}]`));
68
+ return;
69
+ }
70
+ if (!input || typeof input !== "object") {
71
+ assertNoSecretValue(input, path);
72
+ return;
73
+ }
74
+ for (const [key, value] of Object.entries(input)) {
75
+ assertNoSecretKeyName(key, key);
76
+ assertNoSecrets(value, key);
29
77
  }
30
78
  }
31
79
 
package/src/data/http.mjs CHANGED
@@ -37,6 +37,35 @@ const IDENTITY_HEADERS = [
37
37
  "proxy-authorization",
38
38
  ];
39
39
 
40
+ const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]);
41
+ const MAX_REDIRECTS = 3;
42
+
43
+ function credentialHeaderName(name) {
44
+ const h = String(name || "").toLowerCase();
45
+ return IDENTITY_HEADERS.includes(h) || /(^|-)(api[-_]?key|key|token|secret|auth|authorization|session|cookie|signature|access)(-|$)/.test(h);
46
+ }
47
+
48
+ function stripCredentialHeaders(headers) {
49
+ const safe = {};
50
+ for (const [name, value] of Object.entries(headers || {})) {
51
+ if (!credentialHeaderName(name)) safe[name] = value;
52
+ }
53
+ return safe;
54
+ }
55
+
56
+ function redirectedUrl(location, currentUrl) {
57
+ if (!location) throw new Error(`HTTP redirect from ${currentUrl} missing Location`);
58
+ const next = new URL(String(location), String(currentUrl));
59
+ if (next.protocol !== "http:" && next.protocol !== "https:") {
60
+ throw new Error(`HTTP redirect from ${currentUrl} used unsupported protocol ${next.protocol}`);
61
+ }
62
+ return next.toString();
63
+ }
64
+
65
+ function sameOrigin(a, b) {
66
+ return new URL(String(a)).origin === new URL(String(b)).origin;
67
+ }
68
+
40
69
  const inflight = new Map();
41
70
 
42
71
  /**
@@ -77,24 +106,48 @@ async function once(url, { fetchImpl, method, headers, body, timeoutMs }) {
77
106
  const ac = new AbortController();
78
107
  const t = setTimeout(() => ac.abort(), timeoutMs);
79
108
  try {
80
- const res = await fetchImpl(url, { method, headers, body, signal: ac.signal });
81
- const text = await res.text();
82
- let json = null;
83
- if (text) {
84
- try {
85
- json = JSON.parse(text);
86
- } catch {
87
- json = null;
109
+ let currentUrl = String(url);
110
+ let currentHeaders = { ...(headers || {}) };
111
+ for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
112
+ const res = await fetchImpl(currentUrl, {
113
+ method,
114
+ headers: currentHeaders,
115
+ body,
116
+ signal: ac.signal,
117
+ redirect: "manual",
118
+ });
119
+ const text = await res.text();
120
+ let json = null;
121
+ if (text) {
122
+ try {
123
+ json = JSON.parse(text);
124
+ } catch {
125
+ json = null;
126
+ }
88
127
  }
128
+ if (REDIRECT_STATUS.has(res.status)) {
129
+ if (method !== "GET" && method !== "HEAD") {
130
+ const err = new Error(`HTTP ${res.status} ${method} ${currentUrl} redirected non-idempotent request`);
131
+ err.status = res.status;
132
+ err.body = json ?? text.slice(0, 300);
133
+ throw err;
134
+ }
135
+ if (redirects === MAX_REDIRECTS) throw new Error(`HTTP redirect loop for ${url}`);
136
+ const nextUrl = redirectedUrl(res.headers?.get?.("location"), currentUrl);
137
+ if (!sameOrigin(nextUrl, currentUrl)) currentHeaders = stripCredentialHeaders(currentHeaders);
138
+ currentUrl = nextUrl;
139
+ continue;
140
+ }
141
+ if (!res.ok) {
142
+ const err = new Error(`HTTP ${res.status} ${method} ${currentUrl}`);
143
+ err.status = res.status;
144
+ err.retryAfter = res.headers?.get?.("retry-after") ?? null;
145
+ err.body = json ?? text.slice(0, 300);
146
+ throw err;
147
+ }
148
+ return json ?? text;
89
149
  }
90
- if (!res.ok) {
91
- const err = new Error(`HTTP ${res.status} ${method} ${url}`);
92
- err.status = res.status;
93
- err.retryAfter = res.headers?.get?.("retry-after") ?? null;
94
- err.body = json ?? text.slice(0, 300);
95
- throw err;
96
- }
97
- return json ?? text;
150
+ throw new Error(`HTTP redirect loop for ${url}`);
98
151
  } finally {
99
152
  clearTimeout(t);
100
153
  }
@@ -90,17 +90,33 @@ function lamports(sol) {
90
90
  }
91
91
 
92
92
  function listingExpiry(value) {
93
- if (value == null || value === "") return null;
93
+ if (value == null || value === "") throw new Error("magiceden: listing expiry is required and must be in the future");
94
94
  const expiry = Number(value);
95
- if (!Number.isSafeInteger(expiry) || expiry < 0) {
96
- throw new Error("magiceden: expiry must be a whole Unix timestamp in seconds or 0");
95
+ if (!Number.isSafeInteger(expiry) || expiry <= 0) {
96
+ throw new Error("magiceden: expiry must be a future Unix timestamp in seconds");
97
97
  }
98
- if (expiry !== 0 && expiry <= Math.floor(Date.now() / 1000)) {
99
- throw new Error("magiceden: expiry must be in the future or 0 for no expiry");
98
+ if (expiry <= Math.floor(Date.now() / 1000)) {
99
+ throw new Error("magiceden: expiry must be in the future");
100
100
  }
101
101
  return expiry;
102
102
  }
103
103
 
104
+ function listConfirmation({ tokenMint, priceSol, expiry }) {
105
+ return `list ${tokenMint} on magiceden-sol for ${priceSol} SOL until ${expiry}`;
106
+ }
107
+
108
+ function requireListingConfirmation(args, fields) {
109
+ if (args.userConfirmed !== true) {
110
+ throw new Error("magiceden: listing requires userConfirmed=true after reviewing tokenMint, priceSol, and expiry");
111
+ }
112
+ const expected = listConfirmation(fields);
113
+ const actual = String(args.confirmation || args.confirmationPhrase || "").trim();
114
+ if (actual !== expected) {
115
+ throw new Error(`magiceden: confirmation must equal ${JSON.stringify(expected)}`);
116
+ }
117
+ return expected;
118
+ }
119
+
104
120
  export async function magicEdenSolHealth(opts = {}) {
105
121
  try {
106
122
  const stats = await magicEdenSolStats({ symbol: "mad_lads" }, opts);
@@ -269,6 +285,7 @@ export async function magicEdenSolPrepareList(args = {}, opts = {}) {
269
285
  const auctionHouse = solanaPubkey(args.auctionHouse, "auctionHouse");
270
286
  const priceSol = positiveNumber(args.priceSol ?? args.price, "priceSol");
271
287
  const expiry = listingExpiry(args.expiry);
288
+ const confirmation = requireListingConfirmation(args, { tokenMint, priceSol, expiry });
272
289
  const url = new URL(`${base(opts)}/instructions/sell`);
273
290
  url.searchParams.set("seller", seller);
274
291
  url.searchParams.set("auctionHouseAddress", auctionHouse);
@@ -301,6 +318,7 @@ export async function magicEdenSolPrepareList(args = {}, opts = {}) {
301
318
  tokenMint,
302
319
  priceSol,
303
320
  expiry,
321
+ confirmation,
304
322
  transaction,
305
323
  transactionEncoding: "base64",
306
324
  raw,
@@ -419,6 +419,12 @@ export function enforceTxPolicy(tx = {}, phase = "broadcast") {
419
419
  // Spending caps toggle. Set MAD_VALUE_CAPS_ENABLED=0 in exec.env to lift the
420
420
  // per-tx + daily native caps (chain/dest allowlists still enforced). Flip back
421
421
  // to 1 to re-arm the cap wall. Default = ON when unset.
422
+ //
423
+ // Deliberately strict: ONLY the literal "0" lifts the caps. This is the
424
+ // opposite polarity from a kill switch. Disabling a kill switch makes a
425
+ // deployment safer, so it accepts 0/false/off/no. Disabling THIS makes a
426
+ // deployment riskier, so a stray "false" must keep the cap wall standing
427
+ // rather than silently removing it. Do not route this through envEnabled.
422
428
  const capsOn = String(env("ORACLE_VALUE_CAPS_ENABLED", "MAD_VALUE_CAPS_ENABLED", "1")).trim() !== "0";
423
429
  if (!capsOn) return true;
424
430
 
package/src/index.mjs CHANGED
@@ -67,3 +67,27 @@ export {
67
67
  DEFAULT_SCOPES,
68
68
  normalizeScopes,
69
69
  } from "./scopes.mjs";
70
+
71
+ export { emitHarnessConfigs, createHarnessConfigs } from "./onboarding/harness-configs.mjs";
72
+ export {
73
+ ACTION_RECEIPT_VERSION,
74
+ ActionReceiptSecretError,
75
+ assertNoReceiptSecrets,
76
+ canonicalReceiptJson,
77
+ computeReceiptId,
78
+ normalizeActionReceipt,
79
+ createActionReceipt,
80
+ summarizeActionReceipt,
81
+ formatActionReceipt,
82
+ } from "./action-receipts.mjs";
83
+ export { DEFAULT_RISK_THRESHOLDS, summarizePortfolioRisk, portfolioRiskSummary } from "./portfolio-risk.mjs";
84
+ export {
85
+ WATCH_CATEGORIES,
86
+ defaultPreferences,
87
+ subscribe,
88
+ unsubscribe,
89
+ createWatch,
90
+ evaluateAlert,
91
+ shouldDeliverAlert,
92
+ } from "./watch-preferences.mjs";
93
+ export { SIGNAL_TYPES, scoreSignals, createSignalsEngine } from "./signals/index.mjs";
@@ -0,0 +1,102 @@
1
+ const PACKAGE = "@oracle-agent/oracle";
2
+ const COMMAND = "npx";
3
+ const ARGS = ["-y", "--package", PACKAGE, "oracle-data-mcp"];
4
+ const FORBIDDEN_NAME = /(?:^|_)(?:PRIVATE_KEY|SECRET|TOKEN|PASSWORD|PASSPHRASE|MNEMONIC|SEED)(?:_|$)/i;
5
+
6
+ function requiredText(value, name) {
7
+ if (typeof value !== "string" || !value.trim()) throw new TypeError(`${name} must be a non-empty string`);
8
+ if (/\r|\n|\0/.test(value)) throw new TypeError(`${name} must be a single-line string`);
9
+ return value.trim();
10
+ }
11
+
12
+ function validateUrl(value) {
13
+ const text = requiredText(value, "url");
14
+ let parsed;
15
+ try { parsed = new URL(text); } catch { throw new TypeError("url must be a valid http(s) URL"); }
16
+ if (!/^https?:$/.test(parsed.protocol) || parsed.username || parsed.password) {
17
+ throw new TypeError("url must be a credential-free http(s) URL");
18
+ }
19
+ for (const name of parsed.searchParams.keys()) {
20
+ if (FORBIDDEN_NAME.test(name) || /^(?:key|api[_-]?key|auth)$/i.test(name)) {
21
+ throw new TypeError(`url must not contain secret-shaped query parameter: ${name}`);
22
+ }
23
+ }
24
+ return parsed.href;
25
+ }
26
+
27
+ function validateKey(value) {
28
+ const text = requiredText(value, "key");
29
+ if (/^[A-Z][A-Z0-9_]*\s*=/.test(text) || /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(text)) {
30
+ throw new TypeError("key must be an Oracle agent key, not an environment assignment or private key");
31
+ }
32
+ if (/^(?:0x)?[0-9a-f]{64}$/i.test(text) || text.trim().split(/\s+/).length >= 12) {
33
+ throw new TypeError("key looks like private signing material");
34
+ }
35
+ return text;
36
+ }
37
+
38
+ function validateLabel(value) {
39
+ const label = requiredText(value, "label");
40
+ if (!/^[a-z0-9][a-z0-9_-]{0,62}$/i.test(label)) {
41
+ throw new TypeError("label must contain only letters, digits, underscores, and hyphens");
42
+ }
43
+ if (FORBIDDEN_NAME.test(label)) throw new TypeError("label must not be a secret-shaped environment name");
44
+ return label;
45
+ }
46
+
47
+ function json(value) {
48
+ return `${JSON.stringify(value, null, 2)}\n`;
49
+ }
50
+
51
+ function yamlString(value) {
52
+ return JSON.stringify(value);
53
+ }
54
+
55
+ function tomlString(value) {
56
+ return JSON.stringify(value);
57
+ }
58
+
59
+ /**
60
+ * Return deterministic, copy-pasteable MCP configuration snippets.
61
+ * This function performs no I/O and never reads from process.env.
62
+ */
63
+ export function emitHarnessConfigs({ url, key, label = "oracle-data" } = {}) {
64
+ const endpoint = validateUrl(url);
65
+ const agentKey = validateKey(key);
66
+ const name = validateLabel(label);
67
+ const server = {
68
+ command: COMMAND,
69
+ args: [...ARGS],
70
+ env: { ORACLE_DATA_URL: endpoint, ORACLE_AGENT_KEY: agentKey },
71
+ };
72
+ const mcp = { mcpServers: { [name]: server } };
73
+
74
+ return {
75
+ hermes: [
76
+ "mcp_servers:",
77
+ ` ${name}:`,
78
+ ` command: ${yamlString(COMMAND)}`,
79
+ " args:",
80
+ ...ARGS.map((arg) => ` - ${yamlString(arg)}`),
81
+ " env:",
82
+ ` ORACLE_DATA_URL: ${yamlString(endpoint)}`,
83
+ ` ORACLE_AGENT_KEY: ${yamlString(agentKey)}`,
84
+ "",
85
+ ].join("\n"),
86
+ claudeCode: json(mcp),
87
+ codex: [
88
+ `[mcp_servers.${tomlString(name)}]`,
89
+ `command = ${tomlString(COMMAND)}`,
90
+ `args = [${ARGS.map(tomlString).join(", ")}]`,
91
+ "",
92
+ `[mcp_servers.${tomlString(name)}.env]`,
93
+ `ORACLE_DATA_URL = ${tomlString(endpoint)}`,
94
+ `ORACLE_AGENT_KEY = ${tomlString(agentKey)}`,
95
+ "",
96
+ ].join("\n"),
97
+ cursor: json(mcp),
98
+ genericMcp: json(mcp),
99
+ };
100
+ }
101
+
102
+ export const createHarnessConfigs = emitHarnessConfigs;
@@ -20,9 +20,18 @@ export function envFlag(primary, legacy, defaultValue = false) {
20
20
  return String(env(primary, legacy, fallback)).trim() === "1";
21
21
  }
22
22
 
23
+ const DISABLE_WORDS = ["0", "false", "off", "no"];
24
+
25
+ // Reading a flag that may default ON. Only an explicit disable word turns it
26
+ // off, and an unset value keeps `defaultValue`.
27
+ //
28
+ // The old shape (`!== "0"`) inverted with a false default: ORACLE_ONBOARD_HTTP
29
+ // defaults off, so a user writing `false` to keep it off actually ENABLED it,
30
+ // because "false" is not "0". A disable word must never enable a surface.
23
31
  export function envEnabled(primary, legacy, defaultValue = true) {
24
- const fallback = defaultValue ? "1" : "0";
25
- return String(env(primary, legacy, fallback)).trim() !== "0";
32
+ const raw = String(env(primary, legacy, "")).trim().toLowerCase();
33
+ if (raw === "") return defaultValue;
34
+ return !DISABLE_WORDS.includes(raw);
26
35
  }
27
36
 
28
37
  export function csvEnv(primary, legacy) {