@ait-co/devtools 0.1.58 → 0.1.60
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/deeplink-BONXxWEO.cjs +44 -0
- package/dist/deeplink-BONXxWEO.cjs.map +1 -0
- package/dist/deeplink-CCGiyoHq.cjs +44 -0
- package/dist/deeplink-CCGiyoHq.cjs.map +1 -0
- package/dist/deeplink-CaO6hZVG.js +44 -0
- package/dist/deeplink-CaO6hZVG.js.map +1 -0
- package/dist/deeplink-Cqli4qzm.js +44 -0
- package/dist/deeplink-Cqli4qzm.js.map +1 -0
- package/dist/devtools-opener-BbUXBzgA.js +65 -0
- package/dist/devtools-opener-BbUXBzgA.js.map +1 -0
- package/dist/devtools-opener-Bp671YXu.cjs +62 -0
- package/dist/devtools-opener-Bp671YXu.cjs.map +1 -0
- package/dist/devtools-opener-D84kZFtR.js +65 -0
- package/dist/devtools-opener-D84kZFtR.js.map +1 -0
- package/dist/devtools-opener-h6A-UjzC.cjs +62 -0
- package/dist/devtools-opener-h6A-UjzC.cjs.map +1 -0
- package/dist/mcp/cli.js +862 -403
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.js +5 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/panel/index.d.ts +15 -9
- package/dist/panel/index.d.ts.map +1 -1
- package/dist/panel/index.js +27705 -1965
- package/dist/panel/index.js.map +1 -1
- package/dist/qr-http-server-Byk0Yjk_.cjs +901 -0
- package/dist/qr-http-server-Byk0Yjk_.cjs.map +1 -0
- package/dist/qr-http-server-D_Aj5Vq6.cjs +901 -0
- package/dist/qr-http-server-D_Aj5Vq6.cjs.map +1 -0
- package/dist/qr-http-server-N4mX8GaC.js +901 -0
- package/dist/qr-http-server-N4mX8GaC.js.map +1 -0
- package/dist/qr-http-server-kYvmlXlg.js +901 -0
- package/dist/qr-http-server-kYvmlXlg.js.map +1 -0
- package/dist/relay-secret-store-5A7_7zOp.js +111 -0
- package/dist/relay-secret-store-5A7_7zOp.js.map +1 -0
- package/dist/{relay-secret-store-DqyUoeXy.js → relay-secret-store-C4QQN5NA.js} +3 -3
- package/dist/{relay-secret-store-DqyUoeXy.js.map → relay-secret-store-C4QQN5NA.js.map} +1 -1
- package/dist/{relay-secret-store-DnTNl-9z.cjs → relay-secret-store-CLkF8Pa0.cjs} +3 -2
- package/dist/{relay-secret-store-DnTNl-9z.cjs.map → relay-secret-store-CLkF8Pa0.cjs.map} +1 -1
- package/dist/relay-url-store-COG2dSql.cjs +113 -0
- package/dist/relay-url-store-COG2dSql.cjs.map +1 -0
- package/dist/relay-url-store-WKfo0VQV.js +112 -0
- package/dist/relay-url-store-WKfo0VQV.js.map +1 -0
- package/dist/relay-url-store-qaoe0zOD.js +118 -0
- package/dist/relay-url-store-qaoe0zOD.js.map +1 -0
- package/dist/totp-86i_CNqh.js +3 -0
- package/dist/{totp-D0a8VwoR.js → totp-BIrJHsQn.js} +1 -1
- package/dist/{totp-D0a8VwoR.js.map → totp-BIrJHsQn.js.map} +1 -1
- package/dist/{totp-BkP5yU2K.js → totp-BjtKFt88.js} +2 -2
- package/dist/{totp-BkP5yU2K.js.map → totp-BjtKFt88.js.map} +1 -1
- package/dist/totp-BxtxuEt4.js +64 -0
- package/dist/totp-BxtxuEt4.js.map +1 -0
- package/dist/totp-D9rndqg_.cjs +64 -0
- package/dist/totp-D9rndqg_.cjs.map +1 -0
- package/dist/{totp-DLgGbySX.cjs → totp-DA8vjAi7.cjs} +2 -1
- package/dist/{totp-DLgGbySX.cjs.map → totp-DA8vjAi7.cjs.map} +1 -1
- package/dist/{tunnel-nKYPtc-g.cjs → tunnel-GieyWa22.cjs} +114 -3
- package/dist/tunnel-GieyWa22.cjs.map +1 -0
- package/dist/{tunnel-CI61NvPI.js → tunnel-JuZ5_Pci.js} +114 -4
- package/dist/tunnel-JuZ5_Pci.js.map +1 -0
- package/dist/unplugin/index.cjs +116 -4
- package/dist/unplugin/index.cjs.map +1 -1
- package/dist/unplugin/index.d.cts +20 -1
- package/dist/unplugin/index.d.cts.map +1 -1
- package/dist/unplugin/index.d.ts +20 -1
- package/dist/unplugin/index.d.ts.map +1 -1
- package/dist/unplugin/index.js +116 -5
- package/dist/unplugin/index.js.map +1 -1
- package/dist/unplugin/tunnel.cjs +114 -2
- package/dist/unplugin/tunnel.cjs.map +1 -1
- package/dist/unplugin/tunnel.d.cts +62 -1
- package/dist/unplugin/tunnel.d.cts.map +1 -1
- package/dist/unplugin/tunnel.d.ts +62 -1
- package/dist/unplugin/tunnel.d.ts.map +1 -1
- package/dist/unplugin/tunnel.js +113 -3
- package/dist/unplugin/tunnel.js.map +1 -1
- package/package.json +11 -3
- package/dist/totp-CQFmgOhM.js +0 -3
- package/dist/tunnel-CI61NvPI.js.map +0 -1
- package/dist/tunnel-nKYPtc-g.cjs.map +0 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createHmac } from "node:crypto";
|
|
2
|
+
//#region src/mcp/totp.ts
|
|
3
|
+
/**
|
|
4
|
+
* RFC 6238 TOTP implementation (Node.js, node:crypto only).
|
|
5
|
+
*
|
|
6
|
+
* External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used
|
|
7
|
+
* to keep the dependency surface minimal. This hand-roll is ~30 lines and
|
|
8
|
+
* covers exactly what relay-side auth needs.
|
|
9
|
+
*
|
|
10
|
+
* Algorithm summary (RFC 6238 + RFC 4226):
|
|
11
|
+
* T = floor(now / 30) — 30-second time step counter
|
|
12
|
+
* K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)
|
|
13
|
+
* MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)
|
|
14
|
+
* offset = MAC[19] & 0x0f
|
|
15
|
+
* code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits
|
|
16
|
+
*
|
|
17
|
+
* Security note (keep this comment accurate):
|
|
18
|
+
* The baked-in secret in a dogfood build is extractable from the bundle by a
|
|
19
|
+
* determined reverse engineer. This mechanism raises the bar from
|
|
20
|
+
* "anyone with the URL" to "URL + bundle extraction + live TOTP calculation".
|
|
21
|
+
* Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are
|
|
22
|
+
* blocked; deliberate reverse engineering is not. See threat model in
|
|
23
|
+
* src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.
|
|
24
|
+
*
|
|
25
|
+
* SECRET-HANDLING: secret values and computed codes MUST NOT appear in any
|
|
26
|
+
* log, error message, or string visible outside this module. Only boolean
|
|
27
|
+
* pass/fail and reason enum values are safe to surface.
|
|
28
|
+
*/
|
|
29
|
+
/** Time step window in seconds (RFC 6238 default). */
|
|
30
|
+
const TIME_STEP = 30;
|
|
31
|
+
/** Number of digits in the generated code. */
|
|
32
|
+
const DIGITS = 6;
|
|
33
|
+
/**
|
|
34
|
+
* Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-
|
|
35
|
+
* clock time.
|
|
36
|
+
*
|
|
37
|
+
* @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32
|
|
38
|
+
* bytes). Must be the output of `generateAttachToken()` or compatible.
|
|
39
|
+
* @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
|
|
40
|
+
* @returns A zero-padded 6-digit decimal string, e.g. `"042193"`.
|
|
41
|
+
*/
|
|
42
|
+
function generateTotp(secret, when = Date.now()) {
|
|
43
|
+
const key = Buffer.from(secret, "hex");
|
|
44
|
+
const counter = Math.max(0, Math.floor(when / 1e3 / TIME_STEP));
|
|
45
|
+
const counterBuf = Buffer.alloc(8);
|
|
46
|
+
const hi = Math.floor(counter / 4294967296);
|
|
47
|
+
const lo = counter >>> 0;
|
|
48
|
+
counterBuf.writeUInt32BE(hi, 0);
|
|
49
|
+
counterBuf.writeUInt32BE(lo, 4);
|
|
50
|
+
const mac = createHmac("sha1", key).update(counterBuf).digest();
|
|
51
|
+
const offset = mac[19] & 15;
|
|
52
|
+
return (((mac[offset] & 127) << 24 | (mac[offset + 1] & 255) << 16 | (mac[offset + 2] & 255) << 8 | mac[offset + 3] & 255) % 10 ** DIGITS).toString().padStart(DIGITS, "0");
|
|
53
|
+
}
|
|
54
|
+
[
|
|
55
|
+
"[ait-debug] AIT_DEBUG_TOTP_SECRET이 필수입니다. 32자 이상 16진수(hex) 문자열을 설정하세요.",
|
|
56
|
+
"발급: openssl rand -hex 32",
|
|
57
|
+
"데몬은 start_debug의 projectRoot 인자로 받은 디렉토리에서 .ait_relay 파일을 읽어 이 시크릿을 채웁니다.",
|
|
58
|
+
"프로젝트에서 pnpm dev를 한 번 띄우면 unplugin이 .ait_relay를 자동 생성하니, projectRoot를 전달하세요.",
|
|
59
|
+
"자세히: https://docs.aitc.dev/guides/relay-auth-totp"
|
|
60
|
+
].join("\n");
|
|
61
|
+
//#endregion
|
|
62
|
+
export { generateTotp };
|
|
63
|
+
|
|
64
|
+
//# sourceMappingURL=totp-BxtxuEt4.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"totp-BxtxuEt4.js","names":[],"sources":["../src/mcp/totp.ts"],"sourcesContent":["/**\n * RFC 6238 TOTP implementation (Node.js, node:crypto only).\n *\n * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used\n * to keep the dependency surface minimal. This hand-roll is ~30 lines and\n * covers exactly what relay-side auth needs.\n *\n * Algorithm summary (RFC 6238 + RFC 4226):\n * T = floor(now / 30) — 30-second time step counter\n * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)\n * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)\n * offset = MAC[19] & 0x0f\n * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits\n *\n * Security note (keep this comment accurate):\n * The baked-in secret in a dogfood build is extractable from the bundle by a\n * determined reverse engineer. This mechanism raises the bar from\n * \"anyone with the URL\" to \"URL + bundle extraction + live TOTP calculation\".\n * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are\n * blocked; deliberate reverse engineering is not. See threat model in\n * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.\n *\n * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any\n * log, error message, or string visible outside this module. Only boolean\n * pass/fail and reason enum values are safe to surface.\n */\n\nimport { createHmac, timingSafeEqual } from 'node:crypto';\n\n/** Time step window in seconds (RFC 6238 default). */\nconst TIME_STEP = 30;\n\n/** Number of digits in the generated code. */\nconst DIGITS = 6;\n\n/**\n * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-\n * clock time.\n *\n * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32\n * bytes). Must be the output of `generateAttachToken()` or compatible.\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @returns A zero-padded 6-digit decimal string, e.g. `\"042193\"`.\n */\nexport function generateTotp(secret: string, when: number = Date.now()): string {\n const key = Buffer.from(secret, 'hex');\n // Clamp to 0 so negative timestamps (e.g. in ±skew checks near epoch) do not\n // produce a negative counter, which would cause writeUInt32BE to throw.\n const counter = Math.max(0, Math.floor(when / 1000 / TIME_STEP));\n\n // Encode counter as 8-byte big-endian unsigned integer.\n const counterBuf = Buffer.alloc(8);\n // JavaScript numbers are safe integers up to 2^53; counter is ~7.5×10^10 at\n // year 9999 — well within safe range so standard bitwise ops are fine.\n const hi = Math.floor(counter / 0x100000000);\n const lo = counter >>> 0;\n counterBuf.writeUInt32BE(hi, 0);\n counterBuf.writeUInt32BE(lo, 4);\n\n const mac = createHmac('sha1', key).update(counterBuf).digest();\n\n // Dynamic truncation (RFC 4226 §5.4).\n const offset = mac[19] & 0x0f;\n const binCode =\n ((mac[offset] & 0x7f) << 24) |\n ((mac[offset + 1] & 0xff) << 16) |\n ((mac[offset + 2] & 0xff) << 8) |\n (mac[offset + 3] & 0xff);\n\n const otp = binCode % 10 ** DIGITS;\n return otp.toString().padStart(DIGITS, '0');\n}\n\n/**\n * Verifies a TOTP code against the secret, accepting ±`skew` time steps to\n * tolerate clock drift between the relay host and the client device.\n *\n * Uses `timingSafeEqual` for constant-time comparison to prevent timing\n * side-channel attacks.\n *\n * @param secret - Hex-encoded shared secret.\n * @param code - The 6-digit code to verify (string or numeric).\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @param skew - Number of adjacent steps to accept on either side. Default 1\n * (accepts T-1, T, T+1 — a 90-second acceptance window).\n * @returns `true` if the code matches any accepted step, `false` otherwise.\n */\nexport function verifyTotp(\n secret: string,\n code: string,\n when: number = Date.now(),\n skew: number = 1,\n): boolean {\n const normalised = String(code).padStart(DIGITS, '0');\n if (normalised.length !== DIGITS || !/^\\d{6}$/.test(normalised)) {\n return false;\n }\n\n const candidateBuf = Buffer.from(normalised, 'utf8');\n\n for (let delta = -skew; delta <= skew; delta++) {\n const stepWhen = when + delta * TIME_STEP * 1000;\n const expected = generateTotp(secret, stepWhen);\n const expectedBuf = Buffer.from(expected, 'utf8');\n if (timingSafeEqual(expectedBuf, candidateBuf)) {\n return true;\n }\n }\n\n return false;\n}\n\n/**\n * Minimum length (in hex characters) accepted for `AIT_DEBUG_TOTP_SECRET`.\n *\n * The secret is hex-encoded (see {@link generateTotp} — `Buffer.from(secret,\n * 'hex')`). 32 hex chars = 16 bytes = 128 bits, the floor for an HMAC-SHA1 key\n * we are willing to gate a public relay behind. `generateAttachToken()` emits\n * 64 hex chars (32 bytes), comfortably above this bar.\n */\nconst MIN_SECRET_HEX_CHARS = 32;\n\n/** Hex string: one or more hex digits, case-insensitive (RFC 4648 base16). */\nconst HEX_RE = /^[0-9a-fA-F]+$/;\n\n/**\n * Human-facing guidance printed when {@link assertRelayAuthConfigured} fails.\n *\n * SECRET-HANDLING: this message states only the REQUIREMENT (≥32 hex chars) and\n * how to mint one. It NEVER echoes the configured value, its length, or any\n * fragment derived from it — see {@link assertRelayAuthConfigured}.\n *\n * Note on encoding: the secret is hex (base16), not base32 — `generateTotp`\n * decodes it with `Buffer.from(secret, 'hex')`. A base32 string would be\n * silently mis-decoded and every TOTP code would fail to match, so the minting\n * command emits hex.\n */\nexport const RELAY_AUTH_SECRET_MISSING_MESSAGE = [\n '[ait-debug] AIT_DEBUG_TOTP_SECRET이 필수입니다. 32자 이상 16진수(hex) 문자열을 설정하세요.',\n '발급: openssl rand -hex 32',\n '데몬은 start_debug의 projectRoot 인자로 받은 디렉토리에서 .ait_relay 파일을 읽어 이 시크릿을 채웁니다.',\n '프로젝트에서 pnpm dev를 한 번 띄우면 unplugin이 .ait_relay를 자동 생성하니, projectRoot를 전달하세요.',\n '자세히: https://docs.aitc.dev/guides/relay-auth-totp',\n].join('\\n');\n\n/**\n * Whether `secret` is a well-formed relay-auth TOTP secret: a hex string of at\n * least {@link MIN_SECRET_HEX_CHARS} characters with an even length (an odd\n * length would have its trailing nibble silently dropped by `Buffer.from(...,\n * 'hex')`, weakening the key without warning).\n *\n * Pure predicate so callers can test the validation independently of the\n * fail-fast side effect in {@link assertRelayAuthConfigured}.\n *\n * SECRET-HANDLING: returns only a boolean — the input value is never returned,\n * logged, or echoed.\n */\nexport function isValidRelayAuthSecret(secret: string | undefined): secret is string {\n if (secret === undefined || secret === '') return false;\n if (secret.length < MIN_SECRET_HEX_CHARS) return false;\n if (secret.length % 2 !== 0) return false;\n return HEX_RE.test(secret);\n}\n\n/**\n * Fail-fast guard enforcing that a relay-auth TOTP secret is configured before\n * a public-internet-exposed relay is booted (issue #250).\n *\n * Relay-auth (the §4 Layer C TOTP gate) is the only fail-fast layer that closes\n * the real gap: a leaked `wss://…trycloudflare.com` URL otherwise lets a third\n * party attach a debugger to a dogfood/live mini-app. Without a secret the relay\n * comes up unauthenticated, so this guard is called at every relay-boot site —\n * `bootRelayFamily` (intoss env 3/4) and `bootExternalRelayFamily` (env-2 PWA),\n * both eager and lazy. Local-only sessions never boot a relay and so never reach\n * this guard, matching the issue's exemption for non-relay debugging.\n *\n * Throws when the secret is unset, empty, too short, or not a valid hex string.\n * The thrown message is the bin entry's fatal stderr (see `cli.ts` `main().catch`)\n * — the same fatal model as the missing-`AIT_RELAY_BASE_URL` path.\n *\n * SECRET-HANDLING: the env value is read once, passed ONLY to the boolean\n * predicate, and never logged. The thrown message names the requirement, never\n * the value, its length, or any derived fragment.\n *\n * @param env - Environment to read from. Defaults to `process.env`; injectable\n * for tests so they never mutate the real process environment.\n */\nexport function assertRelayAuthConfigured(env: NodeJS.ProcessEnv = process.env): void {\n if (!isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n throw new Error(RELAY_AUTH_SECRET_MISSING_MESSAGE);\n }\n}\n\n/**\n * Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a\n * `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.\n *\n * The predicate checks the `at` query parameter against the current and\n * adjacent TOTP time steps (±1 skew) using {@link verifyTotp}.\n *\n * Returns `undefined` when the env var is not set — callers treat that as\n * \"auth disabled\" (no predicate registered on the relay). Note that since\n * issue #250 the secret is MANDATORY at every relay-boot site (enforced by\n * {@link assertRelayAuthConfigured} BEFORE the relay starts), so in production\n * this never returns `undefined` for a relay that actually boots; the\n * `undefined` branch only matters for the no-relay local path and tests.\n *\n * Lives here (not in the MCP server) so the unplugin's env-2 relay can wire the\n * same gate without importing the heavy MCP server module graph. Re-exported\n * from `debug-server.ts` for back-compat.\n *\n * SECRET-HANDLING: The secret value read from env is captured in a closure and\n * is NEVER written to any log, error message, or process output.\n */\nexport function buildRelayVerifyAuth(\n env: NodeJS.ProcessEnv = process.env,\n): ((req: import('node:http').IncomingMessage) => boolean) | undefined {\n const secret = env.AIT_DEBUG_TOTP_SECRET;\n if (!secret) return undefined;\n\n return (req) => {\n // Parse the `at` query param from the upgrade request URL.\n // req.url is the raw request path + query, e.g. `/client/id?target=…&at=123456`\n const rawUrl = req.url ?? '';\n const qIndex = rawUrl.indexOf('?');\n const queryStr = qIndex === -1 ? '' : rawUrl.slice(qIndex + 1);\n const params = new URLSearchParams(queryStr);\n const code = params.get('at') ?? '';\n\n // Do NOT log `code`, `secret`, or any derived value here.\n return verifyTotp(secret, code);\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,MAAM,YAAY;;AAGlB,MAAM,SAAS;;;;;;;;;;AAWf,SAAgB,aAAa,QAAgB,OAAe,KAAK,KAAK,EAAU;CAC9E,MAAM,MAAM,OAAO,KAAK,QAAQ,MAAM;CAGtC,MAAM,UAAU,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,MAAO,UAAU,CAAC;CAGhE,MAAM,aAAa,OAAO,MAAM,EAAE;CAGlC,MAAM,KAAK,KAAK,MAAM,UAAU,WAAY;CAC5C,MAAM,KAAK,YAAY;AACvB,YAAW,cAAc,IAAI,EAAE;AAC/B,YAAW,cAAc,IAAI,EAAE;CAE/B,MAAM,MAAM,WAAW,QAAQ,IAAI,CAAC,OAAO,WAAW,CAAC,QAAQ;CAG/D,MAAM,SAAS,IAAI,MAAM;AAQzB,WANI,IAAI,UAAU,QAAS,MACvB,IAAI,SAAS,KAAK,QAAS,MAC3B,IAAI,SAAS,KAAK,QAAS,IAC5B,IAAI,SAAS,KAAK,OAEC,MAAM,QACjB,UAAU,CAAC,SAAS,QAAQ,IAAI;;AAmEI;CAC/C;CACA;CACA;CACA;CACA;CACD,CAAC,KAAK,KAAK"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
let node_crypto = require("node:crypto");
|
|
2
|
+
//#region src/mcp/totp.ts
|
|
3
|
+
/**
|
|
4
|
+
* RFC 6238 TOTP implementation (Node.js, node:crypto only).
|
|
5
|
+
*
|
|
6
|
+
* External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used
|
|
7
|
+
* to keep the dependency surface minimal. This hand-roll is ~30 lines and
|
|
8
|
+
* covers exactly what relay-side auth needs.
|
|
9
|
+
*
|
|
10
|
+
* Algorithm summary (RFC 6238 + RFC 4226):
|
|
11
|
+
* T = floor(now / 30) — 30-second time step counter
|
|
12
|
+
* K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)
|
|
13
|
+
* MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)
|
|
14
|
+
* offset = MAC[19] & 0x0f
|
|
15
|
+
* code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits
|
|
16
|
+
*
|
|
17
|
+
* Security note (keep this comment accurate):
|
|
18
|
+
* The baked-in secret in a dogfood build is extractable from the bundle by a
|
|
19
|
+
* determined reverse engineer. This mechanism raises the bar from
|
|
20
|
+
* "anyone with the URL" to "URL + bundle extraction + live TOTP calculation".
|
|
21
|
+
* Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are
|
|
22
|
+
* blocked; deliberate reverse engineering is not. See threat model in
|
|
23
|
+
* src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.
|
|
24
|
+
*
|
|
25
|
+
* SECRET-HANDLING: secret values and computed codes MUST NOT appear in any
|
|
26
|
+
* log, error message, or string visible outside this module. Only boolean
|
|
27
|
+
* pass/fail and reason enum values are safe to surface.
|
|
28
|
+
*/
|
|
29
|
+
/** Time step window in seconds (RFC 6238 default). */
|
|
30
|
+
const TIME_STEP = 30;
|
|
31
|
+
/** Number of digits in the generated code. */
|
|
32
|
+
const DIGITS = 6;
|
|
33
|
+
/**
|
|
34
|
+
* Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-
|
|
35
|
+
* clock time.
|
|
36
|
+
*
|
|
37
|
+
* @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32
|
|
38
|
+
* bytes). Must be the output of `generateAttachToken()` or compatible.
|
|
39
|
+
* @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
|
|
40
|
+
* @returns A zero-padded 6-digit decimal string, e.g. `"042193"`.
|
|
41
|
+
*/
|
|
42
|
+
function generateTotp(secret, when = Date.now()) {
|
|
43
|
+
const key = Buffer.from(secret, "hex");
|
|
44
|
+
const counter = Math.max(0, Math.floor(when / 1e3 / TIME_STEP));
|
|
45
|
+
const counterBuf = Buffer.alloc(8);
|
|
46
|
+
const hi = Math.floor(counter / 4294967296);
|
|
47
|
+
const lo = counter >>> 0;
|
|
48
|
+
counterBuf.writeUInt32BE(hi, 0);
|
|
49
|
+
counterBuf.writeUInt32BE(lo, 4);
|
|
50
|
+
const mac = (0, node_crypto.createHmac)("sha1", key).update(counterBuf).digest();
|
|
51
|
+
const offset = mac[19] & 15;
|
|
52
|
+
return (((mac[offset] & 127) << 24 | (mac[offset + 1] & 255) << 16 | (mac[offset + 2] & 255) << 8 | mac[offset + 3] & 255) % 10 ** DIGITS).toString().padStart(DIGITS, "0");
|
|
53
|
+
}
|
|
54
|
+
[
|
|
55
|
+
"[ait-debug] AIT_DEBUG_TOTP_SECRET이 필수입니다. 32자 이상 16진수(hex) 문자열을 설정하세요.",
|
|
56
|
+
"발급: openssl rand -hex 32",
|
|
57
|
+
"데몬은 start_debug의 projectRoot 인자로 받은 디렉토리에서 .ait_relay 파일을 읽어 이 시크릿을 채웁니다.",
|
|
58
|
+
"프로젝트에서 pnpm dev를 한 번 띄우면 unplugin이 .ait_relay를 자동 생성하니, projectRoot를 전달하세요.",
|
|
59
|
+
"자세히: https://docs.aitc.dev/guides/relay-auth-totp"
|
|
60
|
+
].join("\n");
|
|
61
|
+
//#endregion
|
|
62
|
+
exports.generateTotp = generateTotp;
|
|
63
|
+
|
|
64
|
+
//# sourceMappingURL=totp-D9rndqg_.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"totp-D9rndqg_.cjs","names":[],"sources":["../src/mcp/totp.ts"],"sourcesContent":["/**\n * RFC 6238 TOTP implementation (Node.js, node:crypto only).\n *\n * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used\n * to keep the dependency surface minimal. This hand-roll is ~30 lines and\n * covers exactly what relay-side auth needs.\n *\n * Algorithm summary (RFC 6238 + RFC 4226):\n * T = floor(now / 30) — 30-second time step counter\n * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)\n * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)\n * offset = MAC[19] & 0x0f\n * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits\n *\n * Security note (keep this comment accurate):\n * The baked-in secret in a dogfood build is extractable from the bundle by a\n * determined reverse engineer. This mechanism raises the bar from\n * \"anyone with the URL\" to \"URL + bundle extraction + live TOTP calculation\".\n * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are\n * blocked; deliberate reverse engineering is not. See threat model in\n * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.\n *\n * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any\n * log, error message, or string visible outside this module. Only boolean\n * pass/fail and reason enum values are safe to surface.\n */\n\nimport { createHmac, timingSafeEqual } from 'node:crypto';\n\n/** Time step window in seconds (RFC 6238 default). */\nconst TIME_STEP = 30;\n\n/** Number of digits in the generated code. */\nconst DIGITS = 6;\n\n/**\n * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-\n * clock time.\n *\n * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32\n * bytes). Must be the output of `generateAttachToken()` or compatible.\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @returns A zero-padded 6-digit decimal string, e.g. `\"042193\"`.\n */\nexport function generateTotp(secret: string, when: number = Date.now()): string {\n const key = Buffer.from(secret, 'hex');\n // Clamp to 0 so negative timestamps (e.g. in ±skew checks near epoch) do not\n // produce a negative counter, which would cause writeUInt32BE to throw.\n const counter = Math.max(0, Math.floor(when / 1000 / TIME_STEP));\n\n // Encode counter as 8-byte big-endian unsigned integer.\n const counterBuf = Buffer.alloc(8);\n // JavaScript numbers are safe integers up to 2^53; counter is ~7.5×10^10 at\n // year 9999 — well within safe range so standard bitwise ops are fine.\n const hi = Math.floor(counter / 0x100000000);\n const lo = counter >>> 0;\n counterBuf.writeUInt32BE(hi, 0);\n counterBuf.writeUInt32BE(lo, 4);\n\n const mac = createHmac('sha1', key).update(counterBuf).digest();\n\n // Dynamic truncation (RFC 4226 §5.4).\n const offset = mac[19] & 0x0f;\n const binCode =\n ((mac[offset] & 0x7f) << 24) |\n ((mac[offset + 1] & 0xff) << 16) |\n ((mac[offset + 2] & 0xff) << 8) |\n (mac[offset + 3] & 0xff);\n\n const otp = binCode % 10 ** DIGITS;\n return otp.toString().padStart(DIGITS, '0');\n}\n\n/**\n * Verifies a TOTP code against the secret, accepting ±`skew` time steps to\n * tolerate clock drift between the relay host and the client device.\n *\n * Uses `timingSafeEqual` for constant-time comparison to prevent timing\n * side-channel attacks.\n *\n * @param secret - Hex-encoded shared secret.\n * @param code - The 6-digit code to verify (string or numeric).\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @param skew - Number of adjacent steps to accept on either side. Default 1\n * (accepts T-1, T, T+1 — a 90-second acceptance window).\n * @returns `true` if the code matches any accepted step, `false` otherwise.\n */\nexport function verifyTotp(\n secret: string,\n code: string,\n when: number = Date.now(),\n skew: number = 1,\n): boolean {\n const normalised = String(code).padStart(DIGITS, '0');\n if (normalised.length !== DIGITS || !/^\\d{6}$/.test(normalised)) {\n return false;\n }\n\n const candidateBuf = Buffer.from(normalised, 'utf8');\n\n for (let delta = -skew; delta <= skew; delta++) {\n const stepWhen = when + delta * TIME_STEP * 1000;\n const expected = generateTotp(secret, stepWhen);\n const expectedBuf = Buffer.from(expected, 'utf8');\n if (timingSafeEqual(expectedBuf, candidateBuf)) {\n return true;\n }\n }\n\n return false;\n}\n\n/**\n * Minimum length (in hex characters) accepted for `AIT_DEBUG_TOTP_SECRET`.\n *\n * The secret is hex-encoded (see {@link generateTotp} — `Buffer.from(secret,\n * 'hex')`). 32 hex chars = 16 bytes = 128 bits, the floor for an HMAC-SHA1 key\n * we are willing to gate a public relay behind. `generateAttachToken()` emits\n * 64 hex chars (32 bytes), comfortably above this bar.\n */\nconst MIN_SECRET_HEX_CHARS = 32;\n\n/** Hex string: one or more hex digits, case-insensitive (RFC 4648 base16). */\nconst HEX_RE = /^[0-9a-fA-F]+$/;\n\n/**\n * Human-facing guidance printed when {@link assertRelayAuthConfigured} fails.\n *\n * SECRET-HANDLING: this message states only the REQUIREMENT (≥32 hex chars) and\n * how to mint one. It NEVER echoes the configured value, its length, or any\n * fragment derived from it — see {@link assertRelayAuthConfigured}.\n *\n * Note on encoding: the secret is hex (base16), not base32 — `generateTotp`\n * decodes it with `Buffer.from(secret, 'hex')`. A base32 string would be\n * silently mis-decoded and every TOTP code would fail to match, so the minting\n * command emits hex.\n */\nexport const RELAY_AUTH_SECRET_MISSING_MESSAGE = [\n '[ait-debug] AIT_DEBUG_TOTP_SECRET이 필수입니다. 32자 이상 16진수(hex) 문자열을 설정하세요.',\n '발급: openssl rand -hex 32',\n '데몬은 start_debug의 projectRoot 인자로 받은 디렉토리에서 .ait_relay 파일을 읽어 이 시크릿을 채웁니다.',\n '프로젝트에서 pnpm dev를 한 번 띄우면 unplugin이 .ait_relay를 자동 생성하니, projectRoot를 전달하세요.',\n '자세히: https://docs.aitc.dev/guides/relay-auth-totp',\n].join('\\n');\n\n/**\n * Whether `secret` is a well-formed relay-auth TOTP secret: a hex string of at\n * least {@link MIN_SECRET_HEX_CHARS} characters with an even length (an odd\n * length would have its trailing nibble silently dropped by `Buffer.from(...,\n * 'hex')`, weakening the key without warning).\n *\n * Pure predicate so callers can test the validation independently of the\n * fail-fast side effect in {@link assertRelayAuthConfigured}.\n *\n * SECRET-HANDLING: returns only a boolean — the input value is never returned,\n * logged, or echoed.\n */\nexport function isValidRelayAuthSecret(secret: string | undefined): secret is string {\n if (secret === undefined || secret === '') return false;\n if (secret.length < MIN_SECRET_HEX_CHARS) return false;\n if (secret.length % 2 !== 0) return false;\n return HEX_RE.test(secret);\n}\n\n/**\n * Fail-fast guard enforcing that a relay-auth TOTP secret is configured before\n * a public-internet-exposed relay is booted (issue #250).\n *\n * Relay-auth (the §4 Layer C TOTP gate) is the only fail-fast layer that closes\n * the real gap: a leaked `wss://…trycloudflare.com` URL otherwise lets a third\n * party attach a debugger to a dogfood/live mini-app. Without a secret the relay\n * comes up unauthenticated, so this guard is called at every relay-boot site —\n * `bootRelayFamily` (intoss env 3/4) and `bootExternalRelayFamily` (env-2 PWA),\n * both eager and lazy. Local-only sessions never boot a relay and so never reach\n * this guard, matching the issue's exemption for non-relay debugging.\n *\n * Throws when the secret is unset, empty, too short, or not a valid hex string.\n * The thrown message is the bin entry's fatal stderr (see `cli.ts` `main().catch`)\n * — the same fatal model as the missing-`AIT_RELAY_BASE_URL` path.\n *\n * SECRET-HANDLING: the env value is read once, passed ONLY to the boolean\n * predicate, and never logged. The thrown message names the requirement, never\n * the value, its length, or any derived fragment.\n *\n * @param env - Environment to read from. Defaults to `process.env`; injectable\n * for tests so they never mutate the real process environment.\n */\nexport function assertRelayAuthConfigured(env: NodeJS.ProcessEnv = process.env): void {\n if (!isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n throw new Error(RELAY_AUTH_SECRET_MISSING_MESSAGE);\n }\n}\n\n/**\n * Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a\n * `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.\n *\n * The predicate checks the `at` query parameter against the current and\n * adjacent TOTP time steps (±1 skew) using {@link verifyTotp}.\n *\n * Returns `undefined` when the env var is not set — callers treat that as\n * \"auth disabled\" (no predicate registered on the relay). Note that since\n * issue #250 the secret is MANDATORY at every relay-boot site (enforced by\n * {@link assertRelayAuthConfigured} BEFORE the relay starts), so in production\n * this never returns `undefined` for a relay that actually boots; the\n * `undefined` branch only matters for the no-relay local path and tests.\n *\n * Lives here (not in the MCP server) so the unplugin's env-2 relay can wire the\n * same gate without importing the heavy MCP server module graph. Re-exported\n * from `debug-server.ts` for back-compat.\n *\n * SECRET-HANDLING: The secret value read from env is captured in a closure and\n * is NEVER written to any log, error message, or process output.\n */\nexport function buildRelayVerifyAuth(\n env: NodeJS.ProcessEnv = process.env,\n): ((req: import('node:http').IncomingMessage) => boolean) | undefined {\n const secret = env.AIT_DEBUG_TOTP_SECRET;\n if (!secret) return undefined;\n\n return (req) => {\n // Parse the `at` query param from the upgrade request URL.\n // req.url is the raw request path + query, e.g. `/client/id?target=…&at=123456`\n const rawUrl = req.url ?? '';\n const qIndex = rawUrl.indexOf('?');\n const queryStr = qIndex === -1 ? '' : rawUrl.slice(qIndex + 1);\n const params = new URLSearchParams(queryStr);\n const code = params.get('at') ?? '';\n\n // Do NOT log `code`, `secret`, or any derived value here.\n return verifyTotp(secret, code);\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,MAAM,YAAY;;AAGlB,MAAM,SAAS;;;;;;;;;;AAWf,SAAgB,aAAa,QAAgB,OAAe,KAAK,KAAK,EAAU;CAC9E,MAAM,MAAM,OAAO,KAAK,QAAQ,MAAM;CAGtC,MAAM,UAAU,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,MAAO,UAAU,CAAC;CAGhE,MAAM,aAAa,OAAO,MAAM,EAAE;CAGlC,MAAM,KAAK,KAAK,MAAM,UAAU,WAAY;CAC5C,MAAM,KAAK,YAAY;AACvB,YAAW,cAAc,IAAI,EAAE;AAC/B,YAAW,cAAc,IAAI,EAAE;CAE/B,MAAM,OAAA,GAAA,YAAA,YAAiB,QAAQ,IAAI,CAAC,OAAO,WAAW,CAAC,QAAQ;CAG/D,MAAM,SAAS,IAAI,MAAM;AAQzB,WANI,IAAI,UAAU,QAAS,MACvB,IAAI,SAAS,KAAK,QAAS,MAC3B,IAAI,SAAS,KAAK,QAAS,IAC5B,IAAI,SAAS,KAAK,OAEC,MAAM,QACjB,UAAU,CAAC,SAAS,QAAQ,IAAI;;AAmEI;CAC/C;CACA;CACA;CACA;CACA;CACD,CAAC,KAAK,KAAK"}
|
|
@@ -183,6 +183,7 @@ function buildRelayVerifyAuth(env = process.env) {
|
|
|
183
183
|
//#endregion
|
|
184
184
|
exports.assertRelayAuthConfigured = assertRelayAuthConfigured;
|
|
185
185
|
exports.buildRelayVerifyAuth = buildRelayVerifyAuth;
|
|
186
|
+
exports.generateTotp = generateTotp;
|
|
186
187
|
exports.isValidRelayAuthSecret = isValidRelayAuthSecret;
|
|
187
188
|
|
|
188
|
-
//# sourceMappingURL=totp-
|
|
189
|
+
//# sourceMappingURL=totp-DA8vjAi7.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"totp-DLgGbySX.cjs","names":[],"sources":["../src/mcp/totp.ts"],"sourcesContent":["/**\n * RFC 6238 TOTP implementation (Node.js, node:crypto only).\n *\n * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used\n * to keep the dependency surface minimal. This hand-roll is ~30 lines and\n * covers exactly what relay-side auth needs.\n *\n * Algorithm summary (RFC 6238 + RFC 4226):\n * T = floor(now / 30) — 30-second time step counter\n * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)\n * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)\n * offset = MAC[19] & 0x0f\n * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits\n *\n * Security note (keep this comment accurate):\n * The baked-in secret in a dogfood build is extractable from the bundle by a\n * determined reverse engineer. This mechanism raises the bar from\n * \"anyone with the URL\" to \"URL + bundle extraction + live TOTP calculation\".\n * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are\n * blocked; deliberate reverse engineering is not. See threat model in\n * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.\n *\n * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any\n * log, error message, or string visible outside this module. Only boolean\n * pass/fail and reason enum values are safe to surface.\n */\n\nimport { createHmac, timingSafeEqual } from 'node:crypto';\n\n/** Time step window in seconds (RFC 6238 default). */\nconst TIME_STEP = 30;\n\n/** Number of digits in the generated code. */\nconst DIGITS = 6;\n\n/**\n * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-\n * clock time.\n *\n * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32\n * bytes). Must be the output of `generateAttachToken()` or compatible.\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @returns A zero-padded 6-digit decimal string, e.g. `\"042193\"`.\n */\nexport function generateTotp(secret: string, when: number = Date.now()): string {\n const key = Buffer.from(secret, 'hex');\n // Clamp to 0 so negative timestamps (e.g. in ±skew checks near epoch) do not\n // produce a negative counter, which would cause writeUInt32BE to throw.\n const counter = Math.max(0, Math.floor(when / 1000 / TIME_STEP));\n\n // Encode counter as 8-byte big-endian unsigned integer.\n const counterBuf = Buffer.alloc(8);\n // JavaScript numbers are safe integers up to 2^53; counter is ~7.5×10^10 at\n // year 9999 — well within safe range so standard bitwise ops are fine.\n const hi = Math.floor(counter / 0x100000000);\n const lo = counter >>> 0;\n counterBuf.writeUInt32BE(hi, 0);\n counterBuf.writeUInt32BE(lo, 4);\n\n const mac = createHmac('sha1', key).update(counterBuf).digest();\n\n // Dynamic truncation (RFC 4226 §5.4).\n const offset = mac[19] & 0x0f;\n const binCode =\n ((mac[offset] & 0x7f) << 24) |\n ((mac[offset + 1] & 0xff) << 16) |\n ((mac[offset + 2] & 0xff) << 8) |\n (mac[offset + 3] & 0xff);\n\n const otp = binCode % 10 ** DIGITS;\n return otp.toString().padStart(DIGITS, '0');\n}\n\n/**\n * Verifies a TOTP code against the secret, accepting ±`skew` time steps to\n * tolerate clock drift between the relay host and the client device.\n *\n * Uses `timingSafeEqual` for constant-time comparison to prevent timing\n * side-channel attacks.\n *\n * @param secret - Hex-encoded shared secret.\n * @param code - The 6-digit code to verify (string or numeric).\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @param skew - Number of adjacent steps to accept on either side. Default 1\n * (accepts T-1, T, T+1 — a 90-second acceptance window).\n * @returns `true` if the code matches any accepted step, `false` otherwise.\n */\nexport function verifyTotp(\n secret: string,\n code: string,\n when: number = Date.now(),\n skew: number = 1,\n): boolean {\n const normalised = String(code).padStart(DIGITS, '0');\n if (normalised.length !== DIGITS || !/^\\d{6}$/.test(normalised)) {\n return false;\n }\n\n const candidateBuf = Buffer.from(normalised, 'utf8');\n\n for (let delta = -skew; delta <= skew; delta++) {\n const stepWhen = when + delta * TIME_STEP * 1000;\n const expected = generateTotp(secret, stepWhen);\n const expectedBuf = Buffer.from(expected, 'utf8');\n if (timingSafeEqual(expectedBuf, candidateBuf)) {\n return true;\n }\n }\n\n return false;\n}\n\n/**\n * Minimum length (in hex characters) accepted for `AIT_DEBUG_TOTP_SECRET`.\n *\n * The secret is hex-encoded (see {@link generateTotp} — `Buffer.from(secret,\n * 'hex')`). 32 hex chars = 16 bytes = 128 bits, the floor for an HMAC-SHA1 key\n * we are willing to gate a public relay behind. `generateAttachToken()` emits\n * 64 hex chars (32 bytes), comfortably above this bar.\n */\nconst MIN_SECRET_HEX_CHARS = 32;\n\n/** Hex string: one or more hex digits, case-insensitive (RFC 4648 base16). */\nconst HEX_RE = /^[0-9a-fA-F]+$/;\n\n/**\n * Human-facing guidance printed when {@link assertRelayAuthConfigured} fails.\n *\n * SECRET-HANDLING: this message states only the REQUIREMENT (≥32 hex chars) and\n * how to mint one. It NEVER echoes the configured value, its length, or any\n * fragment derived from it — see {@link assertRelayAuthConfigured}.\n *\n * Note on encoding: the secret is hex (base16), not base32 — `generateTotp`\n * decodes it with `Buffer.from(secret, 'hex')`. A base32 string would be\n * silently mis-decoded and every TOTP code would fail to match, so the minting\n * command emits hex.\n */\nexport const RELAY_AUTH_SECRET_MISSING_MESSAGE = [\n '[ait-debug] AIT_DEBUG_TOTP_SECRET이 필수입니다. 32자 이상 16진수(hex) 문자열을 설정하세요.',\n '발급: openssl rand -hex 32',\n '데몬은 start_debug의 projectRoot 인자로 받은 디렉토리에서 .ait_relay 파일을 읽어 이 시크릿을 채웁니다.',\n '프로젝트에서 pnpm dev를 한 번 띄우면 unplugin이 .ait_relay를 자동 생성하니, projectRoot를 전달하세요.',\n '자세히: https://docs.aitc.dev/guides/relay-auth-totp',\n].join('\\n');\n\n/**\n * Whether `secret` is a well-formed relay-auth TOTP secret: a hex string of at\n * least {@link MIN_SECRET_HEX_CHARS} characters with an even length (an odd\n * length would have its trailing nibble silently dropped by `Buffer.from(...,\n * 'hex')`, weakening the key without warning).\n *\n * Pure predicate so callers can test the validation independently of the\n * fail-fast side effect in {@link assertRelayAuthConfigured}.\n *\n * SECRET-HANDLING: returns only a boolean — the input value is never returned,\n * logged, or echoed.\n */\nexport function isValidRelayAuthSecret(secret: string | undefined): secret is string {\n if (secret === undefined || secret === '') return false;\n if (secret.length < MIN_SECRET_HEX_CHARS) return false;\n if (secret.length % 2 !== 0) return false;\n return HEX_RE.test(secret);\n}\n\n/**\n * Fail-fast guard enforcing that a relay-auth TOTP secret is configured before\n * a public-internet-exposed relay is booted (issue #250).\n *\n * Relay-auth (the §4 Layer C TOTP gate) is the only fail-fast layer that closes\n * the real gap: a leaked `wss://…trycloudflare.com` URL otherwise lets a third\n * party attach a debugger to a dogfood/live mini-app. Without a secret the relay\n * comes up unauthenticated, so this guard is called at every relay-boot site —\n * `bootRelayFamily` (intoss env 3/4) and `bootExternalRelayFamily` (env-2 PWA),\n * both eager and lazy. Local-only sessions never boot a relay and so never reach\n * this guard, matching the issue's exemption for non-relay debugging.\n *\n * Throws when the secret is unset, empty, too short, or not a valid hex string.\n * The thrown message is the bin entry's fatal stderr (see `cli.ts` `main().catch`)\n * — the same fatal model as the missing-`AIT_RELAY_BASE_URL` path.\n *\n * SECRET-HANDLING: the env value is read once, passed ONLY to the boolean\n * predicate, and never logged. The thrown message names the requirement, never\n * the value, its length, or any derived fragment.\n *\n * @param env - Environment to read from. Defaults to `process.env`; injectable\n * for tests so they never mutate the real process environment.\n */\nexport function assertRelayAuthConfigured(env: NodeJS.ProcessEnv = process.env): void {\n if (!isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n throw new Error(RELAY_AUTH_SECRET_MISSING_MESSAGE);\n }\n}\n\n/**\n * Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a\n * `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.\n *\n * The predicate checks the `at` query parameter against the current and\n * adjacent TOTP time steps (±1 skew) using {@link verifyTotp}.\n *\n * Returns `undefined` when the env var is not set — callers treat that as\n * \"auth disabled\" (no predicate registered on the relay). Note that since\n * issue #250 the secret is MANDATORY at every relay-boot site (enforced by\n * {@link assertRelayAuthConfigured} BEFORE the relay starts), so in production\n * this never returns `undefined` for a relay that actually boots; the\n * `undefined` branch only matters for the no-relay local path and tests.\n *\n * Lives here (not in the MCP server) so the unplugin's env-2 relay can wire the\n * same gate without importing the heavy MCP server module graph. Re-exported\n * from `debug-server.ts` for back-compat.\n *\n * SECRET-HANDLING: The secret value read from env is captured in a closure and\n * is NEVER written to any log, error message, or process output.\n */\nexport function buildRelayVerifyAuth(\n env: NodeJS.ProcessEnv = process.env,\n): ((req: import('node:http').IncomingMessage) => boolean) | undefined {\n const secret = env.AIT_DEBUG_TOTP_SECRET;\n if (!secret) return undefined;\n\n return (req) => {\n // Parse the `at` query param from the upgrade request URL.\n // req.url is the raw request path + query, e.g. `/client/id?target=…&at=123456`\n const rawUrl = req.url ?? '';\n const qIndex = rawUrl.indexOf('?');\n const queryStr = qIndex === -1 ? '' : rawUrl.slice(qIndex + 1);\n const params = new URLSearchParams(queryStr);\n const code = params.get('at') ?? '';\n\n // Do NOT log `code`, `secret`, or any derived value here.\n return verifyTotp(secret, code);\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,MAAM,YAAY;;AAGlB,MAAM,SAAS;;;;;;;;;;AAWf,SAAgB,aAAa,QAAgB,OAAe,KAAK,KAAK,EAAU;CAC9E,MAAM,MAAM,OAAO,KAAK,QAAQ,MAAM;CAGtC,MAAM,UAAU,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,MAAO,UAAU,CAAC;CAGhE,MAAM,aAAa,OAAO,MAAM,EAAE;CAGlC,MAAM,KAAK,KAAK,MAAM,UAAU,WAAY;CAC5C,MAAM,KAAK,YAAY;AACvB,YAAW,cAAc,IAAI,EAAE;AAC/B,YAAW,cAAc,IAAI,EAAE;CAE/B,MAAM,OAAA,GAAA,YAAA,YAAiB,QAAQ,IAAI,CAAC,OAAO,WAAW,CAAC,QAAQ;CAG/D,MAAM,SAAS,IAAI,MAAM;AAQzB,WANI,IAAI,UAAU,QAAS,MACvB,IAAI,SAAS,KAAK,QAAS,MAC3B,IAAI,SAAS,KAAK,QAAS,IAC5B,IAAI,SAAS,KAAK,OAEC,MAAM,QACjB,UAAU,CAAC,SAAS,QAAQ,IAAI;;;;;;;;;;;;;;;;AAiB7C,SAAgB,WACd,QACA,MACA,OAAe,KAAK,KAAK,EACzB,OAAe,GACN;CACT,MAAM,aAAa,OAAO,KAAK,CAAC,SAAS,QAAQ,IAAI;AACrD,KAAI,WAAW,WAAW,UAAU,CAAC,UAAU,KAAK,WAAW,CAC7D,QAAO;CAGT,MAAM,eAAe,OAAO,KAAK,YAAY,OAAO;AAEpD,MAAK,IAAI,QAAQ,CAAC,MAAM,SAAS,MAAM,SAAS;EAE9C,MAAM,WAAW,aAAa,QADb,OAAO,QAAQ,YAAY,IACG;AAE/C,OAAA,GAAA,YAAA,iBADoB,OAAO,KAAK,UAAU,OAAO,EAChB,aAAa,CAC5C,QAAO;;AAIX,QAAO;;;;;;;;;;AAWT,MAAM,uBAAuB;;AAG7B,MAAM,SAAS;;;;;;;;;;;;;AAcf,MAAa,oCAAoC;CAC/C;CACA;CACA;CACA;CACA;CACD,CAAC,KAAK,KAAK;;;;;;;;;;;;;AAcZ,SAAgB,uBAAuB,QAA8C;AACnF,KAAI,WAAW,KAAA,KAAa,WAAW,GAAI,QAAO;AAClD,KAAI,OAAO,SAAS,qBAAsB,QAAO;AACjD,KAAI,OAAO,SAAS,MAAM,EAAG,QAAO;AACpC,QAAO,OAAO,KAAK,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;AA0B5B,SAAgB,0BAA0B,MAAyB,QAAQ,KAAW;AACpF,KAAI,CAAC,uBAAuB,IAAI,sBAAsB,CACpD,OAAM,IAAI,MAAM,kCAAkC;;;;;;;;;;;;;;;;;;;;;;;AAyBtD,SAAgB,qBACd,MAAyB,QAAQ,KACoC;CACrE,MAAM,SAAS,IAAI;AACnB,KAAI,CAAC,OAAQ,QAAO,KAAA;AAEpB,SAAQ,QAAQ;EAGd,MAAM,SAAS,IAAI,OAAO;EAC1B,MAAM,SAAS,OAAO,QAAQ,IAAI;EAClC,MAAM,WAAW,WAAW,KAAK,KAAK,OAAO,MAAM,SAAS,EAAE;AAK9D,SAAO,WAAW,QAJH,IAAI,gBAAgB,SAAS,CACxB,IAAI,KAAK,IAAI,GAGF"}
|
|
1
|
+
{"version":3,"file":"totp-DA8vjAi7.cjs","names":[],"sources":["../src/mcp/totp.ts"],"sourcesContent":["/**\n * RFC 6238 TOTP implementation (Node.js, node:crypto only).\n *\n * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used\n * to keep the dependency surface minimal. This hand-roll is ~30 lines and\n * covers exactly what relay-side auth needs.\n *\n * Algorithm summary (RFC 6238 + RFC 4226):\n * T = floor(now / 30) — 30-second time step counter\n * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)\n * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)\n * offset = MAC[19] & 0x0f\n * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits\n *\n * Security note (keep this comment accurate):\n * The baked-in secret in a dogfood build is extractable from the bundle by a\n * determined reverse engineer. This mechanism raises the bar from\n * \"anyone with the URL\" to \"URL + bundle extraction + live TOTP calculation\".\n * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are\n * blocked; deliberate reverse engineering is not. See threat model in\n * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.\n *\n * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any\n * log, error message, or string visible outside this module. Only boolean\n * pass/fail and reason enum values are safe to surface.\n */\n\nimport { createHmac, timingSafeEqual } from 'node:crypto';\n\n/** Time step window in seconds (RFC 6238 default). */\nconst TIME_STEP = 30;\n\n/** Number of digits in the generated code. */\nconst DIGITS = 6;\n\n/**\n * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-\n * clock time.\n *\n * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32\n * bytes). Must be the output of `generateAttachToken()` or compatible.\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @returns A zero-padded 6-digit decimal string, e.g. `\"042193\"`.\n */\nexport function generateTotp(secret: string, when: number = Date.now()): string {\n const key = Buffer.from(secret, 'hex');\n // Clamp to 0 so negative timestamps (e.g. in ±skew checks near epoch) do not\n // produce a negative counter, which would cause writeUInt32BE to throw.\n const counter = Math.max(0, Math.floor(when / 1000 / TIME_STEP));\n\n // Encode counter as 8-byte big-endian unsigned integer.\n const counterBuf = Buffer.alloc(8);\n // JavaScript numbers are safe integers up to 2^53; counter is ~7.5×10^10 at\n // year 9999 — well within safe range so standard bitwise ops are fine.\n const hi = Math.floor(counter / 0x100000000);\n const lo = counter >>> 0;\n counterBuf.writeUInt32BE(hi, 0);\n counterBuf.writeUInt32BE(lo, 4);\n\n const mac = createHmac('sha1', key).update(counterBuf).digest();\n\n // Dynamic truncation (RFC 4226 §5.4).\n const offset = mac[19] & 0x0f;\n const binCode =\n ((mac[offset] & 0x7f) << 24) |\n ((mac[offset + 1] & 0xff) << 16) |\n ((mac[offset + 2] & 0xff) << 8) |\n (mac[offset + 3] & 0xff);\n\n const otp = binCode % 10 ** DIGITS;\n return otp.toString().padStart(DIGITS, '0');\n}\n\n/**\n * Verifies a TOTP code against the secret, accepting ±`skew` time steps to\n * tolerate clock drift between the relay host and the client device.\n *\n * Uses `timingSafeEqual` for constant-time comparison to prevent timing\n * side-channel attacks.\n *\n * @param secret - Hex-encoded shared secret.\n * @param code - The 6-digit code to verify (string or numeric).\n * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.\n * @param skew - Number of adjacent steps to accept on either side. Default 1\n * (accepts T-1, T, T+1 — a 90-second acceptance window).\n * @returns `true` if the code matches any accepted step, `false` otherwise.\n */\nexport function verifyTotp(\n secret: string,\n code: string,\n when: number = Date.now(),\n skew: number = 1,\n): boolean {\n const normalised = String(code).padStart(DIGITS, '0');\n if (normalised.length !== DIGITS || !/^\\d{6}$/.test(normalised)) {\n return false;\n }\n\n const candidateBuf = Buffer.from(normalised, 'utf8');\n\n for (let delta = -skew; delta <= skew; delta++) {\n const stepWhen = when + delta * TIME_STEP * 1000;\n const expected = generateTotp(secret, stepWhen);\n const expectedBuf = Buffer.from(expected, 'utf8');\n if (timingSafeEqual(expectedBuf, candidateBuf)) {\n return true;\n }\n }\n\n return false;\n}\n\n/**\n * Minimum length (in hex characters) accepted for `AIT_DEBUG_TOTP_SECRET`.\n *\n * The secret is hex-encoded (see {@link generateTotp} — `Buffer.from(secret,\n * 'hex')`). 32 hex chars = 16 bytes = 128 bits, the floor for an HMAC-SHA1 key\n * we are willing to gate a public relay behind. `generateAttachToken()` emits\n * 64 hex chars (32 bytes), comfortably above this bar.\n */\nconst MIN_SECRET_HEX_CHARS = 32;\n\n/** Hex string: one or more hex digits, case-insensitive (RFC 4648 base16). */\nconst HEX_RE = /^[0-9a-fA-F]+$/;\n\n/**\n * Human-facing guidance printed when {@link assertRelayAuthConfigured} fails.\n *\n * SECRET-HANDLING: this message states only the REQUIREMENT (≥32 hex chars) and\n * how to mint one. It NEVER echoes the configured value, its length, or any\n * fragment derived from it — see {@link assertRelayAuthConfigured}.\n *\n * Note on encoding: the secret is hex (base16), not base32 — `generateTotp`\n * decodes it with `Buffer.from(secret, 'hex')`. A base32 string would be\n * silently mis-decoded and every TOTP code would fail to match, so the minting\n * command emits hex.\n */\nexport const RELAY_AUTH_SECRET_MISSING_MESSAGE = [\n '[ait-debug] AIT_DEBUG_TOTP_SECRET이 필수입니다. 32자 이상 16진수(hex) 문자열을 설정하세요.',\n '발급: openssl rand -hex 32',\n '데몬은 start_debug의 projectRoot 인자로 받은 디렉토리에서 .ait_relay 파일을 읽어 이 시크릿을 채웁니다.',\n '프로젝트에서 pnpm dev를 한 번 띄우면 unplugin이 .ait_relay를 자동 생성하니, projectRoot를 전달하세요.',\n '자세히: https://docs.aitc.dev/guides/relay-auth-totp',\n].join('\\n');\n\n/**\n * Whether `secret` is a well-formed relay-auth TOTP secret: a hex string of at\n * least {@link MIN_SECRET_HEX_CHARS} characters with an even length (an odd\n * length would have its trailing nibble silently dropped by `Buffer.from(...,\n * 'hex')`, weakening the key without warning).\n *\n * Pure predicate so callers can test the validation independently of the\n * fail-fast side effect in {@link assertRelayAuthConfigured}.\n *\n * SECRET-HANDLING: returns only a boolean — the input value is never returned,\n * logged, or echoed.\n */\nexport function isValidRelayAuthSecret(secret: string | undefined): secret is string {\n if (secret === undefined || secret === '') return false;\n if (secret.length < MIN_SECRET_HEX_CHARS) return false;\n if (secret.length % 2 !== 0) return false;\n return HEX_RE.test(secret);\n}\n\n/**\n * Fail-fast guard enforcing that a relay-auth TOTP secret is configured before\n * a public-internet-exposed relay is booted (issue #250).\n *\n * Relay-auth (the §4 Layer C TOTP gate) is the only fail-fast layer that closes\n * the real gap: a leaked `wss://…trycloudflare.com` URL otherwise lets a third\n * party attach a debugger to a dogfood/live mini-app. Without a secret the relay\n * comes up unauthenticated, so this guard is called at every relay-boot site —\n * `bootRelayFamily` (intoss env 3/4) and `bootExternalRelayFamily` (env-2 PWA),\n * both eager and lazy. Local-only sessions never boot a relay and so never reach\n * this guard, matching the issue's exemption for non-relay debugging.\n *\n * Throws when the secret is unset, empty, too short, or not a valid hex string.\n * The thrown message is the bin entry's fatal stderr (see `cli.ts` `main().catch`)\n * — the same fatal model as the missing-`AIT_RELAY_BASE_URL` path.\n *\n * SECRET-HANDLING: the env value is read once, passed ONLY to the boolean\n * predicate, and never logged. The thrown message names the requirement, never\n * the value, its length, or any derived fragment.\n *\n * @param env - Environment to read from. Defaults to `process.env`; injectable\n * for tests so they never mutate the real process environment.\n */\nexport function assertRelayAuthConfigured(env: NodeJS.ProcessEnv = process.env): void {\n if (!isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) {\n throw new Error(RELAY_AUTH_SECRET_MISSING_MESSAGE);\n }\n}\n\n/**\n * Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a\n * `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.\n *\n * The predicate checks the `at` query parameter against the current and\n * adjacent TOTP time steps (±1 skew) using {@link verifyTotp}.\n *\n * Returns `undefined` when the env var is not set — callers treat that as\n * \"auth disabled\" (no predicate registered on the relay). Note that since\n * issue #250 the secret is MANDATORY at every relay-boot site (enforced by\n * {@link assertRelayAuthConfigured} BEFORE the relay starts), so in production\n * this never returns `undefined` for a relay that actually boots; the\n * `undefined` branch only matters for the no-relay local path and tests.\n *\n * Lives here (not in the MCP server) so the unplugin's env-2 relay can wire the\n * same gate without importing the heavy MCP server module graph. Re-exported\n * from `debug-server.ts` for back-compat.\n *\n * SECRET-HANDLING: The secret value read from env is captured in a closure and\n * is NEVER written to any log, error message, or process output.\n */\nexport function buildRelayVerifyAuth(\n env: NodeJS.ProcessEnv = process.env,\n): ((req: import('node:http').IncomingMessage) => boolean) | undefined {\n const secret = env.AIT_DEBUG_TOTP_SECRET;\n if (!secret) return undefined;\n\n return (req) => {\n // Parse the `at` query param from the upgrade request URL.\n // req.url is the raw request path + query, e.g. `/client/id?target=…&at=123456`\n const rawUrl = req.url ?? '';\n const qIndex = rawUrl.indexOf('?');\n const queryStr = qIndex === -1 ? '' : rawUrl.slice(qIndex + 1);\n const params = new URLSearchParams(queryStr);\n const code = params.get('at') ?? '';\n\n // Do NOT log `code`, `secret`, or any derived value here.\n return verifyTotp(secret, code);\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,MAAM,YAAY;;AAGlB,MAAM,SAAS;;;;;;;;;;AAWf,SAAgB,aAAa,QAAgB,OAAe,KAAK,KAAK,EAAU;CAC9E,MAAM,MAAM,OAAO,KAAK,QAAQ,MAAM;CAGtC,MAAM,UAAU,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,MAAO,UAAU,CAAC;CAGhE,MAAM,aAAa,OAAO,MAAM,EAAE;CAGlC,MAAM,KAAK,KAAK,MAAM,UAAU,WAAY;CAC5C,MAAM,KAAK,YAAY;AACvB,YAAW,cAAc,IAAI,EAAE;AAC/B,YAAW,cAAc,IAAI,EAAE;CAE/B,MAAM,OAAA,GAAA,YAAA,YAAiB,QAAQ,IAAI,CAAC,OAAO,WAAW,CAAC,QAAQ;CAG/D,MAAM,SAAS,IAAI,MAAM;AAQzB,WANI,IAAI,UAAU,QAAS,MACvB,IAAI,SAAS,KAAK,QAAS,MAC3B,IAAI,SAAS,KAAK,QAAS,IAC5B,IAAI,SAAS,KAAK,OAEC,MAAM,QACjB,UAAU,CAAC,SAAS,QAAQ,IAAI;;;;;;;;;;;;;;;;AAiB7C,SAAgB,WACd,QACA,MACA,OAAe,KAAK,KAAK,EACzB,OAAe,GACN;CACT,MAAM,aAAa,OAAO,KAAK,CAAC,SAAS,QAAQ,IAAI;AACrD,KAAI,WAAW,WAAW,UAAU,CAAC,UAAU,KAAK,WAAW,CAC7D,QAAO;CAGT,MAAM,eAAe,OAAO,KAAK,YAAY,OAAO;AAEpD,MAAK,IAAI,QAAQ,CAAC,MAAM,SAAS,MAAM,SAAS;EAE9C,MAAM,WAAW,aAAa,QADb,OAAO,QAAQ,YAAY,IACG;AAE/C,OAAA,GAAA,YAAA,iBADoB,OAAO,KAAK,UAAU,OAAO,EAChB,aAAa,CAC5C,QAAO;;AAIX,QAAO;;;;;;;;;;AAWT,MAAM,uBAAuB;;AAG7B,MAAM,SAAS;;;;;;;;;;;;;AAcf,MAAa,oCAAoC;CAC/C;CACA;CACA;CACA;CACA;CACD,CAAC,KAAK,KAAK;;;;;;;;;;;;;AAcZ,SAAgB,uBAAuB,QAA8C;AACnF,KAAI,WAAW,KAAA,KAAa,WAAW,GAAI,QAAO;AAClD,KAAI,OAAO,SAAS,qBAAsB,QAAO;AACjD,KAAI,OAAO,SAAS,MAAM,EAAG,QAAO;AACpC,QAAO,OAAO,KAAK,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;AA0B5B,SAAgB,0BAA0B,MAAyB,QAAQ,KAAW;AACpF,KAAI,CAAC,uBAAuB,IAAI,sBAAsB,CACpD,OAAM,IAAI,MAAM,kCAAkC;;;;;;;;;;;;;;;;;;;;;;;AAyBtD,SAAgB,qBACd,MAAyB,QAAQ,KACoC;CACrE,MAAM,SAAS,IAAI;AACnB,KAAI,CAAC,OAAQ,QAAO,KAAA;AAEpB,SAAQ,QAAQ;EAGd,MAAM,SAAS,IAAI,OAAO;EAC1B,MAAM,SAAS,OAAO,QAAQ,IAAI;EAClC,MAAM,WAAW,WAAW,KAAK,KAAK,OAAO,MAAM,SAAS,EAAE;AAK9D,SAAO,WAAW,QAJH,IAAI,gBAAgB,SAAS,CACxB,IAAI,KAAK,IAAI,GAGF"}
|
|
@@ -76,6 +76,101 @@ async function printTunnelBanner(url, opts = {}) {
|
|
|
76
76
|
});
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Heuristic: can this process open a GUI browser? Mirrors `canOpenBrowser` in
|
|
81
|
+
* `src/mcp/tools.ts` but is re-declared here (not imported) so the tunnel path
|
|
82
|
+
* does not statically pull the heavy MCP `tools.ts` module graph into the lazy
|
|
83
|
+
* `import('./tunnel.js')` chunk. Kept in sync with the MCP copy.
|
|
84
|
+
*
|
|
85
|
+
* - macOS / Windows → assume yes (env-2 dev normally runs on the user's Mac).
|
|
86
|
+
* - Linux → require `DISPLAY` or `WAYLAND_DISPLAY`.
|
|
87
|
+
* - CI (`CI=true`/`CI=1`) → no.
|
|
88
|
+
*/
|
|
89
|
+
function canOpenBrowser() {
|
|
90
|
+
if (process.env.CI === "true" || process.env.CI === "1") return false;
|
|
91
|
+
const platform = process.platform;
|
|
92
|
+
if (platform === "darwin" || platform === "win32") return true;
|
|
93
|
+
if (platform === "linux") return Boolean(process.env.DISPLAY ?? process.env.WAYLAND_DISPLAY);
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Env-2 UX parity with env 3/4 (issue #408): when CDP wiring is on and a GUI is
|
|
98
|
+
* available, start the SAME `127.0.0.1` HTML dashboard (QR image + connect steps
|
|
99
|
+
* + FAQ) that the MCP `build_attach_url` path serves, and auto-open it in the
|
|
100
|
+
* browser. headless / opt-out falls back to the terminal ASCII QR (printed
|
|
101
|
+
* separately by {@link printTunnelBanner}).
|
|
102
|
+
*
|
|
103
|
+
* Every part the install-graph invariant depends on (`qrcode`, the MCP HTTP
|
|
104
|
+
* server, the opener) is reached only through dynamic `import()` here, inside
|
|
105
|
+
* the already-lazy `tunnel.js` chunk — nothing is added to the common build
|
|
106
|
+
* graph or the MCP-only install graph.
|
|
107
|
+
*
|
|
108
|
+
* TOTP encapsulation: the dashboard's `getDashboardState` closure mints a FRESH
|
|
109
|
+
* TOTP `at=` code on every call via `generateTotp(secret, Date.now())` and folds
|
|
110
|
+
* it into a fresh `buildLauncherAttachUrl(...)`. Because the QR is re-rendered on
|
|
111
|
+
* each SSE push / page reload from this closure, the code a phone scans is always
|
|
112
|
+
* within its 30 s window — no stale code is baked into static HTML.
|
|
113
|
+
*
|
|
114
|
+
* SECRET-HANDLING: the tunnel host, relay wssUrl, TOTP code, and `.ait_relay`
|
|
115
|
+
* value/path are NEVER written to stdout/stderr/logs here. They live only inside
|
|
116
|
+
* the attach URL (HTML body + `/qr.png` query, per qr-http-server's invariant).
|
|
117
|
+
* The only thing opened/logged is `http://127.0.0.1:<port>` (local, safe).
|
|
118
|
+
*
|
|
119
|
+
* @returns the dashboard handle when it started (caller wires `close()` into the
|
|
120
|
+
* tunnel cleanup), or `undefined` when skipped (no relay, `qr:false`, headless,
|
|
121
|
+
* opt-out, or a start failure) — in which case ASCII QR fallback stands alone.
|
|
122
|
+
*/
|
|
123
|
+
async function startTunnelDashboard(opts) {
|
|
124
|
+
const log = opts.log ?? ((m) => console.log(m));
|
|
125
|
+
if (!opts.relayWssUrl) return void 0;
|
|
126
|
+
if (opts.qr === false) return void 0;
|
|
127
|
+
const { isAutoDevtoolsDisabled } = await Promise.resolve().then(() => require("./devtools-opener-Bp671YXu.cjs"));
|
|
128
|
+
if (!(opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser()))()) return void 0;
|
|
129
|
+
const { startQrHttpServer } = await Promise.resolve().then(() => require("./qr-http-server-Byk0Yjk_.cjs"));
|
|
130
|
+
const { buildLauncherAttachUrl } = await Promise.resolve().then(() => require("./deeplink-BONXxWEO.cjs"));
|
|
131
|
+
const { generateTotp } = await Promise.resolve().then(() => require("./totp-DA8vjAi7.cjs"));
|
|
132
|
+
const getDashboardState = () => {
|
|
133
|
+
const secret = process.env.AIT_DEBUG_TOTP_SECRET;
|
|
134
|
+
const totpCode = secret ? generateTotp(secret, Date.now()) : void 0;
|
|
135
|
+
const attachUrl = buildLauncherAttachUrl(opts.tunnelUrl, opts.relayWssUrl, totpCode);
|
|
136
|
+
return {
|
|
137
|
+
tunnel: {
|
|
138
|
+
up: true,
|
|
139
|
+
wssUrl: opts.relayWssUrl
|
|
140
|
+
},
|
|
141
|
+
pages: null,
|
|
142
|
+
attachUrl
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
let server;
|
|
146
|
+
try {
|
|
147
|
+
server = await startQrHttpServer(getDashboardState);
|
|
148
|
+
} catch {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const dashboardUrl = `http://127.0.0.1:${server.port}`;
|
|
152
|
+
const { openUrlInBrowser } = await Promise.resolve().then(() => require("./devtools-opener-Bp671YXu.cjs"));
|
|
153
|
+
log(openUrlInBrowser(dashboardUrl) ? ` │ Opened a QR dashboard in your browser: ${dashboardUrl}` : ` │ Open this QR dashboard in your browser: ${dashboardUrl}`);
|
|
154
|
+
return {
|
|
155
|
+
url: dashboardUrl,
|
|
156
|
+
close: () => server.close()
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Sanitize cloudflared stderr output for error diagnostics (#421).
|
|
161
|
+
*
|
|
162
|
+
* Masks `*.trycloudflare.com` hostnames and full `https://` / `wss://` URLs
|
|
163
|
+
* that carry those hostnames so tunnel host values never appear in error
|
|
164
|
+
* messages. Diagnostic content (error codes, reasons, JSON blobs) is preserved.
|
|
165
|
+
*
|
|
166
|
+
* SECRET-HANDLING: tunnel host is SECRET-class per harness policy — only
|
|
167
|
+
* placeholder text is emitted.
|
|
168
|
+
*/
|
|
169
|
+
function sanitizeCloudflaredOutput(line) {
|
|
170
|
+
let s = line.replace(/(?:https?|wss?):\/\/[a-z0-9-]+\.trycloudflare\.com(?:\/[^\s]*)*/gi, (m) => m.replace(/[a-z0-9-]+\.trycloudflare\.com/i, "<HOST>.trycloudflare.com"));
|
|
171
|
+
s = s.replace(/[a-z0-9-]+\.trycloudflare\.com/gi, "<HOST>.trycloudflare.com");
|
|
172
|
+
return s;
|
|
173
|
+
}
|
|
79
174
|
const URL_TIMEOUT_MS = 2e4;
|
|
80
175
|
/**
|
|
81
176
|
* Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`
|
|
@@ -99,10 +194,20 @@ async function startQuickTunnel(port) {
|
|
|
99
194
|
} catch {}
|
|
100
195
|
};
|
|
101
196
|
return new Promise((resolve, reject) => {
|
|
197
|
+
const stderrLines = [];
|
|
198
|
+
/**
|
|
199
|
+
* Format the last `n` sanitized stderr lines as a diagnostic appendix.
|
|
200
|
+
* Returns an empty string when no lines have been collected.
|
|
201
|
+
*/
|
|
202
|
+
const stderrTail = (n = 15) => {
|
|
203
|
+
if (stderrLines.length === 0) return "";
|
|
204
|
+
const tail = stderrLines.slice(-n).map(sanitizeCloudflaredOutput).join("");
|
|
205
|
+
return `\ncloudflared 출력 (마지막 ${Math.min(n, stderrLines.length)}줄):\n${tail}`;
|
|
206
|
+
};
|
|
102
207
|
const timer = setTimeout(() => {
|
|
103
208
|
cleanup();
|
|
104
209
|
stop();
|
|
105
|
-
reject(/* @__PURE__ */ new Error(`[@ait-co/devtools] cloudflared did not report a tunnel URL within ${URL_TIMEOUT_MS / 1e3}s. Check your network connection, or run \`cloudflared tunnel --url http://localhost:${port}\` manually
|
|
210
|
+
reject(/* @__PURE__ */ new Error(`[@ait-co/devtools] cloudflared did not report a tunnel URL within ${URL_TIMEOUT_MS / 1e3}s. Check your network connection, or run \`cloudflared tunnel --url http://localhost:${port}\` manually.${stderrTail()}`));
|
|
106
211
|
}, URL_TIMEOUT_MS);
|
|
107
212
|
const onUrl = (line) => {
|
|
108
213
|
const found = parseTrycloudflareUrl(line);
|
|
@@ -114,13 +219,18 @@ async function startQuickTunnel(port) {
|
|
|
114
219
|
stop
|
|
115
220
|
});
|
|
116
221
|
};
|
|
222
|
+
const pushStderr = (line) => {
|
|
223
|
+
stderrLines.push(line);
|
|
224
|
+
};
|
|
117
225
|
const cleanup = () => {
|
|
118
226
|
tunnel.off("stdout", onUrl);
|
|
119
227
|
tunnel.off("stderr", onUrl);
|
|
228
|
+
tunnel.off("stderr", pushStderr);
|
|
120
229
|
};
|
|
121
230
|
tunnel.once("url", onUrl);
|
|
122
231
|
tunnel.on("stdout", onUrl);
|
|
123
232
|
tunnel.on("stderr", onUrl);
|
|
233
|
+
tunnel.on("stderr", pushStderr);
|
|
124
234
|
tunnel.once("error", (err) => {
|
|
125
235
|
clearTimeout(timer);
|
|
126
236
|
cleanup();
|
|
@@ -131,12 +241,13 @@ async function startQuickTunnel(port) {
|
|
|
131
241
|
if (stopped) return;
|
|
132
242
|
clearTimeout(timer);
|
|
133
243
|
cleanup();
|
|
134
|
-
reject(/* @__PURE__ */ new Error(`[@ait-co/devtools] cloudflared exited (code ${code ?? "null"}) before reporting a tunnel URL
|
|
244
|
+
reject(/* @__PURE__ */ new Error(`[@ait-co/devtools] cloudflared exited (code ${code ?? "null"}) before reporting a tunnel URL.${stderrTail()}`));
|
|
135
245
|
});
|
|
136
246
|
});
|
|
137
247
|
}
|
|
138
248
|
//#endregion
|
|
139
249
|
exports.printTunnelBanner = printTunnelBanner;
|
|
140
250
|
exports.startQuickTunnel = startQuickTunnel;
|
|
251
|
+
exports.startTunnelDashboard = startTunnelDashboard;
|
|
141
252
|
|
|
142
|
-
//# sourceMappingURL=tunnel-
|
|
253
|
+
//# sourceMappingURL=tunnel-GieyWa22.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tunnel-GieyWa22.cjs","names":[],"sources":["../src/unplugin/tunnel.ts"],"sourcesContent":["/**\n * Cloudflare quick-tunnel helper for the devtools unplugin.\n *\n * Loaded lazily (`await import('./tunnel.js')`) only when the `tunnel` option is\n * on, so `cloudflared` / `qrcode-terminal` are never pulled in for the common\n * case. This is the one place in `@ait-co/devtools` that depends on Node-only\n * APIs (`child_process` via the `cloudflared` wrapper) — keep it thin and out of\n * jsdom unit tests; the spawn path is verified by hand / e2e (same spirit as the\n * \"web 모드는 e2e\" rule in CLAUDE.md). The pure helpers below\n * (`parseTrycloudflareUrl`, `printTunnelBanner`) are unit-tested.\n */\n\nimport { existsSync } from 'node:fs';\nimport { mkdir } from 'node:fs/promises';\nimport { dirname } from 'node:path';\n\n/** Matches the public URL cloudflared prints for an unauthenticated quick tunnel. */\nconst TRYCLOUDFLARE_RE = /https:\\/\\/[a-z0-9-]+\\.trycloudflare\\.com/i;\n\n/**\n * Extract the `https://<sub>.trycloudflare.com` URL from a line of cloudflared\n * output, or `null` if the line doesn't contain one. Pulled out as a pure\n * function so it can be unit-tested without spawning anything.\n */\nexport function parseTrycloudflareUrl(line: string): string | null {\n const m = line.match(TRYCLOUDFLARE_RE);\n return m ? m[0] : null;\n}\n\nexport interface PrintTunnelBannerOptions {\n /** Print an ASCII QR encoding the tunnel URL (default: true). */\n qr?: boolean;\n /** Sink for the banner text (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n /**\n * The `wss://` relay URL of the env-2 CDP tunnel, if `tunnel.cdp` is on. When\n * present the QR deep-link additionally carries `&debug=1&relay=<wss>` so the\n * framed PWA passes the in-app debug gate and attaches a Chii target — the\n * same single scan opens screen preview *and* CDP debugging.\n */\n relayWssUrl?: string;\n}\n\nconst LAUNCHER_URL = 'https://devtools.aitc.dev/launcher/';\n\n/**\n * Build the deep-link URL that QR codes encode: when the launcher PWA is\n * already on the phone's home screen, scanning this opens it directly into the\n * live view for `tunnelUrl` (the launcher consumes `?url=` and clears it).\n * Plain-text raw URL is no longer enough — the launcher gates its setup UI to\n * the installed PWA, so a raw tunnel URL opened in a normal browser tab would\n * land on a \"please install\" screen.\n *\n * When `relayWssUrl` is given (env-2 CDP wiring), the deep-link also carries\n * `&debug=1&relay=<wss>`; the launcher folds those onto the framed tunnel URL so\n * the in-app debug gate's Layer C (`debug=1` opt-in + `relay=<wss>`) is met and\n * a Chii target.js is injected into the live view.\n */\nexport function buildLauncherDeepLink(tunnelUrl: string, relayWssUrl?: string): string {\n const base = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}`;\n if (!relayWssUrl) return base;\n return `${base}&debug=1&relay=${encodeURIComponent(relayWssUrl)}`;\n}\n\n/**\n * Print the terminal banner announcing the live tunnel: the public URL, an ASCII\n * QR encoding a launcher deep-link, and a one-line note that quick tunnels are\n * ephemeral, unauthenticated and not for production. Pure w.r.t. side effects\n * other than the injected `log` sink and `qrcode-terminal` — unit-tested.\n */\nexport async function printTunnelBanner(\n url: string,\n opts: PrintTunnelBannerOptions = {},\n): Promise<void> {\n const log = opts.log ?? ((m: string) => console.log(m));\n const deepLink = buildLauncherDeepLink(url, opts.relayWssUrl);\n const lines: string[] = [\n '',\n ' ┌─ @ait-co/devtools · live tunnel ────────────────────────────',\n ` │ ${url}`,\n ' │',\n ` │ Install the launcher PWA once: ${LAUNCHER_URL}`,\n ' │ Then scan the QR below — it opens the launcher directly',\n ' │ into this tunnel URL (no manual paste needed).',\n ...(opts.relayWssUrl\n ? [\n ' │ The same scan also attaches CDP — connect your AI host',\n ' │ to the relay and debug the live view on-device.',\n ]\n : []),\n ' │ Quick tunnels are unauthenticated, change every run, and are',\n ' │ not for production use.',\n ' └──────────────────────────────────────────────────────────────',\n '',\n ];\n log(lines.join('\\n'));\n\n if (opts.qr !== false) {\n // qrcode-terminal is only pulled in on this code path (ambient types live\n // in src/qrcode-terminal.d.ts).\n const qrcode = (await import('qrcode-terminal')).default;\n await new Promise<void>((resolve) => {\n qrcode.generate(deepLink, { small: true }, (out) => {\n log(out);\n resolve();\n });\n });\n }\n}\n\n/**\n * Heuristic: can this process open a GUI browser? Mirrors `canOpenBrowser` in\n * `src/mcp/tools.ts` but is re-declared here (not imported) so the tunnel path\n * does not statically pull the heavy MCP `tools.ts` module graph into the lazy\n * `import('./tunnel.js')` chunk. Kept in sync with the MCP copy.\n *\n * - macOS / Windows → assume yes (env-2 dev normally runs on the user's Mac).\n * - Linux → require `DISPLAY` or `WAYLAND_DISPLAY`.\n * - CI (`CI=true`/`CI=1`) → no.\n */\nfunction canOpenBrowser(): boolean {\n if (process.env.CI === 'true' || process.env.CI === '1') return false;\n const platform = process.platform;\n if (platform === 'darwin' || platform === 'win32') return true;\n if (platform === 'linux') {\n return Boolean(process.env.DISPLAY ?? process.env.WAYLAND_DISPLAY);\n }\n return false;\n}\n\n/** Handle returned by {@link startTunnelDashboard}. */\nexport interface TunnelDashboard {\n /** `http://127.0.0.1:<port>` — the local dashboard URL opened in the browser. */\n url: string;\n /** Tear down the local HTTP server. Idempotent via the underlying server. */\n close: () => Promise<void>;\n}\n\nexport interface StartTunnelDashboardOptions {\n /** The public `https://*.trycloudflare.com` app tunnel URL the launcher frames. */\n tunnelUrl: string;\n /** The `wss://` relay URL of the env-2 CDP tunnel. REQUIRED — the dashboard is a CDP-only UX. */\n relayWssUrl: string;\n /** Mirror of `tunnel.qr` — when `false` the dashboard is skipped (no browser open). */\n qr?: boolean;\n /**\n * Override the GUI/opt-out gate (testing only). When omitted the real\n * `canOpenBrowser()` + `AIT_AUTO_DEVTOOLS` checks decide.\n */\n shouldOpen?: () => boolean;\n /** Sink for the one-line \"opened in browser\" note (default: `console.log`). Injected for testing. */\n log?: (msg: string) => void;\n}\n\n/**\n * Env-2 UX parity with env 3/4 (issue #408): when CDP wiring is on and a GUI is\n * available, start the SAME `127.0.0.1` HTML dashboard (QR image + connect steps\n * + FAQ) that the MCP `build_attach_url` path serves, and auto-open it in the\n * browser. headless / opt-out falls back to the terminal ASCII QR (printed\n * separately by {@link printTunnelBanner}).\n *\n * Every part the install-graph invariant depends on (`qrcode`, the MCP HTTP\n * server, the opener) is reached only through dynamic `import()` here, inside\n * the already-lazy `tunnel.js` chunk — nothing is added to the common build\n * graph or the MCP-only install graph.\n *\n * TOTP encapsulation: the dashboard's `getDashboardState` closure mints a FRESH\n * TOTP `at=` code on every call via `generateTotp(secret, Date.now())` and folds\n * it into a fresh `buildLauncherAttachUrl(...)`. Because the QR is re-rendered on\n * each SSE push / page reload from this closure, the code a phone scans is always\n * within its 30 s window — no stale code is baked into static HTML.\n *\n * SECRET-HANDLING: the tunnel host, relay wssUrl, TOTP code, and `.ait_relay`\n * value/path are NEVER written to stdout/stderr/logs here. They live only inside\n * the attach URL (HTML body + `/qr.png` query, per qr-http-server's invariant).\n * The only thing opened/logged is `http://127.0.0.1:<port>` (local, safe).\n *\n * @returns the dashboard handle when it started (caller wires `close()` into the\n * tunnel cleanup), or `undefined` when skipped (no relay, `qr:false`, headless,\n * opt-out, or a start failure) — in which case ASCII QR fallback stands alone.\n */\nexport async function startTunnelDashboard(\n opts: StartTunnelDashboardOptions,\n): Promise<TunnelDashboard | undefined> {\n const log = opts.log ?? ((m: string) => console.log(m));\n\n // Gate: dashboard is a CDP-only UX (needs a relay to attach to).\n if (!opts.relayWssUrl) return undefined;\n // Opt-out via `tunnel.qr:false` (same toggle that suppresses the ASCII QR).\n if (opts.qr === false) return undefined;\n\n // GUI + AIT_AUTO_DEVTOOLS gate. Reuse the MCP opener's opt-out predicate so\n // the env-2 path honours the same `AIT_AUTO_DEVTOOLS=0` switch as env 3/4.\n const { isAutoDevtoolsDisabled } = await import('../mcp/devtools-opener.js');\n const gateOpen = opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser());\n if (!gateOpen()) return undefined;\n\n const { startQrHttpServer } = await import('../mcp/qr-http-server.js');\n const { buildLauncherAttachUrl } = await import('../mcp/deeplink.js');\n const { generateTotp } = await import('../mcp/totp.js');\n\n // getDashboardState — mints a fresh TOTP + attach URL on every call so the QR\n // the dashboard renders (on load and on each SSE push) is never expired.\n // SECRET-HANDLING: the secret is read from env AT CALL TIME (it was injected\n // by ensureRelaySecret in the same CDP block) and is used only to compute the\n // at= code folded into attachUrl. tunnel.up is always true here — the relay\n // tunnel is already up by the time this runs.\n const getDashboardState = () => {\n const secret = process.env.AIT_DEBUG_TOTP_SECRET;\n const totpCode = secret ? generateTotp(secret, Date.now()) : undefined;\n const attachUrl = buildLauncherAttachUrl(opts.tunnelUrl, opts.relayWssUrl, totpCode);\n // pages: null — env 2(unplugin)는 데몬이 아니라 vite 플러그인 안이라\n // startChiiRelay 핸들이 connected target을 노출하지 않는다. 라이브 page 목록을\n // 알 수 없으므로 거짓 빈 목록 대신 \"연결된 Pages\" 섹션 자체를 숨긴다(#411).\n // env 3/4(debug-server.ts)는 router.active.listTargets()로 실제 목록을 채운다.\n return { tunnel: { up: true, wssUrl: opts.relayWssUrl }, pages: null, attachUrl };\n };\n\n let server: Awaited<ReturnType<typeof startQrHttpServer>>;\n try {\n server = await startQrHttpServer(getDashboardState);\n } catch {\n // SECRET-HANDLING: do not surface the error (could embed paths/hosts). The\n // ASCII QR printed by printTunnelBanner stays as the fallback.\n return undefined;\n }\n\n const dashboardUrl = `http://127.0.0.1:${server.port}`;\n\n const { openUrlInBrowser } = await import('../mcp/devtools-opener.js');\n const opened = openUrlInBrowser(dashboardUrl);\n // SECRET-HANDLING: only the local 127.0.0.1 URL is logged — never the tunnel\n // host, relay wssUrl, or TOTP code.\n log(\n opened\n ? ` │ Opened a QR dashboard in your browser: ${dashboardUrl}`\n : ` │ Open this QR dashboard in your browser: ${dashboardUrl}`,\n );\n\n return {\n url: dashboardUrl,\n close: () => server.close(),\n };\n}\n\nexport interface QuickTunnel {\n /** The public `https://*.trycloudflare.com` URL. */\n url: string;\n /** Stop the underlying `cloudflared` process. Idempotent. */\n stop: () => void;\n}\n\n/**\n * Sanitize cloudflared stderr output for error diagnostics (#421).\n *\n * Masks `*.trycloudflare.com` hostnames and full `https://` / `wss://` URLs\n * that carry those hostnames so tunnel host values never appear in error\n * messages. Diagnostic content (error codes, reasons, JSON blobs) is preserved.\n *\n * SECRET-HANDLING: tunnel host is SECRET-class per harness policy — only\n * placeholder text is emitted.\n */\nexport function sanitizeCloudflaredOutput(line: string): string {\n // Full URL forms: https://xxx.trycloudflare.com/… and wss://xxx.trycloudflare.com/…\n let s = line.replace(/(?:https?|wss?):\\/\\/[a-z0-9-]+\\.trycloudflare\\.com(?:\\/[^\\s]*)*/gi, (m) =>\n m.replace(/[a-z0-9-]+\\.trycloudflare\\.com/i, '<HOST>.trycloudflare.com'),\n );\n // Bare hostname without scheme (e.g. printed in cloudflared JSON logs)\n s = s.replace(/[a-z0-9-]+\\.trycloudflare\\.com/gi, '<HOST>.trycloudflare.com');\n return s;\n}\n\nconst URL_TIMEOUT_MS = 20_000;\n\n/**\n * Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`\n * and resolve once the public URL is known. Downloads the `cloudflared` binary\n * on first use if it is not already installed. Rejects with a friendly error if\n * no URL appears within {@link URL_TIMEOUT_MS}.\n */\nexport async function startQuickTunnel(port: number): Promise<QuickTunnel> {\n const cloudflared = await import('cloudflared');\n const { bin, install, Tunnel } = cloudflared;\n\n if (!existsSync(bin)) {\n await mkdir(dirname(bin), { recursive: true });\n await install(bin);\n }\n\n const tunnel = Tunnel.quick(`http://localhost:${port}`);\n let stopped = false;\n const stop = () => {\n if (stopped) return;\n stopped = true;\n try {\n tunnel.stop();\n } catch {\n // process may already be gone\n }\n };\n\n return new Promise<QuickTunnel>((resolve, reject) => {\n // #421: accumulate stderr to attach as diagnostics on failure.\n // SECRET-HANDLING: lines are sanitized before inclusion in error messages.\n const stderrLines: string[] = [];\n\n /**\n * Format the last `n` sanitized stderr lines as a diagnostic appendix.\n * Returns an empty string when no lines have been collected.\n */\n const stderrTail = (n = 15): string => {\n if (stderrLines.length === 0) return '';\n const tail = stderrLines.slice(-n).map(sanitizeCloudflaredOutput).join('');\n return `\\ncloudflared 출력 (마지막 ${Math.min(n, stderrLines.length)}줄):\\n${tail}`;\n };\n\n const timer = setTimeout(() => {\n cleanup();\n stop();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared did not report a tunnel URL within ${\n URL_TIMEOUT_MS / 1000\n }s. Check your network connection, or run \\`cloudflared tunnel --url http://localhost:${port}\\` manually.${stderrTail()}`,\n ),\n );\n }, URL_TIMEOUT_MS);\n\n const onUrl = (line: string) => {\n const found = parseTrycloudflareUrl(line);\n if (!found) return;\n clearTimeout(timer);\n // Stop scanning further output once we have the URL.\n cleanup();\n resolve({ url: found, stop });\n };\n\n // Accumulate stderr lines for diagnostics (#421). Named so it can be\n // removed from the listener list when cleanup() runs.\n const pushStderr = (line: string) => {\n stderrLines.push(line);\n };\n\n const cleanup = () => {\n tunnel.off('stdout', onUrl);\n tunnel.off('stderr', onUrl);\n tunnel.off('stderr', pushStderr);\n };\n\n // The library emits a parsed `url` event; we also scan raw stdout/stderr in\n // case the output format shifts.\n tunnel.once('url', onUrl);\n tunnel.on('stdout', onUrl);\n tunnel.on('stderr', onUrl);\n // Second stderr listener: accumulate all lines for error diagnostics.\n tunnel.on('stderr', pushStderr);\n tunnel.once('error', (err: Error) => {\n clearTimeout(timer);\n cleanup();\n stop();\n reject(err);\n });\n tunnel.once('exit', (code: number | null) => {\n if (stopped) return;\n clearTimeout(timer);\n cleanup();\n reject(\n new Error(\n `[@ait-co/devtools] cloudflared exited (code ${code ?? 'null'}) before reporting a tunnel URL.${stderrTail()}`,\n ),\n );\n });\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAiBA,MAAM,mBAAmB;;;;;;AAOzB,SAAgB,sBAAsB,MAA6B;CACjE,MAAM,IAAI,KAAK,MAAM,iBAAiB;AACtC,QAAO,IAAI,EAAE,KAAK;;AAiBpB,MAAM,eAAe;;;;;;;;;;;;;;AAerB,SAAgB,sBAAsB,WAAmB,aAA8B;CACrF,MAAM,OAAO,GAAG,aAAa,OAAO,mBAAmB,UAAU;AACjE,KAAI,CAAC,YAAa,QAAO;AACzB,QAAO,GAAG,KAAK,iBAAiB,mBAAmB,YAAY;;;;;;;;AASjE,eAAsB,kBACpB,KACA,OAAiC,EAAE,EACpB;CACf,MAAM,MAAM,KAAK,SAAS,MAAc,QAAQ,IAAI,EAAE;CACtD,MAAM,WAAW,sBAAsB,KAAK,KAAK,YAAY;AAoB7D,KAnBwB;EACtB;EACA;EACA,QAAQ;EACR;EACA,wCAAwC;EACxC;EACA;EACA,GAAI,KAAK,cACL,CACE,+DACA,uDACD,GACD,EAAE;EACN;EACA;EACA;EACA;EACD,CACS,KAAK,KAAK,CAAC;AAErB,KAAI,KAAK,OAAO,OAAO;EAGrB,MAAM,UAAU,MAAM,OAAO,oBAAoB;AACjD,QAAM,IAAI,SAAe,YAAY;AACnC,UAAO,SAAS,UAAU,EAAE,OAAO,MAAM,GAAG,QAAQ;AAClD,QAAI,IAAI;AACR,aAAS;KACT;IACF;;;;;;;;;;;;;AAcN,SAAS,iBAA0B;AACjC,KAAI,QAAQ,IAAI,OAAO,UAAU,QAAQ,IAAI,OAAO,IAAK,QAAO;CAChE,MAAM,WAAW,QAAQ;AACzB,KAAI,aAAa,YAAY,aAAa,QAAS,QAAO;AAC1D,KAAI,aAAa,QACf,QAAO,QAAQ,QAAQ,IAAI,WAAW,QAAQ,IAAI,gBAAgB;AAEpE,QAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsDT,eAAsB,qBACpB,MACsC;CACtC,MAAM,MAAM,KAAK,SAAS,MAAc,QAAQ,IAAI,EAAE;AAGtD,KAAI,CAAC,KAAK,YAAa,QAAO,KAAA;AAE9B,KAAI,KAAK,OAAO,MAAO,QAAO,KAAA;CAI9B,MAAM,EAAE,2BAA2B,MAAA,QAAA,SAAA,CAAA,WAAA,QAAM,iCAAA,CAAA;AAEzC,KAAI,EADa,KAAK,qBAAqB,CAAC,wBAAwB,IAAI,gBAAgB,IACzE,CAAE,QAAO,KAAA;CAExB,MAAM,EAAE,sBAAsB,MAAA,QAAA,SAAA,CAAA,WAAA,QAAM,gCAAA,CAAA;CACpC,MAAM,EAAE,2BAA2B,MAAA,QAAA,SAAA,CAAA,WAAA,QAAM,0BAAA,CAAA;CACzC,MAAM,EAAE,iBAAiB,MAAA,QAAA,SAAA,CAAA,WAAA,QAAM,sBAAA,CAAA;CAQ/B,MAAM,0BAA0B;EAC9B,MAAM,SAAS,QAAQ,IAAI;EAC3B,MAAM,WAAW,SAAS,aAAa,QAAQ,KAAK,KAAK,CAAC,GAAG,KAAA;EAC7D,MAAM,YAAY,uBAAuB,KAAK,WAAW,KAAK,aAAa,SAAS;AAKpF,SAAO;GAAE,QAAQ;IAAE,IAAI;IAAM,QAAQ,KAAK;IAAa;GAAE,OAAO;GAAM;GAAW;;CAGnF,IAAI;AACJ,KAAI;AACF,WAAS,MAAM,kBAAkB,kBAAkB;SAC7C;AAGN;;CAGF,MAAM,eAAe,oBAAoB,OAAO;CAEhD,MAAM,EAAE,qBAAqB,MAAA,QAAA,SAAA,CAAA,WAAA,QAAM,iCAAA,CAAA;AAInC,KAHe,iBAAiB,aAAa,GAKvC,+CAA+C,iBAC/C,gDAAgD,eACrD;AAED,QAAO;EACL,KAAK;EACL,aAAa,OAAO,OAAO;EAC5B;;;;;;;;;;;;AAoBH,SAAgB,0BAA0B,MAAsB;CAE9D,IAAI,IAAI,KAAK,QAAQ,sEAAsE,MACzF,EAAE,QAAQ,mCAAmC,2BAA2B,CACzE;AAED,KAAI,EAAE,QAAQ,oCAAoC,2BAA2B;AAC7E,QAAO;;AAGT,MAAM,iBAAiB;;;;;;;AAQvB,eAAsB,iBAAiB,MAAoC;CAEzE,MAAM,EAAE,KAAK,SAAS,WADF,MAAM,OAAO;AAGjC,KAAI,EAAA,GAAA,QAAA,YAAY,IAAI,EAAE;AACpB,SAAA,GAAA,iBAAA,QAAA,GAAA,UAAA,SAAoB,IAAI,EAAE,EAAE,WAAW,MAAM,CAAC;AAC9C,QAAM,QAAQ,IAAI;;CAGpB,MAAM,SAAS,OAAO,MAAM,oBAAoB,OAAO;CACvD,IAAI,UAAU;CACd,MAAM,aAAa;AACjB,MAAI,QAAS;AACb,YAAU;AACV,MAAI;AACF,UAAO,MAAM;UACP;;AAKV,QAAO,IAAI,SAAsB,SAAS,WAAW;EAGnD,MAAM,cAAwB,EAAE;;;;;EAMhC,MAAM,cAAc,IAAI,OAAe;AACrC,OAAI,YAAY,WAAW,EAAG,QAAO;GACrC,MAAM,OAAO,YAAY,MAAM,CAAC,EAAE,CAAC,IAAI,0BAA0B,CAAC,KAAK,GAAG;AAC1E,UAAO,yBAAyB,KAAK,IAAI,GAAG,YAAY,OAAO,CAAC,OAAO;;EAGzE,MAAM,QAAQ,iBAAiB;AAC7B,YAAS;AACT,SAAM;AACN,0BACE,IAAI,MACF,qEACE,iBAAiB,IAClB,uFAAuF,KAAK,cAAc,YAAY,GACxH,CACF;KACA,eAAe;EAElB,MAAM,SAAS,SAAiB;GAC9B,MAAM,QAAQ,sBAAsB,KAAK;AACzC,OAAI,CAAC,MAAO;AACZ,gBAAa,MAAM;AAEnB,YAAS;AACT,WAAQ;IAAE,KAAK;IAAO;IAAM,CAAC;;EAK/B,MAAM,cAAc,SAAiB;AACnC,eAAY,KAAK,KAAK;;EAGxB,MAAM,gBAAgB;AACpB,UAAO,IAAI,UAAU,MAAM;AAC3B,UAAO,IAAI,UAAU,MAAM;AAC3B,UAAO,IAAI,UAAU,WAAW;;AAKlC,SAAO,KAAK,OAAO,MAAM;AACzB,SAAO,GAAG,UAAU,MAAM;AAC1B,SAAO,GAAG,UAAU,MAAM;AAE1B,SAAO,GAAG,UAAU,WAAW;AAC/B,SAAO,KAAK,UAAU,QAAe;AACnC,gBAAa,MAAM;AACnB,YAAS;AACT,SAAM;AACN,UAAO,IAAI;IACX;AACF,SAAO,KAAK,SAAS,SAAwB;AAC3C,OAAI,QAAS;AACb,gBAAa,MAAM;AACnB,YAAS;AACT,0BACE,IAAI,MACF,+CAA+C,QAAQ,OAAO,kCAAkC,YAAY,GAC7G,CACF;IACD;GACF"}
|
|
@@ -76,6 +76,101 @@ async function printTunnelBanner(url, opts = {}) {
|
|
|
76
76
|
});
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Heuristic: can this process open a GUI browser? Mirrors `canOpenBrowser` in
|
|
81
|
+
* `src/mcp/tools.ts` but is re-declared here (not imported) so the tunnel path
|
|
82
|
+
* does not statically pull the heavy MCP `tools.ts` module graph into the lazy
|
|
83
|
+
* `import('./tunnel.js')` chunk. Kept in sync with the MCP copy.
|
|
84
|
+
*
|
|
85
|
+
* - macOS / Windows → assume yes (env-2 dev normally runs on the user's Mac).
|
|
86
|
+
* - Linux → require `DISPLAY` or `WAYLAND_DISPLAY`.
|
|
87
|
+
* - CI (`CI=true`/`CI=1`) → no.
|
|
88
|
+
*/
|
|
89
|
+
function canOpenBrowser() {
|
|
90
|
+
if (process.env.CI === "true" || process.env.CI === "1") return false;
|
|
91
|
+
const platform = process.platform;
|
|
92
|
+
if (platform === "darwin" || platform === "win32") return true;
|
|
93
|
+
if (platform === "linux") return Boolean(process.env.DISPLAY ?? process.env.WAYLAND_DISPLAY);
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Env-2 UX parity with env 3/4 (issue #408): when CDP wiring is on and a GUI is
|
|
98
|
+
* available, start the SAME `127.0.0.1` HTML dashboard (QR image + connect steps
|
|
99
|
+
* + FAQ) that the MCP `build_attach_url` path serves, and auto-open it in the
|
|
100
|
+
* browser. headless / opt-out falls back to the terminal ASCII QR (printed
|
|
101
|
+
* separately by {@link printTunnelBanner}).
|
|
102
|
+
*
|
|
103
|
+
* Every part the install-graph invariant depends on (`qrcode`, the MCP HTTP
|
|
104
|
+
* server, the opener) is reached only through dynamic `import()` here, inside
|
|
105
|
+
* the already-lazy `tunnel.js` chunk — nothing is added to the common build
|
|
106
|
+
* graph or the MCP-only install graph.
|
|
107
|
+
*
|
|
108
|
+
* TOTP encapsulation: the dashboard's `getDashboardState` closure mints a FRESH
|
|
109
|
+
* TOTP `at=` code on every call via `generateTotp(secret, Date.now())` and folds
|
|
110
|
+
* it into a fresh `buildLauncherAttachUrl(...)`. Because the QR is re-rendered on
|
|
111
|
+
* each SSE push / page reload from this closure, the code a phone scans is always
|
|
112
|
+
* within its 30 s window — no stale code is baked into static HTML.
|
|
113
|
+
*
|
|
114
|
+
* SECRET-HANDLING: the tunnel host, relay wssUrl, TOTP code, and `.ait_relay`
|
|
115
|
+
* value/path are NEVER written to stdout/stderr/logs here. They live only inside
|
|
116
|
+
* the attach URL (HTML body + `/qr.png` query, per qr-http-server's invariant).
|
|
117
|
+
* The only thing opened/logged is `http://127.0.0.1:<port>` (local, safe).
|
|
118
|
+
*
|
|
119
|
+
* @returns the dashboard handle when it started (caller wires `close()` into the
|
|
120
|
+
* tunnel cleanup), or `undefined` when skipped (no relay, `qr:false`, headless,
|
|
121
|
+
* opt-out, or a start failure) — in which case ASCII QR fallback stands alone.
|
|
122
|
+
*/
|
|
123
|
+
async function startTunnelDashboard(opts) {
|
|
124
|
+
const log = opts.log ?? ((m) => console.log(m));
|
|
125
|
+
if (!opts.relayWssUrl) return void 0;
|
|
126
|
+
if (opts.qr === false) return void 0;
|
|
127
|
+
const { isAutoDevtoolsDisabled } = await import("./devtools-opener-BbUXBzgA.js");
|
|
128
|
+
if (!(opts.shouldOpen ?? (() => !isAutoDevtoolsDisabled() && canOpenBrowser()))()) return void 0;
|
|
129
|
+
const { startQrHttpServer } = await import("./qr-http-server-kYvmlXlg.js");
|
|
130
|
+
const { buildLauncherAttachUrl } = await import("./deeplink-CaO6hZVG.js");
|
|
131
|
+
const { generateTotp } = await import("./totp-BjtKFt88.js");
|
|
132
|
+
const getDashboardState = () => {
|
|
133
|
+
const secret = process.env.AIT_DEBUG_TOTP_SECRET;
|
|
134
|
+
const totpCode = secret ? generateTotp(secret, Date.now()) : void 0;
|
|
135
|
+
const attachUrl = buildLauncherAttachUrl(opts.tunnelUrl, opts.relayWssUrl, totpCode);
|
|
136
|
+
return {
|
|
137
|
+
tunnel: {
|
|
138
|
+
up: true,
|
|
139
|
+
wssUrl: opts.relayWssUrl
|
|
140
|
+
},
|
|
141
|
+
pages: null,
|
|
142
|
+
attachUrl
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
let server;
|
|
146
|
+
try {
|
|
147
|
+
server = await startQrHttpServer(getDashboardState);
|
|
148
|
+
} catch {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const dashboardUrl = `http://127.0.0.1:${server.port}`;
|
|
152
|
+
const { openUrlInBrowser } = await import("./devtools-opener-BbUXBzgA.js");
|
|
153
|
+
log(openUrlInBrowser(dashboardUrl) ? ` │ Opened a QR dashboard in your browser: ${dashboardUrl}` : ` │ Open this QR dashboard in your browser: ${dashboardUrl}`);
|
|
154
|
+
return {
|
|
155
|
+
url: dashboardUrl,
|
|
156
|
+
close: () => server.close()
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Sanitize cloudflared stderr output for error diagnostics (#421).
|
|
161
|
+
*
|
|
162
|
+
* Masks `*.trycloudflare.com` hostnames and full `https://` / `wss://` URLs
|
|
163
|
+
* that carry those hostnames so tunnel host values never appear in error
|
|
164
|
+
* messages. Diagnostic content (error codes, reasons, JSON blobs) is preserved.
|
|
165
|
+
*
|
|
166
|
+
* SECRET-HANDLING: tunnel host is SECRET-class per harness policy — only
|
|
167
|
+
* placeholder text is emitted.
|
|
168
|
+
*/
|
|
169
|
+
function sanitizeCloudflaredOutput(line) {
|
|
170
|
+
let s = line.replace(/(?:https?|wss?):\/\/[a-z0-9-]+\.trycloudflare\.com(?:\/[^\s]*)*/gi, (m) => m.replace(/[a-z0-9-]+\.trycloudflare\.com/i, "<HOST>.trycloudflare.com"));
|
|
171
|
+
s = s.replace(/[a-z0-9-]+\.trycloudflare\.com/gi, "<HOST>.trycloudflare.com");
|
|
172
|
+
return s;
|
|
173
|
+
}
|
|
79
174
|
const URL_TIMEOUT_MS = 2e4;
|
|
80
175
|
/**
|
|
81
176
|
* Start an unauthenticated Cloudflare quick tunnel to `http://localhost:<port>`
|
|
@@ -99,10 +194,20 @@ async function startQuickTunnel(port) {
|
|
|
99
194
|
} catch {}
|
|
100
195
|
};
|
|
101
196
|
return new Promise((resolve, reject) => {
|
|
197
|
+
const stderrLines = [];
|
|
198
|
+
/**
|
|
199
|
+
* Format the last `n` sanitized stderr lines as a diagnostic appendix.
|
|
200
|
+
* Returns an empty string when no lines have been collected.
|
|
201
|
+
*/
|
|
202
|
+
const stderrTail = (n = 15) => {
|
|
203
|
+
if (stderrLines.length === 0) return "";
|
|
204
|
+
const tail = stderrLines.slice(-n).map(sanitizeCloudflaredOutput).join("");
|
|
205
|
+
return `\ncloudflared 출력 (마지막 ${Math.min(n, stderrLines.length)}줄):\n${tail}`;
|
|
206
|
+
};
|
|
102
207
|
const timer = setTimeout(() => {
|
|
103
208
|
cleanup();
|
|
104
209
|
stop();
|
|
105
|
-
reject(/* @__PURE__ */ new Error(`[@ait-co/devtools] cloudflared did not report a tunnel URL within ${URL_TIMEOUT_MS / 1e3}s. Check your network connection, or run \`cloudflared tunnel --url http://localhost:${port}\` manually
|
|
210
|
+
reject(/* @__PURE__ */ new Error(`[@ait-co/devtools] cloudflared did not report a tunnel URL within ${URL_TIMEOUT_MS / 1e3}s. Check your network connection, or run \`cloudflared tunnel --url http://localhost:${port}\` manually.${stderrTail()}`));
|
|
106
211
|
}, URL_TIMEOUT_MS);
|
|
107
212
|
const onUrl = (line) => {
|
|
108
213
|
const found = parseTrycloudflareUrl(line);
|
|
@@ -114,13 +219,18 @@ async function startQuickTunnel(port) {
|
|
|
114
219
|
stop
|
|
115
220
|
});
|
|
116
221
|
};
|
|
222
|
+
const pushStderr = (line) => {
|
|
223
|
+
stderrLines.push(line);
|
|
224
|
+
};
|
|
117
225
|
const cleanup = () => {
|
|
118
226
|
tunnel.off("stdout", onUrl);
|
|
119
227
|
tunnel.off("stderr", onUrl);
|
|
228
|
+
tunnel.off("stderr", pushStderr);
|
|
120
229
|
};
|
|
121
230
|
tunnel.once("url", onUrl);
|
|
122
231
|
tunnel.on("stdout", onUrl);
|
|
123
232
|
tunnel.on("stderr", onUrl);
|
|
233
|
+
tunnel.on("stderr", pushStderr);
|
|
124
234
|
tunnel.once("error", (err) => {
|
|
125
235
|
clearTimeout(timer);
|
|
126
236
|
cleanup();
|
|
@@ -131,11 +241,11 @@ async function startQuickTunnel(port) {
|
|
|
131
241
|
if (stopped) return;
|
|
132
242
|
clearTimeout(timer);
|
|
133
243
|
cleanup();
|
|
134
|
-
reject(/* @__PURE__ */ new Error(`[@ait-co/devtools] cloudflared exited (code ${code ?? "null"}) before reporting a tunnel URL
|
|
244
|
+
reject(/* @__PURE__ */ new Error(`[@ait-co/devtools] cloudflared exited (code ${code ?? "null"}) before reporting a tunnel URL.${stderrTail()}`));
|
|
135
245
|
});
|
|
136
246
|
});
|
|
137
247
|
}
|
|
138
248
|
//#endregion
|
|
139
|
-
export { printTunnelBanner, startQuickTunnel };
|
|
249
|
+
export { printTunnelBanner, startQuickTunnel, startTunnelDashboard };
|
|
140
250
|
|
|
141
|
-
//# sourceMappingURL=tunnel-
|
|
251
|
+
//# sourceMappingURL=tunnel-JuZ5_Pci.js.map
|