@ait-co/devtools 0.1.117 → 0.1.119
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-CFotueL3.js +1749 -0
- package/dist/attach-orchestrator-CFotueL3.js.map +1 -0
- package/dist/attach-orchestrator-CII9Gl5m.js +2797 -0
- package/dist/attach-orchestrator-CII9Gl5m.js.map +1 -0
- package/dist/attach-orchestrator-C_6xb_L5.js +3 -0
- package/dist/attach-orchestrator-DEJfLm7V.js +1749 -0
- package/dist/attach-orchestrator-DEJfLm7V.js.map +1 -0
- package/dist/{cli-CvUNQKqw.js → attach-orchestrator-qFEMBUwb.js} +3 -805
- package/dist/attach-orchestrator-qFEMBUwb.js.map +1 -0
- package/dist/{bundle-KFs4t-wc.d.ts → bundle-CA1TCXED.d.ts} +1 -1
- package/dist/bundle-CA1TCXED.d.ts.map +1 -0
- package/dist/capture-BmK_cBqQ.d.ts +58 -0
- package/dist/capture-BmK_cBqQ.d.ts.map +1 -0
- package/dist/{cdp-connection-C0AP0tH2.d.ts → cdp-connection-DB2zgthr.d.ts} +1 -1
- package/dist/{cdp-connection-C0AP0tH2.d.ts.map → cdp-connection-DB2zgthr.d.ts.map} +1 -1
- package/dist/cell-BJiaGlVu.js +3 -0
- package/dist/cell-BPWZUm_X.js +85 -0
- package/dist/cell-BPWZUm_X.js.map +1 -0
- package/dist/cell-Cv3e_qwn.js +84 -0
- package/dist/cell-Cv3e_qwn.js.map +1 -0
- package/dist/cell-bLwQ4gTq.js +85 -0
- package/dist/cell-bLwQ4gTq.js.map +1 -0
- package/dist/cell-nrWFeqvG.js +84 -0
- package/dist/cell-nrWFeqvG.js.map +1 -0
- package/dist/cli-RhzASQef.js +1057 -0
- package/dist/cli-RhzASQef.js.map +1 -0
- package/dist/debug-server-BhHVy3XT.js +1191 -0
- package/dist/debug-server-BhHVy3XT.js.map +1 -0
- package/dist/{debug-server-jrI9U_f4.js → debug-server-Bi6jLuY6.js} +467 -2989
- package/dist/debug-server-Bi6jLuY6.js.map +1 -0
- package/dist/debug-server-Buc8yoa7.js +1909 -0
- package/dist/debug-server-Buc8yoa7.js.map +1 -0
- package/dist/{debug-server-BvrESnaV.js → debug-server-C_5ag9jj.js} +5 -3
- package/dist/debug-server-C_5ag9jj.js.map +1 -0
- package/dist/mcp/cli.js +3 -2
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/panel/index.js +1 -1
- package/dist/{pool-htlVnEFl.d.ts → pool-U9FrxEuR.d.ts} +5 -5
- package/dist/{pool-htlVnEFl.d.ts.map → pool-U9FrxEuR.d.ts.map} +1 -1
- package/dist/relay-factory-DVI7TrX4.js +69 -0
- package/dist/relay-factory-DVI7TrX4.js.map +1 -0
- package/dist/relay-secret-store-BcVrWwTq.js +153 -0
- package/dist/relay-secret-store-BcVrWwTq.js.map +1 -0
- package/dist/{relay-secret-store-BHcOmaNK.js → relay-secret-store-BlFEhqwb.js} +1 -1
- package/dist/{relay-secret-store-CmqchhR5.js → relay-secret-store-CFc9n0OA.js} +1 -1
- package/dist/{relay-secret-store-CmqchhR5.js.map → relay-secret-store-CFc9n0OA.js.map} +1 -1
- package/dist/{relay-secret-store-CkA7KNUb.js → relay-secret-store-CmDDfZMh.js} +2 -2
- package/dist/{relay-secret-store-CkA7KNUb.js.map → relay-secret-store-CmDDfZMh.js.map} +1 -1
- package/dist/relay-secret-store-DNPJKHNs.js +153 -0
- package/dist/relay-secret-store-DNPJKHNs.js.map +1 -0
- package/dist/relay-url-store-CdA58fgw.js +122 -0
- package/dist/relay-url-store-CdA58fgw.js.map +1 -0
- package/dist/{relay-url-store-C0qukm3R.js → relay-url-store-CnL2zwbH.js} +2 -2
- package/dist/{relay-url-store-C0qukm3R.js.map → relay-url-store-CnL2zwbH.js.map} +1 -1
- package/dist/{relay-url-store-NDtEcOE-.js → relay-url-store-DGQ-HPQC.js} +2 -2
- package/dist/{relay-url-store-NDtEcOE-.js.map → relay-url-store-DGQ-HPQC.js.map} +1 -1
- package/dist/relay-url-store-Dv5HSzwb.js +122 -0
- package/dist/relay-url-store-Dv5HSzwb.js.map +1 -0
- package/dist/{relay-worker-DWW4vZxU.d.ts → relay-worker-D-3aAyDO.d.ts} +28 -4
- package/dist/relay-worker-D-3aAyDO.d.ts.map +1 -0
- package/dist/{runtime-BU-iMHz6.d.ts → runtime-D57Rtnsd.d.ts} +1 -1
- package/dist/{runtime-BU-iMHz6.d.ts.map → runtime-D57Rtnsd.d.ts.map} +1 -1
- package/dist/test-runner/bundle.d.ts +1 -1
- package/dist/test-runner/bundle.js +45 -27
- package/dist/test-runner/bundle.js.map +1 -1
- package/dist/test-runner/capture.d.ts +2 -0
- package/dist/test-runner/capture.js +44 -0
- package/dist/test-runner/capture.js.map +1 -0
- package/dist/test-runner/cli.d.ts +85 -12
- package/dist/test-runner/cli.d.ts.map +1 -1
- package/dist/test-runner/cli.js +2 -2
- package/dist/test-runner/config.d.ts +76 -2
- package/dist/test-runner/config.d.ts.map +1 -1
- package/dist/test-runner/config.js +4 -2
- package/dist/test-runner/config.js.map +1 -1
- package/dist/test-runner/pool.d.ts +1 -1
- package/dist/test-runner/relay-factory.d.ts +11136 -0
- package/dist/test-runner/relay-factory.d.ts.map +1 -0
- package/dist/test-runner/relay-factory.js +69 -0
- package/dist/test-runner/relay-factory.js.map +1 -0
- package/dist/test-runner/relay-worker.d.ts +1 -1
- package/dist/test-runner/relay-worker.js +66 -20
- package/dist/test-runner/relay-worker.js.map +1 -1
- package/dist/test-runner/report.d.ts +106 -0
- package/dist/test-runner/report.d.ts.map +1 -0
- package/dist/test-runner/report.js +139 -0
- package/dist/test-runner/report.js.map +1 -0
- package/dist/test-runner/rpc.d.ts +2 -2
- package/dist/test-runner/runtime.d.ts +1 -1
- package/dist/test-runner/task-graph.d.ts +1 -1
- package/dist/{totp-D70zD5tJ.js → totp-0bvlo9Yb.js} +1 -1
- package/dist/{totp-D70zD5tJ.js.map → totp-0bvlo9Yb.js.map} +1 -1
- package/dist/totp-DfekTBk3.js +211 -0
- package/dist/totp-DfekTBk3.js.map +1 -0
- package/dist/totp-zGIlDsZS.js +3 -0
- package/package.json +1 -1
- package/dist/bundle-KFs4t-wc.d.ts.map +0 -1
- package/dist/cli-CvUNQKqw.js.map +0 -1
- package/dist/debug-server-BvrESnaV.js.map +0 -1
- package/dist/debug-server-jrI9U_f4.js.map +0 -1
- package/dist/relay-worker-DWW4vZxU.d.ts.map +0 -1
- package/dist/totp-D104cJQN.js +0 -3
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
//#region src/test-runner/relay-factory.ts
|
|
2
|
+
/**
|
|
3
|
+
* Builds a {@link RelayConnectionFactory} that opens a standalone env3 relay
|
|
4
|
+
* connection.
|
|
5
|
+
*
|
|
6
|
+
* `open()` performs the full attach lifecycle and BLOCKS for tens of seconds (up
|
|
7
|
+
* to `timeoutMs`) while a human scans the rendered QR with their phone — there
|
|
8
|
+
* is no way around the manual scan for env3. It resolves with the live
|
|
9
|
+
* `CdpConnection` once a matching page attaches; `close()` tears the relay
|
|
10
|
+
* family down.
|
|
11
|
+
*
|
|
12
|
+
* The factory holds the booted relay family in a closure so `close()` can stop
|
|
13
|
+
* it. A second `open()` on the same factory boots a fresh family (the previous
|
|
14
|
+
* one should have been `close()`d first).
|
|
15
|
+
*/
|
|
16
|
+
function createRelayConnectionFactory(opts) {
|
|
17
|
+
const projectRoot = opts.projectRoot ?? process.cwd();
|
|
18
|
+
const timeoutMs = opts.timeoutMs ?? 6e5;
|
|
19
|
+
const headless = opts.headless === true;
|
|
20
|
+
let family;
|
|
21
|
+
return {
|
|
22
|
+
async open() {
|
|
23
|
+
const { prepareAttach, renderAndMaybeWait } = await import("./attach-orchestrator-CFotueL3.js");
|
|
24
|
+
const { injectDebugIndicator, injectGlobals } = await import("./cell-nrWFeqvG.js");
|
|
25
|
+
const { loadRelaySecretReadOnly } = await import("./relay-secret-store-BcVrWwTq.js");
|
|
26
|
+
const { bootRelayFamily, buildRelayVerifyAuth } = await import("./debug-server-BhHVy3XT.js");
|
|
27
|
+
await loadRelaySecretReadOnly({ projectRoot });
|
|
28
|
+
const booted = await bootRelayFamily({ verifyAuth: buildRelayVerifyAuth() });
|
|
29
|
+
family = booted;
|
|
30
|
+
const attachDeps = {
|
|
31
|
+
getTunnelStatus: booted.getTunnelStatus ?? (() => ({
|
|
32
|
+
up: false,
|
|
33
|
+
wssUrl: null
|
|
34
|
+
})),
|
|
35
|
+
getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,
|
|
36
|
+
qrHttpServer: void 0,
|
|
37
|
+
onAttachUrlBuilt: void 0,
|
|
38
|
+
canOpenBrowser: () => !headless
|
|
39
|
+
};
|
|
40
|
+
const prep = await prepareAttach(attachDeps, "relay-dev", { scheme_url: opts.schemeUrl }, booted.connection);
|
|
41
|
+
if (!prep.ok) {
|
|
42
|
+
booted.stop();
|
|
43
|
+
family = void 0;
|
|
44
|
+
throw new Error("createRelayConnectionFactory: attach preparation failed — check the scheme_url and that the relay tunnel is up");
|
|
45
|
+
}
|
|
46
|
+
const waitResult = await renderAndMaybeWait(attachDeps, prep, true, timeoutMs, booted.connection);
|
|
47
|
+
if (waitResult.isError) {
|
|
48
|
+
booted.stop();
|
|
49
|
+
family = void 0;
|
|
50
|
+
const timeoutSec = Math.round(timeoutMs / 1e3);
|
|
51
|
+
throw new Error(`createRelayConnectionFactory: attach timed out after ${timeoutSec}s — phone did not scan the QR within the timeout`);
|
|
52
|
+
}
|
|
53
|
+
const qrChunks = waitResult.content.filter((c) => c.type === "text").map((c) => c.text);
|
|
54
|
+
opts.onQrContent(qrChunks);
|
|
55
|
+
await injectDebugIndicator(booted.connection);
|
|
56
|
+
if (opts.cell !== void 0) await injectGlobals(booted.connection, { __AIT_CELL__: opts.cell });
|
|
57
|
+
await booted.connection.enableDomains();
|
|
58
|
+
return booted.connection;
|
|
59
|
+
},
|
|
60
|
+
async close(_connection) {
|
|
61
|
+
family?.stop();
|
|
62
|
+
family = void 0;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
export { createRelayConnectionFactory as t };
|
|
68
|
+
|
|
69
|
+
//# sourceMappingURL=relay-factory-DVI7TrX4.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relay-factory-DVI7TrX4.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 } from '../mcp/attach-orchestrator.js';\nimport type { CdpConnection } from '../mcp/cdp-connection.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\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 } = await import('../mcp/attach-orchestrator.js');\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 const booted = await bootRelayFamily({ verifyAuth: buildRelayVerifyAuth() });\n family = booted;\n\n // Assemble AttachDeps with no dashboard/SSE (CLI/pool is not the daemon).\n const attachDeps: AttachDeps = {\n getTunnelStatus: booted.getTunnelStatus ?? (() => ({ up: false, wssUrl: null })),\n getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,\n qrHttpServer: undefined,\n onAttachUrlBuilt: undefined,\n canOpenBrowser: () => !headless,\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 // 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 // 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 },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;AA4GA,SAAgB,6BACd,MACwB;CACxB,MAAM,cAAc,KAAK,eAAe,QAAQ,KAAK;CACrD,MAAM,YAAY,KAAK,aAAa;CACpC,MAAM,WAAW,KAAK,aAAa;CAGnC,IAAI;AAEJ,QAAO;EACL,MAAM,OAA+B;GAGnC,MAAM,EAAE,eAAe,uBAAuB,MAAM,OAAO;GAC3D,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;GAE9C,MAAM,SAAS,MAAM,gBAAgB,EAAE,YAAY,sBAAsB,EAAE,CAAC;AAC5E,YAAS;GAGT,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;GAED,MAAM,OAAO,MAAM,cACjB,YACA,aACA,EAAE,YAAY,KAAK,WAAW,EAC9B,OAAO,WACR;AACD,OAAI,CAAC,KAAK,IAAI;AACZ,WAAO,MAAM;AACb,aAAS,KAAA;AAOT,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;IAQT,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;;EAEZ"}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
//#region src/mcp/relay-secret-store.ts
|
|
3
|
+
/**
|
|
4
|
+
* Project-local relay TOTP secret store (#394 first-run auto-mint, #396 moved to
|
|
5
|
+
* a project-local single file `.ait_relay`).
|
|
6
|
+
*
|
|
7
|
+
* Two surfaces, intentionally split by who is allowed to write:
|
|
8
|
+
*
|
|
9
|
+
* - {@link ensureRelaySecret} — WRITE path, called ONLY from the unplugin
|
|
10
|
+
* (env-2 relay boot). Mints a fresh secret on first run and persists it to
|
|
11
|
+
* `<projectRoot>/.ait_relay` (0600). A single file — no directory is created.
|
|
12
|
+
*
|
|
13
|
+
* - {@link loadRelaySecretReadOnly} — READ-ONLY path, called from the MCP
|
|
14
|
+
* daemon when switching into a relay environment. It NEVER mints, chmods, or
|
|
15
|
+
* creates anything: it only reads an already-existing `.ait_relay` and injects
|
|
16
|
+
* its value into `env`. A daemon that minted would defeat the #250 fail-fast
|
|
17
|
+
* (the daemon is the verifier side — a self-minted secret would let a leaked
|
|
18
|
+
* tunnel URL attach unauthenticated), so the daemon stays read-only.
|
|
19
|
+
*
|
|
20
|
+
* Why a per-session `projectRoot` instead of `process.cwd()`: the daemon cannot
|
|
21
|
+
* trust its own cwd — agent-plugin spawns it via `npx` without `cwd`, so cwd is
|
|
22
|
+
* frozen at Claude Code launch and a cwd-walk stops at the monorepo workspace
|
|
23
|
+
* root (which always has a package.json). So the project root is supplied
|
|
24
|
+
* per-debug-session through `start_debug`.
|
|
25
|
+
*
|
|
26
|
+
* SECRET-HANDLING: this module handles AIT_DEBUG_TOTP_SECRET — the raw value and
|
|
27
|
+
* its length MUST NOT appear in any log, error message, stdout, stderr, or
|
|
28
|
+
* assertion output. Only boolean pass/fail signals are safe to surface, and the
|
|
29
|
+
* discovered file path is never logged either. The persist file is written mode
|
|
30
|
+
* 0600.
|
|
31
|
+
*/
|
|
32
|
+
/** Project-local secret file name (single file, not a directory). */
|
|
33
|
+
const RELAY_SECRET_FILE_NAME = ".ait_relay";
|
|
34
|
+
/**
|
|
35
|
+
* Walks upward from `start` and returns the nearest directory that contains a
|
|
36
|
+
* `package.json`. Falls back to `start` itself when none is found (so a write
|
|
37
|
+
* still lands somewhere deterministic).
|
|
38
|
+
*
|
|
39
|
+
* The write (unplugin) and read (daemon) sides use the SAME anchor so a secret
|
|
40
|
+
* minted by `pnpm dev` is found by the daemon: real mini-apps keep
|
|
41
|
+
* `vite.config.ts` and `package.json` in the same directory, so
|
|
42
|
+
* `server.config.root === package.json-dir`. In a monorepo subdir the anchor is
|
|
43
|
+
* the package's own directory — the one the daemon can also reach via the
|
|
44
|
+
* per-session projectRoot.
|
|
45
|
+
*
|
|
46
|
+
* @param start - Directory to start the upward walk from.
|
|
47
|
+
* @param existsSyncFn - Injectable existence check (defaults to node:fs).
|
|
48
|
+
*/
|
|
49
|
+
function nearestPackageJsonDir(start, existsSyncFn) {
|
|
50
|
+
let dir = start;
|
|
51
|
+
while (true) {
|
|
52
|
+
if (existsSyncFn(join(dir, "package.json"))) return dir;
|
|
53
|
+
const parent = dirname(dir);
|
|
54
|
+
if (parent === dir) return start;
|
|
55
|
+
dir = parent;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Absolute path to the project-local `.ait_relay` file for a given start
|
|
60
|
+
* directory (resolved against the nearest package.json directory).
|
|
61
|
+
*
|
|
62
|
+
* Exported so tests can compute the expected path without duplicating the
|
|
63
|
+
* resolution logic.
|
|
64
|
+
*/
|
|
65
|
+
function relaySecretFilePath(start, existsSyncFn) {
|
|
66
|
+
return join(nearestPackageJsonDir(start, existsSyncFn), RELAY_SECRET_FILE_NAME);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Reads an already-existing `<projectRoot>/.ait_relay` and, if its contents are a
|
|
70
|
+
* valid relay TOTP secret, injects them into `env.AIT_DEBUG_TOTP_SECRET`.
|
|
71
|
+
*
|
|
72
|
+
* Strictly READ-ONLY: it uses only `existsSync` + `readFileSync` and NEVER mints,
|
|
73
|
+
* chmods, or creates files/directories. The daemon must not mint because it is
|
|
74
|
+
* the relay verifier side — a self-minted secret would defeat the #250 fail-fast
|
|
75
|
+
* (a leaked tunnel URL could then attach unauthenticated). If no valid secret is
|
|
76
|
+
* found the function leaves `env` untouched and returns without throwing, so the
|
|
77
|
+
* downstream `assertRelayAuthConfigured()` stays the single fail-fast.
|
|
78
|
+
*
|
|
79
|
+
* Resolution order:
|
|
80
|
+
* 1. `env.AIT_DEBUG_TOTP_SECRET` already valid → no-op (operator export wins).
|
|
81
|
+
* 2. `projectRoot` given → read `<nearest package.json dir>/.ait_relay`; inject
|
|
82
|
+
* iff the contents pass {@link isValidRelayAuthSecret}.
|
|
83
|
+
* 3. Otherwise (no projectRoot, file absent, or invalid) → silent no-op.
|
|
84
|
+
*
|
|
85
|
+
* SECRET-HANDLING: the read value is passed ONLY to the boolean predicate before
|
|
86
|
+
* assignment; its value, length, and the discovered file path are never logged.
|
|
87
|
+
*
|
|
88
|
+
* @param deps - Optional dependency overrides for testing.
|
|
89
|
+
*/
|
|
90
|
+
async function loadRelaySecretReadOnly(deps) {
|
|
91
|
+
const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep, log } = deps ?? {};
|
|
92
|
+
const logFn = log ?? ((msg) => process.stderr.write(msg));
|
|
93
|
+
const { isValidRelayAuthSecret } = await import("./totp-DfekTBk3.js").then((n) => n.i);
|
|
94
|
+
if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {
|
|
95
|
+
if (projectRoot !== void 0) {
|
|
96
|
+
const fsEarly = fsDep ?? await import("node:fs");
|
|
97
|
+
const secretPathEarly = relaySecretFilePath(projectRoot, existsSyncDep ?? fsEarly.existsSync);
|
|
98
|
+
warnIfEnvDiffersFromFile(env.AIT_DEBUG_TOTP_SECRET, secretPathEarly, fsEarly, isValidRelayAuthSecret, logFn);
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (projectRoot === void 0) return;
|
|
103
|
+
const fs = fsDep ?? await import("node:fs");
|
|
104
|
+
const secretPath = relaySecretFilePath(projectRoot, existsSyncDep ?? fs.existsSync);
|
|
105
|
+
if (!fs.existsSync(secretPath)) return;
|
|
106
|
+
let stored;
|
|
107
|
+
try {
|
|
108
|
+
stored = fs.readFileSync(secretPath, "utf8").trim();
|
|
109
|
+
} catch {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!isValidRelayAuthSecret(stored)) return;
|
|
113
|
+
env.AIT_DEBUG_TOTP_SECRET = stored;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Compares `envSecret` against the contents of `secretPath` (if the file
|
|
117
|
+
* exists and contains a valid secret) and emits a single warning via `logFn`
|
|
118
|
+
* when they differ.
|
|
119
|
+
*
|
|
120
|
+
* SECRET-HANDLING (hard rules — do NOT relax):
|
|
121
|
+
* - The warning MUST NOT include either secret value, its length, a hash of
|
|
122
|
+
* it, or the resolved file path.
|
|
123
|
+
* - Only an inequality boolean drives the warning; no secret-derived data
|
|
124
|
+
* enters the log message.
|
|
125
|
+
* - If the file is absent, unreadable, or its contents are invalid the
|
|
126
|
+
* function returns silently — no spurious noise.
|
|
127
|
+
*
|
|
128
|
+
* This helper is intentionally synchronous-like (reads via the injected fs)
|
|
129
|
+
* so it can be called from within the async early-return guards without
|
|
130
|
+
* introducing additional async hops.
|
|
131
|
+
*
|
|
132
|
+
* @param envSecret - The validated env value (caller must have confirmed it is
|
|
133
|
+
* valid before calling).
|
|
134
|
+
* @param secretPath - Absolute path to `.ait_relay` to read for comparison.
|
|
135
|
+
* @param fsDep - Injectable fs subset (at minimum `existsSync` + `readFileSync`).
|
|
136
|
+
* @param isValidRelayAuthSecret - Injectable predicate from totp.ts.
|
|
137
|
+
* @param logFn - Injectable log sink; never receives a secret value.
|
|
138
|
+
*/
|
|
139
|
+
function warnIfEnvDiffersFromFile(envSecret, secretPath, fsDep, isValidRelayAuthSecret, logFn) {
|
|
140
|
+
if (!fsDep.existsSync(secretPath)) return;
|
|
141
|
+
let stored;
|
|
142
|
+
try {
|
|
143
|
+
stored = fsDep.readFileSync(secretPath, "utf8").trim();
|
|
144
|
+
} catch {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (!isValidRelayAuthSecret(stored)) return;
|
|
148
|
+
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");
|
|
149
|
+
}
|
|
150
|
+
//#endregion
|
|
151
|
+
export { loadRelaySecretReadOnly, nearestPackageJsonDir };
|
|
152
|
+
|
|
153
|
+
//# sourceMappingURL=relay-secret-store-BcVrWwTq.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relay-secret-store-BcVrWwTq.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"}
|
|
@@ -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-CmqchhR5.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-zGIlDsZS.js");
|
|
95
95
|
if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {
|
|
96
96
|
if (projectRoot !== void 0) {
|
|
97
97
|
const fsEarly = fsDep ?? await import("node:fs");
|
|
@@ -151,4 +151,4 @@ function warnIfEnvDiffersFromFile(envSecret, secretPath, fsDep, isValidRelayAuth
|
|
|
151
151
|
//#endregion
|
|
152
152
|
export { relaySecretFilePath as i, loadRelaySecretReadOnly as n, nearestPackageJsonDir as r, RELAY_SECRET_FILE_NAME as t };
|
|
153
153
|
|
|
154
|
-
//# sourceMappingURL=relay-secret-store-
|
|
154
|
+
//# sourceMappingURL=relay-secret-store-CmDDfZMh.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"relay-secret-store-CkA7KNUb.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-CmDDfZMh.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"}
|