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
|
@@ -155,6 +155,109 @@ export class Browser {
|
|
|
155
155
|
const buf = await page.screenshot({ path, fullPage: opts.fullPage ?? true, type: "png" });
|
|
156
156
|
return buf.length;
|
|
157
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* Capture a full-page screenshot from an explicit capture viewport and
|
|
160
|
+
* evaluate the caller's final evidence expression immediately before the
|
|
161
|
+
* pixels are written. Playwright normally manages the full-page viewport
|
|
162
|
+
* internally, which leaves callers unable to inspect fixed/sticky geometry
|
|
163
|
+
* in the state the PNG actually renders.
|
|
164
|
+
*/
|
|
165
|
+
async screenshotWithEvaluation(path, evaluation, opts = {}) {
|
|
166
|
+
const page = this.currentPage;
|
|
167
|
+
const fullPage = opts.fullPage ?? true;
|
|
168
|
+
const maxPasses = 4;
|
|
169
|
+
const maxDimension = 32_000;
|
|
170
|
+
const maxPixels = 128_000_000;
|
|
171
|
+
const documentExtent = () => page.evaluate(() => ({
|
|
172
|
+
width: Math.max(document.documentElement.scrollWidth, document.body?.scrollWidth ?? 0),
|
|
173
|
+
height: Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0),
|
|
174
|
+
}));
|
|
175
|
+
const originalViewport = page.viewportSize();
|
|
176
|
+
if (!originalViewport) {
|
|
177
|
+
throw new Error("Capture-state evaluation requires a page with an explicit viewport.");
|
|
178
|
+
}
|
|
179
|
+
let captureViewport = originalViewport;
|
|
180
|
+
let passes = fullPage ? 0 : 1;
|
|
181
|
+
let extentBeforeEvaluation = await documentExtent();
|
|
182
|
+
let bounded = true;
|
|
183
|
+
if (fullPage) {
|
|
184
|
+
for (let pass = 0; pass < maxPasses; pass += 1) {
|
|
185
|
+
passes = pass + 1;
|
|
186
|
+
const nextViewport = {
|
|
187
|
+
width: originalViewport.width,
|
|
188
|
+
height: Math.max(originalViewport.height, Math.ceil(extentBeforeEvaluation.height)),
|
|
189
|
+
};
|
|
190
|
+
bounded =
|
|
191
|
+
nextViewport.width <= maxDimension &&
|
|
192
|
+
nextViewport.height <= maxDimension &&
|
|
193
|
+
nextViewport.width * nextViewport.height <= maxPixels;
|
|
194
|
+
if (!bounded)
|
|
195
|
+
break;
|
|
196
|
+
if (nextViewport.width === captureViewport.width &&
|
|
197
|
+
nextViewport.height === captureViewport.height &&
|
|
198
|
+
extentBeforeEvaluation.width <= captureViewport.width &&
|
|
199
|
+
extentBeforeEvaluation.height === captureViewport.height) {
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
captureViewport = nextViewport;
|
|
203
|
+
await page.setViewportSize(captureViewport);
|
|
204
|
+
extentBeforeEvaluation = await documentExtent();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
const evaluationResult = await page.evaluate(evaluation);
|
|
209
|
+
const extentAfterEvaluation = await documentExtent();
|
|
210
|
+
const buf = await page.screenshot({ path, fullPage: fullPage && bounded, type: "png" });
|
|
211
|
+
const extentAfterScreenshot = await documentExtent();
|
|
212
|
+
const screenshot = {
|
|
213
|
+
width: buf.readUInt32BE(16),
|
|
214
|
+
height: buf.readUInt32BE(20),
|
|
215
|
+
bytes: buf.length,
|
|
216
|
+
};
|
|
217
|
+
const exact = fullPage
|
|
218
|
+
? bounded &&
|
|
219
|
+
captureViewport.width === extentBeforeEvaluation.width &&
|
|
220
|
+
captureViewport.height === extentBeforeEvaluation.height &&
|
|
221
|
+
captureViewport.width === extentAfterEvaluation.width &&
|
|
222
|
+
captureViewport.height === extentAfterEvaluation.height &&
|
|
223
|
+
captureViewport.width === extentAfterScreenshot.width &&
|
|
224
|
+
captureViewport.height === extentAfterScreenshot.height &&
|
|
225
|
+
captureViewport.width === screenshot.width &&
|
|
226
|
+
captureViewport.height === screenshot.height
|
|
227
|
+
: captureViewport.width === screenshot.width &&
|
|
228
|
+
captureViewport.height === screenshot.height;
|
|
229
|
+
const reason = !bounded
|
|
230
|
+
? "capture_bounds_exceeded"
|
|
231
|
+
: exact
|
|
232
|
+
? "capture_viewport_converged"
|
|
233
|
+
: "capture_viewport_non_convergent";
|
|
234
|
+
return {
|
|
235
|
+
bytes: buf.length,
|
|
236
|
+
evaluation: evaluationResult,
|
|
237
|
+
viewport: captureViewport,
|
|
238
|
+
evidence: {
|
|
239
|
+
converged: exact,
|
|
240
|
+
reason,
|
|
241
|
+
passes,
|
|
242
|
+
max_passes: maxPasses,
|
|
243
|
+
max_dimension: maxDimension,
|
|
244
|
+
max_pixels: maxPixels,
|
|
245
|
+
original_viewport: originalViewport,
|
|
246
|
+
evaluated_viewport: captureViewport,
|
|
247
|
+
document_extent_before_evaluation: extentBeforeEvaluation,
|
|
248
|
+
document_extent_after_evaluation: extentAfterEvaluation,
|
|
249
|
+
document_extent_after_screenshot: extentAfterScreenshot,
|
|
250
|
+
screenshot,
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
if (captureViewport.width !== originalViewport.width ||
|
|
256
|
+
captureViewport.height !== originalViewport.height) {
|
|
257
|
+
await page.setViewportSize(originalViewport);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
158
261
|
/**
|
|
159
262
|
* Plain-text snapshot of the document body. Suitable as a coarse "what's
|
|
160
263
|
* on screen" signal for LLM iteration loops. For richer extraction, use
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -10,6 +10,10 @@ export interface ExecResult {
|
|
|
10
10
|
stdout: string;
|
|
11
11
|
stderr: string;
|
|
12
12
|
exitCode: number;
|
|
13
|
+
/** True when this process killed the child for exceeding `opts.timeout`. A
|
|
14
|
+
* child that handles the signal cleanly still exits 0, so the exit code alone
|
|
15
|
+
* cannot distinguish a kill from an ordinary finish. */
|
|
16
|
+
timedOut?: boolean;
|
|
13
17
|
}
|
|
14
18
|
export interface ExecOpts {
|
|
15
19
|
cwd?: string;
|
package/dist/lib/exec.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"exec.d.ts","sourceRoot":"","sources":["../../src/lib/exec.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"exec.d.ts","sourceRoot":"","sources":["../../src/lib/exec.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAIH,MAAM,WAAW,UAAU;IACzB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB;;4DAEwD;IACxD,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,QAAQ;IACvB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,IAAI,CAAC,EAAE,OAAO,CAAC;CAChB;AAED;;;;;;;;GAQG;AACH,wBAAgB,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,IAAI,GAAE,QAAa,GAAG,OAAO,CAAC,UAAU,CAAC,CAwC5E;AAED,sEAAsE;AACtE,wBAAsB,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,IAAI,CAAC,QAAQ,EAAE,MAAM,CAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAEhG"}
|
package/dist/lib/exec.js
CHANGED
|
@@ -29,7 +29,11 @@ export function exec(cmd, opts = {}) {
|
|
|
29
29
|
proc.stdout?.on("data", (d) => out.push(d));
|
|
30
30
|
proc.stderr?.on("data", (d) => err.push(d));
|
|
31
31
|
const timeout = opts.timeout ?? 30_000;
|
|
32
|
-
|
|
32
|
+
let timedOut = false;
|
|
33
|
+
const timer = setTimeout(() => {
|
|
34
|
+
timedOut = true;
|
|
35
|
+
proc.kill();
|
|
36
|
+
}, timeout);
|
|
33
37
|
const finish = (exitCode, errOverride) => {
|
|
34
38
|
clearTimeout(timer);
|
|
35
39
|
const stdout = Buffer.concat(out).toString("utf-8");
|
|
@@ -39,6 +43,7 @@ export function exec(cmd, opts = {}) {
|
|
|
39
43
|
stdout: shouldTrim ? stdout.trim() : stdout.replace(/\n$/, ""),
|
|
40
44
|
stderr: shouldTrim ? stderr.trim() : stderr.replace(/\n$/, ""),
|
|
41
45
|
exitCode,
|
|
46
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
42
47
|
});
|
|
43
48
|
};
|
|
44
49
|
// ENOENT (binary not found) and similar spawn failures surface here rather
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
export declare function renderTunnelErrorPage(options: TunnelErrorPageOptions): string;
|
|
15
|
+
//# sourceMappingURL=error-page.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"error-page.d.ts","sourceRoot":"","sources":["../../../src/lib/tunnel/error-page.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,eAAe,GAAG,sBAAsB,CAAC;IAC/C,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AA6BD,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,MAAM,CAwE7E"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
function escapeHtml(value) {
|
|
2
|
+
return value
|
|
3
|
+
.replaceAll("&", "&")
|
|
4
|
+
.replaceAll("<", "<")
|
|
5
|
+
.replaceAll(">", ">")
|
|
6
|
+
.replaceAll('"', """)
|
|
7
|
+
.replaceAll("'", "'");
|
|
8
|
+
}
|
|
9
|
+
function diagnosticText(options) {
|
|
10
|
+
const lines = [
|
|
11
|
+
`Tunnel incident: ${options.incidentId}`,
|
|
12
|
+
`Time: ${options.timestamp}`,
|
|
13
|
+
`Problem: ${options.kind}`,
|
|
14
|
+
`Tunnel: ${options.tunnelName}`,
|
|
15
|
+
`Request: ${options.method} ${options.path}`,
|
|
16
|
+
`Client IP: ${options.clientIp || "(not provided by proxy)"}`,
|
|
17
|
+
`Cloudflare Ray: ${options.cloudflareRay || "(not provided)"}`,
|
|
18
|
+
];
|
|
19
|
+
if (options.target)
|
|
20
|
+
lines.push(`Upstream: ${options.target}`);
|
|
21
|
+
if (options.errorCode)
|
|
22
|
+
lines.push(`Error code: ${options.errorCode}`);
|
|
23
|
+
if (options.errorMessage)
|
|
24
|
+
lines.push(`Error: ${options.errorMessage}`);
|
|
25
|
+
return lines.join("\n");
|
|
26
|
+
}
|
|
27
|
+
export function renderTunnelErrorPage(options) {
|
|
28
|
+
const denied = options.kind === "access-denied";
|
|
29
|
+
const title = denied ? "This device is not allowed yet" : "The preview is temporarily offline";
|
|
30
|
+
const summary = denied
|
|
31
|
+
? "The tunnel is running, but this device's public IP is not on its access list."
|
|
32
|
+
: "The public tunnel and access check are working, but the local preview server is not responding.";
|
|
33
|
+
const nextStep = denied
|
|
34
|
+
? "Copy the diagnostic below and send it to the person running the tunnel. It includes the public IP that needs to be allowed."
|
|
35
|
+
: "Copy the diagnostic below and send it to the person running the tunnel. The incident ID can be matched to the server log.";
|
|
36
|
+
const diagnostic = diagnosticText(options);
|
|
37
|
+
const safeDiagnosticForScript = JSON.stringify(diagnostic).replaceAll("<", "\\u003c");
|
|
38
|
+
return `<!doctype html>
|
|
39
|
+
<html lang="en">
|
|
40
|
+
<head>
|
|
41
|
+
<meta charset="utf-8">
|
|
42
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
43
|
+
<meta name="robots" content="noindex,nofollow">
|
|
44
|
+
<title>${escapeHtml(title)}</title>
|
|
45
|
+
<style>
|
|
46
|
+
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
47
|
+
* { box-sizing: border-box; }
|
|
48
|
+
body { margin: 0; min-height: 100vh; background: #f4f2ed; color: #17211c; display: grid; place-items: center; padding: 24px; }
|
|
49
|
+
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; }
|
|
50
|
+
.bar { height: 8px; background: ${denied ? "#b54708" : "#b42318"}; }
|
|
51
|
+
.content { padding: clamp(24px, 5vw, 48px); }
|
|
52
|
+
.eyebrow { margin: 0 0 10px; color: ${denied ? "#8a3708" : "#912018"}; font-size: 13px; font-weight: 750; letter-spacing: .09em; text-transform: uppercase; }
|
|
53
|
+
h1 { margin: 0; max-width: 18ch; font-size: clamp(30px, 7vw, 48px); line-height: 1.02; letter-spacing: -.04em; }
|
|
54
|
+
.summary { margin: 20px 0 10px; max-width: 58ch; font-size: 18px; line-height: 1.55; }
|
|
55
|
+
.next { margin: 0 0 24px; max-width: 62ch; color: #536058; line-height: 1.55; }
|
|
56
|
+
.diagnostic-wrap { position: relative; }
|
|
57
|
+
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; }
|
|
58
|
+
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; }
|
|
59
|
+
button:hover { background: #0e5230; }
|
|
60
|
+
button:focus-visible { outline: 3px solid #87c89f; outline-offset: 3px; }
|
|
61
|
+
.incident { margin: 18px 0 0; color: #68736c; font-size: 13px; text-align: center; }
|
|
62
|
+
</style>
|
|
63
|
+
</head>
|
|
64
|
+
<body>
|
|
65
|
+
<main>
|
|
66
|
+
<div class="bar"></div>
|
|
67
|
+
<div class="content">
|
|
68
|
+
<p class="eyebrow">Tunnel diagnostic</p>
|
|
69
|
+
<h1>${escapeHtml(title)}</h1>
|
|
70
|
+
<p class="summary">${escapeHtml(summary)}</p>
|
|
71
|
+
<p class="next">${escapeHtml(nextStep)}</p>
|
|
72
|
+
<div class="diagnostic-wrap">
|
|
73
|
+
<pre id="diagnostic">${escapeHtml(diagnostic)}</pre>
|
|
74
|
+
<button id="copy" type="button">Copy diagnostic</button>
|
|
75
|
+
</div>
|
|
76
|
+
<p class="incident">Incident ${escapeHtml(options.incidentId)}</p>
|
|
77
|
+
</div>
|
|
78
|
+
</main>
|
|
79
|
+
<script>
|
|
80
|
+
const diagnostic = ${safeDiagnosticForScript};
|
|
81
|
+
const button = document.getElementById("copy");
|
|
82
|
+
button.addEventListener("click", async () => {
|
|
83
|
+
try {
|
|
84
|
+
await navigator.clipboard.writeText(diagnostic);
|
|
85
|
+
button.textContent = "Copied";
|
|
86
|
+
} catch {
|
|
87
|
+
const range = document.createRange();
|
|
88
|
+
range.selectNodeContents(document.getElementById("diagnostic"));
|
|
89
|
+
const selection = window.getSelection();
|
|
90
|
+
selection.removeAllRanges();
|
|
91
|
+
selection.addRange(range);
|
|
92
|
+
button.textContent = "Selected — copy now";
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
</script>
|
|
96
|
+
</body>
|
|
97
|
+
</html>`;
|
|
98
|
+
}
|
|
@@ -1,13 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
declare const ALLOW: Set<string>;
|
|
3
|
-
declare const TARGET: string;
|
|
4
|
-
declare const VHOST: string;
|
|
5
|
-
declare const PORT: number;
|
|
6
|
-
declare const ACCESS: string;
|
|
7
|
-
declare const UPSTREAM_HTTP: string;
|
|
8
|
-
declare const UPSTREAM_WS: string;
|
|
9
|
-
interface WsData {
|
|
10
|
-
path: string;
|
|
11
|
-
}
|
|
12
|
-
declare const server: Bun.Server<WsData>;
|
|
1
|
+
export {};
|
|
13
2
|
//# sourceMappingURL=gate.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../../../src/lib/tunnel/gate.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../../../src/lib/tunnel/gate.ts"],"names":[],"mappings":""}
|
package/dist/lib/tunnel/gate.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
"use strict";
|
|
2
1
|
// Reverse-proxy worker spawned by `tunnel up`. Listens on 127.0.0.1:<port>
|
|
3
2
|
// and forwards HTTP + WebSocket requests to an upstream with a Host header
|
|
4
3
|
// rewrite and Content-Encoding stripped (Bun's fetch auto-decompresses the
|
|
@@ -9,6 +8,7 @@
|
|
|
9
8
|
// Runs detached, outside the CLI command framework; no command context is
|
|
10
9
|
// available; stdout/stderr is captured into .cache/tunnel/gate.log by the
|
|
11
10
|
// spawner.
|
|
11
|
+
import { renderTunnelErrorPage } from "./error-page";
|
|
12
12
|
// `--port`/`--name` are also passed on argv (not just env) so the gate's port
|
|
13
13
|
// and instance name show up in its process command line. That's what lets
|
|
14
14
|
// `tunnel down` scope its stray-process sweep to a single instance via
|
|
@@ -27,8 +27,27 @@ const TARGET = process.env.HARNERY_TUNNEL_TARGET ?? "127.0.0.1:8001";
|
|
|
27
27
|
const VHOST = process.env.HARNERY_TUNNEL_VHOST ?? "localhost";
|
|
28
28
|
const PORT = Number(process.env.HARNERY_TUNNEL_PORT ?? argvFlag("--port") ?? "9001");
|
|
29
29
|
const ACCESS = process.env.HARNERY_TUNNEL_ACCESS ?? "cloudflare-allowlist";
|
|
30
|
+
const NAME = argvFlag("--name") ?? "default";
|
|
30
31
|
const UPSTREAM_HTTP = `http://${TARGET}`;
|
|
31
32
|
const UPSTREAM_WS = `ws://${TARGET}`;
|
|
33
|
+
function requestDetails(req, url) {
|
|
34
|
+
return {
|
|
35
|
+
incidentId: crypto.randomUUID().slice(0, 8),
|
|
36
|
+
timestamp: new Date().toISOString(),
|
|
37
|
+
tunnelName: NAME,
|
|
38
|
+
method: req.method,
|
|
39
|
+
path: url.pathname + url.search,
|
|
40
|
+
clientIp: req.headers.get("cf-connecting-ip") ?? "",
|
|
41
|
+
cloudflareRay: req.headers.get("cf-ray") ?? "",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function errorDetails(error) {
|
|
45
|
+
if (!(error instanceof Error)) {
|
|
46
|
+
return { code: "UnknownError", message: String(error) };
|
|
47
|
+
}
|
|
48
|
+
const code = "code" in error && typeof error.code === "string" ? error.code : error.name || "Error";
|
|
49
|
+
return { code, message: error.message || "The upstream request failed." };
|
|
50
|
+
}
|
|
32
51
|
const server = Bun.serve({
|
|
33
52
|
port: PORT,
|
|
34
53
|
hostname: "127.0.0.1",
|
|
@@ -42,12 +61,20 @@ const server = Bun.serve({
|
|
|
42
61
|
if (ACCESS === "cloudflare-allowlist") {
|
|
43
62
|
const ip = req.headers.get("cf-connecting-ip") ?? "";
|
|
44
63
|
if (!ALLOW.has(ip)) {
|
|
64
|
+
const details = requestDetails(req, url);
|
|
45
65
|
// Log denials so operators can whitelist a phone/laptop that just
|
|
46
66
|
// hit 403 without asking the human to dig up their public IP.
|
|
47
67
|
console.log(
|
|
48
68
|
// lint-ok-emission: detached worker, see file note above
|
|
49
|
-
`deny:
|
|
50
|
-
return new Response(
|
|
69
|
+
`deny: incident=${details.incidentId} ip=${ip || "(missing-cf-connecting-ip)"} ray=${details.cloudflareRay || "(missing)"} ${req.method} ${details.path}`);
|
|
70
|
+
return new Response(renderTunnelErrorPage({ ...details, kind: "access-denied" }), {
|
|
71
|
+
status: 403,
|
|
72
|
+
headers: {
|
|
73
|
+
"cache-control": "no-store",
|
|
74
|
+
"content-type": "text/html; charset=utf-8",
|
|
75
|
+
"x-harnery-tunnel-incident": details.incidentId,
|
|
76
|
+
},
|
|
77
|
+
});
|
|
51
78
|
}
|
|
52
79
|
}
|
|
53
80
|
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
@@ -68,7 +95,35 @@ const server = Bun.serve({
|
|
|
68
95
|
if (!["GET", "HEAD"].includes(req.method)) {
|
|
69
96
|
init.body = await req.arrayBuffer();
|
|
70
97
|
}
|
|
71
|
-
|
|
98
|
+
let resp;
|
|
99
|
+
try {
|
|
100
|
+
resp = await fetch(UPSTREAM_HTTP + url.pathname + url.search, init);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
const details = requestDetails(req, url);
|
|
104
|
+
const failure = errorDetails(error);
|
|
105
|
+
console.log(
|
|
106
|
+
// lint-ok-emission: detached worker, see file note above
|
|
107
|
+
`upstream-error: incident=${details.incidentId} ip=${details.clientIp || "(missing-cf-connecting-ip)"} ray=${details.cloudflareRay || "(missing)"} code=${failure.code} ${req.method} ${details.path}`);
|
|
108
|
+
return new Response(renderTunnelErrorPage({
|
|
109
|
+
...details,
|
|
110
|
+
kind: "upstream-unavailable",
|
|
111
|
+
target: UPSTREAM_HTTP,
|
|
112
|
+
errorCode: failure.code,
|
|
113
|
+
errorMessage: failure.message,
|
|
114
|
+
}), {
|
|
115
|
+
// Cloudflare replaces 502 response bodies with its own generic error
|
|
116
|
+
// page. Return the diagnostic as a successful document and preserve
|
|
117
|
+
// the semantic status in a header so the human can actually see it.
|
|
118
|
+
status: 200,
|
|
119
|
+
headers: {
|
|
120
|
+
"cache-control": "no-store",
|
|
121
|
+
"content-type": "text/html; charset=utf-8",
|
|
122
|
+
"x-harnery-tunnel-incident": details.incidentId,
|
|
123
|
+
"x-harnery-tunnel-status": "502",
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
}
|
|
72
127
|
const respHeaders = new Headers(resp.headers);
|
|
73
128
|
respHeaders.delete("content-encoding");
|
|
74
129
|
respHeaders.delete("content-length");
|
package/package.json
CHANGED
package/src/commands/browse.ts
CHANGED
|
@@ -67,6 +67,7 @@ interface BrowseOpts {
|
|
|
67
67
|
press?: string;
|
|
68
68
|
waitFor?: string;
|
|
69
69
|
evaluate?: string;
|
|
70
|
+
captureEvaluate?: string;
|
|
70
71
|
batch?: string;
|
|
71
72
|
networkHar?: string;
|
|
72
73
|
login?: boolean;
|
|
@@ -179,6 +180,10 @@ export function registerBrowseCommand(
|
|
|
179
180
|
"--evaluate <js>",
|
|
180
181
|
"Run JS in the page context after navigation; result printed to stdout",
|
|
181
182
|
)
|
|
183
|
+
.option(
|
|
184
|
+
"--capture-evaluate <js>",
|
|
185
|
+
"In trio mode, evaluate JS inside the exact screenshot viewport immediately before capture; result is written as captureEval",
|
|
186
|
+
)
|
|
182
187
|
.option(
|
|
183
188
|
"--batch <steps>",
|
|
184
189
|
"Run multiple steps in one session, semicolon-separated. Each step is one of: " +
|
|
@@ -601,6 +606,11 @@ async function runBrowse(
|
|
|
601
606
|
}
|
|
602
607
|
|
|
603
608
|
if (printMode) {
|
|
609
|
+
if (opts.captureEvaluate) {
|
|
610
|
+
throw new Error(
|
|
611
|
+
"--capture-evaluate requires trio mode so its result can be bound to a screenshot.",
|
|
612
|
+
);
|
|
613
|
+
}
|
|
604
614
|
if (opts.baseline || opts.diff) {
|
|
605
615
|
throw new Error(
|
|
606
616
|
"--baseline / --diff require trio mode (screenshot file). Remove --snapshot/--html/--json or capture the screenshot first.",
|
|
@@ -911,10 +921,27 @@ async function runTrioMode(
|
|
|
911
921
|
const skipScreenshot = opts.screenshot === false || opts.domOnly === true;
|
|
912
922
|
let pngPath: string | undefined;
|
|
913
923
|
let pngBytes: number | undefined;
|
|
924
|
+
let captureEvalResult: unknown;
|
|
925
|
+
let captureViewport: { width: number; height: number } | undefined;
|
|
926
|
+
let captureEvidence: unknown;
|
|
914
927
|
if (!skipScreenshot) {
|
|
915
928
|
pngPath = `${prefix}.png`;
|
|
916
|
-
|
|
929
|
+
if (opts.captureEvaluate) {
|
|
930
|
+
const captured = await browser.screenshotWithEvaluation(pngPath, opts.captureEvaluate, {
|
|
931
|
+
fullPage: opts.fullPage !== false,
|
|
932
|
+
});
|
|
933
|
+
pngBytes = captured.bytes;
|
|
934
|
+
captureEvalResult = captured.evaluation;
|
|
935
|
+
captureViewport = captured.viewport;
|
|
936
|
+
captureEvidence = captured.evidence;
|
|
937
|
+
} else {
|
|
938
|
+
pngBytes = await browser.screenshot(pngPath, { fullPage: opts.fullPage !== false });
|
|
939
|
+
}
|
|
917
940
|
written.push(pngPath);
|
|
941
|
+
} else if (opts.captureEvaluate) {
|
|
942
|
+
throw new Error(
|
|
943
|
+
"--capture-evaluate requires a screenshot; remove --no-screenshot / --dom-only.",
|
|
944
|
+
);
|
|
918
945
|
}
|
|
919
946
|
|
|
920
947
|
const htmlPath = `${prefix}.html`;
|
|
@@ -937,6 +964,11 @@ async function runTrioMode(
|
|
|
937
964
|
};
|
|
938
965
|
if (pngBytes !== undefined) envelope.screenshotBytes = pngBytes;
|
|
939
966
|
if (opts.evaluate) envelope.eval = evalResult;
|
|
967
|
+
if (opts.captureEvaluate) {
|
|
968
|
+
envelope.captureEval = captureEvalResult;
|
|
969
|
+
envelope.captureViewport = captureViewport;
|
|
970
|
+
envelope.captureEvidence = captureEvidence;
|
|
971
|
+
}
|
|
940
972
|
if (opts.networkHar) envelope.har = resolve(opts.networkHar);
|
|
941
973
|
if (visibility) envelope.visibility = visibility;
|
|
942
974
|
if (widths) envelope.width = widths;
|
package/src/commands/harness.ts
CHANGED
|
@@ -24,6 +24,7 @@ interface AttestOpts extends FormatOpts {
|
|
|
24
24
|
timeout?: string;
|
|
25
25
|
yes?: boolean;
|
|
26
26
|
subscriptionOnly?: boolean;
|
|
27
|
+
projection?: boolean;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
const registry = createBuiltinHarnessRegistry();
|
|
@@ -101,6 +102,10 @@ export function registerHarnessCommand(program: Command, emit: EmitContext): voi
|
|
|
101
102
|
"Scrub API-key vars so the child uses its stored login (repo default via config.jsonc workflow.subscriptionOnly)",
|
|
102
103
|
)
|
|
103
104
|
.option("--timeout <ms>", "Per-harness probe timeout in milliseconds")
|
|
105
|
+
.option(
|
|
106
|
+
"--projection",
|
|
107
|
+
"Also probe whether a declared sandbox is enforced; costs two extra turns per capable harness",
|
|
108
|
+
)
|
|
104
109
|
.option("--json", "Machine-readable attestation report")
|
|
105
110
|
.action(async (harnesses: string[], opts: AttestOpts) => {
|
|
106
111
|
if (!opts.yes) {
|
|
@@ -127,6 +132,7 @@ export function registerHarnessCommand(program: Command, emit: EmitContext): voi
|
|
|
127
132
|
harnesses,
|
|
128
133
|
timeoutMs,
|
|
129
134
|
subscriptionOnly,
|
|
135
|
+
projection: opts.projection === true,
|
|
130
136
|
});
|
|
131
137
|
if (opts.json) {
|
|
132
138
|
emit.config({ format: "json" });
|
package/src/commands/work.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
reconcileWorkItem,
|
|
20
20
|
reopenWorkItem,
|
|
21
21
|
runWorkItem,
|
|
22
|
+
type WorkAttempt,
|
|
22
23
|
type WorkRecord,
|
|
23
24
|
} from "../core/work/index.ts";
|
|
24
25
|
import { WorkflowParkedError } from "../core/workflow/index.ts";
|
|
@@ -52,8 +53,12 @@ interface GovernanceOpts {
|
|
|
52
53
|
actor?: string;
|
|
53
54
|
reason?: string;
|
|
54
55
|
json?: boolean;
|
|
56
|
+
finding?: string[];
|
|
57
|
+
dispose?: string[];
|
|
55
58
|
}
|
|
56
59
|
|
|
60
|
+
const MAX_ATTEMPT_JOURNAL_ERROR_RENDER = 240;
|
|
61
|
+
|
|
57
62
|
export function registerWorkCommand(program: Command, emit: EmitContext): void {
|
|
58
63
|
const registry = createBuiltinHarnessRegistry();
|
|
59
64
|
const harnesses = registry.ids();
|
|
@@ -257,14 +262,66 @@ function registerGovernanceCommand(
|
|
|
257
262
|
)
|
|
258
263
|
.option("--actor <name>", "Actor recorded in the governance receipt")
|
|
259
264
|
.option("--reason <text>", "Bounded decision reason")
|
|
260
|
-
.option("--json", "Emit the complete work record as JSON")
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
265
|
+
.option("--json", "Emit the complete work record as JSON");
|
|
266
|
+
const command = work.commands.at(-1) as Command;
|
|
267
|
+
if (name === "reopen") {
|
|
268
|
+
command.option(
|
|
269
|
+
"--finding <text>",
|
|
270
|
+
"Correction the next attempt must address (repeatable)",
|
|
271
|
+
collectValue,
|
|
272
|
+
[] as string[],
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (name === "accept") {
|
|
276
|
+
command.option(
|
|
277
|
+
"--dispose <id=outcome[:reason]>",
|
|
278
|
+
"Dispose of an open finding: fixed, or deferred:<reason> (repeatable)",
|
|
279
|
+
collectValue,
|
|
280
|
+
[] as string[],
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
command.action((workId: string, opts: GovernanceOpts) => {
|
|
284
|
+
withWorkRoot(emit, (coordRoot) => {
|
|
285
|
+
const fn =
|
|
286
|
+
name === "accept" ? acceptWorkItem : name === "cancel" ? cancelWorkItem : reopenWorkItem;
|
|
287
|
+
emitWork(
|
|
288
|
+
fn(coordRoot, workId, {
|
|
289
|
+
...opts,
|
|
290
|
+
...(opts.finding?.length ? { findings: opts.finding } : {}),
|
|
291
|
+
...(opts.dispose?.length ? { dispositions: opts.dispose.map(parseDisposition) } : {}),
|
|
292
|
+
}),
|
|
293
|
+
opts.json,
|
|
294
|
+
emit,
|
|
295
|
+
true,
|
|
296
|
+
);
|
|
267
297
|
});
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function collectValue(value: string, previous: string[]): string[] {
|
|
302
|
+
return [...previous, value];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** `<id>=fixed` or `<id>=deferred:<reason>`. Deferring without a reason is
|
|
306
|
+
* rejected downstream, so a lapsed finding cannot look like a decision. */
|
|
307
|
+
function parseDisposition(input: string): {
|
|
308
|
+
id: string;
|
|
309
|
+
outcome: "fixed" | "deferred";
|
|
310
|
+
reason?: string;
|
|
311
|
+
} {
|
|
312
|
+
const split = input.indexOf("=");
|
|
313
|
+
if (split <= 0)
|
|
314
|
+
throw new Error(`invalid --dispose ${JSON.stringify(input)}; expected <id>=<outcome>`);
|
|
315
|
+
const id = input.slice(0, split).trim();
|
|
316
|
+
const rest = input.slice(split + 1).trim();
|
|
317
|
+
const [outcome, ...reasonParts] = rest.split(":");
|
|
318
|
+
if (outcome !== "fixed" && outcome !== "deferred") {
|
|
319
|
+
throw new Error(
|
|
320
|
+
`invalid --dispose outcome ${JSON.stringify(outcome)}; expected fixed or deferred`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
const reason = reasonParts.join(":").trim();
|
|
324
|
+
return { id, outcome, ...(reason ? { reason } : {}) };
|
|
268
325
|
}
|
|
269
326
|
|
|
270
327
|
function emitWork(
|
|
@@ -297,18 +354,28 @@ function emitWork(
|
|
|
297
354
|
lines.push("acceptance:", ...record.intent.acceptance.map((item) => ` - ${item}`));
|
|
298
355
|
}
|
|
299
356
|
if (projection.attempts.length) {
|
|
300
|
-
lines.push(
|
|
301
|
-
"attempts:",
|
|
302
|
-
...projection.attempts.map(
|
|
303
|
-
(attempt) => ` ${attempt.number}. ${attempt.run_id} ${attempt.status}`,
|
|
304
|
-
),
|
|
305
|
-
);
|
|
357
|
+
lines.push("attempts:", ...projection.attempts.map(renderAttemptRow));
|
|
306
358
|
}
|
|
307
359
|
if (projection.approval_id) lines.push(`approval: ${projection.approval_id}`);
|
|
308
360
|
if (projection.proof_path) lines.push(`proof: ${projection.proof_path}`);
|
|
309
361
|
emit.text(`${lines.join("\n")}\n`);
|
|
310
362
|
}
|
|
311
363
|
|
|
364
|
+
export function renderAttemptRow(attempt: WorkAttempt): string {
|
|
365
|
+
const statusDetail =
|
|
366
|
+
attempt.status === "journal_unreadable"
|
|
367
|
+
? `: ${renderAttemptJournalError(attempt.journal_error)}`
|
|
368
|
+
: "";
|
|
369
|
+
return ` ${attempt.number}. ${attempt.run_id} ${attempt.status}${statusDetail}`;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function renderAttemptJournalError(error: string | undefined): string {
|
|
373
|
+
const normalized = (error ?? "").replace(/\s+/g, " ").trim();
|
|
374
|
+
const value = normalized || "unknown journal read error";
|
|
375
|
+
if (value.length <= MAX_ATTEMPT_JOURNAL_ERROR_RENDER) return value;
|
|
376
|
+
return `${value.slice(0, MAX_ATTEMPT_JOURNAL_ERROR_RENDER - 3).trimEnd()}...`;
|
|
377
|
+
}
|
|
378
|
+
|
|
312
379
|
function renderWorkRow(record: WorkRecord): string {
|
|
313
380
|
const projection = record.projection;
|
|
314
381
|
return `${record.intent.id} ${projection.state.padEnd(17)} ${projection.attempts_used}/${record.intent.max_attempts} ${projection.next_action.padEnd(21)} ${record.intent.title}`;
|