@xuda.io/ai_module 1.1.5656 → 1.1.5658

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.
@@ -0,0 +1,200 @@
1
+ // Xudex runtime: the seam between what runs and where it runs.
2
+ //
3
+ // See docs/plans/xudex.md section 3.8. Every layer above this one (the engine, the
4
+ // verify loop, preview, git, the run tracker) talks to a WORKSPACE and must never
5
+ // know whether that workspace is a persistent VM, an ephemeral runner, a container
6
+ // or a microVM. Section 3.2 says the Proxmox VM is a v1 choice made because we
7
+ // already operate it, not the end state, and 3.9 puts a number on when it has to
8
+ // change. This file is what makes that change one implementation instead of a
9
+ // rewrite, so the rule is simple: nothing above here may reach past the workspace
10
+ // handle to a VM id, an ssh command or a host path.
11
+ //
12
+ // Built BEFORE any substrate, deliberately. An interface written after a VM exists
13
+ // is an interface shaped like a VM.
14
+ //
15
+ // ── The contracts ──────────────────────────────────────────────────────────────
16
+ //
17
+ // A SUBSTRATE is a way of getting compute:
18
+ // { kind, available(), acquire(req) -> workspace }
19
+ //
20
+ // A WORKSPACE is one project's working area, already prepared:
21
+ // {
22
+ // id, kind, project_id, dir,
23
+ // exec(argv, opts) -> { exit_code, stdout, stderr, timed_out, ms }
24
+ // read_file(rel) -> string
25
+ // write_file(rel, data) -> true
26
+ // list_dir(rel) -> [names]
27
+ // expose_port(port) -> { url } | null
28
+ // release(opts) -> true // opts.destroy: keep nothing
29
+ // }
30
+ //
31
+ // exec NEVER throws on a non-zero exit: a failing build is data the verify loop
32
+ // needs, not an exception. It throws only when the command could not be run at all.
33
+ //
34
+ // ── Why the factory ────────────────────────────────────────────────────────────
35
+ // index.mjs already owns `run_process`, whose detached process-group kill is the
36
+ // only thing that reliably stops a Codex run (Node's own spawn timeout kills the
37
+ // shim and orphans the native binary). Duplicating that here would mean two copies
38
+ // of a subtle fix, so the primitive is injected instead.
39
+
40
+ import fs from 'fs';
41
+ import path from 'node:path';
42
+
43
+ if (!global._conf) {
44
+ global._conf = (await import(path.join(process.env.XUDA_HOME, 'common', 'load_conf.mjs'))).loadConf();
45
+ }
46
+
47
+ const xudex_conf = () => global._conf.xudex || {};
48
+
49
+ // Wall clock for a single command. This is abuse layer 3 in the plan (9.3) as much
50
+ // as it is a timeout: an ephemeral workspace that dies on schedule takes a miner
51
+ // with it, so no caller is allowed to run without a deadline.
52
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
53
+
54
+ const run_timeout_ms = () => Number(xudex_conf().run_timeout_ms) || DEFAULT_TIMEOUT_MS;
55
+
56
+ // ── Path safety ────────────────────────────────────────────────────────────────
57
+ // Every relative path from above this layer is attacker-influenced: the agent
58
+ // writes the file list, and the agent is driven by whatever the customer typed.
59
+ // So a workspace path is resolved and then proven to still be inside the workspace,
60
+ // which is the only check that survives `..`, an absolute path, and a symlink that
61
+ // was created during the run.
62
+ const safe_join = function (root, rel) {
63
+ const resolved = path.resolve(root, String(rel || ''));
64
+ const rooted = path.resolve(root) + path.sep;
65
+ if (resolved !== path.resolve(root) && !resolved.startsWith(rooted)) {
66
+ throw new Error(`path escapes the workspace: ${rel}`);
67
+ }
68
+ return resolved;
69
+ };
70
+
71
+ // ── The local substrate ────────────────────────────────────────────────────────
72
+ // What UI-226 already does: a working copy on the region server's own filesystem,
73
+ // with the engine running against it in place. It is expressed through the
74
+ // interface so the seam has a real implementation from day one rather than a
75
+ // comment, and so the existing Code tab has somewhere to land.
76
+ //
77
+ // It is NOT a xudex customer substrate and must never become one. Section 3.1 is
78
+ // the reason: the verify loop installs arbitrary packages and runs arbitrary test
79
+ // suites, and this substrate's filesystem belongs to a box that also runs CouchDB
80
+ // and our cpi services. Hence the config gate below, off unless a box opts in.
81
+ const local_substrate = function ({ run_process }) {
82
+ return {
83
+ kind: 'local',
84
+
85
+ available() {
86
+ return xudex_conf().allow_local_substrate === true;
87
+ },
88
+
89
+ async acquire({ project_id, dir, timeout_ms }) {
90
+ if (!dir) throw new Error('the local substrate needs an existing working copy dir');
91
+ await fs.promises.mkdir(dir, { recursive: true });
92
+ const deadline = Number(timeout_ms) || run_timeout_ms();
93
+
94
+ return {
95
+ id: `local:${project_id}`,
96
+ kind: 'local',
97
+ project_id,
98
+ dir,
99
+
100
+ async exec(argv, opts = {}) {
101
+ if (!Array.isArray(argv) || !argv.length) throw new Error('exec needs an argv array');
102
+ const started = Date.now();
103
+ const ret = await run_process(argv[0], argv.slice(1), opts.input || null, {
104
+ cwd: opts.cwd ? safe_join(dir, opts.cwd) : dir,
105
+ env: { ...process.env, ...(opts.env || {}) },
106
+ timeout: Number(opts.timeout_ms) || deadline,
107
+ killSignal: 'SIGKILL',
108
+ onStdout: opts.onStdout,
109
+ onStderr: opts.onStderr,
110
+ });
111
+ const ms = Date.now() - started;
112
+ return {
113
+ exit_code: ret.exit_code,
114
+ stdout: ret.stdout || '',
115
+ stderr: ret.stderr || '',
116
+ // run_process reports a kill by appending to stderr rather than by a
117
+ // flag, so the deadline is re-derived here instead of being guessed by
118
+ // every caller.
119
+ timed_out: ms >= (Number(opts.timeout_ms) || deadline),
120
+ ms,
121
+ };
122
+ },
123
+
124
+ async read_file(rel) {
125
+ return await fs.promises.readFile(safe_join(dir, rel), 'utf8');
126
+ },
127
+
128
+ async write_file(rel, data) {
129
+ const target = safe_join(dir, rel);
130
+ await fs.promises.mkdir(path.dirname(target), { recursive: true });
131
+ await fs.promises.writeFile(target, data);
132
+ return true;
133
+ },
134
+
135
+ async list_dir(rel = '.') {
136
+ return await fs.promises.readdir(safe_join(dir, rel));
137
+ },
138
+
139
+ // No port can be published from a region server to the outside world, and
140
+ // pretending otherwise would let the preview layer build on something that
141
+ // only works here. Callers must handle null.
142
+ async expose_port() {
143
+ return null;
144
+ },
145
+
146
+ // Nothing to hand back: the working copy is the record, exactly as UI-226
147
+ // designed it. `destroy` is honoured so the contract is uniform, but the
148
+ // caller has to mean it.
149
+ async release(opts = {}) {
150
+ if (opts.destroy === true) await fs.promises.rm(dir, { recursive: true, force: true });
151
+ return true;
152
+ },
153
+ };
154
+ },
155
+ };
156
+ };
157
+
158
+ // ── The registry ───────────────────────────────────────────────────────────────
159
+ // Substrates register themselves here and callers ask for a workspace, not for a
160
+ // machine. `preferred_kind` exists for CI, which wants an ephemeral workspace even
161
+ // when the account owns a warm VM (plan 3.8: a long test suite must not block the
162
+ // user's next message).
163
+ export const create_runtime = function ({ run_process }) {
164
+ if (typeof run_process !== 'function') throw new Error('create_runtime needs run_process');
165
+
166
+ const substrates = new Map();
167
+ const register = (s) => { substrates.set(s.kind, s); return s; };
168
+
169
+ register(local_substrate({ run_process }));
170
+
171
+ return {
172
+ register,
173
+
174
+ list() {
175
+ return [...substrates.values()].map((s) => ({ kind: s.kind, available: s.available() }));
176
+ },
177
+
178
+ get(kind) {
179
+ return substrates.get(kind) || null;
180
+ },
181
+
182
+ // Resolve a workspace for a run. Order of preference: what the caller asked
183
+ // for, then the account's own machine, then anything available. Returning a
184
+ // clear error beats falling back silently onto a substrate the caller did not
185
+ // expect, because "it ran somewhere else" is the hardest class of bug to see.
186
+ async acquire(req = {}) {
187
+ const wanted = req.preferred_kind ? [req.preferred_kind] : [];
188
+ const order = [...wanted, 'vm', 'ephemeral', 'local'];
189
+ for (const kind of order) {
190
+ const s = substrates.get(kind);
191
+ if (s && s.available()) return await s.acquire(req);
192
+ }
193
+ throw new Error(
194
+ `no xudex runtime is available (asked for ${req.preferred_kind || 'any'}, have ${[...substrates.keys()].join(', ') || 'none'})`,
195
+ );
196
+ },
197
+ };
198
+ };
199
+
200
+ export const _internal = { safe_join, run_timeout_ms };
@@ -0,0 +1,276 @@
1
+ // Xudex run tracker: what a run cost, and whether it was a build at all.
2
+ //
3
+ // docs/plans/xudex.md 9.4. One component, three jobs, which is why it is built properly
4
+ // rather than bolted on as monitoring:
5
+ //
6
+ // 1. ABUSE DETECTION. A machine that runs arbitrary customer code is the classic mining
7
+ // target, and neither the membership floor nor the L2 gate stops someone who paid and
8
+ // verified from then mining. This is the layer that notices.
9
+ // 2. THE RUN METER. "50 runs a month" on the free tier is counted from these records.
10
+ // 3. THE BENCHMARK. Section 13 needs wall clock and tokens per task to make any
11
+ // efficiency claim falsifiable, and those are the same numbers.
12
+ //
13
+ // ── Why the detection rule is shaped the way it is ─────────────────────────────────────
14
+ // Not machine learning. The signatures are far apart and a rule you can read is a rule you
15
+ // can defend to a customer you just suspended.
16
+ //
17
+ // A BUILD: CPU spiky rather than flat, bounded in time, network front-loaded into a
18
+ // dependency install and then quiet, and a compiler, test runner or dev server
19
+ // visible in the process tree the whole time.
20
+ // MINING: sustained full CPU, flat, for the entire run, no build or test process, and
21
+ // either no network at all or a steady trickle to one destination.
22
+ //
23
+ // The rule deliberately looks for the ABSENCE OF LEGITIMATE WORK rather than the presence
24
+ // of a miner. Miners rename their binaries, so matching process names against a list of
25
+ // known miners catches only the lazy ones and fails silently forever after. "Full CPU for
26
+ // minutes with nothing that could be producing it" is a property the attacker cannot
27
+ // rename their way out of.
28
+ //
29
+ // ── A known evasion, measured rather than assumed ──────────────────────────────────────
30
+ // CPU here is SYSTEM WIDE, from /proc/stat, so on a multi-core box a miner that uses only
31
+ // some of the cores never reaches the threshold. Measured on dev 2026-08-14: a
32
+ // single-threaded busy loop peaked at 85% and would not have tripped a 90% line. It is
33
+ // much less of a gap than it first looks, because the xudex tiers are 1, 2 and 4 vCPU, so
34
+ // there are not many cores to hide behind, and an attacker throttling to stay under the
35
+ // line is an attacker earning proportionally less. It is a real gap all the same, so the
36
+ // core count is recorded on every run: thresholds cannot be tuned per box size without it,
37
+ // and tuning on real data is exactly what log-only mode is for.
38
+ //
39
+ // ── Log-only first, and this is not optional ───────────────────────────────────────────
40
+ // `xudex.tracker.enforce` is false. The tracker records a verdict and logs it and does
41
+ // nothing else, so thresholds are tuned against real builds before anything can act on
42
+ // them. The first false positive under enforcement suspends a paying customer in the
43
+ // middle of their work, and there is no good way to apologise for that.
44
+
45
+ import path from 'node:path';
46
+
47
+ if (!global._conf) {
48
+ global._conf = (await import(path.join(process.env.XUDA_HOME, 'common', 'load_conf.mjs'))).loadConf();
49
+ }
50
+
51
+ const tracker_conf = () => global._conf.xudex?.tracker || {};
52
+
53
+ // One shell command per sample, because sampling a workspace costs an exec and a run is
54
+ // sampled repeatedly. Every line is prefixed so the parser never has to guess what it is
55
+ // looking at, and each metric fails independently: a box without /proc/net/dev still
56
+ // yields CPU rather than the whole sample being lost.
57
+ export const SAMPLE_COMMAND = [
58
+ `awk '/^cpu /{print "cpu " ($2+$3+$4+$5+$6+$7+$8) " " $5}' /proc/stat 2>/dev/null`,
59
+ `awk -F'[: ]+' '/:/ && $1!~/lo/ {rx+=$3; tx+=$11} END{print "net " rx+0 " " tx+0}' /proc/net/dev 2>/dev/null`,
60
+ `awk '/^MemTotal/{t=$2} /^MemAvailable/{a=$2} END{print "mem " (t-a) " " t}' /proc/meminfo 2>/dev/null`,
61
+ `echo "cores $(nproc 2>/dev/null || echo 1)"`,
62
+ `ps -eo comm= 2>/dev/null | sort -u | tr '\\n' ',' | sed 's/^/proc /'`,
63
+ ].join('; ');
64
+
65
+ // Process names that mean real work is happening. Xudex v1 is Node and TypeScript only
66
+ // (plan section 10), so this list is deliberately small: a wider list is a wider hole,
67
+ // because every name on it is a name a miner can adopt.
68
+ export const BUILD_PROCESSES = [
69
+ 'node',
70
+ 'npm',
71
+ 'npx',
72
+ 'pnpm',
73
+ 'yarn',
74
+ 'tsc',
75
+ 'tsx',
76
+ 'jest',
77
+ 'vitest',
78
+ 'vite',
79
+ 'esbuild',
80
+ 'webpack',
81
+ 'rollup',
82
+ 'next',
83
+ 'eslint',
84
+ 'prettier',
85
+ 'git',
86
+ 'codex',
87
+ 'claude',
88
+ ];
89
+
90
+ // Parse one sample. Returns nulls rather than throwing for anything the box did not give
91
+ // us, because a partial sample is still worth having and a thrown error inside a sampler
92
+ // would take down the run it is supposed to be watching.
93
+ export const parse_sample = function (text) {
94
+ const out = { cpu_total: null, cpu_idle: null, cores: null, net_rx: null, net_tx: null, mem_used_kb: null, mem_total_kb: null, procs: [] };
95
+ for (const line of String(text || '').split('\n')) {
96
+ const parts = line.trim().split(/\s+/);
97
+ if (parts[0] === 'cpu' && parts.length >= 3) {
98
+ out.cpu_total = Number(parts[1]);
99
+ out.cpu_idle = Number(parts[2]);
100
+ } else if (parts[0] === 'cores' && parts.length >= 2) {
101
+ out.cores = Number(parts[1]) || null;
102
+ } else if (parts[0] === 'net' && parts.length >= 3) {
103
+ out.net_rx = Number(parts[1]);
104
+ out.net_tx = Number(parts[2]);
105
+ } else if (parts[0] === 'mem' && parts.length >= 3) {
106
+ out.mem_used_kb = Number(parts[1]);
107
+ out.mem_total_kb = Number(parts[2]);
108
+ } else if (parts[0] === 'proc') {
109
+ out.procs = line
110
+ .trim()
111
+ .slice(5)
112
+ .split(',')
113
+ .map((p) => p.trim())
114
+ .filter(Boolean);
115
+ }
116
+ }
117
+ return out;
118
+ };
119
+
120
+ // CPU percentage between two /proc/stat readings. Jiffies are cumulative, so a single
121
+ // sample says nothing: the interesting number is always a delta.
122
+ export const cpu_pct_between = function (prev, cur) {
123
+ if (!prev || !cur || prev.cpu_total == null || cur.cpu_total == null) return null;
124
+ const total = cur.cpu_total - prev.cpu_total;
125
+ const idle = cur.cpu_idle - prev.cpu_idle;
126
+ if (!(total > 0)) return null;
127
+ return Math.max(0, Math.min(100, ((total - idle) / total) * 100));
128
+ };
129
+
130
+ export const has_build_process = function (procs) {
131
+ if (!Array.isArray(procs) || !procs.length) return false;
132
+ return procs.some((p) => BUILD_PROCESSES.includes(String(p).toLowerCase()));
133
+ };
134
+
135
+ // The verdict. Pure, so it can be argued with in a test rather than in production.
136
+ //
137
+ // Two independent triggers, both requiring a duration or a volume rather than an instant:
138
+ // a build absolutely does peg a CPU, and it absolutely does download a lot, so anything
139
+ // that fires on a moment rather than on a pattern would flag half our own customers.
140
+ export const evaluate_run = function (record, conf = tracker_conf()) {
141
+ const reasons = [];
142
+ const cpu_pct = Number(conf.cpu_sustained_pct ?? 90);
143
+ const cpu_seconds = Number(conf.cpu_sustained_seconds ?? 180);
144
+ const max_out_mb = Number(conf.max_outbound_mb ?? 2048);
145
+
146
+ const sustained = Number(record?.cpu?.sustained_high_seconds) || 0;
147
+ const build_seen = record?.build_seen === true;
148
+
149
+ // The core rule: full CPU for minutes with nothing in the tree that could be producing
150
+ // it. Either half alone is ordinary. A long compile is high CPU WITH a compiler; an
151
+ // idle machine has no build process and no CPU. Only the pair is strange.
152
+ if (sustained >= cpu_seconds && !build_seen) {
153
+ reasons.push(`cpu above ${cpu_pct}% for ${Math.round(sustained)}s with no build or test process running`);
154
+ }
155
+
156
+ const out_mb = (Number(record?.net?.out_bytes) || 0) / (1024 * 1024);
157
+ if (out_mb > max_out_mb) {
158
+ reasons.push(`sent ${Math.round(out_mb)} MB outbound, over the ${max_out_mb} MB ceiling`);
159
+ }
160
+
161
+ return {
162
+ verdict: reasons.length ? 'flag' : 'ok',
163
+ reasons,
164
+ // Whether anything is DONE about it is a separate question from whether it looked
165
+ // wrong, and it stays separate on purpose (see the log-only note at the top).
166
+ enforce: conf.enforce === true,
167
+ };
168
+ };
169
+
170
+ // A tracker for one run. `workspace` is the runtime interface's handle (xudex_runtime.mjs),
171
+ // so this works identically on a VM, an ephemeral runner or a container, which is the
172
+ // whole reason that seam exists.
173
+ export const create_run_tracker = function ({ workspace, conf = tracker_conf(), now = () => Date.now() }) {
174
+ const samples = [];
175
+ let prev = null;
176
+ let first = null;
177
+ let last = null;
178
+ let started_ts = null;
179
+ let sustained_high_ms = 0;
180
+ let peak_cpu = 0;
181
+ let peak_mem_mb = 0;
182
+ let build_seen = false;
183
+ let last_sample_ts = null;
184
+ let cores = null;
185
+ const cpu_threshold = Number(conf.cpu_sustained_pct ?? 90);
186
+
187
+ return {
188
+ async start() {
189
+ started_ts = now();
190
+ try {
191
+ const r = await workspace.exec(['sh', '-c', SAMPLE_COMMAND], { timeout_ms: 15000 });
192
+ prev = parse_sample(r.stdout);
193
+ first = prev;
194
+ last_sample_ts = started_ts;
195
+ } catch (e) {
196
+ // Sampling is observation. It must never be the reason a customer's run fails.
197
+ console.warn(`[xudex] tracker start sample failed: ${e.message}`);
198
+ }
199
+ return true;
200
+ },
201
+
202
+ async sample() {
203
+ try {
204
+ const r = await workspace.exec(['sh', '-c', SAMPLE_COMMAND], { timeout_ms: 15000 });
205
+ const cur = parse_sample(r.stdout);
206
+ const ts = now();
207
+ const pct = cpu_pct_between(prev, cur);
208
+
209
+ if (pct != null) {
210
+ peak_cpu = Math.max(peak_cpu, pct);
211
+ // Credit the elapsed interval, not one tick, so the sustained figure means
212
+ // seconds of wall clock above the line rather than a count of samples.
213
+ if (pct >= cpu_threshold && last_sample_ts) sustained_high_ms += ts - last_sample_ts;
214
+ // A single sample below the line breaks the streak. Mining is flat by nature, so
215
+ // this costs an attacker real throughput to evade, while a spiky build resets it
216
+ // constantly, which is exactly the discrimination we want.
217
+ else sustained_high_ms = 0;
218
+ samples.push({ ts, cpu_pct: pct });
219
+ }
220
+ if (cur.mem_used_kb != null) peak_mem_mb = Math.max(peak_mem_mb, cur.mem_used_kb / 1024);
221
+ if (cur.cores != null) cores = cur.cores;
222
+ if (has_build_process(cur.procs)) build_seen = true;
223
+
224
+ prev = cur;
225
+ last = cur;
226
+ last_sample_ts = ts;
227
+ } catch (e) {
228
+ console.warn(`[xudex] tracker sample failed: ${e.message}`);
229
+ }
230
+ return true;
231
+ },
232
+
233
+ // Close the run and hand back the record. `usage` is the engine's own token count
234
+ // (normalize_codex_usage already produces this shape for codex), which is what makes
235
+ // this the same instrument the benchmark needs.
236
+ finish({ usage = {}, engine = null, model = null, meta = {} } = {}) {
237
+ const ended_ts = now();
238
+ const cpu_values = samples.map((s) => s.cpu_pct);
239
+ const mean = cpu_values.length ? cpu_values.reduce((a, b) => a + b, 0) / cpu_values.length : 0;
240
+
241
+ const record = {
242
+ docType: 'xudex_run',
243
+ ...meta,
244
+ engine,
245
+ model,
246
+ started_ts,
247
+ ended_ts,
248
+ wall_ms: started_ts ? ended_ts - started_ts : 0,
249
+ cpu: {
250
+ mean_pct: Math.round(mean * 10) / 10,
251
+ peak_pct: Math.round(peak_cpu * 10) / 10,
252
+ sustained_high_seconds: Math.round(sustained_high_ms / 1000),
253
+ samples: samples.length,
254
+ // Recorded so a threshold can later be reasoned about per box size. A system-wide
255
+ // percentage means something different on 1 vCPU than on 4, and without this the
256
+ // tuning pass would have no way to tell those runs apart.
257
+ cores: cores ?? first?.cores ?? null,
258
+ },
259
+ mem: { peak_mb: Math.round(peak_mem_mb) },
260
+ net: {
261
+ out_bytes: first && last && last.net_tx != null && first.net_tx != null ? Math.max(0, last.net_tx - first.net_tx) : 0,
262
+ in_bytes: first && last && last.net_rx != null && first.net_rx != null ? Math.max(0, last.net_rx - first.net_rx) : 0,
263
+ },
264
+ build_seen,
265
+ tokens: { input: Number(usage.input_tokens) || 0, output: Number(usage.output_tokens) || 0 },
266
+ ts: ended_ts,
267
+ };
268
+
269
+ const verdict = evaluate_run(record, conf);
270
+ record.verdict = verdict.verdict;
271
+ record.reasons = verdict.reasons;
272
+ record.enforced = verdict.verdict === 'flag' && verdict.enforce === true;
273
+ return record;
274
+ },
275
+ };
276
+ };