ravensight-playtest 0.1.0 → 0.2.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 +87 -3
- package/package.json +12 -1
- package/src/api/client.js +25 -2
- package/src/cli.js +40 -1
- package/src/commands/fakeCore.js +68 -3
- package/src/commands/fix.js +1053 -0
- package/src/commands/run.js +12 -3
- package/src/packs/index.js +17 -2
- package/src/run/args.js +6 -1
- package/src/run/fixLoop.js +430 -0
- package/src/run/git.js +331 -0
- package/src/run/index.js +7 -3
- package/src/run/model.js +7 -1
- package/src/run/repoTools.js +279 -10
package/src/run/repoTools.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { lstat, mkdir, readFile, readdir, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { scanForSecrets } from './secretScan.js';
|
|
4
5
|
|
|
@@ -8,8 +9,16 @@ import { scanForSecrets } from './secretScan.js';
|
|
|
8
9
|
* `routing.json`'s `game_profile.triage` allows (and `read_file` alone on
|
|
9
10
|
* `game_profile.synthesis` and `aggregate.synthesis`).
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
12
|
+
* `fix.apply` is the one step that also gets `write_file`, and it gets it
|
|
13
|
+
* through `WRITE_TOOLS` rather than through `PROFILE_TOOLS`: a step that is
|
|
14
|
+
* allowed to read a repository should not gain the ability to write to it
|
|
15
|
+
* because somebody appended to the wrong array. `write_file` is refused
|
|
16
|
+
* outright unless the caller asked for it by name, so `createRepoTools()`
|
|
17
|
+
* with its default tool set answers "there is no tool called write_file",
|
|
18
|
+
* exactly as it did before that tool existed.
|
|
19
|
+
*
|
|
20
|
+
* Read-only is enforced here, not asked for in a prompt. There is no shell,
|
|
21
|
+
* no network, and:
|
|
13
22
|
*
|
|
14
23
|
* - every path is resolved and refused if it leaves the root, so `../../`
|
|
15
24
|
* and a symlink out of the tree both fail;
|
|
@@ -33,6 +42,16 @@ export const SKIP_DIRS = Object.freeze([
|
|
|
33
42
|
'.venv', 'venv', '__pycache__', '.next', '.cache', 'coverage'
|
|
34
43
|
]);
|
|
35
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The two names refused at any position in a path, not just as a directory.
|
|
47
|
+
* Both hold the machinery rather than the game: git's own database, and this
|
|
48
|
+
* CLI's journals and reports.
|
|
49
|
+
*/
|
|
50
|
+
export const NEVER_TOUCHED = Object.freeze(['.git', '.ravensight']);
|
|
51
|
+
|
|
52
|
+
/** `SKIP_DIRS`, lowercased once, because the comparison is case insensitive. */
|
|
53
|
+
const SKIP_DIRS_LOWER = Object.freeze(SKIP_DIRS.map(name => name.toLowerCase()));
|
|
54
|
+
|
|
36
55
|
/** Files never read, whatever asks for them. */
|
|
37
56
|
export const SECRET_FILE_PATTERNS = Object.freeze([
|
|
38
57
|
/(^|\/)\.env($|\.)/i,
|
|
@@ -95,6 +114,36 @@ export const PROFILE_TOOLS = Object.freeze([
|
|
|
95
114
|
/** The subset allowed on the synthesis steps. */
|
|
96
115
|
export const READ_ONLY_TOOLS = Object.freeze(PROFILE_TOOLS.filter(tool => tool.name === 'read_file'));
|
|
97
116
|
|
|
117
|
+
/**
|
|
118
|
+
* A NUL byte, built rather than written, so this source file stays plain text
|
|
119
|
+
* that a diff and a code review can read.
|
|
120
|
+
*/
|
|
121
|
+
const NUL = String.fromCharCode(0);
|
|
122
|
+
|
|
123
|
+
/** How many bytes one `write_file` may put on disk. */
|
|
124
|
+
export const MAX_WRITE_BYTES = 200000;
|
|
125
|
+
|
|
126
|
+
/** How many distinct paths one run may write. A fix is a patch, not a rewrite. */
|
|
127
|
+
export const MAX_WRITES_PER_RUN = 20;
|
|
128
|
+
|
|
129
|
+
/** The one write tool, defined here so nothing else can invent a second one. */
|
|
130
|
+
export const WRITE_FILE_TOOL = Object.freeze({
|
|
131
|
+
name: 'write_file',
|
|
132
|
+
description: 'Write a text file in the repository, replacing it whole. Paths are relative to the repository root. Read the file first: there is no patch tool, so the content you send is the file.',
|
|
133
|
+
input_schema: {
|
|
134
|
+
type: 'object',
|
|
135
|
+
additionalProperties: false,
|
|
136
|
+
required: ['path', 'content'],
|
|
137
|
+
properties: {
|
|
138
|
+
path: { type: 'string' },
|
|
139
|
+
content: { type: 'string' }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
/** The tool definitions for `fix.apply`: the read-only three, plus the write. */
|
|
145
|
+
export const WRITE_TOOLS = Object.freeze([...PROFILE_TOOLS, WRITE_FILE_TOOL]);
|
|
146
|
+
|
|
98
147
|
/**
|
|
99
148
|
* Compile a glob into a regular expression. Supports `**`, `*` and `?`,
|
|
100
149
|
* which covers every pattern the profile skill actually asks for
|
|
@@ -133,6 +182,58 @@ export function globToRegExp(pattern) {
|
|
|
133
182
|
return new RegExp(`^${source}$`);
|
|
134
183
|
}
|
|
135
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Whether a path lives inside a directory the walk never enters.
|
|
187
|
+
*
|
|
188
|
+
* `listFiles` already skips these, so a read of one answers nothing useful;
|
|
189
|
+
* a write to one is worse than useless. `.git` is the reason this check exists
|
|
190
|
+
* at all: a model that can write into `.git` can rewrite history without ever
|
|
191
|
+
* running git, and this CLI never rewrites history.
|
|
192
|
+
*
|
|
193
|
+
* Two rules, not one:
|
|
194
|
+
*
|
|
195
|
+
* - The names are compared **case insensitively**, because macOS and Windows
|
|
196
|
+
* filesystems are. `.GIT/hooks/pre-commit` is `.git/hooks/pre-commit` on the
|
|
197
|
+
* developer's disk, and a case-sensitive check let exactly that through:
|
|
198
|
+
* the hook was written, and the diff then reported no change, so the unit
|
|
199
|
+
* was refunded while the repository had been modified.
|
|
200
|
+
* - `.git` and `.ravensight` are refused at **any** position, the last one
|
|
201
|
+
* included. A file called `.git` is a gitfile whose contents repoint the
|
|
202
|
+
* repository at another directory, so writing one is as bad as writing
|
|
203
|
+
* inside `.git` itself. Every other name only matters as a directory: a
|
|
204
|
+
* source file called `build` is ordinary.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} relativePath
|
|
207
|
+
* @returns {string|null} the offending component, or null
|
|
208
|
+
*/
|
|
209
|
+
export function skippedDirIn(relativePath) {
|
|
210
|
+
const raw = String(relativePath).split(path.sep).join('/');
|
|
211
|
+
// A trailing slash means the caller is naming a directory, which is how
|
|
212
|
+
// `git status --porcelain` reports an untracked one (`?? node_modules/`).
|
|
213
|
+
// Then every segment is a directory; otherwise the last one is a file name.
|
|
214
|
+
const namesADirectory = raw.endsWith('/');
|
|
215
|
+
const segments = raw.split('/').filter(Boolean);
|
|
216
|
+
const lowered = segments.map(segment => segment.toLowerCase());
|
|
217
|
+
|
|
218
|
+
const always = lowered.findIndex(segment => NEVER_TOUCHED.includes(segment));
|
|
219
|
+
if (always !== -1) return segments[always];
|
|
220
|
+
|
|
221
|
+
const directories = namesADirectory ? lowered : lowered.slice(0, -1);
|
|
222
|
+
const at = directories.findIndex(segment => SKIP_DIRS_LOWER.includes(segment));
|
|
223
|
+
return at === -1 ? null : segments[at];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Whether one directory name is one the walk skips. Used by `listFiles`, which
|
|
228
|
+
* has the name on its own and needs the same case rules.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} name
|
|
231
|
+
* @returns {boolean}
|
|
232
|
+
*/
|
|
233
|
+
export function isSkippedDirName(name) {
|
|
234
|
+
return SKIP_DIRS_LOWER.includes(String(name).toLowerCase());
|
|
235
|
+
}
|
|
236
|
+
|
|
136
237
|
/**
|
|
137
238
|
* @param {string} relativePath
|
|
138
239
|
* @returns {boolean}
|
|
@@ -144,7 +245,11 @@ export function isSecretPath(relativePath) {
|
|
|
144
245
|
|
|
145
246
|
/**
|
|
146
247
|
* Resolve a model-supplied path against the root, refusing anything that
|
|
147
|
-
* escapes it
|
|
248
|
+
* escapes it **lexically**.
|
|
249
|
+
*
|
|
250
|
+
* This is the cheap half of the check and not the whole of it: `../` and an
|
|
251
|
+
* absolute path are caught here, a symbolic link is not. Every caller that
|
|
252
|
+
* then touches the filesystem goes through `resolveInsideReal` as well.
|
|
148
253
|
*
|
|
149
254
|
* @param {string} root
|
|
150
255
|
* @param {string} candidate
|
|
@@ -159,6 +264,54 @@ export function resolveInside(root, candidate) {
|
|
|
159
264
|
return target;
|
|
160
265
|
}
|
|
161
266
|
|
|
267
|
+
/**
|
|
268
|
+
* The other half: refuse a path that leaves the repository through a link.
|
|
269
|
+
*
|
|
270
|
+
* `resolveInside` compares strings, and a string comparison cannot see that
|
|
271
|
+
* `shared/` is a link to somewhere else entirely. A repository with such a
|
|
272
|
+
* link, which is ordinary in a monorepo or a shared-asset layout, would
|
|
273
|
+
* otherwise let a write land outside the tree with every lexical check
|
|
274
|
+
* passing. So every component from the root down that already exists is
|
|
275
|
+
* checked with `lstat` and refused by name if it is a link, the target
|
|
276
|
+
* included; components that do not exist yet are safe, because `mkdir` creates
|
|
277
|
+
* real directories and never links. The deepest component that does exist is
|
|
278
|
+
* then compared against the root's own real path, which also covers a root
|
|
279
|
+
* reached through a link itself (every temp directory on macOS is one).
|
|
280
|
+
*
|
|
281
|
+
* @param {string} root
|
|
282
|
+
* @param {string} candidate
|
|
283
|
+
* @returns {Promise<{absolute: string, relative: string}>}
|
|
284
|
+
*/
|
|
285
|
+
export async function resolveInsideReal(root, candidate) {
|
|
286
|
+
const base = path.resolve(root);
|
|
287
|
+
const target = resolveInside(base, candidate);
|
|
288
|
+
const realRoot = await realpath(base);
|
|
289
|
+
|
|
290
|
+
const parts = path.relative(base, target).split(path.sep).filter(Boolean);
|
|
291
|
+
let walked = base;
|
|
292
|
+
for (const part of parts) {
|
|
293
|
+
const next = path.join(walked, part);
|
|
294
|
+
let info;
|
|
295
|
+
try {
|
|
296
|
+
info = await lstat(next);
|
|
297
|
+
} catch {
|
|
298
|
+
// This component does not exist yet, so nothing below it does either.
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
if (info.isSymbolicLink()) {
|
|
302
|
+
const named = path.relative(base, next).split(path.sep).join('/');
|
|
303
|
+
throw new Error(`${candidate} goes through ${named}, which is a symbolic link, and a link can point out of the repository`);
|
|
304
|
+
}
|
|
305
|
+
walked = next;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const real = await realpath(walked);
|
|
309
|
+
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
|
|
310
|
+
throw new Error(`${candidate} resolves outside the repository`);
|
|
311
|
+
}
|
|
312
|
+
return { absolute: target, relative: path.relative(base, target).split(path.sep).join('/') };
|
|
313
|
+
}
|
|
314
|
+
|
|
162
315
|
/**
|
|
163
316
|
* @param {string} root
|
|
164
317
|
* @param {Object} [opts]
|
|
@@ -180,7 +333,7 @@ export async function listFiles(root, { limit = 20000 } = {}) {
|
|
|
180
333
|
if (entry.isSymbolicLink()) continue;
|
|
181
334
|
const absolute = path.join(dir, entry.name);
|
|
182
335
|
if (entry.isDirectory()) {
|
|
183
|
-
if (
|
|
336
|
+
if (isSkippedDirName(entry.name) || NEVER_TOUCHED.includes(entry.name.toLowerCase())) continue;
|
|
184
337
|
await walk(absolute);
|
|
185
338
|
continue;
|
|
186
339
|
}
|
|
@@ -200,6 +353,9 @@ export async function listFiles(root, { limit = 20000 } = {}) {
|
|
|
200
353
|
*/
|
|
201
354
|
export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
|
|
202
355
|
let cachedFiles = null;
|
|
356
|
+
const canWrite = tools.some(tool => tool.name === 'write_file');
|
|
357
|
+
/** The distinct paths this run has written, which is what the cap counts. */
|
|
358
|
+
const written = new Set();
|
|
203
359
|
|
|
204
360
|
async function files() {
|
|
205
361
|
if (!cachedFiles) cachedFiles = await listFiles(root);
|
|
@@ -209,6 +365,11 @@ export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
|
|
|
209
365
|
return {
|
|
210
366
|
tools,
|
|
211
367
|
|
|
368
|
+
/** Repository-relative paths this tool set has written, in write order. */
|
|
369
|
+
get written() {
|
|
370
|
+
return [...written];
|
|
371
|
+
},
|
|
372
|
+
|
|
212
373
|
/**
|
|
213
374
|
* @param {Object} toolUse
|
|
214
375
|
*/
|
|
@@ -216,6 +377,17 @@ export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
|
|
|
216
377
|
const input = toolUse.input || {};
|
|
217
378
|
try {
|
|
218
379
|
if (toolUse.name === 'read_file') return { result: ok(toolUse.id, await doRead(root, input)) };
|
|
380
|
+
if (toolUse.name === 'write_file' && canWrite) {
|
|
381
|
+
// A refusal comes back as an `is_error` result naming the rule,
|
|
382
|
+
// never as a thrown error: the model has to be able to read why and
|
|
383
|
+
// try a legal write instead, and a thrown error would end the run.
|
|
384
|
+
const planned = await planWrite(root, input, written);
|
|
385
|
+
if (planned.refusal) return { result: ok(toolUse.id, planned.refusal, true) };
|
|
386
|
+
await doWrite(planned.absolute, input.content);
|
|
387
|
+
written.add(planned.relative);
|
|
388
|
+
const left = MAX_WRITES_PER_RUN - written.size;
|
|
389
|
+
return { result: ok(toolUse.id, `Wrote ${planned.relative} (${planned.bytes} bytes). ${left} more file(s) may be written in this run.`) };
|
|
390
|
+
}
|
|
219
391
|
if (toolUse.name === 'glob') {
|
|
220
392
|
const matcher = globToRegExp(input.pattern);
|
|
221
393
|
const matched = (await files()).filter(file => matcher.test(file)).slice(0, MAX_GLOB_RESULTS);
|
|
@@ -231,11 +403,18 @@ export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
|
|
|
231
403
|
}
|
|
232
404
|
|
|
233
405
|
async function doRead(root, input) {
|
|
234
|
-
const
|
|
235
|
-
if (isSecretPath(
|
|
236
|
-
return `Refused: ${
|
|
406
|
+
const asked = String(input.path || '');
|
|
407
|
+
if (isSecretPath(asked)) {
|
|
408
|
+
return `Refused: ${asked} is a credential file and is never read. Note in warnings that credentials are checked into the repository, without quoting anything.`;
|
|
409
|
+
}
|
|
410
|
+
// The same real-path check the write path makes, for the same reason: a
|
|
411
|
+
// symlinked directory would otherwise let a read leave the repository, and
|
|
412
|
+
// `/etc/passwd` read through `shared/` is still a read of `/etc/passwd`.
|
|
413
|
+
const { absolute, relative } = await resolveInsideReal(root, asked);
|
|
414
|
+
const skipped = skippedDirIn(relative);
|
|
415
|
+
if (skipped) {
|
|
416
|
+
return `Refused: ${relative} is under ${skipped}, which is not part of the game's source.`;
|
|
237
417
|
}
|
|
238
|
-
const absolute = resolveInside(root, relative);
|
|
239
418
|
const info = await stat(absolute);
|
|
240
419
|
if (!info.isFile()) throw new Error(`${relative} is not a file`);
|
|
241
420
|
const text = await readFile(absolute, 'utf8');
|
|
@@ -253,6 +432,83 @@ async function doRead(root, input) {
|
|
|
253
432
|
return slice.length > MAX_READ_BYTES ? `${slice.slice(0, MAX_READ_BYTES)}\n... [truncated]` : slice;
|
|
254
433
|
}
|
|
255
434
|
|
|
435
|
+
/**
|
|
436
|
+
* Every rule a `write_file` has to pass, and the sentence that names the one
|
|
437
|
+
* it failed. The rules are checked in the order that is cheapest to answer
|
|
438
|
+
* and hardest to argue with: where the file is, then what is in it, then how
|
|
439
|
+
* many files this run has already touched.
|
|
440
|
+
*
|
|
441
|
+
* The path is canonicalised before any rule is applied and before it counts
|
|
442
|
+
* against the twenty file cap, so `./src/a.gd`, `src/./a.gd` and
|
|
443
|
+
* `src/ui/../a.gd` are one file rather than three.
|
|
444
|
+
*
|
|
445
|
+
* @param {string} root
|
|
446
|
+
* @param {Object} input the model's tool input
|
|
447
|
+
* @param {Set<string>} written the distinct paths written so far
|
|
448
|
+
* @returns {Promise<{relative: string, absolute: string, bytes: number}|{refusal: string}>}
|
|
449
|
+
*/
|
|
450
|
+
export async function planWrite(root, input, written = new Set()) {
|
|
451
|
+
const asked = String((input && input.path) || '').trim();
|
|
452
|
+
if (!asked) return { refusal: 'Refused: write_file needs a path relative to the repository root.' };
|
|
453
|
+
|
|
454
|
+
let absolute;
|
|
455
|
+
let relative;
|
|
456
|
+
try {
|
|
457
|
+
// The real check rather than the lexical one: a link is how a write leaves
|
|
458
|
+
// a tree that every string comparison says it is inside.
|
|
459
|
+
({ absolute, relative } = await resolveInsideReal(root, asked));
|
|
460
|
+
} catch (error) {
|
|
461
|
+
return { refusal: `Refused: ${String(error && error.message ? error.message : error)}. A fix only ever writes inside the repository itself.` };
|
|
462
|
+
}
|
|
463
|
+
if (!relative) return { refusal: 'Refused: that is the repository root, not a file in it.' };
|
|
464
|
+
|
|
465
|
+
if (isSecretPath(relative)) {
|
|
466
|
+
return { refusal: `Refused: ${relative} is a credential file, and those are never read or written.` };
|
|
467
|
+
}
|
|
468
|
+
const skipped = skippedDirIn(relative);
|
|
469
|
+
if (skipped) {
|
|
470
|
+
return { refusal: `Refused: ${relative} is under ${skipped}, which is generated, vendored or this tool's own bookkeeping, and is never written.` };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const content = input && input.content;
|
|
474
|
+
if (typeof content !== 'string') {
|
|
475
|
+
return { refusal: 'Refused: content has to be a string. Send the whole file as text.' };
|
|
476
|
+
}
|
|
477
|
+
if (content.includes(NUL)) {
|
|
478
|
+
return { refusal: 'Refused: content holds a NUL byte, so that is not a text file. Only text files are written.' };
|
|
479
|
+
}
|
|
480
|
+
const bytes = Buffer.byteLength(content, 'utf8');
|
|
481
|
+
if (bytes > MAX_WRITE_BYTES) {
|
|
482
|
+
return { refusal: `Refused: ${relative} would be ${bytes} bytes and one write may be at most ${MAX_WRITE_BYTES}. Change less of it, or change a smaller file.` };
|
|
483
|
+
}
|
|
484
|
+
if (!written.has(relative) && written.size >= MAX_WRITES_PER_RUN) {
|
|
485
|
+
return { refusal: `Refused: this run has already written ${written.size} files and may write at most ${MAX_WRITES_PER_RUN}. Finish with the files you have already changed.` };
|
|
486
|
+
}
|
|
487
|
+
return { relative, absolute, bytes };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Temp file then rename, in the target's own directory, so an interrupted
|
|
492
|
+
* write leaves the developer's file exactly as it was rather than half of a
|
|
493
|
+
* new one. Rename inside one directory is atomic everywhere this CLI runs.
|
|
494
|
+
*
|
|
495
|
+
* @param {string} absolute
|
|
496
|
+
* @param {string} content
|
|
497
|
+
*/
|
|
498
|
+
async function doWrite(absolute, content) {
|
|
499
|
+
await mkdir(path.dirname(absolute), { recursive: true });
|
|
500
|
+
const tmp = `${absolute}.${process.pid}.${randomUUID()}.tmp`;
|
|
501
|
+
await writeFile(tmp, content, 'utf8');
|
|
502
|
+
try {
|
|
503
|
+
await rename(tmp, absolute);
|
|
504
|
+
} catch (error) {
|
|
505
|
+
// A failed rename would otherwise leave the half-named temp file in the
|
|
506
|
+
// developer's source tree, where the next glob would find it.
|
|
507
|
+
await unlink(tmp).catch(() => {});
|
|
508
|
+
throw error;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
256
512
|
async function doGrep(root, all, input) {
|
|
257
513
|
const regex = new RegExp(String(input.pattern), 'i');
|
|
258
514
|
const matcher = input.glob ? globToRegExp(input.glob) : null;
|
|
@@ -283,4 +539,17 @@ function ok(id, text, isError = false) {
|
|
|
283
539
|
return { type: 'tool_result', tool_use_id: id, content: [{ type: 'text', text }], ...(isError ? { is_error: true } : {}) };
|
|
284
540
|
}
|
|
285
541
|
|
|
286
|
-
export default {
|
|
542
|
+
export default {
|
|
543
|
+
createRepoTools,
|
|
544
|
+
PROFILE_TOOLS,
|
|
545
|
+
READ_ONLY_TOOLS,
|
|
546
|
+
WRITE_TOOLS,
|
|
547
|
+
globToRegExp,
|
|
548
|
+
listFiles,
|
|
549
|
+
planWrite,
|
|
550
|
+
resolveInside,
|
|
551
|
+
resolveInsideReal,
|
|
552
|
+
isSecretPath,
|
|
553
|
+
isSkippedDirName,
|
|
554
|
+
skippedDirIn
|
|
555
|
+
};
|