@myagentroam/node 0.11.35 → 0.11.37

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 (143) hide show
  1. package/dist/application/execution-lifecycle.js +54 -0
  2. package/dist/application/node-lifecycle.js +104 -0
  3. package/dist/application/node-operation-admission.js +51 -0
  4. package/dist/application/node-operation-contracts.js +1 -0
  5. package/dist/application/runner-composition.js +22 -0
  6. package/dist/application/session-projection-composition.js +26 -0
  7. package/dist/application/workspace-application.js +61 -0
  8. package/dist/application/workspace-composition.js +21 -0
  9. package/dist/capabilities.js +2 -2
  10. package/dist/codex-app-server.js +1 -471
  11. package/dist/connection/authorization.js +26 -0
  12. package/dist/{service → connection}/direct-transfer-service.js +1 -1
  13. package/dist/connection/endpoints.js +32 -0
  14. package/dist/connector.js +144 -252
  15. package/dist/database.js +34 -14
  16. package/dist/git-askpass.js +1 -1
  17. package/dist/git-execution-helper.js +1 -1
  18. package/dist/hosting/hosted-conversation-file-identity.js +27 -0
  19. package/dist/hosting/hosted-conversation-file-operations.js +44 -0
  20. package/dist/hosting/hosted-conversation-node-service.js +974 -0
  21. package/dist/hosting/hosted-conversation-runs.js +599 -0
  22. package/dist/hosting/hosted-conversation-templates.js +280 -0
  23. package/dist/hosting/hosted-conversation-uploads.js +344 -0
  24. package/dist/hosting/hosted-conversation-watch.js +194 -0
  25. package/dist/hosting/runtime-contracts.js +1 -0
  26. package/dist/marctl-guidance.js +1 -1
  27. package/dist/runner/abstract-runner.js +4 -13
  28. package/dist/{claude-agent-sdk.js → runner/claude/claude-agent-sdk.js} +2 -2
  29. package/dist/runner/claude/conversation-parser.js +2 -1
  30. package/dist/runner/claude/managed-run-controller.js +6 -7
  31. package/dist/runner/claude/message-parsers.js +2 -1
  32. package/dist/runner/claude/native-transcript.js +2 -1
  33. package/dist/runner/claude/tool-normalizer.js +1 -1
  34. package/dist/runner/claude-code-runner.js +2 -2
  35. package/dist/runner/codex/codex-app-server.js +471 -0
  36. package/dist/runner/codex/conversation-parser.js +4 -2
  37. package/dist/runner/codex/managed-run-controller.js +7 -9
  38. package/dist/runner/codex/native-transcript.js +1 -1
  39. package/dist/runner/codex/tool-normalizer.js +1 -1
  40. package/dist/runner/codex-runner.js +9 -27
  41. package/dist/runner/contracts.js +1 -0
  42. package/dist/runner/mar-agent/conversation-normalizer.js +2 -2
  43. package/dist/runner/mar-agent/history-projection.js +1 -1
  44. package/dist/runner/mar-agent/hosted-conversation-runtime.js +6 -10
  45. package/dist/runner/mar-agent/managed-run-controller.js +85 -61
  46. package/dist/runner/mar-agent-runner.js +1 -1
  47. package/dist/runner/normalization.js +42 -0
  48. package/dist/runner/opencode/conversation-parser.js +1 -1
  49. package/dist/runner/opencode/managed-run-controller.js +6 -7
  50. package/dist/{opencode-server.js → runner/opencode/opencode-server.js} +2 -2
  51. package/dist/runner/opencode/tool-normalizer.js +1 -1
  52. package/dist/runner/opencode-runner.js +4 -5
  53. package/dist/runner/runner-registry.js +4 -1
  54. package/dist/runtime-state.js +48 -372
  55. package/dist/service/computer-use-enablement-service.js +1 -1
  56. package/dist/service/continuous-run-coordinator.js +52 -7
  57. package/dist/service/conversation-history-service.js +4 -4
  58. package/dist/service/conversation-hosting-capability-service.js +1 -1
  59. package/dist/service/conversation-hosting-enablement-service.js +1 -1
  60. package/dist/service/conversation-hosting-lifecycle-service.js +3 -3
  61. package/dist/service/conversation-hosting-maintenance-service.js +1 -1
  62. package/dist/service/conversation-observation.js +1 -1
  63. package/dist/service/conversation-segment-service.js +1 -1
  64. package/dist/service/external-session-resume-service.js +47 -27
  65. package/dist/service/fake-managed-run-service.js +3 -7
  66. package/dist/service/git-operation-service.js +1 -1
  67. package/dist/service/goal-continuation-coordinator.js +14 -7
  68. package/dist/service/mar-agent-model-transport-test-service.js +1 -1
  69. package/dist/service/native-session-watch-service.js +1 -1
  70. package/dist/service/node-control-channel.js +1 -5
  71. package/dist/service/node-request-service.js +1 -1
  72. package/dist/service/node-terminal-channel.js +2 -1
  73. package/dist/service/node-workspace-coordinator.js +3 -2
  74. package/dist/service/notification-mcp-bridge.js +6 -2
  75. package/dist/service/recent-run-projection-service.js +1 -1
  76. package/dist/service/restart-session-restorer.js +1 -1
  77. package/dist/service/run-event-service.js +49 -42
  78. package/dist/service/run-workbench-service.js +3 -3
  79. package/dist/service/runner-interaction-service.js +6 -5
  80. package/dist/service/runner-service.js +1 -1
  81. package/dist/service/session-recent-activity.js +2 -1
  82. package/dist/service/skill-directory-service.js +1 -1
  83. package/dist/service/skill-install-service.js +2 -2
  84. package/dist/service/terminal-service.js +1 -1
  85. package/dist/service/workbench-event-projection.js +3 -2
  86. package/dist/service/workbench-event-service.js +1 -1
  87. package/dist/service/workspace-service.js +3 -2
  88. package/dist/session/attachments.js +110 -0
  89. package/dist/session/contracts.js +1 -0
  90. package/dist/{util → session}/conversation-identity.js +1 -1
  91. package/dist/session/input-validation.js +15 -0
  92. package/dist/{service → session}/native-session-projection-service.js +40 -11
  93. package/dist/session/pagination.js +77 -0
  94. package/dist/{util → session}/runner-native-session-parsers.js +2 -1
  95. package/dist/session/runtime/approvals.js +90 -0
  96. package/dist/session/runtime/conversation-projection.js +310 -0
  97. package/dist/session/runtime/type-guards.js +19 -0
  98. package/dist/{service → session}/session-catalog-service.js +5 -4
  99. package/dist/{service → session}/session-command-service.js +3 -3
  100. package/dist/{service → session}/session-identity-service.js +1 -1
  101. package/dist/{service → session}/session-lifecycle-service.js +18 -7
  102. package/dist/{service → session}/session-message-service.js +6 -5
  103. package/dist/{service → session}/session-presentation-service.js +1 -1
  104. package/dist/{service → session}/session-query-service.js +2 -2
  105. package/dist/{service → session}/session-schedule-service.js +2 -2
  106. package/dist/util/records.js +3 -0
  107. package/dist/workspace/contracts.js +1 -0
  108. package/dist/workspace/file-index.js +534 -0
  109. package/dist/workspace/files.js +466 -0
  110. package/dist/workspace/git.js +991 -0
  111. package/dist/workspace/path-access.js +184 -0
  112. package/dist/workspace/utf8.js +12 -0
  113. package/dist/{service → workspace}/workspace-change-service.js +45 -21
  114. package/dist/{service → workspace}/workspace-code-navigation-service.js +6 -6
  115. package/dist/{service → workspace}/workspace-file-manager-service.js +1 -1
  116. package/dist/{service → workspace}/workspace-file-service.js +16 -16
  117. package/dist/{service → workspace}/workspace-git-exclude-service.js +1 -1
  118. package/dist/{service → workspace}/workspace-queue-workbench-service.js +3 -2
  119. package/dist/{service → workspace}/workspace-session-service.js +45 -40
  120. package/dist/{service → workspace}/workspace-upload-service.js +28 -28
  121. package/dist/{service → workspace}/workspace-watch-service.js +29 -28
  122. package/dist/{service → workspace}/workspace-workbench-service.js +4 -3
  123. package/package.json +4 -4
  124. package/dist/service/hosted-conversation-node-service.js +0 -2252
  125. package/dist/service/run-domain-state.js +0 -4
  126. package/dist/service/session-domain-state.js +0 -9
  127. package/dist/service/workspace-domain-state.js +0 -41
  128. package/dist/util/node-operation-parsers.js +0 -320
  129. package/dist/workspace.js +0 -2168
  130. /package/dist/{connector → application}/node-connector-options.js +0 -0
  131. /package/dist/{util → connection}/node-operation-data.js +0 -0
  132. /package/dist/{connector → connection}/node-operation-router.js +0 -0
  133. /package/dist/{service → hosting}/hosted-conversation-sandbox-profile.js +0 -0
  134. /package/dist/{service → hosting}/hosted-conversation-store.js +0 -0
  135. /package/dist/{util → mcp}/secret-environment.js +0 -0
  136. /package/dist/{claude-channel.js → runner/claude/claude-channel.js} +0 -0
  137. /package/dist/{util → runner}/personal-instructions.js +0 -0
  138. /package/dist/{util → runner}/runner-error.js +0 -0
  139. /package/dist/{service/conversation-work-process.js → session/runtime/work-process.js} +0 -0
  140. /package/dist/{service → session}/session-context-service.js +0 -0
  141. /package/dist/{util → workspace}/git-credential-client.js +0 -0
  142. /package/dist/{service → workspace}/workspace-content-search-service.js +0 -0
  143. /package/dist/{service → workspace}/workspace-watch-snapshot-service.js +0 -0
package/dist/workspace.js DELETED
@@ -1,2168 +0,0 @@
1
- import { createHash, randomUUID } from 'node:crypto';
2
- import { lstat, mkdir, opendir, open, readdir, readFile, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises';
3
- import { homedir, platform as hostPlatform } from 'node:os';
4
- import { basename, dirname, isAbsolute, posix, relative, resolve, sep, win32 } from 'node:path';
5
- import { spawn } from 'node:child_process';
6
- import { Minimatch } from 'minimatch';
7
- import { logNodeCommandFailure } from './operational.js';
8
- import { terminateProcessTree } from './process-tree.js';
9
- import { gitOperationEnvironment } from './service/git-operation-service.js';
10
- const GIT_TIMEOUT_MS = 5_000;
11
- const GIT_STATUS_TIMEOUT_MS = 30_000;
12
- const GIT_HISTORY_TIMEOUT_MS = 15_000;
13
- const GIT_OUTPUT_LIMIT = 512 * 1024;
14
- const GIT_LARGE_OUTPUT_LIMIT = 8 * 1024 * 1024;
15
- const FILE_READ_LIMIT = 512 * 1024;
16
- const DIRECTORY_PAGE_LIMIT = 200;
17
- const DIRECTORY_PICKER_LIMIT = 200;
18
- const SEARCH_RESULT_LIMIT = 200;
19
- const GIT_WORKTREE_LIST_LIMIT = 128;
20
- const FILE_INDEX_IDLE_TTL_MS = 60 * 60 * 1_000;
21
- const FILE_INDEX_FRESHNESS_TTL_MS = 30_000;
22
- const FILE_INDEX_ENTRY_LIMIT = 100_000;
23
- const FILE_INDEX_METADATA_CONCURRENCY = 32;
24
- const FILE_INDEX_BYTE_LIMIT = 64 * 1024 * 1024;
25
- const DEFAULT_QUICK_OPEN_EXCLUDES = [
26
- '**/.git/**',
27
- '**/node_modules/**',
28
- '**/bower_components/**',
29
- '**/.pnpm/**',
30
- '**/.yarn/**',
31
- '**/.cache/**',
32
- '**/.parcel-cache/**',
33
- '**/.turbo/**',
34
- '**/dist/**',
35
- '**/build/**',
36
- '**/out/**',
37
- '**/coverage/**',
38
- '**/test/runs/**',
39
- '**/.next/**',
40
- '**/.nuxt/**',
41
- '**/.svelte-kit/**',
42
- '**/.angular/**',
43
- '**/target/**',
44
- '**/.gradle/**',
45
- '**/bin/**',
46
- '**/obj/**',
47
- '**/vendor/**',
48
- '**/__pycache__/**',
49
- '**/.venv/**',
50
- '**/venv/**'
51
- ];
52
- const REF_PATTERN = /^(?!-)(?!.*\.\.)(?!.*@\{)[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/;
53
- export async function createWorkspaceDirectory(parentPath, directoryName, allowedRoots) {
54
- const name = directoryName.trim();
55
- if (name.length === 0 ||
56
- name.length > 255 ||
57
- name === '.' ||
58
- name === '..' ||
59
- name.includes('/') ||
60
- name.includes('\\') ||
61
- name.includes('\0'))
62
- throw new Error('WORKSPACE_DIRECTORY_NAME_INVALID');
63
- const roots = await Promise.all(allowedRoots.map((root) => realpath(root)));
64
- const parent = await realpath(parentPath).catch(() => {
65
- throw new Error('WORKSPACE_DIRECTORY_NOT_ALLOWED');
66
- });
67
- const metadata = await stat(parent).catch(() => undefined);
68
- if (metadata?.isDirectory() !== true || !isWithinAllowedRoot(parent, roots))
69
- throw new Error('WORKSPACE_DIRECTORY_NOT_ALLOWED');
70
- const target = resolve(parent, name);
71
- if (dirname(target) !== parent || !isWithinAllowedRoot(target, roots))
72
- throw new Error('WORKSPACE_DIRECTORY_NAME_INVALID');
73
- try {
74
- await mkdir(target);
75
- }
76
- catch (error) {
77
- if (error.code === 'EEXIST')
78
- throw new Error('WORKSPACE_DIRECTORY_EXISTS');
79
- throw error;
80
- }
81
- return target;
82
- }
83
- /** A per-workspace, process-local index for the Quick Open file picker. */
84
- export class WorkspaceFileIndex {
85
- workspaceRoot;
86
- limits;
87
- entries = [];
88
- fileEntries = [];
89
- retainedBytes = 0;
90
- failure;
91
- foregroundRequested = false;
92
- preparationConsumers = new Set();
93
- truncated = false;
94
- root;
95
- loading;
96
- stale = true;
97
- expiryTimer;
98
- refreshedAt = 0;
99
- refreshAttemptedAt = 0;
100
- lifecycleGeneration = 0;
101
- constructor(workspaceRoot, limits = {}) {
102
- this.workspaceRoot = workspaceRoot;
103
- this.limits = limits;
104
- }
105
- get accountedBytes() {
106
- return this.retainedBytes;
107
- }
108
- /** Prepares only metadata; a queued background scan may lose its consumer. */
109
- prepare(needed = () => true) {
110
- if (!this.stale && this.root !== undefined) {
111
- this.touch();
112
- return Promise.resolve();
113
- }
114
- if (!needed())
115
- return Promise.resolve();
116
- this.preparationConsumers.add(needed);
117
- this.startLoading(true, () => {
118
- for (const consumer of this.preparationConsumers) {
119
- if (consumer())
120
- return true;
121
- this.preparationConsumers.delete(consumer);
122
- }
123
- return false;
124
- });
125
- return this.loading;
126
- }
127
- async contentCandidates(options = {}) {
128
- await this.ensureLoaded(options.requireFresh === true);
129
- return this.currentContentCandidates();
130
- }
131
- /**
132
- * Returns the current candidate snapshot without synchronously rebuilding an
133
- * empty or explicitly invalidated index. A stale loaded snapshot schedules
134
- * its normal single-flight refresh before it is returned.
135
- */
136
- peekContentCandidates() {
137
- if (this.stale || this.root === undefined)
138
- return undefined;
139
- if (Date.now() - this.refreshAttemptedAt >=
140
- (this.limits.freshnessTtlMs ?? FILE_INDEX_FRESHNESS_TTL_MS))
141
- this.refreshInBackground();
142
- return this.currentContentCandidates();
143
- }
144
- /** Starts a single candidate refresh without making the caller await it. */
145
- refreshContentCandidates(requireFresh = false) {
146
- if (this.loading !== undefined)
147
- return this.loading;
148
- if (this.stale || this.root === undefined) {
149
- this.startLoading();
150
- return this.loading;
151
- }
152
- if (requireFresh) {
153
- this.refreshAttemptedAt = Date.now();
154
- this.startLoading();
155
- return this.loading;
156
- }
157
- if (Date.now() - this.refreshAttemptedAt >=
158
- (this.limits.freshnessTtlMs ?? FILE_INDEX_FRESHNESS_TTL_MS))
159
- this.refreshInBackground();
160
- return this.loading ?? Promise.resolve();
161
- }
162
- currentContentCandidates() {
163
- this.touch();
164
- return {
165
- entries: this.fileEntries,
166
- truncated: this.truncated
167
- };
168
- }
169
- dispose() {
170
- this.lifecycleGeneration += 1;
171
- if (this.expiryTimer !== undefined)
172
- clearTimeout(this.expiryTimer);
173
- this.expiryTimer = undefined;
174
- this.entries = [];
175
- this.fileEntries = [];
176
- this.root = undefined;
177
- this.retainedBytes = 0;
178
- this.failure = undefined;
179
- this.foregroundRequested = false;
180
- this.preparationConsumers.clear();
181
- this.truncated = false;
182
- this.stale = true;
183
- this.refreshedAt = 0;
184
- this.refreshAttemptedAt = 0;
185
- }
186
- async search(query, cursor, limit = SEARCH_RESULT_LIMIT, includeDirectories = false, waitForIndex = true) {
187
- const generation = this.lifecycleGeneration;
188
- if (query.trim().length === 0 ||
189
- query.length > 256 ||
190
- !Number.isSafeInteger(limit) ||
191
- limit < 1 ||
192
- limit > SEARCH_RESULT_LIMIT)
193
- throw new Error('FILE_SEARCH_LIMIT_EXCEEDED');
194
- const after = decodeSearchCursor(cursor, query);
195
- if (!waitForIndex && (this.stale || this.root === undefined)) {
196
- if (this.failure !== undefined) {
197
- const failure = this.failure;
198
- this.failure = undefined;
199
- throw failure;
200
- }
201
- this.foregroundRequested = true;
202
- this.startLoading();
203
- return { entries: [], nextCursor: null, truncated: false, indexState: 'BUILDING' };
204
- }
205
- await this.ensureLoaded();
206
- if (generation !== this.lifecycleGeneration || this.root === undefined)
207
- throw new Error('FILE_INDEX_INVALIDATED');
208
- this.touch();
209
- const terms = query.trim().toLocaleLowerCase().split(/\s+/u);
210
- const afterEntry = after === undefined
211
- ? undefined
212
- : this.entries.find((entry) => entry.path === after && (includeDirectories || entry.kind === 'FILE'));
213
- const afterScore = afterEntry === undefined ? -1 : fuzzyPathScore(afterEntry.path, terms);
214
- const afterCandidate = afterEntry === undefined || afterScore < 0
215
- ? undefined
216
- : { entry: afterEntry, score: afterScore };
217
- const ranked = new RankedCandidateHeap(limit + 1);
218
- for (const entry of this.entries) {
219
- if (!includeDirectories && entry.kind !== 'FILE')
220
- continue;
221
- const score = fuzzyPathScore(entry.path, terms);
222
- if (score < 0)
223
- continue;
224
- const candidate = { entry, score };
225
- if (afterCandidate !== undefined && compareRankedCandidate(candidate, afterCandidate) <= 0)
226
- continue;
227
- ranked.add(candidate);
228
- }
229
- const candidates = ranked.sorted();
230
- const page = candidates.slice(0, limit).map((candidate) => ({
231
- ...candidate.entry,
232
- score: candidate.score
233
- }));
234
- const next = candidates[limit]?.entry.path;
235
- return {
236
- entries: page,
237
- nextCursor: next === undefined ? null : encodeSearchCursor(query, page.at(-1)?.path ?? after ?? ''),
238
- truncated: next !== undefined || this.truncated,
239
- ...(!waitForIndex ? { indexState: 'READY' } : {})
240
- };
241
- }
242
- async ensureLoaded(requireFresh = false) {
243
- const generation = this.lifecycleGeneration;
244
- if (!this.stale && this.root !== undefined) {
245
- if (Date.now() - this.refreshAttemptedAt >=
246
- (this.limits.freshnessTtlMs ?? FILE_INDEX_FRESHNESS_TTL_MS)) {
247
- if (requireFresh) {
248
- this.foregroundRequested = true;
249
- if (this.loading === undefined) {
250
- this.refreshAttemptedAt = Date.now();
251
- this.startLoading();
252
- }
253
- await this.loading;
254
- if (generation !== this.lifecycleGeneration)
255
- throw new Error('FILE_INDEX_INVALIDATED');
256
- }
257
- else
258
- this.refreshInBackground();
259
- }
260
- return;
261
- }
262
- this.startLoading();
263
- await this.loading;
264
- if (generation !== this.lifecycleGeneration)
265
- throw new Error('FILE_INDEX_INVALIDATED');
266
- }
267
- startLoading(background = false, needed = () => true) {
268
- if (!background)
269
- this.foregroundRequested = true;
270
- if (this.loading !== undefined)
271
- return;
272
- const generation = this.lifecycleGeneration;
273
- const current = () => generation === this.lifecycleGeneration;
274
- const required = () => current() && (this.foregroundRequested || needed());
275
- const run = async () => {
276
- if (!current())
277
- throw new Error('FILE_INDEX_INVALIDATED');
278
- if (required())
279
- await this.rebuild();
280
- if (!current())
281
- throw new Error('FILE_INDEX_INVALIDATED');
282
- };
283
- const pending = (this.limits.schedule?.(run, () => !this.foregroundRequested, required) ?? run())
284
- .catch((error) => {
285
- if (current())
286
- this.failure = error;
287
- throw error;
288
- })
289
- .finally(() => {
290
- if (this.loading === pending) {
291
- this.loading = undefined;
292
- this.foregroundRequested = false;
293
- this.preparationConsumers.clear();
294
- }
295
- });
296
- this.loading = pending;
297
- // A fast acknowledgement has no awaiting consumer; retain failure for its next query.
298
- void this.loading.catch(() => undefined);
299
- }
300
- touch() {
301
- if (this.expiryTimer !== undefined)
302
- clearTimeout(this.expiryTimer);
303
- this.expiryTimer = setTimeout(() => this.dispose(), this.limits.idleTtlMs ?? FILE_INDEX_IDLE_TTL_MS);
304
- this.expiryTimer.unref();
305
- }
306
- async rebuild() {
307
- const generation = this.lifecycleGeneration;
308
- const root = await resolveRegisteredWorkspaceRoot(this.workspaceRoot);
309
- if (generation !== this.lifecycleGeneration)
310
- return;
311
- const excludeOptions = { dot: true, nocase: hostPlatform() !== 'linux' };
312
- const excludes = [
313
- // Fixed directory globs can use their compiled regex without splitting every path.
314
- ...DEFAULT_QUICK_OPEN_EXCLUDES.map((pattern) => {
315
- const matcher = new Minimatch(pattern, excludeOptions);
316
- const regex = matcher.makeRe();
317
- // Globstar also matches line terminators in valid filesystem names.
318
- return regex ? new RegExp(regex.source, `${regex.flags}s`) : matcher;
319
- }),
320
- ...(await workspaceExcludePatterns(root)).map((pattern) => new Minimatch(pattern, excludeOptions))
321
- ];
322
- if (generation !== this.lifecycleGeneration)
323
- return;
324
- const indexed = [];
325
- const entryLimit = this.limits.entryLimit ?? FILE_INDEX_ENTRY_LIMIT;
326
- const byteLimit = this.limits.byteLimit ?? FILE_INDEX_BYTE_LIMIT;
327
- let bytes = 0;
328
- let truncated = false;
329
- const directories = ['.'];
330
- let directoryCursor = 0;
331
- while (generation === this.lifecycleGeneration &&
332
- directoryCursor < directories.length &&
333
- !truncated) {
334
- const relativeDirectory = directories[directoryCursor];
335
- directoryCursor += 1;
336
- const directory = await resolveWorkspaceEntryFromCanonicalRoot(root, relativeDirectory, true).catch(() => undefined);
337
- if (directory === undefined)
338
- continue;
339
- const stream = await opendir(directory).catch(() => undefined);
340
- if (stream === undefined)
341
- continue;
342
- let pending = [];
343
- const appendPending = async () => {
344
- const resolvedEntries = await Promise.all(pending);
345
- pending = [];
346
- if (generation !== this.lifecycleGeneration)
347
- return;
348
- for (const entry of resolvedEntries) {
349
- if (entry === undefined)
350
- continue;
351
- if (entry.path === '.git' || entry.path.startsWith('.git/'))
352
- continue;
353
- const entryBytes = 160 + 2 * (entry.path.length + entry.name.length);
354
- if (bytes + entryBytes > byteLimit) {
355
- truncated = true;
356
- break;
357
- }
358
- bytes += entryBytes;
359
- if (entry.kind === 'DIRECTORY')
360
- directories.push(entry.path);
361
- indexed.push(entry);
362
- if (indexed.length >= entryLimit) {
363
- truncated = true;
364
- break;
365
- }
366
- }
367
- };
368
- for await (const child of stream) {
369
- if (generation !== this.lifecycleGeneration)
370
- return;
371
- const candidatePath = relativeDirectory === '.'
372
- ? child.name
373
- : posix.join(relativeDirectory.replaceAll('\\', '/'), child.name);
374
- if (candidatePath === '.git' ||
375
- candidatePath.startsWith('.git/') ||
376
- isExcluded(candidatePath, excludes))
377
- continue;
378
- pending.push(inspectWorkspaceIndexEntry(root, candidatePath, child.name, excludes));
379
- if (pending.length >= FILE_INDEX_METADATA_CONCURRENCY)
380
- await appendPending();
381
- if (truncated)
382
- break;
383
- }
384
- if (!truncated && pending.length > 0)
385
- await appendPending();
386
- }
387
- if (generation !== this.lifecycleGeneration)
388
- return;
389
- this.root = root;
390
- this.entries = indexed;
391
- this.fileEntries = indexed.filter((entry) => entry.kind === 'FILE');
392
- this.retainedBytes = bytes;
393
- this.failure = undefined;
394
- this.truncated = truncated;
395
- this.stale = false;
396
- this.refreshedAt = Date.now();
397
- this.refreshAttemptedAt = this.refreshedAt;
398
- this.touch();
399
- this.limits.onRebuilt?.();
400
- }
401
- refreshInBackground() {
402
- if (this.loading !== undefined)
403
- return;
404
- this.refreshAttemptedAt = Date.now();
405
- this.startLoading(true);
406
- }
407
- }
408
- async function workspaceExcludePatterns(root) {
409
- const settingsPath = resolve(root, '.vscode/settings.json');
410
- const settings = (await readFile(settingsPath, 'utf8')
411
- .then(JSON.parse)
412
- .catch(() => undefined));
413
- if (typeof settings !== 'object' || settings === null)
414
- return [];
415
- return ['files.exclude', 'search.exclude'].flatMap((key) => {
416
- const value = settings[key];
417
- return typeof value === 'object' && value !== null
418
- ? Object.entries(value).flatMap(([pattern, enabled]) => (enabled === true ? [pattern] : []))
419
- : [];
420
- });
421
- }
422
- function isExcluded(path, patterns) {
423
- return patterns.some((pattern) => pattern instanceof RegExp
424
- ? pattern.test(path) || pattern.test(`${path}/`)
425
- : pattern.match(path) || pattern.match(`${path}/`));
426
- }
427
- function compareRankedCandidate(left, right) {
428
- return right.score - left.score || left.entry.path.localeCompare(right.entry.path);
429
- }
430
- /** Keeps only the best page-sized candidate set, with the current worst item at the root. */
431
- class RankedCandidateHeap {
432
- capacity;
433
- values = [];
434
- constructor(capacity) {
435
- this.capacity = capacity;
436
- }
437
- add(candidate) {
438
- if (this.values.length < this.capacity) {
439
- this.values.push(candidate);
440
- this.bubbleUp(this.values.length - 1);
441
- return;
442
- }
443
- if (compareRankedCandidate(candidate, this.values[0]) >= 0)
444
- return;
445
- this.values[0] = candidate;
446
- this.sinkDown(0);
447
- }
448
- sorted() {
449
- return [...this.values].sort(compareRankedCandidate);
450
- }
451
- bubbleUp(start) {
452
- let index = start;
453
- while (index > 0) {
454
- const parent = Math.floor((index - 1) / 2);
455
- if (compareRankedCandidate(this.values[index], this.values[parent]) <= 0)
456
- break;
457
- [this.values[index], this.values[parent]] = [
458
- this.values[parent],
459
- this.values[index]
460
- ];
461
- index = parent;
462
- }
463
- }
464
- sinkDown(start) {
465
- let index = start;
466
- while (true) {
467
- const left = index * 2 + 1;
468
- if (left >= this.values.length)
469
- return;
470
- const right = left + 1;
471
- const worse = right < this.values.length &&
472
- compareRankedCandidate(this.values[right], this.values[left]) > 0
473
- ? right
474
- : left;
475
- if (compareRankedCandidate(this.values[worse], this.values[index]) <= 0)
476
- return;
477
- [this.values[index], this.values[worse]] = [
478
- this.values[worse],
479
- this.values[index]
480
- ];
481
- index = worse;
482
- }
483
- }
484
- }
485
- function fuzzyPathScore(path, terms) {
486
- const source = path.toLocaleLowerCase();
487
- const name = source.slice(source.lastIndexOf('/') + 1);
488
- let total = 0;
489
- for (const needle of terms) {
490
- const score = fuzzyTermScore(source, name, needle);
491
- if (score < 0)
492
- return -1;
493
- total += score;
494
- }
495
- return total - path.length / 1_000;
496
- }
497
- function fuzzyTermScore(source, name, needle) {
498
- if (name === needle || source === needle)
499
- return 1_000;
500
- if (name.startsWith(needle) || source.startsWith(needle))
501
- return 700;
502
- if (source.endsWith(`/${needle}`) || source.includes(`/${needle}/`))
503
- return 600;
504
- if (name.includes(needle) || source.includes(needle))
505
- return 500;
506
- let cursor = 0;
507
- let score = 0;
508
- let previous = -2;
509
- for (const character of needle) {
510
- const index = source.indexOf(character, cursor);
511
- if (index === -1)
512
- return -1;
513
- score += index === previous + 1 ? 12 : 1;
514
- if (index === 0 || '/._-'.includes(source[index - 1] ?? ''))
515
- score += 8;
516
- previous = index;
517
- cursor = index + 1;
518
- }
519
- return 100 + score / 1_000;
520
- }
521
- /**
522
- * Lists directory names only for the Workspace picker. Browsing begins at the
523
- * first allowed root when configured, while the Node account home remains a
524
- * browse boundary and terminal default.
525
- */
526
- export async function listWorkspaceDirectories(requestedPath, allowedRoots, homePath = homedir()) {
527
- const home = await realpath(homePath);
528
- const roots = await Promise.all(allowedRoots.map((root) => realpath(root)));
529
- const browseRoots = [...new Set([home, ...roots])];
530
- const requested = requestedPath === undefined ? (roots[0] ?? home) : await realpath(requestedPath);
531
- const directory = await stat(requested);
532
- if (!directory.isDirectory() || !isWithinAllowedRoot(requested, browseRoots))
533
- throw new Error('WORKSPACE_DIRECTORY_NOT_ALLOWED');
534
- const children = await readdir(requested, { withFileTypes: true });
535
- const entries = [];
536
- for (const child of children.sort((left, right) => left.name.localeCompare(right.name))) {
537
- if (!child.isDirectory() && !child.isSymbolicLink())
538
- continue;
539
- const path = await realpath(resolve(requested, child.name)).catch(() => undefined);
540
- if (path === undefined || !isWithinAllowedRoot(path, browseRoots))
541
- continue;
542
- const metadata = await stat(path).catch(() => undefined);
543
- if (metadata === undefined || !metadata.isDirectory())
544
- continue;
545
- entries.push({ path, name: child.name, selectable: isWithinAllowedRoot(path, roots) });
546
- if (entries.length > DIRECTORY_PICKER_LIMIT)
547
- break;
548
- }
549
- const parent = dirname(requested);
550
- return {
551
- homePath: home,
552
- currentPath: requested,
553
- parentPath: parent === requested || !isWithinAllowedRoot(parent, browseRoots) ? null : parent,
554
- browseRoots: roots,
555
- selectable: isWithinAllowedRoot(requested, roots),
556
- entries: entries.slice(0, DIRECTORY_PICKER_LIMIT),
557
- truncated: entries.length > DIRECTORY_PICKER_LIMIT
558
- };
559
- }
560
- export async function listWorkspaceFiles(workspaceRoot, requestedPath = '.', cursor, limit = DIRECTORY_PAGE_LIMIT) {
561
- if (!Number.isSafeInteger(limit) || limit < 1 || limit > DIRECTORY_PAGE_LIMIT)
562
- throw new Error('DIRECTORY_CURSOR_INVALID');
563
- const root = await resolveRegisteredWorkspaceRoot(workspaceRoot);
564
- const directory = await resolveWorkspaceEntryFromCanonicalRoot(root, requestedPath, true);
565
- const entries = await readdir(directory, { withFileTypes: true });
566
- const cursorValue = decodeDirectoryCursor(cursor, requestedPath);
567
- const names = entries
568
- .filter((entry) => entry.name !== '.git' && !isWorkspaceUploadTemporaryName(entry.name))
569
- .map((entry) => entry.name)
570
- .sort((left, right) => left.localeCompare(right));
571
- const start = cursorValue === undefined ? 0 : names.findIndex((name) => name > cursorValue);
572
- if (cursorValue !== undefined && start === -1)
573
- return { entries: [], nextCursor: null, truncated: false };
574
- const pageNames = names.slice(start, start + limit);
575
- const output = [];
576
- for (const name of pageNames) {
577
- const child = await resolveWorkspaceEntry(root, relative(root, resolve(directory, name)), false).catch((error) => {
578
- if (isSkippableWorkspaceEntryError(error))
579
- return undefined;
580
- throw error;
581
- });
582
- if (child === undefined)
583
- continue;
584
- const metadata = await lstat(child).catch((error) => {
585
- if (error.code === 'ENOENT')
586
- return undefined;
587
- throw error;
588
- });
589
- if (metadata === undefined || (!metadata.isFile() && !metadata.isDirectory()))
590
- continue;
591
- output.push({
592
- path: relative(root, child).split(sep).join('/'),
593
- name,
594
- kind: metadata.isDirectory() ? 'DIRECTORY' : 'FILE',
595
- size: metadata.isFile() ? metadata.size : null,
596
- modifiedAt: metadata.mtimeMs
597
- });
598
- }
599
- const nextName = names[start + pageNames.length];
600
- return {
601
- entries: output,
602
- nextCursor: nextName === undefined || pageNames.length === 0
603
- ? null
604
- : encodeDirectoryCursor(requestedPath, pageNames[pageNames.length - 1]),
605
- truncated: nextName !== undefined
606
- };
607
- }
608
- export async function mutateWorkspaceFile(workspaceRoot, mutation) {
609
- const root = await resolveRegisteredWorkspaceRoot(workspaceRoot);
610
- if (mutation.action === 'CREATE_FILE' || mutation.action === 'CREATE_DIRECTORY') {
611
- const target = await resolveNewWorkspaceEntry(root, mutation.path);
612
- if (mutation.action === 'CREATE_FILE')
613
- await writeFile(target, '', { flag: 'wx' });
614
- else
615
- await mkdir(target);
616
- return { path: workspaceRelativePath(root, target) };
617
- }
618
- await assertWorkspaceMutationPathHasNoSymlinks(root, mutation.path);
619
- const source = await resolveWorkspaceEntryMetadata(root, mutation.path);
620
- if (workspaceRelativePath(root, source.path) === '.')
621
- throw new Error('FILE_ROOT_MUTATION_INVALID');
622
- if (mutation.action === 'DELETE') {
623
- await rm(source.path, { recursive: source.metadata.isDirectory(), force: false });
624
- return { deleted: true };
625
- }
626
- const destination = await resolveNewWorkspaceEntry(root, mutation.destination);
627
- if (source.metadata.isDirectory()) {
628
- const destinationFromSource = relative(source.path, destination);
629
- if (destinationFromSource === '' ||
630
- (!isAbsolute(destinationFromSource) &&
631
- destinationFromSource !== '..' &&
632
- !destinationFromSource.startsWith(`..${sep}`)))
633
- throw new Error('FILE_MOVE_DESCENDANT_INVALID');
634
- }
635
- await rename(source.path, destination);
636
- return { path: workspaceRelativePath(root, destination) };
637
- }
638
- async function resolveNewWorkspaceEntry(root, requestedPath) {
639
- const normalized = requestedPath.replaceAll('\\', '/');
640
- const segments = normalized.split('/');
641
- const name = segments.at(-1);
642
- if (requestedPath.includes('\0') ||
643
- normalized.startsWith('/') ||
644
- isAbsolute(requestedPath) ||
645
- win32.isAbsolute(normalized) ||
646
- name === undefined ||
647
- name.length === 0 ||
648
- segments.some(invalidWorkspaceMutationSegment))
649
- throw new Error('FILE_PATH_ESCAPE');
650
- const parentPath = segments.length === 1 ? '.' : segments.slice(0, -1).join('/');
651
- await assertWorkspaceMutationPathHasNoSymlinks(root, parentPath);
652
- const parent = await resolveWorkspaceEntryFromCanonicalRoot(root, parentPath, true);
653
- const target = resolve(parent, name);
654
- try {
655
- await lstat(target);
656
- throw new Error('FILE_ALREADY_EXISTS');
657
- }
658
- catch (error) {
659
- if (error.code !== 'ENOENT')
660
- throw error;
661
- }
662
- return target;
663
- }
664
- async function assertWorkspaceMutationPathHasNoSymlinks(root, requestedPath) {
665
- const normalized = requestedPath.replaceAll('\\', '/');
666
- if (normalized === '.')
667
- return;
668
- const segments = normalized.split('/');
669
- if (requestedPath.includes('\0') ||
670
- normalized.startsWith('/') ||
671
- isAbsolute(requestedPath) ||
672
- win32.isAbsolute(normalized) ||
673
- segments.some(invalidWorkspaceMutationSegment))
674
- throw new Error('FILE_PATH_ESCAPE');
675
- let candidate = root;
676
- for (const segment of segments) {
677
- candidate = resolve(candidate, segment);
678
- const metadata = await lstat(candidate).catch((error) => {
679
- if (error.code === 'ENOENT')
680
- throw new Error('FILE_NOT_FOUND');
681
- throw error;
682
- });
683
- if (metadata.isSymbolicLink())
684
- throw new Error('FILE_PATH_ESCAPE');
685
- }
686
- }
687
- function invalidWorkspaceMutationSegment(segment) {
688
- if (segment.length === 0 || segment === '.' || segment === '..' || segment === '.git')
689
- return true;
690
- if (hostPlatform() !== 'win32')
691
- return false;
692
- const normalized = segment.replace(/[. ]+$/u, '').toUpperCase();
693
- return (/[<>:"|?*]/u.test(segment) ||
694
- /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$/u.test(normalized));
695
- }
696
- function workspaceRelativePath(root, path) {
697
- const value = relative(root, path).split(sep).join('/');
698
- return value.length === 0 ? '.' : value;
699
- }
700
- function isSkippableWorkspaceEntryError(error) {
701
- return (error instanceof Error &&
702
- (error.message === 'FILE_PATH_ESCAPE' ||
703
- error.message === 'FILE_NOT_FOUND' ||
704
- error.message === 'FILE_NOT_REGULAR'));
705
- }
706
- /** Validates an upload destination without following an existing target symlink. */
707
- export async function preflightWorkspaceUpload(workspaceRoot, requestedDirectory, name) {
708
- if (name.length === 0 ||
709
- name === '.' ||
710
- name === '..' ||
711
- name.includes('\0') ||
712
- name.includes('/') ||
713
- name.includes('\\') ||
714
- isWorkspaceUploadTemporaryName(name)) {
715
- return { path: '', absolutePath: '', status: 'INVALID_NAME' };
716
- }
717
- const root = await resolveRegisteredWorkspaceRoot(workspaceRoot);
718
- const directory = await resolveWorkspaceEntry(root, requestedDirectory, true);
719
- const absolutePath = resolve(directory, name);
720
- if (dirname(absolutePath) !== directory)
721
- throw new Error('FILE_PATH_ESCAPE');
722
- const relativePath = relative(root, absolutePath).split(sep).join('/');
723
- const metadata = await lstat(absolutePath).catch((error) => {
724
- if (error.code === 'ENOENT')
725
- return undefined;
726
- throw error;
727
- });
728
- if (metadata === undefined)
729
- return { path: relativePath, absolutePath, status: 'READY' };
730
- if (metadata.isSymbolicLink())
731
- throw new Error('FILE_PATH_ESCAPE');
732
- if (metadata.isFile())
733
- return { path: relativePath, absolutePath, status: 'EXISTS_FILE' };
734
- return { path: relativePath, absolutePath, status: 'EXISTS_DIRECTORY' };
735
- }
736
- export function workspaceUploadTemporaryName(uploadId) {
737
- return `.mar-upload-${uploadId}.part`;
738
- }
739
- function isWorkspaceUploadTemporaryName(name) {
740
- return (/^\.mar-upload-[0-9a-f-]{36}\.part$/iu.test(name) ||
741
- /^\.mar-edit-[0-9a-f-]{36}\.part$/iu.test(name));
742
- }
743
- export async function readWorkspaceTextFile(workspaceRoot, requestedPath, offset = 0, limit = FILE_READ_LIMIT) {
744
- if (!Number.isSafeInteger(offset) ||
745
- offset < 0 ||
746
- !Number.isSafeInteger(limit) ||
747
- limit < 1 ||
748
- limit > FILE_READ_LIMIT)
749
- throw new Error('FILE_RANGE_INVALID');
750
- const root = await resolveRegisteredWorkspaceRoot(workspaceRoot);
751
- const file = await resolveWorkspaceEntry(root, requestedPath, false);
752
- const metadata = await lstat(file);
753
- if (!metadata.isFile())
754
- throw new Error('FILE_NOT_REGULAR');
755
- if (metadata.size > FILE_READ_LIMIT * 16)
756
- throw new Error('FILE_TOO_LARGE');
757
- const source = await readFile(file);
758
- const revision = createHash('sha256').update(source).digest('hex');
759
- const binary = source.includes(0) || !isValidUtf8(source);
760
- if (binary) {
761
- const end = Math.min(source.length, offset + limit);
762
- const slice = source.subarray(offset, end);
763
- return {
764
- path: relative(root, file).split(sep).join('/'),
765
- encoding: 'base64',
766
- size: source.length,
767
- content: slice.toString('base64'),
768
- truncated: end < source.length,
769
- nextOffset: end < source.length ? end : null,
770
- revision
771
- };
772
- }
773
- if (offset > 0 && offset < source.length && isUtf8ContinuationByte(source[offset]))
774
- throw new Error('FILE_RANGE_INVALID');
775
- let end = Math.min(source.length, offset + limit);
776
- if (end < source.length && isUtf8ContinuationByte(source[end])) {
777
- while (end > offset && isUtf8ContinuationByte(source[end]))
778
- end -= 1;
779
- if (end === offset) {
780
- end += 1;
781
- while (end < source.length && isUtf8ContinuationByte(source[end]))
782
- end += 1;
783
- }
784
- }
785
- const slice = source.subarray(offset, end);
786
- const content = new TextDecoder('utf-8', { fatal: true }).decode(slice);
787
- const nextOffset = end < source.length ? end : null;
788
- return {
789
- path: relative(root, file).split(sep).join('/'),
790
- encoding: 'utf-8',
791
- size: source.length,
792
- content,
793
- truncated: nextOffset !== null,
794
- nextOffset,
795
- revision
796
- };
797
- }
798
- export async function writeWorkspaceTextFile(workspaceRoot, requestedPath, content, expectedRevision) {
799
- if (!/^[0-9a-f]{64}$/u.test(expectedRevision))
800
- throw new Error('FILE_REVISION_INVALID');
801
- const source = Buffer.from(content, 'utf8');
802
- if (source.length > FILE_READ_LIMIT * 16)
803
- throw new Error('FILE_TOO_LARGE');
804
- if (source.includes(0))
805
- throw new Error('FILE_NOT_TEXT');
806
- const root = await resolveRegisteredWorkspaceRoot(workspaceRoot);
807
- await assertWorkspaceMutationPathHasNoSymlinks(root, requestedPath);
808
- const file = await resolveWorkspaceEntry(root, requestedPath, false);
809
- const metadata = await lstat(file);
810
- if (!metadata.isFile())
811
- throw new Error('FILE_NOT_REGULAR');
812
- const current = await readFile(file);
813
- if (current.includes(0) || !isValidUtf8(current))
814
- throw new Error('FILE_NOT_TEXT');
815
- const currentRevision = createHash('sha256').update(current).digest('hex');
816
- if (currentRevision !== expectedRevision)
817
- throw new Error('FILE_WRITE_CONFLICT');
818
- const revision = createHash('sha256').update(source).digest('hex');
819
- const staging = resolve(dirname(file), `.mar-edit-${randomUUID()}.part`);
820
- try {
821
- await writeFile(staging, source, { flag: 'wx', mode: metadata.mode });
822
- await rename(staging, file);
823
- }
824
- finally {
825
- await rm(staging, { force: true }).catch(() => undefined);
826
- }
827
- return { path: workspaceRelativePath(root, file), revision, size: source.length };
828
- }
829
- export async function readWorkspaceFileContentRange(workspaceRoot, requestedPath, offset = 0, limit = FILE_READ_LIMIT) {
830
- if (!Number.isSafeInteger(offset) ||
831
- offset < 0 ||
832
- !Number.isSafeInteger(limit) ||
833
- limit < 1 ||
834
- limit > FILE_READ_LIMIT)
835
- throw new Error('FILE_RANGE_INVALID');
836
- const root = await resolveRegisteredWorkspaceRoot(workspaceRoot);
837
- const file = await resolveWorkspaceEntry(root, requestedPath, false);
838
- const metadata = await lstat(file);
839
- if (!metadata.isFile())
840
- throw new Error('FILE_NOT_REGULAR');
841
- if (offset > metadata.size)
842
- throw new Error('FILE_RANGE_INVALID');
843
- const length = Math.min(limit, metadata.size - offset);
844
- const source = Buffer.allocUnsafe(length);
845
- const handle = await open(file, 'r');
846
- try {
847
- const { bytesRead } = await handle.read(source, 0, length, offset);
848
- const end = offset + bytesRead;
849
- return {
850
- path: relative(root, file).split(sep).join('/'),
851
- size: metadata.size,
852
- sourceRevision: fileSourceRevision(metadata),
853
- offset,
854
- contentBase64: source.subarray(0, bytesRead).toString('base64'),
855
- nextOffset: end < metadata.size ? end : null
856
- };
857
- }
858
- finally {
859
- await handle.close();
860
- }
861
- }
862
- /** Reads one regular absolute file range using the Node account's filesystem permissions. */
863
- export async function readAbsoluteFileContentRange(requestedPath, offset = 0, limit = FILE_READ_LIMIT) {
864
- if (!Number.isSafeInteger(offset) ||
865
- offset < 0 ||
866
- !Number.isSafeInteger(limit) ||
867
- limit < 1 ||
868
- limit > FILE_READ_LIMIT)
869
- throw new Error('FILE_RANGE_INVALID');
870
- const { file, metadata } = await resolveAbsoluteFile(requestedPath);
871
- if (offset > metadata.size)
872
- throw new Error('FILE_RANGE_INVALID');
873
- const length = Math.min(limit, metadata.size - offset);
874
- const source = Buffer.allocUnsafe(length);
875
- const handle = await open(file, 'r');
876
- try {
877
- const { bytesRead } = await handle.read(source, 0, length, offset);
878
- const end = offset + bytesRead;
879
- return {
880
- path: file,
881
- size: metadata.size,
882
- sourceRevision: fileSourceRevision(metadata),
883
- offset,
884
- contentBase64: source.subarray(0, bytesRead).toString('base64'),
885
- nextOffset: end < metadata.size ? end : null
886
- };
887
- }
888
- finally {
889
- await handle.close();
890
- }
891
- }
892
- function fileSourceRevision(metadata) {
893
- return createHash('sha256')
894
- .update([metadata.dev, metadata.ino, metadata.size, metadata.mtimeMs, metadata.ctimeMs].join(':'))
895
- .digest('hex');
896
- }
897
- /** Reads one regular absolute file using the Node account's filesystem permissions. */
898
- export async function readAbsoluteTextFile(requestedPath, offset = 0, limit = FILE_READ_LIMIT) {
899
- const { file, metadata } = await resolveAbsoluteFile(requestedPath);
900
- if (metadata.size > FILE_READ_LIMIT * 16)
901
- throw new Error('FILE_TOO_LARGE');
902
- if (!Number.isSafeInteger(offset) ||
903
- offset < 0 ||
904
- !Number.isSafeInteger(limit) ||
905
- limit < 1 ||
906
- limit > FILE_READ_LIMIT)
907
- throw new Error('FILE_RANGE_INVALID');
908
- const source = await readFile(file);
909
- const binary = source.includes(0) || !isValidUtf8(source);
910
- const end = Math.min(source.length, offset + limit);
911
- if (binary) {
912
- const slice = source.subarray(offset, end);
913
- return {
914
- path: file,
915
- encoding: 'base64',
916
- size: source.length,
917
- content: slice.toString('base64'),
918
- truncated: end < source.length,
919
- nextOffset: end < source.length ? end : null
920
- };
921
- }
922
- if (offset > 0 && offset < source.length && isUtf8ContinuationByte(source[offset]))
923
- throw new Error('FILE_RANGE_INVALID');
924
- let textEnd = end;
925
- if (textEnd < source.length && isUtf8ContinuationByte(source[textEnd])) {
926
- while (textEnd > offset && isUtf8ContinuationByte(source[textEnd]))
927
- textEnd -= 1;
928
- if (textEnd === offset) {
929
- textEnd += 1;
930
- while (textEnd < source.length && isUtf8ContinuationByte(source[textEnd]))
931
- textEnd += 1;
932
- }
933
- }
934
- const nextOffset = textEnd < source.length ? textEnd : null;
935
- return {
936
- path: file,
937
- encoding: 'utf-8',
938
- size: source.length,
939
- content: new TextDecoder('utf-8', { fatal: true }).decode(source.subarray(offset, textEnd)),
940
- truncated: nextOffset !== null,
941
- nextOffset
942
- };
943
- }
944
- export function normalizeFilePreviewPath(requestedPath) {
945
- if (!/^sandbox:/iu.test(requestedPath))
946
- return requestedPath;
947
- let decoded;
948
- try {
949
- decoded = decodeURIComponent(requestedPath.slice(requestedPath.indexOf(':') + 1));
950
- }
951
- catch {
952
- throw new Error('FILE_PATH_ESCAPE');
953
- }
954
- const normalized = decoded.replace(/^\/([a-z]:[\\/])/iu, '$1');
955
- if (normalized.length === 0 ||
956
- /^[\\/]{2,}[?.](?:[\\/]|$)/u.test(decoded) ||
957
- (!isAbsolute(normalized) && !win32.isAbsolute(normalized)) ||
958
- normalized.includes('\0') ||
959
- normalized.includes('?') ||
960
- normalized.includes('#'))
961
- throw new Error('FILE_PATH_ESCAPE');
962
- return normalized;
963
- }
964
- async function resolveAbsoluteFile(requestedPath) {
965
- if (!isAbsolute(requestedPath))
966
- throw new Error('FILE_PATH_ESCAPE');
967
- const file = await realpath(requestedPath).catch((error) => {
968
- if (error.code === 'ENOENT')
969
- throw new Error('FILE_NOT_FOUND');
970
- throw error;
971
- });
972
- const metadata = await lstat(file);
973
- if (!metadata.isFile())
974
- throw new Error('FILE_NOT_REGULAR');
975
- return { file, metadata };
976
- }
977
- function isValidUtf8(source) {
978
- try {
979
- new TextDecoder('utf-8', { fatal: true }).decode(source);
980
- return true;
981
- }
982
- catch {
983
- return false;
984
- }
985
- }
986
- function isUtf8ContinuationByte(value) {
987
- return (value & 0b1100_0000) === 0b1000_0000;
988
- }
989
- /** Searches file names only, so an exploratory request cannot exfiltrate file contents. */
990
- export async function searchWorkspaceFiles(workspaceRoot, query, cursor, limit = SEARCH_RESULT_LIMIT, includeDirectories = false) {
991
- const index = new WorkspaceFileIndex(workspaceRoot);
992
- try {
993
- return await index.search(query, cursor, limit, includeDirectories);
994
- }
995
- finally {
996
- index.dispose();
997
- }
998
- }
999
- async function resolveRegisteredWorkspaceRoot(workspaceRoot) {
1000
- const root = await realpath(workspaceRoot);
1001
- if (normalizeWorkspacePath(root) !== normalizeWorkspacePath(workspaceRoot))
1002
- throw Object.assign(new Error('WORKSPACE_NOT_ALLOWED'), { code: 'WORKSPACE_NOT_ALLOWED' });
1003
- return root;
1004
- }
1005
- export class SessionExecutionDirectoryError extends Error {
1006
- publicMessage;
1007
- code = 'SESSION_EXECUTION_DIRECTORY_INVALID';
1008
- publicParameters;
1009
- constructor(reason, publicMessage, options) {
1010
- super(publicMessage, options);
1011
- this.publicMessage = publicMessage;
1012
- this.publicParameters = { reason };
1013
- }
1014
- }
1015
- /** Bind ordinary Session execution to one registered Workspace, not all allowed roots. */
1016
- export async function resolveWorkspaceExecutionDirectory(workspaceRoot, cwd) {
1017
- if (!isAbsolute(cwd) || (process.platform === 'win32' && win32.parse(cwd).root.length <= 1))
1018
- throw new SessionExecutionDirectoryError('ABSOLUTE_PATH_REQUIRED', 'The execution directory must be an absolute path on this Node.');
1019
- try {
1020
- const root = await resolveRegisteredWorkspaceRoot(workspaceRoot);
1021
- const directory = await realpath(cwd);
1022
- if (!isWithinAllowedRoot(directory, [root]))
1023
- throw new SessionExecutionDirectoryError('OUTSIDE_WORKSPACE', 'The execution directory is outside the authorized Workspace.');
1024
- if (!(await stat(directory)).isDirectory())
1025
- throw new SessionExecutionDirectoryError('NOT_DIRECTORY', 'The execution directory is not a directory.');
1026
- return directory;
1027
- }
1028
- catch (error) {
1029
- if (error instanceof SessionExecutionDirectoryError)
1030
- throw error;
1031
- const code = error !== null && typeof error === 'object' && 'code' in error ? error.code : undefined;
1032
- const [reason, message] = code === 'WORKSPACE_NOT_ALLOWED'
1033
- ? [
1034
- 'WORKSPACE_ROOT_CHANGED',
1035
- 'The registered Workspace root no longer identifies the same directory.'
1036
- ]
1037
- : code === 'ENOENT' || code === 'ENOTDIR'
1038
- ? ['NOT_FOUND', 'The execution directory or Workspace root is unavailable.']
1039
- : code === 'EACCES' || code === 'EPERM'
1040
- ? [
1041
- 'ACCESS_DENIED',
1042
- 'The Node account cannot access the execution directory or Workspace root.'
1043
- ]
1044
- : ['IO_ERROR', 'The execution directory could not be verified.'];
1045
- throw new SessionExecutionDirectoryError(reason, message, { cause: error });
1046
- }
1047
- }
1048
- async function resolveWorkspaceEntry(workspaceRoot, requestedPath, directory) {
1049
- const root = await resolveRegisteredWorkspaceRoot(workspaceRoot);
1050
- return resolveWorkspaceEntryFromCanonicalRoot(root, requestedPath, directory);
1051
- }
1052
- async function resolveWorkspaceEntryFromCanonicalRoot(root, requestedPath, directory) {
1053
- const entry = await resolveWorkspaceEntryMetadata(root, requestedPath);
1054
- if (directory
1055
- ? !entry.metadata.isDirectory()
1056
- : !entry.metadata.isFile() && !entry.metadata.isDirectory())
1057
- throw new Error(directory ? 'FILE_NOT_DIRECTORY' : 'FILE_NOT_REGULAR');
1058
- return entry.path;
1059
- }
1060
- async function resolveWorkspaceEntryMetadata(root, requestedPath) {
1061
- if (requestedPath.includes('\0') ||
1062
- requestedPath.length === 0 ||
1063
- requestedPath.startsWith('/') ||
1064
- requestedPath.startsWith('\\'))
1065
- throw new Error('FILE_PATH_ESCAPE');
1066
- const normalized = requestedPath.replaceAll('\\', '/');
1067
- if (normalized.split('/').some((segment) => segment === '..' || segment === '.git'))
1068
- throw new Error('FILE_PATH_ESCAPE');
1069
- let candidate;
1070
- try {
1071
- candidate = await realpath(resolve(root, normalized));
1072
- }
1073
- catch (error) {
1074
- if (error.code === 'ENOENT')
1075
- throw new Error('FILE_NOT_FOUND');
1076
- throw error;
1077
- }
1078
- const pathToCandidate = relative(root, candidate);
1079
- if (isAbsolute(pathToCandidate) ||
1080
- pathToCandidate.startsWith('..') ||
1081
- pathToCandidate === '..' ||
1082
- pathToCandidate.startsWith(`..${sep}`))
1083
- throw new Error('FILE_PATH_ESCAPE');
1084
- const metadata = await lstat(candidate);
1085
- return { path: candidate, metadata };
1086
- }
1087
- export async function resolveWorkspaceFileManagerTarget(workspaceRoot, requestedPath) {
1088
- const root = await resolveRegisteredWorkspaceRoot(workspaceRoot);
1089
- const entry = await resolveWorkspaceEntryMetadata(root, requestedPath);
1090
- if (entry.metadata.isDirectory())
1091
- return { path: entry.path, kind: 'DIRECTORY' };
1092
- if (entry.metadata.isFile())
1093
- return { path: entry.path, kind: 'FILE' };
1094
- throw new Error('FILE_NOT_REGULAR');
1095
- }
1096
- async function inspectWorkspaceIndexEntry(root, requestedPath, name, excludes) {
1097
- try {
1098
- const entry = await resolveWorkspaceEntryMetadata(root, requestedPath);
1099
- if (!entry.metadata.isFile() && !entry.metadata.isDirectory())
1100
- return undefined;
1101
- const safePath = relative(root, entry.path).split(sep).join('/');
1102
- // The requested path was already checked; aliases need the canonical-path check too.
1103
- if (safePath !== requestedPath && isExcluded(safePath, excludes))
1104
- return undefined;
1105
- return entry.metadata.isDirectory()
1106
- ? {
1107
- path: safePath,
1108
- name,
1109
- kind: 'DIRECTORY',
1110
- size: null,
1111
- modifiedAt: entry.metadata.mtimeMs
1112
- }
1113
- : {
1114
- path: safePath,
1115
- name,
1116
- kind: 'FILE',
1117
- size: entry.metadata.size,
1118
- modifiedAt: entry.metadata.mtimeMs
1119
- };
1120
- }
1121
- catch {
1122
- return undefined;
1123
- }
1124
- }
1125
- function encodeDirectoryCursor(path, name) {
1126
- return Buffer.from(JSON.stringify({ path, name })).toString('base64url');
1127
- }
1128
- function decodeDirectoryCursor(cursor, expectedPath) {
1129
- if (cursor === undefined)
1130
- return undefined;
1131
- try {
1132
- const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
1133
- if (typeof value !== 'object' ||
1134
- value === null ||
1135
- value.path !== expectedPath ||
1136
- typeof value.name !== 'string')
1137
- throw new Error();
1138
- return value.name;
1139
- }
1140
- catch {
1141
- throw new Error('DIRECTORY_CURSOR_INVALID');
1142
- }
1143
- }
1144
- function encodeSearchCursor(query, path) {
1145
- return Buffer.from(JSON.stringify({ query, path })).toString('base64url');
1146
- }
1147
- function decodeSearchCursor(cursor, expectedQuery) {
1148
- if (cursor === undefined)
1149
- return undefined;
1150
- try {
1151
- const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
1152
- if (typeof value !== 'object' ||
1153
- value === null ||
1154
- value.query !== expectedQuery ||
1155
- typeof value.path !== 'string')
1156
- throw new Error();
1157
- return value.path;
1158
- }
1159
- catch {
1160
- throw new Error('DIRECTORY_CURSOR_INVALID');
1161
- }
1162
- }
1163
- export function normalizeWorkspacePath(input, platform = hostPlatform()) {
1164
- if (input.length === 0 || input.includes('\u0000')) {
1165
- throw new Error('WORKSPACE_PATH_INVALID');
1166
- }
1167
- if (platform === 'win32') {
1168
- // Device paths bypass normal Win32 resolution and must never be accepted.
1169
- if (/^\\\\[?.]/.test(input)) {
1170
- throw new Error('WORKSPACE_DEVICE_PATH_REJECTED');
1171
- }
1172
- const normalized = win32.normalize(input.replaceAll('/', '\\'));
1173
- if (!win32.isAbsolute(normalized)) {
1174
- throw new Error('WORKSPACE_PATH_NOT_ABSOLUTE');
1175
- }
1176
- // A drive root must remain absolute instead of becoming drive-relative.
1177
- const root = win32.parse(normalized).root;
1178
- return (normalized === root ? root : normalized.replace(/\\+$/, '')).toLowerCase();
1179
- }
1180
- if (platform !== 'linux' && platform !== 'darwin') {
1181
- throw new Error('WORKSPACE_PLATFORM_UNSUPPORTED');
1182
- }
1183
- const normalized = posix.normalize(input);
1184
- if (!posix.isAbsolute(normalized)) {
1185
- throw new Error('WORKSPACE_PATH_NOT_ABSOLUTE');
1186
- }
1187
- return normalized === '/' ? normalized : normalized.replace(/\/+$/, '');
1188
- }
1189
- export function isWithinAllowedRoot(target, roots, platform = hostPlatform()) {
1190
- const normalizedTarget = normalizeWorkspacePath(target, platform);
1191
- const pathApi = platform === 'win32' ? win32 : posix;
1192
- return roots.some((root) => {
1193
- const normalizedRoot = normalizeWorkspacePath(root, platform);
1194
- const relative = pathApi.relative(normalizedRoot, normalizedTarget);
1195
- return relative === '' || (!relative.startsWith('..') && !pathApi.isAbsolute(relative));
1196
- });
1197
- }
1198
- /** Resolving both candidate and roots rejects Linux symlinks which point outside a root. */
1199
- export async function inspectWorkspace(requestedPath, allowedRoots, includeParentRepository = true) {
1200
- const resolvedPath = await realpath(requestedPath);
1201
- const resolvedRoots = await Promise.all(allowedRoots.map((root) => realpath(root)));
1202
- const directory = await stat(resolvedPath);
1203
- if (!directory.isDirectory() || !isWithinAllowedRoot(resolvedPath, resolvedRoots)) {
1204
- throw new Error('WORKSPACE_NOT_ALLOWED');
1205
- }
1206
- try {
1207
- const repositoryRoot = await resolveWorkspaceGitRoot(resolvedPath, includeParentRepository);
1208
- const summary = { ...(await readGitSummary(repositoryRoot)), repositoryRoot };
1209
- return { path: resolvedPath, kind: 'GIT_WORKSPACE', gitSummary: summary };
1210
- }
1211
- catch (error) {
1212
- if ((error instanceof GitCommandError && error.code === 128) ||
1213
- isGitUnavailable(error) ||
1214
- (error instanceof Error && error.message === 'WORKSPACE_NOT_GIT')) {
1215
- return { path: resolvedPath, kind: 'DIRECTORY' };
1216
- }
1217
- throw error;
1218
- }
1219
- }
1220
- /** Resolves the working tree without changing the registered Workspace identity. */
1221
- export async function resolveWorkspaceGitRoot(workspacePath, includeParentRepository = true) {
1222
- const workspace = await resolveRegisteredWorkspaceRoot(workspacePath);
1223
- if (!includeParentRepository) {
1224
- const marker = await lstat(resolve(workspace, '.git')).catch((error) => {
1225
- if (error.code === 'ENOENT')
1226
- return undefined;
1227
- throw error;
1228
- });
1229
- if (marker === undefined ||
1230
- marker.isSymbolicLink() ||
1231
- (!marker.isFile() && !marker.isDirectory()))
1232
- throw new Error('WORKSPACE_NOT_GIT');
1233
- }
1234
- const result = await runReadOnlyGit(workspace, ['rev-parse', '--show-toplevel']);
1235
- const root = await realpath(result.text.replace(/\n$/u, ''));
1236
- if (!includeParentRepository &&
1237
- normalizeWorkspacePath(root) !== normalizeWorkspacePath(workspace))
1238
- throw new Error('WORKSPACE_NOT_GIT');
1239
- return root;
1240
- }
1241
- export async function readGitSummary(workspacePath) {
1242
- const commonDir = (await runReadOnlyGit(workspacePath, ['rev-parse', '--git-common-dir'])).text.trim();
1243
- const headResult = await runReadOnlyGitAllowFailure(workspacePath, [
1244
- 'rev-parse',
1245
- '--verify',
1246
- 'HEAD'
1247
- ]);
1248
- const branchResult = await runReadOnlyGitAllowFailure(workspacePath, [
1249
- 'symbolic-ref',
1250
- '--quiet',
1251
- '--short',
1252
- 'HEAD'
1253
- ]);
1254
- const statusOptions = {
1255
- timeoutMs: GIT_STATUS_TIMEOUT_MS,
1256
- outputLimit: GIT_LARGE_OUTPUT_LIMIT
1257
- };
1258
- const status = await runReadOnlyGit(workspacePath, ['status', '--porcelain=v1'], statusOptions);
1259
- return {
1260
- commonDir,
1261
- head: headResult.code === 0 ? headResult.text.trim() : '',
1262
- branch: branchResult.code === 0 ? branchResult.text.trim() : null,
1263
- dirty: status.output.length > 0,
1264
- snapshot: summarizeText(status.text)
1265
- };
1266
- }
1267
- export function assertSafeGitRef(ref) {
1268
- if (!REF_PATTERN.test(ref)) {
1269
- throw new Error('GIT_REF_INVALID');
1270
- }
1271
- }
1272
- export async function readRestrictedDiff(workspacePath, fromRef, toRef) {
1273
- assertSafeGitRef(fromRef);
1274
- assertSafeGitRef(toRef);
1275
- const result = await runReadOnlyGit(workspacePath, [
1276
- 'diff',
1277
- '--no-ext-diff',
1278
- '--no-textconv',
1279
- fromRef,
1280
- toRef
1281
- ]);
1282
- const binary = result.output.includes(0) || result.text.includes('Binary files ');
1283
- if (binary) {
1284
- return {
1285
- binary: true,
1286
- summary: `binary diff sha256=${createHash('sha256').update(result.output).digest('hex')} bytes=${result.output.length}`,
1287
- truncated: result.truncated
1288
- };
1289
- }
1290
- return { binary: false, text: result.text, truncated: result.truncated };
1291
- }
1292
- function currentBranchFromStatusHeader(header) {
1293
- const value = header.slice(3);
1294
- if (value.startsWith('HEAD '))
1295
- return null;
1296
- const unborn = /^(?:No commits yet on|Initial commit on) (.+)$/.exec(value);
1297
- if (unborn !== null)
1298
- return unborn[1] ?? null;
1299
- return value.split('...')[0]?.split(' ')[0] ?? null;
1300
- }
1301
- function trackingFromStatusHeader(header) {
1302
- const match = /^## [^ ]+\.\.\.([^ ]+)(?: \[(ahead \d+)?(?:, )?(behind \d+)?\])?$/.exec(header);
1303
- if (match?.[1] === undefined)
1304
- return undefined;
1305
- return {
1306
- upstream: match[1],
1307
- ahead: Number(match[2]?.slice(6) ?? 0),
1308
- behind: Number(match[3]?.slice(7) ?? 0)
1309
- };
1310
- }
1311
- export async function listGitWorktrees(workspacePath) {
1312
- const options = {
1313
- timeoutMs: GIT_STATUS_TIMEOUT_MS,
1314
- outputLimit: GIT_LARGE_OUTPUT_LIMIT
1315
- };
1316
- const nulDelimited = await runReadOnlyGitAllowFailure(workspacePath, ['worktree', 'list', '--porcelain', '-z'], options);
1317
- if (nulDelimited.code === 0)
1318
- return parseGitWorktreeList(workspacePath, nulDelimited, true);
1319
- if (!canRetryGitWorktreeListWithoutNullOutput(nulDelimited))
1320
- throw gitCommandFailure(nulDelimited.code, nulDelimited.stderr, 'worktree');
1321
- const lineDelimited = await runReadOnlyGit(workspacePath, ['worktree', 'list', '--porcelain'], options);
1322
- return parseGitWorktreeList(workspacePath, lineDelimited, false);
1323
- }
1324
- async function parseGitWorktreeList(workspacePath, result, nulDelimited) {
1325
- const currentPath = normalizeWorkspacePath(await realpath(workspacePath));
1326
- const entries = [];
1327
- let truncated = result.truncated;
1328
- const groups = nulDelimited ? result.text.split('\0\0') : result.text.split(/\r?\n\r?\n/);
1329
- for (const group of groups) {
1330
- if (group.length === 0)
1331
- continue;
1332
- if (entries.length >= GIT_WORKTREE_LIST_LIMIT) {
1333
- truncated = true;
1334
- break;
1335
- }
1336
- const fields = (nulDelimited ? group.split('\0') : group.split(/\r?\n/)).filter((field) => field.length > 0);
1337
- if (!nulDelimited &&
1338
- fields.some((field) => !(field.startsWith('worktree ') ||
1339
- field.startsWith('HEAD ') ||
1340
- field.startsWith('branch ') ||
1341
- field === 'bare' ||
1342
- field === 'detached' ||
1343
- field === 'locked' ||
1344
- field.startsWith('locked ') ||
1345
- field === 'prunable' ||
1346
- field.startsWith('prunable ')))) {
1347
- throw new Error('GIT_WORKTREE_LIST_INVALID');
1348
- }
1349
- if (fields.some((field) => field === 'bare'))
1350
- continue;
1351
- const pathField = fields.find((field) => field.startsWith('worktree '));
1352
- const headField = fields.find((field) => field.startsWith('HEAD '));
1353
- const path = pathField?.slice('worktree '.length);
1354
- const head = headField?.slice('HEAD '.length);
1355
- if (path === undefined ||
1356
- path.length === 0 ||
1357
- head === undefined ||
1358
- !/^[0-9a-f]{40,64}$/u.test(head)) {
1359
- if (result.truncated) {
1360
- truncated = true;
1361
- continue;
1362
- }
1363
- throw new Error('GIT_WORKTREE_LIST_INVALID');
1364
- }
1365
- const branchField = fields.find((field) => field.startsWith('branch '));
1366
- const branchRef = branchField?.slice('branch '.length);
1367
- const branch = branchRef === undefined
1368
- ? null
1369
- : branchRef.startsWith('refs/heads/')
1370
- ? branchRef.slice('refs/heads/'.length)
1371
- : branchRef;
1372
- const directoryName = (basename(path).trim() || branch || head.slice(0, 12)).slice(0, 512);
1373
- entries.push({
1374
- path,
1375
- directoryName,
1376
- branch,
1377
- head,
1378
- current: normalizeWorkspacePath(path) === currentPath,
1379
- locked: fields.some((field) => field === 'locked' || field.startsWith('locked ')),
1380
- prunable: fields.some((field) => field === 'prunable' || field.startsWith('prunable '))
1381
- });
1382
- }
1383
- return { entries, truncated };
1384
- }
1385
- export async function readCurrentChangesSummary(workspacePath, isolateSubmoduleFailures = false) {
1386
- const args = [
1387
- 'status',
1388
- '--porcelain=v1',
1389
- '-z',
1390
- '--branch',
1391
- '--ahead-behind',
1392
- '--untracked-files=all'
1393
- ];
1394
- const options = { timeoutMs: GIT_STATUS_TIMEOUT_MS, outputLimit: GIT_LARGE_OUTPUT_LIMIT };
1395
- const result = await runReadOnlyGit(workspacePath, args, options).catch((error) => {
1396
- if (!isolateSubmoduleFailures || !(error instanceof GitCommandError))
1397
- throw error;
1398
- // Validate root facts independently when descending into a damaged child fails.
1399
- // A second failure remains a repository failure, never an empty success.
1400
- return runReadOnlyGit(workspacePath, [...args, '--ignore-submodules=dirty'], options);
1401
- });
1402
- const records = result.text.split('\0');
1403
- if (result.truncated)
1404
- records.pop();
1405
- const header = records.shift() ?? '';
1406
- const output = [];
1407
- for (let index = 0; index < records.length; index += 1) {
1408
- const record = records[index];
1409
- if (record.length < 4)
1410
- continue;
1411
- const indexState = record[0];
1412
- const worktreeState = record[1];
1413
- const path = record.slice(3);
1414
- if (indexState === '?' && worktreeState === '?') {
1415
- output.push({ path, state: 'UNTRACKED', change: 'ADDED' });
1416
- continue;
1417
- }
1418
- if (indexState !== ' ') {
1419
- output.push({ path, state: 'STAGED', change: gitChange(indexState) });
1420
- }
1421
- if (worktreeState !== ' ') {
1422
- output.push({ path, state: 'UNSTAGED', change: gitChange(worktreeState) });
1423
- }
1424
- // Porcelain v1 -z emits a second NUL-delimited source path for rename
1425
- // and copy records. It is metadata for the same entry, never another
1426
- // status record.
1427
- if (indexState === 'R' || indexState === 'C' || worktreeState === 'R' || worktreeState === 'C')
1428
- index += 1;
1429
- }
1430
- const tracking = trackingFromStatusHeader(header);
1431
- const branch = header.startsWith('## ') ? currentBranchFromStatusHeader(header) : null;
1432
- const head = branch === null
1433
- ? (await runReadOnlyGit(workspacePath, ['rev-parse', '--verify', 'HEAD'])).text.trim()
1434
- : undefined;
1435
- return {
1436
- branch,
1437
- ...(head === undefined ? {} : { head }),
1438
- ...(tracking === undefined ? {} : { tracking }),
1439
- ...(result.truncated ? { changesTruncated: true } : {}),
1440
- changes: output
1441
- };
1442
- }
1443
- export async function listGitSubmodules(workspacePath) {
1444
- const result = await runReadOnlyGit(workspacePath, ['ls-files', '--stage', '-z'], {
1445
- timeoutMs: GIT_STATUS_TIMEOUT_MS,
1446
- outputLimit: GIT_LARGE_OUTPUT_LIMIT
1447
- });
1448
- if (result.truncated)
1449
- throw new Error('GIT_SUBMODULE_DISCOVERY_TRUNCATED');
1450
- const paths = [
1451
- ...new Set(result.text.split('\0').flatMap((record) => {
1452
- const match = /^160000 [0-9a-f]+ [0-3]\t([\s\S]+)$/.exec(record);
1453
- return match?.[1] === undefined ? [] : [match[1]];
1454
- }))
1455
- ].sort((left, right) => left.localeCompare(right));
1456
- if (paths.length === 0)
1457
- return [];
1458
- const unavailable = (mapping, gitExitCode) => paths.map((path) => ({ path, mapping, ...(gitExitCode === undefined ? {} : { gitExitCode }) }));
1459
- try {
1460
- const modulesFile = await lstat(resolve(workspacePath, '.gitmodules'));
1461
- if (!modulesFile.isFile() || modulesFile.isSymbolicLink())
1462
- return unavailable('INVALID');
1463
- }
1464
- catch (error) {
1465
- return unavailable(error.code === 'ENOENT' ? 'MISSING' : 'INVALID');
1466
- }
1467
- const mappings = await runReadOnlyGitAllowFailure(workspacePath, ['config', '--null', '--file', '.gitmodules', '--get-regexp', '^submodule\\..*\\.(path|url)$'], { outputLimit: GIT_LARGE_OUTPUT_LIMIT });
1468
- if (mappings.truncated || (mappings.code !== 0 && mappings.code !== 1))
1469
- return unavailable('INVALID', mappings.code);
1470
- const byName = new Map();
1471
- for (const entry of mappings.text.split('\0')) {
1472
- const separator = entry.indexOf('\n');
1473
- if (separator < 0)
1474
- continue;
1475
- const key = entry.slice(0, separator);
1476
- const value = entry.slice(separator + 1);
1477
- const match = /^submodule\.(.*)\.(path|url)$/.exec(key);
1478
- if (match?.[1] === undefined)
1479
- continue;
1480
- const mapping = byName.get(match[1]) ?? {};
1481
- if (match[2] === 'path')
1482
- mapping.path = value;
1483
- else
1484
- mapping.hasUrl = value.trim().length > 0;
1485
- byName.set(match[1], mapping);
1486
- }
1487
- const mappedPaths = new Set([...byName.values()].flatMap((mapping) => mapping.path !== undefined && mapping.hasUrl ? [mapping.path] : []));
1488
- return paths.map((path) => ({ path, mapping: mappedPaths.has(path) ? 'MAPPED' : 'MISSING' }));
1489
- }
1490
- export async function resolveGitRepository(workspacePath, repositoryPath, initializedSubmodulePaths) {
1491
- const root = await realpath(workspacePath);
1492
- if (repositoryPath.length === 0)
1493
- return root;
1494
- if (repositoryPath.includes('\0') ||
1495
- repositoryPath.startsWith('/') ||
1496
- repositoryPath.startsWith('\\') ||
1497
- repositoryPath
1498
- .split('/')
1499
- .some((segment) => segment.length === 0 || segment === '.' || segment === '..'))
1500
- throw new Error('GIT_REPOSITORY_INVALID');
1501
- try {
1502
- const requestedRepository = resolve(root, repositoryPath);
1503
- const repository = await realpath(requestedRepository);
1504
- if (normalizeWorkspacePath(requestedRepository) !== normalizeWorkspacePath(repository) ||
1505
- !isWithinAllowedRoot(repository, [root]))
1506
- throw new Error('invalid');
1507
- if (initializedSubmodulePaths !== undefined) {
1508
- if (!initializedSubmodulePaths.includes(repositoryPath))
1509
- throw new Error('invalid');
1510
- return repository;
1511
- }
1512
- await assertGitSuperprojectChain(root, repository);
1513
- return repository;
1514
- }
1515
- catch {
1516
- throw new Error('GIT_REPOSITORY_INVALID');
1517
- }
1518
- }
1519
- export async function resolveGitRepositoryTarget(workspacePath, repositoryPath, worktreePath, initializedSubmodulePaths) {
1520
- if (worktreePath === undefined)
1521
- return resolveGitRepository(workspacePath, repositoryPath, initializedSubmodulePaths);
1522
- if (repositoryPath.length !== 0 ||
1523
- worktreePath.length === 0 ||
1524
- worktreePath.length > 4096 ||
1525
- worktreePath.includes('\0'))
1526
- throw new Error('GIT_REPOSITORY_INVALID');
1527
- const requestedKey = normalizeWorkspacePath(worktreePath);
1528
- const worktrees = await listGitWorktrees(workspacePath);
1529
- const matched = worktrees.entries.find((entry) => normalizeWorkspacePath(entry.path) === requestedKey);
1530
- if (matched === undefined || matched.prunable)
1531
- throw new Error('GIT_REPOSITORY_INVALID');
1532
- try {
1533
- return await realpath(matched.path);
1534
- }
1535
- catch {
1536
- throw new Error('GIT_REPOSITORY_INVALID');
1537
- }
1538
- }
1539
- async function assertGitSuperprojectChain(root, repository) {
1540
- const rootKey = normalizeWorkspacePath(root);
1541
- let current = repository;
1542
- const visited = new Set();
1543
- for (let depth = 0; depth < 64; depth += 1) {
1544
- const currentKey = normalizeWorkspacePath(current);
1545
- if (currentKey === rootKey)
1546
- return;
1547
- if (visited.has(currentKey))
1548
- throw new Error('invalid');
1549
- visited.add(currentKey);
1550
- const result = await runReadOnlyGit(current, [
1551
- 'rev-parse',
1552
- '--show-toplevel',
1553
- '--show-superproject-working-tree'
1554
- ]);
1555
- const [topLevel, superproject] = result.text.split(/\r?\n/);
1556
- if (topLevel === undefined || superproject === undefined || superproject.length === 0)
1557
- throw new Error('invalid');
1558
- const resolvedTopLevel = await realpath(topLevel);
1559
- const resolvedSuperproject = await realpath(superproject);
1560
- if (normalizeWorkspacePath(resolvedTopLevel) !== currentKey ||
1561
- normalizeWorkspacePath(resolvedSuperproject) === currentKey ||
1562
- !isWithinAllowedRoot(resolvedSuperproject, [root]) ||
1563
- !isWithinAllowedRoot(current, [resolvedSuperproject]))
1564
- throw new Error('invalid');
1565
- current = resolvedSuperproject;
1566
- }
1567
- throw new Error('invalid');
1568
- }
1569
- export async function listGitHistoryRefs(repository) {
1570
- const result = await runReadOnlyGit(repository, [
1571
- 'for-each-ref',
1572
- '--format=%(refname)%09%(refname:short)%09%(objectname)%09%(HEAD)%09%(symref)',
1573
- 'refs/heads',
1574
- 'refs/remotes'
1575
- ], {
1576
- timeoutMs: GIT_HISTORY_TIMEOUT_MS,
1577
- outputLimit: GIT_LARGE_OUTPUT_LIMIT
1578
- });
1579
- return result.text
1580
- .split(/\r?\n/)
1581
- .flatMap((line) => {
1582
- const [name, displayName, hash, head, symref] = line.split('\t');
1583
- if (name === undefined ||
1584
- displayName === undefined ||
1585
- hash === undefined ||
1586
- symref === undefined ||
1587
- symref.length > 0 ||
1588
- !/^[0-9a-f]{40,64}$/.test(hash))
1589
- return [];
1590
- const kind = name.startsWith('refs/heads/')
1591
- ? 'LOCAL'
1592
- : name.startsWith('refs/remotes/')
1593
- ? 'REMOTE'
1594
- : undefined;
1595
- return kind === undefined ? [] : [{ name, displayName, kind, hash, current: head === '*' }];
1596
- })
1597
- .sort((left, right) => left.kind.localeCompare(right.kind) || left.displayName.localeCompare(right.displayName));
1598
- }
1599
- function selectedHistoryRefs(available, requested) {
1600
- if (requested === undefined || requested.length === 0)
1601
- return available;
1602
- const unique = [...new Set(requested)];
1603
- const selected = unique.map((name) => available.find((entry) => entry.name === name));
1604
- if (selected.some((entry) => entry === undefined))
1605
- throw new Error('GIT_HISTORY_REF_INVALID');
1606
- return selected;
1607
- }
1608
- function historyCursor(offset, fingerprint) {
1609
- return Buffer.from(JSON.stringify({ offset, fingerprint })).toString('base64url');
1610
- }
1611
- function parseHistoryCursor(value, fingerprint) {
1612
- if (value === undefined)
1613
- return 0;
1614
- try {
1615
- const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8'));
1616
- if (!Number.isInteger(parsed.offset) ||
1617
- parsed.offset < 0 ||
1618
- parsed.fingerprint !== fingerprint)
1619
- throw new Error('invalid');
1620
- return parsed.offset;
1621
- }
1622
- catch {
1623
- throw new Error('GIT_HISTORY_CURSOR_INVALID');
1624
- }
1625
- }
1626
- export async function readGitHistory(repository, input) {
1627
- const available = await listGitHistoryRefs(repository);
1628
- const selected = selectedHistoryRefs(available, input.refs);
1629
- if (selected.length === 0)
1630
- return { commits: [], nextCursor: null };
1631
- const limit = input.limit ?? 50;
1632
- if (!Number.isInteger(limit) || limit < 1 || limit > 100)
1633
- throw new Error('GIT_HISTORY_LIMIT_INVALID');
1634
- const names = selected.map((entry) => entry.name).sort();
1635
- const allBranchesSelected = input.refs === undefined ||
1636
- input.refs.length === 0 ||
1637
- (selected.length === available.length &&
1638
- selected.every((entry) => available.some((candidate) => candidate.name === entry.name)));
1639
- const revisionArguments = allBranchesSelected ? ['--branches', '--remotes'] : names;
1640
- // Bind the offset cursor to the exact branch tips. Advancing a branch invalidates
1641
- // the cursor instead of silently skipping or duplicating commits between pages.
1642
- const fingerprint = createHash('sha256')
1643
- .update(selected
1644
- .map((entry) => `${entry.name}\0${entry.hash}`)
1645
- .sort()
1646
- .join('\0'))
1647
- .digest('hex');
1648
- const offset = parseHistoryCursor(input.cursor, fingerprint);
1649
- const result = await runReadOnlyGit(repository, [
1650
- 'log',
1651
- '--topo-order',
1652
- '--date-order',
1653
- `--skip=${offset}`,
1654
- `--max-count=${limit + 1}`,
1655
- '-z',
1656
- '--format=%H%x00%P%x00%an%x00%ae%x00%ct%x00%s',
1657
- ...revisionArguments,
1658
- '--'
1659
- ], { timeoutMs: GIT_HISTORY_TIMEOUT_MS });
1660
- const refsByHash = new Map();
1661
- for (const entry of available) {
1662
- const refs = refsByHash.get(entry.hash) ?? [];
1663
- refs.push(entry.displayName);
1664
- refsByHash.set(entry.hash, refs);
1665
- }
1666
- const fields = result.text.split('\0');
1667
- const commits = [];
1668
- for (let index = 0; index + 5 < fields.length; index += 6) {
1669
- const [hash, parents = '', authorName = '', authorEmail = '', committedAt = '0', subject = ''] = fields.slice(index, index + 6);
1670
- if (hash === undefined || !/^[0-9a-f]{40,64}$/.test(hash))
1671
- continue;
1672
- commits.push({
1673
- hash,
1674
- parents: parents.length === 0 ? [] : parents.split(' '),
1675
- authorName,
1676
- authorEmail,
1677
- committedAt: Number(committedAt) * 1000,
1678
- subject,
1679
- refs: refsByHash.get(hash) ?? []
1680
- });
1681
- }
1682
- const hasMore = commits.length > limit;
1683
- return {
1684
- commits: commits.slice(0, limit),
1685
- nextCursor: hasMore ? historyCursor(offset + limit, fingerprint) : null
1686
- };
1687
- }
1688
- async function assertHistoryCommitReachable(repository, commit, refs) {
1689
- if (!/^[0-9a-f]{40,64}$/.test(commit))
1690
- throw new Error('GIT_HISTORY_COMMIT_INVALID');
1691
- const selected = selectedHistoryRefs(await listGitHistoryRefs(repository), refs);
1692
- for (const ref of selected) {
1693
- const result = await runReadOnlyGitAllowFailure(repository, [
1694
- 'merge-base',
1695
- '--is-ancestor',
1696
- commit,
1697
- ref.name
1698
- ]);
1699
- if (result.code === 0)
1700
- return;
1701
- }
1702
- throw new Error('GIT_HISTORY_COMMIT_INVALID');
1703
- }
1704
- export async function readGitHistoryFiles(repository, commit, refs) {
1705
- await assertHistoryCommitReachable(repository, commit, refs);
1706
- const result = await runReadOnlyGit(repository, [
1707
- 'diff-tree',
1708
- '--root',
1709
- '--first-parent',
1710
- '--no-commit-id',
1711
- '-r',
1712
- '-z',
1713
- '-M',
1714
- '-C',
1715
- '--name-status',
1716
- commit
1717
- ], {
1718
- timeoutMs: GIT_STATUS_TIMEOUT_MS,
1719
- outputLimit: GIT_LARGE_OUTPUT_LIMIT
1720
- });
1721
- const records = result.text.split('\0');
1722
- const files = [];
1723
- for (let index = 0; index < records.length; index += 1) {
1724
- const status = records[index];
1725
- if (status === undefined || status.length === 0)
1726
- continue;
1727
- const code = status[0] ?? 'M';
1728
- if (code === 'R' || code === 'C') {
1729
- const previousPath = records[index + 1];
1730
- const path = records[index + 2];
1731
- if (previousPath !== undefined && path !== undefined)
1732
- files.push({ path, previousPath, change: gitChange(code) });
1733
- index += 2;
1734
- }
1735
- else {
1736
- const path = records[index + 1];
1737
- if (path !== undefined)
1738
- files.push({ path, change: gitChange(code) });
1739
- index += 1;
1740
- }
1741
- }
1742
- return files;
1743
- }
1744
- export async function readGitHistoryFileDiff(repository, commit, requestedPath, refs, requestedPreviousPath) {
1745
- await assertHistoryCommitReachable(repository, commit, refs);
1746
- const path = safeGitWorkspacePath(requestedPath);
1747
- const previousPath = requestedPreviousPath === undefined ? undefined : safeGitWorkspacePath(requestedPreviousPath);
1748
- const result = await runReadOnlyGit(repository, [
1749
- 'show',
1750
- '--format=',
1751
- '--first-parent',
1752
- '--no-ext-diff',
1753
- '--no-textconv',
1754
- commit,
1755
- '--',
1756
- `:(literal)${path}`,
1757
- ...(previousPath === undefined || previousPath === path ? [] : [`:(literal)${previousPath}`])
1758
- ]);
1759
- const binary = result.output.includes(0) || result.text.includes('Binary files ');
1760
- if (binary) {
1761
- return {
1762
- binary: true,
1763
- summary: `binary diff sha256=${createHash('sha256').update(result.output).digest('hex')} bytes=${result.output.length}`,
1764
- truncated: result.truncated
1765
- };
1766
- }
1767
- return { binary: false, text: result.text, truncated: result.truncated };
1768
- }
1769
- export async function readGitHistoryFileContentRange(repository, commit, requestedPath, offset, limit, refs) {
1770
- if (!Number.isSafeInteger(offset) ||
1771
- offset < 0 ||
1772
- !Number.isSafeInteger(limit) ||
1773
- limit < 1 ||
1774
- limit > 20 * 1024 * 1024 ||
1775
- offset + limit > 20 * 1024 * 1024)
1776
- throw new Error('FILE_RANGE_INVALID');
1777
- await assertHistoryCommitReachable(repository, commit, refs);
1778
- const path = safeGitWorkspacePath(requestedPath);
1779
- const object = `${commit}:${path}`;
1780
- const sizeResult = await runReadOnlyGit(repository, ['cat-file', '-s', object]);
1781
- const size = Number(sizeResult.text.trim());
1782
- if (!Number.isSafeInteger(size) || size < 0 || offset > size)
1783
- throw new Error('FILE_RANGE_INVALID');
1784
- if (offset === size)
1785
- return { path, size, offset, contentBase64: '', nextOffset: null };
1786
- const result = await runReadOnlyGitAllowFailure(repository, ['cat-file', 'blob', object], {
1787
- outputLimit: offset + limit,
1788
- timeoutMs: GIT_HISTORY_TIMEOUT_MS
1789
- });
1790
- if (result.code !== 0)
1791
- throw gitCommandFailure(result.code, result.stderr, 'cat-file');
1792
- const content = result.output.subarray(offset, Math.min(size, offset + limit));
1793
- const nextOffset = offset + content.length;
1794
- return {
1795
- path,
1796
- size,
1797
- offset,
1798
- contentBase64: content.toString('base64'),
1799
- nextOffset: nextOffset < size ? nextOffset : null
1800
- };
1801
- }
1802
- export async function openGitHistoryFileContent(repository, commit, requestedPath, refs) {
1803
- await assertHistoryCommitReachable(repository, commit, refs);
1804
- const path = safeGitWorkspacePath(requestedPath);
1805
- const object = `${commit}:${path}`;
1806
- const sizeResult = await runReadOnlyGit(repository, ['cat-file', '-s', object]);
1807
- const size = Number(sizeResult.text.trim());
1808
- if (!Number.isSafeInteger(size) || size < 0)
1809
- throw new Error('FILE_RANGE_INVALID');
1810
- return {
1811
- path,
1812
- size,
1813
- chunks: (async function* () {
1814
- const child = spawn('git', ['cat-file', 'blob', object], {
1815
- cwd: repository,
1816
- shell: false,
1817
- windowsHide: true,
1818
- stdio: ['ignore', 'pipe', 'pipe'],
1819
- env: {
1820
- ...process.env,
1821
- ...gitOperationEnvironment.getStore(),
1822
- GIT_OPTIONAL_LOCKS: '0'
1823
- }
1824
- });
1825
- const errors = [];
1826
- let errorSize = 0;
1827
- let closed = false;
1828
- const exit = new Promise((resolveExit) => {
1829
- child.stderr.on('data', (chunk) => {
1830
- const remaining = Math.max(0, 4096 - errorSize);
1831
- if (remaining === 0)
1832
- return;
1833
- const retained = chunk.subarray(0, remaining);
1834
- errors.push(retained);
1835
- errorSize += retained.length;
1836
- });
1837
- child.once('error', (error) => {
1838
- closed = true;
1839
- resolveExit({ code: 1, stderr: error.message });
1840
- });
1841
- child.once('close', (code) => {
1842
- closed = true;
1843
- resolveExit({
1844
- code: code ?? 1,
1845
- stderr: Buffer.concat(errors).toString('utf8')
1846
- });
1847
- });
1848
- });
1849
- try {
1850
- for await (const chunk of child.stdout)
1851
- yield new Uint8Array(chunk);
1852
- const result = await exit;
1853
- if (result.code !== 0)
1854
- throw gitCommandFailure(result.code, result.stderr, 'cat-file');
1855
- }
1856
- finally {
1857
- if (!closed)
1858
- child.kill('SIGKILL');
1859
- }
1860
- })()
1861
- };
1862
- }
1863
- export async function readCurrentChanges(workspacePath) {
1864
- return (await readCurrentChangesSummary(workspacePath)).changes;
1865
- }
1866
- export async function readCurrentChangeDiff(workspacePath, requestedPath) {
1867
- const path = safeGitWorkspacePath(requestedPath);
1868
- await assertCurrentChangePathAccessible(workspacePath, path);
1869
- const result = await readCurrentChangeGitDiff(workspacePath, path);
1870
- const after = await readWorkingTreeText(workspacePath, path);
1871
- const binary = result.output.includes(0) || result.text.includes('Binary files ') || after?.binary === true;
1872
- if (binary) {
1873
- return {
1874
- binary: true,
1875
- summary: `binary diff sha256=${createHash('sha256').update(result.output).digest('hex')} bytes=${result.output.length}`,
1876
- truncated: result.truncated || after?.truncated === true
1877
- };
1878
- }
1879
- return {
1880
- binary: false,
1881
- text: result.text,
1882
- truncated: result.truncated || after?.truncated === true,
1883
- afterText: after?.text ?? '',
1884
- ...(after === undefined ? {} : { afterTruncated: after.truncated })
1885
- };
1886
- }
1887
- async function readCurrentChangeGitDiff(workspacePath, path) {
1888
- const untracked = await runReadOnlyGit(workspacePath, [
1889
- 'ls-files',
1890
- '--others',
1891
- '--exclude-standard',
1892
- '-z',
1893
- '--',
1894
- `:(literal)${path}`
1895
- ]);
1896
- if (untracked.text.split('\0').includes(path)) {
1897
- const result = await runReadOnlyGitAllowFailure(workspacePath, [
1898
- 'diff',
1899
- '--no-index',
1900
- '--no-ext-diff',
1901
- '--no-textconv',
1902
- '--',
1903
- '/dev/null',
1904
- path
1905
- ]);
1906
- if (result.code !== 0 && result.code !== 1)
1907
- throw gitCommandFailure(result.code, result.stderr, 'diff');
1908
- return result;
1909
- }
1910
- return runReadOnlyGit(workspacePath, [
1911
- 'diff',
1912
- '--no-ext-diff',
1913
- '--no-textconv',
1914
- 'HEAD',
1915
- '--',
1916
- `:(literal)${path}`
1917
- ]);
1918
- }
1919
- /** Current Changes must not expose an external link. */
1920
- export async function isWorkspaceChangeVisible(workspacePath, requestedPath) {
1921
- try {
1922
- await assertCurrentChangePathAccessible(workspacePath, safeGitWorkspacePath(requestedPath));
1923
- return true;
1924
- }
1925
- catch (error) {
1926
- if (error instanceof Error &&
1927
- (error.message === 'FILE_PATH_ESCAPE' ||
1928
- error.message === 'FILE_NOT_FOUND' ||
1929
- error.message === 'FILE_NOT_REGULAR'))
1930
- return false;
1931
- throw error;
1932
- }
1933
- }
1934
- async function assertCurrentChangePathAccessible(workspacePath, path) {
1935
- const root = await realpath(workspacePath);
1936
- const candidate = resolve(root, path);
1937
- const metadata = await lstat(candidate).catch((error) => {
1938
- if (error.code === 'ENOENT')
1939
- return undefined;
1940
- throw error;
1941
- });
1942
- if (metadata === undefined)
1943
- return;
1944
- await resolveWorkspaceEntry(root, path, false);
1945
- }
1946
- async function readWorkingTreeText(workspacePath, path) {
1947
- const root = await realpath(workspacePath);
1948
- let file;
1949
- try {
1950
- file = await resolveWorkspaceEntry(root, path, false);
1951
- }
1952
- catch (error) {
1953
- if (error instanceof Error && error.message === 'FILE_NOT_FOUND')
1954
- return undefined;
1955
- throw error;
1956
- }
1957
- const metadata = await lstat(file);
1958
- if (!metadata.isFile())
1959
- return undefined;
1960
- const source = await readFile(file);
1961
- return diffTextFromBuffer(source, source.length > GIT_OUTPUT_LIMIT);
1962
- }
1963
- function diffTextFromBuffer(source, truncated) {
1964
- if (source.includes(0) || !isValidUtf8(source))
1965
- return { binary: true, truncated };
1966
- let end = Math.min(source.length, GIT_OUTPUT_LIMIT);
1967
- while (end > 0 && end < source.length && isUtf8ContinuationByte(source[end]))
1968
- end -= 1;
1969
- return { binary: false, text: source.subarray(0, end).toString('utf8'), truncated };
1970
- }
1971
- export async function restoreCurrentChange(workspacePath, requestedPath, state) {
1972
- const path = safeGitWorkspacePath(requestedPath);
1973
- const pathspec = `:(literal)${path}`;
1974
- if (state === 'UNTRACKED') {
1975
- const status = await runReadOnlyGit(workspacePath, [
1976
- 'status',
1977
- '--porcelain=v1',
1978
- '-z',
1979
- '--untracked-files=all',
1980
- '--',
1981
- pathspec
1982
- ]);
1983
- if (!status.text
1984
- .split('\0')
1985
- .some((record) => record.startsWith('?? ') && record.slice(3) === path))
1986
- throw new Error('GIT_CHANGE_RESTORE_UNAVAILABLE');
1987
- const root = await realpath(workspacePath);
1988
- const file = resolve(root, path);
1989
- const metadata = await lstat(file);
1990
- if (!metadata.isFile() || metadata.isSymbolicLink())
1991
- throw new Error('GIT_CHANGE_RESTORE_UNAVAILABLE');
1992
- await unlink(file);
1993
- return;
1994
- }
1995
- if (state === 'UNSTAGED') {
1996
- await runGit(workspacePath, ['restore', '--worktree', '--', pathspec]);
1997
- return;
1998
- }
1999
- await runGit(workspacePath, [
2000
- 'restore',
2001
- '--source=HEAD',
2002
- '--staged',
2003
- '--worktree',
2004
- '--',
2005
- pathspec
2006
- ]);
2007
- }
2008
- function safeGitWorkspacePath(requestedPath) {
2009
- if (requestedPath.length === 0 ||
2010
- requestedPath.includes('\0') ||
2011
- requestedPath.startsWith('/') ||
2012
- requestedPath.startsWith('\\'))
2013
- throw new Error('FILE_PATH_ESCAPE');
2014
- const normalized = requestedPath.replaceAll('\\', '/');
2015
- if (normalized
2016
- .split('/')
2017
- .some((segment) => segment === '' || segment === '..' || segment === '.git'))
2018
- throw new Error('FILE_PATH_ESCAPE');
2019
- return normalized;
2020
- }
2021
- function gitChange(status) {
2022
- if (status === 'A')
2023
- return 'ADDED';
2024
- if (status === 'D')
2025
- return 'DELETED';
2026
- if (status === 'R')
2027
- return 'RENAMED';
2028
- if (status === 'C')
2029
- return 'COPIED';
2030
- if (status === 'T')
2031
- return 'TYPE_CHANGED';
2032
- return 'MODIFIED';
2033
- }
2034
- export class GitCommandError extends Error {
2035
- code;
2036
- stderr;
2037
- publicDetails;
2038
- publicParameters;
2039
- constructor(code, stderr) {
2040
- super('GIT_COMMAND_FAILED');
2041
- this.code = code;
2042
- this.stderr = stderr;
2043
- this.publicParameters = { gitExitCode: code };
2044
- this.publicDetails = stderr.trim().slice(0, 1_000) || `Git exited with status ${code}.`;
2045
- }
2046
- }
2047
- function gitCommandFailure(code, stderr, subcommand, durationMs, reason) {
2048
- logNodeCommandFailure(`git ${subcommand}`, {
2049
- exitCode: code,
2050
- stderr,
2051
- ...(durationMs === undefined ? {} : { durationMs }),
2052
- ...(reason === undefined ? {} : { reason })
2053
- });
2054
- return new GitCommandError(code, stderr);
2055
- }
2056
- async function runReadOnlyGit(workspacePath, args, options = {}) {
2057
- return runGit(workspacePath, args, { ...options, readOnly: true });
2058
- }
2059
- async function runReadOnlyGitAllowFailure(workspacePath, args, options = {}) {
2060
- return runGitAllowFailure(workspacePath, args, { ...options, readOnly: true });
2061
- }
2062
- async function runGit(workspacePath, args, options = {}) {
2063
- const startedAt = performance.now();
2064
- const result = await runGitAllowFailure(workspacePath, args, options);
2065
- if (result.code !== 0) {
2066
- throw gitCommandFailure(result.code, result.stderr, args[0] ?? 'unknown', Math.round(performance.now() - startedAt));
2067
- }
2068
- return result;
2069
- }
2070
- async function runGitAllowFailure(workspacePath, args, options = {}) {
2071
- return new Promise((resolve, reject) => {
2072
- const startedAt = performance.now();
2073
- const outputLimit = options.outputLimit ?? GIT_OUTPUT_LIMIT;
2074
- const child = spawn('git', [...args], {
2075
- cwd: workspacePath,
2076
- shell: false,
2077
- windowsHide: true,
2078
- stdio: ['ignore', 'pipe', 'pipe'],
2079
- env: {
2080
- ...process.env,
2081
- ...gitOperationEnvironment.getStore(),
2082
- ...(options.readOnly === true ? { GIT_OPTIONAL_LOCKS: '0' } : {})
2083
- }
2084
- });
2085
- const chunks = [];
2086
- const errors = [];
2087
- let size = 0;
2088
- let errorSize = 0;
2089
- let truncated = false;
2090
- let timedOut = false;
2091
- const timer = setTimeout(() => {
2092
- timedOut = true;
2093
- if (process.platform === 'win32') {
2094
- const pid = child.pid;
2095
- if (pid === undefined) {
2096
- child.kill('SIGKILL');
2097
- return;
2098
- }
2099
- void terminateProcessTree({
2100
- pid,
2101
- kill: (signal) => child.kill(signal)
2102
- }, 'win32').catch(() => {
2103
- try {
2104
- child.kill('SIGKILL');
2105
- }
2106
- catch {
2107
- // The process may have exited while taskkill was starting.
2108
- }
2109
- });
2110
- return;
2111
- }
2112
- child.kill('SIGKILL');
2113
- }, options.timeoutMs ?? GIT_TIMEOUT_MS);
2114
- child.stdout.on('data', (chunk) => {
2115
- const remaining = Math.max(0, outputLimit - size);
2116
- if (remaining > 0) {
2117
- const retained = chunk.subarray(0, remaining);
2118
- chunks.push(retained);
2119
- size += retained.length;
2120
- }
2121
- truncated ||= chunk.length > remaining;
2122
- });
2123
- child.stderr.on('data', (chunk) => {
2124
- const remaining = Math.max(0, 4096 - errorSize);
2125
- if (remaining === 0)
2126
- return;
2127
- const retained = chunk.subarray(0, remaining);
2128
- errors.push(retained);
2129
- errorSize += retained.length;
2130
- });
2131
- child.once('error', (error) => {
2132
- clearTimeout(timer);
2133
- logNodeCommandFailure(`git ${args[0] ?? 'unknown'}`, {
2134
- reason: error.code ?? 'START_FAILED'
2135
- });
2136
- reject(error);
2137
- });
2138
- child.once('close', (code) => {
2139
- clearTimeout(timer);
2140
- const stderr = Buffer.concat(errors).toString('utf8');
2141
- if (timedOut) {
2142
- reject(gitCommandFailure(code === null || code === 0 ? 1 : code, `Git command timed out after ${options.timeoutMs ?? GIT_TIMEOUT_MS} ms.${stderr ? `\n${stderr}` : ''}`, args[0] ?? 'unknown', Math.round(performance.now() - startedAt), 'TIMEOUT'));
2143
- return;
2144
- }
2145
- const output = Buffer.concat(chunks);
2146
- resolve({
2147
- code: code ?? 1,
2148
- output,
2149
- text: output.toString('utf8'),
2150
- stderr,
2151
- truncated
2152
- });
2153
- });
2154
- });
2155
- }
2156
- function canRetryGitWorktreeListWithoutNullOutput(result) {
2157
- return (result.code === 129 ||
2158
- /(?:unknown|unrecognized|invalid)\s+(?:option|switch).*['"`]?-?z\b/iu.test(result.stderr));
2159
- }
2160
- function isGitUnavailable(error) {
2161
- return (error instanceof Error && 'code' in error && error.code === 'ENOENT');
2162
- }
2163
- function summarizeText(value) {
2164
- return JSON.stringify({
2165
- sha256: createHash('sha256').update(value).digest('hex'),
2166
- bytes: Buffer.byteLength(value)
2167
- });
2168
- }