harnery 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/commands/browse.d.ts.map +1 -1
  2. package/dist/commands/browse.js +27 -1
  3. package/dist/commands/harness.d.ts.map +1 -1
  4. package/dist/commands/harness.js +2 -0
  5. package/dist/commands/work.d.ts +2 -0
  6. package/dist/commands/work.d.ts.map +1 -1
  7. package/dist/commands/work.js +47 -4
  8. package/dist/commands/workflow.d.ts.map +1 -1
  9. package/dist/commands/workflow.js +64 -0
  10. package/dist/core/harnesses/attest-projection.d.ts +44 -0
  11. package/dist/core/harnesses/attest-projection.d.ts.map +1 -0
  12. package/dist/core/harnesses/attest-projection.js +113 -0
  13. package/dist/core/harnesses/attest.d.ts +9 -0
  14. package/dist/core/harnesses/attest.d.ts.map +1 -1
  15. package/dist/core/harnesses/attest.js +14 -1
  16. package/dist/core/harnesses/attestation.d.ts +1 -1
  17. package/dist/core/harnesses/attestation.d.ts.map +1 -1
  18. package/dist/core/harnesses/attestation.js +7 -1
  19. package/dist/core/harnesses/bench.d.ts.map +1 -1
  20. package/dist/core/harnesses/bench.js +13 -0
  21. package/dist/core/harnesses/profiles.d.ts +7 -0
  22. package/dist/core/harnesses/profiles.d.ts.map +1 -1
  23. package/dist/core/harnesses/profiles.js +9 -0
  24. package/dist/core/harnesses/types.d.ts +19 -1
  25. package/dist/core/harnesses/types.d.ts.map +1 -1
  26. package/dist/core/harnesses/types.js +1 -0
  27. package/dist/core/work/index.d.ts +1 -1
  28. package/dist/core/work/index.d.ts.map +1 -1
  29. package/dist/core/work/index.js +1 -1
  30. package/dist/core/work/runner.d.ts.map +1 -1
  31. package/dist/core/work/runner.js +8 -1
  32. package/dist/core/work/state.d.ts +24 -1
  33. package/dist/core/work/state.d.ts.map +1 -1
  34. package/dist/core/work/state.js +108 -14
  35. package/dist/core/workflow/approvals.d.ts.map +1 -1
  36. package/dist/core/workflow/approvals.js +3 -6
  37. package/dist/core/workflow/attempt-context.d.ts.map +1 -1
  38. package/dist/core/workflow/attempt-context.js +38 -1
  39. package/dist/core/workflow/engine.d.ts.map +1 -1
  40. package/dist/core/workflow/engine.js +61 -19
  41. package/dist/core/workflow/index.d.ts +1 -1
  42. package/dist/core/workflow/index.d.ts.map +1 -1
  43. package/dist/core/workflow/index.js +1 -1
  44. package/dist/core/workflow/journal.d.ts +30 -0
  45. package/dist/core/workflow/journal.d.ts.map +1 -0
  46. package/dist/core/workflow/journal.js +70 -0
  47. package/dist/core/workflow/proof.d.ts +3 -1
  48. package/dist/core/workflow/proof.d.ts.map +1 -1
  49. package/dist/core/workflow/proof.js +1 -0
  50. package/dist/core/workflow/sandbox-projection.d.ts +57 -0
  51. package/dist/core/workflow/sandbox-projection.d.ts.map +1 -0
  52. package/dist/core/workflow/sandbox-projection.js +95 -0
  53. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  54. package/dist/core/workflow/spawn-claude.js +14 -1
  55. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  56. package/dist/core/workflow/spawn-codex.js +18 -2
  57. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  58. package/dist/core/workflow/spawn-cursor.js +14 -1
  59. package/dist/core/workflow/types.d.ts +62 -0
  60. package/dist/core/workflow/types.d.ts.map +1 -1
  61. package/dist/core/workflow/workspaces/index.d.ts +2 -0
  62. package/dist/core/workflow/workspaces/index.d.ts.map +1 -1
  63. package/dist/core/workflow/workspaces/index.js +1 -0
  64. package/dist/core/workflow/workspaces/inspect.d.ts.map +1 -1
  65. package/dist/core/workflow/workspaces/inspect.js +14 -0
  66. package/dist/core/workflow/workspaces/reclaim.d.ts +50 -0
  67. package/dist/core/workflow/workspaces/reclaim.d.ts.map +1 -0
  68. package/dist/core/workflow/workspaces/reclaim.js +88 -0
  69. package/dist/core/workflow/workspaces/state.d.ts +1 -1
  70. package/dist/core/workflow/workspaces/state.d.ts.map +1 -1
  71. package/dist/core/workflow/workspaces/state.js +9 -30
  72. package/dist/lib/browser/client.d.ts +50 -0
  73. package/dist/lib/browser/client.d.ts.map +1 -1
  74. package/dist/lib/browser/client.js +103 -0
  75. package/dist/lib/exec.d.ts +4 -0
  76. package/dist/lib/exec.d.ts.map +1 -1
  77. package/dist/lib/exec.js +6 -1
  78. package/dist/lib/tunnel/error-page.d.ts +15 -0
  79. package/dist/lib/tunnel/error-page.d.ts.map +1 -0
  80. package/dist/lib/tunnel/error-page.js +98 -0
  81. package/dist/lib/tunnel/gate.d.ts +1 -12
  82. package/dist/lib/tunnel/gate.d.ts.map +1 -1
  83. package/dist/lib/tunnel/gate.js +59 -4
  84. package/package.json +1 -1
  85. package/src/commands/browse.ts +33 -1
  86. package/src/commands/harness.ts +6 -0
  87. package/src/commands/work.ts +80 -13
  88. package/src/commands/workflow.ts +74 -0
  89. package/src/core/harnesses/attest-projection.ts +151 -0
  90. package/src/core/harnesses/attest.ts +23 -1
  91. package/src/core/harnesses/attestation.ts +7 -1
  92. package/src/core/harnesses/bench.ts +16 -0
  93. package/src/core/harnesses/profiles.ts +11 -0
  94. package/src/core/harnesses/types.ts +20 -0
  95. package/src/core/work/index.ts +1 -0
  96. package/src/core/work/runner.ts +8 -0
  97. package/src/core/work/state.ts +177 -27
  98. package/src/core/workflow/approvals.ts +3 -6
  99. package/src/core/workflow/attempt-context.ts +47 -1
  100. package/src/core/workflow/engine.ts +106 -33
  101. package/src/core/workflow/index.ts +1 -0
  102. package/src/core/workflow/journal.ts +90 -0
  103. package/src/core/workflow/proof.ts +4 -0
  104. package/src/core/workflow/sandbox-projection.ts +150 -0
  105. package/src/core/workflow/spawn-claude.ts +18 -1
  106. package/src/core/workflow/spawn-codex.ts +25 -2
  107. package/src/core/workflow/spawn-cursor.ts +18 -1
  108. package/src/core/workflow/types.ts +66 -0
  109. package/src/core/workflow/workspaces/index.ts +2 -0
  110. package/src/core/workflow/workspaces/inspect.ts +15 -0
  111. package/src/core/workflow/workspaces/reclaim.ts +109 -0
  112. package/src/core/workflow/workspaces/state.ts +13 -33
  113. package/src/lib/browser/client.ts +129 -0
  114. package/src/lib/exec.ts +10 -1
  115. package/src/lib/tunnel/error-page.ts +114 -0
  116. package/src/lib/tunnel/gate.ts +66 -3
@@ -154,6 +154,7 @@ export {
154
154
  isWorkspaceLifecycleState,
155
155
  listWorkflowWorkspaceInspections,
156
156
  prepareIntegration,
157
+ prepareReclaim,
157
158
  probeLocalGitWorktreeProvider,
158
159
  readWorkflowWorkspaceStatus,
159
160
  renderWorkflowWorkspaceStatus,
@@ -0,0 +1,90 @@
1
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, writeFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fsyncParentDirectory, stableDigest } from "./durable-record.ts";
4
+
5
+ // Limit for the JSON record body. The trailing newline delimiter is outside the
6
+ // record and is not counted by readers after splitting journal lines.
7
+ export const WORKFLOW_JOURNAL_EVENT_BYTES = 16 * 1024;
8
+
9
+ /** Key under which a shrunk record names the fields it had to drop. */
10
+ export const WORKFLOW_JOURNAL_OMITTED = "omitted_fields";
11
+
12
+ const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/;
13
+
14
+ export interface WorkflowJournalOmission {
15
+ field: string;
16
+ bytes: number;
17
+ sha256: string;
18
+ }
19
+
20
+ export function workflowJournalPath(coordRoot: string, runId: string): string {
21
+ if (!RUN_ID.test(runId)) throw new Error(`invalid workflow run id ${JSON.stringify(runId)}`);
22
+ return join(resolve(coordRoot), ".harnery", "workflows", runId, "journal.jsonl");
23
+ }
24
+
25
+ /**
26
+ * Append one journal record, always.
27
+ *
28
+ * A record that would exceed what a reader accepts has its largest fields
29
+ * replaced by a digest and a byte count until it fits, and it names what it
30
+ * dropped. Size never raises.
31
+ *
32
+ * Refusing an oversized record would let a valid run fail on its own opening
33
+ * line: `run.start` carries the workflow's declared metadata plus the frozen
34
+ * work and attempt context, and Harnery's own validators permit those to exceed
35
+ * this limit by construction. Losing detail from a record is recoverable.
36
+ * Losing the run that was writing it is not.
37
+ */
38
+ export function appendWorkflowJournalEvent(
39
+ coordRoot: string,
40
+ runId: string,
41
+ event: string,
42
+ data: Record<string, unknown>,
43
+ ): void {
44
+ const path = workflowJournalPath(coordRoot, runId);
45
+ const record = fitWorkflowJournalRecord(
46
+ { schema_version: 1, run_id: runId, ts: new Date().toISOString(), event },
47
+ data,
48
+ );
49
+ const line = `${JSON.stringify(record)}\n`;
50
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
51
+ const existed = existsSync(path);
52
+ const fd = openSync(path, "a", 0o600);
53
+ try {
54
+ writeFileSync(fd, line, "utf8");
55
+ fsyncSync(fd);
56
+ } finally {
57
+ closeSync(fd);
58
+ }
59
+ if (!existed) fsyncParentDirectory(path);
60
+ }
61
+
62
+ /**
63
+ * Shrink a record to the reader's limit by dropping its largest fields first.
64
+ * The envelope is never dropped, so a record keeps its run id, timestamp, and
65
+ * event name however much detail it loses. Exported for tests.
66
+ */
67
+ export function fitWorkflowJournalRecord(
68
+ envelope: Record<string, unknown>,
69
+ data: Record<string, unknown>,
70
+ ): Record<string, unknown> {
71
+ const candidate = { ...envelope, ...data };
72
+ if (Buffer.byteLength(JSON.stringify(candidate)) <= WORKFLOW_JOURNAL_EVENT_BYTES) {
73
+ return candidate;
74
+ }
75
+ const kept: Record<string, unknown> = { ...data };
76
+ const omitted: WorkflowJournalOmission[] = [];
77
+ const bySize = Object.keys(data)
78
+ .map((field) => ({ field, bytes: Buffer.byteLength(JSON.stringify(data[field]) ?? "") }))
79
+ .sort((a, b) => b.bytes - a.bytes);
80
+
81
+ for (const { field, bytes } of bySize) {
82
+ omitted.push({ field, bytes, sha256: stableDigest(data[field]) });
83
+ delete kept[field];
84
+ const next = { ...envelope, ...kept, [WORKFLOW_JOURNAL_OMITTED]: omitted };
85
+ if (Buffer.byteLength(JSON.stringify(next)) <= WORKFLOW_JOURNAL_EVENT_BYTES) return next;
86
+ }
87
+ // Every field dropped and still over: the omission list is itself the excess.
88
+ // Keep the envelope and a count so the record stays parseable and honest.
89
+ return { ...envelope, [WORKFLOW_JOURNAL_OMITTED]: omitted.length };
90
+ }
@@ -30,6 +30,7 @@ import type {
30
30
  WorkflowProofUnknown,
31
31
  WorkflowRepoEvidence,
32
32
  WorkflowRepoSnapshot,
33
+ WorkflowSandboxProjectionEvidence,
33
34
  WorkflowWorkContext,
34
35
  } from "./types.ts";
35
36
  import { WORKFLOW_PROOF_SCHEMA_VERSION } from "./types.ts";
@@ -82,6 +83,8 @@ export interface BuildWorkflowProofInput {
82
83
  agents: WorkflowAgentProof[];
83
84
  evidence: WorkflowEvidenceRecord[];
84
85
  harnessEvidence?: Readonly<Record<string, HarnessEvidenceCapability | undefined>>;
86
+ /** Filesystem policy actually projected into children (ADR 0039). */
87
+ sandboxProjection?: WorkflowSandboxProjectionEvidence;
85
88
  /** Live attestations backing each harness's claims (ADR 0038). Injected by
86
89
  * the caller so proof stays free of filesystem lookups. */
87
90
  harnessAttestations?: Readonly<Record<string, HarnessAttestationCitation | undefined>>;
@@ -254,6 +257,7 @@ export function buildWorkflowProof(input: BuildWorkflowProofInput): WorkflowProo
254
257
  ? buildExecutionEvidence(input.status, input.workspaceBinding, input.workspaceAttestation)
255
258
  : input.workspaceFallback,
256
259
  repository,
260
+ ...(input.sandboxProjection ? { sandbox_projection: input.sandboxProjection } : {}),
257
261
  harnesses,
258
262
  unknowns,
259
263
  integrity: {
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Project the host's filesystem policy into a harness's own vendor sandbox
3
+ * (ADR 0039).
4
+ *
5
+ * Harnery decides where a workflow child may write. Until this existed it never
6
+ * told the child, so a child working in a provider-owned Git worktree could
7
+ * edit files and could not commit: the vendor excludes a repository's
8
+ * administrative directory from its writable set by policy, and Harnery had no
9
+ * way to name it as an exception.
10
+ *
11
+ * The one rule that matters here is that a projection an adapter cannot
12
+ * represent is refused before launch. Passing a policy that gets silently
13
+ * dropped would leave an operator believing a child was constrained when it was
14
+ * not, which is worse than refusing and worse than never offering the feature.
15
+ */
16
+
17
+ import type { HarnessSandboxProjection } from "../harnesses/types.ts";
18
+ import type { GitAdministrativeGrant, SpawnFilesystemPolicy } from "./types.ts";
19
+ import type { WorkspaceBinding } from "./workspaces/types.ts";
20
+
21
+ export class SandboxProjectionError extends Error {
22
+ readonly harness: string;
23
+ readonly reason:
24
+ | "mode_unrepresentable"
25
+ | "writable_roots_unrepresentable"
26
+ | "no_projection"
27
+ | "writable_root_escapes_workspace"
28
+ | "git_grant_unavailable";
29
+
30
+ constructor(harness: string, reason: SandboxProjectionError["reason"], message: string) {
31
+ super(message);
32
+ this.name = "SandboxProjectionError";
33
+ this.harness = harness;
34
+ this.reason = reason;
35
+ }
36
+ }
37
+
38
+ export interface ResolvedSandboxProjection {
39
+ /** Vendor-native name for the requested mode. */
40
+ nativeMode: string;
41
+ writableRoots: readonly string[];
42
+ }
43
+
44
+ /**
45
+ * Resolve a requested policy against what the adapter declares it can carry.
46
+ * Throws rather than degrading; see the module note.
47
+ */
48
+ export function resolveSandboxProjection(
49
+ harness: string,
50
+ declaration: HarnessSandboxProjection | undefined,
51
+ policy: SpawnFilesystemPolicy,
52
+ ): ResolvedSandboxProjection {
53
+ if (!declaration) {
54
+ throw new SandboxProjectionError(
55
+ harness,
56
+ "no_projection",
57
+ `${harness} cannot project a filesystem policy into its sandbox; remove the policy or use a harness that can`,
58
+ );
59
+ }
60
+ const nativeMode = declaration.modes[policy.mode];
61
+ if (!nativeMode) {
62
+ throw new SandboxProjectionError(
63
+ harness,
64
+ "mode_unrepresentable",
65
+ `${harness} does not distinguish the "${policy.mode}" filesystem mode, so it cannot be enforced`,
66
+ );
67
+ }
68
+ const writableRoots = policy.writableRoots ?? [];
69
+ if (writableRoots.length > 0 && !declaration.writableRoots) {
70
+ throw new SandboxProjectionError(
71
+ harness,
72
+ "writable_roots_unrepresentable",
73
+ `${harness} does not accept an explicit writable-root set, so ${writableRoots.length} declared path(s) could not be enforced`,
74
+ );
75
+ }
76
+ for (const root of writableRoots) {
77
+ // Relative paths cannot be validated against the provider's roots and are
78
+ // resolved differently by every vendor, so they are refused outright.
79
+ if (typeof root !== "string" || !root.startsWith("/")) {
80
+ throw new SandboxProjectionError(
81
+ harness,
82
+ "writable_roots_unrepresentable",
83
+ `writable root ${JSON.stringify(root)} must be an absolute path`,
84
+ );
85
+ }
86
+ }
87
+ return { nativeMode, writableRoots };
88
+ }
89
+
90
+ /** True when `candidate` is `root` or lies beneath it, comparing whole path
91
+ * segments so `/a/bc` is not treated as inside `/a/b`. */
92
+ function isWithin(root: string, candidate: string): boolean {
93
+ const normalizedRoot = root.endsWith("/") ? root.slice(0, -1) : root;
94
+ return candidate === normalizedRoot || candidate.startsWith(`${normalizedRoot}/`);
95
+ }
96
+
97
+ /**
98
+ * Refuse a projection that would grant write access outside the root the
99
+ * workspace provider already validated (ADR 0039).
100
+ *
101
+ * The renderer alone cannot do this: it never sees the binding. Granting a path
102
+ * the provider never sanctioned would let a projection quietly widen the blast
103
+ * radius of a run that the workspace lifecycle believes it has contained.
104
+ */
105
+ export function assertProjectionWithinWorkspace(
106
+ harness: string,
107
+ allowedRootRealpath: string,
108
+ writableRoots: readonly string[],
109
+ ): void {
110
+ for (const root of writableRoots) {
111
+ if (!isWithin(allowedRootRealpath, root)) {
112
+ throw new SandboxProjectionError(
113
+ harness,
114
+ "writable_root_escapes_workspace",
115
+ `writable root ${JSON.stringify(root)} is outside the workspace root ${JSON.stringify(allowedRootRealpath)} the provider validated`,
116
+ );
117
+ }
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Resolve a named Git administrative grant into concrete writable roots
123
+ * (ADR 0040).
124
+ *
125
+ * These are the only paths a run may write outside its workspace, and the
126
+ * caller never names them: they come from the binding the provider verified.
127
+ * A caller-supplied path is checked by `assertProjectionWithinWorkspace` and can
128
+ * never reach here, so asking for the grant is the whole of the widening.
129
+ *
130
+ * In a linked worktree both halves of the administrative directory live under
131
+ * the source repository, and a commit needs the shared half regardless, so the
132
+ * grant returns both rather than pretending the private half is useful alone.
133
+ */
134
+ export function resolveGitGrantRoots(
135
+ grant: GitAdministrativeGrant,
136
+ binding: WorkspaceBinding | undefined,
137
+ ): readonly string[] {
138
+ if (grant === "none") return [];
139
+ const repository = binding?.repository;
140
+ if (!repository) {
141
+ throw new SandboxProjectionError(
142
+ "workflow",
143
+ "git_grant_unavailable",
144
+ `gitWrite "${grant}" needs a Git repository binding; this run has ${binding ? "a workspace with no repository" : "no isolated workspace"}`,
145
+ );
146
+ }
147
+ // Deduplicated: a full-clone topology reports the same path for both, and a
148
+ // repeated writable root is noise in the rendered argv and in proof.
149
+ return [...new Set([repository.gitdir.realpath, repository.common_dir.realpath])];
150
+ }
@@ -19,10 +19,11 @@
19
19
  */
20
20
 
21
21
  import { exec } from "../../lib/exec.ts";
22
- import { validateHarnessEffort } from "../harnesses/profiles.ts";
22
+ import { builtinHarnessProfile, validateHarnessEffort } from "../harnesses/profiles.ts";
23
23
  import type { HarnessInvocation, HarnessRawResult } from "../harnesses/types.ts";
24
24
  import { buildChildEnv } from "./child-env.ts";
25
25
  import { notFoundError } from "./harnesses.ts";
26
+ import { resolveSandboxProjection } from "./sandbox-projection.ts";
26
27
  import { vendorFailureText } from "./spawn-failure.ts";
27
28
  import type { Spawner, SpawnRequest, SpawnResult } from "./types.ts";
28
29
 
@@ -38,6 +39,14 @@ interface ClaudeEnvelope {
38
39
 
39
40
  export function buildClaudeInvocation(req: SpawnRequest): HarnessInvocation {
40
41
  validateHarnessEffort("claude-code", req.effort);
42
+ if (req.filesystemPolicy) {
43
+ // Declared unrepresentable: refuse rather than drop it silently (ADR 0039).
44
+ resolveSandboxProjection(
45
+ "claude-code",
46
+ builtinHarnessProfile("claude-code")?.sandboxProjection,
47
+ req.filesystemPolicy,
48
+ );
49
+ }
41
50
  const argv = [
42
51
  "claude",
43
52
  "-p",
@@ -53,6 +62,14 @@ export function buildClaudeInvocation(req: SpawnRequest): HarnessInvocation {
53
62
  }
54
63
 
55
64
  export function normalizeClaudeResult(raw: HarnessRawResult): SpawnResult {
65
+ if (raw.timedOut) {
66
+ return {
67
+ ok: false,
68
+ text: "",
69
+ durationMs: raw.durationMs,
70
+ error: `claude timed out after ${raw.durationMs}ms and was killed`,
71
+ };
72
+ }
56
73
  if (raw.exitCode === 127) {
57
74
  return {
58
75
  ok: false,
@@ -20,16 +20,25 @@ import { existsSync, readFileSync, rmSync } from "node:fs";
20
20
  import { tmpdir } from "node:os";
21
21
  import { join } from "node:path";
22
22
  import { exec } from "../../lib/exec.ts";
23
- import { validateHarnessEffort } from "../harnesses/profiles.ts";
23
+ import { builtinHarnessProfile, validateHarnessEffort } from "../harnesses/profiles.ts";
24
24
  import type { HarnessInvocation, HarnessRawResult } from "../harnesses/types.ts";
25
25
  import { buildChildEnv } from "./child-env.ts";
26
26
  import { notFoundError } from "./harnesses.ts";
27
+ import { resolveSandboxProjection } from "./sandbox-projection.ts";
27
28
  import { vendorFailureText } from "./spawn-failure.ts";
28
29
  import type { Spawner, SpawnRequest, SpawnResult } from "./types.ts";
29
30
 
30
31
  export function buildCodexInvocation(req: SpawnRequest, resultFile?: string): HarnessInvocation {
31
32
  validateHarnessEffort("codex", req.effort);
32
33
  if (!resultFile) throw new Error("codex adapter requires a final-message result file");
34
+ // Default stays workspace-write so an unprojected request is unchanged.
35
+ const projection = req.filesystemPolicy
36
+ ? resolveSandboxProjection(
37
+ "codex",
38
+ builtinHarnessProfile("codex")?.sandboxProjection,
39
+ req.filesystemPolicy,
40
+ )
41
+ : undefined;
33
42
  const argv = [
34
43
  "codex",
35
44
  "exec",
@@ -38,14 +47,28 @@ export function buildCodexInvocation(req: SpawnRequest, resultFile?: string): Ha
38
47
  resultFile,
39
48
  "--skip-git-repo-check",
40
49
  "--sandbox",
41
- "workspace-write",
50
+ projection?.nativeMode ?? "workspace-write",
42
51
  ];
52
+ if (projection && projection.writableRoots.length > 0) {
53
+ argv.push(
54
+ "-c",
55
+ `sandbox_workspace_write.writable_roots=${JSON.stringify(projection.writableRoots)}`,
56
+ );
57
+ }
43
58
  if (req.model) argv.push("--model", req.model);
44
59
  if (req.effort) argv.push("-c", `model_reasoning_effort=${JSON.stringify(req.effort)}`);
45
60
  return { argv, resultFile };
46
61
  }
47
62
 
48
63
  export function normalizeCodexResult(raw: HarnessRawResult): SpawnResult {
64
+ if (raw.timedOut) {
65
+ return {
66
+ ok: false,
67
+ text: "",
68
+ durationMs: raw.durationMs,
69
+ error: `codex timed out after ${raw.durationMs}ms and was killed`,
70
+ };
71
+ }
49
72
  if (raw.exitCode === 127) {
50
73
  return { ok: false, text: "", durationMs: raw.durationMs, error: notFoundError("codex") };
51
74
  }
@@ -20,10 +20,11 @@
20
20
  */
21
21
 
22
22
  import { exec } from "../../lib/exec.ts";
23
- import { validateHarnessEffort } from "../harnesses/profiles.ts";
23
+ import { builtinHarnessProfile, validateHarnessEffort } from "../harnesses/profiles.ts";
24
24
  import type { HarnessInvocation, HarnessRawResult } from "../harnesses/types.ts";
25
25
  import { buildChildEnv } from "./child-env.ts";
26
26
  import { notFoundError } from "./harnesses.ts";
27
+ import { resolveSandboxProjection } from "./sandbox-projection.ts";
27
28
  import { vendorFailureText } from "./spawn-failure.ts";
28
29
  import type { Spawner, SpawnRequest, SpawnResult } from "./types.ts";
29
30
 
@@ -54,6 +55,14 @@ export function parseCursorOutput(stdout: string): {
54
55
 
55
56
  export function buildCursorInvocation(req: SpawnRequest): HarnessInvocation {
56
57
  validateHarnessEffort("cursor", req.effort);
58
+ if (req.filesystemPolicy) {
59
+ // Declared unrepresentable: refuse rather than drop it silently (ADR 0039).
60
+ resolveSandboxProjection(
61
+ "cursor",
62
+ builtinHarnessProfile("cursor")?.sandboxProjection,
63
+ req.filesystemPolicy,
64
+ );
65
+ }
57
66
  // --trust: headless cursor-agent refuses untrusted workspaces (exit 1,
58
67
  // "Workspace Trust Required"). --force: a print-mode child has no interactive
59
68
  // approval channel, so host-authorized workflow dispatch must let it run
@@ -65,6 +74,14 @@ export function buildCursorInvocation(req: SpawnRequest): HarnessInvocation {
65
74
  }
66
75
 
67
76
  export function normalizeCursorResult(raw: HarnessRawResult): SpawnResult {
77
+ if (raw.timedOut) {
78
+ return {
79
+ ok: false,
80
+ text: "",
81
+ durationMs: raw.durationMs,
82
+ error: `cursor timed out after ${raw.durationMs}ms and was killed`,
83
+ };
84
+ }
68
85
  if (raw.exitCode === 127) {
69
86
  return { ok: false, text: "", durationMs: raw.durationMs, error: notFoundError("cursor") };
70
87
  }
@@ -151,6 +151,13 @@ export interface HarnessEvidenceCoverage {
151
151
  attestation?: HarnessAttestationCitation;
152
152
  }
153
153
 
154
+ export interface WorkflowSandboxProjectionEvidence {
155
+ mode: SpawnFilesystemPolicy["mode"];
156
+ writable_roots: string[];
157
+ /** Which Git administrative grant the run asked for (ADR 0040). */
158
+ git_grant: GitAdministrativeGrant;
159
+ }
160
+
154
161
  export interface WorkflowProofUnknown {
155
162
  code:
156
163
  | "tool_evidence_unavailable"
@@ -190,6 +197,10 @@ export interface WorkflowProof {
190
197
  /** Immutable terminal provider evidence for isolated execution. */
191
198
  execution?: WorkspaceExecutionEvidence;
192
199
  repository: WorkflowRepoEvidence;
200
+ /** Filesystem policy projected into every child's vendor sandbox (ADR 0039).
201
+ * Absent means no projection was applied, which is the default. Present means
202
+ * the run can be audited for exactly what its children could write. */
203
+ sandbox_projection?: WorkflowSandboxProjectionEvidence;
193
204
  harnesses: HarnessEvidenceCoverage[];
194
205
  unknowns: WorkflowProofUnknown[];
195
206
  integrity: {
@@ -313,6 +324,33 @@ export interface SpawnRequest {
313
324
  /** Scrub all API-key vars from the child env so it can only authenticate
314
325
  * via its stored (subscription) login. See billing.ts. */
315
326
  subscriptionOnly?: boolean;
327
+ /** Filesystem policy to project into the child's own vendor sandbox
328
+ * (ADR 0039). Absent leaves the adapter's default invocation untouched, which
329
+ * is what shared-checkout runs use. An adapter that cannot represent the
330
+ * requested projection refuses before launch rather than downgrading. */
331
+ filesystemPolicy?: SpawnFilesystemPolicy;
332
+ }
333
+
334
+ /**
335
+ * Whether a run may write the Git administrative directory (ADR 0040).
336
+ *
337
+ * There is no middle setting, and the reason is a property of Git rather than a
338
+ * design choice. A linked worktree has a private administrative directory and a
339
+ * shared one, but object writes and branch refs live in the shared half, so even
340
+ * `git add` fails without it. A grant that enables a commit is therefore always
341
+ * a grant on the repository every worktree shares, including the operator's own
342
+ * checkout. That is why it has to be asked for by name.
343
+ */
344
+ export type GitAdministrativeGrant = "none" | "shared-repository";
345
+
346
+ /** What the host has decided the child may write. `full-access` is deliberately
347
+ * not a mode: Harnery does not project a no-sandbox state into a vendor CLI. */
348
+ export interface SpawnFilesystemPolicy {
349
+ mode: "read-only" | "workspace-write";
350
+ /** Explicit absolute paths the child may write, declared rather than derived
351
+ * from `cwd`. The path that needs writing (a repository's administrative
352
+ * directory, for instance) is routinely outside the working directory. */
353
+ writableRoots?: readonly string[];
316
354
  }
317
355
 
318
356
  /** One headless-subagent runner. The engine is adapter-agnostic; claude-code
@@ -389,6 +427,18 @@ export interface WorkflowAttemptContext {
389
427
  readonly number: number;
390
428
  readonly trigger: "initial" | "retry";
391
429
  readonly prior?: Readonly<WorkflowAttemptPriorContext>;
430
+ /** Open findings an operator raised against a prior attempt. Present on any
431
+ * trigger, because a reopen with findings starts a fresh initial attempt.
432
+ * Distinct from `prior`, which is evidence the run itself produced. */
433
+ readonly findings?: readonly Readonly<WorkflowOperatorFinding>[];
434
+ }
435
+
436
+ /** One correction an operator raised against work they judged wrong. Authored
437
+ * by a human, not derived from proof, and carried until explicitly disposed. */
438
+ export interface WorkflowOperatorFinding {
439
+ readonly id: string;
440
+ readonly actor: string;
441
+ readonly statement: string;
392
442
  }
393
443
 
394
444
  export interface EngineOpts {
@@ -444,6 +494,22 @@ export interface EngineOpts {
444
494
  /** Live attestations backing each harness's claims (ADR 0038). Read once by
445
495
  * the host and injected, so the engine performs no capability lookups. */
446
496
  harnessAttestations?: Readonly<Record<HarnessName, HarnessAttestationCitation | undefined>>;
497
+ /** Filesystem policy projected into every child's own vendor sandbox
498
+ * (ADR 0039). Validated against the workspace binding before the first spawn,
499
+ * and recorded in proof. Absent leaves every adapter invocation unchanged. */
500
+ filesystemPolicy?: SpawnFilesystemPolicy;
501
+ /**
502
+ * Whether children may write the repository's administrative directory
503
+ * (ADR 0040). Defaults to `"none"`.
504
+ *
505
+ * The caller asks for the grant by name and never supplies the path: the
506
+ * engine resolves it from the workspace binding the provider already
507
+ * verified. That asymmetry is deliberate. `filesystemPolicy.writableRoots` is
508
+ * caller-supplied and must stay inside the workspace, so a caller cannot use
509
+ * it to reach the source repository; this grant can, which is exactly why it
510
+ * is a named capability rather than another path.
511
+ */
512
+ gitWrite?: GitAdministrativeGrant;
447
513
  /** Immutable host policy. Workflow scripts and model prompts cannot replace it. */
448
514
  policy?: PolicySpec | NormalizedPolicy;
449
515
  /** Host callback for ASK. Missing, invalid, throwing, or timed-out resolution denies. */
@@ -35,6 +35,8 @@ export {
35
35
  createLocalGitWorktreeProvider,
36
36
  probe as probeLocalGitWorktreeProvider,
37
37
  } from "./local-git.ts";
38
+ export type { PrepareReclaimInput, ReclaimMode, ReclaimPreparation } from "./reclaim.ts";
39
+ export { prepareReclaim } from "./reclaim.ts";
38
40
  export type {
39
41
  AuthorizedIntegrationPlan,
40
42
  AuthorizedProviderIntegrationInput,
@@ -337,9 +337,24 @@ export function renderWorkflowWorkspaceStatus(status: WorkflowWorkspaceStatus):
337
337
  `cleanup: ${status.cleanup.state}; ${status.cleanup.attempts} attempt`,
338
338
  );
339
339
  if (status.cleanup.reason) lines.push(`cleanup reason: ${status.cleanup.reason}`);
340
+ // A count alone cannot be acted on: an operator deciding whether to salvage or
341
+ // discard has to know what is actually uncommitted, and leaving Harnery to run
342
+ // `git status` is the gap `reclaim` exists to close (ADR 0042). Bounded,
343
+ // because a dirty tree can be arbitrarily large.
344
+ if (status.repository.dirty_paths.length > 0) {
345
+ // Entries arrive in porcelain form (` M path`), so the status code is worth
346
+ // keeping and the leading pad is not.
347
+ const shown = status.repository.dirty_paths
348
+ .slice(0, MAX_LISTED_DIRTY_PATHS)
349
+ .map((path) => path.trim());
350
+ const remainder = status.repository.dirty_paths.length - shown.length;
351
+ lines.push(`dirty paths: ${shown.join(", ")}${remainder > 0 ? ` (+${remainder} more)` : ""}`);
352
+ }
340
353
  return `${lines.join("\n")}\n`;
341
354
  }
342
355
 
356
+ const MAX_LISTED_DIRTY_PATHS = 12;
357
+
343
358
  function readWorkspaceJournal(runDir: string, runId: string): WorkflowJournalProjectionEvent[] {
344
359
  const path = join(runDir, "journal.jsonl");
345
360
  if (!existsSync(path)) return [];
@@ -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
+ }