harnery 0.26.0 → 0.28.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/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +27 -1
- package/dist/commands/harness.d.ts.map +1 -1
- package/dist/commands/harness.js +2 -0
- package/dist/commands/work.d.ts +2 -0
- package/dist/commands/work.d.ts.map +1 -1
- package/dist/commands/work.js +47 -4
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +64 -0
- package/dist/core/harnesses/attest-projection.d.ts +44 -0
- package/dist/core/harnesses/attest-projection.d.ts.map +1 -0
- package/dist/core/harnesses/attest-projection.js +113 -0
- package/dist/core/harnesses/attest.d.ts +9 -0
- package/dist/core/harnesses/attest.d.ts.map +1 -1
- package/dist/core/harnesses/attest.js +14 -1
- package/dist/core/harnesses/attestation.d.ts +1 -1
- package/dist/core/harnesses/attestation.d.ts.map +1 -1
- package/dist/core/harnesses/attestation.js +7 -1
- package/dist/core/harnesses/bench.d.ts.map +1 -1
- package/dist/core/harnesses/bench.js +13 -0
- package/dist/core/harnesses/profiles.d.ts +7 -0
- package/dist/core/harnesses/profiles.d.ts.map +1 -1
- package/dist/core/harnesses/profiles.js +9 -0
- package/dist/core/harnesses/types.d.ts +19 -1
- package/dist/core/harnesses/types.d.ts.map +1 -1
- package/dist/core/harnesses/types.js +1 -0
- package/dist/core/work/index.d.ts +1 -1
- package/dist/core/work/index.d.ts.map +1 -1
- package/dist/core/work/index.js +1 -1
- package/dist/core/work/runner.d.ts.map +1 -1
- package/dist/core/work/runner.js +8 -1
- package/dist/core/work/state.d.ts +24 -1
- package/dist/core/work/state.d.ts.map +1 -1
- package/dist/core/work/state.js +108 -14
- package/dist/core/workflow/approvals.d.ts.map +1 -1
- package/dist/core/workflow/approvals.js +3 -6
- package/dist/core/workflow/attempt-context.d.ts.map +1 -1
- package/dist/core/workflow/attempt-context.js +38 -1
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +61 -19
- package/dist/core/workflow/index.d.ts +1 -1
- package/dist/core/workflow/index.d.ts.map +1 -1
- package/dist/core/workflow/index.js +1 -1
- package/dist/core/workflow/journal.d.ts +30 -0
- package/dist/core/workflow/journal.d.ts.map +1 -0
- package/dist/core/workflow/journal.js +70 -0
- package/dist/core/workflow/proof.d.ts +3 -1
- package/dist/core/workflow/proof.d.ts.map +1 -1
- package/dist/core/workflow/proof.js +1 -0
- package/dist/core/workflow/sandbox-projection.d.ts +57 -0
- package/dist/core/workflow/sandbox-projection.d.ts.map +1 -0
- package/dist/core/workflow/sandbox-projection.js +95 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +14 -1
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
- package/dist/core/workflow/spawn-codex.js +18 -2
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
- package/dist/core/workflow/spawn-cursor.js +14 -1
- package/dist/core/workflow/types.d.ts +62 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/index.d.ts +2 -0
- package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/index.js +1 -0
- package/dist/core/workflow/workspaces/inspect.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/inspect.js +14 -0
- package/dist/core/workflow/workspaces/reclaim.d.ts +50 -0
- package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -0
- package/dist/core/workflow/workspaces/reclaim.js +88 -0
- package/dist/core/workflow/workspaces/state.d.ts +1 -1
- package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/state.js +9 -30
- package/dist/lib/browser/client.d.ts +50 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +103 -0
- package/dist/lib/exec.d.ts +4 -0
- package/dist/lib/exec.d.ts.map +1 -1
- package/dist/lib/exec.js +6 -1
- package/dist/lib/tunnel/error-page.d.ts +15 -0
- package/dist/lib/tunnel/error-page.d.ts.map +1 -0
- package/dist/lib/tunnel/error-page.js +98 -0
- package/dist/lib/tunnel/gate.d.ts +1 -12
- package/dist/lib/tunnel/gate.d.ts.map +1 -1
- package/dist/lib/tunnel/gate.js +59 -4
- package/package.json +1 -1
- package/src/commands/browse.ts +33 -1
- package/src/commands/harness.ts +6 -0
- package/src/commands/work.ts +80 -13
- package/src/commands/workflow.ts +74 -0
- package/src/core/harnesses/attest-projection.ts +151 -0
- package/src/core/harnesses/attest.ts +23 -1
- package/src/core/harnesses/attestation.ts +7 -1
- package/src/core/harnesses/bench.ts +16 -0
- package/src/core/harnesses/profiles.ts +11 -0
- package/src/core/harnesses/types.ts +20 -0
- package/src/core/work/index.ts +1 -0
- package/src/core/work/runner.ts +8 -0
- package/src/core/work/state.ts +177 -27
- package/src/core/workflow/approvals.ts +3 -6
- package/src/core/workflow/attempt-context.ts +47 -1
- package/src/core/workflow/engine.ts +106 -33
- package/src/core/workflow/index.ts +1 -0
- package/src/core/workflow/journal.ts +90 -0
- package/src/core/workflow/proof.ts +4 -0
- package/src/core/workflow/sandbox-projection.ts +150 -0
- package/src/core/workflow/spawn-claude.ts +18 -1
- package/src/core/workflow/spawn-codex.ts +25 -2
- package/src/core/workflow/spawn-cursor.ts +18 -1
- package/src/core/workflow/types.ts +66 -0
- package/src/core/workflow/workspaces/index.ts +2 -0
- package/src/core/workflow/workspaces/inspect.ts +15 -0
- package/src/core/workflow/workspaces/reclaim.ts +109 -0
- package/src/core/workflow/workspaces/state.ts +13 -33
- package/src/lib/browser/client.ts +129 -0
- package/src/lib/exec.ts +10 -1
- package/src/lib/tunnel/error-page.ts +114 -0
- package/src/lib/tunnel/gate.ts +66 -3
|
@@ -17,6 +17,9 @@ import {
|
|
|
17
17
|
stableDigest,
|
|
18
18
|
writeImmutableJson,
|
|
19
19
|
} from "../durable-record.ts";
|
|
20
|
+
|
|
21
|
+
export { appendWorkflowJournalEvent } from "../journal.ts";
|
|
22
|
+
|
|
20
23
|
import type {
|
|
21
24
|
GitRepositoryBinding,
|
|
22
25
|
IntegrationApplyAttempt,
|
|
@@ -34,7 +37,7 @@ import type {
|
|
|
34
37
|
} from "./types.ts";
|
|
35
38
|
|
|
36
39
|
const JSON_LIMIT = 512 * 1024;
|
|
37
|
-
const
|
|
40
|
+
const WORKSPACE_EVENT_BYTES = 32 * 1024;
|
|
38
41
|
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
|
|
39
42
|
|
|
40
43
|
export interface WorkspaceClaim {
|
|
@@ -181,7 +184,11 @@ export function appendWorkspaceEvent(
|
|
|
181
184
|
event: string,
|
|
182
185
|
data: Record<string, unknown> = {},
|
|
183
186
|
): WorkspaceProviderEvent {
|
|
184
|
-
if (
|
|
187
|
+
if (
|
|
188
|
+
!event ||
|
|
189
|
+
event.length > 100 ||
|
|
190
|
+
Buffer.byteLength(JSON.stringify(data)) > WORKSPACE_EVENT_BYTES / 2
|
|
191
|
+
) {
|
|
185
192
|
throw new Error("workspace provider event is invalid or too large");
|
|
186
193
|
}
|
|
187
194
|
const path = workspaceEventsPath(coordRoot, claim.provider_id, claim.binding_id);
|
|
@@ -204,7 +211,7 @@ export function appendWorkspaceEvent(
|
|
|
204
211
|
record_sha256: stableDigest(basis),
|
|
205
212
|
};
|
|
206
213
|
const line = `${canonicalJson(record)}\n`;
|
|
207
|
-
if (Buffer.byteLength(line) >
|
|
214
|
+
if (Buffer.byteLength(line) > WORKSPACE_EVENT_BYTES)
|
|
208
215
|
throw new Error("workspace provider event is too large");
|
|
209
216
|
const fd = openSync(path, "a", 0o600);
|
|
210
217
|
try {
|
|
@@ -232,7 +239,7 @@ export function readWorkspaceEvents(
|
|
|
232
239
|
.split("\n")
|
|
233
240
|
.filter(Boolean)
|
|
234
241
|
.map((line, index) => {
|
|
235
|
-
if (Buffer.byteLength(line) >
|
|
242
|
+
if (Buffer.byteLength(line) > WORKSPACE_EVENT_BYTES) {
|
|
236
243
|
throw new Error(`workspace event ${index + 1} is too large`);
|
|
237
244
|
}
|
|
238
245
|
let record: WorkspaceProviderEvent;
|
|
@@ -270,33 +277,6 @@ function recordsDigestPlaceholder(): null {
|
|
|
270
277
|
return null;
|
|
271
278
|
}
|
|
272
279
|
|
|
273
|
-
export function appendWorkflowJournalEvent(
|
|
274
|
-
coordRoot: string,
|
|
275
|
-
runId: string,
|
|
276
|
-
event: string,
|
|
277
|
-
data: Record<string, unknown>,
|
|
278
|
-
): void {
|
|
279
|
-
const path = join(workflowRunDir(coordRoot, runId), "journal.jsonl");
|
|
280
|
-
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
281
|
-
const existed = existsSync(path);
|
|
282
|
-
const line = `${JSON.stringify({
|
|
283
|
-
schema_version: 1,
|
|
284
|
-
run_id: runId,
|
|
285
|
-
ts: new Date().toISOString(),
|
|
286
|
-
event,
|
|
287
|
-
...data,
|
|
288
|
-
})}\n`;
|
|
289
|
-
if (Buffer.byteLength(line) > EVENT_LIMIT) throw new Error("workflow journal event is too large");
|
|
290
|
-
const fd = openSync(path, "a", 0o600);
|
|
291
|
-
try {
|
|
292
|
-
writeFileSync(fd, line, "utf8");
|
|
293
|
-
fsyncSync(fd);
|
|
294
|
-
} finally {
|
|
295
|
-
closeSync(fd);
|
|
296
|
-
}
|
|
297
|
-
if (!existed) fsyncParentDirectory(path);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
280
|
export type WorkflowSupplement =
|
|
301
281
|
| WorkspaceBinding
|
|
302
282
|
| IntegrationReviewRecord
|
|
@@ -327,7 +307,7 @@ export function appendCleanupAttempt(
|
|
|
327
307
|
record_sha256: stableDigest(basis),
|
|
328
308
|
};
|
|
329
309
|
const line = `${canonicalJson(attempt)}\n`;
|
|
330
|
-
if (Buffer.byteLength(line) >
|
|
310
|
+
if (Buffer.byteLength(line) > WORKSPACE_EVENT_BYTES) {
|
|
331
311
|
throw new Error("workspace cleanup attempt is too large");
|
|
332
312
|
}
|
|
333
313
|
const fd = openSync(path, "a", 0o600);
|
|
@@ -388,7 +368,7 @@ function appendChainedRecord(
|
|
|
388
368
|
label: string,
|
|
389
369
|
): void {
|
|
390
370
|
const line = `${canonicalJson(record)}\n`;
|
|
391
|
-
if (Buffer.byteLength(line) >
|
|
371
|
+
if (Buffer.byteLength(line) > WORKSPACE_EVENT_BYTES) {
|
|
392
372
|
throw new Error(`${label} is too large`);
|
|
393
373
|
}
|
|
394
374
|
const fd = openSync(path, "a", 0o600);
|
|
@@ -295,6 +295,135 @@ export class Browser {
|
|
|
295
295
|
return buf.length;
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
/**
|
|
299
|
+
* Capture a full-page screenshot from an explicit capture viewport and
|
|
300
|
+
* evaluate the caller's final evidence expression immediately before the
|
|
301
|
+
* pixels are written. Playwright normally manages the full-page viewport
|
|
302
|
+
* internally, which leaves callers unable to inspect fixed/sticky geometry
|
|
303
|
+
* in the state the PNG actually renders.
|
|
304
|
+
*/
|
|
305
|
+
async screenshotWithEvaluation<T = unknown>(
|
|
306
|
+
path: string,
|
|
307
|
+
evaluation: string,
|
|
308
|
+
opts: { fullPage?: boolean } = {},
|
|
309
|
+
): Promise<{
|
|
310
|
+
bytes: number;
|
|
311
|
+
evaluation: T;
|
|
312
|
+
viewport: { width: number; height: number };
|
|
313
|
+
evidence: {
|
|
314
|
+
converged: boolean;
|
|
315
|
+
reason: string;
|
|
316
|
+
passes: number;
|
|
317
|
+
max_passes: number;
|
|
318
|
+
max_dimension: number;
|
|
319
|
+
max_pixels: number;
|
|
320
|
+
original_viewport: { width: number; height: number };
|
|
321
|
+
evaluated_viewport: { width: number; height: number };
|
|
322
|
+
document_extent_before_evaluation: { width: number; height: number };
|
|
323
|
+
document_extent_after_evaluation: { width: number; height: number };
|
|
324
|
+
document_extent_after_screenshot: { width: number; height: number };
|
|
325
|
+
screenshot: { width: number; height: number; bytes: number };
|
|
326
|
+
};
|
|
327
|
+
}> {
|
|
328
|
+
const page = this.currentPage;
|
|
329
|
+
const fullPage = opts.fullPage ?? true;
|
|
330
|
+
const maxPasses = 4;
|
|
331
|
+
const maxDimension = 32_000;
|
|
332
|
+
const maxPixels = 128_000_000;
|
|
333
|
+
const documentExtent = () =>
|
|
334
|
+
page.evaluate(() => ({
|
|
335
|
+
width: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
|
|
336
|
+
height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0),
|
|
337
|
+
}));
|
|
338
|
+
const originalViewport = page.viewportSize();
|
|
339
|
+
if (!originalViewport) {
|
|
340
|
+
throw new Error("Capture-state evaluation requires a page with an explicit viewport.");
|
|
341
|
+
}
|
|
342
|
+
let captureViewport = originalViewport;
|
|
343
|
+
let passes = fullPage ? 0 : 1;
|
|
344
|
+
let extentBeforeEvaluation = await documentExtent();
|
|
345
|
+
let bounded = true;
|
|
346
|
+
if (fullPage) {
|
|
347
|
+
for (let pass = 0; pass < maxPasses; pass += 1) {
|
|
348
|
+
passes = pass + 1;
|
|
349
|
+
const nextViewport = {
|
|
350
|
+
width: originalViewport.width,
|
|
351
|
+
height: Math.max(originalViewport.height, Math.ceil(extentBeforeEvaluation.height)),
|
|
352
|
+
};
|
|
353
|
+
bounded =
|
|
354
|
+
nextViewport.width <= maxDimension &&
|
|
355
|
+
nextViewport.height <= maxDimension &&
|
|
356
|
+
nextViewport.width * nextViewport.height <= maxPixels;
|
|
357
|
+
if (!bounded) break;
|
|
358
|
+
if (
|
|
359
|
+
nextViewport.width === captureViewport.width &&
|
|
360
|
+
nextViewport.height === captureViewport.height &&
|
|
361
|
+
extentBeforeEvaluation.width <= captureViewport.width &&
|
|
362
|
+
extentBeforeEvaluation.height === captureViewport.height
|
|
363
|
+
) {
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
captureViewport = nextViewport;
|
|
367
|
+
await page.setViewportSize(captureViewport);
|
|
368
|
+
extentBeforeEvaluation = await documentExtent();
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
const evaluationResult = await page.evaluate<T>(evaluation);
|
|
373
|
+
const extentAfterEvaluation = await documentExtent();
|
|
374
|
+
const buf = await page.screenshot({ path, fullPage: fullPage && bounded, type: "png" });
|
|
375
|
+
const extentAfterScreenshot = await documentExtent();
|
|
376
|
+
const screenshot = {
|
|
377
|
+
width: buf.readUInt32BE(16),
|
|
378
|
+
height: buf.readUInt32BE(20),
|
|
379
|
+
bytes: buf.length,
|
|
380
|
+
};
|
|
381
|
+
const exact = fullPage
|
|
382
|
+
? bounded &&
|
|
383
|
+
captureViewport.width === extentBeforeEvaluation.width &&
|
|
384
|
+
captureViewport.height === extentBeforeEvaluation.height &&
|
|
385
|
+
captureViewport.width === extentAfterEvaluation.width &&
|
|
386
|
+
captureViewport.height === extentAfterEvaluation.height &&
|
|
387
|
+
captureViewport.width === extentAfterScreenshot.width &&
|
|
388
|
+
captureViewport.height === extentAfterScreenshot.height &&
|
|
389
|
+
captureViewport.width === screenshot.width &&
|
|
390
|
+
captureViewport.height === screenshot.height
|
|
391
|
+
: captureViewport.width === screenshot.width &&
|
|
392
|
+
captureViewport.height === screenshot.height;
|
|
393
|
+
const reason = !bounded
|
|
394
|
+
? "capture_bounds_exceeded"
|
|
395
|
+
: exact
|
|
396
|
+
? "capture_viewport_converged"
|
|
397
|
+
: "capture_viewport_non_convergent";
|
|
398
|
+
return {
|
|
399
|
+
bytes: buf.length,
|
|
400
|
+
evaluation: evaluationResult,
|
|
401
|
+
viewport: captureViewport,
|
|
402
|
+
evidence: {
|
|
403
|
+
converged: exact,
|
|
404
|
+
reason,
|
|
405
|
+
passes,
|
|
406
|
+
max_passes: maxPasses,
|
|
407
|
+
max_dimension: maxDimension,
|
|
408
|
+
max_pixels: maxPixels,
|
|
409
|
+
original_viewport: originalViewport,
|
|
410
|
+
evaluated_viewport: captureViewport,
|
|
411
|
+
document_extent_before_evaluation: extentBeforeEvaluation,
|
|
412
|
+
document_extent_after_evaluation: extentAfterEvaluation,
|
|
413
|
+
document_extent_after_screenshot: extentAfterScreenshot,
|
|
414
|
+
screenshot,
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
} finally {
|
|
418
|
+
if (
|
|
419
|
+
captureViewport.width !== originalViewport.width ||
|
|
420
|
+
captureViewport.height !== originalViewport.height
|
|
421
|
+
) {
|
|
422
|
+
await page.setViewportSize(originalViewport);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
298
427
|
/**
|
|
299
428
|
* Plain-text snapshot of the document body. Suitable as a coarse "what's
|
|
300
429
|
* on screen" signal for LLM iteration loops. For richer extraction, use
|
package/src/lib/exec.ts
CHANGED
|
@@ -13,6 +13,10 @@ export interface ExecResult {
|
|
|
13
13
|
stdout: string;
|
|
14
14
|
stderr: string;
|
|
15
15
|
exitCode: number;
|
|
16
|
+
/** True when this process killed the child for exceeding `opts.timeout`. A
|
|
17
|
+
* child that handles the signal cleanly still exits 0, so the exit code alone
|
|
18
|
+
* cannot distinguish a kill from an ordinary finish. */
|
|
19
|
+
timedOut?: boolean;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export interface ExecOpts {
|
|
@@ -46,7 +50,11 @@ export function exec(cmd: string[], opts: ExecOpts = {}): Promise<ExecResult> {
|
|
|
46
50
|
proc.stderr?.on("data", (d: Buffer) => err.push(d));
|
|
47
51
|
|
|
48
52
|
const timeout = opts.timeout ?? 30_000;
|
|
49
|
-
|
|
53
|
+
let timedOut = false;
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
timedOut = true;
|
|
56
|
+
proc.kill();
|
|
57
|
+
}, timeout);
|
|
50
58
|
|
|
51
59
|
const finish = (exitCode: number, errOverride?: string): void => {
|
|
52
60
|
clearTimeout(timer);
|
|
@@ -57,6 +65,7 @@ export function exec(cmd: string[], opts: ExecOpts = {}): Promise<ExecResult> {
|
|
|
57
65
|
stdout: shouldTrim ? stdout.trim() : stdout.replace(/\n$/, ""),
|
|
58
66
|
stderr: shouldTrim ? stderr.trim() : stderr.replace(/\n$/, ""),
|
|
59
67
|
exitCode,
|
|
68
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
60
69
|
});
|
|
61
70
|
};
|
|
62
71
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
export interface TunnelErrorPageOptions {
|
|
2
|
+
kind: "access-denied" | "upstream-unavailable";
|
|
3
|
+
incidentId: string;
|
|
4
|
+
timestamp: string;
|
|
5
|
+
tunnelName: string;
|
|
6
|
+
method: string;
|
|
7
|
+
path: string;
|
|
8
|
+
clientIp: string;
|
|
9
|
+
cloudflareRay: string;
|
|
10
|
+
target?: string;
|
|
11
|
+
errorCode?: string;
|
|
12
|
+
errorMessage?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function escapeHtml(value: string): string {
|
|
16
|
+
return value
|
|
17
|
+
.replaceAll("&", "&")
|
|
18
|
+
.replaceAll("<", "<")
|
|
19
|
+
.replaceAll(">", ">")
|
|
20
|
+
.replaceAll('"', """)
|
|
21
|
+
.replaceAll("'", "'");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function diagnosticText(options: TunnelErrorPageOptions): string {
|
|
25
|
+
const lines = [
|
|
26
|
+
`Tunnel incident: ${options.incidentId}`,
|
|
27
|
+
`Time: ${options.timestamp}`,
|
|
28
|
+
`Problem: ${options.kind}`,
|
|
29
|
+
`Tunnel: ${options.tunnelName}`,
|
|
30
|
+
`Request: ${options.method} ${options.path}`,
|
|
31
|
+
`Client IP: ${options.clientIp || "(not provided by proxy)"}`,
|
|
32
|
+
`Cloudflare Ray: ${options.cloudflareRay || "(not provided)"}`,
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
if (options.target) lines.push(`Upstream: ${options.target}`);
|
|
36
|
+
if (options.errorCode) lines.push(`Error code: ${options.errorCode}`);
|
|
37
|
+
if (options.errorMessage) lines.push(`Error: ${options.errorMessage}`);
|
|
38
|
+
|
|
39
|
+
return lines.join("\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function renderTunnelErrorPage(options: TunnelErrorPageOptions): string {
|
|
43
|
+
const denied = options.kind === "access-denied";
|
|
44
|
+
const title = denied ? "This device is not allowed yet" : "The preview is temporarily offline";
|
|
45
|
+
const summary = denied
|
|
46
|
+
? "The tunnel is running, but this device's public IP is not on its access list."
|
|
47
|
+
: "The public tunnel and access check are working, but the local preview server is not responding.";
|
|
48
|
+
const nextStep = denied
|
|
49
|
+
? "Copy the diagnostic below and send it to the person running the tunnel. It includes the public IP that needs to be allowed."
|
|
50
|
+
: "Copy the diagnostic below and send it to the person running the tunnel. The incident ID can be matched to the server log.";
|
|
51
|
+
const diagnostic = diagnosticText(options);
|
|
52
|
+
const safeDiagnosticForScript = JSON.stringify(diagnostic).replaceAll("<", "\\u003c");
|
|
53
|
+
|
|
54
|
+
return `<!doctype html>
|
|
55
|
+
<html lang="en">
|
|
56
|
+
<head>
|
|
57
|
+
<meta charset="utf-8">
|
|
58
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
59
|
+
<meta name="robots" content="noindex,nofollow">
|
|
60
|
+
<title>${escapeHtml(title)}</title>
|
|
61
|
+
<style>
|
|
62
|
+
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
63
|
+
* { box-sizing: border-box; }
|
|
64
|
+
body { margin: 0; min-height: 100vh; background: #f4f2ed; color: #17211c; display: grid; place-items: center; padding: 24px; }
|
|
65
|
+
main { width: min(100%, 720px); background: #fff; border: 1px solid #d8d4ca; border-radius: 18px; box-shadow: 0 18px 50px rgba(23, 33, 28, .10); overflow: hidden; }
|
|
66
|
+
.bar { height: 8px; background: ${denied ? "#b54708" : "#b42318"}; }
|
|
67
|
+
.content { padding: clamp(24px, 5vw, 48px); }
|
|
68
|
+
.eyebrow { margin: 0 0 10px; color: ${denied ? "#8a3708" : "#912018"}; font-size: 13px; font-weight: 750; letter-spacing: .09em; text-transform: uppercase; }
|
|
69
|
+
h1 { margin: 0; max-width: 18ch; font-size: clamp(30px, 7vw, 48px); line-height: 1.02; letter-spacing: -.04em; }
|
|
70
|
+
.summary { margin: 20px 0 10px; max-width: 58ch; font-size: 18px; line-height: 1.55; }
|
|
71
|
+
.next { margin: 0 0 24px; max-width: 62ch; color: #536058; line-height: 1.55; }
|
|
72
|
+
.diagnostic-wrap { position: relative; }
|
|
73
|
+
pre { margin: 0; padding: 18px; border: 1px solid #ccd2cd; border-radius: 12px; background: #111814; color: #edf5ef; font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
74
|
+
button { width: 100%; margin-top: 12px; border: 0; border-radius: 11px; padding: 14px 18px; background: #17643a; color: #fff; font: inherit; font-weight: 750; cursor: pointer; }
|
|
75
|
+
button:hover { background: #0e5230; }
|
|
76
|
+
button:focus-visible { outline: 3px solid #87c89f; outline-offset: 3px; }
|
|
77
|
+
.incident { margin: 18px 0 0; color: #68736c; font-size: 13px; text-align: center; }
|
|
78
|
+
</style>
|
|
79
|
+
</head>
|
|
80
|
+
<body>
|
|
81
|
+
<main>
|
|
82
|
+
<div class="bar"></div>
|
|
83
|
+
<div class="content">
|
|
84
|
+
<p class="eyebrow">Tunnel diagnostic</p>
|
|
85
|
+
<h1>${escapeHtml(title)}</h1>
|
|
86
|
+
<p class="summary">${escapeHtml(summary)}</p>
|
|
87
|
+
<p class="next">${escapeHtml(nextStep)}</p>
|
|
88
|
+
<div class="diagnostic-wrap">
|
|
89
|
+
<pre id="diagnostic">${escapeHtml(diagnostic)}</pre>
|
|
90
|
+
<button id="copy" type="button">Copy diagnostic</button>
|
|
91
|
+
</div>
|
|
92
|
+
<p class="incident">Incident ${escapeHtml(options.incidentId)}</p>
|
|
93
|
+
</div>
|
|
94
|
+
</main>
|
|
95
|
+
<script>
|
|
96
|
+
const diagnostic = ${safeDiagnosticForScript};
|
|
97
|
+
const button = document.getElementById("copy");
|
|
98
|
+
button.addEventListener("click", async () => {
|
|
99
|
+
try {
|
|
100
|
+
await navigator.clipboard.writeText(diagnostic);
|
|
101
|
+
button.textContent = "Copied";
|
|
102
|
+
} catch {
|
|
103
|
+
const range = document.createRange();
|
|
104
|
+
range.selectNodeContents(document.getElementById("diagnostic"));
|
|
105
|
+
const selection = window.getSelection();
|
|
106
|
+
selection.removeAllRanges();
|
|
107
|
+
selection.addRange(range);
|
|
108
|
+
button.textContent = "Selected — copy now";
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
</script>
|
|
112
|
+
</body>
|
|
113
|
+
</html>`;
|
|
114
|
+
}
|
package/src/lib/tunnel/gate.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// available; stdout/stderr is captured into .cache/tunnel/gate.log by the
|
|
10
10
|
// spawner.
|
|
11
11
|
|
|
12
|
+
import { renderTunnelErrorPage } from "./error-page";
|
|
13
|
+
|
|
12
14
|
// `--port`/`--name` are also passed on argv (not just env) so the gate's port
|
|
13
15
|
// and instance name show up in its process command line. That's what lets
|
|
14
16
|
// `tunnel down` scope its stray-process sweep to a single instance via
|
|
@@ -30,6 +32,7 @@ const TARGET = process.env.HARNERY_TUNNEL_TARGET ?? "127.0.0.1:8001";
|
|
|
30
32
|
const VHOST = process.env.HARNERY_TUNNEL_VHOST ?? "localhost";
|
|
31
33
|
const PORT = Number(process.env.HARNERY_TUNNEL_PORT ?? argvFlag("--port") ?? "9001");
|
|
32
34
|
const ACCESS = process.env.HARNERY_TUNNEL_ACCESS ?? "cloudflare-allowlist";
|
|
35
|
+
const NAME = argvFlag("--name") ?? "default";
|
|
33
36
|
|
|
34
37
|
const UPSTREAM_HTTP = `http://${TARGET}`;
|
|
35
38
|
const UPSTREAM_WS = `ws://${TARGET}`;
|
|
@@ -38,6 +41,27 @@ interface WsData {
|
|
|
38
41
|
path: string;
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
function requestDetails(req: Request, url: URL) {
|
|
45
|
+
return {
|
|
46
|
+
incidentId: crypto.randomUUID().slice(0, 8),
|
|
47
|
+
timestamp: new Date().toISOString(),
|
|
48
|
+
tunnelName: NAME,
|
|
49
|
+
method: req.method,
|
|
50
|
+
path: url.pathname + url.search,
|
|
51
|
+
clientIp: req.headers.get("cf-connecting-ip") ?? "",
|
|
52
|
+
cloudflareRay: req.headers.get("cf-ray") ?? "",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function errorDetails(error: unknown): { code: string; message: string } {
|
|
57
|
+
if (!(error instanceof Error)) {
|
|
58
|
+
return { code: "UnknownError", message: String(error) };
|
|
59
|
+
}
|
|
60
|
+
const code =
|
|
61
|
+
"code" in error && typeof error.code === "string" ? error.code : error.name || "Error";
|
|
62
|
+
return { code, message: error.message || "The upstream request failed." };
|
|
63
|
+
}
|
|
64
|
+
|
|
41
65
|
const server = Bun.serve<WsData, never>({
|
|
42
66
|
port: PORT,
|
|
43
67
|
hostname: "127.0.0.1",
|
|
@@ -51,13 +75,21 @@ const server = Bun.serve<WsData, never>({
|
|
|
51
75
|
if (ACCESS === "cloudflare-allowlist") {
|
|
52
76
|
const ip = req.headers.get("cf-connecting-ip") ?? "";
|
|
53
77
|
if (!ALLOW.has(ip)) {
|
|
78
|
+
const details = requestDetails(req, url);
|
|
54
79
|
// Log denials so operators can whitelist a phone/laptop that just
|
|
55
80
|
// hit 403 without asking the human to dig up their public IP.
|
|
56
81
|
console.log(
|
|
57
82
|
// lint-ok-emission: detached worker, see file note above
|
|
58
|
-
`deny:
|
|
83
|
+
`deny: incident=${details.incidentId} ip=${ip || "(missing-cf-connecting-ip)"} ray=${details.cloudflareRay || "(missing)"} ${req.method} ${details.path}`,
|
|
59
84
|
);
|
|
60
|
-
return new Response(
|
|
85
|
+
return new Response(renderTunnelErrorPage({ ...details, kind: "access-denied" }), {
|
|
86
|
+
status: 403,
|
|
87
|
+
headers: {
|
|
88
|
+
"cache-control": "no-store",
|
|
89
|
+
"content-type": "text/html; charset=utf-8",
|
|
90
|
+
"x-harnery-tunnel-incident": details.incidentId,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
61
93
|
}
|
|
62
94
|
}
|
|
63
95
|
|
|
@@ -81,7 +113,38 @@ const server = Bun.serve<WsData, never>({
|
|
|
81
113
|
if (!["GET", "HEAD"].includes(req.method)) {
|
|
82
114
|
init.body = await req.arrayBuffer();
|
|
83
115
|
}
|
|
84
|
-
|
|
116
|
+
let resp: Response;
|
|
117
|
+
try {
|
|
118
|
+
resp = await fetch(UPSTREAM_HTTP + url.pathname + url.search, init);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
const details = requestDetails(req, url);
|
|
121
|
+
const failure = errorDetails(error);
|
|
122
|
+
console.log(
|
|
123
|
+
// lint-ok-emission: detached worker, see file note above
|
|
124
|
+
`upstream-error: incident=${details.incidentId} ip=${details.clientIp || "(missing-cf-connecting-ip)"} ray=${details.cloudflareRay || "(missing)"} code=${failure.code} ${req.method} ${details.path}`,
|
|
125
|
+
);
|
|
126
|
+
return new Response(
|
|
127
|
+
renderTunnelErrorPage({
|
|
128
|
+
...details,
|
|
129
|
+
kind: "upstream-unavailable",
|
|
130
|
+
target: UPSTREAM_HTTP,
|
|
131
|
+
errorCode: failure.code,
|
|
132
|
+
errorMessage: failure.message,
|
|
133
|
+
}),
|
|
134
|
+
{
|
|
135
|
+
// Cloudflare replaces 502 response bodies with its own generic error
|
|
136
|
+
// page. Return the diagnostic as a successful document and preserve
|
|
137
|
+
// the semantic status in a header so the human can actually see it.
|
|
138
|
+
status: 200,
|
|
139
|
+
headers: {
|
|
140
|
+
"cache-control": "no-store",
|
|
141
|
+
"content-type": "text/html; charset=utf-8",
|
|
142
|
+
"x-harnery-tunnel-incident": details.incidentId,
|
|
143
|
+
"x-harnery-tunnel-status": "502",
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
}
|
|
85
148
|
const respHeaders = new Headers(resp.headers);
|
|
86
149
|
respHeaders.delete("content-encoding");
|
|
87
150
|
respHeaders.delete("content-length");
|