@the-open-engine/zeroshot 6.39.0 → 6.39.1

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.
@@ -34,6 +34,14 @@ const { resolveProviderCredentialPaths } = require('../lib/provider-credential-p
34
34
  const { getProvider } = require('./providers');
35
35
  const { readRepoSettings } = require('../lib/repo-settings');
36
36
  const { provisionClaudeCredentials } = require('./claude-credentials');
37
+ const {
38
+ CopyContainmentError,
39
+ copyErrorFromPayload,
40
+ createCopyBoundary,
41
+ resolveCopyPath,
42
+ resolveSourcePath,
43
+ validateRelativePath,
44
+ } = require('./copy-containment');
37
45
 
38
46
  const DEFAULT_WORKTREE_SETUP_TIMEOUT_MS = 15 * 60 * 1000;
39
47
  const FRESH_BASE_REF_PREFIX = 'refs/zeroshot/base-fetch';
@@ -1298,6 +1306,7 @@ class IsolationManager {
1298
1306
  // Phase 1: Collect all files and directories
1299
1307
  const files = [];
1300
1308
  const directories = new Set();
1309
+ const copyBoundary = createCopyBoundary(src, dest);
1301
1310
 
1302
1311
  const shouldIgnoreFsError = (err) =>
1303
1312
  err.code === 'EACCES' || err.code === 'EPERM' || err.code === 'ENOENT';
@@ -1328,12 +1337,13 @@ class IsolationManager {
1328
1337
  }
1329
1338
  };
1330
1339
 
1331
- function handleEntry(entry, srcPath, relPath, relativePath) {
1340
+ function handleEntry(entry, relPath, relativePath, ancestorDirectories) {
1332
1341
  if (entry.isSymbolicLink()) {
1333
- const targetStats = fs.statSync(srcPath);
1342
+ const resolvedSourcePath = resolveSourcePath(copyBoundary, relPath);
1343
+ const targetStats = fs.statSync(resolvedSourcePath);
1334
1344
  if (targetStats.isDirectory()) {
1335
1345
  directories.add(relPath);
1336
- collectFiles(srcPath, relPath);
1346
+ collectFiles(relPath, ancestorDirectories);
1337
1347
  return;
1338
1348
  }
1339
1349
 
@@ -1344,7 +1354,7 @@ class IsolationManager {
1344
1354
 
1345
1355
  if (entry.isDirectory()) {
1346
1356
  directories.add(relPath);
1347
- collectFiles(srcPath, relPath);
1357
+ collectFiles(relPath, ancestorDirectories);
1348
1358
  return;
1349
1359
  }
1350
1360
 
@@ -1352,7 +1362,15 @@ class IsolationManager {
1352
1362
  ensureParentDirTracked(relativePath);
1353
1363
  }
1354
1364
 
1355
- function collectFiles(currentSrc, relativePath = '') {
1365
+ function collectFiles(relativePath = '', ancestorDirectories = new Set()) {
1366
+ const currentSrc = relativePath
1367
+ ? resolveSourcePath(copyBoundary, relativePath)
1368
+ : copyBoundary.sourceRoot.canonicalPath;
1369
+ if (ancestorDirectories.has(currentSrc)) {
1370
+ throw new CopyContainmentError(relativePath, 'source directory symlink creates a cycle');
1371
+ }
1372
+ const childAncestors = new Set(ancestorDirectories);
1373
+ childAncestors.add(currentSrc);
1356
1374
  const entries = readEntries(currentSrc);
1357
1375
 
1358
1376
  for (const entry of entries) {
@@ -1360,11 +1378,11 @@ class IsolationManager {
1360
1378
  continue;
1361
1379
  }
1362
1380
 
1363
- const srcPath = path.join(currentSrc, entry.name);
1364
- const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
1381
+ const entryName = validateRelativePath(entry.name);
1382
+ const relPath = relativePath ? path.join(relativePath, entryName) : entryName;
1365
1383
 
1366
1384
  try {
1367
- handleEntry(entry, srcPath, relPath, relativePath);
1385
+ handleEntry(entry, relPath, relativePath, childAncestors);
1368
1386
  } catch (err) {
1369
1387
  if (shouldIgnoreFsError(err)) {
1370
1388
  continue;
@@ -1374,7 +1392,7 @@ class IsolationManager {
1374
1392
  }
1375
1393
  }
1376
1394
 
1377
- collectFiles(src);
1395
+ collectFiles();
1378
1396
 
1379
1397
  // Phase 2: Create directory structure (sequential - must exist before file copy)
1380
1398
  // Sort directories by depth to ensure parents are created before children
@@ -1385,7 +1403,7 @@ class IsolationManager {
1385
1403
  });
1386
1404
 
1387
1405
  for (const dir of sortedDirs) {
1388
- const destDir = path.join(dest, dir);
1406
+ const { destinationPath: destDir } = resolveCopyPath(copyBoundary, dir);
1389
1407
  try {
1390
1408
  fs.mkdirSync(destDir, { recursive: true });
1391
1409
  } catch (err) {
@@ -1399,10 +1417,9 @@ class IsolationManager {
1399
1417
  // For small file counts (<100), use synchronous copy (worker overhead not worth it)
1400
1418
  if (files.length < 100) {
1401
1419
  for (const relPath of files) {
1402
- const srcPath = path.join(src, relPath);
1403
- const destPath = path.join(dest, relPath);
1404
1420
  try {
1405
- fs.copyFileSync(srcPath, destPath);
1421
+ const { sourcePath, destinationPath } = resolveCopyPath(copyBoundary, relPath);
1422
+ fs.copyFileSync(sourcePath, destinationPath);
1406
1423
  } catch (err) {
1407
1424
  if (err.code !== 'EACCES' && err.code !== 'EPERM' && err.code !== 'ENOENT') {
1408
1425
  throw err;
@@ -1424,18 +1441,22 @@ class IsolationManager {
1424
1441
  }
1425
1442
 
1426
1443
  // Spawn workers and wait for completion
1444
+ const workers = [];
1427
1445
  const workerPromises = chunks.map((chunk) => {
1428
1446
  return new Promise((resolve, reject) => {
1429
1447
  const worker = new Worker(workerPath, {
1430
1448
  workerData: {
1431
1449
  files: chunk,
1432
- sourceBase: src,
1433
- destBase: dest,
1450
+ sourceBase: copyBoundary.sourceRoot.canonicalPath,
1451
+ destBase: copyBoundary.destinationRoot.canonicalPath,
1452
+ expectedBoundary: copyBoundary,
1434
1453
  },
1435
1454
  });
1455
+ workers.push(worker);
1456
+ let result = null;
1436
1457
 
1437
- worker.on('message', (result) => {
1438
- resolve(result);
1458
+ worker.on('message', (workerResult) => {
1459
+ result = workerResult;
1439
1460
  });
1440
1461
 
1441
1462
  worker.on('error', (err) => {
@@ -1445,6 +1466,12 @@ class IsolationManager {
1445
1466
  worker.on('exit', (code) => {
1446
1467
  if (code !== 0) {
1447
1468
  reject(new Error(`Worker exited with code ${code}`));
1469
+ } else if (!result) {
1470
+ reject(new Error('Copy worker exited without reporting a result'));
1471
+ } else if (result.error) {
1472
+ reject(copyErrorFromPayload(result.error));
1473
+ } else {
1474
+ resolve(result);
1448
1475
  }
1449
1476
  });
1450
1477
  });
@@ -1453,7 +1480,12 @@ class IsolationManager {
1453
1480
  // Wait for all workers to complete (proper async/await - no busy-wait!)
1454
1481
  // FIX: Previous version used busy-wait which blocked the event loop,
1455
1482
  // preventing worker thread messages from being processed (timeout bug)
1456
- await Promise.all(workerPromises);
1483
+ try {
1484
+ await Promise.all(workerPromises);
1485
+ } catch (err) {
1486
+ await Promise.all(workers.map((worker) => worker.terminate().catch(() => undefined)));
1487
+ throw err;
1488
+ }
1457
1489
  }
1458
1490
 
1459
1491
  /**
@@ -2383,8 +2415,15 @@ class IsolationManager {
2383
2415
  // NEVER pass --volumes (irreversible data loss) and NEVER tear down a pinned/shared
2384
2416
  // Compose project — only a project scoped to the worktree directory basename, which is
2385
2417
  // the only kind zeroshot could itself have created, is touched.
2386
- const { resolveWorktreeComposeTeardown } = require('../lib/compose-utils');
2387
- const teardown = resolveWorktreeComposeTeardown(worktreeInfo.path);
2418
+ let teardown = { shouldTeardown: false };
2419
+ try {
2420
+ const { resolveWorktreeComposeTeardown } = require('../lib/compose-utils');
2421
+ teardown = resolveWorktreeComposeTeardown(worktreeInfo.path);
2422
+ } catch (error) {
2423
+ console.warn(
2424
+ `[IsolationManager] Skipping Docker Compose teardown in ${worktreeInfo.path}: ${error.message}`
2425
+ );
2426
+ }
2388
2427
  if (teardown.shouldTeardown) {
2389
2428
  try {
2390
2429
  runSync('docker', teardown.args, {
@@ -26,6 +26,7 @@ interface RunOptions extends Record<string, unknown> {
26
26
  mergeQueue?: unknown;
27
27
  pr?: unknown;
28
28
  prBase?: unknown;
29
+ prBody?: unknown;
29
30
  ship?: unknown;
30
31
  worktree?: unknown;
31
32
  }
@@ -236,9 +237,10 @@ async function registerDetachedSetupCluster({
236
237
  setupStartedAt: Date.now(),
237
238
  setupStage: 'starting',
238
239
  autoPr: plan.delivery !== 'none',
239
- prOptions: runOptions.prBase
240
+ prOptions: plan.delivery !== 'none'
240
241
  ? {
241
242
  prBase: runOptions.prBase,
243
+ prBody: typeof runOptions.prBody === 'string' ? runOptions.prBody : null,
242
244
  mergeQueue: runOptions.mergeQueue || false,
243
245
  closeIssue: runOptions.closeIssue || null,
244
246
  autoMerge: plan.autoMerge,
@@ -10,6 +10,7 @@ interface RunOptions extends Record<string, unknown> {
10
10
  mount?: readonly string[] | null;
11
11
  noIsolation?: unknown;
12
12
  prBase?: unknown;
13
+ prBody?: unknown;
13
14
  }
14
15
 
15
16
  interface MountSpec {
@@ -28,6 +28,7 @@ interface RunOptions extends Record<string, unknown> {
28
28
  noMounts?: unknown;
29
29
  pr?: unknown;
30
30
  prBase?: unknown;
31
+ prBody?: unknown;
31
32
  preparedWorktree?: unknown;
32
33
  requiredQualityGates?: unknown;
33
34
  ship?: unknown;
@@ -201,6 +202,7 @@ function buildStartOptionsFromPlan({
201
202
  containerHome: optionalValue(options.containerHome),
202
203
  forceProvider: optionalValue(forceProvider),
203
204
  prBase: environment ? resolvePrBase(options) : optionalValue(options.prBase),
205
+ prBody: typeof options.prBody === 'string' ? options.prBody : undefined,
204
206
  mergeQueue: environment ? resolveMergeQueue(options) : optionalValue(options.mergeQueue),
205
207
  closeIssue: environment ? resolveCloseIssue(options) : optionalValue(options.closeIssue),
206
208
  ship: plan.delivery === 'ship',
@@ -262,6 +262,7 @@ function buildPrOptions(options, requiredQualityGates) {
262
262
  prBase: options.prBase || null,
263
263
  mergeQueue: options.mergeQueue || false,
264
264
  closeIssue: options.closeIssue || null,
265
+ prBody: typeof options.prBody === 'string' ? options.prBody : null,
265
266
  gitRemote: options.gitRemote || null,
266
267
  autoMerge,
267
268
  ...(requiredQualityGates.length > 0 ? { requiredQualityGates } : {}),
@@ -2038,6 +2039,7 @@ class Orchestrator {
2038
2039
  prBase: options.prBase,
2039
2040
  mergeQueue: options.mergeQueue,
2040
2041
  closeIssue: options.closeIssue,
2042
+ prBody: options.prBody,
2041
2043
  requiredQualityGates: options.requiredQualityGates,
2042
2044
  autoMerge: resolveRunPlan(options).autoMerge,
2043
2045
  gitRemote: gitContext?.remote || options.gitRemote,
@@ -2155,8 +2157,16 @@ class Orchestrator {
2155
2157
  // NEVER pass --volumes (irreversible data loss) and NEVER tear down a pinned/shared
2156
2158
  // Compose project — only a project scoped to the worktree directory basename, which is
2157
2159
  // the only kind zeroshot could itself have created, is touched.
2158
- const { resolveWorktreeComposeTeardown } = require('../lib/compose-utils');
2159
- const teardown = resolveWorktreeComposeTeardown(worktreePath);
2160
+ let teardown;
2161
+ try {
2162
+ const { resolveWorktreeComposeTeardown } = require('../lib/compose-utils');
2163
+ teardown = resolveWorktreeComposeTeardown(worktreePath);
2164
+ } catch (error) {
2165
+ this._log(
2166
+ `[Orchestrator] Skipping Docker Compose teardown in ${worktreePath}: ${error.message}`
2167
+ );
2168
+ return;
2169
+ }
2160
2170
  if (!teardown.shouldTeardown) {
2161
2171
  if (teardown.composePath) {
2162
2172
  this._log(
@@ -4217,7 +4227,7 @@ Continue from where you left off. Review your previous output to understand what
4217
4227
 
4218
4228
  // Get issue context from ledger
4219
4229
  const issueMsg = cluster.messageBus.ledger.findLast({ topic: 'ISSUE_OPENED' });
4220
- const issueNumber = issueMsg?.content?.data?.number || 'unknown';
4230
+ const issueNumber = issueMsg?.content?.data?.issue_number || 'unknown';
4221
4231
  const issueTitle = issueMsg?.content?.data?.title || 'Implementation';
4222
4232
 
4223
4233
  // Generate the final prompt in one typed assembly pass. Issue values are
@@ -0,0 +1,71 @@
1
+ const MAX_PR_BODY_LENGTH = 65536;
2
+
3
+ function normalizeIssueNumber(value) {
4
+ const candidate = typeof value === 'number' ? String(value) : value;
5
+ if (typeof candidate !== 'string') return 'unknown';
6
+ const trimmed = candidate.trim();
7
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed) ? trimmed : 'unknown';
8
+ }
9
+
10
+ function normalizeIssueTitle(value) {
11
+ if (typeof value !== 'string' || value.trim() === '') return 'Implementation';
12
+ const normalized = [...value]
13
+ .map((character) => {
14
+ const codePoint = character.codePointAt(0);
15
+ return codePoint < 0x20 || codePoint === 0x7f ? ' ' : character;
16
+ })
17
+ .join('')
18
+ .trim();
19
+ return normalized || 'Implementation';
20
+ }
21
+
22
+ function resolveIssueContext(options = {}) {
23
+ const issueNumber = normalizeIssueNumber(options.issueNumber);
24
+ const issueTitle = normalizeIssueTitle(options.issueTitle);
25
+ const issueReference =
26
+ options.includeIssueReference === false || issueNumber === 'unknown'
27
+ ? ''
28
+ : `Closes #${issueNumber}`;
29
+ return { issueNumber, issueTitle, issueReference };
30
+ }
31
+
32
+ function normalizePrBodyTemplate(value) {
33
+ if (value === undefined || value === null) return null;
34
+ if (typeof value !== 'string') throw new TypeError('PR body template must be a string');
35
+ if (value.includes('\0')) throw new TypeError('PR body template must not contain NUL bytes');
36
+ if (value.length > MAX_PR_BODY_LENGTH) {
37
+ throw new TypeError(`PR body template must not exceed ${MAX_PR_BODY_LENGTH} characters`);
38
+ }
39
+ return value;
40
+ }
41
+
42
+ /**
43
+ * Render a bounded PR body. Missing issue metadata expands to empty text so
44
+ * manual tasks never leak internal sentinel values into pull requests.
45
+ */
46
+ function renderPullRequestBody(template, options = {}) {
47
+ const issueContext = resolveIssueContext(options);
48
+ const normalizedTemplate = normalizePrBodyTemplate(template);
49
+ if (normalizedTemplate === null) return issueContext.issueReference;
50
+
51
+ const hasIssue = issueContext.issueNumber !== 'unknown';
52
+ const substitutions = {
53
+ '{{issue_number}}': hasIssue ? issueContext.issueNumber : '',
54
+ '{{issue_title}}': hasIssue ? issueContext.issueTitle : '',
55
+ '{{issue_reference}}': hasIssue ? issueContext.issueReference : '',
56
+ };
57
+ let rendered = normalizedTemplate;
58
+ for (const [token, replacement] of Object.entries(substitutions)) {
59
+ rendered = rendered.replaceAll(token, replacement);
60
+ }
61
+ if (rendered.length > MAX_PR_BODY_LENGTH) {
62
+ throw new TypeError(`Rendered PR body must not exceed ${MAX_PR_BODY_LENGTH} characters`);
63
+ }
64
+ return rendered;
65
+ }
66
+
67
+ module.exports = {
68
+ MAX_PR_BODY_LENGTH,
69
+ renderPullRequestBody,
70
+ resolveIssueContext,
71
+ };