@ait-co/devtools 0.1.55 → 0.1.57
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 +77 -32
- package/README.md +76 -31
- 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 +1160 -381
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.js +22 -11
- package/dist/mcp/server.js.map +1 -1
- package/dist/panel/index.js +2 -2
- package/dist/relay-secret-store-DnTNl-9z.cjs +140 -0
- package/dist/relay-secret-store-DnTNl-9z.cjs.map +1 -0
- package/dist/relay-secret-store-DqyUoeXy.js +140 -0
- package/dist/relay-secret-store-DqyUoeXy.js.map +1 -0
- package/dist/totp-BkP5yU2K.js +186 -0
- package/dist/totp-BkP5yU2K.js.map +1 -0
- package/dist/totp-CQFmgOhM.js +3 -0
- package/dist/totp-D0a8VwoR.js +187 -0
- package/dist/totp-D0a8VwoR.js.map +1 -0
- package/dist/totp-DLgGbySX.cjs +188 -0
- package/dist/totp-DLgGbySX.cjs.map +1 -0
- package/dist/{tunnel-D0_TwDNE.js → tunnel-CI61NvPI.js} +13 -5
- package/dist/tunnel-CI61NvPI.js.map +1 -0
- package/dist/{tunnel-BYP0yRBN.cjs → tunnel-nKYPtc-g.cjs} +13 -5
- package/dist/tunnel-nKYPtc-g.cjs.map +1 -0
- package/dist/unplugin/index.cjs +31 -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 +31 -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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { i as generateTotp, n as assertRelayAuthConfigured, r as buildRelayVerifyAuth } from "../totp-D0a8VwoR.js";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
3
4
|
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { argv } from "node:process";
|
|
@@ -12,8 +13,8 @@ import { createServer } from "node:http";
|
|
|
12
13
|
import { spawn } from "node:child_process";
|
|
13
14
|
import net from "node:net";
|
|
14
15
|
import { homedir, platform } from "node:os";
|
|
15
|
-
import { join } from "node:path";
|
|
16
|
-
import {
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { randomBytes } from "node:crypto";
|
|
17
18
|
import { Tunnel, bin, install } from "cloudflared";
|
|
18
19
|
//#region \0rolldown/runtime.js
|
|
19
20
|
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
@@ -718,6 +719,157 @@ async function startChiiRelay(options = {}) {
|
|
|
718
719
|
};
|
|
719
720
|
}
|
|
720
721
|
//#endregion
|
|
722
|
+
//#region src/mcp/deeplink.ts
|
|
723
|
+
/**
|
|
724
|
+
* URL of the AITC Sandbox launcher PWA.
|
|
725
|
+
*
|
|
726
|
+
* Declared here (not imported from `src/unplugin/tunnel.ts`) to respect the
|
|
727
|
+
* mcp → unplugin layering boundary. unplugin/tunnel.ts declares its own copy
|
|
728
|
+
* for the same reason — keep the two in sync when the URL changes.
|
|
729
|
+
*/
|
|
730
|
+
const LAUNCHER_URL = "https://devtools.aitc.dev/launcher/";
|
|
731
|
+
/**
|
|
732
|
+
* Builds a launcher PWA deep-link for env-2 MCP-attach (issue #378).
|
|
733
|
+
*
|
|
734
|
+
* The launcher at {@link LAUNCHER_URL} renders tunnelUrl in a full-viewport
|
|
735
|
+
* iframe. `&debug=1&relay=<wssUrl>` is forwarded onto the iframe src so the
|
|
736
|
+
* framed page's in-app debug gate (Layer C) is satisfied and a Chii target.js
|
|
737
|
+
* is injected. `&at=<totpCode>` is added only when a code is provided (same
|
|
738
|
+
* conditional as {@link buildDeepLinkAttachUrl}).
|
|
739
|
+
*
|
|
740
|
+
* Unlike `buildDeepLinkAttachUrl` (which splices onto a non-special scheme URL
|
|
741
|
+
* via raw string manipulation), this function uses WHATWG `encodeURIComponent`
|
|
742
|
+
* because the target is a standard `https:` URL.
|
|
743
|
+
*
|
|
744
|
+
* SECRET-HANDLING: `totpCode` (when provided) is placed into the `at=` param
|
|
745
|
+
* only — never logged or returned separately. Callers must NOT log the result
|
|
746
|
+
* of this function to stdout/stderr.
|
|
747
|
+
*
|
|
748
|
+
* @param tunnelUrl - The `https://*.trycloudflare.com` app tunnel URL
|
|
749
|
+
* (`AIT_TUNNEL_BASE_URL`). This is the URL the launcher frames.
|
|
750
|
+
* @param wssUrl - The `wss://` relay URL the framed page will attach to.
|
|
751
|
+
* @param totpCode - Optional current TOTP code (6 digits). When provided, it
|
|
752
|
+
* is appended as `at=<totpCode>`. Must be computed at call time — it rotates
|
|
753
|
+
* every 30 s. Omit when TOTP is disabled.
|
|
754
|
+
* @returns The launcher deep-link URL with `?url=<enc>&debug=1&relay=<enc>
|
|
755
|
+
* [&at=<code>]` params.
|
|
756
|
+
*/
|
|
757
|
+
function buildLauncherAttachUrl(tunnelUrl, wssUrl, totpCode) {
|
|
758
|
+
let url = `${LAUNCHER_URL}?url=${encodeURIComponent(tunnelUrl)}&debug=1&relay=${encodeURIComponent(wssUrl)}`;
|
|
759
|
+
if (totpCode !== void 0 && totpCode !== "") url += `&at=${encodeURIComponent(totpCode)}`;
|
|
760
|
+
return url;
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Build a self-attaching dogfood deep link.
|
|
764
|
+
*
|
|
765
|
+
* `ait deploy --scheme-only` prints an `intoss-private://…?_deploymentId=<uuid>`
|
|
766
|
+
* URL that opens a dogfood bundle on a phone. The in-app debug gate
|
|
767
|
+
* (`src/in-app/gate.ts`) auto-attaches when the entry URL also carries
|
|
768
|
+
* `debug=1` and `relay=<wss-url>`. This helper splices those params (plus
|
|
769
|
+
* `at=<code>` when TOTP is enabled) into the scheme URL; rendering the result
|
|
770
|
+
* as a QR code and scanning it with the phone camera opens the mini-app and
|
|
771
|
+
* attaches it to the live Chii relay. QR is the single entry path — it needs
|
|
772
|
+
* no USB cable, platform CLI, or driver, and works the same on iOS/Android.
|
|
773
|
+
*
|
|
774
|
+
* The Toss app propagates extra query params from the entry deep link into the
|
|
775
|
+
* mini-app WebView's `location.search` (confirmed behavior), so the gate reads
|
|
776
|
+
* them at attach time.
|
|
777
|
+
*
|
|
778
|
+
* TOTP `at=` param:
|
|
779
|
+
* When a TOTP secret is active, `buildDeepLinkAttachUrl` accepts an optional
|
|
780
|
+
* `totpCode` argument and splices `at=<code>` alongside `debug` and `relay`.
|
|
781
|
+
* The code must be computed by the caller at call time — do NOT pre-compute
|
|
782
|
+
* and cache it, because the 30-second window expires quickly. The in-app gate
|
|
783
|
+
* (`src/in-app/gate.ts` Layer C) validates this code against the baked secret.
|
|
784
|
+
*
|
|
785
|
+
* Why not `URL`/`URLSearchParams`: `intoss-private:` is a non-special scheme.
|
|
786
|
+
* The WHATWG `URL` parser treats such schemes opaquely (no host/path/query
|
|
787
|
+
* decomposition you can rely on across runtimes), so query manipulation via
|
|
788
|
+
* `url.searchParams` is not portable here. We splice the query string directly
|
|
789
|
+
* on the raw string instead, which keeps the scheme, authority, path, and any
|
|
790
|
+
* pre-existing params (notably `_deploymentId`) byte-for-byte intact.
|
|
791
|
+
*/
|
|
792
|
+
/**
|
|
793
|
+
* Suspicious/generic authority values that indicate a malformed or placeholder
|
|
794
|
+
* scheme URL. These are host strings that will almost certainly cause the Toss
|
|
795
|
+
* app to fail with "bundle not found" silently.
|
|
796
|
+
*
|
|
797
|
+
* The expected form from `ait deploy --scheme-only` is:
|
|
798
|
+
* intoss-private://<appName>?_deploymentId=<uuid>
|
|
799
|
+
* where `<appName>` is a non-generic string like `aitc-sdk-example`.
|
|
800
|
+
*/
|
|
801
|
+
const SUSPICIOUS_AUTHORITIES = new Set([
|
|
802
|
+
"",
|
|
803
|
+
"web",
|
|
804
|
+
"localhost",
|
|
805
|
+
"127.0.0.1",
|
|
806
|
+
"app"
|
|
807
|
+
]);
|
|
808
|
+
/**
|
|
809
|
+
* Validates the authority (host) portion of a scheme URL.
|
|
810
|
+
*
|
|
811
|
+
* Returns a warning message if the authority is missing or looks like a
|
|
812
|
+
* placeholder, or `null` if the authority looks valid.
|
|
813
|
+
*
|
|
814
|
+
* Expected form: `intoss-private://<appName>?_deploymentId=<uuid>`
|
|
815
|
+
* The authority must be a non-empty, non-generic app name (e.g. `aitc-sdk-example`).
|
|
816
|
+
*/
|
|
817
|
+
function validateSchemeAuthority(schemeUrl) {
|
|
818
|
+
const afterScheme = schemeUrl.replace(/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//, "");
|
|
819
|
+
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`.";
|
|
820
|
+
const authorityEnd = afterScheme.search(/[/?#]/);
|
|
821
|
+
const authority = authorityEnd === -1 ? afterScheme : afterScheme.slice(0, authorityEnd);
|
|
822
|
+
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.`;
|
|
823
|
+
return null;
|
|
824
|
+
}
|
|
825
|
+
function stripExisting(query, key) {
|
|
826
|
+
if (query === "") return "";
|
|
827
|
+
return query.split("&").filter((pair) => pair !== "" && pair.split("=")[0] !== key).join("&");
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Splices `debug=1`, `relay=<wssUrl>`, and (optionally) `at=<totpCode>` into a
|
|
831
|
+
* scheme URL's query string, preserving everything else (scheme, authority,
|
|
832
|
+
* path, hash, and the existing `_deploymentId` param). If any of the spliced
|
|
833
|
+
* params is already present it is replaced so the helper is idempotent.
|
|
834
|
+
*
|
|
835
|
+
* @param schemeUrl - The `intoss-private://…?_deploymentId=<uuid>` URL printed
|
|
836
|
+
* by `ait deploy --scheme-only`. Must already carry `_deploymentId` (Layer B
|
|
837
|
+
* of the gate); this helper does not invent one.
|
|
838
|
+
* @param wssUrl - The live relay URL (`wss://…trycloudflare.com`) from the
|
|
839
|
+
* running debug MCP server's quick tunnel.
|
|
840
|
+
* @param totpCode - Optional current TOTP code (6 digits). When provided, it
|
|
841
|
+
* is spliced as `at=<totpCode>`. Must be computed at call time — it rotates
|
|
842
|
+
* every 30 s. Pass `undefined` or omit when TOTP is disabled.
|
|
843
|
+
* @returns The same URL with `debug=1&relay=<encoded wssUrl>[&at=<totpCode>]`
|
|
844
|
+
* appended.
|
|
845
|
+
* @throws If `wssUrl` is not a `wss:` URL (the gate rejects anything else, so
|
|
846
|
+
* producing such a link would be a silent dead end).
|
|
847
|
+
*/
|
|
848
|
+
function buildDeepLinkAttachUrl(schemeUrl, wssUrl, totpCode) {
|
|
849
|
+
let relay;
|
|
850
|
+
try {
|
|
851
|
+
relay = new URL(wssUrl);
|
|
852
|
+
} catch {
|
|
853
|
+
throw new Error(`relay URL is not a valid URL: ${wssUrl}`);
|
|
854
|
+
}
|
|
855
|
+
if (relay.protocol !== "wss:") throw new Error(`relay URL must use the wss: scheme, got ${relay.protocol} (${wssUrl})`);
|
|
856
|
+
const hashIndex = schemeUrl.indexOf("#");
|
|
857
|
+
const hash = hashIndex === -1 ? "" : schemeUrl.slice(hashIndex);
|
|
858
|
+
const beforeHash = hashIndex === -1 ? schemeUrl : schemeUrl.slice(0, hashIndex);
|
|
859
|
+
const queryIndex = beforeHash.indexOf("?");
|
|
860
|
+
const base = queryIndex === -1 ? beforeHash : beforeHash.slice(0, queryIndex);
|
|
861
|
+
let query = queryIndex === -1 ? "" : beforeHash.slice(queryIndex + 1);
|
|
862
|
+
const appended = [["debug", "1"], ["relay", wssUrl]];
|
|
863
|
+
if (totpCode !== void 0 && totpCode !== "") appended.push(["at", totpCode]);
|
|
864
|
+
query = stripExisting(query, "at");
|
|
865
|
+
for (const [key] of appended) query = stripExisting(query, key);
|
|
866
|
+
for (const [key, value] of appended) {
|
|
867
|
+
const pair = `${key}=${encodeURIComponent(value)}`;
|
|
868
|
+
query = query === "" ? pair : `${query}&${pair}`;
|
|
869
|
+
}
|
|
870
|
+
return `${base}?${query}${hash}`;
|
|
871
|
+
}
|
|
872
|
+
//#endregion
|
|
721
873
|
//#region src/mcp/devtools-opener.ts
|
|
722
874
|
/**
|
|
723
875
|
* Base URL for the Chrome DevTools inspector hosted on appspot.
|
|
@@ -897,15 +1049,25 @@ function wrapEnvelope(data, ctx) {
|
|
|
897
1049
|
//#endregion
|
|
898
1050
|
//#region src/mcp/environment.ts
|
|
899
1051
|
/**
|
|
900
|
-
* Returns `true` when the environment is any relay variant (`relay-dev
|
|
901
|
-
* `relay-live`). Use this instead of `env === 'relay'` for
|
|
1052
|
+
* Returns `true` when the environment is any relay variant (`relay-dev`,
|
|
1053
|
+
* `relay-live`, or `relay-mobile`). Use this instead of `env === 'relay'` for
|
|
1054
|
+
* tier checks — every relay env surfaces the Tier B / relay-only tool set.
|
|
1055
|
+
*
|
|
1056
|
+
* Written as an exhaustive switch so a future `McpEnvironment` member that is
|
|
1057
|
+
* missing an arm is a TS compile error rather than a silent `false`.
|
|
902
1058
|
*/
|
|
903
1059
|
function isRelayEnv(env) {
|
|
904
|
-
|
|
1060
|
+
switch (env) {
|
|
1061
|
+
case "relay-dev":
|
|
1062
|
+
case "relay-live":
|
|
1063
|
+
case "relay-mobile": return true;
|
|
1064
|
+
case "mock": return false;
|
|
1065
|
+
}
|
|
905
1066
|
}
|
|
906
1067
|
/**
|
|
907
1068
|
* Returns `true` when the environment is the LIVE relay (`relay-live`).
|
|
908
|
-
* This is the guard condition for side-effect tool protection.
|
|
1069
|
+
* This is the guard condition for side-effect tool protection. `relay-mobile`
|
|
1070
|
+
* is a dev-intent env (env 2 PWA) and is NOT live.
|
|
909
1071
|
*/
|
|
910
1072
|
function isLiveRelayEnv(env) {
|
|
911
1073
|
return env === "relay-live";
|
|
@@ -913,25 +1075,38 @@ function isLiveRelayEnv(env) {
|
|
|
913
1075
|
/**
|
|
914
1076
|
* Maps the `McpEnvironment` union to the legacy two-value union
|
|
915
1077
|
* (`'mock' | 'relay'`) for backward-compatible fields in diagnostics output.
|
|
1078
|
+
* Every relay variant (incl. `relay-mobile`) collapses to `'relay'`.
|
|
916
1079
|
*/
|
|
917
1080
|
function toLegacyEnv(env) {
|
|
918
1081
|
if (env === "mock") return "mock";
|
|
919
1082
|
return "relay";
|
|
920
1083
|
}
|
|
921
1084
|
/**
|
|
922
|
-
* Reconstructs the
|
|
923
|
-
* orthogonal signals (
|
|
1085
|
+
* Reconstructs the four-value `McpEnvironment` output string from the
|
|
1086
|
+
* orthogonal signals (issues #348, #378):
|
|
1087
|
+
*
|
|
1088
|
+
* - `kind === 'local'` → `'mock'`
|
|
1089
|
+
* - `kind === 'relay'` && liveIntent → `'relay-live'`
|
|
1090
|
+
* - `kind === 'relay'` && !liveIntent && origin 'external-pwa' → `'relay-mobile'`
|
|
1091
|
+
* - `kind === 'relay'` && !liveIntent && origin intoss/undefined → `'relay-dev'`
|
|
924
1092
|
*
|
|
925
|
-
*
|
|
926
|
-
*
|
|
927
|
-
*
|
|
1093
|
+
* `relayOrigin` is the booted-family discriminator (NOT sniffed from the URL)
|
|
1094
|
+
* that distinguishes the env-2 external-PWA relay (`relay-mobile`) from the
|
|
1095
|
+
* intoss-private dogfood relay (`relay-dev`); both are `kind: 'relay'`.
|
|
928
1096
|
*
|
|
929
1097
|
* Pure — used at every output boundary (envelope `meta.env`, `get_diagnostics`,
|
|
930
1098
|
* `measure_safe_area` provenance) so the surface never sniffs a URL again.
|
|
1099
|
+
*
|
|
1100
|
+
* Written switch-style so a missing arm is a TS compile error (never falls
|
|
1101
|
+
* through to a default).
|
|
931
1102
|
*/
|
|
932
|
-
function deriveEnvironment(kind, liveIntent) {
|
|
933
|
-
|
|
934
|
-
|
|
1103
|
+
function deriveEnvironment(kind, liveIntent, relayOrigin) {
|
|
1104
|
+
switch (kind) {
|
|
1105
|
+
case "local": return "mock";
|
|
1106
|
+
case "relay":
|
|
1107
|
+
if (liveIntent) return "relay-live";
|
|
1108
|
+
return relayOrigin === "external-pwa" ? "relay-mobile" : "relay-dev";
|
|
1109
|
+
}
|
|
935
1110
|
}
|
|
936
1111
|
/**
|
|
937
1112
|
* Module-level `relay-dev` vs `relay-live` intent bit (issue #348).
|
|
@@ -1010,12 +1185,23 @@ function pageCrashError(toolName) {
|
|
|
1010
1185
|
return mcpError(`${toolName ? `${toolName}: ` : ""}페이지가 crash됐습니다. 토스 앱을 재실행한 뒤 build_attach_url → QR 스캔으로 재attach하세요.`);
|
|
1011
1186
|
}
|
|
1012
1187
|
/**
|
|
1013
|
-
* 상태 4: SDK 부재 — window.__sdkCall이 주입되지
|
|
1014
|
-
*
|
|
1015
|
-
* call_sdk 호출 시 브리지가 없을 때.
|
|
1188
|
+
* 상태 4: SDK 부재 — window.__sdkCall이 주입되지 않았다.
|
|
1189
|
+
*
|
|
1190
|
+
* call_sdk 호출 시 브리지가 없을 때. 같은 "브리지 부재"라도 다음 행동은
|
|
1191
|
+
* connection 종류에 따라 정반대다 (issue #360):
|
|
1192
|
+
* - relay(`--target` 없는 intoss / env-2): dogfood 빌드가 아니다 → dogfood
|
|
1193
|
+
* 채널로 재배포 후 QR 재스캔.
|
|
1194
|
+
* - local(`--target=local`, env 1 로컬 브라우저): 재배포가 아니라 dev 서버를
|
|
1195
|
+
* `pnpm dev`로 띄웠는지 + unplugin alias가 `@apps-in-toss/web-framework`를
|
|
1196
|
+
* devtools mock으로 resolve하는지 확인. dev 빌드면 `import.meta.env.DEV`
|
|
1197
|
+
* 경로로 `window.__sdkCall`이 자동 설치된다.
|
|
1198
|
+
*
|
|
1199
|
+
* `isLocal`이 생략되면 relay 안내(이전 동작)를 유지한다.
|
|
1016
1200
|
*/
|
|
1017
|
-
function sdkAbsentError(toolName) {
|
|
1018
|
-
|
|
1201
|
+
function sdkAbsentError(toolName, isLocal = false) {
|
|
1202
|
+
const prefix = toolName ? `${toolName}: ` : "";
|
|
1203
|
+
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\`이 자동 설치됩니다.`);
|
|
1204
|
+
return mcpError(`${prefix}window.__sdkCall이 주입되지 않았습니다 (dogfood 빌드가 아닙니다). dogfood 채널(intoss-private)로 재배포 후 QR을 다시 스캔하세요: \`ait build && aitcc app deploy\`.`);
|
|
1019
1205
|
}
|
|
1020
1206
|
/**
|
|
1021
1207
|
* relay-live 환경에서 side-effect 도구(`call_sdk`, `evaluate`)를 `confirm: true`
|
|
@@ -1048,10 +1234,10 @@ function relayDisconnectError(toolName) {
|
|
|
1048
1234
|
* - 연결 끊김 패턴 (`relay에 연결되어 있지 않습니다`, `relay WebSocket`) → relayDisconnectError
|
|
1049
1235
|
* - 그 외 (일반 에러) → 원본 메시지를 포함한 mcpError
|
|
1050
1236
|
*/
|
|
1051
|
-
function classifyToolError(err, toolName) {
|
|
1237
|
+
function classifyToolError(err, toolName, isLocal = false) {
|
|
1052
1238
|
const message = err instanceof Error ? err.message : String(err);
|
|
1053
1239
|
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);
|
|
1240
|
+
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
1241
|
if (message.includes("replaced-by-new-attach") || message.includes("targetCrashed") || message.includes("targetDestroyed") || message.includes("detachedFromTarget")) return pageCrashError(toolName);
|
|
1056
1242
|
if (message.includes("relay에 연결되어 있지 않습니다") || message.includes("relay WebSocket")) return relayDisconnectError(toolName);
|
|
1057
1243
|
return mcpError(`${toolName} 실패: ${message}\nlist_pages로 미니앱이 relay에 attach됐는지 확인하세요.`);
|
|
@@ -1397,12 +1583,71 @@ async function launchChromium(options = {}) {
|
|
|
1397
1583
|
/**
|
|
1398
1584
|
* 로컬 HTTP 서버를 127.0.0.1 random port(또는 `AIT_DEBUG_HTTP_PORT` env)로 시작한다.
|
|
1399
1585
|
* MCP debug server 생애주기에 묶어 사용 — `runDebugServer` shutdown 시 `close()`로 정리.
|
|
1586
|
+
*
|
|
1587
|
+
* @param getDashboardState - dashboard 상태를 반환하는 클로저. 주입 시 `GET /` dashboard와
|
|
1588
|
+
* `GET /events` SSE 스트림이 활성화된다. 미주입 시 두 라우트는 204/서비스 없음으로 응답.
|
|
1400
1589
|
*/
|
|
1401
|
-
async function startQrHttpServer() {
|
|
1590
|
+
async function startQrHttpServer(getDashboardState) {
|
|
1402
1591
|
const { default: QRCode } = await import("qrcode");
|
|
1403
|
-
|
|
1592
|
+
/** SSE 활성 연결 목록 — `notifyStateChange()` 시 전체 push. */
|
|
1593
|
+
const sseClients = [];
|
|
1594
|
+
/** SSE 연결 하나에 상태 이벤트를 flush한다. */
|
|
1595
|
+
function pushStateToClient(res, state) {
|
|
1596
|
+
const payload = JSON.stringify({
|
|
1597
|
+
tunnel: {
|
|
1598
|
+
up: state.tunnel.up,
|
|
1599
|
+
wssUrl: state.tunnel.wssUrl
|
|
1600
|
+
},
|
|
1601
|
+
pages: state.pages,
|
|
1602
|
+
attachUrl: state.attachUrl
|
|
1603
|
+
});
|
|
1604
|
+
res.write(`data: ${payload}\n\n`);
|
|
1605
|
+
}
|
|
1606
|
+
const server = createServer(async (req, res) => {
|
|
1404
1607
|
const [path, query = ""] = (req.url ?? "/").split("?", 2);
|
|
1405
1608
|
const params = new URLSearchParams(query ?? "");
|
|
1609
|
+
if (path === "/") {
|
|
1610
|
+
if (!getDashboardState) {
|
|
1611
|
+
res.writeHead(204, { "Content-Type": "text/plain; charset=utf-8" });
|
|
1612
|
+
res.end();
|
|
1613
|
+
return;
|
|
1614
|
+
}
|
|
1615
|
+
const state = getDashboardState();
|
|
1616
|
+
let qrDataUrl = null;
|
|
1617
|
+
if (state.attachUrl) try {
|
|
1618
|
+
qrDataUrl = await QRCode.toDataURL(state.attachUrl, {
|
|
1619
|
+
type: "image/png",
|
|
1620
|
+
errorCorrectionLevel: "M"
|
|
1621
|
+
});
|
|
1622
|
+
} catch {}
|
|
1623
|
+
const html = buildDashboardHtml(state, qrDataUrl);
|
|
1624
|
+
res.writeHead(200, {
|
|
1625
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1626
|
+
"Cache-Control": "no-store"
|
|
1627
|
+
});
|
|
1628
|
+
res.end(html);
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
if (path === "/events") {
|
|
1632
|
+
if (!getDashboardState) {
|
|
1633
|
+
res.writeHead(204, { "Content-Type": "text/plain; charset=utf-8" });
|
|
1634
|
+
res.end();
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
res.writeHead(200, {
|
|
1638
|
+
"Content-Type": "text/event-stream",
|
|
1639
|
+
"Cache-Control": "no-cache",
|
|
1640
|
+
Connection: "keep-alive",
|
|
1641
|
+
"X-Accel-Buffering": "no"
|
|
1642
|
+
});
|
|
1643
|
+
pushStateToClient(res, getDashboardState());
|
|
1644
|
+
sseClients.push(res);
|
|
1645
|
+
req.once("close", () => {
|
|
1646
|
+
const idx = sseClients.indexOf(res);
|
|
1647
|
+
if (idx !== -1) sseClients.splice(idx, 1);
|
|
1648
|
+
});
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1406
1651
|
if (path === "/attach") {
|
|
1407
1652
|
const encodedU = params.get("u") ?? "";
|
|
1408
1653
|
let attachUrl;
|
|
@@ -1476,6 +1721,13 @@ async function startQrHttpServer() {
|
|
|
1476
1721
|
buildAttachPageUrl(attachUrl) {
|
|
1477
1722
|
return `http://127.0.0.1:${port}/attach?u=${encodeURIComponent(attachUrl)}`;
|
|
1478
1723
|
},
|
|
1724
|
+
notifyStateChange() {
|
|
1725
|
+
if (!getDashboardState) return;
|
|
1726
|
+
const state = getDashboardState();
|
|
1727
|
+
for (const client of sseClients) try {
|
|
1728
|
+
pushStateToClient(client, state);
|
|
1729
|
+
} catch {}
|
|
1730
|
+
},
|
|
1479
1731
|
close() {
|
|
1480
1732
|
return new Promise((resolve, reject) => {
|
|
1481
1733
|
server.close((err) => err ? reject(err) : resolve());
|
|
@@ -1484,6 +1736,147 @@ async function startQrHttpServer() {
|
|
|
1484
1736
|
};
|
|
1485
1737
|
}
|
|
1486
1738
|
/**
|
|
1739
|
+
* Dashboard HTML — 터널/page/attachUrl 상태를 표시하고 SSE로 자동 갱신.
|
|
1740
|
+
*
|
|
1741
|
+
* SECRET-HANDLING:
|
|
1742
|
+
* - attachUrl은 url-box 안에서만 노출 (TOTP at= 코드 캡슐 그대로).
|
|
1743
|
+
* - tunnel wssUrl은 "터널 연결됨" 상태 표시에서 HOST가 아닌 UP/DOWN만 노출.
|
|
1744
|
+
* wssUrl 값 자체는 dashboard HTML에 넣지 않는다 — 브라우저 탭이 보안 경계 밖에 있음.
|
|
1745
|
+
* - inline <script>로 /events SSE 구독 — 빌드 파이프라인 추가 없음.
|
|
1746
|
+
*/
|
|
1747
|
+
function buildDashboardHtml(state, qrDataUrl) {
|
|
1748
|
+
const tunnelStatus = state.tunnel.up ? "연결됨" : "끊어짐";
|
|
1749
|
+
const tunnelClass = state.tunnel.up ? "status-up" : "status-down";
|
|
1750
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1751
|
+
const pagesHtml = state.pages.length > 0 ? state.pages.map((p) => {
|
|
1752
|
+
return `<li><span class="page-id">${p.id.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`)}</span> <span class="page-url">${p.url.slice(0, 120).replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`)}</span></li>`;
|
|
1753
|
+
}).join("\n") : "<li class=\"empty\">attach된 페이지 없음</li>";
|
|
1754
|
+
let attachSection;
|
|
1755
|
+
if (qrDataUrl && state.attachUrl) attachSection = `
|
|
1756
|
+
<img class="qr" src="${qrDataUrl}" alt="attach QR" />
|
|
1757
|
+
<p class="url-box">${state.attachUrl.replace(/[<>&"']/g, (c) => `&#${c.charCodeAt(0)};`)}</p>`;
|
|
1758
|
+
else attachSection = "<p class=\"hint\">build_attach_url MCP tool을 호출하면 QR이 여기에 표시됩니다.</p>";
|
|
1759
|
+
return `<!DOCTYPE html>
|
|
1760
|
+
<html lang="ko">
|
|
1761
|
+
<head>
|
|
1762
|
+
<meta charset="utf-8" />
|
|
1763
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1764
|
+
<title>AIT 디버그 Dashboard</title>
|
|
1765
|
+
<style>
|
|
1766
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
1767
|
+
body {
|
|
1768
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
1769
|
+
background: #0d1117; color: #c9d1d9;
|
|
1770
|
+
display: flex; flex-direction: column; align-items: center;
|
|
1771
|
+
min-height: 100vh; margin: 0; padding: 2rem 1rem;
|
|
1772
|
+
gap: 1.5rem;
|
|
1773
|
+
}
|
|
1774
|
+
h1 { font-size: 1.25rem; font-weight: 600; color: #e6edf3; margin: 0; text-align: center; }
|
|
1775
|
+
.updated { font-size: 0.75rem; opacity: 0.4; font-family: monospace; margin: 0; }
|
|
1776
|
+
section { width: 100%; max-width: 520px; }
|
|
1777
|
+
h2 { font-size: 1rem; font-weight: 600; color: #e6edf3; margin: 0 0 0.5rem; }
|
|
1778
|
+
.status { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600; }
|
|
1779
|
+
.status-up { background: #238636; color: #fff; }
|
|
1780
|
+
.status-down { background: #6e7681; color: #fff; }
|
|
1781
|
+
img.qr {
|
|
1782
|
+
width: min(80vw, 300px); height: auto;
|
|
1783
|
+
image-rendering: pixelated;
|
|
1784
|
+
background: #fff; padding: 0.75rem; border-radius: 10px;
|
|
1785
|
+
display: block; margin: 0.5rem auto;
|
|
1786
|
+
}
|
|
1787
|
+
.url-box {
|
|
1788
|
+
font-family: monospace; font-size: 0.7rem;
|
|
1789
|
+
word-break: break-all; opacity: 0.45;
|
|
1790
|
+
background: #161b22; padding: 0.6rem 0.85rem;
|
|
1791
|
+
border-radius: 6px; border: 1px solid #30363d; margin: 0.5rem 0 0;
|
|
1792
|
+
}
|
|
1793
|
+
.hint { font-size: 0.85rem; opacity: 0.5; margin: 0.25rem 0 0; }
|
|
1794
|
+
ul { margin: 0; padding-left: 1.25rem; }
|
|
1795
|
+
li { margin-bottom: 0.35rem; font-size: 0.85rem; line-height: 1.5; }
|
|
1796
|
+
li.empty { opacity: 0.4; list-style: none; padding-left: 0; }
|
|
1797
|
+
.page-id { font-family: monospace; font-size: 0.75rem; opacity: 0.5; margin-right: 0.4rem; }
|
|
1798
|
+
.page-url { word-break: break-all; }
|
|
1799
|
+
hr { border: none; border-top: 1px solid #21262d; width: 100%; margin: 0; }
|
|
1800
|
+
</style>
|
|
1801
|
+
</head>
|
|
1802
|
+
<body>
|
|
1803
|
+
<h1>AIT 디버그 Dashboard</h1>
|
|
1804
|
+
<p class="updated" id="updated">마지막 갱신: ${now}</p>
|
|
1805
|
+
|
|
1806
|
+
<section>
|
|
1807
|
+
<h2>터널 상태</h2>
|
|
1808
|
+
<span class="status ${tunnelClass}" id="tunnel-status">${tunnelStatus}</span>
|
|
1809
|
+
</section>
|
|
1810
|
+
|
|
1811
|
+
<hr />
|
|
1812
|
+
|
|
1813
|
+
<section>
|
|
1814
|
+
<h2>Attach QR</h2>
|
|
1815
|
+
<div id="attach-section">${attachSection}</div>
|
|
1816
|
+
</section>
|
|
1817
|
+
|
|
1818
|
+
<hr />
|
|
1819
|
+
|
|
1820
|
+
<section>
|
|
1821
|
+
<h2>연결된 Pages</h2>
|
|
1822
|
+
<ul id="pages-list">${pagesHtml}</ul>
|
|
1823
|
+
</section>
|
|
1824
|
+
|
|
1825
|
+
<script>
|
|
1826
|
+
// SSE — /events 구독해 상태 자동 갱신. 빌드 파이프라인 없는 인라인 스크립트.
|
|
1827
|
+
(function () {
|
|
1828
|
+
var src = new EventSource('/events');
|
|
1829
|
+
src.onmessage = function (e) {
|
|
1830
|
+
try {
|
|
1831
|
+
var s = JSON.parse(e.data);
|
|
1832
|
+
// 터널 상태 갱신
|
|
1833
|
+
var el = document.getElementById('tunnel-status');
|
|
1834
|
+
if (el) {
|
|
1835
|
+
el.textContent = s.tunnel && s.tunnel.up ? '연결됨' : '끊어짐';
|
|
1836
|
+
el.className = 'status ' + (s.tunnel && s.tunnel.up ? 'status-up' : 'status-down');
|
|
1837
|
+
}
|
|
1838
|
+
// page 목록 갱신
|
|
1839
|
+
var ul = document.getElementById('pages-list');
|
|
1840
|
+
if (ul) {
|
|
1841
|
+
if (!s.pages || s.pages.length === 0) {
|
|
1842
|
+
ul.innerHTML = '<li class="empty">attach된 페이지 없음</li>';
|
|
1843
|
+
} else {
|
|
1844
|
+
ul.innerHTML = s.pages.map(function (p) {
|
|
1845
|
+
var sid = String(p.id || '').slice(0, 36).replace(/[<>&"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });
|
|
1846
|
+
var su = String(p.url || '').slice(0, 120).replace(/[<>&"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });
|
|
1847
|
+
return '<li><span class="page-id">' + sid + '</span> <span class="page-url">' + su + '</span></li>';
|
|
1848
|
+
}).join('');
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
// attachUrl QR 갱신 — attachUrl이 없으면 hint 표시.
|
|
1852
|
+
var sec = document.getElementById('attach-section');
|
|
1853
|
+
if (sec) {
|
|
1854
|
+
if (s.attachUrl) {
|
|
1855
|
+
// QR은 서버에서 새로 렌더한 /qr.png?u= 로 img src 교체.
|
|
1856
|
+
// TOTP at= 코드는 attachUrl 안에 캡슐화 — 별도 노출 없음.
|
|
1857
|
+
var encoded = encodeURIComponent(s.attachUrl);
|
|
1858
|
+
var safeUrl = String(s.attachUrl).slice(0, 2000).replace(/[<>&"']/g, function (c) { return '&#' + c.charCodeAt(0) + ';'; });
|
|
1859
|
+
sec.innerHTML =
|
|
1860
|
+
'<img class="qr" src="/qr.png?u=' + encoded + '" alt="attach QR" />' +
|
|
1861
|
+
'<p class="url-box">' + safeUrl + '</p>';
|
|
1862
|
+
} else {
|
|
1863
|
+
sec.innerHTML = '<p class="hint">build_attach_url MCP tool을 호출하면 QR이 여기에 표시됩니다.</p>';
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
// 갱신 시각
|
|
1867
|
+
var upd = document.getElementById('updated');
|
|
1868
|
+
if (upd) upd.textContent = '마지막 갱신: ' + new Date().toISOString();
|
|
1869
|
+
} catch (_) { /* 파싱 오류 무시 */ }
|
|
1870
|
+
};
|
|
1871
|
+
src.onerror = function () {
|
|
1872
|
+
// 재연결은 EventSource가 자동 처리 (spec 기본 동작).
|
|
1873
|
+
};
|
|
1874
|
+
})();
|
|
1875
|
+
<\/script>
|
|
1876
|
+
</body>
|
|
1877
|
+
</html>`;
|
|
1878
|
+
}
|
|
1879
|
+
/**
|
|
1487
1880
|
* QR 스캔 페이지 HTML 본문.
|
|
1488
1881
|
* dark theme, inline style, 외부 fetch 없음.
|
|
1489
1882
|
*/
|
|
@@ -1560,6 +1953,112 @@ function buildAttachHtml(qrDataUrl, safeLabel, safeAttachUrl) {
|
|
|
1560
1953
|
</html>`;
|
|
1561
1954
|
}
|
|
1562
1955
|
//#endregion
|
|
1956
|
+
//#region src/mcp/relay-secret-store.ts
|
|
1957
|
+
/**
|
|
1958
|
+
* Project-local relay TOTP secret store (#394 first-run auto-mint, #396 moved to
|
|
1959
|
+
* a project-local single file `.ait_relay`).
|
|
1960
|
+
*
|
|
1961
|
+
* Two surfaces, intentionally split by who is allowed to write:
|
|
1962
|
+
*
|
|
1963
|
+
* - {@link ensureRelaySecret} — WRITE path, called ONLY from the unplugin
|
|
1964
|
+
* (env-2 relay boot). Mints a fresh secret on first run and persists it to
|
|
1965
|
+
* `<projectRoot>/.ait_relay` (0600). A single file — no directory is created.
|
|
1966
|
+
*
|
|
1967
|
+
* - {@link loadRelaySecretReadOnly} — READ-ONLY path, called from the MCP
|
|
1968
|
+
* daemon when switching into a relay environment. It NEVER mints, chmods, or
|
|
1969
|
+
* creates anything: it only reads an already-existing `.ait_relay` and injects
|
|
1970
|
+
* its value into `env`. A daemon that minted would defeat the #250 fail-fast
|
|
1971
|
+
* (the daemon is the verifier side — a self-minted secret would let a leaked
|
|
1972
|
+
* tunnel URL attach unauthenticated), so the daemon stays read-only.
|
|
1973
|
+
*
|
|
1974
|
+
* Why a per-session `projectRoot` instead of `process.cwd()`: the daemon cannot
|
|
1975
|
+
* trust its own cwd — agent-plugin spawns it via `npx` without `cwd`, so cwd is
|
|
1976
|
+
* frozen at Claude Code launch and a cwd-walk stops at the monorepo workspace
|
|
1977
|
+
* root (which always has a package.json). So the project root is supplied
|
|
1978
|
+
* per-debug-session through `start_debug`.
|
|
1979
|
+
*
|
|
1980
|
+
* SECRET-HANDLING: this module handles AIT_DEBUG_TOTP_SECRET — the raw value and
|
|
1981
|
+
* its length MUST NOT appear in any log, error message, stdout, stderr, or
|
|
1982
|
+
* assertion output. Only boolean pass/fail signals are safe to surface, and the
|
|
1983
|
+
* discovered file path is never logged either. The persist file is written mode
|
|
1984
|
+
* 0600.
|
|
1985
|
+
*/
|
|
1986
|
+
/** Project-local secret file name (single file, not a directory). */
|
|
1987
|
+
const RELAY_SECRET_FILE_NAME = ".ait_relay";
|
|
1988
|
+
/**
|
|
1989
|
+
* Walks upward from `start` and returns the nearest directory that contains a
|
|
1990
|
+
* `package.json`. Falls back to `start` itself when none is found (so a write
|
|
1991
|
+
* still lands somewhere deterministic).
|
|
1992
|
+
*
|
|
1993
|
+
* The write (unplugin) and read (daemon) sides use the SAME anchor so a secret
|
|
1994
|
+
* minted by `pnpm dev` is found by the daemon: real mini-apps keep
|
|
1995
|
+
* `vite.config.ts` and `package.json` in the same directory, so
|
|
1996
|
+
* `server.config.root === package.json-dir`. In a monorepo subdir the anchor is
|
|
1997
|
+
* the package's own directory — the one the daemon can also reach via the
|
|
1998
|
+
* per-session projectRoot.
|
|
1999
|
+
*
|
|
2000
|
+
* @param start - Directory to start the upward walk from.
|
|
2001
|
+
* @param existsSyncFn - Injectable existence check (defaults to node:fs).
|
|
2002
|
+
*/
|
|
2003
|
+
function nearestPackageJsonDir(start, existsSyncFn) {
|
|
2004
|
+
let dir = start;
|
|
2005
|
+
while (true) {
|
|
2006
|
+
if (existsSyncFn(join(dir, "package.json"))) return dir;
|
|
2007
|
+
const parent = dirname(dir);
|
|
2008
|
+
if (parent === dir) return start;
|
|
2009
|
+
dir = parent;
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
/**
|
|
2013
|
+
* Absolute path to the project-local `.ait_relay` file for a given start
|
|
2014
|
+
* directory (resolved against the nearest package.json directory).
|
|
2015
|
+
*
|
|
2016
|
+
* Exported so tests can compute the expected path without duplicating the
|
|
2017
|
+
* resolution logic.
|
|
2018
|
+
*/
|
|
2019
|
+
function relaySecretFilePath(start, existsSyncFn) {
|
|
2020
|
+
return join(nearestPackageJsonDir(start, existsSyncFn), RELAY_SECRET_FILE_NAME);
|
|
2021
|
+
}
|
|
2022
|
+
/**
|
|
2023
|
+
* Reads an already-existing `<projectRoot>/.ait_relay` and, if its contents are a
|
|
2024
|
+
* valid relay TOTP secret, injects them into `env.AIT_DEBUG_TOTP_SECRET`.
|
|
2025
|
+
*
|
|
2026
|
+
* Strictly READ-ONLY: it uses only `existsSync` + `readFileSync` and NEVER mints,
|
|
2027
|
+
* chmods, or creates files/directories. The daemon must not mint because it is
|
|
2028
|
+
* the relay verifier side — a self-minted secret would defeat the #250 fail-fast
|
|
2029
|
+
* (a leaked tunnel URL could then attach unauthenticated). If no valid secret is
|
|
2030
|
+
* found the function leaves `env` untouched and returns without throwing, so the
|
|
2031
|
+
* downstream `assertRelayAuthConfigured()` stays the single fail-fast.
|
|
2032
|
+
*
|
|
2033
|
+
* Resolution order:
|
|
2034
|
+
* 1. `env.AIT_DEBUG_TOTP_SECRET` already valid → no-op (operator export wins).
|
|
2035
|
+
* 2. `projectRoot` given → read `<nearest package.json dir>/.ait_relay`; inject
|
|
2036
|
+
* iff the contents pass {@link isValidRelayAuthSecret}.
|
|
2037
|
+
* 3. Otherwise (no projectRoot, file absent, or invalid) → silent no-op.
|
|
2038
|
+
*
|
|
2039
|
+
* SECRET-HANDLING: the read value is passed ONLY to the boolean predicate before
|
|
2040
|
+
* assignment; its value, length, and the discovered file path are never logged.
|
|
2041
|
+
*
|
|
2042
|
+
* @param deps - Optional dependency overrides for testing.
|
|
2043
|
+
*/
|
|
2044
|
+
async function loadRelaySecretReadOnly(deps) {
|
|
2045
|
+
const { projectRoot, env = process.env, fs: fsDep, existsSync: existsSyncDep } = deps ?? {};
|
|
2046
|
+
const { isValidRelayAuthSecret } = await import("../totp-CQFmgOhM.js");
|
|
2047
|
+
if (isValidRelayAuthSecret(env.AIT_DEBUG_TOTP_SECRET)) return;
|
|
2048
|
+
if (projectRoot === void 0) return;
|
|
2049
|
+
const fs = fsDep ?? await import("node:fs");
|
|
2050
|
+
const secretPath = relaySecretFilePath(projectRoot, existsSyncDep ?? fs.existsSync);
|
|
2051
|
+
if (!fs.existsSync(secretPath)) return;
|
|
2052
|
+
let stored;
|
|
2053
|
+
try {
|
|
2054
|
+
stored = fs.readFileSync(secretPath, "utf8").trim();
|
|
2055
|
+
} catch {
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
if (!isValidRelayAuthSecret(stored)) return;
|
|
2059
|
+
env.AIT_DEBUG_TOTP_SECRET = stored;
|
|
2060
|
+
}
|
|
2061
|
+
//#endregion
|
|
1563
2062
|
//#region src/mcp/server-lock.ts
|
|
1564
2063
|
/**
|
|
1565
2064
|
* Single debug session lock for the `devtools-mcp` debug server.
|
|
@@ -1722,138 +2221,26 @@ function acquireLock(options = {}) {
|
|
|
1722
2221
|
process.stderr.write(`[ait-debug] 기존 debug-mode 세션이 이미 실행 중 — PID=${existing.pid}, started ${existing.startedAt}, ${urlPart}\n[ait-debug] 회복: \`kill ${existing.pid}\` 또는 \`npx @ait-co/devtools devtools-mcp --force\`\n`);
|
|
1723
2222
|
throw new ServerLockConflictError(existing.pid, existing.wssUrl, existing.startedAt);
|
|
1724
2223
|
}
|
|
1725
|
-
else process.stderr.write(`[ait-debug] stale lock from PID ${existing.pid} recovered — starting fresh.\n`);
|
|
1726
|
-
const data = {
|
|
1727
|
-
pid: process.pid,
|
|
1728
|
-
wssUrl: null,
|
|
1729
|
-
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1730
|
-
};
|
|
1731
|
-
writeLock(lockPath, data);
|
|
1732
|
-
let released = false;
|
|
1733
|
-
return {
|
|
1734
|
-
updateWssUrl(wssUrl) {
|
|
1735
|
-
if (released) return;
|
|
1736
|
-
data.wssUrl = wssUrl;
|
|
1737
|
-
writeLock(lockPath, data);
|
|
1738
|
-
},
|
|
1739
|
-
release() {
|
|
1740
|
-
if (released) return;
|
|
1741
|
-
released = true;
|
|
1742
|
-
removeLock(lockPath);
|
|
1743
|
-
}
|
|
1744
|
-
};
|
|
1745
|
-
}
|
|
1746
|
-
//#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}`;
|
|
2224
|
+
else process.stderr.write(`[ait-debug] stale lock from PID ${existing.pid} recovered — starting fresh.\n`);
|
|
2225
|
+
const data = {
|
|
2226
|
+
pid: process.pid,
|
|
2227
|
+
wssUrl: null,
|
|
2228
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2229
|
+
};
|
|
2230
|
+
writeLock(lockPath, data);
|
|
2231
|
+
let released = false;
|
|
2232
|
+
return {
|
|
2233
|
+
updateWssUrl(wssUrl) {
|
|
2234
|
+
if (released) return;
|
|
2235
|
+
data.wssUrl = wssUrl;
|
|
2236
|
+
writeLock(lockPath, data);
|
|
2237
|
+
},
|
|
2238
|
+
release() {
|
|
2239
|
+
if (released) return;
|
|
2240
|
+
released = true;
|
|
2241
|
+
removeLock(lockPath);
|
|
2242
|
+
}
|
|
2243
|
+
};
|
|
1857
2244
|
}
|
|
1858
2245
|
//#endregion
|
|
1859
2246
|
//#region src/mcp/sdk-signatures.ts
|
|
@@ -2012,83 +2399,6 @@ function warnPassthrough(name) {
|
|
|
2012
2399
|
}
|
|
2013
2400
|
SIGNATURES.map((s) => s.name);
|
|
2014
2401
|
//#endregion
|
|
2015
|
-
//#region src/mcp/totp.ts
|
|
2016
|
-
/**
|
|
2017
|
-
* RFC 6238 TOTP implementation (Node.js, node:crypto only).
|
|
2018
|
-
*
|
|
2019
|
-
* External TOTP libraries (otplib, speakeasy, …) are intentionally NOT used
|
|
2020
|
-
* to keep the dependency surface minimal. This hand-roll is ~30 lines and
|
|
2021
|
-
* covers exactly what relay-side auth needs.
|
|
2022
|
-
*
|
|
2023
|
-
* Algorithm summary (RFC 6238 + RFC 4226):
|
|
2024
|
-
* T = floor(now / 30) — 30-second time step counter
|
|
2025
|
-
* K = Buffer.from(secret, 'hex') — shared secret (raw bytes, hex-encoded)
|
|
2026
|
-
* MAC = HMAC-SHA1(K, T as 8-byte big-endian uint64)
|
|
2027
|
-
* offset = MAC[19] & 0x0f
|
|
2028
|
-
* code = (MAC[offset..offset+4] & 0x7fffffff) % 10^6 — 6 digits
|
|
2029
|
-
*
|
|
2030
|
-
* Security note (keep this comment accurate):
|
|
2031
|
-
* The baked-in secret in a dogfood build is extractable from the bundle by a
|
|
2032
|
-
* determined reverse engineer. This mechanism raises the bar from
|
|
2033
|
-
* "anyone with the URL" to "URL + bundle extraction + live TOTP calculation".
|
|
2034
|
-
* Casual URL leaks (Slack paste, QR screenshot, shoulder-surfing) are
|
|
2035
|
-
* blocked; deliberate reverse engineering is not. See threat model in
|
|
2036
|
-
* src/mcp/chii-relay.ts and umbrella CLAUDE.md §4.
|
|
2037
|
-
*
|
|
2038
|
-
* SECRET-HANDLING: secret values and computed codes MUST NOT appear in any
|
|
2039
|
-
* log, error message, or string visible outside this module. Only boolean
|
|
2040
|
-
* pass/fail and reason enum values are safe to surface.
|
|
2041
|
-
*/
|
|
2042
|
-
/** Time step window in seconds (RFC 6238 default). */
|
|
2043
|
-
const TIME_STEP = 30;
|
|
2044
|
-
/** Number of digits in the generated code. */
|
|
2045
|
-
const DIGITS = 6;
|
|
2046
|
-
/**
|
|
2047
|
-
* Derives a 6-digit TOTP code from a hex-encoded secret at the given wall-
|
|
2048
|
-
* clock time.
|
|
2049
|
-
*
|
|
2050
|
-
* @param secret - The shared secret as a hex string (e.g. 64 hex chars = 32
|
|
2051
|
-
* bytes). Must be the output of `generateAttachToken()` or compatible.
|
|
2052
|
-
* @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
|
|
2053
|
-
* @returns A zero-padded 6-digit decimal string, e.g. `"042193"`.
|
|
2054
|
-
*/
|
|
2055
|
-
function generateTotp(secret, when = Date.now()) {
|
|
2056
|
-
const key = Buffer.from(secret, "hex");
|
|
2057
|
-
const counter = Math.max(0, Math.floor(when / 1e3 / TIME_STEP));
|
|
2058
|
-
const counterBuf = Buffer.alloc(8);
|
|
2059
|
-
const hi = Math.floor(counter / 4294967296);
|
|
2060
|
-
const lo = counter >>> 0;
|
|
2061
|
-
counterBuf.writeUInt32BE(hi, 0);
|
|
2062
|
-
counterBuf.writeUInt32BE(lo, 4);
|
|
2063
|
-
const mac = createHmac("sha1", key).update(counterBuf).digest();
|
|
2064
|
-
const offset = mac[19] & 15;
|
|
2065
|
-
return (((mac[offset] & 127) << 24 | (mac[offset + 1] & 255) << 16 | (mac[offset + 2] & 255) << 8 | mac[offset + 3] & 255) % 10 ** DIGITS).toString().padStart(DIGITS, "0");
|
|
2066
|
-
}
|
|
2067
|
-
/**
|
|
2068
|
-
* Verifies a TOTP code against the secret, accepting ±`skew` time steps to
|
|
2069
|
-
* tolerate clock drift between the relay host and the client device.
|
|
2070
|
-
*
|
|
2071
|
-
* Uses `timingSafeEqual` for constant-time comparison to prevent timing
|
|
2072
|
-
* side-channel attacks.
|
|
2073
|
-
*
|
|
2074
|
-
* @param secret - Hex-encoded shared secret.
|
|
2075
|
-
* @param code - The 6-digit code to verify (string or numeric).
|
|
2076
|
-
* @param when - Unix timestamp in milliseconds. Defaults to `Date.now()`.
|
|
2077
|
-
* @param skew - Number of adjacent steps to accept on either side. Default 1
|
|
2078
|
-
* (accepts T-1, T, T+1 — a 90-second acceptance window).
|
|
2079
|
-
* @returns `true` if the code matches any accepted step, `false` otherwise.
|
|
2080
|
-
*/
|
|
2081
|
-
function verifyTotp(secret, code, when = Date.now(), skew = 1) {
|
|
2082
|
-
const normalised = String(code).padStart(DIGITS, "0");
|
|
2083
|
-
if (normalised.length !== DIGITS || !/^\d{6}$/.test(normalised)) return false;
|
|
2084
|
-
const candidateBuf = Buffer.from(normalised, "utf8");
|
|
2085
|
-
for (let delta = -skew; delta <= skew; delta++) {
|
|
2086
|
-
const expected = generateTotp(secret, when + delta * TIME_STEP * 1e3);
|
|
2087
|
-
if (timingSafeEqual(Buffer.from(expected, "utf8"), candidateBuf)) return true;
|
|
2088
|
-
}
|
|
2089
|
-
return false;
|
|
2090
|
-
}
|
|
2091
|
-
//#endregion
|
|
2092
2402
|
//#region src/mcp/tools.ts
|
|
2093
2403
|
/** Static MCP tool descriptors (name + JSONSchema) for the full debug tool surface. */
|
|
2094
2404
|
const DEBUG_TOOL_DEFINITIONS = [
|
|
@@ -2124,13 +2434,13 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
2124
2434
|
},
|
|
2125
2435
|
{
|
|
2126
2436
|
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.
|
|
2437
|
+
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=…). 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
2438
|
inputSchema: {
|
|
2129
2439
|
type: "object",
|
|
2130
2440
|
properties: {
|
|
2131
2441
|
scheme_url: {
|
|
2132
2442
|
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."
|
|
2443
|
+
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."
|
|
2134
2444
|
},
|
|
2135
2445
|
wait_for_attach: {
|
|
2136
2446
|
type: "boolean",
|
|
@@ -2141,7 +2451,7 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
2141
2451
|
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
2452
|
}
|
|
2143
2453
|
},
|
|
2144
|
-
required: [
|
|
2454
|
+
required: []
|
|
2145
2455
|
},
|
|
2146
2456
|
availableIn: "relay"
|
|
2147
2457
|
},
|
|
@@ -2219,7 +2529,7 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
2219
2529
|
},
|
|
2220
2530
|
{
|
|
2221
2531
|
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)
|
|
2532
|
+
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
2533
|
inputSchema: {
|
|
2224
2534
|
type: "object",
|
|
2225
2535
|
properties: {
|
|
@@ -2273,23 +2583,27 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
2273
2583
|
},
|
|
2274
2584
|
{
|
|
2275
2585
|
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-browser-
|
|
2586
|
+
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 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 relay-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 relay-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 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.",
|
|
2277
2587
|
inputSchema: {
|
|
2278
2588
|
type: "object",
|
|
2279
2589
|
properties: {
|
|
2280
2590
|
mode: {
|
|
2281
2591
|
type: "string",
|
|
2282
2592
|
enum: [
|
|
2283
|
-
"local-browser
|
|
2284
|
-
"
|
|
2285
|
-
"relay-
|
|
2593
|
+
"local-browser",
|
|
2594
|
+
"relay-sandbox",
|
|
2595
|
+
"relay-staging",
|
|
2286
2596
|
"relay-live"
|
|
2287
2597
|
],
|
|
2288
|
-
description: "Target environment to switch to. relay-live additionally requires confirm: true."
|
|
2598
|
+
description: "Target environment to switch to. mode=relay-live additionally requires confirm: true (and arms the read-only LIVE guard)."
|
|
2289
2599
|
},
|
|
2290
2600
|
confirm: {
|
|
2291
2601
|
type: "boolean",
|
|
2292
2602
|
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."
|
|
2603
|
+
},
|
|
2604
|
+
projectRoot: {
|
|
2605
|
+
type: "string",
|
|
2606
|
+
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)."
|
|
2293
2607
|
}
|
|
2294
2608
|
},
|
|
2295
2609
|
required: ["mode"]
|
|
@@ -2298,7 +2612,7 @@ const DEBUG_TOOL_DEFINITIONS = [
|
|
|
2298
2612
|
},
|
|
2299
2613
|
{
|
|
2300
2614
|
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.",
|
|
2615
|
+
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
2616
|
inputSchema: {
|
|
2303
2617
|
type: "object",
|
|
2304
2618
|
properties: { recent_errors_limit: {
|
|
@@ -2328,8 +2642,8 @@ function getToolAvailability(name) {
|
|
|
2328
2642
|
* Unknown tools return `false` — callers should reject them as unknown rather
|
|
2329
2643
|
* than as env-mismatched.
|
|
2330
2644
|
*
|
|
2331
|
-
* Relay variants (`relay-dev`, `relay-live`)
|
|
2332
|
-
* availability tier — `isRelayEnv()` is used for the check.
|
|
2645
|
+
* Relay variants (`relay-dev`, `relay-live`, `relay-mobile`) all satisfy the
|
|
2646
|
+
* `'relay'` availability tier — `isRelayEnv()` is used for the check.
|
|
2333
2647
|
*/
|
|
2334
2648
|
function isToolAvailableIn(name, env) {
|
|
2335
2649
|
const availability = getToolAvailability(name);
|
|
@@ -2343,7 +2657,8 @@ function isToolAvailableIn(name, env) {
|
|
|
2343
2657
|
* matches the given env. Pure — preserves order; both Tier C ("both") and the
|
|
2344
2658
|
* matching single-env tier pass through.
|
|
2345
2659
|
*
|
|
2346
|
-
* Relay variants (`relay-dev`, `relay-live`)
|
|
2660
|
+
* Relay variants (`relay-dev`, `relay-live`, `relay-mobile`) all satisfy the
|
|
2661
|
+
* `'relay'` tier.
|
|
2347
2662
|
*/
|
|
2348
2663
|
function filterToolsByEnvironment(tools, env) {
|
|
2349
2664
|
return tools.filter((t) => t.availableIn === "both" || t.availableIn === "relay" && isRelayEnv(env) || t.availableIn === env);
|
|
@@ -2962,8 +3277,8 @@ function findRecentException(connection, windowStart, windowEnd) {
|
|
|
2962
3277
|
* Calls a dogfood SDK method via `window.__sdkCall` on the attached page.
|
|
2963
3278
|
* NOT read-only — SDK calls may have side effects.
|
|
2964
3279
|
*
|
|
2965
|
-
* On env
|
|
2966
|
-
* mock) it hits the mock SDK.
|
|
3280
|
+
* On env 3/4 (toss WebView relay) this hits the real SDK. On env 1 (local
|
|
3281
|
+
* mock) and env 2 (PWA relay — real WebKit, mock SDK) it hits the mock SDK.
|
|
2967
3282
|
*
|
|
2968
3283
|
* 인자 시그니처 검증: 등록된 메서드는 bridge 호출 전에 인자를 검증하고, mismatch면
|
|
2969
3284
|
* `{ok:false, error}` MCP 오류 결과를 반환한다(bridge에 도달하지 않음).
|
|
@@ -3117,7 +3432,7 @@ async function readMcpSdkVersion() {
|
|
|
3117
3432
|
* some test environments that skip the build step).
|
|
3118
3433
|
*/
|
|
3119
3434
|
function readDevtoolsVersion() {
|
|
3120
|
-
return "0.1.
|
|
3435
|
+
return "0.1.57";
|
|
3121
3436
|
}
|
|
3122
3437
|
/**
|
|
3123
3438
|
* Derives the next recommended action from a completed diagnostics snapshot.
|
|
@@ -3534,9 +3849,14 @@ function extractDeploymentId(schemeUrl) {
|
|
|
3534
3849
|
return null;
|
|
3535
3850
|
}
|
|
3536
3851
|
}
|
|
3537
|
-
/**
|
|
3852
|
+
/**
|
|
3853
|
+
* Returns `true` when the mode routes to a relay connection (`relay-sandbox`,
|
|
3854
|
+
* `relay-staging`, or `relay-live`). `relay-sandbox` is an external-PWA relay;
|
|
3855
|
+
* `relay-staging`/`relay-live` are intoss-private relays — but all three surface
|
|
3856
|
+
* the Tier B / relay-only tool set.
|
|
3857
|
+
*/
|
|
3538
3858
|
function isRelayMode(mode) {
|
|
3539
|
-
return mode === "relay-
|
|
3859
|
+
return mode === "relay-sandbox" || mode === "relay-staging" || mode === "relay-live";
|
|
3540
3860
|
}
|
|
3541
3861
|
/**
|
|
3542
3862
|
* Waits for the first target matching `filterFn` to attach, using the
|
|
@@ -3592,14 +3912,15 @@ function waitForAttachWithEvents(connection, filterFn, timeoutMs, pollIntervalMs
|
|
|
3592
3912
|
* naturally via `enableDomains`). The tier only controls visibility.
|
|
3593
3913
|
*/
|
|
3594
3914
|
function createDebugServer(deps) {
|
|
3595
|
-
const { connection, router: routerDep, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep, totpSecret } = deps;
|
|
3915
|
+
const { connection, router: routerDep, aitSource, getTunnelStatus, waitForAttachTimeoutMs = 9e4, qrHttpServer, getEnvironment: getEnvDep, getEnvironmentReason: getEnvReasonDep, diagnosticsCollector: collectorDep, totpSecret, onAttachUrlBuilt } = deps;
|
|
3916
|
+
const getTotpSecret = deps.getTotpSecret ?? (() => totpSecret);
|
|
3596
3917
|
const router = routerDep ?? makeSingleConnectionRouter(connection);
|
|
3597
|
-
const resolveEnvironment = getEnvDep ?? (() => deriveEnvironment(router.active.kind, getLiveIntent()));
|
|
3918
|
+
const resolveEnvironment = getEnvDep ?? (() => deriveEnvironment(router.active.kind, getLiveIntent(), router.activeRelayOrigin));
|
|
3598
3919
|
const resolveEnvironmentReason = getEnvReasonDep ?? (() => `derived:kind=${router.active.kind},liveIntent=${getLiveIntent()}`);
|
|
3599
3920
|
const collector = collectorDep ?? new InMemoryDiagnosticsCollector();
|
|
3600
3921
|
const server = new Server({
|
|
3601
3922
|
name: "ait-debug",
|
|
3602
|
-
version: "0.1.
|
|
3923
|
+
version: "0.1.57"
|
|
3603
3924
|
}, { capabilities: { tools: { listChanged: true } } });
|
|
3604
3925
|
server.setRequestHandler(ListToolsRequestSchema, () => {
|
|
3605
3926
|
const conn = router.active;
|
|
@@ -3621,10 +3942,12 @@ function createDebugServer(deps) {
|
|
|
3621
3942
|
if (name === "start_debug") {
|
|
3622
3943
|
const rawMode = request.params.arguments?.mode;
|
|
3623
3944
|
const mode = normalizeStartDebugMode(rawMode);
|
|
3624
|
-
if (mode === null) return mcpError("start_debug: mode가 올바르지 않습니다. 'local-browser
|
|
3945
|
+
if (mode === null) return mcpError("start_debug: mode가 올바르지 않습니다. 'local-browser' | 'relay-sandbox' | 'relay-staging' | 'relay-live' 중 하나를 전달하세요.");
|
|
3625
3946
|
const confirm = request.params.arguments?.confirm === true;
|
|
3947
|
+
const rawProjectRoot = request.params.arguments?.projectRoot;
|
|
3948
|
+
const projectRoot = typeof rawProjectRoot === "string" ? rawProjectRoot : void 0;
|
|
3626
3949
|
try {
|
|
3627
|
-
return jsonResult$1(await router.switchMode(mode, confirm));
|
|
3950
|
+
return jsonResult$1(await router.switchMode(mode, confirm, projectRoot));
|
|
3628
3951
|
} catch (err) {
|
|
3629
3952
|
return errorResult(err, name);
|
|
3630
3953
|
}
|
|
@@ -3669,10 +3992,179 @@ function createDebugServer(deps) {
|
|
|
3669
3992
|
return errorResult(err, name);
|
|
3670
3993
|
}
|
|
3671
3994
|
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
3995
|
const waitForAttach = request.params.arguments?.wait_for_attach === true;
|
|
3675
3996
|
const openInBrowser = request.params.arguments?.open_in_browser !== false;
|
|
3997
|
+
if (env === "relay-mobile") {
|
|
3998
|
+
const tunnelHttpUrl = process.env.AIT_TUNNEL_BASE_URL?.trim() ?? "";
|
|
3999
|
+
if (tunnelHttpUrl === "") return mcpError("build_attach_url(mobile): AIT_TUNNEL_BASE_URL이 설정되지 않았습니다. unplugin tunnel:{cdp:true} 배너에 출력되는 앱 HTTP 터널 URL을 AIT_TUNNEL_BASE_URL 환경변수로 전달하세요.");
|
|
4000
|
+
const tunnelStatus = getTunnelStatus();
|
|
4001
|
+
if (!tunnelStatus.up || tunnelStatus.wssUrl === null) return mcpError("build_attach_url(mobile): relay wssUrl이 아직 설정되지 않았습니다. unplugin tunnel:{cdp:true}가 relay를 완전히 기동할 때까지 잠시 후 다시 시도하세요.");
|
|
4002
|
+
const secret = getTotpSecret();
|
|
4003
|
+
let totpCode;
|
|
4004
|
+
let totpMeta;
|
|
4005
|
+
if (secret !== void 0 && secret !== "") {
|
|
4006
|
+
const now = Date.now();
|
|
4007
|
+
totpCode = generateTotp(secret, now);
|
|
4008
|
+
const STEP_SECONDS = 30;
|
|
4009
|
+
const currentStep = Math.floor(now / 1e3 / STEP_SECONDS);
|
|
4010
|
+
totpMeta = {
|
|
4011
|
+
enabled: true,
|
|
4012
|
+
ttlSeconds: STEP_SECONDS,
|
|
4013
|
+
expiresAt: (/* @__PURE__ */ new Date((currentStep + 1) * STEP_SECONDS * 1e3)).toISOString()
|
|
4014
|
+
};
|
|
4015
|
+
}
|
|
4016
|
+
const attachUrl = buildLauncherAttachUrl(tunnelHttpUrl, tunnelStatus.wssUrl, totpCode);
|
|
4017
|
+
onAttachUrlBuilt?.(attachUrl);
|
|
4018
|
+
const relayUrl = tunnelStatus.wssUrl;
|
|
4019
|
+
const totp = totpMeta;
|
|
4020
|
+
const isMatchingPage = (pages) => pages.length > 0;
|
|
4021
|
+
const buildTimeoutError = (baseText, timeoutSec, observed) => {
|
|
4022
|
+
const observedUrls = observed.slice(0, 3).map((p) => p.url.slice(0, 80)).join(", ");
|
|
4023
|
+
return `${baseText}\n\nNo page attached within ${timeoutSec}s${observed.length > 0 ? ` — previously attached pages: [${observedUrls}]` : ""} — launcher QR을 폰 카메라로 스캔한 뒤 call list_pages를 다시 호출하세요.`;
|
|
4024
|
+
};
|
|
4025
|
+
return await (async () => {
|
|
4026
|
+
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).";
|
|
4027
|
+
const warningPrefix = "";
|
|
4028
|
+
const guiAvailable = canOpenBrowser();
|
|
4029
|
+
if (openInBrowser && !guiAvailable) {
|
|
4030
|
+
const headlessNote = "[open_in_browser] GUI 환경이 감지되지 않았습니다 (headless/remote 환경). open_in_browser=false로 자동 폴백합니다. 텍스트 QR을 폰 카메라로 스캔하거나, 로컬 GUI 환경에서 실행하세요.\n\n";
|
|
4031
|
+
const qrHeadless = await renderQr(attachUrl);
|
|
4032
|
+
const headlessText = `${warningPrefix}${headlessNote}${header}\n${JSON.stringify({
|
|
4033
|
+
attachUrl,
|
|
4034
|
+
relayUrl,
|
|
4035
|
+
...totp ? { totp } : {}
|
|
4036
|
+
}, null, 2)}\n\n${qrHeadless}`;
|
|
4037
|
+
if (!waitForAttach) return { content: [{
|
|
4038
|
+
type: "text",
|
|
4039
|
+
text: headlessText
|
|
4040
|
+
}] };
|
|
4041
|
+
let attachedPagesHl = [];
|
|
4042
|
+
try {
|
|
4043
|
+
attachedPagesHl = await waitForAttachWithEvents(conn, isMatchingPage, waitForAttachTimeoutMs);
|
|
4044
|
+
} catch {
|
|
4045
|
+
attachedPagesHl = conn.listTargets();
|
|
4046
|
+
return {
|
|
4047
|
+
content: [{
|
|
4048
|
+
type: "text",
|
|
4049
|
+
text: buildTimeoutError(headlessText, waitForAttachTimeoutMs / 1e3, attachedPagesHl)
|
|
4050
|
+
}],
|
|
4051
|
+
isError: true
|
|
4052
|
+
};
|
|
4053
|
+
}
|
|
4054
|
+
const pagesResultHl = listPages(conn, getTunnelStatus());
|
|
4055
|
+
return { content: [{
|
|
4056
|
+
type: "text",
|
|
4057
|
+
text: `${headlessText}\n\n${JSON.stringify(pagesResultHl, null, 2)}`
|
|
4058
|
+
}] };
|
|
4059
|
+
}
|
|
4060
|
+
if (openInBrowser && guiAvailable && qrHttpServer) {
|
|
4061
|
+
const browserResult = await openQrInBrowser(qrHttpServer.buildAttachPageUrl(attachUrl), `http://127.0.0.1:${qrHttpServer.port}/qr.png?u=${encodeURIComponent(attachUrl)}`);
|
|
4062
|
+
if (browserResult.opened) {
|
|
4063
|
+
const retriedNote = browserResult.retried ? " (1회 retry 후 성공)" : "";
|
|
4064
|
+
const openResult = {
|
|
4065
|
+
attempted: true,
|
|
4066
|
+
succeeded: true,
|
|
4067
|
+
...browserResult.retried ? { retried: true } : {}
|
|
4068
|
+
};
|
|
4069
|
+
const shortText = `${warningPrefix}${header}\n${JSON.stringify({
|
|
4070
|
+
relayUrl,
|
|
4071
|
+
openResult,
|
|
4072
|
+
...totp ? { totp } : {}
|
|
4073
|
+
}, null, 2)}\n\n브라우저에서 QR을 열었습니다${retriedNote}. 폰 카메라로 스캔하세요.\nURL: ${browserResult.httpUrl}`;
|
|
4074
|
+
if (!waitForAttach) return { content: [{
|
|
4075
|
+
type: "text",
|
|
4076
|
+
text: shortText
|
|
4077
|
+
}] };
|
|
4078
|
+
let attachedPages = [];
|
|
4079
|
+
try {
|
|
4080
|
+
attachedPages = await waitForAttachWithEvents(conn, isMatchingPage, waitForAttachTimeoutMs);
|
|
4081
|
+
} catch {
|
|
4082
|
+
attachedPages = conn.listTargets();
|
|
4083
|
+
return {
|
|
4084
|
+
content: [{
|
|
4085
|
+
type: "text",
|
|
4086
|
+
text: buildTimeoutError(shortText, waitForAttachTimeoutMs / 1e3, attachedPages)
|
|
4087
|
+
}],
|
|
4088
|
+
isError: true
|
|
4089
|
+
};
|
|
4090
|
+
}
|
|
4091
|
+
const pagesResult = listPages(conn, getTunnelStatus());
|
|
4092
|
+
return { content: [{
|
|
4093
|
+
type: "text",
|
|
4094
|
+
text: `${shortText}\n\n${JSON.stringify(pagesResult, null, 2)}`
|
|
4095
|
+
}] };
|
|
4096
|
+
}
|
|
4097
|
+
const openResult = {
|
|
4098
|
+
attempted: true,
|
|
4099
|
+
succeeded: false,
|
|
4100
|
+
failureReason: browserResult.error ?? "브라우저 실행 후보 모두 실패",
|
|
4101
|
+
pngUrl: browserResult.pngUrl,
|
|
4102
|
+
...browserResult.stderrSummary ? { stderrSummary: browserResult.stderrSummary } : {}
|
|
4103
|
+
};
|
|
4104
|
+
const stderrNote = browserResult.stderrSummary ? `\nstderr: ${browserResult.stderrSummary}` : "";
|
|
4105
|
+
const fallbackNote = `[open_in_browser] 브라우저 자동 열기에 실패했습니다. 다음 URL을 직접 브라우저에서 여세요:\n${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stderrNote + "\n\n";
|
|
4106
|
+
const qr = await renderQr(attachUrl);
|
|
4107
|
+
const baseText = `${warningPrefix}${fallbackNote}${header}\n${JSON.stringify({
|
|
4108
|
+
attachUrl,
|
|
4109
|
+
relayUrl,
|
|
4110
|
+
openResult,
|
|
4111
|
+
...totp ? { totp } : {}
|
|
4112
|
+
}, null, 2)}\n\n${qr}`;
|
|
4113
|
+
if (!waitForAttach) return { content: [{
|
|
4114
|
+
type: "text",
|
|
4115
|
+
text: baseText
|
|
4116
|
+
}] };
|
|
4117
|
+
let attachedPagesFb = [];
|
|
4118
|
+
try {
|
|
4119
|
+
attachedPagesFb = await waitForAttachWithEvents(conn, isMatchingPage, waitForAttachTimeoutMs);
|
|
4120
|
+
} catch {
|
|
4121
|
+
attachedPagesFb = conn.listTargets();
|
|
4122
|
+
return {
|
|
4123
|
+
content: [{
|
|
4124
|
+
type: "text",
|
|
4125
|
+
text: buildTimeoutError(baseText, waitForAttachTimeoutMs / 1e3, attachedPagesFb)
|
|
4126
|
+
}],
|
|
4127
|
+
isError: true
|
|
4128
|
+
};
|
|
4129
|
+
}
|
|
4130
|
+
const pagesResultFb = listPages(conn, getTunnelStatus());
|
|
4131
|
+
return { content: [{
|
|
4132
|
+
type: "text",
|
|
4133
|
+
text: `${baseText}\n\n${JSON.stringify(pagesResultFb, null, 2)}`
|
|
4134
|
+
}] };
|
|
4135
|
+
}
|
|
4136
|
+
const qr = await renderQr(attachUrl);
|
|
4137
|
+
const baseText = `${warningPrefix}${header}\n${JSON.stringify({
|
|
4138
|
+
attachUrl,
|
|
4139
|
+
relayUrl,
|
|
4140
|
+
...totp ? { totp } : {}
|
|
4141
|
+
}, null, 2)}\n\n${qr}`;
|
|
4142
|
+
if (!waitForAttach) return { content: [{
|
|
4143
|
+
type: "text",
|
|
4144
|
+
text: baseText
|
|
4145
|
+
}] };
|
|
4146
|
+
let attachedPages = [];
|
|
4147
|
+
try {
|
|
4148
|
+
attachedPages = await waitForAttachWithEvents(conn, isMatchingPage, waitForAttachTimeoutMs);
|
|
4149
|
+
} catch {
|
|
4150
|
+
attachedPages = conn.listTargets();
|
|
4151
|
+
return {
|
|
4152
|
+
content: [{
|
|
4153
|
+
type: "text",
|
|
4154
|
+
text: buildTimeoutError(baseText, waitForAttachTimeoutMs / 1e3, attachedPages)
|
|
4155
|
+
}],
|
|
4156
|
+
isError: true
|
|
4157
|
+
};
|
|
4158
|
+
}
|
|
4159
|
+
const pagesResult = listPages(conn, getTunnelStatus());
|
|
4160
|
+
return { content: [{
|
|
4161
|
+
type: "text",
|
|
4162
|
+
text: `${baseText}\n\n${JSON.stringify(pagesResult, null, 2)}`
|
|
4163
|
+
}] };
|
|
4164
|
+
})();
|
|
4165
|
+
}
|
|
4166
|
+
const schemeUrl = request.params.arguments?.scheme_url;
|
|
4167
|
+
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
4168
|
const deploymentId = extractDeploymentId(schemeUrl);
|
|
3677
4169
|
if (!deploymentId) logInfo("tool.call", {
|
|
3678
4170
|
tool: "build_attach_url",
|
|
@@ -3691,7 +4183,8 @@ function createDebugServer(deps) {
|
|
|
3691
4183
|
return `${baseText}\n\nNo page${deploymentId ? ` matching deploymentId=${deploymentId}` : ""} attached within ${timeoutSec}s${observedNote} — call list_pages to retry.`;
|
|
3692
4184
|
};
|
|
3693
4185
|
try {
|
|
3694
|
-
const { attachUrl, relayUrl, authorityWarning, totp } = buildAttachUrl(schemeUrl, getTunnelStatus(),
|
|
4186
|
+
const { attachUrl, relayUrl, authorityWarning, totp } = buildAttachUrl(schemeUrl, getTunnelStatus(), getTotpSecret());
|
|
4187
|
+
onAttachUrlBuilt?.(attachUrl);
|
|
3695
4188
|
const warningPrefix = authorityWarning ? `⚠️ scheme_url 경고: ${authorityWarning}\n\n` : "";
|
|
3696
4189
|
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).";
|
|
3697
4190
|
const guiAvailable = canOpenBrowser();
|
|
@@ -3883,26 +4376,27 @@ ${browserResult.httpUrl}\n또는 PNG로 받기: ${browserResult.pngUrl}` + stder
|
|
|
3883
4376
|
const sdkArgs = Array.isArray(rawArgs) ? rawArgs : [];
|
|
3884
4377
|
if (conn.kind === "relay" && getLiveIntent() && request.params.arguments?.confirm !== true) return liveGuardError("call_sdk");
|
|
3885
4378
|
const sdkResult = await callSdk(conn, sdkName, sdkArgs);
|
|
3886
|
-
if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk");
|
|
4379
|
+
if (!sdkResult.ok && typeof sdkResult.error === "string" && sdkResult.error.startsWith("sdk-absent:")) return sdkAbsentError("call_sdk", conn.kind === "local");
|
|
3887
4380
|
return envelopeResult$1(sdkResult, name, env, conn.listTargets().length > 0);
|
|
3888
4381
|
}
|
|
3889
4382
|
default: return unknownTool(name);
|
|
3890
4383
|
}
|
|
3891
4384
|
} catch (err) {
|
|
3892
|
-
return errorResult(err, name);
|
|
4385
|
+
return errorResult(err, name, conn.kind === "local");
|
|
3893
4386
|
}
|
|
3894
4387
|
});
|
|
3895
4388
|
return server;
|
|
3896
4389
|
}
|
|
3897
4390
|
/**
|
|
3898
4391
|
* Normalizes a raw `start_debug` `mode` argument to a `StartDebugMode`, or
|
|
3899
|
-
* `null` when the value is not one of the four accepted modes
|
|
4392
|
+
* `null` when the value is not one of the four accepted modes:
|
|
4393
|
+
* 'local-browser' | 'relay-sandbox' | 'relay-staging' | 'relay-live'
|
|
4394
|
+
*
|
|
4395
|
+
* Hard rename (issue #398): the older `local`/`mobile`/`staging`/`live` names
|
|
4396
|
+
* and their aliases are no longer accepted — pre-1.0, no back-compat.
|
|
3900
4397
|
*/
|
|
3901
4398
|
function normalizeStartDebugMode(raw) {
|
|
3902
|
-
if (raw === "local-browser-
|
|
3903
|
-
if (raw === "local-browser-cdp") return "local-browser-cdp";
|
|
3904
|
-
if (raw === "relay-dev") return "relay-dev";
|
|
3905
|
-
if (raw === "relay-live") return "relay-live";
|
|
4399
|
+
if (raw === "local-browser" || raw === "relay-sandbox" || raw === "relay-staging" || raw === "relay-live") return raw;
|
|
3906
4400
|
return null;
|
|
3907
4401
|
}
|
|
3908
4402
|
/**
|
|
@@ -3919,7 +4413,9 @@ function makeSingleConnectionRouter(connection) {
|
|
|
3919
4413
|
get active() {
|
|
3920
4414
|
return connection;
|
|
3921
4415
|
},
|
|
3922
|
-
|
|
4416
|
+
activeRelayOrigin: void 0,
|
|
4417
|
+
switchMode(mode, confirm, _projectRoot) {
|
|
4418
|
+
if (mode === "relay-sandbox") return Promise.reject(/* @__PURE__ */ new Error("start_debug: 이 세션은 단일 연결만 보유합니다 — 'relay-sandbox'(환경 2 PWA, 외부 relay)로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). MCP 서버를 relay-sandbox 모드로 재시작하세요."));
|
|
3923
4419
|
if (isRelayMode(mode) !== (connection.kind === "relay")) return Promise.reject(/* @__PURE__ */ new Error(`start_debug: 이 세션은 단일 ${connection.kind} 연결만 보유합니다 — '${mode}'로 동적 전환할 수 없습니다 (dual-connection 데몬에서만 지원). MCP 서버를 원하는 모드로 재시작하세요.`));
|
|
3924
4420
|
if (mode === "relay-live" && !confirm) return Promise.reject(/* @__PURE__ */ new Error("start_debug: relay-live(실서비스 LIVE)는 confirm: true가 필요합니다 — 실유저에게 영향이 갈 수 있는 LIVE 디버깅 진입을 명시적으로 승인하세요."));
|
|
3925
4421
|
setLiveIntent(mode === "relay-live");
|
|
@@ -3978,8 +4474,8 @@ function classifyEnableDomainError(err, toolName) {
|
|
|
3978
4474
|
* CDP/AIT 명령 실행 중 catch된 에러를 4상태로 분류해 tool 결과로 반환한다.
|
|
3979
4475
|
* debug-server 내부 try/catch 블록에서 공통으로 사용한다.
|
|
3980
4476
|
*/
|
|
3981
|
-
function errorResult(err, name) {
|
|
3982
|
-
return classifyToolError(err, name);
|
|
4477
|
+
function errorResult(err, name, isLocal = false) {
|
|
4478
|
+
return classifyToolError(err, name, isLocal);
|
|
3983
4479
|
}
|
|
3984
4480
|
/**
|
|
3985
4481
|
* Starts a polling watcher that detects the first 0→N target transition on
|
|
@@ -4059,34 +4555,12 @@ function startParentWatcher(onOrphaned, opts) {
|
|
|
4059
4555
|
} };
|
|
4060
4556
|
}
|
|
4061
4557
|
/**
|
|
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
4558
|
* Factory that constructs a `ChiiCdpConnection` for the given relay base URL.
|
|
4086
4559
|
*
|
|
4087
4560
|
* Introduced as a named seam so PR-2 (dual-connection, #348) can defer
|
|
4088
|
-
* construction to first-activation time by moving or replacing this call
|
|
4089
|
-
*
|
|
4561
|
+
* construction to first-activation time by moving or replacing this call. Since
|
|
4562
|
+
* #396 every family (relay included) is constructed lazily on its first
|
|
4563
|
+
* `start_debug`, so this is always called from the lazy boot path.
|
|
4090
4564
|
*
|
|
4091
4565
|
* The relay base URL is only available after `startChiiRelay()` resolves, so
|
|
4092
4566
|
* the factory is called right after that point (same as before this refactor).
|
|
@@ -4114,11 +4588,9 @@ var RoutingAitSource = class extends ChiiAitSource {
|
|
|
4114
4588
|
* `--remote-debugging-port` and returns a `LocalCdpConnection` attached to it,
|
|
4115
4589
|
* plus a `stop()` that kills both.
|
|
4116
4590
|
*
|
|
4117
|
-
*
|
|
4118
|
-
*
|
|
4119
|
-
*
|
|
4120
|
-
* - `runLocalDebugServer` (local-eager, #356): the eager family booted at
|
|
4121
|
-
* startup.
|
|
4591
|
+
* Booted lazily via the dual router's `bootLazyFor('local-browser')` callback,
|
|
4592
|
+
* at most once on the first `start_debug({ mode: 'local-browser' })` (all-lazy,
|
|
4593
|
+
* #396 — no run function boots a family at startup anymore).
|
|
4122
4594
|
*/
|
|
4123
4595
|
async function bootLocalFamily() {
|
|
4124
4596
|
const chromium = await launchChromium({
|
|
@@ -4143,10 +4615,10 @@ async function bootLocalFamily() {
|
|
|
4143
4615
|
* `getTunnelStatus()` reflects the live tunnel (it flips up once the background
|
|
4144
4616
|
* tunnel resolves and follows reissues).
|
|
4145
4617
|
*
|
|
4146
|
-
*
|
|
4147
|
-
*
|
|
4148
|
-
*
|
|
4149
|
-
*
|
|
4618
|
+
* Booted lazily via the dual router's `bootLazyFor('relay-intoss')` callback
|
|
4619
|
+
* (symmetry with {@link bootLocalFamily}), at most once on the first
|
|
4620
|
+
* `start_debug({ mode: 'relay-staging' | 'relay-live' })` (all-lazy, #396 — every
|
|
4621
|
+
* relay boot now flows through `switchMode` after the project-local secret load).
|
|
4150
4622
|
*
|
|
4151
4623
|
* The relay base URL is only known after `startChiiRelay()` resolves, so the
|
|
4152
4624
|
* `ChiiCdpConnection` (via {@link createRelayConnection}) is constructed inside
|
|
@@ -4156,6 +4628,7 @@ async function bootLocalFamily() {
|
|
|
4156
4628
|
* (relay host) is never logged here directly.
|
|
4157
4629
|
*/
|
|
4158
4630
|
async function bootRelayFamily(options = {}) {
|
|
4631
|
+
assertRelayAuthConfigured();
|
|
4159
4632
|
const relayPort = options.relayPort ?? 0;
|
|
4160
4633
|
const totpEnabled = options.verifyAuth !== void 0;
|
|
4161
4634
|
const relay = await startChiiRelay({
|
|
@@ -4205,6 +4678,7 @@ async function bootRelayFamily(options = {}) {
|
|
|
4205
4678
|
const connection = createRelayConnection(relay.baseUrl);
|
|
4206
4679
|
return {
|
|
4207
4680
|
connection,
|
|
4681
|
+
relayOrigin: "intoss-webview",
|
|
4208
4682
|
getTunnelStatus: () => tunnelStatus,
|
|
4209
4683
|
stop() {
|
|
4210
4684
|
tunnelProbe?.stop();
|
|
@@ -4215,21 +4689,119 @@ async function bootRelayFamily(options = {}) {
|
|
|
4215
4689
|
};
|
|
4216
4690
|
}
|
|
4217
4691
|
/**
|
|
4218
|
-
*
|
|
4219
|
-
*
|
|
4220
|
-
*
|
|
4221
|
-
*
|
|
4222
|
-
*
|
|
4223
|
-
*
|
|
4224
|
-
*
|
|
4692
|
+
* Boots the EXTERNAL relay family for env 2 (real-device PWA, issue #378).
|
|
4693
|
+
*
|
|
4694
|
+
* Unlike {@link bootRelayFamily}, this does NOT start a relay or a tunnel —
|
|
4695
|
+
* the unplugin (`tunnel: { cdp: true }`) already brought up a Chii relay for
|
|
4696
|
+
* the env-2 PWA and exposed its public base URL via `AIT_RELAY_BASE_URL`. Here
|
|
4697
|
+
* the MCP only opens a CDP client (`createRelayConnection`) against that
|
|
4698
|
+
* external relay. The relay's lifecycle is owned by the unplugin, so `stop()`
|
|
4699
|
+
* closes ONLY the CDP client — it must never tear down the relay or a tunnel
|
|
4700
|
+
* we did not start.
|
|
4701
|
+
*
|
|
4702
|
+
* `getTunnelStatus()` reports `up: true` with a `wssUrl` derived from
|
|
4703
|
+
* `relayBaseUrl` (http→ws, https→wss) so the `build_attach_url` gate
|
|
4704
|
+
* (`up: true && wssUrl !== null`) is satisfied even though we never opened a
|
|
4705
|
+
* cloudflared tunnel ourselves.
|
|
4706
|
+
*
|
|
4707
|
+
* SECRET-HANDLING: `relayBaseUrl` carries the relay host (same sensitivity as a
|
|
4708
|
+
* wss URL) — it is NEVER logged here. The caller validates presence and passes
|
|
4709
|
+
* the value straight to the CDP client.
|
|
4710
|
+
*/
|
|
4711
|
+
async function bootExternalRelayFamily(relayBaseUrl) {
|
|
4712
|
+
assertRelayAuthConfigured();
|
|
4713
|
+
const connection = createRelayConnection(relayBaseUrl);
|
|
4714
|
+
const tunnelStatus = makeTunnelStatus(true, relayBaseUrl.replace(/^http/, "ws"));
|
|
4715
|
+
return {
|
|
4716
|
+
connection,
|
|
4717
|
+
relayOrigin: "external-pwa",
|
|
4718
|
+
getTunnelStatus: () => tunnelStatus,
|
|
4719
|
+
stop() {
|
|
4720
|
+
connection.close();
|
|
4721
|
+
}
|
|
4722
|
+
};
|
|
4723
|
+
}
|
|
4724
|
+
/**
|
|
4725
|
+
* Maps a `StartDebugMode` to the {@link FamilyKey} that serves it (issue #378).
|
|
4726
|
+
* local-browser → 'local-browser'; relay-sandbox → 'relay-sandbox';
|
|
4727
|
+
* relay-staging/relay-live → 'relay-intoss' (the shared physical slot).
|
|
4728
|
+
*/
|
|
4729
|
+
function familyKeyForMode(mode) {
|
|
4730
|
+
switch (mode) {
|
|
4731
|
+
case "local-browser": return "local-browser";
|
|
4732
|
+
case "relay-sandbox": return "relay-sandbox";
|
|
4733
|
+
case "relay-staging":
|
|
4734
|
+
case "relay-live": return "relay-intoss";
|
|
4735
|
+
}
|
|
4736
|
+
}
|
|
4737
|
+
/** The error thrown / surfaced when entering `mobile` without AIT_RELAY_BASE_URL. */
|
|
4738
|
+
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가 필요합니다.";
|
|
4739
|
+
/**
|
|
4740
|
+
* Reads `AIT_RELAY_BASE_URL` from the environment for the env-2 (`mobile`) boot
|
|
4741
|
+
* site (issue #378). Returns the trimmed value, or throws the precise
|
|
4742
|
+
* {@link MOBILE_RELAY_BASE_URL_MISSING_MESSAGE} when unset/empty.
|
|
4743
|
+
*
|
|
4744
|
+
* SECRET-HANDLING: `AIT_RELAY_BASE_URL` carries the relay host (same class as a
|
|
4745
|
+
* wss URL). On the missing path the thrown message describes the env var name
|
|
4746
|
+
* and how to obtain it — it NEVER echoes any partial/garbled URL value. The
|
|
4747
|
+
* present value is returned to the caller (the CDP client) but never logged.
|
|
4748
|
+
*/
|
|
4749
|
+
function readMobileRelayBaseUrl(env = process.env) {
|
|
4750
|
+
const raw = env.AIT_RELAY_BASE_URL;
|
|
4751
|
+
const value = typeof raw === "string" ? raw.trim() : "";
|
|
4752
|
+
if (value === "") throw new Error(MOBILE_RELAY_BASE_URL_MISSING_MESSAGE);
|
|
4753
|
+
return value;
|
|
4754
|
+
}
|
|
4755
|
+
/**
|
|
4756
|
+
* Sentinel connection returned by {@link DualConnectionRouter.active} before the
|
|
4757
|
+
* first `start_debug` boots a family (all-lazy, issue #396). It satisfies the
|
|
4758
|
+
* full {@link CdpConnection} interface but holds nothing: `listTargets()` is
|
|
4759
|
+
* empty, every command rejects with a clear "call start_debug first" message,
|
|
4760
|
+
* and all event/teardown members are safe no-ops. Callers that read tools before
|
|
4761
|
+
* any switchMode therefore get an honest empty/down state instead of an NPE.
|
|
4762
|
+
*/
|
|
4763
|
+
const NULL_CDP_CONNECTION = {
|
|
4764
|
+
kind: "local",
|
|
4765
|
+
enableDomains: () => Promise.resolve(),
|
|
4766
|
+
listTargets: () => [],
|
|
4767
|
+
getBufferedEvents: () => [],
|
|
4768
|
+
on: () => () => {},
|
|
4769
|
+
send: () => Promise.reject(/* @__PURE__ */ new Error("no family booted yet — call start_debug first")),
|
|
4770
|
+
close: () => {}
|
|
4771
|
+
};
|
|
4772
|
+
/**
|
|
4773
|
+
* Production `ConnectionRouter` (issues #348, #356, #378 — DUAL-CONNECTION-COEXIST).
|
|
4774
|
+
*
|
|
4775
|
+
* Holds a keyed set of lazily-booted families ({@link FamilyKey} →
|
|
4776
|
+
* `BootedFamily`, issue #378) with NO family active at startup (issue #396); the
|
|
4777
|
+
* first `start_debug` boots and activates one. Plus an `active` pointer and the
|
|
4778
|
+
* single attach watcher armed on the active connection. The router is
|
|
4779
|
+
* **direction-neutral** (#356): any family can be the first one booted, so a
|
|
4780
|
+
* `--target=local` session can hot-switch into relay (and vice versa) without
|
|
4781
|
+
* restarting the MCP server.
|
|
4782
|
+
*
|
|
4783
|
+
* Why a KEYED map and not a single lazy slot (#378): `relay-sandbox` (env-2
|
|
4784
|
+
* external relay) and `relay-staging`/`relay-live` (intoss relay) are BOTH
|
|
4785
|
+
* `kind: 'relay'`. A single "opposite-kind" slot could not warm-keep both at
|
|
4786
|
+
* once — they would collide. The three `FamilyKey`s
|
|
4787
|
+
* (`local-browser` / `relay-intoss` / `relay-sandbox`) give each its own warm
|
|
4788
|
+
* slot — `relay-staging` and `relay-live` deliberately share the one
|
|
4789
|
+
* `relay-intoss` slot (wire-identical, distinguished only by `liveIntent`).
|
|
4790
|
+
*
|
|
4791
|
+
* Why all-lazy (#396): the relay TOTP secret now lives in a project-local
|
|
4792
|
+
* `.ait_relay` file loaded read-only by `switchMode` BEFORE a relay family boots.
|
|
4793
|
+
* Booting any family eagerly at startup would bypass that load. With NO eager
|
|
4794
|
+
* boot every relay boot flows through `switchMode → loadRelaySecretReadOnly`, so
|
|
4795
|
+
* the secret is always populated before `assertRelayAuthConfigured()` /
|
|
4796
|
+
* `buildRelayVerifyAuth()` run at the boot site.
|
|
4225
4797
|
*
|
|
4226
4798
|
* `switchMode`:
|
|
4227
|
-
* 1. rejects re-entrant swaps (`swapInFlight`) and an unconfirmed relay-live
|
|
4228
|
-
* 2.
|
|
4229
|
-
*
|
|
4799
|
+
* 1. rejects re-entrant swaps (`swapInFlight`) and an unconfirmed `relay-live`;
|
|
4800
|
+
* 2. resolves the requested mode's `FamilyKey`:
|
|
4801
|
+
* `lazyFamilies.get(key) ?? (boot via bootLazyFor(key), store)`;
|
|
4230
4802
|
* 3. flips `active` (the MCP `Server` never re-handshakes — it reads through
|
|
4231
4803
|
* `active` per request);
|
|
4232
|
-
* 4. sets `liveIntent` (true only for relay-live);
|
|
4804
|
+
* 4. sets `liveIntent` (true only for `relay-live`; `relay-sandbox` is dev-intent → false);
|
|
4233
4805
|
* 5. stops the old attach watcher and re-arms one on the new connection
|
|
4234
4806
|
* (the watcher self-clears, so re-arm is mandatory);
|
|
4235
4807
|
* 6. emits `tools/list_changed`.
|
|
@@ -4240,30 +4812,37 @@ async function bootRelayFamily(options = {}) {
|
|
|
4240
4812
|
*/
|
|
4241
4813
|
var DualConnectionRouter = class {
|
|
4242
4814
|
deps;
|
|
4243
|
-
/**
|
|
4244
|
-
|
|
4245
|
-
|
|
4815
|
+
/** Families, booted lazily and warm-kept per {@link FamilyKey} (#378, #396). */
|
|
4816
|
+
lazyFamilies = /* @__PURE__ */ new Map();
|
|
4817
|
+
/** `null` until the first `start_debug` boots a family (all-lazy, #396). */
|
|
4818
|
+
activeFamily = null;
|
|
4246
4819
|
server = null;
|
|
4247
4820
|
attachWatcher = null;
|
|
4248
4821
|
swapInFlight = false;
|
|
4249
4822
|
constructor(deps) {
|
|
4250
4823
|
this.deps = deps;
|
|
4251
|
-
this.activeFamily = deps.eager;
|
|
4252
4824
|
}
|
|
4253
4825
|
get active() {
|
|
4254
|
-
return this.activeFamily.connection;
|
|
4826
|
+
return this.activeFamily ? this.activeFamily.connection : NULL_CDP_CONNECTION;
|
|
4255
4827
|
}
|
|
4256
|
-
/**
|
|
4828
|
+
/** Relay origin of the currently-active family (issue #378). */
|
|
4829
|
+
get activeRelayOrigin() {
|
|
4830
|
+
return this.activeFamily?.relayOrigin;
|
|
4831
|
+
}
|
|
4832
|
+
/** Every booted family (for unified shutdown). All families are lazy (#396). */
|
|
4257
4833
|
bootedFamilies() {
|
|
4258
|
-
return
|
|
4834
|
+
return [...this.lazyFamilies.values()];
|
|
4259
4835
|
}
|
|
4260
4836
|
/**
|
|
4261
|
-
* Live tunnel status of the relay family
|
|
4262
|
-
*
|
|
4263
|
-
*
|
|
4264
|
-
*
|
|
4837
|
+
* Live tunnel status of the active relay family (issues #356, #378). Reads
|
|
4838
|
+
* the ACTIVE family's tunnel when it has one (so `relay-sandbox` surfaces the
|
|
4839
|
+
* external relay wss and `relay-staging`/`relay-live` the intoss relay wss); otherwise
|
|
4840
|
+
* falls back to the first booted family that has a tunnel. Returns "down"
|
|
4841
|
+
* until any relay family is booted (any session before the first relay
|
|
4842
|
+
* start_debug) — the correct signal for `build_attach_url` (no tunnel yet).
|
|
4265
4843
|
*/
|
|
4266
4844
|
relayTunnelStatus() {
|
|
4845
|
+
if (this.activeFamily?.getTunnelStatus) return this.activeFamily.getTunnelStatus();
|
|
4267
4846
|
for (const family of this.bootedFamilies()) if (family.getTunnelStatus) return family.getTunnelStatus();
|
|
4268
4847
|
return {
|
|
4269
4848
|
up: false,
|
|
@@ -4271,8 +4850,9 @@ var DualConnectionRouter = class {
|
|
|
4271
4850
|
};
|
|
4272
4851
|
}
|
|
4273
4852
|
/**
|
|
4274
|
-
* Binds the MCP `Server
|
|
4275
|
-
*
|
|
4853
|
+
* Binds the MCP `Server`; the attach watcher is armed by the first
|
|
4854
|
+
* `start_debug` since no family is active at startup (all-lazy, #396). Called
|
|
4855
|
+
* once after `createDebugServer` + `connect`.
|
|
4276
4856
|
*/
|
|
4277
4857
|
start(server) {
|
|
4278
4858
|
this.server = server;
|
|
@@ -4287,32 +4867,43 @@ var DualConnectionRouter = class {
|
|
|
4287
4867
|
armWatcher() {
|
|
4288
4868
|
const server = this.server;
|
|
4289
4869
|
if (!server) return;
|
|
4290
|
-
|
|
4870
|
+
const activeFamily = this.activeFamily;
|
|
4871
|
+
if (!activeFamily) return;
|
|
4872
|
+
this.attachWatcher = startAttachWatcher(activeFamily.connection, server, this.deps.attachWatcherIntervalMs ?? 1e3, () => {
|
|
4291
4873
|
this.deps.diagnosticsCollector.recordAttach();
|
|
4292
|
-
|
|
4874
|
+
this.deps.onPageAttach?.();
|
|
4875
|
+
if (activeFamily.connection.kind === "relay") this.deps.devtoolsOpener.open(this.relayTunnelStatus().wssUrl, deriveEnvironment(activeFamily.connection.kind, getLiveIntent(), activeFamily.relayOrigin));
|
|
4293
4876
|
});
|
|
4294
4877
|
}
|
|
4295
|
-
|
|
4878
|
+
/**
|
|
4879
|
+
* Resolves the `BootedFamily` for `key`: the warm family if already booted,
|
|
4880
|
+
* otherwise boots it via `bootLazyFor(key)` and stores it (once per key).
|
|
4881
|
+
* Since #396 every family is lazy, so this is the single boot path for all
|
|
4882
|
+
* three keys.
|
|
4883
|
+
*/
|
|
4884
|
+
async familyFor(key) {
|
|
4885
|
+
const warm = this.lazyFamilies.get(key);
|
|
4886
|
+
if (warm) return warm;
|
|
4887
|
+
const booted = await this.deps.bootLazyFor(key);
|
|
4888
|
+
this.lazyFamilies.set(key, booted);
|
|
4889
|
+
return booted;
|
|
4890
|
+
}
|
|
4891
|
+
async switchMode(mode, confirm, projectRoot) {
|
|
4296
4892
|
if (this.swapInFlight) throw new Error("start_debug: 이전 전환이 아직 진행 중입니다 — 잠시 후 다시 호출하세요.");
|
|
4297
4893
|
if (mode === "relay-live" && !confirm) throw new Error("start_debug: relay-live(실서비스 LIVE)는 confirm: true가 필요합니다 — 실유저에게 영향이 갈 수 있는 LIVE 디버깅 진입을 명시적으로 승인하세요.");
|
|
4298
4894
|
this.swapInFlight = true;
|
|
4299
4895
|
try {
|
|
4300
|
-
|
|
4301
|
-
const
|
|
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
|
-
}
|
|
4896
|
+
if (isRelayMode(mode)) await loadRelaySecretReadOnly({ projectRoot });
|
|
4897
|
+
const target = await this.familyFor(familyKeyForMode(mode));
|
|
4308
4898
|
this.activeFamily = target;
|
|
4309
4899
|
setLiveIntent(mode === "relay-live");
|
|
4310
4900
|
this.stopWatcher();
|
|
4311
4901
|
this.armWatcher();
|
|
4312
4902
|
this.server?.sendToolListChanged();
|
|
4903
|
+
const wantRelay = isRelayMode(mode);
|
|
4313
4904
|
return {
|
|
4314
4905
|
mode,
|
|
4315
|
-
environment: deriveEnvironment(target.connection.kind, getLiveIntent()),
|
|
4906
|
+
environment: deriveEnvironment(target.connection.kind, getLiveIntent(), target.relayOrigin),
|
|
4316
4907
|
kind: target.connection.kind,
|
|
4317
4908
|
liveGuardActive: target.connection.kind === "relay" && getLiveIntent(),
|
|
4318
4909
|
nextStep: wantRelay ? "build_attach_url로 attach QR을 생성하세요 (relay 세션)." : "list_pages로 로컬 Chromium 페이지 attach를 확인하세요."
|
|
@@ -4332,26 +4923,39 @@ var DualConnectionRouter = class {
|
|
|
4332
4923
|
*/
|
|
4333
4924
|
async function runDebugServer(options = {}) {
|
|
4334
4925
|
const lockHandle = acquireLock({ force: options.force ?? false });
|
|
4335
|
-
const verifyAuth = buildRelayVerifyAuth();
|
|
4336
|
-
const relayFamily = await bootRelayFamily({
|
|
4337
|
-
relayPort: options.relayPort,
|
|
4338
|
-
verifyAuth,
|
|
4339
|
-
onWssUrl: (wssUrl) => lockHandle.updateWssUrl(wssUrl)
|
|
4340
|
-
});
|
|
4341
4926
|
const devtoolsOpener = new AutoDevtoolsOpener();
|
|
4342
4927
|
const diagnosticsCollector = new InMemoryDiagnosticsCollector();
|
|
4343
4928
|
const router = new DualConnectionRouter({
|
|
4344
|
-
|
|
4345
|
-
|
|
4929
|
+
bootLazyFor: (key) => key === "relay-sandbox" ? bootExternalRelayFamily(readMobileRelayBaseUrl()) : key === "local-browser" ? bootLocalFamily() : bootRelayFamily({
|
|
4930
|
+
relayPort: options.relayPort,
|
|
4931
|
+
verifyAuth: buildRelayVerifyAuth(),
|
|
4932
|
+
onWssUrl: (wssUrl) => {
|
|
4933
|
+
lockHandle.updateWssUrl(wssUrl);
|
|
4934
|
+
qrServer?.notifyStateChange();
|
|
4935
|
+
}
|
|
4936
|
+
}),
|
|
4346
4937
|
diagnosticsCollector,
|
|
4347
|
-
devtoolsOpener
|
|
4938
|
+
devtoolsOpener,
|
|
4939
|
+
onPageAttach: () => qrServer?.notifyStateChange()
|
|
4348
4940
|
});
|
|
4349
4941
|
const aitSource = new RoutingAitSource(() => {
|
|
4350
4942
|
return router.active;
|
|
4351
4943
|
});
|
|
4944
|
+
let lastAttachUrl = null;
|
|
4945
|
+
const getDashboardState = () => ({
|
|
4946
|
+
tunnel: {
|
|
4947
|
+
up: router.relayTunnelStatus().up,
|
|
4948
|
+
wssUrl: router.relayTunnelStatus().wssUrl
|
|
4949
|
+
},
|
|
4950
|
+
pages: router.active.listTargets().map((t) => ({
|
|
4951
|
+
id: t.id,
|
|
4952
|
+
url: t.url
|
|
4953
|
+
})),
|
|
4954
|
+
attachUrl: lastAttachUrl
|
|
4955
|
+
});
|
|
4352
4956
|
let qrServer;
|
|
4353
4957
|
try {
|
|
4354
|
-
qrServer = await startQrHttpServer();
|
|
4958
|
+
qrServer = await startQrHttpServer(getDashboardState);
|
|
4355
4959
|
} catch (err) {
|
|
4356
4960
|
logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
|
|
4357
4961
|
}
|
|
@@ -4364,7 +4968,11 @@ async function runDebugServer(options = {}) {
|
|
|
4364
4968
|
return qrServer;
|
|
4365
4969
|
},
|
|
4366
4970
|
diagnosticsCollector,
|
|
4367
|
-
|
|
4971
|
+
getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,
|
|
4972
|
+
onAttachUrlBuilt: (url) => {
|
|
4973
|
+
lastAttachUrl = url;
|
|
4974
|
+
qrServer?.notifyStateChange();
|
|
4975
|
+
}
|
|
4368
4976
|
});
|
|
4369
4977
|
const transport = new StdioServerTransport();
|
|
4370
4978
|
let closed = false;
|
|
@@ -4425,13 +5033,15 @@ async function runDebugServer(options = {}) {
|
|
|
4425
5033
|
}
|
|
4426
5034
|
}
|
|
4427
5035
|
/**
|
|
4428
|
-
*
|
|
4429
|
-
*
|
|
4430
|
-
*
|
|
4431
|
-
*
|
|
4432
|
-
*
|
|
4433
|
-
*
|
|
4434
|
-
* `start_debug({ mode: 'relay
|
|
5036
|
+
* Serves the debug stack over stdio with the local browser as the default
|
|
5037
|
+
* target. Since #396 NOTHING boots at startup — every family (including the
|
|
5038
|
+
* local Chromium) is lazy-booted on its first `start_debug`:
|
|
5039
|
+
* 1. `start_debug({ mode: 'local-browser' })` launches a local Chromium with
|
|
5040
|
+
* `--remote-debugging-port=<port>` and attaches a `LocalCdpConnection`;
|
|
5041
|
+
* 2. the intoss/external relay families lazy-boot on the first
|
|
5042
|
+
* `start_debug({ mode: 'relay-staging' | 'relay-live' | 'relay-sandbox' })`;
|
|
5043
|
+
* 3. all of this runs through the SAME direction-neutral
|
|
5044
|
+
* `DualConnectionRouter` that `runDebugServer` uses (issue #356).
|
|
4435
5045
|
*
|
|
4436
5046
|
* Symmetry with `runDebugServer` (#356): starting with `--target=local` no
|
|
4437
5047
|
* longer pins a single-connection router. A `--target=local` session can
|
|
@@ -4453,37 +5063,195 @@ async function runDebugServer(options = {}) {
|
|
|
4453
5063
|
*/
|
|
4454
5064
|
async function runLocalDebugServer(options = {}) {
|
|
4455
5065
|
const lockHandle = acquireLock({ force: options.force ?? false });
|
|
4456
|
-
const
|
|
4457
|
-
|
|
4458
|
-
|
|
5066
|
+
const cdpPort = options.cdpPort ?? 0;
|
|
5067
|
+
const devUrl = options.devUrl ?? process.env.AIT_DEVTOOLS_URL ?? "http://localhost:5173";
|
|
5068
|
+
const bootLocalFamilyForEntry = async () => {
|
|
5069
|
+
const chromium = await launchChromium({
|
|
5070
|
+
port: cdpPort,
|
|
5071
|
+
devUrl
|
|
5072
|
+
});
|
|
5073
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
5074
|
+
const localConnection = new LocalCdpConnection({ devtoolsHttpUrl: chromium.devtoolsUrl });
|
|
5075
|
+
return {
|
|
5076
|
+
connection: localConnection,
|
|
5077
|
+
stop() {
|
|
5078
|
+
localConnection.close();
|
|
5079
|
+
chromium.stop();
|
|
5080
|
+
}
|
|
5081
|
+
};
|
|
5082
|
+
};
|
|
5083
|
+
const devtoolsOpener = new AutoDevtoolsOpener();
|
|
5084
|
+
const diagnosticsCollector = new InMemoryDiagnosticsCollector();
|
|
5085
|
+
const router = new DualConnectionRouter({
|
|
5086
|
+
bootLazyFor: (key) => key === "relay-sandbox" ? bootExternalRelayFamily(readMobileRelayBaseUrl()) : key === "local-browser" ? bootLocalFamilyForEntry() : bootRelayFamily({
|
|
5087
|
+
verifyAuth: buildRelayVerifyAuth(),
|
|
5088
|
+
onWssUrl: (wssUrl) => {
|
|
5089
|
+
lockHandle.updateWssUrl(wssUrl);
|
|
5090
|
+
qrServer?.notifyStateChange();
|
|
5091
|
+
}
|
|
5092
|
+
}),
|
|
5093
|
+
diagnosticsCollector,
|
|
5094
|
+
devtoolsOpener,
|
|
5095
|
+
onPageAttach: () => qrServer?.notifyStateChange()
|
|
4459
5096
|
});
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
|
|
5097
|
+
const aitSource = new RoutingAitSource(() => {
|
|
5098
|
+
return router.active;
|
|
5099
|
+
});
|
|
5100
|
+
let lastAttachUrl = null;
|
|
5101
|
+
const getDashboardState = () => ({
|
|
5102
|
+
tunnel: {
|
|
5103
|
+
up: router.relayTunnelStatus().up,
|
|
5104
|
+
wssUrl: router.relayTunnelStatus().wssUrl
|
|
5105
|
+
},
|
|
5106
|
+
pages: router.active.listTargets().map((t) => ({
|
|
5107
|
+
id: t.id,
|
|
5108
|
+
url: t.url
|
|
5109
|
+
})),
|
|
5110
|
+
attachUrl: lastAttachUrl
|
|
5111
|
+
});
|
|
5112
|
+
let qrServer;
|
|
5113
|
+
try {
|
|
5114
|
+
qrServer = await startQrHttpServer(getDashboardState);
|
|
5115
|
+
} catch (err) {
|
|
5116
|
+
logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
|
|
5117
|
+
}
|
|
5118
|
+
const server = createDebugServer({
|
|
5119
|
+
connection: router.active,
|
|
5120
|
+
router,
|
|
5121
|
+
aitSource,
|
|
5122
|
+
getTunnelStatus: () => router.relayTunnelStatus(),
|
|
5123
|
+
get qrHttpServer() {
|
|
5124
|
+
return qrServer;
|
|
5125
|
+
},
|
|
5126
|
+
diagnosticsCollector,
|
|
5127
|
+
getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,
|
|
5128
|
+
onAttachUrlBuilt: (url) => {
|
|
5129
|
+
lastAttachUrl = url;
|
|
5130
|
+
qrServer?.notifyStateChange();
|
|
4467
5131
|
}
|
|
5132
|
+
});
|
|
5133
|
+
const transport = new StdioServerTransport();
|
|
5134
|
+
let closed = false;
|
|
5135
|
+
let parentWatcher = null;
|
|
5136
|
+
const shutdown = () => {
|
|
5137
|
+
if (closed) return;
|
|
5138
|
+
closed = true;
|
|
5139
|
+
parentWatcher?.stop();
|
|
5140
|
+
router.stopWatcher();
|
|
5141
|
+
for (const family of router.bootedFamilies()) family.stop();
|
|
5142
|
+
server.close();
|
|
5143
|
+
qrServer?.close();
|
|
5144
|
+
lockHandle.release();
|
|
4468
5145
|
};
|
|
4469
|
-
|
|
5146
|
+
process.once("SIGINT", shutdown);
|
|
5147
|
+
process.once("SIGTERM", shutdown);
|
|
5148
|
+
process.once("SIGHUP", shutdown);
|
|
5149
|
+
process.on("exit", () => {
|
|
5150
|
+
if (!closed) {
|
|
5151
|
+
closed = true;
|
|
5152
|
+
parentWatcher?.stop();
|
|
5153
|
+
router.stopWatcher();
|
|
5154
|
+
for (const family of router.bootedFamilies()) family.stop();
|
|
5155
|
+
lockHandle.release();
|
|
5156
|
+
}
|
|
5157
|
+
});
|
|
5158
|
+
process.on("uncaughtException", (err) => {
|
|
5159
|
+
logError("tool.error", {
|
|
5160
|
+
msg: `uncaughtException: ${String(err)}`,
|
|
5161
|
+
errorKind: "uncaught",
|
|
5162
|
+
mode: "local-browser"
|
|
5163
|
+
});
|
|
5164
|
+
shutdown();
|
|
5165
|
+
process.exit(1);
|
|
5166
|
+
});
|
|
5167
|
+
process.on("unhandledRejection", (reason) => {
|
|
5168
|
+
logError("tool.error", {
|
|
5169
|
+
msg: `unhandledRejection: ${String(reason)}`,
|
|
5170
|
+
errorKind: "unhandled-rejection",
|
|
5171
|
+
mode: "local-browser"
|
|
5172
|
+
});
|
|
5173
|
+
shutdown();
|
|
5174
|
+
process.exit(1);
|
|
5175
|
+
});
|
|
5176
|
+
await server.connect(transport);
|
|
5177
|
+
router.start(server);
|
|
5178
|
+
if (process.env.AIT_DEBUG_NO_PARENT_WATCH !== "1") {
|
|
5179
|
+
parentWatcher = startParentWatcher(() => {
|
|
5180
|
+
shutdown();
|
|
5181
|
+
process.exit(0);
|
|
5182
|
+
}, { intervalMs: 5e3 });
|
|
5183
|
+
process.stdin.once("end", () => {
|
|
5184
|
+
shutdown();
|
|
5185
|
+
process.exit(0);
|
|
5186
|
+
});
|
|
5187
|
+
process.stdin.once("close", () => {
|
|
5188
|
+
shutdown();
|
|
5189
|
+
process.exit(0);
|
|
5190
|
+
});
|
|
5191
|
+
}
|
|
5192
|
+
}
|
|
5193
|
+
/**
|
|
5194
|
+
* Serves the env-2 (real-device PWA) debug stack over stdio with the external
|
|
5195
|
+
* Chii relay as the default target (issue #378). Since #396 NOTHING boots at
|
|
5196
|
+
* startup — the external relay family is lazy-booted on the first
|
|
5197
|
+
* `start_debug({ mode: 'relay-sandbox' })`.
|
|
5198
|
+
*
|
|
5199
|
+
* Unlike `runDebugServer` (which starts its own relay + cloudflared tunnel),
|
|
5200
|
+
* `runMobileDebugServer` attaches to a relay the unplugin ALREADY brought up
|
|
5201
|
+
* (`tunnel: { cdp: true }`) and exposed via `AIT_RELAY_BASE_URL`. The MCP only
|
|
5202
|
+
* opens a CDP client against that external relay — it never starts or tears down
|
|
5203
|
+
* a relay or a tunnel it did not own (see {@link bootExternalRelayFamily}).
|
|
5204
|
+
*
|
|
5205
|
+
* Symmetry with `runDebugServer` / `runLocalDebugServer` (#356, #378, #396): all
|
|
5206
|
+
* three families are lazy-booted — the env-2 external relay on the first
|
|
5207
|
+
* `start_debug({ mode: 'relay-sandbox' })`, the local family on `local-browser`,
|
|
5208
|
+
* the intoss relay on `relay-staging`/`relay-live` — so a `--target=mobile`
|
|
5209
|
+
* session can hot-switch
|
|
5210
|
+
* without a restart. The active env derives to `relay-mobile` (external-PWA
|
|
5211
|
+
* origin, liveIntent off).
|
|
5212
|
+
*
|
|
5213
|
+
* SECRET-HANDLING: `AIT_RELAY_BASE_URL` is read once here via
|
|
5214
|
+
* {@link readMobileRelayBaseUrl}; when unset it throws
|
|
5215
|
+
* {@link MOBILE_RELAY_BASE_URL_MISSING_MESSAGE} — a message that names the env
|
|
5216
|
+
* var and how to obtain it, never echoing any URL value. The error propagates to
|
|
5217
|
+
* the bin entry's fatal handler (the missing-URL path prints the guidance, not a
|
|
5218
|
+
* value). The present value is passed straight to the CDP client, never logged.
|
|
5219
|
+
*/
|
|
5220
|
+
async function runMobileDebugServer(options = {}) {
|
|
5221
|
+
const relayBaseUrl = readMobileRelayBaseUrl();
|
|
5222
|
+
const lockHandle = acquireLock({ force: options.force ?? false });
|
|
4470
5223
|
const devtoolsOpener = new AutoDevtoolsOpener();
|
|
4471
5224
|
const diagnosticsCollector = new InMemoryDiagnosticsCollector();
|
|
4472
5225
|
const router = new DualConnectionRouter({
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
5226
|
+
bootLazyFor: (key) => key === "relay-sandbox" ? bootExternalRelayFamily(relayBaseUrl) : key === "local-browser" ? bootLocalFamily() : bootRelayFamily({
|
|
5227
|
+
verifyAuth: buildRelayVerifyAuth(),
|
|
5228
|
+
onWssUrl: (wssUrl) => {
|
|
5229
|
+
lockHandle.updateWssUrl(wssUrl);
|
|
5230
|
+
qrServer?.notifyStateChange();
|
|
5231
|
+
}
|
|
4477
5232
|
}),
|
|
4478
5233
|
diagnosticsCollector,
|
|
4479
|
-
devtoolsOpener
|
|
5234
|
+
devtoolsOpener,
|
|
5235
|
+
onPageAttach: () => qrServer?.notifyStateChange()
|
|
4480
5236
|
});
|
|
4481
5237
|
const aitSource = new RoutingAitSource(() => {
|
|
4482
5238
|
return router.active;
|
|
4483
5239
|
});
|
|
5240
|
+
let lastAttachUrl = null;
|
|
5241
|
+
const getDashboardState = () => ({
|
|
5242
|
+
tunnel: {
|
|
5243
|
+
up: router.relayTunnelStatus().up,
|
|
5244
|
+
wssUrl: router.relayTunnelStatus().wssUrl
|
|
5245
|
+
},
|
|
5246
|
+
pages: router.active.listTargets().map((t) => ({
|
|
5247
|
+
id: t.id,
|
|
5248
|
+
url: t.url
|
|
5249
|
+
})),
|
|
5250
|
+
attachUrl: lastAttachUrl
|
|
5251
|
+
});
|
|
4484
5252
|
let qrServer;
|
|
4485
5253
|
try {
|
|
4486
|
-
qrServer = await startQrHttpServer();
|
|
5254
|
+
qrServer = await startQrHttpServer(getDashboardState);
|
|
4487
5255
|
} catch (err) {
|
|
4488
5256
|
logWarn("server.start", { msg: `QR HTTP 서버 시작 실패 (text QR fallback 사용): ${err instanceof Error ? err.message : String(err)}` });
|
|
4489
5257
|
}
|
|
@@ -4496,7 +5264,11 @@ async function runLocalDebugServer(options = {}) {
|
|
|
4496
5264
|
return qrServer;
|
|
4497
5265
|
},
|
|
4498
5266
|
diagnosticsCollector,
|
|
4499
|
-
|
|
5267
|
+
getTotpSecret: () => process.env.AIT_DEBUG_TOTP_SECRET,
|
|
5268
|
+
onAttachUrlBuilt: (url) => {
|
|
5269
|
+
lastAttachUrl = url;
|
|
5270
|
+
qrServer?.notifyStateChange();
|
|
5271
|
+
}
|
|
4500
5272
|
});
|
|
4501
5273
|
const transport = new StdioServerTransport();
|
|
4502
5274
|
let closed = false;
|
|
@@ -4527,7 +5299,7 @@ async function runLocalDebugServer(options = {}) {
|
|
|
4527
5299
|
logError("tool.error", {
|
|
4528
5300
|
msg: `uncaughtException: ${String(err)}`,
|
|
4529
5301
|
errorKind: "uncaught",
|
|
4530
|
-
mode: "
|
|
5302
|
+
mode: "relay-sandbox"
|
|
4531
5303
|
});
|
|
4532
5304
|
shutdown();
|
|
4533
5305
|
process.exit(1);
|
|
@@ -4536,7 +5308,7 @@ async function runLocalDebugServer(options = {}) {
|
|
|
4536
5308
|
logError("tool.error", {
|
|
4537
5309
|
msg: `unhandledRejection: ${String(reason)}`,
|
|
4538
5310
|
errorKind: "unhandled-rejection",
|
|
4539
|
-
mode: "
|
|
5311
|
+
mode: "relay-sandbox"
|
|
4540
5312
|
});
|
|
4541
5313
|
shutdown();
|
|
4542
5314
|
process.exit(1);
|
|
@@ -4986,7 +5758,7 @@ function createDevServer(deps = {}) {
|
|
|
4986
5758
|
const aitSource = deps.aitSource ?? new HttpAitSource({ stateEndpoint });
|
|
4987
5759
|
const server = new Server({
|
|
4988
5760
|
name: "ait-devtools",
|
|
4989
|
-
version: "0.1.
|
|
5761
|
+
version: "0.1.57"
|
|
4990
5762
|
}, { capabilities: { tools: {} } });
|
|
4991
5763
|
server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: DEV_TOOL_DEFINITIONS.map((tool) => ({ ...tool })) }));
|
|
4992
5764
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -5061,11 +5833,15 @@ async function runDevServer() {
|
|
|
5061
5833
|
*
|
|
5062
5834
|
* --mode=debug (default)
|
|
5063
5835
|
* --target=relay (default) — CDP/Chii relay + cloudflared quick tunnel.
|
|
5064
|
-
* Attach a running mini-app (real Toss WebView, env
|
|
5836
|
+
* Attach a running mini-app (real Toss WebView, env 3/4) and read its
|
|
5065
5837
|
* console + network over CDP without a human watching a phone.
|
|
5066
5838
|
* --target=local — CDP direct-attach to a local Chromium launched by the
|
|
5067
5839
|
* MCP server (env 1). No relay or tunnel; the browser is launched
|
|
5068
5840
|
* pointing at AIT_DEVTOOLS_URL (default http://localhost:5173).
|
|
5841
|
+
* --target=mobile — CDP attach to an EXTERNAL Chii relay the unplugin
|
|
5842
|
+
* already brought up for the env-2 real-device PWA (`tunnel: { cdp: true }`),
|
|
5843
|
+
* exposed via AIT_RELAY_BASE_URL. The MCP starts no relay or tunnel; it
|
|
5844
|
+
* only opens a CDP client against that external relay (issue #378).
|
|
5069
5845
|
*
|
|
5070
5846
|
* --mode=dev — dev mode — reads the live browser mock state from a running
|
|
5071
5847
|
* Vite dev server (the devtools#130 `devtools_get_mock_state` surface).
|
|
@@ -5119,8 +5895,9 @@ function parseMode(argv) {
|
|
|
5119
5895
|
* Parses `--target=<value>` / `--target <value>` from argv; default `relay`.
|
|
5120
5896
|
*
|
|
5121
5897
|
* Only meaningful when `--mode=debug`:
|
|
5122
|
-
* - `relay` — phone/WebView attach over Chii relay + cloudflared tunnel (env
|
|
5898
|
+
* - `relay` — phone/WebView attach over Chii relay + cloudflared tunnel (env 3/4).
|
|
5123
5899
|
* - `local` — local Chromium CDP attach (env 1, no relay needed).
|
|
5900
|
+
* - `mobile` — CDP attach to an EXTERNAL relay (env 2 PWA, AIT_RELAY_BASE_URL).
|
|
5124
5901
|
*/
|
|
5125
5902
|
function parseTarget(argv) {
|
|
5126
5903
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -5129,7 +5906,7 @@ function parseTarget(argv) {
|
|
|
5129
5906
|
if (arg.startsWith("--target=")) return normalizeTarget(arg.slice(9));
|
|
5130
5907
|
if (arg === "--target") {
|
|
5131
5908
|
const next = argv[i + 1];
|
|
5132
|
-
if (next === void 0) throw new Error("--target requires a value: 'relay' (default) or '
|
|
5909
|
+
if (next === void 0) throw new Error("--target requires a value: 'relay' (default), 'local', or 'mobile'.");
|
|
5133
5910
|
return normalizeTarget(next);
|
|
5134
5911
|
}
|
|
5135
5912
|
}
|
|
@@ -5143,7 +5920,8 @@ function normalizeMode(value) {
|
|
|
5143
5920
|
function normalizeTarget(value) {
|
|
5144
5921
|
if (value === "relay") return "relay";
|
|
5145
5922
|
if (value === "local") return "local";
|
|
5146
|
-
|
|
5923
|
+
if (value === "mobile") return "mobile";
|
|
5924
|
+
throw new Error(`Unknown --target '${value}'. Expected 'relay' (default), 'local', or 'mobile'.`);
|
|
5147
5925
|
}
|
|
5148
5926
|
async function main() {
|
|
5149
5927
|
const args = process.argv.slice(2);
|
|
@@ -5153,6 +5931,7 @@ async function main() {
|
|
|
5153
5931
|
const target = parseTarget(args);
|
|
5154
5932
|
const force = parseForce(args);
|
|
5155
5933
|
if (target === "local") await runLocalDebugServer({ force });
|
|
5934
|
+
else if (target === "mobile") await runMobileDebugServer({ force });
|
|
5156
5935
|
else await runDebugServer({ force });
|
|
5157
5936
|
}
|
|
5158
5937
|
}
|