@evolvingmachines/modal 0.0.51 → 0.0.52-berth-day-one.20260814.cc8d926

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,12 @@
1
- # Evolve SDK Modal Provider
1
+ <br><br>
2
+
3
+ <p align="center">
4
+ <img src="https://raw.githubusercontent.com/evolving-machines-lab/evolve/main/logo/evolving-machines-ink.png" alt="Evolving Machines" width="380">
5
+ </p>
6
+
7
+ <br><br>
8
+
9
+ # Evolving Machines — Evolve Modal Provider
2
10
 
3
11
  Modal sandbox provider for [Evolve SDK](https://www.npmjs.com/package/@evolvingmachines/sdk).
4
12
 
package/dist/index.cjs CHANGED
@@ -1,4 +1,4 @@
1
- 'use strict';var modal=require('modal'),tarStream=require('tar-stream');var b={"evolve-all":"evolvingmachines/evolve-all"},h=new Set([".xlsx",".xls",".docx",".doc",".pptx",".ppt",".pdf",".zip",".tar",".gz",".7z",".rar",".png",".jpg",".jpeg",".gif",".webp",".ico",".bmp",".mp3",".wav",".ogg",".flac",".aac",".mp4",".avi",".mov",".mkv",".webm",".woff",".woff2",".ttf",".otf",".eot",".exe",".dll",".so",".dylib",".sqlite",".db",".pickle",".pkl",".parquet"]);function y(c){let t=c.substring(c.lastIndexOf(".")).toLowerCase();return h.has(t)}var u=class{constructor(t){this.sandbox=t;}wrapAsUser(t,e,n){let r="";n&&Object.keys(n).length>0&&(r=Object.entries(n).filter(([,o])=>o!=null).map(([o,i])=>`export ${o}='${String(i).replace(/'/g,"'\\''")}'`).join("; ")+"; ");let s=e?`cd '${e.replace(/'/g,"'\\''")}' && ${r}${t}`:`${r}${t}`;return ["su","user","-c",`echo ${Buffer.from(s).toString("base64")} | base64 -d | bash`]}async run(t,e){let n=this.wrapAsUser(t,e?.cwd,e?.envs),r=await this.sandbox.exec(n,{timeoutMs:e?.timeoutMs}),{stdout:s,stderr:a}=await this.accumulateStreams(r,e?.onStdout,e?.onStderr);return {exitCode:await r.wait(),stdout:s,stderr:a}}async spawn(t,e){let n=this.wrapAsUser(t,e?.cwd,e?.envs),r=await this.sandbox.exec(n,{timeoutMs:e?.timeoutMs}),s="",a="",o=this.accumulateStreams(r,e?.onStdout?d=>{e.onStdout(d);}:void 0,e?.onStderr?d=>{e.onStderr(d);}:void 0).then(({stdout:d,stderr:m})=>{s=d,a=m;}).catch(()=>{});return {processId:`modal-${Date.now()}-${Math.random().toString(36).slice(2)}`,wait:async()=>(await o,{exitCode:await r.wait(),stdout:s,stderr:a}),kill:async()=>false}}async list(){let t=await this.sandbox.exec(["ps","-eo","pid,comm,args"],{timeoutMs:1e4});return await t.wait(),(await t.stdout.readText()).trim().split(`
2
- `).slice(1).map(r=>{let s=r.trim().split(/\s+/);return {processId:s[0],cmd:s[1]||"",args:s.slice(2),envs:{}}})}async connect(t,e){throw new Error("Modal does not support connecting to existing processes")}async sendStdin(t,e){throw new Error("Modal does not support sendStdin by process ID")}async kill(t){return await(await this.sandbox.exec(["kill","-9",t],{timeoutMs:1e4})).wait()===0}async accumulateStreams(t,e,n){let r="",s="",a=[];return a.push((async()=>{try{for await(let o of t.stdout){let i=typeof o=="string"?o:new TextDecoder().decode(o);r+=i,e?.(i);}}catch{}})()),a.push((async()=>{try{for await(let o of t.stderr){let i=typeof o=="string"?o:new TextDecoder().decode(o);s+=i,n?.(i);}}catch{}})()),await Promise.all(a),{stdout:r,stderr:s}}},g=class{constructor(t){this.sandbox=t;}async read(t){if(y(t)){let r=await this.sandbox.exec(["cat",t],{timeoutMs:3e5,mode:"binary"}),s=await r.wait();if(s!==0){let a=await r.stderr.readText();throw new Error(`Failed to read file ${t}: ${a||`exit code ${s}`}`)}return await r.stdout.readBytes()}let e=await this.sandbox.exec(["cat",t],{timeoutMs:3e5}),n=await e.wait();if(n!==0){let r=await e.stderr.readText();throw new Error(`Failed to read file ${t}: ${r||`exit code ${n}`}`)}return await e.stdout.readText()}async write(t,e){let n=this.toBuffer(e),r=t.substring(0,t.lastIndexOf("/"));r&&await this.makeDir(r);let s=t.replace(/'/g,"'\\''"),a=await this.sandbox.exec(["bash","-c",`cat > '${s}'`],{mode:"binary"});await a.stdin.writeBytes(new Uint8Array(n)),await a.stdin.getWriter().close(),await a.wait(),await(await this.sandbox.exec(["chown","user:user",t],{timeoutMs:1e4})).wait();}async writeBatch(t){let e=tarStream.pack(),n=[],r=new Set;for(let i of t){let d=this.toBuffer(i.data),m=i.path.startsWith("/")?i.path.slice(1):i.path;e.entry({name:m},d);let p=i.path.substring(0,i.path.lastIndexOf("/"));p&&r.add(p);}e.finalize();for await(let i of e)n.push(Buffer.from(i));let s=Buffer.concat(n),a=await this.sandbox.exec(["tar","-xf","-","-C","/"],{mode:"binary"});if(await a.stdin.writeBytes(new Uint8Array(s)),await a.stdin.getWriter().close(),await a.wait(),r.size>0){let i=Array.from(r),d=new Set(i.map(m=>m.split("/").slice(0,4).join("/")));for(let m of d)await(await this.sandbox.exec(["chown","-R","user:user",m],{timeoutMs:3e4})).wait();}}async makeDir(t){await(await this.sandbox.exec(["mkdir","-p",t],{timeoutMs:1e4})).wait(),await(await this.sandbox.exec(["chown","-R","user:user",t],{timeoutMs:1e4})).wait();}async exists(t){return await(await this.sandbox.exec(["test","-e",t],{timeoutMs:1e4})).wait()===0}async list(t){let e=t.replace(/'/g,"'\\''"),n=await this.sandbox.exec(["bash","-c",`ls -la '${e}' | tail -n +2`],{timeoutMs:3e4});await n.wait();let r=await n.stdout.readText(),s=[];for(let a of r.trim().split(`
3
- `)){if(!a)continue;let o=a.split(/\s+/);if(o.length<9)continue;let i=o[0],d=o.slice(8).join(" ");d==="."||d===".."||s.push({name:d,path:t.endsWith("/")?`${t}${d}`:`${t}/${d}`,type:i.startsWith("d")?"dir":"file"});}return s}async remove(t){await(await this.sandbox.exec(["rm","-rf",t],{timeoutMs:3e4})).wait();}async rename(t,e){await(await this.sandbox.exec(["mv",t,e],{timeoutMs:3e4})).wait();}async readStream(t){return (await this.sandbox.exec(["cat",t],{timeoutMs:3e5,mode:"binary"})).stdout}async writeStream(t,e){let n=t.substring(0,t.lastIndexOf("/"));n&&await this.makeDir(n);let r=t.replace(/'/g,"'\\''"),s=await this.sandbox.exec(["bash","-c",`cat > '${r}'`],{mode:"binary"}),a=e.getReader();try{for(;;){let{done:i,value:d}=await a.read();if(i)break;await s.stdin.writeBytes(d);}}finally{a.releaseLock();}await s.stdin.getWriter().close(),await s.wait();}async uploadUrl(t,e){throw new Error("Modal does not support pre-signed upload URLs")}async downloadUrl(t,e){throw new Error("Modal does not support pre-signed download URLs")}async watchDir(t,e,n){throw new Error("Modal does not support watchDir")}toBuffer(t){if(typeof t=="string")return Buffer.from(t,"utf-8");if(t instanceof Buffer)return t;if(t instanceof ArrayBuffer||t instanceof Uint8Array)return Buffer.from(t);throw new Error(`Unsupported data type: ${typeof t}`)}},l=class{constructor(t,e){this.sandbox=t;this.commands=new u(t),this.files=new g(t),this.image=e,this.startTime=new Date;}commands;files;image;startTime;get sandboxId(){return this.sandbox.sandboxId}async getHost(t){let n=(await this.sandbox.tunnels())[t];if(!n)throw new Error(`No tunnel found for port ${t}`);return n.url}async isRunning(){try{return await(await this.sandbox.exec(["echo","ping"],{timeoutMs:5e3})).wait(),!0}catch{return false}}async getInfo(){return {sandboxId:this.sandbox.sandboxId,image:this.image,metadata:{},startedAt:this.startTime.toISOString()}}async kill(){try{await this.sandbox.terminate();}catch{await new Promise(t=>setTimeout(t,500)),await this.sandbox.terminate();}}async pause(){throw new Error("Modal does not support pause. Use kill() instead.")}},w=class{providerType="modal";name="Modal";client;appName;defaultTimeoutMs;imageName;_app;constructor(t){!t.endpoint&&process.env.MODAL_SERVER_URL?.startsWith("unix:")?process.env.MODAL_SERVER_URL="https://api.modal.com:443":t.endpoint&&(process.env.MODAL_SERVER_URL=t.endpoint),this.client=new modal.ModalClient({tokenId:t.tokenId,tokenSecret:t.tokenSecret}),this.appName=t.appName??"evolve-sandbox",this.defaultTimeoutMs=t.defaultTimeoutMs??36e5,this.imageName=t.imageName??"evolve-all";}async getApp(){return this._app||(this._app=await this.client.apps.fromName(this.appName,{createIfMissing:true})),this._app}async create(t){let e=await this.getApp(),n=t.timeoutMs??this.defaultTimeoutMs,r=t.image||this.imageName,s=b[r]??r,o=await this.client.images.fromRegistry(s).build(e),i=t.envs?Object.fromEntries(Object.entries(t.envs).filter(([,p])=>p!=null)):void 0,d=i&&Object.keys(i).length>0?i:void 0,m=await this.client.sandboxes.create(e,o,{cpu:4,memoryMiB:4096,timeoutMs:n,workdir:t.workingDirectory,env:d});return t.workingDirectory&&await(await m.exec(["chown","-R","user:user",t.workingDirectory],{timeoutMs:3e4})).wait(),new l(m,s)}async connect(t,e){let n=await this.client.sandboxes.fromId(t);return new l(n,"unknown")}async list(t){let e=[],n=t?.limit??100;try{for await(let r of this.client.sandboxes.list())if(e.push({sandboxId:r.sandboxId,image:"unknown",metadata:{},startedAt:new Date().toISOString()}),e.length>=n)break}catch{}return e}};function k(c={}){let t=c.tokenId??process.env.MODAL_TOKEN_ID,e=c.tokenSecret??process.env.MODAL_TOKEN_SECRET;if(!t||!e)throw new Error("Modal credentials required. Set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET environment variables, or pass tokenId/tokenSecret in config. Get your token at https://modal.com/settings/tokens");return new w({...c,tokenId:t,tokenSecret:e})}
4
- exports.ModalProvider=w;exports.createModalProvider=k;
1
+ 'use strict';var modal=require('modal'),tarStream=require('tar-stream');var p="c-7bcda3467a79";var R={[`evolve-all-${p}`]:`evolvingmachines/evolve-all:${p}`,"evolve-all":"evolvingmachines/evolve-all"},_=1440*60*1e3,M=8*1024*1024,k="user",C="evolve.image",O="evolve.startedAt",y=class extends Error{requestedTimeoutMs;constructor(e){let t=(e/36e5).toFixed(1);super(`Modal sandboxes have a hard 24h lifetime cap; requested timeout was ${t}h. For sessions longer than 24h, persist progress with Evolve checkpoints and resume in a fresh sandbox instead of extending the timeout.`),this.name="ModalSandboxLifetimeError",this.requestedTimeoutMs=e;}};function T(s){if(s>_)throw new y(s)}var u=class extends Error{requestedIdleTimeoutMs;constructor(e,t){super(`Modal idleTimeoutMs of ${e}ms is invalid: ${t}`),this.name="ModalIdleTimeoutError",this.requestedIdleTimeoutMs=e;}};function D(s){if(s===void 0)return {};if(!Number.isFinite(s)||s<=0)throw new u(s,"it must be a positive number of milliseconds");if(s>_)throw new u(s,"it exceeds Modal's 24h lifetime cap, so the sandbox would always die of the lifetime first");return {idleTimeoutMs:s}}function S(s,e,t,r){let n="";r&&Object.keys(r).length>0&&(n=Object.entries(r).filter(([,d])=>d!=null).map(([d,a])=>`export ${d}='${String(a).replace(/'/g,"'\\''")}'`).join("; ")+"; ");let i=t?`cd '${t.replace(/'/g,"'\\''")}' && ${n}${s}`:`${n}${s}`;if(e==="root")return ["bash","-c",i];let o=Buffer.from(i).toString("base64");return ["su",e,"-c",`echo ${o} | base64 -d | bash`]}var g=class extends Error{constructor(e){super(e),this.name="ModalResourcesError";}},V=4,j=4096;function N(s){if(s?.disk!==void 0)throw new g(`Modal's JS SDK has no create-time disk-size parameter, so a ${s.disk} GiB disk request cannot be enforced. Drop \`resources.disk\` (containers get Modal's default disk quota) or run on a provider that sizes disk.`);if((s?.gpu===void 0||s.gpu<=0)&&s?.gpuTypes?.length)throw new g("resources.gpuTypes was set without a positive resources.gpu count \u2014 declare how many GPUs to reserve, or drop gpuTypes.");let e=s?.gpu!==void 0&&s.gpu>0?`${s.gpuTypes?.length?s.gpuTypes[0]:"any"}:${s.gpu}`:void 0;return {cpu:s?.cpu??V,memoryMiB:s?.memory!==void 0?Math.ceil(s.memory*1024):j,...e!==void 0?{gpu:e}:{}}}var f=class extends Error{reason;destination;constructor(e,t,r){super(t),this.name="ModalNetworkPolicyError",this.reason=e,this.destination=r;}};function $(s){let e=/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,3}))?$/.exec(s);return !(!e||[e[1],e[2],e[3],e[4]].some(t=>Number(t)>255)||e[5]!==void 0&&Number(e[5])>32)}function q(s){return /^\d{1,3}(\.\d{1,3}){3}(\/\d+)?$/.test(s)&&!$(s)}function K(s){return s.startsWith("[")?true:(s.match(/:/g)?.length??0)>=2}function z(s){return /^[^:]+:\d+$/.test(s)}function L(s){if(!s||s.outbound==="open"){if(s?.allowedDestinations?.length)throw new Error("network.allowedDestinations is only valid when outbound is blocked");return {}}let e=s.allowedDestinations??[];if(e.length===0)return {blockNetwork:true};let t=[],r=[];for(let n of e)if($(n))t.push(n.includes("/")?n:`${n}/32`);else {if(q(n))throw new f("invalid-ipv4",`"${n}" is not a valid IPv4 address or CIDR (octets must be 0-255, prefix 0-32). Fix the address or list a hostname instead.`,n);if(K(n))t.push(n.includes("/")?n:`${n}/128`);else {if(z(n))throw new f("port-unsupported",`Modal's network allowlist filters hosts and IPs only and cannot match a port; drop the ":<port>" from "${n}" and list just the host or IP.`,n);r.push(n);}}return {outboundCidrAllowlist:t,outboundDomainAllowlist:r}}function U(s){let e=s.split("/")[0];return /^\d{12}\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com$/.test(e)?"aws-ecr":e==="gcr.io"||e.endsWith(".gcr.io")||e.endsWith("-docker.pkg.dev")?"gcp-artifact-registry":"registry"}function A(s,e,t){let{[C]:r,[O]:n,...i}=e;return {sandboxId:s,image:r??t??"",metadata:i,startedAt:n??""}}var E=1e4,v=class{constructor(e,t){this.sandbox=e;this.user=t;}async run(e,t){let r=S(e,this.user,t?.cwd,t?.envs),n=await this.sandbox.exec(r,{timeoutMs:t?.timeoutMs}),{stdout:i,stderr:o}=await this.accumulateStreams(n,t?.onStdout,t?.onStderr);return {exitCode:await n.wait(),stdout:i,stderr:o}}async spawn(e,t){let r=S(e,this.user,t?.cwd,t?.envs),n=await this.sandbox.exec(r,{timeoutMs:t?.timeoutMs}),i="",o="",d=this.accumulateStreams(n,t?.onStdout?c=>{t.onStdout(c);}:void 0,t?.onStderr?c=>{t.onStderr(c);}:void 0).then(({stdout:c,stderr:m})=>{i=c,o=m;}).catch(()=>{});return {processId:`modal-${Date.now()}-${Math.random().toString(36).slice(2)}`,wait:async()=>(await d,{exitCode:await n.wait(),stdout:i,stderr:o}),kill:async()=>false}}async list(){let e=await this.sandbox.exec(["ps","-eo","pid,comm,args"],{timeoutMs:1e4});return await e.wait(),(await e.stdout.readText()).trim().split(`
2
+ `).slice(1).map(n=>{let i=n.trim().split(/\s+/);return {processId:i[0],cmd:i[1]||"",args:i.slice(2),envs:{}}})}async connect(e,t){throw new Error("Modal does not support connecting to existing processes")}async sendStdin(e,t){throw new Error("Modal does not support sendStdin by process ID")}async kill(e){return await(await this.sandbox.exec(["kill","-9",e],{timeoutMs:1e4})).wait()===0}async accumulateStreams(e,t,r){let n="",i="",o=[];return o.push((async()=>{try{for await(let d of e.stdout){let a=typeof d=="string"?d:new TextDecoder().decode(d);n+=a,t?.(a);}}catch{}})()),o.push((async()=>{try{for await(let d of e.stderr){let a=typeof d=="string"?d:new TextDecoder().decode(d);i+=a,r?.(a);}}catch{}})()),await Promise.all(o),{stdout:n,stderr:i}}},P=class{constructor(e,t){this.sandbox=e;this.user=t;}async chownToUser(e,t=false){if(this.user==="root")return;let r=t?["chown","-R",`${this.user}:${this.user}`,e]:["chown",`${this.user}:${this.user}`,e];await(await this.sandbox.exec(r,{timeoutMs:t?3e4:1e4})).wait();}async writeStdinChunked(e,t){for(let r=0;r<t.length;r+=M)await e.writeBytes(t.subarray(r,r+M));}async read(e){let t=await this.sandbox.exec(["cat",e],{timeoutMs:3e5,mode:"binary"}),r=await t.wait();if(r!==0){let i=await t.stderr.readText();throw new Error(`Failed to read file ${e}: ${i||`exit code ${r}`}`)}let n=await t.stdout.readBytes();if(!n.includes(0))try{return new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}).decode(n)}catch{}return n}async write(e,t){let r=this.toBuffer(t),n=e.substring(0,e.lastIndexOf("/"));n&&await this.makeDir(n);let i=e.replace(/'/g,"'\\''"),o=await this.sandbox.exec(["bash","-c",`cat > '${i}'`],{mode:"binary"});await this.writeStdinChunked(o.stdin,new Uint8Array(r)),await o.stdin.getWriter().close(),await o.wait(),await this.chownToUser(e);}async writeBatch(e){let t=tarStream.pack(),r=[],n=new Set;for(let a of e){let c=this.toBuffer(a.data),m=a.path.startsWith("/")?a.path.slice(1):a.path;t.entry({name:m},c);let l=a.path.substring(0,a.path.lastIndexOf("/"));l&&n.add(l);}t.finalize();for await(let a of t)r.push(Buffer.from(a));let i=Buffer.concat(r),o=await this.sandbox.exec(["tar","-xf","-","-C","/"],{mode:"binary"});if(await this.writeStdinChunked(o.stdin,new Uint8Array(i)),await o.stdin.getWriter().close(),await o.wait(),n.size>0){let a=Array.from(n),c=new Set(a.map(m=>m.split("/").slice(0,4).join("/")));for(let m of c)await this.chownToUser(m,true);}}async makeDir(e){await(await this.sandbox.exec(["mkdir","-p",e],{timeoutMs:1e4})).wait(),await this.chownToUser(e,true);}async exists(e){return await(await this.sandbox.exec(["test","-e",e],{timeoutMs:1e4})).wait()===0}async list(e){let t=e.replace(/'/g,"'\\''"),r=await this.sandbox.exec(["bash","-c",`ls -la '${t}' | tail -n +2`],{timeoutMs:3e4});await r.wait();let n=await r.stdout.readText(),i=[];for(let o of n.trim().split(`
3
+ `)){if(!o)continue;let d=o.split(/\s+/);if(d.length<9)continue;let a=d[0],c=d.slice(8).join(" ");c==="."||c===".."||i.push({name:c,path:e.endsWith("/")?`${e}${c}`:`${e}/${c}`,type:a.startsWith("d")?"dir":"file"});}return i}async remove(e){await(await this.sandbox.exec(["rm","-rf",e],{timeoutMs:3e4})).wait();}async rename(e,t){await(await this.sandbox.exec(["mv",e,t],{timeoutMs:3e4})).wait();}async readStream(e){return (await this.sandbox.exec(["cat",e],{timeoutMs:3e5,mode:"binary"})).stdout}async writeStream(e,t){let r=e.substring(0,e.lastIndexOf("/"));r&&await this.makeDir(r);let n=e.replace(/'/g,"'\\''"),i=await this.sandbox.exec(["bash","-c",`cat > '${n}'`],{mode:"binary"}),o=t.getReader();try{for(;;){let{done:a,value:c}=await o.read();if(a)break;await this.writeStdinChunked(i.stdin,c);}}finally{o.releaseLock();}await i.stdin.getWriter().close(),await i.wait(),await this.chownToUser(e);}async writeFromPath(e,t){let{createReadStream:r}=await import('fs'),{Readable:n}=await import('stream'),i=n.toWeb(r(t));await this.writeStream(e,i);}async uploadUrl(e,t){throw new Error("Modal does not support pre-signed upload URLs")}async downloadUrl(e,t){throw new Error("Modal does not support pre-signed download URLs")}async watchDir(e,t,r){throw new Error("Modal does not support watchDir")}toBuffer(e){if(typeof e=="string")return Buffer.from(e,"utf-8");if(e instanceof Buffer)return e;if(e instanceof ArrayBuffer||e instanceof Uint8Array)return Buffer.from(e);throw new Error(`Unsupported data type: ${typeof e}`)}},h=class{constructor(e,t,r){this.sandbox=e;this.commands=new v(e,r),this.files=new P(e,r),this.image=t;}commands;files;image;get sandboxId(){return this.sandbox.sandboxId}async getHost(e){let r=(await this.sandbox.tunnels())[e];if(!r)throw new Error(`No tunnel found for port ${e}`);return r.url}async isRunning(){try{return await this.sandbox.poll()===null}catch{return false}}async getInfo(){let e=await this.sandbox.getTags();return A(this.sandbox.sandboxId,e,this.image)}async kill(){try{await this.sandbox.terminate();}catch{await new Promise(e=>setTimeout(e,500)),await this.sandbox.terminate();}}async pause(){throw new Error("Modal does not support pause/resume. Persist progress with Evolve checkpoints and resume in a fresh sandbox, or use kill() to terminate.")}},I=class{providerType="modal";name="Modal";client;appName;defaultTimeoutMs;imageName;imageSecretName;_app;sandboxUsers=new Map;constructor(e){!e.endpoint&&process.env.MODAL_SERVER_URL?.startsWith("unix:")?process.env.MODAL_SERVER_URL="https://api.modal.com:443":e.endpoint&&(process.env.MODAL_SERVER_URL=e.endpoint),this.client=new modal.ModalClient({tokenId:e.tokenId,tokenSecret:e.tokenSecret}),this.appName=e.appName??"evolve-sandbox",this.defaultTimeoutMs=e.defaultTimeoutMs??36e5,this.imageName=e.imageName??`evolve-all-${p}`,this.imageSecretName=e.imageSecretName;}async getApp(){return this._app||(this._app=await this.client.apps.fromName(this.appName,{createIfMissing:true})),this._app}async resolveImage(e){let t=U(e);if(t==="registry"){let n=this.imageSecretName?await this.client.secrets.fromName(this.imageSecretName):void 0;return this.client.images.fromRegistry(e,n)}if(!this.imageSecretName)throw new Error(`Private registry image "${e}" requires config.imageSecretName \u2014 the name of a Modal Secret holding registry credentials (AWS ECR: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION with read-only ECR IAM). Create one at https://modal.com/secrets`);let r=await this.client.secrets.fromName(this.imageSecretName);return t==="aws-ecr"?this.client.images.fromAwsEcr(e,r):this.client.images.fromGcpArtifactRegistry(e,r)}async create(e){let t=e.timeoutMs??this.defaultTimeoutMs;T(t);let r=D(e.idleTimeoutMs),n=L(e.network),i=N(e.resources),o=e.user??k,d=await this.getApp(),a=e.image||this.imageName,c=R[a]??a,l=await(await this.resolveImage(c)).build(d),w=e.envs?Object.fromEntries(Object.entries(e.envs).filter(([,x])=>x!=null)):void 0,F=w&&Object.keys(w).length>0?w:void 0,W={...e.metadata,[C]:c,[O]:new Date().toISOString()},b=await this.client.sandboxes.create(d,l,{cpu:i.cpu,memoryMiB:i.memoryMiB,...i.gpu!==void 0?{gpu:i.gpu}:{},timeoutMs:t,...r,workdir:e.workingDirectory,env:F,tags:W,...n});return e.workingDirectory&&o!=="root"&&await(await b.exec(["chown","-R",`${o}:${o}`,e.workingDirectory],{timeoutMs:3e4})).wait(),this.sandboxUsers.set(b.sandboxId,o),new h(b,c,o)}async connect(e,t){let r=await this.client.sandboxes.fromId(e),n=this.sandboxUsers.get(e)??k;return new h(r,void 0,n)}async list(e){let t=await this.walk(e);if(t.error&&!t.stoppedAtLimit)throw new Error(t.error);return t.sandboxes}async listAll(e){let{stoppedAtLimit:t,...r}=await this.walk(e);return r}async walk(e){if(e?.state&&!e.state.includes("running"))return {sandboxes:[],complete:true,pagesFetched:0,stoppedAtLimit:false};let t;try{t=await this.getApp();}catch(r){return {sandboxes:[],complete:false,pagesFetched:0,stoppedAtLimit:false,error:`sandbox list failed: ${r instanceof Error?r.message:String(r)}`}}return B(()=>this.client.sandboxes.list({appId:t.appId,tags:e?.metadata}),e?.limit)}async listSandboxIds(){let e=new Set;try{let t=await this.getApp();for await(let r of this.client.sandboxes.list({appId:t.appId}))e.add(r.sandboxId);return {ids:e,complete:!0}}catch{return {ids:new Set,complete:false}}}};async function B(s,e){let t=[];try{for await(let r of s()){if(e!==void 0&&t.length>=e)return {sandboxes:t,complete:!1,pagesFetched:t.length,stoppedAtLimit:!0,error:`stopped at the requested limit of ${e} with more sandboxes available`};if(t.length>=E)return {sandboxes:t,complete:!1,pagesFetched:t.length,stoppedAtLimit:!1,error:`sandbox list exceeded ${E} sandboxes`};let n=await r.getTags();t.push(A(r.sandboxId,n));}}catch(r){return {sandboxes:t,complete:false,pagesFetched:t.length,stoppedAtLimit:false,error:`sandbox list failed: ${r instanceof Error?r.message:String(r)}`}}return {sandboxes:t,complete:true,pagesFetched:t.length,stoppedAtLimit:false}}function se(s={}){let e=s.tokenId??process.env.MODAL_TOKEN_ID,t=s.tokenSecret??process.env.MODAL_TOKEN_SECRET;if(!e||!t)throw new Error("Modal credentials required. Set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET environment variables, or pass tokenId/tokenSecret in config. Get your token at https://modal.com/settings/tokens");return new I({...s,tokenId:e,tokenSecret:t})}var ne=S,ie=R,oe=L,ae=N,de=U,ce=A,me=B,le=T,pe=D;
4
+ exports.EVOLVE_IMAGE_VERSION=p;exports.MODAL_MAX_LIFETIME_MS=_;exports.MODAL_MAX_LIST_SANDBOXES=E;exports.MODAL_STDIN_CHUNK_BYTES=M;exports.ModalCommands=v;exports.ModalFiles=P;exports.ModalIdleTimeoutError=u;exports.ModalNetworkPolicyError=f;exports.ModalProvider=I;exports.ModalResourcesError=g;exports.ModalSandboxLifetimeError=y;exports._testBuildSandboxInfo=ce;exports._testCollectSandboxes=me;exports._testImageMap=ie;exports._testMapIdleTimeout=pe;exports._testMapNetworkPolicy=oe;exports._testMapResources=ae;exports._testResolveImageRegistry=de;exports._testValidateTimeout=le;exports._testWrapCommand=ne;exports.createModalProvider=se;
package/dist/index.d.cts CHANGED
@@ -1,7 +1,20 @@
1
+ import { Sandbox } from 'modal';
2
+
3
+ /**
4
+ * GENERATED — DO NOT EDIT. `npm run generate:image-version` (repo root)
5
+ * rewrites this file from the evolve-all image build inputs under
6
+ * assets/docker/ (see assets/docker/image-digest.ts for the derivation).
7
+ * The value is content-addressed: same inputs → same tag, any input
8
+ * change → a new tag. The coherence test in
9
+ * packages/daytona/tests/unit/daytona-image-version.test.ts fails the
10
+ * suite whenever this checked-in copy is stale.
11
+ */
12
+ declare const EVOLVE_IMAGE_VERSION = "c-7bcda3467a79";
13
+
1
14
  /**
2
15
  * Modal Sandbox Provider - Clean Architecture
3
16
  *
4
- * @requires modal >= 0.3.0
17
+ * @requires modal >= 0.9.0
5
18
  * @requires Node.js >= 18 (for ReadableStream support)
6
19
  *
7
20
  * Design principles:
@@ -13,9 +26,163 @@
13
26
  *
14
27
  * Modal-specific notes:
15
28
  * - No native file APIs - uses exec() with stdin/stdout
16
- * - pause() not supported - throws error
29
+ * - pause() not supported - throws error (use Evolve checkpoints for persistence)
17
30
  * - Requires app context for sandbox creation
31
+ * - Hard 24h sandbox lifetime cap (ModalSandboxLifetimeError when exceeded)
32
+ * - Everything executes as root inside the sandbox; the `user` option is
33
+ * enforced through an `su <user> -c` wrapper (default user: "user")
34
+ * - Network policy maps to Modal's blockNetwork / outboundDomainAllowlist /
35
+ * outboundCidrAllowlist (domain allowlist admits TLS on port 443 only —
36
+ * plaintext destinations must be listed as IPs/CIDRs)
37
+ * - Modal exposes no metadata or public timestamps on sandboxes; both are
38
+ * stamped into sandbox tags at create time and read back via getTags()
39
+ */
40
+
41
+ /**
42
+ * Modal's hard cap on sandbox lifetime (24 hours).
43
+ * Requests beyond this throw ModalSandboxLifetimeError.
44
+ */
45
+ declare const MODAL_MAX_LIFETIME_MS: number;
46
+ /**
47
+ * Chunk size for stdin uploads. Modal's gRPC transport rejects any single
48
+ * TaskExecStdinWrite message larger than 100MiB (RESOURCE_EXHAUSTED at
49
+ * 104,857,600 bytes), so file payloads are split into 8MiB writeBytes()
50
+ * calls — the same per-chunk pattern writeStream() uses.
51
+ */
52
+ declare const MODAL_STDIN_CHUNK_BYTES: number;
53
+ /**
54
+ * Typed error for Modal's hard 24h sandbox lifetime cap.
55
+ * Long-running sessions must persist progress with Evolve checkpoints and
56
+ * resume in a fresh sandbox instead of extending the timeout.
57
+ */
58
+ declare class ModalSandboxLifetimeError extends Error {
59
+ readonly requestedTimeoutMs: number;
60
+ constructor(requestedTimeoutMs: number);
61
+ }
62
+ /** Throws ModalSandboxLifetimeError when the timeout exceeds Modal's 24h cap. */
63
+ declare function validateTimeout(timeoutMs: number): void;
64
+ /**
65
+ * Typed error for an idle timeout Modal could not act on. Both bounds are
66
+ * refusals rather than clamps: silently raising a zero, or lowering a value past
67
+ * the lifetime cap, would hand back a box that dies on a schedule the caller
68
+ * never asked for.
69
+ */
70
+ declare class ModalIdleTimeoutError extends Error {
71
+ readonly requestedIdleTimeoutMs: number;
72
+ constructor(requestedIdleTimeoutMs: number, reason: string);
73
+ }
74
+ /**
75
+ * Evolve's idle bound -> Modal's create params, same shape as mapNetworkPolicy
76
+ * and mapResources: provider-neutral option in, Modal fragment out.
77
+ *
78
+ * ABSENT MEANS ABSENT. Modal's own default is no idle timer at all, so an unset
79
+ * option must spread to nothing — inventing a default here would start killing
80
+ * boxes that today live out their lifetime, for every caller who never asked.
81
+ *
82
+ * An idle timeout has to be a positive span, and one above the 24h lifetime cap
83
+ * can never fire because the sandbox is already gone. Both are caller mistakes,
84
+ * and both throw rather than clamp: silently raising a zero or lowering an
85
+ * over-cap value hands back a box that dies on a schedule nobody chose.
86
+ */
87
+ declare function mapIdleTimeout(idleTimeoutMs?: number): {
88
+ idleTimeoutMs?: number;
89
+ };
90
+ /**
91
+ * Wrap a command with cwd + env handling and (when not root) an
92
+ * `su <user> -c` wrapper.
93
+ *
94
+ * Modal sandboxes run as root by default (ignoring the Dockerfile USER
95
+ * directive), but Claude CLI and other tools refuse certain operations when
96
+ * running as root.
97
+ *
98
+ * Uses `su <user> -c` instead of `sudo -u <user>` because Claude CLI's
99
+ * --dangerously-skip-permissions flag refuses to run when it detects sudo.
100
+ *
101
+ * Uses base64 encoding to avoid shell escaping issues with complex commands
102
+ * that contain quotes, special characters, etc. Env vars are inlined because
103
+ * su does not preserve the environment the way `sudo -E` does.
104
+ */
105
+ declare function wrapCommand(command: string, user: string, cwd?: string, envs?: Record<string, string>): string[];
106
+ /**
107
+ * Typed error for sizing requests Modal's create() cannot enforce.
108
+ * The installed Modal JS SDK sizes cpu (cores) and memoryMiB at create time
109
+ * only — there is no disk-size parameter, so a requested disk size would be
110
+ * silently ignored. Per the provider law (reject what you cannot enforce,
111
+ * never silently ignore) it is refused loudly here.
112
+ */
113
+ declare class ModalResourcesError extends Error {
114
+ constructor(message: string);
115
+ }
116
+ /**
117
+ * Map Evolve's provider-neutral resources (cpu cores, memory GiB, disk GiB)
118
+ * onto Modal's create() params (cpu cores, memoryMiB). Fractional GiB rounds
119
+ * UP so the sandbox never gets less memory than requested. `disk` throws
120
+ * ModalResourcesError — the SDK cannot express it.
121
+ */
122
+ /**
123
+ * Structural twin of SandboxCreateOptions["resources"] — spelled locally
124
+ * (rather than indexed off the SDK type) so this package can build against
125
+ * an SDK whose published type predates the GPU fields; the provider-parity
126
+ * conformance file still pins the two to each other.
127
+ */
128
+ type ModalCreateResources = {
129
+ cpu?: number;
130
+ memory?: number;
131
+ disk?: number;
132
+ gpu?: number;
133
+ gpuTypes?: string[];
134
+ };
135
+ declare function mapResources(resources?: ModalCreateResources): {
136
+ cpu: number;
137
+ memoryMiB: number;
138
+ gpu?: string;
139
+ };
140
+ /** Modal create() params derived from Evolve's provider-neutral network policy. */
141
+ interface ModalNetworkCreateParams {
142
+ blockNetwork?: boolean;
143
+ outboundCidrAllowlist?: string[];
144
+ outboundDomainAllowlist?: string[];
145
+ }
146
+ /** Why a network destination cannot be mapped onto Modal's allowlist. */
147
+ type ModalNetworkPolicyReason = "port-unsupported" | "invalid-ipv4";
148
+ /**
149
+ * Typed error for destinations Modal's allowlist cannot express.
150
+ *
151
+ * Modal's allowlist filters hosts (domain allowlist) and IPs/CIDRs (CIDR
152
+ * allowlist) only — it has no notion of a port, and an invalid IPv4/CIDR
153
+ * would be silently forwarded to the API. Both are rejected loudly here
154
+ * instead of weakening or mangling the sandbox's egress policy.
155
+ */
156
+ declare class ModalNetworkPolicyError extends Error {
157
+ readonly reason: ModalNetworkPolicyReason;
158
+ /** The offending destination. */
159
+ readonly destination?: string;
160
+ constructor(reason: ModalNetworkPolicyReason, message: string, destination?: string);
161
+ }
162
+ /**
163
+ * Map Evolve's provider-neutral network policy onto Modal create() params.
164
+ *
165
+ * - outbound "open" (or no policy) → no restrictions
166
+ * - outbound "blocked", no allowlist → blockNetwork: true (drops all egress)
167
+ * - outbound "blocked" with allowlist → outboundDomainAllowlist (hostnames,
168
+ * wildcards like "*.example.com") + outboundCidrAllowlist (IPs/CIDRs; bare
169
+ * IPs get /32 or /128 appended). Both lists are always set because Modal
170
+ * treats an unset list as "allow all" — an empty array means "allow none"
171
+ * for that class of destination. Note: Modal's domain allowlist only admits
172
+ * TLS traffic on port 443; plaintext destinations must be listed as CIDRs.
18
173
  */
174
+ declare function mapNetworkPolicy(network?: SandboxCreateOptions["network"]): ModalNetworkCreateParams;
175
+ /** Container registry family for an image tag. */
176
+ type ImageRegistry = "aws-ecr" | "gcp-artifact-registry" | "registry";
177
+ /** Detect which Modal image constructor an image tag needs. */
178
+ declare function resolveImageRegistry(tag: string): ImageRegistry;
179
+ /**
180
+ * Build a SandboxInfo from a sandbox's tags. Modal exposes no metadata or
181
+ * public timestamps, so image and startedAt come from the tags stamped at
182
+ * create time; for sandboxes not created by this SDK they are empty strings
183
+ * (never fabricated). endAt is always undefined — Modal does not expose it.
184
+ */
185
+ declare function buildSandboxInfo(sandboxId: string, tags: Record<string, string>, fallbackImage?: string): SandboxInfo;
19
186
  /** Result of a completed sandbox command */
20
187
  interface SandboxCommandResult {
21
188
  exitCode: number;
@@ -93,15 +260,84 @@ interface SandboxCreateOptions {
93
260
  image?: string;
94
261
  envs?: Record<string, string>;
95
262
  metadata?: Record<string, string>;
263
+ /** Sandbox lifetime in ms. Modal hard-caps lifetime at 24h (MODAL_MAX_LIFETIME_MS). */
96
264
  timeoutMs?: number;
265
+ /**
266
+ * Terminate the sandbox after this long with nothing running in it — the
267
+ * bound that reclaims a box whose client died, without waiting out the whole
268
+ * lifetime. Modal is the only provider with both clocks.
269
+ *
270
+ * OMITTED BY DEFAULT: Modal runs no idle timer unless asked. Modal counts a
271
+ * sandbox active while an exec is running, while its stdin is being written,
272
+ * or while a tunnel connection is open — file operations are not named in
273
+ * that list, and this adapter is safe only because it routes reads and writes
274
+ * through exec (`cat` / `cat >`). A future native filesystem path would need
275
+ * this re-checked.
276
+ */
277
+ idleTimeoutMs?: number;
97
278
  workingDirectory?: string;
279
+ /**
280
+ * Per-sandbox compute sizing: cpu in cores, memory in GiB — mapped to
281
+ * Modal's create-time cpu / memoryMiB requests (defaults when omitted:
282
+ * 4 cores / 4 GiB). `disk` is REJECTED with ModalResourcesError: the Modal
283
+ * JS SDK exposes no disk-size parameter, so a specific disk size cannot be
284
+ * enforced (containers get Modal's default disk quota).
285
+ *
286
+ * `gpu` + `gpuTypes` become Modal's "<TYPE>:<count>" GPU reservation
287
+ * ('any' when no types are named; the FIRST type when several — Modal
288
+ * reserves one type per sandbox; the type passes through verbatim and an
289
+ * unknown one gets Modal's own typed rejection at create).
290
+ */
291
+ resources?: ModalCreateResources;
292
+ /**
293
+ * Provider-neutral outbound network policy, enforced by Modal's network
294
+ * stack. "blocked" with no allowedDestinations drops all egress; with
295
+ * allowedDestinations, hostnames go to Modal's domain allowlist (TLS/443
296
+ * only) and IPs/CIDRs to the CIDR allowlist.
297
+ */
298
+ network?: {
299
+ outbound: "open" | "blocked";
300
+ allowedDestinations?: string[];
301
+ };
302
+ /**
303
+ * Run all commands and file operations as this user (default "user"),
304
+ * enforced via an `su <user> -c` wrapper since Modal executes everything as
305
+ * root. Pass "root" to run directly as root with no wrapper.
306
+ */
307
+ user?: string;
308
+ /** Home directory used by the SDK for agent config paths; not consumed by the provider. */
309
+ homeDir?: string;
98
310
  }
99
311
  /** Options for listing sandboxes */
100
312
  interface SandboxListOptions {
313
+ /** Modal has no paused state; filters that exclude "running" match nothing. */
101
314
  state?: ("running" | "paused")[];
102
315
  metadata?: Record<string, string>;
103
316
  limit?: number;
104
317
  }
318
+ /**
319
+ * A COMPLETE (or admittedly incomplete) enumeration of the app's fleet.
320
+ *
321
+ * `complete` is the load-bearing field. Callers that need a whole fleet —
322
+ * orphan sweeps, lifecycle reconciliation — read a sandbox's ABSENCE from the
323
+ * list as evidence it is gone, so a truncated walk and a small fleet must never
324
+ * be the same answer. That includes a walk stopped by the caller's own `limit`:
325
+ * "you asked for ten and there are more" is a truncated fleet.
326
+ */
327
+ interface SandboxListPage {
328
+ sandboxes: SandboxInfo[];
329
+ complete: boolean;
330
+ pagesFetched: number;
331
+ error?: string;
332
+ }
333
+ /**
334
+ * Sandboxes a single enumeration will walk before it gives up and reports
335
+ * itself incomplete. Modal's list is an async generator with no page size we
336
+ * control, so the ceiling is counted in SANDBOXES rather than pages — same
337
+ * purpose as the other providers' page caps: never return a short list that
338
+ * reads like a whole one.
339
+ */
340
+ declare const MODAL_MAX_LIST_SANDBOXES = 10000;
105
341
  /** Command execution capabilities */
106
342
  interface SandboxCommands {
107
343
  /** Run command and wait for completion */
@@ -132,6 +368,8 @@ interface SandboxFiles {
132
368
  readStream(path: string): Promise<ReadableStream<Uint8Array>>;
133
369
  /** Write from stream */
134
370
  writeStream(path: string, stream: ReadableStream<Uint8Array>): Promise<void>;
371
+ /** Upload a local file by path, streamed off disk (never buffered whole) */
372
+ writeFromPath(sandboxPath: string, localPath: string): Promise<void>;
135
373
  /** Get pre-signed upload URL for large files (expiration in seconds) */
136
374
  uploadUrl(path: string, expiresInSeconds?: number): Promise<string>;
137
375
  /** Get pre-signed download URL for large files (expiration in seconds) */
@@ -169,12 +407,17 @@ interface SandboxInstance {
169
407
  interface SandboxProvider {
170
408
  /** Provider type identifier */
171
409
  readonly providerType: string;
410
+ /** Human-readable provider name for logging */
411
+ readonly name?: string;
172
412
  /** Create new sandbox */
173
413
  create(options: SandboxCreateOptions): Promise<SandboxInstance>;
174
414
  /** Connect to existing sandbox */
175
415
  connect(sandboxId: string, timeoutMs?: number): Promise<SandboxInstance>;
176
416
  /** List sandboxes (first page only, up to limit) */
417
+ /** List sandboxes, walking the whole app. `limit` bounds items returned. */
177
418
  list(options?: SandboxListOptions): Promise<SandboxInfo[]>;
419
+ /** The same enumeration for fleet bookkeeping: never throws, reports completeness. */
420
+ listAll(options?: SandboxListOptions): Promise<SandboxListPage>;
178
421
  }
179
422
  interface ModalConfig {
180
423
  /** Modal app name. Default: "evolve-sandbox" */
@@ -187,8 +430,15 @@ interface ModalConfig {
187
430
  tokenSecret?: string;
188
431
  /** Modal API endpoint. Default: https://api.modal.com:443 */
189
432
  endpoint?: string;
190
- /** Docker image name (default: 'evolve-all'). Resolved through IMAGE_MAP or used as-is for custom images. */
433
+ /** Docker image name (default: 'evolve-all-<EVOLVE_IMAGE_VERSION>'). Resolved through IMAGE_MAP or used as-is for custom images; explicit names pass through untouched. */
191
434
  imageName?: string;
435
+ /**
436
+ * Name of a Modal Secret holding registry credentials for private images.
437
+ * Required for AWS ECR (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY,
438
+ * AWS_REGION with read-only ECR IAM) and GCP Artifact Registry; optional
439
+ * for private Docker Hub images. Create one at https://modal.com/secrets
440
+ */
441
+ imageSecretName?: string;
192
442
  }
193
443
  /** Internal resolved config with required credentials */
194
444
  interface ResolvedModalConfig {
@@ -198,6 +448,64 @@ interface ResolvedModalConfig {
198
448
  defaultTimeoutMs?: number;
199
449
  endpoint?: string;
200
450
  imageName?: string;
451
+ imageSecretName?: string;
452
+ }
453
+ declare class ModalCommands implements SandboxCommands {
454
+ private sandbox;
455
+ private user;
456
+ constructor(sandbox: Sandbox, user: string);
457
+ run(command: string, options?: SandboxRunOptions): Promise<SandboxCommandResult>;
458
+ spawn(command: string, options?: SandboxSpawnOptions): Promise<SandboxCommandHandle>;
459
+ list(): Promise<ProcessInfo[]>;
460
+ connect(_processId: string, _options?: SandboxConnectOptions): Promise<SandboxCommandHandle>;
461
+ sendStdin(_processId: string, _data: string): Promise<void>;
462
+ kill(processId: string): Promise<boolean>;
463
+ /**
464
+ * Accumulate stdout/stderr using for-await pattern (more reliable with Modal streams).
465
+ * Based on vibekit's approach which works correctly with Modal SDK.
466
+ */
467
+ private accumulateStreams;
468
+ }
469
+ declare class ModalFiles implements SandboxFiles {
470
+ private sandbox;
471
+ private user;
472
+ constructor(sandbox: Sandbox, user: string);
473
+ /**
474
+ * Chown a path to the sandbox user so agent CLIs (running via the su
475
+ * wrapper) can access files created by root-level exec. No-op when the
476
+ * sandbox user is root.
477
+ */
478
+ private chownToUser;
479
+ /**
480
+ * Write a payload to a process's stdin in MODAL_STDIN_CHUNK_BYTES slices.
481
+ * Each writeBytes() call becomes one gRPC TaskExecStdinWrite message and
482
+ * Modal rejects messages over 100MiB, so large files must be chunked
483
+ * (multi-hundred-MB payloads are common).
484
+ */
485
+ private writeStdinChunked;
486
+ read(path: string): Promise<string | Uint8Array>;
487
+ write(path: string, content: string | Buffer | ArrayBuffer | Uint8Array): Promise<void>;
488
+ writeBatch(files: Array<{
489
+ path: string;
490
+ data: string | Buffer | ArrayBuffer | Uint8Array;
491
+ }>): Promise<void>;
492
+ makeDir(path: string): Promise<void>;
493
+ exists(path: string): Promise<boolean>;
494
+ list(path: string): Promise<FileInfo[]>;
495
+ remove(path: string): Promise<void>;
496
+ rename(oldPath: string, newPath: string): Promise<void>;
497
+ readStream(path: string): Promise<ReadableStream<Uint8Array>>;
498
+ writeStream(path: string, stream: ReadableStream<Uint8Array>): Promise<void>;
499
+ /**
500
+ * Upload a local file by PATH, chunk by chunk into the same `cat >` sink
501
+ * writeStream() uses — so peak memory is one chunk rather than the whole
502
+ * file, which is what makes a large artifact safe under concurrency.
503
+ */
504
+ writeFromPath(sandboxPath: string, localPath: string): Promise<void>;
505
+ uploadUrl(_path: string, _expiresInSeconds?: number): Promise<string>;
506
+ downloadUrl(_path: string, _expiresInSeconds?: number): Promise<string>;
507
+ watchDir(_path: string, _onEvent: (event: FilesystemEvent) => void | Promise<void>, _options?: WatchOptions): Promise<WatchHandle>;
508
+ private toBuffer;
201
509
  }
202
510
  declare class ModalProvider implements SandboxProvider {
203
511
  readonly providerType: "modal";
@@ -206,13 +514,103 @@ declare class ModalProvider implements SandboxProvider {
206
514
  private readonly appName;
207
515
  private readonly defaultTimeoutMs;
208
516
  private readonly imageName;
517
+ private readonly imageSecretName?;
209
518
  private _app;
519
+ /**
520
+ * Sandbox user configured at create time, reapplied on connect() so the su
521
+ * wrapper keeps targeting the same account. In-memory only: a connect()
522
+ * from a fresh process falls back to the default "user" account — callers
523
+ * reconnecting across processes must recreate the provider and sandbox with
524
+ * the same user, or operations on user-owned files fail loudly.
525
+ */
526
+ private readonly sandboxUsers;
210
527
  constructor(config: ResolvedModalConfig);
211
528
  private getApp;
529
+ /**
530
+ * Build a Modal Image for the resolved tag, routing private registries
531
+ * (AWS ECR, GCP Artifact Registry) through the configured Modal Secret.
532
+ */
533
+ private resolveImage;
212
534
  create(options: SandboxCreateOptions): Promise<SandboxInstance>;
213
535
  connect(sandboxId: string, _timeoutMs?: number): Promise<SandboxInstance>;
214
- list(_options?: SandboxListOptions): Promise<SandboxInfo[]>;
536
+ /**
537
+ * List sandboxes, walking the whole app.
538
+ *
539
+ * This used to stop at a hardcoded default of 100 regardless of fleet size,
540
+ * which silently truncated any app with more — and said nothing about it
541
+ * while the shared SandboxProvider interface promised exhaustive listing.
542
+ * `limit` still bounds the sandboxes RETURNED, so a caller wanting one cheap
543
+ * sample asks for one; without it the answer is the whole app.
544
+ *
545
+ * The O(N)-round-trips warning below is unchanged and is the reason `limit`
546
+ * matters here more than on the other providers.
547
+ */
548
+ list(options?: SandboxListOptions): Promise<SandboxInfo[]>;
549
+ /**
550
+ * The fleet-bookkeeping enumeration: same walk, never throws.
551
+ *
552
+ * Modal has no lifecycle webhooks, so absence from a list is the ONLY
553
+ * termination signal either lane gets — which makes the difference between
554
+ * "the app is empty" and "the enumeration stopped early" the difference
555
+ * between a quiet fleet and one about to be reclaimed.
556
+ *
557
+ * NOTE the divergence from `listSandboxIds` below, which returns an EMPTY set
558
+ * on failure on the grounds that partial results are worse than none for a
559
+ * terminal-state decision. This one returns what it saw alongside
560
+ * `complete: false`. Both are safe because `complete` is what callers branch
561
+ * on, and the shared type documents the choice; do not align one to the other
562
+ * without deciding which rule you want.
563
+ */
564
+ listAll(options?: SandboxListOptions): Promise<SandboxListPage>;
565
+ private walk;
566
+ /**
567
+ * Live sandbox ids for the whole app, in ONE streamed call and O(1) round
568
+ * trips — the fleet-bookkeeping counterpart to `list()`.
569
+ *
570
+ * Exists because `list()` cannot be made cheap without dropping metadata from
571
+ * its contract: it owes callers a populated `SandboxInfo.metadata`, and Modal
572
+ * only serves tags per sandbox. Anything that just needs "which ids are
573
+ * alive" — lifecycle polling, orphan sweeps, reconciliation — uses this and
574
+ * pays one request no matter how large the fleet is.
575
+ *
576
+ * `complete` is the load-bearing field, not a nicety: absence from this list
577
+ * is what callers read as "terminated", so a truncated or errored enumeration
578
+ * MUST NOT be mistaken for an empty fleet. A caller that sees complete=false
579
+ * has to leave rows alone rather than mass-marking live sandboxes dead.
580
+ */
581
+ listSandboxIds(): Promise<{
582
+ ids: Set<string>;
583
+ complete: boolean;
584
+ }>;
215
585
  }
586
+ /** The streamed sandbox surface this walk needs — Modal's list() satisfies it. */
587
+ interface ModalSandboxStream {
588
+ sandboxId: string;
589
+ getTags(): Promise<Record<string, string>>;
590
+ }
591
+ /**
592
+ * Drain Modal's sandbox generator into one answer, with an honest completeness
593
+ * verdict.
594
+ *
595
+ * Separate from the provider because everything worth getting wrong lives here
596
+ * and none of it needs a gRPC connection: the difference between "the caller
597
+ * asked for ten" and "the app ran out", the ceiling that stops an unbounded
598
+ * walk, and the rule that a failure mid-walk yields what it saw marked
599
+ * INCOMPLETE rather than an exception or a short complete list.
600
+ *
601
+ * COST WARNING — this loop is O(N) ROUND TRIPS, not O(1). `list()` itself is one
602
+ * streamed call, but `getTags()` is a separate gRPC request per sandbox
603
+ * (`sandboxTagsGet`), so listing N sandboxes costs N+1 calls. That is fine for a
604
+ * user listing their handful of boxes with metadata, and NOT fine for fleet-wide
605
+ * bookkeeping: anything that only needs to know WHICH ids are alive must use
606
+ * `listSandboxIds`, never this. Please do not "optimize" by reintroducing tag
607
+ * reads into those paths.
608
+ *
609
+ * Exported for its test (`_testCollectSandboxes`).
610
+ */
611
+ declare function collectSandboxes(iterate: () => AsyncIterable<ModalSandboxStream>, wanted?: number): Promise<SandboxListPage & {
612
+ stoppedAtLimit: boolean;
613
+ }>;
216
614
  /**
217
615
  * Create Modal sandbox provider.
218
616
  *
@@ -222,5 +620,14 @@ declare class ModalProvider implements SandboxProvider {
222
620
  * @see https://github.com/evolving-machines-lab/evolve/issues/8
223
621
  */
224
622
  declare function createModalProvider(config?: ModalConfig): SandboxProvider;
623
+ declare const _testWrapCommand: typeof wrapCommand;
624
+ declare const _testImageMap: Record<string, string>;
625
+ declare const _testMapNetworkPolicy: typeof mapNetworkPolicy;
626
+ declare const _testMapResources: typeof mapResources;
627
+ declare const _testResolveImageRegistry: typeof resolveImageRegistry;
628
+ declare const _testBuildSandboxInfo: typeof buildSandboxInfo;
629
+ declare const _testCollectSandboxes: typeof collectSandboxes;
630
+ declare const _testValidateTimeout: typeof validateTimeout;
631
+ declare const _testMapIdleTimeout: typeof mapIdleTimeout;
225
632
 
226
- export { type FileInfo, type FilesystemEvent, type ModalConfig, ModalProvider, type ProcessInfo, type SandboxCommandHandle, type SandboxCommandResult, type SandboxCommands, type SandboxConnectOptions, type SandboxCreateOptions, type SandboxFiles, type SandboxInfo, type SandboxInstance, type SandboxListOptions, type SandboxProvider, type SandboxRunOptions, type SandboxSpawnOptions, type WatchHandle, type WatchOptions, createModalProvider };
633
+ export { EVOLVE_IMAGE_VERSION, type FileInfo, type FilesystemEvent, MODAL_MAX_LIFETIME_MS, MODAL_MAX_LIST_SANDBOXES, MODAL_STDIN_CHUNK_BYTES, ModalCommands, type ModalConfig, ModalFiles, ModalIdleTimeoutError, ModalNetworkPolicyError, type ModalNetworkPolicyReason, ModalProvider, ModalResourcesError, ModalSandboxLifetimeError, type ModalSandboxStream, type ProcessInfo, type SandboxCommandHandle, type SandboxCommandResult, type SandboxCommands, type SandboxConnectOptions, type SandboxCreateOptions, type SandboxFiles, type SandboxInfo, type SandboxInstance, type SandboxListOptions, type SandboxListPage, type SandboxProvider, type SandboxRunOptions, type SandboxSpawnOptions, type WatchHandle, type WatchOptions, _testBuildSandboxInfo, _testCollectSandboxes, _testImageMap, _testMapIdleTimeout, _testMapNetworkPolicy, _testMapResources, _testResolveImageRegistry, _testValidateTimeout, _testWrapCommand, createModalProvider };
package/dist/index.d.ts CHANGED
@@ -1,7 +1,20 @@
1
+ import { Sandbox } from 'modal';
2
+
3
+ /**
4
+ * GENERATED — DO NOT EDIT. `npm run generate:image-version` (repo root)
5
+ * rewrites this file from the evolve-all image build inputs under
6
+ * assets/docker/ (see assets/docker/image-digest.ts for the derivation).
7
+ * The value is content-addressed: same inputs → same tag, any input
8
+ * change → a new tag. The coherence test in
9
+ * packages/daytona/tests/unit/daytona-image-version.test.ts fails the
10
+ * suite whenever this checked-in copy is stale.
11
+ */
12
+ declare const EVOLVE_IMAGE_VERSION = "c-7bcda3467a79";
13
+
1
14
  /**
2
15
  * Modal Sandbox Provider - Clean Architecture
3
16
  *
4
- * @requires modal >= 0.3.0
17
+ * @requires modal >= 0.9.0
5
18
  * @requires Node.js >= 18 (for ReadableStream support)
6
19
  *
7
20
  * Design principles:
@@ -13,9 +26,163 @@
13
26
  *
14
27
  * Modal-specific notes:
15
28
  * - No native file APIs - uses exec() with stdin/stdout
16
- * - pause() not supported - throws error
29
+ * - pause() not supported - throws error (use Evolve checkpoints for persistence)
17
30
  * - Requires app context for sandbox creation
31
+ * - Hard 24h sandbox lifetime cap (ModalSandboxLifetimeError when exceeded)
32
+ * - Everything executes as root inside the sandbox; the `user` option is
33
+ * enforced through an `su <user> -c` wrapper (default user: "user")
34
+ * - Network policy maps to Modal's blockNetwork / outboundDomainAllowlist /
35
+ * outboundCidrAllowlist (domain allowlist admits TLS on port 443 only —
36
+ * plaintext destinations must be listed as IPs/CIDRs)
37
+ * - Modal exposes no metadata or public timestamps on sandboxes; both are
38
+ * stamped into sandbox tags at create time and read back via getTags()
39
+ */
40
+
41
+ /**
42
+ * Modal's hard cap on sandbox lifetime (24 hours).
43
+ * Requests beyond this throw ModalSandboxLifetimeError.
44
+ */
45
+ declare const MODAL_MAX_LIFETIME_MS: number;
46
+ /**
47
+ * Chunk size for stdin uploads. Modal's gRPC transport rejects any single
48
+ * TaskExecStdinWrite message larger than 100MiB (RESOURCE_EXHAUSTED at
49
+ * 104,857,600 bytes), so file payloads are split into 8MiB writeBytes()
50
+ * calls — the same per-chunk pattern writeStream() uses.
51
+ */
52
+ declare const MODAL_STDIN_CHUNK_BYTES: number;
53
+ /**
54
+ * Typed error for Modal's hard 24h sandbox lifetime cap.
55
+ * Long-running sessions must persist progress with Evolve checkpoints and
56
+ * resume in a fresh sandbox instead of extending the timeout.
57
+ */
58
+ declare class ModalSandboxLifetimeError extends Error {
59
+ readonly requestedTimeoutMs: number;
60
+ constructor(requestedTimeoutMs: number);
61
+ }
62
+ /** Throws ModalSandboxLifetimeError when the timeout exceeds Modal's 24h cap. */
63
+ declare function validateTimeout(timeoutMs: number): void;
64
+ /**
65
+ * Typed error for an idle timeout Modal could not act on. Both bounds are
66
+ * refusals rather than clamps: silently raising a zero, or lowering a value past
67
+ * the lifetime cap, would hand back a box that dies on a schedule the caller
68
+ * never asked for.
69
+ */
70
+ declare class ModalIdleTimeoutError extends Error {
71
+ readonly requestedIdleTimeoutMs: number;
72
+ constructor(requestedIdleTimeoutMs: number, reason: string);
73
+ }
74
+ /**
75
+ * Evolve's idle bound -> Modal's create params, same shape as mapNetworkPolicy
76
+ * and mapResources: provider-neutral option in, Modal fragment out.
77
+ *
78
+ * ABSENT MEANS ABSENT. Modal's own default is no idle timer at all, so an unset
79
+ * option must spread to nothing — inventing a default here would start killing
80
+ * boxes that today live out their lifetime, for every caller who never asked.
81
+ *
82
+ * An idle timeout has to be a positive span, and one above the 24h lifetime cap
83
+ * can never fire because the sandbox is already gone. Both are caller mistakes,
84
+ * and both throw rather than clamp: silently raising a zero or lowering an
85
+ * over-cap value hands back a box that dies on a schedule nobody chose.
86
+ */
87
+ declare function mapIdleTimeout(idleTimeoutMs?: number): {
88
+ idleTimeoutMs?: number;
89
+ };
90
+ /**
91
+ * Wrap a command with cwd + env handling and (when not root) an
92
+ * `su <user> -c` wrapper.
93
+ *
94
+ * Modal sandboxes run as root by default (ignoring the Dockerfile USER
95
+ * directive), but Claude CLI and other tools refuse certain operations when
96
+ * running as root.
97
+ *
98
+ * Uses `su <user> -c` instead of `sudo -u <user>` because Claude CLI's
99
+ * --dangerously-skip-permissions flag refuses to run when it detects sudo.
100
+ *
101
+ * Uses base64 encoding to avoid shell escaping issues with complex commands
102
+ * that contain quotes, special characters, etc. Env vars are inlined because
103
+ * su does not preserve the environment the way `sudo -E` does.
104
+ */
105
+ declare function wrapCommand(command: string, user: string, cwd?: string, envs?: Record<string, string>): string[];
106
+ /**
107
+ * Typed error for sizing requests Modal's create() cannot enforce.
108
+ * The installed Modal JS SDK sizes cpu (cores) and memoryMiB at create time
109
+ * only — there is no disk-size parameter, so a requested disk size would be
110
+ * silently ignored. Per the provider law (reject what you cannot enforce,
111
+ * never silently ignore) it is refused loudly here.
112
+ */
113
+ declare class ModalResourcesError extends Error {
114
+ constructor(message: string);
115
+ }
116
+ /**
117
+ * Map Evolve's provider-neutral resources (cpu cores, memory GiB, disk GiB)
118
+ * onto Modal's create() params (cpu cores, memoryMiB). Fractional GiB rounds
119
+ * UP so the sandbox never gets less memory than requested. `disk` throws
120
+ * ModalResourcesError — the SDK cannot express it.
121
+ */
122
+ /**
123
+ * Structural twin of SandboxCreateOptions["resources"] — spelled locally
124
+ * (rather than indexed off the SDK type) so this package can build against
125
+ * an SDK whose published type predates the GPU fields; the provider-parity
126
+ * conformance file still pins the two to each other.
127
+ */
128
+ type ModalCreateResources = {
129
+ cpu?: number;
130
+ memory?: number;
131
+ disk?: number;
132
+ gpu?: number;
133
+ gpuTypes?: string[];
134
+ };
135
+ declare function mapResources(resources?: ModalCreateResources): {
136
+ cpu: number;
137
+ memoryMiB: number;
138
+ gpu?: string;
139
+ };
140
+ /** Modal create() params derived from Evolve's provider-neutral network policy. */
141
+ interface ModalNetworkCreateParams {
142
+ blockNetwork?: boolean;
143
+ outboundCidrAllowlist?: string[];
144
+ outboundDomainAllowlist?: string[];
145
+ }
146
+ /** Why a network destination cannot be mapped onto Modal's allowlist. */
147
+ type ModalNetworkPolicyReason = "port-unsupported" | "invalid-ipv4";
148
+ /**
149
+ * Typed error for destinations Modal's allowlist cannot express.
150
+ *
151
+ * Modal's allowlist filters hosts (domain allowlist) and IPs/CIDRs (CIDR
152
+ * allowlist) only — it has no notion of a port, and an invalid IPv4/CIDR
153
+ * would be silently forwarded to the API. Both are rejected loudly here
154
+ * instead of weakening or mangling the sandbox's egress policy.
155
+ */
156
+ declare class ModalNetworkPolicyError extends Error {
157
+ readonly reason: ModalNetworkPolicyReason;
158
+ /** The offending destination. */
159
+ readonly destination?: string;
160
+ constructor(reason: ModalNetworkPolicyReason, message: string, destination?: string);
161
+ }
162
+ /**
163
+ * Map Evolve's provider-neutral network policy onto Modal create() params.
164
+ *
165
+ * - outbound "open" (or no policy) → no restrictions
166
+ * - outbound "blocked", no allowlist → blockNetwork: true (drops all egress)
167
+ * - outbound "blocked" with allowlist → outboundDomainAllowlist (hostnames,
168
+ * wildcards like "*.example.com") + outboundCidrAllowlist (IPs/CIDRs; bare
169
+ * IPs get /32 or /128 appended). Both lists are always set because Modal
170
+ * treats an unset list as "allow all" — an empty array means "allow none"
171
+ * for that class of destination. Note: Modal's domain allowlist only admits
172
+ * TLS traffic on port 443; plaintext destinations must be listed as CIDRs.
18
173
  */
174
+ declare function mapNetworkPolicy(network?: SandboxCreateOptions["network"]): ModalNetworkCreateParams;
175
+ /** Container registry family for an image tag. */
176
+ type ImageRegistry = "aws-ecr" | "gcp-artifact-registry" | "registry";
177
+ /** Detect which Modal image constructor an image tag needs. */
178
+ declare function resolveImageRegistry(tag: string): ImageRegistry;
179
+ /**
180
+ * Build a SandboxInfo from a sandbox's tags. Modal exposes no metadata or
181
+ * public timestamps, so image and startedAt come from the tags stamped at
182
+ * create time; for sandboxes not created by this SDK they are empty strings
183
+ * (never fabricated). endAt is always undefined — Modal does not expose it.
184
+ */
185
+ declare function buildSandboxInfo(sandboxId: string, tags: Record<string, string>, fallbackImage?: string): SandboxInfo;
19
186
  /** Result of a completed sandbox command */
20
187
  interface SandboxCommandResult {
21
188
  exitCode: number;
@@ -93,15 +260,84 @@ interface SandboxCreateOptions {
93
260
  image?: string;
94
261
  envs?: Record<string, string>;
95
262
  metadata?: Record<string, string>;
263
+ /** Sandbox lifetime in ms. Modal hard-caps lifetime at 24h (MODAL_MAX_LIFETIME_MS). */
96
264
  timeoutMs?: number;
265
+ /**
266
+ * Terminate the sandbox after this long with nothing running in it — the
267
+ * bound that reclaims a box whose client died, without waiting out the whole
268
+ * lifetime. Modal is the only provider with both clocks.
269
+ *
270
+ * OMITTED BY DEFAULT: Modal runs no idle timer unless asked. Modal counts a
271
+ * sandbox active while an exec is running, while its stdin is being written,
272
+ * or while a tunnel connection is open — file operations are not named in
273
+ * that list, and this adapter is safe only because it routes reads and writes
274
+ * through exec (`cat` / `cat >`). A future native filesystem path would need
275
+ * this re-checked.
276
+ */
277
+ idleTimeoutMs?: number;
97
278
  workingDirectory?: string;
279
+ /**
280
+ * Per-sandbox compute sizing: cpu in cores, memory in GiB — mapped to
281
+ * Modal's create-time cpu / memoryMiB requests (defaults when omitted:
282
+ * 4 cores / 4 GiB). `disk` is REJECTED with ModalResourcesError: the Modal
283
+ * JS SDK exposes no disk-size parameter, so a specific disk size cannot be
284
+ * enforced (containers get Modal's default disk quota).
285
+ *
286
+ * `gpu` + `gpuTypes` become Modal's "<TYPE>:<count>" GPU reservation
287
+ * ('any' when no types are named; the FIRST type when several — Modal
288
+ * reserves one type per sandbox; the type passes through verbatim and an
289
+ * unknown one gets Modal's own typed rejection at create).
290
+ */
291
+ resources?: ModalCreateResources;
292
+ /**
293
+ * Provider-neutral outbound network policy, enforced by Modal's network
294
+ * stack. "blocked" with no allowedDestinations drops all egress; with
295
+ * allowedDestinations, hostnames go to Modal's domain allowlist (TLS/443
296
+ * only) and IPs/CIDRs to the CIDR allowlist.
297
+ */
298
+ network?: {
299
+ outbound: "open" | "blocked";
300
+ allowedDestinations?: string[];
301
+ };
302
+ /**
303
+ * Run all commands and file operations as this user (default "user"),
304
+ * enforced via an `su <user> -c` wrapper since Modal executes everything as
305
+ * root. Pass "root" to run directly as root with no wrapper.
306
+ */
307
+ user?: string;
308
+ /** Home directory used by the SDK for agent config paths; not consumed by the provider. */
309
+ homeDir?: string;
98
310
  }
99
311
  /** Options for listing sandboxes */
100
312
  interface SandboxListOptions {
313
+ /** Modal has no paused state; filters that exclude "running" match nothing. */
101
314
  state?: ("running" | "paused")[];
102
315
  metadata?: Record<string, string>;
103
316
  limit?: number;
104
317
  }
318
+ /**
319
+ * A COMPLETE (or admittedly incomplete) enumeration of the app's fleet.
320
+ *
321
+ * `complete` is the load-bearing field. Callers that need a whole fleet —
322
+ * orphan sweeps, lifecycle reconciliation — read a sandbox's ABSENCE from the
323
+ * list as evidence it is gone, so a truncated walk and a small fleet must never
324
+ * be the same answer. That includes a walk stopped by the caller's own `limit`:
325
+ * "you asked for ten and there are more" is a truncated fleet.
326
+ */
327
+ interface SandboxListPage {
328
+ sandboxes: SandboxInfo[];
329
+ complete: boolean;
330
+ pagesFetched: number;
331
+ error?: string;
332
+ }
333
+ /**
334
+ * Sandboxes a single enumeration will walk before it gives up and reports
335
+ * itself incomplete. Modal's list is an async generator with no page size we
336
+ * control, so the ceiling is counted in SANDBOXES rather than pages — same
337
+ * purpose as the other providers' page caps: never return a short list that
338
+ * reads like a whole one.
339
+ */
340
+ declare const MODAL_MAX_LIST_SANDBOXES = 10000;
105
341
  /** Command execution capabilities */
106
342
  interface SandboxCommands {
107
343
  /** Run command and wait for completion */
@@ -132,6 +368,8 @@ interface SandboxFiles {
132
368
  readStream(path: string): Promise<ReadableStream<Uint8Array>>;
133
369
  /** Write from stream */
134
370
  writeStream(path: string, stream: ReadableStream<Uint8Array>): Promise<void>;
371
+ /** Upload a local file by path, streamed off disk (never buffered whole) */
372
+ writeFromPath(sandboxPath: string, localPath: string): Promise<void>;
135
373
  /** Get pre-signed upload URL for large files (expiration in seconds) */
136
374
  uploadUrl(path: string, expiresInSeconds?: number): Promise<string>;
137
375
  /** Get pre-signed download URL for large files (expiration in seconds) */
@@ -169,12 +407,17 @@ interface SandboxInstance {
169
407
  interface SandboxProvider {
170
408
  /** Provider type identifier */
171
409
  readonly providerType: string;
410
+ /** Human-readable provider name for logging */
411
+ readonly name?: string;
172
412
  /** Create new sandbox */
173
413
  create(options: SandboxCreateOptions): Promise<SandboxInstance>;
174
414
  /** Connect to existing sandbox */
175
415
  connect(sandboxId: string, timeoutMs?: number): Promise<SandboxInstance>;
176
416
  /** List sandboxes (first page only, up to limit) */
417
+ /** List sandboxes, walking the whole app. `limit` bounds items returned. */
177
418
  list(options?: SandboxListOptions): Promise<SandboxInfo[]>;
419
+ /** The same enumeration for fleet bookkeeping: never throws, reports completeness. */
420
+ listAll(options?: SandboxListOptions): Promise<SandboxListPage>;
178
421
  }
179
422
  interface ModalConfig {
180
423
  /** Modal app name. Default: "evolve-sandbox" */
@@ -187,8 +430,15 @@ interface ModalConfig {
187
430
  tokenSecret?: string;
188
431
  /** Modal API endpoint. Default: https://api.modal.com:443 */
189
432
  endpoint?: string;
190
- /** Docker image name (default: 'evolve-all'). Resolved through IMAGE_MAP or used as-is for custom images. */
433
+ /** Docker image name (default: 'evolve-all-<EVOLVE_IMAGE_VERSION>'). Resolved through IMAGE_MAP or used as-is for custom images; explicit names pass through untouched. */
191
434
  imageName?: string;
435
+ /**
436
+ * Name of a Modal Secret holding registry credentials for private images.
437
+ * Required for AWS ECR (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY,
438
+ * AWS_REGION with read-only ECR IAM) and GCP Artifact Registry; optional
439
+ * for private Docker Hub images. Create one at https://modal.com/secrets
440
+ */
441
+ imageSecretName?: string;
192
442
  }
193
443
  /** Internal resolved config with required credentials */
194
444
  interface ResolvedModalConfig {
@@ -198,6 +448,64 @@ interface ResolvedModalConfig {
198
448
  defaultTimeoutMs?: number;
199
449
  endpoint?: string;
200
450
  imageName?: string;
451
+ imageSecretName?: string;
452
+ }
453
+ declare class ModalCommands implements SandboxCommands {
454
+ private sandbox;
455
+ private user;
456
+ constructor(sandbox: Sandbox, user: string);
457
+ run(command: string, options?: SandboxRunOptions): Promise<SandboxCommandResult>;
458
+ spawn(command: string, options?: SandboxSpawnOptions): Promise<SandboxCommandHandle>;
459
+ list(): Promise<ProcessInfo[]>;
460
+ connect(_processId: string, _options?: SandboxConnectOptions): Promise<SandboxCommandHandle>;
461
+ sendStdin(_processId: string, _data: string): Promise<void>;
462
+ kill(processId: string): Promise<boolean>;
463
+ /**
464
+ * Accumulate stdout/stderr using for-await pattern (more reliable with Modal streams).
465
+ * Based on vibekit's approach which works correctly with Modal SDK.
466
+ */
467
+ private accumulateStreams;
468
+ }
469
+ declare class ModalFiles implements SandboxFiles {
470
+ private sandbox;
471
+ private user;
472
+ constructor(sandbox: Sandbox, user: string);
473
+ /**
474
+ * Chown a path to the sandbox user so agent CLIs (running via the su
475
+ * wrapper) can access files created by root-level exec. No-op when the
476
+ * sandbox user is root.
477
+ */
478
+ private chownToUser;
479
+ /**
480
+ * Write a payload to a process's stdin in MODAL_STDIN_CHUNK_BYTES slices.
481
+ * Each writeBytes() call becomes one gRPC TaskExecStdinWrite message and
482
+ * Modal rejects messages over 100MiB, so large files must be chunked
483
+ * (multi-hundred-MB payloads are common).
484
+ */
485
+ private writeStdinChunked;
486
+ read(path: string): Promise<string | Uint8Array>;
487
+ write(path: string, content: string | Buffer | ArrayBuffer | Uint8Array): Promise<void>;
488
+ writeBatch(files: Array<{
489
+ path: string;
490
+ data: string | Buffer | ArrayBuffer | Uint8Array;
491
+ }>): Promise<void>;
492
+ makeDir(path: string): Promise<void>;
493
+ exists(path: string): Promise<boolean>;
494
+ list(path: string): Promise<FileInfo[]>;
495
+ remove(path: string): Promise<void>;
496
+ rename(oldPath: string, newPath: string): Promise<void>;
497
+ readStream(path: string): Promise<ReadableStream<Uint8Array>>;
498
+ writeStream(path: string, stream: ReadableStream<Uint8Array>): Promise<void>;
499
+ /**
500
+ * Upload a local file by PATH, chunk by chunk into the same `cat >` sink
501
+ * writeStream() uses — so peak memory is one chunk rather than the whole
502
+ * file, which is what makes a large artifact safe under concurrency.
503
+ */
504
+ writeFromPath(sandboxPath: string, localPath: string): Promise<void>;
505
+ uploadUrl(_path: string, _expiresInSeconds?: number): Promise<string>;
506
+ downloadUrl(_path: string, _expiresInSeconds?: number): Promise<string>;
507
+ watchDir(_path: string, _onEvent: (event: FilesystemEvent) => void | Promise<void>, _options?: WatchOptions): Promise<WatchHandle>;
508
+ private toBuffer;
201
509
  }
202
510
  declare class ModalProvider implements SandboxProvider {
203
511
  readonly providerType: "modal";
@@ -206,13 +514,103 @@ declare class ModalProvider implements SandboxProvider {
206
514
  private readonly appName;
207
515
  private readonly defaultTimeoutMs;
208
516
  private readonly imageName;
517
+ private readonly imageSecretName?;
209
518
  private _app;
519
+ /**
520
+ * Sandbox user configured at create time, reapplied on connect() so the su
521
+ * wrapper keeps targeting the same account. In-memory only: a connect()
522
+ * from a fresh process falls back to the default "user" account — callers
523
+ * reconnecting across processes must recreate the provider and sandbox with
524
+ * the same user, or operations on user-owned files fail loudly.
525
+ */
526
+ private readonly sandboxUsers;
210
527
  constructor(config: ResolvedModalConfig);
211
528
  private getApp;
529
+ /**
530
+ * Build a Modal Image for the resolved tag, routing private registries
531
+ * (AWS ECR, GCP Artifact Registry) through the configured Modal Secret.
532
+ */
533
+ private resolveImage;
212
534
  create(options: SandboxCreateOptions): Promise<SandboxInstance>;
213
535
  connect(sandboxId: string, _timeoutMs?: number): Promise<SandboxInstance>;
214
- list(_options?: SandboxListOptions): Promise<SandboxInfo[]>;
536
+ /**
537
+ * List sandboxes, walking the whole app.
538
+ *
539
+ * This used to stop at a hardcoded default of 100 regardless of fleet size,
540
+ * which silently truncated any app with more — and said nothing about it
541
+ * while the shared SandboxProvider interface promised exhaustive listing.
542
+ * `limit` still bounds the sandboxes RETURNED, so a caller wanting one cheap
543
+ * sample asks for one; without it the answer is the whole app.
544
+ *
545
+ * The O(N)-round-trips warning below is unchanged and is the reason `limit`
546
+ * matters here more than on the other providers.
547
+ */
548
+ list(options?: SandboxListOptions): Promise<SandboxInfo[]>;
549
+ /**
550
+ * The fleet-bookkeeping enumeration: same walk, never throws.
551
+ *
552
+ * Modal has no lifecycle webhooks, so absence from a list is the ONLY
553
+ * termination signal either lane gets — which makes the difference between
554
+ * "the app is empty" and "the enumeration stopped early" the difference
555
+ * between a quiet fleet and one about to be reclaimed.
556
+ *
557
+ * NOTE the divergence from `listSandboxIds` below, which returns an EMPTY set
558
+ * on failure on the grounds that partial results are worse than none for a
559
+ * terminal-state decision. This one returns what it saw alongside
560
+ * `complete: false`. Both are safe because `complete` is what callers branch
561
+ * on, and the shared type documents the choice; do not align one to the other
562
+ * without deciding which rule you want.
563
+ */
564
+ listAll(options?: SandboxListOptions): Promise<SandboxListPage>;
565
+ private walk;
566
+ /**
567
+ * Live sandbox ids for the whole app, in ONE streamed call and O(1) round
568
+ * trips — the fleet-bookkeeping counterpart to `list()`.
569
+ *
570
+ * Exists because `list()` cannot be made cheap without dropping metadata from
571
+ * its contract: it owes callers a populated `SandboxInfo.metadata`, and Modal
572
+ * only serves tags per sandbox. Anything that just needs "which ids are
573
+ * alive" — lifecycle polling, orphan sweeps, reconciliation — uses this and
574
+ * pays one request no matter how large the fleet is.
575
+ *
576
+ * `complete` is the load-bearing field, not a nicety: absence from this list
577
+ * is what callers read as "terminated", so a truncated or errored enumeration
578
+ * MUST NOT be mistaken for an empty fleet. A caller that sees complete=false
579
+ * has to leave rows alone rather than mass-marking live sandboxes dead.
580
+ */
581
+ listSandboxIds(): Promise<{
582
+ ids: Set<string>;
583
+ complete: boolean;
584
+ }>;
215
585
  }
586
+ /** The streamed sandbox surface this walk needs — Modal's list() satisfies it. */
587
+ interface ModalSandboxStream {
588
+ sandboxId: string;
589
+ getTags(): Promise<Record<string, string>>;
590
+ }
591
+ /**
592
+ * Drain Modal's sandbox generator into one answer, with an honest completeness
593
+ * verdict.
594
+ *
595
+ * Separate from the provider because everything worth getting wrong lives here
596
+ * and none of it needs a gRPC connection: the difference between "the caller
597
+ * asked for ten" and "the app ran out", the ceiling that stops an unbounded
598
+ * walk, and the rule that a failure mid-walk yields what it saw marked
599
+ * INCOMPLETE rather than an exception or a short complete list.
600
+ *
601
+ * COST WARNING — this loop is O(N) ROUND TRIPS, not O(1). `list()` itself is one
602
+ * streamed call, but `getTags()` is a separate gRPC request per sandbox
603
+ * (`sandboxTagsGet`), so listing N sandboxes costs N+1 calls. That is fine for a
604
+ * user listing their handful of boxes with metadata, and NOT fine for fleet-wide
605
+ * bookkeeping: anything that only needs to know WHICH ids are alive must use
606
+ * `listSandboxIds`, never this. Please do not "optimize" by reintroducing tag
607
+ * reads into those paths.
608
+ *
609
+ * Exported for its test (`_testCollectSandboxes`).
610
+ */
611
+ declare function collectSandboxes(iterate: () => AsyncIterable<ModalSandboxStream>, wanted?: number): Promise<SandboxListPage & {
612
+ stoppedAtLimit: boolean;
613
+ }>;
216
614
  /**
217
615
  * Create Modal sandbox provider.
218
616
  *
@@ -222,5 +620,14 @@ declare class ModalProvider implements SandboxProvider {
222
620
  * @see https://github.com/evolving-machines-lab/evolve/issues/8
223
621
  */
224
622
  declare function createModalProvider(config?: ModalConfig): SandboxProvider;
623
+ declare const _testWrapCommand: typeof wrapCommand;
624
+ declare const _testImageMap: Record<string, string>;
625
+ declare const _testMapNetworkPolicy: typeof mapNetworkPolicy;
626
+ declare const _testMapResources: typeof mapResources;
627
+ declare const _testResolveImageRegistry: typeof resolveImageRegistry;
628
+ declare const _testBuildSandboxInfo: typeof buildSandboxInfo;
629
+ declare const _testCollectSandboxes: typeof collectSandboxes;
630
+ declare const _testValidateTimeout: typeof validateTimeout;
631
+ declare const _testMapIdleTimeout: typeof mapIdleTimeout;
225
632
 
226
- export { type FileInfo, type FilesystemEvent, type ModalConfig, ModalProvider, type ProcessInfo, type SandboxCommandHandle, type SandboxCommandResult, type SandboxCommands, type SandboxConnectOptions, type SandboxCreateOptions, type SandboxFiles, type SandboxInfo, type SandboxInstance, type SandboxListOptions, type SandboxProvider, type SandboxRunOptions, type SandboxSpawnOptions, type WatchHandle, type WatchOptions, createModalProvider };
633
+ export { EVOLVE_IMAGE_VERSION, type FileInfo, type FilesystemEvent, MODAL_MAX_LIFETIME_MS, MODAL_MAX_LIST_SANDBOXES, MODAL_STDIN_CHUNK_BYTES, ModalCommands, type ModalConfig, ModalFiles, ModalIdleTimeoutError, ModalNetworkPolicyError, type ModalNetworkPolicyReason, ModalProvider, ModalResourcesError, ModalSandboxLifetimeError, type ModalSandboxStream, type ProcessInfo, type SandboxCommandHandle, type SandboxCommandResult, type SandboxCommands, type SandboxConnectOptions, type SandboxCreateOptions, type SandboxFiles, type SandboxInfo, type SandboxInstance, type SandboxListOptions, type SandboxListPage, type SandboxProvider, type SandboxRunOptions, type SandboxSpawnOptions, type WatchHandle, type WatchOptions, _testBuildSandboxInfo, _testCollectSandboxes, _testImageMap, _testMapIdleTimeout, _testMapNetworkPolicy, _testMapResources, _testResolveImageRegistry, _testValidateTimeout, _testWrapCommand, createModalProvider };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import {ModalClient}from'modal';import {pack}from'tar-stream';var b={"evolve-all":"evolvingmachines/evolve-all"},h=new Set([".xlsx",".xls",".docx",".doc",".pptx",".ppt",".pdf",".zip",".tar",".gz",".7z",".rar",".png",".jpg",".jpeg",".gif",".webp",".ico",".bmp",".mp3",".wav",".ogg",".flac",".aac",".mp4",".avi",".mov",".mkv",".webm",".woff",".woff2",".ttf",".otf",".eot",".exe",".dll",".so",".dylib",".sqlite",".db",".pickle",".pkl",".parquet"]);function y(c){let t=c.substring(c.lastIndexOf(".")).toLowerCase();return h.has(t)}var u=class{constructor(t){this.sandbox=t;}wrapAsUser(t,e,n){let r="";n&&Object.keys(n).length>0&&(r=Object.entries(n).filter(([,o])=>o!=null).map(([o,i])=>`export ${o}='${String(i).replace(/'/g,"'\\''")}'`).join("; ")+"; ");let s=e?`cd '${e.replace(/'/g,"'\\''")}' && ${r}${t}`:`${r}${t}`;return ["su","user","-c",`echo ${Buffer.from(s).toString("base64")} | base64 -d | bash`]}async run(t,e){let n=this.wrapAsUser(t,e?.cwd,e?.envs),r=await this.sandbox.exec(n,{timeoutMs:e?.timeoutMs}),{stdout:s,stderr:a}=await this.accumulateStreams(r,e?.onStdout,e?.onStderr);return {exitCode:await r.wait(),stdout:s,stderr:a}}async spawn(t,e){let n=this.wrapAsUser(t,e?.cwd,e?.envs),r=await this.sandbox.exec(n,{timeoutMs:e?.timeoutMs}),s="",a="",o=this.accumulateStreams(r,e?.onStdout?d=>{e.onStdout(d);}:void 0,e?.onStderr?d=>{e.onStderr(d);}:void 0).then(({stdout:d,stderr:m})=>{s=d,a=m;}).catch(()=>{});return {processId:`modal-${Date.now()}-${Math.random().toString(36).slice(2)}`,wait:async()=>(await o,{exitCode:await r.wait(),stdout:s,stderr:a}),kill:async()=>false}}async list(){let t=await this.sandbox.exec(["ps","-eo","pid,comm,args"],{timeoutMs:1e4});return await t.wait(),(await t.stdout.readText()).trim().split(`
2
- `).slice(1).map(r=>{let s=r.trim().split(/\s+/);return {processId:s[0],cmd:s[1]||"",args:s.slice(2),envs:{}}})}async connect(t,e){throw new Error("Modal does not support connecting to existing processes")}async sendStdin(t,e){throw new Error("Modal does not support sendStdin by process ID")}async kill(t){return await(await this.sandbox.exec(["kill","-9",t],{timeoutMs:1e4})).wait()===0}async accumulateStreams(t,e,n){let r="",s="",a=[];return a.push((async()=>{try{for await(let o of t.stdout){let i=typeof o=="string"?o:new TextDecoder().decode(o);r+=i,e?.(i);}}catch{}})()),a.push((async()=>{try{for await(let o of t.stderr){let i=typeof o=="string"?o:new TextDecoder().decode(o);s+=i,n?.(i);}}catch{}})()),await Promise.all(a),{stdout:r,stderr:s}}},g=class{constructor(t){this.sandbox=t;}async read(t){if(y(t)){let r=await this.sandbox.exec(["cat",t],{timeoutMs:3e5,mode:"binary"}),s=await r.wait();if(s!==0){let a=await r.stderr.readText();throw new Error(`Failed to read file ${t}: ${a||`exit code ${s}`}`)}return await r.stdout.readBytes()}let e=await this.sandbox.exec(["cat",t],{timeoutMs:3e5}),n=await e.wait();if(n!==0){let r=await e.stderr.readText();throw new Error(`Failed to read file ${t}: ${r||`exit code ${n}`}`)}return await e.stdout.readText()}async write(t,e){let n=this.toBuffer(e),r=t.substring(0,t.lastIndexOf("/"));r&&await this.makeDir(r);let s=t.replace(/'/g,"'\\''"),a=await this.sandbox.exec(["bash","-c",`cat > '${s}'`],{mode:"binary"});await a.stdin.writeBytes(new Uint8Array(n)),await a.stdin.getWriter().close(),await a.wait(),await(await this.sandbox.exec(["chown","user:user",t],{timeoutMs:1e4})).wait();}async writeBatch(t){let e=pack(),n=[],r=new Set;for(let i of t){let d=this.toBuffer(i.data),m=i.path.startsWith("/")?i.path.slice(1):i.path;e.entry({name:m},d);let p=i.path.substring(0,i.path.lastIndexOf("/"));p&&r.add(p);}e.finalize();for await(let i of e)n.push(Buffer.from(i));let s=Buffer.concat(n),a=await this.sandbox.exec(["tar","-xf","-","-C","/"],{mode:"binary"});if(await a.stdin.writeBytes(new Uint8Array(s)),await a.stdin.getWriter().close(),await a.wait(),r.size>0){let i=Array.from(r),d=new Set(i.map(m=>m.split("/").slice(0,4).join("/")));for(let m of d)await(await this.sandbox.exec(["chown","-R","user:user",m],{timeoutMs:3e4})).wait();}}async makeDir(t){await(await this.sandbox.exec(["mkdir","-p",t],{timeoutMs:1e4})).wait(),await(await this.sandbox.exec(["chown","-R","user:user",t],{timeoutMs:1e4})).wait();}async exists(t){return await(await this.sandbox.exec(["test","-e",t],{timeoutMs:1e4})).wait()===0}async list(t){let e=t.replace(/'/g,"'\\''"),n=await this.sandbox.exec(["bash","-c",`ls -la '${e}' | tail -n +2`],{timeoutMs:3e4});await n.wait();let r=await n.stdout.readText(),s=[];for(let a of r.trim().split(`
3
- `)){if(!a)continue;let o=a.split(/\s+/);if(o.length<9)continue;let i=o[0],d=o.slice(8).join(" ");d==="."||d===".."||s.push({name:d,path:t.endsWith("/")?`${t}${d}`:`${t}/${d}`,type:i.startsWith("d")?"dir":"file"});}return s}async remove(t){await(await this.sandbox.exec(["rm","-rf",t],{timeoutMs:3e4})).wait();}async rename(t,e){await(await this.sandbox.exec(["mv",t,e],{timeoutMs:3e4})).wait();}async readStream(t){return (await this.sandbox.exec(["cat",t],{timeoutMs:3e5,mode:"binary"})).stdout}async writeStream(t,e){let n=t.substring(0,t.lastIndexOf("/"));n&&await this.makeDir(n);let r=t.replace(/'/g,"'\\''"),s=await this.sandbox.exec(["bash","-c",`cat > '${r}'`],{mode:"binary"}),a=e.getReader();try{for(;;){let{done:i,value:d}=await a.read();if(i)break;await s.stdin.writeBytes(d);}}finally{a.releaseLock();}await s.stdin.getWriter().close(),await s.wait();}async uploadUrl(t,e){throw new Error("Modal does not support pre-signed upload URLs")}async downloadUrl(t,e){throw new Error("Modal does not support pre-signed download URLs")}async watchDir(t,e,n){throw new Error("Modal does not support watchDir")}toBuffer(t){if(typeof t=="string")return Buffer.from(t,"utf-8");if(t instanceof Buffer)return t;if(t instanceof ArrayBuffer||t instanceof Uint8Array)return Buffer.from(t);throw new Error(`Unsupported data type: ${typeof t}`)}},l=class{constructor(t,e){this.sandbox=t;this.commands=new u(t),this.files=new g(t),this.image=e,this.startTime=new Date;}commands;files;image;startTime;get sandboxId(){return this.sandbox.sandboxId}async getHost(t){let n=(await this.sandbox.tunnels())[t];if(!n)throw new Error(`No tunnel found for port ${t}`);return n.url}async isRunning(){try{return await(await this.sandbox.exec(["echo","ping"],{timeoutMs:5e3})).wait(),!0}catch{return false}}async getInfo(){return {sandboxId:this.sandbox.sandboxId,image:this.image,metadata:{},startedAt:this.startTime.toISOString()}}async kill(){try{await this.sandbox.terminate();}catch{await new Promise(t=>setTimeout(t,500)),await this.sandbox.terminate();}}async pause(){throw new Error("Modal does not support pause. Use kill() instead.")}},w=class{providerType="modal";name="Modal";client;appName;defaultTimeoutMs;imageName;_app;constructor(t){!t.endpoint&&process.env.MODAL_SERVER_URL?.startsWith("unix:")?process.env.MODAL_SERVER_URL="https://api.modal.com:443":t.endpoint&&(process.env.MODAL_SERVER_URL=t.endpoint),this.client=new ModalClient({tokenId:t.tokenId,tokenSecret:t.tokenSecret}),this.appName=t.appName??"evolve-sandbox",this.defaultTimeoutMs=t.defaultTimeoutMs??36e5,this.imageName=t.imageName??"evolve-all";}async getApp(){return this._app||(this._app=await this.client.apps.fromName(this.appName,{createIfMissing:true})),this._app}async create(t){let e=await this.getApp(),n=t.timeoutMs??this.defaultTimeoutMs,r=t.image||this.imageName,s=b[r]??r,o=await this.client.images.fromRegistry(s).build(e),i=t.envs?Object.fromEntries(Object.entries(t.envs).filter(([,p])=>p!=null)):void 0,d=i&&Object.keys(i).length>0?i:void 0,m=await this.client.sandboxes.create(e,o,{cpu:4,memoryMiB:4096,timeoutMs:n,workdir:t.workingDirectory,env:d});return t.workingDirectory&&await(await m.exec(["chown","-R","user:user",t.workingDirectory],{timeoutMs:3e4})).wait(),new l(m,s)}async connect(t,e){let n=await this.client.sandboxes.fromId(t);return new l(n,"unknown")}async list(t){let e=[],n=t?.limit??100;try{for await(let r of this.client.sandboxes.list())if(e.push({sandboxId:r.sandboxId,image:"unknown",metadata:{},startedAt:new Date().toISOString()}),e.length>=n)break}catch{}return e}};function k(c={}){let t=c.tokenId??process.env.MODAL_TOKEN_ID,e=c.tokenSecret??process.env.MODAL_TOKEN_SECRET;if(!t||!e)throw new Error("Modal credentials required. Set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET environment variables, or pass tokenId/tokenSecret in config. Get your token at https://modal.com/settings/tokens");return new w({...c,tokenId:t,tokenSecret:e})}
4
- export{w as ModalProvider,k as createModalProvider};
1
+ import {ModalClient}from'modal';import {pack}from'tar-stream';var p="c-7bcda3467a79";var R={[`evolve-all-${p}`]:`evolvingmachines/evolve-all:${p}`,"evolve-all":"evolvingmachines/evolve-all"},_=1440*60*1e3,M=8*1024*1024,k="user",C="evolve.image",O="evolve.startedAt",y=class extends Error{requestedTimeoutMs;constructor(e){let t=(e/36e5).toFixed(1);super(`Modal sandboxes have a hard 24h lifetime cap; requested timeout was ${t}h. For sessions longer than 24h, persist progress with Evolve checkpoints and resume in a fresh sandbox instead of extending the timeout.`),this.name="ModalSandboxLifetimeError",this.requestedTimeoutMs=e;}};function T(s){if(s>_)throw new y(s)}var u=class extends Error{requestedIdleTimeoutMs;constructor(e,t){super(`Modal idleTimeoutMs of ${e}ms is invalid: ${t}`),this.name="ModalIdleTimeoutError",this.requestedIdleTimeoutMs=e;}};function D(s){if(s===void 0)return {};if(!Number.isFinite(s)||s<=0)throw new u(s,"it must be a positive number of milliseconds");if(s>_)throw new u(s,"it exceeds Modal's 24h lifetime cap, so the sandbox would always die of the lifetime first");return {idleTimeoutMs:s}}function S(s,e,t,r){let n="";r&&Object.keys(r).length>0&&(n=Object.entries(r).filter(([,d])=>d!=null).map(([d,a])=>`export ${d}='${String(a).replace(/'/g,"'\\''")}'`).join("; ")+"; ");let i=t?`cd '${t.replace(/'/g,"'\\''")}' && ${n}${s}`:`${n}${s}`;if(e==="root")return ["bash","-c",i];let o=Buffer.from(i).toString("base64");return ["su",e,"-c",`echo ${o} | base64 -d | bash`]}var g=class extends Error{constructor(e){super(e),this.name="ModalResourcesError";}},V=4,j=4096;function N(s){if(s?.disk!==void 0)throw new g(`Modal's JS SDK has no create-time disk-size parameter, so a ${s.disk} GiB disk request cannot be enforced. Drop \`resources.disk\` (containers get Modal's default disk quota) or run on a provider that sizes disk.`);if((s?.gpu===void 0||s.gpu<=0)&&s?.gpuTypes?.length)throw new g("resources.gpuTypes was set without a positive resources.gpu count \u2014 declare how many GPUs to reserve, or drop gpuTypes.");let e=s?.gpu!==void 0&&s.gpu>0?`${s.gpuTypes?.length?s.gpuTypes[0]:"any"}:${s.gpu}`:void 0;return {cpu:s?.cpu??V,memoryMiB:s?.memory!==void 0?Math.ceil(s.memory*1024):j,...e!==void 0?{gpu:e}:{}}}var f=class extends Error{reason;destination;constructor(e,t,r){super(t),this.name="ModalNetworkPolicyError",this.reason=e,this.destination=r;}};function $(s){let e=/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,3}))?$/.exec(s);return !(!e||[e[1],e[2],e[3],e[4]].some(t=>Number(t)>255)||e[5]!==void 0&&Number(e[5])>32)}function q(s){return /^\d{1,3}(\.\d{1,3}){3}(\/\d+)?$/.test(s)&&!$(s)}function K(s){return s.startsWith("[")?true:(s.match(/:/g)?.length??0)>=2}function z(s){return /^[^:]+:\d+$/.test(s)}function L(s){if(!s||s.outbound==="open"){if(s?.allowedDestinations?.length)throw new Error("network.allowedDestinations is only valid when outbound is blocked");return {}}let e=s.allowedDestinations??[];if(e.length===0)return {blockNetwork:true};let t=[],r=[];for(let n of e)if($(n))t.push(n.includes("/")?n:`${n}/32`);else {if(q(n))throw new f("invalid-ipv4",`"${n}" is not a valid IPv4 address or CIDR (octets must be 0-255, prefix 0-32). Fix the address or list a hostname instead.`,n);if(K(n))t.push(n.includes("/")?n:`${n}/128`);else {if(z(n))throw new f("port-unsupported",`Modal's network allowlist filters hosts and IPs only and cannot match a port; drop the ":<port>" from "${n}" and list just the host or IP.`,n);r.push(n);}}return {outboundCidrAllowlist:t,outboundDomainAllowlist:r}}function U(s){let e=s.split("/")[0];return /^\d{12}\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com$/.test(e)?"aws-ecr":e==="gcr.io"||e.endsWith(".gcr.io")||e.endsWith("-docker.pkg.dev")?"gcp-artifact-registry":"registry"}function A(s,e,t){let{[C]:r,[O]:n,...i}=e;return {sandboxId:s,image:r??t??"",metadata:i,startedAt:n??""}}var E=1e4,v=class{constructor(e,t){this.sandbox=e;this.user=t;}async run(e,t){let r=S(e,this.user,t?.cwd,t?.envs),n=await this.sandbox.exec(r,{timeoutMs:t?.timeoutMs}),{stdout:i,stderr:o}=await this.accumulateStreams(n,t?.onStdout,t?.onStderr);return {exitCode:await n.wait(),stdout:i,stderr:o}}async spawn(e,t){let r=S(e,this.user,t?.cwd,t?.envs),n=await this.sandbox.exec(r,{timeoutMs:t?.timeoutMs}),i="",o="",d=this.accumulateStreams(n,t?.onStdout?c=>{t.onStdout(c);}:void 0,t?.onStderr?c=>{t.onStderr(c);}:void 0).then(({stdout:c,stderr:m})=>{i=c,o=m;}).catch(()=>{});return {processId:`modal-${Date.now()}-${Math.random().toString(36).slice(2)}`,wait:async()=>(await d,{exitCode:await n.wait(),stdout:i,stderr:o}),kill:async()=>false}}async list(){let e=await this.sandbox.exec(["ps","-eo","pid,comm,args"],{timeoutMs:1e4});return await e.wait(),(await e.stdout.readText()).trim().split(`
2
+ `).slice(1).map(n=>{let i=n.trim().split(/\s+/);return {processId:i[0],cmd:i[1]||"",args:i.slice(2),envs:{}}})}async connect(e,t){throw new Error("Modal does not support connecting to existing processes")}async sendStdin(e,t){throw new Error("Modal does not support sendStdin by process ID")}async kill(e){return await(await this.sandbox.exec(["kill","-9",e],{timeoutMs:1e4})).wait()===0}async accumulateStreams(e,t,r){let n="",i="",o=[];return o.push((async()=>{try{for await(let d of e.stdout){let a=typeof d=="string"?d:new TextDecoder().decode(d);n+=a,t?.(a);}}catch{}})()),o.push((async()=>{try{for await(let d of e.stderr){let a=typeof d=="string"?d:new TextDecoder().decode(d);i+=a,r?.(a);}}catch{}})()),await Promise.all(o),{stdout:n,stderr:i}}},P=class{constructor(e,t){this.sandbox=e;this.user=t;}async chownToUser(e,t=false){if(this.user==="root")return;let r=t?["chown","-R",`${this.user}:${this.user}`,e]:["chown",`${this.user}:${this.user}`,e];await(await this.sandbox.exec(r,{timeoutMs:t?3e4:1e4})).wait();}async writeStdinChunked(e,t){for(let r=0;r<t.length;r+=M)await e.writeBytes(t.subarray(r,r+M));}async read(e){let t=await this.sandbox.exec(["cat",e],{timeoutMs:3e5,mode:"binary"}),r=await t.wait();if(r!==0){let i=await t.stderr.readText();throw new Error(`Failed to read file ${e}: ${i||`exit code ${r}`}`)}let n=await t.stdout.readBytes();if(!n.includes(0))try{return new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}).decode(n)}catch{}return n}async write(e,t){let r=this.toBuffer(t),n=e.substring(0,e.lastIndexOf("/"));n&&await this.makeDir(n);let i=e.replace(/'/g,"'\\''"),o=await this.sandbox.exec(["bash","-c",`cat > '${i}'`],{mode:"binary"});await this.writeStdinChunked(o.stdin,new Uint8Array(r)),await o.stdin.getWriter().close(),await o.wait(),await this.chownToUser(e);}async writeBatch(e){let t=pack(),r=[],n=new Set;for(let a of e){let c=this.toBuffer(a.data),m=a.path.startsWith("/")?a.path.slice(1):a.path;t.entry({name:m},c);let l=a.path.substring(0,a.path.lastIndexOf("/"));l&&n.add(l);}t.finalize();for await(let a of t)r.push(Buffer.from(a));let i=Buffer.concat(r),o=await this.sandbox.exec(["tar","-xf","-","-C","/"],{mode:"binary"});if(await this.writeStdinChunked(o.stdin,new Uint8Array(i)),await o.stdin.getWriter().close(),await o.wait(),n.size>0){let a=Array.from(n),c=new Set(a.map(m=>m.split("/").slice(0,4).join("/")));for(let m of c)await this.chownToUser(m,true);}}async makeDir(e){await(await this.sandbox.exec(["mkdir","-p",e],{timeoutMs:1e4})).wait(),await this.chownToUser(e,true);}async exists(e){return await(await this.sandbox.exec(["test","-e",e],{timeoutMs:1e4})).wait()===0}async list(e){let t=e.replace(/'/g,"'\\''"),r=await this.sandbox.exec(["bash","-c",`ls -la '${t}' | tail -n +2`],{timeoutMs:3e4});await r.wait();let n=await r.stdout.readText(),i=[];for(let o of n.trim().split(`
3
+ `)){if(!o)continue;let d=o.split(/\s+/);if(d.length<9)continue;let a=d[0],c=d.slice(8).join(" ");c==="."||c===".."||i.push({name:c,path:e.endsWith("/")?`${e}${c}`:`${e}/${c}`,type:a.startsWith("d")?"dir":"file"});}return i}async remove(e){await(await this.sandbox.exec(["rm","-rf",e],{timeoutMs:3e4})).wait();}async rename(e,t){await(await this.sandbox.exec(["mv",e,t],{timeoutMs:3e4})).wait();}async readStream(e){return (await this.sandbox.exec(["cat",e],{timeoutMs:3e5,mode:"binary"})).stdout}async writeStream(e,t){let r=e.substring(0,e.lastIndexOf("/"));r&&await this.makeDir(r);let n=e.replace(/'/g,"'\\''"),i=await this.sandbox.exec(["bash","-c",`cat > '${n}'`],{mode:"binary"}),o=t.getReader();try{for(;;){let{done:a,value:c}=await o.read();if(a)break;await this.writeStdinChunked(i.stdin,c);}}finally{o.releaseLock();}await i.stdin.getWriter().close(),await i.wait(),await this.chownToUser(e);}async writeFromPath(e,t){let{createReadStream:r}=await import('fs'),{Readable:n}=await import('stream'),i=n.toWeb(r(t));await this.writeStream(e,i);}async uploadUrl(e,t){throw new Error("Modal does not support pre-signed upload URLs")}async downloadUrl(e,t){throw new Error("Modal does not support pre-signed download URLs")}async watchDir(e,t,r){throw new Error("Modal does not support watchDir")}toBuffer(e){if(typeof e=="string")return Buffer.from(e,"utf-8");if(e instanceof Buffer)return e;if(e instanceof ArrayBuffer||e instanceof Uint8Array)return Buffer.from(e);throw new Error(`Unsupported data type: ${typeof e}`)}},h=class{constructor(e,t,r){this.sandbox=e;this.commands=new v(e,r),this.files=new P(e,r),this.image=t;}commands;files;image;get sandboxId(){return this.sandbox.sandboxId}async getHost(e){let r=(await this.sandbox.tunnels())[e];if(!r)throw new Error(`No tunnel found for port ${e}`);return r.url}async isRunning(){try{return await this.sandbox.poll()===null}catch{return false}}async getInfo(){let e=await this.sandbox.getTags();return A(this.sandbox.sandboxId,e,this.image)}async kill(){try{await this.sandbox.terminate();}catch{await new Promise(e=>setTimeout(e,500)),await this.sandbox.terminate();}}async pause(){throw new Error("Modal does not support pause/resume. Persist progress with Evolve checkpoints and resume in a fresh sandbox, or use kill() to terminate.")}},I=class{providerType="modal";name="Modal";client;appName;defaultTimeoutMs;imageName;imageSecretName;_app;sandboxUsers=new Map;constructor(e){!e.endpoint&&process.env.MODAL_SERVER_URL?.startsWith("unix:")?process.env.MODAL_SERVER_URL="https://api.modal.com:443":e.endpoint&&(process.env.MODAL_SERVER_URL=e.endpoint),this.client=new ModalClient({tokenId:e.tokenId,tokenSecret:e.tokenSecret}),this.appName=e.appName??"evolve-sandbox",this.defaultTimeoutMs=e.defaultTimeoutMs??36e5,this.imageName=e.imageName??`evolve-all-${p}`,this.imageSecretName=e.imageSecretName;}async getApp(){return this._app||(this._app=await this.client.apps.fromName(this.appName,{createIfMissing:true})),this._app}async resolveImage(e){let t=U(e);if(t==="registry"){let n=this.imageSecretName?await this.client.secrets.fromName(this.imageSecretName):void 0;return this.client.images.fromRegistry(e,n)}if(!this.imageSecretName)throw new Error(`Private registry image "${e}" requires config.imageSecretName \u2014 the name of a Modal Secret holding registry credentials (AWS ECR: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION with read-only ECR IAM). Create one at https://modal.com/secrets`);let r=await this.client.secrets.fromName(this.imageSecretName);return t==="aws-ecr"?this.client.images.fromAwsEcr(e,r):this.client.images.fromGcpArtifactRegistry(e,r)}async create(e){let t=e.timeoutMs??this.defaultTimeoutMs;T(t);let r=D(e.idleTimeoutMs),n=L(e.network),i=N(e.resources),o=e.user??k,d=await this.getApp(),a=e.image||this.imageName,c=R[a]??a,l=await(await this.resolveImage(c)).build(d),w=e.envs?Object.fromEntries(Object.entries(e.envs).filter(([,x])=>x!=null)):void 0,F=w&&Object.keys(w).length>0?w:void 0,W={...e.metadata,[C]:c,[O]:new Date().toISOString()},b=await this.client.sandboxes.create(d,l,{cpu:i.cpu,memoryMiB:i.memoryMiB,...i.gpu!==void 0?{gpu:i.gpu}:{},timeoutMs:t,...r,workdir:e.workingDirectory,env:F,tags:W,...n});return e.workingDirectory&&o!=="root"&&await(await b.exec(["chown","-R",`${o}:${o}`,e.workingDirectory],{timeoutMs:3e4})).wait(),this.sandboxUsers.set(b.sandboxId,o),new h(b,c,o)}async connect(e,t){let r=await this.client.sandboxes.fromId(e),n=this.sandboxUsers.get(e)??k;return new h(r,void 0,n)}async list(e){let t=await this.walk(e);if(t.error&&!t.stoppedAtLimit)throw new Error(t.error);return t.sandboxes}async listAll(e){let{stoppedAtLimit:t,...r}=await this.walk(e);return r}async walk(e){if(e?.state&&!e.state.includes("running"))return {sandboxes:[],complete:true,pagesFetched:0,stoppedAtLimit:false};let t;try{t=await this.getApp();}catch(r){return {sandboxes:[],complete:false,pagesFetched:0,stoppedAtLimit:false,error:`sandbox list failed: ${r instanceof Error?r.message:String(r)}`}}return B(()=>this.client.sandboxes.list({appId:t.appId,tags:e?.metadata}),e?.limit)}async listSandboxIds(){let e=new Set;try{let t=await this.getApp();for await(let r of this.client.sandboxes.list({appId:t.appId}))e.add(r.sandboxId);return {ids:e,complete:!0}}catch{return {ids:new Set,complete:false}}}};async function B(s,e){let t=[];try{for await(let r of s()){if(e!==void 0&&t.length>=e)return {sandboxes:t,complete:!1,pagesFetched:t.length,stoppedAtLimit:!0,error:`stopped at the requested limit of ${e} with more sandboxes available`};if(t.length>=E)return {sandboxes:t,complete:!1,pagesFetched:t.length,stoppedAtLimit:!1,error:`sandbox list exceeded ${E} sandboxes`};let n=await r.getTags();t.push(A(r.sandboxId,n));}}catch(r){return {sandboxes:t,complete:false,pagesFetched:t.length,stoppedAtLimit:false,error:`sandbox list failed: ${r instanceof Error?r.message:String(r)}`}}return {sandboxes:t,complete:true,pagesFetched:t.length,stoppedAtLimit:false}}function se(s={}){let e=s.tokenId??process.env.MODAL_TOKEN_ID,t=s.tokenSecret??process.env.MODAL_TOKEN_SECRET;if(!e||!t)throw new Error("Modal credentials required. Set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET environment variables, or pass tokenId/tokenSecret in config. Get your token at https://modal.com/settings/tokens");return new I({...s,tokenId:e,tokenSecret:t})}var ne=S,ie=R,oe=L,ae=N,de=U,ce=A,me=B,le=T,pe=D;
4
+ export{p as EVOLVE_IMAGE_VERSION,_ as MODAL_MAX_LIFETIME_MS,E as MODAL_MAX_LIST_SANDBOXES,M as MODAL_STDIN_CHUNK_BYTES,v as ModalCommands,P as ModalFiles,u as ModalIdleTimeoutError,f as ModalNetworkPolicyError,I as ModalProvider,g as ModalResourcesError,y as ModalSandboxLifetimeError,ce as _testBuildSandboxInfo,me as _testCollectSandboxes,ie as _testImageMap,pe as _testMapIdleTimeout,oe as _testMapNetworkPolicy,ae as _testMapResources,de as _testResolveImageRegistry,le as _testValidateTimeout,ne as _testWrapCommand,se as createModalProvider};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evolvingmachines/modal",
3
- "version": "0.0.51",
3
+ "version": "0.0.52-berth-day-one.20260814.cc8d926",
4
4
  "keywords": [
5
5
  "ai",
6
6
  "agents",
@@ -33,10 +33,11 @@
33
33
  "scripts": {
34
34
  "build": "tsup --minify",
35
35
  "dev": "tsup --watch",
36
- "type-check": "tsc --noEmit"
36
+ "type-check": "tsc --noEmit",
37
+ "test:unit": "tsx tests/unit/modal-provider.test.ts && tsx tests/unit/modal-list-pagination.test.ts"
37
38
  },
38
39
  "dependencies": {
39
- "modal": "^0.6.0",
40
+ "modal": "^0.9.0",
40
41
  "tar-stream": "^3.1.7"
41
42
  },
42
43
  "devDependencies": {