hop2 0.11.47 → 0.11.49

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,315 @@
1
+ // End-to-end: per-device login sessions and the remote-access (password) gate,
2
+ // against a real daemon in a throwaway HOP_HOME.
3
+ //
4
+ // node --test tests/auth-sessions.integration.test.cjs
5
+ //
6
+ // Part 1 runs with HOP_NO_TUNNEL=1 (no cloudflared) and exercises login →
7
+ // per-device cookie, listing, device tokens, revocation, logout, and the
8
+ // WebSocket query-token rule. Part 2 runs with a fake cloudflared and checks
9
+ // that the tunnel stays off without a password and opens with the opt-out.
10
+
11
+ const test = require('node:test');
12
+ const assert = require('node:assert/strict');
13
+ const fs = require('node:fs/promises');
14
+ const path = require('node:path');
15
+ const os = require('node:os');
16
+ const http = require('node:http');
17
+ const net = require('node:net');
18
+ const { spawn } = require('node:child_process');
19
+ const { setTimeout: delay } = require('node:timers/promises');
20
+ const { authenticator } = require('otplib');
21
+
22
+ const HOP_BIN = path.join(__dirname, '..', 'hop');
23
+
24
+ async function writeFakeCloudflared(binPath, logPath) {
25
+ const script = [
26
+ '#!/usr/bin/env bash',
27
+ `LOG_FILE="${logPath}"`,
28
+ 'TS=$(date +%s)',
29
+ 'echo "2024-01-01T00:00:00Z INF tunnel URL https://test-${TS}.trycloudflare.com" >&2',
30
+ 'echo "Registered tunnel connection" >&2',
31
+ 'echo "$$ ${TS} start" >> "$LOG_FILE"',
32
+ 'trap \'echo "$$ $(date +%s) term" >> "$LOG_FILE"; exit 0\' TERM INT',
33
+ 'while true; do sleep 1; done',
34
+ ''
35
+ ].join('\n');
36
+ await fs.writeFile(binPath, script, { mode: 0o755 });
37
+ }
38
+
39
+ function request(port, { method = 'GET', path: reqPath = '/', headers = {}, body = null } = {}) {
40
+ return new Promise((resolve, reject) => {
41
+ const payload = body ? JSON.stringify(body) : null;
42
+ const h = { ...headers };
43
+ if (payload) h['Content-Type'] = 'application/json';
44
+ const req = http.request({ hostname: '127.0.0.1', port, path: reqPath, method, headers: h }, (res) => {
45
+ let data = '';
46
+ res.on('data', (c) => data += c);
47
+ res.on('end', () => {
48
+ let parsed = null;
49
+ try { parsed = data ? JSON.parse(data) : null; } catch (e) { parsed = data; }
50
+ resolve({ status: res.statusCode, headers: res.headers, data: parsed });
51
+ });
52
+ });
53
+ req.on('error', reject);
54
+ if (payload) req.write(payload);
55
+ req.end();
56
+ });
57
+ }
58
+
59
+ // Raw WebSocket upgrade; resolve with the first status line the server sends.
60
+ function upgradeStatus(port, reqPath, extraHeaders = {}) {
61
+ return new Promise((resolve, reject) => {
62
+ const sock = net.connect(port, '127.0.0.1', () => {
63
+ const lines = [
64
+ `GET ${reqPath} HTTP/1.1`,
65
+ `Host: 127.0.0.1:${port}`,
66
+ 'Connection: Upgrade',
67
+ 'Upgrade: websocket',
68
+ 'Sec-WebSocket-Version: 13',
69
+ 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==',
70
+ ...Object.entries(extraHeaders).map(([k, v]) => `${k}: ${v}`),
71
+ '', ''
72
+ ];
73
+ sock.write(lines.join('\r\n'));
74
+ });
75
+ let buf = '';
76
+ const done = (v) => { try { sock.destroy(); } catch (e) { } resolve(v); };
77
+ sock.on('data', (c) => { buf += c.toString('latin1'); const m = /^HTTP\/1\.1 (\d{3})/.exec(buf); if (m) done(Number(m[1])); });
78
+ sock.on('close', () => done(buf ? null : 'closed'));
79
+ sock.on('error', reject);
80
+ setTimeout(() => done('timeout'), 4000).unref();
81
+ });
82
+ }
83
+
84
+ async function readState(home) {
85
+ return JSON.parse(await fs.readFile(path.join(home, '.tunnel-state'), 'utf8'));
86
+ }
87
+
88
+ class Daemon {
89
+ constructor(extraEnv = {}, { fakeTunnel = false } = {}) {
90
+ this.extraEnv = extraEnv;
91
+ this.fakeTunnel = fakeTunnel;
92
+ this.output = [];
93
+ }
94
+
95
+ async start() {
96
+ this.tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hop-auth-test-'));
97
+ this.home = path.join(this.tempDir, 'hop_home');
98
+ this.binDir = path.join(this.tempDir, 'bin');
99
+ await fs.mkdir(this.home, { recursive: true });
100
+ await fs.mkdir(this.binDir, { recursive: true });
101
+ await writeFakeCloudflared(path.join(this.binDir, 'cloudflared'), path.join(this.tempDir, 'cloudflared.log'));
102
+ await fs.writeFile(path.join(this.binDir, 'claude'), '#!/usr/bin/env bash\necho "stub claude: $@"\n', { mode: 0o755 });
103
+ const env = {
104
+ ...process.env,
105
+ HOP_HOME: this.home,
106
+ PATH: `${this.binDir}:${process.env.PATH || ''}`,
107
+ ...(this.fakeTunnel ? {} : { HOP_NO_TUNNEL: '1' }),
108
+ ...this.extraEnv
109
+ };
110
+ delete env.HOP_ALLOW_TOTP_ONLY;
111
+ Object.assign(env, this.extraEnv);
112
+ const child = spawn(process.execPath, [HOP_BIN, '--daemon'], { env, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
113
+ child.stdout.on('data', (c) => this.output.push(c.toString('utf8')));
114
+ child.stderr.on('data', (c) => this.output.push(c.toString('utf8')));
115
+ let exitCode = null;
116
+ child.once('exit', (code) => { exitCode = code; });
117
+ child.unref();
118
+ this.pid = child.pid;
119
+ const start = Date.now();
120
+ while (Date.now() - start < 25000) {
121
+ try {
122
+ const st = await readState(this.home);
123
+ // With the tunnel blocked, url is null but the state file still lands.
124
+ // The daemon claims HOP_HOME (writes state) before the HTTP server is
125
+ // listening, so also wait until the API answers.
126
+ if (st && st.port && st.sessionSecret) {
127
+ try {
128
+ await request(st.port, { path: '/api/auth', headers: { Authorization: `Bearer ${st.sessionSecret}` } });
129
+ this.state = st; this.pid = st.pid; return;
130
+ } catch (e) { /* not listening yet */ }
131
+ }
132
+ } catch (e) { }
133
+ if (exitCode !== null) throw new Error(`daemon exited ${exitCode}:\n${this.output.join('')}`);
134
+ await delay(200);
135
+ }
136
+ throw new Error(`timed out waiting for daemon state:\n${this.output.join('')}`);
137
+ }
138
+
139
+ async totp() {
140
+ const secret = (await fs.readFile(path.join(this.home, '.auth_secret'), 'utf8')).trim();
141
+ return authenticator.generate(secret);
142
+ }
143
+
144
+ async stop() {
145
+ for (const file of ['.hay-host-state']) {
146
+ try {
147
+ const pid = Number(JSON.parse(await fs.readFile(path.join(this.home, file), 'utf8'))?.pid);
148
+ if (pid > 0) { try { process.kill(pid, 'SIGTERM'); } catch (e) { } }
149
+ } catch (e) { }
150
+ }
151
+ if (!this.pid) return;
152
+ try { process.kill(this.pid, 'SIGTERM'); } catch (e) { }
153
+ const start = Date.now();
154
+ while (Date.now() - start < 5000) {
155
+ try { process.kill(this.pid, 0); } catch (e) { return; }
156
+ await delay(100);
157
+ }
158
+ try { process.kill(this.pid, 'SIGKILL'); } catch (e) { }
159
+ }
160
+ }
161
+
162
+ const COOKIE = 'tunnel_session';
163
+ const cookieValue = (setCookie) => {
164
+ const list = Array.isArray(setCookie) ? setCookie : [setCookie];
165
+ for (const c of list) {
166
+ const m = new RegExp(`^${COOKIE}=([^;]*)`).exec(String(c || ''));
167
+ if (m && m[1]) return { value: m[1], raw: String(c) };
168
+ }
169
+ return null;
170
+ };
171
+
172
+ test('per-device login sessions: login, list, device tokens, revoke, logout, ws query rule', async (t) => {
173
+ const d = new Daemon();
174
+ await d.start();
175
+ t.after(() => d.stop());
176
+ const port = d.state.port;
177
+ const secret = d.state.sessionSecret;
178
+ const ipc = { Authorization: `Bearer ${secret}` };
179
+
180
+ // The daemon secret still works as a Bearer for local IPC…
181
+ assert.equal((await request(port, { path: '/api/auth', headers: ipc })).status, 200);
182
+ // …but is no longer a valid cookie: every old browser lands on the login page.
183
+ assert.equal((await request(port, { path: '/api/auth', headers: { Cookie: `${COOKIE}=${secret}` } })).status, 401);
184
+ // A garbage cookie is refused too.
185
+ assert.equal((await request(port, { path: '/api/auth', headers: { Cookie: `${COOKIE}=hs_nope` } })).status, 401);
186
+
187
+ // TOTP login mints a per-device session cookie, 30 days long.
188
+ const login = await request(port, {
189
+ method: 'POST', path: '/api/login', body: { totp: await d.totp() },
190
+ headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1' }
191
+ });
192
+ assert.equal(login.status, 200, JSON.stringify(login.data));
193
+ const ck = cookieValue(login.headers['set-cookie']);
194
+ assert.ok(ck, 'login sets the session cookie');
195
+ assert.ok(ck.value.startsWith('hs_'), `cookie is a per-device token, got ${ck.value.slice(0, 6)}`);
196
+ assert.notEqual(ck.value, secret, 'cookie must not be the daemon secret');
197
+ assert.match(ck.raw, /Max-Age=2592000/, '30-day cookie');
198
+ assert.match(ck.raw, /HttpOnly/);
199
+ assert.match(ck.raw, /Secure/);
200
+ const asCookie = { Cookie: `${COOKIE}=${ck.value}` };
201
+ assert.equal((await request(port, { path: '/api/auth', headers: asCookie })).status, 200);
202
+
203
+ // A second login is a second, independent session.
204
+ await delay(1100); // fresh TOTP window not required (same code is fine), but avoid rate-limit clustering
205
+ const login2 = await request(port, { method: 'POST', path: '/api/login', body: { totp: await d.totp() }, headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) Chrome/126.0 Safari/537.36' } });
206
+ assert.equal(login2.status, 200, JSON.stringify(login2.data));
207
+ const ck2 = cookieValue(login2.headers['set-cookie']);
208
+ assert.notEqual(ck2.value, ck.value);
209
+
210
+ // Listing shows both, marks the caller, hides token material.
211
+ const list = await request(port, { path: '/api/auth/sessions', headers: asCookie });
212
+ assert.equal(list.status, 200);
213
+ assert.equal(list.data.ttlDays, 30);
214
+ assert.equal(list.data.via, 'cookie');
215
+ assert.equal(list.data.sessions.length, 2);
216
+ const mine = list.data.sessions.find((s) => s.current);
217
+ assert.ok(mine, 'caller session marked current');
218
+ assert.equal(mine.id, list.data.current);
219
+ assert.match(mine.label, /iPhone/);
220
+ for (const s of list.data.sessions) {
221
+ assert.equal(s.tokenHash, undefined);
222
+ assert.equal(s.token, undefined);
223
+ assert.equal(typeof s.expiresAt, 'number');
224
+ }
225
+ assert.ok(mine.expiresAt - Date.now() > 29 * 86400000);
226
+
227
+ // IPC callers (the CLI) see no current session.
228
+ const ipcList = await request(port, { path: '/api/auth/sessions', headers: ipc });
229
+ assert.equal(ipcList.data.current, null);
230
+ assert.equal(ipcList.data.via, 'ipc');
231
+
232
+ // Device tokens: minted once, usable as Bearer, honoring --days.
233
+ const tok = await request(port, { method: 'POST', path: '/api/auth/sessions/token', headers: ipc, body: { label: 'ci script', days: 2 } });
234
+ assert.equal(tok.status, 200, JSON.stringify(tok.data));
235
+ assert.ok(tok.data.token.startsWith('hs_'));
236
+ const twoDays = tok.data.expiresAt - Date.now();
237
+ assert.ok(twoDays > 1.9 * 86400000 && twoDays <= 2 * 86400000 + 5000, `2-day token, got ${twoDays}`);
238
+ const asBearer = { Authorization: `Bearer ${tok.data.token}` };
239
+ assert.equal((await request(port, { path: '/api/auth', headers: asBearer })).status, 200);
240
+ assert.equal((await request(port, { path: '/api/auth/sessions', headers: ipc })).data.sessions.length, 3);
241
+ const noExp = await request(port, { method: 'POST', path: '/api/auth/sessions/token', headers: ipc, body: { label: 'phone', noExpiry: true } });
242
+ assert.equal(noExp.data.expiresAt, null);
243
+
244
+ // The web UI's actor inference: a Bearer device token counts as automation,
245
+ // a cookie as a human — same as before the change.
246
+ // (Covered indirectly: /api/auth/sessions reports via.)
247
+
248
+ // Revoke one by id: that device is out, others stay.
249
+ const rev = await request(port, { method: 'POST', path: '/api/auth/sessions/revoke', headers: ipc, body: { id: mine.id } });
250
+ assert.equal(rev.status, 200);
251
+ assert.equal((await request(port, { path: '/api/auth', headers: asCookie })).status, 401, 'revoked cookie refused');
252
+ assert.equal((await request(port, { path: '/api/auth', headers: { Cookie: `${COOKIE}=${ck2.value}` } })).status, 200, 'other device unaffected');
253
+ assert.equal((await request(port, { method: 'POST', path: '/api/auth/sessions/revoke', headers: ipc, body: { id: mine.id } })).status, 404);
254
+
255
+ // Logout revokes the caller's own session and clears the cookie.
256
+ const out = await request(port, { method: 'POST', path: '/api/logout', headers: asBearer });
257
+ assert.equal(out.status, 200);
258
+ const cleared = [].concat(out.headers['set-cookie'] || []).some((c) => /^tunnel_session=;/.test(String(c)) && /Max-Age=0/.test(String(c)));
259
+ assert.ok(cleared, `logout clears the cookie, got ${JSON.stringify(out.headers['set-cookie'])}`);
260
+ assert.equal((await request(port, { path: '/api/auth', headers: asBearer })).status, 401);
261
+
262
+ // Revoke everyone from the CLI path (IPC, keepCurrent:false).
263
+ const all = await request(port, { method: 'POST', path: '/api/auth/sessions/revoke', headers: ipc, body: { all: true, keepCurrent: false } });
264
+ assert.equal(all.status, 200);
265
+ assert.ok(all.data.removed >= 2);
266
+ assert.equal((await request(port, { path: '/api/auth/sessions', headers: ipc })).data.sessions.length, 0);
267
+ assert.equal((await request(port, { path: '/api/auth', headers: { Cookie: `${COOKIE}=${ck2.value}` } })).status, 401);
268
+ // IPC is untouched by revocation: it is not a session.
269
+ assert.equal((await request(port, { path: '/api/auth', headers: ipc })).status, 200);
270
+
271
+ // WebSocket ?token=<daemon secret>: fine from a direct loopback client (the
272
+ // local CLI), refused when the request came in through the tunnel.
273
+ const direct = await upgradeStatus(port, `/ws?room=nope&token=${encodeURIComponent(secret)}`);
274
+ assert.notEqual(direct, 401, `direct loopback query token accepted (got ${direct})`);
275
+ const proxied = await upgradeStatus(port, `/ws?room=nope&token=${encodeURIComponent(secret)}`, { 'CF-Connecting-IP': '203.0.113.9' });
276
+ assert.equal(proxied, 401, 'daemon secret in a proxied query string is refused');
277
+ // A device token in the query works either way.
278
+ const tok3 = await request(port, { method: 'POST', path: '/api/auth/sessions/token', headers: ipc, body: { label: 'ws' } });
279
+ const viaQuery = await upgradeStatus(port, `/ws?room=nope&token=${encodeURIComponent(tok3.data.token)}`, { 'CF-Connecting-IP': '203.0.113.9' });
280
+ assert.notEqual(viaQuery, 401, `device token in query accepted (got ${viaQuery})`);
281
+ const garbage = await upgradeStatus(port, `/ws?room=nope&token=hs_garbage`);
282
+ assert.equal(garbage, 401);
283
+
284
+ // State survives a daemon restart: sessions are on disk, hashed.
285
+ const raw = await fs.readFile(path.join(d.home, '.auth-sessions.json'), 'utf8');
286
+ assert.ok(!raw.includes(tok3.data.token), 'raw token never persisted');
287
+ assert.equal((await fs.stat(path.join(d.home, '.auth-sessions.json'))).mode & 0o777, 0o600);
288
+ });
289
+
290
+ test('remote-access gate: the tunnel stays off without a password and opens with the opt-out', async (t) => {
291
+ const blocked = new Daemon({}, { fakeTunnel: true });
292
+ await blocked.start();
293
+ t.after(() => blocked.stop());
294
+ assert.equal(blocked.state.url, null, 'no public URL without a password');
295
+ const st = await request(blocked.state.port, { path: '/api/tunnel/status', headers: { Authorization: `Bearer ${blocked.state.sessionSecret}` } });
296
+ assert.equal(st.status, 200);
297
+ assert.equal(st.data.connected, false);
298
+ assert.equal(st.data.lastEvent, 'blocked_no_password');
299
+ assert.match(String(st.data.lastError), /password/i);
300
+ assert.match(blocked.output.join(''), /Remote access is off: no password is set/);
301
+ // Local API still works (local-only mode).
302
+ assert.equal((await request(blocked.state.port, { path: '/api/auth', headers: { Authorization: `Bearer ${blocked.state.sessionSecret}` } })).status, 200);
303
+ await blocked.stop();
304
+
305
+ const opted = new Daemon({ HOP_ALLOW_TOTP_ONLY: '1' }, { fakeTunnel: true });
306
+ await opted.start();
307
+ t.after(() => opted.stop());
308
+ const start = Date.now();
309
+ let url = opted.state.url;
310
+ while (!url && Date.now() - start < 15000) {
311
+ await delay(200);
312
+ try { url = (await readState(opted.home)).url; } catch (e) { }
313
+ }
314
+ assert.match(String(url), /^https:\/\/test-\d+\.trycloudflare\.com$/, `tunnel opens with the opt-out, got ${url}`);
315
+ });
@@ -0,0 +1,96 @@
1
+ const test = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
7
+ const { AuthSessionStore, hashToken, isSessionToken, DEFAULT_TTL_MS } = require('../lib/auth-sessions');
8
+
9
+ const tmpStore = (options = {}) => {
10
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hop-auth-sessions-'));
11
+ const filePath = path.join(dir, '.auth-sessions.json');
12
+ return { store: new AuthSessionStore(filePath, options), filePath, dir };
13
+ };
14
+
15
+ test('create mints a distinct token per login and stores only its hash', () => {
16
+ const { store, filePath } = tmpStore();
17
+ const a = store.create({ via: 'totp', label: 'laptop' });
18
+ const b = store.create({ via: 'passkey', label: 'phone' });
19
+ assert.notEqual(a.token, b.token);
20
+ assert.notEqual(a.id, b.id);
21
+ assert.ok(isSessionToken(a.token));
22
+ const raw = fs.readFileSync(filePath, 'utf8');
23
+ assert.ok(!raw.includes(a.token), 'raw token must never be written to disk');
24
+ assert.ok(raw.includes(hashToken(a.token)));
25
+ assert.equal(fs.statSync(filePath).mode & 0o777, 0o600);
26
+ });
27
+
28
+ test('verify accepts a live token, rejects garbage, the daemon secret shape, and revoked tokens', () => {
29
+ const { store } = tmpStore();
30
+ const { id, token } = store.create({ via: 'totp' });
31
+ assert.equal(store.verify(token).id, id);
32
+ assert.equal(store.verify('nope'), null);
33
+ assert.equal(store.verify(''), null);
34
+ assert.equal(store.verify(undefined), null);
35
+ // A 64-hex value is the legacy process-wide secret's shape: never a session.
36
+ assert.equal(store.verify('a'.repeat(64)), null);
37
+ assert.equal(store.revoke(id), true);
38
+ assert.equal(store.verify(token), null);
39
+ assert.equal(store.revoke(id), false);
40
+ });
41
+
42
+ test('sessions expire after the ttl (30 days by default) and are pruned', () => {
43
+ let now = 1_000_000;
44
+ const { store } = tmpStore({ now: () => now });
45
+ const { token, expiresAt } = store.create({ via: 'totp' });
46
+ assert.equal(expiresAt, now + DEFAULT_TTL_MS);
47
+ assert.equal(DEFAULT_TTL_MS, 30 * 86400000);
48
+ now += DEFAULT_TTL_MS - 1;
49
+ assert.ok(store.verify(token));
50
+ now += 2;
51
+ assert.equal(store.verify(token), null);
52
+ assert.equal(store.list().length, 0);
53
+ });
54
+
55
+ test('a custom ttl and no-expiry device tokens are honored', () => {
56
+ let now = 5000;
57
+ const { store } = tmpStore({ now: () => now });
58
+ const short = store.create({ via: 'cli', ttlMs: 1000 });
59
+ const forever = store.create({ via: 'cli', noExpiry: true, label: 'iphone' });
60
+ assert.equal(forever.expiresAt, null);
61
+ now += 1500;
62
+ assert.equal(store.verify(short.token), null);
63
+ assert.equal(store.verify(forever.token).label, 'iphone');
64
+ });
65
+
66
+ test('list hides token material and revokeAll can keep the caller', () => {
67
+ const { store } = tmpStore();
68
+ const keep = store.create({ via: 'passkey', label: 'me' });
69
+ store.create({ via: 'totp', label: 'old laptop' });
70
+ store.create({ via: 'totp', label: 'old phone' });
71
+ const listed = store.list();
72
+ assert.equal(listed.length, 3);
73
+ for (const s of listed) {
74
+ assert.equal(s.tokenHash, undefined);
75
+ assert.equal(s.token, undefined);
76
+ }
77
+ assert.equal(store.revokeAll({ exceptId: keep.id }), 2);
78
+ assert.deepEqual(store.list().map((s) => s.id), [keep.id]);
79
+ assert.equal(store.revokeAll(), 1);
80
+ assert.equal(store.list().length, 0);
81
+ });
82
+
83
+ test('state survives a fresh store instance (daemon restart)', () => {
84
+ const { store, filePath } = tmpStore();
85
+ const { token, id } = store.create({ via: 'totp' });
86
+ const reopened = new AuthSessionStore(filePath);
87
+ assert.equal(reopened.verify(token).id, id);
88
+ });
89
+
90
+ test('a corrupt state file is treated as empty, not fatal', () => {
91
+ const { store, filePath } = tmpStore();
92
+ fs.writeFileSync(filePath, '{not json');
93
+ assert.deepEqual(store.list(), []);
94
+ const { token } = store.create({ via: 'totp' });
95
+ assert.ok(store.verify(token));
96
+ });
@@ -1231,7 +1231,7 @@ test('hopx_spawn_agent gives initial tasks an automatic echo-safe completion con
1231
1231
  }
1232
1232
  });
1233
1233
 
1234
- test('hopx_spawn_agent uses autonomous Sonnet by default unless explicitly overridden', async () => {
1234
+ test('hopx_spawn_agent uses autonomous Opus by default unless explicitly overridden', async () => {
1235
1235
  const { server, port, getWrites } = await startMockHopStreamServer({ startupOutput: '❯ ' });
1236
1236
  const hopHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hop-mcp-claude-model-test-'));
1237
1237
  const { child, call } = startMcp({
@@ -1249,7 +1249,7 @@ test('hopx_spawn_agent uses autonomous Sonnet by default unless explicitly overr
1249
1249
  const defaultPayload = JSON.parse(defaultSpawn.result.content[0].text);
1250
1250
  assert.equal(defaultPayload.ok, true, JSON.stringify(defaultPayload));
1251
1251
  const cleanClaude = 'env -u CLAUDE_CODE_OAUTH_TOKEN -u CLAUDE_CODE_ENTRYPOINT -u CLAUDE_CODE_EXECPATH -u CLAUDE_CODE_SESSION_ID -u CLAUDE_CODE_CHILD_SESSION claude';
1252
- assert.equal(defaultPayload.command, `${cleanClaude} --model sonnet --permission-mode bypassPermissions`);
1252
+ assert.equal(defaultPayload.command, `${cleanClaude} --model opus --permission-mode bypassPermissions`);
1253
1253
 
1254
1254
  const explicitSpawn = await call('tools/call', {
1255
1255
  name: 'hopx_spawn_agent',
@@ -1266,7 +1266,7 @@ test('hopx_spawn_agent uses autonomous Sonnet by default unless explicitly overr
1266
1266
  assert.deepEqual(
1267
1267
  getWrites().filter((write) => write.data.includes(' claude')).map((write) => write.data),
1268
1268
  [
1269
- `${cleanClaude} --model sonnet --permission-mode bypassPermissions\n`,
1269
+ `${cleanClaude} --model opus --permission-mode bypassPermissions\n`,
1270
1270
  `${cleanClaude} --permission-mode bypassPermissions --model opus\n`
1271
1271
  ]
1272
1272
  );
@@ -1367,7 +1367,7 @@ test('hopx_spawn_agent permits a restricted Claude worker mode', async () => {
1367
1367
  });
1368
1368
  const payload = JSON.parse(spawned.result.content[0].text);
1369
1369
  assert.equal(payload.ok, true, JSON.stringify(payload));
1370
- assert.match(payload.command, /--model sonnet --permission-mode manual$/);
1370
+ assert.match(payload.command, /--model opus --permission-mode manual$/);
1371
1371
  } finally {
1372
1372
  child.kill();
1373
1373
  server.close();
@@ -424,7 +424,9 @@ test('a signed-in client hands its session to the new hostname exactly once', as
424
424
  await setCanonical('me.hoptest.example.com');
425
425
  await until(async () => (await request('GET', '/api/instance')).json?.canonicalHost, 'canonical host');
426
426
 
427
- const auth = { Cookie: `tunnel_session=${state.sessionSecret}` };
427
+ // The daemon secret is local-IPC only (Bearer); it is no longer a valid
428
+ // cookie. Any authenticated caller may mint a handoff for its browser.
429
+ const auth = { Authorization: `Bearer ${state.sessionSecret}` };
428
430
  const mint = await request('POST', '/api/handoff', { body: {}, headers: auth });
429
431
  assert.equal(mint.status, 200);
430
432
  assert.match(mint.json.url, /^https:\/\/me\.hoptest\.example\.com\/api\/handoff\/redeem\?token=/);
@@ -7,12 +7,24 @@ const path = require('node:path');
7
7
  const {
8
8
  RegistrationStore,
9
9
  validateSubdomain,
10
- validateEmail
10
+ validateEmail: validateEmailRaw
11
11
  } = require('../lib/registration');
12
12
 
13
+ // The module has no default domain any more; these tests model an admin who
14
+ // configured uchicago.edu.
15
+ const validateEmail = (raw, options = { allowedDomain: 'uchicago.edu' }) => validateEmailRaw(raw, options);
16
+
17
+ test('email validation refuses everything until an email domain is configured', () => {
18
+ assert.equal(validateEmailRaw('alice@uchicago.edu').ok, false);
19
+ assert.match(validateEmailRaw('alice@uchicago.edu', {}).error, /no email domain/i);
20
+ assert.equal(validateEmailRaw('alice@uchicago.edu', { allowedDomain: 'uchicago.edu' }).ok, true);
21
+ });
22
+
13
23
  const tmpStore = (options = {}) => {
14
24
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hop-reg-'));
15
- return new RegistrationStore(path.join(dir, 'registrations.json'), options);
25
+ // Every store models an admin who configured a domain; the module itself
26
+ // ships without one (see the validateEmail test above).
27
+ return new RegistrationStore(path.join(dir, 'registrations.json'), { allowedDomain: 'uchicago.edu', ...options });
16
28
  };
17
29
 
18
30
  test('subdomain validation refuses names that would break or impersonate DNS', () => {