@ait-co/devtools 0.1.122 → 0.1.124
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{attach-orchestrator-EnZEr4Uu.js → attach-orchestrator-CpE0dMew.js} +2 -2
- package/dist/{attach-orchestrator-EnZEr4Uu.js.map → attach-orchestrator-CpE0dMew.js.map} +1 -1
- package/dist/{cell-3bIG9538.js → cell-Ba9lCI3B.js} +2 -2
- package/dist/{cell-3bIG9538.js.map → cell-Ba9lCI3B.js.map} +1 -1
- package/dist/{cell-Cad-vi7X.js → cell-CX92lDI0.js} +3 -3
- package/dist/{cell-CgKUkDxY.js.map → cell-CX92lDI0.js.map} +1 -1
- package/dist/{cell-CgKUkDxY.js → cell-D0dJSGHi.js} +2 -2
- package/dist/{cell-Cad-vi7X.js.map → cell-D0dJSGHi.js.map} +1 -1
- package/dist/{debug-server-D79E3hTa.js → debug-server-B3W_0Ejf.js} +6 -226
- package/dist/debug-server-B3W_0Ejf.js.map +1 -0
- package/dist/{debug-server-CF-1s3ks.js → debug-server-BDfeWytB.js} +6 -944
- package/dist/debug-server-BDfeWytB.js.map +1 -0
- package/dist/{debug-server-D_28CxC1.js → debug-server-BzzNkETg.js} +6 -6
- package/dist/{debug-server-D_28CxC1.js.map → debug-server-BzzNkETg.js.map} +1 -1
- package/dist/mcp/cli.js +8214 -4
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/panel/index.js +1 -1
- package/dist/{qr-http-server-BsWlOA85.js → qr-http-server-BH0UFeUn.js} +11 -1
- package/dist/{qr-http-server-gHBd-z7T.js.map → qr-http-server-BH0UFeUn.js.map} +1 -1
- package/dist/{qr-http-server-UBV2qUEd.js → qr-http-server-Bo8LMOJQ.js} +11 -1
- package/dist/{qr-http-server-BJjw0hDr.js.map → qr-http-server-Bo8LMOJQ.js.map} +1 -1
- package/dist/{qr-http-server-CrVKno9V.cjs → qr-http-server-Bt_RAxFn.cjs} +11 -1
- package/dist/{qr-http-server-BpTvfeku.cjs.map → qr-http-server-Bt_RAxFn.cjs.map} +1 -1
- package/dist/{qr-http-server-CKbtqvKy.js → qr-http-server-CrGohOwC.js} +11 -1
- package/dist/{qr-http-server-CKbtqvKy.js.map → qr-http-server-CrGohOwC.js.map} +1 -1
- package/dist/{qr-http-server-BpTvfeku.cjs → qr-http-server-DM2R25jP.cjs} +11 -1
- package/dist/{qr-http-server-CrVKno9V.cjs.map → qr-http-server-DM2R25jP.cjs.map} +1 -1
- package/dist/{qr-http-server-BJjw0hDr.js → qr-http-server-s1iW_5VQ.js} +11 -1
- package/dist/{qr-http-server-BsWlOA85.js.map → qr-http-server-s1iW_5VQ.js.map} +1 -1
- package/dist/{relay-factory-BBnZM-an.js → relay-factory-CqY264j0.js} +22 -5
- package/dist/relay-factory-CqY264j0.js.map +1 -0
- package/dist/{relay-secret-store-BJjoxes1.js → relay-secret-store-CFc9n0OA.js} +1 -1
- package/dist/{relay-secret-store-BJjoxes1.js.map → relay-secret-store-CFc9n0OA.js.map} +1 -1
- package/dist/{relay-secret-store-CtK1eqoV.js → relay-secret-store-DhzAnnj-.js} +3 -3
- package/dist/{relay-secret-store-CtK1eqoV.js.map → relay-secret-store-DhzAnnj-.js.map} +1 -1
- package/dist/{relay-url-store-D3H067u0.js → relay-url-store-CwKT7i04.js} +2 -2
- package/dist/{relay-url-store-D3H067u0.js.map → relay-url-store-CwKT7i04.js.map} +1 -1
- package/dist/{relay-url-store-uyVNe-Az.js → relay-url-store-DGQ-HPQC.js} +2 -2
- package/dist/{relay-url-store-uyVNe-Az.js.map → relay-url-store-DGQ-HPQC.js.map} +1 -1
- package/dist/test-runner/bin.d.ts +2 -0
- package/dist/{cli-CaJ-vnBj.js → test-runner/bin.js} +49 -9
- package/dist/test-runner/bin.js.map +1 -0
- package/dist/test-runner/config.js +1 -1
- package/dist/test-runner/relay-factory.js +21 -4
- package/dist/test-runner/relay-factory.js.map +1 -1
- package/dist/totp-D1pulXLa.js +3 -0
- package/dist/{totp-0bvlo9Yb.js → totp-WY6l0ysP.js} +1 -1
- package/dist/{totp-0bvlo9Yb.js.map → totp-WY6l0ysP.js.map} +1 -1
- package/dist/{tunnel-B7U5k1xa.js → tunnel-BFwOSwT4.js} +2 -2
- package/dist/{tunnel-B7U5k1xa.js.map → tunnel-BFwOSwT4.js.map} +1 -1
- package/dist/{tunnel-C-cgMnyY.cjs → tunnel-DyEV6WwL.cjs} +2 -2
- package/dist/{tunnel-C-cgMnyY.cjs.map → tunnel-DyEV6WwL.cjs.map} +1 -1
- package/dist/unplugin/index.cjs +1 -1
- package/dist/unplugin/index.js +1 -1
- package/dist/unplugin/tunnel.cjs +1 -1
- package/dist/unplugin/tunnel.js +1 -1
- package/package.json +2 -2
- package/dist/attach-orchestrator-BYMCSPjf.js +0 -2797
- package/dist/attach-orchestrator-BYMCSPjf.js.map +0 -1
- package/dist/attach-orchestrator-LOjvsE9R.js +0 -3
- package/dist/cell-Bfbke0cT.js +0 -3
- package/dist/cell-SKNUQTr8.js +0 -85
- package/dist/cell-SKNUQTr8.js.map +0 -1
- package/dist/cli-CaJ-vnBj.js.map +0 -1
- package/dist/debug-server-CF-1s3ks.js.map +0 -1
- package/dist/debug-server-D79E3hTa.js.map +0 -1
- package/dist/debug-server-IVaJ9vHH.js +0 -4278
- package/dist/debug-server-IVaJ9vHH.js.map +0 -1
- package/dist/qr-http-server-BpcDt-cp.js +0 -3
- package/dist/qr-http-server-UBV2qUEd.js.map +0 -1
- package/dist/qr-http-server-gHBd-z7T.js +0 -1506
- package/dist/relay-factory-BBnZM-an.js.map +0 -1
- package/dist/relay-secret-store-BIM_0y18.js +0 -3
- package/dist/test-runner/cli.d.ts +0 -547
- package/dist/test-runner/cli.d.ts.map +0 -1
- package/dist/test-runner/cli.js +0 -3
- package/dist/totp-zGIlDsZS.js +0 -3
|
@@ -22,12 +22,23 @@ function createRelayConnectionFactory(opts) {
|
|
|
22
22
|
return {
|
|
23
23
|
async open() {
|
|
24
24
|
const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import("./attach-orchestrator-CPMQWkGV.js");
|
|
25
|
-
const { injectDebugIndicator, injectGlobals } = await import("./cell-
|
|
25
|
+
const { injectDebugIndicator, injectGlobals } = await import("./cell-D0dJSGHi.js");
|
|
26
26
|
const { loadRelaySecretReadOnly } = await import("./relay-secret-store-DKxs7zwq.js");
|
|
27
|
-
const { bootRelayFamily, buildRelayVerifyAuth } = await import("./debug-server-
|
|
27
|
+
const { bootRelayFamily, buildRelayVerifyAuth } = await import("./debug-server-B3W_0Ejf.js");
|
|
28
28
|
await loadRelaySecretReadOnly({ projectRoot });
|
|
29
|
-
|
|
29
|
+
let resolveTunnelUp;
|
|
30
|
+
const tunnelReady = new Promise((resolve) => {
|
|
31
|
+
resolveTunnelUp = resolve;
|
|
32
|
+
});
|
|
33
|
+
const booted = await bootRelayFamily({
|
|
34
|
+
verifyAuth: buildRelayVerifyAuth(),
|
|
35
|
+
onWssUrl: () => {
|
|
36
|
+
resolveTunnelUp();
|
|
37
|
+
qrServer?.notifyStateChange();
|
|
38
|
+
}
|
|
39
|
+
});
|
|
30
40
|
family = booted;
|
|
41
|
+
if (booted.getTunnelStatus?.().up) resolveTunnelUp();
|
|
31
42
|
let lastAttachParts;
|
|
32
43
|
const attachDeps = {
|
|
33
44
|
getTunnelStatus: booted.getTunnelStatus ?? (() => ({
|
|
@@ -40,7 +51,7 @@ function createRelayConnectionFactory(opts) {
|
|
|
40
51
|
canOpenBrowser: () => !headless
|
|
41
52
|
};
|
|
42
53
|
try {
|
|
43
|
-
const { startQrHttpServer } = await import("./qr-http-server-
|
|
54
|
+
const { startQrHttpServer } = await import("./qr-http-server-Bo8LMOJQ.js");
|
|
44
55
|
const getDashboardState = () => ({
|
|
45
56
|
tunnel: attachDeps.getTunnelStatus(),
|
|
46
57
|
pages: null,
|
|
@@ -55,16 +66,22 @@ function createRelayConnectionFactory(opts) {
|
|
|
55
66
|
};
|
|
56
67
|
process.stderr.write(`devtools-test: QR dashboard: http://127.0.0.1:${qrServer.port}/\n`);
|
|
57
68
|
} catch {}
|
|
69
|
+
const TUNNEL_BOOT_TIMEOUT_MS = 15e3;
|
|
70
|
+
await Promise.race([tunnelReady, new Promise((resolve) => setTimeout(resolve, TUNNEL_BOOT_TIMEOUT_MS))]);
|
|
58
71
|
const prep = await prepareAttach(attachDeps, "relay-dev", { scheme_url: opts.schemeUrl }, booted.connection);
|
|
59
72
|
if (!prep.ok) {
|
|
60
73
|
booted.stop();
|
|
61
74
|
family = void 0;
|
|
75
|
+
await qrServer?.close();
|
|
76
|
+
qrServer = void 0;
|
|
62
77
|
throw new Error("createRelayConnectionFactory: attach preparation failed — check the scheme_url and that the relay tunnel is up");
|
|
63
78
|
}
|
|
64
79
|
const waitResult = await renderAndMaybeWait(attachDeps, prep, true, timeoutMs, booted.connection);
|
|
65
80
|
if (waitResult.isError) {
|
|
66
81
|
booted.stop();
|
|
67
82
|
family = void 0;
|
|
83
|
+
await qrServer?.close();
|
|
84
|
+
qrServer = void 0;
|
|
68
85
|
const timeoutSec = Math.round(timeoutMs / 1e3);
|
|
69
86
|
throw new Error(`createRelayConnectionFactory: attach timed out after ${timeoutSec}s — phone did not scan the QR within the timeout`);
|
|
70
87
|
}
|
|
@@ -86,4 +103,4 @@ function createRelayConnectionFactory(opts) {
|
|
|
86
103
|
//#endregion
|
|
87
104
|
export { createRelayConnectionFactory as t };
|
|
88
105
|
|
|
89
|
-
//# sourceMappingURL=relay-factory-
|
|
106
|
+
//# sourceMappingURL=relay-factory-CqY264j0.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relay-factory-CqY264j0.js","names":[],"sources":["../src/test-runner/relay-factory.ts"],"sourcesContent":["/**\n * Relay connection factory for the Vitest custom pool (devtools#696).\n *\n * The Vitest pool (`pool.ts`) takes a {@link RelayConnectionFactory} that knows\n * how to `open()` a live CDP relay connection (boot relay → QR → phone scan →\n * cell inject → enableDomains) and `close()` it. Before this module that exact\n * assembly lived only inside the `devtools-test` CLI's `main()`; the standalone\n * CLI and the Vitest pool would otherwise each hand-roll it and drift.\n *\n * This is the single source of that assembly. `cli.ts` is refactored to call\n * `createRelayConnectionFactory(...).open()`, and `definePhoneVitestConfig`\n * (config.ts) exposes it so a downstream `vitest.config.ts` can wire the pool:\n *\n * import { createRelayConnectionFactory } from '@ait-co/devtools/test-runner';\n * const connection = createRelayConnectionFactory({\n * schemeUrl,\n * cell,\n * // REQUIRED — own the stdout decision (the chunks carry the relay wss +\n * // TOTP code). Suppress on non-interactive stdout; print otherwise.\n * onQrContent: (chunks) => {\n * if (!process.stdout.isTTY) return;\n * for (const c of chunks) process.stdout.write(`${c}\\n`);\n * },\n * });\n * export default defineConfig({ test: definePhoneVitestConfig({ connection }) });\n *\n * The heavy boot graph (chii relay, cloudflared, ws, debug-server) is pulled via\n * DYNAMIC import inside `open()` — so merely importing this module (or the\n * `@ait-co/devtools/test-runner` barrel) does NOT statically drag that graph in.\n *\n * SECRET-HANDLING: relay wss URLs, scheme URLs, and the TOTP secret/code are\n * never logged. `open()` reads the project-local `.ait_relay` secret read-only\n * (never mints) and the minted TOTP code rides only inside the QR `at=` param.\n *\n * Node-only. react-free (CdpConnection + lazily-imported MCP boot helpers only).\n */\n\nimport type { AttachDeps, AttachUrlParts } from '../mcp/attach-orchestrator.js';\nimport type { CdpConnection } from '../mcp/cdp-connection.js';\nimport type { QrHttpServer } from '../mcp/qr-http-server.js';\nimport type { RelayConnectionFactory } from './pool.js';\n\n// NOTE: every value import below is a DYNAMIC import inside `open()`. This module\n// keeps ONLY type-level static imports so that re-exporting it from the\n// `@ait-co/devtools/test-runner` barrel (config.ts) does NOT statically drag the\n// heavy MCP graph (cell.ts → attach-orchestrator.ts → tools.ts → server-lock,\n// plus chii/cloudflared via debug-server) onto that Node-config entry.\n\n/** Options for {@link createRelayConnectionFactory}. */\nexport interface RelayConnectionFactoryOptions {\n /**\n * intoss-private:// scheme URL from `ait deploy --scheme-only` (env3). The\n * phone cold-loads the candidate bundle this URL points at. SECRET-HANDLING:\n * never logged.\n */\n schemeUrl: string;\n /**\n * Project root for the `.ait_relay` secret lookup (read-only). Defaults to\n * `process.cwd()`.\n */\n projectRoot?: string;\n /**\n * Attach wait timeout in ms — how long `open()` waits for the phone to scan\n * the QR and attach. Defaults to 600 000 (10 min) to give time for a manual\n * scan. (The per-file evaluate timeout is separate, passed via the pool's\n * `run` options.)\n */\n timeoutMs?: number;\n /** Disable browser auto-open of the QR dashboard (text QR only). */\n headless?: boolean;\n /**\n * Cell axes injected as `__AIT_CELL__` before the first test bundle runs, so\n * sdk-example's capture picks up the correct sdkLine/platform. Optional — when\n * omitted no cell is injected. The values are not secrets.\n */\n cell?: { sdkLine: string; platform: string };\n /**\n * Receives the QR/attach render content (text chunks) from\n * `renderAndMaybeWait`, so the caller decides whether to print them — e.g.\n * suppress on non-interactive stdout.\n *\n * REQUIRED (not optional) on purpose: these chunks contain the QR payload\n * (which encodes the relay wss + TOTP `at=` code) and the attach JSON block.\n * Making the hook mandatory means the factory never falls back to printing\n * them itself — a downstream `vitest.config.ts` consumer that wires this via\n * the `@ait-co/devtools/test-runner` barrel is forced to make an explicit\n * stdout decision rather than silently leaking the secret-bearing block.\n *\n * SECRET-HANDLING: when a non-interactive caller is detected the hook MUST\n * suppress the WHOLE chunk (not just `attachUrl`), since `relayUrl` rides in\n * the same block.\n */\n onQrContent: (textChunks: string[]) => void;\n}\n\n/**\n * Builds a {@link RelayConnectionFactory} that opens a standalone env3 relay\n * connection.\n *\n * `open()` performs the full attach lifecycle and BLOCKS for tens of seconds (up\n * to `timeoutMs`) while a human scans the rendered QR with their phone — there\n * is no way around the manual scan for env3. It resolves with the live\n * `CdpConnection` once a matching page attaches; `close()` tears the relay\n * family down.\n *\n * The factory holds the booted relay family in a closure so `close()` can stop\n * it. A second `open()` on the same factory boots a fresh family (the previous\n * one should have been `close()`d first).\n */\nexport function createRelayConnectionFactory(\n opts: RelayConnectionFactoryOptions,\n): RelayConnectionFactory {\n const projectRoot = opts.projectRoot ?? process.cwd();\n const timeoutMs = opts.timeoutMs ?? 600_000;\n const headless = opts.headless === true;\n\n // Captured so close() can stop the family that open() booted.\n let family: { connection: CdpConnection; stop(): void } | undefined;\n // QR HTTP server — started during open() when not headless, closed in close().\n let qrServer: QrHttpServer | undefined;\n\n return {\n async open(): Promise<CdpConnection> {\n // Dynamic imports: keep the chii/cloudflared/debug-server graph OFF the\n // static import graph of this module (and the test-runner config barrel).\n const { prepareAttach, renderAndMaybeWait, mintAttachUrl } = await import(\n '../mcp/attach-orchestrator.js'\n );\n const { injectDebugIndicator, injectGlobals } = await import('./cell.js');\n const { loadRelaySecretReadOnly } = await import('../mcp/relay-secret-store.js');\n const { bootRelayFamily, buildRelayVerifyAuth } = await import('../mcp/debug-server.js');\n\n // Load the project-local .ait_relay secret into AIT_DEBUG_TOTP_SECRET\n // BEFORE booting the relay so assertRelayAuthConfigured()/buildRelayVerifyAuth()\n // at the boot site see it. Read-only — never mints. SECRET-HANDLING: the\n // value is never logged here.\n await loadRelaySecretReadOnly({ projectRoot });\n\n // PRIMARY FIX (devtools#714): bootRelayFamily starts the cloudflared tunnel\n // as a background promise and returns immediately with tunnel.up === false.\n // We must NOT call prepareAttach until the tunnel is up — it fails fast when\n // tunnel.up is false (attach-orchestrator.ts tunnel-down guard).\n //\n // Wire onWssUrl (mirroring the MCP daemon path in debug-server.ts) to:\n // 1. resolve a caller-held tunnel-ready promise so open() can await it, and\n // 2. re-push dashboard SSE on late tunnel-up events (notifyStateChange).\n //\n // SECRET-HANDLING: onWssUrl receives the relay wss URL — never log it.\n let resolveTunnelUp!: () => void;\n const tunnelReady = new Promise<void>((resolve) => {\n resolveTunnelUp = resolve;\n });\n\n const booted = await bootRelayFamily({\n verifyAuth: buildRelayVerifyAuth(),\n onWssUrl: () => {\n // Resolve the tunnel-ready gate so the prepareAttach call below is\n // unblocked. qrServer may not be set yet at call time (it's started\n // after bootRelayFamily), so we use optional chaining — if the tunnel\n // happens to come up after qrServer is started, notifyStateChange pushes\n // the freshly minted attachUrl to any waiting dashboard SSE clients.\n // SECRET-HANDLING: wssUrl is NOT forwarded here — the value travels only\n // inside the closure via getTunnelStatus().wssUrl (used by mintAttachUrl).\n resolveTunnelUp();\n qrServer?.notifyStateChange();\n },\n });\n family = booted;\n\n // If the tunnel is already up (extremely fast boot or test double), resolve\n // immediately so we don't stall on a promise that will never fire.\n if (booted.getTunnelStatus?.().up) {\n resolveTunnelUp();\n }\n\n // Track the last-captured attach parts so getDashboardState can mint a\n // fresh attach URL on every dashboard request/SSE push (fresh TOTP at=).\n // SECRET-HANDLING: parts contain the relay wss + scheme URL — never logged.\n let lastAttachParts: AttachUrlParts | undefined;\n\n // Assemble AttachDeps. We set qrHttpServer and onAttachUrlBuilt below\n // (before prepareAttach) after optionally starting the web-QR server.\n const attachDeps: AttachDeps = {\n getTunnelStatus: booted.getTunnelStatus ?? (() => ({ up: false, wssUrl: null })),\n getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,\n qrHttpServer: undefined, // filled in below if web-QR server started\n onAttachUrlBuilt: undefined, // filled in below\n canOpenBrowser: () => !headless,\n };\n\n // Web-QR server: reuse the same loopback HTTP dashboard that the MCP\n // start_attach path uses (src/mcp/qr-http-server.ts). This makes the QR\n // scannable even when stdout is non-interactive (Claude Code `!` / CI),\n // because the browser is opened by URL — not via captured stdout.\n //\n // Headless decision: we start the server even in --headless mode so the\n // printed stderr URL can be opened manually. The existing\n // canOpenBrowser: () => !headless gate inside renderAndMaybeWait prevents\n // the auto-open; headless users see only the stderr URL.\n //\n // On failure we fall back gracefully to the text-QR path — do NOT crash.\n // SECRET-HANDLING: only http://127.0.0.1:<port>/ (no secrets) goes to stderr.\n try {\n const { startQrHttpServer } = await import('../mcp/qr-http-server.js');\n\n const getDashboardState = () => ({\n tunnel: attachDeps.getTunnelStatus(),\n pages: null, // CLI/pool: no page-list introspection needed\n attachUrl: lastAttachParts ? mintAttachUrl(attachDeps, lastAttachParts) : null,\n mode: 'relay-dev' as const,\n });\n\n qrServer = await startQrHttpServer(getDashboardState);\n\n // Wire the QR server into attachDeps BEFORE prepareAttach is called so\n // renderAndMaybeWait sees it and takes Path 2/3 (web-QR) instead of Path 4.\n attachDeps.qrHttpServer = qrServer;\n\n // Capture attach parts via onAttachUrlBuilt so getDashboardState can\n // mint a fresh URL (fresh TOTP at= code) on every dashboard render.\n attachDeps.onAttachUrlBuilt = (parts: AttachUrlParts) => {\n lastAttachParts = parts;\n qrServer?.notifyStateChange();\n };\n\n // Print the loopback dashboard URL to stderr — it carries no secrets\n // (TOTP codes and relay wss live only in the in-memory HTTP response).\n process.stderr.write(`devtools-test: QR dashboard: http://127.0.0.1:${qrServer.port}/\\n`);\n } catch {\n // startQrHttpServer failed (e.g. port conflict, import error). Fall back\n // to the existing text-QR path by leaving qrHttpServer: undefined.\n // qrServer remains undefined; close() handles that with optional chaining.\n }\n\n // PRIMARY FIX (devtools#714): await tunnel readiness before calling\n // prepareAttach. Race: a 15 s timeout is generous — cloudflared typically\n // comes up in < 5 s. On timeout we still call prepareAttach; it will hit\n // the tunnel-down guard and throw a secret-free error (same as before,\n // but now with a clear diagnostic instead of a silent WAITING freeze).\n //\n // Implementation: Promise.race against a 15 000 ms timeout signal. We do\n // NOT use a timer-based early-exit because the existing code already\n // surface-fails on tunnel-down inside prepareAttach with a secret-free\n // message. The timeout here is purely a \"give the tunnel a fair chance\"\n // gate — not a correctness boundary.\n const TUNNEL_BOOT_TIMEOUT_MS = 15_000;\n await Promise.race([\n tunnelReady,\n new Promise<void>((resolve) => setTimeout(resolve, TUNNEL_BOOT_TIMEOUT_MS)),\n ]);\n\n const prep = await prepareAttach(\n attachDeps,\n 'relay-dev',\n { scheme_url: opts.schemeUrl },\n booted.connection,\n );\n if (!prep.ok) {\n booted.stop();\n family = undefined;\n // SECONDARY FIX (devtools#714): close the QR server on the failure path\n // so the loopback port listener does not leak. The normal-exit path is\n // handled by close(); this mirrors that cleanup for the error path.\n await qrServer?.close();\n qrServer = undefined;\n // SECRET-HANDLING: do NOT surface `prep.error.content` in the thrown\n // message. Some prep error paths build their text from attach\n // components, and the CLI catch writes `e.message` to stderr — embedding\n // that text risks leaking the scheme/relay wss URL. The detailed\n // diagnostic is the daemon/dashboard's job; the factory throws a\n // secret-free message only.\n throw new Error(\n 'createRelayConnectionFactory: attach preparation failed — check the scheme_url and that the relay tunnel is up',\n );\n }\n\n // Render the QR + wait for the phone to attach. SECRET-HANDLING: this\n // function never logs scheme/wss/TOTP values.\n const waitResult = await renderAndMaybeWait(\n attachDeps,\n prep,\n true,\n timeoutMs,\n booted.connection,\n );\n if (waitResult.isError) {\n booted.stop();\n family = undefined;\n // SECONDARY FIX (devtools#714): close the QR server on the timeout path\n // (mirrors the prep.ok failure path above).\n await qrServer?.close();\n qrServer = undefined;\n // SECRET-HANDLING (BLOCKER fix): `waitResult.content` is the timeout\n // result built from `buildTimeoutError(baseText, ...)`, and `baseText`\n // is `JSON.stringify({ attachUrl, relayUrl, ... })` — `attachUrl` carries\n // the TOTP `at=` code and `relayUrl` is the relay `wss://` URL. The CLI\n // catch writes `e.message` to stderr, so extracting that text here would\n // leak the relay wss + TOTP code on every timeout. Throw a secret-free\n // message with only the timeout duration.\n const timeoutSec = Math.round(timeoutMs / 1000);\n throw new Error(\n `createRelayConnectionFactory: attach timed out after ${timeoutSec}s — phone did not scan the QR within the timeout`,\n );\n }\n\n // Surface the QR/attach render content to the caller, which owns the\n // stdout decision (suppress on non-interactive stdout). There is no\n // fallback that prints here itself: `onQrContent` is a required option so\n // the secret-bearing block (attachUrl TOTP + relayUrl wss) can never be\n // emitted without an explicit caller decision. SECRET-HANDLING.\n const qrChunks = waitResult.content\n .filter((c): c is { type: 'text'; text: string } => c.type === 'text')\n .map((c) => c.text);\n opts.onQrContent(qrChunks);\n\n // Debugger attached — show the on-phone \"Debugger Connected\" badge.\n await injectDebugIndicator(booted.connection);\n\n // Inject the cell globals before any test bundle runs (session-global).\n if (opts.cell !== undefined) {\n await injectGlobals(booted.connection, { __AIT_CELL__: opts.cell });\n }\n\n // Open the CDP client websocket + enable domains so the first run's\n // Runtime.evaluate and console stream are live. enableDomains is\n // idempotent; runTestFilesOverRelay also calls it defensively.\n await booted.connection.enableDomains();\n\n return booted.connection;\n },\n\n async close(_connection: CdpConnection): Promise<void> {\n // family.stop() is synchronous best-effort: closes the CDP connection and\n // shuts down the relay + cloudflared child.\n family?.stop();\n family = undefined;\n // Close the web-QR HTTP server if one was started during open().\n // close() is idempotent via optional chaining + reassignment to undefined.\n await qrServer?.close();\n qrServer = undefined;\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;AA6GA,SAAgB,6BACd,MACwB;CACxB,MAAM,cAAc,KAAK,eAAe,QAAQ,KAAK;CACrD,MAAM,YAAY,KAAK,aAAa;CACpC,MAAM,WAAW,KAAK,aAAa;CAGnC,IAAI;CAEJ,IAAI;AAEJ,QAAO;EACL,MAAM,OAA+B;GAGnC,MAAM,EAAE,eAAe,oBAAoB,kBAAkB,MAAM,OACjE;GAEF,MAAM,EAAE,sBAAsB,kBAAkB,MAAM,OAAO;GAC7D,MAAM,EAAE,4BAA4B,MAAM,OAAO;GACjD,MAAM,EAAE,iBAAiB,yBAAyB,MAAM,OAAO;AAM/D,SAAM,wBAAwB,EAAE,aAAa,CAAC;GAY9C,IAAI;GACJ,MAAM,cAAc,IAAI,SAAe,YAAY;AACjD,sBAAkB;KAClB;GAEF,MAAM,SAAS,MAAM,gBAAgB;IACnC,YAAY,sBAAsB;IAClC,gBAAgB;AAQd,sBAAiB;AACjB,eAAU,mBAAmB;;IAEhC,CAAC;AACF,YAAS;AAIT,OAAI,OAAO,mBAAmB,CAAC,GAC7B,kBAAiB;GAMnB,IAAI;GAIJ,MAAM,aAAyB;IAC7B,iBAAiB,OAAO,2BAA2B;KAAE,IAAI;KAAO,QAAQ;KAAM;IAC9E,qBAAqB,QAAQ,IAAI;IACjC,cAAc,KAAA;IACd,kBAAkB,KAAA;IAClB,sBAAsB,CAAC;IACxB;AAcD,OAAI;IACF,MAAM,EAAE,sBAAsB,MAAM,OAAO;IAE3C,MAAM,2BAA2B;KAC/B,QAAQ,WAAW,iBAAiB;KACpC,OAAO;KACP,WAAW,kBAAkB,cAAc,YAAY,gBAAgB,GAAG;KAC1E,MAAM;KACP;AAED,eAAW,MAAM,kBAAkB,kBAAkB;AAIrD,eAAW,eAAe;AAI1B,eAAW,oBAAoB,UAA0B;AACvD,uBAAkB;AAClB,eAAU,mBAAmB;;AAK/B,YAAQ,OAAO,MAAM,iDAAiD,SAAS,KAAK,KAAK;WACnF;GAiBR,MAAM,yBAAyB;AAC/B,SAAM,QAAQ,KAAK,CACjB,aACA,IAAI,SAAe,YAAY,WAAW,SAAS,uBAAuB,CAAC,CAC5E,CAAC;GAEF,MAAM,OAAO,MAAM,cACjB,YACA,aACA,EAAE,YAAY,KAAK,WAAW,EAC9B,OAAO,WACR;AACD,OAAI,CAAC,KAAK,IAAI;AACZ,WAAO,MAAM;AACb,aAAS,KAAA;AAIT,UAAM,UAAU,OAAO;AACvB,eAAW,KAAA;AAOX,UAAM,IAAI,MACR,iHACD;;GAKH,MAAM,aAAa,MAAM,mBACvB,YACA,MACA,MACA,WACA,OAAO,WACR;AACD,OAAI,WAAW,SAAS;AACtB,WAAO,MAAM;AACb,aAAS,KAAA;AAGT,UAAM,UAAU,OAAO;AACvB,eAAW,KAAA;IAQX,MAAM,aAAa,KAAK,MAAM,YAAY,IAAK;AAC/C,UAAM,IAAI,MACR,wDAAwD,WAAW,kDACpE;;GAQH,MAAM,WAAW,WAAW,QACzB,QAAQ,MAA2C,EAAE,SAAS,OAAO,CACrE,KAAK,MAAM,EAAE,KAAK;AACrB,QAAK,YAAY,SAAS;AAG1B,SAAM,qBAAqB,OAAO,WAAW;AAG7C,OAAI,KAAK,SAAS,KAAA,EAChB,OAAM,cAAc,OAAO,YAAY,EAAE,cAAc,KAAK,MAAM,CAAC;AAMrE,SAAM,OAAO,WAAW,eAAe;AAEvC,UAAO,OAAO;;EAGhB,MAAM,MAAM,aAA2C;AAGrD,WAAQ,MAAM;AACd,YAAS,KAAA;AAGT,SAAM,UAAU,OAAO;AACvB,cAAW,KAAA;;EAEd"}
|
|
@@ -151,4 +151,4 @@ function warnIfEnvDiffersFromFile(envSecret, secretPath, fsDep, isValidRelayAuth
|
|
|
151
151
|
//#endregion
|
|
152
152
|
export { loadRelaySecretReadOnly, nearestPackageJsonDir };
|
|
153
153
|
|
|
154
|
-
//# sourceMappingURL=relay-secret-store-
|
|
154
|
+
//# sourceMappingURL=relay-secret-store-CFc9n0OA.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"relay-secret-store-BJjoxes1.js","names":[],"sources":["../src/mcp/relay-secret-store.ts"],"sourcesContent":["/**\n * Project-local relay TOTP secret store (#394 first-run auto-mint, #396 moved to\n * a project-local single file `.ait_relay`).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link ensureRelaySecret} — WRITE path, called ONLY from the unplugin\n * (env-2 relay boot). Mints a fresh secret on first run and persists it to\n * `<projectRoot>/.ait_relay` (0600). A single file — no directory is created.\n *\n * - {@link loadRelaySecretReadOnly} — READ-ONLY path, called from the MCP\n * daemon when switching into a relay environment. It NEVER mints, chmods, or\n * creates anything: it only reads an already-existing `.ait_relay` and injects\n * its value into `env`. A daemon that minted would defeat the #250 fail-fast\n * (the daemon is the verifier side — a self-minted secret would let a leaked\n * tunnel URL attach unauthenticated), so the daemon stays read-only.\n *\n * Why a per-session `projectRoot` instead of `process.cwd()`: the daemon cannot\n * trust its own cwd — agent-plugin spawns it via `npx` without `cwd`, so cwd is\n * frozen at Claude Code launch and a cwd-walk stops at the monorepo workspace\n * root (which always has a package.json). So the project root is supplied\n * per-debug-session through `start_debug`.\n *\n * SECRET-HANDLING: this module handles AIT_DEBUG_TOTP_SECRET — the raw value and\n * its length MUST NOT appear in any log, error message, stdout, stderr, or\n * assertion output. Only boolean pass/fail signals are safe to surface, and the\n * discovered file path is never logged either. The persist file is written mode\n * 0600.\n */\n\nimport { dirname, join } from 'node:path';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local secret file name (single file, not a directory). */\nexport const RELAY_SECRET_FILE_NAME = '.ait_relay';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surface\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link ensureRelaySecret} — injectable for tests. */\nexport interface RelaySecretFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n readFileSync(path: string, encoding: BufferEncoding): string;\n chmodSync(path: string, mode: number): void;\n existsSync(path: string): boolean;\n appendFileSync(path: string, data: string): void;\n}\n\n/**\n * Minimal fs subset needed by {@link loadRelaySecretReadOnly} — strictly the two\n * read-only operations. Deliberately omits writeFileSync/mkdirSync/chmodSync so\n * the daemon path cannot mutate the filesystem even by accident (the type\n * forbids it).\n */\nexport interface RelaySecretReadOnlyFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\nexport interface RelaySecretDeps {\n /**\n * Project root (typically Vite `server.config.root`). The `.ait_relay` file is\n * resolved against the nearest `package.json` directory at or above this path.\n * When omitted, the current working directory is used as the start point —\n * retained for back-compat/tests; the unplugin always passes it.\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Cryptographically secure random bytes. Defaults to node:crypto randomBytes. */\n randomBytes?: (n: number) => Buffer;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelaySecretFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Current working directory resolver (used only when `projectRoot` is omitted). */\n cwd?: () => string;\n /** Log function for first-mint announcement. Defaults to process.stderr.write. */\n log?: (msg: string) => void;\n}\n\nexport interface RelaySecretReadOnlyDeps {\n /**\n * Project root supplied per-debug-session via `start_debug`. The daemon reads\n * `<nearest package.json dir from projectRoot>/.ait_relay`. When omitted, the\n * loader is a no-op (the daemon has no anchor to read from).\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelaySecretReadOnlyFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Optional log sink — never receives the secret value, length, or file path. */\n log?: (msg: string) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Walks upward from `start` and returns the nearest directory that contains a\n * `package.json`. Falls back to `start` itself when none is found (so a write\n * still lands somewhere deterministic).\n *\n * The write (unplugin) and read (daemon) sides use the SAME anchor so a secret\n * minted by `pnpm dev` is found by the daemon: real mini-apps keep\n * `vite.config.ts` and `package.json` in the same directory, so\n * `server.config.root === package.json-dir`. In a monorepo subdir the anchor is\n * the package's own directory — the one the daemon can also reach via the\n * per-session projectRoot.\n *\n * @param start - Directory to start the upward walk from.\n * @param existsSyncFn - Injectable existence check (defaults to node:fs).\n */\nexport function nearestPackageJsonDir(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n let dir = start;\n // Stop at the filesystem root (dirname of root === root).\n while (true) {\n if (existsSyncFn(join(dir, 'package.json'))) {\n return dir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n // Reached the filesystem root without finding a package.json — fall back\n // to the original start directory.\n return start;\n }\n dir = parent;\n }\n}\n\n/**\n * Absolute path to the project-local `.ait_relay` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function relaySecretFilePath(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), RELAY_SECRET_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — mint + persist\n// ---------------------------------------------------------------------------\n\n/**\n * Ensures `env.AIT_DEBUG_TOTP_SECRET` is set to a valid relay TOTP secret,\n * persisting a freshly-minted one to `<projectRoot>/.ait_relay` (0600) on first\n * run and loading it silently on subsequent runs.\n *\n * Writes a SINGLE file into the already-existing project directory — it never\n * creates a directory (so no `mkdirSync`/dir `chmod`). The file is created with\n * `O_EXCL` (`flag: 'wx'`) so a concurrent process cannot be clobbered; on the\n * EEXIST race the winner's value is read instead.\n *\n * Called ONLY from the unplugin (env-2 relay boot). The MCP daemon uses\n * {@link loadRelaySecretReadOnly} (read-only) — it must never mint.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function ensureRelaySecret(deps?: RelaySecretDeps): Promise<void> {\n const {\n projectRoot,\n env = process.env,\n randomBytes: randomBytesFn,\n fs: fsDep,\n existsSync: existsSyncDep,\n cwd: cwdFn,\n log,\n } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n // Lazily import isValidRelayAuthSecret to avoid pulling in node:crypto at\n // module-load time (keeps the import side-effect free).\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or earlier run wins).\n // But first check for a divergence between the env value and .ait_relay —\n // if they differ the relay will verify against the env value while QR/\n // deep-links carry codes derived from the file, causing silent 401s (#620).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n // We need fs to compare — resolve deps early just for the divergence check.\n // This mirrors the lazy-resolve block below but is hoisted here so we can\n // still early-return after the (possibly-emitted) warning.\n const fsEarly: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncEarly: (path: string) => boolean = existsSyncDep ?? fsEarly.existsSync;\n const startEarly = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPathEarly = relaySecretFilePath(startEarly, existsSyncEarly);\n warnIfEnvDiffersFromFile(\n env.AIT_DEBUG_TOTP_SECRET,\n secretPathEarly,\n fsEarly,\n isValidRelayAuthSecret,\n logFn,\n );\n return;\n }\n\n // Resolve injected or real dependencies lazily to keep the import graph clean.\n const rb: (n: number) => Buffer = randomBytesFn ?? (await import('node:crypto')).randomBytes;\n const fs: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const start = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPath = relaySecretFilePath(start, existsSyncFn);\n\n // 2. Persist file exists — read and inject (silent reload).\n if (fs.existsSync(secretPath)) {\n return readAndInject(secretPath, fs, env, logFn, isValidRelayAuthSecret, rb);\n }\n\n // 3. Mint a fresh secret.\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret);\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never mints, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\n/**\n * Reads an already-existing `<projectRoot>/.ait_relay` and, if its contents are a\n * valid relay TOTP secret, injects them into `env.AIT_DEBUG_TOTP_SECRET`.\n *\n * Strictly READ-ONLY: it uses only `existsSync` + `readFileSync` and NEVER mints,\n * chmods, or creates files/directories. The daemon must not mint because it is\n * the relay verifier side — a self-minted secret would defeat the #250 fail-fast\n * (a leaked tunnel URL could then attach unauthenticated). If no valid secret is\n * found the function leaves `env` untouched and returns without throwing, so the\n * downstream `assertRelayAuthConfigured()` stays the single fail-fast.\n *\n * Resolution order:\n * 1. `env.AIT_DEBUG_TOTP_SECRET` already valid → no-op (operator export wins).\n * 2. `projectRoot` given → read `<nearest package.json dir>/.ait_relay`; inject\n * iff the contents pass {@link isValidRelayAuthSecret}.\n * 3. Otherwise (no projectRoot, file absent, or invalid) → silent no-op.\n *\n * SECRET-HANDLING: the read value is passed ONLY to the boolean predicate before\n * assignment; its value, length, and the discovered file path are never logged.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function loadRelaySecretReadOnly(deps?: RelaySecretReadOnlyDeps): Promise<void> {\n const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep, log } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or unplugin run wins).\n // But first check for a divergence between the env value and .ait_relay —\n // if they differ the relay will verify against the env value while QR/\n // deep-links carry codes derived from the file, causing silent 401s (#620).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n if (projectRoot !== undefined) {\n const fsEarly: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncEarly: (path: string) => boolean = existsSyncDep ?? fsEarly.existsSync;\n const secretPathEarly = relaySecretFilePath(projectRoot, existsSyncEarly);\n warnIfEnvDiffersFromFile(\n env.AIT_DEBUG_TOTP_SECRET,\n secretPathEarly,\n fsEarly,\n isValidRelayAuthSecret,\n logFn,\n );\n }\n return;\n }\n\n // 2. No anchor → nothing to read.\n if (projectRoot === undefined) {\n return;\n }\n\n const fs: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const secretPath = relaySecretFilePath(projectRoot, existsSyncFn);\n if (!fs.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable file (permissions, transient FS error) — stay silent and let\n // the downstream assert be the single fail-fast. SECRET-HANDLING: the error\n // and path are not surfaced.\n return;\n }\n\n // SECRET-HANDLING: the value flows only through the boolean predicate.\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers (not exported — single-use extracted for readability)\n// ---------------------------------------------------------------------------\n\n/**\n * Compares `envSecret` against the contents of `secretPath` (if the file\n * exists and contains a valid secret) and emits a single warning via `logFn`\n * when they differ.\n *\n * SECRET-HANDLING (hard rules — do NOT relax):\n * - The warning MUST NOT include either secret value, its length, a hash of\n * it, or the resolved file path.\n * - Only an inequality boolean drives the warning; no secret-derived data\n * enters the log message.\n * - If the file is absent, unreadable, or its contents are invalid the\n * function returns silently — no spurious noise.\n *\n * This helper is intentionally synchronous-like (reads via the injected fs)\n * so it can be called from within the async early-return guards without\n * introducing additional async hops.\n *\n * @param envSecret - The validated env value (caller must have confirmed it is\n * valid before calling).\n * @param secretPath - Absolute path to `.ait_relay` to read for comparison.\n * @param fsDep - Injectable fs subset (at minimum `existsSync` + `readFileSync`).\n * @param isValidRelayAuthSecret - Injectable predicate from totp.ts.\n * @param logFn - Injectable log sink; never receives a secret value.\n */\nfunction warnIfEnvDiffersFromFile(\n envSecret: string,\n secretPath: string,\n fsDep: RelaySecretReadOnlyFs,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n logFn: (msg: string) => void,\n): void {\n // File absent → nothing to compare.\n if (!fsDep.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fsDep.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable — skip silently. SECRET-HANDLING: error and path not surfaced.\n return;\n }\n\n // Invalid stored contents → skip silently (no spurious noise).\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n // Compare by equality only. Neither value nor path enters the log message.\n if (envSecret !== stored) {\n logFn(\n `[@ait-co/devtools] AIT_DEBUG_TOTP_SECRET (from environment) differs from the project-local relay secret; ` +\n `the relay will verify against the environment value. ` +\n `Remove .env/.env.local/exported AIT_DEBUG_TOTP_SECRET, or sync the file, ` +\n `so QR/deep-links and the relay agree.\\n`,\n );\n }\n}\n\n/**\n * Result of a {@link ensureGitignoreEntries} call.\n *\n * - `injected`: one or more missing lines were appended to `.gitignore`.\n * - `failed`: append was attempted but threw (unwritable FS) — secret generation\n * is never aborted on failure.\n * - Neither flag set: all lines were already present — pure no-op.\n */\ninterface GitignoreResult {\n injected: boolean;\n failed: boolean;\n}\n\n/**\n * Idempotently appends `.ait_relay` and `.ait_urls` ignore entries to\n * `<secretDir>/.gitignore`.\n *\n * Covers the commit-safety net for the auto-generated relay secrets (#669).\n * Both files live in `secretDir` (the nearest package.json directory), so\n * anchoring the `.gitignore` there ensures the ignore entry and the files it\n * guards always co-locate.\n *\n * Idempotency: reads the existing `.gitignore` (if any), splits into lines,\n * appends only names not already present (exact-match, trimmed). A project\n * that already has both entries (the sdk-example case) is a pure no-op — no\n * write, no log noise.\n *\n * On write failure (unwritable FS, read-only mount) the function returns\n * `{ injected: false, failed: true }` — it NEVER aborts secret generation.\n *\n * SECRET-HANDLING: this function only manipulates ignore-pattern strings and\n * the `.gitignore` path. It never reads, logs, or echoes any secret value.\n *\n * @param secretDir - dirname of the `.ait_relay` file (= nearest package.json dir).\n * @param fsDep - Injectable fs subset (same object passed to the write path).\n * @param logFn - Injectable log sink (used by callers for fold-in messages).\n */\nfunction ensureGitignoreEntries(\n secretDir: string,\n fsDep: RelaySecretFs,\n _logFn: (msg: string) => void,\n): GitignoreResult {\n const gi = join(secretDir, '.gitignore');\n\n // '.ait_urls' mirrors URLS_FILE_NAME from relay-url-store.ts. The constant is\n // inlined here (rather than imported) to avoid a circular module dependency:\n // relay-url-store.ts already imports nearestPackageJsonDir from this module,\n // so a reverse import would create a 2-way cycle.\n const names = [RELAY_SECRET_FILE_NAME, '.ait_urls'];\n\n let existing = '';\n if (fsDep.existsSync(gi)) {\n try {\n existing = fsDep.readFileSync(gi, 'utf8');\n } catch {\n // Unreadable .gitignore — degrade gracefully; secret generation is unaffected.\n return { injected: false, failed: true };\n }\n }\n\n const presentLines = new Set(existing.split(/\\r?\\n/).map((l) => l.trim()));\n const missing = names.filter((n) => !presentLines.has(n));\n\n if (missing.length === 0) {\n // Pure no-op: all entries already present (sdk-example case).\n return { injected: false, failed: false };\n }\n\n // Ensure we start on a fresh line when the existing content has no trailing newline.\n const prefix = existing === '' || existing.endsWith('\\n') ? '' : '\\n';\n try {\n fsDep.appendFileSync(gi, `${prefix}${missing.join('\\n')}\\n`);\n return { injected: true, failed: false };\n } catch {\n // Write failed (unwritable FS, read-only mount, etc.) — degrade gracefully.\n // SECRET-HANDLING: the error and path are swallowed, not surfaced.\n return { injected: false, failed: true };\n }\n}\n\nasync function readAndInject(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n rb: (n: number) => Buffer,\n): Promise<void> {\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패: ${msg}`);\n }\n\n if (!isValidRelayAuthSecret(stored)) {\n // Stored value is corrupt — re-mint over the same path.\n logFn('[@ait-co/devtools] relay 시크릿 파일의 값이 유효하지 않습니다. 재생성합니다.\\n');\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret, true);\n }\n\n // Inject into env — silent path (no log on successful reload).\n env.AIT_DEBUG_TOTP_SECRET = stored;\n\n // Ensure .gitignore covers both secret files even on the reload path (the\n // secret already exists so mintAndPersist will not run, but .ait_urls is\n // written unconditionally by the unplugin on every boot and must be ignored).\n // This is idempotent — if the entries are already present it is a pure no-op.\n ensureGitignoreEntries(dirname(secretPath), fs, logFn);\n}\n\nasync function mintAndPersist(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n rb: (n: number) => Buffer,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n /** When re-minting over a corrupt file, the existing file must be overwritten. */\n overwrite = false,\n): Promise<void> {\n // SECRET-HANDLING: the raw bytes are never written to any log or string other\n // than the persist file and the env variable.\n const secret = rb(32).toString('hex'); // 64 hex chars = 256 bits\n\n // Self-consistency guard: our own minted secret must pass validation.\n if (!isValidRelayAuthSecret(secret)) {\n throw new Error(\n '[@ait-co/devtools] 내부 오류: mint된 시크릿이 유효성 검사를 통과하지 못했습니다.',\n );\n }\n\n // Write a SINGLE file into the already-existing project directory — no\n // directory is created. `O_EXCL` (flag 'wx') makes the create exclusive so a\n // concurrent process cannot be clobbered; on EEXIST we read the winner's value.\n // (When re-minting over a corrupt file we must overwrite, so use 'w'.)\n const flag = overwrite ? 'w' : 'wx';\n try {\n fs.writeFileSync(secretPath, secret, { mode: 0o600, flag });\n // Belt-and-suspenders: apply chmod after write in case umask relaxed the mode.\n fs.chmodSync(secretPath, 0o600);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'EEXIST') {\n // Race: another process already wrote the file — read their value.\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (readErr) {\n const msg = readErr instanceof Error ? readErr.message : String(readErr);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패(경합): ${msg}`);\n }\n if (!isValidRelayAuthSecret(stored)) {\n throw new Error('[@ait-co/devtools] relay 시크릿 파일이 경합 후에도 유효하지 않습니다.');\n }\n env.AIT_DEBUG_TOTP_SECRET = stored;\n return;\n }\n throw err;\n }\n\n // Inject into the current process env so the immediately following\n // assertRelayAuthConfigured() / buildRelayVerifyAuth() calls see the value.\n env.AIT_DEBUG_TOTP_SECRET = secret;\n\n // Idempotently inject .gitignore entries for the two secret files (#669).\n // Fold the result into the single first-mint announcement below so\n // logs.length === 1 is preserved (SECRET-HANDLING: no path/value surfaced).\n const giResult = ensureGitignoreEntries(dirname(secretPath), fs, logFn);\n\n // First-mint announcement (value never included — SECRET-HANDLING). The file\n // name is fixed (`.ait_relay`); we do not echo the resolved directory either.\n // The gitignore result is folded in here to keep the single-log invariant.\n const giLine = giResult.injected\n ? `프로젝트의 .gitignore에 .ait_relay / .ait_urls 무시 규칙을 추가했습니다.\\n`\n : giResult.failed\n ? `.gitignore 자동 갱신에 실패했습니다 — .ait_relay / .ait_urls 를 직접 추가하거나 비공개 repo를 권장합니다.\\n`\n : '';\n\n logFn(\n `[@ait-co/devtools] relay 인증 시크릿을 생성해 프로젝트의 ${RELAY_SECRET_FILE_NAME} 파일에 저장했습니다 (권한 0600).\\n` +\n `다음 실행부터 자동으로 사용됩니다. 직접 export할 필요 없습니다.\\n` +\n `팀이 같은 relay를 공유하려면 이 파일을 repo에 커밋하세요(비공개 repo 권장).\\n` +\n (giLine ? giLine : '') +\n `자세히: https://docs.aitc.dev/guides/relay-auth-totp\\n`,\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,MAAa,yBAAyB;;;;;;;;;;;;;;;;AAoFtC,SAAgB,sBACd,OACA,cACQ;CACR,IAAI,MAAM;AAEV,QAAO,MAAM;AACX,MAAI,aAAa,KAAK,KAAK,eAAe,CAAC,CACzC,QAAO;EAET,MAAM,SAAS,QAAQ,IAAI;AAC3B,MAAI,WAAW,IAGb,QAAO;AAET,QAAM;;;;;;;;;;AAWV,SAAgB,oBACd,OACA,cACQ;AACR,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;AAwGjF,eAAsB,wBAAwB,MAA+C;CAC3F,MAAM,EAAE,aAAa,MAAM,QAAQ,KAAK,IAAI,OAAO,YAAY,eAAe,QAAQ,QAAQ,EAAE;CAEhG,MAAM,QAA+B,SAAS,QAAgB,QAAQ,OAAO,MAAM,IAAI;CAEvF,MAAM,EAAE,2BAA2B,MAAM,OAAO,sBAAA,MAAA,MAAA,EAAA,EAAA;AAMhD,KAAI,uBAAuB,IAAI,sBAAsB,EAAE;AACrD,MAAI,gBAAgB,KAAA,GAAW;GAC7B,MAAM,UAAiC,SAAU,MAAM,OAAO;GAE9D,MAAM,kBAAkB,oBAAoB,aADO,iBAAiB,QAAQ,WACH;AACzE,4BACE,IAAI,uBACJ,iBACA,SACA,wBACA,MACD;;AAEH;;AAIF,KAAI,gBAAgB,KAAA,EAClB;CAGF,MAAM,KAA4B,SAAU,MAAM,OAAO;CAGzD,MAAM,aAAa,oBAAoB,aAFS,iBAAiB,GAAG,WAEH;AACjE,KAAI,CAAC,GAAG,WAAW,WAAW,CAC5B;CAGF,IAAI;AACJ,KAAI;AACF,WAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;SAC7C;AAIN;;AAIF,KAAI,CAAC,uBAAuB,OAAO,CACjC;AAGF,KAAI,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;AA+B9B,SAAS,yBACP,WACA,YACA,OACA,wBACA,OACM;AAEN,KAAI,CAAC,MAAM,WAAW,WAAW,CAC/B;CAGF,IAAI;AACJ,KAAI;AACF,WAAS,MAAM,aAAa,YAAY,OAAO,CAAC,MAAM;SAChD;AAEN;;AAIF,KAAI,CAAC,uBAAuB,OAAO,CACjC;AAIF,KAAI,cAAc,OAChB,OACE,iRAID"}
|
|
1
|
+
{"version":3,"file":"relay-secret-store-CFc9n0OA.js","names":[],"sources":["../src/mcp/relay-secret-store.ts"],"sourcesContent":["/**\n * Project-local relay TOTP secret store (#394 first-run auto-mint, #396 moved to\n * a project-local single file `.ait_relay`).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link ensureRelaySecret} — WRITE path, called ONLY from the unplugin\n * (env-2 relay boot). Mints a fresh secret on first run and persists it to\n * `<projectRoot>/.ait_relay` (0600). A single file — no directory is created.\n *\n * - {@link loadRelaySecretReadOnly} — READ-ONLY path, called from the MCP\n * daemon when switching into a relay environment. It NEVER mints, chmods, or\n * creates anything: it only reads an already-existing `.ait_relay` and injects\n * its value into `env`. A daemon that minted would defeat the #250 fail-fast\n * (the daemon is the verifier side — a self-minted secret would let a leaked\n * tunnel URL attach unauthenticated), so the daemon stays read-only.\n *\n * Why a per-session `projectRoot` instead of `process.cwd()`: the daemon cannot\n * trust its own cwd — agent-plugin spawns it via `npx` without `cwd`, so cwd is\n * frozen at Claude Code launch and a cwd-walk stops at the monorepo workspace\n * root (which always has a package.json). So the project root is supplied\n * per-debug-session through `start_debug`.\n *\n * SECRET-HANDLING: this module handles AIT_DEBUG_TOTP_SECRET — the raw value and\n * its length MUST NOT appear in any log, error message, stdout, stderr, or\n * assertion output. Only boolean pass/fail signals are safe to surface, and the\n * discovered file path is never logged either. The persist file is written mode\n * 0600.\n */\n\nimport { dirname, join } from 'node:path';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local secret file name (single file, not a directory). */\nexport const RELAY_SECRET_FILE_NAME = '.ait_relay';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surface\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link ensureRelaySecret} — injectable for tests. */\nexport interface RelaySecretFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n readFileSync(path: string, encoding: BufferEncoding): string;\n chmodSync(path: string, mode: number): void;\n existsSync(path: string): boolean;\n appendFileSync(path: string, data: string): void;\n}\n\n/**\n * Minimal fs subset needed by {@link loadRelaySecretReadOnly} — strictly the two\n * read-only operations. Deliberately omits writeFileSync/mkdirSync/chmodSync so\n * the daemon path cannot mutate the filesystem even by accident (the type\n * forbids it).\n */\nexport interface RelaySecretReadOnlyFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\nexport interface RelaySecretDeps {\n /**\n * Project root (typically Vite `server.config.root`). The `.ait_relay` file is\n * resolved against the nearest `package.json` directory at or above this path.\n * When omitted, the current working directory is used as the start point —\n * retained for back-compat/tests; the unplugin always passes it.\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Cryptographically secure random bytes. Defaults to node:crypto randomBytes. */\n randomBytes?: (n: number) => Buffer;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelaySecretFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Current working directory resolver (used only when `projectRoot` is omitted). */\n cwd?: () => string;\n /** Log function for first-mint announcement. Defaults to process.stderr.write. */\n log?: (msg: string) => void;\n}\n\nexport interface RelaySecretReadOnlyDeps {\n /**\n * Project root supplied per-debug-session via `start_debug`. The daemon reads\n * `<nearest package.json dir from projectRoot>/.ait_relay`. When omitted, the\n * loader is a no-op (the daemon has no anchor to read from).\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelaySecretReadOnlyFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Optional log sink — never receives the secret value, length, or file path. */\n log?: (msg: string) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Walks upward from `start` and returns the nearest directory that contains a\n * `package.json`. Falls back to `start` itself when none is found (so a write\n * still lands somewhere deterministic).\n *\n * The write (unplugin) and read (daemon) sides use the SAME anchor so a secret\n * minted by `pnpm dev` is found by the daemon: real mini-apps keep\n * `vite.config.ts` and `package.json` in the same directory, so\n * `server.config.root === package.json-dir`. In a monorepo subdir the anchor is\n * the package's own directory — the one the daemon can also reach via the\n * per-session projectRoot.\n *\n * @param start - Directory to start the upward walk from.\n * @param existsSyncFn - Injectable existence check (defaults to node:fs).\n */\nexport function nearestPackageJsonDir(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n let dir = start;\n // Stop at the filesystem root (dirname of root === root).\n while (true) {\n if (existsSyncFn(join(dir, 'package.json'))) {\n return dir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n // Reached the filesystem root without finding a package.json — fall back\n // to the original start directory.\n return start;\n }\n dir = parent;\n }\n}\n\n/**\n * Absolute path to the project-local `.ait_relay` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function relaySecretFilePath(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), RELAY_SECRET_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — mint + persist\n// ---------------------------------------------------------------------------\n\n/**\n * Ensures `env.AIT_DEBUG_TOTP_SECRET` is set to a valid relay TOTP secret,\n * persisting a freshly-minted one to `<projectRoot>/.ait_relay` (0600) on first\n * run and loading it silently on subsequent runs.\n *\n * Writes a SINGLE file into the already-existing project directory — it never\n * creates a directory (so no `mkdirSync`/dir `chmod`). The file is created with\n * `O_EXCL` (`flag: 'wx'`) so a concurrent process cannot be clobbered; on the\n * EEXIST race the winner's value is read instead.\n *\n * Called ONLY from the unplugin (env-2 relay boot). The MCP daemon uses\n * {@link loadRelaySecretReadOnly} (read-only) — it must never mint.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function ensureRelaySecret(deps?: RelaySecretDeps): Promise<void> {\n const {\n projectRoot,\n env = process.env,\n randomBytes: randomBytesFn,\n fs: fsDep,\n existsSync: existsSyncDep,\n cwd: cwdFn,\n log,\n } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n // Lazily import isValidRelayAuthSecret to avoid pulling in node:crypto at\n // module-load time (keeps the import side-effect free).\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or earlier run wins).\n // But first check for a divergence between the env value and .ait_relay —\n // if they differ the relay will verify against the env value while QR/\n // deep-links carry codes derived from the file, causing silent 401s (#620).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n // We need fs to compare — resolve deps early just for the divergence check.\n // This mirrors the lazy-resolve block below but is hoisted here so we can\n // still early-return after the (possibly-emitted) warning.\n const fsEarly: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncEarly: (path: string) => boolean = existsSyncDep ?? fsEarly.existsSync;\n const startEarly = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPathEarly = relaySecretFilePath(startEarly, existsSyncEarly);\n warnIfEnvDiffersFromFile(\n env.AIT_DEBUG_TOTP_SECRET,\n secretPathEarly,\n fsEarly,\n isValidRelayAuthSecret,\n logFn,\n );\n return;\n }\n\n // Resolve injected or real dependencies lazily to keep the import graph clean.\n const rb: (n: number) => Buffer = randomBytesFn ?? (await import('node:crypto')).randomBytes;\n const fs: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const start = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPath = relaySecretFilePath(start, existsSyncFn);\n\n // 2. Persist file exists — read and inject (silent reload).\n if (fs.existsSync(secretPath)) {\n return readAndInject(secretPath, fs, env, logFn, isValidRelayAuthSecret, rb);\n }\n\n // 3. Mint a fresh secret.\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret);\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never mints, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\n/**\n * Reads an already-existing `<projectRoot>/.ait_relay` and, if its contents are a\n * valid relay TOTP secret, injects them into `env.AIT_DEBUG_TOTP_SECRET`.\n *\n * Strictly READ-ONLY: it uses only `existsSync` + `readFileSync` and NEVER mints,\n * chmods, or creates files/directories. The daemon must not mint because it is\n * the relay verifier side — a self-minted secret would defeat the #250 fail-fast\n * (a leaked tunnel URL could then attach unauthenticated). If no valid secret is\n * found the function leaves `env` untouched and returns without throwing, so the\n * downstream `assertRelayAuthConfigured()` stays the single fail-fast.\n *\n * Resolution order:\n * 1. `env.AIT_DEBUG_TOTP_SECRET` already valid → no-op (operator export wins).\n * 2. `projectRoot` given → read `<nearest package.json dir>/.ait_relay`; inject\n * iff the contents pass {@link isValidRelayAuthSecret}.\n * 3. Otherwise (no projectRoot, file absent, or invalid) → silent no-op.\n *\n * SECRET-HANDLING: the read value is passed ONLY to the boolean predicate before\n * assignment; its value, length, and the discovered file path are never logged.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function loadRelaySecretReadOnly(deps?: RelaySecretReadOnlyDeps): Promise<void> {\n const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep, log } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or unplugin run wins).\n // But first check for a divergence between the env value and .ait_relay —\n // if they differ the relay will verify against the env value while QR/\n // deep-links carry codes derived from the file, causing silent 401s (#620).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n if (projectRoot !== undefined) {\n const fsEarly: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncEarly: (path: string) => boolean = existsSyncDep ?? fsEarly.existsSync;\n const secretPathEarly = relaySecretFilePath(projectRoot, existsSyncEarly);\n warnIfEnvDiffersFromFile(\n env.AIT_DEBUG_TOTP_SECRET,\n secretPathEarly,\n fsEarly,\n isValidRelayAuthSecret,\n logFn,\n );\n }\n return;\n }\n\n // 2. No anchor → nothing to read.\n if (projectRoot === undefined) {\n return;\n }\n\n const fs: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const secretPath = relaySecretFilePath(projectRoot, existsSyncFn);\n if (!fs.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable file (permissions, transient FS error) — stay silent and let\n // the downstream assert be the single fail-fast. SECRET-HANDLING: the error\n // and path are not surfaced.\n return;\n }\n\n // SECRET-HANDLING: the value flows only through the boolean predicate.\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers (not exported — single-use extracted for readability)\n// ---------------------------------------------------------------------------\n\n/**\n * Compares `envSecret` against the contents of `secretPath` (if the file\n * exists and contains a valid secret) and emits a single warning via `logFn`\n * when they differ.\n *\n * SECRET-HANDLING (hard rules — do NOT relax):\n * - The warning MUST NOT include either secret value, its length, a hash of\n * it, or the resolved file path.\n * - Only an inequality boolean drives the warning; no secret-derived data\n * enters the log message.\n * - If the file is absent, unreadable, or its contents are invalid the\n * function returns silently — no spurious noise.\n *\n * This helper is intentionally synchronous-like (reads via the injected fs)\n * so it can be called from within the async early-return guards without\n * introducing additional async hops.\n *\n * @param envSecret - The validated env value (caller must have confirmed it is\n * valid before calling).\n * @param secretPath - Absolute path to `.ait_relay` to read for comparison.\n * @param fsDep - Injectable fs subset (at minimum `existsSync` + `readFileSync`).\n * @param isValidRelayAuthSecret - Injectable predicate from totp.ts.\n * @param logFn - Injectable log sink; never receives a secret value.\n */\nfunction warnIfEnvDiffersFromFile(\n envSecret: string,\n secretPath: string,\n fsDep: RelaySecretReadOnlyFs,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n logFn: (msg: string) => void,\n): void {\n // File absent → nothing to compare.\n if (!fsDep.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fsDep.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable — skip silently. SECRET-HANDLING: error and path not surfaced.\n return;\n }\n\n // Invalid stored contents → skip silently (no spurious noise).\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n // Compare by equality only. Neither value nor path enters the log message.\n if (envSecret !== stored) {\n logFn(\n `[@ait-co/devtools] AIT_DEBUG_TOTP_SECRET (from environment) differs from the project-local relay secret; ` +\n `the relay will verify against the environment value. ` +\n `Remove .env/.env.local/exported AIT_DEBUG_TOTP_SECRET, or sync the file, ` +\n `so QR/deep-links and the relay agree.\\n`,\n );\n }\n}\n\n/**\n * Result of a {@link ensureGitignoreEntries} call.\n *\n * - `injected`: one or more missing lines were appended to `.gitignore`.\n * - `failed`: append was attempted but threw (unwritable FS) — secret generation\n * is never aborted on failure.\n * - Neither flag set: all lines were already present — pure no-op.\n */\ninterface GitignoreResult {\n injected: boolean;\n failed: boolean;\n}\n\n/**\n * Idempotently appends `.ait_relay` and `.ait_urls` ignore entries to\n * `<secretDir>/.gitignore`.\n *\n * Covers the commit-safety net for the auto-generated relay secrets (#669).\n * Both files live in `secretDir` (the nearest package.json directory), so\n * anchoring the `.gitignore` there ensures the ignore entry and the files it\n * guards always co-locate.\n *\n * Idempotency: reads the existing `.gitignore` (if any), splits into lines,\n * appends only names not already present (exact-match, trimmed). A project\n * that already has both entries (the sdk-example case) is a pure no-op — no\n * write, no log noise.\n *\n * On write failure (unwritable FS, read-only mount) the function returns\n * `{ injected: false, failed: true }` — it NEVER aborts secret generation.\n *\n * SECRET-HANDLING: this function only manipulates ignore-pattern strings and\n * the `.gitignore` path. It never reads, logs, or echoes any secret value.\n *\n * @param secretDir - dirname of the `.ait_relay` file (= nearest package.json dir).\n * @param fsDep - Injectable fs subset (same object passed to the write path).\n * @param logFn - Injectable log sink (used by callers for fold-in messages).\n */\nfunction ensureGitignoreEntries(\n secretDir: string,\n fsDep: RelaySecretFs,\n _logFn: (msg: string) => void,\n): GitignoreResult {\n const gi = join(secretDir, '.gitignore');\n\n // '.ait_urls' mirrors URLS_FILE_NAME from relay-url-store.ts. The constant is\n // inlined here (rather than imported) to avoid a circular module dependency:\n // relay-url-store.ts already imports nearestPackageJsonDir from this module,\n // so a reverse import would create a 2-way cycle.\n const names = [RELAY_SECRET_FILE_NAME, '.ait_urls'];\n\n let existing = '';\n if (fsDep.existsSync(gi)) {\n try {\n existing = fsDep.readFileSync(gi, 'utf8');\n } catch {\n // Unreadable .gitignore — degrade gracefully; secret generation is unaffected.\n return { injected: false, failed: true };\n }\n }\n\n const presentLines = new Set(existing.split(/\\r?\\n/).map((l) => l.trim()));\n const missing = names.filter((n) => !presentLines.has(n));\n\n if (missing.length === 0) {\n // Pure no-op: all entries already present (sdk-example case).\n return { injected: false, failed: false };\n }\n\n // Ensure we start on a fresh line when the existing content has no trailing newline.\n const prefix = existing === '' || existing.endsWith('\\n') ? '' : '\\n';\n try {\n fsDep.appendFileSync(gi, `${prefix}${missing.join('\\n')}\\n`);\n return { injected: true, failed: false };\n } catch {\n // Write failed (unwritable FS, read-only mount, etc.) — degrade gracefully.\n // SECRET-HANDLING: the error and path are swallowed, not surfaced.\n return { injected: false, failed: true };\n }\n}\n\nasync function readAndInject(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n rb: (n: number) => Buffer,\n): Promise<void> {\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패: ${msg}`);\n }\n\n if (!isValidRelayAuthSecret(stored)) {\n // Stored value is corrupt — re-mint over the same path.\n logFn('[@ait-co/devtools] relay 시크릿 파일의 값이 유효하지 않습니다. 재생성합니다.\\n');\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret, true);\n }\n\n // Inject into env — silent path (no log on successful reload).\n env.AIT_DEBUG_TOTP_SECRET = stored;\n\n // Ensure .gitignore covers both secret files even on the reload path (the\n // secret already exists so mintAndPersist will not run, but .ait_urls is\n // written unconditionally by the unplugin on every boot and must be ignored).\n // This is idempotent — if the entries are already present it is a pure no-op.\n ensureGitignoreEntries(dirname(secretPath), fs, logFn);\n}\n\nasync function mintAndPersist(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n rb: (n: number) => Buffer,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n /** When re-minting over a corrupt file, the existing file must be overwritten. */\n overwrite = false,\n): Promise<void> {\n // SECRET-HANDLING: the raw bytes are never written to any log or string other\n // than the persist file and the env variable.\n const secret = rb(32).toString('hex'); // 64 hex chars = 256 bits\n\n // Self-consistency guard: our own minted secret must pass validation.\n if (!isValidRelayAuthSecret(secret)) {\n throw new Error(\n '[@ait-co/devtools] 내부 오류: mint된 시크릿이 유효성 검사를 통과하지 못했습니다.',\n );\n }\n\n // Write a SINGLE file into the already-existing project directory — no\n // directory is created. `O_EXCL` (flag 'wx') makes the create exclusive so a\n // concurrent process cannot be clobbered; on EEXIST we read the winner's value.\n // (When re-minting over a corrupt file we must overwrite, so use 'w'.)\n const flag = overwrite ? 'w' : 'wx';\n try {\n fs.writeFileSync(secretPath, secret, { mode: 0o600, flag });\n // Belt-and-suspenders: apply chmod after write in case umask relaxed the mode.\n fs.chmodSync(secretPath, 0o600);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'EEXIST') {\n // Race: another process already wrote the file — read their value.\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (readErr) {\n const msg = readErr instanceof Error ? readErr.message : String(readErr);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패(경합): ${msg}`);\n }\n if (!isValidRelayAuthSecret(stored)) {\n throw new Error('[@ait-co/devtools] relay 시크릿 파일이 경합 후에도 유효하지 않습니다.');\n }\n env.AIT_DEBUG_TOTP_SECRET = stored;\n return;\n }\n throw err;\n }\n\n // Inject into the current process env so the immediately following\n // assertRelayAuthConfigured() / buildRelayVerifyAuth() calls see the value.\n env.AIT_DEBUG_TOTP_SECRET = secret;\n\n // Idempotently inject .gitignore entries for the two secret files (#669).\n // Fold the result into the single first-mint announcement below so\n // logs.length === 1 is preserved (SECRET-HANDLING: no path/value surfaced).\n const giResult = ensureGitignoreEntries(dirname(secretPath), fs, logFn);\n\n // First-mint announcement (value never included — SECRET-HANDLING). The file\n // name is fixed (`.ait_relay`); we do not echo the resolved directory either.\n // The gitignore result is folded in here to keep the single-log invariant.\n const giLine = giResult.injected\n ? `프로젝트의 .gitignore에 .ait_relay / .ait_urls 무시 규칙을 추가했습니다.\\n`\n : giResult.failed\n ? `.gitignore 자동 갱신에 실패했습니다 — .ait_relay / .ait_urls 를 직접 추가하거나 비공개 repo를 권장합니다.\\n`\n : '';\n\n logFn(\n `[@ait-co/devtools] relay 인증 시크릿을 생성해 프로젝트의 ${RELAY_SECRET_FILE_NAME} 파일에 저장했습니다 (권한 0600).\\n` +\n `다음 실행부터 자동으로 사용됩니다. 직접 export할 필요 없습니다.\\n` +\n `팀이 같은 relay를 공유하려면 이 파일을 repo에 커밋하세요(비공개 repo 권장).\\n` +\n (giLine ? giLine : '') +\n `자세히: https://docs.aitc.dev/guides/relay-auth-totp\\n`,\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,MAAa,yBAAyB;;;;;;;;;;;;;;;;AAoFtC,SAAgB,sBACd,OACA,cACQ;CACR,IAAI,MAAM;AAEV,QAAO,MAAM;AACX,MAAI,aAAa,KAAK,KAAK,eAAe,CAAC,CACzC,QAAO;EAET,MAAM,SAAS,QAAQ,IAAI;AAC3B,MAAI,WAAW,IAGb,QAAO;AAET,QAAM;;;;;;;;;;AAWV,SAAgB,oBACd,OACA,cACQ;AACR,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;AAwGjF,eAAsB,wBAAwB,MAA+C;CAC3F,MAAM,EAAE,aAAa,MAAM,QAAQ,KAAK,IAAI,OAAO,YAAY,eAAe,QAAQ,QAAQ,EAAE;CAEhG,MAAM,QAA+B,SAAS,QAAgB,QAAQ,OAAO,MAAM,IAAI;CAEvF,MAAM,EAAE,2BAA2B,MAAM,OAAO,sBAAA,MAAA,MAAA,EAAA,EAAA;AAMhD,KAAI,uBAAuB,IAAI,sBAAsB,EAAE;AACrD,MAAI,gBAAgB,KAAA,GAAW;GAC7B,MAAM,UAAiC,SAAU,MAAM,OAAO;GAE9D,MAAM,kBAAkB,oBAAoB,aADO,iBAAiB,QAAQ,WACH;AACzE,4BACE,IAAI,uBACJ,iBACA,SACA,wBACA,MACD;;AAEH;;AAIF,KAAI,gBAAgB,KAAA,EAClB;CAGF,MAAM,KAA4B,SAAU,MAAM,OAAO;CAGzD,MAAM,aAAa,oBAAoB,aAFS,iBAAiB,GAAG,WAEH;AACjE,KAAI,CAAC,GAAG,WAAW,WAAW,CAC5B;CAGF,IAAI;AACJ,KAAI;AACF,WAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;SAC7C;AAIN;;AAIF,KAAI,CAAC,uBAAuB,OAAO,CACjC;AAGF,KAAI,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;AA+B9B,SAAS,yBACP,WACA,YACA,OACA,wBACA,OACM;AAEN,KAAI,CAAC,MAAM,WAAW,WAAW,CAC/B;CAGF,IAAI;AACJ,KAAI;AACF,WAAS,MAAM,aAAa,YAAY,OAAO,CAAC,MAAM;SAChD;AAEN;;AAIF,KAAI,CAAC,uBAAuB,OAAO,CACjC;AAIF,KAAI,cAAc,OAChB,OACE,iRAID"}
|
|
@@ -91,7 +91,7 @@ function relaySecretFilePath(start, existsSyncFn) {
|
|
|
91
91
|
async function loadRelaySecretReadOnly(deps) {
|
|
92
92
|
const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep, log } = deps ?? {};
|
|
93
93
|
const logFn = log ?? ((msg) => process.stderr.write(msg));
|
|
94
|
-
const { isValidRelayAuthSecret } = await import("./totp-
|
|
94
|
+
const { isValidRelayAuthSecret } = await import("./totp-D1pulXLa.js");
|
|
95
95
|
if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {
|
|
96
96
|
if (projectRoot !== void 0) {
|
|
97
97
|
const fsEarly = fsDep ?? await import("node:fs");
|
|
@@ -149,6 +149,6 @@ function warnIfEnvDiffersFromFile(envSecret, secretPath, fsDep, isValidRelayAuth
|
|
|
149
149
|
if (envSecret !== stored) logFn("[@ait-co/devtools] AIT_DEBUG_TOTP_SECRET (from environment) differs from the project-local relay secret; the relay will verify against the environment value. Remove .env/.env.local/exported AIT_DEBUG_TOTP_SECRET, or sync the file, so QR/deep-links and the relay agree.\n");
|
|
150
150
|
}
|
|
151
151
|
//#endregion
|
|
152
|
-
export {
|
|
152
|
+
export { nearestPackageJsonDir as n, loadRelaySecretReadOnly as t };
|
|
153
153
|
|
|
154
|
-
//# sourceMappingURL=relay-secret-store-
|
|
154
|
+
//# sourceMappingURL=relay-secret-store-DhzAnnj-.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"relay-secret-store-CtK1eqoV.js","names":[],"sources":["../src/mcp/relay-secret-store.ts"],"sourcesContent":["/**\n * Project-local relay TOTP secret store (#394 first-run auto-mint, #396 moved to\n * a project-local single file `.ait_relay`).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link ensureRelaySecret} — WRITE path, called ONLY from the unplugin\n * (env-2 relay boot). Mints a fresh secret on first run and persists it to\n * `<projectRoot>/.ait_relay` (0600). A single file — no directory is created.\n *\n * - {@link loadRelaySecretReadOnly} — READ-ONLY path, called from the MCP\n * daemon when switching into a relay environment. It NEVER mints, chmods, or\n * creates anything: it only reads an already-existing `.ait_relay` and injects\n * its value into `env`. A daemon that minted would defeat the #250 fail-fast\n * (the daemon is the verifier side — a self-minted secret would let a leaked\n * tunnel URL attach unauthenticated), so the daemon stays read-only.\n *\n * Why a per-session `projectRoot` instead of `process.cwd()`: the daemon cannot\n * trust its own cwd — agent-plugin spawns it via `npx` without `cwd`, so cwd is\n * frozen at Claude Code launch and a cwd-walk stops at the monorepo workspace\n * root (which always has a package.json). So the project root is supplied\n * per-debug-session through `start_debug`.\n *\n * SECRET-HANDLING: this module handles AIT_DEBUG_TOTP_SECRET — the raw value and\n * its length MUST NOT appear in any log, error message, stdout, stderr, or\n * assertion output. Only boolean pass/fail signals are safe to surface, and the\n * discovered file path is never logged either. The persist file is written mode\n * 0600.\n */\n\nimport { dirname, join } from 'node:path';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local secret file name (single file, not a directory). */\nexport const RELAY_SECRET_FILE_NAME = '.ait_relay';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surface\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link ensureRelaySecret} — injectable for tests. */\nexport interface RelaySecretFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n readFileSync(path: string, encoding: BufferEncoding): string;\n chmodSync(path: string, mode: number): void;\n existsSync(path: string): boolean;\n appendFileSync(path: string, data: string): void;\n}\n\n/**\n * Minimal fs subset needed by {@link loadRelaySecretReadOnly} — strictly the two\n * read-only operations. Deliberately omits writeFileSync/mkdirSync/chmodSync so\n * the daemon path cannot mutate the filesystem even by accident (the type\n * forbids it).\n */\nexport interface RelaySecretReadOnlyFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\nexport interface RelaySecretDeps {\n /**\n * Project root (typically Vite `server.config.root`). The `.ait_relay` file is\n * resolved against the nearest `package.json` directory at or above this path.\n * When omitted, the current working directory is used as the start point —\n * retained for back-compat/tests; the unplugin always passes it.\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Cryptographically secure random bytes. Defaults to node:crypto randomBytes. */\n randomBytes?: (n: number) => Buffer;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelaySecretFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Current working directory resolver (used only when `projectRoot` is omitted). */\n cwd?: () => string;\n /** Log function for first-mint announcement. Defaults to process.stderr.write. */\n log?: (msg: string) => void;\n}\n\nexport interface RelaySecretReadOnlyDeps {\n /**\n * Project root supplied per-debug-session via `start_debug`. The daemon reads\n * `<nearest package.json dir from projectRoot>/.ait_relay`. When omitted, the\n * loader is a no-op (the daemon has no anchor to read from).\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelaySecretReadOnlyFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Optional log sink — never receives the secret value, length, or file path. */\n log?: (msg: string) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Walks upward from `start` and returns the nearest directory that contains a\n * `package.json`. Falls back to `start` itself when none is found (so a write\n * still lands somewhere deterministic).\n *\n * The write (unplugin) and read (daemon) sides use the SAME anchor so a secret\n * minted by `pnpm dev` is found by the daemon: real mini-apps keep\n * `vite.config.ts` and `package.json` in the same directory, so\n * `server.config.root === package.json-dir`. In a monorepo subdir the anchor is\n * the package's own directory — the one the daemon can also reach via the\n * per-session projectRoot.\n *\n * @param start - Directory to start the upward walk from.\n * @param existsSyncFn - Injectable existence check (defaults to node:fs).\n */\nexport function nearestPackageJsonDir(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n let dir = start;\n // Stop at the filesystem root (dirname of root === root).\n while (true) {\n if (existsSyncFn(join(dir, 'package.json'))) {\n return dir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n // Reached the filesystem root without finding a package.json — fall back\n // to the original start directory.\n return start;\n }\n dir = parent;\n }\n}\n\n/**\n * Absolute path to the project-local `.ait_relay` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function relaySecretFilePath(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), RELAY_SECRET_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — mint + persist\n// ---------------------------------------------------------------------------\n\n/**\n * Ensures `env.AIT_DEBUG_TOTP_SECRET` is set to a valid relay TOTP secret,\n * persisting a freshly-minted one to `<projectRoot>/.ait_relay` (0600) on first\n * run and loading it silently on subsequent runs.\n *\n * Writes a SINGLE file into the already-existing project directory — it never\n * creates a directory (so no `mkdirSync`/dir `chmod`). The file is created with\n * `O_EXCL` (`flag: 'wx'`) so a concurrent process cannot be clobbered; on the\n * EEXIST race the winner's value is read instead.\n *\n * Called ONLY from the unplugin (env-2 relay boot). The MCP daemon uses\n * {@link loadRelaySecretReadOnly} (read-only) — it must never mint.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function ensureRelaySecret(deps?: RelaySecretDeps): Promise<void> {\n const {\n projectRoot,\n env = process.env,\n randomBytes: randomBytesFn,\n fs: fsDep,\n existsSync: existsSyncDep,\n cwd: cwdFn,\n log,\n } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n // Lazily import isValidRelayAuthSecret to avoid pulling in node:crypto at\n // module-load time (keeps the import side-effect free).\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or earlier run wins).\n // But first check for a divergence between the env value and .ait_relay —\n // if they differ the relay will verify against the env value while QR/\n // deep-links carry codes derived from the file, causing silent 401s (#620).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n // We need fs to compare — resolve deps early just for the divergence check.\n // This mirrors the lazy-resolve block below but is hoisted here so we can\n // still early-return after the (possibly-emitted) warning.\n const fsEarly: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncEarly: (path: string) => boolean = existsSyncDep ?? fsEarly.existsSync;\n const startEarly = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPathEarly = relaySecretFilePath(startEarly, existsSyncEarly);\n warnIfEnvDiffersFromFile(\n env.AIT_DEBUG_TOTP_SECRET,\n secretPathEarly,\n fsEarly,\n isValidRelayAuthSecret,\n logFn,\n );\n return;\n }\n\n // Resolve injected or real dependencies lazily to keep the import graph clean.\n const rb: (n: number) => Buffer = randomBytesFn ?? (await import('node:crypto')).randomBytes;\n const fs: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const start = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPath = relaySecretFilePath(start, existsSyncFn);\n\n // 2. Persist file exists — read and inject (silent reload).\n if (fs.existsSync(secretPath)) {\n return readAndInject(secretPath, fs, env, logFn, isValidRelayAuthSecret, rb);\n }\n\n // 3. Mint a fresh secret.\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret);\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never mints, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\n/**\n * Reads an already-existing `<projectRoot>/.ait_relay` and, if its contents are a\n * valid relay TOTP secret, injects them into `env.AIT_DEBUG_TOTP_SECRET`.\n *\n * Strictly READ-ONLY: it uses only `existsSync` + `readFileSync` and NEVER mints,\n * chmods, or creates files/directories. The daemon must not mint because it is\n * the relay verifier side — a self-minted secret would defeat the #250 fail-fast\n * (a leaked tunnel URL could then attach unauthenticated). If no valid secret is\n * found the function leaves `env` untouched and returns without throwing, so the\n * downstream `assertRelayAuthConfigured()` stays the single fail-fast.\n *\n * Resolution order:\n * 1. `env.AIT_DEBUG_TOTP_SECRET` already valid → no-op (operator export wins).\n * 2. `projectRoot` given → read `<nearest package.json dir>/.ait_relay`; inject\n * iff the contents pass {@link isValidRelayAuthSecret}.\n * 3. Otherwise (no projectRoot, file absent, or invalid) → silent no-op.\n *\n * SECRET-HANDLING: the read value is passed ONLY to the boolean predicate before\n * assignment; its value, length, and the discovered file path are never logged.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function loadRelaySecretReadOnly(deps?: RelaySecretReadOnlyDeps): Promise<void> {\n const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep, log } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or unplugin run wins).\n // But first check for a divergence between the env value and .ait_relay —\n // if they differ the relay will verify against the env value while QR/\n // deep-links carry codes derived from the file, causing silent 401s (#620).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n if (projectRoot !== undefined) {\n const fsEarly: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncEarly: (path: string) => boolean = existsSyncDep ?? fsEarly.existsSync;\n const secretPathEarly = relaySecretFilePath(projectRoot, existsSyncEarly);\n warnIfEnvDiffersFromFile(\n env.AIT_DEBUG_TOTP_SECRET,\n secretPathEarly,\n fsEarly,\n isValidRelayAuthSecret,\n logFn,\n );\n }\n return;\n }\n\n // 2. No anchor → nothing to read.\n if (projectRoot === undefined) {\n return;\n }\n\n const fs: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const secretPath = relaySecretFilePath(projectRoot, existsSyncFn);\n if (!fs.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable file (permissions, transient FS error) — stay silent and let\n // the downstream assert be the single fail-fast. SECRET-HANDLING: the error\n // and path are not surfaced.\n return;\n }\n\n // SECRET-HANDLING: the value flows only through the boolean predicate.\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers (not exported — single-use extracted for readability)\n// ---------------------------------------------------------------------------\n\n/**\n * Compares `envSecret` against the contents of `secretPath` (if the file\n * exists and contains a valid secret) and emits a single warning via `logFn`\n * when they differ.\n *\n * SECRET-HANDLING (hard rules — do NOT relax):\n * - The warning MUST NOT include either secret value, its length, a hash of\n * it, or the resolved file path.\n * - Only an inequality boolean drives the warning; no secret-derived data\n * enters the log message.\n * - If the file is absent, unreadable, or its contents are invalid the\n * function returns silently — no spurious noise.\n *\n * This helper is intentionally synchronous-like (reads via the injected fs)\n * so it can be called from within the async early-return guards without\n * introducing additional async hops.\n *\n * @param envSecret - The validated env value (caller must have confirmed it is\n * valid before calling).\n * @param secretPath - Absolute path to `.ait_relay` to read for comparison.\n * @param fsDep - Injectable fs subset (at minimum `existsSync` + `readFileSync`).\n * @param isValidRelayAuthSecret - Injectable predicate from totp.ts.\n * @param logFn - Injectable log sink; never receives a secret value.\n */\nfunction warnIfEnvDiffersFromFile(\n envSecret: string,\n secretPath: string,\n fsDep: RelaySecretReadOnlyFs,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n logFn: (msg: string) => void,\n): void {\n // File absent → nothing to compare.\n if (!fsDep.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fsDep.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable — skip silently. SECRET-HANDLING: error and path not surfaced.\n return;\n }\n\n // Invalid stored contents → skip silently (no spurious noise).\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n // Compare by equality only. Neither value nor path enters the log message.\n if (envSecret !== stored) {\n logFn(\n `[@ait-co/devtools] AIT_DEBUG_TOTP_SECRET (from environment) differs from the project-local relay secret; ` +\n `the relay will verify against the environment value. ` +\n `Remove .env/.env.local/exported AIT_DEBUG_TOTP_SECRET, or sync the file, ` +\n `so QR/deep-links and the relay agree.\\n`,\n );\n }\n}\n\n/**\n * Result of a {@link ensureGitignoreEntries} call.\n *\n * - `injected`: one or more missing lines were appended to `.gitignore`.\n * - `failed`: append was attempted but threw (unwritable FS) — secret generation\n * is never aborted on failure.\n * - Neither flag set: all lines were already present — pure no-op.\n */\ninterface GitignoreResult {\n injected: boolean;\n failed: boolean;\n}\n\n/**\n * Idempotently appends `.ait_relay` and `.ait_urls` ignore entries to\n * `<secretDir>/.gitignore`.\n *\n * Covers the commit-safety net for the auto-generated relay secrets (#669).\n * Both files live in `secretDir` (the nearest package.json directory), so\n * anchoring the `.gitignore` there ensures the ignore entry and the files it\n * guards always co-locate.\n *\n * Idempotency: reads the existing `.gitignore` (if any), splits into lines,\n * appends only names not already present (exact-match, trimmed). A project\n * that already has both entries (the sdk-example case) is a pure no-op — no\n * write, no log noise.\n *\n * On write failure (unwritable FS, read-only mount) the function returns\n * `{ injected: false, failed: true }` — it NEVER aborts secret generation.\n *\n * SECRET-HANDLING: this function only manipulates ignore-pattern strings and\n * the `.gitignore` path. It never reads, logs, or echoes any secret value.\n *\n * @param secretDir - dirname of the `.ait_relay` file (= nearest package.json dir).\n * @param fsDep - Injectable fs subset (same object passed to the write path).\n * @param logFn - Injectable log sink (used by callers for fold-in messages).\n */\nfunction ensureGitignoreEntries(\n secretDir: string,\n fsDep: RelaySecretFs,\n _logFn: (msg: string) => void,\n): GitignoreResult {\n const gi = join(secretDir, '.gitignore');\n\n // '.ait_urls' mirrors URLS_FILE_NAME from relay-url-store.ts. The constant is\n // inlined here (rather than imported) to avoid a circular module dependency:\n // relay-url-store.ts already imports nearestPackageJsonDir from this module,\n // so a reverse import would create a 2-way cycle.\n const names = [RELAY_SECRET_FILE_NAME, '.ait_urls'];\n\n let existing = '';\n if (fsDep.existsSync(gi)) {\n try {\n existing = fsDep.readFileSync(gi, 'utf8');\n } catch {\n // Unreadable .gitignore — degrade gracefully; secret generation is unaffected.\n return { injected: false, failed: true };\n }\n }\n\n const presentLines = new Set(existing.split(/\\r?\\n/).map((l) => l.trim()));\n const missing = names.filter((n) => !presentLines.has(n));\n\n if (missing.length === 0) {\n // Pure no-op: all entries already present (sdk-example case).\n return { injected: false, failed: false };\n }\n\n // Ensure we start on a fresh line when the existing content has no trailing newline.\n const prefix = existing === '' || existing.endsWith('\\n') ? '' : '\\n';\n try {\n fsDep.appendFileSync(gi, `${prefix}${missing.join('\\n')}\\n`);\n return { injected: true, failed: false };\n } catch {\n // Write failed (unwritable FS, read-only mount, etc.) — degrade gracefully.\n // SECRET-HANDLING: the error and path are swallowed, not surfaced.\n return { injected: false, failed: true };\n }\n}\n\nasync function readAndInject(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n rb: (n: number) => Buffer,\n): Promise<void> {\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패: ${msg}`);\n }\n\n if (!isValidRelayAuthSecret(stored)) {\n // Stored value is corrupt — re-mint over the same path.\n logFn('[@ait-co/devtools] relay 시크릿 파일의 값이 유효하지 않습니다. 재생성합니다.\\n');\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret, true);\n }\n\n // Inject into env — silent path (no log on successful reload).\n env.AIT_DEBUG_TOTP_SECRET = stored;\n\n // Ensure .gitignore covers both secret files even on the reload path (the\n // secret already exists so mintAndPersist will not run, but .ait_urls is\n // written unconditionally by the unplugin on every boot and must be ignored).\n // This is idempotent — if the entries are already present it is a pure no-op.\n ensureGitignoreEntries(dirname(secretPath), fs, logFn);\n}\n\nasync function mintAndPersist(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n rb: (n: number) => Buffer,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n /** When re-minting over a corrupt file, the existing file must be overwritten. */\n overwrite = false,\n): Promise<void> {\n // SECRET-HANDLING: the raw bytes are never written to any log or string other\n // than the persist file and the env variable.\n const secret = rb(32).toString('hex'); // 64 hex chars = 256 bits\n\n // Self-consistency guard: our own minted secret must pass validation.\n if (!isValidRelayAuthSecret(secret)) {\n throw new Error(\n '[@ait-co/devtools] 내부 오류: mint된 시크릿이 유효성 검사를 통과하지 못했습니다.',\n );\n }\n\n // Write a SINGLE file into the already-existing project directory — no\n // directory is created. `O_EXCL` (flag 'wx') makes the create exclusive so a\n // concurrent process cannot be clobbered; on EEXIST we read the winner's value.\n // (When re-minting over a corrupt file we must overwrite, so use 'w'.)\n const flag = overwrite ? 'w' : 'wx';\n try {\n fs.writeFileSync(secretPath, secret, { mode: 0o600, flag });\n // Belt-and-suspenders: apply chmod after write in case umask relaxed the mode.\n fs.chmodSync(secretPath, 0o600);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'EEXIST') {\n // Race: another process already wrote the file — read their value.\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (readErr) {\n const msg = readErr instanceof Error ? readErr.message : String(readErr);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패(경합): ${msg}`);\n }\n if (!isValidRelayAuthSecret(stored)) {\n throw new Error('[@ait-co/devtools] relay 시크릿 파일이 경합 후에도 유효하지 않습니다.');\n }\n env.AIT_DEBUG_TOTP_SECRET = stored;\n return;\n }\n throw err;\n }\n\n // Inject into the current process env so the immediately following\n // assertRelayAuthConfigured() / buildRelayVerifyAuth() calls see the value.\n env.AIT_DEBUG_TOTP_SECRET = secret;\n\n // Idempotently inject .gitignore entries for the two secret files (#669).\n // Fold the result into the single first-mint announcement below so\n // logs.length === 1 is preserved (SECRET-HANDLING: no path/value surfaced).\n const giResult = ensureGitignoreEntries(dirname(secretPath), fs, logFn);\n\n // First-mint announcement (value never included — SECRET-HANDLING). The file\n // name is fixed (`.ait_relay`); we do not echo the resolved directory either.\n // The gitignore result is folded in here to keep the single-log invariant.\n const giLine = giResult.injected\n ? `프로젝트의 .gitignore에 .ait_relay / .ait_urls 무시 규칙을 추가했습니다.\\n`\n : giResult.failed\n ? `.gitignore 자동 갱신에 실패했습니다 — .ait_relay / .ait_urls 를 직접 추가하거나 비공개 repo를 권장합니다.\\n`\n : '';\n\n logFn(\n `[@ait-co/devtools] relay 인증 시크릿을 생성해 프로젝트의 ${RELAY_SECRET_FILE_NAME} 파일에 저장했습니다 (권한 0600).\\n` +\n `다음 실행부터 자동으로 사용됩니다. 직접 export할 필요 없습니다.\\n` +\n `팀이 같은 relay를 공유하려면 이 파일을 repo에 커밋하세요(비공개 repo 권장).\\n` +\n (giLine ? giLine : '') +\n `자세히: https://docs.aitc.dev/guides/relay-auth-totp\\n`,\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,MAAa,yBAAyB;;;;;;;;;;;;;;;;AAoFtC,SAAgB,sBACd,OACA,cACQ;CACR,IAAI,MAAM;AAEV,QAAO,MAAM;AACX,MAAI,aAAa,KAAK,KAAK,eAAe,CAAC,CACzC,QAAO;EAET,MAAM,SAAS,QAAQ,IAAI;AAC3B,MAAI,WAAW,IAGb,QAAO;AAET,QAAM;;;;;;;;;;AAWV,SAAgB,oBACd,OACA,cACQ;AACR,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;AAwGjF,eAAsB,wBAAwB,MAA+C;CAC3F,MAAM,EAAE,aAAa,MAAM,QAAQ,KAAK,IAAI,OAAO,YAAY,eAAe,QAAQ,QAAQ,EAAE;CAEhG,MAAM,QAA+B,SAAS,QAAgB,QAAQ,OAAO,MAAM,IAAI;CAEvF,MAAM,EAAE,2BAA2B,MAAM,OAAO;AAMhD,KAAI,uBAAuB,IAAI,sBAAsB,EAAE;AACrD,MAAI,gBAAgB,KAAA,GAAW;GAC7B,MAAM,UAAiC,SAAU,MAAM,OAAO;GAE9D,MAAM,kBAAkB,oBAAoB,aADO,iBAAiB,QAAQ,WACH;AACzE,4BACE,IAAI,uBACJ,iBACA,SACA,wBACA,MACD;;AAEH;;AAIF,KAAI,gBAAgB,KAAA,EAClB;CAGF,MAAM,KAA4B,SAAU,MAAM,OAAO;CAGzD,MAAM,aAAa,oBAAoB,aAFS,iBAAiB,GAAG,WAEH;AACjE,KAAI,CAAC,GAAG,WAAW,WAAW,CAC5B;CAGF,IAAI;AACJ,KAAI;AACF,WAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;SAC7C;AAIN;;AAIF,KAAI,CAAC,uBAAuB,OAAO,CACjC;AAGF,KAAI,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;AA+B9B,SAAS,yBACP,WACA,YACA,OACA,wBACA,OACM;AAEN,KAAI,CAAC,MAAM,WAAW,WAAW,CAC/B;CAGF,IAAI;AACJ,KAAI;AACF,WAAS,MAAM,aAAa,YAAY,OAAO,CAAC,MAAM;SAChD;AAEN;;AAIF,KAAI,CAAC,uBAAuB,OAAO,CACjC;AAIF,KAAI,cAAc,OAChB,OACE,iRAID"}
|
|
1
|
+
{"version":3,"file":"relay-secret-store-DhzAnnj-.js","names":[],"sources":["../src/mcp/relay-secret-store.ts"],"sourcesContent":["/**\n * Project-local relay TOTP secret store (#394 first-run auto-mint, #396 moved to\n * a project-local single file `.ait_relay`).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link ensureRelaySecret} — WRITE path, called ONLY from the unplugin\n * (env-2 relay boot). Mints a fresh secret on first run and persists it to\n * `<projectRoot>/.ait_relay` (0600). A single file — no directory is created.\n *\n * - {@link loadRelaySecretReadOnly} — READ-ONLY path, called from the MCP\n * daemon when switching into a relay environment. It NEVER mints, chmods, or\n * creates anything: it only reads an already-existing `.ait_relay` and injects\n * its value into `env`. A daemon that minted would defeat the #250 fail-fast\n * (the daemon is the verifier side — a self-minted secret would let a leaked\n * tunnel URL attach unauthenticated), so the daemon stays read-only.\n *\n * Why a per-session `projectRoot` instead of `process.cwd()`: the daemon cannot\n * trust its own cwd — agent-plugin spawns it via `npx` without `cwd`, so cwd is\n * frozen at Claude Code launch and a cwd-walk stops at the monorepo workspace\n * root (which always has a package.json). So the project root is supplied\n * per-debug-session through `start_debug`.\n *\n * SECRET-HANDLING: this module handles AIT_DEBUG_TOTP_SECRET — the raw value and\n * its length MUST NOT appear in any log, error message, stdout, stderr, or\n * assertion output. Only boolean pass/fail signals are safe to surface, and the\n * discovered file path is never logged either. The persist file is written mode\n * 0600.\n */\n\nimport { dirname, join } from 'node:path';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local secret file name (single file, not a directory). */\nexport const RELAY_SECRET_FILE_NAME = '.ait_relay';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surface\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link ensureRelaySecret} — injectable for tests. */\nexport interface RelaySecretFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n readFileSync(path: string, encoding: BufferEncoding): string;\n chmodSync(path: string, mode: number): void;\n existsSync(path: string): boolean;\n appendFileSync(path: string, data: string): void;\n}\n\n/**\n * Minimal fs subset needed by {@link loadRelaySecretReadOnly} — strictly the two\n * read-only operations. Deliberately omits writeFileSync/mkdirSync/chmodSync so\n * the daemon path cannot mutate the filesystem even by accident (the type\n * forbids it).\n */\nexport interface RelaySecretReadOnlyFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\nexport interface RelaySecretDeps {\n /**\n * Project root (typically Vite `server.config.root`). The `.ait_relay` file is\n * resolved against the nearest `package.json` directory at or above this path.\n * When omitted, the current working directory is used as the start point —\n * retained for back-compat/tests; the unplugin always passes it.\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Cryptographically secure random bytes. Defaults to node:crypto randomBytes. */\n randomBytes?: (n: number) => Buffer;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelaySecretFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Current working directory resolver (used only when `projectRoot` is omitted). */\n cwd?: () => string;\n /** Log function for first-mint announcement. Defaults to process.stderr.write. */\n log?: (msg: string) => void;\n}\n\nexport interface RelaySecretReadOnlyDeps {\n /**\n * Project root supplied per-debug-session via `start_debug`. The daemon reads\n * `<nearest package.json dir from projectRoot>/.ait_relay`. When omitted, the\n * loader is a no-op (the daemon has no anchor to read from).\n */\n projectRoot?: string;\n /** Process environment to read from and inject into. Defaults to process.env. */\n env?: NodeJS.ProcessEnv;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelaySecretReadOnlyFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n /** Optional log sink — never receives the secret value, length, or file path. */\n log?: (msg: string) => void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Walks upward from `start` and returns the nearest directory that contains a\n * `package.json`. Falls back to `start` itself when none is found (so a write\n * still lands somewhere deterministic).\n *\n * The write (unplugin) and read (daemon) sides use the SAME anchor so a secret\n * minted by `pnpm dev` is found by the daemon: real mini-apps keep\n * `vite.config.ts` and `package.json` in the same directory, so\n * `server.config.root === package.json-dir`. In a monorepo subdir the anchor is\n * the package's own directory — the one the daemon can also reach via the\n * per-session projectRoot.\n *\n * @param start - Directory to start the upward walk from.\n * @param existsSyncFn - Injectable existence check (defaults to node:fs).\n */\nexport function nearestPackageJsonDir(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n let dir = start;\n // Stop at the filesystem root (dirname of root === root).\n while (true) {\n if (existsSyncFn(join(dir, 'package.json'))) {\n return dir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n // Reached the filesystem root without finding a package.json — fall back\n // to the original start directory.\n return start;\n }\n dir = parent;\n }\n}\n\n/**\n * Absolute path to the project-local `.ait_relay` file for a given start\n * directory (resolved against the nearest package.json directory).\n *\n * Exported so tests can compute the expected path without duplicating the\n * resolution logic.\n */\nexport function relaySecretFilePath(\n start: string,\n existsSyncFn: (path: string) => boolean,\n): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), RELAY_SECRET_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — mint + persist\n// ---------------------------------------------------------------------------\n\n/**\n * Ensures `env.AIT_DEBUG_TOTP_SECRET` is set to a valid relay TOTP secret,\n * persisting a freshly-minted one to `<projectRoot>/.ait_relay` (0600) on first\n * run and loading it silently on subsequent runs.\n *\n * Writes a SINGLE file into the already-existing project directory — it never\n * creates a directory (so no `mkdirSync`/dir `chmod`). The file is created with\n * `O_EXCL` (`flag: 'wx'`) so a concurrent process cannot be clobbered; on the\n * EEXIST race the winner's value is read instead.\n *\n * Called ONLY from the unplugin (env-2 relay boot). The MCP daemon uses\n * {@link loadRelaySecretReadOnly} (read-only) — it must never mint.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function ensureRelaySecret(deps?: RelaySecretDeps): Promise<void> {\n const {\n projectRoot,\n env = process.env,\n randomBytes: randomBytesFn,\n fs: fsDep,\n existsSync: existsSyncDep,\n cwd: cwdFn,\n log,\n } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n // Lazily import isValidRelayAuthSecret to avoid pulling in node:crypto at\n // module-load time (keeps the import side-effect free).\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or earlier run wins).\n // But first check for a divergence between the env value and .ait_relay —\n // if they differ the relay will verify against the env value while QR/\n // deep-links carry codes derived from the file, causing silent 401s (#620).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n // We need fs to compare — resolve deps early just for the divergence check.\n // This mirrors the lazy-resolve block below but is hoisted here so we can\n // still early-return after the (possibly-emitted) warning.\n const fsEarly: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncEarly: (path: string) => boolean = existsSyncDep ?? fsEarly.existsSync;\n const startEarly = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPathEarly = relaySecretFilePath(startEarly, existsSyncEarly);\n warnIfEnvDiffersFromFile(\n env.AIT_DEBUG_TOTP_SECRET,\n secretPathEarly,\n fsEarly,\n isValidRelayAuthSecret,\n logFn,\n );\n return;\n }\n\n // Resolve injected or real dependencies lazily to keep the import graph clean.\n const rb: (n: number) => Buffer = randomBytesFn ?? (await import('node:crypto')).randomBytes;\n const fs: RelaySecretFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const start = projectRoot ?? (cwdFn ?? (() => process.cwd()))();\n const secretPath = relaySecretFilePath(start, existsSyncFn);\n\n // 2. Persist file exists — read and inject (silent reload).\n if (fs.existsSync(secretPath)) {\n return readAndInject(secretPath, fs, env, logFn, isValidRelayAuthSecret, rb);\n }\n\n // 3. Mint a fresh secret.\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret);\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never mints, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\n/**\n * Reads an already-existing `<projectRoot>/.ait_relay` and, if its contents are a\n * valid relay TOTP secret, injects them into `env.AIT_DEBUG_TOTP_SECRET`.\n *\n * Strictly READ-ONLY: it uses only `existsSync` + `readFileSync` and NEVER mints,\n * chmods, or creates files/directories. The daemon must not mint because it is\n * the relay verifier side — a self-minted secret would defeat the #250 fail-fast\n * (a leaked tunnel URL could then attach unauthenticated). If no valid secret is\n * found the function leaves `env` untouched and returns without throwing, so the\n * downstream `assertRelayAuthConfigured()` stays the single fail-fast.\n *\n * Resolution order:\n * 1. `env.AIT_DEBUG_TOTP_SECRET` already valid → no-op (operator export wins).\n * 2. `projectRoot` given → read `<nearest package.json dir>/.ait_relay`; inject\n * iff the contents pass {@link isValidRelayAuthSecret}.\n * 3. Otherwise (no projectRoot, file absent, or invalid) → silent no-op.\n *\n * SECRET-HANDLING: the read value is passed ONLY to the boolean predicate before\n * assignment; its value, length, and the discovered file path are never logged.\n *\n * @param deps - Optional dependency overrides for testing.\n */\nexport async function loadRelaySecretReadOnly(deps?: RelaySecretReadOnlyDeps): Promise<void> {\n const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep, log } = deps ?? {};\n\n const logFn: (msg: string) => void = log ?? ((msg: string) => process.stderr.write(msg));\n\n const { isValidRelayAuthSecret } = await import('./totp.js');\n\n // 1. Already configured — no-op (operator export or unplugin run wins).\n // But first check for a divergence between the env value and .ait_relay —\n // if they differ the relay will verify against the env value while QR/\n // deep-links carry codes derived from the file, causing silent 401s (#620).\n if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n if (projectRoot !== undefined) {\n const fsEarly: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncEarly: (path: string) => boolean = existsSyncDep ?? fsEarly.existsSync;\n const secretPathEarly = relaySecretFilePath(projectRoot, existsSyncEarly);\n warnIfEnvDiffersFromFile(\n env.AIT_DEBUG_TOTP_SECRET,\n secretPathEarly,\n fsEarly,\n isValidRelayAuthSecret,\n logFn,\n );\n }\n return;\n }\n\n // 2. No anchor → nothing to read.\n if (projectRoot === undefined) {\n return;\n }\n\n const fs: RelaySecretReadOnlyFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const secretPath = relaySecretFilePath(projectRoot, existsSyncFn);\n if (!fs.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable file (permissions, transient FS error) — stay silent and let\n // the downstream assert be the single fail-fast. SECRET-HANDLING: the error\n // and path are not surfaced.\n return;\n }\n\n // SECRET-HANDLING: the value flows only through the boolean predicate.\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n env.AIT_DEBUG_TOTP_SECRET = stored;\n}\n\n// ---------------------------------------------------------------------------\n// Internal helpers (not exported — single-use extracted for readability)\n// ---------------------------------------------------------------------------\n\n/**\n * Compares `envSecret` against the contents of `secretPath` (if the file\n * exists and contains a valid secret) and emits a single warning via `logFn`\n * when they differ.\n *\n * SECRET-HANDLING (hard rules — do NOT relax):\n * - The warning MUST NOT include either secret value, its length, a hash of\n * it, or the resolved file path.\n * - Only an inequality boolean drives the warning; no secret-derived data\n * enters the log message.\n * - If the file is absent, unreadable, or its contents are invalid the\n * function returns silently — no spurious noise.\n *\n * This helper is intentionally synchronous-like (reads via the injected fs)\n * so it can be called from within the async early-return guards without\n * introducing additional async hops.\n *\n * @param envSecret - The validated env value (caller must have confirmed it is\n * valid before calling).\n * @param secretPath - Absolute path to `.ait_relay` to read for comparison.\n * @param fsDep - Injectable fs subset (at minimum `existsSync` + `readFileSync`).\n * @param isValidRelayAuthSecret - Injectable predicate from totp.ts.\n * @param logFn - Injectable log sink; never receives a secret value.\n */\nfunction warnIfEnvDiffersFromFile(\n envSecret: string,\n secretPath: string,\n fsDep: RelaySecretReadOnlyFs,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n logFn: (msg: string) => void,\n): void {\n // File absent → nothing to compare.\n if (!fsDep.existsSync(secretPath)) {\n return;\n }\n\n let stored: string;\n try {\n stored = fsDep.readFileSync(secretPath, 'utf8').trim();\n } catch {\n // Unreadable — skip silently. SECRET-HANDLING: error and path not surfaced.\n return;\n }\n\n // Invalid stored contents → skip silently (no spurious noise).\n if (!isValidRelayAuthSecret(stored)) {\n return;\n }\n\n // Compare by equality only. Neither value nor path enters the log message.\n if (envSecret !== stored) {\n logFn(\n `[@ait-co/devtools] AIT_DEBUG_TOTP_SECRET (from environment) differs from the project-local relay secret; ` +\n `the relay will verify against the environment value. ` +\n `Remove .env/.env.local/exported AIT_DEBUG_TOTP_SECRET, or sync the file, ` +\n `so QR/deep-links and the relay agree.\\n`,\n );\n }\n}\n\n/**\n * Result of a {@link ensureGitignoreEntries} call.\n *\n * - `injected`: one or more missing lines were appended to `.gitignore`.\n * - `failed`: append was attempted but threw (unwritable FS) — secret generation\n * is never aborted on failure.\n * - Neither flag set: all lines were already present — pure no-op.\n */\ninterface GitignoreResult {\n injected: boolean;\n failed: boolean;\n}\n\n/**\n * Idempotently appends `.ait_relay` and `.ait_urls` ignore entries to\n * `<secretDir>/.gitignore`.\n *\n * Covers the commit-safety net for the auto-generated relay secrets (#669).\n * Both files live in `secretDir` (the nearest package.json directory), so\n * anchoring the `.gitignore` there ensures the ignore entry and the files it\n * guards always co-locate.\n *\n * Idempotency: reads the existing `.gitignore` (if any), splits into lines,\n * appends only names not already present (exact-match, trimmed). A project\n * that already has both entries (the sdk-example case) is a pure no-op — no\n * write, no log noise.\n *\n * On write failure (unwritable FS, read-only mount) the function returns\n * `{ injected: false, failed: true }` — it NEVER aborts secret generation.\n *\n * SECRET-HANDLING: this function only manipulates ignore-pattern strings and\n * the `.gitignore` path. It never reads, logs, or echoes any secret value.\n *\n * @param secretDir - dirname of the `.ait_relay` file (= nearest package.json dir).\n * @param fsDep - Injectable fs subset (same object passed to the write path).\n * @param logFn - Injectable log sink (used by callers for fold-in messages).\n */\nfunction ensureGitignoreEntries(\n secretDir: string,\n fsDep: RelaySecretFs,\n _logFn: (msg: string) => void,\n): GitignoreResult {\n const gi = join(secretDir, '.gitignore');\n\n // '.ait_urls' mirrors URLS_FILE_NAME from relay-url-store.ts. The constant is\n // inlined here (rather than imported) to avoid a circular module dependency:\n // relay-url-store.ts already imports nearestPackageJsonDir from this module,\n // so a reverse import would create a 2-way cycle.\n const names = [RELAY_SECRET_FILE_NAME, '.ait_urls'];\n\n let existing = '';\n if (fsDep.existsSync(gi)) {\n try {\n existing = fsDep.readFileSync(gi, 'utf8');\n } catch {\n // Unreadable .gitignore — degrade gracefully; secret generation is unaffected.\n return { injected: false, failed: true };\n }\n }\n\n const presentLines = new Set(existing.split(/\\r?\\n/).map((l) => l.trim()));\n const missing = names.filter((n) => !presentLines.has(n));\n\n if (missing.length === 0) {\n // Pure no-op: all entries already present (sdk-example case).\n return { injected: false, failed: false };\n }\n\n // Ensure we start on a fresh line when the existing content has no trailing newline.\n const prefix = existing === '' || existing.endsWith('\\n') ? '' : '\\n';\n try {\n fsDep.appendFileSync(gi, `${prefix}${missing.join('\\n')}\\n`);\n return { injected: true, failed: false };\n } catch {\n // Write failed (unwritable FS, read-only mount, etc.) — degrade gracefully.\n // SECRET-HANDLING: the error and path are swallowed, not surfaced.\n return { injected: false, failed: true };\n }\n}\n\nasync function readAndInject(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n rb: (n: number) => Buffer,\n): Promise<void> {\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패: ${msg}`);\n }\n\n if (!isValidRelayAuthSecret(stored)) {\n // Stored value is corrupt — re-mint over the same path.\n logFn('[@ait-co/devtools] relay 시크릿 파일의 값이 유효하지 않습니다. 재생성합니다.\\n');\n return mintAndPersist(secretPath, fs, env, rb, logFn, isValidRelayAuthSecret, true);\n }\n\n // Inject into env — silent path (no log on successful reload).\n env.AIT_DEBUG_TOTP_SECRET = stored;\n\n // Ensure .gitignore covers both secret files even on the reload path (the\n // secret already exists so mintAndPersist will not run, but .ait_urls is\n // written unconditionally by the unplugin on every boot and must be ignored).\n // This is idempotent — if the entries are already present it is a pure no-op.\n ensureGitignoreEntries(dirname(secretPath), fs, logFn);\n}\n\nasync function mintAndPersist(\n secretPath: string,\n fs: RelaySecretFs,\n env: NodeJS.ProcessEnv,\n rb: (n: number) => Buffer,\n logFn: (msg: string) => void,\n isValidRelayAuthSecret: (s: string | undefined) => s is string,\n /** When re-minting over a corrupt file, the existing file must be overwritten. */\n overwrite = false,\n): Promise<void> {\n // SECRET-HANDLING: the raw bytes are never written to any log or string other\n // than the persist file and the env variable.\n const secret = rb(32).toString('hex'); // 64 hex chars = 256 bits\n\n // Self-consistency guard: our own minted secret must pass validation.\n if (!isValidRelayAuthSecret(secret)) {\n throw new Error(\n '[@ait-co/devtools] 내부 오류: mint된 시크릿이 유효성 검사를 통과하지 못했습니다.',\n );\n }\n\n // Write a SINGLE file into the already-existing project directory — no\n // directory is created. `O_EXCL` (flag 'wx') makes the create exclusive so a\n // concurrent process cannot be clobbered; on EEXIST we read the winner's value.\n // (When re-minting over a corrupt file we must overwrite, so use 'w'.)\n const flag = overwrite ? 'w' : 'wx';\n try {\n fs.writeFileSync(secretPath, secret, { mode: 0o600, flag });\n // Belt-and-suspenders: apply chmod after write in case umask relaxed the mode.\n fs.chmodSync(secretPath, 0o600);\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'EEXIST') {\n // Race: another process already wrote the file — read their value.\n let stored: string;\n try {\n stored = fs.readFileSync(secretPath, 'utf8').trim();\n } catch (readErr) {\n const msg = readErr instanceof Error ? readErr.message : String(readErr);\n throw new Error(`[@ait-co/devtools] relay 시크릿 파일 읽기 실패(경합): ${msg}`);\n }\n if (!isValidRelayAuthSecret(stored)) {\n throw new Error('[@ait-co/devtools] relay 시크릿 파일이 경합 후에도 유효하지 않습니다.');\n }\n env.AIT_DEBUG_TOTP_SECRET = stored;\n return;\n }\n throw err;\n }\n\n // Inject into the current process env so the immediately following\n // assertRelayAuthConfigured() / buildRelayVerifyAuth() calls see the value.\n env.AIT_DEBUG_TOTP_SECRET = secret;\n\n // Idempotently inject .gitignore entries for the two secret files (#669).\n // Fold the result into the single first-mint announcement below so\n // logs.length === 1 is preserved (SECRET-HANDLING: no path/value surfaced).\n const giResult = ensureGitignoreEntries(dirname(secretPath), fs, logFn);\n\n // First-mint announcement (value never included — SECRET-HANDLING). The file\n // name is fixed (`.ait_relay`); we do not echo the resolved directory either.\n // The gitignore result is folded in here to keep the single-log invariant.\n const giLine = giResult.injected\n ? `프로젝트의 .gitignore에 .ait_relay / .ait_urls 무시 규칙을 추가했습니다.\\n`\n : giResult.failed\n ? `.gitignore 자동 갱신에 실패했습니다 — .ait_relay / .ait_urls 를 직접 추가하거나 비공개 repo를 권장합니다.\\n`\n : '';\n\n logFn(\n `[@ait-co/devtools] relay 인증 시크릿을 생성해 프로젝트의 ${RELAY_SECRET_FILE_NAME} 파일에 저장했습니다 (권한 0600).\\n` +\n `다음 실행부터 자동으로 사용됩니다. 직접 export할 필요 없습니다.\\n` +\n `팀이 같은 relay를 공유하려면 이 파일을 repo에 커밋하세요(비공개 repo 권장).\\n` +\n (giLine ? giLine : '') +\n `자세히: https://docs.aitc.dev/guides/relay-auth-totp\\n`,\n );\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,MAAa,yBAAyB;;;;;;;;;;;;;;;;AAoFtC,SAAgB,sBACd,OACA,cACQ;CACR,IAAI,MAAM;AAEV,QAAO,MAAM;AACX,MAAI,aAAa,KAAK,KAAK,eAAe,CAAC,CACzC,QAAO;EAET,MAAM,SAAS,QAAQ,IAAI;AAC3B,MAAI,WAAW,IAGb,QAAO;AAET,QAAM;;;;;;;;;;AAWV,SAAgB,oBACd,OACA,cACQ;AACR,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,uBAAuB;;;;;;;;;;;;;;;;;;;;;;;;AAwGjF,eAAsB,wBAAwB,MAA+C;CAC3F,MAAM,EAAE,aAAa,MAAM,QAAQ,KAAK,IAAI,OAAO,YAAY,eAAe,QAAQ,QAAQ,EAAE;CAEhG,MAAM,QAA+B,SAAS,QAAgB,QAAQ,OAAO,MAAM,IAAI;CAEvF,MAAM,EAAE,2BAA2B,MAAM,OAAO;AAMhD,KAAI,uBAAuB,IAAI,sBAAsB,EAAE;AACrD,MAAI,gBAAgB,KAAA,GAAW;GAC7B,MAAM,UAAiC,SAAU,MAAM,OAAO;GAE9D,MAAM,kBAAkB,oBAAoB,aADO,iBAAiB,QAAQ,WACH;AACzE,4BACE,IAAI,uBACJ,iBACA,SACA,wBACA,MACD;;AAEH;;AAIF,KAAI,gBAAgB,KAAA,EAClB;CAGF,MAAM,KAA4B,SAAU,MAAM,OAAO;CAGzD,MAAM,aAAa,oBAAoB,aAFS,iBAAiB,GAAG,WAEH;AACjE,KAAI,CAAC,GAAG,WAAW,WAAW,CAC5B;CAGF,IAAI;AACJ,KAAI;AACF,WAAS,GAAG,aAAa,YAAY,OAAO,CAAC,MAAM;SAC7C;AAIN;;AAIF,KAAI,CAAC,uBAAuB,OAAO,CACjC;AAGF,KAAI,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;AA+B9B,SAAS,yBACP,WACA,YACA,OACA,wBACA,OACM;AAEN,KAAI,CAAC,MAAM,WAAW,WAAW,CAC/B;CAGF,IAAI;AACJ,KAAI;AACF,WAAS,MAAM,aAAa,YAAY,OAAO,CAAC,MAAM;SAChD;AAEN;;AAIF,KAAI,CAAC,uBAAuB,OAAO,CACjC;AAIF,KAAI,cAAc,OAChB,OACE,iRAID"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { n as nearestPackageJsonDir } from "./relay-secret-store-DhzAnnj-.js";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
//#region src/mcp/relay-url-store.ts
|
|
5
5
|
/**
|
|
@@ -120,4 +120,4 @@ async function readRelayUrls(deps) {
|
|
|
120
120
|
//#endregion
|
|
121
121
|
export { readRelayUrls };
|
|
122
122
|
|
|
123
|
-
//# sourceMappingURL=relay-url-store-
|
|
123
|
+
//# sourceMappingURL=relay-url-store-CwKT7i04.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"relay-url-store-D3H067u0.js","names":[],"sources":["../src/mcp/relay-url-store.ts"],"sourcesContent":["/**\n * Project-local ephemeral URL store (#424).\n *\n * Environment-2 (\"AITC Sandbox PWA\") cold-start requires two ephemeral URLs:\n * - `relayBaseUrl` — the CDP relay's https base (was `AIT_RELAY_BASE_URL`)\n * - `tunnelBaseUrl` — the app's https tunnel base (was `AIT_TUNNEL_BASE_URL`)\n *\n * Quick-tunnel URLs change every run. Previously the user had to copy-paste them\n * into env vars on every cold-start — a single typo silently broke attach. This\n * module replaces that manual hand-off with a file-based discovery pattern that\n * exactly mirrors the `.ait_relay` TOTP-secret store (relay-secret-store.ts).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link writeRelayUrls} — WRITE path, called ONLY from the unplugin\n * (env-2 tunnel boot). Writes JSON to `<projectRoot>/.ait_urls` (0600) on\n * every boot (`flag: 'w'` — overwrite). A single file — no directory is\n * created.\n *\n * - {@link readRelayUrls} — READ-ONLY path, called from the MCP daemon as a\n * fallback when `AIT_RELAY_BASE_URL`/`AIT_TUNNEL_BASE_URL` are not set. It\n * NEVER writes, chmods, or creates anything: it only reads an existing\n * `.ait_urls`. On any failure (missing file / bad JSON / wrong shape) it\n * returns `null` silently, letting the downstream assertion be the single\n * fail-fast.\n *\n * - {@link deleteRelayUrls} — called from the unplugin `cleanup()` on\n * teardown (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing\n * at a dead tunnel would cause the MCP daemon to attempt a doomed attach on\n * the next cold-start — deletion is non-negotiable. Silently swallows all\n * errors.\n *\n * Design note: the env vars (`AIT_RELAY_BASE_URL`, `AIT_TUNNEL_BASE_URL`) are\n * PRESERVED as operator overrides. The file is the fallback — env wins.\n *\n * Why `nearestPackageJsonDir` instead of `process.cwd()`: see relay-secret-store.ts.\n * The unplugin (writer) and the MCP daemon (reader) both anchor to the nearest\n * package.json from their respective `projectRoot` inputs, ensuring both sides\n * find the same file.\n *\n * SECRET-HANDLING: `relayBaseUrl` and `tunnelBaseUrl` carry the relay/tunnel host\n * — same sensitivity class as `.ait_relay`. The raw URL values, partial values,\n * and the resolved file path MUST NOT appear in any log, error message, stdout,\n * stderr, or assertion output anywhere in this module or at its call sites.\n * Only boolean pass/fail signals are safe to surface. The file is written mode\n * 0600.\n */\n\nimport { join } from 'node:path';\nimport { nearestPackageJsonDir } from './relay-secret-store.js';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local ephemeral URL file name (single file, not a directory). */\nexport const URLS_FILE_NAME = '.ait_urls';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surfaces\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link writeRelayUrls} — injectable for tests. */\nexport interface RelayUrlWriteFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n existsSync(path: string): boolean;\n}\n\n/** Minimal fs subset needed by {@link readRelayUrls} — injectable for tests. */\nexport interface RelayUrlReadFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\n/** Minimal fs subset needed by {@link deleteRelayUrls} — injectable for tests. */\nexport interface RelayUrlDeleteFs {\n existsSync(path: string): boolean;\n unlinkSync(path: string): void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helper\n// ---------------------------------------------------------------------------\n\n/**\n * Absolute path to the project-local `.ait_urls` 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 urlsFilePath(start: string, existsSyncFn: (path: string) => boolean): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), URLS_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — overwrite on every boot\n// ---------------------------------------------------------------------------\n\nexport interface WriteRelayUrlsDeps {\n /** Project root (typically Vite `server.config.root`). */\n projectRoot: string;\n /**\n * The CDP relay's https base URL (same value as `AIT_RELAY_BASE_URL`).\n * Omit when the relay was not started (e.g. `cdp:false`).\n * SECRET-HANDLING: never log this value.\n */\n relayBaseUrl?: string;\n /**\n * The CDP relay's LOCAL http base URL (`http://127.0.0.1:<relay-port>`).\n * Set when `cdp: true` and the local relay port is known. Used by the MCP\n * daemon's `bootExternalRelayFamily` to build the Chii inspector URL against\n * the local relay rather than the cloudflare tunnel, so front_end page load\n * and the client WS leg do not traverse the tunnel (issue #530).\n * SECRET-HANDLING: local loopback URL — no tunnel host, safe to surface.\n */\n relayLocalUrl?: string;\n /**\n * The app tunnel's https base URL (same value as `AIT_TUNNEL_BASE_URL`).\n * Omit when no tunnel URL is available.\n * SECRET-HANDLING: never log this value.\n */\n tunnelBaseUrl?: string;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelayUrlWriteFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Writes `{ relayBaseUrl, tunnelBaseUrl }` (omitting absent keys) to\n * `<projectRoot>/.ait_urls` (mode 0600). Uses `flag: 'w'` (overwrite) because\n * URLs are ephemeral — a fresh URL replaces the previous one on every boot.\n *\n * Unlike the `.ait_relay` secret store this does NOT use `O_EXCL` (`'wx'`):\n * there is no race concern here (only the unplugin writes this file) and the\n * URL must be fresh on every cold-start.\n *\n * Called ONLY from the unplugin (env-2 tunnel boot). The MCP daemon uses\n * {@link readRelayUrls} (read-only) — it must never write.\n *\n * SECRET-HANDLING: URL values are never logged.\n */\nexport async function writeRelayUrls(deps: WriteRelayUrlsDeps): Promise<void> {\n const { projectRoot, relayBaseUrl, tunnelBaseUrl, fs: fsDep, existsSync: existsSyncDep } = deps;\n\n const fs: RelayUrlWriteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n // Build the payload — omit keys whose values are absent.\n const payload: { relayBaseUrl?: string; relayLocalUrl?: string; tunnelBaseUrl?: string } = {};\n if (typeof relayBaseUrl === 'string' && relayBaseUrl !== '') {\n payload.relayBaseUrl = relayBaseUrl;\n }\n const { relayLocalUrl } = deps;\n if (typeof relayLocalUrl === 'string' && relayLocalUrl !== '') {\n payload.relayLocalUrl = relayLocalUrl;\n }\n if (typeof tunnelBaseUrl === 'string' && tunnelBaseUrl !== '') {\n payload.tunnelBaseUrl = tunnelBaseUrl;\n }\n\n // SECRET-HANDLING: JSON content (which includes URL values) is written to\n // the file only — never to any log, stdout, or stderr.\n const data = JSON.stringify(payload);\n\n // Overwrite on every boot (`flag: 'w'`) — URLs are ephemeral.\n fs.writeFileSync(filePath, data, { mode: 0o600, flag: 'w' });\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never writes, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\nexport interface ReadRelayUrlsDeps {\n /** Project root supplied per-debug-session. When omitted, returns `null`. */\n projectRoot?: string;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelayUrlReadFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Reads `<projectRoot>/.ait_urls` and returns the stored URLs, or `null` on\n * any failure.\n *\n * Strictly READ-ONLY: only `existsSync` + `readFileSync`. Never writes,\n * chmods, or creates files/directories.\n *\n * Returns `null` (silently, no throw, no log) on:\n * - missing `projectRoot`\n * - missing `.ait_urls` file\n * - unreadable file (permissions, transient FS error)\n * - invalid JSON\n * - wrong shape (non-object, non-string values)\n *\n * Trims string values before returning. Ignores non-string fields.\n *\n * SECRET-HANDLING: URL values and the file path are never logged.\n */\nexport async function readRelayUrls(deps?: ReadRelayUrlsDeps): Promise<{\n relayBaseUrl?: string;\n relayLocalUrl?: string;\n tunnelBaseUrl?: string;\n} | null> {\n const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps ?? {};\n\n if (projectRoot === undefined) {\n return null;\n }\n\n const fs: RelayUrlReadFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n if (!fs.existsSync(filePath)) {\n return null;\n }\n\n let raw: string;\n try {\n raw = fs.readFileSync(filePath, 'utf8');\n } catch {\n // Unreadable file — silent no-op, let the downstream assert be the fail-fast.\n // SECRET-HANDLING: the error and path are not surfaced.\n return null;\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n // Invalid JSON — silent no-op.\n return null;\n }\n\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return null;\n }\n\n const obj = parsed as Record<string, unknown>;\n const result: { relayBaseUrl?: string; relayLocalUrl?: string; tunnelBaseUrl?: string } = {};\n\n const relay = obj.relayBaseUrl;\n if (typeof relay === 'string') {\n const trimmed = relay.trim();\n if (trimmed !== '') result.relayBaseUrl = trimmed;\n }\n\n const relayLocal = obj.relayLocalUrl;\n if (typeof relayLocal === 'string') {\n const trimmed = relayLocal.trim();\n if (trimmed !== '') result.relayLocalUrl = trimmed;\n }\n\n const tunnel = obj.tunnelBaseUrl;\n if (typeof tunnel === 'string') {\n const trimmed = tunnel.trim();\n if (trimmed !== '') result.tunnelBaseUrl = trimmed;\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// DELETE path (unplugin cleanup only)\n// ---------------------------------------------------------------------------\n\nexport interface DeleteRelayUrlsDeps {\n /** Project root. */\n projectRoot: string;\n /** Filesystem operations. Defaults to node:fs (existsSync + unlinkSync). */\n fs?: RelayUrlDeleteFs;\n /** existsSync used to resolve the nearest package.json directory. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Removes `<projectRoot>/.ait_urls` if present. Silently swallows ENOENT and\n * any other error so cleanup always succeeds.\n *\n * Called ONLY from the unplugin's `cleanup()` on `httpServer 'close'` + signals\n * (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing at a dead\n * tunnel would cause the MCP daemon to attempt a doomed attach on the next\n * cold-start — deletion is non-negotiable.\n *\n * SECRET-HANDLING: the file path is never logged.\n */\nexport async function deleteRelayUrls(deps: DeleteRelayUrlsDeps): Promise<void> {\n const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps;\n\n const fs: RelayUrlDeleteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n try {\n if (fs.existsSync(filePath)) {\n fs.unlinkSync(filePath);\n }\n } catch {\n // Swallow ENOENT and any other error — cleanup is best-effort.\n // SECRET-HANDLING: the path is not logged.\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwDA,MAAa,iBAAiB;;;;;;;;AAmC9B,SAAgB,aAAa,OAAe,cAAiD;AAC3F,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,eAAe;;;;;;;;;;;;;;;;;;;;AA+GzE,eAAsB,cAAc,MAI1B;CACR,MAAM,EAAE,aAAa,IAAI,OAAO,YAAY,kBAAkB,QAAQ,EAAE;AAExE,KAAI,gBAAgB,KAAA,EAClB,QAAO;CAGT,MAAM,KAAqB,SAAU,MAAM,OAAO;CAGlD,MAAM,WAAW,aAAa,aAFkB,iBAAiB,GAAG,WAEZ;AAExD,KAAI,CAAC,GAAG,WAAW,SAAS,CAC1B,QAAO;CAGT,IAAI;AACJ,KAAI;AACF,QAAM,GAAG,aAAa,UAAU,OAAO;SACjC;AAGN,SAAO;;CAGT,IAAI;AACJ,KAAI;AACF,WAAS,KAAK,MAAM,IAAI;SAClB;AAEN,SAAO;;AAGT,KAAI,OAAO,WAAW,YAAY,WAAW,QAAQ,MAAM,QAAQ,OAAO,CACxE,QAAO;CAGT,MAAM,MAAM;CACZ,MAAM,SAAoF,EAAE;CAE5F,MAAM,QAAQ,IAAI;AAClB,KAAI,OAAO,UAAU,UAAU;EAC7B,MAAM,UAAU,MAAM,MAAM;AAC5B,MAAI,YAAY,GAAI,QAAO,eAAe;;CAG5C,MAAM,aAAa,IAAI;AACvB,KAAI,OAAO,eAAe,UAAU;EAClC,MAAM,UAAU,WAAW,MAAM;AACjC,MAAI,YAAY,GAAI,QAAO,gBAAgB;;CAG7C,MAAM,SAAS,IAAI;AACnB,KAAI,OAAO,WAAW,UAAU;EAC9B,MAAM,UAAU,OAAO,MAAM;AAC7B,MAAI,YAAY,GAAI,QAAO,gBAAgB;;AAG7C,QAAO"}
|
|
1
|
+
{"version":3,"file":"relay-url-store-CwKT7i04.js","names":[],"sources":["../src/mcp/relay-url-store.ts"],"sourcesContent":["/**\n * Project-local ephemeral URL store (#424).\n *\n * Environment-2 (\"AITC Sandbox PWA\") cold-start requires two ephemeral URLs:\n * - `relayBaseUrl` — the CDP relay's https base (was `AIT_RELAY_BASE_URL`)\n * - `tunnelBaseUrl` — the app's https tunnel base (was `AIT_TUNNEL_BASE_URL`)\n *\n * Quick-tunnel URLs change every run. Previously the user had to copy-paste them\n * into env vars on every cold-start — a single typo silently broke attach. This\n * module replaces that manual hand-off with a file-based discovery pattern that\n * exactly mirrors the `.ait_relay` TOTP-secret store (relay-secret-store.ts).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link writeRelayUrls} — WRITE path, called ONLY from the unplugin\n * (env-2 tunnel boot). Writes JSON to `<projectRoot>/.ait_urls` (0600) on\n * every boot (`flag: 'w'` — overwrite). A single file — no directory is\n * created.\n *\n * - {@link readRelayUrls} — READ-ONLY path, called from the MCP daemon as a\n * fallback when `AIT_RELAY_BASE_URL`/`AIT_TUNNEL_BASE_URL` are not set. It\n * NEVER writes, chmods, or creates anything: it only reads an existing\n * `.ait_urls`. On any failure (missing file / bad JSON / wrong shape) it\n * returns `null` silently, letting the downstream assertion be the single\n * fail-fast.\n *\n * - {@link deleteRelayUrls} — called from the unplugin `cleanup()` on\n * teardown (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing\n * at a dead tunnel would cause the MCP daemon to attempt a doomed attach on\n * the next cold-start — deletion is non-negotiable. Silently swallows all\n * errors.\n *\n * Design note: the env vars (`AIT_RELAY_BASE_URL`, `AIT_TUNNEL_BASE_URL`) are\n * PRESERVED as operator overrides. The file is the fallback — env wins.\n *\n * Why `nearestPackageJsonDir` instead of `process.cwd()`: see relay-secret-store.ts.\n * The unplugin (writer) and the MCP daemon (reader) both anchor to the nearest\n * package.json from their respective `projectRoot` inputs, ensuring both sides\n * find the same file.\n *\n * SECRET-HANDLING: `relayBaseUrl` and `tunnelBaseUrl` carry the relay/tunnel host\n * — same sensitivity class as `.ait_relay`. The raw URL values, partial values,\n * and the resolved file path MUST NOT appear in any log, error message, stdout,\n * stderr, or assertion output anywhere in this module or at its call sites.\n * Only boolean pass/fail signals are safe to surface. The file is written mode\n * 0600.\n */\n\nimport { join } from 'node:path';\nimport { nearestPackageJsonDir } from './relay-secret-store.js';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local ephemeral URL file name (single file, not a directory). */\nexport const URLS_FILE_NAME = '.ait_urls';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surfaces\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link writeRelayUrls} — injectable for tests. */\nexport interface RelayUrlWriteFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n existsSync(path: string): boolean;\n}\n\n/** Minimal fs subset needed by {@link readRelayUrls} — injectable for tests. */\nexport interface RelayUrlReadFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\n/** Minimal fs subset needed by {@link deleteRelayUrls} — injectable for tests. */\nexport interface RelayUrlDeleteFs {\n existsSync(path: string): boolean;\n unlinkSync(path: string): void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helper\n// ---------------------------------------------------------------------------\n\n/**\n * Absolute path to the project-local `.ait_urls` 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 urlsFilePath(start: string, existsSyncFn: (path: string) => boolean): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), URLS_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — overwrite on every boot\n// ---------------------------------------------------------------------------\n\nexport interface WriteRelayUrlsDeps {\n /** Project root (typically Vite `server.config.root`). */\n projectRoot: string;\n /**\n * The CDP relay's https base URL (same value as `AIT_RELAY_BASE_URL`).\n * Omit when the relay was not started (e.g. `cdp:false`).\n * SECRET-HANDLING: never log this value.\n */\n relayBaseUrl?: string;\n /**\n * The CDP relay's LOCAL http base URL (`http://127.0.0.1:<relay-port>`).\n * Set when `cdp: true` and the local relay port is known. Used by the MCP\n * daemon's `bootExternalRelayFamily` to build the Chii inspector URL against\n * the local relay rather than the cloudflare tunnel, so front_end page load\n * and the client WS leg do not traverse the tunnel (issue #530).\n * SECRET-HANDLING: local loopback URL — no tunnel host, safe to surface.\n */\n relayLocalUrl?: string;\n /**\n * The app tunnel's https base URL (same value as `AIT_TUNNEL_BASE_URL`).\n * Omit when no tunnel URL is available.\n * SECRET-HANDLING: never log this value.\n */\n tunnelBaseUrl?: string;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelayUrlWriteFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Writes `{ relayBaseUrl, tunnelBaseUrl }` (omitting absent keys) to\n * `<projectRoot>/.ait_urls` (mode 0600). Uses `flag: 'w'` (overwrite) because\n * URLs are ephemeral — a fresh URL replaces the previous one on every boot.\n *\n * Unlike the `.ait_relay` secret store this does NOT use `O_EXCL` (`'wx'`):\n * there is no race concern here (only the unplugin writes this file) and the\n * URL must be fresh on every cold-start.\n *\n * Called ONLY from the unplugin (env-2 tunnel boot). The MCP daemon uses\n * {@link readRelayUrls} (read-only) — it must never write.\n *\n * SECRET-HANDLING: URL values are never logged.\n */\nexport async function writeRelayUrls(deps: WriteRelayUrlsDeps): Promise<void> {\n const { projectRoot, relayBaseUrl, tunnelBaseUrl, fs: fsDep, existsSync: existsSyncDep } = deps;\n\n const fs: RelayUrlWriteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n // Build the payload — omit keys whose values are absent.\n const payload: { relayBaseUrl?: string; relayLocalUrl?: string; tunnelBaseUrl?: string } = {};\n if (typeof relayBaseUrl === 'string' && relayBaseUrl !== '') {\n payload.relayBaseUrl = relayBaseUrl;\n }\n const { relayLocalUrl } = deps;\n if (typeof relayLocalUrl === 'string' && relayLocalUrl !== '') {\n payload.relayLocalUrl = relayLocalUrl;\n }\n if (typeof tunnelBaseUrl === 'string' && tunnelBaseUrl !== '') {\n payload.tunnelBaseUrl = tunnelBaseUrl;\n }\n\n // SECRET-HANDLING: JSON content (which includes URL values) is written to\n // the file only — never to any log, stdout, or stderr.\n const data = JSON.stringify(payload);\n\n // Overwrite on every boot (`flag: 'w'`) — URLs are ephemeral.\n fs.writeFileSync(filePath, data, { mode: 0o600, flag: 'w' });\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never writes, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\nexport interface ReadRelayUrlsDeps {\n /** Project root supplied per-debug-session. When omitted, returns `null`. */\n projectRoot?: string;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelayUrlReadFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Reads `<projectRoot>/.ait_urls` and returns the stored URLs, or `null` on\n * any failure.\n *\n * Strictly READ-ONLY: only `existsSync` + `readFileSync`. Never writes,\n * chmods, or creates files/directories.\n *\n * Returns `null` (silently, no throw, no log) on:\n * - missing `projectRoot`\n * - missing `.ait_urls` file\n * - unreadable file (permissions, transient FS error)\n * - invalid JSON\n * - wrong shape (non-object, non-string values)\n *\n * Trims string values before returning. Ignores non-string fields.\n *\n * SECRET-HANDLING: URL values and the file path are never logged.\n */\nexport async function readRelayUrls(deps?: ReadRelayUrlsDeps): Promise<{\n relayBaseUrl?: string;\n relayLocalUrl?: string;\n tunnelBaseUrl?: string;\n} | null> {\n const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps ?? {};\n\n if (projectRoot === undefined) {\n return null;\n }\n\n const fs: RelayUrlReadFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n if (!fs.existsSync(filePath)) {\n return null;\n }\n\n let raw: string;\n try {\n raw = fs.readFileSync(filePath, 'utf8');\n } catch {\n // Unreadable file — silent no-op, let the downstream assert be the fail-fast.\n // SECRET-HANDLING: the error and path are not surfaced.\n return null;\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n // Invalid JSON — silent no-op.\n return null;\n }\n\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return null;\n }\n\n const obj = parsed as Record<string, unknown>;\n const result: { relayBaseUrl?: string; relayLocalUrl?: string; tunnelBaseUrl?: string } = {};\n\n const relay = obj.relayBaseUrl;\n if (typeof relay === 'string') {\n const trimmed = relay.trim();\n if (trimmed !== '') result.relayBaseUrl = trimmed;\n }\n\n const relayLocal = obj.relayLocalUrl;\n if (typeof relayLocal === 'string') {\n const trimmed = relayLocal.trim();\n if (trimmed !== '') result.relayLocalUrl = trimmed;\n }\n\n const tunnel = obj.tunnelBaseUrl;\n if (typeof tunnel === 'string') {\n const trimmed = tunnel.trim();\n if (trimmed !== '') result.tunnelBaseUrl = trimmed;\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// DELETE path (unplugin cleanup only)\n// ---------------------------------------------------------------------------\n\nexport interface DeleteRelayUrlsDeps {\n /** Project root. */\n projectRoot: string;\n /** Filesystem operations. Defaults to node:fs (existsSync + unlinkSync). */\n fs?: RelayUrlDeleteFs;\n /** existsSync used to resolve the nearest package.json directory. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Removes `<projectRoot>/.ait_urls` if present. Silently swallows ENOENT and\n * any other error so cleanup always succeeds.\n *\n * Called ONLY from the unplugin's `cleanup()` on `httpServer 'close'` + signals\n * (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing at a dead\n * tunnel would cause the MCP daemon to attempt a doomed attach on the next\n * cold-start — deletion is non-negotiable.\n *\n * SECRET-HANDLING: the file path is never logged.\n */\nexport async function deleteRelayUrls(deps: DeleteRelayUrlsDeps): Promise<void> {\n const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps;\n\n const fs: RelayUrlDeleteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n try {\n if (fs.existsSync(filePath)) {\n fs.unlinkSync(filePath);\n }\n } catch {\n // Swallow ENOENT and any other error — cleanup is best-effort.\n // SECRET-HANDLING: the path is not logged.\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwDA,MAAa,iBAAiB;;;;;;;;AAmC9B,SAAgB,aAAa,OAAe,cAAiD;AAC3F,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,eAAe;;;;;;;;;;;;;;;;;;;;AA+GzE,eAAsB,cAAc,MAI1B;CACR,MAAM,EAAE,aAAa,IAAI,OAAO,YAAY,kBAAkB,QAAQ,EAAE;AAExE,KAAI,gBAAgB,KAAA,EAClB,QAAO;CAGT,MAAM,KAAqB,SAAU,MAAM,OAAO;CAGlD,MAAM,WAAW,aAAa,aAFkB,iBAAiB,GAAG,WAEZ;AAExD,KAAI,CAAC,GAAG,WAAW,SAAS,CAC1B,QAAO;CAGT,IAAI;AACJ,KAAI;AACF,QAAM,GAAG,aAAa,UAAU,OAAO;SACjC;AAGN,SAAO;;CAGT,IAAI;AACJ,KAAI;AACF,WAAS,KAAK,MAAM,IAAI;SAClB;AAEN,SAAO;;AAGT,KAAI,OAAO,WAAW,YAAY,WAAW,QAAQ,MAAM,QAAQ,OAAO,CACxE,QAAO;CAGT,MAAM,MAAM;CACZ,MAAM,SAAoF,EAAE;CAE5F,MAAM,QAAQ,IAAI;AAClB,KAAI,OAAO,UAAU,UAAU;EAC7B,MAAM,UAAU,MAAM,MAAM;AAC5B,MAAI,YAAY,GAAI,QAAO,eAAe;;CAG5C,MAAM,aAAa,IAAI;AACvB,KAAI,OAAO,eAAe,UAAU;EAClC,MAAM,UAAU,WAAW,MAAM;AACjC,MAAI,YAAY,GAAI,QAAO,gBAAgB;;CAG7C,MAAM,SAAS,IAAI;AACnB,KAAI,OAAO,WAAW,UAAU;EAC9B,MAAM,UAAU,OAAO,MAAM;AAC7B,MAAI,YAAY,GAAI,QAAO,gBAAgB;;AAG7C,QAAO"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { nearestPackageJsonDir } from "./relay-secret-store-
|
|
2
|
+
import { nearestPackageJsonDir } from "./relay-secret-store-CFc9n0OA.js";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
//#region src/mcp/relay-url-store.ts
|
|
5
5
|
/**
|
|
@@ -120,4 +120,4 @@ async function readRelayUrls(deps) {
|
|
|
120
120
|
//#endregion
|
|
121
121
|
export { readRelayUrls };
|
|
122
122
|
|
|
123
|
-
//# sourceMappingURL=relay-url-store-
|
|
123
|
+
//# sourceMappingURL=relay-url-store-DGQ-HPQC.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"relay-url-store-uyVNe-Az.js","names":[],"sources":["../src/mcp/relay-url-store.ts"],"sourcesContent":["/**\n * Project-local ephemeral URL store (#424).\n *\n * Environment-2 (\"AITC Sandbox PWA\") cold-start requires two ephemeral URLs:\n * - `relayBaseUrl` — the CDP relay's https base (was `AIT_RELAY_BASE_URL`)\n * - `tunnelBaseUrl` — the app's https tunnel base (was `AIT_TUNNEL_BASE_URL`)\n *\n * Quick-tunnel URLs change every run. Previously the user had to copy-paste them\n * into env vars on every cold-start — a single typo silently broke attach. This\n * module replaces that manual hand-off with a file-based discovery pattern that\n * exactly mirrors the `.ait_relay` TOTP-secret store (relay-secret-store.ts).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link writeRelayUrls} — WRITE path, called ONLY from the unplugin\n * (env-2 tunnel boot). Writes JSON to `<projectRoot>/.ait_urls` (0600) on\n * every boot (`flag: 'w'` — overwrite). A single file — no directory is\n * created.\n *\n * - {@link readRelayUrls} — READ-ONLY path, called from the MCP daemon as a\n * fallback when `AIT_RELAY_BASE_URL`/`AIT_TUNNEL_BASE_URL` are not set. It\n * NEVER writes, chmods, or creates anything: it only reads an existing\n * `.ait_urls`. On any failure (missing file / bad JSON / wrong shape) it\n * returns `null` silently, letting the downstream assertion be the single\n * fail-fast.\n *\n * - {@link deleteRelayUrls} — called from the unplugin `cleanup()` on\n * teardown (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing\n * at a dead tunnel would cause the MCP daemon to attempt a doomed attach on\n * the next cold-start — deletion is non-negotiable. Silently swallows all\n * errors.\n *\n * Design note: the env vars (`AIT_RELAY_BASE_URL`, `AIT_TUNNEL_BASE_URL`) are\n * PRESERVED as operator overrides. The file is the fallback — env wins.\n *\n * Why `nearestPackageJsonDir` instead of `process.cwd()`: see relay-secret-store.ts.\n * The unplugin (writer) and the MCP daemon (reader) both anchor to the nearest\n * package.json from their respective `projectRoot` inputs, ensuring both sides\n * find the same file.\n *\n * SECRET-HANDLING: `relayBaseUrl` and `tunnelBaseUrl` carry the relay/tunnel host\n * — same sensitivity class as `.ait_relay`. The raw URL values, partial values,\n * and the resolved file path MUST NOT appear in any log, error message, stdout,\n * stderr, or assertion output anywhere in this module or at its call sites.\n * Only boolean pass/fail signals are safe to surface. The file is written mode\n * 0600.\n */\n\nimport { join } from 'node:path';\nimport { nearestPackageJsonDir } from './relay-secret-store.js';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local ephemeral URL file name (single file, not a directory). */\nexport const URLS_FILE_NAME = '.ait_urls';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surfaces\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link writeRelayUrls} — injectable for tests. */\nexport interface RelayUrlWriteFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n existsSync(path: string): boolean;\n}\n\n/** Minimal fs subset needed by {@link readRelayUrls} — injectable for tests. */\nexport interface RelayUrlReadFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\n/** Minimal fs subset needed by {@link deleteRelayUrls} — injectable for tests. */\nexport interface RelayUrlDeleteFs {\n existsSync(path: string): boolean;\n unlinkSync(path: string): void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helper\n// ---------------------------------------------------------------------------\n\n/**\n * Absolute path to the project-local `.ait_urls` 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 urlsFilePath(start: string, existsSyncFn: (path: string) => boolean): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), URLS_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — overwrite on every boot\n// ---------------------------------------------------------------------------\n\nexport interface WriteRelayUrlsDeps {\n /** Project root (typically Vite `server.config.root`). */\n projectRoot: string;\n /**\n * The CDP relay's https base URL (same value as `AIT_RELAY_BASE_URL`).\n * Omit when the relay was not started (e.g. `cdp:false`).\n * SECRET-HANDLING: never log this value.\n */\n relayBaseUrl?: string;\n /**\n * The CDP relay's LOCAL http base URL (`http://127.0.0.1:<relay-port>`).\n * Set when `cdp: true` and the local relay port is known. Used by the MCP\n * daemon's `bootExternalRelayFamily` to build the Chii inspector URL against\n * the local relay rather than the cloudflare tunnel, so front_end page load\n * and the client WS leg do not traverse the tunnel (issue #530).\n * SECRET-HANDLING: local loopback URL — no tunnel host, safe to surface.\n */\n relayLocalUrl?: string;\n /**\n * The app tunnel's https base URL (same value as `AIT_TUNNEL_BASE_URL`).\n * Omit when no tunnel URL is available.\n * SECRET-HANDLING: never log this value.\n */\n tunnelBaseUrl?: string;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelayUrlWriteFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Writes `{ relayBaseUrl, tunnelBaseUrl }` (omitting absent keys) to\n * `<projectRoot>/.ait_urls` (mode 0600). Uses `flag: 'w'` (overwrite) because\n * URLs are ephemeral — a fresh URL replaces the previous one on every boot.\n *\n * Unlike the `.ait_relay` secret store this does NOT use `O_EXCL` (`'wx'`):\n * there is no race concern here (only the unplugin writes this file) and the\n * URL must be fresh on every cold-start.\n *\n * Called ONLY from the unplugin (env-2 tunnel boot). The MCP daemon uses\n * {@link readRelayUrls} (read-only) — it must never write.\n *\n * SECRET-HANDLING: URL values are never logged.\n */\nexport async function writeRelayUrls(deps: WriteRelayUrlsDeps): Promise<void> {\n const { projectRoot, relayBaseUrl, tunnelBaseUrl, fs: fsDep, existsSync: existsSyncDep } = deps;\n\n const fs: RelayUrlWriteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n // Build the payload — omit keys whose values are absent.\n const payload: { relayBaseUrl?: string; relayLocalUrl?: string; tunnelBaseUrl?: string } = {};\n if (typeof relayBaseUrl === 'string' && relayBaseUrl !== '') {\n payload.relayBaseUrl = relayBaseUrl;\n }\n const { relayLocalUrl } = deps;\n if (typeof relayLocalUrl === 'string' && relayLocalUrl !== '') {\n payload.relayLocalUrl = relayLocalUrl;\n }\n if (typeof tunnelBaseUrl === 'string' && tunnelBaseUrl !== '') {\n payload.tunnelBaseUrl = tunnelBaseUrl;\n }\n\n // SECRET-HANDLING: JSON content (which includes URL values) is written to\n // the file only — never to any log, stdout, or stderr.\n const data = JSON.stringify(payload);\n\n // Overwrite on every boot (`flag: 'w'`) — URLs are ephemeral.\n fs.writeFileSync(filePath, data, { mode: 0o600, flag: 'w' });\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never writes, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\nexport interface ReadRelayUrlsDeps {\n /** Project root supplied per-debug-session. When omitted, returns `null`. */\n projectRoot?: string;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelayUrlReadFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Reads `<projectRoot>/.ait_urls` and returns the stored URLs, or `null` on\n * any failure.\n *\n * Strictly READ-ONLY: only `existsSync` + `readFileSync`. Never writes,\n * chmods, or creates files/directories.\n *\n * Returns `null` (silently, no throw, no log) on:\n * - missing `projectRoot`\n * - missing `.ait_urls` file\n * - unreadable file (permissions, transient FS error)\n * - invalid JSON\n * - wrong shape (non-object, non-string values)\n *\n * Trims string values before returning. Ignores non-string fields.\n *\n * SECRET-HANDLING: URL values and the file path are never logged.\n */\nexport async function readRelayUrls(deps?: ReadRelayUrlsDeps): Promise<{\n relayBaseUrl?: string;\n relayLocalUrl?: string;\n tunnelBaseUrl?: string;\n} | null> {\n const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps ?? {};\n\n if (projectRoot === undefined) {\n return null;\n }\n\n const fs: RelayUrlReadFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n if (!fs.existsSync(filePath)) {\n return null;\n }\n\n let raw: string;\n try {\n raw = fs.readFileSync(filePath, 'utf8');\n } catch {\n // Unreadable file — silent no-op, let the downstream assert be the fail-fast.\n // SECRET-HANDLING: the error and path are not surfaced.\n return null;\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n // Invalid JSON — silent no-op.\n return null;\n }\n\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return null;\n }\n\n const obj = parsed as Record<string, unknown>;\n const result: { relayBaseUrl?: string; relayLocalUrl?: string; tunnelBaseUrl?: string } = {};\n\n const relay = obj.relayBaseUrl;\n if (typeof relay === 'string') {\n const trimmed = relay.trim();\n if (trimmed !== '') result.relayBaseUrl = trimmed;\n }\n\n const relayLocal = obj.relayLocalUrl;\n if (typeof relayLocal === 'string') {\n const trimmed = relayLocal.trim();\n if (trimmed !== '') result.relayLocalUrl = trimmed;\n }\n\n const tunnel = obj.tunnelBaseUrl;\n if (typeof tunnel === 'string') {\n const trimmed = tunnel.trim();\n if (trimmed !== '') result.tunnelBaseUrl = trimmed;\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// DELETE path (unplugin cleanup only)\n// ---------------------------------------------------------------------------\n\nexport interface DeleteRelayUrlsDeps {\n /** Project root. */\n projectRoot: string;\n /** Filesystem operations. Defaults to node:fs (existsSync + unlinkSync). */\n fs?: RelayUrlDeleteFs;\n /** existsSync used to resolve the nearest package.json directory. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Removes `<projectRoot>/.ait_urls` if present. Silently swallows ENOENT and\n * any other error so cleanup always succeeds.\n *\n * Called ONLY from the unplugin's `cleanup()` on `httpServer 'close'` + signals\n * (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing at a dead\n * tunnel would cause the MCP daemon to attempt a doomed attach on the next\n * cold-start — deletion is non-negotiable.\n *\n * SECRET-HANDLING: the file path is never logged.\n */\nexport async function deleteRelayUrls(deps: DeleteRelayUrlsDeps): Promise<void> {\n const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps;\n\n const fs: RelayUrlDeleteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n try {\n if (fs.existsSync(filePath)) {\n fs.unlinkSync(filePath);\n }\n } catch {\n // Swallow ENOENT and any other error — cleanup is best-effort.\n // SECRET-HANDLING: the path is not logged.\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwDA,MAAa,iBAAiB;;;;;;;;AAmC9B,SAAgB,aAAa,OAAe,cAAiD;AAC3F,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,eAAe;;;;;;;;;;;;;;;;;;;;AA+GzE,eAAsB,cAAc,MAI1B;CACR,MAAM,EAAE,aAAa,IAAI,OAAO,YAAY,kBAAkB,QAAQ,EAAE;AAExE,KAAI,gBAAgB,KAAA,EAClB,QAAO;CAGT,MAAM,KAAqB,SAAU,MAAM,OAAO;CAGlD,MAAM,WAAW,aAAa,aAFkB,iBAAiB,GAAG,WAEZ;AAExD,KAAI,CAAC,GAAG,WAAW,SAAS,CAC1B,QAAO;CAGT,IAAI;AACJ,KAAI;AACF,QAAM,GAAG,aAAa,UAAU,OAAO;SACjC;AAGN,SAAO;;CAGT,IAAI;AACJ,KAAI;AACF,WAAS,KAAK,MAAM,IAAI;SAClB;AAEN,SAAO;;AAGT,KAAI,OAAO,WAAW,YAAY,WAAW,QAAQ,MAAM,QAAQ,OAAO,CACxE,QAAO;CAGT,MAAM,MAAM;CACZ,MAAM,SAAoF,EAAE;CAE5F,MAAM,QAAQ,IAAI;AAClB,KAAI,OAAO,UAAU,UAAU;EAC7B,MAAM,UAAU,MAAM,MAAM;AAC5B,MAAI,YAAY,GAAI,QAAO,eAAe;;CAG5C,MAAM,aAAa,IAAI;AACvB,KAAI,OAAO,eAAe,UAAU;EAClC,MAAM,UAAU,WAAW,MAAM;AACjC,MAAI,YAAY,GAAI,QAAO,gBAAgB;;CAG7C,MAAM,SAAS,IAAI;AACnB,KAAI,OAAO,WAAW,UAAU;EAC9B,MAAM,UAAU,OAAO,MAAM;AAC7B,MAAI,YAAY,GAAI,QAAO,gBAAgB;;AAG7C,QAAO"}
|
|
1
|
+
{"version":3,"file":"relay-url-store-DGQ-HPQC.js","names":[],"sources":["../src/mcp/relay-url-store.ts"],"sourcesContent":["/**\n * Project-local ephemeral URL store (#424).\n *\n * Environment-2 (\"AITC Sandbox PWA\") cold-start requires two ephemeral URLs:\n * - `relayBaseUrl` — the CDP relay's https base (was `AIT_RELAY_BASE_URL`)\n * - `tunnelBaseUrl` — the app's https tunnel base (was `AIT_TUNNEL_BASE_URL`)\n *\n * Quick-tunnel URLs change every run. Previously the user had to copy-paste them\n * into env vars on every cold-start — a single typo silently broke attach. This\n * module replaces that manual hand-off with a file-based discovery pattern that\n * exactly mirrors the `.ait_relay` TOTP-secret store (relay-secret-store.ts).\n *\n * Two surfaces, intentionally split by who is allowed to write:\n *\n * - {@link writeRelayUrls} — WRITE path, called ONLY from the unplugin\n * (env-2 tunnel boot). Writes JSON to `<projectRoot>/.ait_urls` (0600) on\n * every boot (`flag: 'w'` — overwrite). A single file — no directory is\n * created.\n *\n * - {@link readRelayUrls} — READ-ONLY path, called from the MCP daemon as a\n * fallback when `AIT_RELAY_BASE_URL`/`AIT_TUNNEL_BASE_URL` are not set. It\n * NEVER writes, chmods, or creates anything: it only reads an existing\n * `.ait_urls`. On any failure (missing file / bad JSON / wrong shape) it\n * returns `null` silently, letting the downstream assertion be the single\n * fail-fast.\n *\n * - {@link deleteRelayUrls} — called from the unplugin `cleanup()` on\n * teardown (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing\n * at a dead tunnel would cause the MCP daemon to attempt a doomed attach on\n * the next cold-start — deletion is non-negotiable. Silently swallows all\n * errors.\n *\n * Design note: the env vars (`AIT_RELAY_BASE_URL`, `AIT_TUNNEL_BASE_URL`) are\n * PRESERVED as operator overrides. The file is the fallback — env wins.\n *\n * Why `nearestPackageJsonDir` instead of `process.cwd()`: see relay-secret-store.ts.\n * The unplugin (writer) and the MCP daemon (reader) both anchor to the nearest\n * package.json from their respective `projectRoot` inputs, ensuring both sides\n * find the same file.\n *\n * SECRET-HANDLING: `relayBaseUrl` and `tunnelBaseUrl` carry the relay/tunnel host\n * — same sensitivity class as `.ait_relay`. The raw URL values, partial values,\n * and the resolved file path MUST NOT appear in any log, error message, stdout,\n * stderr, or assertion output anywhere in this module or at its call sites.\n * Only boolean pass/fail signals are safe to surface. The file is written mode\n * 0600.\n */\n\nimport { join } from 'node:path';\nimport { nearestPackageJsonDir } from './relay-secret-store.js';\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Project-local ephemeral URL file name (single file, not a directory). */\nexport const URLS_FILE_NAME = '.ait_urls';\n\n// ---------------------------------------------------------------------------\n// Dependency injection surfaces\n// ---------------------------------------------------------------------------\n\n/** Minimal fs subset needed by {@link writeRelayUrls} — injectable for tests. */\nexport interface RelayUrlWriteFs {\n writeFileSync(path: string, data: string, options: { mode: number; flag: string }): void;\n existsSync(path: string): boolean;\n}\n\n/** Minimal fs subset needed by {@link readRelayUrls} — injectable for tests. */\nexport interface RelayUrlReadFs {\n existsSync(path: string): boolean;\n readFileSync(path: string, encoding: BufferEncoding): string;\n}\n\n/** Minimal fs subset needed by {@link deleteRelayUrls} — injectable for tests. */\nexport interface RelayUrlDeleteFs {\n existsSync(path: string): boolean;\n unlinkSync(path: string): void;\n}\n\n// ---------------------------------------------------------------------------\n// Path helper\n// ---------------------------------------------------------------------------\n\n/**\n * Absolute path to the project-local `.ait_urls` 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 urlsFilePath(start: string, existsSyncFn: (path: string) => boolean): string {\n return join(nearestPackageJsonDir(start, existsSyncFn), URLS_FILE_NAME);\n}\n\n// ---------------------------------------------------------------------------\n// WRITE path (unplugin only) — overwrite on every boot\n// ---------------------------------------------------------------------------\n\nexport interface WriteRelayUrlsDeps {\n /** Project root (typically Vite `server.config.root`). */\n projectRoot: string;\n /**\n * The CDP relay's https base URL (same value as `AIT_RELAY_BASE_URL`).\n * Omit when the relay was not started (e.g. `cdp:false`).\n * SECRET-HANDLING: never log this value.\n */\n relayBaseUrl?: string;\n /**\n * The CDP relay's LOCAL http base URL (`http://127.0.0.1:<relay-port>`).\n * Set when `cdp: true` and the local relay port is known. Used by the MCP\n * daemon's `bootExternalRelayFamily` to build the Chii inspector URL against\n * the local relay rather than the cloudflare tunnel, so front_end page load\n * and the client WS leg do not traverse the tunnel (issue #530).\n * SECRET-HANDLING: local loopback URL — no tunnel host, safe to surface.\n */\n relayLocalUrl?: string;\n /**\n * The app tunnel's https base URL (same value as `AIT_TUNNEL_BASE_URL`).\n * Omit when no tunnel URL is available.\n * SECRET-HANDLING: never log this value.\n */\n tunnelBaseUrl?: string;\n /** Filesystem operations. Defaults to node:fs synchronous functions. */\n fs?: RelayUrlWriteFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Writes `{ relayBaseUrl, tunnelBaseUrl }` (omitting absent keys) to\n * `<projectRoot>/.ait_urls` (mode 0600). Uses `flag: 'w'` (overwrite) because\n * URLs are ephemeral — a fresh URL replaces the previous one on every boot.\n *\n * Unlike the `.ait_relay` secret store this does NOT use `O_EXCL` (`'wx'`):\n * there is no race concern here (only the unplugin writes this file) and the\n * URL must be fresh on every cold-start.\n *\n * Called ONLY from the unplugin (env-2 tunnel boot). The MCP daemon uses\n * {@link readRelayUrls} (read-only) — it must never write.\n *\n * SECRET-HANDLING: URL values are never logged.\n */\nexport async function writeRelayUrls(deps: WriteRelayUrlsDeps): Promise<void> {\n const { projectRoot, relayBaseUrl, tunnelBaseUrl, fs: fsDep, existsSync: existsSyncDep } = deps;\n\n const fs: RelayUrlWriteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n // Build the payload — omit keys whose values are absent.\n const payload: { relayBaseUrl?: string; relayLocalUrl?: string; tunnelBaseUrl?: string } = {};\n if (typeof relayBaseUrl === 'string' && relayBaseUrl !== '') {\n payload.relayBaseUrl = relayBaseUrl;\n }\n const { relayLocalUrl } = deps;\n if (typeof relayLocalUrl === 'string' && relayLocalUrl !== '') {\n payload.relayLocalUrl = relayLocalUrl;\n }\n if (typeof tunnelBaseUrl === 'string' && tunnelBaseUrl !== '') {\n payload.tunnelBaseUrl = tunnelBaseUrl;\n }\n\n // SECRET-HANDLING: JSON content (which includes URL values) is written to\n // the file only — never to any log, stdout, or stderr.\n const data = JSON.stringify(payload);\n\n // Overwrite on every boot (`flag: 'w'`) — URLs are ephemeral.\n fs.writeFileSync(filePath, data, { mode: 0o600, flag: 'w' });\n}\n\n// ---------------------------------------------------------------------------\n// READ-ONLY path (daemon only) — never writes, chmods, or creates anything\n// ---------------------------------------------------------------------------\n\nexport interface ReadRelayUrlsDeps {\n /** Project root supplied per-debug-session. When omitted, returns `null`. */\n projectRoot?: string;\n /** Read-only filesystem operations. Defaults to node:fs (existsSync + readFileSync). */\n fs?: RelayUrlReadFs;\n /** existsSync used to resolve the nearest package.json directory. Defaults to node:fs. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Reads `<projectRoot>/.ait_urls` and returns the stored URLs, or `null` on\n * any failure.\n *\n * Strictly READ-ONLY: only `existsSync` + `readFileSync`. Never writes,\n * chmods, or creates files/directories.\n *\n * Returns `null` (silently, no throw, no log) on:\n * - missing `projectRoot`\n * - missing `.ait_urls` file\n * - unreadable file (permissions, transient FS error)\n * - invalid JSON\n * - wrong shape (non-object, non-string values)\n *\n * Trims string values before returning. Ignores non-string fields.\n *\n * SECRET-HANDLING: URL values and the file path are never logged.\n */\nexport async function readRelayUrls(deps?: ReadRelayUrlsDeps): Promise<{\n relayBaseUrl?: string;\n relayLocalUrl?: string;\n tunnelBaseUrl?: string;\n} | null> {\n const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps ?? {};\n\n if (projectRoot === undefined) {\n return null;\n }\n\n const fs: RelayUrlReadFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n if (!fs.existsSync(filePath)) {\n return null;\n }\n\n let raw: string;\n try {\n raw = fs.readFileSync(filePath, 'utf8');\n } catch {\n // Unreadable file — silent no-op, let the downstream assert be the fail-fast.\n // SECRET-HANDLING: the error and path are not surfaced.\n return null;\n }\n\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n // Invalid JSON — silent no-op.\n return null;\n }\n\n if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {\n return null;\n }\n\n const obj = parsed as Record<string, unknown>;\n const result: { relayBaseUrl?: string; relayLocalUrl?: string; tunnelBaseUrl?: string } = {};\n\n const relay = obj.relayBaseUrl;\n if (typeof relay === 'string') {\n const trimmed = relay.trim();\n if (trimmed !== '') result.relayBaseUrl = trimmed;\n }\n\n const relayLocal = obj.relayLocalUrl;\n if (typeof relayLocal === 'string') {\n const trimmed = relayLocal.trim();\n if (trimmed !== '') result.relayLocalUrl = trimmed;\n }\n\n const tunnel = obj.tunnelBaseUrl;\n if (typeof tunnel === 'string') {\n const trimmed = tunnel.trim();\n if (trimmed !== '') result.tunnelBaseUrl = trimmed;\n }\n\n return result;\n}\n\n// ---------------------------------------------------------------------------\n// DELETE path (unplugin cleanup only)\n// ---------------------------------------------------------------------------\n\nexport interface DeleteRelayUrlsDeps {\n /** Project root. */\n projectRoot: string;\n /** Filesystem operations. Defaults to node:fs (existsSync + unlinkSync). */\n fs?: RelayUrlDeleteFs;\n /** existsSync used to resolve the nearest package.json directory. */\n existsSync?: (path: string) => boolean;\n}\n\n/**\n * Removes `<projectRoot>/.ait_urls` if present. Silently swallows ENOENT and\n * any other error so cleanup always succeeds.\n *\n * Called ONLY from the unplugin's `cleanup()` on `httpServer 'close'` + signals\n * (via `void deleteRelayUrls(...)`). A stale `.ait_urls` pointing at a dead\n * tunnel would cause the MCP daemon to attempt a doomed attach on the next\n * cold-start — deletion is non-negotiable.\n *\n * SECRET-HANDLING: the file path is never logged.\n */\nexport async function deleteRelayUrls(deps: DeleteRelayUrlsDeps): Promise<void> {\n const { projectRoot, fs: fsDep, existsSync: existsSyncDep } = deps;\n\n const fs: RelayUrlDeleteFs = fsDep ?? (await import('node:fs'));\n const existsSyncFn: (path: string) => boolean = existsSyncDep ?? fs.existsSync;\n\n const filePath = urlsFilePath(projectRoot, existsSyncFn);\n\n try {\n if (fs.existsSync(filePath)) {\n fs.unlinkSync(filePath);\n }\n } catch {\n // Swallow ENOENT and any other error — cleanup is best-effort.\n // SECRET-HANDLING: the path is not logged.\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAwDA,MAAa,iBAAiB;;;;;;;;AAmC9B,SAAgB,aAAa,OAAe,cAAiD;AAC3F,QAAO,KAAK,sBAAsB,OAAO,aAAa,EAAE,eAAe;;;;;;;;;;;;;;;;;;;;AA+GzE,eAAsB,cAAc,MAI1B;CACR,MAAM,EAAE,aAAa,IAAI,OAAO,YAAY,kBAAkB,QAAQ,EAAE;AAExE,KAAI,gBAAgB,KAAA,EAClB,QAAO;CAGT,MAAM,KAAqB,SAAU,MAAM,OAAO;CAGlD,MAAM,WAAW,aAAa,aAFkB,iBAAiB,GAAG,WAEZ;AAExD,KAAI,CAAC,GAAG,WAAW,SAAS,CAC1B,QAAO;CAGT,IAAI;AACJ,KAAI;AACF,QAAM,GAAG,aAAa,UAAU,OAAO;SACjC;AAGN,SAAO;;CAGT,IAAI;AACJ,KAAI;AACF,WAAS,KAAK,MAAM,IAAI;SAClB;AAEN,SAAO;;AAGT,KAAI,OAAO,WAAW,YAAY,WAAW,QAAQ,MAAM,QAAQ,OAAO,CACxE,QAAO;CAGT,MAAM,MAAM;CACZ,MAAM,SAAoF,EAAE;CAE5F,MAAM,QAAQ,IAAI;AAClB,KAAI,OAAO,UAAU,UAAU;EAC7B,MAAM,UAAU,MAAM,MAAM;AAC5B,MAAI,YAAY,GAAI,QAAO,eAAe;;CAG5C,MAAM,aAAa,IAAI;AACvB,KAAI,OAAO,eAAe,UAAU;EAClC,MAAM,UAAU,WAAW,MAAM;AACjC,MAAI,YAAY,GAAI,QAAO,gBAAgB;;CAG7C,MAAM,SAAS,IAAI;AACnB,KAAI,OAAO,WAAW,UAAU;EAC9B,MAAM,UAAU,OAAO,MAAM;AAC7B,MAAI,YAAY,GAAI,QAAO,gBAAgB;;AAG7C,QAAO"}
|