tinker-agent 2.5.0 → 2.6.0

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.
@@ -0,0 +1,245 @@
1
+ import path from "node:path";
2
+ import { chmod, lstat, mkdir, realpath, rmdir, unlink } from "node:fs/promises";
3
+ import type { SessionId } from "../ids/runtime-id";
4
+ import { canonicalHomeRoot, workspaceStorageRoot } from "./workspace-storage";
5
+ import { SessionError } from "./session-errors";
6
+
7
+ export async function assertPathMissing(
8
+ filePath: string,
9
+ sessionId: SessionId,
10
+ ): Promise<void> {
11
+ try {
12
+ await lstat(filePath);
13
+ } catch (error) {
14
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
15
+ return;
16
+ }
17
+ throw error;
18
+ }
19
+ throw new SessionError(
20
+ "SESSION_ALREADY_EXISTS",
21
+ "clone_session",
22
+ `Session directory already exists: ${filePath}.`,
23
+ { sessionId },
24
+ );
25
+ }
26
+
27
+ export async function canonicalWorkspaceRoot(workspaceRoot: string): Promise<string> {
28
+ if (!path.isAbsolute(workspaceRoot)) {
29
+ throw new Error("Session workspace root must be absolute.");
30
+ }
31
+ return realpath(workspaceRoot);
32
+ }
33
+
34
+ export async function ensureSessionsRoot(
35
+ workspaceRoot: string,
36
+ homeRoot?: string,
37
+ ): Promise<string> {
38
+ const tinkerRoot = workspaceStorageRoot(
39
+ workspaceRoot,
40
+ await canonicalHomeRoot(homeRoot),
41
+ );
42
+ const sessionsRoot = path.join(tinkerRoot, "sessions");
43
+ await mkdir(sessionsRoot, { recursive: true, mode: 0o700 });
44
+ await validateSessionsRoot(sessionsRoot);
45
+ await chmod(tinkerRoot, 0o700);
46
+ await chmod(sessionsRoot, 0o700);
47
+ return sessionsRoot;
48
+ }
49
+
50
+ export async function validateSessionsRoot(
51
+ sessionsRoot: string,
52
+ sessionId?: SessionId,
53
+ ): Promise<void> {
54
+ const tinkerRoot = path.dirname(sessionsRoot);
55
+ for (const directory of [tinkerRoot, sessionsRoot]) {
56
+ let stats;
57
+ try {
58
+ stats = await lstat(directory);
59
+ } catch (error) {
60
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
61
+ throw new SessionError(
62
+ "SESSION_STORE_NOT_FOUND",
63
+ "validate_session_root",
64
+ `Session root does not exist: ${directory}.`,
65
+ { sessionId, cause: error },
66
+ );
67
+ }
68
+ throw error;
69
+ }
70
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
71
+ throw new SessionError(
72
+ "SESSION_PERMISSION_INVALID",
73
+ "validate_session_root",
74
+ `Session root must not be a symlink: ${directory}.`,
75
+ { sessionId },
76
+ );
77
+ }
78
+ }
79
+ if ((await realpath(sessionsRoot)) !== sessionsRoot) {
80
+ throw new SessionError(
81
+ "SESSION_PERMISSION_INVALID",
82
+ "validate_session_root",
83
+ `Session root resolves outside its canonical path: ${sessionsRoot}.`,
84
+ { sessionId },
85
+ );
86
+ }
87
+ }
88
+
89
+ export function safeSessionDirectory(
90
+ sessionsRoot: string,
91
+ sessionId: SessionId,
92
+ ): string {
93
+ const value = String(sessionId);
94
+ if (
95
+ value.trim() === "" ||
96
+ value !== value.trim() ||
97
+ value.includes("/") ||
98
+ value.includes("\\") ||
99
+ value === "." ||
100
+ value === ".."
101
+ ) {
102
+ throw new SessionError(
103
+ "SESSION_ID_INVALID",
104
+ "resolve_session_path",
105
+ `Unsafe session ID: ${JSON.stringify(value)}.`,
106
+ { sessionId },
107
+ );
108
+ }
109
+ const directory = path.join(sessionsRoot, value);
110
+ if (path.dirname(directory) !== sessionsRoot) {
111
+ throw new SessionError(
112
+ "SESSION_ID_INVALID",
113
+ "resolve_session_path",
114
+ `Session ID escapes the sessions directory: ${JSON.stringify(value)}.`,
115
+ { sessionId },
116
+ );
117
+ }
118
+ return directory;
119
+ }
120
+
121
+ export async function validateSecureDirectory(
122
+ directory: string,
123
+ sessionId: SessionId,
124
+ ): Promise<void> {
125
+ let stats;
126
+ try {
127
+ stats = await lstat(directory);
128
+ } catch (error) {
129
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
130
+ throw new SessionError(
131
+ "SESSION_STORE_NOT_FOUND",
132
+ "open_session",
133
+ `Session directory does not exist: ${directory}.`,
134
+ { sessionId, cause: error },
135
+ );
136
+ }
137
+ throw error;
138
+ }
139
+ if (
140
+ !stats.isDirectory() ||
141
+ stats.isSymbolicLink() ||
142
+ (stats.mode & 0o077) !== 0 ||
143
+ (typeof process.getuid === "function" && stats.uid !== process.getuid())
144
+ ) {
145
+ throw new SessionError(
146
+ "SESSION_PERMISSION_INVALID",
147
+ "open_session",
148
+ `Session directory must be an owner-only real directory: ${directory}.`,
149
+ { sessionId },
150
+ );
151
+ }
152
+ }
153
+
154
+ export async function validateSecureFile(
155
+ filePath: string,
156
+ sessionId: SessionId,
157
+ ): Promise<void> {
158
+ let stats;
159
+ try {
160
+ stats = await lstat(filePath);
161
+ } catch (error) {
162
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
163
+ throw new SessionError(
164
+ "SESSION_STORE_NOT_FOUND",
165
+ "open_session",
166
+ `Session database does not exist: ${filePath}.`,
167
+ { sessionId, cause: error },
168
+ );
169
+ }
170
+ throw error;
171
+ }
172
+ if (
173
+ !stats.isFile() ||
174
+ stats.isSymbolicLink() ||
175
+ (stats.mode & 0o077) !== 0 ||
176
+ (typeof process.getuid === "function" && stats.uid !== process.getuid())
177
+ ) {
178
+ throw new SessionError(
179
+ "SESSION_PERMISSION_INVALID",
180
+ "open_session",
181
+ `Session database must be an owner-only regular file: ${filePath}.`,
182
+ { sessionId },
183
+ );
184
+ }
185
+ }
186
+
187
+ export async function validateSecureOptionalFile(
188
+ filePath: string,
189
+ sessionId: SessionId,
190
+ ): Promise<void> {
191
+ try {
192
+ await lstat(filePath);
193
+ } catch (error) {
194
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
195
+ return;
196
+ }
197
+ throw error;
198
+ }
199
+ await validateSecureFile(filePath, sessionId);
200
+ }
201
+
202
+ export async function removeKnownInitializationFiles(
203
+ sessionDirectory: string,
204
+ ): Promise<void> {
205
+ for (const name of [
206
+ "session.sqlite-wal",
207
+ "session.sqlite-shm",
208
+ "session.sqlite",
209
+ "events.jsonl",
210
+ "observations.md",
211
+ "active.lock.reclaim",
212
+ "active.lock",
213
+ ]) {
214
+ await unlinkIfExists(path.join(sessionDirectory, name));
215
+ }
216
+ try {
217
+ await rmdir(sessionDirectory);
218
+ } catch (error) {
219
+ if (
220
+ !new Set(["ENOENT", "ENOTEMPTY"]).has((error as NodeJS.ErrnoException).code ?? "")
221
+ ) {
222
+ throw error;
223
+ }
224
+ }
225
+ }
226
+
227
+ export async function chmodIfExists(filePath: string, mode: number): Promise<void> {
228
+ try {
229
+ await chmod(filePath, mode);
230
+ } catch (error) {
231
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
232
+ throw error;
233
+ }
234
+ }
235
+ }
236
+
237
+ export async function unlinkIfExists(filePath: string): Promise<void> {
238
+ try {
239
+ await unlink(filePath);
240
+ } catch (error) {
241
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
242
+ throw error;
243
+ }
244
+ }
245
+ }