mirror of
https://github.com/hanzoai/chat.git
synced 2026-09-30 20:51:17 +00:00
execute_code no longer POSTs /v1/exec behind a shared service key. It leases a Hanzo sandbox (cloud /v1/sandboxes) with the CALLER's own hanzo.id bearer, runs the program there, and reports what it printed and what it wrote. The session IS the sandbox. `session_id` is the sandbox id, the artifact directory is its workdir, an expired session is a lease the reaper ended. There is no session table here because there is no second object to keep in sync. Why the key had to be deleted rather than fixed ----------------------------------------------- A shared service key IS custom auth, and it is also why that surface could not bill: it carries no tenant, so cloud could not tell whose org a run belonged to — apps/exec's own header says so. With the user's bearer the run lands on the account that ran it, because the account is the only thing the call carries. It was never even wired: `EnvVar.CODE_API_KEY` does not exist in the shipped @hanzochat/agents, so `authFields: [undefined]` at every call site and setting the env var was a silent no-op. Deleted at all ~20 sites, plus the guard in loadAuthValues that existed only to survive the `undefined`, plus the client's key-entry path in the tool-auth contract (`fieldsMap[execute_code]` is now `[]` and verifyToolAuth never consults the credential store for it). What the tool does now ---------------------- One round trip. The program rides the exec channel's stdin, so the source is never quoted into a command line. Its stdout/stderr are redirected to files, so the command's own stdout carries only four framed lines — exit code, then base64 of out, err and the new-file list. That is unforgeable by construction: a delimiter would let a program that printed the delimiter name files it never wrote. Scratch lives in /tmp, never in /mnt/data, so the artifact scan cannot mistake this tool's own source for a user's output. The language menu is measured against the sandbox image rather than inherited: `d`, `f90` and `r` are dropped (no compiler), `rb` added, `ts` runs on bun. Files in and out ride the same three verbs: upload is POST /:id/fs, download is GET /:id/fs, and "is this session still alive?" is GET /:id — one question where there used to be a `lastModified` fetch and a 23-hour freshness rule that only together approximated it. PTC is declared and not executable: it is a different protocol (it suspends the run and resumes from a continuation token) and cloud answers it 501, so offering a tool that fails mid-run is worse than not offering one. De-branding: user-facing prose first, wire second. The `LIBRECHAT_TEST_RUN_HOOK` env var and the `@librechat/*` requires are renamed because BOTH ends live in this repo. The `application/vnd.librechat.*` artifact MIMEs are NOT renamed — they are identifiers on the wire, no user reads them, and a rename needs a migration story it would not buy anything. NOTICE and LICENSE are untouched.
107 lines
4.2 KiB
JavaScript
107 lines
4.2 KiB
JavaScript
const { Readable } = require('stream');
|
|
const { logger } = require('@hanzochat/data-schemas');
|
|
const {
|
|
sandboxForRequest,
|
|
SANDBOX_ID_RE,
|
|
ARTIFACT_NAME_RE,
|
|
} = require('@hanzochat/api');
|
|
|
|
/**
|
|
* Files in and out of the code environment, which is a sandbox.
|
|
*
|
|
* There is no second protocol and no second credential. `POST /:id/fs?path=`
|
|
* writes a file, `GET /:id/fs?path=` reads it, and the sandbox is leased under the
|
|
* caller's own IAM bearer — so a file belongs to the org that uploaded it because
|
|
* that is the only org the call could have reached.
|
|
*
|
|
* The `fileIdentifier` chat stores against a file is `"<sandbox id>/<name>"`, the
|
|
* same two-segment shape as before. Its halves just mean something now: the first
|
|
* is a sandbox that can be asked whether it is still alive, the second is a path
|
|
* in it. Previously they were opaque ids in a service nothing could see into.
|
|
*/
|
|
|
|
/** Refuse a file whose name cannot survive being a URL segment and half of a
|
|
* `session/name` identifier. Checked at the boundary, once. */
|
|
function artifactName(name) {
|
|
const clean = String(name ?? '').trim();
|
|
if (!ARTIFACT_NAME_RE.test(clean)) {
|
|
throw new Error(`Unsupported filename for the code environment: ${JSON.stringify(name)}`);
|
|
}
|
|
return clean;
|
|
}
|
|
|
|
const MAX_FILE_SIZE = 150 * 1024 * 1024;
|
|
|
|
/**
|
|
* Read one artifact back out of the sandbox that holds it.
|
|
*
|
|
* Answers the axios-ish shape the download route already pipes — headers plus a
|
|
* readable — because the route's job is to stream bytes and that has not changed.
|
|
*
|
|
* @param {string} fileIdentifier - `"<sandbox id>/<name>"`.
|
|
* @param {ServerRequest} req - The caller, whose IAM bearer authorizes the read.
|
|
* @returns {Promise<{ headers: Record<string,string>, data: import('stream').Readable }>}
|
|
*/
|
|
async function getCodeOutputDownloadStream(fileIdentifier, req) {
|
|
const [pathPart] = String(fileIdentifier ?? '').split('?');
|
|
const [session_id, ...rest] = pathPart.split('/');
|
|
const name = artifactName(rest.join('/'));
|
|
if (!SANDBOX_ID_RE.test(session_id)) {
|
|
throw new Error(`Invalid code environment session: ${session_id}`);
|
|
}
|
|
|
|
const sandbox = await sandboxForRequest(req, session_id);
|
|
if (sandbox.id !== session_id) {
|
|
/* The lease behind this file is gone and a fresh sandbox is empty, so the
|
|
* bytes are genuinely not retrievable here. Saying so beats streaming an
|
|
* empty file that looks like a successful download of nothing. */
|
|
throw new Error(`Code environment session ${session_id} has expired`);
|
|
}
|
|
const buffer = await sandbox.readBuffer(name);
|
|
if (buffer == null) {
|
|
throw new Error(`No such file in session ${session_id}: ${name}`);
|
|
}
|
|
return {
|
|
headers: {
|
|
'Content-Type': 'application/octet-stream',
|
|
'Content-Length': String(buffer.length),
|
|
'Content-Disposition': `attachment; filename="${name.replace(/"/g, '')}"`,
|
|
},
|
|
data: Readable.from(buffer),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Put a file into the code environment.
|
|
*
|
|
* @param {Object} params
|
|
* @param {ServerRequest} params.req
|
|
* @param {import('stream').Readable} params.stream
|
|
* @param {string} params.filename
|
|
* @param {string} [params.session_id] - Write into this sandbox when it is still
|
|
* leased; otherwise a fresh one is taken and its id comes back in the identifier.
|
|
* @param {string} [params.entity_id]
|
|
* @returns {Promise<string>} `"<sandbox id>/<name>"`, plus `?entity_id=` when given.
|
|
*/
|
|
async function uploadCodeEnvFile({ req, stream, filename, session_id, entity_id = '' }) {
|
|
const name = artifactName(filename);
|
|
const chunks = [];
|
|
let bytes = 0;
|
|
for await (const chunk of stream) {
|
|
bytes += chunk.length;
|
|
if (bytes > MAX_FILE_SIZE) {
|
|
throw new Error(`File ${name} exceeds the ${MAX_FILE_SIZE} byte code environment limit`);
|
|
}
|
|
chunks.push(chunk);
|
|
}
|
|
|
|
const sandbox = await sandboxForRequest(req, session_id);
|
|
await sandbox.write(name, Buffer.concat(chunks));
|
|
logger.debug('[uploadCodeEnvFile] wrote to sandbox', { sandbox: sandbox.id, name, bytes });
|
|
|
|
const fileIdentifier = `${sandbox.id}/${name}`;
|
|
return entity_id.length === 0 ? fileIdentifier : `${fileIdentifier}?entity_id=${entity_id}`;
|
|
}
|
|
|
|
module.exports = { getCodeOutputDownloadStream, uploadCodeEnvFile };
|