@xuda.io/ai_module 1.1.5658 → 1.1.5660

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,426 @@
1
+ // The Xucode provider proxy: the region server holds the credential, the machine never does.
2
+ //
3
+ // docs/plans/xucode.md 9.2 built the git mirror so the CUSTOMER'S repository token never reaches
4
+ // a machine that is running code an AI just wrote. This file is the same argument pointed at the
5
+ // other credential. Once the engine moved onto the machine (the clone fix), the only way codex
6
+ // could authenticate was for OUR platform OpenAI key to be sitting there, on a box whose whole
7
+ // purpose is executing untrusted code. A shared key with our billing behind it is a worse thing
8
+ // to leak than one customer's repo token, so it stays here and the machine gets a token that is
9
+ // worth nothing anywhere else.
10
+ //
11
+ // What the machine receives is a per-run bearer that only this endpoint understands:
12
+ //
13
+ // - it is signed, so it cannot be minted on the machine
14
+ // - it expires with the run, so a copied token dies on its own
15
+ // - it names a path allow-list, so it cannot be pointed at unrelated provider APIs
16
+ // - it is counted, so a machine that decides to mine tokens hits a wall
17
+ //
18
+ // A machine that is fully compromised can still spend the run's own budget through this endpoint
19
+ // while the run is open. That is a much smaller blast radius than handing over the key itself,
20
+ // and it is bounded in time, in path and in request count. It is not zero, and section 9.3's
21
+ // other layers are what carry the rest.
22
+ //
23
+ // ── Why a signed token and not a database row ─────────────────────────────────────────────────
24
+ // The run executes in the ai_module worker; this endpoint is served by http_module. They are
25
+ // different processes, so an in-memory handle cannot be shared, and a Couch lookup on every
26
+ // single API call would put a database round trip in front of every model request. An HMAC needs
27
+ // no shared state and no lookup: expiry does the revoking.
28
+
29
+ import crypto from 'node:crypto';
30
+ import https from 'node:https';
31
+ import http from 'node:http';
32
+ import path from 'node:path';
33
+
34
+ if (!global._conf) {
35
+ global._conf = (await import(path.join(process.env.XUDA_HOME, 'common', 'load_conf.mjs'))).loadConf();
36
+ }
37
+
38
+ const proxy_conf = () => global._conf.xucode?.proxy || {};
39
+
40
+ // ── Upstreams ─────────────────────────────────────────────────────────────────────────────────
41
+ // Only providers we actually front. An unknown provider is refused rather than defaulted, because
42
+ // defaulting here would mean forwarding a credential somewhere nobody chose.
43
+ export const UPSTREAMS = {
44
+ openai: { origin: 'https://api.openai.com', auth: 'bearer' },
45
+ anthropic: { origin: 'https://api.anthropic.com', auth: 'x-api-key' },
46
+ };
47
+
48
+ // The paths an engine legitimately needs. Anything else is refused: this endpoint exists to let
49
+ // codex do its job, not to be a general purpose OpenAI account for whoever holds the token.
50
+ // `metered` marks the ones that actually spend money, which is what the per-run cap counts.
51
+ const ALLOWED = {
52
+ openai: [
53
+ { re: /^\/v1\/responses(\/[A-Za-z0-9_-]+)?$/, methods: ['POST', 'GET', 'DELETE'], metered: true },
54
+ { re: /^\/v1\/responses\/[A-Za-z0-9_-]+\/cancel$/, methods: ['POST'], metered: false },
55
+ { re: /^\/v1\/chat\/completions$/, methods: ['POST'], metered: true },
56
+ { re: /^\/v1\/models$/, methods: ['GET'], metered: false },
57
+ ],
58
+ anthropic: [
59
+ { re: /^\/v1\/messages$/, methods: ['POST'], metered: true },
60
+ { re: /^\/v1\/messages\/count_tokens$/, methods: ['POST'], metered: false },
61
+ { re: /^\/v1\/models$/, methods: ['GET'], metered: false },
62
+ ],
63
+ };
64
+
65
+ export const match_route = function (provider, pathname, method) {
66
+ for (const rule of ALLOWED[provider] || []) {
67
+ if (rule.re.test(pathname)) {
68
+ return rule.methods.includes(String(method || '').toUpperCase()) ? { ok: true, metered: rule.metered } : { ok: false, reason: 'method not allowed on this path' };
69
+ }
70
+ }
71
+ return { ok: false, reason: 'this path is not available through Xucode' };
72
+ };
73
+
74
+ // ── The signing secret ────────────────────────────────────────────────────────────────────────
75
+ // Derived from the provider key rather than added to the secrets file, for two reasons: it needs
76
+ // no new secret to distribute to every region, and rotating the provider key invalidates every
77
+ // outstanding token for free, which is exactly what rotating a key should do. An explicit secret
78
+ // in config wins if anyone ever wants to separate the two.
79
+ export const proxy_secret = function (conf = global._conf) {
80
+ const explicit = conf?.xucode?.proxy?.secret;
81
+ if (explicit) return String(explicit);
82
+ const key = conf?.OPENAI_API_KEY || '';
83
+ if (!key) return null;
84
+ return crypto.createHash('sha256').update(`xucode-proxy/v1|${key}`).digest('hex');
85
+ };
86
+
87
+ const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
88
+ const b64url_decode = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');
89
+
90
+ const sign = (payload_b64, secret) => b64url(crypto.createHmac('sha256', secret).update(payload_b64).digest());
91
+
92
+ // ── Tokens ────────────────────────────────────────────────────────────────────────────────────
93
+ // Deliberately short field names: this value is passed through a shell environment and a TOML
94
+ // file, and a long token is one more thing that can get truncated somewhere unhelpful.
95
+ // `key_app_id` names the account database holding the customer's OWN key for this provider, and
96
+ // its absence means "use the platform key". It is an identifier, not a credential: a machine that
97
+ // steals a token can already spend that run, and the id buys nothing without database access. The
98
+ // alternative was resolving accounts inside http_module, or a broker method that returns a raw
99
+ // customer key, and that second one is exactly what xucode_resolve_api_key refuses to be.
100
+ export const mint_token = function ({ run_id, uid, project_id, engine, provider, key_app_id = null, ttl_ms, secret = proxy_secret(), now = Date.now() } = {}) {
101
+ if (!secret) return { error: 'the proxy has no signing secret (no provider key on this box)' };
102
+ if (!run_id || !provider) return { error: 'a run token needs a run id and a provider' };
103
+ if (!UPSTREAMS[provider]) return { error: `unknown provider ${provider}` };
104
+ const payload = {
105
+ r: String(run_id),
106
+ u: uid ? String(uid) : '',
107
+ p: project_id ? String(project_id) : '',
108
+ e: engine ? String(engine) : '',
109
+ v: provider,
110
+ x: now + (Number(ttl_ms) || 30 * 60 * 1000),
111
+ };
112
+ if (key_app_id) payload.k = String(key_app_id);
113
+ const body = b64url(JSON.stringify(payload));
114
+ return { token: `xc1.${body}.${sign(body, secret)}`, expires_at: payload.x };
115
+ };
116
+
117
+ export const verify_token = function (token, { secret = proxy_secret(), now = Date.now() } = {}) {
118
+ if (!secret) return { ok: false, reason: 'proxy not configured' };
119
+ const parts = String(token || '').split('.');
120
+ if (parts.length !== 3 || parts[0] !== 'xc1') return { ok: false, reason: 'malformed token' };
121
+ const expected = sign(parts[1], secret);
122
+ // Compare through timingSafeEqual, and only after a length check: it throws on a length
123
+ // mismatch, and an exception here would read as a server fault rather than a bad token.
124
+ const a = Buffer.from(expected);
125
+ const b = Buffer.from(parts[2]);
126
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return { ok: false, reason: 'bad signature' };
127
+ let claims;
128
+ try {
129
+ claims = JSON.parse(b64url_decode(parts[1]).toString('utf8'));
130
+ } catch {
131
+ return { ok: false, reason: 'malformed token' };
132
+ }
133
+ if (!claims || typeof claims !== 'object') return { ok: false, reason: 'malformed token' };
134
+ if (!UPSTREAMS[claims.v]) return { ok: false, reason: 'unknown provider' };
135
+ if (!(Number(claims.x) > now)) return { ok: false, reason: 'this run has finished' };
136
+ return { ok: true, claims };
137
+ };
138
+
139
+ // ── Whose key answers a run ───────────────────────────────────────────────────────────────────
140
+ // Two cases, decided by the token rather than by anything the machine says:
141
+ //
142
+ // the token names an account database -> that customer's own key (BYOK)
143
+ // it does not -> our platform key, for the engines we front
144
+ //
145
+ // A machine cannot move itself between those cases: the claim is signed. It cannot reach another
146
+ // account's key either, because the only account it can name is the one the token was minted for.
147
+ //
148
+ // Cached per RUN, expiring with the token, so a run costs one database read rather than one per
149
+ // model request. Caching a miss is deliberate: a key deleted mid-run should stay refused for that
150
+ // run rather than becoming a lookup on every retry.
151
+ export const create_key_resolver = function ({ db_query, platform_key = () => global._conf?.OPENAI_API_KEY || null, now = () => Date.now() } = {}) {
152
+ if (typeof db_query !== 'function') throw new Error('create_key_resolver needs db_query');
153
+ const cache = new Map();
154
+
155
+ return async function resolve_key(claims) {
156
+ if (!claims?.k) {
157
+ // No customer key for this provider. We only front OpenAI; an anthropic run that gets here
158
+ // has lost its key between mint and use, and inventing one of ours would put Anthropic
159
+ // charges on our account for a customer's run.
160
+ return claims?.v === 'openai' ? platform_key() : null;
161
+ }
162
+
163
+ const hit = cache.get(claims.r);
164
+ if (hit && hit.exp > now()) return hit.key;
165
+
166
+ let key = null;
167
+ try {
168
+ const q = await db_query(claims.k, { selector: { docType: 'xucode_key', provider: claims.v, stat: 3 }, limit: 1 });
169
+ key = q?.docs?.[0]?.api_key || null;
170
+ } catch (err) {
171
+ console.error(`[xucode-proxy] could not read the account key: ${err?.message || err}`);
172
+ return null;
173
+ }
174
+
175
+ cache.set(claims.r, { key, exp: Number(claims.x) || now() });
176
+ for (const [k, v] of cache) if (v.exp <= now()) cache.delete(k);
177
+ return key;
178
+ };
179
+ };
180
+
181
+ // ── Usage, read off the wire ──────────────────────────────────────────────────────────────────
182
+ // The engine already reports its own usage, but the engine runs on a machine we are choosing not
183
+ // to trust with a key, so its self-report is not evidence. What passes through here is, and it
184
+ // costs one scan of the bytes we are already forwarding.
185
+ //
186
+ // Streaming responses arrive as SSE, where only `data:` lines carry JSON; non-streaming ones are
187
+ // a single JSON body. Both are handled, and neither is allowed to throw: a parse failure means we
188
+ // lose a usage number, which must never mean the customer loses their run.
189
+ export const create_usage_scanner = function (on_usage) {
190
+ let buffer = '';
191
+ let plain = '';
192
+ let reported = null;
193
+
194
+ const take = (obj) => {
195
+ const u = obj?.usage || obj?.response?.usage || obj?.message?.usage;
196
+ if (!u || typeof u !== 'object') return;
197
+ const input = Number(u.input_tokens ?? u.prompt_tokens ?? 0) || 0;
198
+ const output = Number(u.output_tokens ?? u.completion_tokens ?? 0) || 0;
199
+ if (!input && !output) return;
200
+ // Providers report cumulatively within a stream, so the last complete report wins rather
201
+ // than a sum, which would multiply a long conversation by the number of events in it.
202
+ reported = { input_tokens: input, output_tokens: output };
203
+ };
204
+
205
+ return {
206
+ write(chunk) {
207
+ const text = chunk.toString('utf8');
208
+ buffer += text;
209
+ const lines = buffer.split('\n');
210
+ buffer = lines.pop() || '';
211
+ let sse = false;
212
+ for (const line of lines) {
213
+ if (!line.startsWith('data:')) continue;
214
+ sse = true;
215
+ const body = line.slice(5).trim();
216
+ if (!body || body === '[DONE]') continue;
217
+ try {
218
+ take(JSON.parse(body));
219
+ } catch {
220
+ // A split JSON event, or a keep-alive comment. Not worth reconstructing.
221
+ }
222
+ }
223
+ // A non-streaming reply has no `data:` lines at all, so keep a bounded copy to parse at
224
+ // the end. Bounded because a large embeddings-style body must not be buffered whole.
225
+ if (!sse && plain.length < 262144) plain += text;
226
+ },
227
+ end() {
228
+ if (!reported && plain) {
229
+ try {
230
+ take(JSON.parse(plain));
231
+ } catch {
232
+ // Not JSON, or truncated by the cap. Nothing to report.
233
+ }
234
+ }
235
+ if (reported && typeof on_usage === 'function') on_usage(reported);
236
+ return reported;
237
+ },
238
+ };
239
+ };
240
+
241
+ // Headers that belong to one hop and must not be copied onto the next, plus the ones we replace
242
+ // ourselves. Passing `host` through would send api.openai.com a Host of dev.xuda.ai and get a
243
+ // certificate error; passing `authorization` through would forward the run token upstream, where
244
+ // it means nothing.
245
+ // `accept-encoding` is dropped so the upstream answers in plain bytes, and that is a deliberate
246
+ // choice rather than an oversight. Cloudflare adds `Accept-Encoding: gzip` to everything it sends
247
+ // an origin, so without this the provider compresses, and a compressed body is unreadable to the
248
+ // usage scanner below: metering would silently report nothing on every real call. It also removes
249
+ // any chance of an event stream arriving gzipped, which is a slow, confusing way for streaming to
250
+ // break. The extra bytes are between two servers and cost nothing worth having.
251
+ const DROP_REQUEST_HEADERS = new Set(['host', 'authorization', 'x-api-key', 'connection', 'keep-alive', 'proxy-authorization', 'proxy-connection', 'transfer-encoding', 'upgrade', 'te', 'trailer', 'cookie', 'content-length', 'accept-encoding', 'cf-connecting-ip', 'cf-ipcountry', 'cf-ray', 'cf-visitor', 'x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host', 'x-real-ip']);
252
+ // `content-encoding` is NOT dropped. Stripping it while forwarding the bytes verbatim tells the
253
+ // caller a compressed body is plain text, and everything downstream reads gibberish.
254
+ const DROP_RESPONSE_HEADERS = new Set(['connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'trailer', 'content-length']);
255
+
256
+ const provider_error = (res, status, message, type = 'invalid_request_error') => {
257
+ if (res.headersSent) return res.end();
258
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-transform' });
259
+ res.end(JSON.stringify({ error: { message, type } }));
260
+ };
261
+
262
+ const bearer_of = (req) => {
263
+ const auth = req.headers?.authorization || '';
264
+ const m = /^Bearer\s+(.+)$/i.exec(String(auth).trim());
265
+ if (m) return m[1].trim();
266
+ // Anthropic's SDK sends the credential as x-api-key rather than a bearer, so an engine
267
+ // pointed here through ANTHROPIC_BASE_URL arrives that way.
268
+ return String(req.headers?.['x-api-key'] || '').trim() || null;
269
+ };
270
+
271
+ // ── The endpoint ──────────────────────────────────────────────────────────────────────────────
272
+ // `resolve_key` is injected because deciding WHOSE key answers a run is a policy question that
273
+ // belongs to the caller, not to a forwarder. Today it answers "ours, for codex"; when BYOK keys
274
+ // move behind this endpoint too, that is a change there and nothing here moves.
275
+ export const create_proxy = function ({ resolve_key, secret = null, request_impl = null, now = () => Date.now(), log = (msg) => console.log(msg), on_usage = null } = {}) {
276
+ if (typeof resolve_key !== 'function') throw new Error('create_proxy needs resolve_key');
277
+
278
+ // Per-run request counts, in memory. This is a blunt instrument on purpose: it is a ceiling
279
+ // that stops a runaway or a hijacked machine, not an accounting system. It lives per process,
280
+ // so with several http workers the effective ceiling is the cap times the worker count, which
281
+ // is still a ceiling and still bounded.
282
+ const counts = new Map();
283
+ const bump = (run_id, exp) => {
284
+ const cur = counts.get(run_id) || { n: 0, exp };
285
+ cur.n += 1;
286
+ cur.exp = exp;
287
+ counts.set(run_id, cur);
288
+ return cur.n;
289
+ };
290
+ const prune = () => {
291
+ const t = now();
292
+ for (const [k, v] of counts) if (v.exp <= t) counts.delete(k);
293
+ };
294
+
295
+ const secret_for = () => secret || proxy_secret();
296
+
297
+ return {
298
+ stats: () => ({ tracked_runs: counts.size }),
299
+
300
+ async handle(req, res, rel_path) {
301
+ try {
302
+ prune();
303
+
304
+ const conf = proxy_conf();
305
+ if (conf.enabled === false) return provider_error(res, 503, 'Xucode is not accepting engine traffic on this server.');
306
+
307
+ const token = bearer_of(req);
308
+ if (!token) return provider_error(res, 401, 'Missing bearer token.', 'authentication_error');
309
+
310
+ const v = verify_token(token, { secret: secret_for(), now: now() });
311
+ if (!v.ok) return provider_error(res, 401, `Xucode rejected this run token: ${v.reason}.`, 'authentication_error');
312
+
313
+ const provider = v.claims.v;
314
+ // Query strings are forwarded, but the allow-list is matched on the path alone so a
315
+ // query cannot be used to reach a different endpoint.
316
+ const q = rel_path.indexOf('?');
317
+ const pathname = q === -1 ? rel_path : rel_path.slice(0, q);
318
+ const route = match_route(provider, pathname, req.method);
319
+ if (!route.ok) {
320
+ log(`[xucode-proxy] refused ${req.method} ${pathname} for run ${v.claims.r}: ${route.reason}`);
321
+ return provider_error(res, 403, `Xucode does not allow ${req.method} ${pathname}.`);
322
+ }
323
+
324
+ if (route.metered) {
325
+ const cap = Number(conf.max_requests_per_run) || 400;
326
+ const n = bump(v.claims.r, Number(v.claims.x) || now());
327
+ if (n > cap) {
328
+ log(`[xucode-proxy] run ${v.claims.r} hit the request cap (${cap})`);
329
+ return provider_error(res, 429, 'This run has made too many model requests and has been stopped.', 'rate_limit_error');
330
+ }
331
+ }
332
+
333
+ const key = await resolve_key(v.claims);
334
+ if (!key) return provider_error(res, 502, 'Xucode has no credential for this engine on this server.', 'api_error');
335
+
336
+ const upstream = UPSTREAMS[provider];
337
+ const target = new URL(upstream.origin + rel_path);
338
+ const headers = {};
339
+ for (const [k, val] of Object.entries(req.headers || {})) {
340
+ if (!DROP_REQUEST_HEADERS.has(k.toLowerCase())) headers[k] = val;
341
+ }
342
+ headers.host = target.host;
343
+ if (upstream.auth === 'x-api-key') {
344
+ headers['x-api-key'] = key;
345
+ if (!headers['anthropic-version']) headers['anthropic-version'] = '2023-06-01';
346
+ } else {
347
+ headers.authorization = `Bearer ${key}`;
348
+ }
349
+
350
+ const client = target.protocol === 'http:' ? http : https;
351
+ const doRequest = request_impl || client.request.bind(client);
352
+
353
+ const scanner = create_usage_scanner((usage) => {
354
+ log(`[xucode-proxy] run ${v.claims.r} ${v.claims.e || provider} usage in=${usage.input_tokens} out=${usage.output_tokens}`);
355
+ if (typeof on_usage === 'function') {
356
+ try {
357
+ on_usage({ ...v.claims, ...usage });
358
+ } catch (err) {
359
+ console.error(`[xucode-proxy] usage sink failed: ${err?.message || err}`);
360
+ }
361
+ }
362
+ });
363
+
364
+ const upreq = doRequest(
365
+ {
366
+ protocol: target.protocol,
367
+ hostname: target.hostname,
368
+ port: target.port || (target.protocol === 'http:' ? 80 : 443),
369
+ path: target.pathname + target.search,
370
+ method: req.method,
371
+ headers,
372
+ },
373
+ (upres) => {
374
+ const out = {};
375
+ for (const [k, val] of Object.entries(upres.headers || {})) {
376
+ if (!DROP_RESPONSE_HEADERS.has(k.toLowerCase())) out[k] = val;
377
+ }
378
+ // http_module runs compression() in front of every route. It respects a
379
+ // no-transform directive, and without one it buffers a text/event-stream into
380
+ // silence: the customer would watch a run produce nothing until it finished.
381
+ out['cache-control'] = 'no-transform';
382
+ res.writeHead(upres.statusCode || 502, out);
383
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
384
+
385
+ upres.on('data', (chunk) => {
386
+ scanner.write(chunk);
387
+ res.write(chunk);
388
+ });
389
+ upres.on('end', () => {
390
+ scanner.end();
391
+ res.end();
392
+ });
393
+ upres.on('error', () => {
394
+ scanner.end();
395
+ res.end();
396
+ });
397
+ },
398
+ );
399
+
400
+ // A long turn is normal here, so the socket timeout has to outlast one, and the run's
401
+ // own timeout is the real bound. Too short a value shows up as a run that dies partway
402
+ // through for no visible reason.
403
+ upreq.setTimeout(Number(conf.upstream_timeout_ms) || 20 * 60 * 1000, () => {
404
+ upreq.destroy(new Error('upstream timed out'));
405
+ });
406
+
407
+ upreq.on('error', (err) => {
408
+ log(`[xucode-proxy] upstream error for run ${v.claims.r}: ${err?.message || err}`);
409
+ provider_error(res, 502, 'Xucode could not reach the model provider.', 'api_error');
410
+ });
411
+
412
+ // If the engine gives up, stop paying for the answer.
413
+ const abort = () => upreq.destroy();
414
+ req.on('aborted', abort);
415
+ res.on('close', () => {
416
+ if (!res.writableEnded) abort();
417
+ });
418
+
419
+ req.pipe(upreq);
420
+ } catch (err) {
421
+ console.error(`[xucode-proxy] ${err?.message || err}`);
422
+ provider_error(res, 500, 'Xucode could not process that request.', 'api_error');
423
+ }
424
+ },
425
+ };
426
+ };
@@ -1,6 +1,6 @@
1
- // A xudex run: the piece that makes the other five worth having.
1
+ // A xucode run: the piece that makes the other five worth having.
2
2
  //
3
- // docs/plans/xudex.md. Everything before this was a component with a test. This is the loop
3
+ // docs/plans/xucode.md. Everything before this was a component with a test. This is the loop
4
4
  // the customer actually experiences, and it is the whole product in one function:
5
5
  //
6
6
  // mirror -> workspace -> engine -> capture -> verify -> repair -> answer
@@ -33,6 +33,34 @@ export const run_branch = function (conversation_id) {
33
33
  return `xuda/${String(conversation_id || '').replace(/^cov_/, '').slice(0, 12)}`;
34
34
  };
35
35
 
36
+ // Single-quote for the machine's shell. Same rule as the VM substrate: everything crossing this
37
+ // boundary is attacker-influenced, and the one value here is a signed token, so a quoting slip
38
+ // would leak it into a log or a command line.
39
+ const shq = (value) => `'${String(value).replace(/'/g, `'\\''`)}'`;
40
+
41
+ // Files an engine needs before it starts, written wherever it asks for them.
42
+ //
43
+ // The runner does not know or care what these are: today it is a codex provider config carrying
44
+ // a proxy token, tomorrow it is whatever the next engine wants. They are written OUTSIDE the
45
+ // project directory on purpose, because anything inside it is either a change the customer gets
46
+ // blamed for or a file `git add -A` would commit on their behalf.
47
+ //
48
+ // The content crosses as base64 read from STDIN. Not decoration: `-w0` is GNU-only and the macOS
49
+ // build refuses a positional filename outright while still creating the empty output, which is
50
+ // how an earlier version of this shipped a file that read as corrupt on one platform and worked
51
+ // on the other.
52
+ export const write_launch_files = async function (workspace, files) {
53
+ if (!files?.length) return { ok: true };
54
+ const steps = files.map((f) => {
55
+ const dir = path.posix.dirname(f.path);
56
+ const b64 = Buffer.from(String(f.content), 'utf8').toString('base64');
57
+ return `mkdir -p ${shq(dir)} && printf %s ${shq(b64)} | base64 -d > ${shq(f.path)} && chmod 600 ${shq(f.path)}`;
58
+ });
59
+ const ret = await workspace.exec(['sh', '-c', steps.join(' && ')], { timeout_ms: 60000 });
60
+ if (ret.exit_code !== 0) return { ok: false, error: (ret.stderr || '').trim() || `could not write engine files (exit ${ret.exit_code})` };
61
+ return { ok: true };
62
+ };
63
+
36
64
  // git status porcelain, minus whatever was already dirty when the run started. This is UI-226's
37
65
  // decision and it is load-bearing: the change set is what the REPOSITORY says changed, never what
38
66
  // the agent claims it changed. An agent that forgets to mention a file, or invents one, cannot
@@ -53,17 +81,17 @@ export const parse_status = function (stdout) {
53
81
  return out;
54
82
  };
55
83
 
56
- export const create_runner = function ({ runtime, mirror, engines, verify, tracker_factory, run_process, git_exec, conf = () => global._conf.xudex || {}, now = () => Date.now() }) {
84
+ export const create_runner = function ({ runtime, mirror, engines, verify, tracker_factory, run_process, git_exec, conf = () => global._conf.xucode || {}, now = () => Date.now() }) {
57
85
  for (const [name, dep] of Object.entries({ runtime, mirror, engines, verify, tracker_factory, run_process, git_exec })) {
58
86
  if (!dep) throw new Error(`create_runner needs ${name}`);
59
87
  }
60
88
 
61
- const projects_root = () => conf().projects_root || '/srv/xudex';
89
+ const projects_root = () => conf().projects_root || '/srv/xucode';
62
90
  const sample_interval_ms = () => Number(conf().tracker?.sample_interval_ms) || 15000;
63
91
 
64
92
  // One engine turn. Everything about spawning a CLI lives here, so the run below reads as a
65
93
  // sequence of decisions rather than a pile of process handling.
66
- const one_turn = async function ({ workspace, launch, prompt, emit }) {
94
+ const one_turn = async function ({ workspace, launch, prompt, emit, should_abort = null }) {
67
95
  const state = engines.create_run_state();
68
96
  const feed = engines.create_stream(launch.engine, (event) => {
69
97
  state.accept(event);
@@ -74,8 +102,13 @@ export const create_runner = function ({ runtime, mirror, engines, verify, track
74
102
  env: launch.env,
75
103
  timeout_ms: Number(conf().run_timeout_ms) || 30 * 60 * 1000,
76
104
  onStdout: feed,
105
+ should_abort,
77
106
  });
78
107
 
108
+ if (ret.aborted) {
109
+ return { ...state.state, exit_code: ret.exit_code, timed_out: false, aborted: true, error: ret.abort_reason || 'This run was stopped.' };
110
+ }
111
+
79
112
  // A CLI that dies without emitting a usable error still has to produce one, or the customer
80
113
  // sees a run that simply stopped. stderr is the only thing left to say why.
81
114
  if (ret.exit_code !== 0 && !state.state.error) {
@@ -88,7 +121,7 @@ export const create_runner = function ({ runtime, mirror, engines, verify, track
88
121
  run_branch,
89
122
  parse_status,
90
123
 
91
- async run({ uid, repo, project_id, conversation_id, prompt, launch, workspace_dir, emit = null, verify_ctx = {}, max_repair_attempts } = {}) {
124
+ async run({ uid, repo, project_id, conversation_id, prompt, launch, workspace_dir, emit = null, verify_ctx = {}, verify_ctx_factory = null, max_repair_attempts } = {}) {
92
125
  const fail = (message, extra = {}) => ({ code: -1, data: message, ...extra });
93
126
  if (!prompt) return fail('There is nothing to do: the request was empty.');
94
127
  if (!launch || launch.error) return fail(launch?.error || 'That engine is not available.', { needs_key: launch?.needs_key || null });
@@ -103,24 +136,40 @@ export const create_runner = function ({ runtime, mirror, engines, verify, track
103
136
  const mirrored = await mirror.ensure({ uid, repo });
104
137
  if (mirrored.error) return fail(mirrored.error);
105
138
 
139
+ // ── 2. A workspace, THEN the repository inside it ────────────────────────────────
140
+ // Order matters and getting it wrong was a real bug: cloning on the region server and then
141
+ // acquiring a VM workspace put the working copy and the engine on two different computers,
142
+ // so the engine ran in an empty directory. The workspace is obtained first and hydrates
143
+ // ITSELF, which is the only version that holds for every substrate.
106
144
  const dir = workspace_dir || path.join(projects_root(), String(project_id));
107
- const prepared = await mirror.sync_workspace({ mirror_dir: mirrored.dir, dest: dir, branch, default_branch: repo.default_branch });
108
- if (prepared.error) return fail(prepared.error);
109
-
110
- // ── 2. A workspace to run in ─────────────────────────────────────────────────────
111
145
  let workspace;
112
146
  try {
113
- workspace = await runtime.acquire({ project_id, dir: prepared.dir });
147
+ workspace = await runtime.acquire({ uid, project_id, app_id: project_id, dir });
114
148
  } catch (err) {
115
149
  return fail('Xuda could not prepare a machine for this project. Please try again.');
116
150
  }
117
151
 
152
+ if (emit) emit({ type: 'phase', text: 'Putting the repository on the machine' });
153
+ const prepared = await mirror.hydrate_workspace({ workspace, uid, repo, branch, default_branch: repo.default_branch });
154
+ if (prepared.error) {
155
+ try {
156
+ await workspace.release();
157
+ } catch (e) {}
158
+ return fail(prepared.error);
159
+ }
160
+
118
161
  try {
119
162
  // What was already dirty before we started. Without this the run gets credited with a
120
163
  // change somebody made by hand, and revert would then throw away their work.
121
164
  const before = await workspace.exec(['git', 'status', '--porcelain', '--untracked-files=all'], { timeout_ms: 60000 });
122
165
  const pre_dirty = new Set(parse_status(before.stdout).map((f) => f.rel));
123
166
 
167
+ // Whatever the engine needs on disk before it can start. For a proxied engine this is
168
+ // the provider config carrying the run token, and without it the engine authenticates
169
+ // against nothing and the run dies on a 401 that reads like our fault.
170
+ const placed = await write_launch_files(workspace, launch.files);
171
+ if (!placed.ok) return fail('Xuda could not prepare the engine on this machine. Please try again.');
172
+
124
173
  // ── 3. The engine ──────────────────────────────────────────────────────────────
125
174
  const tracker = tracker_factory({ workspace });
126
175
  await tracker.start();
@@ -128,14 +177,37 @@ export const create_runner = function ({ runtime, mirror, engines, verify, track
128
177
  tracker.sample().catch(() => {});
129
178
  }, sample_interval_ms());
130
179
 
180
+ // The kill switch (plan 9.3 layer 6). The tracker is already watching this run; this is
181
+ // what lets it ACT on what it sees instead of writing a verdict after the cycles are
182
+ // spent. It stays shut unless `tracker.enforce` is on, and even then it waits out a
183
+ // grace period, so the normal state of this callback is to return null forever.
184
+ let killed_for = null;
185
+ const should_abort = async () => {
186
+ const live = tracker.live_verdict();
187
+ if (!live.stop) return null;
188
+ killed_for = live.reasons.join('; ');
189
+ console.warn(`[xucode] KILLING RUN uid=${uid} app=${project_id} after ${live.elapsed_seconds}s: ${killed_for}`);
190
+ return 'This run was stopped because it looked like it was doing something other than building your project. If that is wrong, please contact support.';
191
+ };
192
+
131
193
  let turn;
132
194
  try {
133
195
  if (emit) emit({ type: 'phase', text: `Running ${launch.label}` });
134
- turn = await one_turn({ workspace, launch, prompt, emit });
196
+ turn = await one_turn({ workspace, launch, prompt, emit, should_abort });
135
197
  } finally {
136
198
  clearInterval(sampler);
137
199
  }
138
200
 
201
+ // A killed run does not continue into capture, verify or the mirror. Everything after
202
+ // this point exists to hand work back to the customer, and a run we just judged as abuse
203
+ // has no work to hand back.
204
+ if (turn.aborted) {
205
+ const record = tracker.finish({ usage: turn.usage, engine: launch.engine, meta: { uid, project_id, conversation_id, branch } });
206
+ record.killed = true;
207
+ record.kill_reasons = killed_for ? killed_for.split('; ') : record.reasons;
208
+ return { code: -1, data: turn.error, killed: true, record };
209
+ }
210
+
139
211
  // A provider error is the customer's provider talking, not us, and it is shown verbatim
140
212
  // (plan 5.2). With BYOK we cannot see their balance or their rate limits, so dressing a
141
213
  // 429 up as "something went wrong" would send them looking in the wrong place.
@@ -170,6 +242,13 @@ export const create_runner = function ({ runtime, mirror, engines, verify, track
170
242
  };
171
243
 
172
244
  if (emit) emit({ type: 'phase', text: 'Checking the change against your build and tests' });
245
+
246
+ // What the project looks like, asked of the machine holding it rather than assumed from
247
+ // this server's disk. Read AFTER the engine ran on purpose: a run that adds a test script
248
+ // should be checked by it, and a plan built beforehand would miss the gate the change
249
+ // itself introduced.
250
+ const ctx = verify_ctx_factory ? await verify_ctx_factory(workspace) : verify_ctx;
251
+
173
252
  const verified = await verify.verify_with_repair({
174
253
  workspace,
175
254
  // Repair whenever the tree is red, not only when the run has files to its name. Those
@@ -180,14 +259,23 @@ export const create_runner = function ({ runtime, mirror, engines, verify, track
180
259
  repair,
181
260
  max_attempts: max_repair_attempts,
182
261
  on_step: (step) => emit && emit({ type: 'phase', text: `${step.name}: ${step.ran ? (step.ok ? 'passed' : 'failed') : 'not configured'}` }),
183
- ...verify_ctx,
262
+ ...ctx,
184
263
  });
185
264
 
186
265
  // Files are re-read after repair, because a repair changes more of them.
187
266
  const final_status = repair_turns ? await workspace.exec(['git', 'status', '--porcelain', '--untracked-files=all'], { timeout_ms: 60000 }) : after;
188
267
  const final_files = parse_status(final_status.stdout).filter((f) => !pre_dirty.has(f.rel));
189
268
 
190
- // ── 6. Close the books ──────────────────────────────────────────────────────────
269
+ // ── 6. Carry the branch back to the mirror ──────────────────────────────────────
270
+ // Without this the work exists only on the machine: the mirror would not have it, so Commit
271
+ // and Push would have nothing to send and a reclaimed machine would take the work with it.
272
+ // Only attempted when the run actually produced something.
273
+ if (final_files.length) {
274
+ const collected = await mirror.collect_from_workspace({ workspace, uid, repo, branch });
275
+ if (collected.error) console.error(`[xucode] ${collected.error} (${project_id} ${branch})`);
276
+ }
277
+
278
+ // ── 7. Close the books ──────────────────────────────────────────────────────────
191
279
  const record = tracker.finish({
192
280
  usage: turn.usage,
193
281
  engine: launch.engine,
@@ -219,6 +307,15 @@ export const create_runner = function ({ runtime, mirror, engines, verify, track
219
307
  record,
220
308
  };
221
309
  } finally {
310
+ // The run token dies with the run, but leaving it written on the machine outlives the
311
+ // point of scoping it. Best effort: a machine that cannot be reached to clean up is
312
+ // holding a token that has already expired.
313
+ if (launch.scratch_dir) {
314
+ try {
315
+ await workspace.exec(['sh', '-c', `rm -rf ${shq(launch.scratch_dir)}`], { timeout_ms: 30000 });
316
+ } catch (e) {}
317
+ }
318
+
222
319
  // Releasing without destroy: on a persistent machine the working copy IS the warmth the
223
320
  // customer is paying for, and on an ephemeral one the substrate throws the whole thing
224
321
  // away regardless.