@ait-co/devtools 0.1.54 → 0.1.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +64 -17
- package/README.md +63 -16
- package/dist/chii-relay-57BfqF_5.cjs +88 -0
- package/dist/chii-relay-57BfqF_5.cjs.map +1 -0
- package/dist/chii-relay-itXOz7kS.js +89 -0
- package/dist/chii-relay-itXOz7kS.js.map +1 -0
- package/dist/in-app/index.d.ts +45 -12
- package/dist/in-app/index.d.ts.map +1 -1
- package/dist/in-app/index.js +38 -7
- package/dist/in-app/index.js.map +1 -1
- package/dist/mcp/cli.d.ts +8 -3
- package/dist/mcp/cli.d.ts.map +1 -1
- package/dist/mcp/cli.js +782 -226
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.js +18 -13
- package/dist/mcp/server.js.map +1 -1
- package/dist/mock/index.d.ts.map +1 -1
- package/dist/mock/index.js +18 -2
- package/dist/mock/index.js.map +1 -1
- package/dist/panel/index.js +66 -4
- package/dist/panel/index.js.map +1 -1
- package/dist/totp-BkKP4m8H.cjs +185 -0
- package/dist/totp-BkKP4m8H.cjs.map +1 -0
- package/dist/totp-CxHsagqY.js +184 -0
- package/dist/totp-CxHsagqY.js.map +1 -0
- package/dist/{tunnel-D0_TwDNE.js → tunnel-Cj8g1LIL.js} +12 -4
- package/dist/tunnel-Cj8g1LIL.js.map +1 -0
- package/dist/{tunnel-BYP0yRBN.cjs → tunnel-p-q6eVWT.cjs} +12 -4
- package/dist/tunnel-p-q6eVWT.cjs.map +1 -0
- package/dist/unplugin/index.cjs +29 -3
- package/dist/unplugin/index.cjs.map +1 -1
- package/dist/unplugin/index.d.cts +11 -0
- package/dist/unplugin/index.d.cts.map +1 -1
- package/dist/unplugin/index.d.ts +12 -1
- package/dist/unplugin/index.d.ts.map +1 -1
- package/dist/unplugin/index.js +29 -3
- package/dist/unplugin/index.js.map +1 -1
- package/dist/unplugin/tunnel.cjs +11 -3
- package/dist/unplugin/tunnel.cjs.map +1 -1
- package/dist/unplugin/tunnel.d.cts +13 -1
- package/dist/unplugin/tunnel.d.cts.map +1 -1
- package/dist/unplugin/tunnel.d.ts +13 -1
- package/dist/unplugin/tunnel.d.ts.map +1 -1
- package/dist/unplugin/tunnel.js +11 -3
- package/dist/unplugin/tunnel.js.map +1 -1
- package/package.json +2 -2
- package/dist/tunnel-BYP0yRBN.cjs.map +0 -1
- package/dist/tunnel-D0_TwDNE.js.map +0 -1
package/dist/mcp/cli.js
CHANGED
|
@@ -718,6 +718,157 @@ async function startChiiRelay(options = {}) {
|
|
|
718
718
|
};
|
|
719
719
|
}
|
|
720
720
|
//#endregion
|
|
721
|
+
//#region src/mcp/deeplink.ts
|
|
722
|
+
/**
|
|
723
|
+
* URL of the AITC Sandbox launcher PWA.
|
|
724
|
+
*
|
|
725
|
+
* Declared here (not imported from `src/unplugin/tunnel.ts`) to respect the
|
|
726
|
+
* mcp → unplugin layering boundary. unplugin/tunnel.ts declares its own copy
|
|
727
|
+
* for the same reason — keep the two in sync when the URL changes.
|
|
728
|
+
*/
|
|
729
|
+
const LAUNCHER_URL = "https://devtools.aitc.dev/launcher/";
|
|
730
|
+
/**
|
|
731
|
+
* Builds a launcher PWA deep-link for env-2 MCP-attach (issue #378).
|
|
732
|
+
*
|
|
733
|
+
* The launcher at {@link LAUNCHER_URL} renders tunnelUrl in a full-viewport
|
|
734
|
+
* iframe. `&debug=1&relay=<wssUrl>` is forwarded onto the iframe src so the
|
|
735
|
+
* framed page's in-app debug gate (Layer C) is satisfied and a Chii target.js
|
|
736
|
+
* is injected. `&at=<totpCode>` is added only when a code is provided (same
|
|
737
|
+
* conditional as {@link buildDeepLinkAttachUrl}).
|
|
738
|
+
*
|
|
739
|
+
* Unlike `buildDeepLinkAttachUrl` (which splices onto a non-special scheme URL
|
|
740
|
+
* via raw string manipulation), this function uses WHATWG `encodeURIComponent`
|
|
741
|
+
* because the target is a standard `https:` URL.
|
|
742
|
+
*
|
|
743
|
+
* SECRET-HANDLING: `totpCode` (when provided) is placed into the `at=` param
|
|
744
|
+
* only — never logged or returned separately. Callers must NOT log the result
|
|
745
|
+
* of this function to stdout/stderr.
|
|
746
|
+
*
|
|
747
|
+
* @param tunnelUrl - The `https://*.trycloudflare.com` app tunnel URL
|
|
748
|
+
* (`AIT_TUNNEL_BASE_URL`). This is the URL the launcher frames.
|
|
749
|
+
* @param wssUrl - The `wss://` relay URL the framed page will attach to.
|
|
750
|
+
* @param totpCode - Optional current TOTP code (6 digits). When provided, it
|
|
751
|
+
* is appended as `at=<totpCode>`. Must be computed at call time — it rotates
|
|
752
|
+
* every 30 s. Omit when TOTP is disabled.
|
|
753
|
+
* @returns The launcher deep-link URL with `?url=<enc>&debug=1&relay=<enc>
|
|
754
|
+
* [&at=<code>]` params.
|
|
755
|
+
*/
|
|
756
|
+
function buildLauncherAttachUrl(tunnelUrl, wssUrl, totpCode) {
|
|
757
|
+
let url = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}&debug=1&relay=${encodeURIComponent(wssUrl)}`;
|
|
758
|
+
if (totpCode !== void 0 && totpCode !== "") url += `&at=${encodeURIComponent(totpCode)}`;
|
|
759
|
+
return url;
|
|
760
|
+
}
|
|
761
|
+
/**
|
|
762
|
+
* Build a self-attaching dogfood deep link.
|
|
763
|
+
*
|
|
764
|
+
* `ait deploy --scheme-only` prints an `intoss-private://…?_deploymentId=<uuid>`
|
|
765
|
+
* URL that opens a dogfood bundle on a phone. The in-app debug gate
|
|
766
|
+
* (`src/in-app/gate.ts`) auto-attaches when the entry URL also carries
|
|
767
|
+
* `debug=1` and `relay=<wss-url>`. This helper splices those params (plus
|
|
768
|
+
* `at=<code>` when TOTP is enabled) into the scheme URL; rendering the result
|
|
769
|
+
* as a QR code and scanning it with the phone camera opens the mini-app and
|
|
770
|
+
* attaches it to the live Chii relay. QR is the single entry path — it needs
|
|
771
|
+
* no USB cable, platform CLI, or driver, and works the same on iOS/Android.
|
|
772
|
+
*
|
|
773
|
+
* The Toss app propagates extra query params from the entry deep link into the
|
|
774
|
+
* mini-app WebView's `location.search` (confirmed behavior), so the gate reads
|
|
775
|
+
* them at attach time.
|
|
776
|
+
*
|
|
777
|
+
* TOTP `at=` param:
|
|
778
|
+
* When a TOTP secret is active, `buildDeepLinkAttachUrl` accepts an optional
|
|
779
|
+
* `totpCode` argument and splices `at=<code>` alongside `debug` and `relay`.
|
|
780
|
+
* The code must be computed by the caller at call time — do NOT pre-compute
|
|
781
|
+
* and cache it, because the 30-second window expires quickly. The in-app gate
|
|
782
|
+
* (`src/in-app/gate.ts` Layer C) validates this code against the baked secret.
|
|
783
|
+
*
|
|
784
|
+
* Why not `URL`/`URLSearchParams`: `intoss-private:` is a non-special scheme.
|
|
785
|
+
* The WHATWG `URL` parser treats such schemes opaquely (no host/path/query
|
|
786
|
+
* decomposition you can rely on across runtimes), so query manipulation via
|
|
787
|
+
* `url.searchParams` is not portable here. We splice the query string directly
|
|
788
|
+
* on the raw string instead, which keeps the scheme, authority, path, and any
|
|
789
|
+
* pre-existing params (notably `_deploymentId`) byte-for-byte intact.
|
|
790
|
+
*/
|
|
791
|
+
/**
|
|
792
|
+
* Suspicious/generic authority values that indicate a malformed or placeholder
|
|
793
|
+
* scheme URL. These are host strings that will almost certainly cause the Toss
|
|
794
|
+
* app to fail with "bundle not found" silently.
|
|
795
|
+
*
|
|
796
|
+
* The expected form from `ait deploy --scheme-only` is:
|
|
797
|
+
* intoss-private://<appName>?_deploymentId=<uuid>
|
|
798
|
+
* where `<appName>` is a non-generic string like `aitc-sdk-example`.
|
|
799
|
+
*/
|
|
800
|
+
const SUSPICIOUS_AUTHORITIES = new Set([
|
|
801
|
+
"",
|
|
802
|
+
"web",
|
|
803
|
+
"localhost",
|
|
804
|
+
"127.0.0.1",
|
|
805
|
+
"app"
|
|
806
|
+
]);
|
|
807
|
+
/**
|
|
808
|
+
* Validates the authority (host) portion of a scheme URL.
|
|
809
|
+
*
|
|
810
|
+
* Returns a warning message if the authority is missing or looks like a
|
|
811
|
+
* placeholder, or `null` if the authority looks valid.
|
|
812
|
+
*
|
|
813
|
+
* Expected form: `intoss-private://<appName>?_deploymentId=<uuid>`
|
|
814
|
+
* The authority must be a non-empty, non-generic app name (e.g. `aitc-sdk-example`).
|
|
815
|
+
*/
|
|
816
|
+
function validateSchemeAuthority(schemeUrl) {
|
|
817
|
+
const afterScheme = schemeUrl.replace(/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//, "");
|
|
818
|
+
if (afterScheme === schemeUrl) return "scheme_url does not look like a scheme URL (expected `intoss-private://<appName>?_deploymentId=<uuid>`). Use the URL printed by `ait deploy --scheme-only`.";
|
|
819
|
+
const authorityEnd = afterScheme.search(/[/?#]/);
|
|
820
|
+
const authority = authorityEnd === -1 ? afterScheme : afterScheme.slice(0, authorityEnd);
|
|
821
|
+
if (SUSPICIOUS_AUTHORITIES.has(authority.toLowerCase())) return `scheme_url authority ${authority === "" ? "(empty)" : `"${authority}"`} looks like a placeholder. Expected an app name like \`intoss-private://aitc-sdk-example?_deploymentId=<uuid>\`. Use the URL printed by \`ait deploy --scheme-only\` — it includes the correct app name as the host.`;
|
|
822
|
+
return null;
|
|
823
|
+
}
|
|
824
|
+
function stripExisting(query, key) {
|
|
825
|
+
if (query === "") return "";
|
|
826
|
+
return query.split("&").filter((pair) => pair !== "" && pair.split("=")[0] !== key).join("&");
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Splices `debug=1`, `relay=<wssUrl>`, and (optionally) `at=<totpCode>` into a
|
|
830
|
+
* scheme URL's query string, preserving everything else (scheme, authority,
|
|
831
|
+
* path, hash, and the existing `_deploymentId` param). If any of the spliced
|
|
832
|
+
* params is already present it is replaced so the helper is idempotent.
|
|
833
|
+
*
|
|
834
|
+
* @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL printed
|
|
835
|
+
* by `ait deploy --scheme-only`. Must already carry `_deploymentId` (Layer B
|
|
836
|
+
* of the gate); this helper does not invent one.
|
|
837
|
+
* @param wssUrl - The live relay URL (`wss://…trycloudflare.com`) from the
|
|
838
|
+
* running debug MCP server's quick tunnel.
|
|
839
|
+
* @param totpCode - Optional current TOTP code (6 digits). When provided, it
|
|
840
|
+
* is spliced as `at=<totpCode>`. Must be computed at call time — it rotates
|
|
841
|
+
* every 30 s. Pass `undefined` or omit when TOTP is disabled.
|
|
842
|
+
* @returns The same URL with `debug=1&relay=<encoded wssUrl>[&at=<totpCode>]`
|
|
843
|
+
* appended.
|
|
844
|
+
* @throws If `wssUrl` is not a `wss:` URL (the gate rejects anything else, so
|
|
845
|
+
* producing such a link would be a silent dead end).
|
|
846
|
+
*/
|
|
847
|
+
function buildDeepLinkAttachUrl(schemeUrl, wssUrl, totpCode) {
|
|
848
|
+
let relay;
|
|
849
|
+
try {
|
|
850
|
+
relay = new URL(wssUrl);
|
|
851
|
+
} catch {
|
|
852
|
+
throw new Error(`relay URL is not a valid URL: ${wssUrl}`);
|
|
853
|
+
}
|
|
854
|
+
if (relay.protocol !== "wss:") throw new Error(`relay URL must use the wss: scheme, got ${relay.protocol} (${wssUrl})`);
|
|
855
|
+
const hashIndex = schemeUrl.indexOf("#");
|
|
856
|
+
const hash = hashIndex === -1 ? "" : schemeUrl.slice(hashIndex);
|
|
857
|
+
const beforeHash = hashIndex === -1 ? schemeUrl : schemeUrl.slice(0, hashIndex);
|
|
858
|
+
const queryIndex = beforeHash.indexOf("?");
|
|
859
|
+
const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex);
|
|
860
|
+
let query = queryIndex === -1 ? "" : beforeHash.slice(queryIndex + 1);
|
|
861
|
+
const appended = [["debug", "1"], ["relay", wssUrl]];
|
|
862
|
+
if (totpCode !== void 0 && totpCode !== "") appended.push(["at", totpCode]);
|
|
863
|
+
query = stripExisting(query, "at");
|
|
864
|
+
for (const [key] of appended) query = stripExisting(query, key);
|
|
865
|
+
for (const [key, value] of appended) {
|
|
866
|
+
const pair = `${key}=${encodeURIComponent(value)}`;
|
|
867
|
+
query = query === "" ? pair : `${query}&${pair}`;
|
|
868
|
+
}
|
|
869
|
+
return `${base}?${query}${hash}`;
|
|
870
|
+
}
|
|
871
|
+
//#endregion
|
|
721
872
|
//#region src/mcp/devtools-opener.ts
|
|
722
873
|
/**
|
|
723
874
|
* Base URL for the Chrome DevTools inspector hosted on appspot.
|
|
@@ -897,15 +1048,25 @@ function wrapEnvelope(data, ctx) {
|
|
|
897
1048
|
//#endregion
|
|
898
1049
|
//#region src/mcp/environment.ts
|
|
899
1050
|
/**
|
|
900
|
-
* Returns `true` when the environment is any relay variant (`relay-dev
|
|
901
|
-
* `relay-live`). Use this instead of `env === 'relay'` for
|
|
1051
|
+
* Returns `true` when the environment is any relay variant (`relay-dev`,
|
|
1052
|
+
* `relay-live`, or `relay-mobile`). Use this instead of `env === 'relay'` for
|
|
1053
|
+
* tier checks — every relay env surfaces the Tier B / relay-only tool set.
|
|
1054
|
+
*
|
|
1055
|
+
* Written as an exhaustive switch so a future `McpEnvironment` member that is
|
|
1056
|
+
* missing an arm is a TS compile error rather than a silent `false`.
|
|
902
1057
|
*/
|
|
903
1058
|
function isRelayEnv(env) {
|
|
904
|
-
|
|
1059
|
+
switch (env) {
|
|
1060
|
+
case "relay-dev":
|
|
1061
|
+
case "relay-live":
|
|
1062
|
+
case "relay-mobile": return true;
|
|
1063
|
+
case "mock": return false;
|
|
1064
|
+
}
|
|
905
1065
|
}
|
|
906
1066
|
/**
|
|
907
1067
|
* Returns `true` when the environment is the LIVE relay (`relay-live`).
|
|
908
|
-
* This is the guard condition for side-effect tool protection.
|
|
1068
|
+
* This is the guard condition for side-effect tool protection. `relay-mobile`
|
|
1069
|
+
* is a dev-intent env (env 2 PWA) and is NOT live.
|
|
909
1070
|
*/
|
|
910
1071
|
function isLiveRelayEnv(env) {
|
|
911
1072
|
return env === "relay-live";
|
|
@@ -913,25 +1074,38 @@ function isLiveRelayEnv(env) {
|
|
|
913
1074
|
/**
|
|
914
1075
|
* Maps the `McpEnvironment` union to the legacy two-value union
|
|
915
1076
|
* (`'mock' | 'relay'`) for backward-compatible fields in diagnostics output.
|
|
1077
|
+
* Every relay variant (incl. `relay-mobile`) collapses to `'relay'`.
|
|
916
1078
|
*/
|
|
917
1079
|
function toLegacyEnv(env) {
|
|
918
1080
|
if (env === "mock") return "mock";
|
|
919
1081
|
return "relay";
|
|
920
1082
|
}
|
|
921
1083
|
/**
|
|
922
|
-
* Reconstructs the
|
|
923
|
-
* orthogonal signals (
|
|
1084
|
+
* Reconstructs the four-value `McpEnvironment` output string from the
|
|
1085
|
+
* orthogonal signals (issues #348, #378):
|
|
1086
|
+
*
|
|
1087
|
+
* - `kind === 'local'` → `'mock'`
|
|
1088
|
+
* - `kind === 'relay'` && liveIntent → `'relay-live'`
|
|
1089
|
+
* - `kind === 'relay'` && !liveIntent && origin 'external-pwa' → `'relay-mobile'`
|
|
1090
|
+
* - `kind === 'relay'` && !liveIntent && origin intoss/undefined → `'relay-dev'`
|
|
924
1091
|
*
|
|
925
|
-
*
|
|
926
|
-
*
|
|
927
|
-
*
|
|
1092
|
+
* `relayOrigin` is the booted-family discriminator (NOT sniffed from the URL)
|
|
1093
|
+
* that distinguishes the env-2 external-PWA relay (`relay-mobile`) from the
|
|
1094
|
+
* intoss-private dogfood relay (`relay-dev`); both are `kind: 'relay'`.
|
|
928
1095
|
*
|
|
929
1096
|
* Pure — used at every output boundary (envelope `meta.env`, `get_diagnostics`,
|
|
930
1097
|
* `measure_safe_area` provenance) so the surface never sniffs a URL again.
|
|
1098
|
+
*
|
|
1099
|
+
* Written switch-style so a missing arm is a TS compile error (never falls
|
|
1100
|
+
* through to a default).
|
|
931
1101
|
*/
|
|
932
|
-
function deriveEnvironment(kind, liveIntent) {
|
|
933
|
-
|
|
934
|
-
|
|
1102
|
+
function deriveEnvironment(kind, liveIntent, relayOrigin) {
|
|
1103
|
+
switch (kind) {
|
|
1104
|
+
case "local": return "mock";
|
|
1105
|
+
case "relay":
|
|
1106
|
+
if (liveIntent) return "relay-live";
|
|
1107
|
+
return relayOrigin === "external-pwa" ? "relay-mobile" : "relay-dev";
|
|
1108
|
+
}
|
|
935
1109
|
}
|
|
936
1110
|
/**
|
|
937
1111
|
* Module-level `relay-dev` vs `relay-live` intent bit (issue #348).
|
|
@@ -1010,12 +1184,23 @@ function pageCrashError(toolName) {
|
|
|
1010
1184
|
return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 crash됐습니다. 토스 앱을 재실행한 뒤 build_attach_url → QR 스캔으로 재attach하세요.`);
|
|
1011
1185
|
}
|
|
1012
1186
|
/**
|
|
1013
|
-
* 상태 4: SDK 부재 — window.__sdkCall이 주입되지
|
|
1014
|
-
*
|
|
1015
|
-
* call_sdk 호출 시 브리지가 없을 때.
|
|
1187
|
+
* 상태 4: SDK 부재 — window.__sdkCall이 주입되지 않았다.
|
|
1188
|
+
*
|
|
1189
|
+
* call_sdk 호출 시 브리지가 없을 때. 같은 "브리지 부재"라도 다음 행동은
|
|
1190
|
+
* connection 종류에 따라 정반대다 (issue #360):
|
|
1191
|
+
* - relay(`--target` 없는 intoss / env-2): dogfood 빌드가 아니다 → dogfood
|
|
1192
|
+
* 채널로 재배포 후 QR 재스캔.
|
|
1193
|
+
* - local(`--target=local`, env 1 로컬 브라우저): 재배포가 아니라 dev 서버를
|
|
1194
|
+
* `pnpm dev`로 띄웠는지 + unplugin alias가 `@apps-in-toss/web-framework`를
|
|
1195
|
+
* devtools mock으로 resolve하는지 확인. dev 빌드면 `import.meta.env.DEV`
|
|
1196
|
+
* 경로로 `window.__sdkCall`이 자동 설치된다.
|
|
1197
|
+
*
|
|
1198
|
+
* `isLocal`이 생략되면 relay 안내(이전 동작)를 유지한다.
|
|
1016
1199
|
*/
|
|
1017
|
-
function sdkAbsentError(toolName) {
|
|
1018
|
-
|
|
1200
|
+
function sdkAbsentError(toolName, isLocal = false) {
|
|
1201
|
+
const prefix = toolName ? `${toolName}: ` : "";
|
|
1202
|
+
if (isLocal) return mcpError(`${prefix}window.__sdkCall이 주입되지 않았습니다 (로컬 dev 브리지 부재). sdk-example을 \`pnpm dev\`로 띄웠는지, 그리고 unplugin alias가 \`@apps-in-toss/web-framework\`를 devtools mock으로 resolve하는지 확인하세요. dev 빌드(\`import.meta.env.DEV\`)면 \`window.__sdkCall\`이 자동 설치됩니다.`);
|
|
1203
|
+
return mcpError(`${prefix}window.__sdkCall이 주입되지 않았습니다 (dogfood 빌드가 아닙니다). dogfood 채널(intoss-private)로 재배포 후 QR을 다시 스캔하세요: \`ait build && aitcc app deploy\`.`);
|
|
1019
1204
|
}
|
|
1020
1205
|
/**
|
|
1021
1206
|
* relay-live 환경에서 side-effect 도구(`call_sdk`, `evaluate`)를 `confirm: true`
|
|
@@ -1048,10 +1233,10 @@ function relayDisconnectError(toolName) {
|
|
|
1048
1233
|
* - 연결 끊김 패턴 (`relay에 연결되어 있지 않습니다`, `relay WebSocket`) → relayDisconnectError
|
|
1049
1234
|
* - 그 외 (일반 에러) → 원본 메시지를 포함한 mcpError
|
|
1050
1235
|
*/
|
|
1051
|
-
function classifyToolError(err, toolName) {
|
|
1236
|
+
function classifyToolError(err, toolName, isLocal = false) {
|
|
1052
1237
|
const message = err instanceof Error ? err.message : String(err);
|
|
1053
1238
|
if (message.startsWith("tunnel-down:") || message.includes("터널이 안 떠 있습니다")) return tunnelDownError();
|
|
1054
|
-
if (message.startsWith("sdk-absent:") || message.includes("__sdkCall이 주입되지 않았습니다") || message.includes("window.__sdkCall is not available") || message.includes("__sdkCall") && message.includes("not available")) return sdkAbsentError(toolName);
|
|
1239
|
+
if (message.startsWith("sdk-absent:") || message.includes("__sdkCall이 주입되지 않았습니다") || message.includes("window.__sdkCall is not available") || message.includes("__sdkCall") && message.includes("not available")) return sdkAbsentError(toolName, isLocal);
|
|
1055
1240
|
if (message.includes("replaced-by-new-attach") || message.includes("targetCrashed") || message.includes("targetDestroyed") || message.includes("detachedFromTarget")) return pageCrashError(toolName);
|
|
1056
1241
|
if (message.includes("relay에 연결되어 있지 않습니다") || message.includes("relay WebSocket")) return relayDisconnectError(toolName);
|
|
1057
1242
|
return mcpError(`${toolName} 실패: ${message}\nlist_pages로 미니앱이 relay에 attach됐는지 확인하세요.`);
|
|
@@ -1744,118 +1929,6 @@ function acquireLock(options = {}) {
|
|
|
1744
1929
|
};
|
|
1745
1930
|
}
|
|
1746
1931
|
//#endregion
|
|
1747
|
-
//#region src/mcp/deeplink.ts
|
|
1748
|
-
/**
|
|
1749
|
-
* Build a self-attaching dogfood deep link.
|
|
1750
|
-
*
|
|
1751
|
-
* `ait deploy --scheme-only` prints an `intoss-private://…?_deploymentId=<uuid>`
|
|
1752
|
-
* URL that opens a dogfood bundle on a phone. The in-app debug gate
|
|
1753
|
-
* (`src/in-app/gate.ts`) auto-attaches when the entry URL also carries
|
|
1754
|
-
* `debug=1` and `relay=<wss-url>`. This helper splices those params (plus
|
|
1755
|
-
* `at=<code>` when TOTP is enabled) into the scheme URL; rendering the result
|
|
1756
|
-
* as a QR code and scanning it with the phone camera opens the mini-app and
|
|
1757
|
-
* attaches it to the live Chii relay. QR is the single entry path — it needs
|
|
1758
|
-
* no USB cable, platform CLI, or driver, and works the same on iOS/Android.
|
|
1759
|
-
*
|
|
1760
|
-
* The Toss app propagates extra query params from the entry deep link into the
|
|
1761
|
-
* mini-app WebView's `location.search` (confirmed behavior), so the gate reads
|
|
1762
|
-
* them at attach time.
|
|
1763
|
-
*
|
|
1764
|
-
* TOTP `at=` param:
|
|
1765
|
-
* When a TOTP secret is active, `buildDeepLinkAttachUrl` accepts an optional
|
|
1766
|
-
* `totpCode` argument and splices `at=<code>` alongside `debug` and `relay`.
|
|
1767
|
-
* The code must be computed by the caller at call time — do NOT pre-compute
|
|
1768
|
-
* and cache it, because the 30-second window expires quickly. The in-app gate
|
|
1769
|
-
* (`src/in-app/gate.ts` Layer C) validates this code against the baked secret.
|
|
1770
|
-
*
|
|
1771
|
-
* Why not `URL`/`URLSearchParams`: `intoss-private:` is a non-special scheme.
|
|
1772
|
-
* The WHATWG `URL` parser treats such schemes opaquely (no host/path/query
|
|
1773
|
-
* decomposition you can rely on across runtimes), so query manipulation via
|
|
1774
|
-
* `url.searchParams` is not portable here. We splice the query string directly
|
|
1775
|
-
* on the raw string instead, which keeps the scheme, authority, path, and any
|
|
1776
|
-
* pre-existing params (notably `_deploymentId`) byte-for-byte intact.
|
|
1777
|
-
*/
|
|
1778
|
-
/**
|
|
1779
|
-
* Suspicious/generic authority values that indicate a malformed or placeholder
|
|
1780
|
-
* scheme URL. These are host strings that will almost certainly cause the Toss
|
|
1781
|
-
* app to fail with "bundle not found" silently.
|
|
1782
|
-
*
|
|
1783
|
-
* The expected form from `ait deploy --scheme-only` is:
|
|
1784
|
-
* intoss-private://<appName>?_deploymentId=<uuid>
|
|
1785
|
-
* where `<appName>` is a non-generic string like `aitc-sdk-example`.
|
|
1786
|
-
*/
|
|
1787
|
-
const SUSPICIOUS_AUTHORITIES = new Set([
|
|
1788
|
-
"",
|
|
1789
|
-
"web",
|
|
1790
|
-
"localhost",
|
|
1791
|
-
"127.0.0.1",
|
|
1792
|
-
"app"
|
|
1793
|
-
]);
|
|
1794
|
-
/**
|
|
1795
|
-
* Validates the authority (host) portion of a scheme URL.
|
|
1796
|
-
*
|
|
1797
|
-
* Returns a warning message if the authority is missing or looks like a
|
|
1798
|
-
* placeholder, or `null` if the authority looks valid.
|
|
1799
|
-
*
|
|
1800
|
-
* Expected form: `intoss-private://<appName>?_deploymentId=<uuid>`
|
|
1801
|
-
* The authority must be a non-empty, non-generic app name (e.g. `aitc-sdk-example`).
|
|
1802
|
-
*/
|
|
1803
|
-
function validateSchemeAuthority(schemeUrl) {
|
|
1804
|
-
const afterScheme = schemeUrl.replace(/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//, "");
|
|
1805
|
-
if (afterScheme === schemeUrl) return "scheme_url does not look like a scheme URL (expected `intoss-private://<appName>?_deploymentId=<uuid>`). Use the URL printed by `ait deploy --scheme-only`.";
|
|
1806
|
-
const authorityEnd = afterScheme.search(/[/?#]/);
|
|
1807
|
-
const authority = authorityEnd === -1 ? afterScheme : afterScheme.slice(0, authorityEnd);
|
|
1808
|
-
if (SUSPICIOUS_AUTHORITIES.has(authority.toLowerCase())) return `scheme_url authority ${authority === "" ? "(empty)" : `"${authority}"`} looks like a placeholder. Expected an app name like \`intoss-private://aitc-sdk-example?_deploymentId=<uuid>\`. Use the URL printed by \`ait deploy --scheme-only\` — it includes the correct app name as the host.`;
|
|
1809
|
-
return null;
|
|
1810
|
-
}
|
|
1811
|
-
function stripExisting(query, key) {
|
|
1812
|
-
if (query === "") return "";
|
|
1813
|
-
return query.split("&").filter((pair) => pair !== "" && pair.split("=")[0] !== key).join("&");
|
|
1814
|
-
}
|
|
1815
|
-
/**
|
|
1816
|
-
* Splices `debug=1`, `relay=<wssUrl>`, and (optionally) `at=<totpCode>` into a
|
|
1817
|
-
* scheme URL's query string, preserving everything else (scheme, authority,
|
|
1818
|
-
* path, hash, and the existing `_deploymentId` param). If any of the spliced
|
|
1819
|
-
* params is already present it is replaced so the helper is idempotent.
|
|
1820
|
-
*
|
|
1821
|
-
* @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL printed
|
|
1822
|
-
* by `ait deploy --scheme-only`. Must already carry `_deploymentId` (Layer B
|
|
1823
|
-
* of the gate); this helper does not invent one.
|
|
1824
|
-
* @param wssUrl - The live relay URL (`wss://…trycloudflare.com`) from the
|
|
1825
|
-
* running debug MCP server's quick tunnel.
|
|
1826
|
-
* @param totpCode - Optional current TOTP code (6 digits). When provided, it
|
|
1827
|
-
* is spliced as `at=<totpCode>`. Must be computed at call time — it rotates
|
|
1828
|
-
* every 30 s. Pass `undefined` or omit when TOTP is disabled.
|
|
1829
|
-
* @returns The same URL with `debug=1&relay=<encoded wssUrl>[&at=<totpCode>]`
|
|
1830
|
-
* appended.
|
|
1831
|
-
* @throws If `wssUrl` is not a `wss:` URL (the gate rejects anything else, so
|
|
1832
|
-
* producing such a link would be a silent dead end).
|
|
1833
|
-
*/
|
|
1834
|
-
function buildDeepLinkAttachUrl(schemeUrl, wssUrl, totpCode) {
|
|
1835
|
-
let relay;
|
|
1836
|
-
try {
|
|
1837
|
-
relay = new URL(wssUrl);
|
|
1838
|
-
} catch {
|
|
1839
|
-
throw new Error(`relay URL is not a valid URL: ${wssUrl}`);
|
|
1840
|
-
}
|
|
1841
|
-
if (relay.protocol !== "wss:") throw new Error(`relay URL must use the wss: scheme, got ${relay.protocol} (${wssUrl})`);
|
|
1842
|
-
const hashIndex = schemeUrl.indexOf("#");
|
|
1843
|
-
const hash = hashIndex === -1 ? "" : schemeUrl.slice(hashIndex);
|
|
1844
|
-
const beforeHash = hashIndex === -1 ? schemeUrl : schemeUrl.slice(0, hashIndex);
|
|
1845
|
-
const queryIndex = beforeHash.indexOf("?");
|
|
1846
|
-
const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex);
|
|
1847
|
-
let query = queryIndex === -1 ? "" : beforeHash.slice(queryIndex + 1);
|
|
1848
|
-
const appended = [["debug", "1"], ["relay", wssUrl]];
|
|
1849
|
-
if (totpCode !== void 0 && totpCode !== "") appended.push(["at", totpCode]);
|
|
1850
|
-
query = stripExisting(query, "at");
|
|
1851
|
-
for (const [key] of appended) query = stripExisting(query, key);
|
|
1852
|
-
for (const [key, value] of appended) {
|
|
1853
|
-
const pair = `${key}=${encodeURIComponent(value)}`;
|
|
1854
|
-
query = query === "" ? pair : `${query}&${pair}`;
|
|
1855
|
-
}
|
|
1856
|
-
return `${base}?${query}${hash}`;
|
|
1857
|
-
}
|
|
1858
|
-
//#endregion
|
|
1859
1932
|
//#region src/mcp/sdk-signatures.ts
|
|
1860
1933
|
function isObject$1(v) {
|
|
1861
1934
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -2088,6 +2161,109 @@ function verifyTotp(secret, code, when = Date.now(), skew = 1) {
|
|
|
2088
2161
|
}
|
|
2089
2162
|
return false;
|
|
2090
2163
|
}
|
|
2164
|
+
/**
|
|
2165
|
+
* Minimum length (in hex characters) accepted for `AIT_DEBUG_TOTP_SECRET`.
|
|
2166
|
+
*
|
|
2167
|
+
* The secret is hex-encoded (see {@link generateTotp} — `Buffer.from(secret,
|
|
2168
|
+
* 'hex')`). 32 hex chars = 16 bytes = 128 bits, the floor for an HMAC-SHA1 key
|
|
2169
|
+
* we are willing to gate a public relay behind. `generateAttachToken()` emits
|
|
2170
|
+
* 64 hex chars (32 bytes), comfortably above this bar.
|
|
2171
|
+
*/
|
|
2172
|
+
const MIN_SECRET_HEX_CHARS = 32;
|
|
2173
|
+
/** Hex string: one or more hex digits, case-insensitive (RFC 4648 base16). */
|
|
2174
|
+
const HEX_RE = /^[0-9a-fA-F]+$/;
|
|
2175
|
+
/**
|
|
2176
|
+
* Human-facing guidance printed when {@link assertRelayAuthConfigured} fails.
|
|
2177
|
+
*
|
|
2178
|
+
* SECRET-HANDLING: this message states only the REQUIREMENT (≥32 hex chars) and
|
|
2179
|
+
* how to mint one. It NEVER echoes the configured value, its length, or any
|
|
2180
|
+
* fragment derived from it — see {@link assertRelayAuthConfigured}.
|
|
2181
|
+
*
|
|
2182
|
+
* Note on encoding: the secret is hex (base16), not base32 — `generateTotp`
|
|
2183
|
+
* decodes it with `Buffer.from(secret, 'hex')`. A base32 string would be
|
|
2184
|
+
* silently mis-decoded and every TOTP code would fail to match, so the minting
|
|
2185
|
+
* command emits hex.
|
|
2186
|
+
*/
|
|
2187
|
+
const RELAY_AUTH_SECRET_MISSING_MESSAGE = [
|
|
2188
|
+
"[ait-debug] AIT_DEBUG_TOTP_SECRET이 필수입니다. 32자 이상 16진수(hex) 문자열을 설정하세요.",
|
|
2189
|
+
"발급: openssl rand -hex 32",
|
|
2190
|
+
"자세히: https://docs.aitc.dev/guides/relay-auth-totp"
|
|
2191
|
+
].join("\n");
|
|
2192
|
+
/**
|
|
2193
|
+
* Whether `secret` is a well-formed relay-auth TOTP secret: a hex string of at
|
|
2194
|
+
* least {@link MIN_SECRET_HEX_CHARS} characters with an even length (an odd
|
|
2195
|
+
* length would have its trailing nibble silently dropped by `Buffer.from(...,
|
|
2196
|
+
* 'hex')`, weakening the key without warning).
|
|
2197
|
+
*
|
|
2198
|
+
* Pure predicate so callers can test the validation independently of the
|
|
2199
|
+
* fail-fast side effect in {@link assertRelayAuthConfigured}.
|
|
2200
|
+
*
|
|
2201
|
+
* SECRET-HANDLING: returns only a boolean — the input value is never returned,
|
|
2202
|
+
* logged, or echoed.
|
|
2203
|
+
*/
|
|
2204
|
+
function isValidRelayAuthSecret(secret) {
|
|
2205
|
+
if (secret === void 0 || secret === "") return false;
|
|
2206
|
+
if (secret.length < MIN_SECRET_HEX_CHARS) return false;
|
|
2207
|
+
if (secret.length % 2 !== 0) return false;
|
|
2208
|
+
return HEX_RE.test(secret);
|
|
2209
|
+
}
|
|
2210
|
+
/**
|
|
2211
|
+
* Fail-fast guard enforcing that a relay-auth TOTP secret is configured before
|
|
2212
|
+
* a public-internet-exposed relay is booted (issue #250).
|
|
2213
|
+
*
|
|
2214
|
+
* Relay-auth (the §4 Layer C TOTP gate) is the only fail-fast layer that closes
|
|
2215
|
+
* the real gap: a leaked `wss://…trycloudflare.com` URL otherwise lets a third
|
|
2216
|
+
* party attach a debugger to a dogfood/live mini-app. Without a secret the relay
|
|
2217
|
+
* comes up unauthenticated, so this guard is called at every relay-boot site —
|
|
2218
|
+
* `bootRelayFamily` (intoss env 3/4) and `bootExternalRelayFamily` (env-2 PWA),
|
|
2219
|
+
* both eager and lazy. Local-only sessions never boot a relay and so never reach
|
|
2220
|
+
* this guard, matching the issue's exemption for non-relay debugging.
|
|
2221
|
+
*
|
|
2222
|
+
* Throws when the secret is unset, empty, too short, or not a valid hex string.
|
|
2223
|
+
* The thrown message is the bin entry's fatal stderr (see `cli.ts` `main().catch`)
|
|
2224
|
+
* — the same fatal model as the missing-`AIT_RELAY_BASE_URL` path.
|
|
2225
|
+
*
|
|
2226
|
+
* SECRET-HANDLING: the env value is read once, passed ONLY to the boolean
|
|
2227
|
+
* predicate, and never logged. The thrown message names the requirement, never
|
|
2228
|
+
* the value, its length, or any derived fragment.
|
|
2229
|
+
*
|
|
2230
|
+
* @param env - Environment to read from. Defaults to `process.env`; injectable
|
|
2231
|
+
* for tests so they never mutate the real process environment.
|
|
2232
|
+
*/
|
|
2233
|
+
function assertRelayAuthConfigured(env = process.env) {
|
|
2234
|
+
if (!isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) throw new Error(RELAY_AUTH_SECRET_MISSING_MESSAGE);
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a
|
|
2238
|
+
* `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.
|
|
2239
|
+
*
|
|
2240
|
+
* The predicate checks the `at` query parameter against the current and
|
|
2241
|
+
* adjacent TOTP time steps (±1 skew) using {@link verifyTotp}.
|
|
2242
|
+
*
|
|
2243
|
+
* Returns `undefined` when the env var is not set — callers treat that as
|
|
2244
|
+
* "auth disabled" (no predicate registered on the relay). Note that since
|
|
2245
|
+
* issue #250 the secret is MANDATORY at every relay-boot site (enforced by
|
|
2246
|
+
* {@link assertRelayAuthConfigured} BEFORE the relay starts), so in production
|
|
2247
|
+
* this never returns `undefined` for a relay that actually boots; the
|
|
2248
|
+
* `undefined` branch only matters for the no-relay local path and tests.
|
|
2249
|
+
*
|
|
2250
|
+
* Lives here (not in the MCP server) so the unplugin's env-2 relay can wire the
|
|
2251
|
+
* same gate without importing the heavy MCP server module graph. Re-exported
|
|
2252
|
+
* from `debug-server.ts` for back-compat.
|
|
2253
|
+
*
|
|
2254
|
+
* SECRET-HANDLING: The secret value read from env is captured in a closure and
|
|
2255
|
+
* is NEVER written to any log, error message, or process output.
|
|
2256
|
+
*/
|
|
2257
|
+
function buildRelayVerifyAuth(env = process.env) {
|
|
2258
|
+
const secret = env.AIT_DEBUG_TOTP_SECRET;
|
|
2259
|
+
if (!secret) return void 0;
|
|
2260
|
+
return (req) => {
|
|
2261
|
+
const rawUrl = req.url ?? "";
|
|
2262
|
+
const qIndex = rawUrl.indexOf("?");
|
|
2263
|
+
const queryStr = qIndex === -1 ? "" : rawUrl.slice(qIndex + 1);
|
|
2264
|
+
return verifyTotp(secret, new URLSearchParams(queryStr).get("at") ?? "");
|
|
2265
|
+
};
|
|
2266
|
+
}
|
|
2091
2267
|
//#endregion
|
|
2092
2268
|
//#region src/mcp/tools.ts
|
|
2093
2269
|
/** Static MCP tool descriptors (name + JSONSchema) for the full debug tool surface. */
|
|
@@ -2124,13 +2300,13 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
2124
2300
|
},
|
|
2125
2301
|
{
|
|
2126
2302
|
name: "build_attach_url",
|
|
2127
|
-
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.
|
|
2303
|
+
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 / staging (start_debug mode=\"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 / mobile (start_debug mode=\"mobile\"): 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=…). 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 (polls up to 30 s). On timeout, call build_attach_url again to resume polling. When open_in_browser=true (default), saves the QR as a PNG and opens it in the OS default browser — only works when the MCP server runs on a local GUI machine (not headless/remote containers). \n\nTOTP auth: when AIT_DEBUG_TOTP_SECRET is set on the MCP server, the returned attachUrl automatically includes the current one-time code (at=<code>) — the URL is single-use for that 30-second step. The response includes a `totp` field with `expiresAt` (ISO timestamp). If the phone scan happens after expiresAt, the relay will reject the code — just call build_attach_url again to get a fresh one-time URL. Without AIT_DEBUG_TOTP_SECRET, the attachUrl has no expiry.",
|
|
2128
2304
|
inputSchema: {
|
|
2129
2305
|
type: "object",
|
|
2130
2306
|
properties: {
|
|
2131
2307
|
scheme_url: {
|
|
2132
2308
|
type: "string",
|
|
2133
|
-
description: "The intoss-private:// scheme URL from `ait deploy --scheme-only` (must carry _deploymentId). 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."
|
|
2309
|
+
description: "The intoss-private:// scheme URL from `ait deploy --scheme-only` (must carry _deploymentId). Required for env 3/staging mode. Not used in env 2/mobile 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."
|
|
2134
2310
|
},
|
|
2135
2311
|
wait_for_attach: {
|
|
2136
2312
|
type: "boolean",
|
|
@@ -2141,7 +2317,7 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
2141
2317
|
description: "If true (default), render the QR as a PNG and open it in the OS default browser. Only works when the MCP server is running on a local GUI machine — headless or remote container environments should set this to false to use the text QR fallback."
|
|
2142
2318
|
}
|
|
2143
2319
|
},
|
|
2144
|
-
required: [
|
|
2320
|
+
required: []
|
|
2145
2321
|
},
|
|
2146
2322
|
availableIn: "relay"
|
|
2147
2323
|
},
|
|
@@ -2219,7 +2395,7 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
2219
2395
|
},
|
|
2220
2396
|
{
|
|
2221
2397
|
name: "call_sdk",
|
|
2222
|
-
description: "Calls a dogfood SDK method via the window.__sdkCall bridge (exported by @apps-in-toss/web-framework only in __DEBUG_BUILD__ bundles). NOT read-only — SDK calls have side effects (navigation, payments, permissions, etc.). On env 3/4 (real device relay) this hits the real SDK; on env 1 (local mock)
|
|
2398
|
+
description: "Calls a dogfood SDK method via the window.__sdkCall bridge (exported by @apps-in-toss/web-framework only in __DEBUG_BUILD__ bundles). NOT read-only — SDK calls have side effects (navigation, payments, permissions, etc.). On env 3/4 (real device relay) this hits the real SDK; on env 1 (local mock) 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-dogfood 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\", [])",
|
|
2223
2399
|
inputSchema: {
|
|
2224
2400
|
type: "object",
|
|
2225
2401
|
properties: {
|
|
@@ -2273,23 +2449,23 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
2273
2449
|
},
|
|
2274
2450
|
{
|
|
2275
2451
|
name: "start_debug",
|
|
2276
|
-
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-
|
|
2452
|
+
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 — 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 mobile — env 2: a real-device PWA (real WebKit engine, MOCK SDK) over an external Chii relay that the unplugin already started with tunnel:{cdp:true}. CDP covers real-device WebKit DOM, console, exceptions, and safe-area observation; call_sdk still hits the mock (SDK fidelity needs staging). liveIntent off — dev-intent, LIVE guard inactive, side-effect tools run unguarded against the mock. Prerequisites: AIT_RELAY_BASE_URL env var set + unplugin running with tunnel:{cdp:true} so the relay tunnel is already up.\n staging — env 3: a real-device Toss WebView dogfood 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 (dogfood, not released to real users). Prerequisite: a deployed dogfood candidate bundle + the device cold-loaded via the intoss-private deep-link/QR relay injection.\n 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 live ALSO requires confirm:true on this call. Use it only to observe a shipped regression; verify fixes in staging first.\n\nSwitching back to local automatically disarms the LIVE guard.",
|
|
2277
2453
|
inputSchema: {
|
|
2278
2454
|
type: "object",
|
|
2279
2455
|
properties: {
|
|
2280
2456
|
mode: {
|
|
2281
2457
|
type: "string",
|
|
2282
2458
|
enum: [
|
|
2283
|
-
"local
|
|
2284
|
-
"
|
|
2285
|
-
"
|
|
2286
|
-
"
|
|
2459
|
+
"local",
|
|
2460
|
+
"mobile",
|
|
2461
|
+
"staging",
|
|
2462
|
+
"live"
|
|
2287
2463
|
],
|
|
2288
|
-
description: "Target environment to switch to.
|
|
2464
|
+
description: "Target environment to switch to. mode=live additionally requires confirm: true (and arms the read-only LIVE guard)."
|
|
2289
2465
|
},
|
|
2290
2466
|
confirm: {
|
|
2291
2467
|
type: "boolean",
|
|
2292
|
-
description: "Required when mode=
|
|
2468
|
+
description: "Required when mode=live — set true to acknowledge entering LIVE (env 4) debugging that can affect real users. Ignored for the other modes."
|
|
2293
2469
|
}
|
|
2294
2470
|
},
|
|
2295
2471
|
required: ["mode"]
|
|
@@ -2298,7 +2474,7 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
2298
2474
|
},
|
|
2299
2475
|
{
|
|
2300
2476
|
name: "get_diagnostics",
|
|
2301
|
-
description: "Returns a single-call server status snapshot so the agent can diagnose \"why is this not working?\" without calling multiple tools. Fields: mcpVersion (MCP SDK version), devtoolsVersion (@ait-co/devtools package version), tunnel (up/wssUrl/pid/startedAt), pages (list_pages result + lastSeenAt stats), lastAttachAt, lastDetachAt, recentErrors (last N server-side errors, PII/secret redacted), environment (kind: mock|relay-dev|relay-live, env: mock|relay backward-compat, reason, liveGuardActive: true when relay-live LIVE guard is active), serverLockHolder (pid + startedAt from the lock file, or null), nextRecommendedAction ({tool, reason} or null — the single next tool to call; in local-target mode tunnel.up=false is normal so \"restart\" is never recommended). All fields are nullable — missing data is null, not an error. debug-mode only — dev-mode (--mode=dev) does not support relay diagnostics. Tier C (both mock and relay). Call this first when debugging session state.",
|
|
2477
|
+
description: "Returns a single-call server status snapshot so the agent can diagnose \"why is this not working?\" without calling multiple tools. Fields: mcpVersion (MCP SDK version), devtoolsVersion (@ait-co/devtools package version), tunnel (up/wssUrl/pid/startedAt), pages (list_pages result + lastSeenAt stats), lastAttachAt, lastDetachAt, recentErrors (last N server-side errors, PII/secret redacted), environment (kind: mock|relay-dev|relay-live|relay-mobile, env: mock|relay backward-compat, reason, liveGuardActive: true when relay-live LIVE guard is active), serverLockHolder (pid + startedAt from the lock file, or null), nextRecommendedAction ({tool, reason} or null — the single next tool to call; in local-target mode tunnel.up=false is normal so \"restart\" is never recommended). All fields are nullable — missing data is null, not an error. debug-mode only — dev-mode (--mode=dev) does not support relay diagnostics. Tier C (both mock and relay). Call this first when debugging session state.",
|
|
2302
2478
|
inputSchema: {
|
|
2303
2479
|
type: "object",
|
|
2304
2480
|
properties: { recent_errors_limit: {
|
|
@@ -2328,8 +2504,8 @@ function getToolAvailability(name) {
|
|
|
2328
2504
|
* Unknown tools return `false` — callers should reject them as unknown rather
|
|
2329
2505
|
* than as env-mismatched.
|
|
2330
2506
|
*
|
|
2331
|
-
* Relay variants (`relay-dev`, `relay-live`)
|
|
2332
|
-
* availability tier — `isRelayEnv()` is used for the check.
|
|
2507
|
+
* Relay variants (`relay-dev`, `relay-live`, `relay-mobile`) all satisfy the
|
|
2508
|
+
* `'relay'` availability tier — `isRelayEnv()` is used for the check.
|
|
2333
2509
|
*/
|
|
2334
2510
|
function isToolAvailableIn(name, env) {
|
|
2335
2511
|
const availability = getToolAvailability(name);
|
|
@@ -2343,7 +2519,8 @@ function isToolAvailableIn(name, env) {
|
|
|
2343
2519
|
* matches the given env. Pure — preserves order; both Tier C ("both") and the
|
|
2344
2520
|
* matching single-env tier pass through.
|
|
2345
2521
|
*
|
|
2346
|
-
* Relay variants (`relay-dev`, `relay-live`)
|
|
2522
|
+
* Relay variants (`relay-dev`, `relay-live`, `relay-mobile`) all satisfy the
|
|
2523
|
+
* `'relay'` tier.
|
|
2347
2524
|
*/
|
|
2348
2525
|
function filterToolsByEnvironment(tools, env) {
|
|
2349
2526
|
return tools.filter((t) => t.availableIn === "both" || t.availableIn === "relay" && isRelayEnv(env) || t.availableIn === env);
|
|
@@ -2962,8 +3139,8 @@ function findRecentException(connection, windowStart, windowEnd) {
|
|
|
2962
3139
|
* Calls a dogfood SDK method via `window.__sdkCall` on the attached page.
|
|
2963
3140
|
* NOT read-only — SDK calls may have side effects.
|
|
2964
3141
|
*
|
|
2965
|
-
* On env
|
|
2966
|
-
* mock) it hits the mock SDK.
|
|
3142
|
+
* On env 3/4 (toss WebView relay) this hits the real SDK. On env 1 (local
|
|
3143
|
+
* mock) and env 2 (PWA relay — real WebKit, mock SDK) it hits the mock SDK.
|
|
2967
3144
|
*
|
|
2968
3145
|
* 인자 시그니처 검증: 등록된 메서드는 bridge 호출 전에 인자를 검증하고, mismatch면
|
|
2969
3146
|
* `{ok:false, error}` MCP 오류 결과를 반환한다(bridge에 도달하지 않음).
|
|
@@ -3117,7 +3294,7 @@ async function readMcpSdkVersion() {
|
|
|
3117
3294
|
* some test environments that skip the build step).
|
|
3118
3295
|
*/
|
|
3119
3296
|
function readDevtoolsVersion() {
|
|
3120
|
-
return "0.1.
|
|
3297
|
+
return "0.1.56";
|
|
3121
3298
|
}
|
|
3122
3299
|
/**
|
|
3123
3300
|
* Derives the next recommended action from a completed diagnostics snapshot.
|
|
@@ -3534,9 +3711,14 @@ function extractDeploymentId(schemeUrl) {
|
|
|
3534
3711
|
return null;
|
|
3535
3712
|
}
|
|
3536
3713
|
}
|
|
3537
|
-
/**
|
|
3714
|
+
/**
|
|
3715
|
+
* Returns `true` when the mode routes to a relay connection (`mobile`,
|
|
3716
|
+
* `staging`, or `live`). `mobile` is an external-PWA relay; `staging`/`live`
|
|
3717
|
+
* are intoss-private relays — but all three surface the Tier B / relay-only
|
|
3718
|
+
* tool set.
|
|
3719
|
+
*/
|
|
3538
3720
|
function isRelayMode(mode) {
|
|
3539
|
-
return mode === "
|
|
3721
|
+
return mode === "mobile" || mode === "staging" || mode === "live";
|
|
3540
3722
|
}
|
|
3541
3723
|
/**
|
|
3542
3724
|
* Waits for the first target matching `filterFn` to attach, using the
|
|
@@ -3594,12 +3776,12 @@ function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs
|
|
|
3594
3776
|
function createDebugServer(deps) {
|
|
3595
3777
|
const { connection, router: routerDep, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep, totpSecret } = deps;
|
|
3596
3778
|
const router = routerDep ?? makeSingleConnectionRouter(connection);
|
|
3597
|
-
const resolveEnvironment = getEnvDep ?? (() => deriveEnvironment(router.active.kind, getLiveIntent()));
|
|
3779
|
+
const resolveEnvironment = getEnvDep ?? (() => deriveEnvironment(router.active.kind, getLiveIntent(), router.activeRelayOrigin));
|
|
3598
3780
|
const resolveEnvironmentReason = getEnvReasonDep ?? (() => `derived:kind=${router.active.kind},liveIntent=${getLiveIntent()}`);
|
|
3599
3781
|
const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
|
|
3600
3782
|
const server = new Server({
|
|
3601
3783
|
name: "ait-debug",
|
|
3602
|
-
version: "0.1.
|
|
3784
|
+
version: "0.1.56"
|
|
3603
3785
|
}, { capabilities: { tools: { listChanged: true } } });
|
|
3604
3786
|
server.setRequestHandler(ListToolsRequestSchema, () => {
|
|
3605
3787
|
const conn = router.active;
|
|
@@ -3621,7 +3803,7 @@ function createDebugServer(deps) {
|
|
|
3621
3803
|
if (name === "start_debug") {
|
|
3622
3804
|
const rawMode = request.params.arguments?.mode;
|
|
3623
3805
|
const mode = normalizeStartDebugMode(rawMode);
|
|
3624
|
-
if (mode === null) return mcpError("start_debug: mode가 올바르지 않습니다. 'local-browser-dev'
|
|
3806
|
+
if (mode === null) return mcpError("start_debug: mode가 올바르지 않습니다. 'local' | 'mobile' | 'staging' | 'live' 중 하나를 전달하세요 (deprecated 별칭 'local-browser-dev'/'local-browser-cdp'/'relay-mobile'/'relay-dev'/'relay-live'도 수용).");
|
|
3625
3807
|
const confirm = request.params.arguments?.confirm === true;
|
|
3626
3808
|
try {
|
|
3627
3809
|
return jsonResult$1(await router.switchMode(mode, confirm));
|
|
@@ -3669,10 +3851,177 @@ function createDebugServer(deps) {
|
|
|
3669
3851
|
return errorResult(err, name);
|
|
3670
3852
|
}
|
|
3671
3853
|
if (name === "build_attach_url") {
|
|
3672
|
-
const schemeUrl = request.params.arguments?.scheme_url;
|
|
3673
|
-
if (typeof schemeUrl !== "string" || schemeUrl === "") return mcpError("build_attach_url: scheme_url이 비어 있습니다. `ait deploy --scheme-only`가 출력하는 intoss-private:// URL을 인자로 전달하세요.");
|
|
3674
3854
|
const waitForAttach = request.params.arguments?.wait_for_attach === true;
|
|
3675
3855
|
const openInBrowser = request.params.arguments?.open_in_browser !== false;
|
|
3856
|
+
if (env === "relay-mobile") {
|
|
3857
|
+
const tunnelHttpUrl = process.env.AIT_TUNNEL_BASE_URL?.trim() ?? "";
|
|
3858
|
+
if (tunnelHttpUrl === "") return mcpError("build_attach_url(mobile): AIT_TUNNEL_BASE_URL이 설정되지 않았습니다. unplugin tunnel:{cdp:true} 배너에 출력되는 앱 HTTP 터널 URL을 AIT_TUNNEL_BASE_URL 환경변수로 전달하세요.");
|
|
3859
|
+
const tunnelStatus = getTunnelStatus();
|
|
3860
|
+
if (!tunnelStatus.up || tunnelStatus.wssUrl === null) return mcpError("build_attach_url(mobile): relay wssUrl이 아직 설정되지 않았습니다. unplugin tunnel:{cdp:true}가 relay를 완전히 기동할 때까지 잠시 후 다시 시도하세요.");
|
|
3861
|
+
let totpCode;
|
|
3862
|
+
let totpMeta;
|
|
3863
|
+
if (totpSecret !== void 0 && totpSecret !== "") {
|
|
3864
|
+
const now = Date.now();
|
|
3865
|
+
totpCode = generateTotp(totpSecret, now);
|
|
3866
|
+
const STEP_SECONDS = 30;
|
|
3867
|
+
const currentStep = Math.floor(now / 1e3 / STEP_SECONDS);
|
|
3868
|
+
totpMeta = {
|
|
3869
|
+
enabled: true,
|
|
3870
|
+
ttlSeconds: STEP_SECONDS,
|
|
3871
|
+
expiresAt: (/* @__PURE__ */ new Date((currentStep + 1) * STEP_SECONDS * 1e3)).toISOString()
|
|
3872
|
+
};
|
|
3873
|
+
}
|
|
3874
|
+
const attachUrl = buildLauncherAttachUrl(tunnelHttpUrl, tunnelStatus.wssUrl, totpCode);
|
|
3875
|
+
const relayUrl = tunnelStatus.wssUrl;
|
|
3876
|
+
const totp = totpMeta;
|
|
3877
|
+
const isMatchingPage = (pages) => pages.length > 0;
|
|
3878
|
+
const buildTimeoutError = (baseText, timeoutSec, observed) => {
|
|
3879
|
+
const observedUrls = observed.slice(0, 3).map((p) => p.url.slice(0, 80)).join(", ");
|
|
3880
|
+
return `${baseText}\n\nNo page attached within ${timeoutSec}s${observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : ""} — launcher QR을 폰 카메라로 스캔한 뒤 call list_pages를 다시 호출하세요.`;
|
|
3881
|
+
};
|
|
3882
|
+
return await (async () => {
|
|
3883
|
+
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).";
|
|
3884
|
+
const warningPrefix = "";
|
|
3885
|
+
const guiAvailable = canOpenBrowser();
|
|
3886
|
+
if (openInBrowser && !guiAvailable) {
|
|
3887
|
+
const headlessNote = "[open_in_browser] GUI 환경이 감지되지 않았습니다 (headless/remote 환경). open_in_browser=false로 자동 폴백합니다. 텍스트 QR을 폰 카메라로 스캔하거나, 로컬 GUI 환경에서 실행하세요.\n\n";
|
|
3888
|
+
const qrHeadless = await renderQr(attachUrl);
|
|
3889
|
+
const headlessText = `${warningPrefix}${headlessNote}${header}\n${JSON.stringify({
|
|
3890
|
+
attachUrl,
|
|
3891
|
+
relayUrl,
|
|
3892
|
+
...totp ? { totp } : {}
|
|
3893
|
+
}, null, 2)}\n\n${qrHeadless}`;
|
|
3894
|
+
if (!waitForAttach) return { content: [{
|
|
3895
|
+
type: "text",
|
|
3896
|
+
text: headlessText
|
|
3897
|
+
}] };
|
|
3898
|
+
let attachedPagesHl = [];
|
|
3899
|
+
try {
|
|
3900
|
+
attachedPagesHl = await waitForAttachWithEvents(conn, isMatchingPage, waitForAttachTimeoutMs);
|
|
3901
|
+
} catch {
|
|
3902
|
+
attachedPagesHl = conn.listTargets();
|
|
3903
|
+
return {
|
|
3904
|
+
content: [{
|
|
3905
|
+
type: "text",
|
|
3906
|
+
text: buildTimeoutError(headlessText, waitForAttachTimeoutMs / 1e3, attachedPagesHl)
|
|
3907
|
+
}],
|
|
3908
|
+
isError: true
|
|
3909
|
+
};
|
|
3910
|
+
}
|
|
3911
|
+
const pagesResultHl = listPages(conn, getTunnelStatus());
|
|
3912
|
+
return { content: [{
|
|
3913
|
+
type: "text",
|
|
3914
|
+
text: `${headlessText}\n\n${JSON.stringify(pagesResultHl, null, 2)}`
|
|
3915
|
+
}] };
|
|
3916
|
+
}
|
|
3917
|
+
if (openInBrowser && guiAvailable && qrHttpServer) {
|
|
3918
|
+
const browserResult = await openQrInBrowser(qrHttpServer.buildAttachPageUrl(attachUrl), `http://127.0.0.1:${qrHttpServer.port}/qr.png?u=${encodeURIComponent(attachUrl)}`);
|
|
3919
|
+
if (browserResult.opened) {
|
|
3920
|
+
const retriedNote = browserResult.retried ? " (1회 retry 후 성공)" : "";
|
|
3921
|
+
const openResult = {
|
|
3922
|
+
attempted: true,
|
|
3923
|
+
succeeded: true,
|
|
3924
|
+
...browserResult.retried ? { retried: true } : {}
|
|
3925
|
+
};
|
|
3926
|
+
const shortText = `${warningPrefix}${header}\n${JSON.stringify({
|
|
3927
|
+
relayUrl,
|
|
3928
|
+
openResult,
|
|
3929
|
+
...totp ? { totp } : {}
|
|
3930
|
+
}, null, 2)}\n\n브라우저에서 QR을 열었습니다${retriedNote}. 폰 카메라로 스캔하세요.\nURL: ${browserResult.httpUrl}`;
|
|
3931
|
+
if (!waitForAttach) return { content: [{
|
|
3932
|
+
type: "text",
|
|
3933
|
+
text: shortText
|
|
3934
|
+
}] };
|
|
3935
|
+
let attachedPages = [];
|
|
3936
|
+
try {
|
|
3937
|
+
attachedPages = await waitForAttachWithEvents(conn, isMatchingPage, waitForAttachTimeoutMs);
|
|
3938
|
+
} catch {
|
|
3939
|
+
attachedPages = conn.listTargets();
|
|
3940
|
+
return {
|
|
3941
|
+
content: [{
|
|
3942
|
+
type: "text",
|
|
3943
|
+
text: buildTimeoutError(shortText, waitForAttachTimeoutMs / 1e3, attachedPages)
|
|
3944
|
+
}],
|
|
3945
|
+
isError: true
|
|
3946
|
+
};
|
|
3947
|
+
}
|
|
3948
|
+
const pagesResult = listPages(conn, getTunnelStatus());
|
|
3949
|
+
return { content: [{
|
|
3950
|
+
type: "text",
|
|
3951
|
+
text: `${shortText}\n\n${JSON.stringify(pagesResult, null, 2)}`
|
|
3952
|
+
}] };
|
|
3953
|
+
}
|
|
3954
|
+
const openResult = {
|
|
3955
|
+
attempted: true,
|
|
3956
|
+
succeeded: false,
|
|
3957
|
+
failureReason: browserResult.error ?? "브라우저 실행 후보 모두 실패",
|
|
3958
|
+
pngUrl: browserResult.pngUrl,
|
|
3959
|
+
...browserResult.stderrSummary ? { stderrSummary: browserResult.stderrSummary } : {}
|
|
3960
|
+
};
|
|
3961
|
+
const stderrNote = browserResult.stderrSummary ? `\nstderr: ${browserResult.stderrSummary}` : "";
|
|
3962
|
+
const fallbackNote = `[open_in_browser] 브라우저 자동 열기에 실패했습니다. 다음 URL을 직접 브라우저에서 여세요:\n${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stderrNote + "\n\n";
|
|
3963
|
+
const qr = await renderQr(attachUrl);
|
|
3964
|
+
const baseText = `${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
|
|
3965
|
+
attachUrl,
|
|
3966
|
+
relayUrl,
|
|
3967
|
+
openResult,
|
|
3968
|
+
...totp ? { totp } : {}
|
|
3969
|
+
}, null, 2)}\n\n${qr}`;
|
|
3970
|
+
if (!waitForAttach) return { content: [{
|
|
3971
|
+
type: "text",
|
|
3972
|
+
text: baseText
|
|
3973
|
+
}] };
|
|
3974
|
+
let attachedPagesFb = [];
|
|
3975
|
+
try {
|
|
3976
|
+
attachedPagesFb = await waitForAttachWithEvents(conn, isMatchingPage, waitForAttachTimeoutMs);
|
|
3977
|
+
} catch {
|
|
3978
|
+
attachedPagesFb = conn.listTargets();
|
|
3979
|
+
return {
|
|
3980
|
+
content: [{
|
|
3981
|
+
type: "text",
|
|
3982
|
+
text: buildTimeoutError(baseText, waitForAttachTimeoutMs / 1e3, attachedPagesFb)
|
|
3983
|
+
}],
|
|
3984
|
+
isError: true
|
|
3985
|
+
};
|
|
3986
|
+
}
|
|
3987
|
+
const pagesResultFb = listPages(conn, getTunnelStatus());
|
|
3988
|
+
return { content: [{
|
|
3989
|
+
type: "text",
|
|
3990
|
+
text: `${baseText}\n\n${JSON.stringify(pagesResultFb, null, 2)}`
|
|
3991
|
+
}] };
|
|
3992
|
+
}
|
|
3993
|
+
const qr = await renderQr(attachUrl);
|
|
3994
|
+
const baseText = `${warningPrefix}${header}\n${JSON.stringify({
|
|
3995
|
+
attachUrl,
|
|
3996
|
+
relayUrl,
|
|
3997
|
+
...totp ? { totp } : {}
|
|
3998
|
+
}, null, 2)}\n\n${qr}`;
|
|
3999
|
+
if (!waitForAttach) return { content: [{
|
|
4000
|
+
type: "text",
|
|
4001
|
+
text: baseText
|
|
4002
|
+
}] };
|
|
4003
|
+
let attachedPages = [];
|
|
4004
|
+
try {
|
|
4005
|
+
attachedPages = await waitForAttachWithEvents(conn, isMatchingPage, waitForAttachTimeoutMs);
|
|
4006
|
+
} catch {
|
|
4007
|
+
attachedPages = conn.listTargets();
|
|
4008
|
+
return {
|
|
4009
|
+
content: [{
|
|
4010
|
+
type: "text",
|
|
4011
|
+
text: buildTimeoutError(baseText, waitForAttachTimeoutMs / 1e3, attachedPages)
|
|
4012
|
+
}],
|
|
4013
|
+
isError: true
|
|
4014
|
+
};
|
|
4015
|
+
}
|
|
4016
|
+
const pagesResult = listPages(conn, getTunnelStatus());
|
|
4017
|
+
return { content: [{
|
|
4018
|
+
type: "text",
|
|
4019
|
+
text: `${baseText}\n\n${JSON.stringify(pagesResult, null, 2)}`
|
|
4020
|
+
}] };
|
|
4021
|
+
})();
|
|
4022
|
+
}
|
|
4023
|
+
const schemeUrl = request.params.arguments?.scheme_url;
|
|
4024
|
+
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을 설정하세요.");
|
|
3676
4025
|
const deploymentId = extractDeploymentId(schemeUrl);
|
|
3677
4026
|
if (!deploymentId) logInfo("tool.call", {
|
|
3678
4027
|
tool: "build_attach_url",
|
|
@@ -3883,26 +4232,39 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
|
|
|
3883
4232
|
const sdkArgs = Array.isArray(rawArgs) ? rawArgs : [];
|
|
3884
4233
|
if (conn.kind === "relay" && getLiveIntent() && request.params.arguments?.confirm !== true) return liveGuardError("call_sdk");
|
|
3885
4234
|
const sdkResult = await callSdk(conn, sdkName, sdkArgs);
|
|
3886
|
-
if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk");
|
|
4235
|
+
if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk", conn.kind === "local");
|
|
3887
4236
|
return envelopeResult$1(sdkResult, name, env, conn.listTargets().length > 0);
|
|
3888
4237
|
}
|
|
3889
4238
|
default: return unknownTool(name);
|
|
3890
4239
|
}
|
|
3891
4240
|
} catch (err) {
|
|
3892
|
-
return errorResult(err, name);
|
|
4241
|
+
return errorResult(err, name, conn.kind === "local");
|
|
3893
4242
|
}
|
|
3894
4243
|
});
|
|
3895
4244
|
return server;
|
|
3896
4245
|
}
|
|
3897
4246
|
/**
|
|
3898
4247
|
* Normalizes a raw `start_debug` `mode` argument to a `StartDebugMode`, or
|
|
3899
|
-
* `null` when the value is not one of the
|
|
4248
|
+
* `null` when the value is not one of the accepted modes.
|
|
4249
|
+
*
|
|
4250
|
+
* Accepts the 4 canonical modes + 5 deprecated aliases (back-compat for
|
|
4251
|
+
* pinned .mcp.json / docs / QA runbooks that still emit old strings):
|
|
4252
|
+
* 'local' → 'local' (canonical)
|
|
4253
|
+
* 'mobile' → 'mobile' (canonical)
|
|
4254
|
+
* 'staging' → 'staging' (canonical)
|
|
4255
|
+
* 'live' → 'live' (canonical)
|
|
4256
|
+
* 'local-browser-dev' → 'local' (deprecated alias)
|
|
4257
|
+
* 'local-browser-cdp' → 'local' (deprecated alias)
|
|
4258
|
+
* 'relay-mobile' → 'mobile' (deprecated alias)
|
|
4259
|
+
* 'relay-dev' → 'staging' (deprecated alias)
|
|
4260
|
+
* 'relay-live' → 'live' (deprecated alias)
|
|
3900
4261
|
*/
|
|
3901
4262
|
function normalizeStartDebugMode(raw) {
|
|
3902
|
-
if (raw === "local
|
|
3903
|
-
if (raw === "local-browser-
|
|
3904
|
-
if (raw === "relay-
|
|
3905
|
-
if (raw === "relay-
|
|
4263
|
+
if (raw === "local" || raw === "mobile" || raw === "staging" || raw === "live") return raw;
|
|
4264
|
+
if (raw === "local-browser-dev" || raw === "local-browser-cdp") return "local";
|
|
4265
|
+
if (raw === "relay-mobile") return "mobile";
|
|
4266
|
+
if (raw === "relay-dev") return "staging";
|
|
4267
|
+
if (raw === "relay-live") return "live";
|
|
3906
4268
|
return null;
|
|
3907
4269
|
}
|
|
3908
4270
|
/**
|
|
@@ -3919,10 +4281,12 @@ function makeSingleConnectionRouter(connection) {
|
|
|
3919
4281
|
get active() {
|
|
3920
4282
|
return connection;
|
|
3921
4283
|
},
|
|
4284
|
+
activeRelayOrigin: void 0,
|
|
3922
4285
|
switchMode(mode, confirm) {
|
|
4286
|
+
if (mode === "mobile") return Promise.reject(/* @__PURE__ */ new Error("start_debug: 이 세션은 단일 연결만 보유합니다 — 'mobile'(환경 2 PWA, 외부 relay)로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). MCP 서버를 mobile 모드로 재시작하세요."));
|
|
3923
4287
|
if (isRelayMode(mode) !== (connection.kind === "relay")) return Promise.reject(/* @__PURE__ */ new Error(`start_debug: 이 세션은 단일 ${connection.kind} 연결만 보유합니다 — '${mode}'로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). MCP 서버를 원하는 모드로 재시작하세요.`));
|
|
3924
|
-
if (mode === "
|
|
3925
|
-
setLiveIntent(mode === "
|
|
4288
|
+
if (mode === "live" && !confirm) return Promise.reject(/* @__PURE__ */ new Error("start_debug: live(실서비스 LIVE)는 confirm: true가 필요합니다 — 실유저에게 영향이 갈 수 있는 LIVE 디버깅 진입을 명시적으로 승인하세요."));
|
|
4289
|
+
setLiveIntent(mode === "live");
|
|
3926
4290
|
const environment = deriveEnvironment(connection.kind, getLiveIntent());
|
|
3927
4291
|
return Promise.resolve({
|
|
3928
4292
|
mode,
|
|
@@ -3978,8 +4342,8 @@ function classifyEnableDomainError(err, toolName) {
|
|
|
3978
4342
|
* CDP/AIT 명령 실행 중 catch된 에러를 4상태로 분류해 tool 결과로 반환한다.
|
|
3979
4343
|
* debug-server 내부 try/catch 블록에서 공통으로 사용한다.
|
|
3980
4344
|
*/
|
|
3981
|
-
function errorResult(err, name) {
|
|
3982
|
-
return classifyToolError(err, name);
|
|
4345
|
+
function errorResult(err, name, isLocal = false) {
|
|
4346
|
+
return classifyToolError(err, name, isLocal);
|
|
3983
4347
|
}
|
|
3984
4348
|
/**
|
|
3985
4349
|
* Starts a polling watcher that detects the first 0→N target transition on
|
|
@@ -4059,29 +4423,6 @@ function startParentWatcher(onOrphaned, opts) {
|
|
|
4059
4423
|
} };
|
|
4060
4424
|
}
|
|
4061
4425
|
/**
|
|
4062
|
-
* Reads `AIT_DEBUG_TOTP_SECRET` from `process.env` at runtime and builds a
|
|
4063
|
-
* `verifyAuth` predicate for the Chii relay's WebSocket upgrade gate.
|
|
4064
|
-
*
|
|
4065
|
-
* The predicate checks the `at` query parameter against the current and
|
|
4066
|
-
* adjacent TOTP time steps (±1 skew) using `verifyTotp`.
|
|
4067
|
-
*
|
|
4068
|
-
* Returns `undefined` when the env var is not set — callers treat that as
|
|
4069
|
-
* "auth disabled" (no predicate registered on the relay).
|
|
4070
|
-
*
|
|
4071
|
-
* SECRET-HANDLING: The secret value read from env is captured in a closure and
|
|
4072
|
-
* is NEVER written to any log, error message, or process output.
|
|
4073
|
-
*/
|
|
4074
|
-
function buildRelayVerifyAuth() {
|
|
4075
|
-
const secret = process.env.AIT_DEBUG_TOTP_SECRET;
|
|
4076
|
-
if (!secret) return void 0;
|
|
4077
|
-
return (req) => {
|
|
4078
|
-
const rawUrl = req.url ?? "";
|
|
4079
|
-
const qIndex = rawUrl.indexOf("?");
|
|
4080
|
-
const queryStr = qIndex === -1 ? "" : rawUrl.slice(qIndex + 1);
|
|
4081
|
-
return verifyTotp(secret, new URLSearchParams(queryStr).get("at") ?? "");
|
|
4082
|
-
};
|
|
4083
|
-
}
|
|
4084
|
-
/**
|
|
4085
4426
|
* Factory that constructs a `ChiiCdpConnection` for the given relay base URL.
|
|
4086
4427
|
*
|
|
4087
4428
|
* Introduced as a named seam so PR-2 (dual-connection, #348) can defer
|
|
@@ -4156,6 +4497,7 @@ async function bootLocalFamily() {
|
|
|
4156
4497
|
* (relay host) is never logged here directly.
|
|
4157
4498
|
*/
|
|
4158
4499
|
async function bootRelayFamily(options = {}) {
|
|
4500
|
+
assertRelayAuthConfigured();
|
|
4159
4501
|
const relayPort = options.relayPort ?? 0;
|
|
4160
4502
|
const totpEnabled = options.verifyAuth !== void 0;
|
|
4161
4503
|
const relay = await startChiiRelay({
|
|
@@ -4205,6 +4547,7 @@ async function bootRelayFamily(options = {}) {
|
|
|
4205
4547
|
const connection = createRelayConnection(relay.baseUrl);
|
|
4206
4548
|
return {
|
|
4207
4549
|
connection,
|
|
4550
|
+
relayOrigin: "intoss-webview",
|
|
4208
4551
|
getTunnelStatus: () => tunnelStatus,
|
|
4209
4552
|
stop() {
|
|
4210
4553
|
tunnelProbe?.stop();
|
|
@@ -4215,21 +4558,91 @@ async function bootRelayFamily(options = {}) {
|
|
|
4215
4558
|
};
|
|
4216
4559
|
}
|
|
4217
4560
|
/**
|
|
4218
|
-
*
|
|
4561
|
+
* Boots the EXTERNAL relay family for env 2 (real-device PWA, issue #378).
|
|
4562
|
+
*
|
|
4563
|
+
* Unlike {@link bootRelayFamily}, this does NOT start a relay or a tunnel —
|
|
4564
|
+
* the unplugin (`tunnel: { cdp: true }`) already brought up a Chii relay for
|
|
4565
|
+
* the env-2 PWA and exposed its public base URL via `AIT_RELAY_BASE_URL`. Here
|
|
4566
|
+
* the MCP only opens a CDP client (`createRelayConnection`) against that
|
|
4567
|
+
* external relay. The relay's lifecycle is owned by the unplugin, so `stop()`
|
|
4568
|
+
* closes ONLY the CDP client — it must never tear down the relay or a tunnel
|
|
4569
|
+
* we did not start.
|
|
4570
|
+
*
|
|
4571
|
+
* `getTunnelStatus()` reports `up: true` with a `wssUrl` derived from
|
|
4572
|
+
* `relayBaseUrl` (http→ws, https→wss) so the `build_attach_url` gate
|
|
4573
|
+
* (`up: true && wssUrl !== null`) is satisfied even though we never opened a
|
|
4574
|
+
* cloudflared tunnel ourselves.
|
|
4575
|
+
*
|
|
4576
|
+
* SECRET-HANDLING: `relayBaseUrl` carries the relay host (same sensitivity as a
|
|
4577
|
+
* wss URL) — it is NEVER logged here. The caller validates presence and passes
|
|
4578
|
+
* the value straight to the CDP client.
|
|
4579
|
+
*/
|
|
4580
|
+
async function bootExternalRelayFamily(relayBaseUrl) {
|
|
4581
|
+
assertRelayAuthConfigured();
|
|
4582
|
+
const connection = createRelayConnection(relayBaseUrl);
|
|
4583
|
+
const tunnelStatus = makeTunnelStatus(true, relayBaseUrl.replace(/^http/, "ws"));
|
|
4584
|
+
return {
|
|
4585
|
+
connection,
|
|
4586
|
+
relayOrigin: "external-pwa",
|
|
4587
|
+
getTunnelStatus: () => tunnelStatus,
|
|
4588
|
+
stop() {
|
|
4589
|
+
connection.close();
|
|
4590
|
+
}
|
|
4591
|
+
};
|
|
4592
|
+
}
|
|
4593
|
+
/**
|
|
4594
|
+
* Maps a `StartDebugMode` to the {@link FamilyKey} that serves it (issue #378).
|
|
4595
|
+
* local → 'local'; mobile → 'relay-external'; staging/live → 'relay-intoss'.
|
|
4596
|
+
*/
|
|
4597
|
+
function familyKeyForMode(mode) {
|
|
4598
|
+
switch (mode) {
|
|
4599
|
+
case "local": return "local";
|
|
4600
|
+
case "mobile": return "relay-external";
|
|
4601
|
+
case "staging":
|
|
4602
|
+
case "live": return "relay-intoss";
|
|
4603
|
+
}
|
|
4604
|
+
}
|
|
4605
|
+
/** The error thrown / surfaced when entering `mobile` without AIT_RELAY_BASE_URL. */
|
|
4606
|
+
const MOBILE_RELAY_BASE_URL_MISSING_MESSAGE = "start_debug(mobile): AIT_RELAY_BASE_URL이 설정되지 않았습니다 — unplugin이 tunnel:{cdp:true}로 띄운 relay base URL을 AIT_RELAY_BASE_URL 환경변수로 전달하세요. 환경 2(실기기 PWA) 진입은 외부 relay base가 필요합니다.";
|
|
4607
|
+
/**
|
|
4608
|
+
* Reads `AIT_RELAY_BASE_URL` from the environment for the env-2 (`mobile`) boot
|
|
4609
|
+
* site (issue #378). Returns the trimmed value, or throws the precise
|
|
4610
|
+
* {@link MOBILE_RELAY_BASE_URL_MISSING_MESSAGE} when unset/empty.
|
|
4611
|
+
*
|
|
4612
|
+
* SECRET-HANDLING: `AIT_RELAY_BASE_URL` carries the relay host (same class as a
|
|
4613
|
+
* wss URL). On the missing path the thrown message describes the env var name
|
|
4614
|
+
* and how to obtain it — it NEVER echoes any partial/garbled URL value. The
|
|
4615
|
+
* present value is returned to the caller (the CDP client) but never logged.
|
|
4616
|
+
*/
|
|
4617
|
+
function readMobileRelayBaseUrl(env = process.env) {
|
|
4618
|
+
const raw = env.AIT_RELAY_BASE_URL;
|
|
4619
|
+
const value = typeof raw === "string" ? raw.trim() : "";
|
|
4620
|
+
if (value === "") throw new Error(MOBILE_RELAY_BASE_URL_MISSING_MESSAGE);
|
|
4621
|
+
return value;
|
|
4622
|
+
}
|
|
4623
|
+
/**
|
|
4624
|
+
* Production `ConnectionRouter` (issues #348, #356, #378 — DUAL-CONNECTION-COEXIST).
|
|
4625
|
+
*
|
|
4626
|
+
* Holds one eagerly-booted family plus a keyed set of lazily-booted families
|
|
4627
|
+
* ({@link FamilyKey} → `BootedFamily`, issue #378), an `active` pointer, and the
|
|
4628
|
+
* single attach watcher armed on the active connection. The router is
|
|
4629
|
+
* **direction-neutral** (#356): any family can be the eager one, so a
|
|
4630
|
+
* `--target=local` session can hot-switch into relay (and vice versa) without
|
|
4631
|
+
* restarting the MCP server.
|
|
4219
4632
|
*
|
|
4220
|
-
*
|
|
4221
|
-
*
|
|
4222
|
-
*
|
|
4223
|
-
*
|
|
4224
|
-
*
|
|
4633
|
+
* Why a KEYED map and not a single lazy slot (#378): `mobile` (env-2 external
|
|
4634
|
+
* relay) and `staging`/`live` (intoss relay) are BOTH `kind: 'relay'`. A single
|
|
4635
|
+
* "opposite-kind" slot could not warm-keep both at once — they would collide.
|
|
4636
|
+
* The three `FamilyKey`s (`local` / `relay-intoss` / `relay-external`) give each
|
|
4637
|
+
* its own warm slot.
|
|
4225
4638
|
*
|
|
4226
4639
|
* `switchMode`:
|
|
4227
|
-
* 1. rejects re-entrant swaps (`swapInFlight`) and an unconfirmed
|
|
4228
|
-
* 2.
|
|
4229
|
-
* eager;
|
|
4640
|
+
* 1. rejects re-entrant swaps (`swapInFlight`) and an unconfirmed `live`;
|
|
4641
|
+
* 2. resolves the requested mode's `FamilyKey`: equals `eagerKey` → reuse
|
|
4642
|
+
* eager; else `lazyFamilies.get(key) ?? (boot via bootLazyFor(key), store)`;
|
|
4230
4643
|
* 3. flips `active` (the MCP `Server` never re-handshakes — it reads through
|
|
4231
4644
|
* `active` per request);
|
|
4232
|
-
* 4. sets `liveIntent` (true only for
|
|
4645
|
+
* 4. sets `liveIntent` (true only for `live`; `mobile` is dev-intent → false);
|
|
4233
4646
|
* 5. stops the old attach watcher and re-arms one on the new connection
|
|
4234
4647
|
* (the watcher self-clears, so re-arm is mandatory);
|
|
4235
4648
|
* 6. emits `tools/list_changed`.
|
|
@@ -4240,8 +4653,8 @@ async function bootRelayFamily(options = {}) {
|
|
|
4240
4653
|
*/
|
|
4241
4654
|
var DualConnectionRouter = class {
|
|
4242
4655
|
deps;
|
|
4243
|
-
/**
|
|
4244
|
-
|
|
4656
|
+
/** Non-eager families, booted lazily and warm-kept per {@link FamilyKey} (#378). */
|
|
4657
|
+
lazyFamilies = /* @__PURE__ */ new Map();
|
|
4245
4658
|
activeFamily;
|
|
4246
4659
|
server = null;
|
|
4247
4660
|
attachWatcher = null;
|
|
@@ -4253,17 +4666,24 @@ var DualConnectionRouter = class {
|
|
|
4253
4666
|
get active() {
|
|
4254
4667
|
return this.activeFamily.connection;
|
|
4255
4668
|
}
|
|
4669
|
+
/** Relay origin of the currently-active family (issue #378). */
|
|
4670
|
+
get activeRelayOrigin() {
|
|
4671
|
+
return this.activeFamily.relayOrigin;
|
|
4672
|
+
}
|
|
4256
4673
|
/** Every booted family (for unified shutdown). */
|
|
4257
4674
|
bootedFamilies() {
|
|
4258
|
-
return
|
|
4675
|
+
return [this.deps.eager, ...this.lazyFamilies.values()];
|
|
4259
4676
|
}
|
|
4260
4677
|
/**
|
|
4261
|
-
* Live tunnel status of the relay family
|
|
4262
|
-
*
|
|
4263
|
-
*
|
|
4264
|
-
*
|
|
4678
|
+
* Live tunnel status of the active relay family (issues #356, #378). Reads
|
|
4679
|
+
* the ACTIVE family's tunnel when it has one (so `mobile` surfaces the
|
|
4680
|
+
* external relay wss and `staging`/`live` the intoss relay wss); otherwise
|
|
4681
|
+
* falls back to the first booted family that has a tunnel. Returns "down"
|
|
4682
|
+
* until any relay family is booted (local-eager sessions before the first
|
|
4683
|
+
* relay switch) — the correct signal for `build_attach_url` (no tunnel yet).
|
|
4265
4684
|
*/
|
|
4266
4685
|
relayTunnelStatus() {
|
|
4686
|
+
if (this.activeFamily.getTunnelStatus) return this.activeFamily.getTunnelStatus();
|
|
4267
4687
|
for (const family of this.bootedFamilies()) if (family.getTunnelStatus) return family.getTunnelStatus();
|
|
4268
4688
|
return {
|
|
4269
4689
|
up: false,
|
|
@@ -4289,30 +4709,37 @@ var DualConnectionRouter = class {
|
|
|
4289
4709
|
if (!server) return;
|
|
4290
4710
|
this.attachWatcher = startAttachWatcher(this.activeFamily.connection, server, this.deps.attachWatcherIntervalMs ?? 1e3, () => {
|
|
4291
4711
|
this.deps.diagnosticsCollector.recordAttach();
|
|
4292
|
-
if (this.activeFamily.connection.kind === "relay") this.deps.devtoolsOpener.open(this.relayTunnelStatus().wssUrl, deriveEnvironment(this.activeFamily.connection.kind, getLiveIntent()));
|
|
4712
|
+
if (this.activeFamily.connection.kind === "relay") this.deps.devtoolsOpener.open(this.relayTunnelStatus().wssUrl, deriveEnvironment(this.activeFamily.connection.kind, getLiveIntent(), this.activeFamily.relayOrigin));
|
|
4293
4713
|
});
|
|
4294
4714
|
}
|
|
4715
|
+
/**
|
|
4716
|
+
* Resolves the `BootedFamily` for `key`: the eager family when `key` matches
|
|
4717
|
+
* `eagerKey`, otherwise the warm lazy family (booting + storing it once on
|
|
4718
|
+
* first use). Only ever asks `bootLazyFor` for non-eager keys.
|
|
4719
|
+
*/
|
|
4720
|
+
async familyFor(key) {
|
|
4721
|
+
if (key === this.deps.eagerKey) return this.deps.eager;
|
|
4722
|
+
const warm = this.lazyFamilies.get(key);
|
|
4723
|
+
if (warm) return warm;
|
|
4724
|
+
const booted = await this.deps.bootLazyFor(key);
|
|
4725
|
+
this.lazyFamilies.set(key, booted);
|
|
4726
|
+
return booted;
|
|
4727
|
+
}
|
|
4295
4728
|
async switchMode(mode, confirm) {
|
|
4296
4729
|
if (this.swapInFlight) throw new Error("start_debug: 이전 전환이 아직 진행 중입니다 — 잠시 후 다시 호출하세요.");
|
|
4297
|
-
if (mode === "
|
|
4730
|
+
if (mode === "live" && !confirm) throw new Error("start_debug: live(실서비스 LIVE)는 confirm: true가 필요합니다 — 실유저에게 영향이 갈 수 있는 LIVE 디버깅 진입을 명시적으로 승인하세요.");
|
|
4298
4731
|
this.swapInFlight = true;
|
|
4299
4732
|
try {
|
|
4300
|
-
const
|
|
4301
|
-
const wantKind = wantRelay ? "relay" : "local";
|
|
4302
|
-
let target;
|
|
4303
|
-
if (wantKind === this.deps.eager.connection.kind) target = this.deps.eager;
|
|
4304
|
-
else {
|
|
4305
|
-
if (this.lazyFamily === null) this.lazyFamily = await this.deps.bootLazy();
|
|
4306
|
-
target = this.lazyFamily;
|
|
4307
|
-
}
|
|
4733
|
+
const target = await this.familyFor(familyKeyForMode(mode));
|
|
4308
4734
|
this.activeFamily = target;
|
|
4309
|
-
setLiveIntent(mode === "
|
|
4735
|
+
setLiveIntent(mode === "live");
|
|
4310
4736
|
this.stopWatcher();
|
|
4311
4737
|
this.armWatcher();
|
|
4312
4738
|
this.server?.sendToolListChanged();
|
|
4739
|
+
const wantRelay = isRelayMode(mode);
|
|
4313
4740
|
return {
|
|
4314
4741
|
mode,
|
|
4315
|
-
environment: deriveEnvironment(target.connection.kind, getLiveIntent()),
|
|
4742
|
+
environment: deriveEnvironment(target.connection.kind, getLiveIntent(), target.relayOrigin),
|
|
4316
4743
|
kind: target.connection.kind,
|
|
4317
4744
|
liveGuardActive: target.connection.kind === "relay" && getLiveIntent(),
|
|
4318
4745
|
nextStep: wantRelay ? "build_attach_url로 attach QR을 생성하세요 (relay 세션)." : "list_pages로 로컬 Chromium 페이지 attach를 확인하세요."
|
|
@@ -4342,7 +4769,8 @@ async function runDebugServer(options = {}) {
|
|
|
4342
4769
|
const diagnosticsCollector = new InMemoryDiagnosticsCollector();
|
|
4343
4770
|
const router = new DualConnectionRouter({
|
|
4344
4771
|
eager: relayFamily,
|
|
4345
|
-
|
|
4772
|
+
eagerKey: "relay-intoss",
|
|
4773
|
+
bootLazyFor: (key) => key === "relay-external" ? bootExternalRelayFamily(readMobileRelayBaseUrl()) : bootLocalFamily(),
|
|
4346
4774
|
diagnosticsCollector,
|
|
4347
4775
|
devtoolsOpener
|
|
4348
4776
|
});
|
|
@@ -4471,7 +4899,8 @@ async function runLocalDebugServer(options = {}) {
|
|
|
4471
4899
|
const diagnosticsCollector = new InMemoryDiagnosticsCollector();
|
|
4472
4900
|
const router = new DualConnectionRouter({
|
|
4473
4901
|
eager: localFamily,
|
|
4474
|
-
|
|
4902
|
+
eagerKey: "local",
|
|
4903
|
+
bootLazyFor: (key) => key === "relay-external" ? bootExternalRelayFamily(readMobileRelayBaseUrl()) : bootRelayFamily({
|
|
4475
4904
|
verifyAuth,
|
|
4476
4905
|
onWssUrl: (wssUrl) => lockHandle.updateWssUrl(wssUrl)
|
|
4477
4906
|
}),
|
|
@@ -4558,6 +4987,126 @@ async function runLocalDebugServer(options = {}) {
|
|
|
4558
4987
|
});
|
|
4559
4988
|
}
|
|
4560
4989
|
}
|
|
4990
|
+
/**
|
|
4991
|
+
* Boots the env-2 (real-device PWA) debug stack and serves it over stdio
|
|
4992
|
+
* (issue #378). The external Chii relay is the EAGER family here.
|
|
4993
|
+
*
|
|
4994
|
+
* Unlike `runDebugServer` (which starts its own relay + cloudflared tunnel),
|
|
4995
|
+
* `runMobileDebugServer` attaches to a relay the unplugin ALREADY brought up
|
|
4996
|
+
* (`tunnel: { cdp: true }`) and exposed via `AIT_RELAY_BASE_URL`. The MCP only
|
|
4997
|
+
* opens a CDP client against that external relay — it never starts or tears down
|
|
4998
|
+
* a relay or a tunnel it did not own (see {@link bootExternalRelayFamily}).
|
|
4999
|
+
*
|
|
5000
|
+
* Symmetry with `runDebugServer` / `runLocalDebugServer` (#356, #378): the env-2
|
|
5001
|
+
* external relay is eager; the local family and the intoss relay family are
|
|
5002
|
+
* lazy-booted on the first `start_debug({ mode: 'local' | 'staging' | 'live' })`,
|
|
5003
|
+
* so a `--target=mobile` session can hot-switch without a restart. The active
|
|
5004
|
+
* env derives to `relay-mobile` (external-PWA origin, liveIntent off).
|
|
5005
|
+
*
|
|
5006
|
+
* SECRET-HANDLING: `AIT_RELAY_BASE_URL` is read once here via
|
|
5007
|
+
* {@link readMobileRelayBaseUrl}; when unset it throws
|
|
5008
|
+
* {@link MOBILE_RELAY_BASE_URL_MISSING_MESSAGE} — a message that names the env
|
|
5009
|
+
* var and how to obtain it, never echoing any URL value. The error propagates to
|
|
5010
|
+
* the bin entry's fatal handler (the missing-URL path prints the guidance, not a
|
|
5011
|
+
* value). The present value is passed straight to the CDP client, never logged.
|
|
5012
|
+
*/
|
|
5013
|
+
async function runMobileDebugServer(options = {}) {
|
|
5014
|
+
const relayBaseUrl = readMobileRelayBaseUrl();
|
|
5015
|
+
const lockHandle = acquireLock({ force: options.force ?? false });
|
|
5016
|
+
const externalRelayFamily = await bootExternalRelayFamily(relayBaseUrl);
|
|
5017
|
+
const verifyAuth = buildRelayVerifyAuth();
|
|
5018
|
+
const devtoolsOpener = new AutoDevtoolsOpener();
|
|
5019
|
+
const diagnosticsCollector = new InMemoryDiagnosticsCollector();
|
|
5020
|
+
const router = new DualConnectionRouter({
|
|
5021
|
+
eager: externalRelayFamily,
|
|
5022
|
+
eagerKey: "relay-external",
|
|
5023
|
+
bootLazyFor: (key) => key === "local" ? bootLocalFamily() : bootRelayFamily({
|
|
5024
|
+
verifyAuth,
|
|
5025
|
+
onWssUrl: (wssUrl) => lockHandle.updateWssUrl(wssUrl)
|
|
5026
|
+
}),
|
|
5027
|
+
diagnosticsCollector,
|
|
5028
|
+
devtoolsOpener
|
|
5029
|
+
});
|
|
5030
|
+
const aitSource = new RoutingAitSource(() => {
|
|
5031
|
+
return router.active;
|
|
5032
|
+
});
|
|
5033
|
+
let qrServer;
|
|
5034
|
+
try {
|
|
5035
|
+
qrServer = await startQrHttpServer();
|
|
5036
|
+
} catch (err) {
|
|
5037
|
+
logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
|
|
5038
|
+
}
|
|
5039
|
+
const server = createDebugServer({
|
|
5040
|
+
connection: router.active,
|
|
5041
|
+
router,
|
|
5042
|
+
aitSource,
|
|
5043
|
+
getTunnelStatus: () => router.relayTunnelStatus(),
|
|
5044
|
+
get qrHttpServer() {
|
|
5045
|
+
return qrServer;
|
|
5046
|
+
},
|
|
5047
|
+
diagnosticsCollector,
|
|
5048
|
+
...process.env.AIT_DEBUG_TOTP_SECRET ? { totpSecret: process.env.AIT_DEBUG_TOTP_SECRET } : {}
|
|
5049
|
+
});
|
|
5050
|
+
const transport = new StdioServerTransport();
|
|
5051
|
+
let closed = false;
|
|
5052
|
+
let parentWatcher = null;
|
|
5053
|
+
const shutdown = () => {
|
|
5054
|
+
if (closed) return;
|
|
5055
|
+
closed = true;
|
|
5056
|
+
parentWatcher?.stop();
|
|
5057
|
+
router.stopWatcher();
|
|
5058
|
+
for (const family of router.bootedFamilies()) family.stop();
|
|
5059
|
+
server.close();
|
|
5060
|
+
qrServer?.close();
|
|
5061
|
+
lockHandle.release();
|
|
5062
|
+
};
|
|
5063
|
+
process.once("SIGINT", shutdown);
|
|
5064
|
+
process.once("SIGTERM", shutdown);
|
|
5065
|
+
process.once("SIGHUP", shutdown);
|
|
5066
|
+
process.on("exit", () => {
|
|
5067
|
+
if (!closed) {
|
|
5068
|
+
closed = true;
|
|
5069
|
+
parentWatcher?.stop();
|
|
5070
|
+
router.stopWatcher();
|
|
5071
|
+
for (const family of router.bootedFamilies()) family.stop();
|
|
5072
|
+
lockHandle.release();
|
|
5073
|
+
}
|
|
5074
|
+
});
|
|
5075
|
+
process.on("uncaughtException", (err) => {
|
|
5076
|
+
logError("tool.error", {
|
|
5077
|
+
msg: `uncaughtException: ${String(err)}`,
|
|
5078
|
+
errorKind: "uncaught",
|
|
5079
|
+
mode: "mobile"
|
|
5080
|
+
});
|
|
5081
|
+
shutdown();
|
|
5082
|
+
process.exit(1);
|
|
5083
|
+
});
|
|
5084
|
+
process.on("unhandledRejection", (reason) => {
|
|
5085
|
+
logError("tool.error", {
|
|
5086
|
+
msg: `unhandledRejection: ${String(reason)}`,
|
|
5087
|
+
errorKind: "unhandled-rejection",
|
|
5088
|
+
mode: "mobile"
|
|
5089
|
+
});
|
|
5090
|
+
shutdown();
|
|
5091
|
+
process.exit(1);
|
|
5092
|
+
});
|
|
5093
|
+
await server.connect(transport);
|
|
5094
|
+
router.start(server);
|
|
5095
|
+
if (process.env.AIT_DEBUG_NO_PARENT_WATCH !== "1") {
|
|
5096
|
+
parentWatcher = startParentWatcher(() => {
|
|
5097
|
+
shutdown();
|
|
5098
|
+
process.exit(0);
|
|
5099
|
+
}, { intervalMs: 5e3 });
|
|
5100
|
+
process.stdin.once("end", () => {
|
|
5101
|
+
shutdown();
|
|
5102
|
+
process.exit(0);
|
|
5103
|
+
});
|
|
5104
|
+
process.stdin.once("close", () => {
|
|
5105
|
+
shutdown();
|
|
5106
|
+
process.exit(0);
|
|
5107
|
+
});
|
|
5108
|
+
}
|
|
5109
|
+
}
|
|
4561
5110
|
//#endregion
|
|
4562
5111
|
//#region src/mcp/ait-http-source.ts
|
|
4563
5112
|
function isObject(value) {
|
|
@@ -4986,7 +5535,7 @@ function createDevServer(deps = {}) {
|
|
|
4986
5535
|
const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
|
|
4987
5536
|
const server = new Server({
|
|
4988
5537
|
name: "ait-devtools",
|
|
4989
|
-
version: "0.1.
|
|
5538
|
+
version: "0.1.56"
|
|
4990
5539
|
}, { capabilities: { tools: {} } });
|
|
4991
5540
|
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
|
|
4992
5541
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -5061,11 +5610,15 @@ async function runDevServer() {
|
|
|
5061
5610
|
*
|
|
5062
5611
|
* --mode=debug (default)
|
|
5063
5612
|
* --target=relay (default) — CDP/Chii relay + cloudflared quick tunnel.
|
|
5064
|
-
* Attach a running mini-app (real Toss WebView, env
|
|
5613
|
+
* Attach a running mini-app (real Toss WebView, env 3/4) and read its
|
|
5065
5614
|
* console + network over CDP without a human watching a phone.
|
|
5066
5615
|
* --target=local — CDP direct-attach to a local Chromium launched by the
|
|
5067
5616
|
* MCP server (env 1). No relay or tunnel; the browser is launched
|
|
5068
5617
|
* pointing at AIT_DEVTOOLS_URL (default http://localhost:5173).
|
|
5618
|
+
* --target=mobile — CDP attach to an EXTERNAL Chii relay the unplugin
|
|
5619
|
+
* already brought up for the env-2 real-device PWA (`tunnel: { cdp: true }`),
|
|
5620
|
+
* exposed via AIT_RELAY_BASE_URL. The MCP starts no relay or tunnel; it
|
|
5621
|
+
* only opens a CDP client against that external relay (issue #378).
|
|
5069
5622
|
*
|
|
5070
5623
|
* --mode=dev — dev mode — reads the live browser mock state from a running
|
|
5071
5624
|
* Vite dev server (the devtools#130 `devtools_get_mock_state` surface).
|
|
@@ -5119,8 +5672,9 @@ function parseMode(argv) {
|
|
|
5119
5672
|
* Parses `--target=<value>` / `--target <value>` from argv; default `relay`.
|
|
5120
5673
|
*
|
|
5121
5674
|
* Only meaningful when `--mode=debug`:
|
|
5122
|
-
* - `relay` — phone/WebView attach over Chii relay + cloudflared tunnel (env
|
|
5675
|
+
* - `relay` — phone/WebView attach over Chii relay + cloudflared tunnel (env 3/4).
|
|
5123
5676
|
* - `local` — local Chromium CDP attach (env 1, no relay needed).
|
|
5677
|
+
* - `mobile` — CDP attach to an EXTERNAL relay (env 2 PWA, AIT_RELAY_BASE_URL).
|
|
5124
5678
|
*/
|
|
5125
5679
|
function parseTarget(argv) {
|
|
5126
5680
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -5129,7 +5683,7 @@ function parseTarget(argv) {
|
|
|
5129
5683
|
if (arg.startsWith("--target=")) return normalizeTarget(arg.slice(9));
|
|
5130
5684
|
if (arg === "--target") {
|
|
5131
5685
|
const next = argv[i + 1];
|
|
5132
|
-
if (next === void 0) throw new Error("--target requires a value: 'relay' (default) or '
|
|
5686
|
+
if (next === void 0) throw new Error("--target requires a value: 'relay' (default), 'local', or 'mobile'.");
|
|
5133
5687
|
return normalizeTarget(next);
|
|
5134
5688
|
}
|
|
5135
5689
|
}
|
|
@@ -5143,7 +5697,8 @@ function normalizeMode(value) {
|
|
|
5143
5697
|
function normalizeTarget(value) {
|
|
5144
5698
|
if (value === "relay") return "relay";
|
|
5145
5699
|
if (value === "local") return "local";
|
|
5146
|
-
|
|
5700
|
+
if (value === "mobile") return "mobile";
|
|
5701
|
+
throw new Error(`Unknown --target '${value}'. Expected 'relay' (default), 'local', or 'mobile'.`);
|
|
5147
5702
|
}
|
|
5148
5703
|
async function main() {
|
|
5149
5704
|
const args = process.argv.slice(2);
|
|
@@ -5153,6 +5708,7 @@ async function main() {
|
|
|
5153
5708
|
const target = parseTarget(args);
|
|
5154
5709
|
const force = parseForce(args);
|
|
5155
5710
|
if (target === "local") await runLocalDebugServer({ force });
|
|
5711
|
+
else if (target === "mobile") await runMobileDebugServer({ force });
|
|
5156
5712
|
else await runDebugServer({ force });
|
|
5157
5713
|
}
|
|
5158
5714
|
}
|