@davesheffer/hunch 1.8.2 → 1.9.2
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/README.md +96 -1
- package/dist/cli/index.js +1238 -396
- package/dist/constitution/adapters.js +31 -14
- package/dist/constitution/behaviorEvaluator.js +20 -7
- package/dist/constitution/behaviorProof.js +3 -2
- package/dist/constitution/canonical.js +7 -1
- package/dist/constitution/card.js +7 -2
- package/dist/constitution/compiler.js +71 -1
- package/dist/constitution/correctionPolicyMaterializer.js +496 -0
- package/dist/constitution/delta.js +3 -2
- package/dist/constitution/evaluator.js +29 -3
- package/dist/constitution/experiment.js +96 -5
- package/dist/constitution/experimentRunner.js +43 -14
- package/dist/constitution/g2BehaviorCandidates.js +49 -26
- package/dist/constitution/g2BehaviorDependencies.js +203 -14
- package/dist/constitution/g2Candidates.js +1 -1
- package/dist/constitution/lifecycle.js +17 -0
- package/dist/constitution/plan.js +26 -9
- package/dist/constitution/replacementFreeGit.js +67 -0
- package/dist/constitution/replay.js +6 -0
- package/dist/constitution/replayCache.js +1 -1
- package/dist/constitution/replayWorker.js +1 -1
- package/dist/constitution/repository.js +141 -5
- package/dist/constitution/safeCheckout.js +75 -0
- package/dist/constitution/schema.js +30 -5
- package/dist/constitution/service.js +74 -14
- package/dist/constitution/sourceMutation.js +65 -12
- package/dist/constitution/staticGraphBaseline.js +44 -0
- package/dist/constitution/structural.js +60 -4
- package/dist/core/autoreview.js +1 -1
- package/dist/core/canonicalOrder.js +6 -0
- package/dist/core/conformance.js +68 -27
- package/dist/core/docscan.js +2 -1
- package/dist/core/escalations.js +11 -0
- package/dist/core/io.js +44 -9
- package/dist/core/overlaySafety.js +178 -0
- package/dist/core/paths.js +13 -2
- package/dist/core/safeRepoFile.js +74 -0
- package/dist/extractors/comments.js +6 -8
- package/dist/extractors/git.js +1631 -82
- package/dist/extractors/indexer.js +86 -47
- package/dist/extractors/repoSource.js +390 -0
- package/dist/integrations/ciAction.js +10 -2
- package/dist/integrations/gitignore.js +44 -5
- package/dist/integrations/mergeDriver.js +23 -5
- package/dist/integrations/sync.js +61 -5
- package/dist/integrations/team.js +666 -23
- package/dist/mcp/server.js +261 -34
- package/dist/store/db.js +57 -7
- package/dist/store/hunchStore.js +92 -11
- package/dist/store/jsonStore.js +350 -63
- package/dist/store/schema.js +27 -11
- package/dist/synthesis/provider.js +13 -4
- package/dist/synthesis/synthesize.js +56 -19
- package/dist/wiki/graph.js +5 -4
- package/dist/wiki/wiki.js +16 -10
- package/package.json +15 -3
- package/tooling/competitive-watch.mjs +108 -0
- package/tooling/md1-benchmark.mjs +628 -0
|
@@ -5,46 +5,130 @@
|
|
|
5
5
|
* fresh clone / a new teammate / a headless agent can auto-wire without being told.
|
|
6
6
|
* Written ONLY by `hunch shared --repo <url>` — `hunch private` never publishes its URL.
|
|
7
7
|
*/
|
|
8
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
-
import { join } from "node:path";
|
|
8
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync } from "node:fs";
|
|
9
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
11
|
import { writeFileAtomic } from "../core/io.js";
|
|
12
|
+
import { hunchTreeAttributesAreSafe, safeOverlayGitTreeListing, safeOverlayTree } from "../core/overlaySafety.js";
|
|
12
13
|
import { hunchPaths, hunchPathsForDir } from "../core/paths.js";
|
|
13
|
-
import { mainWorktreeRoot } from "../extractors/git.js";
|
|
14
|
+
import { canonicalRemoteUrl, gitNullDevice, mainWorktreeRoot, sameRemoteUrl } from "../extractors/git.js";
|
|
14
15
|
import { HunchStore } from "../store/hunchStore.js";
|
|
15
16
|
import { JsonStore } from "../store/jsonStore.js";
|
|
16
17
|
import { ensureSharedOverlayPointer } from "./worktree.js";
|
|
18
|
+
import { installMergeDriver } from "./mergeDriver.js";
|
|
19
|
+
import { ensureGitignore } from "./gitignore.js";
|
|
20
|
+
import { resolveInvocation } from "../cli/invocation.js";
|
|
21
|
+
export const DEFAULT_TEAM_REF = "refs/heads/main";
|
|
22
|
+
export function safeTeamRef(value) {
|
|
23
|
+
const ref = value.trim();
|
|
24
|
+
if (!ref.startsWith("refs/heads/") || ref === "refs/heads/")
|
|
25
|
+
return null;
|
|
26
|
+
const checked = spawnSync("git", ["check-ref-format", ref], {
|
|
27
|
+
stdio: "ignore",
|
|
28
|
+
env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1" },
|
|
29
|
+
});
|
|
30
|
+
return checked.status === 0 ? ref : null;
|
|
31
|
+
}
|
|
32
|
+
export function teamSharedRef(team) {
|
|
33
|
+
return team.shared_ref ?? DEFAULT_TEAM_REF;
|
|
34
|
+
}
|
|
17
35
|
/** SECURITY GATE for team.json's URL. team.json is COMMITTED — in a freshly cloned
|
|
18
36
|
* (possibly untrusted) repo it is attacker-controlled, and ensureTeamOverlay auto-clones
|
|
19
37
|
* it on MCP server start. Without this gate a value like `--upload-pack=…` (argument
|
|
20
38
|
* smuggling) or `ext::sh -c …` (git's ext transport) is remote code execution from
|
|
21
|
-
* merely opening a repo. Allow only https
|
|
22
|
-
* and never anything that could parse as a
|
|
39
|
+
* merely opening a repo. Allow only credential-free https://, ssh://, git://,
|
|
40
|
+
* scp-style git@host:path, and never anything that could parse as a Git flag. */
|
|
23
41
|
export function safeGitUrl(url) {
|
|
24
42
|
const u = url.trim();
|
|
25
43
|
if (!u || u.startsWith("-"))
|
|
26
44
|
return null; // flag smuggling
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
45
|
+
// Query strings and fragments are neither needed to locate a Git repository nor
|
|
46
|
+
// safe to publish. Reject them for every accepted form rather than trying to keep
|
|
47
|
+
// an inevitably incomplete list of token/password parameter names.
|
|
48
|
+
if (/[?#]/.test(u))
|
|
49
|
+
return null;
|
|
31
50
|
// A plain absolute path (POSIX / Windows drive / UNC) — a network-mount team store or a
|
|
32
51
|
// local test remote. Safe: a local clone never executes hooks or remote helpers. The
|
|
33
52
|
// file:// URL FORM stays rejected (no legitimate team.json uses it; keeps the gate tight).
|
|
34
53
|
if (u.startsWith("/") || /^[A-Za-z]:[\\/]/.test(u) || u.startsWith("\\\\"))
|
|
35
54
|
return u;
|
|
36
|
-
|
|
55
|
+
// SCP syntax carries an SSH account name, not an embedded authentication secret.
|
|
56
|
+
// Its deliberately narrow account/host grammar cannot encode a password delimiter.
|
|
57
|
+
if (/^[A-Za-z0-9_.-]+@[A-Za-z0-9_.:-]+:[^\s]+$/.test(u) && !u.includes("::"))
|
|
58
|
+
return u; // scp-like, excludes ext::
|
|
59
|
+
// WHATWG URL parsing intentionally repairs forms such as `https:host/path` and
|
|
60
|
+
// backslash-separated HTTPS URLs. Require the exact Git URL shape first so parsing
|
|
61
|
+
// validates an allowlisted form instead of silently broadening the allowlist.
|
|
62
|
+
if (!/^(?:https|ssh|git):\/\/[^\s]+$/i.test(u))
|
|
63
|
+
return null;
|
|
64
|
+
const authority = u.slice(u.indexOf("://") + 3).split("/", 1)[0] ?? "";
|
|
65
|
+
if (!authority || authority.includes("\\"))
|
|
66
|
+
return null;
|
|
67
|
+
let parsed;
|
|
68
|
+
try {
|
|
69
|
+
parsed = new URL(u);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (!new Set(["https:", "ssh:", "git:"]).has(parsed.protocol) || !parsed.hostname)
|
|
75
|
+
return null;
|
|
76
|
+
const at = authority.lastIndexOf("@");
|
|
77
|
+
if (parsed.protocol === "ssh:") {
|
|
78
|
+
// A normal SSH username (`ssh://git@host/repo`) is routing, and remains valid.
|
|
79
|
+
// A colon in its userinfo is a password separator. Check both the raw and decoded
|
|
80
|
+
// spellings so percent-encoding cannot hide the delimiter from this committed gate.
|
|
81
|
+
if (at >= 0) {
|
|
82
|
+
const userinfo = authority.slice(0, at);
|
|
83
|
+
let decodedUserinfo;
|
|
84
|
+
try {
|
|
85
|
+
decodedUserinfo = decodeURIComponent(userinfo);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
if (!userinfo || userinfo.includes(":") || decodedUserinfo.includes(":") || decodedUserinfo.includes("@"))
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (parsed.password)
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
else if (at >= 0 || parsed.username || parsed.password) {
|
|
97
|
+
// HTTPS and unauthenticated git:// have no legitimate committed userinfo.
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return u;
|
|
37
101
|
}
|
|
38
102
|
/** The committed team pointer, or null. Tolerant — an invalid file reads as absent, and
|
|
39
103
|
* a URL that fails the safety gate reads as absent too (never propagated to a consumer). */
|
|
40
104
|
export function readTeamConfig(root) {
|
|
41
105
|
try {
|
|
42
|
-
const
|
|
43
|
-
|
|
106
|
+
const lexicalRoot = resolve(root);
|
|
107
|
+
const rootStat = lstatSync(lexicalRoot);
|
|
108
|
+
if (rootStat.isSymbolicLink() || !rootStat.isDirectory())
|
|
109
|
+
return null;
|
|
110
|
+
const canonicalRoot = realpathSync(lexicalRoot);
|
|
111
|
+
const hunchDir = join(lexicalRoot, ".hunch");
|
|
112
|
+
const hunchStat = lstatSync(hunchDir);
|
|
113
|
+
if (hunchStat.isSymbolicLink() || !hunchStat.isDirectory()
|
|
114
|
+
|| realpathSync(hunchDir) !== join(canonicalRoot, ".hunch"))
|
|
115
|
+
return null;
|
|
116
|
+
const file = join(hunchDir, "team.json");
|
|
117
|
+
const stat = lstatSync(file);
|
|
118
|
+
// team.json is committed attacker input read automatically at startup.
|
|
119
|
+
// Never follow a link/device/FIFO or ingest an unbounded blob merely by
|
|
120
|
+
// opening a repository.
|
|
121
|
+
if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1 || stat.size > 64 * 1024
|
|
122
|
+
|| realpathSync(file) !== join(canonicalRoot, ".hunch", "team.json"))
|
|
44
123
|
return null;
|
|
45
124
|
const v = JSON.parse(readFileSync(file, "utf8"));
|
|
46
125
|
const url = typeof v.shared_repo === "string" ? safeGitUrl(v.shared_repo) : null;
|
|
47
|
-
|
|
126
|
+
if (!url)
|
|
127
|
+
return null;
|
|
128
|
+
if (v.shared_ref === undefined)
|
|
129
|
+
return { shared_repo: url };
|
|
130
|
+
const ref = typeof v.shared_ref === "string" ? safeTeamRef(v.shared_ref) : null;
|
|
131
|
+
return ref ? { shared_repo: url, shared_ref: ref } : null;
|
|
48
132
|
}
|
|
49
133
|
catch {
|
|
50
134
|
return null;
|
|
@@ -52,7 +136,540 @@ export function readTeamConfig(root) {
|
|
|
52
136
|
}
|
|
53
137
|
/** Publish the team's shared-store URL (atomic; committed with the repo). */
|
|
54
138
|
export function writeTeamConfig(root, cfg) {
|
|
55
|
-
|
|
139
|
+
const sharedRepo = safeGitUrl(cfg.shared_repo);
|
|
140
|
+
if (!sharedRepo)
|
|
141
|
+
throw new Error("refusing to write unsafe team repository URL; committed team URLs must be credential-free and cannot contain query or fragment data");
|
|
142
|
+
const sharedRef = cfg.shared_ref === undefined ? undefined : safeTeamRef(cfg.shared_ref);
|
|
143
|
+
if (cfg.shared_ref !== undefined && !sharedRef) {
|
|
144
|
+
throw new Error("refusing to write an unsafe team memory ref; it must be a valid refs/heads/* branch");
|
|
145
|
+
}
|
|
146
|
+
writeFileAtomic(join(hunchPaths(root).hunch, "team.json"), JSON.stringify({ shared_repo: sharedRepo, ...(sharedRef ? { shared_ref: sharedRef } : {}) }, null, 2) + "\n");
|
|
147
|
+
}
|
|
148
|
+
function localGitConfig(root) {
|
|
149
|
+
const result = spawnSync("git", ["-C", root, "config", "--local", "--includes", "--null", "--list"], {
|
|
150
|
+
encoding: "utf8",
|
|
151
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
152
|
+
env: boundedTeamGitEnv(),
|
|
153
|
+
});
|
|
154
|
+
if (result.status !== 0)
|
|
155
|
+
return null;
|
|
156
|
+
const config = new Map();
|
|
157
|
+
for (const entry of result.stdout.split("\0").filter(Boolean)) {
|
|
158
|
+
const newline = entry.indexOf("\n");
|
|
159
|
+
if (newline < 1)
|
|
160
|
+
return null;
|
|
161
|
+
const key = entry.slice(0, newline).toLowerCase();
|
|
162
|
+
const values = config.get(key) ?? [];
|
|
163
|
+
values.push(entry.slice(newline + 1));
|
|
164
|
+
config.set(key, values);
|
|
165
|
+
}
|
|
166
|
+
return config;
|
|
167
|
+
}
|
|
168
|
+
/** Git environment for every shared-route setup operation. Preserve ordinary
|
|
169
|
+
* credential configuration, but discard inherited repository/object selectors
|
|
170
|
+
* and executable transport/prompt/template overrides from the caller. */
|
|
171
|
+
export function boundedTeamGitEnv() {
|
|
172
|
+
const env = { ...process.env };
|
|
173
|
+
const localGitEnv = new Set([
|
|
174
|
+
"GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_CONFIG", "GIT_CONFIG_PARAMETERS", "GIT_CONFIG_COUNT",
|
|
175
|
+
"GIT_OBJECT_DIRECTORY", "GIT_DIR", "GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_GRAFT_FILE",
|
|
176
|
+
"GIT_INDEX_FILE", "GIT_NO_REPLACE_OBJECTS", "GIT_REPLACE_REF_BASE", "GIT_PREFIX",
|
|
177
|
+
"GIT_INTERNAL_SUPER_PREFIX", "GIT_SHALLOW_FILE", "GIT_COMMON_DIR", "GIT_NAMESPACE",
|
|
178
|
+
"GIT_QUARANTINE_PATH", "GIT_PROTOCOL", "GIT_EXEC_PATH", "GIT_TEMPLATE_DIR",
|
|
179
|
+
]);
|
|
180
|
+
for (const key of Object.keys(env)) {
|
|
181
|
+
if (["GIT_SSH", "GIT_SSH_COMMAND", "GIT_PROXY_COMMAND", "GIT_ASKPASS", "SSH_ASKPASS", "SSH_ASKPASS_REQUIRE"].includes(key)
|
|
182
|
+
|| localGitEnv.has(key)
|
|
183
|
+
|| /^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(key))
|
|
184
|
+
delete env[key];
|
|
185
|
+
}
|
|
186
|
+
env.GIT_CONFIG_NOSYSTEM = "1";
|
|
187
|
+
env.GIT_ALLOW_PROTOCOL = "https:ssh:git:file";
|
|
188
|
+
env.GIT_TERMINAL_PROMPT = "0";
|
|
189
|
+
return env;
|
|
190
|
+
}
|
|
191
|
+
/** Return false unless the effective non-system config can be inspected and
|
|
192
|
+
* contains no rewrite whose prefix applies to the exact contract URL.
|
|
193
|
+
* Credential helpers and other harmless global settings remain available; only
|
|
194
|
+
* destination-moving url.*.insteadOf/pushInsteadOf entries are excluded. */
|
|
195
|
+
function effectiveRouteUnrewritten(overlayRoot, fetchUrl, pushUrl) {
|
|
196
|
+
const result = spawnSync("git", ["-C", overlayRoot, "config", "--includes", "--show-scope", "--null", "--list"], {
|
|
197
|
+
encoding: "utf8",
|
|
198
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
199
|
+
env: boundedTeamGitEnv(),
|
|
200
|
+
});
|
|
201
|
+
if (result.status !== 0)
|
|
202
|
+
return false;
|
|
203
|
+
const fetchPrefixes = [];
|
|
204
|
+
const pushPrefixes = [];
|
|
205
|
+
const fields = result.stdout.split("\0").filter(Boolean);
|
|
206
|
+
if (fields.length % 2 !== 0)
|
|
207
|
+
return false;
|
|
208
|
+
for (let i = 1; i < fields.length; i += 2) {
|
|
209
|
+
const entry = fields[i];
|
|
210
|
+
const newline = entry.indexOf("\n");
|
|
211
|
+
if (newline < 1)
|
|
212
|
+
return false;
|
|
213
|
+
const key = entry.slice(0, newline).toLowerCase();
|
|
214
|
+
const value = entry.slice(newline + 1);
|
|
215
|
+
if (/^url\..*\.insteadof$/.test(key))
|
|
216
|
+
fetchPrefixes.push(value);
|
|
217
|
+
if (/^url\..*\.pushinsteadof$/.test(key))
|
|
218
|
+
pushPrefixes.push(value);
|
|
219
|
+
}
|
|
220
|
+
return !fetchPrefixes.some((prefix) => prefix && (fetchUrl.startsWith(prefix) || pushUrl.startsWith(prefix)))
|
|
221
|
+
&& !pushPrefixes.some((prefix) => prefix && pushUrl.startsWith(prefix));
|
|
222
|
+
}
|
|
223
|
+
function overlayBranch(overlayRoot) {
|
|
224
|
+
try {
|
|
225
|
+
const head = readFileSync(join(overlayRoot, ".git", "HEAD"), "utf8").trim();
|
|
226
|
+
const match = head.match(/^ref: refs\/heads\/(.+)$/);
|
|
227
|
+
return match?.[1] ?? null;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function provenRouteAgainst(team, teamUrlCwd, overlayRoot) {
|
|
234
|
+
const config = localGitConfig(overlayRoot);
|
|
235
|
+
const branch = overlayBranch(overlayRoot);
|
|
236
|
+
if (!config || !branch)
|
|
237
|
+
return null;
|
|
238
|
+
const values = (key) => config.get(key.toLowerCase());
|
|
239
|
+
const fetchUrls = values("remote.origin.url");
|
|
240
|
+
const explicitPushUrls = values("remote.origin.pushurl");
|
|
241
|
+
const pushUrls = explicitPushUrls ?? fetchUrls;
|
|
242
|
+
if (fetchUrls?.length !== 1 || pushUrls?.length !== 1)
|
|
243
|
+
return null;
|
|
244
|
+
// The committed URL gate also governs the physical origin values. Identity
|
|
245
|
+
// equivalence alone would accept a credentialed/query-bearing GitHub URL or
|
|
246
|
+
// file:// spelling that normalizes to the advertised repository.
|
|
247
|
+
if (!safeGitUrl(fetchUrls[0]) || !safeGitUrl(pushUrls[0])
|
|
248
|
+
|| !sameRemoteUrl(fetchUrls[0], overlayRoot, team.shared_repo, teamUrlCwd)
|
|
249
|
+
|| !sameRemoteUrl(pushUrls[0], overlayRoot, team.shared_repo, teamUrlCwd)
|
|
250
|
+
|| !effectiveRouteUnrewritten(overlayRoot, fetchUrls[0], pushUrls[0]))
|
|
251
|
+
return null;
|
|
252
|
+
const forbiddenKeys = [
|
|
253
|
+
"remote.origin.push",
|
|
254
|
+
"remote.origin.uploadpack",
|
|
255
|
+
"remote.origin.receivepack",
|
|
256
|
+
"remote.origin.mirror",
|
|
257
|
+
"remote.origin.proxy",
|
|
258
|
+
"core.sshCommand",
|
|
259
|
+
"core.gitProxy",
|
|
260
|
+
"push.default",
|
|
261
|
+
];
|
|
262
|
+
if (forbiddenKeys.some((key) => values(key)?.length))
|
|
263
|
+
return null;
|
|
264
|
+
if ([...config.keys()].some((key) => /^url\..*\.(insteadof|pushinsteadof)$/.test(key)))
|
|
265
|
+
return null;
|
|
266
|
+
const fetchRefspecs = values("remote.origin.fetch");
|
|
267
|
+
if (fetchRefspecs?.length !== 1 || fetchRefspecs[0] !== "+refs/heads/*:refs/remotes/origin/*")
|
|
268
|
+
return null;
|
|
269
|
+
const branchRemote = values(`branch.${branch}.remote`);
|
|
270
|
+
const branchMerge = values(`branch.${branch}.merge`);
|
|
271
|
+
let sharedRef = team.shared_ref ? safeTeamRef(team.shared_ref) : null;
|
|
272
|
+
if (branchRemote || branchMerge) {
|
|
273
|
+
if (branchRemote?.length !== 1 || branchRemote[0] !== "origin"
|
|
274
|
+
|| branchMerge?.length !== 1)
|
|
275
|
+
return null;
|
|
276
|
+
const configuredRef = safeTeamRef(branchMerge[0]);
|
|
277
|
+
if (!configuredRef || (sharedRef && configuredRef !== sharedRef))
|
|
278
|
+
return null;
|
|
279
|
+
sharedRef = configuredRef;
|
|
280
|
+
}
|
|
281
|
+
const refs = spawnSync("git", ["-C", overlayRoot, "for-each-ref", "--format=%(refname)", "refs/remotes/origin"], {
|
|
282
|
+
encoding: "utf8",
|
|
283
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
284
|
+
env: boundedTeamGitEnv(),
|
|
285
|
+
});
|
|
286
|
+
if (refs.status !== 0)
|
|
287
|
+
return null;
|
|
288
|
+
const branches = refs.stdout.split(/\r?\n/)
|
|
289
|
+
.map((line) => line.trim())
|
|
290
|
+
.filter((line) => line && line !== "refs/remotes/origin/HEAD");
|
|
291
|
+
if (branches.length > 1)
|
|
292
|
+
return null;
|
|
293
|
+
const trackedRef = branches.length === 1
|
|
294
|
+
? safeTeamRef(branches[0].replace(/^refs\/remotes\/origin\//, "refs/heads/"))
|
|
295
|
+
: null;
|
|
296
|
+
if (branches.length === 1 && (!trackedRef || (sharedRef && trackedRef !== sharedRef)))
|
|
297
|
+
return null;
|
|
298
|
+
if (!sharedRef) {
|
|
299
|
+
// A pre-shared_ref team file followed the overlay's real canonical branch.
|
|
300
|
+
// Preserve that behavior only when it is unambiguous: one tracked origin
|
|
301
|
+
// branch, or the unborn/current branch for a genuinely empty remote.
|
|
302
|
+
sharedRef = trackedRef ?? safeTeamRef(`refs/heads/${branch}`);
|
|
303
|
+
}
|
|
304
|
+
if (!sharedRef)
|
|
305
|
+
return null;
|
|
306
|
+
for (const key of [`branch.${branch}.pushRemote`, "remote.pushDefault"]) {
|
|
307
|
+
const selected = values(key);
|
|
308
|
+
if (selected && (selected.length !== 1 || selected[0] !== "origin"))
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
return { fetchUrl: fetchUrls[0], pushUrl: pushUrls[0], sharedRef };
|
|
312
|
+
}
|
|
313
|
+
function provenRoute(root, overlayRoot) {
|
|
314
|
+
const team = readTeamConfig(root);
|
|
315
|
+
if (!team)
|
|
316
|
+
return null;
|
|
317
|
+
const route = provenRouteAgainst(team, root, overlayRoot);
|
|
318
|
+
return route ? { team, ...route } : null;
|
|
319
|
+
}
|
|
320
|
+
/** Persist the graph epoch in clone-local Git metadata. If team.json and origin
|
|
321
|
+
* are coherently repointed after a write was admitted, the old checkout must not
|
|
322
|
+
* be reusable as the new graph on reconnect: it may contain a refused local
|
|
323
|
+
* record/commit. A missing binding is a one-time legacy migration after full
|
|
324
|
+
* route proof; an invalid or different binding is never overwritten. */
|
|
325
|
+
function routeBoundToClone(root, overlayRoot, route) {
|
|
326
|
+
const file = join(overlayRoot, ".git", "hunch-team-route.json");
|
|
327
|
+
const expectedRepo = canonicalRemoteUrl(route.team.shared_repo, root);
|
|
328
|
+
if (!safeGitUrl(expectedRepo))
|
|
329
|
+
return false;
|
|
330
|
+
if (!existsSync(file)) {
|
|
331
|
+
try {
|
|
332
|
+
writeFileAtomic(file, `${JSON.stringify({
|
|
333
|
+
version: 1,
|
|
334
|
+
shared_repo: expectedRepo,
|
|
335
|
+
shared_ref: route.sharedRef,
|
|
336
|
+
}, null, 2)}\n`);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
344
|
+
return value.version === 1
|
|
345
|
+
&& typeof value.shared_repo === "string"
|
|
346
|
+
&& !!safeGitUrl(value.shared_repo)
|
|
347
|
+
&& sameRemoteUrl(value.shared_repo, overlayRoot, expectedRepo, overlayRoot)
|
|
348
|
+
&& value.shared_ref === route.sharedRef;
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function boundProvenRoute(root, overlayRoot) {
|
|
355
|
+
const route = provenRoute(root, overlayRoot);
|
|
356
|
+
return route && routeBoundToClone(root, overlayRoot, route) ? route : null;
|
|
357
|
+
}
|
|
358
|
+
/** Prove that the physical overlay reads from and writes to the repository
|
|
359
|
+
* advertised by the committed team config. Path/mode checks alone are not
|
|
360
|
+
* enough: an old healthy clone can otherwise report "current" against a stale
|
|
361
|
+
* origin after team.json changes, silently splitting the team graph.
|
|
362
|
+
*
|
|
363
|
+
* Require one exact fetch URL and one exact push URL for origin, plus an origin
|
|
364
|
+
* upstream/push selector when those branch-level overrides exist. Git's
|
|
365
|
+
* Applicable local or global URL rewrite rules are rejected, so the exact URLs
|
|
366
|
+
* captured from local config remain the URLs handed to Git at the network seam. */
|
|
367
|
+
export function overlayMatchesTeamRemote(root, overlayRoot) {
|
|
368
|
+
return safeOverlayTree(overlayRoot) && !!boundProvenRoute(root, overlayRoot);
|
|
369
|
+
}
|
|
370
|
+
/** Snapshot the effective URLs after proving the committed pointer, local
|
|
371
|
+
* transport configuration, and canonical ref all agree. Sync commands receive
|
|
372
|
+
* this object and re-run `verify` immediately around every network operation. */
|
|
373
|
+
export function teamRemoteContract(root, overlayRoot) {
|
|
374
|
+
if (!safeOverlayTree(overlayRoot))
|
|
375
|
+
return null;
|
|
376
|
+
const route = boundProvenRoute(root, overlayRoot);
|
|
377
|
+
if (!route)
|
|
378
|
+
return null;
|
|
379
|
+
const fetchUrl = canonicalRemoteUrl(route.fetchUrl, overlayRoot);
|
|
380
|
+
const pushUrl = canonicalRemoteUrl(route.pushUrl, overlayRoot);
|
|
381
|
+
const ref = route.sharedRef;
|
|
382
|
+
return {
|
|
383
|
+
// Resolve local relative spellings in the overlay-root context once. The
|
|
384
|
+
// network commands run with `.hunch` as cwd, where handing Git the raw
|
|
385
|
+
// relative value would otherwise name a different repository.
|
|
386
|
+
fetchUrl,
|
|
387
|
+
pushUrl,
|
|
388
|
+
urlCwd: overlayRoot,
|
|
389
|
+
ref,
|
|
390
|
+
// Filesystem/tree safety is proved independently immediately before any
|
|
391
|
+
// materialization. The route check stays deliberately lightweight because
|
|
392
|
+
// it runs around each network seam in long-lived MCP traffic.
|
|
393
|
+
verify: () => {
|
|
394
|
+
const current = boundProvenRoute(root, overlayRoot);
|
|
395
|
+
return !!current && current.sharedRef === ref
|
|
396
|
+
&& sameRemoteUrl(canonicalRemoteUrl(current.fetchUrl, overlayRoot), overlayRoot, fetchUrl, overlayRoot)
|
|
397
|
+
&& sameRemoteUrl(canonicalRemoteUrl(current.pushUrl, overlayRoot), overlayRoot, pushUrl, overlayRoot);
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
/** Setup-time form of the same contract, used before team.json is published.
|
|
402
|
+
* It proves the existing overlay's physical origin/config against the explicit
|
|
403
|
+
* command arguments so attach/refresh cannot traverse an ambient refspec or
|
|
404
|
+
* transport override during the setup command itself. */
|
|
405
|
+
export function explicitTeamRemoteContract(overlayRoot, sharedRepo, sharedRepoCwd, sharedRef) {
|
|
406
|
+
const repo = safeGitUrl(sharedRepo);
|
|
407
|
+
const ref = safeTeamRef(sharedRef);
|
|
408
|
+
if (!repo || !ref || !safeOverlayTree(overlayRoot))
|
|
409
|
+
return null;
|
|
410
|
+
const team = { shared_repo: repo, shared_ref: ref };
|
|
411
|
+
const route = provenRouteAgainst(team, sharedRepoCwd, overlayRoot);
|
|
412
|
+
if (!route)
|
|
413
|
+
return null;
|
|
414
|
+
return {
|
|
415
|
+
fetchUrl: canonicalRemoteUrl(route.fetchUrl, overlayRoot),
|
|
416
|
+
pushUrl: canonicalRemoteUrl(route.pushUrl, overlayRoot),
|
|
417
|
+
urlCwd: overlayRoot,
|
|
418
|
+
ref,
|
|
419
|
+
verify: () => !!provenRouteAgainst(team, sharedRepoCwd, overlayRoot),
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
/** Undefined means this checkout does not advertise team routing. Once the
|
|
423
|
+
* committed file exists, failure to prove it returns an always-refusing
|
|
424
|
+
* contract so a late config change can strand a local commit but can never fall
|
|
425
|
+
* through to ambient `git push`. */
|
|
426
|
+
export function advertisedTeamRemoteContract(root, overlayRoot) {
|
|
427
|
+
// An explicit per-process overlay outranks committed team discovery. Returning
|
|
428
|
+
// no advertised contract lets that selected overlay use its own configured
|
|
429
|
+
// route instead of being locally committed and then permanently stranded by
|
|
430
|
+
// an unrelated team.json destination.
|
|
431
|
+
if (process.env.HUNCH_PRIVATE_DIR?.trim())
|
|
432
|
+
return undefined;
|
|
433
|
+
if (!existsSync(join(hunchPaths(root).hunch, "team.json")))
|
|
434
|
+
return undefined;
|
|
435
|
+
return teamRemoteContract(root, overlayRoot) ?? {
|
|
436
|
+
fetchUrl: "",
|
|
437
|
+
pushUrl: "",
|
|
438
|
+
urlCwd: root,
|
|
439
|
+
ref: "",
|
|
440
|
+
verify: () => false,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
function checkoutIsolatedEnv() {
|
|
444
|
+
return {
|
|
445
|
+
...boundedTeamGitEnv(),
|
|
446
|
+
// The fetch has already completed. Materialization needs no credentials or
|
|
447
|
+
// user customizations, so suppress every ambient filter/attributes source.
|
|
448
|
+
// Git for Windows does not accept Node's native `\\.\nul` spelling as a
|
|
449
|
+
// config pathname; its DOS device name is stable from every working dir.
|
|
450
|
+
GIT_CONFIG_GLOBAL: gitNullDevice(),
|
|
451
|
+
GIT_ATTR_NOSYSTEM: "1",
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
function exactCommit(root, revision, env) {
|
|
455
|
+
const result = spawnSync("git", ["-C", root, "rev-parse", "--verify", `${revision}^{commit}`], {
|
|
456
|
+
encoding: "utf8",
|
|
457
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
458
|
+
env,
|
|
459
|
+
timeout: 2_000,
|
|
460
|
+
});
|
|
461
|
+
const oid = result.status === 0 ? result.stdout.trim() : "";
|
|
462
|
+
return {
|
|
463
|
+
oid: /^[0-9a-f]{40,64}$/i.test(oid) ? oid : null,
|
|
464
|
+
process: result,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
function exactTreeListing(root, oid, env) {
|
|
468
|
+
const result = spawnSync("git", ["-C", root, "ls-tree", "--full-tree", "-r", "-t", "-z", oid], {
|
|
469
|
+
encoding: "utf8",
|
|
470
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
471
|
+
env,
|
|
472
|
+
timeout: 2_000,
|
|
473
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
474
|
+
});
|
|
475
|
+
return result.status === 0 && safeOverlayGitTreeListing(result.stdout) ? result.stdout : null;
|
|
476
|
+
}
|
|
477
|
+
function repositoryHasNoRefs(root, env) {
|
|
478
|
+
const result = spawnSync("git", ["-C", root, "for-each-ref", "--format=%(refname)"], {
|
|
479
|
+
encoding: "utf8",
|
|
480
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
481
|
+
env,
|
|
482
|
+
timeout: 2_000,
|
|
483
|
+
});
|
|
484
|
+
return result.status === 0 && result.stdout.trim() === "";
|
|
485
|
+
}
|
|
486
|
+
function treeAttributesAreSafe(root, listing, env) {
|
|
487
|
+
return hunchTreeAttributesAreSafe(listing, (oid) => {
|
|
488
|
+
const blob = spawnSync("git", ["-C", root, "cat-file", "blob", oid], {
|
|
489
|
+
encoding: "utf8",
|
|
490
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
491
|
+
env,
|
|
492
|
+
timeout: 2_000,
|
|
493
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
494
|
+
});
|
|
495
|
+
return blob.status === 0 ? blob.stdout : null;
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
/** Opt-in, secret-free diagnostics for a transaction that otherwise fails
|
|
499
|
+
* closed as `null`. Never print the remote, path, stderr, or exception text: a
|
|
500
|
+
* committed team URL may still identify private infrastructure. */
|
|
501
|
+
function reportTeamCloneFailure(stage, result, thrown) {
|
|
502
|
+
if (process.env.HUNCH_TEAM_CLONE_DEBUG !== "1")
|
|
503
|
+
return;
|
|
504
|
+
const rawCode = result?.error?.code
|
|
505
|
+
?? thrown?.code;
|
|
506
|
+
const code = typeof rawCode === "string" && /^[A-Z0-9_]+$/.test(rawCode)
|
|
507
|
+
? ` code=${rawCode}`
|
|
508
|
+
: "";
|
|
509
|
+
const status = typeof result?.status === "number" ? ` status=${result.status}` : "";
|
|
510
|
+
process.stderr.write(`[hunch-team-clone] stage=${stage}${status}${code}\n`);
|
|
511
|
+
}
|
|
512
|
+
/** Materialize only one already-fetched, immutable commit. The clone has no
|
|
513
|
+
* worktree yet, so unsafe tree modes or attributes are rejected before any
|
|
514
|
+
* remote-controlled path can invoke a hook/filter or reach disk. */
|
|
515
|
+
function materializeValidatedClone(team, teamRoot, overlayRoot, emptyHooks) {
|
|
516
|
+
const route = provenRouteAgainst(team, teamRoot, overlayRoot);
|
|
517
|
+
if (!route) {
|
|
518
|
+
reportTeamCloneFailure("materialize-route");
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
const sharedRef = route.sharedRef;
|
|
522
|
+
const branch = sharedRef.slice("refs/heads/".length);
|
|
523
|
+
if (!branch || overlayBranch(overlayRoot) !== branch) {
|
|
524
|
+
reportTeamCloneFailure("materialize-branch");
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
const env = checkoutIsolatedEnv();
|
|
528
|
+
const originProbe = exactCommit(overlayRoot, `refs/remotes/origin/${branch}`, env);
|
|
529
|
+
const headProbe = exactCommit(overlayRoot, "HEAD", env);
|
|
530
|
+
const oid = originProbe.oid;
|
|
531
|
+
const head = headProbe.oid;
|
|
532
|
+
// A genuinely empty remote has no object to validate or materialize. Retain
|
|
533
|
+
// its metadata-only clone so a later sole canonical branch can be joined;
|
|
534
|
+
// no remote-controlled working-tree path exists at this point.
|
|
535
|
+
if (!oid || !head) {
|
|
536
|
+
const empty = !oid && !head && repositoryHasNoRefs(overlayRoot, env) && safeOverlayTree(overlayRoot);
|
|
537
|
+
if (!empty) {
|
|
538
|
+
if (!oid)
|
|
539
|
+
reportTeamCloneFailure("materialize-origin-object", originProbe.process);
|
|
540
|
+
if (!head)
|
|
541
|
+
reportTeamCloneFailure("materialize-head-object", headProbe.process);
|
|
542
|
+
if (!oid && !head)
|
|
543
|
+
reportTeamCloneFailure("materialize-empty-proof");
|
|
544
|
+
}
|
|
545
|
+
return empty ? { sharedRef, empty: true } : null;
|
|
546
|
+
}
|
|
547
|
+
if (head !== oid) {
|
|
548
|
+
reportTeamCloneFailure("materialize-object-mismatch");
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
const listing = exactTreeListing(overlayRoot, oid, env);
|
|
552
|
+
if (!listing || !treeAttributesAreSafe(overlayRoot, listing, env)) {
|
|
553
|
+
reportTeamCloneFailure("materialize-tree");
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
const reset = spawnSync("git", [
|
|
557
|
+
"-C", overlayRoot,
|
|
558
|
+
"-c", `core.hooksPath=${emptyHooks}`,
|
|
559
|
+
"-c", `core.attributesFile=${gitNullDevice()}`,
|
|
560
|
+
"reset", "--hard", oid,
|
|
561
|
+
], {
|
|
562
|
+
stdio: "ignore",
|
|
563
|
+
env,
|
|
564
|
+
timeout: 5_000,
|
|
565
|
+
});
|
|
566
|
+
if (reset.status !== 0) {
|
|
567
|
+
reportTeamCloneFailure("materialize-reset", reset);
|
|
568
|
+
return null;
|
|
569
|
+
}
|
|
570
|
+
// Re-prove the immutable object identity and the materialized filesystem.
|
|
571
|
+
// No binding/pointer is written until all three views agree.
|
|
572
|
+
const afterHeadProbe = exactCommit(overlayRoot, "HEAD", env);
|
|
573
|
+
const afterHead = afterHeadProbe.oid;
|
|
574
|
+
const afterListing = afterHead === oid ? exactTreeListing(overlayRoot, oid, env) : null;
|
|
575
|
+
const safe = afterHead === oid
|
|
576
|
+
&& !!afterListing
|
|
577
|
+
&& treeAttributesAreSafe(overlayRoot, afterListing, env)
|
|
578
|
+
&& safeOverlayTree(overlayRoot);
|
|
579
|
+
if (!safe) {
|
|
580
|
+
if (!afterHead)
|
|
581
|
+
reportTeamCloneFailure("materialize-post-reset-head", afterHeadProbe.process);
|
|
582
|
+
reportTeamCloneFailure("materialize-post-reset");
|
|
583
|
+
}
|
|
584
|
+
return safe ? { sharedRef, empty: false } : null;
|
|
585
|
+
}
|
|
586
|
+
/** Clone a shared memory repository without checking out attacker-controlled
|
|
587
|
+
* paths, validate its exact route/OID/tree/attributes, and only then publish the
|
|
588
|
+
* fully materialized clone at `destination`. Failure removes both quarantine and
|
|
589
|
+
* destination so callers cannot accidentally wire a partially validated graph. */
|
|
590
|
+
export function cloneValidatedTeamOverlay(sharedRepo, sharedRepoCwd, destination, opts = {}) {
|
|
591
|
+
const repo = safeGitUrl(sharedRepo);
|
|
592
|
+
const sharedRef = opts.sharedRef === undefined ? undefined : safeTeamRef(opts.sharedRef);
|
|
593
|
+
if (!repo || (opts.sharedRef !== undefined && !sharedRef) || existsSync(destination)) {
|
|
594
|
+
reportTeamCloneFailure("preflight");
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
const requestedTimeout = opts.timeoutMs ?? 5_000;
|
|
598
|
+
const timeoutMs = Number.isFinite(requestedTimeout)
|
|
599
|
+
? Math.min(30_000, Math.max(1, Math.trunc(requestedTimeout)))
|
|
600
|
+
: 5_000;
|
|
601
|
+
const parent = dirname(destination);
|
|
602
|
+
const prefix = basename(destination);
|
|
603
|
+
let stagedDest = "";
|
|
604
|
+
let guardRoot = "";
|
|
605
|
+
let installed = false;
|
|
606
|
+
let accepted = false;
|
|
607
|
+
let stage = "quarantine";
|
|
608
|
+
try {
|
|
609
|
+
stagedDest = mkdtempSync(join(parent, `${prefix}.tmp-`));
|
|
610
|
+
guardRoot = mkdtempSync(join(parent, `${prefix}.guard-`));
|
|
611
|
+
const emptyHooks = join(guardRoot, "hooks");
|
|
612
|
+
const emptyTemplate = join(guardRoot, "template");
|
|
613
|
+
mkdirSync(emptyHooks);
|
|
614
|
+
mkdirSync(emptyTemplate);
|
|
615
|
+
stage = "route-rewrite";
|
|
616
|
+
if (!effectiveRouteUnrewritten(stagedDest, repo, repo)) {
|
|
617
|
+
reportTeamCloneFailure(stage);
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
const cloneEnv = boundedTeamGitEnv();
|
|
621
|
+
stage = "clone";
|
|
622
|
+
const cloned = spawnSync("git", [
|
|
623
|
+
"-c", "protocol.ext.allow=never",
|
|
624
|
+
"-c", `core.hooksPath=${emptyHooks}`,
|
|
625
|
+
"clone", "--no-checkout", `--template=${emptyTemplate}`,
|
|
626
|
+
"--", repo, stagedDest,
|
|
627
|
+
], {
|
|
628
|
+
stdio: "ignore",
|
|
629
|
+
env: cloneEnv,
|
|
630
|
+
timeout: timeoutMs,
|
|
631
|
+
});
|
|
632
|
+
if (cloned.status !== 0) {
|
|
633
|
+
reportTeamCloneFailure(stage, cloned);
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
const validated = materializeValidatedClone({ shared_repo: repo, ...(sharedRef ? { shared_ref: sharedRef } : {}) }, sharedRepoCwd, stagedDest, emptyHooks);
|
|
637
|
+
if (!validated)
|
|
638
|
+
return null;
|
|
639
|
+
stage = "pre-publish-contract";
|
|
640
|
+
if (!explicitTeamRemoteContract(stagedDest, repo, sharedRepoCwd, validated.sharedRef)) {
|
|
641
|
+
reportTeamCloneFailure(stage);
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
stage = "pre-publish-race";
|
|
645
|
+
if (existsSync(destination)) {
|
|
646
|
+
reportTeamCloneFailure(stage);
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
stage = "publish-rename";
|
|
650
|
+
renameSync(stagedDest, destination);
|
|
651
|
+
stagedDest = "";
|
|
652
|
+
installed = true;
|
|
653
|
+
stage = "post-publish-contract";
|
|
654
|
+
if (!explicitTeamRemoteContract(destination, repo, sharedRepoCwd, validated.sharedRef)) {
|
|
655
|
+
reportTeamCloneFailure(stage);
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
accepted = true;
|
|
659
|
+
return validated;
|
|
660
|
+
}
|
|
661
|
+
catch (error) {
|
|
662
|
+
reportTeamCloneFailure(stage ?? "unexpected", undefined, error);
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
finally {
|
|
666
|
+
if (guardRoot)
|
|
667
|
+
rmSync(guardRoot, { recursive: true, force: true });
|
|
668
|
+
if (stagedDest)
|
|
669
|
+
rmSync(stagedDest, { recursive: true, force: true });
|
|
670
|
+
if (installed && !accepted)
|
|
671
|
+
rmSync(destination, { recursive: true, force: true });
|
|
672
|
+
}
|
|
56
673
|
}
|
|
57
674
|
/** Auto-wire this checkout to the team's shared store advertised in `.hunch/team.json`:
|
|
58
675
|
* clone it to the worktree-stable anchor, and register the gitignored local pointer +
|
|
@@ -71,23 +688,49 @@ export function ensureTeamOverlay(root) {
|
|
|
71
688
|
const probe = new HunchStore(hunchPaths(root));
|
|
72
689
|
const configured = probe.privateDir;
|
|
73
690
|
probe.close();
|
|
74
|
-
if (configured && existsSync(configured))
|
|
691
|
+
if (configured && existsSync(configured)) {
|
|
692
|
+
// Upgrade/repair clone-local capabilities on every startup. Older shared
|
|
693
|
+
// overlays predate the merge-driver/runtime-ignore installation; treating
|
|
694
|
+
// an existing pointer as a total no-op would leave those teams permanently
|
|
695
|
+
// vulnerable until they deleted and recloned their memory.
|
|
696
|
+
const configuredRoot = join(configured, "..");
|
|
697
|
+
if (!overlayMatchesTeamRemote(root, configuredRoot))
|
|
698
|
+
return null;
|
|
699
|
+
installMergeDriver(configuredRoot, resolveInvocation().shell);
|
|
700
|
+
ensureGitignore(configuredRoot);
|
|
75
701
|
return null; // already wired and alive
|
|
702
|
+
}
|
|
76
703
|
const anchor = mainWorktreeRoot(root);
|
|
77
704
|
const dest = join(anchor, ".hunch-private");
|
|
78
705
|
if (!existsSync(dest)) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const r = spawnSync("git", ["-c", "protocol.ext.allow=never", "clone", "--", team.shared_repo, dest], {
|
|
83
|
-
stdio: "ignore",
|
|
84
|
-
env: { ...process.env, GIT_ALLOW_PROTOCOL: "https:ssh:git:file", GIT_TERMINAL_PROMPT: "0" },
|
|
706
|
+
const cloned = cloneValidatedTeamOverlay(team.shared_repo, root, dest, {
|
|
707
|
+
sharedRef: team.shared_ref,
|
|
708
|
+
timeoutMs: 5_000,
|
|
85
709
|
});
|
|
86
|
-
if (
|
|
87
|
-
|
|
710
|
+
if (!cloned || !overlayMatchesTeamRemote(root, dest)) {
|
|
711
|
+
if (cloned)
|
|
712
|
+
rmSync(dest, { recursive: true, force: true });
|
|
713
|
+
return null; // offline / invalid / no access — stay unwired, never crash
|
|
714
|
+
}
|
|
88
715
|
}
|
|
716
|
+
// This MUST precede ensureDirs: `.hunch` itself, any Hunch kind directory/file,
|
|
717
|
+
// `.gitignore`, or `.gitattributes` can be a tracked symlink in the remote.
|
|
718
|
+
// Following even one would let merely opening a project create or overwrite
|
|
719
|
+
// files outside the auto-cloned overlay.
|
|
720
|
+
if (!overlayMatchesTeamRemote(root, dest))
|
|
721
|
+
return null;
|
|
89
722
|
const hunchDir = join(dest, ".hunch");
|
|
90
723
|
new JsonStore(hunchPathsForDir(hunchDir)).ensureDirs();
|
|
724
|
+
// `.gitattributes` and the merge driver command are clone-local capabilities:
|
|
725
|
+
// overlay auto-commits deliberately stage only `.hunch/**/*.json`, so the creator's
|
|
726
|
+
// driver configuration never rides the memory remote. Install it for every freshly
|
|
727
|
+
// discovered teammate/agent clone (idempotently) or same-record conflicts would keep
|
|
728
|
+
// aborting forever on machines that did not run `hunch shared` themselves.
|
|
729
|
+
installMergeDriver(dest, resolveInvocation().shell);
|
|
730
|
+
// A real Git conflict runs the Hunch CLI with this overlay as cwd, which can
|
|
731
|
+
// create a derived local SQLite index alongside the shared JSON. Ignore that
|
|
732
|
+
// rebuildable state so it can never poison the JSON-only publication guard.
|
|
733
|
+
ensureGitignore(dest);
|
|
91
734
|
// Merge into any existing local.json (con_8460b6770f): a per-machine autoCommit
|
|
92
735
|
// opt-out must survive the auto-wiring; an unparseable file is left alone.
|
|
93
736
|
const localFile = join(hunchPaths(root).hunch, "local.json");
|