Skip to content

Commit 19aabad

Browse files
committed
Harden review actions and complete bounded file previews
1 parent efbf420 commit 19aabad

11 files changed

Lines changed: 1300 additions & 108 deletions

File tree

‎.gitattributes‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# These are hand-authored application sources, not build output.
2+
web/public/*.js linguist-generated=false linguist-vendored=false
3+
web/public/*.css linguist-generated=false linguist-vendored=false
4+
web/public/*.html linguist-generated=false linguist-vendored=false

‎core/linking.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { diffArrays } from 'diff';
22
import type { Plan } from './plan.ts';
33

4-
export interface FileVersion { oid: string; mode: string; text: string | null }
4+
export interface FileVersion { oid: string; mode: string; text: string | null; byteSize?: number; preview?: string }
55
export interface ContextRange { oldStart: number; oldCount: number; newStart: number; newCount: number; name: string }
66
export interface FileDelta {
77
oldPath: string | null; newPath: string | null;

‎docs/implementation/read-only-review.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,16 @@ Store schema v2 adds the review counter and per-item notes through a transaction
2121
## Known limits and remaining gates
2222

2323
- No agent answers, test execution, AI findings ingestion, send-to-agent action, or merge control is included. These belong after the go/no-go gate. Four checks distinguish unavailable evidence from success.
24-
- File cards show mode/path/object IDs; binary/image previews and byte-size retrieval are not implemented and explicitly say unavailable. The pure history API does not expose binary bytes.
24+
- File cards show mode/path/object IDs and blob byte sizes. PNG/JPEG/GIF/WebP previews are bounded to 1 MiB each and 4 MiB across a history; unsupported/oversized images say unavailable. Gitlink byte sizes are not applicable. SVG/HTML is never embedded.
2525
- The protocol at `docs/experiments/review-protocol.md` must be filled with the real pairs and committed before the first timed review. The manual assignment, real paired PRs, human timing, and final result are pending. Do not mark issue #3 closed or claim the gate passed.
2626
- The planting helper is intentionally limited to disposable clones and supported regular top-level paths; it never publishes PRs.
2727

2828
## Validation
2929

3030
Baseline before this slice: 157 tests. Run `npm run typecheck`, `npm test`, and `npm run test:browser`. Browser tests use real Git and SQLite with Chromium and cover persistence, assignments, metadata, no-change approval, stale views, unsafe origins, keyboard/breakpoints, error display, and untrusted text. A browser regression exposed false stale reasons from JSON field order; structural comparison replaced that check. The plant test verifies source HEAD stays unchanged and both plants retain ledger ownership.
31+
32+
A large-change regression reproduced HTTP 413 when the browser sent the full content-based choice key for a 20 KB segment. Browser segment IDs are now bounded SHA-256 identifiers; the runner reconstructs the original identity/content/copy key before saving the choice. This retains choice expiry semantics without sending source content back in a review command.
33+
34+
## Review round 1
35+
36+
Reproduced and fixed no-change approval with item-owned ambiguous segments; the runner refuses it and the UI directs the user to resolve attribution first. Previously approved no-change items become stale if ambiguous work appears. Reproduced malformed non-ASCII credentials returning a generic conflict instead of unauthorized; credentials now require the expected ASCII hex shape before constant-time comparison. Reproduced stale item controls surviving a failed refresh; errors now discard the loaded view and require refresh. Added acceptance persistence and whole-plan empty-state browser coverage. No findings declined.

‎git/history.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,8 @@ export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', lim
8989
return { sha, parent, files: [] as FileDelta[] };
9090
});
9191
if (expectedParent !== head) throw new Error('The base must be an ancestor of the head.');
92-
const blobs = new Map<string, string | null>();
92+
const blobs = new Map<string, { text: string | null; byteSize: number; preview?: string }>();
93+
let previewBytes = 0;
9394
const version = (oid: string, mode: string): FileVersion | null => {
9495
if (/^0+$/.test(oid)) return null;
9596
if (mode === '160000') return { oid, mode, text: null }; // gitlink is not a local blob
@@ -100,9 +101,18 @@ export function readHistory(repo: string, baseRef: string, headRef = 'HEAD', lim
100101
blobBytes += data.length;
101102
let text: string | null = null;
102103
if (!data.includes(0)) { try { text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(data); } catch { /* binary */ } }
103-
blobs.set(oid, text);
104+
let preview: string | undefined;
105+
// Only bounded raster images; never embed SVG/HTML or fetch external references.
106+
const mime = data.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])) ? 'image/png' :
107+
data.subarray(0,3).equals(Buffer.from([255,216,255])) ? 'image/jpeg' :
108+
/^GIF8[79]a$/.test(data.subarray(0,6).toString('ascii')) ? 'image/gif' :
109+
data.subarray(0,4).toString('ascii') === 'RIFF' && data.subarray(8,12).toString('ascii') === 'WEBP' ? 'image/webp' : null;
110+
if (mime && data.length <= 1024 * 1024 && previewBytes + data.length <= 4 * 1024 * 1024) {
111+
preview = `data:${mime};base64,${data.toString('base64')}`; previewBytes += data.length;
112+
}
113+
blobs.set(oid, { text, byteSize: data.length, ...(preview ? { preview } : {}) });
104114
}
105-
return { oid, mode, text: blobs.get(oid)! };
115+
return { oid, mode, ...blobs.get(oid)! };
106116
};
107117
const diff = (from: string, to: string, contexts: boolean): FileDelta[] => {
108118
const raw = accountDiff(run('diff', '--ignore-submodules=none', '--no-relative', '--raw', '-z', '--no-abbrev', '--no-ext-diff', '--no-textconv', '-M', from, to, '--'));

‎runner/review.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,32 @@ export class ReviewService {
2525
const history = readHistory(repository, snapshot.base, 'HEAD');
2626
if (history.head !== snapshot.head) snapshot = this.store.recordHistory(identity, { revision: plan.revision, snapshotId: snapshot.id, reviewVersion }, history.base, history.head, []);
2727
const pathKey = (path: string) => {
28+
if (!pathIdentity.caseSensitive && /[^\x20-\x7e]/.test(path)) throw new Error('Non-ASCII case-insensitive paths require a filesystem-specific identity adapter.');
2829
const normalized = pathIdentity.unicodeNormalization === 'NFC' ? path.normalize('NFC') : path;
2930
return pathIdentity.caseSensitive ? normalized : normalized.toLowerCase();
3031
};
3132
const raw = linkHistory(plan, history, this.store.ownership(identity, plan.revision), pathKey);
3233
const saved = this.store.getReview(identity), keys = choiceKeys(raw, identity);
34+
const deltas = new Map(history.final.map(file => [JSON.stringify([file.newPath ?? file.oldPath, file.oldPath]), file]));
35+
let previewBudget = 6 * 1024 * 1024;
36+
const preview = (value: string | undefined) => {
37+
if (!value || value.length > previewBudget) return null;
38+
previewBudget -= value.length; return value;
39+
};
3340
const segments = applyChoices(plan, raw, saved.choices, identity).map((segment, index) => {
3441
const target = plan.items.find(item => item.id === segment.row);
3542
if (target && segment.row !== raw[index]!.row) {
3643
const declared = new Set(target.files.flatMap(file => [file.path, ...(file.renamed_from ? [file.renamed_from] : [])]).map(pathKey));
3744
segment.scope = declared.has(pathKey(segment.path)) ? 'in-scope' : 'out-of-scope';
3845
}
39-
return { ...segment, key: keys[index]!, originalRow: raw[index]!.row };
46+
const delta = segment.kind === 'file' ? deltas.get(JSON.stringify([segment.path, segment.oldPath])) : undefined;
47+
const file = delta ? { oldSize: delta.before?.byteSize ?? null, newSize: delta.after?.byteSize ?? null, beforePreview: preview(delta.before?.preview), afterPreview: preview(delta.after?.preview) } : null;
48+
return { ...segment, file, key: createHash('sha256').update(keys[index]!).digest('hex'), originalRow: raw[index]!.row };
4049
});
4150
const states = approvalStates(plan, segments, saved.approvals, identity);
51+
for (const item of plan.items) {
52+
if (states[item.id] === 'approved' && ((!segments.some(segment => segment.row === item.id) && segments.some(segment => segment.row === 'Ambiguous' && segment.owners.includes(item.id))) || item.depends_on.some(id => states[id] === 'stale'))) states[item.id] = 'stale';
53+
}
4254
const expected: ReviewState = { revision: plan.revision, snapshotId: snapshot.id, reviewVersion };
4355
const notes = this.store.getReviewNotes(identity);
4456
if (this.store.reviewVersion(identity) !== reviewVersion || this.store.getPlan(identity).revision !== plan.revision || this.store.getSnapshot(identity).id !== snapshot.id) throw new Error('Stale review state. Reload before writing.');
@@ -55,7 +67,7 @@ export class ReviewService {
5567
for (const dep of item.depends_on) if (states[dep] === 'stale') reasons.push(`Depends on ${dep}, which changed`);
5668
if (!reasons.length) reasons.push('Code or plan definition changed');
5769
}
58-
return { ...item, state: states[item.id], count: owned.length, reasons, before,
70+
return { ...item, state: states[item.id], count: owned.length, ambiguousCount: ambiguous, reasons, before,
5971
checks: { attributed: ambiguous ? `! ${ambiguous} ambiguous` : owned.length ? '✓ Attributed' : '– No changes', scope: outside.length ? `✕ ${new Set(outside).size} out of scope` : owned.length ? '✓ In scope' : '– No changes', tests: item.acceptance.some(check => check.type === 'cmd') ? '– Not run' : '– No tests defined', ai: '– Not run' }, outside: [...new Set(outside)],
6072
};
6173
});
@@ -69,13 +81,16 @@ export class ReviewService {
6981
if (command.token !== view.token) throw new Error('Stale review state. Reload before writing.');
7082
const { identity } = this.config;
7183
if (command.action === 'approve' && typeof command.item === 'string') {
84+
const item = view.items.find(item => item.id === command.item);
85+
if (item && item.count === 0 && item.ambiguousCount > 0) throw new Error('Resolve this item’s ambiguous changes before confirming no change is needed.');
7286
const approval = approveItem(view.plan, view.segments, command.item, identity, command.confirmNoChange === true);
7387
this.store.saveReview(identity, view.expected, [approval], []);
7488
} else if ((command.action === 'assign' || command.action === 'accept') && typeof command.key === 'string') {
7589
const segment = view.segments.find(segment => segment.key === command.key);
7690
if (!segment || !['Ambiguous', 'Unplanned'].includes(segment.row)) throw new Error('This change cannot be assigned or accepted.');
7791
const item = command.action === 'assign' && typeof command.item === 'string' ? command.item : null;
78-
this.store.saveReview(identity, view.expected, [], [{ key: segment.key, action: command.action, item }]);
92+
const storedKey = choiceKeys(view.segments, identity)[view.segments.indexOf(segment)]!;
93+
this.store.saveReview(identity, view.expected, [], [{ key: storedKey, action: command.action, item }]);
7994
} else if (command.action === 'note' && typeof command.item === 'string' && typeof command.text === 'string' && (command.kind === 'question' || command.kind === 'change')) {
8095
this.store.addReviewNote(identity, view.expected, command.item, command.kind, command.text);
8196
} else throw new Error('Unknown review command.');

‎test/browser/review.spec.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,27 @@ test('shows an honest history error and keeps markup in notes as text',async({pa
4545
const repository=app.service.config.repository;execFileSync('git',['checkout','--orphan','unrelated'],{cwd:repository,stdio:'pipe'});execFileSync('git',['-c','core.hooksPath=/dev/null','commit','-m','Unrelated history'],{cwd:repository,stdio:'pipe'});
4646
await page.getByRole('button',{name:'Refresh',exact:true}).click();await expect(page.getByText('Could not read this branch’s history.',{exact:false})).toBeVisible();await expect(page.getByRole('button',{name:'Approve P1',exact:true})).not.toBeVisible();
4747
});
48+
test('can assign a large foreign change without sending its content back in the command',async({request})=>{
49+
const repository=app.service.config.repository;writeFileSync(join(repository,'debug.log'),'x'.repeat(20000)+'\n');execFileSync('git',['-c','core.hooksPath=/dev/null','commit','-am','Large foreign change'],{cwd:repository,stdio:'pipe'});
50+
const base=app.url.split('#')[0]!,headers={'x-codeboost-token':app.token};const view=await(await request.get(base+'api/review',{headers})).json();const segment=view.segments.find((s:{content:string;row:string})=>s.row==='Unplanned'&&s.content.length>19000);
51+
const response=await request.post(base+'api/action',{headers:{...headers,'Content-Type':'application/json'},data:{action:'assign',item:'P1',key:segment.key,token:view.token}});
52+
expect(response.status()).toBe(200);
53+
});
54+
test('shows bounded raster previews and byte sizes for file-change cards',async({page})=>{
55+
const repository=app.service.config.repository;writeFileSync(join(repository,'pixel.png'),Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aRHsAAAAASUVORK5CYII=','base64'));execFileSync('git',['add','pixel.png'],{cwd:repository});execFileSync('git',['-c','core.hooksPath=/dev/null','commit','-m','Add image'],{cwd:repository,stdio:'pipe'});
56+
await page.goto(app.url);await page.getByRole('button',{name:/Unplanned changes/}).click();await expect(page.getByRole('img',{name:'Current image in pixel.png'})).toBeVisible();await expect(page.getByRole('img',{name:'Current image in pixel.png'})).toHaveJSProperty('naturalWidth',1);await expect(page.getByText('Size: N/A → 68 bytes',{exact:true})).toBeVisible();
57+
});
58+
test('rejects malformed non-ASCII credentials consistently',async({request})=>{
59+
const response=await request.get(app.url.split('#')[0]+'api/review',{headers:{'x-codeboost-token':'é'.repeat(64)}});expect(response.status()).toBe(403);
60+
});
61+
test('keeps stale item controls unavailable after a failed refresh',async({page})=>{
62+
await page.goto(app.url);const repository=app.service.config.repository;execFileSync('git',['checkout','--orphan','unrelated'],{cwd:repository,stdio:'pipe'});execFileSync('git',['-c','core.hooksPath=/dev/null','commit','-m','Other history'],{cwd:repository,stdio:'pipe'});
63+
await page.getByRole('button',{name:'Refresh',exact:true}).click();await expect(page.getByText('Could not read this branch’s history.',{exact:false})).toBeVisible();await expect(page.getByRole('button',{name:/P3 Confirm API compatibility/})).toHaveCount(0);
64+
});
65+
test('accepts a foreign segment and keeps that choice across reload',async({page})=>{
66+
await page.goto(app.url);await page.getByRole('button',{name:/Unplanned changes/}).click();await page.getByRole('button',{name:'Accept as is',exact:true}).first().click();await page.getByRole('button',{name:/Accepted 1/}).click();await expect(page.getByRole('article').first()).toBeVisible();await page.reload();await page.getByRole('button',{name:/Accepted 1/}).click();await expect(page.getByRole('article').first()).toBeVisible();
67+
});
68+
test('shows the whole-plan empty state without claiming checks passed',async({page})=>{
69+
const {repository,identity}=app.service.config;const base=app.service.store.getSnapshot(identity).base;execFileSync('git',['reset','--hard',base],{cwd:repository,stdio:'pipe'});
70+
await page.goto(app.url);await expect(page.getByRole('heading',{name:'No code changes yet'})).toBeVisible();await expect(page.getByRole('button',{name:'Confirm no change needed',exact:true})).toBeVisible();await expect(page.getByRole('button',{name:'AI review: – Not run',exact:true})).toBeVisible();
71+
});

‎test/review.test.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { afterEach, it, expect } from 'vitest';
1+
import { afterEach, it, expect, vi } from 'vitest';
22
import { mkdtempSync, rmSync } from 'node:fs';
33
import { tmpdir } from 'node:os';
44
import { join } from 'node:path';
55
import { DatabaseSync } from 'node:sqlite';
66
import { createDemo } from '../scripts/demo.ts';
77
import { ReviewService } from '../runner/review.ts';
8+
// Each integration case performs several bounded real-Git reads.
9+
vi.setConfig({ testTimeout: 15000 });
810
const roots:string[]=[];const services:ReviewService[]=[];
911
afterEach(()=>{services.splice(0).forEach(service=>service.close());roots.splice(0).forEach(root=>rmSync(root,{recursive:true,force:true}));});
1012
function fixture(){const root=mkdtempSync(join(tmpdir(),'codeboost-review-'));roots.push(root);const config=createDemo(join(root,'demo'));const service=new ReviewService(config);services.push(service);return {service,config};}
@@ -29,3 +31,12 @@ it('rejects a concurrent store review edit through the atomic review counter',()
2931
other.store.addReviewNote(config.identity,view.expected,'P1','change','Please explain');
3032
expect(()=>service.store.saveReview(config.identity,view.expected,[],[])).toThrow(/Stale/);
3133
});
34+
it('refuses no-change confirmation while the item still owns ambiguous changes',async()=>{
35+
const {service,config}=fixture();const {writeFileSync}=await import('node:fs');const {execFileSync}=await import('node:child_process');
36+
writeFileSync(join(config.repository,'retry.ts'),'export function delay(attempt: number) {\n return Math.min(10000, 200 * 2 ** attempt);\n}\n');
37+
execFileSync('git',['-c','core.hooksPath=/dev/null','commit','-am','P2 changes retry'],{cwd:config.repository,stdio:'pipe'});
38+
const head=execFileSync('git',['rev-parse','HEAD'],{cwd:config.repository,encoding:'utf8'}).trim();const snapshot=service.store.getSnapshot(config.identity);
39+
service.store.recordHistory(config.identity,{revision:1,snapshotId:snapshot.id},snapshot.base,head,[{sha:head,owner:'P2',origin:'owned',sourceSha:null}]);
40+
const view=service.load();expect(view.items[0]!.count).toBe(0);expect(view.segments.some(s=>s.row==='Ambiguous'&&s.owners.includes('P1'))).toBe(true);
41+
expect(()=>service.act({action:'approve',item:'P1',confirmNoChange:true,token:view.token})).toThrow(/ambiguous/i);
42+
});

0 commit comments

Comments
 (0)