@saadnvd1/agent-os 0.3.468 → 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/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/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/package.json +1 -1
- package/stores/addProject.ts +12 -3
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/package.json
CHANGED
package/stores/addProject.ts
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import { proxy } from "valtio";
|
|
2
|
+
import { sidebarUi } from "./sidebarUi";
|
|
2
3
|
|
|
3
4
|
export type AddProjectKind = "folder" | "clone" | "name";
|
|
4
5
|
|
|
5
|
-
// The add-project form on show, once a machine and a way are picked.
|
|
6
|
+
// The add-project form on show, once a machine and a way are picked. It
|
|
7
|
+
// adds to the workspace the sidebar is on, changeable in the form.
|
|
6
8
|
export const addProjectUi = proxy<{
|
|
7
|
-
open: {
|
|
9
|
+
open: {
|
|
10
|
+
kind: AddProjectKind;
|
|
11
|
+
hostId: string;
|
|
12
|
+
workspaceId: string | null;
|
|
13
|
+
} | null;
|
|
8
14
|
}>({ open: null });
|
|
9
15
|
|
|
10
16
|
export const addProjectActions = {
|
|
11
17
|
show: (kind: AddProjectKind, hostId: string) => {
|
|
12
|
-
addProjectUi.open = { kind, hostId };
|
|
18
|
+
addProjectUi.open = { kind, hostId, workspaceId: sidebarUi.workspaceId };
|
|
19
|
+
},
|
|
20
|
+
setWorkspace: (workspaceId: string | null) => {
|
|
21
|
+
if (addProjectUi.open) addProjectUi.open.workspaceId = workspaceId;
|
|
13
22
|
},
|
|
14
23
|
close: () => {
|
|
15
24
|
addProjectUi.open = null;
|