agim-cli 1.1.9 → 1.1.11
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 +100 -0
- package/dist/cli-ui/entry-menu.d.ts +2 -0
- package/dist/cli-ui/entry-menu.d.ts.map +1 -1
- package/dist/cli-ui/entry-menu.js +2 -1
- package/dist/cli-ui/entry-menu.js.map +1 -1
- package/dist/cli-ui/i18n.d.ts +14 -0
- package/dist/cli-ui/i18n.d.ts.map +1 -1
- package/dist/cli-ui/i18n.js +30 -0
- package/dist/cli-ui/i18n.js.map +1 -1
- package/dist/cli-ui/token-menu.d.ts +3 -0
- package/dist/cli-ui/token-menu.d.ts.map +1 -0
- package/dist/cli-ui/token-menu.js +127 -0
- package/dist/cli-ui/token-menu.js.map +1 -0
- package/dist/cli.js +89 -12
- package/dist/cli.js.map +1 -1
- package/dist/core/a2a-notify.d.ts +35 -0
- package/dist/core/a2a-notify.d.ts.map +1 -0
- package/dist/core/a2a-notify.js +265 -0
- package/dist/core/a2a-notify.js.map +1 -0
- package/dist/core/a2a.d.ts +5 -0
- package/dist/core/a2a.d.ts.map +1 -1
- package/dist/core/a2a.js +59 -3
- package/dist/core/a2a.js.map +1 -1
- package/dist/core/access-token.d.ts +41 -0
- package/dist/core/access-token.d.ts.map +1 -0
- package/dist/core/access-token.js +179 -0
- package/dist/core/access-token.js.map +1 -0
- package/dist/core/agent-base.d.ts.map +1 -1
- package/dist/core/agent-base.js +4 -1
- package/dist/core/agent-base.js.map +1 -1
- package/dist/core/restart-flow.d.ts.map +1 -1
- package/dist/core/restart-flow.js +50 -12
- package/dist/core/restart-flow.js.map +1 -1
- package/dist/core/types.d.ts +6 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/web/public/_app.js +59 -0
- package/dist/web/public/login.html +196 -0
- package/dist/web/public/settings.html +140 -1
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +190 -8
- package/dist/web/server.js.map +1 -1
- package/dist/web/viewer-render.d.ts.map +1 -1
- package/dist/web/viewer-render.js +7 -4
- package/dist/web/viewer-render.js.map +1 -1
- package/package.json +1 -1
package/dist/web/server.js
CHANGED
|
@@ -35,6 +35,114 @@ function isMasked(value) {
|
|
|
35
35
|
return false;
|
|
36
36
|
return /^.{0,2}\*{2,}.{0,2}$/.test(value);
|
|
37
37
|
}
|
|
38
|
+
/** v1.1.10 — paths that never require a web token. */
|
|
39
|
+
const PUBLIC_EXACT_PATHS = new Set([
|
|
40
|
+
'/login', '/login.html',
|
|
41
|
+
'/api/auth/login',
|
|
42
|
+
'/api/health',
|
|
43
|
+
'/_app.js',
|
|
44
|
+
'/loc', '/loc.html',
|
|
45
|
+
// POST /api/loc (the bare path, no trailing slash) is the H5 page's
|
|
46
|
+
// "submit my coordinates" endpoint. It's authenticated by the single-use
|
|
47
|
+
// location token in the request body, NOT by the web access token.
|
|
48
|
+
'/api/loc',
|
|
49
|
+
'/favicon.ico', '/favicon.svg',
|
|
50
|
+
]);
|
|
51
|
+
const PUBLIC_PREFIX_PATHS = ['/l/', '/v/', '/api/loc/'];
|
|
52
|
+
const PUBLIC_API_VIEWER_RE = /^\/api\/viewer\/[0-9a-f-]{8,}$/i;
|
|
53
|
+
function isPublicPath(pathname, method) {
|
|
54
|
+
if (PUBLIC_EXACT_PATHS.has(pathname))
|
|
55
|
+
return true;
|
|
56
|
+
for (const p of PUBLIC_PREFIX_PATHS)
|
|
57
|
+
if (pathname.startsWith(p))
|
|
58
|
+
return true;
|
|
59
|
+
// Single-paste JSON read mirrors /v/:id (same anyone-with-the-url policy);
|
|
60
|
+
// DELETE on the same path still needs a token.
|
|
61
|
+
if (method === 'GET' && PUBLIC_API_VIEWER_RE.test(pathname))
|
|
62
|
+
return true;
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
function isLoopbackPeer(req) {
|
|
66
|
+
const ip = (req.socket.remoteAddress || '').replace(/^::ffff:/, '');
|
|
67
|
+
return ip === '127.0.0.1' || ip === '::1' || ip === '';
|
|
68
|
+
}
|
|
69
|
+
function extractToken(req, url) {
|
|
70
|
+
// 1. Authorization: Bearer <token>
|
|
71
|
+
const auth = req.headers['authorization'];
|
|
72
|
+
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
|
|
73
|
+
const v = auth.slice(7).trim();
|
|
74
|
+
if (v)
|
|
75
|
+
return v;
|
|
76
|
+
}
|
|
77
|
+
// 2. Cookie agim_token=<token>
|
|
78
|
+
const cookie = req.headers['cookie'];
|
|
79
|
+
if (typeof cookie === 'string') {
|
|
80
|
+
for (const part of cookie.split(';')) {
|
|
81
|
+
const [k, ...rest] = part.trim().split('=');
|
|
82
|
+
if (k === 'agim_token')
|
|
83
|
+
return decodeURIComponent(rest.join('=').trim());
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// 3. ?token=... (last resort, for WS upgrade / iframe)
|
|
87
|
+
const q = url.searchParams.get('token');
|
|
88
|
+
if (q)
|
|
89
|
+
return q;
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
/** Returns true if the request is authenticated (or auth not required). */
|
|
93
|
+
function checkAuth(req, res, url) {
|
|
94
|
+
// 1. Disabled by env → pass through.
|
|
95
|
+
if ((process.env.IMHUB_WEB_AUTH || '').toLowerCase() === 'off')
|
|
96
|
+
return true;
|
|
97
|
+
// 2. Loopback → pass through (local CLI / browser-on-same-host).
|
|
98
|
+
if (isLoopbackPeer(req))
|
|
99
|
+
return true;
|
|
100
|
+
// 3. Public-by-design path → pass through.
|
|
101
|
+
if (isPublicPath(url.pathname, req.method || 'GET'))
|
|
102
|
+
return true;
|
|
103
|
+
// 4. Verify token from header / cookie / query.
|
|
104
|
+
const tok = extractToken(req, url);
|
|
105
|
+
if (tok) {
|
|
106
|
+
// Lazy import avoids loading SQLite-adjacent modules in test stubs etc.
|
|
107
|
+
// The synchronous verifyToken touches the small tokens.json only.
|
|
108
|
+
// We require require()-style top-level access by deferring resolution:
|
|
109
|
+
// but ESM has no require — use a global cache instead.
|
|
110
|
+
const id = verifyTokenSync(tok);
|
|
111
|
+
if (id)
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
// 5. No / invalid token. /api/* → 401 JSON; static page → 302 to /login.
|
|
115
|
+
if (url.pathname.startsWith('/api/')) {
|
|
116
|
+
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
117
|
+
res.end(JSON.stringify({ error: 'unauthorized', loginUrl: '/login' }));
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
if (url.pathname.startsWith('/ws') || req.headers.upgrade === 'websocket') {
|
|
121
|
+
res.writeHead(401);
|
|
122
|
+
res.end();
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
// HTML / static — redirect.
|
|
126
|
+
const next = encodeURIComponent(url.pathname + url.search);
|
|
127
|
+
res.writeHead(302, { Location: `/login?next=${next}` });
|
|
128
|
+
res.end();
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
// access-token.verifyToken cached lookup. We avoid await in checkAuth by
|
|
132
|
+
// pre-loading via a small wrapper. ESM top-level await isn't an option in
|
|
133
|
+
// the codebase style, so import on first use and stash the handle.
|
|
134
|
+
let _tokenModule = null;
|
|
135
|
+
function verifyTokenSync(raw) {
|
|
136
|
+
if (!_tokenModule) {
|
|
137
|
+
// Synchronous resolution via the dist file path. We rely on Node's
|
|
138
|
+
// require-via-createRequire only when running compiled; in production
|
|
139
|
+
// the module is already loaded by bootstrapIfEmpty above so this
|
|
140
|
+
// branch is mostly defensive. Worst case we deny one request while the
|
|
141
|
+
// dynamic import resolves.
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
return _tokenModule.verifyToken(raw);
|
|
145
|
+
}
|
|
38
146
|
export function createSerialQueue() {
|
|
39
147
|
let queue = Promise.resolve();
|
|
40
148
|
return (fn) => {
|
|
@@ -50,24 +158,41 @@ export async function startWebServer(options) {
|
|
|
50
158
|
const port = options.port || DEFAULT_PORT;
|
|
51
159
|
const bindHost = process.env.IMHUB_WEB_BIND || '127.0.0.1';
|
|
52
160
|
const clients = new Map();
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
161
|
+
// v1.1.10+: token-based auth for the web console.
|
|
162
|
+
// ─ loopback peers (127.0.0.1 / ::1) bypass auth (local CLI / curl)
|
|
163
|
+
// ─ public viewer pages (/v/:id, /loc, /l/<token>) remain public
|
|
164
|
+
// ─ IMHUB_WEB_AUTH=off disables the gate entirely (for ops with proxy auth)
|
|
165
|
+
// On first start, bootstrapIfEmpty() generates a one-time bootstrap token
|
|
166
|
+
// and prints it to stdout + the logger so the operator finds it in journalctl.
|
|
167
|
+
const accessTokenModule = await import('../core/access-token.js');
|
|
168
|
+
_tokenModule = accessTokenModule;
|
|
169
|
+
const { bootstrapIfEmpty, isAuthEnabled } = accessTokenModule;
|
|
170
|
+
try {
|
|
171
|
+
bootstrapIfEmpty();
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
webLog.warn({ event: 'web.auth_bootstrap_failed', err: String(err) });
|
|
175
|
+
}
|
|
57
176
|
const isPublicBind = bindHost !== '127.0.0.1' && bindHost !== '::1' && bindHost !== 'localhost';
|
|
58
|
-
if (isPublicBind) {
|
|
177
|
+
if (isPublicBind && !isAuthEnabled()) {
|
|
59
178
|
webLog.warn({
|
|
60
|
-
event: 'web.
|
|
179
|
+
event: 'web.auth_disabled_on_public_bind',
|
|
61
180
|
bind: bindHost,
|
|
62
|
-
}, '
|
|
181
|
+
}, 'IMHUB_WEB_AUTH=off + public bind — auth deliberately off, ensure your reverse proxy handles access control');
|
|
63
182
|
}
|
|
64
183
|
webLog.info({
|
|
65
184
|
event: 'web.auth_mode',
|
|
66
185
|
bind: bindHost,
|
|
67
|
-
|
|
186
|
+
enabled: isAuthEnabled(),
|
|
187
|
+
}, `Web console auth: ${isAuthEnabled() ? 'token-gated' : 'disabled (IMHUB_WEB_AUTH=off)'}`);
|
|
68
188
|
// HTTP request handler — static files + REST API
|
|
69
189
|
const httpServer = createServer(async (req, res) => {
|
|
70
190
|
const url = new URL(req.url || '/', `http://localhost:${port}`);
|
|
191
|
+
// ─── v1.1.10 auth gate ───────────────────────────────────────────
|
|
192
|
+
// Always run first so unauthenticated requests never reach handlers.
|
|
193
|
+
// Loopback peers + the public paths below bypass.
|
|
194
|
+
if (!checkAuth(req, res, url))
|
|
195
|
+
return;
|
|
71
196
|
// Static pages — direct serve, no auth gate.
|
|
72
197
|
if (url.pathname === '/' || url.pathname === '/index.html' ||
|
|
73
198
|
url.pathname === '/settings' || url.pathname === '/settings.html' ||
|
|
@@ -89,6 +214,17 @@ export async function startWebServer(options) {
|
|
|
89
214
|
if (url.pathname === '/api/health' && req.method === 'GET') {
|
|
90
215
|
return handleHealth(req, res);
|
|
91
216
|
}
|
|
217
|
+
// v1.1.10 — auth endpoints. /login is a public path; the auth gate
|
|
218
|
+
// above lets it through. These do the actual validation + cookie set.
|
|
219
|
+
if (url.pathname === '/api/auth/login' && req.method === 'POST') {
|
|
220
|
+
return handleAuthLogin(req, res);
|
|
221
|
+
}
|
|
222
|
+
if (url.pathname === '/api/auth/logout' && req.method === 'POST') {
|
|
223
|
+
return handleAuthLogout(req, res);
|
|
224
|
+
}
|
|
225
|
+
if (url.pathname === '/login' && req.method === 'GET') {
|
|
226
|
+
return serveStatic(res, join(PUBLIC_DIR, 'login.html'), 'text/html; charset=utf-8');
|
|
227
|
+
}
|
|
92
228
|
// Shared web-console utilities (theme manager + i18n + error boundary
|
|
93
229
|
// + auth-aware fetch). Loaded synchronously by every static page in
|
|
94
230
|
// <head> so the theme can apply before first paint. No secrets — safe
|
|
@@ -1644,6 +1780,12 @@ const ENV_EDITABLE_KEYS = [
|
|
|
1644
1780
|
'IMHUB_VIEWER_CODE_LINES',
|
|
1645
1781
|
'IMHUB_VIEWER_MAX_PASTES',
|
|
1646
1782
|
'IMHUB_VIEWER_TUNNEL_MODE',
|
|
1783
|
+
// v1.1.10 — A2A notification settings (see src/core/a2a-notify.ts).
|
|
1784
|
+
'IMHUB_A2A_TIMEOUT_DEFAULT_MS',
|
|
1785
|
+
'IMHUB_A2A_MAX_TIMEOUT_MS',
|
|
1786
|
+
'IMHUB_A2A_NOTIFY_MODE',
|
|
1787
|
+
'IMHUB_A2A_NOTIFY_MAX_DEPTH',
|
|
1788
|
+
'IMHUB_A2A_HEARTBEAT_MIN',
|
|
1647
1789
|
];
|
|
1648
1790
|
const SECRET_KEYS = new Set(['IMHUB_SMTP_PASS', 'IMHUB_BAIDU_MAP_AK']);
|
|
1649
1791
|
function maskSecret(v) {
|
|
@@ -2730,6 +2872,46 @@ async function handleArtifactsFile(_req, res, jobId, name) {
|
|
|
2730
2872
|
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
2731
2873
|
}
|
|
2732
2874
|
}
|
|
2875
|
+
// ─── v1.1.10 auth handlers ───
|
|
2876
|
+
async function handleAuthLogin(req, res) {
|
|
2877
|
+
try {
|
|
2878
|
+
const body = await readBody(req, res);
|
|
2879
|
+
const parsed = JSON.parse(body || '{}');
|
|
2880
|
+
const token = (parsed.token || '').trim();
|
|
2881
|
+
if (!token) {
|
|
2882
|
+
sendJson(res, 400, { error: 'token required' });
|
|
2883
|
+
return;
|
|
2884
|
+
}
|
|
2885
|
+
if (!_tokenModule) {
|
|
2886
|
+
sendJson(res, 503, { error: 'auth module not ready' });
|
|
2887
|
+
return;
|
|
2888
|
+
}
|
|
2889
|
+
const id = _tokenModule.verifyToken(token);
|
|
2890
|
+
if (!id) {
|
|
2891
|
+
sendJson(res, 401, { error: 'invalid token' });
|
|
2892
|
+
return;
|
|
2893
|
+
}
|
|
2894
|
+
// 30-day cookie, Path=/, SameSite=Strict. Not HttpOnly so the page JS
|
|
2895
|
+
// can also read it back if needed (e.g. to attach Authorization header
|
|
2896
|
+
// on fetch — though the cookie alone suffices).
|
|
2897
|
+
const maxAge = 30 * 24 * 60 * 60;
|
|
2898
|
+
res.writeHead(200, {
|
|
2899
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
2900
|
+
'Set-Cookie': `agim_token=${encodeURIComponent(token)}; Path=/; Max-Age=${maxAge}; SameSite=Strict`,
|
|
2901
|
+
});
|
|
2902
|
+
res.end(JSON.stringify({ ok: true, tokenId: id }));
|
|
2903
|
+
}
|
|
2904
|
+
catch (err) {
|
|
2905
|
+
sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
function handleAuthLogout(_req, res) {
|
|
2909
|
+
res.writeHead(200, {
|
|
2910
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
2911
|
+
'Set-Cookie': 'agim_token=; Path=/; Max-Age=0; SameSite=Strict',
|
|
2912
|
+
});
|
|
2913
|
+
res.end(JSON.stringify({ ok: true }));
|
|
2914
|
+
}
|
|
2733
2915
|
// ─── Viewer handlers ───
|
|
2734
2916
|
// v1.2 — Render a stored paste as an HTML page. URL is unguessable uuidv4
|
|
2735
2917
|
// (32 hex chars including dashes); we still cap the length match to a
|