From 52b08894c6ae174dc0d2bc9adc8cf4e4ed65ab41 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Fri, 23 Jan 2026 08:34:18 -0500 Subject: [PATCH] Add unsandbox.com API key settings to modal - Add public/src/unsandbox.js browser SDK with HMAC-SHA256 auth - Add settings UI for unsandbox public/secret keys in modal - Refactor code-execution.js to use SDK when keys configured - Falls back to unauthenticated endpoint when not configured - Add English translations for new settings --- public/src/code-execution.js | 241 ++++++++++++++++++---------- public/src/languages/en.js | 5 + public/src/uncloseai-embed-modal.js | 71 ++++++++ public/src/unsandbox.js | 226 ++++++++++++++++++++++++++ 4 files changed, 459 insertions(+), 84 deletions(-) create mode 100644 public/src/unsandbox.js diff --git a/public/src/code-execution.js b/public/src/code-execution.js index bb1f031..013e18c 100644 --- a/public/src/code-execution.js +++ b/public/src/code-execution.js @@ -1,5 +1,6 @@ // Code execution sandbox functionality import { CODE_EXEC_URL } from "./config.js"; +import * as unsandbox from "./unsandbox.js"; // Helper function to sleep/wait function sleep(ms) { @@ -28,90 +29,11 @@ export async function executeCode(code, language, resultsContainer, playButton) language = 'python'; } - // Use /execute/async endpoint with polling - const asyncResponse = await fetch(`${CODE_EXEC_URL}/execute/async`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - language: language, - code: code - }) - }); - - if (!asyncResponse.ok) { - throw new Error(`HTTP error! status: ${asyncResponse.status}`); - } - - const { job_id } = await asyncResponse.json(); - - // Poll for results: 300ms, 750ms, 1450ms, 2350ms, 3000ms, 4600ms, 6600ms+ - const delays = [300, 450, 700, 900, 650, 1600, 2000]; - let pollCount = 0; - let cancelButtonShown = false; - - // Create cancel button (hidden initially) - let cancelButton = resultsContainer.querySelector('.uncloseai-cancel-execution-btn'); - if (!cancelButton) { - cancelButton = document.createElement('button'); - cancelButton.textContent = 'Cancel'; - cancelButton.className = 'uncloseai-cancel-execution-btn uncloseai-btn-small'; - cancelButton.style.display = 'none'; - cancelButton.style.marginTop = '8px'; - cancelButton.onclick = async () => { - try { - await fetch(`${CODE_EXEC_URL}/jobs/${job_id}`, { method: 'DELETE' }); - cancelButton.disabled = true; - cancelButton.textContent = 'Cancelling...'; - } catch (error) { - console.error('Error cancelling job:', error); - } - }; - resultsContainer.appendChild(cancelButton); - } - - while (true) { - await sleep(delays[Math.min(pollCount, delays.length - 1)]); - pollCount++; - - const jobResponse = await fetch(`${CODE_EXEC_URL}/jobs/${job_id}`); - if (!jobResponse.ok) { - throw new Error(`Failed to fetch job status: ${jobResponse.status}`); - } - - const job = await jobResponse.json(); - - if (job.status !== 'pending' && job.status !== 'running') { - // Job finished - hide cancel button - if (cancelButton) { - cancelButton.style.display = 'none'; - } - - if (job.status === 'completed') { - const result = job.result; - displayExecutionResults(result, resultsContainer, language); - break; - } - - // timeout or cancelled - const errorMsg = job.result?.error || 'Execution failed'; - const partialOutput = job.result?.partial_output; - - let outputHtml = `
${escapeHtml(errorMsg)}
`; - if (partialOutput) { - outputHtml += '
Partial output before timeout:
'; - outputHtml += `
${escapeHtml(partialOutput)}
`; - } - resultsContainer.innerHTML = outputHtml; - break; - } - - // Show cancel button after poll #5 (3000ms) if still running - if (!cancelButtonShown && pollCount === 5) { - cancelButtonShown = true; - cancelButton.style.display = 'inline-block'; - } + // Check if unsandbox.com API is configured + if (unsandbox.isConfigured()) { + await executeWithUnsandbox(code, language, resultsContainer); + } else { + await executeWithFallback(code, language, resultsContainer); } } catch (error) { @@ -124,6 +46,157 @@ export async function executeCode(code, language, resultsContainer, playButton) } } +// Execute using unsandbox.com SDK (authenticated) +async function executeWithUnsandbox(code, language, resultsContainer) { + let pollCount = 0; + let cancelButton = null; + let currentJobId = null; + + try { + // Start async execution to get job_id for cancel button + currentJobId = await unsandbox.executeAsync(language, code); + + // Create cancel button (shown after 3 seconds) + cancelButton = createCancelButton(resultsContainer, async () => { + if (currentJobId) { + try { + await unsandbox.cancelJob(currentJobId); + cancelButton.disabled = true; + cancelButton.textContent = 'Cancelling...'; + } catch (error) { + console.error('Error cancelling job:', error); + } + } + }); + + // Wait for job with progress callback + const result = await unsandbox.waitForJob(currentJobId, null, null, (count) => { + pollCount = count; + // Show cancel button after poll #5 (around 3 seconds) + if (count >= 5 && cancelButton) { + cancelButton.style.display = 'inline-block'; + } + }); + + // Hide cancel button + if (cancelButton) { + cancelButton.style.display = 'none'; + } + + // Display result + if (result.status === 'completed') { + displayExecutionResults(result, resultsContainer, language); + } else { + displayErrorResult(result, resultsContainer); + } + + } catch (error) { + if (cancelButton) { + cancelButton.style.display = 'none'; + } + throw error; + } +} + +// Execute using fallback endpoint (unauthenticated) +async function executeWithFallback(code, language, resultsContainer) { + // Use /execute/async endpoint with polling + const asyncResponse = await fetch(`${CODE_EXEC_URL}/execute/async`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + language: language, + code: code + }) + }); + + if (!asyncResponse.ok) { + throw new Error(`HTTP error! status: ${asyncResponse.status}`); + } + + const { job_id } = await asyncResponse.json(); + + // Poll for results using same delays as SDK + const delays = [300, 450, 700, 900, 650, 1600, 2000]; + let pollCount = 0; + + // Create cancel button + const cancelButton = createCancelButton(resultsContainer, async () => { + try { + await fetch(`${CODE_EXEC_URL}/jobs/${job_id}`, { method: 'DELETE' }); + cancelButton.disabled = true; + cancelButton.textContent = 'Cancelling...'; + } catch (error) { + console.error('Error cancelling job:', error); + } + }); + + while (true) { + await sleep(delays[Math.min(pollCount, delays.length - 1)]); + pollCount++; + + const jobResponse = await fetch(`${CODE_EXEC_URL}/jobs/${job_id}`); + if (!jobResponse.ok) { + throw new Error(`Failed to fetch job status: ${jobResponse.status}`); + } + + const job = await jobResponse.json(); + + if (job.status !== 'pending' && job.status !== 'running') { + // Job finished - hide cancel button + cancelButton.style.display = 'none'; + + if (job.status === 'completed') { + const result = job.result || job; + displayExecutionResults(result, resultsContainer, language); + } else { + displayErrorResult(job.result || job, resultsContainer); + } + break; + } + + // Show cancel button after poll #5 (around 3 seconds) + if (pollCount === 5) { + cancelButton.style.display = 'inline-block'; + } + } +} + +// Create cancel button helper +function createCancelButton(resultsContainer, onCancel) { + let cancelButton = resultsContainer.querySelector('.uncloseai-cancel-execution-btn'); + if (!cancelButton) { + cancelButton = document.createElement('button'); + cancelButton.textContent = 'Cancel'; + cancelButton.className = 'uncloseai-cancel-execution-btn uncloseai-btn-small'; + cancelButton.style.display = 'none'; + cancelButton.style.marginTop = '8px'; + cancelButton.onclick = onCancel; + resultsContainer.appendChild(cancelButton); + } else { + cancelButton.disabled = false; + cancelButton.textContent = 'Cancel'; + cancelButton.style.display = 'none'; + cancelButton.onclick = onCancel; + } + return cancelButton; +} + +// Display error result +function displayErrorResult(result, resultsContainer) { + const errorMsg = result.error || result.stderr || result.message || `Execution ${result.status || 'failed'}`; + const partialOutput = result.stdout || result.partial_output; + + let outputHtml = `
${escapeHtml(errorMsg)}
`; + if (partialOutput) { + outputHtml += '
Partial output:
'; + outputHtml += `
${escapeHtml(partialOutput)}
`; + } + resultsContainer.innerHTML = outputHtml; +} + // Helper function to display execution results export function displayExecutionResults(result, resultsContainer, language) { // Format and display results diff --git a/public/src/languages/en.js b/public/src/languages/en.js index 850fc4b..7030d6e 100644 --- a/public/src/languages/en.js +++ b/public/src/languages/en.js @@ -112,4 +112,9 @@ export const en = { pleaseEnterTranslateText: "Please enter some text to translate!", aiModalNotAvailable: "AI modal not available in this context", useCustomAPI: "Use a custom openai compatible endpoint.", + + // Unsandbox code execution settings + useUnsandbox: "Use unsandbox.com API keys for code execution.", + unsandboxPublicKey: "Public Key (unsb-pk-...)", + unsandboxSecretKey: "Secret Key (unsb-sk-...)", }; \ No newline at end of file diff --git a/public/src/uncloseai-embed-modal.js b/public/src/uncloseai-embed-modal.js index 79c5249..036c692 100644 --- a/public/src/uncloseai-embed-modal.js +++ b/public/src/uncloseai-embed-modal.js @@ -572,6 +572,76 @@ async function openUncloseaiEmbeddedModalNew() { apiConfigSection.appendChild(customAPIToggle); apiConfigSection.appendChild(apiConfigInputs); + // Unsandbox Code Execution section + const unsandboxSection = document.createElement("div"); + unsandboxSection.className = "uncloseai-section"; + + // Toggle for unsandbox API keys + const unsandboxToggle = document.createElement("div"); + unsandboxToggle.className = "uncloseai-toggle-container"; + + const unsandboxCheckbox = document.createElement("input"); + unsandboxCheckbox.type = "checkbox"; + unsandboxCheckbox.id = "unsandbox-toggle"; + unsandboxCheckbox.checked = localStorage.getItem("useUnsandbox") === "true"; + + const unsandboxToggleLabel = document.createElement("label"); + unsandboxToggleLabel.htmlFor = "unsandbox-toggle"; + unsandboxToggleLabel.textContent = getUIText("useUnsandbox"); + unsandboxToggleLabel.className = "uncloseai-toggle-label"; + + unsandboxToggle.appendChild(unsandboxCheckbox); + unsandboxToggle.appendChild(unsandboxToggleLabel); + + // Public Key input + const unsandboxPublicKeyContainer = document.createElement("div"); + unsandboxPublicKeyContainer.className = "uncloseai-input-container"; + + const unsandboxPublicKeyInput = document.createElement("input"); + unsandboxPublicKeyInput.type = "text"; + unsandboxPublicKeyInput.placeholder = getUIText("unsandboxPublicKey"); + unsandboxPublicKeyInput.value = localStorage.getItem("unsandboxPublicKey") || ""; + unsandboxPublicKeyInput.className = "uncloseai-input"; + + unsandboxPublicKeyContainer.appendChild(unsandboxPublicKeyInput); + + // Secret Key input + const unsandboxSecretKeyContainer = document.createElement("div"); + unsandboxSecretKeyContainer.className = "uncloseai-input-container"; + + const unsandboxSecretKeyInput = document.createElement("input"); + unsandboxSecretKeyInput.type = "password"; + unsandboxSecretKeyInput.placeholder = getUIText("unsandboxSecretKey"); + unsandboxSecretKeyInput.value = localStorage.getItem("unsandboxSecretKey") || ""; + unsandboxSecretKeyInput.className = "uncloseai-input"; + + unsandboxSecretKeyContainer.appendChild(unsandboxSecretKeyInput); + + // Unsandbox inputs container + const unsandboxInputs = document.createElement("div"); + unsandboxInputs.className = "uncloseai-api-inputs"; + unsandboxInputs.style.display = unsandboxCheckbox.checked ? "block" : "none"; + unsandboxInputs.appendChild(unsandboxPublicKeyContainer); + unsandboxInputs.appendChild(unsandboxSecretKeyContainer); + + // Event handlers for unsandbox + unsandboxCheckbox.onchange = () => { + const useUnsandbox = unsandboxCheckbox.checked; + localStorage.setItem("useUnsandbox", useUnsandbox.toString()); + unsandboxInputs.style.display = useUnsandbox ? "block" : "none"; + }; + + unsandboxPublicKeyInput.onchange = () => { + localStorage.setItem("unsandboxPublicKey", unsandboxPublicKeyInput.value); + }; + + unsandboxSecretKeyInput.onchange = () => { + localStorage.setItem("unsandboxSecretKey", unsandboxSecretKeyInput.value); + }; + + unsandboxSection.appendChild(unsandboxToggle); + unsandboxSection.appendChild(unsandboxInputs); + // Action buttons const actionsSection = document.createElement("div"); actionsSection.className = "uncloseai-section"; @@ -650,6 +720,7 @@ async function openUncloseaiEmbeddedModalNew() { const rightColumn = document.createElement("div"); rightColumn.className = "uncloseai-settings-column"; rightColumn.appendChild(apiConfigSection); + rightColumn.appendChild(unsandboxSection); // Quick actions span both columns actionsSection.className = "uncloseai-section uncloseai-actions-full-width"; diff --git a/public/src/unsandbox.js b/public/src/unsandbox.js new file mode 100644 index 0000000..76fd7c5 --- /dev/null +++ b/public/src/unsandbox.js @@ -0,0 +1,226 @@ +/** + * Unsandbox.com Browser SDK + * Browser-compatible wrapper for unsandbox.com API + * Based on official SDK patterns from lib/un.js + */ + +const API_BASE = "https://api.unsandbox.com"; +const POLL_DELAYS_MS = [300, 450, 700, 900, 650, 1600, 2000]; + +/** + * Get credentials from localStorage + */ +export function getCredentials() { + const useUnsandbox = localStorage.getItem("useUnsandbox") === "true"; + if (!useUnsandbox) return null; + + const publicKey = localStorage.getItem("unsandboxPublicKey"); + const secretKey = localStorage.getItem("unsandboxSecretKey"); + + if (publicKey && secretKey) { + return { publicKey, secretKey }; + } + return null; +} + +/** + * Check if unsandbox.com API is configured + */ +export function isConfigured() { + return getCredentials() !== null; +} + +/** + * Sign a request using HMAC-SHA256 (Web Crypto API) + * Message format: "timestamp:METHOD:path:body" + */ +async function signRequest(secretKey, timestamp, method, path, body = "") { + const message = `${timestamp}:${method}:${path}:${body}`; + const encoder = new TextEncoder(); + const keyData = encoder.encode(secretKey); + const messageData = encoder.encode(message); + + const cryptoKey = await crypto.subtle.importKey( + "raw", + keyData, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + + const signature = await crypto.subtle.sign("HMAC", cryptoKey, messageData); + const hashArray = Array.from(new Uint8Array(signature)); + return hashArray.map(b => b.toString(16).padStart(2, "0")).join(""); +} + +/** + * Make an authenticated request to the unsandbox.com API + */ +async function makeRequest(method, path, publicKey, secretKey, data = null) { + const timestamp = Math.floor(Date.now() / 1000); + const body = data ? JSON.stringify(data) : ""; + const signature = await signRequest(secretKey, timestamp, method, path, data ? body : ""); + + const headers = { + "Authorization": `Bearer ${publicKey}`, + "X-Timestamp": timestamp.toString(), + "X-Signature": signature, + "Content-Type": "application/json", + }; + + const options = { + method, + headers, + }; + + if (data) { + options.body = body; + } + + const response = await fetch(`${API_BASE}${path}`, options); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + return response.json(); +} + +/** + * Execute code and wait for completion + */ +export async function executeCode(language, code, publicKey = null, secretKey = null) { + // Use provided credentials or get from localStorage + if (!publicKey || !secretKey) { + const creds = getCredentials(); + if (!creds) { + throw new Error("No unsandbox.com credentials configured"); + } + publicKey = creds.publicKey; + secretKey = creds.secretKey; + } + + const response = await makeRequest("POST", "/execute", publicKey, secretKey, { + language, + code, + }); + + // Check if we got immediate result or need to poll + const status = response.status; + if (status === "completed" || status === "failed" || status === "timeout" || status === "cancelled") { + return response; + } + + // Need to poll + const jobId = response.job_id; + if (!jobId) { + throw new Error("No job_id returned from execute endpoint"); + } + + return waitForJob(jobId, publicKey, secretKey); +} + +/** + * Execute code asynchronously (returns job_id immediately) + */ +export async function executeAsync(language, code, publicKey = null, secretKey = null) { + if (!publicKey || !secretKey) { + const creds = getCredentials(); + if (!creds) { + throw new Error("No unsandbox.com credentials configured"); + } + publicKey = creds.publicKey; + secretKey = creds.secretKey; + } + + const response = await makeRequest("POST", "/execute", publicKey, secretKey, { + language, + code, + }); + + return response.job_id; +} + +/** + * Get job status/result + */ +export async function getJob(jobId, publicKey = null, secretKey = null) { + if (!publicKey || !secretKey) { + const creds = getCredentials(); + if (!creds) { + throw new Error("No unsandbox.com credentials configured"); + } + publicKey = creds.publicKey; + secretKey = creds.secretKey; + } + + return makeRequest("GET", `/jobs/${jobId}`, publicKey, secretKey); +} + +/** + * Wait for job completion with exponential backoff polling + */ +export async function waitForJob(jobId, publicKey = null, secretKey = null, onPoll = null) { + if (!publicKey || !secretKey) { + const creds = getCredentials(); + if (!creds) { + throw new Error("No unsandbox.com credentials configured"); + } + publicKey = creds.publicKey; + secretKey = creds.secretKey; + } + + let pollCount = 0; + + while (true) { + // Sleep before polling + const delayIdx = Math.min(pollCount, POLL_DELAYS_MS.length - 1); + await new Promise(resolve => setTimeout(resolve, POLL_DELAYS_MS[delayIdx])); + pollCount++; + + const response = await getJob(jobId, publicKey, secretKey); + + // Callback for progress updates + if (onPoll) { + onPoll(pollCount, response); + } + + const status = response.status; + if (status === "completed" || status === "failed" || status === "timeout" || status === "cancelled") { + return response; + } + } +} + +/** + * Cancel a running job + */ +export async function cancelJob(jobId, publicKey = null, secretKey = null) { + if (!publicKey || !secretKey) { + const creds = getCredentials(); + if (!creds) { + throw new Error("No unsandbox.com credentials configured"); + } + publicKey = creds.publicKey; + secretKey = creds.secretKey; + } + + return makeRequest("DELETE", `/jobs/${jobId}`, publicKey, secretKey); +} + +/** + * Get supported languages + */ +export async function getLanguages(publicKey = null, secretKey = null) { + if (!publicKey || !secretKey) { + const creds = getCredentials(); + if (!creds) { + throw new Error("No unsandbox.com credentials configured"); + } + publicKey = creds.publicKey; + secretKey = creds.secretKey; + } + + const response = await makeRequest("GET", "/languages", publicKey, secretKey); + return response.languages || []; +}