@the-open-engine/zeroshot 6.39.0 → 6.39.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +39 -1
  2. package/cli/index.js +90 -24
  3. package/cluster-templates/conductor-bootstrap.json +1 -1
  4. package/lib/agent-cli-provider/adapters/claude.js +1 -1
  5. package/lib/agent-cli-provider/adapters/claude.js.map +1 -1
  6. package/lib/agent-cli-provider/adapters/codex.d.ts.map +1 -1
  7. package/lib/agent-cli-provider/adapters/codex.js +26 -1
  8. package/lib/agent-cli-provider/adapters/codex.js.map +1 -1
  9. package/lib/agent-cli-provider/adapters/omp.d.ts.map +1 -1
  10. package/lib/agent-cli-provider/adapters/omp.js +1 -1
  11. package/lib/agent-cli-provider/adapters/omp.js.map +1 -1
  12. package/lib/agent-cli-provider/omp/sdk-environment-policy.d.ts +4 -0
  13. package/lib/agent-cli-provider/omp/sdk-environment-policy.d.ts.map +1 -0
  14. package/lib/agent-cli-provider/omp/sdk-environment-policy.js +37 -0
  15. package/lib/agent-cli-provider/omp/sdk-environment-policy.js.map +1 -0
  16. package/lib/agent-cli-provider/omp/sdk-process-private-runtime.d.ts.map +1 -1
  17. package/lib/agent-cli-provider/omp/sdk-process-private-runtime.js +4 -15
  18. package/lib/agent-cli-provider/omp/sdk-process-private-runtime.js.map +1 -1
  19. package/lib/agent-cli-provider/omp/sdk-process-result.d.ts +2 -0
  20. package/lib/agent-cli-provider/omp/sdk-process-result.d.ts.map +1 -1
  21. package/lib/agent-cli-provider/omp/sdk-process-result.js +2 -2
  22. package/lib/agent-cli-provider/omp/sdk-process-result.js.map +1 -1
  23. package/lib/agent-cli-provider/omp/sdk-process-runner.d.ts.map +1 -1
  24. package/lib/agent-cli-provider/omp/sdk-process-runner.js +6 -1
  25. package/lib/agent-cli-provider/omp/sdk-process-runner.js.map +1 -1
  26. package/lib/agent-cli-provider/provider-registry.d.ts +3 -1
  27. package/lib/agent-cli-provider/provider-registry.d.ts.map +1 -1
  28. package/lib/agent-cli-provider/provider-registry.js +9 -2
  29. package/lib/agent-cli-provider/provider-registry.js.map +1 -1
  30. package/lib/agent-cli-provider/single-agent-runtime.d.ts +4 -1
  31. package/lib/agent-cli-provider/single-agent-runtime.d.ts.map +1 -1
  32. package/lib/agent-cli-provider/single-agent-runtime.js +18 -15
  33. package/lib/agent-cli-provider/single-agent-runtime.js.map +1 -1
  34. package/lib/agent-cli-provider/types.d.ts +3 -0
  35. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  36. package/lib/agent-cli-provider/types.js.map +1 -1
  37. package/lib/cluster-worker/engine-adapter-common.js +18 -0
  38. package/lib/cluster-worker/engine-adapter.js +2 -1
  39. package/lib/detached-startup.d.ts +1 -0
  40. package/lib/detached-startup.js +2 -1
  41. package/lib/git-remote-utils.js +22 -5
  42. package/lib/start-cluster-environment.d.ts +1 -0
  43. package/lib/start-cluster-run-options.d.ts +1 -0
  44. package/lib/start-cluster-run-options.js +1 -0
  45. package/npm-shrinkwrap.json +6 -8
  46. package/package.json +4 -3
  47. package/scripts/omp/runtime-identities.js +31 -18
  48. package/scripts/omp/runtime.js +2 -0
  49. package/scripts/rust-distribution.js +13 -31
  50. package/src/agent/agent-lifecycle.js +17 -1
  51. package/src/agent/output-extraction-failures.js +73 -0
  52. package/src/agent/output-extraction-failures.ts +100 -0
  53. package/src/agent/output-extraction-types.ts +5 -0
  54. package/src/agent/provider-terminal-failure.js +2 -2
  55. package/src/agent/provider-terminal-failure.ts +4 -2
  56. package/src/agent-cli-provider/adapters/claude.ts +1 -1
  57. package/src/agent-cli-provider/adapters/codex.ts +36 -1
  58. package/src/agent-cli-provider/adapters/omp.ts +2 -1
  59. package/src/agent-cli-provider/omp/sdk-environment-policy.ts +38 -0
  60. package/src/agent-cli-provider/omp/sdk-process-private-runtime.ts +4 -16
  61. package/src/agent-cli-provider/omp/sdk-process-result.ts +4 -2
  62. package/src/agent-cli-provider/omp/sdk-process-runner.ts +7 -1
  63. package/src/agent-cli-provider/provider-registry.ts +10 -2
  64. package/src/agent-cli-provider/single-agent-runtime.ts +24 -15
  65. package/src/agent-cli-provider/types.ts +3 -0
  66. package/src/agents/git-pusher-template.js +78 -105
  67. package/src/attach/socket-paths.js +1 -1
  68. package/src/attach/socket-paths.ts +1 -1
  69. package/src/copy-containment.js +191 -0
  70. package/src/copy-containment.ts +259 -0
  71. package/src/copy-worker.js +29 -30
  72. package/src/copy-worker.ts +43 -30
  73. package/src/foreground-benchmark-run.js +24 -12
  74. package/src/isolation-manager.js +59 -20
  75. package/src/legacy-lib/detached-startup.ts +3 -1
  76. package/src/legacy-lib/git-remote-utils.ts +23 -5
  77. package/src/legacy-lib/start-cluster-environment.ts +1 -0
  78. package/src/legacy-lib/start-cluster-run-options.ts +2 -0
  79. package/src/omp-session-verifier.js +8 -5
  80. package/src/orchestrator.js +55 -3
  81. package/src/pr-body-template.js +71 -0
  82. package/src/preflight.js +31 -6
  83. package/src/providers/index.js +3 -3
  84. package/src/task-execution-context.js +22 -0
  85. package/src/watcher-prompt-channel.js +1 -1
  86. package/src/worktree-claude-config.js +25 -0
  87. package/src/worktree-claude-config.ts +28 -0
  88. package/task-lib/runner.js +57 -22
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CopyContainmentError = exports.CONTAINMENT_ERROR_CODE = void 0;
4
+ exports.isCopyRecord = isCopyRecord;
5
+ exports.copyErrorCode = copyErrorCode;
6
+ exports.validateRelativePath = validateRelativePath;
7
+ exports.resolveSourcePath = resolveSourcePath;
8
+ exports.createCopyBoundary = createCopyBoundary;
9
+ exports.resolveCopyPath = resolveCopyPath;
10
+ exports.isCopyContainmentError = isCopyContainmentError;
11
+ exports.copyErrorFromPayload = copyErrorFromPayload;
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+ exports.CONTAINMENT_ERROR_CODE = 'ERR_COPY_CONTAINMENT';
15
+ class CopyContainmentError extends Error {
16
+ code = exports.CONTAINMENT_ERROR_CODE;
17
+ relativePath;
18
+ constructor(relativePath, reason) {
19
+ super(`Copy containment violation for ${JSON.stringify(relativePath)}: ${reason}`);
20
+ this.name = 'CopyContainmentError';
21
+ this.relativePath = relativePath;
22
+ }
23
+ }
24
+ exports.CopyContainmentError = CopyContainmentError;
25
+ function isContained(rootPath, targetPath) {
26
+ const relative = path.relative(rootPath, targetPath);
27
+ return (relative === '' ||
28
+ (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`)));
29
+ }
30
+ function containmentError(relativePath, reason, cause) {
31
+ const error = new CopyContainmentError(relativePath, reason);
32
+ if (cause !== undefined) {
33
+ error.cause = cause;
34
+ }
35
+ return error;
36
+ }
37
+ function isCopyRecord(value) {
38
+ return typeof value === 'object' && value !== null;
39
+ }
40
+ function copyErrorCode(error) {
41
+ if (isCopyRecord(error) && 'code' in error) {
42
+ return typeof error.code === 'string' ? error.code : null;
43
+ }
44
+ return null;
45
+ }
46
+ function statIdentity(targetPath) {
47
+ const stats = fs.statSync(targetPath, { bigint: true });
48
+ return {
49
+ device: stats.dev.toString(),
50
+ inode: stats.ino.toString(),
51
+ directory: stats.isDirectory(),
52
+ };
53
+ }
54
+ function pinRoot(rootPath, label, expectedRoot) {
55
+ const requestedPath = path.resolve(rootPath);
56
+ const canonicalPath = fs.realpathSync.native(requestedPath);
57
+ const identity = statIdentity(canonicalPath);
58
+ if (!identity.directory) {
59
+ throw containmentError('', `${label} root is not a directory`);
60
+ }
61
+ const pinnedRoot = {
62
+ requestedPath,
63
+ canonicalPath,
64
+ device: identity.device,
65
+ inode: identity.inode,
66
+ };
67
+ if (expectedRoot &&
68
+ (expectedRoot.canonicalPath !== pinnedRoot.canonicalPath ||
69
+ expectedRoot.device !== pinnedRoot.device ||
70
+ expectedRoot.inode !== pinnedRoot.inode)) {
71
+ throw containmentError('', `${label} root changed after it was pinned`);
72
+ }
73
+ return pinnedRoot;
74
+ }
75
+ function assertPinnedRoot(root, label, relativePath) {
76
+ let identity;
77
+ try {
78
+ identity = statIdentity(root.canonicalPath);
79
+ }
80
+ catch (error) {
81
+ throw containmentError(relativePath, `${label} root can no longer be resolved`, error);
82
+ }
83
+ if (identity.device !== root.device || identity.inode !== root.inode || !identity.directory) {
84
+ throw containmentError(relativePath, `${label} root changed after it was pinned`);
85
+ }
86
+ }
87
+ function validateRelativePath(relativePath, pathApi = path) {
88
+ if (typeof relativePath !== 'string' || relativePath.length === 0) {
89
+ throw containmentError(relativePath, 'path must be a non-empty relative string');
90
+ }
91
+ if (relativePath.includes('\0')) {
92
+ throw containmentError(relativePath, 'path contains a null byte');
93
+ }
94
+ if (pathApi.isAbsolute(relativePath) || pathApi.parse(relativePath).root) {
95
+ throw containmentError(relativePath, 'absolute paths are not allowed');
96
+ }
97
+ const components = pathApi.sep === '\\' ? relativePath.split(/[\\/]/) : relativePath.split(pathApi.sep);
98
+ if (components.some((component) => component === '' || component === '.' || component === '..')) {
99
+ throw containmentError(relativePath, 'empty, current-directory, and traversal components are not allowed');
100
+ }
101
+ return pathApi.normalize(relativePath);
102
+ }
103
+ function resolveSourcePath(boundary, relativePath) {
104
+ const normalizedPath = validateRelativePath(relativePath);
105
+ const root = boundary.sourceRoot;
106
+ assertPinnedRoot(root, 'source', relativePath);
107
+ const candidatePath = path.resolve(root.canonicalPath, normalizedPath);
108
+ if (!isContained(root.canonicalPath, candidatePath)) {
109
+ throw containmentError(relativePath, 'source path escapes its pinned root');
110
+ }
111
+ let canonicalPath;
112
+ try {
113
+ canonicalPath = fs.realpathSync.native(candidatePath);
114
+ }
115
+ catch (error) {
116
+ if (copyErrorCode(error) === 'ELOOP') {
117
+ throw containmentError(relativePath, 'source path contains a symlink cycle', error);
118
+ }
119
+ throw error;
120
+ }
121
+ if (!isContained(root.canonicalPath, canonicalPath)) {
122
+ throw containmentError(relativePath, 'resolved source path escapes its pinned root');
123
+ }
124
+ return canonicalPath;
125
+ }
126
+ function resolveDestinationPath(boundary, relativePath) {
127
+ const normalizedPath = validateRelativePath(relativePath);
128
+ const root = boundary.destinationRoot;
129
+ assertPinnedRoot(root, 'destination', relativePath);
130
+ const candidatePath = path.resolve(root.canonicalPath, normalizedPath);
131
+ if (!isContained(root.canonicalPath, candidatePath)) {
132
+ throw containmentError(relativePath, 'destination path escapes its pinned root');
133
+ }
134
+ let existingPath;
135
+ try {
136
+ fs.lstatSync(candidatePath);
137
+ existingPath = candidatePath;
138
+ }
139
+ catch (error) {
140
+ if (copyErrorCode(error) !== 'ENOENT') {
141
+ throw error;
142
+ }
143
+ // The copy pipeline creates directories parent-first in phase two, so the
144
+ // immediate parent must exist before any mkdir/copy effect is attempted.
145
+ existingPath = path.dirname(candidatePath);
146
+ }
147
+ let canonicalExistingPath;
148
+ try {
149
+ canonicalExistingPath = fs.realpathSync.native(existingPath);
150
+ }
151
+ catch (error) {
152
+ throw containmentError(relativePath, 'destination contains an unresolved symlink', error);
153
+ }
154
+ if (!isContained(root.canonicalPath, canonicalExistingPath)) {
155
+ throw containmentError(relativePath, 'resolved destination path escapes its pinned root');
156
+ }
157
+ const unresolvedSuffix = path.relative(existingPath, candidatePath);
158
+ const resolvedPath = unresolvedSuffix
159
+ ? path.join(canonicalExistingPath, unresolvedSuffix)
160
+ : canonicalExistingPath;
161
+ if (!isContained(root.canonicalPath, resolvedPath)) {
162
+ throw containmentError(relativePath, 'resolved destination path escapes its pinned root');
163
+ }
164
+ return resolvedPath;
165
+ }
166
+ function createCopyBoundary(sourceBase, destinationBase, expectedBoundary) {
167
+ return {
168
+ sourceRoot: pinRoot(sourceBase, 'source', expectedBoundary?.sourceRoot),
169
+ destinationRoot: pinRoot(destinationBase, 'destination', expectedBoundary?.destinationRoot),
170
+ };
171
+ }
172
+ function resolveCopyPath(boundary, relativePath) {
173
+ return {
174
+ sourcePath: resolveSourcePath(boundary, relativePath),
175
+ destinationPath: resolveDestinationPath(boundary, relativePath),
176
+ };
177
+ }
178
+ function isCopyContainmentError(error) {
179
+ return isCopyRecord(error) && error.code === exports.CONTAINMENT_ERROR_CODE;
180
+ }
181
+ function copyErrorFromPayload(payload) {
182
+ const error = payload.code === exports.CONTAINMENT_ERROR_CODE
183
+ ? new CopyContainmentError(payload.relativePath, 'worker rejected an unsafe path')
184
+ : new Error(payload.message);
185
+ error.name = payload.name || error.name;
186
+ error.message = payload.message;
187
+ if (payload.code && !(error instanceof CopyContainmentError)) {
188
+ Object.assign(error, { code: payload.code });
189
+ }
190
+ return error;
191
+ }
@@ -0,0 +1,259 @@
1
+ import fs = require('fs');
2
+ import path = require('path');
3
+
4
+ export const CONTAINMENT_ERROR_CODE = 'ERR_COPY_CONTAINMENT';
5
+
6
+ export interface PinnedCopyRoot {
7
+ requestedPath: string;
8
+ canonicalPath: string;
9
+ device: string;
10
+ inode: string;
11
+ }
12
+
13
+ export interface CopyBoundary {
14
+ sourceRoot: PinnedCopyRoot;
15
+ destinationRoot: PinnedCopyRoot;
16
+ }
17
+
18
+ interface PathIdentity {
19
+ device: string;
20
+ inode: string;
21
+ directory: boolean;
22
+ }
23
+
24
+ interface CopyPathApi {
25
+ isAbsolute(targetPath: string): boolean;
26
+ normalize(targetPath: string): string;
27
+ parse(targetPath: string): { root: string };
28
+ readonly sep: string;
29
+ }
30
+
31
+ export class CopyContainmentError extends Error {
32
+ readonly code = CONTAINMENT_ERROR_CODE;
33
+ readonly relativePath: unknown;
34
+
35
+ constructor(relativePath: unknown, reason: string) {
36
+ super(`Copy containment violation for ${JSON.stringify(relativePath)}: ${reason}`);
37
+ this.name = 'CopyContainmentError';
38
+ this.relativePath = relativePath;
39
+ }
40
+ }
41
+
42
+ function isContained(rootPath: string, targetPath: string): boolean {
43
+ const relative = path.relative(rootPath, targetPath);
44
+ return (
45
+ relative === '' ||
46
+ (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`))
47
+ );
48
+ }
49
+
50
+ function containmentError(
51
+ relativePath: unknown,
52
+ reason: string,
53
+ cause?: unknown
54
+ ): CopyContainmentError {
55
+ const error = new CopyContainmentError(relativePath, reason);
56
+ if (cause !== undefined) {
57
+ error.cause = cause;
58
+ }
59
+ return error;
60
+ }
61
+
62
+ export function isCopyRecord(value: unknown): value is Record<PropertyKey, unknown> {
63
+ return typeof value === 'object' && value !== null;
64
+ }
65
+
66
+ export function copyErrorCode(error: unknown): string | null {
67
+ if (isCopyRecord(error) && 'code' in error) {
68
+ return typeof error.code === 'string' ? error.code : null;
69
+ }
70
+ return null;
71
+ }
72
+
73
+ function statIdentity(targetPath: string): PathIdentity {
74
+ const stats = fs.statSync(targetPath, { bigint: true });
75
+ return {
76
+ device: stats.dev.toString(),
77
+ inode: stats.ino.toString(),
78
+ directory: stats.isDirectory(),
79
+ };
80
+ }
81
+
82
+ function pinRoot(rootPath: string, label: string, expectedRoot?: PinnedCopyRoot): PinnedCopyRoot {
83
+ const requestedPath = path.resolve(rootPath);
84
+ const canonicalPath = fs.realpathSync.native(requestedPath);
85
+ const identity = statIdentity(canonicalPath);
86
+
87
+ if (!identity.directory) {
88
+ throw containmentError('', `${label} root is not a directory`);
89
+ }
90
+
91
+ const pinnedRoot = {
92
+ requestedPath,
93
+ canonicalPath,
94
+ device: identity.device,
95
+ inode: identity.inode,
96
+ };
97
+
98
+ if (
99
+ expectedRoot &&
100
+ (expectedRoot.canonicalPath !== pinnedRoot.canonicalPath ||
101
+ expectedRoot.device !== pinnedRoot.device ||
102
+ expectedRoot.inode !== pinnedRoot.inode)
103
+ ) {
104
+ throw containmentError('', `${label} root changed after it was pinned`);
105
+ }
106
+
107
+ return pinnedRoot;
108
+ }
109
+
110
+ function assertPinnedRoot(root: PinnedCopyRoot, label: string, relativePath: string): void {
111
+ let identity: PathIdentity;
112
+ try {
113
+ identity = statIdentity(root.canonicalPath);
114
+ } catch (error: unknown) {
115
+ throw containmentError(relativePath, `${label} root can no longer be resolved`, error);
116
+ }
117
+
118
+ if (identity.device !== root.device || identity.inode !== root.inode || !identity.directory) {
119
+ throw containmentError(relativePath, `${label} root changed after it was pinned`);
120
+ }
121
+ }
122
+
123
+ export function validateRelativePath(relativePath: unknown, pathApi: CopyPathApi = path): string {
124
+ if (typeof relativePath !== 'string' || relativePath.length === 0) {
125
+ throw containmentError(relativePath, 'path must be a non-empty relative string');
126
+ }
127
+ if (relativePath.includes('\0')) {
128
+ throw containmentError(relativePath, 'path contains a null byte');
129
+ }
130
+ if (pathApi.isAbsolute(relativePath) || pathApi.parse(relativePath).root) {
131
+ throw containmentError(relativePath, 'absolute paths are not allowed');
132
+ }
133
+
134
+ const components =
135
+ pathApi.sep === '\\' ? relativePath.split(/[\\/]/) : relativePath.split(pathApi.sep);
136
+ if (components.some((component) => component === '' || component === '.' || component === '..')) {
137
+ throw containmentError(
138
+ relativePath,
139
+ 'empty, current-directory, and traversal components are not allowed'
140
+ );
141
+ }
142
+
143
+ return pathApi.normalize(relativePath);
144
+ }
145
+
146
+ export function resolveSourcePath(boundary: CopyBoundary, relativePath: string): string {
147
+ const normalizedPath = validateRelativePath(relativePath);
148
+ const root = boundary.sourceRoot;
149
+ assertPinnedRoot(root, 'source', relativePath);
150
+
151
+ const candidatePath = path.resolve(root.canonicalPath, normalizedPath);
152
+ if (!isContained(root.canonicalPath, candidatePath)) {
153
+ throw containmentError(relativePath, 'source path escapes its pinned root');
154
+ }
155
+
156
+ let canonicalPath: string;
157
+ try {
158
+ canonicalPath = fs.realpathSync.native(candidatePath);
159
+ } catch (error: unknown) {
160
+ if (copyErrorCode(error) === 'ELOOP') {
161
+ throw containmentError(relativePath, 'source path contains a symlink cycle', error);
162
+ }
163
+ throw error;
164
+ }
165
+
166
+ if (!isContained(root.canonicalPath, canonicalPath)) {
167
+ throw containmentError(relativePath, 'resolved source path escapes its pinned root');
168
+ }
169
+ return canonicalPath;
170
+ }
171
+
172
+ function resolveDestinationPath(boundary: CopyBoundary, relativePath: string): string {
173
+ const normalizedPath = validateRelativePath(relativePath);
174
+ const root = boundary.destinationRoot;
175
+ assertPinnedRoot(root, 'destination', relativePath);
176
+
177
+ const candidatePath = path.resolve(root.canonicalPath, normalizedPath);
178
+ if (!isContained(root.canonicalPath, candidatePath)) {
179
+ throw containmentError(relativePath, 'destination path escapes its pinned root');
180
+ }
181
+
182
+ let existingPath: string;
183
+ try {
184
+ fs.lstatSync(candidatePath);
185
+ existingPath = candidatePath;
186
+ } catch (error: unknown) {
187
+ if (copyErrorCode(error) !== 'ENOENT') {
188
+ throw error;
189
+ }
190
+ // The copy pipeline creates directories parent-first in phase two, so the
191
+ // immediate parent must exist before any mkdir/copy effect is attempted.
192
+ existingPath = path.dirname(candidatePath);
193
+ }
194
+
195
+ let canonicalExistingPath: string;
196
+ try {
197
+ canonicalExistingPath = fs.realpathSync.native(existingPath);
198
+ } catch (error: unknown) {
199
+ throw containmentError(relativePath, 'destination contains an unresolved symlink', error);
200
+ }
201
+
202
+ if (!isContained(root.canonicalPath, canonicalExistingPath)) {
203
+ throw containmentError(relativePath, 'resolved destination path escapes its pinned root');
204
+ }
205
+
206
+ const unresolvedSuffix = path.relative(existingPath, candidatePath);
207
+ const resolvedPath = unresolvedSuffix
208
+ ? path.join(canonicalExistingPath, unresolvedSuffix)
209
+ : canonicalExistingPath;
210
+ if (!isContained(root.canonicalPath, resolvedPath)) {
211
+ throw containmentError(relativePath, 'resolved destination path escapes its pinned root');
212
+ }
213
+ return resolvedPath;
214
+ }
215
+
216
+ export function createCopyBoundary(
217
+ sourceBase: string,
218
+ destinationBase: string,
219
+ expectedBoundary?: CopyBoundary
220
+ ): CopyBoundary {
221
+ return {
222
+ sourceRoot: pinRoot(sourceBase, 'source', expectedBoundary?.sourceRoot),
223
+ destinationRoot: pinRoot(destinationBase, 'destination', expectedBoundary?.destinationRoot),
224
+ };
225
+ }
226
+
227
+ export function resolveCopyPath(
228
+ boundary: CopyBoundary,
229
+ relativePath: string
230
+ ): { sourcePath: string; destinationPath: string } {
231
+ return {
232
+ sourcePath: resolveSourcePath(boundary, relativePath),
233
+ destinationPath: resolveDestinationPath(boundary, relativePath),
234
+ };
235
+ }
236
+
237
+ export function isCopyContainmentError(error: unknown): boolean {
238
+ return isCopyRecord(error) && error.code === CONTAINMENT_ERROR_CODE;
239
+ }
240
+
241
+ interface CopyErrorPayload {
242
+ code?: string | null;
243
+ relativePath?: unknown;
244
+ name?: string;
245
+ message: string;
246
+ }
247
+
248
+ export function copyErrorFromPayload(payload: CopyErrorPayload): Error {
249
+ const error =
250
+ payload.code === CONTAINMENT_ERROR_CODE
251
+ ? new CopyContainmentError(payload.relativePath, 'worker rejected an unsafe path')
252
+ : new Error(payload.message);
253
+ error.name = payload.name || error.name;
254
+ error.message = payload.message;
255
+ if (payload.code && !(error instanceof CopyContainmentError)) {
256
+ Object.assign(error, { code: payload.code });
257
+ }
258
+ return error;
259
+ }
@@ -7,24 +7,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
7
7
  * Used by IsolationManager._copyDirExcluding() for parallel copying.
8
8
  */
9
9
  const fs = require("fs");
10
- const path = require("path");
11
10
  const worker_threads_1 = require("worker_threads");
11
+ const copy_containment_1 = require("./copy-containment");
12
12
  function isCopyWorkerData(value) {
13
- return (typeof value === 'object' &&
14
- value !== null &&
15
- 'files' in value &&
16
- Array.isArray(value.files) &&
13
+ if (!(0, copy_containment_1.isCopyRecord)(value)) {
14
+ return false;
15
+ }
16
+ return (Array.isArray(value.files) &&
17
17
  value.files.every((entry) => typeof entry === 'string') &&
18
- 'sourceBase' in value &&
19
18
  typeof value.sourceBase === 'string' &&
20
- 'destBase' in value &&
21
- typeof value.destBase === 'string');
22
- }
23
- function errorCode(error) {
24
- if (typeof error === 'object' && error !== null && 'code' in error) {
25
- return typeof error.code === 'string' ? error.code : null;
26
- }
27
- return null;
19
+ typeof value.destBase === 'string' &&
20
+ (0, copy_containment_1.isCopyRecord)(value.expectedBoundary));
28
21
  }
29
22
  function errorMessage(error) {
30
23
  return error instanceof Error ? error.message : String(error);
@@ -33,35 +26,41 @@ const rawWorkerData = worker_threads_1.workerData;
33
26
  if (!isCopyWorkerData(rawWorkerData)) {
34
27
  throw new TypeError('copy worker requires files, sourceBase, and destBase');
35
28
  }
36
- const { files, sourceBase, destBase } = rawWorkerData;
29
+ const { files, sourceBase, destBase, expectedBoundary } = rawWorkerData;
30
+ const copyBoundary = (0, copy_containment_1.createCopyBoundary)(sourceBase, destBase, expectedBoundary);
37
31
  let copied = 0;
38
32
  let skipped = 0;
39
- const errors = [];
33
+ let error = null;
40
34
  for (const relativePath of files) {
41
- const srcPath = path.join(sourceBase, relativePath);
42
- const destPath = path.join(destBase, relativePath);
43
35
  try {
44
- // Ensure parent directory exists
45
- const destDir = path.dirname(destPath);
46
- if (!fs.existsSync(destDir)) {
47
- fs.mkdirSync(destDir, { recursive: true });
48
- }
49
- // Copy the file
50
- fs.copyFileSync(srcPath, destPath);
36
+ // Phase two creates every parent directory. Re-resolve the source and
37
+ // destination immediately before the only worker filesystem effect.
38
+ const { sourcePath, destinationPath } = (0, copy_containment_1.resolveCopyPath)(copyBoundary, relativePath);
39
+ fs.copyFileSync(sourcePath, destinationPath);
51
40
  copied++;
52
41
  }
53
- catch (error) {
42
+ catch (caughtError) {
54
43
  // Skip files we can't copy (permission denied, broken symlinks, etc.)
55
- const code = errorCode(error);
56
- if (code === 'EACCES' || code === 'EPERM' || code === 'ENOENT') {
44
+ const code = (0, copy_containment_1.copyErrorCode)(caughtError);
45
+ if (!(0, copy_containment_1.isCopyContainmentError)(caughtError) &&
46
+ (code === 'EACCES' || code === 'EPERM' || code === 'ENOENT')) {
57
47
  skipped++;
58
48
  continue;
59
49
  }
60
- errors.push({ file: relativePath, error: errorMessage(error) });
50
+ error = {
51
+ file: relativePath,
52
+ name: caughtError instanceof Error ? caughtError.name : 'Error',
53
+ code,
54
+ message: errorMessage(caughtError),
55
+ relativePath: (0, copy_containment_1.isCopyRecord)(caughtError) && 'relativePath' in caughtError
56
+ ? caughtError.relativePath
57
+ : relativePath,
58
+ };
59
+ break;
61
60
  }
62
61
  }
63
62
  // Report results back to main thread
64
63
  if (!worker_threads_1.parentPort) {
65
64
  throw new Error('copy worker requires a parent port');
66
65
  }
67
- worker_threads_1.parentPort.postMessage({ copied, skipped, errors });
66
+ worker_threads_1.parentPort.postMessage({ copied, skipped, error });
@@ -5,36 +5,40 @@
5
5
  * Used by IsolationManager._copyDirExcluding() for parallel copying.
6
6
  */
7
7
  import fs = require('fs');
8
- import path = require('path');
9
8
  import { parentPort, workerData } from 'worker_threads';
9
+ import {
10
+ copyErrorCode,
11
+ createCopyBoundary,
12
+ isCopyContainmentError,
13
+ isCopyRecord,
14
+ resolveCopyPath,
15
+ } from './copy-containment';
16
+ import type { CopyBoundary } from './copy-containment';
10
17
  interface CopyWorkerData {
11
18
  files: string[];
12
19
  sourceBase: string;
13
20
  destBase: string;
21
+ expectedBoundary: CopyBoundary;
14
22
  }
15
23
  interface CopyError {
16
24
  file: string;
17
- error: string;
25
+ name: string;
26
+ code: string | null;
27
+ message: string;
28
+ relativePath: unknown;
18
29
  }
19
30
  function isCopyWorkerData(value: unknown): value is CopyWorkerData {
31
+ if (!isCopyRecord(value)) {
32
+ return false;
33
+ }
20
34
  return (
21
- typeof value === 'object' &&
22
- value !== null &&
23
- 'files' in value &&
24
35
  Array.isArray(value.files) &&
25
36
  value.files.every((entry: unknown) => typeof entry === 'string') &&
26
- 'sourceBase' in value &&
27
37
  typeof value.sourceBase === 'string' &&
28
- 'destBase' in value &&
29
- typeof value.destBase === 'string'
38
+ typeof value.destBase === 'string' &&
39
+ isCopyRecord(value.expectedBoundary)
30
40
  );
31
41
  }
32
- function errorCode(error: unknown): string | null {
33
- if (typeof error === 'object' && error !== null && 'code' in error) {
34
- return typeof error.code === 'string' ? error.code : null;
35
- }
36
- return null;
37
- }
38
42
  function errorMessage(error: unknown): string {
39
43
  return error instanceof Error ? error.message : String(error);
40
44
  }
@@ -42,34 +46,43 @@ const rawWorkerData: unknown = workerData;
42
46
  if (!isCopyWorkerData(rawWorkerData)) {
43
47
  throw new TypeError('copy worker requires files, sourceBase, and destBase');
44
48
  }
45
- const { files, sourceBase, destBase } = rawWorkerData;
49
+ const { files, sourceBase, destBase, expectedBoundary } = rawWorkerData;
50
+ const copyBoundary = createCopyBoundary(sourceBase, destBase, expectedBoundary);
46
51
  let copied = 0;
47
52
  let skipped = 0;
48
- const errors: CopyError[] = [];
53
+ let error: CopyError | null = null;
49
54
  for (const relativePath of files) {
50
- const srcPath = path.join(sourceBase, relativePath);
51
- const destPath = path.join(destBase, relativePath);
52
55
  try {
53
- // Ensure parent directory exists
54
- const destDir = path.dirname(destPath);
55
- if (!fs.existsSync(destDir)) {
56
- fs.mkdirSync(destDir, { recursive: true });
57
- }
58
- // Copy the file
59
- fs.copyFileSync(srcPath, destPath);
56
+ // Phase two creates every parent directory. Re-resolve the source and
57
+ // destination immediately before the only worker filesystem effect.
58
+ const { sourcePath, destinationPath } = resolveCopyPath(copyBoundary, relativePath);
59
+ fs.copyFileSync(sourcePath, destinationPath);
60
60
  copied++;
61
- } catch (error: unknown) {
61
+ } catch (caughtError: unknown) {
62
62
  // Skip files we can't copy (permission denied, broken symlinks, etc.)
63
- const code = errorCode(error);
64
- if (code === 'EACCES' || code === 'EPERM' || code === 'ENOENT') {
63
+ const code = copyErrorCode(caughtError);
64
+ if (
65
+ !isCopyContainmentError(caughtError) &&
66
+ (code === 'EACCES' || code === 'EPERM' || code === 'ENOENT')
67
+ ) {
65
68
  skipped++;
66
69
  continue;
67
70
  }
68
- errors.push({ file: relativePath, error: errorMessage(error) });
71
+ error = {
72
+ file: relativePath,
73
+ name: caughtError instanceof Error ? caughtError.name : 'Error',
74
+ code,
75
+ message: errorMessage(caughtError),
76
+ relativePath:
77
+ isCopyRecord(caughtError) && 'relativePath' in caughtError
78
+ ? caughtError.relativePath
79
+ : relativePath,
80
+ };
81
+ break;
69
82
  }
70
83
  }
71
84
  // Report results back to main thread
72
85
  if (!parentPort) {
73
86
  throw new Error('copy worker requires a parent port');
74
87
  }
75
- parentPort.postMessage({ copied, skipped, errors });
88
+ parentPort.postMessage({ copied, skipped, error });