harnery 0.28.0 → 0.29.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/work.d.ts.map +1 -1
- package/dist/commands/work.js +27 -1
- package/dist/core/workflow/workspaces/local-git.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/local-git.js +101 -20
- package/package.json +1 -1
- package/src/commands/work.ts +29 -1
- package/src/core/workflow/workspaces/local-git.ts +112 -22
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"work.d.ts","sourceRoot":"","sources":["../../src/commands/work.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"work.d.ts","sourceRoot":"","sources":["../../src/commands/work.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAUnD,OAAO,EAUL,KAAK,WAAW,EAEjB,MAAM,uBAAuB,CAAC;AAuC/B,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI,CA8F7E;AA0OD,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAM7D"}
|
package/dist/commands/work.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
1
2
|
import { workflowSubscriptionOnly } from "../core/config.js";
|
|
2
3
|
import { createBuiltinHarnessRegistry, harnessProofInputs, probeBinaryVersion, } from "../core/harnesses/index.js";
|
|
3
4
|
import { findCoordRoot } from "../core/hooks/resolve/coord-root.js";
|
|
@@ -111,6 +112,7 @@ function registerRunCommand(work, name, retry, registry, harnesses, emit) {
|
|
|
111
112
|
.option("--allow-api-billing", "Permit API-key override billing")
|
|
112
113
|
.option("--policy <file>", "Host policy JSON/JSONC")
|
|
113
114
|
.option("--isolation <mode>", "shared | worktree | sandbox | remote")
|
|
115
|
+
.option("--workspace-root <dir>", "Writable root the isolated workspace is allocated under")
|
|
114
116
|
.option("--approval-to <address>", "Address durable ASK requests")
|
|
115
117
|
.option("--actor <name>", "Actor recorded in attempt receipts")
|
|
116
118
|
.option("--json", "Emit the workflow report or parked result as JSON")
|
|
@@ -119,7 +121,21 @@ function registerRunCommand(work, name, retry, registry, harnesses, emit) {
|
|
|
119
121
|
if (opts.harness && !registry.get(opts.harness)) {
|
|
120
122
|
throw new Error(`unknown harness ${JSON.stringify(opts.harness)}`);
|
|
121
123
|
}
|
|
124
|
+
if (opts.workspaceRoot && opts.isolation !== "worktree") {
|
|
125
|
+
throw new Error("--workspace-root requires --isolation worktree");
|
|
126
|
+
}
|
|
122
127
|
try {
|
|
128
|
+
// An isolated attempt needs a provider AND the writable root it may
|
|
129
|
+
// allocate under; the engine refuses to guess either. Without them a
|
|
130
|
+
// worktree request degrades to shared, which the proof records as
|
|
131
|
+
// requested vs effective isolation and the reporting below surfaces.
|
|
132
|
+
const { createLocalGitWorktreeProvider } = await import("../core/workflow/index.js");
|
|
133
|
+
const workspace = opts.workspaceRoot
|
|
134
|
+
? {
|
|
135
|
+
provider: createLocalGitWorktreeProvider({ coordRoot }),
|
|
136
|
+
writableRoots: [resolve(opts.workspaceRoot)],
|
|
137
|
+
}
|
|
138
|
+
: undefined;
|
|
123
139
|
const report = await runWorkItem({
|
|
124
140
|
coordRoot,
|
|
125
141
|
workId,
|
|
@@ -138,6 +154,7 @@ function registerRunCommand(work, name, retry, registry, harnesses, emit) {
|
|
|
138
154
|
approvalMode: "park",
|
|
139
155
|
approvalAddressee: opts.approvalTo,
|
|
140
156
|
isolation: opts.isolation,
|
|
157
|
+
workspace,
|
|
141
158
|
networkAccess: "enabled",
|
|
142
159
|
},
|
|
143
160
|
});
|
|
@@ -146,7 +163,16 @@ function registerRunCommand(work, name, retry, registry, harnesses, emit) {
|
|
|
146
163
|
emit.data(report);
|
|
147
164
|
}
|
|
148
165
|
else {
|
|
149
|
-
|
|
166
|
+
// A requested isolation the run could not honour is recorded in the
|
|
167
|
+
// proof as requested vs effective, which nobody reads on a green run.
|
|
168
|
+
// Say it here instead, so a silent downgrade to shared cannot pass for
|
|
169
|
+
// an isolated attempt.
|
|
170
|
+
const degraded = opts.isolation && opts.isolation !== "shared" && !report.workspaceBinding
|
|
171
|
+
? `note: ran shared; ${opts.isolation} isolation was requested but not allocated` +
|
|
172
|
+
`${opts.workspaceRoot ? "" : " (no --workspace-root given)"}\n`
|
|
173
|
+
: "";
|
|
174
|
+
emit.text(`work ${workId}: run ${report.runId} finished\n${degraded}` +
|
|
175
|
+
`proof: ${report.proofPath}\n` +
|
|
150
176
|
`next: harn work reconcile ${workId}\n`);
|
|
151
177
|
}
|
|
152
178
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local-git.d.ts","sourceRoot":"","sources":["../../../../src/core/workflow/workspaces/local-git.ts"],"names":[],"mappings":"AA6CA,OAAO,KAAK,EAgBV,mBAAmB,EACnB,oBAAoB,EACpB,iBAAiB,EAElB,MAAM,YAAY,CAAC;AASpB,MAAM,WAAW,+BAA+B;IAC9C,SAAS,EAAE,MAAM,CAAC;IAClB,wBAAwB,CAAC,EAAE,MAAM,OAAO,CAAC;CAC1C;AAED,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,+BAA+B,GACvC,iBAAiB,CAgBnB;AAED,wBAAsB,KAAK,CACzB,KAAK,EAAE,mBAAmB,EAC1B,wBAAwB,GAAE,MAAM,OAAwC,GACvE,OAAO,CAAC,oBAAoB,CAAC,
|
|
1
|
+
{"version":3,"file":"local-git.d.ts","sourceRoot":"","sources":["../../../../src/core/workflow/workspaces/local-git.ts"],"names":[],"mappings":"AA6CA,OAAO,KAAK,EAgBV,mBAAmB,EACnB,oBAAoB,EACpB,iBAAiB,EAElB,MAAM,YAAY,CAAC;AASpB,MAAM,WAAW,+BAA+B;IAC9C,SAAS,EAAE,MAAM,CAAC;IAClB,wBAAwB,CAAC,EAAE,MAAM,OAAO,CAAC;CAC1C;AAED,wBAAgB,8BAA8B,CAC5C,OAAO,EAAE,+BAA+B,GACvC,iBAAiB,CAgBnB;AAED,wBAAsB,KAAK,CACzB,KAAK,EAAE,mBAAmB,EAC1B,wBAAwB,GAAE,MAAM,OAAwC,GACvE,OAAO,CAAC,oBAAoB,CAAC,CA4E/B"}
|
|
@@ -44,17 +44,28 @@ export async function probe(input, descriptorPathsSupported = descriptorBackedPa
|
|
|
44
44
|
if (input.writable_roots.length === 0) {
|
|
45
45
|
unsupported.push(reason("writable_roots_required", "explicit writable roots are required"));
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
// Allocation always selects writable_roots[0] (validateRequest pins the selected root
|
|
48
|
+
// to it), so probe's authority-coverage verdict must be judged against that same root.
|
|
49
|
+
let selectedRoot;
|
|
50
|
+
input.writable_roots.forEach((root, index) => {
|
|
48
51
|
try {
|
|
49
|
-
validateConfiguredRoot(root);
|
|
52
|
+
const validated = validateConfiguredRoot(root);
|
|
53
|
+
if (index === 0)
|
|
54
|
+
selectedRoot = validated;
|
|
50
55
|
}
|
|
51
56
|
catch (error) {
|
|
52
57
|
unsupported.push(reason("writable_root_invalid", error.message));
|
|
53
58
|
}
|
|
54
|
-
}
|
|
59
|
+
});
|
|
55
60
|
if (version.ok) {
|
|
56
61
|
try {
|
|
57
|
-
inspectSourceRepository(input.requested_cwd);
|
|
62
|
+
const repo = inspectSourceRepository(input.requested_cwd);
|
|
63
|
+
if (selectedRoot) {
|
|
64
|
+
const outside = describeAuthorityOutsideRoot(repo, selectedRoot.realpath);
|
|
65
|
+
if (outside) {
|
|
66
|
+
unsupported.push(reason("repository_authority_outside_writable_root", outside));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
58
69
|
}
|
|
59
70
|
catch (error) {
|
|
60
71
|
unsupported.push(reason("repository_unsupported", error.message));
|
|
@@ -115,10 +126,9 @@ async function allocate(coordRoot, request) {
|
|
|
115
126
|
const repo = inspectSourceRepository(request.requested_cwd);
|
|
116
127
|
assertAllowedPathAuthority(request.allowed_paths, repo.sourceRoot.realpath, "source repository");
|
|
117
128
|
assertAllowedPathAuthority(request.allowed_paths, repo.commonDir.realpath, "Git common directory");
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
throw new Error(
|
|
121
|
-
}
|
|
129
|
+
const authorityOutside = describeAuthorityOutsideRoot(repo, root.realpath);
|
|
130
|
+
if (authorityOutside)
|
|
131
|
+
throw new Error(authorityOutside);
|
|
122
132
|
const workspaceRoot = candidateUnderRoot(root, ["harnery-workspaces", bindingId]);
|
|
123
133
|
const activeRoot = resolve(workspaceRoot, repo.requestedRelativePath);
|
|
124
134
|
if (!containsPath(workspaceRoot, activeRoot)) {
|
|
@@ -887,6 +897,17 @@ async function applyIntegrationUnderLease(coordRoot, input) {
|
|
|
887
897
|
}
|
|
888
898
|
revalidateClaimResources(claim, validateConfiguredRoot(plan.binding.writable_root.configured));
|
|
889
899
|
inspectIntegrationTarget(current.target_root, plan.binding);
|
|
900
|
+
// The fast-forward moves the target branch ref, which for a submodule or linked
|
|
901
|
+
// worktree lives in the common directory outside the checkout tree. allowed_paths is
|
|
902
|
+
// the real write authority (allocate and cleanup already gate the common directory on
|
|
903
|
+
// it), so re-authorize both the target checkout and its common directory here rather
|
|
904
|
+
// than trusting containment alone.
|
|
905
|
+
const applyCommonDir = plan.binding.repository?.common_dir.realpath;
|
|
906
|
+
if (!applyCommonDir) {
|
|
907
|
+
throw new Error("integration binding is missing Git common-dir authority");
|
|
908
|
+
}
|
|
909
|
+
assertAllowedPathAuthority(claim.request.allowed_paths, current.target_root, "integration target path");
|
|
910
|
+
assertAllowedPathAuthority(claim.request.allowed_paths, applyCommonDir, "integration Git common directory");
|
|
890
911
|
git(current.target_root, ["merge", "--ff-only", expected.source_commit]);
|
|
891
912
|
const finalCommit = git(current.target_root, ["rev-parse", "HEAD"]);
|
|
892
913
|
const finalTree = git(current.target_root, ["rev-parse", "HEAD^{tree}"]);
|
|
@@ -969,18 +990,15 @@ function inspectSourceRepository(cwd) {
|
|
|
969
990
|
if (!containsPath(sourceRoot, requestedCwd.realpath)) {
|
|
970
991
|
throw new Error("requested working directory is outside the resolved Git worktree");
|
|
971
992
|
}
|
|
972
|
-
|
|
973
|
-
throw new Error("symlink Git directories are unsupported");
|
|
974
|
-
}
|
|
975
|
-
if (!lstatSync(join(sourceRoot, ".git")).isDirectory()) {
|
|
976
|
-
throw new Error("linked worktree and submodule source repositories are unsupported");
|
|
977
|
-
}
|
|
978
|
-
const requestedGitDir = realpathSync(join(sourceRoot, ".git"));
|
|
993
|
+
const pointedGitDir = resolveGitDirPointer(sourceRoot);
|
|
979
994
|
const gitDir = realpathSync(resolve(sourceRoot, git(sourceRoot, ["rev-parse", "--git-dir"])));
|
|
980
995
|
const commonDir = realpathSync(resolve(sourceRoot, git(sourceRoot, ["rev-parse", "--git-common-dir"])));
|
|
981
|
-
if (
|
|
996
|
+
if (pointedGitDir !== gitDir) {
|
|
982
997
|
throw new Error("resolved Git authority does not match the requested checkout");
|
|
983
998
|
}
|
|
999
|
+
if (!containsPath(commonDir, gitDir)) {
|
|
1000
|
+
throw new Error("resolved Git directory is not inside its common directory");
|
|
1001
|
+
}
|
|
984
1002
|
const branch = gitMaybe(sourceRoot, ["symbolic-ref", "-q", "--short", "HEAD"]);
|
|
985
1003
|
if (!branch.ok || !branch.out)
|
|
986
1004
|
throw new Error("detached integration targets are unsupported");
|
|
@@ -1002,6 +1020,48 @@ function inspectSourceRepository(cwd) {
|
|
|
1002
1020
|
requestedRelativePath: relative(sourceRoot, requestedCwd.realpath),
|
|
1003
1021
|
};
|
|
1004
1022
|
}
|
|
1023
|
+
// Resolve the `.git` entry at a checkout root to the real Git directory it names,
|
|
1024
|
+
// without trusting the ambient environment. `.git` is a real directory in a plain
|
|
1025
|
+
// checkout, and a `gitdir:` pointer file in a linked worktree, submodule, or
|
|
1026
|
+
// worktree-of-a-submodule. A symlink is refused: the identity model pins a path to a
|
|
1027
|
+
// device and inode, and a symlink lets the target move under a pinned pointer.
|
|
1028
|
+
//
|
|
1029
|
+
// The caller cross-checks the result against `rev-parse --git-dir`. That is a
|
|
1030
|
+
// consistency assertion, not spoof detection: Git honours a `gitdir:` pointer, so a
|
|
1031
|
+
// pointer aimed at an unrelated repository agrees with `rev-parse` and is accepted.
|
|
1032
|
+
// It cannot be otherwise, because a doctored pointer and a submodule's pointer are
|
|
1033
|
+
// the same construct. What the equality buys is that this resolution and Git's own
|
|
1034
|
+
// never silently diverge, which is defence in depth over the `GIT_*` stripping in
|
|
1035
|
+
// `isolatedGitEnvironment`. The real authority check is containment plus
|
|
1036
|
+
// `allowed_paths` on the resolved common directory.
|
|
1037
|
+
function resolveGitDirPointer(sourceRoot) {
|
|
1038
|
+
const dotGit = join(sourceRoot, ".git");
|
|
1039
|
+
let stats;
|
|
1040
|
+
try {
|
|
1041
|
+
stats = lstatSync(dotGit);
|
|
1042
|
+
}
|
|
1043
|
+
catch {
|
|
1044
|
+
throw new Error("requested checkout has no .git entry");
|
|
1045
|
+
}
|
|
1046
|
+
if (stats.isSymbolicLink()) {
|
|
1047
|
+
throw new Error("symlink Git directories are unsupported");
|
|
1048
|
+
}
|
|
1049
|
+
if (stats.isDirectory()) {
|
|
1050
|
+
return realpathSync(dotGit);
|
|
1051
|
+
}
|
|
1052
|
+
if (!stats.isFile()) {
|
|
1053
|
+
throw new Error(".git must be a directory or a gitdir pointer file");
|
|
1054
|
+
}
|
|
1055
|
+
const pointer = readFileSync(dotGit, "utf8").split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
1056
|
+
const prefix = "gitdir:";
|
|
1057
|
+
if (!pointer.startsWith(prefix)) {
|
|
1058
|
+
throw new Error(".git file is not a gitdir pointer");
|
|
1059
|
+
}
|
|
1060
|
+
const target = pointer.slice(prefix.length).trim();
|
|
1061
|
+
if (!target)
|
|
1062
|
+
throw new Error(".git gitdir pointer is empty");
|
|
1063
|
+
return realpathSync(resolve(sourceRoot, target));
|
|
1064
|
+
}
|
|
1005
1065
|
function inspectFrozenSourceAuthority(sourceRoot, commonDir, targetRef) {
|
|
1006
1066
|
const configured = validateConfiguredRoot(sourceRoot.configured);
|
|
1007
1067
|
if (configured.realpath !== sourceRoot.realpath ||
|
|
@@ -1295,6 +1355,22 @@ function validFrozenFilesystemPath(path) {
|
|
|
1295
1355
|
typeof path.identity.device === "string" &&
|
|
1296
1356
|
typeof path.identity.inode === "string");
|
|
1297
1357
|
}
|
|
1358
|
+
// Explain, in the caller's terms, why a checkout's Git authority falls outside a
|
|
1359
|
+
// writable root — naming the offending path so a submodule or linked-worktree user
|
|
1360
|
+
// who declared only the inner checkout knows exactly what to widen. Returns undefined
|
|
1361
|
+
// when both the source checkout and its common directory are inside the root.
|
|
1362
|
+
function describeAuthorityOutsideRoot(repo, rootRealpath) {
|
|
1363
|
+
if (!containsPath(rootRealpath, repo.commonDir.realpath)) {
|
|
1364
|
+
return (`Git keeps this checkout's administrative files in ${repo.commonDir.realpath}, ` +
|
|
1365
|
+
`which is outside the writable root ${rootRealpath}. Linked-worktree and submodule ` +
|
|
1366
|
+
`checkouts keep their Git authority in the enclosing repository, and allocating a ` +
|
|
1367
|
+
`worktree writes there; declare that repository (or a parent of it) as the writable root.`);
|
|
1368
|
+
}
|
|
1369
|
+
if (!containsPath(rootRealpath, repo.sourceRoot.realpath)) {
|
|
1370
|
+
return `the source checkout ${repo.sourceRoot.realpath} is outside the writable root ${rootRealpath}`;
|
|
1371
|
+
}
|
|
1372
|
+
return undefined;
|
|
1373
|
+
}
|
|
1298
1374
|
function assertAllowedPathAuthority(allowedPaths, candidate, label) {
|
|
1299
1375
|
if (allowedPaths.length > 0 &&
|
|
1300
1376
|
!allowedPaths.some((allowedPath) => {
|
|
@@ -1383,10 +1459,15 @@ function capabilityDigestForClaim(claim) {
|
|
|
1383
1459
|
function acquireRepositoryLease(coordRoot, claim) {
|
|
1384
1460
|
const leaseDir = join(resolve(coordRoot), ".harnery", "workspaces", PROVIDER_ID, ".leases");
|
|
1385
1461
|
mkdirSync(leaseDir, { recursive: true, mode: 0o700 });
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1462
|
+
// Mutual exclusion keys on the Git common directory alone. `git worktree add`,
|
|
1463
|
+
// `prune`, and the shared-`config` migration all write to the common directory's
|
|
1464
|
+
// administrative area, and once worktrees and submodules are allowed, several
|
|
1465
|
+
// distinct source checkouts (a superproject and its linked worktrees; a submodule
|
|
1466
|
+
// and a worktree of it) share one common directory under different — possibly
|
|
1467
|
+
// nested — writable roots. Including the writable root in the key would hand those
|
|
1468
|
+
// agents different locks and let their admin-area writes race. The writable root
|
|
1469
|
+
// stays in the lease metadata for diagnostics, not in the exclusion key.
|
|
1470
|
+
const key = stableDigest({ common: claim.repository.common_dir.identity });
|
|
1390
1471
|
return acquireLease(coordRoot, join(leaseDir, `repository-${key}.lock`), claim, "repository");
|
|
1391
1472
|
}
|
|
1392
1473
|
function acquireLease(coordRoot, path, claim, scope) {
|
package/package.json
CHANGED
package/src/commands/work.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
1
2
|
import type { Command } from "commander";
|
|
2
3
|
import type { EmitContext } from "../commander.ts";
|
|
3
4
|
import { workflowSubscriptionOnly } from "../core/config.ts";
|
|
@@ -44,6 +45,7 @@ interface WorkRunOpts {
|
|
|
44
45
|
allowApiBilling?: boolean;
|
|
45
46
|
policy?: string;
|
|
46
47
|
isolation?: PolicyIsolation;
|
|
48
|
+
workspaceRoot?: string;
|
|
47
49
|
approvalTo?: string;
|
|
48
50
|
actor?: string;
|
|
49
51
|
json?: boolean;
|
|
@@ -178,6 +180,7 @@ function registerRunCommand(
|
|
|
178
180
|
.option("--allow-api-billing", "Permit API-key override billing")
|
|
179
181
|
.option("--policy <file>", "Host policy JSON/JSONC")
|
|
180
182
|
.option("--isolation <mode>", "shared | worktree | sandbox | remote")
|
|
183
|
+
.option("--workspace-root <dir>", "Writable root the isolated workspace is allocated under")
|
|
181
184
|
.option("--approval-to <address>", "Address durable ASK requests")
|
|
182
185
|
.option("--actor <name>", "Actor recorded in attempt receipts")
|
|
183
186
|
.option("--json", "Emit the workflow report or parked result as JSON")
|
|
@@ -186,7 +189,21 @@ function registerRunCommand(
|
|
|
186
189
|
if (opts.harness && !registry.get(opts.harness)) {
|
|
187
190
|
throw new Error(`unknown harness ${JSON.stringify(opts.harness)}`);
|
|
188
191
|
}
|
|
192
|
+
if (opts.workspaceRoot && opts.isolation !== "worktree") {
|
|
193
|
+
throw new Error("--workspace-root requires --isolation worktree");
|
|
194
|
+
}
|
|
189
195
|
try {
|
|
196
|
+
// An isolated attempt needs a provider AND the writable root it may
|
|
197
|
+
// allocate under; the engine refuses to guess either. Without them a
|
|
198
|
+
// worktree request degrades to shared, which the proof records as
|
|
199
|
+
// requested vs effective isolation and the reporting below surfaces.
|
|
200
|
+
const { createLocalGitWorktreeProvider } = await import("../core/workflow/index.ts");
|
|
201
|
+
const workspace = opts.workspaceRoot
|
|
202
|
+
? {
|
|
203
|
+
provider: createLocalGitWorktreeProvider({ coordRoot }),
|
|
204
|
+
writableRoots: [resolve(opts.workspaceRoot)],
|
|
205
|
+
}
|
|
206
|
+
: undefined;
|
|
190
207
|
const report = await runWorkItem({
|
|
191
208
|
coordRoot,
|
|
192
209
|
workId,
|
|
@@ -209,6 +226,7 @@ function registerRunCommand(
|
|
|
209
226
|
approvalMode: "park",
|
|
210
227
|
approvalAddressee: opts.approvalTo,
|
|
211
228
|
isolation: opts.isolation,
|
|
229
|
+
workspace,
|
|
212
230
|
networkAccess: "enabled",
|
|
213
231
|
},
|
|
214
232
|
});
|
|
@@ -216,8 +234,18 @@ function registerRunCommand(
|
|
|
216
234
|
emit.config({ format: "json" });
|
|
217
235
|
emit.data(report);
|
|
218
236
|
} else {
|
|
237
|
+
// A requested isolation the run could not honour is recorded in the
|
|
238
|
+
// proof as requested vs effective, which nobody reads on a green run.
|
|
239
|
+
// Say it here instead, so a silent downgrade to shared cannot pass for
|
|
240
|
+
// an isolated attempt.
|
|
241
|
+
const degraded =
|
|
242
|
+
opts.isolation && opts.isolation !== "shared" && !report.workspaceBinding
|
|
243
|
+
? `note: ran shared; ${opts.isolation} isolation was requested but not allocated` +
|
|
244
|
+
`${opts.workspaceRoot ? "" : " (no --workspace-root given)"}\n`
|
|
245
|
+
: "";
|
|
219
246
|
emit.text(
|
|
220
|
-
`work ${workId}: run ${report.runId} finished\
|
|
247
|
+
`work ${workId}: run ${report.runId} finished\n${degraded}` +
|
|
248
|
+
`proof: ${report.proofPath}\n` +
|
|
221
249
|
`next: harn work reconcile ${workId}\n`,
|
|
222
250
|
);
|
|
223
251
|
}
|
|
@@ -123,16 +123,26 @@ export async function probe(
|
|
|
123
123
|
if (input.writable_roots.length === 0) {
|
|
124
124
|
unsupported.push(reason("writable_roots_required", "explicit writable roots are required"));
|
|
125
125
|
}
|
|
126
|
-
|
|
126
|
+
// Allocation always selects writable_roots[0] (validateRequest pins the selected root
|
|
127
|
+
// to it), so probe's authority-coverage verdict must be judged against that same root.
|
|
128
|
+
let selectedRoot: ValidatedRoot | undefined;
|
|
129
|
+
input.writable_roots.forEach((root, index) => {
|
|
127
130
|
try {
|
|
128
|
-
validateConfiguredRoot(root);
|
|
131
|
+
const validated = validateConfiguredRoot(root);
|
|
132
|
+
if (index === 0) selectedRoot = validated;
|
|
129
133
|
} catch (error) {
|
|
130
134
|
unsupported.push(reason("writable_root_invalid", (error as Error).message));
|
|
131
135
|
}
|
|
132
|
-
}
|
|
136
|
+
});
|
|
133
137
|
if (version.ok) {
|
|
134
138
|
try {
|
|
135
|
-
inspectSourceRepository(input.requested_cwd);
|
|
139
|
+
const repo = inspectSourceRepository(input.requested_cwd);
|
|
140
|
+
if (selectedRoot) {
|
|
141
|
+
const outside = describeAuthorityOutsideRoot(repo, selectedRoot.realpath);
|
|
142
|
+
if (outside) {
|
|
143
|
+
unsupported.push(reason("repository_authority_outside_writable_root", outside));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
136
146
|
} catch (error) {
|
|
137
147
|
unsupported.push(reason("repository_unsupported", (error as Error).message));
|
|
138
148
|
}
|
|
@@ -209,12 +219,8 @@ async function allocate(
|
|
|
209
219
|
repo.commonDir.realpath,
|
|
210
220
|
"Git common directory",
|
|
211
221
|
);
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
!containsPath(root.realpath, repo.commonDir.realpath)
|
|
215
|
-
) {
|
|
216
|
-
throw new Error("source repository and Git common dir must be inside the writable root");
|
|
217
|
-
}
|
|
222
|
+
const authorityOutside = describeAuthorityOutsideRoot(repo, root.realpath);
|
|
223
|
+
if (authorityOutside) throw new Error(authorityOutside);
|
|
218
224
|
const workspaceRoot = candidateUnderRoot(root, ["harnery-workspaces", bindingId]);
|
|
219
225
|
const activeRoot = resolve(workspaceRoot, repo.requestedRelativePath);
|
|
220
226
|
if (!containsPath(workspaceRoot, activeRoot)) {
|
|
@@ -1192,6 +1198,25 @@ async function applyIntegrationUnderLease(
|
|
|
1192
1198
|
}
|
|
1193
1199
|
revalidateClaimResources(claim, validateConfiguredRoot(plan.binding.writable_root.configured));
|
|
1194
1200
|
inspectIntegrationTarget(current.target_root, plan.binding);
|
|
1201
|
+
// The fast-forward moves the target branch ref, which for a submodule or linked
|
|
1202
|
+
// worktree lives in the common directory outside the checkout tree. allowed_paths is
|
|
1203
|
+
// the real write authority (allocate and cleanup already gate the common directory on
|
|
1204
|
+
// it), so re-authorize both the target checkout and its common directory here rather
|
|
1205
|
+
// than trusting containment alone.
|
|
1206
|
+
const applyCommonDir = plan.binding.repository?.common_dir.realpath;
|
|
1207
|
+
if (!applyCommonDir) {
|
|
1208
|
+
throw new Error("integration binding is missing Git common-dir authority");
|
|
1209
|
+
}
|
|
1210
|
+
assertAllowedPathAuthority(
|
|
1211
|
+
claim.request.allowed_paths,
|
|
1212
|
+
current.target_root,
|
|
1213
|
+
"integration target path",
|
|
1214
|
+
);
|
|
1215
|
+
assertAllowedPathAuthority(
|
|
1216
|
+
claim.request.allowed_paths,
|
|
1217
|
+
applyCommonDir,
|
|
1218
|
+
"integration Git common directory",
|
|
1219
|
+
);
|
|
1195
1220
|
git(current.target_root, ["merge", "--ff-only", expected.source_commit]);
|
|
1196
1221
|
const finalCommit = git(current.target_root, ["rev-parse", "HEAD"]);
|
|
1197
1222
|
const finalTree = git(current.target_root, ["rev-parse", "HEAD^{tree}"]);
|
|
@@ -1287,20 +1312,17 @@ function inspectSourceRepository(cwd: string): {
|
|
|
1287
1312
|
if (!containsPath(sourceRoot, requestedCwd.realpath)) {
|
|
1288
1313
|
throw new Error("requested working directory is outside the resolved Git worktree");
|
|
1289
1314
|
}
|
|
1290
|
-
|
|
1291
|
-
throw new Error("symlink Git directories are unsupported");
|
|
1292
|
-
}
|
|
1293
|
-
if (!lstatSync(join(sourceRoot, ".git")).isDirectory()) {
|
|
1294
|
-
throw new Error("linked worktree and submodule source repositories are unsupported");
|
|
1295
|
-
}
|
|
1296
|
-
const requestedGitDir = realpathSync(join(sourceRoot, ".git"));
|
|
1315
|
+
const pointedGitDir = resolveGitDirPointer(sourceRoot);
|
|
1297
1316
|
const gitDir = realpathSync(resolve(sourceRoot, git(sourceRoot, ["rev-parse", "--git-dir"])));
|
|
1298
1317
|
const commonDir = realpathSync(
|
|
1299
1318
|
resolve(sourceRoot, git(sourceRoot, ["rev-parse", "--git-common-dir"])),
|
|
1300
1319
|
);
|
|
1301
|
-
if (
|
|
1320
|
+
if (pointedGitDir !== gitDir) {
|
|
1302
1321
|
throw new Error("resolved Git authority does not match the requested checkout");
|
|
1303
1322
|
}
|
|
1323
|
+
if (!containsPath(commonDir, gitDir)) {
|
|
1324
|
+
throw new Error("resolved Git directory is not inside its common directory");
|
|
1325
|
+
}
|
|
1304
1326
|
const branch = gitMaybe(sourceRoot, ["symbolic-ref", "-q", "--short", "HEAD"]);
|
|
1305
1327
|
if (!branch.ok || !branch.out) throw new Error("detached integration targets are unsupported");
|
|
1306
1328
|
const targetRef = `refs/heads/${branch.out}`;
|
|
@@ -1321,6 +1343,47 @@ function inspectSourceRepository(cwd: string): {
|
|
|
1321
1343
|
};
|
|
1322
1344
|
}
|
|
1323
1345
|
|
|
1346
|
+
// Resolve the `.git` entry at a checkout root to the real Git directory it names,
|
|
1347
|
+
// without trusting the ambient environment. `.git` is a real directory in a plain
|
|
1348
|
+
// checkout, and a `gitdir:` pointer file in a linked worktree, submodule, or
|
|
1349
|
+
// worktree-of-a-submodule. A symlink is refused: the identity model pins a path to a
|
|
1350
|
+
// device and inode, and a symlink lets the target move under a pinned pointer.
|
|
1351
|
+
//
|
|
1352
|
+
// The caller cross-checks the result against `rev-parse --git-dir`. That is a
|
|
1353
|
+
// consistency assertion, not spoof detection: Git honours a `gitdir:` pointer, so a
|
|
1354
|
+
// pointer aimed at an unrelated repository agrees with `rev-parse` and is accepted.
|
|
1355
|
+
// It cannot be otherwise, because a doctored pointer and a submodule's pointer are
|
|
1356
|
+
// the same construct. What the equality buys is that this resolution and Git's own
|
|
1357
|
+
// never silently diverge, which is defence in depth over the `GIT_*` stripping in
|
|
1358
|
+
// `isolatedGitEnvironment`. The real authority check is containment plus
|
|
1359
|
+
// `allowed_paths` on the resolved common directory.
|
|
1360
|
+
function resolveGitDirPointer(sourceRoot: string): string {
|
|
1361
|
+
const dotGit = join(sourceRoot, ".git");
|
|
1362
|
+
let stats: ReturnType<typeof lstatSync>;
|
|
1363
|
+
try {
|
|
1364
|
+
stats = lstatSync(dotGit);
|
|
1365
|
+
} catch {
|
|
1366
|
+
throw new Error("requested checkout has no .git entry");
|
|
1367
|
+
}
|
|
1368
|
+
if (stats.isSymbolicLink()) {
|
|
1369
|
+
throw new Error("symlink Git directories are unsupported");
|
|
1370
|
+
}
|
|
1371
|
+
if (stats.isDirectory()) {
|
|
1372
|
+
return realpathSync(dotGit);
|
|
1373
|
+
}
|
|
1374
|
+
if (!stats.isFile()) {
|
|
1375
|
+
throw new Error(".git must be a directory or a gitdir pointer file");
|
|
1376
|
+
}
|
|
1377
|
+
const pointer = readFileSync(dotGit, "utf8").split(/\r?\n/, 1)[0]?.trim() ?? "";
|
|
1378
|
+
const prefix = "gitdir:";
|
|
1379
|
+
if (!pointer.startsWith(prefix)) {
|
|
1380
|
+
throw new Error(".git file is not a gitdir pointer");
|
|
1381
|
+
}
|
|
1382
|
+
const target = pointer.slice(prefix.length).trim();
|
|
1383
|
+
if (!target) throw new Error(".git gitdir pointer is empty");
|
|
1384
|
+
return realpathSync(resolve(sourceRoot, target));
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1324
1387
|
function inspectFrozenSourceAuthority(
|
|
1325
1388
|
sourceRoot: GitRepositoryBinding["source_root"],
|
|
1326
1389
|
commonDir: GitRepositoryBinding["common_dir"],
|
|
@@ -1680,6 +1743,28 @@ function validFrozenFilesystemPath(
|
|
|
1680
1743
|
);
|
|
1681
1744
|
}
|
|
1682
1745
|
|
|
1746
|
+
// Explain, in the caller's terms, why a checkout's Git authority falls outside a
|
|
1747
|
+
// writable root — naming the offending path so a submodule or linked-worktree user
|
|
1748
|
+
// who declared only the inner checkout knows exactly what to widen. Returns undefined
|
|
1749
|
+
// when both the source checkout and its common directory are inside the root.
|
|
1750
|
+
function describeAuthorityOutsideRoot(
|
|
1751
|
+
repo: ReturnType<typeof inspectSourceRepository>,
|
|
1752
|
+
rootRealpath: string,
|
|
1753
|
+
): string | undefined {
|
|
1754
|
+
if (!containsPath(rootRealpath, repo.commonDir.realpath)) {
|
|
1755
|
+
return (
|
|
1756
|
+
`Git keeps this checkout's administrative files in ${repo.commonDir.realpath}, ` +
|
|
1757
|
+
`which is outside the writable root ${rootRealpath}. Linked-worktree and submodule ` +
|
|
1758
|
+
`checkouts keep their Git authority in the enclosing repository, and allocating a ` +
|
|
1759
|
+
`worktree writes there; declare that repository (or a parent of it) as the writable root.`
|
|
1760
|
+
);
|
|
1761
|
+
}
|
|
1762
|
+
if (!containsPath(rootRealpath, repo.sourceRoot.realpath)) {
|
|
1763
|
+
return `the source checkout ${repo.sourceRoot.realpath} is outside the writable root ${rootRealpath}`;
|
|
1764
|
+
}
|
|
1765
|
+
return undefined;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1683
1768
|
function assertAllowedPathAuthority(
|
|
1684
1769
|
allowedPaths: WorkspaceAllocationRequest["allowed_paths"],
|
|
1685
1770
|
candidate: string,
|
|
@@ -1783,10 +1868,15 @@ function capabilityDigestForClaim(claim: WorkspaceClaim): string {
|
|
|
1783
1868
|
function acquireRepositoryLease(coordRoot: string, claim: WorkspaceClaim): () => void {
|
|
1784
1869
|
const leaseDir = join(resolve(coordRoot), ".harnery", "workspaces", PROVIDER_ID, ".leases");
|
|
1785
1870
|
mkdirSync(leaseDir, { recursive: true, mode: 0o700 });
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1871
|
+
// Mutual exclusion keys on the Git common directory alone. `git worktree add`,
|
|
1872
|
+
// `prune`, and the shared-`config` migration all write to the common directory's
|
|
1873
|
+
// administrative area, and once worktrees and submodules are allowed, several
|
|
1874
|
+
// distinct source checkouts (a superproject and its linked worktrees; a submodule
|
|
1875
|
+
// and a worktree of it) share one common directory under different — possibly
|
|
1876
|
+
// nested — writable roots. Including the writable root in the key would hand those
|
|
1877
|
+
// agents different locks and let their admin-area writes race. The writable root
|
|
1878
|
+
// stays in the lease metadata for diagnostics, not in the exclusion key.
|
|
1879
|
+
const key = stableDigest({ common: claim.repository.common_dir.identity });
|
|
1790
1880
|
return acquireLease(coordRoot, join(leaseDir, `repository-${key}.lock`), claim, "repository");
|
|
1791
1881
|
}
|
|
1792
1882
|
|