@ctrl-spc/cs 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/mcp.js +311 -3
- package/dist/screenshots.js +196 -0
- package/package.json +1 -1
package/dist/mcp.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
4
|
import { createServer as createHttpServer } from 'node:http';
|
|
@@ -9,6 +9,7 @@ import { z } from 'zod';
|
|
|
9
9
|
import { TOOLS_SERVER_PORT, SESSION_TTL_MS } from './env.js';
|
|
10
10
|
import { agentPath } from './agents.js';
|
|
11
11
|
import { mcpToken, readSession } from './config.js';
|
|
12
|
+
import { readPngScreenshot, } from './screenshots.js';
|
|
12
13
|
export function attributionFromClientName(name) {
|
|
13
14
|
if (!name)
|
|
14
15
|
return null;
|
|
@@ -279,6 +280,297 @@ async function createArtifactHandler(client, userId, currentSession, args) {
|
|
|
279
280
|
return errorResult(`create_artifact failed: ${err.message}`);
|
|
280
281
|
}
|
|
281
282
|
}
|
|
283
|
+
const SCREENSHOT_PLATFORM_LABEL = {
|
|
284
|
+
web: 'Web',
|
|
285
|
+
ios: 'iOS',
|
|
286
|
+
android: 'Android',
|
|
287
|
+
};
|
|
288
|
+
function attachScreenshotFailure(title, reason) {
|
|
289
|
+
return errorResult(`Couldn’t attach "${title}": ${reason}. No artifact was created.`);
|
|
290
|
+
}
|
|
291
|
+
function screenshotArtifactId(taskId, title, platform, target, bytes) {
|
|
292
|
+
const digest = createHash('sha256')
|
|
293
|
+
.update('ctrl-spc:screenshot:v1\0')
|
|
294
|
+
.update(taskId)
|
|
295
|
+
.update('\0')
|
|
296
|
+
.update(title)
|
|
297
|
+
.update('\0')
|
|
298
|
+
.update(platform)
|
|
299
|
+
.update('\0')
|
|
300
|
+
.update(target)
|
|
301
|
+
.update('\0')
|
|
302
|
+
.update(bytes)
|
|
303
|
+
.digest()
|
|
304
|
+
.subarray(0, 16);
|
|
305
|
+
// RFC 9562-shaped, deterministic UUID. The content-addressed identity makes
|
|
306
|
+
// a retry after Storage succeeded but Postgres failed converge on the same
|
|
307
|
+
// object instead of leaking one new object per retry.
|
|
308
|
+
digest[6] = (digest[6] & 0x0f) | 0x50;
|
|
309
|
+
digest[8] = (digest[8] & 0x3f) | 0x80;
|
|
310
|
+
const hex = digest.toString('hex');
|
|
311
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
312
|
+
}
|
|
313
|
+
function isDuplicateStorageObject(error) {
|
|
314
|
+
return /duplicate|already exists|resource exists/i.test(`${error.message} ${error.error ?? ''} ${error.name ?? ''}`);
|
|
315
|
+
}
|
|
316
|
+
function screenshotArtifactMatches(row, expected) {
|
|
317
|
+
return (row.id === expected.id &&
|
|
318
|
+
row.task_id === expected.taskId &&
|
|
319
|
+
row.type === 'image' &&
|
|
320
|
+
row.format === 'png' &&
|
|
321
|
+
row.title === expected.title &&
|
|
322
|
+
row.content === expected.content &&
|
|
323
|
+
row.storage_path === expected.storagePath &&
|
|
324
|
+
row.created_by === expected.userId &&
|
|
325
|
+
row.deleted_at === null);
|
|
326
|
+
}
|
|
327
|
+
async function fetchScreenshotArtifact(client, artifactId) {
|
|
328
|
+
return must(client
|
|
329
|
+
.from('artifacts')
|
|
330
|
+
.select('id,task_id,type,format,title,content,storage_path,created_by,deleted_at')
|
|
331
|
+
.eq('id', artifactId)
|
|
332
|
+
.maybeSingle());
|
|
333
|
+
}
|
|
334
|
+
async function downloadScreenshotObject(client, storagePath) {
|
|
335
|
+
const { data, error } = await client.storage.from('artifacts').download(storagePath);
|
|
336
|
+
if (error)
|
|
337
|
+
throw new Error(error.message);
|
|
338
|
+
if (!data)
|
|
339
|
+
throw new Error('Storage returned no file data');
|
|
340
|
+
const arrayBuffer = await data.arrayBuffer();
|
|
341
|
+
return Buffer.from(arrayBuffer);
|
|
342
|
+
}
|
|
343
|
+
async function ensureScreenshotAttribution(client, userId, sessionId, artifactId) {
|
|
344
|
+
try {
|
|
345
|
+
await must(client.from('cliv2_agent_outputs').insert({
|
|
346
|
+
user_id: userId,
|
|
347
|
+
session_id: sessionId,
|
|
348
|
+
kind: 'artifact',
|
|
349
|
+
product_id: artifactId,
|
|
350
|
+
}));
|
|
351
|
+
return undefined;
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
// A completed call whose response was lost already has this unique
|
|
355
|
+
// attribution row. Treat it as converged rather than warning or duplicating.
|
|
356
|
+
try {
|
|
357
|
+
const existing = await must(client
|
|
358
|
+
.from('cliv2_agent_outputs')
|
|
359
|
+
.select('user_id,session_id,product_id')
|
|
360
|
+
.eq('kind', 'artifact')
|
|
361
|
+
.eq('product_id', artifactId)
|
|
362
|
+
.maybeSingle());
|
|
363
|
+
if (existing?.user_id === userId && existing.product_id === artifactId)
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
// Preserve the original attribution failure below.
|
|
368
|
+
}
|
|
369
|
+
return `Screenshot attached, but recording session attribution failed: ${err.message}`;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function attachedScreenshotResult(title, platform, target, artifactId, attributionWarning) {
|
|
373
|
+
const success = `✓ Attached "${title}" to this work item.\n` +
|
|
374
|
+
` ${SCREENSHOT_PLATFORM_LABEL[platform]} · ${target} · image artifact ${artifactId}`;
|
|
375
|
+
return attributionWarning
|
|
376
|
+
? { content: [{ type: 'text', text: `${success}\n Warning: ${attributionWarning}` }] }
|
|
377
|
+
: { content: [{ type: 'text', text: success }] };
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Attach one already-captured PNG to this connection's open work session.
|
|
381
|
+
*
|
|
382
|
+
* The local file is fully validated before the first hosted request. The
|
|
383
|
+
* Storage upload intentionally precedes the artifact INSERT: the private bucket
|
|
384
|
+
* has no DELETE policy (objects follow the artifact soft-delete posture), so an
|
|
385
|
+
* upload failure must never leave a live broken card. There is no transaction
|
|
386
|
+
* across Supabase Storage and Postgres; if the upload succeeds but the row
|
|
387
|
+
* insert fails, the stable object key is reported for maintenance cleanup.
|
|
388
|
+
*/
|
|
389
|
+
export async function attachScreenshotHandler(client, userId, currentSession, args, deps = {}) {
|
|
390
|
+
const title = args.title?.trim() ?? '';
|
|
391
|
+
if (!currentSession) {
|
|
392
|
+
return errorResult('attach_screenshot needs an open work session — call begin_work first. No artifact was created.');
|
|
393
|
+
}
|
|
394
|
+
if (!title) {
|
|
395
|
+
return attachScreenshotFailure('Untitled screenshot', 'title must be non-empty');
|
|
396
|
+
}
|
|
397
|
+
const target = args.target?.trim() ?? '';
|
|
398
|
+
if (!target) {
|
|
399
|
+
return attachScreenshotFailure(title, 'target must be non-empty');
|
|
400
|
+
}
|
|
401
|
+
if (!['web', 'ios', 'android'].includes(args.platform)) {
|
|
402
|
+
return attachScreenshotFailure(title, "platform must be 'web', 'ios', or 'android'");
|
|
403
|
+
}
|
|
404
|
+
let screenshot;
|
|
405
|
+
try {
|
|
406
|
+
screenshot = await (deps.readScreenshot ?? readPngScreenshot)(args.path);
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
return attachScreenshotFailure(title, err.message);
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
// Resolve only the open session's task. There is deliberately no task_id
|
|
413
|
+
// argument, so one MCP connection cannot attach a screenshot to some other
|
|
414
|
+
// work item while it is working this one.
|
|
415
|
+
const task = await must(client
|
|
416
|
+
.from('tasks')
|
|
417
|
+
.select('id,project_id')
|
|
418
|
+
.eq('id', currentSession.taskId)
|
|
419
|
+
.is('archived_at', null)
|
|
420
|
+
.maybeSingle());
|
|
421
|
+
if (!task) {
|
|
422
|
+
return attachScreenshotFailure(title, 'the open work item was not found or is archived');
|
|
423
|
+
}
|
|
424
|
+
const project = await must(client
|
|
425
|
+
.from('projects')
|
|
426
|
+
.select('id,org_id')
|
|
427
|
+
.eq('id', task.project_id)
|
|
428
|
+
.is('archived_at', null)
|
|
429
|
+
.maybeSingle());
|
|
430
|
+
if (!project) {
|
|
431
|
+
return attachScreenshotFailure(title, 'the work item project was not found or is not accessible');
|
|
432
|
+
}
|
|
433
|
+
const artifactId = deps.artifactId?.() ??
|
|
434
|
+
screenshotArtifactId(task.id, title, args.platform, target, screenshot.bytes);
|
|
435
|
+
const storagePath = `${project.org_id}/${task.project_id}/${task.id}/${artifactId}.png`;
|
|
436
|
+
const metadata = {
|
|
437
|
+
platform: args.platform,
|
|
438
|
+
target,
|
|
439
|
+
width: screenshot.width,
|
|
440
|
+
height: screenshot.height,
|
|
441
|
+
size_bytes: screenshot.sizeBytes,
|
|
442
|
+
};
|
|
443
|
+
const content = JSON.stringify(metadata);
|
|
444
|
+
const expectedArtifact = {
|
|
445
|
+
id: artifactId,
|
|
446
|
+
taskId: task.id,
|
|
447
|
+
title,
|
|
448
|
+
content,
|
|
449
|
+
storagePath,
|
|
450
|
+
userId,
|
|
451
|
+
};
|
|
452
|
+
// Supabase Storage documents that upload needs INSERT RLS. upsert:false is
|
|
453
|
+
// intentional: this call never replaces an existing screenshot and
|
|
454
|
+
// therefore does not require SELECT + UPDATE permissions.
|
|
455
|
+
const { error: uploadError } = await client.storage
|
|
456
|
+
.from('artifacts')
|
|
457
|
+
.upload(storagePath, screenshot.bytes, {
|
|
458
|
+
contentType: 'image/png',
|
|
459
|
+
upsert: false,
|
|
460
|
+
});
|
|
461
|
+
if (uploadError && !isDuplicateStorageObject(uploadError)) {
|
|
462
|
+
return attachScreenshotFailure(title, `the PNG could not be uploaded: ${uploadError.message}`);
|
|
463
|
+
}
|
|
464
|
+
if (uploadError) {
|
|
465
|
+
let existing;
|
|
466
|
+
try {
|
|
467
|
+
existing = await fetchScreenshotArtifact(client, artifactId);
|
|
468
|
+
}
|
|
469
|
+
catch (err) {
|
|
470
|
+
return errorResult(`Couldn’t attach "${title}": the screenshot object already exists, but CTRL+SPC could not confirm ` +
|
|
471
|
+
`whether artifact ${artifactId} is complete: ${err.message}. The existing object and any ` +
|
|
472
|
+
'artifact record were left unchanged; do not retry until the artifact can be checked.');
|
|
473
|
+
}
|
|
474
|
+
if (existing) {
|
|
475
|
+
if (!screenshotArtifactMatches(existing, expectedArtifact)) {
|
|
476
|
+
return errorResult(`Couldn’t attach "${title}": the retry key is already linked to a different artifact record. ` +
|
|
477
|
+
`Existing artifact ${artifactId} was left unchanged; no new artifact was created.`);
|
|
478
|
+
}
|
|
479
|
+
const warning = await ensureScreenshotAttribution(client, userId, currentSession.sessionId, artifactId);
|
|
480
|
+
return attachedScreenshotResult(title, args.platform, target, artifactId, warning);
|
|
481
|
+
}
|
|
482
|
+
// No artifact row means this may be an orphan from an interrupted call.
|
|
483
|
+
// Verify the private object's exact bytes before linking it; a key
|
|
484
|
+
// collision must never turn someone else's object into this evidence.
|
|
485
|
+
let existingBytes;
|
|
486
|
+
try {
|
|
487
|
+
existingBytes = await downloadScreenshotObject(client, storagePath);
|
|
488
|
+
}
|
|
489
|
+
catch (err) {
|
|
490
|
+
return errorResult(`Couldn’t attach "${title}": the screenshot object already exists, but CTRL+SPC could not verify ` +
|
|
491
|
+
`its bytes: ${err.message}. The existing object was left unchanged; no artifact was created.`);
|
|
492
|
+
}
|
|
493
|
+
if (!existingBytes.equals(screenshot.bytes)) {
|
|
494
|
+
return errorResult(`Couldn’t attach "${title}": the retry key points to an existing screenshot with different bytes. ` +
|
|
495
|
+
`The existing object was left unchanged; no artifact was created.`);
|
|
496
|
+
}
|
|
497
|
+
// The exact object is an orphan from a previous call that failed at
|
|
498
|
+
// Postgres. Continue with the same deterministic id/path to finish it.
|
|
499
|
+
}
|
|
500
|
+
let row;
|
|
501
|
+
try {
|
|
502
|
+
row = await must(client
|
|
503
|
+
.from('artifacts')
|
|
504
|
+
.insert({
|
|
505
|
+
id: artifactId,
|
|
506
|
+
task_id: task.id,
|
|
507
|
+
type: 'image',
|
|
508
|
+
format: 'png',
|
|
509
|
+
title,
|
|
510
|
+
content,
|
|
511
|
+
storage_path: storagePath,
|
|
512
|
+
created_by: userId,
|
|
513
|
+
from_agent: null,
|
|
514
|
+
agent_run_id: null,
|
|
515
|
+
})
|
|
516
|
+
.select(ARTIFACT_COLUMNS)
|
|
517
|
+
.single());
|
|
518
|
+
}
|
|
519
|
+
catch (err) {
|
|
520
|
+
// The insert response may have been lost after Postgres committed, or a
|
|
521
|
+
// concurrent retry may have won. Re-read before describing an orphan.
|
|
522
|
+
let existing;
|
|
523
|
+
try {
|
|
524
|
+
existing = await fetchScreenshotArtifact(client, artifactId);
|
|
525
|
+
}
|
|
526
|
+
catch (readErr) {
|
|
527
|
+
return errorResult(`Couldn’t confirm whether "${title}" finished attaching after the PNG upload: ` +
|
|
528
|
+
`${readErr.message}. Artifact ${artifactId} may already exist; the private object at ` +
|
|
529
|
+
`"${storagePath}" was left unchanged. Do not retry until the artifact can be checked.`);
|
|
530
|
+
}
|
|
531
|
+
if (existing) {
|
|
532
|
+
if (!screenshotArtifactMatches(existing, expectedArtifact)) {
|
|
533
|
+
return errorResult(`Couldn’t attach "${title}": artifact id ${artifactId} is already used by a different record. ` +
|
|
534
|
+
'The existing artifact was left unchanged; no new artifact was created.');
|
|
535
|
+
}
|
|
536
|
+
const warning = await ensureScreenshotAttribution(client, userId, currentSession.sessionId, artifactId);
|
|
537
|
+
return attachedScreenshotResult(title, args.platform, target, artifactId, warning);
|
|
538
|
+
}
|
|
539
|
+
// The bucket intentionally has no DELETE policy. Removing an object by
|
|
540
|
+
// writing storage.objects directly is unsupported by Supabase, so do not
|
|
541
|
+
// pretend this cross-service boundary is atomic.
|
|
542
|
+
return errorResult(`Couldn’t attach "${title}": the PNG uploaded, but the artifact record could not be created: ` +
|
|
543
|
+
`${err.message}. No artifact was created. The unlinked private object at ` +
|
|
544
|
+
`"${storagePath}" may require maintenance cleanup.`);
|
|
545
|
+
}
|
|
546
|
+
if (!row) {
|
|
547
|
+
let existing;
|
|
548
|
+
try {
|
|
549
|
+
existing = await fetchScreenshotArtifact(client, artifactId);
|
|
550
|
+
}
|
|
551
|
+
catch (readErr) {
|
|
552
|
+
return errorResult(`Couldn’t confirm whether "${title}" finished attaching after the PNG upload: ` +
|
|
553
|
+
`${readErr.message}. Artifact ${artifactId} may already exist; the private object at ` +
|
|
554
|
+
`"${storagePath}" was left unchanged. Do not retry until the artifact can be checked.`);
|
|
555
|
+
}
|
|
556
|
+
if (existing) {
|
|
557
|
+
if (!screenshotArtifactMatches(existing, expectedArtifact)) {
|
|
558
|
+
return errorResult(`Couldn’t attach "${title}": artifact id ${artifactId} is already used by a different record. ` +
|
|
559
|
+
'The existing artifact was left unchanged; no new artifact was created.');
|
|
560
|
+
}
|
|
561
|
+
const warning = await ensureScreenshotAttribution(client, userId, currentSession.sessionId, artifactId);
|
|
562
|
+
return attachedScreenshotResult(title, args.platform, target, artifactId, warning);
|
|
563
|
+
}
|
|
564
|
+
return errorResult(`Couldn’t attach "${title}": the PNG uploaded, but the artifact record returned no row. ` +
|
|
565
|
+
`No artifact was created. The unlinked private object at "${storagePath}" may require maintenance cleanup.`);
|
|
566
|
+
}
|
|
567
|
+
const attributionWarning = await ensureScreenshotAttribution(client, userId, currentSession.sessionId, artifactId);
|
|
568
|
+
return attachedScreenshotResult(title, args.platform, target, artifactId, attributionWarning);
|
|
569
|
+
}
|
|
570
|
+
catch (err) {
|
|
571
|
+
return attachScreenshotFailure(title, err.message);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
282
574
|
/** Re-fetch one task with the SAME select get_task uses (`*` + the tag join),
|
|
283
575
|
* returning the parsed task object ({ ...row, tags }, task_tags dropped) or null.
|
|
284
576
|
* Shared by update_task and create_task so their result shape matches get_task's
|
|
@@ -1414,12 +1706,13 @@ async function getCredentialHandler(client, args) {
|
|
|
1414
1706
|
}
|
|
1415
1707
|
}
|
|
1416
1708
|
// ---------------------------------------------------------------------------
|
|
1417
|
-
// The twenty-
|
|
1709
|
+
// The twenty-three tools this server exposes. Exported for the self-check.
|
|
1418
1710
|
// ---------------------------------------------------------------------------
|
|
1419
1711
|
export const TOOL_NAMES = [
|
|
1420
1712
|
'list_tasks',
|
|
1421
1713
|
'get_task',
|
|
1422
1714
|
'create_artifact',
|
|
1715
|
+
'attach_screenshot',
|
|
1423
1716
|
'update_task',
|
|
1424
1717
|
'update_artifact',
|
|
1425
1718
|
'set_task_role_slugs',
|
|
@@ -1440,7 +1733,7 @@ export const TOOL_NAMES = [
|
|
|
1440
1733
|
'list_credentials',
|
|
1441
1734
|
'get_credential',
|
|
1442
1735
|
];
|
|
1443
|
-
/** Build a per-session McpServer with the twenty-
|
|
1736
|
+
/** Build a per-session McpServer with the twenty-three tools. A fresh instance per
|
|
1444
1737
|
* session is what makes `server.server.getClientVersion()` (populated during
|
|
1445
1738
|
* that session's `initialize`) the right source for attribution — mirroring
|
|
1446
1739
|
* v1's per-session `buildServer`. `connectionId` is this connection's key into
|
|
@@ -1496,6 +1789,21 @@ export function buildToolsServer(client, userId, machineId, connectionId) {
|
|
|
1496
1789
|
touchSession(connectionId);
|
|
1497
1790
|
return createArtifactHandler(client, userId, openSessions.get(connectionId) ?? null, args);
|
|
1498
1791
|
});
|
|
1792
|
+
server.registerTool('attach_screenshot', {
|
|
1793
|
+
description: 'Attach one real Web, iOS, or Android PNG screenshot to the work item in your open session. ' +
|
|
1794
|
+
'Playwright or Maestro captures the file first; pass its absolute local path, a clear title, the ' +
|
|
1795
|
+
'platform, and the exact browser/simulator/emulator target. The local path is never uploaded or stored. ' +
|
|
1796
|
+
'Requires an open session (begin_work).',
|
|
1797
|
+
inputSchema: {
|
|
1798
|
+
path: z.string().describe('Absolute local path to one existing PNG'),
|
|
1799
|
+
title: z.string().describe('Non-empty title shown on the work item'),
|
|
1800
|
+
platform: z.enum(['web', 'ios', 'android']),
|
|
1801
|
+
target: z.string().describe('Non-empty exact browser, simulator, or emulator name'),
|
|
1802
|
+
},
|
|
1803
|
+
}, async (args) => {
|
|
1804
|
+
touchSession(connectionId);
|
|
1805
|
+
return attachScreenshotHandler(client, userId, openSessions.get(connectionId) ?? null, args);
|
|
1806
|
+
});
|
|
1499
1807
|
server.registerTool('update_task', {
|
|
1500
1808
|
description: 'Update a task you own — its status (backlog / in_progress / done), name, description, or due_date. ' +
|
|
1501
1809
|
'Pass expected_revision from the most recent get_task for optimistic concurrency; on a conflict, ' +
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { inflateSync } from 'node:zlib';
|
|
4
|
+
export const MAX_SCREENSHOT_BYTES = 20 * 1024 * 1024;
|
|
5
|
+
const MAX_DECODED_SCREENSHOT_BYTES = 256 * 1024 * 1024;
|
|
6
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
7
|
+
const CRC32_TABLE = Uint32Array.from({ length: 256 }, (_, value) => {
|
|
8
|
+
let crc = value;
|
|
9
|
+
for (let bit = 0; bit < 8; bit += 1) {
|
|
10
|
+
crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
|
|
11
|
+
}
|
|
12
|
+
return crc >>> 0;
|
|
13
|
+
});
|
|
14
|
+
function crc32(bytes) {
|
|
15
|
+
let crc = 0xffffffff;
|
|
16
|
+
for (const byte of bytes) {
|
|
17
|
+
crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
18
|
+
}
|
|
19
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Read and validate the local screenshot before any hosted request is made.
|
|
23
|
+
*
|
|
24
|
+
* This validates the PNG framing and decoded scanline envelope without
|
|
25
|
+
* rewriting the user's original bytes. Interlaced images are rejected because
|
|
26
|
+
* browser and mobile screenshot tools produce standard non-interlaced PNGs,
|
|
27
|
+
* and validating Adam7 safely would add complexity without helping Phase 1.
|
|
28
|
+
*/
|
|
29
|
+
export async function readPngScreenshot(localPath) {
|
|
30
|
+
if (!localPath || !isAbsolute(localPath)) {
|
|
31
|
+
throw new Error('path must be an absolute local path');
|
|
32
|
+
}
|
|
33
|
+
let stat;
|
|
34
|
+
try {
|
|
35
|
+
stat = await lstat(localPath);
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
const code = err.code;
|
|
39
|
+
if (code === 'ENOENT')
|
|
40
|
+
throw new Error(`the PNG was not found at ${localPath}`);
|
|
41
|
+
throw new Error(`the PNG could not be inspected at ${localPath}: ${err.message}`);
|
|
42
|
+
}
|
|
43
|
+
if (!stat.isFile()) {
|
|
44
|
+
throw new Error(`the path is not a regular file: ${localPath}`);
|
|
45
|
+
}
|
|
46
|
+
if (stat.size === 0) {
|
|
47
|
+
throw new Error('the PNG is empty');
|
|
48
|
+
}
|
|
49
|
+
if (stat.size > MAX_SCREENSHOT_BYTES) {
|
|
50
|
+
throw new Error(`the PNG is larger than the ${MAX_SCREENSHOT_BYTES / (1024 * 1024)} MB limit`);
|
|
51
|
+
}
|
|
52
|
+
let bytes;
|
|
53
|
+
try {
|
|
54
|
+
bytes = await readFile(localPath);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
throw new Error(`the PNG could not be read at ${localPath}: ${err.message}`);
|
|
58
|
+
}
|
|
59
|
+
if (bytes.length !== stat.size) {
|
|
60
|
+
throw new Error('the PNG changed while it was being read; capture it again and retry');
|
|
61
|
+
}
|
|
62
|
+
if (bytes.length < PNG_SIGNATURE.length || !bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
63
|
+
throw new Error('the file is not a readable PNG (invalid PNG signature)');
|
|
64
|
+
}
|
|
65
|
+
let offset = 8;
|
|
66
|
+
let width = 0;
|
|
67
|
+
let height = 0;
|
|
68
|
+
let sawHeader = false;
|
|
69
|
+
let sawImageData = false;
|
|
70
|
+
let sawEnd = false;
|
|
71
|
+
let sawPalette = false;
|
|
72
|
+
let endedImageData = false;
|
|
73
|
+
let bitDepth = 0;
|
|
74
|
+
let colorType = 0;
|
|
75
|
+
const imageDataChunks = [];
|
|
76
|
+
while (offset < bytes.length) {
|
|
77
|
+
if (bytes.length - offset < 12) {
|
|
78
|
+
throw new Error('the file is not a readable PNG (truncated chunk header)');
|
|
79
|
+
}
|
|
80
|
+
const length = bytes.readUInt32BE(offset);
|
|
81
|
+
const chunkEnd = offset + 12 + length;
|
|
82
|
+
if (chunkEnd > bytes.length || chunkEnd < offset) {
|
|
83
|
+
throw new Error('the file is not a readable PNG (truncated chunk data)');
|
|
84
|
+
}
|
|
85
|
+
const typeBytes = bytes.subarray(offset + 4, offset + 8);
|
|
86
|
+
const type = typeBytes.toString('ascii');
|
|
87
|
+
if (!/^[A-Za-z]{4}$/.test(type)) {
|
|
88
|
+
throw new Error('the file is not a readable PNG (invalid chunk type)');
|
|
89
|
+
}
|
|
90
|
+
const expectedCrc = bytes.readUInt32BE(offset + 8 + length);
|
|
91
|
+
const actualCrc = crc32(bytes.subarray(offset + 4, offset + 8 + length));
|
|
92
|
+
if (actualCrc !== expectedCrc) {
|
|
93
|
+
throw new Error(`the file is not a readable PNG (${type} chunk CRC mismatch)`);
|
|
94
|
+
}
|
|
95
|
+
if (!sawHeader) {
|
|
96
|
+
if (type !== 'IHDR' || length !== 13) {
|
|
97
|
+
throw new Error('the file is not a readable PNG (missing the required IHDR header)');
|
|
98
|
+
}
|
|
99
|
+
width = bytes.readUInt32BE(offset + 8);
|
|
100
|
+
height = bytes.readUInt32BE(offset + 12);
|
|
101
|
+
bitDepth = bytes[offset + 16];
|
|
102
|
+
colorType = bytes[offset + 17];
|
|
103
|
+
const compression = bytes[offset + 18];
|
|
104
|
+
const filter = bytes[offset + 19];
|
|
105
|
+
const interlace = bytes[offset + 20];
|
|
106
|
+
const legalDepths = {
|
|
107
|
+
0: [1, 2, 4, 8, 16],
|
|
108
|
+
2: [8, 16],
|
|
109
|
+
3: [1, 2, 4, 8],
|
|
110
|
+
4: [8, 16],
|
|
111
|
+
6: [8, 16],
|
|
112
|
+
};
|
|
113
|
+
if (!legalDepths[colorType]?.includes(bitDepth)) {
|
|
114
|
+
throw new Error('the file is not a readable PNG (unsupported IHDR color type or bit depth)');
|
|
115
|
+
}
|
|
116
|
+
if (compression !== 0 || filter !== 0) {
|
|
117
|
+
throw new Error('the file is not a readable PNG (unsupported compression or filter method)');
|
|
118
|
+
}
|
|
119
|
+
if (interlace !== 0) {
|
|
120
|
+
throw new Error('the file is not a readable PNG (interlaced PNGs are not supported)');
|
|
121
|
+
}
|
|
122
|
+
sawHeader = true;
|
|
123
|
+
}
|
|
124
|
+
else if (type === 'IHDR') {
|
|
125
|
+
throw new Error('the file is not a readable PNG (duplicate IHDR header)');
|
|
126
|
+
}
|
|
127
|
+
if (type === 'PLTE') {
|
|
128
|
+
if (sawImageData || length === 0 || length % 3 !== 0 || length > 768) {
|
|
129
|
+
throw new Error('the file is not a readable PNG (invalid PLTE chunk)');
|
|
130
|
+
}
|
|
131
|
+
sawPalette = true;
|
|
132
|
+
}
|
|
133
|
+
if (type === 'IDAT') {
|
|
134
|
+
if (endedImageData) {
|
|
135
|
+
throw new Error('the file is not a readable PNG (IDAT chunks must be consecutive)');
|
|
136
|
+
}
|
|
137
|
+
sawImageData = true;
|
|
138
|
+
imageDataChunks.push(bytes.subarray(offset + 8, offset + 8 + length));
|
|
139
|
+
}
|
|
140
|
+
else if (type === 'IEND') {
|
|
141
|
+
if (length !== 0) {
|
|
142
|
+
throw new Error('the file is not a readable PNG (invalid IEND chunk)');
|
|
143
|
+
}
|
|
144
|
+
sawEnd = true;
|
|
145
|
+
offset = chunkEnd;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
else if (sawImageData) {
|
|
149
|
+
endedImageData = true;
|
|
150
|
+
}
|
|
151
|
+
if (/^[A-Z]/.test(type) && !['IHDR', 'PLTE', 'IDAT', 'IEND'].includes(type)) {
|
|
152
|
+
throw new Error(`the file is not a readable PNG (unsupported critical chunk ${type})`);
|
|
153
|
+
}
|
|
154
|
+
offset = chunkEnd;
|
|
155
|
+
}
|
|
156
|
+
if (!sawImageData) {
|
|
157
|
+
throw new Error('the file is not a readable PNG (missing image data)');
|
|
158
|
+
}
|
|
159
|
+
if (!sawEnd) {
|
|
160
|
+
throw new Error('the file is not a readable PNG (missing IEND chunk)');
|
|
161
|
+
}
|
|
162
|
+
if (offset !== bytes.length) {
|
|
163
|
+
throw new Error('the file is not a readable PNG (trailing data after IEND)');
|
|
164
|
+
}
|
|
165
|
+
if (width < 1 || height < 1) {
|
|
166
|
+
throw new Error('the file is not a readable PNG (width and height must be positive)');
|
|
167
|
+
}
|
|
168
|
+
if (colorType === 3 && !sawPalette) {
|
|
169
|
+
throw new Error('the file is not a readable PNG (indexed color requires a PLTE chunk)');
|
|
170
|
+
}
|
|
171
|
+
const channels = colorType === 0 || colorType === 3 ? 1 : colorType === 2 ? 3 : colorType === 4 ? 2 : 4;
|
|
172
|
+
const rowBytes = Math.ceil((width * channels * bitDepth) / 8);
|
|
173
|
+
const scanlineBytes = rowBytes + 1;
|
|
174
|
+
const expectedDecodedBytes = scanlineBytes * height;
|
|
175
|
+
if (!Number.isSafeInteger(expectedDecodedBytes) || expectedDecodedBytes > MAX_DECODED_SCREENSHOT_BYTES) {
|
|
176
|
+
throw new Error('the PNG expands beyond the 256 MB decoded screenshot limit');
|
|
177
|
+
}
|
|
178
|
+
let decoded;
|
|
179
|
+
try {
|
|
180
|
+
decoded = inflateSync(Buffer.concat(imageDataChunks), {
|
|
181
|
+
maxOutputLength: expectedDecodedBytes + 1,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
throw new Error(`the file is not a readable PNG (invalid compressed image data: ${err.message})`);
|
|
186
|
+
}
|
|
187
|
+
if (decoded.length !== expectedDecodedBytes) {
|
|
188
|
+
throw new Error(`the file is not a readable PNG (decoded scanline size is ${decoded.length}, expected ${expectedDecodedBytes})`);
|
|
189
|
+
}
|
|
190
|
+
for (let row = 0; row < height; row += 1) {
|
|
191
|
+
if (decoded[row * scanlineBytes] > 4) {
|
|
192
|
+
throw new Error(`the file is not a readable PNG (invalid filter byte on scanline ${row + 1})`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return { bytes, width, height, sizeBytes: bytes.length };
|
|
196
|
+
}
|
package/package.json
CHANGED