agent-shim 8.0.0 → 8.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -61039,7 +61039,7 @@ function registerIdentityCommand(program2, deps) {
61039
61039
  // package.json
61040
61040
  var package_default = {
61041
61041
  name: "agent-shim",
61042
- version: "8.0.0",
61042
+ version: "8.0.2",
61043
61043
  description: "A profile manager and launcher for Claude Code that lets one person run multiple logins from one machine while controlling what gets shared between them.",
61044
61044
  license: "MIT",
61045
61045
  author: "Joseph Mearman <joseph@mearman.co.uk>",
@@ -64665,14 +64665,11 @@ ${request6.rawHeaders.reduce((accumulated, value, index) => index % 2 === 0 ? `$
64665
64665
  };
64666
64666
  const sessions = config2.interceptHosts.map((host) => {
64667
64667
  if (config2.tapHosts.includes(host)) {
64668
- const tlsAcceptor2 = effects.createTlsAcceptor(
64669
- config2.leafFor(host),
64670
- (secure) => {
64671
- pumpTapSession(secure, async (clientAlpn) => await effects.connectTlsUpstream(host, HTTPS_PORT, clientAlpn), config2.capture?.tapStream?.(host));
64672
- },
64673
- TAP_ALPN_PROTOCOLS
64674
- );
64675
- return { host, tlsAcceptor: tlsAcceptor2 };
64668
+ const serveSecure2 = (secure) => {
64669
+ pumpTapSession(secure, async (clientAlpn) => await effects.connectTlsUpstream(host, HTTPS_PORT, clientAlpn), config2.capture?.tapStream?.(host));
64670
+ };
64671
+ const tlsAcceptor2 = effects.createTlsAcceptor(config2.leafFor(host), serveSecure2, TAP_ALPN_PROTOCOLS);
64672
+ return { host, serveSecure: serveSecure2, tlsAcceptor: tlsAcceptor2 };
64676
64673
  }
64677
64674
  const handler = (request6, response) => {
64678
64675
  if (host === config2.routedHost && servedByPipeline(request6.url)) {
@@ -64686,19 +64683,29 @@ ${request6.rawHeaders.reduce((accumulated, value, index) => index % 2 === 0 ? `$
64686
64683
  effects.forwardHttp(config2.upstreamFor(host), request6, response, config2.capture?.observePassthrough(request6));
64687
64684
  };
64688
64685
  const httpSession = effects.createHttpSession(handler, onUpgrade(host));
64689
- const tlsAcceptor = effects.createTlsAcceptor(config2.leafFor(host), (secure, context) => {
64690
- tunnelTokens.set(secure, context);
64686
+ const serveSecure = (secure, capability) => {
64687
+ tunnelTokens.set(secure, capability);
64691
64688
  httpSession.serve(secure);
64692
- });
64693
- return { host, httpSession, tlsAcceptor };
64689
+ };
64690
+ const tlsAcceptor = effects.createTlsAcceptor(config2.leafFor(host), serveSecure);
64691
+ return { host, serveSecure, httpSession, tlsAcceptor };
64694
64692
  });
64695
64693
  const ledger = createConnectionLedger(config2.limits, config2.isLiveCapability);
64696
64694
  const listener = await effects.listenLoopback(preferredPort, (socket) => {
64697
64695
  handleConnect(socket, { config: config2, effects, sessions, ledger });
64698
64696
  });
64699
64697
  const apiSession = sessions.find((session) => session.host === config2.routedHost);
64698
+ const sessionByHost = new Map(sessions.map((session) => [session.host, session]));
64699
+ const sniAcceptor = effects.createSniTlsAcceptor(config2.interceptHosts, config2.leafFor, (secure, host) => {
64700
+ const session = sessionByHost.get(host);
64701
+ if (session === void 0) {
64702
+ secure.destroy();
64703
+ return;
64704
+ }
64705
+ session.serveSecure(secure, config2.transparentCapability ?? "");
64706
+ });
64700
64707
  const transparent = config2.transparentPort === void 0 || apiSession === void 0 ? void 0 : await effects.listenLoopback(config2.transparentPort, (socket) => {
64701
- apiSession.tlsAcceptor.accept(socket, config2.transparentCapability ?? "");
64708
+ sniAcceptor.accept(socket);
64702
64709
  });
64703
64710
  return {
64704
64711
  port: listener.port,
@@ -64707,6 +64714,7 @@ ${request6.rawHeaders.reduce((accumulated, value, index) => index % 2 === 0 ? `$
64707
64714
  ledger.close();
64708
64715
  await listener.close();
64709
64716
  await transparent?.close();
64717
+ sniAcceptor.close();
64710
64718
  for (const { httpSession, tlsAcceptor } of sessions) {
64711
64719
  tlsAcceptor.close();
64712
64720
  httpSession?.close();
@@ -65036,6 +65044,38 @@ function realConnectEffects() {
65036
65044
  }
65037
65045
  };
65038
65046
  },
65047
+ createSniTlsAcceptor: (hosts, leafFor, onSecure) => {
65048
+ const contexts = new Map(
65049
+ hosts.map((host) => {
65050
+ const leaf = leafFor(host);
65051
+ return [host, tls.createSecureContext({ key: leaf.keyPem, cert: leaf.certPem })];
65052
+ })
65053
+ );
65054
+ const server = tls.createServer(
65055
+ {
65056
+ // No default key or cert exists, so the SNI selection is the only source of credentials: an unserved name fails its handshake here, and a client that sent no SNI at all never reaches the selection and fails for want of any certificate, which is exactly the fail-closed shape this surface wants.
65057
+ SNICallback: (servername, callback) => {
65058
+ const context = contexts.get(servername);
65059
+ callback(context === void 0 ? new Error(`the transparent surface serves no host named ${servername}`) : null, context);
65060
+ }
65061
+ },
65062
+ (secure) => {
65063
+ onSecure(secure, typeof secure.servername === "string" ? secure.servername : "");
65064
+ }
65065
+ );
65066
+ server.on("clientError", (_error, socket) => {
65067
+ socket.destroy();
65068
+ });
65069
+ return {
65070
+ // The same hand-off the single-leaf acceptor uses: emitting the connection event runs the TLS server's own connection listener on a socket this process already owns (the redirect half of the connection) rather than one the server accepted itself.
65071
+ accept: (socket) => {
65072
+ server.emit("connection", socket);
65073
+ },
65074
+ close: () => {
65075
+ server.close();
65076
+ }
65077
+ };
65078
+ },
65039
65079
  createHttpSession: (handler, onUpgrade) => {
65040
65080
  const server = http.createServer(handler);
65041
65081
  server.on("clientError", (_error, socket) => {
@@ -68037,13 +68077,231 @@ function registerDoctorCommand(program2, deps) {
68037
68077
  }
68038
68078
 
68039
68079
  // src/headroom/commands.ts
68040
- var import_node_fs15 = __toESM(require("node:fs"), 1);
68080
+ var import_node_fs16 = __toESM(require("node:fs"), 1);
68041
68081
  var import_node_http2 = __toESM(require("node:http"), 1);
68042
- var import_node_path35 = __toESM(require("node:path"), 1);
68082
+ var import_node_path36 = __toESM(require("node:path"), 1);
68043
68083
  var import_node_child_process5 = require("node:child_process");
68044
68084
 
68045
- // src/headroom/settings.ts
68085
+ // src/frontdoor/realFrontDoorPort.ts
68086
+ var import_node_fs15 = __toESM(require("node:fs"), 1);
68087
+
68088
+ // src/frontdoor/ensure.ts
68046
68089
  var import_node_crypto12 = require("node:crypto");
68090
+ var FRONTDOOR_START_TIMEOUT_MS = 3e4;
68091
+ var FRONTDOOR_LAUNCHER_POLL_MS = 100;
68092
+ var FrontDoorStartError = class extends CliError {
68093
+ constructor(message) {
68094
+ super(message);
68095
+ this.name = "FrontDoorStartError";
68096
+ }
68097
+ };
68098
+ function ensureFrontDoor(params) {
68099
+ const { paths, ports } = params;
68100
+ const deadline = ports.now() + FRONTDOOR_START_TIMEOUT_MS;
68101
+ let spawned = false;
68102
+ let replaced = false;
68103
+ const distrusted = /* @__PURE__ */ new Set();
68104
+ let lastRejection;
68105
+ for (; ; ) {
68106
+ const state = readFrontDoorState(ports.fs, paths.frontdoorStateFile);
68107
+ const supervisorPid = state?.supervisorPid;
68108
+ if (supervisorPid !== void 0 && !distrusted.has(supervisorPid) && ports.isRunning(supervisorPid)) {
68109
+ if (state?.port !== void 0 && state.connectPort !== void 0) {
68110
+ const verdict = ports.verifyListener(state.port);
68111
+ if (verdict.ok && (state.protocol ?? 0) < FRONT_DOOR_PROTOCOL && !replaced) {
68112
+ ports.stopSupervisor(supervisorPid);
68113
+ while (ports.isRunning(supervisorPid)) {
68114
+ if (ports.now() >= deadline) {
68115
+ throw new FrontDoorStartError(`agent-shim: the front door (pid ${String(supervisorPid)}, protocol ${String(state.protocol ?? 0)}) is older than this launcher (protocol ${String(FRONT_DOOR_PROTOCOL)}) and did not exit when asked. Daemon log: ${paths.frontdoorLogPath}`);
68116
+ }
68117
+ ports.sleep(FRONTDOOR_LAUNCHER_POLL_MS);
68118
+ }
68119
+ replaced = true;
68120
+ continue;
68121
+ }
68122
+ if (verdict.ok) {
68123
+ const token = (0, import_node_crypto12.randomUUID)();
68124
+ writeFrontDoorSession(ports.fs, paths.frontdoorSessionsDir, { pid: params.launcherPid, startedAt: ports.now(), token });
68125
+ return { port: state.port, connectPort: state.connectPort, token };
68126
+ }
68127
+ lastRejection = `the listener on 127.0.0.1:${String(state.port)} (recorded for pid ${String(supervisorPid)}) failed authentication: ${verdict.reason}`;
68128
+ if (spawned) {
68129
+ throw new FrontDoorStartError(`agent-shim: ${lastRejection}; refusing to send this session's credentials to it. Daemon log: ${paths.frontdoorLogPath}`);
68130
+ }
68131
+ distrusted.add(supervisorPid);
68132
+ continue;
68133
+ }
68134
+ if (state?.lastError !== void 0) {
68135
+ throw new FrontDoorStartError(`agent-shim: the front door reported a fatal error and is not serving: ${state.lastError} (daemon log: ${paths.frontdoorLogPath})`);
68136
+ }
68137
+ } else if (!spawned) {
68138
+ ports.fs.mkdirp(paths.frontdoorDir);
68139
+ const created = ports.fs.writeFileExclusive(paths.frontdoorLockFile, `${JSON.stringify({ pid: params.launcherPid, at: ports.now() })}
68140
+ `);
68141
+ if (created) {
68142
+ ports.spawnSupervisor(paths);
68143
+ spawned = true;
68144
+ } else {
68145
+ const lock = readStartLock(ports.fs, paths.frontdoorLockFile);
68146
+ if (lock === void 0 || !ports.isRunning(lock.pid)) {
68147
+ ports.fs.removeRecursive(paths.frontdoorLockFile);
68148
+ continue;
68149
+ }
68150
+ }
68151
+ }
68152
+ if (ports.now() >= deadline) {
68153
+ const lastError = state?.lastError;
68154
+ throw new FrontDoorStartError(
68155
+ `agent-shim: the front door did not become ready within ${String(FRONTDOOR_START_TIMEOUT_MS)}ms` + (lastError === void 0 ? "" : ` (last error: ${lastError})`) + (lastRejection === void 0 ? "" : ` (${lastRejection})`) + `. Daemon log: ${paths.frontdoorLogPath}`
68156
+ );
68157
+ }
68158
+ ports.sleep(FRONTDOOR_LAUNCHER_POLL_MS);
68159
+ }
68160
+ }
68161
+
68162
+ // src/frontdoor/probe.ts
68163
+ var import_node_worker_threads = require("node:worker_threads");
68164
+ var PROBE_TIMEOUT_MS = 2e3;
68165
+ var PROBE_WAIT_MARGIN_MS = 1e3;
68166
+ var REASON_BYTES = 1024;
68167
+ var HEADER_SLOTS = 2;
68168
+ var INT32_BYTES = 4;
68169
+ var VERDICT_PENDING = 0;
68170
+ var VERDICT_OK = 1;
68171
+ var VERDICT_FAILED = 2;
68172
+ var PROBE_SOURCE = `
68173
+ const { workerData } = require("node:worker_threads");
68174
+ const https = require("node:https");
68175
+ const { port, ca, timeoutMs, shared, ok, failed, headerSlots } = workerData;
68176
+ const header = new Int32Array(shared, 0, headerSlots);
68177
+ const reason = new Uint8Array(shared, headerSlots * 4);
68178
+ let done = false;
68179
+ const finish = (verdict, message) => {
68180
+ if (done) return;
68181
+ done = true;
68182
+ const bytes = Buffer.from(String(message), "utf8").subarray(0, reason.length);
68183
+ reason.set(bytes);
68184
+ Atomics.store(header, 1, bytes.length);
68185
+ Atomics.store(header, 0, verdict);
68186
+ Atomics.notify(header, 0);
68187
+ };
68188
+ const request = https.get({ host: "127.0.0.1", port, path: "/healthz", ca: [ca], agent: false }, (response) => {
68189
+ let body = "";
68190
+ response.setEncoding("utf8");
68191
+ response.on("data", (chunk) => { body += chunk; });
68192
+ response.on("end", () => {
68193
+ if (response.statusCode === 200 && body === "ok") finish(ok, "");
68194
+ else finish(failed, "the listener answered its health probe with HTTP " + String(response.statusCode));
68195
+ });
68196
+ response.on("error", (error) => finish(failed, error.message));
68197
+ });
68198
+ // A hard deadline over the whole exchange, handshake included: a listener that accepts the connection and then says nothing must still fail the probe.
68199
+ setTimeout(() => { request.destroy(new Error("no answer within " + String(timeoutMs) + "ms")); }, timeoutMs);
68200
+ request.on("error", (error) => finish(failed, (error.code ? error.code + ": " : "") + error.message));
68201
+ `;
68202
+ function probeFrontDoorSync(port, caPem) {
68203
+ const shared = new SharedArrayBuffer(HEADER_SLOTS * INT32_BYTES + REASON_BYTES);
68204
+ const header = new Int32Array(shared, 0, HEADER_SLOTS);
68205
+ const worker = new import_node_worker_threads.Worker(PROBE_SOURCE, {
68206
+ eval: true,
68207
+ workerData: { port, ca: caPem, timeoutMs: PROBE_TIMEOUT_MS, shared, ok: VERDICT_OK, failed: VERDICT_FAILED, headerSlots: HEADER_SLOTS }
68208
+ });
68209
+ worker.unref();
68210
+ try {
68211
+ const waited = Atomics.wait(header, 0, VERDICT_PENDING, PROBE_TIMEOUT_MS + PROBE_WAIT_MARGIN_MS);
68212
+ const verdict = Atomics.load(header, 0);
68213
+ if (waited === "timed-out" || verdict === VERDICT_PENDING) {
68214
+ return { ok: false, reason: `the probe did not finish within ${String(PROBE_TIMEOUT_MS + PROBE_WAIT_MARGIN_MS)}ms` };
68215
+ }
68216
+ if (verdict === VERDICT_OK) {
68217
+ return { ok: true };
68218
+ }
68219
+ const length = Atomics.load(header, 1);
68220
+ return { ok: false, reason: Buffer.from(new Uint8Array(shared, HEADER_SLOTS * INT32_BYTES, length)).toString("utf8") };
68221
+ } finally {
68222
+ void worker.terminate();
68223
+ }
68224
+ }
68225
+
68226
+ // src/frontdoor/trust.ts
68227
+ var import_node_crypto13 = require("node:crypto");
68228
+ var import_node_path35 = __toESM(require("node:path"), 1);
68229
+ var BUNDLE_NAME_HEX_DIGITS = 16;
68230
+ function resolveTrustBundle(params) {
68231
+ const { caCertFile, bundlesDir, variable, inherited, fs: fs19 } = params;
68232
+ if (inherited === void 0 || inherited === "" || import_node_path35.default.resolve(inherited) === import_node_path35.default.resolve(caCertFile)) {
68233
+ return { path: caCertFile };
68234
+ }
68235
+ const caPem = fs19.readFileUtf8(caCertFile);
68236
+ let inheritedPem;
68237
+ try {
68238
+ inheritedPem = fs19.readFileUtf8(inherited);
68239
+ } catch (error62) {
68240
+ return {
68241
+ path: caCertFile,
68242
+ warning: `agent-shim: ${variable} names ${inherited}, which could not be read (${error62 instanceof Error ? error62.message : String(error62)}); the child trusts agent-shim's front-door CA on top of the system store, and nothing from that file`
68243
+ };
68244
+ }
68245
+ if (inheritedPem.includes(caPem.trim())) {
68246
+ return { path: inherited };
68247
+ }
68248
+ const combined = `${inheritedPem.trimEnd()}
68249
+ ${caPem}`;
68250
+ const bundle = import_node_path35.default.join(bundlesDir, `${(0, import_node_crypto13.createHash)("sha256").update(combined).digest("hex").slice(0, BUNDLE_NAME_HEX_DIGITS)}.pem`);
68251
+ if (!fs19.exists(bundle)) {
68252
+ fs19.mkdirp(bundlesDir);
68253
+ fs19.writeFileAtomic(bundle, combined);
68254
+ }
68255
+ return { path: bundle };
68256
+ }
68257
+
68258
+ // src/frontdoor/realFrontDoorPort.ts
68259
+ var realTrustBundleFs = {
68260
+ readFileUtf8: (file2) => import_node_fs15.default.readFileSync(file2, "utf8"),
68261
+ exists: (file2) => import_node_fs15.default.existsSync(file2),
68262
+ mkdirp: (dir) => {
68263
+ import_node_fs15.default.mkdirSync(dir, { recursive: true });
68264
+ },
68265
+ writeFileAtomic: (file2, contents) => {
68266
+ realFarmFs.writeFilePrivate(file2, contents);
68267
+ }
68268
+ };
68269
+ function realFrontDoorPort(paths, spawnDaemon) {
68270
+ return {
68271
+ ensure: (inheritedExtraCaCerts) => {
68272
+ const up = ensureFrontDoor({
68273
+ paths,
68274
+ launcherPid: process.pid,
68275
+ ports: {
68276
+ fs: realFarmFs,
68277
+ isRunning: realIsProcessRunning,
68278
+ now: () => Date.now(),
68279
+ sleep: realSleepSync,
68280
+ spawnSupervisor: (layout) => spawnDaemon(layout, "__frontdoor-supervisor", layout.frontdoorLogPath),
68281
+ stopSupervisor: (pid) => {
68282
+ process.kill(pid, "SIGTERM");
68283
+ },
68284
+ // Read fresh for each probe: a replacement supervisor may have regenerated an unparseable CA, and the probe must trust exactly what the serving door's leaf chains to.
68285
+ verifyListener: (port) => probeFrontDoorSync(port, import_node_fs15.default.readFileSync(paths.frontdoorCaCertFile, "utf8"))
68286
+ }
68287
+ });
68288
+ const trust = resolveTrustBundle({ caCertFile: paths.frontdoorCaCertFile, bundlesDir: paths.frontdoorCaBundlesDir, variable: "NODE_EXTRA_CA_CERTS", inherited: inheritedExtraCaCerts, fs: realTrustBundleFs });
68289
+ return {
68290
+ port: up.port,
68291
+ connectPort: up.connectPort,
68292
+ trustBundlePath: trust.path,
68293
+ ...trust.warning === void 0 ? {} : { trustWarning: trust.warning },
68294
+ sessionToken: up.token
68295
+ };
68296
+ },
68297
+ release: () => {
68298
+ removeFrontDoorSession(realFarmFs, paths.frontdoorSessionsDir, process.pid);
68299
+ }
68300
+ };
68301
+ }
68302
+
68303
+ // src/headroom/settings.ts
68304
+ var import_node_crypto14 = require("node:crypto");
68047
68305
  function settingsOf(config2) {
68048
68306
  return {
68049
68307
  ...config2.mode === void 0 ? {} : { mode: config2.mode },
@@ -68068,7 +68326,7 @@ function settingsEnv(settings2) {
68068
68326
  return settings2.rolloutChannel === void 0 ? {} : { HEADROOM_ROLLOUT_CHANNEL: settings2.rolloutChannel };
68069
68327
  }
68070
68328
  function hashSettings(settings2) {
68071
- return (0, import_node_crypto12.createHash)("sha256").update(JSON.stringify([...settingsArgs(settings2), JSON.stringify(settingsEnv(settings2))])).digest("hex");
68329
+ return (0, import_node_crypto14.createHash)("sha256").update(JSON.stringify([...settingsArgs(settings2), JSON.stringify(settingsEnv(settings2))])).digest("hex");
68072
68330
  }
68073
68331
 
68074
68332
  // src/headroom/supervisor.ts
@@ -68413,7 +68671,14 @@ var READY_FETCH_TIMEOUT_MS = 2e3;
68413
68671
  function headroomPidRunning(pid) {
68414
68672
  return !exitedHeadroom.has(pid) && realIsProcessRunning(pid);
68415
68673
  }
68416
- function headroomSpawnEnv(parentEnv, allowlist, settings2, caBundle) {
68674
+ var HEADROOM_CA_BUNDLE = "HEADROOM_CA_BUNDLE";
68675
+ function resolveHeadroomTrustBundle(params) {
68676
+ if (!params.fs.exists(params.caCertFile)) {
68677
+ return void 0;
68678
+ }
68679
+ return resolveTrustBundle({ ...params, variable: HEADROOM_CA_BUNDLE });
68680
+ }
68681
+ function headroomSpawnEnv(parentEnv, allowlist, settings2, trustBundlePath) {
68417
68682
  const inherited = { ...parentEnv };
68418
68683
  delete inherited.HEADROOM_HOST;
68419
68684
  delete inherited.HEADROOM_PORT;
@@ -68421,8 +68686,8 @@ function headroomSpawnEnv(parentEnv, allowlist, settings2, caBundle) {
68421
68686
  ...inherited,
68422
68687
  HEADROOM_ALLOWED_BASE_URLS: allowlist.join(","),
68423
68688
  HEADROOM_HTTP2: parentEnv.HEADROOM_HTTP2 ?? "0",
68424
- // A transparent-interception deployment redirects the API host's address machine-wide, so headroom's own Python dials reach the front door's leaf instead of the origin's public certificate, and Python trusts only the system store: point its two trust variables (urllib3/httpx read both) at the door's combined bundle, which carries the system-equivalent anchors alongside the door's CA. An explicit value in the parent environment always wins, exactly like HEADROOM_HTTP2.
68425
- ...caBundle === void 0 || parentEnv.REQUESTS_CA_BUNDLE !== void 0 || parentEnv.SSL_CERT_FILE !== void 0 ? {} : { REQUESTS_CA_BUNDLE: caBundle, SSL_CERT_FILE: caBundle },
68689
+ // The bundle already holds any inherited HEADROOM_CA_BUNDLE's certificates, so replacing the variable loses nothing. An inherited SSL_CERT_FILE or REQUESTS_CA_BUNDLE passes through untouched and, by headroom's own precedence, still replaces everything: an explicit choice in the parent environment wins.
68690
+ ...trustBundlePath === void 0 ? {} : { [HEADROOM_CA_BUNDLE]: trustBundlePath },
68426
68691
  ...settingsEnv(settings2)
68427
68692
  };
68428
68693
  }
@@ -68449,12 +68714,12 @@ function readInstalledHeadroomCommit() {
68449
68714
  if (toolDir.status !== 0) {
68450
68715
  return void 0;
68451
68716
  }
68452
- const libDir = import_node_path35.default.join(toolDir.stdout.trim(), HEADROOM_TOOL_NAME, "lib");
68717
+ const libDir = import_node_path36.default.join(toolDir.stdout.trim(), HEADROOM_TOOL_NAME, "lib");
68453
68718
  for (const python of realFarmFs.readdir(libDir)) {
68454
- const sitePackages = import_node_path35.default.join(libDir, python, "site-packages");
68719
+ const sitePackages = import_node_path36.default.join(libDir, python, "site-packages");
68455
68720
  for (const entry of realFarmFs.readdir(sitePackages)) {
68456
68721
  if (entry.startsWith("headroom_ai-") && entry.endsWith(".dist-info")) {
68457
- const directUrl = realFarmFs.readFileUtf8(import_node_path35.default.join(sitePackages, entry, "direct_url.json"));
68722
+ const directUrl = realFarmFs.readFileUtf8(import_node_path36.default.join(sitePackages, entry, "direct_url.json"));
68458
68723
  return directUrl === void 0 ? void 0 : parseInstalledCommit(directUrl);
68459
68724
  }
68460
68725
  }
@@ -68477,14 +68742,18 @@ function realSupervisorPorts(paths) {
68477
68742
  socketTrust: realHeadroomSocketTrust,
68478
68743
  readConfig: () => resolveSupervisorConfig(readGlobalConfig(paths)?.headroom ?? {}),
68479
68744
  spawnHeadroom: (socketPath, allowlist, settings2) => {
68480
- const caBundle = import_node_fs15.default.existsSync(paths.frontdoorCaCertFile) ? import_node_fs15.default.readFileSync(paths.frontdoorCaCertFile, "utf8") : void 0;
68481
- import_node_fs15.default.mkdirSync(paths.logsDir, { recursive: true });
68482
- const logFd = import_node_fs15.default.openSync(paths.headroomLogPath, "a");
68745
+ const trust = resolveHeadroomTrustBundle({ caCertFile: paths.frontdoorCaCertFile, bundlesDir: paths.frontdoorCaBundlesDir, inherited: process.env[HEADROOM_CA_BUNDLE], fs: realTrustBundleFs });
68746
+ import_node_fs16.default.mkdirSync(paths.logsDir, { recursive: true });
68747
+ if (trust?.warning !== void 0) {
68748
+ import_node_fs16.default.appendFileSync(paths.headroomLogPath, `${(/* @__PURE__ */ new Date()).toISOString()} ${trust.warning}
68749
+ `);
68750
+ }
68751
+ const logFd = import_node_fs16.default.openSync(paths.headroomLogPath, "a");
68483
68752
  try {
68484
68753
  const child = (0, import_node_child_process5.spawn)("headroom", headroomProxyArgs(socketPath, settings2), {
68485
68754
  detached: true,
68486
68755
  stdio: ["ignore", logFd, logFd],
68487
- env: headroomSpawnEnv(process.env, allowlist, settings2, caBundle)
68756
+ env: headroomSpawnEnv(process.env, allowlist, settings2, trust?.path)
68488
68757
  });
68489
68758
  if (child.pid === void 0) {
68490
68759
  throw new Error("spawning headroom returned no pid");
@@ -68494,12 +68763,12 @@ function realSupervisorPorts(paths) {
68494
68763
  child.once("exit", (code, signal) => {
68495
68764
  ownedHeadroom.delete(pid);
68496
68765
  exitedHeadroom.add(pid);
68497
- import_node_fs15.default.appendFileSync(paths.headroomLogPath, `${(/* @__PURE__ */ new Date()).toISOString()} agent-shim headroom supervisor: headroom exited (${signal ?? `code ${String(code)}`})
68766
+ import_node_fs16.default.appendFileSync(paths.headroomLogPath, `${(/* @__PURE__ */ new Date()).toISOString()} agent-shim headroom supervisor: headroom exited (${signal ?? `code ${String(code)}`})
68498
68767
  `);
68499
68768
  });
68500
68769
  return pid;
68501
68770
  } finally {
68502
- import_node_fs15.default.closeSync(logFd);
68771
+ import_node_fs16.default.closeSync(logFd);
68503
68772
  }
68504
68773
  },
68505
68774
  stopProcess: (pid) => {
@@ -68512,7 +68781,7 @@ function realSupervisorPorts(paths) {
68512
68781
  now: () => Date.now()
68513
68782
  });
68514
68783
  if (outcome === "still-running") {
68515
- import_node_fs15.default.appendFileSync(
68784
+ import_node_fs16.default.appendFileSync(
68516
68785
  paths.headroomLogPath,
68517
68786
  `${(/* @__PURE__ */ new Date()).toISOString()} agent-shim headroom supervisor: pid ${String(pid)} survived SIGKILL within its grace; it is stuck uninterruptibly
68518
68787
  `
@@ -68526,10 +68795,10 @@ function realSupervisorPorts(paths) {
68526
68795
  return { ok: false, error: `uv is not installed or not on PATH (${result.error.message})` };
68527
68796
  }
68528
68797
  if (result.stdout !== "") {
68529
- import_node_fs15.default.appendFileSync(paths.headroomLogPath, result.stdout);
68798
+ import_node_fs16.default.appendFileSync(paths.headroomLogPath, result.stdout);
68530
68799
  }
68531
68800
  if (result.stderr !== "") {
68532
- import_node_fs15.default.appendFileSync(paths.headroomLogPath, result.stderr);
68801
+ import_node_fs16.default.appendFileSync(paths.headroomLogPath, result.stderr);
68533
68802
  }
68534
68803
  return result.status === 0 ? { ok: true } : { ok: false, error: `uv tool install exited ${String(result.status)}` };
68535
68804
  },
@@ -68539,8 +68808,8 @@ function realSupervisorPorts(paths) {
68539
68808
  },
68540
68809
  installedCommit: readInstalledHeadroomCommit,
68541
68810
  log: (line) => {
68542
- import_node_fs15.default.mkdirSync(paths.logsDir, { recursive: true });
68543
- import_node_fs15.default.appendFileSync(paths.headroomLogPath, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
68811
+ import_node_fs16.default.mkdirSync(paths.logsDir, { recursive: true });
68812
+ import_node_fs16.default.appendFileSync(paths.headroomLogPath, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
68544
68813
  `);
68545
68814
  }
68546
68815
  };
@@ -69253,224 +69522,6 @@ function buildProgram(deps) {
69253
69522
  var import_node_os8 = __toESM(require("node:os"), 1);
69254
69523
  var import_node_crypto16 = require("node:crypto");
69255
69524
 
69256
- // src/frontdoor/realFrontDoorPort.ts
69257
- var import_node_fs16 = __toESM(require("node:fs"), 1);
69258
-
69259
- // src/frontdoor/ensure.ts
69260
- var import_node_crypto13 = require("node:crypto");
69261
- var FRONTDOOR_START_TIMEOUT_MS = 3e4;
69262
- var FRONTDOOR_LAUNCHER_POLL_MS = 100;
69263
- var FrontDoorStartError = class extends CliError {
69264
- constructor(message) {
69265
- super(message);
69266
- this.name = "FrontDoorStartError";
69267
- }
69268
- };
69269
- function ensureFrontDoor(params) {
69270
- const { paths, ports } = params;
69271
- const deadline = ports.now() + FRONTDOOR_START_TIMEOUT_MS;
69272
- let spawned = false;
69273
- let replaced = false;
69274
- const distrusted = /* @__PURE__ */ new Set();
69275
- let lastRejection;
69276
- for (; ; ) {
69277
- const state = readFrontDoorState(ports.fs, paths.frontdoorStateFile);
69278
- const supervisorPid = state?.supervisorPid;
69279
- if (supervisorPid !== void 0 && !distrusted.has(supervisorPid) && ports.isRunning(supervisorPid)) {
69280
- if (state?.port !== void 0 && state.connectPort !== void 0) {
69281
- const verdict = ports.verifyListener(state.port);
69282
- if (verdict.ok && (state.protocol ?? 0) < FRONT_DOOR_PROTOCOL && !replaced) {
69283
- ports.stopSupervisor(supervisorPid);
69284
- while (ports.isRunning(supervisorPid)) {
69285
- if (ports.now() >= deadline) {
69286
- throw new FrontDoorStartError(`agent-shim: the front door (pid ${String(supervisorPid)}, protocol ${String(state.protocol ?? 0)}) is older than this launcher (protocol ${String(FRONT_DOOR_PROTOCOL)}) and did not exit when asked. Daemon log: ${paths.frontdoorLogPath}`);
69287
- }
69288
- ports.sleep(FRONTDOOR_LAUNCHER_POLL_MS);
69289
- }
69290
- replaced = true;
69291
- continue;
69292
- }
69293
- if (verdict.ok) {
69294
- const token = (0, import_node_crypto13.randomUUID)();
69295
- writeFrontDoorSession(ports.fs, paths.frontdoorSessionsDir, { pid: params.launcherPid, startedAt: ports.now(), token });
69296
- return { port: state.port, connectPort: state.connectPort, token };
69297
- }
69298
- lastRejection = `the listener on 127.0.0.1:${String(state.port)} (recorded for pid ${String(supervisorPid)}) failed authentication: ${verdict.reason}`;
69299
- if (spawned) {
69300
- throw new FrontDoorStartError(`agent-shim: ${lastRejection}; refusing to send this session's credentials to it. Daemon log: ${paths.frontdoorLogPath}`);
69301
- }
69302
- distrusted.add(supervisorPid);
69303
- continue;
69304
- }
69305
- if (state?.lastError !== void 0) {
69306
- throw new FrontDoorStartError(`agent-shim: the front door reported a fatal error and is not serving: ${state.lastError} (daemon log: ${paths.frontdoorLogPath})`);
69307
- }
69308
- } else if (!spawned) {
69309
- ports.fs.mkdirp(paths.frontdoorDir);
69310
- const created = ports.fs.writeFileExclusive(paths.frontdoorLockFile, `${JSON.stringify({ pid: params.launcherPid, at: ports.now() })}
69311
- `);
69312
- if (created) {
69313
- ports.spawnSupervisor(paths);
69314
- spawned = true;
69315
- } else {
69316
- const lock = readStartLock(ports.fs, paths.frontdoorLockFile);
69317
- if (lock === void 0 || !ports.isRunning(lock.pid)) {
69318
- ports.fs.removeRecursive(paths.frontdoorLockFile);
69319
- continue;
69320
- }
69321
- }
69322
- }
69323
- if (ports.now() >= deadline) {
69324
- const lastError = state?.lastError;
69325
- throw new FrontDoorStartError(
69326
- `agent-shim: the front door did not become ready within ${String(FRONTDOOR_START_TIMEOUT_MS)}ms` + (lastError === void 0 ? "" : ` (last error: ${lastError})`) + (lastRejection === void 0 ? "" : ` (${lastRejection})`) + `. Daemon log: ${paths.frontdoorLogPath}`
69327
- );
69328
- }
69329
- ports.sleep(FRONTDOOR_LAUNCHER_POLL_MS);
69330
- }
69331
- }
69332
-
69333
- // src/frontdoor/probe.ts
69334
- var import_node_worker_threads = require("node:worker_threads");
69335
- var PROBE_TIMEOUT_MS = 2e3;
69336
- var PROBE_WAIT_MARGIN_MS = 1e3;
69337
- var REASON_BYTES = 1024;
69338
- var HEADER_SLOTS = 2;
69339
- var INT32_BYTES = 4;
69340
- var VERDICT_PENDING = 0;
69341
- var VERDICT_OK = 1;
69342
- var VERDICT_FAILED = 2;
69343
- var PROBE_SOURCE = `
69344
- const { workerData } = require("node:worker_threads");
69345
- const https = require("node:https");
69346
- const { port, ca, timeoutMs, shared, ok, failed, headerSlots } = workerData;
69347
- const header = new Int32Array(shared, 0, headerSlots);
69348
- const reason = new Uint8Array(shared, headerSlots * 4);
69349
- let done = false;
69350
- const finish = (verdict, message) => {
69351
- if (done) return;
69352
- done = true;
69353
- const bytes = Buffer.from(String(message), "utf8").subarray(0, reason.length);
69354
- reason.set(bytes);
69355
- Atomics.store(header, 1, bytes.length);
69356
- Atomics.store(header, 0, verdict);
69357
- Atomics.notify(header, 0);
69358
- };
69359
- const request = https.get({ host: "127.0.0.1", port, path: "/healthz", ca: [ca], agent: false }, (response) => {
69360
- let body = "";
69361
- response.setEncoding("utf8");
69362
- response.on("data", (chunk) => { body += chunk; });
69363
- response.on("end", () => {
69364
- if (response.statusCode === 200 && body === "ok") finish(ok, "");
69365
- else finish(failed, "the listener answered its health probe with HTTP " + String(response.statusCode));
69366
- });
69367
- response.on("error", (error) => finish(failed, error.message));
69368
- });
69369
- // A hard deadline over the whole exchange, handshake included: a listener that accepts the connection and then says nothing must still fail the probe.
69370
- setTimeout(() => { request.destroy(new Error("no answer within " + String(timeoutMs) + "ms")); }, timeoutMs);
69371
- request.on("error", (error) => finish(failed, (error.code ? error.code + ": " : "") + error.message));
69372
- `;
69373
- function probeFrontDoorSync(port, caPem) {
69374
- const shared = new SharedArrayBuffer(HEADER_SLOTS * INT32_BYTES + REASON_BYTES);
69375
- const header = new Int32Array(shared, 0, HEADER_SLOTS);
69376
- const worker = new import_node_worker_threads.Worker(PROBE_SOURCE, {
69377
- eval: true,
69378
- workerData: { port, ca: caPem, timeoutMs: PROBE_TIMEOUT_MS, shared, ok: VERDICT_OK, failed: VERDICT_FAILED, headerSlots: HEADER_SLOTS }
69379
- });
69380
- worker.unref();
69381
- try {
69382
- const waited = Atomics.wait(header, 0, VERDICT_PENDING, PROBE_TIMEOUT_MS + PROBE_WAIT_MARGIN_MS);
69383
- const verdict = Atomics.load(header, 0);
69384
- if (waited === "timed-out" || verdict === VERDICT_PENDING) {
69385
- return { ok: false, reason: `the probe did not finish within ${String(PROBE_TIMEOUT_MS + PROBE_WAIT_MARGIN_MS)}ms` };
69386
- }
69387
- if (verdict === VERDICT_OK) {
69388
- return { ok: true };
69389
- }
69390
- const length = Atomics.load(header, 1);
69391
- return { ok: false, reason: Buffer.from(new Uint8Array(shared, HEADER_SLOTS * INT32_BYTES, length)).toString("utf8") };
69392
- } finally {
69393
- void worker.terminate();
69394
- }
69395
- }
69396
-
69397
- // src/frontdoor/trust.ts
69398
- var import_node_crypto14 = require("node:crypto");
69399
- var import_node_path36 = __toESM(require("node:path"), 1);
69400
- var BUNDLE_NAME_HEX_DIGITS = 16;
69401
- function resolveTrustBundle(params) {
69402
- const { caCertFile, bundlesDir, inherited, fs: fs19 } = params;
69403
- if (inherited === void 0 || inherited === "" || import_node_path36.default.resolve(inherited) === import_node_path36.default.resolve(caCertFile)) {
69404
- return { path: caCertFile };
69405
- }
69406
- const caPem = fs19.readFileUtf8(caCertFile);
69407
- let inheritedPem;
69408
- try {
69409
- inheritedPem = fs19.readFileUtf8(inherited);
69410
- } catch (error62) {
69411
- return {
69412
- path: caCertFile,
69413
- warning: `agent-shim: NODE_EXTRA_CA_CERTS names ${inherited}, which could not be read (${error62 instanceof Error ? error62.message : String(error62)}); the child trusts agent-shim's front-door CA on top of the system store, and nothing from that file`
69414
- };
69415
- }
69416
- if (inheritedPem.includes(caPem.trim())) {
69417
- return { path: inherited };
69418
- }
69419
- const combined = `${inheritedPem.trimEnd()}
69420
- ${caPem}`;
69421
- const bundle = import_node_path36.default.join(bundlesDir, `${(0, import_node_crypto14.createHash)("sha256").update(combined).digest("hex").slice(0, BUNDLE_NAME_HEX_DIGITS)}.pem`);
69422
- if (!fs19.exists(bundle)) {
69423
- fs19.mkdirp(bundlesDir);
69424
- fs19.writeFileAtomic(bundle, combined);
69425
- }
69426
- return { path: bundle };
69427
- }
69428
-
69429
- // src/frontdoor/realFrontDoorPort.ts
69430
- var realTrustBundleFs = {
69431
- readFileUtf8: (file2) => import_node_fs16.default.readFileSync(file2, "utf8"),
69432
- exists: (file2) => import_node_fs16.default.existsSync(file2),
69433
- mkdirp: (dir) => {
69434
- import_node_fs16.default.mkdirSync(dir, { recursive: true });
69435
- },
69436
- writeFileAtomic: (file2, contents) => {
69437
- realFarmFs.writeFilePrivate(file2, contents);
69438
- }
69439
- };
69440
- function realFrontDoorPort(paths, spawnDaemon) {
69441
- return {
69442
- ensure: (inheritedExtraCaCerts) => {
69443
- const up = ensureFrontDoor({
69444
- paths,
69445
- launcherPid: process.pid,
69446
- ports: {
69447
- fs: realFarmFs,
69448
- isRunning: realIsProcessRunning,
69449
- now: () => Date.now(),
69450
- sleep: realSleepSync,
69451
- spawnSupervisor: (layout) => spawnDaemon(layout, "__frontdoor-supervisor", layout.frontdoorLogPath),
69452
- stopSupervisor: (pid) => {
69453
- process.kill(pid, "SIGTERM");
69454
- },
69455
- // Read fresh for each probe: a replacement supervisor may have regenerated an unparseable CA, and the probe must trust exactly what the serving door's leaf chains to.
69456
- verifyListener: (port) => probeFrontDoorSync(port, import_node_fs16.default.readFileSync(paths.frontdoorCaCertFile, "utf8"))
69457
- }
69458
- });
69459
- const trust = resolveTrustBundle({ caCertFile: paths.frontdoorCaCertFile, bundlesDir: paths.frontdoorCaBundlesDir, inherited: inheritedExtraCaCerts, fs: realTrustBundleFs });
69460
- return {
69461
- port: up.port,
69462
- connectPort: up.connectPort,
69463
- trustBundlePath: trust.path,
69464
- ...trust.warning === void 0 ? {} : { trustWarning: trust.warning },
69465
- sessionToken: up.token
69466
- };
69467
- },
69468
- release: () => {
69469
- removeFrontDoorSession(realFarmFs, paths.frontdoorSessionsDir, process.pid);
69470
- }
69471
- };
69472
- }
69473
-
69474
69525
  // src/launcher.ts
69475
69526
  var import_node_crypto15 = require("node:crypto");
69476
69527
  var import_node_path38 = __toESM(require("node:path"), 1);
package/dist/index.cjs CHANGED
@@ -2959,14 +2959,11 @@ ${request3.rawHeaders.reduce((accumulated, value, index) => index % 2 === 0 ? `$
2959
2959
  };
2960
2960
  const sessions = config.interceptHosts.map((host) => {
2961
2961
  if (config.tapHosts.includes(host)) {
2962
- const tlsAcceptor2 = effects.createTlsAcceptor(
2963
- config.leafFor(host),
2964
- (secure) => {
2965
- pumpTapSession(secure, async (clientAlpn) => await effects.connectTlsUpstream(host, HTTPS_PORT, clientAlpn), config.capture?.tapStream?.(host));
2966
- },
2967
- TAP_ALPN_PROTOCOLS
2968
- );
2969
- return { host, tlsAcceptor: tlsAcceptor2 };
2962
+ const serveSecure2 = (secure) => {
2963
+ pumpTapSession(secure, async (clientAlpn) => await effects.connectTlsUpstream(host, HTTPS_PORT, clientAlpn), config.capture?.tapStream?.(host));
2964
+ };
2965
+ const tlsAcceptor2 = effects.createTlsAcceptor(config.leafFor(host), serveSecure2, TAP_ALPN_PROTOCOLS);
2966
+ return { host, serveSecure: serveSecure2, tlsAcceptor: tlsAcceptor2 };
2970
2967
  }
2971
2968
  const handler = (request3, response) => {
2972
2969
  if (host === config.routedHost && servedByPipeline(request3.url)) {
@@ -2980,19 +2977,29 @@ ${request3.rawHeaders.reduce((accumulated, value, index) => index % 2 === 0 ? `$
2980
2977
  effects.forwardHttp(config.upstreamFor(host), request3, response, config.capture?.observePassthrough(request3));
2981
2978
  };
2982
2979
  const httpSession = effects.createHttpSession(handler, onUpgrade(host));
2983
- const tlsAcceptor = effects.createTlsAcceptor(config.leafFor(host), (secure, context) => {
2984
- tunnelTokens.set(secure, context);
2980
+ const serveSecure = (secure, capability) => {
2981
+ tunnelTokens.set(secure, capability);
2985
2982
  httpSession.serve(secure);
2986
- });
2987
- return { host, httpSession, tlsAcceptor };
2983
+ };
2984
+ const tlsAcceptor = effects.createTlsAcceptor(config.leafFor(host), serveSecure);
2985
+ return { host, serveSecure, httpSession, tlsAcceptor };
2988
2986
  });
2989
2987
  const ledger = createConnectionLedger(config.limits, config.isLiveCapability);
2990
2988
  const listener = await effects.listenLoopback(preferredPort, (socket) => {
2991
2989
  handleConnect(socket, { config, effects, sessions, ledger });
2992
2990
  });
2993
2991
  const apiSession = sessions.find((session) => session.host === config.routedHost);
2992
+ const sessionByHost = new Map(sessions.map((session) => [session.host, session]));
2993
+ const sniAcceptor = effects.createSniTlsAcceptor(config.interceptHosts, config.leafFor, (secure, host) => {
2994
+ const session = sessionByHost.get(host);
2995
+ if (session === void 0) {
2996
+ secure.destroy();
2997
+ return;
2998
+ }
2999
+ session.serveSecure(secure, config.transparentCapability ?? "");
3000
+ });
2994
3001
  const transparent = config.transparentPort === void 0 || apiSession === void 0 ? void 0 : await effects.listenLoopback(config.transparentPort, (socket) => {
2995
- apiSession.tlsAcceptor.accept(socket, config.transparentCapability ?? "");
3002
+ sniAcceptor.accept(socket);
2996
3003
  });
2997
3004
  return {
2998
3005
  port: listener.port,
@@ -3001,6 +3008,7 @@ ${request3.rawHeaders.reduce((accumulated, value, index) => index % 2 === 0 ? `$
3001
3008
  ledger.close();
3002
3009
  await listener.close();
3003
3010
  await transparent?.close();
3011
+ sniAcceptor.close();
3004
3012
  for (const { httpSession, tlsAcceptor } of sessions) {
3005
3013
  tlsAcceptor.close();
3006
3014
  httpSession?.close();
@@ -3330,6 +3338,38 @@ function realConnectEffects() {
3330
3338
  }
3331
3339
  };
3332
3340
  },
3341
+ createSniTlsAcceptor: (hosts, leafFor, onSecure) => {
3342
+ const contexts = new Map(
3343
+ hosts.map((host) => {
3344
+ const leaf = leafFor(host);
3345
+ return [host, tls.createSecureContext({ key: leaf.keyPem, cert: leaf.certPem })];
3346
+ })
3347
+ );
3348
+ const server = tls.createServer(
3349
+ {
3350
+ // No default key or cert exists, so the SNI selection is the only source of credentials: an unserved name fails its handshake here, and a client that sent no SNI at all never reaches the selection and fails for want of any certificate, which is exactly the fail-closed shape this surface wants.
3351
+ SNICallback: (servername, callback) => {
3352
+ const context = contexts.get(servername);
3353
+ callback(context === void 0 ? new Error(`the transparent surface serves no host named ${servername}`) : null, context);
3354
+ }
3355
+ },
3356
+ (secure) => {
3357
+ onSecure(secure, typeof secure.servername === "string" ? secure.servername : "");
3358
+ }
3359
+ );
3360
+ server.on("clientError", (_error, socket) => {
3361
+ socket.destroy();
3362
+ });
3363
+ return {
3364
+ // The same hand-off the single-leaf acceptor uses: emitting the connection event runs the TLS server's own connection listener on a socket this process already owns (the redirect half of the connection) rather than one the server accepted itself.
3365
+ accept: (socket) => {
3366
+ server.emit("connection", socket);
3367
+ },
3368
+ close: () => {
3369
+ server.close();
3370
+ }
3371
+ };
3372
+ },
3333
3373
  createHttpSession: (handler, onUpgrade) => {
3334
3374
  const server = http.createServer(handler);
3335
3375
  server.on("clientError", (_error, socket) => {
@@ -8142,7 +8182,7 @@ var import_node_crypto10 = require("node:crypto");
8142
8182
  var import_node_path28 = __toESM(require("node:path"), 1);
8143
8183
  var BUNDLE_NAME_HEX_DIGITS = 16;
8144
8184
  function resolveTrustBundle(params) {
8145
- const { caCertFile, bundlesDir, inherited, fs: fs12 } = params;
8185
+ const { caCertFile, bundlesDir, variable, inherited, fs: fs12 } = params;
8146
8186
  if (inherited === void 0 || inherited === "" || import_node_path28.default.resolve(inherited) === import_node_path28.default.resolve(caCertFile)) {
8147
8187
  return { path: caCertFile };
8148
8188
  }
@@ -8153,7 +8193,7 @@ function resolveTrustBundle(params) {
8153
8193
  } catch (error) {
8154
8194
  return {
8155
8195
  path: caCertFile,
8156
- warning: `agent-shim: NODE_EXTRA_CA_CERTS names ${inherited}, which could not be read (${error instanceof Error ? error.message : String(error)}); the child trusts agent-shim's front-door CA on top of the system store, and nothing from that file`
8196
+ warning: `agent-shim: ${variable} names ${inherited}, which could not be read (${error instanceof Error ? error.message : String(error)}); the child trusts agent-shim's front-door CA on top of the system store, and nothing from that file`
8157
8197
  };
8158
8198
  }
8159
8199
  if (inheritedPem.includes(caPem.trim())) {
@@ -8199,7 +8239,7 @@ function realFrontDoorPort(paths, spawnDaemon) {
8199
8239
  verifyListener: (port) => probeFrontDoorSync(port, import_node_fs10.default.readFileSync(paths.frontdoorCaCertFile, "utf8"))
8200
8240
  }
8201
8241
  });
8202
- const trust = resolveTrustBundle({ caCertFile: paths.frontdoorCaCertFile, bundlesDir: paths.frontdoorCaBundlesDir, inherited: inheritedExtraCaCerts, fs: realTrustBundleFs });
8242
+ const trust = resolveTrustBundle({ caCertFile: paths.frontdoorCaCertFile, bundlesDir: paths.frontdoorCaBundlesDir, variable: "NODE_EXTRA_CA_CERTS", inherited: inheritedExtraCaCerts, fs: realTrustBundleFs });
8203
8243
  return {
8204
8244
  port: up.port,
8205
8245
  connectPort: up.connectPort,
package/dist/index.mjs CHANGED
@@ -2793,14 +2793,11 @@ ${request3.rawHeaders.reduce((accumulated, value, index) => index % 2 === 0 ? `$
2793
2793
  };
2794
2794
  const sessions = config.interceptHosts.map((host) => {
2795
2795
  if (config.tapHosts.includes(host)) {
2796
- const tlsAcceptor2 = effects.createTlsAcceptor(
2797
- config.leafFor(host),
2798
- (secure) => {
2799
- pumpTapSession(secure, async (clientAlpn) => await effects.connectTlsUpstream(host, HTTPS_PORT, clientAlpn), config.capture?.tapStream?.(host));
2800
- },
2801
- TAP_ALPN_PROTOCOLS
2802
- );
2803
- return { host, tlsAcceptor: tlsAcceptor2 };
2796
+ const serveSecure2 = (secure) => {
2797
+ pumpTapSession(secure, async (clientAlpn) => await effects.connectTlsUpstream(host, HTTPS_PORT, clientAlpn), config.capture?.tapStream?.(host));
2798
+ };
2799
+ const tlsAcceptor2 = effects.createTlsAcceptor(config.leafFor(host), serveSecure2, TAP_ALPN_PROTOCOLS);
2800
+ return { host, serveSecure: serveSecure2, tlsAcceptor: tlsAcceptor2 };
2804
2801
  }
2805
2802
  const handler = (request3, response) => {
2806
2803
  if (host === config.routedHost && servedByPipeline(request3.url)) {
@@ -2814,19 +2811,29 @@ ${request3.rawHeaders.reduce((accumulated, value, index) => index % 2 === 0 ? `$
2814
2811
  effects.forwardHttp(config.upstreamFor(host), request3, response, config.capture?.observePassthrough(request3));
2815
2812
  };
2816
2813
  const httpSession = effects.createHttpSession(handler, onUpgrade(host));
2817
- const tlsAcceptor = effects.createTlsAcceptor(config.leafFor(host), (secure, context) => {
2818
- tunnelTokens.set(secure, context);
2814
+ const serveSecure = (secure, capability) => {
2815
+ tunnelTokens.set(secure, capability);
2819
2816
  httpSession.serve(secure);
2820
- });
2821
- return { host, httpSession, tlsAcceptor };
2817
+ };
2818
+ const tlsAcceptor = effects.createTlsAcceptor(config.leafFor(host), serveSecure);
2819
+ return { host, serveSecure, httpSession, tlsAcceptor };
2822
2820
  });
2823
2821
  const ledger = createConnectionLedger(config.limits, config.isLiveCapability);
2824
2822
  const listener = await effects.listenLoopback(preferredPort, (socket) => {
2825
2823
  handleConnect(socket, { config, effects, sessions, ledger });
2826
2824
  });
2827
2825
  const apiSession = sessions.find((session) => session.host === config.routedHost);
2826
+ const sessionByHost = new Map(sessions.map((session) => [session.host, session]));
2827
+ const sniAcceptor = effects.createSniTlsAcceptor(config.interceptHosts, config.leafFor, (secure, host) => {
2828
+ const session = sessionByHost.get(host);
2829
+ if (session === void 0) {
2830
+ secure.destroy();
2831
+ return;
2832
+ }
2833
+ session.serveSecure(secure, config.transparentCapability ?? "");
2834
+ });
2828
2835
  const transparent = config.transparentPort === void 0 || apiSession === void 0 ? void 0 : await effects.listenLoopback(config.transparentPort, (socket) => {
2829
- apiSession.tlsAcceptor.accept(socket, config.transparentCapability ?? "");
2836
+ sniAcceptor.accept(socket);
2830
2837
  });
2831
2838
  return {
2832
2839
  port: listener.port,
@@ -2835,6 +2842,7 @@ ${request3.rawHeaders.reduce((accumulated, value, index) => index % 2 === 0 ? `$
2835
2842
  ledger.close();
2836
2843
  await listener.close();
2837
2844
  await transparent?.close();
2845
+ sniAcceptor.close();
2838
2846
  for (const { httpSession, tlsAcceptor } of sessions) {
2839
2847
  tlsAcceptor.close();
2840
2848
  httpSession?.close();
@@ -3164,6 +3172,38 @@ function realConnectEffects() {
3164
3172
  }
3165
3173
  };
3166
3174
  },
3175
+ createSniTlsAcceptor: (hosts, leafFor, onSecure) => {
3176
+ const contexts = new Map(
3177
+ hosts.map((host) => {
3178
+ const leaf = leafFor(host);
3179
+ return [host, tls.createSecureContext({ key: leaf.keyPem, cert: leaf.certPem })];
3180
+ })
3181
+ );
3182
+ const server = tls.createServer(
3183
+ {
3184
+ // No default key or cert exists, so the SNI selection is the only source of credentials: an unserved name fails its handshake here, and a client that sent no SNI at all never reaches the selection and fails for want of any certificate, which is exactly the fail-closed shape this surface wants.
3185
+ SNICallback: (servername, callback) => {
3186
+ const context = contexts.get(servername);
3187
+ callback(context === void 0 ? new Error(`the transparent surface serves no host named ${servername}`) : null, context);
3188
+ }
3189
+ },
3190
+ (secure) => {
3191
+ onSecure(secure, typeof secure.servername === "string" ? secure.servername : "");
3192
+ }
3193
+ );
3194
+ server.on("clientError", (_error, socket) => {
3195
+ socket.destroy();
3196
+ });
3197
+ return {
3198
+ // The same hand-off the single-leaf acceptor uses: emitting the connection event runs the TLS server's own connection listener on a socket this process already owns (the redirect half of the connection) rather than one the server accepted itself.
3199
+ accept: (socket) => {
3200
+ server.emit("connection", socket);
3201
+ },
3202
+ close: () => {
3203
+ server.close();
3204
+ }
3205
+ };
3206
+ },
3167
3207
  createHttpSession: (handler, onUpgrade) => {
3168
3208
  const server = http.createServer(handler);
3169
3209
  server.on("clientError", (_error, socket) => {
@@ -7976,7 +8016,7 @@ import { createHash as createHash5 } from "node:crypto";
7976
8016
  import path28 from "node:path";
7977
8017
  var BUNDLE_NAME_HEX_DIGITS = 16;
7978
8018
  function resolveTrustBundle(params) {
7979
- const { caCertFile, bundlesDir, inherited, fs: fs12 } = params;
8019
+ const { caCertFile, bundlesDir, variable, inherited, fs: fs12 } = params;
7980
8020
  if (inherited === void 0 || inherited === "" || path28.resolve(inherited) === path28.resolve(caCertFile)) {
7981
8021
  return { path: caCertFile };
7982
8022
  }
@@ -7987,7 +8027,7 @@ function resolveTrustBundle(params) {
7987
8027
  } catch (error) {
7988
8028
  return {
7989
8029
  path: caCertFile,
7990
- warning: `agent-shim: NODE_EXTRA_CA_CERTS names ${inherited}, which could not be read (${error instanceof Error ? error.message : String(error)}); the child trusts agent-shim's front-door CA on top of the system store, and nothing from that file`
8030
+ warning: `agent-shim: ${variable} names ${inherited}, which could not be read (${error instanceof Error ? error.message : String(error)}); the child trusts agent-shim's front-door CA on top of the system store, and nothing from that file`
7991
8031
  };
7992
8032
  }
7993
8033
  if (inheritedPem.includes(caPem.trim())) {
@@ -8033,7 +8073,7 @@ function realFrontDoorPort(paths, spawnDaemon) {
8033
8073
  verifyListener: (port) => probeFrontDoorSync(port, fs11.readFileSync(paths.frontdoorCaCertFile, "utf8"))
8034
8074
  }
8035
8075
  });
8036
- const trust = resolveTrustBundle({ caCertFile: paths.frontdoorCaCertFile, bundlesDir: paths.frontdoorCaBundlesDir, inherited: inheritedExtraCaCerts, fs: realTrustBundleFs });
8076
+ const trust = resolveTrustBundle({ caCertFile: paths.frontdoorCaCertFile, bundlesDir: paths.frontdoorCaBundlesDir, variable: "NODE_EXTRA_CA_CERTS", inherited: inheritedExtraCaCerts, fs: realTrustBundleFs });
8037
8077
  return {
8038
8078
  port: up.port,
8039
8079
  connectPort: up.connectPort,
@@ -686,7 +686,7 @@ interface LayoutPaths {
686
686
  readonly frontdoorCaCertFile: string;
687
687
  /** Path to the front door's CA private key, mode 0600: whoever can read it can impersonate the door to every routed child. */
688
688
  readonly frontdoorCaKeyFile: string;
689
- /** Directory of combined CA bundles: a parent environment's own NODE_EXTRA_CA_CERTS file plus the front door's CA, one file per distinct combination, for children that must keep trusting both. */
689
+ /** Directory of combined CA bundles: a parent environment's own NODE_EXTRA_CA_CERTS (for a routed child) or HEADROOM_CA_BUNDLE (for the headroom daemon) file plus the front door's CA, one file per distinct combination, for processes that must keep trusting both. */
690
690
  readonly frontdoorCaBundlesDir: string;
691
691
  /** Path to the front door's state.json: its supervisor pid, its port, and the sticky port it last served on. */
692
692
  readonly frontdoorStateFile: string;
@@ -1470,7 +1470,7 @@ declare function mintLeaf(ca: CaMaterial, names: readonly string[], now: Readonl
1470
1470
  /** Mints each host's leaf once from the CA and reuses it, because generating a 2048-bit RSA keypair costs noticeable time and nothing about the certificate changes between sessions. */
1471
1471
  declare function createLeafCache(ca: CaMaterial, now: () => Date): (host: string) => LeafCert;
1472
1472
  /**
1473
- * Persistence for the CA: generated once on the machine's first headroom start and reused forever after, because regenerating it would invalidate every child's `NODE_EXTRA_CA_CERTS` pointing at the old file. The real implementation writes the key with mode 0600; injected fakes keep it in memory.
1473
+ * Persistence for the CA: generated once on the machine's first front-door start and reused forever after, because regenerating it would invalidate every child's `NODE_EXTRA_CA_CERTS` pointing at the old file. The real implementation writes the key with mode 0600; injected fakes keep it in memory.
1474
1474
  */
1475
1475
  interface ConnectCertStore {
1476
1476
  /** The persisted CA, or undefined when none exists yet (or only half of it does: a cert without its key can sign nothing, so it counts as absent). */
@@ -1509,6 +1509,13 @@ interface TlsAcceptor {
1509
1509
  /** Drops every session this acceptor is terminating. */
1510
1510
  readonly close: () => void;
1511
1511
  }
1512
+ /** Presents every served host's leaf on one handshake, selected by the SNI name the client sent: the transparent surface's terminator, answering each redirected connection as the host it named. */
1513
+ interface SniTlsAcceptor {
1514
+ /** Starts the TLS handshake on a redirected socket; a handshake that completes for a served host hands its secure session to the acceptor's `onSecure` with that host's name. */
1515
+ readonly accept: (socket: net.Socket) => void;
1516
+ /** Drops every session this acceptor is terminating. */
1517
+ readonly close: () => void;
1518
+ }
1512
1519
  /** Parses HTTP over terminated sockets and serves each request through the handler, keep-alive included. */
1513
1520
  interface HttpParserSession {
1514
1521
  readonly serve: (socket: net.Socket) => void;
@@ -1522,6 +1529,8 @@ interface ConnectEffects {
1522
1529
  readonly listenLoopback: (preferredPort: number | undefined, onSocket: (socket: net.Socket) => void) => Promise<ConnectListenerHandle>;
1523
1530
  /** Builds the TLS terminator for one leaf, handing each successfully handshaked session to `onSecure` together with the context its `accept` call carried. `alpnProtocols`, when given, is advertised during the handshake, which is how a tap host's session can offer HTTP/2 the way its real server does. */
1524
1531
  readonly createTlsAcceptor: (leaf: LeafCert, onSecure: (secure: net.Socket, context: string) => void, alpnProtocols?: readonly string[]) => TlsAcceptor;
1532
+ /** Builds the transparent surface's TLS terminator: one handshake whose SNI name selects which served host's leaf answers it, so a connection redirected from any of the hosts sharing the redirected address is terminated as the host it named. A name the surface does not serve is answered with no certificate at all (a client that sends no SNI never reaches the selection and finds no default certificate either), because the only leaf that could answer such a name is another host's, which the client's own hostname check would reject anyway. No ALPN is offered: node's TLS server negotiates ALPN from the listener's own options and ignores protocols named on an SNI-selected secure context, and the API host's terminated session is parsed as HTTP/1.1, which a negotiated `h2` would strand. */
1533
+ readonly createSniTlsAcceptor: (hosts: readonly string[], leafFor: (host: string) => LeafCert, onSecure: (secure: net.Socket, host: string) => void) => SniTlsAcceptor;
1525
1534
  /** Opens a TLS connection to a real host, presenting its name as SNI, for a tap session's upstream half. `clientAlpn`, when given, is the protocol the client negotiated with the surface and the only one offered upstream, so the tap never changes the channel's protocol. */
1526
1535
  readonly connectTlsUpstream: (host: string, port: number, clientAlpn: string | undefined) => Promise<net.Socket>;
1527
1536
  /** Builds the HTTP parser bound to one request handler. `onUpgrade`, when given, owns every HTTP upgrade request arriving on a terminated session, socket and already-read bytes included; a session without one lets Node destroy the upgrade, its own no-listener behaviour. */
@@ -1550,7 +1559,7 @@ interface ConnectServerConfig {
1550
1559
  /** Whether a capability presented as a CONNECT request's proxy credential belongs to a live launch: the same constant-time check against the same registry the routed pipeline's admission makes, read fresh on every call because launches come and go. */
1551
1560
  readonly isLiveCapability: (token: string) => boolean;
1552
1561
  /**
1553
- * Binds a second listener that serves the intercept hosts' sessions to connections arriving pre-established, with no CONNECT handshake to authenticate: what an operating-system redirect (a pf rule sending the real hosts' address to this port) delivers. Only the API host's session is reachable this way, never a tap host's, because a redirect can name only addresses and the control plane shares the API host's address today; if that ever splits, the transparent listener must not follow it blindly. There is no capability to check on this surface: the connection carried no proxy credential, so every request rides the pipeline anonymously, admitted the way a tunnel's own header-less requests are (by nothing, which is exactly what they present), and the surface exists solely for clients that ignore proxies and would otherwise be invisible to the door entirely.
1562
+ * Binds a second listener that terminates redirected connections per host, with no CONNECT handshake to authenticate: what an operating-system redirect (a pf rule sending the real hosts' address to this port) delivers, pre-established. A redirect names addresses, and several real hosts share the API host's, so the client's TLS SNI name is what says which host arrived: each intercept host is served with its own leaf and its own session exactly as a CONNECT-tunnelled connection to that host is (the API host's `/v1/` rides the routed pipeline, a tap host's stream is pumped to its real host), and a name outside the intercept set, or a connection presenting none, is refused with no certificate at all, because the only leaf that could answer it is another host's. There is no capability to check on this surface: the connection carried no proxy credential, so every request rides the pipeline anonymously, admitted the way a tunnel's own header-less requests are (by nothing, which is exactly what they present), and the surface exists solely for clients that ignore proxies and would otherwise be invisible to the door entirely.
1554
1563
  */
1555
1564
  readonly transparentPort?: number;
1556
1565
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-shim",
3
- "version": "8.0.0",
3
+ "version": "8.0.2",
4
4
  "description": "A profile manager and launcher for Claude Code that lets one person run multiple logins from one machine while controlling what gets shared between them.",
5
5
  "license": "MIT",
6
6
  "author": "Joseph Mearman <joseph@mearman.co.uk>",