@ait-co/devtools 0.1.109 → 0.1.110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.en.md +13 -31
  2. package/README.md +13 -31
  3. package/dist/in-app/auto.d.ts.map +1 -1
  4. package/dist/in-app/auto.js +40 -3
  5. package/dist/in-app/auto.js.map +1 -1
  6. package/dist/in-app/index.d.ts.map +1 -1
  7. package/dist/in-app/index.js +39 -2
  8. package/dist/in-app/index.js.map +1 -1
  9. package/dist/mcp/cli.d.ts +4 -16
  10. package/dist/mcp/cli.d.ts.map +1 -1
  11. package/dist/mcp/cli.js +623 -678
  12. package/dist/mcp/cli.js.map +1 -1
  13. package/dist/mcp/server.d.ts.map +1 -1
  14. package/dist/mcp/server.js +47 -59
  15. package/dist/mcp/server.js.map +1 -1
  16. package/dist/mock/index.d.ts.map +1 -1
  17. package/dist/mock/index.js +21 -2
  18. package/dist/mock/index.js.map +1 -1
  19. package/dist/panel/index.js +47 -32
  20. package/dist/panel/index.js.map +1 -1
  21. package/dist/{pool-CuVMzWGB.d.ts → pool-Bf6rQci4.d.ts} +206 -44
  22. package/dist/pool-Bf6rQci4.d.ts.map +1 -0
  23. package/dist/{qr-http-server-D4EAA7Il.js → qr-http-server-BJJt3ush.js} +8 -17
  24. package/dist/qr-http-server-BJJt3ush.js.map +1 -0
  25. package/dist/{qr-http-server-A9vld8r7.cjs → qr-http-server-BVS-HZjU.cjs} +8 -17
  26. package/dist/qr-http-server-BVS-HZjU.cjs.map +1 -0
  27. package/dist/{qr-http-server-Dj3Z0NHi.cjs → qr-http-server-C1T4RNbq.cjs} +8 -17
  28. package/dist/qr-http-server-C1T4RNbq.cjs.map +1 -0
  29. package/dist/{qr-http-server-HzdCLU8s.js → qr-http-server-Cs93vEPH.js} +8 -17
  30. package/dist/qr-http-server-Cs93vEPH.js.map +1 -0
  31. package/dist/test-runner/config.d.ts +1 -1
  32. package/dist/test-runner/pool.d.ts +1 -1
  33. package/dist/{tunnel-BjJROkcj.js → tunnel-Cpn3mA4u.js} +3 -3
  34. package/dist/tunnel-Cpn3mA4u.js.map +1 -0
  35. package/dist/{tunnel-d_G9AIFn.cjs → tunnel-Dj8Kf2QS.cjs} +3 -3
  36. package/dist/tunnel-Dj8Kf2QS.cjs.map +1 -0
  37. package/dist/unplugin/index.cjs +1 -1
  38. package/dist/unplugin/index.d.cts +196 -34
  39. package/dist/unplugin/index.d.cts.map +1 -1
  40. package/dist/unplugin/index.d.ts +196 -34
  41. package/dist/unplugin/index.d.ts.map +1 -1
  42. package/dist/unplugin/index.js +1 -1
  43. package/dist/unplugin/tunnel.cjs +2 -2
  44. package/dist/unplugin/tunnel.cjs.map +1 -1
  45. package/dist/unplugin/tunnel.d.cts +1 -1
  46. package/dist/unplugin/tunnel.d.ts +1 -1
  47. package/dist/unplugin/tunnel.js +2 -2
  48. package/dist/unplugin/tunnel.js.map +1 -1
  49. package/package.json +14 -14
  50. package/dist/pool-CuVMzWGB.d.ts.map +0 -1
  51. package/dist/qr-http-server-A9vld8r7.cjs.map +0 -1
  52. package/dist/qr-http-server-D4EAA7Il.js.map +0 -1
  53. package/dist/qr-http-server-Dj3Z0NHi.cjs.map +0 -1
  54. package/dist/qr-http-server-HzdCLU8s.js.map +0 -1
  55. package/dist/tunnel-BjJROkcj.js.map +0 -1
  56. package/dist/tunnel-d_G9AIFn.cjs.map +0 -1
package/dist/mcp/cli.js CHANGED
@@ -24,6 +24,95 @@ import { Tunnel, bin, install } from "cloudflared";
24
24
  //#region \0rolldown/runtime.js
25
25
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
26
26
  //#endregion
27
+ //#region src/in-app/gate.ts
28
+ /**
29
+ * The host suffix the Toss app uses to serve dogfood / private mini-apps.
30
+ *
31
+ * A `intoss-private://` (dogfood) entry maps to a host such as
32
+ * `aitc-sdk-example.private-apps.tossmini.com`. A production `intoss://`
33
+ * entry is served from `*.apps.tossmini.com` — the `.private-apps.` segment
34
+ * is absent. Confirmed live over CDP for mini-app 31146; the exact production
35
+ * host is to be re-confirmed once 31146 passes review (spec open question 2).
36
+ */
37
+ const PRIVATE_APPS_HOST_SUFFIX = ".private-apps.tossmini.com";
38
+ /**
39
+ * The host suffix Cloudflare quick-tunnels serve from — the env 2 (PWA) entry.
40
+ * See {@link isTrycloudflareHost} for why this host kind bypasses Layer B1.
41
+ */
42
+ const TRYCLOUDFLARE_HOST_SUFFIX = ".trycloudflare.com";
43
+ /**
44
+ * Returns whether `hostname` is a `*.private-apps.tossmini.com` subdomain —
45
+ * the host the Toss app reserves for dogfood / private mini-app entries.
46
+ *
47
+ * The match is an exact suffix check, not a substring `.includes()`: a
48
+ * substring test would also accept an attacker-controlled host like
49
+ * `private-apps.tossmini.com.evil.example`, which ends in `.example`, not in
50
+ * `.tossmini.com`. Requiring the string to END with the suffix closes that.
51
+ * The leading `.` in the suffix also forces a real subdomain label, so a
52
+ * bare `private-apps.tossmini.com` (no mini-app subdomain) does not match.
53
+ */
54
+ function isPrivateAppsHost(hostname) {
55
+ return hostname.endsWith(PRIVATE_APPS_HOST_SUFFIX);
56
+ }
57
+ /**
58
+ * The host suffix Cloudflare quick-tunnels use — the env 2 (PWA) entry.
59
+ *
60
+ * Env 2 serves the local Vite dev server through a `*.trycloudflare.com` quick
61
+ * tunnel (`src/unplugin/tunnel.ts`). It has no Toss app, no `intoss-private://`
62
+ * scheme, and — critically — no production runtime: the SDK is the devtools
63
+ * mock, and the page is the developer's own dev build. The Layer B1 safety net
64
+ * (which stops a dogfood build that lands on a Toss *production* host from
65
+ * attaching) has nothing to protect against here, because env 2 has no
66
+ * production host. So a trycloudflare host is allowed past B1 — but ONLY past
67
+ * B1: the remaining layers (C1 opt-in, C2 relay, C3 TOTP) still apply, so a
68
+ * leaked tunnel URL is still blocked by TOTP exactly as on the Toss path.
69
+ *
70
+ * The match is the same exact-suffix `endsWith` check as
71
+ * {@link isPrivateAppsHost} — never a substring `.includes()`, which would
72
+ * accept an attacker-controlled `evil.trycloudflare.com.example.com`. The
73
+ * leading `.` forces a real subdomain label, so a bare `trycloudflare.com`
74
+ * (no tunnel subdomain) does not match.
75
+ */
76
+ function isTrycloudflareHost(hostname) {
77
+ return hostname.endsWith(TRYCLOUDFLARE_HOST_SUFFIX);
78
+ }
79
+ /**
80
+ * Returns true when the hostname is a localhost/loopback address.
81
+ * Allowed: `localhost`, `127.x.x.x` (full RFC 5735 loopback block), `[::1]`,
82
+ * `0.0.0.0`, `*.localhost`.
83
+ *
84
+ * Security note: `hostname.startsWith('127.')` is intentionally NOT used —
85
+ * that pattern would accept `127.evil.com`, which starts with "127." but is an
86
+ * attacker-controlled hostname, not a loopback address. Instead, the 127/8
87
+ * loopback block is matched with a strict numeric-quad regex so only valid
88
+ * dotted-decimal IPv4 in the 127.x.x.x range pass (#665 작업 A fix).
89
+ */
90
+ function isLocalhostHost(hostname) {
91
+ if (hostname === "localhost" || hostname === "0.0.0.0") return true;
92
+ if (hostname === "[::1]") return true;
93
+ if (/^127\.\d+\.\d+\.\d+$/.test(hostname)) return true;
94
+ if (hostname.endsWith(".localhost")) return true;
95
+ return false;
96
+ }
97
+ /**
98
+ * Positive-allowlist kill-switch (#665): returns true when the hostname is a
99
+ * known debug-allowed host. The debug surface is ONLY active on:
100
+ * - localhost / loopback (env 1 desktop dev)
101
+ * - *.trycloudflare.com (env 2 PWA tunnel)
102
+ * - *.private-apps.tossmini.com (env 3 dog-food)
103
+ *
104
+ * Any other host (including apps.tossmini.com — the former env 4 LIVE host)
105
+ * is silently blocked. This is a positive allowlist — unlisted hosts never
106
+ * had debug surface regardless, but this function makes it explicit and
107
+ * auditable in a single place.
108
+ *
109
+ * SECRET-HANDLING: the hostname value MUST NOT be logged or included in any
110
+ * error reason string — only benign labels ('host not in allowlist') are safe.
111
+ */
112
+ function isDebugAllowedHost(hostname) {
113
+ return isLocalhostHost(hostname) || isTrycloudflareHost(hostname) || isPrivateAppsHost(hostname);
114
+ }
115
+ //#endregion
27
116
  //#region src/shared/parent-watcher.ts
28
117
  /**
29
118
  * Shared parent-PID watcher — used by both the MCP debug daemon and the
@@ -817,7 +906,7 @@ function logError(event, fields = {}) {
817
906
  * Attach reliability (#281):
818
907
  * `refreshTargets()` emits an internal 'target:attached' event whenever a
819
908
  * new target is added to the relay. `waitForFirstTarget()` awaits that event
820
- * (with a polling-interval fallback) so `build_attach_url wait_for_attach`
909
+ * (with a polling-interval fallback) so `start_attach`'s attach wait
821
910
  * resolves deterministically rather than racing between polling rounds.
822
911
  */
823
912
  /** Max events retained per domain ring buffer. */
@@ -1957,9 +2046,10 @@ function isCompatMode() {
1957
2046
  return process.env.AIT_MCP_COMPAT === "chrome-devtools";
1958
2047
  }
1959
2048
  /**
1960
- * Maps `McpEnvironment` to `EnvelopeEnv`. After #307 these are the same
1961
- * union (`mock | relay-dev | relay-live`), so this is identity — kept as a
1962
- * named export for surface stability if envelope env diverges in the future.
2049
+ * Maps `McpEnvironment` to `EnvelopeEnv`. These are now the same 3-value
2050
+ * union (`mock | relay-dev | relay-mobile`; `relay-live` removed in #665),
2051
+ * so this is identity — kept as a named export for surface stability if
2052
+ * envelope env diverges in the future.
1963
2053
  */
1964
2054
  function toEnvelopeEnv(env) {
1965
2055
  return env;
@@ -1995,9 +2085,9 @@ function wrapEnvelope(data, ctx) {
1995
2085
  //#endregion
1996
2086
  //#region src/mcp/environment.ts
1997
2087
  /**
1998
- * Returns `true` when the environment is any relay variant (`relay-dev`,
1999
- * `relay-live`, or `relay-mobile`). Use this instead of `env === 'relay'` for
2000
- * tier checks — every relay env surfaces the Tier B / relay-only tool set.
2088
+ * Returns `true` when the environment is any relay variant (`relay-dev` or
2089
+ * `relay-mobile`). Use this instead of `env === 'relay'` for tier checks —
2090
+ * every relay env surfaces the Tier B / relay-only tool set.
2001
2091
  *
2002
2092
  * Written as an exhaustive switch so a future `McpEnvironment` member that is
2003
2093
  * missing an arm is a TS compile error rather than a silent `false`.
@@ -2005,78 +2095,49 @@ function wrapEnvelope(data, ctx) {
2005
2095
  function isRelayEnv(env) {
2006
2096
  switch (env) {
2007
2097
  case "relay-dev":
2008
- case "relay-live":
2009
2098
  case "relay-mobile": return true;
2010
2099
  case "mock": return false;
2011
2100
  }
2012
2101
  }
2013
2102
  /**
2014
- * Returns `true` when the environment is the LIVE relay (`relay-live`).
2015
- * This is the guard condition for side-effect tool protection. `relay-mobile`
2016
- * is a dev-intent env (env 2 PWA) and is NOT live.
2017
- */
2018
- function isLiveRelayEnv(env) {
2019
- return env === "relay-live";
2020
- }
2021
- /**
2022
2103
  * Maps the `McpEnvironment` union to the legacy two-value union
2023
2104
  * (`'mock' | 'relay'`) for backward-compatible fields in diagnostics output.
2024
- * Every relay variant (incl. `relay-mobile`) collapses to `'relay'`.
2105
+ * Every relay variant (`relay-dev`, `relay-mobile`) collapses to `'relay'`.
2106
+ * Written as an exhaustive switch so a missing arm is a TS compile error.
2025
2107
  */
2026
2108
  function toLegacyEnv(env) {
2027
- if (env === "mock") return "mock";
2028
- return "relay";
2109
+ switch (env) {
2110
+ case "mock": return "mock";
2111
+ case "relay-dev":
2112
+ case "relay-mobile": return "relay";
2113
+ }
2029
2114
  }
2030
2115
  /**
2031
- * Reconstructs the four-value `McpEnvironment` output string from the
2032
- * orthogonal signals (issues #348, #378):
2116
+ * Reconstructs the three-value `McpEnvironment` output string from the
2117
+ * orthogonal signals (issues #348, #378, #665):
2033
2118
  *
2034
- * - `kind === 'local'` → `'mock'`
2035
- * - `kind === 'relay'` && liveIntent → `'relay-live'`
2036
- * - `kind === 'relay'` && !liveIntent && origin 'external-pwa' → `'relay-mobile'`
2037
- * - `kind === 'relay'` && !liveIntent && origin intoss/undefined → `'relay-dev'`
2119
+ * - `kind === 'local'` → `'mock'`
2120
+ * - `kind === 'relay'` && origin 'external-pwa' → `'relay-mobile'`
2121
+ * - `kind === 'relay'` && origin intoss/undefined → `'relay-dev'`
2038
2122
  *
2039
2123
  * `relayOrigin` is the booted-family discriminator (NOT sniffed from the URL)
2040
2124
  * that distinguishes the env-2 external-PWA relay (`relay-mobile`) from the
2041
2125
  * intoss-private dog-food relay (`relay-dev`); both are `kind: 'relay'`.
2042
2126
  *
2127
+ * `relay-live` (env 4) has been removed (#665). `liveIntent` parameter is gone.
2128
+ *
2043
2129
  * Pure — used at every output boundary (envelope `meta.env`, `get_debug_status`,
2044
2130
  * `measure_safe_area` provenance) so the surface never sniffs a URL again.
2045
2131
  *
2046
2132
  * Written switch-style so a missing arm is a TS compile error (never falls
2047
2133
  * through to a default).
2048
2134
  */
2049
- function deriveEnvironment(kind, liveIntent, relayOrigin) {
2135
+ function deriveEnvironment(kind, relayOrigin) {
2050
2136
  switch (kind) {
2051
2137
  case "local": return "mock";
2052
- case "relay":
2053
- if (liveIntent) return "relay-live";
2054
- return relayOrigin === "external-pwa" ? "relay-mobile" : "relay-dev";
2138
+ case "relay": return relayOrigin === "external-pwa" ? "relay-mobile" : "relay-dev";
2055
2139
  }
2056
2140
  }
2057
- /**
2058
- * Module-level `relay-dev` vs `relay-live` intent bit (issue #348).
2059
- *
2060
- * Armed by `start_debug({ mode: 'relay-live' })` (and seeded at boot by the
2061
- * deprecated `MCP_ENV=relay-live` alias). Disarming is implicit: when the
2062
- * active connection becomes local, the LIVE guard reads
2063
- * `connection.kind === 'relay' && liveIntent`, so a stale `true` bit is inert.
2064
- *
2065
- * SECRET-HANDLING: this is a boolean — never a secret. Safe to read in logs.
2066
- */
2067
- let liveIntent = false;
2068
- /** Returns the current `liveIntent` bit. */
2069
- function getLiveIntent() {
2070
- return liveIntent;
2071
- }
2072
- /**
2073
- * Sets the `liveIntent` bit. Called by `start_debug` (true for `relay-live`,
2074
- * false for every other mode) and once at boot by the `MCP_ENV=relay-live`
2075
- * deprecated alias.
2076
- */
2077
- function setLiveIntent(value) {
2078
- liveIntent = value;
2079
- }
2080
2141
  //#endregion
2081
2142
  //#region src/mcp/errors.ts
2082
2143
  /**
@@ -2103,12 +2164,12 @@ function mcpError(message) {
2103
2164
  * (예: `derived:kind=relay,liveIntent=true`).
2104
2165
  */
2105
2166
  function tierRejectionError(toolName, requiredEnv, currentEnv, reason) {
2106
- return mcpError(`${`${toolName}은 ${requiredEnv === "relay" ? "relay (실기기 연결)" : "mock (로컬 브라우저)"} 환경에서만 사용할 수 있습니다. 현재 환경: ${currentEnv === "relay" ? "relay" : "mock"} (${reason}). ${requiredEnv === "relay" ? "relay로 전환하려면 MCP_ENV=relay 설정 후 서버를 재시작하고 build_attach_url → QR 스캔으로 실기기를 attach하세요." : "mock으로 전환하려면 MCP_ENV=mock 설정 후 서버를 재시작하세요."}`}\n\n${`tool ${toolName} is available only in ${requiredEnv}. Current environment is ${currentEnv} (${reason}).`}`);
2167
+ return mcpError(`${`${toolName}은 ${requiredEnv === "relay" ? "relay (실기기 연결)" : "mock (로컬 브라우저)"} 환경에서만 사용할 수 있습니다. 현재 환경: ${currentEnv === "relay" ? "relay" : "mock"} (${reason}). ${requiredEnv === "relay" ? "relay로 전환하려면 MCP_ENV=relay 설정 후 서버를 재시작하고 start_attach → QR 스캔으로 실기기를 attach하세요." : "mock으로 전환하려면 MCP_ENV=mock 설정 후 서버를 재시작하세요."}`}\n\n${`tool ${toolName} is available only in ${requiredEnv}. Current environment is ${currentEnv} (${reason}).`}`);
2107
2168
  }
2108
2169
  /**
2109
2170
  * 상태 1: tunnel 미가동 — cloudflared 터널이 아직 뜨지 않았다.
2110
2171
  *
2111
- * `build_attach_url` 호출 시 tunnel.up === false 인 경우.
2172
+ * `start_attach` 호출 시 tunnel.up === false 인 경우.
2112
2173
  */
2113
2174
  function tunnelDownError() {
2114
2175
  return mcpError("cloudflared 터널이 안 떠 있습니다. MCP 서버를 재시작하거나 잠시 후 list_pages로 터널 상태를 다시 확인하세요.");
@@ -2119,7 +2180,7 @@ function tunnelDownError() {
2119
2180
  * enableDomains()가 "No mini-app page attached" 에러를 던질 때.
2120
2181
  */
2121
2182
  function pageMissingError(toolName) {
2122
- return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 attach 안 됨. dog-food 번들 배포 후 build_attach_url을 호출해 QR 생성하세요: \`ait deploy --scheme-only\` → \`build_attach_url(scheme_url)\` → QR 스캔.`);
2183
+ return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 attach 안 됨. dog-food 번들 배포 후 start_attach를 호출해 QR 생성 + attach까지 진행하세요: \`ait deploy --scheme-only\` → \`start_attach(scheme_url)\` → QR 스캔.`);
2123
2184
  }
2124
2185
  /**
2125
2186
  * 상태 3: page crash — 연결됐던 페이지가 crash/destroy됐다.
@@ -2128,7 +2189,7 @@ function pageMissingError(toolName) {
2128
2189
  * 던질 때 이 메시지를 사용한다.
2129
2190
  */
2130
2191
  function pageCrashError(toolName) {
2131
- return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 crash됐습니다. 토스 앱을 재실행한 뒤 build_attach_url → QR 스캔으로 재attach하세요.`);
2192
+ return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 crash됐습니다. 토스 앱을 재실행한 뒤 start_attach → QR 스캔으로 재attach하세요.`);
2132
2193
  }
2133
2194
  /**
2134
2195
  * 상태 4: SDK 부재 — window.__sdkCall이 주입되지 않았다.
@@ -2150,23 +2211,6 @@ function sdkAbsentError(toolName, isLocal = false) {
2150
2211
  return mcpError(`${prefix}window.__sdkCall이 주입되지 않았습니다 (dog-food 빌드가 아닙니다). dog-food 채널(intoss-private)로 재배포 후 QR을 다시 스캔하세요: \`ait build && aitcc app deploy\`.`);
2151
2212
  }
2152
2213
  /**
2153
- * relay-live 환경에서 side-effect 도구(`call_sdk`, `evaluate`)를 `confirm: true`
2154
- * 없이 호출했을 때 반환하는 거부 메시지.
2155
- *
2156
- * 다음 행동을 두 가지로 제시한다:
2157
- * 1. 같은 호출에 `confirm: true` 인자를 추가해 재시도.
2158
- * 2. 읽기 전용 환경(relay-dev, mock)으로 전환.
2159
- */
2160
- function liveGuardError(toolName) {
2161
- return mcpError(`[LIVE relay guard] ${toolName}은 현재 relay-live(실 출시 런타임) 세션에서 side-effect 호출입니다. 실유저에게 영향을 줄 수 있어 명시적 동의가 필요합니다.
2162
-
2163
- 다음 중 하나를 선택하세요:
2164
- 1. \`confirm: true\` 인자를 추가해 재호출: ${toolName}(…, confirm: true)\n 2. 읽기 전용 도구(list_pages, list_console_messages, take_screenshot 등)를 사용하세요.
2165
- 3. dog-food 빌드(relay-dev 환경)에서 먼저 검증 후 live에 적용하세요.
2166
-
2167
- live-guard: MCP_ENV=relay-live + confirm: true missing`);
2168
- }
2169
- /**
2170
2214
  * relay WebSocket 연결이 끊겼을 때 — 크래시가 아닌 네트워크/프로세스 종료.
2171
2215
  */
2172
2216
  function relayDisconnectError(toolName) {
@@ -2676,7 +2720,7 @@ const en = {
2676
2720
  "dashboard.tunnel.up": "Connected",
2677
2721
  "dashboard.tunnel.down": "Disconnected",
2678
2722
  "dashboard.attach.section": "Attach QR",
2679
- "dashboard.attach.hint": "Call the build_attach_url MCP tool to show the QR here.",
2723
+ "dashboard.attach.hint": "Call the start_attach MCP tool to show the QR here.",
2680
2724
  "dashboard.attach.tunnelDown": "Relay disconnected — this QR is no longer valid. Restart the relay, then regenerate the QR.",
2681
2725
  "dashboard.pages.section": "Connected Pages",
2682
2726
  "dashboard.pages.empty": "No attached pages",
@@ -2694,7 +2738,6 @@ const en = {
2694
2738
  "attach.url.section": "URL (fallback)",
2695
2739
  "attach.mode.sandbox": "env 2 — AITC Sandbox App (PWA)",
2696
2740
  "attach.mode.intossDev": "env 3 — intoss-private relay dev",
2697
- "attach.mode.intossLive": "env 4 — intoss live relay debug",
2698
2741
  "attach.sandbox.step1": "Launch the launcher PWA icon on your home screen (if the Safari address bar is visible, it is not standalone).",
2699
2742
  "attach.sandbox.step2": "Scan this QR code with <strong>\"Scan QR with camera\"</strong> inside the launcher.",
2700
2743
  "attach.sandbox.step3": "The mini-app opens fullscreen and the debug session attaches automatically.",
@@ -2710,7 +2753,6 @@ const en = {
2710
2753
  "attach.intoss.faq.prepare": "<strong>Mini-app stuck in PREPARE state</strong> — verify the deep-link has a <code>_deploymentId</code> parameter",
2711
2754
  "attach.intoss.faq.chii": "<strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import",
2712
2755
  "attach.intoss.faq.totp": "<strong>TOTP gate Layer C is inactive</strong> — check that <code>AIT_DEBUG_TOTP_SECRET</code> is set on the relay server",
2713
- "attach.intoss.faq.liveReadOnly": "<strong>LIVE session is read-only</strong> — <code>call_sdk</code>/<code>evaluate</code> require an explicit <code>confirm</code>",
2714
2756
  "launcher.title": "AITC DevTools Launcher",
2715
2757
  "launcher.description": "Scan the terminal QR code or paste the tunnel URL.",
2716
2758
  "launcher.installCta": "Install launcher to your phone",
@@ -2913,7 +2955,7 @@ const tables = {
2913
2955
  "dashboard.tunnel.up": "연결됨",
2914
2956
  "dashboard.tunnel.down": "끊어짐",
2915
2957
  "dashboard.attach.section": "Attach QR",
2916
- "dashboard.attach.hint": "build_attach_url MCP tool을 호출하면 QR이 여기에 표시됩니다.",
2958
+ "dashboard.attach.hint": "start_attach MCP tool을 호출하면 QR이 여기에 표시됩니다.",
2917
2959
  "dashboard.attach.tunnelDown": "relay 연결이 끊겼습니다 — 이 QR은 더 이상 유효하지 않습니다. relay를 재시작한 뒤 QR을 다시 생성하세요.",
2918
2960
  "dashboard.pages.section": "연결된 Pages",
2919
2961
  "dashboard.pages.empty": "attach된 페이지 없음",
@@ -2931,7 +2973,6 @@ const tables = {
2931
2973
  "attach.url.section": "URL (fallback)",
2932
2974
  "attach.mode.sandbox": "환경 2 — AITC Sandbox App (PWA)",
2933
2975
  "attach.mode.intossDev": "환경 3 — intoss-private relay dev",
2934
- "attach.mode.intossLive": "환경 4 — intoss live relay debug",
2935
2976
  "attach.sandbox.step1": "홈 화면의 launcher PWA 아이콘으로 실행하세요 (Safari 주소창이 보이면 standalone이 아닙니다).",
2936
2977
  "attach.sandbox.step2": "launcher 안의 <strong>\"QR 카메라로 스캔\"</strong>으로 이 QR 코드를 스캔하세요.",
2937
2978
  "attach.sandbox.step3": "미니앱이 풀스크린으로 열리고 디버그 세션이 자동으로 attach됩니다.",
@@ -2947,7 +2988,6 @@ const tables = {
2947
2988
  "attach.intoss.faq.prepare": "<strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link에 <code>_deploymentId</code> 파라미터가 있는지 확인",
2948
2989
  "attach.intoss.faq.chii": "<strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인",
2949
2990
  "attach.intoss.faq.totp": "<strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인",
2950
- "attach.intoss.faq.liveReadOnly": "<strong>LIVE 세션은 read-only입니다</strong> — <code>call_sdk</code>/<code>evaluate</code> 실행에는 명시적 <code>confirm</code>이 필요합니다",
2951
2991
  "launcher.title": "AITC DevTools Launcher",
2952
2992
  "launcher.description": "터미널 QR을 스캔하거나 URL을 입력하세요.",
2953
2993
  "launcher.installCta": "폰에 런처 설치하기",
@@ -3206,7 +3246,7 @@ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0;
3206
3246
  }
3207
3247
  .inspector-link:hover { background: #388bfd; }
3208
3248
  .inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }
3209
- </style></head><body><h1>AIT 디버그 세션 — QR 스캔</h1>__MODE_LABEL____LANG_SWITCHER__<p class="label">deployment: __SAFE_LABEL__</p><div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>스캔 절차</h2><ol><li>토스 앱을 실행하세요.</li><li>폰 카메라 앱으로 QR 코드를 스캔하세요.</li><li>팝업이 뜨면 <strong>"토스로 열기"</strong>를 탭하세요.</li><li>미니앱이 열리고 디버그 세션이 자동으로 attach됩니다.</li></ol></section><hr/><section><h2>진단 체크리스트</h2><ul><li><strong>토스 앱이 안 열리는 경우</strong> — 앱 버전 확인, 카메라 앱으로 스캔 (토스 앱 내 QR 리더 X)</li><li><strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link에 <code>_deploymentId</code> 파라미터가 있는지 확인</li><li><strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인</li><li><strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인</li>__LIVE_FAQ__</ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="복사">복사</button></div></section><hr/><section id="inspector-section"><h2>인스펙터</h2>__INSPECTOR_SECTION__</section></body></html>`;
3249
+ </style></head><body><h1>AIT 디버그 세션 — QR 스캔</h1>__MODE_LABEL____LANG_SWITCHER__<p class="label">deployment: __SAFE_LABEL__</p><div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>스캔 절차</h2><ol><li>토스 앱을 실행하세요.</li><li>폰 카메라 앱으로 QR 코드를 스캔하세요.</li><li>팝업이 뜨면 <strong>"토스로 열기"</strong>를 탭하세요.</li><li>미니앱이 열리고 디버그 세션이 자동으로 attach됩니다.</li></ol></section><hr/><section><h2>진단 체크리스트</h2><ul><li><strong>토스 앱이 안 열리는 경우</strong> — 앱 버전 확인, 카메라 앱으로 스캔 (토스 앱 내 QR 리더 X)</li><li><strong>미니앱이 PREPARE 상태에서 멈추는 경우</strong> — deep-link에 <code>_deploymentId</code> 파라미터가 있는지 확인</li><li><strong>Chii 주입 실패 / 콘솔이 비어 있는 경우</strong> — 미니앱 번들에 <code>in-app</code> debug import가 있는지 확인</li><li><strong>TOTP gate Layer C가 비활성인 경우</strong> — relay 서버에 <code>AIT_DEBUG_TOTP_SECRET</code>이 설정돼 있는지 확인</li></ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="복사">복사</button></div></section><hr/><section id="inspector-section"><h2>인스펙터</h2>__INSPECTOR_SECTION__</section></body></html>`;
3210
3250
  const dashboardChromeHtmlEn = `<!DOCTYPE html>
3211
3251
  <html lang="en"><head><meta charSet="utf-8"/><meta name="viewport" content="width=device-width, initial-scale=1"/><title>AIT Debug Dashboard</title><style>
3212
3252
  *, *::before, *::after { box-sizing: border-box; }
@@ -3387,7 +3427,7 @@ hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0.5rem 0;
3387
3427
  }
3388
3428
  .inspector-link:hover { background: #388bfd; }
3389
3429
  .inspector-hint { display: inline-block; margin-top: 0.5rem; font-size: 0.8rem; opacity: 0.45; }
3390
- </style></head><body><h1>AIT Debug Session — QR Scan</h1>__MODE_LABEL____LANG_SWITCHER__<p class="label">deployment: __SAFE_LABEL__</p><div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>How to scan</h2><ol><li>Open the Toss app.</li><li>Scan the QR code with your phone camera app.</li><li>Tap <strong>"Open in Toss"</strong> when the popup appears.</li><li>The mini-app opens and the debug session attaches automatically.</li></ol></section><hr/><section><h2>Troubleshooting checklist</h2><ul><li><strong>Toss app does not open</strong> — check app version; scan with the system camera app (not the Toss in-app QR reader)</li><li><strong>Mini-app stuck in PREPARE state</strong> — verify the deep-link has a <code>_deploymentId</code> parameter</li><li><strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import</li><li><strong>TOTP gate Layer C is inactive</strong> — check that <code>AIT_DEBUG_TOTP_SECRET</code> is set on the relay server</li>__LIVE_FAQ__</ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="Copy">Copy</button></div></section><hr/><section id="inspector-section"><h2>Inspector</h2>__INSPECTOR_SECTION__</section></body></html>`;
3430
+ </style></head><body><h1>AIT Debug Session — QR Scan</h1>__MODE_LABEL____LANG_SWITCHER__<p class="label">deployment: __SAFE_LABEL__</p><div id="attach-section"><img class="qr" src="__QR_DATA_URL__" alt="attach QR"/></div><section><h2>How to scan</h2><ol><li>Open the Toss app.</li><li>Scan the QR code with your phone camera app.</li><li>Tap <strong>"Open in Toss"</strong> when the popup appears.</li><li>The mini-app opens and the debug session attaches automatically.</li></ol></section><hr/><section><h2>Troubleshooting checklist</h2><ul><li><strong>Toss app does not open</strong> — check app version; scan with the system camera app (not the Toss in-app QR reader)</li><li><strong>Mini-app stuck in PREPARE state</strong> — verify the deep-link has a <code>_deploymentId</code> parameter</li><li><strong>Chii injection failure / console is empty</strong> — verify the mini-app bundle has an <code>in-app</code> debug import</li><li><strong>TOTP gate Layer C is inactive</strong> — check that <code>AIT_DEBUG_TOTP_SECRET</code> is set on the relay server</li></ul></section><hr/><section id="url-section"><h2>URL (fallback)</h2><div class="url-row"><p class="url-box" id="url-box">__SAFE_ATTACH_URL__</p><button class="copy-btn" id="copy-btn" type="button" aria-label="Copy">Copy</button></div></section><hr/><section id="inspector-section"><h2>Inspector</h2>__INSPECTOR_SECTION__</section></body></html>`;
3391
3431
  /** Map from Locale to the precompiled dashboard chrome string. */
3392
3432
  const dashboardChromeByLocale = {
3393
3433
  ko: dashboardChromeHtmlKo,
@@ -3424,9 +3464,6 @@ function buildModeLabel(mode, s) {
3424
3464
  case "relay-dev":
3425
3465
  label = s("attach.mode.intossDev");
3426
3466
  break;
3427
- case "relay-live":
3428
- label = s("attach.mode.intossLive");
3429
- break;
3430
3467
  case "mock":
3431
3468
  case void 0: return "";
3432
3469
  }
@@ -3700,12 +3737,11 @@ function buildSseScript(strings) {
3700
3737
  * - __SAFE_LABEL__ : HTML-escaped deploymentId label (intoss family에만 존재)
3701
3738
  * - __SAFE_ATTACH_URL__ : HTML-escaped attach URL (TOTP at= 코드 포함 — 의도된 전달)
3702
3739
  * - __MODE_LABEL__ : 환경 배지 (`<p class="mode-label">…</p>` 또는 빈 문자열, #468)
3703
- * - __LIVE_FAQ__ : 환경 4 LIVE read-only `<li>` 또는 빈 문자열 (intoss family에만 존재)
3704
3740
  * - __INSPECTOR_SECTION__ : "디버그 툴 열기" 버튼 또는 대기 힌트 (#544)
3705
3741
  *
3706
3742
  * mode-aware 분기 (#468): mode가 `relay-mobile`이면 sandbox family chrome(launcher
3707
- * PWA 절차), 그 외는 intoss family chrome(토스 앱 절차)을 선택한다. `relay-live`는
3708
- * intoss chrome에 LIVE read-only 라인을 추가한다.
3743
+ * PWA 절차), 그 외는 intoss family chrome(토스 앱 절차)을 선택한다.
3744
+ * relay-live (env 4) 제거 (#665) — positive-allowlist kill-switch.
3709
3745
  *
3710
3746
  * SSE 스크립트도 주입 — `#attach-section` hook이 있으면 `/events` push 때 QR이
3711
3747
  * `/qr.png?u=<fresh attachUrl>`로 자동 갱신된다. `#inspector-link`도 SSE push로
@@ -3718,11 +3754,10 @@ function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl, locale, path = "/a
3718
3754
  const s = resolveLocaleStrings(locale);
3719
3755
  const langSwitcher = buildLangSwitcher(path, params, locale, s);
3720
3756
  const family = attachFamilyForMode(mode);
3721
- const liveFaq = mode === "relay-live" ? `<li>${s("attach.intoss.faq.liveReadOnly")}</li>` : "";
3722
3757
  let inspectorSection;
3723
3758
  if (pagesAttached && inspectorStableUrl) inspectorSection = `<a class="inspector-link" id="inspector-link" href="${escapeHtml(inspectorStableUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(s("dashboard.inspector.open"))}</a>`;
3724
3759
  else inspectorSection = `<span class="inspector-hint" id="inspector-link">${escapeHtml(s("dashboard.inspector.waiting"))}</span>`;
3725
- const filled = attachChromeByLocale[locale][family].replaceAll("__LANG_SWITCHER__", langSwitcher).replaceAll("__MODE_LABEL__", buildModeLabel(mode, s)).replaceAll("__LIVE_FAQ__", liveFaq).replaceAll("__QR_DATA_URL__", qrDataUrl).replaceAll("__SAFE_LABEL__", safeLabel).replaceAll("__SAFE_ATTACH_URL__", safeAttachUrl).replaceAll("__INSPECTOR_SECTION__", inspectorSection);
3760
+ const filled = attachChromeByLocale[locale][family].replaceAll("__LANG_SWITCHER__", langSwitcher).replaceAll("__MODE_LABEL__", buildModeLabel(mode, s)).replaceAll("__QR_DATA_URL__", qrDataUrl).replaceAll("__SAFE_LABEL__", safeLabel).replaceAll("__SAFE_ATTACH_URL__", safeAttachUrl).replaceAll("__INSPECTOR_SECTION__", inspectorSection);
3726
3761
  const sseScript = buildSseScript({
3727
3762
  tunnelUp: JSON.stringify(s("dashboard.tunnel.up")),
3728
3763
  tunnelDown: JSON.stringify(s("dashboard.tunnel.down")),
@@ -4349,22 +4384,27 @@ const DEBUG_TOOL_DEFINITIONS = [
4349
4384
  availableIn: "both"
4350
4385
  },
4351
4386
  {
4352
- name: "build_attach_url",
4353
- 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. Builds a self-attaching deep-link for the active relay environment and returns a QR code. 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). Call list_pages first to confirm the relay/tunnel is up. If the tunnel is not up, restart: `npx @ait-co/devtools devtools-mcp`.\n\nEnvironment-specific behaviour:\n • env 3 / relay-staging (start_debug mode=\"relay-staging\"): requires scheme_url — the intoss-private://…?_deploymentId=<uuid> URL from `ait deploy --scheme-only`. Splices debug=1 + relay URL into the scheme URL to produce a self-attach deep-link.\n • env 2 / relay-sandbox (start_debug mode=\"relay-sandbox\"): scheme_url is NOT used. Instead, reads AIT_TUNNEL_BASE_URL (the https://*.trycloudflare.com app tunnel from `tunnel:{cdp:true}`) and builds a launcher PWA deep-link (https://devtools.aitc.dev/launcher/?url=…&debug=1&relay=…). When projectRoot is given, the app name from <projectRoot>/package.json is automatically added as name= so the launcher partner bar shows it. Scan the QR with the phone to open the launcher, which frames the tunnel URL and attaches CDP.\n\nSet wait_for_attach=true to block until a page attaches (default 60 s, adjustable via wait_timeout_seconds). On timeout, call build_attach_url again to resume polling. The server automatically opens the QR dashboard in the OS default browser when running on a local GUI machine — headless/remote environments fall back to the text QR in the tool output.\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 code is valid for ~3 minutes (the relay gate accepts ±6 TOTP steps = 180–210 s of backwards acceptance). The response includes a `totp` field with `expiresAt` (ISO timestamp, ~3 min from issuance). If the phone scan happens after expiresAt, the relay will reject the code — just call build_attach_url again to get a fresh URL. Without AIT_DEBUG_TOTP_SECRET, the attachUrl has no expiry.\n\nselfdebug (env 2 / relay-sandbox only): pass selfdebug=true to add &selfdebug=1 to the launcher deep-link. The launcher PWA then registers its own document as the CDP target instead of the framed mini-app. SINGLE-ATTACH MODEL: attaching the launcher self-target evicts any currently-attached mini-app target — use this mode exclusively for diagnosing the launcher document itself (DOM, safe-area, console). Not applicable in env 3/4 (relay-staging/relay-live) — passing selfdebug=true there returns an error.",
4387
+ name: "start_attach",
4388
+ 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. Single entry point to attach a real device: switches the debug mode (if `mode` is given), builds the self-attaching deep-link QR for the active relay environment, and waits for the phone to attach — all in one call (replaces the old attach-URL + start_debug two-step). 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).\n\nmode (optional): pass \"relay-sandbox\" (env 2) or \"relay-staging\" (env 3) to switch the active environment first. When omitted, the current relay environment is used as-is (no switch). Passing \"local-browser\" returns an error — start_attach is relay-only (env 2/3). When the session is already in the requested mode, the switch is skipped.\n\nEnvironment-specific behaviour:\n • env 3 / relay-staging: requires scheme_url — the intoss-private://…?_deploymentId=<uuid> URL from `ait deploy --scheme-only`. Splices debug=1 + relay URL into the scheme URL.\n • env 2 / relay-sandbox: scheme_url is NOT used. Instead, reads AIT_TUNNEL_BASE_URL (the https://*.trycloudflare.com app tunnel from `tunnel:{cdp:true}`) and builds a launcher PWA deep-link (https://devtools.aitc.dev/launcher/?url=…&debug=1&relay=…). When projectRoot is given, the app name from <projectRoot>/package.json is added as name= so the launcher partner bar shows it.\n\nWaits for a page to attach by default (up to wait_timeout_seconds, default 60 s). The server automatically opens the QR dashboard in the OS default browser when running on a local GUI machine — headless/remote environments fall back to the text QR in the tool output.\n\nTOTP auto re-mint: when AIT_DEBUG_TOTP_SECRET is set on the MCP server, the attachUrl carries a one-time code (at=<code>) valid for ~3 minutes (the relay gate accepts ±6 TOTP steps). While waiting, start_attach AUTOMATICALLY re-mints a fresh code before the current one expires and refreshes the dashboard QR in place (no browser re-open). You do NOT need to re-call start_attach every time the code would expire a single call covers the whole wait window. The response includes a `totp` field with `expiresAt` and a `reminted` count of how many fresh codes were issued during the wait. Without AIT_DEBUG_TOTP_SECRET, the attachUrl has no expiry.\n\nselfdebug (env 2 / relay-sandbox only): pass selfdebug=true to add &selfdebug=1 to the launcher deep-link. The launcher PWA then registers its own document as the CDP target instead of the framed mini-app. SINGLE-ATTACH MODEL: attaching the launcher self-target evicts any currently-attached mini-app target — use this mode exclusively for diagnosing the launcher document itself (DOM, safe-area, console). Not applicable in env 3 (relay-staging) — passing selfdebug=true there returns an error.",
4354
4389
  inputSchema: {
4355
4390
  type: "object",
4356
4391
  properties: {
4392
+ mode: {
4393
+ type: "string",
4394
+ enum: [
4395
+ "local-browser",
4396
+ "relay-sandbox",
4397
+ "relay-staging"
4398
+ ],
4399
+ description: "Optional debug mode to switch into before attaching. \"relay-sandbox\" = env 2 (launcher PWA), \"relay-staging\" = env 3 (intoss-private dog-food). \"local-browser\" returns an error (start_attach is relay-only). Omit to keep the current relay environment."
4400
+ },
4357
4401
  scheme_url: {
4358
4402
  type: "string",
4359
4403
  description: "The intoss-private:// scheme URL from `ait deploy --scheme-only` (must carry _deploymentId). Required for env 3/relay-staging mode. Not used in env 2/relay-sandbox mode (use AIT_TUNNEL_BASE_URL instead). The authority (host) must be the app name (e.g. intoss-private://aitc-sdk-example?_deploymentId=…). Generic values like \"web\" or an empty host indicate a malformed URL."
4360
4404
  },
4361
- wait_for_attach: {
4362
- type: "boolean",
4363
- description: "If true, block after returning the QR until a page attaches to the relay (polls listTargets ~1 s interval, default 60 s). On attach, the response includes the attached page list. On timeout, call build_attach_url again to resume polling."
4364
- },
4365
4405
  wait_timeout_seconds: {
4366
4406
  type: "number",
4367
- description: "Maximum seconds to wait when wait_for_attach=true (default 60, range 1–600). Values outside the range or invalid inputs (0, negative, NaN) fall back to the default silently. Only meaningful when wait_for_attach=true."
4407
+ description: "Maximum seconds to wait for a page to attach (default 60, range 1–600). Values outside the range or invalid inputs (0, negative, NaN) fall back to the default silently. During the wait the TOTP code is auto re-minted as needed, so a single call covers the whole window."
4368
4408
  },
4369
4409
  projectRoot: {
4370
4410
  type: "string",
@@ -4372,7 +4412,7 @@ const DEBUG_TOOL_DEFINITIONS = [
4372
4412
  },
4373
4413
  selfdebug: {
4374
4414
  type: "boolean",
4375
- description: "Env 2 / relay-sandbox only. When true, adds &selfdebug=1 to the launcher deep-link so the launcher PWA registers its own document as the CDP target (launcher diagnostics mode). SINGLE-ATTACH MODEL: self-target attach evicts any currently-attached mini-app target. Use only when you need to inspect the launcher itself (DOM, safe-area, console). Passing selfdebug=true in env 3/4 (relay-staging/relay-live) returns an error. Default: false (omitted — output is byte-identical to previous behaviour)."
4415
+ description: "Env 2 / relay-sandbox only. When true, adds &selfdebug=1 to the launcher deep-link so the launcher PWA registers its own document as the CDP target (launcher diagnostics mode). SINGLE-ATTACH MODEL: self-target attach evicts any currently-attached mini-app target. Use only when you need to inspect the launcher itself (DOM, safe-area, console). Passing selfdebug=true in env 3 (relay-staging) returns an error. Default: false (omitted)."
4376
4416
  }
4377
4417
  },
4378
4418
  required: []
@@ -4411,7 +4451,7 @@ const DEBUG_TOOL_DEFINITIONS = [
4411
4451
  },
4412
4452
  {
4413
4453
  name: "measure_safe_area",
4414
- 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\" | \"relay-mobile\"` field so consumers can identify provenance without inspecting payload values. (`relay-mobile` = env 2 real-device PWA over an external relay; `relay-dev` = env 3 dog-food WebView; `relay-live` = env 4 production WebView.) 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.",
4454
+ 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-mobile\"` field so consumers can identify provenance without inspecting payload values. (`relay-mobile` = env 2 real-device PWA over an external relay; `relay-dev` = env 3 dog-food WebView; relay-live/env 4 removed #665.) 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.",
4415
4455
  inputSchema: {
4416
4456
  type: "object",
4417
4457
  properties: {},
@@ -4421,19 +4461,13 @@ const DEBUG_TOOL_DEFINITIONS = [
4421
4461
  },
4422
4462
  {
4423
4463
  name: "evaluate",
4424
- description: "Evaluates an arbitrary JavaScript expression on the attached mini-app page via CDP Runtime.evaluate (returnByValue: true) and returns the result. NOT read-only — the expression can have side effects (DOM mutations, SDK calls, state changes). Requires the relay to be attached — call list_pages first. Throws if the evaluation throws an exception on the page.\n\nSECURITY: expression and result are not redacted — never include secrets or auth tokens in the expression.\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 expression may affect real users. Without it the call is rejected with a structured error. mock and relay-dev sessions are unaffected.",
4464
+ description: "Evaluates an arbitrary JavaScript expression on the attached mini-app page via CDP Runtime.evaluate (returnByValue: true) and returns the result. NOT read-only — the expression can have side effects (DOM mutations, SDK calls, state changes). Requires the relay to be attached — call list_pages first. Throws if the evaluation throws an exception on the page.\n\nSECURITY: expression and result are not redacted — never include secrets or auth tokens in the expression.\n\nPositive-allowlist kill-switch (#665): this tool is blocked when the attached page is on a non-debug host (apps.tossmini.com / env 4). Only localhost, *.trycloudflare.com, and *.private-apps.tossmini.com are allowed. relay-live (env 4) and the LIVE confirm guard are removed.",
4425
4465
  inputSchema: {
4426
4466
  type: "object",
4427
- properties: {
4428
- expression: {
4429
- type: "string",
4430
- description: "JavaScript expression to evaluate in the page context."
4431
- },
4432
- confirm: {
4433
- type: "boolean",
4434
- description: "Required when MCP_ENV=relay-live. Set to `true` to explicitly acknowledge that this expression may have side effects on real/live users. Omitting this in a relay-live session results in a structured rejection error. Has no effect in mock or relay-dev sessions."
4435
- }
4436
- },
4467
+ properties: { expression: {
4468
+ type: "string",
4469
+ description: "JavaScript expression to evaluate in the page context."
4470
+ } },
4437
4471
  required: ["expression"]
4438
4472
  },
4439
4473
  availableIn: "both"
@@ -4453,7 +4487,7 @@ const DEBUG_TOOL_DEFINITIONS = [
4453
4487
  },
4454
4488
  {
4455
4489
  name: "call_sdk",
4456
- description: "Calls a dog-food 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) and env 2 (PWA relay — real WebKit, mock SDK) 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 — on relay (env 3/4) that means a non-dog-food bundle (redeploy via `ait build && aitcc app deploy`); on local (--target=local, env 1) it means the dev bridge is not installed (start the dev server with `pnpm dev`).\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\", [])",
4490
+ description: "Calls a dog-food 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) and env 2 (PWA relay — real WebKit, mock SDK) 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 — on relay (env 3/4) that means a non-dog-food bundle (redeploy via `ait build && aitcc app deploy`); on local (--target=local, env 1) it means the dev bridge is not installed (start the dev server with `pnpm dev`).\n\nSECURITY: method name, args, and result value are not redacted — never include secrets.\n\nPositive-allowlist kill-switch (#665): blocked when the attached page is on a non-debug host (apps.tossmini.com / env 4). relay-live and the LIVE guard removed.\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\", [])",
4457
4491
  inputSchema: {
4458
4492
  type: "object",
4459
4493
  properties: {
@@ -4465,10 +4499,6 @@ const DEBUG_TOOL_DEFINITIONS = [
4465
4499
  type: "array",
4466
4500
  description: "Arguments to pass to the SDK method (optional, default []).",
4467
4501
  items: {}
4468
- },
4469
- confirm: {
4470
- type: "boolean",
4471
- description: "Required when MCP_ENV=relay-live. Set to `true` to explicitly acknowledge that this SDK call may have side effects on real/live users. Omitting this in a relay-live session results in a structured rejection error. Has no effect in mock or relay-dev sessions."
4472
4502
  }
4473
4503
  },
4474
4504
  required: ["name"]
@@ -4507,7 +4537,7 @@ const DEBUG_TOOL_DEFINITIONS = [
4507
4537
  },
4508
4538
  {
4509
4539
  name: "start_debug",
4510
- description: "Switches the active debug environment in-place (issue #348) — no Claude Code restart and no MCP re-handshake. One daemon holds both a local (env 1, mock SDK in a Chromium) and a relay (env 3/4, real-device Toss WebView over the Chii relay + cloudflared tunnel) connection at once; this tool flips which one every other tool reads from, lazily booting the requested family's infra on first use and keeping the inactive one warm so an existing attach survives the switch. After switching it emits notifications/tools/list_changed — call tools/list again to see the updated tool surface for the new environment.\n\nmodes:\n local-browser — env 1: desktop Chromium with the mock SDK and a local CDP attach. Side-effect tools (call_sdk/evaluate) run unguarded against the mock; nothing touches a real device or real users. No prerequisites — the default, always-available environment for state/contract and visual-layout work.\n relay-sandbox — env 2: a real-device PWA (real WebKit engine, mock SDK) over an external Chii relay. CDP covers real-device WebKit DOM, console, exceptions, and safe-area observation; call_sdk still hits the mock (SDK fidelity needs relay-staging). liveIntent off — dev-intent, LIVE guard inactive, side-effect tools run unguarded against the mock. Only the dual-connection daemon can enter relay-sandbox in-place; a single-connection session rejects it with \"동적 전환할 수 없습니다 … relay-sandbox 모드로 재시작하세요\" — follow that hint and restart the MCP server in relay-sandbox mode rather than retrying. Prerequisites: both AIT_RELAY_BASE_URL (the relay base the unplugin emits when started with tunnel:{cdp:true}, used for the CDP attach) and AIT_TUNNEL_BASE_URL (the dev-server tunnel host, required by build_attach_url to render the launcher QR) must be set before the MCP server starts — the unplugin does not auto-forward either; set them explicitly. Both carry relay/tunnel hosts (secret-class) — keep them out of logs.\n relay-staging — env 3: a real-device Toss WebView dog-food build with the REAL SDK over the intoss-private relay. The first environment where call_sdk exercises the genuine native bridge. Side-effect tools run unguarded (dog-food, not released to real users). Prerequisite: a dog-food candidate bundle built with `RELEASE_CHANNEL=dogfood ait build`, then uploaded with `ait deploy` (add `--scheme-only` to print the resulting intoss-private://…?_deploymentId=… deep-link); open that deep-link/QR on the device to cold-load the bundle with the relay injected. Unlike env 2, env 3 is NOT a dev-server tunnel — it is a deployed bundle reached via the intoss-private scheme, so `pnpm dev` plays no part here.\n relay-live — env 4: the REVIEW-PASSED, released production runtime with the REAL SDK over the intoss relay — real end users are on the other side. Read-only debugging is the intent: the LIVE guard is armed, so call_sdk/evaluate require confirm:true per call, and ENTERING relay-live ALSO requires confirm:true on this call. Use it only to observe a shipped regression; verify fixes in relay-staging first.\n\nSwitching back to local-browser automatically disarms the LIVE guard.\n\nFor a relay mode (relay-sandbox/relay-staging/relay-live), also pass projectRoot — the absolute mini-app project root — so the daemon can read the relay auth secret from <projectRoot>/.ait_relay (read-only; the daemon never mints it). Omit it for local-browser.",
4540
+ description: "Switches the active debug environment in-place (issue #348) — no Claude Code restart and no MCP re-handshake. One daemon holds both a local (env 1, mock SDK in a Chromium) and a relay (env 2/3, real-device over the Chii relay + cloudflared tunnel) connection at once; this tool flips which one every other tool reads from, lazily booting the requested family's infra on first use and keeping the inactive one warm so an existing attach survives the switch. After switching it emits notifications/tools/list_changed — call tools/list again to see the updated tool surface for the new environment.\n\nPositive-allowlist kill-switch (#665): relay sessions on apps.tossmini.com (env 4, released production) are silently blocked at both the in-app gate and this MCP layer — relay-live and the LIVE guard have been removed. Only localhost/loopback (env 1), *.trycloudflare.com (env 2), and *.private-apps.tossmini.com (env 3) are allowed.\n\nmodes:\n local-browser — env 1: desktop Chromium with the mock SDK and a local CDP attach. Side-effect tools (call_sdk/evaluate) run unguarded against the mock; nothing touches a real device or real users. No prerequisites — the default, always-available environment for state/contract and visual-layout work.\n relay-sandbox — env 2: a real-device PWA (real WebKit engine, mock SDK) over an external Chii relay. CDP covers real-device WebKit DOM, console, exceptions, and safe-area observation; call_sdk still hits the mock (SDK fidelity needs relay-staging). Side-effect tools run unguarded against the mock. Only the dual-connection daemon can enter relay-sandbox in-place; a single-connection session rejects it with \"동적 전환할 수 없습니다 … relay-sandbox 모드로 재시작하세요\" — follow that hint and restart the MCP server in relay-sandbox mode rather than retrying. Prerequisites: both AIT_RELAY_BASE_URL (the relay base the unplugin emits when started with tunnel:{cdp:true}, used for the CDP attach) and AIT_TUNNEL_BASE_URL (the dev-server tunnel host, required by start_attach to render the launcher QR) must be set before the MCP server starts — the unplugin does not auto-forward either; set them explicitly. Both carry relay/tunnel hosts (secret-class) — keep them out of logs.\n relay-staging — env 3: a real-device Toss WebView dog-food build with the REAL SDK over the intoss-private relay. The first environment where call_sdk exercises the genuine native bridge. Side-effect tools run unguarded (dog-food, not released to real users). Prerequisite: a dog-food candidate bundle built with `RELEASE_CHANNEL=dogfood ait build`, then uploaded with `ait deploy` (add `--scheme-only` to print the resulting intoss-private://…?_deploymentId=… deep-link); open that deep-link/QR on the device to cold-load the bundle with the relay injected. Unlike env 2, env 3 is NOT a dev-server tunnel — it is a deployed bundle reached via the intoss-private scheme, so `pnpm dev` plays no part here.\n\nFor a relay mode (relay-sandbox/relay-staging), also pass projectRoot — the absolute mini-app project root — so the daemon can read the relay auth secret from <projectRoot>/.ait_relay (read-only; the daemon never mints it). Omit it for local-browser.",
4511
4541
  inputSchema: {
4512
4542
  type: "object",
4513
4543
  properties: {
@@ -4516,18 +4546,13 @@ const DEBUG_TOOL_DEFINITIONS = [
4516
4546
  enum: [
4517
4547
  "local-browser",
4518
4548
  "relay-sandbox",
4519
- "relay-staging",
4520
- "relay-live"
4549
+ "relay-staging"
4521
4550
  ],
4522
- description: "Target environment to switch to. mode=relay-live additionally requires confirm: true (and arms the read-only LIVE guard)."
4523
- },
4524
- confirm: {
4525
- type: "boolean",
4526
- description: "Required when mode=relay-live — set true to acknowledge entering LIVE (env 4) debugging that can affect real users. Ignored for the other modes."
4551
+ description: "Target environment to switch to. relay-live (env 4) has been removed (#665) use relay-staging (env 3) for dog-food debugging."
4527
4552
  },
4528
4553
  projectRoot: {
4529
4554
  type: "string",
4530
- description: "Absolute path to the mini-app project root (the directory containing its package.json and .ait_relay). The daemon reads the relay auth secret from <projectRoot>/.ait_relay (read-only) when switching to a relay environment (relay-staging/relay-live/relay-sandbox). Pass this because the daemon's own cwd is fixed at launch and may not be the project being debugged. Omit for mode=local-browser (no secret needed)."
4555
+ description: "Absolute path to the mini-app project root (the directory containing its package.json and .ait_relay). The daemon reads the relay auth secret from <projectRoot>/.ait_relay (read-only) when switching to a relay environment (relay-staging/relay-sandbox). Pass this because the daemon's own cwd is fixed at launch and may not be the project being debugged. Omit for mode=local-browser (no secret needed)."
4531
4556
  }
4532
4557
  },
4533
4558
  required: ["mode"]
@@ -4536,7 +4561,7 @@ const DEBUG_TOOL_DEFINITIONS = [
4536
4561
  },
4537
4562
  {
4538
4563
  name: "get_debug_status",
4539
- description: "Reports the current debug session state — which environment/mode is active, whether a page is attached, and a full diagnostic snapshot — in one call. Use this any time to answer \"what mode am I in right now?\" or \"why is this not working?\" without chaining 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), authRejects ({count, lastAt} — relay TOTP 401 rejections, secret-free; count > 0 with empty pages means the phone reached the relay but its code was rejected), environment (kind: mock|relay-dev|relay-live|relay-mobile, env: mock|relay backward-compat, reason, liveGuardActive: true when relay-live LIVE guard is active; start_debug mode→kind mapping: relay-sandbox→relay-mobile, relay-staging→relay-dev, relay-live→relay-live, local-browser→mock), 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).",
4564
+ description: "Reports the current debug session state — which environment/mode is active, whether a page is attached, and a full diagnostic snapshot — in one call. Use this any time to answer \"what mode am I in right now?\" or \"why is this not working?\" without chaining 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), authRejects ({count, lastAt} — relay TOTP 401 rejections, secret-free; count > 0 with empty pages means the phone reached the relay but its code was rejected), environment (kind: mock|relay-dev|relay-mobile, env: mock|relay backward-compat, reason, liveGuardActive: always false relay-live and LIVE guard removed (#665); start_debug mode→kind mapping: relay-sandbox→relay-mobile, relay-staging→relay-dev, local-browser→mock), 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).",
4540
4565
  inputSchema: {
4541
4566
  type: "object",
4542
4567
  properties: { recent_errors_limit: {
@@ -4549,7 +4574,7 @@ const DEBUG_TOOL_DEFINITIONS = [
4549
4574
  },
4550
4575
  {
4551
4576
  name: "run_tests",
4552
- description: "Runs mini-app test files on the attached page over CDP (Runtime.evaluate). Each matched file is bundled with esbuild (SDK imports redirected to the live mock/SDK), injected into the attached WebView, and executed; returns per-file results plus flattened totals (passed/failed/skipped/total). Requires an attached page — call list_pages first to confirm one is attached. Files run SEQUENTIALLY (single-attach model: the relay/local target serves one page), and one run_tests call runs at a time (a concurrent call is rejected). Test verification (assert/snapshot) is delegated to the in-page Vitest runtime; this tool is the transport + report. The per-file results array is the progress record — on partial failure you see exactly which files passed/failed/timed-out. In a relay-live session this is a state-mutating injection and is blocked unless confirm=true (confirm is ignored in every non-live session: mock/local, relay-dev, relay-mobile). debug-mode only — dev-mode (--mode=dev) has no CDP. Tier C (both mock/local and relay). The devtools-test CLI shares this run core and file discovery, but its standalone relay attach is not wired yet — run via this tool for now.",
4577
+ description: "Runs mini-app test files on the attached page over CDP (Runtime.evaluate). Each matched file is bundled with esbuild (SDK imports redirected to the live mock/SDK), injected into the attached WebView, and executed; returns per-file results plus flattened totals (passed/failed/skipped/total). Requires an attached page — call list_pages first to confirm one is attached. Files run SEQUENTIALLY (single-attach model: the relay/local target serves one page), and one run_tests call runs at a time (a concurrent call is rejected). Test verification (assert/snapshot) is delegated to the in-page Vitest runtime; this tool is the transport + report. The per-file results array is the progress record — on partial failure you see exactly which files passed/failed/timed-out. Positive-allowlist kill-switch (#665): blocked when the attached page is on a non-debug host. debug-mode only — dev-mode (--mode=dev) has no CDP. Tier C (both mock/local and relay). The devtools-test CLI shares this run core and file discovery, but its standalone relay attach is not wired yet — run via this tool for now.",
4553
4578
  inputSchema: {
4554
4579
  type: "object",
4555
4580
  properties: {
@@ -4565,10 +4590,6 @@ const DEBUG_TOOL_DEFINITIONS = [
4565
4590
  timeout_ms: {
4566
4591
  type: "number",
4567
4592
  description: "Per-file evaluate timeout in ms (default 30000, range 1000–600000). Out-of-range/invalid values fall back to the default."
4568
- },
4569
- confirm: {
4570
- type: "boolean",
4571
- description: "Required (true) to run in a relay-live session — test injection mutates page state. Ignored in every non-live session (mock/local, relay-dev, relay-mobile)."
4572
4593
  }
4573
4594
  },
4574
4595
  required: ["files"]
@@ -4594,8 +4615,9 @@ function getToolAvailability(name) {
4594
4615
  * Unknown tools return `false` — callers should reject them as unknown rather
4595
4616
  * than as env-mismatched.
4596
4617
  *
4597
- * Relay variants (`relay-dev`, `relay-live`, `relay-mobile`) all satisfy the
4618
+ * Relay variants (`relay-dev`, `relay-mobile`) all satisfy the
4598
4619
  * `'relay'` availability tier — `isRelayEnv()` is used for the check.
4620
+ * (`relay-live` removed #665.)
4599
4621
  */
4600
4622
  function isToolAvailableIn(name, env) {
4601
4623
  const availability = getToolAvailability(name);
@@ -4609,8 +4631,8 @@ function isToolAvailableIn(name, env) {
4609
4631
  * matches the given env. Pure — preserves order; both Tier C ("both") and the
4610
4632
  * matching single-env tier pass through.
4611
4633
  *
4612
- * Relay variants (`relay-dev`, `relay-live`, `relay-mobile`) all satisfy the
4613
- * `'relay'` tier.
4634
+ * Relay variants (`relay-dev`, `relay-mobile`) all satisfy the
4635
+ * `'relay'` tier. (`relay-live` removed #665.)
4614
4636
  */
4615
4637
  function filterToolsByEnvironment(tools, env) {
4616
4638
  return tools.filter((t) => t.availableIn === "both" || t.availableIn === "relay" && isRelayEnv(env) || t.availableIn === env);
@@ -4618,14 +4640,14 @@ function filterToolsByEnvironment(tools, env) {
4618
4640
  /**
4619
4641
  * Tool names that are available before any page attaches (bootstrap tier).
4620
4642
  *
4621
- * `build_attach_url` — pure URL synthesis, no attach needed.
4622
- * `list_pages` — reports tunnel status + empty pages even pre-attach.
4643
+ * `start_attach` — mode switch + QR synthesis + attach wait, no prior attach needed.
4644
+ * `list_pages` — reports tunnel status + empty pages even pre-attach.
4623
4645
  *
4624
4646
  * All other tools require an attached page (`enableDomains` must succeed) and
4625
4647
  * are only advertised in `tools/list` once a target appears.
4626
4648
  */
4627
4649
  const BOOTSTRAP_TOOL_NAMES = new Set([
4628
- "build_attach_url",
4650
+ "start_attach",
4629
4651
  "get_debug_status",
4630
4652
  "list_pages",
4631
4653
  "start_debug"
@@ -4729,57 +4751,6 @@ function listPages(connection, tunnel) {
4729
4751
  };
4730
4752
  }
4731
4753
  /**
4732
- * Builds a self-attaching dog-food deep-link from an `ait deploy --scheme-only`
4733
- * URL plus this session's live relay. Throws if the tunnel is not up yet (no
4734
- * relay URL to splice in) — the caller surfaces that as a tool error.
4735
- *
4736
- * When `AIT_DEBUG_TOTP_SECRET` is set, generates the current TOTP code and
4737
- * splices it as `at=<code>` into the attach URL. The code is valid for ~3
4738
- * minutes (the relay gate uses {@link RELAY_VERIFY_SKEW_STEPS}=6, accepting
4739
- * past 6 steps = 180–210 s backwards from issuance). If the scan happens after
4740
- * `totp.expiresAt`, call `build_attach_url` again to get a fresh code (#490).
4741
- *
4742
- * Also validates the scheme URL's authority. A suspicious authority (empty,
4743
- * "web", "localhost", etc.) is surfaced as a non-fatal `authorityWarning` on
4744
- * the result so the caller can show a helpful hint without blocking the link
4745
- * generation (the warning is consistent with how other validation in
4746
- * `buildDeepLinkAttachUrl` works — hard errors for relay, soft warning for
4747
- * the scheme authority which is in the caller's input, not ours to own).
4748
- *
4749
- * SECRET-HANDLING: `totpSecret` (if provided) is used only to compute a code
4750
- * and must never appear in any log, error message, or output outside of the
4751
- * spliced `at=` param in `attachUrl`.
4752
- *
4753
- * @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL.
4754
- * @param tunnel - Current tunnel status from the running debug server.
4755
- * @param totpSecret - Optional hex-encoded TOTP secret (from
4756
- * `AIT_DEBUG_TOTP_SECRET`). When provided, the current code is spliced into
4757
- * the attach URL as `at=<code>`.
4758
- */
4759
- function buildAttachUrl(schemeUrl, tunnel, totpSecret) {
4760
- if (!tunnel.up || tunnel.wssUrl === null) throw new Error("tunnel-down: cloudflared 터널이 안 떠 있습니다. MCP 서버를 재시작하거나 잠시 후 list_pages로 터널 상태를 다시 확인하세요.");
4761
- const authorityWarning = validateSchemeAuthority(schemeUrl) ?? void 0;
4762
- let totpCode;
4763
- let totpMeta;
4764
- if (totpSecret !== void 0 && totpSecret !== "") {
4765
- const now = Date.now();
4766
- totpCode = generateTotp(totpSecret, now);
4767
- const STEP_SECONDS = 30;
4768
- const expiresAtMs = now + 6 * STEP_SECONDS * 1e3;
4769
- totpMeta = {
4770
- enabled: true,
4771
- ttlSeconds: 6 * STEP_SECONDS,
4772
- expiresAt: new Date(expiresAtMs).toISOString()
4773
- };
4774
- }
4775
- return {
4776
- attachUrl: buildDeepLinkAttachUrl(schemeUrl, tunnel.wssUrl, totpCode),
4777
- relayUrl: tunnel.wssUrl,
4778
- ...authorityWarning !== void 0 ? { authorityWarning } : {},
4779
- ...totpMeta !== void 0 ? { totp: totpMeta } : {}
4780
- };
4781
- }
4782
- /**
4783
4754
  * Heuristic: can this process open a GUI browser?
4784
4755
  *
4785
4756
  * Returns `true` when we think a GUI is available:
@@ -5396,7 +5367,7 @@ async function readMcpSdkVersion() {
5396
5367
  * some test environments that skip the build step).
5397
5368
  */
5398
5369
  function readDevtoolsVersion() {
5399
- return "0.1.109";
5370
+ return "0.1.110";
5400
5371
  }
5401
5372
  /**
5402
5373
  * Derives the next recommended action from a completed diagnostics snapshot.
@@ -5405,10 +5376,10 @@ function readDevtoolsVersion() {
5405
5376
  * 0. tunnel.droppedAt non-null → restart (permanent tunnel drop — highest priority)
5406
5377
  * 1. tunnel.up === false AND env is relay → restart (relay needs a live tunnel)
5407
5378
  * 1b. tunnel.up === false AND env is mock → wait_for_page (local target: tunnel-less is normal)
5408
- * 2a. authRejects.count > 0 AND pages empty → build_attach_url (relay TOTP 거부 관측 — QR 재스캔
5379
+ * 2a. authRejects.count > 0 AND pages empty → start_attach (relay TOTP 거부 관측 — QR 재스캔
5409
5380
  * 또는 target-side `at` 전달 확인. 일반 rule 2보다 구체적이므로 먼저 평가 — issue #467)
5410
- * 2. tunnel.up, pages empty, env === relay → build_attach_url (start attach)
5411
- * 3. pages has entry + crashDetectedAt non-null → build_attach_url (re-attach after crash)
5381
+ * 2. tunnel.up, pages empty, env === relay → start_attach (start attach)
5382
+ * 3. pages has entry + crashDetectedAt non-null → start_attach (re-attach after crash)
5412
5383
  * 4. otherwise → null (session looks healthy)
5413
5384
  *
5414
5385
  * Pure — does not throw; receives the final assembled snapshot fields.
@@ -5431,16 +5402,16 @@ function computeNextRecommendedAction(tunnel, pages, env, authRejects = null) {
5431
5402
  reason: "tunnel not up — run `npx @ait-co/devtools devtools-mcp` to restart"
5432
5403
  };
5433
5404
  if (authRejects !== null && authRejects.count > 0 && pages !== null && pages.pages.length === 0) return {
5434
- tool: "build_attach_url",
5405
+ tool: "start_attach",
5435
5406
  reason: `relay 인증(TOTP) 거부 ${authRejects.count}건 발생 (last ${authRejects.lastAt ?? "unknown"}) — QR을 다시 스캔해 새 코드로 attach하세요(코드는 ~3분마다 만료). 반복되면 폰 페이지 URL에 at 파라미터가 전달되는지(target-side TOTP 전달 경로)를 확인하세요`
5436
5407
  };
5437
5408
  if (isRelayEnv(env) && pages !== null && pages.pages.length === 0 && !pages.crashDetectedAt) return {
5438
- tool: "build_attach_url",
5439
- reason: "tunnel ready, no pages attached — call build_attach_url to generate the attach QR"
5409
+ tool: "start_attach",
5410
+ reason: "tunnel ready, no pages attached — call start_attach to generate the attach QR"
5440
5411
  };
5441
5412
  if (pages !== null && pages.crashDetectedAt !== null) return {
5442
- tool: "build_attach_url",
5443
- reason: `page crashed at ${pages.crashDetectedAt} — call build_attach_url to re-attach`
5413
+ tool: "start_attach",
5414
+ reason: `page crashed at ${pages.crashDetectedAt} — call start_attach to re-attach`
5444
5415
  };
5445
5416
  return null;
5446
5417
  }
@@ -5505,7 +5476,7 @@ async function getDiagnostics(input) {
5505
5476
  kind: env,
5506
5477
  env: toLegacyEnv(env),
5507
5478
  reason: envReason,
5508
- liveGuardActive: isLiveRelayEnv(env)
5479
+ liveGuardActive: false
5509
5480
  },
5510
5481
  serverLockHolder,
5511
5482
  process: {
@@ -5618,7 +5589,7 @@ async function startQuickTunnel(localPort) {
5618
5589
  * every surface (terminal, VS Code, JetBrains, web) and can be scanned by a
5619
5590
  * phone camera when shown verbatim in an agent response.
5620
5591
  *
5621
- * Shared by `renderAttachBanner` (relay wssUrl QR) and the `build_attach_url`
5592
+ * Shared by `renderAttachBanner` (relay wssUrl QR) and the `start_attach`
5622
5593
  * MCP tool response (attach deep-link QR).
5623
5594
  */
5624
5595
  async function renderQr(text) {
@@ -5649,7 +5620,7 @@ async function renderQr(text) {
5649
5620
  * The QR is produced by `renderQr` (a half-block matrix, not the
5650
5621
  * `qrcode-terminal` ASCII art used by the unplugin banner) and encodes the
5651
5622
  * base `wssUrl` only. When `totpEnabled` is true, a note
5652
- * is added that attach URLs generated by `build_attach_url` will include a
5623
+ * is added that attach URLs generated by `start_attach` will include a
5653
5624
  * live TOTP code (`at=`) appended at call time.
5654
5625
  *
5655
5626
  * SECRET-HANDLING: no secret value, TOTP code, or intermediate value is
@@ -5665,9 +5636,9 @@ async function renderAttachBanner(input) {
5665
5636
  ` relay (wss): ${input.wssUrl}`,
5666
5637
  authNote,
5667
5638
  "",
5668
- " Use build_attach_url to generate a deep link with the current TOTP code.",
5639
+ " Use start_attach to generate a deep link with the current TOTP code.",
5669
5640
  " Scan the QR to locate the relay (open the dog-food URL separately with",
5670
- " ?debug=1&relay=<wss>&at=<code> or use the build_attach_url tool):",
5641
+ " ?debug=1&relay=<wss>&at=<code> or use the start_attach tool):",
5671
5642
  "",
5672
5643
  qr
5673
5644
  ].join("\n");
@@ -5841,7 +5812,7 @@ function makeTunnelStatus(up, wssUrl, droppedAt = null, reissueAttempts = 0) {
5841
5812
  * Dynamic tool registration (issue #208):
5842
5813
  * The server advertises `listChanged: true` so MCP clients can subscribe to
5843
5814
  * `notifications/tools/list_changed`. Before any page attaches, only bootstrap
5844
- * tools (`build_attach_url`, `list_pages`) are listed. Once a target appears,
5815
+ * tools (`start_attach`, `list_pages`) are listed. Once a target appears,
5845
5816
  * the full attach-dependent tool set is added and a `list_changed` notification
5846
5817
  * is sent — without requiring a session restart. `runDebugServer` and
5847
5818
  * `runLocalDebugServer` start a polling watcher that detects the 0→N target
@@ -5854,7 +5825,7 @@ function makeTunnelStatus(up, wssUrl, droppedAt = null, reissueAttempts = 0) {
5854
5825
  */
5855
5826
  /**
5856
5827
  * Maximum age (ms) of a page's `lastSeenAt` before it is treated as a ghost
5857
- * and excluded from the `wait_for_attach` short-circuit in `build_attach_url`
5828
+ * and excluded from the `wait_for_attach` short-circuit in `start_attach`
5858
5829
  * (issue #610).
5859
5830
  *
5860
5831
  * Rationale: the env-2 relay is owned by the dev server (unplugin), so every
@@ -5869,7 +5840,15 @@ function makeTunnelStatus(up, wssUrl, droppedAt = null, reissueAttempts = 0) {
5869
5840
  */
5870
5841
  const RELAY_SANDBOX_STALE_PAGE_MS = 300 * 1e3;
5871
5842
  /**
5872
- * Predicate used by `build_attach_url`'s `wait_for_attach` loop to decide
5843
+ * Segment length (ms) of the `start_attach` wait loop (issue #626 — TOTP in-call
5844
+ * re-mint). The single-shot `wait_for_attach` of the old attach tool could
5845
+ * not re-mint a TOTP code mid-wait; `start_attach` decomposes the wait into
5846
+ * SEGMENT_MS slices so it can detect an aging code between slices and re-mint a
5847
+ * fresh one without the agent re-calling the tool. 30 s = one TOTP step.
5848
+ */
5849
+ const START_ATTACH_SEGMENT_MS = 3e4;
5850
+ /**
5851
+ * Predicate used by `start_attach`'s `wait_for_attach` loop to decide
5873
5852
  * whether the relay-sandbox connection has a genuinely fresh page attached.
5874
5853
  *
5875
5854
  * Stale-ghost gating (issue #610): when the dev server restarts with a new
@@ -5919,13 +5898,28 @@ function extractDeploymentId(schemeUrl) {
5919
5898
  }
5920
5899
  }
5921
5900
  /**
5922
- * Returns `true` when the mode routes to a relay connection (`relay-sandbox`,
5923
- * `relay-staging`, or `relay-live`). `relay-sandbox` is an external-PWA relay;
5924
- * `relay-staging`/`relay-live` are intoss-private relays — but all three surface
5925
- * the Tier B / relay-only tool set.
5901
+ * Returns `true` when the mode routes to a relay connection (`relay-sandbox` or
5902
+ * `relay-staging`). Both surface the Tier B / relay-only tool set.
5926
5903
  */
5927
5904
  function isRelayMode(mode) {
5928
- return mode === "relay-sandbox" || mode === "relay-staging" || mode === "relay-live";
5905
+ return mode === "relay-sandbox" || mode === "relay-staging";
5906
+ }
5907
+ /**
5908
+ * Maps a `StartDebugMode` to the `McpEnvironment` it routes to (issue #626).
5909
+ * Used by `start_attach`'s mode prologue to decide whether a `switchMode` is
5910
+ * needed: when the active env already equals `envForMode(mode)`, the switch is
5911
+ * skipped (no `tools/list_changed` churn).
5912
+ *
5913
+ * - `local-browser` → `mock`
5914
+ * - `relay-sandbox` → `relay-mobile` (env 2 external-PWA relay)
5915
+ * - `relay-staging` → `relay-dev` (env 3 intoss-private relay)
5916
+ */
5917
+ function envForMode(mode) {
5918
+ switch (mode) {
5919
+ case "local-browser": return "mock";
5920
+ case "relay-sandbox": return "relay-mobile";
5921
+ case "relay-staging": return "relay-dev";
5922
+ }
5929
5923
  }
5930
5924
  /**
5931
5925
  * Single-attach guard for `run_tests` (#646). Two concurrent runs injecting
@@ -5948,7 +5942,7 @@ let runTestsInFlight = false;
5948
5942
  * to resolve before the relay had observed the first inbound CDP message from
5949
5943
  * the phone.
5950
5944
  *
5951
- * Timeout note: callers (e.g. the `build_attach_url` path) always pass an
5945
+ * Timeout note: callers (e.g. the `start_attach` path) always pass an
5952
5946
  * explicit `timeoutMs`, sourced from the factory's `waitForAttachTimeoutMs`
5953
5947
  * (default 60 000). That value is forwarded to `waitForFirstTarget`, so it
5954
5948
  * overrides that method's own 90 000 signature default — the effective
@@ -5990,7 +5984,7 @@ function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs
5990
5984
  * tunnel, which is what makes the tool surface unit-testable.
5991
5985
  *
5992
5986
  * `tools/list` is two-tiered (issue #208):
5993
- * - bootstrap (always): `build_attach_url`, `list_pages`
5987
+ * - bootstrap (always): `start_attach`, `list_pages`
5994
5988
  * - attach-dependent (after `connection.listTargets().length > 0`): all others
5995
5989
  *
5996
5990
  * `CallTool` is NOT tiered — hidden tools still execute (attach errors surface
@@ -6001,12 +5995,299 @@ function createDebugServer(deps) {
6001
5995
  const getTotpSecret = deps.getTotpSecret ?? (() => totpSecret);
6002
5996
  const readLockFn = readLockDep ?? readServerLock;
6003
5997
  const router = routerDep ?? makeSingleConnectionRouter(connection);
6004
- const resolveEnvironment = getEnvDep ?? (() => deriveEnvironment(router.active.kind, getLiveIntent(), router.activeRelayOrigin));
6005
- const resolveEnvironmentReason = getEnvReasonDep ?? (() => `derived:kind=${router.active.kind},liveIntent=${getLiveIntent()}`);
5998
+ const resolveEnvironment = getEnvDep ?? (() => deriveEnvironment(router.active.kind, router.activeRelayOrigin));
5999
+ const resolveEnvironmentReason = getEnvReasonDep ?? (() => `derived:kind=${router.active.kind},relayOrigin=${router.activeRelayOrigin ?? "none"}`);
6006
6000
  const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
6001
+ /**
6002
+ * Synthesizes an attach URL from stored components with a FRESHLY-minted TOTP
6003
+ * code (issue #626 §3/§4 — the single mint point). Reads the late-bound secret
6004
+ * via `getTotpSecret()` so the project-local `.ait_relay` secret loaded by
6005
+ * `switchMode` is visible. SECRET-HANDLING: the minted code rides inside the
6006
+ * URL's `at=` param only — never logged or returned separately.
6007
+ */
6008
+ function mintAttachUrl(parts) {
6009
+ const secret = getTotpSecret();
6010
+ const code = secret ? generateTotp(secret) : void 0;
6011
+ return parts.kind === "launcher" ? buildLauncherAttachUrl(parts.tunnelHttpUrl, parts.wssUrl, code, {
6012
+ name: parts.appName,
6013
+ ...parts.selfdebug ? { selfdebug: true } : {}
6014
+ }) : buildDeepLinkAttachUrl(parts.schemeUrl, parts.wssUrl, code);
6015
+ }
6016
+ /** Builds the fresh TOTP metadata (expiresAt window) for a tool result. */
6017
+ function buildTotpMeta() {
6018
+ const secret = getTotpSecret();
6019
+ if (secret === void 0 || secret === "") return void 0;
6020
+ const STEP_SECONDS = 30;
6021
+ const expiresAtMs = nowMs() + 6 * STEP_SECONDS * 1e3;
6022
+ return {
6023
+ enabled: true,
6024
+ ttlSeconds: 6 * STEP_SECONDS,
6025
+ expiresAt: new Date(expiresAtMs).toISOString()
6026
+ };
6027
+ }
6028
+ /**
6029
+ * Env-specific validation + component bundle for `start_attach` (issue #626).
6030
+ * Branches on `env`: `relay-mobile` reads AIT_TUNNEL_BASE_URL + builds launcher
6031
+ * parts; `relay-dev` requires scheme_url + builds scheme parts. Returns
6032
+ * `{ ok: false, error }` with a ready McpResult on any failure.
6033
+ */
6034
+ async function prepareAttach(env, args, conn) {
6035
+ const selfdebug = args?.selfdebug === true;
6036
+ if (selfdebug && env !== "relay-mobile") return {
6037
+ ok: false,
6038
+ error: mcpError("start_attach: selfdebug=true는 env 2 / relay-sandbox 전용 기능입니다. 현재 환경(env 3)에서는 launcher가 없어 self-target 모드를 지원하지 않습니다. launcher self-target이 필요하다면 relay-sandbox 모드로 전환하세요.")
6039
+ };
6040
+ if (env === "relay-mobile") {
6041
+ const rawProjectRoot = args?.projectRoot;
6042
+ const buildProjectRoot = typeof rawProjectRoot === "string" ? rawProjectRoot : void 0;
6043
+ let tunnelHttpUrl = process.env.AIT_TUNNEL_BASE_URL?.trim() ?? "";
6044
+ if (tunnelHttpUrl === "" && buildProjectRoot !== void 0) {
6045
+ const { readRelayUrls } = await import("../relay-url-store-CwKT7i04.js");
6046
+ tunnelHttpUrl = (await readRelayUrls({ projectRoot: buildProjectRoot }))?.tunnelBaseUrl ?? "";
6047
+ }
6048
+ if (tunnelHttpUrl === "") return {
6049
+ ok: false,
6050
+ error: mcpError("start_attach(mobile): AIT_TUNNEL_BASE_URL이 설정되지 않았습니다. dev 서버가 tunnel:{cdp:true}로 기동 중이면 .ait_urls 파일이 자동 생성돼 있어야 합니다. 자동 발견이 되지 않을 경우 앱 HTTP 터널 URL을 AIT_TUNNEL_BASE_URL 환경변수로 직접 전달하세요.")
6051
+ };
6052
+ const tunnelStatus = getTunnelStatus();
6053
+ if (!tunnelStatus.up || tunnelStatus.wssUrl === null) return {
6054
+ ok: false,
6055
+ error: mcpError("start_attach(mobile): relay wssUrl이 아직 설정되지 않았습니다. unplugin tunnel:{cdp:true}가 relay를 완전히 기동할 때까지 잠시 후 다시 시도하세요.")
6056
+ };
6057
+ const secret = getTotpSecret();
6058
+ if (secret === void 0 || secret === "") return {
6059
+ ok: false,
6060
+ error: mcpError("start_attach(relay): TOTP secret(AIT_DEBUG_TOTP_SECRET)이 설정되지 않았습니다. relay 환경은 TOTP 인증이 필수입니다 — relay를 secret과 함께 재기동하세요.")
6061
+ };
6062
+ let launcherAppName;
6063
+ if (buildProjectRoot !== void 0) try {
6064
+ const { readFileSync } = await import("node:fs");
6065
+ const pkgRaw = readFileSync(`${buildProjectRoot}/package.json`, "utf8");
6066
+ const pkg = JSON.parse(pkgRaw);
6067
+ const rawName = typeof pkg.name === "string" ? pkg.name : "";
6068
+ launcherAppName = (rawName.includes("/") ? rawName.slice(rawName.indexOf("/") + 1) : rawName).trim() || void 0;
6069
+ } catch {}
6070
+ const parts = {
6071
+ kind: "launcher",
6072
+ tunnelHttpUrl,
6073
+ wssUrl: tunnelStatus.wssUrl,
6074
+ appName: launcherAppName,
6075
+ ...selfdebug ? { selfdebug: true } : {}
6076
+ };
6077
+ const connAsAny = conn;
6078
+ const getLastSeenAt = typeof connAsAny.getTargetLastSeenAt === "function" ? (id) => connAsAny.getTargetLastSeenAt(id) : null;
6079
+ const callNow = nowMs();
6080
+ const isMatchingPage = (pages) => isSandboxPageFresh(pages, getLastSeenAt, callNow, stalePageThresholdMs);
6081
+ const buildTimeoutError = (baseText, timeoutSec, observed) => {
6082
+ const observedUrls = observed.slice(0, 3).map((p) => p.url.slice(0, 80)).join(", ");
6083
+ return `${baseText}\n\nNo page attached within ${timeoutSec}s${observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : ""} — launcher QR을 폰 카메라로 스캔한 뒤 call list_pages를 다시 호출하세요.`;
6084
+ };
6085
+ return {
6086
+ ok: true,
6087
+ parts,
6088
+ isMatchingPage,
6089
+ buildTimeoutError,
6090
+ authorityWarning: void 0,
6091
+ totpMeta: buildTotpMeta()
6092
+ };
6093
+ }
6094
+ const schemeUrl = args?.scheme_url;
6095
+ if (typeof schemeUrl !== "string" || schemeUrl === "") return {
6096
+ ok: false,
6097
+ error: mcpError("start_attach: scheme_url이 비어 있습니다. `ait deploy --scheme-only`가 출력하는 intoss-private:// URL을 인자로 전달하세요. 환경 2(mobile)라면 scheme_url 대신 AIT_TUNNEL_BASE_URL을 설정하세요.")
6098
+ };
6099
+ {
6100
+ const relaySecret = getTotpSecret();
6101
+ if (relaySecret === void 0 || relaySecret === "") return {
6102
+ ok: false,
6103
+ error: mcpError("start_attach(relay): TOTP secret(AIT_DEBUG_TOTP_SECRET)이 설정되지 않았습니다. relay 환경은 TOTP 인증이 필수입니다 — relay를 secret과 함께 재기동하세요.")
6104
+ };
6105
+ }
6106
+ const tunnelForBuild = getTunnelStatus();
6107
+ if (!tunnelForBuild.up || tunnelForBuild.wssUrl === null) return {
6108
+ ok: false,
6109
+ error: classifyToolError(/* @__PURE__ */ new Error("tunnel-down:"), "start_attach")
6110
+ };
6111
+ const authorityWarning = validateSchemeAuthority(schemeUrl) ?? void 0;
6112
+ const parts = {
6113
+ kind: "scheme",
6114
+ schemeUrl,
6115
+ wssUrl: tunnelForBuild.wssUrl
6116
+ };
6117
+ const deploymentId = extractDeploymentId(schemeUrl);
6118
+ if (!deploymentId) logInfo("tool.call", {
6119
+ tool: "start_attach",
6120
+ msg: "no _deploymentId in scheme_url; matching on presence only"
6121
+ });
6122
+ const isMatchingPage = (pages) => {
6123
+ if (pages.length === 0) return false;
6124
+ if (deploymentId === null) return true;
6125
+ return pages.some((p) => p.url.includes(deploymentId));
6126
+ };
6127
+ const buildTimeoutError = (baseText, timeoutSec, observed) => {
6128
+ const observedUrls = observed.slice(0, 3).map((p) => p.url.slice(0, 80)).join(", ");
6129
+ const observedNote = observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : "";
6130
+ return `${baseText}\n\nNo page${deploymentId ? ` matching deploymentId=${deploymentId}` : ""} attached within ${timeoutSec}s${observedNote} — call list_pages to retry.`;
6131
+ };
6132
+ return {
6133
+ ok: true,
6134
+ parts,
6135
+ isMatchingPage,
6136
+ buildTimeoutError,
6137
+ authorityWarning,
6138
+ totpMeta: buildTotpMeta()
6139
+ };
6140
+ }
6141
+ /**
6142
+ * QR render + browser open + segmented attach wait with in-call TOTP re-mint
6143
+ * (issue #626 §3). Shared by env-2 and env-3 (4 render paths:
6144
+ * headless / browser-opened / browser-open-failed / no-http-server).
6145
+ *
6146
+ * The wait is decomposed into `START_ATTACH_SEGMENT_MS` slices. Between slices,
6147
+ * if the current TOTP code has aged past `START_ATTACH_REMINT_THRESHOLD_MS`,
6148
+ * a fresh URL is minted via `mintAttachUrl` and pushed to the dashboard via
6149
+ * `onAttachUrlBuilt` (SSE refresh — NO browser re-open). The `reminted` count
6150
+ * rides in the success/timeout result.
6151
+ *
6152
+ * SECRET-HANDLING: attachUrl encodes tunnel/scheme host + the TOTP `at=` code
6153
+ * in the QR payload only. The browser is opened on a 127.0.0.1 URL only. The
6154
+ * tool result carries `totp.expiresAt` + `reminted` count — never the code.
6155
+ */
6156
+ async function renderAndMaybeWait(prep, waitForAttach, callTimeoutMs, conn) {
6157
+ const { parts, isMatchingPage, buildTimeoutError, authorityWarning, totpMeta } = prep;
6158
+ let attachUrl = mintAttachUrl(parts);
6159
+ onAttachUrlBuilt?.(parts);
6160
+ let totpIssuedAt = nowMs();
6161
+ let reminted = 0;
6162
+ const relayUrl = parts.wssUrl;
6163
+ 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).";
6164
+ const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
6165
+ const guiAvailable = canOpenBrowser();
6166
+ /** Builds the totp object surfaced in results (fresh expiresAt + reminted). */
6167
+ const totpResult = () => {
6168
+ if (!totpMeta) return void 0;
6169
+ const expiresAtMs = totpIssuedAt + 180 * 1e3;
6170
+ return {
6171
+ enabled: true,
6172
+ ttlSeconds: totpMeta.ttlSeconds,
6173
+ expiresAt: new Date(expiresAtMs).toISOString(),
6174
+ ...reminted > 0 ? { reminted } : {}
6175
+ };
6176
+ };
6177
+ /**
6178
+ * Segmented wait with TOTP re-mint (issue #626 §3). Resolves with the
6179
+ * attached page list, or rejects on timeout. Between SEGMENT_MS slices it
6180
+ * re-mints when the code has aged past the threshold (max ~4 re-mints over
6181
+ * 600 s). Returns immediately once a matching page attaches (no re-mint).
6182
+ */
6183
+ async function waitWithRemint() {
6184
+ const deadline = nowMs() + callTimeoutMs;
6185
+ if (isMatchingPage(conn.listTargets())) return conn.listTargets();
6186
+ for (;;) {
6187
+ const remaining = deadline - nowMs();
6188
+ if (remaining <= 0) throw new Error(`start_attach: 타임아웃 (${callTimeoutMs}ms)`);
6189
+ const segmentMs = Math.min(START_ATTACH_SEGMENT_MS, remaining);
6190
+ try {
6191
+ return await waitForAttachWithEvents(conn, isMatchingPage, segmentMs);
6192
+ } catch {
6193
+ if (totpMeta && nowMs() - totpIssuedAt >= 15e4) {
6194
+ attachUrl = mintAttachUrl(parts);
6195
+ onAttachUrlBuilt?.(parts);
6196
+ totpIssuedAt = nowMs();
6197
+ reminted += 1;
6198
+ }
6199
+ }
6200
+ }
6201
+ }
6202
+ /**
6203
+ * Assembles the success result after a page attaches. `baseText` carries the
6204
+ * QR + pre-wait JSON block (the QR the user already scanned). The attach
6205
+ * itself ends the wait, so the QR is moot — what matters now is the final
6206
+ * TOTP state. If the segmented wait re-minted (issue #626 §3), surface the
6207
+ * post-wait `totp` block (fresh `expiresAt` + `reminted` count) so the result
6208
+ * reflects how many times the code rotated during the wait. SECRET-HANDLING:
6209
+ * the totp block carries expiresAt + reminted only — never the code value.
6210
+ */
6211
+ const successResult = (baseText) => {
6212
+ const pagesResult = listPages(conn, getTunnelStatus());
6213
+ const finalTotp = totpResult();
6214
+ const remintNote = finalTotp && reminted > 0 ? `\n\n${JSON.stringify({ totp: finalTotp }, null, 2)}` : "";
6215
+ return { content: [{
6216
+ type: "text",
6217
+ text: `${baseText}\n\n${JSON.stringify(pagesResult, null, 2)}${remintNote}`
6218
+ }] };
6219
+ };
6220
+ /** Runs the wait (when requested) and returns success/timeout result. */
6221
+ const runWait = async (baseText) => {
6222
+ if (!waitForAttach) return { content: [{
6223
+ type: "text",
6224
+ text: baseText
6225
+ }] };
6226
+ try {
6227
+ await waitWithRemint();
6228
+ } catch {
6229
+ const observed = conn.listTargets();
6230
+ return {
6231
+ content: [{
6232
+ type: "text",
6233
+ text: buildTimeoutError(baseText, callTimeoutMs / 1e3, observed)
6234
+ }],
6235
+ isError: true
6236
+ };
6237
+ }
6238
+ return successResult(baseText);
6239
+ };
6240
+ if (!guiAvailable) {
6241
+ const headlessNote = "GUI 환경이 감지되지 않았습니다 (headless/remote 환경). 텍스트 QR을 폰 카메라로 스캔하거나, 로컬 GUI 환경에서 실행하세요.\n\n";
6242
+ const qr = await renderQr(attachUrl);
6243
+ return runWait(`${warningPrefix}${headlessNote}${header}\n${JSON.stringify({
6244
+ attachUrl,
6245
+ relayUrl,
6246
+ ...totpResult() ? { totp: totpResult() } : {}
6247
+ }, null, 2)}\n\n${qr}`);
6248
+ }
6249
+ if (guiAvailable && qrHttpServer) {
6250
+ const browserResult = await openQrInBrowser(qrHttpServer.buildAttachPageUrl(attachUrl), `http://127.0.0.1:${qrHttpServer.port}/qr.png?u=${encodeURIComponent(attachUrl)}`);
6251
+ if (browserResult.opened) {
6252
+ const retriedNote = browserResult.retried ? " (1회 retry 후 성공)" : "";
6253
+ const openResult = {
6254
+ attempted: true,
6255
+ succeeded: true,
6256
+ ...browserResult.retried ? { retried: true } : {}
6257
+ };
6258
+ return runWait(`${warningPrefix}${header}\n${JSON.stringify({
6259
+ relayUrl,
6260
+ openResult,
6261
+ ...totpResult() ? { totp: totpResult() } : {}
6262
+ }, null, 2)}\n\n브라우저에서 QR을 열었습니다${retriedNote}. 폰 카메라로 스캔하세요.\nURL: ${browserResult.httpUrl}`);
6263
+ }
6264
+ const openResult = {
6265
+ attempted: true,
6266
+ succeeded: false,
6267
+ failureReason: browserResult.error ?? "브라우저 실행 후보 모두 실패",
6268
+ pngUrl: browserResult.pngUrl,
6269
+ ...browserResult.stderrSummary ? { stderrSummary: browserResult.stderrSummary } : {}
6270
+ };
6271
+ const stderrNote = browserResult.stderrSummary ? `\nstderr: ${browserResult.stderrSummary}` : "";
6272
+ const fallbackNote = `브라우저 자동 열기에 실패했습니다. 다음 URL을 직접 브라우저에서 여세요:\n${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stderrNote + "\n\n";
6273
+ const qr = await renderQr(attachUrl);
6274
+ return runWait(`${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
6275
+ attachUrl,
6276
+ relayUrl,
6277
+ openResult,
6278
+ ...totpResult() ? { totp: totpResult() } : {}
6279
+ }, null, 2)}\n\n${qr}`);
6280
+ }
6281
+ const qr = await renderQr(attachUrl);
6282
+ return runWait(`${warningPrefix}${header}\n${JSON.stringify({
6283
+ attachUrl,
6284
+ relayUrl,
6285
+ ...totpResult() ? { totp: totpResult() } : {}
6286
+ }, null, 2)}\n\n${qr}`);
6287
+ }
6007
6288
  const server = new Server({
6008
6289
  name: "ait-debug",
6009
- version: "0.1.109"
6290
+ version: "0.1.110"
6010
6291
  }, { capabilities: { tools: { listChanged: true } } });
6011
6292
  server.setRequestHandler(ListToolsRequestSchema, () => {
6012
6293
  const conn = router.active;
@@ -6028,12 +6309,47 @@ function createDebugServer(deps) {
6028
6309
  if (name === "start_debug") {
6029
6310
  const rawMode = request.params.arguments?.mode;
6030
6311
  const mode = normalizeStartDebugMode(rawMode);
6031
- if (mode === null) return mcpError("start_debug: mode가 올바르지 않습니다. 'local-browser' | 'relay-sandbox' | 'relay-staging' | 'relay-live' 하나를 전달하세요.");
6032
- const confirm = request.params.arguments?.confirm === true;
6312
+ if (mode === null) return mcpError("start_debug: mode가 올바르지 않습니다. 'local-browser' | 'relay-sandbox' | 'relay-staging' 하나를 전달하세요. (relay-live / env 4는 #665에서 제거됐습니다.)");
6033
6313
  const rawProjectRoot = request.params.arguments?.projectRoot;
6034
6314
  const projectRoot = typeof rawProjectRoot === "string" ? rawProjectRoot : void 0;
6035
6315
  try {
6036
- return jsonResult$1(await router.switchMode(mode, confirm, projectRoot));
6316
+ return jsonResult$1(await router.switchMode(mode, projectRoot));
6317
+ } catch (err) {
6318
+ return errorResult(err, name);
6319
+ }
6320
+ }
6321
+ if (name === "start_attach") {
6322
+ const args = request.params.arguments;
6323
+ let attachConn = conn;
6324
+ const rawMode = args?.mode;
6325
+ if (rawMode !== void 0) {
6326
+ const mode = normalizeStartDebugMode(rawMode);
6327
+ if (mode === null || mode === "local-browser") return mcpError("start_attach: mode가 올바르지 않습니다. 'relay-sandbox' | 'relay-staging' 중 하나를 전달하세요 (local-browser는 QR attach가 없어 start_attach에서 지원하지 않습니다).");
6328
+ const targetEnv = envForMode(mode);
6329
+ if (resolveEnvironment() !== targetEnv) {
6330
+ const rawProjectRoot = args?.projectRoot;
6331
+ const projectRoot = typeof rawProjectRoot === "string" ? rawProjectRoot : void 0;
6332
+ try {
6333
+ await router.switchMode(mode, projectRoot);
6334
+ } catch (err) {
6335
+ return errorResult(err, name);
6336
+ }
6337
+ attachConn = router.active;
6338
+ }
6339
+ }
6340
+ const attachEnv = resolveEnvironment();
6341
+ if (!isRelayEnv(attachEnv)) return mcpError("start_attach: relay 전용 tool입니다 (env 2 / relay-sandbox 또는 env 3 / relay-staging). 현재 환경은 'local-browser'(mock)입니다 — mode 인자로 'relay-sandbox' 또는 'relay-staging'을 전달하거나, 먼저 relay 모드로 전환하세요.");
6342
+ const waitForAttach = true;
6343
+ const rawWaitTimeout = args?.wait_timeout_seconds;
6344
+ const callTimeoutMs = (() => {
6345
+ if (typeof rawWaitTimeout !== "number" || !Number.isFinite(rawWaitTimeout)) return waitForAttachTimeoutMs;
6346
+ if (rawWaitTimeout <= 0) return waitForAttachTimeoutMs;
6347
+ return Math.round(Math.max(1, Math.min(600, rawWaitTimeout))) * 1e3;
6348
+ })();
6349
+ try {
6350
+ const prep = await prepareAttach(attachEnv, args, attachConn);
6351
+ if (!prep.ok) return prep.error;
6352
+ return await renderAndMaybeWait(prep, waitForAttach, callTimeoutMs, attachConn);
6037
6353
  } catch (err) {
6038
6354
  return errorResult(err, name);
6039
6355
  }
@@ -6078,386 +6394,6 @@ function createDebugServer(deps) {
6078
6394
  } catch (err) {
6079
6395
  return errorResult(err, name);
6080
6396
  }
6081
- if (name === "build_attach_url") {
6082
- const waitForAttach = request.params.arguments?.wait_for_attach === true;
6083
- const selfdebug = request.params.arguments?.selfdebug === true;
6084
- const rawWaitTimeout = request.params.arguments?.wait_timeout_seconds;
6085
- const callTimeoutMs = (() => {
6086
- if (typeof rawWaitTimeout !== "number" || !Number.isFinite(rawWaitTimeout)) return waitForAttachTimeoutMs;
6087
- const clamped = Math.max(1, Math.min(600, rawWaitTimeout));
6088
- if (rawWaitTimeout <= 0) return waitForAttachTimeoutMs;
6089
- return Math.round(clamped) * 1e3;
6090
- })();
6091
- if (selfdebug && env !== "relay-mobile") return mcpError("build_attach_url: selfdebug=true는 env 2 / relay-sandbox 전용 기능입니다. 현재 환경(env 3/4)에서는 launcher가 없어 self-target 모드를 지원하지 않습니다. launcher self-target이 필요하다면 relay-sandbox 모드로 재시작하세요.");
6092
- if (env === "relay-mobile") {
6093
- const rawBuildProjectRoot = request.params.arguments?.projectRoot;
6094
- const buildProjectRoot = typeof rawBuildProjectRoot === "string" ? rawBuildProjectRoot : void 0;
6095
- let tunnelHttpUrl = process.env.AIT_TUNNEL_BASE_URL?.trim() ?? "";
6096
- if (tunnelHttpUrl === "" && buildProjectRoot !== void 0) {
6097
- const { readRelayUrls } = await import("../relay-url-store-CwKT7i04.js");
6098
- tunnelHttpUrl = (await readRelayUrls({ projectRoot: buildProjectRoot }))?.tunnelBaseUrl ?? "";
6099
- }
6100
- if (tunnelHttpUrl === "") return mcpError("build_attach_url(mobile): AIT_TUNNEL_BASE_URL이 설정되지 않았습니다. dev 서버가 tunnel:{cdp:true}로 기동 중이면 .ait_urls 파일이 자동 생성돼 있어야 합니다. 자동 발견이 되지 않을 경우 앱 HTTP 터널 URL을 AIT_TUNNEL_BASE_URL 환경변수로 직접 전달하세요.");
6101
- const tunnelStatus = getTunnelStatus();
6102
- if (!tunnelStatus.up || tunnelStatus.wssUrl === null) return mcpError("build_attach_url(mobile): relay wssUrl이 아직 설정되지 않았습니다. unplugin tunnel:{cdp:true}가 relay를 완전히 기동할 때까지 잠시 후 다시 시도하세요.");
6103
- const secret = getTotpSecret();
6104
- if (secret === void 0 || secret === "") return mcpError("build_attach_url(relay): TOTP secret(AIT_DEBUG_TOTP_SECRET)이 설정되지 않았습니다. relay 환경은 TOTP 인증이 필수입니다 — relay를 secret과 함께 재기동하세요.");
6105
- let totpCode;
6106
- let totpMeta;
6107
- {
6108
- const now = Date.now();
6109
- totpCode = generateTotp(secret, now);
6110
- const STEP_SECONDS = 30;
6111
- const expiresAtMs = now + 6 * STEP_SECONDS * 1e3;
6112
- totpMeta = {
6113
- enabled: true,
6114
- ttlSeconds: 6 * STEP_SECONDS,
6115
- expiresAt: new Date(expiresAtMs).toISOString()
6116
- };
6117
- }
6118
- let launcherAppName;
6119
- if (buildProjectRoot !== void 0) try {
6120
- const { readFileSync } = await import("node:fs");
6121
- const pkgRaw = readFileSync(`${buildProjectRoot}/package.json`, "utf8");
6122
- const pkg = JSON.parse(pkgRaw);
6123
- const rawName = typeof pkg.name === "string" ? pkg.name : "";
6124
- launcherAppName = (rawName.includes("/") ? rawName.slice(rawName.indexOf("/") + 1) : rawName).trim() || void 0;
6125
- } catch {}
6126
- const attachUrl = buildLauncherAttachUrl(tunnelHttpUrl, tunnelStatus.wssUrl, totpCode, {
6127
- name: launcherAppName,
6128
- ...selfdebug ? { selfdebug: true } : {}
6129
- });
6130
- onAttachUrlBuilt?.({
6131
- kind: "launcher",
6132
- tunnelHttpUrl,
6133
- wssUrl: tunnelStatus.wssUrl,
6134
- appName: launcherAppName
6135
- });
6136
- const relayUrl = tunnelStatus.wssUrl;
6137
- const totp = totpMeta;
6138
- const connAsAny = conn;
6139
- const getLastSeenAt = typeof connAsAny.getTargetLastSeenAt === "function" ? (id) => connAsAny.getTargetLastSeenAt(id) : null;
6140
- const callNow = nowMs();
6141
- const isMatchingPage = (pages) => isSandboxPageFresh(pages, getLastSeenAt, callNow, stalePageThresholdMs);
6142
- const buildTimeoutError = (baseText, timeoutSec, observed) => {
6143
- const observedUrls = observed.slice(0, 3).map((p) => p.url.slice(0, 80)).join(", ");
6144
- return `${baseText}\n\nNo page attached within ${timeoutSec}s${observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : ""} — launcher QR을 폰 카메라로 스캔한 뒤 call list_pages를 다시 호출하세요.`;
6145
- };
6146
- return await (async () => {
6147
- 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).";
6148
- const warningPrefix = "";
6149
- const guiAvailable = canOpenBrowser();
6150
- if (!guiAvailable) {
6151
- const headlessNote = "GUI 환경이 감지되지 않았습니다 (headless/remote 환경). 텍스트 QR을 폰 카메라로 스캔하거나, 로컬 GUI 환경에서 실행하세요.\n\n";
6152
- const qrHeadless = await renderQr(attachUrl);
6153
- const headlessText = `${warningPrefix}${headlessNote}${header}\n${JSON.stringify({
6154
- attachUrl,
6155
- relayUrl,
6156
- ...totp ? { totp } : {}
6157
- }, null, 2)}\n\n${qrHeadless}`;
6158
- if (!waitForAttach) return { content: [{
6159
- type: "text",
6160
- text: headlessText
6161
- }] };
6162
- let attachedPagesHl = [];
6163
- try {
6164
- attachedPagesHl = await waitForAttachWithEvents(conn, isMatchingPage, callTimeoutMs);
6165
- } catch {
6166
- attachedPagesHl = conn.listTargets();
6167
- return {
6168
- content: [{
6169
- type: "text",
6170
- text: buildTimeoutError(headlessText, callTimeoutMs / 1e3, attachedPagesHl)
6171
- }],
6172
- isError: true
6173
- };
6174
- }
6175
- const pagesResultHl = listPages(conn, getTunnelStatus());
6176
- return { content: [{
6177
- type: "text",
6178
- text: `${headlessText}\n\n${JSON.stringify(pagesResultHl, null, 2)}`
6179
- }] };
6180
- }
6181
- if (guiAvailable && qrHttpServer) {
6182
- const browserResult = await openQrInBrowser(qrHttpServer.buildAttachPageUrl(attachUrl), `http://127.0.0.1:${qrHttpServer.port}/qr.png?u=${encodeURIComponent(attachUrl)}`);
6183
- if (browserResult.opened) {
6184
- const retriedNote = browserResult.retried ? " (1회 retry 후 성공)" : "";
6185
- const openResult = {
6186
- attempted: true,
6187
- succeeded: true,
6188
- ...browserResult.retried ? { retried: true } : {}
6189
- };
6190
- const shortText = `${warningPrefix}${header}\n${JSON.stringify({
6191
- relayUrl,
6192
- openResult,
6193
- ...totp ? { totp } : {}
6194
- }, null, 2)}\n\n브라우저에서 QR을 열었습니다${retriedNote}. 폰 카메라로 스캔하세요.\nURL: ${browserResult.httpUrl}`;
6195
- if (!waitForAttach) return { content: [{
6196
- type: "text",
6197
- text: shortText
6198
- }] };
6199
- let attachedPages = [];
6200
- try {
6201
- attachedPages = await waitForAttachWithEvents(conn, isMatchingPage, callTimeoutMs);
6202
- } catch {
6203
- attachedPages = conn.listTargets();
6204
- return {
6205
- content: [{
6206
- type: "text",
6207
- text: buildTimeoutError(shortText, callTimeoutMs / 1e3, attachedPages)
6208
- }],
6209
- isError: true
6210
- };
6211
- }
6212
- const pagesResult = listPages(conn, getTunnelStatus());
6213
- return { content: [{
6214
- type: "text",
6215
- text: `${shortText}\n\n${JSON.stringify(pagesResult, null, 2)}`
6216
- }] };
6217
- }
6218
- const openResult = {
6219
- attempted: true,
6220
- succeeded: false,
6221
- failureReason: browserResult.error ?? "브라우저 실행 후보 모두 실패",
6222
- pngUrl: browserResult.pngUrl,
6223
- ...browserResult.stderrSummary ? { stderrSummary: browserResult.stderrSummary } : {}
6224
- };
6225
- const stderrNote = browserResult.stderrSummary ? `\nstderr: ${browserResult.stderrSummary}` : "";
6226
- const fallbackNote = `브라우저 자동 열기에 실패했습니다. 다음 URL을 직접 브라우저에서 여세요:\n${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stderrNote + "\n\n";
6227
- const qr = await renderQr(attachUrl);
6228
- const baseText = `${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
6229
- attachUrl,
6230
- relayUrl,
6231
- openResult,
6232
- ...totp ? { totp } : {}
6233
- }, null, 2)}\n\n${qr}`;
6234
- if (!waitForAttach) return { content: [{
6235
- type: "text",
6236
- text: baseText
6237
- }] };
6238
- let attachedPagesFb = [];
6239
- try {
6240
- attachedPagesFb = await waitForAttachWithEvents(conn, isMatchingPage, callTimeoutMs);
6241
- } catch {
6242
- attachedPagesFb = conn.listTargets();
6243
- return {
6244
- content: [{
6245
- type: "text",
6246
- text: buildTimeoutError(baseText, callTimeoutMs / 1e3, attachedPagesFb)
6247
- }],
6248
- isError: true
6249
- };
6250
- }
6251
- const pagesResultFb = listPages(conn, getTunnelStatus());
6252
- return { content: [{
6253
- type: "text",
6254
- text: `${baseText}\n\n${JSON.stringify(pagesResultFb, null, 2)}`
6255
- }] };
6256
- }
6257
- const qr = await renderQr(attachUrl);
6258
- const baseText = `${warningPrefix}${header}\n${JSON.stringify({
6259
- attachUrl,
6260
- relayUrl,
6261
- ...totp ? { totp } : {}
6262
- }, null, 2)}\n\n${qr}`;
6263
- if (!waitForAttach) return { content: [{
6264
- type: "text",
6265
- text: baseText
6266
- }] };
6267
- let attachedPages = [];
6268
- try {
6269
- attachedPages = await waitForAttachWithEvents(conn, isMatchingPage, callTimeoutMs);
6270
- } catch {
6271
- attachedPages = conn.listTargets();
6272
- return {
6273
- content: [{
6274
- type: "text",
6275
- text: buildTimeoutError(baseText, callTimeoutMs / 1e3, attachedPages)
6276
- }],
6277
- isError: true
6278
- };
6279
- }
6280
- const pagesResult = listPages(conn, getTunnelStatus());
6281
- return { content: [{
6282
- type: "text",
6283
- text: `${baseText}\n\n${JSON.stringify(pagesResult, null, 2)}`
6284
- }] };
6285
- })();
6286
- }
6287
- const schemeUrl = request.params.arguments?.scheme_url;
6288
- if (typeof schemeUrl !== "string" || schemeUrl === "") return mcpError("build_attach_url: scheme_url이 비어 있습니다. `ait deploy --scheme-only`가 출력하는 intoss-private:// URL을 인자로 전달하세요. 환경 2(mobile)라면 scheme_url 대신 AIT_TUNNEL_BASE_URL을 설정하세요.");
6289
- const deploymentId = extractDeploymentId(schemeUrl);
6290
- if (!deploymentId) logInfo("tool.call", {
6291
- tool: "build_attach_url",
6292
- msg: "no _deploymentId in scheme_url; matching on presence only"
6293
- });
6294
- /** Returns true when the page list satisfies the attach condition. */
6295
- const isMatchingPage = (pages) => {
6296
- if (pages.length === 0) return false;
6297
- if (deploymentId === null) return true;
6298
- return pages.some((p) => p.url.includes(deploymentId));
6299
- };
6300
- /** Builds a timeout error message with diagnostic context. */
6301
- const buildTimeoutError = (baseText, timeoutSec, observed) => {
6302
- const observedUrls = observed.slice(0, 3).map((p) => p.url.slice(0, 80)).join(", ");
6303
- const observedNote = observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : "";
6304
- return `${baseText}\n\nNo page${deploymentId ? ` matching deploymentId=${deploymentId}` : ""} attached within ${timeoutSec}s${observedNote} — call list_pages to retry.`;
6305
- };
6306
- {
6307
- const relaySecret = getTotpSecret();
6308
- if (relaySecret === void 0 || relaySecret === "") return mcpError("build_attach_url(relay): TOTP secret(AIT_DEBUG_TOTP_SECRET)이 설정되지 않았습니다. relay 환경은 TOTP 인증이 필수입니다 — relay를 secret과 함께 재기동하세요.");
6309
- }
6310
- try {
6311
- const tunnelForBuild = getTunnelStatus();
6312
- const { attachUrl, relayUrl, authorityWarning, totp } = buildAttachUrl(schemeUrl, tunnelForBuild, getTotpSecret());
6313
- if (tunnelForBuild.wssUrl !== null) onAttachUrlBuilt?.({
6314
- kind: "scheme",
6315
- schemeUrl,
6316
- wssUrl: tunnelForBuild.wssUrl
6317
- });
6318
- const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
6319
- 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).";
6320
- const guiAvailable = canOpenBrowser();
6321
- if (!guiAvailable) {
6322
- const headlessNote = "GUI 환경이 감지되지 않았습니다 (headless/remote 환경). 텍스트 QR을 폰 카메라로 스캔하거나, 로컬 GUI 환경에서 실행하세요.\n\n";
6323
- const qrHeadless = await renderQr(attachUrl);
6324
- const headlessText = `${warningPrefix}${headlessNote}${header}\n${JSON.stringify({
6325
- attachUrl,
6326
- relayUrl,
6327
- ...totp ? { totp } : {}
6328
- }, null, 2)}\n\n${qrHeadless}`;
6329
- if (!waitForAttach) return { content: [{
6330
- type: "text",
6331
- text: headlessText
6332
- }] };
6333
- let attachedPagesHl = [];
6334
- try {
6335
- attachedPagesHl = await waitForAttachWithEvents(conn, isMatchingPage, callTimeoutMs);
6336
- } catch {
6337
- attachedPagesHl = conn.listTargets();
6338
- return {
6339
- content: [{
6340
- type: "text",
6341
- text: buildTimeoutError(headlessText, callTimeoutMs / 1e3, attachedPagesHl)
6342
- }],
6343
- isError: true
6344
- };
6345
- }
6346
- const pagesResultHl = listPages(conn, getTunnelStatus());
6347
- return { content: [{
6348
- type: "text",
6349
- text: `${headlessText}\n\n${JSON.stringify(pagesResultHl, null, 2)}`
6350
- }] };
6351
- }
6352
- if (guiAvailable && qrHttpServer) {
6353
- const browserResult = await openQrInBrowser(qrHttpServer.buildAttachPageUrl(attachUrl), `http://127.0.0.1:${qrHttpServer.port}/qr.png?u=${encodeURIComponent(attachUrl)}`);
6354
- if (browserResult.opened) {
6355
- const retriedNote = browserResult.retried ? " (1회 retry 후 성공)" : "";
6356
- const openResult = {
6357
- attempted: true,
6358
- succeeded: true,
6359
- ...browserResult.retried ? { retried: true } : {}
6360
- };
6361
- const shortText = `${warningPrefix}${header}\n${JSON.stringify({
6362
- relayUrl,
6363
- openResult,
6364
- ...totp ? { totp } : {}
6365
- }, null, 2)}\n\n브라우저에서 QR을 열었습니다${retriedNote}. 폰 카메라로 스캔하세요.\nURL: ${browserResult.httpUrl}`;
6366
- if (!waitForAttach) return { content: [{
6367
- type: "text",
6368
- text: shortText
6369
- }] };
6370
- let attachedPages = [];
6371
- try {
6372
- attachedPages = await waitForAttachWithEvents(conn, isMatchingPage, callTimeoutMs);
6373
- } catch {
6374
- attachedPages = conn.listTargets();
6375
- return {
6376
- content: [{
6377
- type: "text",
6378
- text: buildTimeoutError(shortText, callTimeoutMs / 1e3, attachedPages)
6379
- }],
6380
- isError: true
6381
- };
6382
- }
6383
- const pagesResult = listPages(conn, getTunnelStatus());
6384
- return { content: [{
6385
- type: "text",
6386
- text: `${shortText}\n\n${JSON.stringify(pagesResult, null, 2)}`
6387
- }] };
6388
- }
6389
- const openResult = {
6390
- attempted: true,
6391
- succeeded: false,
6392
- failureReason: browserResult.error ?? "브라우저 실행 후보 모두 실패",
6393
- pngUrl: browserResult.pngUrl,
6394
- ...browserResult.stderrSummary ? { stderrSummary: browserResult.stderrSummary } : {}
6395
- };
6396
- const stderrNote = browserResult.stderrSummary ? `\nstderr: ${browserResult.stderrSummary}` : "";
6397
- const fallbackNote = `브라우저 자동 열기에 실패했습니다. 다음 URL을 직접 브라우저에서 여세요:
6398
- ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stderrNote + "\n\n";
6399
- const qr = await renderQr(attachUrl);
6400
- const baseText = `${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
6401
- attachUrl,
6402
- relayUrl,
6403
- openResult,
6404
- ...totp ? { totp } : {}
6405
- }, null, 2)}\n\n${qr}`;
6406
- if (!waitForAttach) return { content: [{
6407
- type: "text",
6408
- text: baseText
6409
- }] };
6410
- let attachedPagesFb = [];
6411
- try {
6412
- attachedPagesFb = await waitForAttachWithEvents(conn, isMatchingPage, callTimeoutMs);
6413
- } catch {
6414
- attachedPagesFb = conn.listTargets();
6415
- return {
6416
- content: [{
6417
- type: "text",
6418
- text: buildTimeoutError(baseText, callTimeoutMs / 1e3, attachedPagesFb)
6419
- }],
6420
- isError: true
6421
- };
6422
- }
6423
- const pagesResultFb = listPages(conn, getTunnelStatus());
6424
- return { content: [{
6425
- type: "text",
6426
- text: `${baseText}\n\n${JSON.stringify(pagesResultFb, null, 2)}`
6427
- }] };
6428
- }
6429
- const qr = await renderQr(attachUrl);
6430
- const baseText = `${warningPrefix}${header}\n${JSON.stringify({
6431
- attachUrl,
6432
- relayUrl,
6433
- ...totp ? { totp } : {}
6434
- }, null, 2)}\n\n${qr}`;
6435
- if (!waitForAttach) return { content: [{
6436
- type: "text",
6437
- text: baseText
6438
- }] };
6439
- let attachedPages = [];
6440
- try {
6441
- attachedPages = await waitForAttachWithEvents(conn, isMatchingPage, callTimeoutMs);
6442
- } catch {
6443
- attachedPages = conn.listTargets();
6444
- return {
6445
- content: [{
6446
- type: "text",
6447
- text: buildTimeoutError(baseText, callTimeoutMs / 1e3, attachedPages)
6448
- }],
6449
- isError: true
6450
- };
6451
- }
6452
- const pagesResult = listPages(conn, getTunnelStatus());
6453
- return { content: [{
6454
- type: "text",
6455
- text: `${baseText}\n\n${JSON.stringify(pagesResult, null, 2)}`
6456
- }] };
6457
- } catch (err) {
6458
- return errorResult(err, name);
6459
- }
6460
- }
6461
6397
  try {
6462
6398
  await conn.enableDomains();
6463
6399
  } catch (err) {
@@ -6496,7 +6432,7 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
6496
6432
  case "evaluate": {
6497
6433
  const expression = request.params.arguments?.expression;
6498
6434
  if (typeof expression !== "string" || expression === "") return mcpError("evaluate: expression 인자가 비어 있습니다. 평가할 JavaScript 표현식을 전달하세요.");
6499
- if (conn.kind === "relay" && getLiveIntent() && request.params.arguments?.confirm !== true) return liveGuardError("evaluate");
6435
+ if (!connectionHostsAllowed(conn)) return mcpError("evaluate: 현재 연결된 페이지는 debug 허용 호스트가 아닙니다 (#665). 허용 호스트: localhost, *.trycloudflare.com, *.private-apps.tossmini.com.");
6500
6436
  return jsonResult$1(await evaluate(conn, expression));
6501
6437
  }
6502
6438
  case "call_sdk": {
@@ -6504,7 +6440,7 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
6504
6440
  if (typeof sdkName !== "string" || sdkName === "") return mcpError("call_sdk: name 인자가 비어 있습니다. 호출할 SDK 메서드 이름을 전달하세요.");
6505
6441
  const rawArgs = request.params.arguments?.args;
6506
6442
  const sdkArgs = Array.isArray(rawArgs) ? rawArgs : [];
6507
- if (conn.kind === "relay" && getLiveIntent() && request.params.arguments?.confirm !== true) return liveGuardError("call_sdk");
6443
+ if (!connectionHostsAllowed(conn)) return mcpError("call_sdk: 현재 연결된 페이지는 debug 허용 호스트가 아닙니다 (#665). 허용 호스트: localhost, *.trycloudflare.com, *.private-apps.tossmini.com.");
6508
6444
  const sdkResult = await callSdk(conn, sdkName, sdkArgs);
6509
6445
  if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk", conn.kind === "local");
6510
6446
  return envelopeResult$1(sdkResult, name, env, conn.listTargets().length > 0);
@@ -6518,7 +6454,7 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
6518
6454
  const projectRoot = typeof rawRoot === "string" ? rawRoot : process.cwd();
6519
6455
  const rawTimeout = request.params.arguments?.timeout_ms;
6520
6456
  const timeoutMs = typeof rawTimeout === "number" && rawTimeout >= 1e3 && rawTimeout <= 6e5 ? rawTimeout : void 0;
6521
- if (conn.kind === "relay" && getLiveIntent() && request.params.arguments?.confirm !== true) return liveGuardError("run_tests");
6457
+ if (!connectionHostsAllowed(conn)) return mcpError("run_tests: 현재 연결된 페이지는 debug 허용 호스트가 아닙니다 (#665). 허용 호스트: localhost, *.trycloudflare.com, *.private-apps.tossmini.com.");
6522
6458
  if (runTestsInFlight) return mcpError("run_tests: 이미 다른 테스트 실행이 진행 중입니다 (single-attach 모델: 페이지는 한 번에 하나의 실행만 처리). 완료 후 다시 시도하세요.");
6523
6459
  runTestsInFlight = true;
6524
6460
  try {
@@ -6548,24 +6484,52 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
6548
6484
  }
6549
6485
  /**
6550
6486
  * Normalizes a raw `start_debug` `mode` argument to a `StartDebugMode`, or
6551
- * `null` when the value is not one of the four accepted modes:
6552
- * 'local-browser' | 'relay-sandbox' | 'relay-staging' | 'relay-live'
6487
+ * `null` when the value is not one of the three accepted modes:
6488
+ * 'local-browser' | 'relay-sandbox' | 'relay-staging'
6553
6489
  *
6554
6490
  * Hard rename (issue #398): the older `local`/`mobile`/`staging`/`live` names
6555
6491
  * and their aliases are no longer accepted — pre-1.0, no back-compat.
6492
+ * `relay-live` (env 4) removed in #665.
6556
6493
  */
6557
6494
  function normalizeStartDebugMode(raw) {
6558
- if (raw === "local-browser" || raw === "relay-sandbox" || raw === "relay-staging" || raw === "relay-live") return raw;
6495
+ if (raw === "local-browser" || raw === "relay-sandbox" || raw === "relay-staging") return raw;
6559
6496
  return null;
6560
6497
  }
6561
6498
  /**
6499
+ * Positive-allowlist kill-switch for side-effect MCP tools (#665).
6500
+ *
6501
+ * Returns `true` when the connection's attached targets are all on allowed
6502
+ * debug hosts (localhost / trycloudflare / private-apps). Returns `false` when
6503
+ * any target's page URL is on a non-allowed host (e.g. `apps.tossmini.com`).
6504
+ *
6505
+ * For local connections this always returns `true` — the local Chromium is
6506
+ * always on localhost. For relay connections without any pages it returns
6507
+ * `true` (no pages = nothing to block; the caller's page-missing guard fires
6508
+ * first).
6509
+ *
6510
+ * SECRET-HANDLING: hostnames are NEVER logged here — only the boolean result
6511
+ * is returned to the caller.
6512
+ */
6513
+ function connectionHostsAllowed(conn) {
6514
+ if (conn.kind === "local") return true;
6515
+ const pages = conn.listTargets();
6516
+ if (pages.length === 0) return true;
6517
+ return pages.every((p) => {
6518
+ try {
6519
+ return isDebugAllowedHost(new URL(p.url ?? "").hostname);
6520
+ } catch {
6521
+ return false;
6522
+ }
6523
+ });
6524
+ }
6525
+ /**
6562
6526
  * Builds a trivial `ConnectionRouter` pinned to a single connection (issue
6563
6527
  * #348). Used by `createDebugServer` when no real dual router is injected —
6564
6528
  * every existing single-connection test and the `local`-only / `relay`-only
6565
6529
  * boot path. `switchMode` here cannot lazily boot another family, so it only
6566
- * honors a request that matches the connection's own kind (and arms/disarms
6567
- * `liveIntent` accordingly for relay-live); any cross-family request is
6568
- * rejected with a clear "dynamic switch unavailable in this session" error.
6530
+ * honors a request that matches the connection's own kind; any cross-family
6531
+ * request is rejected with a clear "dynamic switch unavailable in this session"
6532
+ * error. `confirm` parameter and `relay-live` gate removed (#665).
6569
6533
  */
6570
6534
  function makeSingleConnectionRouter(connection) {
6571
6535
  return {
@@ -6573,18 +6537,15 @@ function makeSingleConnectionRouter(connection) {
6573
6537
  return connection;
6574
6538
  },
6575
6539
  activeRelayOrigin: void 0,
6576
- switchMode(mode, confirm, _projectRoot) {
6540
+ switchMode(mode, _projectRoot) {
6577
6541
  if (mode === "relay-sandbox") return Promise.reject(/* @__PURE__ */ new Error("start_debug: 이 세션은 단일 연결만 보유합니다 — 'relay-sandbox'(환경 2 PWA, 외부 relay)로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). MCP 서버를 relay-sandbox 모드로 재시작하세요."));
6578
6542
  if (isRelayMode(mode) !== (connection.kind === "relay")) return Promise.reject(/* @__PURE__ */ new Error(`start_debug: 이 세션은 단일 ${connection.kind} 연결만 보유합니다 — '${mode}'로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). MCP 서버를 원하는 모드로 재시작하세요.`));
6579
- if (mode === "relay-live" && !confirm) return Promise.reject(/* @__PURE__ */ new Error("start_debug: relay-live(실서비스 LIVE)는 confirm: true가 필요합니다 — 실유저에게 영향이 갈 수 있는 LIVE 디버깅 진입을 명시적으로 승인하세요."));
6580
- setLiveIntent(mode === "relay-live");
6581
- const environment = deriveEnvironment(connection.kind, getLiveIntent());
6543
+ const environment = deriveEnvironment(connection.kind);
6582
6544
  return Promise.resolve({
6583
6545
  mode,
6584
6546
  environment,
6585
6547
  kind: connection.kind,
6586
- liveGuardActive: connection.kind === "relay" && getLiveIntent(),
6587
- nextStep: connection.kind === "relay" ? "build_attach_url로 attach QR을 생성하세요." : "list_pages로 로컬 페이지 attach를 확인하세요."
6548
+ nextStep: connection.kind === "relay" ? "start_attach로 attach QR 생성 + 폰 attach까지 한 번에 진행하세요." : "list_pages로 로컬 페이지 attach를 확인하세요."
6588
6549
  });
6589
6550
  }
6590
6551
  };
@@ -6599,7 +6560,10 @@ function makeSingleConnectionRouter(connection) {
6599
6560
  function rebuildAttachUrl(parts) {
6600
6561
  const secret = process.env.AIT_DEBUG_TOTP_SECRET;
6601
6562
  const code = secret ? generateTotp(secret) : void 0;
6602
- return parts.kind === "launcher" ? buildLauncherAttachUrl(parts.tunnelHttpUrl, parts.wssUrl, code, { name: parts.appName }) : buildDeepLinkAttachUrl(parts.schemeUrl, parts.wssUrl, code);
6563
+ return parts.kind === "launcher" ? buildLauncherAttachUrl(parts.tunnelHttpUrl, parts.wssUrl, code, {
6564
+ name: parts.appName,
6565
+ ...parts.selfdebug ? { selfdebug: true } : {}
6566
+ }) : buildDeepLinkAttachUrl(parts.schemeUrl, parts.wssUrl, code);
6603
6567
  }
6604
6568
  function jsonResult$1(value) {
6605
6569
  return { content: [{
@@ -6792,8 +6756,9 @@ async function bootLocalFamily() {
6792
6756
  *
6793
6757
  * Booted lazily via the dual router's `bootLazyFor('relay-intoss')` callback
6794
6758
  * (symmetry with {@link bootLocalFamily}), at most once on the first
6795
- * `start_debug({ mode: 'relay-staging' | 'relay-live' })` (all-lazy, #396 — every
6796
- * relay boot now flows through `switchMode` after the project-local secret load).
6759
+ * `start_debug({ mode: 'relay-staging' })` (all-lazy, #396 — every relay boot now
6760
+ * flows through `switchMode` after the project-local secret load). `relay-live`
6761
+ * removed (#665).
6797
6762
  *
6798
6763
  * The relay base URL is only known after `startChiiRelay()` resolves, so the
6799
6764
  * `ChiiCdpConnection` (via {@link createRelayConnection}) is constructed inside
@@ -6880,7 +6845,7 @@ async function bootRelayFamily(options = {}) {
6880
6845
  * we did not start.
6881
6846
  *
6882
6847
  * `getTunnelStatus()` reports `up: true` with a `wssUrl` derived from
6883
- * `relayBaseUrl` (http→ws, https→wss) so the `build_attach_url` gate
6848
+ * `relayBaseUrl` (http→ws, https→wss) so the `start_attach` gate
6884
6849
  * (`up: true && wssUrl !== null`) is satisfied even though we never opened a
6885
6850
  * cloudflared tunnel ourselves.
6886
6851
  *
@@ -6925,14 +6890,14 @@ async function bootExternalRelayFamily(relayBaseUrl, relayLocalUrl) {
6925
6890
  /**
6926
6891
  * Maps a `StartDebugMode` to the {@link FamilyKey} that serves it (issue #378).
6927
6892
  * local-browser → 'local-browser'; relay-sandbox → 'relay-sandbox';
6928
- * relay-staging/relay-live → 'relay-intoss' (the shared physical slot).
6893
+ * relay-staging → 'relay-intoss' (the intoss-private relay slot).
6894
+ * `relay-live` removed (#665).
6929
6895
  */
6930
6896
  function familyKeyForMode(mode) {
6931
6897
  switch (mode) {
6932
6898
  case "local-browser": return "local-browser";
6933
6899
  case "relay-sandbox": return "relay-sandbox";
6934
- case "relay-staging":
6935
- case "relay-live": return "relay-intoss";
6900
+ case "relay-staging": return "relay-intoss";
6936
6901
  }
6937
6902
  }
6938
6903
  /** The error thrown / surfaced when entering `mobile` without AIT_RELAY_BASE_URL. */
@@ -6991,12 +6956,11 @@ const NULL_CDP_CONNECTION = {
6991
6956
  * restarting the MCP server.
6992
6957
  *
6993
6958
  * Why a KEYED map and not a single lazy slot (#378): `relay-sandbox` (env-2
6994
- * external relay) and `relay-staging`/`relay-live` (intoss relay) are BOTH
6995
- * `kind: 'relay'`. A single "opposite-kind" slot could not warm-keep both at
6996
- * once — they would collide. The three `FamilyKey`s
6997
- * (`local-browser` / `relay-intoss` / `relay-sandbox`) give each its own warm
6998
- * slot — `relay-staging` and `relay-live` deliberately share the one
6999
- * `relay-intoss` slot (wire-identical, distinguished only by `liveIntent`).
6959
+ * external relay) and `relay-staging` (intoss relay) are BOTH `kind: 'relay'`.
6960
+ * A single "opposite-kind" slot could not warm-keep both at once — they would
6961
+ * collide. The three `FamilyKey`s (`local-browser` / `relay-intoss` /
6962
+ * `relay-sandbox`) give each its own warm slot. `relay-live` (env 4) removed
6963
+ * (#665) — `relay-intoss` slot now maps only to `relay-staging`.
7000
6964
  *
7001
6965
  * Why all-lazy (#396): the relay TOTP secret now lives in a project-local
7002
6966
  * `.ait_relay` file loaded read-only by `switchMode` BEFORE a relay family boots.
@@ -7006,15 +6970,14 @@ const NULL_CDP_CONNECTION = {
7006
6970
  * `buildRelayVerifyAuth()` run at the boot site.
7007
6971
  *
7008
6972
  * `switchMode`:
7009
- * 1. rejects re-entrant swaps (`swapInFlight`) and an unconfirmed `relay-live`;
6973
+ * 1. rejects re-entrant swaps (`swapInFlight`);
7010
6974
  * 2. resolves the requested mode's `FamilyKey`:
7011
6975
  * `lazyFamilies.get(key) ?? (boot via bootLazyFor(key), store)`;
7012
6976
  * 3. flips `active` (the MCP `Server` never re-handshakes — it reads through
7013
6977
  * `active` per request);
7014
- * 4. sets `liveIntent` (true only for `relay-live`; `relay-sandbox` is dev-intent false);
7015
- * 5. stops the old attach watcher and re-arms one on the new connection
6978
+ * 4. stops the old attach watcher and re-arms one on the new connection
7016
6979
  * (the watcher self-clears, so re-arm is mandatory);
7017
- * 6. emits `tools/list_changed`.
6980
+ * 5. emits `tools/list_changed`.
7018
6981
  *
7019
6982
  * Inactive infra is left WARM — teardown happens only at process exit (the
7020
6983
  * unified shutdown in the run functions), which is what keeps a phone attach
@@ -7060,10 +7023,10 @@ var DualConnectionRouter = class {
7060
7023
  /**
7061
7024
  * Live tunnel status of the active relay family (issues #356, #378). Reads
7062
7025
  * the ACTIVE family's tunnel when it has one (so `relay-sandbox` surfaces the
7063
- * external relay wss and `relay-staging`/`relay-live` the intoss relay wss); otherwise
7026
+ * external relay wss and `relay-staging` the intoss relay wss); otherwise
7064
7027
  * falls back to the first booted family that has a tunnel. Returns "down"
7065
7028
  * until any relay family is booted (any session before the first relay
7066
- * start_debug) — the correct signal for `build_attach_url` (no tunnel yet).
7029
+ * start_debug) — the correct signal for `start_attach` (no tunnel yet).
7067
7030
  */
7068
7031
  relayTunnelStatus() {
7069
7032
  if (this.activeFamily?.getTunnelStatus) return this.activeFamily.getTunnelStatus();
@@ -7098,7 +7061,7 @@ var DualConnectionRouter = class {
7098
7061
  this.deps.onPageAttach?.();
7099
7062
  if (activeFamily.connection.kind === "relay") {
7100
7063
  const firstTarget = activeFamily.connection.listTargets()[0];
7101
- const env = deriveEnvironment(activeFamily.connection.kind, getLiveIntent(), activeFamily.relayOrigin);
7064
+ const env = deriveEnvironment(activeFamily.connection.kind, activeFamily.relayOrigin);
7102
7065
  const inspectorStableUrl = this.deps.getInspectorStableUrl?.() ?? null;
7103
7066
  this.deps.devtoolsOpener.open({
7104
7067
  inspectorStableUrl,
@@ -7156,25 +7119,22 @@ var DualConnectionRouter = class {
7156
7119
  this.lazyFamilies.set(key, booted);
7157
7120
  return booted;
7158
7121
  }
7159
- async switchMode(mode, confirm, projectRoot) {
7122
+ async switchMode(mode, projectRoot) {
7160
7123
  if (this.swapInFlight) throw new Error("start_debug: 이전 전환이 아직 진행 중입니다 — 잠시 후 다시 호출하세요.");
7161
- if (mode === "relay-live" && !confirm) throw new Error("start_debug: relay-live(실서비스 LIVE)는 confirm: true가 필요합니다 — 실유저에게 영향이 갈 수 있는 LIVE 디버깅 진입을 명시적으로 승인하세요.");
7162
7124
  this.swapInFlight = true;
7163
7125
  try {
7164
7126
  if (isRelayMode(mode)) await loadRelaySecretReadOnly({ projectRoot });
7165
7127
  const target = await this.familyFor(familyKeyForMode(mode), projectRoot);
7166
7128
  this.activeFamily = target;
7167
- setLiveIntent(mode === "relay-live");
7168
7129
  this.stopWatcher();
7169
7130
  this.armWatcher();
7170
7131
  this.server?.sendToolListChanged();
7171
7132
  const wantRelay = isRelayMode(mode);
7172
7133
  return {
7173
7134
  mode,
7174
- environment: deriveEnvironment(target.connection.kind, getLiveIntent(), target.relayOrigin),
7135
+ environment: deriveEnvironment(target.connection.kind, target.relayOrigin),
7175
7136
  kind: target.connection.kind,
7176
- liveGuardActive: target.connection.kind === "relay" && getLiveIntent(),
7177
- nextStep: wantRelay ? "build_attach_url로 attach QR을 생성하세요 (relay 세션)." : "list_pages로 로컬 Chromium 페이지 attach를 확인하세요."
7137
+ nextStep: wantRelay ? "start_attach로 attach QR 생성 + 폰 attach까지 한 번에 진행하세요 (relay 세션)." : "list_pages로 로컬 Chromium 페이지 attach를 확인하세요."
7178
7138
  };
7179
7139
  } finally {
7180
7140
  this.swapInFlight = false;
@@ -7233,7 +7193,7 @@ async function runDebugServer(options = {}) {
7233
7193
  })),
7234
7194
  attachUrl: lastAttachParts ? rebuildAttachUrl(lastAttachParts) : null,
7235
7195
  inspectorUrl,
7236
- mode: deriveEnvironment(router.active.kind, getLiveIntent(), router.activeRelayOrigin)
7196
+ mode: deriveEnvironment(router.active.kind, router.activeRelayOrigin)
7237
7197
  };
7238
7198
  };
7239
7199
  const getDirectInspectorUrl = () => {
@@ -7365,7 +7325,7 @@ async function runDebugServer(options = {}) {
7365
7325
  * 1. `start_debug({ mode: 'local-browser' })` launches a local Chromium with
7366
7326
  * `--remote-debugging-port=<port>` and attaches a `LocalCdpConnection`;
7367
7327
  * 2. the intoss/external relay families lazy-boot on the first
7368
- * `start_debug({ mode: 'relay-staging' | 'relay-live' | 'relay-sandbox' })`;
7328
+ * `start_debug({ mode: 'relay-staging' | 'relay-sandbox' })` (#665: relay-live removed);
7369
7329
  * 3. all of this runs through the SAME direction-neutral
7370
7330
  * `DualConnectionRouter` that `runDebugServer` uses (issue #356).
7371
7331
  *
@@ -7377,7 +7337,7 @@ async function runDebugServer(options = {}) {
7377
7337
  * env 1 (local), then env 3 (intoss-private) in ONE session, no restart" — now
7378
7338
  * works from either entry point.
7379
7339
  *
7380
- * `build_attach_url` (relay-specific) stays effectively hidden / non-applicable
7340
+ * `start_attach` (relay-specific) stays effectively hidden / non-applicable
7381
7341
  * until the relay family is booted: before the first relay switch the env
7382
7342
  * derives to `mock` and `relayTunnelStatus()` reports "down", so the tool fails
7383
7343
  * with a clear "tunnel not up" message. After a relay switch the relay tunnel
@@ -7588,10 +7548,9 @@ async function runLocalDebugServer(options = {}) {
7588
7548
  * Symmetry with `runDebugServer` / `runLocalDebugServer` (#356, #378, #396): all
7589
7549
  * three families are lazy-booted — the env-2 external relay on the first
7590
7550
  * `start_debug({ mode: 'relay-sandbox' })`, the local family on `local-browser`,
7591
- * the intoss relay on `relay-staging`/`relay-live` — so a `--target=mobile`
7592
- * session can hot-switch
7593
- * without a restart. The active env derives to `relay-mobile` (external-PWA
7594
- * origin, liveIntent off).
7551
+ * the intoss relay on `relay-staging` (#665: relay-live removed) — so a
7552
+ * `--target=mobile` session can hot-switch without a restart. The active env
7553
+ * derives to `relay-mobile` (external-PWA origin).
7595
7554
  *
7596
7555
  * SECRET-HANDLING: `AIT_RELAY_BASE_URL` is read once here via
7597
7556
  * {@link readMobileRelayBaseUrl}; when unset it throws
@@ -7972,25 +7931,30 @@ const DEV_TOOL_DEFINITIONS = [
7972
7931
  availableIn: "both"
7973
7932
  },
7974
7933
  {
7975
- name: "build_attach_url",
7976
- 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",
7934
+ name: "start_attach",
7935
+ description: "Switches into a relay mode (if given), builds a self-attaching deep-link QR for a real device, and waits for the phone to attach — all in one call. 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 start_attach to generate the QR for phone scanning. See: https://docs.aitc.dev/guides/debug-relay",
7977
7936
  inputSchema: {
7978
7937
  type: "object",
7979
7938
  properties: {
7980
- scheme_url: {
7939
+ mode: {
7981
7940
  type: "string",
7982
- description: "The intoss-private:// URL from `ait deploy --scheme-only`."
7941
+ enum: [
7942
+ "local-browser",
7943
+ "relay-sandbox",
7944
+ "relay-staging"
7945
+ ],
7946
+ description: "Optional relay mode to switch into before attaching."
7983
7947
  },
7984
- wait_for_attach: {
7985
- type: "boolean",
7986
- description: "If true, block until a page attaches (default 60 s)."
7948
+ scheme_url: {
7949
+ type: "string",
7950
+ description: "The intoss-private:// URL from `ait deploy --scheme-only` (env 3/relay-staging)."
7987
7951
  },
7988
7952
  wait_timeout_seconds: {
7989
7953
  type: "number",
7990
- description: "Maximum seconds to wait when wait_for_attach=true (default 60, range 1–600). Invalid inputs fall back to default."
7954
+ description: "Maximum seconds to wait for a page to attach (default 60, range 1–600). Invalid inputs fall back to default."
7991
7955
  }
7992
7956
  },
7993
- required: ["scheme_url"]
7957
+ required: []
7994
7958
  },
7995
7959
  availableIn: "relay"
7996
7960
  },
@@ -8088,10 +8052,6 @@ const DEV_TOOL_DEFINITIONS = [
8088
8052
  timeout_ms: {
8089
8053
  type: "number",
8090
8054
  description: "Per-file evaluate timeout in ms."
8091
- },
8092
- confirm: {
8093
- type: "boolean",
8094
- description: "Required in relay-live sessions."
8095
8055
  }
8096
8056
  },
8097
8057
  required: ["files"]
@@ -8117,7 +8077,7 @@ const CDP_ONLY_TOOL_NAMES = new Set([
8117
8077
  * Listed in dev-mode tool surface (issue #323) so agents get a hand-off hint
8118
8078
  * toward `--mode=debug` instead of "Unknown tool".
8119
8079
  */
8120
- const TIER_B_TOOL_NAMES = new Set(["build_attach_url"]);
8080
+ const TIER_B_TOOL_NAMES = new Set(["start_attach"]);
8121
8081
  /**
8122
8082
  * Builds the `list_pages` dev-mode shim response.
8123
8083
  * Returns the Vite dev URL as a single-entry page list with `devMode: true`.
@@ -8227,7 +8187,7 @@ function createDevServer(deps = {}) {
8227
8187
  const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
8228
8188
  const server = new Server({
8229
8189
  name: "ait-devtools",
8230
- version: "0.1.109"
8190
+ version: "0.1.110"
8231
8191
  }, { capabilities: { tools: {} } });
8232
8192
  server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
8233
8193
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -8302,7 +8262,7 @@ async function runDevServer() {
8302
8262
  *
8303
8263
  * --mode=debug (default)
8304
8264
  * --target=relay (default) — CDP/Chii relay + cloudflared quick tunnel.
8305
- * Attach a running mini-app (real Toss WebView, env 3/4) and read its
8265
+ * Attach a running mini-app (real Toss WebView, env 3) and read its
8306
8266
  * console + network over CDP without a human watching a phone.
8307
8267
  * --target=local — CDP direct-attach to a local Chromium launched by the
8308
8268
  * MCP server (env 1). No relay or tunnel; the browser is launched
@@ -8318,26 +8278,12 @@ async function runDevServer() {
8318
8278
  * Back-compat (issue #348): the legacy `--mode`/`--target` flags and `MCP_ENV`
8319
8279
  * still work. `--target=relay`/`local` select the initial active connection;
8320
8280
  * the in-session `start_debug(mode)` MCP tool can then flip between them with no
8321
- * restart. `MCP_ENV=relay-live` seeds LIVE intent at boot (deprecated alias);
8322
- * `MCP_ENV=mock|relay|relay-dev` are accepted and ignored for env derivation
8323
- * (the active connection's `kind` is authoritative).
8281
+ * restart. `MCP_ENV` values are accepted and ignored (the active connection's
8282
+ * `kind` is authoritative; `relay-live` and `liveIntent` are removed, #665).
8324
8283
  *
8325
8284
  * Node-only stdio process.
8326
8285
  */
8327
8286
  /**
8328
- * Seeds the module-level `liveIntent` bit from the deprecated `MCP_ENV` alias
8329
- * (issue #348). `MCP_ENV=relay-live` is the only value that matters now — it
8330
- * arms LIVE intent at boot so a session launched straight into env 4 has the
8331
- * guard active without a `start_debug({ mode: 'relay-live' })` round-trip. All
8332
- * other `MCP_ENV` values (`mock`, `relay`, `relay-dev`) are accepted-and-ignored
8333
- * for env derivation — the active connection's `kind` is authoritative.
8334
- *
8335
- * SECRET-HANDLING: reads only the env-var string; never logs a secret.
8336
- */
8337
- function seedLiveIntentFromEnv(env = process.env) {
8338
- if (env.MCP_ENV === "relay-live") setLiveIntent(true);
8339
- }
8340
- /**
8341
8287
  * Returns `true` when `--force` or `--takeover` is present in argv.
8342
8288
  *
8343
8289
  * Both flags are accepted as aliases — `--force` is the short form listed in
@@ -8394,7 +8340,6 @@ function normalizeTarget(value) {
8394
8340
  }
8395
8341
  async function main() {
8396
8342
  const args = process.argv.slice(2);
8397
- seedLiveIntentFromEnv();
8398
8343
  if (parseMode(args) === "dev") await runDevServer();
8399
8344
  else {
8400
8345
  const target = parseTarget(args);
@@ -8429,6 +8374,6 @@ if (isEntrypoint()) main().catch((err) => {
8429
8374
  process.exitCode = 1;
8430
8375
  });
8431
8376
  //#endregion
8432
- export { parseForce, parseMode, parseTarget, seedLiveIntentFromEnv };
8377
+ export { parseForce, parseMode, parseTarget };
8433
8378
 
8434
8379
  //# sourceMappingURL=cli.js.map