hop2 0.11.12 → 0.11.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hay/apps/server/src/rooms.ts +20 -0
- package/hay/apps/server/test/rooms.test.ts +54 -0
- package/hay/apps/web/src/App.tsx +25 -1
- package/hay/apps/web/src/components/SessionSwitcher.tsx +55 -38
- package/hay/apps/web/test/SessionSwitcher.test.tsx +12 -0
- package/hay-web/assets/index--8sYRyoP.js +133 -0
- package/hay-web/assets/index--8sYRyoP.js.map +1 -0
- package/hay-web/assets/index-DnSA-VR6.js +133 -0
- package/hay-web/assets/index-DnSA-VR6.js.map +1 -0
- package/hay-web/index.html +1 -1
- package/hop +222 -63
- package/package.json +1 -1
- package/scripts/hay-host.js +25 -0
- package/skills/hop-session/SKILL.md +1 -1
- package/tests/terminal-api.integration.test.cjs +46 -0
package/hay-web/index.html
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
<!-- Terminal prompt chevron on a dark tile — reads "terminal app" at 16px. -->
|
|
7
7
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%2317181d'/%3E%3Cpath d='M11 9.5 L20.5 16 L11 22.5' fill='none' stroke='%23a78bfa' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
|
|
8
8
|
<title>Hay</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-DnSA-VR6.js"></script>
|
|
10
10
|
<link rel="stylesheet" crossorigin href="/assets/index-DHotLq-D.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
package/hop
CHANGED
|
@@ -2875,6 +2875,10 @@ function unregisterTerminalStream(id, res) {
|
|
|
2875
2875
|
streams.delete(res);
|
|
2876
2876
|
if (streams.size === 0) {
|
|
2877
2877
|
terminalApiStreams.delete(id);
|
|
2878
|
+
// The idle clock starts when the LAST stream closes — an agent that
|
|
2879
|
+
// held a stream for an hour was active the whole time.
|
|
2880
|
+
const entry = terminalApiSessions.get(id);
|
|
2881
|
+
if (entry) entry.lastTouchedAt = Date.now();
|
|
2878
2882
|
}
|
|
2879
2883
|
}
|
|
2880
2884
|
|
|
@@ -3675,6 +3679,39 @@ function createVirtualClientAdapter(onServerMessage) {
|
|
|
3675
3679
|
};
|
|
3676
3680
|
}
|
|
3677
3681
|
|
|
3682
|
+
// Fetch a terminal API entry AND stamp it as recently used — every API touch
|
|
3683
|
+
// (stream, write, resize, exec, delete) resets the idle clock the reaper
|
|
3684
|
+
// below runs on.
|
|
3685
|
+
function getTerminalApiEntryTouched(terminalId) {
|
|
3686
|
+
const entry = terminalApiSessions.get(terminalId);
|
|
3687
|
+
if (entry) entry.lastTouchedAt = Date.now();
|
|
3688
|
+
return entry;
|
|
3689
|
+
}
|
|
3690
|
+
|
|
3691
|
+
// Idle reaper for agent attaches. A virtual client is presence like any
|
|
3692
|
+
// other ("agent" in the roster) but has no socket that could die — an agent
|
|
3693
|
+
// that crashed or simply never called close would sit in a session's
|
|
3694
|
+
// presence forever. Idle = no API touch and no open SSE stream. Reaping is
|
|
3695
|
+
// safe for well-behaved agents: the MCP core re-attaches by session name on
|
|
3696
|
+
// the next op (recoverTerminalId), so a reaped handle costs one round-trip.
|
|
3697
|
+
const TERMINAL_API_IDLE_TTL_MS = Number(process.env.HOP_TERMINAL_API_IDLE_TTL_MS || 15 * 60 * 1000);
|
|
3698
|
+
const TERMINAL_API_SWEEP_MS = Number(process.env.HOP_TERMINAL_API_SWEEP_MS || 60 * 1000);
|
|
3699
|
+
if (DAEMON_FLAG && TERMINAL_API_IDLE_TTL_MS > 0) {
|
|
3700
|
+
setInterval(() => {
|
|
3701
|
+
const nowMs = Date.now();
|
|
3702
|
+
for (const entry of [...terminalApiSessions.values()]) {
|
|
3703
|
+
if (terminalApiStreams.has(entry.id)) continue; // live stream = in use
|
|
3704
|
+
const idleSince = entry.lastTouchedAt || Date.parse(entry.createdAt) || nowMs;
|
|
3705
|
+
if (nowMs - idleSince <= TERMINAL_API_IDLE_TTL_MS) continue;
|
|
3706
|
+
log(`[terminal-api] detached idle '${entry.actor}' terminal from ${entry.displayName || entry.sessionName} after ${Math.round((nowMs - idleSince) / 60000)}m`, DIM);
|
|
3707
|
+
try { entry.adapter.close(); } catch (e) { /* already closing */ }
|
|
3708
|
+
// adapter.close() detaches the room client and drops the entry;
|
|
3709
|
+
// belt-and-braces in case a close handler threw mid-chain.
|
|
3710
|
+
terminalApiSessions.delete(entry.id);
|
|
3711
|
+
}
|
|
3712
|
+
}, TERMINAL_API_SWEEP_MS).unref();
|
|
3713
|
+
}
|
|
3714
|
+
|
|
3678
3715
|
function closeTerminalApiEntry(terminalId) {
|
|
3679
3716
|
terminalApiSessions.delete(terminalId);
|
|
3680
3717
|
const streams = terminalApiStreams.get(terminalId);
|
|
@@ -3803,7 +3840,8 @@ async function attachExternalTerminalApiClient(sessionName, displayName, opts =
|
|
|
3803
3840
|
actor: opts.actor || 'user',
|
|
3804
3841
|
clientId,
|
|
3805
3842
|
adapter: adapterBundle,
|
|
3806
|
-
createdAt: new Date().toISOString()
|
|
3843
|
+
createdAt: new Date().toISOString(),
|
|
3844
|
+
lastTouchedAt: Date.now()
|
|
3807
3845
|
};
|
|
3808
3846
|
terminalApiSessions.set(terminalId, sessionEntry);
|
|
3809
3847
|
return sessionEntry;
|
|
@@ -3851,7 +3889,8 @@ async function attachTerminalApiClient(sessionName, displayName, opts = {}) {
|
|
|
3851
3889
|
actor: opts.actor || 'user',
|
|
3852
3890
|
clientId,
|
|
3853
3891
|
adapter: adapterBundle,
|
|
3854
|
-
createdAt: new Date().toISOString()
|
|
3892
|
+
createdAt: new Date().toISOString(),
|
|
3893
|
+
lastTouchedAt: Date.now()
|
|
3855
3894
|
};
|
|
3856
3895
|
terminalApiSessions.set(terminalId, sessionEntry);
|
|
3857
3896
|
return sessionEntry;
|
|
@@ -7415,6 +7454,30 @@ function recordAuth(authEmitter) {
|
|
|
7415
7454
|
if (authEmitter) authEmitter.emit('authenticated');
|
|
7416
7455
|
}
|
|
7417
7456
|
|
|
7457
|
+
// One MIME map for every static-serving path (hub assets, session assets,
|
|
7458
|
+
// Views files) — drifting per-branch copies are how .pdf went missing and a
|
|
7459
|
+
// published PDF downloaded as a blob. Includes the `hop view` families
|
|
7460
|
+
// (pdf/video/txt) alongside the app bundle's own types.
|
|
7461
|
+
const STATIC_CONTENT_TYPES = {
|
|
7462
|
+
'.html': 'text/html; charset=utf-8',
|
|
7463
|
+
'.js': 'application/javascript',
|
|
7464
|
+
'.css': 'text/css',
|
|
7465
|
+
'.json': 'application/json',
|
|
7466
|
+
'.png': 'image/png',
|
|
7467
|
+
'.jpg': 'image/jpeg',
|
|
7468
|
+
'.jpeg': 'image/jpeg',
|
|
7469
|
+
'.gif': 'image/gif',
|
|
7470
|
+
'.webp': 'image/webp',
|
|
7471
|
+
'.svg': 'image/svg+xml',
|
|
7472
|
+
'.ico': 'image/x-icon',
|
|
7473
|
+
'.pdf': 'application/pdf',
|
|
7474
|
+
'.mp4': 'video/mp4',
|
|
7475
|
+
'.mov': 'video/quicktime',
|
|
7476
|
+
'.webm': 'video/webm',
|
|
7477
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
7478
|
+
'.md': 'text/plain; charset=utf-8'
|
|
7479
|
+
};
|
|
7480
|
+
|
|
7418
7481
|
function startAuthProxy(authEmitter) {
|
|
7419
7482
|
log(`[2/4] Starting Session Manager on port ${PROXY_PORT}...`, BOLD);
|
|
7420
7483
|
|
|
@@ -7424,6 +7487,27 @@ function startAuthProxy(authEmitter) {
|
|
|
7424
7487
|
const ip = getClientIp(req);
|
|
7425
7488
|
const sessionInfo = parseSessionFromRequest(req, parsedUrl);
|
|
7426
7489
|
|
|
7490
|
+
// Shared caches must never store what the cookie gate protects.
|
|
7491
|
+
// Cloudflare edge-caches common extensions (.pdf, images, .js) BY
|
|
7492
|
+
// DEFAULT when the origin stays silent, then serves the copy to
|
|
7493
|
+
// whoever asks next — cookie or not. Proven live: a `hop view` PDF
|
|
7494
|
+
// fetched once WITH auth came back cf-cache-status: HIT without it.
|
|
7495
|
+
// Enforced here, at the writeHead choke point, so proxied upstream
|
|
7496
|
+
// headers cannot reopen the hole; a route with a deliberately
|
|
7497
|
+
// different policy sets res.hopCacheControl before responding.
|
|
7498
|
+
const rawWriteHead = res.writeHead.bind(res);
|
|
7499
|
+
res.writeHead = (...args) => {
|
|
7500
|
+
for (const a of args) {
|
|
7501
|
+
if (a && typeof a === 'object' && !Array.isArray(a)) {
|
|
7502
|
+
for (const k of Object.keys(a)) {
|
|
7503
|
+
if (String(k).toLowerCase() === 'cache-control') delete a[k];
|
|
7504
|
+
}
|
|
7505
|
+
}
|
|
7506
|
+
}
|
|
7507
|
+
res.setHeader('Cache-Control', res.hopCacheControl || 'private, no-store');
|
|
7508
|
+
return rawWriteHead(...args);
|
|
7509
|
+
};
|
|
7510
|
+
|
|
7427
7511
|
// Check auth via cookie OR Authorization header (internal hop-to-hop only)
|
|
7428
7512
|
// Bearer token is read from state file by other hop processes on same machine
|
|
7429
7513
|
const authHeader = req.headers.authorization;
|
|
@@ -7453,6 +7537,18 @@ function startAuthProxy(authEmitter) {
|
|
|
7453
7537
|
return;
|
|
7454
7538
|
}
|
|
7455
7539
|
|
|
7540
|
+
// Apple App Site Association — the trust anchor for NATIVE passkey
|
|
7541
|
+
// sign-in from hop-ios (webcredentials). Apple's CDN fetches this
|
|
7542
|
+
// anonymously over HTTPS, so it must sit in front of the auth gate.
|
|
7543
|
+
// Team + bundle are hop-ios's (project.yml); a WKWebView can never run
|
|
7544
|
+
// WebAuthn, so without this file the phone has no passkey path at all.
|
|
7545
|
+
if (parsedUrl.pathname === '/.well-known/apple-app-site-association' && req.method === 'GET') {
|
|
7546
|
+
res.hopCacheControl = 'public, max-age=300'; // public on purpose — Apple's CDN reads it
|
|
7547
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
7548
|
+
res.end(JSON.stringify({ webcredentials: { apps: ['5AD7QB9795.io.zhoulab.hop.spike'] } }));
|
|
7549
|
+
return;
|
|
7550
|
+
}
|
|
7551
|
+
|
|
7456
7552
|
// --- PASSKEYS (WebAuthn) ---
|
|
7457
7553
|
if (parsedUrl.pathname.startsWith('/api/passkeys/') && req.method === 'POST') {
|
|
7458
7554
|
const respond = (status, payload) => {
|
|
@@ -7957,6 +8053,42 @@ function startAuthProxy(authEmitter) {
|
|
|
7957
8053
|
return;
|
|
7958
8054
|
}
|
|
7959
8055
|
|
|
8056
|
+
// ---- Views files --------------------------------------------------
|
|
8057
|
+
// Served on an EXTENSIONLESS path on purpose: the edge cache stores
|
|
8058
|
+
// by URL extension and can override origin cache directives, and a
|
|
8059
|
+
// stored copy answers cookie-less clients — observed live with a
|
|
8060
|
+
// `hop view` PDF. The /inline terminal segment keeps the extension
|
|
8061
|
+
// off the end of the path; content-type still comes from the real
|
|
8062
|
+
// file name. The legacy /assets/view/<s>/<f> form redirects here, so
|
|
8063
|
+
// an edge-cached response is at worst a redirect, never the bytes.
|
|
8064
|
+
const viewMatch = req.method === 'GET'
|
|
8065
|
+
&& parsedUrl.pathname.match(/^\/view\/([^/]+)\/([^/]+?)(\/inline)?$/);
|
|
8066
|
+
if (viewMatch) {
|
|
8067
|
+
const viewRoot = path.resolve(path.join(HAY_WEB_DIR, 'assets', 'view'));
|
|
8068
|
+
const target = path.resolve(path.join(
|
|
8069
|
+
viewRoot, decodeURIComponent(viewMatch[1]), decodeURIComponent(viewMatch[2])));
|
|
8070
|
+
if (!target.startsWith(viewRoot + path.sep)
|
|
8071
|
+
|| !fs.existsSync(target) || !fs.statSync(target).isFile()) {
|
|
8072
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
8073
|
+
res.end('Not found');
|
|
8074
|
+
return;
|
|
8075
|
+
}
|
|
8076
|
+
res.writeHead(200, {
|
|
8077
|
+
'Content-Type': STATIC_CONTENT_TYPES[path.extname(target).toLowerCase()] || 'application/octet-stream'
|
|
8078
|
+
});
|
|
8079
|
+
res.end(fs.readFileSync(target));
|
|
8080
|
+
return;
|
|
8081
|
+
}
|
|
8082
|
+
if (req.method === 'GET' && parsedUrl.pathname.startsWith('/assets/view/')) {
|
|
8083
|
+
const segs = parsedUrl.pathname.slice('/assets/view/'.length).split('/');
|
|
8084
|
+
if (segs.length === 2 && segs[1] && segs[1] !== 'manifest.json') {
|
|
8085
|
+
res.writeHead(302, { Location: `/view/${segs[0]}/${segs[1]}/inline` });
|
|
8086
|
+
res.end();
|
|
8087
|
+
return;
|
|
8088
|
+
}
|
|
8089
|
+
// manifest.json falls through to plain static serving
|
|
8090
|
+
}
|
|
8091
|
+
|
|
7960
8092
|
if (sessionInfoAuth && sessionInfoAuth.source === 'query' && shouldRedirectToSessionPath(req)) {
|
|
7961
8093
|
res.writeHead(302, { 'Location': buildSessionPath(displayName) });
|
|
7962
8094
|
res.end();
|
|
@@ -9423,7 +9555,7 @@ function startAuthProxy(authEmitter) {
|
|
|
9423
9555
|
const terminalStreamMatch = (parsedUrl.pathname || '').match(/^\/api\/terminals\/([^/]+)\/stream$/);
|
|
9424
9556
|
if (terminalStreamMatch && req.method === 'GET') {
|
|
9425
9557
|
const terminalId = terminalStreamMatch[1];
|
|
9426
|
-
const entry =
|
|
9558
|
+
const entry = getTerminalApiEntryTouched(terminalId);
|
|
9427
9559
|
if (!entry) {
|
|
9428
9560
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
9429
9561
|
res.end(JSON.stringify({ error: 'Terminal not found' }));
|
|
@@ -9459,7 +9591,7 @@ function startAuthProxy(authEmitter) {
|
|
|
9459
9591
|
const terminalWriteMatch = (parsedUrl.pathname || '').match(/^\/api\/terminals\/([^/]+)\/write$/);
|
|
9460
9592
|
if (terminalWriteMatch && req.method === 'POST') {
|
|
9461
9593
|
const terminalId = terminalWriteMatch[1];
|
|
9462
|
-
const entry =
|
|
9594
|
+
const entry = getTerminalApiEntryTouched(terminalId);
|
|
9463
9595
|
if (!entry) {
|
|
9464
9596
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
9465
9597
|
res.end(JSON.stringify({ error: 'Terminal not found' }));
|
|
@@ -9498,7 +9630,7 @@ function startAuthProxy(authEmitter) {
|
|
|
9498
9630
|
const terminalResizeMatch = (parsedUrl.pathname || '').match(/^\/api\/terminals\/([^/]+)\/resize$/);
|
|
9499
9631
|
if (terminalResizeMatch && req.method === 'POST') {
|
|
9500
9632
|
const terminalId = terminalResizeMatch[1];
|
|
9501
|
-
const entry =
|
|
9633
|
+
const entry = getTerminalApiEntryTouched(terminalId);
|
|
9502
9634
|
if (!entry) {
|
|
9503
9635
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
9504
9636
|
res.end(JSON.stringify({ error: 'Terminal not found' }));
|
|
@@ -9531,7 +9663,7 @@ function startAuthProxy(authEmitter) {
|
|
|
9531
9663
|
const terminalExecMatch = (parsedUrl.pathname || '').match(/^\/api\/terminals\/([^/]+)\/exec$/);
|
|
9532
9664
|
if (terminalExecMatch && req.method === 'POST') {
|
|
9533
9665
|
const terminalId = terminalExecMatch[1];
|
|
9534
|
-
const entry =
|
|
9666
|
+
const entry = getTerminalApiEntryTouched(terminalId);
|
|
9535
9667
|
if (!entry) {
|
|
9536
9668
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
9537
9669
|
res.end(JSON.stringify({ error: 'Terminal not found' }));
|
|
@@ -9571,7 +9703,7 @@ function startAuthProxy(authEmitter) {
|
|
|
9571
9703
|
const terminalDeleteMatch = (parsedUrl.pathname || '').match(/^\/api\/terminals\/([^/]+)$/);
|
|
9572
9704
|
if (terminalDeleteMatch && req.method === 'DELETE') {
|
|
9573
9705
|
const terminalId = terminalDeleteMatch[1];
|
|
9574
|
-
const entry =
|
|
9706
|
+
const entry = getTerminalApiEntryTouched(terminalId);
|
|
9575
9707
|
if (!entry) {
|
|
9576
9708
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
9577
9709
|
res.end(JSON.stringify({ error: 'Terminal not found' }));
|
|
@@ -9795,16 +9927,13 @@ function startAuthProxy(authEmitter) {
|
|
|
9795
9927
|
return;
|
|
9796
9928
|
}
|
|
9797
9929
|
const hubExt = path.extname(resolvedHub).toLowerCase();
|
|
9798
|
-
const hubTypes =
|
|
9799
|
-
|
|
9800
|
-
|
|
9801
|
-
|
|
9802
|
-
|
|
9803
|
-
|
|
9804
|
-
|
|
9805
|
-
'.svg': 'image/svg+xml',
|
|
9806
|
-
'.ico': 'image/x-icon'
|
|
9807
|
-
};
|
|
9930
|
+
const hubTypes = STATIC_CONTENT_TYPES;
|
|
9931
|
+
// Hashed bundle files may live in the BROWSER's cache for a while
|
|
9932
|
+
// ('private' keeps every shared cache out); html and Views files
|
|
9933
|
+
// stay on the no-store default.
|
|
9934
|
+
if (hubExt !== '.html' && !parsedUrl.pathname.startsWith('/assets/view/')) {
|
|
9935
|
+
res.hopCacheControl = 'private, max-age=3600';
|
|
9936
|
+
}
|
|
9808
9937
|
let hubContent = fs.readFileSync(resolvedHub);
|
|
9809
9938
|
if (hubExt === '.html') {
|
|
9810
9939
|
// Room-less config: marks the page as hop-served (hub mode).
|
|
@@ -9952,16 +10081,12 @@ function startAuthProxy(authEmitter) {
|
|
|
9952
10081
|
|
|
9953
10082
|
// Determine content type
|
|
9954
10083
|
const ext = path.extname(filePath).toLowerCase();
|
|
9955
|
-
|
|
9956
|
-
|
|
9957
|
-
|
|
9958
|
-
|
|
9959
|
-
|
|
9960
|
-
|
|
9961
|
-
'.jpg': 'image/jpeg',
|
|
9962
|
-
'.svg': 'image/svg+xml',
|
|
9963
|
-
'.ico': 'image/x-icon'
|
|
9964
|
-
};
|
|
10084
|
+
// Same split as hub scope: hashed bundles browser-cache privately,
|
|
10085
|
+
// html and Views files keep the no-store default.
|
|
10086
|
+
if (ext !== '.html' && !reqPath.startsWith('/assets/view/')) {
|
|
10087
|
+
res.hopCacheControl = 'private, max-age=3600';
|
|
10088
|
+
}
|
|
10089
|
+
const contentTypes = STATIC_CONTENT_TYPES;
|
|
9965
10090
|
const contentType = contentTypes[ext] || 'application/octet-stream';
|
|
9966
10091
|
|
|
9967
10092
|
// Read and serve the file
|
|
@@ -10154,6 +10279,28 @@ function startAuthProxy(authEmitter) {
|
|
|
10154
10279
|
const rows = parseInt(wsUrl.searchParams.get('rows') || '24', 10);
|
|
10155
10280
|
const source = wsUrl.searchParams.get('source') || '';
|
|
10156
10281
|
const claimId = wsUrl.searchParams.get('claim') || null;
|
|
10282
|
+
// Client-supplied tab identity; the room evicts a same-key
|
|
10283
|
+
// predecessor on attach (reconnect replaces its own ghost).
|
|
10284
|
+
const deviceKeyRaw = wsUrl.searchParams.get('device') || '';
|
|
10285
|
+
const deviceKey = /^[A-Za-z0-9._-]{1,80}$/.test(deviceKeyRaw) ? deviceKeyRaw : '';
|
|
10286
|
+
|
|
10287
|
+
// Liveness: browsers answer protocol pings in the network stack,
|
|
10288
|
+
// so silence means the peer is gone (locked phone, network hop)
|
|
10289
|
+
// even though no close frame ever arrived. Without this, ghost
|
|
10290
|
+
// clients sat in presence until the tunnel's TCP gave up.
|
|
10291
|
+
let wsAlive = true;
|
|
10292
|
+
ws.on('pong', () => { wsAlive = true; });
|
|
10293
|
+
const wsHeartbeat = setInterval(() => {
|
|
10294
|
+
if (ws.readyState !== WebSocket.OPEN) { clearInterval(wsHeartbeat); return; }
|
|
10295
|
+
if (!wsAlive) {
|
|
10296
|
+
clearInterval(wsHeartbeat);
|
|
10297
|
+
try { ws.terminate(); } catch (e) { /* already gone */ }
|
|
10298
|
+
return;
|
|
10299
|
+
}
|
|
10300
|
+
wsAlive = false;
|
|
10301
|
+
try { ws.ping(); } catch (e) { /* closing */ }
|
|
10302
|
+
}, 30000);
|
|
10303
|
+
ws.on('close', () => clearInterval(wsHeartbeat));
|
|
10157
10304
|
|
|
10158
10305
|
const clientId = hay.generateClientId();
|
|
10159
10306
|
const colorIndex = Math.floor(Math.random() * 1000);
|
|
@@ -10173,7 +10320,7 @@ function startAuthProxy(authEmitter) {
|
|
|
10173
10320
|
|
|
10174
10321
|
// Attach client to hay room
|
|
10175
10322
|
session.room.attachClient(
|
|
10176
|
-
{ id: clientId, name: clientName, source, colorIndex, cols, rows },
|
|
10323
|
+
{ id: clientId, name: clientName, source, deviceKey, colorIndex, cols, rows },
|
|
10177
10324
|
hay.createSocketAdapter(ws)
|
|
10178
10325
|
);
|
|
10179
10326
|
});
|
|
@@ -12785,41 +12932,51 @@ async function main() {
|
|
|
12785
12932
|
process.exit(1);
|
|
12786
12933
|
}
|
|
12787
12934
|
}
|
|
12788
|
-
|
|
12935
|
+
// --restart: cycle onto the CURRENT checkout even when git has
|
|
12936
|
+
// nothing to pull. The running processes can predate the checkout —
|
|
12937
|
+
// commit locally, push, `hop update`, and "Already up to date" used
|
|
12938
|
+
// to leave every process on pre-commit code with no hint.
|
|
12939
|
+
const restartOnly = process.argv.includes('--restart');
|
|
12940
|
+
if (local === upstream && (checkOnly || !restartOnly)) {
|
|
12789
12941
|
log(`Already up to date (${local.slice(0, 7)}).`, GREEN);
|
|
12942
|
+
if (!checkOnly) log(`Processes were not cycled — if they predate this checkout, ${CYAN}hop update --restart${RESET} restarts them onto it.`, RESET);
|
|
12790
12943
|
process.exit(0);
|
|
12791
12944
|
}
|
|
12792
|
-
|
|
12793
|
-
|
|
12794
|
-
|
|
12795
|
-
|
|
12796
|
-
|
|
12797
|
-
|
|
12798
|
-
|
|
12799
|
-
|
|
12800
|
-
|
|
12801
|
-
|
|
12802
|
-
|
|
12803
|
-
|
|
12804
|
-
|
|
12805
|
-
|
|
12806
|
-
|
|
12807
|
-
|
|
12808
|
-
|
|
12809
|
-
|
|
12810
|
-
|
|
12811
|
-
|
|
12812
|
-
|
|
12813
|
-
|
|
12814
|
-
|
|
12815
|
-
|
|
12816
|
-
|
|
12817
|
-
|
|
12818
|
-
|
|
12819
|
-
|
|
12820
|
-
|
|
12821
|
-
|
|
12822
|
-
|
|
12945
|
+
if (local === upstream) {
|
|
12946
|
+
log(`Checkout already current (${local.slice(0, 7)}) — restarting processes onto it.`, CYAN);
|
|
12947
|
+
} else {
|
|
12948
|
+
const behind = runGit(`git rev-list --count HEAD..${upstream}`);
|
|
12949
|
+
const ahead = runGit(`git rev-list --count ${upstream}..HEAD`);
|
|
12950
|
+
log(`Update available: ${behind} new commit(s)${ahead !== '0' ? ` — but local is ${ahead} ahead; resolve manually` : ''}`, YELLOW);
|
|
12951
|
+
try { log(runGit(`git log --oneline --no-decorate HEAD..${upstream} -15`), RESET); } catch (e) { }
|
|
12952
|
+
if (checkOnly) process.exit(0);
|
|
12953
|
+
if (ahead !== '0') process.exit(1);
|
|
12954
|
+
let dirty = '';
|
|
12955
|
+
try { dirty = runGit('git status --porcelain'); } catch (e) { }
|
|
12956
|
+
if (dirty) {
|
|
12957
|
+
log('Working tree has local changes — commit or stash them first.', RED);
|
|
12958
|
+
process.exit(1);
|
|
12959
|
+
}
|
|
12960
|
+
// Dependency fingerprint across the pull: reinstall only when a
|
|
12961
|
+
// lockfile actually changed.
|
|
12962
|
+
const lockRev = (ref, file) => { try { return runGit(`git rev-parse ${ref}:${file}`); } catch (e) { return ''; } };
|
|
12963
|
+
const locks = ['package-lock.json', 'hay/package-lock.json'];
|
|
12964
|
+
const before = locks.map((f) => lockRev('HEAD', f));
|
|
12965
|
+
try { runGit(`git merge --ff-only ${upstream}`); } catch (e) {
|
|
12966
|
+
log(`Fast-forward failed: ${e.message}`, RED);
|
|
12967
|
+
process.exit(1);
|
|
12968
|
+
}
|
|
12969
|
+
const after = locks.map((f) => lockRev('HEAD', f));
|
|
12970
|
+
for (let i = 0; i < locks.length; i++) {
|
|
12971
|
+
if (before[i] !== after[i]) {
|
|
12972
|
+
const dir = locks[i].startsWith('hay/') ? path.join(__dirname, 'hay') : __dirname;
|
|
12973
|
+
log(`Dependencies changed (${locks[i]}) — installing…`, CYAN);
|
|
12974
|
+
try {
|
|
12975
|
+
execSync('npm install --no-audit --no-fund', { cwd: dir, stdio: 'inherit', timeout: 600000 });
|
|
12976
|
+
} catch (e) {
|
|
12977
|
+
log(`npm install failed in ${dir}: ${e.message}`, RED);
|
|
12978
|
+
process.exit(1);
|
|
12979
|
+
}
|
|
12823
12980
|
}
|
|
12824
12981
|
}
|
|
12825
12982
|
}
|
|
@@ -13162,7 +13319,9 @@ async function main() {
|
|
|
13162
13319
|
entries.push({
|
|
13163
13320
|
session: sess,
|
|
13164
13321
|
name: f,
|
|
13165
|
-
path
|
|
13322
|
+
// The extensionless serving path — see the /view route
|
|
13323
|
+
// for why the extension must stay off the URL's end.
|
|
13324
|
+
path: `/view/${encodeURIComponent(sess)}/${encodeURIComponent(f)}/inline`,
|
|
13166
13325
|
bytes: st.size,
|
|
13167
13326
|
mtime: Math.floor(st.mtimeMs / 1000)
|
|
13168
13327
|
});
|
|
@@ -13204,7 +13363,7 @@ async function main() {
|
|
|
13204
13363
|
// session subdir already namespaces collisions between agents.
|
|
13205
13364
|
const dest = path.join(destDir, path.basename(src));
|
|
13206
13365
|
fs.copyFileSync(src, dest);
|
|
13207
|
-
console.log(`View: ${state.url}/
|
|
13366
|
+
console.log(`View: ${state.url}/view/${encodeURIComponent(session)}/${encodeURIComponent(path.basename(src))}/inline`);
|
|
13208
13367
|
}
|
|
13209
13368
|
if (failed === 0 && files.length > 0) {
|
|
13210
13369
|
// One bell: the artifact is usually the deliverable the human is
|
|
@@ -13274,7 +13433,7 @@ async function main() {
|
|
|
13274
13433
|
].join(''));
|
|
13275
13434
|
rebuildArtifactManifest();
|
|
13276
13435
|
console.log(`View: ${proxyUrl}`);
|
|
13277
|
-
console.log(`Attached to ${caller} — also in its
|
|
13436
|
+
console.log(`Attached to ${caller} — also in its Views.`);
|
|
13278
13437
|
} else {
|
|
13279
13438
|
console.log(`View: ${proxyUrl}`);
|
|
13280
13439
|
}
|
package/package.json
CHANGED
package/scripts/hay-host.js
CHANGED
|
@@ -393,11 +393,36 @@ async function main() {
|
|
|
393
393
|
if (clientBg && typeof room.setClientColors === 'function') {
|
|
394
394
|
room.setClientColors(clientBg, clientFg);
|
|
395
395
|
}
|
|
396
|
+
// Tab identity forwarded by the daemon: the room evicts a same-key
|
|
397
|
+
// predecessor on attach, so a phone's reconnect replaces its own
|
|
398
|
+
// ghost in presence instead of standing next to it.
|
|
399
|
+
const deviceKeyRaw = wsUrl.searchParams.get('device') || '';
|
|
400
|
+
const deviceKey = /^[A-Za-z0-9._-]{1,80}$/.test(deviceKeyRaw) ? deviceKeyRaw : '';
|
|
401
|
+
|
|
402
|
+
// Liveness: browsers answer protocol pings in the network stack, so
|
|
403
|
+
// silence means the peer is gone (locked phone, network hop) even
|
|
404
|
+
// though no close frame ever arrived — through the tunnel that can
|
|
405
|
+
// outlive the client by hours. Ghosts die within a minute instead.
|
|
406
|
+
let wsAlive = true;
|
|
407
|
+
ws.on('pong', () => { wsAlive = true; });
|
|
408
|
+
const wsHeartbeat = setInterval(() => {
|
|
409
|
+
if (ws.readyState !== ws.OPEN) { clearInterval(wsHeartbeat); return; }
|
|
410
|
+
if (!wsAlive) {
|
|
411
|
+
clearInterval(wsHeartbeat);
|
|
412
|
+
try { ws.terminate(); } catch (e) { /* already gone */ }
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
wsAlive = false;
|
|
416
|
+
try { ws.ping(); } catch (e) { /* closing */ }
|
|
417
|
+
}, 30000);
|
|
418
|
+
ws.on('close', () => clearInterval(wsHeartbeat));
|
|
419
|
+
|
|
396
420
|
room.attachClient(
|
|
397
421
|
{
|
|
398
422
|
id: randomUUID(),
|
|
399
423
|
name,
|
|
400
424
|
source,
|
|
425
|
+
deviceKey,
|
|
401
426
|
colorIndex: Math.floor(Math.random() * 1000),
|
|
402
427
|
cols,
|
|
403
428
|
rows,
|
|
@@ -37,7 +37,7 @@ hop view report.html analysis.pdf
|
|
|
37
37
|
`hop view` copies the file behind hop's auth and prints a `View:` URL. The
|
|
38
38
|
human taps it and their browser renders it — on the desktop wall or from the
|
|
39
39
|
phone app, which opens these in-app. It rings the bell once for you, since a
|
|
40
|
-
published
|
|
40
|
+
published file is usually the thing the human is waiting on. Re-running
|
|
41
41
|
it after updating the file re-publishes under the same link.
|
|
42
42
|
|
|
43
43
|
## Showing a running web server (dev server, local app)
|
|
@@ -688,3 +688,49 @@ test('bare token API creations default to agent origin', async () => {
|
|
|
688
688
|
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'BareTokenProbe', internalName: 'BareTokenProbe' });
|
|
689
689
|
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete', { name: 'CliUserProbe', internalName: 'CliUserProbe' });
|
|
690
690
|
});
|
|
691
|
+
|
|
692
|
+
// An agent attach with no API activity and no open stream must expire — a
|
|
693
|
+
// crashed or forgetful agent otherwise parks an "agent" in the session's
|
|
694
|
+
// presence forever. Needs a daemon with a short TTL, so this test relaunches
|
|
695
|
+
// (declaration order puts it last; nothing later depends on daemon state).
|
|
696
|
+
test('an idle agent terminal attach is reaped after the TTL', async () => {
|
|
697
|
+
const previousState = state;
|
|
698
|
+
daemonEnv = {
|
|
699
|
+
...daemonEnv,
|
|
700
|
+
HOP_TERMINAL_API_IDLE_TTL_MS: '1500',
|
|
701
|
+
HOP_TERMINAL_API_SWEEP_MS: '300'
|
|
702
|
+
};
|
|
703
|
+
await stopDaemon(true);
|
|
704
|
+
await launchDaemon(previousState);
|
|
705
|
+
|
|
706
|
+
const create = await requestJson(state.port, state.sessionSecret, 'POST', '/api/terminals', {
|
|
707
|
+
name: 'idle-agent-probe',
|
|
708
|
+
cwd: tempDir
|
|
709
|
+
}, agentHeaders);
|
|
710
|
+
assert.equal(create.status, 200);
|
|
711
|
+
const terminalId = create.data.id;
|
|
712
|
+
|
|
713
|
+
const listed = await requestJson(state.port, state.sessionSecret, 'GET', '/api/terminals', null, agentHeaders);
|
|
714
|
+
assert.ok(listed.data.terminals.some(t => t.id === terminalId), 'attach visible before the TTL');
|
|
715
|
+
|
|
716
|
+
// Touches reset the clock: keep it alive past one full TTL with writes.
|
|
717
|
+
await delay(1000);
|
|
718
|
+
await requestJson(state.port, state.sessionSecret, 'POST', `/api/terminals/${terminalId}/write`,
|
|
719
|
+
{ data: ' ' }, agentHeaders);
|
|
720
|
+
await delay(1000);
|
|
721
|
+
const alive = await requestJson(state.port, state.sessionSecret, 'GET', '/api/terminals', null, agentHeaders);
|
|
722
|
+
assert.ok(alive.data.terminals.some(t => t.id === terminalId), 'touched attach survives past a TTL of idle-from-create');
|
|
723
|
+
|
|
724
|
+
// Now go silent: the sweep must detach it (session itself stays alive).
|
|
725
|
+
await delay(2500);
|
|
726
|
+
const after = await requestJson(state.port, state.sessionSecret, 'GET', '/api/terminals', null, agentHeaders);
|
|
727
|
+
assert.ok(!after.data.terminals.some(t => t.id === terminalId), 'idle attach reaped');
|
|
728
|
+
const sessions = await requestJson(state.port, state.sessionSecret, 'GET', '/api/sessions');
|
|
729
|
+
const s = sessions.data.sessions.find(x => x.displayName === 'idle-agent-probe');
|
|
730
|
+
assert.ok(s, 'the session itself outlives its reaped attach');
|
|
731
|
+
const clients = Number(s.clientCount) || 0;
|
|
732
|
+
assert.equal(clients, 0, `reap must detach the room client, got clientCount=${clients}`);
|
|
733
|
+
|
|
734
|
+
await requestJson(state.port, state.sessionSecret, 'POST', '/api/sessions/delete',
|
|
735
|
+
{ name: 'idle-agent-probe', internalName: s.internalName });
|
|
736
|
+
});
|