native-sim 0.1.0

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.
@@ -0,0 +1,184 @@
1
+ // native-sim-template-version: 18
2
+ /**
3
+ * native-sim auth gate.
4
+ *
5
+ * serve-sim ships no authentication, so exposing port 3200 through a public
6
+ * tunnel would hand simulator control to anyone who guessed the URL. This is a
7
+ * dependency-free reverse proxy that requires `?k=<token>` once, trades it for
8
+ * an HttpOnly cookie, and forwards everything (including the MJPEG stream and
9
+ * the control WebSocket) to serve-sim on localhost.
10
+ *
11
+ * It also multiplexes a second upstream onto the same tunnel: when
12
+ * NATIVE_SIM_AGENT_PORT is set, `/agent-device/*` is routed to the local
13
+ * `agent-device proxy` instead of serve-sim, so one URL carries both the
14
+ * human-facing stream and the agent-facing control API.
15
+ */
16
+ const http = require('node:http');
17
+ const net = require('node:net');
18
+
19
+ const TOKEN = process.env.NATIVE_SIM_GATE_TOKEN || '';
20
+ const TARGET_PORT = Number(process.env.NATIVE_SIM_TARGET_PORT || 3200);
21
+ // 0 disables the agent-device route entirely, so a session started without
22
+ // --agent exposes no extra surface at all.
23
+ const AGENT_PORT = Number(process.env.NATIVE_SIM_AGENT_PORT || 0);
24
+ const AGENT_PREFIX = '/agent-device';
25
+ const TARGET_HOST = '127.0.0.1';
26
+ const PORT = Number(process.env.NATIVE_SIM_GATE_PORT || 3199);
27
+ const COOKIE = 'native_sim_k';
28
+
29
+ if (!TOKEN) {
30
+ console.error('NATIVE_SIM_GATE_TOKEN is required — refusing to proxy an unauthenticated simulator');
31
+ process.exit(1);
32
+ }
33
+
34
+ function timingSafeEqual(a, b) {
35
+ if (typeof a !== 'string' || a.length !== b.length) return false;
36
+ let diff = 0;
37
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
38
+ return diff === 0;
39
+ }
40
+
41
+ function cookieToken(req) {
42
+ const raw = req.headers.cookie || '';
43
+ for (const part of raw.split(';')) {
44
+ const [name, ...rest] = part.trim().split('=');
45
+ if (name === COOKIE) return rest.join('=');
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /** agent-device authenticates with a bearer header; it never sends cookies. */
51
+ function bearerToken(req) {
52
+ const match = /^Bearer\s+(.+)$/i.exec((req.headers.authorization || '').trim());
53
+ return match ? match[1] : null;
54
+ }
55
+
56
+ function pathnameOf(req) {
57
+ return new URL(req.url, 'http://localhost').pathname;
58
+ }
59
+
60
+ /** True when this request belongs to the agent-device proxy, not serve-sim. */
61
+ function isAgentRoute(req) {
62
+ if (!AGENT_PORT) return false;
63
+ const path = pathnameOf(req);
64
+ return path === AGENT_PREFIX || path.startsWith(`${AGENT_PREFIX}/`);
65
+ }
66
+
67
+ /** Returns 'cookie' | 'bearer' | 'query' when authorised, or false. */
68
+ function authorize(req) {
69
+ if (timingSafeEqual(cookieToken(req), TOKEN)) return 'cookie';
70
+ if (timingSafeEqual(bearerToken(req), TOKEN)) return 'bearer';
71
+ const url = new URL(req.url, 'http://localhost');
72
+ if (timingSafeEqual(url.searchParams.get('k'), TOKEN)) return 'query';
73
+ return false;
74
+ }
75
+
76
+ const DENIED = `<!doctype html><meta charset=utf-8><title>native-sim</title>
77
+ <style>body{font:14px/1.6 -apple-system,system-ui,sans-serif;margin:15vh auto;max-width:34rem;padding:0 1.5rem;color:#111}
78
+ @media(prefers-color-scheme:dark){body{background:#111;color:#eee}}code{background:#8882;padding:.15em .4em;border-radius:4px}</style>
79
+ <h1>🔒 native-sim</h1>
80
+ <p>This simulator stream needs the access key from the link the CLI printed.</p>
81
+ <p>Ask whoever started the session for the full URL — the one ending in <code>?k=…</code>.</p>`;
82
+
83
+ const server = http.createServer((req, res) => {
84
+ if (req.url.startsWith('/__native-sim/healthz')) {
85
+ res.writeHead(200, { 'content-type': 'application/json' });
86
+ res.end(JSON.stringify({ ok: true, target: TARGET_PORT, agent: AGENT_PORT || null }));
87
+ return;
88
+ }
89
+
90
+ const auth = authorize(req);
91
+ if (!auth) {
92
+ // agent-device leaves /health unauthenticated for reachability probes; the
93
+ // gate deliberately does not, so an unauthenticated request can never reach
94
+ // either upstream. `connect proxy` carries --daemon-auth-token on every
95
+ // request, including that probe, so it authenticates normally.
96
+ res.writeHead(403, { 'content-type': 'text/html; charset=utf-8' });
97
+ res.end(DENIED);
98
+ return;
99
+ }
100
+
101
+ const agent = isAgentRoute(req);
102
+
103
+ // Trade the query token for a cookie so the key stops travelling in URLs
104
+ // (and so the preview's own fetches and WebSocket upgrades carry it). Never
105
+ // on the agent route: a 302 mid-RPC would break the client, which has no
106
+ // cookie jar and already authenticates per request.
107
+ if (auth === 'query' && !agent) {
108
+ const url = new URL(req.url, 'http://localhost');
109
+ url.searchParams.delete('k');
110
+ res.writeHead(302, {
111
+ 'set-cookie': `${COOKIE}=${TOKEN}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200`,
112
+ location: url.pathname + url.search,
113
+ });
114
+ res.end();
115
+ return;
116
+ }
117
+
118
+ const upstream = http.request(
119
+ {
120
+ host: TARGET_HOST,
121
+ port: agent ? AGENT_PORT : TARGET_PORT,
122
+ method: req.method,
123
+ // The agent-device proxy serves these routes under /agent-device/* itself,
124
+ // so the path is forwarded verbatim rather than stripped.
125
+ path: req.url,
126
+ // Do NOT rewrite Host. serve-sim derives the URLs it advertises to the
127
+ // browser from these headers; pointing them at 127.0.0.1:3200 makes the
128
+ // page open its control WebSocket against the *viewer's* loopback, which
129
+ // fails as "control socket connect timeout". Forward the public origin so
130
+ // the helper and WebSocket URLs stay same-origin and route back through
131
+ // this gate.
132
+ headers: {
133
+ ...req.headers,
134
+ 'x-forwarded-proto': 'https',
135
+ 'x-forwarded-host': req.headers.host,
136
+ },
137
+ },
138
+ (upRes) => {
139
+ res.writeHead(upRes.statusCode || 502, upRes.headers);
140
+ upRes.pipe(res);
141
+ },
142
+ );
143
+
144
+ upstream.on('error', (err) => {
145
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain' });
146
+ res.end(`upstream error: ${err.message}`);
147
+ });
148
+
149
+ req.pipe(upstream);
150
+ });
151
+
152
+ // WebSockets carry simulator input, so the upgrade path has to be proxied too.
153
+ server.on('upgrade', (req, socket, head) => {
154
+ if (!authorize(req)) {
155
+ socket.end('HTTP/1.1 403 Forbidden\r\n\r\n');
156
+ return;
157
+ }
158
+
159
+ const upstream = net.connect(isAgentRoute(req) ? AGENT_PORT : TARGET_PORT, TARGET_HOST, () => {
160
+ const forwarded = {
161
+ ...req.headers,
162
+ 'x-forwarded-proto': 'https',
163
+ 'x-forwarded-host': req.headers.host,
164
+ };
165
+ const headers = Object.entries(forwarded)
166
+ .map(([k, v]) => (Array.isArray(v) ? v.map((x) => `${k}: ${x}`).join('\r\n') : `${k}: ${v}`))
167
+ .join('\r\n');
168
+ upstream.write(`${req.method} ${req.url} HTTP/1.1\r\n${headers}\r\n\r\n`);
169
+ if (head && head.length) upstream.write(head);
170
+ upstream.pipe(socket);
171
+ socket.pipe(upstream);
172
+ });
173
+
174
+ const drop = () => {
175
+ socket.destroy();
176
+ upstream.destroy();
177
+ };
178
+ upstream.on('error', drop);
179
+ socket.on('error', drop);
180
+ });
181
+
182
+ server.listen(PORT, '127.0.0.1', () => {
183
+ console.log(`native-sim gate on :${PORT} -> :${TARGET_PORT}${AGENT_PORT ? ` (agent-device -> :${AGENT_PORT})` : ''}`);
184
+ });