@ait-co/devtools 0.1.143 → 0.1.144

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 (76) hide show
  1. package/README.en.md +87 -1
  2. package/README.md +87 -1
  3. package/dist/{chii-relay-BZ3HqWL5.js → chii-relay-B3ZhjGMi.js} +1 -1
  4. package/dist/{chii-relay-BZ3HqWL5.js.map → chii-relay-B3ZhjGMi.js.map} +1 -1
  5. package/dist/{chii-relay-D7eK2acz.cjs → chii-relay-CGMlePMd.cjs} +1 -1
  6. package/dist/{chii-relay-D7eK2acz.cjs.map → chii-relay-CGMlePMd.cjs.map} +1 -1
  7. package/dist/mcp/cli.js +3 -3
  8. package/dist/mcp/server.js +1 -1
  9. package/dist/optional-peers-CDEFhlhJ.cjs +131 -0
  10. package/dist/optional-peers-CDEFhlhJ.cjs.map +1 -0
  11. package/dist/optional-peers-FdVbUJst.js +96 -0
  12. package/dist/optional-peers-FdVbUJst.js.map +1 -0
  13. package/dist/panel/index.js +1 -1
  14. package/dist/{relay-secret-store-DWKdV-eY.cjs → relay-secret-store-BR0YIkNv.cjs} +2 -2
  15. package/dist/{relay-secret-store-DWKdV-eY.cjs.map → relay-secret-store-BR0YIkNv.cjs.map} +1 -1
  16. package/dist/{relay-secret-store-BPhN1upr.js → relay-secret-store-CYM8CBIF.js} +2 -2
  17. package/dist/{relay-secret-store-BPhN1upr.js.map → relay-secret-store-CYM8CBIF.js.map} +1 -1
  18. package/dist/{relay-url-store-1FGuSYAn.cjs → relay-url-store-C1as_m5G.cjs} +2 -2
  19. package/dist/{relay-url-store-1FGuSYAn.cjs.map → relay-url-store-C1as_m5G.cjs.map} +1 -1
  20. package/dist/{relay-url-store-Bskcyeg8.js → relay-url-store-CzFo_84F.js} +2 -2
  21. package/dist/{relay-url-store-Bskcyeg8.js.map → relay-url-store-CzFo_84F.js.map} +1 -1
  22. package/dist/{totp-DIbrZtI7.js → totp-BqmCLSNA.js} +2 -2
  23. package/dist/{totp-DIbrZtI7.js.map → totp-BqmCLSNA.js.map} +1 -1
  24. package/dist/{totp-Df252ZdA.cjs → totp-CMHR5lsW.cjs} +1 -2
  25. package/dist/{totp-Df252ZdA.cjs.map → totp-CMHR5lsW.cjs.map} +1 -1
  26. package/dist/{tunnel-BVSMXctM.cjs → tunnel-BGT9Curk.cjs} +27 -75
  27. package/dist/tunnel-BGT9Curk.cjs.map +1 -0
  28. package/dist/{tunnel-D7xkimBu.js → tunnel-BOKmLzBO.js} +27 -75
  29. package/dist/tunnel-BOKmLzBO.js.map +1 -0
  30. package/dist/unplugin/index.cjs +13 -17
  31. package/dist/unplugin/index.cjs.map +1 -1
  32. package/dist/unplugin/index.d.cts +12 -2
  33. package/dist/unplugin/index.d.cts.map +1 -1
  34. package/dist/unplugin/index.d.ts +12 -2
  35. package/dist/unplugin/index.d.ts.map +1 -1
  36. package/dist/unplugin/index.js +13 -17
  37. package/dist/unplugin/index.js.map +1 -1
  38. package/dist/unplugin/tunnel.cjs +61 -74
  39. package/dist/unplugin/tunnel.cjs.map +1 -1
  40. package/dist/unplugin/tunnel.d.cts +22 -16
  41. package/dist/unplugin/tunnel.d.cts.map +1 -1
  42. package/dist/unplugin/tunnel.d.ts +22 -16
  43. package/dist/unplugin/tunnel.d.ts.map +1 -1
  44. package/dist/unplugin/tunnel.js +61 -74
  45. package/dist/unplugin/tunnel.js.map +1 -1
  46. package/package.json +11 -1
  47. package/dist/deeplink-B5-Hxu0Q.js +0 -62
  48. package/dist/deeplink-B5-Hxu0Q.js.map +0 -1
  49. package/dist/deeplink-BpO9qc-D.js +0 -62
  50. package/dist/deeplink-BpO9qc-D.js.map +0 -1
  51. package/dist/deeplink-BzdbA1gV.cjs +0 -62
  52. package/dist/deeplink-BzdbA1gV.cjs.map +0 -1
  53. package/dist/deeplink-DCScMYcp.cjs +0 -62
  54. package/dist/deeplink-DCScMYcp.cjs.map +0 -1
  55. package/dist/devtools-opener-B8nxrxqu.js +0 -71
  56. package/dist/devtools-opener-B8nxrxqu.js.map +0 -1
  57. package/dist/devtools-opener-BDY0w3_0.cjs +0 -68
  58. package/dist/devtools-opener-BDY0w3_0.cjs.map +0 -1
  59. package/dist/devtools-opener-BTl5A6Cd.js +0 -71
  60. package/dist/devtools-opener-BTl5A6Cd.js.map +0 -1
  61. package/dist/devtools-opener-iv1OwfJN.cjs +0 -68
  62. package/dist/devtools-opener-iv1OwfJN.cjs.map +0 -1
  63. package/dist/qr-http-server--gl2-WKc.cjs +0 -1644
  64. package/dist/qr-http-server--gl2-WKc.cjs.map +0 -1
  65. package/dist/qr-http-server-Bb7lMeqi.js +0 -1644
  66. package/dist/qr-http-server-Bb7lMeqi.js.map +0 -1
  67. package/dist/qr-http-server-D-Off6K1.cjs +0 -1644
  68. package/dist/qr-http-server-D-Off6K1.cjs.map +0 -1
  69. package/dist/qr-http-server-n1twN18z.js +0 -1644
  70. package/dist/qr-http-server-n1twN18z.js.map +0 -1
  71. package/dist/totp-95OAa20j.js +0 -64
  72. package/dist/totp-95OAa20j.js.map +0 -1
  73. package/dist/totp-BjtoQNfu.cjs +0 -64
  74. package/dist/totp-BjtoQNfu.cjs.map +0 -1
  75. package/dist/tunnel-BVSMXctM.cjs.map +0 -1
  76. package/dist/tunnel-D7xkimBu.js.map +0 -1
package/dist/mcp/cli.js CHANGED
@@ -2077,7 +2077,7 @@ async function readMcpSdkVersion() {
2077
2077
  * some test environments that skip the build step).
2078
2078
  */
2079
2079
  function readDevtoolsVersion() {
2080
- return "0.1.143";
2080
+ return "0.1.144";
2081
2081
  }
2082
2082
  /**
2083
2083
  * Derives the next recommended action from a completed diagnostics snapshot.
@@ -7554,7 +7554,7 @@ function createDebugServer(deps) {
7554
7554
  };
7555
7555
  const server = new Server({
7556
7556
  name: "ait-debug",
7557
- version: "0.1.143"
7557
+ version: "0.1.144"
7558
7558
  }, { capabilities: { tools: { listChanged: true } } });
7559
7559
  server.setRequestHandler(ListToolsRequestSchema, () => {
7560
7560
  const conn = router.active;
@@ -9545,7 +9545,7 @@ function createDevServer(deps = {}) {
9545
9545
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
9546
9546
  const server = new Server({
9547
9547
  name: "ait-devtools",
9548
- version: "0.1.143"
9548
+ version: "0.1.144"
9549
9549
  }, { capabilities: { tools: {} } });
9550
9550
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
9551
9551
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -1022,7 +1022,7 @@ function createDevServer(deps = {}) {
1022
1022
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
1023
1023
  const server = new Server({
1024
1024
  name: "ait-devtools",
1025
- version: "0.1.143"
1025
+ version: "0.1.144"
1026
1026
  }, { capabilities: { tools: {} } });
1027
1027
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
1028
1028
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -0,0 +1,131 @@
1
+ //#region src/unplugin/optional-peers.ts
2
+ /**
3
+ * Optional-peer resolution gates for the debug surface (issue #817).
4
+ *
5
+ * The debug surface now ships as two SEPARATE packages, declared here as
6
+ * OPTIONAL peerDependencies of `@ait-co/devtools`:
7
+ *
8
+ * - `@ait-co/debugger` — MCP daemon / test runner / dev-bridge. devDependency
9
+ * or `npx` only; never part of an app bundle.
10
+ * - `@ait-co/debug-console` — on-device attach + eruda console. The ONLY debug
11
+ * package that can legitimately enter an app bundle.
12
+ *
13
+ * Neither may become a hard dependency. The overwhelming majority of consumers
14
+ * use environment 1 only (browser + mock SDK + panel) and must install NOTHING
15
+ * extra — that is the whole point of splitting the packages apart. So every
16
+ * call site reaches them through the resolution gates below and degrades
17
+ * gracefully when they are absent:
18
+ *
19
+ * - env-2 CDP wiring (`tunnel.cdp`) → falls back to the plain screen-preview
20
+ * tunnel (no relay, no dashboard) and prints {@link INSTALL_HINT}.
21
+ * - in-app attach injection → the snippet is simply not injected, so the
22
+ * attach code is STRUCTURALLY ABSENT from the bundle rather than merely
23
+ * gated at runtime. This gate is the technical enforcement point of the
24
+ * debug surface's security scope: no `@ait-co/debug-console` installed
25
+ * means no attach code can reach a bundle at all.
26
+ *
27
+ * The `import.meta.resolve` probe follows the `MOCK_PATH` precedent in
28
+ * `./index.ts`: resolution is attempted once at plugin-load time and a failure
29
+ * is a plain "not installed" signal, never an error.
30
+ *
31
+ * SECRET-HANDLING: nothing here touches a relay URL, tunnel host, TOTP secret
32
+ * or code. The only strings emitted are fixed specifiers and the install hint.
33
+ */
34
+ /** Subpath of `@ait-co/debugger` that the unplugin's dev loop delegates to. */
35
+ const DEBUGGER_DEV_BRIDGE_ID = "@ait-co/debugger/dev-bridge";
36
+ /** The on-device attach package injected into a consumer entry point. */
37
+ const DEBUG_CONSOLE_ID = "@ait-co/debug-console";
38
+ /** One-line install hint printed whenever a gate closes on a missing package. */
39
+ const INSTALL_HINT = "pnpm add -D @ait-co/debugger @ait-co/debug-console";
40
+ /**
41
+ * Returns whether `specifier` resolves from this package's location.
42
+ *
43
+ * `import.meta.resolve` throws (rather than returning a falsy value) for an
44
+ * unresolvable specifier, and is itself absent on a few older runtimes — both
45
+ * are treated as "not installed", which is the safe direction: the caller then
46
+ * takes the degraded path instead of importing something that is not there.
47
+ */
48
+ function canResolveOptionalPeer(specifier) {
49
+ try {
50
+ return typeof {}.resolve(specifier) === "string";
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+ /** Whether `@ait-co/debugger`'s dev-bridge is installed (env-2 CDP path). */
56
+ function hasDebugger() {
57
+ return canResolveOptionalPeer(DEBUGGER_DEV_BRIDGE_ID);
58
+ }
59
+ /** Whether `@ait-co/debug-console` is installed (in-app attach injection). */
60
+ function hasDebugConsole() {
61
+ return canResolveOptionalPeer(DEBUG_CONSOLE_ID);
62
+ }
63
+ /**
64
+ * Whether `code` already wires the in-app debug attach itself.
65
+ *
66
+ * Both the current (`@ait-co/debug-console`) and the pre-split
67
+ * (`@ait-co/devtools/in-app`) specifier count, so a consumer who wired either
68
+ * one by hand never gets a duplicate auto-injection.
69
+ */
70
+ function hasInAppWiring(code) {
71
+ return code.includes("@ait-co/debug-console") || code.includes("@ait-co/devtools/in-app");
72
+ }
73
+ /**
74
+ * The gated dynamic-import snippet prepended to a consumer entry point.
75
+ *
76
+ * The runtime gate (`?debug=1` + a `relay` param) keeps the chunk unloaded on a
77
+ * normal page load, and a production build dead-code-eliminates it. The
78
+ * build-time gate is {@link hasDebugConsole} at the call site: when the package
79
+ * is absent this snippet is never produced, so the attach code cannot be in the
80
+ * bundle at all.
81
+ */
82
+ function buildInAppSnippet() {
83
+ return [
84
+ "// @ait-co/devtools: in-app attach auto-injected by unplugin — 수동 배선 불필요",
85
+ "if (typeof window !== 'undefined') {",
86
+ " const __ait_p = new URLSearchParams(window.location.search);",
87
+ " if (__ait_p.get('debug') === '1' && __ait_p.get('relay')) {",
88
+ ` void import('${DEBUG_CONSOLE_ID}').then((m) => m.maybeAttach());`,
89
+ " }",
90
+ "}"
91
+ ].join("\n");
92
+ }
93
+ //#endregion
94
+ Object.defineProperty(exports, "DEBUGGER_DEV_BRIDGE_ID", {
95
+ enumerable: true,
96
+ get: function() {
97
+ return DEBUGGER_DEV_BRIDGE_ID;
98
+ }
99
+ });
100
+ Object.defineProperty(exports, "INSTALL_HINT", {
101
+ enumerable: true,
102
+ get: function() {
103
+ return INSTALL_HINT;
104
+ }
105
+ });
106
+ Object.defineProperty(exports, "buildInAppSnippet", {
107
+ enumerable: true,
108
+ get: function() {
109
+ return buildInAppSnippet;
110
+ }
111
+ });
112
+ Object.defineProperty(exports, "hasDebugConsole", {
113
+ enumerable: true,
114
+ get: function() {
115
+ return hasDebugConsole;
116
+ }
117
+ });
118
+ Object.defineProperty(exports, "hasDebugger", {
119
+ enumerable: true,
120
+ get: function() {
121
+ return hasDebugger;
122
+ }
123
+ });
124
+ Object.defineProperty(exports, "hasInAppWiring", {
125
+ enumerable: true,
126
+ get: function() {
127
+ return hasInAppWiring;
128
+ }
129
+ });
130
+
131
+ //# sourceMappingURL=optional-peers-CDEFhlhJ.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"optional-peers-CDEFhlhJ.cjs","names":[],"sources":["../src/unplugin/optional-peers.ts"],"sourcesContent":["/**\n * Optional-peer resolution gates for the debug surface (issue #817).\n *\n * The debug surface now ships as two SEPARATE packages, declared here as\n * OPTIONAL peerDependencies of `@ait-co/devtools`:\n *\n * - `@ait-co/debugger` — MCP daemon / test runner / dev-bridge. devDependency\n * or `npx` only; never part of an app bundle.\n * - `@ait-co/debug-console` — on-device attach + eruda console. The ONLY debug\n * package that can legitimately enter an app bundle.\n *\n * Neither may become a hard dependency. The overwhelming majority of consumers\n * use environment 1 only (browser + mock SDK + panel) and must install NOTHING\n * extra — that is the whole point of splitting the packages apart. So every\n * call site reaches them through the resolution gates below and degrades\n * gracefully when they are absent:\n *\n * - env-2 CDP wiring (`tunnel.cdp`) → falls back to the plain screen-preview\n * tunnel (no relay, no dashboard) and prints {@link INSTALL_HINT}.\n * - in-app attach injection → the snippet is simply not injected, so the\n * attach code is STRUCTURALLY ABSENT from the bundle rather than merely\n * gated at runtime. This gate is the technical enforcement point of the\n * debug surface's security scope: no `@ait-co/debug-console` installed\n * means no attach code can reach a bundle at all.\n *\n * The `import.meta.resolve` probe follows the `MOCK_PATH` precedent in\n * `./index.ts`: resolution is attempted once at plugin-load time and a failure\n * is a plain \"not installed\" signal, never an error.\n *\n * SECRET-HANDLING: nothing here touches a relay URL, tunnel host, TOTP secret\n * or code. The only strings emitted are fixed specifiers and the install hint.\n */\n\n/** Subpath of `@ait-co/debugger` that the unplugin's dev loop delegates to. */\nexport const DEBUGGER_DEV_BRIDGE_ID = '@ait-co/debugger/dev-bridge';\n\n/** The on-device attach package injected into a consumer entry point. */\nexport const DEBUG_CONSOLE_ID = '@ait-co/debug-console';\n\n/**\n * The pre-split in-app specifier. Still recognised for DEDUPE only — a consumer\n * who wired `@ait-co/devtools/in-app` by hand must not also receive the new\n * auto-injected snippet. It is never injected any more.\n */\nexport const LEGACY_IN_APP_ID = '@ait-co/devtools/in-app';\n\n/** One-line install hint printed whenever a gate closes on a missing package. */\nexport const INSTALL_HINT = 'pnpm add -D @ait-co/debugger @ait-co/debug-console';\n\n/**\n * Returns whether `specifier` resolves from this package's location.\n *\n * `import.meta.resolve` throws (rather than returning a falsy value) for an\n * unresolvable specifier, and is itself absent on a few older runtimes — both\n * are treated as \"not installed\", which is the safe direction: the caller then\n * takes the degraded path instead of importing something that is not there.\n */\nexport function canResolveOptionalPeer(specifier: string): boolean {\n try {\n return typeof import.meta.resolve(specifier) === 'string';\n } catch {\n return false;\n }\n}\n\n/** Whether `@ait-co/debugger`'s dev-bridge is installed (env-2 CDP path). */\nexport function hasDebugger(): boolean {\n return canResolveOptionalPeer(DEBUGGER_DEV_BRIDGE_ID);\n}\n\n/** Whether `@ait-co/debug-console` is installed (in-app attach injection). */\nexport function hasDebugConsole(): boolean {\n return canResolveOptionalPeer(DEBUG_CONSOLE_ID);\n}\n\n/**\n * Whether `code` already wires the in-app debug attach itself.\n *\n * Both the current (`@ait-co/debug-console`) and the pre-split\n * (`@ait-co/devtools/in-app`) specifier count, so a consumer who wired either\n * one by hand never gets a duplicate auto-injection.\n */\nexport function hasInAppWiring(code: string): boolean {\n return code.includes(DEBUG_CONSOLE_ID) || code.includes(LEGACY_IN_APP_ID);\n}\n\n/**\n * The gated dynamic-import snippet prepended to a consumer entry point.\n *\n * The runtime gate (`?debug=1` + a `relay` param) keeps the chunk unloaded on a\n * normal page load, and a production build dead-code-eliminates it. The\n * build-time gate is {@link hasDebugConsole} at the call site: when the package\n * is absent this snippet is never produced, so the attach code cannot be in the\n * bundle at all.\n */\nexport function buildInAppSnippet(): string {\n return [\n '// @ait-co/devtools: in-app attach auto-injected by unplugin — 수동 배선 불필요',\n \"if (typeof window !== 'undefined') {\",\n ' const __ait_p = new URLSearchParams(window.location.search);',\n \" if (__ait_p.get('debug') === '1' && __ait_p.get('relay')) {\",\n ` void import('${DEBUG_CONSOLE_ID}').then((m) => m.maybeAttach());`,\n ' }',\n '}',\n ].join('\\n');\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCA,MAAa,yBAAyB;;AAGtC,MAAa,mBAAmB;;AAUhC,MAAa,eAAe;;;;;;;;;AAU5B,SAAgB,uBAAuB,WAA4B;AACjE,KAAI;AACF,SAAO,OAAA,EAAA,CAAmB,QAAQ,UAAU,KAAK;SAC3C;AACN,SAAO;;;;AAKX,SAAgB,cAAuB;AACrC,QAAO,uBAAuB,uBAAuB;;;AAIvD,SAAgB,kBAA2B;AACzC,QAAO,uBAAuB,iBAAiB;;;;;;;;;AAUjD,SAAgB,eAAe,MAAuB;AACpD,QAAO,KAAK,SAAA,wBAA0B,IAAI,KAAK,SAAA,0BAA0B;;;;;;;;;;;AAY3E,SAAgB,oBAA4B;AAC1C,QAAO;EACL;EACA;EACA;EACA;EACA,oBAAoB,iBAAiB;EACrC;EACA;EACD,CAAC,KAAK,KAAK"}
@@ -0,0 +1,96 @@
1
+ //#region src/unplugin/optional-peers.ts
2
+ /**
3
+ * Optional-peer resolution gates for the debug surface (issue #817).
4
+ *
5
+ * The debug surface now ships as two SEPARATE packages, declared here as
6
+ * OPTIONAL peerDependencies of `@ait-co/devtools`:
7
+ *
8
+ * - `@ait-co/debugger` — MCP daemon / test runner / dev-bridge. devDependency
9
+ * or `npx` only; never part of an app bundle.
10
+ * - `@ait-co/debug-console` — on-device attach + eruda console. The ONLY debug
11
+ * package that can legitimately enter an app bundle.
12
+ *
13
+ * Neither may become a hard dependency. The overwhelming majority of consumers
14
+ * use environment 1 only (browser + mock SDK + panel) and must install NOTHING
15
+ * extra — that is the whole point of splitting the packages apart. So every
16
+ * call site reaches them through the resolution gates below and degrades
17
+ * gracefully when they are absent:
18
+ *
19
+ * - env-2 CDP wiring (`tunnel.cdp`) → falls back to the plain screen-preview
20
+ * tunnel (no relay, no dashboard) and prints {@link INSTALL_HINT}.
21
+ * - in-app attach injection → the snippet is simply not injected, so the
22
+ * attach code is STRUCTURALLY ABSENT from the bundle rather than merely
23
+ * gated at runtime. This gate is the technical enforcement point of the
24
+ * debug surface's security scope: no `@ait-co/debug-console` installed
25
+ * means no attach code can reach a bundle at all.
26
+ *
27
+ * The `import.meta.resolve` probe follows the `MOCK_PATH` precedent in
28
+ * `./index.ts`: resolution is attempted once at plugin-load time and a failure
29
+ * is a plain "not installed" signal, never an error.
30
+ *
31
+ * SECRET-HANDLING: nothing here touches a relay URL, tunnel host, TOTP secret
32
+ * or code. The only strings emitted are fixed specifiers and the install hint.
33
+ */
34
+ /** Subpath of `@ait-co/debugger` that the unplugin's dev loop delegates to. */
35
+ const DEBUGGER_DEV_BRIDGE_ID = "@ait-co/debugger/dev-bridge";
36
+ /** The on-device attach package injected into a consumer entry point. */
37
+ const DEBUG_CONSOLE_ID = "@ait-co/debug-console";
38
+ /** One-line install hint printed whenever a gate closes on a missing package. */
39
+ const INSTALL_HINT = "pnpm add -D @ait-co/debugger @ait-co/debug-console";
40
+ /**
41
+ * Returns whether `specifier` resolves from this package's location.
42
+ *
43
+ * `import.meta.resolve` throws (rather than returning a falsy value) for an
44
+ * unresolvable specifier, and is itself absent on a few older runtimes — both
45
+ * are treated as "not installed", which is the safe direction: the caller then
46
+ * takes the degraded path instead of importing something that is not there.
47
+ */
48
+ function canResolveOptionalPeer(specifier) {
49
+ try {
50
+ return typeof import.meta.resolve(specifier) === "string";
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+ /** Whether `@ait-co/debugger`'s dev-bridge is installed (env-2 CDP path). */
56
+ function hasDebugger() {
57
+ return canResolveOptionalPeer(DEBUGGER_DEV_BRIDGE_ID);
58
+ }
59
+ /** Whether `@ait-co/debug-console` is installed (in-app attach injection). */
60
+ function hasDebugConsole() {
61
+ return canResolveOptionalPeer(DEBUG_CONSOLE_ID);
62
+ }
63
+ /**
64
+ * Whether `code` already wires the in-app debug attach itself.
65
+ *
66
+ * Both the current (`@ait-co/debug-console`) and the pre-split
67
+ * (`@ait-co/devtools/in-app`) specifier count, so a consumer who wired either
68
+ * one by hand never gets a duplicate auto-injection.
69
+ */
70
+ function hasInAppWiring(code) {
71
+ return code.includes("@ait-co/debug-console") || code.includes("@ait-co/devtools/in-app");
72
+ }
73
+ /**
74
+ * The gated dynamic-import snippet prepended to a consumer entry point.
75
+ *
76
+ * The runtime gate (`?debug=1` + a `relay` param) keeps the chunk unloaded on a
77
+ * normal page load, and a production build dead-code-eliminates it. The
78
+ * build-time gate is {@link hasDebugConsole} at the call site: when the package
79
+ * is absent this snippet is never produced, so the attach code cannot be in the
80
+ * bundle at all.
81
+ */
82
+ function buildInAppSnippet() {
83
+ return [
84
+ "// @ait-co/devtools: in-app attach auto-injected by unplugin — 수동 배선 불필요",
85
+ "if (typeof window !== 'undefined') {",
86
+ " const __ait_p = new URLSearchParams(window.location.search);",
87
+ " if (__ait_p.get('debug') === '1' && __ait_p.get('relay')) {",
88
+ ` void import('${DEBUG_CONSOLE_ID}').then((m) => m.maybeAttach());`,
89
+ " }",
90
+ "}"
91
+ ].join("\n");
92
+ }
93
+ //#endregion
94
+ export { hasDebugger as a, hasDebugConsole as i, INSTALL_HINT as n, hasInAppWiring as o, buildInAppSnippet as r, DEBUGGER_DEV_BRIDGE_ID as t };
95
+
96
+ //# sourceMappingURL=optional-peers-FdVbUJst.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"optional-peers-FdVbUJst.js","names":[],"sources":["../src/unplugin/optional-peers.ts"],"sourcesContent":["/**\n * Optional-peer resolution gates for the debug surface (issue #817).\n *\n * The debug surface now ships as two SEPARATE packages, declared here as\n * OPTIONAL peerDependencies of `@ait-co/devtools`:\n *\n * - `@ait-co/debugger` — MCP daemon / test runner / dev-bridge. devDependency\n * or `npx` only; never part of an app bundle.\n * - `@ait-co/debug-console` — on-device attach + eruda console. The ONLY debug\n * package that can legitimately enter an app bundle.\n *\n * Neither may become a hard dependency. The overwhelming majority of consumers\n * use environment 1 only (browser + mock SDK + panel) and must install NOTHING\n * extra — that is the whole point of splitting the packages apart. So every\n * call site reaches them through the resolution gates below and degrades\n * gracefully when they are absent:\n *\n * - env-2 CDP wiring (`tunnel.cdp`) → falls back to the plain screen-preview\n * tunnel (no relay, no dashboard) and prints {@link INSTALL_HINT}.\n * - in-app attach injection → the snippet is simply not injected, so the\n * attach code is STRUCTURALLY ABSENT from the bundle rather than merely\n * gated at runtime. This gate is the technical enforcement point of the\n * debug surface's security scope: no `@ait-co/debug-console` installed\n * means no attach code can reach a bundle at all.\n *\n * The `import.meta.resolve` probe follows the `MOCK_PATH` precedent in\n * `./index.ts`: resolution is attempted once at plugin-load time and a failure\n * is a plain \"not installed\" signal, never an error.\n *\n * SECRET-HANDLING: nothing here touches a relay URL, tunnel host, TOTP secret\n * or code. The only strings emitted are fixed specifiers and the install hint.\n */\n\n/** Subpath of `@ait-co/debugger` that the unplugin's dev loop delegates to. */\nexport const DEBUGGER_DEV_BRIDGE_ID = '@ait-co/debugger/dev-bridge';\n\n/** The on-device attach package injected into a consumer entry point. */\nexport const DEBUG_CONSOLE_ID = '@ait-co/debug-console';\n\n/**\n * The pre-split in-app specifier. Still recognised for DEDUPE only — a consumer\n * who wired `@ait-co/devtools/in-app` by hand must not also receive the new\n * auto-injected snippet. It is never injected any more.\n */\nexport const LEGACY_IN_APP_ID = '@ait-co/devtools/in-app';\n\n/** One-line install hint printed whenever a gate closes on a missing package. */\nexport const INSTALL_HINT = 'pnpm add -D @ait-co/debugger @ait-co/debug-console';\n\n/**\n * Returns whether `specifier` resolves from this package's location.\n *\n * `import.meta.resolve` throws (rather than returning a falsy value) for an\n * unresolvable specifier, and is itself absent on a few older runtimes — both\n * are treated as \"not installed\", which is the safe direction: the caller then\n * takes the degraded path instead of importing something that is not there.\n */\nexport function canResolveOptionalPeer(specifier: string): boolean {\n try {\n return typeof import.meta.resolve(specifier) === 'string';\n } catch {\n return false;\n }\n}\n\n/** Whether `@ait-co/debugger`'s dev-bridge is installed (env-2 CDP path). */\nexport function hasDebugger(): boolean {\n return canResolveOptionalPeer(DEBUGGER_DEV_BRIDGE_ID);\n}\n\n/** Whether `@ait-co/debug-console` is installed (in-app attach injection). */\nexport function hasDebugConsole(): boolean {\n return canResolveOptionalPeer(DEBUG_CONSOLE_ID);\n}\n\n/**\n * Whether `code` already wires the in-app debug attach itself.\n *\n * Both the current (`@ait-co/debug-console`) and the pre-split\n * (`@ait-co/devtools/in-app`) specifier count, so a consumer who wired either\n * one by hand never gets a duplicate auto-injection.\n */\nexport function hasInAppWiring(code: string): boolean {\n return code.includes(DEBUG_CONSOLE_ID) || code.includes(LEGACY_IN_APP_ID);\n}\n\n/**\n * The gated dynamic-import snippet prepended to a consumer entry point.\n *\n * The runtime gate (`?debug=1` + a `relay` param) keeps the chunk unloaded on a\n * normal page load, and a production build dead-code-eliminates it. The\n * build-time gate is {@link hasDebugConsole} at the call site: when the package\n * is absent this snippet is never produced, so the attach code cannot be in the\n * bundle at all.\n */\nexport function buildInAppSnippet(): string {\n return [\n '// @ait-co/devtools: in-app attach auto-injected by unplugin — 수동 배선 불필요',\n \"if (typeof window !== 'undefined') {\",\n ' const __ait_p = new URLSearchParams(window.location.search);',\n \" if (__ait_p.get('debug') === '1' && __ait_p.get('relay')) {\",\n ` void import('${DEBUG_CONSOLE_ID}').then((m) => m.maybeAttach());`,\n ' }',\n '}',\n ].join('\\n');\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCA,MAAa,yBAAyB;;AAGtC,MAAa,mBAAmB;;AAUhC,MAAa,eAAe;;;;;;;;;AAU5B,SAAgB,uBAAuB,WAA4B;AACjE,KAAI;AACF,SAAO,OAAO,OAAO,KAAK,QAAQ,UAAU,KAAK;SAC3C;AACN,SAAO;;;;AAKX,SAAgB,cAAuB;AACrC,QAAO,uBAAuB,uBAAuB;;;AAIvD,SAAgB,kBAA2B;AACzC,QAAO,uBAAuB,iBAAiB;;;;;;;;;AAUjD,SAAgB,eAAe,MAAuB;AACpD,QAAO,KAAK,SAAA,wBAA0B,IAAI,KAAK,SAAA,0BAA0B;;;;;;;;;;;AAY3E,SAAgB,oBAA4B;AAC1C,QAAO;EACL;EACA;EACA;EACA;EACA,oBAAoB,iBAAiB;EACrC;EACA;EACD,CAAC,KAAK,KAAK"}
@@ -30295,7 +30295,7 @@ function Panel() {
30295
30295
  color: "#666",
30296
30296
  fontWeight: 400
30297
30297
  },
30298
- children: ["v", "0.1.143"]
30298
+ children: ["v", "0.1.144"]
30299
30299
  }),
30300
30300
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
30301
30301
  type: "button",
@@ -83,7 +83,7 @@ function relaySecretFilePath(start, existsSyncFn) {
83
83
  async function ensureRelaySecret(deps) {
84
84
  const { projectRoot, env = process.env, randomBytes: randomBytesFn, fs: fsDep, existsSync: existsSyncDep, cwd: cwdFn, log } = deps ?? {};
85
85
  const logFn = log ?? ((msg) => process.stderr.write(msg));
86
- const { isValidRelayAuthSecret } = await Promise.resolve().then(() => require("./totp-Df252ZdA.cjs"));
86
+ const { isValidRelayAuthSecret } = await Promise.resolve().then(() => require("./totp-CMHR5lsW.cjs"));
87
87
  if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {
88
88
  const fsEarly = fsDep ?? await import("node:fs");
89
89
  const existsSyncEarly = existsSyncDep ?? fsEarly.existsSync;
@@ -238,4 +238,4 @@ async function mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSe
238
238
  exports.ensureRelaySecret = ensureRelaySecret;
239
239
  exports.nearestPackageJsonDir = nearestPackageJsonDir;
240
240
 
241
- //# sourceMappingURL=relay-secret-store-DWKdV-eY.cjs.map
241
+ //# sourceMappingURL=relay-secret-store-BR0YIkNv.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"relay-secret-store-DWKdV-eY.cjs","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":"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"}
1
+ {"version":3,"file":"relay-secret-store-BR0YIkNv.cjs","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,cAAA,GAAA,UAAA,MAAkB,KAAK,eAAe,CAAC,CACzC,QAAO;EAET,MAAM,UAAA,GAAA,UAAA,SAAiB,IAAI;AAC3B,MAAI,WAAW,IAGb,QAAO;AAET,QAAM;;;;;;;;;;AAWV,SAAgB,oBACd,OACA,cACQ;AACR,SAAA,GAAA,UAAA,MAAY,sBAAsB,OAAO,aAAa,EAAE,uBAAuB;;;;;;;;;;;;;;;;;AAsBjF,eAAsB,kBAAkB,MAAuC;CAC7E,MAAM,EACJ,aACA,MAAM,QAAQ,KACd,aAAa,eACb,IAAI,OACJ,YAAY,eACZ,KAAK,OACL,QACE,QAAQ,EAAE;CAEd,MAAM,QAA+B,SAAS,QAAgB,QAAQ,OAAO,MAAM,IAAI;CAIvF,MAAM,EAAE,2BAA2B,MAAA,QAAA,SAAA,CAAA,WAAA,QAAM,sBAAA,CAAA;AAMzC,KAAI,uBAAuB,IAAI,sBAAsB,EAAE;EAIrD,MAAM,UAAyB,SAAU,MAAM,OAAO;EACtD,MAAM,kBAA6C,iBAAiB,QAAQ;EAE5E,MAAM,kBAAkB,oBADL,gBAAgB,gBAAgB,QAAQ,KAAK,IAAI,EACZ,gBAAgB;AACxE,2BACE,IAAI,uBACJ,iBACA,SACA,wBACA,MACD;AACD;;CAIF,MAAM,KAA4B,kBAAkB,MAAM,OAAO,gBAAgB;CACjF,MAAM,KAAoB,SAAU,MAAM,OAAO;CACjD,MAAM,eAA0C,iBAAiB,GAAG;CAGpE,MAAM,aAAa,oBADL,gBAAgB,gBAAgB,QAAQ,KAAK,IAAI,EACjB,aAAa;AAG3D,KAAI,GAAG,WAAW,WAAW,CAC3B,QAAO,cAAc,YAAY,IAAI,KAAK,OAAO,wBAAwB,GAAG;AAI9E,QAAO,eAAe,YAAY,IAAI,KAAK,IAAI,OAAO,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;;;AAmH/E,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;;;;;;;;;;;;;;;;;;;;;;;;;;AAyCL,SAAS,uBACP,WACA,OACA,QACiB;CACjB,MAAM,MAAA,GAAA,UAAA,MAAU,WAAW,aAAa;CAMxC,MAAM,QAAQ,CAAC,wBAAwB,YAAY;CAEnD,IAAI,WAAW;AACf,KAAI,MAAM,WAAW,GAAG,CACtB,KAAI;AACF,aAAW,MAAM,aAAa,IAAI,OAAO;SACnC;AAEN,SAAO;GAAE,UAAU;GAAO,QAAQ;GAAM;;CAI5C,MAAM,eAAe,IAAI,IAAI,SAAS,MAAM,QAAQ,CAAC,KAAK,MAAM,EAAE,MAAM,CAAC,CAAC;CAC1E,MAAM,UAAU,MAAM,QAAQ,MAAM,CAAC,aAAa,IAAI,EAAE,CAAC;AAEzD,KAAI,QAAQ,WAAW,EAErB,QAAO;EAAE,UAAU;EAAO,QAAQ;EAAO;CAI3C,MAAM,SAAS,aAAa,MAAM,SAAS,SAAS,KAAK,GAAG,KAAK;AACjE,KAAI;AACF,QAAM,eAAe,IAAI,GAAG,SAAS,QAAQ,KAAK,KAAK,CAAC,IAAI;AAC5D,SAAO;GAAE,UAAU;GAAM,QAAQ;GAAO;SAClC;AAGN,SAAO;GAAE,UAAU;GAAO,QAAQ;GAAM;;;AAI5C,eAAe,cACb,YACA,IACA,KACA,OACA,wBACA,IACe;CACf,IAAI;AACJ,KAAI;AACF,WAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;UAC5C,KAAK;EACZ,MAAM,MAAM,eAAe,QAAQ,IAAI,UAAU,OAAO,IAAI;AAC5D,QAAM,IAAI,MAAM,0CAA0C,MAAM;;AAGlE,KAAI,CAAC,uBAAuB,OAAO,EAAE;AAEnC,QAAM,2DAA2D;AACjE,SAAO,eAAe,YAAY,IAAI,KAAK,IAAI,OAAO,wBAAwB,KAAK;;AAIrF,KAAI,wBAAwB;AAM5B,yBAAA,GAAA,UAAA,SAA+B,WAAW,EAAE,IAAI,MAAM;;AAGxD,eAAe,eACb,YACA,IACA,KACA,IACA,OACA,wBAEA,YAAY,OACG;CAGf,MAAM,SAAS,GAAG,GAAG,CAAC,SAAS,MAAM;AAGrC,KAAI,CAAC,uBAAuB,OAAO,CACjC,OAAM,IAAI,MACR,2DACD;CAOH,MAAM,OAAO,YAAY,MAAM;AAC/B,KAAI;AACF,KAAG,cAAc,YAAY,QAAQ;GAAE,MAAM;GAAO;GAAM,CAAC;AAE3D,KAAG,UAAU,YAAY,IAAM;UACxB,KAAK;AACZ,MAAK,IAA8B,SAAS,UAAU;GAEpD,IAAI;AACJ,OAAI;AACF,aAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;YAC5C,SAAS;IAChB,MAAM,MAAM,mBAAmB,QAAQ,QAAQ,UAAU,OAAO,QAAQ;AACxE,UAAM,IAAI,MAAM,8CAA8C,MAAM;;AAEtE,OAAI,CAAC,uBAAuB,OAAO,CACjC,OAAM,IAAI,MAAM,qDAAqD;AAEvE,OAAI,wBAAwB;AAC5B;;AAEF,QAAM;;AAKR,KAAI,wBAAwB;CAK5B,MAAM,WAAW,wBAAA,GAAA,UAAA,SAA+B,WAAW,EAAE,IAAI,MAAM;CAKvE,MAAM,SAAS,SAAS,WACpB,8DACA,SAAS,SACP,oFACA;AAEN,OACE,8CAA8C,uBAAuB,0HAGlE,SAAS,SAAS,MACnB,sDACH"}
@@ -83,7 +83,7 @@ function relaySecretFilePath(start, existsSyncFn) {
83
83
  async function ensureRelaySecret(deps) {
84
84
  const { projectRoot, env = process.env, randomBytes: randomBytesFn, fs: fsDep, existsSync: existsSyncDep, cwd: cwdFn, log } = deps ?? {};
85
85
  const logFn = log ?? ((msg) => process.stderr.write(msg));
86
- const { isValidRelayAuthSecret } = await import("./totp-DIbrZtI7.js");
86
+ const { isValidRelayAuthSecret } = await import("./totp-BqmCLSNA.js");
87
87
  if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {
88
88
  const fsEarly = fsDep ?? await import("node:fs");
89
89
  const existsSyncEarly = existsSyncDep ?? fsEarly.existsSync;
@@ -237,4 +237,4 @@ async function mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSe
237
237
  //#endregion
238
238
  export { ensureRelaySecret, nearestPackageJsonDir };
239
239
 
240
- //# sourceMappingURL=relay-secret-store-BPhN1upr.js.map
240
+ //# sourceMappingURL=relay-secret-store-CYM8CBIF.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"relay-secret-store-BPhN1upr.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":"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"}
1
+ {"version":3,"file":"relay-secret-store-CYM8CBIF.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":"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"}