Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 14 additions & 12 deletions integration-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,21 +240,23 @@ Publishing gotcha found here: a new contract id resets the **secrets-map read
ACL** (`readers: only:[id]`), so `grantContractReads(newId)` must run after every
`publish` or all forward calls fail with `cannot read map "…:secrets"`.

### Two operational gotchas (cost real diagnosis time — documented so they don't again)

- **`blindfold grant` REPLACES the egress allowlist, it doesn't append.**
`agentAuthUpdate` sets `allowedHosts: <hosts>` for the contract, so each grant
overwrites the previous one. Granting `api.github.com` then `api.stripe.com`
separately leaves ONLY Stripe authorized; earlier hosts silently start
returning `egress_denied`. **Fix: grant every host in one call** —
`grant --host generativelanguage.googleapis.com,api.stripe.com,api.github.com`.
(A good follow-up would be to make the CLI merge with the existing allowlist.)
### Two operational gotchas (cost real diagnosis time — now fixed)

- **`blindfold grant` used to REPLACE the egress allowlist.** ✅ **Fixed.**
T3 replaces `allowedHosts` on every agent-auth update, so `grant` now unions new
hosts with the previously-granted set (cached per tenant in
`.blindfold/egress-hosts.json`) and sends the full list — grants are additive.
`grant` prints the complete authorized set; `grant --replace` forces a reset.
- **The proxy hid real enclave errors behind `internal proxy error`.** ✅ **Fixed.**
The proxy now returns the real T3 error as JSON — status, `code`, `detail`,
`request_id`, and an actionable `hint` (e.g. egress not authorized → the exact
`blindfold grant` to run; `fuel_per_minute` → "rate limited, retry in ~60s";
secrets-ACL → "run blindfold init"; JSON-payload → "use query-string params").
- **Testnet tenants have a per-minute compute quota (`fuel_per_minute`).**
Hammering the enclave (tight reliability loops, retry storms, repeated demo
runs) trips `HTTP 500 too_many_requests: quota exceeded (fuel_per_minute)`,
which surfaces to the agent as a generic `internal proxy error`. It looks like
an outage but resets within a minute — space calls out, and don't let demo
retry loops hammer a already-exhausted quota.
now surfaced clearly (see above) instead of a generic 500. It looks like an
outage but resets within a minute — space calls out.

### Honesty hardening of the demos

Expand Down
9 changes: 6 additions & 3 deletions packages/blindfold/bin/blindfold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,13 +297,16 @@ async function main(): Promise<void> {
if (hosts.length === 0) {
die("usage: blindfold grant --host <host>[,<host2>...] (e.g. --host api.openai.com)");
}
const replace = !!argv.flags.replace;
const env = loadBlindfoldEnv();
const { openT3Client } = await import("../src/t3-client.ts");
const client = await openT3Client(env);
try {
await client.grantEgress(hosts);
console.log(`✓ Egress granted for: ${hosts.join(", ")}`);
console.log(` The blindfold-proxy contract (forward / release-to-tenant) may now call these hosts.`);
// T3 replaces the allowlist on every update, so grant is additive by
// default (merges with previously-granted hosts). Use --replace to reset.
const authorized = await client.grantEgress(hosts, { replace });
console.log(`✓ Egress granted for: ${hosts.join(", ")}${replace ? " (replaced allowlist)" : ""}`);
console.log(` Contract is now authorized to call ALL of: ${authorized.join(", ")}`);
console.log(` Run \`blindfold publish\` first if you haven't — the grant targets the published contract.`);
} finally {
await client.close();
Expand Down
66 changes: 65 additions & 1 deletion packages/blindfold/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,18 @@ async function handle(
});

const startedAt = Date.now();
const result = await t3.invokeForward(forwardReq);
let result;
try {
result = await t3.invokeForward(forwardReq);
} catch (e) {
// Surface the REAL enclave/host error (egress denied, rate limit, secrets
// ACL, payload parse) with an actionable hint — not a generic 500.
const { status, body } = explainForwardError(e as Error);
safeLog("error", { msg: "proxy_forward_failed", status, provider: provider.id, error: (e as Error).message });
if (!res.headersSent) res.writeHead(status, { "content-type": "application/json" });
res.end(body);
return;
}
const latency = Date.now() - startedAt;

// Record non-sensitive telemetry. Never the body, never the header values.
Expand Down Expand Up @@ -213,3 +224,56 @@ function headersFromTuple(t: Array<[string, string]>): Record<string, string> {
for (const [k, v] of t) out[k] = v;
return out;
}

/**
* Turn a raw T3 forward error into a real HTTP status + a JSON body that names
* the cause and how to fix it. The enclave/host errors look like:
* HTTP 400: Invalid params ({"code":"bad_request","detail":"…","request_id":"…"})
* These are the exact failures that cost real diagnosis time when hidden behind
* a generic "internal proxy error".
*/
function explainForwardError(err: Error): { status: number; body: string } {
const msg = err?.message ?? String(err);
let status = Number(msg.match(/HTTP (\d{3})/)?.[1]) || 502;
let code = "";
let detail = msg;
let requestId = "";
const jsonM = msg.match(/\((\{.*\})\)\s*$/);
if (jsonM) {
try {
const o = JSON.parse(jsonM[1]) as Record<string, string>;
code = o.code ?? "";
detail = o.detail ?? detail;
requestId = o.request_id ?? "";
} catch {
/* keep the raw message */
}
}

let hint = "";
if (/egress_denied|authorised_hosts|allowlist/i.test(detail)) {
const host = detail.match(/host '([^']+)'/)?.[1];
hint = `Egress is not authorized${host ? ` for '${host}'` : ""}. Run: blindfold grant --host ${host ?? "<host>"} — list every host you use in ONE command (grant replaces the allowlist unless merged).`;
if (status < 400) status = 403;
} else if (/fuel_per_minute|too_many_requests|rate limit/i.test(detail)) {
hint = "Rate limited by the testnet per-minute compute quota (fuel_per_minute). Retry in ~60s and space calls out — this is not an outage.";
status = 429;
} else if (/cannot read map|:secrets/i.test(detail)) {
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).";
} else if (/parse_payload|expected value at line/i.test(detail)) {
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.";
if (status < 400) status = 400;
} else if (/secret .* not found|not found in the secrets map/i.test(detail)) {
hint = "That sealed secret name doesn't exist. Seal it: blindfold register --name <name> --from-env <ENV_VAR>.";
}

const payload = {
error: "blindfold_forward_failed",
status,
code: code || undefined,
detail,
request_id: requestId || undefined,
hint: hint || undefined,
};
return { status, body: JSON.stringify(payload, null, 2) };
}
44 changes: 41 additions & 3 deletions packages/blindfold/src/t3-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,43 @@
* installed it. REAL mode requires `@terminal3/t3n-sdk` (optionalDep).
*/
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { BlindfoldEnv, ForwardRequest, ForwardResponse } from "./types.ts";
import { CONTRACT_TAIL, CONTRACT_VERSION } from "./constants.ts";
import { assertRealReady } from "./env.ts";
import { safeLog } from "./log.ts";

/* ---- egress allowlist cache -------------------------------------------------
* T3 REPLACES the contract's egress allowlist on every agent-auth update, so a
* grant must send the FULL desired set each time. We remember previously-granted
* hosts per tenant in .blindfold/egress-hosts.json and union new hosts in, so
* `grant` becomes additive instead of clobbering earlier grants.
* ---------------------------------------------------------------------------- */
function egressCachePath(): string {
return process.env.BLINDFOLD_EGRESS_CACHE ?? path.join(process.cwd(), ".blindfold", "egress-hosts.json");
}
function loadEgressHosts(did: string): string[] {
try {
const all = JSON.parse(fs.readFileSync(egressCachePath(), "utf8")) as Record<string, string[]>;
return Array.isArray(all[did]) ? all[did] : [];
} catch {
return [];
}
}
function saveEgressHosts(did: string, hosts: string[]): void {
const p = egressCachePath();
let all: Record<string, string[]> = {};
try {
all = JSON.parse(fs.readFileSync(p, "utf8")) as Record<string, string[]>;
} catch {
/* fresh file */
}
all[did] = hosts;
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(all, null, 2));
}

/** Loaded SDK module shape (subset of the real @terminal3/t3n-sdk exports). */
interface T3Sdk {
setEnvironment: (env: "testnet" | "production") => void;
Expand Down Expand Up @@ -78,7 +110,7 @@ export interface T3ClientHandle {
* functions to make outbound calls to the given hosts (the egress grant the
* proxy + in-enclave http::call path require).
*/
grantEgress: (hosts: string[]) => Promise<void>;
grantEgress: (hosts: string[], opts?: { replace?: boolean }) => Promise<string[]>;
/**
* Authorize an arbitrary agent DID (a teammate) to call this tenant's
* contract functions for the given hosts. Empty hosts+functions revokes.
Expand Down Expand Up @@ -260,8 +292,13 @@ async function openRealClient(env: BlindfoldEnv): Promise<T3ClientHandle> {
});
};

const grantEgress = (hosts: string[]): Promise<void> =>
agentAuthUpdate(env.did, hosts, ["forward", "release-to-tenant"]);
const grantEgress = async (hosts: string[], opts?: { replace?: boolean }): Promise<string[]> => {
const prev = opts?.replace ? [] : loadEgressHosts(env.did);
const merged = Array.from(new Set([...prev, ...hosts])).sort();
await agentAuthUpdate(env.did, merged, ["forward", "release-to-tenant"]);
saveEgressHosts(env.did, merged);
return merged;
};

const setAgentGrant = (agentDid: string, hosts: string[], functions: string[]): Promise<void> =>
agentAuthUpdate(agentDid, hosts, functions);
Expand Down Expand Up @@ -325,6 +362,7 @@ function openMockClient(): T3ClientHandle {
},
async grantEgress(hosts) {
safeLog("info", { msg: "mock-grant-egress", hosts });
return hosts;
},
async setAgentGrant(agentDid, hosts, functions) {
safeLog("info", { msg: "mock-set-agent-grant", agentDid, hosts, functions });
Expand Down
Loading