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.
Files changed (76) hide show
  1. package/dist/commands/harness.d.ts.map +1 -1
  2. package/dist/commands/harness.js +2 -0
  3. package/dist/commands/workflow.d.ts.map +1 -1
  4. package/dist/commands/workflow.js +64 -0
  5. package/dist/core/harnesses/attest-projection.d.ts +44 -0
  6. package/dist/core/harnesses/attest-projection.d.ts.map +1 -0
  7. package/dist/core/harnesses/attest-projection.js +113 -0
  8. package/dist/core/harnesses/attest.d.ts +9 -0
  9. package/dist/core/harnesses/attest.d.ts.map +1 -1
  10. package/dist/core/harnesses/attest.js +14 -1
  11. package/dist/core/harnesses/attestation.d.ts +1 -1
  12. package/dist/core/harnesses/attestation.d.ts.map +1 -1
  13. package/dist/core/harnesses/attestation.js +7 -1
  14. package/dist/core/harnesses/bench.d.ts.map +1 -1
  15. package/dist/core/harnesses/bench.js +13 -0
  16. package/dist/core/harnesses/profiles.d.ts +7 -0
  17. package/dist/core/harnesses/profiles.d.ts.map +1 -1
  18. package/dist/core/harnesses/profiles.js +9 -0
  19. package/dist/core/harnesses/types.d.ts +14 -1
  20. package/dist/core/harnesses/types.d.ts.map +1 -1
  21. package/dist/core/harnesses/types.js +1 -0
  22. package/dist/core/workflow/engine.d.ts.map +1 -1
  23. package/dist/core/workflow/engine.js +41 -0
  24. package/dist/core/workflow/index.d.ts +1 -1
  25. package/dist/core/workflow/index.d.ts.map +1 -1
  26. package/dist/core/workflow/index.js +1 -1
  27. package/dist/core/workflow/proof.d.ts +3 -1
  28. package/dist/core/workflow/proof.d.ts.map +1 -1
  29. package/dist/core/workflow/proof.js +1 -0
  30. package/dist/core/workflow/sandbox-projection.d.ts +57 -0
  31. package/dist/core/workflow/sandbox-projection.d.ts.map +1 -0
  32. package/dist/core/workflow/sandbox-projection.js +95 -0
  33. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  34. package/dist/core/workflow/spawn-claude.js +6 -1
  35. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  36. package/dist/core/workflow/spawn-codex.js +10 -2
  37. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  38. package/dist/core/workflow/spawn-cursor.js +6 -1
  39. package/dist/core/workflow/types.d.ts +51 -0
  40. package/dist/core/workflow/types.d.ts.map +1 -1
  41. package/dist/core/workflow/workspaces/index.d.ts +2 -0
  42. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  43. package/dist/core/workflow/workspaces/index.js +1 -0
  44. package/dist/core/workflow/workspaces/inspect.d.ts.map +1 -1
  45. package/dist/core/workflow/workspaces/inspect.js +14 -0
  46. package/dist/core/workflow/workspaces/reclaim.d.ts +50 -0
  47. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -0
  48. package/dist/core/workflow/workspaces/reclaim.js +88 -0
  49. package/dist/lib/tunnel/error-page.d.ts +15 -0
  50. package/dist/lib/tunnel/error-page.d.ts.map +1 -0
  51. package/dist/lib/tunnel/error-page.js +98 -0
  52. package/dist/lib/tunnel/gate.d.ts +1 -12
  53. package/dist/lib/tunnel/gate.d.ts.map +1 -1
  54. package/dist/lib/tunnel/gate.js +59 -4
  55. package/package.json +1 -1
  56. package/src/commands/harness.ts +6 -0
  57. package/src/commands/workflow.ts +74 -0
  58. package/src/core/harnesses/attest-projection.ts +151 -0
  59. package/src/core/harnesses/attest.ts +23 -1
  60. package/src/core/harnesses/attestation.ts +7 -1
  61. package/src/core/harnesses/bench.ts +16 -0
  62. package/src/core/harnesses/profiles.ts +11 -0
  63. package/src/core/harnesses/types.ts +15 -0
  64. package/src/core/workflow/engine.ts +58 -0
  65. package/src/core/workflow/index.ts +1 -0
  66. package/src/core/workflow/proof.ts +4 -0
  67. package/src/core/workflow/sandbox-projection.ts +150 -0
  68. package/src/core/workflow/spawn-claude.ts +10 -1
  69. package/src/core/workflow/spawn-codex.ts +17 -2
  70. package/src/core/workflow/spawn-cursor.ts +10 -1
  71. package/src/core/workflow/types.ts +54 -0
  72. package/src/core/workflow/workspaces/index.ts +2 -0
  73. package/src/core/workflow/workspaces/inspect.ts +15 -0
  74. package/src/core/workflow/workspaces/reclaim.ts +109 -0
  75. package/src/lib/tunnel/error-page.ts +114 -0
  76. package/src/lib/tunnel/gate.ts +66 -3
@@ -0,0 +1,88 @@
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
+ import { existsSync } from "node:fs";
18
+ import { git, gitMaybe } from "./git.js";
19
+ /**
20
+ * Bring the worktree to a clean state so ordinary cleanup can release it.
21
+ *
22
+ * Returns `already_gone` rather than throwing when the directory has been
23
+ * removed out from under us. A workspace whose worktree no longer exists has
24
+ * effectively been reclaimed; treating that as an error is what produced an
25
+ * attempt counter that only ever went up.
26
+ */
27
+ export function prepareReclaim(input) {
28
+ if (!existsSync(input.worktreePath)) {
29
+ return {
30
+ action: "already_gone",
31
+ detail: "the worktree directory is already gone; nothing to reclaim",
32
+ };
33
+ }
34
+ const status = gitMaybe(input.worktreePath, ["status", "--porcelain"]);
35
+ if (!status.ok) {
36
+ throw new Error(`cannot read worktree status for reclaim: ${status.err || "git failed"}`);
37
+ }
38
+ if (status.out.trim().length === 0) {
39
+ return { action: "already_clean", detail: "the worktree has no uncommitted changes" };
40
+ }
41
+ if (input.mode === "discard") {
42
+ // Ordered: reset drops tracked modifications, clean removes what reset
43
+ // cannot see. Running clean first would leave staged deletions behind.
44
+ git(input.worktreePath, ["reset", "--hard"]);
45
+ git(input.worktreePath, ["clean", "-fd"]);
46
+ return { action: "discarded", detail: "uncommitted changes were discarded on request" };
47
+ }
48
+ // Salvage onto a ref of its own, then put the checked-out branch back exactly
49
+ // where it was. Two reasons, both learned by doing it the obvious way first:
50
+ //
51
+ // 1. Cleanup DELETES the provider's workspace branch. Committing the salvage
52
+ // there would have parked the work on a ref that the very next step
53
+ // removes, leaving it unreachable and eventually collectable.
54
+ // 2. Cleanup pins the workspace ref's OID in a frozen intent and refuses when
55
+ // it moves, which is a guard worth keeping. Advancing that branch turned
56
+ // every reclaim into `blocked`.
57
+ //
58
+ // Committing and rewinding satisfies both: the tree ends clean, the workspace
59
+ // ref ends untouched, and the work lives on a ref cleanup has no reason to
60
+ // touch.
61
+ const before = git(input.worktreePath, ["rev-parse", "HEAD"]).trim();
62
+ git(input.worktreePath, ["add", "-A"]);
63
+ git(input.worktreePath, ["commit", "--no-verify", "-m", salvageMessage(input.runId)]);
64
+ const commit = git(input.worktreePath, ["rev-parse", "HEAD"]).trim();
65
+ const branch = salvageBranch(input.runId);
66
+ git(input.worktreePath, ["branch", "--force", branch, commit]);
67
+ git(input.worktreePath, ["reset", "--hard", before]);
68
+ return {
69
+ action: "salvaged",
70
+ branch,
71
+ commit,
72
+ detail: `salvaged to ${branch} at ${commit.slice(0, 12)}`,
73
+ };
74
+ }
75
+ /**
76
+ * `--no-verify` above is not a shortcut. A salvage commit is an archival act on
77
+ * an abandoned workspace, so a repository hook that rejects work in progress
78
+ * would convert "preserve the work" into "cannot preserve the work", which is
79
+ * the failure this whole path exists to prevent.
80
+ */
81
+ function salvageMessage(runId) {
82
+ return `chore(workspace): salvage uncommitted work from run ${runId}`;
83
+ }
84
+ /** Named for the run rather than the workspace, because the run id is what an
85
+ * operator has in hand when they come looking for the work later. */
86
+ export function salvageBranch(runId) {
87
+ return `harnery/salvage/${runId}`;
88
+ }
@@ -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("<", "&lt;")
5
+ .replaceAll(">", "&gt;")
6
+ .replaceAll('"', "&quot;")
7
+ .replaceAll("'", "&#039;");
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
- declare function argvFlag(flag: string): string | undefined;
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":"AAiBA,iBAAS,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAGlD;AAED,QAAA,MAAM,KAAK,aAKV,CAAC;AACF,QAAA,MAAM,MAAM,QAAwD,CAAC;AACrE,QAAA,MAAM,KAAK,QAAkD,CAAC;AAC9D,QAAA,MAAM,IAAI,QAA0E,CAAC;AACrF,QAAA,MAAM,MAAM,QAA8D,CAAC;AAE3E,QAAA,MAAM,aAAa,QAAqB,CAAC;AACzC,QAAA,MAAM,WAAW,QAAmB,CAAC;AAErC,UAAU,MAAM;IACd,IAAI,EAAE,MAAM,CAAC;CACd;AAED,QAAA,MAAM,MAAM,oBA4EV,CAAC"}
1
+ {"version":3,"file":"gate.d.ts","sourceRoot":"","sources":["../../../src/lib/tunnel/gate.ts"],"names":[],"mappings":""}
@@ -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: ${ip || "(missing-cf-connecting-ip)"} ${req.method} ${url.pathname}`);
50
- return new Response("403 Forbidden\n", { status: 403 });
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
- const resp = await fetch(UPSTREAM_HTTP + url.pathname + url.search, init);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harnery",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Multi-agent coordination for AI coding agents - Claude Code, Cursor, and Codex.",
5
5
  "license": "MIT",
6
6
  "author": "Ryan Kelly",
@@ -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" });
@@ -70,6 +70,10 @@ interface WorkflowConfirmedMutationOpts {
70
70
  json?: boolean;
71
71
  }
72
72
 
73
+ interface WorkflowReclaimOpts extends WorkflowConfirmedMutationOpts {
74
+ discard?: boolean;
75
+ }
76
+
73
77
  export function registerWorkflowCommand(program: Command, emit: EmitContext): void {
74
78
  const registry = createBuiltinHarnessRegistry();
75
79
  const harnesses = registry.ids();
@@ -594,6 +598,76 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
594
598
  }
595
599
  });
596
600
 
601
+ workflow
602
+ .command("reclaim <run-id>")
603
+ .description(
604
+ "Resolve a workspace preserved because it was dirty: salvage the work to its branch, then release.",
605
+ )
606
+ .option("--yes", "Confirm that this commits or discards uncommitted work, then releases")
607
+ .option("--discard", "Throw the uncommitted work away instead of salvaging it")
608
+ .option("--json", "Emit the reclaim preparation and cleanup result as JSON")
609
+ .action(async (runId: string, opts: WorkflowReclaimOpts) => {
610
+ const coordRoot = findCoordRoot();
611
+ if (!coordRoot) {
612
+ emit.error({
613
+ code: "no_coord_root",
614
+ message: "no .harnery/ coordination root found; run `init` first",
615
+ });
616
+ process.exit(1);
617
+ }
618
+ if (!opts.yes) {
619
+ emit.error({
620
+ code: "reclaim_confirmation_required",
621
+ message: opts.discard
622
+ ? "reclaim --discard permanently destroys uncommitted work; pass --yes to confirm"
623
+ : "workspace reclaim commits uncommitted work to its branch and then releases the worktree; pass --yes to confirm",
624
+ });
625
+ process.exit(1);
626
+ }
627
+ try {
628
+ const { inspectWorkflowWorkspace, prepareReclaim, cleanupWorkspace } = await import(
629
+ "../core/workflow/index.ts"
630
+ );
631
+ const inspection = inspectWorkflowWorkspace(coordRoot, runId);
632
+ if (!inspection.ok) {
633
+ emit.error({ code: "workflow_reclaim_failed", message: inspection.error });
634
+ process.exit(1);
635
+ }
636
+ const allocation = inspection.value.allocation;
637
+ if (!allocation) {
638
+ emit.error({
639
+ code: "workflow_reclaim_unavailable",
640
+ message: `run ${runId} has no provider-owned Git workspace to reclaim`,
641
+ });
642
+ process.exit(1);
643
+ }
644
+ const preparation = prepareReclaim({
645
+ worktreePath: allocation.active_root,
646
+ mode: opts.discard ? "discard" : "salvage",
647
+ runId,
648
+ });
649
+ // Cleanup still owns removal. Reclaim only changes whether the tree it
650
+ // finds is dirty, so there is one audited release path, not two.
651
+ const provider = await builtInProviderForRun(coordRoot, runId);
652
+ const cleanup = await cleanupWorkspace({ coordRoot, runId, provider });
653
+ if (opts.json) {
654
+ emit.config({ format: "json" });
655
+ emit.data({ preparation, cleanup });
656
+ return;
657
+ }
658
+ emit.text(
659
+ `reclaim ${preparation.action}: ${preparation.detail}\n` +
660
+ `workspace cleanup ${cleanup.status}: ${cleanup.binding_id}\n`,
661
+ );
662
+ } catch (error) {
663
+ emit.error({
664
+ code: "workflow_reclaim_failed",
665
+ message: error instanceof Error ? error.message : String(error),
666
+ });
667
+ process.exit(1);
668
+ }
669
+ });
670
+
597
671
  const approvals = workflow
598
672
  .command("approvals")
599
673
  .description("Inspect and resolve durable workflow policy approvals.");
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Live probe for the `filesystemPolicyProjection` capability (ADR 0041).
3
+ *
4
+ * Every other attested dimension can be read off a single successful turn: the
5
+ * result either carries a session id or it does not. Projection cannot. A
6
+ * sandbox that is declared but not enforced looks exactly like a sandbox that is
7
+ * enforced, because in both cases the CLI accepts the flag and exits zero. The
8
+ * only way to tell them apart is to give a child something to write and look at
9
+ * the filesystem afterwards.
10
+ *
11
+ * That introduces its own confound. If the treatment child never attempts the
12
+ * write, the sentinel is absent for a reason that has nothing to do with the
13
+ * sandbox, and absence would be read as enforcement. So the probe runs a control
14
+ * first, under a mode that permits the write. Only a control that actually
15
+ * produced its sentinel licenses any reading of the treatment run; otherwise the
16
+ * probe reports inconclusive and records nothing, which is the same prerequisite
17
+ * rule the main attestation applies to a failed turn.
18
+ */
19
+
20
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+ import type { SpawnRequest, SpawnResult } from "../workflow/types.ts";
24
+ import type { HarnessAdapter } from "./types.ts";
25
+
26
+ /** Distinct names so a control artifact can never be mistaken for a treatment
27
+ * one, even if a child writes both or the directory is reused. */
28
+ const CONTROL_SENTINEL = "harnery-projection-control.txt";
29
+ const TREATMENT_SENTINEL = "harnery-projection-treatment.txt";
30
+
31
+ /** A child needs a few turns to run a shell command and report back; one turn is
32
+ * not enough to both act and answer. */
33
+ const PROJECTION_MAX_TURNS = 4;
34
+
35
+ export function projectionPrompt(sentinel: string): string {
36
+ return `Create a file named ${sentinel} in the current directory containing the text ok. Then reply with the single word: done`;
37
+ }
38
+
39
+ export type ProjectionObservation = "supported" | "unsupported" | "inconclusive";
40
+
41
+ export interface ProjectionProbeResult {
42
+ observation: ProjectionObservation;
43
+ /** Why, in one line, suitable for an attestation note or a report row. */
44
+ detail: string;
45
+ }
46
+
47
+ export interface ProjectionProbeOptions {
48
+ timeoutMs: number;
49
+ subscriptionOnly: boolean;
50
+ /** Test seam. Defaults to the adapter's production spawner. */
51
+ spawn?: (request: SpawnRequest) => Promise<SpawnResult>;
52
+ /** Test seam. Defaults to a fresh directory under the OS temp root. */
53
+ workdir?: string;
54
+ }
55
+
56
+ /**
57
+ * Run the control/treatment pair and report what the filesystem showed.
58
+ *
59
+ * Returns `inconclusive` rather than throwing for every expected failure, so a
60
+ * probe that cannot reach a verdict degrades into "nothing recorded" instead of
61
+ * failing the whole attestation sweep.
62
+ */
63
+ export async function probeFilesystemProjection(
64
+ adapter: HarnessAdapter,
65
+ opts: ProjectionProbeOptions,
66
+ ): Promise<ProjectionProbeResult> {
67
+ if (!adapter.profile.sandboxProjection) {
68
+ // Nothing to observe: the adapter refuses a projection before launch, which
69
+ // is a fact about our own code and is already covered by unit tests. Spending
70
+ // a vendor turn here would attest nothing.
71
+ return {
72
+ observation: "inconclusive",
73
+ detail: "the adapter declares no sandbox projection, so there is nothing to observe live",
74
+ };
75
+ }
76
+
77
+ const spawn = opts.spawn ?? ((request: SpawnRequest) => adapter.spawn(request));
78
+ const ownsWorkdir = !opts.workdir;
79
+ const workdir = opts.workdir ?? mkdtempSync(join(tmpdir(), "harnery-projection-"));
80
+
81
+ try {
82
+ const control = await runTurn(spawn, workdir, CONTROL_SENTINEL, "workspace-write", opts);
83
+ if (!control.ok) {
84
+ return {
85
+ observation: "inconclusive",
86
+ detail: `the control turn did not complete (${control.detail})`,
87
+ };
88
+ }
89
+ if (!existsSync(join(workdir, CONTROL_SENTINEL))) {
90
+ return {
91
+ observation: "inconclusive",
92
+ detail:
93
+ "the control child did not create its file even though writing was permitted, so an absent treatment file would prove nothing",
94
+ };
95
+ }
96
+
97
+ const treatment = await runTurn(spawn, workdir, TREATMENT_SENTINEL, "read-only", opts);
98
+ // A read-only child may well fail its turn: refusing the write is the point.
99
+ // So unlike the control, a failed treatment turn is still evidence, and only
100
+ // the filesystem decides.
101
+ const wrote = existsSync(join(workdir, TREATMENT_SENTINEL));
102
+ if (wrote) {
103
+ return {
104
+ observation: "unsupported",
105
+ detail:
106
+ "the child wrote under a read-only projection, so the declared mode is not enforced",
107
+ };
108
+ }
109
+ return {
110
+ observation: "supported",
111
+ detail: `a read-only projection blocked a write the same child performed when permitted${
112
+ treatment.ok ? "" : ` (treatment turn also reported: ${treatment.detail})`
113
+ }`,
114
+ };
115
+ } finally {
116
+ if (ownsWorkdir) rmSync(workdir, { recursive: true, force: true });
117
+ }
118
+ }
119
+
120
+ async function runTurn(
121
+ spawn: (request: SpawnRequest) => Promise<SpawnResult>,
122
+ cwd: string,
123
+ sentinel: string,
124
+ mode: "read-only" | "workspace-write",
125
+ opts: ProjectionProbeOptions,
126
+ ): Promise<{ ok: boolean; detail: string }> {
127
+ try {
128
+ const result = await spawn({
129
+ prompt: projectionPrompt(sentinel),
130
+ timeoutMs: opts.timeoutMs,
131
+ maxTurns: PROJECTION_MAX_TURNS,
132
+ cwd,
133
+ subscriptionOnly: opts.subscriptionOnly,
134
+ filesystemPolicy: { mode },
135
+ });
136
+ return { ok: result.ok, detail: result.ok ? "completed" : boundedDetail(result.error) };
137
+ } catch (error) {
138
+ return { ok: false, detail: boundedDetail((error as Error).message) };
139
+ }
140
+ }
141
+
142
+ const MAX_DETAIL_CHARS = 160;
143
+
144
+ /** Same tail-preserving rule as the main probe: a CLI prints its banner first
145
+ * and the reason it failed last. */
146
+ function boundedDetail(reason: string | undefined): string {
147
+ if (!reason) return "no error reported";
148
+ const collapsed = reason.replace(/\s+/g, " ").trim();
149
+ if (!collapsed) return "no error reported";
150
+ return collapsed.length > MAX_DETAIL_CHARS ? `…${collapsed.slice(-MAX_DETAIL_CHARS)}` : collapsed;
151
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { SpawnResult } from "../workflow/types.ts";
10
+ import { probeFilesystemProjection } from "./attest-projection.ts";
10
11
  import type { AttestableDimension, HarnessAttestation } from "./attestation.ts";
11
12
  import {
12
13
  ATTESTATION_SCHEMA_VERSION,
@@ -80,6 +81,14 @@ export interface RunHarnessAttestationOptions {
80
81
  spawn?: (harness: HarnessId, prompt: string, timeoutMs: number) => Promise<SpawnResult>;
81
82
  /** Test seam. Defaults to writing under the coord root. */
82
83
  persist?: (record: HarnessAttestation) => void;
84
+ /**
85
+ * Also probe `filesystemPolicyProjection` (ADR 0041). Off by default because
86
+ * it costs two extra turns per capable harness, against one for everything
87
+ * else: the observation needs a control run to be readable at all.
88
+ */
89
+ projection?: boolean;
90
+ /** Test seam for the projection probe. */
91
+ probeProjection?: typeof probeFilesystemProjection;
83
92
  now?: () => Date;
84
93
  }
85
94
 
@@ -148,6 +157,19 @@ export async function runHarnessAttestation(
148
157
  cost: result.costUsd !== undefined ? "supported" : "unsupported",
149
158
  };
150
159
 
160
+ let projectionNote = "";
161
+ if (opts.projection) {
162
+ const probe = opts.probeProjection ?? probeFilesystemProjection;
163
+ const outcome = await probe(adapter, { timeoutMs, subscriptionOnly });
164
+ // An inconclusive probe records nothing for the dimension, leaving the
165
+ // declaration to stand on its own rather than dressing a non-observation
166
+ // as an observation.
167
+ if (outcome.observation !== "inconclusive") {
168
+ observations.filesystemPolicyProjection = outcome.observation;
169
+ }
170
+ projectionNote = `; projection ${outcome.observation}: ${outcome.detail}`;
171
+ }
172
+
151
173
  const record = sealAttestation({
152
174
  schema_version: ATTESTATION_SCHEMA_VERSION,
153
175
  harness: id,
@@ -167,7 +189,7 @@ export async function runHarnessAttestation(
167
189
  binaryVersion,
168
190
  observations,
169
191
  durationMs: result.durationMs,
170
- note: `observed on ${binaryVersion}`,
192
+ note: `observed on ${binaryVersion}${projectionNote}`,
171
193
  });
172
194
  }
173
195
 
@@ -30,7 +30,13 @@ export const ATTESTATION_SCHEMA_VERSION = 2;
30
30
  /** Dimensions one minimal live turn can honestly establish. Everything else
31
31
  * needs a purpose-built scenario and stays outside the record rather than
32
32
  * being guessed at. */
33
- export const ATTESTABLE_DIMENSIONS = ["invocation", "finalResult", "sessionId", "cost"] as const;
33
+ export const ATTESTABLE_DIMENSIONS = [
34
+ "invocation",
35
+ "finalResult",
36
+ "sessionId",
37
+ "cost",
38
+ "filesystemPolicyProjection",
39
+ ] as const;
34
40
 
35
41
  export type AttestableDimension = (typeof ATTESTABLE_DIMENSIONS)[number];
36
42