UNPKG

@tangle-network/tcloud

Version:

TypeScript SDK and CLI for Tangle Router, Sandbox, model routing, and agent service calls

277 lines (276 loc) • 10.7 kB
import { Sandbox } from "@tangle-network/sandbox"; import { createHash, randomBytes } from "node:crypto"; import { normalizeTeeType, verifyAttestationAsync } from "@tangle-network/tcloud-attestation"; //#region src/sandbox.ts const DEFAULT_SANDBOX_URL = "https://sandbox.tangle.tools"; var TCloudSandbox = class { client; apiKey; baseUrl; timeoutMs; constructor(config) { this.apiKey = config.apiKey; this.baseUrl = (config.baseUrl ?? DEFAULT_SANDBOX_URL).replace(/\/+$/, ""); this.timeoutMs = config.timeoutMs; this.client = new Sandbox({ apiKey: this.apiKey, baseUrl: this.baseUrl, timeoutMs: config.timeoutMs }); } /** * The underlying SDK-compatible client (#44): the full * `SandboxClient` shape (`create(options?: CreateSandboxOptions)`) that * agent-runtime's `runLoop` and SDK-native code expect — already * authenticated to the sandbox gateway. `TCloudSandbox.create` remains the * convenience wrapper (attestation policy + TEE fallbacks); this exposes * the raw surface for callers that need complete `CreateSandboxOptions`. */ sdkClient() { return this.client; } async create(options) { const effectiveVerify = shouldVerifyAttestation(options); const attestationPolicy = buildAttestationPolicy(options); const createOptions = buildSandboxCreateOptions({ ...options, verify: effectiveVerify }); const sandbox = await this.client.create(createOptions); this.attachTeeApiFallbacks(sandbox); let attestation = attestationFromMetadata(sandbox.metadata); if ((effectiveVerify || createOptions.confidential?.attestationNonce) && !attestation) { const getTeeAttestation = sandbox.getTeeAttestation; if (typeof getTeeAttestation !== "function") throw new Error("Installed @tangle-network/sandbox does not expose TEE attestation fetching"); attestation = (await getTeeAttestation.call(sandbox, createOptions.confidential?.attestationNonce ? { attestationNonce: createOptions.confidential.attestationNonce } : void 0)).attestation; } if (effectiveVerify && !attestation) throw new Error("TEE attestation verification requested but no evidence was returned"); const verification = effectiveVerify ? await verifyAttestationAsync(attestation, { ...attestationPolicy, expectedNonce: createOptions.confidential?.attestationNonce }) : void 0; if (verification && !verification.valid) throw new Error(`TEE attestation verification failed: ${verification.errors.join("; ")}`); return { sandbox, attestation, verification, attestationNonce: createOptions.confidential?.attestationNonce, attestationStatus: { requested: Boolean(options.tee), evidenceReturned: Boolean(attestation), verified: Boolean(verification?.valid), nonceBound: Boolean(createOptions.confidential?.attestationNonce && verification?.valid), errors: verification?.errors ?? [] } }; } attachTeeApiFallbacks(sandbox) { if (typeof sandbox !== "object" || sandbox === null) return; const target = sandbox; if (typeof target.id !== "string" || target.id.length === 0) return; if (typeof target.getTeeAttestation !== "function") target.getTeeAttestation = (options) => this.fetchTeeAttestation(target.id, options?.attestationNonce); if (typeof target.getTeePublicKey !== "function") target.getTeePublicKey = () => this.fetchTeePublicKey(target.id); } async fetchTeeAttestation(sandboxId, attestationNonce) { const data = await (await this.fetchSandboxApi(`/v1/sandboxes/${encodeURIComponent(sandboxId)}/tee/attestation`, { method: attestationNonce ? "POST" : "GET", body: attestationNonce ? JSON.stringify({ attestation_nonce: attestationNonce }) : void 0 })).json(); if (attestationNonce && !data.attestationNonce) data.attestationNonce = attestationNonce; return data; } async fetchTeePublicKey(sandboxId) { return (await this.fetchSandboxApi(`/v1/sandboxes/${encodeURIComponent(sandboxId)}/tee/public-key`, { method: "GET" })).json(); } async fetchSandboxApi(path, options) { const headers = new Headers(options.headers); headers.set("Authorization", `Bearer ${this.apiKey}`); if (options.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json"); const response = await fetch(`${this.baseUrl}${path}`, { ...options, headers, signal: options.signal ?? (this.timeoutMs ? AbortSignal.timeout(this.timeoutMs) : void 0) }); if (!response.ok) { const body = await response.text(); throw new Error(`Sandbox API ${path} failed with HTTP ${response.status}: ${body}`); } return response; } }; function createTeeAttestationChallenge(context) { const randomHex = generateAttestationNonce(32); if (context == null) return { nonce: randomHex, randomHex }; const contextHashHex = createHash("sha256").update(typeof context === "string" ? Buffer.from(context) : Buffer.from(context)).digest("hex"); return { nonce: `${randomHex}${contextHashHex}`, randomHex, contextHashHex }; } function startTeeAttestationHeartbeat(sandbox, options = {}) { const getTeeAttestation = sandbox?.getTeeAttestation; if (typeof getTeeAttestation !== "function") throw new Error("Sandbox does not expose TEE attestation fetching"); const intervalMs = options.intervalMs ?? 6e4; const sessionId = options.sessionId ?? generateAttestationNonce(16); const policy = options.tee ? buildAttestationPolicy({ tee: options.tee, attestationPolicy: options.attestationPolicy }) : options.attestationPolicy ?? {}; let stopped = false; let failures = 0; let sequence = 0; let latest; let timer; let resolveDone; const done = new Promise((resolve) => { resolveDone = resolve; }); const stop = () => { if (stopped) return; stopped = true; if (timer) clearTimeout(timer); resolveDone?.(); }; const ping = async (context) => { if (stopped || options.signal?.aborted) throw new Error("TEE attestation heartbeat is stopped"); const nextSequence = sequence + 1; const boundContext = context ?? `${sessionId}:${nextSequence}`; const challenge = createTeeAttestationChallenge(boundContext); const attestation = (await getTeeAttestation.call(sandbox, { attestationNonce: challenge.nonce }))?.attestation; if (!attestation) throw new Error("TEE attestation heartbeat returned no evidence"); const verification = await verifyAttestationAsync(attestation, { ...policy, expectedNonce: challenge.nonce }); if (!verification.valid) throw new Error(`TEE attestation heartbeat verification failed: ${verification.errors.join("; ")}`); const sample = { sequence: nextSequence, nonce: challenge.nonce, context: boundContext, attestation, verification, checkedAt: /* @__PURE__ */ new Date() }; sequence = nextSequence; latest = sample; options.onSuccess?.(sample); return sample; }; const schedule = () => { if (stopped || options.signal?.aborted) { stop(); return; } timer = setTimeout(() => { loop(); }, intervalMs); }; const loop = async () => { try { await ping(); schedule(); } catch (error) { failures += 1; options.onFailure?.(error); if (options.continueOnFailure) schedule(); else stop(); } }; options.signal?.addEventListener("abort", stop, { once: true }); if (options.immediate === false) schedule(); else loop(); return { stop, ping, get stopped() { return stopped; }, get failures() { return failures; }, get latest() { return latest; }, done }; } function buildSandboxCreateOptions(options) { if (!options.tee && (options.sealed || options.attestationNonce || options.verify)) throw new Error("TEE options require a tee value"); const attestationNonce = options.attestationNonce === "auto" || shouldVerifyAttestation(options) && !options.attestationNonce ? generateAttestationNonce() : options.attestationNonce; if (attestationNonce) validateAttestationNonce(attestationNonce); return { name: options.name, environment: options.environment ?? options.image, sshEnabled: options.ssh || void 0, git: options.gitUrl ? { url: options.gitUrl, ref: options.gitRef } : void 0, resources: options.cpu || options.memoryMb || options.diskGb || options.accelerator ? { cpuCores: options.cpu, memoryMB: options.memoryMb, diskGB: options.diskGb, accelerator: options.accelerator } : void 0, backend: options.backend || options.agentProfile ? { ...options.backend ? { type: options.backend } : {}, ...options.agentProfile ? { profile: options.agentProfile } : {} } : void 0, confidential: options.tee ? { tee: options.tee, sealed: options.sealed || void 0, attestationNonce, attestationRefresh: Boolean(attestationNonce) } : void 0 }; } function shouldVerifyAttestation(options) { return Boolean(options.tee || options.verify); } function buildAttestationPolicy(options) { if (!options.tee || options.tee === "any") return options.attestationPolicy ?? {}; const requestedTypes = acceptedAttestationTypesForTee(options.tee); const acceptedTeeTypes = options.attestationPolicy?.acceptedTeeTypes; if (acceptedTeeTypes?.length && !acceptedTeeTypes.some((type) => requestedTypes.includes(type))) throw new Error(`TEE attestation policy does not accept requested TEE type ${options.tee}`); return { ...options.attestationPolicy, acceptedTeeTypes: acceptedTeeTypes?.length ? acceptedTeeTypes.filter((type) => requestedTypes.includes(type)) : requestedTypes }; } function acceptedAttestationTypesForTee(tee) { switch (tee) { case "phala-dstack": return ["tdx", "phala-dstack"]; case "gcp": return [ "tdx", "sev-snp", "gcp" ]; case "azure": return ["sev-snp", "azure"]; default: return [normalizeTeeType(tee)]; } } function validateAttestationNonce(value) { const normalized = value.trim().toLowerCase().replace(/^0x/, ""); if (!/^[0-9a-f]+$/.test(normalized)) throw new Error("attestation nonce must be hex"); if (normalized.length % 2 !== 0) throw new Error("attestation nonce must have even hex length"); const bytes = normalized.length / 2; if (bytes < 32 || bytes > 64) throw new Error(`attestation nonce must be 32-64 bytes, got ${bytes}`); } function generateAttestationNonce(bytes = 32) { return Array.from(randomBytes(bytes)).map((byte) => byte.toString(16).padStart(2, "0")).join(""); } function attestationFromMetadata(metadata) { const raw = metadata?.teeAttestationJson; if (typeof raw !== "string" || raw.trim() === "") return void 0; try { return JSON.parse(raw); } catch { return; } } //#endregion export { TCloudSandbox, buildSandboxCreateOptions, createTeeAttestationChallenge, generateAttestationNonce, shouldVerifyAttestation, startTeeAttestationHeartbeat };