@ruvector/ruvllm-gpu-loop 1.0.0 → 1.1.1

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/GPU-LOOP.md CHANGED
@@ -159,12 +159,18 @@ the spool adapter; recalculate and review its pin after any code change.
159
159
  | `diskGb`, `armDeadlineSeconds` | Rental disk; per-arm worker deadline |
160
160
  | `guardGraceSeconds` | Extra time after `timeoutSeconds` before the independent destroy timer fires |
161
161
  | `resultsRoot`, `sshKey`, `repoRoot`, `python`, `node` | Absolute local paths for receipts, retrieval and local re-scoring |
162
+ | `sshKnownHosts` | Absolute path to an owner-managed `known_hosts` file (not group/world-writable) holding the pinned host keys; ssh runs with `StrictHostKeyChecking=yes` and refuses unknown or changed keys |
163
+ | `sshTrustOnFirstUse` | `true` to accept a rental's first host key into a fresh per-instance file (the pre-1.1.0 behaviour). One of `sshKnownHosts` or `sshTrustOnFirstUse: true` is required; the config is rejected (`VAST_HOST_CONFIG_SSH_TRUST`) before anything is rented otherwise |
164
+ | `maxEvidenceBytes`, `maxEvidenceEntries` | Optional caps on the retrieved evidence archive (defaults 2 GiB and 20000 entries) |
162
165
 
163
166
  Jobs must use `resource.offerId`. Submit refuses the offer when its hourly price
164
167
  times `timeoutSeconds` exceeds `maxCostUsd`, labels the rental with the attempt's
165
168
  idempotency key, and arms a `systemd-run --user` destroy timer. Inspect reports
166
169
  accrued cost, pulls raw outputs when the remote run is done, and re-scores them
167
- locally; only an identical report counts as verified completion. Cleanup returns
170
+ locally. The pull stops at `maxEvidenceBytes`, and every tar header is checked
171
+ before extraction: only regular files and directories, only relative paths under
172
+ `out/` or `run/` with no `..`, within the entry and size caps. A rejected archive
173
+ is never extracted, fails the attempt and is recorded in `evidence-rejected.json`; only an identical report counts as verified completion. Cleanup returns
168
174
  `released: true` only after the instance is absent from the account listing.
169
175
  Receipts (`submission.json`, `progress.json`, `verification.json`,
170
176
  `cleanup-ID.json`, `guard-ID.json`) contain no credential. Copy the module to a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ruvector/ruvllm-gpu-loop",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "description": "RuVLLM GPU job loop: bounded weight experiments, a durable GPU job scheduler with a Vast.ai host adapter, adapter archives and verified paired measurements (split out of @ruvector/ruvllm 4.0.x)",
6
6
  "license": "Apache-2.0",
@@ -64,7 +64,7 @@
64
64
  "access": "public"
65
65
  },
66
66
  "dependencies": {
67
- "@metaharness/darwin": "^0.12.0",
67
+ "@metaharness/darwin": "^0.13.0",
68
68
  "@metaharness/flywheel": "^0.1.13",
69
69
  "@metaharness/weight-eft": "^0.1.2"
70
70
  }
package/src/engine.mjs CHANGED
@@ -87,6 +87,23 @@ export async function fileDigest(path) {
87
87
  } finally { await file.close(); }
88
88
  }
89
89
 
90
+ // Digest cache for the pinned native binary: re-hash only when the file's
91
+ // identity or change metadata differs (dev, inode, size, mtime, ctime in ns).
92
+ // Any write to the file updates ctime, which callers cannot set.
93
+ const digestCache = new Map();
94
+ async function verifiedDigest(path) {
95
+ const info = await lstat(path, { bigint: true });
96
+ assert(info.isFile(), 'Expected regular file');
97
+ const identity = [info.dev, info.ino, info.size, info.mtimeNs, info.ctimeNs].join(':');
98
+ const cached = digestCache.get(path);
99
+ if (cached?.identity === identity) return cached.digest;
100
+ const digest = await fileDigest(path);
101
+ const after = await lstat(path, { bigint: true });
102
+ if ([after.dev, after.ino, after.size, after.mtimeNs, after.ctimeNs].join(':') === identity) digestCache.set(path, { identity, digest });
103
+ else digestCache.delete(path);
104
+ return digest;
105
+ }
106
+
90
107
  async function identicalFiles(a, b) {
91
108
  const first = await open(a, constants.O_RDONLY | constants.O_NOFOLLOW), second = await open(b, constants.O_RDONLY | constants.O_NOFOLLOW);
92
109
  try {
@@ -118,12 +135,12 @@ async function runtime(configPath) {
118
135
  assert(!inside(config.writeRoot, configPath) && !inside(config.writeRoot, config.binaryPath), 'Runtime config and binary must be outside write root');
119
136
  const stat = await lstat(configPath); assert((stat.mode & 0o077) === 0, 'Runtime config must be owner-only');
120
137
  assert(((await lstat(config.writeRoot)).mode & 0o077) === 0, 'Write root must be private');
121
- assert(await fileDigest(config.binaryPath) === config.binarySha256, 'Binary digest mismatch');
138
+ assert(await verifiedDigest(config.binaryPath) === config.binarySha256, 'Binary digest mismatch');
122
139
  return { ...config, configSha256: sha256(bytes) };
123
140
  }
124
141
 
125
142
  async function native(config, request) {
126
- assert(await fileDigest(config.binaryPath) === config.binarySha256, 'Binary digest mismatch');
143
+ assert(await verifiedDigest(config.binaryPath) === config.binarySha256, 'Binary digest mismatch');
127
144
  const input = JSON.stringify(request); assert(Buffer.byteLength(input) <= 64 * 1024, 'Native request limit');
128
145
  return new Promise((accept, reject) => {
129
146
  const environment = Object.fromEntries(['PATH', 'HOME', 'TMPDIR', 'LANG', 'LC_ALL'].filter(k => process.env[k]).map(k => [k, process.env[k]]));
package/src/gpu-host.mjs CHANGED
@@ -3,11 +3,61 @@
3
3
  import { createHash } from 'node:crypto';
4
4
  import { constants } from 'node:fs';
5
5
  import { open } from 'node:fs/promises';
6
+ import nodeModule from 'node:module';
6
7
  import { pathToFileURL } from 'node:url';
7
8
  import { checkedPath } from './engine.mjs';
8
9
 
9
10
  function failure(code) { const error = new Error(code); error.code = code; return error; }
10
11
 
12
+ // Import the entry module from the bytes that were hashed, not by re-reading the
13
+ // path: a loader hook answers the pinned URL with those exact bytes, so a file
14
+ // swapped after the check is never executed. Relative imports still resolve
15
+ // next to the original file (they are trusted operator code, as before).
16
+ const ASYNC_HOOKS = 'data:text/javascript,' + encodeURIComponent(`
17
+ const sources = new Map();
18
+ export async function initialize({ port }) {
19
+ port.on('message', ({ href, source }) => { sources.set(href, source); port.postMessage(href); });
20
+ port.unref();
21
+ }
22
+ export async function load(url, context, nextLoad) {
23
+ const source = sources.get(url);
24
+ if (source === undefined) return nextLoad(url, context);
25
+ sources.delete(url);
26
+ return { format: 'module', source, shortCircuit: true };
27
+ }`);
28
+ let asyncPort;
29
+ const pending = new Map();
30
+
31
+ function asyncHooks() {
32
+ if (!asyncPort) {
33
+ const { port1, port2 } = new MessageChannel();
34
+ port1.on('message', href => { pending.get(href)?.(); pending.delete(href); });
35
+ port1.unref();
36
+ nodeModule.register(ASYNC_HOOKS, { data: { port: port2 }, transferList: [port2] });
37
+ asyncPort = port1;
38
+ }
39
+ return asyncPort;
40
+ }
41
+
42
+ async function importVerified(href, bytes) {
43
+ if (typeof nodeModule.registerHooks === 'function') {
44
+ const hooks = nodeModule.registerHooks({
45
+ load(url, context, nextLoad) {
46
+ if (url === href) return { format: 'module', source: bytes, shortCircuit: true };
47
+ return nextLoad(url, context);
48
+ },
49
+ });
50
+ try { return await import(href); } finally { hooks.deregister(); }
51
+ }
52
+ // Node 22 before module.registerHooks (22.15): off-thread loader hooks.
53
+ const port = asyncHooks();
54
+ port.ref();
55
+ try {
56
+ await new Promise(resolve => { pending.set(href, resolve); port.postMessage({ href, source: bytes }); });
57
+ } finally { port.unref(); }
58
+ return import(href);
59
+ }
60
+
11
61
  export async function loadHostAdapter({ modulePath, sha256 }) {
12
62
  if (typeof sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(sha256)) throw failure('HOST_ADAPTER_INVALID_PIN');
13
63
  let path, bytes;
@@ -35,7 +85,7 @@ export async function loadHostAdapter({ modulePath, sha256 }) {
35
85
  // The entry point and its transitive imports are trusted operator code. The
36
86
  // digest is a cache identity as well as a pin; never accept tool-supplied code.
37
87
  const url = pathToFileURL(path); url.searchParams.set('sha256', sha256);
38
- module = await import(url.href);
88
+ module = await importVerified(url.href, bytes);
39
89
  } catch { throw failure('HOST_ADAPTER_UNAVAILABLE'); }
40
90
  if (!['submit', 'inspect', 'cleanup'].every(name => typeof module[name] === 'function')) {
41
91
  throw failure('HOST_ADAPTER_INVALID_CONTRACT');
@@ -15,6 +15,10 @@ import { fileURLToPath } from 'node:url';
15
15
  export const HOST_CONFIG_ENV = 'RUVLLM_VAST_HOST_CONFIG';
16
16
  const SELF = fileURLToPath(import.meta.url);
17
17
  const INSPECT_BUDGET_MS = 24000;
18
+ // Evidence comes from a rented host and is untrusted until re-scored here.
19
+ const DEFAULT_MAX_EVIDENCE_BYTES = 2 * 1024 ** 3;
20
+ const DEFAULT_MAX_EVIDENCE_ENTRIES = 20000;
21
+ const EVIDENCE_ROOTS = new Set(['out', 'run']);
18
22
  const fail = code => { const error = new Error(code); error.code = code; throw error; };
19
23
  const need = (ok, code) => { if (!ok) fail(code); };
20
24
  const abs = value => typeof value === 'string' && isAbsolute(value) && resolve(value) === value;
@@ -45,6 +49,17 @@ export async function readHostConfig(path = process.env[HOST_CONFIG_ENV]) {
45
49
  need(config.adapterDir === undefined || (/^[a-z0-9][a-z0-9._-]{0,63}$/.test(config.adapterDir) && !config.adapterDir.includes('..')), 'VAST_HOST_CONFIG_ADAPTER');
46
50
  need(config.trainPayload === undefined || /^experiments\/model-improvement\/training\/[a-z0-9][a-z0-9._-]{0,63}$/.test(config.trainPayload), 'VAST_HOST_CONFIG_PAYLOAD');
47
51
  need(config.secrets && typeof config.secrets === 'object' && Object.values(config.secrets).every(entry => abs(entry?.file)), 'VAST_HOST_CONFIG_SECRETS');
52
+ need(config.maxEvidenceBytes === undefined || (Number.isSafeInteger(config.maxEvidenceBytes) && config.maxEvidenceBytes >= 1 << 20 && config.maxEvidenceBytes <= 64 * 1024 ** 3), 'VAST_HOST_CONFIG_EVIDENCE');
53
+ need(config.maxEvidenceEntries === undefined || (Number.isInteger(config.maxEvidenceEntries) && config.maxEvidenceEntries >= 1 && config.maxEvidenceEntries <= 1000000), 'VAST_HOST_CONFIG_EVIDENCE');
54
+ // SSH host keys: pinned in an operator-managed known_hosts file, or trust on
55
+ // first use only when explicitly enabled. Checked before anything is rented.
56
+ need(config.sshTrustOnFirstUse === undefined || typeof config.sshTrustOnFirstUse === 'boolean', 'VAST_HOST_CONFIG_SSH_TRUST');
57
+ need(config.sshKnownHosts === undefined || abs(config.sshKnownHosts), 'VAST_HOST_CONFIG_SSH_TRUST');
58
+ need(config.sshKnownHosts !== undefined || config.sshTrustOnFirstUse === true, 'VAST_HOST_CONFIG_SSH_TRUST');
59
+ if (config.sshKnownHosts !== undefined) {
60
+ const info = await stat(config.sshKnownHosts);
61
+ need(info.isFile() && (info.mode & 0o022) === 0 && info.uid === process.getuid() && info.size > 0, 'VAST_SSH_KNOWN_HOSTS_UNSAFE');
62
+ }
48
63
  return Object.freeze({ ...config, sha256 });
49
64
  }
50
65
 
@@ -59,27 +74,109 @@ async function apiKey(config, ref) {
59
74
  }
60
75
 
61
76
  // Bounded child process without a shell. Output is collected, never echoed.
62
- function defaultRun(command, args, { timeoutMs, cwd, stdoutFile, input } = {}) {
77
+ function defaultRun(command, args, { timeoutMs, cwd, stdoutFile, input, maxStdoutBytes } = {}) {
63
78
  return new Promise(resolvePromise => {
64
79
  const child = spawn(command, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] });
65
80
  const chunks = [], errors = [];
66
- let size = 0;
81
+ let size = 0, overflow = false;
67
82
  const sink = stdoutFile ? createWriteStream(stdoutFile, { flags: 'wx', mode: 0o600 }) : null;
68
83
  const flushed = sink ? new Promise(done => { sink.on('close', done); sink.on('error', done); }) : Promise.resolve();
69
84
  const timer = setTimeout(() => child.kill('SIGKILL'), Math.max(1, timeoutMs ?? 10000));
70
- if (sink) child.stdout.pipe(sink);
85
+ if (sink) {
86
+ child.stdout.on('data', chunk => {
87
+ size += chunk.length;
88
+ if (maxStdoutBytes !== undefined && size > maxStdoutBytes && !overflow) { overflow = true; child.kill('SIGKILL'); }
89
+ });
90
+ child.stdout.pipe(sink);
91
+ }
71
92
  else child.stdout.on('data', chunk => { size += chunk.length; if (size <= 8 << 20) chunks.push(chunk); });
72
93
  child.stderr.on('data', chunk => { if (errors.length < 64) errors.push(chunk); });
73
94
  child.on('error', () => {});
74
95
  child.on('close', async code => {
75
96
  clearTimeout(timer);
76
97
  await flushed;
77
- resolvePromise({ code, stdout: Buffer.concat(chunks).toString('utf8'), stderr: Buffer.concat(errors).toString('utf8') });
98
+ resolvePromise({ code: overflow ? null : code, overflow, stdout: Buffer.concat(chunks).toString('utf8'), stderr: Buffer.concat(errors).toString('utf8') });
78
99
  });
79
100
  child.stdin.end(input ?? '');
80
101
  });
81
102
  }
82
103
 
104
+ // Tar numeric field: octal text, or GNU base-256 when the high bit is set.
105
+ function tarNumber(header, offset, length) {
106
+ const field = header.subarray(offset, offset + length);
107
+ if (field[0] & 0x80) {
108
+ let value = field[0] & 0x7f;
109
+ for (const byte of field.subarray(1)) value = value * 256 + byte;
110
+ need(Number.isSafeInteger(value), 'VAST_EVIDENCE_ARCHIVE');
111
+ return value;
112
+ }
113
+ const text = field.toString('latin1').replace(/[\0 ]+$/, '').trim();
114
+ need(/^[0-7]*$/.test(text), 'VAST_EVIDENCE_ARCHIVE');
115
+ return text ? parseInt(text, 8) : 0;
116
+ }
117
+
118
+ function evidenceName(name) {
119
+ const clean = name.replace(/\/+$/, '');
120
+ const parts = clean.split('/');
121
+ need(clean.length > 0 && clean.length <= 4096 && !clean.includes('\0') && !clean.includes('\\') && !isAbsolute(clean)
122
+ && EVIDENCE_ROOTS.has(parts[0]) && parts.every(part => part !== '..' && part !== ''), 'VAST_EVIDENCE_UNSAFE_PATH');
123
+ }
124
+
125
+ // Validate every header of a retrieved tar before extracting it: regular files
126
+ // and directories only (no links or devices), relative paths under out/ or
127
+ // run/ with no `..`, and bounded entry count and total size.
128
+ async function checkEvidenceArchive(path, { maxBytes, maxEntries }) {
129
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
130
+ try {
131
+ const { size } = await handle.stat();
132
+ need(size <= maxBytes, 'VAST_EVIDENCE_TOO_LARGE');
133
+ need(size > 0 && size % 512 === 0, 'VAST_EVIDENCE_ARCHIVE');
134
+ const header = Buffer.alloc(512);
135
+ const read = async (buffer, position) => {
136
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
137
+ need(bytesRead === buffer.length, 'VAST_EVIDENCE_ARCHIVE');
138
+ };
139
+ let offset = 0, entries = 0, total = 0, longName = null, paxPath = null, ended = false;
140
+ while (offset + 512 <= size) {
141
+ await read(header, offset); offset += 512;
142
+ if (header.every(byte => byte === 0)) { ended = true; break; }
143
+ let sum = 0;
144
+ for (let i = 0; i < 512; i++) sum += i >= 148 && i < 156 ? 32 : header[i];
145
+ need(sum === tarNumber(header, 148, 8), 'VAST_EVIDENCE_ARCHIVE');
146
+ const type = header[156] === 0 ? '0' : String.fromCharCode(header[156]);
147
+ const bodySize = tarNumber(header, 124, 12);
148
+ const padded = Math.ceil(bodySize / 512) * 512;
149
+ need(offset + padded <= size, 'VAST_EVIDENCE_ARCHIVE');
150
+ if (type === 'L' || type === 'x') {
151
+ need(bodySize <= 65536, 'VAST_EVIDENCE_ARCHIVE');
152
+ const body = Buffer.alloc(bodySize); await read(body, offset);
153
+ if (type === 'L') longName = body.toString('utf8').replace(/\0.*$/s, '');
154
+ else {
155
+ for (const record of body.toString('utf8').split('\n').filter(Boolean)) {
156
+ const match = /^[0-9]+ ([^=]+)=(.*)$/s.exec(record);
157
+ need(match, 'VAST_EVIDENCE_ARCHIVE');
158
+ if (match[1] === 'path') paxPath = match[2];
159
+ }
160
+ }
161
+ offset += padded;
162
+ continue;
163
+ }
164
+ need(type === '0' || type === '7' || type === '5', 'VAST_EVIDENCE_ENTRY_TYPE');
165
+ const ustar = header.subarray(257, 263).toString('latin1') === 'ustar\0';
166
+ const field = (start, length) => header.subarray(start, start + length).toString('utf8').replace(/\0.*$/s, '');
167
+ const prefix = ustar ? field(345, 155) : '';
168
+ const name = paxPath ?? longName ?? (prefix ? `${prefix}/${field(0, 100)}` : field(0, 100));
169
+ longName = null; paxPath = null;
170
+ evidenceName(name);
171
+ entries += 1; total += bodySize;
172
+ need(entries <= maxEntries, 'VAST_EVIDENCE_TOO_MANY_ENTRIES');
173
+ need(total <= maxBytes, 'VAST_EVIDENCE_TOO_LARGE');
174
+ offset += padded;
175
+ }
176
+ need(ended && entries > 0, 'VAST_EVIDENCE_ARCHIVE');
177
+ } finally { await handle.close(); }
178
+ }
179
+
83
180
  async function writePrivate(path, value) {
84
181
  await writeFile(path, typeof value === 'string' ? value : JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
85
182
  }
@@ -118,8 +215,14 @@ export function createVastAdapter({ fetchImpl = globalThis.fetch, run = defaultR
118
215
  fail('VAST_LIST_UNBOUNDED');
119
216
  }
120
217
  const attemptDir = (config, key) => join(config.resultsRoot, key);
218
+ // Host keys: verified against the operator's pinned known_hosts file; an
219
+ // unknown or changed key is refused. Trust on first use (a fresh per-instance
220
+ // file) only when the host config sets sshTrustOnFirstUse: true.
221
+ const hostKeyOptions = (config, instance) => config.sshKnownHosts !== undefined
222
+ ? ['-o', 'StrictHostKeyChecking=yes', '-o', `UserKnownHostsFile=${config.sshKnownHosts}`, '-o', 'GlobalKnownHostsFile=/dev/null']
223
+ : ['-o', 'StrictHostKeyChecking=accept-new', '-o', `UserKnownHostsFile=${join(config.resultsRoot, `known_hosts-${instance.id}`)}`];
121
224
  const ssh = (config, instance, remote) => ['-i', config.sshKey, '-p', String(instance.ssh_port), '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8',
122
- '-o', 'StrictHostKeyChecking=accept-new', '-o', `UserKnownHostsFile=${join(config.resultsRoot, `known_hosts-${instance.id}`)}`, '-o', 'IdentitiesOnly=yes',
225
+ ...hostKeyOptions(config, instance), '-o', 'IdentitiesOnly=yes',
123
226
  `root@${instance.ssh_host}`, remote];
124
227
 
125
228
  async function submit({ job, idempotencyKey, signal }) {
@@ -159,15 +262,28 @@ export function createVastAdapter({ fetchImpl = globalThis.fetch, run = defaultR
159
262
  return { runId: String(instanceId) };
160
263
  }
161
264
 
265
+ // 'ok' | 'retry' (slow or failed pull; next tick tries again) | 'rejected'
266
+ // (archive over the caps or with unsafe entries; never extracted).
162
267
  async function retrieve(config, instance, dir, started) {
163
268
  const left = () => INSPECT_BUDGET_MS - (now() - started);
164
269
  const archive = join(dir, 'evidence.tar');
270
+ const maxBytes = config.maxEvidenceBytes ?? DEFAULT_MAX_EVIDENCE_BYTES;
271
+ const maxEntries = config.maxEvidenceEntries ?? DEFAULT_MAX_EVIDENCE_ENTRIES;
165
272
  await rm(archive, { force: true }); await rm(join(dir, 'evidence'), { recursive: true, force: true });
166
- if (left() < 4000) return false;
167
- const pulled = await run('ssh', ssh(config, instance, 'tar -C /workspace -cf - out run'), { timeoutMs: left() - 3000, stdoutFile: archive });
168
- if (pulled.code !== 0) return false;
273
+ if (left() < 4000) return 'retry';
274
+ const pulled = await run('ssh', ssh(config, instance, 'tar -C /workspace -cf - out run'), { timeoutMs: left() - 3000, stdoutFile: archive, maxStdoutBytes: maxBytes });
275
+ const reject = async reason => {
276
+ await rm(archive, { force: true });
277
+ await writePrivate(join(dir, 'evidence-rejected.json'), { schema: 'ruvllm-vast-evidence-rejected/v1', reason, maxBytes, maxEntries, at: new Date(now()).toISOString() });
278
+ return 'rejected';
279
+ };
280
+ if (pulled.overflow) return reject('VAST_EVIDENCE_TOO_LARGE');
281
+ if (pulled.code !== 0) return 'retry';
282
+ try { await checkEvidenceArchive(archive, { maxBytes, maxEntries }); }
283
+ catch (error) { if (error.code?.startsWith('VAST_EVIDENCE_')) return reject(error.code); throw error; }
169
284
  await mkdir(join(dir, 'evidence'), { mode: 0o700 });
170
- return (await run('tar', ['-xf', archive, '-C', join(dir, 'evidence'), '--no-same-owner', '--no-same-permissions'], { timeoutMs: 5000 })).code === 0;
285
+ const extracted = await run('tar', ['-xf', archive, '-C', join(dir, 'evidence'), '--no-same-owner', '--no-same-permissions'], { timeoutMs: 5000 });
286
+ return extracted.code === 0 ? 'ok' : 'retry';
171
287
  }
172
288
 
173
289
  async function verify(config, job, dir) {
@@ -230,8 +346,11 @@ export function createVastAdapter({ fetchImpl = globalThis.fetch, run = defaultR
230
346
  if (remote.state === 'running') return { status: 'running', costUsd };
231
347
  if (remote.state === 'failed') { await retrieve(config, instance, dir, started); return { status: 'failed', costUsd }; }
232
348
  if (remote.state !== 'done') return { status: 'running', costUsd };
233
- // Retain raw outputs locally before any terminal report; retry next tick on a slow pull.
234
- if (!await retrieve(config, instance, dir, started)) return { status: 'running', costUsd };
349
+ // Retain raw outputs locally before any terminal report; retry next tick on a
350
+ // slow pull; an oversized or unsafe archive fails the job.
351
+ const retrieved = await retrieve(config, instance, dir, started);
352
+ if (retrieved === 'rejected') return { status: 'failed', costUsd };
353
+ if (retrieved !== 'ok') return { status: 'running', costUsd };
235
354
  const checked = job.task === 'train' ? await verifyTraining(config, job, dir) : await verify(config, job, dir);
236
355
  return { status: 'completed', costUsd, completion: { completionVersion: 1, completionVerified: checked.ok, status: checked.ok ? 'ok' : 'error',
237
356
  exitCode: checked.ok ? 0 : 1, assertionsVerified: checked.ok, evidenceComplete: checked.ok, truncated: false, jobId: job.id,
@@ -264,11 +383,18 @@ export const submit = input => defaults.submit(input);
264
383
  export const inspect = input => defaults.inspect(input);
265
384
  export const cleanup = input => defaults.cleanup(input);
266
385
 
267
- if (process.argv[1] === SELF && process.argv[2] === 'guard') {
268
- const [instanceId, ref] = process.argv.slice(3);
386
+ // The destroy timer entry point. No top-level await, so this module can also be
387
+ // loaded with require() where Node supports require(esm).
388
+ async function guard(instanceId, ref) {
269
389
  const config = await readHostConfig();
270
390
  const released = await defaults.destroyConfirmed(config, await apiKey(config, ref), instanceId);
271
391
  await writePrivate(join(config.resultsRoot, `guard-${instanceId}.json`), { schema: 'ruvllm-vast-guard/v1', instanceId,
272
392
  confirmedAbsent: released, at: new Date().toISOString() });
273
- process.exitCode = released ? 0 : 1;
393
+ return released;
394
+ }
395
+
396
+ if (process.argv[1] === SELF && process.argv[2] === 'guard') {
397
+ const [instanceId, ref] = process.argv.slice(3);
398
+ guard(instanceId, ref).then(released => { process.exitCode = released ? 0 : 1; },
399
+ error => { console.error(error.code ?? 'VAST_GUARD_FAILED'); process.exitCode = 1; });
274
400
  }