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.
@@ -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>im-hub-pro — Settings</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: 'im-hub-pro — Settings',
19
+ title: 'Agim — Settings',
20
20
  backToChat: 'Back to Chat',
21
- settingsTitle: 'im-hub-pro Settings',
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>im-hub-pro config wechat</code> in terminal to set up.',
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: 'im-hub-pro — 设置',
94
+ title: 'Agim — 设置',
95
95
  backToChat: '返回对话',
96
- settingsTitle: 'im-hub-pro 设置',
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>im-hub-pro config wechat</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: add auth token to all API requests (uses session cookie;
566
- // falls back to IMHUB_TOKEN header if set for backward compat).
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>im-hub-pro — Tasks</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: 'im-hub-pro — Tasks',
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: 'im-hub-pro — 任务',
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
- headers: agentHeaders, credentials: 'same-origin',
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
- headers: fHeaders, credentials: 'same-origin',
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: wHeaders, credentials: 'same-origin',
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
- // Session cookie is sent automatically. For backward compat,
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;
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/web/server.ts"],"names":[],"mappings":"AAwEA,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,CA2sB/C"}
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"}
@@ -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, writeFileSync, mkdirSync } from 'node:fs';
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 { AGIM_HOME } from '../core/agim-paths.js';
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
- // Auth-required mode is now scoped to public binds. When listening on
72
- // 127.0.0.1 / ::1 / localhost the web console is wide open — whoever has
73
- // shell on this host already owns it, and asking small users to paste a
74
- // token they don't understand was the biggest friction point in onboarding.
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
- requireAuth,
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
- requireAuth,
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
- // Login pagealways accessible
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 pagesdirect 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 — gated by header token OR session cookie ONLY when
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. EventSource has no
522
- // header API, so the token rides in `?token=<webToken>` (same shape
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
- // Verify token from URL query or session cookie before accepting connection
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. Token is no longer injected —
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)) {