@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.
Files changed (35) hide show
  1. package/dist/{chii-relay-BNd3G3UG.js → chii-relay-BcWDKbQ1.js} +89 -25
  2. package/dist/chii-relay-BcWDKbQ1.js.map +1 -0
  3. package/dist/{chii-relay-DngjQ2_A.cjs → chii-relay-D5Hc0G39.cjs} +91 -26
  4. package/dist/chii-relay-D5Hc0G39.cjs.map +1 -0
  5. package/dist/in-app/index.d.ts.map +1 -1
  6. package/dist/in-app/index.js +179 -0
  7. package/dist/in-app/index.js.map +1 -1
  8. package/dist/mcp/cli.js +97 -29
  9. package/dist/mcp/cli.js.map +1 -1
  10. package/dist/mcp/server.js +1 -1
  11. package/dist/panel/index.js +4 -2
  12. package/dist/panel/index.js.map +1 -1
  13. package/dist/{qr-http-server-DR__VNnX.cjs → qr-http-server-BIIMOcuU.cjs} +3 -1
  14. package/dist/qr-http-server-BIIMOcuU.cjs.map +1 -0
  15. package/dist/{qr-http-server-CyVQphTM.js → qr-http-server-CeEzLS3g.js} +3 -1
  16. package/dist/qr-http-server-CeEzLS3g.js.map +1 -0
  17. package/dist/{qr-http-server-DnQSQ3hC.cjs → qr-http-server-ClakYBO9.cjs} +3 -1
  18. package/dist/qr-http-server-ClakYBO9.cjs.map +1 -0
  19. package/dist/{qr-http-server-DKEca8J3.js → qr-http-server-JjGU81q7.js} +3 -1
  20. package/dist/qr-http-server-JjGU81q7.js.map +1 -0
  21. package/dist/{tunnel-BMY7KgO5.cjs → tunnel-DwVrcZ56.cjs} +2 -2
  22. package/dist/{tunnel-BMY7KgO5.cjs.map → tunnel-DwVrcZ56.cjs.map} +1 -1
  23. package/dist/{tunnel-DIN5Vvbo.js → tunnel-aIy_7nWm.js} +2 -2
  24. package/dist/{tunnel-DIN5Vvbo.js.map → tunnel-aIy_7nWm.js.map} +1 -1
  25. package/dist/unplugin/index.cjs +2 -2
  26. package/dist/unplugin/index.js +2 -2
  27. package/dist/unplugin/tunnel.cjs +1 -1
  28. package/dist/unplugin/tunnel.js +1 -1
  29. package/package.json +1 -1
  30. package/dist/chii-relay-BNd3G3UG.js.map +0 -1
  31. package/dist/chii-relay-DngjQ2_A.cjs.map +0 -1
  32. package/dist/qr-http-server-CyVQphTM.js.map +0 -1
  33. package/dist/qr-http-server-DKEca8J3.js.map +0 -1
  34. package/dist/qr-http-server-DR__VNnX.cjs.map +0 -1
  35. 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 registers HTTP 'upgrade' and
17
- * 'request' listeners on the server BEFORE calling `chii.start({server})`.
18
- * Node's `http.Server` calls listeners in registration order; the first to
19
- * call `socket.destroy()` (upgrade) or `res.end()` (request) wins. Invalid
20
- * auth → 401 + destroy (chii never sees the connection). Valid auth →
21
- * return without side-effect (chii handles it).
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.on("upgrade", (req, socket) => {
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
- socket.write("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n");
113
- socket.destroy();
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-BNd3G3UG.js.map
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 registers HTTP 'upgrade' and
16
- * 'request' listeners on the server BEFORE calling `chii.start({server})`.
17
- * Node's `http.Server` calls listeners in registration order; the first to
18
- * call `socket.destroy()` (upgrade) or `res.end()` (request) wins. Invalid
19
- * auth → 401 + destroy (chii never sees the connection). Valid auth →
20
- * return without side-effect (chii handles it).
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, require("node:module").createRequire)(require("url").pathToFileURL(__filename).href);
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.on("upgrade", (req, socket) => {
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
- socket.write("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n");
112
- socket.destroy();
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-DngjQ2_A.cjs.map
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;;;;;;;;;;;;AC9LA;;;;;AAsCA;;;;;;;;AC1CA;;;;;;;;;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;;;;iBC9LgB,qBAAA,CAAsB,QAAA,UAAkB,MAAA;;;;;;;;AAAxD;;;;;AAsCA;;;;;;;;AC1CA;;;iBD0CgB,WAAA,CAAY,UAAA,GAAY,UAAA;;;;;;;ADwGxC;;;;;AAuBA;;;;;iBEzKgB,cAAA,CAAA,GAAkB,UAAA"}
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"}
@@ -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;