@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.
Files changed (33) 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/data/projects/add.ts +23 -3
  15. package/lib/chat/driver.ts +8 -0
  16. package/lib/chat/drivers/claude-approvals.ts +40 -0
  17. package/lib/chat/drivers/claude.ts +7 -10
  18. package/lib/chat/worker/extras.ts +1 -0
  19. package/lib/chat/worker/host.ts +4 -0
  20. package/lib/orchestrator/add-project.ts +35 -0
  21. package/lib/orchestrator/brief.ts +3 -2
  22. package/lib/orchestrator/diff.ts +5 -1
  23. package/lib/orchestrator/serve.ts +6 -0
  24. package/lib/orchestrator/tool-names.ts +13 -6
  25. package/lib/orchestrator/tool-schemas.ts +15 -0
  26. package/lib/orchestrator/tools.ts +2 -0
  27. package/lib/orchestrator/worker.ts +4 -1
  28. package/lib/orchestrator/write-guard.ts +295 -0
  29. package/lib/project-add/index.ts +133 -14
  30. package/lib/project-add/name.ts +1 -0
  31. package/lib/secret-paths.ts +9 -0
  32. package/package.json +1 -1
  33. package/stores/addProject.ts +12 -3
@@ -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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.468",
3
+ "version": "0.3.470",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -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: { kind: AddProjectKind; hostId: string } | null;
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;