@openclaw/fs-safe 0.2.0 → 0.2.2
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/CHANGELOG.md +16 -0
- package/dist/archive-utils.d.ts +3 -0
- package/dist/archive-utils.d.ts.map +1 -0
- package/dist/archive-utils.js +26 -0
- package/dist/boundary-file-read.d.ts +44 -0
- package/dist/boundary-file-read.d.ts.map +1 -0
- package/dist/boundary-file-read.js +129 -0
- package/dist/boundary-path.d.ts +39 -0
- package/dist/boundary-path.d.ts.map +1 -0
- package/dist/boundary-path.js +598 -0
- package/dist/fs-pinned-path-helper.d.ts +7 -0
- package/dist/fs-pinned-path-helper.d.ts.map +1 -0
- package/dist/fs-pinned-path-helper.js +182 -0
- package/dist/fs-pinned-write-helper.d.ts +21 -0
- package/dist/fs-pinned-write-helper.d.ts.map +1 -0
- package/dist/fs-pinned-write-helper.js +263 -0
- package/dist/hardlink-guards.d.ts +7 -0
- package/dist/hardlink-guards.d.ts.map +1 -0
- package/dist/hardlink-guards.js +30 -0
- package/dist/home-dir.d.ts.map +1 -1
- package/dist/home-dir.js +38 -33
- package/dist/install-safe-path.d.ts +20 -0
- package/dist/install-safe-path.d.ts.map +1 -0
- package/dist/install-safe-path.js +94 -0
- package/dist/json-file.d.ts +3 -0
- package/dist/json-file.d.ts.map +1 -0
- package/dist/json-file.js +123 -0
- package/dist/json-files.d.ts +20 -0
- package/dist/json-files.d.ts.map +1 -0
- package/dist/json-files.js +153 -0
- package/dist/path-alias-guards.d.ts +19 -0
- package/dist/path-alias-guards.d.ts.map +1 -0
- package/dist/path-alias-guards.js +21 -0
- package/dist/path-guards.d.ts +7 -0
- package/dist/path-guards.d.ts.map +1 -0
- package/dist/path-guards.js +49 -0
- package/dist/path-safety.d.ts +12 -0
- package/dist/path-safety.d.ts.map +1 -0
- package/dist/path-safety.js +50 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +4 -3
- package/dist/pinned-python-config.d.ts.map +1 -1
- package/dist/pinned-python-config.js +2 -3
- package/dist/private-file-store.d.ts +7 -5
- package/dist/private-file-store.d.ts.map +1 -1
- package/dist/private-file-store.js +34 -21
- package/dist/root-impl.js +5 -2
- package/dist/safe-open-sync.d.ts +24 -0
- package/dist/safe-open-sync.d.ts.map +1 -0
- package/dist/safe-open-sync.js +71 -0
- package/dist/safe-root.d.ts +123 -0
- package/dist/safe-root.d.ts.map +1 -0
- package/dist/safe-root.js +1060 -0
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +22 -6
- package/dist/secure-temp-workspace.d.ts +25 -0
- package/dist/secure-temp-workspace.d.ts.map +1 -0
- package/dist/secure-temp-workspace.js +136 -0
- package/dist/sibling-temp-file.d.ts +16 -0
- package/dist/sibling-temp-file.d.ts.map +1 -0
- package/dist/sibling-temp-file.js +73 -0
- package/dist/sibling-temp-write.d.ts +8 -0
- package/dist/sibling-temp-write.d.ts.map +1 -0
- package/dist/sibling-temp-write.js +40 -0
- package/package.json +1 -1
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fsSync from "node:fs";
|
|
3
|
+
import { FsSafeError } from "./errors.js";
|
|
4
|
+
const LOCAL_PINNED_PATH_PYTHON = [
|
|
5
|
+
"import errno",
|
|
6
|
+
"import json",
|
|
7
|
+
"import os",
|
|
8
|
+
"import stat",
|
|
9
|
+
"import sys",
|
|
10
|
+
"",
|
|
11
|
+
"operation = sys.argv[1]",
|
|
12
|
+
"root_path = sys.argv[2]",
|
|
13
|
+
"relative_path = sys.argv[3]",
|
|
14
|
+
"",
|
|
15
|
+
"DIR_FLAGS = os.O_RDONLY",
|
|
16
|
+
"if hasattr(os, 'O_DIRECTORY'):",
|
|
17
|
+
" DIR_FLAGS |= os.O_DIRECTORY",
|
|
18
|
+
"if hasattr(os, 'O_NOFOLLOW'):",
|
|
19
|
+
" DIR_FLAGS |= os.O_NOFOLLOW",
|
|
20
|
+
"",
|
|
21
|
+
"def open_dir(path_value, dir_fd=None):",
|
|
22
|
+
" return os.open(path_value, DIR_FLAGS, dir_fd=dir_fd)",
|
|
23
|
+
"",
|
|
24
|
+
"def split_segments(relative_path):",
|
|
25
|
+
" return [part for part in relative_path.split('/') if part and part != '.']",
|
|
26
|
+
"",
|
|
27
|
+
"def validate_segment(segment):",
|
|
28
|
+
" if segment == '..':",
|
|
29
|
+
" raise OSError(errno.EPERM, 'path traversal is not allowed', segment)",
|
|
30
|
+
"",
|
|
31
|
+
"def walk_existing_path(root_fd, segments):",
|
|
32
|
+
" current_fd = os.dup(root_fd)",
|
|
33
|
+
" try:",
|
|
34
|
+
" for segment in segments:",
|
|
35
|
+
" validate_segment(segment)",
|
|
36
|
+
" next_fd = open_dir(segment, dir_fd=current_fd)",
|
|
37
|
+
" os.close(current_fd)",
|
|
38
|
+
" current_fd = next_fd",
|
|
39
|
+
" return current_fd",
|
|
40
|
+
" except Exception:",
|
|
41
|
+
" os.close(current_fd)",
|
|
42
|
+
" raise",
|
|
43
|
+
"",
|
|
44
|
+
"def mkdirp_within_root(root_fd, segments):",
|
|
45
|
+
" current_fd = os.dup(root_fd)",
|
|
46
|
+
" try:",
|
|
47
|
+
" for segment in segments:",
|
|
48
|
+
" validate_segment(segment)",
|
|
49
|
+
" try:",
|
|
50
|
+
" next_fd = open_dir(segment, dir_fd=current_fd)",
|
|
51
|
+
" except FileNotFoundError:",
|
|
52
|
+
" os.mkdir(segment, 0o777, dir_fd=current_fd)",
|
|
53
|
+
" next_fd = open_dir(segment, dir_fd=current_fd)",
|
|
54
|
+
" os.close(current_fd)",
|
|
55
|
+
" current_fd = next_fd",
|
|
56
|
+
" finally:",
|
|
57
|
+
" os.close(current_fd)",
|
|
58
|
+
"",
|
|
59
|
+
"def remove_within_root(root_fd, segments):",
|
|
60
|
+
" if not segments:",
|
|
61
|
+
" raise OSError(errno.EPERM, 'refusing to remove root path')",
|
|
62
|
+
" parent_segments = segments[:-1]",
|
|
63
|
+
" basename = segments[-1]",
|
|
64
|
+
" validate_segment(basename)",
|
|
65
|
+
" parent_fd = walk_existing_path(root_fd, parent_segments)",
|
|
66
|
+
" try:",
|
|
67
|
+
" target_stat = os.lstat(basename, dir_fd=parent_fd)",
|
|
68
|
+
" if stat.S_ISDIR(target_stat.st_mode) and not stat.S_ISLNK(target_stat.st_mode):",
|
|
69
|
+
" os.rmdir(basename, dir_fd=parent_fd)",
|
|
70
|
+
" else:",
|
|
71
|
+
" os.unlink(basename, dir_fd=parent_fd)",
|
|
72
|
+
" finally:",
|
|
73
|
+
" os.close(parent_fd)",
|
|
74
|
+
"",
|
|
75
|
+
"def emit_error(exc):",
|
|
76
|
+
" payload = {",
|
|
77
|
+
" 'name': exc.__class__.__name__,",
|
|
78
|
+
" 'errno': getattr(exc, 'errno', None),",
|
|
79
|
+
" 'message': str(exc),",
|
|
80
|
+
" }",
|
|
81
|
+
" print(json.dumps(payload), file=sys.stderr)",
|
|
82
|
+
"",
|
|
83
|
+
"root_fd = None",
|
|
84
|
+
"try:",
|
|
85
|
+
" root_fd = open_dir(root_path)",
|
|
86
|
+
" segments = split_segments(relative_path)",
|
|
87
|
+
" if operation == 'mkdirp':",
|
|
88
|
+
" mkdirp_within_root(root_fd, segments)",
|
|
89
|
+
" elif operation == 'remove':",
|
|
90
|
+
" remove_within_root(root_fd, segments)",
|
|
91
|
+
" else:",
|
|
92
|
+
" raise RuntimeError(f'unknown pinned path operation: {operation}')",
|
|
93
|
+
"except Exception as exc:",
|
|
94
|
+
" emit_error(exc)",
|
|
95
|
+
" sys.exit(1)",
|
|
96
|
+
"finally:",
|
|
97
|
+
" if root_fd is not None:",
|
|
98
|
+
" os.close(root_fd)",
|
|
99
|
+
].join("\n");
|
|
100
|
+
const PINNED_PATH_PYTHON_CANDIDATES = [
|
|
101
|
+
process.env.OPENCLAW_PINNED_PYTHON,
|
|
102
|
+
// Keep the write-specific alias for backwards compatibility.
|
|
103
|
+
process.env.OPENCLAW_PINNED_WRITE_PYTHON,
|
|
104
|
+
"/usr/bin/python3",
|
|
105
|
+
"/opt/homebrew/bin/python3",
|
|
106
|
+
"/usr/local/bin/python3",
|
|
107
|
+
].filter((value) => Boolean(value));
|
|
108
|
+
let cachedPinnedPathPython = "";
|
|
109
|
+
function canExecute(binPath) {
|
|
110
|
+
try {
|
|
111
|
+
fsSync.accessSync(binPath, fsSync.constants.X_OK);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function resolvePinnedPathPython() {
|
|
119
|
+
if (cachedPinnedPathPython) {
|
|
120
|
+
return cachedPinnedPathPython;
|
|
121
|
+
}
|
|
122
|
+
for (const candidate of PINNED_PATH_PYTHON_CANDIDATES) {
|
|
123
|
+
if (canExecute(candidate)) {
|
|
124
|
+
cachedPinnedPathPython = candidate;
|
|
125
|
+
return cachedPinnedPathPython;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
cachedPinnedPathPython = "python3";
|
|
129
|
+
return cachedPinnedPathPython;
|
|
130
|
+
}
|
|
131
|
+
function buildPinnedPathError(stderr, code, signal) {
|
|
132
|
+
const trimmed = stderr.trim();
|
|
133
|
+
if (trimmed.startsWith("{")) {
|
|
134
|
+
try {
|
|
135
|
+
const payload = JSON.parse(trimmed);
|
|
136
|
+
if (payload.errno === 2) {
|
|
137
|
+
return new FsSafeError("not-found", "file not found");
|
|
138
|
+
}
|
|
139
|
+
if (payload.errno === 20 || payload.errno === 40) {
|
|
140
|
+
return new FsSafeError("path-alias", "path is not under root");
|
|
141
|
+
}
|
|
142
|
+
if (payload.errno === 39) {
|
|
143
|
+
return new FsSafeError("not-empty", "directory is not empty");
|
|
144
|
+
}
|
|
145
|
+
if (payload.errno === 1 || payload.errno === 13 || payload.errno === 21) {
|
|
146
|
+
return new FsSafeError("not-removable", "path is not removable under root");
|
|
147
|
+
}
|
|
148
|
+
return new FsSafeError("helper-failed", payload.message || "pinned path helper failed");
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// Fall through to the generic helper failure below.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return new FsSafeError("helper-failed", trimmed || `Pinned path helper failed with code ${code ?? "null"} (${signal ?? "?"})`);
|
|
155
|
+
}
|
|
156
|
+
export function isPinnedPathHelperSpawnError(error) {
|
|
157
|
+
if (!(error instanceof Error)) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
const maybeErrno = error;
|
|
161
|
+
if (typeof maybeErrno.syscall !== "string" || !maybeErrno.syscall.startsWith("spawn")) {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
return ["EACCES", "ENOENT", "ENOEXEC"].includes(maybeErrno.code ?? "");
|
|
165
|
+
}
|
|
166
|
+
export async function runPinnedPathHelper(params) {
|
|
167
|
+
const child = spawn(resolvePinnedPathPython(), ["-c", LOCAL_PINNED_PATH_PYTHON, params.operation, params.rootPath, params.relativePath], {
|
|
168
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
169
|
+
});
|
|
170
|
+
let stderr = "";
|
|
171
|
+
child.stderr.setEncoding?.("utf8");
|
|
172
|
+
child.stderr.on("data", (chunk) => {
|
|
173
|
+
stderr += chunk;
|
|
174
|
+
});
|
|
175
|
+
const [code, signal] = await new Promise((resolve, reject) => {
|
|
176
|
+
child.once("error", reject);
|
|
177
|
+
child.once("close", (exitCode, exitSignal) => resolve([exitCode, exitSignal]));
|
|
178
|
+
});
|
|
179
|
+
if (code !== 0) {
|
|
180
|
+
throw buildPinnedPathError(stderr, code, signal);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Readable } from "node:stream";
|
|
2
|
+
import type { FileIdentityStat } from "./file-identity.js";
|
|
3
|
+
type PinnedWriteInput = {
|
|
4
|
+
kind: "buffer";
|
|
5
|
+
data: string | Buffer;
|
|
6
|
+
encoding?: BufferEncoding;
|
|
7
|
+
} | {
|
|
8
|
+
kind: "stream";
|
|
9
|
+
stream: Readable;
|
|
10
|
+
};
|
|
11
|
+
export declare function runPinnedWriteHelper(params: {
|
|
12
|
+
rootPath: string;
|
|
13
|
+
relativeParentPath: string;
|
|
14
|
+
basename: string;
|
|
15
|
+
mkdir: boolean;
|
|
16
|
+
mode: number;
|
|
17
|
+
overwrite?: boolean;
|
|
18
|
+
input: PinnedWriteInput;
|
|
19
|
+
}): Promise<FileIdentityStat>;
|
|
20
|
+
export {};
|
|
21
|
+
//# sourceMappingURL=fs-pinned-write-helper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fs-pinned-write-helper.d.ts","sourceRoot":"","sources":["../src/fs-pinned-write-helper.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAE5C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAE3D,KAAK,gBAAgB,GACjB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,cAAc,CAAA;CAAE,GACpE;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,MAAM,EAAE,QAAQ,CAAA;CAAE,CAAC;AAoJzC,wBAAsB,oBAAoB,CAAC,MAAM,EAAE;IACjD,QAAQ,EAAE,MAAM,CAAC;IACjB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,OAAO,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,KAAK,EAAE,gBAAgB,CAAC;CACzB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAgE5B"}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { once } from "node:events";
|
|
3
|
+
import fsSync from "node:fs";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
const LOCAL_PINNED_WRITE_PYTHON = [
|
|
8
|
+
"import errno",
|
|
9
|
+
"import os",
|
|
10
|
+
"import secrets",
|
|
11
|
+
"import stat",
|
|
12
|
+
"import sys",
|
|
13
|
+
"",
|
|
14
|
+
"root_path = sys.argv[1]",
|
|
15
|
+
"relative_parent = sys.argv[2]",
|
|
16
|
+
"basename = sys.argv[3]",
|
|
17
|
+
'mkdir_enabled = sys.argv[4] == "1"',
|
|
18
|
+
"file_mode = int(sys.argv[5], 8)",
|
|
19
|
+
'overwrite_enabled = sys.argv[6] == "1"',
|
|
20
|
+
"",
|
|
21
|
+
"DIR_FLAGS = os.O_RDONLY",
|
|
22
|
+
"if hasattr(os, 'O_DIRECTORY'):",
|
|
23
|
+
" DIR_FLAGS |= os.O_DIRECTORY",
|
|
24
|
+
"if hasattr(os, 'O_NOFOLLOW'):",
|
|
25
|
+
" DIR_FLAGS |= os.O_NOFOLLOW",
|
|
26
|
+
"",
|
|
27
|
+
"WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL",
|
|
28
|
+
"if hasattr(os, 'O_NOFOLLOW'):",
|
|
29
|
+
" WRITE_FLAGS |= os.O_NOFOLLOW",
|
|
30
|
+
"",
|
|
31
|
+
"def open_dir(path_value, dir_fd=None):",
|
|
32
|
+
" return os.open(path_value, DIR_FLAGS, dir_fd=dir_fd)",
|
|
33
|
+
"",
|
|
34
|
+
"def walk_parent(root_fd, rel_parent, mkdir_enabled):",
|
|
35
|
+
" current_fd = os.dup(root_fd)",
|
|
36
|
+
" try:",
|
|
37
|
+
" for segment in [part for part in rel_parent.split('/') if part and part != '.']:",
|
|
38
|
+
" if segment == '..':",
|
|
39
|
+
" raise OSError(errno.EPERM, 'path traversal is not allowed', segment)",
|
|
40
|
+
" try:",
|
|
41
|
+
" next_fd = open_dir(segment, dir_fd=current_fd)",
|
|
42
|
+
" except FileNotFoundError:",
|
|
43
|
+
" if not mkdir_enabled:",
|
|
44
|
+
" raise",
|
|
45
|
+
" os.mkdir(segment, 0o777, dir_fd=current_fd)",
|
|
46
|
+
" next_fd = open_dir(segment, dir_fd=current_fd)",
|
|
47
|
+
" os.close(current_fd)",
|
|
48
|
+
" current_fd = next_fd",
|
|
49
|
+
" return current_fd",
|
|
50
|
+
" except Exception:",
|
|
51
|
+
" os.close(current_fd)",
|
|
52
|
+
" raise",
|
|
53
|
+
"",
|
|
54
|
+
"def create_temp_file(parent_fd, basename, mode):",
|
|
55
|
+
" prefix = '.' + basename + '.'",
|
|
56
|
+
" for _ in range(128):",
|
|
57
|
+
" candidate = prefix + secrets.token_hex(6) + '.tmp'",
|
|
58
|
+
" try:",
|
|
59
|
+
" fd = os.open(candidate, WRITE_FLAGS, mode, dir_fd=parent_fd)",
|
|
60
|
+
" return candidate, fd",
|
|
61
|
+
" except FileExistsError:",
|
|
62
|
+
" continue",
|
|
63
|
+
" raise RuntimeError('failed to allocate pinned temp file')",
|
|
64
|
+
"",
|
|
65
|
+
"root_fd = open_dir(root_path)",
|
|
66
|
+
"parent_fd = None",
|
|
67
|
+
"temp_fd = None",
|
|
68
|
+
"temp_name = None",
|
|
69
|
+
"try:",
|
|
70
|
+
" parent_fd = walk_parent(root_fd, relative_parent, mkdir_enabled)",
|
|
71
|
+
" temp_name, temp_fd = create_temp_file(parent_fd, basename, file_mode)",
|
|
72
|
+
" while True:",
|
|
73
|
+
" chunk = sys.stdin.buffer.read(65536)",
|
|
74
|
+
" if not chunk:",
|
|
75
|
+
" break",
|
|
76
|
+
" os.write(temp_fd, chunk)",
|
|
77
|
+
" os.fsync(temp_fd)",
|
|
78
|
+
" os.close(temp_fd)",
|
|
79
|
+
" temp_fd = None",
|
|
80
|
+
" if overwrite_enabled:",
|
|
81
|
+
" os.replace(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)",
|
|
82
|
+
" temp_name = None",
|
|
83
|
+
" else:",
|
|
84
|
+
" os.link(temp_name, basename, src_dir_fd=parent_fd, dst_dir_fd=parent_fd, follow_symlinks=False)",
|
|
85
|
+
" os.unlink(temp_name, dir_fd=parent_fd)",
|
|
86
|
+
" temp_name = None",
|
|
87
|
+
" os.fsync(parent_fd)",
|
|
88
|
+
" result_stat = os.stat(basename, dir_fd=parent_fd, follow_symlinks=False)",
|
|
89
|
+
" print(f'{result_stat.st_dev}|{result_stat.st_ino}')",
|
|
90
|
+
"finally:",
|
|
91
|
+
" if temp_fd is not None:",
|
|
92
|
+
" os.close(temp_fd)",
|
|
93
|
+
" if temp_name is not None and parent_fd is not None:",
|
|
94
|
+
" try:",
|
|
95
|
+
" os.unlink(temp_name, dir_fd=parent_fd)",
|
|
96
|
+
" except FileNotFoundError:",
|
|
97
|
+
" pass",
|
|
98
|
+
" if parent_fd is not None:",
|
|
99
|
+
" os.close(parent_fd)",
|
|
100
|
+
" os.close(root_fd)",
|
|
101
|
+
].join("\n");
|
|
102
|
+
const PINNED_WRITE_PYTHON_CANDIDATES = [
|
|
103
|
+
process.env.OPENCLAW_PINNED_WRITE_PYTHON,
|
|
104
|
+
"/usr/bin/python3",
|
|
105
|
+
"/opt/homebrew/bin/python3",
|
|
106
|
+
"/usr/local/bin/python3",
|
|
107
|
+
].filter((value) => Boolean(value));
|
|
108
|
+
let cachedPinnedWritePython = "";
|
|
109
|
+
function canExecute(binPath) {
|
|
110
|
+
try {
|
|
111
|
+
fsSync.accessSync(binPath, fsSync.constants.X_OK);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function resolvePinnedWritePython() {
|
|
119
|
+
if (cachedPinnedWritePython) {
|
|
120
|
+
return cachedPinnedWritePython;
|
|
121
|
+
}
|
|
122
|
+
for (const candidate of PINNED_WRITE_PYTHON_CANDIDATES) {
|
|
123
|
+
if (canExecute(candidate)) {
|
|
124
|
+
cachedPinnedWritePython = candidate;
|
|
125
|
+
return cachedPinnedWritePython;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
cachedPinnedWritePython = "python3";
|
|
129
|
+
return cachedPinnedWritePython;
|
|
130
|
+
}
|
|
131
|
+
function parsePinnedIdentity(stdout) {
|
|
132
|
+
const line = stdout
|
|
133
|
+
.trim()
|
|
134
|
+
.split(/\r?\n/)
|
|
135
|
+
.map((value) => value.trim())
|
|
136
|
+
.findLast(Boolean);
|
|
137
|
+
if (!line) {
|
|
138
|
+
throw new Error("Pinned write helper returned no identity");
|
|
139
|
+
}
|
|
140
|
+
const [devRaw, inoRaw] = line.split("|");
|
|
141
|
+
const dev = Number.parseInt(devRaw ?? "", 10);
|
|
142
|
+
const ino = Number.parseInt(inoRaw ?? "", 10);
|
|
143
|
+
if (!Number.isFinite(dev) || !Number.isFinite(ino)) {
|
|
144
|
+
throw new Error(`Pinned write helper returned invalid identity: ${line}`);
|
|
145
|
+
}
|
|
146
|
+
return { dev, ino };
|
|
147
|
+
}
|
|
148
|
+
export async function runPinnedWriteHelper(params) {
|
|
149
|
+
const child = spawn(resolvePinnedWritePython(), [
|
|
150
|
+
"-c",
|
|
151
|
+
LOCAL_PINNED_WRITE_PYTHON,
|
|
152
|
+
params.rootPath,
|
|
153
|
+
params.relativeParentPath,
|
|
154
|
+
params.basename,
|
|
155
|
+
params.mkdir ? "1" : "0",
|
|
156
|
+
(params.mode || 0o600).toString(8),
|
|
157
|
+
params.overwrite === false ? "0" : "1",
|
|
158
|
+
], {
|
|
159
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
160
|
+
});
|
|
161
|
+
let stdout = "";
|
|
162
|
+
let stderr = "";
|
|
163
|
+
child.stdout.setEncoding?.("utf8");
|
|
164
|
+
child.stderr.setEncoding?.("utf8");
|
|
165
|
+
child.stdout.on("data", (chunk) => {
|
|
166
|
+
stdout += chunk;
|
|
167
|
+
});
|
|
168
|
+
child.stderr.on("data", (chunk) => {
|
|
169
|
+
stderr += chunk;
|
|
170
|
+
});
|
|
171
|
+
const exitPromise = once(child, "close");
|
|
172
|
+
try {
|
|
173
|
+
if (!child.stdin) {
|
|
174
|
+
const identity = await runPinnedWriteFallback(params);
|
|
175
|
+
await exitPromise.catch(() => { });
|
|
176
|
+
return identity;
|
|
177
|
+
}
|
|
178
|
+
if (params.input.kind === "buffer") {
|
|
179
|
+
const input = params.input;
|
|
180
|
+
await new Promise((resolve, reject) => {
|
|
181
|
+
child.stdin.once("error", reject);
|
|
182
|
+
if (typeof input.data === "string") {
|
|
183
|
+
child.stdin.end(input.data, input.encoding ?? "utf8", () => resolve());
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
child.stdin.end(input.data, () => resolve());
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
await pipeline(params.input.stream, child.stdin);
|
|
191
|
+
}
|
|
192
|
+
const [code, signal] = await exitPromise;
|
|
193
|
+
if (code !== 0) {
|
|
194
|
+
throw new Error(stderr.trim() ||
|
|
195
|
+
`Pinned write helper failed with code ${code ?? "null"} (${signal ?? "?"})`);
|
|
196
|
+
}
|
|
197
|
+
return parsePinnedIdentity(stdout);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
child.kill("SIGKILL");
|
|
201
|
+
await exitPromise.catch(() => { });
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
async function runPinnedWriteFallback(params) {
|
|
206
|
+
const parentPath = params.relativeParentPath
|
|
207
|
+
? path.join(params.rootPath, ...params.relativeParentPath.split("/"))
|
|
208
|
+
: params.rootPath;
|
|
209
|
+
if (params.mkdir) {
|
|
210
|
+
await fs.mkdir(parentPath, { recursive: true });
|
|
211
|
+
}
|
|
212
|
+
const targetPath = path.join(parentPath, params.basename);
|
|
213
|
+
if (params.overwrite === false) {
|
|
214
|
+
const handle = await fs.open(targetPath, fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_EXCL, params.mode);
|
|
215
|
+
let created = true;
|
|
216
|
+
try {
|
|
217
|
+
if (params.input.kind === "buffer") {
|
|
218
|
+
if (typeof params.input.data === "string") {
|
|
219
|
+
await handle.writeFile(params.input.data, params.input.encoding ?? "utf8");
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
await handle.writeFile(params.input.data);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
await pipeline(params.input.stream, handle.createWriteStream());
|
|
227
|
+
}
|
|
228
|
+
const stat = await handle.stat();
|
|
229
|
+
created = false;
|
|
230
|
+
return { dev: stat.dev, ino: stat.ino };
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
await handle.close().catch(() => undefined);
|
|
234
|
+
if (created) {
|
|
235
|
+
await fs.rm(targetPath, { force: true }).catch(() => undefined);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const tempPath = path.join(parentPath, `.${params.basename}.fallback.tmp`);
|
|
240
|
+
if (params.input.kind === "buffer") {
|
|
241
|
+
if (typeof params.input.data === "string") {
|
|
242
|
+
await fs.writeFile(tempPath, params.input.data, {
|
|
243
|
+
encoding: params.input.encoding ?? "utf8",
|
|
244
|
+
mode: params.mode,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
await fs.writeFile(tempPath, params.input.data, { mode: params.mode });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
const handle = await fs.open(tempPath, "w", params.mode);
|
|
253
|
+
try {
|
|
254
|
+
await pipeline(params.input.stream, handle.createWriteStream());
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
await handle.close().catch(() => { });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
await fs.rename(tempPath, targetPath);
|
|
261
|
+
const stat = await fs.stat(targetPath);
|
|
262
|
+
return { dev: stat.dev, ino: stat.ino };
|
|
263
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hardlink-guards.d.ts","sourceRoot":"","sources":["../src/hardlink-guards.ts"],"names":[],"mappings":"AAIA,wBAAsB,2BAA2B,CAAC,MAAM,EAAE;IACxD,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,aAAa,EAAE,MAAM,CAAC;IACtB,2BAA2B,CAAC,EAAE,OAAO,CAAC;CACvC,GAAG,OAAO,CAAC,IAAI,CAAC,CAqBhB"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import { isNotFoundPathError } from "./path-guards.js";
|
|
4
|
+
export async function assertNoHardlinkedFinalPath(params) {
|
|
5
|
+
if (params.allowFinalHardlinkForUnlink) {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
let stat;
|
|
9
|
+
try {
|
|
10
|
+
stat = await fs.stat(params.filePath);
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
if (isNotFoundPathError(err)) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
throw err;
|
|
17
|
+
}
|
|
18
|
+
if (!stat.isFile()) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (stat.nlink > 1) {
|
|
22
|
+
throw new Error(`Hardlinked path is not allowed under ${params.boundaryLabel} (${shortPath(params.root)}): ${shortPath(params.filePath)}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function shortPath(value) {
|
|
26
|
+
if (value.startsWith(os.homedir())) {
|
|
27
|
+
return `~${value.slice(os.homedir().length)}`;
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
}
|
package/dist/home-dir.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"home-dir.d.ts","sourceRoot":"","sources":["../src/home-dir.ts"],"names":[],"mappings":"AAeA,wBAAgB,uBAAuB,CACrC,GAAG,GAAE,MAAM,CAAC,UAAwB,EACpC,OAAO,GAAE,MAAM,MAAmB,GACjC,MAAM,GAAG,SAAS,CAGpB;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,GAAE,MAAM,CAAC,UAAwB,EACpC,OAAO,GAAE,MAAM,MAAmB,GACjC,MAAM,GAAG,SAAS,CAGpB;
|
|
1
|
+
{"version":3,"file":"home-dir.d.ts","sourceRoot":"","sources":["../src/home-dir.ts"],"names":[],"mappings":"AAeA,wBAAgB,uBAAuB,CACrC,GAAG,GAAE,MAAM,CAAC,UAAwB,EACpC,OAAO,GAAE,MAAM,MAAmB,GACjC,MAAM,GAAG,SAAS,CAGpB;AAED,wBAAgB,gBAAgB,CAC9B,GAAG,GAAE,MAAM,CAAC,UAAwB,EACpC,OAAO,GAAE,MAAM,MAAmB,GACjC,MAAM,GAAG,SAAS,CAGpB;AAyCD,wBAAgB,sBAAsB,CACpC,GAAG,GAAE,MAAM,CAAC,UAAwB,EACpC,OAAO,GAAE,MAAM,MAAmB,GACjC,MAAM,CAER;AAED,wBAAgB,wBAAwB,CACtC,GAAG,GAAE,MAAM,CAAC,UAAwB,EACpC,OAAO,GAAE,MAAM,MAAmB,GACjC,MAAM,CAER;AAED,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE;IACL,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,MAAM,CAAC;CACxB,GACA,MAAM,CAYR;AAED,wBAAgB,uBAAuB,CACrC,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE;IACL,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,MAAM,CAAC;CACxB,GACA,MAAM,CAcR;AAED,wBAAgB,eAAe,CAC7B,KAAK,EAAE,MAAM,EACb,SAAS,CAAC,EACN;IACE,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,MAAM,CAAC;CACxB,GACD,MAAM,CAAC,UAAU,EACrB,OAAO,CAAC,EAAE,MAAM,MAAM,GACrB,MAAM,CAMR;AAED,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,MAAM,EACb,IAAI,CAAC,EAAE;IACL,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,OAAO,CAAC,EAAE,MAAM,MAAM,CAAC;CACxB,GACA,MAAM,CAcR"}
|
package/dist/home-dir.js
CHANGED
|
@@ -21,17 +21,21 @@ export function resolveOsHomeDir(env = process.env, homedir = os.homedir) {
|
|
|
21
21
|
}
|
|
22
22
|
function resolveRawHomeDir(env, homedir) {
|
|
23
23
|
const explicitHome = normalize(env.OPENCLAW_HOME);
|
|
24
|
-
if (explicitHome) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
return undefined;
|
|
31
|
-
}
|
|
24
|
+
if (!explicitHome) {
|
|
25
|
+
return resolveRawOsHomeDir(env, homedir);
|
|
26
|
+
}
|
|
27
|
+
const segments = path.normalize(explicitHome).split(path.sep);
|
|
28
|
+
if (segments[0] !== "~") {
|
|
32
29
|
return explicitHome;
|
|
33
30
|
}
|
|
34
|
-
|
|
31
|
+
// OPENCLAW_HOME starts with "~"; expand against the os home dir. Fall
|
|
32
|
+
// back to undefined when there is no os home to expand against rather
|
|
33
|
+
// than returning a raw "~"-prefixed path the caller cannot use.
|
|
34
|
+
const fallbackHome = resolveRawOsHomeDir(env, homedir);
|
|
35
|
+
if (!fallbackHome) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
return expandHomePrefix(explicitHome, { home: fallbackHome });
|
|
35
39
|
}
|
|
36
40
|
function resolveRawOsHomeDir(env, homedir) {
|
|
37
41
|
const envHome = normalize(env.HOME);
|
|
@@ -59,7 +63,8 @@ export function resolveRequiredOsHomeDir(env = process.env, homedir = os.homedir
|
|
|
59
63
|
return resolveOsHomeDir(env, homedir) ?? path.resolve(process.cwd());
|
|
60
64
|
}
|
|
61
65
|
export function expandHomePrefix(input, opts) {
|
|
62
|
-
|
|
66
|
+
const segments = path.normalize(input).split(path.sep);
|
|
67
|
+
if (segments[0] !== "~") {
|
|
63
68
|
return input;
|
|
64
69
|
}
|
|
65
70
|
const home = normalize(opts?.home) ??
|
|
@@ -67,22 +72,22 @@ export function expandHomePrefix(input, opts) {
|
|
|
67
72
|
if (!home) {
|
|
68
73
|
return input;
|
|
69
74
|
}
|
|
70
|
-
return
|
|
75
|
+
return path.join(home, ...segments.slice(1));
|
|
71
76
|
}
|
|
72
77
|
export function resolveHomeRelativePath(input, opts) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
return trimmed;
|
|
78
|
+
if (!input) {
|
|
79
|
+
return input;
|
|
76
80
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
env: opts?.env,
|
|
81
|
-
homedir: opts?.homedir,
|
|
82
|
-
});
|
|
83
|
-
return path.resolve(expanded);
|
|
81
|
+
const segments = path.normalize(input).split(path.sep);
|
|
82
|
+
if (segments[0] !== "~") {
|
|
83
|
+
return path.resolve(input);
|
|
84
84
|
}
|
|
85
|
-
|
|
85
|
+
const expanded = expandHomePrefix(input, {
|
|
86
|
+
home: resolveRequiredHomeDir(opts?.env ?? process.env, opts?.homedir ?? os.homedir),
|
|
87
|
+
env: opts?.env,
|
|
88
|
+
homedir: opts?.homedir,
|
|
89
|
+
});
|
|
90
|
+
return path.resolve(expanded);
|
|
86
91
|
}
|
|
87
92
|
export function resolveUserPath(input, optsOrEnv, homedir) {
|
|
88
93
|
const opts = optsOrEnv && ("env" in optsOrEnv || "homedir" in optsOrEnv)
|
|
@@ -91,17 +96,17 @@ export function resolveUserPath(input, optsOrEnv, homedir) {
|
|
|
91
96
|
return resolveHomeRelativePath(input, opts);
|
|
92
97
|
}
|
|
93
98
|
export function resolveOsHomeRelativePath(input, opts) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return trimmed;
|
|
99
|
+
if (!input) {
|
|
100
|
+
return input;
|
|
97
101
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
env: opts?.env,
|
|
102
|
-
homedir: opts?.homedir,
|
|
103
|
-
});
|
|
104
|
-
return path.resolve(expanded);
|
|
102
|
+
const segments = path.normalize(input).split(path.sep);
|
|
103
|
+
if (segments[0] !== "~") {
|
|
104
|
+
return path.resolve(input);
|
|
105
105
|
}
|
|
106
|
-
|
|
106
|
+
const expanded = expandHomePrefix(input, {
|
|
107
|
+
home: resolveRequiredOsHomeDir(opts?.env ?? process.env, opts?.homedir ?? os.homedir),
|
|
108
|
+
env: opts?.env,
|
|
109
|
+
homedir: opts?.homedir,
|
|
110
|
+
});
|
|
111
|
+
return path.resolve(expanded);
|
|
107
112
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare function safeDirName(input: string): string;
|
|
2
|
+
export declare function safePathSegmentHashed(input: string): string;
|
|
3
|
+
export declare function resolveSafeInstallDir(params: {
|
|
4
|
+
baseDir: string;
|
|
5
|
+
id: string;
|
|
6
|
+
invalidNameMessage: string;
|
|
7
|
+
nameEncoder?: (id: string) => string;
|
|
8
|
+
}): {
|
|
9
|
+
ok: true;
|
|
10
|
+
path: string;
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
error: string;
|
|
14
|
+
};
|
|
15
|
+
export declare function assertCanonicalPathWithinBase(params: {
|
|
16
|
+
baseDir: string;
|
|
17
|
+
candidatePath: string;
|
|
18
|
+
boundaryLabel: string;
|
|
19
|
+
}): Promise<void>;
|
|
20
|
+
//# sourceMappingURL=install-safe-path.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"install-safe-path.d.ts","sourceRoot":"","sources":["../src/install-safe-path.ts"],"names":[],"mappings":"AAKA,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAMjD;AAED,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAsB3D;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE;IAC5C,OAAO,EAAE,MAAM,CAAC;IAChB,EAAE,EAAE,MAAM,CAAC;IACX,kBAAkB,EAAE,MAAM,CAAC;IAC3B,WAAW,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,CAAC;CACtC,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAe5D;AAED,wBAAsB,6BAA6B,CAAC,MAAM,EAAE;IAC1D,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;CACvB,GAAG,OAAO,CAAC,IAAI,CAAC,CAkDhB"}
|