agim-cli 1.1.8 → 1.1.10

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/dist/cli-ui/entry-menu.d.ts +2 -0
  3. package/dist/cli-ui/entry-menu.d.ts.map +1 -1
  4. package/dist/cli-ui/entry-menu.js +2 -1
  5. package/dist/cli-ui/entry-menu.js.map +1 -1
  6. package/dist/cli-ui/i18n.d.ts +14 -0
  7. package/dist/cli-ui/i18n.d.ts.map +1 -1
  8. package/dist/cli-ui/i18n.js +30 -0
  9. package/dist/cli-ui/i18n.js.map +1 -1
  10. package/dist/cli-ui/token-menu.d.ts +3 -0
  11. package/dist/cli-ui/token-menu.d.ts.map +1 -0
  12. package/dist/cli-ui/token-menu.js +127 -0
  13. package/dist/cli-ui/token-menu.js.map +1 -0
  14. package/dist/cli.js +90 -11
  15. package/dist/cli.js.map +1 -1
  16. package/dist/core/a2a-notify.d.ts +35 -0
  17. package/dist/core/a2a-notify.d.ts.map +1 -0
  18. package/dist/core/a2a-notify.js +265 -0
  19. package/dist/core/a2a-notify.js.map +1 -0
  20. package/dist/core/a2a.d.ts +5 -0
  21. package/dist/core/a2a.d.ts.map +1 -1
  22. package/dist/core/a2a.js +59 -3
  23. package/dist/core/a2a.js.map +1 -1
  24. package/dist/core/access-token.d.ts +41 -0
  25. package/dist/core/access-token.d.ts.map +1 -0
  26. package/dist/core/access-token.js +179 -0
  27. package/dist/core/access-token.js.map +1 -0
  28. package/dist/core/agent-base.d.ts.map +1 -1
  29. package/dist/core/agent-base.js +4 -1
  30. package/dist/core/agent-base.js.map +1 -1
  31. package/dist/core/message-sink.d.ts +14 -0
  32. package/dist/core/message-sink.d.ts.map +1 -1
  33. package/dist/core/message-sink.js +7 -5
  34. package/dist/core/message-sink.js.map +1 -1
  35. package/dist/core/restart-flow.d.ts.map +1 -1
  36. package/dist/core/restart-flow.js +50 -12
  37. package/dist/core/restart-flow.js.map +1 -1
  38. package/dist/core/types.d.ts +6 -0
  39. package/dist/core/types.d.ts.map +1 -1
  40. package/dist/web/public/_app.js +59 -0
  41. package/dist/web/public/login.html +196 -0
  42. package/dist/web/public/settings.html +140 -1
  43. package/dist/web/server.d.ts.map +1 -1
  44. package/dist/web/server.js +190 -8
  45. package/dist/web/server.js.map +1 -1
  46. package/package.json +1 -1
@@ -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
- // Agim's web console intentionally ships with NO built-in auth — neither
54
- // for localhost nor for public binds. Operators putting it on 0.0.0.0 are
55
- // responsible for fronting it with a reverse proxy / firewall / SSH tunnel
56
- // / VPN of their choice. See README "Web console exposure".
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.public_bind_warning',
179
+ event: 'web.auth_disabled_on_public_bind',
61
180
  bind: bindHost,
62
- }, 'Web server bound to a non-localhost address with NO built-in auth front it with a reverse proxy / firewall / VPN');
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
- }, 'Web console: open access (no built-in auth — access control is upstream)');
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