ravensight-playtest 0.5.2 → 0.6.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.
Files changed (96) hide show
  1. package/README.md +249 -14
  2. package/addons/ravensight_driver/driver.gd +406 -8
  3. package/addons/ravensight_simulate/simulate.gd +360 -0
  4. package/docs/godot-driver.md +70 -8
  5. package/package.json +4 -3
  6. package/src/api/README.md +4 -1
  7. package/src/api/client.js +92 -3
  8. package/src/api/errors.js +17 -1
  9. package/src/api/http.js +43 -17
  10. package/src/assets/formats.js +267 -0
  11. package/src/assets/index.js +260 -0
  12. package/src/assets/integration.js +429 -0
  13. package/src/assets/inventory.js +337 -0
  14. package/src/assets/measure.js +392 -0
  15. package/src/cli.js +165 -0
  16. package/src/commands/assets.js +62 -0
  17. package/src/commands/check.js +4 -1
  18. package/src/commands/create.js +734 -0
  19. package/src/commands/fakeCore.js +16 -1
  20. package/src/commands/feature.js +845 -0
  21. package/src/commands/fix.js +578 -68
  22. package/src/commands/init.js +19 -3
  23. package/src/commands/profile.js +25 -6
  24. package/src/commands/replay.js +204 -0
  25. package/src/commands/resume.js +7 -0
  26. package/src/commands/run.js +200 -24
  27. package/src/commands/template.js +70 -0
  28. package/src/commands/validate.js +414 -0
  29. package/src/commands/validateHosted.js +292 -0
  30. package/src/create/scaffold.js +192 -0
  31. package/src/run/acceptance.js +212 -0
  32. package/src/run/args.js +18 -1
  33. package/src/run/checks.js +705 -0
  34. package/src/run/dimension.js +95 -0
  35. package/src/run/drivers/godot-observation.js +59 -11
  36. package/src/run/drivers/godot-project.js +201 -31
  37. package/src/run/drivers/godot.js +532 -45
  38. package/src/run/drivers/web.js +18 -0
  39. package/src/run/evidence.js +170 -0
  40. package/src/run/featureLoop.js +288 -0
  41. package/src/run/fixLoop.js +82 -12
  42. package/src/run/git.js +110 -20
  43. package/src/run/json.js +65 -9
  44. package/src/run/labsOutcome.js +136 -0
  45. package/src/run/liveWork.js +103 -0
  46. package/src/run/model.js +105 -5
  47. package/src/run/openPr.js +107 -0
  48. package/src/run/paths.js +4 -1
  49. package/src/run/pendingRegister.js +81 -0
  50. package/src/run/personaLoop.js +111 -4
  51. package/src/run/profile.js +68 -7
  52. package/src/run/replay.js +236 -0
  53. package/src/run/repoTools.js +227 -27
  54. package/src/run/report.js +7 -1
  55. package/src/run/resume.js +19 -1
  56. package/src/run/secretScan.js +35 -0
  57. package/src/run/settle.js +41 -0
  58. package/src/run/tools.js +33 -4
  59. package/src/run/trace.js +124 -0
  60. package/src/simulation/godotPack.js +112 -0
  61. package/src/ui/index.js +61 -9
  62. package/src/upload/allowlist.js +6 -2
  63. package/src/upload/index.js +5 -4
  64. package/templates/godot-2d/README.md +36 -0
  65. package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
  66. package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
  67. package/templates/godot-2d/export_presets.cfg +27 -0
  68. package/templates/godot-2d/gitignore +10 -0
  69. package/templates/godot-2d/project.godot +58 -0
  70. package/templates/godot-2d/scenes/main.tscn +67 -0
  71. package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
  72. package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
  73. package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
  74. package/templates/godot-2d/scripts/main.gd +32 -0
  75. package/templates/godot-2d/scripts/player.gd +10 -0
  76. package/templates/godot-2d/scripts/state_hook.gd +16 -0
  77. package/templates/godot-2d/template.json +14 -0
  78. package/templates/godot-2d/tools/inject_release_key.sh +19 -0
  79. package/templates/godot-2d/tools/selftest.gd +70 -0
  80. package/templates/godot-3d/README.md +49 -0
  81. package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
  82. package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
  83. package/templates/godot-3d/export_presets.cfg +27 -0
  84. package/templates/godot-3d/gitignore +10 -0
  85. package/templates/godot-3d/project.godot +56 -0
  86. package/templates/godot-3d/scenes/main.tscn +154 -0
  87. package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
  88. package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
  89. package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
  90. package/templates/godot-3d/scripts/beacon.gd +20 -0
  91. package/templates/godot-3d/scripts/main.gd +49 -0
  92. package/templates/godot-3d/scripts/player.gd +19 -0
  93. package/templates/godot-3d/scripts/state_hook.gd +25 -0
  94. package/templates/godot-3d/template.json +16 -0
  95. package/templates/godot-3d/tools/inject_release_key.sh +19 -0
  96. package/templates/godot-3d/tools/selftest.gd +77 -0
@@ -1,7 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { lstat, mkdir, readFile, readdir, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises';
2
+ import { chmod, lstat, mkdir, readFile, readdir, realpath, rename, stat, unlink, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
- import { scanForSecrets } from './secretScan.js';
4
+ import vm from 'node:vm';
5
+ import { isMediaPath } from '../assets/inventory.js';
6
+ import { allIgnoredFiles, hooksDirectory, ignoredPaths } from './git.js';
7
+ import { MAX_SCAN_BYTES, scanForSecrets } from './secretScan.js';
5
8
 
6
9
  /**
7
10
  * The read-only source tools for `game_profile`: `read_file`, `glob` and
@@ -49,6 +52,20 @@ export const SKIP_DIRS = Object.freeze([
49
52
  */
50
53
  export const NEVER_TOUCHED = Object.freeze(['.git', '.ravensight']);
51
54
 
55
+ /**
56
+ * What a fix reports with: this CLI writes them beside its journals once the fix
57
+ * is done, and they are never part of the change. A model once wrote them into
58
+ * the repository root as well, where they were counted as the fix's own edits
59
+ * ("+77 -1" for a three line change). Only at the root, and only when the project
60
+ * does not already have one: a project's own report.md is the project's.
61
+ */
62
+ export const RUN_OUTPUT_NAMES = Object.freeze(['changeset.json', 'report.md']);
63
+
64
+ /** @param {string} relativePath */
65
+ export function isRunOutputName(relativePath) {
66
+ return RUN_OUTPUT_NAMES.includes(String(relativePath).toLowerCase());
67
+ }
68
+
52
69
  /** `SKIP_DIRS`, lowercased once, because the comparison is case insensitive. */
53
70
  const SKIP_DIRS_LOWER = Object.freeze(SKIP_DIRS.map(name => name.toLowerCase()));
54
71
 
@@ -57,10 +74,77 @@ export const SECRET_FILE_PATTERNS = Object.freeze([
57
74
  /(^|\/)\.env($|\.)/i,
58
75
  /(^|\/)\.npmrc$/i,
59
76
  /(^|\/)id_(rsa|dsa|ecdsa|ed25519)$/i,
60
- /(^|\/)credentials$/i,
61
- /\.(pem|key|p12|pfx|keystore|jks)$/i
77
+ /(^|\/)credentials(\.[a-z]+)?$/i,
78
+ /\.(pem|key|p8|ppk|p12|pfx|keystore|jks)$/i,
79
+ /(^|\/)[._]netrc$/i,
80
+ /(^|\/)\.(git-credentials|pgpass|pypirc|htpasswd|dockercfg)$/i,
81
+ /(^|\/)secrets?\.(json|ya?ml|toml|cfg|ini|xml|properties)$/i,
82
+ /(^|\/)(google-services\.json|googleservice-info\.plist)$/i,
83
+ /(^|\/)service[-_]?account[^/]*\.json$/i,
84
+ /(^|\/)\.(aws|ssh|gnupg|kube|docker)\//i,
85
+ // Godot keeps signing passwords here: in export_presets.cfg up to 4.2, and in
86
+ // export_credentials.cfg after it.
87
+ /(^|\/)export_(presets|credentials)\.cfg$/i,
88
+ // A game's Ravensight key file, written by `create` and ignored by git.
89
+ /(^|\/)ravensight_key(\.[a-z]+)?\.cfg$/i
62
90
  ]);
63
91
 
92
+ /**
93
+ * Files that are not the game. What is in them runs on a developer's or a CI
94
+ * machine, or changes what git shows, so a write here is either code execution
95
+ * (`git commit` runs a hook, and `--open-pr` commits) or a change that hides other
96
+ * changes (a `.gitignore` that ignores the file just written). None of it is what
97
+ * a playtest finding asks for, so it is never written, whatever the model read.
98
+ */
99
+ export const NEVER_WRITTEN_PATTERNS = Object.freeze([
100
+ /(^|\/)\.(husky|githooks)(\/|$)/i,
101
+ /(^|\/)\.git(ignore|attributes|modules|config)$/i,
102
+ /(^|\/)\.mailmap$/i,
103
+ /(^|\/)\.envrc$/i,
104
+ /^\.github\/(workflows|actions)\//i,
105
+ /^\.gitlab-ci\.ya?ml$/i,
106
+ /^\.circleci\//i,
107
+ /^\.pre-commit-config\.ya?ml$/i,
108
+ /(^|\/)\.?lefthook(-local)?\.ya?ml$/i,
109
+ /(^|\/)\.vscode\//i
110
+ ]);
111
+
112
+ /**
113
+ * A file name that means something else somewhere else: control characters and
114
+ * invisible or direction-changing ones (a terminal, a code review), a trailing dot
115
+ * or space and a colon or short name (`.git.`, `.git::$INDEX_ALLOCATION` and `git~1`
116
+ * are `.git` on Windows), and a backslash (a separator on Windows, a filename here).
117
+ */
118
+ /* eslint-disable-next-line no-control-regex */
119
+ const UNSAFE_NAME_CHARS = /[\u0000-\u001f\u007f-\u009f\u00ad\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff:\\]/;
120
+
121
+ /**
122
+ * @param {string} relative
123
+ * @returns {boolean}
124
+ */
125
+ function isUnsafeName(relative) {
126
+ if (UNSAFE_NAME_CHARS.test(relative)) return true;
127
+ return relative.split('/').some(segment => /[. ]$/.test(segment) || /~\d+$/.test(segment));
128
+ }
129
+
130
+ /**
131
+ * The directory under the root that is its own repository, if the path goes
132
+ * through one. The outer diff shows a submodule as one line with no counts and a
133
+ * nested repository's new files not at all, so a write there is one nobody reviews.
134
+ *
135
+ * @param {string} root
136
+ * @param {string} relative
137
+ * @returns {Promise<string|null>}
138
+ */
139
+ async function nestedRepositoryIn(root, relative) {
140
+ const parts = relative.split('/').slice(0, -1);
141
+ for (let i = 1; i <= parts.length; i += 1) {
142
+ const directory = parts.slice(0, i).join('/');
143
+ if (await lstat(path.join(root, directory, '.git')).then(() => true, () => false)) return directory;
144
+ }
145
+ return null;
146
+ }
147
+
64
148
  /** How much of one file a single read returns. */
65
149
  export const MAX_READ_BYTES = 120000;
66
150
 
@@ -381,14 +465,18 @@ export async function listFiles(root, { limit = 20000 } = {}) {
381
465
  * @param {string} args.root the repository root
382
466
  * @returns {{tools: Array, execute: (toolUse: Object) => Promise<{result: Object}>}}
383
467
  */
384
- export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
468
+ export function createRepoTools({ root, tools = PROFILE_TOOLS, maxWrites = MAX_WRITES_PER_RUN }) {
385
469
  let cachedFiles = null;
386
470
  const canWrite = tools.some(tool => tool.name === 'write_file');
387
471
  /** The distinct paths this run has written, which is what the cap counts. */
388
472
  const written = new Set();
389
473
 
390
474
  async function files() {
391
- if (!cachedFiles) cachedFiles = await listFiles(root);
475
+ if (!cachedFiles) {
476
+ const all = await listFiles(root);
477
+ const ignored = await allIgnoredFiles(root);
478
+ cachedFiles = all.filter(file => !ignored.has(file));
479
+ }
392
480
  return cachedFiles;
393
481
  }
394
482
 
@@ -411,25 +499,23 @@ export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
411
499
  // A refusal comes back as an `is_error` result naming the rule,
412
500
  // never as a thrown error: the model has to be able to read why and
413
501
  // try a legal write instead, and a thrown error would end the run.
414
- const planned = await planWrite(root, input, written);
502
+ const planned = await planWrite(root, input, written, maxWrites);
415
503
  if (planned.refusal) return { result: ok(toolUse.id, planned.refusal, true) };
416
504
  await doWrite(planned.absolute, input.content);
417
505
  written.add(planned.relative);
418
- const left = MAX_WRITES_PER_RUN - written.size;
506
+ const left = maxWrites - written.size;
419
507
  return { result: ok(toolUse.id, `Wrote ${planned.relative} (${planned.bytes} bytes). ${left} more file(s) may be written in this run.`) };
420
508
  }
421
509
  if (toolUse.name === 'edit_file' && canWrite) {
422
- const planned = await planEdit(root, input, written);
510
+ const planned = await planEdit(root, input, written, maxWrites);
423
511
  if (planned.refusal) return { result: ok(toolUse.id, planned.refusal, true) };
424
512
  await doWrite(planned.absolute, planned.content);
425
513
  written.add(planned.relative);
426
- const left = MAX_WRITES_PER_RUN - written.size;
514
+ const left = maxWrites - written.size;
427
515
  return { result: ok(toolUse.id, `Edited ${planned.relative}: ${planned.replaced} occurrence(s) replaced, now ${planned.bytes} bytes. ${left} more file(s) may be written in this run.`) };
428
516
  }
429
517
  if (toolUse.name === 'glob') {
430
- const matcher = globToRegExp(input.pattern);
431
- const matched = (await files()).filter(file => matcher.test(file)).slice(0, MAX_GLOB_RESULTS);
432
- return { result: ok(toolUse.id, matched.length > 0 ? matched.join('\n') : '(no matches)') };
518
+ return { result: ok(toolUse.id, doGlob(await files(), input.pattern)) };
433
519
  }
434
520
  if (toolUse.name === 'grep') return { result: ok(toolUse.id, await doGrep(root, await files(), input)) };
435
521
  return { result: ok(toolUse.id, `There is no tool called ${toolUse.name}.`, true) };
@@ -440,6 +526,12 @@ export function createRepoTools({ root, tools = PROFILE_TOOLS }) {
440
526
  };
441
527
  }
442
528
 
529
+ /**
530
+ * Art, audio and anything else that is not text never reach the model: the bytes say
531
+ * nothing it can use, and they would spend the run's budget and leave this machine.
532
+ */
533
+ const NOT_TEXT = relative => `Refused: ${relative} is an art, audio or other binary file, and its content is never read or sent.`;
534
+
443
535
  async function doRead(root, input) {
444
536
  const asked = String(input.path || '');
445
537
  if (isSecretPath(asked)) {
@@ -449,13 +541,28 @@ async function doRead(root, input) {
449
541
  // symlinked directory would otherwise let a read leave the repository, and
450
542
  // `/etc/passwd` read through `shared/` is still a read of `/etc/passwd`.
451
543
  const { absolute, relative } = await resolveInsideReal(root, asked);
544
+ // Again on the resolved path: `.env/` and `.env/.` are spellings of `.env` that
545
+ // the filesystem accepts and the patterns above do not.
546
+ if (isSecretPath(relative)) {
547
+ return `Refused: ${relative} is a credential file and is never read. Note in warnings that credentials are checked into the repository, without quoting anything.`;
548
+ }
452
549
  const skipped = skippedDirIn(relative);
453
550
  if (skipped) {
454
551
  return `Refused: ${relative} is under ${skipped}, which is not part of the game's source.`;
455
552
  }
553
+ // What a developer told git to leave alone is local to this machine, and it is
554
+ // where a key with no recognisable shape lives.
555
+ if (relative && (await ignoredPaths(root, [relative])).has(relative)) {
556
+ return `Refused: ${relative} is gitignored, so it is local to this machine and is never read.`;
557
+ }
456
558
  const info = await stat(absolute);
457
559
  if (!info.isFile()) throw new Error(`${relative} is not a file`);
560
+ if (isMediaPath(relative)) return NOT_TEXT(relative);
561
+ // Refused before it is loaded: a raw asset dump would otherwise be read into
562
+ // memory whole, only to be refused by the scan below.
563
+ if (info.size > MAX_SCAN_BYTES) return `Refused: ${relative} is too large (${info.size} bytes) to read here.`;
458
564
  const text = await readFile(absolute, 'utf8');
565
+ if (text.includes(NUL)) return NOT_TEXT(relative);
459
566
  const secrets = scanForSecrets(text);
460
567
  if (secrets.length > 0) {
461
568
  // The content is withheld rather than redacted: a partial redaction of
@@ -485,7 +592,7 @@ async function doRead(root, input) {
485
592
  * @param {Set<string>} written the distinct paths written so far
486
593
  * @returns {Promise<{relative: string, absolute: string, bytes: number}|{refusal: string}>}
487
594
  */
488
- export async function planWrite(root, input, written = new Set()) {
595
+ export async function planWrite(root, input, written = new Set(), maxWrites = MAX_WRITES_PER_RUN) {
489
596
  const asked = String((input && input.path) || '').trim();
490
597
  if (!asked) return { refusal: 'Refused: write_file needs a path relative to the repository root.' };
491
598
 
@@ -507,6 +614,31 @@ export async function planWrite(root, input, written = new Set()) {
507
614
  if (skipped) {
508
615
  return { refusal: `Refused: ${relative} is under ${skipped}, which is generated, vendored or this tool's own bookkeeping, and is never written.` };
509
616
  }
617
+ if (isUnsafeName(relative)) {
618
+ return { refusal: 'Refused: that file name holds characters that are unsafe in a terminal, in git or on another filesystem. Use a plain name.' };
619
+ }
620
+ if (NEVER_WRITTEN_PATTERNS.some(pattern => pattern.test(relative))) {
621
+ return { refusal: `Refused: ${relative} controls git or runs on a developer's or CI machine, and is never written. Change only the game.` };
622
+ }
623
+ const hooks = await hooksDirectory(root);
624
+ if (hooks && (absolute === hooks || absolute.startsWith(hooks + path.sep))) {
625
+ return { refusal: `Refused: ${relative} is in this repository's git hooks directory, and is never written.` };
626
+ }
627
+ const nested = await nestedRepositoryIn(root, relative);
628
+ if (nested) {
629
+ return { refusal: `Refused: ${nested} is its own git repository, so a change inside it would not show in this branch's diff. Change only this repository's files.` };
630
+ }
631
+ if (isMediaPath(relative)) {
632
+ return { refusal: `Refused: ${relative} is an art, audio or other binary file, and only text files are written.` };
633
+ }
634
+ // Git would never show this write: the changeset would call it no change, the
635
+ // unit would be refunded, and the developer's file would stay edited.
636
+ if ((await ignoredPaths(root, [relative])).has(relative)) {
637
+ return { refusal: `Refused: ${relative} is gitignored, so a change to it could never be reviewed. Change a tracked file, or a new file git will see.` };
638
+ }
639
+ if (isRunOutputName(relative) && !(await lstat(absolute).catch(() => null))) {
640
+ return { refusal: `Refused: ${relative} is a report this CLI writes itself once you stop, and it is never part of the change. Change only the project's own files.` };
641
+ }
510
642
 
511
643
  const content = input && input.content;
512
644
  if (typeof content !== 'string') {
@@ -519,8 +651,13 @@ export async function planWrite(root, input, written = new Set()) {
519
651
  if (bytes > MAX_WRITE_BYTES) {
520
652
  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.` };
521
653
  }
522
- if (!written.has(relative) && written.size >= MAX_WRITES_PER_RUN) {
523
- 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.` };
654
+ const existing = await lstat(absolute).catch(() => null);
655
+ if (existing && !existing.isFile()) return { refusal: `Refused: ${relative} is not a regular file.` };
656
+ if (existing && !isPlainText(await readFile(absolute).catch(() => Buffer.alloc(0)))) {
657
+ return { refusal: `Refused: ${relative} is not UTF-8 text, and replacing it would destroy it.` };
658
+ }
659
+ if (!written.has(relative) && written.size >= maxWrites) {
660
+ return { refusal: `Refused: this run has already written ${written.size} files and may write at most ${maxWrites}. Finish with the files you have already changed.` };
524
661
  }
525
662
  return { relative, absolute, bytes };
526
663
  }
@@ -545,7 +682,7 @@ export async function planWrite(root, input, written = new Set()) {
545
682
  * @param {Set<string>} written
546
683
  * @returns {Promise<{refusal: string}|{relative: string, absolute: string, content: string, bytes: number, replaced: number}>}
547
684
  */
548
- export async function planEdit(root, input, written = new Set()) {
685
+ export async function planEdit(root, input, written = new Set(), maxWrites = MAX_WRITES_PER_RUN) {
549
686
  const oldText = input && input.old_text;
550
687
  const newText = input && input.new_text;
551
688
  if (typeof oldText !== 'string' || oldText.length === 0) {
@@ -554,16 +691,19 @@ export async function planEdit(root, input, written = new Set()) {
554
691
  if (typeof newText !== 'string') return { refusal: 'Refused: new_text has to be a string (it may be empty to delete the passage).' };
555
692
  // The path rules are the write rules; the placeholder content only has to be
556
693
  // a string so the shared checks run.
557
- const planned = await planWrite(root, { path: input && input.path, content: '' }, written);
694
+ const planned = await planWrite(root, { path: input && input.path, content: '' }, written, maxWrites);
558
695
  if (planned.refusal) return { refusal: planned.refusal.replace('write_file', 'edit_file') };
559
696
 
560
697
  let current;
561
698
  try {
562
- current = await readFile(planned.absolute, 'utf8');
699
+ const raw = await readFile(planned.absolute);
700
+ // Decoding other bytes as UTF-8 and writing them back would corrupt every
701
+ // character outside the passage being replaced.
702
+ if (!isPlainText(raw)) return { refusal: `Refused: ${planned.relative} is not UTF-8 text, so it cannot be edited here without damaging it.` };
703
+ current = raw.toString('utf8');
563
704
  } catch {
564
705
  return { refusal: `Refused: ${planned.relative} does not exist yet. Use write_file to create a new file.` };
565
706
  }
566
- if (current.includes(NUL)) return { refusal: `Refused: ${planned.relative} is not a text file.` };
567
707
 
568
708
  const occurrences = current.split(oldText).length - 1;
569
709
  if (occurrences === 0) {
@@ -584,8 +724,11 @@ export async function planEdit(root, input, written = new Set()) {
584
724
  async function doWrite(absolute, content) {
585
725
  await mkdir(path.dirname(absolute), { recursive: true });
586
726
  const tmp = `${absolute}.${process.pid}.${randomUUID()}.tmp`;
587
- await writeFile(tmp, content, 'utf8');
727
+ // A rename takes the new file's permissions, and git records the executable bit.
728
+ const before = await stat(absolute).catch(() => null);
588
729
  try {
730
+ await writeFile(tmp, content, 'utf8');
731
+ if (before) await chmod(tmp, before.mode & 0o777);
589
732
  await rename(tmp, absolute);
590
733
  } catch (error) {
591
734
  // A failed rename would otherwise leave the half-named temp file in the
@@ -595,29 +738,72 @@ async function doWrite(absolute, content) {
595
738
  }
596
739
  }
597
740
 
741
+ /**
742
+ * A model's pattern is untrusted, and a catastrophic one spins inside one
743
+ * synchronous call: no timer fires, no signal handler runs, Ctrl-C does nothing and
744
+ * the job's heartbeat stops. So every pattern runs under a time limit, in a script
745
+ * V8 can interrupt, and a limit reached is an answer the model can act on.
746
+ */
747
+ export const MAX_PATTERN_MS = 3000;
748
+
749
+ const SCAN = new vm.Script('for (; next < items.length && found.length < limit; next += 1) { if (pattern.test(items[next])) found.push(next); }');
750
+
751
+ /**
752
+ * @param {number} [budgetMs]
753
+ * @returns {(pattern: RegExp, items: Array<string>, limit: number) => Array<number>|null} matching
754
+ * indexes; `stopped` says the budget ran out, and what was found so far stands
755
+ */
756
+ function boundedMatcher(budgetMs = MAX_PATTERN_MS) {
757
+ const context = vm.createContext({ pattern: null, items: [], limit: 0, found: [], next: 0 });
758
+ const scan = (pattern, items, limit) => {
759
+ Object.assign(context, { pattern, items, limit, found: [], next: 0 });
760
+ const started = Date.now();
761
+ if (budgetMs > 0) {
762
+ try {
763
+ SCAN.runInContext(context, { timeout: Math.max(1, Math.ceil(budgetMs)) });
764
+ } catch (error) {
765
+ if (!error || error.code !== 'ERR_SCRIPT_EXECUTION_TIMEOUT') throw error;
766
+ budgetMs = 0;
767
+ }
768
+ if (budgetMs > 0) budgetMs -= Date.now() - started;
769
+ }
770
+ scan.stopped = budgetMs <= 0;
771
+ return context.found;
772
+ };
773
+ scan.stopped = false;
774
+ return scan;
775
+ }
776
+
777
+ const TOO_SLOW = 'that pattern takes too long to run here, so use a simpler one';
778
+
598
779
  async function doGrep(root, all, input) {
599
780
  const regex = new RegExp(String(input.pattern), 'i');
600
781
  const matcher = input.glob ? globToRegExp(input.glob) : null;
601
782
  const hits = [];
602
- for (const relative of all) {
603
- if (hits.length >= MAX_GREP_RESULTS) break;
604
- if (matcher && !matcher.test(relative)) continue;
783
+ const scan = boundedMatcher();
784
+ const candidates = matcher ? scan(matcher, all, Number.MAX_SAFE_INTEGER).map(index => all[index]) : all;
785
+ for (const relative of candidates) {
786
+ if (hits.length >= MAX_GREP_RESULTS || scan.stopped) break;
787
+ if (isMediaPath(relative)) continue;
605
788
  let text;
606
789
  try {
607
790
  const info = await stat(path.join(root, relative));
608
- if (info.size > MAX_READ_BYTES) continue;
791
+ // A named pipe has no size and a read of one never returns.
792
+ if (!info.isFile() || info.size > MAX_READ_BYTES) continue;
609
793
  text = await readFile(path.join(root, relative), 'utf8');
610
794
  } catch {
611
795
  continue;
612
796
  }
797
+ if (text.includes(NUL)) continue;
613
798
  // A file with secrets in it is not searched either: a grep result is a
614
799
  // quoted line, and a quoted line from such a file is the leak.
615
800
  if (scanForSecrets(text).length > 0) continue;
616
801
  const lines = text.split('\n');
617
- for (let i = 0; i < lines.length && hits.length < MAX_GREP_RESULTS; i += 1) {
618
- if (regex.test(lines[i])) hits.push(`${relative}:${i + 1}: ${lines[i].trim().slice(0, 300)}`);
802
+ for (const i of scan(regex, lines, MAX_GREP_RESULTS - hits.length)) {
803
+ hits.push(`${relative}:${i + 1}: ${lines[i].trim().slice(0, 300)}`);
619
804
  }
620
805
  }
806
+ if (scan.stopped) return `${hits.length > 0 ? `${hits.join('\n')}\n` : ''}(stopped: ${TOO_SLOW})`;
621
807
  if (hits.length === 0) return '(no matches)';
622
808
  const capped = hits.length >= MAX_GREP_RESULTS;
623
809
  return capped
@@ -625,6 +811,20 @@ async function doGrep(root, all, input) {
625
811
  : hits.join('\n');
626
812
  }
627
813
 
814
+ /** The paths a glob matches, under the same limit a grep runs under. */
815
+ function doGlob(all, pattern) {
816
+ const scan = boundedMatcher();
817
+ const matched = scan(globToRegExp(pattern), all, MAX_GLOB_RESULTS).map(index => all[index]);
818
+ if (scan.stopped) return `${matched.length > 0 ? `${matched.join('\n')}\n` : ''}(stopped: ${TOO_SLOW})`;
819
+ return matched.length > 0 ? matched.join('\n') : '(no matches)';
820
+ }
821
+
822
+ /** No NUL byte, and decoding then encoding gives back exactly these bytes. */
823
+ function isPlainText(buffer) {
824
+ if (buffer.includes(0)) return false;
825
+ return Buffer.from(buffer.toString('utf8'), 'utf8').equals(buffer);
826
+ }
827
+
628
828
  function ok(id, text, isError = false) {
629
829
  return { type: 'tool_result', tool_use_id: id, content: [{ type: 'text', text }], ...(isError ? { is_error: true } : {}) };
630
830
  }
package/src/run/report.js CHANGED
@@ -120,7 +120,13 @@ export function stampReport(report, facts) {
120
120
  // A run the proxy cut off for money did not quit for the reason the model
121
121
  // thinks it did. `cost_cap` is in the schema's quit_reason enum precisely
122
122
  // for this.
123
- if (facts.quitReason) stamped.quit_reason = facts.quitReason;
123
+ //
124
+ // The one thing the runner cannot know is whether a persona that chose to stop
125
+ // had finished what it came for, so the model's own `goal_reached` stands over a
126
+ // plain `persona_quit`. No money reads the difference; a validation's completion
127
+ // measure does, and without this no real run could ever have reached a goal.
128
+ const reachedGoal = facts.quitReason === 'persona_quit' && stamped.quit_reason === 'goal_reached';
129
+ if (facts.quitReason && !reachedGoal) stamped.quit_reason = facts.quitReason;
124
130
  // Why it stopped, in the runner's words rather than the model's guess: the
125
131
  // action budget ran out, the screen stopped changing, the proxy cut it off.
126
132
  if (facts.quitDetail) stamped.quit_detail = facts.quitDetail;
package/src/run/resume.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { optionsFrom } from './args.js';
2
+ import { finishJob } from './settle.js';
2
3
  import { getDeps } from './deps.js';
3
4
  import EXIT from './exit.js';
4
5
  import { runsMatchPlan } from './controllers.js';
@@ -9,6 +10,9 @@ import { createReportValidator, writeReportFiles } from './report.js';
9
10
  import * as stateFile from './state.js';
10
11
  import { playJob, resolvePersonas } from '../commands/run.js';
11
12
 
13
+ /** Server states that only a live process can be in, and this one is resuming it. */
14
+ const ABANDONED_MID_RUN = Object.freeze(['launching', 'playing']);
15
+
12
16
  /**
13
17
  * `resumeJob`, the hand-off cli-core's `resume` command makes once it has
14
18
  * decided there is something to carry on with.
@@ -58,6 +62,12 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
58
62
  log(`There is no journal for ${jobId} in this repository, so there is nothing to resume from. "ravensight-playtest upload ${jobId}" can still drain any pending uploads.`);
59
63
  return EXIT.ENVIRONMENT;
60
64
  }
65
+ // Its runs play two extracted commits this path knows nothing about, so playing
66
+ // them here would put both sides on one build.
67
+ if (journal.validate) {
68
+ log(`${jobId} is a validation. Carry on with "ravensight-playtest validate --resume ${jobId}".`);
69
+ return EXIT.ENVIRONMENT;
70
+ }
61
71
  const controllers = job.controllers?.length ? job.controllers : null;
62
72
  if (journal.job_id !== jobId || journal.game_id !== gameId ||
63
73
  JSON.stringify(journal.options?.controllers || null) !== JSON.stringify(controllers) ||
@@ -87,6 +97,12 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
87
97
  journal.runs[row.run_id] = stateFile.newRunEntry({ runId: row.run_id, module: row.module, persona: row.persona });
88
98
  }
89
99
  const entry = journal.runs[row.run_id];
100
+ // A process killed outright never said it was stopping, and the server refuses
101
+ // `launching` to `launching`, so a replay of this run would die on its first move.
102
+ if (ABANDONED_MID_RUN.includes(row.state)) {
103
+ await api.runs.transition(gameId, jobId, row.run_id, 'interrupted', { reason: 'the process that was playing it is gone' });
104
+ row.state = 'interrupted';
105
+ }
90
106
  entry.state = row.state;
91
107
  if (stateFile.RUN_DONE_STATES.includes(row.state)) entry.completed = true;
92
108
  if (Number.isFinite(Number(row.actions_taken))) entry.actions_taken = Number(row.actions_taken);
@@ -127,10 +143,12 @@ export async function resumeJob({ api, gameId, jobId, job, runs, state, repoRoot
127
143
  if (outstanding.length === 0) {
128
144
  log(`${finalized.length} interrupted run(s) were finalised from their own transcripts. Nothing left to replay.`);
129
145
  if (!isTerminalJob(job)) {
130
- const settled = await api.jobs.finish(gameId, jobId, { reason: 'resumed and finalised' });
146
+ const settled = await finishJob({ api, gameId, jobId, body: { reason: 'resumed and finalised' }, log });
147
+ if (!settled) return EXIT.OK;
131
148
  journal.finished = true;
132
149
  await stateFile.save(repoRoot, journal);
133
150
  log(`job ${jobId} ${settled.job ? settled.job.state : 'finished'}: refunded ${formatCents(settled.refunded_cents)}.`);
151
+ if (settled.job && ['failed', 'stalled'].includes(settled.job.state)) return EXIT.FAILED;
134
152
  }
135
153
  return EXIT.OK;
136
154
  }
@@ -169,3 +169,38 @@ export function assertNoSecrets(what, text) {
169
169
  const secrets = scanForSecrets(text);
170
170
  if (secrets.length > 0) throw new SecretsFoundError(what, secrets);
171
171
  }
172
+
173
+ /**
174
+ * A changeset whose pull request text does not quote a secret. The text is the
175
+ * model's, written after reading source, and it is the one part of a changeset
176
+ * that can go without: a refusal here would throw away a finished change over its
177
+ * description. So the match goes, the developer is told without being shown it, and
178
+ * `assertNoSecrets` still refuses anything else in the file.
179
+ *
180
+ * @param {Object} changeset
181
+ * @param {(message: string) => void} [log]
182
+ * @returns {Object}
183
+ */
184
+ export function withoutQuotedSecrets(changeset, log = () => {}) {
185
+ const found = scanForSecrets(`${changeset.pr_title || ''}\n${changeset.pr_body || ''}`);
186
+ if (found.length === 0) return changeset;
187
+ log(`The model's pull request text quoted something shaped like a secret (${found.map(item => item.type).join(', ')}). It was removed from the text and the change was kept. If it is a real key, rotate it.`);
188
+ return { ...changeset, pr_title: redactSecrets(changeset.pr_title), pr_body: redactSecrets(changeset.pr_body) };
189
+ }
190
+
191
+ /**
192
+ * The text with every secret shaped match replaced by `[redacted <type>]`,
193
+ * for local diagnostics that must still be readable. Files that are uploaded
194
+ * use assertNoSecrets and are refused instead.
195
+ *
196
+ * @param {string} text
197
+ * @returns {string}
198
+ */
199
+ export function redactSecrets(text) {
200
+ let out = String(text == null ? '' : text);
201
+ for (const { type, regex, accept } of PATTERNS) {
202
+ const pattern = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : `${regex.flags}g`);
203
+ out = out.replace(pattern, match => (!accept || accept(match) ? `[redacted ${type}]` : match));
204
+ }
205
+ return out;
206
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * What a job's finish call says when the server could not settle it. 409
3
+ * `settle_contended` is already retried by the HTTP layer on its `Retry-After`, so
4
+ * reaching this means the retries ran out: nothing moved, the job is still open,
5
+ * and it is not this CLI's to settle any other way.
6
+ */
7
+
8
+ /** @param {string} jobId */
9
+ export function stillOpenLine(jobId) {
10
+ return `job ${jobId} is still open: the server could not settle it after several tries, and no money moved. The hourly sweep will settle it, so there is nothing to do.`;
11
+ }
12
+
13
+ /**
14
+ * @param {string} jobId
15
+ * @param {unknown} error
16
+ * @returns {string}
17
+ */
18
+ export function settleFailureLine(jobId, error) {
19
+ if (error && error.code === 'settle_contended') return stillOpenLine(jobId);
20
+ return `the job could not be settled now; the hourly sweep will settle it: ${String(error && error.message)}`;
21
+ }
22
+
23
+ /**
24
+ * `api.jobs.finish`, except a contended settle is an answer rather than a crash at
25
+ * the end of a run that already did its work.
26
+ *
27
+ * @returns {Promise<Object|null>} the finish answer, or null when the job is still open
28
+ */
29
+ export async function finishJob({ api, gameId, jobId, body, log }) {
30
+ try {
31
+ const finished = await api.jobs.finish(gameId, jobId, body);
32
+ if (finished && finished.refund_limited_note) log(finished.refund_limited_note);
33
+ return finished;
34
+ } catch (error) {
35
+ if (!error || error.code !== 'settle_contended') throw error;
36
+ log(stillOpenLine(jobId));
37
+ return null;
38
+ }
39
+ }
40
+
41
+ export default { stillOpenLine, settleFailureLine, finishJob };
package/src/run/tools.js CHANGED
@@ -35,6 +35,12 @@ export const NOTE_KINDS = Object.freeze(['bug', 'confusing', 'good', 'suggestion
35
35
  /** Severities, from the report schema's finding severity enum. */
36
36
  export const SEVERITIES = Object.freeze(['blocker', 'major', 'minor', 'cosmetic', 'praise']);
37
37
 
38
+ /** The longest a persona may hold a key in one action (godot_driver's own cap). */
39
+ export const MAX_HOLD_MS = 5000;
40
+
41
+ /** Extra keys one key action may hold together with `key`, for a diagonal. */
42
+ export const MAX_WITH_KEYS = 2;
43
+
38
44
  /** Tools that spend an action. */
39
45
  export const ACTION_TOOLS = Object.freeze(['click', 'type', 'key', 'navigate', 'swipe']);
40
46
 
@@ -111,13 +117,15 @@ export const PLAY_TOOLS = Object.freeze([
111
117
  },
112
118
  {
113
119
  name: 'key',
114
- description: 'Press one key. Spends an action. Names are the usual ones: Enter, Space, ArrowUp, Escape, a, 1. Hold a modifier with +, such as Shift+Tab; editing shortcuts such as Control+A are sent with this platform\'s own modifier. To clear a text field, use type with replace instead.',
120
+ description: `Press one key. Spends an action. Names are the usual ones: Enter, Space, ArrowUp, Escape, a, 1. Hold a modifier with +, such as Shift+Tab; editing shortcuts such as Control+A are sent with this platform\'s own modifier. To clear a text field, use type with replace instead. A press without hold_ms is a tap of about one frame, which moves a character a pixel or two: to walk or run, set hold_ms (up to ${MAX_HOLD_MS}) and the key is held that long while the game runs. with_keys holds more keys at the same time, such as with_keys ["s"] with key "d" to move diagonally. On godot_driver the game is paused between your actions and runs only while one executes, so thinking costs no game time.`,
115
121
  input_schema: {
116
122
  type: 'object',
117
123
  additionalProperties: false,
118
124
  required: ['key'],
119
125
  properties: {
120
126
  key: { type: 'string' },
127
+ hold_ms: { type: 'integer', minimum: 0, maximum: MAX_HOLD_MS, description: 'How long to hold the key down, in milliseconds of game time. Leave out for a tap.' },
128
+ with_keys: { type: 'array', maxItems: MAX_WITH_KEYS, items: { type: 'string' }, description: 'Keys held down together with key for the same time, such as ["s"] for a diagonal.' },
121
129
  thought_in_character: thought,
122
130
  note
123
131
  }
@@ -205,8 +213,14 @@ export function actionFor(toolUse) {
205
213
  if (typeof input.ref === 'string' && input.ref.trim()) action.ref = input.ref.trim();
206
214
  return action;
207
215
  }
208
- case 'key':
209
- return { kind: 'key', key: String(input.key || '') };
216
+ case 'key': {
217
+ const action = { kind: 'key', key: String(input.key || '') };
218
+ const hold = Math.min(Math.round(Number(input.hold_ms) || 0), MAX_HOLD_MS);
219
+ if (hold > 0) action.holdMs = hold;
220
+ const extra = Array.isArray(input.with_keys) ? input.with_keys.filter(key => typeof key === 'string' && key.trim()).slice(0, MAX_WITH_KEYS) : [];
221
+ if (extra.length > 0) action.keys = extra;
222
+ return action;
223
+ }
210
224
  case 'navigate':
211
225
  return { kind: 'navigate', url: String(input.url || '') };
212
226
  case 'wait':
@@ -251,6 +265,16 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot, stale
251
265
  if (toolUse.name === 'screenshot') {
252
266
  try {
253
267
  const shot = await driver.screenshot(String(input.name || 'shot'));
268
+ // Saved nothing (headless Godot): no file to list, cite or claim.
269
+ if (!shot || !shot.relativePath) {
270
+ return {
271
+ result: toolResult(toolUse.id, `No screenshot was saved here (${(shot && shot.skipped) || 'the driver returned no file'}), so there is nothing to cite. Describe what you saw in a note.`),
272
+ observation: null,
273
+ spentAction: false,
274
+ changed: false,
275
+ error: null
276
+ };
277
+ }
254
278
  if (onScreenshot) onScreenshot({ ...shot, reason: String(input.reason || '') });
255
279
  return {
256
280
  result: toolResult(toolUse.id, `Saved ${shot.relativePath}.`),
@@ -301,7 +325,12 @@ export async function executeTool({ driver, toolUse, onNote, onScreenshot, stale
301
325
  // before anything reaches the game.
302
326
  // A ref in a later tool call of a turn whose earlier call already changed
303
327
  // the screen was read off a screen that is gone.
304
- const outcome = action.ref && !driver.supportsRefs
328
+ // A hold sent to a driver that can only tap would be a one frame tap the
329
+ // persona believes was a walk, which is the 0.5.0 Godot failure in reverse.
330
+ const wantsHold = action.kind === 'key' && (action.holdMs > 0 || (action.keys && action.keys.length > 0));
331
+ const outcome = wantsHold && !driver.supportsHold
332
+ ? { ok: false, changed: false, error: `${driver.name || 'this driver'} cannot hold keys; send key without hold_ms or with_keys`, driverError: true }
333
+ : action.ref && !driver.supportsRefs
305
334
  ? { ok: false, changed: false, error: `ref targets are only available on playwright_web; ${driver.name || 'this driver'} takes a named target or coordinates`, driverError: true }
306
335
  : action.ref && staleRefs
307
336
  ? { ok: false, changed: false, error: STALE_REF_MESSAGE, driverError: true }