@ait-co/devtools 0.1.68 → 0.1.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chii-relay-BNd3G3UG.js → chii-relay-BcWDKbQ1.js} +89 -25
- package/dist/chii-relay-BcWDKbQ1.js.map +1 -0
- package/dist/{chii-relay-DngjQ2_A.cjs → chii-relay-D5Hc0G39.cjs} +91 -26
- package/dist/chii-relay-D5Hc0G39.cjs.map +1 -0
- package/dist/in-app/index.d.ts.map +1 -1
- package/dist/in-app/index.js +179 -0
- package/dist/in-app/index.js.map +1 -1
- package/dist/mcp/cli.js +97 -29
- package/dist/mcp/cli.js.map +1 -1
- package/dist/mcp/server.js +1 -1
- package/dist/panel/index.js +4 -2
- package/dist/panel/index.js.map +1 -1
- package/dist/{qr-http-server-DR__VNnX.cjs → qr-http-server-BIIMOcuU.cjs} +3 -1
- package/dist/qr-http-server-BIIMOcuU.cjs.map +1 -0
- package/dist/{qr-http-server-CyVQphTM.js → qr-http-server-CeEzLS3g.js} +3 -1
- package/dist/qr-http-server-CeEzLS3g.js.map +1 -0
- package/dist/{qr-http-server-DnQSQ3hC.cjs → qr-http-server-ClakYBO9.cjs} +3 -1
- package/dist/qr-http-server-ClakYBO9.cjs.map +1 -0
- package/dist/{qr-http-server-DKEca8J3.js → qr-http-server-JjGU81q7.js} +3 -1
- package/dist/qr-http-server-JjGU81q7.js.map +1 -0
- package/dist/{tunnel-BMY7KgO5.cjs → tunnel-DwVrcZ56.cjs} +2 -2
- package/dist/{tunnel-BMY7KgO5.cjs.map → tunnel-DwVrcZ56.cjs.map} +1 -1
- package/dist/{tunnel-DIN5Vvbo.js → tunnel-aIy_7nWm.js} +2 -2
- package/dist/{tunnel-DIN5Vvbo.js.map → tunnel-aIy_7nWm.js.map} +1 -1
- package/dist/unplugin/index.cjs +2 -2
- package/dist/unplugin/index.js +2 -2
- package/dist/unplugin/tunnel.cjs +1 -1
- package/dist/unplugin/tunnel.js +1 -1
- package/package.json +1 -1
- package/dist/chii-relay-BNd3G3UG.js.map +0 -1
- package/dist/chii-relay-DngjQ2_A.cjs.map +0 -1
- package/dist/qr-http-server-CyVQphTM.js.map +0 -1
- package/dist/qr-http-server-DKEca8J3.js.map +0 -1
- package/dist/qr-http-server-DR__VNnX.cjs.map +0 -1
- package/dist/qr-http-server-DnQSQ3hC.cjs.map +0 -1
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
+
import { WebSocketServer } from "ws";
|
|
4
|
+
//#region src/shared/relay-auth-close.ts
|
|
5
|
+
/**
|
|
6
|
+
* Shared constants for the relay's named TOTP-auth rejection (issue #478).
|
|
7
|
+
*
|
|
8
|
+
* Before #478 the relay rejected an unauthenticated WebSocket upgrade with a
|
|
9
|
+
* raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is
|
|
10
|
+
* indistinguishable from a network failure on the browser side — the
|
|
11
|
+
* WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)
|
|
12
|
+
* could not tell "stale TOTP code" apart from "tunnel down" and stayed
|
|
13
|
+
* silent. The fix is accept-then-close: complete the handshake, then close
|
|
14
|
+
* with an application close code that NAMES the rejection.
|
|
15
|
+
*
|
|
16
|
+
* Three parties share this contract:
|
|
17
|
+
* - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;
|
|
18
|
+
* - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and
|
|
19
|
+
* surfaces the code to the launcher shell;
|
|
20
|
+
* - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code
|
|
21
|
+
* as an auth failure on its own `/client` dial (defensive — #439's fresh
|
|
22
|
+
* code mint means it should not normally hit this).
|
|
23
|
+
*
|
|
24
|
+
* This module is intentionally dependency-free (no Node, no DOM) so it is
|
|
25
|
+
* safe to import from both the browser in-app bundle and the MCP daemon
|
|
26
|
+
* bundle.
|
|
27
|
+
*
|
|
28
|
+
* SECRET-HANDLING: these are fixed enum values. The close reason / error body
|
|
29
|
+
* must never grow to carry a secret, a TOTP code, or a host.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* WebSocket close code sent by the relay when TOTP auth is rejected.
|
|
33
|
+
*
|
|
34
|
+
* 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors
|
|
35
|
+
* HTTP 401 so it reads as "unauthorized" at a glance.
|
|
36
|
+
*/
|
|
37
|
+
const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;
|
|
38
|
+
/**
|
|
39
|
+
* Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and
|
|
40
|
+
* the `error` value of the relay's HTTP 401 JSON body. Enum string only —
|
|
41
|
+
* never interpolated with request data.
|
|
42
|
+
*/
|
|
43
|
+
const RELAY_AUTH_REJECT_REASON = "totp-rejected";
|
|
44
|
+
//#endregion
|
|
3
45
|
//#region src/mcp/chii-relay.ts
|
|
4
46
|
/**
|
|
5
47
|
* Boots the local Chii relay server.
|
|
@@ -13,12 +55,27 @@ import { createServer } from "node:http";
|
|
|
13
55
|
* entries.
|
|
14
56
|
*
|
|
15
57
|
* TOTP auth (relay-side, authoritative gate):
|
|
16
|
-
* When `verifyAuth` is provided, this module
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
58
|
+
* When `verifyAuth` is provided, this module gates both inbound surfaces:
|
|
59
|
+
*
|
|
60
|
+
* - HTTP 'request': a listener registered BEFORE `chii.start({server})`.
|
|
61
|
+
* Node's `http.Server` calls listeners in registration order; the first
|
|
62
|
+
* to call `res.end()` wins. Invalid auth → 401 + CORS header + a tiny
|
|
63
|
+
* JSON body (`{"error":"totp-rejected"}`) so a cross-origin script
|
|
64
|
+
* `fetch()` probe can READ the status (issue #478). Valid auth → return
|
|
65
|
+
* without side-effect (chii's Koa handler serves it).
|
|
66
|
+
*
|
|
67
|
+
* - WS 'upgrade': after `chii.start()` has registered chii's own upgrade
|
|
68
|
+
* listener, we take over the upgrade chain (remove chii's listeners,
|
|
69
|
+
* re-dispatch manually). Invalid auth → accept-then-close: complete the
|
|
70
|
+
* handshake via a `noServer` WebSocketServer, then immediately close
|
|
71
|
+
* with code 4401 reason 'totp-rejected' (issue #478). A raw 401 +
|
|
72
|
+
* `socket.destroy()` only ever surfaced as close code 1006 in the
|
|
73
|
+
* browser — indistinguishable from a tunnel failure, which left the
|
|
74
|
+
* env-2 phone UI silent. The explicit dispatch (not listener ordering)
|
|
75
|
+
* is what keeps chii away from rejected sockets: accept-then-close
|
|
76
|
+
* leaves the socket alive, so an order-based early-return would let
|
|
77
|
+
* chii's later listener complete a SECOND handshake on the same socket
|
|
78
|
+
* — an auth bypass. Valid auth → forward to chii's captured listeners.
|
|
22
79
|
*
|
|
23
80
|
* TOTP code transports (issue #466) — two equivalent ways to carry the code:
|
|
24
81
|
* 1. Query param `at=<code>` — used by the daemon-side `/client` connection
|
|
@@ -104,33 +161,40 @@ async function startChiiRelay(options = {}) {
|
|
|
104
161
|
onAuthReject({ kind });
|
|
105
162
|
} catch {}
|
|
106
163
|
};
|
|
164
|
+
if (verifyAuth) httpServer.on("request", (req, res) => {
|
|
165
|
+
const rewritten = rewriteAtPathPrefix(req.url ?? "");
|
|
166
|
+
if (rewritten === null) return;
|
|
167
|
+
req.url = rewritten;
|
|
168
|
+
if (!verifyAuth(req)) {
|
|
169
|
+
res.statusCode = 401;
|
|
170
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
171
|
+
res.setHeader("Content-Type", "application/json");
|
|
172
|
+
res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));
|
|
173
|
+
notifyAuthReject("http-request");
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
await loadChiiServer().start({
|
|
177
|
+
server: httpServer,
|
|
178
|
+
domain: `${host}:${requestedPort}`,
|
|
179
|
+
port: requestedPort
|
|
180
|
+
});
|
|
107
181
|
if (verifyAuth) {
|
|
108
|
-
httpServer.
|
|
182
|
+
const chiiUpgradeListeners = httpServer.listeners("upgrade");
|
|
183
|
+
httpServer.removeAllListeners("upgrade");
|
|
184
|
+
const rejectWss = new WebSocketServer({ noServer: true });
|
|
185
|
+
httpServer.on("upgrade", (req, socket, head) => {
|
|
109
186
|
const rewritten = rewriteAtPathPrefix(req.url ?? "");
|
|
110
187
|
if (rewritten !== null) req.url = rewritten;
|
|
111
188
|
if (!verifyAuth(req)) {
|
|
112
|
-
|
|
113
|
-
|
|
189
|
+
rejectWss.handleUpgrade(req, socket, head, (ws) => {
|
|
190
|
+
ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);
|
|
191
|
+
});
|
|
114
192
|
notifyAuthReject("ws-upgrade");
|
|
115
193
|
return;
|
|
116
194
|
}
|
|
117
|
-
|
|
118
|
-
httpServer.on("request", (req, res) => {
|
|
119
|
-
const rewritten = rewriteAtPathPrefix(req.url ?? "");
|
|
120
|
-
if (rewritten === null) return;
|
|
121
|
-
req.url = rewritten;
|
|
122
|
-
if (!verifyAuth(req)) {
|
|
123
|
-
res.statusCode = 401;
|
|
124
|
-
res.end();
|
|
125
|
-
notifyAuthReject("http-request");
|
|
126
|
-
}
|
|
195
|
+
for (const listener of chiiUpgradeListeners) listener(req, socket, head);
|
|
127
196
|
});
|
|
128
197
|
}
|
|
129
|
-
await loadChiiServer().start({
|
|
130
|
-
server: httpServer,
|
|
131
|
-
domain: `${host}:${requestedPort}`,
|
|
132
|
-
port: requestedPort
|
|
133
|
-
});
|
|
134
198
|
const actualPort = await new Promise((resolve, reject) => {
|
|
135
199
|
httpServer.once("error", reject);
|
|
136
200
|
httpServer.listen(requestedPort, host, () => {
|
|
@@ -149,4 +213,4 @@ async function startChiiRelay(options = {}) {
|
|
|
149
213
|
//#endregion
|
|
150
214
|
export { startChiiRelay };
|
|
151
215
|
|
|
152
|
-
//# sourceMappingURL=chii-relay-
|
|
216
|
+
//# sourceMappingURL=chii-relay-BcWDKbQ1.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chii-relay-BcWDKbQ1.js","names":[],"sources":["../src/shared/relay-auth-close.ts","../src/mcp/chii-relay.ts"],"sourcesContent":["/**\n * Shared constants for the relay's named TOTP-auth rejection (issue #478).\n *\n * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a\n * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is\n * indistinguishable from a network failure on the browser side — the\n * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)\n * could not tell \"stale TOTP code\" apart from \"tunnel down\" and stayed\n * silent. The fix is accept-then-close: complete the handshake, then close\n * with an application close code that NAMES the rejection.\n *\n * Three parties share this contract:\n * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;\n * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and\n * surfaces the code to the launcher shell;\n * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code\n * as an auth failure on its own `/client` dial (defensive — #439's fresh\n * code mint means it should not normally hit this).\n *\n * This module is intentionally dependency-free (no Node, no DOM) so it is\n * safe to import from both the browser in-app bundle and the MCP daemon\n * bundle.\n *\n * SECRET-HANDLING: these are fixed enum values. The close reason / error body\n * must never grow to carry a secret, a TOTP code, or a host.\n */\n\n/**\n * WebSocket close code sent by the relay when TOTP auth is rejected.\n *\n * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors\n * HTTP 401 so it reads as \"unauthorized\" at a glance.\n */\nexport const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;\n\n/**\n * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and\n * the `error` value of the relay's HTTP 401 JSON body. Enum string only —\n * never interpolated with request data.\n */\nexport const RELAY_AUTH_REJECT_REASON = 'totp-rejected';\n","/**\n * Boots the local Chii relay server.\n *\n * Chii (liriliri/chii) is a chobitsu-based CDP relay that lets non-Chrome\n * WebViews (iOS WKWebView / Android WebView — i.e. the Toss app) expose CDP.\n * The relay accepts a `target` websocket from the phone's injected `target.js`\n * and `client` websockets from CDP frontends (our MCP connection).\n *\n * Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app\n * entries.\n *\n * TOTP auth (relay-side, authoritative gate):\n * When `verifyAuth` is provided, this module gates both inbound surfaces:\n *\n * - HTTP 'request': a listener registered BEFORE `chii.start({server})`.\n * Node's `http.Server` calls listeners in registration order; the first\n * to call `res.end()` wins. Invalid auth → 401 + CORS header + a tiny\n * JSON body (`{\"error\":\"totp-rejected\"}`) so a cross-origin script\n * `fetch()` probe can READ the status (issue #478). Valid auth → return\n * without side-effect (chii's Koa handler serves it).\n *\n * - WS 'upgrade': after `chii.start()` has registered chii's own upgrade\n * listener, we take over the upgrade chain (remove chii's listeners,\n * re-dispatch manually). Invalid auth → accept-then-close: complete the\n * handshake via a `noServer` WebSocketServer, then immediately close\n * with code 4401 reason 'totp-rejected' (issue #478). A raw 401 +\n * `socket.destroy()` only ever surfaced as close code 1006 in the\n * browser — indistinguishable from a tunnel failure, which left the\n * env-2 phone UI silent. The explicit dispatch (not listener ordering)\n * is what keeps chii away from rejected sockets: accept-then-close\n * leaves the socket alive, so an order-based early-return would let\n * chii's later listener complete a SECOND handshake on the same socket\n * — an auth bypass. Valid auth → forward to chii's captured listeners.\n *\n * TOTP code transports (issue #466) — two equivalent ways to carry the code:\n * 1. Query param `at=<code>` — used by the daemon-side `/client` connection\n * (`chii-connection.ts` appends it; it holds the secret).\n * 2. Path prefix `/at/<code>/…` — used by the phone-side target. Chii's\n * stock `target.js` derives its WS endpoint from the script `src`\n * (`scriptEl.src.replace('target.js','')`), so the only way for the\n * phone to carry a code is to embed it in the script URL path. The\n * in-app attach injects `https://<host>/at/<code>/target.js`; both the\n * script fetch and the derived `wss://<host>/at/<code>/target/<id>` WS\n * dial then carry the prefix. The listeners below rewrite the prefix\n * into the query form (`rewriteAtPathPrefix`) and MUTATE `req.url`\n * before chii's own handlers (registered later) parse it — chii only\n * ever sees the stripped URL.\n *\n * Threat model: \"URL leak\" — someone obtains the tunnel URL (Slack paste, QR\n * screenshot, shoulder-surfing) but does not have the shared TOTP secret.\n * Rotating 6-digit code makes the URL stale after 30 s.\n * A determined attacker who extracts the secret from the dogfood bundle can\n * still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).\n *\n * SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear\n * in any log, error message, or process output. `verifyAuth` is a black-box\n * predicate from the caller's perspective; this module only forwards pass/fail.\n */\n\nimport { createServer, type IncomingMessage, type Server } from 'node:http';\nimport { createRequire } from 'node:module';\nimport type { AddressInfo } from 'node:net';\nimport type { Duplex } from 'node:stream';\n// `ws` is a direct dependency of this package (NOT a transitive reach into\n// chii's tree — same principle as the ajv incident): the reject path below\n// needs `WebSocketServer.handleUpgrade` to complete a handshake we are about\n// to close with a named code.\nimport { WebSocketServer } from 'ws';\nimport {\n RELAY_AUTH_REJECT_CLOSE_CODE,\n RELAY_AUTH_REJECT_REASON,\n} from '../shared/relay-auth-close.js';\n\nconst require = createRequire(import.meta.url);\n\n/** `chii/server` is CommonJS and shipped without TypeScript types. */\ninterface ChiiServerModule {\n start(options: {\n port?: number;\n host?: string;\n domain?: string;\n server?: Server;\n basePath?: string;\n }): Promise<void>;\n}\n\nfunction loadChiiServer(): ChiiServerModule {\n // `chii`'s package `main` is `./server/index.js`, exposing `{ start }`.\n const mod: unknown = require('chii');\n if (\n typeof mod === 'object' &&\n mod !== null &&\n 'start' in mod &&\n typeof (mod as { start: unknown }).start === 'function'\n ) {\n return mod as ChiiServerModule;\n }\n throw new Error('chii server module did not expose start()');\n}\n\nexport interface ChiiRelay {\n port: number;\n /** Base URL for the relay HTTP/WS server, e.g. `http://127.0.0.1:54321`. */\n baseUrl: string;\n close(): Promise<void>;\n}\n\n/**\n * Secret-free metadata about a single auth rejection (issue #467).\n *\n * SECRET-HANDLING: this event carries ONLY the surface kind. It must never\n * grow fields for `req.url`, query strings, codes, or secrets — observers\n * (diagnostics counters, console hints) only need \"a rejection happened\".\n */\nexport interface RelayAuthRejectEvent {\n /** Which inbound surface was rejected. */\n kind: 'ws-upgrade' | 'http-request';\n}\n\n/**\n * Rewrites a `/at/<code>/…` path-prefixed request URL into the equivalent\n * query-based form, e.g.:\n *\n * `/at/123456/target.js` → `/target.js?at=123456`\n * `/at/123456/target/x?url=u` → `/target/x?url=u&at=123456`\n * `/at/123456/` → `/?at=123456`\n *\n * Returns `null` when the URL does not carry the prefix (including an empty\n * code segment) — callers fall back to the unmodified URL and the existing\n * query-based auth path.\n *\n * Pure string surgery — this function knows nothing about secrets or code\n * validity; verification stays inside the caller-provided `verifyAuth`\n * predicate (which parses the query). The raw path segment is appended\n * verbatim to the query: both path segments and query values are\n * percent-decoded exactly once by their consumers, so no re-encoding is\n * needed (TOTP codes are 6 digits and never percent-encoded in practice).\n */\nexport function rewriteAtPathPrefix(rawUrl: string): string | null {\n const match = /^\\/at\\/([^/?]+)(\\/[^?]*)?(\\?.*)?$/.exec(rawUrl);\n if (match === null) return null;\n const code = match[1];\n const path = match[2] === undefined || match[2] === '' ? '/' : match[2];\n const query = match[3] ?? '';\n const separator = query === '' ? '?' : '&';\n return `${path}${query}${separator}at=${code}`;\n}\n\nexport interface StartChiiRelayOptions {\n /**\n * Local port for the relay. Default 0 (OS-assigned ephemeral port).\n *\n * Using 0 means the OS picks a free port — this is the safe default because\n * a stale cloudflared child process (PPID 1, orphaned after SIGKILL) may still\n * be holding a fixed port. A fixed port causes EADDRINUSE on the next startup,\n * which makes the MCP handshake fail with -32000. With port 0 the new relay\n * always gets a fresh port, making any orphaned process harmless.\n *\n * Pass an explicit number to restore fixed-port behaviour (backwards-compatible).\n */\n port?: number;\n /** Bind host. Default 127.0.0.1 (tunnel reaches it locally). */\n host?: string;\n /**\n * Optional auth predicate for WebSocket upgrade requests.\n *\n * When provided, every inbound WebSocket upgrade is checked by calling\n * `verifyAuth(req)` before Chii processes it. Return `true` to allow the\n * upgrade; return `false` to reject with HTTP 401 and destroy the socket.\n *\n * The predicate MUST NOT log the secret or any TOTP code — it is a black-box\n * from this module's perspective.\n *\n * @param req - The raw HTTP `IncomingMessage` from the upgrade handshake.\n * Inspect `req.url` for query parameters (e.g. `at=<code>`). Path-prefixed\n * URLs (`/at/<code>/…`, the phone-target transport — issue #466) are\n * rewritten into the query form BEFORE this predicate runs, so a\n * query-only predicate covers both transports.\n * @returns `true` if the upgrade is authorised, `false` to reject.\n */\n verifyAuth?: (req: IncomingMessage) => boolean;\n /**\n * Secret-free observability callback fired on every auth rejection\n * (issue #467). Only meaningful together with `verifyAuth`.\n *\n * SECRET-HANDLING: the event carries ONLY the rejection kind — never\n * `req.url`, query strings, TOTP codes, or the secret. Implementations must\n * keep it that way (e.g. increment a counter + timestamp). Exceptions thrown\n * by the callback are swallowed so observability can never break the gate.\n */\n onAuthReject?: (event: RelayAuthRejectEvent) => void;\n}\n\n/**\n * Starts the Chii relay and resolves once listening.\n *\n * Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral\n * port on every start, so a stale cloudflared orphan holding any particular\n * port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`\n * always reflect the actual bound port.\n *\n * chii.start() is called with `server` (our pre-created httpServer) BEFORE\n * httpServer.listen(). This is intentional: chii attaches its Koa handler and\n * WS upgrade listener to the server object, but the actual TCP bind is\n * performed by our httpServer.listen() call below. The `port`/`domain` values\n * passed to chii.start() are used for display/banner purposes inside chii and\n * do not affect which port the server binds. The connection path (clients\n * connecting to `relay.baseUrl`) always uses the post-listen confirmed port.\n */\nexport async function startChiiRelay(options: StartChiiRelayOptions = {}): Promise<ChiiRelay> {\n const requestedPort = options.port ?? 0;\n const host = options.host ?? '127.0.0.1';\n const { verifyAuth, onAuthReject } = options;\n\n const httpServer = createServer();\n\n // Secret-free observability hook (issue #467). Swallow callback exceptions —\n // a broken observer must never turn into an open gate or a crashed relay.\n const notifyAuthReject = (kind: RelayAuthRejectEvent['kind']): void => {\n if (onAuthReject === undefined) return;\n try {\n onAuthReject({ kind });\n } catch {\n // Ignore — observability is best-effort.\n }\n };\n\n // Register the HTTP-request auth listener BEFORE chii.start() so it fires\n // first. Node's http.Server emits 'request' to all listeners in registration\n // order; the first to end() the response wins. Valid requests return without\n // side-effect so chii's own handler takes over normally — and because\n // listeners run synchronously in order, mutating `req.url` here (path-prefix\n // strip, issue #466) means chii's later-registered handler only ever sees\n // the stripped URL.\n //\n // We only register when verifyAuth is provided so the no-auth path is\n // zero-overhead for tests and local-only dev sessions. (The phone-side\n // `/at/<code>/` prefix only ever appears when TOTP is armed — the launcher\n // QR carries the `at` code — so the no-auth path never needs the strip.)\n if (verifyAuth) {\n // Plain HTTP requests: only the path-prefixed form is ours — the phone\n // fetches `target.js` via `https://<host>/at/<code>/target.js` (issue\n // #466), which must be verified + stripped so chii's Koa static handler\n // serves `/target.js`. Non-prefixed requests keep today's behaviour\n // (ungated pass-through to chii).\n httpServer.on('request', (req, res) => {\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten === null) return;\n req.url = rewritten;\n if (!verifyAuth(req)) {\n // We do NOT log req.url or any auth param here to avoid leaking codes.\n // CORS header + tiny JSON body (issue #478): the script URL is\n // cross-origin from the phone page (tunnel origin ≠ relay origin), so\n // without ACAO a fetch() probe sees an opaque error and cannot tell\n // auth rejection from a network failure. The header rides ONLY on\n // this error response — no relay asset is exposed through it.\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n }\n // Auth passed: no-op — chii's Koa 'request' listener (registered below\n // by chii.start) serves the rewritten URL. (Koa skips writing when an\n // earlier listener already ended the response, so the 401 path is safe\n // even though Koa still runs.)\n });\n }\n\n const chii = loadChiiServer();\n // Passing an existing `server` makes chii attach its Koa handler + WS upgrade\n // to our HTTP server rather than creating its own listener.\n // Note: port/domain here are display-only inside chii — the TCP bind is ours.\n await chii.start({ server: httpServer, domain: `${host}:${requestedPort}`, port: requestedPort });\n\n // WS upgrade gate (issue #478, accept-then-close): take over the upgrade\n // chain AFTER chii.start() has registered chii's own upgrade listener.\n // Listener ordering alone protected chii when rejection meant\n // socket.destroy(); accept-then-close keeps the socket ALIVE, so chii's\n // listener (which always runs on every 'upgrade' emit) would complete a\n // second handshake on the rejected socket — frames after our close frame\n // would reach chii's server-side WebSocket, i.e. an auth bypass. Capturing\n // chii's listeners and re-dispatching only on auth pass closes that hole.\n if (verifyAuth) {\n const chiiUpgradeListeners = httpServer.listeners('upgrade') as Array<\n (req: IncomingMessage, socket: Duplex, head: Buffer) => void\n >;\n httpServer.removeAllListeners('upgrade');\n // noServer: handshake-only — never binds a port; used purely to send a\n // spec-compliant close frame with a code the browser can read.\n const rejectWss = new WebSocketServer({ noServer: true });\n httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {\n // Phone-target transport (issue #466): normalise a `/at/<code>/…` path\n // prefix into the query form before verification, and strip it from the\n // URL chii will see. No-prefix URLs pass through untouched (daemon\n // client query transport — back-compat).\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n req.url = rewritten;\n }\n if (!verifyAuth(req)) {\n // Reject: complete the handshake, then close with a NAMED code so the\n // browser-side observer (in-app attach.ts) can distinguish \"stale\n // TOTP code\" (4401) from \"tunnel down\" (1006). Raw-401-destroy only\n // ever produced 1006 client-side — the env-2 silence gap (#478).\n // We do NOT log req.url or any auth param here to avoid leaking codes;\n // the close reason is a fixed enum string.\n rejectWss.handleUpgrade(req, socket, head, (ws) => {\n ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);\n });\n notifyAuthReject('ws-upgrade');\n // Early return — chii's captured listeners are NOT called.\n return;\n }\n // Auth passed: hand the upgrade to chii's own listeners (it sees the\n // stripped URL — same observable behaviour as the pre-#478 ordering).\n for (const listener of chiiUpgradeListeners) {\n listener(req, socket, head);\n }\n });\n }\n\n const actualPort = await new Promise<number>((resolve, reject) => {\n httpServer.once('error', reject);\n httpServer.listen(requestedPort, host, () => {\n httpServer.off('error', reject);\n // httpServer.address() is non-null immediately after the listen callback.\n const addr = httpServer.address() as AddressInfo;\n resolve(addr.port);\n });\n });\n\n return {\n port: actualPort,\n baseUrl: `http://${host}:${actualPort}`,\n close: () =>\n new Promise<void>((resolve) => {\n httpServer.close(() => resolve());\n }),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,MAAa,+BAA+B;;;;;;AAO5C,MAAa,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACiCxC,MAAM,UAAU,cAAc,OAAO,KAAK,IAAI;AAa9C,SAAS,iBAAmC;CAE1C,MAAM,MAAe,QAAQ,OAAO;AACpC,KACE,OAAO,QAAQ,YACf,QAAQ,QACR,WAAW,OACX,OAAQ,IAA2B,UAAU,WAE7C,QAAO;AAET,OAAM,IAAI,MAAM,4CAA4C;;;;;;;;;;;;;;;;;;;;;AAyC9D,SAAgB,oBAAoB,QAA+B;CACjE,MAAM,QAAQ,oCAAoC,KAAK,OAAO;AAC9D,KAAI,UAAU,KAAM,QAAO;CAC3B,MAAM,OAAO,MAAM;CACnB,MAAM,OAAO,MAAM,OAAO,KAAA,KAAa,MAAM,OAAO,KAAK,MAAM,MAAM;CACrE,MAAM,QAAQ,MAAM,MAAM;AAE1B,QAAO,GAAG,OAAO,QADC,UAAU,KAAK,MAAM,IACJ,KAAK;;;;;;;;;;;;;;;;;;AAgE1C,eAAsB,eAAe,UAAiC,EAAE,EAAsB;CAC5F,MAAM,gBAAgB,QAAQ,QAAQ;CACtC,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,EAAE,YAAY,iBAAiB;CAErC,MAAM,aAAa,cAAc;CAIjC,MAAM,oBAAoB,SAA6C;AACrE,MAAI,iBAAiB,KAAA,EAAW;AAChC,MAAI;AACF,gBAAa,EAAE,MAAM,CAAC;UAChB;;AAiBV,KAAI,WAMF,YAAW,GAAG,YAAY,KAAK,QAAQ;EACrC,MAAM,YAAY,oBAAoB,IAAI,OAAO,GAAG;AACpD,MAAI,cAAc,KAAM;AACxB,MAAI,MAAM;AACV,MAAI,CAAC,WAAW,IAAI,EAAE;AAOpB,OAAI,aAAa;AACjB,OAAI,UAAU,+BAA+B,IAAI;AACjD,OAAI,UAAU,gBAAgB,mBAAmB;AACjD,OAAI,IAAI,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC,CAAC;AAC5D,oBAAiB,eAAe;;GAMlC;AAOJ,OAJa,gBAAgB,CAIlB,MAAM;EAAE,QAAQ;EAAY,QAAQ,GAAG,KAAK,GAAG;EAAiB,MAAM;EAAe,CAAC;AAUjG,KAAI,YAAY;EACd,MAAM,uBAAuB,WAAW,UAAU,UAAU;AAG5D,aAAW,mBAAmB,UAAU;EAGxC,MAAM,YAAY,IAAI,gBAAgB,EAAE,UAAU,MAAM,CAAC;AACzD,aAAW,GAAG,YAAY,KAAsB,QAAgB,SAAiB;GAK/E,MAAM,YAAY,oBAAoB,IAAI,OAAO,GAAG;AACpD,OAAI,cAAc,KAChB,KAAI,MAAM;AAEZ,OAAI,CAAC,WAAW,IAAI,EAAE;AAOpB,cAAU,cAAc,KAAK,QAAQ,OAAO,OAAO;AACjD,QAAG,MAAM,8BAA8B,yBAAyB;MAChE;AACF,qBAAiB,aAAa;AAE9B;;AAIF,QAAK,MAAM,YAAY,qBACrB,UAAS,KAAK,QAAQ,KAAK;IAE7B;;CAGJ,MAAM,aAAa,MAAM,IAAI,SAAiB,SAAS,WAAW;AAChE,aAAW,KAAK,SAAS,OAAO;AAChC,aAAW,OAAO,eAAe,YAAY;AAC3C,cAAW,IAAI,SAAS,OAAO;AAG/B,WADa,WAAW,SAAS,CACpB,KAAK;IAClB;GACF;AAEF,QAAO;EACL,MAAM;EACN,SAAS,UAAU,KAAK,GAAG;EAC3B,aACE,IAAI,SAAe,YAAY;AAC7B,cAAW,YAAY,SAAS,CAAC;IACjC;EACL"}
|
|
@@ -1,4 +1,47 @@
|
|
|
1
1
|
let node_http = require("node:http");
|
|
2
|
+
let node_module = require("node:module");
|
|
3
|
+
let ws = require("ws");
|
|
4
|
+
//#region src/shared/relay-auth-close.ts
|
|
5
|
+
/**
|
|
6
|
+
* Shared constants for the relay's named TOTP-auth rejection (issue #478).
|
|
7
|
+
*
|
|
8
|
+
* Before #478 the relay rejected an unauthenticated WebSocket upgrade with a
|
|
9
|
+
* raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is
|
|
10
|
+
* indistinguishable from a network failure on the browser side — the
|
|
11
|
+
* WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)
|
|
12
|
+
* could not tell "stale TOTP code" apart from "tunnel down" and stayed
|
|
13
|
+
* silent. The fix is accept-then-close: complete the handshake, then close
|
|
14
|
+
* with an application close code that NAMES the rejection.
|
|
15
|
+
*
|
|
16
|
+
* Three parties share this contract:
|
|
17
|
+
* - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;
|
|
18
|
+
* - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and
|
|
19
|
+
* surfaces the code to the launcher shell;
|
|
20
|
+
* - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code
|
|
21
|
+
* as an auth failure on its own `/client` dial (defensive — #439's fresh
|
|
22
|
+
* code mint means it should not normally hit this).
|
|
23
|
+
*
|
|
24
|
+
* This module is intentionally dependency-free (no Node, no DOM) so it is
|
|
25
|
+
* safe to import from both the browser in-app bundle and the MCP daemon
|
|
26
|
+
* bundle.
|
|
27
|
+
*
|
|
28
|
+
* SECRET-HANDLING: these are fixed enum values. The close reason / error body
|
|
29
|
+
* must never grow to carry a secret, a TOTP code, or a host.
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* WebSocket close code sent by the relay when TOTP auth is rejected.
|
|
33
|
+
*
|
|
34
|
+
* 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors
|
|
35
|
+
* HTTP 401 so it reads as "unauthorized" at a glance.
|
|
36
|
+
*/
|
|
37
|
+
const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;
|
|
38
|
+
/**
|
|
39
|
+
* Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and
|
|
40
|
+
* the `error` value of the relay's HTTP 401 JSON body. Enum string only —
|
|
41
|
+
* never interpolated with request data.
|
|
42
|
+
*/
|
|
43
|
+
const RELAY_AUTH_REJECT_REASON = "totp-rejected";
|
|
44
|
+
//#endregion
|
|
2
45
|
//#region src/mcp/chii-relay.ts
|
|
3
46
|
/**
|
|
4
47
|
* Boots the local Chii relay server.
|
|
@@ -12,12 +55,27 @@ let node_http = require("node:http");
|
|
|
12
55
|
* entries.
|
|
13
56
|
*
|
|
14
57
|
* TOTP auth (relay-side, authoritative gate):
|
|
15
|
-
* When `verifyAuth` is provided, this module
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
58
|
+
* When `verifyAuth` is provided, this module gates both inbound surfaces:
|
|
59
|
+
*
|
|
60
|
+
* - HTTP 'request': a listener registered BEFORE `chii.start({server})`.
|
|
61
|
+
* Node's `http.Server` calls listeners in registration order; the first
|
|
62
|
+
* to call `res.end()` wins. Invalid auth → 401 + CORS header + a tiny
|
|
63
|
+
* JSON body (`{"error":"totp-rejected"}`) so a cross-origin script
|
|
64
|
+
* `fetch()` probe can READ the status (issue #478). Valid auth → return
|
|
65
|
+
* without side-effect (chii's Koa handler serves it).
|
|
66
|
+
*
|
|
67
|
+
* - WS 'upgrade': after `chii.start()` has registered chii's own upgrade
|
|
68
|
+
* listener, we take over the upgrade chain (remove chii's listeners,
|
|
69
|
+
* re-dispatch manually). Invalid auth → accept-then-close: complete the
|
|
70
|
+
* handshake via a `noServer` WebSocketServer, then immediately close
|
|
71
|
+
* with code 4401 reason 'totp-rejected' (issue #478). A raw 401 +
|
|
72
|
+
* `socket.destroy()` only ever surfaced as close code 1006 in the
|
|
73
|
+
* browser — indistinguishable from a tunnel failure, which left the
|
|
74
|
+
* env-2 phone UI silent. The explicit dispatch (not listener ordering)
|
|
75
|
+
* is what keeps chii away from rejected sockets: accept-then-close
|
|
76
|
+
* leaves the socket alive, so an order-based early-return would let
|
|
77
|
+
* chii's later listener complete a SECOND handshake on the same socket
|
|
78
|
+
* — an auth bypass. Valid auth → forward to chii's captured listeners.
|
|
21
79
|
*
|
|
22
80
|
* TOTP code transports (issue #466) — two equivalent ways to carry the code:
|
|
23
81
|
* 1. Query param `at=<code>` — used by the daemon-side `/client` connection
|
|
@@ -43,7 +101,7 @@ let node_http = require("node:http");
|
|
|
43
101
|
* in any log, error message, or process output. `verifyAuth` is a black-box
|
|
44
102
|
* predicate from the caller's perspective; this module only forwards pass/fail.
|
|
45
103
|
*/
|
|
46
|
-
const require$1 = (0,
|
|
104
|
+
const require$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
|
|
47
105
|
function loadChiiServer() {
|
|
48
106
|
const mod = require$1("chii");
|
|
49
107
|
if (typeof mod === "object" && mod !== null && "start" in mod && typeof mod.start === "function") return mod;
|
|
@@ -103,33 +161,40 @@ async function startChiiRelay(options = {}) {
|
|
|
103
161
|
onAuthReject({ kind });
|
|
104
162
|
} catch {}
|
|
105
163
|
};
|
|
164
|
+
if (verifyAuth) httpServer.on("request", (req, res) => {
|
|
165
|
+
const rewritten = rewriteAtPathPrefix(req.url ?? "");
|
|
166
|
+
if (rewritten === null) return;
|
|
167
|
+
req.url = rewritten;
|
|
168
|
+
if (!verifyAuth(req)) {
|
|
169
|
+
res.statusCode = 401;
|
|
170
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
171
|
+
res.setHeader("Content-Type", "application/json");
|
|
172
|
+
res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));
|
|
173
|
+
notifyAuthReject("http-request");
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
await loadChiiServer().start({
|
|
177
|
+
server: httpServer,
|
|
178
|
+
domain: `${host}:${requestedPort}`,
|
|
179
|
+
port: requestedPort
|
|
180
|
+
});
|
|
106
181
|
if (verifyAuth) {
|
|
107
|
-
httpServer.
|
|
182
|
+
const chiiUpgradeListeners = httpServer.listeners("upgrade");
|
|
183
|
+
httpServer.removeAllListeners("upgrade");
|
|
184
|
+
const rejectWss = new ws.WebSocketServer({ noServer: true });
|
|
185
|
+
httpServer.on("upgrade", (req, socket, head) => {
|
|
108
186
|
const rewritten = rewriteAtPathPrefix(req.url ?? "");
|
|
109
187
|
if (rewritten !== null) req.url = rewritten;
|
|
110
188
|
if (!verifyAuth(req)) {
|
|
111
|
-
|
|
112
|
-
|
|
189
|
+
rejectWss.handleUpgrade(req, socket, head, (ws$1) => {
|
|
190
|
+
ws$1.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);
|
|
191
|
+
});
|
|
113
192
|
notifyAuthReject("ws-upgrade");
|
|
114
193
|
return;
|
|
115
194
|
}
|
|
116
|
-
|
|
117
|
-
httpServer.on("request", (req, res) => {
|
|
118
|
-
const rewritten = rewriteAtPathPrefix(req.url ?? "");
|
|
119
|
-
if (rewritten === null) return;
|
|
120
|
-
req.url = rewritten;
|
|
121
|
-
if (!verifyAuth(req)) {
|
|
122
|
-
res.statusCode = 401;
|
|
123
|
-
res.end();
|
|
124
|
-
notifyAuthReject("http-request");
|
|
125
|
-
}
|
|
195
|
+
for (const listener of chiiUpgradeListeners) listener(req, socket, head);
|
|
126
196
|
});
|
|
127
197
|
}
|
|
128
|
-
await loadChiiServer().start({
|
|
129
|
-
server: httpServer,
|
|
130
|
-
domain: `${host}:${requestedPort}`,
|
|
131
|
-
port: requestedPort
|
|
132
|
-
});
|
|
133
198
|
const actualPort = await new Promise((resolve, reject) => {
|
|
134
199
|
httpServer.once("error", reject);
|
|
135
200
|
httpServer.listen(requestedPort, host, () => {
|
|
@@ -148,4 +213,4 @@ async function startChiiRelay(options = {}) {
|
|
|
148
213
|
//#endregion
|
|
149
214
|
exports.startChiiRelay = startChiiRelay;
|
|
150
215
|
|
|
151
|
-
//# sourceMappingURL=chii-relay-
|
|
216
|
+
//# sourceMappingURL=chii-relay-D5Hc0G39.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chii-relay-D5Hc0G39.cjs","names":["require","WebSocketServer"],"sources":["../src/shared/relay-auth-close.ts","../src/mcp/chii-relay.ts"],"sourcesContent":["/**\n * Shared constants for the relay's named TOTP-auth rejection (issue #478).\n *\n * Before #478 the relay rejected an unauthenticated WebSocket upgrade with a\n * raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is\n * indistinguishable from a network failure on the browser side — the\n * WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)\n * could not tell \"stale TOTP code\" apart from \"tunnel down\" and stayed\n * silent. The fix is accept-then-close: complete the handshake, then close\n * with an application close code that NAMES the rejection.\n *\n * Three parties share this contract:\n * - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;\n * - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and\n * surfaces the code to the launcher shell;\n * - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code\n * as an auth failure on its own `/client` dial (defensive — #439's fresh\n * code mint means it should not normally hit this).\n *\n * This module is intentionally dependency-free (no Node, no DOM) so it is\n * safe to import from both the browser in-app bundle and the MCP daemon\n * bundle.\n *\n * SECRET-HANDLING: these are fixed enum values. The close reason / error body\n * must never grow to carry a secret, a TOTP code, or a host.\n */\n\n/**\n * WebSocket close code sent by the relay when TOTP auth is rejected.\n *\n * 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors\n * HTTP 401 so it reads as \"unauthorized\" at a glance.\n */\nexport const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;\n\n/**\n * Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and\n * the `error` value of the relay's HTTP 401 JSON body. Enum string only —\n * never interpolated with request data.\n */\nexport const RELAY_AUTH_REJECT_REASON = 'totp-rejected';\n","/**\n * Boots the local Chii relay server.\n *\n * Chii (liriliri/chii) is a chobitsu-based CDP relay that lets non-Chrome\n * WebViews (iOS WKWebView / Android WebView — i.e. the Toss app) expose CDP.\n * The relay accepts a `target` websocket from the phone's injected `target.js`\n * and `client` websockets from CDP frontends (our MCP connection).\n *\n * Node-only: `chii` pulls in Koa + ws. Never bundled into the browser/in-app\n * entries.\n *\n * TOTP auth (relay-side, authoritative gate):\n * When `verifyAuth` is provided, this module gates both inbound surfaces:\n *\n * - HTTP 'request': a listener registered BEFORE `chii.start({server})`.\n * Node's `http.Server` calls listeners in registration order; the first\n * to call `res.end()` wins. Invalid auth → 401 + CORS header + a tiny\n * JSON body (`{\"error\":\"totp-rejected\"}`) so a cross-origin script\n * `fetch()` probe can READ the status (issue #478). Valid auth → return\n * without side-effect (chii's Koa handler serves it).\n *\n * - WS 'upgrade': after `chii.start()` has registered chii's own upgrade\n * listener, we take over the upgrade chain (remove chii's listeners,\n * re-dispatch manually). Invalid auth → accept-then-close: complete the\n * handshake via a `noServer` WebSocketServer, then immediately close\n * with code 4401 reason 'totp-rejected' (issue #478). A raw 401 +\n * `socket.destroy()` only ever surfaced as close code 1006 in the\n * browser — indistinguishable from a tunnel failure, which left the\n * env-2 phone UI silent. The explicit dispatch (not listener ordering)\n * is what keeps chii away from rejected sockets: accept-then-close\n * leaves the socket alive, so an order-based early-return would let\n * chii's later listener complete a SECOND handshake on the same socket\n * — an auth bypass. Valid auth → forward to chii's captured listeners.\n *\n * TOTP code transports (issue #466) — two equivalent ways to carry the code:\n * 1. Query param `at=<code>` — used by the daemon-side `/client` connection\n * (`chii-connection.ts` appends it; it holds the secret).\n * 2. Path prefix `/at/<code>/…` — used by the phone-side target. Chii's\n * stock `target.js` derives its WS endpoint from the script `src`\n * (`scriptEl.src.replace('target.js','')`), so the only way for the\n * phone to carry a code is to embed it in the script URL path. The\n * in-app attach injects `https://<host>/at/<code>/target.js`; both the\n * script fetch and the derived `wss://<host>/at/<code>/target/<id>` WS\n * dial then carry the prefix. The listeners below rewrite the prefix\n * into the query form (`rewriteAtPathPrefix`) and MUTATE `req.url`\n * before chii's own handlers (registered later) parse it — chii only\n * ever sees the stripped URL.\n *\n * Threat model: \"URL leak\" — someone obtains the tunnel URL (Slack paste, QR\n * screenshot, shoulder-surfing) but does not have the shared TOTP secret.\n * Rotating 6-digit code makes the URL stale after 30 s.\n * A determined attacker who extracts the secret from the dogfood bundle can\n * still compute valid codes; that is out of scope (see umbrella CLAUDE.md §4).\n *\n * SECRET-HANDLING: The secret value and computed TOTP codes MUST NOT appear\n * in any log, error message, or process output. `verifyAuth` is a black-box\n * predicate from the caller's perspective; this module only forwards pass/fail.\n */\n\nimport { createServer, type IncomingMessage, type Server } from 'node:http';\nimport { createRequire } from 'node:module';\nimport type { AddressInfo } from 'node:net';\nimport type { Duplex } from 'node:stream';\n// `ws` is a direct dependency of this package (NOT a transitive reach into\n// chii's tree — same principle as the ajv incident): the reject path below\n// needs `WebSocketServer.handleUpgrade` to complete a handshake we are about\n// to close with a named code.\nimport { WebSocketServer } from 'ws';\nimport {\n RELAY_AUTH_REJECT_CLOSE_CODE,\n RELAY_AUTH_REJECT_REASON,\n} from '../shared/relay-auth-close.js';\n\nconst require = createRequire(import.meta.url);\n\n/** `chii/server` is CommonJS and shipped without TypeScript types. */\ninterface ChiiServerModule {\n start(options: {\n port?: number;\n host?: string;\n domain?: string;\n server?: Server;\n basePath?: string;\n }): Promise<void>;\n}\n\nfunction loadChiiServer(): ChiiServerModule {\n // `chii`'s package `main` is `./server/index.js`, exposing `{ start }`.\n const mod: unknown = require('chii');\n if (\n typeof mod === 'object' &&\n mod !== null &&\n 'start' in mod &&\n typeof (mod as { start: unknown }).start === 'function'\n ) {\n return mod as ChiiServerModule;\n }\n throw new Error('chii server module did not expose start()');\n}\n\nexport interface ChiiRelay {\n port: number;\n /** Base URL for the relay HTTP/WS server, e.g. `http://127.0.0.1:54321`. */\n baseUrl: string;\n close(): Promise<void>;\n}\n\n/**\n * Secret-free metadata about a single auth rejection (issue #467).\n *\n * SECRET-HANDLING: this event carries ONLY the surface kind. It must never\n * grow fields for `req.url`, query strings, codes, or secrets — observers\n * (diagnostics counters, console hints) only need \"a rejection happened\".\n */\nexport interface RelayAuthRejectEvent {\n /** Which inbound surface was rejected. */\n kind: 'ws-upgrade' | 'http-request';\n}\n\n/**\n * Rewrites a `/at/<code>/…` path-prefixed request URL into the equivalent\n * query-based form, e.g.:\n *\n * `/at/123456/target.js` → `/target.js?at=123456`\n * `/at/123456/target/x?url=u` → `/target/x?url=u&at=123456`\n * `/at/123456/` → `/?at=123456`\n *\n * Returns `null` when the URL does not carry the prefix (including an empty\n * code segment) — callers fall back to the unmodified URL and the existing\n * query-based auth path.\n *\n * Pure string surgery — this function knows nothing about secrets or code\n * validity; verification stays inside the caller-provided `verifyAuth`\n * predicate (which parses the query). The raw path segment is appended\n * verbatim to the query: both path segments and query values are\n * percent-decoded exactly once by their consumers, so no re-encoding is\n * needed (TOTP codes are 6 digits and never percent-encoded in practice).\n */\nexport function rewriteAtPathPrefix(rawUrl: string): string | null {\n const match = /^\\/at\\/([^/?]+)(\\/[^?]*)?(\\?.*)?$/.exec(rawUrl);\n if (match === null) return null;\n const code = match[1];\n const path = match[2] === undefined || match[2] === '' ? '/' : match[2];\n const query = match[3] ?? '';\n const separator = query === '' ? '?' : '&';\n return `${path}${query}${separator}at=${code}`;\n}\n\nexport interface StartChiiRelayOptions {\n /**\n * Local port for the relay. Default 0 (OS-assigned ephemeral port).\n *\n * Using 0 means the OS picks a free port — this is the safe default because\n * a stale cloudflared child process (PPID 1, orphaned after SIGKILL) may still\n * be holding a fixed port. A fixed port causes EADDRINUSE on the next startup,\n * which makes the MCP handshake fail with -32000. With port 0 the new relay\n * always gets a fresh port, making any orphaned process harmless.\n *\n * Pass an explicit number to restore fixed-port behaviour (backwards-compatible).\n */\n port?: number;\n /** Bind host. Default 127.0.0.1 (tunnel reaches it locally). */\n host?: string;\n /**\n * Optional auth predicate for WebSocket upgrade requests.\n *\n * When provided, every inbound WebSocket upgrade is checked by calling\n * `verifyAuth(req)` before Chii processes it. Return `true` to allow the\n * upgrade; return `false` to reject with HTTP 401 and destroy the socket.\n *\n * The predicate MUST NOT log the secret or any TOTP code — it is a black-box\n * from this module's perspective.\n *\n * @param req - The raw HTTP `IncomingMessage` from the upgrade handshake.\n * Inspect `req.url` for query parameters (e.g. `at=<code>`). Path-prefixed\n * URLs (`/at/<code>/…`, the phone-target transport — issue #466) are\n * rewritten into the query form BEFORE this predicate runs, so a\n * query-only predicate covers both transports.\n * @returns `true` if the upgrade is authorised, `false` to reject.\n */\n verifyAuth?: (req: IncomingMessage) => boolean;\n /**\n * Secret-free observability callback fired on every auth rejection\n * (issue #467). Only meaningful together with `verifyAuth`.\n *\n * SECRET-HANDLING: the event carries ONLY the rejection kind — never\n * `req.url`, query strings, TOTP codes, or the secret. Implementations must\n * keep it that way (e.g. increment a counter + timestamp). Exceptions thrown\n * by the callback are swallowed so observability can never break the gate.\n */\n onAuthReject?: (event: RelayAuthRejectEvent) => void;\n}\n\n/**\n * Starts the Chii relay and resolves once listening.\n *\n * Default port is 0 (OS-assigned). With port 0 the OS picks a free ephemeral\n * port on every start, so a stale cloudflared orphan holding any particular\n * port cannot cause EADDRINUSE. The resolved `ChiiRelay.port` and `baseUrl`\n * always reflect the actual bound port.\n *\n * chii.start() is called with `server` (our pre-created httpServer) BEFORE\n * httpServer.listen(). This is intentional: chii attaches its Koa handler and\n * WS upgrade listener to the server object, but the actual TCP bind is\n * performed by our httpServer.listen() call below. The `port`/`domain` values\n * passed to chii.start() are used for display/banner purposes inside chii and\n * do not affect which port the server binds. The connection path (clients\n * connecting to `relay.baseUrl`) always uses the post-listen confirmed port.\n */\nexport async function startChiiRelay(options: StartChiiRelayOptions = {}): Promise<ChiiRelay> {\n const requestedPort = options.port ?? 0;\n const host = options.host ?? '127.0.0.1';\n const { verifyAuth, onAuthReject } = options;\n\n const httpServer = createServer();\n\n // Secret-free observability hook (issue #467). Swallow callback exceptions —\n // a broken observer must never turn into an open gate or a crashed relay.\n const notifyAuthReject = (kind: RelayAuthRejectEvent['kind']): void => {\n if (onAuthReject === undefined) return;\n try {\n onAuthReject({ kind });\n } catch {\n // Ignore — observability is best-effort.\n }\n };\n\n // Register the HTTP-request auth listener BEFORE chii.start() so it fires\n // first. Node's http.Server emits 'request' to all listeners in registration\n // order; the first to end() the response wins. Valid requests return without\n // side-effect so chii's own handler takes over normally — and because\n // listeners run synchronously in order, mutating `req.url` here (path-prefix\n // strip, issue #466) means chii's later-registered handler only ever sees\n // the stripped URL.\n //\n // We only register when verifyAuth is provided so the no-auth path is\n // zero-overhead for tests and local-only dev sessions. (The phone-side\n // `/at/<code>/` prefix only ever appears when TOTP is armed — the launcher\n // QR carries the `at` code — so the no-auth path never needs the strip.)\n if (verifyAuth) {\n // Plain HTTP requests: only the path-prefixed form is ours — the phone\n // fetches `target.js` via `https://<host>/at/<code>/target.js` (issue\n // #466), which must be verified + stripped so chii's Koa static handler\n // serves `/target.js`. Non-prefixed requests keep today's behaviour\n // (ungated pass-through to chii).\n httpServer.on('request', (req, res) => {\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten === null) return;\n req.url = rewritten;\n if (!verifyAuth(req)) {\n // We do NOT log req.url or any auth param here to avoid leaking codes.\n // CORS header + tiny JSON body (issue #478): the script URL is\n // cross-origin from the phone page (tunnel origin ≠ relay origin), so\n // without ACAO a fetch() probe sees an opaque error and cannot tell\n // auth rejection from a network failure. The header rides ONLY on\n // this error response — no relay asset is exposed through it.\n res.statusCode = 401;\n res.setHeader('Access-Control-Allow-Origin', '*');\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify({ error: RELAY_AUTH_REJECT_REASON }));\n notifyAuthReject('http-request');\n }\n // Auth passed: no-op — chii's Koa 'request' listener (registered below\n // by chii.start) serves the rewritten URL. (Koa skips writing when an\n // earlier listener already ended the response, so the 401 path is safe\n // even though Koa still runs.)\n });\n }\n\n const chii = loadChiiServer();\n // Passing an existing `server` makes chii attach its Koa handler + WS upgrade\n // to our HTTP server rather than creating its own listener.\n // Note: port/domain here are display-only inside chii — the TCP bind is ours.\n await chii.start({ server: httpServer, domain: `${host}:${requestedPort}`, port: requestedPort });\n\n // WS upgrade gate (issue #478, accept-then-close): take over the upgrade\n // chain AFTER chii.start() has registered chii's own upgrade listener.\n // Listener ordering alone protected chii when rejection meant\n // socket.destroy(); accept-then-close keeps the socket ALIVE, so chii's\n // listener (which always runs on every 'upgrade' emit) would complete a\n // second handshake on the rejected socket — frames after our close frame\n // would reach chii's server-side WebSocket, i.e. an auth bypass. Capturing\n // chii's listeners and re-dispatching only on auth pass closes that hole.\n if (verifyAuth) {\n const chiiUpgradeListeners = httpServer.listeners('upgrade') as Array<\n (req: IncomingMessage, socket: Duplex, head: Buffer) => void\n >;\n httpServer.removeAllListeners('upgrade');\n // noServer: handshake-only — never binds a port; used purely to send a\n // spec-compliant close frame with a code the browser can read.\n const rejectWss = new WebSocketServer({ noServer: true });\n httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {\n // Phone-target transport (issue #466): normalise a `/at/<code>/…` path\n // prefix into the query form before verification, and strip it from the\n // URL chii will see. No-prefix URLs pass through untouched (daemon\n // client query transport — back-compat).\n const rewritten = rewriteAtPathPrefix(req.url ?? '');\n if (rewritten !== null) {\n req.url = rewritten;\n }\n if (!verifyAuth(req)) {\n // Reject: complete the handshake, then close with a NAMED code so the\n // browser-side observer (in-app attach.ts) can distinguish \"stale\n // TOTP code\" (4401) from \"tunnel down\" (1006). Raw-401-destroy only\n // ever produced 1006 client-side — the env-2 silence gap (#478).\n // We do NOT log req.url or any auth param here to avoid leaking codes;\n // the close reason is a fixed enum string.\n rejectWss.handleUpgrade(req, socket, head, (ws) => {\n ws.close(RELAY_AUTH_REJECT_CLOSE_CODE, RELAY_AUTH_REJECT_REASON);\n });\n notifyAuthReject('ws-upgrade');\n // Early return — chii's captured listeners are NOT called.\n return;\n }\n // Auth passed: hand the upgrade to chii's own listeners (it sees the\n // stripped URL — same observable behaviour as the pre-#478 ordering).\n for (const listener of chiiUpgradeListeners) {\n listener(req, socket, head);\n }\n });\n }\n\n const actualPort = await new Promise<number>((resolve, reject) => {\n httpServer.once('error', reject);\n httpServer.listen(requestedPort, host, () => {\n httpServer.off('error', reject);\n // httpServer.address() is non-null immediately after the listen callback.\n const addr = httpServer.address() as AddressInfo;\n resolve(addr.port);\n });\n });\n\n return {\n port: actualPort,\n baseUrl: `http://${host}:${actualPort}`,\n close: () =>\n new Promise<void>((resolve) => {\n httpServer.close(() => resolve());\n }),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,MAAa,+BAA+B;;;;;;AAO5C,MAAa,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACiCxC,MAAMA,aAAAA,GAAAA,YAAAA,eAAAA,QAAAA,MAAAA,CAAAA,cAAAA,WAAAA,CAAAA,KAAwC;AAa9C,SAAS,iBAAmC;CAE1C,MAAM,MAAeA,UAAQ,OAAO;AACpC,KACE,OAAO,QAAQ,YACf,QAAQ,QACR,WAAW,OACX,OAAQ,IAA2B,UAAU,WAE7C,QAAO;AAET,OAAM,IAAI,MAAM,4CAA4C;;;;;;;;;;;;;;;;;;;;;AAyC9D,SAAgB,oBAAoB,QAA+B;CACjE,MAAM,QAAQ,oCAAoC,KAAK,OAAO;AAC9D,KAAI,UAAU,KAAM,QAAO;CAC3B,MAAM,OAAO,MAAM;CACnB,MAAM,OAAO,MAAM,OAAO,KAAA,KAAa,MAAM,OAAO,KAAK,MAAM,MAAM;CACrE,MAAM,QAAQ,MAAM,MAAM;AAE1B,QAAO,GAAG,OAAO,QADC,UAAU,KAAK,MAAM,IACJ,KAAK;;;;;;;;;;;;;;;;;;AAgE1C,eAAsB,eAAe,UAAiC,EAAE,EAAsB;CAC5F,MAAM,gBAAgB,QAAQ,QAAQ;CACtC,MAAM,OAAO,QAAQ,QAAQ;CAC7B,MAAM,EAAE,YAAY,iBAAiB;CAErC,MAAM,cAAA,GAAA,UAAA,eAA2B;CAIjC,MAAM,oBAAoB,SAA6C;AACrE,MAAI,iBAAiB,KAAA,EAAW;AAChC,MAAI;AACF,gBAAa,EAAE,MAAM,CAAC;UAChB;;AAiBV,KAAI,WAMF,YAAW,GAAG,YAAY,KAAK,QAAQ;EACrC,MAAM,YAAY,oBAAoB,IAAI,OAAO,GAAG;AACpD,MAAI,cAAc,KAAM;AACxB,MAAI,MAAM;AACV,MAAI,CAAC,WAAW,IAAI,EAAE;AAOpB,OAAI,aAAa;AACjB,OAAI,UAAU,+BAA+B,IAAI;AACjD,OAAI,UAAU,gBAAgB,mBAAmB;AACjD,OAAI,IAAI,KAAK,UAAU,EAAE,OAAO,0BAA0B,CAAC,CAAC;AAC5D,oBAAiB,eAAe;;GAMlC;AAOJ,OAJa,gBAAgB,CAIlB,MAAM;EAAE,QAAQ;EAAY,QAAQ,GAAG,KAAK,GAAG;EAAiB,MAAM;EAAe,CAAC;AAUjG,KAAI,YAAY;EACd,MAAM,uBAAuB,WAAW,UAAU,UAAU;AAG5D,aAAW,mBAAmB,UAAU;EAGxC,MAAM,YAAY,IAAIC,GAAAA,gBAAgB,EAAE,UAAU,MAAM,CAAC;AACzD,aAAW,GAAG,YAAY,KAAsB,QAAgB,SAAiB;GAK/E,MAAM,YAAY,oBAAoB,IAAI,OAAO,GAAG;AACpD,OAAI,cAAc,KAChB,KAAI,MAAM;AAEZ,OAAI,CAAC,WAAW,IAAI,EAAE;AAOpB,cAAU,cAAc,KAAK,QAAQ,OAAO,SAAO;AACjD,UAAG,MAAM,8BAA8B,yBAAyB;MAChE;AACF,qBAAiB,aAAa;AAE9B;;AAIF,QAAK,MAAM,YAAY,qBACrB,UAAS,KAAK,QAAQ,KAAK;IAE7B;;CAGJ,MAAM,aAAa,MAAM,IAAI,SAAiB,SAAS,WAAW;AAChE,aAAW,KAAK,SAAS,OAAO;AAChC,aAAW,OAAO,eAAe,YAAY;AAC3C,cAAW,IAAI,SAAS,OAAO;AAG/B,WADa,WAAW,SAAS,CACpB,KAAK;IAClB;GACF;AAEF,QAAO;EACL,MAAM;EACN,SAAS,UAAU,KAAK,GAAG;EAC3B,aACE,IAAI,SAAe,YAAY;AAC7B,cAAW,YAAY,SAAS,CAAC;IACjC;EACL"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","names":[],"sources":["../../src/in-app/gate.ts","../../src/in-app/attach.ts","../../src/in-app/index.ts"],"mappings":";;AA4EA;;;;;;;;;AASA;;;;;AAmBA;;;;;AAQA;;;;;;;;;;;AA8EA;;;;;AAuBA;;;;;AAyBA;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../../src/in-app/gate.ts","../../src/in-app/attach.ts","../../src/in-app/index.ts"],"mappings":";;AA4EA;;;;;;;;;AASA;;;;;AAmBA;;;;;AAQA;;;;;;;;;;;AA8EA;;;;;AAuBA;;;;;AAyBA;;;;;;;;;;;;AC1LA;;;;;AAuMA;;;;;;;;AC/MA;;;;;;;;;UFgCiB,gBAAA;EAAA,SACN,MAAA;;WAEA,QAAA;;WAEA,YAAA;AAAA;;UAIM,iBAAA;EAAA,SACN,MAAA;;;;;;;;;;;;;;;WAeA,MAAA;AAAA;AAAA,KAGC,UAAA,GAAa,gBAAA,GAAmB,iBAAA;;;;;;;UAQ3B,SAAA;;;;;;;;;;;;;WAaN,QAAA;;;;;;;WAQA,YAAA,EAAc,eAAA;;;;;;;;;;;;;;;;;;;;;;;;;WA0Bd,cAAA,IAAkB,IAAA;AAAA;;;;;;;;;;;;iBA+Bb,iBAAA,CAAkB,QAAA;;;;;;;;;;;;;;;;;;;;iBAuBlB,mBAAA,CAAoB,QAAA;;;;;;;;;;;;;;;;;;;;;;iBAyBpB,iBAAA,CAAkB,KAAA,EAAO,SAAA,GAAY,UAAA;;;;;;AAtIrD;;;;;AAQA;;;;;;;;;;;AA8EA;;;;;AAuBA;;;;;AAyBA;;;;iBC1LgB,qBAAA,CAAsB,QAAA,UAAkB,MAAA;;;;;ACRxD;;;;;;;;;;;;;;;;;;;iBD+MgB,WAAA,CAAY,UAAA,GAAY,UAAA;;;;;;;AD7DxC;;;;;AAuBA;;;;;iBEzKgB,cAAA,CAAA,GAAkB,UAAA"}
|
package/dist/in-app/index.js
CHANGED
|
@@ -122,6 +122,47 @@ function evaluateDebugGate(input) {
|
|
|
122
122
|
};
|
|
123
123
|
}
|
|
124
124
|
//#endregion
|
|
125
|
+
//#region src/shared/relay-auth-close.ts
|
|
126
|
+
/**
|
|
127
|
+
* Shared constants for the relay's named TOTP-auth rejection (issue #478).
|
|
128
|
+
*
|
|
129
|
+
* Before #478 the relay rejected an unauthenticated WebSocket upgrade with a
|
|
130
|
+
* raw `HTTP/1.1 401` + `socket.destroy()`. A handshake aborted that way is
|
|
131
|
+
* indistinguishable from a network failure on the browser side — the
|
|
132
|
+
* WebSocket only ever sees close code 1006, so the phone (env-2 launcher PWA)
|
|
133
|
+
* could not tell "stale TOTP code" apart from "tunnel down" and stayed
|
|
134
|
+
* silent. The fix is accept-then-close: complete the handshake, then close
|
|
135
|
+
* with an application close code that NAMES the rejection.
|
|
136
|
+
*
|
|
137
|
+
* Three parties share this contract:
|
|
138
|
+
* - `src/mcp/chii-relay.ts` (Node) sends the close frame / HTTP error body;
|
|
139
|
+
* - `src/in-app/attach.ts` (browser) observes relay-bound WebSockets and
|
|
140
|
+
* surfaces the code to the launcher shell;
|
|
141
|
+
* - `src/mcp/chii-connection.ts` (Node daemon client) recognises the code
|
|
142
|
+
* as an auth failure on its own `/client` dial (defensive — #439's fresh
|
|
143
|
+
* code mint means it should not normally hit this).
|
|
144
|
+
*
|
|
145
|
+
* This module is intentionally dependency-free (no Node, no DOM) so it is
|
|
146
|
+
* safe to import from both the browser in-app bundle and the MCP daemon
|
|
147
|
+
* bundle.
|
|
148
|
+
*
|
|
149
|
+
* SECRET-HANDLING: these are fixed enum values. The close reason / error body
|
|
150
|
+
* must never grow to carry a secret, a TOTP code, or a host.
|
|
151
|
+
*/
|
|
152
|
+
/**
|
|
153
|
+
* WebSocket close code sent by the relay when TOTP auth is rejected.
|
|
154
|
+
*
|
|
155
|
+
* 4000–4999 is the application-reserved range (RFC 6455 §7.4.2); 4401 mirrors
|
|
156
|
+
* HTTP 401 so it reads as "unauthorized" at a glance.
|
|
157
|
+
*/
|
|
158
|
+
const RELAY_AUTH_REJECT_CLOSE_CODE = 4401;
|
|
159
|
+
/**
|
|
160
|
+
* Close reason string accompanying {@link RELAY_AUTH_REJECT_CLOSE_CODE}, and
|
|
161
|
+
* the `error` value of the relay's HTTP 401 JSON body. Enum string only —
|
|
162
|
+
* never interpolated with request data.
|
|
163
|
+
*/
|
|
164
|
+
const RELAY_AUTH_REJECT_REASON = "totp-rejected";
|
|
165
|
+
//#endregion
|
|
125
166
|
//#region src/in-app/attach.ts
|
|
126
167
|
/**
|
|
127
168
|
* In-app Chii target injection for the debug attach flow.
|
|
@@ -177,6 +218,138 @@ function deriveTargetScriptUrl(relayUrl, atCode) {
|
|
|
177
218
|
}
|
|
178
219
|
/** Module-level guard against double-injection within a page lifecycle. */
|
|
179
220
|
let attached = false;
|
|
221
|
+
/** One-shot guard for the parent notification (both observer + onerror probe). */
|
|
222
|
+
let authExpiredNotified = false;
|
|
223
|
+
/** Set once a relay-bound socket closed with 4401 — flips dials to fail-fast. */
|
|
224
|
+
let relayAuthExpired = false;
|
|
225
|
+
/** Guard against stacking multiple observer wrappers on window.WebSocket. */
|
|
226
|
+
let wsObserverInstalled = false;
|
|
227
|
+
/**
|
|
228
|
+
* Posts the `auth-expired` block signal to the parent launcher shell, once.
|
|
229
|
+
*
|
|
230
|
+
* Mirrors the existing `reason: 'auth'` postMessage in {@link maybeAttach}.
|
|
231
|
+
* SECRET-HANDLING: the payload carries ONLY the reason enum — never the code,
|
|
232
|
+
* secret, host, or relay URL.
|
|
233
|
+
*/
|
|
234
|
+
function notifyAuthExpired() {
|
|
235
|
+
if (authExpiredNotified) return;
|
|
236
|
+
if (typeof window === "undefined" || window.parent === window) return;
|
|
237
|
+
authExpiredNotified = true;
|
|
238
|
+
window.parent.postMessage({
|
|
239
|
+
type: "ait:debug-attach-blocked",
|
|
240
|
+
reason: "auth-expired"
|
|
241
|
+
}, "*");
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Normalises a URL into a comparable origin key, mapping the HTTP scheme pair
|
|
245
|
+
* onto the WS pair (`https:`→`wss:`, `http:`→`ws:`) so the `wss:` relay URL
|
|
246
|
+
* from the gate result matches the dials target.js derives from its
|
|
247
|
+
* `https://…/target.js` script src. Returns `null` for unparsable URLs.
|
|
248
|
+
*/
|
|
249
|
+
function wsOriginKey(rawUrl) {
|
|
250
|
+
let parsed;
|
|
251
|
+
try {
|
|
252
|
+
parsed = new URL(rawUrl);
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
return `${parsed.protocol === "https:" ? "wss:" : parsed.protocol === "http:" ? "ws:" : parsed.protocol}//${parsed.host}`;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Builds a dummy WebSocket that never connects and closes immediately
|
|
260
|
+
* (asynchronously, with the 4401 code) — returned for relay-bound dials after
|
|
261
|
+
* auth expiry so chii's internal reconnect loop stops producing real network
|
|
262
|
+
* traffic. We cannot stop the loop itself (it lives inside stock target.js);
|
|
263
|
+
* we can only make each iteration free.
|
|
264
|
+
*
|
|
265
|
+
* Both `onclose`-style property handlers and `addEventListener` listeners are
|
|
266
|
+
* fired — stock target.js uses property handlers, but we cannot know every
|
|
267
|
+
* consumer. (A consumer wiring BOTH would see a double callback; acceptable
|
|
268
|
+
* for a retry scheduler and irrelevant for chii.)
|
|
269
|
+
*/
|
|
270
|
+
function createFailFastSocket(url) {
|
|
271
|
+
const eventTarget = new EventTarget();
|
|
272
|
+
const sock = {
|
|
273
|
+
url,
|
|
274
|
+
readyState: 3,
|
|
275
|
+
bufferedAmount: 0,
|
|
276
|
+
extensions: "",
|
|
277
|
+
protocol: "",
|
|
278
|
+
binaryType: "blob",
|
|
279
|
+
onopen: null,
|
|
280
|
+
onmessage: null,
|
|
281
|
+
onerror: null,
|
|
282
|
+
onclose: null,
|
|
283
|
+
close() {},
|
|
284
|
+
send() {},
|
|
285
|
+
addEventListener: eventTarget.addEventListener.bind(eventTarget),
|
|
286
|
+
removeEventListener: eventTarget.removeEventListener.bind(eventTarget),
|
|
287
|
+
dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget),
|
|
288
|
+
CONNECTING: 0,
|
|
289
|
+
OPEN: 1,
|
|
290
|
+
CLOSING: 2,
|
|
291
|
+
CLOSED: 3
|
|
292
|
+
};
|
|
293
|
+
setTimeout(() => {
|
|
294
|
+
const errorEvent = new Event("error");
|
|
295
|
+
sock.onerror?.(errorEvent);
|
|
296
|
+
eventTarget.dispatchEvent(errorEvent);
|
|
297
|
+
let closeEvent;
|
|
298
|
+
try {
|
|
299
|
+
closeEvent = new CloseEvent("close", {
|
|
300
|
+
code: RELAY_AUTH_REJECT_CLOSE_CODE,
|
|
301
|
+
reason: RELAY_AUTH_REJECT_REASON,
|
|
302
|
+
wasClean: false
|
|
303
|
+
});
|
|
304
|
+
} catch {
|
|
305
|
+
closeEvent = Object.assign(new Event("close"), {
|
|
306
|
+
code: RELAY_AUTH_REJECT_CLOSE_CODE,
|
|
307
|
+
reason: RELAY_AUTH_REJECT_REASON,
|
|
308
|
+
wasClean: false
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
sock.onclose?.(closeEvent);
|
|
312
|
+
eventTarget.dispatchEvent(closeEvent);
|
|
313
|
+
}, 0);
|
|
314
|
+
return sock;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Wraps `window.WebSocket` with a relay-origin-scoped observer (issue #478).
|
|
318
|
+
*
|
|
319
|
+
* - Connections whose URL origin does NOT match the relay origin pass through
|
|
320
|
+
* to the native constructor untouched — app traffic is never observed.
|
|
321
|
+
* - Relay-origin connections get a `close` listener: code 4401 (the relay's
|
|
322
|
+
* named TOTP rejection) flips the module into the expired state and posts
|
|
323
|
+
* `reason: 'auth-expired'` to the parent launcher shell (once).
|
|
324
|
+
* - After 4401, further relay-origin dials return a fail-fast dummy socket so
|
|
325
|
+
* target.js's autonomous reconnect loop stops hitting the network.
|
|
326
|
+
*
|
|
327
|
+
* Installed by {@link maybeAttach} BEFORE target.js is injected so the very
|
|
328
|
+
* first dial is already observed. Idempotent per page lifecycle. Exported for
|
|
329
|
+
* unit tests.
|
|
330
|
+
*/
|
|
331
|
+
function installRelayWsObserver(relayUrl) {
|
|
332
|
+
if (wsObserverInstalled) return;
|
|
333
|
+
if (typeof window === "undefined" || typeof window.WebSocket !== "function") return;
|
|
334
|
+
const relayKey = wsOriginKey(relayUrl);
|
|
335
|
+
if (relayKey === null) return;
|
|
336
|
+
wsObserverInstalled = true;
|
|
337
|
+
const NativeWebSocket = window.WebSocket;
|
|
338
|
+
const observed = new Proxy(NativeWebSocket, { construct(target, args) {
|
|
339
|
+
const url = String(args[0]);
|
|
340
|
+
if (wsOriginKey(url) !== relayKey) return Reflect.construct(target, args);
|
|
341
|
+
if (relayAuthExpired) return createFailFastSocket(url);
|
|
342
|
+
const ws = Reflect.construct(target, args);
|
|
343
|
+
ws.addEventListener("close", (event) => {
|
|
344
|
+
if (event.code === 4401) {
|
|
345
|
+
relayAuthExpired = true;
|
|
346
|
+
notifyAuthExpired();
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
return ws;
|
|
350
|
+
} });
|
|
351
|
+
window.WebSocket = observed;
|
|
352
|
+
}
|
|
180
353
|
/**
|
|
181
354
|
* Evaluates the 3-layer debug gate and, if the gate passes, injects the Chii
|
|
182
355
|
* `target.js` script into `document.head`.
|
|
@@ -213,6 +386,7 @@ function maybeAttach(gateResult = checkDebugGate()) {
|
|
|
213
386
|
if (typeof document === "undefined") return;
|
|
214
387
|
const atCode = typeof window !== "undefined" ? new URLSearchParams(window.location.search).get("at") : null;
|
|
215
388
|
const src = deriveTargetScriptUrl(gateResult.relayUrl, atCode);
|
|
389
|
+
installRelayWsObserver(gateResult.relayUrl);
|
|
216
390
|
if (document.querySelector(`script[src="${src}"]`) !== null) {
|
|
217
391
|
attached = true;
|
|
218
392
|
return;
|
|
@@ -220,6 +394,11 @@ function maybeAttach(gateResult = checkDebugGate()) {
|
|
|
220
394
|
const script = document.createElement("script");
|
|
221
395
|
script.src = src;
|
|
222
396
|
script.async = true;
|
|
397
|
+
script.onerror = () => {
|
|
398
|
+
fetch(src).then((res) => {
|
|
399
|
+
if (res.status === 401) notifyAuthExpired();
|
|
400
|
+
}).catch(() => {});
|
|
401
|
+
};
|
|
223
402
|
(document.head ?? document.documentElement).appendChild(script);
|
|
224
403
|
attached = true;
|
|
225
404
|
if (typeof window !== "undefined" && new URLSearchParams(window.location.search).get("noKeepAwake") === "1") return;
|