@fiscalmindset/blindfold 0.4.0
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.
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/assets/SKILL.md +205 -0
- package/assets/blindfold_proxy.wasm +0 -0
- package/bin/blindfold.ts +90 -0
- package/bin/cli-shared.ts +72 -0
- package/bin/cmd-auth.ts +193 -0
- package/bin/cmd-enclave.ts +373 -0
- package/bin/cmd-lifecycle.ts +151 -0
- package/bin/cmd-secrets.ts +136 -0
- package/bin/cmd-serve.ts +165 -0
- package/bin/cmd-tenant.ts +84 -0
- package/dist/cli.mjs +4317 -0
- package/dist/lib/index.mjs +2362 -0
- package/dist/lib/proxy.mjs +1078 -0
- package/dist/lib/register.mjs +756 -0
- package/dist/lib/wrap.mjs +81 -0
- package/package.json +48 -0
- package/postinstall.mjs +21 -0
- package/src/attest.ts +235 -0
- package/src/color.ts +31 -0
- package/src/compat.ts +217 -0
- package/src/constants.ts +24 -0
- package/src/dashboard.ts +785 -0
- package/src/env.ts +261 -0
- package/src/index.ts +11 -0
- package/src/init.ts +391 -0
- package/src/keychain.ts +155 -0
- package/src/log.ts +51 -0
- package/src/migrate.ts +135 -0
- package/src/prompt.ts +114 -0
- package/src/providers.ts +224 -0
- package/src/proxy.ts +454 -0
- package/src/register.ts +81 -0
- package/src/release.ts +82 -0
- package/src/sealed-ledger.ts +158 -0
- package/src/t3-client.ts +580 -0
- package/src/types.ts +50 -0
- package/src/usage-log.ts +149 -0
- package/src/versions.ts +64 -0
- package/src/wrap.ts +122 -0
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
// src/proxy.ts
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import fs4 from "node:fs";
|
|
5
|
+
import net from "node:net";
|
|
6
|
+
|
|
7
|
+
// src/constants.ts
|
|
8
|
+
var SENTINEL = "__BLINDFOLD__";
|
|
9
|
+
var CONTRACT_TAIL = "blindfold-proxy";
|
|
10
|
+
var CONTRACT_VERSION = "0.5.6";
|
|
11
|
+
|
|
12
|
+
// src/env.ts
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
// src/keychain.ts
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
var SERVICE = "blindfold";
|
|
21
|
+
var isWin = process.platform === "win32";
|
|
22
|
+
function has(cmd) {
|
|
23
|
+
const finder = isWin ? "where" : "which";
|
|
24
|
+
const r = spawnSync(finder, [cmd], { stdio: "ignore" });
|
|
25
|
+
return r.status === 0;
|
|
26
|
+
}
|
|
27
|
+
var WIN_CS = `
|
|
28
|
+
using System;
|
|
29
|
+
using System.Runtime.InteropServices;
|
|
30
|
+
public static class BFCred {
|
|
31
|
+
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)]
|
|
32
|
+
public struct CREDENTIAL {
|
|
33
|
+
public UInt32 Flags; public UInt32 Type;
|
|
34
|
+
[MarshalAs(UnmanagedType.LPWStr)] public string TargetName;
|
|
35
|
+
[MarshalAs(UnmanagedType.LPWStr)] public string Comment;
|
|
36
|
+
public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
|
|
37
|
+
public UInt32 CredentialBlobSize; public IntPtr CredentialBlob; public UInt32 Persist;
|
|
38
|
+
public UInt32 AttributeCount; public IntPtr Attributes;
|
|
39
|
+
[MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias;
|
|
40
|
+
[MarshalAs(UnmanagedType.LPWStr)] public string UserName;
|
|
41
|
+
}
|
|
42
|
+
[DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)] public static extern bool CredWriteW(ref CREDENTIAL c, UInt32 f);
|
|
43
|
+
[DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)] public static extern bool CredReadW(string t, UInt32 ty, UInt32 f, out IntPtr c);
|
|
44
|
+
[DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)] public static extern bool CredDeleteW(string t, UInt32 ty, UInt32 f);
|
|
45
|
+
[DllImport("advapi32.dll")] public static extern void CredFree(IntPtr c);
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
var PS_HEADER = `$ErrorActionPreference='Stop'
|
|
49
|
+
Add-Type -TypeDefinition @"
|
|
50
|
+
${WIN_CS}
|
|
51
|
+
"@
|
|
52
|
+
`;
|
|
53
|
+
function winPS(op, target, secret) {
|
|
54
|
+
let body;
|
|
55
|
+
if (op === "set") {
|
|
56
|
+
body = `$b=[System.Text.Encoding]::UTF8.GetBytes($env:BF_SECRET);$p=[Runtime.InteropServices.Marshal]::AllocHGlobal($b.Length);[Runtime.InteropServices.Marshal]::Copy($b,0,$p,$b.Length);$c=New-Object BFCred+CREDENTIAL;$c.Type=1;$c.TargetName=$env:BF_TARGET;$c.UserName=$env:BF_TARGET;$c.CredentialBlobSize=$b.Length;$c.CredentialBlob=$p;$c.Persist=2;$ok=[BFCred]::CredWriteW([ref]$c,0);[Runtime.InteropServices.Marshal]::FreeHGlobal($p);if($ok){[Console]::Out.Write('BFOK')}`;
|
|
57
|
+
} else if (op === "get") {
|
|
58
|
+
body = `$ptr=[IntPtr]::Zero;if([BFCred]::CredReadW($env:BF_TARGET,1,0,[ref]$ptr)){$c=[Runtime.InteropServices.Marshal]::PtrToStructure($ptr,[BFCred+CREDENTIAL]);$n=[int]$c.CredentialBlobSize;$b=New-Object byte[] $n;[Runtime.InteropServices.Marshal]::Copy([IntPtr]$c.CredentialBlob,$b,0,$n);[BFCred]::CredFree($ptr);[Console]::Out.Write([System.Text.Encoding]::UTF8.GetString($b))}else{exit 1}`;
|
|
59
|
+
} else {
|
|
60
|
+
body = `if([BFCred]::CredDeleteW($env:BF_TARGET,1,0)){[Console]::Out.Write('BFOK')}`;
|
|
61
|
+
}
|
|
62
|
+
const env = { ...process.env, BF_TARGET: target };
|
|
63
|
+
if (secret !== void 0) env.BF_SECRET = secret;
|
|
64
|
+
const r = spawnSync(
|
|
65
|
+
"powershell",
|
|
66
|
+
["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "-"],
|
|
67
|
+
{ input: PS_HEADER + body, env, encoding: "utf8" }
|
|
68
|
+
);
|
|
69
|
+
return { status: r.status, stdout: typeof r.stdout === "string" ? r.stdout : "" };
|
|
70
|
+
}
|
|
71
|
+
function winTarget(account) {
|
|
72
|
+
return `${SERVICE}:${account}`;
|
|
73
|
+
}
|
|
74
|
+
function keychainAvailable() {
|
|
75
|
+
if (process.platform === "darwin") return has("security");
|
|
76
|
+
if (process.platform === "linux") return has("secret-tool");
|
|
77
|
+
if (isWin) return has("powershell");
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
function keychainGet(account) {
|
|
81
|
+
if (process.platform === "darwin") {
|
|
82
|
+
const r = spawnSync("security", ["find-generic-password", "-a", account, "-s", SERVICE, "-w"], { encoding: "utf8" });
|
|
83
|
+
if (r.status === 0 && typeof r.stdout === "string") return r.stdout.replace(/\n$/, "");
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (process.platform === "linux") {
|
|
87
|
+
const r = spawnSync("secret-tool", ["lookup", "service", SERVICE, "account", account], { encoding: "utf8" });
|
|
88
|
+
if (r.status === 0 && r.stdout) return r.stdout.replace(/\n$/, "");
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (isWin) {
|
|
92
|
+
const r = winPS("get", winTarget(account));
|
|
93
|
+
if (r.status === 0 && r.stdout) return r.stdout.replace(/\r?\n$/, "");
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/env.ts
|
|
100
|
+
var HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
101
|
+
var SRC_RELATIVE_ROOT = path.resolve(HERE, "..", "..", "..");
|
|
102
|
+
var _repoRoot = null;
|
|
103
|
+
function repoRoot() {
|
|
104
|
+
if (_repoRoot) return _repoRoot;
|
|
105
|
+
let dir = process.cwd();
|
|
106
|
+
for (let i = 0; i < 12; i++) {
|
|
107
|
+
if (fs.existsSync(path.join(dir, ".env")) || fs.existsSync(path.join(dir, ".blindfold")) || fs.existsSync(path.join(dir, ".git"))) {
|
|
108
|
+
_repoRoot = dir;
|
|
109
|
+
return dir;
|
|
110
|
+
}
|
|
111
|
+
const parent = path.dirname(dir);
|
|
112
|
+
if (parent === dir) break;
|
|
113
|
+
dir = parent;
|
|
114
|
+
}
|
|
115
|
+
_repoRoot = SRC_RELATIVE_ROOT;
|
|
116
|
+
return _repoRoot;
|
|
117
|
+
}
|
|
118
|
+
function homeDir() {
|
|
119
|
+
return path.join(os.homedir(), ".blindfold");
|
|
120
|
+
}
|
|
121
|
+
function configPath() {
|
|
122
|
+
return path.join(homeDir(), "config.json");
|
|
123
|
+
}
|
|
124
|
+
function stateDir() {
|
|
125
|
+
const override = process.env.BLINDFOLD_STATE_DIR;
|
|
126
|
+
if (override && override.trim()) return path.resolve(override.trim());
|
|
127
|
+
const home = homeDir();
|
|
128
|
+
if (!fs.existsSync(home)) {
|
|
129
|
+
try {
|
|
130
|
+
const legacy = path.join(repoRoot(), ".blindfold");
|
|
131
|
+
if (fs.existsSync(legacy) && fs.statSync(legacy).isDirectory()) {
|
|
132
|
+
fs.cpSync(legacy, home, { recursive: true });
|
|
133
|
+
} else {
|
|
134
|
+
fs.mkdirSync(home, { recursive: true });
|
|
135
|
+
}
|
|
136
|
+
} catch {
|
|
137
|
+
try {
|
|
138
|
+
fs.mkdirSync(home, { recursive: true });
|
|
139
|
+
} catch {
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return home;
|
|
144
|
+
}
|
|
145
|
+
function assertSafeOverrideUrl(raw, label) {
|
|
146
|
+
let u;
|
|
147
|
+
try {
|
|
148
|
+
u = new URL(raw);
|
|
149
|
+
} catch {
|
|
150
|
+
throw new Error(`${label} is not a valid URL: ${raw}`);
|
|
151
|
+
}
|
|
152
|
+
const isLocal = ["localhost", "127.0.0.1", "::1", "[::1]"].includes(u.hostname);
|
|
153
|
+
if (u.protocol !== "https:" && !isLocal) {
|
|
154
|
+
throw new Error(`${label} must be https (got ${u.protocol}//${u.hostname}); refusing to trust an insecure endpoint`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function withFileLockSync(target, fn) {
|
|
158
|
+
const lockPath = `${target}.lock`;
|
|
159
|
+
const deadline = Date.now() + 1e4;
|
|
160
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
161
|
+
const sleep = (ms) => {
|
|
162
|
+
try {
|
|
163
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
164
|
+
} catch {
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
for (; ; ) {
|
|
168
|
+
try {
|
|
169
|
+
const fd = fs.openSync(lockPath, "wx");
|
|
170
|
+
fs.writeSync(fd, String(process.pid));
|
|
171
|
+
fs.closeSync(fd);
|
|
172
|
+
break;
|
|
173
|
+
} catch (e) {
|
|
174
|
+
if (e?.code !== "EEXIST") throw e;
|
|
175
|
+
try {
|
|
176
|
+
const st = fs.statSync(lockPath);
|
|
177
|
+
if (Date.now() - st.mtimeMs > 1e4) {
|
|
178
|
+
fs.rmSync(lockPath, { force: true });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
}
|
|
183
|
+
if (Date.now() > deadline) throw new Error(`timed out acquiring lock ${lockPath}`);
|
|
184
|
+
sleep(25);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
return fn();
|
|
189
|
+
} finally {
|
|
190
|
+
fs.rmSync(lockPath, { force: true });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
function loadEnvFromFile(envPath = path.join(repoRoot(), ".env")) {
|
|
194
|
+
if (!fs.existsSync(envPath)) return;
|
|
195
|
+
const text = fs.readFileSync(envPath, "utf8");
|
|
196
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
197
|
+
const line = raw.trim();
|
|
198
|
+
if (!line || line.startsWith("#")) continue;
|
|
199
|
+
const eq = line.indexOf("=");
|
|
200
|
+
if (eq <= 0) continue;
|
|
201
|
+
const key = line.slice(0, eq).trim();
|
|
202
|
+
let val = line.slice(eq + 1).trim();
|
|
203
|
+
if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) {
|
|
204
|
+
val = val.slice(1, -1);
|
|
205
|
+
}
|
|
206
|
+
if (process.env[key] === void 0) process.env[key] = val;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function loadHomeConfig() {
|
|
210
|
+
try {
|
|
211
|
+
const cfg = configPath();
|
|
212
|
+
if (fs.existsSync(cfg)) {
|
|
213
|
+
const obj = JSON.parse(fs.readFileSync(cfg, "utf8"));
|
|
214
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
215
|
+
if (k === "T3N_API_KEY_STORE") continue;
|
|
216
|
+
if (typeof v === "string" && process.env[k] === void 0) process.env[k] = v;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
}
|
|
221
|
+
loadEnvFromFile(path.join(homeDir(), ".env"));
|
|
222
|
+
if (process.env.T3N_API_KEY === void 0 && process.env.DID && keychainAvailable()) {
|
|
223
|
+
const k = keychainGet(process.env.DID);
|
|
224
|
+
if (k) process.env.T3N_API_KEY = k;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function loadBlindfoldEnv() {
|
|
228
|
+
loadEnvFromFile();
|
|
229
|
+
loadHomeConfig();
|
|
230
|
+
const t3nApiKey = process.env.T3N_API_KEY ?? "";
|
|
231
|
+
const did = process.env.DID ?? "";
|
|
232
|
+
const port = Number.parseInt(process.env.BLINDFOLD_PORT ?? "8787", 10);
|
|
233
|
+
const t3EnvRaw = (process.env.BLINDFOLD_T3_ENV ?? "testnet").toLowerCase();
|
|
234
|
+
const t3Env = t3EnvRaw === "production" ? "production" : "testnet";
|
|
235
|
+
const t3BaseUrl = (process.env.T3_BASE_URL ?? process.env.BLINDFOLD_BASE_URL ?? "").trim();
|
|
236
|
+
if (t3BaseUrl) {
|
|
237
|
+
assertSafeOverrideUrl(t3BaseUrl, process.env.T3_BASE_URL ? "T3_BASE_URL" : "BLINDFOLD_BASE_URL");
|
|
238
|
+
}
|
|
239
|
+
const mock = process.env.BLINDFOLD_MOCK === "1";
|
|
240
|
+
return { t3nApiKey, did, port, t3Env, t3BaseUrl, mock };
|
|
241
|
+
}
|
|
242
|
+
function assertRealReady(env) {
|
|
243
|
+
if (env.mock) return;
|
|
244
|
+
const missing = [];
|
|
245
|
+
if (!env.t3nApiKey) missing.push("T3N_API_KEY");
|
|
246
|
+
if (!env.did) missing.push("DID");
|
|
247
|
+
if (missing.length > 0) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`Missing required env: ${missing.join(", ")}. Claim them at https://docs.terminal3.io/developers/adk/get-started/prerequisites/request-test-tokens, then put them in .env. Or set BLINDFOLD_MOCK=1 to use mock mode for the demo only.`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// src/log.ts
|
|
255
|
+
var HEADER_BLOCKLIST = /* @__PURE__ */ new Set([
|
|
256
|
+
"authorization",
|
|
257
|
+
"proxy-authorization",
|
|
258
|
+
"x-api-key",
|
|
259
|
+
"cookie",
|
|
260
|
+
"set-cookie"
|
|
261
|
+
]);
|
|
262
|
+
function safeLog(level, obj) {
|
|
263
|
+
const safe = typeof obj === "string" ? { msg: obj } : redact(obj);
|
|
264
|
+
const line = JSON.stringify({ t: (/* @__PURE__ */ new Date()).toISOString(), level, ...safe });
|
|
265
|
+
process.stderr.write(line + "\n");
|
|
266
|
+
}
|
|
267
|
+
function redact(input) {
|
|
268
|
+
const out = {};
|
|
269
|
+
for (const [k, v] of Object.entries(input)) {
|
|
270
|
+
if (k === "headers" && v && typeof v === "object") {
|
|
271
|
+
out[k] = redactHeaders(v);
|
|
272
|
+
} else if (HEADER_BLOCKLIST.has(k.toLowerCase())) {
|
|
273
|
+
out[k] = "[redacted]";
|
|
274
|
+
} else {
|
|
275
|
+
out[k] = v;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
function redactHeaders(h) {
|
|
281
|
+
if (Array.isArray(h)) {
|
|
282
|
+
return h.map(([k, v]) => [k, HEADER_BLOCKLIST.has(k.toLowerCase()) ? "[redacted]" : v]);
|
|
283
|
+
}
|
|
284
|
+
const out = {};
|
|
285
|
+
for (const [k, v] of Object.entries(h)) {
|
|
286
|
+
out[k] = HEADER_BLOCKLIST.has(k.toLowerCase()) ? "[redacted]" : String(v);
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// src/t3-client.ts
|
|
292
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
293
|
+
import fs2 from "node:fs";
|
|
294
|
+
import path2 from "node:path";
|
|
295
|
+
var T3_TIMEOUT_MS = Number(process.env.BLINDFOLD_T3_TIMEOUT_MS) || 3e4;
|
|
296
|
+
var T3TimeoutError = class extends Error {
|
|
297
|
+
};
|
|
298
|
+
function withDeadline(promise, label, ms = T3_TIMEOUT_MS) {
|
|
299
|
+
return new Promise((resolve, reject) => {
|
|
300
|
+
const timer = setTimeout(() => reject(new T3TimeoutError(`T3 ${label} timed out after ${ms}ms`)), ms);
|
|
301
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
302
|
+
promise.then(
|
|
303
|
+
(v) => {
|
|
304
|
+
clearTimeout(timer);
|
|
305
|
+
resolve(v);
|
|
306
|
+
},
|
|
307
|
+
(e) => {
|
|
308
|
+
clearTimeout(timer);
|
|
309
|
+
reject(e);
|
|
310
|
+
}
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function egressCachePath() {
|
|
315
|
+
return process.env.BLINDFOLD_EGRESS_CACHE ?? path2.join(stateDir(), "egress-hosts.json");
|
|
316
|
+
}
|
|
317
|
+
function loadEgressHosts(did) {
|
|
318
|
+
try {
|
|
319
|
+
const all = JSON.parse(fs2.readFileSync(egressCachePath(), "utf8"));
|
|
320
|
+
return Array.isArray(all[did]) ? all[did] : [];
|
|
321
|
+
} catch {
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function saveEgressHosts(did, hosts) {
|
|
326
|
+
const p = egressCachePath();
|
|
327
|
+
withFileLockSync(p, () => {
|
|
328
|
+
let all = {};
|
|
329
|
+
try {
|
|
330
|
+
all = JSON.parse(fs2.readFileSync(p, "utf8"));
|
|
331
|
+
} catch {
|
|
332
|
+
}
|
|
333
|
+
const existing = Array.isArray(all[did]) ? all[did] : [];
|
|
334
|
+
all[did] = Array.from(/* @__PURE__ */ new Set([...existing, ...hosts])).sort();
|
|
335
|
+
fs2.mkdirSync(path2.dirname(p), { recursive: true });
|
|
336
|
+
fs2.writeFileSync(p, JSON.stringify(all, null, 2));
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
var sdkCache = null;
|
|
340
|
+
async function loadSdk() {
|
|
341
|
+
if (sdkCache) return sdkCache;
|
|
342
|
+
try {
|
|
343
|
+
const mod = await import("@terminal3/t3n-sdk");
|
|
344
|
+
sdkCache = mod;
|
|
345
|
+
return mod;
|
|
346
|
+
} catch {
|
|
347
|
+
throw new Error(
|
|
348
|
+
"@terminal3/t3n-sdk not installed. Run `npm install @terminal3/t3n-sdk` for REAL T3 mode, or set BLINDFOLD_MOCK=1 to keep using mock mode."
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async function openT3Client(env) {
|
|
353
|
+
if (env.mock) return openMockClient();
|
|
354
|
+
assertRealReady(env);
|
|
355
|
+
return openRealClient(env);
|
|
356
|
+
}
|
|
357
|
+
async function openRealClient(env) {
|
|
358
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(env.t3nApiKey)) {
|
|
359
|
+
throw new Error("T3N_API_KEY must be a 0x-prefixed 32-byte hex (secp256k1 private key).");
|
|
360
|
+
}
|
|
361
|
+
if (!/^did:t3n:[0-9a-fA-F]+$/.test(env.did)) {
|
|
362
|
+
throw new Error('DID must look like "did:t3n:<hex>".');
|
|
363
|
+
}
|
|
364
|
+
const sdk = await loadSdk();
|
|
365
|
+
sdk.setEnvironment(env.t3Env);
|
|
366
|
+
const baseUrl = env.t3BaseUrl || sdk.NODE_URLS[env.t3Env];
|
|
367
|
+
const wasmComponent = await sdk.loadWasmComponent();
|
|
368
|
+
const address = sdk.eth_get_address(env.t3nApiKey);
|
|
369
|
+
const t3n = new sdk.T3nClient({
|
|
370
|
+
baseUrl,
|
|
371
|
+
wasmComponent,
|
|
372
|
+
handlers: { EthSign: sdk.metamask_sign(address, void 0, env.t3nApiKey) }
|
|
373
|
+
});
|
|
374
|
+
await t3n.handshake();
|
|
375
|
+
await t3n.authenticate(sdk.createEthAuthInput(address));
|
|
376
|
+
const tenant = new sdk.TenantClient({
|
|
377
|
+
environment: env.t3Env,
|
|
378
|
+
baseUrl,
|
|
379
|
+
tenantDid: env.did,
|
|
380
|
+
t3n
|
|
381
|
+
});
|
|
382
|
+
const seedSecret = async (name, value) => {
|
|
383
|
+
await tenant.executeControl("map-entry-set", {
|
|
384
|
+
map_name: tenant.canonicalName("secrets"),
|
|
385
|
+
key: name,
|
|
386
|
+
value
|
|
387
|
+
});
|
|
388
|
+
safeLog("info", { msg: "seeded", name });
|
|
389
|
+
};
|
|
390
|
+
const registerContract = async (wasm) => {
|
|
391
|
+
const r = await tenant.contracts.register({
|
|
392
|
+
tail: CONTRACT_TAIL,
|
|
393
|
+
version: CONTRACT_VERSION,
|
|
394
|
+
wasm
|
|
395
|
+
});
|
|
396
|
+
return {
|
|
397
|
+
contractId: r.contract_id ?? r.contractId ?? "(unknown)"
|
|
398
|
+
};
|
|
399
|
+
};
|
|
400
|
+
const invokeForward = async (req) => {
|
|
401
|
+
const raw = await withDeadline(tenant.contracts.execute(CONTRACT_TAIL, {
|
|
402
|
+
version: CONTRACT_VERSION,
|
|
403
|
+
functionName: "forward",
|
|
404
|
+
input: req
|
|
405
|
+
}), "forward");
|
|
406
|
+
if (typeof raw.status === "number" && Array.isArray(raw.headers)) {
|
|
407
|
+
return { status: raw.status, headers: raw.headers, body: raw.body ?? "" };
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
status: raw.code ?? 502,
|
|
411
|
+
headers: [["content-type", "application/json"]],
|
|
412
|
+
body: raw.body ?? ""
|
|
413
|
+
};
|
|
414
|
+
};
|
|
415
|
+
const decodeSecret = (v) => {
|
|
416
|
+
if (typeof v === "string") return v;
|
|
417
|
+
if (Array.isArray(v)) return Buffer.from(v).toString("utf8");
|
|
418
|
+
if (v && typeof v === "object") {
|
|
419
|
+
const o = v;
|
|
420
|
+
if (typeof o.value === "string") return o.value;
|
|
421
|
+
if (Array.isArray(o.value)) return Buffer.from(o.value).toString("utf8");
|
|
422
|
+
const entry = o.entry;
|
|
423
|
+
if (entry && typeof entry.value === "string") return entry.value;
|
|
424
|
+
}
|
|
425
|
+
return "";
|
|
426
|
+
};
|
|
427
|
+
const releaseSecret = async (name) => {
|
|
428
|
+
try {
|
|
429
|
+
const r = await withDeadline(tenant.contracts.execute(CONTRACT_TAIL, {
|
|
430
|
+
version: CONTRACT_VERSION,
|
|
431
|
+
functionName: "release-to-tenant",
|
|
432
|
+
input: { secret_key: name }
|
|
433
|
+
}), "release-to-tenant");
|
|
434
|
+
if (r && r.ok && r.value) return r.value;
|
|
435
|
+
} catch (e) {
|
|
436
|
+
if (e instanceof T3TimeoutError) throw e;
|
|
437
|
+
}
|
|
438
|
+
const direct = decodeSecret(
|
|
439
|
+
await withDeadline(tenant.executeControl("map-entry-get", {
|
|
440
|
+
map_name: tenant.canonicalName("secrets"),
|
|
441
|
+
key: name
|
|
442
|
+
}), "map-entry-get")
|
|
443
|
+
);
|
|
444
|
+
if (!direct) throw new Error(`secret "${name}" not found in the secrets map`);
|
|
445
|
+
return direct;
|
|
446
|
+
};
|
|
447
|
+
const me = async () => {
|
|
448
|
+
const info = await tenant.tenant.me();
|
|
449
|
+
return { tenant: String(info.tenant ?? ""), status: info.status };
|
|
450
|
+
};
|
|
451
|
+
const agentAuthUpdate = async (agentDid, hosts, functions) => {
|
|
452
|
+
let ucv = "0.1.0";
|
|
453
|
+
if (typeof sdk.getScriptVersion === "function") {
|
|
454
|
+
try {
|
|
455
|
+
const v = await sdk.getScriptVersion(baseUrl, "tee:user/contracts");
|
|
456
|
+
if (typeof v === "string" && /^\d/.test(v)) ucv = v;
|
|
457
|
+
} catch {
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const didHex = env.did.replace(/^did:t3n:/, "");
|
|
461
|
+
const revoking = functions.length === 0 && hosts.length === 0;
|
|
462
|
+
const scripts = [{
|
|
463
|
+
scriptName: `z:${didHex}:${CONTRACT_TAIL}`,
|
|
464
|
+
versionReq: `>=${CONTRACT_VERSION}`,
|
|
465
|
+
functions: revoking ? ["forward"] : functions,
|
|
466
|
+
allowedHosts: revoking ? [] : hosts
|
|
467
|
+
}];
|
|
468
|
+
await t3n.execute({
|
|
469
|
+
script_name: "tee:user/contracts",
|
|
470
|
+
script_version: ucv,
|
|
471
|
+
function_name: "agent-auth-update",
|
|
472
|
+
input: { agents: [{ agentDid, scripts }] }
|
|
473
|
+
});
|
|
474
|
+
};
|
|
475
|
+
const grantEgress = async (hosts, opts) => {
|
|
476
|
+
const prev = opts?.replace ? [] : loadEgressHosts(env.did);
|
|
477
|
+
const merged = Array.from(/* @__PURE__ */ new Set([...prev, ...hosts])).sort();
|
|
478
|
+
await agentAuthUpdate(env.did, merged, ["forward", "release-to-tenant"]);
|
|
479
|
+
saveEgressHosts(env.did, merged);
|
|
480
|
+
return merged;
|
|
481
|
+
};
|
|
482
|
+
const setAgentGrant = (agentDid, hosts, functions) => agentAuthUpdate(agentDid, hosts, functions);
|
|
483
|
+
const verifySecret = async (name) => {
|
|
484
|
+
try {
|
|
485
|
+
const value = await releaseSecret(name);
|
|
486
|
+
return { present: true, length: value.length, fingerprint: createHash("sha256").update(value).digest("hex").slice(0, 8) };
|
|
487
|
+
} catch {
|
|
488
|
+
return { present: false, length: 0, fingerprint: "" };
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
const getBalance = async () => {
|
|
492
|
+
const page = await withDeadline(tenant.token.getUsage(), "token.getUsage");
|
|
493
|
+
const b = page.balance ?? {};
|
|
494
|
+
return {
|
|
495
|
+
available: Number(b.available ?? 0),
|
|
496
|
+
reserved: Number(b.reserved ?? 0),
|
|
497
|
+
creditExhausted: Boolean(b.credit_exhausted),
|
|
498
|
+
storageDeposit: b.storage_deposit != null ? Number(b.storage_deposit) : void 0
|
|
499
|
+
};
|
|
500
|
+
};
|
|
501
|
+
return {
|
|
502
|
+
close: async () => {
|
|
503
|
+
},
|
|
504
|
+
seedSecret,
|
|
505
|
+
invokeForward,
|
|
506
|
+
registerContract,
|
|
507
|
+
releaseSecret,
|
|
508
|
+
me,
|
|
509
|
+
grantEgress,
|
|
510
|
+
setAgentGrant,
|
|
511
|
+
verifySecret,
|
|
512
|
+
getBalance,
|
|
513
|
+
isReal: true
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
function openMockClient() {
|
|
517
|
+
return {
|
|
518
|
+
close: async () => {
|
|
519
|
+
},
|
|
520
|
+
async seedSecret(name, value) {
|
|
521
|
+
if (!value || value.length === 0) throw new Error(`secret ${name} is empty`);
|
|
522
|
+
safeLog("info", { msg: "mock-seed (value dropped, length only)", name, length: value.length });
|
|
523
|
+
},
|
|
524
|
+
async registerContract(wasm) {
|
|
525
|
+
safeLog("info", { msg: "mock-register-contract", wasmBytes: wasm.byteLength });
|
|
526
|
+
return { contractId: "mock-contract-1" };
|
|
527
|
+
},
|
|
528
|
+
async invokeForward(req) {
|
|
529
|
+
safeLog("info", {
|
|
530
|
+
msg: "mock-forward",
|
|
531
|
+
method: req.method,
|
|
532
|
+
url: schemeAndHost(req.url),
|
|
533
|
+
secret_key: req.secret_key
|
|
534
|
+
});
|
|
535
|
+
const body = `{"mock":true,"note":"Blindfold mock mode \u2014 no real call made.","echo":{"url":${JSON.stringify(req.url)}}}`;
|
|
536
|
+
return { status: 200, headers: [["content-type", "application/json"]], body };
|
|
537
|
+
},
|
|
538
|
+
async releaseSecret(name) {
|
|
539
|
+
safeLog("info", { msg: "mock-release", name });
|
|
540
|
+
return `mock-released:${name}`;
|
|
541
|
+
},
|
|
542
|
+
async me() {
|
|
543
|
+
return { tenant: "did:t3n:mock", status: "active" };
|
|
544
|
+
},
|
|
545
|
+
async grantEgress(hosts) {
|
|
546
|
+
safeLog("info", { msg: "mock-grant-egress", hosts });
|
|
547
|
+
return hosts;
|
|
548
|
+
},
|
|
549
|
+
async setAgentGrant(agentDid, hosts, functions) {
|
|
550
|
+
safeLog("info", { msg: "mock-set-agent-grant", agentDid, hosts, functions });
|
|
551
|
+
},
|
|
552
|
+
async verifySecret(name) {
|
|
553
|
+
return { present: true, length: 0, fingerprint: `mock-${name}`.slice(0, 8) };
|
|
554
|
+
},
|
|
555
|
+
async getBalance() {
|
|
556
|
+
return { available: 1e9, reserved: 0, creditExhausted: false, mock: true };
|
|
557
|
+
},
|
|
558
|
+
isReal: false
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
function schemeAndHost(url) {
|
|
562
|
+
try {
|
|
563
|
+
const u = new URL(url);
|
|
564
|
+
return `${u.protocol}//${u.host}`;
|
|
565
|
+
} catch {
|
|
566
|
+
return "<bad-url>";
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// src/usage-log.ts
|
|
571
|
+
import fs3 from "node:fs";
|
|
572
|
+
import path3 from "node:path";
|
|
573
|
+
function defaultLogPath() {
|
|
574
|
+
return process.env.BLINDFOLD_USAGE_LOG ?? path3.join(stateDir(), "usage.jsonl");
|
|
575
|
+
}
|
|
576
|
+
var USAGE_MAX_BYTES = Number(process.env.BLINDFOLD_USAGE_MAX_BYTES) || 10 * 1024 * 1024;
|
|
577
|
+
function rotateIfNeeded(p) {
|
|
578
|
+
try {
|
|
579
|
+
const size = fs3.statSync(p).size;
|
|
580
|
+
if (size < USAGE_MAX_BYTES) return;
|
|
581
|
+
if (fs3.existsSync(`${p}.1`)) fs3.renameSync(`${p}.1`, `${p}.2`);
|
|
582
|
+
fs3.renameSync(p, `${p}.1`);
|
|
583
|
+
} catch {
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
var ensuredDirs = /* @__PURE__ */ new Set();
|
|
587
|
+
var ROTATE_CHECK_EVERY = 500;
|
|
588
|
+
var writesSinceRotateCheck = ROTATE_CHECK_EVERY;
|
|
589
|
+
function logUsage(event) {
|
|
590
|
+
const p = defaultLogPath();
|
|
591
|
+
try {
|
|
592
|
+
const dir = path3.dirname(p);
|
|
593
|
+
if (!ensuredDirs.has(dir)) {
|
|
594
|
+
fs3.mkdirSync(dir, { recursive: true });
|
|
595
|
+
ensuredDirs.add(dir);
|
|
596
|
+
}
|
|
597
|
+
if (++writesSinceRotateCheck >= ROTATE_CHECK_EVERY) {
|
|
598
|
+
writesSinceRotateCheck = 0;
|
|
599
|
+
rotateIfNeeded(p);
|
|
600
|
+
}
|
|
601
|
+
fs3.appendFile(p, JSON.stringify(event) + "\n", () => {
|
|
602
|
+
});
|
|
603
|
+
} catch {
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
function providerForUpstream(upstream) {
|
|
607
|
+
try {
|
|
608
|
+
const u = new URL(upstream);
|
|
609
|
+
const h = u.hostname;
|
|
610
|
+
if (h.endsWith("openai.com")) return "openai";
|
|
611
|
+
if (h.endsWith("anthropic.com")) return "anthropic";
|
|
612
|
+
if (h.endsWith("googleapis.com")) return "google";
|
|
613
|
+
if (h.endsWith("x.ai")) return "xai";
|
|
614
|
+
if (h.endsWith("groq.com")) return "groq";
|
|
615
|
+
return h;
|
|
616
|
+
} catch {
|
|
617
|
+
return "unknown";
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// src/providers.ts
|
|
622
|
+
function amzDate(now = /* @__PURE__ */ new Date()) {
|
|
623
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
624
|
+
return `${now.getUTCFullYear()}${p(now.getUTCMonth() + 1)}${p(now.getUTCDate())}T${p(now.getUTCHours())}${p(now.getUTCMinutes())}${p(now.getUTCSeconds())}Z`;
|
|
625
|
+
}
|
|
626
|
+
var awsRegion = () => process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1";
|
|
627
|
+
var awsAccessKeyId = () => process.env.AWS_ACCESS_KEY_ID || "";
|
|
628
|
+
function stripPrefix(path4, prefix) {
|
|
629
|
+
const rest = path4.slice(prefix.length - 1);
|
|
630
|
+
return rest.startsWith("/") ? rest : `/${rest}`;
|
|
631
|
+
}
|
|
632
|
+
var PROVIDERS = [
|
|
633
|
+
// ---- LLM providers (OpenAI-shaped, bearer). Back-compatible. --------------
|
|
634
|
+
{ id: "openai", prefix: "/v1/", upstream: (p) => `https://api.openai.com${p}`, auth: () => ({ scheme: "bearer" }) },
|
|
635
|
+
{ id: "openai", prefix: "/openai/", upstream: (p) => `https://api.openai.com${stripPrefix(p, "/openai/")}`, auth: () => ({ scheme: "bearer" }) },
|
|
636
|
+
// Anthropic REQUIRES the anthropic-version header on the raw REST API.
|
|
637
|
+
{ id: "anthropic", prefix: "/anthropic/", upstream: (p) => `https://api.anthropic.com${stripPrefix(p, "/anthropic/")}`, auth: () => ({ scheme: "bearer" }), defaultHeaders: { "anthropic-version": "2023-06-01" } },
|
|
638
|
+
{ id: "xai", prefix: "/x/", upstream: (p) => `https://api.x.ai${stripPrefix(p, "/x/")}`, auth: () => ({ scheme: "bearer" }) },
|
|
639
|
+
{ id: "groq", prefix: "/groq/", upstream: (p) => `https://api.groq.com/openai${stripPrefix(p, "/groq/")}`, auth: () => ({ scheme: "bearer" }) },
|
|
640
|
+
// ---- LLM: Google Gemini (native API — key rides in `x-goog-api-key`). ------
|
|
641
|
+
// Not OpenAI-shaped and NOT `Authorization: Bearer`. The sentinel is planted
|
|
642
|
+
// in Google's provider-specific header and swapped for the sealed key inside
|
|
643
|
+
// the enclave — proving Blindfold handles a provider's real auth convention,
|
|
644
|
+
// not just the one bearer shape.
|
|
645
|
+
{
|
|
646
|
+
id: "gemini",
|
|
647
|
+
prefix: "/gemini/",
|
|
648
|
+
upstream: (p) => `https://generativelanguage.googleapis.com${stripPrefix(p, "/gemini/")}`,
|
|
649
|
+
secretKey: "gemini_api_key",
|
|
650
|
+
auth: () => ({ scheme: "bearer" }),
|
|
651
|
+
sentinelHeader: { name: "x-goog-api-key", prefix: "" }
|
|
652
|
+
},
|
|
653
|
+
// ---- Payments: Stripe (bearer; pins the API version so behaviour is stable). -----
|
|
654
|
+
{
|
|
655
|
+
id: "stripe",
|
|
656
|
+
prefix: "/stripe/",
|
|
657
|
+
upstream: (p) => `https://api.stripe.com${stripPrefix(p, "/stripe/")}`,
|
|
658
|
+
secretKey: "stripe_secret_key",
|
|
659
|
+
auth: () => ({ scheme: "bearer" }),
|
|
660
|
+
defaultHeaders: { "stripe-version": "2024-06-20", "content-type": "application/x-www-form-urlencoded" }
|
|
661
|
+
},
|
|
662
|
+
// ---- Dev infra: GitHub. GitHub REJECTS requests with no User-Agent (403),
|
|
663
|
+
// and best practice is to pin the REST API version + set the Accept type.
|
|
664
|
+
{
|
|
665
|
+
id: "github",
|
|
666
|
+
prefix: "/github/",
|
|
667
|
+
upstream: (p) => `https://api.github.com${stripPrefix(p, "/github/")}`,
|
|
668
|
+
secretKey: "github_token",
|
|
669
|
+
auth: () => ({ scheme: "bearer" }),
|
|
670
|
+
defaultHeaders: {
|
|
671
|
+
accept: "application/vnd.github+json",
|
|
672
|
+
"x-github-api-version": "2022-11-28",
|
|
673
|
+
"user-agent": "blindfold"
|
|
674
|
+
}
|
|
675
|
+
},
|
|
676
|
+
// ---- Email: SendGrid (bearer; JSON v3 API). -------------------------------
|
|
677
|
+
{
|
|
678
|
+
id: "sendgrid",
|
|
679
|
+
prefix: "/sendgrid/",
|
|
680
|
+
upstream: (p) => `https://api.sendgrid.com${stripPrefix(p, "/sendgrid/")}`,
|
|
681
|
+
secretKey: "sendgrid_api_key",
|
|
682
|
+
auth: () => ({ scheme: "bearer" }),
|
|
683
|
+
defaultHeaders: { "content-type": "application/json" }
|
|
684
|
+
},
|
|
685
|
+
// ---- Comms: Slack (bearer bot token; Web API wants JSON+charset on POST). --
|
|
686
|
+
{
|
|
687
|
+
id: "slack",
|
|
688
|
+
prefix: "/slack/",
|
|
689
|
+
upstream: (p) => `https://slack.com/api${stripPrefix(p, "/slack/")}`,
|
|
690
|
+
secretKey: "slack_bot_token",
|
|
691
|
+
auth: () => ({ scheme: "bearer" }),
|
|
692
|
+
defaultHeaders: { "content-type": "application/json; charset=utf-8" }
|
|
693
|
+
},
|
|
694
|
+
// ---- Telephony: Twilio (HTTP Basic — base64 computed IN the enclave). -----
|
|
695
|
+
// Username = Account SID (not secret; also appears in the URL path). The
|
|
696
|
+
// sealed secret is the Auth Token. A generic proxy CANNOT do this: the
|
|
697
|
+
// base64 must be computed after the secret is joined, inside TDX.
|
|
698
|
+
{
|
|
699
|
+
id: "twilio",
|
|
700
|
+
prefix: "/twilio/",
|
|
701
|
+
upstream: (p) => `https://api.twilio.com${stripPrefix(p, "/twilio/")}`,
|
|
702
|
+
secretKey: "twilio_auth_token",
|
|
703
|
+
auth: () => ({ scheme: "basic", username: process.env.TWILIO_ACCOUNT_SID || "" }),
|
|
704
|
+
defaultHeaders: { "content-type": "application/x-www-form-urlencoded" }
|
|
705
|
+
},
|
|
706
|
+
// ---- Cloud: AWS SES (SigV4 — secret SIGNS, never transmitted). ------------
|
|
707
|
+
{
|
|
708
|
+
id: "aws-ses",
|
|
709
|
+
prefix: "/aws/ses/",
|
|
710
|
+
upstream: (p) => `https://email.${awsRegion()}.amazonaws.com${stripPrefix(p, "/aws/ses/")}`,
|
|
711
|
+
secretKey: "aws_secret_access_key",
|
|
712
|
+
auth: () => ({ scheme: "sigv4", access_key_id: awsAccessKeyId(), region: awsRegion(), service: "ses", amz_date: amzDate() })
|
|
713
|
+
},
|
|
714
|
+
// ---- Cloud: AWS S3 (SigV4). -----------------------------------------------
|
|
715
|
+
{
|
|
716
|
+
id: "aws-s3",
|
|
717
|
+
prefix: "/aws/s3/",
|
|
718
|
+
upstream: (p) => `https://s3.${awsRegion()}.amazonaws.com${stripPrefix(p, "/aws/s3/")}`,
|
|
719
|
+
secretKey: "aws_secret_access_key",
|
|
720
|
+
auth: () => ({ scheme: "sigv4", access_key_id: awsAccessKeyId(), region: awsRegion(), service: "s3", amz_date: amzDate() })
|
|
721
|
+
},
|
|
722
|
+
// ---- Webhook: Discord. The SECRET IS THE URL — the enclave substitutes the
|
|
723
|
+
// sealed webhook URL for the sentinel, so the agent POSTs to /discord
|
|
724
|
+
// with only a JSON body and never holds the URL. Needs egress for
|
|
725
|
+
// discord.com (`blindfold grant --host discord.com`).
|
|
726
|
+
{
|
|
727
|
+
id: "discord",
|
|
728
|
+
prefix: "/discord",
|
|
729
|
+
upstream: () => SENTINEL,
|
|
730
|
+
// enclave replaces this with the sealed webhook URL
|
|
731
|
+
secretKey: "webhook_discord_url",
|
|
732
|
+
auth: () => ({ scheme: "webhook" })
|
|
733
|
+
}
|
|
734
|
+
];
|
|
735
|
+
function resolveProvider(path4) {
|
|
736
|
+
const def = PROVIDERS.filter((d) => path4.startsWith(d.prefix)).sort((a, b) => b.prefix.length - a.prefix.length)[0];
|
|
737
|
+
if (!def) return null;
|
|
738
|
+
return {
|
|
739
|
+
id: def.id,
|
|
740
|
+
upstream: def.upstream(path4),
|
|
741
|
+
secretKey: def.secretKey,
|
|
742
|
+
auth: def.auth(),
|
|
743
|
+
sentinelHeader: def.sentinelHeader,
|
|
744
|
+
defaultHeaders: def.defaultHeaders
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// src/proxy.ts
|
|
749
|
+
var MAX_BODY_BYTES = Number(process.env.BLINDFOLD_MAX_BODY_BYTES) || 5 * 1024 * 1024;
|
|
750
|
+
var BODY_IDLE_MS = Number(process.env.BLINDFOLD_BODY_IDLE_MS) || 3e4;
|
|
751
|
+
var MAX_INFLIGHT = Number(process.env.BLINDFOLD_MAX_INFLIGHT) || 64;
|
|
752
|
+
var inflight = 0;
|
|
753
|
+
var PayloadTooLargeError = class extends Error {
|
|
754
|
+
};
|
|
755
|
+
function tokenMatches(provided, expected) {
|
|
756
|
+
const a = Buffer.from(provided);
|
|
757
|
+
const b = Buffer.from(expected);
|
|
758
|
+
if (a.length !== b.length) return false;
|
|
759
|
+
return crypto.timingSafeEqual(a, b);
|
|
760
|
+
}
|
|
761
|
+
var PROXY_TOKEN_HEADER = "x-blindfold-token";
|
|
762
|
+
function isSocketLive(socketPath) {
|
|
763
|
+
return new Promise((resolve) => {
|
|
764
|
+
if (!fs4.existsSync(socketPath)) {
|
|
765
|
+
resolve(false);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
const c = net.connect(socketPath);
|
|
769
|
+
const done = (live) => {
|
|
770
|
+
try {
|
|
771
|
+
c.destroy();
|
|
772
|
+
} catch {
|
|
773
|
+
}
|
|
774
|
+
resolve(live);
|
|
775
|
+
};
|
|
776
|
+
c.once("connect", () => done(true));
|
|
777
|
+
c.once("error", () => done(false));
|
|
778
|
+
c.setTimeout(500, () => done(false));
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
async function startProxy(opts = {}) {
|
|
782
|
+
const env = loadBlindfoldEnv();
|
|
783
|
+
const port = opts.port ?? env.port ?? 8787;
|
|
784
|
+
const secretKey = opts.secretKey ?? "openai_api_key";
|
|
785
|
+
const token = (opts.token ?? process.env.BLINDFOLD_PROXY_TOKEN ?? "").trim() || void 0;
|
|
786
|
+
const socketPath = opts.socket?.trim() || void 0;
|
|
787
|
+
if (socketPath && await isSocketLive(socketPath)) {
|
|
788
|
+
throw new Error(`a Blindfold proxy is already listening on ${socketPath} \u2014 stop it first, or use a different --socket path`);
|
|
789
|
+
}
|
|
790
|
+
const t3 = await openT3Client(env);
|
|
791
|
+
const server = http.createServer((req, res) => {
|
|
792
|
+
handle(req, res, t3, secretKey, env, token).catch((e) => {
|
|
793
|
+
if (e instanceof PayloadTooLargeError) {
|
|
794
|
+
if (!res.headersSent) res.writeHead(413, { "content-type": "text/plain", "connection": "close" });
|
|
795
|
+
res.end("request body too large");
|
|
796
|
+
req.destroy();
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
safeLog("error", { msg: "proxy_unhandled", error: e.message });
|
|
800
|
+
if (!res.headersSent) res.writeHead(500, { "content-type": "text/plain" });
|
|
801
|
+
res.end("internal proxy error");
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
return await new Promise((resolve, reject) => {
|
|
805
|
+
server.once("error", reject);
|
|
806
|
+
let prevUmask;
|
|
807
|
+
const onListening = () => {
|
|
808
|
+
let url;
|
|
809
|
+
let bound = 0;
|
|
810
|
+
if (socketPath) {
|
|
811
|
+
try {
|
|
812
|
+
fs4.chmodSync(socketPath, 384);
|
|
813
|
+
} catch {
|
|
814
|
+
}
|
|
815
|
+
if (prevUmask !== void 0) {
|
|
816
|
+
try {
|
|
817
|
+
process.umask(prevUmask);
|
|
818
|
+
} catch {
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
url = `unix:${socketPath}`;
|
|
822
|
+
} else {
|
|
823
|
+
bound = server.address().port;
|
|
824
|
+
url = `http://127.0.0.1:${bound}`;
|
|
825
|
+
}
|
|
826
|
+
safeLog("info", { msg: "proxy_listening", url, mock: env.mock });
|
|
827
|
+
resolve({
|
|
828
|
+
url,
|
|
829
|
+
port: bound,
|
|
830
|
+
token,
|
|
831
|
+
socket: socketPath,
|
|
832
|
+
close: async () => {
|
|
833
|
+
await new Promise((r) => server.close(() => r()));
|
|
834
|
+
if (socketPath) {
|
|
835
|
+
try {
|
|
836
|
+
fs4.rmSync(socketPath, { force: true });
|
|
837
|
+
} catch {
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
await t3.close();
|
|
841
|
+
}
|
|
842
|
+
});
|
|
843
|
+
};
|
|
844
|
+
if (socketPath) {
|
|
845
|
+
try {
|
|
846
|
+
fs4.rmSync(socketPath, { force: true });
|
|
847
|
+
} catch {
|
|
848
|
+
}
|
|
849
|
+
prevUmask = process.umask(127);
|
|
850
|
+
server.listen(socketPath, onListening);
|
|
851
|
+
} else {
|
|
852
|
+
server.listen(port, "127.0.0.1", onListening);
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
async function handle(req, res, t3, secretKey, env, token) {
|
|
857
|
+
if (req.url === "/health") {
|
|
858
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
859
|
+
res.end(JSON.stringify({ ok: true, mock: env.mock, auth: Boolean(token) }));
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
if (token) {
|
|
863
|
+
const provided = String(req.headers[PROXY_TOKEN_HEADER] ?? "");
|
|
864
|
+
if (!provided || !tokenMatches(provided, token)) {
|
|
865
|
+
safeLog("warn", { msg: "proxy_unauthorized", path: req.url });
|
|
866
|
+
res.writeHead(401, { "content-type": "text/plain" });
|
|
867
|
+
res.end(`unauthorized: missing or invalid ${PROXY_TOKEN_HEADER} header`);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
const provider = resolveProvider(req.url ?? "/");
|
|
872
|
+
if (!provider) {
|
|
873
|
+
res.writeHead(404, { "content-type": "text/plain" });
|
|
874
|
+
res.end(`no upstream mapping for ${req.url}`);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
const upstream = provider.upstream;
|
|
878
|
+
const providerSecretKey = provider.secretKey ?? secretKey;
|
|
879
|
+
const body = await readBody(req, MAX_BODY_BYTES);
|
|
880
|
+
const agentSuppliedAuth = Object.keys(req.headers).some((k) => k.toLowerCase() === "authorization");
|
|
881
|
+
const headers = forwardableHeaders(req.headers);
|
|
882
|
+
if (provider.auth.scheme === "bearer") {
|
|
883
|
+
const sh = provider.sentinelHeader ?? { name: "authorization", prefix: "Bearer " };
|
|
884
|
+
removeHeader(headers, "authorization");
|
|
885
|
+
ensureHeader(headers, sh.name, `${sh.prefix}${SENTINEL}`);
|
|
886
|
+
} else {
|
|
887
|
+
removeHeader(headers, "authorization");
|
|
888
|
+
}
|
|
889
|
+
for (const [name, value] of Object.entries(provider.defaultHeaders ?? {})) {
|
|
890
|
+
if (!headers.some(([k]) => k.toLowerCase() === name.toLowerCase())) {
|
|
891
|
+
headers.push([name, value]);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
let outboundUrl = upstream;
|
|
895
|
+
let outboundBody = body.length ? body.toString("utf8") : void 0;
|
|
896
|
+
const contentType = (headers.find(([k]) => k.toLowerCase() === "content-type")?.[1] ?? "").toLowerCase();
|
|
897
|
+
if (provider.auth.scheme !== "webhook" && outboundBody && contentType.includes("application/x-www-form-urlencoded")) {
|
|
898
|
+
const u = new URL(outboundUrl);
|
|
899
|
+
for (const [k, v] of new URLSearchParams(outboundBody)) u.searchParams.append(k, v);
|
|
900
|
+
outboundUrl = u.toString();
|
|
901
|
+
outboundBody = void 0;
|
|
902
|
+
safeLog("info", { msg: "form_body_to_query", provider: provider.id });
|
|
903
|
+
}
|
|
904
|
+
const forwardReq = {
|
|
905
|
+
method: req.method ?? "GET",
|
|
906
|
+
url: outboundUrl,
|
|
907
|
+
headers,
|
|
908
|
+
body: outboundBody,
|
|
909
|
+
secret_key: providerSecretKey,
|
|
910
|
+
auth: provider.auth
|
|
911
|
+
};
|
|
912
|
+
safeLog("info", {
|
|
913
|
+
msg: "proxy_forward",
|
|
914
|
+
method: forwardReq.method,
|
|
915
|
+
upstream: upstream.replace(/\?.*$/, "")
|
|
916
|
+
});
|
|
917
|
+
if (inflight >= MAX_INFLIGHT) {
|
|
918
|
+
safeLog("warn", { msg: "proxy_saturated", inflight, max: MAX_INFLIGHT });
|
|
919
|
+
if (!res.headersSent) res.writeHead(503, { "content-type": "text/plain", "retry-after": "1" });
|
|
920
|
+
res.end("proxy saturated: too many concurrent enclave calls \u2014 retry shortly");
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
const startedAt = Date.now();
|
|
924
|
+
let result;
|
|
925
|
+
inflight++;
|
|
926
|
+
try {
|
|
927
|
+
result = await t3.invokeForward(forwardReq);
|
|
928
|
+
} catch (e) {
|
|
929
|
+
const { status, body: body2 } = explainForwardError(e);
|
|
930
|
+
safeLog("error", { msg: "proxy_forward_failed", status, provider: provider.id, error: e.message });
|
|
931
|
+
if (!res.headersSent) res.writeHead(status, { "content-type": "application/json" });
|
|
932
|
+
res.end(body2);
|
|
933
|
+
return;
|
|
934
|
+
} finally {
|
|
935
|
+
inflight--;
|
|
936
|
+
}
|
|
937
|
+
const latency = Date.now() - startedAt;
|
|
938
|
+
logUsage({
|
|
939
|
+
t: (/* @__PURE__ */ new Date()).toISOString(),
|
|
940
|
+
mode: env.mock ? "mock" : "real",
|
|
941
|
+
provider: provider.id || providerForUpstream(upstream),
|
|
942
|
+
method: forwardReq.method,
|
|
943
|
+
path: req.url ?? "/",
|
|
944
|
+
upstream: upstream.replace(/\?.*$/, ""),
|
|
945
|
+
status: result.status,
|
|
946
|
+
latency_ms: latency,
|
|
947
|
+
agent_supplied_auth: agentSuppliedAuth,
|
|
948
|
+
auth_scheme: provider.auth.scheme,
|
|
949
|
+
sentinel_in_outbound: forwardReq.headers.some(([k, v]) => k.toLowerCase() === "authorization" && v.includes(SENTINEL)),
|
|
950
|
+
via: "proxy",
|
|
951
|
+
secret_key: providerSecretKey
|
|
952
|
+
});
|
|
953
|
+
res.writeHead(result.status, headersFromTuple(result.headers));
|
|
954
|
+
const bodyBytes = typeof result.body === "string" ? Buffer.from(result.body, "utf8") : Buffer.from(result.body);
|
|
955
|
+
res.end(bodyBytes);
|
|
956
|
+
}
|
|
957
|
+
function readBody(req, maxBytes) {
|
|
958
|
+
return new Promise((resolve, reject) => {
|
|
959
|
+
const chunks = [];
|
|
960
|
+
let size = 0;
|
|
961
|
+
let aborted = false;
|
|
962
|
+
let timer;
|
|
963
|
+
const fail = (e) => {
|
|
964
|
+
aborted = true;
|
|
965
|
+
clearTimeout(timer);
|
|
966
|
+
reject(e);
|
|
967
|
+
};
|
|
968
|
+
const arm = () => {
|
|
969
|
+
clearTimeout(timer);
|
|
970
|
+
timer = setTimeout(() => {
|
|
971
|
+
if (!aborted) fail(new Error(`request body read timed out after ${BODY_IDLE_MS}ms`));
|
|
972
|
+
}, BODY_IDLE_MS);
|
|
973
|
+
};
|
|
974
|
+
arm();
|
|
975
|
+
req.on("data", (c) => {
|
|
976
|
+
if (aborted) return;
|
|
977
|
+
arm();
|
|
978
|
+
size += c.length;
|
|
979
|
+
if (size > maxBytes) {
|
|
980
|
+
fail(new PayloadTooLargeError(`request body exceeds ${maxBytes} bytes`));
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
chunks.push(c);
|
|
984
|
+
});
|
|
985
|
+
req.on("end", () => {
|
|
986
|
+
clearTimeout(timer);
|
|
987
|
+
if (!aborted) resolve(Buffer.concat(chunks));
|
|
988
|
+
});
|
|
989
|
+
req.on("error", (e) => {
|
|
990
|
+
if (!aborted) fail(e);
|
|
991
|
+
});
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
var STRIPPED = /* @__PURE__ */ new Set(["host", "content-length", "connection", "transfer-encoding"]);
|
|
995
|
+
function forwardableHeaders(h) {
|
|
996
|
+
const out = [];
|
|
997
|
+
for (const [k, v] of Object.entries(h)) {
|
|
998
|
+
if (v === void 0) continue;
|
|
999
|
+
if (STRIPPED.has(k.toLowerCase())) continue;
|
|
1000
|
+
out.push([k, Array.isArray(v) ? v.join(", ") : v]);
|
|
1001
|
+
}
|
|
1002
|
+
return out;
|
|
1003
|
+
}
|
|
1004
|
+
function ensureHeader(h, name, value) {
|
|
1005
|
+
const lower = name.toLowerCase();
|
|
1006
|
+
const idx = h.findIndex(([k]) => k.toLowerCase() === lower);
|
|
1007
|
+
if (idx >= 0) h[idx] = [name, value];
|
|
1008
|
+
else h.push([name, value]);
|
|
1009
|
+
}
|
|
1010
|
+
function removeHeader(h, name) {
|
|
1011
|
+
const lower = name.toLowerCase();
|
|
1012
|
+
for (let i = h.length - 1; i >= 0; i--) {
|
|
1013
|
+
if (h[i][0].toLowerCase() === lower) h.splice(i, 1);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
function headersFromTuple(t) {
|
|
1017
|
+
const out = {};
|
|
1018
|
+
for (const [k, v] of t) out[k] = v;
|
|
1019
|
+
return out;
|
|
1020
|
+
}
|
|
1021
|
+
function explainForwardError(err) {
|
|
1022
|
+
const msg = err?.message ?? String(err);
|
|
1023
|
+
if (err instanceof T3TimeoutError) {
|
|
1024
|
+
return {
|
|
1025
|
+
status: 504,
|
|
1026
|
+
body: JSON.stringify({
|
|
1027
|
+
error: "blindfold_forward_failed",
|
|
1028
|
+
status: 504,
|
|
1029
|
+
code: "t3_timeout",
|
|
1030
|
+
detail: msg,
|
|
1031
|
+
hint: "The T3 node did not respond within the deadline (BLINDFOLD_T3_TIMEOUT_MS). It may be slow or unreachable; retry, or point T3_BASE_URL at a healthy node."
|
|
1032
|
+
}, null, 2)
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
let status = Number(msg.match(/HTTP (\d{3})/)?.[1]) || 502;
|
|
1036
|
+
let code = "";
|
|
1037
|
+
let detail = msg;
|
|
1038
|
+
let requestId = "";
|
|
1039
|
+
const jsonM = msg.match(/\((\{.*\})\)\s*$/);
|
|
1040
|
+
if (jsonM && jsonM[1]) {
|
|
1041
|
+
try {
|
|
1042
|
+
const o = JSON.parse(jsonM[1]);
|
|
1043
|
+
code = o.code ?? "";
|
|
1044
|
+
detail = o.detail ?? detail;
|
|
1045
|
+
requestId = o.request_id ?? "";
|
|
1046
|
+
} catch {
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
let hint = "";
|
|
1050
|
+
if (/egress_denied|authorised_hosts|allowlist/i.test(detail)) {
|
|
1051
|
+
const host = detail.match(/host '([^']+)'/)?.[1];
|
|
1052
|
+
hint = `Egress is not authorized${host ? ` for '${host}'` : ""}. Run: blindfold grant --host ${host ?? "<host>"} \u2014 list every host you use in ONE command (grant replaces the allowlist unless merged).`;
|
|
1053
|
+
if (status < 400) status = 403;
|
|
1054
|
+
} else if (/fuel_per_minute|too_many_requests|rate limit/i.test(detail)) {
|
|
1055
|
+
hint = "Rate limited by the testnet per-minute compute quota (fuel_per_minute). Retry in ~60s and space calls out \u2014 this is not an outage.";
|
|
1056
|
+
status = 429;
|
|
1057
|
+
} else if (/cannot read map|:secrets/i.test(detail)) {
|
|
1058
|
+
hint = "The contract isn't authorized to read your secrets map (common right after publishing a new contract id). Run: blindfold init (re-grants the secrets read ACL).";
|
|
1059
|
+
} else if (/parse_payload|expected value at line/i.test(detail)) {
|
|
1060
|
+
hint = "The T3 host egress parses request bodies as JSON. For form-encoded APIs (Stripe/Twilio), send params in the query string with an empty body.";
|
|
1061
|
+
if (status < 400) status = 400;
|
|
1062
|
+
} else if (/secret .* not found|not found in the secrets map/i.test(detail)) {
|
|
1063
|
+
hint = "That sealed secret name doesn't exist. Seal it: blindfold register --name <name> --from-env <ENV_VAR>.";
|
|
1064
|
+
}
|
|
1065
|
+
const payload = {
|
|
1066
|
+
error: "blindfold_forward_failed",
|
|
1067
|
+
status,
|
|
1068
|
+
code: code || void 0,
|
|
1069
|
+
detail,
|
|
1070
|
+
request_id: requestId || void 0,
|
|
1071
|
+
hint: hint || void 0
|
|
1072
|
+
};
|
|
1073
|
+
return { status, body: JSON.stringify(payload, null, 2) };
|
|
1074
|
+
}
|
|
1075
|
+
export {
|
|
1076
|
+
PROXY_TOKEN_HEADER,
|
|
1077
|
+
startProxy
|
|
1078
|
+
};
|