@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.
- package/README.md +28 -1
- package/app/api/projects/clone/route.ts +4 -2
- package/app/api/projects/init/route.test.ts +46 -0
- package/app/api/projects/init/route.ts +24 -4
- package/components/CommandPalette/CommandPalette.tsx +3 -1
- package/components/CommandPalette/useAppCommands.ts +10 -0
- package/components/Projects/AddProject/AddProjectDialog.tsx +25 -5
- package/components/Projects/AddProject/CloneForm.tsx +3 -1
- package/components/Projects/AddProject/FolderForm.tsx +98 -4
- package/components/Projects/AddProject/NameForm.tsx +3 -1
- package/components/Projects/AddProject/WorkspacePick.tsx +56 -0
- package/components/Projects/AddProject/useAddExistingProject.ts +58 -0
- package/components/SessionList/WorkspaceSwitcher.tsx +10 -0
- package/components/SessionList/useDoneAction.ts +2 -1
- package/data/projects/add.ts +23 -3
- package/lib/chat/driver.ts +8 -0
- package/lib/chat/drivers/claude-approvals.ts +40 -0
- package/lib/chat/drivers/claude.ts +7 -10
- package/lib/chat/worker/extras.ts +1 -0
- package/lib/chat/worker/host.ts +4 -0
- package/lib/done/index.ts +1 -8
- package/lib/done/plan.ts +13 -2
- package/lib/done/worktree.ts +55 -33
- package/lib/orchestrator/add-project.ts +35 -0
- package/lib/orchestrator/brief.ts +3 -2
- package/lib/orchestrator/diff.ts +5 -1
- package/lib/orchestrator/serve.ts +6 -0
- package/lib/orchestrator/tool-names.ts +13 -6
- package/lib/orchestrator/tool-schemas.ts +15 -0
- package/lib/orchestrator/tools.ts +2 -0
- package/lib/orchestrator/worker.ts +4 -1
- package/lib/orchestrator/write-guard.ts +295 -0
- package/lib/project-add/index.ts +133 -14
- package/lib/project-add/name.ts +1 -0
- package/lib/secret-paths.ts +9 -0
- package/lib/tasks/finish.ts +8 -2
- package/lib/worktree-removal.ts +115 -0
- package/lib/worktrees.ts +30 -27
- package/package.json +1 -1
- 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
|
+
}
|
package/lib/project-add/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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);
|
package/lib/tasks/finish.ts
CHANGED
|
@@ -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
|
|
79
|
-
|
|
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
|
+
}
|