pi-better-subagents 0.1.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.
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Disposable Git clone workspace preparation for sandboxed subagents.
3
+ *
4
+ * Sandboxed subagents that need to mutate Git cannot safely run in a linked
5
+ * Git worktree: the worktree's `.git` file points back to administrative state
6
+ * under the main repository, outside the sandbox writable root. This module
7
+ * prepares a self-contained clone whose `.git/` directory lives inside the
8
+ * sandbox root.
9
+ *
10
+ * Preferred clone strategy:
11
+ * git clone --reference-if-able <local-reference-repo> --dissociate \
12
+ * <remote-url> <sandbox-workspace>
13
+ *
14
+ * --reference-if-able borrows local objects during setup; --dissociate makes
15
+ * the resulting clone independent of the reference repository afterwards.
16
+ * The clone source prefers the source workspace's upstream remote URL so the
17
+ * disposable workspace's `origin` points at the real remote, not the parent
18
+ * working tree. The local repository is used only as a reference (and as a
19
+ * content fallback when no remote URL is configured).
20
+ */
21
+
22
+ import { execFileSync } from "node:child_process";
23
+ import { existsSync, lstatSync, mkdirSync, realpathSync } from "node:fs";
24
+ import { dirname, join, resolve } from "node:path";
25
+
26
+ import { readGitRemotes, syncGitRemotes, type GitRemote } from "./git-remotes.ts";
27
+
28
+ export { readGitRemotes, syncGitRemotes, type GitRemote };
29
+
30
+ export interface GitWorkspaceInfo {
31
+ /** Absolute path to the working tree root. */
32
+ repoRoot: string;
33
+ /** Absolute path to the git directory for this working tree. */
34
+ gitDir: string;
35
+ /** Absolute path to the common git directory containing the object database. */
36
+ commonGitDir: string;
37
+ /** True when this working tree is a linked worktree (its `.git` is a file). */
38
+ isLinkedWorktree: boolean;
39
+ }
40
+
41
+ function runGit(cwd: string, args: string[], opts?: { encoding?: BufferEncoding; stdio?: any }): string {
42
+ try {
43
+ return execFileSync("git", args, {
44
+ cwd,
45
+ encoding: "utf-8",
46
+ stdio: ["ignore", "pipe", "pipe"],
47
+ ...opts,
48
+ }).trim();
49
+ } catch (err) {
50
+ const message = (err as Error).message ?? String(err);
51
+ throw new Error(`git ${args.join(" ")} failed in ${cwd}: ${message}`);
52
+ }
53
+ }
54
+
55
+ /** Resolve `path` to an absolute, real path. Falls back to resolve() if the path does not exist. */
56
+ function realPath(path: string): string {
57
+ try {
58
+ return realpathSync(path);
59
+ } catch {
60
+ return resolve(path);
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Inspect a directory that is expected to be a Git working tree.
66
+ *
67
+ * Returns whether the directory is a linked worktree and where its Git
68
+ * metadata lives. Throws if the directory is not inside a Git repository.
69
+ */
70
+ export function inspectGitWorkspace(dir: string): GitWorkspaceInfo {
71
+ const repoRoot = runGit(dir, ["rev-parse", "--show-toplevel"]);
72
+ const gitDir = runGit(dir, ["rev-parse", "--path-format=absolute", "--git-dir"]);
73
+ const commonGitDir = runGit(dir, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
74
+
75
+ const gitDot = resolve(repoRoot, ".git");
76
+ const isLinkedWorktree = existsSync(gitDot) && !lstatSync(gitDot).isDirectory();
77
+
78
+ return {
79
+ repoRoot: realPath(repoRoot),
80
+ gitDir: realPath(gitDir),
81
+ commonGitDir: realPath(commonGitDir),
82
+ isLinkedWorktree,
83
+ };
84
+ }
85
+
86
+ /** True when the Git metadata for `info` lives outside `sandboxRoot`. */
87
+ export function isGitMetadataOutsideSandbox(info: GitWorkspaceInfo, sandboxRoot: string): boolean {
88
+ const root = realPath(sandboxRoot);
89
+ const gitDir = info.gitDir;
90
+ // Outside means the git dir is not the sandbox root itself and does not
91
+ // start with the sandbox root followed by a path separator.
92
+ if (gitDir === root) return false;
93
+ const prefix = root.endsWith("/") ? root : `${root}/`;
94
+ return !gitDir.startsWith(prefix);
95
+ }
96
+
97
+ function readCurrentCommit(dir: string): string {
98
+ return runGit(dir, ["rev-parse", "HEAD"]);
99
+ }
100
+
101
+ function readCurrentBranch(dir: string): string | undefined {
102
+ try {
103
+ const branch = runGit(dir, ["rev-parse", "--abbrev-ref", "HEAD"]);
104
+ if (branch && branch !== "HEAD") return branch;
105
+ } catch {
106
+ // Detached HEAD or missing ref.
107
+ }
108
+ return undefined;
109
+ }
110
+
111
+ function readCurrentBranchOrCommit(dir: string): string {
112
+ return readCurrentBranch(dir) ?? readCurrentCommit(dir);
113
+ }
114
+
115
+ /**
116
+ * Build the `git clone` argv for a disposable workspace.
117
+ *
118
+ * Exported so tests can assert the preferred AC5 shape without re-deriving it:
119
+ * `--reference-if-able <local-reference> --dissociate <clone-url> <target>`.
120
+ */
121
+ export function buildGitCloneArgs(options: {
122
+ referenceRepo: string;
123
+ cloneUrl: string;
124
+ targetDir: string;
125
+ }): string[] {
126
+ return [
127
+ "clone",
128
+ "--reference-if-able", options.referenceRepo,
129
+ "--dissociate",
130
+ options.cloneUrl,
131
+ options.targetDir,
132
+ ];
133
+ }
134
+
135
+ /**
136
+ * Choose the clone URL for a disposable workspace.
137
+ *
138
+ * Prefer the source's `origin` fetch URL so the clone's default remote points at
139
+ * the real upstream rather than the parent working tree. Fall back to the local
140
+ * repository path only when no origin URL is configured.
141
+ */
142
+ export function resolveCloneUrl(sourceDir: string, info: GitWorkspaceInfo): string {
143
+ const remotes = readGitRemotes(sourceDir);
144
+ const origin = remotes.find((remote) => remote.name === "origin");
145
+ if (origin?.urls[0]) return origin.urls[0];
146
+ // Any configured remote is still better than rewriting origin to the parent tree.
147
+ if (remotes[0]?.urls[0]) return remotes[0].urls[0];
148
+ return info.repoRoot;
149
+ }
150
+
151
+ /**
152
+ * Ensure the disposable clone has the source's commit and, when applicable, the
153
+ * same branch name checked out. Objects may come from the upstream clone, the
154
+ * local reference, or an explicit fetch from the source working tree.
155
+ */
156
+ /**
157
+ * True when `ref` names a local branch in the source repository (not a tag,
158
+ * remote-tracking ref, or raw commit). Used so an explicit `checkout:"side"`
159
+ * request lands on symbolic branch `side` even when the source is on another branch.
160
+ */
161
+ function isLocalBranchName(dir: string, ref: string): boolean {
162
+ if (!ref || ref === "HEAD" || ref.startsWith("refs/")) return false;
163
+ try {
164
+ runGit(dir, ["show-ref", "--verify", "--quiet", `refs/heads/${ref}`]);
165
+ return true;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ function checkoutSourceRef(options: {
172
+ sourceDir: string;
173
+ targetDir: string;
174
+ info: GitWorkspaceInfo;
175
+ checkout: string;
176
+ }): void {
177
+ const { sourceDir, targetDir, info, checkout } = options;
178
+ const sourceCommit = runGit(sourceDir, ["rev-parse", `${checkout}^{commit}`]);
179
+ const sourceBranch = readCurrentBranch(sourceDir);
180
+
181
+ // Prefer an explicitly requested named branch (checkout:"side") even when the
182
+ // source working tree is currently on a different branch. "HEAD" preserves the
183
+ // source's current branch. Raw commits / tags stay detached.
184
+ let branchName: string | undefined;
185
+ if (checkout === "HEAD") {
186
+ branchName = sourceBranch;
187
+ } else if (isLocalBranchName(sourceDir, checkout)) {
188
+ branchName = checkout;
189
+ }
190
+
191
+ // Fast path: ref already present after the clone.
192
+ try {
193
+ if (branchName) {
194
+ runGit(targetDir, ["checkout", "-B", branchName, sourceCommit]);
195
+ return;
196
+ }
197
+ runGit(targetDir, ["checkout", "--detach", sourceCommit]);
198
+ return;
199
+ } catch {
200
+ // Need objects/refs from the local source workspace.
201
+ }
202
+
203
+ // Fetch the exact commit from the local source. --reference-if-able may already
204
+ // have the objects; this makes the ref available even for local-only commits.
205
+ runGit(targetDir, ["fetch", "--no-tags", info.repoRoot, sourceCommit]);
206
+ const fetched = runGit(targetDir, ["rev-parse", "FETCH_HEAD"]);
207
+ if (branchName) {
208
+ runGit(targetDir, ["checkout", "-B", branchName, fetched]);
209
+ return;
210
+ }
211
+ runGit(targetDir, ["checkout", "--detach", fetched]);
212
+ }
213
+
214
+ export interface SubagentWorkspaceOptions {
215
+ /** Current working directory from the extension context. */
216
+ ctxCwd: string;
217
+ /** Per-call cwd override. */
218
+ cwd?: string;
219
+ /** Per-call sandbox_dir override. */
220
+ sandboxDir?: string;
221
+ /** Whether the caller requested a disposable Git clone workspace. */
222
+ gitCloneWorkspace?: boolean;
223
+ /** Run id, used to build a workspace path under the run directory. */
224
+ runId: string;
225
+ /** Absolute path to the run directory for this subagent. */
226
+ runDirPath: string;
227
+ /** Whether the OS sandbox is enabled for this subagent. */
228
+ sandboxEnabled: boolean;
229
+ }
230
+
231
+ export interface SubagentWorkspace {
232
+ /** Working directory the child should run in. */
233
+ cwd: string;
234
+ /** Sandbox writable root, when sandboxing is enabled. */
235
+ requestedSandboxDir?: string;
236
+ }
237
+
238
+ /**
239
+ * Resolve the subagent's working directory and sandbox root.
240
+ *
241
+ * When `gitCloneWorkspace` is true, the source workspace is cloned into a
242
+ * disposable, self-contained Git workspace under the sandbox root. This keeps
243
+ * Git metadata inside the writable sandbox directory for Git-mutating
244
+ * subagents.
245
+ */
246
+ export function resolveSubagentWorkspace(options: SubagentWorkspaceOptions): SubagentWorkspace {
247
+ let cwd = options.sandboxDir ?? options.cwd ?? options.ctxCwd;
248
+ let requestedSandboxDir: string | undefined = options.sandboxEnabled
249
+ ? (options.sandboxDir ?? cwd)
250
+ : undefined;
251
+
252
+ if (options.gitCloneWorkspace) {
253
+ const sourceDir = options.cwd ?? options.ctxCwd;
254
+ const cloneTarget = options.sandboxDir ?? join(options.runDirPath, "workspace");
255
+ mkdirSync(cloneTarget, { recursive: true });
256
+ cwd = prepareGitCloneWorkspace({ sourceDir, targetDir: cloneTarget });
257
+ if (options.sandboxEnabled) {
258
+ requestedSandboxDir = cwd;
259
+ }
260
+ }
261
+
262
+ return { cwd, requestedSandboxDir };
263
+ }
264
+
265
+ export interface PrepareGitCloneWorkspaceOptions {
266
+ /** Directory of the source working tree to clone. */
267
+ sourceDir: string;
268
+ /** Directory to clone into. Created if missing. */
269
+ targetDir: string;
270
+ /**
271
+ * Branch or commit to checkout after cloning. Defaults to the source's
272
+ * current branch/commit.
273
+ */
274
+ checkout?: string;
275
+ /**
276
+ * Override the local reference repository used with `--reference-if-able`.
277
+ * Intended for tests that exercise unavailable-reference fallback.
278
+ */
279
+ referenceRepo?: string;
280
+ }
281
+
282
+ /**
283
+ * Prepare a disposable, self-contained Git clone workspace for a sandboxed
284
+ * subagent that will mutate Git.
285
+ *
286
+ * The returned directory is a full Git working tree with a real `.git/`
287
+ * directory inside it. No live alternates link to the parent repo remains.
288
+ * Source remotes are preserved so pushes target the real upstream, not the
289
+ * parent working tree.
290
+ */
291
+ /** True when `dir` has a `.git` file pointer (linked worktree layout). */
292
+ function hasLinkedWorktreePointer(dir: string): boolean {
293
+ const gitDot = resolve(dir, ".git");
294
+ try {
295
+ return existsSync(gitDot) && !lstatSync(gitDot).isDirectory();
296
+ } catch {
297
+ return false;
298
+ }
299
+ }
300
+
301
+ export function prepareGitCloneWorkspace(options: PrepareGitCloneWorkspaceOptions): string {
302
+ const sourceDir = realPath(options.sourceDir);
303
+ const targetDir = realPath(options.targetDir);
304
+
305
+ // Detect the linked-worktree layout from the filesystem first so a broken
306
+ // pointer still fails with the AC8 outside-sandbox explanation rather than
307
+ // a generic "not a git repository" error from rev-parse.
308
+ const linkedPointer = hasLinkedWorktreePointer(sourceDir);
309
+
310
+ let info: GitWorkspaceInfo;
311
+ try {
312
+ info = inspectGitWorkspace(sourceDir);
313
+ } catch (err) {
314
+ if (linkedPointer) {
315
+ throw linkedWorktreeError(sourceDir);
316
+ }
317
+ throw new Error(
318
+ `git_clone_workspace requires a Git repository. ${(err as Error).message}`,
319
+ );
320
+ }
321
+
322
+ if (info.isLinkedWorktree && isGitMetadataOutsideSandbox(info, sourceDir)) {
323
+ // A linked worktree's `.git` pointer references metadata outside the
324
+ // source directory. The only safe path is to prepare a writable clone.
325
+ // If we cannot, fail fast rather than launch into a broken sandbox.
326
+ if (!existsSync(info.commonGitDir) || !lstatSync(info.commonGitDir).isDirectory()) {
327
+ throw linkedWorktreeError(sourceDir);
328
+ }
329
+ }
330
+
331
+ mkdirSync(targetDir, { recursive: true });
332
+
333
+ // Use the common git directory as the local reference so linked worktrees
334
+ // benefit from object sharing with the main repository. Callers (tests) may
335
+ // override this to exercise --reference-if-able fallback.
336
+ const referenceRepo = options.referenceRepo ?? info.commonGitDir;
337
+ const checkout = options.checkout ?? readCurrentBranchOrCommit(sourceDir);
338
+ const cloneUrl = resolveCloneUrl(sourceDir, info);
339
+
340
+ // Preferred AC5 shape: remote URL as clone source, local repo as reference.
341
+ // --reference-if-able is best-effort: an unavailable reference falls back to
342
+ // a normal clone. --dissociate removes the alternates link after setup.
343
+ const cloneArgs = buildGitCloneArgs({ referenceRepo, cloneUrl, targetDir });
344
+
345
+ try {
346
+ runGit(dirname(targetDir), cloneArgs, { stdio: ["ignore", "pipe", "pipe"] });
347
+ } catch (err) {
348
+ if (info.isLinkedWorktree) {
349
+ throw linkedWorktreeError(sourceDir);
350
+ }
351
+ throw err;
352
+ }
353
+
354
+ try {
355
+ checkoutSourceRef({ sourceDir, targetDir, info, checkout });
356
+ } catch (err) {
357
+ throw new Error(
358
+ `git_clone_workspace could not checkout ${checkout}: ${(err as Error).message}`,
359
+ );
360
+ }
361
+
362
+ // Verify the clone is self-contained.
363
+ const cloneDotGit = resolve(targetDir, ".git");
364
+ if (!existsSync(cloneDotGit) || !lstatSync(cloneDotGit).isDirectory()) {
365
+ throw new Error(
366
+ `git_clone_workspace produced a workspace without a real .git directory at ${cloneDotGit}`,
367
+ );
368
+ }
369
+ const alternates = resolve(cloneDotGit, "objects", "info", "alternates");
370
+ if (existsSync(alternates)) {
371
+ throw new Error(
372
+ `git_clone_workspace left a live alternates link at ${alternates}; clone is not self-contained`,
373
+ );
374
+ }
375
+
376
+ // Whether we cloned from a remote URL or a local path, force remotes to
377
+ // match the source so pushes never target the parent working tree.
378
+ syncGitRemotes(sourceDir, targetDir);
379
+
380
+ // `git clone` does not copy repo-local config. Preserve identity settings
381
+ // the source already has so Git-mutating producers can commit inside the
382
+ // disposable workspace without reconfiguring author identity.
383
+ copyRepoLocalGitIdentity(sourceDir, targetDir);
384
+
385
+ return targetDir;
386
+ }
387
+
388
+ /**
389
+ * Repo-local Git config keys that Git-mutating producers need for ordinary
390
+ * commits. Only values that are set in the source's local config scope are
391
+ * copied — global/system identity is intentionally not mirrored.
392
+ */
393
+ const REPO_LOCAL_GIT_IDENTITY_KEYS = ["user.name", "user.email", "user.signingkey"] as const;
394
+
395
+ function copyRepoLocalGitIdentity(sourceDir: string, targetDir: string): void {
396
+ for (const key of REPO_LOCAL_GIT_IDENTITY_KEYS) {
397
+ let value: string;
398
+ try {
399
+ value = runGit(sourceDir, ["config", "--local", "--get", key]);
400
+ } catch {
401
+ // Key is not set in the source's repo-local config; leave the clone alone.
402
+ continue;
403
+ }
404
+ if (!value) continue;
405
+ runGit(targetDir, ["config", "--local", key, value]);
406
+ }
407
+ }
408
+
409
+ function linkedWorktreeError(sourceDir: string): Error {
410
+ return new Error(
411
+ `Linked worktree at ${sourceDir} has Git metadata outside the sandbox. ` +
412
+ `Use git_clone_workspace:true to request a disposable clone workspace ` +
413
+ `so the subagent can mutate Git safely.`,
414
+ );
415
+ }
416
+
417
+ /**
418
+ * Verify that a sandboxed Git-mutating request will not launch into a linked
419
+ * worktree whose metadata lives outside the sandbox root.
420
+ *
421
+ * When the caller has explicitly requested a clone workspace, the workspace
422
+ * will be converted; this guard is for callers that requested Git mutation
423
+ * without clone preparation.
424
+ */
425
+ export function assertSafeGitWorkspace(workspaceDir: string, sandboxRoot: string): void {
426
+ const info = inspectGitWorkspace(workspaceDir);
427
+ if (info.isLinkedWorktree && isGitMetadataOutsideSandbox(info, sandboxRoot)) {
428
+ throw linkedWorktreeError(workspaceDir);
429
+ }
430
+ }