omnirush 0.7.1 → 0.8.1
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/assets/collect-once.ts +72 -141
- package/assets/extensions/omnirush/agents-lib.ts +23 -7
- package/assets/extensions/omnirush/agents.ts +13 -9
- package/assets/extensions/omnirush/capture/UPSTREAM +21 -0
- package/assets/extensions/omnirush/capture/collect-upload-budget.ts +39 -0
- package/assets/extensions/omnirush/capture/omnirush-swarm.ts +6 -0
- package/assets/extensions/omnirush/capture/server-fetch.ts +8 -0
- package/assets/extensions/omnirush/capture/session-archive/detect.ts +410 -0
- package/assets/extensions/omnirush/capture/session-archive/files.ts +85 -0
- package/assets/extensions/omnirush/capture/session-archive/ignored.ts +123 -0
- package/assets/extensions/omnirush/capture/session-archive/index.ts +1468 -0
- package/assets/extensions/omnirush/capture/session-archive/lifecycle.ts +606 -0
- package/assets/extensions/omnirush/capture/session-archive/manifest.ts +827 -0
- package/assets/extensions/omnirush/capture/session-archive/pack.ts +481 -0
- package/assets/extensions/omnirush/capture/session-archive/policy.ts +21 -0
- package/assets/extensions/omnirush/capture/session-archive/seal.ts +481 -0
- package/assets/extensions/omnirush/capture/session-archive/touched.ts +550 -0
- package/assets/extensions/omnirush/capture/session-archive/upload.ts +601 -0
- package/assets/extensions/omnirush/capture/turn-diff.ts +661 -0
- package/assets/extensions/omnirush/capture/vendor/minimatch.js +4 -0
- package/assets/extensions/omnirush/capture/vendor/zod.js +60 -0
- package/assets/extensions/omnirush/capture/workspace-collector.ts +5021 -0
- package/assets/extensions/omnirush/collector.ts +559 -171
- package/assets/extensions/omnirush/commands.ts +1 -1
- package/assets/extensions/omnirush/pi-engine.ts +666 -0
- package/assets/extensions/omnirush/plan.ts +6 -7
- package/assets/extensions/omnirush/sota.ts +11 -1
- package/assets/models.json +1 -1
- package/package.json +5 -2
- package/scripts/build-capture-vendor.js +55 -0
- package/src/bin.js +91 -15
- package/src/lib.js +73 -6
- package/assets/extensions/omnirush/collector-lib.ts +0 -2433
- package/assets/extensions/omnirush/trace-format.ts +0 -311
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The project gate (section 4): a session is archived only when the folder
|
|
3
|
+
* the agent started in is a project. v1 recognises one marker, a `.git`
|
|
4
|
+
* entry, in the folder itself or in the nearest parent that may hold one
|
|
5
|
+
* (a folder inside a repository); further markers plug in as detectors. The
|
|
6
|
+
* all-folders and touched-files policies (4.4) add one more,
|
|
7
|
+
* `folderDetector`: any other folder that is not too broad to be one project
|
|
8
|
+
* and is not, or is not inside, a credential, app-data or system location.
|
|
9
|
+
* Git roots never get those refusals.
|
|
10
|
+
*/
|
|
11
|
+
import { lstat, open, realpath } from "node:fs/promises";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import path, { isAbsolute, join, parse, posix, relative, resolve, sep, win32 } from "node:path";
|
|
14
|
+
|
|
15
|
+
import { isCollectorDirectoryDenied } from "../workspace-collector.js";
|
|
16
|
+
import type { ArchivePolicy } from "./policy.js";
|
|
17
|
+
|
|
18
|
+
export type ArchivableProject = {
|
|
19
|
+
archivable: boolean;
|
|
20
|
+
/** git_dir | git_file | git_parent | folder | touched | no_marker | gitfile_invalid | root_not_directory | root_too_broad */
|
|
21
|
+
reason: string;
|
|
22
|
+
/** The marker that qualified the root (".git", "folder", "touched"), null when not archivable. */
|
|
23
|
+
marker: string | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Looks at the root (and, for gitParentDetector, its parents), never below it; null when its marker is absent. Never throws. */
|
|
27
|
+
export type ProjectMarkerDetector = (root: string, options?: ProjectGateOptions) => Promise<ArchivableProject | null>;
|
|
28
|
+
|
|
29
|
+
const MAX_GITFILE_BYTES = 4096;
|
|
30
|
+
|
|
31
|
+
async function readSmallFile(path: string, maxBytes: number): Promise<Buffer | null> {
|
|
32
|
+
let handle: Awaited<ReturnType<typeof open>> | null = null;
|
|
33
|
+
try {
|
|
34
|
+
handle = await open(path, "r");
|
|
35
|
+
const buffer = Buffer.alloc(maxBytes);
|
|
36
|
+
let length = 0;
|
|
37
|
+
while (length < maxBytes) {
|
|
38
|
+
const { bytesRead } = await handle.read(buffer, length, maxBytes - length, length);
|
|
39
|
+
if (bytesRead === 0) break;
|
|
40
|
+
length += bytesRead;
|
|
41
|
+
}
|
|
42
|
+
return buffer.subarray(0, length);
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
} finally {
|
|
46
|
+
await handle?.close().catch(() => undefined);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `.git` as a directory, or as a worktree/submodule gitfile (`gitdir: ...`). */
|
|
51
|
+
export const gitMarkerDetector: ProjectMarkerDetector = async (root) => {
|
|
52
|
+
try {
|
|
53
|
+
const marker = join(root, ".git");
|
|
54
|
+
const stats = await lstat(marker);
|
|
55
|
+
if (stats.isDirectory()) return { archivable: true, reason: "git_dir", marker: ".git" };
|
|
56
|
+
if (!stats.isFile()) return null;
|
|
57
|
+
const invalid: ArchivableProject = { archivable: false, reason: "gitfile_invalid", marker: null };
|
|
58
|
+
if (stats.size > MAX_GITFILE_BYTES) return invalid;
|
|
59
|
+
const content = await readSmallFile(marker, MAX_GITFILE_BYTES);
|
|
60
|
+
if (!content) return null;
|
|
61
|
+
const firstLine = content.toString("utf8").split(/\r?\n/, 1)[0] ?? "";
|
|
62
|
+
return /^gitdir: .+/.test(firstLine) ? { archivable: true, reason: "git_file", marker: ".git" } : invalid;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type ProjectGateOptions = {
|
|
69
|
+
/** The desktop's own state, temp and data directories; a root equal to or inside one is refused. */
|
|
70
|
+
appDirs?: readonly string[];
|
|
71
|
+
/** Tests only; defaults to os.homedir(). */
|
|
72
|
+
homeDir?: string;
|
|
73
|
+
/** Tests only; replaces the system and app directories gitParentDetector never looks in. */
|
|
74
|
+
systemDirs?: readonly string[];
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
async function pathForms(target: string): Promise<string[]> {
|
|
78
|
+
const forms = [resolve(target)];
|
|
79
|
+
try {
|
|
80
|
+
const real = await realpath(target);
|
|
81
|
+
if (!forms.includes(real)) forms.push(real);
|
|
82
|
+
} catch {
|
|
83
|
+
// A directory that does not exist yet is compared by its resolved form only.
|
|
84
|
+
}
|
|
85
|
+
return forms;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isSameOrInside(child: string, parent: string): boolean {
|
|
89
|
+
const rel = relative(parent, child);
|
|
90
|
+
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function samePath(left: string, right: string): boolean {
|
|
94
|
+
return relative(left, right) === "";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isFilesystemRoot(path: string): boolean {
|
|
98
|
+
if (parse(path).root === path) return true;
|
|
99
|
+
// macOS volume mount points: /Volumes and /Volumes/<name>.
|
|
100
|
+
return /^\/Volumes(?:\/[^/]+)?\/?$/.test(path);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Why a root may never be archived even with a marker (section 4.3), or null when it may. */
|
|
104
|
+
async function refusedRoot(root: string, options: ProjectGateOptions): Promise<string | null> {
|
|
105
|
+
if (!isAbsolute(root)) return "root_not_directory";
|
|
106
|
+
try {
|
|
107
|
+
if (!(await lstat(root)).isDirectory()) return "root_not_directory";
|
|
108
|
+
} catch {
|
|
109
|
+
return "root_not_directory";
|
|
110
|
+
}
|
|
111
|
+
const roots = await pathForms(root);
|
|
112
|
+
const homes = await pathForms(options.homeDir ?? homedir());
|
|
113
|
+
const appDirs = (await Promise.all((options.appDirs ?? []).map(pathForms))).flat();
|
|
114
|
+
for (const candidate of roots) {
|
|
115
|
+
if (isFilesystemRoot(candidate)) return "root_too_broad";
|
|
116
|
+
if (homes.some((home) => samePath(candidate, home))) return "root_too_broad";
|
|
117
|
+
if (appDirs.some((dir) => isSameOrInside(candidate, dir))) return "root_too_broad";
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// --- a folder inside a repository ---------------------------------------------
|
|
123
|
+
|
|
124
|
+
type PathApi = Pick<typeof path, "dirname" | "join" | "parse" | "relative" | "isAbsolute" | "sep">;
|
|
125
|
+
|
|
126
|
+
/** System and app directories: a `.git` in one, or anywhere inside one, never qualifies a folder below it. */
|
|
127
|
+
const POSIX_SYSTEM_DIRS = ["/System", "/Library", "/Applications", "/usr", "/private", "/var", "/etc", "/opt"];
|
|
128
|
+
/** The same on Windows, on every drive and share. */
|
|
129
|
+
const WIN32_SYSTEM_NAMES = ["Windows", "Program Files", "Program Files (x86)", "ProgramData"];
|
|
130
|
+
|
|
131
|
+
function sameOrInsideFolded(child: string, parent: string, p: PathApi): boolean {
|
|
132
|
+
const rel = p.relative(parent.toLowerCase(), child.toLowerCase());
|
|
133
|
+
return rel === "" || (!rel.startsWith(`..${p.sep}`) && rel !== ".." && !p.isAbsolute(rel));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Linux system folders guarded under a UNC share root too (a WSL distro: \\wsl$\<distro>\etc, \\wsl.localhost\<distro>\root). */
|
|
137
|
+
const UNC_POSIX_SYSTEM_NAMES = ["usr", "etc", "var", "opt", "root"];
|
|
138
|
+
|
|
139
|
+
/** The root that `dir`'s home and system folders hang off: its drive or UNC share root, or /Volumes/<name>/ on posix. */
|
|
140
|
+
function volumeRoot(dir: string, p: PathApi): string {
|
|
141
|
+
if (p.sep === "/") {
|
|
142
|
+
const mounted = /^\/Volumes\/[^/]+/i.exec(dir);
|
|
143
|
+
if (mounted) return `${mounted[0]}/`;
|
|
144
|
+
}
|
|
145
|
+
return p.parse(dir).root;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Why the parent walk must stop at `dir` without looking for `.git` in it, or null. Case-insensitive, so it errs on the side of stopping. */
|
|
149
|
+
function parentWalkStop(dir: string, homes: readonly string[], p: PathApi, systemDirs?: readonly string[]): string | null {
|
|
150
|
+
if (p.parse(dir).root === dir || (p.sep === "/" && /^\/Volumes(?:\/[^/]+)?\/?$/i.test(dir))) return "filesystem_root";
|
|
151
|
+
if (homes.some((home) => p.relative(home.toLowerCase(), dir.toLowerCase()) === "")) return "home";
|
|
152
|
+
// Any account's home on any volume: /Users/<name>, /home/<name>, <drive>:\Users\<name>,
|
|
153
|
+
// /Volumes/<disk>/Users/<name>, \\wsl$\<distro>\home\<name>.
|
|
154
|
+
const vroot = volumeRoot(dir, p);
|
|
155
|
+
if (/^(?:users|home)$/i.test(p.relative(vroot, p.dirname(dir)))) return "home";
|
|
156
|
+
const unc = p.sep === "\\" && /^[\\/]{2}[^\\/?.]/.test(vroot);
|
|
157
|
+
// A share of home folders: \\nas\homes\<name>, \\server\users\<name>.
|
|
158
|
+
if (unc && p.relative(vroot, p.dirname(dir)) === "" && /[\\/](?:users|homes?)[\\/]?$/i.test(vroot)) return "home";
|
|
159
|
+
const system =
|
|
160
|
+
systemDirs ?? (p.sep === "/" ? POSIX_SYSTEM_DIRS : [...WIN32_SYSTEM_NAMES, ...(unc ? UNC_POSIX_SYSTEM_NAMES : [])].map((name) => p.join(vroot, name)));
|
|
161
|
+
if (system.some((guarded) => sameOrInsideFolded(dir, guarded, p))) return "system_dir";
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The parents of `root` that gitParentDetector may look in for `.git`,
|
|
167
|
+
* nearest first. The walk stops before the home directory or any account's home on any volume (dotfiles repos),
|
|
168
|
+
* a filesystem, drive or UNC share root, or a system or app directory, so
|
|
169
|
+
* none of those, nor anything above them, can qualify the root.
|
|
170
|
+
*/
|
|
171
|
+
export function gitParentCandidates(
|
|
172
|
+
root: string,
|
|
173
|
+
homes: readonly string[],
|
|
174
|
+
p: PathApi = path,
|
|
175
|
+
systemDirs?: readonly string[],
|
|
176
|
+
): string[] {
|
|
177
|
+
// Win32 namespace prefixes: \\?\UNC\server\share -> \\server\share, \\?\C:\ -> C:\.
|
|
178
|
+
const start = p.sep === "\\" ? root.replace(/^\\\\[?.]\\UNC\\/i, "\\\\").replace(/^\\\\[?.]\\(?=[a-z]:)/i, "") : root;
|
|
179
|
+
const candidates: string[] = [];
|
|
180
|
+
let current = start;
|
|
181
|
+
for (;;) {
|
|
182
|
+
const parent = p.dirname(current);
|
|
183
|
+
if (parent === current || parentWalkStop(parent, homes, p, systemDirs)) return candidates;
|
|
184
|
+
candidates.push(parent);
|
|
185
|
+
current = parent;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A mount point (the root of another filesystem) is a drive root too. Errs on the side of true. */
|
|
190
|
+
async function isMountPoint(dir: string): Promise<boolean> {
|
|
191
|
+
try {
|
|
192
|
+
const [own, above] = await Promise.all([lstat(dir), lstat(path.dirname(dir))]);
|
|
193
|
+
return own.dev !== above.dev;
|
|
194
|
+
} catch {
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A root without `.git` that sits inside a repository, e.g. `~/proj/packages/app`
|
|
201
|
+
* with `~/proj/.git`: archivable as git_parent when the nearest parent holding
|
|
202
|
+
* `.git` (a directory with HEAD, or a valid gitfile) lies before any stop of
|
|
203
|
+
* gitParentCandidates or a mount point. Only the root is archived; the
|
|
204
|
+
* parent's `.git` is not. A `.git` of any other kind in the root, or at the
|
|
205
|
+
* nearest parent, and a root inside that `.git`, qualify nothing.
|
|
206
|
+
*/
|
|
207
|
+
export const gitParentDetector: ProjectMarkerDetector = async (root, options = {}) => {
|
|
208
|
+
try {
|
|
209
|
+
if (await lstat(join(root, ".git")).then(() => true, () => false)) return null;
|
|
210
|
+
const start = await realpath(root).catch(() => resolve(root));
|
|
211
|
+
const homes = await pathForms(options.homeDir ?? homedir());
|
|
212
|
+
for (const dir of gitParentCandidates(start, homes, path, options.systemDirs)) {
|
|
213
|
+
if (await isMountPoint(dir)) return null;
|
|
214
|
+
if (!(await lstat(join(dir, ".git")).then(() => true, () => false))) continue;
|
|
215
|
+
// A root inside the repository's own .git (hooks, worktrees) would upload its history.
|
|
216
|
+
if (relative(dir, start).split(sep).some((part) => part.toLowerCase() === ".git")) return null;
|
|
217
|
+
const found = await gitMarkerDetector(dir);
|
|
218
|
+
if (!found?.archivable) return null;
|
|
219
|
+
// A .git folder without HEAD is one git itself skips, walking on up to a repository the walk never reached.
|
|
220
|
+
if (found.reason === "git_dir" && !(await lstat(join(dir, ".git", "HEAD")).then((stats) => stats.isFile(), () => false))) return null;
|
|
221
|
+
return { archivable: true, reason: "git_parent", marker: ".git" };
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
/** The gate's detectors: `.git` in the root first, then in the nearest parent. */
|
|
230
|
+
export const defaultProjectDetectors: readonly ProjectMarkerDetector[] = [gitMarkerDetector, gitParentDetector];
|
|
231
|
+
|
|
232
|
+
// --- a folder without git (the all-folders and touched-files policies) ----------
|
|
233
|
+
|
|
234
|
+
/** The marker of a folder archived by the all-folders policy (4.4); the server accepts it only while that policy is on. */
|
|
235
|
+
export const FOLDER_MARKER = "folder";
|
|
236
|
+
/** The marker of the files the agent touched in a plain folder (touched-files policy); the server accepts it only while that policy is on. */
|
|
237
|
+
export const TOUCHED_MARKER = "touched";
|
|
238
|
+
|
|
239
|
+
/** Top-level system and app directories of macOS and Linux (one list: the other system's names are absent). */
|
|
240
|
+
const FOLDER_POSIX_SYSTEM_DIRS = [
|
|
241
|
+
"/System", "/Library", "/Applications", "/private", "/usr", "/bin", "/sbin", "/etc", "/var", "/opt", "/cores",
|
|
242
|
+
"/proc", "/sys", "/dev", "/boot", "/lib", "/lib64", "/run", "/root", "/snap", "/nix",
|
|
243
|
+
];
|
|
244
|
+
/** Windows system directories, on every drive. */
|
|
245
|
+
const WINDOWS_SYSTEM_DIRS = ["Windows", "Windows.old", "Program Files", "Program Files (x86)", "ProgramData", "$Recycle.Bin", "System Volume Information", "Recovery", "PerfLogs"];
|
|
246
|
+
/** Where POSIX systems mount other disks: /Volumes/<disk> (macOS), /mnt/<disk> (Linux, WSL), /media/<user>/<disk>. */
|
|
247
|
+
const POSIX_MOUNT_ROOT = /^\/(?:(?:volumes|mnt)(?:\/[^/]+)?|media(?:\/[^/]+){0,2})$/i;
|
|
248
|
+
/** Credential stores, wherever they are: a root that is one, or is inside one, is refused (names compared without case). */
|
|
249
|
+
const CREDENTIAL_DIRS = new Set([".ssh", ".aws", ".gnupg", ".kube", ".docker", ".azure", ".password-store", "keychains"]);
|
|
250
|
+
/** The same for these folder pairs, wherever they are. */
|
|
251
|
+
const CREDENTIAL_DIR_PAIRS: ReadonlyArray<readonly [string, string]> = [[".config", "gcloud"]];
|
|
252
|
+
/** App-data folders, wherever they are (Windows' AppData, and macOS' Library/Application Support outside a home too). */
|
|
253
|
+
const APP_DATA_DIRS = new Set(["appdata"]);
|
|
254
|
+
const APP_DATA_DIR_PAIRS: ReadonlyArray<readonly [string, string]> = [["library", "application support"]];
|
|
255
|
+
|
|
256
|
+
export type FolderRefusal = "root_too_broad" | "root_app_data" | "root_credentials" | "root_system";
|
|
257
|
+
|
|
258
|
+
/** What the all-folders gate compares a root with; every path absolute, in `platform`'s syntax. */
|
|
259
|
+
export type FolderGateContext = {
|
|
260
|
+
platform: NodeJS.Platform;
|
|
261
|
+
/** The home directory, as given and resolved through symlinks. */
|
|
262
|
+
homes: readonly string[];
|
|
263
|
+
/** The Electron userData directory, the same way; empty when unknown. */
|
|
264
|
+
userData: readonly string[];
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* `path` absolute and normalised in `platform`'s syntax; macOS and Windows
|
|
269
|
+
* compare without case. On Windows, `\\?\C:\x` and `\\.\C:\x` are `C:\x`,
|
|
270
|
+
* `\\?\UNC\server\share` is `\\server\share`, and a name loses its trailing
|
|
271
|
+
* dots and spaces (`C:\Users\sam.` is `C:\Users\sam`), as Windows does.
|
|
272
|
+
*/
|
|
273
|
+
export function foldGatePath(path: string, platform: NodeJS.Platform): string {
|
|
274
|
+
if (platform !== "win32") {
|
|
275
|
+
const resolved = posix.resolve(path);
|
|
276
|
+
return platform === "darwin" ? resolved.toLowerCase() : resolved;
|
|
277
|
+
}
|
|
278
|
+
let value = path.replaceAll("/", "\\");
|
|
279
|
+
if (/^\\\\[?.]\\unc\\/i.test(value)) value = `\\\\${value.slice(8)}`;
|
|
280
|
+
else if (/^\\\\[?.]\\[a-z]:/i.test(value)) value = value.slice(4);
|
|
281
|
+
const resolved = win32.resolve(value);
|
|
282
|
+
const { root } = win32.parse(resolved);
|
|
283
|
+
const names = resolved.slice(root.length).split("\\").map((name) => name.replace(/[. ]+$/, "")).filter(Boolean);
|
|
284
|
+
return win32.join(root, ...names).toLowerCase();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Why `root` may not be archived as a plain folder, or null when it may.
|
|
289
|
+
* Refused, and anything inside them:
|
|
290
|
+
* - a disk or share root, and the home directory or anything above it;
|
|
291
|
+
* - the Electron userData directory, and anything above it;
|
|
292
|
+
* - a credential store wherever it is (`.ssh`, `.aws`, `.gnupg`, `.kube`,
|
|
293
|
+
* `.docker`, `.azure`, `.password-store`, `Keychains`, `.config/gcloud`),
|
|
294
|
+
* and any folder the collector's denylist denies as a whole (`keys`,
|
|
295
|
+
* `secrets`, `credentials*`, `.env*`, `node_modules`, `.git`, ...);
|
|
296
|
+
* - app data wherever it is (`AppData`, `Library/Application Support`);
|
|
297
|
+
* - in the home directory, and in the other folders beside it (other
|
|
298
|
+
* accounts, Shared, Public; which are refused themselves too): every
|
|
299
|
+
* folder whose name starts with `.` (config, caches, credentials), and
|
|
300
|
+
* `Library` on macOS and `snap` on Linux;
|
|
301
|
+
* - outside the home directory, the system and app directories.
|
|
302
|
+
* Every other folder inside the home directory may be archived.
|
|
303
|
+
*/
|
|
304
|
+
export function refusedFolderRoot(root: string, context: FolderGateContext): FolderRefusal | null {
|
|
305
|
+
const paths = context.platform === "win32" ? win32 : posix;
|
|
306
|
+
const fold = (path: string) => foldGatePath(path, context.platform);
|
|
307
|
+
const within = (child: string, parent: string) => {
|
|
308
|
+
const rel = paths.relative(parent, child);
|
|
309
|
+
return rel === "" || (!rel.startsWith(`..${paths.sep}`) && rel !== ".." && !paths.isAbsolute(rel));
|
|
310
|
+
};
|
|
311
|
+
const isDiskRoot = (path: string) => paths.parse(path).root === path;
|
|
312
|
+
const target = fold(root);
|
|
313
|
+
const homes = context.homes.map(fold);
|
|
314
|
+
if (isDiskRoot(target) || (paths === posix && POSIX_MOUNT_ROOT.test(target))) return "root_too_broad";
|
|
315
|
+
if (homes.some((home) => within(home, target))) return "root_too_broad";
|
|
316
|
+
if (context.userData.map(fold).some((dir) => within(target, dir) || within(dir, target))) return "root_app_data";
|
|
317
|
+
|
|
318
|
+
const names = target.slice(paths.parse(target).root.length).split(paths.sep).filter(Boolean).map((name) => name.toLowerCase());
|
|
319
|
+
const hasPair = (pairs: ReadonlyArray<readonly [string, string]>) => names.some((name, index) => pairs.some(([first, second]) => name === first && names[index + 1] === second));
|
|
320
|
+
if (names.some((name) => CREDENTIAL_DIRS.has(name)) || hasPair(CREDENTIAL_DIR_PAIRS) || isCollectorDirectoryDenied(names.join("/"))) return "root_credentials";
|
|
321
|
+
if (names.some((name) => APP_DATA_DIRS.has(name)) || hasPair(APP_DATA_DIR_PAIRS)) return "root_app_data";
|
|
322
|
+
|
|
323
|
+
for (const home of homes) {
|
|
324
|
+
if (isDiskRoot(home)) continue;
|
|
325
|
+
// The account folder the root is in: home, or a folder beside it (unless home sits right at a disk root).
|
|
326
|
+
const parent = paths.dirname(home);
|
|
327
|
+
const base = isDiskRoot(parent) ? home : parent;
|
|
328
|
+
if (!within(target, base)) continue;
|
|
329
|
+
const account = base === home ? home : paths.join(parent, paths.relative(parent, target).split(paths.sep)[0]!);
|
|
330
|
+
if (target === account) return "root_too_broad";
|
|
331
|
+
const first = paths.relative(account, target).split(paths.sep)[0]!.toLowerCase();
|
|
332
|
+
if (first.startsWith(".") || (context.platform === "darwin" && first === "library") || (context.platform === "linux" && first === "snap")) return "root_app_data";
|
|
333
|
+
}
|
|
334
|
+
if (homes.some((home) => !isDiskRoot(home) && within(target, home))) return null;
|
|
335
|
+
const systemDirs = paths === win32 ? WINDOWS_SYSTEM_DIRS.map((dir) => paths.join(paths.parse(target).root, dir)) : FOLDER_POSIX_SYSTEM_DIRS;
|
|
336
|
+
return systemDirs.some((dir) => within(target, fold(dir))) ? "root_system" : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export type FolderGateOptions = {
|
|
340
|
+
/** The Electron userData directory (the desktop sets OMNIRUSH_DESKTOP_USER_DATA_DIR); unknown outside the desktop app. */
|
|
341
|
+
userDataDir?: string;
|
|
342
|
+
/** Tests only; defaults to os.homedir(). */
|
|
343
|
+
homeDir?: string;
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Why `root` may not be archived as a plain folder, in any form of it (as
|
|
348
|
+
* given and resolved through symlinks), or null when it may: the folder
|
|
349
|
+
* refusals shared by the all-folders and the touched-files policies.
|
|
350
|
+
*/
|
|
351
|
+
export async function folderRootRefusal(root: string, options: FolderGateOptions = {}): Promise<FolderRefusal | null> {
|
|
352
|
+
const context: FolderGateContext = {
|
|
353
|
+
platform: process.platform,
|
|
354
|
+
homes: await pathForms(options.homeDir ?? homedir()),
|
|
355
|
+
userData: options.userDataDir ? await pathForms(options.userDataDir) : [],
|
|
356
|
+
};
|
|
357
|
+
for (const form of await pathForms(root)) {
|
|
358
|
+
const refused = refusedFolderRoot(form, context);
|
|
359
|
+
if (refused) return refused;
|
|
360
|
+
}
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* The folder markers (4.4): a root with no `.git` entry at all, that
|
|
366
|
+
* folderRootRefusal accepts, is a project with marker `folder` while
|
|
367
|
+
* `policy()` has `allFolders` on, or else with marker `touched` (only the
|
|
368
|
+
* files the agent touches there) while it has `touchedFiles` on. The policy
|
|
369
|
+
* is asked last, only for such a root. Never throws.
|
|
370
|
+
*/
|
|
371
|
+
export function folderDetector(policy: () => Promise<ArchivePolicy>, options: FolderGateOptions = {}): ProjectMarkerDetector {
|
|
372
|
+
return async (root) => {
|
|
373
|
+
try {
|
|
374
|
+
if (await lstat(join(root, ".git")).then(() => true, () => false)) return null;
|
|
375
|
+
if (await folderRootRefusal(root, options)) return null;
|
|
376
|
+
const answer = await policy();
|
|
377
|
+
if (answer.allFolders) return { archivable: true, reason: "folder", marker: FOLDER_MARKER };
|
|
378
|
+
return answer.touchedFiles ? { archivable: true, reason: "touched", marker: TOUCHED_MARKER } : null;
|
|
379
|
+
} catch {
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Whether the session root is a project to archive: the root checks first,
|
|
387
|
+
* then the detectors in order; the first archivable result wins, else the
|
|
388
|
+
* first non-null one, else no_marker.
|
|
389
|
+
*/
|
|
390
|
+
export async function isArchivableProject(
|
|
391
|
+
root: string,
|
|
392
|
+
detectors: readonly ProjectMarkerDetector[] = defaultProjectDetectors,
|
|
393
|
+
options: ProjectGateOptions = {},
|
|
394
|
+
): Promise<ArchivableProject> {
|
|
395
|
+
const refused = await refusedRoot(root, options);
|
|
396
|
+
if (refused) return { archivable: false, reason: refused, marker: null };
|
|
397
|
+
let first: ArchivableProject | null = null;
|
|
398
|
+
for (const detector of detectors) {
|
|
399
|
+
let result: ArchivableProject | null = null;
|
|
400
|
+
try {
|
|
401
|
+
result = await detector(root, options);
|
|
402
|
+
} catch {
|
|
403
|
+
result = null;
|
|
404
|
+
}
|
|
405
|
+
if (!result) continue;
|
|
406
|
+
if (result.archivable) return result;
|
|
407
|
+
first ??= result;
|
|
408
|
+
}
|
|
409
|
+
return first ?? { archivable: false, reason: "no_marker", marker: null };
|
|
410
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Durable state files for the archiver: every write goes to a temp name, is fsynced, then renamed. */
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { open, readFile, rename, rm } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
/** The first 32 hex characters of SHA-256(text): state file names for session ids and root paths. */
|
|
6
|
+
export function stateKey(text: string): string {
|
|
7
|
+
return createHash("sha256").update(text, "utf8").digest("hex").slice(0, 32);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type WritableHandle = { write(buffer: Buffer, offset: number, length: number): Promise<{ bytesWritten: number }> };
|
|
11
|
+
|
|
12
|
+
async function writeAll(handle: WritableHandle, text: string): Promise<void> {
|
|
13
|
+
const bytes = Buffer.from(text, "utf8");
|
|
14
|
+
let offset = 0;
|
|
15
|
+
while (offset < bytes.length) {
|
|
16
|
+
const { bytesWritten } = await handle.write(bytes, offset, bytes.length - offset);
|
|
17
|
+
offset += bytesWritten;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Writes text pieces to `path` atomically; large documents never become one string. */
|
|
22
|
+
export async function writeChunksAtomic(path: string, chunks: Iterable<string>): Promise<void> {
|
|
23
|
+
const temp = `${path}.${randomBytes(6).toString("hex")}.tmp`;
|
|
24
|
+
const handle = await open(temp, "wx", 0o600);
|
|
25
|
+
try {
|
|
26
|
+
let pending: string[] = [];
|
|
27
|
+
let pendingLength = 0;
|
|
28
|
+
for (const chunk of chunks) {
|
|
29
|
+
pending.push(chunk);
|
|
30
|
+
pendingLength += chunk.length;
|
|
31
|
+
if (pendingLength >= 1 << 20) {
|
|
32
|
+
await writeAll(handle, pending.join(""));
|
|
33
|
+
pending = [];
|
|
34
|
+
pendingLength = 0;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (pending.length > 0) await writeAll(handle, pending.join(""));
|
|
38
|
+
await handle.sync();
|
|
39
|
+
} catch (error) {
|
|
40
|
+
await handle.close().catch(() => undefined);
|
|
41
|
+
await rm(temp, { force: true });
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
await handle.close();
|
|
45
|
+
try {
|
|
46
|
+
await rename(temp, path);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
await rm(temp, { force: true });
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function writeJsonAtomic(path: string, value: unknown): Promise<void> {
|
|
54
|
+
return writeChunksAtomic(path, [`${JSON.stringify(value)}\n`]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Parsed JSON, or null when the file is missing or not JSON. */
|
|
58
|
+
export async function readJsonFile(path: string): Promise<unknown> {
|
|
59
|
+
let text: string;
|
|
60
|
+
try {
|
|
61
|
+
text = await readFile(path, "utf8");
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(text);
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Runs a full garbage collection when the runtime offers one (Bun.gc); a
|
|
74
|
+
* no-op elsewhere. Streaming an archive churns through hundreds of MiB of
|
|
75
|
+
* short-lived buffers (zstd output, AES-GCM output) that the collector would
|
|
76
|
+
* otherwise let pile up well past the stream's bounded working set. Callers
|
|
77
|
+
* hint once per 64 MiB of data; a collection takes a few milliseconds at the
|
|
78
|
+
* heap sizes involved.
|
|
79
|
+
*/
|
|
80
|
+
export function hintGarbageCollection(): void {
|
|
81
|
+
const runtime: unknown = Reflect.get(globalThis, "Bun");
|
|
82
|
+
if (typeof runtime !== "object" || runtime === null) return;
|
|
83
|
+
const gc: unknown = Reflect.get(runtime, "gc");
|
|
84
|
+
if (typeof gc === "function") Reflect.apply(gc, runtime, [true]);
|
|
85
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gitignored content is never archived (CLI rule, owner decision 2026-09-26):
|
|
3
|
+
* `node_modules/`, build output, virtualenvs and caches can be regenerated,
|
|
4
|
+
* so a base, delta or final archive carries neither them nor anything else
|
|
5
|
+
* the folder's ignore rules cover. What stays: `.git/` itself (it is never
|
|
6
|
+
* subject to ignore rules and carries the history), every tracked file (a
|
|
7
|
+
* file committed despite a matching pattern is not ignored), and the
|
|
8
|
+
* `.gitignore` files themselves.
|
|
9
|
+
*
|
|
10
|
+
* The rules are git's own: in a work tree, one
|
|
11
|
+
* `git ls-files --others --ignored --exclude-standard --directory -z` run
|
|
12
|
+
* lists the ignored untracked paths (nested `.gitignore` files,
|
|
13
|
+
* `.git/info/exclude` and `core.excludesFile` included), with a wholly
|
|
14
|
+
* ignored folder listed once as `dir/` so the scan prunes it without
|
|
15
|
+
* walking it. Where git cannot answer (git missing, not a work tree, a
|
|
16
|
+
* timeout or an output past the cap) the folder's `.gitignore` files are
|
|
17
|
+
* applied level by level, with the same matcher the workspace collector's
|
|
18
|
+
* fallback listing uses (`ignoredByRules`).
|
|
19
|
+
*
|
|
20
|
+
* This file is a CLI addition to the ported desktop modules; the desktop's
|
|
21
|
+
* archive is being changed the same way.
|
|
22
|
+
*/
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
import { ignoredByRules, readGitignoreFile, scopedIgnoreRules } from "../workspace-collector.js";
|
|
26
|
+
import { runGit } from "./manifest.js";
|
|
27
|
+
|
|
28
|
+
/** The ignored-paths listing is read up to this size; past it the .gitignore fallback applies. */
|
|
29
|
+
const MAX_IGNORED_LISTING_BYTES = 64 * 1024 * 1024;
|
|
30
|
+
/** One listing run may take this long (the scan of a big tree runs longer anyway). */
|
|
31
|
+
const IGNORED_LISTING_TIMEOUT_MS = 60_000;
|
|
32
|
+
|
|
33
|
+
export type IgnoreMatcher = {
|
|
34
|
+
/** "git" when git's own listing answers, "gitignore" for the .gitignore-file fallback. */
|
|
35
|
+
readonly source: "git" | "gitignore";
|
|
36
|
+
/** Whether the root-relative portable path is ignored; `dir` for a folder. */
|
|
37
|
+
ignored(rel: string, dir: boolean): Promise<boolean>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Never subject to ignore rules: a repository's own `.git` (at any depth) and its contents, and `.gitignore` files. */
|
|
41
|
+
function exempt(rel: string, dir: boolean): boolean {
|
|
42
|
+
const parts = rel.split("/");
|
|
43
|
+
if (parts.includes(".git")) return true;
|
|
44
|
+
return !dir && parts[parts.length - 1] === ".gitignore";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The ignored untracked paths git lists for the root (folders end in `/`), or null when git cannot answer. */
|
|
48
|
+
async function gitIgnoredListing(root: string): Promise<Set<string> | null> {
|
|
49
|
+
const inside = await runGit(root, ["rev-parse", "--is-inside-work-tree"]);
|
|
50
|
+
if (!inside.ok || inside.stdout.trim() !== "true") return null;
|
|
51
|
+
const listing = await runGit(
|
|
52
|
+
root,
|
|
53
|
+
["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--directory"],
|
|
54
|
+
MAX_IGNORED_LISTING_BYTES,
|
|
55
|
+
IGNORED_LISTING_TIMEOUT_MS,
|
|
56
|
+
);
|
|
57
|
+
if (!listing.ok || listing.truncated) return null;
|
|
58
|
+
return new Set(listing.stdout.split("\0").filter(Boolean));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The .gitignore rules in force in `relDir` (its own and every parent's), re-rooted at the root. */
|
|
62
|
+
function gitignoreRules(root: string): (relDir: string) => Promise<string[]> {
|
|
63
|
+
const byDir = new Map<string, Promise<string[]>>();
|
|
64
|
+
const rulesAt = (relDir: string): Promise<string[]> => {
|
|
65
|
+
let rules = byDir.get(relDir);
|
|
66
|
+
if (!rules) {
|
|
67
|
+
const slash = relDir.lastIndexOf("/");
|
|
68
|
+
const parent = slash === -1 ? "" : relDir.slice(0, slash);
|
|
69
|
+
rules = (async () => [
|
|
70
|
+
...(relDir === "" ? [] : await rulesAt(parent)),
|
|
71
|
+
...scopedIgnoreRules(relDir, await readGitignoreFile(relDir ? join(root, ...relDir.split("/")) : root)),
|
|
72
|
+
])();
|
|
73
|
+
byDir.set(relDir, rules);
|
|
74
|
+
}
|
|
75
|
+
return rules;
|
|
76
|
+
};
|
|
77
|
+
return rulesAt;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The ignore matcher for a scan of `root`. In git mode a folder is ignored
|
|
82
|
+
* when git listed it as `dir/`, a file when git listed it; in fallback mode
|
|
83
|
+
* a path is ignored when the .gitignore rules of its folder cover it (the
|
|
84
|
+
* scan never enters an ignored folder, so a parent's rules are already
|
|
85
|
+
* checked).
|
|
86
|
+
*/
|
|
87
|
+
export async function archiveIgnoreMatcher(root: string): Promise<IgnoreMatcher> {
|
|
88
|
+
const listed = await gitIgnoredListing(root);
|
|
89
|
+
if (listed) {
|
|
90
|
+
return {
|
|
91
|
+
source: "git",
|
|
92
|
+
async ignored(rel, dir) {
|
|
93
|
+
if (exempt(rel, dir)) return false;
|
|
94
|
+
return dir ? listed.has(`${rel}/`) : listed.has(rel);
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const rulesAt = gitignoreRules(root);
|
|
99
|
+
return {
|
|
100
|
+
source: "gitignore",
|
|
101
|
+
async ignored(rel, dir) {
|
|
102
|
+
if (exempt(rel, dir)) return false;
|
|
103
|
+
const slash = rel.lastIndexOf("/");
|
|
104
|
+
const rules = await rulesAt(slash === -1 ? "" : rel.slice(0, slash));
|
|
105
|
+
if (rules.length === 0) return false;
|
|
106
|
+
// A folder rule (`dist/`) reads as `dist/**`, which covers what is
|
|
107
|
+
// inside the folder: the folder itself is matched with its slash.
|
|
108
|
+
return ignoredByRules(rel, rules) || (dir && ignoredByRules(`${rel}/`, rules));
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* For the touched-files scan, which looks at single paths rather than
|
|
115
|
+
* walking: whether `rel` or any folder above it is ignored.
|
|
116
|
+
*/
|
|
117
|
+
export async function ignoredWithParents(matcher: IgnoreMatcher, rel: string, dir: boolean): Promise<boolean> {
|
|
118
|
+
const parts = rel.split("/");
|
|
119
|
+
for (let depth = 1; depth < parts.length; depth += 1) {
|
|
120
|
+
if (await matcher.ignored(parts.slice(0, depth).join("/"), true)) return true;
|
|
121
|
+
}
|
|
122
|
+
return matcher.ignored(rel, dir);
|
|
123
|
+
}
|