@the-open-engine/zeroshot 6.23.0 → 6.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/cli/index.js +37 -2
  2. package/docker/zeroshot-cluster/Dockerfile +7 -0
  3. package/lib/agent-cli-provider/adapters/omp.d.ts.map +1 -1
  4. package/lib/agent-cli-provider/adapters/omp.js +37 -11
  5. package/lib/agent-cli-provider/adapters/omp.js.map +1 -1
  6. package/lib/agent-cli-provider/omp-release.d.ts +3 -0
  7. package/lib/agent-cli-provider/omp-release.d.ts.map +1 -1
  8. package/lib/agent-cli-provider/omp-release.js +20 -1
  9. package/lib/agent-cli-provider/omp-release.js.map +1 -1
  10. package/lib/agent-cli-provider/omp-rpc-driver.d.ts.map +1 -1
  11. package/lib/agent-cli-provider/omp-rpc-driver.js +27 -2
  12. package/lib/agent-cli-provider/omp-rpc-driver.js.map +1 -1
  13. package/lib/agent-cli-provider/omp-rpc-session.js +3 -3
  14. package/lib/agent-cli-provider/omp-rpc-session.js.map +1 -1
  15. package/lib/agent-cli-provider/provider-registry.d.ts +20 -8
  16. package/lib/agent-cli-provider/provider-registry.d.ts.map +1 -1
  17. package/lib/agent-cli-provider/provider-registry.js +56 -9
  18. package/lib/agent-cli-provider/provider-registry.js.map +1 -1
  19. package/lib/agent-cli-provider/types.d.ts +2 -0
  20. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  21. package/lib/agent-cli-provider/types.js.map +1 -1
  22. package/lib/docker-config.js +122 -5
  23. package/package.json +2 -2
  24. package/src/agent/agent-lifecycle.js +78 -3
  25. package/src/agent/agent-task-executor.js +72 -3
  26. package/src/agent/provider-session.js +112 -2
  27. package/src/agent-cli-provider/adapters/omp.ts +41 -11
  28. package/src/agent-cli-provider/omp-release.ts +26 -0
  29. package/src/agent-cli-provider/omp-rpc-driver.ts +31 -3
  30. package/src/agent-cli-provider/omp-rpc-session.ts +3 -3
  31. package/src/agent-cli-provider/provider-registry.ts +102 -20
  32. package/src/agent-cli-provider/types.ts +4 -0
  33. package/src/isolation-manager.js +535 -89
  34. package/src/omp-blob-root.js +110 -0
  35. package/src/omp-config-overlay.js +9 -1
  36. package/src/omp-execution-fingerprint.js +62 -0
  37. package/src/omp-session-limits.js +17 -0
  38. package/src/omp-session-partition.js +297 -0
  39. package/src/omp-session-verifier.js +576 -0
  40. package/src/orchestrator.js +11 -1
  41. package/src/preflight.js +15 -2
  42. package/task-lib/commands/clean.js +23 -0
  43. package/task-lib/commands/resume.js +42 -0
  44. package/task-lib/commands/run.js +65 -0
  45. package/task-lib/omp-session-cleanup.js +160 -0
  46. package/task-lib/omp-session-ownership-schema.js +262 -0
  47. package/task-lib/omp-session-ownership.js +332 -0
  48. package/task-lib/omp-storage-root.js +35 -0
  49. package/task-lib/rpc-watcher.js +332 -2
  50. package/task-lib/runner.js +195 -4
  51. package/task-lib/store.js +42 -7
@@ -15,7 +15,7 @@ const path = require('path');
15
15
  const os = require('os');
16
16
  const fs = require('fs');
17
17
  const { loadSettings } = require('../lib/settings');
18
- const { CLAUDE_AUTH_ENV_VARS, resolveClaudeAuth } = require('../lib/settings/claude-auth');
18
+ const { resolveClaudeAuth } = require('../lib/settings/claude-auth');
19
19
  const {
20
20
  normalizeProviderName,
21
21
  getProviderMetadata,
@@ -23,9 +23,12 @@ const {
23
23
  } = require('../lib/provider-names');
24
24
  const {
25
25
  MOUNT_PRESETS,
26
+ ENV_PRESETS,
26
27
  resolveMounts,
27
28
  resolveEnvs,
28
29
  expandEnvPatterns,
30
+ isUsableEnvValue,
31
+ validateProviderEnvAuth,
29
32
  } = require('../lib/docker-config');
30
33
  const { getProvider } = require('./providers');
31
34
  const { readRepoSettings } = require('../lib/repo-settings');
@@ -181,6 +184,40 @@ function providerDockerInstall(providerName) {
181
184
  }
182
185
  }
183
186
 
187
+ /**
188
+ * Registry-owned Docker platform (e.g. 'linux/amd64') for a provider, or null when unset.
189
+ * Providers other than the ones that declare `docker.platform` keep today's host-native
190
+ * (unset `--platform`) behavior.
191
+ * @param {string} providerName
192
+ * @returns {string|null}
193
+ */
194
+ function providerDockerPlatform(providerName) {
195
+ if (!providerName) return null;
196
+ try {
197
+ const metadata = getProviderMetadata(providerName);
198
+ const platform = metadata && metadata.docker && metadata.docker.platform;
199
+ return typeof platform === 'string' && platform.trim() ? platform.trim() : null;
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Registry-owned $HOME-placeholder config/overlay roots for a provider, unexpanded.
207
+ * @param {string} providerName
208
+ * @returns {readonly string[]}
209
+ */
210
+ function providerDockerConfigRootsRaw(providerName) {
211
+ if (!providerName) return [];
212
+ try {
213
+ const metadata = getProviderMetadata(providerName);
214
+ const roots = metadata && metadata.docker && metadata.docker.configRoots;
215
+ return Array.isArray(roots) ? roots : [];
216
+ } catch {
217
+ return [];
218
+ }
219
+ }
220
+
184
221
  class IsolationManager {
185
222
  constructor(options = {}) {
186
223
  this.image = options.image || DEFAULT_IMAGE;
@@ -234,35 +271,48 @@ class IsolationManager {
234
271
  return runningContainerId;
235
272
  }
236
273
 
237
- this._removeContainerByName(containerName);
238
-
239
- workDir = await this._prepareIsolatedWorkspace(clusterId, workDir, reuseExisting);
240
-
241
274
  const settings = loadSettings();
242
275
  const providerName = normalizeProviderName(
243
276
  config.provider || settings.defaultProvider || getDefaultProviderId()
244
277
  );
245
278
  const containerHome = config.containerHome || settings.dockerContainerHome || '/root';
246
279
 
247
- const clusterConfigDir = this._createClusterConfigDir(clusterId, containerHome);
248
- console.log(`[IsolationManager] Created cluster config dir at ${clusterConfigDir}`);
280
+ // Pre-effect auth gate. The effective env/mount plan is computed (read-only) and validated
281
+ // BEFORE the stale-container removal and the isolated-workspace copy, so a missing or
282
+ // malformed credential plan leaves no container or workspace side effect behind — matching
283
+ // the platform probe's ordering in orchestrator/agent-lifecycle/preflight.
284
+ const credentialPlan = this._buildCredentialPlan(config, settings, containerHome, providerName);
285
+ this._assertProviderCredentialPlan(providerName, {
286
+ ...credentialPlan,
287
+ config,
288
+ containerHome,
289
+ });
290
+
291
+ this._removeContainerByName(containerName);
292
+
293
+ workDir = await this._prepareIsolatedWorkspace(clusterId, workDir, reuseExisting);
294
+
295
+ // The cluster config dir carries Claude credentials (via provisionClaudeCredentials) and the
296
+ // Claude-specific AskUserQuestion-blocking hook (~/.claude/settings.json PreToolUse), which
297
+ // only the `claude` CLI reads. Creating and mounting it for every provider — regardless of
298
+ // whether Claude is even running in the container — was an unconditional Claude-auth side
299
+ // channel into other providers' containers (e.g. omp, whose Docker isolation must be
300
+ // env/broker-only with zero automatic mounts). Scope it to the claude provider only.
301
+ const clusterConfigDir =
302
+ providerName === 'claude' ? this._createClusterConfigDir(clusterId, containerHome) : null;
303
+ if (clusterConfigDir) {
304
+ console.log(`[IsolationManager] Created cluster config dir at ${clusterConfigDir}`);
305
+ }
249
306
 
250
307
  const args = this._buildBaseDockerArgs({
251
308
  containerName,
252
309
  workDir,
253
310
  containerHome,
254
311
  clusterConfigDir,
312
+ platform: config.platform,
255
313
  });
256
314
 
257
- const mountedHosts = this._applyCredentialMounts(
258
- args,
259
- config,
260
- settings,
261
- containerHome,
262
- providerName
263
- );
264
- this._warnMissingProviderCredentials(providerName, mountedHosts, config, containerHome);
265
-
315
+ args.push(...credentialPlan.args);
266
316
  args.push('-w', '/workspace', image, 'tail', '-f', '/dev/null');
267
317
 
268
318
  const containerId = await this._spawnContainer(clusterId, args, workDir);
@@ -340,21 +390,26 @@ class IsolationManager {
340
390
  return isolatedDir;
341
391
  }
342
392
 
343
- _buildBaseDockerArgs({ containerName, workDir, containerHome, clusterConfigDir }) {
344
- return [
345
- 'run',
346
- '-d',
347
- '--name',
348
- containerName,
393
+ _buildBaseDockerArgs({ containerName, workDir, containerHome, clusterConfigDir, platform }) {
394
+ const args = ['run', '-d', '--name', containerName];
395
+ if (platform) {
396
+ args.push('--platform', platform);
397
+ }
398
+ args.push(
349
399
  '-v',
350
400
  `${workDir}:/workspace`,
351
401
  '-v',
352
402
  '/var/run/docker.sock:/var/run/docker.sock',
353
403
  '--group-add',
354
- this._getDockerGid(),
355
- '-v',
356
- `${clusterConfigDir}:${containerHome}/.claude`,
357
- ];
404
+ this._getDockerGid()
405
+ );
406
+ // Only mounted when the active provider is claude (see createContainer) — carries Claude
407
+ // credentials and the Claude-specific AskUserQuestion-blocking hook, neither of which any
408
+ // other provider's CLI reads.
409
+ if (clusterConfigDir) {
410
+ args.push('-v', `${clusterConfigDir}:${containerHome}/.claude`);
411
+ }
412
+ return args;
358
413
  }
359
414
 
360
415
  _resolveMountConfig(config, settings) {
@@ -374,26 +429,56 @@ class IsolationManager {
374
429
  return settings.dockerMounts;
375
430
  }
376
431
 
377
- // Auto-activate the running provider's own credential preset (mount + env) so `--docker` works
378
- // without listing it in dockerMounts. Claude is mounted separately, so skip it.
432
+ // Auto-activate the running provider's own credential preset (mount and/or env) so `--docker`
433
+ // works without listing it in dockerMounts. Env-only providers (e.g. omp) have no MOUNT_PRESETS
434
+ // entry, so both preset maps are checked.
379
435
  _withActiveProviderPreset(mountConfig, providerName) {
380
- if (!providerName || providerName === 'claude') return mountConfig;
381
- if (!MOUNT_PRESETS[providerName]) return mountConfig;
436
+ if (!providerName) return mountConfig;
437
+ if (!MOUNT_PRESETS[providerName] && !ENV_PRESETS[providerName]) return mountConfig;
382
438
  if (mountConfig.some((item) => item === providerName)) return mountConfig;
383
439
  return [...mountConfig, providerName];
384
440
  }
385
441
 
386
- _applyCredentialMounts(args, config, settings, containerHome, providerName) {
387
- const mountedHosts = [];
442
+ /**
443
+ * Compute the *effective* credential plan for a container — the exact `-v`/`-e` argv the
444
+ * container would receive, plus which of it the user explicitly opted into — WITHOUT applying
445
+ * any side effect. Callers validate the plan (see `_assertProviderCredentialPlan`) before
446
+ * touching containers or workspaces, then splice `plan.args` into the final argv.
447
+ *
448
+ * `forwardedEnv` holds the ACTUAL values the container would receive, so a forced-empty entry
449
+ * (`dockerEnvPassthrough: ["OPENAI_API_KEY="]`) is distinguishable from a real key. Values stay
450
+ * internal — they are never logged or included in any error message.
451
+ *
452
+ * @param {object} config
453
+ * @param {object} settings
454
+ * @param {string} containerHome
455
+ * @param {string} providerName
456
+ * @returns {{args: string[], mountedHosts: string[], explicitMountContainerPaths: string[],
457
+ * forwardedEnv: Record<string, string>, explicitEnvNames: Set<string>}}
458
+ */
459
+ _buildCredentialPlan(config, settings, containerHome, providerName) {
460
+ const plan = {
461
+ args: [],
462
+ mountedHosts: [],
463
+ explicitMountContainerPaths: [],
464
+ forwardedEnv: {},
465
+ explicitEnvNames: new Set(),
466
+ };
467
+
388
468
  if (config.noMounts) {
389
- return mountedHosts;
469
+ return plan;
390
470
  }
391
471
 
392
- const mountConfig = this._withActiveProviderPreset(
393
- this._resolveMountConfig(config, settings),
394
- providerName
395
- );
472
+ // The user's own config, before the running provider's preset is auto-activated. Anything
473
+ // sourced from here is an *explicit* opt-in; anything added by `_withActiveProviderPreset` is
474
+ // automatic. Credential accounting depends on that distinction.
475
+ const userMountConfig = this._resolveMountConfig(config, settings);
476
+ const mountConfig = this._withActiveProviderPreset(userMountConfig, providerName);
477
+
396
478
  const mounts = resolveMounts(mountConfig, { containerHome });
479
+ const explicitContainerPaths = new Set(
480
+ resolveMounts(userMountConfig, { containerHome }).map((mount) => mount.container)
481
+ );
397
482
  const claudeContainerPath = path.posix.join(containerHome, '.claude');
398
483
 
399
484
  for (const mount of mounts) {
@@ -419,21 +504,52 @@ class IsolationManager {
419
504
  const mountSpec = mount.readonly
420
505
  ? `${hostPath}:${mount.container}:ro`
421
506
  : `${hostPath}:${mount.container}`;
422
- args.push('-v', mountSpec);
423
- mountedHosts.push(hostPath);
507
+ plan.args.push('-v', mountSpec);
508
+ plan.mountedHosts.push(hostPath);
509
+ if (explicitContainerPaths.has(mount.container)) {
510
+ plan.explicitMountContainerPaths.push(mount.container);
511
+ }
424
512
  }
425
513
 
426
- const envToPass = this._collectDockerEnvVars(mountConfig, settings);
514
+ const { envToPass, explicitNames } = this._collectDockerEnvVars(
515
+ mountConfig,
516
+ userMountConfig,
517
+ settings
518
+ );
427
519
  for (const [key, value] of Object.entries(envToPass)) {
428
- args.push('-e', `${key}=${value}`);
520
+ plan.args.push('-e', `${key}=${value}`);
521
+ plan.forwardedEnv[key] = value;
429
522
  }
523
+ plan.explicitEnvNames = explicitNames;
430
524
 
431
- return mountedHosts;
525
+ return plan;
432
526
  }
433
527
 
434
- _collectDockerEnvVars(mountConfig, settings) {
528
+ /**
529
+ * Apply the credential plan to an argv array. Thin wrapper around `_buildCredentialPlan` kept
530
+ * for callers that build argv incrementally.
531
+ */
532
+ _applyCredentialMounts(args, config, settings, containerHome, providerName) {
533
+ const plan = this._buildCredentialPlan(config, settings, containerHome, providerName);
534
+ args.push(...plan.args);
535
+ return plan;
536
+ }
537
+
538
+ /**
539
+ * @param {Array<string|object>} mountConfig - effective config (user's + auto provider preset)
540
+ * @param {Array<string|object>} userMountConfig - the user's config only
541
+ * @param {object} settings
542
+ * @returns {{envToPass: Record<string, string>, explicitNames: Set<string>}}
543
+ */
544
+ _collectDockerEnvVars(mountConfig, userMountConfig, settings) {
435
545
  const envToPass = {};
436
546
  const envSpecs = expandEnvPatterns(resolveEnvs(mountConfig, settings.dockerEnvPassthrough));
547
+ // Names the user opted into by name (dockerEnvPassthrough) or by explicitly listing a preset,
548
+ // as opposed to the running provider's automatically-activated preset.
549
+ const explicitSpecs = expandEnvPatterns(
550
+ resolveEnvs(userMountConfig, settings.dockerEnvPassthrough)
551
+ );
552
+ const explicitNames = new Set(explicitSpecs.map((spec) => spec.name));
437
553
 
438
554
  for (const spec of envSpecs) {
439
555
  if (spec.forced) {
@@ -443,69 +559,215 @@ class IsolationManager {
443
559
  }
444
560
  }
445
561
 
446
- for (const envVar of CLAUDE_AUTH_ENV_VARS) {
447
- if (process.env[envVar]) {
448
- envToPass[envVar] = process.env[envVar];
449
- }
450
- }
451
-
452
- const authEnv = resolveClaudeAuth(settings);
453
- for (const [key, value] of Object.entries(authEnv)) {
454
- if (!(key in envToPass)) {
455
- envToPass[key] = value;
562
+ // Claude's own auth resolution only applies when Claude's preset is actually active (either
563
+ // as the running provider or explicitly configured) — never as an unconditional side channel
564
+ // into another provider's container (e.g. omp).
565
+ if (mountConfig.includes('claude')) {
566
+ const authEnv = resolveClaudeAuth(settings);
567
+ for (const [key, value] of Object.entries(authEnv)) {
568
+ if (!(key in envToPass)) {
569
+ envToPass[key] = value;
570
+ }
456
571
  }
457
572
  }
458
573
 
459
- return envToPass;
574
+ return { envToPass, explicitNames };
460
575
  }
461
576
 
462
- _warnMissingProviderCredentials(providerName, mountedHosts, config, containerHome) {
577
+ /**
578
+ * Decide whether the running provider has usable credentials in the *effective* container plan
579
+ * (what would actually be mounted/forwarded, with actual values), not host presence. Providers
580
+ * with no `docker.mount` (env-only, e.g. omp) fail closed — throw with remediation and never
581
+ * fall back to another provider. All other providers keep today's non-fatal warning.
582
+ *
583
+ * A credential counts when ALL of the following hold:
584
+ * - it is a registry-known credential env key for this provider, AND
585
+ * - it is in the provider's automatic allowlist (`docker.envAuth.requireOneOf`) OR the user
586
+ * explicitly opted it in (dockerEnvPassthrough / an explicitly listed preset), AND
587
+ * - its forwarded value is non-empty/non-whitespace, AND
588
+ * - if that value is an absolute container path (a *path* credential), an explicitly
589
+ * configured mount actually provides that path inside the container.
590
+ *
591
+ * Hard plan defects (a partial required pair, a non-http(s) broker URL) are never compensated
592
+ * for by another credential.
593
+ *
594
+ * Error/warning text names variables and paths from the *configuration*, never a forwarded
595
+ * value.
596
+ *
597
+ * @param {string} providerName
598
+ * @param {{mountedHosts: string[], explicitMountContainerPaths: string[],
599
+ * forwardedEnv: Record<string, string>, explicitEnvNames: Set<string>,
600
+ * config: object, containerHome: string}} plan
601
+ */
602
+ _assertProviderCredentialPlan(
603
+ providerName,
604
+ {
605
+ mountedHosts,
606
+ explicitMountContainerPaths = [],
607
+ forwardedEnv,
608
+ explicitEnvNames = new Set(),
609
+ config,
610
+ containerHome,
611
+ }
612
+ ) {
463
613
  if (providerName === 'claude') {
464
614
  return;
465
615
  }
466
616
 
467
617
  const metadata = getProviderMetadata(providerName);
468
618
  const provider = getProvider(providerName);
469
-
470
- // An env token (e.g. COPILOT_GITHUB_TOKEN) is a complete credential on its own.
471
- const credentialEnvKeys = metadata.credentialEnvKeys || [];
472
- if (credentialEnvKeys.some((key) => process.env[key])) {
473
- return;
474
- }
619
+ const docker = metadata.docker || {};
620
+
621
+ // Structural env/broker validation: required-pair completeness and URL shape.
622
+ const envAuthResult = validateProviderEnvAuth(providerName, forwardedEnv);
623
+ const { satisfying, notOptedIn, unmountedPath } = this._classifyForwardedCredentials(metadata, {
624
+ forwardedEnv,
625
+ explicitEnvNames,
626
+ explicitMountContainerPaths,
627
+ });
475
628
 
476
629
  // A mount only counts if it carries the secret (credentialInMount !== false).
477
- const credentialInMount = metadata.docker && metadata.docker.credentialInMount === false;
478
630
  const credentialPaths = provider.getCredentialPaths ? provider.getCredentialPaths() : [];
479
631
  const expandedCreds = credentialPaths.map((cred) => expandHomePath(cred));
480
632
  const hasCredentialMount =
481
- !credentialInMount &&
633
+ docker.credentialInMount !== false &&
482
634
  mountedHosts.some((hostPath) =>
483
635
  expandedCreds.some(
484
636
  (credPath) => pathContains(hostPath, credPath) || pathContains(credPath, hostPath)
485
637
  )
486
638
  );
487
- if (hasCredentialMount) {
639
+
640
+ if (envAuthResult.malformed.length === 0 && (satisfying.length > 0 || hasCredentialMount)) {
488
641
  return;
489
642
  }
490
643
 
491
- if (credentialInMount && credentialEnvKeys.length > 0) {
492
- console.warn(
493
- `[IsolationManager] ⚠️ ${provider.displayName} could not find credentials for Docker. ` +
494
- `Its login token is not stored in a mountable file export one of ` +
495
- `${credentialEnvKeys.join(', ')} before running with --docker.`
644
+ const reasons = [...envAuthResult.malformed];
645
+ if (unmountedPath.length > 0) {
646
+ reasons.push(
647
+ `${unmountedPath.join(', ')} points at a container path that no explicit --mount provides`
496
648
  );
497
- return;
498
649
  }
650
+ if (notOptedIn.length > 0) {
651
+ reasons.push(
652
+ `${notOptedIn.join(', ')} (known ${provider.displayName} credentials outside the ` +
653
+ 'automatic allowlist) were not explicitly opted in'
654
+ );
655
+ }
656
+ if (reasons.length === 0) {
657
+ reasons.push('no credential env var or mount found in the effective container plan');
658
+ }
659
+
660
+ const mountNote = config.noMounts ? 'Credential mounts are disabled. ' : '';
661
+ const allowlist = docker.envPassthrough || [];
662
+ const message =
663
+ `${mountNote}No usable credentials found for ${provider.displayName} in the effective ` +
664
+ `Docker env/mount plan (${reasons.join('; ')}). Automatic env allowlist: ` +
665
+ `${allowlist.join(', ') || '(none)'}. ` +
666
+ this._credentialRemediation(provider, docker, credentialPaths, containerHome);
667
+
668
+ // Env-only providers (no automatic mount) have no fallback credential surface, so an
669
+ // unsatisfied/malformed plan must fail closed before the container ever starts.
670
+ if (!docker.mount) {
671
+ throw new Error(`[IsolationManager] ${message}`);
672
+ }
673
+
674
+ console.warn(`[IsolationManager] ⚠️ ${message}`);
675
+ }
676
+
677
+ /**
678
+ * Split this provider's registry-known credential env vars into the ones that actually
679
+ * authenticate the container and the two ways they can fail to.
680
+ *
681
+ * @param {object} metadata - registry provider metadata
682
+ * @param {{forwardedEnv: Record<string, string>, explicitEnvNames: Set<string>,
683
+ * explicitMountContainerPaths: string[]}} plan
684
+ * @returns {{satisfying: string[], notOptedIn: string[], unmountedPath: string[]}}
685
+ * `notOptedIn`: a known credential carrying a real value that is neither on the automatic
686
+ * allowlist nor explicitly passed through. `unmountedPath`: a path credential whose container
687
+ * path no explicit mount provides — the file simply is not there.
688
+ */
689
+ _classifyForwardedCredentials(
690
+ metadata,
691
+ { forwardedEnv, explicitEnvNames, explicitMountContainerPaths }
692
+ ) {
693
+ const docker = metadata.docker || {};
694
+ const envAuth = docker.envAuth;
695
+ const automatic = new Set(envAuth ? envAuth.requireOneOf : []);
696
+ const usable = (metadata.credentialEnvKeys || []).filter((key) =>
697
+ isUsableEnvValue(forwardedEnv[key])
698
+ );
699
+
700
+ const satisfying = [];
701
+ const notOptedIn = [];
702
+ const unmountedPath = [];
499
703
 
500
- if (expandedCreds.length > 0) {
501
- const exampleHost = credentialPaths[0];
502
- const exampleContainer = exampleHost.replace(/^~(?=\/|$)/, containerHome);
503
- const mountNote = config.noMounts ? 'Credential mounts are disabled. ' : '';
504
- console.warn(
505
- `[IsolationManager] ⚠️ ${mountNote}No credential mounts found for ${provider.displayName}. ` +
506
- `Add one with --mount ${exampleHost}:${exampleContainer}:ro`
704
+ for (const name of usable) {
705
+ // A registry-known credential outside the automatic allowlist is usable only when explicitly
706
+ // opted in that is the "custom env credential requires explicit passthrough" rule, not a
707
+ // reason for it to stay permanently unusable.
708
+ if (envAuth && !automatic.has(name) && !explicitEnvNames.has(name)) {
709
+ notOptedIn.push(name);
710
+ continue;
711
+ }
712
+ // A path credential (value is an absolute container path) is only real when an explicitly
713
+ // configured mount actually provides that path. Host-side existence proves nothing about
714
+ // what the container can read.
715
+ const value = forwardedEnv[name];
716
+ const isContainerPath = value.startsWith('/');
717
+ if (
718
+ isContainerPath &&
719
+ !explicitMountContainerPaths.some((containerPath) => pathContains(containerPath, value))
720
+ ) {
721
+ unmountedPath.push(name);
722
+ continue;
723
+ }
724
+ satisfying.push(name);
725
+ }
726
+
727
+ return { satisfying, notOptedIn, unmountedPath };
728
+ }
729
+
730
+ /**
731
+ * Remediation sentence for a provider with no usable credential plan.
732
+ *
733
+ * Env-only providers (no `docker.mount`) must NEVER be told to mount their host auth store —
734
+ * not mounting/copying it is the whole point of their Docker contract. They get the broker
735
+ * pair (when the registry declares one) plus a generic custom-path example instead. Mount-based
736
+ * providers keep the concrete "mount your credential dir" hint.
737
+ *
738
+ * @param {{displayName: string}} provider
739
+ * @param {object} docker - registry docker metadata
740
+ * @param {readonly string[]} credentialPaths
741
+ * @param {string} containerHome
742
+ * @returns {string}
743
+ */
744
+ _credentialRemediation(provider, docker, credentialPaths, containerHome) {
745
+ const customEnvHint =
746
+ `Any other credential needs explicit opt-in: ` +
747
+ `zeroshot settings set dockerEnvPassthrough '["MY_KEY"]'`;
748
+ const genericPathHint =
749
+ `for a file credential also mount it and point the var at the container path: ` +
750
+ `--mount /host/path/to/credential:${path.posix.join(containerHome, 'credential')}:ro ` +
751
+ `with dockerEnvPassthrough '["MY_PATH_CREDENTIAL=${path.posix.join(containerHome, 'credential')}"]'`;
752
+
753
+ if (!docker.mount) {
754
+ const brokerPair =
755
+ (docker.envAuth && docker.envAuth.requireTogether && docker.envAuth.requireTogether[0]) ||
756
+ null;
757
+ const brokerHint = brokerPair
758
+ ? `Prefer the auth broker (${brokerPair.join(' + ')}) so host refresh tokens never cross. `
759
+ : '';
760
+ return (
761
+ `Export one of the listed vars. ${brokerHint}${customEnvHint}, or ${genericPathHint}. ` +
762
+ `${provider.displayName}'s host auth store is never mounted or copied into the container.`
507
763
  );
508
764
  }
765
+
766
+ const exampleHost = credentialPaths[0];
767
+ const mountHint = exampleHost
768
+ ? ` or --mount ${exampleHost}:${exampleHost.replace(/^~(?=\/|$)/, containerHome)}:ro for a custom path credential`
769
+ : '';
770
+ return `Export one of the listed vars, or add a custom credential with ${customEnvHint}${mountHint}.`;
509
771
  }
510
772
 
511
773
  _spawnContainer(clusterId, args, workDir) {
@@ -1374,40 +1636,207 @@ class IsolationManager {
1374
1636
  }
1375
1637
  }
1376
1638
 
1639
+ /**
1640
+ * Registry-owned Docker platform for a provider (e.g. 'linux/amd64'), or null when the
1641
+ * provider declares no `docker.platform` (host-native, unset `--platform`).
1642
+ * @param {string} providerName
1643
+ * @returns {string|null}
1644
+ */
1645
+ static providerDockerPlatform(providerName) {
1646
+ return providerDockerPlatform(providerName);
1647
+ }
1648
+
1649
+ /**
1650
+ * Registry-owned config/overlay roots for a provider, with $HOME expanded to containerHome.
1651
+ * @param {string} providerName
1652
+ * @param {string} [containerHome]
1653
+ * @returns {string[]}
1654
+ */
1655
+ static providerConfigRoots(providerName, containerHome = '/root') {
1656
+ return providerDockerConfigRootsRaw(providerName).map((root) =>
1657
+ root.replace(/\$HOME/g, containerHome)
1658
+ );
1659
+ }
1660
+
1661
+ /**
1662
+ * Parse the exact platform tokens advertised by `docker buildx inspect`.
1663
+ *
1664
+ * The `Platforms:` line is comma-delimited, and buildx marks preferred entries with a trailing
1665
+ * `*`. Tokens are returned verbatim (minus that marker) so callers can compare exactly:
1666
+ * `linux/amd64/v2` is a *variant*, not `linux/amd64`, and a substring test would wrongly accept
1667
+ * a builder that only advertises the variant. Multiple builder nodes each emit their own
1668
+ * `Platforms:` line; all are collected.
1669
+ *
1670
+ * @param {string} buildxOutput
1671
+ * @returns {string[]}
1672
+ */
1673
+ static parseBuildxPlatforms(buildxOutput) {
1674
+ const platforms = [];
1675
+ for (const line of (buildxOutput || '').split('\n')) {
1676
+ const trimmed = line.trim();
1677
+ if (!trimmed.startsWith('Platforms:')) {
1678
+ continue;
1679
+ }
1680
+ for (const token of trimmed.slice('Platforms:'.length).split(',')) {
1681
+ const platform = token.trim().replace(/\*$/, '');
1682
+ if (platform) {
1683
+ platforms.push(platform);
1684
+ }
1685
+ }
1686
+ }
1687
+ return platforms;
1688
+ }
1689
+
1690
+ /**
1691
+ * Pre-effect probe: throws before any workspace/container side effect when the Docker engine
1692
+ * cannot run `platform`. No-op when `platform` is null (provider declares no platform
1693
+ * requirement). Native arch match satisfies the platform directly; otherwise a Buildx builder
1694
+ * advertising exactly that platform (emulation) is required.
1695
+ * @param {string|null} platform
1696
+ * @param {{info?: () => string, buildxInspect?: () => string}} [probe] - command runners,
1697
+ * injectable for tests; defaults to the real `docker info` / `docker buildx inspect`
1698
+ */
1699
+ static assertPlatformSupported(platform, probe = {}) {
1700
+ if (!platform) {
1701
+ return;
1702
+ }
1703
+
1704
+ const runInfo =
1705
+ probe.info ||
1706
+ (() =>
1707
+ runSync('docker', ['info', '--format', '{{.OSType}}|{{.Architecture}}'], {
1708
+ encoding: 'utf8',
1709
+ stdio: 'pipe',
1710
+ }));
1711
+ const runBuildxInspect =
1712
+ probe.buildxInspect ||
1713
+ (() => runSync('docker', ['buildx', 'inspect'], { encoding: 'utf8', stdio: 'pipe' }));
1714
+
1715
+ let info;
1716
+ try {
1717
+ info = String(runInfo()).trim();
1718
+ } catch (err) {
1719
+ throw new Error(`Cannot determine Docker engine platform: ${err.message}`);
1720
+ }
1721
+
1722
+ const [osType, arch] = info.split('|').map((part) => (part || '').trim());
1723
+ const requiredArch = platform.split('/')[1] || '';
1724
+ const nativeMatch = arch === requiredArch || (requiredArch === 'amd64' && arch === 'x86_64');
1725
+
1726
+ if (osType === 'linux' && nativeMatch) {
1727
+ return;
1728
+ }
1729
+
1730
+ let buildxOutput = '';
1731
+ if (osType === 'linux') {
1732
+ try {
1733
+ buildxOutput = String(runBuildxInspect());
1734
+ } catch {
1735
+ buildxOutput = '';
1736
+ }
1737
+ }
1738
+
1739
+ if (
1740
+ osType === 'linux' &&
1741
+ IsolationManager.parseBuildxPlatforms(buildxOutput).includes(platform)
1742
+ ) {
1743
+ return;
1744
+ }
1745
+
1746
+ throw new Error(
1747
+ `Docker engine cannot run ${platform} (server ${osType || 'unknown'}/${arch || 'unknown'}, ` +
1748
+ `no buildx builder advertising ${platform}). Install Buildx and run: ` +
1749
+ `docker run --privileged --rm tonistiigi/binfmt --install ${requiredArch || platform}`
1750
+ );
1751
+ }
1752
+
1753
+ /**
1754
+ * Split a Docker image reference into `{name, tag, digest}` per the reference grammar
1755
+ * `[registry[:port]/]name[:tag][@digest]`.
1756
+ *
1757
+ * The only ambiguity is `:` — it is a registry port when it appears before the last `/`, and a
1758
+ * tag separator only when it appears after it. `registry.example:5000/base` therefore has no
1759
+ * tag, while `base:v2` does.
1760
+ *
1761
+ * @param {string} reference
1762
+ * @returns {{name: string, tag: string|null, digest: string|null}}
1763
+ */
1764
+ static parseImageReference(reference) {
1765
+ const atIndex = reference.indexOf('@');
1766
+ const digest = atIndex === -1 ? null : reference.slice(atIndex + 1);
1767
+ const withoutDigest = atIndex === -1 ? reference : reference.slice(0, atIndex);
1768
+
1769
+ const lastSlash = withoutDigest.lastIndexOf('/');
1770
+ const colonIndex = withoutDigest.indexOf(':', lastSlash + 1);
1771
+ const name = colonIndex === -1 ? withoutDigest : withoutDigest.slice(0, colonIndex);
1772
+ const tag = colonIndex === -1 ? null : withoutDigest.slice(colonIndex + 1);
1773
+
1774
+ return { name, tag, digest };
1775
+ }
1776
+
1377
1777
  /**
1378
1778
  * Resolve the cluster image tag for a provider. Providers baked into the base image (e.g.
1379
1779
  * Claude) run on the base image directly; providers with a `docker.install` command get a
1380
- * per-provider image variant `<baseImage>-<providerId>` whose install step is a Docker-cached
1381
- * layer (built once, reused thereafter).
1780
+ * per-provider image variant `<baseName>-<providerId>-<hash>`.
1781
+ *
1782
+ * `<baseName>` is the base reference's NAME only. A tag or digest may not be carried over: the
1783
+ * derived value is a new locally-built tag, and `registry/base@sha256:…-omp-<hash>` /
1784
+ * `base:v2-omp-<hash>` are not valid references (the first is a malformed digest, the second
1785
+ * silently reinterprets the tag). A registry port is preserved because it belongs to the name
1786
+ * (`registry.example:5000/base` → `registry.example:5000/base-omp-<hash>`).
1787
+ *
1788
+ * `<hash>` covers the FULL base reference (tag and digest included) alongside the install
1789
+ * command and platform, so two different pins of the same base name — or a pinned-version or
1790
+ * platform change — never collide on one cached tag.
1791
+ *
1382
1792
  * @param {string} providerName
1383
1793
  * @param {string} [baseImage]
1384
1794
  * @returns {string}
1385
1795
  */
1386
1796
  static imageForProvider(providerName, baseImage = DEFAULT_IMAGE) {
1387
- if (!providerDockerInstall(providerName)) {
1797
+ const install = providerDockerInstall(providerName);
1798
+ if (!install) {
1388
1799
  return baseImage;
1389
1800
  }
1390
- return `${baseImage}-${normalizeProviderName(providerName)}`;
1801
+ const platform = providerDockerPlatform(providerName) || '';
1802
+ const { name } = IsolationManager.parseImageReference(baseImage);
1803
+ const hash = crypto
1804
+ .createHash('sha256')
1805
+ .update(`${baseImage}\n${platform}\n${install}`)
1806
+ .digest('hex')
1807
+ .slice(0, 12);
1808
+ return `${name}-${normalizeProviderName(providerName)}-${hash}`;
1391
1809
  }
1392
1810
 
1393
1811
  /**
1394
- * Docker `--build-arg` values that install a provider's CLI into its image variant, or [] when
1395
- * the provider is baked into the base image or has no installer.
1812
+ * Docker `--build-arg` values that install a provider's CLI (and create its config roots) in
1813
+ * its image variant, or [] when the provider is baked into the base image or has no installer.
1396
1814
  * @param {string} providerName
1815
+ * @param {string} [containerHome]
1397
1816
  * @returns {string[]}
1398
1817
  */
1399
- static providerBuildArgs(providerName) {
1818
+ static providerBuildArgs(providerName, containerHome = '/home/node') {
1400
1819
  const install = providerDockerInstall(providerName);
1401
- return install ? [`PROVIDER_INSTALL=${install}`] : [];
1820
+ if (!install) {
1821
+ return [];
1822
+ }
1823
+ const args = [`PROVIDER_INSTALL=${install}`];
1824
+ const configRoots = IsolationManager.providerConfigRoots(providerName, containerHome);
1825
+ if (configRoots.length > 0) {
1826
+ args.push(`PROVIDER_CONFIG_ROOTS=${configRoots.join(' ')}`);
1827
+ }
1828
+ return args;
1402
1829
  }
1403
1830
 
1404
1831
  /**
1405
1832
  * Build the Docker image with retry logic
1406
1833
  * @param {string} [image] - Image name to build
1407
1834
  * @param {number} [maxRetries=3] - Maximum retry attempts
1835
+ * @param {string[]} [buildArgs] - `--build-arg KEY=VALUE` pairs
1836
+ * @param {string|null} [platform] - Registry-owned `--platform` value, or null for host-native
1408
1837
  * @returns {Promise<void>}
1409
1838
  */
1410
- static async buildImage(image = DEFAULT_IMAGE, maxRetries = 3, buildArgs = []) {
1839
+ static async buildImage(image = DEFAULT_IMAGE, maxRetries = 3, buildArgs = [], platform = null) {
1411
1840
  // Repository root is one level up from src/
1412
1841
  const repoRoot = path.join(__dirname, '..');
1413
1842
  const dockerfilePath = path.join(repoRoot, 'docker', 'zeroshot-cluster', 'Dockerfile');
@@ -1421,6 +1850,7 @@ class IsolationManager {
1421
1850
  for (const arg of buildArgs) {
1422
1851
  buildArgFlags.push('--build-arg', arg);
1423
1852
  }
1853
+ const platformFlags = platform ? ['--platform', platform] : [];
1424
1854
 
1425
1855
  console.log(`[IsolationManager] Building Docker image '${image}'...`);
1426
1856
 
@@ -1432,7 +1862,16 @@ class IsolationManager {
1432
1862
  // Use -f flag to specify Dockerfile location
1433
1863
  runSync(
1434
1864
  'docker',
1435
- ['build', '-f', 'docker/zeroshot-cluster/Dockerfile', ...buildArgFlags, '-t', image, '.'],
1865
+ [
1866
+ 'build',
1867
+ '-f',
1868
+ 'docker/zeroshot-cluster/Dockerfile',
1869
+ ...platformFlags,
1870
+ ...buildArgFlags,
1871
+ '-t',
1872
+ image,
1873
+ '.',
1874
+ ],
1436
1875
  {
1437
1876
  cwd: repoRoot,
1438
1877
  encoding: 'utf8',
@@ -1466,9 +1905,16 @@ class IsolationManager {
1466
1905
  * Ensure Docker image exists, building it if necessary
1467
1906
  * @param {string} [image] - Image name to ensure
1468
1907
  * @param {boolean} [autoBuild=true] - Auto-build if missing
1908
+ * @param {string[]} [buildArgs] - `--build-arg KEY=VALUE` pairs
1909
+ * @param {string|null} [platform] - Registry-owned `--platform` value, or null for host-native
1469
1910
  * @returns {Promise<void>}
1470
1911
  */
1471
- static async ensureImage(image = DEFAULT_IMAGE, autoBuild = true, buildArgs = []) {
1912
+ static async ensureImage(
1913
+ image = DEFAULT_IMAGE,
1914
+ autoBuild = true,
1915
+ buildArgs = [],
1916
+ platform = null
1917
+ ) {
1472
1918
  if (this.imageExists(image)) {
1473
1919
  return;
1474
1920
  }
@@ -1481,7 +1927,7 @@ class IsolationManager {
1481
1927
  }
1482
1928
 
1483
1929
  console.log(`[IsolationManager] Image '${image}' not found, building automatically...`);
1484
- await this.buildImage(image, 3, buildArgs);
1930
+ await this.buildImage(image, 3, buildArgs, platform);
1485
1931
  }
1486
1932
 
1487
1933
  /**