@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 +7 -1
- package/package.json +2 -2
- package/src/engine.mjs +19 -2
- package/src/gpu-host.mjs +51 -1
- package/src/vast-host-adapter.mjs +140 -14
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
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
|
167
|
-
const pulled = await run('ssh', ssh(config, instance, 'tar -C /workspace -cf - out run'), { timeoutMs: left() - 3000, stdoutFile: archive });
|
|
168
|
-
|
|
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
|
-
|
|
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
|
|
234
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
}
|