Files
builder/scripts/test-published-workers.mjs
Nariman JelvehandClaude Opus 5.5 02b39144f5 let users set the email sender name from chat
Email a Builder backend sends with puter.email.sendTransactional is labelled
with the title of the worker's sandbox app, which starts out as its internal
name, so recipients saw "sandbox-<worker>" as the sender.

- New set_email_sender_name tool renames the worker's sandbox app (the
  preview's backend, and the live one when the site is published). Names the
  email service would mangle (@, quotes, over 78 chars) are refused.
- Publish copies a chosen name to the live backend; Duplicate copies it to the
  copy's backends; Delete, retiring a live backend, and a discarded duplicate
  put the default back first, since Puter reuses sandbox apps by name.
- A backend on its default name never triggers a rename or a warning, and a
  live name set by hand in the Dev Center is never overwritten.
- Prompt tells the AI to set a sender name whenever a worker sends email and
  to pass replyTo when recipients should be able to reply.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:07:13 -07:00

406 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs';
import vm from 'node:vm';
// ---- Regression guard: the published app has its own backend ----------------
// A deployed worker is ONE account-level resource behind ONE URL, and the draft
// and published frontends both called it. So the draft/published split
// protected the static files and nothing protected the backend: the moment the
// AI redeployed a worker while building, the published app's behaviour changed
// with it — a draft-only edit broke live clients before Publish was clicked.
//
// Publishing must give the release its own deployment, from a frozen copy of
// the worker's source, and point the release's files at it. Editing the draft's
// worker afterwards must leave the published endpoint exactly as it was.
//
// Drives the real doPublish and the real create_worker tool (VM sandbox, only
// the FS/hosting/worker/UI boundary mocked) with the real WorkerOwnership.
let failures = 0;
function check(name, cond, detail) {
if (cond) console.log('ok - ' + name);
else { console.error('FAIL - ' + name + (detail ? '\n ' + detail : '')); failures++; }
}
const uiSource = fs.readFileSync(new URL('../src/js/ui.js', import.meta.url), 'utf8');
const appSource = fs.readFileSync(new URL('../src/js/app.js', import.meta.url), 'utf8');
const helpersSource = fs.readFileSync(new URL('../src/js/helpers.js', import.meta.url), 'utf8');
const ownershipSource = fs.readFileSync(new URL('../src/js/worker-ownership.js', import.meta.url), 'utf8');
const createWorkerSource = fs.readFileSync(new URL('../src/tools/workers/create_worker.js', import.meta.url), 'utf8');
const listWorkerSource = fs.readFileSync(new URL('../src/tools/workers/list_workers.js', import.meta.url), 'utf8');
const deleteWorkerSource = fs.readFileSync(new URL('../src/tools/workers/delete_worker.js', import.meta.url), 'utf8');
const senderSource = fs.readFileSync(new URL('../src/js/worker-sender.js', import.meta.url), 'utf8');
function extract(src, signature) {
const a = src.indexOf(signature);
if (a < 0) throw new Error('could not find ' + signature);
const b = src.indexOf('\n}\n', a);
if (b < 0) throw new Error('could not find the end of ' + signature);
return src.slice(a, b + 2);
}
function slice(src, from, last) {
const a = src.indexOf(from);
if (a < 0) throw new Error('could not find ' + from);
const b = src.indexOf('\n}\n', src.indexOf(last, a));
if (b < 0) throw new Error('could not find the end of ' + last);
return src.slice(a, b + 2);
}
const CODE = [
helpersSource.slice(
helpersSource.indexOf('const _fileLocks = new Map()'),
helpersSource.indexOf('// Write `data` to `path` and CONFIRM')),
helpersSource.slice(
helpersSource.indexOf('// ---- Worker deploy canary'),
helpersSource.indexOf('// ---- Preview sub-resource cache-busting')),
appSource.match(/^const WORKER_URL_TEXT_FILE_RE = .*$/m)[0],
extract(appSource, 'async function rewriteWorkerUrlsInDir(dir, renames, changed = new Set()) {'),
'window.rewriteWorkerUrlsInDir = rewriteWorkerUrlsInDir;',
extract(uiSource, 'function publishedRootDir() {'),
extract(uiSource, 'function publishedDirForChat(chatId) {'),
extract(uiSource, 'function newReleaseDirName() {'),
slice(uiSource, 'const PUBLISHED_BACKEND_DIR', 'function publishedBackendDir(chatId) {'),
extract(uiSource, 'async function retirePublishedReleases(pubRoot, keepName) {'),
slice(uiSource, 'const LIVE_WORKER_SUFFIX', 'async function retirePublishedWorkers(chatId, keepNames) {'),
extract(uiSource, 'function previewSubdomain(url) {'),
extract(uiSource, 'function shortRand() {'),
extract(uiSource, 'function slugifyTitle(t) {'),
extract(uiSource, 'function publishBlockedReason() {'),
extract(uiSource, 'async function createPublishSubdomain(path, desired'),
extract(helpersSource, 'function generateChatTitle(history) {'),
extract(uiSource, 'async function doPublish() {'),
].join('\n\n');
const ROOT = '/alice/AppData/builder';
const WORK = ROOT + '/chat1';
const PUB = ROOT + '/.published/chat1';
const V1 = "router.get('/', () => ({ version: 1 }));";
const V2 = "router.get('/', () => ({ version: 2, extra: true }));";
function makeWorld({ failDeploy = false, takenLiveName = null, titles = {}, failRename = false } = {}) {
const files = new Map([
[WORK + '/index.html', "fetch('https://api-a.puter.work/items')"],
[WORK + '/workers/api-a.js', V1],
// Already published once, in the pre-release container layout.
[PUB + '/index.html', "fetch('https://api-a.puter.work/items')"],
]);
const deployed = new Map([['api-a', {
name: 'api-a', url: 'https://api-a.puter.work',
file_path: WORK + '/workers/api-a.js',
}]]);
if (takenLiveName) {
deployed.set(takenLiveName, {
name: takenLiveName, url: `https://${takenLiveName}.puter.work`,
file_path: '/alice/AppData/builder/other-project/workers/x.js',
});
}
// Each worker's sandbox app, whose title is the sender name on its email
// (worker-sender.js). puter.js makes one per worker name, titled with
// that name, and reuses it on every later deploy.
const apps = new Map();
const renames = [];
const ensureApp = (name) => {
const key = 'sandbox-' + name;
if (!apps.has(key)) apps.set(key, { uid: 'uid-' + name, name: key, title: key });
};
for (const name of deployed.keys()) ensureApp(name);
for (const [key, title] of Object.entries(titles)) apps.get(key).title = title;
const sites = new Map([['ay-site', PUB]]);
const alerts = [];
const under = dir => [...files.keys()].filter(p => p.startsWith(dir + '/'));
const $stub = () => ({ length: 0, val: () => '', trigger() { return this; }, 0: undefined });
const sandbox = {
currentChatId: 'chat1',
currentAppDir: WORK,
chatHistory: [{ role: 'system', content: '' }, { role: 'user', content: 'Alpha' }],
isProcessing: false,
_publishPanelOpen: false,
_publishPanelView: 'form',
_publishViewIsFirst: false,
_publishSuccessData: null,
renderPublishPanel() {},
setPublishBusy() {},
loadPublishedSiteMeta: async () => ({ title: '', description: '', icon: '' }),
$: $stub,
setTimeout,
URL,
console: { warn() {}, error() {} },
window: {
tools: [],
user: { username: 'alice' },
currentPreviewPath: WORK,
currentPublishedUrl: 'https://ay-site.puter.site/',
currentPublishedPath: PUB,
currentPublishedVersionId: 'a-v0',
_projectDirtySinceSnapshot: false,
getCurrentVersionId: () => 'a-v1',
savePublishedFields: async () => {},
showToast: () => {},
isSubdomainLimitErr: () => false,
isSubdomainTakenErr: () => false,
puterErrInfo: e => ({ message: (e && e.message) || '' }),
projectRootDir: () => WORK,
assertPathInProject: p => p,
writeFileVerified: async (p, data) => { files.set(p, data); return { success: true }; },
},
puter: {
appID: 'builder',
randName: () => 'random-name',
ui: { alert: async m => alerts.push(String(m)) },
fs: {
mkdir: async () => {},
stat: async p => {
if (files.has(p) || under(p).length) return { path: p };
const e = new Error('not found'); e.code = 'subject_does_not_exist'; throw e;
},
read: async p => {
if (!files.has(p)) { const e = new Error('not found'); e.code = 'subject_does_not_exist'; throw e; }
return { text: async () => files.get(p) };
},
write: async (p, data) => { files.set(p, data); },
readdir: async dir => {
const names = new Set();
for (const p of under(dir)) names.add(p.slice(dir.length + 1).split('/')[0]);
if (!names.size) { const e = new Error('not found'); e.code = 'subject_does_not_exist'; throw e; }
return [...names].map(name => ({ name, is_dir: !files.has(dir + '/' + name) }));
},
delete: async p => { files.delete(p); for (const q of under(p)) files.delete(q); },
copy: async (from, to, options) => {
const dest = to + '/' + options.newName;
if (files.has(from)) { files.set(dest, files.get(from)); return; }
const src = under(from);
if (!src.length) { const e = new Error('not found'); e.code = 'subject_does_not_exist'; throw e; }
for (const p of src) files.set(dest + p.slice(from.length), files.get(p));
},
},
hosting: {
create: async (sub, path) => { sites.set(sub, path); return { subdomain: sub }; },
update: async (sub, path) => { sites.set(sub, path); },
},
workers: {
list: async () => [...deployed.values()],
create: async (name, filePath) => {
if (failDeploy && name.endsWith('-live')) throw new Error('deploy failed');
const url = `https://${name}.puter.work`;
// A deployment snapshots the source file's bytes.
deployed.set(name, { name, url, file_path: filePath, code: files.get(filePath) });
ensureApp(name);
return { success: true, url };
},
delete: async name => { deployed.delete(name); },
get: async name => (deployed.has(name) ? { name, app_uid: apps.get('sandbox-' + name).uid } : null),
},
apps: {
get: async name => {
if (!apps.has(name)) throw { error: { code: 'entity_not_found' } };
return { ...apps.get(name) };
},
update: async (name, attrs) => {
if (failRename) throw new Error('rename failed');
renames.push([name, attrs.title]);
apps.get(name).title = attrs.title;
return { ...apps.get(name) };
},
},
},
};
sandbox.saveCurrentChat = async () => {};
vm.createContext(sandbox);
vm.runInContext(ownershipSource, sandbox, { filename: 'src/js/worker-ownership.js' });
vm.runInContext(senderSource, sandbox, { filename: 'src/js/worker-sender.js' });
vm.runInContext(CODE, sandbox, { filename: 'src/js/ui.js' });
for (const [name, src] of [['create_worker', createWorkerSource], ['list_workers', listWorkerSource], ['delete_worker', deleteWorkerSource]]) {
vm.runInContext(src, sandbox, { filename: 'src/tools/workers/' + name + '.js' });
}
const tool = name => sandbox.window.tools.find(t => t.function.name === name);
return { sandbox, files, deployed, sites, alerts, tool, apps, renames, served: () => files.get(sites.get('ay-site') + '/index.html') };
}
// ---- The reported scenario --------------------------------------------------
{
const w = makeWorld();
await w.sandbox.doPublish();
const releaseFrontend = w.served();
check('publish: the release calls a backend of its own, not the draft’s',
/api-a-live\.puter\.work/.test(releaseFrontend) && !/\/\/api-a\.puter\.work/.test(releaseFrontend),
releaseFrontend);
check('publish: the published worker is deployed from a frozen copy',
w.deployed.has('api-a-live') && w.deployed.get('api-a-live').code === V1,
JSON.stringify([...w.deployed.keys()]));
check('publish: its source lives outside the app directory',
w.deployed.get('api-a-live').file_path.indexOf(PUB + '/') === 0,
w.deployed.get('api-a-live').file_path);
check('publish: no error', w.alerts.length === 0, JSON.stringify(w.alerts));
// A draft saved since carries a deploy canary (stampWorkerCanary). The
// published backend gets the code without it.
w.files.set(WORK + '/workers/api-a.js', w.sandbox.window.stampWorkerCanary(V1, '0abc123def45678'));
await w.sandbox.doPublish();
check('republish: the published backend never gets the draft\'s deploy canary',
w.deployed.get('api-a-live').code === V1, w.deployed.get('api-a-live').code);
w.files.set(WORK + '/workers/api-a.js', V1);
// Now the draft edit the report is about: the AI updates the worker.
await w.tool('create_worker').exec({ name: 'api-a', code: V2 }, {});
check('draft edit: the DRAFT worker is updated', w.deployed.get('api-a').code === V2);
check('draft edit: the published backend still runs the published code',
w.deployed.get('api-a-live').code === V1, w.deployed.get('api-a-live').code);
check('draft edit: the published frontend still points at it',
/api-a-live\.puter\.work/.test(w.served()), w.served());
// And publishing again is what moves it.
await w.sandbox.doPublish();
check('republish: the published backend picks up the new code',
w.deployed.get('api-a-live').code === V2, w.deployed.get('api-a-live').code);
check('republish: the published backend keeps its URL, so live clients keep working',
w.deployed.get('api-a-live').url === 'https://api-a-live.puter.work');
}
// ---- The email sender name goes live with the backend ----------------------
{
// A project that never set a sender name: publishing renames nothing.
const w = makeWorld();
await w.sandbox.doPublish();
await w.sandbox.doPublish();
check('sender: a project on the default name renames nothing when it publishes',
w.renames.length === 0 && w.apps.get('sandbox-api-a-live').title === 'sandbox-api-a-live' && w.alerts.length === 0,
JSON.stringify(w.renames));
}
{
const w = makeWorld({ titles: { 'sandbox-api-a': 'Makor Family Portal' } });
await w.sandbox.doPublish();
check('sender: the live backend sends under the preview\'s name',
w.apps.get('sandbox-api-a-live').title === 'Makor Family Portal' && w.alerts.length === 0, JSON.stringify(w.renames));
await w.sandbox.doPublish();
check('sender: publishing again renames nothing more', w.renames.length === 1, JSON.stringify(w.renames));
}
{
// A live backend renamed by hand stays that way while the preview is on
// the default name.
const w = makeWorld();
await w.sandbox.doPublish();
w.apps.get('sandbox-api-a-live').title = 'Hand Set';
await w.sandbox.doPublish();
check('sender: a hand-set live name survives a publish', w.apps.get('sandbox-api-a-live').title === 'Hand Set' && w.renames.length === 0);
}
{
const w = makeWorld({ titles: { 'sandbox-api-a': 'Makor' }, failRename: true });
const toasts = [];
w.sandbox.window.showToast = (m, o) => toasts.push([m, o && o.key]);
await w.sandbox.doPublish();
check('sender: a failed rename never fails the publish',
w.alerts.length === 0 && w.deployed.has('api-a-live') && /api-a-live\.puter\.work/.test(w.served()), JSON.stringify(w.alerts));
check('sender: …and the user is told the live site has the old name',
toasts.some(([m, k]) => k === 'publish-sender' && /old sender name/.test(m)), JSON.stringify(toasts));
}
{
// The draft backend is deleted, so the next publish retires the live one.
// Puter reuses a sandbox app by worker name, so its name goes first.
const w = makeWorld({ titles: { 'sandbox-api-a': 'Makor' } });
await w.sandbox.doPublish();
check('sender: (setup) the live backend has the name', w.apps.get('sandbox-api-a-live').title === 'Makor');
w.deployed.delete('api-a');
w.files.delete(WORK + '/workers/api-a.js');
await w.sandbox.doPublish();
check('sender: a retired live backend gets its default name back',
!w.deployed.has('api-a-live') && w.apps.get('sandbox-api-a-live').title === 'sandbox-api-a-live',
JSON.stringify({ deployed: [...w.deployed.keys()], renames: w.renames }));
}
// ---- The AI cannot reach the live backend ----------------------------------
{
const w = makeWorld();
await w.sandbox.doPublish();
const listed = await w.tool('list_workers').exec({}, {});
check('tools: the live backend is not listed to the model',
listed.workers.map(x => x.name).join() === 'api-a', JSON.stringify(listed.workers));
let refusedDelete = false;
try { await w.tool('delete_worker').exec({ name: 'api-a-live' }, {}); }
catch (e) { refusedDelete = /does not belong to this project/.test(e.message); }
check('tools: the model cannot delete the live backend', refusedDelete);
check('tools: …and it is still deployed', w.deployed.has('api-a-live'));
// Asking for the live backend's name gets a new worker of the project's
// own, under a suffixed name (create_worker never reuses a name it
// doesn't own).
const made = await w.tool('create_worker').exec({ name: 'api-a-live', code: 'x' }, {});
check('tools: the model cannot redeploy over the live backend',
/^api-a-live-[a-z0-9]{6}$/.test(made.name), made.name);
check('tools: …and its code is untouched', w.deployed.get('api-a-live').code === V1);
}
// ---- A backend that cannot be published fails the publish -------------------
{
const w = makeWorld({ failDeploy: true });
await w.sandbox.doPublish();
check('deploy failure: the previous release is still what the address serves',
w.served() === "fetch('https://api-a.puter.work/items')", w.served());
check('deploy failure: the user is told', w.alerts.length === 1
&& /deploy|publish/i.test(w.alerts[0]), JSON.stringify(w.alerts));
}
// ---- A name held by another project is refused, not hijacked ----------------
{
const w = makeWorld({ takenLiveName: 'api-a-live' });
await w.sandbox.doPublish();
check('name clash: another project’s worker is not overwritten',
w.deployed.get('api-a-live').file_path === '/alice/AppData/builder/other-project/workers/x.js');
check('name clash: the publish fails with a rename suggestion',
w.alerts.length === 1 && /rename/i.test(w.alerts[0]), JSON.stringify(w.alerts));
}
// ---- Projects without workers are entirely unaffected ----------------------
{
const w = makeWorld();
w.files.delete(WORK + '/workers/api-a.js');
w.deployed.delete('api-a');
w.files.set(WORK + '/index.html', 'plain static app');
await w.sandbox.doPublish();
check('no workers: the release is published unchanged',
w.served() === 'plain static app' && w.deployed.size === 0, w.served());
check('no workers: no error', w.alerts.length === 0, JSON.stringify(w.alerts));
}
// ---- The release doesn't publish the backend's source ------------------------
// The release is a copy of the served directory, so it carried
// workers/<name>.js — served at <site>/workers/<name>.js, with any key or
// private logic the backend holds. The published worker runs from its frozen
// copy in __backend, so the release's copy is removed; any other file under
// workers/ (a browser Web Worker script the app loads) stays.
{
const w = makeWorld();
w.files.set(WORK + '/workers/sw-helper.js', 'self.onmessage = () => {};');
await w.sandbox.doPublish();
const release = w.sites.get('ay-site');
check('release: the serverless worker source is not served',
!w.files.has(release + '/workers/api-a.js'), [...w.files.keys()].filter(k => k.startsWith(release)).join(', '));
check('release: other files under workers/ are kept', w.files.get(release + '/workers/sw-helper.js') === 'self.onmessage = () => {};');
check('release: the published worker still runs (from its frozen copy)',
w.deployed.get('api-a-live').code === V1 && w.files.has(w.deployed.get('api-a-live').file_path));
check('release: the draft keeps its worker source', w.files.get(WORK + '/workers/api-a.js') === V1);
check('release: no error', w.alerts.length === 0, JSON.stringify(w.alerts));
}
// ---- The Settings dialog's first publish goes through the popover ----------
// Its first-publish path used to be a second copy of the publish flow that
// drifted from doPublish (it once skipped staging entirely, so the public site
// kept calling the draft's workers). It now hands off to the publish popover,
// so there is one flow to keep correct.
{
const a = uiSource.indexOf('async function publishFromSettings() {');
const body = a >= 0 ? uiSource.slice(a, uiSource.indexOf('\n }\n', a)) : '';
check('settings first publish: hands off to the publish popover', body.includes('openPublishPanel()'));
check('settings first publish: opens the project first when another is on screen', body.includes('await loadChat(chatId)'));
const s = uiSource.indexOf('async function submitAddress($form) {');
const submit = s >= 0 ? uiSource.slice(s, uiSource.indexOf("$published.on('click', '.properties-rename-site'", s)) : '';
check('settings: no second copy of the publish flow',
submit.length > 0 && !submit.includes('stagePublishRelease(') && !submit.includes('puter.fs.copy('));
}
if (failures) { console.error(failures + ' failure(s)'); process.exit(1); }
console.log('all published-workers checks passed');