@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.
- package/index.mjs +291 -118
- package/index_ms.mjs +10 -10
- package/index_msa.mjs +10 -10
- package/package.json +1 -1
- package/{xudex_engines.mjs → xucode_engines.mjs} +74 -7
- package/{xudex_mirror.mjs → xucode_mirror.mjs} +115 -8
- package/{xudex_preview.mjs → xucode_preview.mjs} +5 -5
- package/xucode_proxy.mjs +426 -0
- package/{xudex_run.mjs → xucode_run.mjs} +111 -14
- package/{xudex_runtime.mjs → xucode_runtime.mjs} +13 -7
- package/{xudex_tracker.mjs → xucode_tracker.mjs} +40 -10
- package/{xudex_verify.mjs → xucode_verify.mjs} +5 -5
- package/{xudex_vm.mjs → xucode_vm.mjs} +69 -21
package/xucode_proxy.mjs
ADDED
|
@@ -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
|
|
1
|
+
// A xucode run: the piece that makes the other five worth having.
|
|
2
2
|
//
|
|
3
|
-
// docs/plans/
|
|
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.
|
|
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/
|
|
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,
|
|
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
|
-
...
|
|
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.
|
|
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.
|