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
|
@@ -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
|
+
}
|