@saadnvd1/agent-os 0.3.564 → 0.3.565

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.
@@ -29,6 +29,7 @@ import type { TaskPR } from "../tasks/state";
29
29
  import { run } from "../tasks/gh";
30
30
  import { isAllowedBase } from "../tasks/session";
31
31
  import { git as runGit, gitFetch, revParse } from "../git-exec";
32
+ import { addWorktree } from "../worktree-add";
32
33
  import { comparePatches } from "./patch";
33
34
  import { outputOf, type Runner } from "../stacks/git";
34
35
  import { restackedHeads, stackNotice } from "../stacks/restack";
@@ -289,7 +290,13 @@ async function rebaseAt(
289
290
  ): Promise<{ sha: string }> {
290
291
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aos-rebase-"));
291
292
  try {
292
- await deps.runner("git", ["worktree", "add", "--detach", dir, head], repo);
293
+ await addWorktree(
294
+ (args, cwd) => deps.runner("git", args, cwd),
295
+ repo,
296
+ dir,
297
+ head,
298
+ ["--detach"]
299
+ );
293
300
  try {
294
301
  await deps.runner("git", ["rebase", onto], dir);
295
302
  } catch (error) {
@@ -9,15 +9,9 @@ import type { Session } from "../db";
9
9
  import { SKILL_DIRS } from "../agents/skill-dirs";
10
10
  import { getDefaultBranch } from "../git";
11
11
  import { getProject } from "../projects";
12
- import {
13
- git,
14
- gitFetch,
15
- gitOk,
16
- isGitError,
17
- worktreeAdd,
18
- worktreeRemove,
19
- } from "../git-exec";
12
+ import { git, gitFetch, gitOk, isGitError, worktreeRemove } from "../git-exec";
20
13
  import { expandHome } from "../tasks/session";
14
+ import { addWorktree } from "../worktree-add";
21
15
 
22
16
  export function repoOf(task: Session): string {
23
17
  const project = task.project_id ? getProject(task.project_id) : null;
@@ -169,7 +163,9 @@ export async function checkout(repo: string, sha: string): Promise<string> {
169
163
  const dir = fs.realpathSync(
170
164
  fs.mkdtempSync(path.join(os.tmpdir(), "aos-review-"))
171
165
  );
172
- await worktreeAdd(repo, ["--detach", dir, sha]);
166
+ await addWorktree((args, cwd) => git(cwd, args), repo, dir, sha, [
167
+ "--detach",
168
+ ]);
173
169
  const staged = await git(dir, ["ls-files", "-s"]);
174
170
  for (const line of staged.split("\n"))
175
171
  if (line.startsWith("120000 "))
@@ -10,6 +10,7 @@ import { getRepoName, slugify } from "../git";
10
10
  import { WORKTREES_DIR } from "../worktrees";
11
11
  import { installWorktreeHooks } from "../worktree-hooks";
12
12
  import { git as runGit } from "../git-exec";
13
+ import { addWorktree } from "../worktree-add";
13
14
  import { pushedRef } from "./move";
14
15
 
15
16
  const git = (cwd: string, ...args: string[]) =>
@@ -105,14 +106,12 @@ async function checkoutBranch(
105
106
  if (existing) await git(projectPath, "worktree", "prune");
106
107
  fs.mkdirSync(WORKTREES_DIR, { recursive: true });
107
108
  const worktreePath = worktreeDirFor(projectPath, branch);
108
- await git(
109
+ await addWorktree(
110
+ (args, cwd) => runGit(cwd, args, { timeout: 120000 }),
109
111
  projectPath,
110
- "worktree",
111
- "add",
112
- "-B",
113
- branch,
114
112
  worktreePath,
115
- `origin/${branch}`
113
+ `origin/${branch}`,
114
+ ["-B", branch]
116
115
  );
117
116
  await git(worktreePath, "branch", "--set-upstream-to", `origin/${branch}`);
118
117
  return { worktreePath, reused: false };
@@ -9,6 +9,7 @@
9
9
  import { db, type Session } from "../db";
10
10
  import { escalations } from "../orchestrator/gates";
11
11
  import { taskSubject } from "../orchestrator/asks";
12
+ import { usesGitCrypt } from "../worktree-add";
12
13
 
13
14
  /** Started by an orchestrator, or held for one by the machine it left. */
14
15
  export function isOrchestrated(session: Session): boolean {
@@ -23,6 +24,8 @@ export function isOrchestrated(session: Session): boolean {
23
24
  export function moveRefusal(session: Session): string | null {
24
25
  if (session.quick_of)
25
26
  return "It's a quick change its orchestrator makes on this machine, so it stays here";
27
+ if (usesGitCrypt(session.working_directory))
28
+ return "Its repo is encrypted with git-crypt and the key never leaves this machine, so it stays here";
26
29
  if (escalations(session.id).length)
27
30
  return "It's waiting on you (an escalated gate), so it stays here";
28
31
  if (session.plan_only && session.plan_phase !== "implementing")
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The one place AgentOS runs `git worktree add`. git-crypt looks for its key
3
+ * under the worktree's own git dir, so in a repo that uses it a plain add
4
+ * dies in the smudge filter. There the worktree is made without a checkout,
5
+ * the main repo's git-crypt dir is symlinked in (one copy of the key, so a
6
+ * re-lock or revoke applies everywhere; `git worktree remove` unlinks it and
7
+ * leaves the target), and the files are checked out after. The key is never
8
+ * read, copied or printed.
9
+ */
10
+
11
+ import fs from "fs";
12
+ import path from "path";
13
+
14
+ export type AddRunner = (args: string[], cwd: string) => Promise<string>;
15
+
16
+ export class GitCryptLockedError extends Error {
17
+ constructor(repo: string) {
18
+ super(`${path.basename(repo)} is git-crypt locked; unlock it first`);
19
+ this.name = "GitCryptLockedError";
20
+ }
21
+ }
22
+
23
+ /** The repo's git-crypt dir when it uses git-crypt, else null. */
24
+ async function cryptDir(run: AddRunner, repo: string): Promise<string | null> {
25
+ const common = (await run(["rev-parse", "--git-common-dir"], repo)).trim();
26
+ const dir = path.join(path.resolve(repo, common), "git-crypt");
27
+ return fs.existsSync(dir) ? dir : null;
28
+ }
29
+
30
+ // `git-crypt lock` removes the keys; an unlocked repo has one per key name.
31
+ const isUnlocked = (dir: string) => {
32
+ try {
33
+ return fs.readdirSync(path.join(dir, "keys")).length > 0;
34
+ } catch {
35
+ return false;
36
+ }
37
+ };
38
+
39
+ /**
40
+ * `git worktree add <addArgs…> <dir> <ref>`; `addArgs` is the flags in front
41
+ * of the path (`["-b", branch]`, `["--detach"]`).
42
+ */
43
+ export async function addWorktree(
44
+ run: AddRunner,
45
+ repo: string,
46
+ dir: string,
47
+ ref: string,
48
+ addArgs: string[] = []
49
+ ): Promise<void> {
50
+ const crypt = await cryptDir(run, repo);
51
+ if (!crypt) {
52
+ await run(["worktree", "add", ...addArgs, "--", dir, ref], repo);
53
+ return;
54
+ }
55
+ if (!isUnlocked(crypt)) throw new GitCryptLockedError(repo);
56
+ await run(
57
+ ["worktree", "add", "--no-checkout", ...addArgs, "--", dir, ref],
58
+ repo
59
+ );
60
+ try {
61
+ const gitDir = (await run(["rev-parse", "--absolute-git-dir"], dir)).trim();
62
+ const link = path.join(gitDir, "git-crypt");
63
+ fs.rmSync(link, { force: true });
64
+ fs.symlinkSync(crypt, link, "dir");
65
+ await run(["reset", "--hard", "-q"], dir);
66
+ } catch (error) {
67
+ await run(["worktree", "remove", "-f", "-f", "--", dir], repo).catch(
68
+ () => ""
69
+ );
70
+ throw error;
71
+ }
72
+ }
73
+
74
+ /** Whether a worktree was given the repo's git-crypt key (see addWorktree). */
75
+ export function usesGitCrypt(worktree: string): boolean {
76
+ try {
77
+ const dotGit = path.join(worktree, ".git");
78
+ const gitDir = fs.statSync(dotGit).isDirectory()
79
+ ? dotGit
80
+ : path.resolve(
81
+ worktree,
82
+ fs
83
+ .readFileSync(dotGit, "utf8")
84
+ .replace(/^gitdir:\s*/, "")
85
+ .trim()
86
+ );
87
+ return fs.existsSync(path.join(gitDir, "git-crypt"));
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
package/lib/worktrees.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  } from "./git";
15
15
  import { installWorktreeHooks } from "./worktree-hooks";
16
16
  import { gitResult, isGitError, worktreeList } from "./git-exec";
17
+ import { addWorktree } from "./worktree-add";
17
18
  import {
18
19
  markRemoving,
19
20
  removalKey,
@@ -149,10 +150,12 @@ export async function createWorktree(
149
150
  break;
150
151
  }
151
152
  try {
152
- await git(
153
+ await addWorktree(
154
+ (args, cwd) => git(cwd, args, timeout).then((r) => r.stdout),
153
155
  resolvedProjectPath,
154
- ["worktree", "add", "-b", branchName, "--", worktreePath, ref],
155
- timeout
156
+ worktreePath,
157
+ ref,
158
+ ["-b", branchName]
156
159
  );
157
160
  // Best effort: a worktree without the hook still works, it just
158
161
  // doesn't strip attribution.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.564",
3
+ "version": "0.3.565",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"