@hraness/sponge 0.5.3 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -11
- package/dist/capture/NOTICES.txt +472 -0
- package/dist/capture/worker.js +553 -0
- package/dist/src/acquisition/capture.d.ts +3 -10
- package/dist/src/acquisition/capture.js +28 -202
- package/dist/src/acquisition/child-request.d.ts +40 -0
- package/dist/src/acquisition/child.d.ts +31 -0
- package/dist/src/acquisition/child.js +130 -0
- package/dist/src/acquisition/errors.d.ts +2 -2
- package/dist/src/acquisition/errors.js +15 -4
- package/dist/src/acquisition/index.d.ts +0 -3
- package/dist/src/acquisition/index.js +0 -2
- package/dist/src/acquisition/normalize.d.ts +1 -3
- package/dist/src/acquisition/normalize.js +20 -22
- package/dist/src/app/article.d.ts +1 -2
- package/dist/src/app/article.js +1 -2
- package/dist/src/cli/commands.d.ts +0 -1
- package/dist/src/cli/commands.js +8 -35
- package/dist/src/cli/import.d.ts +1 -3
- package/dist/src/cli/import.js +3 -3
- package/dist/src/cli/main.js +0 -2
- package/dist/src/cli/runtimes.js +2 -3
- package/dist/src/cli/worker.d.ts +1 -4
- package/dist/src/cli/worker.js +0 -4
- package/dist/src/importers/apply.d.ts +0 -2
- package/dist/src/importers/apply.js +2 -2
- package/dist/src/importers/inspect.d.ts +2 -2
- package/dist/src/importers/inspect.js +24 -23
- package/dist/src/metadata/article.d.ts +0 -1
- package/dist/src/metadata/article.js +3 -2
- package/dist/src/pdf/capture.d.ts +7 -16
- package/dist/src/pdf/capture.js +44 -198
- package/dist/src/pdf/handler.d.ts +1 -2
- package/dist/src/pdf/handler.js +0 -1
- package/dist/src/pdf/normalize.d.ts +2 -3
- package/dist/src/pdf/normalize.js +34 -38
- package/dist/src/pdf/tools.d.ts +2 -0
- package/dist/src/pdf/tools.js +21 -0
- package/native/runtime-manifest.json +5 -5
- package/package.json +11 -2
- package/dist/src/acquisition/clip-helper.js +0 -14
- package/dist/src/acquisition/lock.d.ts +0 -1
- package/dist/src/acquisition/lock.js +0 -2
- package/dist/src/acquisition/pins.d.ts +0 -3
- package/dist/src/acquisition/pins.js +0 -21
- package/dist/src/acquisition/runtime.d.ts +0 -48
- package/dist/src/acquisition/runtime.js +0 -273
- package/dist/src/acquisition/verify-helper.d.ts +0 -1
- package/dist/src/acquisition/verify-helper.js +0 -26
- package/dist/src/importers/verify-helper.d.ts +0 -1
- package/dist/src/importers/verify-helper.js +0 -41
- package/dist/src/pdf/capture-helper.d.ts +0 -1
- package/dist/src/pdf/capture-helper.js +0 -15
- package/dist/src/pdf/errors.d.ts +0 -2
- package/dist/src/pdf/errors.js +0 -23
- package/dist/src/pdf/source-identity.d.ts +0 -4
- package/dist/src/pdf/source-identity.js +0 -13
- package/dist/src/pdf/verify-helper.d.ts +0 -1
- package/dist/src/pdf/verify-helper.js +0 -29
- /package/dist/src/acquisition/{clip-helper.d.ts → child-request.js} +0 -0
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import type { runOwned } from './process.js';
|
|
2
|
+
import { type CaptureOutcome } from './child.js';
|
|
3
|
+
export type { CaptureOutcome } from './child.js';
|
|
4
4
|
/** A signed-in browser the user attached over CDP on loopback. */
|
|
5
5
|
export type CaptureConnection = {
|
|
6
6
|
kind: 'cdp';
|
|
7
7
|
endpoint: string;
|
|
8
8
|
};
|
|
9
9
|
export interface CaptureRequest {
|
|
10
|
-
runtime: CaptureRuntime;
|
|
11
10
|
stagingRoot: string;
|
|
12
11
|
sourceUrl: string;
|
|
13
12
|
requestId: string;
|
|
@@ -17,10 +16,4 @@ export interface CaptureRequest {
|
|
|
17
16
|
allowPrivateNetwork?: boolean;
|
|
18
17
|
runProcess?: typeof runOwned;
|
|
19
18
|
}
|
|
20
|
-
export type CaptureOutcome = {
|
|
21
|
-
ok: true;
|
|
22
|
-
capture: CaptureResult;
|
|
23
|
-
bundleRoot: string;
|
|
24
|
-
dispose: () => Promise<void>;
|
|
25
|
-
} | ErrorEnvelope;
|
|
26
19
|
export declare function captureArticle(request: CaptureRequest): Promise<CaptureOutcome>;
|
|
@@ -1,208 +1,34 @@
|
|
|
1
|
-
import { mkdir, mkdtemp, writeFile, statfs, realpath } from 'node:fs/promises';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { fileURLToPath } from 'node:url';
|
|
4
1
|
import { limits } from '../../contracts/index.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import { failure, clipFailure } from './errors.js';
|
|
10
|
-
import { normalizeBundle, object } from './normalize.js';
|
|
11
|
-
import { SpongeError, publicCode, codeOf } from '../errors.js';
|
|
12
|
-
export async function captureArticle(request) {
|
|
13
|
-
const { requestId, signal } = request;
|
|
14
|
-
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(requestId))
|
|
15
|
-
return failure('invalid-request', 'invalid-input');
|
|
16
|
-
let url;
|
|
2
|
+
import { superviseChild } from './child.js';
|
|
3
|
+
import { normalizeBundle } from './normalize.js';
|
|
4
|
+
const loopbackEndpoint = /^(?:[1-9][0-9]{0,4}|(?:http|ws):\/\/127\.0\.0\.1:[1-9][0-9]{0,4}(?:\/[^\s]*)?)$/;
|
|
5
|
+
function validSource(sourceUrl) {
|
|
17
6
|
try {
|
|
18
|
-
url = new URL(
|
|
19
|
-
|
|
20
|
-
throw Error();
|
|
7
|
+
const url = new URL(sourceUrl);
|
|
8
|
+
return ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password && sourceUrl.length <= 16384;
|
|
21
9
|
}
|
|
22
10
|
catch {
|
|
23
|
-
return
|
|
24
|
-
}
|
|
25
|
-
if (signal?.aborted)
|
|
26
|
-
return failure(requestId, 'cancelled');
|
|
27
|
-
let runtime;
|
|
28
|
-
try {
|
|
29
|
-
runtime = await inspectCaptureRuntime(request.runtime.root, request.runtime.pins);
|
|
30
|
-
}
|
|
31
|
-
catch (error) {
|
|
32
|
-
return failure(requestId, publicCode(error, 'runtime-missing'), 'setup');
|
|
33
|
-
}
|
|
34
|
-
let base;
|
|
35
|
-
try {
|
|
36
|
-
base = await privateRoot(request.stagingRoot);
|
|
37
|
-
const disk = await statfs(base);
|
|
38
|
-
if (disk.bavail * disk.bsize < limits.stagingBytes + limits.freeReserveBytes)
|
|
39
|
-
return failure(requestId, 'limit-exceeded');
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
return failure(requestId, 'invalid-input');
|
|
43
|
-
}
|
|
44
|
-
const deadline = request.deadlineMs ?? limits.captureDeadlineMs;
|
|
45
|
-
if (!Number.isSafeInteger(deadline) || deadline < 100 || deadline > limits.captureDeadlineMs)
|
|
46
|
-
return failure(requestId, 'invalid-input');
|
|
47
|
-
const clipHelper = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? './clip-helper.ts' : './clip-helper.js', import.meta.url));
|
|
48
|
-
const args = [
|
|
49
|
-
clipHelper,
|
|
50
|
-
runtime.root,
|
|
51
|
-
request.sourceUrl,
|
|
52
|
-
'article',
|
|
53
|
-
'--media',
|
|
54
|
-
'images',
|
|
55
|
-
'--timeout-ms',
|
|
56
|
-
String(deadline),
|
|
57
|
-
'--max-html-bytes',
|
|
58
|
-
String(limits.markdownBytes),
|
|
59
|
-
'--max-asset-bytes',
|
|
60
|
-
String(limits.imageBytes),
|
|
61
|
-
'--max-total-asset-bytes',
|
|
62
|
-
String(500 * 1024 ** 2),
|
|
63
|
-
'--quiet',
|
|
64
|
-
'--json',
|
|
65
|
-
];
|
|
66
|
-
if (request.allowPrivateNetwork)
|
|
67
|
-
args.push('--allow-private-network');
|
|
68
|
-
if (request.connection) {
|
|
69
|
-
if (!/^(?:[1-9][0-9]{0,4}|(?:http|ws):\/\/127\.0\.0\.1:[1-9][0-9]{0,4}(?:\/[^\s]*)?)$/.test(request.connection.endpoint))
|
|
70
|
-
return failure(requestId, 'invalid-input');
|
|
71
|
-
// Wordcell takes the loopback DevTools port alone.
|
|
72
|
-
const endpoint = request.connection.endpoint;
|
|
73
|
-
args.push('--cdp', /^[0-9]+$/.test(endpoint) ? endpoint : new URL(endpoint).port);
|
|
74
|
-
}
|
|
75
|
-
else
|
|
76
|
-
args.push('--mode', 'http');
|
|
77
|
-
const root = await realpath(await mkdtemp(join(base, 'capture-'))), home = join(root, 'home'), output = join(root, 'bundles');
|
|
78
|
-
args.push('--output', output);
|
|
79
|
-
const env = isolatedEnvironment(home, home);
|
|
80
|
-
const controller = new AbortController();
|
|
81
|
-
const abort = () => controller.abort();
|
|
82
|
-
signal?.addEventListener('abort', abort, { once: true });
|
|
83
|
-
if (signal?.aborted)
|
|
84
|
-
abort();
|
|
85
|
-
let cleanup = 'settled', keep = false, quota = false, checking = false;
|
|
86
|
-
const monitor = setInterval(async () => {
|
|
87
|
-
if (checking)
|
|
88
|
-
return;
|
|
89
|
-
checking = true;
|
|
90
|
-
try {
|
|
91
|
-
if ((await physicalBytes(root)) > limits.stagingBytes) {
|
|
92
|
-
quota = true;
|
|
93
|
-
controller.abort();
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
catch {
|
|
97
|
-
quota = true;
|
|
98
|
-
controller.abort();
|
|
99
|
-
}
|
|
100
|
-
finally {
|
|
101
|
-
checking = false;
|
|
102
|
-
}
|
|
103
|
-
}, 100);
|
|
104
|
-
const started = Date.now();
|
|
105
|
-
try {
|
|
106
|
-
await mkdir(home, { mode: 0o700 });
|
|
107
|
-
await mkdir(output, { mode: 0o700 });
|
|
108
|
-
await writeFile(join(root, 'ownership.json'), JSON.stringify({ schemaVersion: 1, requestId, state: 'acquiring' }), {
|
|
109
|
-
mode: 0o600,
|
|
110
|
-
});
|
|
111
|
-
cleanup = 'pending';
|
|
112
|
-
const result = await (request.runProcess ?? runOwned)(runtime.bun, args, {
|
|
113
|
-
cwd: root,
|
|
114
|
-
env,
|
|
115
|
-
signal: controller.signal,
|
|
116
|
-
deadlineMs: deadline,
|
|
117
|
-
maxOutputBytes: limits.metadataBytes,
|
|
118
|
-
graceMs: 5000,
|
|
119
|
-
});
|
|
120
|
-
cleanup = result.cleanup;
|
|
121
|
-
// An attached browser can start helpers outside the owned process group.
|
|
122
|
-
let summary;
|
|
123
|
-
try {
|
|
124
|
-
summary = JSON.parse(result.stdout);
|
|
125
|
-
}
|
|
126
|
-
catch {
|
|
127
|
-
summary = null;
|
|
128
|
-
}
|
|
129
|
-
const upstream = clipFailure(summary, requestId);
|
|
130
|
-
if (cleanup === 'settled' && request.connection) {
|
|
131
|
-
cleanup = 'pending';
|
|
132
|
-
const census = await (request.runProcess ?? runOwned)('/bin/ps', ['-axo', 'command'], {
|
|
133
|
-
env,
|
|
134
|
-
deadlineMs: 3000,
|
|
135
|
-
maxOutputBytes: 4 * 1024 ** 2,
|
|
136
|
-
});
|
|
137
|
-
cleanup = census.cleanup;
|
|
138
|
-
if (census.code !== 0 || census.stopped || census.stdout.split('\n').some((line) => line.includes(root)))
|
|
139
|
-
cleanup = 'pending';
|
|
140
|
-
}
|
|
141
|
-
if (cleanup !== 'settled')
|
|
142
|
-
return failure(requestId, 'cleanup-required', 'cleanup');
|
|
143
|
-
if (quota)
|
|
144
|
-
return failure(requestId, 'limit-exceeded');
|
|
145
|
-
if (result.stopped)
|
|
146
|
-
return failure(requestId, result.stopped);
|
|
147
|
-
if (result.code !== 0 || object(summary).ok !== true)
|
|
148
|
-
return upstream;
|
|
149
|
-
const outputDirectory = object(summary).outputDirectory;
|
|
150
|
-
if (typeof outputDirectory !== 'string' ||
|
|
151
|
-
!outputDirectory.startsWith(output + '/') ||
|
|
152
|
-
(await realpath(outputDirectory)) !== outputDirectory)
|
|
153
|
-
return failure(requestId, 'integrity', 'verification');
|
|
154
|
-
const helper = fileURLToPath(new URL(import.meta.url.endsWith('.ts') ? './verify-helper.ts' : './verify-helper.js', import.meta.url));
|
|
155
|
-
cleanup = 'pending';
|
|
156
|
-
const checked = await (request.runProcess ?? runOwned)(runtime.bun, [helper, runtime.root, outputDirectory], {
|
|
157
|
-
cwd: root,
|
|
158
|
-
env,
|
|
159
|
-
signal: controller.signal,
|
|
160
|
-
deadlineMs: Math.max(1, deadline - (Date.now() - started)),
|
|
161
|
-
maxOutputBytes: limits.helperFrameBytes,
|
|
162
|
-
});
|
|
163
|
-
cleanup = checked.cleanup;
|
|
164
|
-
if (cleanup !== 'settled')
|
|
165
|
-
return failure(requestId, 'cleanup-required', 'cleanup');
|
|
166
|
-
if (checked.stopped)
|
|
167
|
-
return failure(requestId, checked.stopped);
|
|
168
|
-
if (checked.code !== 0)
|
|
169
|
-
return failure(requestId, 'integrity', 'verification');
|
|
170
|
-
const capture = await normalizeBundle({
|
|
171
|
-
bundleRoot: outputDirectory,
|
|
172
|
-
inspection: JSON.parse(checked.stdout),
|
|
173
|
-
summary,
|
|
174
|
-
requestId,
|
|
175
|
-
sourceUrl: request.sourceUrl,
|
|
176
|
-
wordcellVersion: runtime.pins.wordcell.version,
|
|
177
|
-
});
|
|
178
|
-
if (controller.signal.aborted)
|
|
179
|
-
return failure(requestId, quota ? 'limit-exceeded' : 'cancelled');
|
|
180
|
-
await writeFile(join(root, 'ownership.json'), JSON.stringify({ schemaVersion: 1, requestId, state: 'verified' }), {
|
|
181
|
-
mode: 0o600,
|
|
182
|
-
});
|
|
183
|
-
keep = true;
|
|
184
|
-
return {
|
|
185
|
-
ok: true,
|
|
186
|
-
capture,
|
|
187
|
-
bundleRoot: outputDirectory,
|
|
188
|
-
dispose: async () => {
|
|
189
|
-
if ((await settleCaptureRoot(root, requestId)) === 'pending')
|
|
190
|
-
throw new SpongeError('cleanup-required');
|
|
191
|
-
},
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
catch (error) {
|
|
195
|
-
if (cleanup !== 'settled')
|
|
196
|
-
return failure(requestId, 'cleanup-required', 'cleanup');
|
|
197
|
-
const raw = codeOf(error), code = raw === 'integrity' || raw === 'limit-exceeded' || raw === 'schema-newer' ? raw : 'unclassified';
|
|
198
|
-
return failure(requestId, code, 'verification');
|
|
199
|
-
}
|
|
200
|
-
finally {
|
|
201
|
-
clearInterval(monitor);
|
|
202
|
-
while (checking)
|
|
203
|
-
await new Promise((r) => setTimeout(r, 5));
|
|
204
|
-
signal?.removeEventListener('abort', abort);
|
|
205
|
-
if (!keep && cleanup === 'settled' && (await settleCaptureRoot(root, requestId)) === 'pending')
|
|
206
|
-
return failure(requestId, 'cleanup-required', 'cleanup');
|
|
11
|
+
return false;
|
|
207
12
|
}
|
|
208
13
|
}
|
|
14
|
+
export function captureArticle(request) {
|
|
15
|
+
const endpoint = request.connection?.endpoint;
|
|
16
|
+
return superviseChild({
|
|
17
|
+
...request,
|
|
18
|
+
valid: validSource(request.sourceUrl) && (endpoint === undefined || loopbackEndpoint.test(endpoint)),
|
|
19
|
+
prepare: async ({ output, deadline }) => ({
|
|
20
|
+
schemaVersion: 1,
|
|
21
|
+
kind: 'article',
|
|
22
|
+
url: request.sourceUrl,
|
|
23
|
+
outputDirectory: output,
|
|
24
|
+
// Each acquisition attempt gets half the budget so a slow one cannot starve its fallback.
|
|
25
|
+
timeoutMs: Math.floor(deadline / 2),
|
|
26
|
+
maxHtmlBytes: limits.markdownBytes,
|
|
27
|
+
maxAssetBytes: limits.imageBytes,
|
|
28
|
+
maxTotalAssetBytes: 500 * 1024 ** 2,
|
|
29
|
+
allowPrivateNetwork: request.allowPrivateNetwork === true,
|
|
30
|
+
...(endpoint === undefined ? {} : { cdp: endpoint }),
|
|
31
|
+
}),
|
|
32
|
+
verify: (bundleRoot) => normalizeBundle({ bundleRoot, requestId: request.requestId, sourceUrl: request.sourceUrl }),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
interface ChildRequestBase {
|
|
2
|
+
schemaVersion: 1;
|
|
3
|
+
/** The bundle directory the child creates; the parent re-verifies everything inside it. */
|
|
4
|
+
outputDirectory: string;
|
|
5
|
+
/** The budget of each network acquisition attempt. */
|
|
6
|
+
timeoutMs: number;
|
|
7
|
+
maxAssetBytes: number;
|
|
8
|
+
maxTotalAssetBytes: number;
|
|
9
|
+
allowPrivateNetwork: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface ArticleChildRequest extends ChildRequestBase {
|
|
12
|
+
kind: 'article';
|
|
13
|
+
url: string;
|
|
14
|
+
maxHtmlBytes: number;
|
|
15
|
+
/** A loopback DevTools endpoint of the user's attached signed-in browser. */
|
|
16
|
+
cdp?: string;
|
|
17
|
+
}
|
|
18
|
+
export interface PdfChildRequest extends ChildRequestBase {
|
|
19
|
+
kind: 'pdf';
|
|
20
|
+
/** A remote PDF, or the parent's private copy of a local one. */
|
|
21
|
+
source: {
|
|
22
|
+
url: string;
|
|
23
|
+
} | {
|
|
24
|
+
path: string;
|
|
25
|
+
};
|
|
26
|
+
maxPdfBytes: number;
|
|
27
|
+
maxPages: number;
|
|
28
|
+
maxImages: number;
|
|
29
|
+
}
|
|
30
|
+
/** Reads an existing bundle for import; the answer carries hashes and paths, never document text. */
|
|
31
|
+
export interface VerifyChildRequest {
|
|
32
|
+
schemaVersion: 1;
|
|
33
|
+
kind: 'verify';
|
|
34
|
+
format: 'article' | 'pdf';
|
|
35
|
+
bundle: string;
|
|
36
|
+
}
|
|
37
|
+
export type ChildRequest = ArticleChildRequest | PdfChildRequest | VerifyChildRequest;
|
|
38
|
+
/** Categories the child may report; the parent passes only some of them through. */
|
|
39
|
+
export type ChildFailureCode = 'invalid-input' | 'rate-limited' | 'unavailable' | 'unsupported' | 'limit-exceeded' | 'runtime-missing' | 'integrity' | 'schema-newer' | 'unclassified';
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { CaptureResult, ErrorEnvelope } from '../../contracts/index.js';
|
|
2
|
+
import type { ChildRequest } from './child-request.js';
|
|
3
|
+
import { runOwned } from './process.js';
|
|
4
|
+
export type CaptureOutcome = {
|
|
5
|
+
ok: true;
|
|
6
|
+
capture: CaptureResult;
|
|
7
|
+
bundleRoot: string;
|
|
8
|
+
dispose: () => Promise<void>;
|
|
9
|
+
} | ErrorEnvelope;
|
|
10
|
+
export interface ChildRun {
|
|
11
|
+
requestId: string;
|
|
12
|
+
stagingRoot: string;
|
|
13
|
+
/** The caller's own validation of its source; checked after the request id. */
|
|
14
|
+
valid: boolean;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
deadlineMs?: number;
|
|
17
|
+
runProcess?: typeof runOwned;
|
|
18
|
+
/** Stages any input under the private root and returns the request the child reads. */
|
|
19
|
+
prepare: (staged: {
|
|
20
|
+
root: string;
|
|
21
|
+
output: string;
|
|
22
|
+
deadline: number;
|
|
23
|
+
signal: AbortSignal;
|
|
24
|
+
}) => Promise<ChildRequest>;
|
|
25
|
+
/** Re-verifies every file the child wrote; the child's own claims are never trusted. */
|
|
26
|
+
verify: (bundleRoot: string) => Promise<CaptureResult>;
|
|
27
|
+
}
|
|
28
|
+
/** The owned child: the bundled worker when installed, the TypeScript source under tsx in a checkout. */
|
|
29
|
+
export declare function workerArguments(): string[];
|
|
30
|
+
/** One capture in the owned Node child, under a private staging root with a disk quota, deadline and cleanup. */
|
|
31
|
+
export declare function superviseChild(run: ChildRun): Promise<CaptureOutcome>;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, writeFile, statfs, realpath } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { limits } from '../../contracts/index.js';
|
|
5
|
+
import { privateRoot, physicalBytes } from './files.js';
|
|
6
|
+
import { runOwned, isolatedEnvironment } from './process.js';
|
|
7
|
+
import { settleCaptureRoot } from './cleanup.js';
|
|
8
|
+
import { failure, childFailure } from './errors.js';
|
|
9
|
+
import { object } from './normalize.js';
|
|
10
|
+
import { SpongeError, codeOf } from '../errors.js';
|
|
11
|
+
/** The owned child: the bundled worker when installed, the TypeScript source under tsx in a checkout. */
|
|
12
|
+
export function workerArguments() {
|
|
13
|
+
if (!import.meta.url.endsWith('.ts'))
|
|
14
|
+
return [fileURLToPath(new URL('../../capture/worker.js', import.meta.url))];
|
|
15
|
+
return ['--import', import.meta.resolve('tsx'), fileURLToPath(new URL('../capture/worker.ts', import.meta.url))];
|
|
16
|
+
}
|
|
17
|
+
/** One capture in the owned Node child, under a private staging root with a disk quota, deadline and cleanup. */
|
|
18
|
+
export async function superviseChild(run) {
|
|
19
|
+
const { requestId, signal } = run;
|
|
20
|
+
if (!/^[a-zA-Z0-9_-]{1,128}$/.test(requestId))
|
|
21
|
+
return failure('invalid-request', 'invalid-input');
|
|
22
|
+
if (!run.valid)
|
|
23
|
+
return failure(requestId, 'invalid-input');
|
|
24
|
+
if (signal?.aborted)
|
|
25
|
+
return failure(requestId, 'cancelled');
|
|
26
|
+
let base;
|
|
27
|
+
try {
|
|
28
|
+
base = await privateRoot(run.stagingRoot);
|
|
29
|
+
const disk = await statfs(base);
|
|
30
|
+
if (disk.bavail * disk.bsize < limits.stagingBytes + limits.freeReserveBytes)
|
|
31
|
+
return failure(requestId, 'limit-exceeded');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return failure(requestId, 'invalid-input');
|
|
35
|
+
}
|
|
36
|
+
const deadline = run.deadlineMs ?? limits.captureDeadlineMs;
|
|
37
|
+
if (!Number.isSafeInteger(deadline) || deadline < 100 || deadline > limits.captureDeadlineMs)
|
|
38
|
+
return failure(requestId, 'invalid-input');
|
|
39
|
+
const root = await realpath(await mkdtemp(join(base, 'capture-'))), home = join(root, 'home'), output = join(root, 'bundle'), requestFile = join(root, 'request.json');
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
const abort = () => controller.abort();
|
|
42
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
43
|
+
if (signal?.aborted)
|
|
44
|
+
abort();
|
|
45
|
+
let cleanup = 'settled', keep = false, quota = false, checking = false;
|
|
46
|
+
const monitor = setInterval(async () => {
|
|
47
|
+
if (checking)
|
|
48
|
+
return;
|
|
49
|
+
checking = true;
|
|
50
|
+
try {
|
|
51
|
+
if ((await physicalBytes(root)) > limits.stagingBytes) {
|
|
52
|
+
quota = true;
|
|
53
|
+
controller.abort();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
quota = true;
|
|
58
|
+
controller.abort();
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
checking = false;
|
|
62
|
+
}
|
|
63
|
+
}, 100);
|
|
64
|
+
const started = Date.now();
|
|
65
|
+
try {
|
|
66
|
+
await mkdir(home, { mode: 0o700 });
|
|
67
|
+
await writeFile(join(root, 'ownership.json'), JSON.stringify({ schemaVersion: 1, requestId, state: 'acquiring' }), {
|
|
68
|
+
mode: 0o600,
|
|
69
|
+
});
|
|
70
|
+
const childRequest = await run.prepare({ root, output, deadline, signal: controller.signal });
|
|
71
|
+
await writeFile(requestFile, JSON.stringify(childRequest), { mode: 0o600 });
|
|
72
|
+
cleanup = 'pending';
|
|
73
|
+
const result = await (run.runProcess ?? runOwned)(process.execPath, [...workerArguments(), requestFile], {
|
|
74
|
+
cwd: root,
|
|
75
|
+
env: isolatedEnvironment(home, home),
|
|
76
|
+
signal: controller.signal,
|
|
77
|
+
deadlineMs: Math.max(1, deadline - (Date.now() - started)),
|
|
78
|
+
maxOutputBytes: limits.metadataBytes,
|
|
79
|
+
graceMs: 5000,
|
|
80
|
+
});
|
|
81
|
+
cleanup = result.cleanup;
|
|
82
|
+
let summary;
|
|
83
|
+
try {
|
|
84
|
+
summary = JSON.parse(result.stdout);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
summary = null;
|
|
88
|
+
}
|
|
89
|
+
if (cleanup !== 'settled')
|
|
90
|
+
return failure(requestId, 'cleanup-required', 'cleanup');
|
|
91
|
+
if (quota)
|
|
92
|
+
return failure(requestId, 'limit-exceeded');
|
|
93
|
+
if (result.stopped)
|
|
94
|
+
return failure(requestId, result.stopped);
|
|
95
|
+
if (result.code !== 0 || object(summary).ok !== true)
|
|
96
|
+
return childFailure(summary, requestId);
|
|
97
|
+
if ((await realpath(output)) !== output)
|
|
98
|
+
return failure(requestId, 'integrity', 'verification');
|
|
99
|
+
const capture = await run.verify(output);
|
|
100
|
+
if (controller.signal.aborted)
|
|
101
|
+
return failure(requestId, quota ? 'limit-exceeded' : 'cancelled');
|
|
102
|
+
await writeFile(join(root, 'ownership.json'), JSON.stringify({ schemaVersion: 1, requestId, state: 'verified' }), {
|
|
103
|
+
mode: 0o600,
|
|
104
|
+
});
|
|
105
|
+
keep = true;
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
capture,
|
|
109
|
+
bundleRoot: output,
|
|
110
|
+
dispose: async () => {
|
|
111
|
+
if ((await settleCaptureRoot(root, requestId)) === 'pending')
|
|
112
|
+
throw new SpongeError('cleanup-required');
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (cleanup !== 'settled')
|
|
118
|
+
return failure(requestId, 'cleanup-required', 'cleanup');
|
|
119
|
+
const raw = codeOf(error), code = ['integrity', 'limit-exceeded', 'schema-newer', 'invalid-input', 'cancelled'].find((known) => known === raw) ?? 'unclassified';
|
|
120
|
+
return failure(requestId, code, 'verification');
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
clearInterval(monitor);
|
|
124
|
+
while (checking)
|
|
125
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
126
|
+
signal?.removeEventListener('abort', abort);
|
|
127
|
+
if (!keep && cleanup === 'settled' && (await settleCaptureRoot(root, requestId)) === 'pending')
|
|
128
|
+
return failure(requestId, 'cleanup-required', 'cleanup');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { ErrorCode, ErrorEnvelope } from '../../contracts/index.js';
|
|
2
2
|
export declare const failure: (requestId: string, code: ErrorCode, phase?: ErrorEnvelope["phase"], retryable?: boolean) => ErrorEnvelope;
|
|
3
|
-
/**
|
|
4
|
-
export declare function
|
|
3
|
+
/** The capture child answers with one JSON line; prose is never classified. Only whitelisted categories pass through. */
|
|
4
|
+
export declare function childFailure(value: unknown, requestId: string): ErrorEnvelope;
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
export const failure = (requestId, code, phase = 'acquisition', retryable = false) => ({ schemaVersion: 1, requestId, ok: false, code, phase, retryable });
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
// A missing converter or an oversized source is actionable; the child's own integrity claims are not trusted.
|
|
3
|
+
const childCodes = new Set([
|
|
4
|
+
'invalid-input',
|
|
5
|
+
'rate-limited',
|
|
6
|
+
'unavailable',
|
|
7
|
+
'unsupported',
|
|
8
|
+
'limit-exceeded',
|
|
9
|
+
'runtime-missing',
|
|
10
|
+
]);
|
|
11
|
+
/** The capture child answers with one JSON line; prose is never classified. Only whitelisted categories pass through. */
|
|
12
|
+
export function childFailure(value, requestId) {
|
|
13
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || !('ok' in value))
|
|
14
|
+
return failure(requestId, 'unclassified');
|
|
15
|
+
const code = value.code;
|
|
16
|
+
return childCodes.has(code) ? failure(requestId, code) : failure(requestId, 'unavailable');
|
|
6
17
|
}
|
|
@@ -1,5 +1,2 @@
|
|
|
1
1
|
export { captureArticle } from './capture.js';
|
|
2
2
|
export type { CaptureConnection, CaptureRequest, CaptureOutcome } from './capture.js';
|
|
3
|
-
export { provisionCapture, inspectCaptureRuntime, captureSetupPlan } from './runtime.js';
|
|
4
|
-
export type { CaptureRuntime } from './runtime.js';
|
|
5
|
-
export { defaultCapturePins } from './pins.js';
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
import type { CaptureResult, MetadataClaim } from '../../contracts/index.js';
|
|
2
2
|
export declare function object(value: unknown): Record<string, unknown>;
|
|
3
3
|
export declare function metadataFromMarkdown(markdown: string, observedAt: string): MetadataClaim[];
|
|
4
|
+
/** Re-verify a Sponge capture manifest v1 bundle written by the owned child. The child's word is never trusted. */
|
|
4
5
|
export declare function normalizeBundle(input: {
|
|
5
6
|
bundleRoot: string;
|
|
6
|
-
inspection: unknown;
|
|
7
|
-
summary: unknown;
|
|
8
7
|
requestId: string;
|
|
9
8
|
sourceUrl: string;
|
|
10
|
-
wordcellVersion: string;
|
|
11
9
|
}): Promise<CaptureResult>;
|
|
@@ -27,18 +27,12 @@ export function metadataFromMarkdown(markdown, observedAt) {
|
|
|
27
27
|
}
|
|
28
28
|
return claims;
|
|
29
29
|
}
|
|
30
|
+
/** Identifies Sponge's own capture representation; bump when the bundle meaning changes. */
|
|
31
|
+
const captureExtractor = { name: 'sponge', version: 'capture-v1' };
|
|
32
|
+
/** Re-verify a Sponge capture manifest v1 bundle written by the owned child. The child's word is never trusted. */
|
|
30
33
|
export async function normalizeBundle(input) {
|
|
31
|
-
const verified = object(input.inspection), inspection = object(verified.inspection), document = object(inspection.document), summary = object(input.summary);
|
|
32
|
-
if (verified.ok !== true ||
|
|
33
|
-
inspection.schemaVersion !== 4 ||
|
|
34
|
-
document.integrity !== 'verified' ||
|
|
35
|
-
!Array.isArray(inspection.assets) ||
|
|
36
|
-
inspection.assets.some((a) => object(a).integrity !== 'verified'))
|
|
37
|
-
throw new SpongeError('integrity');
|
|
38
34
|
const manifestBytes = await readConfined(input.bundleRoot, 'capture.json', limits.metadataBytes), manifest = object(JSON.parse(manifestBytes.toString()));
|
|
39
|
-
if (manifest.schemaVersion !==
|
|
40
|
-
manifest.sourceUrl !== input.sourceUrl ||
|
|
41
|
-
inspection.sourceUrl !== input.sourceUrl)
|
|
35
|
+
if (manifest.schemaVersion !== 1 || manifest.sourceUrl !== input.sourceUrl)
|
|
42
36
|
throw new SpongeError('integrity');
|
|
43
37
|
const documentRecord = object(manifest.document);
|
|
44
38
|
const markdown = {
|
|
@@ -48,12 +42,12 @@ export async function normalizeBundle(input) {
|
|
|
48
42
|
mediaType: 'text/markdown',
|
|
49
43
|
};
|
|
50
44
|
validateAsset(markdown);
|
|
45
|
+
if (markdown.relativePath !== 'article.md')
|
|
46
|
+
throw new SpongeError('integrity');
|
|
51
47
|
const bytes = await readConfined(input.bundleRoot, markdown.relativePath, limits.markdownBytes);
|
|
52
|
-
if (bytes.length !== markdown.bytes || hash(bytes) !== markdown.sha256
|
|
48
|
+
if (bytes.length !== markdown.bytes || hash(bytes) !== markdown.sha256)
|
|
53
49
|
throw new SpongeError('integrity');
|
|
54
|
-
if (!Array.isArray(manifest.assets) ||
|
|
55
|
-
manifest.assets.length > limits.imagesPerItem ||
|
|
56
|
-
manifest.assets.length !== inspection.assets.length)
|
|
50
|
+
if (!Array.isArray(manifest.assets) || manifest.assets.length > limits.imagesPerItem)
|
|
57
51
|
throw new SpongeError('limit-exceeded');
|
|
58
52
|
const assets = [];
|
|
59
53
|
for (const raw of manifest.assets) {
|
|
@@ -62,21 +56,25 @@ export async function normalizeBundle(input) {
|
|
|
62
56
|
relativePath: String(item.path),
|
|
63
57
|
sha256: String(item.sha256),
|
|
64
58
|
bytes: Number(item.bytes),
|
|
65
|
-
mediaType: String(item.
|
|
59
|
+
mediaType: String(item.mediaType),
|
|
66
60
|
};
|
|
67
61
|
validateAsset(asset);
|
|
68
62
|
if (!asset.mediaType.startsWith('image/') || asset.bytes > limits.imageBytes)
|
|
69
63
|
throw new SpongeError('limit-exceeded');
|
|
70
|
-
|
|
71
|
-
|
|
64
|
+
if (!asset.relativePath.startsWith('assets/') || assets.some((a) => a.relativePath === asset.relativePath))
|
|
65
|
+
throw new SpongeError('integrity');
|
|
66
|
+
const content = await readConfined(input.bundleRoot, asset.relativePath, limits.imageBytes);
|
|
67
|
+
if (content.length !== asset.bytes || hash(content) !== asset.sha256)
|
|
72
68
|
throw new SpongeError('integrity');
|
|
73
69
|
assets.push(asset);
|
|
74
70
|
}
|
|
75
|
-
const images = object(
|
|
76
|
-
if (
|
|
71
|
+
const images = object(manifest.images);
|
|
72
|
+
if (images.status !== 'captured' && images.status !== 'partial')
|
|
73
|
+
throw new SpongeError('integrity');
|
|
74
|
+
if (manifest.status !== 'complete' && manifest.status !== 'partial')
|
|
77
75
|
throw new SpongeError('integrity');
|
|
78
|
-
const text = manifest.status === 'complete'
|
|
79
|
-
const imageStatus = images.status === 'partial' ? 'partial' :
|
|
76
|
+
const text = manifest.status === 'complete' ? 'complete' : 'partial';
|
|
77
|
+
const imageStatus = images.status === 'partial' ? 'partial' : 'complete';
|
|
80
78
|
const metadataWarnings = manifest.metadata === undefined ? [] : articleMetadataWarnings(manifest.metadata);
|
|
81
79
|
const partial = text === 'partial' || imageStatus === 'partial' || metadataWarnings.length > 0;
|
|
82
80
|
const capture = {
|
|
@@ -92,7 +90,7 @@ export async function normalizeBundle(input) {
|
|
|
92
90
|
? metadataFromMarkdown(bytes.toString('utf8'), String(manifest.capturedAt))
|
|
93
91
|
: articleMetadata(manifest.metadata, String(manifest.capturedAt)),
|
|
94
92
|
warnings: partial ? ['partial'] : [],
|
|
95
|
-
extractor: {
|
|
93
|
+
extractor: { ...captureExtractor },
|
|
96
94
|
manifestSha256: hash(manifestBytes),
|
|
97
95
|
completeness: { text, images: imageStatus },
|
|
98
96
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ErrorEnvelope, JobCheckpoint } from '../../contracts/index.js';
|
|
2
|
-
import { type CaptureRequest, type CaptureOutcome, type
|
|
2
|
+
import { type CaptureRequest, type CaptureOutcome, type CaptureConnection } from '../acquisition/index.js';
|
|
3
3
|
import type { LibraryStore, AttemptToken } from '../store/index.js';
|
|
4
4
|
interface ArticleResult {
|
|
5
5
|
ok: true;
|
|
@@ -13,7 +13,6 @@ export type ArticleOutcome = (ArticleResult | ErrorEnvelope) & {
|
|
|
13
13
|
export interface ArticleOptions {
|
|
14
14
|
checkpoint?: (stage: JobCheckpoint['stage']) => Promise<void>;
|
|
15
15
|
runProcess?: CaptureRequest['runProcess'];
|
|
16
|
-
runtime: CaptureRuntime;
|
|
17
16
|
connection?: CaptureConnection;
|
|
18
17
|
signal?: AbortSignal;
|
|
19
18
|
allowPrivateNetwork?: boolean;
|
package/dist/src/app/article.js
CHANGED
|
@@ -23,7 +23,6 @@ export function ingestArticle(store, token, options) {
|
|
|
23
23
|
throw new SpongeError('invalid-input');
|
|
24
24
|
const result = await (options.capture ?? captureArticle)({
|
|
25
25
|
runProcess: options.runProcess,
|
|
26
|
-
runtime: options.runtime,
|
|
27
26
|
stagingRoot: join(store.root, 'staging'),
|
|
28
27
|
sourceUrl: source.locator,
|
|
29
28
|
requestId: token.attemptId,
|
|
@@ -58,7 +57,7 @@ export function ingestArticle(store, token, options) {
|
|
|
58
57
|
const title = typeof selectedTitle === 'string' ? selectedTitle : undefined;
|
|
59
58
|
const status = localized.missingImages ? 'partial' : capture.status;
|
|
60
59
|
// Representation algorithm participates in identity: later extraction changes cannot reuse an old payload.
|
|
61
|
-
const bundleHash = sha256(JSON.stringify(['article-local-markdown-
|
|
60
|
+
const bundleHash = sha256(JSON.stringify(['article-local-markdown-v2', capture.manifestSha256]));
|
|
62
61
|
await options.checkpoint?.('promoting');
|
|
63
62
|
const revision = store.revisions.commit({
|
|
64
63
|
...token,
|