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
This commit is contained in:
russell@unturf.com 2026-01-23 08:34:18 -05:00
parent a00c7fd9e2
commit 52b08894c6
4 changed files with 459 additions and 84 deletions

View file

@ -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 = `<div style="color: #dc3545; font-weight: bold;">${escapeHtml(errorMsg)}</div>`;
if (partialOutput) {
outputHtml += '<div style="color: var(--uncloseai-secondary-text); margin-top: 8px;">Partial output before timeout:</div>';
outputHtml += `<div style="color: var(--uncloseai-primary-text); margin-left: 10px;">${escapeHtml(partialOutput)}</div>`;
}
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 = `<div style="color: #dc3545; font-weight: bold;">${escapeHtml(errorMsg)}</div>`;
if (partialOutput) {
outputHtml += '<div style="color: var(--uncloseai-secondary-text); margin-top: 8px;">Partial output:</div>';
outputHtml += `<div style="color: var(--uncloseai-primary-text); margin-left: 10px;">${escapeHtml(partialOutput)}</div>`;
}
resultsContainer.innerHTML = outputHtml;
}
// Helper function to display execution results
export function displayExecutionResults(result, resultsContainer, language) {
// Format and display results

View file

@ -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-...)",
};

View file

@ -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";

226
public/src/unsandbox.js Normal file
View file

@ -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 || [];
}