@riddledc/riddle-proof 0.8.31 → 0.8.33
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@riddledc/riddle-proof",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.33",
|
|
4
4
|
"description": "Reusable Riddle Proof contracts and helpers for evidence-backed agent changes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "RiddleDC",
|
|
@@ -227,6 +227,6 @@
|
|
|
227
227
|
"build": "npm --workspace @riddledc/riddle-proof-app-contract run build --if-present && tsup src/index.ts src/types.ts src/result.ts src/state.ts src/checkpoint.ts src/run-card.ts src/runner.ts src/engine-harness.ts src/codex-exec-agent.ts src/local-agent.ts src/cli.ts src/cli/index.ts src/diagnostics.ts src/proof-session.ts src/playability.ts src/basic-gameplay.ts src/profile.ts src/profile/index.ts src/openclaw.ts src/proof-run-core.ts src/proof-run-engine.ts src/riddle-client.ts src/runtime/riddle-client.ts src/spec/index.ts src/spec/types.ts src/spec/result.ts src/spec/state.ts src/spec/checkpoint.ts src/spec/run-card.ts src/runtime/index.ts src/app-contract/index.ts src/advanced/index.ts src/advanced/runner.ts src/advanced/engine-harness.ts src/advanced/proof-run-core.ts src/advanced/proof-run-engine.ts src/adapters/openclaw.ts src/adapters/local-agent.ts src/adapters/codex-exec-agent.ts src/adapters/codex.ts --format cjs,esm --dts --out-dir dist --clean",
|
|
228
228
|
"clean": "rm -rf dist",
|
|
229
229
|
"lint": "echo 'lint: (not configured)'",
|
|
230
|
-
"test": "npm run build && node test.js && node proof-run.test.js && node trust-boundary.test.js && node regression-packs.test.js && python3 runtime/tests/trust_boundary_regression.py && (python3 runtime/tests/recon_verify_smoke.py >/tmp/riddle-proof-recon-verify-smoke.json || (tail -120 /tmp/riddle-proof-recon-verify-smoke.json; exit 1))"
|
|
230
|
+
"test": "npm run build && node test.js && node proof-run.test.js && node trust-boundary.test.js && node regression-packs.test.js && python3 runtime/tests/trust_boundary_regression.py && python3 runtime/tests/ship_artifact_publication.py && (python3 runtime/tests/recon_verify_smoke.py >/tmp/riddle-proof-recon-verify-smoke.json || (tail -120 /tmp/riddle-proof-recon-verify-smoke.json; exit 1))"
|
|
231
231
|
}
|
|
232
232
|
}
|
package/runtime/lib/ship.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Ship: commit, create PR, post proof artifacts, wait for CI, mark ready, cleanup."""
|
|
2
2
|
|
|
3
|
-
import json, subprocess as sp, time, os, sys, re
|
|
3
|
+
import json, subprocess as sp, time, os, sys, re, shutil, tempfile, hashlib
|
|
4
4
|
import urllib.error
|
|
5
|
+
import urllib.parse
|
|
5
6
|
import urllib.request
|
|
6
7
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
7
8
|
from util import load_state, save_state, invoke, git
|
|
@@ -9,6 +10,7 @@ from util import load_state, save_state, invoke, git
|
|
|
9
10
|
|
|
10
11
|
DISCORD_API = 'https://discord.com/api/v10'
|
|
11
12
|
SHIP_NOISE_PATHS = ('.codex', '.oc-smoke')
|
|
13
|
+
MAX_GITHUB_PROOF_ARTIFACT_BYTES = 10 * 1024 * 1024
|
|
12
14
|
VISUAL_FIRST_MODES = {
|
|
13
15
|
'visual', 'render', 'ui', 'layout', 'screenshot',
|
|
14
16
|
'canvas', 'animation',
|
|
@@ -185,6 +187,311 @@ def truthy(value):
|
|
|
185
187
|
return str(value or '').strip().lower() in ('1', 'true', 'yes', 'y', 'on')
|
|
186
188
|
|
|
187
189
|
|
|
190
|
+
def safe_slug(value, fallback='artifact'):
|
|
191
|
+
slug = re.sub(r'[^a-zA-Z0-9._-]+', '-', str(value or '').strip()).strip('-._')
|
|
192
|
+
return slug[:80] or fallback
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def is_http_url(value):
|
|
196
|
+
text = str(value or '').strip()
|
|
197
|
+
return text.startswith('https://') or text.startswith('http://')
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def is_file_url(value):
|
|
201
|
+
return str(value or '').strip().startswith('file://')
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def path_from_file_url(url):
|
|
205
|
+
parsed = urllib.parse.urlparse(str(url or '').strip())
|
|
206
|
+
if parsed.scheme != 'file':
|
|
207
|
+
return ''
|
|
208
|
+
return urllib.request.url2pathname(parsed.path)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def local_path_from_artifact(artifact):
|
|
212
|
+
if not isinstance(artifact, dict):
|
|
213
|
+
return ''
|
|
214
|
+
path_value = str(artifact.get('path') or '').strip()
|
|
215
|
+
if path_value and os.path.exists(path_value):
|
|
216
|
+
return path_value
|
|
217
|
+
url = str(artifact.get('url') or '').strip()
|
|
218
|
+
if is_file_url(url):
|
|
219
|
+
decoded = path_from_file_url(url)
|
|
220
|
+
if decoded and os.path.exists(decoded):
|
|
221
|
+
return decoded
|
|
222
|
+
return ''
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def artifact_kind(name, path_value='', url=''):
|
|
226
|
+
target = (str(name or '') + ' ' + str(path_value or '') + ' ' + str(url or '')).lower()
|
|
227
|
+
if re.search(r'\.(png|jpe?g|gif|webp|avif|svg)(\?|$|\s)', target):
|
|
228
|
+
return 'image'
|
|
229
|
+
if re.search(r'\.(json|har|txt|md|html|log)(\?|$|\s)', target):
|
|
230
|
+
return 'data'
|
|
231
|
+
return 'artifact'
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def artifact_name(name, path_value='', fallback='artifact'):
|
|
235
|
+
source = str(name or '').strip() or os.path.basename(str(path_value or '').strip()) or fallback
|
|
236
|
+
source = source.split('?', 1)[0].split('#', 1)[0]
|
|
237
|
+
return safe_slug(source, fallback)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def add_artifact_source(sources, seen, role, name='', url='', path_value='', source='state'):
|
|
241
|
+
local_path = path_value if path_value and os.path.exists(path_value) else ''
|
|
242
|
+
if not local_path and is_file_url(url):
|
|
243
|
+
local_path = path_from_file_url(url)
|
|
244
|
+
key = local_path or str(url or '').strip()
|
|
245
|
+
if not key or key in seen:
|
|
246
|
+
return
|
|
247
|
+
seen.add(key)
|
|
248
|
+
kind = artifact_kind(name, local_path, url)
|
|
249
|
+
sources.append({
|
|
250
|
+
'role': role,
|
|
251
|
+
'name': artifact_name(name, local_path, role + '-artifact'),
|
|
252
|
+
'url': str(url or '').strip(),
|
|
253
|
+
'path': local_path,
|
|
254
|
+
'kind': kind,
|
|
255
|
+
'source': source,
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def collect_proof_artifact_sources(state):
|
|
260
|
+
sources = []
|
|
261
|
+
seen = set()
|
|
262
|
+
for role, key in (('before', 'before_cdn'), ('prod', 'prod_cdn'), ('after', 'after_cdn')):
|
|
263
|
+
value = str(state.get(key) or '').strip()
|
|
264
|
+
if value:
|
|
265
|
+
add_artifact_source(sources, seen, role, os.path.basename(path_from_file_url(value)) if is_file_url(value) else role + '.png', value, '', key)
|
|
266
|
+
|
|
267
|
+
verify_results = state.get('verify_results') or {}
|
|
268
|
+
after = verify_results.get('after') if isinstance(verify_results, dict) else {}
|
|
269
|
+
raw = after.get('raw') if isinstance(after, dict) else {}
|
|
270
|
+
if isinstance(raw, dict):
|
|
271
|
+
for artifact in raw.get('outputs') or []:
|
|
272
|
+
if isinstance(artifact, dict):
|
|
273
|
+
add_artifact_source(sources, seen, 'after', artifact.get('name', ''), artifact.get('url', ''), local_path_from_artifact(artifact), 'verify_results.after.raw.outputs')
|
|
274
|
+
for artifact in raw.get('screenshots') or []:
|
|
275
|
+
if isinstance(artifact, dict):
|
|
276
|
+
add_artifact_source(sources, seen, 'after', artifact.get('name', ''), artifact.get('url', ''), local_path_from_artifact(artifact), 'verify_results.after.raw.screenshots')
|
|
277
|
+
|
|
278
|
+
bundle = state.get('evidence_bundle') or {}
|
|
279
|
+
bundle_after = bundle.get('after') if isinstance(bundle, dict) else {}
|
|
280
|
+
supporting = bundle_after.get('supporting_artifacts') if isinstance(bundle_after, dict) else {}
|
|
281
|
+
if isinstance(supporting, dict):
|
|
282
|
+
for field, kind_role in (('image_outputs', 'after'), ('data_outputs', 'after'), ('other_outputs', 'after')):
|
|
283
|
+
for artifact in supporting.get(field) or []:
|
|
284
|
+
if isinstance(artifact, dict):
|
|
285
|
+
add_artifact_source(sources, seen, kind_role, artifact.get('name', ''), artifact.get('url', ''), local_path_from_artifact(artifact), 'evidence_bundle.after.supporting_artifacts.' + field)
|
|
286
|
+
|
|
287
|
+
request = state.get('proof_assessment_request') or {}
|
|
288
|
+
request_supporting = request.get('supporting_artifacts') if isinstance(request, dict) else {}
|
|
289
|
+
if isinstance(request_supporting, dict):
|
|
290
|
+
for field, kind_role in (('image_outputs', 'after'), ('data_outputs', 'after'), ('other_outputs', 'after')):
|
|
291
|
+
for artifact in request_supporting.get(field) or []:
|
|
292
|
+
if isinstance(artifact, dict):
|
|
293
|
+
add_artifact_source(sources, seen, kind_role, artifact.get('name', ''), artifact.get('url', ''), local_path_from_artifact(artifact), 'proof_assessment_request.supporting_artifacts.' + field)
|
|
294
|
+
|
|
295
|
+
return sources
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def local_proof_artifact_sources(state):
|
|
299
|
+
return [artifact for artifact in collect_proof_artifact_sources(state) if artifact.get('path') and os.path.exists(artifact.get('path'))]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def local_artifact_sources_fingerprint(local_sources):
|
|
303
|
+
digest = hashlib.sha256()
|
|
304
|
+
for artifact in sorted(local_sources, key=lambda item: item.get('path') or ''):
|
|
305
|
+
path_value = artifact.get('path') or ''
|
|
306
|
+
if not path_value or not os.path.exists(path_value):
|
|
307
|
+
continue
|
|
308
|
+
digest.update(path_value.encode('utf-8'))
|
|
309
|
+
digest.update(str(artifact.get('name') or '').encode('utf-8'))
|
|
310
|
+
digest.update(str(os.path.getsize(path_value)).encode('utf-8'))
|
|
311
|
+
with open(path_value, 'rb') as f:
|
|
312
|
+
while True:
|
|
313
|
+
chunk = f.read(1024 * 1024)
|
|
314
|
+
if not chunk:
|
|
315
|
+
break
|
|
316
|
+
digest.update(chunk)
|
|
317
|
+
return digest.hexdigest()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def public_proof_artifacts(state):
|
|
321
|
+
published = state.get('proof_artifact_publication') or {}
|
|
322
|
+
artifacts = published.get('artifacts') if isinstance(published, dict) else []
|
|
323
|
+
public = [artifact for artifact in artifacts or [] if isinstance(artifact, dict) and (artifact.get('raw_url') or artifact.get('html_url'))]
|
|
324
|
+
for artifact in collect_proof_artifact_sources(state):
|
|
325
|
+
url = str(artifact.get('url') or '').strip()
|
|
326
|
+
if is_http_url(url):
|
|
327
|
+
public.append({
|
|
328
|
+
**artifact,
|
|
329
|
+
'raw_url': url,
|
|
330
|
+
'html_url': url,
|
|
331
|
+
'published': False,
|
|
332
|
+
})
|
|
333
|
+
deduped = []
|
|
334
|
+
seen = set()
|
|
335
|
+
for artifact in public:
|
|
336
|
+
key = artifact.get('raw_url') or artifact.get('html_url') or artifact.get('url')
|
|
337
|
+
if key and key not in seen:
|
|
338
|
+
seen.add(key)
|
|
339
|
+
deduped.append(artifact)
|
|
340
|
+
return deduped
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def first_public_artifact_url(state, role, kind=None):
|
|
344
|
+
for artifact in public_proof_artifacts(state):
|
|
345
|
+
if artifact.get('role') != role:
|
|
346
|
+
continue
|
|
347
|
+
if kind and artifact.get('kind') != kind:
|
|
348
|
+
continue
|
|
349
|
+
return artifact.get('raw_url') or artifact.get('html_url') or artifact.get('url') or ''
|
|
350
|
+
return ''
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def resolve_github_repo_name(repo_dir):
|
|
354
|
+
result = sp.run(['gh', 'repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'],
|
|
355
|
+
cwd=repo_dir, capture_output=True, text=True, timeout=30)
|
|
356
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
357
|
+
return result.stdout.strip()
|
|
358
|
+
remote = git_stdout(['config', '--get', 'remote.origin.url'], repo_dir)
|
|
359
|
+
match = re.search(r'github\.com[:/]([^/]+)/([^/.]+)(?:\.git)?$', remote)
|
|
360
|
+
if match:
|
|
361
|
+
return match.group(1) + '/' + match.group(2)
|
|
362
|
+
return ''
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def github_file_url(repo_name, ref, path_value, mode='blob'):
|
|
366
|
+
safe_path = urllib.parse.quote(str(path_value or '').lstrip('/'), safe='/._-')
|
|
367
|
+
return 'https://github.com/' + repo_name + '/' + mode + '/' + ref + '/' + safe_path
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def write_artifact_readme(path_value, state, artifacts):
|
|
371
|
+
lines = [
|
|
372
|
+
'# Riddle Proof Artifacts',
|
|
373
|
+
'',
|
|
374
|
+
'Run id: `' + str(state.get('run_id') or '') + '`',
|
|
375
|
+
'PR: ' + str(state.get('pr_url') or ''),
|
|
376
|
+
'Goal: ' + str(state.get('change_request') or ''),
|
|
377
|
+
'Verification mode: ' + str(state.get('verification_mode') or 'proof'),
|
|
378
|
+
'',
|
|
379
|
+
'## Artifacts',
|
|
380
|
+
'',
|
|
381
|
+
]
|
|
382
|
+
for artifact in artifacts:
|
|
383
|
+
rel = artifact.get('filename') or artifact.get('published_path') or ''
|
|
384
|
+
label = artifact.get('role', 'artifact') + ' / ' + artifact.get('name', rel)
|
|
385
|
+
if artifact.get('kind') == 'image':
|
|
386
|
+
lines.append('- ' + label + ': `'+ rel + '`')
|
|
387
|
+
lines.append('')
|
|
388
|
+
lines.append(' ')
|
|
389
|
+
lines.append('')
|
|
390
|
+
else:
|
|
391
|
+
lines.append('- [' + label + '](' + rel + ')')
|
|
392
|
+
with open(path_value, 'w') as f:
|
|
393
|
+
f.write('\n'.join(lines).rstrip() + '\n')
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def publish_local_proof_artifacts_to_github(state, repo_dir, pr_num):
|
|
397
|
+
local_sources = local_proof_artifact_sources(state)
|
|
398
|
+
if not local_sources:
|
|
399
|
+
state['proof_artifact_publication'] = {'ok': True, 'skipped': True, 'reason': 'no local file artifacts'}
|
|
400
|
+
save_state(state)
|
|
401
|
+
return state['proof_artifact_publication']
|
|
402
|
+
|
|
403
|
+
source_fingerprint = local_artifact_sources_fingerprint(local_sources)
|
|
404
|
+
existing = state.get('proof_artifact_publication') or {}
|
|
405
|
+
if existing.get('ok') and existing.get('artifacts') and existing.get('source_fingerprint') == source_fingerprint:
|
|
406
|
+
return existing
|
|
407
|
+
|
|
408
|
+
repo_name = resolve_github_repo_name(repo_dir)
|
|
409
|
+
if not repo_name:
|
|
410
|
+
raise SystemExit('Could not resolve GitHub repository name for proof artifact publication.')
|
|
411
|
+
|
|
412
|
+
run_id = safe_slug(state.get('run_id') or str(int(time.time())), 'run')
|
|
413
|
+
pr_slug = safe_slug('pr-' + str(pr_num or 'unknown'), 'pr')
|
|
414
|
+
artifact_branch = safe_slug('riddle-proof-artifacts-' + pr_slug + '-' + run_id, 'riddle-proof-artifacts')
|
|
415
|
+
artifact_dir_name = 'riddle-proof/' + run_id
|
|
416
|
+
tmp = tempfile.mkdtemp(prefix='riddle-proof-gh-artifacts-')
|
|
417
|
+
published = []
|
|
418
|
+
try:
|
|
419
|
+
git_checked(['init'], tmp)
|
|
420
|
+
origin = git_stdout(['config', '--get', 'remote.origin.url'], repo_dir)
|
|
421
|
+
git_checked(['remote', 'add', 'origin', origin], tmp)
|
|
422
|
+
git_checked(['checkout', '-b', artifact_branch], tmp)
|
|
423
|
+
git_checked(['config', 'user.name', 'Riddle Proof'], tmp)
|
|
424
|
+
git_checked(['config', 'user.email', 'riddle-proof@riddledc.com'], tmp)
|
|
425
|
+
target_dir = os.path.join(tmp, artifact_dir_name)
|
|
426
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
427
|
+
used_names = set()
|
|
428
|
+
for artifact in local_sources:
|
|
429
|
+
source_path = artifact.get('path')
|
|
430
|
+
try:
|
|
431
|
+
size = os.path.getsize(source_path)
|
|
432
|
+
except Exception:
|
|
433
|
+
continue
|
|
434
|
+
if size > MAX_GITHUB_PROOF_ARTIFACT_BYTES:
|
|
435
|
+
published.append({**artifact, 'published': False, 'skipped': True, 'reason': 'artifact exceeds size limit'})
|
|
436
|
+
continue
|
|
437
|
+
base_name = artifact_name(artifact.get('name'), source_path, artifact.get('role', 'artifact') + '-artifact')
|
|
438
|
+
filename = base_name
|
|
439
|
+
stem, ext = os.path.splitext(base_name)
|
|
440
|
+
counter = 2
|
|
441
|
+
while filename in used_names:
|
|
442
|
+
filename = stem + '-' + str(counter) + ext
|
|
443
|
+
counter += 1
|
|
444
|
+
used_names.add(filename)
|
|
445
|
+
dest = os.path.join(target_dir, filename)
|
|
446
|
+
shutil.copyfile(source_path, dest)
|
|
447
|
+
published_path = artifact_dir_name + '/' + filename
|
|
448
|
+
digest = hashlib.sha256(open(source_path, 'rb').read()).hexdigest()
|
|
449
|
+
published.append({
|
|
450
|
+
**artifact,
|
|
451
|
+
'filename': filename,
|
|
452
|
+
'published_path': published_path,
|
|
453
|
+
'size_bytes': size,
|
|
454
|
+
'sha256': digest,
|
|
455
|
+
'published': True,
|
|
456
|
+
})
|
|
457
|
+
write_artifact_readme(os.path.join(target_dir, 'README.md'), state, [a for a in published if a.get('published')])
|
|
458
|
+
with open(os.path.join(target_dir, 'proof-artifacts.json'), 'w') as f:
|
|
459
|
+
json.dump({
|
|
460
|
+
'version': 'riddle-proof.github-artifacts.v1',
|
|
461
|
+
'run_id': state.get('run_id', ''),
|
|
462
|
+
'pr_url': state.get('pr_url', ''),
|
|
463
|
+
'artifacts': published,
|
|
464
|
+
}, f, indent=2)
|
|
465
|
+
git_checked(['add', '.'], tmp)
|
|
466
|
+
git_checked(['commit', '-m', 'Publish Riddle Proof artifacts'], tmp)
|
|
467
|
+
commit = git_stdout(['rev-parse', 'HEAD'], tmp)
|
|
468
|
+
push = sp.run(['git', 'push', 'origin', 'HEAD:refs/heads/' + artifact_branch, '--force'],
|
|
469
|
+
cwd=tmp, capture_output=True, text=True, timeout=120)
|
|
470
|
+
if push.returncode != 0:
|
|
471
|
+
raise SystemExit('Failed to push proof artifact branch: ' + push.stderr[:300])
|
|
472
|
+
for artifact in published:
|
|
473
|
+
if artifact.get('published'):
|
|
474
|
+
published_path = artifact.get('published_path')
|
|
475
|
+
artifact['raw_url'] = github_file_url(repo_name, commit, published_path, 'raw')
|
|
476
|
+
artifact['html_url'] = github_file_url(repo_name, commit, published_path, 'blob')
|
|
477
|
+
publication = {
|
|
478
|
+
'ok': True,
|
|
479
|
+
'branch': artifact_branch,
|
|
480
|
+
'commit': commit,
|
|
481
|
+
'repo': repo_name,
|
|
482
|
+
'html_url': github_file_url(repo_name, commit, artifact_dir_name, 'tree'),
|
|
483
|
+
'manifest_url': github_file_url(repo_name, commit, artifact_dir_name + '/proof-artifacts.json', 'blob'),
|
|
484
|
+
'readme_url': github_file_url(repo_name, commit, artifact_dir_name + '/README.md', 'blob'),
|
|
485
|
+
'source_fingerprint': source_fingerprint,
|
|
486
|
+
'artifacts': published,
|
|
487
|
+
}
|
|
488
|
+
state['proof_artifact_publication'] = publication
|
|
489
|
+
save_state(state)
|
|
490
|
+
return publication
|
|
491
|
+
finally:
|
|
492
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
493
|
+
|
|
494
|
+
|
|
188
495
|
def is_temp_proof_branch(branch):
|
|
189
496
|
return str(branch or '').strip().startswith('riddle-proof/')
|
|
190
497
|
|
|
@@ -271,6 +578,10 @@ def build_ship_report(state, marked_ready=None):
|
|
|
271
578
|
branch = state.get('target_branch') or state.get('branch') or ''
|
|
272
579
|
if marked_ready is None:
|
|
273
580
|
marked_ready = state.get('marked_ready')
|
|
581
|
+
before_artifact_url = first_public_artifact_url(state, 'before', 'image') or state.get('before_cdn', '')
|
|
582
|
+
prod_artifact_url = first_public_artifact_url(state, 'prod', 'image') or state.get('prod_cdn', '')
|
|
583
|
+
after_artifact_url = first_public_artifact_url(state, 'after', 'image') or state.get('after_cdn', '')
|
|
584
|
+
artifact_publication = state.get('proof_artifact_publication') or {}
|
|
274
585
|
return {
|
|
275
586
|
'pr_url': state.get('pr_url', ''),
|
|
276
587
|
'pr_branch': branch,
|
|
@@ -282,9 +593,12 @@ def build_ship_report(state, marked_ready=None):
|
|
|
282
593
|
'ci_status': state.get('ci_status', ''),
|
|
283
594
|
'proof_comment_url': state.get('proof_comment_url', ''),
|
|
284
595
|
'proof_assessment_comment_url': state.get('proof_assessment_comment_url', ''),
|
|
285
|
-
'before_artifact_url':
|
|
286
|
-
'prod_artifact_url':
|
|
287
|
-
'after_artifact_url':
|
|
596
|
+
'before_artifact_url': before_artifact_url,
|
|
597
|
+
'prod_artifact_url': prod_artifact_url,
|
|
598
|
+
'after_artifact_url': after_artifact_url,
|
|
599
|
+
'proof_artifacts_url': artifact_publication.get('html_url', '') if isinstance(artifact_publication, dict) else '',
|
|
600
|
+
'proof_artifacts_manifest_url': artifact_publication.get('manifest_url', '') if isinstance(artifact_publication, dict) else '',
|
|
601
|
+
'proof_artifact_publication': artifact_publication if isinstance(artifact_publication, dict) else {},
|
|
288
602
|
}
|
|
289
603
|
|
|
290
604
|
|
|
@@ -515,12 +829,15 @@ def post_discord_ready_message(state, marked_ready):
|
|
|
515
829
|
else:
|
|
516
830
|
ci_line = 'CI was not confirmed green yet; review the PR checks before merging.'
|
|
517
831
|
proof_bits = []
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
832
|
+
before_url = first_public_artifact_url(state, 'before', 'image') or state.get('before_cdn')
|
|
833
|
+
prod_url = first_public_artifact_url(state, 'prod', 'image') or state.get('prod_cdn')
|
|
834
|
+
after_url = first_public_artifact_url(state, 'after', 'image') or state.get('after_cdn')
|
|
835
|
+
if before_url:
|
|
836
|
+
proof_bits.append('before: ' + before_url)
|
|
837
|
+
if prod_url:
|
|
838
|
+
proof_bits.append('prod: ' + prod_url)
|
|
839
|
+
if after_url:
|
|
840
|
+
proof_bits.append('after: ' + after_url)
|
|
524
841
|
elif state_has_after_evidence(state):
|
|
525
842
|
proof_bits.append('after: structured evidence bundle')
|
|
526
843
|
|
|
@@ -671,6 +988,8 @@ if s.get('finalized') and s.get('pr_url') and existing_after_dir and not os.path
|
|
|
671
988
|
'proof_comment_url': report.get('proof_comment_url', ''),
|
|
672
989
|
'before_artifact_url': report.get('before_artifact_url', ''),
|
|
673
990
|
'after_artifact_url': report.get('after_artifact_url', ''),
|
|
991
|
+
'proof_artifacts_url': report.get('proof_artifacts_url', ''),
|
|
992
|
+
'proof_artifacts_manifest_url': report.get('proof_artifacts_manifest_url', ''),
|
|
674
993
|
'finalized_retry': True,
|
|
675
994
|
'proof_assessment_comment_posted': bool(s.get('proof_assessment_comment_posted')),
|
|
676
995
|
'discord_notification': s.get('discord_notification'),
|
|
@@ -755,6 +1074,12 @@ pr_num = s.get('pr_number', '')
|
|
|
755
1074
|
if not pr_num:
|
|
756
1075
|
raise SystemExit('No PR created. Check gh auth.')
|
|
757
1076
|
|
|
1077
|
+
publication = publish_local_proof_artifacts_to_github(s, repo_dir, pr_num)
|
|
1078
|
+
if publication.get('ok') and not publication.get('skipped'):
|
|
1079
|
+
print('Proof artifacts published: ' + publication.get('html_url', ''))
|
|
1080
|
+
elif publication.get('skipped'):
|
|
1081
|
+
print('Proof artifact publication skipped: ' + publication.get('reason', 'unknown'))
|
|
1082
|
+
|
|
758
1083
|
# Post proof comment on PR
|
|
759
1084
|
body = '## Riddle Proof — Proof of Fix\n\n'
|
|
760
1085
|
body += '**Goal:** ' + s.get('change_request', '') + '\n\n'
|
|
@@ -762,14 +1087,39 @@ if s.get('success_criteria'):
|
|
|
762
1087
|
body += '**Success criteria:** ' + s['success_criteria'] + '\n\n'
|
|
763
1088
|
body += '**Verification mode:** ' + s.get('verification_mode', 'proof') + '\n\n'
|
|
764
1089
|
body += '**Merge recommendation:** ' + effective_merge_recommendation(s) + '\n\n'
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
if
|
|
768
|
-
body += '
|
|
769
|
-
|
|
770
|
-
|
|
1090
|
+
|
|
1091
|
+
public_artifacts = public_proof_artifacts(s)
|
|
1092
|
+
if publication.get('ok') and not publication.get('skipped'):
|
|
1093
|
+
body += '**Proof artifacts:** '
|
|
1094
|
+
body += '[bundle](' + publication.get('html_url', '') + ')'
|
|
1095
|
+
if publication.get('manifest_url'):
|
|
1096
|
+
body += ' | [manifest](' + publication.get('manifest_url', '') + ')'
|
|
1097
|
+
body += '\n\n'
|
|
1098
|
+
|
|
1099
|
+
before_image = first_public_artifact_url(s, 'before', 'image')
|
|
1100
|
+
prod_image = first_public_artifact_url(s, 'prod', 'image')
|
|
1101
|
+
after_image = first_public_artifact_url(s, 'after', 'image')
|
|
1102
|
+
if before_image:
|
|
1103
|
+
body += '### Before\n\n\n'
|
|
1104
|
+
if prod_image:
|
|
1105
|
+
body += '### Prod\n\n\n'
|
|
1106
|
+
if after_image:
|
|
1107
|
+
body += '### After\n\n\n'
|
|
771
1108
|
else:
|
|
772
1109
|
body += '### After evidence\nNo after screenshot was captured for this verification mode; structured evidence is summarized below.\n\n'
|
|
1110
|
+
|
|
1111
|
+
data_artifacts = [
|
|
1112
|
+
artifact for artifact in public_artifacts
|
|
1113
|
+
if artifact.get('kind') != 'image' and (artifact.get('html_url') or artifact.get('raw_url'))
|
|
1114
|
+
]
|
|
1115
|
+
if data_artifacts:
|
|
1116
|
+
body += '### Structured artifacts\n'
|
|
1117
|
+
for artifact in data_artifacts[:12]:
|
|
1118
|
+
label = artifact.get('name') or artifact.get('filename') or artifact.get('kind') or 'artifact'
|
|
1119
|
+
url = artifact.get('html_url') or artifact.get('raw_url')
|
|
1120
|
+
body += '- [' + str(label) + '](' + str(url) + ')\n'
|
|
1121
|
+
body += '\n'
|
|
1122
|
+
|
|
773
1123
|
bundle_text = evidence_bundle_text(s)
|
|
774
1124
|
if bundle_text:
|
|
775
1125
|
body += '### Evidence bundle\n```\n' + bundle_text + '\n```\n\n'
|
|
@@ -904,5 +1254,7 @@ print(json.dumps({
|
|
|
904
1254
|
'before_artifact_url': report.get('before_artifact_url', ''),
|
|
905
1255
|
'prod_artifact_url': report.get('prod_artifact_url', ''),
|
|
906
1256
|
'after_artifact_url': report.get('after_artifact_url', ''),
|
|
1257
|
+
'proof_artifacts_url': report.get('proof_artifacts_url', ''),
|
|
1258
|
+
'proof_artifacts_manifest_url': report.get('proof_artifacts_manifest_url', ''),
|
|
907
1259
|
'ship_report': report,
|
|
908
1260
|
}))
|
|
@@ -12,7 +12,17 @@ steps:
|
|
|
12
12
|
import json, os
|
|
13
13
|
state_file = os.environ.get('RIDDLE_PROOF_STATE_FILE', '/tmp/riddle-proof-state.json')
|
|
14
14
|
s = json.load(open(state_file))
|
|
15
|
-
|
|
15
|
+
after = ((s.get('evidence_bundle') or {}).get('after') or {})
|
|
16
|
+
observation = after.get('observation') or {}
|
|
17
|
+
supporting = after.get('supporting_artifacts') or {}
|
|
18
|
+
has_structured_after = bool(
|
|
19
|
+
observation.get('valid') and (
|
|
20
|
+
supporting.get('has_structured_payload') or
|
|
21
|
+
supporting.get('proof_evidence_present') or
|
|
22
|
+
observation.get('telemetry_ready')
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
if not s.get('after_cdn') and not has_structured_after:
|
|
16
26
|
raise SystemExit('No after evidence. Run verify first.')
|
|
17
27
|
print('SHIP')
|
|
18
28
|
print(' Proof goal: ' + s.get('change_request', ''))
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
9
|
+
SHIP = ROOT / "runtime" / "lib" / "ship.py"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run(args, cwd, env=None):
|
|
13
|
+
result = subprocess.run(args, cwd=cwd, env=env, capture_output=True, text=True, timeout=120)
|
|
14
|
+
if result.returncode != 0:
|
|
15
|
+
raise AssertionError(
|
|
16
|
+
f"{' '.join(args)} failed\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
|
17
|
+
)
|
|
18
|
+
return result
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def write_fake_gh(path):
|
|
22
|
+
path.write_text(
|
|
23
|
+
"""#!/usr/bin/env python3
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
args = sys.argv[1:]
|
|
29
|
+
if args[:3] == ["repo", "view", "--json"]:
|
|
30
|
+
print("example/test-repo")
|
|
31
|
+
raise SystemExit(0)
|
|
32
|
+
if args[:2] == ["pr", "list"]:
|
|
33
|
+
print("")
|
|
34
|
+
raise SystemExit(0)
|
|
35
|
+
if args[:2] == ["pr", "create"]:
|
|
36
|
+
print("https://github.com/example/test-repo/pull/321")
|
|
37
|
+
raise SystemExit(0)
|
|
38
|
+
if args[:2] == ["pr", "comment"]:
|
|
39
|
+
body = ""
|
|
40
|
+
if "--body" in args:
|
|
41
|
+
body = args[args.index("--body") + 1]
|
|
42
|
+
with open(os.environ["FAKE_GH_COMMENT_BODY"], "w") as f:
|
|
43
|
+
f.write(body)
|
|
44
|
+
print("https://github.com/example/test-repo/pull/321#issuecomment-999")
|
|
45
|
+
raise SystemExit(0)
|
|
46
|
+
if args[:2] == ["pr", "checks"]:
|
|
47
|
+
print("[]")
|
|
48
|
+
raise SystemExit(0)
|
|
49
|
+
if args[:2] == ["pr", "ready"]:
|
|
50
|
+
raise SystemExit(0)
|
|
51
|
+
if args[:2] == ["pr", "edit"]:
|
|
52
|
+
raise SystemExit(0)
|
|
53
|
+
print("unknown gh command: " + " ".join(args), file=sys.stderr)
|
|
54
|
+
raise SystemExit(1)
|
|
55
|
+
""",
|
|
56
|
+
encoding="utf-8",
|
|
57
|
+
)
|
|
58
|
+
path.chmod(0o755)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main():
|
|
62
|
+
with tempfile.TemporaryDirectory(prefix="riddle-proof-ship-artifacts-") as tmp:
|
|
63
|
+
root = Path(tmp)
|
|
64
|
+
origin = root / "origin.git"
|
|
65
|
+
repo = root / "repo"
|
|
66
|
+
artifacts = root / "artifacts"
|
|
67
|
+
bin_dir = root / "bin"
|
|
68
|
+
state_path = root / "state.json"
|
|
69
|
+
comment_body_path = root / "comment.md"
|
|
70
|
+
artifacts.mkdir()
|
|
71
|
+
bin_dir.mkdir()
|
|
72
|
+
|
|
73
|
+
run(["git", "init", "--bare", str(origin)], cwd=root)
|
|
74
|
+
run(["git", "init", str(repo)], cwd=root)
|
|
75
|
+
run(["git", "config", "user.name", "Test User"], cwd=repo)
|
|
76
|
+
run(["git", "config", "user.email", "test@example.com"], cwd=repo)
|
|
77
|
+
run(["git", "remote", "add", "origin", str(origin)], cwd=repo)
|
|
78
|
+
run(["git", "checkout", "-b", "agent/proof-artifact-test"], cwd=repo)
|
|
79
|
+
(repo / "README.md").write_text("initial\n", encoding="utf-8")
|
|
80
|
+
run(["git", "add", "README.md"], cwd=repo)
|
|
81
|
+
run(["git", "commit", "-m", "Initial"], cwd=repo)
|
|
82
|
+
(repo / "README.md").write_text("changed\n", encoding="utf-8")
|
|
83
|
+
|
|
84
|
+
# Tiny valid PNG header/body is enough for GitHub Markdown image embedding.
|
|
85
|
+
screenshot = artifacts / "after-proof.png"
|
|
86
|
+
screenshot.write_bytes(
|
|
87
|
+
bytes.fromhex(
|
|
88
|
+
"89504e470d0a1a0a0000000d4948445200000001000000010802000000907753de"
|
|
89
|
+
"0000000c49444154789c63606060000000040001f61738550000000049454e44ae426082"
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
proof_json = artifacts / "proof.json"
|
|
93
|
+
proof_json.write_text(
|
|
94
|
+
json.dumps(
|
|
95
|
+
{
|
|
96
|
+
"version": "riddle-proof.test.v1",
|
|
97
|
+
"assertions": [{"name": "proof image published", "passed": True}],
|
|
98
|
+
},
|
|
99
|
+
indent=2,
|
|
100
|
+
),
|
|
101
|
+
encoding="utf-8",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
write_fake_gh(bin_dir / "gh")
|
|
105
|
+
state = {
|
|
106
|
+
"repo_dir": str(repo),
|
|
107
|
+
"branch": "agent/proof-artifact-test",
|
|
108
|
+
"target_branch": "agent/proof-artifact-test",
|
|
109
|
+
"run_id": "rp_ship_artifact_test",
|
|
110
|
+
"change_request": "Prove PR artifact publication.",
|
|
111
|
+
"commit_message": "Test proof artifact publication",
|
|
112
|
+
"success_criteria": "The PR proof comment embeds a GitHub-hosted image.",
|
|
113
|
+
"verification_mode": "proof",
|
|
114
|
+
"requested_reference": "none",
|
|
115
|
+
"reference": "none",
|
|
116
|
+
"verify_status": "evidence_captured",
|
|
117
|
+
"after_cdn": screenshot.as_uri(),
|
|
118
|
+
"assertion_status": "passed",
|
|
119
|
+
"proof_summary": "All assertions passed.",
|
|
120
|
+
"proof_assessment_source": "supervising_agent",
|
|
121
|
+
"proof_assessment": {
|
|
122
|
+
"source": "supervising_agent",
|
|
123
|
+
"decision": "ready_to_ship",
|
|
124
|
+
"summary": "Evidence is strong enough to ship.",
|
|
125
|
+
},
|
|
126
|
+
"evidence_bundle": {
|
|
127
|
+
"verification_mode": "proof",
|
|
128
|
+
"after": {
|
|
129
|
+
"observation": {"valid": True, "reason": "ok", "telemetry_ready": True},
|
|
130
|
+
"supporting_artifacts": {
|
|
131
|
+
"has_structured_payload": True,
|
|
132
|
+
"proof_evidence_present": True,
|
|
133
|
+
"image_outputs": [{"name": "after-proof.png", "url": screenshot.as_uri()}],
|
|
134
|
+
"data_outputs": [{"name": "proof.json", "url": proof_json.as_uri()}],
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
"verify_results": {
|
|
139
|
+
"after": {
|
|
140
|
+
"raw": {
|
|
141
|
+
"outputs": [
|
|
142
|
+
{"name": "after-proof.png", "url": screenshot.as_uri(), "path": str(screenshot)},
|
|
143
|
+
{"name": "proof.json", "url": proof_json.as_uri(), "path": str(proof_json)},
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
state_path.write_text(json.dumps(state, indent=2), encoding="utf-8")
|
|
150
|
+
env = {
|
|
151
|
+
**os.environ,
|
|
152
|
+
"PATH": str(bin_dir) + os.pathsep + os.environ.get("PATH", ""),
|
|
153
|
+
"RIDDLE_PROOF_STATE_FILE": str(state_path),
|
|
154
|
+
"FAKE_GH_COMMENT_BODY": str(comment_body_path),
|
|
155
|
+
"DISCORD_BOT_TOKEN": "",
|
|
156
|
+
"OPENCLAW_HOME": str(root / "openclaw-home"),
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
run(["python3", str(SHIP)], cwd=repo, env=env)
|
|
160
|
+
|
|
161
|
+
updated = json.loads(state_path.read_text(encoding="utf-8"))
|
|
162
|
+
publication = updated.get("proof_artifact_publication") or {}
|
|
163
|
+
assert publication.get("ok") is True, "proof artifact publication should be recorded"
|
|
164
|
+
assert publication.get("artifacts"), "published artifact list should be recorded"
|
|
165
|
+
assert updated.get("ship_report", {}).get("after_artifact_url", "").startswith(
|
|
166
|
+
"https://github.com/example/test-repo/raw/"
|
|
167
|
+
), "ship report should expose a GitHub-hosted after artifact URL"
|
|
168
|
+
|
|
169
|
+
comment = comment_body_path.read_text(encoding="utf-8")
|
|
170
|
+
assert "file://" not in comment, "PR proof comment must not expose local file URLs"
|
|
171
|
+
assert "raw.githubusercontent.com" not in comment, (
|
|
172
|
+
"PR proof comment must not depend on unauthenticated raw GitHub URLs"
|
|
173
|
+
)
|
|
174
|
+
assert "
|
|
177
|
+
assert "[proof.json](https://github.com/example/test-repo/blob/" in comment, (
|
|
178
|
+
"PR proof comment should link the structured proof JSON"
|
|
179
|
+
)
|
|
180
|
+
assert "Proof artifacts:" in comment, "PR proof comment should link the artifact bundle"
|
|
181
|
+
|
|
182
|
+
artifact_branch = publication.get("branch")
|
|
183
|
+
refs = run(["git", f"--git-dir={origin}", "show-ref", f"refs/heads/{artifact_branch}"], cwd=root)
|
|
184
|
+
assert artifact_branch in refs.stdout, "artifact branch should be pushed to origin"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
main()
|