@saadnvd1/agent-os 0.3.467 → 0.3.470

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.
Files changed (40) hide show
  1. package/README.md +28 -1
  2. package/app/api/projects/clone/route.ts +4 -2
  3. package/app/api/projects/init/route.test.ts +46 -0
  4. package/app/api/projects/init/route.ts +24 -4
  5. package/components/CommandPalette/CommandPalette.tsx +3 -1
  6. package/components/CommandPalette/useAppCommands.ts +10 -0
  7. package/components/Projects/AddProject/AddProjectDialog.tsx +25 -5
  8. package/components/Projects/AddProject/CloneForm.tsx +3 -1
  9. package/components/Projects/AddProject/FolderForm.tsx +98 -4
  10. package/components/Projects/AddProject/NameForm.tsx +3 -1
  11. package/components/Projects/AddProject/WorkspacePick.tsx +56 -0
  12. package/components/Projects/AddProject/useAddExistingProject.ts +58 -0
  13. package/components/SessionList/WorkspaceSwitcher.tsx +10 -0
  14. package/components/SessionList/useDoneAction.ts +2 -1
  15. package/data/projects/add.ts +23 -3
  16. package/lib/chat/driver.ts +8 -0
  17. package/lib/chat/drivers/claude-approvals.ts +40 -0
  18. package/lib/chat/drivers/claude.ts +7 -10
  19. package/lib/chat/worker/extras.ts +1 -0
  20. package/lib/chat/worker/host.ts +4 -0
  21. package/lib/done/index.ts +1 -8
  22. package/lib/done/plan.ts +13 -2
  23. package/lib/done/worktree.ts +55 -33
  24. package/lib/orchestrator/add-project.ts +35 -0
  25. package/lib/orchestrator/brief.ts +3 -2
  26. package/lib/orchestrator/diff.ts +5 -1
  27. package/lib/orchestrator/serve.ts +6 -0
  28. package/lib/orchestrator/tool-names.ts +13 -6
  29. package/lib/orchestrator/tool-schemas.ts +15 -0
  30. package/lib/orchestrator/tools.ts +2 -0
  31. package/lib/orchestrator/worker.ts +4 -1
  32. package/lib/orchestrator/write-guard.ts +295 -0
  33. package/lib/project-add/index.ts +133 -14
  34. package/lib/project-add/name.ts +1 -0
  35. package/lib/secret-paths.ts +9 -0
  36. package/lib/tasks/finish.ts +8 -2
  37. package/lib/worktree-removal.ts +115 -0
  38. package/lib/worktrees.ts +30 -27
  39. package/package.json +1 -1
  40. package/stores/addProject.ts +12 -3
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Where an orchestrator may write. Anywhere on the machine except the
3
+ * workspace's project repositories and their task worktrees (code changes go
4
+ * through tasks, so the merge gates keep meaning something), AgentOS's own
5
+ * install and state (all of ~/.agent-os but its own folder), secret files,
6
+ * and files that make something run: the dotfiles and dot-folders in its
7
+ * home (shell, git, Claude and tool config), ~/Library, the folders on PATH,
8
+ * and any .git, .claude, .mcp.json, CLAUDE.md or the like. A write to one of
9
+ * those would hand it a shell, or run code in AgentOS's own git, without
10
+ * touching a protected path itself. Its chat worker checks every Write, Edit and NotebookEdit here
11
+ * before it runs (a PreToolUse hook), on the path with every symlink and
12
+ * `..` resolved the way the system will resolve it.
13
+ */
14
+
15
+ import fs from "fs";
16
+ import os from "os";
17
+ import path from "path";
18
+ import { db, type Session } from "../db";
19
+ import { git, GitError } from "../git-exec";
20
+ import { isSecretPath } from "../secret-paths";
21
+ import { WORKTREES_DIR } from "../worktrees";
22
+ import { orchestratorDir } from "./home";
23
+ import { LIMITS_FILE } from "./usage";
24
+
25
+ // The tools that write a file, and the argument naming it.
26
+ export const WRITE_TOOLS: Record<string, string> = {
27
+ Write: "file_path",
28
+ Edit: "file_path",
29
+ MultiEdit: "file_path",
30
+ NotebookEdit: "notebook_path",
31
+ };
32
+
33
+ export interface WriteBounds {
34
+ // Relative paths are taken from here.
35
+ cwd: string;
36
+ // Its own folder, the one part of `state` it may write.
37
+ ownDir: string;
38
+ // AgentOS's state (~/.agent-os).
39
+ state: string;
40
+ // Project repositories, task worktrees and AgentOS's install.
41
+ repos: string[];
42
+ // Its home: no dotfile or dot-folder in it may be written.
43
+ home: string;
44
+ // Folders whose files run (~/Library, PATH, the usage brake's input...).
45
+ runs: string[];
46
+ }
47
+
48
+ // Wherever they are, files that configure what an agent, git or a
49
+ // language runtime runs.
50
+ const RUNS_CODE =
51
+ /(^|\/)(\.git|\.claude|\.codex|\.cursor|\.vscode|\.husky|\.githooks)(\/|$)|(^|\/)(\.mcp\.json|\.claude\.json|CLAUDE(\.local)?\.md|AGENTS\.md|\.envrc|\.gitconfig|\.gitattributes|\.gitmodules|sitecustomize\.py|usercustomize\.py)$|\.pth$/i;
52
+
53
+ // The temporary checkouts AgentOS reviews, rebases and writes PR bodies in
54
+ // (repo.ts, rebase.ts): an edit while a review runs would change what it
55
+ // judges.
56
+ const AGENTOS_TEMP = /(^|\/)aos-(review|rebase|body)-[^/]+(\/|$)/;
57
+
58
+ const MAX_LINKS = 40;
59
+
60
+ // The path as the system resolves it: component by component from the root,
61
+ // each symlink followed where it sits (a dangling one too, since a write
62
+ // creates its target), and `..` taken from the real folder it's in. Parts
63
+ // that don't exist yet are kept as named, under the real folder they'd be
64
+ // made in.
65
+ export async function physicalPath(p: string): Promise<string> {
66
+ const parts = p.split("/").filter(Boolean);
67
+ let at = "/";
68
+ let links = 0;
69
+ while (parts.length) {
70
+ const part = parts.shift()!;
71
+ if (part === ".") continue;
72
+ if (part === "..") {
73
+ at = path.dirname(at);
74
+ continue;
75
+ }
76
+ const next = path.join(at, part);
77
+ const link = await fs.promises.readlink(next).catch(() => null);
78
+ if (link === null) {
79
+ at = next;
80
+ continue;
81
+ }
82
+ if (++links > MAX_LINKS) throw new Error("Too many symlinks");
83
+ parts.unshift(...link.split("/").filter(Boolean));
84
+ if (link.startsWith("/")) at = "/";
85
+ }
86
+ return canonical(at);
87
+ }
88
+
89
+ // Its deepest existing folder as the file system spells it, so a path in
90
+ // another case or Unicode form (one folder on macOS) compares equal.
91
+ async function canonical(p: string): Promise<string> {
92
+ const rest: string[] = [];
93
+ let at = p;
94
+ for (;;) {
95
+ try {
96
+ return path.join(await fs.promises.realpath(at), ...rest.reverse());
97
+ } catch {
98
+ const up = path.dirname(at);
99
+ if (up === at) return p;
100
+ rest.push(path.basename(at));
101
+ at = up;
102
+ }
103
+ }
104
+ }
105
+
106
+ const FOLDS_CASE =
107
+ process.platform === "darwin" || process.platform === "win32";
108
+ const key = (p: string) =>
109
+ FOLDS_CASE ? p.normalize("NFC").toLowerCase() : p.normalize("NFC");
110
+
111
+ const within = (p: string, root: string) => {
112
+ const [a, b] = [key(p), key(root)];
113
+ return a === b || a.startsWith(b === "/" ? b : b + "/");
114
+ };
115
+
116
+ const expandHome = (p: string) => p.replace(/^~(?=$|\/)/, os.homedir());
117
+
118
+ // Why a write to this path is refused, or null when it may go ahead.
119
+ export async function writeRefusal(
120
+ filePath: string,
121
+ bounds: WriteBounds
122
+ ): Promise<string | null> {
123
+ if (!filePath) return "No file path to check.";
124
+ const given = expandHome(filePath);
125
+ const abs = given.startsWith("/") ? given : `${bounds.cwd}/${given}`;
126
+ let targets: string[];
127
+ let repos: string[];
128
+ let state: string[];
129
+ let ownDir: string[];
130
+ let home: string[];
131
+ let runs: string[];
132
+ try {
133
+ // Where it lands as written, and with `..` taken by name first, as a
134
+ // tool that normalises the path before opening it would; and where it
135
+ // was asked for, before any link: all must be allowed. A link sitting in
136
+ // a protected place (a dotfile kept in a dotfiles repo, Homebrew's
137
+ // bin/git) is refused there, wherever it points.
138
+ targets = [
139
+ ...new Set([
140
+ ...(await Promise.all([
141
+ physicalPath(abs),
142
+ physicalPath(path.resolve(abs)),
143
+ ])),
144
+ path.resolve(abs),
145
+ ]),
146
+ ];
147
+ // Each folder by its own name too, for a path that reaches it by name.
148
+ const both = async (p: string) => [path.resolve(p), await physicalPath(p)];
149
+ repos = (await Promise.all(bounds.repos.map(both))).flat();
150
+ runs = (await Promise.all(bounds.runs.map(both))).flat();
151
+ state = await both(bounds.state);
152
+ ownDir = await both(bounds.ownDir);
153
+ home = await both(bounds.home);
154
+ } catch (e) {
155
+ return `Couldn't resolve ${filePath}: ${(e as Error).message}.`;
156
+ }
157
+ for (const target of targets) {
158
+ const repo = repos.find((r) => within(target, r));
159
+ if (repo)
160
+ return `${filePath} is inside ${repo}, a project repository, task worktree or AgentOS itself. Change code there through a task (start_task), so its PR goes through the merge gates.`;
161
+ if (AGENTOS_TEMP.test(target))
162
+ return `${filePath} is in a checkout AgentOS is reviewing or rebasing; orchestrators never write there.`;
163
+ const own = ownDir.some((d) => within(target, d));
164
+ if (state.some((d) => within(target, d)) && !own)
165
+ return `${filePath} is in AgentOS's own state (${bounds.state}); you may write only in your folder, ${bounds.ownDir}.`;
166
+ if (isSecretPath(target) || isSecretPath(filePath))
167
+ return `${filePath} holds or guards secrets; orchestrators never write those.`;
168
+ const runsIn = runs.find((r) => within(target, r));
169
+ if (
170
+ RUNS_CODE.test(target) ||
171
+ RUNS_CODE.test(filePath) ||
172
+ runsIn ||
173
+ (!own &&
174
+ home.some(
175
+ (h) =>
176
+ within(target, h) &&
177
+ key(target)
178
+ .slice(key(h).length + 1)
179
+ .startsWith(".")
180
+ ))
181
+ )
182
+ return `${filePath} configures what runs (shell, git, agent or tool config${runsIn ? `, in ${runsIn}` : ""}); orchestrators never write those.`;
183
+ }
184
+ return null;
185
+ }
186
+
187
+ // What an orchestrator's writes are held to, read fresh for each one so a
188
+ // project added since counts: every local project's repositories (not just
189
+ // its workspace's, since a write to another workspace's repo skips its gates
190
+ // as much), every session's worktree, and AgentOS's install and database.
191
+ export async function orchestratorWriteBounds(
192
+ session: Session
193
+ ): Promise<WriteBounds> {
194
+ const projects = db
195
+ .prepare(
196
+ `SELECT working_directory AS dir FROM projects
197
+ WHERE is_uncategorized = 0 AND host_id = 'local'
198
+ UNION SELECT r.path FROM project_repositories r
199
+ JOIN projects p ON p.id = r.project_id
200
+ WHERE p.host_id = 'local'
201
+ UNION SELECT worktree_path FROM sessions
202
+ WHERE worktree_path IS NOT NULL AND worktree_path != ''`
203
+ )
204
+ .all() as { dir: string }[];
205
+ const dbPath = path.resolve(
206
+ process.env.DB_PATH || path.join(process.cwd(), "agent-os.db")
207
+ );
208
+ const repos = projects.map((p) => expandHome(p.dir)).filter((d) => d !== "");
209
+ return {
210
+ cwd: expandHome(session.working_directory),
211
+ home: os.homedir(),
212
+ runs: [
213
+ path.join(os.homedir(), "Library"),
214
+ "/Library/LaunchAgents",
215
+ "/Library/LaunchDaemons",
216
+ path.dirname(LIMITS_FILE),
217
+ ...(await hooksPaths(repos)),
218
+ ...(process.env.XDG_CONFIG_HOME ? [process.env.XDG_CONFIG_HOME] : []),
219
+ ...(process.env.PATH ?? "").split(":").filter((d) => d.startsWith("/")),
220
+ ],
221
+ ownDir: orchestratorDir(session.workspace_id ?? ""),
222
+ state: path.join(os.homedir(), ".agent-os"),
223
+ repos: [...repos, WORKTREES_DIR, process.cwd(), path.dirname(dbPath)],
224
+ };
225
+ }
226
+
227
+ // Where git looks for hooks instead of .git/hooks (core.hooksPath), in the
228
+ // user's config and each project's: a script there runs on the next commit.
229
+ // Read at most once a minute, not on every write.
230
+ const HOOKS_TTL_MS = 60_000;
231
+ let hooksCache: { key: string; at: number; dirs: string[] } | null = null;
232
+
233
+ export async function hooksPaths(repos: string[]): Promise<string[]> {
234
+ const key = repos.join("\0");
235
+ if (hooksCache?.key === key && Date.now() - hooksCache.at < HOOKS_TTL_MS)
236
+ return hooksCache.dirs;
237
+ // Only "not set" and "not a repository" mean there's nothing to protect;
238
+ // any other failure (a timeout under load) throws, so the write is
239
+ // refused and nothing is cached.
240
+ const run = (cwd: string, args: string[]) =>
241
+ git(cwd, args, { timeout: 5000 })
242
+ .then((out) => out.trim())
243
+ .catch((e: unknown) => {
244
+ if (
245
+ e instanceof GitError &&
246
+ !e.timedOut &&
247
+ (e.exitCode === 1 || /not a git repository/i.test(e.stderr))
248
+ )
249
+ return "";
250
+ throw new Error(`couldn't read git's hooks folder in ${cwd}`);
251
+ });
252
+ // The user's, expanded by git (~ and ~user). A relative one is taken from
253
+ // each repo's top, so it names no one folder: each project's answer
254
+ // below covers it there. Each project's comes as git itself resolves it.
255
+ const global = await run(os.homedir(), [
256
+ "config",
257
+ "--global",
258
+ "--type=path",
259
+ "--get",
260
+ "core.hooksPath",
261
+ ]);
262
+ const found = await Promise.all(
263
+ repos.map(async (r) => {
264
+ // A project whose folder is gone has no hooks to run.
265
+ if (!fs.existsSync(r)) return "";
266
+ const dir = await run(r, [
267
+ "rev-parse",
268
+ "--path-format=absolute",
269
+ "--git-path",
270
+ "hooks",
271
+ ]);
272
+ if (dir !== "" && !dir.startsWith("/"))
273
+ throw new Error(`git named no folder for ${r}'s hooks`);
274
+ return dir;
275
+ })
276
+ );
277
+ const dirs = [
278
+ ...new Set([global, ...found].filter((d) => d.startsWith("/"))),
279
+ ];
280
+ hooksCache = { key, at: Date.now(), dirs };
281
+ return dirs;
282
+ }
283
+
284
+ // The check a PreToolUse hook makes: why this tool call is refused, or null.
285
+ export async function orchestratorToolRefusal(
286
+ session: Session,
287
+ tool: string,
288
+ input: Record<string, unknown>
289
+ ): Promise<string | null> {
290
+ const arg = WRITE_TOOLS[tool];
291
+ if (!arg) return null;
292
+ const filePath = input[arg];
293
+ if (typeof filePath !== "string") return "No file path to check.";
294
+ return writeRefusal(filePath, await orchestratorWriteBounds(session));
295
+ }
@@ -3,15 +3,25 @@
3
3
  * into it, or start a new one from a name (folder, git init, first commit).
4
4
  */
5
5
 
6
+ import os from "os";
6
7
  import path from "path";
7
8
  import { randomUUID } from "crypto";
8
- import { createProject, type ProjectWithRepositories } from "../projects";
9
+ import {
10
+ createProject,
11
+ getProject,
12
+ getProjectWithDevServers,
13
+ type ProjectWithRepositories,
14
+ } from "../projects";
15
+ import { db, queries, type Project } from "../db";
9
16
  import { getHost } from "../hosts";
17
+ import { getWorkspace, setProjectWorkspace } from "../workspaces";
10
18
  import { shellQuote } from "../hosts/ssh";
11
19
  import { run, shellPath, spawnOn } from "./shell";
12
20
  import { onWindowsDrive, WINDOWS_DRIVE_WARNING } from "../wsl";
13
21
 
14
- export const PROJECT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
22
+ import { PROJECT_NAME } from "./name";
23
+
24
+ export { PROJECT_NAME };
15
25
  // https://host/owner/repo(.git), ssh://…, or git@host:owner/repo(.git).
16
26
  const REPO_URL =
17
27
  /^(?:https:\/\/[\w.-]+(?::\d+)?\/|ssh:\/\/[\w.@-]+(?::\d+)?\/|[\w.-]+@[\w.-]+:)[\w./~-]+$/;
@@ -25,6 +35,8 @@ export interface FolderListing {
25
35
  parent: string | null;
26
36
  folders: string[];
27
37
  isGitRepo: boolean;
38
+ // The folder with every symlink resolved, for the secrets check.
39
+ realPath: string;
28
40
  // Set when the folder is somewhere a project shouldn't live (a Windows
29
41
  // drive under WSL).
30
42
  warning: string | null;
@@ -38,28 +50,129 @@ export async function listFolders(
38
50
  checkHost(hostId);
39
51
  const out = await run(
40
52
  hostId,
41
- `cd ${shellPath(dir || "~")} && pwd && { test -e .git && echo GIT || echo NOGIT; } && find . -mindepth 1 -maxdepth 1 -type d ! -name '.*' 2>/dev/null | sort | head -500`
53
+ `cd ${shellPath(dir || "~")} && pwd && pwd -P && { test -e .git && echo GIT || echo NOGIT; } && find . -mindepth 1 -maxdepth 1 -type d ! -name '.*' 2>/dev/null | sort | head -500`
42
54
  );
43
- const [abs, git, ...rest] = out.split("\n");
55
+ const [abs, real, git, ...rest] = out.split("\n");
44
56
  return {
45
57
  path: abs,
46
58
  parent: abs === "/" ? null : path.posix.dirname(abs),
47
59
  folders: rest.filter(Boolean).map((f) => f.replace(/^\.\//, "")),
48
60
  isGitRepo: git === "GIT",
61
+ realPath: real,
49
62
  warning: onWindowsDrive(abs) ? WINDOWS_DRIVE_WARNING : null,
50
63
  };
51
64
  }
52
65
 
66
+ function checkWorkspace(workspaceId: string | null | undefined) {
67
+ if (workspaceId && !getWorkspace(workspaceId))
68
+ throw new Error("Unknown workspace");
69
+ }
70
+
71
+ // Folders that hold keys and credentials are never a project's home.
72
+ const SECRET_DIR =
73
+ /(^|\/)(\.ssh|\.gnupg|\.aws|\.azure|\.kube|\.docker|\.password-store|\.agent-os|Keychains|tokenvault)(\/|$)/i;
74
+
75
+ export function isSecretPath(dir: string): boolean {
76
+ const p = dir.replace(os.homedir(), "~");
77
+ return SECRET_DIR.test(p) || /^~\/\.config(\/|$)/i.test(p);
78
+ }
79
+
80
+ const folderKey = (hostId: string, dir: string) => {
81
+ const abs =
82
+ hostId === "local" ? dir.replace(/^~(?=$|\/)/, os.homedir()) : dir;
83
+ return `${hostId}\0${abs.replace(/\/+$/, "") || "/"}`;
84
+ };
85
+
86
+ // The project that already lives in this folder on this machine, if any.
87
+ export function projectAt(hostId: string, dir: string): Project | null {
88
+ const key = folderKey(hostId, dir);
89
+ const row = (
90
+ queries.getProjectFolders(db).all() as {
91
+ id: string;
92
+ host_id: string;
93
+ working_directory: string;
94
+ is_uncategorized: number;
95
+ }[]
96
+ ).find(
97
+ (p) =>
98
+ !p.is_uncategorized &&
99
+ folderKey(p.host_id || "local", p.working_directory) === key
100
+ );
101
+ return (row && getProject(row.id)) || null;
102
+ }
103
+
104
+ // Adding a folder that's already a project: the caller is told which, and
105
+ // can ask again with moveExisting to bring it into the workspace instead.
106
+ export class ProjectExistsError extends Error {
107
+ constructor(readonly project: Project) {
108
+ super(`${project.name} is already a project`);
109
+ }
110
+ }
111
+
112
+ export interface AddFolderOptions {
113
+ workspaceId?: string | null;
114
+ name?: string;
115
+ // An existing project at the folder moves into workspaceId rather than
116
+ // failing.
117
+ moveExisting?: boolean;
118
+ // ...but only one in no workspace: taking a project from another
119
+ // workspace is refused (the orchestrator's limit).
120
+ moveOnlyUnassigned?: boolean;
121
+ requireGit?: boolean;
122
+ }
123
+
124
+ export interface AddedFolder {
125
+ project: ProjectWithRepositories;
126
+ // What happened: made, or an existing one moved (from its old
127
+ // workspace) or already there.
128
+ outcome: "created" | "moved" | "unchanged";
129
+ from: string | null;
130
+ }
131
+
132
+ // The one way a folder becomes a project in a workspace, for the add
133
+ // dialog and the orchestrator's add_project alike.
53
134
  export async function addFolder(
54
135
  hostId: string,
55
- dir: string
56
- ): Promise<ProjectWithRepositories> {
57
- const { path: abs } = await listFolders(hostId, dir);
58
- return createProject({
59
- name: path.posix.basename(abs) || "project",
60
- workingDirectory: abs,
61
- hostId,
62
- });
136
+ dir: string,
137
+ opts: AddFolderOptions = {}
138
+ ): Promise<AddedFolder> {
139
+ checkHost(hostId);
140
+ checkWorkspace(opts.workspaceId);
141
+ if (isSecretPath(dir)) throw new Error("That folder holds secrets");
142
+ const { path: abs, realPath, isGitRepo } = await listFolders(hostId, dir);
143
+ if (isSecretPath(abs) || isSecretPath(realPath))
144
+ throw new Error("That folder holds secrets");
145
+ if (opts.requireGit && !isGitRepo)
146
+ throw new Error(`${abs} isn't a git repository`);
147
+ const workspaceId = opts.workspaceId ?? null;
148
+ const existing = projectAt(hostId, abs) ?? projectAt(hostId, realPath);
149
+ if (existing) {
150
+ if (!opts.moveExisting) throw new ProjectExistsError(existing);
151
+ const from = existing.workspace_id;
152
+ if (opts.moveOnlyUnassigned && from && from !== workspaceId)
153
+ throw new Error(
154
+ `${existing.name} belongs to the ${getWorkspace(from)?.name ?? "another"} workspace; only Saad moves a project between workspaces`
155
+ );
156
+ const outcome =
157
+ !workspaceId || from === workspaceId ? "unchanged" : "moved";
158
+ if (outcome === "moved") setProjectWorkspace(existing.id, workspaceId);
159
+ return {
160
+ project: getProjectWithDevServers(existing.id) as ProjectWithRepositories,
161
+ outcome,
162
+ from,
163
+ };
164
+ }
165
+ const name = opts.name?.trim() || path.posix.basename(abs) || "project";
166
+ return {
167
+ project: createProject({
168
+ name,
169
+ workingDirectory: abs,
170
+ hostId,
171
+ workspaceId,
172
+ }),
173
+ outcome: "created",
174
+ from: null,
175
+ };
63
176
  }
64
177
 
65
178
  // A machine with no git identity still gets its first commit, under a
@@ -71,9 +184,11 @@ const IDENTITY =
71
184
  export async function initProject(
72
185
  hostId: string,
73
186
  parent: string,
74
- name: string
187
+ name: string,
188
+ workspaceId: string | null = null
75
189
  ): Promise<ProjectWithRepositories> {
76
190
  checkHost(hostId);
191
+ checkWorkspace(workspaceId);
77
192
  if (!PROJECT_NAME.test(name))
78
193
  throw new Error("Use letters, numbers, dots, dashes or underscores");
79
194
  const out = await run(
@@ -84,6 +199,7 @@ export async function initProject(
84
199
  name,
85
200
  workingDirectory: out.trim().split("\n").pop() || name,
86
201
  hostId,
202
+ workspaceId,
87
203
  });
88
204
  }
89
205
 
@@ -119,9 +235,11 @@ export const getCloneJob = (id: string) => jobs.get(id) ?? null;
119
235
  export function startClone(
120
236
  hostId: string,
121
237
  parent: string,
122
- url: string
238
+ url: string,
239
+ workspaceId: string | null = null
123
240
  ): CloneJob {
124
241
  checkHost(hostId);
242
+ checkWorkspace(workspaceId);
125
243
  if (!REPO_URL.test(url)) throw new Error("That isn't a repository URL");
126
244
  const name = repoNameOf(url);
127
245
  if (!name) throw new Error("Couldn't tell the repository's name");
@@ -182,6 +300,7 @@ export function startClone(
182
300
  name,
183
301
  workingDirectory: stdout.trim().split("\n").pop() || name,
184
302
  hostId,
303
+ workspaceId,
185
304
  });
186
305
  job.projectId = project.id;
187
306
  job.status = "done";
@@ -0,0 +1 @@
1
+ export const PROJECT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
@@ -0,0 +1,9 @@
1
+ // Files that hold or guard secrets: env files, keys, credential stores and
2
+ // the folders keys live in (.ssh, .aws, .gnupg, .kube, .docker, gh, gcloud,
3
+ // tokenvault). Tested against a repo-relative or absolute path. A merge that
4
+ // touches one goes to Saad (lib/orchestrator/diff), and an orchestrator may
5
+ // never write one (lib/orchestrator/write-guard).
6
+ export const SECRET_PATH =
7
+ /(^|\/)\.env(\.[^/]*)?$|(^|\/)[^/]*(secret|credential|tokenvault|keychain)[^/]*(\/|$)|\.(pem|key|p12|pfx)$|(^|\/)(id_(rsa|dsa|ecdsa|ed25519)|\.npmrc|\.netrc|\.pgpass)$|(^|\/)\.(ssh|aws|azure|gnupg|kube|docker)(\/|$)|(^|\/)\.config\/(gh|gcloud|op)(\/|$)/i;
8
+
9
+ export const isSecretPath = (p: string): boolean => SECRET_PATH.test(p);
@@ -75,8 +75,14 @@ async function cleanup(
75
75
  export const signingOff = new Set<string>();
76
76
 
77
77
  // What happens after a merge: restack anything stacked on it, then remove the
78
- // session, worktree and branch. Kept per task so Land can wait for it.
79
- const afterMerge = new Map<string, Promise<void>>();
78
+ // session, worktree and branch. Kept per task so Land and done can wait for
79
+ // it; on globalThis because the watcher's merges run in server.ts and done in
80
+ // a route bundle, which load this module separately.
81
+ const g = globalThis as unknown as {
82
+ __agentosAfterMerge?: Map<string, Promise<void>>;
83
+ };
84
+ const afterMerge: Map<string, Promise<void>> = (g.__agentosAfterMerge ??=
85
+ new Map());
80
86
 
81
87
  async function finishMerge(
82
88
  session: Session,
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Removals of a worktree in progress, visible to every reader. `git
3
+ * worktree remove` takes seconds on a tree with cloned dependencies, and a
4
+ * tree read halfway through lists every file it already deleted as an
5
+ * uncommitted change. The removal that a merge's cleanup starts runs in
6
+ * server.ts while done runs in a route bundle (each loads its own copy of
7
+ * a module) or another process, so besides the in-memory record a marker
8
+ * file beside the worktree says a removal is running.
9
+ */
10
+
11
+ import fs from "fs";
12
+ import os from "os";
13
+ import path from "path";
14
+
15
+ export interface Removal {
16
+ done: Promise<void>;
17
+ // The branch the worktree was on, read before anything was removed.
18
+ branch: Promise<string | null>;
19
+ }
20
+
21
+ const g = globalThis as unknown as { __agentosRemovals?: Map<string, Removal> };
22
+ export const removals: Map<string, Removal> = (g.__agentosRemovals ??=
23
+ new Map());
24
+
25
+ export const removalKey = (p: string) =>
26
+ path.resolve(p.replace(/^~/, os.homedir()));
27
+
28
+ // Beside the worktree, not in it: git never lists it and the removal
29
+ // doesn't delete it.
30
+ export const removalMarker = (key: string) =>
31
+ path.join(path.dirname(key), `.${path.basename(key)}.removing`);
32
+
33
+ // A marker older than this, or whose process is gone, is from a removal
34
+ // cut short (a restart) and no longer means anything.
35
+ const STALE_MS = 10 * 60_000;
36
+
37
+ // Returns what it wrote, so only the removal that wrote it clears it.
38
+ export function markRemoving(key: string): string | null {
39
+ const marker = removalMarker(key);
40
+ const tmp = `${marker}.${process.pid}`;
41
+ const body = JSON.stringify({ pid: process.pid, at: Date.now() });
42
+ try {
43
+ fs.writeFileSync(tmp, body);
44
+ fs.renameSync(tmp, marker);
45
+ return body;
46
+ } catch {
47
+ // No marker only means other processes can't see this removal.
48
+ return null;
49
+ }
50
+ }
51
+
52
+ export function unmarkRemoving(key: string, body: string): void {
53
+ const marker = removalMarker(key);
54
+ try {
55
+ if (fs.readFileSync(marker, "utf8") === body) fs.rmSync(marker);
56
+ } catch {
57
+ // Already gone.
58
+ }
59
+ }
60
+
61
+ const alive = (pid: number) => {
62
+ try {
63
+ process.kill(pid, 0);
64
+ return true;
65
+ } catch (e) {
66
+ return (e as NodeJS.ErrnoException).code === "EPERM";
67
+ }
68
+ };
69
+
70
+ function markerLive(key: string): boolean {
71
+ let raw: string;
72
+ try {
73
+ raw = fs.readFileSync(removalMarker(key), "utf8");
74
+ } catch {
75
+ return false;
76
+ }
77
+ try {
78
+ const { pid, at } = JSON.parse(raw) as { pid: number; at: number };
79
+ return Date.now() - at < STALE_MS && alive(pid);
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ // Whether any code path or process is removing this worktree now.
86
+ export const removalRunning = (worktreePath: string) => {
87
+ const key = removalKey(worktreePath);
88
+ return removals.has(key) || markerLive(key);
89
+ };
90
+
91
+ const sleep = (ms: number) =>
92
+ new Promise<void>((r) => setTimeout(r, Math.max(0, ms)));
93
+
94
+ /**
95
+ * Resolves once no removal of this worktree is running: true, or false
96
+ * when one still is after `timeoutMs`. `foreignOnly` waits only for one
97
+ * another process (or a copy of this module) marked.
98
+ */
99
+ export async function removalSettled(
100
+ worktreePath: string,
101
+ timeoutMs = 60_000,
102
+ { foreignOnly = false } = {}
103
+ ): Promise<boolean> {
104
+ const key = removalKey(worktreePath);
105
+ const deadline = Date.now() + timeoutMs;
106
+ for (;;) {
107
+ const own = foreignOnly ? undefined : removals.get(key);
108
+ if (!own && !markerLive(key)) return true;
109
+ const left = deadline - Date.now();
110
+ if (left <= 0) return false;
111
+ await (own
112
+ ? Promise.race([own.done.catch(() => {}), sleep(left)])
113
+ : sleep(Math.min(250, left)));
114
+ }
115
+ }