agim-cli 1.0.1 → 1.0.3
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/CHANGELOG.md +58 -0
- package/dist/cli.js +22 -12
- package/dist/cli.js.map +1 -1
- package/dist/core/acp-server.d.ts.map +1 -1
- package/dist/core/acp-server.js +21 -4
- package/dist/core/acp-server.js.map +1 -1
- package/dist/core/onboarding.d.ts.map +1 -1
- package/dist/core/onboarding.js +8 -6
- package/dist/core/onboarding.js.map +1 -1
- package/dist/web/public/_app.js +5 -12
- package/dist/web/public/index.html +6 -6
- package/dist/web/public/memos.html +1 -1
- package/dist/web/public/reminders.html +1 -1
- package/dist/web/public/settings.html +9 -11
- package/dist/web/public/tasks.html +8 -19
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +14 -159
- package/dist/web/server.js.map +1 -1
- package/package.json +1 -1
- package/dist/web/public/login.html +0 -106
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<title>
|
|
6
|
+
<title>Agim — Settings</title>
|
|
7
7
|
<!-- Shared utilities: theme manager (applies before first paint), error
|
|
8
8
|
boundary (surfaces silent script failures), i18n + api helpers. -->
|
|
9
9
|
<script src="/_app.js"></script>
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
|
|
17
17
|
const T = {
|
|
18
18
|
en: {
|
|
19
|
-
title: '
|
|
19
|
+
title: 'Agim — Settings',
|
|
20
20
|
backToChat: 'Back to Chat',
|
|
21
|
-
settingsTitle: '
|
|
21
|
+
settingsTitle: 'Agim Settings',
|
|
22
22
|
loading: 'Loading configuration...',
|
|
23
23
|
loadFailed: 'Failed to load config',
|
|
24
24
|
h1: 'Settings',
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
messengers: 'Messengers (Channels)',
|
|
29
29
|
wechat: 'WeChat',
|
|
30
30
|
wechatHint: 'iLink QR scan login',
|
|
31
|
-
wechatBox: 'WeChat requires QR scan login. Run <code>
|
|
31
|
+
wechatBox: 'WeChat requires QR scan login. Run <code>agim config wechat</code> in terminal to set up.',
|
|
32
32
|
telegram: 'Telegram',
|
|
33
33
|
telegramHint: 'Bot token from @BotFather',
|
|
34
34
|
botToken: 'Bot Token',
|
|
@@ -91,9 +91,9 @@
|
|
|
91
91
|
workspaceDefaultLocked: 'default (locked)',
|
|
92
92
|
},
|
|
93
93
|
zh: {
|
|
94
|
-
title: '
|
|
94
|
+
title: 'Agim — 设置',
|
|
95
95
|
backToChat: '返回对话',
|
|
96
|
-
settingsTitle: '
|
|
96
|
+
settingsTitle: 'Agim 设置',
|
|
97
97
|
loading: '加载配置中...',
|
|
98
98
|
loadFailed: '加载配置失败',
|
|
99
99
|
h1: '设置',
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
messengers: '消息通道 (Channels)',
|
|
104
104
|
wechat: '微信',
|
|
105
105
|
wechatHint: 'iLink 扫码登录',
|
|
106
|
-
wechatBox: '微信需要扫码登录。在终端运行 <code>
|
|
106
|
+
wechatBox: '微信需要扫码登录。在终端运行 <code>agim config wechat</code> 进行配置。',
|
|
107
107
|
telegram: 'Telegram',
|
|
108
108
|
telegramHint: '从 @BotFather 获取 Bot Token',
|
|
109
109
|
botToken: 'Bot Token',
|
|
@@ -562,12 +562,10 @@
|
|
|
562
562
|
'opencode': { aliases: ['oc'], pkg: 'opencode-ai', cmd: 'opencode' },
|
|
563
563
|
};
|
|
564
564
|
|
|
565
|
-
// Helper:
|
|
566
|
-
//
|
|
565
|
+
// Helper: API requests share the page origin so the session cookie
|
|
566
|
+
// (if any reverse-proxy added one) is sent automatically.
|
|
567
567
|
function authFetch(url, init = {}) {
|
|
568
|
-
const token = window.IMHUB_TOKEN || '';
|
|
569
568
|
const headers = { ...(init.headers || {}) };
|
|
570
|
-
if (token) headers['X-IM-Hub-Token'] = token;
|
|
571
569
|
return fetch(url, { ...init, headers, credentials: 'same-origin' });
|
|
572
570
|
}
|
|
573
571
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
-
<title>
|
|
6
|
+
<title>Agim — Tasks</title>
|
|
7
7
|
<!-- Shared utilities: theme manager applies before first paint, error
|
|
8
8
|
boundary surfaces silent script failures, i18n + api helpers are
|
|
9
9
|
used by the page-specific script below. -->
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
const T = {
|
|
26
26
|
en: {
|
|
27
|
-
title: '
|
|
27
|
+
title: 'Agim — Tasks',
|
|
28
28
|
h1: 'Tasks & Schedules',
|
|
29
29
|
backToChat: 'Chat',
|
|
30
30
|
toSettings: 'Settings',
|
|
@@ -151,7 +151,7 @@
|
|
|
151
151
|
jobsBatchResult: 'Batch result: {ok} ok, {fail} failed',
|
|
152
152
|
},
|
|
153
153
|
zh: {
|
|
154
|
-
title: '
|
|
154
|
+
title: 'Agim — 任务',
|
|
155
155
|
h1: '任务与定时',
|
|
156
156
|
backToChat: '对话',
|
|
157
157
|
toSettings: '设置',
|
|
@@ -697,7 +697,6 @@
|
|
|
697
697
|
|
|
698
698
|
<script>
|
|
699
699
|
const T = window.__t;
|
|
700
|
-
const TOKEN = window.IMHUB_TOKEN || '';
|
|
701
700
|
|
|
702
701
|
// Theme toggle (light / dark / system). _app.js applied the theme
|
|
703
702
|
// synchronously in <head>; here we wire the button so clicks cycle
|
|
@@ -773,7 +772,6 @@
|
|
|
773
772
|
// API helper
|
|
774
773
|
async function api(path, init) {
|
|
775
774
|
const headers = { 'Content-Type': 'application/json', ...(init?.headers) };
|
|
776
|
-
if (TOKEN) headers['X-IM-Hub-Token'] = TOKEN;
|
|
777
775
|
const res = await fetch(path, { ...init, headers, credentials: 'same-origin' });
|
|
778
776
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
779
777
|
return res.json();
|
|
@@ -961,10 +959,8 @@
|
|
|
961
959
|
|
|
962
960
|
// New job modal
|
|
963
961
|
document.getElementById('btn-new').onclick = async () => {
|
|
964
|
-
const agentHeaders = {};
|
|
965
|
-
if (TOKEN) agentHeaders['X-IM-Hub-Token'] = TOKEN;
|
|
966
962
|
const agentsRes = await fetch('/api/agents/status', {
|
|
967
|
-
|
|
963
|
+
credentials: 'same-origin',
|
|
968
964
|
}).then(r => r.json());
|
|
969
965
|
const agents = Object.keys(agentsRes);
|
|
970
966
|
|
|
@@ -1528,10 +1524,8 @@
|
|
|
1528
1524
|
if (filesAgentsLoaded) return;
|
|
1529
1525
|
filesAgentsLoaded = true;
|
|
1530
1526
|
try {
|
|
1531
|
-
const fHeaders = {};
|
|
1532
|
-
if (TOKEN) fHeaders['X-IM-Hub-Token'] = TOKEN;
|
|
1533
1527
|
const agentsRes = await fetch('/api/agents/status', {
|
|
1534
|
-
|
|
1528
|
+
credentials: 'same-origin',
|
|
1535
1529
|
}).then(r => r.json());
|
|
1536
1530
|
const names = Object.keys(agentsRes);
|
|
1537
1531
|
const sel = document.getElementById('files-agent');
|
|
@@ -1694,11 +1688,10 @@
|
|
|
1694
1688
|
// structured error body (e.g. "Content exceeds 1 MiB cap")
|
|
1695
1689
|
// rather than just the HTTP statusText.
|
|
1696
1690
|
const qs = new URLSearchParams({ agent: data.agent, path: data.path });
|
|
1697
|
-
const wHeaders = { 'Content-Type': 'application/json' };
|
|
1698
|
-
if (TOKEN) wHeaders['X-IM-Hub-Token'] = TOKEN;
|
|
1699
1691
|
const res = await fetch(`/api/workspace-files?${qs.toString()}`, {
|
|
1700
1692
|
method: 'PUT',
|
|
1701
|
-
headers:
|
|
1693
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1694
|
+
credentials: 'same-origin',
|
|
1702
1695
|
body: JSON.stringify({ content: newContent }),
|
|
1703
1696
|
});
|
|
1704
1697
|
if (!res.ok) {
|
|
@@ -1753,11 +1746,7 @@
|
|
|
1753
1746
|
function setupSSE() {
|
|
1754
1747
|
try {
|
|
1755
1748
|
if (evtSource) try { evtSource.close(); } catch {}
|
|
1756
|
-
|
|
1757
|
-
// also include the token in the query if available (EventSource
|
|
1758
|
-
// does not support custom headers).
|
|
1759
|
-
const sseUrl = TOKEN ? `/events?token=${encodeURIComponent(TOKEN)}` : '/events';
|
|
1760
|
-
evtSource = new EventSource(sseUrl, { withCredentials: true });
|
|
1749
|
+
evtSource = new EventSource('/events', { withCredentials: true });
|
|
1761
1750
|
} catch (err) {
|
|
1762
1751
|
console.warn('[sse] init failed', err);
|
|
1763
1752
|
return;
|
package/dist/web/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/web/server.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/web/server.ts"],"names":[],"mappings":"AAqDA,wBAAgB,iBAAiB,IAAI,CAAC,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,KAAK,IAAI,CAOrE;AAED;;GAEG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE;IAC5C,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,YAAY,EAAE,MAAM,CAAA;CACrB,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAmlB/C"}
|
package/dist/web/server.js
CHANGED
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
// Web chat server — HTTP + WebSocket for browser-based agent interaction
|
|
2
2
|
import { createServer } from 'node:http';
|
|
3
|
-
import { readFileSync, existsSync
|
|
3
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
4
4
|
import { readdir, stat, readFile, writeFile, rename, unlink, realpath, open } from 'node:fs/promises';
|
|
5
5
|
import { join, dirname, resolve as resolvePath, sep as pathSep, relative as relativePath } from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import {
|
|
8
|
-
import { randomBytes, createHmac, timingSafeEqual } from 'node:crypto';
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
9
8
|
import { WebSocketServer } from 'ws';
|
|
10
9
|
import { parseMessage, routeMessage } from '../core/router.js';
|
|
11
10
|
import { sessionManager } from '../core/session.js';
|
|
12
11
|
import { registry } from '../core/registry.js';
|
|
13
12
|
import { generateTraceId, createLogger, logger as rootLogger } from '../core/logger.js';
|
|
14
13
|
import { validateConfig } from '../core/config-schema.js';
|
|
15
|
-
import { safeEqual } from '../utils/safe-equal.js';
|
|
16
14
|
import { consumeToken, peekToken } from '../core/location-token.js';
|
|
17
15
|
import { createMemo, updateMemo, getMemo, mapUrls } from '../core/memos.js';
|
|
18
16
|
import { normalizeIncomingCoords } from '../core/coord-systems.js';
|
|
@@ -31,22 +29,6 @@ import { isAgentAvailableCached, loadConfig, saveConfig, } from '../core/onboard
|
|
|
31
29
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
32
30
|
const PUBLIC_DIR = join(__dirname, 'public');
|
|
33
31
|
const DEFAULT_PORT = 3000;
|
|
34
|
-
const WEB_TOKEN_DIR = AGIM_HOME;
|
|
35
|
-
const WEB_TOKEN_FILE = join(WEB_TOKEN_DIR, 'web-token');
|
|
36
|
-
function generateToken() {
|
|
37
|
-
return randomBytes(32).toString('hex');
|
|
38
|
-
}
|
|
39
|
-
function getOrCreateWebToken() {
|
|
40
|
-
try {
|
|
41
|
-
return readFileSync(WEB_TOKEN_FILE, 'utf-8').trim();
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
const token = generateToken();
|
|
45
|
-
mkdirSync(WEB_TOKEN_DIR, { recursive: true });
|
|
46
|
-
writeFileSync(WEB_TOKEN_FILE, token, { mode: 0o600 });
|
|
47
|
-
return token;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
32
|
function isMasked(value) {
|
|
51
33
|
if (!value)
|
|
52
34
|
return false;
|
|
@@ -66,130 +48,31 @@ export function createSerialQueue() {
|
|
|
66
48
|
export async function startWebServer(options) {
|
|
67
49
|
const port = options.port || DEFAULT_PORT;
|
|
68
50
|
const bindHost = process.env.IMHUB_WEB_BIND || '127.0.0.1';
|
|
69
|
-
const webToken = getOrCreateWebToken();
|
|
70
51
|
const clients = new Map();
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
// For 0.0.0.0 / LAN / WAN binds we keep the token requirement so a
|
|
76
|
-
// copy-pasted IMHUB_WEB_BIND=0.0.0.0 doesn't expose memos/reminders/config.
|
|
77
|
-
//
|
|
78
|
-
// Escape hatches via env:
|
|
79
|
-
// IMHUB_WEB_REQUIRE_AUTH=1 — force token even on localhost (legacy / paranoid)
|
|
80
|
-
// IMHUB_WEB_REQUIRE_AUTH=0 — force NO token even on public bind (you know
|
|
81
|
-
// what you're doing — proxy + HTTPS + external auth)
|
|
52
|
+
// Agim's web console intentionally ships with NO built-in auth — neither
|
|
53
|
+
// for localhost nor for public binds. Operators putting it on 0.0.0.0 are
|
|
54
|
+
// responsible for fronting it with a reverse proxy / firewall / SSH tunnel
|
|
55
|
+
// / VPN of their choice. See README "Web console exposure".
|
|
82
56
|
const isPublicBind = bindHost !== '127.0.0.1' && bindHost !== '::1' && bindHost !== 'localhost';
|
|
83
|
-
const requireAuth = (() => {
|
|
84
|
-
const forced = process.env.IMHUB_WEB_REQUIRE_AUTH;
|
|
85
|
-
if (forced === '1')
|
|
86
|
-
return true;
|
|
87
|
-
if (forced === '0')
|
|
88
|
-
return false;
|
|
89
|
-
return isPublicBind;
|
|
90
|
-
})();
|
|
91
|
-
// Per-process secret for signing session cookies. Not persisted — browser
|
|
92
|
-
// sessions expire on restart, which is acceptable for a dev tool.
|
|
93
|
-
const cookieSecret = randomBytes(32).toString('hex');
|
|
94
|
-
const COOKIE_NAME = 'imhub_session';
|
|
95
|
-
function makeSessionCookie() {
|
|
96
|
-
return createHmac('sha256', cookieSecret).update(webToken).digest('hex');
|
|
97
|
-
}
|
|
98
|
-
function isValidSessionCookie(cookie) {
|
|
99
|
-
const expected = makeSessionCookie();
|
|
100
|
-
if (cookie.length !== expected.length)
|
|
101
|
-
return false;
|
|
102
|
-
try {
|
|
103
|
-
return timingSafeEqual(Buffer.from(cookie, 'utf8'), Buffer.from(expected, 'utf8'));
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
return false;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
function parseCookies(req) {
|
|
110
|
-
const hdr = req.headers.cookie || '';
|
|
111
|
-
const out = {};
|
|
112
|
-
for (const pair of hdr.split(';')) {
|
|
113
|
-
const eq = pair.indexOf('=');
|
|
114
|
-
if (eq < 0)
|
|
115
|
-
continue;
|
|
116
|
-
out[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim();
|
|
117
|
-
}
|
|
118
|
-
return out;
|
|
119
|
-
}
|
|
120
|
-
function hasValidSession(req) {
|
|
121
|
-
const cookies = parseCookies(req);
|
|
122
|
-
return isValidSessionCookie(cookies[COOKIE_NAME] || '');
|
|
123
|
-
}
|
|
124
|
-
function setSessionCookie(req, res) {
|
|
125
|
-
const val = makeSessionCookie();
|
|
126
|
-
const parts = [`${COOKIE_NAME}=${val}`, 'HttpOnly', 'SameSite=Strict', 'Path=/'];
|
|
127
|
-
// Mark Secure when we have any signal we're served over TLS:
|
|
128
|
-
// - public bind (assume reverse proxy will/should terminate TLS)
|
|
129
|
-
// - X-Forwarded-Proto: https from a trusted reverse proxy
|
|
130
|
-
// For pure localhost dev (no proxy) we omit Secure so plain HTTP works.
|
|
131
|
-
const isPublicBind = bindHost !== '127.0.0.1' && bindHost !== '::1' && bindHost !== 'localhost';
|
|
132
|
-
const xfp = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
|
|
133
|
-
if (isPublicBind || xfp === 'https')
|
|
134
|
-
parts.push('Secure');
|
|
135
|
-
res.setHeader('Set-Cookie', parts.join('; '));
|
|
136
|
-
}
|
|
137
57
|
if (isPublicBind) {
|
|
138
58
|
webLog.warn({
|
|
139
59
|
event: 'web.public_bind_warning',
|
|
140
60
|
bind: bindHost,
|
|
141
|
-
|
|
142
|
-
}, requireAuth
|
|
143
|
-
? 'Web server bound to a non-localhost address — token auth REQUIRED; front it with a reverse proxy that terminates TLS'
|
|
144
|
-
: 'Web server bound publicly with IMHUB_WEB_REQUIRE_AUTH=0 — caller assumes responsibility for upstream auth');
|
|
61
|
+
}, 'Web server bound to a non-localhost address with NO built-in auth — front it with a reverse proxy / firewall / VPN');
|
|
145
62
|
}
|
|
146
63
|
webLog.info({
|
|
147
64
|
event: 'web.auth_mode',
|
|
148
65
|
bind: bindHost,
|
|
149
|
-
|
|
150
|
-
}, requireAuth
|
|
151
|
-
? 'Web console: token auth REQUIRED (login page at /login)'
|
|
152
|
-
: 'Web console: token auth DISABLED (localhost bind — open access)');
|
|
66
|
+
}, 'Web console: open access (no built-in auth — access control is upstream)');
|
|
153
67
|
// HTTP request handler — static files + REST API
|
|
154
68
|
const httpServer = createServer(async (req, res) => {
|
|
155
69
|
const url = new URL(req.url || '/', `http://localhost:${port}`);
|
|
156
|
-
//
|
|
157
|
-
if (url.pathname === '/login' || url.pathname === '/login.html') {
|
|
158
|
-
return serveStatic(res, join(PUBLIC_DIR, 'login.html'), 'text/html; charset=utf-8');
|
|
159
|
-
}
|
|
160
|
-
// POST /api/auth/login — validate token, set session cookie
|
|
161
|
-
if (url.pathname === '/api/auth/login' && req.method === 'POST') {
|
|
162
|
-
const body = await readBody(req, res);
|
|
163
|
-
let parsed;
|
|
164
|
-
try {
|
|
165
|
-
parsed = JSON.parse(body);
|
|
166
|
-
}
|
|
167
|
-
catch {
|
|
168
|
-
sendJson(res, 400, { error: 'Invalid JSON' });
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
if (typeof parsed.token === 'string' && safeEqual(parsed.token, webToken)) {
|
|
172
|
-
setSessionCookie(req, res);
|
|
173
|
-
sendJson(res, 200, { ok: true });
|
|
174
|
-
}
|
|
175
|
-
else {
|
|
176
|
-
sendJson(res, 401, { error: 'Invalid token' });
|
|
177
|
-
}
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
// Static pages — gated by session cookie ONLY when requireAuth is on
|
|
181
|
-
// (i.e. the server is bound publicly). Localhost binds skip the gate
|
|
182
|
-
// entirely so small users don't need to know what a token is.
|
|
70
|
+
// Static pages — direct serve, no auth gate.
|
|
183
71
|
if (url.pathname === '/' || url.pathname === '/index.html' ||
|
|
184
72
|
url.pathname === '/settings' || url.pathname === '/settings.html' ||
|
|
185
73
|
url.pathname === '/tasks' || url.pathname === '/tasks.html' ||
|
|
186
74
|
url.pathname === '/reminders' || url.pathname === '/reminders.html' ||
|
|
187
75
|
url.pathname === '/memos' || url.pathname === '/memos.html') {
|
|
188
|
-
if (requireAuth && !hasValidSession(req)) {
|
|
189
|
-
res.writeHead(302, { Location: `/login?next=${encodeURIComponent(url.pathname)}` });
|
|
190
|
-
res.end();
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
76
|
const fileMap = {
|
|
194
77
|
'/': 'index.html', '/index.html': 'index.html',
|
|
195
78
|
'/settings': 'settings.html', '/settings.html': 'settings.html',
|
|
@@ -370,17 +253,7 @@ export async function startWebServer(options) {
|
|
|
370
253
|
sendJson(res, 200, { ok: true, id: memoId, lat, lng });
|
|
371
254
|
return;
|
|
372
255
|
}
|
|
373
|
-
// REST API —
|
|
374
|
-
// requireAuth is on. Localhost binds let the call through; same
|
|
375
|
-
// trust model as the static pages above.
|
|
376
|
-
if (requireAuth && url.pathname.startsWith('/api/')) {
|
|
377
|
-
const token = req.headers['x-im-hub-token'] || '';
|
|
378
|
-
if (!safeEqual(token, webToken) && !hasValidSession(req)) {
|
|
379
|
-
res.writeHead(401);
|
|
380
|
-
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
381
|
-
return;
|
|
382
|
-
}
|
|
383
|
-
}
|
|
256
|
+
// REST API — open access (no built-in auth). See top-of-function note.
|
|
384
257
|
// REST API
|
|
385
258
|
if (url.pathname === '/api/config' && req.method === 'GET') {
|
|
386
259
|
return handleGetConfig(req, res);
|
|
@@ -518,21 +391,11 @@ export async function startWebServer(options) {
|
|
|
518
391
|
return handleBatchJob(req, res, 'run', options.defaultAgent);
|
|
519
392
|
}
|
|
520
393
|
// PR-C: SSE event stream — audit / approval / job / metrics events
|
|
521
|
-
// pushed real-time so the dashboard stops polling.
|
|
522
|
-
//
|
|
523
|
-
// the WS upgrade uses). Auth is validated inside the handler since
|
|
524
|
-
// /events is outside the /api/* token gate above.
|
|
394
|
+
// pushed real-time so the dashboard stops polling. Open access; same
|
|
395
|
+
// trust model as the REST API.
|
|
525
396
|
if (url.pathname === '/events' && req.method === 'GET') {
|
|
526
|
-
const evToken = url.searchParams.get('token') || '';
|
|
527
|
-
if (requireAuth && !safeEqual(evToken, webToken) && !hasValidSession(req)) {
|
|
528
|
-
res.writeHead(401, { 'Content-Type': 'text/plain' });
|
|
529
|
-
res.end('Unauthorized');
|
|
530
|
-
return;
|
|
531
|
-
}
|
|
532
397
|
return handleEventsSSE(req, res);
|
|
533
398
|
}
|
|
534
|
-
// /api/health handled above the token gate (M4) — keep this comment so
|
|
535
|
-
// future contributors don't re-add the route inside the auth block.
|
|
536
399
|
if (url.pathname === '/api/notify' && req.method === 'POST') {
|
|
537
400
|
return handleNotify(req, res);
|
|
538
401
|
}
|
|
@@ -569,14 +432,7 @@ export async function startWebServer(options) {
|
|
|
569
432
|
ws.close(1013, 'Server too busy');
|
|
570
433
|
return;
|
|
571
434
|
}
|
|
572
|
-
//
|
|
573
|
-
// — only when requireAuth (public bind). Localhost connections are trusted.
|
|
574
|
-
const wsUrl = new URL(req.url || '/', `http://localhost:${port}`);
|
|
575
|
-
const wsToken = wsUrl.searchParams.get('token') || '';
|
|
576
|
-
if (requireAuth && !safeEqual(wsToken, webToken) && !hasValidSession(req)) {
|
|
577
|
-
ws.close(1008, 'Unauthorized');
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
435
|
+
// No auth gate — access control is upstream (reverse proxy / firewall).
|
|
580
436
|
const clientId = `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
581
437
|
const client = { ws, id: clientId, agent: options.defaultAgent };
|
|
582
438
|
clients.set(clientId, client);
|
|
@@ -2366,8 +2222,7 @@ function serveStatic(res, filePath, contentType) {
|
|
|
2366
2222
|
res.end(content);
|
|
2367
2223
|
}
|
|
2368
2224
|
/**
|
|
2369
|
-
* Serve HTML pages with security headers.
|
|
2370
|
-
* the browser authenticates via httpOnly session cookie set at /login.
|
|
2225
|
+
* Serve HTML pages with security headers.
|
|
2371
2226
|
*/
|
|
2372
2227
|
function servePageHtml(res, filePath) {
|
|
2373
2228
|
if (!existsSync(filePath)) {
|