@harness-fe/mcp-server 3.0.1

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/dist/auth.d.ts +53 -0
  4. package/dist/auth.js +212 -0
  5. package/dist/bridge.d.ts +302 -0
  6. package/dist/bridge.js +1580 -0
  7. package/dist/cli.d.ts +18 -0
  8. package/dist/cli.js +277 -0
  9. package/dist/daemon.d.ts +98 -0
  10. package/dist/daemon.js +80 -0
  11. package/dist/dashboardApi.d.ts +40 -0
  12. package/dist/dashboardApi.js +142 -0
  13. package/dist/dashboardSpa.d.ts +18 -0
  14. package/dist/dashboardSpa.js +180 -0
  15. package/dist/dashboardUrl.d.ts +13 -0
  16. package/dist/dashboardUrl.js +18 -0
  17. package/dist/eventsHandler.d.ts +24 -0
  18. package/dist/eventsHandler.js +114 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.js +6 -0
  21. package/dist/mcp.d.ts +15 -0
  22. package/dist/mcp.js +923 -0
  23. package/dist/mcpHttp.d.ts +39 -0
  24. package/dist/mcpHttp.js +49 -0
  25. package/dist/openBrowser.d.ts +33 -0
  26. package/dist/openBrowser.js +63 -0
  27. package/dist/remoteBridge.d.ts +61 -0
  28. package/dist/remoteBridge.js +307 -0
  29. package/dist/replayCreate.d.ts +36 -0
  30. package/dist/replayCreate.js +156 -0
  31. package/dist/replayViewer.d.ts +20 -0
  32. package/dist/replayViewer.js +168 -0
  33. package/dist/sessionRouter.d.ts +42 -0
  34. package/dist/sessionRouter.js +88 -0
  35. package/dist/store/JsonMemoryStore.d.ts +52 -0
  36. package/dist/store/JsonMemoryStore.js +119 -0
  37. package/dist/store/JsonTaskStore.d.ts +21 -0
  38. package/dist/store/JsonTaskStore.js +53 -0
  39. package/dist/store/JsonlStore.d.ts +128 -0
  40. package/dist/store/JsonlStore.js +1168 -0
  41. package/dist/store/MemoryEventStore.d.ts +47 -0
  42. package/dist/store/MemoryEventStore.js +111 -0
  43. package/dist/store/WriteQueue.d.ts +51 -0
  44. package/dist/store/WriteQueue.js +142 -0
  45. package/dist/store/index.d.ts +6 -0
  46. package/dist/store/index.js +5 -0
  47. package/dist/store/types.d.ts +416 -0
  48. package/dist/store/types.js +19 -0
  49. package/package.json +63 -0
  50. package/src/auth.test.ts +90 -0
  51. package/src/auth.ts +248 -0
  52. package/src/bridge-auth.test.ts +196 -0
  53. package/src/bridge.test.ts +1708 -0
  54. package/src/bridge.ts +1804 -0
  55. package/src/cli.ts +315 -0
  56. package/src/daemon.test.ts +123 -0
  57. package/src/daemon.ts +161 -0
  58. package/src/dashboardApi.test.ts +235 -0
  59. package/src/dashboardApi.ts +184 -0
  60. package/src/dashboardSpa.test.ts +239 -0
  61. package/src/dashboardSpa.ts +195 -0
  62. package/src/dashboardUrl.test.ts +46 -0
  63. package/src/dashboardUrl.ts +28 -0
  64. package/src/eventsHandler.test.ts +247 -0
  65. package/src/eventsHandler.ts +136 -0
  66. package/src/index.ts +26 -0
  67. package/src/mcp.ts +1407 -0
  68. package/src/mcpHttp.test.ts +101 -0
  69. package/src/mcpHttp.ts +88 -0
  70. package/src/openBrowser.test.ts +103 -0
  71. package/src/openBrowser.ts +81 -0
  72. package/src/remoteBridge.test.ts +119 -0
  73. package/src/remoteBridge.ts +404 -0
  74. package/src/replay.test.ts +271 -0
  75. package/src/replayCreate.ts +194 -0
  76. package/src/replayViewer.ts +173 -0
  77. package/src/sessionRouter.ts +116 -0
  78. package/src/store/JsonMemoryStore.test.ts +175 -0
  79. package/src/store/JsonMemoryStore.ts +128 -0
  80. package/src/store/JsonTaskStore.test.ts +212 -0
  81. package/src/store/JsonTaskStore.ts +59 -0
  82. package/src/store/JsonlStore.test.ts +1538 -0
  83. package/src/store/JsonlStore.ts +1321 -0
  84. package/src/store/MemoryEventStore.test.ts +119 -0
  85. package/src/store/MemoryEventStore.ts +151 -0
  86. package/src/store/WriteQueue.ts +165 -0
  87. package/src/store/index.ts +29 -0
  88. package/src/store/types.ts +517 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 MorphixAI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,145 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/Morphicai/harness-fe/main/branding/logo.svg" alt="Harness-FE" width="96" />
3
+ </p>
4
+
5
+ # @harness-fe/mcp-server
6
+
7
+ > The MCP daemon for [Harness-FE](https://github.com/Morphicai/harness-fe). Bridges AI agents (Claude, Cursor, Kiro) with running dev servers and browser tabs.
8
+
9
+ The MCP server exposes tools over **stdio MCP** to AI agents and runs a **WebSocket bridge** for the Vite/Webpack plugin and the browser runtime client. One daemon can serve multiple projects simultaneously.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ # Run on demand (recommended)
15
+ npx @harness-fe/mcp-server
16
+
17
+ # Or install globally
18
+ pnpm add -g @harness-fe/mcp-server
19
+ harness-fe
20
+ ```
21
+
22
+ ## Use with Claude Code
23
+
24
+ Register the daemon as an MCP server in your Claude Code settings:
25
+
26
+ ```jsonc
27
+ {
28
+ "mcpServers": {
29
+ "harness-fe": {
30
+ "command": "npx",
31
+ "args": ["-y", "@harness-fe/mcp-server"]
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ Cursor, Kiro, and other MCP-compatible clients use the same pattern.
38
+
39
+ ## Multiple daemons (port = identity)
40
+
41
+ The daemon's identity is its listening port. Same port = same daemon
42
+ = same on-disk store. Different port = independent daemons with
43
+ independent stores.
44
+
45
+ This means:
46
+
47
+ - **All IDEs targeting default 47729 share one daemon automatically.**
48
+ No extra config needed. Cursor + Claude Desktop + Kiro on the same
49
+ machine see the same sessions, browser tabs, and projects.
50
+ - **Want isolation? Pick a different `--port`.** That's the whole
51
+ isolation knob.
52
+
53
+ | Scenario | Config |
54
+ |---|---|
55
+ | Single shared daemon (default) | Nothing extra |
56
+ | One project gets its own daemon | `"args": ["...", "--port", "47730"]` in that IDE's mcp.json |
57
+ | Monorepo: aggregate everything | All IDEs use default port — they pool automatically |
58
+ | Friendly name in banner / dashboard | `"env": { "HARNESS_FE_LABEL": "my-mono" }` (cosmetic only) |
59
+
60
+ Data lives at `~/.harness/daemons/<port>/data/`. The label is purely
61
+ cosmetic — isolation comes from the port, never the label.
62
+
63
+ Full guide: [docs/multi-daemon.md](https://github.com/Morphicai/harness-fe/blob/main/docs/multi-daemon.md)
64
+
65
+ ## LAN mode (real-device debugging)
66
+
67
+ The daemon binds `127.0.0.1` by default. Token is **entirely
68
+ optional** — set one if you want auth, leave it off for a fully open
69
+ daemon. The CLI never refuses to start; binding decisions are yours.
70
+
71
+ | You want… | Run | Behavior |
72
+ |-----------|-----|----------|
73
+ | Local-only, zero config | `npx @harness-fe/mcp-server` | Loopback, no auth |
74
+ | Local with auth (defense in depth) | `--token <value>` or `HARNESS_FE_TOKEN=<value>` | Loopback, auth required for HTTP / WS |
75
+ | LAN debug (phone, tablet, other host) — open | `--host 0.0.0.0` | LAN-reachable, no auth. Banner warns you. |
76
+ | LAN debug — protected | `--host 0.0.0.0 --token auto` | LAN-reachable, token required. Banner prints the dashboard URL with `?token=` baked in |
77
+
78
+ The startup banner always prints the dashboard URL. When a token is
79
+ configured, the first browser hit on `?token=…` hands it off to a
80
+ cookie so the visible URL stays clean for the next 30 days. When no
81
+ token is configured, the bare URL works as-is.
82
+
83
+ Want a remote agent to share the daemon? Mount the MCP HTTP transport:
84
+
85
+ ```bash
86
+ npx @harness-fe/mcp-server --host 0.0.0.0 --mcp-transport http --mcp-path /mcp
87
+ # … with auth:
88
+ npx @harness-fe/mcp-server --host 0.0.0.0 --token auto \
89
+ --mcp-transport http --mcp-path /mcp
90
+ ```
91
+
92
+ Remote Claude Code / Cursor config:
93
+
94
+ ```jsonc
95
+ // No-auth daemon:
96
+ { "type": "http", "url": "http://<lan-ip>:47729/mcp" }
97
+
98
+ // Token-protected daemon:
99
+ {
100
+ "type": "http",
101
+ "url": "http://<lan-ip>:47729/mcp",
102
+ "headers": { "Authorization": "Bearer <token>" }
103
+ }
104
+ ```
105
+
106
+ **Full guide:** [docs/lan-mode.md](https://github.com/Morphicai/harness-fe/blob/main/docs/lan-mode.md)
107
+
108
+ ## All CLI flags
109
+
110
+ ```
111
+ --host <addr> Bind address (default 127.0.0.1; use 0.0.0.0 for LAN)
112
+ --port <number> TCP port (default 47729)
113
+ --token <value|auto> Optional. When set, all HTTP/WS requests must carry it
114
+ (header / cookie / query / WS subprotocol). When unset,
115
+ auth is disabled entirely.
116
+ --mcp-transport <kind> stdio (default) | http
117
+ --mcp-path <path> Default /mcp
118
+ --public-host <addr> Override the host printed in outbound URLs
119
+ -h, --help
120
+ ```
121
+
122
+ Matching env vars: `HARNESS_FE_HOST`, `HARNESS_FE_PORT`,
123
+ `HARNESS_FE_TOKEN`, `HARNESS_FE_MCP_TRANSPORT`, `HARNESS_FE_MCP_PATH`,
124
+ `HARNESS_FE_HEADLESS`.
125
+
126
+ ## What it exposes
127
+
128
+ Tools across these domains (see [Architecture](https://github.com/Morphicai/harness-fe/blob/main/ARCHITECTURE.md)):
129
+
130
+ - **page** — `navigate`, `click`, `type`, `dom_query`, `evaluate`, `screenshot`, …
131
+ - **console / network / errors** — tail and search runtime events
132
+ - **session** — list, replay, slice rrweb recordings
133
+ - **project** — `source`, `where_is`, `module_graph` (source-code intelligence)
134
+ - **tasks** — point-and-task annotation queue
135
+
136
+ Persistence lives in `~/.harness/` (JSONL event logs + JSON records).
137
+
138
+ ## Docs
139
+
140
+ - [Root README](https://github.com/Morphicai/harness-fe#readme)
141
+ - [Architecture](https://github.com/Morphicai/harness-fe/blob/main/ARCHITECTURE.md)
142
+
143
+ ## License
144
+
145
+ MIT
package/dist/auth.d.ts ADDED
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Token-based auth for the bridge's HTTP + WS surfaces.
3
+ *
4
+ * Loopback (127.*, localhost, ::1) — auth disabled by default; the daemon
5
+ * trusts everything that can reach the loopback socket. As soon as the
6
+ * daemon is bound to a non-loopback host (e.g. 0.0.0.0 for LAN debugging),
7
+ * the CLI requires a token and this module enforces it on every HTTP route
8
+ * and WS upgrade.
9
+ *
10
+ * Why a single module: dashboard / replay viewer / events handler /
11
+ * MCP HTTP transport all live behind the same bridge HTTP server. Bridge
12
+ * wraps requests with `isAuthorized` once, so individual handlers never
13
+ * see unauthenticated traffic and don't carry auth code.
14
+ */
15
+ import type { IncomingMessage, ServerResponse } from 'node:http';
16
+ export declare const DEFAULT_COOKIE_NAME = "harness_fe_token";
17
+ export declare const DEFAULT_LOGIN_PATH = "/__auth";
18
+ export interface AuthOptions {
19
+ /** Expected token. Empty/undefined disables token auth. */
20
+ token?: string;
21
+ /**
22
+ * Custom authorization predicate. When supplied, runs *instead of* the
23
+ * token check on every HTTP request and WS upgrade. Synchronous: the
24
+ * WS upgrade handshake completes inline. For host-injected auth that
25
+ * needs an async lookup, cache the result in a cookie via the host's
26
+ * own middleware and have `authorize` read the cookie.
27
+ */
28
+ authorize?: (req: IncomingMessage) => boolean;
29
+ /** Cookie name set after a successful login. Default: harness_fe_token. */
30
+ cookieName?: string;
31
+ /** POST path that consumes the login form. Default: /__auth. */
32
+ loginPath?: string;
33
+ }
34
+ export declare function isAuthEnabled(opts: AuthOptions): boolean;
35
+ /** Pull token from header / cookie / query / WS subprotocol (first match wins). */
36
+ export declare function extractToken(req: IncomingMessage, opts?: AuthOptions): string | undefined;
37
+ /**
38
+ * Constant-time token compare. Hashing both sides first means we always
39
+ * compare equal-length buffers, sidestepping the length-leak that a raw
40
+ * timingSafeEqual on user input would have.
41
+ */
42
+ export declare function verifyToken(provided: string | undefined, expected: string): boolean;
43
+ /** True if request is allowed (auth disabled, custom predicate accepts, or token matches). */
44
+ export declare function isAuthorized(req: IncomingMessage, opts: AuthOptions): boolean;
45
+ /**
46
+ * Write a 401 response. Browsers (Accept: text/html) get a minimal login
47
+ * form they can post the token through; everything else gets JSON.
48
+ */
49
+ export declare function sendUnauthorized(req: IncomingMessage, res: ServerResponse, opts: AuthOptions): void;
50
+ /**
51
+ * Handle POST {loginPath}: read form body, verify token, set cookie, 303 → next.
52
+ */
53
+ export declare function handleLoginPost(req: IncomingMessage, res: ServerResponse, opts: AuthOptions): Promise<void>;
package/dist/auth.js ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Token-based auth for the bridge's HTTP + WS surfaces.
3
+ *
4
+ * Loopback (127.*, localhost, ::1) — auth disabled by default; the daemon
5
+ * trusts everything that can reach the loopback socket. As soon as the
6
+ * daemon is bound to a non-loopback host (e.g. 0.0.0.0 for LAN debugging),
7
+ * the CLI requires a token and this module enforces it on every HTTP route
8
+ * and WS upgrade.
9
+ *
10
+ * Why a single module: dashboard / replay viewer / events handler /
11
+ * MCP HTTP transport all live behind the same bridge HTTP server. Bridge
12
+ * wraps requests with `isAuthorized` once, so individual handlers never
13
+ * see unauthenticated traffic and don't carry auth code.
14
+ */
15
+ import { createHash, timingSafeEqual } from 'node:crypto';
16
+ export const DEFAULT_COOKIE_NAME = 'harness_fe_token';
17
+ export const DEFAULT_LOGIN_PATH = '/__auth';
18
+ const WS_SUBPROTOCOL_PREFIX = 'harness-fe.token.';
19
+ export function isAuthEnabled(opts) {
20
+ return !!(opts.token || opts.authorize);
21
+ }
22
+ /** Pull token from header / cookie / query / WS subprotocol (first match wins). */
23
+ export function extractToken(req, opts = {}) {
24
+ const auth = req.headers.authorization;
25
+ if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
26
+ const v = auth.slice(7).trim();
27
+ if (v)
28
+ return v;
29
+ }
30
+ const cookieName = opts.cookieName ?? DEFAULT_COOKIE_NAME;
31
+ const cookies = parseCookieHeader(req.headers.cookie);
32
+ if (cookies[cookieName])
33
+ return decodeURIComponent(cookies[cookieName]);
34
+ const url = req.url ?? '';
35
+ const qi = url.indexOf('?');
36
+ if (qi >= 0) {
37
+ const params = new URLSearchParams(url.slice(qi + 1));
38
+ const t = params.get('token');
39
+ if (t)
40
+ return t;
41
+ }
42
+ const subproto = req.headers['sec-websocket-protocol'];
43
+ if (typeof subproto === 'string') {
44
+ for (const p of subproto.split(',')) {
45
+ const trimmed = p.trim();
46
+ if (trimmed.startsWith(WS_SUBPROTOCOL_PREFIX)) {
47
+ return trimmed.slice(WS_SUBPROTOCOL_PREFIX.length);
48
+ }
49
+ }
50
+ }
51
+ return undefined;
52
+ }
53
+ /**
54
+ * Constant-time token compare. Hashing both sides first means we always
55
+ * compare equal-length buffers, sidestepping the length-leak that a raw
56
+ * timingSafeEqual on user input would have.
57
+ */
58
+ export function verifyToken(provided, expected) {
59
+ if (!provided || !expected)
60
+ return false;
61
+ const a = createHash('sha256').update(provided).digest();
62
+ const b = createHash('sha256').update(expected).digest();
63
+ return timingSafeEqual(a, b);
64
+ }
65
+ /** True if request is allowed (auth disabled, custom predicate accepts, or token matches). */
66
+ export function isAuthorized(req, opts) {
67
+ if (!isAuthEnabled(opts))
68
+ return true;
69
+ // Custom predicate wins when supplied. Hosts that embed the daemon pass
70
+ // their own check here (e.g. JWT verification reading from a cookie).
71
+ if (opts.authorize)
72
+ return opts.authorize(req);
73
+ return verifyToken(extractToken(req, opts), opts.token);
74
+ }
75
+ /**
76
+ * Write a 401 response. Browsers (Accept: text/html) get a minimal login
77
+ * form they can post the token through; everything else gets JSON.
78
+ */
79
+ export function sendUnauthorized(req, res, opts) {
80
+ // Custom-authorize mode is for host apps that own their own login UX —
81
+ // the built-in token form is never the right answer there. Always 401
82
+ // as JSON and let the host redirect.
83
+ const wantsLoginForm = !opts.authorize;
84
+ const accept = (req.headers.accept ?? '').toLowerCase();
85
+ const wantsHtml = accept.includes('text/html') && wantsLoginForm;
86
+ if (wantsHtml) {
87
+ res.statusCode = 401;
88
+ res.setHeader('content-type', 'text/html; charset=utf-8');
89
+ res.setHeader('cache-control', 'no-store');
90
+ res.end(renderLoginPage(opts, req.url ?? '/'));
91
+ return;
92
+ }
93
+ res.statusCode = 401;
94
+ res.setHeader('content-type', 'application/json; charset=utf-8');
95
+ res.setHeader('www-authenticate', 'Bearer realm="harness-fe"');
96
+ res.end(JSON.stringify({
97
+ error: 'unauthorized',
98
+ message: 'Missing or invalid token. Provide Authorization: Bearer <token>, ?token=<token>, or the harness_fe_token cookie.',
99
+ }));
100
+ }
101
+ /**
102
+ * Handle POST {loginPath}: read form body, verify token, set cookie, 303 → next.
103
+ */
104
+ export async function handleLoginPost(req, res, opts) {
105
+ if (!isAuthEnabled(opts) || opts.authorize) {
106
+ // Auth disabled, or the host owns auth via a custom predicate — the
107
+ // built-in login form isn't meaningful here. Redirect home.
108
+ res.statusCode = 303;
109
+ res.setHeader('location', '/');
110
+ res.end();
111
+ return;
112
+ }
113
+ const chunks = [];
114
+ let total = 0;
115
+ const MAX = 4096;
116
+ for await (const c of req) {
117
+ const buf = c;
118
+ total += buf.length;
119
+ if (total > MAX) {
120
+ res.statusCode = 413;
121
+ res.setHeader('content-type', 'text/plain; charset=utf-8');
122
+ res.end('payload too large');
123
+ return;
124
+ }
125
+ chunks.push(buf);
126
+ }
127
+ const body = Buffer.concat(chunks).toString('utf8');
128
+ const form = new URLSearchParams(body);
129
+ const token = form.get('token') ?? '';
130
+ const next = safeNext(form.get('next') ?? '/');
131
+ if (!verifyToken(token, opts.token)) {
132
+ res.statusCode = 401;
133
+ res.setHeader('content-type', 'text/html; charset=utf-8');
134
+ res.setHeader('cache-control', 'no-store');
135
+ res.end(renderLoginPage(opts, next, 'Invalid token. Try again.'));
136
+ return;
137
+ }
138
+ const cookieName = opts.cookieName ?? DEFAULT_COOKIE_NAME;
139
+ // 30 days. HttpOnly so JS can't read it; SameSite=Lax so cross-tab nav works.
140
+ const cookie = `${cookieName}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`;
141
+ res.statusCode = 303;
142
+ res.setHeader('set-cookie', cookie);
143
+ res.setHeader('location', next);
144
+ res.end();
145
+ }
146
+ /**
147
+ * Allow only same-origin relative paths as the post-login redirect. Anything
148
+ * else degrades to "/" so a crafted form can't redirect to an external site.
149
+ */
150
+ function safeNext(next) {
151
+ if (typeof next !== 'string')
152
+ return '/';
153
+ if (!next.startsWith('/'))
154
+ return '/';
155
+ if (next.startsWith('//'))
156
+ return '/';
157
+ return next;
158
+ }
159
+ function parseCookieHeader(raw) {
160
+ if (!raw)
161
+ return {};
162
+ const out = {};
163
+ for (const part of raw.split(';')) {
164
+ const eq = part.indexOf('=');
165
+ if (eq < 0)
166
+ continue;
167
+ const k = part.slice(0, eq).trim();
168
+ const v = part.slice(eq + 1).trim();
169
+ if (k)
170
+ out[k] = v;
171
+ }
172
+ return out;
173
+ }
174
+ function escapeHtml(s) {
175
+ return s.replace(/[&<>"']/g, (c) => {
176
+ switch (c) {
177
+ case '&': return '&amp;';
178
+ case '<': return '&lt;';
179
+ case '>': return '&gt;';
180
+ case '"': return '&quot;';
181
+ default: return '&#39;';
182
+ }
183
+ });
184
+ }
185
+ function renderLoginPage(opts, next, error) {
186
+ const loginPath = opts.loginPath ?? DEFAULT_LOGIN_PATH;
187
+ const safeN = escapeHtml(safeNext(next));
188
+ const errBlock = error
189
+ ? `<p style="color:#c0392b;margin:0 0 12px">${escapeHtml(error)}</p>`
190
+ : '';
191
+ return `<!doctype html>
192
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
193
+ <title>harness-fe — sign in</title>
194
+ <style>
195
+ body{font:14px/1.4 -apple-system,BlinkMacSystemFont,system-ui,sans-serif;background:#fafafa;color:#222;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
196
+ form{background:#fff;border:1px solid #e5e7eb;border-radius:8px;padding:24px;max-width:360px;width:100%;box-shadow:0 4px 12px rgba(0,0,0,.04)}
197
+ h1{font-size:16px;margin:0 0 12px}
198
+ input[type=password]{display:block;width:100%;box-sizing:border-box;padding:10px;border:1px solid #d1d5db;border-radius:6px;font-size:14px;margin-bottom:12px}
199
+ button{display:block;width:100%;padding:10px;background:#111;color:#fff;border:0;border-radius:6px;font-size:14px;cursor:pointer}
200
+ .muted{color:#666;font-size:12px;margin-top:12px}
201
+ </style></head>
202
+ <body>
203
+ <form method="post" action="${escapeHtml(loginPath)}" autocomplete="off">
204
+ <h1>harness-fe</h1>
205
+ ${errBlock}
206
+ <input type="password" name="token" placeholder="token" autofocus required>
207
+ <input type="hidden" name="next" value="${safeN}">
208
+ <button type="submit">Sign in</button>
209
+ <p class="muted">Paste the token from the daemon startup banner.</p>
210
+ </form>
211
+ </body></html>`;
212
+ }