@reason-machines/cli-linux-arm64-musl 0.2.277 → 0.2.279
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/bin/ara +1 -1
- package/bin/vm-hands/connect-cli-m9tjjyvr.js +2 -0
- package/bin/vm-hands/custom-endpoint-device-92914wyp.js +2 -0
- package/bin/vm-hands/desktop-account-vsrtgkk9.js +2 -0
- package/bin/vm-hands/desktop-wallpaper-0pjhgyfk.jpg +0 -0
- package/bin/vm-hands/device-workspace-zk5xwd10.js +2 -0
- package/bin/vm-hands/dock-browser-afp005mt.png +0 -0
- package/bin/vm-hands/dock-files-rywdm96b.png +0 -0
- package/bin/vm-hands/dock-terminal-s41cj1jp.png +0 -0
- package/bin/vm-hands/hands-worker-source-da93x0qx.js +2 -0
- package/bin/vm-hands/index-w9nq2f0p.js +12818 -0
- package/bin/vm-hands/local-automation-v1egrx6g.js +2 -0
- package/bin/vm-hands/local-capabilities-jpyj1dh1.js +2 -0
- package/bin/vm-hands/local-desktop-users-3j79jbyp.js +2 -0
- package/bin/vm-hands/local-display-mac-j1v3mcgx.js +2 -0
- package/bin/vm-hands/local-display-session-hmqaj491.js +2 -0
- package/bin/vm-hands/network-contract-41qanjhp.js +2 -0
- package/bin/vm-hands/package-rx9y4bwe.js +2 -0
- package/bin/vm-hands/permissions-wbdkvw1s.js +2 -0
- package/bin/vm-hands/reason-hands-protocol-51gs8j7v.js +2 -0
- package/bin/vm-hands/status-pxc0vwbt.js +96 -0
- package/bin/vm-hands/version-n43gjzwa.js +2 -0
- package/bin/vm-hands/vm-hands-entry-lite-01dd716a.js +3 -0
- package/bin/vm-hands/vm-hands-entry-lite-1hwdab4e.js +3 -0
- package/bin/vm-hands/vm-hands-entry-lite-22f3c914.js +43 -0
- package/bin/vm-hands/vm-hands-entry-lite-2s3w4m10.js +529 -0
- package/bin/vm-hands/vm-hands-entry-lite-65pbrs1x.js +3 -0
- package/bin/vm-hands/vm-hands-entry-lite-6tpswr2c.js +459 -0
- package/bin/vm-hands/vm-hands-entry-lite-7gh1rbn7.js +3 -0
- package/bin/vm-hands/vm-hands-entry-lite-84aq8d12.js +3 -0
- package/bin/vm-hands/vm-hands-entry-lite-9jfh56xt.js +343 -0
- package/bin/vm-hands/vm-hands-entry-lite-9pmag2qk.js +3 -0
- package/bin/vm-hands/vm-hands-entry-lite-anaydk9m.js +4 -0
- package/bin/vm-hands/vm-hands-entry-lite-bfwf82rw.js +5 -0
- package/bin/vm-hands/vm-hands-entry-lite-bwt3y6wz.js +3 -0
- package/bin/vm-hands/vm-hands-entry-lite-c2nrar90.js +4046 -0
- package/bin/vm-hands/vm-hands-entry-lite-cq7qt28m.js +179 -0
- package/bin/vm-hands/vm-hands-entry-lite-ffg7rf02.js +3 -0
- package/bin/vm-hands/vm-hands-entry-lite-fraghb8k.js +5 -0
- package/bin/vm-hands/vm-hands-entry-lite-g7faacwr.js +3 -0
- package/bin/vm-hands/vm-hands-entry-lite-gqncr2j0.js +42 -0
- package/bin/vm-hands/vm-hands-entry-lite-k4c504n7.js +3 -0
- package/bin/vm-hands/vm-hands-entry-lite-n07kxrd1.js +3 -0
- package/bin/vm-hands/vm-hands-entry-lite-ptmqg51y.js +4 -0
- package/bin/vm-hands/vm-hands-entry-lite-qdz95cmv.js +759 -0
- package/bin/vm-hands/vm-hands-entry-lite-qvrnx0vk.js +4 -0
- package/bin/vm-hands/vm-hands-entry-lite-s2xydt0n.js +112 -0
- package/bin/vm-hands/vm-hands-entry-lite-s5qn1k80.js +4 -0
- package/bin/vm-hands/vm-hands-entry-lite-sv6xwe0h.js +66 -0
- package/bin/vm-hands/vm-hands-entry-lite.js +2 -0
- package/bin/vm-hands/vm-hands-executor-xg99mezq.js +9 -0
- package/bin/vm-hands/vm-hands-op-runner-b0xkges8.js +2 -0
- package/bin/vm-hands/vm-hands-runtime-gnbcsqcy.js +4 -0
- package/bin/vm-hands/worker-sandbox-nnce49w0.js +2 -0
- package/package.json +1 -1
|
@@ -0,0 +1,4046 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import{Vb as i,Wb as l,Xb as g,Yb as y}from"./vm-hands-entry-lite-n07kxrd1.js";import{Af as I,Cf as O,ff as Q,gf as J,hf as z,if as Z,jf as K,kf as $,lf as k,nf as G,pf as X,qf as M,rf as Y,sf as H,tf as V,vf as S,wf as _,yf as P,zf as C}from"./vm-hands-entry-lite-ptmqg51y.js";import{lh as L,mh as W,nh as F,oh as j}from"./vm-hands-entry-lite-84aq8d12.js";var d=["cgroup_membership_read_failed","cgroup_v2_membership_absent","cgroup_scope_invalid","cgroup_root_unavailable","cgroup_directory_unavailable","cgroup_limit_read_failed","cgroup_events_read_failed","cgroup_current_read_failed","cgroup_stat_read_failed","boot_id_read_failed","boot_id_invalid","guest_meminfo_read_failed","guest_vmstat_read_failed","memory_counter_invalid","memory_field_missing","memory_capacity_invalid"];var a=String.raw`(function(fs, path) {
|
|
3
|
+
const fail = (message, reasonCode) => { throw Object.assign(Error(message), {reasonCode}); };
|
|
4
|
+
const read = (p, reasonCode, trim=true) => { try { const text=fs.readFileSync(p, "utf8"); return trim?text.trim():text; }
|
|
5
|
+
catch { fail("memory_read", reasonCode); } };
|
|
6
|
+
const checkPath = (p, reasonCode) => { let resolved; try { resolved=fs.realpathSync(p); }
|
|
7
|
+
catch { fail("memory_scope", reasonCode); }
|
|
8
|
+
if(resolved!==p) fail("memory_scope", "cgroup_scope_invalid"); };
|
|
9
|
+
const number = v => { if (!/^\d+$/.test(v)) fail("memory_count", "memory_counter_invalid"); const n=Number(v);
|
|
10
|
+
if (!Number.isSafeInteger(n)) fail("memory_count", "memory_counter_invalid"); return n; };
|
|
11
|
+
const field = (text, key) => { const row=text.split("\n").find(line=>line.split(/\s+/)[0]===key);
|
|
12
|
+
if (!row) fail("memory_field", "memory_field_missing"); return number(row.trim().split(/\s+/)[1]); };
|
|
13
|
+
const membership=read("/proc/self/cgroup", "cgroup_membership_read_failed", false);
|
|
14
|
+
const bareGuest=membership==="";
|
|
15
|
+
let p=bareGuest?null:/^0::([^\n]+)\n?$/.exec(membership)?.[1];
|
|
16
|
+
if (!bareGuest && !p) fail("memory_scope", "cgroup_v2_membership_absent");
|
|
17
|
+
if (p && (!/^\/(?:[A-Za-z0-9_.:@-]+\/?)*$/.test(p) || p.split("/").includes(".."))) fail("memory_scope", "cgroup_scope_invalid");
|
|
18
|
+
if (bareGuest) {
|
|
19
|
+
// Empty membership alone is not proof of a whole VM. Require a complete,
|
|
20
|
+
// bounded kernel mount table with no cgroup hierarchy before using /proc.
|
|
21
|
+
// Reuse the qualified diagnostic enum so rolling clients keep parsing it.
|
|
22
|
+
const rawMounts=read("/proc/self/mountinfo", "cgroup_v2_membership_absent", false);
|
|
23
|
+
const refuse=()=>fail("memory_scope", "cgroup_v2_membership_absent");
|
|
24
|
+
if(!rawMounts || rawMounts.length>262144 || Buffer.byteLength(rawMounts,"utf8")>262144) refuse();
|
|
25
|
+
// A kernel table may end in one LF; never erase malformed whitespace rows.
|
|
26
|
+
const mounts=rawMounts.endsWith("\n")?rawMounts.slice(0,-1):rawMounts;
|
|
27
|
+
const rows=mounts.split("\n"); if(rows.length>4096) refuse();
|
|
28
|
+
const mountIds=new Set();
|
|
29
|
+
const id=v=>/^\d+$/.test(v)&&Number.isSafeInteger(Number(v))&&Number(v)>0;
|
|
30
|
+
const device=v=>/^\d+:\d+$/.test(v)&&v.split(":").every(n=>Number.isSafeInteger(Number(n)));
|
|
31
|
+
for(const row of rows) {
|
|
32
|
+
if(row.length>8192 || Buffer.byteLength(row,"utf8")>8192) refuse();
|
|
33
|
+
const fields=row.split(" "), separator=fields.indexOf("-");
|
|
34
|
+
if(fields.some(v=>!v||/\s/.test(v)) || separator<6 || fields.lastIndexOf("-")!==separator || fields.length!==separator+4
|
|
35
|
+
|| !id(fields[0]) || !id(fields[1]) || !device(fields[2])
|
|
36
|
+
|| !fields[3].startsWith("/") || !fields[4].startsWith("/")
|
|
37
|
+
|| fields[5].split(",").some(v=>!v) || fields[separator+3].split(",").some(v=>!v)
|
|
38
|
+
|| !/^[A-Za-z0-9_.+-]+$/.test(fields[separator+1])
|
|
39
|
+
|| fields.slice(6,separator).some(v=>! /^[a-z][a-z0-9_]*(?::\d+)?$/.test(v))) refuse();
|
|
40
|
+
if(mountIds.has(Number(fields[0]))) refuse(); mountIds.add(Number(fields[0]));
|
|
41
|
+
if(fields[separator+1]==="cgroup" || fields[separator+1]==="cgroup2") refuse();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const root="/sys/fs/cgroup"; let selected=null;
|
|
45
|
+
const readCgroup = (p, reasonCode) => { checkPath(p, reasonCode); return read(p, reasonCode); };
|
|
46
|
+
if (!bareGuest) {
|
|
47
|
+
checkPath(root, "cgroup_root_unavailable");
|
|
48
|
+
for (let depth=0; depth<128; depth++) {
|
|
49
|
+
const dir=root+(p==="/"?"":p);
|
|
50
|
+
checkPath(dir, "cgroup_directory_unavailable");
|
|
51
|
+
if (fs.existsSync(dir+"/memory.events") && fs.existsSync(dir+"/memory.max")) {
|
|
52
|
+
const limit=readCgroup(dir+"/memory.max", "cgroup_limit_read_failed"); if(limit!=="max") selected={dir,limit:number(limit)};
|
|
53
|
+
}
|
|
54
|
+
if(p==="/") break;
|
|
55
|
+
p=path.posix.dirname(p); if(depth===127) fail("memory_scope", "cgroup_scope_invalid");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const bootId=read("/proc/sys/kernel/random/boot_id", "boot_id_read_failed");
|
|
59
|
+
if(!UUID_RE.test(bootId)) fail("memory_boot", "boot_id_invalid");
|
|
60
|
+
if(selected) return {bootId,oomKills:field(readCgroup(selected.dir+"/memory.events", "cgroup_events_read_failed"),"oom_kill"),
|
|
61
|
+
currentBytes:number(readCgroup(selected.dir+"/memory.current", "cgroup_current_read_failed")),limitBytes:selected.limit,
|
|
62
|
+
inactiveFileBytes:field(readCgroup(selected.dir+"/memory.stat", "cgroup_stat_read_failed"),"inactive_file"),capacitySource:"cgroup",memoryScope:selected.dir};
|
|
63
|
+
const info=read("/proc/meminfo", "guest_meminfo_read_failed"); const limitBytes=field(info,"MemTotal:")*1024,available=field(info,"MemAvailable:")*1024;
|
|
64
|
+
if(!Number.isSafeInteger(limitBytes)||limitBytes<=0||!Number.isSafeInteger(available)||available>limitBytes) fail("memory_capacity", "memory_capacity_invalid");
|
|
65
|
+
return {bootId,oomKills:field(read("/proc/vmstat", "guest_vmstat_read_failed"),"oom_kill"),currentBytes:limitBytes-available,
|
|
66
|
+
limitBytes,inactiveFileBytes:0,capacitySource:"guest",memoryScope:"/proc"};
|
|
67
|
+
})`;var r=(t)=>t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&").replace(/[a-z]/gi,(e)=>`[${e.toLowerCase()}${e.toUpperCase()}]`),U=[...i.map((t)=>`/(${t.map(r).join("/")})(/|$)`),...l.map((t)=>`/(${t.map(r).join("/")})(/|$)`),...[...g].map((t)=>`/(${r(t)})$`),`/${r(".env.")}[^/]*$`,...y.map((t)=>`/[^/]*${r(t)}$`)],n=`function localGithubSandboxProfile(root, temporary) {
|
|
68
|
+
return ['(version 1)','(deny default)','(import "system.sb")','(allow process-exec process-fork)',
|
|
69
|
+
'(allow file-read* (subpath "/bin") (subpath "/sbin") (subpath "/usr/bin") (subpath "/usr/sbin") (subpath "/usr/libexec") (subpath "/usr/share") (subpath "/private/var/select") (literal "/private/var/db/xcode_select_link"))',
|
|
70
|
+
'(allow file-read-metadata (path-ancestors "/opt/homebrew/bin") (path-ancestors "/usr/local/bin") (path-ancestors "/Library/Developer/CommandLineTools"))',
|
|
71
|
+
'(allow file-read* (subpath "/opt/homebrew/Cellar") (subpath "/opt/homebrew/opt") (subpath "/opt/homebrew/bin") (subpath "/opt/homebrew/lib") (subpath "/opt/homebrew/share") (subpath "/opt/homebrew/include") (subpath "/usr/local/Cellar") (subpath "/usr/local/opt") (subpath "/usr/local/bin") (subpath "/usr/local/lib") (subpath "/usr/local/share") (subpath "/usr/local/include") (subpath "/Library/Developer/CommandLineTools"))',
|
|
72
|
+
'(allow file-read* file-write* (subpath '+JSON.stringify(root)+') (subpath '+JSON.stringify(temporary)+'))',
|
|
73
|
+
'(allow file-read-metadata (path-ancestors '+JSON.stringify(root)+') (path-ancestors '+JSON.stringify(temporary)+'))',
|
|
74
|
+
'(deny file-read* file-write* (subpath "/opt/homebrew/etc") (subpath "/usr/local/etc") (literal "/private/etc/master.passwd"))',
|
|
75
|
+
'(deny file-write* (subpath "/opt/homebrew") (subpath "/usr/local") (subpath "/Library/Developer") (subpath "/cores"))',
|
|
76
|
+
...${JSON.stringify(U)}.map(pattern => '(deny file-read* file-write* (regex #"'+pattern+'"))'),
|
|
77
|
+
'(deny network*)','(deny mach-lookup)','(deny process-info*)','(allow process-info* (target same-sandbox))','(allow process-info-listpids)','(allow file-read-metadata (subpath "/dev"))','(deny signal)','(allow signal (target same-sandbox))'].join('\\n');
|
|
78
|
+
}`,h="/opt/homebrew/bin:/usr/local/bin:/Library/Developer/CommandLineTools/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin";function qt(t,e){return Function("return ("+n+")")()(t,e)+`
|
|
79
|
+
(allow network-bind network-inbound (local tcp "localhost:*"))`}var x=String.raw`import os,sys,json,stat,fcntl,time,ctypes
|
|
80
|
+
v=json.load(sys.stdin)
|
|
81
|
+
root=v['root']; paths=list(dict.fromkeys([v['source'],v['snapshot']]))
|
|
82
|
+
marker=('# reason-job-env-owner:'+v['runId']+'\n').encode()
|
|
83
|
+
fds=[];parents=[]
|
|
84
|
+
try:
|
|
85
|
+
if os.path.realpath(root)!=root:raise ValueError('root')
|
|
86
|
+
rootfd=os.open(root,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW)
|
|
87
|
+
try:
|
|
88
|
+
r=os.fstat(rootfd)
|
|
89
|
+
if not stat.S_ISDIR(r.st_mode):raise ValueError('root')
|
|
90
|
+
for p in paths:
|
|
91
|
+
parent=os.path.dirname(p)
|
|
92
|
+
if os.path.realpath(parent)!=parent:raise ValueError('parent')
|
|
93
|
+
pfd=os.open(parent,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW);parents.append((parent,pfd))
|
|
94
|
+
ps=os.fstat(pfd);pn=os.stat(parent,follow_symlinks=False)
|
|
95
|
+
if ps.st_uid!=os.geteuid() or stat.S_IMODE(ps.st_mode)&0o022 or (ps.st_dev,ps.st_ino)!=(pn.st_dev,pn.st_ino):raise ValueError('parent')
|
|
96
|
+
fd=os.open(os.path.basename(p),os.O_RDWR|os.O_NOFOLLOW|os.O_NONBLOCK,dir_fd=pfd);fds.append((p,fd))
|
|
97
|
+
s=os.fstat(fd)
|
|
98
|
+
if not stat.S_ISREG(s.st_mode) or s.st_nlink!=1 or s.st_uid!=os.geteuid() or stat.S_IMODE(s.st_mode)!=0o600:raise ValueError('file')
|
|
99
|
+
end=time.monotonic()+2
|
|
100
|
+
while True:
|
|
101
|
+
try:fcntl.flock(fd,fcntl.LOCK_EX|fcntl.LOCK_NB);break
|
|
102
|
+
except BlockingIOError:
|
|
103
|
+
if time.monotonic()>=end:raise ValueError('lock')
|
|
104
|
+
time.sleep(.02)
|
|
105
|
+
n=os.stat(p,follow_symlinks=False)
|
|
106
|
+
if (s.st_dev,s.st_ino)!=(n.st_dev,n.st_ino) or os.pread(fd,len(marker),0)!=marker:raise ValueError('owner')
|
|
107
|
+
libc=ctypes.CDLL(None,use_errno=True)
|
|
108
|
+
if libc.syncfs(rootfd)!=0:raise OSError(ctypes.get_errno(),'sync')
|
|
109
|
+
for parent,pfd in parents:
|
|
110
|
+
ps=os.fstat(pfd);pn=os.stat(parent,follow_symlinks=False)
|
|
111
|
+
if (ps.st_dev,ps.st_ino)!=(pn.st_dev,pn.st_ino):raise ValueError('parent_changed')
|
|
112
|
+
for p,fd in fds:
|
|
113
|
+
s=os.fstat(fd);n=os.stat(p,follow_symlinks=False)
|
|
114
|
+
if (s.st_dev,s.st_ino)!=(n.st_dev,n.st_ino):raise ValueError('changed')
|
|
115
|
+
os.ftruncate(fd,0);os.fsync(fd)
|
|
116
|
+
if libc.syncfs(rootfd)!=0:raise OSError(ctypes.get_errno(),'sync')
|
|
117
|
+
finally:os.close(rootfd)
|
|
118
|
+
finally:
|
|
119
|
+
for _,fd in fds:os.close(fd)
|
|
120
|
+
for _,pfd in parents:os.close(pfd)
|
|
121
|
+
`;var s=String.raw`function workspaceTarget(requested) {
|
|
122
|
+
if (typeof requested !== "string" || requested.length < 1 || requested.includes("\0")) {
|
|
123
|
+
throw new Error("workspace_file_path_invalid");
|
|
124
|
+
}
|
|
125
|
+
const root = fs.realpathSync(config.repoPath);
|
|
126
|
+
const target = path.isAbsolute(requested) ? path.resolve(requested) : path.resolve(root, requested);
|
|
127
|
+
const relative = path.relative(root, target);
|
|
128
|
+
if (relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) {
|
|
129
|
+
throw new Error("workspace_file_path_invalid");
|
|
130
|
+
}
|
|
131
|
+
let cursor = root;
|
|
132
|
+
for (const part of relative.split(path.sep).filter(Boolean)) {
|
|
133
|
+
cursor = path.join(cursor, part);
|
|
134
|
+
if (!fs.existsSync(cursor)) continue;
|
|
135
|
+
if (fs.lstatSync(cursor).isSymbolicLink()) throw new Error("workspace_file_symlink_rejected");
|
|
136
|
+
}
|
|
137
|
+
return { root, target, relative: relative.split(path.sep).join("/") || "." };
|
|
138
|
+
}
|
|
139
|
+
function atomicWrite(target, content) {
|
|
140
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
141
|
+
const temporary = target + ".ara-" + crypto.randomUUID() + ".tmp";
|
|
142
|
+
try {
|
|
143
|
+
const mode = fs.existsSync(target) ? fs.statSync(target).mode & 0o777 : 0o644;
|
|
144
|
+
fs.writeFileSync(temporary, content, { encoding: "utf8", mode });
|
|
145
|
+
fs.renameSync(temporary, target);
|
|
146
|
+
} finally {
|
|
147
|
+
try { fs.unlinkSync(temporary); } catch (error) { void error; }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const fileMutationQueues = new Map();
|
|
151
|
+
function withFileMutationQueue(target, operation) {
|
|
152
|
+
const previous = fileMutationQueues.get(target) || Promise.resolve();
|
|
153
|
+
const queued = previous.catch(() => {}).then(operation);
|
|
154
|
+
fileMutationQueues.set(target, queued);
|
|
155
|
+
return queued.finally(() => {
|
|
156
|
+
if (fileMutationQueues.get(target) === queued) fileMutationQueues.delete(target);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
function startsWith(bytes, signature) {
|
|
160
|
+
return bytes.length >= signature.length
|
|
161
|
+
&& signature.every((byte, index) => bytes[index] === byte);
|
|
162
|
+
}
|
|
163
|
+
function startsWithAscii(bytes, offset, value) {
|
|
164
|
+
if (bytes.length < offset + value.length) return false;
|
|
165
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
166
|
+
if (bytes[offset + index] !== value.charCodeAt(index)) return false;
|
|
167
|
+
}
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
function imageMimeType(bytes) {
|
|
171
|
+
if (startsWith(bytes, [0xff, 0xd8, 0xff]) && bytes[3] !== 0xf7) return "image/jpeg";
|
|
172
|
+
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png";
|
|
173
|
+
if (startsWithAscii(bytes, 0, "GIF")) return "image/gif";
|
|
174
|
+
if (startsWithAscii(bytes, 0, "RIFF") && startsWithAscii(bytes, 8, "WEBP")) return "image/webp";
|
|
175
|
+
if (startsWithAscii(bytes, 0, "BM")) return "image/bmp";
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
function linesForCounting(content) {
|
|
179
|
+
if (content.length === 0) return [];
|
|
180
|
+
const lines = content.split("\n");
|
|
181
|
+
if (content.endsWith("\n")) lines.pop();
|
|
182
|
+
return lines;
|
|
183
|
+
}
|
|
184
|
+
function truncateReadHead(content) {
|
|
185
|
+
const maxLines = 2000;
|
|
186
|
+
const maxBytes = 50 * 1024;
|
|
187
|
+
const lines = linesForCounting(content);
|
|
188
|
+
const totalBytes = Buffer.byteLength(content, "utf8");
|
|
189
|
+
if (lines.length <= maxLines && totalBytes <= maxBytes) {
|
|
190
|
+
return {
|
|
191
|
+
content,
|
|
192
|
+
truncated: false,
|
|
193
|
+
truncatedBy: null,
|
|
194
|
+
outputLines: lines.length,
|
|
195
|
+
firstLineExceedsLimit: false,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const firstLineBytes = Buffer.byteLength(lines[0] || "", "utf8");
|
|
199
|
+
if (firstLineBytes > maxBytes) {
|
|
200
|
+
return {
|
|
201
|
+
content: "",
|
|
202
|
+
truncated: true,
|
|
203
|
+
truncatedBy: "bytes",
|
|
204
|
+
outputLines: 0,
|
|
205
|
+
firstLineExceedsLimit: true,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
const output = [];
|
|
209
|
+
let outputBytes = 0;
|
|
210
|
+
let truncatedBy = "lines";
|
|
211
|
+
for (let index = 0; index < lines.length && index < maxLines; index += 1) {
|
|
212
|
+
const line = lines[index];
|
|
213
|
+
const lineBytes = Buffer.byteLength(line, "utf8") + (index > 0 ? 1 : 0);
|
|
214
|
+
if (outputBytes + lineBytes > maxBytes) {
|
|
215
|
+
truncatedBy = "bytes";
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
output.push(line);
|
|
219
|
+
outputBytes += lineBytes;
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
content: output.join("\n"),
|
|
223
|
+
truncated: true,
|
|
224
|
+
truncatedBy,
|
|
225
|
+
outputLines: output.length,
|
|
226
|
+
firstLineExceedsLimit: false,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function fileOperation(request) {
|
|
230
|
+
const promise = Promise.resolve().then(async () => {
|
|
231
|
+
let resolved;
|
|
232
|
+
try {
|
|
233
|
+
resolved = workspaceTarget(request.input.path);
|
|
234
|
+
} catch (error) {
|
|
235
|
+
return failure(request.id, 1, "command_failed", "", error instanceof Error ? error.message : "workspace_file_failed");
|
|
236
|
+
}
|
|
237
|
+
const execute = () => {
|
|
238
|
+
try {
|
|
239
|
+
if (request.tool === "read") {
|
|
240
|
+
if (!fs.existsSync(resolved.target) || !fs.statSync(resolved.target).isFile()) throw new Error("workspace_file_not_found");
|
|
241
|
+
const bytes = fs.readFileSync(resolved.target);
|
|
242
|
+
const mimeType = imageMimeType(bytes.subarray(0, 4100));
|
|
243
|
+
if (mimeType) {
|
|
244
|
+
const output = JSON.stringify({
|
|
245
|
+
type: "image",
|
|
246
|
+
path: resolved.relative,
|
|
247
|
+
mimeType,
|
|
248
|
+
data: bytes.toString("base64"),
|
|
249
|
+
});
|
|
250
|
+
if (output.length > MAX_OUTPUT_CHARS) throw new Error("workspace_image_too_large");
|
|
251
|
+
return success(request.id, output, "");
|
|
252
|
+
}
|
|
253
|
+
const original = bytes.toString("utf8");
|
|
254
|
+
const lines = original.split("\n");
|
|
255
|
+
const start = request.input.offset || 1;
|
|
256
|
+
const limit = request.input.limit === undefined
|
|
257
|
+
? lines.length
|
|
258
|
+
: request.input.limit;
|
|
259
|
+
const startIndex = start - 1;
|
|
260
|
+
if (startIndex >= lines.length) {
|
|
261
|
+
throw new Error("Offset " + start + " is beyond end of file (" + lines.length + " lines total)");
|
|
262
|
+
}
|
|
263
|
+
const selected = lines.slice(startIndex, startIndex + limit);
|
|
264
|
+
const selectedContent = selected.join("\n");
|
|
265
|
+
const truncated = truncateReadHead(selectedContent);
|
|
266
|
+
const hasMore = startIndex + selected.length < lines.length;
|
|
267
|
+
const end = truncated.truncated
|
|
268
|
+
? start + truncated.outputLines - 1
|
|
269
|
+
: start + selected.length - 1;
|
|
270
|
+
const output = JSON.stringify({
|
|
271
|
+
type: "text",
|
|
272
|
+
path: resolved.relative,
|
|
273
|
+
content: truncated.content,
|
|
274
|
+
startLine: start,
|
|
275
|
+
endLine: end,
|
|
276
|
+
totalLines: lines.length,
|
|
277
|
+
truncated: truncated.truncated || hasMore,
|
|
278
|
+
truncatedBy: truncated.truncatedBy,
|
|
279
|
+
firstLineExceedsLimit: truncated.firstLineExceedsLimit,
|
|
280
|
+
firstLineBytes: Buffer.byteLength(lines[startIndex] || "", "utf8"),
|
|
281
|
+
});
|
|
282
|
+
return success(request.id, output, "");
|
|
283
|
+
}
|
|
284
|
+
if (request.tool === "write") {
|
|
285
|
+
atomicWrite(resolved.target, request.input.content);
|
|
286
|
+
return success(request.id, JSON.stringify({
|
|
287
|
+
type: "write",
|
|
288
|
+
path: resolved.relative,
|
|
289
|
+
bytes: Buffer.byteLength(request.input.content, "utf8"),
|
|
290
|
+
}), "");
|
|
291
|
+
}
|
|
292
|
+
const original = fs.readFileSync(resolved.target, "utf8");
|
|
293
|
+
const matches = request.input.edits.map((edit) => {
|
|
294
|
+
const first = original.indexOf(edit.oldText);
|
|
295
|
+
if (first < 0) throw new Error("workspace_file_old_text_not_found");
|
|
296
|
+
if (original.indexOf(edit.oldText, first + edit.oldText.length) >= 0) throw new Error("workspace_file_old_text_not_unique");
|
|
297
|
+
return { ...edit, start: first, end: first + edit.oldText.length };
|
|
298
|
+
}).sort((left, right) => left.start - right.start);
|
|
299
|
+
for (let index = 1; index < matches.length; index += 1) {
|
|
300
|
+
if (matches[index].start < matches[index - 1].end) throw new Error("workspace_file_edits_overlap");
|
|
301
|
+
}
|
|
302
|
+
let updated = original;
|
|
303
|
+
for (const match of [...matches].reverse()) {
|
|
304
|
+
updated = updated.slice(0, match.start) + match.newText + updated.slice(match.end);
|
|
305
|
+
}
|
|
306
|
+
atomicWrite(resolved.target, updated);
|
|
307
|
+
return success(request.id, JSON.stringify({
|
|
308
|
+
type: "edit",
|
|
309
|
+
path: resolved.relative,
|
|
310
|
+
replacements: matches.length,
|
|
311
|
+
beforeSha256: crypto.createHash("sha256").update(original).digest("hex"),
|
|
312
|
+
afterSha256: crypto.createHash("sha256").update(updated).digest("hex"),
|
|
313
|
+
}), "");
|
|
314
|
+
} catch (error) {
|
|
315
|
+
return failure(request.id, 1, "command_failed", "", error instanceof Error ? error.message : "workspace_file_failed");
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
return request.tool === "read"
|
|
319
|
+
? execute()
|
|
320
|
+
: await withFileMutationQueue(resolved.target, execute);
|
|
321
|
+
});
|
|
322
|
+
return { promise, cancel() {} };
|
|
323
|
+
}
|
|
324
|
+
`;var p="a3e39eb55160acfc116fa9871ccc594c68be7a642d2331b24baa707c8f601ec0";import{createHash as ot}from"crypto";var Ft=Object.freeze({"api.ara.so":"api.reasonmachines.com","api.reasonmachines.ai":"api.reasonmachines.com","api-staging.ara.so":"staging-api.reasonmachines.com","staging-api.reasonmachines.ai":"staging-api.reasonmachines.com"});var w=Object.freeze({trailingSlashPattern:/\/+$/.source,keyPrefix:"http.",keySuffix:"/.extraheader",valuePrefix:"Authorization: Bearer "});var q=Object.freeze({tokenEnv:"ARA_GIT_BROKER_TOKEN",reasonTokenEnv:"REASON_GIT_BROKER_TOKEN",urlEnv:"ARA_GIT_BROKER_URL",keyPrefix:"credential.",keySuffix:"/.helper",value:'!f(){ printf "username=%s\\npassword=%s\\n" "ara-run" "${REASON_GIT_BROKER_TOKEN:-$ARA_GIT_BROKER_TOKEN}"; };f'});var o=/^[A-Za-z_][A-Za-z0-9_]*$/;var N=new Set(["ARA_INFERENCE_TOKEN","AI_GATEWAY_API_KEY","CLOUD_AGENT_AI_GATEWAY_API_KEY","INFERENCE_V2_GATEWAY_API_KEY","VERCEL_AI_GATEWAY_API_KEY","INFERENCE_V2_CONCENTRATE_API_KEY","INFERENCE_V2_OPENAI_API_KEY","INFERENCE_V2_ANTHROPIC_API_KEY","INFERENCE_V2_FINGERPRINT_SECRET","OPENAI_API_KEY","OPENROUTER_API_KEY","ANTHROPIC_API_KEY","CODEX_ACCESS_TOKEN","GITHUB_COPILOT_API_KEY","AZURE_OPENAI_API_KEY","AZURE_OPENAI_CLIENT_SECRET"]),R=new Set(["ARA_OPERATOR_KEY","BL_API_KEY","BLAXEL_API_KEY","NSC_TOKEN","NSC_TOKEN_FILE","NAMESPACE_API_KEY","NAMESPACE_ACCESS_TOKEN","NAMESPACE_TOKEN","NAMESPACE_TOKEN_JSON","NAMESPACE_SANDBOX_TOKEN_JSON","ARA_LOCAL_QA_NAMESPACE_WORKLOAD_TOKEN_FILE","NAMESPACE_SANDBOX_USE_LOCAL_USER_TOKEN","COMPUTE_SERVICE_TOKEN"]),v=["INFISICAL_","NSC_"],E=new Set(["ARA_JOB_ENV","ARA_DELIVERY_BASE_SHA","WORK_ROOT","REPO_DIR","ARA_CONTROL_DIR","ARA_PRESERVE_WORKSPACE","ARA_PINNED_CHECKOUT_REUSE","FOLLOWUP_BRANCH","REPO_URL","TOKENLESS_URL","REPOSITORY_IDENTITY_URL","DEFAULT_BRANCH","BRANCH","BRANCH_GENERATED","BASE_REF","REPO_EMPTY","REPO_WRITABLE","ARA_DELIVERY_BASE_BRANCH","ARA_SERVER_GIT_PUBLICATION","ARA_EMPTY_REPOSITORY_COMMIT","REASON_MODEL","REASON_DELIVERY_BASE_BRANCH","REASON_DELIVERY_BASE_SHA","REASON_EMPTY_REPOSITORY_COMMIT","REASON_SCRATCH_SESSION_ID","REASON_SERVER_GIT_PUBLICATION","REASON_ALLOW_INSTALL_SCRIPTS","SCM_TOKEN","SCM_TOKEN_USER","REASON_OIDC_TOKEN_URL","REASON_OIDC_REQUEST_TOKEN","AWS_CONTAINER_CREDENTIALS_FULL_URI","AWS_CONTAINER_AUTHORIZATION_TOKEN","AWS_CONTAINER_CREDENTIALS_RELATIVE_URI","AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE","BASH_ENV","ENV","BASHOPTS","SHELLOPTS","PS4","IFS","CDPATH","GLOBIGNORE","PROMPT_COMMAND","PATH","HOME","TMPDIR","TMP","TEMP","XDG_CONFIG_HOME","GIT_DIR","GIT_WORK_TREE","GIT_INDEX_FILE","GIT_OBJECT_DIRECTORY","GIT_COMMON_DIR","GIT_NAMESPACE","GIT_CEILING_DIRECTORIES","GIT_DISCOVERY_ACROSS_FILESYSTEM","GIT_SHALLOW_FILE","GIT_ALLOW_PROTOCOL","GIT_PROTOCOL_FROM_USER","GIT_REPLACE_REF_BASE","NODE_OPTIONS","NODE_PATH","NODE_REPL_EXTERNAL_MODULES","CLASSPATH","PYTHONPATH","PYTHONSTARTUP","PYTHONHOME","RUBYOPT","RUBYLIB","PERL5LIB","PERL5OPT","JAVA_TOOL_OPTIONS","_JAVA_OPTIONS","JDK_JAVA_OPTIONS","GEM_PATH","GEM_HOME","GIT_SSH","GIT_SSH_COMMAND","GIT_EXEC_PATH","GIT_TEMPLATE_DIR","GIT_ALTERNATE_OBJECT_DIRECTORIES","GIT_PROXY_COMMAND","GIT_EXTERNAL_DIFF","GIT_PAGER","GIT_EDITOR","GIT_ASKPASS","SSH_ASKPASS","PAGER","EDITOR","TAR_OPTIONS","CURL_HOME","WGETRC","APT_CONFIG","PERLLIB","GIT_GRAFT_FILE","POSIXLY_CORRECT"]),tt=["GIT_TRACE","CONTEXT_REPO_","ARA_CHECKPOINT_BOOTSTRAP_","_","ARA_HANDS_","ARA_DEVICE_","ARA_GIT_BROKER_","REASON_HANDS_","REASON_DEVICE_","REASON_GIT_BROKER_","LD_","DYLD_","GIT_CONFIG","BASH_FUNC_"];var u="^(?!(?:"+[...N,...R,...E].join("|")+")$)(?!(?:"+[...v,...tt].join("|")+"))"+o.source.slice(1),zt=new RegExp(u);var et=["HOME","PWD","OLDPWD","TMPDIR","TMP","TEMP","SHELL","USER","LOGNAME","SCM_TOKEN","SCM_TOKEN_USER","GH_TOKEN","GITHUB_TOKEN","GH_ENTERPRISE_TOKEN","GITHUB_ENTERPRISE_TOKEN","GITLAB_TOKEN"],rt=["ARA_HANDS_","ARA_DEVICE_","ARA_GIT_BROKER_","REASON_HANDS_","REASON_DEVICE_","REASON_GIT_BROKER_","BUN_"],it="^(?!(?:"+et.join("|")+")$)(?!(?:"+rt.join("|")+"))"+u.slice(1),nt="ARA_DELIVERY_BASE_SHA|WORK_ROOT|FOLLOWUP_BRANCH|ARA_PRESERVE_WORKSPACE|ARA_PINNED_CHECKOUT_REUSE|CONTEXT_REPO_[A-Z0-9_]+|REPO_URL|TOKENLESS_URL|REPOSITORY_IDENTITY_URL|DEFAULT_BRANCH|BRANCH|BRANCH_GENERATED|BASE_REF|REPO_EMPTY|REPO_WRITABLE|ARA_DELIVERY_BASE_BRANCH|ARA_SERVER_GIT_PUBLICATION|ARA_EMPTY_REPOSITORY_COMMIT|REASON_MODEL|REASON_DELIVERY_BASE_BRANCH|REASON_DELIVERY_BASE_SHA|REASON_EMPTY_REPOSITORY_COMMIT|REASON_SCRATCH_SESSION_ID|REASON_SERVER_GIT_PUBLICATION|REASON_ALLOW_INSTALL_SCRIPTS",c="^(?:"+nt+"|"+it.slice(1,-1)+")$",st=new RegExp(c);function kt(t){return st.test(t)}var b=Object.freeze({tokenPath:"/identity/token",awsPath:"/identity/aws",tokenPattern:/^[A-Za-z0-9_.~-]+$/.source}),m=Object.freeze({variables:256,valueBytes:130816,snapshotBytes:1048576}),Gt=Object.freeze({valueChars:16384,snapshotBytes:65536});var B=["set -g history-limit 50000","set -g status off","set -g window-size smallest","set -gq fill-character ' '","set -g escape-time 10","set -g prefix None","set -g prefix2 None","set -g terminal-overrides ',*:smcup@:rmcup@'"];var ut=2,ct="ara-hands-worker.cjs",A=["/usr/local/go/bin","/opt/node/bin","/usr/local/bin","/usr/sbin","/usr/bin","/sbin","/bin","/usr/local/cargo/bin"],ft=A.join(":"),dt=["/opt/homebrew/bin",...A].join(":"),at='(platform, linuxPath, macosPath) => platform === "darwin" ? macosPath : linuxPath';function Nt(t,e,f){return t==="darwin"?f:e}var lt=String.raw`(function(anchor, pid, native) {
|
|
325
|
+
const token = native.identity(pid);
|
|
326
|
+
const session = native.session(pid);
|
|
327
|
+
if (!native.same(anchor)) throw new Error("pty_session_identity_lost");
|
|
328
|
+
if (session.id === -1 && session.missing) return { retry: true, token: null };
|
|
329
|
+
if (session.id !== anchor[5]) throw new Error("pty_member_session_changed");
|
|
330
|
+
if (!token) return { retry: true, token: null };
|
|
331
|
+
return { retry: false, token };
|
|
332
|
+
})`,gt=String.raw`(function(native) {
|
|
333
|
+
let probe;
|
|
334
|
+
try {
|
|
335
|
+
try {
|
|
336
|
+
probe = native.identity();
|
|
337
|
+
if (!probe) throw new Error("pty_pidfd_unavailable");
|
|
338
|
+
native.children();
|
|
339
|
+
native.signal(probe);
|
|
340
|
+
} finally { if (probe) native.release(probe); }
|
|
341
|
+
} catch (error) { native.close(); throw error; }
|
|
342
|
+
})`,yt=String.raw`(function(bootstrap) {
|
|
343
|
+
const { dlopen, ptr } = require("bun:ffi");
|
|
344
|
+
const api = dlopen("libc.so.6", {
|
|
345
|
+
prctl: { args: ["i32", "u64", "u64", "u64", "u64"], returns: "i32" },
|
|
346
|
+
execve: { args: ["ptr", "ptr", "ptr"], returns: "i32" },
|
|
347
|
+
}).symbols;
|
|
348
|
+
const bit = new Int32Array(1);
|
|
349
|
+
if (api.prctl(36, 1, 0, 0, 0) !== 0 || api.prctl(37, ptr(bit), 0, 0, 0) !== 0 || bit[0] !== 1) {
|
|
350
|
+
throw new Error("pty_subreaper_unavailable");
|
|
351
|
+
}
|
|
352
|
+
const args = ["/bin/bash", "--noprofile", "--norc", "-c", bootstrap].map(value => Buffer.from(value + "\0"));
|
|
353
|
+
// Keep the serialized program distinct from native runtime lookup bytes:
|
|
354
|
+
// Mac release hardening removes those native lookups before signing.
|
|
355
|
+
const runtimeModeKey = ["BUN", "BE", "BUN"].join("_");
|
|
356
|
+
const env = Object.entries(process.env).filter(([name]) => name !== runtimeModeKey)
|
|
357
|
+
.map(([name, value]) => Buffer.from(name + "=" + value + "\0"));
|
|
358
|
+
const argv = new BigUint64Array([...args.map(value => BigInt(ptr(value))), 0n]);
|
|
359
|
+
const envp = new BigUint64Array([...env.map(value => BigInt(ptr(value))), 0n]);
|
|
360
|
+
require("node:fs").writeSync(3, "subreaper\n");
|
|
361
|
+
api.execve(ptr(args[0]), ptr(argv), ptr(envp));
|
|
362
|
+
throw new Error("pty_exec_failed");
|
|
363
|
+
})`,ht=String.raw`
|
|
364
|
+
"use strict";
|
|
365
|
+
const crypto = require("node:crypto");
|
|
366
|
+
const fs = require("node:fs");
|
|
367
|
+
const net = require("node:net");
|
|
368
|
+
const path = require("node:path");
|
|
369
|
+
const { spawn, spawnSync } = require("node:child_process");
|
|
370
|
+
const { StringDecoder } = require("node:string_decoder");
|
|
371
|
+
const GIT_BROKER_HTTP_HEADER_POLICY = ${JSON.stringify(w)};
|
|
372
|
+
const GIT_BROKER_CREDENTIAL_HELPER_POLICY = ${JSON.stringify(q)};
|
|
373
|
+
const DEVICE_ENVIRONMENT_NAME_RE = new RegExp(${JSON.stringify(c)});
|
|
374
|
+
const DEVICE_CLOUD_ACCESS_POLICY = ${JSON.stringify(b)};
|
|
375
|
+
const DEVICE_ENVIRONMENT_LIMITS = ${JSON.stringify(m)};
|
|
376
|
+
|
|
377
|
+
const VERSION = ${z};
|
|
378
|
+
const WORKER_VERSION = ${ut};
|
|
379
|
+
const WORKER_ENTRYPOINT = ${JSON.stringify(ct)};
|
|
380
|
+
const GUEST_MEMORY_UNAVAILABLE_REASONS = ${JSON.stringify(d)};
|
|
381
|
+
const sampleGuestMemory = ${a};
|
|
382
|
+
const inspectMacPtyMember = ${lt};
|
|
383
|
+
const probeLinuxPtyLibrary = ${gt};
|
|
384
|
+
const LINUX_PTY_EXEC_SOURCE = ${JSON.stringify(yt)};
|
|
385
|
+
const CLOSE_CODES = ${JSON.stringify(O)};
|
|
386
|
+
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
387
|
+
const MAX_FRAME_BYTES = ${Z};
|
|
388
|
+
const MAX_INPUT_CHARS = ${K};
|
|
389
|
+
const MAX_STRUCTURED_INPUT_CHARS = ${$};
|
|
390
|
+
const MAX_OUTPUT_CHARS = ${k};
|
|
391
|
+
const MAX_ERROR_CHARS = ${G};
|
|
392
|
+
const MAX_LEDGER_IDS = 16384;
|
|
393
|
+
const MAX_CACHED_RESULTS = 256;
|
|
394
|
+
const MAX_JOB_INPUT_CHARS = ${X};
|
|
395
|
+
const MAX_JOB_OUTPUT_CHARS = ${M};
|
|
396
|
+
const MAX_JOB_OUTPUT_CHUNKS = ${Y};
|
|
397
|
+
const MAX_JOBS = ${H};
|
|
398
|
+
const MAX_JOB_RECORD_BYTES = 262144;
|
|
399
|
+
const MAX_JOB_JOURNAL_BYTES = MAX_JOBS * MAX_JOB_RECORD_BYTES;
|
|
400
|
+
const MAX_JOB_CONTROL_FRAME_BYTES = ${V};
|
|
401
|
+
const MAX_PTY_INPUT_BYTES = ${S};
|
|
402
|
+
const MAX_PTY_OUTPUT_BYTES = ${_};
|
|
403
|
+
const MAX_PTY_DIMENSION = ${P};
|
|
404
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/;
|
|
405
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
406
|
+
const FIXED_BASH = "/bin/bash";
|
|
407
|
+
/** A Reason cloud machine's memory ceiling, which guestd records on every
|
|
408
|
+
* start (/run/sc/memory-max-mib), or 0 elsewhere. Read per command: a resize
|
|
409
|
+
* changes it. */
|
|
410
|
+
function machineMemoryMaxMiB() {
|
|
411
|
+
try {
|
|
412
|
+
const file = validPath(config.machineMemoryMaxPath) ? config.machineMemoryMaxPath : "/run/sc/memory-max-mib";
|
|
413
|
+
const text = fs.readFileSync(file, "utf8").trim();
|
|
414
|
+
return /^[1-9][0-9]{2,6}$/.test(text) ? Number(text) : 0;
|
|
415
|
+
} catch {
|
|
416
|
+
return 0;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
/** The shell prelude that sizes the command for the memory the machine can
|
|
420
|
+
* grow to. Node takes a quarter of the memory it sees at start as its heap
|
|
421
|
+
* limit, and a cloud machine starts small and grows on demand, so without
|
|
422
|
+
* this a TypeScript build stops at a 512 MiB heap while gigabytes could be
|
|
423
|
+
* plugged in. NODE_OPTIONS is otherwise unset for every command; this sets
|
|
424
|
+
* only the heap size, a quarter of the ceiling as on a machine that size,
|
|
425
|
+
* and publishes the ceiling as REASON_MEMORY_MAX_MIB. A command's own
|
|
426
|
+
* NODE_OPTIONS still wins. Empty off a cloud machine. */
|
|
427
|
+
function machineMemoryPrelude() {
|
|
428
|
+
const max = machineMemoryMaxMiB();
|
|
429
|
+
return max ? "export NODE_OPTIONS=--max-old-space-size=" + Math.max(2048, Math.floor(max / 4)) + " REASON_MEMORY_MAX_MIB=" + max + "; " : "";
|
|
430
|
+
}
|
|
431
|
+
/** config.jobsUser: the argv prefix that runs a job as the payload account, never root.
|
|
432
|
+
* A script job (model: true) takes the egress guard's model-jobs group when there is one. */
|
|
433
|
+
function jobsUserPrefix(model) {
|
|
434
|
+
const user = config.jobsUser;
|
|
435
|
+
const gid = model && user && user.modelGid !== undefined ? user.modelGid : user && user.gid;
|
|
436
|
+
return user ? ["/usr/bin/setpriv", "--reuid=" + user.uid, "--regid=" + gid, "--clear-groups", "--no-new-privs", "--",
|
|
437
|
+
"/usr/bin/env", "HOME=" + user.home, "USER=" + user.username, "LOGNAME=" + user.username] : [];
|
|
438
|
+
}
|
|
439
|
+
const FIXED_CLOUD_CHILD_PATH = ${JSON.stringify(ft)};
|
|
440
|
+
const FIXED_MACOS_CLOUD_CHILD_PATH = ${JSON.stringify(dt)};
|
|
441
|
+
const selectCloudChildPath = ${at};
|
|
442
|
+
const config = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
|
443
|
+
const allowedTools = config.allowedTools === undefined
|
|
444
|
+
? ["bash", "repl", "read", "edit", "write"]
|
|
445
|
+
: config.allowedTools;
|
|
446
|
+
// Local GitHub authority confines a Session's Bash and file access under
|
|
447
|
+
// sandbox-exec on macOS. A personal Linux worker runs every Session's tools
|
|
448
|
+
// with the person's own permissions (host mode, which already reach their gh
|
|
449
|
+
// login and the network), so there it keeps the ordinary tool paths.
|
|
450
|
+
const localGithubSandbox = config.localGithubConfinement === true && process.platform === "darwin";
|
|
451
|
+
|
|
452
|
+
function validPath(value) {
|
|
453
|
+
return typeof value === "string" && path.isAbsolute(value) && !value.includes("\0");
|
|
454
|
+
}
|
|
455
|
+
function validTimeout(value) {
|
|
456
|
+
return Number.isSafeInteger(value) && value >= 50 && value <= 600000;
|
|
457
|
+
}
|
|
458
|
+
function validHostVariables(variables) {
|
|
459
|
+
if (!variables || typeof variables !== "object" || Array.isArray(variables)) return false;
|
|
460
|
+
const entries = Object.entries(variables);
|
|
461
|
+
return entries.length <= 512 && entries.every(([name, value]) => name.length <= 128 && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)
|
|
462
|
+
&& typeof value === "string" && value.length <= 32768 && !value.includes("\0"));
|
|
463
|
+
}
|
|
464
|
+
function validEnvironmentConfiguration() {
|
|
465
|
+
if (config.localGithubConfinement === true && config.environmentSnapshotPath === undefined) return false;
|
|
466
|
+
if (config.gitCredentialEnvironment !== undefined && (config.gitCredentialEnvironment !== true
|
|
467
|
+
|| config.environmentSnapshotPath === undefined || config.localGithubConfinement === true)) return false;
|
|
468
|
+
const baseline = config.executionEnvironment;
|
|
469
|
+
if (baseline !== undefined && (config.environmentSnapshotPath === undefined
|
|
470
|
+
|| !hasOnlyKeys(baseline, ["home", "path", "temporaryDirectory", "variables"])
|
|
471
|
+
|| (baseline.variables !== undefined && !validHostVariables(baseline.variables))
|
|
472
|
+
|| ![baseline.home, baseline.temporaryDirectory].every(value => validPath(value) && value.length <= 32768 && !/[\r\n]/.test(value))
|
|
473
|
+
|| typeof baseline.path !== "string" || baseline.path.length > 32768
|
|
474
|
+
|| !baseline.path.split(":").every(value => validPath(value) && !/[\r\n]/.test(value)))) return false;
|
|
475
|
+
if (config.environmentSnapshotPath === undefined) return config.environmentExpectedBinding === undefined;
|
|
476
|
+
const authority = config.environmentExpectedBinding;
|
|
477
|
+
if (!validPath(config.environmentSnapshotPath) || path.basename(config.environmentSnapshotPath) !== "environment.json"
|
|
478
|
+
|| config.jobEnvSourcePath !== undefined
|
|
479
|
+
|| !hasOnlyKeys(authority, ["runId", "deviceId", "rootId", "apiOrigin", "binding"])
|
|
480
|
+
|| typeof authority.runId !== "string" || authority.runId.length < 1 || authority.runId.length > 160
|
|
481
|
+
|| !UUID_RE.test(authority.deviceId) || !UUID_RE.test(authority.rootId)
|
|
482
|
+
|| !hasOnlyKeys(authority.binding, ["kind", "target_revision", "root_grant_revision"])
|
|
483
|
+
|| !["workspace_source", "mcp_control"].includes(authority.binding.kind)
|
|
484
|
+
|| !Number.isSafeInteger(authority.binding.target_revision) || authority.binding.target_revision < 0
|
|
485
|
+
|| !Number.isSafeInteger(authority.binding.root_grant_revision) || authority.binding.root_grant_revision < 1) return false;
|
|
486
|
+
try {
|
|
487
|
+
const origin = new URL(authority.apiOrigin);
|
|
488
|
+
return ["https:", "http:"].includes(origin.protocol) && !origin.username && !origin.password
|
|
489
|
+
&& origin.pathname === "/" && !origin.search && !origin.hash;
|
|
490
|
+
} catch { return false; }
|
|
491
|
+
}
|
|
492
|
+
let parsedUrl;
|
|
493
|
+
try { parsedUrl = new URL(config.url); } catch { parsedUrl = null; }
|
|
494
|
+
const validLoopbackRelay = config.transport === "sandbox_loopback"
|
|
495
|
+
&& parsedUrl && parsedUrl.protocol === "ws:"
|
|
496
|
+
&& parsedUrl.hostname === "127.0.0.1"
|
|
497
|
+
&& !parsedUrl.username && !parsedUrl.password && !parsedUrl.search && !parsedUrl.hash;
|
|
498
|
+
const validDeviceRelay = config.transport === "ara_device_wss"
|
|
499
|
+
&& parsedUrl
|
|
500
|
+
&& ((parsedUrl.protocol === "wss:"
|
|
501
|
+
&& parsedUrl.hostname !== "127.0.0.1" && parsedUrl.hostname !== "localhost")
|
|
502
|
+
|| (parsedUrl.protocol === "ws:"
|
|
503
|
+
&& (parsedUrl.hostname === "127.0.0.1" || parsedUrl.hostname === "localhost")))
|
|
504
|
+
&& !parsedUrl.username && !parsedUrl.password && !parsedUrl.hash;
|
|
505
|
+
const validPrivateJob = config.transport === "local_job_ipc" && config.url === ""
|
|
506
|
+
&& config.proxyUrl === undefined && config.managedJobs && config.jobControlSocketPath
|
|
507
|
+
&& config.allowedTools?.length === 1 && config.allowedTools[0] === "bash"
|
|
508
|
+
&& config.workRoot === config.workspaceAliasPath && config.environmentSnapshotPath === undefined
|
|
509
|
+
&& config.jobEnvSourcePath === undefined && config.spawnPolicy === undefined && config.managedPty === undefined
|
|
510
|
+
&& hasOnlyKeys(config.directShell, ["mode", "executable", "jobId"]) && UUID_RE.test(config.directShell.jobId)
|
|
511
|
+
&& validPath(config.directShell.executable) && ["bash", "zsh"].includes(path.basename(config.directShell.executable))
|
|
512
|
+
&& (config.directShell.mode === "host" ? config.bashCommandPrefix === undefined
|
|
513
|
+
: config.directShell.mode === "confined" && process.platform === "darwin"
|
|
514
|
+
&& config.bashCommandPrefix?.length === 3 && config.bashCommandPrefix[0] === "/usr/bin/sandbox-exec"
|
|
515
|
+
&& config.bashCommandPrefix[1] === "-p");
|
|
516
|
+
let validProxyUrl = config.proxyUrl === undefined;
|
|
517
|
+
if (!validProxyUrl && config.transport === "ara_device_wss" && typeof config.proxyUrl === "string") {
|
|
518
|
+
try {
|
|
519
|
+
const proxyParsed = new URL(config.proxyUrl);
|
|
520
|
+
validProxyUrl = (proxyParsed.protocol === "https:" || proxyParsed.protocol === "http:")
|
|
521
|
+
&& proxyParsed.hostname !== "";
|
|
522
|
+
} catch { validProxyUrl = false; }
|
|
523
|
+
}
|
|
524
|
+
function validJobEnvSourcePath() {
|
|
525
|
+
if (config.jobEnvSourcePath === undefined || config.jobEnvSourcePath === path.join(config.workRoot, "job.env")) return true;
|
|
526
|
+
// A cloud workspace's job.env is in its Session's root-only control
|
|
527
|
+
// directory (shared/device-workspace.ts), derived from the root alone.
|
|
528
|
+
const cloud = new RegExp(${JSON.stringify(j.source)}).exec(config.workRoot);
|
|
529
|
+
if (process.platform === "linux" && cloud) {
|
|
530
|
+
const workspaceId = cloud[3];
|
|
531
|
+
const owner = (new RegExp(${JSON.stringify(W.source)}).exec(workspaceId) || [])[1]
|
|
532
|
+
?? (new RegExp(${JSON.stringify(F.source)}).exec(workspaceId) || [])[1] ?? workspaceId;
|
|
533
|
+
return config.jobEnvSourcePath === ${JSON.stringify(L)} + "/" + owner + "/job.env";
|
|
534
|
+
}
|
|
535
|
+
// Personal Cloud controls are root-owned and outside the writable grant.
|
|
536
|
+
// Admit only the same canonical session, never a caller-selected private path.
|
|
537
|
+
const session = /^\/var\/lib\/reason\/sessions\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/.exec(config.workRoot);
|
|
538
|
+
return process.platform === "linux" && session !== null
|
|
539
|
+
&& config.jobEnvSourcePath === "/var/lib/reason/session-controls/" + session[1] + "/job.env";
|
|
540
|
+
}
|
|
541
|
+
if (
|
|
542
|
+
typeof WebSocket !== "function" ||
|
|
543
|
+
(!validLoopbackRelay && !validDeviceRelay && !validPrivateJob) || !validProxyUrl ||
|
|
544
|
+
(config.directShell !== undefined && !validPrivateJob) ||
|
|
545
|
+
(config.directTerminalReceiptPath !== undefined && (!validPrivateJob
|
|
546
|
+
|| config.directTerminalReceiptPath !== path.join(path.dirname(config.readyPath), "terminal.json"))) ||
|
|
547
|
+
(!validPrivateJob && !/^\/device\/[A-Za-z0-9-]+\/[A-Za-z0-9]+$/.test(parsedUrl.pathname)) ||
|
|
548
|
+
config.workerVersion !== WORKER_VERSION || config.protocolVersion !== VERSION ||
|
|
549
|
+
typeof config.workerArtifactSha256 !== "string" || !SHA256_RE.test(config.workerArtifactSha256) ||
|
|
550
|
+
config.workerEntrypoint !== WORKER_ENTRYPOINT ||
|
|
551
|
+
!Number.isSafeInteger(config.targetGeneration) || config.targetGeneration < 1 ||
|
|
552
|
+
typeof config.fenceId !== "string" || !UUID_RE.test(config.fenceId) ||
|
|
553
|
+
!validPath(config.workRoot) || !validPath(config.workspaceAliasPath) ||
|
|
554
|
+
(config.deviceRootPath !== undefined && !validPath(config.deviceRootPath)) ||
|
|
555
|
+
(config.workspacePoolPath !== undefined && !validPath(config.workspacePoolPath)) ||
|
|
556
|
+
!validPath(config.jobEnvPath) || !validPath(config.repoPath) ||
|
|
557
|
+
!validEnvironmentConfiguration() ||
|
|
558
|
+
!validJobEnvSourcePath() ||
|
|
559
|
+
!validPath(config.replSocketPath) || !validPath(config.readyPath) ||
|
|
560
|
+
(config.terminalCleanupRunId !== undefined && (!UUID_RE.test(config.terminalCleanupRunId) || config.terminalCleanupRunId !== config.browserRunId)) ||
|
|
561
|
+
(config.terminalDaemonNonce !== undefined && (!UUID_RE.test(config.terminalDaemonNonce) || !config.terminalCleanupRunId)) ||
|
|
562
|
+
(config.browserRunId !== undefined && (typeof config.browserRunId !== "string" || !UUID_RE.test(config.browserRunId))) ||
|
|
563
|
+
config.workRoot === "/" || config.workspaceAliasPath === "/" ||
|
|
564
|
+
(config.workRoot !== config.workspaceAliasPath && (
|
|
565
|
+
config.workRoot.startsWith(config.workspaceAliasPath + "/") ||
|
|
566
|
+
config.workspaceAliasPath.startsWith(config.workRoot + "/")
|
|
567
|
+
)) ||
|
|
568
|
+
typeof config.launchId !== "string" || !/^[a-f0-9-]{36}$/.test(config.launchId) ||
|
|
569
|
+
!Number.isSafeInteger(config.expiresAtMs) || config.expiresAtMs <= Date.now() ||
|
|
570
|
+
!validTimeout(config.bashTimeoutMs) || !validTimeout(config.replTimeoutMs) ||
|
|
571
|
+
// An argument may be a whole sandbox-exec profile: a local GitHub Session's
|
|
572
|
+
// dev server's (shared/local-gh-confinement.ts) is about 7.5 KB.
|
|
573
|
+
(config.bashCommandPrefix !== undefined && (
|
|
574
|
+
!Array.isArray(config.bashCommandPrefix) || config.bashCommandPrefix.length < 1 ||
|
|
575
|
+
config.bashCommandPrefix.length > 32 || !validPath(config.bashCommandPrefix[0]) ||
|
|
576
|
+
config.bashCommandPrefix.some((arg) => typeof arg !== "string" || arg.length > 32768 || arg.includes("\0"))
|
|
577
|
+
)) ||
|
|
578
|
+
(config.managedJobs !== undefined && (
|
|
579
|
+
!config.managedJobs || !validPath(config.managedJobs.journalDirectory) ||
|
|
580
|
+
typeof config.managedJobs.scopeId !== "string" || !ID_RE.test(config.managedJobs.scopeId)
|
|
581
|
+
)) ||
|
|
582
|
+
(config.jobsUser !== undefined && (!hasOnlyKeys(config.jobsUser, ["uid", "gid", "home", "username", "modelGid"])
|
|
583
|
+
|| (config.jobsUser.modelGid !== undefined && (!Number.isSafeInteger(config.jobsUser.modelGid) || config.jobsUser.modelGid < 1))
|
|
584
|
+
|| process.platform !== "linux" || process.getuid() !== 0
|
|
585
|
+
|| !Number.isSafeInteger(config.jobsUser.uid) || config.jobsUser.uid < 1
|
|
586
|
+
|| !Number.isSafeInteger(config.jobsUser.gid) || config.jobsUser.gid < 1
|
|
587
|
+
|| typeof config.jobsUser.home !== "string" || !/^\/[A-Za-z0-9._\/-]+$/.test(config.jobsUser.home) || config.jobsUser.home.includes("..")
|
|
588
|
+
|| typeof config.jobsUser.username !== "string" || !/^[a-z_][a-z0-9_-]{0,31}$/i.test(config.jobsUser.username))) ||
|
|
589
|
+
(config.managedPty !== undefined && (!hasOnlyKeys(config.managedPty, ["version", "persistent", "shell"])
|
|
590
|
+
|| config.managedPty.version !== 1 || ![undefined, true].includes(config.managedPty.persistent) || !config.managedJobs || config.environmentSnapshotPath === undefined
|
|
591
|
+
|| (config.managedPty.shell !== undefined && (process.platform !== "darwin" || typeof config.managedPty.shell !== "string"
|
|
592
|
+
|| !/^\/[A-Za-z0-9._\/-]*\/(?:bash|zsh)$/.test(config.managedPty.shell))))) ||
|
|
593
|
+
(config.spawnPolicy !== undefined && (
|
|
594
|
+
!config.spawnPolicy || !validPath(config.spawnPolicy.socketPath) ||
|
|
595
|
+
path.basename(config.spawnPolicy.socketPath) !== "spawn.sock" || Buffer.byteLength(config.spawnPolicy.socketPath) > 100 ||
|
|
596
|
+
!ID_RE.test(config.spawnPolicy.scopeId) || !ID_RE.test(config.spawnPolicy.targetRevision) ||
|
|
597
|
+
config.bashCommandPrefix !== undefined || config.jobEnvSourcePath !== undefined
|
|
598
|
+
)) ||
|
|
599
|
+
(config.jobControlSocketPath !== undefined && (
|
|
600
|
+
!config.managedJobs || !validPath(config.jobControlSocketPath) ||
|
|
601
|
+
path.basename(config.jobControlSocketPath) !== "control.sock" || Buffer.byteLength(config.jobControlSocketPath) > 100
|
|
602
|
+
)) ||
|
|
603
|
+
!Array.isArray(allowedTools) || allowedTools.length < 1 || allowedTools.length > 5 ||
|
|
604
|
+
new Set(allowedTools).size !== allowedTools.length ||
|
|
605
|
+
allowedTools.some((tool) => !["bash", "repl", "read", "edit", "write"].includes(tool))
|
|
606
|
+
) process.exit(42);
|
|
607
|
+
|
|
608
|
+
function workspaceAliasMatches() {
|
|
609
|
+
try {
|
|
610
|
+
const alias = fs.lstatSync(config.workspaceAliasPath);
|
|
611
|
+
if (!alias.isSymbolicLink()) return false;
|
|
612
|
+
if (fs.readlinkSync(config.workspaceAliasPath) !== config.workRoot) return false;
|
|
613
|
+
return fs.realpathSync(config.workspaceAliasPath) === fs.realpathSync(config.workRoot);
|
|
614
|
+
} catch {
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
function ensureWorkspaceAlias() {
|
|
619
|
+
try {
|
|
620
|
+
if (!fs.statSync(config.workRoot).isDirectory()) return false;
|
|
621
|
+
if (config.workRoot === config.workspaceAliasPath) return true;
|
|
622
|
+
try {
|
|
623
|
+
fs.lstatSync(config.workspaceAliasPath);
|
|
624
|
+
return workspaceAliasMatches();
|
|
625
|
+
} catch (error) {
|
|
626
|
+
if (!error || error.code !== "ENOENT") return false;
|
|
627
|
+
}
|
|
628
|
+
try {
|
|
629
|
+
fs.symlinkSync(config.workRoot, config.workspaceAliasPath, "dir");
|
|
630
|
+
} catch (error) {
|
|
631
|
+
if (!error || error.code !== "EEXIST") return false;
|
|
632
|
+
}
|
|
633
|
+
return workspaceAliasMatches();
|
|
634
|
+
} catch {
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
if (!ensureWorkspaceAlias()) process.exit(43);
|
|
639
|
+
|
|
640
|
+
let stopped = false;
|
|
641
|
+
let terminalCleanupStarted = false;
|
|
642
|
+
let terminalCleanupPromise = null;
|
|
643
|
+
let terminalParentPending = null;
|
|
644
|
+
let terminalParentBuffer = "";
|
|
645
|
+
let terminalParentFailed = false;
|
|
646
|
+
if (config.terminalDaemonNonce) {
|
|
647
|
+
const failParent = () => { terminalParentFailed = true; terminalParentPending?.finish(false); };
|
|
648
|
+
process.stdin.on("data", chunk => {
|
|
649
|
+
terminalParentBuffer += chunk.toString();
|
|
650
|
+
if (Buffer.byteLength(terminalParentBuffer) > 4096) return failParent();
|
|
651
|
+
let at;
|
|
652
|
+
while ((at = terminalParentBuffer.indexOf("\n")) >= 0) {
|
|
653
|
+
const line = terminalParentBuffer.slice(0, at); terminalParentBuffer = terminalParentBuffer.slice(at + 1);
|
|
654
|
+
let value; try { value = JSON.parse(line); } catch { return failParent(); }
|
|
655
|
+
if (!terminalParentPending || !hasOnlyKeys(value, ["type", "version", "nonce", "id", "accepted"])
|
|
656
|
+
|| value.type !== "terminal_daemon_ack" || value.version !== 1 || value.nonce !== config.terminalDaemonNonce
|
|
657
|
+
|| value.id !== terminalParentPending.id || typeof value.accepted !== "boolean") return failParent();
|
|
658
|
+
terminalParentPending.finish(value.accepted);
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
process.stdin.on("error", failParent); process.stdin.on("end", failParent);
|
|
662
|
+
}
|
|
663
|
+
function terminalParentRequest(action, claim) {
|
|
664
|
+
if (!config.terminalDaemonNonce) return Promise.resolve(true);
|
|
665
|
+
if (terminalParentFailed || terminalParentPending) return Promise.resolve(false);
|
|
666
|
+
return new Promise(resolve => {
|
|
667
|
+
const id = crypto.randomUUID();
|
|
668
|
+
const timer = setTimeout(() => { terminalParentFailed = true; finish(false); }, 2000);
|
|
669
|
+
let settled = false;
|
|
670
|
+
const finish = accepted => { if (settled) return; settled = true; clearTimeout(timer); terminalParentPending = null; resolve(accepted); };
|
|
671
|
+
terminalParentPending = { id, finish };
|
|
672
|
+
process.stdout.write(JSON.stringify({ type: "terminal_daemon", version: 1, nonce: config.terminalDaemonNonce,
|
|
673
|
+
runId: config.terminalCleanupRunId, executorLaunchId: config.launchId, generation: claim.generation, action, id }) + "\n",
|
|
674
|
+
error => { if (error) { terminalParentFailed = true; finish(false); } });
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
let stoppingPromise = null;
|
|
678
|
+
let socket = null;
|
|
679
|
+
let generation = 0;
|
|
680
|
+
let retryMs = 25;
|
|
681
|
+
let connectionAttempt = 0;
|
|
682
|
+
let targetGeneration = config.targetGeneration;
|
|
683
|
+
let targetFenceId = config.fenceId;
|
|
684
|
+
const active = new Map();
|
|
685
|
+
const ledger = new Map();
|
|
686
|
+
const cachedResultIds = [];
|
|
687
|
+
// Managed records extend this worker's Bash supervisor, not the backend queue.
|
|
688
|
+
// No command/environment/stdin text or recoverable PID is stored in the journal.
|
|
689
|
+
const jobs = new Map();
|
|
690
|
+
let journalFailed = false;
|
|
691
|
+
let cleanupUnconfirmed = false;
|
|
692
|
+
let journalDebrisBytes = 0;
|
|
693
|
+
let journalDirectory = null;
|
|
694
|
+
let journalOwnership = null;
|
|
695
|
+
let jobControlServer = null;
|
|
696
|
+
const jobControlClients = new Set();
|
|
697
|
+
let pendingJobRequests = 0;
|
|
698
|
+
let maintenanceLease = null;
|
|
699
|
+
// Process groups of settled retainBackground Bash requests and Pi shells whose descendants
|
|
700
|
+
// were still running when the command exited, keyed by process group ID. They
|
|
701
|
+
// are live work until the group is empty, and stop() ends them with the worker.
|
|
702
|
+
const backgroundGroups = new Map();
|
|
703
|
+
let backgroundGroupTimer = null;
|
|
704
|
+
// reason-sdk state (see machineSdkEndpoint): the claim of the current socket,
|
|
705
|
+
// the worker's private directory with the shim, the live per-request
|
|
706
|
+
// endpoints and the calls waiting for the Brain's sdk_response.
|
|
707
|
+
let socketClaim = null;
|
|
708
|
+
let sdkBase = null;
|
|
709
|
+
let sdkEndpointCount = 0;
|
|
710
|
+
const sdkEndpoints = new Set();
|
|
711
|
+
const sdkPending = new Map();
|
|
712
|
+
|
|
713
|
+
// Fixed-shape diagnostics: never include URLs, peer reasons, errors, request IDs,
|
|
714
|
+
// command text, paths, or credential-bearing configuration. Bound reconnect storms.
|
|
715
|
+
let diagnosticWindowMs = 0;
|
|
716
|
+
let diagnosticCount = 0;
|
|
717
|
+
let suppressedDiagnostics = 0;
|
|
718
|
+
function diagnostic(event, reason = null, peerGeneration = null, closeCode = null) {
|
|
719
|
+
const now = Date.now();
|
|
720
|
+
if (now - diagnosticWindowMs >= 60000) {
|
|
721
|
+
diagnosticWindowMs = now;
|
|
722
|
+
diagnosticCount = 0;
|
|
723
|
+
}
|
|
724
|
+
if (diagnosticCount >= 120 && event !== "stopping" && event !== "stopped") {
|
|
725
|
+
suppressedDiagnostics += 1;
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
diagnosticCount += 1;
|
|
729
|
+
try {
|
|
730
|
+
process.stderr.write(JSON.stringify({
|
|
731
|
+
event: "hands.worker." + event,
|
|
732
|
+
timestampMs: now,
|
|
733
|
+
launchId: config.launchId,
|
|
734
|
+
connectionAttempt,
|
|
735
|
+
connectionGeneration: generation,
|
|
736
|
+
targetGeneration,
|
|
737
|
+
peerGeneration,
|
|
738
|
+
activeRequestCount: active.size,
|
|
739
|
+
socketState: socket ? socket.readyState : null,
|
|
740
|
+
remainingLifetimeMs: Math.max(0, config.expiresAtMs - now),
|
|
741
|
+
closeCode,
|
|
742
|
+
reason,
|
|
743
|
+
suppressedDiagnostics,
|
|
744
|
+
}) + "\n");
|
|
745
|
+
suppressedDiagnostics = 0;
|
|
746
|
+
} catch { /* Diagnostics must never affect execution. */ }
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function hasOnlyKeys(value, allowed) {
|
|
750
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
751
|
+
const keys = Object.keys(value);
|
|
752
|
+
return keys.every((key) => allowed.includes(key));
|
|
753
|
+
}
|
|
754
|
+
function safeId(value) {
|
|
755
|
+
return typeof value === "string" && ID_RE.test(value) ? value : "invalid";
|
|
756
|
+
}
|
|
757
|
+
function clip(value, maximum) {
|
|
758
|
+
value = typeof value === "string" ? value : String(value || "");
|
|
759
|
+
if (value.length <= maximum) return value;
|
|
760
|
+
const marker = "\n...[executor output truncated]";
|
|
761
|
+
return value.slice(0, Math.max(0, maximum - marker.length)) + marker;
|
|
762
|
+
}
|
|
763
|
+
function failure(id, exitCode, error, stdout, stderr, responseGeneration = targetGeneration) {
|
|
764
|
+
return {
|
|
765
|
+
version: VERSION,
|
|
766
|
+
generation: responseGeneration,
|
|
767
|
+
id: safeId(id),
|
|
768
|
+
ok: false,
|
|
769
|
+
exitCode: Number.isSafeInteger(exitCode) && exitCode > 0 && exitCode <= 255 ? exitCode : 1,
|
|
770
|
+
stdout: clip(stdout || "", MAX_OUTPUT_CHARS),
|
|
771
|
+
stderr: clip(stderr || "", MAX_OUTPUT_CHARS),
|
|
772
|
+
cwd: config.repoPath,
|
|
773
|
+
error: clip(error, MAX_ERROR_CHARS),
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
function success(id, stdout, stderr, responseGeneration = targetGeneration) {
|
|
777
|
+
return {
|
|
778
|
+
version: VERSION,
|
|
779
|
+
generation: responseGeneration,
|
|
780
|
+
id,
|
|
781
|
+
ok: true,
|
|
782
|
+
exitCode: 0,
|
|
783
|
+
stdout: clip(stdout || "", MAX_OUTPUT_CHARS),
|
|
784
|
+
stderr: clip(stderr || "", MAX_OUTPUT_CHARS),
|
|
785
|
+
cwd: config.repoPath,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
// The code alone names the reason: Bun's WebSocket client drops close text.
|
|
789
|
+
function closeSocket(target, reason) {
|
|
790
|
+
try {
|
|
791
|
+
if (target.readyState < WebSocket.CLOSING) target.close(CLOSE_CODES[reason], reason);
|
|
792
|
+
} catch {
|
|
793
|
+
try { target.close(); } catch { return; }
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
function parseRequest(raw, expectedGeneration) {
|
|
797
|
+
if (Buffer.byteLength(raw, "utf8") > MAX_FRAME_BYTES) {
|
|
798
|
+
const error = new Error("frame_too_large"); error.code = "frame_too_large"; throw error;
|
|
799
|
+
}
|
|
800
|
+
let value;
|
|
801
|
+
try { value = JSON.parse(raw); } catch {
|
|
802
|
+
const error = new Error("invalid_json"); error.code = "invalid_json"; throw error;
|
|
803
|
+
}
|
|
804
|
+
const id = safeId(value && value.id);
|
|
805
|
+
if (
|
|
806
|
+
!hasOnlyKeys(value, ["version", "generation", "id", "tool", "input", "semanticDigest", "lifecycleLeaseId"]) ||
|
|
807
|
+
(value.lifecycleLeaseId !== undefined && (value.tool !== "bash" || typeof value.lifecycleLeaseId !== "string" || !UUID_RE.test(value.lifecycleLeaseId))) ||
|
|
808
|
+
value.version !== VERSION || !Number.isSafeInteger(value.generation) || value.generation < 1 || id === "invalid" ||
|
|
809
|
+
(value.tool === "pi_tool" ? !validPiToolRequest(value) : value.tool !== "resume" && !allowedTools.includes(value.tool)) ||
|
|
810
|
+
!value.input || typeof value.input !== "object" || Array.isArray(value.input)
|
|
811
|
+
) {
|
|
812
|
+
const error = new Error("invalid_request"); error.code = "invalid_request"; error.id = id; throw error;
|
|
813
|
+
}
|
|
814
|
+
if (value.generation !== expectedGeneration) {
|
|
815
|
+
const error = new Error("stale_generation"); error.code = "stale_generation"; error.id = id; throw error;
|
|
816
|
+
}
|
|
817
|
+
if (
|
|
818
|
+
value.tool === "bash" &&
|
|
819
|
+
(!hasOnlyKeys(value.input, ["script", "retainBackground", "machineSdk"]) || typeof value.input.script !== "string" || value.input.script.length > MAX_INPUT_CHARS ||
|
|
820
|
+
(value.input.retainBackground !== undefined && value.input.retainBackground !== true) ||
|
|
821
|
+
(value.input.machineSdk !== undefined && value.input.machineSdk !== true) ||
|
|
822
|
+
(value.semanticDigest !== undefined && (typeof value.semanticDigest !== "string" || !SHA256_RE.test(value.semanticDigest))))
|
|
823
|
+
) {
|
|
824
|
+
const error = new Error("invalid_request"); error.code = "invalid_request"; error.id = id; throw error;
|
|
825
|
+
}
|
|
826
|
+
if (
|
|
827
|
+
value.tool === "repl" &&
|
|
828
|
+
(!hasOnlyKeys(value.input, ["code"]) || typeof value.input.code !== "string" ||
|
|
829
|
+
value.input.code.length > MAX_INPUT_CHARS || value.semanticDigest !== undefined)
|
|
830
|
+
) {
|
|
831
|
+
const error = new Error("invalid_request"); error.code = "invalid_request"; error.id = id; throw error;
|
|
832
|
+
}
|
|
833
|
+
if (value.tool === "resume" && (
|
|
834
|
+
value.semanticDigest !== undefined || !hasOnlyKeys(value.input, ["semanticDigest"]) ||
|
|
835
|
+
typeof value.input.semanticDigest !== "string" || !SHA256_RE.test(value.input.semanticDigest)
|
|
836
|
+
)) {
|
|
837
|
+
const error = new Error("invalid_request"); error.code = "invalid_request"; error.id = id; throw error;
|
|
838
|
+
}
|
|
839
|
+
if (
|
|
840
|
+
value.tool === "read" &&
|
|
841
|
+
(!hasOnlyKeys(value.input, ["path", "offset", "limit"]) ||
|
|
842
|
+
typeof value.input.path !== "string" || value.input.path.length < 1 || value.input.path.length > MAX_INPUT_CHARS ||
|
|
843
|
+
value.semanticDigest !== undefined ||
|
|
844
|
+
(value.input.offset !== undefined && (!Number.isSafeInteger(value.input.offset) || value.input.offset < 1)) ||
|
|
845
|
+
(value.input.limit !== undefined && (!Number.isSafeInteger(value.input.limit) || value.input.limit < 1 || value.input.limit > 2000)))
|
|
846
|
+
) {
|
|
847
|
+
const error = new Error("invalid_request"); error.code = "invalid_request"; error.id = id; throw error;
|
|
848
|
+
}
|
|
849
|
+
if (value.tool === "edit") {
|
|
850
|
+
let chars = typeof value.input.path === "string"
|
|
851
|
+
? value.input.path.length
|
|
852
|
+
: MAX_STRUCTURED_INPUT_CHARS + 1;
|
|
853
|
+
const validEdits = Array.isArray(value.input.edits) && value.input.edits.length >= 1 && value.input.edits.length <= 128
|
|
854
|
+
&& value.input.edits.every((edit) => {
|
|
855
|
+
if (!hasOnlyKeys(edit, ["oldText", "newText"]) || typeof edit.oldText !== "string" || edit.oldText.length < 1 || typeof edit.newText !== "string") return false;
|
|
856
|
+
chars += edit.oldText.length + edit.newText.length;
|
|
857
|
+
return chars <= MAX_STRUCTURED_INPUT_CHARS;
|
|
858
|
+
});
|
|
859
|
+
if (!hasOnlyKeys(value.input, ["path", "edits"]) || typeof value.input.path !== "string" || value.input.path.length < 1 || value.semanticDigest !== undefined || !validEdits) {
|
|
860
|
+
const error = new Error("invalid_request"); error.code = "invalid_request"; error.id = id; throw error;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
if (
|
|
864
|
+
value.tool === "write" &&
|
|
865
|
+
(!hasOnlyKeys(value.input, ["path", "content"]) || typeof value.input.path !== "string" || value.input.path.length < 1 ||
|
|
866
|
+
typeof value.input.content !== "string"
|
|
867
|
+
|| value.input.path.length + value.input.content.length > MAX_STRUCTURED_INPUT_CHARS
|
|
868
|
+
|| value.semanticDigest !== undefined)
|
|
869
|
+
) {
|
|
870
|
+
const error = new Error("invalid_request"); error.code = "invalid_request"; error.id = id; throw error;
|
|
871
|
+
}
|
|
872
|
+
return value;
|
|
873
|
+
}
|
|
874
|
+
function sendResult(target, result) {
|
|
875
|
+
if (target.readyState !== WebSocket.OPEN) return;
|
|
876
|
+
const encoded = JSON.stringify(result);
|
|
877
|
+
if (Buffer.byteLength(encoded, "utf8") > MAX_FRAME_BYTES) {
|
|
878
|
+
closeSocket(target, "result_too_large");
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
target.send(encoded);
|
|
882
|
+
}
|
|
883
|
+
function sendProgress(target, id, sequence, stdout, stderr, responseGeneration = targetGeneration) {
|
|
884
|
+
if (target.readyState !== WebSocket.OPEN) return;
|
|
885
|
+
const encoded = JSON.stringify({
|
|
886
|
+
type: "progress",
|
|
887
|
+
version: VERSION,
|
|
888
|
+
generation: responseGeneration,
|
|
889
|
+
id,
|
|
890
|
+
sequence,
|
|
891
|
+
stdout: clip(stdout || "", MAX_OUTPUT_CHARS),
|
|
892
|
+
stderr: clip(stderr || "", MAX_OUTPUT_CHARS),
|
|
893
|
+
});
|
|
894
|
+
if (Buffer.byteLength(encoded, "utf8") > MAX_FRAME_BYTES) {
|
|
895
|
+
closeSocket(target, "progress_too_large");
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
target.send(encoded);
|
|
899
|
+
}
|
|
900
|
+
function bindResultGeneration(result, responseGeneration) {
|
|
901
|
+
return { ...result, generation: responseGeneration };
|
|
902
|
+
}
|
|
903
|
+
function bindResumeResult(result, responseGeneration) {
|
|
904
|
+
return { ...result, generation: responseGeneration, resumed: true };
|
|
905
|
+
}
|
|
906
|
+
function requestHash(request, input = request.input) {
|
|
907
|
+
return crypto.createHash("sha256").update(JSON.stringify({
|
|
908
|
+
version: request.version,
|
|
909
|
+
id: request.id,
|
|
910
|
+
tool: request.tool,
|
|
911
|
+
input,
|
|
912
|
+
semanticDigest: request.semanticDigest || null,
|
|
913
|
+
})).digest("hex");
|
|
914
|
+
}
|
|
915
|
+
function digest(value) {
|
|
916
|
+
return crypto.createHash("sha256").update(value).digest("hex");
|
|
917
|
+
}
|
|
918
|
+
function privateJournalRead(name, maxBytes = MAX_JOB_RECORD_BYTES) {
|
|
919
|
+
const fd = fs.openSync(path.join(journalDirectory, name), fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
920
|
+
try {
|
|
921
|
+
const stat = fs.fstatSync(fd);
|
|
922
|
+
if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o077) !== 0 || stat.size > maxBytes) {
|
|
923
|
+
throw new Error("invalid_job_journal");
|
|
924
|
+
}
|
|
925
|
+
return { value: JSON.parse(fs.readFileSync(fd, "utf8")), bytes: stat.size };
|
|
926
|
+
} finally { fs.closeSync(fd); }
|
|
927
|
+
}
|
|
928
|
+
// Atomic replacement always survives a worker crash. Only a durable write also
|
|
929
|
+
// survives a host crash: fsync costs ~17 ms per call on slow container disks,
|
|
930
|
+
// so hot paths (output, stdin, snapshots) skip it; transitions keep it.
|
|
931
|
+
function privateJournalWrite(name, encoded, maxBytes = MAX_JOB_RECORD_BYTES, durable = true) {
|
|
932
|
+
const target = path.join(journalDirectory, name);
|
|
933
|
+
const temp = target + ".tmp";
|
|
934
|
+
const bytes = Buffer.byteLength(encoded);
|
|
935
|
+
if (bytes > maxBytes) throw new Error("job_journal_full");
|
|
936
|
+
// A fixed temp name bounds crash debris. The composition root must give a
|
|
937
|
+
// single live worker exclusive ownership of this scope's private directory.
|
|
938
|
+
try { fs.unlinkSync(temp); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
939
|
+
const fd = fs.openSync(temp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
940
|
+
try { fs.writeFileSync(fd, encoded); if (durable) fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
941
|
+
fs.renameSync(temp, target);
|
|
942
|
+
if (!durable) return bytes;
|
|
943
|
+
const directoryFd = fs.openSync(path.dirname(target), fs.constants.O_RDONLY);
|
|
944
|
+
try { fs.fsyncSync(directoryFd); } finally { fs.closeSync(directoryFd); }
|
|
945
|
+
return bytes;
|
|
946
|
+
}
|
|
947
|
+
function journalScope(generation, fenceId) {
|
|
948
|
+
return { schema: 1, scopeId: config.managedJobs.scopeId, repoPath: fs.realpathSync(config.repoPath), generation, fenceId };
|
|
949
|
+
}
|
|
950
|
+
function isLiveJob(record) { return record.state === "starting" || record.state === "running"; }
|
|
951
|
+
function jobJournalReservation(job) {
|
|
952
|
+
return isLiveJob(job.record) || job.record.state === "uncertain" || job.operation
|
|
953
|
+
? MAX_JOB_RECORD_BYTES : job.journalBytes;
|
|
954
|
+
}
|
|
955
|
+
function hasJobCapacity() {
|
|
956
|
+
// Identity metadata uses the same bounded lifetime ceiling as request replay.
|
|
957
|
+
// The byte budget remains the original 64 full records, including reservations
|
|
958
|
+
// for every process whose future output or cleanup is still unresolved.
|
|
959
|
+
if (jobs.size >= MAX_LEDGER_IDS) return false;
|
|
960
|
+
let reserved = MAX_JOB_RECORD_BYTES + journalDebrisBytes;
|
|
961
|
+
for (const job of jobs.values()) reserved += jobJournalReservation(job);
|
|
962
|
+
return reserved <= MAX_JOB_JOURNAL_BYTES;
|
|
963
|
+
}
|
|
964
|
+
function jobFile(jobId) { return digest(jobId) + ".job.json"; }
|
|
965
|
+
function initializeJobs() {
|
|
966
|
+
if (!config.managedJobs) return;
|
|
967
|
+
fs.mkdirSync(config.managedJobs.journalDirectory, { mode: 0o700 });
|
|
968
|
+
}
|
|
969
|
+
// Worker state (the job journal, the private environment) stays out of the
|
|
970
|
+
// Session's folder, except under a protected directory
|
|
971
|
+
// (shared/local-protected-paths.ts) that the file tools and every confinement
|
|
972
|
+
// refuse. A home-folder worker keeps it in ~/.config/reason, so its Session may
|
|
973
|
+
// work in the home folder itself, as a Mac's home grant does.
|
|
974
|
+
const PROTECTED_STATE_DIRECTORIES = ${JSON.stringify(i)};
|
|
975
|
+
function stateOverlapsWorkspace(state, root) {
|
|
976
|
+
if (state === root || root.startsWith(state + path.sep)) return true;
|
|
977
|
+
if (!state.startsWith(root + path.sep)) return false;
|
|
978
|
+
const segments = state.slice(root.length + 1).toLowerCase().split(path.sep);
|
|
979
|
+
return !PROTECTED_STATE_DIRECTORIES.some((sequence) => segments.some((_, start) =>
|
|
980
|
+
sequence.every((part, offset) => segments[start + offset] === part)));
|
|
981
|
+
}
|
|
982
|
+
function prepareJournalDirectory() {
|
|
983
|
+
if (!config.managedJobs) return;
|
|
984
|
+
const candidate = config.managedJobs.journalDirectory;
|
|
985
|
+
try { initializeJobs(); } catch (error) { if (error.code !== "EEXIST") throw error; }
|
|
986
|
+
const stat = fs.lstatSync(candidate);
|
|
987
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid() || (stat.mode & 0o077) !== 0) throw new Error("invalid_job_journal");
|
|
988
|
+
journalDirectory = fs.realpathSync(candidate);
|
|
989
|
+
for (const root of [config.repoPath, config.workRoot]) {
|
|
990
|
+
const canonical = fs.realpathSync(root);
|
|
991
|
+
if (stateOverlapsWorkspace(journalDirectory, canonical)) throw new Error("job_journal_inside_workspace");
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
function acquireJournalOwnership() {
|
|
995
|
+
if (!journalDirectory) return;
|
|
996
|
+
const lockPath = path.join(journalDirectory, "owner.lock");
|
|
997
|
+
// Atomic across distinct launch sockets and surviving daemon generations.
|
|
998
|
+
// Crash markers are deliberately NOT reclaimed by PID, expiry or reconnect.
|
|
999
|
+
// Only the serialized daemon recovery owner may retire a marker after exact
|
|
1000
|
+
// prior-launch and descendant-stop proof. Never race two recovery cleaners.
|
|
1001
|
+
const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | fs.constants.O_NOFOLLOW, 0o600);
|
|
1002
|
+
journalOwnership = { fd, path: lockPath, stat: fs.fstatSync(fd) };
|
|
1003
|
+
fs.writeFileSync(fd, JSON.stringify({ version: 1, scopeId: config.managedJobs.scopeId,
|
|
1004
|
+
executorLaunchId: config.launchId, workerArtifactSha256: config.workerArtifactSha256,
|
|
1005
|
+
pid: process.pid, createdAtMs: Date.now() }));
|
|
1006
|
+
fs.fsyncSync(fd);
|
|
1007
|
+
const directoryFd = fs.openSync(journalDirectory, fs.constants.O_RDONLY);
|
|
1008
|
+
try { fs.fsyncSync(directoryFd); } finally { fs.closeSync(directoryFd); }
|
|
1009
|
+
}
|
|
1010
|
+
function releaseJournalOwnership() {
|
|
1011
|
+
if (!journalOwnership) return;
|
|
1012
|
+
const owned = journalOwnership;
|
|
1013
|
+
const current = fs.lstatSync(owned.path);
|
|
1014
|
+
if (current.dev !== owned.stat.dev || current.ino !== owned.stat.ino || current.nlink !== 1) throw new Error("job_ownership_changed");
|
|
1015
|
+
fs.unlinkSync(owned.path);
|
|
1016
|
+
fs.closeSync(owned.fd);
|
|
1017
|
+
journalOwnership = null;
|
|
1018
|
+
const directoryFd = fs.openSync(journalDirectory, fs.constants.O_RDONLY);
|
|
1019
|
+
try { fs.fsyncSync(directoryFd); } finally { fs.closeSync(directoryFd); }
|
|
1020
|
+
}
|
|
1021
|
+
function loadJobs() {
|
|
1022
|
+
if (!config.managedJobs) return;
|
|
1023
|
+
const names = fs.readdirSync(journalDirectory);
|
|
1024
|
+
if (names.length > MAX_LEDGER_IDS * 2 + 4) throw new Error("job_journal_full");
|
|
1025
|
+
if (names.includes("scope.json")) {
|
|
1026
|
+
const { value: scope } = privateJournalRead("scope.json");
|
|
1027
|
+
if (scope.schema !== 1 || scope.scopeId !== config.managedJobs.scopeId || scope.repoPath !== fs.realpathSync(config.repoPath)
|
|
1028
|
+
|| !Number.isSafeInteger(scope.generation) || scope.generation < 1 || !UUID_RE.test(scope.fenceId)) throw new Error("job_scope_mismatch");
|
|
1029
|
+
if (scope.generation > targetGeneration || (scope.generation === targetGeneration && scope.fenceId !== targetFenceId)) {
|
|
1030
|
+
targetGeneration = scope.generation;
|
|
1031
|
+
targetFenceId = scope.fenceId;
|
|
1032
|
+
}
|
|
1033
|
+
} else if (names.some((name) => name !== "scope.json.tmp" && name !== "control.sock" && name !== "owner.lock")) throw new Error("job_scope_missing");
|
|
1034
|
+
privateJournalWrite("scope.json", JSON.stringify(journalScope(targetGeneration, targetFenceId)));
|
|
1035
|
+
let reservedBytes = 0;
|
|
1036
|
+
for (const name of names) {
|
|
1037
|
+
if (name === "scope.json" || name === "scope.json.tmp" || name === "owner.lock" || name === "requests") continue;
|
|
1038
|
+
if (name === "control.sock" && fs.lstatSync(path.join(journalDirectory, name)).isSocket()) continue;
|
|
1039
|
+
if (/^[0-9a-f]{64}\.job\.json\.tmp$/.test(name)) continue;
|
|
1040
|
+
if (!/^[0-9a-f]{64}\.job\.json$/.test(name)) throw new Error("invalid_job_journal");
|
|
1041
|
+
let { value: record, bytes } = privateJournalRead(name);
|
|
1042
|
+
if (!hasOnlyKeys(record, ["schema", "jobId", "digest", "state", "startedAtMs", "finishedAtMs", "exitCode",
|
|
1043
|
+
"leaseExpiresAtMs", "inputSequence", "inputDigest", "output", "nextCursor", "outputLost", "environmentRevision", "environmentSnapshotId", "pty"])
|
|
1044
|
+
|| record.schema !== 1 || typeof record.jobId !== "string" || !ID_RE.test(record.jobId)
|
|
1045
|
+
|| jobFile(record.jobId) !== name || !SHA256_RE.test(record.digest)
|
|
1046
|
+
|| !["starting", "running", "exited", "cancelled", "expired", "uncertain"].includes(record.state)
|
|
1047
|
+
|| !Number.isSafeInteger(record.startedAtMs) || record.startedAtMs < 1
|
|
1048
|
+
|| !Number.isSafeInteger(record.leaseExpiresAtMs) || record.leaseExpiresAtMs < 1
|
|
1049
|
+
|| (record.finishedAtMs !== null && (!Number.isSafeInteger(record.finishedAtMs) || record.finishedAtMs < record.startedAtMs))
|
|
1050
|
+
|| (record.exitCode !== null && (!Number.isSafeInteger(record.exitCode) || record.exitCode < 0 || record.exitCode > 255))
|
|
1051
|
+
|| !Number.isSafeInteger(record.inputSequence) || record.inputSequence < 0
|
|
1052
|
+
|| (record.inputDigest !== null && !SHA256_RE.test(record.inputDigest))
|
|
1053
|
+
|| !Number.isSafeInteger(record.nextCursor) || record.nextCursor < 0 || typeof record.outputLost !== "boolean"
|
|
1054
|
+
|| !Array.isArray(record.output) || record.output.length > MAX_JOB_OUTPUT_CHUNKS
|
|
1055
|
+
|| (record.environmentRevision === undefined) !== (record.environmentSnapshotId === undefined)
|
|
1056
|
+
|| (record.environmentRevision !== undefined && (!Number.isSafeInteger(record.environmentRevision)
|
|
1057
|
+
|| record.environmentRevision < 1 || !UUID_RE.test(record.environmentSnapshotId)))) throw new Error("invalid_job_journal");
|
|
1058
|
+
let cursor = 0;
|
|
1059
|
+
let chars = 0;
|
|
1060
|
+
for (const chunk of record.output) {
|
|
1061
|
+
if (!chunk || !Number.isSafeInteger(chunk.cursor) || chunk.cursor <= cursor || chunk.cursor > record.nextCursor
|
|
1062
|
+
|| !["stdout", "stderr"].includes(chunk.stream) || typeof chunk.text !== "string") throw new Error("invalid_job_journal");
|
|
1063
|
+
cursor = chunk.cursor;
|
|
1064
|
+
chars += chunk.text.length;
|
|
1065
|
+
}
|
|
1066
|
+
if (chars > MAX_JOB_OUTPUT_CHARS || jobs.size >= MAX_LEDGER_IDS) throw new Error("job_journal_full");
|
|
1067
|
+
if (record.pty !== undefined) {
|
|
1068
|
+
const pty = record.pty;
|
|
1069
|
+
if (!isPtyJobId(record.jobId) || !hasOnlyKeys(pty, ["version", "cols", "rows", "output", "cleanup"])
|
|
1070
|
+
|| pty.version !== 1 || !validPtyDimensions(pty) || record.output.length !== 0
|
|
1071
|
+
|| !Array.isArray(pty.output) || pty.output.length > MAX_JOB_OUTPUT_CHUNKS
|
|
1072
|
+
|| (isLiveJob(record) ? pty.cleanup !== "pending" : record.state === "uncertain"
|
|
1073
|
+
? pty.cleanup !== "unconfirmed" : pty.cleanup !== "confirmed")) throw new Error("invalid_job_journal");
|
|
1074
|
+
let previous = 0, bytes = 0;
|
|
1075
|
+
for (const chunk of pty.output) {
|
|
1076
|
+
if (!hasOnlyKeys(chunk, ["cursor", "dataBase64"]) || !Number.isSafeInteger(chunk.cursor)
|
|
1077
|
+
|| chunk.cursor <= previous || chunk.cursor > record.nextCursor || !validPtyBytes(chunk.dataBase64)) throw new Error("invalid_job_journal");
|
|
1078
|
+
previous = chunk.cursor;
|
|
1079
|
+
bytes += Buffer.from(chunk.dataBase64, "base64").length;
|
|
1080
|
+
}
|
|
1081
|
+
if (bytes > MAX_PTY_OUTPUT_BYTES) throw new Error("job_journal_full");
|
|
1082
|
+
}
|
|
1083
|
+
if (isLiveJob(record)) {
|
|
1084
|
+
record.state = "uncertain";
|
|
1085
|
+
if (record.pty) record.pty.cleanup = "unconfirmed";
|
|
1086
|
+
record.finishedAtMs = Date.now();
|
|
1087
|
+
record.exitCode = null;
|
|
1088
|
+
bytes = privateJournalWrite(name, JSON.stringify(record));
|
|
1089
|
+
}
|
|
1090
|
+
const job = { record, operation: null, flushTimer: null, journalBytes: bytes };
|
|
1091
|
+
reservedBytes += jobJournalReservation(job);
|
|
1092
|
+
if (reservedBytes > MAX_JOB_JOURNAL_BYTES) throw new Error("job_journal_full");
|
|
1093
|
+
jobs.set(record.jobId, job);
|
|
1094
|
+
}
|
|
1095
|
+
// Recovery rewrites remove matching temp files. Scan again afterward so
|
|
1096
|
+
// neither missing entries nor already-deleted bytes depend on listing order.
|
|
1097
|
+
for (const name of fs.readdirSync(journalDirectory)) {
|
|
1098
|
+
if (!/^[0-9a-f]{64}\.job\.json\.tmp$/.test(name)) continue;
|
|
1099
|
+
const stat = fs.lstatSync(path.join(journalDirectory, name));
|
|
1100
|
+
if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o077) !== 0 || stat.size > MAX_JOB_RECORD_BYTES) throw new Error("invalid_job_journal");
|
|
1101
|
+
journalDebrisBytes += stat.size;
|
|
1102
|
+
reservedBytes += stat.size;
|
|
1103
|
+
if (reservedBytes > MAX_JOB_JOURNAL_BYTES) throw new Error("job_journal_full");
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
try { prepareJournalDirectory(); } catch { process.exit(44); }
|
|
1107
|
+
try { acquireJournalOwnership(); } catch { process.exit(46); }
|
|
1108
|
+
// An unchanged record is not rewritten; a durable request still fsyncs one
|
|
1109
|
+
// that was last written without it.
|
|
1110
|
+
function persistJob(job, durable = true) {
|
|
1111
|
+
if (journalFailed) throw new Error("job_journal_unavailable");
|
|
1112
|
+
const encoded = JSON.stringify(job.record), written = digest(encoded);
|
|
1113
|
+
if (written === job.journalDigest && (job.journalDurable || !durable)) return;
|
|
1114
|
+
try {
|
|
1115
|
+
job.journalBytes = privateJournalWrite(jobFile(job.record.jobId), encoded, MAX_JOB_RECORD_BYTES, durable);
|
|
1116
|
+
job.journalDigest = written;
|
|
1117
|
+
job.journalDurable = durable;
|
|
1118
|
+
}
|
|
1119
|
+
catch {
|
|
1120
|
+
journalFailed = true;
|
|
1121
|
+
// Once durable evidence is unavailable no new effects are admitted. Only
|
|
1122
|
+
// our live ChildProcess handles are cancelled; journaled PIDs are never used.
|
|
1123
|
+
for (const candidate of jobs.values()) {
|
|
1124
|
+
if (!isLiveJob(candidate.record)) continue;
|
|
1125
|
+
candidate.record.state = "uncertain";
|
|
1126
|
+
if (candidate.record.pty) candidate.record.pty.cleanup = "unconfirmed";
|
|
1127
|
+
candidate.record.exitCode = null;
|
|
1128
|
+
if (candidate.operation) void candidate.operation.cancel().catch(() => {});
|
|
1129
|
+
}
|
|
1130
|
+
throw new Error("job_journal_unavailable");
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
function appendJobOutput(job, stream, text) {
|
|
1134
|
+
if (!text || journalFailed) return;
|
|
1135
|
+
const record = job.record;
|
|
1136
|
+
// Split oversized reads rather than silently losing the prefix inside one
|
|
1137
|
+
// cursor. Cursor gaps then describe every discarded output chunk precisely.
|
|
1138
|
+
if (text.length > MAX_JOB_OUTPUT_CHARS) {
|
|
1139
|
+
for (let offset = 0; offset < text.length; offset += MAX_JOB_OUTPUT_CHARS) {
|
|
1140
|
+
appendJobOutput(job, stream, text.slice(offset, offset + MAX_JOB_OUTPUT_CHARS));
|
|
1141
|
+
}
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
record.output.push({ cursor: ++record.nextCursor, stream, text });
|
|
1145
|
+
let chars = record.output.reduce((sum, chunk) => sum + chunk.text.length, 0);
|
|
1146
|
+
while (record.output.length > MAX_JOB_OUTPUT_CHUNKS || chars > MAX_JOB_OUTPUT_CHARS) {
|
|
1147
|
+
chars -= record.output.shift().text.length;
|
|
1148
|
+
record.outputLost = true;
|
|
1149
|
+
}
|
|
1150
|
+
scheduleJobFlush(job);
|
|
1151
|
+
}
|
|
1152
|
+
function scheduleJobFlush(job) {
|
|
1153
|
+
if (!job.flushTimer) job.flushTimer = setTimeout(() => {
|
|
1154
|
+
job.flushTimer = null;
|
|
1155
|
+
try { persistJob(job, false); } catch { /* failure already stops live jobs */ }
|
|
1156
|
+
}, 50);
|
|
1157
|
+
}
|
|
1158
|
+
function appendPtyOutput(job, data) {
|
|
1159
|
+
if (!data.length || journalFailed) return;
|
|
1160
|
+
const record = job.record, output = record.pty.output;
|
|
1161
|
+
for (let offset = 0; offset < data.length; offset += MAX_PTY_INPUT_BYTES) {
|
|
1162
|
+
output.push({ cursor: ++record.nextCursor, dataBase64: Buffer.from(data.subarray(offset, offset + MAX_PTY_INPUT_BYTES)).toString("base64") });
|
|
1163
|
+
}
|
|
1164
|
+
let bytes = output.reduce((sum, chunk) => sum + Buffer.from(chunk.dataBase64, "base64").length, 0);
|
|
1165
|
+
while (output.length > MAX_JOB_OUTPUT_CHUNKS || bytes > MAX_PTY_OUTPUT_BYTES) {
|
|
1166
|
+
bytes -= Buffer.from(output.shift().dataBase64, "base64").length;
|
|
1167
|
+
record.outputLost = true;
|
|
1168
|
+
}
|
|
1169
|
+
scheduleJobFlush(job);
|
|
1170
|
+
}
|
|
1171
|
+
async function jobSnapshot(job, after = 0) {
|
|
1172
|
+
const record = job.record;
|
|
1173
|
+
const operation = job.operation;
|
|
1174
|
+
let processIdentity = isLiveJob(record) && operation ? operation.processIdentity() : null;
|
|
1175
|
+
if (record.pty && isLiveJob(record) && !processIdentity && operation) {
|
|
1176
|
+
await operation.started;
|
|
1177
|
+
processIdentity = isLiveJob(record) ? operation.processIdentity() : null;
|
|
1178
|
+
// The anchor can exit before its promise publishes the terminal record.
|
|
1179
|
+
// Wait for the owning cleanup proof, not a client retry or a fabricated
|
|
1180
|
+
// running PID. Unexpected identity loss still resolves uncertain.
|
|
1181
|
+
if (!processIdentity) { await operation.settle(); await operation.promise; }
|
|
1182
|
+
}
|
|
1183
|
+
const firstCursor = (record.pty ? record.pty.output : record.output)[0]?.cursor || record.nextCursor + 1;
|
|
1184
|
+
return {
|
|
1185
|
+
jobId: record.jobId, state: record.state, startedAtMs: record.startedAtMs,
|
|
1186
|
+
finishedAtMs: record.finishedAtMs, leaseExpiresAtMs: record.leaseExpiresAtMs,
|
|
1187
|
+
exitCode: record.exitCode, inputSequence: record.inputSequence,
|
|
1188
|
+
firstCursor, nextCursor: record.nextCursor,
|
|
1189
|
+
truncated: after < firstCursor - 1 || (record.outputLost && after === 0),
|
|
1190
|
+
output: record.output.filter((chunk) => chunk.cursor > after),
|
|
1191
|
+
...(record.pty ? { pty: { ...record.pty, output: record.pty.output.filter((chunk) => chunk.cursor > after) } } : {}),
|
|
1192
|
+
...(processIdentity ? { process: processIdentity } : {}),
|
|
1193
|
+
...(record.environmentRevision === undefined ? {} : {
|
|
1194
|
+
environmentRevision: record.environmentRevision, environmentSnapshotId: record.environmentSnapshotId,
|
|
1195
|
+
}),
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
function isPtyJobId(value) { return typeof value === "string" && value.startsWith("pty-") && UUID_RE.test(value.slice(4)); }
|
|
1199
|
+
function validPtyDimensions(input) {
|
|
1200
|
+
return [input.cols, input.rows].every((n) => Number.isSafeInteger(n) && n >= 1 && n <= MAX_PTY_DIMENSION);
|
|
1201
|
+
}
|
|
1202
|
+
function validPtyBytes(value) {
|
|
1203
|
+
if (typeof value !== "string" || !value.length || value.length > Math.ceil(MAX_PTY_INPUT_BYTES / 3) * 4) return false;
|
|
1204
|
+
const bytes = Buffer.from(value, "base64");
|
|
1205
|
+
return bytes.length > 0 && bytes.length <= MAX_PTY_INPUT_BYTES && bytes.toString("base64") === value;
|
|
1206
|
+
}
|
|
1207
|
+
function validJobRequest(value) {
|
|
1208
|
+
if (!hasOnlyKeys(value, ["type", "version", "generation", "id", "jobId", "action", "input"])
|
|
1209
|
+
|| value.type !== "job" || value.version !== VERSION || !Number.isSafeInteger(value.generation) || value.generation < 1
|
|
1210
|
+
|| typeof value.id !== "string" || !ID_RE.test(value.id) || typeof value.jobId !== "string" || !ID_RE.test(value.jobId)
|
|
1211
|
+
|| !hasOnlyKeys(value.input, ["script", "leaseExpiresAtMs", "after", "sequence", "data", "eof", "mode", "cols", "rows", "dataBase64"])) return false;
|
|
1212
|
+
const input = value.input;
|
|
1213
|
+
const validLease = Number.isSafeInteger(input.leaseExpiresAtMs) && input.leaseExpiresAtMs > 0;
|
|
1214
|
+
if (value.action === "start") return input.mode === "pty"
|
|
1215
|
+
? hasOnlyKeys(input, ["mode", "cols", "rows", "leaseExpiresAtMs"]) && isPtyJobId(value.jobId) && validPtyDimensions(input) && validLease
|
|
1216
|
+
: hasOnlyKeys(input, ["script", "leaseExpiresAtMs"]) && typeof input.script === "string" && input.script.length <= MAX_INPUT_CHARS && validLease;
|
|
1217
|
+
if (value.action === "inspect") return hasOnlyKeys(input, ["after"]) && Number.isSafeInteger(input.after) && input.after >= 0;
|
|
1218
|
+
if (value.action === "resize") return hasOnlyKeys(input, ["sequence", "cols", "rows"])
|
|
1219
|
+
&& isPtyJobId(value.jobId) && Number.isSafeInteger(input.sequence) && input.sequence >= 1 && validPtyDimensions(input);
|
|
1220
|
+
if (value.action === "input" && "dataBase64" in input) return hasOnlyKeys(input, ["sequence", "dataBase64"])
|
|
1221
|
+
&& isPtyJobId(value.jobId) && Number.isSafeInteger(input.sequence) && input.sequence >= 1 && validPtyBytes(input.dataBase64);
|
|
1222
|
+
if (value.action === "input") return hasOnlyKeys(input, ["sequence", "data", "eof"])
|
|
1223
|
+
&& Number.isSafeInteger(input.sequence) && input.sequence >= 1 && typeof input.data === "string"
|
|
1224
|
+
&& input.data.length <= MAX_JOB_INPUT_CHARS && typeof input.eof === "boolean";
|
|
1225
|
+
if (value.action === "renew") return hasOnlyKeys(input, ["leaseExpiresAtMs"]) && validLease;
|
|
1226
|
+
return value.action === "cancel" && hasOnlyKeys(input, []);
|
|
1227
|
+
}
|
|
1228
|
+
function currentMaintenance() {
|
|
1229
|
+
if (maintenanceLease && maintenanceLease.expiresAtMs <= Date.now() && active.size === 0) maintenanceLease = null;
|
|
1230
|
+
return maintenanceLease;
|
|
1231
|
+
}
|
|
1232
|
+
function handleLifecycle(target, claim, raw) {
|
|
1233
|
+
let value;
|
|
1234
|
+
try { value = JSON.parse(raw); } catch { return; }
|
|
1235
|
+
if (!hasOnlyKeys(value, ["type", "version", "generation", "id", "action", "leaseId"])
|
|
1236
|
+
|| value.type !== "lifecycle" || value.version !== VERSION || safeId(value.id) === "invalid"
|
|
1237
|
+
|| typeof value.leaseId !== "string" || !UUID_RE.test(value.leaseId)
|
|
1238
|
+
|| !["inspect", "quiesce", "release", "retire"].includes(value.action)) return;
|
|
1239
|
+
// A running managed job also holds a slot in active (so it is cancelled with
|
|
1240
|
+
// the worker) but is counted as a live job. Reporting it as an active request
|
|
1241
|
+
// too made a running dev server look like an in-flight request to callers
|
|
1242
|
+
// that proceed when only live jobs remain. Admission below still uses active.
|
|
1243
|
+
let liveJobs = liveBackgroundGroups(), uncertainJobs = 0, activeJobs = 0;
|
|
1244
|
+
for (const job of jobs.values()) {
|
|
1245
|
+
if (["starting", "running"].includes(job.record.state) || job.operation) liveJobs += 1;
|
|
1246
|
+
if (job.record.state === "uncertain") uncertainJobs += 1;
|
|
1247
|
+
if (active.has(job)) activeJobs += 1;
|
|
1248
|
+
}
|
|
1249
|
+
const held = currentMaintenance();
|
|
1250
|
+
let status = held ? "held" : "idle";
|
|
1251
|
+
if (stopped || value.generation !== claim.generation || claim.generation !== targetGeneration || claim.fenceId !== targetFenceId) status = "fenced";
|
|
1252
|
+
else if (value.action === "retire") {
|
|
1253
|
+
if (!held || held.id !== value.leaseId) status = "fenced";
|
|
1254
|
+
else if (active.size || pendingJobRequests || liveJobs || uncertainJobs || journalFailed || requestJournalFailed || uncertainRequestIds.size) status = "busy";
|
|
1255
|
+
else { stopped = true; status = "retired"; }
|
|
1256
|
+
} else if (value.action === "release") {
|
|
1257
|
+
if (held && held.id === value.leaseId && active.size > 0) status = "busy";
|
|
1258
|
+
else if (held && held.id === value.leaseId) { maintenanceLease = null; status = "released"; }
|
|
1259
|
+
else status = "fenced";
|
|
1260
|
+
} else if (value.action === "quiesce") {
|
|
1261
|
+
if (held && held.id !== value.leaseId) status = "busy";
|
|
1262
|
+
else if (active.size || pendingJobRequests || liveJobs || uncertainJobs || journalFailed || requestJournalFailed || uncertainRequestIds.size) status = "busy";
|
|
1263
|
+
else {
|
|
1264
|
+
maintenanceLease = held || { id: value.leaseId, expiresAtMs: Math.min(config.expiresAtMs, Date.now() + 10 * 60 * 1000) };
|
|
1265
|
+
status = "acquired";
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
sendResult(target, { type: "lifecycle_result", version: VERSION, generation: claim.generation,
|
|
1269
|
+
id: value.id, leaseId: value.leaseId, status, activeRequests: active.size - activeJobs,
|
|
1270
|
+
pendingJobRequests, liveJobs, uncertainJobs, uncertainRequests: uncertainRequestIds.size, expiresAtMs: maintenanceLease ? maintenanceLease.expiresAtMs : null });
|
|
1271
|
+
// Retirement revokes admission synchronously. Give the response a bounded
|
|
1272
|
+
// flush window, then exit cleanly and release the durable journal owner.
|
|
1273
|
+
// A lost acknowledgement is recoverable as worker loss, never a live replay.
|
|
1274
|
+
if (status === "retired") setTimeout(() => { void stop("planned_retirement"); }, 50);
|
|
1275
|
+
}
|
|
1276
|
+
function handleGuestMemory(target, claim, raw) {
|
|
1277
|
+
let v; try { v=JSON.parse(raw); } catch { return; }
|
|
1278
|
+
if (!hasOnlyKeys(v,["type","version","generation","fenceId","id","runId","executorLaunchId"])
|
|
1279
|
+
|| v.type!=="guest_memory" || v.version!==VERSION || safeId(v.id)==="invalid"
|
|
1280
|
+
|| !UUID_RE.test(v.runId) || !UUID_RE.test(v.executorLaunchId) || !UUID_RE.test(v.fenceId)) return;
|
|
1281
|
+
const reply=(status,sample,reasonCode) => sendResult(target,{...v,type:"guest_memory_result",status,observedAtMs:Date.now(),...(sample?{sample}:{}),...(claim.guestMemoryDiagnosticsVersion===1 && reasonCode?{reasonCode}:{})});
|
|
1282
|
+
if(claim.guestMemoryVersion!==1 || process.platform!=="linux" || !config.terminalCleanupRunId) return reply("unsupported");
|
|
1283
|
+
if(stopped || terminalCleanupStarted || target!==socket || v.generation!==claim.generation
|
|
1284
|
+
|| claim.generation!==targetGeneration || claim.fenceId!==targetFenceId || v.fenceId!==claim.fenceId
|
|
1285
|
+
|| v.executorLaunchId!==config.launchId || v.runId!==config.terminalCleanupRunId
|
|
1286
|
+
|| Date.now()>=config.expiresAtMs || currentMaintenance()) return reply("fenced");
|
|
1287
|
+
try { const sample=sampleGuestMemory(fs,path); reply("observed",sample); }
|
|
1288
|
+
catch (error) {
|
|
1289
|
+
const reasonCode=error && typeof error=== "object" && typeof error.reasonCode=== "string"
|
|
1290
|
+
&& GUEST_MEMORY_UNAVAILABLE_REASONS.includes(error.reasonCode) ? error.reasonCode : undefined;
|
|
1291
|
+
reply("unavailable",undefined,reasonCode);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
async function handleTerminalCleanup(target, claim, raw) {
|
|
1295
|
+
let v;
|
|
1296
|
+
try { v = JSON.parse(raw); } catch { return; }
|
|
1297
|
+
if (!hasOnlyKeys(v, ["type", "version", "generation", "id", "runId", "executorLaunchId"])
|
|
1298
|
+
|| v.type !== "terminal_cleanup" || v.version !== VERSION || safeId(v.id) === "invalid"
|
|
1299
|
+
|| !UUID_RE.test(v.runId) || !UUID_RE.test(v.executorLaunchId)) return;
|
|
1300
|
+
const reply = status => sendResult(target, { ...v, type: "terminal_cleanup_result", status, observedAtMs: Date.now() });
|
|
1301
|
+
if (claim.terminalCleanupVersion !== 1 || process.platform !== "linux" || !config.jobEnvSourcePath || !config.terminalCleanupRunId) return reply("unsupported");
|
|
1302
|
+
if (stopped || terminalCleanupStarted || target !== socket || v.generation !== claim.generation
|
|
1303
|
+
|| claim.generation !== targetGeneration || claim.fenceId !== targetFenceId
|
|
1304
|
+
|| v.executorLaunchId !== config.launchId || v.runId !== config.terminalCleanupRunId || Date.now() >= config.expiresAtMs) return reply("fenced");
|
|
1305
|
+
if (currentMaintenance() || active.size || pendingJobRequests || journalFailed || requestJournalFailed || uncertainRequestIds.size
|
|
1306
|
+
|| [...jobs.values()].some(job => job.operation || ["starting", "running", "uncertain"].includes(job.record.state))) return reply("busy");
|
|
1307
|
+
// No new claim, model request, private job or reconnect may race the fixed
|
|
1308
|
+
// privileged operation. This fence is never released, even on failure.
|
|
1309
|
+
terminalCleanupStarted = true;
|
|
1310
|
+
terminalCleanupPromise = (async () => {
|
|
1311
|
+
// The run is over: its retained background commands end before cleanup.
|
|
1312
|
+
if ((await Promise.all(stopBackgroundGroups())).some(Boolean)) { cleanupUnconfirmed = true; return false; }
|
|
1313
|
+
if (!(await terminalParentRequest("begin", claim)) || stopped || target !== socket
|
|
1314
|
+
|| claim.generation !== targetGeneration || claim.fenceId !== targetFenceId || Date.now() >= config.expiresAtMs) {
|
|
1315
|
+
cleanupUnconfirmed = true; return false;
|
|
1316
|
+
}
|
|
1317
|
+
const ok = await new Promise(resolve => {
|
|
1318
|
+
let child, timer, killTimer;
|
|
1319
|
+
const finish = ok => { clearTimeout(timer); clearTimeout(killTimer); if (!ok) cleanupUnconfirmed = true; resolve(ok); };
|
|
1320
|
+
try {
|
|
1321
|
+
child = spawn("/usr/bin/python3", ["-I", "-c", ${JSON.stringify(x)}],
|
|
1322
|
+
{ stdio: ["pipe", "ignore", "ignore"], env: { PATH: "/usr/bin:/bin" }, detached: true });
|
|
1323
|
+
let timedOut = false;
|
|
1324
|
+
timer = setTimeout(() => { timedOut = true; try { process.kill(-child.pid, "SIGTERM"); } catch { cleanupUnconfirmed = true; }
|
|
1325
|
+
killTimer = setTimeout(() => { try { process.kill(-child.pid, "SIGKILL"); } catch { cleanupUnconfirmed = true; } }, 500); }, 10_000);
|
|
1326
|
+
child.once("error", () => finish(false));
|
|
1327
|
+
child.once("close", code => finish(!timedOut && code === 0));
|
|
1328
|
+
child.stdin.on("error", () => {});
|
|
1329
|
+
child.stdin.end(JSON.stringify({ root: config.workRoot, source: config.jobEnvSourcePath,
|
|
1330
|
+
snapshot: config.jobEnvPath, runId: config.terminalCleanupRunId }));
|
|
1331
|
+
} catch { finish(false); }
|
|
1332
|
+
});
|
|
1333
|
+
const accepted = ok && !stopped && target === socket && claim.generation === targetGeneration
|
|
1334
|
+
&& claim.fenceId === targetFenceId && await terminalParentRequest("cleaned", claim);
|
|
1335
|
+
if (!accepted) cleanupUnconfirmed = true;
|
|
1336
|
+
return accepted;
|
|
1337
|
+
})();
|
|
1338
|
+
const parentAccepted = await terminalCleanupPromise;
|
|
1339
|
+
reply(parentAccepted && target === socket && claim.generation === targetGeneration && claim.fenceId === targetFenceId ? "cleaned" : "unconfirmed");
|
|
1340
|
+
}
|
|
1341
|
+
function validJobLease(deadline) {
|
|
1342
|
+
return deadline > Date.now() && deadline <= config.expiresAtMs && deadline <= Date.now() + 12 * 60 * 60 * 1000;
|
|
1343
|
+
}
|
|
1344
|
+
async function handleJob(claim, raw, sendReply, isCurrent, fromControl = false) {
|
|
1345
|
+
let request;
|
|
1346
|
+
try { request = JSON.parse(raw); } catch { return; }
|
|
1347
|
+
const reply = (body) => {
|
|
1348
|
+
if (!isCurrent() || claim.generation !== targetGeneration || claim.fenceId !== targetFenceId) return;
|
|
1349
|
+
sendReply({ type: "job_result", version: VERSION, generation: claim.generation, id: safeId(request.id), ...body });
|
|
1350
|
+
};
|
|
1351
|
+
const fail = (error) => reply({ ok: false, error });
|
|
1352
|
+
if (!isCurrent() || terminalCleanupStarted || stopped || claim.generation !== targetGeneration || claim.fenceId !== targetFenceId) return;
|
|
1353
|
+
if (!validJobRequest(request)) return fail("invalid_job_request");
|
|
1354
|
+
if (validPrivateJob && (request.jobId !== config.directShell.jobId
|
|
1355
|
+
|| !["start", "inspect", "cancel"].includes(request.action) || request.input.mode !== undefined)) return fail("invalid_job_request");
|
|
1356
|
+
if (request.generation !== claim.generation) return fail("stale_generation");
|
|
1357
|
+
if (!config.managedJobs || !allowedTools.includes("bash")) return fail("jobs_disabled");
|
|
1358
|
+
if (journalFailed) return fail("job_journal_unavailable");
|
|
1359
|
+
if (currentMaintenance() && !["inspect", "poll", "cancel"].includes(request.action)) return fail("maintenance_held");
|
|
1360
|
+
if (pendingJobRequests >= 128) return fail("job_capacity");
|
|
1361
|
+
pendingJobRequests += 1;
|
|
1362
|
+
let job = jobs.get(request.jobId);
|
|
1363
|
+
try {
|
|
1364
|
+
if (request.action === "start") {
|
|
1365
|
+
const pty = request.input.mode === "pty";
|
|
1366
|
+
if (pty && !ptySupported()) return fail("pty_unsupported");
|
|
1367
|
+
if (!pty && request.input.script.includes("\0")) return fail("invalid_job_request");
|
|
1368
|
+
const hash = digest(pty ? JSON.stringify(["pty", request.input.cols, request.input.rows]) : request.input.script);
|
|
1369
|
+
if (job) {
|
|
1370
|
+
if (Boolean(job.record.pty) !== pty) return fail("job_mode_mismatch");
|
|
1371
|
+
if (job.record.digest !== hash) return fail("job_conflict");
|
|
1372
|
+
// A duplicate start observes the original admission, including its
|
|
1373
|
+
// consumed environment receipt; "starting" is only an inspect result.
|
|
1374
|
+
if (job.operation) await job.operation.started;
|
|
1375
|
+
} else {
|
|
1376
|
+
if (!validJobLease(request.input.leaseExpiresAtMs)) return fail("job_lease_expired");
|
|
1377
|
+
if (!hasJobCapacity()) return fail("job_capacity");
|
|
1378
|
+
// Chosen before the durable record, so nothing can cancel a job whose spawn is pending.
|
|
1379
|
+
const terminal = pty ? await terminalProgram() : null;
|
|
1380
|
+
if (jobs.has(request.jobId) || !hasJobCapacity()) {
|
|
1381
|
+
if (terminal) terminal.release();
|
|
1382
|
+
return fail(jobs.has(request.jobId) ? "job_conflict" : "job_capacity");
|
|
1383
|
+
}
|
|
1384
|
+
job = { record: {
|
|
1385
|
+
schema: 1, jobId: request.jobId, digest: hash, state: "starting", startedAtMs: Date.now(),
|
|
1386
|
+
finishedAtMs: null, exitCode: null, leaseExpiresAtMs: request.input.leaseExpiresAtMs,
|
|
1387
|
+
inputSequence: 0, inputDigest: null, output: [], nextCursor: 0, outputLost: false,
|
|
1388
|
+
...(pty ? { pty: { version: 1, cols: request.input.cols, rows: request.input.rows, output: [], cleanup: "pending" } } : {}),
|
|
1389
|
+
}, operation: null, flushTimer: null, journalBytes: 0 };
|
|
1390
|
+
jobs.set(request.jobId, job);
|
|
1391
|
+
persistJob(job); // Durable intent before any process is spawned.
|
|
1392
|
+
if (!validJobLease(job.record.leaseExpiresAtMs)) {
|
|
1393
|
+
job.record.state = "expired";
|
|
1394
|
+
job.record.finishedAtMs = Date.now();
|
|
1395
|
+
job.record.exitCode = 124;
|
|
1396
|
+
if (pty) job.record.pty.cleanup = "confirmed";
|
|
1397
|
+
if (terminal) terminal.release();
|
|
1398
|
+
persistJob(job);
|
|
1399
|
+
return fail("job_lease_expired");
|
|
1400
|
+
}
|
|
1401
|
+
const managed = {
|
|
1402
|
+
// A job a person started over the control socket runs as the payload account (config.jobsUser).
|
|
1403
|
+
asJobsUser: fromControl && Boolean(config.jobsUser),
|
|
1404
|
+
leaseExpiresAtMs: job.record.leaseExpiresAtMs,
|
|
1405
|
+
onOutput: (stream, text) => appendJobOutput(job, stream, text),
|
|
1406
|
+
onBytes: (bytes) => appendPtyOutput(job, bytes),
|
|
1407
|
+
onEnvironment: (environment) => {
|
|
1408
|
+
job.record.environmentRevision = environment.revision;
|
|
1409
|
+
job.record.environmentSnapshotId = environment.snapshot_id;
|
|
1410
|
+
persistJob(job);
|
|
1411
|
+
},
|
|
1412
|
+
};
|
|
1413
|
+
const operation = pty ? ptyOperation(request, managed, terminal.program)
|
|
1414
|
+
: bashOperation({ id: request.jobId, input: { script: request.input.script } }, null, managed);
|
|
1415
|
+
job.operation = operation;
|
|
1416
|
+
active.set(job, operation);
|
|
1417
|
+
void operation.promise.then((result) => {
|
|
1418
|
+
if (terminal) terminal.release();
|
|
1419
|
+
active.delete(job);
|
|
1420
|
+
job.operation = null;
|
|
1421
|
+
clearTimeout(job.flushTimer);
|
|
1422
|
+
job.flushTimer = null;
|
|
1423
|
+
if (job.record.state !== "uncertain") {
|
|
1424
|
+
job.record.state = result.error === "timeout" ? "expired"
|
|
1425
|
+
: result.error === "cancelled" ? "cancelled"
|
|
1426
|
+
: result.error === "process_group_cleanup_unconfirmed" ? "uncertain" : "exited";
|
|
1427
|
+
job.record.exitCode = job.record.state === "uncertain" ? null : result.exitCode;
|
|
1428
|
+
}
|
|
1429
|
+
job.record.finishedAtMs = Date.now();
|
|
1430
|
+
if (job.record.pty) job.record.pty.cleanup = job.record.state === "uncertain" ? "unconfirmed" : "confirmed";
|
|
1431
|
+
try { persistJob(job); } catch { /* remains uncertain on durable failure */ }
|
|
1432
|
+
});
|
|
1433
|
+
if (await operation.started && job.record.state === "starting") job.record.state = "running";
|
|
1434
|
+
persistJob(job);
|
|
1435
|
+
}
|
|
1436
|
+
} else if (!job) return fail("job_not_found");
|
|
1437
|
+
else if (request.action === "cancel") {
|
|
1438
|
+
if (job.operation && isLiveJob(job.record)) {
|
|
1439
|
+
const operation = job.operation;
|
|
1440
|
+
await operation.cancel();
|
|
1441
|
+
await operation.promise;
|
|
1442
|
+
}
|
|
1443
|
+
} else if (request.action === "input" || request.action === "resize") {
|
|
1444
|
+
const input = request.input;
|
|
1445
|
+
const pty = request.action === "resize" || "dataBase64" in input;
|
|
1446
|
+
if (Boolean(job.record.pty) !== pty) return fail("job_mode_mismatch");
|
|
1447
|
+
const hash = digest(JSON.stringify(pty ? [request.action, input.dataBase64, input.cols, input.rows] : [input.data, input.eof]));
|
|
1448
|
+
const data = pty ? Buffer.from(input.dataBase64 || "", "base64") : input.data;
|
|
1449
|
+
if (input.sequence === job.record.inputSequence && hash === job.record.inputDigest) {
|
|
1450
|
+
// Duplicate intent: returning state is not a promise that stdin was consumed.
|
|
1451
|
+
} else {
|
|
1452
|
+
if (!job.operation || !isLiveJob(job.record)) return fail("job_not_running");
|
|
1453
|
+
if (Date.now() >= job.record.leaseExpiresAtMs) return fail("job_lease_expired");
|
|
1454
|
+
if (input.sequence !== job.record.inputSequence + 1) return fail("job_input_sequence");
|
|
1455
|
+
const inputError = job.operation.canInput(data);
|
|
1456
|
+
if (inputError) return fail(inputError);
|
|
1457
|
+
job.record.inputSequence = input.sequence;
|
|
1458
|
+
job.record.inputDigest = hash;
|
|
1459
|
+
persistJob(job, false); // Record accepted intent before stdin effects; never replay on recovery.
|
|
1460
|
+
const error = request.action === "resize" ? job.operation.resize(input.cols, input.rows) : job.operation.input(data, input.eof);
|
|
1461
|
+
if (error) return fail(error);
|
|
1462
|
+
if (request.action === "resize") { job.record.pty.cols = input.cols; job.record.pty.rows = input.rows; }
|
|
1463
|
+
}
|
|
1464
|
+
} else if (request.action === "renew") {
|
|
1465
|
+
if (!job.operation || !isLiveJob(job.record)) return fail("job_not_running");
|
|
1466
|
+
// Preparation holds its own immutable deadline. Do not acknowledge a
|
|
1467
|
+
// pipe-job extension until that authority has admitted the child.
|
|
1468
|
+
if (!job.record.pty && job.record.state !== "running") return fail("job_not_running");
|
|
1469
|
+
if (Date.now() >= job.record.leaseExpiresAtMs || !validJobLease(request.input.leaseExpiresAtMs)) return fail("job_lease_expired");
|
|
1470
|
+
// Retried or out-of-order renewals never shorten an already accepted lease.
|
|
1471
|
+
job.record.leaseExpiresAtMs = Math.max(job.record.leaseExpiresAtMs, request.input.leaseExpiresAtMs);
|
|
1472
|
+
persistJob(job);
|
|
1473
|
+
if (!job.operation.renew(job.record.leaseExpiresAtMs)) return fail("job_not_running");
|
|
1474
|
+
}
|
|
1475
|
+
const snapshot = await jobSnapshot(job, request.action === "inspect" ? request.input.after : 0);
|
|
1476
|
+
persistJob(job, false); // The acknowledged output cursor survives a worker restart.
|
|
1477
|
+
reply({ ok: true, job: snapshot });
|
|
1478
|
+
} catch { fail(journalFailed ? "job_journal_unavailable" : "job_not_running"); }
|
|
1479
|
+
finally { pendingJobRequests -= 1; }
|
|
1480
|
+
}
|
|
1481
|
+
function startJobControl() {
|
|
1482
|
+
if (!config.jobControlSocketPath) return Promise.resolve();
|
|
1483
|
+
return new Promise((resolve, reject) => {
|
|
1484
|
+
const directory = path.dirname(config.jobControlSocketPath);
|
|
1485
|
+
const stat = fs.lstatSync(directory);
|
|
1486
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid()
|
|
1487
|
+
|| (stat.mode & 0o777) !== 0o700 || fs.realpathSync(directory) !== directory
|
|
1488
|
+
|| path.join(directory, "control.sock") !== config.jobControlSocketPath) throw new Error("invalid_job_control_directory");
|
|
1489
|
+
for (const root of [config.repoPath, config.workRoot]) {
|
|
1490
|
+
const canonical = fs.realpathSync(root);
|
|
1491
|
+
if (directory === canonical || directory.startsWith(canonical + path.sep) || canonical.startsWith(directory + path.sep)) {
|
|
1492
|
+
throw new Error("job_control_overlaps_workspace");
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
// lstat also rejects dangling symlinks; never unlink or adopt an existing
|
|
1496
|
+
// endpoint. An exclusive per-launch directory is composition-owned.
|
|
1497
|
+
try { fs.lstatSync(config.jobControlSocketPath); throw new Error("job_control_path_exists"); }
|
|
1498
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
1499
|
+
// Bind before connecting the relay so a duplicate/stale socket cannot leave
|
|
1500
|
+
// a second serving worker. Single framed request per connection, bounded
|
|
1501
|
+
// clients/body/time; the existing job handler owns all mutations.
|
|
1502
|
+
const server = net.createServer((client) => {
|
|
1503
|
+
jobControlClients.add(client);
|
|
1504
|
+
client.once("close", () => jobControlClients.delete(client));
|
|
1505
|
+
client.on("error", () => {});
|
|
1506
|
+
client.setTimeout(10000, () => client.destroy());
|
|
1507
|
+
client.setEncoding("utf8");
|
|
1508
|
+
let raw = "";
|
|
1509
|
+
let received = false;
|
|
1510
|
+
client.on("data", (chunk) => {
|
|
1511
|
+
if (received) { client.destroy(); return; }
|
|
1512
|
+
raw += chunk;
|
|
1513
|
+
if (Buffer.byteLength(raw) > MAX_JOB_CONTROL_FRAME_BYTES) { client.destroy(); return; }
|
|
1514
|
+
const end = raw.indexOf("\n");
|
|
1515
|
+
if (end < 0) return;
|
|
1516
|
+
received = true;
|
|
1517
|
+
if (end !== raw.length - 1) { client.destroy(); return; }
|
|
1518
|
+
let envelope;
|
|
1519
|
+
try { envelope = JSON.parse(raw.slice(0, end)); } catch { client.destroy(); return; }
|
|
1520
|
+
if (!hasOnlyKeys(envelope, ["version", "scopeId", "fenceId", "executorLaunchId", "request"])
|
|
1521
|
+
|| envelope.version !== 1 || envelope.scopeId !== config.managedJobs.scopeId
|
|
1522
|
+
|| envelope.fenceId !== targetFenceId || envelope.executorLaunchId !== config.launchId
|
|
1523
|
+
|| !validJobRequest(envelope.request) || envelope.request.generation !== targetGeneration || stopped) {
|
|
1524
|
+
client.destroy(); return;
|
|
1525
|
+
}
|
|
1526
|
+
void handleJob({ generation: envelope.request.generation, fenceId: envelope.fenceId }, JSON.stringify(envelope.request),
|
|
1527
|
+
(result) => client.end(JSON.stringify(result) + "\n"), () => !client.destroyed, true);
|
|
1528
|
+
});
|
|
1529
|
+
});
|
|
1530
|
+
server.maxConnections = 16;
|
|
1531
|
+
server.once("error", reject);
|
|
1532
|
+
server.listen(config.jobControlSocketPath, () => {
|
|
1533
|
+
try { fs.chmodSync(config.jobControlSocketPath, 0o600); }
|
|
1534
|
+
catch (error) { server.close(); reject(error); return; }
|
|
1535
|
+
jobControlServer = server;
|
|
1536
|
+
resolve();
|
|
1537
|
+
});
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
async function handleRequestCancel(target, claim, raw) {
|
|
1541
|
+
let request;
|
|
1542
|
+
try { request = JSON.parse(raw); } catch { return; }
|
|
1543
|
+
if (!hasOnlyKeys(request, ["type", "version", "generation", "id", "targetId"])
|
|
1544
|
+
|| request.type !== "cancel_request" || request.version !== VERSION || request.generation !== claim.generation
|
|
1545
|
+
|| typeof request.id !== "string" || !ID_RE.test(request.id)
|
|
1546
|
+
|| typeof request.targetId !== "string" || !ID_RE.test(request.targetId)) {
|
|
1547
|
+
closeSocket(target, "invalid_cancel_request"); return;
|
|
1548
|
+
}
|
|
1549
|
+
const operation = active.get(request.targetId);
|
|
1550
|
+
let status = ledger.has(request.targetId) ? "settled" : "not_found";
|
|
1551
|
+
if (operation) {
|
|
1552
|
+
// The CLI owns REPL child termination on socket abort. Closing that RPC
|
|
1553
|
+
// requests reset but cannot itself attest child death over a closed socket.
|
|
1554
|
+
status = operation.supportsCancellation ? "cancelled" : "unsupported";
|
|
1555
|
+
if (operation.supportsCancellation) {
|
|
1556
|
+
try {
|
|
1557
|
+
await operation.cancel(true);
|
|
1558
|
+
const result = await operation.promise;
|
|
1559
|
+
if (result.error === "process_group_cleanup_unconfirmed") status = "unconfirmed";
|
|
1560
|
+
else if (operation.cancellationRequiresExternalProof) status = "unconfirmed";
|
|
1561
|
+
else if (result.error !== "cancelled") status = "settled";
|
|
1562
|
+
} catch { status = "unconfirmed"; }
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
if (target === socket && claim.generation === targetGeneration && claim.fenceId === targetFenceId) {
|
|
1566
|
+
sendResult(target, { type: "cancel_ack", version: VERSION, generation: claim.generation,
|
|
1567
|
+
id: request.id, targetId: request.targetId, status });
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
function processGroupAbsent(pgid) {
|
|
1571
|
+
try {
|
|
1572
|
+
process.kill(-pgid, 0);
|
|
1573
|
+
if (process.platform === "linux") {
|
|
1574
|
+
try {
|
|
1575
|
+
const entries = fs.readdirSync("/proc");
|
|
1576
|
+
for (let i = 0; i < entries.length; i++) {
|
|
1577
|
+
const entry = entries[i];
|
|
1578
|
+
if (entry.charCodeAt(0) < 48 || entry.charCodeAt(0) > 57) continue;
|
|
1579
|
+
try {
|
|
1580
|
+
const stat = fs.readFileSync("/proc/" + entry + "/stat", "utf8");
|
|
1581
|
+
const closeParen = stat.lastIndexOf(")");
|
|
1582
|
+
if (closeParen < 0) continue;
|
|
1583
|
+
const fields = stat.slice(closeParen + 2).split(" ");
|
|
1584
|
+
const state = fields[0];
|
|
1585
|
+
const pgrp = Number(fields[2]);
|
|
1586
|
+
if (pgrp === pgid && state !== "Z") {
|
|
1587
|
+
return false;
|
|
1588
|
+
}
|
|
1589
|
+
} catch {
|
|
1590
|
+
// Process exited between readdir and stat read
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
return true;
|
|
1594
|
+
} catch {
|
|
1595
|
+
// Fallback to process.kill result
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
return false;
|
|
1599
|
+
} catch (error) {
|
|
1600
|
+
return Boolean(error && error.code === "ESRCH");
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
function waitForProcessGroupAbsent(pgid, timeoutMs) {
|
|
1604
|
+
const deadline = Date.now() + timeoutMs;
|
|
1605
|
+
return new Promise((resolve) => {
|
|
1606
|
+
const inspect = () => {
|
|
1607
|
+
if (processGroupAbsent(pgid)) {
|
|
1608
|
+
resolve(true);
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
if (Date.now() >= deadline) {
|
|
1612
|
+
resolve(false);
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
// This referenced timer is part of the worker's exact stop proof. The
|
|
1616
|
+
// worker must remain alive until every tracked process group is absent.
|
|
1617
|
+
setTimeout(inspect, 20);
|
|
1618
|
+
};
|
|
1619
|
+
inspect();
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
async function killGroup(child) {
|
|
1623
|
+
if (!child || !Number.isSafeInteger(child.pid)) return;
|
|
1624
|
+
const pgid = child.pid;
|
|
1625
|
+
if (processGroupAbsent(pgid)) return;
|
|
1626
|
+
try { process.kill(-pgid, "SIGTERM"); } catch (error) {
|
|
1627
|
+
if (error && error.code !== "ESRCH") throw error;
|
|
1628
|
+
}
|
|
1629
|
+
if (await waitForProcessGroupAbsent(pgid, 500)) return;
|
|
1630
|
+
try { process.kill(-pgid, "SIGKILL"); } catch (error) {
|
|
1631
|
+
if (error && error.code !== "ESRCH") throw error;
|
|
1632
|
+
}
|
|
1633
|
+
if (await waitForProcessGroupAbsent(pgid, 2_000)) return;
|
|
1634
|
+
throw new Error("process_group_cleanup_unconfirmed");
|
|
1635
|
+
}
|
|
1636
|
+
// A retainBackground Bash request or Pi shell settles when its command exits; descendants
|
|
1637
|
+
// still in its process group keep running and stay tracked here. A group ID
|
|
1638
|
+
// cannot be reused while any member lives, and the group is forgotten once
|
|
1639
|
+
// empty (polled every 500 ms, and when a Bash leader is spawned with that PID).
|
|
1640
|
+
function liveBackgroundGroups() {
|
|
1641
|
+
for (const pgid of backgroundGroups.keys()) if (processGroupAbsent(pgid)) backgroundGroups.delete(pgid);
|
|
1642
|
+
if (!backgroundGroups.size && backgroundGroupTimer) { clearInterval(backgroundGroupTimer); backgroundGroupTimer = null; }
|
|
1643
|
+
return backgroundGroups.size;
|
|
1644
|
+
}
|
|
1645
|
+
function retainBackgroundGroup(child) {
|
|
1646
|
+
if (processGroupAbsent(child.pid)) return;
|
|
1647
|
+
backgroundGroups.set(child.pid, child);
|
|
1648
|
+
if (!backgroundGroupTimer) {
|
|
1649
|
+
backgroundGroupTimer = setInterval(liveBackgroundGroups, 500);
|
|
1650
|
+
backgroundGroupTimer.unref();
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
function stopBackgroundGroups() {
|
|
1654
|
+
const groups = [...backgroundGroups.values()];
|
|
1655
|
+
backgroundGroups.clear();
|
|
1656
|
+
return groups.map((child) => killGroup(child).then(() => null,
|
|
1657
|
+
() => ({ error: "process_group_cleanup_unconfirmed" })));
|
|
1658
|
+
}
|
|
1659
|
+
// reason-sdk (runtime/reason-v7-sync-yield-machine-sdk-v1): Reason's SDK as a
|
|
1660
|
+
// command on this machine, composable in shell. A Bash request sent with
|
|
1661
|
+
// machineSdk gets a private Unix socket for as long as the request runs; the
|
|
1662
|
+
// reason-sdk shim on its PATH sends each call there, and the worker forwards it
|
|
1663
|
+
// to the Brain as an sdk_request bound to that request's id. The Brain runs the
|
|
1664
|
+
// call and answers; no Reason credential is ever on the machine. Once the
|
|
1665
|
+
// request settles its socket is gone, so a process it left running in the
|
|
1666
|
+
// background cannot call Reason.
|
|
1667
|
+
const SDK_USAGE = [
|
|
1668
|
+
"reason-sdk search WORDS [--namespace NAME] [--limit N]",
|
|
1669
|
+
"reason-sdk namespace NAME",
|
|
1670
|
+
"reason-sdk describe NAME",
|
|
1671
|
+
"reason-sdk call NAME [--json '{...}' | --json -]",
|
|
1672
|
+
].join("\n");
|
|
1673
|
+
const SDK_NOT_LIVE = "reason-sdk: unavailable: it works only while the Bash call that ran it is running (within its first 60 s), and that call has ended.";
|
|
1674
|
+
const SDK_DISCONNECTED = "reason-sdk: unavailable: Reason is not connected to this machine right now. Try again.";
|
|
1675
|
+
const SDK_MAX_LINE_CHARS = 4 * MAX_INPUT_CHARS;
|
|
1676
|
+
const SDK_MAX_PENDING = 64;
|
|
1677
|
+
// The shim's socket client: macOS's own Perl (its nc exits at the end of its
|
|
1678
|
+
// input, before the answer), or this worker's runtime on Linux (the compiled CLI
|
|
1679
|
+
// runs as Bun there, as the Linux PTY launcher does). Either sends the shim's
|
|
1680
|
+
// line, prints the answer and prints nothing when the socket is gone.
|
|
1681
|
+
const SDK_RUNTIME_CLIENT = 'const s=require("node:net").createConnection(process.env.REASON_SDK_SOCKET);'
|
|
1682
|
+
+ 'const c=[];process.stdin.on("data",d=>c.push(d));process.stdin.on("end",()=>s.write(Buffer.concat(c)));'
|
|
1683
|
+
+ 's.pipe(process.stdout);s.on("error",()=>process.exit(0));';
|
|
1684
|
+
const SDK_PERL_CLIENT = 'use IO::Socket::UNIX;my $s=IO::Socket::UNIX->new(Type=>SOCK_STREAM(),Peer=>$ENV{REASON_SDK_SOCKET}) or exit 0;'
|
|
1685
|
+
+ 'local $/;my $in=<STDIN>;print $s $in;$s->flush;my $b;while(sysread($s,$b,65536)){syswrite(STDOUT,$b)}';
|
|
1686
|
+
function shellQuote(value) { return "'" + String(value).replace(/'/g, "'\\''") + "'"; }
|
|
1687
|
+
function machineSdkClientCommand() {
|
|
1688
|
+
if (process.platform === "darwin") return fs.existsSync("/usr/bin/perl") ? "/usr/bin/perl -e " + shellQuote(SDK_PERL_CLIENT) : null;
|
|
1689
|
+
if (process.platform !== "linux") return null;
|
|
1690
|
+
return typeof Bun === "undefined"
|
|
1691
|
+
? shellQuote(process.execPath) + " -e " + shellQuote(SDK_RUNTIME_CLIENT)
|
|
1692
|
+
: "env " + ["BUN", "BE", "BUN"].join("_") + "=1 " + shellQuote(process.execPath)
|
|
1693
|
+
+ " --no-env-file --config=/dev/null -e " + shellQuote(SDK_RUNTIME_CLIENT);
|
|
1694
|
+
}
|
|
1695
|
+
function machineSdkShim(client) {
|
|
1696
|
+
return [
|
|
1697
|
+
"#!/bin/bash",
|
|
1698
|
+
"# reason-sdk: Reason's tools from this machine, while the Bash call that runs it is running.",
|
|
1699
|
+
'socket="' + "$" + '{REASON_SDK_SOCKET:-}"',
|
|
1700
|
+
'if [ -z "$socket" ] || [ ! -S "$socket" ]; then',
|
|
1701
|
+
" printf '%s\\n' " + shellQuote(SDK_NOT_LIVE) + " >&2",
|
|
1702
|
+
" exit 69",
|
|
1703
|
+
"fi",
|
|
1704
|
+
"stdin=-",
|
|
1705
|
+
"previous=",
|
|
1706
|
+
'for arg in "$@"; do',
|
|
1707
|
+
' if { [ "$previous" = --json ] && [ "$arg" = - ]; } || [ "$arg" = --json=- ]; then stdin="a$(base64 | tr -d \'\\n\')"; fi',
|
|
1708
|
+
' previous=$arg',
|
|
1709
|
+
"done",
|
|
1710
|
+
'line="v1 $stdin"',
|
|
1711
|
+
'for arg in "$@"; do line="$line a$(printf %s "$arg" | base64 | tr -d \'\\n\')"; done',
|
|
1712
|
+
"printf '%s\\n' \"$line\" | " + client + " | {",
|
|
1713
|
+
" IFS= read -r status || { printf '%s\\n' " + shellQuote(SDK_NOT_LIVE) + " >&2; exit 69; }",
|
|
1714
|
+
' case "$status" in',
|
|
1715
|
+
" 0) cat ;;",
|
|
1716
|
+
' 1|2|69|75) cat >&2; exit "$status" ;;',
|
|
1717
|
+
" *) printf '%s\\n' 'reason-sdk: invalid reply.' >&2; exit 75 ;;",
|
|
1718
|
+
" esac",
|
|
1719
|
+
"}",
|
|
1720
|
+
"",
|
|
1721
|
+
].join("\n");
|
|
1722
|
+
}
|
|
1723
|
+
// Asked at every claim, so it touches nothing on disk: the private directory
|
|
1724
|
+
// and shim are created by the first machineSdk request (machineSdkBase).
|
|
1725
|
+
function machineSdkAvailable() {
|
|
1726
|
+
if (!allowedTools.includes("bash") || validPrivateJob || config.spawnPolicy || config.bashCommandPrefix
|
|
1727
|
+
|| localGithubSandbox || !machineSdkClientCommand()) return false;
|
|
1728
|
+
return machineSdkTemporaryRoot() !== null;
|
|
1729
|
+
}
|
|
1730
|
+
// Where the private directory goes: the worker's temporary directory when a
|
|
1731
|
+
// socket path under it fits the Unix limit, else /tmp. A headless worker's
|
|
1732
|
+
// TMPDIR is a per-launch directory under its state directory, which commonly
|
|
1733
|
+
// exceeds the limit (as the managed-job control socket's does).
|
|
1734
|
+
function machineSdkTemporaryRoot() {
|
|
1735
|
+
for (const candidate of [require("node:os").tmpdir(), "/tmp"]) {
|
|
1736
|
+
try {
|
|
1737
|
+
const root = fs.realpathSync(candidate);
|
|
1738
|
+
if (fs.statSync(root).isDirectory() && Buffer.byteLength(path.join(root, "rsdk-XXXXXX", "00000000.sock")) <= 100) return root;
|
|
1739
|
+
} catch (error) { void error; }
|
|
1740
|
+
}
|
|
1741
|
+
return null;
|
|
1742
|
+
}
|
|
1743
|
+
// One private directory per worker (owner-only, outside the workspace), created
|
|
1744
|
+
// by the first machineSdk request: the shim, and a socket per live machineSdk
|
|
1745
|
+
// request. Removed when the worker stops.
|
|
1746
|
+
function machineSdkBase() {
|
|
1747
|
+
if (sdkBase) return sdkBase;
|
|
1748
|
+
const client = machineSdkClientCommand();
|
|
1749
|
+
const temporaryRoot = machineSdkTemporaryRoot();
|
|
1750
|
+
if (!client || !temporaryRoot) throw new Error("machine_sdk_unavailable");
|
|
1751
|
+
const root = fs.mkdtempSync(path.join(temporaryRoot, "rsdk-"));
|
|
1752
|
+
try {
|
|
1753
|
+
fs.chmodSync(root, 0o700);
|
|
1754
|
+
const bin = path.join(root, "bin");
|
|
1755
|
+
fs.mkdirSync(bin, { mode: 0o700 });
|
|
1756
|
+
fs.writeFileSync(path.join(bin, "reason-sdk"), machineSdkShim(client), { mode: 0o700, flag: "wx" });
|
|
1757
|
+
if (Buffer.byteLength(path.join(root, "00000000.sock")) > 100) throw new Error("machine_sdk_path_too_long");
|
|
1758
|
+
sdkBase = { root, bin };
|
|
1759
|
+
return sdkBase;
|
|
1760
|
+
} catch (error) {
|
|
1761
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
1762
|
+
throw error;
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
function removeMachineSdk() {
|
|
1766
|
+
for (const endpoint of [...sdkEndpoints]) endpoint.close();
|
|
1767
|
+
if (!sdkBase) return;
|
|
1768
|
+
const root = sdkBase.root;
|
|
1769
|
+
sdkBase = null;
|
|
1770
|
+
// Best effort while stopping: a leftover private directory holds no live socket.
|
|
1771
|
+
try { fs.rmSync(root, { recursive: true, force: true }); } catch (error) { void error; }
|
|
1772
|
+
}
|
|
1773
|
+
function failSdkCalls(target, message) {
|
|
1774
|
+
for (const [id, entry] of sdkPending) {
|
|
1775
|
+
if (target !== null && entry.target !== target) continue;
|
|
1776
|
+
sdkPending.delete(id);
|
|
1777
|
+
entry.reply(69, message);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
// The shim's line: "v1", its stdin (only for --json -) and each argument, each
|
|
1781
|
+
// "a" + base64 or "-". The command is parsed here, so a usage error never
|
|
1782
|
+
// reaches the Brain; a call is forwarded only in a shape the Brain accepts.
|
|
1783
|
+
function machineSdkCall(line) {
|
|
1784
|
+
const tokens = line.split(" ");
|
|
1785
|
+
const decode = (token) => /^a[A-Za-z0-9+/]*={0,2}$/.test(token) ? Buffer.from(token.slice(1), "base64").toString("utf8") : null;
|
|
1786
|
+
if (tokens.length < 2 || tokens[0] !== "v1" || (tokens[1] !== "-" && decode(tokens[1]) === null)) return { error: "reason-sdk: invalid request." };
|
|
1787
|
+
const stdin = tokens[1] === "-" ? null : decode(tokens[1]);
|
|
1788
|
+
const argv = tokens.slice(2).map(decode);
|
|
1789
|
+
if (argv.some((arg) => arg === null)) return { error: "reason-sdk: invalid request." };
|
|
1790
|
+
const usage = (message) => ({ error: "reason-sdk: " + message + "\nUsage:\n" + SDK_USAGE });
|
|
1791
|
+
const validName = (name) => typeof name === "string" && name.length >= 1 && name.length <= 200 && !name.includes("\0");
|
|
1792
|
+
const [verb, ...rest] = argv;
|
|
1793
|
+
if (verb === undefined || ((verb === "help" || verb === "--help" || verb === "-h") && rest.length === 0)) return { call: { verb: "help" } };
|
|
1794
|
+
if (verb === "search") {
|
|
1795
|
+
const words = [];
|
|
1796
|
+
const args = {};
|
|
1797
|
+
for (let index = 0; index < rest.length;) {
|
|
1798
|
+
const word = rest[index];
|
|
1799
|
+
const flag = ["--namespace", "--limit"].find((name) => word === name || word.startsWith(name + "="));
|
|
1800
|
+
if (!flag) { words.push(word); index += 1; continue; }
|
|
1801
|
+
let value;
|
|
1802
|
+
if (word === flag) {
|
|
1803
|
+
if (index + 1 >= rest.length) return usage(flag + " needs a value.");
|
|
1804
|
+
value = rest[index + 1]; index += 2;
|
|
1805
|
+
} else { value = word.slice(flag.length + 1); index += 1; }
|
|
1806
|
+
if (flag === "--namespace") {
|
|
1807
|
+
if (!validName(value)) return usage("--namespace takes one name.");
|
|
1808
|
+
args.namespace = value;
|
|
1809
|
+
} else {
|
|
1810
|
+
const limit = Number(value);
|
|
1811
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 50) return usage("--limit takes a whole number from 1 to 50.");
|
|
1812
|
+
args.limit = limit;
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
const query = words.join(" ");
|
|
1816
|
+
if (!query.trim()) return usage("search needs words to search for.");
|
|
1817
|
+
if (query.length > 2000) return usage("the search words are too long.");
|
|
1818
|
+
return { call: { verb: "search", query, ...(Object.keys(args).length ? { args } : {}) } };
|
|
1819
|
+
}
|
|
1820
|
+
if (verb === "describe" || verb === "namespace") {
|
|
1821
|
+
if (rest.length !== 1 || !validName(rest[0])) return usage(verb + " takes exactly one name.");
|
|
1822
|
+
return { call: { verb, name: rest[0] } };
|
|
1823
|
+
}
|
|
1824
|
+
if (verb !== "call") return usage("unknown command " + JSON.stringify(verb) + ".");
|
|
1825
|
+
const [name, ...options] = rest;
|
|
1826
|
+
if (!validName(name)) return usage("call needs a tool name.");
|
|
1827
|
+
let json;
|
|
1828
|
+
for (let index = 0; index < options.length;) {
|
|
1829
|
+
const option = options[index];
|
|
1830
|
+
let value;
|
|
1831
|
+
if (option.startsWith("--json=")) { value = option.slice(7); index += 1; }
|
|
1832
|
+
else if (option === "--json") {
|
|
1833
|
+
if (index + 1 >= options.length) return usage("--json needs a value.");
|
|
1834
|
+
value = options[index + 1]; index += 2;
|
|
1835
|
+
} else return usage("unexpected argument " + JSON.stringify(option) + "; pass the arguments as one JSON object with --json.");
|
|
1836
|
+
if (json !== undefined) return usage("pass --json once.");
|
|
1837
|
+
json = value === "-" ? (stdin ?? "") : value;
|
|
1838
|
+
}
|
|
1839
|
+
let args = {};
|
|
1840
|
+
if (json !== undefined) {
|
|
1841
|
+
try { args = JSON.parse(json); } catch (error) {
|
|
1842
|
+
return { error: "reason-sdk: the --json arguments are not valid JSON (" + (error && error.message ? error.message : "parse error") + ")." };
|
|
1843
|
+
}
|
|
1844
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) return { error: "reason-sdk: the --json arguments must be one JSON object." };
|
|
1845
|
+
if (JSON.stringify(args).length > MAX_INPUT_CHARS) return { error: "reason-sdk: the --json arguments are too large." };
|
|
1846
|
+
}
|
|
1847
|
+
return { call: { verb: "call", name, args } };
|
|
1848
|
+
}
|
|
1849
|
+
function forwardSdkCall(jobRequestId, call, reply) {
|
|
1850
|
+
const target = socket;
|
|
1851
|
+
const claim = socketClaim;
|
|
1852
|
+
if (!target || target.readyState !== WebSocket.OPEN || !claim || claim.machineSdkVersion !== 1
|
|
1853
|
+
|| claim.generation !== targetGeneration || claim.fenceId !== targetFenceId) { reply(69, SDK_DISCONNECTED); return; }
|
|
1854
|
+
if (sdkPending.size >= SDK_MAX_PENDING) { reply(75, "reason-sdk: too many calls at once; wait for some to finish."); return; }
|
|
1855
|
+
const id = "sdk-" + crypto.randomUUID();
|
|
1856
|
+
const frame = JSON.stringify({ type: "sdk_request", version: VERSION, generation: targetGeneration, id, jobRequestId, ...call });
|
|
1857
|
+
if (Buffer.byteLength(frame, "utf8") > MAX_FRAME_BYTES) { reply(2, "reason-sdk: the request is too large."); return; }
|
|
1858
|
+
sdkPending.set(id, { jobRequestId, reply, target });
|
|
1859
|
+
try { target.send(frame); } catch { sdkPending.delete(id); reply(69, SDK_DISCONNECTED); }
|
|
1860
|
+
}
|
|
1861
|
+
function handleSdkResponse(target, raw) {
|
|
1862
|
+
let value;
|
|
1863
|
+
try { value = JSON.parse(raw); } catch { closeSocket(target, "invalid_sdk_response"); return; }
|
|
1864
|
+
const answer = value && value.ok === true ? "result" : "error";
|
|
1865
|
+
if (!hasOnlyKeys(value, ["type", "version", "generation", "id", "ok", answer]) || value.type !== "sdk_response"
|
|
1866
|
+
|| value.version !== VERSION || value.generation !== targetGeneration || typeof value.id !== "string" || !ID_RE.test(value.id)
|
|
1867
|
+
|| (value.ok !== true && value.ok !== false) || typeof value[answer] !== "string" || value[answer].length > MAX_OUTPUT_CHARS
|
|
1868
|
+
|| (value.ok === false && value.error.length < 1)) {
|
|
1869
|
+
closeSocket(target, "invalid_sdk_response");
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
const entry = sdkPending.get(value.id);
|
|
1873
|
+
// A late answer for a call whose request already settled has nowhere to go.
|
|
1874
|
+
if (!entry || entry.target !== target) return;
|
|
1875
|
+
sdkPending.delete(value.id);
|
|
1876
|
+
entry.reply(value.ok ? 0 : 1, value[answer]);
|
|
1877
|
+
}
|
|
1878
|
+
// The socket and PATH entry of one machineSdk Bash request. Calls are accepted
|
|
1879
|
+
// only until close(), which the request's settlement calls.
|
|
1880
|
+
function machineSdkEndpoint(jobRequestId) {
|
|
1881
|
+
const base = machineSdkBase();
|
|
1882
|
+
sdkEndpointCount += 1;
|
|
1883
|
+
const socketPath = path.join(base.root, String(sdkEndpointCount).padStart(8, "0") + ".sock");
|
|
1884
|
+
const clients = new Map();
|
|
1885
|
+
let live = true;
|
|
1886
|
+
const server = net.createServer({ allowHalfOpen: true }, (client) => {
|
|
1887
|
+
if (!live) { client.destroy(); return; }
|
|
1888
|
+
let answered = false;
|
|
1889
|
+
const reply = (status, text) => {
|
|
1890
|
+
if (answered) return;
|
|
1891
|
+
answered = true;
|
|
1892
|
+
clients.delete(client);
|
|
1893
|
+
if (!client.destroyed) client.end(status + "\n" + text + (text.endsWith("\n") ? "" : "\n"));
|
|
1894
|
+
};
|
|
1895
|
+
clients.set(client, reply);
|
|
1896
|
+
client.once("close", () => clients.delete(client));
|
|
1897
|
+
client.on("error", () => {});
|
|
1898
|
+
client.setEncoding("utf8");
|
|
1899
|
+
let raw = "";
|
|
1900
|
+
let received = false;
|
|
1901
|
+
client.on("data", (chunk) => {
|
|
1902
|
+
if (received) return;
|
|
1903
|
+
raw += chunk;
|
|
1904
|
+
if (raw.length > SDK_MAX_LINE_CHARS) { received = true; reply(2, "reason-sdk: the request is too large."); return; }
|
|
1905
|
+
const end = raw.indexOf("\n");
|
|
1906
|
+
if (end < 0) return;
|
|
1907
|
+
received = true;
|
|
1908
|
+
const parsed = machineSdkCall(raw.slice(0, end));
|
|
1909
|
+
if (parsed.error) reply(2, parsed.error);
|
|
1910
|
+
else if (!live) reply(69, SDK_NOT_LIVE);
|
|
1911
|
+
else forwardSdkCall(jobRequestId, parsed.call, reply);
|
|
1912
|
+
});
|
|
1913
|
+
});
|
|
1914
|
+
server.maxConnections = 16;
|
|
1915
|
+
const ready = new Promise((resolve, reject) => {
|
|
1916
|
+
server.once("error", reject);
|
|
1917
|
+
server.listen(socketPath, () => {
|
|
1918
|
+
try { fs.chmodSync(socketPath, 0o600); resolve(); } catch (error) { reject(error); }
|
|
1919
|
+
});
|
|
1920
|
+
});
|
|
1921
|
+
void ready.catch(() => {});
|
|
1922
|
+
const endpoint = {
|
|
1923
|
+
ready,
|
|
1924
|
+
prelude: "export PATH=" + shellQuote(base.bin) + '":$PATH" REASON_SDK_SOCKET=' + shellQuote(socketPath) + "; ",
|
|
1925
|
+
close() {
|
|
1926
|
+
if (!live) return;
|
|
1927
|
+
live = false;
|
|
1928
|
+
sdkEndpoints.delete(endpoint);
|
|
1929
|
+
server.close();
|
|
1930
|
+
for (const [id, entry] of sdkPending) {
|
|
1931
|
+
if (entry.jobRequestId !== jobRequestId) continue;
|
|
1932
|
+
sdkPending.delete(id);
|
|
1933
|
+
entry.reply(69, SDK_NOT_LIVE);
|
|
1934
|
+
}
|
|
1935
|
+
for (const reply of [...clients.values()]) reply(69, SDK_NOT_LIVE);
|
|
1936
|
+
fs.rmSync(socketPath, { force: true });
|
|
1937
|
+
},
|
|
1938
|
+
};
|
|
1939
|
+
sdkEndpoints.add(endpoint);
|
|
1940
|
+
return endpoint;
|
|
1941
|
+
}
|
|
1942
|
+
function refreshJobEnvSnapshot() {
|
|
1943
|
+
if (config.jobEnvSourcePath === undefined) return true;
|
|
1944
|
+
const temporaryPath = config.jobEnvPath + "." + crypto.randomUUID() + ".tmp";
|
|
1945
|
+
try {
|
|
1946
|
+
const source = fs.lstatSync(config.jobEnvSourcePath);
|
|
1947
|
+
if (source.isSymbolicLink() || !source.isFile()) return false;
|
|
1948
|
+
fs.copyFileSync(
|
|
1949
|
+
config.jobEnvSourcePath,
|
|
1950
|
+
temporaryPath,
|
|
1951
|
+
fs.constants.COPYFILE_EXCL,
|
|
1952
|
+
);
|
|
1953
|
+
fs.chmodSync(temporaryPath, 0o600);
|
|
1954
|
+
fs.renameSync(temporaryPath, config.jobEnvPath);
|
|
1955
|
+
return true;
|
|
1956
|
+
} catch {
|
|
1957
|
+
try { fs.unlinkSync(temporaryPath); } catch (error) { void error; }
|
|
1958
|
+
return false;
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
// The descriptor is opened and read synchronously immediately before child
|
|
1962
|
+
// spawn, after any asynchronous policy preparation. Atomic replacement is
|
|
1963
|
+
// allowed; links, oversized files and mutable workspace paths are not.
|
|
1964
|
+
let consumedEnvironment = null;
|
|
1965
|
+
function readPrivateEnvironment() {
|
|
1966
|
+
const candidate = config.environmentSnapshotPath;
|
|
1967
|
+
const parent = path.dirname(candidate);
|
|
1968
|
+
if (fs.realpathSync(parent) !== parent) throw new Error("environment_unavailable");
|
|
1969
|
+
const directory = fs.lstatSync(parent);
|
|
1970
|
+
if (!directory.isDirectory() || directory.uid !== process.getuid() || (directory.mode & 0o7777) !== 0o700) throw new Error("environment_unavailable");
|
|
1971
|
+
for (const root of [config.workRoot, config.repoPath]) {
|
|
1972
|
+
const canonical = fs.realpathSync(root);
|
|
1973
|
+
if (stateOverlapsWorkspace(parent, canonical)) throw new Error("environment_unavailable");
|
|
1974
|
+
}
|
|
1975
|
+
const fd = fs.openSync(candidate, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
1976
|
+
try {
|
|
1977
|
+
const before = fs.fstatSync(fd);
|
|
1978
|
+
if (!before.isFile() || before.uid !== process.getuid() || before.nlink !== 1
|
|
1979
|
+
|| ![0o600, 0o400].includes(before.mode & 0o7777) || before.size < 1 || before.size > DEVICE_ENVIRONMENT_LIMITS.snapshotBytes) throw new Error("environment_unavailable");
|
|
1980
|
+
const bytes = Buffer.alloc(before.size + 1);
|
|
1981
|
+
let length = 0;
|
|
1982
|
+
while (length < bytes.length) {
|
|
1983
|
+
const count = fs.readSync(fd, bytes, length, bytes.length - length, null);
|
|
1984
|
+
if (!count) break;
|
|
1985
|
+
length += count;
|
|
1986
|
+
}
|
|
1987
|
+
const after = fs.fstatSync(fd);
|
|
1988
|
+
if (length !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs
|
|
1989
|
+
|| after.ctimeMs !== before.ctimeMs || after.nlink !== 1) throw new Error("environment_unavailable");
|
|
1990
|
+
const contents = bytes.subarray(0, length);
|
|
1991
|
+
return { snapshot: JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(contents)), fileSha256: digest(contents) };
|
|
1992
|
+
} finally { fs.closeSync(fd); }
|
|
1993
|
+
}
|
|
1994
|
+
function validateEnvironmentSnapshot(snapshot) {
|
|
1995
|
+
const authority = config.environmentExpectedBinding;
|
|
1996
|
+
if (!hasOnlyKeys(snapshot, ["version", "snapshot_id", "revision", "run_id", "device_id", "root_id", "binding", "expires_at", "variables", "git", "github_auth", "cloud_access"])
|
|
1997
|
+
|| snapshot.version !== 1 || !UUID_RE.test(snapshot.snapshot_id)
|
|
1998
|
+
|| !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 1
|
|
1999
|
+
|| snapshot.run_id !== authority.runId || snapshot.device_id !== authority.deviceId || snapshot.root_id !== authority.rootId
|
|
2000
|
+
|| !hasOnlyKeys(snapshot.binding, ["kind", "target_revision", "root_grant_revision", "target_generation", "target_fence_id"])
|
|
2001
|
+
|| snapshot.binding.kind !== authority.binding.kind || snapshot.binding.target_revision !== authority.binding.target_revision
|
|
2002
|
+
|| snapshot.binding.root_grant_revision !== authority.binding.root_grant_revision
|
|
2003
|
+
|| snapshot.binding.target_generation !== targetGeneration || snapshot.binding.target_fence_id !== targetFenceId
|
|
2004
|
+
|| !hasOnlyKeys(snapshot.variables, Object.keys(snapshot.variables || {})) || Object.keys(snapshot.variables).length > DEVICE_ENVIRONMENT_LIMITS.variables) throw new Error("environment_unavailable");
|
|
2005
|
+
const expires = Date.parse(snapshot.expires_at);
|
|
2006
|
+
if (!Number.isFinite(expires) || expires <= Date.now() || expires > config.expiresAtMs) throw new Error("environment_unavailable");
|
|
2007
|
+
for (const [name, value] of Object.entries(snapshot.variables)) {
|
|
2008
|
+
if (name.length > 128 || !DEVICE_ENVIRONMENT_NAME_RE.test(name) || typeof value !== "string" || Buffer.byteLength(value, "utf8") > DEVICE_ENVIRONMENT_LIMITS.valueBytes || value.includes("\0")) throw new Error("environment_unavailable");
|
|
2009
|
+
}
|
|
2010
|
+
if (snapshot.github_auth !== undefined && (snapshot.github_auth !== "local_gh" || snapshot.git !== null
|
|
2011
|
+
|| !["darwin", "linux"].includes(process.platform) || !config.executionEnvironment || config.environmentExpectedBinding.binding.kind !== "workspace_source")) {
|
|
2012
|
+
throw new Error("environment_unavailable");
|
|
2013
|
+
}
|
|
2014
|
+
if (snapshot.git !== null) {
|
|
2015
|
+
const git = snapshot.git;
|
|
2016
|
+
if (!hasOnlyKeys(git, ["base_url", "token", "expires_at"]) || typeof git.base_url !== "string" || git.base_url.length > 2048
|
|
2017
|
+
|| typeof git.token !== "string" || git.token.length < 1 || git.token.length > 4096 || !/^[A-Za-z0-9._~-]+$/.test(git.token)) throw new Error("environment_unavailable");
|
|
2018
|
+
const url = new URL(git.base_url);
|
|
2019
|
+
const gitExpires = Date.parse(git.expires_at);
|
|
2020
|
+
if (url.origin !== new URL(authority.apiOrigin).origin || !["/git", "/git/"].includes(url.pathname)
|
|
2021
|
+
|| url.username || url.password || url.search || url.hash || !Number.isFinite(gitExpires)
|
|
2022
|
+
|| gitExpires <= Date.now() || gitExpires > expires) throw new Error("environment_unavailable");
|
|
2023
|
+
}
|
|
2024
|
+
if (snapshot.cloud_access !== undefined && !cloudAccessVariables(snapshot.cloud_access, authority.apiOrigin)) throw new Error("environment_unavailable");
|
|
2025
|
+
const hash = digest(JSON.stringify({ ...snapshot,
|
|
2026
|
+
variables: Object.fromEntries(Object.entries(snapshot.variables).sort(([left], [right]) => left.localeCompare(right))),
|
|
2027
|
+
}));
|
|
2028
|
+
if (consumedEnvironment && (snapshot.revision < consumedEnvironment.revision
|
|
2029
|
+
|| (snapshot.revision === consumedEnvironment.revision && hash !== consumedEnvironment.hash))) throw new Error("environment_unavailable");
|
|
2030
|
+
return { snapshot, hash };
|
|
2031
|
+
}
|
|
2032
|
+
// The reserved cloud access variables (shared/device-environment.ts
|
|
2033
|
+
// cloudAccessEnvironment), or null unless both URLs are exactly the policy's
|
|
2034
|
+
// endpoints on this worker's API origin.
|
|
2035
|
+
function cloudAccessVariables(access, apiOrigin) {
|
|
2036
|
+
if (!access || typeof access !== "object" || !hasOnlyKeys(access, ["token_url", "request_token", "aws_credentials_url"])
|
|
2037
|
+
|| typeof access.request_token !== "string" || access.request_token.length > 8192
|
|
2038
|
+
|| !new RegExp(DEVICE_CLOUD_ACCESS_POLICY.tokenPattern).test(access.request_token)) return null;
|
|
2039
|
+
const exact = (value, pathname) => {
|
|
2040
|
+
if (typeof value !== "string" || value.length > 2048) return false;
|
|
2041
|
+
try {
|
|
2042
|
+
const url = new URL(value);
|
|
2043
|
+
return url.origin === new URL(apiOrigin).origin && url.pathname === pathname && !url.username && !url.password && !url.search && !url.hash;
|
|
2044
|
+
} catch { return false; }
|
|
2045
|
+
};
|
|
2046
|
+
if (!exact(access.token_url, DEVICE_CLOUD_ACCESS_POLICY.tokenPath)
|
|
2047
|
+
|| (access.aws_credentials_url !== null && !exact(access.aws_credentials_url, DEVICE_CLOUD_ACCESS_POLICY.awsPath))) return null;
|
|
2048
|
+
return { REASON_OIDC_TOKEN_URL: access.token_url, REASON_OIDC_REQUEST_TOKEN: access.request_token,
|
|
2049
|
+
...(access.aws_credentials_url !== null ? { AWS_CONTAINER_CREDENTIALS_FULL_URI: access.aws_credentials_url,
|
|
2050
|
+
AWS_CONTAINER_AUTHORIZATION_TOKEN: access.request_token } : {}) };
|
|
2051
|
+
}
|
|
2052
|
+
${n}
|
|
2053
|
+
// Pi's shell's global Git config: a private file beside this worker's config,
|
|
2054
|
+
// so git config --global works for the Hands session, as stock Pi's does. The
|
|
2055
|
+
// path is in the shell's environment, where the person reads it, so the file
|
|
2056
|
+
// carries the product's name. Each worker launch has its own new directory
|
|
2057
|
+
// (cli/local-hands-relay.ts), so no earlier file exists to carry over.
|
|
2058
|
+
const PI_GIT_CONFIG_GLOBAL = path.join(path.dirname(process.argv[2]), "reason-gitconfig");
|
|
2059
|
+
/** Spawns that waited for their environment, by request id: reported once on the result. */
|
|
2060
|
+
const environmentWaits = new Map();
|
|
2061
|
+
/** Set when a claim negotiated environmentWaitVersion (only this build's server, for resident takes): spawns may wait. */
|
|
2062
|
+
let environmentWaitNegotiated = false;
|
|
2063
|
+
/** How long a spawn waits for its generation's environment before it refuses (environment_unavailable). */
|
|
2064
|
+
const SPAWN_ENVIRONMENT_WAIT_MS = 2_000;
|
|
2065
|
+
/** Waits (bounded) until this generation's environment validates; returns the ms waited. Never validates
|
|
2066
|
+
* anything itself: the spawn below still calls environmentForSpawn, which refuses on its own. */
|
|
2067
|
+
async function waitForSpawnEnvironment(piShell, confined) {
|
|
2068
|
+
const startedAt = performance.now();
|
|
2069
|
+
for (;;) {
|
|
2070
|
+
try { environmentForSpawn(piShell, confined); return Math.round(performance.now() - startedAt); }
|
|
2071
|
+
catch (error) {
|
|
2072
|
+
if (!(error instanceof Error && error.message === "environment_unavailable")
|
|
2073
|
+
|| performance.now() - startedAt >= SPAWN_ENVIRONMENT_WAIT_MS) return Math.round(performance.now() - startedAt);
|
|
2074
|
+
}
|
|
2075
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
function environmentForSpawn(piShell = false, confined = false) {
|
|
2079
|
+
const privateFile = readPrivateEnvironment();
|
|
2080
|
+
const validated = validateEnvironmentSnapshot(privateFile.snapshot);
|
|
2081
|
+
const snapshot = validated.snapshot;
|
|
2082
|
+
const deviceRoot = fs.realpathSync(config.deviceRootPath || config.workRoot);
|
|
2083
|
+
const workspace = fs.realpathSync(config.workRoot);
|
|
2084
|
+
const repo = fs.realpathSync(config.repoPath);
|
|
2085
|
+
const within = (candidate, root) => candidate === root || candidate.startsWith(root + path.sep);
|
|
2086
|
+
if (!within(workspace, deviceRoot) || !within(repo, workspace)) throw new Error("environment_unavailable");
|
|
2087
|
+
let pool;
|
|
2088
|
+
if (config.workspacePoolPath !== undefined) {
|
|
2089
|
+
pool = fs.realpathSync(config.workspacePoolPath);
|
|
2090
|
+
if (!within(pool, deviceRoot) || path.dirname(workspace) !== pool) throw new Error("environment_unavailable");
|
|
2091
|
+
}
|
|
2092
|
+
// Never spread process.env here. Explicit local host-mode composition may
|
|
2093
|
+
// preserve toolchain coordinates, and for Pi's shell the host's filtered
|
|
2094
|
+
// variables (an image's GOPATH, LANG...) below the snapshot; secrets/loaders
|
|
2095
|
+
// still come only from the fenced snapshot, and shell/Git controls cannot be
|
|
2096
|
+
// overridden.
|
|
2097
|
+
const baseline = config.executionEnvironment;
|
|
2098
|
+
const temporaryDirectory = baseline?.temporaryDirectory ?? config.workRoot;
|
|
2099
|
+
const hostVariables = piShell && !(snapshot.github_auth === "local_gh" && localGithubSandbox) ? baseline?.variables : undefined;
|
|
2100
|
+
const env = { ...hostVariables, ...snapshot.variables, PATH: baseline?.path ?? "/opt/homebrew/bin:" + FIXED_CLOUD_CHILD_PATH,
|
|
2101
|
+
HOME: baseline?.home ?? config.workRoot, PWD: config.repoPath, TMPDIR: temporaryDirectory, TMP: temporaryDirectory, TEMP: temporaryDirectory,
|
|
2102
|
+
SHELL: FIXED_BASH, BASH_ENV: "/dev/null", ENV: "/dev/null",
|
|
2103
|
+
// Each coordinate under its Reason name and the Ara name every server
|
|
2104
|
+
// script and Session shell before the rename reads.
|
|
2105
|
+
REASON_DEVICE_ROOT: deviceRoot, REASON_HANDS_WORKSPACE: workspace, REASON_HANDS_REPO: repo,
|
|
2106
|
+
ARA_DEVICE_ROOT: deviceRoot, ARA_HANDS_WORKSPACE: workspace, ARA_HANDS_REPO: repo,
|
|
2107
|
+
...(pool === undefined ? {} : { REASON_HANDS_WORKSPACE_POOL: pool, ARA_HANDS_WORKSPACE_POOL: pool }),
|
|
2108
|
+
GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_SYSTEM: "/dev/null", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0",
|
|
2109
|
+
};
|
|
2110
|
+
const pairs = [["credential.helper", ""], ["http.extraheader", ""]];
|
|
2111
|
+
let confinementPrefix;
|
|
2112
|
+
if (snapshot.github_auth === "local_gh" && config.localGithubConfinement !== true) throw new Error("local_device_gh_confinement_unavailable");
|
|
2113
|
+
if (snapshot.github_auth === "local_gh" && localGithubSandbox) {
|
|
2114
|
+
if (config.spawnPolicy || config.bashCommandPrefix
|
|
2115
|
+
|| !fs.existsSync("/usr/bin/sandbox-exec")) throw new Error("local_device_gh_confinement_unavailable");
|
|
2116
|
+
const temporary = path.join(workspace, ".ara-local-gh-tmp");
|
|
2117
|
+
fs.mkdirSync(temporary, { recursive: true, mode: 0o700 });
|
|
2118
|
+
if (fs.lstatSync(temporary).isSymbolicLink() || fs.realpathSync(temporary) !== temporary) throw new Error("local_device_gh_confinement_unavailable");
|
|
2119
|
+
env.HOME = workspace;
|
|
2120
|
+
env.OPENSSL_CONF = "/dev/null";
|
|
2121
|
+
env.TMPDIR = temporary; env.TMP = temporary; env.TEMP = temporary;
|
|
2122
|
+
env.PATH = ${JSON.stringify(h)};
|
|
2123
|
+
for (const name of Object.keys(env)) if (name.startsWith("GH_") || name.startsWith("GITHUB_") || name.startsWith("GIT_TRACE")) delete env[name];
|
|
2124
|
+
confinementPrefix = ["/usr/bin/sandbox-exec", "-p", localGithubSandboxProfile(workspace, temporary)];
|
|
2125
|
+
}
|
|
2126
|
+
if (config.localGithubConfinement === true && snapshot.github_auth !== "local_gh") throw new Error("local_device_gh_authority_changed");
|
|
2127
|
+
if (snapshot.git && config.gitCredentialEnvironment === true) {
|
|
2128
|
+
const helper = GIT_BROKER_CREDENTIAL_HELPER_POLICY;
|
|
2129
|
+
const base = snapshot.git.base_url.replace(new RegExp(GIT_BROKER_HTTP_HEADER_POLICY.trailingSlashPattern), "");
|
|
2130
|
+
env[helper.urlEnv] = base;
|
|
2131
|
+
env[helper.tokenEnv] = snapshot.git.token;
|
|
2132
|
+
env[helper.reasonTokenEnv] = snapshot.git.token;
|
|
2133
|
+
pairs.push([helper.keyPrefix + base + helper.keySuffix, helper.value]);
|
|
2134
|
+
} else if (snapshot.git) {
|
|
2135
|
+
const policy = GIT_BROKER_HTTP_HEADER_POLICY;
|
|
2136
|
+
const base = snapshot.git.base_url.replace(new RegExp(policy.trailingSlashPattern), "");
|
|
2137
|
+
pairs.push([policy.keyPrefix + base + policy.keySuffix, policy.valuePrefix + snapshot.git.token]);
|
|
2138
|
+
}
|
|
2139
|
+
// Server-provided, after the customer variables (which can never carry these names).
|
|
2140
|
+
if (snapshot.cloud_access !== undefined) Object.assign(env, cloudAccessVariables(snapshot.cloud_access, config.environmentExpectedBinding.apiOrigin));
|
|
2141
|
+
env.GIT_CONFIG_COUNT = String(pairs.length);
|
|
2142
|
+
pairs.forEach(([key, value], index) => { env["GIT_CONFIG_KEY_" + index] = key; env["GIT_CONFIG_VALUE_" + index] = value; });
|
|
2143
|
+
// Local GitHub confinement denies files outside the workspace, so it keeps
|
|
2144
|
+
// the empty /dev/null config.
|
|
2145
|
+
// A confined payload cannot read the worker's private files; it keeps the bash tool's empty config.
|
|
2146
|
+
if (piShell && !confinementPrefix && !confined) env.GIT_CONFIG_GLOBAL = PI_GIT_CONFIG_GLOBAL;
|
|
2147
|
+
return { ...validated, env, confinementPrefix, fileSha256: privateFile.fileSha256 };
|
|
2148
|
+
}
|
|
2149
|
+
${s}const WORKSPACE_FILE_OPERATIONS_RUNTIME_SOURCE = ${JSON.stringify(s)};
|
|
2150
|
+
// One workspace file call in a spawned child (sandbox-exec confined under local
|
|
2151
|
+
// GitHub authority), bounded by workspaceTarget: inside the canonical
|
|
2152
|
+
// repository root, with no symlink path component. The typed file tools and
|
|
2153
|
+
// Pi's confined file operations share this boundary.
|
|
2154
|
+
function workspaceFileScript(request, body, nodeRedirect = "") {
|
|
2155
|
+
const encodedRequest = Buffer.from(JSON.stringify(request)).toString("base64");
|
|
2156
|
+
const program = [
|
|
2157
|
+
'"use strict";',
|
|
2158
|
+
'const crypto = require("node:crypto");',
|
|
2159
|
+
'const fs = require("node:fs");',
|
|
2160
|
+
'const path = require("node:path");',
|
|
2161
|
+
'const config = { repoPath: process.cwd() };',
|
|
2162
|
+
'const MAX_OUTPUT_CHARS = ' + MAX_OUTPUT_CHARS + ';',
|
|
2163
|
+
'const success = (_id, stdout, stderr) => ({ code: 0, stdout, stderr });',
|
|
2164
|
+
'const failure = (_id, code, _kind, stdout, stderr) => ({ code, stdout, stderr });',
|
|
2165
|
+
WORKSPACE_FILE_OPERATIONS_RUNTIME_SOURCE,
|
|
2166
|
+
'const request = JSON.parse(Buffer.from("' + encodedRequest + '", "base64").toString("utf8"));',
|
|
2167
|
+
body,
|
|
2168
|
+
].join("\n");
|
|
2169
|
+
return "node" + nodeRedirect + " <<'ARA_WORKSPACE_FILE_OPERATION'\n" + program + "\nARA_WORKSPACE_FILE_OPERATION\n";
|
|
2170
|
+
}
|
|
2171
|
+
function payloadFileOperation(request) {
|
|
2172
|
+
return bashOperation({ id: request.id, input: { script: workspaceFileScript(request,
|
|
2173
|
+
'fileOperation(request).promise.then(result => { process.stdout.write(result.stdout); process.stderr.write(result.stderr); process.exitCode = result.code; });'),
|
|
2174
|
+
} }, null);
|
|
2175
|
+
}
|
|
2176
|
+
// Pi's read/edit/write operations under local GitHub confinement. A read
|
|
2177
|
+
// returns raw bytes through the unclipped Pi shell stream (the child's stderr
|
|
2178
|
+
// is discarded so nothing interleaves) and is shared by access, image sniffing
|
|
2179
|
+
// and readFile within one tool call. writeFile is the typed tool's checked
|
|
2180
|
+
// atomic write, which creates parent directories after checking the target, so
|
|
2181
|
+
// mkdir defers to it rather than creating directories ahead of that check.
|
|
2182
|
+
// Cancelling the tool call stops the child like a cancelled typed file tool.
|
|
2183
|
+
function confinedPiFileOperations(id, state, writable, signal) {
|
|
2184
|
+
const run = (operation) => {
|
|
2185
|
+
const stop = () => { void operation.cancel().catch(() => {}); };
|
|
2186
|
+
if (signal.aborted) stop();
|
|
2187
|
+
else signal.addEventListener("abort", stop, { once: true });
|
|
2188
|
+
return operation.promise.then((result) => {
|
|
2189
|
+
signal.removeEventListener("abort", stop);
|
|
2190
|
+
if (result.error === "process_group_cleanup_unconfirmed") state.cleanupUnconfirmed = true;
|
|
2191
|
+
return result;
|
|
2192
|
+
});
|
|
2193
|
+
};
|
|
2194
|
+
const reads = new Map();
|
|
2195
|
+
const read = (file) => {
|
|
2196
|
+
if (!reads.has(file)) {
|
|
2197
|
+
const chunks = [];
|
|
2198
|
+
const script = workspaceFileScript({ path: file, writable }, [
|
|
2199
|
+
'let reply;',
|
|
2200
|
+
'try {',
|
|
2201
|
+
' const { target } = workspaceTarget(request.path);',
|
|
2202
|
+
' fs.accessSync(target, request.writable ? fs.constants.R_OK | fs.constants.W_OK : fs.constants.R_OK);',
|
|
2203
|
+
' reply = { data: fs.readFileSync(target).toString("base64") };',
|
|
2204
|
+
'} catch (error) {',
|
|
2205
|
+
' reply = { error: error instanceof Error ? error.message : "workspace_file_failed", code: typeof error?.code === "string" ? error.code : undefined };',
|
|
2206
|
+
'}',
|
|
2207
|
+
'process.stdout.write(JSON.stringify(reply));',
|
|
2208
|
+
].join("\n"), " 2>/dev/null");
|
|
2209
|
+
reads.set(file, run(bashOperation({ id, input: { script } }, null, null, { onData: (chunk) => chunks.push(chunk) }))
|
|
2210
|
+
.then((result) => {
|
|
2211
|
+
if (!result.ok) throw new Error(result.error || "workspace_file_failed");
|
|
2212
|
+
const reply = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
2213
|
+
if (typeof reply.error === "string") throw Object.assign(new Error(reply.error), reply.code ? { code: reply.code } : {});
|
|
2214
|
+
return Buffer.from(reply.data, "base64");
|
|
2215
|
+
}));
|
|
2216
|
+
}
|
|
2217
|
+
return reads.get(file);
|
|
2218
|
+
};
|
|
2219
|
+
return {
|
|
2220
|
+
access: (file) => read(file).then(() => undefined),
|
|
2221
|
+
readFile: read,
|
|
2222
|
+
// Pi's own sniffing over the leading bytes its file-based default reads.
|
|
2223
|
+
detectImageMimeType: (file) => read(file).then((bytes) => loadPiTools().detectSupportedImageMimeType(bytes.subarray(0, 4100))),
|
|
2224
|
+
writeFile: (file, content) => run(payloadFileOperation({ id, tool: "write", input: { path: file, content } })).then((result) => {
|
|
2225
|
+
reads.delete(file);
|
|
2226
|
+
if (!result.ok) throw new Error(result.stderr || result.error || "workspace_file_failed");
|
|
2227
|
+
}),
|
|
2228
|
+
mkdir: () => Promise.resolve(),
|
|
2229
|
+
};
|
|
2230
|
+
}
|
|
2231
|
+
function prepareBashSpawn(argv, extraFileDescriptors, expiresAtMs, signal, onLost) {
|
|
2232
|
+
const policy = config.spawnPolicy;
|
|
2233
|
+
const claimedGeneration = targetGeneration;
|
|
2234
|
+
const claimedFence = targetFenceId;
|
|
2235
|
+
return new Promise((resolve, reject) => {
|
|
2236
|
+
const parent = path.dirname(policy.socketPath);
|
|
2237
|
+
const directory = fs.lstatSync(parent);
|
|
2238
|
+
const endpoint = fs.lstatSync(policy.socketPath);
|
|
2239
|
+
if (fs.realpathSync(parent) !== parent || !directory.isDirectory() || directory.uid !== process.getuid()
|
|
2240
|
+
|| (directory.mode & 0o777) !== 0o700 || !endpoint.isSocket() || endpoint.uid !== process.getuid()
|
|
2241
|
+
|| (endpoint.mode & 0o077) !== 0) throw new Error("invalid_spawn_socket");
|
|
2242
|
+
const id = crypto.randomUUID();
|
|
2243
|
+
const client = net.createConnection(policy.socketPath);
|
|
2244
|
+
let raw = "";
|
|
2245
|
+
let prepared = null;
|
|
2246
|
+
let preparedEnvironmentSha256;
|
|
2247
|
+
let authorized = false;
|
|
2248
|
+
let released = false;
|
|
2249
|
+
const current = () => !stopped && !signal.aborted && claimedGeneration === targetGeneration
|
|
2250
|
+
&& claimedFence === targetFenceId && Date.now() < expiresAtMs;
|
|
2251
|
+
const abort = () => client.destroy();
|
|
2252
|
+
const close = (confirmed) => {
|
|
2253
|
+
if (released) return;
|
|
2254
|
+
released = true;
|
|
2255
|
+
signal.removeEventListener("abort", abort);
|
|
2256
|
+
if (confirmed && !client.destroyed) client.end(JSON.stringify({ version: 1, type: "release", id }) + "\n");
|
|
2257
|
+
else client.destroy();
|
|
2258
|
+
};
|
|
2259
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
2260
|
+
client.setEncoding("utf8");
|
|
2261
|
+
client.setTimeout(5000, abort);
|
|
2262
|
+
client.on("error", () => {});
|
|
2263
|
+
client.once("close", () => {
|
|
2264
|
+
signal.removeEventListener("abort", abort);
|
|
2265
|
+
if (!authorized) reject(new Error("spawn_policy_rejected"));
|
|
2266
|
+
else if (!released) onLost();
|
|
2267
|
+
});
|
|
2268
|
+
client.once("connect", () => {
|
|
2269
|
+
if (!current()) { client.destroy(); return; }
|
|
2270
|
+
client.write(JSON.stringify({ version: 1, type: "prepare", id, scopeId: policy.scopeId,
|
|
2271
|
+
targetRevision: policy.targetRevision, generation: claimedGeneration, fenceId: claimedFence,
|
|
2272
|
+
executorLaunchId: config.launchId, expiresAtMs, cwd: fs.realpathSync(config.repoPath), argv, extraFileDescriptors }) + "\n");
|
|
2273
|
+
});
|
|
2274
|
+
client.on("data", (chunk) => {
|
|
2275
|
+
raw += chunk;
|
|
2276
|
+
if (Buffer.byteLength(raw) > 262144) { client.destroy(); return; }
|
|
2277
|
+
const newline = raw.indexOf("\n");
|
|
2278
|
+
if (newline < 0) return;
|
|
2279
|
+
if (newline !== raw.length - 1 || !current()) { client.destroy(); return; }
|
|
2280
|
+
let frame;
|
|
2281
|
+
try { frame = JSON.parse(raw.slice(0, -1)); } catch { client.destroy(); return; }
|
|
2282
|
+
raw = "";
|
|
2283
|
+
if (frame.version !== 1 || frame.id !== id) { client.destroy(); return; }
|
|
2284
|
+
if (!prepared && frame.type === "prepared" && hasOnlyKeys(frame, ["version", "id", "type", "argv", "cwd", "environmentSnapshotSha256"])
|
|
2285
|
+
&& (config.environmentSnapshotPath === undefined ? frame.environmentSnapshotSha256 === undefined
|
|
2286
|
+
: typeof frame.environmentSnapshotSha256 === "string" && SHA256_RE.test(frame.environmentSnapshotSha256))
|
|
2287
|
+
&& frame.cwd === fs.realpathSync(config.repoPath) && Array.isArray(frame.argv) && frame.argv.length > 0 && frame.argv.length <= 32
|
|
2288
|
+
&& validPath(frame.argv[0]) && frame.argv.every((arg) => typeof arg === "string" && !arg.includes("\0"))) {
|
|
2289
|
+
prepared = frame.argv;
|
|
2290
|
+
preparedEnvironmentSha256 = frame.environmentSnapshotSha256;
|
|
2291
|
+
client.write(JSON.stringify({ version: 1, type: "activate", id }) + "\n");
|
|
2292
|
+
} else if (prepared && !authorized && frame.type === "spawn" && hasOnlyKeys(frame, ["version", "id", "type", "expiresAtMs"])
|
|
2293
|
+
&& Number.isSafeInteger(frame.expiresAtMs) && frame.expiresAtMs > Date.now() && frame.expiresAtMs <= expiresAtMs) {
|
|
2294
|
+
authorized = true; client.setTimeout(0);
|
|
2295
|
+
signal.removeEventListener("abort", abort);
|
|
2296
|
+
resolve({ argv: prepared, environmentSnapshotSha256: preparedEnvironmentSha256,
|
|
2297
|
+
current: () => current() && Date.now() < frame.expiresAtMs, release: () => close(true), abandon: () => close(false) });
|
|
2298
|
+
} else client.destroy();
|
|
2299
|
+
});
|
|
2300
|
+
});
|
|
2301
|
+
}
|
|
2302
|
+
let nativePtyOwner = null;
|
|
2303
|
+
function ptySupported() {
|
|
2304
|
+
if (localGithubSandbox || !config.managedPty || config.environmentSnapshotPath === undefined || config.spawnPolicy || config.bashCommandPrefix
|
|
2305
|
+
|| !["darwin", "linux"].includes(process.platform) || typeof Bun === "undefined" || typeof Bun.Terminal !== "function") return false;
|
|
2306
|
+
if (process.platform === "linux" && !["x64", "arm64"].includes(process.arch)) return false;
|
|
2307
|
+
try { platformPtyOwner(); return true; } catch { return false; }
|
|
2308
|
+
}
|
|
2309
|
+
function platformPtyOwner() { return process.platform === "linux" ? linuxPtyOwner() : macPtyOwner(); }
|
|
2310
|
+
/** Only live native process identities, never recovered PIDs. Darwin's audit
|
|
2311
|
+
* token includes the kernel PID version; stale versions cannot signal a reused
|
|
2312
|
+
* PID. Keep the trusted session leader alive until its interactive groups are
|
|
2313
|
+
* empty. This is graceful PTY-attached session cleanup, NOT supervisor-crash containment or
|
|
2314
|
+
* confinement against host-mode code that deliberately escapes its session. */
|
|
2315
|
+
function macPtyOwner() {
|
|
2316
|
+
if (nativePtyOwner) return nativePtyOwner;
|
|
2317
|
+
const { dlopen, ptr, read } = require("bun:ffi");
|
|
2318
|
+
const library = dlopen("/usr/lib/libSystem.B.dylib", {
|
|
2319
|
+
mach_task_self: { args: [], returns: "u32" },
|
|
2320
|
+
task_name_for_pid: { args: ["u32", "i32", "ptr"], returns: "i32" },
|
|
2321
|
+
task_info: { args: ["u32", "u32", "ptr", "ptr"], returns: "i32" },
|
|
2322
|
+
mach_port_deallocate: { args: ["u32", "u32"], returns: "i32" },
|
|
2323
|
+
proc_signal_with_audittoken: { args: ["ptr", "i32"], returns: "i32" },
|
|
2324
|
+
proc_listpids: { args: ["u32", "u32", "ptr", "i32"], returns: "i32" },
|
|
2325
|
+
proc_pidinfo: { args: ["i32", "i32", "u64", "ptr", "i32"], returns: "i32" },
|
|
2326
|
+
getsid: { args: ["i32"], returns: "i32" },
|
|
2327
|
+
getpgid: { args: ["i32"], returns: "i32" },
|
|
2328
|
+
socketpair: { args: ["i32", "i32", "i32", "ptr"], returns: "i32" },
|
|
2329
|
+
ioctl: { args: ["i32", "u64"], returns: "i32" },
|
|
2330
|
+
setsockopt: { args: ["i32", "i32", "i32", "ptr", "u32"], returns: "i32" },
|
|
2331
|
+
recv: { args: ["i32", "ptr", "u64", "i32"], returns: "i64_fast" },
|
|
2332
|
+
send: { args: ["i32", "ptr", "u64", "i32"], returns: "i64_fast" },
|
|
2333
|
+
__error: { args: [], returns: "ptr" },
|
|
2334
|
+
});
|
|
2335
|
+
const api = library.symbols, self = api.mach_task_self();
|
|
2336
|
+
function identity(pid) {
|
|
2337
|
+
const port = new Uint32Array(1), count = new Uint32Array([8]), token = new Uint32Array(8);
|
|
2338
|
+
if (api.task_name_for_pid(self, pid, ptr(port)) !== 0) return null;
|
|
2339
|
+
try {
|
|
2340
|
+
// TASK_AUDIT_TOKEN / TASK_AUDIT_TOKEN_COUNT from the macOS SDK.
|
|
2341
|
+
if (api.task_info(port[0], 15, ptr(token), ptr(count)) !== 0 || count[0] !== 8 || token[5] !== pid) return null;
|
|
2342
|
+
return token;
|
|
2343
|
+
} finally { api.mach_port_deallocate(self, port[0]); }
|
|
2344
|
+
}
|
|
2345
|
+
function same(token) {
|
|
2346
|
+
const current = identity(token[5]);
|
|
2347
|
+
return current && current[5] === token[5] && current[7] === token[7];
|
|
2348
|
+
}
|
|
2349
|
+
function ttyDevice(anchor) {
|
|
2350
|
+
// 64-bit proc_bsdinfo / PROC_PIDTBSDINFO from the current macOS SDK.
|
|
2351
|
+
const info = Buffer.alloc(136);
|
|
2352
|
+
if (!same(anchor) || api.proc_pidinfo(anchor[5], 3, 0, ptr(info), info.length) !== info.length
|
|
2353
|
+
|| info.readUInt32LE(12) !== anchor[5] || !(info.readUInt32LE(0) & 0x40)) throw new Error("pty_terminal_identity_lost");
|
|
2354
|
+
return info.readUInt32LE(108);
|
|
2355
|
+
}
|
|
2356
|
+
function session(anchor, device) {
|
|
2357
|
+
if (!same(anchor) || api.getsid(anchor[5]) !== anchor[5] || ttyDevice(anchor) !== device) throw new Error("pty_session_identity_lost");
|
|
2358
|
+
const pids = new Int32Array(32768);
|
|
2359
|
+
const bytes = api.proc_listpids(3, device, ptr(pids), pids.byteLength); // PROC_TTY_ONLY
|
|
2360
|
+
const count = bytes / 4;
|
|
2361
|
+
if (!Number.isSafeInteger(count) || count < 1 || count >= pids.length
|
|
2362
|
+
|| !pids.subarray(0, count).includes(anchor[5])) throw new Error("pty_session_inventory_unconfirmed");
|
|
2363
|
+
const members = [];
|
|
2364
|
+
for (let index = 0; index < count; index++) {
|
|
2365
|
+
const pid = pids[index];
|
|
2366
|
+
if (pid <= 0 || pid === anchor[5]) continue;
|
|
2367
|
+
const member = inspectMacPtyMember(anchor, pid, { identity, same, session(memberPid) {
|
|
2368
|
+
const id = api.getsid(memberPid);
|
|
2369
|
+
return { id, missing: id === -1 && read.i32(api.__error()) === 3 };
|
|
2370
|
+
} });
|
|
2371
|
+
// No signals from a partially invalidated inventory. The owning bounded
|
|
2372
|
+
// sweep re-inventories while its anchor remains verified; it never turns
|
|
2373
|
+
// a vanished or unreadable member into empty-session proof.
|
|
2374
|
+
if (member.retry) return { members: [], empty: false };
|
|
2375
|
+
members.push(member.token);
|
|
2376
|
+
}
|
|
2377
|
+
// Do not mistake a reused session number for the original live session.
|
|
2378
|
+
if (!same(anchor) || ttyDevice(anchor) !== device) throw new Error("pty_session_identity_lost");
|
|
2379
|
+
// Empty comes from the kernel-filtered snapshot, not from failed per-PID
|
|
2380
|
+
// lookups. Fork/exit races cannot turn vanished identities into empty proof.
|
|
2381
|
+
return { members, empty: count === 1 };
|
|
2382
|
+
}
|
|
2383
|
+
function signal(token, number) {
|
|
2384
|
+
const result = api.proc_signal_with_audittoken(ptr(token), number);
|
|
2385
|
+
if (result !== 0 && result !== 3) throw new Error("pty_signal_unconfirmed"); // ESRCH is already gone.
|
|
2386
|
+
}
|
|
2387
|
+
function controlPair() {
|
|
2388
|
+
const descriptors = new Int32Array(2);
|
|
2389
|
+
if (api.socketpair(1, 1, 0, ptr(descriptors)) !== 0) throw new Error("pty_control_unavailable");
|
|
2390
|
+
try {
|
|
2391
|
+
// AF_UNIX/SOCK_STREAM; FIOCLEX has no ioctl argument. Avoid fcntl's
|
|
2392
|
+
// variadic integer ABI (different from a fixed-argument FFI on arm64).
|
|
2393
|
+
const enabled = new Int32Array([1]);
|
|
2394
|
+
for (const fd of descriptors) {
|
|
2395
|
+
if (api.ioctl(fd, 0x20006601) !== 0 || api.setsockopt(fd, 0xffff, 0x1022, ptr(enabled), 4) !== 0) {
|
|
2396
|
+
throw new Error("pty_control_unavailable");
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
return [...descriptors];
|
|
2400
|
+
} catch (error) { for (const fd of descriptors) fs.closeSync(fd); throw error; }
|
|
2401
|
+
}
|
|
2402
|
+
function readControl(fd) {
|
|
2403
|
+
const bytes = Buffer.alloc(256);
|
|
2404
|
+
const size = api.recv(fd, ptr(bytes), bytes.length, 0x80); // MSG_DONTWAIT
|
|
2405
|
+
if (size < 0) {
|
|
2406
|
+
if ([4, 35].includes(read.i32(api.__error()))) return ""; // EINTR/EAGAIN
|
|
2407
|
+
throw new Error("pty_control_read_failed");
|
|
2408
|
+
}
|
|
2409
|
+
if (!size) throw new Error("pty_control_closed");
|
|
2410
|
+
return bytes.subarray(0, size).toString("ascii");
|
|
2411
|
+
}
|
|
2412
|
+
function releaseControl(fd) {
|
|
2413
|
+
const bytes = Buffer.from("release\n");
|
|
2414
|
+
if (api.send(fd, ptr(bytes), bytes.length, 0x80) !== bytes.length) throw new Error("pty_control_release_failed");
|
|
2415
|
+
}
|
|
2416
|
+
nativePtyOwner = { identity, same, session, ttyDevice, signal, controlPair, readControl, releaseControl,
|
|
2417
|
+
identityKey: (token) => token[5] + ":" + token[7], group: api.getpgid, sid: api.getsid };
|
|
2418
|
+
if (!identity(process.pid)) { nativePtyOwner = null; library.close(); throw new Error("pty_identity_unavailable"); }
|
|
2419
|
+
return nativePtyOwner;
|
|
2420
|
+
}
|
|
2421
|
+
function linuxPtyOwner() {
|
|
2422
|
+
if (nativePtyOwner) return nativePtyOwner;
|
|
2423
|
+
const { dlopen, ptr, read } = require("bun:ffi");
|
|
2424
|
+
const library = dlopen("libc.so.6", {
|
|
2425
|
+
pidfd_open: { args: ["i32", "u32"], returns: "i32" },
|
|
2426
|
+
pidfd_send_signal: { args: ["i32", "i32", "ptr", "u32"], returns: "i32" },
|
|
2427
|
+
poll: { args: ["ptr", "u64", "i32"], returns: "i32" },
|
|
2428
|
+
getsid: { args: ["i32"], returns: "i32" },
|
|
2429
|
+
getpgid: { args: ["i32"], returns: "i32" },
|
|
2430
|
+
socketpair: { args: ["i32", "i32", "i32", "ptr"], returns: "i32" },
|
|
2431
|
+
recv: { args: ["i32", "ptr", "u64", "i32"], returns: "i64_fast" },
|
|
2432
|
+
send: { args: ["i32", "ptr", "u64", "i32"], returns: "i64_fast" },
|
|
2433
|
+
__errno_location: { args: [], returns: "ptr" },
|
|
2434
|
+
});
|
|
2435
|
+
const api = library.symbols;
|
|
2436
|
+
const errno = () => read.i32(api.__errno_location());
|
|
2437
|
+
function stat(pid) {
|
|
2438
|
+
const raw = fs.readFileSync("/proc/" + pid + "/stat", "utf8");
|
|
2439
|
+
const end = raw.lastIndexOf(")"), fields = raw.slice(end + 2).trim().split(/\s+/);
|
|
2440
|
+
if (!raw.startsWith(pid + " (") || end < 0 || fields.length < 20 || !/^\d+$/.test(fields[19])) throw new Error("pty_process_identity_unconfirmed");
|
|
2441
|
+
return { parent: Number(fields[1]), tty: Number(fields[4]), birth: fields[19] };
|
|
2442
|
+
}
|
|
2443
|
+
function same(token) {
|
|
2444
|
+
const pollfd = Buffer.alloc(8);
|
|
2445
|
+
pollfd.writeInt32LE(token.fd, 0); pollfd.writeInt16LE(1, 4); // POLLIN reports exit, including an unreaped zombie.
|
|
2446
|
+
const result = api.poll(ptr(pollfd), 1, 0);
|
|
2447
|
+
if (result < 0) throw new Error("pty_identity_poll_failed");
|
|
2448
|
+
return result === 0;
|
|
2449
|
+
}
|
|
2450
|
+
function identity(pid) {
|
|
2451
|
+
const fd = api.pidfd_open(pid, 0);
|
|
2452
|
+
if (fd < 0) {
|
|
2453
|
+
if (errno() === 3) return null;
|
|
2454
|
+
throw new Error("pty_pidfd_unavailable");
|
|
2455
|
+
}
|
|
2456
|
+
try {
|
|
2457
|
+
const record = stat(pid), token = { pid, fd, birth: record.birth };
|
|
2458
|
+
if (!same(token)) { fs.closeSync(fd); return null; }
|
|
2459
|
+
return token;
|
|
2460
|
+
} catch (error) {
|
|
2461
|
+
fs.closeSync(fd);
|
|
2462
|
+
if (error.code === "ENOENT" || error.code === "ESRCH") return null;
|
|
2463
|
+
throw error;
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
function ttyDevice(anchor) {
|
|
2467
|
+
if (!same(anchor)) throw new Error("pty_session_identity_lost");
|
|
2468
|
+
const tty = stat(anchor.pid).tty;
|
|
2469
|
+
if (!tty || !same(anchor)) throw new Error("pty_terminal_identity_lost");
|
|
2470
|
+
return tty;
|
|
2471
|
+
}
|
|
2472
|
+
function childrenOf(pid) {
|
|
2473
|
+
const fd = fs.openSync("/proc/" + pid + "/task/" + pid + "/children", "r");
|
|
2474
|
+
try {
|
|
2475
|
+
const bytes = Buffer.alloc(262145), size = fs.readSync(fd, bytes, 0, bytes.length, null);
|
|
2476
|
+
if (size > 262144) throw new Error("pty_children_inventory_unconfirmed");
|
|
2477
|
+
const raw = bytes.subarray(0, size).toString("ascii");
|
|
2478
|
+
if (raw.length && !/^(?:[1-9][0-9]*\s+)*$/.test(raw)) throw new Error("pty_children_inventory_unconfirmed");
|
|
2479
|
+
const children = raw.trim() ? raw.trim().split(/\s+/).map(Number) : [];
|
|
2480
|
+
if (children.length > 32768 || new Set(children).size !== children.length) throw new Error("pty_children_inventory_unconfirmed");
|
|
2481
|
+
return children;
|
|
2482
|
+
} finally { fs.closeSync(fd); }
|
|
2483
|
+
}
|
|
2484
|
+
function session(anchor, device) {
|
|
2485
|
+
if (!same(anchor) || api.getsid(anchor.pid) !== anchor.pid || ttyDevice(anchor) !== device) throw new Error("pty_session_identity_lost");
|
|
2486
|
+
// Fresh open at position zero: the kernel checks the first direct child
|
|
2487
|
+
// under tasklist_lock. Nonempty seq walks can omit exiting children, so
|
|
2488
|
+
// they are ONLY a worklist. Only an empty fresh read, with the subreaper
|
|
2489
|
+
// still pinned and unable to spawn again, proves the tree is empty.
|
|
2490
|
+
const children = childrenOf(anchor.pid);
|
|
2491
|
+
const members = [];
|
|
2492
|
+
try {
|
|
2493
|
+
for (const pid of children) {
|
|
2494
|
+
const token = identity(pid);
|
|
2495
|
+
if (!token) continue;
|
|
2496
|
+
members.push(token);
|
|
2497
|
+
let parent;
|
|
2498
|
+
try { parent = stat(pid).parent; }
|
|
2499
|
+
catch (error) {
|
|
2500
|
+
if (error.code === "ENOENT" && !same(token)) continue;
|
|
2501
|
+
throw error;
|
|
2502
|
+
}
|
|
2503
|
+
if (!same(anchor)) throw new Error("pty_session_identity_lost");
|
|
2504
|
+
if (parent !== anchor.pid && same(token)) throw new Error("pty_child_ownership_changed");
|
|
2505
|
+
}
|
|
2506
|
+
if (!same(anchor)) throw new Error("pty_session_identity_lost");
|
|
2507
|
+
return { members, empty: children.length === 0 };
|
|
2508
|
+
} catch (error) { for (const token of members) fs.closeSync(token.fd); throw error; }
|
|
2509
|
+
}
|
|
2510
|
+
function signal(token, number) {
|
|
2511
|
+
if (api.pidfd_send_signal(token.fd, number, null, 0) !== 0 && errno() !== 3) throw new Error("pty_signal_unconfirmed");
|
|
2512
|
+
}
|
|
2513
|
+
function controlPair() {
|
|
2514
|
+
const descriptors = new Int32Array(2);
|
|
2515
|
+
if (api.socketpair(1, 1 | 0x80000, 0, ptr(descriptors)) !== 0) throw new Error("pty_control_unavailable"); // SOCK_CLOEXEC
|
|
2516
|
+
return [...descriptors];
|
|
2517
|
+
}
|
|
2518
|
+
function readControl(fd) {
|
|
2519
|
+
const bytes = Buffer.alloc(256), size = api.recv(fd, ptr(bytes), bytes.length, 0x40); // MSG_DONTWAIT
|
|
2520
|
+
if (size < 0) {
|
|
2521
|
+
if ([4, 11].includes(errno())) return "";
|
|
2522
|
+
throw new Error("pty_control_read_failed");
|
|
2523
|
+
}
|
|
2524
|
+
if (!size) throw new Error("pty_control_closed");
|
|
2525
|
+
return bytes.subarray(0, size).toString("ascii");
|
|
2526
|
+
}
|
|
2527
|
+
function releaseControl(fd) {
|
|
2528
|
+
const bytes = Buffer.from("release\n");
|
|
2529
|
+
if (api.send(fd, ptr(bytes), bytes.length, 0x40 | 0x4000) !== bytes.length) throw new Error("pty_control_release_failed"); // MSG_NOSIGNAL
|
|
2530
|
+
}
|
|
2531
|
+
// CONFIG_PROC_CHILDREN and pidfd signaling must work before admission.
|
|
2532
|
+
probeLinuxPtyLibrary({ identity: () => identity(process.pid), children: () => childrenOf(process.pid),
|
|
2533
|
+
signal: (probe) => signal(probe, 0), release: (probe) => fs.closeSync(probe.fd), close: () => library.close() });
|
|
2534
|
+
nativePtyOwner = { identity, same, session, ttyDevice, signal, controlPair, readControl, releaseControl,
|
|
2535
|
+
releaseIdentity: (token) => fs.closeSync(token.fd), identityKey: (token) => token.pid + ":" + token.birth,
|
|
2536
|
+
group: api.getpgid, sid: api.getsid };
|
|
2537
|
+
return nativePtyOwner;
|
|
2538
|
+
}
|
|
2539
|
+
// Persistent terminals (config.managedPty.persistent, Linux). Each terminal's
|
|
2540
|
+
// shell lives in its own tmux server, started detached in a private 0700
|
|
2541
|
+
// directory derived from this worker's managed-jobs scope (one Session on one
|
|
2542
|
+
// root), so it outlives this worker. A viewer PTY job runs only a tmux client
|
|
2543
|
+
// attached to a host: closing the viewer, releasing the Session's interaction
|
|
2544
|
+
// (last tab closed, idle, Disconnect) or this worker stopping ends that client
|
|
2545
|
+
// (detach), never the shell, and frees nothing but the client. The next worker
|
|
2546
|
+
// of the same scope (one at a time, as its journal ownership enforces) finds
|
|
2547
|
+
// those shells, so a reopened terminal reattaches through an ordinary pty.start
|
|
2548
|
+
// with that start's authority. A new viewer takes a host no live viewer holds,
|
|
2549
|
+
// so concurrent terminals keep separate shells. The directory comes from the
|
|
2550
|
+
// trusted config, never a payload, so only this Session's terminals reach its
|
|
2551
|
+
// sockets. A persisted shell ends when it exits (exit or Ctrl-D, a Device
|
|
2552
|
+
// terminal's explicit close) or 12 h after it started (a host's lease cap):
|
|
2553
|
+
// tmux then hangs up the shell, which hangs up its jobs. As on cloud session
|
|
2554
|
+
// terminals (same tmux settings, shared/session-terminals.ts), processes the
|
|
2555
|
+
// user detaches from the shell (nohup, setsid, disown, or jobs left at exit)
|
|
2556
|
+
// are not swept. At most TERMINAL_HOST_LIMIT shells per scope.
|
|
2557
|
+
const TERMINAL_SHELL = "/bin/bash --noprofile --norc -i";
|
|
2558
|
+
const TERMINAL_TMUX_CONFIG = ${JSON.stringify([...B,"new-session -d -s main /bin/bash --noprofile --norc -i","set -g exit-empty on","run-shell -b -d 43200 -C kill-server",""].join(`
|
|
2559
|
+
`))};
|
|
2560
|
+
const TERMINAL_HOST_LIMIT = 4;
|
|
2561
|
+
const terminalViewers = new Map();
|
|
2562
|
+
let terminalTmux;
|
|
2563
|
+
let terminalChoice = Promise.resolve();
|
|
2564
|
+
function terminalTmuxPath() {
|
|
2565
|
+
if (terminalTmux !== undefined) return terminalTmux;
|
|
2566
|
+
terminalTmux = null;
|
|
2567
|
+
if (process.platform !== "linux" || !config.managedPty || config.managedPty.persistent !== true || config.jobsUser) return null;
|
|
2568
|
+
for (const candidate of ["/usr/bin/tmux", "/usr/local/bin/tmux", "/bin/tmux"]) {
|
|
2569
|
+
// 3.2 added clients that never start a server (-N) and run-shell -d/-C.
|
|
2570
|
+
const version = spawnSync(candidate, ["-V"], { encoding: "utf8", timeout: 2000, env: { PATH: "/usr/bin:/bin" } });
|
|
2571
|
+
const match = /^tmux (?:next-)?([0-9]+)\.([0-9]+)/.exec(version.status === 0 ? version.stdout : "");
|
|
2572
|
+
if (match && Number(match[1]) * 100 + Number(match[2]) >= 302) { terminalTmux = candidate; break; }
|
|
2573
|
+
}
|
|
2574
|
+
return terminalTmux;
|
|
2575
|
+
}
|
|
2576
|
+
// PTY admission needs the program to lead its own foreground group, as the
|
|
2577
|
+
// interactive shell does. An interactive bash running a compound command
|
|
2578
|
+
// (never a lone exec, which skips job control) leads one and runs tmux as its job.
|
|
2579
|
+
function terminalCommand(command) { return TERMINAL_SHELL + ' -c "' + command + '; exit"'; }
|
|
2580
|
+
function terminalScopeDirectory() {
|
|
2581
|
+
// Terminals a later worker keeps under the Reason name are found there, when that is this
|
|
2582
|
+
// user's own private directory (never followed through a link); else /tmp/ara-terminals-*.
|
|
2583
|
+
const scope = digest(config.managedJobs.scopeId).slice(0, 16);
|
|
2584
|
+
const reasonDirectory = "/tmp/reason-terminals-" + scope;
|
|
2585
|
+
let ownReasonDirectory = false;
|
|
2586
|
+
try {
|
|
2587
|
+
const found = fs.lstatSync(reasonDirectory);
|
|
2588
|
+
ownReasonDirectory = found.isDirectory() && found.uid === process.getuid() && (found.mode & 0o077) === 0;
|
|
2589
|
+
} catch { /* none */ }
|
|
2590
|
+
const directory = ownReasonDirectory ? reasonDirectory : "/tmp/ara-terminals-" + scope;
|
|
2591
|
+
try { fs.mkdirSync(directory, { mode: 0o700 }); }
|
|
2592
|
+
catch (error) { if (!error || error.code !== "EEXIST") throw error; }
|
|
2593
|
+
const stat = fs.lstatSync(directory);
|
|
2594
|
+
if (!stat.isDirectory() || stat.uid !== process.getuid() || (stat.mode & 0o077) !== 0) throw new Error("terminal_directory_invalid");
|
|
2595
|
+
return directory;
|
|
2596
|
+
}
|
|
2597
|
+
function terminalHostServes(socketPath) {
|
|
2598
|
+
return new Promise((resolve) => {
|
|
2599
|
+
const child = spawn(terminalTmux, ["-S", socketPath, "-N", "has-session", "-t", "=main"],
|
|
2600
|
+
{ stdio: "ignore", env: { PATH: "/usr/bin:/bin" } });
|
|
2601
|
+
const timer = setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* exited */ } }, 2000);
|
|
2602
|
+
child.once("error", () => { clearTimeout(timer); resolve(false); });
|
|
2603
|
+
child.once("close", (code) => { clearTimeout(timer); resolve(code === 0); });
|
|
2604
|
+
});
|
|
2605
|
+
}
|
|
2606
|
+
/** Starts a detached tmux server with this Session's private environment. A
|
|
2607
|
+
* stale socket at the path is replaced by tmux itself. */
|
|
2608
|
+
function startTerminalHost(socketPath) {
|
|
2609
|
+
if (stopped || !/^[A-Za-z0-9/._-]+$/.test(socketPath)) return false;
|
|
2610
|
+
const configPath = socketPath + ".conf";
|
|
2611
|
+
try { fs.unlinkSync(configPath); } catch { /* absent */ }
|
|
2612
|
+
fs.writeFileSync(configPath, TERMINAL_TMUX_CONFIG, { mode: 0o600, flag: "wx" });
|
|
2613
|
+
const env = { ...environmentForSpawn().env, TERM: "xterm-256color", BASH_SILENCE_DEPRECATION_WARNING: "1" };
|
|
2614
|
+
return spawnSync(terminalTmux, ["-u", "-S", socketPath, "-f", configPath, "start-server"],
|
|
2615
|
+
{ cwd: config.repoPath, env, stdio: "ignore", timeout: 5000 }).status === 0;
|
|
2616
|
+
}
|
|
2617
|
+
/** The program a new PTY job runs: a client attached to a free persistent
|
|
2618
|
+
* terminal, or the one-shot shell. release() frees the host once the job ends. */
|
|
2619
|
+
function terminalProgram() {
|
|
2620
|
+
// Serialized, so concurrent starts never pick the same free host.
|
|
2621
|
+
const choice = terminalChoice.then(chooseTerminalProgram);
|
|
2622
|
+
terminalChoice = choice.catch(() => {});
|
|
2623
|
+
return choice;
|
|
2624
|
+
}
|
|
2625
|
+
async function chooseTerminalProgram() {
|
|
2626
|
+
// A Mac terminal runs the user's login shell, as their own terminal would.
|
|
2627
|
+
const shell = config.managedPty && config.managedPty.shell;
|
|
2628
|
+
const oneShot = { program: shell ? shell + " -i -l" : TERMINAL_SHELL, release: () => {} };
|
|
2629
|
+
if (stopped || !terminalTmuxPath()) return oneShot;
|
|
2630
|
+
let directory;
|
|
2631
|
+
try { directory = terminalScopeDirectory(); } catch { return oneShot; }
|
|
2632
|
+
const numbers = fs.readdirSync(directory).filter((name) => /^[1-9][0-9]{0,8}$/.test(name)).map(Number).sort((a, b) => a - b);
|
|
2633
|
+
let socketPath = null, live = 0, unused = null;
|
|
2634
|
+
for (const number of numbers) {
|
|
2635
|
+
const candidate = path.join(directory, String(number));
|
|
2636
|
+
if (await terminalHostServes(candidate)) {
|
|
2637
|
+
live += 1;
|
|
2638
|
+
if (!socketPath && !terminalViewers.get(candidate)) socketPath = candidate;
|
|
2639
|
+
} else if (unused === null && !terminalViewers.get(candidate)) unused = candidate;
|
|
2640
|
+
}
|
|
2641
|
+
if (!socketPath) {
|
|
2642
|
+
if (live >= TERMINAL_HOST_LIMIT) return oneShot;
|
|
2643
|
+
let next = 1;
|
|
2644
|
+
while (numbers.includes(next)) next += 1;
|
|
2645
|
+
socketPath = unused || path.join(directory, String(next));
|
|
2646
|
+
try { if (!startTerminalHost(socketPath)) return oneShot; } catch { return oneShot; }
|
|
2647
|
+
}
|
|
2648
|
+
terminalViewers.set(socketPath, (terminalViewers.get(socketPath) || 0) + 1);
|
|
2649
|
+
let released = false;
|
|
2650
|
+
const release = () => {
|
|
2651
|
+
if (released) return;
|
|
2652
|
+
released = true;
|
|
2653
|
+
const viewers = terminalViewers.get(socketPath) - 1;
|
|
2654
|
+
if (viewers > 0) terminalViewers.set(socketPath, viewers); else terminalViewers.delete(socketPath);
|
|
2655
|
+
};
|
|
2656
|
+
for (const deadline = Date.now() + 3000; !stopped && Date.now() < deadline;) {
|
|
2657
|
+
if (await terminalHostServes(socketPath)) {
|
|
2658
|
+
return { program: terminalCommand(terminalTmux + " -u -S " + socketPath + " -N attach-session -t =main"), release };
|
|
2659
|
+
}
|
|
2660
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
2661
|
+
}
|
|
2662
|
+
release();
|
|
2663
|
+
return oneShot;
|
|
2664
|
+
}
|
|
2665
|
+
function ptyOperation(request, managed, program = TERMINAL_SHELL) {
|
|
2666
|
+
const native = platformPtyOwner();
|
|
2667
|
+
let child = null, anchor = null, terminal = null, childEnvironment = null, device = null;
|
|
2668
|
+
let done = false, cancelled = false, timedOut = false, cleanup = null, exitCode = null;
|
|
2669
|
+
let timer = null, poll = null, metadata = "", shellPid = null, admitted = false;
|
|
2670
|
+
let controlFd = null;
|
|
2671
|
+
let controlReleased = false;
|
|
2672
|
+
let subreaper = process.platform !== "linux";
|
|
2673
|
+
const spawnGeneration = targetGeneration, spawnFence = targetFenceId;
|
|
2674
|
+
let resolveResult, acknowledgeSpawn;
|
|
2675
|
+
const promise = new Promise((resolve) => { resolveResult = resolve; });
|
|
2676
|
+
const started = new Promise((resolve) => { acknowledgeSpawn = resolve; });
|
|
2677
|
+
const finish = (error, code) => {
|
|
2678
|
+
if (done) return;
|
|
2679
|
+
done = true; acknowledgeSpawn(false);
|
|
2680
|
+
clearTimeout(timer); clearInterval(poll);
|
|
2681
|
+
let closeFailed = false;
|
|
2682
|
+
try { if (terminal) terminal.close(); } catch { closeFailed = true; }
|
|
2683
|
+
try { if (controlFd !== null) fs.closeSync(controlFd); } catch { closeFailed = true; }
|
|
2684
|
+
try { if (anchor) native.releaseIdentity?.(anchor); } catch { closeFailed = true; }
|
|
2685
|
+
controlFd = null;
|
|
2686
|
+
if (closeFailed) { error = "process_group_cleanup_unconfirmed"; code = 1; }
|
|
2687
|
+
resolveResult(error ? failure(request.id, code, error, "", "") : success(request.id, "", ""));
|
|
2688
|
+
};
|
|
2689
|
+
const delay = () => new Promise((resolve) => setTimeout(resolve, 20));
|
|
2690
|
+
function stopSession() {
|
|
2691
|
+
if (cleanup) return cleanup;
|
|
2692
|
+
cleanup = Promise.resolve().then(async () => {
|
|
2693
|
+
if (!child) { finish(timedOut ? "timeout" : "cancelled", timedOut ? 124 : 130); return; }
|
|
2694
|
+
try {
|
|
2695
|
+
if (!anchor || child.exitCode !== null) throw new Error("pty_session_identity_lost");
|
|
2696
|
+
const startedAt = Date.now(), termSent = new Set();
|
|
2697
|
+
for (;;) {
|
|
2698
|
+
const { members, empty } = native.session(anchor, device);
|
|
2699
|
+
try {
|
|
2700
|
+
if (empty && exitCode !== null && subreaper) break;
|
|
2701
|
+
if (Date.now() - startedAt > ${C}) throw new Error("pty_cleanup_timeout");
|
|
2702
|
+
for (const token of members) {
|
|
2703
|
+
if (!native.same(anchor)) throw new Error("pty_session_identity_lost");
|
|
2704
|
+
const id = native.identityKey(token);
|
|
2705
|
+
if (Date.now() - startedAt >= 500) native.signal(token, 9);
|
|
2706
|
+
else if (!termSent.has(id)) { native.signal(token, 15); termSent.add(id); }
|
|
2707
|
+
}
|
|
2708
|
+
} finally { for (const token of members) native.releaseIdentity?.(token); }
|
|
2709
|
+
await delay();
|
|
2710
|
+
}
|
|
2711
|
+
// The fixed bootstrap cannot spawn again after its interactive shell
|
|
2712
|
+
// exits. Release only after the still-owned session has no other tasks.
|
|
2713
|
+
native.releaseControl(controlFd);
|
|
2714
|
+
controlReleased = true;
|
|
2715
|
+
let exitTimer;
|
|
2716
|
+
try { await Promise.race([child.exited, new Promise((_, reject) => {
|
|
2717
|
+
exitTimer = setTimeout(() => reject(new Error("pty_anchor_exit_unconfirmed")), ${I});
|
|
2718
|
+
})]); } finally { clearTimeout(exitTimer); }
|
|
2719
|
+
if (timedOut) finish("timeout", 124);
|
|
2720
|
+
else if (cancelled) finish("cancelled", 130);
|
|
2721
|
+
else finish(exitCode === 0 ? null : "command_failed", exitCode ?? 1);
|
|
2722
|
+
} catch {
|
|
2723
|
+
// Do not signal a reconstructed PID/session after losing the anchor.
|
|
2724
|
+
finish("process_group_cleanup_unconfirmed", 1);
|
|
2725
|
+
}
|
|
2726
|
+
});
|
|
2727
|
+
return cleanup;
|
|
2728
|
+
}
|
|
2729
|
+
function arm(deadline) {
|
|
2730
|
+
clearTimeout(timer);
|
|
2731
|
+
timer = setTimeout(() => { timedOut = true; void stopSession(); }, Math.max(1, deadline - Date.now()));
|
|
2732
|
+
}
|
|
2733
|
+
// Each accepted input is handed to Bun exactly once. Terminal.write() keeps
|
|
2734
|
+
// whatever the PTY cannot take now in Bun's own ordered queue and writes it as
|
|
2735
|
+
// the PTY drains; its return value counts only bytes written synchronously, so
|
|
2736
|
+
// writing the "rest" again duplicates input. Bun 1.3.14 never calls the
|
|
2737
|
+
// Terminal "drain" callback (Terminal.zig wires "onWritable", but
|
|
2738
|
+
// PosixStreamingWriter reads "function_table.onReady") and exposes no queue
|
|
2739
|
+
// size, so that queue cannot be bounded here; each input stays capped by the
|
|
2740
|
+
// protocol's per-request size and the bridge sends one at a time.
|
|
2741
|
+
const canInput = () => done || cleanup || !admitted || !terminal || terminal.closed ? "job_not_running" : null;
|
|
2742
|
+
try {
|
|
2743
|
+
childEnvironment = environmentForSpawn();
|
|
2744
|
+
if (stopped || spawnGeneration !== targetGeneration || spawnFence !== targetFenceId
|
|
2745
|
+
|| !validJobLease(managed.leaseExpiresAtMs)) throw new Error("pty_spawn_expired");
|
|
2746
|
+
// A fixed non-login bootstrap pins the session identity. Private fds are
|
|
2747
|
+
// closed before the interactive shell; no workspace startup/env file runs.
|
|
2748
|
+
const bootstrap = 'trap ":" HUP TERM; '
|
|
2749
|
+
+ '/bin/bash --noprofile --norc -c \'printf "ready:%s\\n" "$$" >&3; exec 3>&-; exec ' + [...jobsUserPrefix(false), program].join(" ") + '\'; '
|
|
2750
|
+
+ 'ara_pty_exit=$?; printf "exit:%s\\n" "$ara_pty_exit" >&3; '
|
|
2751
|
+
+ 'while IFS= read -r ara_pty_control <&3; do [ "$ara_pty_control" = release ] && break; done; exit "$ara_pty_exit"';
|
|
2752
|
+
const pair = native.controlPair(); controlFd = pair[0];
|
|
2753
|
+
try {
|
|
2754
|
+
const command = process.platform === "linux"
|
|
2755
|
+
? [process.execPath, "--no-env-file", "--config=/dev/null", "-e", LINUX_PTY_EXEC_SOURCE + "(" + JSON.stringify(bootstrap) + ")"]
|
|
2756
|
+
: [FIXED_BASH, "--noprofile", "--norc", "-c", bootstrap];
|
|
2757
|
+
const env = { ...childEnvironment.env, TERM: "xterm-256color", BASH_SILENCE_DEPRECATION_WARNING: "1" };
|
|
2758
|
+
if (process.platform === "linux") env[["BUN", "BE", "BUN"].join("_")] = "1";
|
|
2759
|
+
if (process.platform === "darwin") {
|
|
2760
|
+
if (config.managedPty.shell) env.SHELL = config.managedPty.shell;
|
|
2761
|
+
// Without a locale, a Mac shell's line editor reads typed UTF-8 as
|
|
2762
|
+
// meta keys and drops it.
|
|
2763
|
+
if (!env.LANG && !env.LC_ALL) env.LANG = "en_US.UTF-8";
|
|
2764
|
+
}
|
|
2765
|
+
child = Bun.spawn({ cmd: command, cwd: config.repoPath, env,
|
|
2766
|
+
stdio: ["ignore", "ignore", "ignore", pair[1]],
|
|
2767
|
+
terminal: { cols: request.input.cols, rows: request.input.rows, data: (_terminal, bytes) => {
|
|
2768
|
+
if (!done) managed.onBytes(bytes);
|
|
2769
|
+
} },
|
|
2770
|
+
});
|
|
2771
|
+
} finally { fs.closeSync(pair[1]); }
|
|
2772
|
+
terminal = child.terminal;
|
|
2773
|
+
anchor = native.identity(child.pid);
|
|
2774
|
+
if (!terminal || !anchor || native.sid(child.pid) !== child.pid || native.group(child.pid) !== child.pid) throw new Error("pty_spawn_identity_unconfirmed");
|
|
2775
|
+
device = native.ttyDevice(anchor);
|
|
2776
|
+
consumedEnvironment = { revision: childEnvironment.snapshot.revision, hash: childEnvironment.hash };
|
|
2777
|
+
managed.onEnvironment(childEnvironment.snapshot);
|
|
2778
|
+
arm(managed.leaseExpiresAtMs);
|
|
2779
|
+
poll = setInterval(() => {
|
|
2780
|
+
if (done || controlReleased) return;
|
|
2781
|
+
try {
|
|
2782
|
+
metadata += native.readControl(controlFd);
|
|
2783
|
+
if (metadata.length > 512) throw new Error("pty_metadata_invalid");
|
|
2784
|
+
for (;;) {
|
|
2785
|
+
const end = metadata.indexOf("\n");
|
|
2786
|
+
if (end < 0) break;
|
|
2787
|
+
const line = metadata.slice(0, end); metadata = metadata.slice(end + 1);
|
|
2788
|
+
if (line === "subreaper" && !subreaper && shellPid === null) subreaper = true;
|
|
2789
|
+
else if (/^ready:[1-9][0-9]*$/.test(line) && shellPid === null) shellPid = Number(line.slice(6));
|
|
2790
|
+
else if (/^exit:[0-9]{1,3}$/.test(line) && Number(line.slice(5)) <= 255) {
|
|
2791
|
+
exitCode = Number(line.slice(5)); void stopSession();
|
|
2792
|
+
} else throw new Error("pty_metadata_invalid");
|
|
2793
|
+
}
|
|
2794
|
+
if (!admitted && !cleanup && subreaper && shellPid && native.same(anchor) && native.sid(shellPid) === child.pid && native.group(shellPid) === shellPid) {
|
|
2795
|
+
admitted = true; acknowledgeSpawn(true);
|
|
2796
|
+
}
|
|
2797
|
+
} catch { cancelled = true; void stopSession(); }
|
|
2798
|
+
}, 10);
|
|
2799
|
+
void child.exited.then(() => { if (!done && !cleanup) void stopSession(); });
|
|
2800
|
+
} catch {
|
|
2801
|
+
if (child) { cancelled = true; void stopSession(); }
|
|
2802
|
+
else finish("spawn_failed", 1);
|
|
2803
|
+
}
|
|
2804
|
+
return { promise, started, supportsCancellation: true, canInput,
|
|
2805
|
+
settle: stopSession,
|
|
2806
|
+
processIdentity() {
|
|
2807
|
+
return admitted && !done && child.exitCode === null && native.same(anchor)
|
|
2808
|
+
? { pid: child.pid, processGroupId: child.pid, executorLaunchId: config.launchId } : null;
|
|
2809
|
+
},
|
|
2810
|
+
input(data) {
|
|
2811
|
+
const error = canInput(); if (error) return error;
|
|
2812
|
+
try { terminal.write(data); return null; }
|
|
2813
|
+
catch { cancelled = true; void stopSession(); return "job_not_running"; }
|
|
2814
|
+
},
|
|
2815
|
+
resize(cols, rows) {
|
|
2816
|
+
const error = canInput(); if (error) return error;
|
|
2817
|
+
try { terminal.resize(cols, rows); return null; }
|
|
2818
|
+
catch { cancelled = true; void stopSession(); return "job_not_running"; }
|
|
2819
|
+
},
|
|
2820
|
+
renew(deadline) { if (done || cleanup) return false; arm(deadline); return true; },
|
|
2821
|
+
cancel() { cancelled = true; return stopSession(); },
|
|
2822
|
+
};
|
|
2823
|
+
}
|
|
2824
|
+
function bashOperation(request, onProgress, managed, piShell) {
|
|
2825
|
+
// Only a claimed retainBackground request; never managed jobs. Pi's shell
|
|
2826
|
+
// retains its background processes by stock Pi's rule (settle below).
|
|
2827
|
+
const retainBackground = !managed && !piShell && request.input.retainBackground === true;
|
|
2828
|
+
// Only a claimed machineSdk request: reason-sdk lives exactly as long as it.
|
|
2829
|
+
const machineSdk = !managed && !piShell && request.input.machineSdk === true;
|
|
2830
|
+
let sdkEndpoint = null;
|
|
2831
|
+
let child = null;
|
|
2832
|
+
let groupCleanup = null;
|
|
2833
|
+
let timedOut = false;
|
|
2834
|
+
let cancelled = false;
|
|
2835
|
+
let done = false;
|
|
2836
|
+
let timer = null;
|
|
2837
|
+
const preparationAbort = new AbortController();
|
|
2838
|
+
let preparation = null;
|
|
2839
|
+
let childEnvironment = null;
|
|
2840
|
+
const spawnGeneration = targetGeneration;
|
|
2841
|
+
const spawnFence = targetFenceId;
|
|
2842
|
+
// Pi's shell has no default timeout (stock Pi's has none): it runs until the
|
|
2843
|
+
// model's own timeout, a cancel or the Hands lease.
|
|
2844
|
+
const initialDeadline = piShell ? config.expiresAtMs
|
|
2845
|
+
: Math.min(config.expiresAtMs, managed ? managed.leaseExpiresAtMs : Date.now() + config.bashTimeoutMs);
|
|
2846
|
+
let acknowledgeSpawn;
|
|
2847
|
+
const started = new Promise((resolve) => { acknowledgeSpawn = resolve; });
|
|
2848
|
+
const canInput = (data) => {
|
|
2849
|
+
if (done || cancelled || timedOut || !child || !child.stdin || child.stdin.destroyed || child.stdin.writableEnded) return "job_not_running";
|
|
2850
|
+
if (child.stdin.writableLength + Buffer.byteLength(data) > 65536) return "job_input_backpressure";
|
|
2851
|
+
return null;
|
|
2852
|
+
};
|
|
2853
|
+
const startGroupCleanup = () => {
|
|
2854
|
+
if (!child) return Promise.resolve();
|
|
2855
|
+
if (!groupCleanup) {
|
|
2856
|
+
groupCleanup = killGroup(child);
|
|
2857
|
+
// Keep the rejection observed even if the process has not closed yet;
|
|
2858
|
+
// callers still await the original promise and fail closed on it.
|
|
2859
|
+
void groupCleanup.catch(() => {});
|
|
2860
|
+
}
|
|
2861
|
+
return groupCleanup;
|
|
2862
|
+
};
|
|
2863
|
+
let progressTimer = null;
|
|
2864
|
+
let progressSequence = 0;
|
|
2865
|
+
let keepaliveTimer = null;
|
|
2866
|
+
const promise = new Promise((resolve) => {
|
|
2867
|
+
const stdoutDecoder = new StringDecoder("utf8");
|
|
2868
|
+
const stderrDecoder = new StringDecoder("utf8");
|
|
2869
|
+
let stdout = "";
|
|
2870
|
+
let stderr = "";
|
|
2871
|
+
const finish = (result) => {
|
|
2872
|
+
if (done) return;
|
|
2873
|
+
done = true;
|
|
2874
|
+
if (sdkEndpoint) sdkEndpoint.close();
|
|
2875
|
+
if (result.error === "process_group_cleanup_unconfirmed") cleanupUnconfirmed = true;
|
|
2876
|
+
acknowledgeSpawn(false);
|
|
2877
|
+
clearTimeout(timer);
|
|
2878
|
+
clearTimeout(progressTimer);
|
|
2879
|
+
clearInterval(keepaliveTimer);
|
|
2880
|
+
if (preparation) {
|
|
2881
|
+
if (result.error === "process_group_cleanup_unconfirmed") preparation.abandon();
|
|
2882
|
+
else preparation.release();
|
|
2883
|
+
}
|
|
2884
|
+
resolve(result);
|
|
2885
|
+
};
|
|
2886
|
+
const publishProgress = () => {
|
|
2887
|
+
progressTimer = null;
|
|
2888
|
+
if (done || (stdout.length === 0 && stderr.length === 0)) return;
|
|
2889
|
+
progressSequence += 1;
|
|
2890
|
+
if (onProgress) onProgress(stdout, stderr, progressSequence);
|
|
2891
|
+
};
|
|
2892
|
+
const scheduleProgress = () => {
|
|
2893
|
+
if (done || progressTimer) return;
|
|
2894
|
+
progressTimer = setTimeout(publishProgress, 50);
|
|
2895
|
+
};
|
|
2896
|
+
timer = setTimeout(() => {
|
|
2897
|
+
timedOut = true;
|
|
2898
|
+
preparationAbort.abort();
|
|
2899
|
+
if (child) void startGroupCleanup().catch(() => {});
|
|
2900
|
+
else finish(failure(request.id, 124, "timeout", "", ""));
|
|
2901
|
+
}, Math.max(1, initialDeadline - Date.now()));
|
|
2902
|
+
const launch = async () => {
|
|
2903
|
+
if (!validPrivateJob && config.environmentSnapshotPath === undefined && !refreshJobEnvSnapshot()) {
|
|
2904
|
+
finish(failure(request.id, 1, "spawn_failed", "", "job_env_refresh_failed"));
|
|
2905
|
+
return;
|
|
2906
|
+
}
|
|
2907
|
+
try {
|
|
2908
|
+
if (machineSdk) {
|
|
2909
|
+
try {
|
|
2910
|
+
sdkEndpoint = machineSdkEndpoint(request.id);
|
|
2911
|
+
await sdkEndpoint.ready;
|
|
2912
|
+
} catch {
|
|
2913
|
+
// Nothing ran: reason-sdk could not be set up on this machine.
|
|
2914
|
+
finish(failure(request.id, 1, "spawn_failed", "", "machine_sdk_unavailable: reason-sdk could not be set up on this machine; nothing ran."));
|
|
2915
|
+
return;
|
|
2916
|
+
}
|
|
2917
|
+
if (done) { sdkEndpoint.close(); return; }
|
|
2918
|
+
}
|
|
2919
|
+
let command = [
|
|
2920
|
+
...(config.bashCommandPrefix || []),
|
|
2921
|
+
FIXED_BASH,
|
|
2922
|
+
"--noprofile",
|
|
2923
|
+
"--norc",
|
|
2924
|
+
"-c",
|
|
2925
|
+
'set -euo pipefail; '
|
|
2926
|
+
+ (config.environmentSnapshotPath === undefined ? 'set -a; . "$1"; set +a; ' : '')
|
|
2927
|
+
// The wrapper starts with no startup file; Pi's shell then has none set, as stock Pi's.
|
|
2928
|
+
+ (piShell ? 'unset BASH_ENV ENV; ' : 'export BASH_ENV=/dev/null ENV=/dev/null; ')
|
|
2929
|
+
+ 'unset NODE_OPTIONS NODE_PATH LD_PRELOAD LD_LIBRARY_PATH; '
|
|
2930
|
+
+ machineMemoryPrelude()
|
|
2931
|
+
+ (sdkEndpoint ? sdkEndpoint.prelude : '')
|
|
2932
|
+
+ (managed
|
|
2933
|
+
? 'IFS= read -r -d "" ara_job_script; eval "$ara_job_script"'
|
|
2934
|
+
// Pi runs bash -c with no stdin script and no errexit.
|
|
2935
|
+
: piShell ? 'exec /bin/bash --noprofile --norc -c "$2"'
|
|
2936
|
+
: 'exec /bin/bash --noprofile --norc -se'),
|
|
2937
|
+
"h1-bash",
|
|
2938
|
+
config.environmentSnapshotPath === undefined ? config.jobEnvPath : "/dev/null",
|
|
2939
|
+
...(piShell ? [request.input.script] : []),
|
|
2940
|
+
];
|
|
2941
|
+
if (validPrivateJob) command = [...(config.bashCommandPrefix || []), config.directShell.executable,
|
|
2942
|
+
config.directShell.mode === "host" ? "-lic" : "-c",
|
|
2943
|
+
// Interactive login configuration is retained, but foreground commands
|
|
2944
|
+
// must stay in the owned process group instead of shell job-control groups.
|
|
2945
|
+
config.directShell.mode === "host" ? "set +m; " + request.input.script : request.input.script];
|
|
2946
|
+
// Resident Hands a run took over report ready at claim_ack, while their
|
|
2947
|
+
// environment for this generation is still being installed: wait for it,
|
|
2948
|
+
// bounded, instead of refusing; the wait is reported (environmentWaitMs).
|
|
2949
|
+
// Before the spawn preparation and the currency check below, which then
|
|
2950
|
+
// re-prove cancellation, generation, fence and deadline after the wait.
|
|
2951
|
+
if (config.environmentSnapshotPath !== undefined && environmentWaitNegotiated) {
|
|
2952
|
+
const waitedMs = await waitForSpawnEnvironment(Boolean(piShell), Boolean(piShell && piShell.confined));
|
|
2953
|
+
if (waitedMs > 0) environmentWaits.set(request.id, waitedMs);
|
|
2954
|
+
}
|
|
2955
|
+
if (config.spawnPolicy) {
|
|
2956
|
+
preparation = await prepareBashSpawn(command, 0, initialDeadline, preparationAbort.signal, () => {
|
|
2957
|
+
cancelled = true;
|
|
2958
|
+
void startGroupCleanup().catch(() => {});
|
|
2959
|
+
});
|
|
2960
|
+
command = preparation.argv;
|
|
2961
|
+
}
|
|
2962
|
+
if (done || cancelled || timedOut || stopped || spawnGeneration !== targetGeneration || spawnFence !== targetFenceId
|
|
2963
|
+
|| Date.now() >= initialDeadline || (preparation && !preparation.current())) {
|
|
2964
|
+
if (preparation) preparation.release();
|
|
2965
|
+
finish(failure(request.id, 130, "cancelled", "", ""));
|
|
2966
|
+
return;
|
|
2967
|
+
}
|
|
2968
|
+
// This is deliberately after policy RPC awaits: a missing/revoked/stale
|
|
2969
|
+
// snapshot cannot authorize a later asynchronous child spawn.
|
|
2970
|
+
if (config.environmentSnapshotPath !== undefined) childEnvironment = environmentForSpawn(Boolean(piShell), Boolean(piShell && piShell.confined));
|
|
2971
|
+
if (childEnvironment?.confinementPrefix) command = [...childEnvironment.confinementPrefix, ...command];
|
|
2972
|
+
if (managed && managed.asJobsUser) command = [...jobsUserPrefix(true), ...command];
|
|
2973
|
+
if (preparation && childEnvironment && preparation.environmentSnapshotSha256 !== childEnvironment.fileSha256) throw new Error("environment_preparation_changed");
|
|
2974
|
+
if (Date.now() >= initialDeadline || (preparation && !preparation.current())) throw new Error("spawn_lease_expired");
|
|
2975
|
+
child = spawn(
|
|
2976
|
+
command[0], command.slice(1),
|
|
2977
|
+
{
|
|
2978
|
+
cwd: config.repoPath,
|
|
2979
|
+
detached: true,
|
|
2980
|
+
env: validPrivateJob ? process.env : childEnvironment ? childEnvironment.env : {
|
|
2981
|
+
...process.env,
|
|
2982
|
+
// A physical Device keeps the CLI-resolved host PATH (including
|
|
2983
|
+
// Homebrew). Cloud Hands accepts only immutable image toolchains.
|
|
2984
|
+
PATH: config.jobEnvSourcePath === undefined
|
|
2985
|
+
|| (process.platform === "darwin" && config.workspacePoolPath === path.dirname(config.workRoot)
|
|
2986
|
+
&& config.repoPath === path.join(config.workRoot, "repo"))
|
|
2987
|
+
? process.env.PATH
|
|
2988
|
+
: selectCloudChildPath(process.platform, FIXED_CLOUD_CHILD_PATH, FIXED_MACOS_CLOUD_CHILD_PATH),
|
|
2989
|
+
BASH_ENV: "/dev/null",
|
|
2990
|
+
ENV: "/dev/null",
|
|
2991
|
+
NODE_OPTIONS: "",
|
|
2992
|
+
NODE_PATH: "",
|
|
2993
|
+
LD_PRELOAD: "",
|
|
2994
|
+
LD_LIBRARY_PATH: "",
|
|
2995
|
+
},
|
|
2996
|
+
// Pi's shell reads /dev/null, as stock Pi's, so rg PATTERN searches the cwd.
|
|
2997
|
+
// A confined Pi file write passes its content on stdin.
|
|
2998
|
+
stdio: [piShell && piShell.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
2999
|
+
},
|
|
3000
|
+
);
|
|
3001
|
+
// A new leader with this PID proves the retained group of that ID ended.
|
|
3002
|
+
if (Number.isSafeInteger(child.pid)) backgroundGroups.delete(child.pid);
|
|
3003
|
+
} catch (error) {
|
|
3004
|
+
// The protocol error stays spawn_failed; a coded refusal such as
|
|
3005
|
+
// local_device_gh_confinement_unavailable or environment_unavailable is
|
|
3006
|
+
// its stderr, so the server sees why nothing ran. Uncoded error text
|
|
3007
|
+
// (paths, OS messages) is never sent.
|
|
3008
|
+
const code = !timedOut && !cancelled && error instanceof Error && /^[a-z][a-z0-9_]{2,79}$/.test(error.message) ? error.message : "";
|
|
3009
|
+
finish(failure(request.id, timedOut ? 124 : cancelled ? 130 : 1, timedOut ? "timeout" : cancelled ? "cancelled" : "spawn_failed", "", code));
|
|
3010
|
+
return;
|
|
3011
|
+
}
|
|
3012
|
+
// Pi's shell settles as stock Pi's: once its stdio ends after the shell
|
|
3013
|
+
// exits, or its output has been idle for 100 ms after the exit. Processes
|
|
3014
|
+
// it started in the background keep running, retained below.
|
|
3015
|
+
let settled = false;
|
|
3016
|
+
let exited = null;
|
|
3017
|
+
let idleTimer = null;
|
|
3018
|
+
const armIdle = () => {
|
|
3019
|
+
if (!exited || settled) return;
|
|
3020
|
+
clearTimeout(idleTimer);
|
|
3021
|
+
idleTimer = setTimeout(() => {
|
|
3022
|
+
child.stdout.destroy();
|
|
3023
|
+
child.stderr.destroy();
|
|
3024
|
+
void settle(exited.code, exited.signal);
|
|
3025
|
+
}, PI_EXIT_STDIO_GRACE_MS);
|
|
3026
|
+
};
|
|
3027
|
+
child.stdout.on("data", (chunk) => {
|
|
3028
|
+
if (piShell) { piShell.onData(chunk); armIdle(); }
|
|
3029
|
+
const text = stdoutDecoder.write(chunk);
|
|
3030
|
+
if (managed) managed.onOutput("stdout", text);
|
|
3031
|
+
stdout = clip(stdout + text, MAX_OUTPUT_CHARS);
|
|
3032
|
+
scheduleProgress();
|
|
3033
|
+
});
|
|
3034
|
+
child.stderr.on("data", (chunk) => {
|
|
3035
|
+
if (piShell) { (piShell.onStderr || piShell.onData)(chunk); armIdle(); }
|
|
3036
|
+
const text = stderrDecoder.write(chunk);
|
|
3037
|
+
if (managed) managed.onOutput("stderr", text);
|
|
3038
|
+
stderr = clip(stderr + text, MAX_OUTPUT_CHARS);
|
|
3039
|
+
scheduleProgress();
|
|
3040
|
+
});
|
|
3041
|
+
child.once("error", () => finish(failure(request.id, 1, "spawn_failed", stdout, stderr)));
|
|
3042
|
+
child.once("spawn", () => {
|
|
3043
|
+
if (childEnvironment) {
|
|
3044
|
+
consumedEnvironment = { revision: childEnvironment.snapshot.revision, hash: childEnvironment.hash };
|
|
3045
|
+
try { if (managed) managed.onEnvironment(childEnvironment.snapshot); }
|
|
3046
|
+
catch { cancelled = true; void startGroupCleanup().catch(() => {}); }
|
|
3047
|
+
}
|
|
3048
|
+
acknowledgeSpawn(true);
|
|
3049
|
+
if (onProgress) {
|
|
3050
|
+
keepaliveTimer = setInterval(() => {
|
|
3051
|
+
if (done) { clearInterval(keepaliveTimer); return; }
|
|
3052
|
+
publishProgress();
|
|
3053
|
+
}, 15_000);
|
|
3054
|
+
}
|
|
3055
|
+
});
|
|
3056
|
+
// A leader exiting must not leave descendants holding pipes open until the
|
|
3057
|
+
// deadline. This uses only the live owned group, never recovered PID data.
|
|
3058
|
+
// A retained command's descendants are deliberately left running; any that
|
|
3059
|
+
// still hold its stdio keep the result open until they exit or time out.
|
|
3060
|
+
child.once("exit", (code, signal) => {
|
|
3061
|
+
if (piShell) { exited = { code, signal }; armIdle(); }
|
|
3062
|
+
else if (!retainBackground) void startGroupCleanup().catch(() => {});
|
|
3063
|
+
});
|
|
3064
|
+
const settle = async (code, signal) => {
|
|
3065
|
+
if (settled) return;
|
|
3066
|
+
settled = true;
|
|
3067
|
+
clearTimeout(idleTimer);
|
|
3068
|
+
try {
|
|
3069
|
+
// A command that deliberately closes inherited stdio can leave a
|
|
3070
|
+
// background descendant after its group leader exits. No Bash result
|
|
3071
|
+
// is terminal until the entire detached process group is absent, or,
|
|
3072
|
+
// for a retained command or Pi's shell, tracked until it is.
|
|
3073
|
+
if ((retainBackground || piShell) && !groupCleanup && !timedOut && !cancelled) retainBackgroundGroup(child);
|
|
3074
|
+
else await startGroupCleanup();
|
|
3075
|
+
} catch {
|
|
3076
|
+
finish(failure(request.id, 1, "process_group_cleanup_unconfirmed", stdout, stderr));
|
|
3077
|
+
return;
|
|
3078
|
+
}
|
|
3079
|
+
const stdoutTail = stdoutDecoder.end();
|
|
3080
|
+
const stderrTail = stderrDecoder.end();
|
|
3081
|
+
if (managed) {
|
|
3082
|
+
managed.onOutput("stdout", stdoutTail);
|
|
3083
|
+
managed.onOutput("stderr", stderrTail);
|
|
3084
|
+
}
|
|
3085
|
+
stdout = clip(stdout + stdoutTail, MAX_OUTPUT_CHARS);
|
|
3086
|
+
stderr = clip(stderr + stderrTail, MAX_OUTPUT_CHARS);
|
|
3087
|
+
if (timedOut) finish(failure(request.id, 124, "timeout", stdout, stderr));
|
|
3088
|
+
else if (cancelled) finish(failure(request.id, 130, "cancelled", stdout, stderr));
|
|
3089
|
+
else if (code === 0) finish(success(request.id, stdout, stderr));
|
|
3090
|
+
else finish(failure(request.id, Number.isSafeInteger(code) ? code
|
|
3091
|
+
: piShell && signal ? 128 + (require("node:os").constants.signals[signal] || 0) : 1, "command_failed", stdout, stderr));
|
|
3092
|
+
};
|
|
3093
|
+
child.once("close", (code, signal) => { void settle(code, signal); });
|
|
3094
|
+
if (piShell) {
|
|
3095
|
+
if (piShell.stdin !== undefined) { child.stdin.on("error", () => {}); child.stdin.end(piShell.stdin); }
|
|
3096
|
+
return;
|
|
3097
|
+
}
|
|
3098
|
+
child.stdin.on("error", () => {});
|
|
3099
|
+
if (managed && !validPrivateJob) {
|
|
3100
|
+
// One ordered pipe carries a complete script frame followed by job input.
|
|
3101
|
+
// EOF before its delimiter fails closed instead of evaluating a prefix.
|
|
3102
|
+
child.stdin.write(request.input.script + "\0");
|
|
3103
|
+
} else child.stdin.end(validPrivateJob ? undefined : request.input.script);
|
|
3104
|
+
};
|
|
3105
|
+
void launch().catch(() => finish(failure(request.id, 1, "spawn_failed", "", "")));
|
|
3106
|
+
});
|
|
3107
|
+
return {
|
|
3108
|
+
promise,
|
|
3109
|
+
started,
|
|
3110
|
+
supportsCancellation: true,
|
|
3111
|
+
canInput,
|
|
3112
|
+
processIdentity() {
|
|
3113
|
+
if (done || !child || !Number.isSafeInteger(child.pid) || child.exitCode !== null || child.signalCode !== null) return null;
|
|
3114
|
+
return { pid: child.pid, processGroupId: child.pid, executorLaunchId: config.launchId };
|
|
3115
|
+
},
|
|
3116
|
+
input(data, eof) {
|
|
3117
|
+
const error = canInput(data);
|
|
3118
|
+
if (error) return error;
|
|
3119
|
+
if (eof) child.stdin.end(data);
|
|
3120
|
+
else child.stdin.write(data);
|
|
3121
|
+
return null;
|
|
3122
|
+
},
|
|
3123
|
+
renew(expiresAtMs) {
|
|
3124
|
+
if (done || cancelled || timedOut) return false;
|
|
3125
|
+
clearTimeout(timer);
|
|
3126
|
+
timer = setTimeout(() => { timedOut = true; void startGroupCleanup().catch(() => {}); }, Math.max(1, expiresAtMs - Date.now()));
|
|
3127
|
+
return true;
|
|
3128
|
+
},
|
|
3129
|
+
cancel() {
|
|
3130
|
+
cancelled = true;
|
|
3131
|
+
preparationAbort.abort();
|
|
3132
|
+
return startGroupCleanup();
|
|
3133
|
+
},
|
|
3134
|
+
};
|
|
3135
|
+
}
|
|
3136
|
+
function replOperation(request) {
|
|
3137
|
+
let client = null;
|
|
3138
|
+
let timedOut = false;
|
|
3139
|
+
let hardCancelled = false;
|
|
3140
|
+
let done = false;
|
|
3141
|
+
let timer = null;
|
|
3142
|
+
const promise = new Promise((resolve) => {
|
|
3143
|
+
let raw = "";
|
|
3144
|
+
const finish = (result) => {
|
|
3145
|
+
if (done) return;
|
|
3146
|
+
done = true;
|
|
3147
|
+
clearTimeout(timer);
|
|
3148
|
+
resolve(result);
|
|
3149
|
+
};
|
|
3150
|
+
client = net.createConnection(config.replSocketPath);
|
|
3151
|
+
client.setEncoding("utf8");
|
|
3152
|
+
client.once("connect", () => {
|
|
3153
|
+
client.write(JSON.stringify({ action: "evaluate", code: request.input.code, includeProgressCapture: true,
|
|
3154
|
+
...(config.browserRunId ? { runId: config.browserRunId } : {}) }) + "\n");
|
|
3155
|
+
});
|
|
3156
|
+
client.on("data", (chunk) => {
|
|
3157
|
+
raw += chunk;
|
|
3158
|
+
if (raw.length > MAX_FRAME_BYTES) {
|
|
3159
|
+
client.destroy();
|
|
3160
|
+
finish(failure(request.id, 1, "repl_response_too_large", "", ""));
|
|
3161
|
+
}
|
|
3162
|
+
});
|
|
3163
|
+
client.once("error", () => {
|
|
3164
|
+
finish(failure(
|
|
3165
|
+
request.id,
|
|
3166
|
+
timedOut ? 124 : hardCancelled ? 130 : 1,
|
|
3167
|
+
timedOut ? "timeout" : hardCancelled ? "cancelled" : "repl_transport_failed",
|
|
3168
|
+
"",
|
|
3169
|
+
"",
|
|
3170
|
+
));
|
|
3171
|
+
});
|
|
3172
|
+
client.once("end", () => {
|
|
3173
|
+
if (hardCancelled) {
|
|
3174
|
+
finish(failure(request.id, 130, "cancelled", "", ""));
|
|
3175
|
+
return;
|
|
3176
|
+
}
|
|
3177
|
+
let response;
|
|
3178
|
+
try { response = JSON.parse(raw); } catch {
|
|
3179
|
+
finish(failure(request.id, 1, "invalid_repl_response", "", "Invalid REPL response"));
|
|
3180
|
+
return;
|
|
3181
|
+
}
|
|
3182
|
+
if (response && response.ok === true && typeof response.output === "string") {
|
|
3183
|
+
finish({ ...success(request.id, response.output, ""), browserActions: response.browserActions,
|
|
3184
|
+
...(response.automaticProgressImage === true ? { automaticProgressImage: true } : {}) });
|
|
3185
|
+
return;
|
|
3186
|
+
}
|
|
3187
|
+
const stderr = response && typeof response.stack === "string"
|
|
3188
|
+
? response.stack
|
|
3189
|
+
: response && typeof response.error === "string" ? response.error : "REPL request failed";
|
|
3190
|
+
finish({ ...failure(request.id, 1, "repl_failed", "", stderr), browserActions: response && response.browserActions });
|
|
3191
|
+
});
|
|
3192
|
+
client.once("close", () => {
|
|
3193
|
+
if (!done) finish(failure(
|
|
3194
|
+
request.id,
|
|
3195
|
+
hardCancelled ? 130 : 1,
|
|
3196
|
+
hardCancelled ? "cancelled" : "repl_transport_failed",
|
|
3197
|
+
"",
|
|
3198
|
+
"",
|
|
3199
|
+
));
|
|
3200
|
+
});
|
|
3201
|
+
timer = setTimeout(() => {
|
|
3202
|
+
timedOut = true;
|
|
3203
|
+
client.destroy();
|
|
3204
|
+
finish(failure(request.id, 124, "timeout", "", ""));
|
|
3205
|
+
}, config.replTimeoutMs);
|
|
3206
|
+
});
|
|
3207
|
+
return {
|
|
3208
|
+
promise,
|
|
3209
|
+
supportsCancellation: true,
|
|
3210
|
+
cancellationRequiresExternalProof: true,
|
|
3211
|
+
cancel(hard) {
|
|
3212
|
+
if (!hard) return;
|
|
3213
|
+
hardCancelled = true;
|
|
3214
|
+
if (client) client.destroy();
|
|
3215
|
+
},
|
|
3216
|
+
};
|
|
3217
|
+
}
|
|
3218
|
+
// Pi's own read/bash/edit/write (pi_tool), bundled unchanged. The 3 MB source
|
|
3219
|
+
// is not part of this worker: a host that serves pi_tool passes it as
|
|
3220
|
+
// araHandsPiToolsSource, and it is checked against the pinned digest and
|
|
3221
|
+
// evaluated on first use, so worker starts never parse it. read/edit/write use
|
|
3222
|
+
// Pi's local file operations in this process, which deliberately reach paths
|
|
3223
|
+
// outside the workspace; under local GitHub confinement they use
|
|
3224
|
+
// confinedPiFileOperations, the typed file tools' boundary, instead. pi_tool is
|
|
3225
|
+
// refused where a spawn policy or command prefix confines file access to a
|
|
3226
|
+
// child process this worker does not compose. Bash keeps Pi's
|
|
3227
|
+
// output handling and full-output file; its spawn operation is the ordinary
|
|
3228
|
+
// Hands Bash spawn above, in the fenced environment, with stock Pi's process
|
|
3229
|
+
// semantics: the host's filtered variables, BASH_ENV and ENV unset, a
|
|
3230
|
+
// private global Git config, stdin from /dev/null, no deadline but the model's
|
|
3231
|
+
// timeout and the lease, and background processes retained until the worker
|
|
3232
|
+
// stops. One departure from stock: a command idle on a watcher's banner is
|
|
3233
|
+
// stopped (PI_WATCH_BANNER). PI_* variables are not set here.
|
|
3234
|
+
const PI_TOOLS_SHA256 = "${p}";
|
|
3235
|
+
const piToolsSource = typeof araHandsPiToolsSource === "function" ? araHandsPiToolsSource : null;
|
|
3236
|
+
let piTools = null;
|
|
3237
|
+
function loadPiTools() {
|
|
3238
|
+
if (piTools) return piTools;
|
|
3239
|
+
const source = piToolsSource();
|
|
3240
|
+
if (digest(source) !== PI_TOOLS_SHA256) throw new Error("pi_tools_digest_mismatch");
|
|
3241
|
+
const module = { exports: {} };
|
|
3242
|
+
new Function("module", "exports", "require", '"use strict";\n' + source)(module, module.exports, require);
|
|
3243
|
+
piTools = module.exports;
|
|
3244
|
+
return piTools;
|
|
3245
|
+
}
|
|
3246
|
+
function piToolsAvailable() {
|
|
3247
|
+
return piToolsSource !== null && !validPrivateJob && !config.spawnPolicy && !config.bashCommandPrefix
|
|
3248
|
+
&& ["bash", "read", "edit", "write"].every((tool) => allowedTools.includes(tool));
|
|
3249
|
+
}
|
|
3250
|
+
function validPiToolRequest(value) {
|
|
3251
|
+
const input = value.input;
|
|
3252
|
+
return piToolsAvailable() && value.semanticDigest === undefined && hasOnlyKeys(input, ["tool", "args"])
|
|
3253
|
+
&& ["bash", "read", "edit", "write"].includes(input.tool)
|
|
3254
|
+
&& Boolean(input.args) && typeof input.args === "object" && !Array.isArray(input.args)
|
|
3255
|
+
&& (input.tool !== "bash" || (typeof input.args.command === "string" && input.args.command.length <= MAX_INPUT_CHARS));
|
|
3256
|
+
}
|
|
3257
|
+
const PI_MAX_TIMEOUT_SECONDS = 2147483647 / 1000;
|
|
3258
|
+
const PI_EXIT_STDIO_GRACE_MS = 100;
|
|
3259
|
+
// Run confinement (shared/pi-tool-confinement.ts): the Brain's template holds
|
|
3260
|
+
// the token once under quoteDepth layers of single quoting, so the confined
|
|
3261
|
+
// script is byte-identical to what the Brain's confineBashPayload gives the
|
|
3262
|
+
// bash tool: srt/bwrap, the payload uid, the network allowlist and allowWrite.
|
|
3263
|
+
const PI_TOOL_CONFINEMENT_TOKEN = "${Q}";
|
|
3264
|
+
function validPiToolConfinement(value) {
|
|
3265
|
+
return hasOnlyKeys(value, ["template", "quoteDepth"]) && typeof value.template === "string"
|
|
3266
|
+
&& value.template.length <= ${J} && !value.template.includes("\0")
|
|
3267
|
+
&& value.template.split(PI_TOOL_CONFINEMENT_TOKEN).length === 2 && [1, 2, 3].includes(value.quoteDepth);
|
|
3268
|
+
}
|
|
3269
|
+
// The script T when this command is exactly this policy's confineBashPayload(T)
|
|
3270
|
+
// (a Brain that wraps Pi's command itself): the middle is a well-formed escape
|
|
3271
|
+
// of T. Null for any other command.
|
|
3272
|
+
function piConfinedInnerScript(confinement, script) {
|
|
3273
|
+
const [before, after] = confinement.template.split(PI_TOOL_CONFINEMENT_TOKEN);
|
|
3274
|
+
if (script.length < before.length + after.length || !script.startsWith(before) || !script.endsWith(after)) return null;
|
|
3275
|
+
const middle = script.slice(before.length, script.length - after.length);
|
|
3276
|
+
let inner = middle;
|
|
3277
|
+
for (let layer = 0; layer < confinement.quoteDepth; layer += 1) inner = inner.split("'\\''").join("'");
|
|
3278
|
+
let again = inner;
|
|
3279
|
+
for (let layer = 0; layer < confinement.quoteDepth; layer += 1) again = again.replace(/'/g, "'\\''");
|
|
3280
|
+
return again === middle ? inner : null;
|
|
3281
|
+
}
|
|
3282
|
+
function piConfinedScript(confinement, script) {
|
|
3283
|
+
// Already confined by this exact policy: wrapping it again would nest srt.
|
|
3284
|
+
if (piConfinedInnerScript(confinement, script) !== null) return script;
|
|
3285
|
+
const [before, after] = confinement.template.split(PI_TOOL_CONFINEMENT_TOKEN);
|
|
3286
|
+
let escaped = script;
|
|
3287
|
+
for (let layer = 0; layer < confinement.quoteDepth; layer += 1) escaped = escaped.replace(/'/g, "'\\''");
|
|
3288
|
+
return before + escaped + after;
|
|
3289
|
+
}
|
|
3290
|
+
// What identifies a Brain-confined Pi Bash call in the request ledger: the
|
|
3291
|
+
// model's command and timeout, without the wrapper. The wrapper is rendered by
|
|
3292
|
+
// the dispatching Brain's build from the run's policy, so the Brain that takes
|
|
3293
|
+
// a run over at a deploy sends the same call id around a different wrapper. The
|
|
3294
|
+
// ledger then refused the replay as another request (duplicate_request_id) and
|
|
3295
|
+
// the model read a failure for a command that was still running (prod
|
|
3296
|
+
// 2026-10-10, Session 9d60d6ca: a sleep 3500 handed off across the deploy of a
|
|
3297
|
+
// confinement change). Null when the request is not such a call.
|
|
3298
|
+
function piConfinedBashCallInput(request, confinement) {
|
|
3299
|
+
if (request.tool !== "pi_tool" || request.input.tool !== "bash" || !confinement) return null;
|
|
3300
|
+
const command = piConfinedInnerScript(confinement, request.input.args.command);
|
|
3301
|
+
return command === null ? null : { tool: "bash", confinedArgs: { ...request.input.args, command } };
|
|
3302
|
+
}
|
|
3303
|
+
// Pi's read/edit/write under run confinement: every file access is a small
|
|
3304
|
+
// shell step run through the confined script as the payload, so srt's read
|
|
3305
|
+
// denials and allowWrite decide what Pi can see and change, as for model Bash.
|
|
3306
|
+
// The step's exit code names the failure; file bytes come back on stdout and
|
|
3307
|
+
// written content goes in on stdin, so neither passes through argv.
|
|
3308
|
+
const PI_CONFINED_FILE_ERRORS = { 71: ["ENOENT", "no such file or directory"], 72: ["EACCES", "permission denied"],
|
|
3309
|
+
73: ["EISDIR", "illegal operation on a directory"], 75: ["EEXIST", "file already exists"] };
|
|
3310
|
+
function piConfinedFileError(result, syscall, file) {
|
|
3311
|
+
const known = result.error === "command_failed" ? PI_CONFINED_FILE_ERRORS[result.exitCode] : undefined;
|
|
3312
|
+
if (known) return Object.assign(new Error(known[0] + ": " + known[1] + ", " + syscall + " '" + file + "'"), { code: known[0], syscall, path: file });
|
|
3313
|
+
const detail = String(result.stderr || "").trim().slice(0, 2000);
|
|
3314
|
+
return Object.assign(new Error("confined " + syscall + " failed (" + (result.error || "exit " + result.exitCode) + ")" + (detail ? ": " + detail : "")), { code: "EIO" });
|
|
3315
|
+
}
|
|
3316
|
+
function piConfinedFileScript(step, file) {
|
|
3317
|
+
const quoted = shellQuote(file);
|
|
3318
|
+
if (step === "read") return "p=" + quoted + '; if [ ! -e "$p" ]; then exit 71; fi; if [ -d "$p" ]; then exit 73; fi; if [ ! -r "$p" ]; then exit 72; fi; exec cat -- "$p"';
|
|
3319
|
+
if (step === "access_rw") return "p=" + quoted + '; if [ ! -e "$p" ]; then exit 71; fi; if [ ! -r "$p" ] || [ ! -w "$p" ]; then exit 72; fi';
|
|
3320
|
+
if (step === "mkdir") return "p=" + quoted + '; if [ -e "$p" ] && [ ! -d "$p" ]; then exit 75; fi; mkdir -p -- "$p" || exit 72';
|
|
3321
|
+
return "p=" + quoted + '; if [ -d "$p" ]; then exit 73; fi; if [ ! -d "$(dirname -- "$p")" ]; then exit 71; fi; cat > "$p" || exit 72';
|
|
3322
|
+
}
|
|
3323
|
+
function piConfinedFileOperations(id, state, confinement, signal, writable) {
|
|
3324
|
+
const run = (step, file, stdin) => {
|
|
3325
|
+
const chunks = [];
|
|
3326
|
+
const errors = [];
|
|
3327
|
+
const operation = bashOperation({ id, input: { script: piConfinedScript(confinement, piConfinedFileScript(step, file)) } }, null, null,
|
|
3328
|
+
{ onData: (chunk) => chunks.push(chunk), onStderr: (chunk) => errors.push(chunk), confined: true, ...(stdin === undefined ? {} : { stdin }) });
|
|
3329
|
+
const stop = () => { void operation.cancel().catch(() => {}); };
|
|
3330
|
+
if (signal.aborted) stop();
|
|
3331
|
+
else signal.addEventListener("abort", stop, { once: true });
|
|
3332
|
+
return operation.promise.then((result) => {
|
|
3333
|
+
signal.removeEventListener("abort", stop);
|
|
3334
|
+
if (result.error === "process_group_cleanup_unconfirmed") state.cleanupUnconfirmed = true;
|
|
3335
|
+
if (!result.ok) throw piConfinedFileError({ ...result, stderr: Buffer.concat(errors).toString("utf8") },
|
|
3336
|
+
step === "access_rw" ? "access" : step === "read" ? "open" : step === "mkdir" ? "mkdir" : "open", file);
|
|
3337
|
+
return Buffer.concat(chunks);
|
|
3338
|
+
});
|
|
3339
|
+
};
|
|
3340
|
+
const reads = new Map();
|
|
3341
|
+
const read = (file) => {
|
|
3342
|
+
if (!reads.has(file)) reads.set(file, run("read", file));
|
|
3343
|
+
return reads.get(file);
|
|
3344
|
+
};
|
|
3345
|
+
return {
|
|
3346
|
+
// Pi's read checks R_OK (the read itself answers it) and its edit R_OK|W_OK.
|
|
3347
|
+
access: (file) => (writable ? run("access_rw", file) : read(file)).then(() => undefined),
|
|
3348
|
+
readFile: read,
|
|
3349
|
+
detectImageMimeType: (file) => read(file).then((bytes) => loadPiTools().detectSupportedImageMimeType(bytes.subarray(0, 4100))),
|
|
3350
|
+
writeFile: (file, content) => run("write", file, Buffer.from(content, "utf8")).then(() => { reads.delete(file); }),
|
|
3351
|
+
mkdir: (dir) => run("mkdir", dir).then(() => undefined),
|
|
3352
|
+
};
|
|
3353
|
+
}
|
|
3354
|
+
// A watcher's banner ends the output of a command that will never exit: the
|
|
3355
|
+
// tool says it is waiting for file changes. stdin is /dev/null and the output
|
|
3356
|
+
// is a pipe, which stops prompts and pagers but not watchers: vitest before 2
|
|
3357
|
+
// watches unless CI is set, whatever its stdin (prod 2026-10-11: all 10 hung
|
|
3358
|
+
// test calls of a field study ended on vitest's banner and ran to the model's
|
|
3359
|
+
// 120 s timeout, the tests done in 0.5 to 73 s). CI is deliberately not set
|
|
3360
|
+
// for every command: under it pnpm install refuses a changed package.json,
|
|
3361
|
+
// jest and vitest refuse to write a new snapshot, and repository scripts
|
|
3362
|
+
// switch to their CI branches. Only text that itself states the wait counts:
|
|
3363
|
+
// silence or an idle CPU also describes sleep, a download and a remote build.
|
|
3364
|
+
// A dev server's ready line is not a banner (a command may start one, wait and
|
|
3365
|
+
// then use it). Output through a pipeline (| tail) never shows the banner here.
|
|
3366
|
+
const PI_WATCH_IDLE_MS = 10000;
|
|
3367
|
+
const PI_WATCH_BANNER = /(?:waiting|watching) for (?:file )?changes[^\n]*(?:\n[^\n]*press h to show help[^\n]*)?\s*$/i;
|
|
3368
|
+
const PI_VITEST_BANNER = /Waiting for file changes\.\.\.\s+press h to show help/;
|
|
3369
|
+
function piWatchBanner(tail) {
|
|
3370
|
+
const text = tail.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "").replace(/\r/g, "\n");
|
|
3371
|
+
if (!PI_WATCH_BANNER.test(text)) return null;
|
|
3372
|
+
return PI_VITEST_BANNER.test(text) ? "vitest" : "watcher";
|
|
3373
|
+
}
|
|
3374
|
+
function piWatchNote(banner) {
|
|
3375
|
+
return "\n\n[Stopped by Reason after " + (PI_WATCH_IDLE_MS / 1000) + " seconds without output: the command finished its run and was waiting for file changes (watch mode), so it would never exit. Its results are above; exit code 124 is this stop, not the command's own. To run it once: "
|
|
3376
|
+
+ (banner === "vitest"
|
|
3377
|
+
? "prefix the same command with CI=1, or pass --run to vitest before any \"--\" (vitest run ...)."
|
|
3378
|
+
: "use its one-shot form (drop --watch or -w; jest: --watchAll=false; vitest: --run), or prefix the command with CI=1 where the tool honours it.")
|
|
3379
|
+
+ " To keep a watcher running, start it in the background with its output in a file.]\n";
|
|
3380
|
+
}
|
|
3381
|
+
function piShellOperations(id, onProgress, state, confinement) {
|
|
3382
|
+
return { exec(command, _cwd, { onData: piOnData, signal, timeout }) {
|
|
3383
|
+
// Pi's local operation contract: reject invalid timeouts, report "aborted"
|
|
3384
|
+
// and "timeout:<seconds>", otherwise resolve the exit code.
|
|
3385
|
+
if (timeout !== undefined && (!Number.isFinite(timeout) || timeout <= 0)) {
|
|
3386
|
+
return Promise.reject(new Error("Invalid timeout: must be a finite number of seconds"));
|
|
3387
|
+
}
|
|
3388
|
+
if (timeout > PI_MAX_TIMEOUT_SECONDS) {
|
|
3389
|
+
return Promise.reject(new Error("Invalid timeout: maximum is " + PI_MAX_TIMEOUT_SECONDS + " seconds"));
|
|
3390
|
+
}
|
|
3391
|
+
if (signal.aborted) return Promise.reject(new Error("aborted"));
|
|
3392
|
+
const startedAt = Date.now();
|
|
3393
|
+
let timedOut = false;
|
|
3394
|
+
let watchTail = "";
|
|
3395
|
+
let watchTimer = null;
|
|
3396
|
+
let watchStopped = false;
|
|
3397
|
+
let stop = () => {};
|
|
3398
|
+
// Every chunk restarts the wait: the banner must be the last thing printed.
|
|
3399
|
+
const onData = (chunk) => {
|
|
3400
|
+
piOnData(chunk);
|
|
3401
|
+
clearTimeout(watchTimer);
|
|
3402
|
+
watchTail = (watchTail + chunk.toString("latin1")).slice(-2048);
|
|
3403
|
+
const banner = piWatchBanner(watchTail);
|
|
3404
|
+
if (!banner || timedOut || watchStopped || signal.aborted) return;
|
|
3405
|
+
watchTimer = setTimeout(() => {
|
|
3406
|
+
if (timedOut || signal.aborted) return;
|
|
3407
|
+
watchStopped = true;
|
|
3408
|
+
piOnData(Buffer.from(piWatchNote(banner), "utf8"));
|
|
3409
|
+
stop();
|
|
3410
|
+
}, PI_WATCH_IDLE_MS);
|
|
3411
|
+
};
|
|
3412
|
+
const shell = confinement
|
|
3413
|
+
? bashOperation({ id, input: { script: piConfinedScript(confinement, command) } }, onProgress, null, { onData, confined: true })
|
|
3414
|
+
: bashOperation({ id, input: { script: command } }, onProgress, null, { onData });
|
|
3415
|
+
stop = () => { void shell.cancel().catch(() => {}); };
|
|
3416
|
+
const timer = timeout === undefined ? null : setTimeout(() => { if (!watchStopped) { timedOut = true; stop(); } }, timeout * 1000);
|
|
3417
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
3418
|
+
return shell.promise.then((result) => {
|
|
3419
|
+
clearTimeout(timer);
|
|
3420
|
+
clearTimeout(watchTimer);
|
|
3421
|
+
signal.removeEventListener("abort", stop);
|
|
3422
|
+
if (result.error === "process_group_cleanup_unconfirmed") state.cleanupUnconfirmed = true;
|
|
3423
|
+
if (signal.aborted || state.cleanupUnconfirmed) throw new Error("aborted");
|
|
3424
|
+
// The whole group is gone, the watcher with it; the note is the output's last lines.
|
|
3425
|
+
if (watchStopped) return { exitCode: 124 };
|
|
3426
|
+
if (timedOut) throw new Error("timeout:" + timeout);
|
|
3427
|
+
// The Hands lease ended first.
|
|
3428
|
+
if (result.error === "timeout") throw new Error("timeout:" + Math.round((Date.now() - startedAt) / 1000));
|
|
3429
|
+
if (result.ok || result.error === "command_failed") return { exitCode: result.exitCode };
|
|
3430
|
+
throw new Error(result.error || "spawn_failed");
|
|
3431
|
+
});
|
|
3432
|
+
} };
|
|
3433
|
+
}
|
|
3434
|
+
// Results are journaled and cached; Pi's largest are inline images (4.5 MB
|
|
3435
|
+
// base64) and Bash structured output (1 MiB, escaped).
|
|
3436
|
+
const MAX_PI_TOOL_OUTCOME_BYTES = 12 * 1024 * 1024;
|
|
3437
|
+
function piToolOperation(request, onProgress, confinement) {
|
|
3438
|
+
const controller = new AbortController();
|
|
3439
|
+
const state = { cleanupUnconfirmed: false };
|
|
3440
|
+
let cancelled = false;
|
|
3441
|
+
const promise = (async () => {
|
|
3442
|
+
const { tool, args } = request.input;
|
|
3443
|
+
let outcome;
|
|
3444
|
+
try {
|
|
3445
|
+
const tools = loadPiTools();
|
|
3446
|
+
const cwd = config.repoPath;
|
|
3447
|
+
let instance;
|
|
3448
|
+
if (tool === "bash") {
|
|
3449
|
+
instance = tools.createBashTool(cwd, { operations: piShellOperations(request.id, onProgress, state, confinement), exposeSessionEnvironment: false });
|
|
3450
|
+
} else if (confinement) {
|
|
3451
|
+
const operations = piConfinedFileOperations(request.id, state, confinement, controller.signal, tool === "edit");
|
|
3452
|
+
instance = tool === "read" ? tools.createReadTool(cwd, { operations })
|
|
3453
|
+
: tool === "edit" ? tools.createEditTool(cwd, { operations }) : tools.createWriteTool(cwd, { operations });
|
|
3454
|
+
} else if (localGithubSandbox) {
|
|
3455
|
+
// Relative paths resolve against the canonical root workspaceTarget checks.
|
|
3456
|
+
const root = fs.realpathSync(cwd);
|
|
3457
|
+
const operations = confinedPiFileOperations(request.id, state, tool === "edit", controller.signal);
|
|
3458
|
+
instance = tool === "read" ? tools.createReadTool(root, { operations })
|
|
3459
|
+
: tool === "edit" ? tools.createEditTool(root, { operations }) : tools.createWriteTool(root, { operations });
|
|
3460
|
+
} else {
|
|
3461
|
+
instance = tool === "read" ? tools.createReadTool(cwd) : tool === "edit" ? tools.createEditTool(cwd) : tools.createWriteTool(cwd);
|
|
3462
|
+
}
|
|
3463
|
+
const result = await instance.execute(request.id, args, controller.signal);
|
|
3464
|
+
outcome = {
|
|
3465
|
+
content: result.content,
|
|
3466
|
+
...(result.details === undefined ? {} : { details: result.details }),
|
|
3467
|
+
...(result.structuredContent === undefined ? {} : { structuredContent: result.structuredContent }),
|
|
3468
|
+
...(result.isError === true ? { isError: true } : {}),
|
|
3469
|
+
};
|
|
3470
|
+
} catch (error) {
|
|
3471
|
+
outcome = { thrown: error instanceof Error ? error.message : String(error) };
|
|
3472
|
+
}
|
|
3473
|
+
if (state.cleanupUnconfirmed) return failure(request.id, 1, "process_group_cleanup_unconfirmed", "", "");
|
|
3474
|
+
if (cancelled) return failure(request.id, 130, "cancelled", "", "");
|
|
3475
|
+
const bytes = Buffer.byteLength(JSON.stringify(outcome), "utf8");
|
|
3476
|
+
if (bytes > MAX_PI_TOOL_OUTCOME_BYTES) return failure(request.id, 1, "result_too_large", "", "");
|
|
3477
|
+
const result = { ...success(request.id, "", ""), piToolOutcome: outcome };
|
|
3478
|
+
piToolOutcomeBytes.set(result, bytes);
|
|
3479
|
+
return result;
|
|
3480
|
+
})();
|
|
3481
|
+
return {
|
|
3482
|
+
promise,
|
|
3483
|
+
supportsCancellation: true,
|
|
3484
|
+
cancel() {
|
|
3485
|
+
cancelled = true;
|
|
3486
|
+
controller.abort();
|
|
3487
|
+
return promise.then(() => undefined);
|
|
3488
|
+
},
|
|
3489
|
+
};
|
|
3490
|
+
}
|
|
3491
|
+
// The same exclusive scope lock used by managed jobs owns request receipts.
|
|
3492
|
+
// Store no command/input text. Intent is fsynced BEFORE execution; completion
|
|
3493
|
+
// before acknowledgement. A restarted worker can recover completed output, but
|
|
3494
|
+
// an interrupted intent is uncertain and must never launch that effect again.
|
|
3495
|
+
const MAX_REQUEST_RECORD_BYTES = 16 * 1024 * 1024;
|
|
3496
|
+
let requestJournalFailed = false;
|
|
3497
|
+
const uncertainRequestIds = new Set();
|
|
3498
|
+
function prepareRequestJournal() {
|
|
3499
|
+
if (!journalDirectory) return;
|
|
3500
|
+
const directory = path.join(journalDirectory, "requests");
|
|
3501
|
+
try { fs.mkdirSync(directory, { mode: 0o700 }); }
|
|
3502
|
+
catch (error) { if (error.code !== "EEXIST") throw error; }
|
|
3503
|
+
const stat = fs.lstatSync(directory);
|
|
3504
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid()
|
|
3505
|
+
|| (stat.mode & 0o077) !== 0) throw new Error("invalid_request_journal");
|
|
3506
|
+
// Stream the private directory: receipt count is not a lifetime call cap.
|
|
3507
|
+
// A crash may leave the fixed temp sibling; only the renamed final record
|
|
3508
|
+
// is authoritative, so it cannot authorize replay or a completed result.
|
|
3509
|
+
const directoryHandle = fs.opendirSync(directory);
|
|
3510
|
+
try {
|
|
3511
|
+
let item;
|
|
3512
|
+
while ((item = directoryHandle.readSync()) !== null) {
|
|
3513
|
+
const file = item.name;
|
|
3514
|
+
if (/^[0-9a-f]{64}\.json\.tmp$/.test(file)) continue;
|
|
3515
|
+
if (!/^[0-9a-f]{64}\.json$/.test(file)) throw new Error("invalid_request_journal");
|
|
3516
|
+
const record = privateJournalRead("requests/" + file, MAX_REQUEST_RECORD_BYTES).value;
|
|
3517
|
+
if (safeId(record.id) === "invalid" || requestFile(record.id) !== "requests/" + file) throw new Error("invalid_request_journal");
|
|
3518
|
+
const entry = readRequestReceipt(record.id);
|
|
3519
|
+
if (entry && entry.uncertain) uncertainRequestIds.add(record.id);
|
|
3520
|
+
}
|
|
3521
|
+
} finally { directoryHandle.closeSync(); }
|
|
3522
|
+
}
|
|
3523
|
+
function requestFile(id) { return "requests/" + digest(id) + ".json"; }
|
|
3524
|
+
function readRequestReceipt(id) {
|
|
3525
|
+
if (!journalDirectory) return null;
|
|
3526
|
+
if (requestJournalFailed) throw new Error("request_journal_unavailable");
|
|
3527
|
+
let record;
|
|
3528
|
+
try { record = privateJournalRead(requestFile(id), MAX_REQUEST_RECORD_BYTES).value; }
|
|
3529
|
+
catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
3530
|
+
if (!hasOnlyKeys(record, ["schema", "id", "hash", "semanticDigest", "result"])
|
|
3531
|
+
|| record.schema !== 1 || record.id !== id || !SHA256_RE.test(record.hash)
|
|
3532
|
+
|| (record.semanticDigest !== null && !SHA256_RE.test(record.semanticDigest))
|
|
3533
|
+
|| (record.result !== null && (record.result.id !== id
|
|
3534
|
+
|| record.result.version !== VERSION || typeof record.result.ok !== "boolean"
|
|
3535
|
+
|| typeof record.result.stdout !== "string" || typeof record.result.stderr !== "string"))) {
|
|
3536
|
+
throw new Error("invalid_request_journal");
|
|
3537
|
+
}
|
|
3538
|
+
return { hash: record.hash, semanticDigest: record.semanticDigest, result: record.result,
|
|
3539
|
+
promise: null, uncertain: record.result === null };
|
|
3540
|
+
}
|
|
3541
|
+
function persistRequestReceipt(id, entry, result) {
|
|
3542
|
+
if (!journalDirectory) return;
|
|
3543
|
+
if (requestJournalFailed) throw new Error("request_journal_unavailable");
|
|
3544
|
+
try {
|
|
3545
|
+
privateJournalWrite(requestFile(id), JSON.stringify({ schema: 1, id, hash: entry.hash,
|
|
3546
|
+
semanticDigest: entry.semanticDigest, result }), MAX_REQUEST_RECORD_BYTES);
|
|
3547
|
+
} catch (error) { requestJournalFailed = true; throw error; }
|
|
3548
|
+
}
|
|
3549
|
+
function rememberResult(id, entry, result) {
|
|
3550
|
+
try { persistRequestReceipt(id, entry, result); }
|
|
3551
|
+
catch {
|
|
3552
|
+
entry.promise = null; entry.result = null; entry.uncertain = true;
|
|
3553
|
+
uncertainRequestIds.add(id);
|
|
3554
|
+
return false;
|
|
3555
|
+
}
|
|
3556
|
+
entry.promise = null;
|
|
3557
|
+
entry.result = result;
|
|
3558
|
+
uncertainRequestIds.delete(id);
|
|
3559
|
+
cachedResultIds.push(id);
|
|
3560
|
+
cachedPiToolOutcomeBytes += piToolOutcomeBytes.get(result) || 0;
|
|
3561
|
+
while (cachedResultIds.length > MAX_CACHED_RESULTS || cachedPiToolOutcomeBytes > MAX_CACHED_PI_TOOL_OUTCOME_BYTES) {
|
|
3562
|
+
const evictedId = cachedResultIds.shift();
|
|
3563
|
+
const evicted = ledger.get(evictedId);
|
|
3564
|
+
if (evicted && evicted.result) cachedPiToolOutcomeBytes -= piToolOutcomeBytes.get(evicted.result) || 0;
|
|
3565
|
+
if (journalDirectory) ledger.delete(evictedId);
|
|
3566
|
+
else if (evicted) evicted.result = null;
|
|
3567
|
+
}
|
|
3568
|
+
return true;
|
|
3569
|
+
}
|
|
3570
|
+
// Count retained Pi outcomes by size as well; other results are bounded by count.
|
|
3571
|
+
const MAX_CACHED_PI_TOOL_OUTCOME_BYTES = 64 * 1024 * 1024;
|
|
3572
|
+
const piToolOutcomeBytes = new WeakMap();
|
|
3573
|
+
let cachedPiToolOutcomeBytes = 0;
|
|
3574
|
+
async function handleRequest(target, connectionGeneration, claim, raw) {
|
|
3575
|
+
const receivedAtMs = performance.now();
|
|
3576
|
+
if (
|
|
3577
|
+
claim.generation !== targetGeneration
|
|
3578
|
+
|| claim.fenceId !== targetFenceId
|
|
3579
|
+
) {
|
|
3580
|
+
closeSocket(target, "stale_generation");
|
|
3581
|
+
return;
|
|
3582
|
+
}
|
|
3583
|
+
let request;
|
|
3584
|
+
try {
|
|
3585
|
+
request = parseRequest(raw, claim.generation);
|
|
3586
|
+
} catch (error) {
|
|
3587
|
+
if (error.code === "frame_too_large") closeSocket(target, "frame_too_large");
|
|
3588
|
+
else if (error.code === "invalid_json") closeSocket(target, "invalid_json");
|
|
3589
|
+
else if (error.code === "stale_generation") closeSocket(target, "stale_generation");
|
|
3590
|
+
else sendResult(target, failure(
|
|
3591
|
+
error.id || "invalid",
|
|
3592
|
+
2,
|
|
3593
|
+
"invalid_request",
|
|
3594
|
+
"",
|
|
3595
|
+
"",
|
|
3596
|
+
claim.generation,
|
|
3597
|
+
));
|
|
3598
|
+
return;
|
|
3599
|
+
}
|
|
3600
|
+
if ((request.tool === "pi_tool" && claim.piToolsVersion !== 1)
|
|
3601
|
+
|| (request.tool === "bash" && request.input.retainBackground === true && claim.backgroundBashVersion !== 1)
|
|
3602
|
+
|| (request.tool === "bash" && request.input.machineSdk === true && claim.machineSdkVersion !== 1)) {
|
|
3603
|
+
sendResult(target, failure(request.id, 2, "invalid_request", "", "", request.generation));
|
|
3604
|
+
return;
|
|
3605
|
+
}
|
|
3606
|
+
const heldMaintenance = currentMaintenance();
|
|
3607
|
+
if ((heldMaintenance && (request.tool !== "bash" || request.lifecycleLeaseId !== heldMaintenance.id || heldMaintenance.expiresAtMs <= Date.now()))
|
|
3608
|
+
|| (request.lifecycleLeaseId !== undefined && (!heldMaintenance || request.lifecycleLeaseId !== heldMaintenance.id))) {
|
|
3609
|
+
sendResult(target, failure(request.id, 2, "maintenance_held", "", "", request.generation)); return;
|
|
3610
|
+
}
|
|
3611
|
+
let existing;
|
|
3612
|
+
try { existing = ledger.get(request.id) || readRequestReceipt(request.id); }
|
|
3613
|
+
catch {
|
|
3614
|
+
sendResult(target, failure(request.id, 2, "request_journal_unavailable", "", "", request.generation));
|
|
3615
|
+
return;
|
|
3616
|
+
}
|
|
3617
|
+
if (request.tool === "resume") {
|
|
3618
|
+
if (!existing) {
|
|
3619
|
+
sendResult(target, failure(request.id, 2, "request_not_found", "", "", request.generation));
|
|
3620
|
+
return;
|
|
3621
|
+
}
|
|
3622
|
+
if (existing.semanticDigest !== request.input.semanticDigest) {
|
|
3623
|
+
sendResult(target, failure(request.id, 2, "resume_digest_mismatch", "", "", request.generation));
|
|
3624
|
+
return;
|
|
3625
|
+
}
|
|
3626
|
+
if (existing.result) sendResult(
|
|
3627
|
+
target,
|
|
3628
|
+
bindResumeResult(existing.result, request.generation),
|
|
3629
|
+
);
|
|
3630
|
+
else if (existing.promise) {
|
|
3631
|
+
const result = await existing.promise;
|
|
3632
|
+
if (
|
|
3633
|
+
target === socket
|
|
3634
|
+
&& generation === connectionGeneration
|
|
3635
|
+
&& targetGeneration === request.generation
|
|
3636
|
+
&& targetFenceId === claim.fenceId
|
|
3637
|
+
) sendResult(target, bindResumeResult(result, request.generation));
|
|
3638
|
+
} else sendResult(target, failure(
|
|
3639
|
+
request.id,
|
|
3640
|
+
2,
|
|
3641
|
+
existing.uncertain ? "request_outcome_uncertain" : "duplicate_result_evicted",
|
|
3642
|
+
"",
|
|
3643
|
+
"",
|
|
3644
|
+
request.generation,
|
|
3645
|
+
));
|
|
3646
|
+
return;
|
|
3647
|
+
}
|
|
3648
|
+
// A receipt an earlier worker build journaled holds the hash of the frame as sent.
|
|
3649
|
+
const frameHash = requestHash(request);
|
|
3650
|
+
const confinedBashCall = piConfinedBashCallInput(request, claim.piToolsConfinement);
|
|
3651
|
+
const hash = confinedBashCall ? requestHash(request, confinedBashCall) : frameHash;
|
|
3652
|
+
if (existing) {
|
|
3653
|
+
if (existing.hash !== hash && existing.hash !== frameHash) {
|
|
3654
|
+
sendResult(target, failure(
|
|
3655
|
+
request.id,
|
|
3656
|
+
2,
|
|
3657
|
+
"duplicate_request_id",
|
|
3658
|
+
"",
|
|
3659
|
+
"",
|
|
3660
|
+
request.generation,
|
|
3661
|
+
));
|
|
3662
|
+
return;
|
|
3663
|
+
}
|
|
3664
|
+
if (existing.result) sendResult(
|
|
3665
|
+
target,
|
|
3666
|
+
bindResultGeneration(existing.result, request.generation),
|
|
3667
|
+
);
|
|
3668
|
+
else if (existing.promise) {
|
|
3669
|
+
const result = await existing.promise;
|
|
3670
|
+
if (
|
|
3671
|
+
target === socket
|
|
3672
|
+
&& generation === connectionGeneration
|
|
3673
|
+
&& targetGeneration === request.generation
|
|
3674
|
+
&& targetFenceId === claim.fenceId
|
|
3675
|
+
) sendResult(target, bindResultGeneration(result, request.generation));
|
|
3676
|
+
} else sendResult(target, failure(
|
|
3677
|
+
request.id,
|
|
3678
|
+
2,
|
|
3679
|
+
existing.uncertain ? "request_outcome_uncertain" : "duplicate_result_evicted",
|
|
3680
|
+
"",
|
|
3681
|
+
"",
|
|
3682
|
+
request.generation,
|
|
3683
|
+
));
|
|
3684
|
+
return;
|
|
3685
|
+
}
|
|
3686
|
+
if (ledger.size >= MAX_LEDGER_IDS) {
|
|
3687
|
+
sendResult(target, failure(
|
|
3688
|
+
request.id,
|
|
3689
|
+
1,
|
|
3690
|
+
"request_ledger_full",
|
|
3691
|
+
"",
|
|
3692
|
+
"",
|
|
3693
|
+
request.generation,
|
|
3694
|
+
));
|
|
3695
|
+
return;
|
|
3696
|
+
}
|
|
3697
|
+
const entry = { hash, semanticDigest: request.semanticDigest || null, promise: null, result: null };
|
|
3698
|
+
try { persistRequestReceipt(request.id, entry, null); }
|
|
3699
|
+
catch {
|
|
3700
|
+
sendResult(target, failure(request.id, 2, "request_journal_unavailable", "", "", request.generation));
|
|
3701
|
+
return;
|
|
3702
|
+
}
|
|
3703
|
+
const progress = (stdout, stderr, sequence) => {
|
|
3704
|
+
if (
|
|
3705
|
+
target === socket
|
|
3706
|
+
&& generation === connectionGeneration
|
|
3707
|
+
&& targetGeneration === request.generation
|
|
3708
|
+
&& targetFenceId === claim.fenceId
|
|
3709
|
+
) sendProgress(
|
|
3710
|
+
target,
|
|
3711
|
+
request.id,
|
|
3712
|
+
sequence,
|
|
3713
|
+
stdout,
|
|
3714
|
+
stderr,
|
|
3715
|
+
request.generation,
|
|
3716
|
+
);
|
|
3717
|
+
};
|
|
3718
|
+
const operation = request.tool === "bash"
|
|
3719
|
+
? bashOperation(request, progress)
|
|
3720
|
+
: request.tool === "pi_tool"
|
|
3721
|
+
? piToolOperation(request, request.input.tool === "bash" ? progress : null, claim.piToolsConfinement)
|
|
3722
|
+
: request.tool === "repl"
|
|
3723
|
+
? localGithubSandbox ? { promise: Promise.resolve(failure(request.id, 1, "tool_unavailable", "", "local_device_gh_repl_unavailable")), cancel() {} } : replOperation(request)
|
|
3724
|
+
: localGithubSandbox || (config.spawnPolicy || config.bashCommandPrefix) && request.tool !== "read"
|
|
3725
|
+
? payloadFileOperation(request)
|
|
3726
|
+
: fileOperation(request);
|
|
3727
|
+
// Every waiter observes the durable completion, never the raw child result.
|
|
3728
|
+
entry.promise = operation.promise.then((result) => {
|
|
3729
|
+
active.delete(request.id);
|
|
3730
|
+
return rememberResult(request.id, entry, result) ? result
|
|
3731
|
+
: failure(request.id, 2, "request_outcome_uncertain", "", "", request.generation);
|
|
3732
|
+
});
|
|
3733
|
+
ledger.set(request.id, entry);
|
|
3734
|
+
active.set(request.id, operation);
|
|
3735
|
+
const result = await entry.promise.finally(() => {
|
|
3736
|
+
// Read below at most once; never kept past this request (a changed socket or generation sends nothing).
|
|
3737
|
+
queueMicrotask(() => environmentWaits.delete(request.id));
|
|
3738
|
+
});
|
|
3739
|
+
if (
|
|
3740
|
+
target === socket
|
|
3741
|
+
&& generation === connectionGeneration
|
|
3742
|
+
&& targetGeneration === request.generation
|
|
3743
|
+
&& targetFenceId === claim.fenceId
|
|
3744
|
+
) {
|
|
3745
|
+
// Only this connection's own execution is timed; a cached or resumed
|
|
3746
|
+
// result is not. The Brain subtracts this from its round trip.
|
|
3747
|
+
const bound = bindResultGeneration(result, request.generation);
|
|
3748
|
+
const environmentWaitMs = claim.environmentWaitVersion === 1 ? environmentWaits.get(request.id) : undefined;
|
|
3749
|
+
sendResult(target, claim.executionTimingVersion === 1
|
|
3750
|
+
? { ...bound, workerElapsedMs: Math.max(0, Math.round(performance.now() - receivedAtMs)),
|
|
3751
|
+
...(environmentWaitMs ? { environmentWaitMs } : {}) } : bound);
|
|
3752
|
+
}
|
|
3753
|
+
}
|
|
3754
|
+
function parseClaim(raw) {
|
|
3755
|
+
if (Buffer.byteLength(raw, "utf8") > MAX_FRAME_BYTES) {
|
|
3756
|
+
const error = new Error("frame_too_large"); error.code = "frame_too_large"; throw error;
|
|
3757
|
+
}
|
|
3758
|
+
let value;
|
|
3759
|
+
try { value = JSON.parse(raw); } catch {
|
|
3760
|
+
const error = new Error("invalid_json"); error.code = "invalid_json"; throw error;
|
|
3761
|
+
}
|
|
3762
|
+
if (
|
|
3763
|
+
!hasOnlyKeys(value, ["type", "version", "generation", "fenceId", "executorLaunchId", "requestCancelVersion", "managedJobsVersion", "browserRunEnvelopeVersion", "terminalCleanupVersion", "guestMemoryVersion", "guestMemoryDiagnosticsVersion", "piToolsVersion", "piToolsConfinement", "backgroundBashVersion", "machineSdkVersion", "executionTimingVersion", "environmentWaitVersion"]) ||
|
|
3764
|
+
value.type !== "claim" || value.version !== VERSION ||
|
|
3765
|
+
!Number.isSafeInteger(value.generation) || value.generation < 1 ||
|
|
3766
|
+
typeof value.fenceId !== "string" || !UUID_RE.test(value.fenceId) ||
|
|
3767
|
+
typeof value.executorLaunchId !== "string" || !UUID_RE.test(value.executorLaunchId) ||
|
|
3768
|
+
(value.requestCancelVersion !== undefined && value.requestCancelVersion !== 1) ||
|
|
3769
|
+
(value.managedJobsVersion !== undefined && value.managedJobsVersion !== 1) ||
|
|
3770
|
+
(value.browserRunEnvelopeVersion !== undefined && value.browserRunEnvelopeVersion !== 1) ||
|
|
3771
|
+
(value.guestMemoryVersion !== undefined && value.guestMemoryVersion !== 1) ||
|
|
3772
|
+
(value.guestMemoryDiagnosticsVersion !== undefined && (value.guestMemoryDiagnosticsVersion !== 1 || value.guestMemoryVersion !== 1)) ||
|
|
3773
|
+
(value.terminalCleanupVersion !== undefined && value.terminalCleanupVersion !== 1) ||
|
|
3774
|
+
(value.piToolsVersion !== undefined && value.piToolsVersion !== 1) ||
|
|
3775
|
+
(value.piToolsConfinement !== undefined && (!validPiToolConfinement(value.piToolsConfinement) || value.piToolsVersion !== 1)) ||
|
|
3776
|
+
(value.backgroundBashVersion !== undefined && value.backgroundBashVersion !== 1) ||
|
|
3777
|
+
(value.machineSdkVersion !== undefined && value.machineSdkVersion !== 1) ||
|
|
3778
|
+
(value.executionTimingVersion !== undefined && value.executionTimingVersion !== 1)
|
|
3779
|
+
|| (value.environmentWaitVersion !== undefined && (value.environmentWaitVersion !== 1 || value.executionTimingVersion !== 1))
|
|
3780
|
+
) {
|
|
3781
|
+
const error = new Error("invalid_claim"); error.code = "invalid_claim"; throw error;
|
|
3782
|
+
}
|
|
3783
|
+
return value;
|
|
3784
|
+
}
|
|
3785
|
+
function handleClaim(target, connectionClaim, raw) {
|
|
3786
|
+
if (terminalCleanupStarted) { closeSocket(target, "terminal_cleanup_started"); return; }
|
|
3787
|
+
let claim;
|
|
3788
|
+
try {
|
|
3789
|
+
claim = parseClaim(raw);
|
|
3790
|
+
} catch (error) {
|
|
3791
|
+
diagnostic("claim_rejected", error.code === "frame_too_large" ? "frame_too_large" : error.code === "invalid_json" ? "invalid_json" : "invalid_claim");
|
|
3792
|
+
closeSocket(
|
|
3793
|
+
target,
|
|
3794
|
+
error.code === "frame_too_large" ? "frame_too_large" : error.code === "invalid_json" ? "invalid_json" : "invalid_claim",
|
|
3795
|
+
);
|
|
3796
|
+
return;
|
|
3797
|
+
}
|
|
3798
|
+
if (claim.executorLaunchId !== config.launchId) {
|
|
3799
|
+
diagnostic("claim_rejected", "executor_mismatch", claim.generation);
|
|
3800
|
+
closeSocket(target, "executor_mismatch");
|
|
3801
|
+
return;
|
|
3802
|
+
}
|
|
3803
|
+
if (claim.generation < targetGeneration) {
|
|
3804
|
+
diagnostic("claim_rejected", "stale_generation", claim.generation);
|
|
3805
|
+
closeSocket(target, "stale_generation");
|
|
3806
|
+
return;
|
|
3807
|
+
}
|
|
3808
|
+
if (claim.generation === targetGeneration && claim.fenceId !== targetFenceId) {
|
|
3809
|
+
diagnostic("claim_rejected", "fence_conflict", claim.generation);
|
|
3810
|
+
closeSocket(target, "fence_conflict");
|
|
3811
|
+
return;
|
|
3812
|
+
}
|
|
3813
|
+
if (claim.generation > targetGeneration) {
|
|
3814
|
+
maintenanceLease = null;
|
|
3815
|
+
if (journalDirectory) {
|
|
3816
|
+
try { privateJournalWrite("scope.json", JSON.stringify(journalScope(claim.generation, claim.fenceId))); }
|
|
3817
|
+
catch { closeSocket(target, "job_journal_unavailable"); return; }
|
|
3818
|
+
}
|
|
3819
|
+
targetGeneration = claim.generation;
|
|
3820
|
+
targetFenceId = claim.fenceId;
|
|
3821
|
+
markReady();
|
|
3822
|
+
}
|
|
3823
|
+
diagnostic("claim_accepted", null, claim.generation);
|
|
3824
|
+
connectionClaim.generation = claim.generation;
|
|
3825
|
+
connectionClaim.fenceId = claim.fenceId;
|
|
3826
|
+
connectionClaim.terminalCleanupVersion = claim.terminalCleanupVersion;
|
|
3827
|
+
connectionClaim.guestMemoryVersion = claim.guestMemoryVersion;
|
|
3828
|
+
connectionClaim.guestMemoryDiagnosticsVersion = claim.guestMemoryDiagnosticsVersion;
|
|
3829
|
+
connectionClaim.piToolsVersion = claim.piToolsVersion === 1 && piToolsAvailable() ? 1 : undefined;
|
|
3830
|
+
// The run's confinement for every pi_tool request on this connection. Local
|
|
3831
|
+
// GitHub confinement owns Pi's file and shell boundary there instead.
|
|
3832
|
+
connectionClaim.piToolsConfinement = claim.piToolsConfinement !== undefined && connectionClaim.piToolsVersion === 1
|
|
3833
|
+
&& !localGithubSandbox ? claim.piToolsConfinement : undefined;
|
|
3834
|
+
// A spawn policy authorizes each Bash child only until its own deadline.
|
|
3835
|
+
connectionClaim.backgroundBashVersion = claim.backgroundBashVersion === 1 && allowedTools.includes("bash")
|
|
3836
|
+
&& !config.spawnPolicy ? 1 : undefined;
|
|
3837
|
+
connectionClaim.machineSdkVersion = claim.machineSdkVersion === 1 && machineSdkAvailable() ? 1 : undefined;
|
|
3838
|
+
connectionClaim.executionTimingVersion = claim.executionTimingVersion === 1 ? 1 : undefined;
|
|
3839
|
+
connectionClaim.environmentWaitVersion = claim.environmentWaitVersion === 1 && claim.executionTimingVersion === 1 ? 1 : undefined;
|
|
3840
|
+
if (connectionClaim.environmentWaitVersion === 1) environmentWaitNegotiated = true;
|
|
3841
|
+
target.send(JSON.stringify({
|
|
3842
|
+
type: "claim_ack",
|
|
3843
|
+
version: VERSION,
|
|
3844
|
+
generation: targetGeneration,
|
|
3845
|
+
fenceId: targetFenceId,
|
|
3846
|
+
executorLaunchId: config.launchId,
|
|
3847
|
+
workerVersion: WORKER_VERSION,
|
|
3848
|
+
workerArtifactSha256: config.workerArtifactSha256,
|
|
3849
|
+
...(claim.guestMemoryVersion === 1 && process.platform === "linux" && config.terminalCleanupRunId ? { guestMemoryVersion: 1 } : {}),
|
|
3850
|
+
...(claim.guestMemoryDiagnosticsVersion === 1 && process.platform === "linux" && config.terminalCleanupRunId ? { guestMemoryDiagnosticsVersion: 1 } : {}),
|
|
3851
|
+
...(claim.terminalCleanupVersion === 1 && process.platform === "linux" && config.terminalCleanupRunId && config.jobEnvSourcePath ? { terminalCleanupVersion: 1 } : {}),
|
|
3852
|
+
...(claim.browserRunEnvelopeVersion === 1 && config.browserRunId ? { browserRunEnvelopeVersion: 1 } : {}),
|
|
3853
|
+
...(connectionClaim.piToolsVersion === 1 ? { piToolsVersion: 1 } : {}),
|
|
3854
|
+
...(connectionClaim.piToolsConfinement !== undefined ? { piToolsConfinementVersion: 1 } : {}),
|
|
3855
|
+
...(connectionClaim.backgroundBashVersion === 1 ? { backgroundBashVersion: 1 } : {}),
|
|
3856
|
+
...(connectionClaim.machineSdkVersion === 1 ? { machineSdkVersion: 1 } : {}),
|
|
3857
|
+
...(connectionClaim.executionTimingVersion === 1 ? { executionTimingVersion: 1 } : {}),
|
|
3858
|
+
...(connectionClaim.environmentWaitVersion === 1 ? { environmentWaitVersion: 1 } : {}),
|
|
3859
|
+
...(claim.requestCancelVersion === 1 && claim.managedJobsVersion === 1 ? { lifecycleVersion: 1 } : {}),
|
|
3860
|
+
...(claim.requestCancelVersion === 1 ? { requestCancelVersion: 1 } : {}),
|
|
3861
|
+
...(claim.managedJobsVersion === 1 && config.managedJobs && allowedTools.includes("bash") ? { managedJobsVersion: 1 } : {}),
|
|
3862
|
+
}));
|
|
3863
|
+
}
|
|
3864
|
+
function cancelActive(hard) {
|
|
3865
|
+
const terminal = [];
|
|
3866
|
+
for (const operation of active.values()) {
|
|
3867
|
+
const cleanup = operation.cancel(hard);
|
|
3868
|
+
terminal.push(operation.promise);
|
|
3869
|
+
if (cleanup) terminal.push(cleanup);
|
|
3870
|
+
}
|
|
3871
|
+
return terminal;
|
|
3872
|
+
}
|
|
3873
|
+
function markReady() {
|
|
3874
|
+
const temp = config.readyPath + ".tmp";
|
|
3875
|
+
fs.writeFileSync(temp, JSON.stringify({
|
|
3876
|
+
generation,
|
|
3877
|
+
pid: process.pid,
|
|
3878
|
+
launchId: config.launchId,
|
|
3879
|
+
workerVersion: WORKER_VERSION,
|
|
3880
|
+
protocolVersion: VERSION,
|
|
3881
|
+
workerArtifactSha256: config.workerArtifactSha256,
|
|
3882
|
+
workerEntrypoint: WORKER_ENTRYPOINT,
|
|
3883
|
+
targetGeneration,
|
|
3884
|
+
fenceId: targetFenceId,
|
|
3885
|
+
workspaceAliasReady: true,
|
|
3886
|
+
}), { encoding: "utf8", mode: 0o600 });
|
|
3887
|
+
fs.renameSync(temp, config.readyPath);
|
|
3888
|
+
fs.chmodSync(config.readyPath, 0o600);
|
|
3889
|
+
}
|
|
3890
|
+
function reconnect() {
|
|
3891
|
+
if (stopped || Date.now() >= config.expiresAtMs) return;
|
|
3892
|
+
setTimeout(connect, retryMs);
|
|
3893
|
+
retryMs = Math.min(1000, retryMs * 2);
|
|
3894
|
+
}
|
|
3895
|
+
function disconnected(next, closeEvent) {
|
|
3896
|
+
if (stopped || next !== socket) return;
|
|
3897
|
+
socket = null;
|
|
3898
|
+
socketClaim = null;
|
|
3899
|
+
failSdkCalls(next, SDK_DISCONNECTED);
|
|
3900
|
+
// A Brain socket disappearing is not, by itself, a user cancellation. The
|
|
3901
|
+
// executor ledger must keep an in-flight Bash/REPL operation alive so a
|
|
3902
|
+
// replacement Brain can reconnect and receive the exact result. Explicit
|
|
3903
|
+
// lifecycle/abort close reasons still cancel work; an unclean TCP close has
|
|
3904
|
+
// no reason and is deliberately recoverable.
|
|
3905
|
+
const reason = closeEvent && typeof closeEvent.reason === "string"
|
|
3906
|
+
? closeEvent.reason
|
|
3907
|
+
: "";
|
|
3908
|
+
if ([
|
|
3909
|
+
"request_aborted",
|
|
3910
|
+
"request_timeout",
|
|
3911
|
+
"client_closed",
|
|
3912
|
+
"driver_stopped",
|
|
3913
|
+
"driver_handoff",
|
|
3914
|
+
"run_cancelled",
|
|
3915
|
+
"run_finished",
|
|
3916
|
+
].includes(reason)) {
|
|
3917
|
+
cancelActive(false);
|
|
3918
|
+
}
|
|
3919
|
+
reconnect();
|
|
3920
|
+
}
|
|
3921
|
+
function connect() {
|
|
3922
|
+
if (stopped || Date.now() >= config.expiresAtMs) return;
|
|
3923
|
+
connectionAttempt += 1;
|
|
3924
|
+
diagnostic("connecting");
|
|
3925
|
+
const next = config.proxyUrl
|
|
3926
|
+
? new WebSocket(config.url, { proxy: config.proxyUrl })
|
|
3927
|
+
: new WebSocket(config.url);
|
|
3928
|
+
const connectionClaim = { generation: 0, fenceId: "" };
|
|
3929
|
+
socket = next;
|
|
3930
|
+
socketClaim = connectionClaim;
|
|
3931
|
+
const pendingTimer = setTimeout(() => {
|
|
3932
|
+
if (!stopped && next === socket && next.readyState === WebSocket.CONNECTING) diagnostic("dial_pending", "open_not_received");
|
|
3933
|
+
}, 10000);
|
|
3934
|
+
pendingTimer.unref();
|
|
3935
|
+
next.addEventListener("open", () => {
|
|
3936
|
+
if (stopped || next !== socket) return;
|
|
3937
|
+
clearTimeout(pendingTimer);
|
|
3938
|
+
generation += 1;
|
|
3939
|
+
diagnostic("open");
|
|
3940
|
+
retryMs = 25;
|
|
3941
|
+
markReady();
|
|
3942
|
+
});
|
|
3943
|
+
next.addEventListener("message", (event) => {
|
|
3944
|
+
if (next !== socket || generation < 1) return;
|
|
3945
|
+
if (typeof event.data !== "string") {
|
|
3946
|
+
closeSocket(next, "text_only");
|
|
3947
|
+
return;
|
|
3948
|
+
}
|
|
3949
|
+
if (Buffer.byteLength(event.data, "utf8") > MAX_FRAME_BYTES) {
|
|
3950
|
+
closeSocket(next, "frame_too_large"); return;
|
|
3951
|
+
}
|
|
3952
|
+
let frameType = "";
|
|
3953
|
+
try {
|
|
3954
|
+
const value = JSON.parse(event.data);
|
|
3955
|
+
frameType = value && typeof value === "object" ? value.type : "";
|
|
3956
|
+
} catch { frameType = ""; }
|
|
3957
|
+
if (frameType === "claim") {
|
|
3958
|
+
handleClaim(next, connectionClaim, event.data);
|
|
3959
|
+
return;
|
|
3960
|
+
}
|
|
3961
|
+
if (connectionClaim.generation < 1) {
|
|
3962
|
+
closeSocket(next, "claim_required");
|
|
3963
|
+
return;
|
|
3964
|
+
}
|
|
3965
|
+
if (connectionClaim.generation !== targetGeneration || connectionClaim.fenceId !== targetFenceId) {
|
|
3966
|
+
closeSocket(next, "stale_generation"); return;
|
|
3967
|
+
}
|
|
3968
|
+
if (frameType === "guest_memory") { handleGuestMemory(next, { ...connectionClaim }, event.data); return; }
|
|
3969
|
+
if (frameType === "terminal_cleanup") { void handleTerminalCleanup(next, { ...connectionClaim }, event.data); return; }
|
|
3970
|
+
if (terminalCleanupStarted) { closeSocket(next, "terminal_cleanup_started"); return; }
|
|
3971
|
+
if (frameType === "lifecycle") { handleLifecycle(next, { ...connectionClaim }, event.data); return; }
|
|
3972
|
+
if (frameType === "job") {
|
|
3973
|
+
void handleJob({ ...connectionClaim }, event.data, (result) => sendResult(next, result), () => next === socket);
|
|
3974
|
+
return;
|
|
3975
|
+
}
|
|
3976
|
+
if (frameType === "cancel_request") { void handleRequestCancel(next, { ...connectionClaim }, event.data); return; }
|
|
3977
|
+
if (frameType === "sdk_response") { handleSdkResponse(next, event.data); return; }
|
|
3978
|
+
void handleRequest(next, generation, connectionClaim, event.data);
|
|
3979
|
+
});
|
|
3980
|
+
next.addEventListener("error", () => {
|
|
3981
|
+
clearTimeout(pendingTimer);
|
|
3982
|
+
diagnostic("socket_error", next === socket ? "current_connection" : "obsolete_connection");
|
|
3983
|
+
disconnected(next);
|
|
3984
|
+
});
|
|
3985
|
+
next.addEventListener("close", (event) => {
|
|
3986
|
+
clearTimeout(pendingTimer);
|
|
3987
|
+
const knownReasons = ["request_aborted", "request_timeout", "client_closed", "driver_stopped", "driver_handoff", "run_cancelled", "run_finished"];
|
|
3988
|
+
diagnostic("close", knownReasons.includes(event.reason) ? event.reason : "other",
|
|
3989
|
+
null, Number.isSafeInteger(event.code) ? event.code : null);
|
|
3990
|
+
disconnected(next, event);
|
|
3991
|
+
});
|
|
3992
|
+
}
|
|
3993
|
+
function stop(reason) {
|
|
3994
|
+
if (stoppingPromise) return stoppingPromise;
|
|
3995
|
+
stopped = true;
|
|
3996
|
+
for (const client of jobControlClients) client.destroy();
|
|
3997
|
+
if (jobControlServer) jobControlServer.close();
|
|
3998
|
+
diagnostic("stopping", reason);
|
|
3999
|
+
// Persistent terminal shells outlive the worker; their viewer clients are active PTY jobs.
|
|
4000
|
+
const terminal = [...cancelActive(true), ...stopBackgroundGroups()];
|
|
4001
|
+
removeMachineSdk();
|
|
4002
|
+
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "stopped");
|
|
4003
|
+
try { fs.unlinkSync(config.readyPath); } catch { stopped = true; }
|
|
4004
|
+
// A clean worker exit is the driver's exact stop proof. Do not exit until
|
|
4005
|
+
// every tracked Bash process group or REPL request has reached its terminal
|
|
4006
|
+
// close path after cancellation. If one cannot settle, the driver eventually
|
|
4007
|
+
// force-kills us and deliberately withholds hands.stopped.
|
|
4008
|
+
stoppingPromise = Promise.all([...terminal, ...(terminalCleanupPromise ? [terminalCleanupPromise] : [])]).then(
|
|
4009
|
+
(results) => {
|
|
4010
|
+
if (journalFailed || cleanupUnconfirmed || results.some((result) => result && result.error === "process_group_cleanup_unconfirmed")
|
|
4011
|
+
|| [...jobs.values()].some((job) => job.record.state === "uncertain")) {
|
|
4012
|
+
diagnostic("stopped", "cleanup_failed"); process.exit(1);
|
|
4013
|
+
}
|
|
4014
|
+
try { releaseJournalOwnership(); } catch { diagnostic("stopped", "cleanup_failed"); process.exit(1); }
|
|
4015
|
+
if (config.directTerminalReceiptPath) {
|
|
4016
|
+
const job = jobs.get(config.directShell.jobId)?.record;
|
|
4017
|
+
if (job && ["exited", "cancelled", "expired"].includes(job.state) && Number.isSafeInteger(job.exitCode)) {
|
|
4018
|
+
try {
|
|
4019
|
+
const fd = fs.openSync(config.directTerminalReceiptPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, 0o600);
|
|
4020
|
+
try {
|
|
4021
|
+
fs.writeFileSync(fd, JSON.stringify({ launch_id: config.launchId, fence_id: config.fenceId,
|
|
4022
|
+
scope_id: config.managedJobs.scopeId, observation: { version: 1, process_id: job.jobId,
|
|
4023
|
+
state: job.state, exit_code: job.exitCode, observed_at: new Date().toISOString() } }));
|
|
4024
|
+
fs.fsyncSync(fd);
|
|
4025
|
+
} finally { fs.closeSync(fd); }
|
|
4026
|
+
} catch { diagnostic("stopped", "cleanup_receipt_failed"); process.exit(1); }
|
|
4027
|
+
}
|
|
4028
|
+
}
|
|
4029
|
+
diagnostic("stopped", "cleanup_complete");
|
|
4030
|
+
process.exit(0);
|
|
4031
|
+
},
|
|
4032
|
+
() => { diagnostic("stopped", "cleanup_failed"); process.exit(1); },
|
|
4033
|
+
);
|
|
4034
|
+
return stoppingPromise;
|
|
4035
|
+
}
|
|
4036
|
+
process.once("SIGTERM", () => { void stop("SIGTERM"); });
|
|
4037
|
+
process.once("SIGINT", () => { void stop("SIGINT"); });
|
|
4038
|
+
process.once("SIGHUP", () => { void stop("SIGHUP"); });
|
|
4039
|
+
setTimeout(() => { void stop("expired"); }, Math.max(1, config.expiresAtMs - Date.now())).unref();
|
|
4040
|
+
void startJobControl().then(() => {
|
|
4041
|
+
// Socket ownership is established before recovery mutates any live records.
|
|
4042
|
+
try { loadJobs(); prepareRequestJournal(); } catch { process.exit(44); }
|
|
4043
|
+
if (validPrivateJob) markReady(); else connect();
|
|
4044
|
+
}, () => process.exit(45));
|
|
4045
|
+
`.trimStart(),Rt=ot("sha256").update(ht,"utf8").digest("hex");
|
|
4046
|
+
export{n as tb,h as ub,qt as vb,o as wb,kt as xb,b as yb,m as zb,ut as Ab,ct as Bb,ft as Cb,dt as Db,Nt as Eb,lt as Fb,gt as Gb,ht as Hb,Rt as Ib};
|