harnery 0.26.0 → 0.27.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/harness.d.ts.map +1 -1
- package/dist/commands/harness.js +2 -0
- 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 +14 -1
- package/dist/core/harnesses/types.d.ts.map +1 -1
- package/dist/core/harnesses/types.js +1 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +41 -0
- 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/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 +6 -1
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
- package/dist/core/workflow/spawn-codex.js +10 -2
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
- package/dist/core/workflow/spawn-cursor.js +6 -1
- package/dist/core/workflow/types.d.ts +51 -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/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/harness.ts +6 -0
- 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 +15 -0
- package/src/core/workflow/engine.ts +58 -0
- package/src/core/workflow/index.ts +1 -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 +10 -1
- package/src/core/workflow/spawn-codex.ts +17 -2
- package/src/core/workflow/spawn-cursor.ts +10 -1
- package/src/core/workflow/types.ts +54 -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/lib/tunnel/error-page.ts +114 -0
- package/src/lib/tunnel/gate.ts +66 -3
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a workspace stuck at `preserved_dirty` (ADR 0042).
|
|
3
|
+
*
|
|
4
|
+
* When a run ends with uncommitted work, the provider preserves the worktree
|
|
5
|
+
* rather than destroying the only copy of that work. That decision is correct,
|
|
6
|
+
* and until this existed it was also permanent: cleanup re-attempted, found the
|
|
7
|
+
* tree still dirty, preserved again, and incremented a counter. The only exit
|
|
8
|
+
* was to leave Harnery and remove the directory by hand, after which Harnery's
|
|
9
|
+
* records described a workspace that no longer existed.
|
|
10
|
+
*
|
|
11
|
+
* The shape of the fix is deliberate. Neither mode deletes anything directly.
|
|
12
|
+
* Each one makes the working tree *clean* by an explicit, named act, and then
|
|
13
|
+
* hands off to the ordinary cleanup path, which releases a clean workspace as it
|
|
14
|
+
* always has. So reclaim adds no second removal path that could diverge from the
|
|
15
|
+
* audited one, and a force-delete of live work exists nowhere in the codebase.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync } from "node:fs";
|
|
19
|
+
import { git, gitMaybe } from "./git.ts";
|
|
20
|
+
|
|
21
|
+
export type ReclaimMode = "salvage" | "discard";
|
|
22
|
+
|
|
23
|
+
export type ReclaimPreparation =
|
|
24
|
+
| { action: "salvaged"; branch: string; commit: string; detail: string }
|
|
25
|
+
| { action: "discarded"; detail: string }
|
|
26
|
+
| { action: "already_clean"; detail: string }
|
|
27
|
+
| { action: "already_gone"; detail: string };
|
|
28
|
+
|
|
29
|
+
export interface PrepareReclaimInput {
|
|
30
|
+
worktreePath: string;
|
|
31
|
+
mode: ReclaimMode;
|
|
32
|
+
runId: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Bring the worktree to a clean state so ordinary cleanup can release it.
|
|
37
|
+
*
|
|
38
|
+
* Returns `already_gone` rather than throwing when the directory has been
|
|
39
|
+
* removed out from under us. A workspace whose worktree no longer exists has
|
|
40
|
+
* effectively been reclaimed; treating that as an error is what produced an
|
|
41
|
+
* attempt counter that only ever went up.
|
|
42
|
+
*/
|
|
43
|
+
export function prepareReclaim(input: PrepareReclaimInput): ReclaimPreparation {
|
|
44
|
+
if (!existsSync(input.worktreePath)) {
|
|
45
|
+
return {
|
|
46
|
+
action: "already_gone",
|
|
47
|
+
detail: "the worktree directory is already gone; nothing to reclaim",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const status = gitMaybe(input.worktreePath, ["status", "--porcelain"]);
|
|
52
|
+
if (!status.ok) {
|
|
53
|
+
throw new Error(`cannot read worktree status for reclaim: ${status.err || "git failed"}`);
|
|
54
|
+
}
|
|
55
|
+
if (status.out.trim().length === 0) {
|
|
56
|
+
return { action: "already_clean", detail: "the worktree has no uncommitted changes" };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (input.mode === "discard") {
|
|
60
|
+
// Ordered: reset drops tracked modifications, clean removes what reset
|
|
61
|
+
// cannot see. Running clean first would leave staged deletions behind.
|
|
62
|
+
git(input.worktreePath, ["reset", "--hard"]);
|
|
63
|
+
git(input.worktreePath, ["clean", "-fd"]);
|
|
64
|
+
return { action: "discarded", detail: "uncommitted changes were discarded on request" };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Salvage onto a ref of its own, then put the checked-out branch back exactly
|
|
68
|
+
// where it was. Two reasons, both learned by doing it the obvious way first:
|
|
69
|
+
//
|
|
70
|
+
// 1. Cleanup DELETES the provider's workspace branch. Committing the salvage
|
|
71
|
+
// there would have parked the work on a ref that the very next step
|
|
72
|
+
// removes, leaving it unreachable and eventually collectable.
|
|
73
|
+
// 2. Cleanup pins the workspace ref's OID in a frozen intent and refuses when
|
|
74
|
+
// it moves, which is a guard worth keeping. Advancing that branch turned
|
|
75
|
+
// every reclaim into `blocked`.
|
|
76
|
+
//
|
|
77
|
+
// Committing and rewinding satisfies both: the tree ends clean, the workspace
|
|
78
|
+
// ref ends untouched, and the work lives on a ref cleanup has no reason to
|
|
79
|
+
// touch.
|
|
80
|
+
const before = git(input.worktreePath, ["rev-parse", "HEAD"]).trim();
|
|
81
|
+
git(input.worktreePath, ["add", "-A"]);
|
|
82
|
+
git(input.worktreePath, ["commit", "--no-verify", "-m", salvageMessage(input.runId)]);
|
|
83
|
+
const commit = git(input.worktreePath, ["rev-parse", "HEAD"]).trim();
|
|
84
|
+
const branch = salvageBranch(input.runId);
|
|
85
|
+
git(input.worktreePath, ["branch", "--force", branch, commit]);
|
|
86
|
+
git(input.worktreePath, ["reset", "--hard", before]);
|
|
87
|
+
return {
|
|
88
|
+
action: "salvaged",
|
|
89
|
+
branch,
|
|
90
|
+
commit,
|
|
91
|
+
detail: `salvaged to ${branch} at ${commit.slice(0, 12)}`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* `--no-verify` above is not a shortcut. A salvage commit is an archival act on
|
|
97
|
+
* an abandoned workspace, so a repository hook that rejects work in progress
|
|
98
|
+
* would convert "preserve the work" into "cannot preserve the work", which is
|
|
99
|
+
* the failure this whole path exists to prevent.
|
|
100
|
+
*/
|
|
101
|
+
function salvageMessage(runId: string): string {
|
|
102
|
+
return `chore(workspace): salvage uncommitted work from run ${runId}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Named for the run rather than the workspace, because the run id is what an
|
|
106
|
+
* operator has in hand when they come looking for the work later. */
|
|
107
|
+
export function salvageBranch(runId: string): string {
|
|
108
|
+
return `harnery/salvage/${runId}`;
|
|
109
|
+
}
|
|
@@ -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");
|