@ait-co/devtools 0.1.45 → 0.1.48

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/mcp/cli.js CHANGED
@@ -848,6 +848,51 @@ var AutoDevtoolsOpener = class {
848
848
  }
849
849
  };
850
850
  //#endregion
851
+ //#region src/mcp/envelope.ts
852
+ /**
853
+ * Returns `true` when `AIT_MCP_COMPAT=chrome-devtools` is set, which bypasses
854
+ * envelope wrapping and returns raw payloads (0.1.x back-compat).
855
+ */
856
+ function isCompatMode() {
857
+ return process.env.AIT_MCP_COMPAT === "chrome-devtools";
858
+ }
859
+ /**
860
+ * Maps `McpEnvironment` to `EnvelopeEnv`. After #307 these are the same
861
+ * union (`mock | relay-dev | relay-live`), so this is identity — kept as a
862
+ * named export for surface stability if envelope env diverges in the future.
863
+ */
864
+ function toEnvelopeEnv(env) {
865
+ return env;
866
+ }
867
+ /**
868
+ * Wraps `data` in a `ToolEnvelope<T>` **unless** compat mode is active, in
869
+ * which case `data` is returned as-is.
870
+ *
871
+ * Use this at every tool call-site in `debug-server.ts` and `server.ts`.
872
+ *
873
+ * @example
874
+ * ```ts
875
+ * return jsonResult(wrapEnvelope(listPages(connection, tunnel), {
876
+ * tool: 'list_pages',
877
+ * env: resolveEnvironment(),
878
+ * attached: connection.listTargets().length > 0,
879
+ * }));
880
+ * ```
881
+ */
882
+ function wrapEnvelope(data, ctx) {
883
+ if (isCompatMode()) return data;
884
+ return {
885
+ ok: true,
886
+ data,
887
+ meta: {
888
+ tool: ctx.tool,
889
+ env: toEnvelopeEnv(ctx.env),
890
+ attached: ctx.attached,
891
+ contentType: ctx.contentType ?? "json"
892
+ }
893
+ };
894
+ }
895
+ //#endregion
851
896
  //#region src/mcp/environment.ts
852
897
  /**
853
898
  * Returns `true` when the environment is any relay variant (`relay-dev` or
@@ -2013,6 +2058,83 @@ function warnPassthrough(name) {
2013
2058
  }
2014
2059
  SIGNATURES.map((s) => s.name);
2015
2060
  //#endregion
2061
+ //#region src/mcp/totp.ts
2062
+ /**
2063
+ * RFC 6238 TOTP implementation (Node.js, node:crypto only).
2064
+ *
2065
+ * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used
2066
+ * to keep the dependency surface minimal. This hand-roll is ~30 lines and
2067
+ * covers exactly what relay-side auth needs.
2068
+ *
2069
+ * Algorithm summary (RFC 6238 + RFC 4226):
2070
+ * T = floor(now / 30) — 30-second time step counter
2071
+ * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)
2072
+ * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)
2073
+ * offset = MAC[19] & 0x0f
2074
+ * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits
2075
+ *
2076
+ * Security note (keep this comment accurate):
2077
+ * The baked-in secret in a dogfood build is extractable from the bundle by a
2078
+ * determined reverse engineer. This mechanism raises the bar from
2079
+ * "anyone with the URL" to "URL + bundle extraction + live TOTP calculation".
2080
+ * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are
2081
+ * blocked; deliberate reverse engineering is not. See threat model in
2082
+ * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.
2083
+ *
2084
+ * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any
2085
+ * log, error message, or string visible outside this module. Only boolean
2086
+ * pass/fail and reason enum values are safe to surface.
2087
+ */
2088
+ /** Time step window in seconds (RFC 6238 default). */
2089
+ const TIME_STEP = 30;
2090
+ /** Number of digits in the generated code. */
2091
+ const DIGITS = 6;
2092
+ /**
2093
+ * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-
2094
+ * clock time.
2095
+ *
2096
+ * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32
2097
+ * bytes). Must be the output of `generateAttachToken()` or compatible.
2098
+ * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
2099
+ * @returns A zero-padded 6-digit decimal string, e.g. `"042193"`.
2100
+ */
2101
+ function generateTotp(secret, when = Date.now()) {
2102
+ const key = Buffer.from(secret, "hex");
2103
+ const counter = Math.max(0, Math.floor(when / 1e3 / TIME_STEP));
2104
+ const counterBuf = Buffer.alloc(8);
2105
+ const hi = Math.floor(counter / 4294967296);
2106
+ const lo = counter >>> 0;
2107
+ counterBuf.writeUInt32BE(hi, 0);
2108
+ counterBuf.writeUInt32BE(lo, 4);
2109
+ const mac = createHmac("sha1", key).update(counterBuf).digest();
2110
+ const offset = mac[19] & 15;
2111
+ 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");
2112
+ }
2113
+ /**
2114
+ * Verifies a TOTP code against the secret, accepting ±`skew` time steps to
2115
+ * tolerate clock drift between the relay host and the client device.
2116
+ *
2117
+ * Uses `timingSafeEqual` for constant-time comparison to prevent timing
2118
+ * side-channel attacks.
2119
+ *
2120
+ * @param secret - Hex-encoded shared secret.
2121
+ * @param code - The 6-digit code to verify (string or numeric).
2122
+ * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
2123
+ * @param skew - Number of adjacent steps to accept on either side. Default 1
2124
+ * (accepts T-1, T, T+1 — a 90-second acceptance window).
2125
+ * @returns `true` if the code matches any accepted step, `false` otherwise.
2126
+ */
2127
+ function verifyTotp(secret, code, when = Date.now(), skew = 1) {
2128
+ const normalised = String(code).padStart(DIGITS, "0");
2129
+ if (normalised.length !== DIGITS || !/^\d{6}$/.test(normalised)) return false;
2130
+ const candidateBuf = Buffer.from(normalised, "utf8");
2131
+ for (let delta = -skew; delta <= skew; delta++) {
2132
+ const expected = generateTotp(secret, when + delta * TIME_STEP * 1e3);
2133
+ if (timingSafeEqual(Buffer.from(expected, "utf8"), candidateBuf)) return true;
2134
+ }
2135
+ return false;
2136
+ }
2137
+ //#endregion
2016
2138
  //#region src/mcp/tools.ts
2017
2139
  /** Static MCP tool descriptors (name + JSONSchema) for the full debug tool surface. */
2018
2140
  const DEBUG_TOOL_DEFINITIONS = [
@@ -2048,7 +2170,7 @@ const DEBUG_TOOL_DEFINITIONS = [
2048
2170
  },
2049
2171
  {
2050
2172
  name: "build_attach_url",
2051
- description: "The tool result already shows the QR to the user directly (Claude Code renders MCP tool output to the user's screen; they press Ctrl+O to expand if it's collapsed). Do NOT re-print or re-render the QR in your reply — that just wastes output tokens. Simply tell the user to scan the QR shown in this tool's output with their phone camera. Turns an `ait deploy --scheme-only` URL (intoss-private://…?_deploymentId=<uuid>) into a self-attaching deep link by splicing in debug=1 and the live relay URL for this session. Returns the deep link JSON and a unicode QR of that deep link. Scan the QR with the phone camera to open the mini-app and attach it to this debug session (QR is the single entry path — no USB cable or platform CLI needed). Requires the tunnel to be up — call list_pages first. If the tunnel is not up, restart the MCP server: `npx @ait-co/devtools devtools-mcp`. Set wait_for_attach=true to block until the phone scans and a page attaches (polls listTargets up to 30 s by default), then returns the attached page info too. On timeout, call build_attach_url again to resume polling. When open_in_browser=true (default), saves the QR as a PNG and opens it in the OS default browser — only works when the MCP server runs on a local GUI machine (not headless/remote containers). Requires MCP_ENV=relay (set automatically when a relay tunnel is detected).",
2173
+ description: "The tool result already shows the QR to the user directly (Claude Code renders MCP tool output to the user's screen; they press Ctrl+O to expand if it's collapsed). Do NOT re-print or re-render the QR in your reply — that just wastes output tokens. Simply tell the user to scan the QR shown in this tool's output with their phone camera. Turns an `ait deploy --scheme-only` URL (intoss-private://…?_deploymentId=<uuid>) into a self-attaching deep link by splicing in debug=1 and the live relay URL for this session. Returns the deep link JSON and a unicode QR of that deep link. Scan the QR with the phone camera to open the mini-app and attach it to this debug session (QR is the single entry path — no USB cable or platform CLI needed). Requires the tunnel to be up — call list_pages first. If the tunnel is not up, restart the MCP server: `npx @ait-co/devtools devtools-mcp`. Set wait_for_attach=true to block until the phone scans and a page attaches (polls listTargets up to 30 s by default), then returns the attached page info too. On timeout, call build_attach_url again to resume polling. When open_in_browser=true (default), saves the QR as a PNG and opens it in the OS default browser — only works when the MCP server runs on a local GUI machine (not headless/remote containers). Requires MCP_ENV=relay-dev or relay-live (set automatically in debug-mode default).\n\nTOTP auth: when AIT_DEBUG_TOTP_SECRET is set on the MCP server, the returned attachUrl automatically includes the current one-time code (at=<code>) — the URL is single-use for that 30-second step. The response includes a `totp` field with `expiresAt` (ISO timestamp). If the phone scan happens after expiresAt, the relay will reject the code — just call build_attach_url again to get a fresh one-time URL. Without AIT_DEBUG_TOTP_SECRET, the attachUrl has no expiry.",
2052
2174
  inputSchema: {
2053
2175
  type: "object",
2054
2176
  properties: {
@@ -2101,7 +2223,7 @@ const DEBUG_TOOL_DEFINITIONS = [
2101
2223
  },
2102
2224
  {
2103
2225
  name: "measure_safe_area",
2104
- description: "Runs a safe-area probe on the attached mini-app page via Runtime.evaluate and returns normalized safe-area insets, viewport geometry, device pixel ratio, and User-Agent. Read-only — does not modify page state. Tier C per RFC #277: the same Runtime.evaluate probe runs in both `mock` (devtools panel page with window.__ait state) and `relay` (real-device WebView with window.__sdk). The result includes a `source: \"mock\" | \"relay\"` field so consumers can identify provenance without inspecting payload values. Use in a relay session (phone attached) to get ground-truth values for upgrading a viewport preset from extrapolated/placeholder to measured. Requires a page to be attached — call list_pages first.",
2226
+ description: "Runs a safe-area probe on the attached mini-app page via Runtime.evaluate and returns normalized safe-area insets, viewport geometry, device pixel ratio, and User-Agent. Read-only — does not modify page state. Tier C per RFC #277: the same Runtime.evaluate probe runs in both `mock` (devtools panel page with window.__ait state) and `relay` (real-device WebView with window.__sdk). The result includes a `source: \"mock\" | \"relay-dev\" | \"relay-live\"` field so consumers can identify provenance without inspecting payload values. Use in a relay session (phone attached) to get ground-truth values for upgrading a viewport preset from extrapolated/placeholder to measured. Requires a page to be attached — call list_pages first.",
2105
2227
  inputSchema: {
2106
2228
  type: "object",
2107
2229
  properties: {},
@@ -2143,7 +2265,7 @@ const DEBUG_TOOL_DEFINITIONS = [
2143
2265
  },
2144
2266
  {
2145
2267
  name: "call_sdk",
2146
- description: "Calls a dogfood SDK method via the window.__sdkCall bridge (exported by @apps-in-toss/web-framework only in __DEBUG_BUILD__ bundles). NOT read-only — SDK calls have side effects (navigation, payments, permissions, etc.). On env 2/3 (real device relay) this hits the real SDK; on env 1 (local mock) it hits the mock SDK. Requires the relay to be attached — call list_pages first. Returns {ok: true, value} on success or {ok: false, error} on failure. If a Runtime.exceptionThrown event was observed within [callStart-50ms, callEnd+200ms], the result also includes `recentException` for crash triage. Returns a clear error if window.__sdkCall is not available (non-dogfood bundle) — redeploy via dogfood channel: `ait build && aitcc app deploy`.\n\nSECURITY: method name, args, and result value are not redacted — never include secrets.\n\nLIVE guard: when running against a live/production relay (relay-live env, MCP_ENV=relay-live), this tool requires `confirm: true` to acknowledge that the SDK call may affect real users. Without it the call is rejected with a structured error. mock and relay-dev sessions are unaffected.\n\nIMPORTANT — 인자 시그니처 (잘못된 인자로 호출하면 토스 앱 crash 위험):\n setDeviceOrientation: call_sdk(\"setDeviceOrientation\", [{ type: \"landscape\" }]) // NOT \"landscape\"\n setIosSwipeGestureEnabled: call_sdk(\"setIosSwipeGestureEnabled\", [{ isEnabled: false }])\n setSecureScreen: call_sdk(\"setSecureScreen\", [{ enabled: true }])\n setScreenAwakeMode: call_sdk(\"setScreenAwakeMode\", [{ enabled: true }])\n getOperationalEnvironment: call_sdk(\"getOperationalEnvironment\", [])\n getPlatformOS: call_sdk(\"getPlatformOS\", [])\n getDeviceId: call_sdk(\"getDeviceId\", [])\n getLocale: call_sdk(\"getLocale\", [])\n getNetworkStatus: call_sdk(\"getNetworkStatus\", [])\n getSchemeUri: call_sdk(\"getSchemeUri\", [])\n requestReview: call_sdk(\"requestReview\", [])\n closeView: call_sdk(\"closeView\", [])",
2268
+ description: "Calls a dogfood SDK method via the window.__sdkCall bridge (exported by @apps-in-toss/web-framework only in __DEBUG_BUILD__ bundles). NOT read-only — SDK calls have side effects (navigation, payments, permissions, etc.). On env 3/4 (real device relay) this hits the real SDK; on env 1 (local mock) it hits the mock SDK. (env 2 PWA does not inject the SDK — call_sdk is not available there.) Requires the relay to be attached — call list_pages first. Returns {ok: true, value} on success or {ok: false, error} on failure. If a Runtime.exceptionThrown event was observed within [callStart-50ms, callEnd+200ms], the result also includes `recentException` for crash triage. Returns a clear error if window.__sdkCall is not available (non-dogfood bundle) — redeploy via dogfood channel: `ait build && aitcc app deploy`.\n\nSECURITY: method name, args, and result value are not redacted — never include secrets.\n\nLIVE guard: when running against a live/production relay (relay-live env, MCP_ENV=relay-live), this tool requires `confirm: true` to acknowledge that the SDK call may affect real users. Without it the call is rejected with a structured error. mock and relay-dev sessions are unaffected.\n\nIMPORTANT — 인자 시그니처 (잘못된 인자로 호출하면 토스 앱 crash 위험):\n setDeviceOrientation: call_sdk(\"setDeviceOrientation\", [{ type: \"landscape\" }]) // NOT \"landscape\"\n setIosSwipeGestureEnabled: call_sdk(\"setIosSwipeGestureEnabled\", [{ isEnabled: false }])\n setSecureScreen: call_sdk(\"setSecureScreen\", [{ enabled: true }])\n setScreenAwakeMode: call_sdk(\"setScreenAwakeMode\", [{ enabled: true }])\n getOperationalEnvironment: call_sdk(\"getOperationalEnvironment\", [])\n getPlatformOS: call_sdk(\"getPlatformOS\", [])\n getDeviceId: call_sdk(\"getDeviceId\", [])\n getLocale: call_sdk(\"getLocale\", [])\n getNetworkStatus: call_sdk(\"getNetworkStatus\", [])\n getSchemeUri: call_sdk(\"getSchemeUri\", [])\n requestReview: call_sdk(\"requestReview\", [])\n closeView: call_sdk(\"closeView\", [])",
2147
2269
  inputSchema: {
2148
2270
  type: "object",
2149
2271
  properties: {
@@ -2197,7 +2319,7 @@ const DEBUG_TOOL_DEFINITIONS = [
2197
2319
  },
2198
2320
  {
2199
2321
  name: "get_diagnostics",
2200
- description: "Returns a single-call server status snapshot so the agent can diagnose \"why is this not working?\" without calling multiple tools. Fields: mcpVersion (MCP SDK version), devtoolsVersion (@ait-co/devtools package version), tunnel (up/wssUrl/pid/startedAt), pages (list_pages result + lastSeenAt stats), lastAttachAt, lastDetachAt, recentErrors (last N server-side errors, PII/secret redacted), environment (kind: mock|relay-dev|relay-live, env: mock|relay backward-compat, reason, liveGuardActive: true when relay-live LIVE guard is active), serverLockHolder (pid + startedAt from the lock file, or null), nextRecommendedAction ({tool, reason} or null — the single next tool to call). All fields are nullable — missing data is null, not an error. debug-mode only — dev-mode (--mode=dev) does not support relay diagnostics. Tier C (both mock and relay). Call this first when debugging session state.",
2322
+ description: "Returns a single-call server status snapshot so the agent can diagnose \"why is this not working?\" without calling multiple tools. Fields: mcpVersion (MCP SDK version), devtoolsVersion (@ait-co/devtools package version), tunnel (up/wssUrl/pid/startedAt), pages (list_pages result + lastSeenAt stats), lastAttachAt, lastDetachAt, recentErrors (last N server-side errors, PII/secret redacted), environment (kind: mock|relay-dev|relay-live, env: mock|relay backward-compat, reason, liveGuardActive: true when relay-live LIVE guard is active), serverLockHolder (pid + startedAt from the lock file, or null), nextRecommendedAction ({tool, reason} or null — the single next tool to call; in local-target mode tunnel.up=false is normal so \"restart\" is never recommended). All fields are nullable — missing data is null, not an error. debug-mode only — dev-mode (--mode=dev) does not support relay diagnostics. Tier C (both mock and relay). Call this first when debugging session state.",
2201
2323
  inputSchema: {
2202
2324
  type: "object",
2203
2325
  properties: { recent_errors_limit: {
@@ -2364,20 +2486,50 @@ function listPages(connection, tunnel) {
2364
2486
  * URL plus this session's live relay. Throws if the tunnel is not up yet (no
2365
2487
  * relay URL to splice in) — the caller surfaces that as a tool error.
2366
2488
  *
2489
+ * When `AIT_DEBUG_TOTP_SECRET` is set, generates the current TOTP code and
2490
+ * splices it as `at=<code>` into the attach URL. The code is valid for one
2491
+ * 30-second time step (±1 skew accepted by the relay, so the effective window
2492
+ * is up to 90 s). If the scan happens after `totp.expiresAt`, call
2493
+ * `build_attach_url` again to get a fresh code.
2494
+ *
2367
2495
  * Also validates the scheme URL's authority. A suspicious authority (empty,
2368
2496
  * "web", "localhost", etc.) is surfaced as a non-fatal `authorityWarning` on
2369
2497
  * the result so the caller can show a helpful hint without blocking the link
2370
2498
  * generation (the warning is consistent with how other validation in
2371
2499
  * `buildDeepLinkAttachUrl` works — hard errors for relay, soft warning for
2372
2500
  * the scheme authority which is in the caller's input, not ours to own).
2501
+ *
2502
+ * SECRET-HANDLING: `totpSecret` (if provided) is used only to compute a code
2503
+ * and must never appear in any log, error message, or output outside of the
2504
+ * spliced `at=` param in `attachUrl`.
2505
+ *
2506
+ * @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL.
2507
+ * @param tunnel - Current tunnel status from the running debug server.
2508
+ * @param totpSecret - Optional hex-encoded TOTP secret (from
2509
+ * `AIT_DEBUG_TOTP_SECRET`). When provided, the current code is spliced into
2510
+ * the attach URL as `at=<code>`.
2373
2511
  */
2374
- function buildAttachUrl(schemeUrl, tunnel) {
2512
+ function buildAttachUrl(schemeUrl, tunnel, totpSecret) {
2375
2513
  if (!tunnel.up || tunnel.wssUrl === null) throw new Error("tunnel-down: cloudflared 터널이 안 떠 있습니다. MCP 서버를 재시작하거나 잠시 후 list_pages로 터널 상태를 다시 확인하세요.");
2376
2514
  const authorityWarning = validateSchemeAuthority(schemeUrl) ?? void 0;
2515
+ let totpCode;
2516
+ let totpMeta;
2517
+ if (totpSecret !== void 0 && totpSecret !== "") {
2518
+ const now = Date.now();
2519
+ totpCode = generateTotp(totpSecret, now);
2520
+ const STEP_SECONDS = 30;
2521
+ const expiresAtMs = (Math.floor(now / 1e3 / STEP_SECONDS) + 1) * STEP_SECONDS * 1e3;
2522
+ totpMeta = {
2523
+ enabled: true,
2524
+ ttlSeconds: STEP_SECONDS,
2525
+ expiresAt: new Date(expiresAtMs).toISOString()
2526
+ };
2527
+ }
2377
2528
  return {
2378
- attachUrl: buildDeepLinkAttachUrl(schemeUrl, tunnel.wssUrl),
2529
+ attachUrl: buildDeepLinkAttachUrl(schemeUrl, tunnel.wssUrl, totpCode),
2379
2530
  relayUrl: tunnel.wssUrl,
2380
- ...authorityWarning !== void 0 ? { authorityWarning } : {}
2531
+ ...authorityWarning !== void 0 ? { authorityWarning } : {},
2532
+ ...totpMeta !== void 0 ? { totp: totpMeta } : {}
2381
2533
  };
2382
2534
  }
2383
2535
  /**
@@ -2996,7 +3148,8 @@ function readDevtoolsVersion() {
2996
3148
  * Derives the next recommended action from a completed diagnostics snapshot.
2997
3149
  *
2998
3150
  * Branch rules (evaluated in priority order):
2999
- * 1. tunnel.up === false → restart
3151
+ * 1. tunnel.up === false AND env is relay → restart (relay needs a live tunnel)
3152
+ * 1b. tunnel.up === false AND env is mock → wait_for_page (local target: tunnel-less is normal)
3000
3153
  * 2. tunnel.up, pages empty, env === relay → build_attach_url (start attach)
3001
3154
  * 3. pages has entry + crashDetectedAt non-null → build_attach_url (re-attach after crash)
3002
3155
  * 4. otherwise → null (session looks healthy)
@@ -3004,7 +3157,12 @@ function readDevtoolsVersion() {
3004
3157
  * Pure — does not throw; receives the final assembled snapshot fields.
3005
3158
  */
3006
3159
  function computeNextRecommendedAction(tunnel, pages, env) {
3007
- if (!tunnel.up) return {
3160
+ if (!tunnel.up) if (!isRelayEnv(env)) {
3161
+ if (pages !== null && pages.pages.length === 0 && !pages.crashDetectedAt) return {
3162
+ tool: "wait_for_page",
3163
+ reason: "local Chromium spawn 직후 — 페이지 로드를 기다리거나 list_pages를 재호출하세요 (local 모드는 tunnel이 없는 게 정상입니다)"
3164
+ };
3165
+ } else return {
3008
3166
  tool: "restart",
3009
3167
  reason: "tunnel not up — run `npx @ait-co/devtools devtools-mcp` to restart"
3010
3168
  };
@@ -3069,83 +3227,6 @@ async function getDiagnostics(input) {
3069
3227
  };
3070
3228
  }
3071
3229
  //#endregion
3072
- //#region src/mcp/totp.ts
3073
- /**
3074
- * RFC 6238 TOTP implementation (Node.js, node:crypto only).
3075
- *
3076
- * External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used
3077
- * to keep the dependency surface minimal. This hand-roll is ~30 lines and
3078
- * covers exactly what relay-side auth needs.
3079
- *
3080
- * Algorithm summary (RFC 6238 + RFC 4226):
3081
- * T = floor(now / 30) — 30-second time step counter
3082
- * K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)
3083
- * MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)
3084
- * offset = MAC[19] & 0x0f
3085
- * code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits
3086
- *
3087
- * Security note (keep this comment accurate):
3088
- * The baked-in secret in a dogfood build is extractable from the bundle by a
3089
- * determined reverse engineer. This mechanism raises the bar from
3090
- * "anyone with the URL" to "URL + bundle extraction + live TOTP calculation".
3091
- * Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are
3092
- * blocked; deliberate reverse engineering is not. See threat model in
3093
- * src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.
3094
- *
3095
- * SECRET-HANDLING: secret values and computed codes MUST NOT appear in any
3096
- * log, error message, or string visible outside this module. Only boolean
3097
- * pass/fail and reason enum values are safe to surface.
3098
- */
3099
- /** Time step window in seconds (RFC 6238 default). */
3100
- const TIME_STEP = 30;
3101
- /** Number of digits in the generated code. */
3102
- const DIGITS = 6;
3103
- /**
3104
- * Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-
3105
- * clock time.
3106
- *
3107
- * @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32
3108
- * bytes). Must be the output of `generateAttachToken()` or compatible.
3109
- * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
3110
- * @returns A zero-padded 6-digit decimal string, e.g. `"042193"`.
3111
- */
3112
- function generateTotp(secret, when = Date.now()) {
3113
- const key = Buffer.from(secret, "hex");
3114
- const counter = Math.max(0, Math.floor(when / 1e3 / TIME_STEP));
3115
- const counterBuf = Buffer.alloc(8);
3116
- const hi = Math.floor(counter / 4294967296);
3117
- const lo = counter >>> 0;
3118
- counterBuf.writeUInt32BE(hi, 0);
3119
- counterBuf.writeUInt32BE(lo, 4);
3120
- const mac = createHmac("sha1", key).update(counterBuf).digest();
3121
- const offset = mac[19] & 15;
3122
- 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");
3123
- }
3124
- /**
3125
- * Verifies a TOTP code against the secret, accepting ±`skew` time steps to
3126
- * tolerate clock drift between the relay host and the client device.
3127
- *
3128
- * Uses `timingSafeEqual` for constant-time comparison to prevent timing
3129
- * side-channel attacks.
3130
- *
3131
- * @param secret - Hex-encoded shared secret.
3132
- * @param code - The 6-digit code to verify (string or numeric).
3133
- * @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
3134
- * @param skew - Number of adjacent steps to accept on either side. Default 1
3135
- * (accepts T-1, T, T+1 — a 90-second acceptance window).
3136
- * @returns `true` if the code matches any accepted step, `false` otherwise.
3137
- */
3138
- function verifyTotp(secret, code, when = Date.now(), skew = 1) {
3139
- const normalised = String(code).padStart(DIGITS, "0");
3140
- if (normalised.length !== DIGITS || !/^\d{6}$/.test(normalised)) return false;
3141
- const candidateBuf = Buffer.from(normalised, "utf8");
3142
- for (let delta = -skew; delta <= skew; delta++) {
3143
- const expected = generateTotp(secret, when + delta * TIME_STEP * 1e3);
3144
- if (timingSafeEqual(Buffer.from(expected, "utf8"), candidateBuf)) return true;
3145
- }
3146
- return false;
3147
- }
3148
- //#endregion
3149
3230
  //#region src/mcp/tunnel.ts
3150
3231
  /**
3151
3232
  * cloudflared quick tunnel + attach banner for the debug-mode MCP server.
@@ -3518,7 +3599,7 @@ function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs
3518
3599
  * naturally via `enableDomains`). The tier only controls visibility.
3519
3600
  */
3520
3601
  function createDebugServer(deps) {
3521
- const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep, defaultEnv } = deps;
3602
+ const { connection, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep, defaultEnv, totpSecret } = deps;
3522
3603
  const resolveEnvironment = getEnvDep ?? (() => getEnvironment({
3523
3604
  connection,
3524
3605
  defaultEnv
@@ -3530,7 +3611,7 @@ function createDebugServer(deps) {
3530
3611
  const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
3531
3612
  const server = new Server({
3532
3613
  name: "ait-debug",
3533
- version: "0.1.45"
3614
+ version: "0.1.48"
3534
3615
  }, { capabilities: { tools: { listChanged: true } } });
3535
3616
  server.setRequestHandler(ListToolsRequestSchema, () => {
3536
3617
  const env = resolveEnvironment();
@@ -3574,7 +3655,7 @@ function createDebugServer(deps) {
3574
3655
  if (name === "get_diagnostics") try {
3575
3656
  const rawLimit = request.params.arguments?.recent_errors_limit;
3576
3657
  const recentErrorsLimit = typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 10;
3577
- return jsonResult$1(await getDiagnostics({
3658
+ const result = await getDiagnostics({
3578
3659
  tunnel: getTunnelStatus(),
3579
3660
  connection,
3580
3661
  env: resolveEnvironment(),
@@ -3582,7 +3663,9 @@ function createDebugServer(deps) {
3582
3663
  collector,
3583
3664
  readLock: readServerLock,
3584
3665
  recentErrorsLimit
3585
- }));
3666
+ });
3667
+ const attached = connection.listTargets().length > 0;
3668
+ return envelopeResult$1(result, name, resolveEnvironment(), attached);
3586
3669
  } catch (err) {
3587
3670
  return errorResult(err, name);
3588
3671
  }
@@ -3609,7 +3692,7 @@ function createDebugServer(deps) {
3609
3692
  return `${baseText}\n\nNo page${deploymentId ? ` matching deploymentId=${deploymentId}` : ""} attached within ${timeoutSec}s${observedNote} — call list_pages to retry.`;
3610
3693
  };
3611
3694
  try {
3612
- const { attachUrl, relayUrl, authorityWarning } = buildAttachUrl(schemeUrl, getTunnelStatus());
3695
+ const { attachUrl, relayUrl, authorityWarning, totp } = buildAttachUrl(schemeUrl, getTunnelStatus(), totpSecret);
3613
3696
  const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
3614
3697
  const header = "This tool result is shown to the user directly — do NOT re-print the QR below in your reply (it wastes output tokens). Just tell the user to scan the QR in this output (Ctrl+O to expand if collapsed).";
3615
3698
  const guiAvailable = canOpenBrowser();
@@ -3618,7 +3701,8 @@ function createDebugServer(deps) {
3618
3701
  const qrHeadless = await renderQr(attachUrl);
3619
3702
  const headlessText = `${warningPrefix}${headlessNote}${header}\n${JSON.stringify({
3620
3703
  attachUrl,
3621
- relayUrl
3704
+ relayUrl,
3705
+ ...totp ? { totp } : {}
3622
3706
  }, null, 2)}\n\n${qrHeadless}`;
3623
3707
  if (!waitForAttach) return { content: [{
3624
3708
  type: "text",
@@ -3654,7 +3738,8 @@ function createDebugServer(deps) {
3654
3738
  };
3655
3739
  const shortText = `${warningPrefix}${header}\n${JSON.stringify({
3656
3740
  relayUrl,
3657
- openResult
3741
+ openResult,
3742
+ ...totp ? { totp } : {}
3658
3743
  }, null, 2)}\n\n브라우저에서 QR을 열었습니다${retriedNote}. 폰 카메라로 스캔하세요.\nURL: ${browserResult.httpUrl}`;
3659
3744
  if (!waitForAttach) return { content: [{
3660
3745
  type: "text",
@@ -3693,7 +3778,8 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3693
3778
  const baseText = `${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
3694
3779
  attachUrl,
3695
3780
  relayUrl,
3696
- openResult
3781
+ openResult,
3782
+ ...totp ? { totp } : {}
3697
3783
  }, null, 2)}\n\n${qr}`;
3698
3784
  if (!waitForAttach) return { content: [{
3699
3785
  type: "text",
@@ -3721,7 +3807,8 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3721
3807
  const qr = await renderQr(attachUrl);
3722
3808
  const baseText = `${warningPrefix}${header}\n${JSON.stringify({
3723
3809
  attachUrl,
3724
- relayUrl
3810
+ relayUrl,
3811
+ ...totp ? { totp } : {}
3725
3812
  }, null, 2)}\n\n${qr}`;
3726
3813
  if (!waitForAttach) return { content: [{
3727
3814
  type: "text",
@@ -3756,7 +3843,9 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3756
3843
  if (connection instanceof ChiiCdpConnection) try {
3757
3844
  await connection.refreshTargets();
3758
3845
  } catch {}
3759
- return jsonResult$1(listPages(connection, getTunnelStatus()));
3846
+ const pagesData = listPages(connection, getTunnelStatus());
3847
+ const attached = connection.listTargets().length > 0;
3848
+ return envelopeResult$1(pagesData, name, resolveEnvironment(), attached);
3760
3849
  }
3761
3850
  return classifyEnableDomainError(err, name);
3762
3851
  }
@@ -3768,11 +3857,14 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3768
3857
  return jsonResult$1({ exceptions: listExceptions(connection, typeof rawLimit === "number" && rawLimit > 0 ? rawLimit : 50) });
3769
3858
  }
3770
3859
  case "list_network_requests": return jsonResult$1(listNetworkRequests(connection));
3771
- case "list_pages":
3860
+ case "list_pages": {
3772
3861
  if (connection instanceof ChiiCdpConnection) try {
3773
3862
  await connection.refreshTargets();
3774
3863
  } catch {}
3775
- return jsonResult$1(listPages(connection, getTunnelStatus()));
3864
+ const listPagesData = listPages(connection, getTunnelStatus());
3865
+ const listPagesAttached = connection.listTargets().length > 0;
3866
+ return envelopeResult$1(listPagesData, name, resolveEnvironment(), listPagesAttached);
3867
+ }
3776
3868
  case "get_dom_document": return jsonResult$1(await getDomDocument(connection));
3777
3869
  case "take_snapshot": return jsonResult$1(await takeSnapshot(connection));
3778
3870
  case "take_screenshot": {
@@ -3783,7 +3875,11 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3783
3875
  mimeType: shot.mimeType
3784
3876
  }] };
3785
3877
  }
3786
- case "measure_safe_area": return jsonResult$1(await measureSafeArea(connection, resolveEnvironment()));
3878
+ case "measure_safe_area": {
3879
+ const safeAreaData = await measureSafeArea(connection, resolveEnvironment());
3880
+ const safeAreaAttached = connection.listTargets().length > 0;
3881
+ return envelopeResult$1(safeAreaData, name, resolveEnvironment(), safeAreaAttached);
3882
+ }
3787
3883
  case "evaluate": {
3788
3884
  const expression = request.params.arguments?.expression;
3789
3885
  if (typeof expression !== "string" || expression === "") return mcpError("evaluate: expression 인자가 비어 있습니다. 평가할 JavaScript 표현식을 전달하세요.");
@@ -3798,7 +3894,8 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
3798
3894
  if (isLiveRelayEnv(env) && request.params.arguments?.confirm !== true) return liveGuardError("call_sdk");
3799
3895
  const sdkResult = await callSdk(connection, sdkName, sdkArgs);
3800
3896
  if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk");
3801
- return jsonResult$1(sdkResult);
3897
+ const callSdkAttached = connection.listTargets().length > 0;
3898
+ return envelopeResult$1(sdkResult, name, resolveEnvironment(), callSdkAttached);
3802
3899
  }
3803
3900
  default: return unknownTool(name);
3804
3901
  }
@@ -3814,6 +3911,22 @@ function jsonResult$1(value) {
3814
3911
  text: JSON.stringify(value, null, 2)
3815
3912
  }] };
3816
3913
  }
3914
+ /**
3915
+ * Wraps `value` in a `ToolEnvelope` (when compat mode is off) and returns it
3916
+ * as a text content block. When `AIT_MCP_COMPAT=chrome-devtools` is set the
3917
+ * envelope is skipped and the raw value is returned — identical to `jsonResult`.
3918
+ */
3919
+ function envelopeResult$1(value, tool, env, attached) {
3920
+ const wrapped = wrapEnvelope(value, {
3921
+ tool,
3922
+ env,
3923
+ attached
3924
+ });
3925
+ return { content: [{
3926
+ type: "text",
3927
+ text: JSON.stringify(wrapped, null, 2)
3928
+ }] };
3929
+ }
3817
3930
  function unknownTool(name) {
3818
3931
  return mcpError(`알 수 없는 tool: ${name}`);
3819
3932
  }
@@ -3975,7 +4088,8 @@ async function runDebugServer(options = {}) {
3975
4088
  return qrServer;
3976
4089
  },
3977
4090
  diagnosticsCollector,
3978
- defaultEnv: "relay-dev"
4091
+ defaultEnv: "relay-dev",
4092
+ ...process.env.AIT_DEBUG_TOTP_SECRET ? { totpSecret: process.env.AIT_DEBUG_TOTP_SECRET } : {}
3979
4093
  });
3980
4094
  const transport = new StdioServerTransport();
3981
4095
  let closed = false;
@@ -4311,6 +4425,29 @@ const DEV_TOOL_DEFINITIONS = [
4311
4425
  },
4312
4426
  availableIn: "both"
4313
4427
  },
4428
+ {
4429
+ name: "build_attach_url",
4430
+ description: "Turns an `ait deploy --scheme-only` URL into a self-attaching deep link for a real device. NOT available in dev-mode — requires a live cloudflared relay (Tier B, relay-only). To use this tool: restart the MCP server with `--mode=debug` (or omit --mode) and set MCP_ENV=relay, then call build_attach_url to generate the QR for phone scanning. See: https://docs.aitc.dev/guides/debug-relay",
4431
+ inputSchema: {
4432
+ type: "object",
4433
+ properties: {
4434
+ scheme_url: {
4435
+ type: "string",
4436
+ description: "The intoss-private:// URL from `ait deploy --scheme-only`."
4437
+ },
4438
+ wait_for_attach: {
4439
+ type: "boolean",
4440
+ description: "If true, block until a page attaches."
4441
+ },
4442
+ open_in_browser: {
4443
+ type: "boolean",
4444
+ description: "If true (default), open the QR PNG in the OS browser."
4445
+ }
4446
+ },
4447
+ required: ["scheme_url"]
4448
+ },
4449
+ availableIn: "relay"
4450
+ },
4314
4451
  {
4315
4452
  name: "evaluate",
4316
4453
  description: "Evaluates an arbitrary JavaScript expression via CDP Runtime.evaluate. NOT available in dev-mode (no CDP connection). Switch to `--mode=local` or `--mode=debug` for CDP access.",
@@ -4401,6 +4538,12 @@ const CDP_ONLY_TOOL_NAMES = new Set([
4401
4538
  "list_exceptions"
4402
4539
  ]);
4403
4540
  /**
4541
+ * Tier B tools — relay-only per RFC #277.
4542
+ * Listed in dev-mode tool surface (issue #323) so agents get a hand-off hint
4543
+ * toward `--mode=debug` instead of "Unknown tool".
4544
+ */
4545
+ const TIER_B_TOOL_NAMES = new Set(["build_attach_url"]);
4546
+ /**
4404
4547
  * Builds the `list_pages` dev-mode shim response.
4405
4548
  * Returns the Vite dev URL as a single-entry page list with `devMode: true`.
4406
4549
  */
@@ -4515,13 +4658,14 @@ function createDevServer(deps = {}) {
4515
4658
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
4516
4659
  const server = new Server({
4517
4660
  name: "ait-devtools",
4518
- version: "0.1.45"
4661
+ version: "0.1.48"
4519
4662
  }, { capabilities: { tools: {} } });
4520
4663
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
4521
4664
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
4522
4665
  const name = request.params.name;
4523
4666
  if (!DEV_TOOL_NAMES.has(name)) return mcpError(`알 수 없는 tool: ${name}`);
4524
4667
  if (CDP_ONLY_TOOL_NAMES.has(name)) return mcpError(`${name}: ${CDP_UNAVAILABLE_IN_DEV_MODE}`);
4668
+ if (TIER_B_TOOL_NAMES.has(name)) return tierRejectionError(name, "relay", "mock", "dev-mode — Vite HTTP endpoint, no CDP/relay connection. `--mode=debug` (または `devtools-mcp` without --mode) + MCP_ENV=relay로 재시작하세요.");
4525
4669
  try {
4526
4670
  const effective = name === "devtools_get_mock_state" ? "AIT.getMockState" : name;
4527
4671
  if (isAitToolName(effective)) switch (effective) {
@@ -4531,13 +4675,13 @@ function createDevServer(deps = {}) {
4531
4675
  default: return mcpError(`알 수 없는 tool: ${name}`);
4532
4676
  }
4533
4677
  switch (name) {
4534
- case "list_pages": return jsonResult(buildDevListPagesResult(devtoolsUrl));
4535
- case "get_diagnostics": return jsonResult(await buildDevDiagnostics(devtoolsUrl, stateEndpoint, (url) => fetch(url)));
4536
- case "measure_safe_area": return jsonResult(await buildDevMeasureSafeArea(aitSource));
4678
+ case "list_pages": return envelopeResult("list_pages", buildDevListPagesResult(devtoolsUrl));
4679
+ case "get_diagnostics": return envelopeResult("get_diagnostics", await buildDevDiagnostics(devtoolsUrl, stateEndpoint, (url) => fetch(url)));
4680
+ case "measure_safe_area": return envelopeResult("measure_safe_area", await buildDevMeasureSafeArea(aitSource));
4537
4681
  case "call_sdk": {
4538
4682
  const sdkName = request.params.arguments?.name;
4539
4683
  if (typeof sdkName !== "string" || sdkName === "") return mcpError("call_sdk: name 인자가 비어 있습니다. 호출할 메서드 이름을 전달하세요.");
4540
- return jsonResult(await buildDevCallSdk(sdkName, aitSource));
4684
+ return envelopeResult("call_sdk", await buildDevCallSdk(sdkName, aitSource));
4541
4685
  }
4542
4686
  default: return mcpError(`알 수 없는 tool: ${name}`);
4543
4687
  }
@@ -4553,6 +4697,26 @@ function jsonResult(value) {
4553
4697
  text: JSON.stringify(value, null, 2)
4554
4698
  }] };
4555
4699
  }
4700
+ /**
4701
+ * Wraps `value` in a `ToolEnvelope` (when compat mode is off) and returns it
4702
+ * as a text content block. In dev-mode `env` is always `'mock'` and
4703
+ * `attached` is always `true` (the Vite dev server is the single implicit
4704
+ * "attached" page).
4705
+ *
4706
+ * When `AIT_MCP_COMPAT=chrome-devtools` the envelope is skipped and the raw
4707
+ * value is returned — identical to `jsonResult` (0.1.x back-compat).
4708
+ */
4709
+ function envelopeResult(tool, value) {
4710
+ const wrapped = wrapEnvelope(value, {
4711
+ tool,
4712
+ env: "mock",
4713
+ attached: true
4714
+ });
4715
+ return { content: [{
4716
+ type: "text",
4717
+ text: JSON.stringify(wrapped, null, 2)
4718
+ }] };
4719
+ }
4556
4720
  /** Builds the dev-mode server and connects it over stdio. */
4557
4721
  async function runDevServer() {
4558
4722
  const server = createDevServer();