Files
PaperClipAI/server/src/modules/computers/adapters/remote-program.ts
T
DottaandPaperclip cebfa8b09f Bound initial computer home uploads and exclude runtime state
Stream initial personal files through bounded chunks into private staging and atomically publish without replacing an existing home. Keep the home-level runtime directory out of instruction enumeration and seeding while preserving nested user content.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-10 11:00:36 -05:00

279 lines
12 KiB
TypeScript

/** Fixed remote program. Inputs travel as JSON, never interpolated into Python or shell. */
export const remoteProgram = String.raw`
import os,sys,json,hashlib,base64,tempfile,subprocess,fcntl,stat,shutil
p=json.load(sys.stdin)
def fail(code):
print(json.dumps({'error':code}));sys.exit(0)
def digest(data):return hashlib.sha256(data).hexdigest()
def run(args,cwd=None,env=None):
r=subprocess.run(args,cwd=cwd,env=env,capture_output=True,text=True)
if r.returncode:fail('command_failed')
return r.stdout.strip()
root=p['root']
if not root.startswith('/home/user/paperclip/') or '..' in root.split('/'):fail('invalid')
# Reject symlinks even in existing ancestors; one Unix user is not an OS boundary.
def safe(path):
if not isinstance(path,str) or path.startswith('/') or '\x00' in path or '..' in path.split('/'):fail('invalid')
current=root
for part in path.split('/'):
if part in ('','.'):continue
current=os.path.join(current,part)
if os.path.islink(current):fail('invalid')
if os.path.commonpath([root,os.path.realpath(current)])!=root:fail('invalid')
return current
current='/'
for part in root.split('/'):
if not part:continue
current=os.path.join(current,part)
if os.path.islink(current):fail('invalid')
act=p['action']
if act=='owned-port':
port=p['port'];owner=p['ownerId'];inodes=set()
for table in ['/proc/net/tcp','/proc/net/tcp6']:
for line in open(table).readlines()[1:]:
cols=line.split()
if int(cols[1].split(':')[1],16)==port and cols[3]=='0A':inodes.add(cols[9])
for pid in os.listdir('/proc'):
if not pid.isdigit():continue
try:
if 'paperclip-'+owner+'.slice' not in open('/proc/'+pid+'/cgroup').read():continue
for fd in os.listdir('/proc/'+pid+'/fd'):
target=os.readlink('/proc/'+pid+'/fd/'+fd)
if target.startswith('socket:[') and target[8:-1] in inodes:print('{}');sys.exit(0)
except (FileNotFoundError,PermissionError,ProcessLookupError):pass
fail('conflict')
if act=='seed':
if os.path.exists(root):print(json.dumps({'seeded':False}));sys.exit(0)
os.makedirs(os.path.dirname(root),exist_ok=True)
temp=tempfile.mkdtemp(prefix='.seed-',dir=os.path.dirname(root))
try:
for name,value in p['files'].items():
dest=safe(name);relative=os.path.relpath(dest,root)
if relative=='.':fail('invalid')
target=os.path.join(temp,relative);os.makedirs(os.path.dirname(target),exist_ok=True)
with open(target,'wb') as f:f.write(base64.b64decode(value,validate=True))
try:os.rename(temp,root);result=True
except FileExistsError:result=False
print(json.dumps({'seeded':result}))
finally:
if os.path.exists(temp):shutil.rmtree(temp)
sys.exit(0)
if act=='workspace':
os.makedirs(root,exist_ok=True)
repo=p.get('repositoryUrl');branch=p.get('branch');task=p.get('taskId');base_ref=p.get('baseRef')
if base_ref and (base_ref.startswith('-') or '\n' in base_ref):fail('invalid')
checkout=os.path.join(root,'checkout')
if repo:
if not (repo.startswith('https://') or repo.startswith('ssh://') or repo.startswith('git@')) or '\n' in repo:fail('invalid')
if not os.path.exists(checkout):
# Resolve credentials on the controller for this operation. Only the clone
# child receives the helper environment; no credential file or Git config persists.
auth=p.get('gitAuth') or {};git_env=dict(os.environ)
git_env.update({'GIT_TERMINAL_PROMPT':'0','GIT_CONFIG_GLOBAL':'/dev/null','GIT_CONFIG_NOSYSTEM':'1'})
git_env.update(auth.get('env',{}))
run(['git']+auth.get('configArgs',[])+['clone','--',repo,checkout],env=git_env)
elif run(['git','remote','get-url','origin'],checkout)!=repo:fail('conflict')
else:
os.makedirs(checkout,exist_ok=True)
if not os.path.isdir(os.path.join(checkout,'.git')):run(['git','init',checkout])
if branch:
if branch.startswith('-') or '\n' in branch:fail('invalid')
run(['git','check-ref-format','--branch',branch])
if p.get('mode')=='worktree':
if not task:fail('invalid')
target=os.path.join(root,'tasks',task);os.makedirs(os.path.dirname(target),exist_ok=True)
task_branch=branch or 'paperclip/task-'+task
if not os.path.exists(target):run(['git','worktree','add','-b',task_branch,target,base_ref or 'HEAD'],checkout)
run(['git','rev-parse','--show-toplevel'],target)
if run(['git','symbolic-ref','--quiet','--short','HEAD'],target)!=task_branch:fail('conflict')
else:
target=checkout
if branch:run(['git','checkout',branch],checkout)
print(json.dumps({'remoteCwd':target}));sys.exit(0)
# Hold directory descriptors throughout each operation. Ancestor symlink swaps cannot
# redirect a checked pathname outside the selected placement.
def directory(parts,create=False,start=None):
fd=os.dup(start) if start is not None else os.open('/',os.O_RDONLY|os.O_DIRECTORY)
try:
for part in parts:
if not part or part=='.':continue
if part=='..':fail('invalid')
try:nxt=os.open(part,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=fd)
except FileNotFoundError:
if not create:raise
try:os.mkdir(part,0o700,dir_fd=fd)
except FileExistsError:pass
nxt=os.open(part,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=fd)
os.close(fd);fd=nxt
return fd
except BaseException:os.close(fd);raise
# Initial uploads stay in a private sibling until a no-replace rename publishes them.
# Each request carries at most one bounded chunk, never the whole home.
if act in ('seed-begin','seed-chunk','seed-commit','seed-abort'):
import uuid,ctypes,errno
token=p.get('seedId','')
try:
if str(uuid.UUID(token))!=token:fail('invalid')
except (ValueError,TypeError,AttributeError):fail('invalid')
parentfd=directory(os.path.dirname(root).split('/'),create=True)
name=os.path.basename(root);staging='.paperclip-seed-'+name+'-'+token
stagefd=None;lockfd=None
try:
if act=='seed-abort':
try:shutil.rmtree(staging,dir_fd=parentfd)
except FileNotFoundError:pass
print('{}');sys.exit(0)
if act=='seed-begin':
try:
existing=os.stat(name,dir_fd=parentfd,follow_symlinks=False)
if not stat.S_ISDIR(existing.st_mode):fail('invalid')
print(json.dumps({'started':False}));sys.exit(0)
except FileNotFoundError:pass
os.mkdir(staging,0o700,dir_fd=parentfd)
stagefd=os.open(staging,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW,dir_fd=parentfd)
lockfd=os.open('lock',os.O_CREAT|os.O_RDWR|os.O_NOFOLLOW,0o600,dir_fd=stagefd)
fcntl.flock(lockfd,fcntl.LOCK_EX)
if act=='seed-begin':
os.mkdir('tree',0o700,dir_fd=stagefd)
print(json.dumps({'started':True}))
elif act=='seed-chunk':
relative=p.get('path','');safe(relative)
parts=[part for part in relative.split('/') if part not in ('','.')]
if not parts:fail('invalid')
data=base64.b64decode(p['base64'],validate=True);offset=p.get('offset')
if len(data)>1024*1024 or type(offset)!=int or offset<0 or offset+len(data)>256*1024*1024:fail('invalid')
destfd=directory(['tree']+parts[:-1],create=True,start=stagefd)
try:fd=os.open(parts[-1],os.O_WRONLY|os.O_CREAT|os.O_NOFOLLOW,0o600,dir_fd=destfd)
finally:os.close(destfd)
with os.fdopen(fd,'r+b') as f:
info=os.fstat(f.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_nlink!=1:fail('invalid')
if info.st_size!=offset:fail('conflict')
f.seek(offset);f.write(data)
print('{}')
else:
libc=ctypes.CDLL(None,use_errno=True)
if hasattr(libc,'renameat2'):status=libc.renameat2(stagefd,b'tree',parentfd,os.fsencode(name),1)
elif hasattr(libc,'renameatx_np'):status=libc.renameatx_np(stagefd,b'tree',parentfd,os.fsencode(name),4)
else:fail('invalid')
if status!=0:
code=ctypes.get_errno()
if code not in (errno.EEXIST,errno.ENOTEMPTY):raise OSError(code,os.strerror(code))
if not stat.S_ISDIR(os.stat(name,dir_fd=parentfd,follow_symlinks=False).st_mode):fail('invalid')
print(json.dumps({'seeded':status==0}))
except FileNotFoundError:fail('not_found')
except (OSError,ValueError):fail('invalid')
finally:
if lockfd is not None:os.close(lockfd)
if stagefd is not None:os.close(stagefd)
if act=='seed-commit':
try:shutil.rmtree(staging,dir_fd=parentfd)
except FileNotFoundError:pass
os.close(parentfd)
sys.exit(0)
try:rootfd=directory(root.split('/'))
except FileNotFoundError:fail('not_found')
except OSError:fail('invalid')
def parent(relative,create=False):
safe(relative)
parts=[part for part in relative.split('/') if part not in ('','.')]
if not parts:fail('invalid')
return directory(parts[:-1],create,rootfd),parts[-1]
path=p.get('path','');safe(path)
lock=os.open('.paperclip-editor.lock',os.O_CREAT|os.O_RDWR|os.O_NOFOLLOW,0o600,dir_fd=rootfd)
fcntl.flock(lock,fcntl.LOCK_EX)
try:
if act=='list':
fd=directory(path.split('/'),start=rootfd)
try:
limit=p.get('limit',1000)
if type(limit)!=int or limit<1 or limit>1000:fail('invalid')
out=[];truncated=False;scanned=0
with os.scandir(fd) as entries:
for entry in entries:
if entry.name=='.paperclip-editor.lock':continue
if scanned>=limit:truncated=True;break
scanned+=1
if entry.is_symlink():continue
try:info=entry.stat(follow_symlinks=False)
except FileNotFoundError:continue
if not stat.S_ISDIR(info.st_mode) and not stat.S_ISREG(info.st_mode):continue
out.append({'name':entry.name,'kind':'directory' if stat.S_ISDIR(info.st_mode) else 'file','size':info.st_size,'mtimeMs':info.st_mtime*1000})
print(json.dumps({'entries':out,'truncated':truncated}))
finally:os.close(fd)
elif act=='stat':
parentfd,name=parent(path)
try:info=os.stat(name,dir_fd=parentfd,follow_symlinks=False)
finally:os.close(parentfd)
if not stat.S_ISDIR(info.st_mode) and not stat.S_ISREG(info.st_mode):fail('invalid')
print(json.dumps({'name':name,'kind':'directory' if stat.S_ISDIR(info.st_mode) else 'file','size':info.st_size,'mtimeMs':info.st_mtime*1000}))
elif act=='hash':
parentfd,name=parent(path)
try:fd=os.open(name,os.O_RDONLY|os.O_NOFOLLOW,dir_fd=parentfd)
finally:os.close(parentfd)
with os.fdopen(fd,'rb') as f:
if not stat.S_ISREG(os.fstat(f.fileno()).st_mode):fail('invalid')
h=hashlib.sha256();size=0
while True:
block=f.read(65536)
if not block:break
h.update(block);size+=len(block)
print(json.dumps({'sha256':h.hexdigest(),'size':size}))
elif act=='read':
parentfd,name=parent(path)
try:fd=os.open(name,os.O_RDONLY|os.O_NOFOLLOW,dir_fd=parentfd)
finally:os.close(parentfd)
with os.fdopen(fd,'rb') as f:
st=os.fstat(f.fileno())
if not stat.S_ISREG(st.st_mode) or st.st_size>p.get('maxBytes',16*1024*1024):fail('invalid')
data=f.read(p.get('maxBytes',16*1024*1024)+1)
if len(data)>p.get('maxBytes',16*1024*1024):fail('invalid')
print(json.dumps({'base64':base64.b64encode(data).decode(),'sha256':digest(data)}))
elif act in ('write','remove','move'):
parentfd,name=parent(path,create=act=='write')
try:
old=None
try:fd=os.open(name,os.O_RDONLY|os.O_NOFOLLOW,dir_fd=parentfd)
except FileNotFoundError:fd=None
if fd is not None:
with os.fdopen(fd,'rb') as f:
if not stat.S_ISREG(os.fstat(f.fileno()).st_mode):fail('invalid')
h=hashlib.sha256()
while True:
block=f.read(65536)
if not block:break
h.update(block)
old=h.hexdigest()
if old!=p.get('expectedSha256'):fail('conflict')
if act=='write':
data=base64.b64decode(p['base64'],validate=True)
if len(data)>16*1024*1024:fail('invalid')
import secrets
temp='.paperclip-write-'+secrets.token_hex(16)
fd=os.open(temp,os.O_WRONLY|os.O_CREAT|os.O_EXCL|os.O_NOFOLLOW,0o600,dir_fd=parentfd)
try:
with os.fdopen(fd,'wb') as f:f.write(data);f.flush();os.fsync(f.fileno())
os.replace(temp,name,src_dir_fd=parentfd,dst_dir_fd=parentfd)
finally:
try:os.unlink(temp,dir_fd=parentfd)
except FileNotFoundError:pass
print(json.dumps({'sha256':digest(data)}))
elif act=='remove':
if old is None:fail('not_found')
os.unlink(name,dir_fd=parentfd);print('{}')
else:
if old is None:fail('not_found')
targetfd,target=parent(p['to'],create=True)
try:
try:os.link(name,target,src_dir_fd=parentfd,dst_dir_fd=targetfd,follow_symlinks=False)
except FileExistsError:fail('conflict')
os.unlink(name,dir_fd=parentfd)
finally:os.close(targetfd)
print(json.dumps({'sha256':old}))
finally:os.close(parentfd)
else:fail('invalid')
except FileNotFoundError:fail('not_found')
except OSError:fail('invalid')
finally:os.close(lock);os.close(rootfd)
`;