hammerkit 1.26.0 → 1.27.0-next.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.
Files changed (118) hide show
  1. package/build.schema.json +53 -1
  2. package/dist/cache/backends/local-cache-backend.js +10 -2
  3. package/dist/cache/explain.js +13 -11
  4. package/dist/cli.js +3 -0
  5. package/dist/docker/item-network.js +471 -0
  6. package/dist/docker/pull.js +4 -2
  7. package/dist/docker/remove-orphaned-containers.js +13 -6
  8. package/dist/environment/replace-env-variables.js +8 -6
  9. package/dist/executer/await-completed-dependencies.js +2 -1
  10. package/dist/executer/check-readiness.js +2 -2
  11. package/dist/executer/docker-service.js +24 -13
  12. package/dist/executer/docker-task.js +18 -29
  13. package/dist/executer/event-cache.js +4 -0
  14. package/dist/executer/execute-command.js +18 -5
  15. package/dist/executer/execute-docker.js +4 -1
  16. package/dist/executer/execution-steps.js +33 -7
  17. package/dist/executer/get-docker-executor.js +14 -7
  18. package/dist/executer/scheduler/enqueue-next.js +17 -3
  19. package/dist/executer/watch-loop.js +2 -2
  20. package/dist/executer/watch-state-key.js +1 -1
  21. package/dist/file/within-root.js +42 -0
  22. package/dist/harness/connect.js +99 -0
  23. package/dist/harness/format.js +73 -0
  24. package/dist/harness/mcp-command.js +59 -0
  25. package/dist/harness/mcp.js +432 -0
  26. package/dist/harness/opencode.js +492 -0
  27. package/dist/harness/pi.js +217 -0
  28. package/dist/harness/sandbox-client.js +373 -0
  29. package/dist/index.js +33 -36
  30. package/dist/kubernetes/apply.js +31 -1
  31. package/dist/kubernetes/await-running-state.js +33 -1
  32. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +43 -21
  33. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
  34. package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
  35. package/dist/kubernetes/ensure-persistent-data.js +48 -7
  36. package/dist/kubernetes/get-service-ip.js +15 -1
  37. package/dist/kubernetes/job-placement.js +99 -0
  38. package/dist/kubernetes/job-secrets.js +306 -0
  39. package/dist/kubernetes/kept-claims.js +191 -0
  40. package/dist/kubernetes/kubernetes-instance.js +10 -2
  41. package/dist/kubernetes/network-gate.js +71 -0
  42. package/dist/kubernetes/network-policy.js +526 -0
  43. package/dist/kubernetes/resources.js +13 -2
  44. package/dist/kubernetes/restore-kubernetes-data.js +19 -4
  45. package/dist/kubernetes/secrets.js +2 -0
  46. package/dist/kubernetes/store-kubernetes-data.js +1 -1
  47. package/dist/kubernetes/volumes.js +100 -11
  48. package/dist/log.js +1 -1
  49. package/dist/main.js +41 -0
  50. package/dist/network/egress-proxy.js +368 -0
  51. package/dist/network/port-relay.js +35 -0
  52. package/dist/optimizer/get-work-cache-stats.js +16 -0
  53. package/dist/optimizer/work-task-cache-description.js +3 -2
  54. package/dist/planner/service-definition.js +5 -0
  55. package/dist/planner/utils/append-work-environment.js +2 -0
  56. package/dist/planner/utils/append-work-service.js +4 -0
  57. package/dist/planner/utils/append-work-task.js +17 -1
  58. package/dist/planner/utils/assert-item-paths.js +50 -0
  59. package/dist/planner/utils/hosted-volumes.js +35 -0
  60. package/dist/planner/work-network.js +193 -0
  61. package/dist/planner/work-runtime-docker.js +41 -0
  62. package/dist/planner/work-runtime-kubernetes.js +335 -102
  63. package/dist/program.js +23 -1
  64. package/dist/remote/annotations.js +103 -0
  65. package/dist/remote/bundle-text.js +24 -0
  66. package/dist/remote/bundle.js +9 -1
  67. package/dist/remote/remote-commands.js +4 -1
  68. package/dist/remote/remote-work-commands.js +182 -69
  69. package/dist/remote/server-client.js +18 -5
  70. package/dist/remote/source-manifest.js +15 -2
  71. package/dist/remote/token-source.js +67 -0
  72. package/dist/schema/build-file-base-task-schema.js +3 -0
  73. package/dist/schema/build-file-container-service-schema.js +6 -0
  74. package/dist/schema/build-file-container-task-schema.js +3 -0
  75. package/dist/schema/build-file-environment-schema.js +9 -0
  76. package/dist/schema/network-schema.js +78 -0
  77. package/dist/schema/reference-parser.js +1 -0
  78. package/dist/schema/schema-parser.js +16 -3
  79. package/dist/schema/work-scope-parser.js +5 -0
  80. package/dist/secrets/command-secret-provider.js +73 -10
  81. package/dist/secrets/github-app-token-command.js +26 -0
  82. package/dist/secrets/github-app-token.js +191 -0
  83. package/dist/server/accounts-api.js +9 -2
  84. package/dist/server/definitions-api.js +69 -47
  85. package/dist/server/definitions.js +76 -51
  86. package/dist/server/executor.js +156 -41
  87. package/dist/server/git-checkout.js +72 -8
  88. package/dist/server/kubernetes-backend.js +562 -0
  89. package/dist/server/kubernetes-sandbox-storage.js +996 -0
  90. package/dist/server/oauth.js +1 -0
  91. package/dist/server/plan.js +51 -1
  92. package/dist/server/policy.js +104 -15
  93. package/dist/server/proposals-api.js +24 -40
  94. package/dist/server/proposals.js +12 -25
  95. package/dist/server/repo-access.js +37 -6
  96. package/dist/server/run-logs.js +217 -0
  97. package/dist/server/run-plan.js +51 -2
  98. package/dist/server/run-request.js +23 -10
  99. package/dist/server/run-store.js +5 -0
  100. package/dist/server/run-worker-command.js +13 -1
  101. package/dist/server/run-worker.js +121 -24
  102. package/dist/server/runs.js +184 -10
  103. package/dist/server/sandbox-docker.js +62 -0
  104. package/dist/server/sandbox-files-api.js +269 -0
  105. package/dist/server/sandbox-files.js +1054 -0
  106. package/dist/server/sandbox-patch.js +481 -0
  107. package/dist/server/sandbox-pod-command.js +15 -0
  108. package/dist/server/sandbox-pod.js +146 -0
  109. package/dist/server/sandbox-storage.js +462 -0
  110. package/dist/server/sandboxes-api.js +210 -0
  111. package/dist/server/sandboxes.js +519 -0
  112. package/dist/server/server-config.js +412 -22
  113. package/dist/server/server.js +204 -39
  114. package/dist/utils/run-id.js +12 -0
  115. package/harness/opencode.ts +11 -0
  116. package/harness/pi.ts +7 -0
  117. package/package.json +8 -2
  118. package/dist/remote/text-diff.js +0 -89
@@ -0,0 +1,1054 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HostSandboxFiles = exports.isRunState = exports.RUN_STATE_DIRECTORY = exports.deniedPath = exports.checkPath = exports.SandboxFileError = exports.MAX_CHANGES_BYTES = exports.READ_LINES = exports.MAX_FILE_BYTES = void 0;
4
+ const child_process_1 = require("child_process");
5
+ const crypto_1 = require("crypto");
6
+ const fs_1 = require("fs");
7
+ const promises_1 = require("fs/promises");
8
+ const path_1 = require("path");
9
+ const minimatch_1 = require("minimatch");
10
+ const source_manifest_1 = require("../remote/source-manifest");
11
+ const sandbox_patch_1 = require("./sandbox-patch");
12
+ // The largest file the API reads into memory, edits or writes: the limit of an upload.
13
+ exports.MAX_FILE_BYTES = source_manifest_1.MAX_SOURCE_FILE_BYTES;
14
+ // what one typed read answers at most
15
+ exports.READ_LINES = 2000;
16
+ const MAX_READ_LINES = 10000;
17
+ const MAX_READ_BYTES = 256 * 1024;
18
+ const MAX_LINE_CHARS = 2000;
19
+ const TREE_LIMIT = 1000;
20
+ const MAX_TREE_LIMIT = 10000;
21
+ const SEARCH_LIMIT = 250;
22
+ const MAX_SEARCH_LIMIT = 5000;
23
+ // a search counts its results up to this many past the page, then stops ripgrep
24
+ const SEARCH_COUNT = 20000;
25
+ const SEARCH_TIMEOUT_MS = 60000;
26
+ // the most a diff or patch of the changes may take
27
+ exports.MAX_CHANGES_BYTES = 64 * 1024 * 1024;
28
+ class SandboxFileError extends Error {
29
+ constructor(status, message, details = {}) {
30
+ super(message);
31
+ this.status = status;
32
+ this.details = details;
33
+ }
34
+ }
35
+ exports.SandboxFileError = SandboxFileError;
36
+ /**
37
+ * A path as the API takes it: relative, `/` between names, no `.` or `..`, and nothing the
38
+ * deny list of uploads names (`.git/`, `.env`, keys).
39
+ */
40
+ function checkPath(path) {
41
+ if (path.length === 0 ||
42
+ path.length > 4096 ||
43
+ path.startsWith('/') ||
44
+ path.includes('\\') ||
45
+ path.includes('\0') ||
46
+ /^[A-Za-z]:/.test(path) ||
47
+ path.split('/').some((part) => part === '' || part === '.' || part === '..')) {
48
+ throw new SandboxFileError(400, `${JSON.stringify(path.slice(0, 200))} is not a path in the sandbox: relative, / between names, no . or ..`);
49
+ }
50
+ const denied = deniedPath(path);
51
+ if (denied) {
52
+ throw new SandboxFileError(403, `${path}: ${denied}, the API does not read or write it`);
53
+ }
54
+ return path;
55
+ }
56
+ exports.checkPath = checkPath;
57
+ // The deny list of uploads, with the last name taken as a directory too, so `.git` itself
58
+ // is as denied as what is in it.
59
+ function deniedPath(path) {
60
+ return (0, source_manifest_1.deniedSourcePath)(path) ?? (0, source_manifest_1.deniedSourcePath)(`${path}/-`);
61
+ }
62
+ exports.deniedPath = deniedPath;
63
+ const DENIED_DIRECTORIES = ['.git', '.ssh', '.aws', '.gnupg', '.kube'];
64
+ // What hammerkit itself writes next to a task while it runs: `.hammerkit/` in the task's
65
+ // directory, at any depth (the last resolved cache entry, the state of local tasks). It is
66
+ // the run's, not a change to the sandbox's files, so changes and pushes leave it out.
67
+ exports.RUN_STATE_DIRECTORY = '.hammerkit';
68
+ function isRunState(path) {
69
+ return path.split('/').slice(0, -1).includes(exports.RUN_STATE_DIRECTORY);
70
+ }
71
+ exports.isRunState = isRunState;
72
+ function encodeCursor(value) {
73
+ return Buffer.from(JSON.stringify(value)).toString('base64url');
74
+ }
75
+ function decodeCursor(cursor, type) {
76
+ if (cursor === undefined || cursor === '') {
77
+ return undefined;
78
+ }
79
+ try {
80
+ const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
81
+ if (typeof value === type) {
82
+ return value;
83
+ }
84
+ }
85
+ catch {
86
+ // answered below
87
+ }
88
+ throw new SandboxFileError(400, 'the cursor is not one this API gave out');
89
+ }
90
+ function clamp(value, fallback, max) {
91
+ return Math.min(Math.max(1, Math.floor(value ?? fallback)), max);
92
+ }
93
+ // A commit of `tree` (the index when left out) on top of the commit the sandbox is at.
94
+ async function writeCommit(run, author, tree) {
95
+ const identity = {
96
+ GIT_AUTHOR_NAME: author.name,
97
+ GIT_AUTHOR_EMAIL: author.email,
98
+ GIT_COMMITTER_NAME: author.name,
99
+ GIT_COMMITTER_EMAIL: author.email,
100
+ };
101
+ const written = tree ?? (await run(['write-tree'], 'read')).trim();
102
+ return (await run(['commit-tree', written, '-p', 'HEAD', '-m', author.message], 'read', { env: identity })).trim();
103
+ }
104
+ function sha256(content) {
105
+ return (0, crypto_1.createHash)('sha256').update(content).digest('hex');
106
+ }
107
+ function isNotFound(e) {
108
+ const code = e.code;
109
+ return code === 'ENOENT' || code === 'ENOTDIR';
110
+ }
111
+ const utf8 = new TextDecoder('utf-8', { fatal: true });
112
+ // The text of a file, or why it has none.
113
+ function decodeText(path, content) {
114
+ if (content.subarray(0, 8000).includes(0)) {
115
+ throw new SandboxFileError(415, `${path} is a binary file, read it raw (?raw) or write it with PUT`);
116
+ }
117
+ try {
118
+ return utf8.decode(content);
119
+ }
120
+ catch {
121
+ throw new SandboxFileError(415, `${path} is not UTF-8 text, read it raw (?raw) or write it with PUT`);
122
+ }
123
+ }
124
+ // Files larger than this are read one by one, across sandboxes: a read holds a few times
125
+ // the size of its file at once.
126
+ const LARGE_FILE_BYTES = 1024 * 1024;
127
+ const LARGE_READS = 4;
128
+ let largeReads = 0;
129
+ const waitingReads = [];
130
+ async function readLimited(size, fn) {
131
+ if (size <= LARGE_FILE_BYTES) {
132
+ return fn();
133
+ }
134
+ if (largeReads < LARGE_READS) {
135
+ largeReads++;
136
+ }
137
+ else {
138
+ // the read that finishes hands its place over
139
+ await new Promise((resolve) => waitingReads.push(resolve));
140
+ }
141
+ try {
142
+ return await fn();
143
+ }
144
+ finally {
145
+ const next = waitingReads.shift();
146
+ if (next) {
147
+ next();
148
+ }
149
+ else {
150
+ largeReads--;
151
+ }
152
+ }
153
+ }
154
+ // The bytes of an open file, as many as it had when it was opened: a task of a run may
155
+ // still be writing to it.
156
+ async function readOpened(handle, size) {
157
+ const content = Buffer.alloc(size);
158
+ let read = 0;
159
+ while (read < size) {
160
+ const { bytesRead } = await handle.read(content, read, size - read, read);
161
+ if (bytesRead === 0) {
162
+ break;
163
+ }
164
+ read += bytesRead;
165
+ }
166
+ return content.subarray(0, read);
167
+ }
168
+ function numbered(line, text) {
169
+ const shown = text.length > MAX_LINE_CHARS ? `${text.slice(0, MAX_LINE_CHARS)}… [${text.length} chars]` : text;
170
+ return `${String(line).padStart(6)}\t${shown}`;
171
+ }
172
+ function fileType(stats) {
173
+ return stats.isSymbolicLink() ? 'symlink' : stats.isDirectory() ? 'directory' : stats.isFile() ? 'file' : 'other';
174
+ }
175
+ /**
176
+ * The files of a sandbox in a directory of this machine. Every path is checked name by
177
+ * name with `lstat`, so a symbolic link a task made is never followed; a file is opened
178
+ * without following a link, and on Linux the opened file is checked to be the one in the
179
+ * sandbox, in case a task swapped a directory for a link in between.
180
+ */
181
+ class HostSandboxFiles {
182
+ constructor(root, git,
183
+ // the environment of ripgrep: the variables runs see
184
+ env,
185
+ // the most a diff or patch of the changes may take
186
+ maxChangesBytes = exports.MAX_CHANGES_BYTES) {
187
+ this.root = root;
188
+ this.git = git;
189
+ this.env = env;
190
+ this.maxChangesBytes = maxChangesBytes;
191
+ }
192
+ absolute(path) {
193
+ return (0, path_1.join)(this.root, ...path.split('/'));
194
+ }
195
+ // Whether the directories above `path` are there; a link or a file among them is an error.
196
+ async parents(path) {
197
+ const parts = path.split('/');
198
+ let current = this.root;
199
+ for (let i = 0; i < parts.length - 1; i++) {
200
+ current = (0, path_1.join)(current, parts[i]);
201
+ const stats = await (0, promises_1.lstat)(current).catch((e) => {
202
+ if (isNotFound(e)) {
203
+ return undefined;
204
+ }
205
+ throw e;
206
+ });
207
+ if (!stats) {
208
+ return false;
209
+ }
210
+ const prefix = parts.slice(0, i + 1).join('/');
211
+ if (stats.isSymbolicLink()) {
212
+ throw new SandboxFileError(400, `${prefix} is a symbolic link, the API does not follow links`);
213
+ }
214
+ if (!stats.isDirectory()) {
215
+ throw new SandboxFileError(400, `${prefix} is a file, not a directory`);
216
+ }
217
+ }
218
+ return true;
219
+ }
220
+ async stat(path) {
221
+ checkPath(path);
222
+ if (!(await this.parents(path))) {
223
+ return undefined;
224
+ }
225
+ const absolute = this.absolute(path);
226
+ const stats = await (0, promises_1.lstat)(absolute).catch((e) => {
227
+ if (isNotFound(e)) {
228
+ return undefined;
229
+ }
230
+ throw e;
231
+ });
232
+ if (!stats) {
233
+ return undefined;
234
+ }
235
+ const type = fileType(stats);
236
+ return {
237
+ path,
238
+ type,
239
+ size: type === 'file' || type === 'symlink' ? stats.size : 0,
240
+ exec: type === 'file' && (stats.mode & 0o111) !== 0,
241
+ modifiedAt: stats.mtime.toISOString(),
242
+ ...(type === 'symlink' ? { target: await (0, promises_1.readlink)(absolute) } : {}),
243
+ };
244
+ }
245
+ // Opens a file of the sandbox for reading, never through a link.
246
+ async open(path) {
247
+ const found = await this.stat(path);
248
+ if (!found) {
249
+ throw new SandboxFileError(404, `${path} does not exist`);
250
+ }
251
+ if (found.type === 'symlink') {
252
+ throw new SandboxFileError(400, `${path} is a symbolic link to ${found.target}, the API does not follow links`);
253
+ }
254
+ if (found.type === 'directory') {
255
+ throw new SandboxFileError(400, `${path} is a directory, list it with tree`);
256
+ }
257
+ if (found.type === 'other') {
258
+ throw new SandboxFileError(400, `${path} is a pipe, a socket or a device, not a file`);
259
+ }
260
+ const absolute = this.absolute(path);
261
+ // without O_NONBLOCK, opening a pipe a task swapped in after the check waits for a writer
262
+ const flags = fs_1.constants.O_RDONLY | (fs_1.constants.O_NOFOLLOW ?? 0) | (fs_1.constants.O_NONBLOCK ?? 0);
263
+ const handle = await (0, promises_1.open)(absolute, flags).catch((e) => {
264
+ if (isNotFound(e)) {
265
+ throw new SandboxFileError(404, `${path} does not exist`);
266
+ }
267
+ if (e.code === 'ELOOP') {
268
+ throw new SandboxFileError(400, `${path} is a symbolic link, the API does not follow links`);
269
+ }
270
+ throw e;
271
+ });
272
+ try {
273
+ const stats = await handle.stat();
274
+ if (!stats.isFile()) {
275
+ throw new SandboxFileError(400, `${path} is not a file`);
276
+ }
277
+ await this.mustBeInside(handle, absolute, path);
278
+ return { handle, stat: { ...found, size: stats.size } };
279
+ }
280
+ catch (e) {
281
+ await handle.close();
282
+ throw e;
283
+ }
284
+ }
285
+ // The opened file is the one in the sandbox: a task may have swapped a directory above it
286
+ // for a link after the names were checked. Linux names the file a descriptor is open on;
287
+ // elsewhere the path has to lead to the same file and to no link.
288
+ async mustBeInside(handle, absolute, path) {
289
+ let actual;
290
+ if (process.platform === 'linux') {
291
+ actual = await (0, promises_1.readlink)(`/proc/self/fd/${handle.fd}`).catch(() => undefined);
292
+ }
293
+ if (actual === undefined) {
294
+ const [opened, named] = await Promise.all([handle.stat(), (0, promises_1.lstat)(absolute)]);
295
+ if (opened.ino !== named.ino || opened.dev !== named.dev) {
296
+ throw new SandboxFileError(400, `${path} changed while it was opened, read it again`);
297
+ }
298
+ actual = await (0, promises_1.realpath)(absolute);
299
+ }
300
+ this.realRoot ?? (this.realRoot = (0, promises_1.realpath)(this.root));
301
+ const expected = (0, path_1.join)(await this.realRoot, ...path.split('/'));
302
+ const same = process.platform === 'win32' || process.platform === 'darwin'
303
+ ? (0, path_1.normalize)(actual).toLowerCase() === (0, path_1.normalize)(expected).toLowerCase()
304
+ : (0, path_1.normalize)(actual) === (0, path_1.normalize)(expected);
305
+ if (!same) {
306
+ throw new SandboxFileError(400, `${path} is reached through a symbolic link, the API does not follow links`);
307
+ }
308
+ }
309
+ async read(path, query) {
310
+ const { handle, stat } = await this.open(path);
311
+ if (stat.size > exports.MAX_FILE_BYTES) {
312
+ await handle.close();
313
+ throw new SandboxFileError(413, `${path} takes ${stat.size} bytes, more than a read answers: read it raw`);
314
+ }
315
+ return readLimited(stat.size, async () => {
316
+ let content;
317
+ try {
318
+ content = await readOpened(handle, stat.size);
319
+ }
320
+ finally {
321
+ await handle.close();
322
+ }
323
+ return this.lines(path, content, query);
324
+ });
325
+ }
326
+ lines(path, content, query) {
327
+ const text = decodeText(path, content);
328
+ const offset = Math.max(1, Math.floor(query.offset ?? 1));
329
+ const limit = clamp(query.limit, exports.READ_LINES, MAX_READ_LINES);
330
+ const shown = [];
331
+ let bytes = 0;
332
+ let next = offset;
333
+ let full = false;
334
+ // line by line: splitting a file of empty lines at once takes many times its size
335
+ let lines = 0;
336
+ for (let start = 0; start < text.length;) {
337
+ const end = text.indexOf('\n', start);
338
+ const stop = end < 0 ? text.length : end;
339
+ lines++;
340
+ if (!full && lines === next && next < offset + limit) {
341
+ const line = numbered(next, text.slice(start, stop));
342
+ if (shown.length > 0 && bytes + line.length > MAX_READ_BYTES) {
343
+ full = true;
344
+ }
345
+ else {
346
+ shown.push(line);
347
+ bytes += line.length + 1;
348
+ next++;
349
+ }
350
+ }
351
+ start = stop + 1;
352
+ }
353
+ const truncated = next <= lines;
354
+ return {
355
+ path,
356
+ sha256: sha256(content),
357
+ size: content.length,
358
+ lines,
359
+ offset,
360
+ content: shown.join('\n'),
361
+ truncated,
362
+ nextOffset: truncated ? next : null,
363
+ };
364
+ }
365
+ async raw(path) {
366
+ const { handle, stat } = await this.open(path);
367
+ if (stat.size > exports.MAX_FILE_BYTES) {
368
+ return { stat, stream: handle.createReadStream({ start: 0, end: Math.max(0, stat.size - 1) }) };
369
+ }
370
+ return readLimited(stat.size, async () => {
371
+ try {
372
+ const content = await readOpened(handle, stat.size);
373
+ return { stat: { ...stat, size: content.length }, content, sha256: sha256(content) };
374
+ }
375
+ finally {
376
+ await handle.close();
377
+ }
378
+ });
379
+ }
380
+ // The files git sees in the sandbox below `path`: committed or not, without what
381
+ // `.gitignore` leaves out, without the deny list.
382
+ async listFiles(path) {
383
+ const spec = path ? ['--', `:(literal)${path}`] : [];
384
+ const list = async (args) => (await this.git.run(['ls-files', '-z', ...args, ...spec], 'list the files of', { maxBuffer: 256 * 1024 * 1024 }))
385
+ .split('\0')
386
+ .filter((file) => file.length > 0);
387
+ const deleted = new Set(await list(['--deleted']));
388
+ const files = await list(['--cached', '--others', '--exclude-standard']);
389
+ return [...new Set(files)].filter((file) => !deleted.has(file) && !deniedPath(file.replace(/\/$/, '')));
390
+ }
391
+ async tree(query) {
392
+ const path = query.path ? checkPath(query.path) : undefined;
393
+ if (path) {
394
+ const found = await this.stat(path);
395
+ if (!found) {
396
+ throw new SandboxFileError(404, `${path} does not exist`);
397
+ }
398
+ if (found.type !== 'directory') {
399
+ const what = { file: 'file, read it', symlink: 'symbolic link', other: 'pipe, a socket or a device' };
400
+ throw new SandboxFileError(400, `${path} is a ${what[found.type]}`);
401
+ }
402
+ }
403
+ const depth = clamp(query.depth, 1, 64);
404
+ const limit = clamp(query.limit, TREE_LIMIT, MAX_TREE_LIMIT);
405
+ const after = decodeCursor(query.cursor, 'string');
406
+ const glob = query.glob ? new minimatch_1.Minimatch(query.glob, { dot: true }) : undefined;
407
+ const prefix = path ? `${path}/` : '';
408
+ const entries = new Map();
409
+ for (const listed of await this.listFiles(path)) {
410
+ // an untracked repository inside the sandbox is listed as `dir/`
411
+ const directory = listed.endsWith('/');
412
+ const file = directory ? listed.slice(0, -1) : listed;
413
+ const relative = file.slice(prefix.length);
414
+ if (glob) {
415
+ if (!directory && glob.match(relative)) {
416
+ entries.set(file, 'file');
417
+ }
418
+ continue;
419
+ }
420
+ const parts = relative.split('/');
421
+ for (let level = 1; level <= Math.min(depth, parts.length); level++) {
422
+ const entry = prefix + parts.slice(0, level).join('/');
423
+ entries.set(entry, level < parts.length || directory ? 'directory' : 'file');
424
+ }
425
+ }
426
+ const all = [...entries.keys()].sort().filter((entry) => after === undefined || entry > after);
427
+ const page = all.slice(0, limit);
428
+ const result = [];
429
+ for (const entry of page) {
430
+ const type = entries.get(entry);
431
+ if (type === 'directory') {
432
+ result.push({ path: entry, type });
433
+ continue;
434
+ }
435
+ const stats = await (0, promises_1.lstat)(this.absolute(entry)).catch(() => undefined);
436
+ const found = stats ? fileType(stats) : 'file';
437
+ result.push(found === 'symlink'
438
+ ? { path: entry, type: found, target: await (0, promises_1.readlink)(this.absolute(entry)).catch(() => '') }
439
+ : found === 'other'
440
+ ? { path: entry, type: found }
441
+ : { path: entry, type: 'file', size: stats?.size ?? 0 });
442
+ }
443
+ const truncated = all.length > page.length;
444
+ return {
445
+ path: path ?? '',
446
+ entries: result,
447
+ total: entries.size,
448
+ truncated,
449
+ cursor: truncated ? encodeCursor(page[page.length - 1]) : null,
450
+ };
451
+ }
452
+ async search(query) {
453
+ const mode = query.output_mode ?? 'files_with_matches';
454
+ const limit = clamp(query.head_limit, SEARCH_LIMIT, MAX_SEARCH_LIMIT);
455
+ const skip = decodeCursor(query.cursor, 'number') ?? 0;
456
+ const path = query.path ? checkPath(query.path) : undefined;
457
+ if (path) {
458
+ const found = await this.stat(path);
459
+ if (!found) {
460
+ throw new SandboxFileError(404, `${path} does not exist`);
461
+ }
462
+ if (found.type === 'symlink') {
463
+ throw new SandboxFileError(400, `${path} is a symbolic link, the API does not follow links`);
464
+ }
465
+ if (found.type === 'other') {
466
+ throw new SandboxFileError(400, `${path} is a pipe, a socket or a device, not a file`);
467
+ }
468
+ }
469
+ if (query.type !== undefined && !/^[A-Za-z0-9_+-]{1,40}$/.test(query.type)) {
470
+ throw new SandboxFileError(400, `${JSON.stringify(query.type.slice(0, 40))} is not a file type of ripgrep`);
471
+ }
472
+ const args = [
473
+ '--no-config',
474
+ // `.gitignore` counts although the git data is not in the files
475
+ '--no-require-git',
476
+ '--hidden',
477
+ '--sort=path',
478
+ '--path-separator=/',
479
+ `--max-filesize=${exports.MAX_FILE_BYTES}`,
480
+ '--color=never',
481
+ '--no-messages',
482
+ ];
483
+ if (query.i) {
484
+ args.push('--ignore-case');
485
+ }
486
+ if (query.fixed_strings) {
487
+ args.push('--fixed-strings');
488
+ }
489
+ if (query.multiline) {
490
+ args.push('--multiline', '--multiline-dotall');
491
+ }
492
+ for (const glob of query.glob ?? []) {
493
+ args.push(`--glob=${glob}`);
494
+ }
495
+ // after the caller's globs, which would otherwise take precedence
496
+ // in any case, as a case-insensitive disk opens `.SSH` as `.ssh`; the paths found are
497
+ // checked against the deny list as well
498
+ for (const directory of DENIED_DIRECTORIES) {
499
+ args.push(`--iglob=!${directory}`);
500
+ }
501
+ if (query.type) {
502
+ args.push(`--type=${query.type}`);
503
+ }
504
+ if (mode === 'content') {
505
+ args.push('--json');
506
+ for (const flag of ['A', 'B', 'C']) {
507
+ const value = query[flag];
508
+ if (value !== undefined) {
509
+ args.push(`-${flag}`, String(Math.min(Math.max(0, Math.floor(value)), 100)));
510
+ }
511
+ }
512
+ }
513
+ else if (mode === 'files_with_matches') {
514
+ args.push('--files-with-matches', '--null');
515
+ }
516
+ else {
517
+ args.push('--count-matches', '--null');
518
+ }
519
+ args.push('--regexp', query.pattern, '--', path ?? '.');
520
+ const result = { output_mode: mode, total: 0, exact: true, truncated: false, cursor: null };
521
+ const files = [];
522
+ const matches = [];
523
+ const counts = [];
524
+ let total = 0;
525
+ const found = (add) => {
526
+ total++;
527
+ if (total > skip && total <= skip + limit) {
528
+ add();
529
+ }
530
+ return total < skip + limit + SEARCH_COUNT;
531
+ };
532
+ // the JSON of ripgrep names paths with the separator of the system, whatever --path-separator says
533
+ const clean = (file) => {
534
+ const path = process.platform === 'win32' ? file.replace(/\\/g, '/') : file;
535
+ return path.startsWith('./') ? path.slice(2) : path;
536
+ };
537
+ const onRecord = (record) => {
538
+ if (mode === 'files_with_matches') {
539
+ const file = clean(record);
540
+ return deniedPath(file) ? true : found(() => files.push(file));
541
+ }
542
+ if (mode === 'count') {
543
+ const [file, count] = record.split('\0');
544
+ return deniedPath(clean(file)) ? true : found(() => counts.push({ path: clean(file), count: Number(count) }));
545
+ }
546
+ const message = JSON.parse(record);
547
+ if (message.type !== 'match' && message.type !== 'context') {
548
+ return true;
549
+ }
550
+ const file = clean(message.data.path?.text ?? '');
551
+ if (!file || deniedPath(file)) {
552
+ return true;
553
+ }
554
+ const lines = (message.data.lines?.text ?? '').replace(/\n$/, '').split('\n');
555
+ return lines.every((text, index) => found(() => matches.push({
556
+ path: file,
557
+ line: (message.data.line_number ?? 0) + index,
558
+ text: text.length > MAX_LINE_CHARS ? `${text.slice(0, MAX_LINE_CHARS)}… [${text.length} chars]` : text,
559
+ ...(message.type === 'context' ? { context: true } : {}),
560
+ })));
561
+ };
562
+ const { code, stderr, stopped } = await this.ripgrep(args, mode === 'files_with_matches' ? '\0' : '\n', onRecord);
563
+ if (code === 2 && total === 0 && !stopped) {
564
+ throw new SandboxFileError(400, stderr.trim() || 'ripgrep failed');
565
+ }
566
+ result.total = total;
567
+ result.exact = !stopped;
568
+ result.truncated = total > skip + limit;
569
+ result.cursor = result.truncated ? encodeCursor(skip + limit) : null;
570
+ if (mode === 'files_with_matches') {
571
+ result.files = files;
572
+ }
573
+ else if (mode === 'count') {
574
+ result.counts = counts;
575
+ }
576
+ else {
577
+ result.matches = matches;
578
+ }
579
+ return result;
580
+ }
581
+ // Runs ripgrep in the files and hands each record of its output to `onRecord`, which
582
+ // answers whether to read on.
583
+ ripgrep(args, separator, onRecord) {
584
+ const env = {};
585
+ for (const [key, value] of Object.entries(this.env)) {
586
+ if (value !== undefined && !key.toUpperCase().startsWith('RIPGREP_')) {
587
+ env[key] = value;
588
+ }
589
+ }
590
+ return new Promise((resolve, reject) => {
591
+ const child = (0, child_process_1.spawn)('rg', args, {
592
+ cwd: this.root,
593
+ env,
594
+ stdio: ['ignore', 'pipe', 'pipe'],
595
+ signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS),
596
+ });
597
+ let stopped = false;
598
+ let buffered = '';
599
+ let stderr = '';
600
+ const take = (chunk) => {
601
+ buffered += chunk;
602
+ let at;
603
+ while (!stopped && (at = buffered.indexOf(separator)) >= 0) {
604
+ const record = buffered.slice(0, at);
605
+ buffered = buffered.slice(at + 1);
606
+ if (record.length > 0 && !onRecord(record)) {
607
+ stopped = true;
608
+ child.kill();
609
+ }
610
+ }
611
+ };
612
+ child.stdout.setEncoding('utf8').on('data', take);
613
+ child.stderr.setEncoding('utf8').on('data', (chunk) => {
614
+ stderr = (stderr + chunk).slice(-4000);
615
+ });
616
+ child.on('error', (e) => {
617
+ if (e.code === 'ENOENT') {
618
+ reject(new SandboxFileError(501, 'ripgrep (rg) is not installed on the server, search needs it'));
619
+ }
620
+ else if (e.name === 'AbortError') {
621
+ reject(new SandboxFileError(504, `the search took longer than ${SEARCH_TIMEOUT_MS / 1000}s, narrow it`));
622
+ }
623
+ else {
624
+ reject(e);
625
+ }
626
+ });
627
+ child.on('close', (code) => {
628
+ if (!stopped && buffered.length > 0) {
629
+ onRecord(buffered);
630
+ }
631
+ resolve({ code, stderr, stopped });
632
+ });
633
+ });
634
+ }
635
+ // The index of `fn` holds the files as they are, without what `.gitignore` leaves out and
636
+ // without the deny list, so nothing on it leaves the sandbox through a diff. A repository a
637
+ // task made inside the files is left out too: git adds it as a link to a commit of it,
638
+ // whose id is not the sandbox's to tell. So is hammerkit's own run state (`isRunState`).
639
+ async withFilesIndex(fn) {
640
+ return this.git.withIndex(async (run) => {
641
+ // `:<old mode> <new mode> <old id> <new id> <status>\0<path>\0`
642
+ const raw = (await run(['diff', '--cached', '--raw', '-z', '--no-renames', 'HEAD'], 'read')).split('\0');
643
+ const changed = [];
644
+ for (let i = 0; i + 1 < raw.length; i += 2) {
645
+ const file = raw[i + 1];
646
+ if (deniedPath(file) || isRunState(file) || raw[i].split(' ')[1] === '160000') {
647
+ changed.push(file);
648
+ }
649
+ }
650
+ if (changed.length > 0) {
651
+ await run(['reset', '--quiet', 'HEAD', '--', ...changed.map((file) => `:(literal)${file}`)], 'read');
652
+ }
653
+ return fn(run);
654
+ });
655
+ }
656
+ async changes() {
657
+ return this.withFilesIndex(async (run) => {
658
+ const statuses = (await run(['diff', '--cached', '-M', '--name-status', '-z', 'HEAD'], 'read')).split('\0');
659
+ const numbers = (await run(['diff', '--cached', '-M', '--numstat', '-z', 'HEAD'], 'read')).split('\0');
660
+ const files = [];
661
+ for (let i = 0; i < statuses.length - 1;) {
662
+ const status = statuses[i++];
663
+ const letter = status[0];
664
+ const from = letter === 'R' || letter === 'C' ? statuses[i++] : undefined;
665
+ const path = statuses[i++];
666
+ files.push({
667
+ path,
668
+ status: { A: 'added', D: 'deleted', R: 'renamed', C: 'copied', T: 'type' }[letter] ?? 'modified',
669
+ ...(from !== undefined ? { from } : {}),
670
+ additions: null,
671
+ deletions: null,
672
+ });
673
+ }
674
+ // `added\tdeleted\tpath\0`, or for a rename `added\tdeleted\t\0from\0to\0`
675
+ const byPath = new Map(files.map((file) => [file.path, file]));
676
+ for (let i = 0; i < numbers.length - 1;) {
677
+ const [added, deleted, path] = numbers[i++].split('\t');
678
+ const to = path === '' ? (i++, numbers[i++]) : path;
679
+ const file = byPath.get(to);
680
+ if (file && added !== '-') {
681
+ file.additions = Number(added);
682
+ file.deletions = Number(deleted);
683
+ }
684
+ }
685
+ return files;
686
+ });
687
+ }
688
+ async diff(format, author) {
689
+ return this.withFilesIndex(async (run) => {
690
+ if (format === 'diff') {
691
+ return run(['diff', '--cached', '-M', 'HEAD'], 'read', { maxBuffer: this.maxChangesBytes });
692
+ }
693
+ // a commit of the changes on top of the commit the sandbox is at, which no branch
694
+ // points to, written out as `git format-patch` writes it
695
+ const commit = await writeCommit(run, author);
696
+ return run(['format-patch', '-1', '--stdout', '--binary', '-M', commit], 'read', {
697
+ maxBuffer: this.maxChangesBytes,
698
+ });
699
+ });
700
+ }
701
+ async commit(author) {
702
+ return this.withFilesIndex(async (run) => {
703
+ const tree = (await run(['write-tree'], 'read')).trim();
704
+ const base = (await run(['rev-parse', 'HEAD^{tree}'], 'read')).trim();
705
+ return tree === base ? undefined : writeCommit(run, author, tree);
706
+ });
707
+ }
708
+ async plan(ops) {
709
+ const batch = new EditBatch(this);
710
+ for (const [index, op] of ops.entries()) {
711
+ try {
712
+ await batch.apply(op);
713
+ }
714
+ catch (e) {
715
+ if (e instanceof SandboxFileError) {
716
+ throw new SandboxFileError(e.status, `op ${index} (${op.op}): ${e.message}`, { ...e.details, op: index });
717
+ }
718
+ if (e instanceof sandbox_patch_1.PatchError) {
719
+ throw new SandboxFileError(422, `op ${index} (patch): ${e.message}`, { op: index });
720
+ }
721
+ throw e;
722
+ }
723
+ }
724
+ return batch.plan();
725
+ }
726
+ // What a path holds now, for an edit: its kind, and a file's content when it is read.
727
+ async load(path) {
728
+ if (!(await this.parents(path))) {
729
+ return { kind: 'absent' };
730
+ }
731
+ const absolute = this.absolute(path);
732
+ const stats = await (0, promises_1.lstat)(absolute).catch((e) => {
733
+ if (isNotFound(e)) {
734
+ return undefined;
735
+ }
736
+ throw e;
737
+ });
738
+ if (!stats) {
739
+ return { kind: 'absent' };
740
+ }
741
+ if (stats.isSymbolicLink()) {
742
+ return { kind: 'link' };
743
+ }
744
+ if (stats.isDirectory()) {
745
+ return { kind: 'directory' };
746
+ }
747
+ return { kind: 'file', size: stats.size, exec: (stats.mode & 0o111) !== 0, mode: stats.mode & 0o7777 };
748
+ }
749
+ async content(path, state) {
750
+ if (!state.content) {
751
+ if (state.size > exports.MAX_FILE_BYTES) {
752
+ throw new SandboxFileError(413, `${path} takes ${state.size} bytes, more than an edit reads`);
753
+ }
754
+ const { handle, stat } = await this.open(path);
755
+ try {
756
+ // the size checked above is the one when the edit started
757
+ if (stat.size > exports.MAX_FILE_BYTES) {
758
+ throw new SandboxFileError(413, `${path} takes ${stat.size} bytes, more than an edit reads`);
759
+ }
760
+ state.content = await readLimited(stat.size, () => readOpened(handle, stat.size));
761
+ }
762
+ finally {
763
+ await handle.close();
764
+ }
765
+ }
766
+ return state.content;
767
+ }
768
+ // Writes the changes: what a path held is moved aside first, and put back when a later
769
+ // write fails.
770
+ async write(changes) {
771
+ const backups = [];
772
+ const written = [];
773
+ // the directories above every path are still the ones the edits were planned in
774
+ for (const [path] of changes) {
775
+ await this.sameParents(path);
776
+ }
777
+ try {
778
+ for (const [path, before] of changes) {
779
+ if (before.kind === 'file' || before.kind === 'link') {
780
+ const absolute = this.absolute(path);
781
+ const backup = (0, path_1.join)((0, path_1.dirname)(absolute), `.hammerkit-${(0, crypto_1.randomBytes)(6).toString('hex')}.bak`);
782
+ await (0, promises_1.rename)(absolute, backup);
783
+ backups.push([absolute, backup]);
784
+ }
785
+ }
786
+ for (const [path, before, after] of [...changes].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
787
+ if (after.kind !== 'file') {
788
+ continue;
789
+ }
790
+ await this.makeParents(path);
791
+ const absolute = this.absolute(path);
792
+ const temporary = (0, path_1.join)((0, path_1.dirname)(absolute), `.hammerkit-${(0, crypto_1.randomBytes)(6).toString('hex')}.tmp`);
793
+ const base = before.kind === 'file' ? before.mode ?? 0o644 : 0o644;
794
+ const mode = after.exec ? base | ((base & 0o444) >> 2) | 0o100 : base & ~0o111;
795
+ await (0, promises_1.writeFile)(temporary, after.content ?? Buffer.alloc(0), { flag: 'wx', mode });
796
+ await (0, promises_1.chmod)(temporary, mode);
797
+ await (0, promises_1.rename)(temporary, absolute);
798
+ written.push(absolute);
799
+ }
800
+ }
801
+ catch (e) {
802
+ for (const absolute of written.reverse()) {
803
+ await (0, promises_1.rm)(absolute, { force: true });
804
+ }
805
+ for (const [absolute, backup] of backups.reverse()) {
806
+ await (0, promises_1.rename)(backup, absolute).catch(() => undefined);
807
+ }
808
+ throw e;
809
+ }
810
+ for (const [, backup] of backups) {
811
+ await (0, promises_1.rm)(backup, { force: true });
812
+ }
813
+ }
814
+ async sameParents(path) {
815
+ const parts = path.split('/');
816
+ let current = this.root;
817
+ for (let i = 0; i < parts.length - 1; i++) {
818
+ current = (0, path_1.join)(current, parts[i]);
819
+ const stats = await (0, promises_1.lstat)(current).catch(() => undefined);
820
+ if (!stats) {
821
+ return;
822
+ }
823
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
824
+ throw new SandboxFileError(409, `${parts.slice(0, i + 1).join('/')} is not a directory any more`);
825
+ }
826
+ }
827
+ }
828
+ async makeParents(path) {
829
+ const parts = path.split('/');
830
+ let current = this.root;
831
+ for (let i = 0; i < parts.length - 1; i++) {
832
+ current = (0, path_1.join)(current, parts[i]);
833
+ const stats = await (0, promises_1.lstat)(current).catch(() => undefined);
834
+ if (!stats) {
835
+ await (0, promises_1.mkdir)(current);
836
+ }
837
+ else if (stats.isSymbolicLink() || !stats.isDirectory()) {
838
+ throw new SandboxFileError(409, `${parts.slice(0, i + 1).join('/')} is not a directory any more`);
839
+ }
840
+ }
841
+ }
842
+ }
843
+ exports.HostSandboxFiles = HostSandboxFiles;
844
+ // The edits of one request, applied to what the files would hold after them, before any
845
+ // of it is written.
846
+ class EditBatch {
847
+ constructor(files) {
848
+ this.files = files;
849
+ this.before = new Map();
850
+ this.after = new Map();
851
+ }
852
+ async current(path) {
853
+ const changed = this.after.get(path);
854
+ if (changed) {
855
+ return changed;
856
+ }
857
+ let original = this.before.get(path);
858
+ if (!original) {
859
+ original = await this.files.load(path);
860
+ this.before.set(path, original);
861
+ }
862
+ return original;
863
+ }
864
+ async text(path, state) {
865
+ return decodeText(path, await this.files.content(path, state));
866
+ }
867
+ async file(path, verb) {
868
+ const state = await this.current(path);
869
+ if (state.kind === 'absent') {
870
+ throw new SandboxFileError(404, `${path} does not exist${verb === 'replace in' ? ', create it with write' : ''}`);
871
+ }
872
+ if (state.kind === 'link') {
873
+ throw new SandboxFileError(400, `${path} is a symbolic link, the API does not ${verb} links`);
874
+ }
875
+ if (state.kind === 'directory') {
876
+ throw new SandboxFileError(400, `${path} is a directory, ${verb} its files one by one`);
877
+ }
878
+ return state;
879
+ }
880
+ async checkHash(path, ifHash) {
881
+ if (ifHash === undefined) {
882
+ return;
883
+ }
884
+ const state = await this.current(path);
885
+ const actual = state.kind === 'file' ? sha256(await this.files.content(path, state)) : undefined;
886
+ if (actual !== ifHash.toLowerCase()) {
887
+ throw new SandboxFileError(412, actual
888
+ ? `${path} changed since you read it (sha256 ${actual}), read it again`
889
+ : `${path} does not exist any more, read it again`);
890
+ }
891
+ }
892
+ // A path a file may be written to: nothing but directories above it, no directory there.
893
+ async writable(path) {
894
+ const parts = path.split('/');
895
+ for (let i = 1; i < parts.length; i++) {
896
+ const above = parts.slice(0, i).join('/');
897
+ const state = await this.current(above);
898
+ if (state.kind === 'file' || state.kind === 'link') {
899
+ throw new SandboxFileError(400, `${above} is a ${state.kind === 'file' ? 'file' : 'symbolic link'}, not a directory`);
900
+ }
901
+ }
902
+ for (const [other, state] of this.after) {
903
+ if (other.startsWith(`${path}/`) && state.kind === 'file') {
904
+ throw new SandboxFileError(400, `${path} is a directory of ${other}, written before in this batch`);
905
+ }
906
+ }
907
+ const state = await this.current(path);
908
+ if (state.kind === 'directory') {
909
+ throw new SandboxFileError(400, `${path} is a directory`);
910
+ }
911
+ return state;
912
+ }
913
+ set(path, content, exec) {
914
+ if (content.length > exports.MAX_FILE_BYTES) {
915
+ throw new SandboxFileError(413, `${path} would take ${content.length} bytes, more than ${exports.MAX_FILE_BYTES}`);
916
+ }
917
+ this.after.set(path, { kind: 'file', size: content.length, exec, content });
918
+ }
919
+ async apply(op) {
920
+ if (op.op === 'patch') {
921
+ return this.patch(op.patch);
922
+ }
923
+ const path = checkPath(op.path);
924
+ await this.checkHash(path, op.ifHash);
925
+ if (op.op === 'replace') {
926
+ const state = await this.file(path, 'replace in');
927
+ const text = await this.text(path, state);
928
+ if (op.old_string === '') {
929
+ throw new SandboxFileError(422, 'old_string is empty, write the file to create it');
930
+ }
931
+ if (op.old_string === op.new_string) {
932
+ throw new SandboxFileError(422, 'old_string and new_string are the same, nothing would change');
933
+ }
934
+ const at = [];
935
+ for (let found = text.indexOf(op.old_string); found >= 0; found = text.indexOf(op.old_string, found + op.old_string.length)) {
936
+ at.push(found);
937
+ }
938
+ if (at.length === 0) {
939
+ throw new SandboxFileError(422, `old_string is not in ${path}; it has to match the file exactly, whitespace and indentation included: read the file again`);
940
+ }
941
+ if (at.length > 1 && !op.replace_all) {
942
+ const lines = at.slice(0, 10).map((offset) => text.slice(0, offset).split('\n').length);
943
+ throw new SandboxFileError(422, `old_string is ${at.length} times in ${path}, at lines ${lines.join(', ')}${at.length > 10 ? ', …' : ''}: add lines around it until it is there once, or set replace_all`, { lines });
944
+ }
945
+ const replaced = op.replace_all
946
+ ? text.split(op.old_string).join(op.new_string)
947
+ : text.slice(0, at[0]) + op.new_string + text.slice(at[0] + op.old_string.length);
948
+ this.set(path, Buffer.from(replaced, 'utf8'), state.exec);
949
+ }
950
+ else if (op.op === 'write') {
951
+ const state = await this.writable(path);
952
+ const content = typeof op.content === 'string'
953
+ ? Buffer.from(op.content, op.encoding === 'base64' ? 'base64' : 'utf8')
954
+ : op.content;
955
+ this.set(path, content, state.kind === 'file' ? state.exec : false);
956
+ }
957
+ else if (op.op === 'delete') {
958
+ const state = await this.current(path);
959
+ if (state.kind === 'absent') {
960
+ throw new SandboxFileError(404, `${path} does not exist`);
961
+ }
962
+ if (state.kind === 'directory') {
963
+ throw new SandboxFileError(400, `${path} is a directory, delete its files one by one`);
964
+ }
965
+ this.after.set(path, { kind: 'absent' });
966
+ }
967
+ else {
968
+ const to = checkPath(op.to);
969
+ const state = await this.file(path, 'move');
970
+ const content = await this.files.content(path, state);
971
+ const target = await this.writable(to);
972
+ if (target.kind !== 'absent') {
973
+ throw new SandboxFileError(409, `${to} exists, delete it first`);
974
+ }
975
+ this.after.set(path, { kind: 'absent' });
976
+ this.set(to, content, state.exec);
977
+ }
978
+ }
979
+ async patch(text) {
980
+ for (const file of (0, sandbox_patch_1.parsePatch)(text)) {
981
+ await this.patchFile(file);
982
+ }
983
+ }
984
+ async patchFile(file) {
985
+ const from = file.from !== undefined ? checkPath(file.from) : undefined;
986
+ const to = file.to !== undefined ? checkPath(file.to) : undefined;
987
+ let content = '';
988
+ let exec = false;
989
+ if (from !== undefined) {
990
+ const state = await this.file(from, 'patch');
991
+ content = await this.text(from, state);
992
+ exec = state.exec;
993
+ }
994
+ if (to !== undefined && to !== from) {
995
+ const target = await this.writable(to);
996
+ if (target.kind !== 'absent') {
997
+ throw new SandboxFileError(409, `${to} exists, the patch ${from ? 'moves a file onto' : 'adds'} it`);
998
+ }
999
+ }
1000
+ const result = file.apply(content);
1001
+ if (to === undefined) {
1002
+ if (from !== undefined && result !== '') {
1003
+ throw new sandbox_patch_1.PatchError(`${from}: the patch deletes it, but its lines are not what the file holds`);
1004
+ }
1005
+ this.after.set(from, { kind: 'absent' });
1006
+ return;
1007
+ }
1008
+ if (from !== undefined && from !== to && !file.copy) {
1009
+ this.after.set(from, { kind: 'absent' });
1010
+ }
1011
+ this.set(to, Buffer.from(result, 'utf8'), file.exec ?? exec);
1012
+ }
1013
+ async plan() {
1014
+ const changes = [];
1015
+ for (const [path, after] of this.after) {
1016
+ const before = this.before.get(path) ?? (await this.files.load(path));
1017
+ if (await this.same(path, before, after)) {
1018
+ continue;
1019
+ }
1020
+ changes.push([path, before, after]);
1021
+ }
1022
+ const size = (state) => (state.kind === 'file' ? state.size : 0);
1023
+ return {
1024
+ files: changes
1025
+ .map(([path, before, after]) => {
1026
+ if (after.kind !== 'file') {
1027
+ return { path, change: 'deleted' };
1028
+ }
1029
+ const content = after.content ?? Buffer.alloc(0);
1030
+ return {
1031
+ path,
1032
+ change: before.kind === 'absent' ? 'added' : 'modified',
1033
+ sha256: sha256(content),
1034
+ size: content.length,
1035
+ };
1036
+ })
1037
+ .sort((a, b) => (a.path < b.path ? -1 : 1)),
1038
+ bytes: changes.reduce((sum, [, before, after]) => sum + size(after) - size(before), 0),
1039
+ commit: () => this.files.write(changes),
1040
+ };
1041
+ }
1042
+ async same(path, before, after) {
1043
+ if (before.kind !== after.kind) {
1044
+ return false;
1045
+ }
1046
+ if (before.kind !== 'file' || after.kind !== 'file') {
1047
+ return before.kind === 'absent';
1048
+ }
1049
+ return (before.exec === after.exec &&
1050
+ before.size === after.size &&
1051
+ (await this.files.content(path, before)).equals(after.content ?? Buffer.alloc(0)));
1052
+ }
1053
+ }
1054
+ //# sourceMappingURL=sandbox-files.js.map