@beachside/freestyle 0.0.0-stage → 0.11.0
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/LICENSE +201 -0
- package/README.md +54 -2
- package/dist/adapter.d.ts +103 -0
- package/dist/exec.d.ts +61 -0
- package/dist/factory.d.ts +7 -0
- package/dist/filesystem.d.ts +49 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2490 -0
- package/dist/process.d.ts +72 -0
- package/dist/terminal.d.ts +51 -0
- package/dist/transport.d.ts +48 -0
- package/package.json +44 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,2490 @@
|
|
|
1
|
+
// src/adapter.ts
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { FreestyleApiError as FreestyleApiError2 } from "freestyle";
|
|
4
|
+
import { z as z4 } from "zod";
|
|
5
|
+
import {
|
|
6
|
+
AdapterCheckpointError,
|
|
7
|
+
AdapterError as AdapterError6,
|
|
8
|
+
AdapterFilesystemError as AdapterFilesystemError2,
|
|
9
|
+
assertResourceScope,
|
|
10
|
+
DEFAULT_LIFETIME_SECONDS,
|
|
11
|
+
defineAdapter,
|
|
12
|
+
listedLabels,
|
|
13
|
+
listedTime,
|
|
14
|
+
MAX_DIRECTORY_ENTRIES,
|
|
15
|
+
Preserve,
|
|
16
|
+
PreserveSchema,
|
|
17
|
+
PreviewPort,
|
|
18
|
+
resolveSnapshot,
|
|
19
|
+
ResourceMinimums,
|
|
20
|
+
sandboxReference,
|
|
21
|
+
smallestSize,
|
|
22
|
+
unknownSandboxFacts
|
|
23
|
+
} from "@beachside/adapter";
|
|
24
|
+
|
|
25
|
+
// src/exec.ts
|
|
26
|
+
import { AdapterError } from "@beachside/adapter";
|
|
27
|
+
var EXEC_RECORD = "BEACHSIDE-EXEC-1";
|
|
28
|
+
var INLINE_BYTES = 32 * 1024;
|
|
29
|
+
var EnvKey = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
30
|
+
function quote(word) {
|
|
31
|
+
if (word.includes("\x00"))
|
|
32
|
+
throw new AdapterError("INVALID_ARGUMENT", "Arguments cannot contain NUL");
|
|
33
|
+
return `'${word.replaceAll("'", `'\\''`)}'`;
|
|
34
|
+
}
|
|
35
|
+
var ENV_FILE = "/etc/beachside.env";
|
|
36
|
+
function checkedEnvKey(key) {
|
|
37
|
+
if (!EnvKey.test(key))
|
|
38
|
+
throw new AdapterError("INVALID_ARGUMENT", "Environment variable names must be POSIX names");
|
|
39
|
+
return key;
|
|
40
|
+
}
|
|
41
|
+
function envFile(env, attempt) {
|
|
42
|
+
const lines = Object.entries(env).map(([key, value]) => `export ${checkedEnvKey(key)}=${quote(value)}`);
|
|
43
|
+
return new TextEncoder().encode([envMarker(attempt), ...lines, ""].join(`
|
|
44
|
+
`));
|
|
45
|
+
}
|
|
46
|
+
function envMarker(attempt) {
|
|
47
|
+
return `# beachside ${attempt}`;
|
|
48
|
+
}
|
|
49
|
+
function checkEnv(env) {
|
|
50
|
+
for (const [key, value] of Object.entries(env ?? {}))
|
|
51
|
+
quote(`${checkedEnvKey(key)}=${value}`);
|
|
52
|
+
}
|
|
53
|
+
function commandLine(input) {
|
|
54
|
+
if (input.command.kind === "argv" && input.command.argv.length === 0)
|
|
55
|
+
throw new AdapterError("INVALID_ARGUMENT", "argv must name a program");
|
|
56
|
+
const env = Object.entries(input.env ?? {}).map(([key, value]) => quote(`${checkedEnvKey(key)}=${value}`));
|
|
57
|
+
const program = input.command.kind === "argv" ? input.command.argv.map(quote).join(" ") : `/bin/sh -c ${quote(input.command.script)}`;
|
|
58
|
+
const exports = env.length ? `export ${env.join(" ")}; ` : "";
|
|
59
|
+
const run = `{ if [ -r ${ENV_FILE} ]; then . ${ENV_FILE}; fi; ${exports}exec ${program}; }`;
|
|
60
|
+
return input.cwd === undefined ? run : `cd -- ${quote(input.cwd)} && ${run}`;
|
|
61
|
+
}
|
|
62
|
+
function wrapperScript(input) {
|
|
63
|
+
const body = commandLine(input);
|
|
64
|
+
const stdin = input.stdin ? "" : " </dev/null";
|
|
65
|
+
const bound = Math.max(0, Math.floor(input.maxBytes));
|
|
66
|
+
return [
|
|
67
|
+
"beachside_d=$(mktemp -d /tmp/beachside-exec.XXXXXXXX) || exit 97",
|
|
68
|
+
`(while [ ! -e "$beachside_d/done" ]; do for beachside_s in stdout stderr; do beachside_z=$(($(wc -c <"$beachside_d/$beachside_s.raw" 2>/dev/null || echo 0))); if [ "$beachside_z" -gt ${bound} ]; then fallocate -p -o ${bound} -l $((beachside_z - ${bound})) "$beachside_d/$beachside_s.raw" 2>/dev/null; fi; done; sleep 0.2; done) </dev/null >/dev/null 2>&1 &`,
|
|
69
|
+
"beachside_w=$!",
|
|
70
|
+
`(${body})${stdin} >"$beachside_d/stdout.raw" 2>"$beachside_d/stderr.raw"`,
|
|
71
|
+
"beachside_c=$?",
|
|
72
|
+
`: >"$beachside_d/done"; kill "$beachside_w" 2>/dev/null; wait "$beachside_w" 2>/dev/null; rm -f "$beachside_d/done"`,
|
|
73
|
+
`beachside_ol=$(($(wc -c <"$beachside_d/stdout.raw"))); beachside_el=$(($(wc -c <"$beachside_d/stderr.raw")))`,
|
|
74
|
+
`beachside_ok=$((beachside_ol < ${bound} ? beachside_ol : ${bound}))`,
|
|
75
|
+
`head -c "$beachside_ok" "$beachside_d/stdout.raw" >"$beachside_d/stdout"; head -c $((${bound} - beachside_ok)) "$beachside_d/stderr.raw" >"$beachside_d/stderr"`,
|
|
76
|
+
`rm -f "$beachside_d/stdout.raw" "$beachside_d/stderr.raw"`,
|
|
77
|
+
`if [ $(($(wc -c <"$beachside_d/stdout") + $(wc -c <"$beachside_d/stderr"))) -le ${INLINE_BYTES} ]; then`,
|
|
78
|
+
`printf '${EXEC_RECORD} inline %s %s %s\\n' "$beachside_c" "$beachside_ol" "$beachside_el"`,
|
|
79
|
+
`base64 <"$beachside_d/stdout" | tr -d '\\n'; printf '\\n'`,
|
|
80
|
+
`base64 <"$beachside_d/stderr" | tr -d '\\n'; printf '\\n'`,
|
|
81
|
+
`rm -rf "$beachside_d"`,
|
|
82
|
+
"else",
|
|
83
|
+
`printf '${EXEC_RECORD} files %s %s %s %s\\n' "$beachside_c" "$beachside_ol" "$beachside_el" "$beachside_d"`,
|
|
84
|
+
"fi"
|
|
85
|
+
].join(`
|
|
86
|
+
`);
|
|
87
|
+
}
|
|
88
|
+
var Inline = new RegExp(`^${EXEC_RECORD} inline (\\d{1,3}) (\\d{1,15}) (\\d{1,15})\\n([A-Za-z0-9+/]*={0,2})\\n([A-Za-z0-9+/]*={0,2})\\n$`);
|
|
89
|
+
var Files = new RegExp(`^${EXEC_RECORD} files (\\d{1,3}) (\\d{1,15}) (\\d{1,15}) (/tmp/beachside-exec\\.[A-Za-z0-9]{8})\\n$`);
|
|
90
|
+
function parseWrapper(stdout) {
|
|
91
|
+
const files = Files.exec(stdout);
|
|
92
|
+
if (files) {
|
|
93
|
+
const [, code2, outLength2, errLength2, directory = ""] = files;
|
|
94
|
+
return {
|
|
95
|
+
exitCode: Number(code2),
|
|
96
|
+
stdoutLength: Number(outLength2),
|
|
97
|
+
stderrLength: Number(errLength2),
|
|
98
|
+
directory
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const match = Inline.exec(stdout);
|
|
102
|
+
if (!match)
|
|
103
|
+
return null;
|
|
104
|
+
const [, code, outLength, errLength, out = "", err = ""] = match;
|
|
105
|
+
const stdoutBytes = Buffer.from(out, "base64");
|
|
106
|
+
const stderrBytes = Buffer.from(err, "base64");
|
|
107
|
+
const lengths = [Number(outLength), Number(errLength)];
|
|
108
|
+
if (stdoutBytes.length > lengths[0] || stderrBytes.length > lengths[1] || stdoutBytes.toString("base64") !== out || stderrBytes.toString("base64") !== err)
|
|
109
|
+
return null;
|
|
110
|
+
return {
|
|
111
|
+
exitCode: Number(code),
|
|
112
|
+
stdout: new Uint8Array(stdoutBytes),
|
|
113
|
+
stderr: new Uint8Array(stderrBytes),
|
|
114
|
+
truncated: stdoutBytes.length < lengths[0] || stderrBytes.length < lengths[1]
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function filesResult(files, stdout, stderr, maxBytes) {
|
|
118
|
+
const bound = Math.max(0, Math.floor(maxBytes));
|
|
119
|
+
const outExpected = Math.min(files.stdoutLength, bound);
|
|
120
|
+
const errExpected = Math.min(files.stderrLength, bound - outExpected);
|
|
121
|
+
if (stdout.byteLength > outExpected || stderr.byteLength > errExpected)
|
|
122
|
+
return null;
|
|
123
|
+
return {
|
|
124
|
+
complete: stdout.byteLength === outExpected && stderr.byteLength === errExpected,
|
|
125
|
+
result: {
|
|
126
|
+
exitCode: files.exitCode,
|
|
127
|
+
stdout,
|
|
128
|
+
stderr,
|
|
129
|
+
truncated: stdout.byteLength < files.stdoutLength || stderr.byteLength < files.stderrLength
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/filesystem.ts
|
|
135
|
+
import { randomUUID } from "node:crypto";
|
|
136
|
+
import { posix } from "node:path";
|
|
137
|
+
import {
|
|
138
|
+
AdapterError as AdapterError2,
|
|
139
|
+
AdapterFilesystemError
|
|
140
|
+
} from "@beachside/adapter";
|
|
141
|
+
import { z } from "zod";
|
|
142
|
+
var FILESYSTEM_HELPER = String.raw`
|
|
143
|
+
import os,sys,json,stat,errno,ctypes,tempfile,shutil
|
|
144
|
+
op,p,q,flag,stage=sys.argv[1:6]
|
|
145
|
+
published=False
|
|
146
|
+
created=False
|
|
147
|
+
overwrite=flag=='true'
|
|
148
|
+
def kind(s):
|
|
149
|
+
return 'symlink' if stat.S_ISLNK(s.st_mode) else 'file' if stat.S_ISREG(s.st_mode) else 'directory' if stat.S_ISDIR(s.st_mode) else 'unknown'
|
|
150
|
+
def rename(a,b):
|
|
151
|
+
if overwrite: os.replace(a,b)
|
|
152
|
+
else:
|
|
153
|
+
libc=ctypes.CDLL(None,use_errno=True)
|
|
154
|
+
fn=getattr(libc,'renameat2',None)
|
|
155
|
+
if fn is None: raise OSError(errno.ENOSYS,'renameat2 unavailable')
|
|
156
|
+
if fn(-100,os.fsencode(a),-100,os.fsencode(b),1)!=0:
|
|
157
|
+
e=ctypes.get_errno();raise OSError(e,os.strerror(e))
|
|
158
|
+
def publish(a,b):
|
|
159
|
+
global published
|
|
160
|
+
if overwrite and os.path.lexists(b) and not stat.S_ISREG(os.lstat(b).st_mode): raise OSError(errno.EINVAL,'regular destination required')
|
|
161
|
+
if overwrite: os.replace(a,b);published=True
|
|
162
|
+
else:
|
|
163
|
+
os.link(a,b,follow_symlinks=False);published=True;os.unlink(a)
|
|
164
|
+
def parents(path):
|
|
165
|
+
os.makedirs(os.path.dirname(path) or '/',exist_ok=True)
|
|
166
|
+
def regular(path):
|
|
167
|
+
s=os.lstat(path)
|
|
168
|
+
if not stat.S_ISREG(s.st_mode): raise OSError(errno.EINVAL,'regular file required')
|
|
169
|
+
try:
|
|
170
|
+
result={}
|
|
171
|
+
if op=='list':
|
|
172
|
+
entries=[]; names=0
|
|
173
|
+
with os.scandir(p) as it:
|
|
174
|
+
for e in it:
|
|
175
|
+
names+=len(e.name.encode('utf-8'))
|
|
176
|
+
if len(entries)>=1024 or names>65536: raise OSError(errno.EFBIG,'directory bound exceeded')
|
|
177
|
+
try: t=kind(e.stat(follow_symlinks=False))
|
|
178
|
+
except FileNotFoundError: t='unknown'
|
|
179
|
+
entries.append({'name':e.name,'type':t})
|
|
180
|
+
entries.sort(key=lambda e:e['name'])
|
|
181
|
+
result={'entries':entries,'completeness':'complete'}
|
|
182
|
+
elif op=='stat':
|
|
183
|
+
s=os.stat(p) if overwrite else os.lstat(p)
|
|
184
|
+
result={'type':kind(s),'sizeBytes':s.st_size,'modifiedAt':s.st_mtime,'mode':stat.S_IMODE(s.st_mode)}
|
|
185
|
+
elif op=='mkdir':
|
|
186
|
+
try:
|
|
187
|
+
if overwrite: os.makedirs(p,exist_ok=True)
|
|
188
|
+
else: os.mkdir(p)
|
|
189
|
+
except FileExistsError:
|
|
190
|
+
if not os.path.isdir(p): raise OSError(errno.EINVAL,'existing entry is not a directory')
|
|
191
|
+
elif op=='remove':
|
|
192
|
+
try:
|
|
193
|
+
s=os.lstat(p)
|
|
194
|
+
if stat.S_ISDIR(s.st_mode):
|
|
195
|
+
if overwrite: shutil.rmtree(p)
|
|
196
|
+
else: os.rmdir(p)
|
|
197
|
+
else: os.unlink(p)
|
|
198
|
+
except FileNotFoundError: pass
|
|
199
|
+
elif op=='exists':
|
|
200
|
+
try: os.lstat(p);result={'exists':True}
|
|
201
|
+
except FileNotFoundError: result={'exists':False}
|
|
202
|
+
elif op=='append':
|
|
203
|
+
fd=os.open(p,os.O_WRONLY|os.O_NOFOLLOW)
|
|
204
|
+
with os.fdopen(fd,'r+b') as f:
|
|
205
|
+
f.seek(0,2)
|
|
206
|
+
if f.tell()!=int(q): raise OSError(errno.EINVAL,'unexpected staging size')
|
|
207
|
+
data=sys.stdin.buffer.read(1048577)
|
|
208
|
+
if len(data)>1048576: raise OSError(errno.EFBIG,'chunk bound exceeded')
|
|
209
|
+
f.write(data)
|
|
210
|
+
result={'bytesWritten':len(data)}
|
|
211
|
+
elif op=='reserve':
|
|
212
|
+
parents(p);fd=os.open(p,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600);os.close(fd)
|
|
213
|
+
elif op=='publish':
|
|
214
|
+
regular(p)
|
|
215
|
+
try: publish(p,q);published=True
|
|
216
|
+
finally:
|
|
217
|
+
# A refused publish leaves nothing behind: the staged bytes are this attempt's own.
|
|
218
|
+
if not published and os.path.lexists(p): os.unlink(p)
|
|
219
|
+
elif op=='copy':
|
|
220
|
+
regular(p)
|
|
221
|
+
if overwrite and os.path.lexists(q) and not stat.S_ISREG(os.lstat(q).st_mode): raise OSError(errno.EINVAL,'regular destination required')
|
|
222
|
+
parents(stage);fd=os.open(stage,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600);created=True
|
|
223
|
+
try:
|
|
224
|
+
src=os.open(p,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK)
|
|
225
|
+
with os.fdopen(src,'rb') as inp,os.fdopen(fd,'wb') as out:
|
|
226
|
+
if not stat.S_ISREG(os.fstat(inp.fileno()).st_mode): raise OSError(errno.EINVAL,'regular file required')
|
|
227
|
+
shutil.copyfileobj(inp,out,65536)
|
|
228
|
+
publish(stage,q);published=True
|
|
229
|
+
finally:
|
|
230
|
+
if created and os.path.lexists(stage): os.unlink(stage)
|
|
231
|
+
elif op=='move':
|
|
232
|
+
os.lstat(p);rename(p,q)
|
|
233
|
+
print(json.dumps({'ok':True,'value':result},ensure_ascii=True))
|
|
234
|
+
except OSError as e:
|
|
235
|
+
code='NOT_FOUND' if e.errno==errno.ENOENT else 'CONFLICT' if e.errno in (errno.EEXIST,errno.ENOTEMPTY) else 'FORBIDDEN' if e.errno in (errno.EACCES,errno.EPERM) else 'CAPACITY' if e.errno in (errno.EFBIG,errno.ENOSPC) else 'UNSUPPORTED' if e.errno in (errno.EXDEV,errno.ENOSYS,errno.EOPNOTSUPP) else 'INVALID_ARGUMENT'
|
|
236
|
+
print(json.dumps({'ok':False,'code':code,'effect':'applied' if published else 'possible' if (created and os.path.lexists(stage)) or (op in ('mkdir','remove') and overwrite) else 'none','temporaryPaths':[stage] if created and os.path.lexists(stage) else []}))
|
|
237
|
+
`;
|
|
238
|
+
var STAGE_CHUNK_BYTES = 1048576;
|
|
239
|
+
var READ_CHUNK_BYTES = 1048576;
|
|
240
|
+
var Receipt = z.discriminatedUnion("ok", [
|
|
241
|
+
z.object({ ok: z.literal(true), value: z.json() }),
|
|
242
|
+
z.object({
|
|
243
|
+
ok: z.literal(false),
|
|
244
|
+
code: z.enum([
|
|
245
|
+
"NOT_FOUND",
|
|
246
|
+
"CONFLICT",
|
|
247
|
+
"FORBIDDEN",
|
|
248
|
+
"CAPACITY",
|
|
249
|
+
"UNSUPPORTED",
|
|
250
|
+
"INVALID_ARGUMENT"
|
|
251
|
+
]),
|
|
252
|
+
effect: z.enum(["none", "possible", "applied"]),
|
|
253
|
+
temporaryPaths: z.array(z.string())
|
|
254
|
+
})
|
|
255
|
+
]);
|
|
256
|
+
var reads = new Set(["stat", "list", "exists"]);
|
|
257
|
+
async function rangeResult(response, offset, length) {
|
|
258
|
+
const header = response.headers.get("content-range") ?? "";
|
|
259
|
+
if (response.status === 416) {
|
|
260
|
+
await response.body?.cancel().catch(() => {
|
|
261
|
+
return;
|
|
262
|
+
});
|
|
263
|
+
if (!header)
|
|
264
|
+
return { bytes: new Uint8Array, total: offset };
|
|
265
|
+
const match2 = /^bytes \*\/(\d{1,15})$/.exec(header);
|
|
266
|
+
const total2 = match2 ? Number(match2[1]) : undefined;
|
|
267
|
+
return total2 === offset ? { bytes: new Uint8Array, total: total2 } : null;
|
|
268
|
+
}
|
|
269
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
270
|
+
if (response.status === 200)
|
|
271
|
+
return offset === 0 && bytes.length <= length ? { bytes, total: bytes.length } : null;
|
|
272
|
+
if (response.status !== 206 || bytes.length > length)
|
|
273
|
+
return null;
|
|
274
|
+
if (!header)
|
|
275
|
+
return { bytes, total: undefined };
|
|
276
|
+
const match = /^bytes (\d{1,15})-(\d{1,15})\/(\d{1,15}|\*)$/.exec(header);
|
|
277
|
+
if (!match)
|
|
278
|
+
return null;
|
|
279
|
+
const [, start, end, size] = match;
|
|
280
|
+
const total = size === "*" ? undefined : Number(size);
|
|
281
|
+
if (Number(start) !== offset || Number(end) - Number(start) + 1 !== bytes.length || total !== undefined && Number(end) >= total)
|
|
282
|
+
return null;
|
|
283
|
+
return { bytes, total };
|
|
284
|
+
}
|
|
285
|
+
function filesystem(run, readRange) {
|
|
286
|
+
const helper = async (op, path, destination, flag, ctx, stage = "", stdin) => {
|
|
287
|
+
ctx.signal.throwIfAborted();
|
|
288
|
+
const command = ["python3", "-c", FILESYSTEM_HELPER, op, path, destination, String(flag), stage].map(quote).join(" ");
|
|
289
|
+
const uncertain = () => new AdapterFilesystemError("UNAVAILABLE", "Freestyle filesystem response was not confirmed; do not replay", {
|
|
290
|
+
effect: "possible",
|
|
291
|
+
source: path,
|
|
292
|
+
destination: destination || path,
|
|
293
|
+
temporaryPaths: stage ? [stage] : []
|
|
294
|
+
});
|
|
295
|
+
let receipt;
|
|
296
|
+
try {
|
|
297
|
+
const stdout = await run(command, { ...ctx, deadline: Math.min(ctx.deadline, Date.now() + 60000) }, stdin);
|
|
298
|
+
receipt = Receipt.parse(JSON.parse(stdout));
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (ctx.signal.aborted)
|
|
301
|
+
throw ctx.signal.reason;
|
|
302
|
+
if (!reads.has(op))
|
|
303
|
+
throw uncertain();
|
|
304
|
+
if (error instanceof AdapterError2)
|
|
305
|
+
throw error;
|
|
306
|
+
throw new AdapterError2("UNAVAILABLE", "Freestyle filesystem helper failed; Python 3 is required in the VM");
|
|
307
|
+
}
|
|
308
|
+
if (!receipt.ok)
|
|
309
|
+
throw new AdapterFilesystemError(receipt.code, "Freestyle filesystem request rejected", {
|
|
310
|
+
effect: receipt.effect,
|
|
311
|
+
source: path,
|
|
312
|
+
destination: destination || path,
|
|
313
|
+
temporaryPaths: receipt.temporaryPaths
|
|
314
|
+
});
|
|
315
|
+
return receipt.value;
|
|
316
|
+
};
|
|
317
|
+
const staging = (path) => posix.join(posix.dirname(path), `.beachside-${randomUUID()}`);
|
|
318
|
+
const write = async (path, bytes, overwrite, ctx) => {
|
|
319
|
+
const stage = staging(path);
|
|
320
|
+
let count = 0;
|
|
321
|
+
ctx.retain?.({ temporaryPaths: [stage] });
|
|
322
|
+
try {
|
|
323
|
+
await helper("reserve", stage, "", false, ctx);
|
|
324
|
+
for await (const input of bytes)
|
|
325
|
+
for (let offset = 0;offset < input.length; offset += STAGE_CHUNK_BYTES) {
|
|
326
|
+
ctx.signal.throwIfAborted();
|
|
327
|
+
const chunk = input.subarray(offset, offset + STAGE_CHUNK_BYTES);
|
|
328
|
+
const ack = z.object({ bytesWritten: z.number().int() }).parse(await helper("append", stage, String(count), false, { ...ctx, deadline: Date.now() + 60000 }, "", chunk));
|
|
329
|
+
if (ack.bytesWritten !== chunk.length)
|
|
330
|
+
throw new AdapterFilesystemError("UNAVAILABLE", "Freestyle staging byte count differs", {
|
|
331
|
+
effect: "possible",
|
|
332
|
+
source: stage,
|
|
333
|
+
destination: path,
|
|
334
|
+
temporaryPaths: [stage]
|
|
335
|
+
});
|
|
336
|
+
count += chunk.length;
|
|
337
|
+
ctx.retain?.({ temporaryPaths: [stage], bytesTransferred: count });
|
|
338
|
+
}
|
|
339
|
+
await helper("publish", stage, path, overwrite, { ...ctx, deadline: Date.now() + 60000 });
|
|
340
|
+
ctx.retain?.({ effect: "applied", temporaryPaths: [], bytesTransferred: count });
|
|
341
|
+
return { bytesWritten: count };
|
|
342
|
+
} catch (error) {
|
|
343
|
+
if (error instanceof AdapterFilesystemError) {
|
|
344
|
+
if (error.details.effect === "applied")
|
|
345
|
+
ctx.retain?.({ effect: "applied", temporaryPaths: [stage], bytesTransferred: count });
|
|
346
|
+
throw new AdapterFilesystemError(error.code, error.message, {
|
|
347
|
+
...error.details,
|
|
348
|
+
source: stage,
|
|
349
|
+
destination: path,
|
|
350
|
+
temporaryPaths: [stage],
|
|
351
|
+
bytesTransferred: count
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
throw error;
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
return {
|
|
358
|
+
helper,
|
|
359
|
+
write,
|
|
360
|
+
async transfer(op, source, destination, overwrite, ctx) {
|
|
361
|
+
const stage = op === "copy" ? staging(destination) : "";
|
|
362
|
+
ctx.retain?.({ temporaryPaths: stage ? [stage] : [] });
|
|
363
|
+
await helper(op, source, destination, overwrite, ctx, stage);
|
|
364
|
+
ctx.retain?.({ effect: "applied", temporaryPaths: [] });
|
|
365
|
+
return { acknowledged: true };
|
|
366
|
+
},
|
|
367
|
+
read(path, ctx) {
|
|
368
|
+
let offset = 0;
|
|
369
|
+
const cancellation = new AbortController;
|
|
370
|
+
const signal = AbortSignal.any([ctx.signal, cancellation.signal]);
|
|
371
|
+
return new ReadableStream({
|
|
372
|
+
cancel() {
|
|
373
|
+
cancellation.abort();
|
|
374
|
+
},
|
|
375
|
+
async pull(controller) {
|
|
376
|
+
try {
|
|
377
|
+
const { bytes, total } = await readRange(path, offset, READ_CHUNK_BYTES, {
|
|
378
|
+
signal,
|
|
379
|
+
deadline: Date.now() + 60000
|
|
380
|
+
});
|
|
381
|
+
if (bytes.length > READ_CHUNK_BYTES)
|
|
382
|
+
throw new AdapterError2("UNAVAILABLE", "Freestyle file range is malformed");
|
|
383
|
+
offset += bytes.length;
|
|
384
|
+
if (bytes.length)
|
|
385
|
+
controller.enqueue(bytes);
|
|
386
|
+
if (total === undefined ? bytes.length < READ_CHUNK_BYTES : offset >= total)
|
|
387
|
+
controller.close();
|
|
388
|
+
else if (!bytes.length)
|
|
389
|
+
throw new AdapterError2("UNAVAILABLE", "Freestyle file range ended early");
|
|
390
|
+
} catch (error) {
|
|
391
|
+
controller.error(error);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}, { highWaterMark: 0 });
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
var Directory = z.object({
|
|
399
|
+
entries: z.array(z.object({ name: z.string(), type: z.enum(["file", "directory", "symlink", "unknown"]) })),
|
|
400
|
+
completeness: z.literal("complete")
|
|
401
|
+
});
|
|
402
|
+
function directoryValue(value) {
|
|
403
|
+
const parsed = Directory.safeParse(value);
|
|
404
|
+
if (!parsed.success)
|
|
405
|
+
throw new AdapterError2("UNAVAILABLE", "Freestyle directory response is invalid");
|
|
406
|
+
return { ...parsed.data, observedAt: new Date().toISOString() };
|
|
407
|
+
}
|
|
408
|
+
function statValue(value) {
|
|
409
|
+
const result = z.object({
|
|
410
|
+
type: z.enum(["file", "directory", "symlink", "unknown"]),
|
|
411
|
+
sizeBytes: z.number().int().nonnegative().safe(),
|
|
412
|
+
modifiedAt: z.number(),
|
|
413
|
+
mode: z.number().int().nonnegative()
|
|
414
|
+
}).safeParse(value);
|
|
415
|
+
if (!result.success || Math.abs(result.data.modifiedAt * 1000) > 8640000000000000)
|
|
416
|
+
throw new AdapterError2("UNAVAILABLE", "Freestyle metadata response is invalid");
|
|
417
|
+
return { ...result.data, modifiedAt: new Date(result.data.modifiedAt * 1000).toISOString() };
|
|
418
|
+
}
|
|
419
|
+
var Exists = z.object({ exists: z.boolean() });
|
|
420
|
+
|
|
421
|
+
// src/process.ts
|
|
422
|
+
import {
|
|
423
|
+
AdapterError as AdapterError3
|
|
424
|
+
} from "@beachside/adapter";
|
|
425
|
+
import { setImmediate as yieldOutput } from "node:timers/promises";
|
|
426
|
+
import { z as z2 } from "zod";
|
|
427
|
+
var PROCESS_RECORD = "BEACHSIDE-PROC-1";
|
|
428
|
+
var POLL_RECORD = "BEACHSIDE-POLL-1";
|
|
429
|
+
var POLL_BYTES = 256 * 1024;
|
|
430
|
+
var DELIVERY_BYTES = 16384;
|
|
431
|
+
var MAX_WRITE_BYTES = 1048576;
|
|
432
|
+
var POLL_FAILURES = 5;
|
|
433
|
+
var Directory2 = z2.string().regex(/^\/tmp\/beachside-proc\.[A-Za-z0-9]{8}$/);
|
|
434
|
+
var ProcessReference = z2.strictObject({
|
|
435
|
+
version: z2.literal(1),
|
|
436
|
+
kind: z2.literal("freestyle-job"),
|
|
437
|
+
sandbox: z2.string().min(1).max(128),
|
|
438
|
+
directory: Directory2,
|
|
439
|
+
stdin: z2.enum(["closed", "pipe"])
|
|
440
|
+
});
|
|
441
|
+
function deferred() {
|
|
442
|
+
let resolve;
|
|
443
|
+
let reject;
|
|
444
|
+
const promise = new Promise((accept, fail) => {
|
|
445
|
+
resolve = accept;
|
|
446
|
+
reject = fail;
|
|
447
|
+
});
|
|
448
|
+
promise.catch(() => {
|
|
449
|
+
return;
|
|
450
|
+
});
|
|
451
|
+
return { promise, resolve, reject };
|
|
452
|
+
}
|
|
453
|
+
function sleep(ms, signal) {
|
|
454
|
+
return new Promise((resolve) => {
|
|
455
|
+
if (signal.aborted) {
|
|
456
|
+
resolve();
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const timer = setTimeout(() => {
|
|
460
|
+
signal.removeEventListener("abort", stop);
|
|
461
|
+
resolve();
|
|
462
|
+
}, ms);
|
|
463
|
+
const stop = () => {
|
|
464
|
+
clearTimeout(timer);
|
|
465
|
+
resolve();
|
|
466
|
+
};
|
|
467
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
function launcherScript(input) {
|
|
471
|
+
const piped = input.stdin === "pipe";
|
|
472
|
+
const command = [
|
|
473
|
+
'echo $$ >"$1/pid"',
|
|
474
|
+
`exec <${piped ? '"$1/in"' : "/dev/null"} >"$1/out" 2>"$1/err"`,
|
|
475
|
+
commandLine(input)
|
|
476
|
+
].join("; ");
|
|
477
|
+
const job = [
|
|
478
|
+
`setsid -w sh -c ${quote(command)} sh "$1"`,
|
|
479
|
+
'echo $? >"$1/exit.tmp"',
|
|
480
|
+
...piped ? ['[ -s "$1/holder" ] && kill "$(cat "$1/holder")" 2>/dev/null; : >"$1/holder"'] : [],
|
|
481
|
+
'mv "$1/exit.tmp" "$1/exit"'
|
|
482
|
+
].join("; ");
|
|
483
|
+
const lines = [
|
|
484
|
+
"beachside_d=$(mktemp -d /tmp/beachside-proc.XXXXXXXX) || exit 97",
|
|
485
|
+
': >"$beachside_d/out"; : >"$beachside_d/err"'
|
|
486
|
+
];
|
|
487
|
+
if (piped)
|
|
488
|
+
lines.push('mkfifo "$beachside_d/in" || exit 97', `setsid -f sh -c ${quote('echo $$ >"$1/holder"; exec sleep 2147483647 >"$1/in"')} sh "$beachside_d" </dev/null >/dev/null 2>&1`);
|
|
489
|
+
lines.push(`setsid -f sh -c ${quote(job)} sh "$beachside_d" </dev/null >/dev/null 2>&1`, "beachside_i=0", 'while [ ! -s "$beachside_d/pid" ] && [ "$beachside_i" -lt 200 ]; do sleep 0.05; beachside_i=$((beachside_i + 1)); done', '[ -s "$beachside_d/pid" ] || exit 98', `printf '${PROCESS_RECORD} %s\\n' "$beachside_d"`);
|
|
490
|
+
return lines.join(`
|
|
491
|
+
`);
|
|
492
|
+
}
|
|
493
|
+
function pollScript(directory, offsets) {
|
|
494
|
+
const dir = quote(directory);
|
|
495
|
+
return [
|
|
496
|
+
`cd ${dir} 2>/dev/null || { printf '${POLL_RECORD} gone\\n'; exit 0; }`,
|
|
497
|
+
"beachside_x=-; if [ -f exit ]; then beachside_x=$(cat exit); fi",
|
|
498
|
+
"beachside_o=$(($(wc -c <out))); beachside_e=$(($(wc -c <err)))",
|
|
499
|
+
`printf '${POLL_RECORD} %s %s %s\\n' "$beachside_x" "$beachside_o" "$beachside_e"`,
|
|
500
|
+
`tail -c +${offsets.stdout + 1} out | head -c ${POLL_BYTES} | base64 | tr -d '\\n'; printf '\\n'`,
|
|
501
|
+
`tail -c +${offsets.stderr + 1} err | head -c ${POLL_BYTES} | base64 | tr -d '\\n'; printf '\\n'`
|
|
502
|
+
].join(`
|
|
503
|
+
`);
|
|
504
|
+
}
|
|
505
|
+
var Base64 = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
506
|
+
var PollLine = new RegExp(`^${POLL_RECORD} (-|\\d{1,3}) (\\d{1,15}) (\\d{1,15})$`);
|
|
507
|
+
function parsePoll(text) {
|
|
508
|
+
if (text === `${POLL_RECORD} gone
|
|
509
|
+
`)
|
|
510
|
+
return { gone: true };
|
|
511
|
+
const [header = "", out = "", err = "", rest] = text.split(`
|
|
512
|
+
`);
|
|
513
|
+
const match = PollLine.exec(header);
|
|
514
|
+
if (!match || rest !== "" || !Base64.test(out) || !Base64.test(err))
|
|
515
|
+
return null;
|
|
516
|
+
const stdout = Buffer.from(out, "base64");
|
|
517
|
+
const stderr = Buffer.from(err, "base64");
|
|
518
|
+
if (stdout.toString("base64") !== out || stderr.toString("base64") !== err)
|
|
519
|
+
return null;
|
|
520
|
+
return {
|
|
521
|
+
gone: false,
|
|
522
|
+
exit: match[1] === "-" ? undefined : Number(match[1]),
|
|
523
|
+
sizes: { stdout: Number(match[2]), stderr: Number(match[3]) },
|
|
524
|
+
stdout: new Uint8Array(stdout),
|
|
525
|
+
stderr: new Uint8Array(stderr)
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
var signalNames = { SIGTERM: "TERM", SIGKILL: "KILL" };
|
|
529
|
+
async function observeJob(options, reference, input, ctx) {
|
|
530
|
+
if (input.format === "bytes" && !ctx.onOutputBytes)
|
|
531
|
+
throw new AdapterError3("UNSUPPORTED", "Freestyle binary output requires a byte consumer hook");
|
|
532
|
+
const { run } = options;
|
|
533
|
+
const dir = quote(reference.directory);
|
|
534
|
+
const controller = new AbortController;
|
|
535
|
+
const exited = deferred();
|
|
536
|
+
const output = deferred();
|
|
537
|
+
const capture = input.capture ? deferred() : undefined;
|
|
538
|
+
const captureLimit = input.capture?.maxBytes ?? 0;
|
|
539
|
+
const captured = { stdout: new Array, stderr: new Array };
|
|
540
|
+
let capturedBytes = 0;
|
|
541
|
+
let truncated = false;
|
|
542
|
+
const decoders = { stdout: new TextDecoder, stderr: new TextDecoder };
|
|
543
|
+
const offsets = { stdout: 0, stderr: 0 };
|
|
544
|
+
let confirmedExit;
|
|
545
|
+
let outputDetached = false;
|
|
546
|
+
let detached = false;
|
|
547
|
+
const fail = () => new AdapterError3("UNAVAILABLE", "Freestyle process observation failed");
|
|
548
|
+
const request = () => ({
|
|
549
|
+
signal: controller.signal,
|
|
550
|
+
deadline: Date.now() + options.requestSeconds * 1000
|
|
551
|
+
});
|
|
552
|
+
const poll = async (context) => {
|
|
553
|
+
const parsed = parsePoll(await run(pollScript(reference.directory, offsets), context));
|
|
554
|
+
if (!parsed)
|
|
555
|
+
throw fail();
|
|
556
|
+
return parsed;
|
|
557
|
+
};
|
|
558
|
+
if (input.fromEnd) {
|
|
559
|
+
const first = await poll({ signal: ctx.signal, deadline: ctx.deadline });
|
|
560
|
+
if (first.gone)
|
|
561
|
+
throw new AdapterError3("NOT_FOUND", "Freestyle process is absent");
|
|
562
|
+
offsets.stdout = first.sizes.stdout;
|
|
563
|
+
offsets.stderr = first.sizes.stderr;
|
|
564
|
+
}
|
|
565
|
+
const deliver = async (stream, bytes) => {
|
|
566
|
+
for (let index = 0;index < bytes.length; index += DELIVERY_BYTES) {
|
|
567
|
+
deliverSegment(stream, bytes.subarray(index, index + DELIVERY_BYTES));
|
|
568
|
+
await yieldOutput();
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
const deliverSegment = (stream, bytes) => {
|
|
572
|
+
if (!bytes.length)
|
|
573
|
+
return;
|
|
574
|
+
if (capture) {
|
|
575
|
+
const take = Math.min(bytes.length, captureLimit - capturedBytes);
|
|
576
|
+
if (take > 0)
|
|
577
|
+
captured[stream].push(bytes.slice(0, take));
|
|
578
|
+
capturedBytes += take;
|
|
579
|
+
if (take < bytes.length)
|
|
580
|
+
truncated = true;
|
|
581
|
+
}
|
|
582
|
+
if (outputDetached || detached)
|
|
583
|
+
return;
|
|
584
|
+
try {
|
|
585
|
+
if (input.format === "bytes")
|
|
586
|
+
ctx.onOutputBytes({ stream, bytes });
|
|
587
|
+
else {
|
|
588
|
+
const text = decoders[stream].decode(bytes, { stream: true });
|
|
589
|
+
if (text)
|
|
590
|
+
ctx.onOutput({ stream, text });
|
|
591
|
+
}
|
|
592
|
+
} catch (error) {
|
|
593
|
+
outputDetached = true;
|
|
594
|
+
output.reject(error instanceof Error ? error : fail());
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
async function observe() {
|
|
598
|
+
let failures = 0;
|
|
599
|
+
while (!controller.signal.aborted) {
|
|
600
|
+
let result;
|
|
601
|
+
if (await options.held?.()) {
|
|
602
|
+
await sleep(options.pollMs, controller.signal);
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
try {
|
|
606
|
+
result = await poll(request());
|
|
607
|
+
failures = 0;
|
|
608
|
+
} catch (error) {
|
|
609
|
+
if (controller.signal.aborted)
|
|
610
|
+
return;
|
|
611
|
+
if (++failures >= POLL_FAILURES)
|
|
612
|
+
throw error;
|
|
613
|
+
await sleep(options.pollMs, controller.signal);
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
if (result.gone)
|
|
617
|
+
throw fail();
|
|
618
|
+
await deliver("stdout", result.stdout);
|
|
619
|
+
await deliver("stderr", result.stderr);
|
|
620
|
+
offsets.stdout += result.stdout.length;
|
|
621
|
+
offsets.stderr += result.stderr.length;
|
|
622
|
+
const drained = offsets.stdout >= result.sizes.stdout && offsets.stderr >= result.sizes.stderr;
|
|
623
|
+
if (result.exit !== undefined && drained) {
|
|
624
|
+
confirmedExit = { exitCode: result.exit };
|
|
625
|
+
if (input.format === "text" && !outputDetached && !detached)
|
|
626
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
627
|
+
const text = decoders[stream].decode();
|
|
628
|
+
if (text)
|
|
629
|
+
ctx.onOutput({ stream, text });
|
|
630
|
+
}
|
|
631
|
+
exited.resolve(confirmedExit);
|
|
632
|
+
capture?.resolve({
|
|
633
|
+
exitCode: confirmedExit.exitCode,
|
|
634
|
+
stdout: new Uint8Array(Buffer.concat(captured.stdout)),
|
|
635
|
+
stderr: new Uint8Array(Buffer.concat(captured.stderr)),
|
|
636
|
+
truncated
|
|
637
|
+
});
|
|
638
|
+
output.resolve();
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
if (!drained)
|
|
642
|
+
continue;
|
|
643
|
+
await sleep(options.pollMs, controller.signal);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
observe().catch(() => {
|
|
647
|
+
return;
|
|
648
|
+
}).then(() => {
|
|
649
|
+
if (confirmedExit)
|
|
650
|
+
return;
|
|
651
|
+
const error = fail();
|
|
652
|
+
exited.reject(error);
|
|
653
|
+
output.reject(error);
|
|
654
|
+
capture?.reject(error);
|
|
655
|
+
});
|
|
656
|
+
const control = async (script, context, stdin) => {
|
|
657
|
+
if (detached)
|
|
658
|
+
throw fail();
|
|
659
|
+
if (await options.held?.())
|
|
660
|
+
throw new AdapterError3("CONFLICT", "Freestyle VM is paused; resume it before controlling the process");
|
|
661
|
+
return run(`cd ${dir} 2>/dev/null || { echo gone; exit 0; }
|
|
662
|
+
${script}`, context, stdin);
|
|
663
|
+
};
|
|
664
|
+
async function sendSignal(signal, context) {
|
|
665
|
+
const name = signalNames[signal];
|
|
666
|
+
const answer = await control(`if [ -f exit ] || [ ! -s pid ]; then echo gone; elif kill -${name} "-$(cat pid)" 2>/dev/null; then echo ok; else echo gone; fi`, context);
|
|
667
|
+
return { status: answer.trim() === "ok" ? "requested" : "not-found" };
|
|
668
|
+
}
|
|
669
|
+
async function disconnect() {
|
|
670
|
+
if (detached)
|
|
671
|
+
return;
|
|
672
|
+
detached = true;
|
|
673
|
+
controller.abort();
|
|
674
|
+
if (!confirmedExit)
|
|
675
|
+
exited.reject(fail());
|
|
676
|
+
output.reject(fail());
|
|
677
|
+
capture?.reject(fail());
|
|
678
|
+
}
|
|
679
|
+
return {
|
|
680
|
+
reference,
|
|
681
|
+
signal: sendSignal,
|
|
682
|
+
terminate: (context) => sendSignal("SIGKILL", context),
|
|
683
|
+
disconnect,
|
|
684
|
+
detach: disconnect,
|
|
685
|
+
async detachOutput() {
|
|
686
|
+
outputDetached = true;
|
|
687
|
+
output.reject(fail());
|
|
688
|
+
},
|
|
689
|
+
get confirmedExit() {
|
|
690
|
+
return confirmedExit;
|
|
691
|
+
},
|
|
692
|
+
outputDone: output.promise,
|
|
693
|
+
capture: capture?.promise,
|
|
694
|
+
wait: () => exited.promise,
|
|
695
|
+
async status(context) {
|
|
696
|
+
const observedAt = new Date().toISOString();
|
|
697
|
+
if (confirmedExit)
|
|
698
|
+
return { state: "exited", exit: confirmedExit, observedAt };
|
|
699
|
+
const answer = (await control('if [ -f exit ]; then printf "exit %s\\n" "$(cat exit)"; elif [ -s pid ] && kill -0 "-$(cat pid)" 2>/dev/null; then echo running; else echo unknown; fi', context)).trim();
|
|
700
|
+
const code = /^exit (\d{1,3})$/.exec(answer);
|
|
701
|
+
if (code)
|
|
702
|
+
return { state: "exited", exit: { exitCode: Number(code[1]) }, observedAt };
|
|
703
|
+
return { state: answer === "running" ? "running" : "unknown", observedAt };
|
|
704
|
+
},
|
|
705
|
+
async write(bytes, context) {
|
|
706
|
+
if (reference.stdin !== "pipe")
|
|
707
|
+
throw new AdapterError3("UNSUPPORTED", "This Freestyle process has closed stdin");
|
|
708
|
+
for (let index = 0;index < bytes.length; index += MAX_WRITE_BYTES) {
|
|
709
|
+
const answer = (await control("if [ -f exit ] || [ ! -s holder ]; then echo gone; else timeout 10 sh -c 'cat >>in'; beachside_s=$?; if [ $beachside_s = 0 ]; then echo ok; elif [ $beachside_s = 124 ]; then echo timeout; else echo failed; fi; fi", context, bytes.subarray(index, index + MAX_WRITE_BYTES))).trim();
|
|
710
|
+
if (answer === "gone")
|
|
711
|
+
throw new AdapterError3("NOT_FOUND", "Freestyle process stdin is closed or absent");
|
|
712
|
+
if (answer === "timeout")
|
|
713
|
+
throw new AdapterError3("TIMEOUT", "Freestyle process did not read its stdin in time; part of the input may have been written");
|
|
714
|
+
if (answer !== "ok")
|
|
715
|
+
throw new AdapterError3("UNAVAILABLE", "Freestyle process stdin write failed; part of the input may have been written");
|
|
716
|
+
}
|
|
717
|
+
},
|
|
718
|
+
async closeStdin(context) {
|
|
719
|
+
if (reference.stdin !== "pipe")
|
|
720
|
+
throw new AdapterError3("UNSUPPORTED", "This Freestyle process has closed stdin");
|
|
721
|
+
const answer = await control('if [ -s holder ] && kill "$(cat holder)" 2>/dev/null; then : >holder; echo ok; elif [ -f holder ]; then echo ok; else echo gone; fi', context);
|
|
722
|
+
if (answer.trim() !== "ok")
|
|
723
|
+
throw new AdapterError3("NOT_FOUND", "Freestyle process is absent");
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
function parseLauncher(text) {
|
|
728
|
+
const match = new RegExp(`^${PROCESS_RECORD} (\\S+)\\n$`).exec(text);
|
|
729
|
+
const directory = Directory2.safeParse(match?.[1]);
|
|
730
|
+
return directory.success ? directory.data : null;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// src/terminal.ts
|
|
734
|
+
import {
|
|
735
|
+
AdapterError as AdapterError4
|
|
736
|
+
} from "@beachside/adapter";
|
|
737
|
+
import { randomBytes } from "node:crypto";
|
|
738
|
+
import { z as z3 } from "zod";
|
|
739
|
+
var TerminalSize = z3.strictObject({
|
|
740
|
+
columns: z3.number().int().min(1).max(1000),
|
|
741
|
+
rows: z3.number().int().min(1).max(1000)
|
|
742
|
+
});
|
|
743
|
+
var SessionSlug = z3.string().regex(/^beachside-term-[0-9a-f]{24}$/);
|
|
744
|
+
var TerminalReference = z3.strictObject({
|
|
745
|
+
version: z3.literal(1),
|
|
746
|
+
kind: z3.literal("freestyle-pty"),
|
|
747
|
+
sandbox: z3.string().min(1).max(128),
|
|
748
|
+
session: SessionSlug
|
|
749
|
+
});
|
|
750
|
+
function terminalSlug() {
|
|
751
|
+
return `beachside-term-${randomBytes(12).toString("hex")}`;
|
|
752
|
+
}
|
|
753
|
+
function deferred2() {
|
|
754
|
+
let resolve;
|
|
755
|
+
let reject;
|
|
756
|
+
const promise = new Promise((accept, fail) => {
|
|
757
|
+
resolve = accept;
|
|
758
|
+
reject = fail;
|
|
759
|
+
});
|
|
760
|
+
promise.catch(() => {
|
|
761
|
+
return;
|
|
762
|
+
});
|
|
763
|
+
return { promise, resolve, reject };
|
|
764
|
+
}
|
|
765
|
+
async function openTerminal(driver, input, ctx) {
|
|
766
|
+
if (input.terminal && !TerminalSize.safeParse(input.terminal).success)
|
|
767
|
+
throw new AdapterError4("INVALID_ARGUMENT", "Freestyle terminal dimensions are invalid");
|
|
768
|
+
if (input.format === "bytes" && !ctx.onOutputBytes)
|
|
769
|
+
throw new AdapterError4("UNSUPPORTED", "Freestyle binary output requires a byte consumer hook");
|
|
770
|
+
const exited = deferred2();
|
|
771
|
+
const output = deferred2();
|
|
772
|
+
const capture = input.capture ? deferred2() : undefined;
|
|
773
|
+
const captureLimit = input.capture?.maxBytes ?? 0;
|
|
774
|
+
const captured = [];
|
|
775
|
+
let capturedBytes = 0;
|
|
776
|
+
let truncated = false;
|
|
777
|
+
const decoder = new TextDecoder;
|
|
778
|
+
let confirmedExit;
|
|
779
|
+
let outputDetached = false;
|
|
780
|
+
let detached = false;
|
|
781
|
+
let replaying = false;
|
|
782
|
+
const fail = () => new AdapterError4("UNAVAILABLE", "Freestyle terminal observation failed");
|
|
783
|
+
const lost = () => {
|
|
784
|
+
if (confirmedExit)
|
|
785
|
+
return;
|
|
786
|
+
const error = fail();
|
|
787
|
+
exited.reject(error);
|
|
788
|
+
output.reject(error);
|
|
789
|
+
capture?.reject(error);
|
|
790
|
+
};
|
|
791
|
+
const events = {
|
|
792
|
+
reconnect: false,
|
|
793
|
+
onReplayStart: () => {
|
|
794
|
+
replaying = input.command === undefined;
|
|
795
|
+
},
|
|
796
|
+
onReplayEnd: () => {
|
|
797
|
+
replaying = false;
|
|
798
|
+
},
|
|
799
|
+
onData(data) {
|
|
800
|
+
if (replaying || confirmedExit)
|
|
801
|
+
return;
|
|
802
|
+
if (capture) {
|
|
803
|
+
const take = Math.min(data.length, captureLimit - capturedBytes);
|
|
804
|
+
if (take > 0)
|
|
805
|
+
captured.push(data.slice(0, take));
|
|
806
|
+
capturedBytes += take;
|
|
807
|
+
if (take < data.length)
|
|
808
|
+
truncated = true;
|
|
809
|
+
}
|
|
810
|
+
if (outputDetached || detached || !data.length)
|
|
811
|
+
return;
|
|
812
|
+
try {
|
|
813
|
+
if (input.format === "bytes")
|
|
814
|
+
ctx.onOutputBytes({ stream: "stdout", bytes: new Uint8Array(data) });
|
|
815
|
+
else {
|
|
816
|
+
const text = decoder.decode(data, { stream: true });
|
|
817
|
+
if (text)
|
|
818
|
+
ctx.onOutput({ stream: "stdout", text });
|
|
819
|
+
}
|
|
820
|
+
} catch (error) {
|
|
821
|
+
outputDetached = true;
|
|
822
|
+
output.reject(error instanceof Error ? error : fail());
|
|
823
|
+
}
|
|
824
|
+
},
|
|
825
|
+
onExit(exitCode) {
|
|
826
|
+
if (confirmedExit)
|
|
827
|
+
return;
|
|
828
|
+
confirmedExit = { exitCode };
|
|
829
|
+
if (input.format === "text" && !outputDetached && !detached) {
|
|
830
|
+
const text = decoder.decode();
|
|
831
|
+
if (text)
|
|
832
|
+
ctx.onOutput({ stream: "stdout", text });
|
|
833
|
+
}
|
|
834
|
+
exited.resolve(confirmedExit);
|
|
835
|
+
capture?.resolve({
|
|
836
|
+
exitCode,
|
|
837
|
+
stdout: new Uint8Array(Buffer.concat(captured)),
|
|
838
|
+
stderr: new Uint8Array,
|
|
839
|
+
truncated
|
|
840
|
+
});
|
|
841
|
+
output.resolve();
|
|
842
|
+
},
|
|
843
|
+
onClose: lost,
|
|
844
|
+
onError: () => {
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
let handle;
|
|
849
|
+
try {
|
|
850
|
+
handle = input.command ? await driver.open(input.vmId, {
|
|
851
|
+
exec: input.command,
|
|
852
|
+
cols: input.terminal?.columns ?? 80,
|
|
853
|
+
rows: input.terminal?.rows ?? 24,
|
|
854
|
+
slug: input.reference.session,
|
|
855
|
+
...events
|
|
856
|
+
}) : await driver.attach(input.vmId, { session: input.reference.session, ...events });
|
|
857
|
+
} catch (error) {
|
|
858
|
+
if (error instanceof AdapterError4)
|
|
859
|
+
throw error;
|
|
860
|
+
throw new AdapterError4("UNAVAILABLE", "Freestyle terminal session did not open");
|
|
861
|
+
}
|
|
862
|
+
const live = () => {
|
|
863
|
+
if (detached)
|
|
864
|
+
throw fail();
|
|
865
|
+
if (confirmedExit)
|
|
866
|
+
throw new AdapterError4("NOT_FOUND", "Freestyle terminal has exited");
|
|
867
|
+
};
|
|
868
|
+
const send = (action) => {
|
|
869
|
+
live();
|
|
870
|
+
try {
|
|
871
|
+
action();
|
|
872
|
+
} catch {
|
|
873
|
+
throw new AdapterError4("UNAVAILABLE", "Freestyle terminal is disconnected");
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
async function disconnect() {
|
|
877
|
+
if (detached)
|
|
878
|
+
return;
|
|
879
|
+
detached = true;
|
|
880
|
+
handle.detach();
|
|
881
|
+
if (!confirmedExit)
|
|
882
|
+
exited.reject(fail());
|
|
883
|
+
output.reject(fail());
|
|
884
|
+
capture?.reject(fail());
|
|
885
|
+
}
|
|
886
|
+
return {
|
|
887
|
+
reference: input.reference,
|
|
888
|
+
async signal(signal) {
|
|
889
|
+
if (confirmedExit)
|
|
890
|
+
return { status: "not-found" };
|
|
891
|
+
if (signal !== "SIGKILL")
|
|
892
|
+
throw new AdapterError4("UNSUPPORTED", "Freestyle terminals accept SIGKILL only");
|
|
893
|
+
send(() => handle.signal("sigkill"));
|
|
894
|
+
return { status: "requested" };
|
|
895
|
+
},
|
|
896
|
+
async terminate() {
|
|
897
|
+
if (confirmedExit)
|
|
898
|
+
return { status: "not-found" };
|
|
899
|
+
send(() => handle.signal("sigkill"));
|
|
900
|
+
return { status: "requested" };
|
|
901
|
+
},
|
|
902
|
+
async resize(dimensions) {
|
|
903
|
+
if (!TerminalSize.safeParse(dimensions).success)
|
|
904
|
+
throw new AdapterError4("INVALID_ARGUMENT", "Freestyle terminal dimensions are invalid");
|
|
905
|
+
send(() => handle.resize({ cols: dimensions.columns, rows: dimensions.rows }));
|
|
906
|
+
},
|
|
907
|
+
async write(bytes) {
|
|
908
|
+
send(() => handle.write(new Uint8Array(bytes)));
|
|
909
|
+
},
|
|
910
|
+
async closeStdin() {
|
|
911
|
+
throw new AdapterError4("UNSUPPORTED", "Freestyle terminals do not have pipe EOF");
|
|
912
|
+
},
|
|
913
|
+
async status(context) {
|
|
914
|
+
const observedAt = new Date().toISOString();
|
|
915
|
+
if (confirmedExit)
|
|
916
|
+
return { state: "exited", exit: confirmedExit, observedAt };
|
|
917
|
+
const session = (await driver.list(input.vmId, context)).find((item) => item.slug === input.reference.session);
|
|
918
|
+
if (session?.state === "exited" && Number.isInteger(session.exitCode))
|
|
919
|
+
return { state: "exited", exit: { exitCode: Number(session.exitCode) }, observedAt };
|
|
920
|
+
return { state: session?.state === "running" ? "running" : "unknown", observedAt };
|
|
921
|
+
},
|
|
922
|
+
get confirmedExit() {
|
|
923
|
+
return confirmedExit;
|
|
924
|
+
},
|
|
925
|
+
outputDone: output.promise,
|
|
926
|
+
capture: capture?.promise,
|
|
927
|
+
wait: () => exited.promise,
|
|
928
|
+
async detachOutput() {
|
|
929
|
+
outputDetached = true;
|
|
930
|
+
output.reject(fail());
|
|
931
|
+
},
|
|
932
|
+
disconnect,
|
|
933
|
+
detach: disconnect
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// src/transport.ts
|
|
938
|
+
import { AdapterError as AdapterError5 } from "@beachside/adapter";
|
|
939
|
+
import { Freestyle, FreestyleApiError } from "freestyle";
|
|
940
|
+
var FREESTYLE_API = "https://api.freestyle.sh";
|
|
941
|
+
var MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
|
|
942
|
+
var CANCELLED = "BEACHSIDE_CANCELLED";
|
|
943
|
+
var RESULT_LOST = "BACKGROUND_RESULT_LOST";
|
|
944
|
+
var BACKGROUND_ID = "x-freestyle-background-request-id";
|
|
945
|
+
var inFlight = new Set(["VM_NON_RESPONSIVE"]);
|
|
946
|
+
var background = /^BACKGROUND_/;
|
|
947
|
+
var codes = new Map([
|
|
948
|
+
[400, "INVALID_ARGUMENT"],
|
|
949
|
+
[401, "UNAUTHENTICATED"],
|
|
950
|
+
[402, "CAPACITY"],
|
|
951
|
+
[403, "FORBIDDEN"],
|
|
952
|
+
[404, "NOT_FOUND"],
|
|
953
|
+
[409, "CONFLICT"],
|
|
954
|
+
[410, "NOT_FOUND"],
|
|
955
|
+
[413, "CAPACITY"],
|
|
956
|
+
[422, "INVALID_ARGUMENT"],
|
|
957
|
+
[429, "RATE_LIMIT"],
|
|
958
|
+
[503, "UNAVAILABLE"],
|
|
959
|
+
[504, "TIMEOUT"]
|
|
960
|
+
]);
|
|
961
|
+
var definitive = new Set([400, 401, 402, 403, 404, 410, 413, 422, 429]);
|
|
962
|
+
|
|
963
|
+
class FetchRefused extends Error {
|
|
964
|
+
reason;
|
|
965
|
+
constructor(reason, message) {
|
|
966
|
+
super(message);
|
|
967
|
+
this.name = "FetchRefused";
|
|
968
|
+
this.reason = reason;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
function clean(text, secrets) {
|
|
972
|
+
let value = text;
|
|
973
|
+
for (const secret of secrets)
|
|
974
|
+
if (secret)
|
|
975
|
+
value = value.replaceAll(secret, "[redacted]");
|
|
976
|
+
return value.replace(/Bearer\s+\S+/gi, "Bearer [redacted]").replace(/[\p{Cc}\s]+/gu, " ").trim().slice(0, 300);
|
|
977
|
+
}
|
|
978
|
+
function nativeFailure(error, secrets) {
|
|
979
|
+
if (error instanceof AdapterError5)
|
|
980
|
+
return { error };
|
|
981
|
+
if (error instanceof FetchRefused)
|
|
982
|
+
return {
|
|
983
|
+
error: new AdapterError5(error.reason === "bound" ? "CAPACITY" : "INTERNAL", error.message)
|
|
984
|
+
};
|
|
985
|
+
if (error instanceof FreestyleApiError) {
|
|
986
|
+
if (error.code === CANCELLED)
|
|
987
|
+
return {
|
|
988
|
+
code: error.code,
|
|
989
|
+
error: new AdapterError5("TIMEOUT", "Freestyle request was cancelled before it settled")
|
|
990
|
+
};
|
|
991
|
+
const code = error.code === "AGENT_UNSUPPORTED" ? "UNSUPPORTED" : codes.get(error.status) ?? (error.status >= 500 ? "UNAVAILABLE" : "INTERNAL");
|
|
992
|
+
const nativeCode = clean(error.code, secrets).slice(0, 128);
|
|
993
|
+
const detail = clean(error.message, secrets);
|
|
994
|
+
return {
|
|
995
|
+
status: error.status,
|
|
996
|
+
code: error.code,
|
|
997
|
+
error: new AdapterError5(code, `Freestyle API ${error.status}${nativeCode ? ` ${nativeCode}` : ""}${detail ? `: ${detail}` : ""}`)
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
const name = error instanceof Error ? error.name : "";
|
|
1001
|
+
return {
|
|
1002
|
+
error: new AdapterError5(name === "AbortError" || name === "TimeoutError" ? "TIMEOUT" : "UNAVAILABLE", "Freestyle API request did not complete")
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
function refusedBeforeEffect(failure) {
|
|
1006
|
+
if (failure.status === undefined || background.test(failure.code ?? ""))
|
|
1007
|
+
return false;
|
|
1008
|
+
if (failure.status === 409)
|
|
1009
|
+
return !inFlight.has(failure.code ?? "");
|
|
1010
|
+
return definitive.has(failure.status);
|
|
1011
|
+
}
|
|
1012
|
+
async function bounded(response, limit) {
|
|
1013
|
+
const init = {
|
|
1014
|
+
status: response.status,
|
|
1015
|
+
statusText: response.statusText,
|
|
1016
|
+
headers: response.headers
|
|
1017
|
+
};
|
|
1018
|
+
if (!response.body || [101, 204, 205, 304].includes(response.status))
|
|
1019
|
+
return new Response(null, init);
|
|
1020
|
+
const reader = response.body.getReader();
|
|
1021
|
+
const chunks = [];
|
|
1022
|
+
let length = 0;
|
|
1023
|
+
try {
|
|
1024
|
+
for (;; ) {
|
|
1025
|
+
const next = await reader.read();
|
|
1026
|
+
if (next.done)
|
|
1027
|
+
break;
|
|
1028
|
+
length += next.value.byteLength;
|
|
1029
|
+
if (length > limit)
|
|
1030
|
+
throw new FetchRefused("bound", "Freestyle API response exceeds the adapter bound");
|
|
1031
|
+
chunks.push(next.value);
|
|
1032
|
+
}
|
|
1033
|
+
} finally {
|
|
1034
|
+
await reader.cancel().catch(() => {
|
|
1035
|
+
return;
|
|
1036
|
+
});
|
|
1037
|
+
reader.releaseLock();
|
|
1038
|
+
}
|
|
1039
|
+
const body = new Uint8Array(length);
|
|
1040
|
+
let offset = 0;
|
|
1041
|
+
for (const chunk of chunks) {
|
|
1042
|
+
body.set(chunk, offset);
|
|
1043
|
+
offset += chunk.byteLength;
|
|
1044
|
+
}
|
|
1045
|
+
return new Response(body, init);
|
|
1046
|
+
}
|
|
1047
|
+
function requestUrl(input) {
|
|
1048
|
+
return new URL(input instanceof Request ? input.url : String(input));
|
|
1049
|
+
}
|
|
1050
|
+
function callFetch(base, signal, limit = MAX_RESPONSE_BYTES) {
|
|
1051
|
+
const wrapped = async (input, init) => {
|
|
1052
|
+
const url = requestUrl(input);
|
|
1053
|
+
if (url.origin !== FREESTYLE_API)
|
|
1054
|
+
throw new FetchRefused("origin", "Freestyle SDK requested another origin");
|
|
1055
|
+
if (signal.aborted && url.pathname.startsWith("/v5/background-requests/"))
|
|
1056
|
+
return new Response(JSON.stringify({ code: CANCELLED, message: "The Beachside call was cancelled" }), { status: 499, headers: { "content-type": "application/json" } });
|
|
1057
|
+
const signals = init?.signal ? [signal, init.signal] : [signal];
|
|
1058
|
+
const response = await base(input, {
|
|
1059
|
+
...init,
|
|
1060
|
+
redirect: "error",
|
|
1061
|
+
signal: AbortSignal.any(signals)
|
|
1062
|
+
});
|
|
1063
|
+
if (url.pathname.startsWith("/v5/background-requests/") && !response.ok && ![408, 429, 502, 503, 504].includes(response.status) && !response.headers.has(BACKGROUND_ID)) {
|
|
1064
|
+
await response.body?.cancel().catch(() => {
|
|
1065
|
+
return;
|
|
1066
|
+
});
|
|
1067
|
+
return new Response(JSON.stringify({
|
|
1068
|
+
code: RESULT_LOST,
|
|
1069
|
+
message: `Freestyle background result was unavailable (${response.status})`
|
|
1070
|
+
}), { status: 500, headers: { "content-type": "application/json" } });
|
|
1071
|
+
}
|
|
1072
|
+
return bounded(response, limit);
|
|
1073
|
+
};
|
|
1074
|
+
return wrapped;
|
|
1075
|
+
}
|
|
1076
|
+
function createFreestyleClient(apiKey, base, signal, limit = MAX_RESPONSE_BYTES) {
|
|
1077
|
+
return new Freestyle({ apiKey, baseUrl: FREESTYLE_API, fetch: callFetch(base, signal, limit) });
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// src/adapter.ts
|
|
1081
|
+
var BASE_IMAGE = "freestyle/ubuntu";
|
|
1082
|
+
var MAX_EXEC_SECONDS = 86400;
|
|
1083
|
+
var MAX_OUTPUT_BYTES = 1048576;
|
|
1084
|
+
var MAX_STDIN_BYTES = 1048576;
|
|
1085
|
+
var MAX_FILE_BYTES = 8 * 1024 * 1024;
|
|
1086
|
+
var MAX_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
1087
|
+
var UBUNTU_SIZES = [
|
|
1088
|
+
{ image: "freestyle/ubuntu-sm", vcpus: 2, memoryMiB: 4096 },
|
|
1089
|
+
{ image: "freestyle/ubuntu", vcpus: 4, memoryMiB: 8192 },
|
|
1090
|
+
{ image: "freestyle/ubuntu-lg", vcpus: 8, memoryMiB: 16384 },
|
|
1091
|
+
{ image: "freestyle/ubuntu-xl", vcpus: 16, memoryMiB: 32768 },
|
|
1092
|
+
{ image: "freestyle/ubuntu-2xl", vcpus: 32, memoryMiB: 65536 }
|
|
1093
|
+
];
|
|
1094
|
+
var Image = z4.string().min(1).max(256).regex(/^[A-Za-z0-9][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9][A-Za-z0-9_.-]*)?$/, "Use a Freestyle snapshot id, your snapshot slug or a public owner/slug");
|
|
1095
|
+
var Lifetime = z4.number().int().min(60).max(MAX_TTL_SECONDS);
|
|
1096
|
+
var Configuration = z4.strictObject({
|
|
1097
|
+
network: z4.enum(["blocked", "internet"]).default("blocked"),
|
|
1098
|
+
image: Image.optional(),
|
|
1099
|
+
resources: ResourceMinimums.optional(),
|
|
1100
|
+
lifecycle: z4.strictObject({
|
|
1101
|
+
lifetimeSeconds: Lifetime.default(DEFAULT_LIFETIME_SECONDS),
|
|
1102
|
+
idleSeconds: Lifetime.optional()
|
|
1103
|
+
}).default({ lifetimeSeconds: DEFAULT_LIFETIME_SECONDS }),
|
|
1104
|
+
preview: z4.strictObject({
|
|
1105
|
+
access: z4.enum(["public", "protected"]).default("public"),
|
|
1106
|
+
ports: z4.array(PreviewPort).max(16).refine((ports) => new Set(ports).size === ports.length, "Preview ports must be unique").default([])
|
|
1107
|
+
}).default({ access: "public", ports: [] }),
|
|
1108
|
+
snapshots: z4.strictObject({
|
|
1109
|
+
preserve: PreserveSchema.optional(),
|
|
1110
|
+
retentionSeconds: z4.number().int().nonnegative().safe().optional()
|
|
1111
|
+
}).optional()
|
|
1112
|
+
}).superRefine((value, ctx) => {
|
|
1113
|
+
if (value.image !== undefined && value.resources !== undefined)
|
|
1114
|
+
ctx.addIssue({
|
|
1115
|
+
code: "custom",
|
|
1116
|
+
path: ["resources"],
|
|
1117
|
+
message: "Supply image or resources, not both; Freestyle snapshots keep the size they were built with"
|
|
1118
|
+
});
|
|
1119
|
+
});
|
|
1120
|
+
var Credentials = z4.strictObject({ apiKey: z4.string().min(1) });
|
|
1121
|
+
var Id = z4.string().min(1).max(128).regex(/^[A-Za-z0-9_-]+$/);
|
|
1122
|
+
var AttemptSlug = z4.string().regex(/^beachside-[0-9a-f]{24}$/);
|
|
1123
|
+
var CreateToken = z4.strictObject({
|
|
1124
|
+
slug: AttemptSlug,
|
|
1125
|
+
id: Id.optional(),
|
|
1126
|
+
env: z4.literal(true).optional()
|
|
1127
|
+
});
|
|
1128
|
+
var DestroyToken = z4.strictObject({ id: Id, acknowledged: z4.boolean() });
|
|
1129
|
+
var ApiErrorBody = z4.object({ code: z4.string().max(256), message: z4.string().max(4096) });
|
|
1130
|
+
var LifecycleToken = z4.strictObject({
|
|
1131
|
+
id: Id,
|
|
1132
|
+
since: z4.number().int().nonnegative().optional()
|
|
1133
|
+
});
|
|
1134
|
+
var SnapshotSlug = z4.string().regex(/^beachside-snapshot-[0-9a-f]{24}$/);
|
|
1135
|
+
var CaptureToken = z4.strictObject({
|
|
1136
|
+
slug: SnapshotSlug,
|
|
1137
|
+
source: Id,
|
|
1138
|
+
sourceState: z4.enum(["running", "suspended"]),
|
|
1139
|
+
id: Id.optional()
|
|
1140
|
+
});
|
|
1141
|
+
var SnapshotDeleteToken = z4.strictObject({ id: Id });
|
|
1142
|
+
var snapshotProfile = {
|
|
1143
|
+
id: "freestyle-vm",
|
|
1144
|
+
preserve: Preserve.FILESYSTEM_AND_MEMORY,
|
|
1145
|
+
sourceStates: ["running", "suspended"],
|
|
1146
|
+
interruption: "pause",
|
|
1147
|
+
sourceAfter: "unchanged",
|
|
1148
|
+
connections: "unknown",
|
|
1149
|
+
consistency: "unknown",
|
|
1150
|
+
restoreExecution: "resume",
|
|
1151
|
+
mountHandling: "none"
|
|
1152
|
+
};
|
|
1153
|
+
var snapshotSupport = {
|
|
1154
|
+
status: "supported",
|
|
1155
|
+
value: { profiles: [snapshotProfile], defaultProfileId: snapshotProfile.id }
|
|
1156
|
+
};
|
|
1157
|
+
var restoreSupport = {
|
|
1158
|
+
networkPolicies: ["blocked", "internet"],
|
|
1159
|
+
resources: false,
|
|
1160
|
+
mounts: false,
|
|
1161
|
+
independentLifecycle: true
|
|
1162
|
+
};
|
|
1163
|
+
function snapshotSlug(submissionId) {
|
|
1164
|
+
return `beachside-snapshot-${createHash("sha256").update(submissionId).digest("hex").slice(0, 24)}`;
|
|
1165
|
+
}
|
|
1166
|
+
var Labels = z4.record(z4.string().min(1).max(63), z4.string().max(63)).refine((labels) => Object.keys(labels).length <= 64, "At most 64 labels");
|
|
1167
|
+
function attemptSlug(submissionId) {
|
|
1168
|
+
return `beachside-${createHash("sha256").update(submissionId).digest("hex").slice(0, 24)}`;
|
|
1169
|
+
}
|
|
1170
|
+
function previewDomain(slug, port) {
|
|
1171
|
+
return `${slug}-${port}.style.dev`;
|
|
1172
|
+
}
|
|
1173
|
+
function state(native) {
|
|
1174
|
+
switch (native) {
|
|
1175
|
+
case "starting":
|
|
1176
|
+
return "creating";
|
|
1177
|
+
case "running":
|
|
1178
|
+
return "running";
|
|
1179
|
+
case "paused":
|
|
1180
|
+
return "suspended";
|
|
1181
|
+
case "stopped":
|
|
1182
|
+
return "stopped";
|
|
1183
|
+
default:
|
|
1184
|
+
return "unknown";
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
function vmSize(vm) {
|
|
1188
|
+
const vcpus = vm.resources?.cpu ?? 0;
|
|
1189
|
+
const memoryMiB = vm.resources?.memory ?? 0;
|
|
1190
|
+
return Number.isFinite(vcpus) && vcpus > 0 && Number.isSafeInteger(memoryMiB) && memoryMiB > 0 ? { status: "known", value: { vcpus, memoryMiB } } : { status: "unknown", reason: "Freestyle did not report the VM size" };
|
|
1191
|
+
}
|
|
1192
|
+
function expiry(vm) {
|
|
1193
|
+
if (vm.ttlSeconds === null || vm.ttlSeconds === undefined || vm.ttlSeconds < 0)
|
|
1194
|
+
return { status: "none" };
|
|
1195
|
+
const created = Date.parse(vm.createdAt);
|
|
1196
|
+
return Number.isNaN(created) ? { status: "unknown", reason: "Freestyle reported no usable creation time" } : {
|
|
1197
|
+
status: "known",
|
|
1198
|
+
at: new Date(created + vm.ttlSeconds * 1000).toISOString(),
|
|
1199
|
+
action: "destroy",
|
|
1200
|
+
scope: "sandbox"
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
var OWNER_READ = `if [ -f "$1" ] && [ ! -h "$1" ]; then beachside_o=$(stat -c %u:%g -- "$1") || exit 1; if command -v python3 >/dev/null 2>&1; then echo "$beachside_o"; else echo "$beachside_o nopy"; fi; fi`;
|
|
1204
|
+
var OWNER_HELPER = `import hashlib,os,stat,sys
|
|
1205
|
+
p=sys.argv[2]
|
|
1206
|
+
u,g,n,h,r=int(sys.argv[3]),int(sys.argv[4]),int(sys.argv[5]),sys.argv[6],int(sys.argv[7])
|
|
1207
|
+
fd=os.open(p,os.O_RDONLY|os.O_NOFOLLOW|os.O_NONBLOCK)
|
|
1208
|
+
try:
|
|
1209
|
+
st=os.fstat(fd)
|
|
1210
|
+
if not stat.S_ISREG(st.st_mode) or st.st_uid!=r or st.st_nlink!=1 or st.st_size!=n:print('skip');sys.exit(0)
|
|
1211
|
+
d=hashlib.sha256()
|
|
1212
|
+
while True:
|
|
1213
|
+
b=os.read(fd,1048576)
|
|
1214
|
+
if not b:break
|
|
1215
|
+
d.update(b)
|
|
1216
|
+
if d.hexdigest()!=h:print('skip');sys.exit(0)
|
|
1217
|
+
os.fchown(fd,u,g);print('ok')
|
|
1218
|
+
finally:os.close(fd)
|
|
1219
|
+
`;
|
|
1220
|
+
function readContext(signal, seconds = 30) {
|
|
1221
|
+
return { signal, deadline: Date.now() + seconds * 1000 };
|
|
1222
|
+
}
|
|
1223
|
+
function callSignal(ctx) {
|
|
1224
|
+
return AbortSignal.any([ctx.signal, AbortSignal.timeout(Math.max(1, ctx.deadline - Date.now()))]);
|
|
1225
|
+
}
|
|
1226
|
+
function delay(ms, signal) {
|
|
1227
|
+
return new Promise((resolve, reject) => {
|
|
1228
|
+
if (signal.aborted) {
|
|
1229
|
+
reject(signal.reason);
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
const timer = setTimeout(() => {
|
|
1233
|
+
signal.removeEventListener("abort", stop);
|
|
1234
|
+
resolve();
|
|
1235
|
+
}, ms);
|
|
1236
|
+
const stop = () => {
|
|
1237
|
+
clearTimeout(timer);
|
|
1238
|
+
reject(signal.reason);
|
|
1239
|
+
};
|
|
1240
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
function base64(bytes) {
|
|
1244
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
|
|
1245
|
+
}
|
|
1246
|
+
function createFreestyleAdapter(options = {}) {
|
|
1247
|
+
const pollMs = options.pollIntervalMs ?? 1000;
|
|
1248
|
+
const settleSeconds = options.settleSeconds ?? 120;
|
|
1249
|
+
return defineAdapter({
|
|
1250
|
+
name: "freestyle",
|
|
1251
|
+
displayName: "Freestyle",
|
|
1252
|
+
config: Configuration,
|
|
1253
|
+
credentials: Credentials,
|
|
1254
|
+
async connect({ config, credentials, host }) {
|
|
1255
|
+
if (config.preview.access !== "public")
|
|
1256
|
+
throw new AdapterError6("UNSUPPORTED", "Freestyle previews are public style.dev names; use preview.access public");
|
|
1257
|
+
if (config.snapshots?.preserve === Preserve.FILESYSTEM)
|
|
1258
|
+
throw new AdapterError6("UNSUPPORTED", "Freestyle snapshots always keep memory and disk; filesystem-only snapshots are not supported");
|
|
1259
|
+
const image = config.image ?? (config.resources ? smallestSize("Freestyle", UBUNTU_SIZES, config.resources).image : BASE_IMAGE);
|
|
1260
|
+
const base = options.fetch ?? globalThis.fetch;
|
|
1261
|
+
const secrets = [credentials.apiKey];
|
|
1262
|
+
const api = (signal, limit) => createFreestyleClient(credentials.apiKey, base, signal, limit);
|
|
1263
|
+
const failure = (error) => nativeFailure(error, secrets);
|
|
1264
|
+
const read = async (ctx, run) => {
|
|
1265
|
+
try {
|
|
1266
|
+
return await run(api(callSignal(ctx)));
|
|
1267
|
+
} catch (error) {
|
|
1268
|
+
throw failure(error).error;
|
|
1269
|
+
}
|
|
1270
|
+
};
|
|
1271
|
+
await read(readContext(host.signal), (native) => native.vms.list({ limit: 1 }));
|
|
1272
|
+
if (image.startsWith("sh-")) {
|
|
1273
|
+
let found;
|
|
1274
|
+
try {
|
|
1275
|
+
found = await api(callSignal(readContext(host.signal))).vms.snapshots.get(image);
|
|
1276
|
+
} catch (error) {
|
|
1277
|
+
const native = failure(error);
|
|
1278
|
+
if (native.status !== 404)
|
|
1279
|
+
throw native.error;
|
|
1280
|
+
found = null;
|
|
1281
|
+
}
|
|
1282
|
+
if (found?.id !== image)
|
|
1283
|
+
throw new AdapterError6("NOT_FOUND", "Freestyle image snapshot is unavailable to this API key");
|
|
1284
|
+
}
|
|
1285
|
+
const scope = {
|
|
1286
|
+
authority: {
|
|
1287
|
+
kind: "api-key",
|
|
1288
|
+
id: createHash("sha256").update("beachside:freestyle:api-key-scope:v1\x00").update(credentials.apiKey).digest("hex")
|
|
1289
|
+
},
|
|
1290
|
+
partition: { endpoint: FREESTYLE_API }
|
|
1291
|
+
};
|
|
1292
|
+
const checkedRef = (ref) => {
|
|
1293
|
+
assertResourceScope(ref, { provider: "freestyle", scope });
|
|
1294
|
+
if (ref.kind !== "sandbox" || ref.generation !== undefined || !Id.safeParse(ref.nativeId).success)
|
|
1295
|
+
throw new AdapterError6("CONFLICT", "Invalid Freestyle VM identity");
|
|
1296
|
+
};
|
|
1297
|
+
const find = async (id, ctx) => {
|
|
1298
|
+
Id.parse(id);
|
|
1299
|
+
try {
|
|
1300
|
+
const vm = await api(callSignal(ctx)).vms.get(id);
|
|
1301
|
+
if (vm.id !== id)
|
|
1302
|
+
throw new AdapterError6("CONFLICT", "Freestyle returned a different VM identity");
|
|
1303
|
+
return vm;
|
|
1304
|
+
} catch (error) {
|
|
1305
|
+
const native = failure(error);
|
|
1306
|
+
if (native.status === 404)
|
|
1307
|
+
return null;
|
|
1308
|
+
throw native.error;
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
const findBySlug = async (slug, ctx) => {
|
|
1312
|
+
const page2 = await read(ctx, (native) => native.vms.list({ slug, limit: 2 }));
|
|
1313
|
+
const matches = page2.vms.filter((vm) => vm.slug === slug);
|
|
1314
|
+
if (matches.length > 1)
|
|
1315
|
+
throw new AdapterError6("CONFLICT", "Freestyle returned several VMs for one slug");
|
|
1316
|
+
return matches[0] ?? null;
|
|
1317
|
+
};
|
|
1318
|
+
const requireVm = async (box, ctx) => {
|
|
1319
|
+
if (box.reference)
|
|
1320
|
+
checkedRef(box.reference);
|
|
1321
|
+
const vm = await find(box.id, ctx);
|
|
1322
|
+
if (!vm)
|
|
1323
|
+
throw new AdapterError6("NOT_FOUND", "Freestyle VM is unavailable in the verified account");
|
|
1324
|
+
return vm;
|
|
1325
|
+
};
|
|
1326
|
+
const requireRunning = async (box, ctx) => {
|
|
1327
|
+
const vm = await requireVm(box, ctx);
|
|
1328
|
+
if (vm.state !== "running")
|
|
1329
|
+
throw new AdapterError6("CONFLICT", `Freestyle VM is ${vm.state}; commands and files need a running VM`);
|
|
1330
|
+
return vm;
|
|
1331
|
+
};
|
|
1332
|
+
const observation = (vm, ref) => ({
|
|
1333
|
+
reference: ref,
|
|
1334
|
+
state: state(vm.state),
|
|
1335
|
+
nativeState: vm.state,
|
|
1336
|
+
observedAt: new Date().toISOString(),
|
|
1337
|
+
...unknownSandboxFacts(),
|
|
1338
|
+
expires: expiry(vm),
|
|
1339
|
+
idleStop: {
|
|
1340
|
+
status: "known",
|
|
1341
|
+
value: vm.idleTimeoutSeconds !== null && vm.idleTimeoutSeconds !== undefined && vm.idleTimeoutSeconds > 0 ? { seconds: vm.idleTimeoutSeconds, action: "suspend" } : null
|
|
1342
|
+
},
|
|
1343
|
+
resources: vmSize(vm)
|
|
1344
|
+
});
|
|
1345
|
+
const partialCreate = (id, identity) => ({
|
|
1346
|
+
kind: "create",
|
|
1347
|
+
status: "partial",
|
|
1348
|
+
sandbox: sandboxReference("freestyle", scope, id, {
|
|
1349
|
+
operation: identity.operationId,
|
|
1350
|
+
submission: identity.submissionId
|
|
1351
|
+
}),
|
|
1352
|
+
setup: { expiry: "acknowledged", readiness: "unconfirmed" }
|
|
1353
|
+
});
|
|
1354
|
+
const settle = async (signal, check) => {
|
|
1355
|
+
const until = Date.now() + settleSeconds * 1000;
|
|
1356
|
+
for (;; ) {
|
|
1357
|
+
const value = await check(readContext(signal));
|
|
1358
|
+
if (value !== undefined || Date.now() + pollMs > until)
|
|
1359
|
+
return value;
|
|
1360
|
+
await delay(pollMs, signal);
|
|
1361
|
+
}
|
|
1362
|
+
};
|
|
1363
|
+
const rejection = (ctx, error, reason) => {
|
|
1364
|
+
const native = failure(error);
|
|
1365
|
+
return refusedBeforeEffect(native) ? ctx.reject(native.error.code, native.error.message) : ctx.unknown(`${reason}; do not replay`);
|
|
1366
|
+
};
|
|
1367
|
+
const partialRestore = (id, identity) => ({
|
|
1368
|
+
kind: "snapshot_restore",
|
|
1369
|
+
status: "unknown",
|
|
1370
|
+
sandbox: sandboxReference("freestyle", scope, id, {
|
|
1371
|
+
operation: identity.operationId,
|
|
1372
|
+
submission: identity.submissionId
|
|
1373
|
+
}),
|
|
1374
|
+
mounts: []
|
|
1375
|
+
});
|
|
1376
|
+
const launched = (vm, token, identity, settle2, partial = partialCreate) => {
|
|
1377
|
+
if (!vm)
|
|
1378
|
+
return settle2.unknown(token.id ? "Freestyle VM disappeared before it was running; nothing remains to clean up" : "Freestyle create response was lost and no VM carries this attempt's slug; inspect inventory without replay");
|
|
1379
|
+
if (vm.slug !== token.slug || token.id !== undefined && vm.id !== token.id)
|
|
1380
|
+
return settle2.unknown("Freestyle VM identity differs from the attempt; do not replay");
|
|
1381
|
+
if (vm.state === "running")
|
|
1382
|
+
return {
|
|
1383
|
+
id: vm.id,
|
|
1384
|
+
reference: sandboxReference("freestyle", scope, vm.id, {
|
|
1385
|
+
operation: identity.operationId,
|
|
1386
|
+
submission: identity.submissionId
|
|
1387
|
+
}),
|
|
1388
|
+
state: "running"
|
|
1389
|
+
};
|
|
1390
|
+
if (vm.state === "starting")
|
|
1391
|
+
return settle2.pending({ ...token, id: vm.id }, { pollAfterMs: pollMs });
|
|
1392
|
+
return settle2.unknown(`Freestyle VM is ${vm.state} before it was running; inspect or destroy it`, partial(vm.id, identity));
|
|
1393
|
+
};
|
|
1394
|
+
const launch = async (input, ctx, partial = partialCreate) => {
|
|
1395
|
+
const slug = attemptSlug(ctx.submissionId);
|
|
1396
|
+
const request = {
|
|
1397
|
+
snapshotId: input.image.value,
|
|
1398
|
+
slug,
|
|
1399
|
+
ttlSeconds: input.lifetimeSeconds ?? config.lifecycle.lifetimeSeconds,
|
|
1400
|
+
firewall: firewall(input.networkPolicy)
|
|
1401
|
+
};
|
|
1402
|
+
if (config.lifecycle.idleSeconds !== undefined)
|
|
1403
|
+
request.idleTimeoutSeconds = config.lifecycle.idleSeconds;
|
|
1404
|
+
if (input.labels && Object.keys(input.labels).length)
|
|
1405
|
+
request.metadata = input.labels;
|
|
1406
|
+
const tls = previews(slug);
|
|
1407
|
+
if (tls)
|
|
1408
|
+
request.tls = tls;
|
|
1409
|
+
const env = input.env && Object.keys(input.env).length ? input.env : undefined;
|
|
1410
|
+
await ctx.checkpoint(env ? { slug, env: true } : { slug });
|
|
1411
|
+
if (env)
|
|
1412
|
+
launchEnv.set(slug, env);
|
|
1413
|
+
let created;
|
|
1414
|
+
try {
|
|
1415
|
+
created = (await api(ctx.signal).vms.create(request)).data;
|
|
1416
|
+
} catch (error) {
|
|
1417
|
+
if (refusedBeforeEffect(failure(error)))
|
|
1418
|
+
launchEnv.delete(slug);
|
|
1419
|
+
return rejection(ctx, error, "Freestyle create response was not confirmed; observe finds the VM by its slug");
|
|
1420
|
+
}
|
|
1421
|
+
const id = Id.safeParse(created.id);
|
|
1422
|
+
if (!id.success || created.slug !== slug)
|
|
1423
|
+
return ctx.unknown("Freestyle create acknowledgement does not match the attempt");
|
|
1424
|
+
const token = { slug, id: id.data };
|
|
1425
|
+
if (env)
|
|
1426
|
+
token.env = true;
|
|
1427
|
+
try {
|
|
1428
|
+
await ctx.checkpoint(token);
|
|
1429
|
+
} catch (error) {
|
|
1430
|
+
if (error instanceof AdapterCheckpointError)
|
|
1431
|
+
error.outcome = partial(id.data, ctx);
|
|
1432
|
+
throw error;
|
|
1433
|
+
}
|
|
1434
|
+
try {
|
|
1435
|
+
const result = await settle(ctx.signal, async (read2) => {
|
|
1436
|
+
const current = created.state === "running" ? created : await find(id.data, read2);
|
|
1437
|
+
return current?.state === "starting" ? undefined : { current };
|
|
1438
|
+
});
|
|
1439
|
+
if (result?.current?.state === "running" && env) {
|
|
1440
|
+
launchEnv.delete(slug);
|
|
1441
|
+
if (!await writeEnv(id.data, env, slug, ctx.signal))
|
|
1442
|
+
return ctx.unknown("Freestyle VM is running but its sandbox environment was not confirmed; destroy it", partial(id.data, ctx));
|
|
1443
|
+
}
|
|
1444
|
+
if (result)
|
|
1445
|
+
return launched(result.current, token, ctx, ctx, partial);
|
|
1446
|
+
} catch {}
|
|
1447
|
+
return ctx.pending(token, { pollAfterMs: pollMs });
|
|
1448
|
+
};
|
|
1449
|
+
const launchEnv = new Map;
|
|
1450
|
+
const writeEnv = (id, env, slug, signal) => api(callSignal(readContext(signal, 60))).vms.ref(id).fs.writeFile(ENV_FILE, envFile(env, slug), { mode: 384 }).then(() => true, () => false);
|
|
1451
|
+
const observeLaunch = async (token, attempt, ctx, partial) => {
|
|
1452
|
+
const vm = token.id ? await find(token.id, ctx) : await findBySlug(token.slug, ctx);
|
|
1453
|
+
if (vm?.state === "running" && token.env) {
|
|
1454
|
+
const env = launchEnv.get(token.slug);
|
|
1455
|
+
launchEnv.delete(token.slug);
|
|
1456
|
+
const written = env ? await writeEnv(vm.id, env, token.slug, ctx.signal) : await api(callSignal(ctx)).vms.ref(vm.id).fs.readTextFile(ENV_FILE).then((text) => text.startsWith(`${envMarker(token.slug)}
|
|
1457
|
+
`), () => false);
|
|
1458
|
+
if (!written)
|
|
1459
|
+
return ctx.unknown(env ? "Freestyle VM is running but its sandbox environment was not confirmed; destroy it" : "Freestyle VM is running but its sandbox environment was never written; destroy it", partial(vm.id, attempt));
|
|
1460
|
+
}
|
|
1461
|
+
if (vm?.state !== "starting")
|
|
1462
|
+
launchEnv.delete(token.slug);
|
|
1463
|
+
return launched(vm, token, attempt, ctx, partial);
|
|
1464
|
+
};
|
|
1465
|
+
const firewall = (policy) => policy === "internet" ? { rules: [{ action: "allow", source: {}, destination: { public: true } }] } : { rules: [] };
|
|
1466
|
+
const previews = (slug) => config.preview.ports.length ? {
|
|
1467
|
+
rules: config.preview.ports.map((port) => ({
|
|
1468
|
+
action: "allow",
|
|
1469
|
+
domain: previewDomain(slug, port),
|
|
1470
|
+
source: { public: true },
|
|
1471
|
+
destination: { port }
|
|
1472
|
+
}))
|
|
1473
|
+
} : undefined;
|
|
1474
|
+
const runCommand = async (input, ctx) => {
|
|
1475
|
+
const vm = api(callSignal(readContext(ctx.signal, input.deadlineSeconds + 60))).vms.ref(input.sandbox.id);
|
|
1476
|
+
const request = {
|
|
1477
|
+
command: wrapperScript({
|
|
1478
|
+
...input,
|
|
1479
|
+
stdin: input.stdin !== undefined,
|
|
1480
|
+
maxBytes: input.maxOutputBytes
|
|
1481
|
+
}),
|
|
1482
|
+
timeoutMs: Math.max(1, Math.ceil(input.deadlineSeconds)) * 1000
|
|
1483
|
+
};
|
|
1484
|
+
if (input.stdin !== undefined)
|
|
1485
|
+
request.stdin = base64(input.stdin);
|
|
1486
|
+
let response;
|
|
1487
|
+
try {
|
|
1488
|
+
response = await vm.exec(request);
|
|
1489
|
+
} catch (error) {
|
|
1490
|
+
return rejection(ctx, error, "Freestyle command response was not confirmed");
|
|
1491
|
+
}
|
|
1492
|
+
if (response.statusCode === null || response.statusCode === undefined)
|
|
1493
|
+
return ctx.unknown(`Freestyle stopped the command at its ${input.deadlineSeconds}s deadline; output is unavailable`);
|
|
1494
|
+
const record = parseWrapper(response.stdout ?? "");
|
|
1495
|
+
if (!record)
|
|
1496
|
+
return ctx.unknown("Freestyle ran the command but its result record could not be decoded");
|
|
1497
|
+
if ("stdout" in record)
|
|
1498
|
+
return record;
|
|
1499
|
+
const files2 = api(callSignal(readContext(ctx.signal, 120)), input.maxOutputBytes + 1024).vms.ref(input.sandbox.id).fs;
|
|
1500
|
+
const download = (stream) => files2.readFile(`${record.directory}/${stream}`).catch(() => null);
|
|
1501
|
+
const stdout = await download("stdout");
|
|
1502
|
+
const stderr = await download("stderr");
|
|
1503
|
+
if (!stdout || !stderr)
|
|
1504
|
+
return {
|
|
1505
|
+
exitCode: record.exitCode,
|
|
1506
|
+
stdout: stdout ?? new Uint8Array,
|
|
1507
|
+
stderr: stderr ?? new Uint8Array,
|
|
1508
|
+
truncated: true
|
|
1509
|
+
};
|
|
1510
|
+
const assembled = filesResult(record, stdout, stderr, input.maxOutputBytes);
|
|
1511
|
+
if (!assembled)
|
|
1512
|
+
return ctx.unknown("Freestyle command output is longer than its recorded length");
|
|
1513
|
+
if (assembled.complete)
|
|
1514
|
+
await api(callSignal(readContext(ctx.signal, 15))).vms.ref(input.sandbox.id).exec({ command: `rm -rf -- ${quote(record.directory)}`, timeoutMs: 1e4 }).catch(() => {
|
|
1515
|
+
return;
|
|
1516
|
+
});
|
|
1517
|
+
return assembled.result;
|
|
1518
|
+
};
|
|
1519
|
+
const putFile = async (vmId, path, bytes, signal) => {
|
|
1520
|
+
const vm = api(callSignal(readContext(signal, 120))).vms.ref(vmId);
|
|
1521
|
+
const asRoot = async (command) => {
|
|
1522
|
+
const result = await vm.exec({ command, linuxUser: "root", timeoutMs: 1e4 });
|
|
1523
|
+
if (result.statusCode !== 0)
|
|
1524
|
+
throw new AdapterError6("UNAVAILABLE", "Freestyle file owner command failed");
|
|
1525
|
+
return (result.stdout ?? "").trim();
|
|
1526
|
+
};
|
|
1527
|
+
let owner;
|
|
1528
|
+
try {
|
|
1529
|
+
owner = await asRoot(`sh -c ${quote(OWNER_READ)} beachside-owner ${quote(path)}`);
|
|
1530
|
+
} catch (error) {
|
|
1531
|
+
throw error instanceof AdapterError6 ? error : failure(error).error;
|
|
1532
|
+
}
|
|
1533
|
+
const parsed = /^(\d{1,10}):(\d{1,10})( nopy)?$/.exec(owner);
|
|
1534
|
+
if (owner && !parsed)
|
|
1535
|
+
throw new AdapterError6("UNAVAILABLE", "Freestyle file owner is malformed");
|
|
1536
|
+
const keepOwner = parsed && !(parsed[1] === "0" && parsed[2] === "0");
|
|
1537
|
+
if (keepOwner && parsed[3])
|
|
1538
|
+
throw new AdapterError6("UNSUPPORTED", "Freestyle needs Python 3 in the image to replace a file another user owns");
|
|
1539
|
+
await vm.fs.writeFile(path, bytes);
|
|
1540
|
+
if (!keepOwner)
|
|
1541
|
+
return true;
|
|
1542
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
1543
|
+
return asRoot(`python3 -I -c ${quote(OWNER_HELPER)} ${[
|
|
1544
|
+
"restore",
|
|
1545
|
+
path,
|
|
1546
|
+
parsed[1] ?? "",
|
|
1547
|
+
parsed[2] ?? "",
|
|
1548
|
+
String(bytes.byteLength),
|
|
1549
|
+
digest,
|
|
1550
|
+
"0"
|
|
1551
|
+
].map(quote).join(" ")}`).then((result) => result === "ok", () => false);
|
|
1552
|
+
};
|
|
1553
|
+
const nativeEntries = async (vmId, path, ctx) => {
|
|
1554
|
+
const entries = await read(ctx, (native) => native.vms.ref(vmId).fs.readDir(path));
|
|
1555
|
+
if (entries.length > MAX_DIRECTORY_ENTRIES)
|
|
1556
|
+
throw new AdapterError6("CAPACITY", "Freestyle directory exceeds the listing bound");
|
|
1557
|
+
return entries.map((entry) => ({
|
|
1558
|
+
name: entry.name,
|
|
1559
|
+
type: entry.kind === "file" || entry.kind === "directory" || entry.kind === "symlink" ? entry.kind : "unknown"
|
|
1560
|
+
}));
|
|
1561
|
+
};
|
|
1562
|
+
const nativeStat = async (vmId, path, ctx) => {
|
|
1563
|
+
const native = await read(ctx, (client) => client.vms.ref(vmId).fs.stat(path));
|
|
1564
|
+
if (!/^0?[0-7]{1,4}$/.test(native.permissions) || !Number.isSafeInteger(native.size) || native.size < 0)
|
|
1565
|
+
throw new AdapterError6("UNAVAILABLE", "Freestyle metadata response is invalid");
|
|
1566
|
+
const stat = {
|
|
1567
|
+
type: native.isSymlink ? "symlink" : native.isFile ? "file" : native.isDirectory ? "directory" : "unknown",
|
|
1568
|
+
sizeBytes: native.size,
|
|
1569
|
+
mode: Number.parseInt(native.permissions, 8) & 511
|
|
1570
|
+
};
|
|
1571
|
+
const modified = /^\d{1,12}(\.\d+)?$/.test(native.modified) ? Number(native.modified) * 1000 : Date.parse(native.modified);
|
|
1572
|
+
if (native.modified && !Number.isFinite(modified))
|
|
1573
|
+
throw new AdapterError6("UNAVAILABLE", "Freestyle metadata response is invalid");
|
|
1574
|
+
if (native.modified)
|
|
1575
|
+
stat.modifiedAt = new Date(modified).toISOString();
|
|
1576
|
+
return stat;
|
|
1577
|
+
};
|
|
1578
|
+
const guest = (vmId) => async (command, ctx, stdin) => {
|
|
1579
|
+
const request = {
|
|
1580
|
+
command,
|
|
1581
|
+
timeoutMs: Math.max(1000, Math.min(300000, ctx.deadline - Date.now()))
|
|
1582
|
+
};
|
|
1583
|
+
if (stdin)
|
|
1584
|
+
request.stdin = base64(stdin);
|
|
1585
|
+
let response;
|
|
1586
|
+
try {
|
|
1587
|
+
response = await api(callSignal(ctx)).vms.ref(vmId).exec(request);
|
|
1588
|
+
} catch (error) {
|
|
1589
|
+
throw failure(error).error;
|
|
1590
|
+
}
|
|
1591
|
+
if (response.statusCode !== 0)
|
|
1592
|
+
throw new AdapterError6(response.statusCode === null || response.statusCode === undefined ? "TIMEOUT" : "UNAVAILABLE", "Freestyle guest control command failed");
|
|
1593
|
+
return response.stdout ?? "";
|
|
1594
|
+
};
|
|
1595
|
+
const held = new Map;
|
|
1596
|
+
const release = (vmId) => {
|
|
1597
|
+
if (!held.get(vmId)?.inFlight)
|
|
1598
|
+
held.delete(vmId);
|
|
1599
|
+
};
|
|
1600
|
+
const holding = async (vmId) => {
|
|
1601
|
+
const hold = held.get(vmId);
|
|
1602
|
+
if (!hold)
|
|
1603
|
+
return false;
|
|
1604
|
+
if (hold.inFlight || Date.now() - hold.checkedAt < 2000)
|
|
1605
|
+
return true;
|
|
1606
|
+
hold.checkedAt = Date.now();
|
|
1607
|
+
const vm = await find(vmId, readContext(AbortSignal.timeout(1e4), 10)).catch(() => {
|
|
1608
|
+
return;
|
|
1609
|
+
});
|
|
1610
|
+
if (vm === null) {
|
|
1611
|
+
held.delete(vmId);
|
|
1612
|
+
return false;
|
|
1613
|
+
}
|
|
1614
|
+
if (vm?.state !== "running")
|
|
1615
|
+
return true;
|
|
1616
|
+
release(vmId);
|
|
1617
|
+
return held.has(vmId);
|
|
1618
|
+
};
|
|
1619
|
+
const jobOptions = (vmId) => ({
|
|
1620
|
+
held: () => holding(vmId),
|
|
1621
|
+
run: guest(vmId),
|
|
1622
|
+
pollMs: options.processPollMs ?? 250,
|
|
1623
|
+
requestSeconds: 60
|
|
1624
|
+
});
|
|
1625
|
+
const files = (vmId) => filesystem(guest(vmId), async (path, offset, length, ctx) => {
|
|
1626
|
+
const route = `/v5/vms/${encodeURIComponent(vmId)}/fs/read?${new URLSearchParams({ path })}`;
|
|
1627
|
+
let response;
|
|
1628
|
+
try {
|
|
1629
|
+
response = await api(callSignal(ctx), length + 1024).fetch(route, {
|
|
1630
|
+
method: "GET",
|
|
1631
|
+
headers: { range: `bytes=${offset}-${offset + length - 1}` }
|
|
1632
|
+
});
|
|
1633
|
+
} catch (error) {
|
|
1634
|
+
throw failure(error).error;
|
|
1635
|
+
}
|
|
1636
|
+
if (!response.ok && response.status !== 416) {
|
|
1637
|
+
const parsed = ApiErrorBody.safeParse(await response.json().catch(() => {
|
|
1638
|
+
return;
|
|
1639
|
+
}));
|
|
1640
|
+
const body = parsed.success ? parsed.data : { code: "UNKNOWN_ERROR", message: `HTTP ${response.status}` };
|
|
1641
|
+
throw failure(new FreestyleApiError2(response.status, body, route)).error;
|
|
1642
|
+
}
|
|
1643
|
+
const result = await rangeResult(response, offset, length);
|
|
1644
|
+
if (!result)
|
|
1645
|
+
throw new AdapterError6("UNAVAILABLE", "Freestyle file range is malformed");
|
|
1646
|
+
return result;
|
|
1647
|
+
});
|
|
1648
|
+
const fileRejection = (ctx, error) => {
|
|
1649
|
+
if (error instanceof AdapterFilesystemError2 && error.details.effect !== "none")
|
|
1650
|
+
return ctx.unknown("Freestyle filesystem operation may have changed the requested path; do not replay");
|
|
1651
|
+
if (error instanceof AdapterError6)
|
|
1652
|
+
return ctx.reject(error.code, error.message);
|
|
1653
|
+
return ctx.unknown("Freestyle filesystem response was not confirmed; do not replay");
|
|
1654
|
+
};
|
|
1655
|
+
const pty = options.pty ?? {
|
|
1656
|
+
open: (vmId, request) => api(host.signal).vms.ref(vmId).pty.open(request),
|
|
1657
|
+
attach: (vmId, request) => api(host.signal).vms.ref(vmId).pty.attach(request),
|
|
1658
|
+
list: async (vmId, ctx) => (await read(ctx, (native) => native.vms.ref(vmId).pty.list())).sessions
|
|
1659
|
+
};
|
|
1660
|
+
const listedReference = (vm) => Id.safeParse(vm.id).success ? {
|
|
1661
|
+
version: 1,
|
|
1662
|
+
kind: "sandbox",
|
|
1663
|
+
provider: "freestyle",
|
|
1664
|
+
scope,
|
|
1665
|
+
nativeId: vm.id,
|
|
1666
|
+
ownership: "unknown"
|
|
1667
|
+
} : null;
|
|
1668
|
+
const page = async (input, ctx) => {
|
|
1669
|
+
const offset = input.cursor === undefined ? 0 : Number(input.cursor);
|
|
1670
|
+
if (!Number.isSafeInteger(offset) || offset < 0)
|
|
1671
|
+
throw new AdapterError6("INVALID_ARGUMENT", "Invalid Freestyle VM cursor");
|
|
1672
|
+
const limit = Math.min(100, input.limit);
|
|
1673
|
+
const listed = await read(ctx, (native) => native.vms.list({ limit, offset }));
|
|
1674
|
+
const next = offset + listed.vms.length;
|
|
1675
|
+
return {
|
|
1676
|
+
vms: listed.vms.filter((vm) => Id.safeParse(vm.id).success),
|
|
1677
|
+
nextCursor: listed.vms.length === limit && next < listed.totalCount ? String(next) : undefined
|
|
1678
|
+
};
|
|
1679
|
+
};
|
|
1680
|
+
const snapshotRef = (id, ownership = "unknown") => ({
|
|
1681
|
+
version: 1,
|
|
1682
|
+
kind: "snapshot",
|
|
1683
|
+
provider: "freestyle",
|
|
1684
|
+
scope,
|
|
1685
|
+
nativeId: id,
|
|
1686
|
+
ownership
|
|
1687
|
+
});
|
|
1688
|
+
const checkedSnapshot = (ref) => {
|
|
1689
|
+
assertResourceScope(ref, { provider: "freestyle", scope });
|
|
1690
|
+
if (ref.kind !== "snapshot" || ref.generation !== undefined || !Id.safeParse(ref.nativeId).success)
|
|
1691
|
+
throw new AdapterError6("CONFLICT", "Invalid Freestyle snapshot identity");
|
|
1692
|
+
return ref.nativeId;
|
|
1693
|
+
};
|
|
1694
|
+
const findSnapshot = async (id, ctx) => {
|
|
1695
|
+
try {
|
|
1696
|
+
const snapshot = await api(callSignal(ctx)).vms.snapshots.get(id);
|
|
1697
|
+
if (snapshot.id !== id)
|
|
1698
|
+
throw new AdapterError6("CONFLICT", "Freestyle returned a different snapshot identity");
|
|
1699
|
+
return snapshot;
|
|
1700
|
+
} catch (error) {
|
|
1701
|
+
const native = failure(error);
|
|
1702
|
+
if (native.status === 404)
|
|
1703
|
+
return null;
|
|
1704
|
+
throw native.error;
|
|
1705
|
+
}
|
|
1706
|
+
};
|
|
1707
|
+
const requireSnapshot = async (ref, ctx) => {
|
|
1708
|
+
const snapshot = await findSnapshot(checkedSnapshot(ref), ctx);
|
|
1709
|
+
if (!snapshot)
|
|
1710
|
+
throw new AdapterError6("NOT_FOUND", "Freestyle snapshot is unavailable");
|
|
1711
|
+
return snapshot;
|
|
1712
|
+
};
|
|
1713
|
+
const findSnapshotBySlug = async (slug, source, ctx) => {
|
|
1714
|
+
const matches = [];
|
|
1715
|
+
for (let offset = 0;; ) {
|
|
1716
|
+
const listed = await read(ctx, (native) => native.vms.snapshots.list({ sourceVmId: source, limit: 100, offset }));
|
|
1717
|
+
matches.push(...listed.snapshots.filter((snapshot) => snapshot.slug === slug));
|
|
1718
|
+
offset += listed.snapshots.length;
|
|
1719
|
+
if (!listed.snapshots.length || offset >= listed.totalCount)
|
|
1720
|
+
break;
|
|
1721
|
+
}
|
|
1722
|
+
if (matches.length > 1)
|
|
1723
|
+
throw new AdapterError6("CONFLICT", "Freestyle returned several snapshots for one slug");
|
|
1724
|
+
return matches[0] ?? null;
|
|
1725
|
+
};
|
|
1726
|
+
const snapshotInfo = (snapshot, ref) => ({
|
|
1727
|
+
reference: ref,
|
|
1728
|
+
preserve: Preserve.FILESYSTEM_AND_MEMORY,
|
|
1729
|
+
restoreExecution: "resume",
|
|
1730
|
+
consistency: "unknown",
|
|
1731
|
+
source: snapshot.sourceVmId && Id.safeParse(snapshot.sourceVmId).success ? { id: snapshot.sourceVmId, class: "freestyle-vm" } : null,
|
|
1732
|
+
state: "ready",
|
|
1733
|
+
createdAt: listedTime(snapshot.createdAt),
|
|
1734
|
+
expiration: "unknown",
|
|
1735
|
+
excludedPaths: null,
|
|
1736
|
+
mounts: [],
|
|
1737
|
+
mountHandling: "none",
|
|
1738
|
+
restore: restoreSupport,
|
|
1739
|
+
dependencies: [],
|
|
1740
|
+
nativeDependencies: null
|
|
1741
|
+
});
|
|
1742
|
+
const imageInfo = (snapshot) => ({
|
|
1743
|
+
reference: {
|
|
1744
|
+
version: 1,
|
|
1745
|
+
kind: "image",
|
|
1746
|
+
provider: "freestyle",
|
|
1747
|
+
scope,
|
|
1748
|
+
nativeId: snapshot.id,
|
|
1749
|
+
ownership: "unknown"
|
|
1750
|
+
},
|
|
1751
|
+
state: "ready",
|
|
1752
|
+
createdAt: listedTime(snapshot.createdAt),
|
|
1753
|
+
source: null,
|
|
1754
|
+
visibility: snapshot.public ? "public" : "private",
|
|
1755
|
+
nativeDependencies: null
|
|
1756
|
+
});
|
|
1757
|
+
const snapshotPage = async (input, ctx) => {
|
|
1758
|
+
const offset = input.cursor === undefined ? 0 : Number(input.cursor);
|
|
1759
|
+
if (!Number.isSafeInteger(offset) || offset < 0)
|
|
1760
|
+
throw new AdapterError6("INVALID_ARGUMENT", "Invalid Freestyle snapshot cursor");
|
|
1761
|
+
const limit = Math.min(100, input.limit);
|
|
1762
|
+
const listed = await read(ctx, (native) => native.vms.snapshots.list({ limit, offset }));
|
|
1763
|
+
const next = offset + listed.snapshots.length;
|
|
1764
|
+
return {
|
|
1765
|
+
snapshots: listed.snapshots.filter((snapshot) => Id.safeParse(snapshot.id).success),
|
|
1766
|
+
nextCursor: listed.snapshots.length === limit && next < listed.totalCount ? String(next) : undefined
|
|
1767
|
+
};
|
|
1768
|
+
};
|
|
1769
|
+
const captureResult = (token, snapshot, settle2) => {
|
|
1770
|
+
if (!snapshot)
|
|
1771
|
+
return settle2.unknown("Freestyle snapshot response was lost and no snapshot carries this attempt's slug; list snapshots without replay");
|
|
1772
|
+
if (snapshot.slug !== token.slug || snapshot.sourceVmId !== token.source || token.id !== undefined && snapshot.id !== token.id || !Id.safeParse(snapshot.id).success)
|
|
1773
|
+
return settle2.unknown("Freestyle snapshot identity differs from the attempt; do not replay");
|
|
1774
|
+
const ref = snapshotRef(snapshot.id, "verified-created");
|
|
1775
|
+
return {
|
|
1776
|
+
snapshot: snapshotInfo(snapshot, ref),
|
|
1777
|
+
capture: {
|
|
1778
|
+
preserve: snapshotProfile.preserve,
|
|
1779
|
+
interruption: snapshotProfile.interruption,
|
|
1780
|
+
restoreExecution: snapshotProfile.restoreExecution
|
|
1781
|
+
},
|
|
1782
|
+
source: { state: token.sourceState, connections: snapshotProfile.connections },
|
|
1783
|
+
retainedResources: [ref]
|
|
1784
|
+
};
|
|
1785
|
+
};
|
|
1786
|
+
const session = {
|
|
1787
|
+
scope,
|
|
1788
|
+
defaultImage: { kind: "prepared", value: image },
|
|
1789
|
+
defaultNetworkPolicy: config.network,
|
|
1790
|
+
supports: {
|
|
1791
|
+
images: ["prepared"],
|
|
1792
|
+
network: ["blocked", "internet"],
|
|
1793
|
+
exec: { commands: ["argv", "shell"], maxOutputBytes: MAX_OUTPUT_BYTES },
|
|
1794
|
+
fileWrite: { overwrite: true, noClobber: true },
|
|
1795
|
+
createEnv: true,
|
|
1796
|
+
lifetime: { minSeconds: 60, maxSeconds: MAX_TTL_SECONDS }
|
|
1797
|
+
},
|
|
1798
|
+
create: {
|
|
1799
|
+
recovery: { version: 1, token: CreateToken },
|
|
1800
|
+
async prepare(input) {
|
|
1801
|
+
if (input.image.kind !== "prepared")
|
|
1802
|
+
throw new AdapterError6("UNSUPPORTED", "Freestyle boots snapshots only; OCI images are unavailable");
|
|
1803
|
+
if (!Image.safeParse(input.image.value).success)
|
|
1804
|
+
throw new AdapterError6("INVALID_ARGUMENT", "Freestyle images are a snapshot id, your snapshot slug or a public owner/slug");
|
|
1805
|
+
if (input.networkPolicy !== "blocked" && input.networkPolicy !== "internet")
|
|
1806
|
+
throw new AdapterError6("UNSUPPORTED", "Freestyle network policies are blocked and internet; allowlists are unavailable");
|
|
1807
|
+
if (config.resources && input.image.value !== image) {
|
|
1808
|
+
const size = UBUNTU_SIZES.find((candidate) => candidate.image === input.image.value);
|
|
1809
|
+
if (!size || config.resources.vcpus !== undefined && size.vcpus < config.resources.vcpus || config.resources.memoryMiB !== undefined && size.memoryMiB < config.resources.memoryMiB)
|
|
1810
|
+
throw new AdapterError6("UNSUPPORTED", "This image may not meet the configured resources; Freestyle snapshots keep the size they were built with");
|
|
1811
|
+
}
|
|
1812
|
+
if (input.region !== undefined || input.mounts?.length || input.requirements)
|
|
1813
|
+
throw new AdapterError6("UNSUPPORTED", "Freestyle has no region selection, volume mounts or snapshot requirements");
|
|
1814
|
+
if (input.labels && !Labels.safeParse(input.labels).success)
|
|
1815
|
+
throw new AdapterError6("INVALID_ARGUMENT", "Freestyle labels allow at most 64 entries with keys and values up to 63 characters");
|
|
1816
|
+
checkEnv(input.env);
|
|
1817
|
+
return input;
|
|
1818
|
+
},
|
|
1819
|
+
submit: (input, ctx) => launch(input, ctx),
|
|
1820
|
+
async observe(attempt, ctx) {
|
|
1821
|
+
const token = CreateToken.safeParse(attempt.token);
|
|
1822
|
+
if (!token.success || token.data.slug !== attemptSlug(attempt.submissionId))
|
|
1823
|
+
return ctx.unknown("Freestyle create identity is unavailable; do not replay");
|
|
1824
|
+
return observeLaunch(token.data, attempt, ctx, partialCreate);
|
|
1825
|
+
}
|
|
1826
|
+
},
|
|
1827
|
+
async inspect(box, ctx) {
|
|
1828
|
+
if (box.reference)
|
|
1829
|
+
checkedRef(box.reference);
|
|
1830
|
+
const vm = await find(box.id, ctx);
|
|
1831
|
+
return vm ? { id: vm.id, ...observation(vm, box.reference ?? null) } : null;
|
|
1832
|
+
},
|
|
1833
|
+
async reopen(ref, ctx) {
|
|
1834
|
+
checkedRef(ref);
|
|
1835
|
+
return observation(await requireVm({ id: ref.nativeId, reference: ref }, ctx), ref);
|
|
1836
|
+
},
|
|
1837
|
+
async inventory(input, ctx) {
|
|
1838
|
+
const listed = await page(input, ctx);
|
|
1839
|
+
return {
|
|
1840
|
+
items: listed.vms.map((vm) => ({
|
|
1841
|
+
id: vm.id,
|
|
1842
|
+
state: vm.state === "running" ? "running" : "unknown"
|
|
1843
|
+
})),
|
|
1844
|
+
nextCursor: listed.nextCursor
|
|
1845
|
+
};
|
|
1846
|
+
},
|
|
1847
|
+
async sandboxList(input, ctx) {
|
|
1848
|
+
const listed = await page(input, ctx);
|
|
1849
|
+
return {
|
|
1850
|
+
coverage: "provider-scope",
|
|
1851
|
+
items: listed.vms.map((vm) => ({
|
|
1852
|
+
id: vm.id,
|
|
1853
|
+
state: state(vm.state),
|
|
1854
|
+
reference: listedReference(vm),
|
|
1855
|
+
labels: listedLabels(vm.metadata, () => false),
|
|
1856
|
+
createdAt: listedTime(vm.createdAt)
|
|
1857
|
+
})),
|
|
1858
|
+
nextCursor: listed.nextCursor
|
|
1859
|
+
};
|
|
1860
|
+
},
|
|
1861
|
+
destroy: {
|
|
1862
|
+
recovery: { version: 1, token: DestroyToken },
|
|
1863
|
+
async prepare(input, ctx) {
|
|
1864
|
+
await requireVm(input, ctx);
|
|
1865
|
+
return input;
|
|
1866
|
+
},
|
|
1867
|
+
async submit(input, ctx) {
|
|
1868
|
+
const token = { id: Id.parse(input.id), acknowledged: false };
|
|
1869
|
+
await ctx.checkpoint(token);
|
|
1870
|
+
try {
|
|
1871
|
+
await api(callSignal(readContext(ctx.signal))).vms.delete(token.id);
|
|
1872
|
+
} catch (error) {
|
|
1873
|
+
const native = failure(error);
|
|
1874
|
+
if (refusedBeforeEffect(native))
|
|
1875
|
+
return ctx.reject(native.error.code, native.error.message);
|
|
1876
|
+
return ctx.pending(token, { pollAfterMs: pollMs });
|
|
1877
|
+
}
|
|
1878
|
+
const acknowledged = { ...token, acknowledged: true };
|
|
1879
|
+
await ctx.checkpoint(acknowledged);
|
|
1880
|
+
try {
|
|
1881
|
+
const gone = await settle(ctx.signal, async (read2) => await find(token.id, read2) ? undefined : true);
|
|
1882
|
+
if (gone)
|
|
1883
|
+
return { computeStopped: true, retainedResources: [] };
|
|
1884
|
+
} catch {}
|
|
1885
|
+
return ctx.pending(acknowledged, { pollAfterMs: pollMs });
|
|
1886
|
+
},
|
|
1887
|
+
async observe(attempt, ctx) {
|
|
1888
|
+
const token = DestroyToken.safeParse(attempt.token);
|
|
1889
|
+
if (!token.success || token.data.id !== attempt.sandbox?.id)
|
|
1890
|
+
return ctx.unknown("Freestyle deletion identity is unavailable; do not replay");
|
|
1891
|
+
if (!await find(token.data.id, ctx))
|
|
1892
|
+
return { computeStopped: true, retainedResources: [] };
|
|
1893
|
+
return token.data.acknowledged ? ctx.pending(token.data, { pollAfterMs: pollMs }) : ctx.unknown("Freestyle delete response was lost and the VM still exists; destroy it again explicitly");
|
|
1894
|
+
}
|
|
1895
|
+
},
|
|
1896
|
+
processes: {
|
|
1897
|
+
supports: {
|
|
1898
|
+
sustainedOutput: true,
|
|
1899
|
+
binaryOutput: true,
|
|
1900
|
+
signals: ["SIGTERM", "SIGKILL"],
|
|
1901
|
+
terminal: true,
|
|
1902
|
+
reopen: true,
|
|
1903
|
+
stdin: "bytes",
|
|
1904
|
+
status: true,
|
|
1905
|
+
execCapture: "bytes"
|
|
1906
|
+
},
|
|
1907
|
+
async start(input, ctx) {
|
|
1908
|
+
if (input.capture && (!Number.isSafeInteger(input.capture.maxBytes) || input.capture.maxBytes < 1 || input.capture.maxBytes > MAX_OUTPUT_BYTES))
|
|
1909
|
+
throw new AdapterError6("CAPACITY", "Freestyle process capture exceeds its byte bound");
|
|
1910
|
+
commandLine(input);
|
|
1911
|
+
await requireRunning(input.sandbox, ctx);
|
|
1912
|
+
const format = input.output?.format ?? "text";
|
|
1913
|
+
if (input.terminal) {
|
|
1914
|
+
if (input.stdin === "closed")
|
|
1915
|
+
throw new AdapterError6("UNSUPPORTED", "Freestyle terminals always take input");
|
|
1916
|
+
return openTerminal(pty, {
|
|
1917
|
+
vmId: input.sandbox.id,
|
|
1918
|
+
command: commandLine({ ...input, env: { TERM: "xterm-256color", ...input.env } }),
|
|
1919
|
+
terminal: input.terminal,
|
|
1920
|
+
reference: {
|
|
1921
|
+
version: 1,
|
|
1922
|
+
kind: "freestyle-pty",
|
|
1923
|
+
sandbox: input.sandbox.id,
|
|
1924
|
+
session: terminalSlug()
|
|
1925
|
+
},
|
|
1926
|
+
format,
|
|
1927
|
+
capture: input.capture
|
|
1928
|
+
}, ctx);
|
|
1929
|
+
}
|
|
1930
|
+
const directory = parseLauncher(await guest(input.sandbox.id)(launcherScript(input), ctx));
|
|
1931
|
+
if (!directory)
|
|
1932
|
+
throw new AdapterError6("UNAVAILABLE", "Freestyle process start was not acknowledged");
|
|
1933
|
+
return observeJob(jobOptions(input.sandbox.id), {
|
|
1934
|
+
version: 1,
|
|
1935
|
+
kind: "freestyle-job",
|
|
1936
|
+
sandbox: input.sandbox.id,
|
|
1937
|
+
directory,
|
|
1938
|
+
stdin: input.stdin ?? "closed"
|
|
1939
|
+
}, { format, capture: input.capture, fromEnd: false }, ctx);
|
|
1940
|
+
},
|
|
1941
|
+
async reopen(input, ctx) {
|
|
1942
|
+
await requireRunning(input.sandbox, ctx);
|
|
1943
|
+
release(input.sandbox.id);
|
|
1944
|
+
const job = ProcessReference.safeParse(input.reference);
|
|
1945
|
+
const terminal = TerminalReference.safeParse(input.reference);
|
|
1946
|
+
if (job.success && job.data.sandbox === input.sandbox.id && input.profile === "process" && job.data.stdin === input.stdin)
|
|
1947
|
+
return observeJob(jobOptions(input.sandbox.id), job.data, { format: input.output.format, fromEnd: true }, ctx);
|
|
1948
|
+
if (terminal.success && terminal.data.sandbox === input.sandbox.id && input.profile === "terminal")
|
|
1949
|
+
return openTerminal(pty, {
|
|
1950
|
+
vmId: input.sandbox.id,
|
|
1951
|
+
reference: terminal.data,
|
|
1952
|
+
format: input.output.format
|
|
1953
|
+
}, ctx);
|
|
1954
|
+
throw new AdapterError6("CONFLICT", "Process reference does not belong to this Freestyle VM and profile");
|
|
1955
|
+
}
|
|
1956
|
+
},
|
|
1957
|
+
exec: {
|
|
1958
|
+
finiteStdin: "bytes",
|
|
1959
|
+
async prepare(input, ctx) {
|
|
1960
|
+
if (input.deadlineSeconds > MAX_EXEC_SECONDS)
|
|
1961
|
+
throw new AdapterError6("INVALID_ARGUMENT", `Freestyle commands run for at most ${MAX_EXEC_SECONDS} seconds`);
|
|
1962
|
+
if (input.stdin !== undefined && input.stdin.byteLength > MAX_STDIN_BYTES)
|
|
1963
|
+
throw new AdapterError6("CAPACITY", `Freestyle command stdin is limited to ${MAX_STDIN_BYTES} bytes`);
|
|
1964
|
+
wrapperScript({ ...input, stdin: false, maxBytes: input.maxOutputBytes });
|
|
1965
|
+
await requireRunning(input.sandbox, ctx);
|
|
1966
|
+
return input;
|
|
1967
|
+
},
|
|
1968
|
+
submit: (input, ctx) => runCommand(input, ctx)
|
|
1969
|
+
},
|
|
1970
|
+
async preview(input, ctx) {
|
|
1971
|
+
const vm = await requireRunning(input.sandbox, ctx);
|
|
1972
|
+
const published = async () => (await read(ctx, (native) => native.tls.rules.list({ vmId: vm.id }))).rules.find((candidate) => candidate.action === "allow" && candidate.source.public === true && candidate.destination.vmId === vm.id && candidate.destination.port === input.port && /^[a-z0-9-]{1,63}\.style\.dev$/.test(candidate.domain));
|
|
1973
|
+
let rule = await published();
|
|
1974
|
+
if (!rule) {
|
|
1975
|
+
if (!vm.slug || !AttemptSlug.safeParse(vm.slug).success)
|
|
1976
|
+
throw new AdapterError6("UNSUPPORTED", "Only VMs created by Beachside get style.dev preview names");
|
|
1977
|
+
try {
|
|
1978
|
+
await api(callSignal(ctx)).tls.rules.create({
|
|
1979
|
+
action: "allow",
|
|
1980
|
+
domain: previewDomain(vm.slug, input.port),
|
|
1981
|
+
source: { public: true },
|
|
1982
|
+
destination: { vmId: vm.id, port: input.port }
|
|
1983
|
+
});
|
|
1984
|
+
} catch (error) {
|
|
1985
|
+
const native = failure(error);
|
|
1986
|
+
if (native.status !== 409)
|
|
1987
|
+
throw native.error;
|
|
1988
|
+
}
|
|
1989
|
+
rule = await published();
|
|
1990
|
+
}
|
|
1991
|
+
if (!rule)
|
|
1992
|
+
throw new AdapterError6("UNAVAILABLE", "Freestyle did not publish the preview port");
|
|
1993
|
+
return { access: "public", url: `https://${rule.domain}` };
|
|
1994
|
+
},
|
|
1995
|
+
files: {
|
|
1996
|
+
maxBytes: MAX_FILE_BYTES,
|
|
1997
|
+
async read(input, ctx) {
|
|
1998
|
+
await requireVm(input.sandbox, ctx);
|
|
1999
|
+
const limit = Math.min(input.maxBytes ?? MAX_FILE_BYTES, MAX_FILE_BYTES);
|
|
2000
|
+
try {
|
|
2001
|
+
const bytes = await api(callSignal(ctx), limit + 1).vms.ref(input.sandbox.id).fs.readFile(input.path);
|
|
2002
|
+
if (bytes.byteLength > limit)
|
|
2003
|
+
throw new AdapterError6("CAPACITY", "Freestyle file exceeds the read bound");
|
|
2004
|
+
return bytes;
|
|
2005
|
+
} catch (error) {
|
|
2006
|
+
throw failure(error).error;
|
|
2007
|
+
}
|
|
2008
|
+
},
|
|
2009
|
+
write: {
|
|
2010
|
+
async prepare(input, ctx) {
|
|
2011
|
+
if (input.bytes.byteLength > MAX_FILE_BYTES)
|
|
2012
|
+
throw new AdapterError6("CAPACITY", "Freestyle file exceeds the write bound");
|
|
2013
|
+
await requireRunning(input.sandbox, ctx);
|
|
2014
|
+
return input;
|
|
2015
|
+
},
|
|
2016
|
+
async submit(input, ctx) {
|
|
2017
|
+
if (!input.overwrite)
|
|
2018
|
+
try {
|
|
2019
|
+
return await files(input.sandbox.id).write(input.path, [input.bytes], false, readContext(ctx.signal, 120));
|
|
2020
|
+
} catch (error) {
|
|
2021
|
+
return fileRejection(ctx, error);
|
|
2022
|
+
}
|
|
2023
|
+
try {
|
|
2024
|
+
if (!await putFile(input.sandbox.id, input.path, input.bytes, ctx.signal))
|
|
2025
|
+
return ctx.unknown("Freestyle wrote the file but could not restore its owner; inspect it");
|
|
2026
|
+
} catch (error) {
|
|
2027
|
+
if (error instanceof AdapterError6)
|
|
2028
|
+
return ctx.reject(error.code, error.message);
|
|
2029
|
+
return rejection(ctx, error, "Freestyle file write was not confirmed");
|
|
2030
|
+
}
|
|
2031
|
+
return { bytesWritten: input.bytes.byteLength };
|
|
2032
|
+
}
|
|
2033
|
+
},
|
|
2034
|
+
async readDirectory(input, ctx) {
|
|
2035
|
+
if ((await requireVm(input.sandbox, ctx)).state !== "running")
|
|
2036
|
+
return {
|
|
2037
|
+
entries: (await nativeEntries(input.sandbox.id, input.path, ctx)).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0),
|
|
2038
|
+
completeness: "complete",
|
|
2039
|
+
observedAt: new Date().toISOString()
|
|
2040
|
+
};
|
|
2041
|
+
return directoryValue(await files(input.sandbox.id).helper("list", input.path, "", false, ctx));
|
|
2042
|
+
},
|
|
2043
|
+
async stat(input, ctx) {
|
|
2044
|
+
if ((await requireVm(input.sandbox, ctx)).state !== "running") {
|
|
2045
|
+
if (!input.followSymlinks)
|
|
2046
|
+
throw new AdapterError6("CONFLICT", "Freestyle reports a link's own metadata only on a running VM");
|
|
2047
|
+
return nativeStat(input.sandbox.id, input.path, ctx);
|
|
2048
|
+
}
|
|
2049
|
+
return statValue(await files(input.sandbox.id).helper("stat", input.path, "", input.followSymlinks, ctx));
|
|
2050
|
+
},
|
|
2051
|
+
async exists(input, ctx) {
|
|
2052
|
+
if ((await requireVm(input.sandbox, ctx)).state !== "running")
|
|
2053
|
+
return read(ctx, (native) => native.vms.ref(input.sandbox.id).fs.exists(input.path));
|
|
2054
|
+
return Exists.parse(await files(input.sandbox.id).helper("exists", input.path, "", false, ctx)).exists;
|
|
2055
|
+
},
|
|
2056
|
+
async readStream(input, ctx) {
|
|
2057
|
+
await requireVm(input.sandbox, ctx);
|
|
2058
|
+
return files(input.sandbox.id).read(input.path, ctx);
|
|
2059
|
+
},
|
|
2060
|
+
async writeStream(input, ctx) {
|
|
2061
|
+
await requireRunning(input.sandbox, ctx);
|
|
2062
|
+
const iterator = input.bytes[Symbol.asyncIterator]();
|
|
2063
|
+
const head = [];
|
|
2064
|
+
let size = 0;
|
|
2065
|
+
let ended = false;
|
|
2066
|
+
while (input.overwrite && size <= MAX_FILE_BYTES) {
|
|
2067
|
+
const next = await iterator.next();
|
|
2068
|
+
if (next.done) {
|
|
2069
|
+
ended = true;
|
|
2070
|
+
break;
|
|
2071
|
+
}
|
|
2072
|
+
head.push(next.value);
|
|
2073
|
+
size += next.value.length;
|
|
2074
|
+
}
|
|
2075
|
+
if (ended) {
|
|
2076
|
+
try {
|
|
2077
|
+
const restored = await putFile(input.sandbox.id, input.path, new Uint8Array(Buffer.concat(head)), ctx.signal);
|
|
2078
|
+
if (!restored)
|
|
2079
|
+
throw new AdapterFilesystemError2("UNAVAILABLE", "Freestyle wrote the file but could not restore its owner; inspect it", {
|
|
2080
|
+
effect: "applied",
|
|
2081
|
+
source: input.path,
|
|
2082
|
+
destination: input.path,
|
|
2083
|
+
temporaryPaths: []
|
|
2084
|
+
});
|
|
2085
|
+
} catch (error) {
|
|
2086
|
+
if (error instanceof AdapterError6)
|
|
2087
|
+
throw error;
|
|
2088
|
+
const native = failure(error);
|
|
2089
|
+
if (refusedBeforeEffect(native))
|
|
2090
|
+
throw native.error;
|
|
2091
|
+
throw new AdapterFilesystemError2("UNAVAILABLE", "Freestyle file write was not confirmed; do not replay", {
|
|
2092
|
+
effect: "possible",
|
|
2093
|
+
source: input.path,
|
|
2094
|
+
destination: input.path,
|
|
2095
|
+
temporaryPaths: []
|
|
2096
|
+
});
|
|
2097
|
+
}
|
|
2098
|
+
ctx.retain?.({ effect: "applied", temporaryPaths: [], bytesTransferred: size });
|
|
2099
|
+
return { bytesWritten: size };
|
|
2100
|
+
}
|
|
2101
|
+
async function* rest() {
|
|
2102
|
+
yield* head;
|
|
2103
|
+
for (let next = await iterator.next();!next.done; next = await iterator.next())
|
|
2104
|
+
yield next.value;
|
|
2105
|
+
}
|
|
2106
|
+
return files(input.sandbox.id).write(input.path, rest(), input.overwrite, ctx);
|
|
2107
|
+
},
|
|
2108
|
+
async copy(input, ctx) {
|
|
2109
|
+
await requireRunning(input.sandbox, ctx);
|
|
2110
|
+
return files(input.sandbox.id).transfer("copy", input.source, input.destination, input.overwrite, ctx);
|
|
2111
|
+
},
|
|
2112
|
+
async move(input, ctx) {
|
|
2113
|
+
await requireRunning(input.sandbox, ctx);
|
|
2114
|
+
return files(input.sandbox.id).transfer("move", input.source, input.destination, input.overwrite, ctx);
|
|
2115
|
+
},
|
|
2116
|
+
makeDirectory: {
|
|
2117
|
+
async prepare(input, ctx) {
|
|
2118
|
+
await requireRunning(input.sandbox, ctx);
|
|
2119
|
+
return input;
|
|
2120
|
+
},
|
|
2121
|
+
async submit(input, ctx) {
|
|
2122
|
+
try {
|
|
2123
|
+
await files(input.sandbox.id).helper("mkdir", input.path, "", input.recursive, readContext(ctx.signal));
|
|
2124
|
+
return { acknowledged: true };
|
|
2125
|
+
} catch (error) {
|
|
2126
|
+
return fileRejection(ctx, error);
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
},
|
|
2130
|
+
remove: {
|
|
2131
|
+
async prepare(input, ctx) {
|
|
2132
|
+
await requireRunning(input.sandbox, ctx);
|
|
2133
|
+
return input;
|
|
2134
|
+
},
|
|
2135
|
+
async submit(input, ctx) {
|
|
2136
|
+
try {
|
|
2137
|
+
await files(input.sandbox.id).helper("remove", input.path, "", input.recursive, readContext(ctx.signal));
|
|
2138
|
+
return { acknowledged: true };
|
|
2139
|
+
} catch (error) {
|
|
2140
|
+
return fileRejection(ctx, error);
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
},
|
|
2144
|
+
async list(input, ctx) {
|
|
2145
|
+
await requireVm(input.sandbox, ctx);
|
|
2146
|
+
return nativeEntries(input.sandbox.id, input.path, ctx);
|
|
2147
|
+
}
|
|
2148
|
+
},
|
|
2149
|
+
async suspensionCapabilities() {
|
|
2150
|
+
return {
|
|
2151
|
+
status: "supported",
|
|
2152
|
+
value: {
|
|
2153
|
+
preserve: Preserve.FILESYSTEM_AND_MEMORY,
|
|
2154
|
+
processes: "preserved",
|
|
2155
|
+
connections: "dropped"
|
|
2156
|
+
}
|
|
2157
|
+
};
|
|
2158
|
+
},
|
|
2159
|
+
async resumeCapabilities() {
|
|
2160
|
+
return {
|
|
2161
|
+
status: "supported",
|
|
2162
|
+
value: { sourceStates: ["suspended"], setsSessionTimeout: false }
|
|
2163
|
+
};
|
|
2164
|
+
},
|
|
2165
|
+
suspend: {
|
|
2166
|
+
recovery: { version: 1, token: LifecycleToken },
|
|
2167
|
+
async prepare(input, ctx) {
|
|
2168
|
+
const vm = await requireVm(input.sandbox, ctx);
|
|
2169
|
+
if (vm.state !== "running")
|
|
2170
|
+
throw new AdapterError6("CONFLICT", `Freestyle can pause only a running VM, not ${vm.state}`);
|
|
2171
|
+
return {
|
|
2172
|
+
...input,
|
|
2173
|
+
intent: { action: "suspend", preserve: Preserve.FILESYSTEM_AND_MEMORY }
|
|
2174
|
+
};
|
|
2175
|
+
},
|
|
2176
|
+
async submit(input, ctx) {
|
|
2177
|
+
const token = { id: Id.parse(input.sandbox.id), since: Date.now() };
|
|
2178
|
+
await ctx.checkpoint(token);
|
|
2179
|
+
const hold = { inFlight: true, checkedAt: 0 };
|
|
2180
|
+
held.set(token.id, hold);
|
|
2181
|
+
try {
|
|
2182
|
+
try {
|
|
2183
|
+
await api(callSignal(readContext(ctx.signal, 120))).vms.ref(token.id).pause();
|
|
2184
|
+
} catch (error) {
|
|
2185
|
+
const rejected = rejection(ctx, error, "Freestyle pause response was not confirmed");
|
|
2186
|
+
if (refusedBeforeEffect(failure(error)))
|
|
2187
|
+
held.delete(token.id);
|
|
2188
|
+
return rejected;
|
|
2189
|
+
}
|
|
2190
|
+
try {
|
|
2191
|
+
const paused = await settle(ctx.signal, async (read2) => {
|
|
2192
|
+
const vm = await find(token.id, read2);
|
|
2193
|
+
return vm?.state === "paused" ? vm : undefined;
|
|
2194
|
+
});
|
|
2195
|
+
if (paused) {
|
|
2196
|
+
hold.checkedAt = Date.now();
|
|
2197
|
+
return suspended(paused, input.sandbox.reference);
|
|
2198
|
+
}
|
|
2199
|
+
} catch {}
|
|
2200
|
+
return ctx.pending(token, { pollAfterMs: pollMs });
|
|
2201
|
+
} finally {
|
|
2202
|
+
hold.inFlight = false;
|
|
2203
|
+
}
|
|
2204
|
+
},
|
|
2205
|
+
async observe(attempt, ctx) {
|
|
2206
|
+
const token = LifecycleToken.safeParse(attempt.token);
|
|
2207
|
+
const box = attempt.sandbox;
|
|
2208
|
+
if (!token.success || !box?.reference || token.data.id !== box.id)
|
|
2209
|
+
return ctx.unknown("Freestyle pause identity is unavailable; do not replay");
|
|
2210
|
+
const vm = await find(token.data.id, ctx);
|
|
2211
|
+
if (!vm) {
|
|
2212
|
+
held.delete(token.data.id);
|
|
2213
|
+
return ctx.unknown("Freestyle VM disappeared while pausing");
|
|
2214
|
+
}
|
|
2215
|
+
if (vm.state === "paused") {
|
|
2216
|
+
const hold = held.get(vm.id);
|
|
2217
|
+
if (hold)
|
|
2218
|
+
hold.checkedAt = Date.now();
|
|
2219
|
+
else
|
|
2220
|
+
held.set(vm.id, { inFlight: false, checkedAt: Date.now() });
|
|
2221
|
+
return suspended(vm, box.reference);
|
|
2222
|
+
}
|
|
2223
|
+
const waiting = vm.state === "pausing" || vm.state === "running" && Date.now() - (token.data.since ?? 0) < settleSeconds * 1000;
|
|
2224
|
+
if (waiting)
|
|
2225
|
+
return ctx.pending(token.data, { pollAfterMs: pollMs });
|
|
2226
|
+
held.delete(token.data.id);
|
|
2227
|
+
return ctx.unknown(vm.state === "running" ? "Freestyle acknowledged the pause but the VM kept running; inspect it before retrying" : `Freestyle VM is ${vm.state} after pause; inspect without replay`);
|
|
2228
|
+
}
|
|
2229
|
+
},
|
|
2230
|
+
resume: {
|
|
2231
|
+
recovery: { version: 1, token: LifecycleToken },
|
|
2232
|
+
async prepare(input, ctx) {
|
|
2233
|
+
const vm = await requireVm(input.sandbox, ctx);
|
|
2234
|
+
if (vm.state !== "paused")
|
|
2235
|
+
throw new AdapterError6("CONFLICT", `Freestyle can resume only a paused VM, not ${vm.state}`);
|
|
2236
|
+
return { ...input, intent: { action: "resume" } };
|
|
2237
|
+
},
|
|
2238
|
+
async submit(input, ctx) {
|
|
2239
|
+
const token = { id: Id.parse(input.sandbox.id) };
|
|
2240
|
+
await ctx.checkpoint(token);
|
|
2241
|
+
try {
|
|
2242
|
+
await api(callSignal(readContext(ctx.signal, 120))).vms.ref(token.id).start();
|
|
2243
|
+
} catch (error) {
|
|
2244
|
+
return rejection(ctx, error, "Freestyle start response was not confirmed");
|
|
2245
|
+
}
|
|
2246
|
+
try {
|
|
2247
|
+
const ready = await settle(ctx.signal, async (read2) => {
|
|
2248
|
+
const vm = await find(token.id, read2);
|
|
2249
|
+
return vm?.state === "running" ? vm : undefined;
|
|
2250
|
+
});
|
|
2251
|
+
if (ready) {
|
|
2252
|
+
release(token.id);
|
|
2253
|
+
return resumed(ready, input.sandbox.reference);
|
|
2254
|
+
}
|
|
2255
|
+
} catch {}
|
|
2256
|
+
return ctx.pending(token, { pollAfterMs: pollMs });
|
|
2257
|
+
},
|
|
2258
|
+
async observe(attempt, ctx) {
|
|
2259
|
+
const token = LifecycleToken.safeParse(attempt.token);
|
|
2260
|
+
const box = attempt.sandbox;
|
|
2261
|
+
if (!token.success || !box?.reference || token.data.id !== box.id)
|
|
2262
|
+
return ctx.unknown("Freestyle resume identity is unavailable; do not replay");
|
|
2263
|
+
const vm = await find(token.data.id, ctx);
|
|
2264
|
+
if (!vm)
|
|
2265
|
+
return ctx.unknown("Freestyle VM disappeared while resuming");
|
|
2266
|
+
if (vm.state === "running") {
|
|
2267
|
+
release(vm.id);
|
|
2268
|
+
return resumed(vm, box.reference);
|
|
2269
|
+
}
|
|
2270
|
+
return vm.state === "paused" || vm.state === "starting" ? ctx.pending(token.data, { pollAfterMs: pollMs }) : ctx.unknown(`Freestyle VM is ${vm.state} after resume; inspect without replay`);
|
|
2271
|
+
}
|
|
2272
|
+
},
|
|
2273
|
+
async renewCapabilities() {
|
|
2274
|
+
return {
|
|
2275
|
+
status: "supported",
|
|
2276
|
+
value: {
|
|
2277
|
+
minSeconds: 60,
|
|
2278
|
+
maxSeconds: MAX_TTL_SECONDS,
|
|
2279
|
+
stepSeconds: 1,
|
|
2280
|
+
scope: "sandbox"
|
|
2281
|
+
}
|
|
2282
|
+
};
|
|
2283
|
+
},
|
|
2284
|
+
renew: {
|
|
2285
|
+
async prepare(input, ctx) {
|
|
2286
|
+
await requireVm(input.sandbox, ctx);
|
|
2287
|
+
const forSeconds = input.forSeconds ?? config.lifecycle.lifetimeSeconds;
|
|
2288
|
+
if (forSeconds < 60 || forSeconds > MAX_TTL_SECONDS)
|
|
2289
|
+
throw new AdapterError6("INVALID_ARGUMENT", `Freestyle lifetimes are 60 to ${MAX_TTL_SECONDS} seconds`);
|
|
2290
|
+
return { ...input, forSeconds };
|
|
2291
|
+
},
|
|
2292
|
+
async submit(input, ctx) {
|
|
2293
|
+
const current = await find(input.sandbox.id, readContext(ctx.signal));
|
|
2294
|
+
if (!current)
|
|
2295
|
+
return ctx.reject("NOT_FOUND", "Freestyle VM is unavailable in the verified account");
|
|
2296
|
+
const created = Date.parse(current.createdAt);
|
|
2297
|
+
if (Number.isNaN(created))
|
|
2298
|
+
return ctx.reject("UNSUPPORTED", "Freestyle reported no usable creation time");
|
|
2299
|
+
const ttlSeconds = Math.ceil(Math.max(0, Date.now() - created) / 1000) + input.forSeconds;
|
|
2300
|
+
let vm;
|
|
2301
|
+
try {
|
|
2302
|
+
vm = await api(callSignal(readContext(ctx.signal))).vms.ref(input.sandbox.id).update({ ttlSeconds });
|
|
2303
|
+
} catch (error) {
|
|
2304
|
+
return rejection(ctx, error, "Freestyle lifetime update was not confirmed");
|
|
2305
|
+
}
|
|
2306
|
+
return {
|
|
2307
|
+
reference: input.sandbox.reference,
|
|
2308
|
+
requested: { forSeconds: input.forSeconds },
|
|
2309
|
+
acknowledged: true,
|
|
2310
|
+
observation: vm.id === input.sandbox.id ? observation(vm, input.sandbox.reference) : null
|
|
2311
|
+
};
|
|
2312
|
+
}
|
|
2313
|
+
},
|
|
2314
|
+
snapshotProfiles: async () => snapshotSupport,
|
|
2315
|
+
snapshotCapture: {
|
|
2316
|
+
recovery: { version: 1, token: CaptureToken },
|
|
2317
|
+
async prepare(input, ctx) {
|
|
2318
|
+
if (input.request.retention?.cleanupAfterSeconds === 0)
|
|
2319
|
+
throw new AdapterError6("INVALID_ARGUMENT", "Freestyle snapshot cleanup needs at least 1 second; omit it to keep the snapshot");
|
|
2320
|
+
const vm = await requireVm(input.sandbox, ctx);
|
|
2321
|
+
const plan = resolveSnapshot(snapshotSupport, input.request, state(vm.state));
|
|
2322
|
+
if (plan.status !== "supported")
|
|
2323
|
+
throw new AdapterError6("UNSUPPORTED", plan.reason);
|
|
2324
|
+
return {
|
|
2325
|
+
...input,
|
|
2326
|
+
expectation: { profile: plan.value.profile, sourceState: plan.value.sourceState }
|
|
2327
|
+
};
|
|
2328
|
+
},
|
|
2329
|
+
async submit(input, ctx) {
|
|
2330
|
+
const token = {
|
|
2331
|
+
slug: snapshotSlug(ctx.submissionId),
|
|
2332
|
+
source: Id.parse(input.sandbox.id),
|
|
2333
|
+
sourceState: input.expectation?.sourceState === "suspended" ? "suspended" : "running"
|
|
2334
|
+
};
|
|
2335
|
+
const request = { slug: token.slug };
|
|
2336
|
+
const ttlSeconds = input.request.retention?.cleanupAfterSeconds ?? config.snapshots?.retentionSeconds;
|
|
2337
|
+
if (ttlSeconds)
|
|
2338
|
+
request.ttlSeconds = ttlSeconds;
|
|
2339
|
+
await ctx.checkpoint(token);
|
|
2340
|
+
let created;
|
|
2341
|
+
try {
|
|
2342
|
+
created = await api(callSignal(readContext(ctx.signal, 600))).vms.ref(token.source).snapshot(request);
|
|
2343
|
+
} catch (error) {
|
|
2344
|
+
const native = failure(error);
|
|
2345
|
+
return refusedBeforeEffect(native) ? ctx.reject(native.error.code, native.error.message) : ctx.pending(token, { pollAfterMs: pollMs });
|
|
2346
|
+
}
|
|
2347
|
+
const id = Id.safeParse(created.snapshotId);
|
|
2348
|
+
if (!id.success)
|
|
2349
|
+
return ctx.unknown("Freestyle snapshot acknowledgement has no usable id; do not replay");
|
|
2350
|
+
const tracked = { ...token, id: id.data };
|
|
2351
|
+
await ctx.checkpoint(tracked);
|
|
2352
|
+
return captureResult(tracked, { ...created.snapshot, id: id.data }, ctx);
|
|
2353
|
+
},
|
|
2354
|
+
async observe(attempt, ctx) {
|
|
2355
|
+
const token = CaptureToken.safeParse(attempt.token);
|
|
2356
|
+
if (!token.success || token.data.source !== attempt.sandbox?.id || token.data.slug !== snapshotSlug(attempt.submissionId))
|
|
2357
|
+
return ctx.unknown("Freestyle capture identity is unavailable; do not replay");
|
|
2358
|
+
const snapshot = token.data.id ? await findSnapshot(token.data.id, ctx) : await findSnapshotBySlug(token.data.slug, token.data.source, ctx);
|
|
2359
|
+
return captureResult(token.data, snapshot, ctx);
|
|
2360
|
+
}
|
|
2361
|
+
},
|
|
2362
|
+
snapshotRestore: {
|
|
2363
|
+
recovery: { version: 1, token: CreateToken },
|
|
2364
|
+
options: {
|
|
2365
|
+
env: true,
|
|
2366
|
+
labels: true,
|
|
2367
|
+
lifetime: { minSeconds: 60, maxSeconds: MAX_TTL_SECONDS }
|
|
2368
|
+
},
|
|
2369
|
+
async prepare(input, ctx) {
|
|
2370
|
+
if (input.request.resources || input.request.mounts?.length)
|
|
2371
|
+
throw new AdapterError6("UNSUPPORTED", "Freestyle restores keep the snapshot's VM size and take no mounts");
|
|
2372
|
+
if (!restoreSupport.networkPolicies.includes(input.request.networkPolicy))
|
|
2373
|
+
throw new AdapterError6("UNSUPPORTED", "Freestyle network policies are blocked and internet");
|
|
2374
|
+
checkEnv(input.request.env);
|
|
2375
|
+
if (input.request.labels && !Labels.safeParse(input.request.labels).success)
|
|
2376
|
+
throw new AdapterError6("INVALID_ARGUMENT", "Freestyle labels allow at most 64 entries with keys and values up to 63 characters");
|
|
2377
|
+
await requireSnapshot(input.snapshot, ctx);
|
|
2378
|
+
return input;
|
|
2379
|
+
},
|
|
2380
|
+
submit: (input, ctx) => launch({
|
|
2381
|
+
image: { kind: "prepared", value: input.snapshot.nativeId },
|
|
2382
|
+
networkPolicy: input.request.networkPolicy,
|
|
2383
|
+
labels: input.request.labels,
|
|
2384
|
+
env: input.request.env,
|
|
2385
|
+
lifetimeSeconds: input.request.lifetimeSeconds
|
|
2386
|
+
}, ctx, partialRestore),
|
|
2387
|
+
async observe(attempt, ctx) {
|
|
2388
|
+
const token = CreateToken.safeParse(attempt.token);
|
|
2389
|
+
if (!attempt.resource || !token.success || token.data.slug !== attemptSlug(attempt.submissionId))
|
|
2390
|
+
return ctx.unknown("Freestyle restore identity is unavailable; do not replay");
|
|
2391
|
+
checkedSnapshot(attempt.resource);
|
|
2392
|
+
return observeLaunch(token.data, attempt, ctx, partialRestore);
|
|
2393
|
+
}
|
|
2394
|
+
},
|
|
2395
|
+
async snapshotInspect(ref, ctx) {
|
|
2396
|
+
return snapshotInfo(await requireSnapshot(ref, ctx), ref);
|
|
2397
|
+
},
|
|
2398
|
+
snapshotListCoverage: "provider-scope",
|
|
2399
|
+
async snapshotList(input, ctx) {
|
|
2400
|
+
const listed = await snapshotPage(input, ctx);
|
|
2401
|
+
return {
|
|
2402
|
+
items: listed.snapshots.map((snapshot) => snapshotInfo(snapshot, snapshotRef(snapshot.id))),
|
|
2403
|
+
nextCursor: listed.nextCursor,
|
|
2404
|
+
coverage: "provider-scope"
|
|
2405
|
+
};
|
|
2406
|
+
},
|
|
2407
|
+
snapshotDelete: {
|
|
2408
|
+
recovery: { version: 1, token: SnapshotDeleteToken },
|
|
2409
|
+
async prepare(ref, ctx) {
|
|
2410
|
+
await requireSnapshot(ref, ctx);
|
|
2411
|
+
return ref;
|
|
2412
|
+
},
|
|
2413
|
+
async submit(ref, ctx) {
|
|
2414
|
+
const token = { id: checkedSnapshot(ref) };
|
|
2415
|
+
await ctx.checkpoint(token);
|
|
2416
|
+
try {
|
|
2417
|
+
await api(callSignal(readContext(ctx.signal))).vms.snapshots.delete(token.id);
|
|
2418
|
+
} catch (error) {
|
|
2419
|
+
const native = failure(error);
|
|
2420
|
+
if (refusedBeforeEffect(native))
|
|
2421
|
+
return ctx.reject(native.error.code, native.error.message);
|
|
2422
|
+
return ctx.pending(token, { pollAfterMs: pollMs });
|
|
2423
|
+
}
|
|
2424
|
+
return { deleted: true, reference: ref };
|
|
2425
|
+
},
|
|
2426
|
+
async observe(attempt, ctx) {
|
|
2427
|
+
const token = SnapshotDeleteToken.safeParse(attempt.token);
|
|
2428
|
+
const ref = attempt.resource;
|
|
2429
|
+
if (!token.success || !ref || checkedSnapshot(ref) !== token.data.id)
|
|
2430
|
+
return ctx.unknown("Freestyle snapshot deletion identity is unavailable; do not replay");
|
|
2431
|
+
return await findSnapshot(token.data.id, ctx) ? ctx.unknown("Freestyle snapshot still exists; deletion was not confirmed, so delete it again explicitly") : { deleted: true, reference: ref };
|
|
2432
|
+
}
|
|
2433
|
+
},
|
|
2434
|
+
async imageInspect(ref, ctx) {
|
|
2435
|
+
assertResourceScope(ref, { provider: "freestyle", scope });
|
|
2436
|
+
if (ref.kind !== "image" || !Id.safeParse(ref.nativeId).success)
|
|
2437
|
+
throw new AdapterError6("CONFLICT", "Invalid Freestyle image identity");
|
|
2438
|
+
const snapshot = await findSnapshot(ref.nativeId, ctx);
|
|
2439
|
+
if (!snapshot)
|
|
2440
|
+
throw new AdapterError6("NOT_FOUND", "Freestyle snapshot is unavailable");
|
|
2441
|
+
return imageInfo(snapshot);
|
|
2442
|
+
},
|
|
2443
|
+
imageListCoverage: "provider-scope",
|
|
2444
|
+
async imageList(input, ctx) {
|
|
2445
|
+
const listed = await snapshotPage(input, ctx);
|
|
2446
|
+
return {
|
|
2447
|
+
items: listed.snapshots.map(imageInfo),
|
|
2448
|
+
nextCursor: listed.nextCursor,
|
|
2449
|
+
coverage: "provider-scope"
|
|
2450
|
+
};
|
|
2451
|
+
},
|
|
2452
|
+
async resourceCapabilities() {
|
|
2453
|
+
return {
|
|
2454
|
+
restore: { status: "supported", value: restoreSupport },
|
|
2455
|
+
volumes: { status: "unsupported", reason: "Freestyle has no volumes" },
|
|
2456
|
+
mounts: { status: "unsupported", reason: "Freestyle has no volume mounts" }
|
|
2457
|
+
};
|
|
2458
|
+
}
|
|
2459
|
+
};
|
|
2460
|
+
const suspended = (vm, reference) => ({
|
|
2461
|
+
reference,
|
|
2462
|
+
preserve: Preserve.FILESYSTEM_AND_MEMORY,
|
|
2463
|
+
processes: "preserved",
|
|
2464
|
+
connections: "dropped",
|
|
2465
|
+
observation: observation(vm, reference)
|
|
2466
|
+
});
|
|
2467
|
+
const resumed = (vm, reference) => ({
|
|
2468
|
+
reference,
|
|
2469
|
+
execution: "resumed",
|
|
2470
|
+
executionIdentity: unknownSandboxFacts().execution,
|
|
2471
|
+
connections: "dropped",
|
|
2472
|
+
observation: observation(vm, reference)
|
|
2473
|
+
});
|
|
2474
|
+
return session;
|
|
2475
|
+
}
|
|
2476
|
+
});
|
|
2477
|
+
}
|
|
2478
|
+
var freestyleAdapter = createFreestyleAdapter();
|
|
2479
|
+
// src/factory.ts
|
|
2480
|
+
import { bindAdapter } from "@beachside/adapter";
|
|
2481
|
+
function freestyle(options) {
|
|
2482
|
+
const { apiKey, ...config } = options;
|
|
2483
|
+
return bindAdapter(freestyleAdapter, config, { apiKey });
|
|
2484
|
+
}
|
|
2485
|
+
export {
|
|
2486
|
+
freestyleAdapter,
|
|
2487
|
+
freestyle,
|
|
2488
|
+
createFreestyleAdapter,
|
|
2489
|
+
BASE_IMAGE
|
|
2490
|
+
};
|