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.
Files changed (116) hide show
  1. package/dist/commands/browse.d.ts.map +1 -1
  2. package/dist/commands/browse.js +27 -1
  3. package/dist/commands/harness.d.ts.map +1 -1
  4. package/dist/commands/harness.js +2 -0
  5. package/dist/commands/work.d.ts +2 -0
  6. package/dist/commands/work.d.ts.map +1 -1
  7. package/dist/commands/work.js +47 -4
  8. package/dist/commands/workflow.d.ts.map +1 -1
  9. package/dist/commands/workflow.js +64 -0
  10. package/dist/core/harnesses/attest-projection.d.ts +44 -0
  11. package/dist/core/harnesses/attest-projection.d.ts.map +1 -0
  12. package/dist/core/harnesses/attest-projection.js +113 -0
  13. package/dist/core/harnesses/attest.d.ts +9 -0
  14. package/dist/core/harnesses/attest.d.ts.map +1 -1
  15. package/dist/core/harnesses/attest.js +14 -1
  16. package/dist/core/harnesses/attestation.d.ts +1 -1
  17. package/dist/core/harnesses/attestation.d.ts.map +1 -1
  18. package/dist/core/harnesses/attestation.js +7 -1
  19. package/dist/core/harnesses/bench.d.ts.map +1 -1
  20. package/dist/core/harnesses/bench.js +13 -0
  21. package/dist/core/harnesses/profiles.d.ts +7 -0
  22. package/dist/core/harnesses/profiles.d.ts.map +1 -1
  23. package/dist/core/harnesses/profiles.js +9 -0
  24. package/dist/core/harnesses/types.d.ts +19 -1
  25. package/dist/core/harnesses/types.d.ts.map +1 -1
  26. package/dist/core/harnesses/types.js +1 -0
  27. package/dist/core/work/index.d.ts +1 -1
  28. package/dist/core/work/index.d.ts.map +1 -1
  29. package/dist/core/work/index.js +1 -1
  30. package/dist/core/work/runner.d.ts.map +1 -1
  31. package/dist/core/work/runner.js +8 -1
  32. package/dist/core/work/state.d.ts +24 -1
  33. package/dist/core/work/state.d.ts.map +1 -1
  34. package/dist/core/work/state.js +108 -14
  35. package/dist/core/workflow/approvals.d.ts.map +1 -1
  36. package/dist/core/workflow/approvals.js +3 -6
  37. package/dist/core/workflow/attempt-context.d.ts.map +1 -1
  38. package/dist/core/workflow/attempt-context.js +38 -1
  39. package/dist/core/workflow/engine.d.ts.map +1 -1
  40. package/dist/core/workflow/engine.js +61 -19
  41. package/dist/core/workflow/index.d.ts +1 -1
  42. package/dist/core/workflow/index.d.ts.map +1 -1
  43. package/dist/core/workflow/index.js +1 -1
  44. package/dist/core/workflow/journal.d.ts +30 -0
  45. package/dist/core/workflow/journal.d.ts.map +1 -0
  46. package/dist/core/workflow/journal.js +70 -0
  47. package/dist/core/workflow/proof.d.ts +3 -1
  48. package/dist/core/workflow/proof.d.ts.map +1 -1
  49. package/dist/core/workflow/proof.js +1 -0
  50. package/dist/core/workflow/sandbox-projection.d.ts +57 -0
  51. package/dist/core/workflow/sandbox-projection.d.ts.map +1 -0
  52. package/dist/core/workflow/sandbox-projection.js +95 -0
  53. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  54. package/dist/core/workflow/spawn-claude.js +14 -1
  55. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  56. package/dist/core/workflow/spawn-codex.js +18 -2
  57. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  58. package/dist/core/workflow/spawn-cursor.js +14 -1
  59. package/dist/core/workflow/types.d.ts +62 -0
  60. package/dist/core/workflow/types.d.ts.map +1 -1
  61. package/dist/core/workflow/workspaces/index.d.ts +2 -0
  62. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  63. package/dist/core/workflow/workspaces/index.js +1 -0
  64. package/dist/core/workflow/workspaces/inspect.d.ts.map +1 -1
  65. package/dist/core/workflow/workspaces/inspect.js +14 -0
  66. package/dist/core/workflow/workspaces/reclaim.d.ts +50 -0
  67. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -0
  68. package/dist/core/workflow/workspaces/reclaim.js +88 -0
  69. package/dist/core/workflow/workspaces/state.d.ts +1 -1
  70. package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
  71. package/dist/core/workflow/workspaces/state.js +9 -30
  72. package/dist/lib/browser/client.d.ts +50 -0
  73. package/dist/lib/browser/client.d.ts.map +1 -1
  74. package/dist/lib/browser/client.js +103 -0
  75. package/dist/lib/exec.d.ts +4 -0
  76. package/dist/lib/exec.d.ts.map +1 -1
  77. package/dist/lib/exec.js +6 -1
  78. package/dist/lib/tunnel/error-page.d.ts +15 -0
  79. package/dist/lib/tunnel/error-page.d.ts.map +1 -0
  80. package/dist/lib/tunnel/error-page.js +98 -0
  81. package/dist/lib/tunnel/gate.d.ts +1 -12
  82. package/dist/lib/tunnel/gate.d.ts.map +1 -1
  83. package/dist/lib/tunnel/gate.js +59 -4
  84. package/package.json +1 -1
  85. package/src/commands/browse.ts +33 -1
  86. package/src/commands/harness.ts +6 -0
  87. package/src/commands/work.ts +80 -13
  88. package/src/commands/workflow.ts +74 -0
  89. package/src/core/harnesses/attest-projection.ts +151 -0
  90. package/src/core/harnesses/attest.ts +23 -1
  91. package/src/core/harnesses/attestation.ts +7 -1
  92. package/src/core/harnesses/bench.ts +16 -0
  93. package/src/core/harnesses/profiles.ts +11 -0
  94. package/src/core/harnesses/types.ts +20 -0
  95. package/src/core/work/index.ts +1 -0
  96. package/src/core/work/runner.ts +8 -0
  97. package/src/core/work/state.ts +177 -27
  98. package/src/core/workflow/approvals.ts +3 -6
  99. package/src/core/workflow/attempt-context.ts +47 -1
  100. package/src/core/workflow/engine.ts +106 -33
  101. package/src/core/workflow/index.ts +1 -0
  102. package/src/core/workflow/journal.ts +90 -0
  103. package/src/core/workflow/proof.ts +4 -0
  104. package/src/core/workflow/sandbox-projection.ts +150 -0
  105. package/src/core/workflow/spawn-claude.ts +18 -1
  106. package/src/core/workflow/spawn-codex.ts +25 -2
  107. package/src/core/workflow/spawn-cursor.ts +18 -1
  108. package/src/core/workflow/types.ts +66 -0
  109. package/src/core/workflow/workspaces/index.ts +2 -0
  110. package/src/core/workflow/workspaces/inspect.ts +15 -0
  111. package/src/core/workflow/workspaces/reclaim.ts +109 -0
  112. package/src/core/workflow/workspaces/state.ts +13 -33
  113. package/src/lib/browser/client.ts +129 -0
  114. package/src/lib/exec.ts +10 -1
  115. package/src/lib/tunnel/error-page.ts +114 -0
  116. 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 EVENT_LIMIT = 32 * 1024;
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 (!event || event.length > 100 || Buffer.byteLength(JSON.stringify(data)) > EVENT_LIMIT / 2) {
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) > EVENT_LIMIT)
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) > EVENT_LIMIT) {
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) > EVENT_LIMIT) {
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) > EVENT_LIMIT) {
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
- const timer = setTimeout(() => proc.kill(), timeout);
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("&", "&amp;")
18
+ .replaceAll("<", "&lt;")
19
+ .replaceAll(">", "&gt;")
20
+ .replaceAll('"', "&quot;")
21
+ .replaceAll("'", "&#039;");
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
+ }
@@ -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: ${ip || "(missing-cf-connecting-ip)"} ${req.method} ${url.pathname}`,
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("403 Forbidden\n", { status: 403 });
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
- const resp = await fetch(UPSTREAM_HTTP + url.pathname + url.search, init);
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");