@hncode/hncode 0.3.6 → 0.4.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.
- package/package.json +4 -2
- package/src/agent-task.js +154 -84
- package/src/agent.js +533 -145
- package/src/clipboard.js +5 -2
- package/src/colors.js +242 -238
- package/src/config.js +217 -17
- package/src/file-history.js +177 -0
- package/src/hooks.js +11 -1
- package/src/i18n.js +389 -0
- package/src/index.js +46 -3
- package/src/llm.js +139 -16
- package/src/permissions.js +120 -0
- package/src/session-hub.js +407 -0
- package/src/session.js +31 -0
- package/src/term.js +19 -3
- package/src/tool-result.js +236 -0
- package/src/tools/agent.js +11 -0
- package/src/tools/bash.js +84 -17
- package/src/tools/edit.js +192 -32
- package/src/tools/file-lines.js +5 -0
- package/src/tools/index.js +4 -0
- package/src/tools/memory.js +88 -0
- package/src/tools/read-media-file.js +85 -68
- package/src/tools/read.js +15 -1
- package/src/tools/utils.js +14 -0
- package/src/tools/write.js +35 -5
- package/src/tui.js +11665 -9179
- package/src/web-auth.js +98 -0
- package/src/web-daemon-cli.js +63 -0
- package/src/web-daemon-client.js +203 -0
- package/src/web-daemon.js +871 -0
- package/src/web-public/app.css +1509 -0
- package/src/web-public/app.js +2913 -0
- package/src/web-public/complete.js +154 -0
- package/src/web-public/fonts/JetBrainsMono-Bold.woff2 +0 -0
- package/src/web-public/fonts/JetBrainsMono-Regular.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-100-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-101-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-102-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-103-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-104-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-105-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-106-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-107-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-108-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-109-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-110-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-111-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-112-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-113-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-114-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-115-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-116-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-117-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-118-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-119-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-21-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-22-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-23-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-24-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-25-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-26-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-27-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-28-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-29-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-30-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-31-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-32-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-33-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-34-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-35-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-36-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-37-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-38-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-39-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-4-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-40-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-41-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-42-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-43-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-44-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-45-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-46-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-47-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-48-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-49-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-5-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-50-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-51-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-52-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-53-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-54-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-55-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-56-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-57-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-58-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-59-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-6-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-60-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-61-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-62-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-63-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-64-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-65-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-66-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-67-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-68-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-69-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-70-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-71-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-72-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-73-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-74-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-75-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-76-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-77-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-78-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-79-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-80-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-81-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-82-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-83-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-84-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-85-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-86-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-87-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-88-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-89-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-90-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-91-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-97-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-98-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc-99-400-normal.woff2 +0 -0
- package/src/web-public/fonts/noto-sans-sc.css +775 -0
- package/src/web-public/fonts.css +31 -0
- package/src/web-public/home.css +222 -0
- package/src/web-public/home.html +33 -0
- package/src/web-public/home.js +160 -0
- package/src/web-public/i18n.js +983 -0
- package/src/web-public/index.html +196 -0
- package/src/web-public/login.css +123 -0
- package/src/web-public/login.html +25 -0
- package/src/web-public/login.js +46 -0
- package/src/web-public/settings.js +290 -0
- package/src/web-ws.js +226 -0
- package/src/web.js +380 -0
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
// The global hncode web UI.
|
|
2
|
+
//
|
|
3
|
+
// WHY A DAEMON: the per-session server in web.js can only ever serve the one TUI
|
|
4
|
+
// process that started it — its state lives in that process's closures. The user
|
|
5
|
+
// wants ONE address that lists every workspace and every session on the machine,
|
|
6
|
+
// with a fixed port and a fixed token, so a bookmark and a saved login keep
|
|
7
|
+
// working. That requires something that outlives an individual session.
|
|
8
|
+
//
|
|
9
|
+
// WHAT IT DOES
|
|
10
|
+
// 1. Owns the fixed port + token from config.toml.
|
|
11
|
+
// 2. Accepts registrations from running TUI sessions over a named pipe. Each
|
|
12
|
+
// registration names the loopback port that session's own web.js listens on.
|
|
13
|
+
// 3. Proxies /s/<id>/* to that session's port (live, two-way, SSE included).
|
|
14
|
+
// 4. Serves a session whose TUI has EXITED straight from its file in
|
|
15
|
+
// ~/.hncode/sessions (read-only), so nothing is lost when a terminal closes.
|
|
16
|
+
// 5. Exits when the last TUI session deregisters.
|
|
17
|
+
//
|
|
18
|
+
// The daemon deliberately does NOT re-implement SessionHub. A live session's
|
|
19
|
+
// transcript, actions and SSE stream stay in web.js, where they already work;
|
|
20
|
+
// this process only routes to them. A second copy of the incremental row state
|
|
21
|
+
// would be two sources of truth, which is exactly the class of bug this project
|
|
22
|
+
// keeps having to fix.
|
|
23
|
+
|
|
24
|
+
import http from 'node:http';
|
|
25
|
+
import net from 'node:net';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import * as sess from './session.js';
|
|
31
|
+
import { ensureWebToken } from './config.js';
|
|
32
|
+
import {
|
|
33
|
+
safeEqual, isLoopback, sameOriginOk, requestSecret, isAuthed,
|
|
34
|
+
} from './web-auth.js';
|
|
35
|
+
|
|
36
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const PUBLIC_DIR = path.join(HERE, 'web-public');
|
|
38
|
+
const COOKIE = 'hncode_web';
|
|
39
|
+
const LOGIN_DELAY_MS = 400;
|
|
40
|
+
|
|
41
|
+
/** How long a session may go without a heartbeat before it is presumed dead. */
|
|
42
|
+
const STALE_MS = 45_000;
|
|
43
|
+
|
|
44
|
+
/** Heartbeat cadence the client is told to use (it may send more often). */
|
|
45
|
+
export const HEARTBEAT_MS = 15_000;
|
|
46
|
+
/**
|
|
47
|
+
* How often the registry checks whether a session's process is still there.
|
|
48
|
+
*
|
|
49
|
+
* Short, because the check is `process.kill(pid, 0)` — a syscall, not I/O — and
|
|
50
|
+
* it is what makes quitting hncode clear the daemon promptly. Waiting for the
|
|
51
|
+
* heartbeat instead means up to STALE_MS of a stale entry, which is what left a
|
|
52
|
+
* daemon running after the last TUI had gone.
|
|
53
|
+
*/
|
|
54
|
+
const SWEEP_MS = 5_000;
|
|
55
|
+
|
|
56
|
+
/** The pipe the sessions register on. */
|
|
57
|
+
export function defaultDaemonPipe() {
|
|
58
|
+
if (process.env.HNCODE_WEB_PIPE) return process.env.HNCODE_WEB_PIPE;
|
|
59
|
+
if (process.platform === 'win32') return '\\\\.\\pipe\\hncode-web-daemon';
|
|
60
|
+
return path.join(os.homedir(), '.hncode', 'web-daemon.sock');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Registry
|
|
65
|
+
|
|
66
|
+
export class Registry {
|
|
67
|
+
constructor() {
|
|
68
|
+
/** sessionId -> { id, port, token, title, workspace, pid, at } */
|
|
69
|
+
this.live = new Map();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Add or refresh a session. Returns the stored record. */
|
|
73
|
+
register(info) {
|
|
74
|
+
const id = String((info && info.id) || '').trim();
|
|
75
|
+
if (!id) throw new Error('id is required');
|
|
76
|
+
const port = Number(info.port);
|
|
77
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error('invalid port');
|
|
78
|
+
const rec = {
|
|
79
|
+
id,
|
|
80
|
+
port,
|
|
81
|
+
// The session's OWN access token. The daemon presents it upstream so
|
|
82
|
+
// web.js's checks pass without the browser logging in twice.
|
|
83
|
+
token: String(info.token || ''),
|
|
84
|
+
title: String(info.title || ''),
|
|
85
|
+
workspace: String(info.workspace || ''),
|
|
86
|
+
pid: Number(info.pid) || 0,
|
|
87
|
+
at: Date.now(),
|
|
88
|
+
};
|
|
89
|
+
this.live.set(id, rec);
|
|
90
|
+
return rec;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
touch(id) {
|
|
94
|
+
const rec = this.live.get(String(id || ''));
|
|
95
|
+
if (rec) { rec.at = Date.now(); return true; }
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
unregister(id) {
|
|
100
|
+
return this.live.delete(String(id || ''));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
get(id) {
|
|
104
|
+
const rec = this.live.get(String(id || ''));
|
|
105
|
+
if (!rec) return null;
|
|
106
|
+
// Same liveness rules as sweep, so a browser refresh reflects a session that
|
|
107
|
+
// has exited instead of waiting for the next sweep tick.
|
|
108
|
+
if (this.isProcessAlive(rec.pid) === false || Date.now() - rec.at > STALE_MS) {
|
|
109
|
+
this.live.delete(rec.id);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return rec;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Is the process that registered this session still alive?
|
|
117
|
+
*
|
|
118
|
+
* `process.kill(pid, 0)` sends no signal; it only asks the OS whether the pid
|
|
119
|
+
* exists, and it answers IMMEDIATELY. That matters because the alternative —
|
|
120
|
+
* waiting for the heartbeat to go stale — takes up to STALE_MS, and a TUI that
|
|
121
|
+
* exits through its own quit path cannot deregister reliably: `detach()` sends
|
|
122
|
+
* an async pipe request and `process.exit()` follows in the same tick, so the
|
|
123
|
+
* request is usually never written. The pid check turns "left a daemon running
|
|
124
|
+
* for a minute after I quit" into "gone on the next sweep".
|
|
125
|
+
*
|
|
126
|
+
* EPERM means the pid exists but belongs to another user — still alive. Only
|
|
127
|
+
* ESRCH means genuinely gone. An unknown pid (0) falls back to the heartbeat.
|
|
128
|
+
*/
|
|
129
|
+
isProcessAlive(pid) {
|
|
130
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) return null;
|
|
131
|
+
try {
|
|
132
|
+
process.kill(pid, 0);
|
|
133
|
+
return true;
|
|
134
|
+
} catch (e) {
|
|
135
|
+
if (e && e.code === 'ESRCH') return false;
|
|
136
|
+
return true; // EPERM and anything else: assume alive
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Drop sessions whose process is gone, or whose heartbeat stopped (the latter
|
|
142
|
+
* covers a same-machine pid we cannot see, and a session that lost its event
|
|
143
|
+
* loop while the pid stayed alive).
|
|
144
|
+
*/
|
|
145
|
+
sweep(now = Date.now()) {
|
|
146
|
+
const gone = [];
|
|
147
|
+
for (const [id, rec] of this.live) {
|
|
148
|
+
const dead = this.isProcessAlive(rec.pid) === false;
|
|
149
|
+
const stale = now - rec.at > STALE_MS;
|
|
150
|
+
if (dead || stale) { this.live.delete(id); gone.push(id); }
|
|
151
|
+
}
|
|
152
|
+
return gone;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
list() {
|
|
156
|
+
this.sweep();
|
|
157
|
+
// The token is stripped from anything sent to the browser.
|
|
158
|
+
return [...this.live.values()]
|
|
159
|
+
.sort((a, b) => b.at - a.at)
|
|
160
|
+
.map((r) => ({
|
|
161
|
+
id: r.id, title: r.title, workspace: r.workspace,
|
|
162
|
+
pid: r.pid, at: r.at, live: true,
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
get size() { return this.live.size; }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Reading finished sessions off disk
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Every saved session, newest first.
|
|
174
|
+
*
|
|
175
|
+
* `readSessionMeta` stops scanning at `"messages"`, so this is cheap even with
|
|
176
|
+
* hundreds of long transcripts. Sessions with no messages are skipped: an empty
|
|
177
|
+
* session is one the user never really started, and listing it is just noise
|
|
178
|
+
* beside the real ones.
|
|
179
|
+
*/
|
|
180
|
+
export function listSavedSessions(storeDir) {
|
|
181
|
+
const dir = storeDir || sess.sessionsDir();
|
|
182
|
+
let files = [];
|
|
183
|
+
try { files = fs.readdirSync(dir); } catch { return []; }
|
|
184
|
+
const out = [];
|
|
185
|
+
for (const f of files) {
|
|
186
|
+
if (!f.endsWith('.json')) continue;
|
|
187
|
+
const full = path.join(dir, f);
|
|
188
|
+
let meta = null;
|
|
189
|
+
try { meta = sess.readSessionMeta(full); } catch { continue; }
|
|
190
|
+
if (!meta || !meta.id) continue;
|
|
191
|
+
out.push({
|
|
192
|
+
id: String(meta.id),
|
|
193
|
+
title: String(meta.title || ''),
|
|
194
|
+
workspace: String(meta.workspace || ''),
|
|
195
|
+
updatedAt: Number(meta.updatedAt || meta.createdAt || 0),
|
|
196
|
+
rounds: Number(meta.rounds || 0),
|
|
197
|
+
steps: Number(meta.steps || 0),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
out.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A saved session rendered in the SAME payload shape web.js serves for a live
|
|
206
|
+
* one — `{ rows, status, events }` — so the browser needs no second renderer.
|
|
207
|
+
*
|
|
208
|
+
* Read-only by construction: no process is running, so there is nothing that
|
|
209
|
+
* could action a request. `status.readOnly` is what the UI keys its banner off,
|
|
210
|
+
* and it is also what makes the actions refuse rather than silently do nothing.
|
|
211
|
+
*/
|
|
212
|
+
export function savedSessionState(sessionId, storeDir, opts = {}) {
|
|
213
|
+
const s = sess.loadSession(String(sessionId), storeDir);
|
|
214
|
+
if (!s) return null;
|
|
215
|
+
const msgs = s.messages || [];
|
|
216
|
+
// The WHOLE transcript by default. Serving only a tail made the page start
|
|
217
|
+
// mid-conversation and put a "load older" button in front of the user's own
|
|
218
|
+
// history — the wrong shape, and unnecessary now that the browser keeps only
|
|
219
|
+
// the visible rows mounted (web-public/vlist.js). `limit` is still honoured for
|
|
220
|
+
// callers that explicitly want a bounded tail.
|
|
221
|
+
const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.floor(opts.limit) : 0;
|
|
222
|
+
const start = limit > 0 ? Math.max(0, msgs.length - limit) : 0;
|
|
223
|
+
const slice = start > 0 ? msgs.slice(start) : msgs;
|
|
224
|
+
const rows = [];
|
|
225
|
+
let seq = 0;
|
|
226
|
+
for (const m of slice) {
|
|
227
|
+
// convRowFor returns an ARRAY (an assistant turn expands into its own row
|
|
228
|
+
// plus one row per tool call) — flatten, never spread into an object.
|
|
229
|
+
for (const r of sess.convRowFor(m)) rows.push({ ...r, id: ++seq });
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
rows,
|
|
233
|
+
// How much was left out, so the page can say so instead of silently looking
|
|
234
|
+
// like it starts mid-conversation.
|
|
235
|
+
hidden: start,
|
|
236
|
+
totalMessages: msgs.length,
|
|
237
|
+
status: {
|
|
238
|
+
session: s.id,
|
|
239
|
+
title: s.title || '',
|
|
240
|
+
model: s.model || '',
|
|
241
|
+
provider: '',
|
|
242
|
+
mode: s.mode || 'ask',
|
|
243
|
+
cwd: s.workspace || '',
|
|
244
|
+
plan: !!s.plan,
|
|
245
|
+
busy: false,
|
|
246
|
+
readOnly: true,
|
|
247
|
+
updatedAt: s.updatedAt || s.createdAt || 0,
|
|
248
|
+
rounds: s.rounds || 0,
|
|
249
|
+
steps: s.steps || 0,
|
|
250
|
+
ctxTokens: 0,
|
|
251
|
+
ctxMax: 0,
|
|
252
|
+
ctxPercent: 0,
|
|
253
|
+
},
|
|
254
|
+
events: [],
|
|
255
|
+
readOnly: true,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// Static assets
|
|
261
|
+
|
|
262
|
+
const MIME = {
|
|
263
|
+
'.html': 'text/html; charset=utf-8',
|
|
264
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
265
|
+
'.css': 'text/css; charset=utf-8',
|
|
266
|
+
'.json': 'application/json; charset=utf-8',
|
|
267
|
+
'.svg': 'image/svg+xml',
|
|
268
|
+
'.ico': 'image/x-icon',
|
|
269
|
+
// The self-hosted faces under web-public/fonts. Without these the browser gets
|
|
270
|
+
// `application/octet-stream` and refuses to apply the font.
|
|
271
|
+
'.woff2': 'font/woff2',
|
|
272
|
+
'.woff': 'font/woff',
|
|
273
|
+
'.ttf': 'font/ttf',
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
export function readAsset(urlPath) {
|
|
277
|
+
const rel = String(urlPath || '/').split('?')[0];
|
|
278
|
+
// NOTE: '/' is NOT mapped to index.html here. The daemon serves TWO shells —
|
|
279
|
+
// the directory at '/', a session page under '/s/<id>/' — and picking one for
|
|
280
|
+
// the caller is what silently made the root serve the single-session UI. Every
|
|
281
|
+
// caller names the file it wants.
|
|
282
|
+
if (rel === '/' || rel === '') return null;
|
|
283
|
+
const safeRel = path.normalize(rel).replace(/^([/\\])+/, '');
|
|
284
|
+
const target = path.join(PUBLIC_DIR, safeRel);
|
|
285
|
+
// Containment check on the RESOLVED path: `..%2f..%2fetc/passwd` and friends
|
|
286
|
+
// must not escape the asset directory.
|
|
287
|
+
if (target !== PUBLIC_DIR && !target.startsWith(PUBLIC_DIR + path.sep)) return null;
|
|
288
|
+
try {
|
|
289
|
+
if (!fs.statSync(target).isFile()) return null;
|
|
290
|
+
return {
|
|
291
|
+
body: fs.readFileSync(target),
|
|
292
|
+
type: MIME[path.extname(target).toLowerCase()] || 'application/octet-stream',
|
|
293
|
+
};
|
|
294
|
+
} catch { return null; }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// Proxying one request to a session's own server
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Forward `req` to `http://<host>:<port><targetPath>` and pipe the reply back.
|
|
302
|
+
*
|
|
303
|
+
* SSE needs no special handling beyond NOT buffering: `pipe` forwards each chunk
|
|
304
|
+
* as it arrives, which is exactly what an event stream needs. The session's own
|
|
305
|
+
* server does its own token check, so the daemon presents that token upstream —
|
|
306
|
+
* the browser authenticated once, against the daemon.
|
|
307
|
+
*/
|
|
308
|
+
export function proxyRequest(req, res, targetPath, port, opts = {}) {
|
|
309
|
+
const upstreamHost = opts.host || '127.0.0.1';
|
|
310
|
+
const token = String(opts.token || '');
|
|
311
|
+
return new Promise((resolve) => {
|
|
312
|
+
const headers = { ...req.headers };
|
|
313
|
+
delete headers.host;
|
|
314
|
+
// The body length the browser declared is for the body we are about to pipe
|
|
315
|
+
// through unchanged, so it stays valid — but Node rejects a mismatched one,
|
|
316
|
+
// and the daemon reads some bodies itself. Let the pipe length be recomputed.
|
|
317
|
+
delete headers['content-length'];
|
|
318
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
319
|
+
// Drop the browser's Origin/Referer. The session's own server runs the same
|
|
320
|
+
// CSRF check the daemon just ran, but it compares against ITS host and port —
|
|
321
|
+
// which the browser never used, because it talked to the daemon. Forwarding
|
|
322
|
+
// the header therefore made every state-changing request come back 403.
|
|
323
|
+
// Removing it leaves the upstream with no Origin, which web-auth treats as a
|
|
324
|
+
// token-bearing script (already authenticated here) and allows.
|
|
325
|
+
delete headers.origin;
|
|
326
|
+
delete headers.referer;
|
|
327
|
+
|
|
328
|
+
const proxied = http.request({
|
|
329
|
+
host: upstreamHost,
|
|
330
|
+
port,
|
|
331
|
+
method: req.method,
|
|
332
|
+
path: targetPath,
|
|
333
|
+
headers,
|
|
334
|
+
}, (upstream) => {
|
|
335
|
+
try { res.writeHead(upstream.statusCode || 502, upstream.headers); }
|
|
336
|
+
catch { resolve(false); return; }
|
|
337
|
+
upstream.pipe(res);
|
|
338
|
+
upstream.on('end', () => resolve(true));
|
|
339
|
+
upstream.on('error', () => { try { res.end(); } catch { /* gone */ } resolve(false); });
|
|
340
|
+
});
|
|
341
|
+
proxied.on('error', (e) => {
|
|
342
|
+
if (!res.headersSent) {
|
|
343
|
+
try {
|
|
344
|
+
res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' });
|
|
345
|
+
res.end(JSON.stringify({ ok: false, error: 'session unreachable: ' + e.message }));
|
|
346
|
+
} catch { /* gone */ }
|
|
347
|
+
}
|
|
348
|
+
resolve(false);
|
|
349
|
+
});
|
|
350
|
+
req.pipe(proxied);
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Forward a WebSocket upgrade to an upstream session server and bridge the two
|
|
356
|
+
* raw sockets. The upstream answers its own 101 handshake; when it does, we pipe
|
|
357
|
+
* the client's socket to the upstream's upgraded socket (and the `head` bytes
|
|
358
|
+
* that arrived with the upgrade request).
|
|
359
|
+
*/
|
|
360
|
+
export function proxyUpgrade(req, socket, head, targetPath, port, opts = {}) {
|
|
361
|
+
const upstreamHost = opts.host || '127.0.0.1';
|
|
362
|
+
const token = String(opts.token || '');
|
|
363
|
+
const headers = { ...req.headers };
|
|
364
|
+
delete headers.host;
|
|
365
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
366
|
+
const proxied = http.request({
|
|
367
|
+
host: upstreamHost,
|
|
368
|
+
port,
|
|
369
|
+
method: 'GET',
|
|
370
|
+
path: targetPath,
|
|
371
|
+
headers,
|
|
372
|
+
});
|
|
373
|
+
proxied.on('upgrade', (upstreamRes, upstreamSocket, head2) => {
|
|
374
|
+
// Whatever the upstream decided (101 or a rejection) is replayed to the
|
|
375
|
+
// client, then the sockets are bridged so frames flow both ways.
|
|
376
|
+
const statusLine = 'HTTP/1.1 ' + (upstreamRes.statusCode || 502) + ' ' + (upstreamRes.statusMessage || '') + '\r\n';
|
|
377
|
+
const headerBlock = Object.entries(upstreamRes.headers).map(([k, v]) => `${k}: ${v}\r\n`).join('');
|
|
378
|
+
socket.write(statusLine + headerBlock + '\r\n');
|
|
379
|
+
if (head2 && head2.length) socket.write(head2);
|
|
380
|
+
if (head && head.length) upstreamSocket.write(head);
|
|
381
|
+
|
|
382
|
+
// A bridged socket must NEVER surface an unhandled 'error' — a client that
|
|
383
|
+
// closes mid-stream makes the upstream write fail with ECONNABORTED, and an
|
|
384
|
+
// uncaught error kills the whole daemon (which is what the log showed). Both
|
|
385
|
+
// directions are made safe, and one side closing shuts the other.
|
|
386
|
+
const shut = () => { try { upstreamSocket.destroy(); } catch { /* gone */ } try { socket.destroy(); } catch { /* gone */ } };
|
|
387
|
+
socket.on('error', shut);
|
|
388
|
+
upstreamSocket.on('error', shut);
|
|
389
|
+
socket.on('close', shut);
|
|
390
|
+
upstreamSocket.on('close', shut);
|
|
391
|
+
|
|
392
|
+
upstreamSocket.pipe(socket);
|
|
393
|
+
socket.pipe(upstreamSocket);
|
|
394
|
+
});
|
|
395
|
+
proxied.on('error', () => { try { socket.destroy(); } catch { /* gone */ } });
|
|
396
|
+
proxied.end();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
// The registration pipe
|
|
401
|
+
//
|
|
402
|
+
// One line of JSON per request, one line of JSON per reply — the same shape
|
|
403
|
+
// ci.js uses for its control socket, so there is one protocol idiom in the
|
|
404
|
+
// project rather than two.
|
|
405
|
+
|
|
406
|
+
export function attachRegistryPipe(registry, opts = {}) {
|
|
407
|
+
const pipePath = opts.path || defaultDaemonPipe();
|
|
408
|
+
const onEmpty = typeof opts.onEmpty === 'function' ? opts.onEmpty : () => {};
|
|
409
|
+
const address = typeof opts.address === 'function' ? opts.address : null;
|
|
410
|
+
const onShutdown = typeof opts.onShutdown === 'function' ? opts.onShutdown : null;
|
|
411
|
+
const isPipe = process.platform === 'win32' && pipePath.startsWith('\\\\');
|
|
412
|
+
if (!isPipe) {
|
|
413
|
+
try { fs.mkdirSync(path.dirname(pipePath), { recursive: true }); } catch { /* exists */ }
|
|
414
|
+
try {
|
|
415
|
+
const st = fs.statSync(pipePath);
|
|
416
|
+
if (st.isSocket() || st.isFIFO()) fs.unlinkSync(pipePath);
|
|
417
|
+
} catch { /* nothing to clean */ }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const server = net.createServer((conn) => {
|
|
421
|
+
let buf = '';
|
|
422
|
+
conn.on('data', (d) => {
|
|
423
|
+
buf += d.toString('utf8');
|
|
424
|
+
let i;
|
|
425
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
426
|
+
const line = buf.slice(0, i).trim();
|
|
427
|
+
buf = buf.slice(i + 1);
|
|
428
|
+
if (!line) continue;
|
|
429
|
+
handleLine(registry, line, address).then((res) => {
|
|
430
|
+
try { conn.write(JSON.stringify(res) + '\n'); } catch { /* peer gone */ }
|
|
431
|
+
// The last session leaving means nothing is left to serve. Reported
|
|
432
|
+
// AFTER the reply is written so the client is not left hanging.
|
|
433
|
+
if (res.ok && res.op === 'unregister' && registry.size === 0) onEmpty();
|
|
434
|
+
// Same ordering for shutdown: answer first, then go.
|
|
435
|
+
if (res.ok && res.op === 'shutdown' && onShutdown) onShutdown();
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
conn.on('error', () => { /* client hung up */ });
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
return new Promise((resolve) => {
|
|
443
|
+
server.on('error', (e) => resolve({ server: null, path: pipePath, error: e.message, close() {} }));
|
|
444
|
+
server.listen(pipePath, () => {
|
|
445
|
+
resolve({
|
|
446
|
+
server,
|
|
447
|
+
path: pipePath,
|
|
448
|
+
error: null,
|
|
449
|
+
close() {
|
|
450
|
+
try { server.close(); } catch { /* already closed */ }
|
|
451
|
+
if (!isPipe) { try { fs.unlinkSync(pipePath); } catch { /* already gone */ } }
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function handleLine(registry, line, address) {
|
|
459
|
+
let req;
|
|
460
|
+
try { req = JSON.parse(line); } catch { return { ok: false, error: 'invalid JSON' }; }
|
|
461
|
+
try {
|
|
462
|
+
switch (req.op) {
|
|
463
|
+
case 'register': return { ok: true, op: 'register', session: registry.register(req) };
|
|
464
|
+
case 'touch': return { ok: true, op: 'touch', found: registry.touch(req.id) };
|
|
465
|
+
case 'unregister': registry.unregister(req.id); return { ok: true, op: 'unregister' };
|
|
466
|
+
// The listen address travels with `ping` so a client never has to assume
|
|
467
|
+
// it. With a fixed port that is a convenience; with port 0 it is the only
|
|
468
|
+
// way to learn where the daemon actually landed.
|
|
469
|
+
case 'ping': {
|
|
470
|
+
const addr = address ? address() : {};
|
|
471
|
+
return { ok: true, op: 'ping', sessions: registry.size, host: addr.host, port: addr.port };
|
|
472
|
+
}
|
|
473
|
+
// Ask the daemon to stand down. Used by tests and by an operator who wants
|
|
474
|
+
// the port back; the same trust model as the rest of this pipe, which is a
|
|
475
|
+
// loopback/named-pipe channel only this user can open.
|
|
476
|
+
case 'shutdown': return { ok: true, op: 'shutdown' };
|
|
477
|
+
default: return { ok: false, error: `unknown op: ${req.op}` };
|
|
478
|
+
}
|
|
479
|
+
} catch (e) {
|
|
480
|
+
return { ok: false, error: e.message };
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// ---------------------------------------------------------------------------
|
|
485
|
+
// The daemon
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* @param {object} opts
|
|
489
|
+
* port fixed port (0 = OS-assigned, for tests)
|
|
490
|
+
* host bind address (default 127.0.0.1)
|
|
491
|
+
* token access token (default: the persisted one)
|
|
492
|
+
* storeDir session store directory
|
|
493
|
+
* registry injectable Registry (tests)
|
|
494
|
+
* idleExit exit once the last session deregisters (default: true)
|
|
495
|
+
* onEmpty called instead of exiting when idleExit is false
|
|
496
|
+
*/
|
|
497
|
+
export async function startWebDaemon(opts = {}) {
|
|
498
|
+
const host = opts.host || '127.0.0.1';
|
|
499
|
+
const port = opts.port == null ? 0 : Number(opts.port);
|
|
500
|
+
const token = opts.token || ensureWebToken();
|
|
501
|
+
const storeDir = opts.storeDir || sess.sessionsDir();
|
|
502
|
+
const registry = opts.registry || new Registry();
|
|
503
|
+
const idleExit = opts.idleExit !== false;
|
|
504
|
+
const loopback = isLoopback(host);
|
|
505
|
+
const expectedHosts = [];
|
|
506
|
+
// The bind address decides whether a token is required, matching the HTTP rule:
|
|
507
|
+
// on loopback the daemon is reachable only from this machine (reading config.toml
|
|
508
|
+
// grants the same access), but bound beyond loopback it is on the network and the
|
|
509
|
+
// token is the only thing between it and a shell. The browser carries it as the
|
|
510
|
+
// `hncode_web` cookie; a script passes it as a Bearer token.
|
|
511
|
+
const wsAuthed = (req) => loopback || isAuthed(req, token, COOKIE);
|
|
512
|
+
|
|
513
|
+
let pipe = null;
|
|
514
|
+
let idleTimer = null;
|
|
515
|
+
|
|
516
|
+
const server = http.createServer((req, res) => {
|
|
517
|
+
handle(req, res).catch((e) => {
|
|
518
|
+
try {
|
|
519
|
+
res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
|
|
520
|
+
res.end(JSON.stringify({ ok: false, error: (e && e.message) || String(e) }));
|
|
521
|
+
} catch { /* headers already sent */ }
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// Forward WebSocket upgrades for a live session's /api/ws to that session's own
|
|
526
|
+
// server. Without this the daemon (which does auth + path routing) would drop
|
|
527
|
+
// the WS handshake — a plain http server destroys any upgrade it has no
|
|
528
|
+
// listener for. Route /s/<id>/api/ws like handle() does, then bridge the two
|
|
529
|
+
// sockets.
|
|
530
|
+
server.on('upgrade', (req, socket, head) => {
|
|
531
|
+
try {
|
|
532
|
+
// A WebSocket upgrade bypasses the normal request handler (that is what an
|
|
533
|
+
// upgrade IS), so auth cannot ride on handle(). Mirror the HTTP rule here,
|
|
534
|
+
// or a daemon bound beyond loopback would hand any session's full transcript
|
|
535
|
+
// to anyone who can reach the port.
|
|
536
|
+
if (!wsAuthed(req)) { socket.destroy(); return; }
|
|
537
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
538
|
+
const p = url.pathname;
|
|
539
|
+
const m = /^\/s\/([^/]+)(\/.*)?$/.exec(p);
|
|
540
|
+
if (!m || !m[2].startsWith('/api/ws')) { socket.destroy(); return; }
|
|
541
|
+
const id = decodeURIComponent(m[1]);
|
|
542
|
+
const live = registry.get(id);
|
|
543
|
+
if (!live) { socket.destroy(); return; }
|
|
544
|
+
proxyUpgrade(req, socket, head, m[2], live.port, { token: live.token });
|
|
545
|
+
} catch { socket.destroy(); }
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
function send(res, code, body, headers = {}) {
|
|
549
|
+
res.writeHead(code, { 'content-type': 'text/plain; charset=utf-8', ...headers });
|
|
550
|
+
res.end(body);
|
|
551
|
+
}
|
|
552
|
+
function json(res, code, obj) {
|
|
553
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' });
|
|
554
|
+
res.end(JSON.stringify(obj));
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async function readBody(req, limit = 1 << 20) {
|
|
558
|
+
return new Promise((resolve, reject) => {
|
|
559
|
+
let size = 0; const chunks = [];
|
|
560
|
+
req.on('data', (c) => {
|
|
561
|
+
size += c.length;
|
|
562
|
+
if (size > limit) { reject(new Error('body too large')); req.destroy(); return; }
|
|
563
|
+
chunks.push(c);
|
|
564
|
+
});
|
|
565
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
566
|
+
req.on('error', reject);
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// The Origin check accepts the host the browser actually used, plus localhost
|
|
571
|
+
// on the same port (a user may reach the daemon either way).
|
|
572
|
+
function noteHost(req) {
|
|
573
|
+
const h = String(req.headers.host || '');
|
|
574
|
+
if (h && !expectedHosts.includes(h)) expectedHosts.push(h);
|
|
575
|
+
const bare = h.replace(/:\d+$/, '');
|
|
576
|
+
for (const alt of [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`]) {
|
|
577
|
+
if (!expectedHosts.includes(alt) && (bare === 'localhost' || bare === '127.0.0.1' || bare === '::1')) {
|
|
578
|
+
expectedHosts.push(alt);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function handle(req, res) {
|
|
584
|
+
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
585
|
+
const p = url.pathname;
|
|
586
|
+
const method = (req.method || 'GET').toUpperCase();
|
|
587
|
+
noteHost(req);
|
|
588
|
+
|
|
589
|
+
// --- unauthenticated -----------------------------------------------------
|
|
590
|
+
// Reveals only that a daemon exists and how many sessions it holds.
|
|
591
|
+
if (p === '/healthz') {
|
|
592
|
+
return json(res, 200, { ok: true, service: 'hncode-web-daemon', sessions: registry.size });
|
|
593
|
+
}
|
|
594
|
+
// The login page and its assets have to load before a token exists, and the
|
|
595
|
+
// login script imports the message table. NONE carry session data.
|
|
596
|
+
if (p === '/login' || p === '/login.html' || p === '/login.css'
|
|
597
|
+
|| p === '/login.js' || p === '/i18n.js'
|
|
598
|
+
|| p === '/fonts.css' || p.startsWith('/fonts/')) {
|
|
599
|
+
const asset = readAsset(p === '/login' ? '/login.html' : p);
|
|
600
|
+
if (!asset) return send(res, 404, 'not found');
|
|
601
|
+
return send(res, 200, asset.body, { 'content-type': asset.type, 'cache-control': 'no-store' });
|
|
602
|
+
}
|
|
603
|
+
// POST /api/login { token } -> HttpOnly cookie. The token travels in the
|
|
604
|
+
// BODY, never the query string, so it stays out of logs and history.
|
|
605
|
+
if (p === '/api/login' && method === 'POST') {
|
|
606
|
+
let body = {};
|
|
607
|
+
try { body = JSON.parse((await readBody(req)) || '{}'); } catch { /* handled below */ }
|
|
608
|
+
if (!safeEqual(String(body.token || ''), token)) {
|
|
609
|
+
await new Promise((r) => setTimeout(r, LOGIN_DELAY_MS));
|
|
610
|
+
return json(res, 401, { ok: false, error: 'invalid token' });
|
|
611
|
+
}
|
|
612
|
+
const secure = !loopback && String(req.headers['x-forwarded-proto'] || '') === 'https';
|
|
613
|
+
// A 30-day cookie: the point of a stable token is that the browser does not
|
|
614
|
+
// ask again every day.
|
|
615
|
+
const cookie = [
|
|
616
|
+
`${COOKIE}=${encodeURIComponent(token)}`,
|
|
617
|
+
'HttpOnly', 'SameSite=Strict', 'Path=/',
|
|
618
|
+
secure ? 'Secure' : '',
|
|
619
|
+
`Max-Age=${60 * 60 * 24 * 30}`,
|
|
620
|
+
].filter(Boolean).join('; ');
|
|
621
|
+
return send(res, 200, JSON.stringify({ ok: true }), {
|
|
622
|
+
'content-type': 'application/json; charset=utf-8',
|
|
623
|
+
'set-cookie': cookie,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// --- authentication ------------------------------------------------------
|
|
628
|
+
//
|
|
629
|
+
// A request from THIS machine, addressed to a loopback name, is let straight
|
|
630
|
+
// in without a token. That is the common case — the user starts the UI and
|
|
631
|
+
// opens it in their own browser — and asking for a token there protects
|
|
632
|
+
// nothing: anything that can open this socket already runs as this user and
|
|
633
|
+
// can read the token out of config.toml.
|
|
634
|
+
//
|
|
635
|
+
// Everything else still needs the token, which is the case that matters: a
|
|
636
|
+
// daemon bound beyond loopback, reached from another machine on the network.
|
|
637
|
+
//
|
|
638
|
+
// The rule keys off the BIND ADDRESS, not the client's: bound to a loopback
|
|
639
|
+
// address the daemon is reachable only from this machine, so a token would
|
|
640
|
+
// gate nothing the user does not already have access to. Bound anywhere else
|
|
641
|
+
// it is on the network, and the token is what stands between that network and
|
|
642
|
+
// a shell.
|
|
643
|
+
if (!loopback && !isAuthed(req, token, COOKIE)) {
|
|
644
|
+
if (p.startsWith('/api/')) return json(res, 401, { ok: false, error: 'unauthorized' });
|
|
645
|
+
if (method === 'GET') { res.writeHead(302, { location: '/login' }); return res.end(); }
|
|
646
|
+
return send(res, 401, 'unauthorized');
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// CSRF: a state-changing request must come from a page we served.
|
|
650
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
651
|
+
const ok = sameOriginOk(req, expectedHosts);
|
|
652
|
+
if (ok === false) return json(res, 403, { ok: false, error: 'cross-origin request refused' });
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// --- directory -----------------------------------------------------------
|
|
656
|
+
// One request gives the landing page everything: live sessions from the
|
|
657
|
+
// registry and finished ones read off disk.
|
|
658
|
+
if (p === '/api/sessions') {
|
|
659
|
+
return json(res, 200, {
|
|
660
|
+
ok: true,
|
|
661
|
+
live: registry.list(),
|
|
662
|
+
saved: listSavedSessions(storeDir),
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (p === '/api/daemon') {
|
|
667
|
+
// `actualPort`, not the requested `port`: when the caller asks for 0 the OS
|
|
668
|
+
// picks one, and reporting 0 back would tell a client to connect nowhere.
|
|
669
|
+
return json(res, 200, {
|
|
670
|
+
ok: true,
|
|
671
|
+
host,
|
|
672
|
+
port: actualPort,
|
|
673
|
+
token,
|
|
674
|
+
heartbeatMs: HEARTBEAT_MS,
|
|
675
|
+
pid: process.pid,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// --- session-scoped ------------------------------------------------------
|
|
680
|
+
// /s/<id>/... — proxied to the live session, or served read-only from disk.
|
|
681
|
+
// The prefix is STRIPPED before forwarding, so the session's own server sees
|
|
682
|
+
// exactly the paths it always has and needs no changes.
|
|
683
|
+
const m = /^\/s\/([^/]+)(\/.*)?$/.exec(p);
|
|
684
|
+
if (m) {
|
|
685
|
+
const id = decodeURIComponent(m[1]);
|
|
686
|
+
// `/s/<id>` must become `/s/<id>/` before anything is served. The session
|
|
687
|
+
// page loads its assets and calls its API through RELATIVE paths (so that
|
|
688
|
+
// the same files work both standalone and behind this prefix); without the
|
|
689
|
+
// trailing slash the browser resolves `api/state` against `/s/` and every
|
|
690
|
+
// request misses. Redirecting once is cheaper and more reliable than
|
|
691
|
+
// rewriting every path in the HTML and JS.
|
|
692
|
+
if (m[2] === undefined) {
|
|
693
|
+
res.writeHead(302, { location: `/s/${encodeURIComponent(id)}/${url.search || ''}` });
|
|
694
|
+
return res.end();
|
|
695
|
+
}
|
|
696
|
+
const rest = m[2] + (url.search || '');
|
|
697
|
+
const live = registry.get(id);
|
|
698
|
+
if (live) {
|
|
699
|
+
// A plain GET of the session root is the page itself: mark it live so the
|
|
700
|
+
// UI enables the composer.
|
|
701
|
+
return proxyRequest(req, res, rest, live.port, { host: '127.0.0.1', token: live.token });
|
|
702
|
+
}
|
|
703
|
+
return serveSaved(res, id, rest, method, storeDir);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// --- the landing page ----------------------------------------------------
|
|
707
|
+
// Serve the SPA shell for any other GET so the client-side router works on a
|
|
708
|
+
// deep link (`/d/<ws>` etc.). Assets are matched by extension so a missing
|
|
709
|
+
// file 404s instead of silently returning HTML.
|
|
710
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
711
|
+
const asset = readAsset(p);
|
|
712
|
+
if (asset) return send(res, 200, asset.body, { 'content-type': asset.type, 'cache-control': 'no-store' });
|
|
713
|
+
if (p !== '/' && path.extname(p)) return send(res, 404, 'not found');
|
|
714
|
+
// The directory page is the daemon's root. A session page is only ever
|
|
715
|
+
// served under /s/<id>/ (see above), so this shell must NOT be index.html —
|
|
716
|
+
// that is the single-session UI and it would call a relative `api/state`
|
|
717
|
+
// against the daemon's root, which has no such endpoint.
|
|
718
|
+
const shell = readAsset('/home.html');
|
|
719
|
+
if (!shell) return send(res, 500, 'web assets missing');
|
|
720
|
+
return send(res, 200, shell.body, { 'content-type': shell.type, 'cache-control': 'no-store' });
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return json(res, 404, { ok: false, error: 'not found' });
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Serve a session whose TUI has exited, from its file on disk.
|
|
729
|
+
*
|
|
730
|
+
* Only the paths a live session's own server answers are answered (`/api/state`,
|
|
731
|
+
* `/api/events`, `/api/sessions` and the page + its assets); everything that
|
|
732
|
+
* would CHANGE something is refused with a clear reason. A read-only viewer that
|
|
733
|
+
* silently accepted a prompt would be worse than one that says it cannot.
|
|
734
|
+
*/
|
|
735
|
+
function serveSaved(res, id, rest, method, storeDir2) {
|
|
736
|
+
const clean = rest.split('?')[0];
|
|
737
|
+
// `?limit=N` bounds how much of a long transcript is sent. It defaults to
|
|
738
|
+
// savedSessionState's own default; a caller can ask for more, but not for
|
|
739
|
+
// "everything" — the whole point is that the payload stays bounded.
|
|
740
|
+
const qs = rest.includes('?') ? new URLSearchParams(rest.slice(rest.indexOf('?') + 1)) : null;
|
|
741
|
+
const limitArg = qs && qs.get('limit');
|
|
742
|
+
const limit = limitArg && /^\d+$/.test(limitArg) ? Number(limitArg) : undefined;
|
|
743
|
+
if (clean === '/api/state') {
|
|
744
|
+
const state = savedSessionState(id, storeDir2, { limit });
|
|
745
|
+
if (!state) return json(res, 404, { ok: false, error: 'no such session' });
|
|
746
|
+
return json(res, 200, { ok: true, ...state });
|
|
747
|
+
}
|
|
748
|
+
// The session page's left rail asks for the directory. A LIVE session answers
|
|
749
|
+
// this from its own web.js; a finished one has no process to ask, so the
|
|
750
|
+
// daemon answers here. Without it the rail came up empty on exactly the pages
|
|
751
|
+
// that are served from disk — the header said "no other sessions" and the
|
|
752
|
+
// list the user just clicked in was gone.
|
|
753
|
+
if (clean === '/api/sessions') {
|
|
754
|
+
const live = registry.list().map((s) => ({
|
|
755
|
+
id: s.id, title: s.title, workspace: s.workspace, updatedAt: s.at,
|
|
756
|
+
}));
|
|
757
|
+
const liveIds = new Set(live.map((s) => s.id));
|
|
758
|
+
const saved = listSavedSessions(storeDir2).filter((s) => !liveIds.has(s.id));
|
|
759
|
+
return json(res, 200, { ok: true, current: id, sessions: [...live, ...saved] });
|
|
760
|
+
}
|
|
761
|
+
if (clean === '/api/events') {
|
|
762
|
+
// A finished session has no events to stream. Answer with a well-formed
|
|
763
|
+
// stream that immediately reports the (empty) snapshot and stays open, so
|
|
764
|
+
// the client's EventSource does not spin on reconnects.
|
|
765
|
+
res.writeHead(200, {
|
|
766
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
767
|
+
'cache-control': 'no-cache',
|
|
768
|
+
connection: 'keep-alive',
|
|
769
|
+
});
|
|
770
|
+
const state = savedSessionState(id, storeDir2) || { rows: [], status: {}, events: [] };
|
|
771
|
+
res.write(`data: ${JSON.stringify({ type: 'snapshot', ...state, readOnly: true })}\n\n`);
|
|
772
|
+
return undefined;
|
|
773
|
+
}
|
|
774
|
+
if (clean === '/api/action') {
|
|
775
|
+
return json(res, 409, {
|
|
776
|
+
ok: false,
|
|
777
|
+
error: 'this session has exited — its transcript is read-only',
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
// Assets come from the same web-public directory as everything else.
|
|
781
|
+
const asset = readAsset(clean === '/' ? '/index.html' : clean);
|
|
782
|
+
if (asset) return send(res, 200, asset.body, { 'content-type': asset.type, 'cache-control': 'no-store' });
|
|
783
|
+
if (method === 'GET' && !path.extname(clean)) {
|
|
784
|
+
const shell = readAsset('/index.html');
|
|
785
|
+
if (shell) return send(res, 200, shell.body, { 'content-type': shell.type });
|
|
786
|
+
}
|
|
787
|
+
return send(res, 404, 'not found');
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ---- bind ---------------------------------------------------------------
|
|
791
|
+
await new Promise((resolve, reject) => {
|
|
792
|
+
server.once('error', reject);
|
|
793
|
+
server.listen(port, host, resolve);
|
|
794
|
+
});
|
|
795
|
+
const actualPort = server.address().port;
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Stand down once nothing is left to serve.
|
|
799
|
+
*
|
|
800
|
+
* Called from two places, and they are NOT the same event:
|
|
801
|
+
* * a session deregisters cleanly (its /web off, or the TUI exiting through
|
|
802
|
+
* its normal shutdown path);
|
|
803
|
+
* * the sweep drops a session that never deregistered because its process
|
|
804
|
+
* was killed outright — a closed terminal window, a crash, Task Manager.
|
|
805
|
+
* The second case is why this cannot live only in the deregister handler: a
|
|
806
|
+
* daemon started on this machine outlived the TUI that spawned it exactly
|
|
807
|
+
* because nothing re-checked emptiness after a sweep.
|
|
808
|
+
*
|
|
809
|
+
* The short delay lets the triggering reply flush before the listener goes.
|
|
810
|
+
*/
|
|
811
|
+
function goIdleIfEmpty() {
|
|
812
|
+
if (registry.size > 0) return;
|
|
813
|
+
if (!idleExit) {
|
|
814
|
+
if (typeof opts.onEmpty === 'function') opts.onEmpty();
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
clearTimeout(idleTimer);
|
|
818
|
+
idleTimer = setTimeout(() => {
|
|
819
|
+
if (registry.size === 0) close();
|
|
820
|
+
}, 1500);
|
|
821
|
+
if (idleTimer.unref) idleTimer.unref();
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ---- registration pipe --------------------------------------------------
|
|
825
|
+
pipe = await attachRegistryPipe(registry, {
|
|
826
|
+
path: opts.pipePath,
|
|
827
|
+
// The address is reported on `ping` so a client never has to guess it. When
|
|
828
|
+
// the caller asked for port 0 the OS picked one, and a client that assumed
|
|
829
|
+
// its configured port would connect to nothing.
|
|
830
|
+
address: () => ({ host, port: actualPort }),
|
|
831
|
+
onShutdown: () => {
|
|
832
|
+
// Let the reply reach the caller before the listeners go away.
|
|
833
|
+
setTimeout(() => close(), 60);
|
|
834
|
+
},
|
|
835
|
+
onEmpty: goIdleIfEmpty,
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
// A process killed outright never deregisters (no terminal to run the exit
|
|
839
|
+
// handler), so the sweep is what notices it. Dropping the entry is not enough
|
|
840
|
+
// on its own — the daemon also has to re-check whether anything is left.
|
|
841
|
+
const sweepTimer = setInterval(() => {
|
|
842
|
+
const gone = registry.sweep();
|
|
843
|
+
if (!gone.length) return;
|
|
844
|
+
if (typeof opts.onSweep === 'function') opts.onSweep(gone);
|
|
845
|
+
goIdleIfEmpty();
|
|
846
|
+
}, SWEEP_MS);
|
|
847
|
+
if (sweepTimer.unref) sweepTimer.unref();
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
let closed = false;
|
|
851
|
+
function close() {
|
|
852
|
+
if (closed) return;
|
|
853
|
+
closed = true;
|
|
854
|
+
clearInterval(sweepTimer);
|
|
855
|
+
clearTimeout(idleTimer);
|
|
856
|
+
try { server.close(); } catch { /* already closed */ }
|
|
857
|
+
if (pipe) pipe.close();
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
return {
|
|
861
|
+
server,
|
|
862
|
+
registry,
|
|
863
|
+
port: actualPort,
|
|
864
|
+
host,
|
|
865
|
+
token,
|
|
866
|
+
url: `http://${host}:${actualPort}/`,
|
|
867
|
+
pipePath: pipe && pipe.path,
|
|
868
|
+
pipeError: pipe && pipe.error,
|
|
869
|
+
close,
|
|
870
|
+
};
|
|
871
|
+
}
|