@ait-co/devtools 0.1.137 → 0.1.139

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 (119) hide show
  1. package/dist/{attach-orchestrator-Cjdhir2U.js → attach-orchestrator-6kz_ZdWL.js} +5 -3
  2. package/dist/{attach-orchestrator-Cjdhir2U.js.map → attach-orchestrator-6kz_ZdWL.js.map} +1 -1
  3. package/dist/{attach-orchestrator-CE0S09YU.js → attach-orchestrator-BEiz3wAx.js} +5 -3
  4. package/dist/{attach-orchestrator-CMoDuG2A.js.map → attach-orchestrator-BEiz3wAx.js.map} +1 -1
  5. package/dist/{attach-orchestrator-CMoDuG2A.js → attach-orchestrator-BG8TGJOZ.js} +5 -3
  6. package/dist/{attach-orchestrator-CE0S09YU.js.map → attach-orchestrator-BG8TGJOZ.js.map} +1 -1
  7. package/dist/{bundle-BKqyhEK9.d.ts → bundle-C796JIwG.d.ts} +65 -2
  8. package/dist/bundle-C796JIwG.d.ts.map +1 -0
  9. package/dist/{capture-ltuV0gZa.d.ts → capture-DsP525OZ.d.ts} +1 -1
  10. package/dist/{capture-ltuV0gZa.d.ts.map → capture-DsP525OZ.d.ts.map} +1 -1
  11. package/dist/{cdp-connection-BYE9meXe.d.ts → cdp-connection-rP1WdnH5.d.ts} +1 -1
  12. package/dist/{cdp-connection-BYE9meXe.d.ts.map → cdp-connection-rP1WdnH5.d.ts.map} +1 -1
  13. package/dist/{cell-D1y4shoV.js → cell-BMlFh08P.js} +2 -2
  14. package/dist/cell-BMlFh08P.js.map +1 -0
  15. package/dist/{cell-DHA578lX.js → cell-Dtgtusog.js} +90 -16
  16. package/dist/cell-Dtgtusog.js.map +1 -0
  17. package/dist/{cell-BIdSQb9T.js → cell-KsRXFdDs.js} +122 -16
  18. package/dist/cell-KsRXFdDs.js.map +1 -0
  19. package/dist/{debug-server-CBK7dn8O.js → debug-server-6c22qSWa.js} +132 -9
  20. package/dist/debug-server-6c22qSWa.js.map +1 -0
  21. package/dist/{debug-server-BQplCzf8.js → debug-server-BQHbaKPT.js} +134 -11
  22. package/dist/debug-server-BQHbaKPT.js.map +1 -0
  23. package/dist/{debug-server-D2wpPK3_.js → debug-server-DWat7d-9.js} +82 -6
  24. package/dist/debug-server-DWat7d-9.js.map +1 -0
  25. package/dist/devtools-opener-3Drge_RJ.js +75 -0
  26. package/dist/devtools-opener-3Drge_RJ.js.map +1 -0
  27. package/dist/devtools-opener-CJpEsXXQ.js +76 -0
  28. package/dist/devtools-opener-CJpEsXXQ.js.map +1 -0
  29. package/dist/devtools-opener-CxtryS8c.js +75 -0
  30. package/dist/devtools-opener-CxtryS8c.js.map +1 -0
  31. package/dist/in-app/auto.js +59 -12
  32. package/dist/in-app/auto.js.map +1 -1
  33. package/dist/in-app/index.d.ts +21 -2
  34. package/dist/in-app/index.d.ts.map +1 -1
  35. package/dist/in-app/index.js +60 -13
  36. package/dist/in-app/index.js.map +1 -1
  37. package/dist/mcp/cli.js +299 -60
  38. package/dist/mcp/cli.js.map +1 -1
  39. package/dist/mcp/server.js +1 -1
  40. package/dist/mock/index.d.ts +79 -1
  41. package/dist/mock/index.d.ts.map +1 -1
  42. package/dist/mock/index.js +218 -14
  43. package/dist/mock/index.js.map +1 -1
  44. package/dist/panel/index.js +171 -10
  45. package/dist/panel/index.js.map +1 -1
  46. package/dist/{pool-CmfvXnwh.d.ts → pool-CrP5CPvU.d.ts} +3 -3
  47. package/dist/{pool-CmfvXnwh.d.ts.map → pool-CrP5CPvU.d.ts.map} +1 -1
  48. package/dist/{qr-http-server-ZkT6F7is.js → qr-http-server-D1hpoyBF.js} +1 -1
  49. package/dist/{qr-http-server-ZkT6F7is.js.map → qr-http-server-D1hpoyBF.js.map} +1 -1
  50. package/dist/qr-http-server-D4rGz8M7.js +1640 -0
  51. package/dist/qr-http-server-D4rGz8M7.js.map +1 -0
  52. package/dist/{qr-http-server-C7q8Gn04.js → qr-http-server-DIjCV7h_.js} +1 -1
  53. package/dist/{qr-http-server-C7q8Gn04.js.map → qr-http-server-DIjCV7h_.js.map} +1 -1
  54. package/dist/{relay-factory-DVzpBRxz.js → relay-factory-C1X3G3WY.js} +71 -15
  55. package/dist/relay-factory-C1X3G3WY.js.map +1 -0
  56. package/dist/{relay-secret-store-CLEGyHou.js → relay-secret-store-Bmyleu0A.js} +1 -1
  57. package/dist/{relay-secret-store-CLEGyHou.js.map → relay-secret-store-Bmyleu0A.js.map} +1 -1
  58. package/dist/{relay-secret-store-DhzAnnj-.js → relay-secret-store-CQenfcSL.js} +2 -2
  59. package/dist/{relay-secret-store-DhzAnnj-.js.map → relay-secret-store-CQenfcSL.js.map} +1 -1
  60. package/dist/{relay-secret-store-BcVrWwTq.js → relay-secret-store-DKxs7zwq.js} +1 -1
  61. package/dist/{relay-secret-store-BcVrWwTq.js.map → relay-secret-store-DKxs7zwq.js.map} +1 -1
  62. package/dist/{relay-secret-store-DGduVJhs.js → relay-secret-store-WJ8EGkIl.js} +1 -1
  63. package/dist/{relay-secret-store-DGduVJhs.js.map → relay-secret-store-WJ8EGkIl.js.map} +1 -1
  64. package/dist/{relay-url-store-CKW8RQzf.js → relay-url-store-BR2XodiO.js} +2 -2
  65. package/dist/{relay-url-store-CKW8RQzf.js.map → relay-url-store-BR2XodiO.js.map} +1 -1
  66. package/dist/{relay-url-store-CdA58fgw.js → relay-url-store-CH63fVCm.js} +2 -2
  67. package/dist/{relay-url-store-B0X8TsGr.js.map → relay-url-store-CH63fVCm.js.map} +1 -1
  68. package/dist/{relay-url-store-CwKT7i04.js → relay-url-store-DaY1QPes.js} +2 -2
  69. package/dist/{relay-url-store-CwKT7i04.js.map → relay-url-store-DaY1QPes.js.map} +1 -1
  70. package/dist/{relay-url-store-B0X8TsGr.js → relay-url-store-xmUuTjXA.js} +2 -2
  71. package/dist/{relay-url-store-CdA58fgw.js.map → relay-url-store-xmUuTjXA.js.map} +1 -1
  72. package/dist/{relay-worker-6fy1eaIo.js → relay-worker-BUXI0K0b.js} +8 -4
  73. package/dist/{relay-worker-6fy1eaIo.js.map → relay-worker-BUXI0K0b.js.map} +1 -1
  74. package/dist/{relay-worker-DJnZkXza.d.ts → relay-worker-DcyboK43.d.ts} +30 -5
  75. package/dist/relay-worker-DcyboK43.d.ts.map +1 -0
  76. package/dist/{runtime-DfHHZms2.d.ts → runtime-BiigOuvb.d.ts} +30 -3
  77. package/dist/runtime-BiigOuvb.d.ts.map +1 -0
  78. package/dist/test-runner/bin.js +376 -38
  79. package/dist/test-runner/bin.js.map +1 -1
  80. package/dist/test-runner/bundle.d.ts +2 -2
  81. package/dist/test-runner/bundle.js +66 -13
  82. package/dist/test-runner/bundle.js.map +1 -1
  83. package/dist/test-runner/capture.d.ts +1 -1
  84. package/dist/test-runner/config.d.ts +58 -5
  85. package/dist/test-runner/config.d.ts.map +1 -1
  86. package/dist/test-runner/config.js +1 -1
  87. package/dist/test-runner/method-pace.d.ts +82 -0
  88. package/dist/test-runner/method-pace.d.ts.map +1 -0
  89. package/dist/test-runner/method-pace.js +120 -0
  90. package/dist/test-runner/method-pace.js.map +1 -0
  91. package/dist/test-runner/pool.d.ts +1 -1
  92. package/dist/test-runner/pool.js +1 -1
  93. package/dist/test-runner/relay-factory.d.ts +57 -4
  94. package/dist/test-runner/relay-factory.d.ts.map +1 -1
  95. package/dist/test-runner/relay-factory.js +70 -14
  96. package/dist/test-runner/relay-factory.js.map +1 -1
  97. package/dist/test-runner/relay-worker.d.ts +1 -1
  98. package/dist/test-runner/relay-worker.js +1 -1
  99. package/dist/test-runner/report.d.ts +3 -3
  100. package/dist/test-runner/rpc.d.ts +2 -2
  101. package/dist/test-runner/runtime.d.ts +2 -2
  102. package/dist/test-runner/runtime.js +83 -6
  103. package/dist/test-runner/runtime.js.map +1 -1
  104. package/dist/test-runner/task-graph.d.ts +1 -1
  105. package/dist/throttle-DKKzX1qC.js +59 -0
  106. package/dist/throttle-DKKzX1qC.js.map +1 -0
  107. package/dist/totp-Dwft0Kz7.js +3 -0
  108. package/package.json +1 -1
  109. package/dist/bundle-BKqyhEK9.d.ts.map +0 -1
  110. package/dist/cell-BIdSQb9T.js.map +0 -1
  111. package/dist/cell-D1y4shoV.js.map +0 -1
  112. package/dist/cell-DHA578lX.js.map +0 -1
  113. package/dist/debug-server-BQplCzf8.js.map +0 -1
  114. package/dist/debug-server-CBK7dn8O.js.map +0 -1
  115. package/dist/debug-server-D2wpPK3_.js.map +0 -1
  116. package/dist/relay-factory-DVzpBRxz.js.map +0 -1
  117. package/dist/relay-worker-DJnZkXza.d.ts.map +0 -1
  118. package/dist/runtime-DfHHZms2.d.ts.map +0 -1
  119. package/dist/totp-D1pulXLa.js +0 -3
@@ -1,11 +1,12 @@
1
1
  //#region src/test-runner/relay-factory.ts
2
2
  /**
3
- * Builds a {@link RelayConnectionFactory} that opens a standalone env3 relay
4
- * connection.
3
+ * Builds a {@link RelayConnectionFactory} that opens a standalone relay
4
+ * connection for env 3 (intoss-private scheme, default) or env 2 (AITC Sandbox
5
+ * PWA launcher deep-link, when `opts.attachLauncher` is set — devtools#774).
5
6
  *
6
7
  * `open()` performs the full attach lifecycle and BLOCKS while a human scans
7
- * the rendered QR with their phone — there is no way around the manual scan
8
- * for env3. By default the wait is UNBOUNDED (`opts.timeoutMs` omitted): the
8
+ * the rendered QR with their phone — there is no way around the manual scan.
9
+ * By default the wait is UNBOUNDED (`opts.timeoutMs` omitted): the
9
10
  * runner stays up until the user stops it (Ctrl-C/SIGTERM), since QR-scan is
10
11
  * a human-paced action with no sound default bound (devtools#735). Passing an
11
12
  * explicit `timeoutMs` opts into the old bounded behavior (CI/headless
@@ -22,14 +23,17 @@ function createRelayConnectionFactory(opts) {
22
23
  const headless = opts.headless === true;
23
24
  let family;
24
25
  let qrServer;
26
+ let attachWatcher;
25
27
  let phase = "active";
26
28
  let manualPrompt = null;
27
29
  return {
28
30
  async open() {
29
- const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import("./attach-orchestrator-CMoDuG2A.js").then((n) => n.i);
30
- const { injectDebugIndicator, injectGlobals } = await import("./cell-DHA578lX.js").then((n) => n.t);
31
- const { loadRelaySecretReadOnly } = await import("./relay-secret-store-DGduVJhs.js");
32
- const { bootRelayFamily, buildRelayVerifyAuth } = await import("./debug-server-BQplCzf8.js");
31
+ const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import("./attach-orchestrator-BG8TGJOZ.js").then((n) => n.i);
32
+ const { injectDebugIndicator, injectGlobals } = await import("./cell-Dtgtusog.js").then((n) => n.n);
33
+ const { loadRelaySecretReadOnly } = await import("./relay-secret-store-WJ8EGkIl.js");
34
+ const { bootRelayFamily, buildRelayVerifyAuth, startAttachWatcher } = await import("./debug-server-BQHbaKPT.js");
35
+ const { buildChiiInspectorUrl } = await import("./devtools-opener-CxtryS8c.js");
36
+ const { generateTotp } = await import("./totp-DAxys-r0.js").then((n) => n.i);
33
37
  await loadRelaySecretReadOnly({ projectRoot });
34
38
  let resolveTunnelUp;
35
39
  const tunnelReady = new Promise((resolve) => {
@@ -59,29 +63,73 @@ function createRelayConnectionFactory(opts) {
59
63
  canOpenBrowser: () => !headless
60
64
  };
61
65
  try {
62
- const { startQrHttpServer } = await import("./qr-http-server-ZkT6F7is.js");
66
+ const { startQrHttpServer } = await import("./qr-http-server-D4rGz8M7.js");
63
67
  const getDashboardState = () => ({
64
68
  tunnel: attachDeps.getTunnelStatus(),
65
- pages: null,
69
+ pages: booted.connection.listTargets().map((t) => ({
70
+ id: t.id,
71
+ url: t.url
72
+ })),
66
73
  attachUrl: lastAttachParts ? mintAttachUrl(attachDeps, lastAttachParts) : null,
67
- mode: "relay-dev",
74
+ mode: opts.attachLauncher === true ? "relay-mobile" : "relay-dev",
68
75
  phase,
69
76
  manualPrompt
70
77
  });
71
- qrServer = await startQrHttpServer(getDashboardState, { dashboardPort: opts.dashboardPort });
78
+ const getDirectInspectorUrl = () => {
79
+ if (!booted.relayHttpUrl) return {
80
+ ok: false,
81
+ reason: "relayDown"
82
+ };
83
+ const targets = booted.connection.listTargets();
84
+ if (targets.length === 0) return {
85
+ ok: false,
86
+ reason: "noTarget"
87
+ };
88
+ const totpSecret = process.env.AIT_DEBUG_TOTP_SECRET;
89
+ if (!totpSecret) return {
90
+ ok: false,
91
+ reason: "totpUnavailable"
92
+ };
93
+ const url = buildChiiInspectorUrl(booted.relayHttpUrl, targets[0].id, () => generateTotp(totpSecret, Date.now()));
94
+ if (url === null) return {
95
+ ok: false,
96
+ reason: "totpUnavailable"
97
+ };
98
+ return {
99
+ ok: true,
100
+ url
101
+ };
102
+ };
103
+ qrServer = await startQrHttpServer(getDashboardState, {
104
+ dashboardPort: opts.dashboardPort,
105
+ getDirectInspectorUrl
106
+ });
72
107
  attachDeps.qrHttpServer = qrServer;
73
108
  attachDeps.onAttachUrlBuilt = (parts) => {
74
109
  lastAttachParts = parts;
75
110
  qrServer?.notifyStateChange();
76
111
  };
112
+ attachWatcher = startAttachWatcher(booted.connection, void 0, 1e3, () => qrServer?.notifyStateChange(), () => qrServer?.notifyStateChange());
77
113
  process.stderr.write(`devtools-test: QR dashboard: http://127.0.0.1:${qrServer.port}/\n`);
78
114
  } catch {}
79
115
  const TUNNEL_BOOT_TIMEOUT_MS = 15e3;
80
116
  await Promise.race([tunnelReady, new Promise((resolve) => setTimeout(resolve, TUNNEL_BOOT_TIMEOUT_MS))]);
81
- const prep = await prepareAttach(attachDeps, "relay-dev", { scheme_url: opts.schemeUrl }, booted.connection);
117
+ let prep;
118
+ if (opts.attachLauncher === true) {
119
+ const priorTunnelBaseUrl = process.env.AIT_TUNNEL_BASE_URL;
120
+ process.env.AIT_TUNNEL_BASE_URL = opts.appUrl ?? "";
121
+ try {
122
+ prep = await prepareAttach(attachDeps, "relay-mobile", {}, booted.connection);
123
+ } finally {
124
+ if (priorTunnelBaseUrl === void 0) delete process.env.AIT_TUNNEL_BASE_URL;
125
+ else process.env.AIT_TUNNEL_BASE_URL = priorTunnelBaseUrl;
126
+ }
127
+ } else prep = await prepareAttach(attachDeps, "relay-dev", { scheme_url: opts.schemeUrl }, booted.connection);
82
128
  if (!prep.ok) {
83
129
  booted.stop();
84
130
  family = void 0;
131
+ attachWatcher?.stop();
132
+ attachWatcher = void 0;
85
133
  await qrServer?.close();
86
134
  qrServer = void 0;
87
135
  throw new Error("createRelayConnectionFactory: attach preparation failed — check the scheme_url and that the relay tunnel is up");
@@ -90,6 +138,8 @@ function createRelayConnectionFactory(opts) {
90
138
  if (waitResult.isError) {
91
139
  booted.stop();
92
140
  family = void 0;
141
+ attachWatcher?.stop();
142
+ attachWatcher = void 0;
93
143
  await qrServer?.close();
94
144
  qrServer = void 0;
95
145
  const timeoutSec = Math.round(timeoutMs / 1e3);
@@ -111,6 +161,8 @@ function createRelayConnectionFactory(opts) {
111
161
  if (lastEnableError !== void 0) {
112
162
  booted.stop();
113
163
  family = void 0;
164
+ attachWatcher?.stop();
165
+ attachWatcher = void 0;
114
166
  await qrServer?.close();
115
167
  qrServer = void 0;
116
168
  throw new Error(`createRelayConnectionFactory: page did not become ready after ${PAGE_READY_RETRIES} attempts (${Math.round(PAGE_READY_RETRIES * PAGE_READY_RETRY_DELAY_MS / 1e3)}s) — the mini-app page may have disconnected before enableDomains() could open the CDP websocket`);
@@ -118,6 +170,8 @@ function createRelayConnectionFactory(opts) {
118
170
  await injectDebugIndicator(booted.connection);
119
171
  if (opts.cell !== void 0) await injectGlobals(booted.connection, { __AIT_CELL__: opts.cell });
120
172
  if (opts.stubBlocking === true) await injectGlobals(booted.connection, { __AIT_STUB_BLOCKING__: true });
173
+ if (opts.paceMs !== void 0 && opts.paceMs > 0) await injectGlobals(booted.connection, { __AIT_PACE_MS__: opts.paceMs });
174
+ if (opts.paceMethodMs !== void 0 && opts.paceMethodMs > 0) await injectGlobals(booted.connection, { __AIT_PACE_METHOD_MS__: opts.paceMethodMs });
121
175
  return booted.connection;
122
176
  },
123
177
  onSessionPhase(next) {
@@ -134,11 +188,13 @@ function createRelayConnectionFactory(opts) {
134
188
  qrServer?.notifyStateChange();
135
189
  }
136
190
  try {
137
- const { injectDebugIndicator } = await import("./cell-DHA578lX.js").then((n) => n.t);
191
+ const { injectDebugIndicator } = await import("./cell-Dtgtusog.js").then((n) => n.n);
138
192
  await injectDebugIndicator(connection, { state: "disconnected" });
139
193
  } catch {}
140
194
  family?.stop();
141
195
  family = void 0;
196
+ attachWatcher?.stop();
197
+ attachWatcher = void 0;
142
198
  await qrServer?.close();
143
199
  qrServer = void 0;
144
200
  }
@@ -147,4 +203,4 @@ function createRelayConnectionFactory(opts) {
147
203
  //#endregion
148
204
  export { createRelayConnectionFactory as t };
149
205
 
150
- //# sourceMappingURL=relay-factory-DVzpBRxz.js.map
206
+ //# sourceMappingURL=relay-factory-C1X3G3WY.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"relay-factory-C1X3G3WY.js","names":[],"sources":["../src/test-runner/relay-factory.ts"],"sourcesContent":["/**\n * Relay connection factory for the Vitest custom pool (devtools#696).\n *\n * The Vitest pool (`pool.ts`) takes a {@link RelayConnectionFactory} that knows\n * how to `open()` a live CDP relay connection (boot relay → QR → phone scan →\n * cell inject → enableDomains) and `close()` it. Before this module that exact\n * assembly lived only inside the `devtools-test` CLI's `main()`; the standalone\n * CLI and the Vitest pool would otherwise each hand-roll it and drift.\n *\n * This is the single source of that assembly. `cli.ts` is refactored to call\n * `createRelayConnectionFactory(...).open()`, and `definePhoneVitestConfig`\n * (config.ts) exposes it so a downstream `vitest.config.ts` can wire the pool:\n *\n * import { createRelayConnectionFactory } from '@ait-co/devtools/test-runner';\n * const connection = createRelayConnectionFactory({\n * schemeUrl,\n * cell,\n * // REQUIRED — own the stdout decision (the chunks carry the relay wss +\n * // TOTP code). Suppress on non-interactive stdout; print otherwise.\n * onQrContent: (chunks) => {\n * if (!process.stdout.isTTY) return;\n * for (const c of chunks) process.stdout.write(`${c}\\n`);\n * },\n * });\n * export default defineConfig({ test: definePhoneVitestConfig({ connection }) });\n *\n * The heavy boot graph (chii relay, cloudflared, ws, debug-server) is pulled via\n * DYNAMIC import inside `open()` — so merely importing this module (or the\n * `@ait-co/devtools/test-runner` barrel) does NOT statically drag that graph in.\n *\n * SECRET-HANDLING: relay wss URLs, scheme URLs, and the TOTP secret/code are\n * never logged. `open()` reads the project-local `.ait_relay` secret read-only\n * (never mints) and the minted TOTP code rides only inside the QR `at=` param.\n *\n * Node-only. react-free (CdpConnection + lazily-imported MCP boot helpers only).\n */\n\nimport type { AttachDeps, AttachUrlParts } from '../mcp/attach-orchestrator.js';\nimport type { CdpConnection } from '../mcp/cdp-connection.js';\nimport type { DashboardState, QrHttpServer } from '../mcp/qr-http-server.js';\nimport type { RelayConnectionFactory } from './pool.js';\n\n// NOTE: every value import below is a DYNAMIC import inside `open()`. This module\n// keeps ONLY type-level static imports so that re-exporting it from the\n// `@ait-co/devtools/test-runner` barrel (config.ts) does NOT statically drag the\n// heavy MCP graph (cell.ts → attach-orchestrator.ts → tools.ts → server-lock,\n// plus chii/cloudflared via debug-server) onto that Node-config entry.\n\n/** Options for {@link createRelayConnectionFactory}. */\nexport interface RelayConnectionFactoryOptions {\n /**\n * intoss-private:// scheme URL from `ait deploy --scheme-only` (env3). The\n * phone cold-loads the candidate bundle this URL points at. SECRET-HANDLING:\n * never logged.\n *\n * Ignored when {@link attachLauncher} is `true` — the env-2 launcher path\n * builds its attach URL from {@link appUrl} + the relay wss, not a scheme URL.\n * Pass `''` in that case.\n */\n schemeUrl: string;\n /**\n * env-2 (AITC Sandbox PWA) attach mode (devtools#774). When `true`, `open()`\n * builds the attach URL as a **launcher deep-link**\n * (`buildLauncherAttachUrl(appUrl, wssUrl, totp)` — the same env-2 path the\n * MCP daemon takes via `prepareAttach(deps, 'relay-mobile', …)`, issue #378)\n * instead of an intoss-private scheme deep-link. The relay boot, QR\n * rendering, dashboard, and attach-wait are byte-for-byte identical to env 3;\n * only the URL family differs. Omitted/false keeps the env-3 scheme path\n * unchanged (zero behavior change).\n */\n attachLauncher?: boolean;\n /**\n * The consumer dev server's HTTP tunnel URL (e.g. an sdk-example\n * `dev:phone:cdp` `*.trycloudflare.com` URL) that the launcher PWA frames.\n * REQUIRED when {@link attachLauncher} is `true`; ignored otherwise.\n *\n * Threaded to the `relay-mobile` `prepareAttach` branch via the\n * `AIT_TUNNEL_BASE_URL` env var (the same key the MCP daemon reads), so the\n * exact same env-2 assembly runs — no divergent code path.\n *\n * SECRET-HANDLING: this is a tunnel host at the same sensitivity as the relay\n * wss — NEVER logged. It rides only inside the QR/dashboard attach capsule.\n */\n appUrl?: string;\n /**\n * Project root for the `.ait_relay` secret lookup (read-only). Defaults to\n * `process.cwd()`.\n */\n projectRoot?: string;\n /**\n * Attach wait timeout in ms — how long `open()` waits for the phone to scan\n * the QR and attach. Omitted (the default) means **wait indefinitely** —\n * the runner stays up until the user stops it (Ctrl-C/SIGTERM). QR-scan\n * wait is a human-paced action; there is no sound default bound for it\n * (devtools#735). Pass an explicit value to opt into a bounded wait (CI/\n * headless callers — `--attach-timeout` on the CLI). (The per-file evaluate\n * timeout is separate, passed via the pool's `run` options.)\n */\n timeoutMs?: number;\n /** Disable browser auto-open of the QR dashboard (text QR only). */\n headless?: boolean;\n /**\n * Cell axes injected as `__AIT_CELL__` before the first test bundle runs, so\n * sdk-example's capture picks up the correct sdkLine/platform. Optional — when\n * omitted no cell is injected. The values are not secrets.\n */\n cell?: { sdkLine: string; platform: string };\n /**\n * When `true`, injects `__AIT_STUB_BLOCKING__ = true` before the first test\n * bundle runs (devtools#740, DT-2) — `bundle.ts`'s sdk-redirect module reads\n * this flag via `isStubBlockingEnabled()` and answers the blocking-UI APIs\n * in `bridge-stub.ts`'s `STUB_REGISTRY` from fixtures instead of forwarding\n * them to native. Optional — omitted/false means no flag is injected, which\n * is byte-for-byte the pre-#740 behavior (the sdk-redirect module treats\n * absence the same as `false`). Not a secret.\n */\n stubBlocking?: boolean;\n /**\n * Injects `__AIT_PACE_MS__ = paceMs` before the first test bundle runs\n * (devtools#767) — `runtime.ts`'s test-execution loop reads this page\n * global and waits `paceMs` before every test after the first one that\n * actually runs. Optional — omitted/0 means no flag is injected, which is\n * byte-for-byte the pre-#767 behavior (the loop treats an absent/non-\n * positive value as \"no pacing\"). Not a secret. This is the PAGE-side half\n * of `--pace`; the RUNNER-side (file-to-file) half is a plain `opts.paceMs`\n * forwarded to `runTestFilesOverRelay` by the caller (cli.ts) — this\n * factory only owns the page-global injection.\n */\n paceMs?: number;\n /**\n * Injects `__AIT_PACE_METHOD_MS__ = paceMethodMs` before the first test\n * bundle runs (devtools#769) — `bundle.ts`'s sdk-redirect virtual module\n * reads this page global via `method-pace.ts#getPaceMethodMs()` and paces\n * every SDK call to a minimum interval BETWEEN calls to the SAME named\n * function, independent of `--pace`'s test-to-test/file-to-file spacing.\n * Optional — omitted/0 means no flag is injected, which is byte-for-byte\n * the pre-#769 behavior (`getPaceMethodMs()` treats an absent/non-positive\n * value as \"no pacing\", and `wrapWithMethodPacing` returns the SDK object\n * untouched in that case). Not a secret.\n */\n paceMethodMs?: number;\n /**\n * Overrides the dashboard HTTP server's bind base port (devtools#752).\n * Threaded straight through to `startQrHttpServer`'s `dashboardPort`\n * option — see that option's doc for the increment-on-EADDRINUSE scan\n * behaviour and the `AIT_DEBUG_HTTP_PORT` env fallback. Omitted means\n * `startQrHttpServer` resolves its own default (env → fixed default).\n */\n dashboardPort?: number;\n /**\n * Receives the QR/attach render content (text chunks) from\n * `renderAndMaybeWait`, so the caller decides whether to print them — e.g.\n * suppress on non-interactive stdout.\n *\n * REQUIRED (not optional) on purpose: these chunks contain the QR payload\n * (which encodes the relay wss + TOTP `at=` code) and the attach JSON block.\n * Making the hook mandatory means the factory never falls back to printing\n * them itself — a downstream `vitest.config.ts` consumer that wires this via\n * the `@ait-co/devtools/test-runner` barrel is forced to make an explicit\n * stdout decision rather than silently leaking the secret-bearing block.\n *\n * SECRET-HANDLING: when a non-interactive caller is detected the hook MUST\n * suppress the WHOLE chunk (not just `attachUrl`), since `relayUrl` rides in\n * the same block.\n */\n onQrContent: (textChunks: string[]) => void;\n}\n\n/**\n * Builds a {@link RelayConnectionFactory} that opens a standalone relay\n * connection for env 3 (intoss-private scheme, default) or env 2 (AITC Sandbox\n * PWA launcher deep-link, when `opts.attachLauncher` is set — devtools#774).\n *\n * `open()` performs the full attach lifecycle and BLOCKS while a human scans\n * the rendered QR with their phone — there is no way around the manual scan.\n * By default the wait is UNBOUNDED (`opts.timeoutMs` omitted): the\n * runner stays up until the user stops it (Ctrl-C/SIGTERM), since QR-scan is\n * a human-paced action with no sound default bound (devtools#735). Passing an\n * explicit `timeoutMs` opts into the old bounded behavior (CI/headless\n * callers). It resolves with the live `CdpConnection` once a matching page\n * attaches; `close()` tears the relay family down.\n *\n * The factory holds the booted relay family in a closure so `close()` can stop\n * it. A second `open()` on the same factory boots a fresh family (the previous\n * one should have been `close()`d first).\n */\nexport function createRelayConnectionFactory(\n opts: RelayConnectionFactoryOptions,\n): RelayConnectionFactory {\n const projectRoot = opts.projectRoot ?? process.cwd();\n // Undefined/absent → Infinity (wait forever). An explicit finite value opts\n // into the old bounded behavior. Number.isFinite guards downstream\n // (renderAndMaybeWait / waitForFirstTarget) treat Infinity as \"no timer\".\n const timeoutMs = opts.timeoutMs ?? Number.POSITIVE_INFINITY;\n const headless = opts.headless === true;\n\n // Captured so close() can stop the family that open() booted.\n let family: { connection: CdpConnection; stop(): void } | undefined;\n // QR HTTP server — started during open() when not headless, closed in close().\n let qrServer: QrHttpServer | undefined;\n // devtools#772: target attach/detach poller (mirrors debug-server.ts's\n // startAttachWatcher wiring) — started alongside qrServer in open(), stopped\n // in close(). Optional-chained everywhere since qrServer startup can fail.\n let attachWatcher: { stop(): void } | undefined;\n // Session-phase state (#730) — drives the dashboard's `phase` field so the\n // CLI's run start/complete and final teardown push an immediate SSE update\n // instead of the dashboard just going dark when the process exits.\n let phase: DashboardState['phase'] = 'active';\n // Manual-blocking prompt state (devtools#741) — drives the dashboard's\n // `manualPrompt` field. `null` outside a manual step.\n let manualPrompt: DashboardState['manualPrompt'] = null;\n\n return {\n async open(): Promise<CdpConnection> {\n // Dynamic imports: keep the chii/cloudflared/debug-server graph OFF the\n // static import graph of this module (and the test-runner config barrel).\n const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import(\n '../mcp/attach-orchestrator.js'\n );\n const { injectDebugIndicator, injectGlobals } = await import('./cell.js');\n const { loadRelaySecretReadOnly } = await import('../mcp/relay-secret-store.js');\n const { bootRelayFamily, buildRelayVerifyAuth, startAttachWatcher } = await import(\n '../mcp/debug-server.js'\n );\n const { buildChiiInspectorUrl } = await import('../mcp/devtools-opener.js');\n const { generateTotp } = await import('../mcp/totp.js');\n\n // Load the project-local .ait_relay secret into AIT_DEBUG_TOTP_SECRET\n // BEFORE booting the relay so assertRelayAuthConfigured()/buildRelayVerifyAuth()\n // at the boot site see it. Read-only — never mints. SECRET-HANDLING: the\n // value is never logged here.\n await loadRelaySecretReadOnly({ projectRoot });\n\n // PRIMARY FIX (devtools#714): bootRelayFamily starts the cloudflared tunnel\n // as a background promise and returns immediately with tunnel.up === false.\n // We must NOT call prepareAttach until the tunnel is up — it fails fast when\n // tunnel.up is false (attach-orchestrator.ts tunnel-down guard).\n //\n // Wire onWssUrl (mirroring the MCP daemon path in debug-server.ts) to:\n // 1. resolve a caller-held tunnel-ready promise so open() can await it, and\n // 2. re-push dashboard SSE on late tunnel-up events (notifyStateChange).\n //\n // SECRET-HANDLING: onWssUrl receives the relay wss URL — never log it.\n let resolveTunnelUp!: () => void;\n const tunnelReady = new Promise<void>((resolve) => {\n resolveTunnelUp = resolve;\n });\n\n const booted = await bootRelayFamily({\n verifyAuth: buildRelayVerifyAuth(),\n onWssUrl: () => {\n // Resolve the tunnel-ready gate so the prepareAttach call below is\n // unblocked. qrServer may not be set yet at call time (it's started\n // after bootRelayFamily), so we use optional chaining — if the tunnel\n // happens to come up after qrServer is started, notifyStateChange pushes\n // the freshly minted attachUrl to any waiting dashboard SSE clients.\n // SECRET-HANDLING: wssUrl is NOT forwarded here — the value travels only\n // inside the closure via getTunnelStatus().wssUrl (used by mintAttachUrl).\n resolveTunnelUp();\n qrServer?.notifyStateChange();\n },\n // #730: parity with the MCP daemon path (debug-server.ts's onTunnelDown\n // wiring) — without this, a permanent tunnel drop during a standalone\n // CLI run left the dashboard showing a dead-but-scannable QR until the\n // watchdog (up to ~210s) finally fired.\n onTunnelDown: () => {\n qrServer?.notifyStateChange();\n },\n });\n family = booted;\n\n // If the tunnel is already up (extremely fast boot or test double), resolve\n // immediately so we don't stall on a promise that will never fire.\n if (booted.getTunnelStatus?.().up) {\n resolveTunnelUp();\n }\n\n // Track the last-captured attach parts so getDashboardState can mint a\n // fresh attach URL on every dashboard request/SSE push (fresh TOTP at=).\n // SECRET-HANDLING: parts contain the relay wss + scheme URL — never logged.\n let lastAttachParts: AttachUrlParts | undefined;\n\n // Assemble AttachDeps. We set qrHttpServer and onAttachUrlBuilt below\n // (before prepareAttach) after optionally starting the web-QR server.\n const attachDeps: AttachDeps = {\n getTunnelStatus: booted.getTunnelStatus ?? (() => ({ up: false, wssUrl: null })),\n getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,\n qrHttpServer: undefined, // filled in below if web-QR server started\n onAttachUrlBuilt: undefined, // filled in below\n canOpenBrowser: () => !headless,\n };\n\n // Web-QR server: reuse the same loopback HTTP dashboard that the MCP\n // start_attach path uses (src/mcp/qr-http-server.ts). This makes the QR\n // scannable even when stdout is non-interactive (Claude Code `!` / CI),\n // because the browser is opened by URL — not via captured stdout.\n //\n // Headless decision: we start the server even in --headless mode so the\n // printed stderr URL can be opened manually. The existing\n // canOpenBrowser: () => !headless gate inside renderAndMaybeWait prevents\n // the auto-open; headless users see only the stderr URL.\n //\n // On failure we fall back gracefully to the text-QR path — do NOT crash.\n // SECRET-HANDLING: only http://127.0.0.1:<port>/ (no secrets) goes to stderr.\n try {\n const { startQrHttpServer } = await import('../mcp/qr-http-server.js');\n\n // devtools#772: parity with the MCP daemon path (debug-server.ts) —\n // read the live target list off the booted connection instead of the\n // `pages: null` placeholder. `Array.isArray(pages) && pages.length > 0`\n // is the SSE client's Inspector-active gate (qr-http-server.ts #544),\n // so a hardcoded `null` left the Inspector section stuck on the\n // waiting hint forever, independent of any actual attach state.\n const getDashboardState = (): DashboardState => ({\n tunnel: attachDeps.getTunnelStatus(),\n pages: booted.connection.listTargets().map((t) => ({ id: t.id, url: t.url })),\n attachUrl: lastAttachParts ? mintAttachUrl(attachDeps, lastAttachParts) : null,\n // devtools#774: label the dashboard chrome for the env being attached\n // — 'relay-mobile' (env 2 launcher/sandbox family) vs 'relay-dev'\n // (env 3 intoss family). Mirrors the MCP daemon's mode selection.\n mode: opts.attachLauncher === true ? ('relay-mobile' as const) : ('relay-dev' as const),\n phase, // #730 — CLI-only 'running'/'complete' transitions via onSessionPhase\n manualPrompt, // #741 — CLI-only --manual-blocking transitions via onManualPrompt\n });\n\n // devtools#772: getDirectInspectorUrl — mirrors debug-server.ts's\n // assembly (buildChiiInspectorUrl over the relay's local HTTP base +\n // a freshly-minted TOTP code) so the runner dashboard's `/inspector`\n // stable route (#530) is actually functional once a page attaches.\n // SECRET-HANDLING: `ok:true`'s `url` carries the relay host + TOTP\n // `at=` code — never logged; it only ever rides a Location header.\n const getDirectInspectorUrl = (): ReturnType<\n NonNullable<\n import('../mcp/qr-http-server.js').QrHttpServerOptions['getDirectInspectorUrl']\n >\n > => {\n if (!booted.relayHttpUrl) {\n return { ok: false, reason: 'relayDown' };\n }\n const targets = booted.connection.listTargets();\n if (targets.length === 0) {\n return { ok: false, reason: 'noTarget' };\n }\n const totpSecret = process.env.AIT_DEBUG_TOTP_SECRET;\n if (!totpSecret) {\n return { ok: false, reason: 'totpUnavailable' };\n }\n const url = buildChiiInspectorUrl(booted.relayHttpUrl, targets[0].id, () =>\n generateTotp(totpSecret, Date.now()),\n );\n if (url === null) {\n return { ok: false, reason: 'totpUnavailable' };\n }\n return { ok: true, url };\n };\n\n qrServer = await startQrHttpServer(getDashboardState, {\n dashboardPort: opts.dashboardPort,\n getDirectInspectorUrl,\n });\n\n // Wire the QR server into attachDeps BEFORE prepareAttach is called so\n // renderAndMaybeWait sees it and takes Path 2/3 (web-QR) instead of Path 4.\n attachDeps.qrHttpServer = qrServer;\n\n // Capture attach parts via onAttachUrlBuilt so getDashboardState can\n // mint a fresh URL (fresh TOTP at= code) on every dashboard render.\n attachDeps.onAttachUrlBuilt = (parts: AttachUrlParts) => {\n lastAttachParts = parts;\n qrServer?.notifyStateChange();\n };\n\n // devtools#772: parity with the MCP daemon path's onPageAttach/\n // onPageDetach wiring (debug-server.ts ~2348) — push an immediate SSE\n // update whenever the target set changes, so the dashboard's Inspector\n // section flips out of the waiting hint without waiting for the next\n // periodic SSE refresh. `server` is omitted (no MCP Server here — the\n // standalone runner has no tools/list to notify); startAttachWatcher\n // treats that as \"skip sendToolListChanged\" and runs the rest of its\n // signature-diff logic unchanged.\n attachWatcher = startAttachWatcher(\n booted.connection,\n undefined,\n 1_000,\n () => qrServer?.notifyStateChange(),\n () => qrServer?.notifyStateChange(),\n );\n\n // Print the loopback dashboard URL to stderr — it carries no secrets\n // (TOTP codes and relay wss live only in the in-memory HTTP response).\n process.stderr.write(`devtools-test: QR dashboard: http://127.0.0.1:${qrServer.port}/\\n`);\n } catch {\n // startQrHttpServer failed (e.g. port conflict, import error). Fall back\n // to the existing text-QR path by leaving qrHttpServer: undefined.\n // qrServer remains undefined; close() handles that with optional chaining.\n }\n\n // PRIMARY FIX (devtools#714): await tunnel readiness before calling\n // prepareAttach. Race: a 15 s timeout is generous — cloudflared typically\n // comes up in < 5 s. On timeout we still call prepareAttach; it will hit\n // the tunnel-down guard and throw a secret-free error (same as before,\n // but now with a clear diagnostic instead of a silent WAITING freeze).\n //\n // Implementation: Promise.race against a 15 000 ms timeout signal. We do\n // NOT use a timer-based early-exit because the existing code already\n // surface-fails on tunnel-down inside prepareAttach with a secret-free\n // message. The timeout here is purely a \"give the tunnel a fair chance\"\n // gate — not a correctness boundary.\n const TUNNEL_BOOT_TIMEOUT_MS = 15_000;\n await Promise.race([\n tunnelReady,\n new Promise<void>((resolve) => setTimeout(resolve, TUNNEL_BOOT_TIMEOUT_MS)),\n ]);\n\n // devtools#774: env 2 (launcher) vs env 3 (scheme) attach. The relay\n // boot / QR / dashboard / attach-wait above are identical for both — only\n // the attach-URL family differs. We reuse `prepareAttach`'s existing\n // `relay-mobile` branch (issue #378) rather than hand-rolling a second\n // assembly: it reads AIT_TUNNEL_BASE_URL, verifies the tunnel wss + TOTP\n // secret, and builds the launcher parts. We set that env var from\n // `opts.appUrl` here (scoped to this open()) so the same code path runs.\n // SECRET-HANDLING: appUrl is a tunnel host — set into the env only, never\n // logged; prepareAttach's relay-mobile branch never echoes it either.\n let prep: Awaited<ReturnType<typeof prepareAttach>>;\n if (opts.attachLauncher === true) {\n const priorTunnelBaseUrl = process.env.AIT_TUNNEL_BASE_URL;\n process.env.AIT_TUNNEL_BASE_URL = opts.appUrl ?? '';\n try {\n prep = await prepareAttach(attachDeps, 'relay-mobile', {}, booted.connection);\n } finally {\n // Restore the prior env value so we don't leak the tunnel host into\n // the process env past this attach (it is read only inside prepareAttach).\n if (priorTunnelBaseUrl === undefined) delete process.env.AIT_TUNNEL_BASE_URL;\n else process.env.AIT_TUNNEL_BASE_URL = priorTunnelBaseUrl;\n }\n } else {\n prep = await prepareAttach(\n attachDeps,\n 'relay-dev',\n { scheme_url: opts.schemeUrl },\n booted.connection,\n );\n }\n if (!prep.ok) {\n booted.stop();\n family = undefined;\n // devtools#772: stop the attach watcher poller alongside the QR server\n // on this failure path (mirrors the qrServer cleanup below).\n attachWatcher?.stop();\n attachWatcher = undefined;\n // SECONDARY FIX (devtools#714): close the QR server on the failure path\n // so the loopback port listener does not leak. The normal-exit path is\n // handled by close(); this mirrors that cleanup for the error path.\n await qrServer?.close();\n qrServer = undefined;\n // SECRET-HANDLING: do NOT surface `prep.error.content` in the thrown\n // message. Some prep error paths build their text from attach\n // components, and the CLI catch writes `e.message` to stderr — embedding\n // that text risks leaking the scheme/relay wss URL. The detailed\n // diagnostic is the daemon/dashboard's job; the factory throws a\n // secret-free message only.\n throw new Error(\n 'createRelayConnectionFactory: attach preparation failed — check the scheme_url and that the relay tunnel is up',\n );\n }\n\n // Render the QR + wait for the phone to attach. SECRET-HANDLING: this\n // function never logs scheme/wss/TOTP values.\n const waitResult = await renderAndMaybeWait(\n attachDeps,\n prep,\n true,\n timeoutMs,\n booted.connection,\n );\n if (waitResult.isError) {\n booted.stop();\n family = undefined;\n // devtools#772: stop the attach watcher poller alongside the QR server\n // on this failure path (mirrors the prep.ok failure path above).\n attachWatcher?.stop();\n attachWatcher = undefined;\n // SECONDARY FIX (devtools#714): close the QR server on the timeout path\n // (mirrors the prep.ok failure path above).\n await qrServer?.close();\n qrServer = undefined;\n // SECRET-HANDLING (BLOCKER fix): `waitResult.content` is the timeout\n // result built from `buildTimeoutError(baseText, ...)`, and `baseText`\n // is `JSON.stringify({ attachUrl, relayUrl, ... })` — `attachUrl` carries\n // the TOTP `at=` code and `relayUrl` is the relay `wss://` URL. The CLI\n // catch writes `e.message` to stderr, so extracting that text here would\n // leak the relay wss + TOTP code on every timeout. Throw a secret-free\n // message with only the timeout duration.\n const timeoutSec = Math.round(timeoutMs / 1000);\n throw new Error(\n `createRelayConnectionFactory: attach timed out after ${timeoutSec}s — phone did not scan the QR within the timeout`,\n );\n }\n\n // Surface the QR/attach render content to the caller, which owns the\n // stdout decision (suppress on non-interactive stdout). There is no\n // fallback that prints here itself: `onQrContent` is a required option so\n // the secret-bearing block (attachUrl TOTP + relayUrl wss) can never be\n // emitted without an explicit caller decision. SECRET-HANDLING.\n const qrChunks = waitResult.content\n .filter((c): c is { type: 'text'; text: string } => c.type === 'text')\n .map((c) => c.text);\n opts.onQrContent(qrChunks);\n\n // PAGE-READY GATE (devtools#720, fix (a)+(b)):\n //\n // FIX (a) — ORDER: enableDomains() MUST run before injectDebugIndicator()\n // and injectGlobals(). Both inject calls use Runtime.evaluate via\n // sendCommand(), which guards with `if (!this.ws) reject(\"Call\n // enableDomains() first\")`. With the old ordering (inject → inject →\n // enableDomains) injectDebugIndicator's throw was swallowed by cell.ts\n // try/catch but injectGlobals (no try/catch) propagated fatally — when\n // --cell was set open() hard-threw before enableDomains ever ran and the\n // CLI exited with 0 files executed.\n //\n // FIX (b) — BOUNDED RETRY: the window between waitForFirstTarget (non-\n // empty /targets) and enableDomains (page-level WS open) is where a\n // Cloudflare edge idle-drop can disconnect the phone. enableDomains()\n // calls refreshTargets() internally; if the target list is empty at that\n // point it throws \"No mini-app page attached\". We absorb up to\n // PAGE_READY_RETRIES transient failures by waiting briefly and retrying\n // the refreshTargets+enableDomains sequence. enableDomains() is\n // idempotent (concurrent callers share the in-flight promise), so retries\n // are safe. Only after all retries fail do we surface a secret-free\n // error. This is the CLI equivalent of the MCP daemon's soft-fail\n // cushion in relay-worker.ts.\n //\n // Neither fix touches chii-connection.ts or the MCP daemon path.\n const PAGE_READY_RETRIES = 3;\n const PAGE_READY_RETRY_DELAY_MS = 1_500;\n\n let lastEnableError: Error | undefined;\n for (let attempt = 1; attempt <= PAGE_READY_RETRIES; attempt++) {\n try {\n // FIX (a): enableDomains first — opens the page-level CDP websocket.\n await booted.connection.enableDomains();\n lastEnableError = undefined;\n break;\n } catch (err) {\n lastEnableError = err instanceof Error ? err : new Error(String(err));\n if (attempt < PAGE_READY_RETRIES) {\n // FIX (b): brief pause then re-poll /targets before retrying\n // enableDomains. Use a non-leaking approach: wait, then fall\n // through to the next loop iteration.\n await new Promise<void>((resolve) => setTimeout(resolve, PAGE_READY_RETRY_DELAY_MS));\n }\n }\n }\n\n if (lastEnableError !== undefined) {\n booted.stop();\n family = undefined;\n // devtools#772: stop the attach watcher poller alongside the QR server\n // on this failure path (mirrors the two failure paths above).\n attachWatcher?.stop();\n attachWatcher = undefined;\n await qrServer?.close();\n qrServer = undefined;\n // SECRET-HANDLING: message contains only a duration + attempt count.\n // No relay wss URL, scheme URL, or TOTP code is included.\n throw new Error(\n `createRelayConnectionFactory: page did not become ready after ${PAGE_READY_RETRIES} attempts (${Math.round((PAGE_READY_RETRIES * PAGE_READY_RETRY_DELAY_MS) / 1000)}s) — the mini-app page may have disconnected before enableDomains() could open the CDP websocket`,\n );\n }\n\n // FIX (a): inject AFTER enableDomains so the page-level CDP websocket is\n // open and sendCommand() will not hit the ws===null guard.\n\n // Show the on-phone \"Debugger Connected\" badge. injectDebugIndicator\n // swallows its own errors (cell.ts try/catch), so a badge failure is\n // non-fatal — test execution proceeds regardless.\n await injectDebugIndicator(booted.connection);\n\n // Inject the cell globals before any test bundle runs (session-global).\n // injectGlobals() does NOT swallow errors — a genuine type/eval failure\n // here surfaces clearly instead of silently skipping cell injection.\n if (opts.cell !== undefined) {\n await injectGlobals(booted.connection, { __AIT_CELL__: opts.cell });\n }\n\n // devtools#740 (DT-2): inject the bridge-stub gate before any test\n // bundle runs, session-global like __AIT_CELL__ above. Only injected\n // when explicitly requested — omitted/false means the sdk-redirect\n // module's `isStubBlockingEnabled()` reads `undefined`, which is\n // treated identically to `false` (zero-diff-when-off).\n if (opts.stubBlocking === true) {\n await injectGlobals(booted.connection, { __AIT_STUB_BLOCKING__: true });\n }\n\n // devtools#767: inject the page-side --pace pacing value, session-global\n // like __AIT_CELL__/__AIT_STUB_BLOCKING__ above. Only injected when\n // positive — omitted/0 means runtime.ts's own `__AIT_PACE_MS__` read\n // sees `undefined`, which its `typeof raw === 'number' && raw > 0` guard\n // treats identically to a literal `0` (zero-diff-when-off).\n if (opts.paceMs !== undefined && opts.paceMs > 0) {\n await injectGlobals(booted.connection, { __AIT_PACE_MS__: opts.paceMs });\n }\n\n // devtools#769: inject the page-side --pace-method pacing value,\n // session-global like __AIT_PACE_MS__ above. Only injected when\n // positive — omitted/0 means method-pace.ts's own\n // `__AIT_PACE_METHOD_MS__` read sees `undefined`, which its\n // `typeof raw === 'number' && raw > 0` guard treats identically to a\n // literal `0` (zero-diff-when-off).\n if (opts.paceMethodMs !== undefined && opts.paceMethodMs > 0) {\n await injectGlobals(booted.connection, { __AIT_PACE_METHOD_MS__: opts.paceMethodMs });\n }\n\n return booted.connection;\n },\n\n // #730: drives the dashboard's `phase` field so the CLI's run start/end\n // push an immediate SSE update instead of the dashboard just going dark.\n onSessionPhase(next: 'running' | 'complete'): void {\n phase = next;\n qrServer?.notifyStateChange();\n },\n\n // #741: drives the dashboard's `manualPrompt` field for --manual-blocking\n // — the CLI calls this immediately before injecting each manual file\n // (prompt set) and once more with `null` after the last one (prompt\n // cleared). Push is immediate (not waited on for ack) — v1 does not block\n // on dashboard acknowledgment, only on the file's own (long) evaluate.\n onManualPrompt(next: { file: string; index: number; total: number } | null): void {\n manualPrompt = next;\n qrServer?.notifyStateChange();\n },\n\n async close(connection: CdpConnection): Promise<void> {\n // #730: flip the on-phone badge to disconnected AND push the terminal\n // dashboard frame BEFORE tearing anything down, so neither surface goes\n // dark without explanation when the CLI exits (dog-food gaps #1 + #2).\n // Both are best-effort over the still-open channels — the ordering\n // (before family.stop()/qrServer.close()) is load-bearing:\n // - qrServer.notifyStateChange() writes synchronously to the still-open\n // SSE sockets, so the 'complete' frame is on the wire before the HTTP\n // server is closed below.\n // - injectDebugIndicator runs over the still-open CDP channel, so the\n // phone actually receives the disconnected-state update before the\n // relay/tunnel are torn down.\n if (phase !== 'complete') {\n phase = 'complete';\n qrServer?.notifyStateChange();\n }\n try {\n const { injectDebugIndicator } = await import('./cell.js');\n await injectDebugIndicator(connection, { state: 'disconnected' });\n } catch {\n // Channel may already be down (e.g. attach never completed) — non-fatal,\n // the badge is informational UI only.\n }\n // family.stop() is synchronous best-effort: closes the CDP connection and\n // shuts down the relay + cloudflared child.\n family?.stop();\n family = undefined;\n // devtools#772: stop the attach watcher poller before closing the QR\n // server it pushes into — idempotent via optional chaining.\n attachWatcher?.stop();\n attachWatcher = undefined;\n // Close the web-QR HTTP server if one was started during open().\n // close() is idempotent via optional chaining + reassignment to undefined.\n await qrServer?.close();\n qrServer = undefined;\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA0LA,SAAgB,6BACd,MACwB;CACxB,MAAM,cAAc,KAAK,eAAe,QAAQ,KAAK;CAIrD,MAAM,YAAY,KAAK,aAAa,OAAO;CAC3C,MAAM,WAAW,KAAK,aAAa;CAGnC,IAAI;CAEJ,IAAI;CAIJ,IAAI;CAIJ,IAAI,QAAiC;CAGrC,IAAI,eAA+C;AAEnD,QAAO;EACL,MAAM,OAA+B;GAGnC,MAAM,EAAE,eAAe,oBAAoB,kBAAkB,MAAM,OACjE,qCAAA,MAAA,MAAA,EAAA,EAAA;GAEF,MAAM,EAAE,sBAAsB,kBAAkB,MAAM,OAAO,sBAAA,MAAA,MAAA,EAAA,EAAA;GAC7D,MAAM,EAAE,4BAA4B,MAAM,OAAO;GACjD,MAAM,EAAE,iBAAiB,sBAAsB,uBAAuB,MAAM,OAC1E;GAEF,MAAM,EAAE,0BAA0B,MAAM,OAAO;GAC/C,MAAM,EAAE,iBAAiB,MAAM,OAAO,sBAAA,MAAA,MAAA,EAAA,EAAA;AAMtC,SAAM,wBAAwB,EAAE,aAAa,CAAC;GAY9C,IAAI;GACJ,MAAM,cAAc,IAAI,SAAe,YAAY;AACjD,sBAAkB;KAClB;GAEF,MAAM,SAAS,MAAM,gBAAgB;IACnC,YAAY,sBAAsB;IAClC,gBAAgB;AAQd,sBAAiB;AACjB,eAAU,mBAAmB;;IAM/B,oBAAoB;AAClB,eAAU,mBAAmB;;IAEhC,CAAC;AACF,YAAS;AAIT,OAAI,OAAO,mBAAmB,CAAC,GAC7B,kBAAiB;GAMnB,IAAI;GAIJ,MAAM,aAAyB;IAC7B,iBAAiB,OAAO,2BAA2B;KAAE,IAAI;KAAO,QAAQ;KAAM;IAC9E,qBAAqB,QAAQ,IAAI;IACjC,cAAc,KAAA;IACd,kBAAkB,KAAA;IAClB,sBAAsB,CAAC;IACxB;AAcD,OAAI;IACF,MAAM,EAAE,sBAAsB,MAAM,OAAO;IAQ3C,MAAM,2BAA2C;KAC/C,QAAQ,WAAW,iBAAiB;KACpC,OAAO,OAAO,WAAW,aAAa,CAAC,KAAK,OAAO;MAAE,IAAI,EAAE;MAAI,KAAK,EAAE;MAAK,EAAE;KAC7E,WAAW,kBAAkB,cAAc,YAAY,gBAAgB,GAAG;KAI1E,MAAM,KAAK,mBAAmB,OAAQ,iBAA4B;KAClE;KACA;KACD;IAQD,MAAM,8BAID;AACH,SAAI,CAAC,OAAO,aACV,QAAO;MAAE,IAAI;MAAO,QAAQ;MAAa;KAE3C,MAAM,UAAU,OAAO,WAAW,aAAa;AAC/C,SAAI,QAAQ,WAAW,EACrB,QAAO;MAAE,IAAI;MAAO,QAAQ;MAAY;KAE1C,MAAM,aAAa,QAAQ,IAAI;AAC/B,SAAI,CAAC,WACH,QAAO;MAAE,IAAI;MAAO,QAAQ;MAAmB;KAEjD,MAAM,MAAM,sBAAsB,OAAO,cAAc,QAAQ,GAAG,UAChE,aAAa,YAAY,KAAK,KAAK,CAAC,CACrC;AACD,SAAI,QAAQ,KACV,QAAO;MAAE,IAAI;MAAO,QAAQ;MAAmB;AAEjD,YAAO;MAAE,IAAI;MAAM;MAAK;;AAG1B,eAAW,MAAM,kBAAkB,mBAAmB;KACpD,eAAe,KAAK;KACpB;KACD,CAAC;AAIF,eAAW,eAAe;AAI1B,eAAW,oBAAoB,UAA0B;AACvD,uBAAkB;AAClB,eAAU,mBAAmB;;AAW/B,oBAAgB,mBACd,OAAO,YACP,KAAA,GACA,WACM,UAAU,mBAAmB,QAC7B,UAAU,mBAAmB,CACpC;AAID,YAAQ,OAAO,MAAM,iDAAiD,SAAS,KAAK,KAAK;WACnF;GAiBR,MAAM,yBAAyB;AAC/B,SAAM,QAAQ,KAAK,CACjB,aACA,IAAI,SAAe,YAAY,WAAW,SAAS,uBAAuB,CAAC,CAC5E,CAAC;GAWF,IAAI;AACJ,OAAI,KAAK,mBAAmB,MAAM;IAChC,MAAM,qBAAqB,QAAQ,IAAI;AACvC,YAAQ,IAAI,sBAAsB,KAAK,UAAU;AACjD,QAAI;AACF,YAAO,MAAM,cAAc,YAAY,gBAAgB,EAAE,EAAE,OAAO,WAAW;cACrE;AAGR,SAAI,uBAAuB,KAAA,EAAW,QAAO,QAAQ,IAAI;SACpD,SAAQ,IAAI,sBAAsB;;SAGzC,QAAO,MAAM,cACX,YACA,aACA,EAAE,YAAY,KAAK,WAAW,EAC9B,OAAO,WACR;AAEH,OAAI,CAAC,KAAK,IAAI;AACZ,WAAO,MAAM;AACb,aAAS,KAAA;AAGT,mBAAe,MAAM;AACrB,oBAAgB,KAAA;AAIhB,UAAM,UAAU,OAAO;AACvB,eAAW,KAAA;AAOX,UAAM,IAAI,MACR,iHACD;;GAKH,MAAM,aAAa,MAAM,mBACvB,YACA,MACA,MACA,WACA,OAAO,WACR;AACD,OAAI,WAAW,SAAS;AACtB,WAAO,MAAM;AACb,aAAS,KAAA;AAGT,mBAAe,MAAM;AACrB,oBAAgB,KAAA;AAGhB,UAAM,UAAU,OAAO;AACvB,eAAW,KAAA;IAQX,MAAM,aAAa,KAAK,MAAM,YAAY,IAAK;AAC/C,UAAM,IAAI,MACR,wDAAwD,WAAW,kDACpE;;GAQH,MAAM,WAAW,WAAW,QACzB,QAAQ,MAA2C,EAAE,SAAS,OAAO,CACrE,KAAK,MAAM,EAAE,KAAK;AACrB,QAAK,YAAY,SAAS;GA0B1B,MAAM,qBAAqB;GAC3B,MAAM,4BAA4B;GAElC,IAAI;AACJ,QAAK,IAAI,UAAU,GAAG,WAAW,oBAAoB,UACnD,KAAI;AAEF,UAAM,OAAO,WAAW,eAAe;AACvC,sBAAkB,KAAA;AAClB;YACO,KAAK;AACZ,sBAAkB,eAAe,QAAQ,MAAM,IAAI,MAAM,OAAO,IAAI,CAAC;AACrE,QAAI,UAAU,mBAIZ,OAAM,IAAI,SAAe,YAAY,WAAW,SAAS,0BAA0B,CAAC;;AAK1F,OAAI,oBAAoB,KAAA,GAAW;AACjC,WAAO,MAAM;AACb,aAAS,KAAA;AAGT,mBAAe,MAAM;AACrB,oBAAgB,KAAA;AAChB,UAAM,UAAU,OAAO;AACvB,eAAW,KAAA;AAGX,UAAM,IAAI,MACR,iEAAiE,mBAAmB,aAAa,KAAK,MAAO,qBAAqB,4BAA6B,IAAK,CAAC,kGACtK;;AASH,SAAM,qBAAqB,OAAO,WAAW;AAK7C,OAAI,KAAK,SAAS,KAAA,EAChB,OAAM,cAAc,OAAO,YAAY,EAAE,cAAc,KAAK,MAAM,CAAC;AAQrE,OAAI,KAAK,iBAAiB,KACxB,OAAM,cAAc,OAAO,YAAY,EAAE,uBAAuB,MAAM,CAAC;AAQzE,OAAI,KAAK,WAAW,KAAA,KAAa,KAAK,SAAS,EAC7C,OAAM,cAAc,OAAO,YAAY,EAAE,iBAAiB,KAAK,QAAQ,CAAC;AAS1E,OAAI,KAAK,iBAAiB,KAAA,KAAa,KAAK,eAAe,EACzD,OAAM,cAAc,OAAO,YAAY,EAAE,wBAAwB,KAAK,cAAc,CAAC;AAGvF,UAAO,OAAO;;EAKhB,eAAe,MAAoC;AACjD,WAAQ;AACR,aAAU,mBAAmB;;EAQ/B,eAAe,MAAmE;AAChF,kBAAe;AACf,aAAU,mBAAmB;;EAG/B,MAAM,MAAM,YAA0C;AAYpD,OAAI,UAAU,YAAY;AACxB,YAAQ;AACR,cAAU,mBAAmB;;AAE/B,OAAI;IACF,MAAM,EAAE,yBAAyB,MAAM,OAAO,sBAAA,MAAA,MAAA,EAAA,EAAA;AAC9C,UAAM,qBAAqB,YAAY,EAAE,OAAO,gBAAgB,CAAC;WAC3D;AAMR,WAAQ,MAAM;AACd,YAAS,KAAA;AAGT,kBAAe,MAAM;AACrB,mBAAgB,KAAA;AAGhB,SAAM,UAAU,OAAO;AACvB,cAAW,KAAA;;EAEd"}
@@ -151,4 +151,4 @@ function warnIfEnvDiffersFromFile(envSecret, secretPath, fsDep, isValidRelayAuth
151
151
  //#endregion
152
152
  export { loadRelaySecretReadOnly, nearestPackageJsonDir };
153
153
 
154
- //# sourceMappingURL=relay-secret-store-CLEGyHou.js.map
154
+ //# sourceMappingURL=relay-secret-store-Bmyleu0A.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"relay-secret-store-CLEGyHou.js","names":[],"sources":["../src/mcp/relay-secret-store.ts"],"sourcesContent":["/**\n * Project-local relay TOTP secret store (#394 first-run auto-mint, #396 moved to\n * a project-local single file `.ait_relay`).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link ensureRelaySecret} — WRITE path, called ONLY from the unplugin\n * (env-2 relay boot). Mints a fresh secret on first run and persists it to\n * `<projectRoot>/.ait_relay` (0600). A single file — no directory is created.\n *\n * - {@link loadRelaySecretReadOnly} — READ-ONLY path, called from the MCP\n * daemon when switching into a relay environment. It NEVER mints, chmods, or\n * creates anything: it only reads an already-existing `.ait_relay` and injects\n * its value into `env`. A daemon that minted would defeat the #250 fail-fast\n * (the daemon is the verifier side — a self-minted secret would let a leaked\n * tunnel URL attach unauthenticated), so the daemon stays read-only.\n *\n * Why a per-session `projectRoot` instead of `process.cwd()`: the daemon cannot\n * trust its own cwd — agent-plugin spawns it via `npx` without `cwd`, so cwd is\n * frozen at Claude Code launch and a cwd-walk stops at the monorepo workspace\n * root (which always has a package.json). So the project root is supplied\n * per-debug-session through `start_debug`.\n *\n * SECRET-HANDLING: this module handles AIT_DEBUG_TOTP_SECRET — the raw value and\n * its length MUST NOT appear in any log, error message, stdout, stderr, or\n * assertion output. Only boolean pass/fail signals are safe to surface, and the\n * discovered file path is never logged either. The persist file is written mode\n * 0600.\n */\n\nimport { dirname, join } from 'node:path';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local secret file name (single file, not a directory). */\nexport const RELAY_SECRET_FILE_NAME = '.ait_relay';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surface\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link ensureRelaySecret} — injectable for tests. */\nexport interface RelaySecretFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n readFileSync(path: string, encoding: BufferEncoding): string;\n chmodSync(path: string, mode: number): void;\n existsSync(path: string): boolean;\n appendFileSync(path: string, data: string): void;\n}\n\n/**\n * Minimal fs subset needed by {@link loadRelaySecretReadOnly} — strictly the two\n * read-only operations. Deliberately omits writeFileSync/mkdirSync/chmodSync so\n * the daemon path cannot mutate the filesystem even by accident (the type\n * forbids it).\n */\nexport interface RelaySecretReadOnlyFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\nexport interface RelaySecretDeps {\n /**\n * Project root (typically Vite `server.config.root`). The `.ait_relay` file is\n * resolved against the nearest `package.json` directory at or above this path.\n * When omitted, the current working directory is used as the start point —\n * retained for back-compat/tests; the unplugin always passes it.\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Cryptographically secure random bytes. Defaults to node:crypto randomBytes. */\n randomBytes?: (n: number) => Buffer;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelaySecretFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Current working directory resolver (used only when `projectRoot` is omitted). */\n cwd?: () => string;\n /** Log function for first-mint announcement. Defaults to process.stderr.write. */\n log?: (msg: string) => void;\n}\n\nexport interface RelaySecretReadOnlyDeps {\n /**\n * Project root supplied per-debug-session via `start_debug`. The daemon reads\n * `<nearest package.json dir from projectRoot>/.ait_relay`. When omitted, the\n * loader is a no-op (the daemon has no anchor to read from).\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelaySecretReadOnlyFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Optional log sink — never receives the secret value, length, or file path. */\n log?: (msg: string) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Walks upward from `start` and returns the nearest directory that contains a\n * `package.json`. Falls back to `start` itself when none is found (so a write\n * still lands somewhere deterministic).\n *\n * The write (unplugin) and read (daemon) sides use the SAME anchor so a secret\n * minted by `pnpm dev` is found by the daemon: real mini-apps keep\n * `vite.config.ts` and `package.json` in the same directory, so\n * `server.config.root === package.json-dir`. In a monorepo subdir the anchor is\n * the package's own directory — the one the daemon can also reach via the\n * per-session projectRoot.\n *\n * @param start - Directory to start the upward walk from.\n * @param existsSyncFn - Injectable existence check (defaults to node:fs).\n */\nexport function nearestPackageJsonDir(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n let dir = start;\n // Stop at the filesystem root (dirname of root === root).\n while (true) {\n if (existsSyncFn(join(dir, 'package.json'))) {\n return dir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n // Reached the filesystem root without finding a package.json — fall back\n // to the original start directory.\n return start;\n }\n dir = parent;\n }\n}\n\n/**\n * Absolute path to the project-local `.ait_relay` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function relaySecretFilePath(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), RELAY_SECRET_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — mint + persist\n// ---------------------------------------------------------------------------\n\n/**\n * Ensures `env.AIT_DEBUG_TOTP_SECRET` is set to a valid relay TOTP secret,\n * persisting a freshly-minted one to `<projectRoot>/.ait_relay` (0600) on first\n * run and loading it silently on subsequent runs.\n *\n * Writes a SINGLE file into the already-existing project directory — it never\n * creates a directory (so no `mkdirSync`/dir `chmod`). The file is created with\n * `O_EXCL` (`flag: 'wx'`) so a concurrent process cannot be clobbered; on the\n * EEXIST race the winner's value is read instead.\n *\n * Called ONLY from the unplugin (env-2 relay boot). The MCP daemon uses\n * {@link loadRelaySecretReadOnly} (read-only) — it must never mint.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function ensureRelaySecret(deps?: RelaySecretDeps): Promise<void> {\n const {\n projectRoot,\n env = process.env,\n randomBytes: randomBytesFn,\n fs: fsDep,\n existsSync: existsSyncDep,\n cwd: cwdFn,\n log,\n } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n // Lazily import isValidRelayAuthSecret to avoid pulling in node:crypto at\n // module-load time (keeps the import side-effect free).\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or earlier run wins).\n // But first check for a divergence between the env value and .ait_relay —\n // if they differ the relay will verify against the env value while QR/\n // deep-links carry codes derived from the file, causing silent 401s (#620).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n // We need fs to compare — resolve deps early just for the divergence check.\n // This mirrors the lazy-resolve block below but is hoisted here so we can\n // still early-return after the (possibly-emitted) warning.\n const fsEarly: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncEarly: (path: string) => boolean = existsSyncDep ?? fsEarly.existsSync;\n const startEarly = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPathEarly = relaySecretFilePath(startEarly, existsSyncEarly);\n warnIfEnvDiffersFromFile(\n env.AIT_DEBUG_TOTP_SECRET,\n secretPathEarly,\n fsEarly,\n isValidRelayAuthSecret,\n logFn,\n );\n return;\n }\n\n // Resolve injected or real dependencies lazily to keep the import graph clean.\n const rb: (n: number) => Buffer = randomBytesFn ?? (await import('node:crypto')).randomBytes;\n const fs: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const start = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPath = relaySecretFilePath(start, existsSyncFn);\n\n // 2. Persist file exists — read and inject (silent reload).\n if (fs.existsSync(secretPath)) {\n return readAndInject(secretPath, fs, env, logFn, isValidRelayAuthSecret, rb);\n }\n\n // 3. Mint a fresh secret.\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret);\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never mints, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\n/**\n * Reads an already-existing `<projectRoot>/.ait_relay` and, if its contents are a\n * valid relay TOTP secret, injects them into `env.AIT_DEBUG_TOTP_SECRET`.\n *\n * Strictly READ-ONLY: it uses only `existsSync` + `readFileSync` and NEVER mints,\n * chmods, or creates files/directories. The daemon must not mint because it is\n * the relay verifier side — a self-minted secret would defeat the #250 fail-fast\n * (a leaked tunnel URL could then attach unauthenticated). If no valid secret is\n * found the function leaves `env` untouched and returns without throwing, so the\n * downstream `assertRelayAuthConfigured()` stays the single fail-fast.\n *\n * Resolution order:\n * 1. `env.AIT_DEBUG_TOTP_SECRET` already valid → no-op (operator export wins).\n * 2. `projectRoot` given → read `<nearest package.json dir>/.ait_relay`; inject\n * iff the contents pass {@link isValidRelayAuthSecret}.\n * 3. Otherwise (no projectRoot, file absent, or invalid) → silent no-op.\n *\n * SECRET-HANDLING: the read value is passed ONLY to the boolean predicate before\n * assignment; its value, length, and the discovered file path are never logged.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function loadRelaySecretReadOnly(deps?: RelaySecretReadOnlyDeps): Promise<void> {\n const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep, log } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or unplugin run wins).\n // But first check for a divergence between the env value and .ait_relay —\n // if they differ the relay will verify against the env value while QR/\n // deep-links carry codes derived from the file, causing silent 401s (#620).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n if (projectRoot !== undefined) {\n const fsEarly: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncEarly: (path: string) => boolean = existsSyncDep ?? fsEarly.existsSync;\n const secretPathEarly = relaySecretFilePath(projectRoot, existsSyncEarly);\n warnIfEnvDiffersFromFile(\n env.AIT_DEBUG_TOTP_SECRET,\n secretPathEarly,\n fsEarly,\n isValidRelayAuthSecret,\n logFn,\n );\n }\n return;\n }\n\n // 2. No anchor → nothing to read.\n if (projectRoot === undefined) {\n return;\n }\n\n const fs: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const secretPath = relaySecretFilePath(projectRoot, existsSyncFn);\n if (!fs.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable file (permissions, transient FS error) — stay silent and let\n // the downstream assert be the single fail-fast. SECRET-HANDLING: the error\n // and path are not surfaced.\n return;\n }\n\n // SECRET-HANDLING: the value flows only through the boolean predicate.\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers (not exported — single-use extracted for readability)\n// ---------------------------------------------------------------------------\n\n/**\n * Compares `envSecret` against the contents of `secretPath` (if the file\n * exists and contains a valid secret) and emits a single warning via `logFn`\n * when they differ.\n *\n * SECRET-HANDLING (hard rules — do NOT relax):\n * - The warning MUST NOT include either secret value, its length, a hash of\n * it, or the resolved file path.\n * - Only an inequality boolean drives the warning; no secret-derived data\n * enters the log message.\n * - If the file is absent, unreadable, or its contents are invalid the\n * function returns silently — no spurious noise.\n *\n * This helper is intentionally synchronous-like (reads via the injected fs)\n * so it can be called from within the async early-return guards without\n * introducing additional async hops.\n *\n * @param envSecret - The validated env value (caller must have confirmed it is\n * valid before calling).\n * @param secretPath - Absolute path to `.ait_relay` to read for comparison.\n * @param fsDep - Injectable fs subset (at minimum `existsSync` + `readFileSync`).\n * @param isValidRelayAuthSecret - Injectable predicate from totp.ts.\n * @param logFn - Injectable log sink; never receives a secret value.\n */\nfunction warnIfEnvDiffersFromFile(\n envSecret: string,\n secretPath: string,\n fsDep: RelaySecretReadOnlyFs,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n logFn: (msg: string) => void,\n): void {\n // File absent → nothing to compare.\n if (!fsDep.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fsDep.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable — skip silently. SECRET-HANDLING: error and path not surfaced.\n return;\n }\n\n // Invalid stored contents → skip silently (no spurious noise).\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n // Compare by equality only. Neither value nor path enters the log message.\n if (envSecret !== stored) {\n logFn(\n `[@ait-co/devtools] AIT_DEBUG_TOTP_SECRET (from environment) differs from the project-local relay secret; ` +\n `the relay will verify against the environment value. ` +\n `Remove .env/.env.local/exported AIT_DEBUG_TOTP_SECRET, or sync the file, ` +\n `so QR/deep-links and the relay agree.\\n`,\n );\n }\n}\n\n/**\n * Result of a {@link ensureGitignoreEntries} call.\n *\n * - `injected`: one or more missing lines were appended to `.gitignore`.\n * - `failed`: append was attempted but threw (unwritable FS) — secret generation\n * is never aborted on failure.\n * - Neither flag set: all lines were already present — pure no-op.\n */\ninterface GitignoreResult {\n injected: boolean;\n failed: boolean;\n}\n\n/**\n * Idempotently appends `.ait_relay` and `.ait_urls` ignore entries to\n * `<secretDir>/.gitignore`.\n *\n * Covers the commit-safety net for the auto-generated relay secrets (#669).\n * Both files live in `secretDir` (the nearest package.json directory), so\n * anchoring the `.gitignore` there ensures the ignore entry and the files it\n * guards always co-locate.\n *\n * Idempotency: reads the existing `.gitignore` (if any), splits into lines,\n * appends only names not already present (exact-match, trimmed). A project\n * that already has both entries (the sdk-example case) is a pure no-op — no\n * write, no log noise.\n *\n * On write failure (unwritable FS, read-only mount) the function returns\n * `{ injected: false, failed: true }` — it NEVER aborts secret generation.\n *\n * SECRET-HANDLING: this function only manipulates ignore-pattern strings and\n * the `.gitignore` path. It never reads, logs, or echoes any secret value.\n *\n * @param secretDir - dirname of the `.ait_relay` file (= nearest package.json dir).\n * @param fsDep - Injectable fs subset (same object passed to the write path).\n * @param logFn - Injectable log sink (used by callers for fold-in messages).\n */\nfunction ensureGitignoreEntries(\n secretDir: string,\n fsDep: RelaySecretFs,\n _logFn: (msg: string) => void,\n): GitignoreResult {\n const gi = join(secretDir, '.gitignore');\n\n // '.ait_urls' mirrors URLS_FILE_NAME from relay-url-store.ts. The constant is\n // inlined here (rather than imported) to avoid a circular module dependency:\n // relay-url-store.ts already imports nearestPackageJsonDir from this module,\n // so a reverse import would create a 2-way cycle.\n const names = [RELAY_SECRET_FILE_NAME, '.ait_urls'];\n\n let existing = '';\n if (fsDep.existsSync(gi)) {\n try {\n existing = fsDep.readFileSync(gi, 'utf8');\n } catch {\n // Unreadable .gitignore — degrade gracefully; secret generation is unaffected.\n return { injected: false, failed: true };\n }\n }\n\n const presentLines = new Set(existing.split(/\\r?\\n/).map((l) => l.trim()));\n const missing = names.filter((n) => !presentLines.has(n));\n\n if (missing.length === 0) {\n // Pure no-op: all entries already present (sdk-example case).\n return { injected: false, failed: false };\n }\n\n // Ensure we start on a fresh line when the existing content has no trailing newline.\n const prefix = existing === '' || existing.endsWith('\\n') ? '' : '\\n';\n try {\n fsDep.appendFileSync(gi, `${prefix}${missing.join('\\n')}\\n`);\n return { injected: true, failed: false };\n } catch {\n // Write failed (unwritable FS, read-only mount, etc.) — degrade gracefully.\n // SECRET-HANDLING: the error and path are swallowed, not surfaced.\n return { injected: false, failed: true };\n }\n}\n\nasync function readAndInject(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n rb: (n: number) => Buffer,\n): Promise<void> {\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패: ${msg}`);\n }\n\n if (!isValidRelayAuthSecret(stored)) {\n // Stored value is corrupt — re-mint over the same path.\n logFn('[@ait-co/devtools] relay 시크릿 파일의 값이 유효하지 않습니다. 재생성합니다.\\n');\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret, true);\n }\n\n // Inject into env — silent path (no log on successful reload).\n env.AIT_DEBUG_TOTP_SECRET = stored;\n\n // Ensure .gitignore covers both secret files even on the reload path (the\n // secret already exists so mintAndPersist will not run, but .ait_urls is\n // written unconditionally by the unplugin on every boot and must be ignored).\n // This is idempotent — if the entries are already present it is a pure no-op.\n ensureGitignoreEntries(dirname(secretPath), fs, logFn);\n}\n\nasync function mintAndPersist(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n rb: (n: number) => Buffer,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n /** When re-minting over a corrupt file, the existing file must be overwritten. */\n overwrite = false,\n): Promise<void> {\n // SECRET-HANDLING: the raw bytes are never written to any log or string other\n // than the persist file and the env variable.\n const secret = rb(32).toString('hex'); // 64 hex chars = 256 bits\n\n // Self-consistency guard: our own minted secret must pass validation.\n if (!isValidRelayAuthSecret(secret)) {\n throw new Error(\n '[@ait-co/devtools] 내부 오류: mint된 시크릿이 유효성 검사를 통과하지 못했습니다.',\n );\n }\n\n // Write a SINGLE file into the already-existing project directory — no\n // directory is created. `O_EXCL` (flag 'wx') makes the create exclusive so a\n // concurrent process cannot be clobbered; on EEXIST we read the winner's value.\n // (When re-minting over a corrupt file we must overwrite, so use 'w'.)\n const flag = overwrite ? 'w' : 'wx';\n try {\n fs.writeFileSync(secretPath, secret, { mode: 0o600, flag });\n // Belt-and-suspenders: apply chmod after write in case umask relaxed the mode.\n fs.chmodSync(secretPath, 0o600);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'EEXIST') {\n // Race: another process already wrote the file — read their value.\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (readErr) {\n const msg = readErr instanceof Error ? readErr.message : String(readErr);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패(경합): ${msg}`);\n }\n if (!isValidRelayAuthSecret(stored)) {\n throw new Error('[@ait-co/devtools] relay 시크릿 파일이 경합 후에도 유효하지 않습니다.');\n }\n env.AIT_DEBUG_TOTP_SECRET = stored;\n return;\n }\n throw err;\n }\n\n // Inject into the current process env so the immediately following\n // assertRelayAuthConfigured() / buildRelayVerifyAuth() calls see the value.\n env.AIT_DEBUG_TOTP_SECRET = secret;\n\n // Idempotently inject .gitignore entries for the two secret files (#669).\n // Fold the result into the single first-mint announcement below so\n // logs.length === 1 is preserved (SECRET-HANDLING: no path/value surfaced).\n const giResult = ensureGitignoreEntries(dirname(secretPath), fs, logFn);\n\n // First-mint announcement (value never included — SECRET-HANDLING). The file\n // name is fixed (`.ait_relay`); we do not echo the resolved directory either.\n // The gitignore result is folded in here to keep the single-log invariant.\n const giLine = giResult.injected\n ? `프로젝트의 .gitignore에 .ait_relay / .ait_urls 무시 규칙을 추가했습니다.\\n`\n : giResult.failed\n ? `.gitignore 자동 갱신에 실패했습니다 — .ait_relay / .ait_urls 를 직접 추가하거나 비공개 repo를 권장합니다.\\n`\n : '';\n\n logFn(\n `[@ait-co/devtools] relay 인증 시크릿을 생성해 프로젝트의 ${RELAY_SECRET_FILE_NAME} 파일에 저장했습니다 (권한 0600).\\n` +\n `다음 실행부터 자동으로 사용됩니다. 직접 export할 필요 없습니다.\\n` +\n `팀이 같은 relay를 공유하려면 이 파일을 repo에 커밋하세요(비공개 repo 권장).\\n` +\n (giLine ? giLine : '') +\n `자세히: https://docs.aitc.dev/guides/relay-auth-totp\\n`,\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,MAAa,yBAAyB;;;;;;;;;;;;;;;;AAoFtC,SAAgB,sBACd,OACA,cACQ;CACR,IAAI,MAAM;AAEV,QAAO,MAAM;AACX,MAAI,aAAa,KAAK,KAAK,eAAe,CAAC,CACzC,QAAO;EAET,MAAM,SAAS,QAAQ,IAAI;AAC3B,MAAI,WAAW,IAGb,QAAO;AAET,QAAM;;;;;;;;;;AAWV,SAAgB,oBACd,OACA,cACQ;AACR,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;AAwGjF,eAAsB,wBAAwB,MAA+C;CAC3F,MAAM,EAAE,aAAa,MAAM,QAAQ,KAAK,IAAI,OAAO,YAAY,eAAe,QAAQ,QAAQ,EAAE;CAEhG,MAAM,QAA+B,SAAS,QAAgB,QAAQ,OAAO,MAAM,IAAI;CAEvF,MAAM,EAAE,2BAA2B,MAAM,OAAO,sBAAA,MAAA,MAAA,EAAA,EAAA;AAMhD,KAAI,uBAAuB,IAAI,sBAAsB,EAAE;AACrD,MAAI,gBAAgB,KAAA,GAAW;GAC7B,MAAM,UAAiC,SAAU,MAAM,OAAO;GAE9D,MAAM,kBAAkB,oBAAoB,aADO,iBAAiB,QAAQ,WACH;AACzE,4BACE,IAAI,uBACJ,iBACA,SACA,wBACA,MACD;;AAEH;;AAIF,KAAI,gBAAgB,KAAA,EAClB;CAGF,MAAM,KAA4B,SAAU,MAAM,OAAO;CAGzD,MAAM,aAAa,oBAAoB,aAFS,iBAAiB,GAAG,WAEH;AACjE,KAAI,CAAC,GAAG,WAAW,WAAW,CAC5B;CAGF,IAAI;AACJ,KAAI;AACF,WAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;SAC7C;AAIN;;AAIF,KAAI,CAAC,uBAAuB,OAAO,CACjC;AAGF,KAAI,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;AA+B9B,SAAS,yBACP,WACA,YACA,OACA,wBACA,OACM;AAEN,KAAI,CAAC,MAAM,WAAW,WAAW,CAC/B;CAGF,IAAI;AACJ,KAAI;AACF,WAAS,MAAM,aAAa,YAAY,OAAO,CAAC,MAAM;SAChD;AAEN;;AAIF,KAAI,CAAC,uBAAuB,OAAO,CACjC;AAIF,KAAI,cAAc,OAChB,OACE,iRAID"}
1
+ {"version":3,"file":"relay-secret-store-Bmyleu0A.js","names":[],"sources":["../src/mcp/relay-secret-store.ts"],"sourcesContent":["/**\n * Project-local relay TOTP secret store (#394 first-run auto-mint, #396 moved to\n * a project-local single file `.ait_relay`).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link ensureRelaySecret} — WRITE path, called ONLY from the unplugin\n * (env-2 relay boot). Mints a fresh secret on first run and persists it to\n * `<projectRoot>/.ait_relay` (0600). A single file — no directory is created.\n *\n * - {@link loadRelaySecretReadOnly} — READ-ONLY path, called from the MCP\n * daemon when switching into a relay environment. It NEVER mints, chmods, or\n * creates anything: it only reads an already-existing `.ait_relay` and injects\n * its value into `env`. A daemon that minted would defeat the #250 fail-fast\n * (the daemon is the verifier side — a self-minted secret would let a leaked\n * tunnel URL attach unauthenticated), so the daemon stays read-only.\n *\n * Why a per-session `projectRoot` instead of `process.cwd()`: the daemon cannot\n * trust its own cwd — agent-plugin spawns it via `npx` without `cwd`, so cwd is\n * frozen at Claude Code launch and a cwd-walk stops at the monorepo workspace\n * root (which always has a package.json). So the project root is supplied\n * per-debug-session through `start_debug`.\n *\n * SECRET-HANDLING: this module handles AIT_DEBUG_TOTP_SECRET — the raw value and\n * its length MUST NOT appear in any log, error message, stdout, stderr, or\n * assertion output. Only boolean pass/fail signals are safe to surface, and the\n * discovered file path is never logged either. The persist file is written mode\n * 0600.\n */\n\nimport { dirname, join } from 'node:path';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local secret file name (single file, not a directory). */\nexport const RELAY_SECRET_FILE_NAME = '.ait_relay';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surface\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link ensureRelaySecret} — injectable for tests. */\nexport interface RelaySecretFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n readFileSync(path: string, encoding: BufferEncoding): string;\n chmodSync(path: string, mode: number): void;\n existsSync(path: string): boolean;\n appendFileSync(path: string, data: string): void;\n}\n\n/**\n * Minimal fs subset needed by {@link loadRelaySecretReadOnly} — strictly the two\n * read-only operations. Deliberately omits writeFileSync/mkdirSync/chmodSync so\n * the daemon path cannot mutate the filesystem even by accident (the type\n * forbids it).\n */\nexport interface RelaySecretReadOnlyFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\nexport interface RelaySecretDeps {\n /**\n * Project root (typically Vite `server.config.root`). The `.ait_relay` file is\n * resolved against the nearest `package.json` directory at or above this path.\n * When omitted, the current working directory is used as the start point —\n * retained for back-compat/tests; the unplugin always passes it.\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Cryptographically secure random bytes. Defaults to node:crypto randomBytes. */\n randomBytes?: (n: number) => Buffer;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelaySecretFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Current working directory resolver (used only when `projectRoot` is omitted). */\n cwd?: () => string;\n /** Log function for first-mint announcement. Defaults to process.stderr.write. */\n log?: (msg: string) => void;\n}\n\nexport interface RelaySecretReadOnlyDeps {\n /**\n * Project root supplied per-debug-session via `start_debug`. The daemon reads\n * `<nearest package.json dir from projectRoot>/.ait_relay`. When omitted, the\n * loader is a no-op (the daemon has no anchor to read from).\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelaySecretReadOnlyFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Optional log sink — never receives the secret value, length, or file path. */\n log?: (msg: string) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Walks upward from `start` and returns the nearest directory that contains a\n * `package.json`. Falls back to `start` itself when none is found (so a write\n * still lands somewhere deterministic).\n *\n * The write (unplugin) and read (daemon) sides use the SAME anchor so a secret\n * minted by `pnpm dev` is found by the daemon: real mini-apps keep\n * `vite.config.ts` and `package.json` in the same directory, so\n * `server.config.root === package.json-dir`. In a monorepo subdir the anchor is\n * the package's own directory — the one the daemon can also reach via the\n * per-session projectRoot.\n *\n * @param start - Directory to start the upward walk from.\n * @param existsSyncFn - Injectable existence check (defaults to node:fs).\n */\nexport function nearestPackageJsonDir(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n let dir = start;\n // Stop at the filesystem root (dirname of root === root).\n while (true) {\n if (existsSyncFn(join(dir, 'package.json'))) {\n return dir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n // Reached the filesystem root without finding a package.json — fall back\n // to the original start directory.\n return start;\n }\n dir = parent;\n }\n}\n\n/**\n * Absolute path to the project-local `.ait_relay` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function relaySecretFilePath(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), RELAY_SECRET_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — mint + persist\n// ---------------------------------------------------------------------------\n\n/**\n * Ensures `env.AIT_DEBUG_TOTP_SECRET` is set to a valid relay TOTP secret,\n * persisting a freshly-minted one to `<projectRoot>/.ait_relay` (0600) on first\n * run and loading it silently on subsequent runs.\n *\n * Writes a SINGLE file into the already-existing project directory — it never\n * creates a directory (so no `mkdirSync`/dir `chmod`). The file is created with\n * `O_EXCL` (`flag: 'wx'`) so a concurrent process cannot be clobbered; on the\n * EEXIST race the winner's value is read instead.\n *\n * Called ONLY from the unplugin (env-2 relay boot). The MCP daemon uses\n * {@link loadRelaySecretReadOnly} (read-only) — it must never mint.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function ensureRelaySecret(deps?: RelaySecretDeps): Promise<void> {\n const {\n projectRoot,\n env = process.env,\n randomBytes: randomBytesFn,\n fs: fsDep,\n existsSync: existsSyncDep,\n cwd: cwdFn,\n log,\n } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n // Lazily import isValidRelayAuthSecret to avoid pulling in node:crypto at\n // module-load time (keeps the import side-effect free).\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or earlier run wins).\n // But first check for a divergence between the env value and .ait_relay —\n // if they differ the relay will verify against the env value while QR/\n // deep-links carry codes derived from the file, causing silent 401s (#620).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n // We need fs to compare — resolve deps early just for the divergence check.\n // This mirrors the lazy-resolve block below but is hoisted here so we can\n // still early-return after the (possibly-emitted) warning.\n const fsEarly: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncEarly: (path: string) => boolean = existsSyncDep ?? fsEarly.existsSync;\n const startEarly = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPathEarly = relaySecretFilePath(startEarly, existsSyncEarly);\n warnIfEnvDiffersFromFile(\n env.AIT_DEBUG_TOTP_SECRET,\n secretPathEarly,\n fsEarly,\n isValidRelayAuthSecret,\n logFn,\n );\n return;\n }\n\n // Resolve injected or real dependencies lazily to keep the import graph clean.\n const rb: (n: number) => Buffer = randomBytesFn ?? (await import('node:crypto')).randomBytes;\n const fs: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const start = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPath = relaySecretFilePath(start, existsSyncFn);\n\n // 2. Persist file exists — read and inject (silent reload).\n if (fs.existsSync(secretPath)) {\n return readAndInject(secretPath, fs, env, logFn, isValidRelayAuthSecret, rb);\n }\n\n // 3. Mint a fresh secret.\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret);\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never mints, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\n/**\n * Reads an already-existing `<projectRoot>/.ait_relay` and, if its contents are a\n * valid relay TOTP secret, injects them into `env.AIT_DEBUG_TOTP_SECRET`.\n *\n * Strictly READ-ONLY: it uses only `existsSync` + `readFileSync` and NEVER mints,\n * chmods, or creates files/directories. The daemon must not mint because it is\n * the relay verifier side — a self-minted secret would defeat the #250 fail-fast\n * (a leaked tunnel URL could then attach unauthenticated). If no valid secret is\n * found the function leaves `env` untouched and returns without throwing, so the\n * downstream `assertRelayAuthConfigured()` stays the single fail-fast.\n *\n * Resolution order:\n * 1. `env.AIT_DEBUG_TOTP_SECRET` already valid → no-op (operator export wins).\n * 2. `projectRoot` given → read `<nearest package.json dir>/.ait_relay`; inject\n * iff the contents pass {@link isValidRelayAuthSecret}.\n * 3. Otherwise (no projectRoot, file absent, or invalid) → silent no-op.\n *\n * SECRET-HANDLING: the read value is passed ONLY to the boolean predicate before\n * assignment; its value, length, and the discovered file path are never logged.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function loadRelaySecretReadOnly(deps?: RelaySecretReadOnlyDeps): Promise<void> {\n const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep, log } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or unplugin run wins).\n // But first check for a divergence between the env value and .ait_relay —\n // if they differ the relay will verify against the env value while QR/\n // deep-links carry codes derived from the file, causing silent 401s (#620).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n if (projectRoot !== undefined) {\n const fsEarly: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncEarly: (path: string) => boolean = existsSyncDep ?? fsEarly.existsSync;\n const secretPathEarly = relaySecretFilePath(projectRoot, existsSyncEarly);\n warnIfEnvDiffersFromFile(\n env.AIT_DEBUG_TOTP_SECRET,\n secretPathEarly,\n fsEarly,\n isValidRelayAuthSecret,\n logFn,\n );\n }\n return;\n }\n\n // 2. No anchor → nothing to read.\n if (projectRoot === undefined) {\n return;\n }\n\n const fs: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const secretPath = relaySecretFilePath(projectRoot, existsSyncFn);\n if (!fs.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable file (permissions, transient FS error) — stay silent and let\n // the downstream assert be the single fail-fast. SECRET-HANDLING: the error\n // and path are not surfaced.\n return;\n }\n\n // SECRET-HANDLING: the value flows only through the boolean predicate.\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers (not exported — single-use extracted for readability)\n// ---------------------------------------------------------------------------\n\n/**\n * Compares `envSecret` against the contents of `secretPath` (if the file\n * exists and contains a valid secret) and emits a single warning via `logFn`\n * when they differ.\n *\n * SECRET-HANDLING (hard rules — do NOT relax):\n * - The warning MUST NOT include either secret value, its length, a hash of\n * it, or the resolved file path.\n * - Only an inequality boolean drives the warning; no secret-derived data\n * enters the log message.\n * - If the file is absent, unreadable, or its contents are invalid the\n * function returns silently — no spurious noise.\n *\n * This helper is intentionally synchronous-like (reads via the injected fs)\n * so it can be called from within the async early-return guards without\n * introducing additional async hops.\n *\n * @param envSecret - The validated env value (caller must have confirmed it is\n * valid before calling).\n * @param secretPath - Absolute path to `.ait_relay` to read for comparison.\n * @param fsDep - Injectable fs subset (at minimum `existsSync` + `readFileSync`).\n * @param isValidRelayAuthSecret - Injectable predicate from totp.ts.\n * @param logFn - Injectable log sink; never receives a secret value.\n */\nfunction warnIfEnvDiffersFromFile(\n envSecret: string,\n secretPath: string,\n fsDep: RelaySecretReadOnlyFs,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n logFn: (msg: string) => void,\n): void {\n // File absent → nothing to compare.\n if (!fsDep.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fsDep.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable — skip silently. SECRET-HANDLING: error and path not surfaced.\n return;\n }\n\n // Invalid stored contents → skip silently (no spurious noise).\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n // Compare by equality only. Neither value nor path enters the log message.\n if (envSecret !== stored) {\n logFn(\n `[@ait-co/devtools] AIT_DEBUG_TOTP_SECRET (from environment) differs from the project-local relay secret; ` +\n `the relay will verify against the environment value. ` +\n `Remove .env/.env.local/exported AIT_DEBUG_TOTP_SECRET, or sync the file, ` +\n `so QR/deep-links and the relay agree.\\n`,\n );\n }\n}\n\n/**\n * Result of a {@link ensureGitignoreEntries} call.\n *\n * - `injected`: one or more missing lines were appended to `.gitignore`.\n * - `failed`: append was attempted but threw (unwritable FS) — secret generation\n * is never aborted on failure.\n * - Neither flag set: all lines were already present — pure no-op.\n */\ninterface GitignoreResult {\n injected: boolean;\n failed: boolean;\n}\n\n/**\n * Idempotently appends `.ait_relay` and `.ait_urls` ignore entries to\n * `<secretDir>/.gitignore`.\n *\n * Covers the commit-safety net for the auto-generated relay secrets (#669).\n * Both files live in `secretDir` (the nearest package.json directory), so\n * anchoring the `.gitignore` there ensures the ignore entry and the files it\n * guards always co-locate.\n *\n * Idempotency: reads the existing `.gitignore` (if any), splits into lines,\n * appends only names not already present (exact-match, trimmed). A project\n * that already has both entries (the sdk-example case) is a pure no-op — no\n * write, no log noise.\n *\n * On write failure (unwritable FS, read-only mount) the function returns\n * `{ injected: false, failed: true }` — it NEVER aborts secret generation.\n *\n * SECRET-HANDLING: this function only manipulates ignore-pattern strings and\n * the `.gitignore` path. It never reads, logs, or echoes any secret value.\n *\n * @param secretDir - dirname of the `.ait_relay` file (= nearest package.json dir).\n * @param fsDep - Injectable fs subset (same object passed to the write path).\n * @param logFn - Injectable log sink (used by callers for fold-in messages).\n */\nfunction ensureGitignoreEntries(\n secretDir: string,\n fsDep: RelaySecretFs,\n _logFn: (msg: string) => void,\n): GitignoreResult {\n const gi = join(secretDir, '.gitignore');\n\n // '.ait_urls' mirrors URLS_FILE_NAME from relay-url-store.ts. The constant is\n // inlined here (rather than imported) to avoid a circular module dependency:\n // relay-url-store.ts already imports nearestPackageJsonDir from this module,\n // so a reverse import would create a 2-way cycle.\n const names = [RELAY_SECRET_FILE_NAME, '.ait_urls'];\n\n let existing = '';\n if (fsDep.existsSync(gi)) {\n try {\n existing = fsDep.readFileSync(gi, 'utf8');\n } catch {\n // Unreadable .gitignore — degrade gracefully; secret generation is unaffected.\n return { injected: false, failed: true };\n }\n }\n\n const presentLines = new Set(existing.split(/\\r?\\n/).map((l) => l.trim()));\n const missing = names.filter((n) => !presentLines.has(n));\n\n if (missing.length === 0) {\n // Pure no-op: all entries already present (sdk-example case).\n return { injected: false, failed: false };\n }\n\n // Ensure we start on a fresh line when the existing content has no trailing newline.\n const prefix = existing === '' || existing.endsWith('\\n') ? '' : '\\n';\n try {\n fsDep.appendFileSync(gi, `${prefix}${missing.join('\\n')}\\n`);\n return { injected: true, failed: false };\n } catch {\n // Write failed (unwritable FS, read-only mount, etc.) — degrade gracefully.\n // SECRET-HANDLING: the error and path are swallowed, not surfaced.\n return { injected: false, failed: true };\n }\n}\n\nasync function readAndInject(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n rb: (n: number) => Buffer,\n): Promise<void> {\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패: ${msg}`);\n }\n\n if (!isValidRelayAuthSecret(stored)) {\n // Stored value is corrupt — re-mint over the same path.\n logFn('[@ait-co/devtools] relay 시크릿 파일의 값이 유효하지 않습니다. 재생성합니다.\\n');\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret, true);\n }\n\n // Inject into env — silent path (no log on successful reload).\n env.AIT_DEBUG_TOTP_SECRET = stored;\n\n // Ensure .gitignore covers both secret files even on the reload path (the\n // secret already exists so mintAndPersist will not run, but .ait_urls is\n // written unconditionally by the unplugin on every boot and must be ignored).\n // This is idempotent — if the entries are already present it is a pure no-op.\n ensureGitignoreEntries(dirname(secretPath), fs, logFn);\n}\n\nasync function mintAndPersist(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n rb: (n: number) => Buffer,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n /** When re-minting over a corrupt file, the existing file must be overwritten. */\n overwrite = false,\n): Promise<void> {\n // SECRET-HANDLING: the raw bytes are never written to any log or string other\n // than the persist file and the env variable.\n const secret = rb(32).toString('hex'); // 64 hex chars = 256 bits\n\n // Self-consistency guard: our own minted secret must pass validation.\n if (!isValidRelayAuthSecret(secret)) {\n throw new Error(\n '[@ait-co/devtools] 내부 오류: mint된 시크릿이 유효성 검사를 통과하지 못했습니다.',\n );\n }\n\n // Write a SINGLE file into the already-existing project directory — no\n // directory is created. `O_EXCL` (flag 'wx') makes the create exclusive so a\n // concurrent process cannot be clobbered; on EEXIST we read the winner's value.\n // (When re-minting over a corrupt file we must overwrite, so use 'w'.)\n const flag = overwrite ? 'w' : 'wx';\n try {\n fs.writeFileSync(secretPath, secret, { mode: 0o600, flag });\n // Belt-and-suspenders: apply chmod after write in case umask relaxed the mode.\n fs.chmodSync(secretPath, 0o600);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'EEXIST') {\n // Race: another process already wrote the file — read their value.\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (readErr) {\n const msg = readErr instanceof Error ? readErr.message : String(readErr);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패(경합): ${msg}`);\n }\n if (!isValidRelayAuthSecret(stored)) {\n throw new Error('[@ait-co/devtools] relay 시크릿 파일이 경합 후에도 유효하지 않습니다.');\n }\n env.AIT_DEBUG_TOTP_SECRET = stored;\n return;\n }\n throw err;\n }\n\n // Inject into the current process env so the immediately following\n // assertRelayAuthConfigured() / buildRelayVerifyAuth() calls see the value.\n env.AIT_DEBUG_TOTP_SECRET = secret;\n\n // Idempotently inject .gitignore entries for the two secret files (#669).\n // Fold the result into the single first-mint announcement below so\n // logs.length === 1 is preserved (SECRET-HANDLING: no path/value surfaced).\n const giResult = ensureGitignoreEntries(dirname(secretPath), fs, logFn);\n\n // First-mint announcement (value never included — SECRET-HANDLING). The file\n // name is fixed (`.ait_relay`); we do not echo the resolved directory either.\n // The gitignore result is folded in here to keep the single-log invariant.\n const giLine = giResult.injected\n ? `프로젝트의 .gitignore에 .ait_relay / .ait_urls 무시 규칙을 추가했습니다.\\n`\n : giResult.failed\n ? `.gitignore 자동 갱신에 실패했습니다 — .ait_relay / .ait_urls 를 직접 추가하거나 비공개 repo를 권장합니다.\\n`\n : '';\n\n logFn(\n `[@ait-co/devtools] relay 인증 시크릿을 생성해 프로젝트의 ${RELAY_SECRET_FILE_NAME} 파일에 저장했습니다 (권한 0600).\\n` +\n `다음 실행부터 자동으로 사용됩니다. 직접 export할 필요 없습니다.\\n` +\n `팀이 같은 relay를 공유하려면 이 파일을 repo에 커밋하세요(비공개 repo 권장).\\n` +\n (giLine ? giLine : '') +\n `자세히: https://docs.aitc.dev/guides/relay-auth-totp\\n`,\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,MAAa,yBAAyB;;;;;;;;;;;;;;;;AAoFtC,SAAgB,sBACd,OACA,cACQ;CACR,IAAI,MAAM;AAEV,QAAO,MAAM;AACX,MAAI,aAAa,KAAK,KAAK,eAAe,CAAC,CACzC,QAAO;EAET,MAAM,SAAS,QAAQ,IAAI;AAC3B,MAAI,WAAW,IAGb,QAAO;AAET,QAAM;;;;;;;;;;AAWV,SAAgB,oBACd,OACA,cACQ;AACR,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;AAwGjF,eAAsB,wBAAwB,MAA+C;CAC3F,MAAM,EAAE,aAAa,MAAM,QAAQ,KAAK,IAAI,OAAO,YAAY,eAAe,QAAQ,QAAQ,EAAE;CAEhG,MAAM,QAA+B,SAAS,QAAgB,QAAQ,OAAO,MAAM,IAAI;CAEvF,MAAM,EAAE,2BAA2B,MAAM,OAAO,sBAAA,MAAA,MAAA,EAAA,EAAA;AAMhD,KAAI,uBAAuB,IAAI,sBAAsB,EAAE;AACrD,MAAI,gBAAgB,KAAA,GAAW;GAC7B,MAAM,UAAiC,SAAU,MAAM,OAAO;GAE9D,MAAM,kBAAkB,oBAAoB,aADO,iBAAiB,QAAQ,WACH;AACzE,4BACE,IAAI,uBACJ,iBACA,SACA,wBACA,MACD;;AAEH;;AAIF,KAAI,gBAAgB,KAAA,EAClB;CAGF,MAAM,KAA4B,SAAU,MAAM,OAAO;CAGzD,MAAM,aAAa,oBAAoB,aAFS,iBAAiB,GAAG,WAEH;AACjE,KAAI,CAAC,GAAG,WAAW,WAAW,CAC5B;CAGF,IAAI;AACJ,KAAI;AACF,WAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;SAC7C;AAIN;;AAIF,KAAI,CAAC,uBAAuB,OAAO,CACjC;AAGF,KAAI,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;AA+B9B,SAAS,yBACP,WACA,YACA,OACA,wBACA,OACM;AAEN,KAAI,CAAC,MAAM,WAAW,WAAW,CAC/B;CAGF,IAAI;AACJ,KAAI;AACF,WAAS,MAAM,aAAa,YAAY,OAAO,CAAC,MAAM;SAChD;AAEN;;AAIF,KAAI,CAAC,uBAAuB,OAAO,CACjC;AAIF,KAAI,cAAc,OAChB,OACE,iRAID"}
@@ -91,7 +91,7 @@ function relaySecretFilePath(start, existsSyncFn) {
91
91
  async function loadRelaySecretReadOnly(deps) {
92
92
  const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep, log } = deps ?? {};
93
93
  const logFn = log ?? ((msg) => process.stderr.write(msg));
94
- const { isValidRelayAuthSecret } = await import("./totp-D1pulXLa.js");
94
+ const { isValidRelayAuthSecret } = await import("./totp-Dwft0Kz7.js");
95
95
  if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {
96
96
  if (projectRoot !== void 0) {
97
97
  const fsEarly = fsDep ?? await import("node:fs");
@@ -151,4 +151,4 @@ function warnIfEnvDiffersFromFile(envSecret, secretPath, fsDep, isValidRelayAuth
151
151
  //#endregion
152
152
  export { nearestPackageJsonDir as n, loadRelaySecretReadOnly as t };
153
153
 
154
- //# sourceMappingURL=relay-secret-store-DhzAnnj-.js.map
154
+ //# sourceMappingURL=relay-secret-store-CQenfcSL.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"relay-secret-store-DhzAnnj-.js","names":[],"sources":["../src/mcp/relay-secret-store.ts"],"sourcesContent":["/**\n * Project-local relay TOTP secret store (#394 first-run auto-mint, #396 moved to\n * a project-local single file `.ait_relay`).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link ensureRelaySecret} — WRITE path, called ONLY from the unplugin\n * (env-2 relay boot). Mints a fresh secret on first run and persists it to\n * `<projectRoot>/.ait_relay` (0600). A single file — no directory is created.\n *\n * - {@link loadRelaySecretReadOnly} — READ-ONLY path, called from the MCP\n * daemon when switching into a relay environment. It NEVER mints, chmods, or\n * creates anything: it only reads an already-existing `.ait_relay` and injects\n * its value into `env`. A daemon that minted would defeat the #250 fail-fast\n * (the daemon is the verifier side — a self-minted secret would let a leaked\n * tunnel URL attach unauthenticated), so the daemon stays read-only.\n *\n * Why a per-session `projectRoot` instead of `process.cwd()`: the daemon cannot\n * trust its own cwd — agent-plugin spawns it via `npx` without `cwd`, so cwd is\n * frozen at Claude Code launch and a cwd-walk stops at the monorepo workspace\n * root (which always has a package.json). So the project root is supplied\n * per-debug-session through `start_debug`.\n *\n * SECRET-HANDLING: this module handles AIT_DEBUG_TOTP_SECRET — the raw value and\n * its length MUST NOT appear in any log, error message, stdout, stderr, or\n * assertion output. Only boolean pass/fail signals are safe to surface, and the\n * discovered file path is never logged either. The persist file is written mode\n * 0600.\n */\n\nimport { dirname, join } from 'node:path';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local secret file name (single file, not a directory). */\nexport const RELAY_SECRET_FILE_NAME = '.ait_relay';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surface\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link ensureRelaySecret} — injectable for tests. */\nexport interface RelaySecretFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n readFileSync(path: string, encoding: BufferEncoding): string;\n chmodSync(path: string, mode: number): void;\n existsSync(path: string): boolean;\n appendFileSync(path: string, data: string): void;\n}\n\n/**\n * Minimal fs subset needed by {@link loadRelaySecretReadOnly} — strictly the two\n * read-only operations. Deliberately omits writeFileSync/mkdirSync/chmodSync so\n * the daemon path cannot mutate the filesystem even by accident (the type\n * forbids it).\n */\nexport interface RelaySecretReadOnlyFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\nexport interface RelaySecretDeps {\n /**\n * Project root (typically Vite `server.config.root`). The `.ait_relay` file is\n * resolved against the nearest `package.json` directory at or above this path.\n * When omitted, the current working directory is used as the start point —\n * retained for back-compat/tests; the unplugin always passes it.\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Cryptographically secure random bytes. Defaults to node:crypto randomBytes. */\n randomBytes?: (n: number) => Buffer;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelaySecretFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Current working directory resolver (used only when `projectRoot` is omitted). */\n cwd?: () => string;\n /** Log function for first-mint announcement. Defaults to process.stderr.write. */\n log?: (msg: string) => void;\n}\n\nexport interface RelaySecretReadOnlyDeps {\n /**\n * Project root supplied per-debug-session via `start_debug`. The daemon reads\n * `<nearest package.json dir from projectRoot>/.ait_relay`. When omitted, the\n * loader is a no-op (the daemon has no anchor to read from).\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelaySecretReadOnlyFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Optional log sink — never receives the secret value, length, or file path. */\n log?: (msg: string) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Walks upward from `start` and returns the nearest directory that contains a\n * `package.json`. Falls back to `start` itself when none is found (so a write\n * still lands somewhere deterministic).\n *\n * The write (unplugin) and read (daemon) sides use the SAME anchor so a secret\n * minted by `pnpm dev` is found by the daemon: real mini-apps keep\n * `vite.config.ts` and `package.json` in the same directory, so\n * `server.config.root === package.json-dir`. In a monorepo subdir the anchor is\n * the package's own directory — the one the daemon can also reach via the\n * per-session projectRoot.\n *\n * @param start - Directory to start the upward walk from.\n * @param existsSyncFn - Injectable existence check (defaults to node:fs).\n */\nexport function nearestPackageJsonDir(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n let dir = start;\n // Stop at the filesystem root (dirname of root === root).\n while (true) {\n if (existsSyncFn(join(dir, 'package.json'))) {\n return dir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n // Reached the filesystem root without finding a package.json — fall back\n // to the original start directory.\n return start;\n }\n dir = parent;\n }\n}\n\n/**\n * Absolute path to the project-local `.ait_relay` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function relaySecretFilePath(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), RELAY_SECRET_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — mint + persist\n// ---------------------------------------------------------------------------\n\n/**\n * Ensures `env.AIT_DEBUG_TOTP_SECRET` is set to a valid relay TOTP secret,\n * persisting a freshly-minted one to `<projectRoot>/.ait_relay` (0600) on first\n * run and loading it silently on subsequent runs.\n *\n * Writes a SINGLE file into the already-existing project directory — it never\n * creates a directory (so no `mkdirSync`/dir `chmod`). The file is created with\n * `O_EXCL` (`flag: 'wx'`) so a concurrent process cannot be clobbered; on the\n * EEXIST race the winner's value is read instead.\n *\n * Called ONLY from the unplugin (env-2 relay boot). The MCP daemon uses\n * {@link loadRelaySecretReadOnly} (read-only) — it must never mint.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function ensureRelaySecret(deps?: RelaySecretDeps): Promise<void> {\n const {\n projectRoot,\n env = process.env,\n randomBytes: randomBytesFn,\n fs: fsDep,\n existsSync: existsSyncDep,\n cwd: cwdFn,\n log,\n } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n // Lazily import isValidRelayAuthSecret to avoid pulling in node:crypto at\n // module-load time (keeps the import side-effect free).\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or earlier run wins).\n // But first check for a divergence between the env value and .ait_relay —\n // if they differ the relay will verify against the env value while QR/\n // deep-links carry codes derived from the file, causing silent 401s (#620).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n // We need fs to compare — resolve deps early just for the divergence check.\n // This mirrors the lazy-resolve block below but is hoisted here so we can\n // still early-return after the (possibly-emitted) warning.\n const fsEarly: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncEarly: (path: string) => boolean = existsSyncDep ?? fsEarly.existsSync;\n const startEarly = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPathEarly = relaySecretFilePath(startEarly, existsSyncEarly);\n warnIfEnvDiffersFromFile(\n env.AIT_DEBUG_TOTP_SECRET,\n secretPathEarly,\n fsEarly,\n isValidRelayAuthSecret,\n logFn,\n );\n return;\n }\n\n // Resolve injected or real dependencies lazily to keep the import graph clean.\n const rb: (n: number) => Buffer = randomBytesFn ?? (await import('node:crypto')).randomBytes;\n const fs: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const start = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPath = relaySecretFilePath(start, existsSyncFn);\n\n // 2. Persist file exists — read and inject (silent reload).\n if (fs.existsSync(secretPath)) {\n return readAndInject(secretPath, fs, env, logFn, isValidRelayAuthSecret, rb);\n }\n\n // 3. Mint a fresh secret.\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret);\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never mints, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\n/**\n * Reads an already-existing `<projectRoot>/.ait_relay` and, if its contents are a\n * valid relay TOTP secret, injects them into `env.AIT_DEBUG_TOTP_SECRET`.\n *\n * Strictly READ-ONLY: it uses only `existsSync` + `readFileSync` and NEVER mints,\n * chmods, or creates files/directories. The daemon must not mint because it is\n * the relay verifier side — a self-minted secret would defeat the #250 fail-fast\n * (a leaked tunnel URL could then attach unauthenticated). If no valid secret is\n * found the function leaves `env` untouched and returns without throwing, so the\n * downstream `assertRelayAuthConfigured()` stays the single fail-fast.\n *\n * Resolution order:\n * 1. `env.AIT_DEBUG_TOTP_SECRET` already valid → no-op (operator export wins).\n * 2. `projectRoot` given → read `<nearest package.json dir>/.ait_relay`; inject\n * iff the contents pass {@link isValidRelayAuthSecret}.\n * 3. Otherwise (no projectRoot, file absent, or invalid) → silent no-op.\n *\n * SECRET-HANDLING: the read value is passed ONLY to the boolean predicate before\n * assignment; its value, length, and the discovered file path are never logged.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function loadRelaySecretReadOnly(deps?: RelaySecretReadOnlyDeps): Promise<void> {\n const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep, log } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or unplugin run wins).\n // But first check for a divergence between the env value and .ait_relay —\n // if they differ the relay will verify against the env value while QR/\n // deep-links carry codes derived from the file, causing silent 401s (#620).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n if (projectRoot !== undefined) {\n const fsEarly: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncEarly: (path: string) => boolean = existsSyncDep ?? fsEarly.existsSync;\n const secretPathEarly = relaySecretFilePath(projectRoot, existsSyncEarly);\n warnIfEnvDiffersFromFile(\n env.AIT_DEBUG_TOTP_SECRET,\n secretPathEarly,\n fsEarly,\n isValidRelayAuthSecret,\n logFn,\n );\n }\n return;\n }\n\n // 2. No anchor → nothing to read.\n if (projectRoot === undefined) {\n return;\n }\n\n const fs: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const secretPath = relaySecretFilePath(projectRoot, existsSyncFn);\n if (!fs.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable file (permissions, transient FS error) — stay silent and let\n // the downstream assert be the single fail-fast. SECRET-HANDLING: the error\n // and path are not surfaced.\n return;\n }\n\n // SECRET-HANDLING: the value flows only through the boolean predicate.\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers (not exported — single-use extracted for readability)\n// ---------------------------------------------------------------------------\n\n/**\n * Compares `envSecret` against the contents of `secretPath` (if the file\n * exists and contains a valid secret) and emits a single warning via `logFn`\n * when they differ.\n *\n * SECRET-HANDLING (hard rules — do NOT relax):\n * - The warning MUST NOT include either secret value, its length, a hash of\n * it, or the resolved file path.\n * - Only an inequality boolean drives the warning; no secret-derived data\n * enters the log message.\n * - If the file is absent, unreadable, or its contents are invalid the\n * function returns silently — no spurious noise.\n *\n * This helper is intentionally synchronous-like (reads via the injected fs)\n * so it can be called from within the async early-return guards without\n * introducing additional async hops.\n *\n * @param envSecret - The validated env value (caller must have confirmed it is\n * valid before calling).\n * @param secretPath - Absolute path to `.ait_relay` to read for comparison.\n * @param fsDep - Injectable fs subset (at minimum `existsSync` + `readFileSync`).\n * @param isValidRelayAuthSecret - Injectable predicate from totp.ts.\n * @param logFn - Injectable log sink; never receives a secret value.\n */\nfunction warnIfEnvDiffersFromFile(\n envSecret: string,\n secretPath: string,\n fsDep: RelaySecretReadOnlyFs,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n logFn: (msg: string) => void,\n): void {\n // File absent → nothing to compare.\n if (!fsDep.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fsDep.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable — skip silently. SECRET-HANDLING: error and path not surfaced.\n return;\n }\n\n // Invalid stored contents → skip silently (no spurious noise).\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n // Compare by equality only. Neither value nor path enters the log message.\n if (envSecret !== stored) {\n logFn(\n `[@ait-co/devtools] AIT_DEBUG_TOTP_SECRET (from environment) differs from the project-local relay secret; ` +\n `the relay will verify against the environment value. ` +\n `Remove .env/.env.local/exported AIT_DEBUG_TOTP_SECRET, or sync the file, ` +\n `so QR/deep-links and the relay agree.\\n`,\n );\n }\n}\n\n/**\n * Result of a {@link ensureGitignoreEntries} call.\n *\n * - `injected`: one or more missing lines were appended to `.gitignore`.\n * - `failed`: append was attempted but threw (unwritable FS) — secret generation\n * is never aborted on failure.\n * - Neither flag set: all lines were already present — pure no-op.\n */\ninterface GitignoreResult {\n injected: boolean;\n failed: boolean;\n}\n\n/**\n * Idempotently appends `.ait_relay` and `.ait_urls` ignore entries to\n * `<secretDir>/.gitignore`.\n *\n * Covers the commit-safety net for the auto-generated relay secrets (#669).\n * Both files live in `secretDir` (the nearest package.json directory), so\n * anchoring the `.gitignore` there ensures the ignore entry and the files it\n * guards always co-locate.\n *\n * Idempotency: reads the existing `.gitignore` (if any), splits into lines,\n * appends only names not already present (exact-match, trimmed). A project\n * that already has both entries (the sdk-example case) is a pure no-op — no\n * write, no log noise.\n *\n * On write failure (unwritable FS, read-only mount) the function returns\n * `{ injected: false, failed: true }` — it NEVER aborts secret generation.\n *\n * SECRET-HANDLING: this function only manipulates ignore-pattern strings and\n * the `.gitignore` path. It never reads, logs, or echoes any secret value.\n *\n * @param secretDir - dirname of the `.ait_relay` file (= nearest package.json dir).\n * @param fsDep - Injectable fs subset (same object passed to the write path).\n * @param logFn - Injectable log sink (used by callers for fold-in messages).\n */\nfunction ensureGitignoreEntries(\n secretDir: string,\n fsDep: RelaySecretFs,\n _logFn: (msg: string) => void,\n): GitignoreResult {\n const gi = join(secretDir, '.gitignore');\n\n // '.ait_urls' mirrors URLS_FILE_NAME from relay-url-store.ts. The constant is\n // inlined here (rather than imported) to avoid a circular module dependency:\n // relay-url-store.ts already imports nearestPackageJsonDir from this module,\n // so a reverse import would create a 2-way cycle.\n const names = [RELAY_SECRET_FILE_NAME, '.ait_urls'];\n\n let existing = '';\n if (fsDep.existsSync(gi)) {\n try {\n existing = fsDep.readFileSync(gi, 'utf8');\n } catch {\n // Unreadable .gitignore — degrade gracefully; secret generation is unaffected.\n return { injected: false, failed: true };\n }\n }\n\n const presentLines = new Set(existing.split(/\\r?\\n/).map((l) => l.trim()));\n const missing = names.filter((n) => !presentLines.has(n));\n\n if (missing.length === 0) {\n // Pure no-op: all entries already present (sdk-example case).\n return { injected: false, failed: false };\n }\n\n // Ensure we start on a fresh line when the existing content has no trailing newline.\n const prefix = existing === '' || existing.endsWith('\\n') ? '' : '\\n';\n try {\n fsDep.appendFileSync(gi, `${prefix}${missing.join('\\n')}\\n`);\n return { injected: true, failed: false };\n } catch {\n // Write failed (unwritable FS, read-only mount, etc.) — degrade gracefully.\n // SECRET-HANDLING: the error and path are swallowed, not surfaced.\n return { injected: false, failed: true };\n }\n}\n\nasync function readAndInject(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n rb: (n: number) => Buffer,\n): Promise<void> {\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패: ${msg}`);\n }\n\n if (!isValidRelayAuthSecret(stored)) {\n // Stored value is corrupt — re-mint over the same path.\n logFn('[@ait-co/devtools] relay 시크릿 파일의 값이 유효하지 않습니다. 재생성합니다.\\n');\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret, true);\n }\n\n // Inject into env — silent path (no log on successful reload).\n env.AIT_DEBUG_TOTP_SECRET = stored;\n\n // Ensure .gitignore covers both secret files even on the reload path (the\n // secret already exists so mintAndPersist will not run, but .ait_urls is\n // written unconditionally by the unplugin on every boot and must be ignored).\n // This is idempotent — if the entries are already present it is a pure no-op.\n ensureGitignoreEntries(dirname(secretPath), fs, logFn);\n}\n\nasync function mintAndPersist(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n rb: (n: number) => Buffer,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n /** When re-minting over a corrupt file, the existing file must be overwritten. */\n overwrite = false,\n): Promise<void> {\n // SECRET-HANDLING: the raw bytes are never written to any log or string other\n // than the persist file and the env variable.\n const secret = rb(32).toString('hex'); // 64 hex chars = 256 bits\n\n // Self-consistency guard: our own minted secret must pass validation.\n if (!isValidRelayAuthSecret(secret)) {\n throw new Error(\n '[@ait-co/devtools] 내부 오류: mint된 시크릿이 유효성 검사를 통과하지 못했습니다.',\n );\n }\n\n // Write a SINGLE file into the already-existing project directory — no\n // directory is created. `O_EXCL` (flag 'wx') makes the create exclusive so a\n // concurrent process cannot be clobbered; on EEXIST we read the winner's value.\n // (When re-minting over a corrupt file we must overwrite, so use 'w'.)\n const flag = overwrite ? 'w' : 'wx';\n try {\n fs.writeFileSync(secretPath, secret, { mode: 0o600, flag });\n // Belt-and-suspenders: apply chmod after write in case umask relaxed the mode.\n fs.chmodSync(secretPath, 0o600);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'EEXIST') {\n // Race: another process already wrote the file — read their value.\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (readErr) {\n const msg = readErr instanceof Error ? readErr.message : String(readErr);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패(경합): ${msg}`);\n }\n if (!isValidRelayAuthSecret(stored)) {\n throw new Error('[@ait-co/devtools] relay 시크릿 파일이 경합 후에도 유효하지 않습니다.');\n }\n env.AIT_DEBUG_TOTP_SECRET = stored;\n return;\n }\n throw err;\n }\n\n // Inject into the current process env so the immediately following\n // assertRelayAuthConfigured() / buildRelayVerifyAuth() calls see the value.\n env.AIT_DEBUG_TOTP_SECRET = secret;\n\n // Idempotently inject .gitignore entries for the two secret files (#669).\n // Fold the result into the single first-mint announcement below so\n // logs.length === 1 is preserved (SECRET-HANDLING: no path/value surfaced).\n const giResult = ensureGitignoreEntries(dirname(secretPath), fs, logFn);\n\n // First-mint announcement (value never included — SECRET-HANDLING). The file\n // name is fixed (`.ait_relay`); we do not echo the resolved directory either.\n // The gitignore result is folded in here to keep the single-log invariant.\n const giLine = giResult.injected\n ? `프로젝트의 .gitignore에 .ait_relay / .ait_urls 무시 규칙을 추가했습니다.\\n`\n : giResult.failed\n ? `.gitignore 자동 갱신에 실패했습니다 — .ait_relay / .ait_urls 를 직접 추가하거나 비공개 repo를 권장합니다.\\n`\n : '';\n\n logFn(\n `[@ait-co/devtools] relay 인증 시크릿을 생성해 프로젝트의 ${RELAY_SECRET_FILE_NAME} 파일에 저장했습니다 (권한 0600).\\n` +\n `다음 실행부터 자동으로 사용됩니다. 직접 export할 필요 없습니다.\\n` +\n `팀이 같은 relay를 공유하려면 이 파일을 repo에 커밋하세요(비공개 repo 권장).\\n` +\n (giLine ? giLine : '') +\n `자세히: https://docs.aitc.dev/guides/relay-auth-totp\\n`,\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,MAAa,yBAAyB;;;;;;;;;;;;;;;;AAoFtC,SAAgB,sBACd,OACA,cACQ;CACR,IAAI,MAAM;AAEV,QAAO,MAAM;AACX,MAAI,aAAa,KAAK,KAAK,eAAe,CAAC,CACzC,QAAO;EAET,MAAM,SAAS,QAAQ,IAAI;AAC3B,MAAI,WAAW,IAGb,QAAO;AAET,QAAM;;;;;;;;;;AAWV,SAAgB,oBACd,OACA,cACQ;AACR,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;AAwGjF,eAAsB,wBAAwB,MAA+C;CAC3F,MAAM,EAAE,aAAa,MAAM,QAAQ,KAAK,IAAI,OAAO,YAAY,eAAe,QAAQ,QAAQ,EAAE;CAEhG,MAAM,QAA+B,SAAS,QAAgB,QAAQ,OAAO,MAAM,IAAI;CAEvF,MAAM,EAAE,2BAA2B,MAAM,OAAO;AAMhD,KAAI,uBAAuB,IAAI,sBAAsB,EAAE;AACrD,MAAI,gBAAgB,KAAA,GAAW;GAC7B,MAAM,UAAiC,SAAU,MAAM,OAAO;GAE9D,MAAM,kBAAkB,oBAAoB,aADO,iBAAiB,QAAQ,WACH;AACzE,4BACE,IAAI,uBACJ,iBACA,SACA,wBACA,MACD;;AAEH;;AAIF,KAAI,gBAAgB,KAAA,EAClB;CAGF,MAAM,KAA4B,SAAU,MAAM,OAAO;CAGzD,MAAM,aAAa,oBAAoB,aAFS,iBAAiB,GAAG,WAEH;AACjE,KAAI,CAAC,GAAG,WAAW,WAAW,CAC5B;CAGF,IAAI;AACJ,KAAI;AACF,WAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;SAC7C;AAIN;;AAIF,KAAI,CAAC,uBAAuB,OAAO,CACjC;AAGF,KAAI,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;AA+B9B,SAAS,yBACP,WACA,YACA,OACA,wBACA,OACM;AAEN,KAAI,CAAC,MAAM,WAAW,WAAW,CAC/B;CAGF,IAAI;AACJ,KAAI;AACF,WAAS,MAAM,aAAa,YAAY,OAAO,CAAC,MAAM;SAChD;AAEN;;AAIF,KAAI,CAAC,uBAAuB,OAAO,CACjC;AAIF,KAAI,cAAc,OAChB,OACE,iRAID"}
1
+ {"version":3,"file":"relay-secret-store-CQenfcSL.js","names":[],"sources":["../src/mcp/relay-secret-store.ts"],"sourcesContent":["/**\n * Project-local relay TOTP secret store (#394 first-run auto-mint, #396 moved to\n * a project-local single file `.ait_relay`).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link ensureRelaySecret} — WRITE path, called ONLY from the unplugin\n * (env-2 relay boot). Mints a fresh secret on first run and persists it to\n * `<projectRoot>/.ait_relay` (0600). A single file — no directory is created.\n *\n * - {@link loadRelaySecretReadOnly} — READ-ONLY path, called from the MCP\n * daemon when switching into a relay environment. It NEVER mints, chmods, or\n * creates anything: it only reads an already-existing `.ait_relay` and injects\n * its value into `env`. A daemon that minted would defeat the #250 fail-fast\n * (the daemon is the verifier side — a self-minted secret would let a leaked\n * tunnel URL attach unauthenticated), so the daemon stays read-only.\n *\n * Why a per-session `projectRoot` instead of `process.cwd()`: the daemon cannot\n * trust its own cwd — agent-plugin spawns it via `npx` without `cwd`, so cwd is\n * frozen at Claude Code launch and a cwd-walk stops at the monorepo workspace\n * root (which always has a package.json). So the project root is supplied\n * per-debug-session through `start_debug`.\n *\n * SECRET-HANDLING: this module handles AIT_DEBUG_TOTP_SECRET — the raw value and\n * its length MUST NOT appear in any log, error message, stdout, stderr, or\n * assertion output. Only boolean pass/fail signals are safe to surface, and the\n * discovered file path is never logged either. The persist file is written mode\n * 0600.\n */\n\nimport { dirname, join } from 'node:path';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local secret file name (single file, not a directory). */\nexport const RELAY_SECRET_FILE_NAME = '.ait_relay';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surface\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link ensureRelaySecret} — injectable for tests. */\nexport interface RelaySecretFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n readFileSync(path: string, encoding: BufferEncoding): string;\n chmodSync(path: string, mode: number): void;\n existsSync(path: string): boolean;\n appendFileSync(path: string, data: string): void;\n}\n\n/**\n * Minimal fs subset needed by {@link loadRelaySecretReadOnly} — strictly the two\n * read-only operations. Deliberately omits writeFileSync/mkdirSync/chmodSync so\n * the daemon path cannot mutate the filesystem even by accident (the type\n * forbids it).\n */\nexport interface RelaySecretReadOnlyFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\nexport interface RelaySecretDeps {\n /**\n * Project root (typically Vite `server.config.root`). The `.ait_relay` file is\n * resolved against the nearest `package.json` directory at or above this path.\n * When omitted, the current working directory is used as the start point —\n * retained for back-compat/tests; the unplugin always passes it.\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Cryptographically secure random bytes. Defaults to node:crypto randomBytes. */\n randomBytes?: (n: number) => Buffer;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelaySecretFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Current working directory resolver (used only when `projectRoot` is omitted). */\n cwd?: () => string;\n /** Log function for first-mint announcement. Defaults to process.stderr.write. */\n log?: (msg: string) => void;\n}\n\nexport interface RelaySecretReadOnlyDeps {\n /**\n * Project root supplied per-debug-session via `start_debug`. The daemon reads\n * `<nearest package.json dir from projectRoot>/.ait_relay`. When omitted, the\n * loader is a no-op (the daemon has no anchor to read from).\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelaySecretReadOnlyFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Optional log sink — never receives the secret value, length, or file path. */\n log?: (msg: string) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Walks upward from `start` and returns the nearest directory that contains a\n * `package.json`. Falls back to `start` itself when none is found (so a write\n * still lands somewhere deterministic).\n *\n * The write (unplugin) and read (daemon) sides use the SAME anchor so a secret\n * minted by `pnpm dev` is found by the daemon: real mini-apps keep\n * `vite.config.ts` and `package.json` in the same directory, so\n * `server.config.root === package.json-dir`. In a monorepo subdir the anchor is\n * the package's own directory — the one the daemon can also reach via the\n * per-session projectRoot.\n *\n * @param start - Directory to start the upward walk from.\n * @param existsSyncFn - Injectable existence check (defaults to node:fs).\n */\nexport function nearestPackageJsonDir(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n let dir = start;\n // Stop at the filesystem root (dirname of root === root).\n while (true) {\n if (existsSyncFn(join(dir, 'package.json'))) {\n return dir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n // Reached the filesystem root without finding a package.json — fall back\n // to the original start directory.\n return start;\n }\n dir = parent;\n }\n}\n\n/**\n * Absolute path to the project-local `.ait_relay` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function relaySecretFilePath(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), RELAY_SECRET_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — mint + persist\n// ---------------------------------------------------------------------------\n\n/**\n * Ensures `env.AIT_DEBUG_TOTP_SECRET` is set to a valid relay TOTP secret,\n * persisting a freshly-minted one to `<projectRoot>/.ait_relay` (0600) on first\n * run and loading it silently on subsequent runs.\n *\n * Writes a SINGLE file into the already-existing project directory — it never\n * creates a directory (so no `mkdirSync`/dir `chmod`). The file is created with\n * `O_EXCL` (`flag: 'wx'`) so a concurrent process cannot be clobbered; on the\n * EEXIST race the winner's value is read instead.\n *\n * Called ONLY from the unplugin (env-2 relay boot). The MCP daemon uses\n * {@link loadRelaySecretReadOnly} (read-only) — it must never mint.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function ensureRelaySecret(deps?: RelaySecretDeps): Promise<void> {\n const {\n projectRoot,\n env = process.env,\n randomBytes: randomBytesFn,\n fs: fsDep,\n existsSync: existsSyncDep,\n cwd: cwdFn,\n log,\n } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n // Lazily import isValidRelayAuthSecret to avoid pulling in node:crypto at\n // module-load time (keeps the import side-effect free).\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or earlier run wins).\n // But first check for a divergence between the env value and .ait_relay —\n // if they differ the relay will verify against the env value while QR/\n // deep-links carry codes derived from the file, causing silent 401s (#620).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n // We need fs to compare — resolve deps early just for the divergence check.\n // This mirrors the lazy-resolve block below but is hoisted here so we can\n // still early-return after the (possibly-emitted) warning.\n const fsEarly: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncEarly: (path: string) => boolean = existsSyncDep ?? fsEarly.existsSync;\n const startEarly = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPathEarly = relaySecretFilePath(startEarly, existsSyncEarly);\n warnIfEnvDiffersFromFile(\n env.AIT_DEBUG_TOTP_SECRET,\n secretPathEarly,\n fsEarly,\n isValidRelayAuthSecret,\n logFn,\n );\n return;\n }\n\n // Resolve injected or real dependencies lazily to keep the import graph clean.\n const rb: (n: number) => Buffer = randomBytesFn ?? (await import('node:crypto')).randomBytes;\n const fs: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const start = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPath = relaySecretFilePath(start, existsSyncFn);\n\n // 2. Persist file exists — read and inject (silent reload).\n if (fs.existsSync(secretPath)) {\n return readAndInject(secretPath, fs, env, logFn, isValidRelayAuthSecret, rb);\n }\n\n // 3. Mint a fresh secret.\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret);\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never mints, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\n/**\n * Reads an already-existing `<projectRoot>/.ait_relay` and, if its contents are a\n * valid relay TOTP secret, injects them into `env.AIT_DEBUG_TOTP_SECRET`.\n *\n * Strictly READ-ONLY: it uses only `existsSync` + `readFileSync` and NEVER mints,\n * chmods, or creates files/directories. The daemon must not mint because it is\n * the relay verifier side — a self-minted secret would defeat the #250 fail-fast\n * (a leaked tunnel URL could then attach unauthenticated). If no valid secret is\n * found the function leaves `env` untouched and returns without throwing, so the\n * downstream `assertRelayAuthConfigured()` stays the single fail-fast.\n *\n * Resolution order:\n * 1. `env.AIT_DEBUG_TOTP_SECRET` already valid → no-op (operator export wins).\n * 2. `projectRoot` given → read `<nearest package.json dir>/.ait_relay`; inject\n * iff the contents pass {@link isValidRelayAuthSecret}.\n * 3. Otherwise (no projectRoot, file absent, or invalid) → silent no-op.\n *\n * SECRET-HANDLING: the read value is passed ONLY to the boolean predicate before\n * assignment; its value, length, and the discovered file path are never logged.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function loadRelaySecretReadOnly(deps?: RelaySecretReadOnlyDeps): Promise<void> {\n const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep, log } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or unplugin run wins).\n // But first check for a divergence between the env value and .ait_relay —\n // if they differ the relay will verify against the env value while QR/\n // deep-links carry codes derived from the file, causing silent 401s (#620).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n if (projectRoot !== undefined) {\n const fsEarly: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncEarly: (path: string) => boolean = existsSyncDep ?? fsEarly.existsSync;\n const secretPathEarly = relaySecretFilePath(projectRoot, existsSyncEarly);\n warnIfEnvDiffersFromFile(\n env.AIT_DEBUG_TOTP_SECRET,\n secretPathEarly,\n fsEarly,\n isValidRelayAuthSecret,\n logFn,\n );\n }\n return;\n }\n\n // 2. No anchor → nothing to read.\n if (projectRoot === undefined) {\n return;\n }\n\n const fs: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const secretPath = relaySecretFilePath(projectRoot, existsSyncFn);\n if (!fs.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable file (permissions, transient FS error) — stay silent and let\n // the downstream assert be the single fail-fast. SECRET-HANDLING: the error\n // and path are not surfaced.\n return;\n }\n\n // SECRET-HANDLING: the value flows only through the boolean predicate.\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers (not exported — single-use extracted for readability)\n// ---------------------------------------------------------------------------\n\n/**\n * Compares `envSecret` against the contents of `secretPath` (if the file\n * exists and contains a valid secret) and emits a single warning via `logFn`\n * when they differ.\n *\n * SECRET-HANDLING (hard rules — do NOT relax):\n * - The warning MUST NOT include either secret value, its length, a hash of\n * it, or the resolved file path.\n * - Only an inequality boolean drives the warning; no secret-derived data\n * enters the log message.\n * - If the file is absent, unreadable, or its contents are invalid the\n * function returns silently — no spurious noise.\n *\n * This helper is intentionally synchronous-like (reads via the injected fs)\n * so it can be called from within the async early-return guards without\n * introducing additional async hops.\n *\n * @param envSecret - The validated env value (caller must have confirmed it is\n * valid before calling).\n * @param secretPath - Absolute path to `.ait_relay` to read for comparison.\n * @param fsDep - Injectable fs subset (at minimum `existsSync` + `readFileSync`).\n * @param isValidRelayAuthSecret - Injectable predicate from totp.ts.\n * @param logFn - Injectable log sink; never receives a secret value.\n */\nfunction warnIfEnvDiffersFromFile(\n envSecret: string,\n secretPath: string,\n fsDep: RelaySecretReadOnlyFs,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n logFn: (msg: string) => void,\n): void {\n // File absent → nothing to compare.\n if (!fsDep.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fsDep.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable — skip silently. SECRET-HANDLING: error and path not surfaced.\n return;\n }\n\n // Invalid stored contents → skip silently (no spurious noise).\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n // Compare by equality only. Neither value nor path enters the log message.\n if (envSecret !== stored) {\n logFn(\n `[@ait-co/devtools] AIT_DEBUG_TOTP_SECRET (from environment) differs from the project-local relay secret; ` +\n `the relay will verify against the environment value. ` +\n `Remove .env/.env.local/exported AIT_DEBUG_TOTP_SECRET, or sync the file, ` +\n `so QR/deep-links and the relay agree.\\n`,\n );\n }\n}\n\n/**\n * Result of a {@link ensureGitignoreEntries} call.\n *\n * - `injected`: one or more missing lines were appended to `.gitignore`.\n * - `failed`: append was attempted but threw (unwritable FS) — secret generation\n * is never aborted on failure.\n * - Neither flag set: all lines were already present — pure no-op.\n */\ninterface GitignoreResult {\n injected: boolean;\n failed: boolean;\n}\n\n/**\n * Idempotently appends `.ait_relay` and `.ait_urls` ignore entries to\n * `<secretDir>/.gitignore`.\n *\n * Covers the commit-safety net for the auto-generated relay secrets (#669).\n * Both files live in `secretDir` (the nearest package.json directory), so\n * anchoring the `.gitignore` there ensures the ignore entry and the files it\n * guards always co-locate.\n *\n * Idempotency: reads the existing `.gitignore` (if any), splits into lines,\n * appends only names not already present (exact-match, trimmed). A project\n * that already has both entries (the sdk-example case) is a pure no-op — no\n * write, no log noise.\n *\n * On write failure (unwritable FS, read-only mount) the function returns\n * `{ injected: false, failed: true }` — it NEVER aborts secret generation.\n *\n * SECRET-HANDLING: this function only manipulates ignore-pattern strings and\n * the `.gitignore` path. It never reads, logs, or echoes any secret value.\n *\n * @param secretDir - dirname of the `.ait_relay` file (= nearest package.json dir).\n * @param fsDep - Injectable fs subset (same object passed to the write path).\n * @param logFn - Injectable log sink (used by callers for fold-in messages).\n */\nfunction ensureGitignoreEntries(\n secretDir: string,\n fsDep: RelaySecretFs,\n _logFn: (msg: string) => void,\n): GitignoreResult {\n const gi = join(secretDir, '.gitignore');\n\n // '.ait_urls' mirrors URLS_FILE_NAME from relay-url-store.ts. The constant is\n // inlined here (rather than imported) to avoid a circular module dependency:\n // relay-url-store.ts already imports nearestPackageJsonDir from this module,\n // so a reverse import would create a 2-way cycle.\n const names = [RELAY_SECRET_FILE_NAME, '.ait_urls'];\n\n let existing = '';\n if (fsDep.existsSync(gi)) {\n try {\n existing = fsDep.readFileSync(gi, 'utf8');\n } catch {\n // Unreadable .gitignore — degrade gracefully; secret generation is unaffected.\n return { injected: false, failed: true };\n }\n }\n\n const presentLines = new Set(existing.split(/\\r?\\n/).map((l) => l.trim()));\n const missing = names.filter((n) => !presentLines.has(n));\n\n if (missing.length === 0) {\n // Pure no-op: all entries already present (sdk-example case).\n return { injected: false, failed: false };\n }\n\n // Ensure we start on a fresh line when the existing content has no trailing newline.\n const prefix = existing === '' || existing.endsWith('\\n') ? '' : '\\n';\n try {\n fsDep.appendFileSync(gi, `${prefix}${missing.join('\\n')}\\n`);\n return { injected: true, failed: false };\n } catch {\n // Write failed (unwritable FS, read-only mount, etc.) — degrade gracefully.\n // SECRET-HANDLING: the error and path are swallowed, not surfaced.\n return { injected: false, failed: true };\n }\n}\n\nasync function readAndInject(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n rb: (n: number) => Buffer,\n): Promise<void> {\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패: ${msg}`);\n }\n\n if (!isValidRelayAuthSecret(stored)) {\n // Stored value is corrupt — re-mint over the same path.\n logFn('[@ait-co/devtools] relay 시크릿 파일의 값이 유효하지 않습니다. 재생성합니다.\\n');\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret, true);\n }\n\n // Inject into env — silent path (no log on successful reload).\n env.AIT_DEBUG_TOTP_SECRET = stored;\n\n // Ensure .gitignore covers both secret files even on the reload path (the\n // secret already exists so mintAndPersist will not run, but .ait_urls is\n // written unconditionally by the unplugin on every boot and must be ignored).\n // This is idempotent — if the entries are already present it is a pure no-op.\n ensureGitignoreEntries(dirname(secretPath), fs, logFn);\n}\n\nasync function mintAndPersist(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n rb: (n: number) => Buffer,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n /** When re-minting over a corrupt file, the existing file must be overwritten. */\n overwrite = false,\n): Promise<void> {\n // SECRET-HANDLING: the raw bytes are never written to any log or string other\n // than the persist file and the env variable.\n const secret = rb(32).toString('hex'); // 64 hex chars = 256 bits\n\n // Self-consistency guard: our own minted secret must pass validation.\n if (!isValidRelayAuthSecret(secret)) {\n throw new Error(\n '[@ait-co/devtools] 내부 오류: mint된 시크릿이 유효성 검사를 통과하지 못했습니다.',\n );\n }\n\n // Write a SINGLE file into the already-existing project directory — no\n // directory is created. `O_EXCL` (flag 'wx') makes the create exclusive so a\n // concurrent process cannot be clobbered; on EEXIST we read the winner's value.\n // (When re-minting over a corrupt file we must overwrite, so use 'w'.)\n const flag = overwrite ? 'w' : 'wx';\n try {\n fs.writeFileSync(secretPath, secret, { mode: 0o600, flag });\n // Belt-and-suspenders: apply chmod after write in case umask relaxed the mode.\n fs.chmodSync(secretPath, 0o600);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'EEXIST') {\n // Race: another process already wrote the file — read their value.\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (readErr) {\n const msg = readErr instanceof Error ? readErr.message : String(readErr);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패(경합): ${msg}`);\n }\n if (!isValidRelayAuthSecret(stored)) {\n throw new Error('[@ait-co/devtools] relay 시크릿 파일이 경합 후에도 유효하지 않습니다.');\n }\n env.AIT_DEBUG_TOTP_SECRET = stored;\n return;\n }\n throw err;\n }\n\n // Inject into the current process env so the immediately following\n // assertRelayAuthConfigured() / buildRelayVerifyAuth() calls see the value.\n env.AIT_DEBUG_TOTP_SECRET = secret;\n\n // Idempotently inject .gitignore entries for the two secret files (#669).\n // Fold the result into the single first-mint announcement below so\n // logs.length === 1 is preserved (SECRET-HANDLING: no path/value surfaced).\n const giResult = ensureGitignoreEntries(dirname(secretPath), fs, logFn);\n\n // First-mint announcement (value never included — SECRET-HANDLING). The file\n // name is fixed (`.ait_relay`); we do not echo the resolved directory either.\n // The gitignore result is folded in here to keep the single-log invariant.\n const giLine = giResult.injected\n ? `프로젝트의 .gitignore에 .ait_relay / .ait_urls 무시 규칙을 추가했습니다.\\n`\n : giResult.failed\n ? `.gitignore 자동 갱신에 실패했습니다 — .ait_relay / .ait_urls 를 직접 추가하거나 비공개 repo를 권장합니다.\\n`\n : '';\n\n logFn(\n `[@ait-co/devtools] relay 인증 시크릿을 생성해 프로젝트의 ${RELAY_SECRET_FILE_NAME} 파일에 저장했습니다 (권한 0600).\\n` +\n `다음 실행부터 자동으로 사용됩니다. 직접 export할 필요 없습니다.\\n` +\n `팀이 같은 relay를 공유하려면 이 파일을 repo에 커밋하세요(비공개 repo 권장).\\n` +\n (giLine ? giLine : '') +\n `자세히: https://docs.aitc.dev/guides/relay-auth-totp\\n`,\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,MAAa,yBAAyB;;;;;;;;;;;;;;;;AAoFtC,SAAgB,sBACd,OACA,cACQ;CACR,IAAI,MAAM;AAEV,QAAO,MAAM;AACX,MAAI,aAAa,KAAK,KAAK,eAAe,CAAC,CACzC,QAAO;EAET,MAAM,SAAS,QAAQ,IAAI;AAC3B,MAAI,WAAW,IAGb,QAAO;AAET,QAAM;;;;;;;;;;AAWV,SAAgB,oBACd,OACA,cACQ;AACR,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;AAwGjF,eAAsB,wBAAwB,MAA+C;CAC3F,MAAM,EAAE,aAAa,MAAM,QAAQ,KAAK,IAAI,OAAO,YAAY,eAAe,QAAQ,QAAQ,EAAE;CAEhG,MAAM,QAA+B,SAAS,QAAgB,QAAQ,OAAO,MAAM,IAAI;CAEvF,MAAM,EAAE,2BAA2B,MAAM,OAAO;AAMhD,KAAI,uBAAuB,IAAI,sBAAsB,EAAE;AACrD,MAAI,gBAAgB,KAAA,GAAW;GAC7B,MAAM,UAAiC,SAAU,MAAM,OAAO;GAE9D,MAAM,kBAAkB,oBAAoB,aADO,iBAAiB,QAAQ,WACH;AACzE,4BACE,IAAI,uBACJ,iBACA,SACA,wBACA,MACD;;AAEH;;AAIF,KAAI,gBAAgB,KAAA,EAClB;CAGF,MAAM,KAA4B,SAAU,MAAM,OAAO;CAGzD,MAAM,aAAa,oBAAoB,aAFS,iBAAiB,GAAG,WAEH;AACjE,KAAI,CAAC,GAAG,WAAW,WAAW,CAC5B;CAGF,IAAI;AACJ,KAAI;AACF,WAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;SAC7C;AAIN;;AAIF,KAAI,CAAC,uBAAuB,OAAO,CACjC;AAGF,KAAI,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;AA+B9B,SAAS,yBACP,WACA,YACA,OACA,wBACA,OACM;AAEN,KAAI,CAAC,MAAM,WAAW,WAAW,CAC/B;CAGF,IAAI;AACJ,KAAI;AACF,WAAS,MAAM,aAAa,YAAY,OAAO,CAAC,MAAM;SAChD;AAEN;;AAIF,KAAI,CAAC,uBAAuB,OAAO,CACjC;AAIF,KAAI,cAAc,OAChB,OACE,iRAID"}
@@ -150,4 +150,4 @@ function warnIfEnvDiffersFromFile(envSecret, secretPath, fsDep, isValidRelayAuth
150
150
  //#endregion
151
151
  export { loadRelaySecretReadOnly, nearestPackageJsonDir };
152
152
 
153
- //# sourceMappingURL=relay-secret-store-BcVrWwTq.js.map
153
+ //# sourceMappingURL=relay-secret-store-DKxs7zwq.js.map