Script API reference
Every function in scope inside an automation script. See the Automation API for deploying, scheduling and pricing, and tips & recipes for the patterns.
Three rules that apply everywhere:
- Everything is synchronous. No promises, no
await. - Waits are for facts — a window exists, a file stopped growing, an element became visible.
sys.sleepexists; you almost never need it. - An error is thrown; a status is data. An HTTP 500, a non-zero exit code and a failed OCR match are values you branch on.
job
job.params | The run's input. A key that was not passed reads undefined. |
job.name | The script's deployed name. |
job.outDir | This run's output directory. Anything written here is uploaded, then deleted. Empty string in a local run. |
job.emit(record) | Append one JSON record to _emitted.ndjson. |
job.next(params) | Queue another run of this script with different input. Only a successful run chains. |
job.state | A store that survives between runs on this machine. |
job.waitForData(key, opts?) | Block until something outside sends a value. |
job.state
const since = job.state.get("watermark", "2020-01-01");
// … do the work …
job.state.set("watermark", today);get(key, default?) / set(key, value) | Any JSON value. |
delete(key) / keys() / clear() | |
flush() | Write now. Otherwise it saves on its own and when the run ends. |
Per machine, not per fleet. Survives a failed run. Bounded at 256 KB and 2,000 keys.
job.waitForData
win.clickButton("Send code");
const code = job.waitForData("otp.dealer-42", { timeout: 300000 });
win.find("name", "Code").setValue(code.value || code);job.waitForData(key, {timeout, default}?) | Blocks. Default 5 minutes, maximum 2 hours. With no default, a timeout throws. |
job.peekData(key) | Non-blocking. null if nothing has arrived. |
job.dataUrl(key) | A URL scoped to this run and this key, carrying its own capability token. |
A value that arrives before you ask for it is held, not dropped. JSON arrives as an object, anything else as a string.
Sending the value
Three ways in. All three address the key, which does the routing: a value reaches every job running on the instance, and only one that asks for that key sees it. script and agent_id narrow it further. Posting when nothing is running is a 409.
From a function:
export default {
async fetch(req, env) {
const body = await req.json();
const code = /\b(\d{6})\b/.exec(body.Body)?.[1];
if (!code) return new Response("no code in that message", { status: 200 });
await env.automation.deliver({ instance: "fleet" }, "otp.dealer-42", code);
return new Response("ok");
}
}Over the API, with an organization API key:
curl -X POST https://api.altengine.net/v1/automation/fleet/data/otp.dealer-42 \
-H "Authorization: Bearer $ALTENGINE_API_KEY" \
-d '"123456"'Or with the URL job.dataUrl(key) returns, which does one thing to one run and dies with it.
All three answer with what the machine confirmed. A value written to an open socket that goes unacknowledged is neither delivered nor refused: deliver names those runs in undetermined and the API answers 202. Do not send a replacement — the job may already have it, and a second one-time code invalidates the first. All three outcomes.
env
Credentials set on the instance and handed to the run. Values are write-only from outside — a read gives names — and travel with the dispatch, so a run that starts during an outage can still sign in.
env.NAME | The value. Uppercase by rule. |
env.get(name, default?) / env.has(name) / env.names() | |
env.set(name, value) | Persist a new value for the next run. null deletes. Blocks until saved, and throws if the save failed. |
env holds only what the run was given, never the machine's own environment.
log
log.debug / info / warn / error. console.log maps to info. Lines are tailed live and uploaded as _log.ndjson when the run ends.
sys
sys.exec(exe, args?, opts?) | {ok, code, stdout, stderr, timedOut, truncated}. Options: timeout, cwd, input, env, encoding. |
sys.launch(exe, …args) | Start something and carry on. |
sys.kill(pid) | |
sys.platform | "windows", "linux" or "darwin". |
sys.desktop() | "available", "locked" or "none" — the same word the console shows against the machine. |
sys.sendKeys(…) | Types into whatever holds the foreground. Prefer win.sendKeys. |
sys.screenshot(rect?) | PNG bytes. |
sys.keepAwake(bool) | Runs already hold the display awake; this is for the gaps. |
sys.sleep(ms) |
Arguments are an array, never one command string. For a shell, name it: sys.exec("cmd", ["/c", …]).
A timeout kills the program and everything it started. Only a program that could not start throws; a non-zero exit is data, and a timeout still returns whatever was printed.
An office PC at 2am is locked, so a script with a headless half and a desktop half asks sys.desktop() rather than finding out from a failure — provoking one costs the run's whole desktop timeout to learn something you could have asked for free.
const rows = csv.read(exportPath); // works whatever the machine is doing
http.fetch(url, { method: "POST", json: rows });
if (sys.desktop() !== "available") job.emit({ uploaded: rows.length, drove: false });
else { /* drive the application */ }"none" means this run has no desktop at all — a headless lane, or a build without desktop automation. It is not the same as locked, and neither one can drive a window.
fs and csv
Covered on the Automation page. Two additions:
fs.zip(sourceDir, archivePath) | Build an archive from a directory. |
fs.unzip(archive, targetDir, {overwrite}?) | Refuses entries that would land outside targetDir. |
fs.zipList(archive) | Names and sizes, without extracting. |
fs.diskFree(path?) | {free, total, used, path} in bytes. |
crypto
crypto.md5 / sha1 / sha256 / sha512(input) | Hex. A string, bytes, or an object. |
crypto.hash(algo, input) | |
crypto.hashFile(path) or (algo, path) | Streams, so file size does not matter. Defaults to sha256. |
crypto.hmac(algo, key, input) | |
crypto.equal(a, b) | Constant time. |
crypto.base64 / fromBase64 / hex / fromHex | There is no btoa here. The from* pair returns bytes. |
crypto.randomBytes(n) / randomHex(n) / uuid() | |
crypto.password(length?, opts?) | One character from each class, and no 0/O/1/l/I. |
crypto.pbkdf2(algo, password, salt, iterations, length) | Hex. sha1 is allowed: it is what .NET's Rfc2898DeriveBytes defaults to, so it is what most applications you have to match were built against. |
Hashing an object encodes it as JSON with sorted keys, so field order does not change the hash.
time
This runtime has no Intl, so Date.toLocaleString ignores a timeZone and uses the machine's. Use time for anything zone-dependent.
const day = time.format(time.now(), "date", "America/Chicago");
const start = time.startOfDay(time.now(), "America/Chicago");
const back = time.addDays(start, -30, "America/Chicago");time.now() / time.zone() | Milliseconds, and the machine's zone. |
time.format(ms, layout?, zone?) | Layouts: date, datetime, rfc3339, time, us, stamp, or a Go layout. |
time.parse(text, layout?, zone?) | |
time.parts(ms, zone?) | {year, month, day, hour, minute, second, weekday, yearDay, zone, offsetMinutes, millis}. |
time.startOfDay(ms, zone?) | |
time.addDays(ms, n, zone?) / addMonths | Calendar arithmetic, so a daylight-saving day is still one day. |
time.daysBetween(a, b, zone?) | Whole days, by calendar. |
addMonths clamps: one month after 31 January is 28 February. An unknown zone name throws rather than falling back to local.
http
const r = http.fetch("http://erp.internal/api/invoices", {
method: "POST",
headers: { authorization: "Bearer " + env.ERP_TOKEN },
body: { since: day },
});
if (!r.ok) throw new Error("ERP said " + r.status + ": " + r.text());
for (const row of r.json().rows) job.emit(row);{status, ok, headers, truncated, text(), json()}. Options: method, headers, body, timeout. A body that is not a string or bytes is sent as JSON, and sets the content type unless you set your own.
Only a request that could not be made throws. There is no outbound allowlist. Responses over 32 MB are truncated and say so.
http.download
A response body that goes to disk instead of through memory. A separate function rather than an option on fetch, because nothing can look at a string and tell a path from the contents of a file.
const r = http.download("http://erp.internal/export?day=" + day,
job.outDir + "\\invoices.zip");
if (!r.ok) throw new Error("the export said " + r.status + ": " + r.excerpt);
artifact.putFile("invoices.zip", r.path, { remove: true });http.download(url, dest, opts?) | {status, ok, headers, path, bytes, excerpt}. Options are fetch's. |
http.downloadAll(items, {limit}?) | items is [{url, to, method?, body?, headers?, timeout?}]; any other field is refused. Results in input order. |
- No size cap. The bytes never pass through memory, so the disk is the bound.
- Atomic. The body goes to a temp file beside
destand is renamed once all of it has arrived and been flushed. A server that sent fewer bytes than it declared throws, rather than leaving a short file that tomorrow's run treats as the export. - A non-2xx writes nothing and does not throw:
pathcomes back empty, andexcerptholds the first 8 KB of the error body — the portal's sign-in page, the API's complaint. timeoutbounds how long a transfer may go without progress, not how long it may take. Default 5 minutes; a 20 GB export runs for as long as it keeps arriving.- The response
headerscome back because the server is often the only thing that knows what the file is:content-dispositionfor its real name,content-typefor an extension the URL does not have,etagfor whether to fetch it again at all.
downloadAll's limit defaults to 4 and is clamped to 16. A transfer that failed outright is a result carrying error, not an exception — one unreachable host out of four thousand must not discard the files that did arrive. The bulk pattern is in tips & recipes.
db
SQL Server, PostgreSQL and MySQL. The application on the screen keeps its data somewhere, and that somewhere is usually reachable from the same PC.
const c = db.connect("sqlserver", "sqlserver://user:pw@10.0.0.9:1433?database=DEALER");
for (const row of c.query("SELECT ro, total FROM repair_order WHERE closed > @p1", [since])) {
out.write(row);
}
c.close();Drivers: sqlserver (mssql), postgres (postgresql, pgx), mysql (mariadb). db.drivers() lists them. connect takes {timeout} and verifies the connection before it returns, so a wrong password fails there rather than at the first query.
c.query(sql, params?, {timeout}?) | An iterator — one row at a time, so a million-row report holds one. [...c.query()] defeats the point. |
c.queryAll(sql, params?, {timeout, max}?) | An array. Refuses past max (10,000). |
c.queryRow(sql, params?, {timeout}?) | The first row, or null. |
c.exec(sql, params?, {timeout}?) | {rowsAffected, lastInsertId}; either is null where the driver cannot say. |
c.transact(fn) | One transaction. See below. |
c.close() / c.closed() / c.server |
Placeholders are the driver's own — @p1 for SQL Server, $1 for PostgreSQL, ? for MySQL — so an example written for your database works as it stands. Parameters are always bound. An array is positional; an object is named, which is how a stored procedure is called:
c.queryRow("EXEC sp_Login @UserName, @Password, @CheckCurrentUsers", {
UserName: "FOX",
Password: crypto.fromHex(crypto.pbkdf2("sha1", pw, salt, 1000 + "FOX".length, 20)),
CheckCurrentUsers: false,
});An object or array passed as a value is refused rather than serialized; pass JSON.stringify(it) if that is what you mean.
Coming back: binary columns are hex (crypto.fromHex for the bytes), a uniqueidentifier is a lower-case dashed GUID and goes back as a parameter in that form, dates are RFC 3339 strings, NULL is null, and a duplicated column name is renamed the way csv.read renames a duplicated header.
Transactions
A transaction is a scope. There is no begin/commit pair.
const roNumber = c.transact((tx) => {
tx.exec("INSERT INTO repair_order (vin, opened) VALUES (@p1, @p2)", [vin, now]);
return tx.queryRow("SELECT SCOPE_IDENTITY() AS ro").ro;
});It commits when the function returns and hands back what it returned; it rolls back and re-throws if the function throws. tx.rollback() inside is for deciding not to keep the batch — it returns normally rather than throwing. The transaction is finished once the function returns, and says so if you use it afterwards. Nesting transact on one connection is refused: these databases do not nest transactions.
There is no default query timeout. A report on these systems legitimately takes minutes, so the bound is the run's own budget; pass {timeout} where you know better. An open iterator holds a connection until it is exhausted or you break.
net
A raw TCP socket, for what speaks neither HTTP nor SQL.
const s = net.connect("10.0.0.50:23", { timeout: 10000 });
s.readUntil("login:");
s.write(env.HOST_USER + "\n");
s.readUntil("Password:");
s.write(env.HOST_PASSWORD + "\n");
const screen = s.read({ timeout: 2000 }); // "" on timeout
s.close();{address, write(data), read(opts?), readUntil(text, opts?), close(), closed}. connect takes {timeout, tls, insecure, encoding}; the read calls take {timeout, max}.
readUntil matches on the decoded text, not the bytes. A socket you forget to close is closed when the run ends.
task
Work that runs off the script's own thread. Each worker gets its own runtime, and every rule below follows from that.
const out = task.parallel(accounts, (a) => {
const r = http.fetch(a.url, { headers: { authorization: a.token } });
return { id: a.id, status: r.status, rows: r.ok ? r.json().rows.length : 0 };
}, { limit: 8 });
for (const o of out) {
if (o.ok) job.emit(o.value); else log.warn(o.error);
}task.parallel(items, fn, {limit, failFast}?) | [{ok, value, error}] in input order. limit defaults to 4 and is clamped to 16. |
task.run(fn, params) | Starts one function and returns a handle immediately. |
t.wait() / t.done() / t.cancel() / t.name | wait() returns the value, or throws what the task threw. |
task.run is for the other shape — start something long and carry on:
const t = task.run((p) => http.download(p.url, p.to).bytes,
{ url: exportUrl, to: job.outDir + "\\year-end.zip" });
// … drive the application meanwhile …
log.info(t.wait() + " bytes");There are no closures. The function is taken by source and recompiled in the worker, so it sees its argument and the globals and nothing else — a variable captured from the surrounding script is refused rather than silently reading as undefined. Pass it in as part of the argument.
For the same reason only a plain or arrow function written in your script can be a task: a built-in, a bound function and a method shorthand have no source to rebuild from. Arguments and return values cross as JSON, so a window, an element or a file handle can be neither passed nor returned.
| Available | http, fs, csv, net, crypto, time, artifact, log, sys.sleep, env for reading, and job.params / outDir / emit / state / worker. |
|---|---|
| Refused, with the reason | ui, mouse, clip, vision, browser, vm, the rest of sys, job.next, job.waitForData, env.set, and a task inside a task. |
There is one desktop and one machine, so nothing that drives either is available on a second thread. The rest are decisions about the run rather than about one item: make them in the main script, with what the task returned.
failFast is off by default — one bad account out of four thousand should not discard the rest. Turned on, it stops handing out work and cancels what is in flight; the results already collected still come back.
To fetch files, use http.downloadAll instead: its parallelism is inside the agent, with no second runtime and no source to recompile.
ui
Finding and driving windows. Every call that waits takes an optional timeout, defaulting to the instance's action timeout.
ui.findWindow(titleOrFn, timeout?) | Waits. Matches a title substring, or a predicate over {title, className, process, handle, visible}. |
ui.findWindows(…) | Every match. |
ui.window(…) / ui.windows(…) | The same, but now — null or [] rather than waiting. |
ui.listWindows() | Everything on the desktop. |
ui.waitFor(fn, {timeout, describe}?) | Wait for anything. describe becomes the error message. |
ui.guard(match, handler) | Run a handler before every poll of every wait. |
ui.guard(w => w.title.includes("Session expired"), (win) => {
win.clickButton("OK");
signIn();
});A window
focus() / isForeground() | focus() waits and confirms. |
title() / rect() / pid / process | title() re-reads now. |
typeText(text) | Posts characters to the focused control. |
sendKeys(…) | Keys and chords: {ENTER}, {F4}, ^{c}, +{TAB}, %{F4}. |
find(by, query) / findAll(by, query) | by is name, role, automationId or class. find returns the first match. |
tree(maxDepth?, maxNodes?) | The accessibility tree as JSON. |
controls() / setControlText(handle, text) | Classic Win32 children, with handles and rectangles. |
controlItems(handle, {max}?) / selectControlItem(handle, text) | A classic combo box or list box. |
buttons() / clickButton(text) | The mnemonic & and case are ignored, so "OK" finds "&OK". |
maximize() / minimize() / restore() / isMaximized() / isMinimized() | |
exists() / waitUntilClosed(timeout?) / close() | close() is a request a dialog can decline. |
typeText carries the character rather than a key event, so an application watching for key presses will not see it. Use sendKeys for keys and typeText for text.
A modifier applies to a brace group, so "^{s}" is Ctrl+S and "^s" is refused. That is what keeps "+1" and "100%" ordinary text rather than keystrokes.
Naming a file in a Save dialog
On Windows 11 the file name field is a ComboBox with an Edit inside it, and the dialog reads neither — it tracks the name through real key events. setControlText, setValue and typeText all change what is on screen, all read back correctly, and the file is then written under the prefilled name.
const dialog = ui.findWindow((w) => w.className === "#32770" && /save as/i.test(w.title), 15000);
dialog.sendKeys("%{n}"); // the "File name:" accelerator
dialog.sendKeys("^{a}"); // replace what is prefilled
dialog.sendKeys(fullPath); // real keystrokes, not typeText
dialog.clickButton("Save"); // Enter would take autocomplete's suggestion
dialog.waitUntilClosed(10000);
if (!fs.exists(fullPath)) throw new Error(`saved somewhere else: ${fullPath}`);The dialog closing tells you a save happened, never where, so check the path.
An element
name / role / automationId / className / enabled | |
click() | Through the accessibility API, not the mouse. |
setValue(text) / text() | setValue is atomic. |
rect() / center() / children() | |
rows({maxRows, maxCols}?) | {rows, row_count, column_count, truncated}. |
items({max}?) / select(text) | A list, combo box or tree, and what is selected. |
expand() / collapse() | Some applications only populate a list once it is open. |
const grid = win.find("automationId", "resultsGrid");
const g = grid.rows({ maxRows: 5000 });
if (g.truncated) log.warn("read " + g.rows.length + " of " + g.row_count + " rows");
for (const row of g.rows) job.emit({ invoice: row[0], customer: row[1] });row_count is what the control claims, which can be far more than you read — check truncated. A control that draws its own table publishes no structure, and rows() says so rather than returning something empty.
mouse
mouse.position(), move(pt), click(pt?, button?), doubleClick, down, up, drag(from, to), wheel(clicks, pt?). Points are {x, y} in screen coordinates; el.center() and vision.find().center both give you one. Travel is interpolated.
clip
clip.read() / clip.write(text).
vision
Covered on the Automation page. The surface:
vision.text({region}?) | Every word, with a rectangle and a confidence. |
vision.find(phrase, opts?) / findAll | null when absent. {region, exact}. |
vision.waitForText(phrase, opts?) | Throws, and says what was readable. |
vision.findImage(template, opts?) / bestImageMatch | {path} or {base64}; {threshold, region}. |
vision.waitForStill(opts?) / waitForChange(opts?) | The pixels settled, or something moved. |
Every result carries rect and center in screen coordinates, even when you passed a region.
browser
Drives the browser already installed on the machine; nothing is downloaded.
const b = browser.open({ session: "dealer-42", headless: true });
b.goto("https://portal.example/login");
if (b.exists("#username")) {
b.type("#username", env.PORTAL_USER);
b.type("#password", env.PORTAL_PASSWORD);
b.click("button[type=submit]");
}
b.waitFor("#invoices");
const file = b.download(() => b.click("#export-csv"));
for (const row of csv.read(file)) job.emit(row);
b.close();browser.open(opts?) | {session, headless, timeout, url, width, height, proxy, path, args}. A named session keeps its profile between runs; without one it is discarded at the end of the run. |
browser.available() / browser.path() | |
goto(url) / url() / title() | |
waitFor(sel, {timeout, visible}?) / exists(sel) | |
text(sel) / texts(sel) / attr(sel, name) / html(sel?) | |
click(sel) / type(sel, text) / select(sel, value) / press(key) | |
eval(js) | Runs in the page and returns JSON. |
screenshot(sel?) / pdf() | Bytes, ready for artifact.put. |
download(fn, {timeout}?) | Runs fn, waits for the file to finish, returns its path. |
cookies() / setHeaders(obj) | Hand the session to http.fetch. |
profile() / downloadDir() / close() | Downloads land in job.outDir when there is one. |
Every selector call waits for the element to be visible, not merely to exist. A browser you forget to close is closed when the run ends.
vm
The VirtualBox guests this machine hosts. A host switches a guest on and asks for a run on it; the guest is an ordinary enrolled machine with its own credential and its own socket, so that run's log, trace and artifacts are read from its run and never come back through the host.
const g = vm.ensure("lab-1"); // on, provisioned, connected
const run = g.run("nightly", { params: { date: day } });
if (run.status !== "done") log.warn("lab-1: " + run.status + " " + (run.error || ""));
g.stop(); // saves its statevm.ensure(name, opts?) | Built if there is none, running, holding an agent, and connected. Blocks; default 10 minutes. |
vm.available() | {installed, version, path, error?}. Answers rather than throwing, so one script can suit machines with a hypervisor and machines without. |
vm.install({version}?) | Put VirtualBox on this machine. Maintenance lane only. |
vm.list() | [{name, state, running, enrolled, agent_id, online}] — what VirtualBox knows joined to what the control plane knows. |
vm.get(name) / vm.state(name) | A handle without touching the machine, null if VirtualBox has never heard of it; and its power state. |
vm.start(name) | Presses the power button and returns. Already running is not an error. |
vm.stop(name, {mode}?) | save (default), acpi, or force. |
vm.snapshot(name, snapshot) / vm.restore(name, snapshot) | Restoring needs the VM stopped. |
A guest
name / state() | What VirtualBox says. |
online() / agentId() | What the control plane says. Only this one predicts whether a run will be picked up. |
start() / stop(mode?) / waitOnline({timeout}?) | |
run(script, opts?) | The guest's run: {id, agent_id, script, status, error, exit_code, cost_usd, queued_at, ended_at}. |
run options: params, version (the active one by default), webhook, timeoutMs for the guest run's own wall clock, wait, and timeout for how long to wait here. It waits by default, because there are no promises in this runtime; { wait: false } returns the queued run instead. A guest run that failed comes back rather than throwing — branch on status, the way you would on an HTTP one.
The host's own run is billed for every second it sits waiting, and holds that machine's desk lease unless the script was deployed --parallel.
vm.restorerolls back the guest's enrolment too. A snapshot taken before it enrolled restores a machine with no credential, which will never connect again. Snapshot after.- A guest is matched to its agent by name: the VM name against the guest's Windows hostname, uppercased and truncated to 15 characters the way Windows does it. A hostname changed by hand simply will not be found.
ensureclimbs four rungs and skips the ones already done: build the VM if there is none, switch it on, install the agent into it, wait for that agent to connect. Installing looks first and leaves an installed guest alone. It needs the fleet to allow guests and the host to hold a Windows account for that guest; where it does not, install the agent in the guest yourself andensurestarts it and waits.- The lifecycle calls are VirtualBox on this machine, so they work with the control plane unreachable.
run,waitOnlineand the waiting half ofensuredo not.
Resetting a guest to a snapshot for every run is in tips & recipes.
Building a guest
When there is no VM of that name, ensure builds one: it runs Windows setup unattended from installation media, then carries on up the ladder. That takes 20 minutes to a few hours, and none of it is billed.
const g = vm.ensure("lab-2", {
iso: "\\\\fileserver\\images\\win11.iso",
imageIndex: 6,
});timeout | How long to wait for the guest's agent to connect. Default 10 minutes. vm.ensure(name, 600000) still means this. |
iso | A path on that machine, a UNC share, or an http(s) URL. Omitted uses the fleet's Windows installation media. |
sha256 | The digest to check it against. Required when iso is a URL; optional and honoured for a path. Given without iso, it throws. |
imageIndex | Which edition inside the ISO, counting from 1. 0 lets the media decide. |
- Media decides nothing for a guest that already exists. Building happens only when the VM is absent, so naming a different ISO does not re-image a working guest.
- An option this call does not know is refused. A misspelled
isoquietly falling back to the fleet's would install the wrong Windows and say nothing about it for an hour. imageIndex: 0takes the only edition, or the(Desktop Experience)one on a Server ISO. A client ISO carrying Home, Pro and Enterprise is refused with the editions listed — which one you are licensed for is not a technical question. The fleet's index describes the fleet's ISO and is not applied to media a script named.- A URL is fetched once into a cache on that machine, verified, and reused by every later guest there — the second one costs no download. It is re-hashed before each use, not trusted for its name.
- The VM name becomes the guest's Windows computer name, so it is at most 15 characters. Longer is refused before the hour is spent, not after: Windows would truncate it and the guest would come online under a name its host does not match.
- The guest gets a local account with a generated password, held on the host. Building it stores that account first, so an install interrupted halfway leaves a machine somebody can still sign in to.
Installing VirtualBox
vm.install({version}?) puts the hypervisor on this machine — the rung below owning guests at all. It returns {version, path, already_present, installed, reboot_pending, warnings}, and its time is not billed.
const r = vm.install();
if (r.already_present) log.info("VirtualBox " + r.version + " was already here");
vm.ensure("lab-2");- Maintenance lane only. It loads a kernel driver, so it needs the fleet's Allow maintenance-lane scripts setting and a script deployed to run in that lane. Anywhere else it refuses on the line that called it.
- It drops the machine's network for a few seconds while the bridged-networking driver re-initialises every adapter — this run's own connection included. Log before you call it, not during.
- A VirtualBox that was already there is left exactly as it is, and
already_presentsays so. That is also what stops an uninstall from removing somebody else's software. warningslists anything else holding the processor's virtualization — Hyper-V, VBS, HVCI, WSL2. Not errors: the install was still right, but a guest that takes three hours instead of twenty minutes looks exactly like a hang unless somebody was told. They reach the run's log too.- A pinned version is installed by default.
{version: "latest"}takes whatever Oracle published today.
artifact
artifact.put(name, bytes, contentType?), artifact.putFile(name, path, opts?), artifact.putFolder(path, opts?). See Results are files.
What stops a run
| Bound | Catches |
|---|---|
| Cost ceiling | Everything, eventually. The one bound you cannot turn off. |
| Stall timeout | A run doing nothing. Default 15 minutes. |
| Livelock check | A run repeating the same action against the same target. Anything different resets it, including a log line. Tune with loopRepeats, or -1 to switch it off. |
| Wall clock | Only if you set one. The default is none. |