ravensight-playtest 0.5.0 → 0.5.2
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 +48 -0
- package/package.json +1 -1
- package/src/api/client.js +2 -1
- package/src/api/http.js +17 -7
- package/src/cli.js +44 -0
- package/src/commands/build.js +81 -0
- package/src/commands/hosted.js +39 -0
- package/src/commands/run.js +9 -6
- package/src/hosted/archivePreflight.js +240 -0
- package/src/hosted/buildUpload.js +118 -0
- package/src/hosted/recordedRun.js +191 -0
- package/src/run/drivers/stdio.js +24 -8
- package/src/run/drivers/web.js +3 -1
- package/src/run/drivers/webdriver.js +1 -1
- package/src/run/personaLoop.js +123 -39
package/README.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# ravensight-playtest
|
|
2
2
|
|
|
3
|
+
Hosted comparisons use a separate command from the local `run`:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
ravensight-playtest hosted quote --game GAME --build BUILD --profile PROFILE --persona curious-kid --video --transcript --output hosted-quote.json
|
|
7
|
+
ravensight-playtest hosted run hosted-quote.json
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The quote names the full Standard then Jev price, prepaid amount, evidence
|
|
11
|
+
options, expiry and refund rules. It charges nothing. `hosted run` confirms
|
|
12
|
+
that saved quote before one admission request. Its durable journal blocks an
|
|
13
|
+
automatic second POST after an uncertain outcome; preserve it for reconciliation.
|
|
14
|
+
A charged-blocked acknowledgement does not prove gameplay or a report. This
|
|
15
|
+
command does not top up funds, change local run/report files or ask for model
|
|
16
|
+
proxy permission on the CLI credential.
|
|
17
|
+
|
|
18
|
+
On a server that advertises it as ready, `hosted quote --standard-provider openai`
|
|
19
|
+
selects OpenAI for Standard. Explicit `anthropic` works the same way. The returned
|
|
20
|
+
provider and policy hash must match and remain in the saved consent; `hosted run`
|
|
21
|
+
has no provider override. Omitting the flag keeps the original default request
|
|
22
|
+
and consent wire. No choice falls back to another provider, and a CLI declaration
|
|
23
|
+
does not establish deployed provider or hosted engine readiness. Existing `run`
|
|
24
|
+
continues to use its local runner and original API contract.
|
|
25
|
+
|
|
26
|
+
The hosted build command source adds a local check before a paid upload:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
ravensight-playtest build preflight ./game.zip --format web --entrypoint index.html
|
|
30
|
+
ravensight-playtest build upload ./game.zip --game GAME --profile READY_PROFILE --entrypoint index.html --dry-run
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`preflight` seeks through bounded ZIP metadata; it does not load a multi-GB
|
|
34
|
+
archive into memory. A saved server profile can be supplied with `--capacity`
|
|
35
|
+
for its declared limits. Upload selects a currently advertised ready profile,
|
|
36
|
+
checks metadata and a streamed checksum, then asks for the separate $0.49
|
|
37
|
+
validation hold before requesting it. The server’s isolated validator remains
|
|
38
|
+
authoritative. A passing local check does not establish engine, GPU or license
|
|
39
|
+
compatibility.
|
|
40
|
+
|
|
41
|
+
The current upload protocol is one streamed presigned PUT. Multipart upload
|
|
42
|
+
and byte-range resume are not advertised. The original upload journal is kept
|
|
43
|
+
after an uncertain response, and repeating that upload cannot silently request
|
|
44
|
+
a second reservation. A different journal does not cancel or refund an earlier
|
|
45
|
+
upload. Reports and existing run-upload journals are left intact. Larger limits
|
|
46
|
+
and native executable formats are usable only when the server advertises a
|
|
47
|
+
ready profile and the corresponding upload and runtime support exists. This
|
|
48
|
+
source addition does not claim an accepted AAA engine run or a published CLI
|
|
49
|
+
release.
|
|
50
|
+
|
|
3
51
|
AI personas play your game, then file a report you can read, mark up and argue
|
|
4
52
|
with. The personas run on your machine, against your build; Ravensight stores
|
|
5
53
|
the reports and renders them.
|
package/package.json
CHANGED
package/src/api/client.js
CHANGED
|
@@ -51,7 +51,7 @@ export function createClient(options = {}) {
|
|
|
51
51
|
* @param {string} method
|
|
52
52
|
* @param {string} path
|
|
53
53
|
* @param {{query?: Object, body?: unknown, headers?: Object, idempotent?: boolean,
|
|
54
|
-
* raw?: boolean, anonymous?: boolean}} [opts]
|
|
54
|
+
* raw?: boolean, anonymous?: boolean, onResponseRaw?: Function}} [opts]
|
|
55
55
|
* @returns {Promise<{status: number, headers: Headers, body: unknown}>}
|
|
56
56
|
*/
|
|
57
57
|
async function request(method, path, opts = {}) {
|
|
@@ -86,6 +86,7 @@ export function createClient(options = {}) {
|
|
|
86
86
|
bodyText,
|
|
87
87
|
idempotent: Boolean(opts.idempotent),
|
|
88
88
|
raw: Boolean(opts.raw),
|
|
89
|
+
onResponseRaw: opts.onResponseRaw,
|
|
89
90
|
...transport
|
|
90
91
|
});
|
|
91
92
|
}
|
package/src/api/http.js
CHANGED
|
@@ -43,18 +43,26 @@ export function buildUrl(apiUrl, path, query) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* Read a response body without
|
|
46
|
+
* Read a response body without throwing for body or JSON decoding errors. A 502 from a load balancer is
|
|
47
47
|
* HTML, a 204 is empty, and a JSON parse failure on the error path would
|
|
48
48
|
* replace a useful status code with a useless SyntaxError.
|
|
49
49
|
* @param {Response} response
|
|
50
50
|
* @returns {Promise<{json: unknown, text: string}>}
|
|
51
51
|
*/
|
|
52
|
-
async function readBody(response) {
|
|
52
|
+
async function readBody(response, onResponseRaw) {
|
|
53
53
|
let text = '';
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
if (onResponseRaw) {
|
|
55
|
+
let bytes = Buffer.alloc(0), bodyComplete = false;
|
|
56
|
+
try { bytes = Buffer.from(await response.arrayBuffer()); bodyComplete = true; }
|
|
57
|
+
catch { /* Preserve a read-failure observation, never invent complete bytes. */ }
|
|
58
|
+
// Opt-in capture runs on every HTTP status BEFORE JSON parsing or an
|
|
59
|
+
// ApiError. Capture failures propagate and cannot trigger a request retry.
|
|
60
|
+
await onResponseRaw({ status: response.status, contentType: response.headers.get('content-type'), bytes, bodyComplete });
|
|
61
|
+
if (!bodyComplete) return { json: null, text: '' };
|
|
62
|
+
text = new TextDecoder().decode(bytes);
|
|
63
|
+
} else {
|
|
64
|
+
try { text = await response.text(); }
|
|
65
|
+
catch { return { json: null, text: '' }; }
|
|
58
66
|
}
|
|
59
67
|
if (!text) return { json: null, text: '' };
|
|
60
68
|
try {
|
|
@@ -102,6 +110,7 @@ function errorFrom(response, parsed, { method, path }) {
|
|
|
102
110
|
* @param {string} [options.bodyText]
|
|
103
111
|
* @param {boolean} [options.idempotent]
|
|
104
112
|
* @param {boolean} [options.raw] - resolve the body as text, not JSON
|
|
113
|
+
* @param {Function} [options.onResponseRaw] - await exact response-byte capture before parsing, including non-2xx
|
|
105
114
|
* @param {number} [options.retries]
|
|
106
115
|
* @param {number} [options.timeoutMs]
|
|
107
116
|
* @param {typeof fetch} [options.fetchImpl]
|
|
@@ -119,6 +128,7 @@ export async function requestWithRetry(options) {
|
|
|
119
128
|
bodyText,
|
|
120
129
|
idempotent = false,
|
|
121
130
|
raw = false,
|
|
131
|
+
onResponseRaw,
|
|
122
132
|
retries = DEFAULT_RETRIES,
|
|
123
133
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
124
134
|
fetchImpl = globalThis.fetch,
|
|
@@ -162,7 +172,7 @@ export async function requestWithRetry(options) {
|
|
|
162
172
|
}
|
|
163
173
|
|
|
164
174
|
if (response) {
|
|
165
|
-
const parsed = await readBody(response);
|
|
175
|
+
const parsed = await readBody(response, onResponseRaw);
|
|
166
176
|
if (response.ok || response.status === 304) {
|
|
167
177
|
return {
|
|
168
178
|
status: response.status,
|
package/src/cli.js
CHANGED
|
@@ -143,6 +143,50 @@ function withoutFirst(values, value) {
|
|
|
143
143
|
export function buildProgram() {
|
|
144
144
|
const program = new Command();
|
|
145
145
|
const slot = { code: ExitCode.OK };
|
|
146
|
+
const hosted = common(program.command('hosted').description('quote and admit a server-hosted Standard/Jev comparison'));
|
|
147
|
+
hosted.command('quote')
|
|
148
|
+
.description('save a full hosted quote without charging or admitting a run')
|
|
149
|
+
.requiredOption('--build <id>', 'an existing validated hosted build')
|
|
150
|
+
.requiredOption('--profile <version>', 'one currently advertised ready hosted profile')
|
|
151
|
+
.requiredOption('--persona <slug>', 'one available persona for both separate playthroughs')
|
|
152
|
+
.requiredOption('--output <file>', 'save the original quote and immutable consent here')
|
|
153
|
+
.option('--game <id>', 'game id, overriding this repo’s configuration')
|
|
154
|
+
.addOption(new Option('--standard-provider <name>', 'explicit Standard provider; unavailable choices are refused').choices(['anthropic', 'openai']))
|
|
155
|
+
.option('--video', 'include hosted gameplay video in this quote')
|
|
156
|
+
.option('--transcript', 'include the hosted transcript in this quote')
|
|
157
|
+
.action(capture(slot, async (options, command) => {
|
|
158
|
+
const { hostedQuote } = await import('./commands/hosted.js');
|
|
159
|
+
return hostedQuote(flagsFrom(command.optsWithGlobals(), { build: options.build, profile: options.profile,
|
|
160
|
+
persona: options.persona, output: options.output, game: options.game, standardProvider: options.standardProvider,
|
|
161
|
+
video: options.video, transcript: options.transcript }));
|
|
162
|
+
}));
|
|
163
|
+
hosted.command('run <quote-file>')
|
|
164
|
+
.description('confirm the full saved quote and request one hosted admission; never retry an unknown outcome')
|
|
165
|
+
.action(capture(slot, async (filename, _options, command) => {
|
|
166
|
+
const { hostedRun } = await import('./commands/hosted.js');
|
|
167
|
+
return hostedRun(filename, flagsFrom(command.optsWithGlobals()));
|
|
168
|
+
}));
|
|
169
|
+
const build = common(program.command('build').description('check or upload a self-contained hosted game archive'));
|
|
170
|
+
build.command('preflight <archive>')
|
|
171
|
+
.description('check bounded ZIP metadata locally before any paid upload')
|
|
172
|
+
.requiredOption('--entrypoint <path>', 'entry file inside the ZIP')
|
|
173
|
+
.option('--format <name>', 'web or godot-project without a selected capacity profile', 'web')
|
|
174
|
+
.option('--capacity <file>', 'saved profile JSON for a local capacity check; does not establish server readiness')
|
|
175
|
+
.action(capture(slot, async (archive, options, command) => {
|
|
176
|
+
const { buildPreflight } = await import('./commands/build.js');
|
|
177
|
+
return buildPreflight(archive, flagsFrom(command.optsWithGlobals(), { entrypoint: options.entrypoint, format: options.format, capacity: options.capacity }));
|
|
178
|
+
}));
|
|
179
|
+
build.command('upload <archive>')
|
|
180
|
+
.description('preflight and stream one private build upload after validation-hold consent')
|
|
181
|
+
.requiredOption('--profile <version>', 'one currently advertised ready server profile')
|
|
182
|
+
.requiredOption('--entrypoint <path>', 'entry file inside the ZIP')
|
|
183
|
+
.option('--game <id>', 'game id, overriding this repo’s configuration')
|
|
184
|
+
.option('--journal <file>', 'preserve this original upload intent at the specified path')
|
|
185
|
+
.option('--dry-run', 'inspect and hash the archive without requesting a reservation')
|
|
186
|
+
.action(capture(slot, async (archive, options, command) => {
|
|
187
|
+
const { buildUpload } = await import('./commands/build.js');
|
|
188
|
+
return buildUpload(archive, flagsFrom(command.optsWithGlobals(), { entrypoint: options.entrypoint, profile: options.profile, game: options.game, journal: options.journal, dryRun: options.dryRun }));
|
|
189
|
+
}));
|
|
146
190
|
program.exitCodeSlot = slot;
|
|
147
191
|
program
|
|
148
192
|
.name('ravensight-playtest')
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { createContext } from '../api/index.js';
|
|
3
|
+
import { resolveGameId } from '../config.js';
|
|
4
|
+
import { withAuthHandling } from '../auth/session.js';
|
|
5
|
+
import { preflightBuildArchive, limitsFromProfile } from '../hosted/archivePreflight.js';
|
|
6
|
+
import { uploadHostedBuild } from '../hosted/buildUpload.js';
|
|
7
|
+
import { CliError, ExitCode } from '../errors.js';
|
|
8
|
+
import { ui } from '../ui/index.js';
|
|
9
|
+
|
|
10
|
+
// TTY animation stays live while I/O awaits. Piped logs receive at most one
|
|
11
|
+
// byte update per second, plus phase boundaries and the final byte count.
|
|
12
|
+
export function buildUploadProgress({ json = false, now = Date.now } = {}) {
|
|
13
|
+
let spin, phase, last = 0, finished = false;
|
|
14
|
+
const labels = { capabilities: 'Checking available build profiles', preflight: 'Checking ZIP metadata',
|
|
15
|
+
hash: 'Hashing archive', reservation: 'Requesting the confirmed validation hold',
|
|
16
|
+
upload: 'Streaming archive bytes', completion: 'Submitting the original upload for validation' };
|
|
17
|
+
return {
|
|
18
|
+
update(event) {
|
|
19
|
+
if (json || ui.isQuiet()) return;
|
|
20
|
+
const at = now(), changed = phase !== event.phase;
|
|
21
|
+
const complete = event.totalBytes !== undefined && event.completedBytes === event.totalBytes;
|
|
22
|
+
if (!changed && at - last < 1000 && (!complete || finished)) return;
|
|
23
|
+
const label = labels[event.phase] + (event.totalBytes === undefined ? ''
|
|
24
|
+
: `: ${ui.bytes(event.completedBytes)} / ${ui.bytes(event.totalBytes)}`);
|
|
25
|
+
if (changed) { spin?.stop(); spin = ui.spinner(label); phase = event.phase; }
|
|
26
|
+
else spin.update(label);
|
|
27
|
+
last = at; finished = complete;
|
|
28
|
+
},
|
|
29
|
+
stop() { spin?.stop(); spin = null; phase = null; finished = false; }
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function buildPreflight(filename, flags = {}) {
|
|
34
|
+
let profile;
|
|
35
|
+
if (flags.capacity) {
|
|
36
|
+
const info = await stat(flags.capacity);
|
|
37
|
+
if (!info.isFile() || info.size > 65536) throw new CliError('Choose a capacity profile JSON file no larger than 64 KiB.');
|
|
38
|
+
profile = JSON.parse(await readFile(flags.capacity, 'utf8'));
|
|
39
|
+
}
|
|
40
|
+
const result = await preflightBuildArchive(filename, { format: profile?.format || flags.format,
|
|
41
|
+
entrypoint: flags.entrypoint, limits: profile ? limitsFromProfile(profile) : undefined,
|
|
42
|
+
requirements: profile?.capacity?.requirements });
|
|
43
|
+
if (flags.json) ui.json(result);
|
|
44
|
+
else {
|
|
45
|
+
ui.ok(`ZIP metadata passed: ${result.entries} entries; ${ui.bytes(result.archiveBytes)} uploaded, ${ui.bytes(result.expandedBytes)} expanded.`);
|
|
46
|
+
ui.info('No wallet charge or upload was requested. Server validation and runtime compatibility checks are still required.');
|
|
47
|
+
}
|
|
48
|
+
return ExitCode.OK;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function buildUpload(filename, flags = {}, deps = {}) {
|
|
52
|
+
const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
|
|
53
|
+
return withAuthHandling(context, async () => {
|
|
54
|
+
const progress = buildUploadProgress({ json: Boolean(flags.json) });
|
|
55
|
+
try {
|
|
56
|
+
const result = await uploadHostedBuild({ filename, gameId: resolveGameId(flags, context.config), profileVersion: flags.profile,
|
|
57
|
+
entrypoint: flags.entrypoint, repoRoot: context.repoRoot, api: context.api, apiUrl: context.api.apiUrl,
|
|
58
|
+
journalPath: flags.journal, dryRun: Boolean(flags.dryRun), fetchImpl: deps.fetch,
|
|
59
|
+
confirm: deps.confirm || (question => ui.confirm(question, { yes: Boolean(flags.yes) })),
|
|
60
|
+
onProgress: event => progress.update(event),
|
|
61
|
+
onPrepared: prepared => {
|
|
62
|
+
progress.stop();
|
|
63
|
+
if (flags.json) return;
|
|
64
|
+
ui.info(`ZIP metadata checked: ${prepared.entries} entries; ${ui.bytes(prepared.archiveBytes)} archive, ${ui.bytes(prepared.expandedBytes)} expanded.`);
|
|
65
|
+
const limit = prepared.limits;
|
|
66
|
+
ui.info(`Selected limits: archive ${ui.bytes(limit.archiveBytes)}, expanded ${ui.bytes(limit.expandedBytes)}, file ${ui.bytes(limit.fileBytes)}, ${limit.files} entries, depth ${limit.depth}, ratio ${limit.ratio}:1.`);
|
|
67
|
+
const required = prepared.capacity?.requirements;
|
|
68
|
+
if (required) ui.info(`Server profile: ${required.os}/${required.architecture}, ${required.cpuVcpus} CPU, ${ui.bytes(required.memoryBytes)} RAM. Runtime and license readiness must come from the server.`);
|
|
69
|
+
if (Number.isSafeInteger(prepared.funding?.minimumFundingCents)) ui.info(`Server prepaid funding minimum: $${(prepared.funding.minimumFundingCents / 100).toFixed(2)}; the separate gameplay price is quoted after validation.`);
|
|
70
|
+
ui.info('Static validation hold: $0.49 from prepaid funds, returned after safe cleanup. A gameplay quote is separate. No top-up is performed.');
|
|
71
|
+
ui.info('Passing this local check does not establish GPU, licensing or game runtime compatibility.');
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
progress.stop();
|
|
75
|
+
if (flags.json) ui.json(result);
|
|
76
|
+
else if (result.state === 'awaiting_validation') ui.ok(`Uploaded ${result.buildId}; awaiting server validation. Journal: ${result.journal}`);
|
|
77
|
+
else ui.info(result.state === 'declined' ? 'Upload declined; no reservation requested.' : 'Preflight finished; no reservation requested.');
|
|
78
|
+
return result.state === 'declined' ? ExitCode.CANCELED : ExitCode.OK;
|
|
79
|
+
} finally { progress.stop(); }
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createContext } from '../api/index.js';
|
|
2
|
+
import { resolveGameId } from '../config.js';
|
|
3
|
+
import { withAuthHandling } from '../auth/session.js';
|
|
4
|
+
import { CliError, ExitCode } from '../errors.js';
|
|
5
|
+
import { ui } from '../ui/index.js';
|
|
6
|
+
import { quoteHostedRun, runHostedQuote, hostedQuoteLines } from '../hosted/recordedRun.js';
|
|
7
|
+
|
|
8
|
+
export async function hostedQuote(flags = {}, deps = {}) {
|
|
9
|
+
const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
|
|
10
|
+
return withAuthHandling(context, async () => {
|
|
11
|
+
const result = await quoteHostedRun({ api: context.api, apiUrl: context.api.apiUrl,
|
|
12
|
+
gameId: resolveGameId(flags, context.config), buildId: flags.build, profileVersion: flags.profile,
|
|
13
|
+
persona: flags.persona, standardProvider: flags.standardProvider, includeVideo: Boolean(flags.video),
|
|
14
|
+
includeTranscript: Boolean(flags.transcript), output: flags.output });
|
|
15
|
+
if (flags.json) ui.json(result);
|
|
16
|
+
else {
|
|
17
|
+
hostedQuoteLines(result).forEach(line => ui.info(line));
|
|
18
|
+
ui.ok(`Saved ${result.quoteFile}. No admission or charge was requested.`);
|
|
19
|
+
}
|
|
20
|
+
return ExitCode.OK;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function hostedRun(filename, flags = {}, deps = {}) {
|
|
25
|
+
if (flags.json && !flags.yes && !deps.confirm) throw new CliError('Inspect the saved quote, then use --yes with --json. No admission was requested.');
|
|
26
|
+
const context = deps.context || await createContext({ apiUrl: flags.apiUrl, repoRoot: flags.repoRoot, requireAuth: true });
|
|
27
|
+
return withAuthHandling(context, async () => {
|
|
28
|
+
const result = await runHostedQuote({ filename, api: context.api, apiUrl: context.api.apiUrl, repoRoot: context.repoRoot,
|
|
29
|
+
confirm: deps.confirm || (question => ui.confirm(question, { yes: Boolean(flags.yes) })),
|
|
30
|
+
onPrepared: offer => { if (!flags.json) hostedQuoteLines(offer).forEach(line => ui.info(line)); } });
|
|
31
|
+
if (flags.json) ui.json(result);
|
|
32
|
+
else if (result.state === 'declined') ui.info('Hosted quote declined. No admission was requested.');
|
|
33
|
+
else {
|
|
34
|
+
ui.ok(`Admission ${result.admissionId}; job ${result.jobId}. Saved ${result.journal}.`);
|
|
35
|
+
ui.info('The server acknowledged charged_blocked. This does not prove a worker started or produced gameplay or reports.');
|
|
36
|
+
}
|
|
37
|
+
return result.state === 'declined' ? ExitCode.CANCELED : ExitCode.OK;
|
|
38
|
+
});
|
|
39
|
+
}
|
package/src/commands/run.js
CHANGED
|
@@ -870,11 +870,11 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
|
|
|
870
870
|
body
|
|
871
871
|
});
|
|
872
872
|
} catch (error) {
|
|
873
|
-
// A server older than partial reports refuses one as `invalid_report
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
// report.json either way.
|
|
877
|
-
if (!(body.report && body.report.partial && error && error.status === 422 && error.code
|
|
873
|
+
// A server older than partial reports refuses one as `invalid_report`,
|
|
874
|
+
// and any server refuses one with a secret shaped value in it. The
|
|
875
|
+
// uploads are already done by then, so the run is completed without it
|
|
876
|
+
// rather than left open: the notes stay on disk in report.json either way.
|
|
877
|
+
if (!(body.report && body.report.partial && error && error.status === 422 && ['invalid_report', 'secrets_detected'].includes(error.code))) throw error;
|
|
878
878
|
log(`run ${runId}: the server refused the partial report (${String(error.message)}), so the run is completed without it. The notes are in ${directory}.`);
|
|
879
879
|
if (delivery) delivery.reportRefused = true;
|
|
880
880
|
const { report: ignored, report_md: alsoIgnored, ...withoutReport } = body;
|
|
@@ -1037,7 +1037,10 @@ async function aggregatePhase({
|
|
|
1037
1037
|
|
|
1038
1038
|
// The aggregate reads reports off disk, so a resumed job whose earlier runs
|
|
1039
1039
|
// were played by a previous process is covered too.
|
|
1040
|
-
|
|
1040
|
+
// A failed run's report explains the failure and its unit is refunded. It is
|
|
1041
|
+
// not a result to summarise, and the server refuses a summary of only those.
|
|
1042
|
+
const reports = (await loadRunReports(repoRoot, jobId, personaRuns, journal))
|
|
1043
|
+
.map(run => (run.state === 'failed' ? { ...run, report: null } : run));
|
|
1041
1044
|
let aggregate;
|
|
1042
1045
|
try {
|
|
1043
1046
|
aggregate = await buildAggregate({
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open, lstat } from 'node:fs/promises';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { Readable } from 'node:stream';
|
|
5
|
+
import { createInflateRaw } from 'node:zlib';
|
|
6
|
+
import { CliError } from '../errors.js';
|
|
7
|
+
|
|
8
|
+
export const LEGACY_BUILD_LIMITS = Object.freeze({ archiveBytes: 64 * 1024 ** 2,
|
|
9
|
+
expandedBytes: 512 * 1024 ** 2, fileBytes: 64 * 1024 ** 2, files: 1000, depth: 16, ratio: 100,
|
|
10
|
+
archiveFormats: Object.freeze(['zip']), zip64: false });
|
|
11
|
+
// These parser bounds cap metadata/memory even when an operator declares a
|
|
12
|
+
// much larger byte budget. They do not advertise server or engine capacity.
|
|
13
|
+
const MAX_DIRECTORY_BYTES = 64 * 1024 ** 2;
|
|
14
|
+
const MAX_ENTRIES = 1_000_000;
|
|
15
|
+
const MAX_PATH_BYTES = 1024;
|
|
16
|
+
const reject = reason => { throw new CliError(`Build preflight failed: ${reason}. No upload reservation was requested.`); };
|
|
17
|
+
const integer = value => Number.isSafeInteger(value) && value > 0;
|
|
18
|
+
const same = (a, b) => ['dev', 'ino', 'size', 'mtimeNs', 'ctimeNs'].every(key => a[key] === b[key]);
|
|
19
|
+
|
|
20
|
+
export function buildLimits(value = LEGACY_BUILD_LIMITS) {
|
|
21
|
+
const keys = ['archiveBytes', 'expandedBytes', 'fileBytes', 'files', 'depth', 'ratio'];
|
|
22
|
+
if (!value || keys.some(key => !integer(value[key]))) reject('the selected build limits are incomplete');
|
|
23
|
+
if (value.files > MAX_ENTRIES || value.depth > 128 || value.ratio > 10000) reject('the selected limits exceed this client’s bounded metadata parser');
|
|
24
|
+
if (value.archiveFormats !== undefined && (!Array.isArray(value.archiveFormats) || value.archiveFormats.length !== 1 || value.archiveFormats[0] !== 'zip')
|
|
25
|
+
|| value.zip64 !== undefined && typeof value.zip64 !== 'boolean') reject('the selected archive policy is unsupported');
|
|
26
|
+
return { ...Object.fromEntries(keys.map(key => [key, value[key]])), archiveFormats: ['zip'], zip64: value.zip64 === true };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function limitsFromProfile(profile) {
|
|
30
|
+
if (profile?.capacity?.version !== 1) reject('the selected profile does not declare a supported capacity schema');
|
|
31
|
+
return buildLimits(profile.capacity.uploadLimits);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function safePath(value, depth, directory = false) {
|
|
35
|
+
if (!value || Buffer.byteLength(value) > MAX_PATH_BYTES || /[\\\x00-\x1f\x7f:]/.test(value) || value.startsWith('/')) reject('an archive path is unsafe');
|
|
36
|
+
const name = directory && value.endsWith('/') ? value.slice(0, -1) : value;
|
|
37
|
+
const parts = name.split('/');
|
|
38
|
+
if (parts.length > depth || parts.some(part => !part || part === '.' || part === '..' || part.length > 255
|
|
39
|
+
|| /[. ]$/.test(part) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))
|
|
40
|
+
|| !/^[A-Za-z0-9_./ +@()=-]+$/.test(name)) reject('an archive path is not portable or exceeds the selected depth');
|
|
41
|
+
return name;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function number64(bytes, offset) {
|
|
45
|
+
const value = bytes.readBigUInt64LE(offset);
|
|
46
|
+
if (value > BigInt(Number.MAX_SAFE_INTEGER)) reject('a ZIP64 offset or size cannot be represented safely');
|
|
47
|
+
return Number(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A descriptor owns one open file throughout preflight, hash and upload. The
|
|
51
|
+
// pathname is never reopened for transfer after consent. No file-size buffer.
|
|
52
|
+
export async function openBuildArchive(filename, { signal, onRead } = {}) {
|
|
53
|
+
const before = await lstat(filename, { bigint: true });
|
|
54
|
+
if (!before.isFile() || before.isSymbolicLink()) reject('choose a regular ZIP file');
|
|
55
|
+
const handle = await open(filename, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
56
|
+
try {
|
|
57
|
+
const stat = await handle.stat({ bigint: true });
|
|
58
|
+
if (!same(before, stat) || stat.size > BigInt(Number.MAX_SAFE_INTEGER)) reject('the archive changed while opening');
|
|
59
|
+
let closed = false;
|
|
60
|
+
const check = async () => {
|
|
61
|
+
if (closed || signal?.aborted) reject('the archive operation was stopped');
|
|
62
|
+
if (!same(stat, await handle.stat({ bigint: true }))) reject('the archive changed; run preflight again');
|
|
63
|
+
};
|
|
64
|
+
const read = async (offset, length) => {
|
|
65
|
+
if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length) || offset < 0 || length < 0
|
|
66
|
+
|| length > 65557 || offset + length > Number(stat.size)) reject('an archive record extends outside the file');
|
|
67
|
+
await check();
|
|
68
|
+
const bytes = Buffer.alloc(length); let done = 0;
|
|
69
|
+
while (done < length) {
|
|
70
|
+
const result = await handle.read(bytes, done, length - done, offset + done);
|
|
71
|
+
if (!result.bytesRead) reject('the archive is truncated');
|
|
72
|
+
done += result.bytesRead;
|
|
73
|
+
}
|
|
74
|
+
onRead?.(length); return bytes;
|
|
75
|
+
};
|
|
76
|
+
return { size: Number(stat.size), read, check,
|
|
77
|
+
async sha256({ onProgress } = {}) {
|
|
78
|
+
const hash = createHash('sha256');
|
|
79
|
+
for (let at = 0; at < Number(stat.size); at += 65536) {
|
|
80
|
+
const chunk = await read(at, Math.min(65536, Number(stat.size) - at));
|
|
81
|
+
hash.update(chunk); onProgress?.(at + chunk.length, Number(stat.size));
|
|
82
|
+
}
|
|
83
|
+
await check(); return hash.digest('hex');
|
|
84
|
+
},
|
|
85
|
+
async *chunks({ start = 0, end = Number(stat.size), onProgress } = {}) {
|
|
86
|
+
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start || end > Number(stat.size)) reject('the upload range is invalid');
|
|
87
|
+
for (let at = start; at < end; at += 65536) {
|
|
88
|
+
const chunk = await read(at, Math.min(65536, end - at));
|
|
89
|
+
yield chunk; onProgress?.(at + chunk.length - start, end - start);
|
|
90
|
+
}
|
|
91
|
+
await check();
|
|
92
|
+
},
|
|
93
|
+
async close() { if (!closed) { closed = true; await handle.close(); } }
|
|
94
|
+
};
|
|
95
|
+
} catch (error) { await handle.close(); throw error; }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function directory(archive, limits) {
|
|
99
|
+
if (archive.size < 22 || archive.size > limits.archiveBytes) reject('the ZIP exceeds the selected upload size or is truncated');
|
|
100
|
+
const tailStart = Math.max(0, archive.size - 65557), tail = await archive.read(tailStart, archive.size - tailStart);
|
|
101
|
+
let eocd = -1;
|
|
102
|
+
for (let at = tail.length - 22; at >= 0; at--) {
|
|
103
|
+
if (tail.readUInt32LE(at) === 0x06054b50 && at + 22 + tail.readUInt16LE(at + 20) === tail.length) { eocd = at; break; }
|
|
104
|
+
}
|
|
105
|
+
if (eocd < 0) reject('the ZIP directory is missing');
|
|
106
|
+
const record = tail.subarray(eocd), end = tailStart + eocd;
|
|
107
|
+
if (record.readUInt16LE(4) || record.readUInt16LE(6) || record.readUInt16LE(8) !== record.readUInt16LE(10)) reject('split ZIP archives are unsupported');
|
|
108
|
+
let count = record.readUInt16LE(10), size = record.readUInt32LE(12), offset = record.readUInt32LE(16), boundary = end;
|
|
109
|
+
const locator = end >= 20 ? await archive.read(end - 20, 20) : null;
|
|
110
|
+
const zip64 = locator?.readUInt32LE(0) === 0x07064b50;
|
|
111
|
+
if (zip64 && !limits.zip64) reject('the selected profile does not accept ZIP64');
|
|
112
|
+
if (zip64) {
|
|
113
|
+
if (locator.readUInt32LE(4) || locator.readUInt32LE(16) !== 1) reject('split ZIP64 archives are unsupported');
|
|
114
|
+
boundary = number64(locator, 8);
|
|
115
|
+
const expanded = await archive.read(boundary, 56);
|
|
116
|
+
if (expanded.readUInt32LE(0) !== 0x06064b50 || number64(expanded, 4) < 44
|
|
117
|
+
|| number64(expanded, 4) > 1024 || boundary + 12 + number64(expanded, 4) !== end - 20
|
|
118
|
+
|| expanded.readUInt32LE(16) || expanded.readUInt32LE(20) || number64(expanded, 24) !== number64(expanded, 32)) reject('the ZIP64 directory is invalid');
|
|
119
|
+
const values = [number64(expanded, 32), number64(expanded, 40), number64(expanded, 48)];
|
|
120
|
+
if ([count, size, offset].some((value, i) => value !== (i ? 0xffffffff : 0xffff) && value !== values[i])) reject('ZIP and ZIP64 directories disagree');
|
|
121
|
+
[count, size, offset] = values;
|
|
122
|
+
} else if (count === 0xffff || size === 0xffffffff || offset === 0xffffffff) reject('the ZIP64 locator is missing');
|
|
123
|
+
if (!count || count > limits.files || count > MAX_ENTRIES) reject('the ZIP entry count exceeds the selected limit');
|
|
124
|
+
if (size > MAX_DIRECTORY_BYTES || size < count * 46 || offset + size !== boundary) reject('the ZIP directory exceeds the bounded metadata budget or is inconsistent');
|
|
125
|
+
return { count, size, offset, zip64 };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sizes(header, extra) {
|
|
129
|
+
let expanded = header.readUInt32LE(24), compressed = header.readUInt32LE(20), offset = header.readUInt32LE(42), disk = header.readUInt16LE(34);
|
|
130
|
+
let zip64;
|
|
131
|
+
for (let at = 0; at < extra.length;) {
|
|
132
|
+
if (at + 4 > extra.length) reject('a ZIP extra field is truncated');
|
|
133
|
+
const kind = extra.readUInt16LE(at), size = extra.readUInt16LE(at + 2); at += 4;
|
|
134
|
+
if (at + size > extra.length) reject('a ZIP extra field is truncated');
|
|
135
|
+
if (kind === 1) { if (zip64) reject('duplicate ZIP64 extra fields'); zip64 = extra.subarray(at, at + size); }
|
|
136
|
+
at += size;
|
|
137
|
+
}
|
|
138
|
+
let at = 0;
|
|
139
|
+
const next = () => { if (!zip64 || at + 8 > zip64.length) reject('a ZIP64 file size is missing'); const value = number64(zip64, at); at += 8; return value; };
|
|
140
|
+
if (expanded === 0xffffffff) expanded = next();
|
|
141
|
+
if (compressed === 0xffffffff) compressed = next();
|
|
142
|
+
if (offset === 0xffffffff) offset = next();
|
|
143
|
+
if (disk === 0xffff) { if (!zip64 || at + 4 > zip64.length) reject('a ZIP64 disk field is missing'); disk = zip64.readUInt32LE(at); }
|
|
144
|
+
if (disk) reject('split ZIP archives are unsupported');
|
|
145
|
+
return { expanded, compressed, offset };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function prefix(archive, file, length) {
|
|
149
|
+
if (length > 1024 ** 2 || length > file.expanded) reject('the native executable header is outside the bounded metadata window');
|
|
150
|
+
const source = Readable.from(archive.chunks({ start: file.dataOffset, end: file.dataEnd }), { objectMode: false, highWaterMark: 65536 });
|
|
151
|
+
const output = file.method === 8 ? source.pipe(createInflateRaw({ chunkSize: 16384 })) : source;
|
|
152
|
+
const parts = []; let total = 0;
|
|
153
|
+
const timer = setTimeout(() => { source.destroy(Error('header timeout')); output.destroy(Error('header timeout')); }, 5000);
|
|
154
|
+
source.on('error', error => output.destroy(error));
|
|
155
|
+
try {
|
|
156
|
+
for await (const chunk of output) {
|
|
157
|
+
const take = chunk.subarray(0, length - total); parts.push(take); total += take.length;
|
|
158
|
+
if (total === length) return Buffer.concat(parts, total);
|
|
159
|
+
}
|
|
160
|
+
reject('the native executable header is truncated');
|
|
161
|
+
} catch { reject('the native executable header could not be inspected within its bound'); }
|
|
162
|
+
finally { clearTimeout(timer); source.destroy(); output.destroy(); }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function nativeHeader(archive, file, format, requirements) {
|
|
166
|
+
const os = format === 'linux-executable' ? 'linux' : 'windows';
|
|
167
|
+
if (requirements?.os !== os || !['arm64', 'x86_64'].includes(requirements.architecture)) reject('the native format does not match the selected profile requirements');
|
|
168
|
+
const header = await prefix(archive, file, 64); let architecture, binaryFormat;
|
|
169
|
+
if (os === 'linux') {
|
|
170
|
+
if (header.subarray(0, 4).toString('hex') !== '7f454c46' || header[4] !== 2 || header[5] !== 1 || header[6] !== 1
|
|
171
|
+
|| ![2, 3].includes(header.readUInt16LE(16))) reject('the Linux entrypoint must be a little-endian ELF64 executable');
|
|
172
|
+
architecture = { 62: 'x86_64', 183: 'arm64' }[header.readUInt16LE(18)]; binaryFormat = 'ELF64';
|
|
173
|
+
} else {
|
|
174
|
+
if (header.subarray(0, 2).toString() !== 'MZ') reject('the Windows entrypoint must be a PE executable');
|
|
175
|
+
const at = header.readUInt32LE(60);
|
|
176
|
+
if (at < 64 || at > 1024 ** 2 - 26) reject('the PE header offset exceeds the bounded metadata window');
|
|
177
|
+
const pe = await prefix(archive, file, at + 26);
|
|
178
|
+
if (pe.readUInt32LE(at) !== 0x00004550 || pe.readUInt16LE(at + 24) !== 0x20b) reject('the Windows entrypoint must be a PE32+ executable');
|
|
179
|
+
architecture = { 0x8664: 'x86_64', 0xaa64: 'arm64' }[pe.readUInt16LE(at + 4)]; binaryFormat = 'PE32+';
|
|
180
|
+
}
|
|
181
|
+
if (architecture !== requirements.architecture) reject('the executable architecture does not match the selected profile');
|
|
182
|
+
return { os, architecture, binaryFormat, runtimeCompatibilityProven: false };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function inspectBuildArchive(archive, { limits: supplied, format, entrypoint, requirements } = {}) {
|
|
186
|
+
const limits = buildLimits(supplied);
|
|
187
|
+
if (!['web', 'godot-project', 'linux-executable', 'windows-executable'].includes(format)) reject('the selected runtime format is unsupported');
|
|
188
|
+
if (typeof entrypoint !== 'string' || entrypoint.length > 240 || !/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/.test(entrypoint)) reject('the entrypoint does not match the current server path policy');
|
|
189
|
+
const wanted = safePath(entrypoint, limits.depth);
|
|
190
|
+
if (format === 'web' && !wanted.endsWith('.html') || format === 'godot-project' && wanted.split('/').at(-1) !== 'project.godot'
|
|
191
|
+
|| format === 'windows-executable' && !wanted.toLowerCase().endsWith('.exe')) reject('the entrypoint does not match the selected format');
|
|
192
|
+
const dir = await directory(archive, limits), paths = new Map(), explicit = new Set(), ranges = [];
|
|
193
|
+
let cursor = dir.offset, expandedBytes = 0, files = 0, found = false, pathBytes = 0, nativeFile;
|
|
194
|
+
for (let index = 0; index < dir.count; index++) {
|
|
195
|
+
const h = await archive.read(cursor, 46);
|
|
196
|
+
if (h.readUInt32LE(0) !== 0x02014b50) reject('a ZIP directory entry is invalid');
|
|
197
|
+
const flags = h.readUInt16LE(8), method = h.readUInt16LE(10), nameBytes = h.readUInt16LE(28), extraBytes = h.readUInt16LE(30), commentBytes = h.readUInt16LE(32);
|
|
198
|
+
if (flags & 0x41 || ![0, 8].includes(method)) reject('encrypted or unsupported ZIP compression');
|
|
199
|
+
if (!nameBytes || nameBytes > MAX_PATH_BYTES || cursor + 46 + nameBytes + extraBytes + commentBytes > dir.offset + dir.size) reject('a ZIP directory entry is oversized');
|
|
200
|
+
const nameBuffer = await archive.read(cursor + 46, nameBytes), nameText = nameBuffer.toString('utf8');
|
|
201
|
+
const isDirectory = nameText.endsWith('/'), name = safePath(nameText, limits.depth, isDirectory), folded = name.toLowerCase();
|
|
202
|
+
if (explicit.has(folded)) reject('duplicate ZIP paths'); explicit.add(folded);
|
|
203
|
+
const parts = name.split('/');
|
|
204
|
+
for (let n = 1; n <= parts.length; n++) {
|
|
205
|
+
const prefix = parts.slice(0, n).join('/'), key = prefix.toLowerCase(), kind = n < parts.length || isDirectory ? 'directory' : 'file', prior = paths.get(key);
|
|
206
|
+
if (prior && (prior.name !== prefix || prior.kind !== kind)) reject('colliding ZIP paths');
|
|
207
|
+
if (!prior) { pathBytes += Buffer.byteLength(prefix); if (pathBytes > MAX_DIRECTORY_BYTES) reject('the ZIP path index exceeds the bounded metadata budget'); }
|
|
208
|
+
paths.set(key, { name: prefix, kind });
|
|
209
|
+
}
|
|
210
|
+
const mode = h.readUInt32LE(38) >>> 16, type = mode & 0xf000;
|
|
211
|
+
if (type && type !== (isDirectory ? 0x4000 : 0x8000) || mode & 0x0e00 || !isDirectory && h.readUInt32LE(38) & 0x10) reject('links, devices or privileged ZIP entries are unsupported');
|
|
212
|
+
const extra = await archive.read(cursor + 46 + nameBytes, extraBytes), size = sizes(h, extra);
|
|
213
|
+
if (size.expanded > limits.fileBytes || BigInt(size.expanded) > BigInt(Math.max(1, size.compressed)) * BigInt(limits.ratio)) reject('a file exceeds the selected size or compression-ratio limit');
|
|
214
|
+
if (isDirectory && (size.expanded || size.compressed)) reject('a ZIP directory contains file data');
|
|
215
|
+
expandedBytes += size.expanded;
|
|
216
|
+
if (!Number.isSafeInteger(expandedBytes) || expandedBytes > limits.expandedBytes) reject('the expanded build exceeds the selected limit');
|
|
217
|
+
const local = await archive.read(size.offset, 30);
|
|
218
|
+
if (local.readUInt32LE(0) !== 0x04034b50 || local.readUInt16LE(6) !== flags || local.readUInt16LE(8) !== method
|
|
219
|
+
|| local.readUInt16LE(26) !== nameBytes) reject('local and central ZIP headers disagree');
|
|
220
|
+
if (!(await archive.read(size.offset + 30, nameBytes)).equals(nameBuffer)) reject('local and central ZIP filenames disagree');
|
|
221
|
+
const dataEnd = size.offset + 30 + nameBytes + local.readUInt16LE(28) + size.compressed;
|
|
222
|
+
if (!Number.isSafeInteger(dataEnd) || dataEnd > dir.offset) reject('file data overlaps the ZIP directory');
|
|
223
|
+
ranges.push([size.offset, dataEnd]);
|
|
224
|
+
if (!isDirectory) { files++; if (name === wanted) { if (!size.expanded) reject('the entrypoint is empty'); found = true;
|
|
225
|
+
nativeFile = { ...size, method, dataOffset: size.offset + 30 + nameBytes + local.readUInt16LE(28), dataEnd }; } }
|
|
226
|
+
cursor += 46 + nameBytes + extraBytes + commentBytes;
|
|
227
|
+
}
|
|
228
|
+
if (cursor !== dir.offset + dir.size || !found) reject('the declared entrypoint is absent or the directory is inconsistent');
|
|
229
|
+
ranges.sort((a, b) => a[0] - b[0]);
|
|
230
|
+
for (let i = 1; i < ranges.length; i++) if (ranges[i][0] < ranges[i - 1][1]) reject('ZIP file data overlaps');
|
|
231
|
+
await archive.check();
|
|
232
|
+
const nativeBinaryMetadata = format.endsWith('-executable') ? await nativeHeader(archive, nativeFile, format, requirements) : null;
|
|
233
|
+
return { archiveBytes: archive.size, expandedBytes, entries: dir.count, files, format, entrypoint: wanted,
|
|
234
|
+
zip64: dir.zip64, metadataVerified: true, nativeBinaryMetadata, contentsVerified: false, serverValidationRequired: true, runtimeCompatibilityProven: false };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function preflightBuildArchive(filename, options) {
|
|
238
|
+
const archive = await openBuildArchive(filename, options);
|
|
239
|
+
try { return await inspectBuildArchive(archive, options); } finally { await archive.close(); }
|
|
240
|
+
}
|