@myagentroam/node 0.1.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.
Files changed (68) hide show
  1. package/README.md +17 -0
  2. package/dist/capabilities.d.ts +5 -0
  3. package/dist/capabilities.js +23 -0
  4. package/dist/capabilities.js.map +1 -0
  5. package/dist/claude-agent-sdk.d.ts +101 -0
  6. package/dist/claude-agent-sdk.js +341 -0
  7. package/dist/claude-agent-sdk.js.map +1 -0
  8. package/dist/claude-channel.d.ts +28 -0
  9. package/dist/claude-channel.js +45 -0
  10. package/dist/claude-channel.js.map +1 -0
  11. package/dist/codex-app-server.d.ts +96 -0
  12. package/dist/codex-app-server.js +361 -0
  13. package/dist/codex-app-server.js.map +1 -0
  14. package/dist/config.d.ts +17 -0
  15. package/dist/config.js +51 -0
  16. package/dist/config.js.map +1 -0
  17. package/dist/connector.d.ts +343 -0
  18. package/dist/connector.js +5525 -0
  19. package/dist/connector.js.map +1 -0
  20. package/dist/database.d.ts +282 -0
  21. package/dist/database.js +1347 -0
  22. package/dist/database.js.map +1 -0
  23. package/dist/event-buffer.d.ts +12 -0
  24. package/dist/event-buffer.js +29 -0
  25. package/dist/event-buffer.js.map +1 -0
  26. package/dist/fake-runner.d.ts +42 -0
  27. package/dist/fake-runner.js +130 -0
  28. package/dist/fake-runner.js.map +1 -0
  29. package/dist/health.d.ts +4 -0
  30. package/dist/health.js +4 -0
  31. package/dist/health.js.map +1 -0
  32. package/dist/main.d.ts +2 -0
  33. package/dist/main.js +47 -0
  34. package/dist/main.js.map +1 -0
  35. package/dist/native-session-history.d.ts +72 -0
  36. package/dist/native-session-history.js +647 -0
  37. package/dist/native-session-history.js.map +1 -0
  38. package/dist/operational.d.ts +30 -0
  39. package/dist/operational.js +82 -0
  40. package/dist/operational.js.map +1 -0
  41. package/dist/process-tree.d.ts +9 -0
  42. package/dist/process-tree.js +19 -0
  43. package/dist/process-tree.js.map +1 -0
  44. package/dist/runner-command-engine.d.ts +19 -0
  45. package/dist/runner-command-engine.js +47 -0
  46. package/dist/runner-command-engine.js.map +1 -0
  47. package/dist/runner-profiles.d.ts +11 -0
  48. package/dist/runner-profiles.js +138 -0
  49. package/dist/runner-profiles.js.map +1 -0
  50. package/dist/runner-usage.d.ts +33 -0
  51. package/dist/runner-usage.js +193 -0
  52. package/dist/runner-usage.js.map +1 -0
  53. package/dist/runtime-state.d.ts +174 -0
  54. package/dist/runtime-state.js +957 -0
  55. package/dist/runtime-state.js.map +1 -0
  56. package/dist/service.d.ts +4 -0
  57. package/dist/service.js +39 -0
  58. package/dist/service.js.map +1 -0
  59. package/dist/storage.d.ts +2 -0
  60. package/dist/storage.js +33 -0
  61. package/dist/storage.js.map +1 -0
  62. package/dist/terminal.d.ts +132 -0
  63. package/dist/terminal.js +417 -0
  64. package/dist/terminal.js.map +1 -0
  65. package/dist/workspace.d.ts +116 -0
  66. package/dist/workspace.js +732 -0
  67. package/dist/workspace.js.map +1 -0
  68. package/package.json +36 -0
@@ -0,0 +1,732 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, readdir, readFile, realpath, stat, unlink } from 'node:fs/promises';
3
+ import { homedir, platform as hostPlatform } from 'node:os';
4
+ import { dirname, isAbsolute, posix, relative, resolve, sep, win32 } from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+ const GIT_TIMEOUT_MS = 5_000;
7
+ const GIT_OUTPUT_LIMIT = 512 * 1024;
8
+ const FILE_READ_LIMIT = 512 * 1024;
9
+ const DIRECTORY_PAGE_LIMIT = 200;
10
+ const DIRECTORY_PICKER_LIMIT = 200;
11
+ const SEARCH_RESULT_LIMIT = 200;
12
+ const SEARCH_SCAN_LIMIT = 10_000;
13
+ const FILE_SEARCH_TIMEOUT_MS = 2_000;
14
+ const REF_PATTERN = /^(?!-)(?!.*\.\.)(?!.*@\{)[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/;
15
+ /**
16
+ * Lists directory names only for the Workspace picker. Browsing begins at the
17
+ * first allowed root when configured, while the Node account home remains a
18
+ * browse boundary and terminal default.
19
+ */
20
+ export async function listWorkspaceDirectories(requestedPath, allowedRoots, homePath = homedir()) {
21
+ const home = await realpath(homePath);
22
+ const roots = await Promise.all(allowedRoots.map((root) => realpath(root)));
23
+ const browseRoots = [...new Set([home, ...roots])];
24
+ const requested = requestedPath === undefined ? (roots[0] ?? home) : await realpath(requestedPath);
25
+ const directory = await stat(requested);
26
+ if (!directory.isDirectory() || !isWithinAllowedRoot(requested, browseRoots))
27
+ throw new Error('WORKSPACE_DIRECTORY_NOT_ALLOWED');
28
+ const children = await readdir(requested, { withFileTypes: true });
29
+ const entries = [];
30
+ for (const child of children.sort((left, right) => left.name.localeCompare(right.name))) {
31
+ if (!child.isDirectory() && !child.isSymbolicLink())
32
+ continue;
33
+ const path = await realpath(resolve(requested, child.name)).catch(() => undefined);
34
+ if (path === undefined || !isWithinAllowedRoot(path, browseRoots))
35
+ continue;
36
+ const metadata = await stat(path).catch(() => undefined);
37
+ if (metadata === undefined || !metadata.isDirectory())
38
+ continue;
39
+ entries.push({ path, name: child.name, selectable: isWithinAllowedRoot(path, roots) });
40
+ if (entries.length > DIRECTORY_PICKER_LIMIT)
41
+ break;
42
+ }
43
+ const parent = dirname(requested);
44
+ return {
45
+ homePath: home,
46
+ currentPath: requested,
47
+ parentPath: parent === requested || !isWithinAllowedRoot(parent, browseRoots) ? null : parent,
48
+ browseRoots: roots,
49
+ selectable: isWithinAllowedRoot(requested, roots),
50
+ entries: entries.slice(0, DIRECTORY_PICKER_LIMIT),
51
+ truncated: entries.length > DIRECTORY_PICKER_LIMIT
52
+ };
53
+ }
54
+ export async function listWorkspaceFiles(workspaceRoot, requestedPath = '.', cursor, limit = DIRECTORY_PAGE_LIMIT, options = {}) {
55
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > DIRECTORY_PAGE_LIMIT)
56
+ throw new Error('DIRECTORY_CURSOR_INVALID');
57
+ const directory = await resolveWorkspaceEntry(workspaceRoot, requestedPath, true, options);
58
+ const entries = await readdir(directory, { withFileTypes: true });
59
+ const root = await realpath(workspaceRoot);
60
+ const cursorValue = decodeDirectoryCursor(cursor, requestedPath);
61
+ const names = entries
62
+ .filter((entry) => entry.name !== '.git' && !isWorkspaceUploadTemporaryName(entry.name))
63
+ .map((entry) => entry.name)
64
+ .sort((left, right) => left.localeCompare(right));
65
+ const start = cursorValue === undefined ? 0 : names.findIndex((name) => name > cursorValue);
66
+ if (cursorValue !== undefined && start === -1)
67
+ return { entries: [], nextCursor: null, truncated: false };
68
+ const pageNames = names.slice(start, start + limit);
69
+ const output = [];
70
+ for (const name of pageNames) {
71
+ const child = await resolveWorkspaceEntry(root, relative(root, resolve(directory, name)), false, options).catch(() => undefined);
72
+ if (child === undefined)
73
+ continue;
74
+ const metadata = await lstat(child).catch(() => undefined);
75
+ if (metadata === undefined || (!metadata.isFile() && !metadata.isDirectory()))
76
+ continue;
77
+ output.push({
78
+ path: relative(root, child).split(sep).join('/'),
79
+ name,
80
+ kind: metadata.isDirectory() ? 'DIRECTORY' : 'FILE',
81
+ size: metadata.isFile() ? metadata.size : null,
82
+ modifiedAt: metadata.mtimeMs
83
+ });
84
+ }
85
+ const nextName = names[start + pageNames.length];
86
+ return {
87
+ entries: output,
88
+ nextCursor: nextName === undefined || pageNames.length === 0
89
+ ? null
90
+ : encodeDirectoryCursor(requestedPath, pageNames[pageNames.length - 1]),
91
+ truncated: nextName !== undefined
92
+ };
93
+ }
94
+ /** Validates an upload destination without following an existing target symlink. */
95
+ export async function preflightWorkspaceUpload(workspaceRoot, requestedDirectory, name, options = {}) {
96
+ if (name.length === 0 ||
97
+ name === '.' ||
98
+ name === '..' ||
99
+ name.includes('\0') ||
100
+ name.includes('/') ||
101
+ name.includes('\\') ||
102
+ isWorkspaceUploadTemporaryName(name)) {
103
+ return { path: '', absolutePath: '', status: 'INVALID_NAME' };
104
+ }
105
+ const root = await realpath(workspaceRoot);
106
+ const directory = await resolveWorkspaceEntry(root, requestedDirectory, true, options);
107
+ const absolutePath = resolve(directory, name);
108
+ if (dirname(absolutePath) !== directory)
109
+ throw new Error('FILE_PATH_ESCAPE');
110
+ assertNotPrivate(absolutePath, options.privateRoots);
111
+ const relativePath = relative(root, absolutePath).split(sep).join('/');
112
+ const metadata = await lstat(absolutePath).catch((error) => {
113
+ if (error.code === 'ENOENT')
114
+ return undefined;
115
+ throw error;
116
+ });
117
+ if (metadata === undefined)
118
+ return { path: relativePath, absolutePath, status: 'READY' };
119
+ if (metadata.isSymbolicLink())
120
+ throw new Error('FILE_PATH_ESCAPE');
121
+ if (metadata.isFile())
122
+ return { path: relativePath, absolutePath, status: 'EXISTS_FILE' };
123
+ return { path: relativePath, absolutePath, status: 'EXISTS_DIRECTORY' };
124
+ }
125
+ export function workspaceUploadTemporaryName(uploadId) {
126
+ return `.mar-upload-${uploadId}.part`;
127
+ }
128
+ function isWorkspaceUploadTemporaryName(name) {
129
+ return /^\.mar-upload-[0-9a-f-]{36}\.part$/i.test(name);
130
+ }
131
+ export async function readWorkspaceTextFile(workspaceRoot, requestedPath, offset = 0, limit = FILE_READ_LIMIT, options = {}) {
132
+ if (!Number.isSafeInteger(offset) ||
133
+ offset < 0 ||
134
+ !Number.isSafeInteger(limit) ||
135
+ limit < 1 ||
136
+ limit > FILE_READ_LIMIT)
137
+ throw new Error('FILE_RANGE_INVALID');
138
+ const root = await realpath(workspaceRoot);
139
+ const file = await resolveWorkspaceEntry(root, requestedPath, false, options);
140
+ const metadata = await lstat(file);
141
+ if (!metadata.isFile())
142
+ throw new Error('FILE_NOT_REGULAR');
143
+ if (metadata.size > FILE_READ_LIMIT * 16)
144
+ throw new Error('FILE_TOO_LARGE');
145
+ const source = await readFile(file);
146
+ const binary = source.includes(0) || !isValidUtf8(source);
147
+ if (binary) {
148
+ const end = Math.min(source.length, offset + limit);
149
+ const slice = source.subarray(offset, end);
150
+ return {
151
+ path: relative(root, file).split(sep).join('/'),
152
+ encoding: 'base64',
153
+ size: source.length,
154
+ content: slice.toString('base64'),
155
+ truncated: end < source.length,
156
+ nextOffset: end < source.length ? end : null
157
+ };
158
+ }
159
+ if (offset > 0 && offset < source.length && isUtf8ContinuationByte(source[offset]))
160
+ throw new Error('FILE_RANGE_INVALID');
161
+ let end = Math.min(source.length, offset + limit);
162
+ if (end < source.length && isUtf8ContinuationByte(source[end])) {
163
+ while (end > offset && isUtf8ContinuationByte(source[end]))
164
+ end -= 1;
165
+ if (end === offset) {
166
+ end += 1;
167
+ while (end < source.length && isUtf8ContinuationByte(source[end]))
168
+ end += 1;
169
+ }
170
+ }
171
+ const slice = source.subarray(offset, end);
172
+ const content = new TextDecoder('utf-8', { fatal: true }).decode(slice);
173
+ const nextOffset = end < source.length ? end : null;
174
+ return {
175
+ path: relative(root, file).split(sep).join('/'),
176
+ encoding: 'utf-8',
177
+ size: source.length,
178
+ content,
179
+ truncated: nextOffset !== null,
180
+ nextOffset
181
+ };
182
+ }
183
+ /** Reads one absolute file only when the resolved target remains within a Node allowed root. */
184
+ export async function readAllowedTextFile(requestedPath, allowedRoots, offset = 0, limit = FILE_READ_LIMIT, options = {}) {
185
+ if (!isAbsolute(requestedPath))
186
+ throw new Error('FILE_PATH_ESCAPE');
187
+ const file = await realpath(requestedPath).catch((error) => {
188
+ if (error.code === 'ENOENT')
189
+ throw new Error('FILE_NOT_FOUND');
190
+ throw error;
191
+ });
192
+ const roots = await Promise.all(allowedRoots.map((root) => realpath(root)));
193
+ if (!isWithinAllowedRoot(file, roots))
194
+ throw new Error('FILE_PATH_ESCAPE');
195
+ assertNotPrivate(file, options.privateRoots);
196
+ const metadata = await lstat(file);
197
+ if (!metadata.isFile())
198
+ throw new Error('FILE_NOT_REGULAR');
199
+ if (metadata.size > FILE_READ_LIMIT * 16)
200
+ throw new Error('FILE_TOO_LARGE');
201
+ if (!Number.isSafeInteger(offset) ||
202
+ offset < 0 ||
203
+ !Number.isSafeInteger(limit) ||
204
+ limit < 1 ||
205
+ limit > FILE_READ_LIMIT)
206
+ throw new Error('FILE_RANGE_INVALID');
207
+ const source = await readFile(file);
208
+ const binary = source.includes(0) || !isValidUtf8(source);
209
+ const end = Math.min(source.length, offset + limit);
210
+ if (binary) {
211
+ const slice = source.subarray(offset, end);
212
+ return {
213
+ path: file,
214
+ encoding: 'base64',
215
+ size: source.length,
216
+ content: slice.toString('base64'),
217
+ truncated: end < source.length,
218
+ nextOffset: end < source.length ? end : null
219
+ };
220
+ }
221
+ if (offset > 0 && offset < source.length && isUtf8ContinuationByte(source[offset]))
222
+ throw new Error('FILE_RANGE_INVALID');
223
+ let textEnd = end;
224
+ if (textEnd < source.length && isUtf8ContinuationByte(source[textEnd])) {
225
+ while (textEnd > offset && isUtf8ContinuationByte(source[textEnd]))
226
+ textEnd -= 1;
227
+ if (textEnd === offset) {
228
+ textEnd += 1;
229
+ while (textEnd < source.length && isUtf8ContinuationByte(source[textEnd]))
230
+ textEnd += 1;
231
+ }
232
+ }
233
+ const nextOffset = textEnd < source.length ? textEnd : null;
234
+ return {
235
+ path: file,
236
+ encoding: 'utf-8',
237
+ size: source.length,
238
+ content: new TextDecoder('utf-8', { fatal: true }).decode(source.subarray(offset, textEnd)),
239
+ truncated: nextOffset !== null,
240
+ nextOffset
241
+ };
242
+ }
243
+ function isValidUtf8(source) {
244
+ try {
245
+ new TextDecoder('utf-8', { fatal: true }).decode(source);
246
+ return true;
247
+ }
248
+ catch {
249
+ return false;
250
+ }
251
+ }
252
+ function isUtf8ContinuationByte(value) {
253
+ return (value & 0b1100_0000) === 0b1000_0000;
254
+ }
255
+ /** Searches file names only, so an exploratory request cannot exfiltrate file contents. */
256
+ export async function searchWorkspaceFiles(workspaceRoot, query, cursor, limit = SEARCH_RESULT_LIMIT, options = {}) {
257
+ if (query.trim().length === 0 || query.length > 256)
258
+ throw new Error('FILE_SEARCH_LIMIT_EXCEEDED');
259
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > SEARCH_RESULT_LIMIT)
260
+ throw new Error('FILE_SEARCH_LIMIT_EXCEEDED');
261
+ const root = await realpath(workspaceRoot);
262
+ assertNotPrivate(root, options.privateRoots);
263
+ const after = decodeSearchCursor(cursor, query);
264
+ const deadline = Date.now() + FILE_SEARCH_TIMEOUT_MS;
265
+ const results = [];
266
+ let scanned = 0;
267
+ let exhausted = true;
268
+ const directories = ['.'];
269
+ const normalizedQuery = query.toLocaleLowerCase();
270
+ while (directories.length > 0) {
271
+ if (Date.now() > deadline || scanned >= SEARCH_SCAN_LIMIT) {
272
+ exhausted = false;
273
+ break;
274
+ }
275
+ const directoryPath = directories.shift();
276
+ const directory = await resolveWorkspaceEntry(root, directoryPath, true, options).catch((error) => {
277
+ if (error instanceof Error && error.message === 'FILE_NOT_FOUND')
278
+ return undefined;
279
+ throw error;
280
+ });
281
+ if (directory === undefined)
282
+ continue;
283
+ const entries = await readdir(directory, { withFileTypes: true }).catch((error) => {
284
+ if (error.code === 'ENOENT')
285
+ return undefined;
286
+ throw error;
287
+ });
288
+ if (entries === undefined)
289
+ continue;
290
+ for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
291
+ if (entry.name === '.git')
292
+ continue;
293
+ scanned += 1;
294
+ if (scanned > SEARCH_SCAN_LIMIT || Date.now() > deadline) {
295
+ exhausted = false;
296
+ break;
297
+ }
298
+ const requestPath = relative(root, resolve(directory, entry.name)).split(sep).join('/');
299
+ const child = await resolveWorkspaceEntry(root, requestPath, false, options).catch(() => undefined);
300
+ if (child === undefined)
301
+ continue;
302
+ const metadata = await lstat(child).catch(() => undefined);
303
+ if (metadata === undefined || (!metadata.isFile() && !metadata.isDirectory()))
304
+ continue;
305
+ const safePath = relative(root, child).split(sep).join('/');
306
+ if (metadata.isDirectory())
307
+ directories.push(safePath);
308
+ if (safePath <= (after ?? '') || !safePath.toLocaleLowerCase().includes(normalizedQuery))
309
+ continue;
310
+ results.push({
311
+ path: safePath,
312
+ name: entry.name,
313
+ kind: metadata.isDirectory() ? 'DIRECTORY' : 'FILE',
314
+ size: metadata.isFile() ? metadata.size : null,
315
+ modifiedAt: metadata.mtimeMs
316
+ });
317
+ if (results.length > limit) {
318
+ exhausted = false;
319
+ break;
320
+ }
321
+ }
322
+ if (!exhausted || results.length > limit)
323
+ break;
324
+ }
325
+ results.sort((left, right) => left.path.localeCompare(right.path));
326
+ const page = results.slice(0, limit);
327
+ const nextCursor = !exhausted || results.length > limit
328
+ ? encodeSearchCursor(query, page[page.length - 1]?.path ?? after ?? '')
329
+ : null;
330
+ return { entries: page, nextCursor, truncated: nextCursor !== null };
331
+ }
332
+ async function resolveWorkspaceEntry(workspaceRoot, requestedPath, directory, options = {}) {
333
+ if (requestedPath.includes('\0') ||
334
+ requestedPath.length === 0 ||
335
+ requestedPath.startsWith('/') ||
336
+ requestedPath.startsWith('\\'))
337
+ throw new Error('FILE_PATH_ESCAPE');
338
+ const normalized = requestedPath.replaceAll('\\', '/');
339
+ if (normalized.split('/').some((segment) => segment === '..' || segment === '.git'))
340
+ throw new Error('FILE_PATH_ESCAPE');
341
+ const root = await realpath(workspaceRoot);
342
+ let candidate;
343
+ try {
344
+ candidate = await realpath(resolve(root, normalized));
345
+ }
346
+ catch (error) {
347
+ if (error.code === 'ENOENT')
348
+ throw new Error('FILE_NOT_FOUND');
349
+ throw error;
350
+ }
351
+ const pathToCandidate = relative(root, candidate);
352
+ if (pathToCandidate.startsWith('..') ||
353
+ pathToCandidate === '..' ||
354
+ pathToCandidate.startsWith(`..${sep}`))
355
+ throw new Error('FILE_PATH_ESCAPE');
356
+ assertNotPrivate(candidate, options.privateRoots);
357
+ const metadata = await lstat(candidate);
358
+ if (directory ? !metadata.isDirectory() : !metadata.isFile() && !metadata.isDirectory())
359
+ throw new Error(directory ? 'FILE_NOT_DIRECTORY' : 'FILE_NOT_REGULAR');
360
+ return candidate;
361
+ }
362
+ function assertNotPrivate(candidate, privateRoots) {
363
+ if (privateRoots?.some((privateRoot) => isSameOrDescendant(privateRoot, candidate)))
364
+ throw new Error('FILE_PATH_ESCAPE');
365
+ }
366
+ function isSameOrDescendant(root, candidate) {
367
+ const relativePath = relative(root, candidate);
368
+ return (relativePath === '' || (!relativePath.startsWith('..') && !relativePath.startsWith(`..${sep}`)));
369
+ }
370
+ function encodeDirectoryCursor(path, name) {
371
+ return Buffer.from(JSON.stringify({ path, name })).toString('base64url');
372
+ }
373
+ function decodeDirectoryCursor(cursor, expectedPath) {
374
+ if (cursor === undefined)
375
+ return undefined;
376
+ try {
377
+ const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
378
+ if (typeof value !== 'object' ||
379
+ value === null ||
380
+ value.path !== expectedPath ||
381
+ typeof value.name !== 'string')
382
+ throw new Error();
383
+ return value.name;
384
+ }
385
+ catch {
386
+ throw new Error('DIRECTORY_CURSOR_INVALID');
387
+ }
388
+ }
389
+ function encodeSearchCursor(query, path) {
390
+ return Buffer.from(JSON.stringify({ query, path })).toString('base64url');
391
+ }
392
+ function decodeSearchCursor(cursor, expectedQuery) {
393
+ if (cursor === undefined)
394
+ return undefined;
395
+ try {
396
+ const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
397
+ if (typeof value !== 'object' ||
398
+ value === null ||
399
+ value.query !== expectedQuery ||
400
+ typeof value.path !== 'string')
401
+ throw new Error();
402
+ return value.path;
403
+ }
404
+ catch {
405
+ throw new Error('DIRECTORY_CURSOR_INVALID');
406
+ }
407
+ }
408
+ export function normalizeWorkspacePath(input, platform = hostPlatform()) {
409
+ if (input.length === 0 || input.includes('\u0000')) {
410
+ throw new Error('WORKSPACE_PATH_INVALID');
411
+ }
412
+ if (platform === 'win32') {
413
+ // Device paths bypass normal Win32 resolution and must never be accepted.
414
+ if (/^\\\\[?.]/.test(input)) {
415
+ throw new Error('WORKSPACE_DEVICE_PATH_REJECTED');
416
+ }
417
+ const normalized = win32.normalize(input.replaceAll('/', '\\'));
418
+ if (!win32.isAbsolute(normalized)) {
419
+ throw new Error('WORKSPACE_PATH_NOT_ABSOLUTE');
420
+ }
421
+ return normalized.replace(/\\+$/, '').toLowerCase();
422
+ }
423
+ if (platform !== 'linux') {
424
+ throw new Error('WORKSPACE_PLATFORM_UNSUPPORTED');
425
+ }
426
+ const normalized = posix.normalize(input);
427
+ if (!posix.isAbsolute(normalized)) {
428
+ throw new Error('WORKSPACE_PATH_NOT_ABSOLUTE');
429
+ }
430
+ return normalized === '/' ? normalized : normalized.replace(/\/+$/, '');
431
+ }
432
+ export function isWithinAllowedRoot(target, roots, platform = hostPlatform()) {
433
+ const normalizedTarget = normalizeWorkspacePath(target, platform);
434
+ const pathApi = platform === 'win32' ? win32 : posix;
435
+ return roots.some((root) => {
436
+ const normalizedRoot = normalizeWorkspacePath(root, platform);
437
+ const relative = pathApi.relative(normalizedRoot, normalizedTarget);
438
+ return relative === '' || (!relative.startsWith('..') && !pathApi.isAbsolute(relative));
439
+ });
440
+ }
441
+ /** Resolving both candidate and roots rejects Linux symlinks which point outside a root. */
442
+ export async function inspectWorkspace(requestedPath, allowedRoots) {
443
+ const resolvedPath = await realpath(requestedPath);
444
+ const resolvedRoots = await Promise.all(allowedRoots.map((root) => realpath(root)));
445
+ const directory = await stat(resolvedPath);
446
+ if (!directory.isDirectory() || !isWithinAllowedRoot(resolvedPath, resolvedRoots)) {
447
+ throw new Error('WORKSPACE_NOT_ALLOWED');
448
+ }
449
+ try {
450
+ const summary = await readGitSummary(resolvedPath);
451
+ return { path: resolvedPath, kind: 'GIT_WORKSPACE', gitSummary: summary };
452
+ }
453
+ catch (error) {
454
+ if ((error instanceof GitCommandError && error.code === 128) || isGitUnavailable(error)) {
455
+ return { path: resolvedPath, kind: 'DIRECTORY' };
456
+ }
457
+ throw error;
458
+ }
459
+ }
460
+ export async function readGitSummary(workspacePath) {
461
+ const commonDir = (await runGit(workspacePath, ['rev-parse', '--git-common-dir'])).text.trim();
462
+ const head = (await runGit(workspacePath, ['rev-parse', '--verify', 'HEAD'])).text.trim();
463
+ const branchResult = await runGitAllowFailure(workspacePath, [
464
+ 'symbolic-ref',
465
+ '--quiet',
466
+ '--short',
467
+ 'HEAD'
468
+ ]);
469
+ const dirty = (await runGit(workspacePath, ['status', '--porcelain=v1', '-z'])).output.length > 0;
470
+ const snapshotOutput = await runGit(workspacePath, ['status', '--porcelain=v1']).then((result) => result.text);
471
+ return {
472
+ commonDir,
473
+ head,
474
+ branch: branchResult.code === 0 ? branchResult.text.trim() : null,
475
+ dirty,
476
+ snapshot: summarizeText(snapshotOutput)
477
+ };
478
+ }
479
+ export function assertSafeGitRef(ref) {
480
+ if (!REF_PATTERN.test(ref)) {
481
+ throw new Error('GIT_REF_INVALID');
482
+ }
483
+ }
484
+ export async function readRestrictedDiff(workspacePath, fromRef, toRef) {
485
+ assertSafeGitRef(fromRef);
486
+ assertSafeGitRef(toRef);
487
+ const result = await runGit(workspacePath, [
488
+ 'diff',
489
+ '--no-ext-diff',
490
+ '--no-textconv',
491
+ fromRef,
492
+ toRef
493
+ ]);
494
+ const binary = result.output.includes(0) || result.text.includes('Binary files ');
495
+ if (binary) {
496
+ return {
497
+ binary: true,
498
+ summary: `binary diff sha256=${createHash('sha256').update(result.output).digest('hex')} bytes=${result.output.length}`,
499
+ truncated: result.truncated
500
+ };
501
+ }
502
+ return { binary: false, text: result.text, truncated: result.truncated };
503
+ }
504
+ function currentBranchFromStatusHeader(header) {
505
+ const value = header.slice(3);
506
+ if (value.startsWith('HEAD '))
507
+ return null;
508
+ const unborn = /^(?:No commits yet on|Initial commit on) (.+)$/.exec(value);
509
+ if (unborn !== null)
510
+ return unborn[1] ?? null;
511
+ return value.split('...')[0]?.split(' ')[0] ?? null;
512
+ }
513
+ export async function readCurrentChangesSummary(workspacePath) {
514
+ const result = await runGit(workspacePath, [
515
+ 'status',
516
+ '--porcelain=v1',
517
+ '-z',
518
+ '--branch',
519
+ '--untracked-files=all'
520
+ ]);
521
+ const records = result.text.split('\0');
522
+ const header = records.shift() ?? '';
523
+ const output = [];
524
+ for (let index = 0; index < records.length; index += 1) {
525
+ const record = records[index];
526
+ if (record.length < 4)
527
+ continue;
528
+ const indexState = record[0];
529
+ const worktreeState = record[1];
530
+ const path = record.slice(3);
531
+ if (indexState === '?' && worktreeState === '?') {
532
+ output.push({ path, state: 'UNTRACKED', change: 'ADDED' });
533
+ continue;
534
+ }
535
+ if (indexState !== ' ') {
536
+ output.push({ path, state: 'STAGED', change: gitChange(indexState) });
537
+ }
538
+ if (worktreeState !== ' ') {
539
+ output.push({ path, state: 'UNSTAGED', change: gitChange(worktreeState) });
540
+ }
541
+ // Porcelain v1 -z emits a second NUL-delimited source path for rename
542
+ // and copy records. It is metadata for the same entry, never another
543
+ // status record.
544
+ if (indexState === 'R' || indexState === 'C' || worktreeState === 'R' || worktreeState === 'C')
545
+ index += 1;
546
+ }
547
+ return {
548
+ branch: header.startsWith('## ') ? currentBranchFromStatusHeader(header) : null,
549
+ changes: output
550
+ };
551
+ }
552
+ export async function readCurrentChanges(workspacePath) {
553
+ return (await readCurrentChangesSummary(workspacePath)).changes;
554
+ }
555
+ export async function readCurrentChangeDiff(workspacePath, requestedPath) {
556
+ const path = safeGitWorkspacePath(requestedPath);
557
+ const result = await runGit(workspacePath, [
558
+ 'diff',
559
+ '--no-ext-diff',
560
+ '--no-textconv',
561
+ 'HEAD',
562
+ '--',
563
+ `:(literal)${path}`
564
+ ]);
565
+ const after = await readWorkingTreeText(workspacePath, path);
566
+ const binary = result.output.includes(0) || result.text.includes('Binary files ') || after?.binary === true;
567
+ if (binary) {
568
+ return {
569
+ binary: true,
570
+ summary: `binary diff sha256=${createHash('sha256').update(result.output).digest('hex')} bytes=${result.output.length}`,
571
+ truncated: result.truncated || after?.truncated === true
572
+ };
573
+ }
574
+ return {
575
+ binary: false,
576
+ text: result.text,
577
+ truncated: result.truncated || after?.truncated === true,
578
+ afterText: after?.text ?? '',
579
+ ...(after === undefined ? {} : { afterTruncated: after.truncated })
580
+ };
581
+ }
582
+ async function readWorkingTreeText(workspacePath, path) {
583
+ const root = await realpath(workspacePath);
584
+ let file;
585
+ try {
586
+ file = await resolveWorkspaceEntry(root, path, false);
587
+ }
588
+ catch (error) {
589
+ if (error instanceof Error && error.message === 'FILE_NOT_FOUND')
590
+ return undefined;
591
+ throw error;
592
+ }
593
+ const source = await readFile(file);
594
+ return diffTextFromBuffer(source, source.length > GIT_OUTPUT_LIMIT);
595
+ }
596
+ function diffTextFromBuffer(source, truncated) {
597
+ if (source.includes(0) || !isValidUtf8(source))
598
+ return { binary: true, truncated };
599
+ let end = Math.min(source.length, GIT_OUTPUT_LIMIT);
600
+ while (end > 0 && end < source.length && isUtf8ContinuationByte(source[end]))
601
+ end -= 1;
602
+ return { binary: false, text: source.subarray(0, end).toString('utf8'), truncated };
603
+ }
604
+ export async function restoreCurrentChange(workspacePath, requestedPath, state, options = {}) {
605
+ const path = safeGitWorkspacePath(requestedPath);
606
+ const pathspec = `:(literal)${path}`;
607
+ if (state === 'UNTRACKED') {
608
+ const status = await runGit(workspacePath, [
609
+ 'status',
610
+ '--porcelain=v1',
611
+ '-z',
612
+ '--untracked-files=all',
613
+ '--',
614
+ pathspec
615
+ ]);
616
+ if (!status.text
617
+ .split('\0')
618
+ .some((record) => record.startsWith('?? ') && record.slice(3) === path))
619
+ throw new Error('GIT_CHANGE_RESTORE_UNAVAILABLE');
620
+ const root = await realpath(workspacePath);
621
+ const file = resolve(root, path);
622
+ assertNotPrivate(file, options.privateRoots);
623
+ const metadata = await lstat(file);
624
+ if (!metadata.isFile() || metadata.isSymbolicLink())
625
+ throw new Error('GIT_CHANGE_RESTORE_UNAVAILABLE');
626
+ await unlink(file);
627
+ return;
628
+ }
629
+ if (state === 'UNSTAGED') {
630
+ await runGit(workspacePath, ['restore', '--worktree', '--', pathspec]);
631
+ return;
632
+ }
633
+ await runGit(workspacePath, [
634
+ 'restore',
635
+ '--source=HEAD',
636
+ '--staged',
637
+ '--worktree',
638
+ '--',
639
+ pathspec
640
+ ]);
641
+ }
642
+ function safeGitWorkspacePath(requestedPath) {
643
+ if (requestedPath.length === 0 ||
644
+ requestedPath.includes('\0') ||
645
+ requestedPath.startsWith('/') ||
646
+ requestedPath.startsWith('\\'))
647
+ throw new Error('FILE_PATH_ESCAPE');
648
+ const normalized = requestedPath.replaceAll('\\', '/');
649
+ if (normalized
650
+ .split('/')
651
+ .some((segment) => segment === '' || segment === '..' || segment === '.git'))
652
+ throw new Error('FILE_PATH_ESCAPE');
653
+ return normalized;
654
+ }
655
+ function gitChange(status) {
656
+ if (status === 'A')
657
+ return 'ADDED';
658
+ if (status === 'D')
659
+ return 'DELETED';
660
+ if (status === 'R')
661
+ return 'RENAMED';
662
+ if (status === 'C')
663
+ return 'COPIED';
664
+ if (status === 'T')
665
+ return 'TYPE_CHANGED';
666
+ return 'MODIFIED';
667
+ }
668
+ export class GitCommandError extends Error {
669
+ code;
670
+ stderr;
671
+ constructor(code, stderr) {
672
+ super('GIT_COMMAND_FAILED');
673
+ this.code = code;
674
+ this.stderr = stderr;
675
+ }
676
+ }
677
+ async function runGit(workspacePath, args) {
678
+ const result = await runGitAllowFailure(workspacePath, args);
679
+ if (result.code !== 0) {
680
+ throw new GitCommandError(result.code, result.stderr);
681
+ }
682
+ return result;
683
+ }
684
+ async function runGitAllowFailure(workspacePath, args) {
685
+ return new Promise((resolve, reject) => {
686
+ const child = spawn('git', [...args], {
687
+ cwd: workspacePath,
688
+ shell: false,
689
+ windowsHide: true,
690
+ stdio: ['ignore', 'pipe', 'pipe']
691
+ });
692
+ const chunks = [];
693
+ const errors = [];
694
+ let size = 0;
695
+ let truncated = false;
696
+ const timer = setTimeout(() => child.kill('SIGKILL'), GIT_TIMEOUT_MS);
697
+ child.stdout.on('data', (chunk) => {
698
+ if (size < GIT_OUTPUT_LIMIT) {
699
+ const retained = chunk.subarray(0, Math.max(0, GIT_OUTPUT_LIMIT - size));
700
+ chunks.push(retained);
701
+ size += retained.length;
702
+ }
703
+ truncated ||= size >= GIT_OUTPUT_LIMIT;
704
+ });
705
+ child.stderr.on('data', (chunk) => errors.push(chunk.subarray(0, 4096)));
706
+ child.once('error', (error) => {
707
+ clearTimeout(timer);
708
+ reject(error);
709
+ });
710
+ child.once('close', (code) => {
711
+ clearTimeout(timer);
712
+ const output = Buffer.concat(chunks);
713
+ resolve({
714
+ code: code ?? 1,
715
+ output,
716
+ text: output.toString('utf8'),
717
+ stderr: Buffer.concat(errors).toString('utf8'),
718
+ truncated
719
+ });
720
+ });
721
+ });
722
+ }
723
+ function isGitUnavailable(error) {
724
+ return (error instanceof Error && 'code' in error && error.code === 'ENOENT');
725
+ }
726
+ function summarizeText(value) {
727
+ return JSON.stringify({
728
+ sha256: createHash('sha256').update(value).digest('hex'),
729
+ bytes: Buffer.byteLength(value)
730
+ });
731
+ }
732
+ //# sourceMappingURL=workspace.js.map