verikun 0.4.1 → 0.7.0
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/README.md +145 -27
- package/dist/agent/cost.js +21 -10
- package/dist/agent/engine.js +8 -8
- package/dist/agent/openai.js +243 -0
- package/dist/agent/remote.js +166 -0
- package/dist/args.js +2 -0
- package/dist/bin/verikun.js +0 -0
- package/dist/cli.js +425 -108
- package/dist/drivers/adb.js +12 -0
- package/dist/drivers/index.js +5 -5
- package/dist/drivers/ios.js +358 -0
- package/dist/report.js +93 -3
- package/dist/rpc.js +46 -0
- package/dist/run.js +101 -1
- package/dist/server.js +349 -0
- package/dist/suite.js +152 -0
- package/dist/ui/ios-parse.js +149 -0
- package/dist/version.js +1 -1
- package/package.json +2 -2
package/dist/run.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.Recorder = void 0;
|
|
4
4
|
exports.isRecordable = isRecordable;
|
|
5
|
+
exports.runId = runId;
|
|
6
|
+
exports.uniqueDir = uniqueDir;
|
|
5
7
|
exports.rolloverReason = rolloverReason;
|
|
6
8
|
exports.stepName = stepName;
|
|
7
9
|
const node_fs_1 = require("node:fs");
|
|
@@ -54,11 +56,13 @@ function saveState(dir, state) {
|
|
|
54
56
|
function nowIso() {
|
|
55
57
|
return new Date().toISOString();
|
|
56
58
|
}
|
|
59
|
+
/** Timestamp id used for runs — exported so `vk suite` mints suite ids the same way. */
|
|
57
60
|
function runId() {
|
|
58
61
|
const d = new Date();
|
|
59
62
|
const p = (n) => String(n).padStart(2, '0');
|
|
60
63
|
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
|
61
64
|
}
|
|
65
|
+
/** First non-existing `<base>`, `<base>-2`, `<base>-3`, … — exported for `vk suite`. */
|
|
62
66
|
function uniqueDir(base) {
|
|
63
67
|
if (!(0, node_fs_1.existsSync)(base))
|
|
64
68
|
return base;
|
|
@@ -155,11 +159,17 @@ class Recorder {
|
|
|
155
159
|
dir;
|
|
156
160
|
step;
|
|
157
161
|
startMs;
|
|
158
|
-
|
|
162
|
+
sink;
|
|
163
|
+
constructor(state, dir, step, startMs,
|
|
164
|
+
/** Ephemeral mode (vk server): artifacts land here instead of on disk, and
|
|
165
|
+
* commit() never touches ./.verikun — the step is returned to the caller
|
|
166
|
+
* via takeEphemeral() and spliced into the CALLER's run instead. */
|
|
167
|
+
sink) {
|
|
159
168
|
this.state = state;
|
|
160
169
|
this.dir = dir;
|
|
161
170
|
this.step = step;
|
|
162
171
|
this.startMs = startMs;
|
|
172
|
+
this.sink = sink;
|
|
163
173
|
}
|
|
164
174
|
/**
|
|
165
175
|
* Open a step for a recordable command, auto-starting an implicit run if none
|
|
@@ -234,6 +244,43 @@ class Recorder {
|
|
|
234
244
|
};
|
|
235
245
|
return new Recorder(state, dir, step, Date.now());
|
|
236
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* Open a single-step recorder that never touches disk — the `vk server` path.
|
|
249
|
+
* The step (and any artifacts, e.g. failure screenshots) accumulate in memory
|
|
250
|
+
* and are handed back by takeEphemeral() for the wire; the CALLING verikun
|
|
251
|
+
* splices them into its own on-disk run via appendForeignStep. Deliberately
|
|
252
|
+
* ignores VERIKUN_NO_RUN: this is how step detail reaches the remote caller,
|
|
253
|
+
* not a local test run.
|
|
254
|
+
*/
|
|
255
|
+
static beginEphemeralStep(command, positionals, flags, platform, serial) {
|
|
256
|
+
const state = {
|
|
257
|
+
id: 'ephemeral',
|
|
258
|
+
name: 'ephemeral',
|
|
259
|
+
startedAt: nowIso(),
|
|
260
|
+
updatedAt: nowIso(),
|
|
261
|
+
platform,
|
|
262
|
+
device: serial,
|
|
263
|
+
implicit: true,
|
|
264
|
+
steps: [],
|
|
265
|
+
};
|
|
266
|
+
const step = {
|
|
267
|
+
index: 0,
|
|
268
|
+
command,
|
|
269
|
+
name: stepName(command, positionals, flags),
|
|
270
|
+
startedAt: nowIso(),
|
|
271
|
+
durationMs: 0,
|
|
272
|
+
status: 'passed',
|
|
273
|
+
exitCode: 0,
|
|
274
|
+
};
|
|
275
|
+
// No dir: ephemeral recording never touches disk (activeDir() would mkdir
|
|
276
|
+
// ./.verikun as a side effect), and writeArtifact/commit divert to the sink.
|
|
277
|
+
return new Recorder(state, '', step, Date.now(), {});
|
|
278
|
+
}
|
|
279
|
+
/** The finished ephemeral step + its in-memory artifacts. Only meaningful after
|
|
280
|
+
* finish()/finishError() on a beginEphemeralStep recorder. */
|
|
281
|
+
takeEphemeral() {
|
|
282
|
+
return { step: this.step, artifacts: this.sink ?? {} };
|
|
283
|
+
}
|
|
237
284
|
/** Attach selector / heal-tier / resolved-element / message detail to the current step. */
|
|
238
285
|
note(info) {
|
|
239
286
|
const s = this.step;
|
|
@@ -319,11 +366,17 @@ class Recorder {
|
|
|
319
366
|
}
|
|
320
367
|
}
|
|
321
368
|
writeArtifact(rel, buf) {
|
|
369
|
+
if (this.sink) {
|
|
370
|
+
this.sink[rel] = buf;
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
322
373
|
(0, node_fs_1.mkdirSync)((0, node_path_1.join)(this.dir, 'artifacts'), { recursive: true });
|
|
323
374
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(this.dir, rel), buf);
|
|
324
375
|
}
|
|
325
376
|
commit() {
|
|
326
377
|
this.step.durationMs = Date.now() - this.startMs;
|
|
378
|
+
if (this.sink)
|
|
379
|
+
return; // ephemeral: the step goes to the wire, not to disk
|
|
327
380
|
this.state.steps.push(this.step);
|
|
328
381
|
this.state.updatedAt = nowIso();
|
|
329
382
|
saveState(this.dir, this.state);
|
|
@@ -364,6 +417,53 @@ class Recorder {
|
|
|
364
417
|
Object.assign(state, patch);
|
|
365
418
|
saveState(dir, state);
|
|
366
419
|
}
|
|
420
|
+
/**
|
|
421
|
+
* Splice a step another verikun produced (a `vk server`'s ephemeral step) into
|
|
422
|
+
* the ACTIVE local run, so a remote `vk ai` run archives a report identical to
|
|
423
|
+
* a local one. Re-indexes the step, writes its artifact buffers under the new
|
|
424
|
+
* index, and rewrites the step's artifact references to match. Creates an
|
|
425
|
+
* implicit run first if none is active (parity with beginStep's auto-start).
|
|
426
|
+
*/
|
|
427
|
+
static appendForeignStep(step, artifacts = {}, ctx = {}) {
|
|
428
|
+
if (process.env.VERIKUN_NO_RUN)
|
|
429
|
+
return;
|
|
430
|
+
const dir = activeDir();
|
|
431
|
+
let state = loadState(dir);
|
|
432
|
+
if (!state) {
|
|
433
|
+
state = {
|
|
434
|
+
id: runId(),
|
|
435
|
+
name: 'run',
|
|
436
|
+
startedAt: nowIso(),
|
|
437
|
+
updatedAt: nowIso(),
|
|
438
|
+
platform: ctx.platform ?? 'android',
|
|
439
|
+
device: ctx.device,
|
|
440
|
+
session: currentSession(),
|
|
441
|
+
implicit: true,
|
|
442
|
+
steps: [],
|
|
443
|
+
};
|
|
444
|
+
(0, output_1.err)('[verikun] recording test run (implicit) — archive: `vk run archive` · discard: `vk run clear`');
|
|
445
|
+
}
|
|
446
|
+
const index = state.steps.length;
|
|
447
|
+
const spliced = { ...step, index };
|
|
448
|
+
for (const [rel, buf] of Object.entries(artifacts)) {
|
|
449
|
+
// The rel path comes from the (authenticated) server's response; still, never
|
|
450
|
+
// let it steer the write outside the run's artifacts/ directory.
|
|
451
|
+
if (!/^artifacts\/[A-Za-z0-9._-]+$/.test(rel)) {
|
|
452
|
+
(0, output_1.err)(`[verikun] skipping remote artifact with unexpected path '${rel}'`);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
const newRel = rel.replace(/\bstep-\d+-/, `step-${index}-`);
|
|
456
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.join)(dir, 'artifacts'), { recursive: true });
|
|
457
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, newRel), buf);
|
|
458
|
+
if (spliced.image === rel)
|
|
459
|
+
spliced.image = newRel;
|
|
460
|
+
if (spliced.failImage === rel)
|
|
461
|
+
spliced.failImage = newRel;
|
|
462
|
+
}
|
|
463
|
+
state.steps.push(spliced);
|
|
464
|
+
state.updatedAt = nowIso();
|
|
465
|
+
saveState(dir, state);
|
|
466
|
+
}
|
|
367
467
|
/** Downgrade the most-recently-recorded step from a failed attempt to a healed
|
|
368
468
|
* pass — the `vk ai` engine calls this after a successful repair so a
|
|
369
469
|
* self-healed leaf does not count as a failure (and the run that the engine
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// `vk server` — expose THIS machine's connected device to remote verikun clients
|
|
3
|
+
// (`vk ai/suite/install --server <url>`) over HTTP+JSON, Node's built-in http only.
|
|
4
|
+
//
|
|
5
|
+
// Security model (the server is the trust boundary, not the transport):
|
|
6
|
+
// - Mandatory bearer auth: a key is REQUIRED unless --allow-unsafe-anonymous is
|
|
7
|
+
// passed explicitly (for networks that are themselves the boundary, e.g. a
|
|
8
|
+
// private tailnet). If none is configured, one is generated and printed loudly.
|
|
9
|
+
// Comparison is crypto.timingSafeEqual over fixed-width sha256 digests.
|
|
10
|
+
// - /v1/exec accepts ONLY verikun's validated action grammar: every request runs
|
|
11
|
+
// through the SAME validateNode gate that guards `vk ai` model repairs, so only
|
|
12
|
+
// KNOWN_COMMANDS action verbs execute — never `ui`/`log`, never a shell. The
|
|
13
|
+
// server's driver is fixed at startup; client flags can never repoint the device.
|
|
14
|
+
// - /v1/install is a privileged management verb: auth PLUS --allow-install, body
|
|
15
|
+
// streamed to a server-generated temp path (the client supplies only an
|
|
16
|
+
// allowlisted extension — never a path), optional sha256 verification.
|
|
17
|
+
// - Binds 127.0.0.1 unless --bind opts into exposure. One run-token holds the
|
|
18
|
+
// device lock at a time (409 otherwise; an idle lock is taken over so a crashed
|
|
19
|
+
// caller can't wedge the box). Device endpoints are serialized via a mutex.
|
|
20
|
+
//
|
|
21
|
+
// cli.ts reaches this module via a DYNAMIC import (no static cli↔server cycle, and
|
|
22
|
+
// node:http stays off the default CLI load path); this module imports cli.ts's
|
|
23
|
+
// executeForServer statically — a one-way runtime edge.
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.buildServer = buildServer;
|
|
26
|
+
exports.cmdServer = cmdServer;
|
|
27
|
+
const node_http_1 = require("node:http");
|
|
28
|
+
const node_crypto_1 = require("node:crypto");
|
|
29
|
+
const node_fs_1 = require("node:fs");
|
|
30
|
+
const node_os_1 = require("node:os");
|
|
31
|
+
const node_path_1 = require("node:path");
|
|
32
|
+
const node_stream_1 = require("node:stream");
|
|
33
|
+
const promises_1 = require("node:stream/promises");
|
|
34
|
+
const args_1 = require("./args");
|
|
35
|
+
const errors_1 = require("./errors");
|
|
36
|
+
const drivers_1 = require("./drivers");
|
|
37
|
+
const output_1 = require("./output");
|
|
38
|
+
const ir_1 = require("./agent/ir");
|
|
39
|
+
const rpc_1 = require("./rpc");
|
|
40
|
+
const cli_1 = require("./cli");
|
|
41
|
+
const version_1 = require("./version");
|
|
42
|
+
const DEFAULT_PORT = 8391;
|
|
43
|
+
const EXEC_BODY_CAP = 1024 * 1024; // 1 MB of JSON is far beyond any leaf command
|
|
44
|
+
const INSTALL_BODY_CAP = 512 * 1024 * 1024; // 512 MB app build
|
|
45
|
+
// A silent run-token older than this may be taken over by a new one — long enough
|
|
46
|
+
// to survive a client-side compile/repair pause, short enough that a crashed
|
|
47
|
+
// caller doesn't wedge the device.
|
|
48
|
+
const LOCK_IDLE_MS = 5 * 60 * 1000;
|
|
49
|
+
/** An error that already knows its HTTP status + the client-side exit code. */
|
|
50
|
+
class HttpError extends Error {
|
|
51
|
+
status;
|
|
52
|
+
exitCode;
|
|
53
|
+
constructor(status, message, exitCode = status === 400 || status === 404 || status === 413 ? 2 : 3) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.status = status;
|
|
56
|
+
this.exitCode = exitCode;
|
|
57
|
+
this.name = 'HttpError';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function sendJson(res, status, payload) {
|
|
61
|
+
const body = JSON.stringify(payload);
|
|
62
|
+
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) });
|
|
63
|
+
res.end(body);
|
|
64
|
+
}
|
|
65
|
+
function readBody(req, cap) {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const chunks = [];
|
|
68
|
+
let size = 0;
|
|
69
|
+
req.on('data', (c) => {
|
|
70
|
+
size += c.length;
|
|
71
|
+
if (size > cap) {
|
|
72
|
+
reject(new HttpError(413, `request body exceeds ${cap} bytes`));
|
|
73
|
+
req.destroy();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
chunks.push(c);
|
|
77
|
+
});
|
|
78
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
79
|
+
req.on('error', reject);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/** Wire flags → FlagSpec[] for validateNode. Primitives are coerced to strings
|
|
83
|
+
* (a boolean flag travels as "true"); anything structured is rejected. */
|
|
84
|
+
function flagsToSpecs(flags) {
|
|
85
|
+
if (flags === undefined || flags === null)
|
|
86
|
+
return [];
|
|
87
|
+
if (typeof flags !== 'object' || Array.isArray(flags))
|
|
88
|
+
throw new HttpError(400, 'flags must be an object');
|
|
89
|
+
return Object.entries(flags).map(([name, value]) => {
|
|
90
|
+
if (typeof value === 'string')
|
|
91
|
+
return { name, value };
|
|
92
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
93
|
+
return { name, value: String(value) };
|
|
94
|
+
throw new HttpError(400, `flag '${name}' must be a string`);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function encodeArtifacts(artifacts) {
|
|
98
|
+
const out = {};
|
|
99
|
+
for (const [rel, buf] of Object.entries(artifacts))
|
|
100
|
+
out[rel] = buf.toString('base64');
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
function buildServer(config) {
|
|
104
|
+
const sha = (s) => (0, node_crypto_1.createHash)('sha256').update(s, 'utf8').digest();
|
|
105
|
+
const authorized = (req) => {
|
|
106
|
+
if (!config.authKey)
|
|
107
|
+
return true; // --allow-unsafe-anonymous
|
|
108
|
+
const m = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '');
|
|
109
|
+
if (!m)
|
|
110
|
+
return false;
|
|
111
|
+
// Fixed-width digests make the comparison length-safe as well as timing-safe.
|
|
112
|
+
return (0, node_crypto_1.timingSafeEqual)(sha(m[1]), sha(config.authKey));
|
|
113
|
+
};
|
|
114
|
+
// One active run-token owns the device; see LOCK_IDLE_MS for takeover.
|
|
115
|
+
let lock = null;
|
|
116
|
+
const acquireLock = (token) => {
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
if (lock && lock.token !== token) {
|
|
119
|
+
if (now - lock.lastSeenMs < LOCK_IDLE_MS)
|
|
120
|
+
return false;
|
|
121
|
+
(0, output_1.err)(`[server] device lock: idle run ${lock.token.slice(0, 8)}… taken over by ${token.slice(0, 8)}…`);
|
|
122
|
+
}
|
|
123
|
+
lock = { token, lastSeenMs: now };
|
|
124
|
+
return true;
|
|
125
|
+
};
|
|
126
|
+
// The device can serve one interaction at a time — serialize the device endpoints
|
|
127
|
+
// through a promise chain (requests queue in arrival order).
|
|
128
|
+
let queue = Promise.resolve();
|
|
129
|
+
const serialize = (fn) => {
|
|
130
|
+
const next = queue.then(fn, fn);
|
|
131
|
+
queue = next.catch(() => undefined);
|
|
132
|
+
return next;
|
|
133
|
+
};
|
|
134
|
+
async function handleExec(req, res) {
|
|
135
|
+
const body = await readBody(req, EXEC_BODY_CAP);
|
|
136
|
+
let parsed;
|
|
137
|
+
try {
|
|
138
|
+
parsed = JSON.parse(body.toString('utf8'));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw new HttpError(400, 'invalid JSON body');
|
|
142
|
+
}
|
|
143
|
+
// The exact gate that guards model repairs: only KNOWN_COMMANDS action verbs
|
|
144
|
+
// pass — a client cannot run `ui`, `log`, or anything outside the grammar.
|
|
145
|
+
let node;
|
|
146
|
+
try {
|
|
147
|
+
node = (0, ir_1.validateNode)({ type: 'command', command: parsed?.command, positionals: parsed?.positionals, flags: flagsToSpecs(parsed?.flags) }, 'rpc');
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
if (e instanceof ir_1.InvalidPlanError)
|
|
151
|
+
throw new HttpError(400, `rejected: ${e.message}`);
|
|
152
|
+
throw e;
|
|
153
|
+
}
|
|
154
|
+
if (node.type !== 'command')
|
|
155
|
+
throw new HttpError(400, 'rejected: not a command leaf');
|
|
156
|
+
const t0 = Date.now();
|
|
157
|
+
const { code, error, step, artifacts } = await (0, cli_1.executeForServer)(node.command, node.positionals, (0, ir_1.leafToFlags)(node), config.driver, config.platform);
|
|
158
|
+
(0, output_1.err)(`[server] exec ${node.command} ${node.positionals.join(' ')} → exit ${code} (${Date.now() - t0}ms)`);
|
|
159
|
+
const payload = {
|
|
160
|
+
code,
|
|
161
|
+
...(error ? { error: (0, rpc_1.describeError)(error) } : {}),
|
|
162
|
+
...(step ? { step } : {}),
|
|
163
|
+
...(artifacts && Object.keys(artifacts).length ? { artifacts: encodeArtifacts(artifacts) } : {}),
|
|
164
|
+
};
|
|
165
|
+
sendJson(res, 200, payload);
|
|
166
|
+
}
|
|
167
|
+
async function handleElements(req, res) {
|
|
168
|
+
await readBody(req, EXEC_BODY_CAP); // drain (the body is unused; keeps keep-alive sane)
|
|
169
|
+
const elements = config.driver.getElements(); // CliError(3) on dump failure → 500 below
|
|
170
|
+
sendJson(res, 200, { elements });
|
|
171
|
+
}
|
|
172
|
+
async function handleInstall(req, res) {
|
|
173
|
+
const ext = String(req.headers['x-verikun-ext'] ?? '').toLowerCase();
|
|
174
|
+
if (ext !== 'apk' && ext !== 'ipa') {
|
|
175
|
+
throw new HttpError(400, `x-verikun-ext must be 'apk' or 'ipa' (got '${ext || '(missing)'}')`);
|
|
176
|
+
}
|
|
177
|
+
// The temp path is server-generated — the client never supplies a path, so
|
|
178
|
+
// there is no traversal surface. Streamed (backpressured), never buffered.
|
|
179
|
+
const dir = (0, node_path_1.join)((0, node_os_1.tmpdir)(), 'verikun-server');
|
|
180
|
+
(0, node_fs_1.mkdirSync)(dir, { recursive: true });
|
|
181
|
+
const tmpPath = (0, node_path_1.join)(dir, `${(0, node_crypto_1.randomUUID)()}.${ext}`);
|
|
182
|
+
const hasher = (0, node_crypto_1.createHash)('sha256');
|
|
183
|
+
let size = 0;
|
|
184
|
+
const counter = new node_stream_1.Transform({
|
|
185
|
+
transform(chunk, _enc, cb) {
|
|
186
|
+
size += chunk.length;
|
|
187
|
+
if (size > INSTALL_BODY_CAP) {
|
|
188
|
+
cb(new HttpError(413, `install body exceeds ${INSTALL_BODY_CAP} bytes`));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
hasher.update(chunk);
|
|
192
|
+
cb(null, chunk);
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
try {
|
|
196
|
+
await (0, promises_1.pipeline)(req, counter, (0, node_fs_1.createWriteStream)(tmpPath));
|
|
197
|
+
const digest = hasher.digest('hex');
|
|
198
|
+
const expected = String(req.headers['x-verikun-sha256'] ?? '').toLowerCase();
|
|
199
|
+
if (expected && expected !== digest) {
|
|
200
|
+
throw new HttpError(400, `sha256 mismatch: upload arrived corrupted (got ${digest.slice(0, 12)}…, expected ${expected.slice(0, 12)}…)`);
|
|
201
|
+
}
|
|
202
|
+
(0, output_1.err)(`[server] install: received ${size} bytes (.${ext}), installing…`);
|
|
203
|
+
config.driver.install(tmpPath);
|
|
204
|
+
(0, output_1.err)('[server] install: done');
|
|
205
|
+
sendJson(res, 200, { ok: true, bytes: size, sha256: digest });
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
try {
|
|
209
|
+
(0, node_fs_1.unlinkSync)(tmpPath);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
/* upload may have failed before the file existed */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async function handle(req, res) {
|
|
217
|
+
const path = (req.url ?? '').split('?')[0];
|
|
218
|
+
if (req.method === 'GET' && path === '/v1/health') {
|
|
219
|
+
// Unauthenticated preflight — but if the caller DID send a key, verify it, so
|
|
220
|
+
// a client with a wrong key fails fast at ping instead of at its first step.
|
|
221
|
+
if (config.authKey && req.headers.authorization && !authorized(req)) {
|
|
222
|
+
throw new HttpError(401, 'invalid auth key');
|
|
223
|
+
}
|
|
224
|
+
const health = {
|
|
225
|
+
ok: true,
|
|
226
|
+
version: version_1.VERSION,
|
|
227
|
+
platform: config.platform,
|
|
228
|
+
serial: config.serial,
|
|
229
|
+
installEnabled: config.allowInstall,
|
|
230
|
+
};
|
|
231
|
+
sendJson(res, 200, health);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (!authorized(req))
|
|
235
|
+
throw new HttpError(401, 'missing or invalid auth key');
|
|
236
|
+
const token = String(req.headers['x-verikun-run'] ?? '(anonymous)');
|
|
237
|
+
const deviceEndpoint = (fn) => serialize(async () => {
|
|
238
|
+
if (!acquireLock(token)) {
|
|
239
|
+
throw new HttpError(409, 'device is locked by another active run — retry when it finishes');
|
|
240
|
+
}
|
|
241
|
+
await fn();
|
|
242
|
+
});
|
|
243
|
+
if (req.method === 'POST' && path === '/v1/release') {
|
|
244
|
+
// A finished client frees its lock so the NEXT command (a fresh run token)
|
|
245
|
+
// proceeds immediately instead of waiting out the idle takeover. Serialized
|
|
246
|
+
// so it lands after any in-flight request; only the holder can release.
|
|
247
|
+
return serialize(async () => {
|
|
248
|
+
await readBody(req, EXEC_BODY_CAP); // drain
|
|
249
|
+
const released = lock !== null && lock.token === token;
|
|
250
|
+
if (released)
|
|
251
|
+
lock = null;
|
|
252
|
+
sendJson(res, 200, { ok: true, released });
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
if (req.method === 'POST' && path === '/v1/exec')
|
|
256
|
+
return deviceEndpoint(() => handleExec(req, res));
|
|
257
|
+
if (req.method === 'POST' && path === '/v1/elements')
|
|
258
|
+
return deviceEndpoint(() => handleElements(req, res));
|
|
259
|
+
if (req.method === 'POST' && path === '/v1/install') {
|
|
260
|
+
if (!config.allowInstall) {
|
|
261
|
+
throw new HttpError(403, 'install is disabled on this server (start it with --allow-install)', 3);
|
|
262
|
+
}
|
|
263
|
+
return deviceEndpoint(() => handleInstall(req, res));
|
|
264
|
+
}
|
|
265
|
+
throw new HttpError(404, `unknown endpoint ${req.method} ${path}`);
|
|
266
|
+
}
|
|
267
|
+
const server = (0, node_http_1.createServer)((req, res) => {
|
|
268
|
+
const started = Date.now();
|
|
269
|
+
handle(req, res)
|
|
270
|
+
.catch((e) => {
|
|
271
|
+
const mapped = e instanceof HttpError
|
|
272
|
+
? e
|
|
273
|
+
: e instanceof errors_1.CliError
|
|
274
|
+
? new HttpError(e.exitCode === 2 ? 400 : 500, e.message, e.exitCode)
|
|
275
|
+
: new HttpError(500, e.message || 'internal error', 3);
|
|
276
|
+
if (!res.headersSent) {
|
|
277
|
+
const body = { error: mapped.message, exitCode: mapped.exitCode };
|
|
278
|
+
sendJson(res, mapped.status, body);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
res.destroy();
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
.finally(() => {
|
|
285
|
+
(0, output_1.err)(`[server] ${req.method} ${(req.url ?? '').split('?')[0]} → ${res.statusCode} (${Date.now() - started}ms)`);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
// A 512 MB upload over a slow link can legitimately exceed Node's 5-minute
|
|
289
|
+
// default request window.
|
|
290
|
+
server.requestTimeout = 30 * 60 * 1000;
|
|
291
|
+
return server;
|
|
292
|
+
}
|
|
293
|
+
async function cmdServer(positionals, flags) {
|
|
294
|
+
if (positionals.length > 0) {
|
|
295
|
+
throw new errors_1.CliError(`server: unexpected argument '${positionals[0]}'. Usage: verikun server [--bind addr] [--port n] [--auth-key k] [--allow-install] [--allow-unsafe-anonymous]`, 2);
|
|
296
|
+
}
|
|
297
|
+
const platform = (0, cli_1.platformFromFlags)(flags);
|
|
298
|
+
const device = (0, cli_1.deviceFromFlags)(flags, platform);
|
|
299
|
+
const bind = (0, args_1.flagStr)(flags, 'bind') || '127.0.0.1';
|
|
300
|
+
const port = (0, args_1.flagNum)(flags, 'port') ?? DEFAULT_PORT;
|
|
301
|
+
const allowInstall = (0, args_1.flagBool)(flags, 'allow-install');
|
|
302
|
+
const anonymous = (0, args_1.flagBool)(flags, 'allow-unsafe-anonymous');
|
|
303
|
+
// The env var is the documented channel for the key (keeps it out of argv/ps).
|
|
304
|
+
let authKey = (0, args_1.flagStr)(flags, 'auth-key') || process.env.VERIKUN_SERVER_AUTH_KEY || undefined;
|
|
305
|
+
if (anonymous && authKey) {
|
|
306
|
+
throw new errors_1.CliError('--allow-unsafe-anonymous cannot be combined with an auth key (--auth-key / VERIKUN_SERVER_AUTH_KEY) — pick one.', 2);
|
|
307
|
+
}
|
|
308
|
+
let generated = false;
|
|
309
|
+
if (!anonymous && !authKey) {
|
|
310
|
+
authKey = (0, node_crypto_1.randomBytes)(32).toString('base64url');
|
|
311
|
+
generated = true;
|
|
312
|
+
}
|
|
313
|
+
// Build the ONE driver the server will ever use, and fail fast (exit 2/3) if no
|
|
314
|
+
// device resolves — before binding a port.
|
|
315
|
+
const driver = (0, drivers_1.getDriver)(platform, device);
|
|
316
|
+
const serial = driver.resolvedSerial();
|
|
317
|
+
// Handlers print "tapped …" confirmations via out(); a server's stdout is not a
|
|
318
|
+
// data channel, so silence them — request logging goes to stderr instead.
|
|
319
|
+
(0, output_1.setOutputQuiet)(true);
|
|
320
|
+
const server = buildServer({ driver, platform, serial, authKey, allowInstall });
|
|
321
|
+
return new Promise((resolve, reject) => {
|
|
322
|
+
server.on('error', (e) => reject(new errors_1.CliError(`server: could not listen on ${bind}:${port} (${e.message})`, 3)));
|
|
323
|
+
server.listen(port, bind, () => {
|
|
324
|
+
(0, output_1.err)(`[server] verikun ${version_1.VERSION} listening on http://${bind}:${port}`);
|
|
325
|
+
(0, output_1.err)(`[server] device: ${platform} · ${serial}`);
|
|
326
|
+
(0, output_1.err)(`[server] install endpoint: ${allowInstall ? 'ENABLED (--allow-install)' : 'disabled (pass --allow-install to accept builds)'}`);
|
|
327
|
+
if (generated) {
|
|
328
|
+
(0, output_1.err)('[server] auth key generated for this session — clients pass it via VERIKUN_SERVER_AUTH_KEY or --auth-key:');
|
|
329
|
+
(0, output_1.err)(`[server] ${authKey}`);
|
|
330
|
+
}
|
|
331
|
+
else if (anonymous) {
|
|
332
|
+
(0, output_1.err)('[server] WARNING: --allow-unsafe-anonymous — NO AUTHENTICATION. Anyone who can reach this');
|
|
333
|
+
(0, output_1.err)('[server] address fully controls the connected device. Only use when the network');
|
|
334
|
+
(0, output_1.err)('[server] itself is the boundary (e.g. a private tailnet), never on a shared LAN.');
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
(0, output_1.err)('[server] auth: key configured');
|
|
338
|
+
}
|
|
339
|
+
(0, output_1.err)('[server] stop with Ctrl-C');
|
|
340
|
+
});
|
|
341
|
+
const close = () => {
|
|
342
|
+
(0, output_1.err)('[server] shutting down');
|
|
343
|
+
server.close();
|
|
344
|
+
resolve(0);
|
|
345
|
+
};
|
|
346
|
+
process.once('SIGINT', close);
|
|
347
|
+
process.once('SIGTERM', close);
|
|
348
|
+
});
|
|
349
|
+
}
|
package/dist/suite.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// `vk suite <dir>` — run a directory of natural-language tests sequentially as one
|
|
3
|
+
// suite: reset the app between tests, collect each test's result, and write a suite
|
|
4
|
+
// overview (index.json manifest + index.html) that links every test's archived
|
|
5
|
+
// report. Exits 1 when any test failed, so the command doubles as the CI gate.
|
|
6
|
+
//
|
|
7
|
+
// Dependency-injected like agent/engine.ts: this module imports NOTHING from cli.ts
|
|
8
|
+
// — the actual test execution (`runTest`, which is cli.ts's runAiTest bound to a
|
|
9
|
+
// local-or-remote backend) and the between-test reset come in via SuiteDeps. That
|
|
10
|
+
// keeps the enumeration/tally/manifest logic pure enough to unit-test without a
|
|
11
|
+
// device, and cli.ts free of a suite→cli import cycle.
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.sortTestFiles = sortTestFiles;
|
|
14
|
+
exports.listTestFiles = listTestFiles;
|
|
15
|
+
exports.toSuiteResult = toSuiteResult;
|
|
16
|
+
exports.cmdSuite = cmdSuite;
|
|
17
|
+
const node_fs_1 = require("node:fs");
|
|
18
|
+
const node_path_1 = require("node:path");
|
|
19
|
+
const args_1 = require("./args");
|
|
20
|
+
const errors_1 = require("./errors");
|
|
21
|
+
const output_1 = require("./output");
|
|
22
|
+
const run_1 = require("./run");
|
|
23
|
+
const report_1 = require("./report");
|
|
24
|
+
const version_1 = require("./version");
|
|
25
|
+
/** Lexicographic order, so authors sequence flows with 01-…, 02-… prefixes. */
|
|
26
|
+
function sortTestFiles(files) {
|
|
27
|
+
return [...files].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
28
|
+
}
|
|
29
|
+
/** The *.md files in a suite dir (non-recursive). README.md is documentation for
|
|
30
|
+
* the suite, not a test — skipped by convention. */
|
|
31
|
+
function listTestFiles(dir) {
|
|
32
|
+
const files = (0, node_fs_1.readdirSync)(dir).filter((f) => {
|
|
33
|
+
if (!f.toLowerCase().endsWith('.md') || f.toLowerCase() === 'readme.md')
|
|
34
|
+
return false;
|
|
35
|
+
try {
|
|
36
|
+
return (0, node_fs_1.statSync)((0, node_path_1.join)(dir, f)).isFile();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
return sortTestFiles(files);
|
|
43
|
+
}
|
|
44
|
+
/** Fold one test's AiRunResult into the manifest row (pure; unit-tested). */
|
|
45
|
+
function toSuiteResult(file, r, durationMs) {
|
|
46
|
+
const steps = r.state?.steps ?? [];
|
|
47
|
+
const passedSteps = steps.filter((s) => s.status === 'passed').length;
|
|
48
|
+
const failure = r.failure
|
|
49
|
+
? `FAIL at ${r.failure.where}: ${r.failure.reason}`
|
|
50
|
+
: r.abortedForBudget
|
|
51
|
+
? 'aborted: cost ceiling reached'
|
|
52
|
+
: r.abortedForTimeout
|
|
53
|
+
? 'aborted: run timeout reached'
|
|
54
|
+
: undefined;
|
|
55
|
+
return {
|
|
56
|
+
id: r.runDir ? (0, node_path_1.basename)(r.runDir) : '',
|
|
57
|
+
file,
|
|
58
|
+
name: (0, node_path_1.basename)(file, (0, node_path_1.extname)(file)),
|
|
59
|
+
ok: r.ok,
|
|
60
|
+
durationMs,
|
|
61
|
+
costUsd: r.costUsd,
|
|
62
|
+
steps: steps.length,
|
|
63
|
+
passedSteps,
|
|
64
|
+
failedSteps: steps.length - passedSteps,
|
|
65
|
+
modelRepairs: r.modelRepairs,
|
|
66
|
+
...(r.ok ? {} : { failure: failure ?? 'failed' }),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
async function cmdSuite(dirArg, flags, deps) {
|
|
70
|
+
const dir = (0, node_path_1.resolve)(process.cwd(), dirArg);
|
|
71
|
+
if (!(0, node_fs_1.existsSync)(dir) || !(0, node_fs_1.statSync)(dir).isDirectory()) {
|
|
72
|
+
throw new errors_1.CliError(`suite: '${dirArg}' is not a directory`, 2);
|
|
73
|
+
}
|
|
74
|
+
const files = listTestFiles(dir);
|
|
75
|
+
if (files.length === 0) {
|
|
76
|
+
throw new errors_1.CliError(`suite: no test files (*.md) in '${dirArg}'`, 2);
|
|
77
|
+
}
|
|
78
|
+
const suiteId = (0, run_1.runId)();
|
|
79
|
+
const name = (0, args_1.flagStr)(flags, 'name') || (0, node_path_1.basename)(dir);
|
|
80
|
+
const startedAt = new Date().toISOString();
|
|
81
|
+
(0, output_1.err)(`[suite] '${name}': ${files.length} test(s) from ${dirArg} (${deps.platform}${deps.device ? ` · ${deps.device}` : ''})`);
|
|
82
|
+
const results = [];
|
|
83
|
+
for (let i = 0; i < files.length; i++) {
|
|
84
|
+
const file = files[i];
|
|
85
|
+
(0, output_1.err)(`[suite] ── (${i + 1}/${files.length}) ${file} ──`);
|
|
86
|
+
if (deps.reset) {
|
|
87
|
+
try {
|
|
88
|
+
await deps.reset();
|
|
89
|
+
(0, output_1.err)('[suite] app state reset');
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
// Surface but continue: a flaky reset should not zero out the whole suite —
|
|
93
|
+
// the test itself will fail loudly if the stale state actually matters.
|
|
94
|
+
(0, output_1.err)(`[suite] reset failed (${e.message}) — continuing`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const t0 = Date.now();
|
|
98
|
+
try {
|
|
99
|
+
const r = await deps.runTest((0, node_path_1.join)(dir, file));
|
|
100
|
+
results.push(toSuiteResult(file, r, Date.now() - t0));
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
// A test that THREW (env error: device gone, server unreachable, bad file)
|
|
104
|
+
// still becomes a failed row — one broken test must not vaporize the suite
|
|
105
|
+
// report for the tests that already ran.
|
|
106
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
107
|
+
(0, output_1.err)(`[suite] ${file} errored: ${msg}`);
|
|
108
|
+
results.push({
|
|
109
|
+
id: '',
|
|
110
|
+
file,
|
|
111
|
+
name: (0, node_path_1.basename)(file, (0, node_path_1.extname)(file)),
|
|
112
|
+
ok: false,
|
|
113
|
+
durationMs: Date.now() - t0,
|
|
114
|
+
costUsd: 0,
|
|
115
|
+
steps: 0,
|
|
116
|
+
passedSteps: 0,
|
|
117
|
+
failedSteps: 0,
|
|
118
|
+
modelRepairs: 0,
|
|
119
|
+
failure: msg.split('\n')[0],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const suite = {
|
|
124
|
+
schemaVersion: 1,
|
|
125
|
+
id: suiteId,
|
|
126
|
+
name,
|
|
127
|
+
startedAt,
|
|
128
|
+
finishedAt: new Date().toISOString(),
|
|
129
|
+
platform: deps.platform,
|
|
130
|
+
device: deps.device,
|
|
131
|
+
verikun: version_1.VERSION,
|
|
132
|
+
totals: (0, report_1.suiteTotals)(results),
|
|
133
|
+
tests: results,
|
|
134
|
+
};
|
|
135
|
+
// .verikun/suites/<id>/ sits beside .verikun/runs/<id>/, so index.html reaches a
|
|
136
|
+
// test report at ../../runs/<id>/report.html — the linkBase below.
|
|
137
|
+
const outDir = (0, run_1.uniqueDir)((0, node_path_1.join)((0, output_1.artifactDir)(), 'suites', suiteId));
|
|
138
|
+
(0, node_fs_1.mkdirSync)(outDir, { recursive: true });
|
|
139
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(outDir, 'index.json'), (0, report_1.toSuiteIndexJson)(suite));
|
|
140
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(outDir, 'index.html'), (0, report_1.toSuiteHtml)(suite, { linkBase: '../../' }));
|
|
141
|
+
const t = suite.totals;
|
|
142
|
+
(0, output_1.err)(`[suite] ${t.passed}/${t.tests} passed · ${t.steps} steps · $${t.costUsd.toFixed(4)} · ${(t.durationMs / 1000).toFixed(1)}s`);
|
|
143
|
+
for (const r of results)
|
|
144
|
+
(0, output_1.err)(` ${r.ok ? 'PASS' : 'FAIL'} ${r.file}${r.failure ? ` — ${r.failure}` : ''}`);
|
|
145
|
+
(0, output_1.err)(`[suite] overview: ${(0, node_path_1.join)(outDir, 'index.html')}`);
|
|
146
|
+
if ((0, args_1.flagBool)(flags, 'json'))
|
|
147
|
+
(0, output_1.json)(suite);
|
|
148
|
+
else
|
|
149
|
+
(0, output_1.out)(outDir); // primary machine result: the suite directory
|
|
150
|
+
// The CI gate: any failed test fails the invocation (mirrors `vk run archive`).
|
|
151
|
+
return t.failed > 0 ? 1 : 0;
|
|
152
|
+
}
|