@provos/ironcurtain 0.12.0 → 0.13.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 (211) hide show
  1. package/README.md +14 -12
  2. package/dist/cli.js +6 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config/config-command.d.ts +33 -1
  5. package/dist/config/config-command.js +608 -11
  6. package/dist/config/config-command.js.map +1 -1
  7. package/dist/config/openrouter-catalog.d.ts +46 -0
  8. package/dist/config/openrouter-catalog.js +111 -0
  9. package/dist/config/openrouter-catalog.js.map +1 -0
  10. package/dist/config/openrouter-models-fallback.d.ts +15 -0
  11. package/dist/config/openrouter-models-fallback.js +49 -0
  12. package/dist/config/openrouter-models-fallback.js.map +1 -0
  13. package/dist/config/types.d.ts +8 -0
  14. package/dist/config/user-config.d.ts +133 -7
  15. package/dist/config/user-config.js +234 -5
  16. package/dist/config/user-config.js.map +1 -1
  17. package/dist/daemon/daemon-command.js.map +1 -1
  18. package/dist/daemon/ironcurtain-daemon.js +6 -0
  19. package/dist/daemon/ironcurtain-daemon.js.map +1 -1
  20. package/dist/docker/adapters/claude-code.js +109 -1
  21. package/dist/docker/adapters/claude-code.js.map +1 -1
  22. package/dist/docker/adapters/codex.d.ts +10 -1
  23. package/dist/docker/adapters/codex.js +99 -20
  24. package/dist/docker/adapters/codex.js.map +1 -1
  25. package/dist/docker/adapters/goose.js +38 -2
  26. package/dist/docker/adapters/goose.js.map +1 -1
  27. package/dist/docker/agent-adapter.d.ts +29 -5
  28. package/dist/docker/agent-registry.js +1 -1
  29. package/dist/docker/agent-registry.js.map +1 -1
  30. package/dist/docker/apple-container-manager.d.ts +4 -4
  31. package/dist/docker/apple-container-manager.js +44 -22
  32. package/dist/docker/apple-container-manager.js.map +1 -1
  33. package/dist/docker/ca.js +9 -2
  34. package/dist/docker/ca.js.map +1 -1
  35. package/dist/docker/container-lifecycle.js +9 -0
  36. package/dist/docker/container-lifecycle.js.map +1 -1
  37. package/dist/docker/docker-agent-session.d.ts +31 -0
  38. package/dist/docker/docker-agent-session.js +67 -6
  39. package/dist/docker/docker-agent-session.js.map +1 -1
  40. package/dist/docker/docker-infrastructure.d.ts +55 -15
  41. package/dist/docker/docker-infrastructure.js +261 -61
  42. package/dist/docker/docker-infrastructure.js.map +1 -1
  43. package/dist/docker/docker-manager.js +127 -0
  44. package/dist/docker/docker-manager.js.map +1 -1
  45. package/dist/docker/docker-resource-lifecycle.d.ts +71 -0
  46. package/dist/docker/docker-resource-lifecycle.js +454 -0
  47. package/dist/docker/docker-resource-lifecycle.js.map +1 -0
  48. package/dist/docker/gc-command.d.ts +2 -0
  49. package/dist/docker/gc-command.js +37 -0
  50. package/dist/docker/gc-command.js.map +1 -0
  51. package/dist/docker/mitm-proxy.d.ts +6 -4
  52. package/dist/docker/mitm-proxy.js +105 -17
  53. package/dist/docker/mitm-proxy.js.map +1 -1
  54. package/dist/docker/network-topology.d.ts +16 -9
  55. package/dist/docker/network-topology.js +17 -10
  56. package/dist/docker/network-topology.js.map +1 -1
  57. package/dist/docker/openrouter.d.ts +107 -0
  58. package/dist/docker/openrouter.js +236 -0
  59. package/dist/docker/openrouter.js.map +1 -0
  60. package/dist/docker/orientation.js +1 -1
  61. package/dist/docker/orientation.js.map +1 -1
  62. package/dist/docker/platform.d.ts +3 -1
  63. package/dist/docker/platform.js +3 -1
  64. package/dist/docker/platform.js.map +1 -1
  65. package/dist/docker/provider-config.d.ts +6 -0
  66. package/dist/docker/provider-config.js.map +1 -1
  67. package/dist/docker/pty-session.d.ts +7 -0
  68. package/dist/docker/pty-session.js +111 -29
  69. package/dist/docker/pty-session.js.map +1 -1
  70. package/dist/docker/pty-types.d.ts +15 -0
  71. package/dist/docker/pty-types.js +8 -0
  72. package/dist/docker/pty-types.js.map +1 -1
  73. package/dist/docker/sse-extractor.js +55 -5
  74. package/dist/docker/sse-extractor.js.map +1 -1
  75. package/dist/docker/stream-delay.d.ts +86 -0
  76. package/dist/docker/stream-delay.js +218 -0
  77. package/dist/docker/stream-delay.js.map +1 -0
  78. package/dist/docker/token-stream-types.d.ts +15 -0
  79. package/dist/docker/trajectory-reassembler.d.ts +2 -2
  80. package/dist/docker/trajectory-reassembler.js +22 -2
  81. package/dist/docker/trajectory-reassembler.js.map +1 -1
  82. package/dist/docker/trajectory-tap.js +2 -2
  83. package/dist/docker/trajectory-tap.js.map +1 -1
  84. package/dist/docker/types.d.ts +63 -5
  85. package/dist/doctor/checks.d.ts +3 -3
  86. package/dist/doctor/checks.js +28 -11
  87. package/dist/doctor/checks.js.map +1 -1
  88. package/dist/doctor/doctor-command.js +1 -1
  89. package/dist/doctor/doctor-command.js.map +1 -1
  90. package/dist/escalation/escalation-watcher.js +6 -0
  91. package/dist/escalation/escalation-watcher.js.map +1 -1
  92. package/dist/{mux → escalation}/trusted-input.d.ts +7 -4
  93. package/dist/{mux → escalation}/trusted-input.js +8 -5
  94. package/dist/escalation/trusted-input.js.map +1 -0
  95. package/dist/hash.js.map +1 -1
  96. package/dist/index.js +37 -6
  97. package/dist/index.js.map +1 -1
  98. package/dist/mux/mux-app.d.ts +7 -0
  99. package/dist/mux/mux-app.js +17 -16
  100. package/dist/mux/mux-app.js.map +1 -1
  101. package/dist/mux/mux-command.js +8 -3
  102. package/dist/mux/mux-command.js.map +1 -1
  103. package/dist/mux/mux-input-handler.d.ts +26 -2
  104. package/dist/mux/mux-input-handler.js +93 -11
  105. package/dist/mux/mux-input-handler.js.map +1 -1
  106. package/dist/mux/mux-renderer.d.ts +2 -1
  107. package/dist/mux/mux-renderer.js +67 -0
  108. package/dist/mux/mux-renderer.js.map +1 -1
  109. package/dist/mux/provider-profile-snapshot.d.ts +44 -0
  110. package/dist/mux/provider-profile-snapshot.js +56 -0
  111. package/dist/mux/provider-profile-snapshot.js.map +1 -0
  112. package/dist/mux/types.d.ts +3 -2
  113. package/dist/mux/types.js +6 -2
  114. package/dist/mux/types.js.map +1 -1
  115. package/dist/observe/observe-command.js.map +1 -1
  116. package/dist/pipeline/constitution-compiler.js.map +1 -1
  117. package/dist/pipeline/generate-with-repair.js.map +1 -1
  118. package/dist/pipeline/refresh-lists.js.map +1 -1
  119. package/dist/pipeline/scenario-schema-validator.js.map +1 -1
  120. package/dist/{mux → pty}/pty-bridge.d.ts +39 -3
  121. package/dist/{mux → pty}/pty-bridge.js +68 -18
  122. package/dist/pty/pty-bridge.js.map +1 -0
  123. package/dist/pty/resolve-ironcurtain-bin.d.ts +17 -0
  124. package/dist/pty/resolve-ironcurtain-bin.js +22 -0
  125. package/dist/pty/resolve-ironcurtain-bin.js.map +1 -0
  126. package/dist/servers/fetch-server.js +2 -0
  127. package/dist/servers/fetch-server.js.map +1 -1
  128. package/dist/session/index.d.ts +1 -1
  129. package/dist/session/index.js +11 -2
  130. package/dist/session/index.js.map +1 -1
  131. package/dist/session/preflight.d.ts +19 -5
  132. package/dist/session/preflight.js +41 -25
  133. package/dist/session/preflight.js.map +1 -1
  134. package/dist/session/resource-budget-tracker.js +8 -0
  135. package/dist/session/resource-budget-tracker.js.map +1 -1
  136. package/dist/session/session-manager.d.ts +7 -0
  137. package/dist/session/session-manager.js.map +1 -1
  138. package/dist/session/types.d.ts +14 -0
  139. package/dist/signal/signal-bot-daemon.d.ts +1 -0
  140. package/dist/signal/signal-bot-daemon.js +9 -2
  141. package/dist/signal/signal-bot-daemon.js.map +1 -1
  142. package/dist/trusted-process/mcp-error-utils.js +4 -1
  143. package/dist/trusted-process/mcp-error-utils.js.map +1 -1
  144. package/dist/trusted-process/mcp-proxy-server.js +2 -0
  145. package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
  146. package/dist/trusted-process/policy-engine.js.map +1 -1
  147. package/dist/trusted-process/tcp-server-transport.d.ts +7 -0
  148. package/dist/trusted-process/tcp-server-transport.js +48 -10
  149. package/dist/trusted-process/tcp-server-transport.js.map +1 -1
  150. package/dist/utils/preflight-checks.js +1 -3
  151. package/dist/utils/preflight-checks.js.map +1 -1
  152. package/dist/utils/stdio-guard.d.ts +1 -0
  153. package/dist/utils/stdio-guard.js +27 -0
  154. package/dist/utils/stdio-guard.js.map +1 -0
  155. package/dist/web-ui/__tests__/config-dispatch.test.d.ts +13 -0
  156. package/dist/web-ui/__tests__/config-dispatch.test.js +383 -0
  157. package/dist/web-ui/__tests__/config-dispatch.test.js.map +1 -0
  158. package/dist/web-ui/__tests__/json-rpc-dispatch.test.js +6 -0
  159. package/dist/web-ui/__tests__/json-rpc-dispatch.test.js.map +1 -1
  160. package/dist/web-ui/dispatch/config-dispatch.d.ts +15 -0
  161. package/dist/web-ui/dispatch/config-dispatch.js +277 -0
  162. package/dist/web-ui/dispatch/config-dispatch.js.map +1 -0
  163. package/dist/web-ui/dispatch/escalation-dispatch.js +11 -2
  164. package/dist/web-ui/dispatch/escalation-dispatch.js.map +1 -1
  165. package/dist/web-ui/dispatch/pty-dispatch.d.ts +15 -0
  166. package/dist/web-ui/dispatch/pty-dispatch.js +66 -0
  167. package/dist/web-ui/dispatch/pty-dispatch.js.map +1 -0
  168. package/dist/web-ui/dispatch/session-dispatch.js +69 -4
  169. package/dist/web-ui/dispatch/session-dispatch.js.map +1 -1
  170. package/dist/web-ui/dispatch/types.d.ts +12 -0
  171. package/dist/web-ui/dispatch/types.js +38 -1
  172. package/dist/web-ui/dispatch/types.js.map +1 -1
  173. package/dist/web-ui/dispatch/workflow-dispatch.js.map +1 -1
  174. package/dist/web-ui/json-rpc-dispatch.js +3 -0
  175. package/dist/web-ui/json-rpc-dispatch.js.map +1 -1
  176. package/dist/web-ui/pty-session-manager.d.ts +234 -0
  177. package/dist/web-ui/pty-session-manager.js +599 -0
  178. package/dist/web-ui/pty-session-manager.js.map +1 -0
  179. package/dist/web-ui/web-event-bus.d.ts +9 -0
  180. package/dist/web-ui/web-event-bus.js.map +1 -1
  181. package/dist/web-ui/web-session-transport.js.map +1 -1
  182. package/dist/web-ui/web-ui-server.d.ts +8 -0
  183. package/dist/web-ui/web-ui-server.js +25 -0
  184. package/dist/web-ui/web-ui-server.js.map +1 -1
  185. package/dist/web-ui/web-ui-types.d.ts +80 -1
  186. package/dist/web-ui/web-ui-types.js.map +1 -1
  187. package/dist/web-ui-static/assets/index-CCVI4nEK.css +1 -0
  188. package/dist/web-ui-static/assets/index-Dm012gvY.js +114 -0
  189. package/dist/web-ui-static/index.html +2 -2
  190. package/dist/workflow/daemon-gate-commands.js +38 -42
  191. package/dist/workflow/daemon-gate-commands.js.map +1 -1
  192. package/dist/workflow/machine-builder.js.map +1 -1
  193. package/dist/workflow/orchestrator.js +10 -7
  194. package/dist/workflow/orchestrator.js.map +1 -1
  195. package/dist/workflow/shutdown-signals.d.ts +7 -0
  196. package/dist/workflow/shutdown-signals.js +40 -0
  197. package/dist/workflow/shutdown-signals.js.map +1 -0
  198. package/dist/workflow/validate.js.map +1 -1
  199. package/dist/workflow/workflow-command.js +10 -8
  200. package/dist/workflow/workflow-command.js.map +1 -1
  201. package/dist/workflow/workflows/evolve/README.md +66 -0
  202. package/dist/workflow/workflows/vuln-discovery/README.md +54 -0
  203. package/docker/entrypoint-claude-code.sh +22 -0
  204. package/package.json +31 -27
  205. package/src/workflow/workflows/evolve/README.md +66 -0
  206. package/src/workflow/workflows/vuln-discovery/README.md +54 -0
  207. package/dist/mux/pty-bridge.js.map +0 -1
  208. package/dist/mux/trusted-input.js.map +0 -1
  209. package/dist/web-ui-static/assets/index-BD5U6ILh.css +0 -1
  210. package/dist/web-ui-static/assets/index-FMsHWEfK.js +0 -79
  211. package/packages/web-ui/README.md +0 -113
@@ -15,14 +15,17 @@ import { getBundleRuntimeRoot } from '../config/paths.js';
15
15
  import { getBundleShortId } from '../session/types.js';
16
16
  import { DEFAULT_CONTAINER_SCOPE } from '../workflow/types.js';
17
17
  import { CONTAINER_SCRIPTS_DIR, CONTAINER_WORKSPACE_DIR, } from './agent-adapter.js';
18
+ import { OPENROUTER_HOST, resolveActiveProfile } from '../config/user-config.js';
18
19
  import { parseUpstreamBaseUrl } from './provider-config.js';
19
20
  import { getInternalNetworkName } from './platform.js';
20
21
  import { cleanupContainers } from './container-lifecycle.js';
22
+ import { createIronCurtainInternalNetwork, InternalNetworkConnectivityError, managedResourceLabels, reconcileIronCurtainDockerResourcesBestEffort, releaseManagedResourceLease, withInternalNetworkAllocationRetry, } from './docker-resource-lifecycle.js';
21
23
  import { clampDockerResources } from './resource-limits.js';
22
24
  import { errorMessage } from '../utils/error-message.js';
23
25
  import { createCachedStager } from '../skills/staging.js';
24
26
  import { withProvisionLock } from './provision-lock.js';
25
27
  import * as logger from '../logger.js';
28
+ export { InternalNetworkConnectivityError };
26
29
  /**
27
30
  * Create a bundle-owned directory and enforce 0o700 permissions even if
28
31
  * it already exists. `mkdirSync`'s `mode` only applies on creation, so a
@@ -77,7 +80,24 @@ const BUNDLE_SKILLS_SUBDIR = 'skills';
77
80
  const WORKFLOW_PYTHON_VENV_DIR = '/opt/workflow-venv';
78
81
  /** Container path for runtime-provisioned workflow Node dependencies. */
79
82
  const WORKFLOW_NODE_MODULES_DIR = '/opt/workflow-node_modules';
80
- export async function prepareDockerInfrastructure(config, mode, bundleDir, workspaceDir, escalationDir, bundleId, workflowId, scope, resolvedSkills, captureInput, scriptsDir) {
83
+ export async function prepareDockerInfrastructure(config, mode, bundleDir, workspaceDir, escalationDir, bundleId, workflowId, scope, resolvedSkills, captureInput, scriptsDir, providerProfileName) {
84
+ // Resolve and STAMP the active provider profile as the FIRST step (§9.7 F1),
85
+ // before any container-runtime probe, adapter registration, or auth
86
+ // detection. This ordering is load-bearing: Claude Code's
87
+ // detectCredential(config) reads config.activeProviderProfile to return an
88
+ // API-key AuthMethod for an OpenRouter-only user, so the stamp must already
89
+ // be present when auth detection runs below. An unknown providerProfileName
90
+ // throws a clear error listing the available profiles before any expensive
91
+ // work or container launch. Safe to mutate: callers always pass a
92
+ // session-specific config copy (the same invariant the config.dockerAuth
93
+ // stamp below relies on).
94
+ const activeProfile = resolveActiveProfile(config.userConfig.modelProviders, providerProfileName);
95
+ config.activeProviderProfile = activeProfile;
96
+ if (activeProfile.type === 'openrouter' && activeProfile.apiKey === '') {
97
+ const activeName = providerProfileName ?? config.userConfig.modelProviders.default;
98
+ throw new Error(`Provider profile "${activeName}" is OpenRouter but no API key is configured. ` +
99
+ "Set OPENROUTER_API_KEY or the profile's apiKey in ~/.ironcurtain/config.json.");
100
+ }
81
101
  // The audit log path is read from config so the bundle is
82
102
  // self-describing: downstream consumers (AuditLogTailer, sandbox
83
103
  // coordinator) can take it from either `config.auditLogPath` or
@@ -102,9 +122,14 @@ export async function prepareDockerInfrastructure(config, mode, bundleDir, works
102
122
  const topology = resolveNetworkTopology(runtimeKind);
103
123
  const useTcp = topology !== 'uds';
104
124
  // Detect authentication method. Adapters with detectCredential() handle
105
- // their own credential detection (e.g., Goose checks provider-specific keys).
106
- // Adapters without it fall back to detectAuthMethod() (Anthropic OAuth + API key).
107
- const authMethod = adapter.detectCredential ? adapter.detectCredential(config) : await detectAuthMethod(config);
125
+ // their own credential detection (e.g., Goose checks provider-specific keys;
126
+ // Claude Code returns an api-key AuthMethod for an OpenRouter-only profile).
127
+ // A `detectCredential` that returns `undefined` DEFERS to detectAuthMethod()
128
+ // (Anthropic OAuth + API key) — this is how Claude Code preserves today's
129
+ // detection for a native profile (B2a). Adapters without the method also
130
+ // fall back.
131
+ const detected = adapter.detectCredential?.(config);
132
+ const authMethod = detected ?? (await detectAuthMethod(config));
108
133
  if (authMethod.kind === 'none') {
109
134
  throw new Error(adapter.credentialHelpText ??
110
135
  'No credentials available for Docker session. ' +
@@ -181,7 +206,7 @@ export async function prepareDockerInfrastructure(config, mode, bundleDir, works
181
206
  ...tokenManagerCodexDeps,
182
207
  })
183
208
  : undefined;
184
- const providers = adapter.getProviders(authKind);
209
+ const providers = adapter.getProviders(config, authKind);
185
210
  const resolvedProviders = applyUpstreamOverrides(providers, parseUpstreamBaseUrl, {
186
211
  'api.anthropic.com': config.userConfig.anthropicBaseUrl,
187
212
  'api.openai.com': config.userConfig.openaiBaseUrl,
@@ -287,6 +312,14 @@ export async function prepareDockerInfrastructure(config, mode, bundleDir, works
287
312
  else {
288
313
  logger.info(`MITM proxy listening on ${mitmAddr.socketPath}`);
289
314
  }
315
+ // apple-container's `-v <sock>` vsock relay propagates the host
316
+ // socket's mode bits to the guest side (owner is always root there),
317
+ // so the non-root `codespace` user can only connect() when "other"
318
+ // has write. The parent `sockets/` dir is 0o700, so widening the
319
+ // socket mode does not expose it to other host users.
320
+ if (!useTcp && runtimeKind === 'apple-container' && mitmAddr.socketPath !== undefined) {
321
+ chmodSync(mitmAddr.socketPath, 0o666);
322
+ }
290
323
  // Compute control address for the proxy tools MCP server instance
291
324
  const controlAddr = mitmAddr.controlPort !== undefined
292
325
  ? `http://127.0.0.1:${mitmAddr.controlPort}`
@@ -308,6 +341,9 @@ export async function prepareDockerInfrastructure(config, mode, bundleDir, works
308
341
  else {
309
342
  logger.info(`Code Mode proxy listening on ${proxy.socketPath}`);
310
343
  }
344
+ if (!useTcp && runtimeKind === 'apple-container') {
345
+ chmodSync(socketPath, 0o666);
346
+ }
311
347
  // Remaining setup steps can fail -- clean up started proxies on error.
312
348
  try {
313
349
  // Build orientation
@@ -337,6 +373,10 @@ export async function prepareDockerInfrastructure(config, mode, bundleDir, works
337
373
  const agentImage = await adapter.getImage();
338
374
  const agentBuildHash = await ensureImage(agentImage, docker, ca);
339
375
  const image = agentImage;
376
+ // Surface the (unpinned) agent CLI version baked into the image so silent
377
+ // version drift on rebuild is visible in logs (issue #367). Keyed by build
378
+ // hash so a same-process rebuild re-logs the possibly-changed version.
379
+ await logResolvedAgentVersion(docker, agentImage, agentBuildHash, adapter.versionProbe);
340
380
  const workflowDependencyMounts = prepareWorkflowDependencyMounts(agentBuildHash, scriptsDir, getIronCurtainHome());
341
381
  const orientationDir = resolve(bundleDir, 'orientation');
342
382
  // Set up conversation state directory if the adapter supports resume
@@ -458,8 +498,8 @@ export async function prepareDockerInfrastructure(config, mode, bundleDir, works
458
498
  * failure after the proxies are started, all started resources are torn
459
499
  * down before the error propagates.
460
500
  */
461
- export async function createDockerInfrastructure(config, mode, bundleDir, workspaceDir, escalationDir, bundleId, workflowId, scope, resolvedSkills, captureInput, scriptsDir, options) {
462
- const core = await prepareDockerInfrastructure(config, mode, bundleDir, workspaceDir, escalationDir, bundleId, workflowId, scope, resolvedSkills, captureInput, scriptsDir);
501
+ export async function createDockerInfrastructure(config, mode, bundleDir, workspaceDir, escalationDir, bundleId, workflowId, scope, resolvedSkills, captureInput, scriptsDir, options, providerProfileName) {
502
+ const core = await prepareDockerInfrastructure(config, mode, bundleDir, workspaceDir, escalationDir, bundleId, workflowId, scope, resolvedSkills, captureInput, scriptsDir, providerProfileName);
463
503
  let containerResources;
464
504
  try {
465
505
  containerResources = await createSessionContainers(core, config, options);
@@ -509,6 +549,8 @@ export async function destroyDockerInfrastructure(infra) {
509
549
  sidecarContainerId: infra.sidecarContainerId ?? null,
510
550
  networkName: infra.internalNetwork ?? null,
511
551
  });
552
+ if (infra.runtimeKind === 'docker')
553
+ releaseManagedResourceLease(infra.bundleId);
512
554
  // Proxies are independent producers -- stop them in parallel. Each
513
555
  // per-promise catch logs so one failure doesn't mask the other, and
514
556
  // allSettled ensures both complete even if one throws synchronously.
@@ -561,6 +603,10 @@ export async function destroyDockerInfrastructure(infra) {
561
603
  * See `docs/designs/workflow-session-identity.md` §7.
562
604
  */
563
605
  export function buildBundleLabels(core) {
606
+ const managedLabels = core.runtimeKind === 'docker' ? managedResourceLabels(core.bundleId) : undefined;
607
+ const labels = managedLabels
608
+ ? Object.fromEntries(Object.entries(managedLabels).filter(([key]) => key !== 'ironcurtain.bundle'))
609
+ : undefined;
564
610
  if (core.workflowId !== undefined) {
565
611
  return {
566
612
  bundleLabel: core.bundleId,
@@ -569,9 +615,10 @@ export function buildBundleLabels(core) {
569
615
  // bundle; default-fall back to DEFAULT_CONTAINER_SCOPE so that a
570
616
  // workflow bundle always carries a scope label.
571
617
  scopeLabel: core.scope ?? DEFAULT_CONTAINER_SCOPE,
618
+ labels,
572
619
  };
573
620
  }
574
- return { bundleLabel: core.bundleId };
621
+ return { bundleLabel: core.bundleId, labels };
575
622
  }
576
623
  /**
577
624
  * Returns the `user` override and env vars needed for runtime UID
@@ -583,15 +630,20 @@ export function buildBundleLabels(core) {
583
630
  * so the entrypoint (running as root) can renumber the codespace
584
631
  * account before dropping privileges via `runuser`.
585
632
  *
586
- * On macOS (`useTcp === true`), Docker Desktop's VirtioFS handles UID
587
- * translation transparently and `--user 0:0` would defeat it; this
588
- * function returns an empty mapping there, leaving the container to
589
- * run as the baked `codespace` user from the Dockerfile.
633
+ * When `skipRemap` is true this returns an empty mapping so the
634
+ * container runs as the baked `codespace` user from the Dockerfile.
635
+ * Skipped on macOS Docker Desktop (VirtioFS handles UID translation
636
+ * transparently and `--user 0:0` would defeat it) AND on apple-container
637
+ * (virtiofs presents host files as root; the existing sessions work
638
+ * without the Linux renumber-and-drop dance) — i.e., remap is Linux
639
+ * Docker only. Callers pass `runtimeKind === 'apple-container' || useTcp`
640
+ * so apple-container's `uds` topology does not accidentally trip the
641
+ * Linux-only remap.
590
642
  *
591
643
  * Exported for testability.
592
644
  */
593
- export function buildAgentUidRemap(useTcp) {
594
- if (useTcp)
645
+ export function buildAgentUidRemap(skipRemap) {
646
+ if (skipRemap)
595
647
  return { user: undefined, env: {} };
596
648
  const uid = process.getuid?.() ?? 1000;
597
649
  const gid = process.getgid?.() ?? 1000;
@@ -603,6 +655,30 @@ export function buildAgentUidRemap(useTcp) {
603
655
  },
604
656
  };
605
657
  }
658
+ /** In-container mount root for the UDS proxies (both runtimes). */
659
+ export const CONTAINER_SOCKETS_DIR = '/run/ironcurtain';
660
+ /**
661
+ * Returns the `uds`-topology socket mounts for a given runtime. Linux
662
+ * Docker bind-mounts the whole `sockets/` directory (the sockets are
663
+ * shared kernel objects). Apple `container` mounts each socket FILE via
664
+ * `-v` so the runtime creates a per-socket vsock relay — a virtiofs
665
+ * directory share does not carry sockets (verified: `connect()` on a
666
+ * socket inside a shared dir fails ENOTSUP on 1.1.0). Shared by batch
667
+ * (`createSessionContainers`) and PTY (`runPtySession`).
668
+ */
669
+ export function buildUdsSocketMounts(runtimeKind, socketsDir) {
670
+ if (runtimeKind === 'apple-container') {
671
+ return [
672
+ { source: resolve(socketsDir, 'proxy.sock'), target: `${CONTAINER_SOCKETS_DIR}/proxy.sock`, readonly: false },
673
+ {
674
+ source: resolve(socketsDir, 'mitm-proxy.sock'),
675
+ target: `${CONTAINER_SOCKETS_DIR}/mitm-proxy.sock`,
676
+ readonly: false,
677
+ },
678
+ ];
679
+ }
680
+ return [{ source: socketsDir, target: CONTAINER_SOCKETS_DIR, readonly: false }];
681
+ }
606
682
  /**
607
683
  * Creates and starts the main agent container (plus TCP-mode sidecar and
608
684
  * internal network on macOS). Cleans up any partially-created resources
@@ -613,6 +689,17 @@ export function buildAgentUidRemap(useTcp) {
613
689
  * with a scripted `ContainerRuntime`.
614
690
  */
615
691
  export async function createSessionContainers(core, config, options) {
692
+ if (core.runtimeKind === 'docker') {
693
+ await reconcileIronCurtainDockerResourcesBestEffort(core.docker, 'session startup');
694
+ }
695
+ const maxAttempts = core.topology === 'tcp-sidecar' ? 4 : 1;
696
+ return withInternalNetworkAllocationRetry({
697
+ maxAttempts,
698
+ description: 'Internal Docker network',
699
+ reconcile: () => reconcileIronCurtainDockerResourcesBestEffort(core.docker, 'internal network retry'),
700
+ }, (excludedSubnets, attempt) => createSessionContainersAttempt(core, config, options, excludedSubnets, attempt));
701
+ }
702
+ async function createSessionContainersAttempt(core, config, options, excludedSubnets, attempt) {
616
703
  const shortId = getBundleShortId(core.bundleId);
617
704
  const mainContainerName = `${CONTAINER_NAME_PREFIX}${shortId}`;
618
705
  // Labels applied to every IronCurtain-owned container (main + sidecar).
@@ -628,6 +715,7 @@ export async function createSessionContainers(core, config, options) {
628
715
  let mainContainerId;
629
716
  let sidecarContainerId;
630
717
  let internalNetwork;
718
+ let allocatedNetworkSubnet;
631
719
  try {
632
720
  const mainImage = options?.baseImageOverride && (await core.docker.imageExists(options.baseImageOverride))
633
721
  ? options.baseImageOverride
@@ -644,12 +732,11 @@ export async function createSessionContainers(core, config, options) {
644
732
  };
645
733
  let network;
646
734
  let extraHosts;
647
- // tcp-hostonly only: apt proxy config to write into the container via
648
- // exec after start. Apple container's virtiofs shares directories
649
- // only the single-file bind mount the Docker topologies use for
650
- // /etc/apt/apt.conf.d/90-ironcurtain-proxy is rejected with
651
- // "path ... is not a directory".
652
- let hostOnlyAptProxyUrl;
735
+ // apple-container only (both `uds` and the retained `tcp-hostonly`):
736
+ // the apt proxy config is written into the container via exec after
737
+ // start instead of bind-mounted see writeAptProxyConfigViaExec for
738
+ // the virtiofs constraints that make the bind mount unworkable.
739
+ let execAptProxyUrl;
653
740
  if (core.topology === 'tcp-hostonly') {
654
741
  // Apple container host-only mode: the agent VM reaches the host
655
742
  // proxies directly at the vmnet gateway address. No sidecar, no
@@ -664,7 +751,7 @@ export async function createSessionContainers(core, config, options) {
664
751
  HTTPS_PROXY: proxyUrl,
665
752
  HTTP_PROXY: proxyUrl,
666
753
  };
667
- hostOnlyAptProxyUrl = proxyUrl;
754
+ execAptProxyUrl = proxyUrl;
668
755
  network = core.hostOnlyNetwork.name;
669
756
  // Report the host-only network as `internalNetwork` so the standard
670
757
  // teardown paths (destroyDockerInfrastructure, rollback below)
@@ -688,10 +775,15 @@ export async function createSessionContainers(core, config, options) {
688
775
  writeFileSync(aptProxyPath, `Acquire::http::Proxy "${proxyUrl}";\nAcquire::https::Proxy "${proxyUrl}";\n`);
689
776
  mounts.push({ source: aptProxyPath, target: '/etc/apt/apt.conf.d/90-ironcurtain-proxy', readonly: true });
690
777
  // Create a per-session --internal Docker network that blocks internet egress.
691
- const networkName = getInternalNetworkName(shortId);
692
- await core.docker.createNetwork(networkName, { internal: true });
778
+ const baseNetworkName = getInternalNetworkName(shortId);
779
+ const networkName = attempt === 1 ? baseNetworkName : `${baseNetworkName}-a${attempt}`;
780
+ const allocatedNetwork = await createIronCurtainInternalNetwork(core.docker, networkName, core.bundleId, {
781
+ excludedSubnets,
782
+ });
783
+ allocatedNetworkSubnet = allocatedNetwork.subnet;
693
784
  internalNetwork = networkName;
694
785
  network = networkName;
786
+ logger.info(`Allocated internal Docker network ${networkName} at ${allocatedNetwork.subnet}`);
695
787
  // Ensure the socat image is available
696
788
  const socatImage = 'alpine/socat';
697
789
  if (!(await core.docker.imageExists(socatImage))) {
@@ -726,30 +818,43 @@ export async function createSessionContainers(core, config, options) {
726
818
  logger.info(`Sidecar ${sidecarName} bridging ports ${mcpPort},${mitmPort} at ${sidecarIp}`);
727
819
  }
728
820
  else {
729
- // Linux UDS mode: --network=none, session dir with sockets mounted
730
- const linuxProxyUrl = 'http://127.0.0.1:18080';
821
+ // UDS mode (Linux Docker AND apple-container >= 1.1.0):
822
+ // --network none, proxy sockets mounted at /run/ironcurtain.
823
+ const udsProxyUrl = 'http://127.0.0.1:18080';
731
824
  env = {
732
825
  ...env,
733
- HTTPS_PROXY: linuxProxyUrl,
734
- HTTP_PROXY: linuxProxyUrl,
826
+ HTTPS_PROXY: udsProxyUrl,
827
+ HTTP_PROXY: udsProxyUrl,
735
828
  };
736
829
  network = null;
737
- // Write apt proxy config so sudo apt-get routes through the MITM proxy
738
- const aptProxyPathLinux = resolve(core.orientationDir, 'apt-proxy.conf');
739
- writeFileSync(aptProxyPathLinux, `Acquire::http::Proxy "${linuxProxyUrl}";\nAcquire::https::Proxy "${linuxProxyUrl}";\n`);
740
- mounts.push({
741
- source: aptProxyPathLinux,
742
- target: '/etc/apt/apt.conf.d/90-ironcurtain-proxy',
743
- readonly: true,
744
- });
745
- // Mount ONLY the per-bundle `sockets/` directory into the
746
- // container. The sockets dir lives under a short
747
- // `~/.ironcurtain/run/<bid12>/` path (see `getBundleSocketsDir`)
748
- // so the host path stays under `sockaddr_un.sun_path` on both
749
- // macOS and Linux. The host-only MITM control socket lives in a
750
- // sibling `host/` dir, NOT under this mount, so it is not
751
- // visible to the container.
752
- mounts.push({ source: core.socketsDir, target: '/run/ironcurtain', readonly: false });
830
+ // Write apt proxy config so sudo apt-get routes through the MITM
831
+ // proxy. On apple-container we CANNOT bind-mount this file: it
832
+ // lives inside `orientationDir`, and 1.1.0's virtiofs silently
833
+ // drops a directory share when a file inside that directory is
834
+ // ALSO `-v`-mounted (verified — /etc/ironcurtain vanishes). The
835
+ // exec-based writer runs after start instead. Linux Docker keeps
836
+ // the single-file bind mount.
837
+ if (core.runtimeKind === 'apple-container') {
838
+ execAptProxyUrl = udsProxyUrl;
839
+ }
840
+ else {
841
+ const aptProxyPathUds = resolve(core.orientationDir, 'apt-proxy.conf');
842
+ writeFileSync(aptProxyPathUds, `Acquire::http::Proxy "${udsProxyUrl}";\nAcquire::https::Proxy "${udsProxyUrl}";\n`);
843
+ mounts.push({
844
+ source: aptProxyPathUds,
845
+ target: '/etc/apt/apt.conf.d/90-ironcurtain-proxy',
846
+ readonly: true,
847
+ });
848
+ }
849
+ // Mount ONLY the per-bundle proxy sockets into the container. The
850
+ // sockets dir lives under a short `~/.ironcurtain/run/<bid12>/`
851
+ // path (see `getBundleSocketsDir`) so host paths stay under
852
+ // `sockaddr_un.sun_path`. Linux mounts the directory; apple-
853
+ // container mounts each socket file so the runtime creates a
854
+ // vsock relay per socket (see buildUdsSocketMounts). The
855
+ // host-only MITM control socket lives in a sibling `host/` dir
856
+ // and is never mounted.
857
+ mounts.push(...buildUdsSocketMounts(core.runtimeKind, core.socketsDir));
753
858
  }
754
859
  // Mount conversation state directory for session resume (e.g., claude --continue)
755
860
  if (core.conversationStateDir && core.conversationStateConfig) {
@@ -790,7 +895,7 @@ export async function createSessionContainers(core, config, options) {
790
895
  // passing `--user 0:0` would actually break that translation,
791
896
  // so we leave the container running as the baked codespace user
792
897
  // and skip the env vars entirely.
793
- const uidRemap = buildAgentUidRemap(core.useTcp);
898
+ const uidRemap = buildAgentUidRemap(core.runtimeKind === 'apple-container' || core.useTcp);
794
899
  // Resource ceilings come from userConfig (defaults: 8 GB / 4 cpus) and
795
900
  // are clamped to fit the host. `null` in either field is preserved as
796
901
  // "no flag emitted" (see clampDockerResources docs).
@@ -828,10 +933,13 @@ export async function createSessionContainers(core, config, options) {
828
933
  });
829
934
  await core.docker.start(mainContainerId);
830
935
  logger.info(`Container started: ${mainContainerId.substring(0, 12)}`);
936
+ if (core.runtimeKind === 'docker') {
937
+ await checkDockerContainerWritableStorage(core.docker, mainContainerId);
938
+ }
831
939
  // tcp-hostonly: write the apt proxy config inside the container (the
832
- // Docker topologies bind-mount it; see hostOnlyAptProxyUrl above).
833
- if (hostOnlyAptProxyUrl !== undefined) {
834
- await writeHostOnlyAptProxyConfig(core.docker, mainContainerId, hostOnlyAptProxyUrl);
940
+ // Docker topologies bind-mount it; see execAptProxyUrl above).
941
+ if (execAptProxyUrl !== undefined) {
942
+ await writeAptProxyConfigViaExec(core.docker, mainContainerId, execAptProxyUrl);
835
943
  }
836
944
  // Connectivity check: verify the container can reach host proxies
837
945
  // through the internal network. Abort if unreachable. Host-only
@@ -841,7 +949,18 @@ export async function createSessionContainers(core, config, options) {
841
949
  await checkHostOnlyConnectivity(core.docker, mainContainerId, core.hostOnlyNetwork.gateway, core.proxy.port);
842
950
  }
843
951
  else if (core.useTcp && internalNetwork !== undefined && core.proxy.port !== undefined) {
844
- await checkInternalNetworkConnectivity(core.docker, mainContainerId, core.proxy.port);
952
+ if (core.mitmAddr.port === undefined) {
953
+ throw new Error('tcp-sidecar bundle is missing its MITM proxy port');
954
+ }
955
+ try {
956
+ await checkInternalNetworkConnectivity(core.docker, mainContainerId, core.proxy.port, core.mitmAddr.port);
957
+ }
958
+ catch (error) {
959
+ if (error instanceof InternalNetworkConnectivityError) {
960
+ throw new InternalNetworkConnectivityError(error.message, allocatedNetworkSubnet);
961
+ }
962
+ throw error;
963
+ }
845
964
  }
846
965
  return {
847
966
  containerId: mainContainerId,
@@ -860,6 +979,8 @@ export async function createSessionContainers(core, config, options) {
860
979
  sidecarContainerId: sidecarContainerId ?? null,
861
980
  networkName: internalNetwork ?? null,
862
981
  });
982
+ if (core.runtimeKind === 'docker')
983
+ releaseManagedResourceLease(core.bundleId);
863
984
  throw err;
864
985
  }
865
986
  }
@@ -867,26 +988,60 @@ export async function createSessionContainers(core, config, options) {
867
988
  * Probes whether the container can reach host-side proxies via the socat
868
989
  * sidecar on the internal Docker network. Throws a descriptive error if not.
869
990
  */
870
- async function checkInternalNetworkConnectivity(docker, containerId, mcpPort) {
871
- const result = await docker.exec(containerId, ['socat', '-u', '/dev/null', `TCP:${DOCKER_HOST_GATEWAY}:${mcpPort},connect-timeout=5`],
991
+ export async function checkInternalNetworkConnectivity(docker, containerId, mcpPort, mitmPort) {
992
+ const mcpResult = await docker.exec(containerId, [
993
+ '/bin/sh',
994
+ '-c',
995
+ `printf 'IRONCURTAIN_HEALTH/1\\n' | socat -T5 - TCP:${DOCKER_HOST_GATEWAY}:${mcpPort},connect-timeout=5 | grep -q '^IRONCURTAIN_OK/1'`,
996
+ ],
872
997
  // Allow a small buffer above socat's 5s connect-timeout for docker exec/process startup overhead.
873
998
  6_000);
874
- if (result.exitCode !== 0) {
875
- throw new Error(`Internal network connectivity check failed (exit=${result.exitCode}). ` +
876
- `The container cannot reach host-side proxies via the socat sidecar on the --internal Docker network. ` +
877
- `Check that the sidecar container is running and connected to the internal network.`);
999
+ if (mcpResult.exitCode !== 0) {
1000
+ throw new InternalNetworkConnectivityError(`Internal network connectivity check failed: MCP round-trip exited ${mcpResult.exitCode}; ` +
1001
+ `the sidecar could not reach the host proxy.`);
878
1002
  }
1003
+ const healthRequest = 'GET http://ironcurtain.invalid/__ironcurtain/health HTTP/1.1\\r\\n' +
1004
+ 'Host: ironcurtain.invalid\\r\\nConnection: close\\r\\n\\r\\n';
1005
+ const mitmResult = await docker.exec(containerId, [
1006
+ '/bin/sh',
1007
+ '-c',
1008
+ `printf '${healthRequest}' | socat -T5 - TCP:${DOCKER_HOST_GATEWAY}:${mitmPort},connect-timeout=5 | grep -q 'IRONCURTAIN_OK/1'`,
1009
+ ], 6_000);
1010
+ if (mitmResult.exitCode !== 0) {
1011
+ throw new InternalNetworkConnectivityError(`Internal network connectivity check failed: MITM round-trip exited ${mitmResult.exitCode}; ` +
1012
+ `the sidecar could not reach the host proxy.`);
1013
+ }
1014
+ }
1015
+ /**
1016
+ * Verifies that Docker's writable layer has space before an agent starts.
1017
+ * Docker Desktop can keep creating/starting containers after its VM disk is
1018
+ * full, while writes inside the container fail with ENOSPC. Claude Code then
1019
+ * exits 0 with no output after its initialization timeout, which otherwise
1020
+ * looks like a networking failure.
1021
+ */
1022
+ export async function checkDockerContainerWritableStorage(docker, containerId) {
1023
+ const result = await docker.exec(containerId, ['/bin/sh', '-c', 'probe="${HOME:-/home/codespace}/.ironcurtain-write-probe-$$"; mkdir "$probe" && rmdir "$probe"'], 5_000);
1024
+ if (result.exitCode === 0)
1025
+ return;
1026
+ const detail = result.stderr.trim() || result.stdout.trim() || `exit ${result.exitCode}`;
1027
+ const guidance = /ENOSPC|no space left on device/i.test(detail)
1028
+ ? 'Docker storage may be full; inspect it with `docker system df` and reclaim unused data or increase the disk limit.'
1029
+ : 'Inspect the container state, Docker daemon logs, and filesystem permissions.';
1030
+ throw new Error(`Docker container writable storage check failed: ${detail}. ${guidance}`);
879
1031
  }
880
1032
  /**
881
1033
  * Writes /etc/apt/apt.conf.d/90-ironcurtain-proxy inside a running
882
- * container via exec (as root). Used by the tcp-hostonly topology in
883
- * both batch and PTY modes — Apple container's virtiofs shares
884
- * directories only, so the single-file bind mount the Docker topologies
885
- * use is rejected. The URL is built from our own gateway address and
886
- * OS-assigned port runtime-generated values, not untrusted input — so
887
- * embedding it in the sh script is safe.
1034
+ * container via exec (as root). Used by apple-container in both batch
1035
+ * and PTY modes — the single-file bind mount the Docker topologies use
1036
+ * is unworkable there: on `tcp-hostonly` (pre-1.1.0) `--mount` rejected
1037
+ * non-directory sources, and on `uds` (1.1.0+) a `-v <dir>/file` mount
1038
+ * whose source nests under the already-shared orientation dir silently
1039
+ * drops that dir share. The URL is built from IronCurtain's own
1040
+ * gateway/loopback address and OS-assigned port — runtime-generated
1041
+ * values, not untrusted input — so embedding it in the sh script is
1042
+ * safe.
888
1043
  */
889
- export async function writeHostOnlyAptProxyConfig(docker, containerId, proxyUrl) {
1044
+ export async function writeAptProxyConfigViaExec(docker, containerId, proxyUrl) {
890
1045
  const aptWrite = await docker.exec(containerId, [
891
1046
  'sh',
892
1047
  '-c',
@@ -963,6 +1118,15 @@ export function resolveRealKey(host, config, oauthAccessToken) {
963
1118
  case 'generativelanguage.googleapis.com':
964
1119
  key = config.userConfig.googleApiKey;
965
1120
  break;
1121
+ case OPENROUTER_HOST: {
1122
+ // OpenRouter uses a static bearer key from the stamped active profile
1123
+ // (§7.5). The same host serves all three agents, so this single case
1124
+ // covers them. `isManagedOAuthHost` never matches openrouter.ai, so no
1125
+ // OAuth token is involved here.
1126
+ const profile = config.activeProviderProfile;
1127
+ key = profile?.type === 'openrouter' ? profile.apiKey : '';
1128
+ break;
1129
+ }
966
1130
  default:
967
1131
  logger.warn(`No API key mapping for unknown provider host: ${host}`);
968
1132
  return '';
@@ -1077,6 +1241,42 @@ async function buildImageFromCleanContext(docker, image, dockerDir, dockerfile,
1077
1241
  rmSync(tmpContext, { recursive: true, force: true });
1078
1242
  }
1079
1243
  }
1244
+ /**
1245
+ * `${image}@${buildHash}` keys whose baked CLI version has already been logged
1246
+ * this process, so the diagnostic probe (a throwaway `docker run --version`)
1247
+ * runs at most once per built image rather than on every session/workflow-state.
1248
+ * Keyed by build hash — not the tag alone — so a same-process rebuild (which
1249
+ * keeps the `:latest` tag but changes `ironcurtain.build-hash`) re-probes and
1250
+ * re-logs the possibly-changed version, which is the whole point of surfacing
1251
+ * drift.
1252
+ */
1253
+ const loggedAgentVersions = new Set();
1254
+ /**
1255
+ * Best-effort: log the agent CLI version baked into `image`. The agent CLI is
1256
+ * installed UNPINNED in the Docker image, so any change to a hashed build input
1257
+ * (Dockerfile / *.sh) rebuilds the image and can silently pull a newer agent —
1258
+ * see issue #367, where a Claude Code minor bump flipped subagents to run in the
1259
+ * background, breaking the one-shot `claude -p` workflow model. Surfacing the
1260
+ * resolved version makes that drift visible. Skipped when the adapter defines no
1261
+ * probe or the runtime can't run an ephemeral container; never throws.
1262
+ */
1263
+ async function logResolvedAgentVersion(runtime, image, buildHash, versionProbe) {
1264
+ if (!versionProbe || versionProbe.length === 0 || !runtime.probeImageVersion)
1265
+ return;
1266
+ const cacheKey = `${image}@${buildHash}`;
1267
+ if (loggedAgentVersions.has(cacheKey))
1268
+ return;
1269
+ loggedAgentVersions.add(cacheKey);
1270
+ try {
1271
+ const version = await runtime.probeImageVersion(image, versionProbe);
1272
+ if (version) {
1273
+ logger.info(`[docker] ${image} agent version (unpinned): ${version}`);
1274
+ }
1275
+ }
1276
+ catch {
1277
+ // Diagnostic only — never disturb infra prep.
1278
+ }
1279
+ }
1080
1280
  // `docker` is typed `ContainerRuntime` (the apple-container generalization of
1081
1281
  // the former `DockerManager`); exported because callers/tests import it.
1082
1282
  export async function ensureImage(image, docker, ca) {